Compare commits

...
Author SHA1 Message Date
sorokin.oleg.edu bed7f825fd docs: publish State multiselect verification and native evidence 2026-09-23 04:41:42 -07:00
sorokin.oleg.edu 3480b54201 Box the optional exact State menu target to bound message size 2026-09-23 03:37:02 -07:00
sorokin.oleg.edu 2a3b9ba822 Add exact State multiselection, atomic movement and placement Hide
Preserve the desktop-2 captured source (1286 passed, 6 existing ignored). Three menu payload blobs are staged from that verified capture without replacing working files; the strict-lint size correction follows in a separate commit. Native acceptance remains pending.
2026-09-23 03:37:01 -07:00
sorokin.oleg.edu 7e348d967d docs: publish saved-view group selection verification and native evidence 2026-09-23 02:19:57 -07:00
sorokin.oleg.edu f6a6010bae fix: keep saved diagram viewport stable across group selection 2026-09-23 01:53:11 -07:00
sorokin.oleg.edu 12a7352dde feat: add exact saved-view multiselection and atomic group movement 2026-09-23 01:11:52 -07:00
Codex-ytr83r b8685416f5 Preserve read-only panning while canceling State edits 2026-09-22 21:21:50 -07:00
Codex-ytr83r 26979c1093 Enable direct and menu bend editing in State diagrams 2026-09-22 21:01:35 -07:00
Codex-ytr83r 53d01a3d44 Cancel menu-armed State attachment tool on Escape 2026-09-22 19:26:28 -07:00
Codex-ytr83r 3aa80de89c Fix attachment overshoot and semantic trace port geometry 2026-09-22 18:13:07 -07:00
Codex-ytr83r 79cc9d5551 Support per-view connection boundary attachments in ordinary and state diagrams 2026-09-22 17:51:13 -07:00
171 changed files with 26624 additions and 128 deletions
+12 -12
View File
@@ -4,12 +4,12 @@
- Follow `PROJECT_GOAL.md` and the user's current instructions. Work autonomously within scope. Resolve discoverable questions yourself; record, test, and revise reversible assumptions. Ask only when a consequential decision needs information or authorization not already available. Continue independent useful work while blocked elsewhere.
- Map unfamiliar domains through primary sources, comparable systems, and real user workflows. Identify underlying needs, valuable concepts, and avoidable friction; evaluate improvements rather than merely reproducing features. Synthesize findings into decisions or experiments, and stop research when another action offers greater value.
- Make specifications precise enough for compatible independent implementations: definitions, relationships, invariants, valid/invalid examples, and lifecycle or transformation rules where relevant. Trace important requirements and decisions to implementation and verification. Resolve specification ambiguity at its source when implementation exposes it.
- Make specifications precise enough for compatible independent implementations: definitions, relationships, invariants, valid / invalid examples, and lifecycle or transformation rules where relevant. Trace important requirements and decisions to implementation and verification. Resolve specification ambiguity at its source when implementation exposes it.
- For consequential uncertain choices, compare materially different candidates against explicit user-derived criteria. Ask independent critics to challenge assumptions and attempt to falsify proposals; refine or replace candidates as evidence warrants. Record the choice, evidence, tradeoffs, rejected approaches, and reasons to revisit it. Use proportionate effort for reversible local choices.
- Plan milestones that resolve uncertainty or deliver useful capability, with explicit dependencies. Test foundational assumptions before substantial downstream work. At checkpoints, assess progress toward the actual objective and replan when evidence invalidates the strategy or progress stalls.
- Define observable success and suitable evaluators before repeated improvement: invariants, examples, round trips, transformations, performance checks, realistic user tasks, or explicit review rubrics. Ensure the evaluator measures the intended outcome. For experiments, record the question, expectations, method, result, and implications; isolate exploratory prototypes from production architecture.
- Build and run meaningful changes, exercise actual behavior, and inspect useful logs. Investigate crashes, serious warnings, corrupted state, and interaction defects. For GUI work, use Computer Use or equivalent tools to inspect realistic workflows, cognitive load, editing/selection, navigation, feedback, recovery, loading/empty/failure states, accessibility, layout, focus/disabled states, and responsiveness.
- Match independent reviews to the artifact and risks. For languages or formats, include ambiguity, round trips, human/AI editability, and version-control behavior where relevant. Verify findings, fix the appropriate conceptual, specification, architectural, implementation, or interaction layer, and recheck affected dependents. Reuse valid evidence; repeat checks for concrete risks, changes, findings, or required gates. Compilation, screenshots, and worker reports alone do not establish correctness or usability.
- Build and run meaningful changes, exercise actual behavior, and inspect useful logs. Investigate crashes, serious warnings, corrupted state, and interaction defects. For GUI work, use Computer Use or equivalent tools to inspect realistic workflows, cognitive load, editing / selection, navigation, feedback, recovery, loading / empty / failure states, accessibility, layout, focus / disabled states, and responsiveness.
- Match independent reviews to the artifact and risks. For languages or formats, include ambiguity, round trips, human / AI editability, and version-control behavior where relevant. Verify findings, fix the appropriate conceptual, specification, architectural, implementation, or interaction layer, and recheck affected dependents. Reuse valid evidence; repeat checks for concrete risks, changes, findings, or required gates. Compilation, screenshots, and worker reports alone do not establish correctness or usability.
- Favor the simplest maintainable design satisfying known requirements and justified extensibility. Avoid speculative abstraction, unrelated refactoring, and repeating approaches already invalidated by evidence.
## User input
@@ -24,10 +24,10 @@ Keep one authoritative home for each kind of information. Use links and brief sc
| Information | Authoritative home |
| --- | --- |
| Live plans, priorities, dependencies, ownership, status, blockers, and handoffs | Gitea issues/milestones; linked PRs hold change review and validation. |
| Specifications, architecture, interfaces, and build/run documentation | Established version-controlled project documents. |
| Live plans, priorities, dependencies, ownership, status, blockers, and handoffs | Gitea issues / milestones; linked PRs hold change review and validation. |
| Specifications, architecture, interfaces, and build / run documentation | Established version-controlled project documents. |
| Durable decisions and reusable evidence | `harness/DECISIONS.md` and relevant topic notes, or existing equivalents. |
| Navigation and integrated-state checkpoint | `harness/STATE.md`: links, verified code commit/release, capabilities, and material limitations. No live task ledger. |
| Navigation and integrated-state checkpoint | `harness/STATE.md`: links, verified code commit / release, capabilities, and material limitations. No live task ledger. |
| Shared entrypoint | Gitea Wiki: overview and links, without duplicating versioned documentation. |
| Published deliverables | Gitea releases, tied to source commits and verification. |
@@ -38,9 +38,9 @@ Use Gitea for live coordination and the relevant code version for implementation
All independent agents coordinating through Gitea run on completely separate machines, each with its own clone, filesystem, tools, processes, and runtime. Gitea and pushed Git history are the shared communication and delivery channels. Local files (including `harness`), unpushed commits, worktrees, test results, and localhost services are not accessible to peers. Matching branch names or paths do not imply synchronized contents.
1. On starting or resuming, including after compaction, inspect the working branch, local changes, applicable instructions, relevant shared records, and existing implementation. Recover context from authoritative records. Check for newer shared instructions on the actual remote default branch without overwriting local work. Read what is relevant; avoid repeated full-history scans and constant polling.
2. Search before creating tasks. Claim a meaningful outcome in its Gitea issue with a stable session identity, scope, remote repository/branch, and acceptance criteria. Keep small steps within that task. Recheck after claiming; comments are not atomic locks. Respect existing ownership and resolve overlapping claims before overlapping work.
3. Independent sessions are peers. Each owns its contribution and delegated work on its own machine. Record one integration/release owner when a milestone needs shared coordination. Use distinct remote work branches, partition files/interfaces, and coordinate changes to shared specifications and documents. Worktrees provide local isolation only; they do not share work between machines.
4. At meaningful checkpoints or handoffs, record changed results, commit/PR links, verification commands and environment, blockers, and next actions. Identify the tested code version and label uncommitted or unpublished work as local-only. Before asking peers to consume results, push the required code and versioned documentation, including relevant `harness` updates; publish other required artifacts in Gitea and link them. Use repository-relative paths with commit references, reproducible commands, and necessary environment details; local paths and localhost URLs alone are not handoffs. Do not assume another agent is running or has read a message; silence alone does not establish abandonment. Record evidence and preserve existing work when reassigning a task.
2. Search before creating tasks. Claim a meaningful outcome in its Gitea issue with a stable session identity, scope, remote repository / branch, and acceptance criteria. Keep small steps within that task. Recheck after claiming; comments are not atomic locks. Respect existing ownership and resolve overlapping claims before overlapping work.
3. Independent sessions are peers. Each owns its contribution and delegated work on its own machine. Record one integration / release owner when a milestone needs shared coordination. Use distinct remote work branches, partition files / interfaces, and coordinate changes to shared specifications and documents. Worktrees provide local isolation only; they do not share work between machines.
4. At meaningful checkpoints or handoffs, record changed results, commit / PR links, verification commands and environment, blockers, and next actions. Identify the tested code version and label uncommitted or unpublished work as local-only. Before asking peers to consume results, push the required code and versioned documentation, including relevant `harness` updates; publish other required artifacts in Gitea and link them. Use repository-relative paths with commit references, reproducible commands, and necessary environment details; local paths and localhost URLs alone are not handoffs. Do not assume another agent is running or has read a message; silence alone does not establish abandonment. Record evidence and preserve existing work when reassigning a task.
5. Follow project review and merge conventions. Fetch the relevant remote branches and inspect the identified commits before reviewing or integrating another machine's work. Resolve conflicts while preserving others' edits, and verify the combined result locally; report material environment differences. Local instruction-file edits reach peers only after publication and retrieval. Do not overwrite others' work or force-push shared history to resolve coordination problems.
6. Close work when its acceptance criteria are met and the result is integrated or delivered as appropriate. Mark duplicates and superseded tasks explicitly with replacement links. Then choose further useful unowned work while the requested objective remains incomplete.
@@ -64,10 +64,10 @@ Preserve concise records of decisions and rationale, useful experiments, failed
Consolidate duplicates, repair stale guidance, and remove obsolete temporary files alongside useful project work. Before removing material, check references, active ownership, and ongoing use; preserve unique knowledge and update affected links. Prefer closing, superseding, or archiving history over deletion. User input is excluded from autonomous cleanup.
If legacy local task ledgers or plans duplicate Gitea, migrate their live content once into existing or appropriately scoped issues, preserve useful decisions/evidence, and mark the old records superseded. Record the migration so later sessions do not repeat it. Do not create empty documents or maintain records for ceremony.
If legacy local task ledgers or plans duplicate Gitea, migrate their live content once into existing or appropriately scoped issues, preserve useful decisions / evidence, and mark the old records superseded. Record the migration so later sessions do not repeat it. Do not create empty documents or maintain records for ceremony.
## Delivery
Publish versioned Gitea releases at meaningful working milestones; mark unfinished versions as prereleases. Check existing releases/tags and coordinate one owner and version before publishing. Build from an identified integrated commit and verify the packaged result and launch steps.
Publish versioned Gitea releases at meaningful working milestones; mark unfinished versions as prereleases. Check existing releases / tags and coordinate one owner and version before publishing. Build from an identified integrated commit and verify the packaged result and launch steps.
Include runnable builds or appropriate artifacts, concise release notes, build/run/deployment instructions as applicable, known limitations, tested platforms, and verification evidence. Confirm publication and artifact availability. Use existing authorized API access or project tooling when MCP lacks a needed release/upload operation. Do not silently replace published versions with different contents.
Include runnable builds or appropriate artifacts, concise release notes, build / run / deployment instructions as applicable, known limitations, tested platforms, and verification evidence. Confirm publication and artifact availability. Use existing authorized API access or project tooling when MCP lacks a needed release / upload operation. Do not silently replace published versions with different contents.
+3 -1
View File
@@ -94,11 +94,13 @@ Create named **Exchange categories** at the perspective root to group Work excha
In **Select / move**, drag cards on a 10-unit grid, drag empty space to pan, and scroll to zoom. Drag ports along their owner's border or onto any of its four sides. Automatic sides account for direction and visible connections. Manual positions are per diagram and remain relative to the owner through movement or resize; right-click **Reset automatic position** restores automatic placement. **Arrange** changes the current view layout; **Fit** frames its projected bounds. SVG exports the current architecture diagram as a view, not an editable model file.
In saved architecture and State views, **Shift+click** adds or removes an exact displayed item; its context menu also offers **Add to selection** and **Remove from selection**. Properties edits only the primary item. Drag a selected member to move a placement-only group in one operation, including visual descendants once and the authored pins of connections whose endpoint owners move together. A stationary click selects only that member; Escape during a drag cancels it and keeps the group. **Hide selected** removes only selected placements, preserving model elements, unselected descendant placements and other views; one Undo restores the operation. Groups containing connections, ports or State action/internal-transition rows cannot be moved or hidden as placement groups. State rows select their relation, never their owning placement. Dragging a group of allocated work cards and deleting a group from the model remain unavailable; these actions refuse the whole group instead of editing just its primary item. Selection itself is temporary and is not saved.
Connections start with orthogonal routes that avoid cards and container headers. In Select mode, select a connection and drag a segment to add a bend, or right-click for **Add bend here**. Drag its circles to adjust the route; the menu offers **Remove bend** and **Reset routing**. With the canvas focused, Insert adds a bend and Delete removes the selected bend. Shift+F10 opens the selected item's menu; **Actions** in Properties provides the same commands. Drag the connection label independently. Each completed gesture is one undoable edit; Escape cancels a drag. Routes belong to the current diagram and persist in source, JSON, REST and SVG export. Folding an endpoint container temporarily uses an automatic route; expanding it restores your authored route. Dense diagrams can still need manual adjustment; automatic routing is bounded and does not guarantee an overlap-free result.
For state diagrams, use **+ Region** inside a highlighted machine or State/Mode, then choose **+ State / symbol** and click its owning region. **Transition** supports dragging between compatible vertices or clicking the source and then the target. Select a drawn transition and drag its endpoint handle, or right-click **Reconnect source/target**. Right-click a state or the background for **New transition with details…** to create Local, Internal or timed transitions; an existing transition's **Edit transition** form edits its kind, triggers, guard and effects. EntryPoint outgoing transitions use Local.
In the state view, **Select / move** drags vertices and containers without reparenting; container movement includes its descendants. Middle-button drag pans, scroll or **−/+** zooms, and **Fit** frames the view. Right-click for **Fold contents**, **Unfold contents**, **Placement properties**, or **Hide from this diagram**. The **Show an element not yet in this view…** selector restores missing placements. Escape cancels a gesture; **Undo** restores a completed edit. **+ View** creates an independent layout. The persisted diagram's machine reference and placements are the same records in source, JSON and REST; opening the page alone creates no diagram.
In the state view, **Select / move** drags vertices and containers without reparenting or grid snapping; container movement includes its descendants. Multiple selected placements share the same fractional displacement, including authored positions of boundary symbols. State route bends retain their separate 5-unit grid. Middle-button drag pans, scroll or **−/+** zooms, and **Fit** frames the view. Right-click for **Fold contents**, **Unfold contents**, **Placement properties**, or **Hide from this diagram**. The **Show an element not yet in this view…** selector restores missing placements. Escape cancels a gesture; **Undo** restores a completed edit. **+ View** creates an independent layout. The persisted diagram's machine reference and placements are the same records in source, JSON and REST; opening the page alone creates no diagram.
## Edit the same model as text
@@ -0,0 +1,27 @@
//! Read and validate a saved acceptance fixture without rewriting its source.
//! cargo run --locked -p oa-core --example attachment_snapshot -- input.arcadia output.json
use oa_core::{persistence, validate_candidate};
use std::{fs::OpenOptions, io::Write, path::Path};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let args: Vec<_> = std::env::args_os().skip(1).collect();
if args.len() != 2 {
return Err("Expected input source/JSON and a new output JSON path".into());
}
let project = persistence::load(Path::new(&args[0]))?;
validate_candidate(&project)?;
let bytes = serde_json::to_vec_pretty(&project)?;
let mut output = OpenOptions::new()
.write(true)
.create_new(true)
.open(&args[1])?;
output.write_all(&bytes)?;
output.write_all(b"\n")?;
output.sync_all()?;
println!(
"Validated {} objects and {} views; saved a complete Project snapshot",
project.identities().len(),
project.diagrams.len()
);
Ok(())
}
@@ -0,0 +1,716 @@
//! Native evidence fixture/checker; install as a temporary oa-core example.
//! This invariant evaluator derives delta from saved A, NOT from pointer input.
use oa_core::{source_project::*, *};
use std::{fs, io::Write, path::Path};
type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
fn id(alias: &str) -> Id {
// Make the intended OA view first for default UUID-ordered view selection.
if alias == "oa_view" {
return Id::from_u128(0x29_0001);
}
Id::new_v5(
&Id::from_u128(0xc1192026_0923_5000_8000_000000000029),
alias.as_bytes(),
)
}
fn point(x: f32, y: f32) -> Position {
Position { x, y }
}
fn element(p: &mut Project, alias: &str, name: &str, kind: Kind, layer: Layer, parent: Option<Id>) {
p.elements.insert(
id(alias),
Element {
id: id(alias),
alias: alias.into(),
name: name.into(),
description: format!("Preserve engineering record {alias}."),
parent,
body: ElementBody::new(kind, layer).unwrap(),
},
);
}
fn diagram(p: &mut Project, alias: &str, name: &str, layer: Layer) {
p.diagrams.insert(
id(alias),
Diagram {
id: id(alias),
alias: alias.into(),
name: name.into(),
description: "Selection changes placement geometry only.".into(),
layer,
projection: ProjectionKind::Overview,
view_options: Default::default(),
nodes: Default::default(),
routes: vec![],
},
);
}
fn place(p: &mut Project, view: &str, alias: &str, object: &str, position: Position, size: Size) {
p.diagrams.get_mut(&id(view)).unwrap().nodes.insert(
id(alias),
NodePlacement {
id: id(alias),
alias: alias.into(),
element: id(object),
position,
size,
collapsed: false,
port_anchors: vec![],
},
);
}
fn route(edge: &str, pins: Vec<Position>) -> EdgeRoute {
EdgeRoute {
edge: id(edge),
waypoints: pins,
source_attachment: Some(EdgeAttachment {
side: PortAnchorSide::Right,
offset: 0.25,
}),
target_attachment: Some(EdgeAttachment {
side: PortAnchorSide::Left,
offset: 0.7,
}),
label: Some(EdgeLabel {
fraction: 0.45,
offset: point(7., -19.),
}),
}
}
fn fixture() -> Result<Project> {
let mut p = Project::empty();
p.id = id("project");
p.alias = "multiselect_native".into();
p.name = "Multiselect native".into();
p.description =
"Shift-select A and B; move together, retain C and engineering semantics.".into();
for (alias, name, parent) in [
("activity_a", "A - receive request", None),
("activity_b", "B - assess request", None),
("activity_c", "C - dispatch response", None),
(
"activity_child",
"A.1 - classify request",
Some(id("activity_a")),
),
] {
element(&mut p, alias, name, Kind::Activity, Layer::Oa, parent);
}
element(
&mut p,
"sa_function",
"Technical request processing",
Kind::Function,
Layer::Sa,
None,
);
for (alias, name, source, target) in [
("internal", "Request assessment", "activity_a", "activity_b"),
("external", "Response decision", "activity_b", "activity_c"),
] {
let old = p.relate(RelationKind::Interaction, name, id(source), id(target));
reidentify(&mut p, old, id(alias))?;
p.relations.get_mut(&id(alias)).unwrap().alias = alias.into();
}
diagram(
&mut p,
"oa_view",
"Multiselect native - OA overview",
Layer::Oa,
);
diagram(&mut p, "oa_other", "Other OA view - preserve", Layer::Oa);
diagram(&mut p, "sa_view", "SA engineering - preserve", Layer::Sa);
let size = Size {
width: 224.,
height: 94.,
};
place(
&mut p,
"oa_view",
"node_a",
"activity_a",
point(80., 80.),
Size {
width: 310.,
height: 260.,
},
);
place(
&mut p,
"oa_view",
"node_b",
"activity_b",
point(470., 120.),
size,
);
place(
&mut p,
"oa_view",
"node_c",
"activity_c",
point(780., 270.),
size,
);
place(
&mut p,
"oa_view",
"node_child",
"activity_child",
point(120., 210.),
size,
);
place(
&mut p,
"oa_other",
"other_a",
"activity_a",
point(40., 60.),
size,
);
place(
&mut p,
"sa_view",
"sa_node",
"sa_function",
point(100., 110.),
size,
);
p.diagrams.get_mut(&id("oa_view")).unwrap().routes = vec![
route("internal", vec![point(420., 145.), point(420., 186.)]),
route("external", vec![point(725., 150.), point(725., 336.)]),
];
p.traces.insert(
id("trace"),
Trace {
id: id("trace"),
alias: "preserved_refinement".into(),
name: "Reviewed request refinement".into(),
description: "Group geometry and Hide preserve this trace and both endpoints.".into(),
oa: id("activity_a"),
sa: id("sa_function"),
kind: TraceKind::Refinement,
baseline: Some(TraceBaseline {
oa: p.properties(id("activity_a")).unwrap(),
sa: p.properties(id("sa_function")).unwrap(),
semantic: None,
}),
},
);
validate_candidate(&p)?;
Ok(p)
}
fn load(path: &Path) -> Result<Project> {
if path
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("arcadia"))
{
let disk = source_files::load_project(path, ProjectLimits::default())?;
// This deliberately partial engineering fixture has completeness
// warnings (allocation/behavior context). They are not source errors.
// Compilation, candidate validation and whole-Project equality still
// reject malformed source or any unexpected model change.
if disk
.diagnostics
.iter()
.any(|d| d.diagnostic.severity == validation::Severity::Error)
{
return Err(format!("Source discovery diagnostics: {:?}", disk.diagnostics).into());
}
let compiled = compile_bundle(&disk.reachable_bundle(), ProjectLimits::default())
.map_err(|e| format!("Source compile diagnostics: {e:?}"))?;
Ok(compiled.decoded.project)
} else {
Ok(persistence::load(path)?)
}
}
fn equal(expected: &Project, actual: &Project) -> Result<()> {
if expected == actual {
return Ok(());
}
let e = serde_json::to_value(expected)?;
let a = serde_json::to_value(actual)?;
let fields: Vec<_> = e
.as_object()
.unwrap()
.keys()
.filter(|key| e[*key] != a[*key])
.cloned()
.collect();
Err(format!("Whole Project mismatch in {fields:?}; no partial PASS").into())
}
fn roundtrip(p: &Project) -> Result<SourceBundle> {
validate_candidate(p)?;
equal(p, &persistence::decode(&serde_json::to_vec(p)?)?)?;
let bundle = format_bundle(p);
let compiled = compile_bundle(&bundle, ProjectLimits::default())
.map_err(|e| format!("Roundtrip: {e:?}"))?;
equal(p, &compiled.decoded.project)?;
Ok(bundle)
}
fn write_new(path: &Path, bytes: &[u8]) -> Result<()> {
fs::create_dir_all(path.parent().ok_or("Missing parent directory")?)?;
fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(path)?
.write_all(bytes)?;
Ok(())
}
fn generate(out: &Path) -> Result<()> {
if out.exists() {
return Err("Output directory already exists; choose a fresh path".into());
}
let p = fixture()?;
let bundle = roundtrip(&p)?;
write_new(&out.join("baseline.json"), &serde_json::to_vec_pretty(&p)?)?;
for (path, source) in &bundle.files {
write_new(&out.join("source").join(path), source.as_bytes())?;
}
let names = [
"project",
"oa_view",
"oa_other",
"sa_view",
"activity_a",
"activity_b",
"activity_c",
"activity_child",
"node_a",
"node_b",
"node_c",
"node_child",
"internal",
"external",
"trace",
"sa_function",
"sa_node",
"other_a",
];
let ids: std::collections::BTreeMap<_, _> =
names.into_iter().map(|name| (name, id(name))).collect();
write_new(
&out.join("manifest.json"),
&serde_json::to_vec_pretty(&serde_json::json!({
"fixture": "multiselect-native-20260923", "ids": ids, "source_entry": bundle.entry,
"move_members": ["node_a", "node_b", "node_child"], "fixed_member": "node_c",
"evaluation": "Whole Project invariant comparison; saved A supplies bounded shared delta, not an exact pointer oracle",
"hide": "Remove node_a/node_b only; retain child, stored routes, all model records and other views",
}))?,
)?;
equal(&p, &load(&out.join("baseline.json"))?)?;
equal(&p, &load(&out.join("source/project.arcadia"))?)?;
println!("PASS generated JSON and source bundle: {}", out.display());
Ok(())
}
fn check(mode: &str, baseline: &Path, saved: &Path) -> Result<()> {
let before = load(baseline)?;
let actual = load(saved)?;
validate_candidate(&before)?;
validate_candidate(&actual)?;
if before.id != id("project") {
return Err("Not this fixture's baseline project".into());
}
let mut expected = before.clone();
let mut delta = None;
match mode {
"unchanged" => {}
"hide" => {
let view = expected
.diagrams
.get_mut(&id("oa_view"))
.ok_or("Missing OA view")?;
for alias in ["node_a", "node_b"] {
view.nodes
.remove(&id(alias))
.ok_or("Baseline selected placement missing")?;
}
// references::remove_objects removes routes only when their semantic
// edge is deleted. Hide deletes placements; both routes MUST survive.
}
"moved" => {
let old = before
.diagrams
.get(&id("oa_view"))
.ok_or("Missing baseline OA view")?;
let new = actual
.diagrams
.get(&id("oa_view"))
.ok_or("Missing saved OA view")?;
let a = old
.nodes
.get(&id("node_a"))
.ok_or("Missing baseline A")?
.position;
let moved_a = new
.nodes
.get(&id("node_a"))
.ok_or("Missing saved A")?
.position;
let d = point(moved_a.x - a.x, moved_a.y - a.y);
if !d.x.is_finite()
|| !d.y.is_finite()
|| d.x.abs().max(d.y.abs()) > 500.
|| d.x.hypot(d.y) < 10.
{
return Err(format!(
"Move delta must have length >=10 and each axis <=500 world units: {d:?}"
)
.into());
}
if moved_a.x % 10. != 0. || moved_a.y % 10. != 0. {
return Err("Saved anchor is not on 10-unit grid".into());
}
let view = expected.diagrams.get_mut(&id("oa_view")).unwrap();
// Independent explicit fixture enumeration: no group/descendant/
// routing planner is called to predict the result.
for alias in ["node_a", "node_b", "node_child"] {
let node = view
.nodes
.get_mut(&id(alias))
.ok_or("Missing expected moved placement")?;
node.position.x += d.x;
node.position.y += d.y;
}
let internal = view
.routes
.iter_mut()
.find(|route| route.edge == id("internal"))
.ok_or("Missing internal pins")?;
for pin in &mut internal.waypoints {
pin.x += d.x;
pin.y += d.y;
}
delta = Some(d);
}
_ => return Err("Mode must be moved, hide, or unchanged".into()),
}
equal(&expected, &actual)?;
roundtrip(&actual)?;
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
"result": "PASS", "mode": mode, "baseline": baseline, "saved": saved,
"comparison": "complete Project equality", "inferred_common_delta": delta,
"limits": "Invariant evaluator only; no pointer/action/focus/Undo/source-byte acceptance claim",
}))?
);
Ok(())
}
fn main() -> Result<()> {
let args: Vec<_> = std::env::args().skip(1).collect();
match args.as_slice() {
[command, out] if command == "generate" => generate(Path::new(out)),
[command, mode, baseline, saved] if command == "check" => check(mode, Path::new(baseline), Path::new(saved)),
_ => Err("Usage: multiselect-native-producer generate DIRECTORY | check moved|hide|unchanged BASELINE SAVED".into()),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::BTreeSet;
// Produce evidence through real validated commands; the checker itself uses
// explicit fixture enumeration and never calls this movement implementation.
fn moved(before: &Project, delta: Position) -> Project {
let command = diagram_edit::translate_placements(
before,
id("oa_view"),
&BTreeSet::from([id("node_a"), id("node_b")]),
delta,
)
.unwrap();
let mut session = Session::new(before.clone());
session.execute(command).unwrap();
session.project().clone()
}
fn hidden(before: &Project) -> Project {
let mut session = Session::new(before.clone());
session
.execute(Command::Batch(vec![
Command::Delete(id("node_a")),
Command::Delete(id("node_b")),
]))
.unwrap();
session.project().clone()
}
fn through_saved_json(mode: &str, baseline: &Project, actual: &Project) -> Result<()> {
let dir = tempfile::tempdir()?;
let before = dir.path().join("before.json");
let saved = dir.path().join("saved.json");
// These assertions distinguish meaningful invariant corruptions from
// malformed projects rejected before the evaluator even sees them.
validate_candidate(baseline).expect("test baseline must be valid");
validate_candidate(actual).expect("corruption must remain a valid model");
persistence::save(&before, baseline)?;
persistence::save(&saved, actual)?;
check(mode, &before, &saved)
}
fn mismatch(mode: &str, before: &Project, actual: &Project, case: &str) {
let error = through_saved_json(mode, before, actual)
.expect_err(case)
.to_string();
assert!(
error.contains("Whole Project mismatch"),
"{case}: unexpected rejection: {error}"
);
}
fn edge<'a>(p: &'a mut Project, alias: &str) -> &'a mut EdgeRoute {
p.diagrams
.get_mut(&id("oa_view"))
.unwrap()
.routes
.iter_mut()
.find(|route| route.edge == id(alias))
.unwrap()
}
#[test]
fn generated_json_and_real_source_bundle_are_accepted_unchanged() {
let dir = tempfile::tempdir().unwrap();
let out = dir.path().join("fixture");
generate(&out).unwrap();
let baseline = out.join("baseline.json");
let saved = out.join("saved.json");
persistence::save(&saved, &fixture().unwrap()).unwrap();
check("unchanged", &baseline, &saved).unwrap();
check("unchanged", &baseline, &out.join("source/project.arcadia")).unwrap();
let bytes = fs::read(&baseline).unwrap();
assert!(
generate(&out).is_err(),
"generation cannot overwrite evidence"
);
assert_eq!(fs::read(&baseline).unwrap(), bytes);
let invalid = dir.path().join("invalid.arcadia");
fs::write(&invalid, "project {").unwrap();
assert!(load(&invalid).is_err(), "source errors must still reject");
}
#[test]
fn real_group_command_outputs_pass_and_are_not_unchanged() {
let before = fixture().unwrap();
for delta in [point(40., 60.), point(-20., 30.)] {
let actual = moved(&before, delta);
through_saved_json("moved", &before, &actual).unwrap();
mismatch(
"unchanged",
&before,
&actual,
"real movement cannot count as unchanged",
);
}
}
#[test]
fn real_hide_command_outputs_pass_before_and_after_movement() {
let initial = fixture().unwrap();
for before in [initial.clone(), moved(&initial, point(40., 60.))] {
let actual = hidden(&before);
through_saved_json("hide", &before, &actual).unwrap();
mismatch(
"unchanged",
&before,
&actual,
"real Hide cannot count as unchanged",
);
}
}
#[test]
fn omitted_and_double_moved_child_are_rejected() {
let before = fixture().unwrap();
let delta = point(40., 60.);
let correct = moved(&before, delta);
let initial = before.diagrams[&id("oa_view")].nodes[&id("node_child")].position;
for position in [
initial,
point(initial.x + 2. * delta.x, initial.y + 2. * delta.y),
] {
let mut bad = correct.clone();
bad.diagrams
.get_mut(&id("oa_view"))
.unwrap()
.nodes
.get_mut(&id("node_child"))
.unwrap()
.position = position;
mismatch("moved", &before, &bad, "child did not move exactly once");
}
}
#[test]
fn internal_pins_left_behind_and_external_pins_moved_are_rejected() {
let mut before = fixture().unwrap();
let original_pins = edge(&mut before, "internal").waypoints.clone();
let correct = moved(&before, point(40., 60.));
let mut bad = correct.clone();
edge(&mut bad, "internal").waypoints = original_pins;
mismatch("moved", &before, &bad, "internal pins left behind");
let mut bad = correct;
for pin in &mut edge(&mut bad, "external").waypoints {
pin.x += 40.;
pin.y += 60.;
}
mismatch("moved", &before, &bad, "one-ended pins moved");
}
#[test]
fn manual_attachments_and_label_data_must_remain_exact() {
let before = fixture().unwrap();
let correct = moved(&before, point(40., 60.));
let mutations: [fn(&mut EdgeRoute); 4] = [
|r| r.source_attachment.as_mut().unwrap().offset = 0.65,
|r| r.target_attachment.as_mut().unwrap().side = PortAnchorSide::Top,
|r| r.label.as_mut().unwrap().offset.x += 40.,
|r| r.label.as_mut().unwrap().fraction = 0.75,
];
for mutate in mutations {
let mut bad = correct.clone();
mutate(edge(&mut bad, "internal"));
mismatch("moved", &before, &bad, "relative route metadata changed");
}
}
#[test]
fn semantic_other_view_fixed_node_and_trace_drift_are_rejected() {
let before = fixture().unwrap();
let correct = moved(&before, point(40., 60.));
let mutations: [fn(&mut Project); 5] = [
|p| {
p.elements
.get_mut(&id("activity_a"))
.unwrap()
.description
.push_str(" drift")
},
|p| {
p.diagrams
.get_mut(&id("oa_other"))
.unwrap()
.nodes
.get_mut(&id("other_a"))
.unwrap()
.position
.x += 40.
},
|p| {
p.diagrams
.get_mut(&id("sa_view"))
.unwrap()
.nodes
.get_mut(&id("sa_node"))
.unwrap()
.position
.y += 60.
},
|p| {
p.diagrams
.get_mut(&id("oa_view"))
.unwrap()
.nodes
.get_mut(&id("node_c"))
.unwrap()
.position
.x += 40.
},
|p| {
p.traces
.get_mut(&id("trace"))
.unwrap()
.description
.push_str(" drift")
},
];
for mutate in mutations {
let mut bad = correct.clone();
mutate(&mut bad);
mismatch(
"moved",
&before,
&bad,
"record outside permitted movement changed",
);
}
}
#[test]
fn zero_small_excessive_and_offgrid_moves_cannot_satisfy_move_evidence() {
let before = fixture().unwrap();
for (delta, reason) in [
(point(0., 0.), "length >=10"),
(point(3., 0.), "length >=10"),
(point(510., 0.), "each axis <=500"),
(point(20.5, 0.), "10-unit grid"),
] {
let actual = moved(&before, delta);
let error = through_saved_json("moved", &before, &actual)
.unwrap_err()
.to_string();
assert!(error.contains(reason), "{delta:?}: {error}");
}
}
#[test]
fn hide_must_preserve_child_routes_and_semantic_objects() {
let before = fixture().unwrap();
let correct = hidden(&before);
let mut missing_child = correct.clone();
missing_child
.diagrams
.get_mut(&id("oa_view"))
.unwrap()
.nodes
.remove(&id("node_child"));
mismatch(
"hide",
&before,
&missing_child,
"Hide removed unselected child placement",
);
let mut missing_routes = correct;
missing_routes
.diagrams
.get_mut(&id("oa_view"))
.unwrap()
.routes
.clear();
mismatch(
"hide",
&before,
&missing_routes,
"Hide removed persistent route records",
);
let mut semantic_delete = Session::new(before.clone());
semantic_delete
.execute(Command::Batch(vec![
Command::Delete(id("activity_a")),
Command::Delete(id("activity_b")),
]))
.unwrap();
mismatch(
"hide",
&before,
semantic_delete.project(),
"Hide became semantic deletion",
);
}
#[test]
fn unchanged_rejects_valid_semantic_drift_without_geometry_changes() {
let before = fixture().unwrap();
let mut actual = before.clone();
actual
.elements
.get_mut(&id("activity_b"))
.unwrap()
.name
.push_str(" edited");
mismatch(
"unchanged",
&before,
&actual,
"geometry equality is not complete Project equality",
);
}
}
@@ -0,0 +1,101 @@
//! Read-only saved-State geometry evidence for a predeclared native bend trial.
//!
//! Usage:
//! state_bend_routing_snapshot INPUT.arcadia NEW_OUTPUT.json
//!
//! Loads and validates the complete Project, then independently projects/routes
//! every saved State view through core APIs. The complete Project permits exact
//! semantic and presentation comparison; derived geometry permits declaring a
//! segment and insertion position before native actions. No desktop command,
//! planner, gesture, or pin insertion helper is called. Input is never written;
//! output uses create_new and fails rather than overwriting an existing file.
//!
//! Limits: this shares production core routing, so it is not an independent
//! mathematical test of that router, nor evidence of UI picking/gesture success.
//! It does not generate expected mutations or adapt an oracle to observed edits.
//! The acceptance recipe must declare the intended segment/index/point and exact
//! Project mutation before UI execution, and bind this executable/source/input
//! and output hashes externally. Warning strings are diagnostic Debug output,
//! not a stable interchange schema. Missing/hidden/internal transitions have no
//! routed edge; their semantic records remain in the complete Project snapshot.
use oa_core::{persistence, projection, routing, validate_candidate};
use serde_json::json;
use std::{fs::OpenOptions, io::Write, path::Path};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let args: Vec<_> = std::env::args_os().skip(1).collect();
if args.len() != 2 {
return Err("Expected input source/JSON and a new output JSON path".into());
}
let project = persistence::load(Path::new(&args[0]))?;
validate_candidate(&project)?;
let mut views = Vec::new();
for (diagram_id, diagram) in &project.diagrams {
let Some(machine) = diagram.projection.state_machine() else {
continue;
};
let projected = projection::project_diagram(&project, diagram);
let routed = routing::route_diagram(&project, diagram, &projected);
let edges: Vec<_> = routed
.edges
.iter()
.map(|(edge_id, edge)| {
json!({
"edge": edge_id,
"id": edge.id,
"points": edge.points,
"pins": edge.pins,
"pin_fractions": edge.pin_fractions,
"custom_suspended": edge.custom_suspended,
"warnings": edge.warnings.iter().map(|warning| format!("{warning:?}")).collect::<Vec<_>>(),
"label": {
"text": edge.label.text,
"fraction": edge.label.fraction,
"anchor": edge.label.anchor,
"center": edge.label.center,
"bounds": {
"position": edge.label.bounds.position,
"size": edge.label.bounds.size
}
}
})
})
.collect();
views.push(json!({
"diagram": diagram_id,
"machine": machine,
"projected": projected,
"routed_bounds": {
"position": routed.bounds.position,
"size": routed.bounds.size
},
"routed_edges": edges
}));
}
// Preserve the canonical typed Project serialization (including f32
// formatting). Serializing it through Value directly promotes f32 to f64.
let project_json: serde_json::Value = serde_json::from_slice(&serde_json::to_vec(&project)?)?;
let snapshot = json!({
"schema": "state-bend-routing-snapshot/v1",
"project": project_json,
"state_views": views
});
// Finish all reading, validation, routing and serialization before creating
// the new evidence file. A failed load cannot leave an apparent snapshot.
let mut bytes = serde_json::to_vec_pretty(&snapshot)?;
bytes.push(b'\n');
let mut output = OpenOptions::new()
.write(true)
.create_new(true)
.open(&args[1])?;
output.write_all(&bytes)?;
output.sync_all()?;
println!(
"Validated {} objects and {} saved views; snapshotted {} State views and the complete Project",
project.identities().len(),
project.diagrams.len(),
views.len()
);
Ok(())
}
@@ -0,0 +1,911 @@
//! Deterministic persisted-State native fixture and complete-Project checker.
//! This invariant evaluator derives delta from saved A, NOT from pointer input.
use oa_core::{source_project::*, *};
use std::{fs, io::Write, path::Path};
type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
fn id(alias: &str) -> Id {
// Dedicated State view is the smallest diagram UUID, for native opening.
if alias == "state_view" {
return Id::from_u128(0x29_0101);
}
Id::new_v5(
&Id::from_u128(0xc1192026_0923_5000_8000_000000002929),
alias.as_bytes(),
)
}
fn point(x: f32, y: f32) -> Position {
Position { x, y }
}
fn element(p: &mut Project, alias: &str, name: &str, kind: Kind, layer: Layer, parent: Option<Id>) {
p.elements.insert(
id(alias),
Element {
id: id(alias),
alias: alias.into(),
name: name.into(),
description: format!("Preserve engineering record {alias}."),
parent,
body: ElementBody::new(kind, layer).unwrap(),
},
);
}
fn diagram(p: &mut Project, alias: &str, name: &str, layer: Layer) {
p.diagrams.insert(
id(alias),
Diagram {
id: id(alias),
alias: alias.into(),
name: name.into(),
description: "Selection changes placement geometry only.".into(),
layer,
projection: ProjectionKind::Overview,
view_options: Default::default(),
nodes: Default::default(),
routes: vec![],
},
);
}
fn place(p: &mut Project, view: &str, alias: &str, object: &str, position: Position, size: Size) {
p.diagrams.get_mut(&id(view)).unwrap().nodes.insert(
id(alias),
NodePlacement {
id: id(alias),
alias: alias.into(),
element: id(object),
position,
size,
collapsed: false,
port_anchors: vec![],
},
);
}
fn route(edge: &str, pins: Vec<Position>) -> EdgeRoute {
EdgeRoute {
edge: id(edge),
waypoints: pins,
source_attachment: Some(EdgeAttachment {
side: PortAnchorSide::Right,
offset: 0.25,
}),
target_attachment: Some(EdgeAttachment {
side: PortAnchorSide::Left,
offset: 0.7,
}),
label: Some(EdgeLabel {
fraction: 0.45,
offset: point(7., -19.),
}),
}
}
fn transition(
p: &mut Project,
alias: &str,
name: &str,
source: &str,
target: &str,
kind: TransitionKind,
) -> Result<()> {
let old = p.relate(RelationKind::StateTransition, name, id(source), id(target));
reidentify(p, old, id(alias))?;
let relation = p.relations.get_mut(&id(alias)).unwrap();
relation.alias = alias.into();
relation.description = format!("Preserve transition {alias}.");
if let RelationBody::StateTransition {
transition_kind,
guard,
..
} = &mut relation.body
{
*transition_kind = kind;
*guard = if alias == "between" {
"request_ready"
} else {
""
}
.into();
}
Ok(())
}
fn fixture() -> Result<Project> {
let mut p = Project::empty();
p.id = id("project");
p.alias = "state_multiselect_native".into();
p.name = "State group native".into();
p.description = "Shift-select A and B; move their placements together. C and shared engineering records stay fixed.".into();
for (alias, name, kind, parent) in [
("crew", "Operations crew", Kind::Entity, None),
(
"machine",
"Request lifecycle",
Kind::StateMachine,
Some(id("crew")),
),
("region", "Main region", Kind::Region, Some(id("machine"))),
("state_a", "A - Processing", Kind::State, Some(id("region"))),
("state_b", "B - Ready", Kind::State, Some(id("region"))),
("state_c", "C - Complete", Kind::State, Some(id("region"))),
(
"inner",
"A nested region",
Kind::Region,
Some(id("state_a")),
),
(
"state_leaf",
"A.1 - Classify",
Kind::State,
Some(id("inner")),
),
("entry", "Enter A", Kind::EntryPoint, Some(id("inner"))),
("exit", "Leave A", Kind::ExitPoint, Some(id("inner"))),
("shared_work", "Record request", Kind::Activity, None),
] {
element(&mut p, alias, name, kind, Layer::Oa, parent);
}
element(
&mut p,
"sa_function",
"Technical record request",
Kind::Function,
Layer::Sa,
None,
);
for (alias, name, source, target, kind) in [
(
"between",
"Ready\nfor review",
"state_a",
"state_b",
TransitionKind::External,
),
(
"external",
"Complete request",
"state_b",
"state_c",
TransitionKind::External,
),
(
"entry_route",
"Begin classification",
"entry",
"state_leaf",
TransitionKind::Local,
),
(
"exit_route",
"Classification complete",
"state_leaf",
"exit",
TransitionKind::External,
),
(
"refresh",
"Refresh record",
"state_a",
"state_a",
TransitionKind::Internal,
),
] {
transition(&mut p, alias, name, source, target, kind)?;
}
for (alias, source, phase) in [
("action_a", "state_a", StateActionKind::Entry),
("action_c", "state_c", StateActionKind::Do),
] {
let old = p.relate(
RelationKind::StateAction,
"Record shared work",
id(source),
id("shared_work"),
);
reidentify(&mut p, old, id(alias))?;
let relation = p.relations.get_mut(&id(alias)).unwrap();
relation.alias = alias.into();
relation.description = format!("Preserve shared-work action {alias}.");
if let RelationBody::StateAction { phase: value, .. } = &mut relation.body {
*value = phase;
}
}
for (alias, name) in [
("state_view", "State multiselect - main"),
("state_other", "Other State view - preserve"),
] {
diagram(&mut p, alias, name, Layer::Oa);
p.diagrams.get_mut(&id(alias)).unwrap().projection = ProjectionKind::StateMachine {
machine: id("machine"),
};
}
diagram(&mut p, "sa_view", "SA engineering - preserve", Layer::Sa);
// The main machine and outer Region are deliberately unplaced to keep
// the target headers readable in a 1442 x 932 native window after Fit.
// A starts at the authored origin so saved A recovers the exact f32 delta;
// subtracting a nonzero original can round it differently from sibling XY.
for (alias, object, x, y, width, height) in [
("node_a", "state_a", 0., 0., 340., 450.),
("node_b", "state_b", 410., 30., 210., 150.),
("node_c", "state_c", 410., 330., 210., 140.),
("node_inner", "inner", 25., 220., 290., 200.),
("node_leaf", "state_leaf", 60., 290., 180., 90.),
("node_entry", "entry", -210., 280., 24., 24.),
("node_exit", "exit", 870., 350., 24., 24.),
] {
place(
&mut p,
"state_view",
alias,
object,
point(x, y),
Size { width, height },
);
}
let size = Size {
width: 224.,
height: 94.,
};
place(
&mut p,
"state_other",
"other_a",
"state_a",
point(40., 60.),
Size {
width: 340.,
height: 220.,
},
);
place(
&mut p,
"state_other",
"other_b",
"state_b",
point(460., 80.),
size,
);
place(
&mut p,
"sa_view",
"sa_node",
"sa_function",
point(100., 110.),
size,
);
p.diagrams.get_mut(&id("state_view")).unwrap().routes = vec![
route("between", vec![point(365., 70.), point(365., 120.)]),
route("external", vec![point(660., 140.), point(660., 360.)]),
route("entry_route", vec![point(40., 280.), point(40., 320.)]),
route("exit_route", vec![point(290., 335.), point(290., 370.)]),
];
p.traces.insert(
id("trace"),
Trace {
id: id("trace"),
alias: "preserved_refinement".into(),
name: "Reviewed recording refinement".into(),
description:
"Moving states or hiding placements must preserve shared work and its refinement."
.into(),
oa: id("shared_work"),
sa: id("sa_function"),
kind: TraceKind::Refinement,
baseline: Some(TraceBaseline {
oa: p.properties(id("shared_work")).unwrap(),
sa: p.properties(id("sa_function")).unwrap(),
semantic: None,
}),
},
);
validate_candidate(&p)?;
Ok(p)
}
fn load(path: &Path) -> Result<Project> {
if path
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("arcadia"))
{
let disk = source_files::load_project(path, ProjectLimits::default())?;
// This deliberately partial engineering fixture has completeness
// warnings (allocation/behavior context). They are not source errors.
// Compilation, candidate validation and whole-Project equality still
// reject malformed source or any unexpected model change.
if disk
.diagnostics
.iter()
.any(|d| d.diagnostic.severity == validation::Severity::Error)
{
return Err(format!("Source discovery diagnostics: {:?}", disk.diagnostics).into());
}
let compiled = compile_bundle(&disk.reachable_bundle(), ProjectLimits::default())
.map_err(|e| format!("Source compile diagnostics: {e:?}"))?;
Ok(compiled.decoded.project)
} else {
Ok(persistence::load(path)?)
}
}
fn equal(expected: &Project, actual: &Project) -> Result<()> {
if expected == actual {
return Ok(());
}
let e = serde_json::to_value(expected)?;
let a = serde_json::to_value(actual)?;
let fields: Vec<_> = e
.as_object()
.unwrap()
.keys()
.filter(|key| e[*key] != a[*key])
.cloned()
.collect();
Err(format!("Whole Project mismatch in {fields:?}; no partial PASS").into())
}
fn roundtrip(p: &Project) -> Result<SourceBundle> {
validate_candidate(p)?;
equal(p, &persistence::decode(&serde_json::to_vec(p)?)?)?;
let bundle = format_bundle(p);
let compiled = compile_bundle(&bundle, ProjectLimits::default())
.map_err(|e| format!("Roundtrip: {e:?}"))?;
equal(p, &compiled.decoded.project)?;
Ok(bundle)
}
fn write_new(path: &Path, bytes: &[u8]) -> Result<()> {
fs::create_dir_all(path.parent().ok_or("Missing parent directory")?)?;
fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(path)?
.write_all(bytes)?;
Ok(())
}
fn generate(out: &Path) -> Result<()> {
if out.exists() {
return Err("Output directory already exists; choose a fresh path".into());
}
let p = fixture()?;
let bundle = roundtrip(&p)?;
write_new(&out.join("baseline.json"), &serde_json::to_vec_pretty(&p)?)?;
for (path, source) in &bundle.files {
write_new(&out.join("source").join(path), source.as_bytes())?;
}
let names = [
"crew",
"machine",
"region",
"inner",
"entry",
"exit",
"shared_work",
"node_inner",
"node_entry",
"node_exit",
"entry_route",
"exit_route",
"refresh",
"action_a",
"action_c",
"other_b",
"project",
"state_view",
"state_other",
"sa_view",
"state_a",
"state_b",
"state_c",
"state_leaf",
"node_a",
"node_b",
"node_c",
"node_leaf",
"between",
"external",
"trace",
"sa_function",
"sa_node",
"other_a",
];
let ids: std::collections::BTreeMap<_, _> =
names.into_iter().map(|name| (name, id(name))).collect();
write_new(
&out.join("manifest.json"),
&serde_json::to_vec_pretty(&serde_json::json!({
"fixture": "state-multiselect-native-20260923", "ids": ids, "source_entry": bundle.entry,
"move_members": ["node_a", "node_b", "node_inner", "node_leaf", "node_entry", "node_exit"],
"translated_routes": ["between", "entry_route", "exit_route"],
"fixed_route": "external",
"window": [1442, 932],
"target_headers_world": {"A": [40, 35], "B": [450, 65]},
"delta_limits": "finite, nonzero length >=0.01, each axis <=300; no grid quantization",
"receipt": "Pin baseline.json, manifest.json and generation command externally before interaction; baseline identity alone does not authenticate evidence", "fixed_member": "node_c",
"evaluation": "Whole Project invariant comparison; saved A supplies bounded shared delta, not an exact pointer oracle",
"hide": "Remove node_a/node_b only; retain nested Region/leaf/entry/exit, stored routes, all model records and other views",
}))?,
)?;
equal(&p, &load(&out.join("baseline.json"))?)?;
equal(&p, &load(&out.join("source/project.arcadia"))?)?;
println!("PASS generated JSON and source bundle: {}", out.display());
Ok(())
}
fn check(mode: &str, baseline: &Path, saved: &Path) -> Result<()> {
let before = load(baseline)?;
let actual = load(saved)?;
validate_candidate(&before)?;
validate_candidate(&actual)?;
if before.id != id("project") {
return Err("Not this fixture's baseline project".into());
}
let mut expected = before.clone();
let mut delta = None;
match mode {
"unchanged" => {}
"hide" => {
let view = expected
.diagrams
.get_mut(&id("state_view"))
.ok_or("Missing State view")?;
for alias in ["node_a", "node_b"] {
view.nodes
.remove(&id(alias))
.ok_or("Baseline selected placement missing")?;
}
// references::remove_objects removes routes only when their semantic
// edge is deleted. Hide deletes placements; both routes MUST survive.
}
"moved" => {
let old = before
.diagrams
.get(&id("state_view"))
.ok_or("Missing baseline State view")?;
let new = actual
.diagrams
.get(&id("state_view"))
.ok_or("Missing saved State view")?;
let a = old
.nodes
.get(&id("node_a"))
.ok_or("Missing baseline A")?
.position;
let moved_a = new
.nodes
.get(&id("node_a"))
.ok_or("Missing saved A")?
.position;
let d = point(moved_a.x - a.x, moved_a.y - a.y);
if !d.x.is_finite()
|| !d.y.is_finite()
|| d.x.abs().max(d.y.abs()) > 300.
|| d.x.hypot(d.y) < 0.01
{
return Err(format!(
"Move delta must have length >=0.01 and each axis <=300 world units: {d:?}"
)
.into());
}
let view = expected.diagrams.get_mut(&id("state_view")).unwrap();
// Independent explicit fixture enumeration: no group/descendant/
// routing planner is called to predict the result.
for alias in [
"node_a",
"node_b",
"node_inner",
"node_leaf",
"node_entry",
"node_exit",
] {
let node = view
.nodes
.get_mut(&id(alias))
.ok_or("Missing expected moved placement")?;
node.position.x += d.x;
node.position.y += d.y;
}
for alias in ["between", "entry_route", "exit_route"] {
let route = view
.routes
.iter_mut()
.find(|route| route.edge == id(alias))
.ok_or("Missing explicitly expected moved route")?;
for pin in &mut route.waypoints {
pin.x += d.x;
pin.y += d.y;
}
}
delta = Some(d);
}
_ => return Err("Mode must be moved, hide, or unchanged".into()),
}
equal(&expected, &actual)?;
roundtrip(&actual)?;
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
"result": "PASS", "mode": mode, "baseline": baseline, "saved": saved,
"comparison": "complete Project equality", "inferred_common_delta": delta,
"limits": "Invariant evaluator only; no pointer/action/focus/Undo/source-byte acceptance claim",
}))?
);
Ok(())
}
fn main() -> Result<()> {
let args: Vec<_> = std::env::args().skip(1).collect();
match args.as_slice() {
[command, out] if command == "generate" => generate(Path::new(out)),
[command, mode, baseline, saved] if command == "check" => check(mode, Path::new(baseline), Path::new(saved)),
_ => Err("Usage: state_multiselect_native generate DIRECTORY | check moved|hide|unchanged BASELINE SAVED".into()),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::BTreeSet;
// Produce evidence through real validated commands; the checker itself uses
// explicit fixture enumeration and never calls this movement implementation.
fn moved(before: &Project, delta: Position) -> Project {
let command = diagram_edit::translate_placements(
before,
id("state_view"),
&BTreeSet::from([id("node_a"), id("node_b")]),
delta,
)
.unwrap();
let mut session = Session::new(before.clone());
session.execute(command).unwrap();
session.project().clone()
}
fn hidden(before: &Project) -> Project {
let mut session = Session::new(before.clone());
session
.execute(Command::Batch(vec![
Command::Delete(id("node_a")),
Command::Delete(id("node_b")),
]))
.unwrap();
session.project().clone()
}
fn through_saved_json(mode: &str, baseline: &Project, actual: &Project) -> Result<()> {
let dir = tempfile::tempdir()?;
let before = dir.path().join("before.json");
let saved = dir.path().join("saved.json");
// These assertions distinguish meaningful invariant corruptions from
// malformed projects rejected before the evaluator even sees them.
validate_candidate(baseline).expect("test baseline must be valid");
validate_candidate(actual).expect("corruption must remain a valid model");
persistence::save(&before, baseline)?;
persistence::save(&saved, actual)?;
check(mode, &before, &saved)
}
fn mismatch(mode: &str, before: &Project, actual: &Project, case: &str) {
let error = through_saved_json(mode, before, actual)
.expect_err(case)
.to_string();
assert!(
error.contains("Whole Project mismatch"),
"{case}: unexpected rejection: {error}"
);
}
fn edge<'a>(p: &'a mut Project, alias: &str) -> &'a mut EdgeRoute {
p.diagrams
.get_mut(&id("state_view"))
.unwrap()
.routes
.iter_mut()
.find(|route| route.edge == id(alias))
.unwrap()
}
#[test]
fn generated_json_and_real_source_bundle_are_accepted_unchanged() {
let dir = tempfile::tempdir().unwrap();
let out = dir.path().join("fixture");
generate(&out).unwrap();
let baseline = out.join("baseline.json");
let saved = out.join("saved.json");
persistence::save(&saved, &fixture().unwrap()).unwrap();
check("unchanged", &baseline, &saved).unwrap();
check("unchanged", &baseline, &out.join("source/project.arcadia")).unwrap();
let bytes = fs::read(&baseline).unwrap();
assert!(
generate(&out).is_err(),
"generation cannot overwrite evidence"
);
assert_eq!(fs::read(&baseline).unwrap(), bytes);
let invalid = dir.path().join("invalid.arcadia");
fs::write(&invalid, "project {").unwrap();
assert!(load(&invalid).is_err(), "source errors must still reject");
}
#[test]
fn real_group_command_outputs_pass_and_are_not_unchanged() {
let before = fixture().unwrap();
for delta in [
point(7.25, -2.75),
point(-20.5, 30.125),
point(0.25, 0.),
point(0.01, 10.),
point(0.1, -0.2),
] {
let actual = moved(&before, delta);
through_saved_json("moved", &before, &actual).unwrap();
mismatch(
"unchanged",
&before,
&actual,
"real movement cannot count as unchanged",
);
}
}
#[test]
fn real_hide_command_outputs_pass_before_and_after_movement() {
let initial = fixture().unwrap();
for before in [initial.clone(), moved(&initial, point(40., 60.))] {
let actual = hidden(&before);
through_saved_json("hide", &before, &actual).unwrap();
mismatch(
"unchanged",
&before,
&actual,
"real Hide cannot count as unchanged",
);
}
}
#[test]
fn omitted_and_double_moved_child_are_rejected() {
let before = fixture().unwrap();
let delta = point(40., 60.);
let correct = moved(&before, delta);
for alias in ["node_inner", "node_leaf", "node_entry", "node_exit"] {
let initial = before.diagrams[&id("state_view")].nodes[&id(alias)].position;
for position in [
initial,
point(initial.x + 2. * delta.x, initial.y + 2. * delta.y),
] {
let mut bad = correct.clone();
bad.diagrams
.get_mut(&id("state_view"))
.unwrap()
.nodes
.get_mut(&id(alias))
.unwrap()
.position = position;
mismatch("moved", &before, &bad, "child did not move exactly once");
}
}
}
#[test]
fn internal_pins_left_behind_and_external_pins_moved_are_rejected() {
let mut before = fixture().unwrap();
let correct = moved(&before, point(40., 60.));
for alias in ["between", "entry_route", "exit_route"] {
let original_pins = edge(&mut before, alias).waypoints.clone();
let mut bad = correct.clone();
edge(&mut bad, alias).waypoints = original_pins;
mismatch("moved", &before, &bad, "internal pins left behind");
}
let mut bad = correct;
for pin in &mut edge(&mut bad, "external").waypoints {
pin.x += 40.;
pin.y += 60.;
}
mismatch("moved", &before, &bad, "one-ended pins moved");
}
#[test]
fn manual_attachments_and_label_data_must_remain_exact() {
let before = fixture().unwrap();
let correct = moved(&before, point(40., 60.));
let mutations: [fn(&mut EdgeRoute); 4] = [
|r| r.source_attachment.as_mut().unwrap().offset = 0.65,
|r| r.target_attachment.as_mut().unwrap().side = PortAnchorSide::Top,
|r| r.label.as_mut().unwrap().offset.x += 40.,
|r| r.label.as_mut().unwrap().fraction = 0.75,
];
for mutate in mutations {
let mut bad = correct.clone();
mutate(edge(&mut bad, "between"));
mismatch("moved", &before, &bad, "relative route metadata changed");
}
}
#[test]
fn semantic_other_view_fixed_node_and_trace_drift_are_rejected() {
let before = fixture().unwrap();
let correct = moved(&before, point(40., 60.));
let mutations: [fn(&mut Project); 9] = [
|p| p.traces.get_mut(&id("trace")).unwrap().baseline = None,
|p| {
p.elements
.get_mut(&id("shared_work"))
.unwrap()
.description
.push_str(" drift")
},
|p| {
if let RelationBody::StateAction { phase, .. } =
&mut p.relations.get_mut(&id("action_a")).unwrap().body
{
*phase = StateActionKind::Exit;
}
},
|p| {
if let RelationBody::StateTransition { guard, .. } =
&mut p.relations.get_mut(&id("refresh")).unwrap().body
{
*guard = "changed_refresh_guard".into();
}
},
|p| {
p.elements
.get_mut(&id("state_a"))
.unwrap()
.description
.push_str(" drift")
},
|p| {
p.diagrams
.get_mut(&id("state_other"))
.unwrap()
.nodes
.get_mut(&id("other_a"))
.unwrap()
.position
.x += 40.
},
|p| {
p.diagrams
.get_mut(&id("sa_view"))
.unwrap()
.nodes
.get_mut(&id("sa_node"))
.unwrap()
.position
.y += 60.
},
|p| {
p.diagrams
.get_mut(&id("state_view"))
.unwrap()
.nodes
.get_mut(&id("node_c"))
.unwrap()
.position
.x += 40.
},
|p| {
p.traces
.get_mut(&id("trace"))
.unwrap()
.description
.push_str(" drift")
},
];
for mutate in mutations {
let mut bad = correct.clone();
mutate(&mut bad);
mismatch(
"moved",
&before,
&bad,
"record outside permitted movement changed",
);
}
}
#[test]
fn zero_and_excessive_moves_cannot_satisfy_move_evidence() {
let before = fixture().unwrap();
for (delta, reason) in [
(point(0., 0.), "length >=0.01"),
(point(310., 0.), "each axis <=300"),
] {
let actual = moved(&before, delta);
let error = through_saved_json("moved", &before, &actual)
.unwrap_err()
.to_string();
assert!(error.contains(reason), "{delta:?}: {error}");
}
}
#[test]
fn hide_must_preserve_child_routes_and_semantic_objects() {
let before = fixture().unwrap();
let correct = hidden(&before);
let mut missing_child = correct.clone();
missing_child
.diagrams
.get_mut(&id("state_view"))
.unwrap()
.nodes
.remove(&id("node_leaf"));
mismatch(
"hide",
&before,
&missing_child,
"Hide removed unselected child placement",
);
let mut missing_routes = correct;
missing_routes
.diagrams
.get_mut(&id("state_view"))
.unwrap()
.routes
.clear();
mismatch(
"hide",
&before,
&missing_routes,
"Hide removed persistent route records",
);
let mut semantic_delete = Session::new(before.clone());
semantic_delete
.execute(Command::Batch(vec![
Command::Delete(id("state_a")),
Command::Delete(id("state_b")),
]))
.unwrap();
mismatch(
"hide",
&before,
semantic_delete.project(),
"Hide became semantic deletion",
);
}
#[test]
fn unchanged_rejects_valid_semantic_drift_without_geometry_changes() {
let before = fixture().unwrap();
let mut actual = before.clone();
actual
.elements
.get_mut(&id("state_b"))
.unwrap()
.name
.push_str(" edited");
mismatch(
"unchanged",
&before,
&actual,
"geometry equality is not complete Project equality",
);
}
#[test]
fn moved_and_hidden_source_inputs_are_checked_against_complete_expected_projects() {
let before = fixture().unwrap();
for (mode, actual) in [
("moved", moved(&before, point(7.25, -2.75))),
("hide", hidden(&before)),
] {
let dir = tempfile::tempdir().unwrap();
let baseline = dir.path().join("baseline.json");
persistence::save(&baseline, &before).unwrap();
let bundle = roundtrip(&actual).unwrap();
for (path, source) in &bundle.files {
write_new(&dir.path().join("saved").join(path), source.as_bytes()).unwrap();
}
check(
mode,
&baseline,
&dir.path().join("saved").join(&bundle.entry),
)
.unwrap();
let mut corrupt = actual.clone();
corrupt.elements.get_mut(&id("shared_work")).unwrap().name =
"Unrequested source edit".into();
let bundle = roundtrip(&corrupt).unwrap();
for (path, source) in &bundle.files {
write_new(&dir.path().join("corrupt").join(path), source.as_bytes()).unwrap();
}
let error = check(
mode,
&baseline,
&dir.path().join("corrupt").join(&bundle.entry),
)
.unwrap_err()
.to_string();
assert!(error.contains("Whole Project mismatch"), "{error}");
}
}
}
+91
View File
@@ -0,0 +1,91 @@
//! Pure diagram edits shared by movement previews and committed transactions.
use crate::{Command, Id, MAX_COORDINATE, ModelError, Position, Project, projection};
use std::collections::{BTreeMap, BTreeSet};
/// Translate selected placements and their projected descendants simultaneously.
///
/// Membership and geometry are read from one snapshot, including folded children.
/// Overlapping roots move each placement once. Route pins follow the combined
/// movement of their actual endpoint owners (and State boundaries). Semantic
/// ownership and other views are untouched. Apply the returned batch through the
/// normal validated command path; its selected identity is not a primary selection.
pub fn translate_placements(
project: &Project,
diagram: Id,
selected: &BTreeSet<Id>,
delta: Position,
) -> Result<Command, ModelError> {
let view = project
.diagrams
.get(&diagram)
.ok_or_else(|| ModelError("Movement requires an existing diagram.".into()))?;
if selected.is_empty() {
return Err(ModelError(
"Movement requires at least one placement.".into(),
));
}
if selected.iter().any(|id| !view.nodes.contains_key(id)) {
return Err(ModelError(
"Every selected placement must belong to the diagram.".into(),
));
}
if !delta.x.is_finite() || !delta.y.is_finite() {
return Err(ModelError("Movement displacement must be finite.".into()));
}
if delta.x == 0. && delta.y == 0. {
return Ok(Command::Batch(vec![]));
}
let mut children: BTreeMap<Id, Vec<Id>> = BTreeMap::new();
for (child, parent) in projection::parents(project, view) {
if let Some(parent) = parent {
children.entry(parent).or_default().push(child);
}
}
let mut moved = BTreeSet::new();
let mut pending: Vec<_> = selected.iter().copied().collect();
while let Some(id) = pending.pop() {
if moved.insert(id) {
pending.extend(children.get(&id).into_iter().flatten().copied());
}
}
let deltas: BTreeMap<_, _> = moved.iter().map(|id| (*id, delta)).collect();
let routes = projection::translated_routes(project, view, &deltas);
let bounded = |value: f32| value.is_finite() && value.abs() <= MAX_COORDINATE;
let mut commands = Vec::with_capacity(moved.len() + routes.len());
for id in moved {
let mut node = view.nodes[&id].clone();
node.position.x += delta.x;
node.position.y += delta.y;
if ![
node.position.x,
node.position.y,
node.size.width,
node.size.height,
]
.into_iter()
.all(bounded)
|| node.size.width <= 0.
|| node.size.height <= 0.
{
return Err(ModelError(
"Movement produces invalid placement bounds.".into(),
));
}
commands.push(Command::EditNode {
diagram,
original: id,
node,
});
}
for route in routes {
if route
.waypoints
.iter()
.any(|point| !bounded(point.x) || !bounded(point.y))
{
return Err(ModelError("Movement produces invalid route bounds.".into()));
}
commands.push(Command::SetEdgeRoute { diagram, route });
}
Ok(Command::Batch(commands))
}
+1
View File
@@ -1,6 +1,7 @@
//! Framework-independent OA/SA model, transactional commands and editing services.
pub mod analysis_numeric;
pub mod data;
pub mod diagram_edit;
pub mod language;
pub mod model;
pub mod operations;
+34
View File
@@ -1502,11 +1502,45 @@ pub struct Diagram {
#[serde(deny_unknown_fields)]
pub struct EdgeRoute {
pub edge: Id,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source_attachment: Option<EdgeAttachment>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub target_attachment: Option<EdgeAttachment>,
#[serde(default)]
pub waypoints: Vec<Position>,
#[serde(default)]
pub label: Option<EdgeLabel>,
}
/// Presentation preference on one end of a connection. Real ports take precedence.
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct EdgeAttachment {
pub side: PortAnchorSide,
pub offset: f32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EdgeEnd {
Source,
Target,
}
impl EdgeRoute {
pub fn attachment(&self, end: EdgeEnd) -> Option<EdgeAttachment> {
match end {
EdgeEnd::Source => self.source_attachment,
EdgeEnd::Target => self.target_attachment,
}
}
pub fn set_attachment(&mut self, end: EdgeEnd, attachment: Option<EdgeAttachment>) {
match end {
EdgeEnd::Source => self.source_attachment = attachment,
EdgeEnd::Target => self.target_attachment = attachment,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct EdgeLabel {
+132 -38
View File
@@ -1,7 +1,12 @@
//! Deterministic, bounded diagram geometry. Only authored pins belong in the model;
//! callers share this derived geometry for drawing, picking, fitting and export.
use crate::projection::{self, ProjectedEdge, ProjectedNode, ProjectedView};
use crate::{Diagram, Id, Position, Project, Relation, RelationBody, RelationKind, Size};
use crate::{
Diagram, EdgeAttachment, EdgeEnd, Id, PortAnchorSide, Position, Project, Relation,
RelationBody, RelationKind, Size,
};
mod attachments;
pub use attachments::{attachment_from_point, attachment_point, semantic_port_endpoint};
use std::cmp::Ordering;
use std::collections::{BTreeMap, BTreeSet, BinaryHeap};
use std::fmt;
@@ -41,6 +46,7 @@ pub struct RoutedEdge {
// Derived solver state, never persisted. A preview must inherit the same
// remaining work budget as this edge had in stable-ID settled traversal.
search_budget_before: usize,
attachments: [Option<EdgeAttachment>; 2],
}
#[derive(Debug, Clone, PartialEq)]
@@ -173,6 +179,7 @@ pub fn association_end_geometry(
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RouteWarning {
CoincidentAttachments,
PinInsideObstacle { pin: usize, obstacle: Id },
NoClearPath,
SearchBudgetExhausted,
@@ -181,6 +188,9 @@ pub enum RouteWarning {
impl fmt::Display for RouteWarning {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::CoincidentAttachments => f.write_str(
"The two attachments coincide; move either end to separate the loop tips.",
),
Self::PinInsideObstacle { pin, .. } => {
write!(f, "Bend {} is inside a card or its clearance", pin + 1)
}
@@ -374,7 +384,14 @@ struct Endpoint {
point: Position,
stub: Position,
}
fn endpoints(node: &ProjectedNode, port: Option<Id>, view: &ProjectedView) -> Vec<Endpoint> {
fn endpoints(
project: &Project,
diagram: &Diagram,
node: &ProjectedNode,
port: Option<Id>,
view: &ProjectedView,
attachment: Option<EdgeAttachment>,
) -> Vec<Endpoint> {
if let Some(port) = port.and_then(|id| {
view.ports
.iter()
@@ -389,26 +406,18 @@ fn endpoints(node: &ProjectedNode, port: Option<Id>, view: &ProjectedView) -> Ve
),
}];
}
let r = Rect::node(node);
let mid = pos((r.left + r.right) / 2.0, (r.top + r.bottom) / 2.0);
vec![
Endpoint {
point: pos(r.left, mid.y),
stub: pos(r.left - PORT_STUB, mid.y),
},
Endpoint {
point: pos(r.right, mid.y),
stub: pos(r.right + PORT_STUB, mid.y),
},
Endpoint {
point: pos(mid.x, r.top),
stub: pos(mid.x, r.top - PORT_STUB),
},
Endpoint {
point: pos(mid.x, r.bottom),
stub: pos(mid.x, r.bottom + PORT_STUB),
},
if let Some(attachment) = attachment {
return vec![attachments::endpoint(project, diagram, node, attachment)];
}
[
PortAnchorSide::Left,
PortAnchorSide::Right,
PortAnchorSide::Top,
PortAnchorSide::Bottom,
]
.into_iter()
.map(|side| attachments::endpoint(project, diagram, node, EdgeAttachment { side, offset: 0.5 }))
.collect()
}
fn ancestors(id: Id, nodes: &BTreeMap<Id, &ProjectedNode>, out: &mut BTreeSet<Id>) {
@@ -507,6 +516,12 @@ fn route_diagram_impl(
.is_some_and(|(source, target)| source != edge.source || target != edge.target);
let custom_suspended = promoted;
let route = if promoted { None } else { stored };
let attachments = [EdgeEnd::Source, EdgeEnd::Target].map(|end| {
semantic_port_endpoint(project, edge.id, end)
.is_none()
.then(|| route.and_then(|r| r.attachment(end)))
.flatten()
});
let pins = route.map_or_else(Vec::new, |r| r.waypoints.clone());
let obstacles = edge_obstacles(edge, &nodes, &rectangles, &headers);
@@ -517,7 +532,10 @@ fn route_diagram_impl(
});
if let Some(cached) = cached {
budget = cached.search_budget_before;
if cached.pins == pins && cached.custom_suspended == custom_suspended {
if cached.pins == pins
&& cached.attachments == attachments
&& cached.custom_suspended == custom_suspended
{
// Label-only editing cannot change the connection beneath the
// pointer, including a settled fallback with routing warnings.
for &point in &cached.points {
@@ -543,9 +561,22 @@ fn route_diagram_impl(
}
let (points, pin_lengths, failure) = route_edge(
edge,
source,
target,
view,
&endpoints(
project,
diagram,
source,
semantic_port_endpoint(project, edge.id, EdgeEnd::Source).or(edge.source_port),
view,
attachments[0],
),
&endpoints(
project,
diagram,
target,
semantic_port_endpoint(project, edge.id, EdgeEnd::Target).or(edge.target_port),
view,
attachments[1],
),
&pins,
&obstacles,
&previous,
@@ -574,6 +605,7 @@ fn route_diagram_impl(
warnings,
custom_suspended,
search_budget_before,
attachments,
},
);
}
@@ -679,6 +711,46 @@ struct RoutingSpace<'a> {
xs: Vec<f32>,
ys: Vec<f32>,
}
fn coincident_detour(a: Endpoint, obstacles: &[Rect]) -> [Position; 3] {
let direction = |delta: f32| if delta == 0. { 0. } else { delta.signum() };
let normal = pos(
direction(a.stub.x - a.point.x),
direction(a.stub.y - a.point.y),
);
let tangent = pos(-normal.y, normal.x);
let candidates: Vec<_> = [36., 72., 144., 288.]
.into_iter()
.flat_map(|extent| {
[-1., 1.].into_iter().map(move |side| {
[
pos(a.stub.x + normal.x * extent, a.stub.y + normal.y * extent),
pos(
a.stub.x + (normal.x + tangent.x * side) * extent,
a.stub.y + (normal.y + tangent.y * side) * extent,
),
pos(
a.stub.x + tangent.x * side * extent,
a.stub.y + tangent.y * side * extent,
),
]
})
})
.collect();
// These are computed loop guards, not authored hard pins. Prefer a clear
// orientation/size before asking the bounded router to connect them.
candidates
.iter()
.find(|p| clear_path(&[a.stub, p[0], p[1], p[2], a.stub], obstacles))
.or_else(|| {
candidates.iter().find(|p| {
p.iter()
.all(|point| !obstacles.iter().any(|r| r.contains(*point)))
})
})
.copied()
.unwrap_or(candidates[0])
}
impl<'a> RoutingSpace<'a> {
fn new(obstacles: &'a [Rect]) -> Self {
Self {
@@ -692,24 +764,41 @@ impl<'a> RoutingSpace<'a> {
#[allow(clippy::too_many_arguments)]
fn route_edge(
edge: &ProjectedEdge,
source: &ProjectedNode,
target: &ProjectedNode,
view: &ProjectedView,
source_ends: &[Endpoint],
target_ends: &[Endpoint],
pins: &[Position],
obstacles: &[Rect],
previous: &[Segment],
budget: &mut usize,
) -> EdgePath {
// Coincident authored tips are valid presentation, including on self loops.
// Preserve them and add an external detour; never index an empty candidate
// list or turn the connection into an out-and-back degenerate stub.
let coincident = edge.source == edge.target
&& source_ends.len() == 1
&& target_ends.len() == 1
&& source_ends[0].point == target_ends[0].point
&& source_ends[0].stub == target_ends[0].stub;
let authored_pin_count = pins.len();
let mut loop_pins = Vec::new();
if coincident {
let a = source_ends[0];
loop_pins.extend_from_slice(pins);
loop_pins.extend(coincident_detour(a, obstacles));
}
let pins = if coincident { &loop_pins[..] } else { pins };
let space = RoutingSpace::new(obstacles);
let mut candidates = Vec::new();
for a in endpoints(source, edge.source_port, view) {
for b in endpoints(target, edge.target_port, view) {
for &a in source_ends {
for &b in target_ends {
// A real self-relation must leave and return at distinct anchors.
// Reusing one anchor makes the cheapest path a degenerate stub.
if edge.source == edge.target
&& edge.source_port.is_none()
&& edge.target_port.is_none()
&& a.point == b.point
&& a.stub == b.stub
&& !coincident
{
continue;
}
@@ -729,10 +818,9 @@ fn route_edge(
// Try all side combinations cheaply before spending the bounded grid budget.
for search in [false, true] {
for &(estimate, a, b) in &candidates {
if best
.as_ref()
.is_some_and(|(cost, _, _)| estimate + 2.0 * PORT_STUB > *cost)
{
if best.as_ref().is_some_and(|(cost, _, _)| {
estimate + manhattan(a.point, a.stub) + manhattan(b.point, b.stub) > *cost
}) {
continue;
}
if obstacles
@@ -748,7 +836,7 @@ fn route_edge(
let mut occupied = previous.to_vec();
record_segments(&mut occupied, &points);
let mut pin_lengths = Vec::new();
let mut distance = PORT_STUB;
let mut distance = manhattan(a.point, a.stub);
let mut start = a.stub;
let mut failed = false;
for (index, end) in pins
@@ -792,15 +880,20 @@ fn route_edge(
break;
}
}
if let Some((_, points, pin_lengths)) = best {
return (points, pin_lengths, None);
if let Some((_, points, mut pin_lengths)) = best {
pin_lengths.truncate(authored_pin_count);
return (
points,
pin_lengths,
coincident.then_some(RouteWarning::CoincidentAttachments),
);
}
// Recoverable fallback retains every hard pin, in order, and the actual port
// leads. Its warning is deliberately visible to consumers.
let (_, a, b) = candidates[0];
let mut points = vec![a.point, a.stub];
let mut pin_lengths = Vec::new();
let mut distance = PORT_STUB;
let mut distance = manhattan(a.point, a.stub);
let mut start = a.stub;
for (index, end) in pins
.iter()
@@ -833,6 +926,7 @@ fn route_edge(
start = end;
}
points.push(b.point);
pin_lengths.truncate(authored_pin_count);
(
points,
pin_lengths,
+160
View File
@@ -0,0 +1,160 @@
//! One boundary definition shared by persisted routing and attachment gestures.
use super::*;
use crate::{EdgeAttachment, EdgeEnd, Kind, PortAnchorSide};
/// Semantic authority, independent of whether projection currently exposes a port.
pub fn semantic_port_endpoint(project: &Project, edge: Id, end: EdgeEnd) -> Option<Id> {
let endpoint = if let Some(relation) = project.relations.get(&edge) {
match (&relation.body, end) {
(RelationBody::FunctionalExchange { source_port, .. }, EdgeEnd::Source) => *source_port,
(RelationBody::FunctionalExchange { target_port, .. }, EdgeEnd::Target) => *target_port,
(_, EdgeEnd::Source) => relation.source(),
(_, EdgeEnd::Target) => relation.target(),
}
} else {
let trace = project.traces.get(&edge)?;
match end {
EdgeEnd::Source => trace.oa,
EdgeEnd::Target => trace.sa,
}
};
project
.elements
.get(&endpoint)
.filter(|e| e.kind() == Kind::Port)
.map(|_| endpoint)
}
/// Intersect the chosen cardinal scan line with the painted shape. Offset is
/// measured over the complete projected bounding side, including its corners.
/// Terminate's X uses its outermost stroke intersection rather than a contour.
pub fn attachment_point(
project: &Project,
diagram: &Diagram,
node: &ProjectedNode,
attachment: &EdgeAttachment,
) -> Position {
let vertical = matches!(
attachment.side,
PortAnchorSide::Left | PortAnchorSide::Right
);
let (extent, depth) = if vertical {
(node.size.height, node.size.width)
} else {
(node.size.width, node.size.height)
};
let along = attachment.offset.clamp(0., 1.) * extent;
let t = 2. * attachment.offset.clamp(0., 1.) - 1.;
let kind = diagram
.projection
.state_machine()
.and_then(|_| project.elements.get(&node.element))
.map(|e| e.kind());
let inset = match kind {
Some(Kind::Choice) => depth * 0.5 * t.abs(),
Some(Kind::Terminate) => {
depth
* attachment
.offset
.clamp(0., 1.)
.min(1. - attachment.offset.clamp(0., 1.))
}
Some(
Kind::Initial
| Kind::Junction
| Kind::Final
| Kind::ShallowHistory
| Kind::DeepHistory
| Kind::EntryPoint
| Kind::ExitPoint,
) => depth * 0.5 * (1. - (1. - t * t).max(0.).sqrt()),
Some(Kind::Region | Kind::Fork | Kind::Join) => 0.,
_ => {
let radius = 10_f32.min(extent / 2.).min(depth / 2.);
let corner = (radius - along.min(extent - along)).max(0.);
radius - (radius * radius - corner * corner).max(0.).sqrt()
}
};
match attachment.side {
PortAnchorSide::Left => pos(node.position.x + inset, node.position.y + along),
PortAnchorSide::Right => pos(
node.position.x + node.size.width - inset,
node.position.y + along,
),
PortAnchorSide::Top => pos(node.position.x + along, node.position.y + inset),
PortAnchorSide::Bottom => pos(
node.position.x + along,
node.position.y + node.size.height - inset,
),
}
}
/// Choose a cardinal bounding side, then map its fraction onto the painted
/// contour. Contour distances cannot distinguish the sides of a circle at its
/// extrema: Right/0 and Top/0.5 denote the same point but different lead directions.
/// Hysteresis is in diagram units, capped for small symbols. Only UI snapping
/// rounds to hundredths: source/API values retain their complete precision.
pub fn attachment_from_point(
node: &ProjectedNode,
cursor: Position,
previous: Option<PortAnchorSide>,
hysteresis: f32,
) -> EdgeAttachment {
let candidate = |side| EdgeAttachment {
side,
offset: projection::port_anchor_offset(node, side, cursor),
};
// Measure side intent at the box, not from a distant overshoot: Euclidean
// distance differences converge outside a small symbol and would otherwise
// let hysteresis retain a perpendicular side indefinitely.
let boundary_cursor = pos(
cursor
.x
.clamp(node.position.x, node.position.x + node.size.width),
cursor
.y
.clamp(node.position.y, node.position.y + node.size.height),
);
let distance = |side| {
length(
projection::port_anchor_position(node, side, candidate(side).offset),
boundary_cursor,
)
};
let mut side = [
PortAnchorSide::Left,
PortAnchorSide::Right,
PortAnchorSide::Top,
PortAnchorSide::Bottom,
]
.into_iter()
.min_by(|a, b| distance(*a).total_cmp(&distance(*b)))
.unwrap();
let hysteresis = hysteresis
.max(0.)
.min(node.size.width.min(node.size.height) * 0.2);
if let Some(old) = previous
&& distance(old) <= distance(side) + hysteresis
{
side = old;
}
let mut attachment = candidate(side);
attachment.offset = (attachment.offset * 100.).round() / 100.;
attachment
}
pub(super) fn endpoint(
project: &Project,
diagram: &Diagram,
node: &ProjectedNode,
attachment: EdgeAttachment,
) -> Endpoint {
let point = attachment_point(project, diagram, node, &attachment);
let stub = match attachment.side {
PortAnchorSide::Left => pos(node.position.x - PORT_STUB, point.y),
PortAnchorSide::Right => pos(node.position.x + node.size.width + PORT_STUB, point.y),
PortAnchorSide::Top => pos(point.x, node.position.y - PORT_STUB),
PortAnchorSide::Bottom => pos(point.x, node.position.y + node.size.height + PORT_STUB),
};
Endpoint { point, stub }
}
+18 -1
View File
@@ -93,6 +93,18 @@ fn restore_serialization_skips(context: Context, fields: &mut Map<String, Value>
.or_insert(Value::Null);
}
}
Context::Diagram => {
if let Some(routes) = fields.get_mut("routes").and_then(Value::as_array_mut) {
for route in routes.iter_mut().filter_map(Value::as_object_mut) {
route
.entry("source_attachment".to_owned())
.or_insert(Value::Null);
route
.entry("target_attachment".to_owned())
.or_insert(Value::Null);
}
}
}
Context::Node => {
fields.entry("port_anchors".to_owned()).or_insert(json!([]));
}
@@ -244,7 +256,12 @@ fn compact_nested(context: Context, fields: &mut Map<String, Value>) {
if let Some(routes) = fields.get_mut("routes").and_then(Value::as_array_mut) {
for route in routes {
if let Some(route) = route.as_object_mut() {
omit_defaults(route, &object(json!({"waypoints": [], "label": null})));
omit_defaults(
route,
&object(
json!({"waypoints": [], "label": null, "source_attachment": null, "target_attachment": null}),
),
);
}
}
}
+14
View File
@@ -643,6 +643,20 @@ pub fn validate(p: &Project) -> Vec<Issue> {
}
let mut routed_edges = BTreeSet::new();
for (index, route) in d.routes.iter().enumerate() {
for attachment in [route.source_attachment, route.target_attachment]
.into_iter()
.flatten()
{
if !attachment.offset.is_finite() || !(0.0..=1.0).contains(&attachment.offset) {
error(
&mut out,
d.id,
"invalid_attachment_offset",
"routes",
"Connection attachment offset must be finite and between zero and one.",
);
}
}
if !routed_edges.insert(route.edge) {
error(
&mut out,
+385
View File
@@ -0,0 +1,385 @@
use oa_core::{projection::*, routing::*, *};
fn pos(x: f32, y: f32) -> Position {
Position { x, y }
}
fn anchor(side: PortAnchorSide, offset: f32) -> EdgeAttachment {
EdgeAttachment { side, offset }
}
fn fixture() -> (Project, Id, Id, Id, Id) {
let mut p = Project::empty();
let a = p
.add_element(Kind::Activity, Layer::Oa, "Report", None)
.unwrap();
let b = p
.add_element(Kind::Activity, Layer::Oa, "Receive", None)
.unwrap();
let edge = p.relate(RelationKind::Interaction, "Report status", a, b);
let d = p.ensure_diagram(Layer::Oa);
p.place(d, a, pos(50., 50.));
p.place(d, b, pos(750., 200.));
(p, d, a, b, edge)
}
fn stored(edge: Id) -> EdgeRoute {
EdgeRoute {
edge,
source_attachment: None,
target_attachment: None,
waypoints: vec![],
label: None,
}
}
fn routed(p: &Project, d: Id) -> RoutedView {
let diagram = &p.diagrams[&d];
route_diagram(p, diagram, &project_diagram(p, diagram))
}
fn near(a: f32, b: f32) {
assert!((a - b).abs() < 0.002, "{a} != {b}");
}
fn orthogonal(points: &[Position]) {
assert!(points.len() >= 2);
for span in points.windows(2) {
assert!(span[0].x == span[1].x || span[0].y == span[1].y, "{span:?}");
}
}
fn length(points: &[Position]) -> f32 {
points
.windows(2)
.map(|p| (p[0].x - p[1].x).abs() + (p[0].y - p[1].y).abs())
.sum()
}
#[test]
fn authored_boundary_follows_projected_owner_and_preview_matches_committed_geometry() {
let (mut p, d, a, _, edge) = fixture();
let initial = routed(&p, d);
let mut route = stored(edge);
route.source_attachment = Some(anchor(PortAnchorSide::Bottom, 0.25));
route.target_attachment = Some(anchor(PortAnchorSide::Top, 0.75));
route.waypoints = vec![pos(460., 350.)];
p.diagrams.get_mut(&d).unwrap().routes.push(route.clone());
let view = project_diagram(&p, &p.diagrams[&d]);
let preview = route_edge_preview(&p, &p.diagrams[&d], &view, &initial, edge);
let full = routed(&p, d);
assert_eq!(
preview, full,
"attachment-only changes must invalidate the label-only path cache"
);
let n = view.nodes.iter().find(|n| n.element == a).unwrap();
assert_eq!(
full.edges[&edge].points[0],
pos(
n.position.x + n.size.width * 0.25,
n.position.y + n.size.height
)
);
assert_eq!(full.edges[&edge].pins, route.waypoints);
orthogonal(&full.edges[&edge].points);
let node = p
.diagrams
.get_mut(&d)
.unwrap()
.nodes
.values_mut()
.find(|n| n.element == a)
.unwrap();
node.position.x += 75.;
node.size.width += 100.;
node.size.height += 50.;
let moved = routed(&p, d);
near(
moved.edges[&edge].points[0].x - full.edges[&edge].points[0].x,
100.,
);
near(
moved.edges[&edge].points[0].y - full.edges[&edge].points[0].y,
50.,
);
assert_eq!(p.diagrams[&d].routes[0], route);
}
#[test]
fn attachment_only_preview_cannot_reuse_old_endpoints_with_unchanged_pins() {
let (mut p, d, _, _, edge) = fixture();
let baseline = routed(&p, d);
let mut route = stored(edge);
route.source_attachment = Some(anchor(PortAnchorSide::Top, 0.2));
p.diagrams.get_mut(&d).unwrap().routes.push(route);
let view = project_diagram(&p, &p.diagrams[&d]);
let preview = route_edge_preview(&p, &p.diagrams[&d], &view, &baseline, edge);
assert_ne!(preview.edges[&edge].points, baseline.edges[&edge].points);
assert_eq!(preview, routed(&p, d));
}
#[test]
fn ports_remain_authoritative_even_when_projection_filters_the_port() {
let mut p = Project::empty();
let a = p
.add_element(Kind::Function, Layer::Sa, "Send", None)
.unwrap();
let b = p
.add_element(Kind::Function, Layer::Sa, "Receive", None)
.unwrap();
let edge = p.relate(RelationKind::FunctionalExchange, "Data", a, b);
let d = p.ensure_diagram(Layer::Sa);
p.place(d, a, pos(0., 0.));
p.place(d, b, pos(600., 0.));
let diagram = p.diagrams[&d].clone();
let view = project_diagram(&p, &diagram);
let expected = route_diagram(&p, &diagram, &view);
assert!(semantic_port_endpoint(&p, edge, EdgeEnd::Source).is_some());
let mut route = stored(edge);
route.source_attachment = Some(anchor(PortAnchorSide::Bottom, 0.1));
route.target_attachment = Some(anchor(PortAnchorSide::Top, 0.9));
p.diagrams.get_mut(&d).unwrap().routes.push(route);
assert_eq!(routed(&p, d), expected);
let mut hidden = view.clone();
hidden.ports.clear();
for e in &mut hidden.edges {
e.source_port = None;
e.target_port = None;
}
let absent = route_diagram(&p, &diagram, &hidden);
assert_eq!(
route_diagram(&p, &p.diagrams[&d], &hidden),
absent,
"hidden ports must not activate a dormant node preference"
);
}
fn state(kind: Kind) -> (Project, Id, Id, Id) {
let mut p = Project::empty();
let owner = p
.add_element(Kind::Entity, Layer::Oa, "Crew", None)
.unwrap();
let machine = p
.add_element(Kind::StateMachine, Layer::Oa, "Modes", Some(owner))
.unwrap();
let region = p
.add_element(Kind::Region, Layer::Oa, "Main", Some(machine))
.unwrap();
let a = p.add_element(kind, Layer::Oa, "A", Some(region)).unwrap();
let b = p
.add_element(Kind::State, Layer::Oa, "B", Some(region))
.unwrap();
let edge = p.relate(RelationKind::StateTransition, "Next", a, b);
let d = p.ensure_diagram(Layer::Oa);
p.diagrams.get_mut(&d).unwrap().projection = ProjectionKind::StateMachine { machine };
p.place(d, a, pos(100., 100.));
p.place(d, b, pos(600., 100.));
(p, d, a, edge)
}
#[test]
fn nonrectangular_state_attachments_intersect_the_painted_shape() {
for kind in [
Kind::Initial,
Kind::Junction,
Kind::Final,
Kind::Choice,
Kind::ShallowHistory,
Kind::DeepHistory,
Kind::EntryPoint,
Kind::ExitPoint,
Kind::Terminate,
Kind::Fork,
Kind::Join,
Kind::State,
] {
let (p, d, a, _) = state(kind);
let diagram = &p.diagrams[&d];
let view = project_diagram(&p, diagram);
let n = view.nodes.iter().find(|n| n.element == a).unwrap();
for side in [
PortAnchorSide::Left,
PortAnchorSide::Right,
PortAnchorSide::Top,
PortAnchorSide::Bottom,
] {
for offset in [0., 0.2, 0.5, 0.8, 1.] {
let at = attachment_point(&p, diagram, n, &anchor(side, offset));
let x = (at.x - n.position.x) / n.size.width;
let y = (at.y - n.position.y) / n.size.height;
match kind {
Kind::Choice => near((2. * x - 1.).abs() + (2. * y - 1.).abs(), 1.),
Kind::Terminate => near((x - y).abs().min((x + y - 1.).abs()), 0.),
Kind::Fork | Kind::Join => near(x.min(1. - x).min(y.min(1. - y)), 0.),
Kind::State => {
assert!((0. ..=1.).contains(&x) && (0. ..=1.).contains(&y));
if offset == 0. || offset == 1. {
assert!(
x != 0. && x != 1. || y != 0. && y != 1.,
"rounded corner cannot use bounding-box corner"
);
}
}
_ => near((2. * x - 1.).powi(2) + (2. * y - 1.).powi(2), 1.),
}
}
}
}
}
#[test]
fn inset_contour_leads_keep_pin_fractions_and_labels_on_the_complete_path() {
let (mut p, d, _, edge) = state(Kind::Choice);
let mut route = stored(edge);
route.source_attachment = Some(anchor(PortAnchorSide::Right, 0.));
route.waypoints = vec![pos(380., 40.)];
route.label = Some(EdgeLabel {
fraction: 0.3,
offset: pos(0., 0.),
});
p.diagrams.get_mut(&d).unwrap().routes.push(route);
let result = routed(&p, d);
let r = &result.edges[&edge];
orthogonal(&r.points);
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
let pin = r.pins[0];
let pin_index = r.points.iter().position(|p| *p == pin).unwrap();
near(
r.pin_fractions[0],
length(&r.points[..=pin_index]) / length(&r.points),
);
assert_eq!(
r.label.anchor,
point_along_polyline(&r.points, 0.3).unwrap()
);
}
#[test]
fn small_symbol_drag_chooses_the_intended_exit_side_at_low_zoom() {
for kind in [
Kind::Initial,
Kind::Choice,
Kind::Terminate,
Kind::Fork,
Kind::State,
] {
let (p, d, a, _) = state(kind);
let view = project_diagram(&p, &p.diagrams[&d]);
let n = view.nodes.iter().find(|n| n.element == a).unwrap();
for zoom in [0.5, 1., 2.] {
for overshoot in [0., 20.] {
let cursor = pos(n.position.x + n.size.width / 2., n.position.y - overshoot);
let chosen =
attachment_from_point(n, cursor, Some(PortAnchorSide::Right), 6. / zoom);
assert_eq!(
chosen,
anchor(PortAnchorSide::Top, 0.5),
"{kind:?}, zoom {zoom}, overshoot {overshoot}"
);
let painted = attachment_point(&p, &p.diagrams[&d], n, &chosen);
near(painted.x, n.position.x + n.size.width / 2.);
near(
painted.y,
n.position.y
+ if kind == Kind::Terminate {
n.size.height / 2.
} else {
0.
},
);
}
let near_corner = pos(n.position.x + n.size.width - 1., n.position.y + 1.);
let stable =
attachment_from_point(n, near_corner, Some(PortAnchorSide::Right), 6. / zoom);
assert_eq!(
stable.side,
PortAnchorSide::Right,
"corner hysteresis stays stable"
);
}
}
}
#[test]
fn coincident_self_attachments_retain_hard_pins_and_form_a_visible_loop() {
let (mut p, d, a, edge) = state(Kind::State);
if let RelationBody::StateTransition { target, .. } =
&mut p.relations.get_mut(&edge).unwrap().body
{
*target = a;
}
for pins in [vec![], vec![pos(400., 30.)]] {
let mut route = stored(edge);
route.source_attachment = Some(anchor(PortAnchorSide::Top, 0.5));
route.target_attachment = route.source_attachment;
route.waypoints = pins.clone();
p.diagrams.get_mut(&d).unwrap().routes = vec![route.clone()];
let result = routed(&p, d);
let r = &result.edges[&edge];
orthogonal(&r.points);
assert_eq!(r.points.first(), r.points.last());
assert!(length(&r.points) > 2. * PORT_STUB + 70.);
assert_eq!(r.pins, pins);
assert_eq!(r.pin_fractions.len(), pins.len());
assert!(
r.warnings.contains(&RouteWarning::CoincidentAttachments),
"{:?}",
r.warnings
);
assert_eq!(routed(&p, d), result, "deterministic fallback");
assert_eq!(p.diagrams[&d].routes[0], route);
}
}
#[test]
fn coincident_loop_chooses_clear_side_instead_of_inventing_an_obstructed_pin() {
for blocker_side in [-1., 1.] {
let (mut p, d, a, edge) = state(Kind::State);
if let RelationBody::StateTransition { target, .. } =
&mut p.relations.get_mut(&edge).unwrap().body
{
*target = a;
}
let mut route = stored(edge);
route.source_attachment = Some(anchor(PortAnchorSide::Top, 0.5));
route.target_attachment = route.source_attachment;
p.diagrams.get_mut(&d).unwrap().routes.push(route);
let view = project_diagram(&p, &p.diagrams[&d]);
let source = view.nodes.iter().find(|n| n.element == a).unwrap();
let tip = attachment_point(
&p,
&p.diagrams[&d],
source,
&anchor(PortAnchorSide::Top, 0.5),
);
let region = p.elements[&a].parent;
let blocker = p
.add_element(Kind::State, Layer::Oa, "Detour obstruction", region)
.unwrap();
let left = if blocker_side > 0. {
tip.x + 30.
} else {
tip.x - 30. - 224.
};
p.place(d, blocker, pos(left, tip.y - 144.));
let result = routed(&p, d);
let r = &result.edges[&edge];
assert_eq!(r.warnings, vec![RouteWarning::CoincidentAttachments]);
orthogonal(&r.points);
let view = project_diagram(&p, &p.diagrams[&d]);
let n = view.nodes.iter().find(|n| n.element == blocker).unwrap();
for segment in r.points.windows(2) {
for step in 0..=100 {
let t = step as f32 / 100.;
let q = pos(
segment[0].x + (segment[1].x - segment[0].x) * t,
segment[0].y + (segment[1].y - segment[0].y) * t,
);
assert!(
!(q.x > n.position.x - CLEARANCE
&& q.x < n.position.x + n.size.width + CLEARANCE
&& q.y > n.position.y - CLEARANCE
&& q.y < n.position.y + n.size.height + CLEARANCE),
"route crosses blocker: {q:?}"
);
}
}
assert!(
r.pins.is_empty(),
"generated detours must never become authored pins"
);
}
}
@@ -0,0 +1,272 @@
use oa_core::{language, projection::*, routing::*, *};
fn pos(x: f32, y: f32) -> Position {
Position { x, y }
}
fn id(project: &Project, alias: &str) -> Id {
project
.identities()
.into_iter()
.find(|(_, a)| *a == alias)
.unwrap()
.0
}
fn authored(edge: Id) -> EdgeRoute {
EdgeRoute {
edge,
source_attachment: Some(EdgeAttachment {
side: PortAnchorSide::Bottom,
offset: 0.2,
}),
target_attachment: Some(EdgeAttachment {
side: PortAnchorSide::Top,
offset: 0.8,
}),
waypoints: vec![pos(520., 420.)],
label: Some(EdgeLabel {
fraction: 0.35,
offset: pos(17., -23.),
}),
}
}
fn routed(project: &Project, diagram: Id) -> RoutedView {
let d = &project.diagrams[&diagram];
route_diagram(project, d, &project_diagram(project, d))
}
fn fixture() -> (Project, Id, Id, Id, Id, Id, Id) {
let mut p = language::parse(
r#"arcadia 7;
project lifecycle "Attachment lifecycle" {
oa_activity group "Group" { oa_activity first "First" {} }
oa_activity second "Second" {}
oa_activity replacement "Replacement" {}
interaction signal "Signal" { source @first; target @second; }
diagram view "View" {}
diagram other "Other" {}
}"#,
)
.unwrap();
let (d, other, group, first, second, replacement, edge) = (
id(&p, "view"),
id(&p, "other"),
id(&p, "group"),
id(&p, "first"),
id(&p, "second"),
id(&p, "replacement"),
id(&p, "signal"),
);
for diagram in [d, other] {
p.place(diagram, group, pos(20., 20.));
p.place(diagram, first, pos(100., 130.));
p.place(diagram, second, pos(750., 150.));
p.place(diagram, replacement, pos(750., 570.));
p.diagrams
.get_mut(&diagram)
.unwrap()
.routes
.push(authored(edge));
}
(p, d, other, group, first, replacement, edge)
}
#[test]
fn attachment_fold_promotion_suspends_the_complete_route_and_expand_restores_it() {
let (mut p, d, other, group, _, _, edge) = fixture();
assert_eq!(semantic_port_endpoint(&p, edge, EdgeEnd::Source), None);
assert_eq!(semantic_port_endpoint(&p, edge, EdgeEnd::Target), None);
let before = p.clone();
let expanded = routed(&p, d);
let other_geometry = routed(&p, other);
let parent = p.diagrams[&d]
.nodes
.values()
.find(|n| n.element == group)
.unwrap()
.id;
p.diagrams
.get_mut(&d)
.unwrap()
.nodes
.get_mut(&parent)
.unwrap()
.collapsed = true;
let folded = routed(&p, d);
assert!(folded.edges[&edge].custom_suspended);
assert_eq!(folded.edges[&edge].pins, []);
assert!(
folded.edges[&edge]
.warnings
.contains(&RouteWarning::PromotedEndpoints)
);
assert_eq!(p.diagrams[&d].routes, before.diagrams[&d].routes);
let mut automatic = p.clone();
automatic.diagrams.get_mut(&d).unwrap().routes.clear();
assert_eq!(
folded,
routed(&automatic, d),
"attachments, pins and label must all be dormant on promotion"
);
assert_eq!(p.diagrams[&other], before.diagrams[&other]);
assert_eq!(routed(&p, other), other_geometry);
p.diagrams
.get_mut(&d)
.unwrap()
.nodes
.get_mut(&parent)
.unwrap()
.collapsed = false;
assert_eq!(p, before);
assert_eq!(routed(&p, d), expanded);
}
#[test]
fn attachment_reconnect_retains_slot_preferences_and_other_view_records_with_exact_history() {
let (p, d, other, _, _, replacement, edge) = fixture();
let before = routed(&p, d);
let mut relation = p.relations[&edge].clone();
let RelationBody::Interaction { target, .. } = &mut relation.body else {
unreachable!()
};
*target = replacement;
let mut expected = p.clone();
expected.relations.insert(edge, relation.clone());
let mut session = Session::new(p.clone());
session
.execute(Command::EditRelation(edge, relation))
.unwrap();
assert_eq!(
session.project(),
&expected,
"reconnect changes only the semantic endpoint; all per-view records remain exact"
);
assert_eq!(session.project().diagrams[&other], p.diagrams[&other]);
for diagram in [d, other] {
let current = session.project();
let view = project_diagram(current, &current.diagrams[&diagram]);
let node = view
.nodes
.iter()
.find(|n| n.element == replacement)
.unwrap();
let preference = current.diagrams[&diagram].routes[0]
.target_attachment
.unwrap();
let expected_tip =
attachment_point(current, &current.diagrams[&diagram], node, &preference);
let result = routed(current, diagram);
assert_eq!(result.edges[&edge].points.last(), Some(&expected_tip));
assert_eq!(
result.edges[&edge].pins,
p.diagrams[&diagram].routes[0].waypoints
);
}
assert_ne!(
routed(session.project(), d).edges[&edge].points.last(),
before.edges[&edge].points.last()
);
session.undo();
assert_eq!(session.project(), &p);
assert_eq!(routed(session.project(), d), before);
session.redo();
assert_eq!(session.project(), &expected);
}
#[test]
fn attachment_direct_port_endpoints_remain_authoritative_when_their_glyphs_are_filtered() {
let mut p = Project::empty();
let system = p
.add_element(Kind::System, Layer::Sa, "System", None)
.unwrap();
let actor = p
.add_element(Kind::SystemActor, Layer::Sa, "Actor", None)
.unwrap();
let function = p
.add_element(Kind::Function, Layer::Sa, "Send", None)
.unwrap();
let mut ports = Vec::new();
for (owner, name, contract) in [
(
system,
"Output",
PortContract::Component {
kind: ComponentPortKind::Flow,
orientation: ComponentPortOrientation::Out,
},
),
(
actor,
"Input",
PortContract::Component {
kind: ComponentPortKind::Flow,
orientation: ComponentPortOrientation::In,
},
),
(
function,
"Function output",
PortContract::FunctionOutput {
outgoing_items: vec![],
},
),
] {
let port = Id::new_v4();
p.elements
.insert(port, make_port(&p, port, owner, name, contract).unwrap());
ports.push(port);
}
p.relate(
RelationKind::Allocation,
"Function allocation",
system,
function,
);
let boundary = p.relate(
RelationKind::BoundaryExchange,
"Boundary",
ports[0],
ports[1],
);
let allocation = p.relate(
RelationKind::PortAllocation,
"Port allocation",
ports[0],
ports[2],
);
let d = p.ensure_diagram(Layer::Sa);
p.place(d, system, pos(0., 0.));
p.place(d, actor, pos(700., 0.));
p.place(d, function, pos(0., 450.));
let original = p.clone();
let baseline = routed(&p, d);
for (edge, target) in [(boundary, ports[1]), (allocation, ports[2])] {
assert!(baseline.edges.contains_key(&edge));
assert_eq!(
semantic_port_endpoint(&p, edge, EdgeEnd::Source),
Some(ports[0])
);
assert_eq!(
semantic_port_endpoint(&p, edge, EdgeEnd::Target),
Some(target)
);
let mut route = authored(edge);
route.waypoints.clear();
route.label = None;
p.diagrams.get_mut(&d).unwrap().routes.push(route);
}
assert_eq!(routed(&p, d), baseline);
let mut hidden = project_diagram(&p, &p.diagrams[&d]);
hidden.ports.clear();
for edge in &mut hidden.edges {
edge.source_port = None;
edge.target_port = None;
}
assert_eq!(
route_diagram(&p, &p.diagrams[&d], &hidden),
route_diagram(&original, &original.diagrams[&d], &hidden),
"filtered direct Port endpoints must not activate their stored preferences"
);
}
@@ -0,0 +1,136 @@
use oa_core::{projection::*, routing::*, *};
#[test]
fn valid_item_trace_follows_manual_port_anchors_and_ignores_dormant_attachments() {
let mut project = Project::empty();
let oa = project
.add_element(Kind::Activity, Layer::Oa, "Send", None)
.unwrap();
let sa = project
.add_element(Kind::Function, Layer::Sa, "Receive", None)
.unwrap();
let ports = [(oa, Layer::Oa), (sa, Layer::Sa)].map(|(owner, layer)| {
let id = Id::new_v4();
project.elements.insert(
id,
Element {
id,
alias: format!("port_{}", id.simple()),
name: "Status port".into(),
description: "Existing legacy port retained by Item trace".into(),
parent: Some(owner),
body: ElementBody::shared_in(
layer,
SharedBody::Port {
contract: PortContract::Legacy,
legacy: Some(LegacyPortContract {
direction: PortDirection::InOut,
interface: None,
}),
},
),
},
);
id
});
let trace = Trace {
id: Id::new_v4(),
alias: "status_correspondence".into(),
name: "Status correspondence".into(),
description: "Preserve this semantic evidence".into(),
oa: ports[0],
sa: ports[1],
kind: TraceKind::Item,
baseline: None,
};
project.traces.insert(trace.id, trace.clone());
let diagram = project.ensure_diagram(Layer::Oa);
project.diagrams.get_mut(&diagram).unwrap().projection = ProjectionKind::Traceability;
let owners = [
project.place(diagram, oa, Position { x: 100., y: 100. }),
project.place(diagram, sa, Position { x: 800., y: 300. }),
];
validate_candidate(&project).unwrap();
let semantic_elements = project.elements.clone();
let semantic_relations = project.relations.clone();
let semantic_traces = project.traces.clone();
let view = project_diagram(&project, &project.diagrams[&diagram]);
let edge = view.edges.iter().find(|edge| edge.id == trace.id).unwrap();
assert!(edge.trace);
assert_eq!((edge.source, edge.target), (owners[0], owners[1]));
assert_eq!((edge.source_port, edge.target_port), (None, None));
let initial = route_diagram(&project, &project.diagrams[&diagram], &view);
for (index, (side, offset)) in [(PortAnchorSide::Top, 0.25), (PortAnchorSide::Bottom, 0.75)]
.into_iter()
.enumerate()
{
project
.diagrams
.get_mut(&diagram)
.unwrap()
.nodes
.get_mut(&owners[index])
.unwrap()
.port_anchors
.push(PortAnchor {
port: ports[index],
side,
offset,
});
}
validate_candidate(&project).unwrap();
let moved_view = project_diagram(&project, &project.diagrams[&diagram]);
let moved = route_diagram(&project, &project.diagrams[&diagram], &moved_view);
for index in 0..2 {
let marker = moved_view
.ports
.iter()
.find(|port| port.element == ports[index] && port.owner == owners[index])
.unwrap();
let points = &moved.edges[&trace.id].points;
let tip = if index == 0 {
points[0]
} else {
*points.last().unwrap()
};
assert_eq!(tip, marker.position);
let old = &initial.edges[&trace.id].points;
assert_ne!(
tip,
if index == 0 {
old[0]
} else {
*old.last().unwrap()
}
);
}
project
.diagrams
.get_mut(&diagram)
.unwrap()
.routes
.push(EdgeRoute {
edge: trace.id,
waypoints: vec![],
label: None,
source_attachment: Some(EdgeAttachment {
side: PortAnchorSide::Bottom,
offset: 0.9,
}),
target_attachment: Some(EdgeAttachment {
side: PortAnchorSide::Top,
offset: 0.1,
}),
});
validate_candidate(&project).unwrap();
let dormant_view = project_diagram(&project, &project.diagrams[&diagram]);
assert_eq!(dormant_view, moved_view);
assert_eq!(
route_diagram(&project, &project.diagrams[&diagram], &dormant_view),
moved
);
assert_eq!(project.elements, semantic_elements);
assert_eq!(project.relations, semantic_relations);
assert_eq!(project.traces, semantic_traces);
assert_eq!(project.traces[&trace.id], trace);
}
+154
View File
@@ -0,0 +1,154 @@
//! Route the exact native acceptance fixture and intended saved checkpoints.
use oa_core::{projection::project_diagram, routing::route_diagram, *};
const FIXTURE: &str =
include_str!("../../../harness/experiments/edge-attachments-20260922/native-fixture.arcadia");
fn id(project: &Project, alias: &str) -> Id {
project
.identities()
.into_iter()
.find(|(_, name)| *name == alias)
.unwrap()
.0
}
fn set(
project: &mut Project,
view: &str,
edge: &str,
end: EdgeEnd,
attachment: Option<(PortAnchorSide, f32)>,
) {
let view = id(project, view);
let edge = id(project, edge);
let route = project
.diagrams
.get_mut(&view)
.unwrap()
.routes
.iter_mut()
.find(|route| route.edge == edge)
.unwrap();
route.set_attachment(
end,
attachment.map(|(side, offset)| EdgeAttachment { side, offset }),
);
}
fn clear(project: &Project, checkpoint: &str) {
validate_candidate(project).unwrap();
for (view, edges) in [
("dispatch_view", &["dispatch_request"][..]),
("readiness_view", &["activate", "initialize", "finish"][..]),
] {
let diagram = &project.diagrams[&id(project, view)];
let routed = route_diagram(project, diagram, &project_diagram(project, diagram));
for alias in edges {
let edge = &routed.edges[&id(project, alias)];
assert!(
edge.warnings.is_empty(),
"{checkpoint}/{view}/{alias}: {:?}",
edge.warnings
);
let stored = diagram
.routes
.iter()
.find(|r| r.edge == id(project, alias))
.unwrap();
assert_eq!(
edge.pins, stored.waypoints,
"{checkpoint}/{alias}: manual pins retained"
);
}
}
}
#[test]
fn native_workshop_baseline_and_planned_attachments_have_clear_routes() {
use EdgeEnd::{Source, Target};
use PortAnchorSide::{Bottom, Top};
let mut project = language::parse(FIXTURE).unwrap();
let baseline = project.clone();
clear(&project, "B");
set(
&mut project,
"dispatch_view",
"dispatch_request",
Source,
Some((Top, 0.25)),
);
clear(&project, "O1");
set(
&mut project,
"dispatch_view",
"dispatch_request",
Target,
Some((Bottom, 0.75)),
);
clear(&project, "O2");
set(
&mut project,
"dispatch_view",
"dispatch_request",
Source,
None,
);
clear(&project, "O3");
set(
&mut project,
"readiness_view",
"activate",
Source,
Some((Top, 0.25)),
);
clear(&project, "S1");
set(
&mut project,
"readiness_view",
"activate",
Target,
Some((Bottom, 0.75)),
);
clear(&project, "S2");
set(&mut project, "readiness_view", "activate", Target, None);
clear(&project, "S3");
set(
&mut project,
"readiness_view",
"initialize",
Source,
Some((Top, 0.5)),
);
clear(&project, "S4");
set(
&mut project,
"readiness_view",
"finish",
Source,
Some((Top, 0.5)),
);
clear(&project, "S5");
assert_eq!(project.elements, baseline.elements);
assert_eq!(project.relations, baseline.relations);
for alias in ["dispatch_reference", "readiness_reference", "ports_view"] {
let view = id(&project, alias);
assert_eq!(project.diagrams[&view], baseline.diagrams[&view]);
}
for alias in ["dispatch_view", "readiness_view"] {
let view = id(&project, alias);
assert_eq!(
project.diagrams[&view].nodes,
baseline.diagrams[&view].nodes
);
for route in &project.diagrams[&view].routes {
let original = baseline.diagrams[&view]
.routes
.iter()
.find(|r| r.edge == route.edge)
.unwrap();
assert_eq!(route.waypoints, original.waypoints);
assert_eq!(route.label, original.label);
}
}
}
+290
View File
@@ -0,0 +1,290 @@
use oa_core::{language, persistence, source_project::*, *};
use serde_json::{Value, json};
use std::collections::BTreeMap;
const SOURCE: &str = r#"arcadia 7;
project attachments "Connection ends" id "70000000-0000-4000-8000-000000000001" {
oa_activity first "First" id "70000000-0000-4000-8000-000000000002" {}
oa_activity second "Second" id "70000000-0000-4000-8000-000000000003" {}
interaction signal "Signal" id "70000000-0000-4000-8000-000000000004" { source @first; target @second; }
diagram view "View" id "70000000-0000-4000-8000-000000000005" {
routes [{ edge @signal; waypoints [{ x 120; y 80; }];
label { fraction 0.5; offset { x 3; y -7; }; };
source_attachment { side Left; offset 0.25; };
target_attachment { side Bottom; offset 1; };
}];
}
diagram other "Other" id "70000000-0000-4000-8000-000000000006" { routes [{ edge @signal; }]; }
}
"#;
fn id(project: &Project, alias: &str) -> Id {
project
.identities()
.into_iter()
.find(|(_, a)| *a == alias)
.unwrap()
.0
}
fn original() -> Project {
language::parse(SOURCE).unwrap()
}
fn set(diagram: Id, route: EdgeRoute) -> Command {
Command::SetEdgeRoute { diagram, route }
}
#[test]
fn edge_attachments_roundtrip_source_json_bundle_and_automatic_defaults() {
let project = original();
let view = id(&project, "view");
let route = &project.diagrams[&view].routes[0];
assert_eq!(
route.source_attachment,
Some(EdgeAttachment {
side: PortAnchorSide::Left,
offset: 0.25
})
);
assert_eq!(
route.target_attachment,
Some(EdgeAttachment {
side: PortAnchorSide::Bottom,
offset: 1.0
})
);
for text in [SOURCE.to_owned(), language::format(&project)] {
assert_eq!(language::parse(&text).unwrap(), project);
}
assert_eq!(
persistence::decode(&serde_json::to_vec(&project).unwrap()).unwrap(),
project
);
assert_eq!(
compile_bundle(&format_bundle(&project), ProjectLimits::default())
.unwrap()
.decoded
.project,
project
);
let automatic = &project.diagrams[&id(&project, "other")].routes[0];
assert_eq!(automatic.source_attachment, None);
assert_eq!(automatic.target_attachment, None);
let encoded = serde_json::to_value(automatic).unwrap();
assert!(encoded.get("source_attachment").is_none());
assert!(encoded.get("target_attachment").is_none());
let mut with_null = encoded.clone();
with_null["source_attachment"] = Value::Null;
with_null["target_attachment"] = Value::Null;
assert_eq!(
serde_json::from_value::<EdgeRoute>(with_null).unwrap(),
*automatic
);
assert_eq!(
serde_json::from_value::<EdgeRoute>(encoded).unwrap(),
*automatic
);
// Old source without either new field still means automatic presentation.
let old = SOURCE
.replace(" source_attachment { side Left; offset 0.25; };\n", "")
.replace(" target_attachment { side Bottom; offset 1; };\n", "");
let old = language::parse(&old).unwrap();
assert!(
old.diagrams
.values()
.flat_map(|d| &d.routes)
.all(|r| r.source_attachment.is_none() && r.target_attachment.is_none())
);
assert!(!language::format(&old).contains("_attachment"));
assert_eq!(
persistence::decode(&serde_json::to_vec(&old).unwrap()).unwrap(),
old
);
}
#[test]
fn edge_attachments_reject_malformed_nested_source_and_json_without_defaults() {
let project = original();
let edge = id(&project, "signal");
for bad in [
json!({}),
json!({"side":"Top"}),
json!({"offset":0.5}),
json!({"side":"Diagonal","offset":0.5}),
json!({"side":"Top","offset":null}),
json!({"side":"Top","offset":0.5,"unknown":true}),
json!({"side":"Top","offset":"NaN"}),
] {
for field in ["source_attachment", "target_attachment"] {
let mut route = json!({"edge":edge});
route[field] = bad.clone();
assert!(
serde_json::from_value::<EdgeRoute>(route).is_err(),
"{field}: {bad}"
);
}
}
for bad in [
"{}",
"{ side Top; }",
"{ offset 0.5; }",
"{ side Diagonal; offset 0.5; }",
"{ side Top; offset null; }",
"{ side Top; offset 0.5; unknown true; }",
"{ side Top; offset -0.01; }",
"{ side Top; offset 1.01; }",
"{ side Top; offset NaN; }",
"{ side Top; offset 1e999; }",
] {
let source = SOURCE.replace("{ side Left; offset 0.25; }", bad);
assert!(language::parse(&source).is_err(), "{bad}");
let bundle = SourceBundle {
entry: "p.arcadia".into(),
files: BTreeMap::from([("p.arcadia".into(), source)]),
};
assert!(
compile_bundle(&bundle, ProjectLimits::default()).is_err(),
"{bad}"
);
}
for version in [1, 2] {
let legacy =
format!("arcadia {version}; project p \"P\" {{ diagram v \"V\" {{ routes []; }} }}");
assert!(
language::parse(&legacy).is_err(),
"legacy route extension must not be silently admitted"
);
}
}
#[test]
fn edge_attachments_nonfinite_and_out_of_range_commands_reject_atomically() {
let project = original();
let view = id(&project, "view");
let first = id(&project, "first");
for offset in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, -0.01, 1.01] {
for end in [EdgeEnd::Source, EdgeEnd::Target] {
let mut route = project.diagrams[&view].routes[0].clone();
route.set_attachment(
end,
Some(EdgeAttachment {
side: PortAnchorSide::Top,
offset,
}),
);
let mut session = Session::new(project.clone());
assert!(
session
.execute(Command::Batch(vec![
Command::Rename(first, "Must roll back".into()),
set(view, route)
]))
.is_err()
);
assert_eq!(session.project(), &project);
assert!(!session.can_undo() && !session.can_redo());
}
}
for offset in [-0.01, 1.01] {
let mut value = serde_json::to_value(&project).unwrap();
value["diagrams"][view.to_string()]["routes"][0]["source_attachment"]["offset"] =
json!(offset);
assert!(persistence::decode(&serde_json::to_vec(&value).unwrap()).is_err());
}
}
#[test]
fn edge_attachments_reset_one_end_and_all_routing_have_exact_history_and_view_scope() {
let project = original();
let view = id(&project, "view");
let edge = id(&project, "signal");
let mut session = Session::new(project.clone());
let mut route = project.diagrams[&view].routes[0].clone();
route.source_attachment = Some(EdgeAttachment {
side: PortAnchorSide::Right,
offset: 0.75,
});
let command = set(view, route.clone());
assert_eq!(
serde_json::from_value::<Command>(serde_json::to_value(&command).unwrap()).unwrap(),
command
);
session.execute(command).unwrap();
let mut expected = project.clone();
expected.diagrams.get_mut(&view).unwrap().routes[0] = route.clone();
assert_eq!(
session.project(),
&expected,
"only this view/end changes; semantic fields and other view stay exact"
);
session.undo();
assert_eq!(session.project(), &project);
assert!(!session.can_undo() && session.can_redo());
session.redo();
assert_eq!(session.project(), &expected);
route.source_attachment = None;
session.execute(set(view, route.clone())).unwrap();
let mut reset = expected.clone();
reset.diagrams.get_mut(&view).unwrap().routes[0] = route;
assert_eq!(
session.project(),
&reset,
"opposite attachment, pins and label survive endpoint reset"
);
session.undo();
assert_eq!(session.project(), &expected);
session
.execute(Command::RemoveEdgeRoute {
diagram: view,
edge,
})
.unwrap();
let mut removed = expected.clone();
removed.diagrams.get_mut(&view).unwrap().routes.clear();
assert_eq!(session.project(), &removed);
session.undo();
assert_eq!(session.project(), &expected);
}
#[test]
fn edge_attachments_nested_writeback_preserves_comments_adjacent_tokens_and_other_files() {
let current = compile_bundle(&SourceBundle {
entry: "p.arcadia".into(),
files: BTreeMap::from([
("p.arcadia".into(), "// project rationale\narcadia 7; project p \"P\" { include \"model.arcadia\"; include \"views.arcadia\"; }\n".into()),
("model.arcadia".into(), "// untouched semantic ownership\noa_activity a \"A\" {}\noa_activity b \"B\" {}\ninteraction signal \"Signal\" { source @a; target @b; }\n".into()),
("views.arcadia".into(), "// view rationale\r\ndiagram view \"View\" {\r\n routes [ { edge @signal; // endpoint explanation\r\n source_attachment { side Left; offset 0.25 ; // exact nested rationale\r\n }; target_attachment { side Bottom; offset 1; };\r\n waypoints [{ x 120; y 80; }]; label { fraction 0.5; offset { x 3; y -7; }; }; } ];\r\n}\r\ndiagram other \"Other\" { routes [{ edge @signal; }]; } // adjacent view\r\n".into()),
]),
}, ProjectLimits::default()).unwrap();
let mut project = current.decoded.project.clone();
let view = id(&project, "view");
project.diagrams.get_mut(&view).unwrap().routes[0]
.source_attachment
.as_mut()
.unwrap()
.offset = 0.375;
let changed = update_bundle(&current, &project, ProjectLimits::default()).unwrap();
assert_eq!(changed.decoded.project, project);
for path in ["p.arcadia", "model.arcadia"] {
assert_eq!(changed.bundle.files[path], current.bundle.files[path]);
}
assert_eq!(
changed.bundle.files["views.arcadia"],
current.bundle.files["views.arcadia"].replace("offset 0.25 ;", "offset 0.375 ;")
);
assert_eq!(
compile_bundle(&changed.bundle, ProjectLimits::default())
.unwrap()
.bundle,
changed.bundle
);
assert_eq!(
update_bundle(&changed, &project, ProjectLimits::default())
.unwrap()
.bundle,
changed.bundle
);
}
+427
View File
@@ -0,0 +1,427 @@
use oa_core::{diagram_edit::translate_placements, source_project::*, *};
use std::collections::BTreeSet;
fn point(x: f32, y: f32) -> Position {
Position { x, y }
}
fn route(edge: Id, x: f32, y: f32) -> EdgeRoute {
EdgeRoute {
edge,
source_attachment: Some(EdgeAttachment {
side: PortAnchorSide::Bottom,
offset: 0.2,
}),
target_attachment: Some(EdgeAttachment {
side: PortAnchorSide::Top,
offset: 0.8,
}),
waypoints: vec![point(x, y)],
label: Some(EdgeLabel {
fraction: 0.4,
offset: point(3., -9.),
}),
}
}
struct Fixture {
project: Project,
diagram: Id,
a: Id,
b: Id,
child: Id,
foreign: Id,
}
fn fixture() -> Fixture {
let mut p = Project::empty();
let a = p.add_element(Kind::Activity, Layer::Oa, "A", None).unwrap();
let b = p.add_element(Kind::Activity, Layer::Oa, "B", None).unwrap();
let c = p.add_element(Kind::Activity, Layer::Oa, "C", None).unwrap();
let child = p
.add_element(Kind::Activity, Layer::Oa, "Child", Some(a))
.unwrap();
let internal = p.relate(RelationKind::Interaction, "Internal", a, b);
let external = p.relate(RelationKind::Interaction, "External", b, c);
let d = p.ensure_diagram(Layer::Oa);
let na = p.place(d, a, point(10., 20.));
let nb = p.place(d, b, point(400., 20.));
let nc = p.place(d, child, point(30., 80.));
p.place(d, c, point(800., 20.));
p.diagrams.get_mut(&d).unwrap().routes =
vec![route(internal, 320., 150.), route(external, 720., 170.)];
p.diagrams
.get_mut(&d)
.unwrap()
.nodes
.get_mut(&na)
.unwrap()
.collapsed = true;
let other = Id::new_v4();
let mut other_view = p.diagrams[&d].clone();
other_view.id = other;
other_view.alias = "other_view".into();
other_view.nodes.clear();
other_view.routes.clear();
p.diagrams.insert(other, other_view);
let foreign = p.place(other, a, point(-40., -50.));
validate_candidate(&p).unwrap();
Fixture {
project: p,
diagram: d,
a: na,
b: nb,
child: nc,
foreign,
}
}
#[test]
fn simultaneous_siblings_translate_internal_pins_once_and_preserve_complete_project() {
let f = fixture();
let before = f.project.clone();
let command = translate_placements(
&before,
f.diagram,
&BTreeSet::from([f.a, f.b]),
point(40., 60.),
)
.unwrap();
assert_eq!(
before, f.project,
"command construction must not mutate input"
);
// Independently enumerated expected edits, without the projection helpers.
let mut expected = before.clone();
let view = expected.diagrams.get_mut(&f.diagram).unwrap();
view.nodes.get_mut(&f.a).unwrap().position = point(50., 80.);
view.nodes.get_mut(&f.b).unwrap().position = point(440., 80.);
view.nodes.get_mut(&f.child).unwrap().position = point(70., 140.);
view.routes[0].waypoints = vec![point(360., 210.)];
let mut session = Session::new(before.clone());
session.execute(command.clone()).unwrap();
assert_eq!(session.project(), &expected);
session.undo();
assert_eq!(session.project(), &before);
assert!(
!session.can_undo(),
"one movement must be one history entry"
);
session.redo();
assert_eq!(session.project(), &expected);
assert!(!session.can_redo());
// Independent one-node moves cannot see the shared endpoint displacement.
let mut sequential = Session::new(before.clone());
for (node, position) in [(f.a, point(50., 80.)), (f.b, point(440., 80.))] {
sequential
.execute(Command::MoveNode {
diagram: f.diagram,
node,
position,
with_descendants: true,
})
.unwrap();
}
assert_eq!(
sequential.project().diagrams[&f.diagram].routes[0],
before.diagrams[&f.diagram].routes[0]
);
assert_ne!(sequential.project(), &expected);
let json = serde_json::to_string(&expected).unwrap();
assert_eq!(serde_json::from_str::<Project>(&json).unwrap(), expected);
let mut bundle = format_bundle(&before);
bundle
.files
.get_mut(&bundle.entry.clone())
.unwrap()
.insert_str(0, "// preserve movement rationale\n");
let original = compile_bundle(&bundle, ProjectLimits::default()).unwrap();
let prepared = prepare_command(&before, &command).unwrap();
let updated = update_bundle_with_identity_map(
&original,
&prepared.project,
&prepared.identity_map,
ProjectLimits::default(),
)
.unwrap();
assert_eq!(updated.decoded.project, expected);
assert!(updated.bundle.files[&bundle.entry].starts_with("// preserve movement rationale\n"));
let reread = compile_bundle(&updated.bundle, ProjectLimits::default()).unwrap();
assert_eq!(reread.decoded.project, expected);
assert_eq!(reread.bundle, updated.bundle);
}
#[test]
fn selecting_folded_ancestor_and_hidden_descendant_moves_each_once() {
let f = fixture();
assert!(
!projection::project_view(&f.project, f.diagram)
.nodes
.iter()
.any(|n| n.id == f.child)
);
let ancestor = translate_placements(
&f.project,
f.diagram,
&BTreeSet::from([f.a]),
point(-15., 25.),
)
.unwrap();
let overlap = translate_placements(
&f.project,
f.diagram,
&BTreeSet::from([f.a, f.child]),
point(-15., 25.),
)
.unwrap();
assert_eq!(overlap, ancestor);
let after = prepare_command(&f.project, &overlap).unwrap().project;
assert_eq!(
after.diagrams[&f.diagram].nodes[&f.child].position,
point(15., 105.)
);
assert_eq!(
after.diagrams[&f.diagram].routes,
f.project.diagrams[&f.diagram].routes
);
}
#[test]
fn invalid_movements_refuse_atomically_and_zero_has_no_history() {
let f = fixture();
let selected = BTreeSet::from([f.a, f.b]);
for (diagram, ids, delta) in [
(Id::new_v4(), selected.clone(), point(1., 1.)),
(f.diagram, BTreeSet::new(), point(1., 1.)),
(
f.diagram,
BTreeSet::from([f.a, Id::new_v4()]),
point(1., 1.),
),
(f.diagram, BTreeSet::from([f.a, f.foreign]), point(0., 0.)),
(f.diagram, selected.clone(), point(f32::NAN, 1.)),
(f.diagram, selected.clone(), point(1., f32::INFINITY)),
(f.diagram, selected.clone(), point(MAX_COORDINATE, 0.)),
(f.diagram, selected.clone(), point(f32::MAX, f32::MAX)),
] {
let before = f.project.clone();
assert!(translate_placements(&f.project, diagram, &ids, delta).is_err());
assert_eq!(f.project, before);
}
let mut bad_route = f.project.clone();
bad_route.diagrams.get_mut(&f.diagram).unwrap().routes[0].waypoints =
vec![point(MAX_COORDINATE, 0.)];
validate_candidate(&bad_route).unwrap();
let snapshot = bad_route.clone();
assert!(translate_placements(&bad_route, f.diagram, &selected, point(1., 0.)).is_err());
assert_eq!(bad_route, snapshot);
let zero = translate_placements(&f.project, f.diagram, &selected, point(0., -0.)).unwrap();
assert_eq!(zero, Command::Batch(vec![]));
let mut session = Session::new(f.project.clone());
session.execute(zero).unwrap();
assert_eq!(session.project(), &f.project);
assert!(!session.can_undo());
assert_eq!(session.revision(), 0);
}
#[test]
fn typed_component_port_endpoints_follow_their_owner_placements() {
let mut p = Project::empty();
let a = p
.add_element(Kind::System, Layer::Sa, "System", None)
.unwrap();
let b = p
.add_element(Kind::SystemActor, Layer::Sa, "Actor", None)
.unwrap();
let output = Id::new_v4();
let input = Id::new_v4();
for (id, owner, name, orientation) in [
(output, a, "Output", ComponentPortOrientation::Out),
(input, b, "Input", ComponentPortOrientation::In),
] {
let port = make_port(
&p,
id,
owner,
name,
PortContract::Component {
kind: ComponentPortKind::Flow,
orientation,
},
)
.unwrap();
p.elements.insert(id, port);
}
let edge = p.relate(RelationKind::BoundaryExchange, "Boundary", output, input);
let d = p.ensure_diagram(Layer::Sa);
let na = p.place(d, a, point(0., 0.));
let nb = p.place(d, b, point(400., 0.));
for (node, port, side, offset) in [
(na, output, PortAnchorSide::Bottom, 0.3),
(nb, input, PortAnchorSide::Top, 0.7),
] {
p.diagrams
.get_mut(&d)
.unwrap()
.nodes
.get_mut(&node)
.unwrap()
.port_anchors
.push(PortAnchor { port, side, offset });
}
p.diagrams
.get_mut(&d)
.unwrap()
.routes
.push(route(edge, 300., 100.));
validate_candidate(&p).unwrap();
let command = translate_placements(&p, d, &BTreeSet::from([na, nb]), point(10., -20.)).unwrap();
let after = prepare_command(&p, &command).unwrap().project;
let mut expected = p.clone();
let view = expected.diagrams.get_mut(&d).unwrap();
view.nodes.get_mut(&na).unwrap().position = point(10., -20.);
view.nodes.get_mut(&nb).unwrap().position = point(410., -20.);
view.routes[0].waypoints = vec![point(310., 80.)];
assert_eq!(after, expected);
}
#[test]
fn allocation_movement_follows_visual_performers_and_same_performer_work_groups() {
let mut p = Project::empty();
let performer_a = p
.add_element(Kind::Entity, Layer::Oa, "Performer A", None)
.unwrap();
let performer_b = p
.add_element(Kind::Entity, Layer::Oa, "Performer B", None)
.unwrap();
let mixed = p
.add_element(Kind::Activity, Layer::Oa, "Mixed work", None)
.unwrap();
let branch = p
.add_element(Kind::Activity, Layer::Oa, "A work", Some(mixed))
.unwrap();
let leaf_a = p
.add_element(Kind::Activity, Layer::Oa, "A leaf", Some(branch))
.unwrap();
let leaf_b = p
.add_element(Kind::Activity, Layer::Oa, "B leaf", Some(mixed))
.unwrap();
p.relate(RelationKind::Allocation, "A performs", performer_a, leaf_a);
p.relate(RelationKind::Allocation, "B performs", performer_b, leaf_b);
let d = p.ensure_diagram(Layer::Oa);
p.diagrams.get_mut(&d).unwrap().projection = ProjectionKind::Allocation;
let na = p.place(d, performer_a, point(0., 0.));
p.place(d, performer_b, point(600., 0.));
let nm = p.place(d, mixed, point(1000., 0.));
let branch_node = p.place(d, branch, point(20., 80.));
let leaf_node = p.place(d, leaf_a, point(40., 160.));
p.place(d, leaf_b, point(620., 80.));
validate_candidate(&p).unwrap();
// Mixed work retains semantic ownership of both branches, but its leaves
// have different performers. A work is visually nested under Performer A,
// with its own same-performer leaf nested inside it.
let command =
translate_placements(&p, d, &BTreeSet::from([na, branch_node]), point(15., -25.)).unwrap();
let after = prepare_command(&p, &command).unwrap().project;
let mut expected = p.clone();
let view = expected.diagrams.get_mut(&d).unwrap();
view.nodes.get_mut(&na).unwrap().position = point(15., -25.);
view.nodes.get_mut(&branch_node).unwrap().position = point(35., 55.);
view.nodes.get_mut(&leaf_node).unwrap().position = point(55., 135.);
assert_eq!(after, expected);
// Moving the semantic parent does not move children displayed elsewhere.
let command = translate_placements(&p, d, &BTreeSet::from([nm]), point(15., -25.)).unwrap();
let after = prepare_command(&p, &command).unwrap().project;
let mut expected = p.clone();
expected
.diagrams
.get_mut(&d)
.unwrap()
.nodes
.get_mut(&nm)
.unwrap()
.position = point(1015., -25.);
assert_eq!(after, expected);
}
#[test]
fn traceability_movement_is_flat_even_for_a_semantic_parent() {
let mut f = fixture();
f.project.diagrams.get_mut(&f.diagram).unwrap().projection = ProjectionKind::Traceability;
validate_candidate(&f.project).unwrap();
let command = translate_placements(
&f.project,
f.diagram,
&BTreeSet::from([f.a]),
point(40., 60.),
)
.unwrap();
let after = prepare_command(&f.project, &command).unwrap().project;
let mut expected = f.project.clone();
expected
.diagrams
.get_mut(&f.diagram)
.unwrap()
.nodes
.get_mut(&f.a)
.unwrap()
.position = point(50., 80.);
assert_eq!(
after, expected,
"flat view movement must preserve semantic descendants, routes, other views and all semantics"
);
}
#[test]
fn state_entry_pins_follow_the_boundary_only_when_it_also_moves() {
let mut p = Project::empty();
let machine = p
.add_element(Kind::StateMachine, Layer::Oa, "Machine", None)
.unwrap();
let region = p
.add_element(Kind::Region, Layer::Oa, "Region", Some(machine))
.unwrap();
let composite = p
.add_element(Kind::State, Layer::Oa, "Composite", Some(region))
.unwrap();
let inner = p
.add_element(Kind::Region, Layer::Oa, "Inner", Some(composite))
.unwrap();
let entry = p
.add_element(Kind::EntryPoint, Layer::Oa, "Entry", Some(inner))
.unwrap();
let leaf = p
.add_element(Kind::State, Layer::Oa, "Leaf", Some(inner))
.unwrap();
let edge = p.relate(RelationKind::StateTransition, "Enter", entry, leaf);
if let RelationBody::StateTransition {
transition_kind, ..
} = &mut p.relations.get_mut(&edge).unwrap().body
{
*transition_kind = TransitionKind::Local;
}
let d = p.ensure_diagram(Layer::Oa);
p.diagrams.get_mut(&d).unwrap().projection = ProjectionKind::StateMachine { machine };
let boundary = p.place(d, composite, point(0., 0.));
let ne = p.place(d, entry, point(20., 80.));
let nl = p.place(d, leaf, point(100., 80.));
p.diagrams
.get_mut(&d)
.unwrap()
.routes
.push(route(edge, 90., 100.));
validate_candidate(&p).unwrap();
let endpoints =
translate_placements(&p, d, &BTreeSet::from([ne, nl]), point(10., 20.)).unwrap();
let after = prepare_command(&p, &endpoints).unwrap().project;
assert_eq!(after.diagrams[&d].routes, p.diagrams[&d].routes);
let with_boundary =
translate_placements(&p, d, &BTreeSet::from([boundary, ne, nl]), point(10., 20.)).unwrap();
let after = prepare_command(&p, &with_boundary).unwrap().project;
assert_eq!(
after.diagrams[&d].routes[0].waypoints,
vec![point(100., 120.)]
);
assert_eq!(after.diagrams[&d].nodes[&ne].position, point(30., 100.));
}
+2
View File
@@ -441,6 +441,8 @@ fn nested_owner_bounds_group_movement_and_authored_routes_share_the_same_geometr
diagram: d,
route: EdgeRoute {
edge,
source_attachment: None,
target_attachment: None,
waypoints: vec![pos(500., 420.)],
label: None,
},
+4
View File
@@ -70,6 +70,8 @@ fn fixture() -> Fixture {
fn route(edge: Id) -> EdgeRoute {
EdgeRoute {
edge,
source_attachment: None,
target_attachment: None,
waypoints: vec![Position { x: 550., y: 240. }, Position { x: 550., y: 360. }],
label: Some(EdgeLabel {
fraction: 0.4,
@@ -689,6 +691,8 @@ fn old_models_get_empty_routes_and_route_json_is_strict() {
serde_json::from_value::<EdgeRoute>(minimal).unwrap(),
EdgeRoute {
edge: f.edge,
source_attachment: None,
target_attachment: None,
waypoints: vec![],
label: None
}
+2
View File
@@ -27,6 +27,8 @@ fn route(p: &Project, d: Id) -> RoutedView {
fn set_route(p: &mut Project, d: Id, edge: Id, pins: Vec<Position>, label: Option<EdgeLabel>) {
p.diagrams.get_mut(&d).unwrap().routes.push(EdgeRoute {
edge,
source_attachment: None,
target_attachment: None,
waypoints: pins,
label,
});
+2
View File
@@ -117,6 +117,8 @@ fn authored_overlap_is_exact_and_reset_only_repositions_automatic_text() {
.routes
.push(EdgeRoute {
edge: fe,
source_attachment: None,
target_attachment: None,
waypoints: vec![],
label: Some(authored.clone()),
});
+16
View File
@@ -301,6 +301,8 @@ fn folding_retains_authored_routes_without_false_self_transitions() {
let pins = vec![point(690., 480.), point(690., 230.)];
f.p.diagrams.get_mut(&f.d).unwrap().routes.push(EdgeRoute {
edge: out,
source_attachment: None,
target_attachment: None,
waypoints: pins.clone(),
label: None,
});
@@ -362,11 +364,15 @@ fn grouped_moves_translate_hidden_descendants_and_pins_only_in_one_view() {
let pins = vec![point(570., 460.)];
f.p.diagrams.get_mut(&f.d).unwrap().routes.push(EdgeRoute {
edge: f.local,
source_attachment: None,
target_attachment: None,
waypoints: pins.clone(),
label: Some(label.clone()),
});
f.p.diagrams.get_mut(&f.d).unwrap().routes.push(EdgeRoute {
edge: f.external,
source_attachment: None,
target_attachment: None,
waypoints: vec![point(690., 230.)],
label: None,
});
@@ -422,6 +428,8 @@ fn cross_machine_reparent_requires_atomic_placement_and_route_repair() {
let placement = f.p.place(f.d, orphan, point(30., 750.));
f.p.diagrams.get_mut(&f.d).unwrap().routes.push(EdgeRoute {
edge,
source_attachment: None,
target_attachment: None,
waypoints: vec![],
label: None,
});
@@ -456,6 +464,8 @@ fn cross_machine_reparent_requires_atomic_placement_and_route_repair() {
diagram: other,
route: EdgeRoute {
edge,
source_attachment: None,
target_attachment: None,
waypoints: vec![],
label: None,
},
@@ -583,6 +593,8 @@ fn layout_reserves_internal_rows_and_state_routes_retain_hard_pins() {
let pins = vec![point(730., 130.), point(1060., 130.)];
f.p.diagrams.get_mut(&f.d).unwrap().routes.push(EdgeRoute {
edge: f.self_edge,
source_attachment: None,
target_attachment: None,
waypoints: pins.clone(),
label: Some(EdgeLabel {
fraction: 0.25,
@@ -652,6 +664,8 @@ fn moving_a_region_does_not_translate_pins_attached_to_a_stationary_state_bounda
let pins = vec![point(280., 390.), point(280., 470.)];
f.p.diagrams.get_mut(&f.d).unwrap().routes.push(EdgeRoute {
edge: f.local,
source_attachment: None,
target_attachment: None,
waypoints: pins.clone(),
label: None,
});
@@ -730,6 +744,8 @@ fn initial_transition_attaches_to_the_compact_symbol_and_preview_keeps_settled_g
);
f.p.diagrams.get_mut(&f.d).unwrap().routes.push(EdgeRoute {
edge: f.self_edge,
source_attachment: None,
target_attachment: None,
waypoints: vec![],
label: Some(EdgeLabel {
fraction: 0.3,
@@ -748,6 +748,8 @@ fn allocation_artifact_and_manual_trace_cascades_are_named_and_refused() {
fn route(edge: Id) -> EdgeRoute {
EdgeRoute {
edge,
source_attachment: None,
target_attachment: None,
waypoints: vec![xy(420., 400.), xy(420., 550.)],
label: Some(EdgeLabel {
fraction: 0.4,
@@ -60,6 +60,7 @@ impl Root<'_> {
return false;
}
let canvas = DiagramCanvas {
selection: self.app.diagram_selection.as_ref(),
allocation: Some(self.app.allocation_capability()),
project: self.app.project(),
view_index: Some(self.app.document.view_index()),
@@ -70,6 +71,7 @@ impl Root<'_> {
pan: self.app.pan,
context: self.app.route_context().unwrap(),
selected_bend: None,
selected_attachment: None,
editable: false,
placing: false,
connection: None,
+184 -4
View File
@@ -15,11 +15,18 @@ mod diagram_reveal;
mod diagram_selection_tests;
mod diagram_usages;
mod hosted;
pub(crate) mod multiselect;
#[cfg(test)]
mod multiselect_tests;
mod reference_authoring;
#[cfg(test)]
mod review_navigation_tests;
pub(crate) mod source_rename;
mod source_symbols;
#[cfg(test)]
mod state_multiselect_focus_tests;
#[cfg(test)]
mod state_multiselect_tests;
pub(crate) mod text_workspace;
#[cfg(test)]
mod transition_context_tests;
@@ -364,6 +371,7 @@ pub enum Message {
Select(Id),
EditProperties(Id),
SelectNode(Id),
DiagramSelection(multiselect::Event),
SelectProject,
Search(String),
StartTransition,
@@ -493,6 +501,7 @@ pub struct App {
pub camera_epoch: u64,
camera_boundary_epoch: u64,
pub selected_bend: Option<(Id, usize)>,
pub selected_attachment: Option<(crate::route_edit::Context, Id, EdgeEnd)>,
pub exact_strings: bool,
pub document: Document,
pub page: Page,
@@ -506,6 +515,7 @@ pub struct App {
pub layer: Layer,
pub selected: Id,
pub selected_node: Option<Id>,
pub diagram_selection: Option<crate::diagram_selection::Selection>,
pub draft: Option<Draft>,
pub active_diagram: Option<Id>,
pub search: String,
@@ -613,6 +623,7 @@ impl App {
camera_epoch: 0,
camera_boundary_epoch: 0,
selected_bend: None,
selected_attachment: None,
exact_strings: false,
document,
page: Page::Diagram,
@@ -625,6 +636,7 @@ impl App {
layer,
selected,
selected_node: None,
diagram_selection: None,
draft,
active_diagram,
search: String::new(),
@@ -1103,6 +1115,9 @@ impl App {
previous != (self.layer, self.active_diagram)
}
fn select(&mut self, id: Id) {
if id != self.selected {
self.selected_attachment = None;
}
if self.selected == id {
return;
}
@@ -1279,6 +1294,38 @@ impl App {
])
}
pub fn update(&mut self, message: Message) -> Task<Message> {
let selection_boundary = self.multiselect_boundary();
// Reject delayed gestures before they can retire newer input ownership.
if let Message::DiagramSelection(event)
| Message::StateDiagram(crate::behavior::state_edit::Event::Selection(event)) = &message
&& !self.multiselect_envelope(event)
{
return Task::none();
}
if let Message::StateDiagram(event) = &message
&& let Some(captured) = event.context()
&& self.state_context().is_none_or(|current| {
crate::behavior::state_edit::validate_context(self.project(), captured, current)
.is_err()
})
{
return Task::none();
}
if matches!(
&message,
Message::StateDiagram(
crate::behavior::state_edit::Event::Select(..)
| crate::behavior::state_edit::Event::Move { .. }
| crate::behavior::state_edit::Event::Remove(_)
)
) && self
.multiselect_snapshot()
.is_some_and(|s| s.targets().len() > 1)
{
// A queued singleton cannot retire the current group's navigation
// or allocation ownership before its edit is refused.
return Task::none();
}
// Captured Text commands validate their origin before they can
// invalidate that same origin or pass into existing command handlers.
if let Message::TextWorkspace(event) = message {
@@ -1331,7 +1378,11 @@ impl App {
self.reconcile_pending_diagram_reveal(&message);
self.allocation_before(&message);
if let Message::WorkDrag(event) = message {
return self.allocation_event(event);
let task = self.allocation_event(event);
if self.multiselect_boundary() != selection_boundary {
self.reconcile_multiselect();
}
return task;
}
let allocation_before = self.allocation_boundary();
if let Message::BehaviorControl(intent) = message {
@@ -1364,6 +1415,11 @@ impl App {
self.camera_epoch = self.camera_epoch.wrapping_add(1);
self.camera_boundary_epoch = self.canvas_epoch;
}
if self.multiselect_boundary() != selection_boundary
|| (self.diagram_selection.is_none() && self.multiselect_context().is_some())
{
self.reconcile_multiselect();
}
task
}
fn behavior_control(&mut self, intent: behavior::canvas_workspace::Intent) -> Task<Message> {
@@ -1727,6 +1783,7 @@ impl App {
message,
Message::Page(_) | Message::Layer(_) | Message::Diagram(_)
) {
self.diagram_selection = None;
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
self.selected_bend = None;
}
@@ -2305,8 +2362,17 @@ impl App {
"Apply or revert pending edits before using modeling actions.".into();
return Task::none();
}
if matches!(*action, Message::DiagramSelection(_)) {
return self.update(*action);
}
self.select(menu.request.object);
self.selected_node = menu.request.placement;
if self.page == Page::Behavior && menu.request.canvas.is_some() {
self.behavior.diagram.placement = menu.request.placement;
self.selected_node = None;
} else {
self.selected_node = menu.request.placement;
}
self.reconcile_multiselect();
self.selected_bend = menu.request.bend.map(|i| (menu.request.object, i));
return self.update(*action);
}
@@ -2329,6 +2395,15 @@ impl App {
} else if self.behavior.creation.has_pending() {
self.behavior.creation.acknowledge_commit();
self.status = "Behavior creation cancelled".into();
} else if self.page == Page::Behavior
&& matches!(
self.behavior.diagram.tool,
behavior::state_edit::Tool::AdjustAttachment { .. }
)
{
// Menu arming invalidates canvas keyboard ownership. Escape
// must also cancel before the next canvas click grants it.
self.behavior.diagram.tool = behavior::state_edit::Tool::Select;
}
if self.connection.take().is_some() {
self.status = "Drawing cancelled · Select / move is active".into();
@@ -2455,6 +2530,7 @@ impl App {
self.status = "Property draft reverted".into();
}
Message::Select(id) => self.select(id),
Message::DiagramSelection(event) => self.multiselect_event(event),
Message::EditProperties(id) => {
self.select(id);
self.inspector_tab = crate::navigation::InspectorTab::Properties;
@@ -3112,7 +3188,21 @@ impl App {
}
}
}
Message::DiagramRemove(intent) => self.remove_from_diagram(intent),
Message::DiagramRemove(intent) => {
if self
.multiselect_snapshot()
.is_some_and(|s| s.targets().len() > 1)
{
// A pre-redraw singleton Delete cannot remove just one
// member of a selection changed earlier in the same batch.
if Some(intent.context) == self.route_context() {
self.status =
"Selection changed. Use Hide selected or press Delete again.".into();
}
} else {
self.remove_from_diagram(intent);
}
}
Message::DiagramTraverse(back) => {
return if back {
iced::widget::operation::focus_previous()
@@ -3121,6 +3211,13 @@ impl App {
};
}
Message::Delete => {
if self
.multiselect_snapshot()
.is_some_and(|s| s.targets().len() > 1)
{
self.status = "Group model-deletion review is not available yet. Select one element or use Hide selected.".into();
return Task::none();
}
if self.selected != self.project().id && self.apply() {
self.begin_model_delete(None);
}
@@ -3252,6 +3349,13 @@ impl App {
}
}
Message::HideSelected => {
if let Some(snapshot) = self
.multiselect_snapshot()
.filter(|s| s.targets().len() > 1)
{
self.multiselect_event(multiselect::Event::Hide(snapshot));
return Task::none();
}
if self.apply()
&& let Some(node) = self.selected_node
{
@@ -3281,6 +3385,13 @@ impl App {
}
Message::Route(event) => self.edit_route(event),
Message::MoveNode(node, position) => {
if self
.multiselect_snapshot()
.is_some_and(|s| s.targets().len() > 1)
{
self.status = "Selection changed. Start the group drag again.".into();
return Task::none();
}
let original = self.selected;
if self.apply()
&& let Some(diagram) = self.active_diagram
@@ -4523,7 +4634,7 @@ impl App {
}
fn edit_route(&mut self, event: crate::route_edit::Event) {
use crate::route_edit::{Event, insert_pin, stored_route};
if self.connection.is_some() {
if self.connection.is_some() || self.page != Page::Diagram {
return;
}
let Some(context) = self.route_context() else {
@@ -4551,6 +4662,75 @@ impl App {
.filter(|(id, _)| *id == self.selected)
.map(|(_, i)| i);
let command = match event {
Event::ChooseAttachment(end) => {
if crate::route_edit::attachment_node(
self.project(),
diagram,
&view,
self.selected,
end,
)
.is_some()
{
self.selected_attachment = Some((context, self.selected, end));
self.selected_bend = None;
self.status = format!(
"Drag the {} attachment handle along its element boundary · Esc cancels",
if end == EdgeEnd::Source {
"source"
} else {
"target"
}
);
}
return;
}
Event::ResetAttachment(end) => {
if crate::route_edit::attachment_node(
self.project(),
diagram,
&view,
self.selected,
end,
)
.is_none()
|| route.attachment(end).is_none()
{
return;
}
route.set_attachment(end, None);
selected_bend = None;
Command::SetEdgeRoute {
diagram: context.diagram,
route,
}
}
Event::CommitAttachment(expected, changed, end, owner) => {
if expected != context
|| changed.edge != self.selected
|| crate::route_edit::attachment_node(
self.project(),
diagram,
&view,
self.selected,
end,
)
.is_none_or(|node| node.id != owner)
{
return;
}
// A boundary gesture owns exactly one presentation field.
let mut intended = route.clone();
intended.set_attachment(end, changed.attachment(end));
if changed != intended || intended == route {
return;
}
selected_bend = None;
Command::SetEdgeRoute {
diagram: context.diagram,
route: intended,
}
}
Event::SelectBend(id, index) => {
if id == self.selected && index < route.waypoints.len() {
self.selected_bend = Some((id, index));
@@ -59,6 +59,7 @@ fn fixture(layer: Layer) -> Fixture {
}
fn canvas(app: &App) -> DiagramCanvas<'_> {
DiagramCanvas {
selection: None,
allocation: Some(app.allocation_capability()),
project: app.project(),
view_index: Some(app.document.view_index()),
@@ -69,6 +70,7 @@ fn canvas(app: &App) -> DiagramCanvas<'_> {
pan: app.pan,
context: app.route_context().unwrap(),
selected_bend: None,
selected_attachment: None,
editable: !app.draft_dirty() && !app.source_dirty(),
placing: false,
connection: None,
@@ -66,6 +66,7 @@ fn architecture_f2_routes_selected_node_to_inline_rename_and_exact_undo() {
let camera = (app.zoom, app.pan);
let bounds = iced::Rectangle::new(iced::Point::ORIGIN, iced::Size::new(600., 380.));
let canvas = crate::diagram::DiagramCanvas {
selection: None,
project: app.project(),
view_index: None,
diagram: app.active_diagram.unwrap(),
@@ -75,6 +76,7 @@ fn architecture_f2_routes_selected_node_to_inline_rename_and_exact_undo() {
pan: app.pan,
context: app.route_context().unwrap(),
selected_bend: None,
selected_attachment: None,
editable: true,
placing: false,
connection: None,
@@ -221,6 +221,7 @@ fn architecture_canvas_left_click_uses_world_coordinate_once_at_zoom_and_pan() {
y: (local.y - app.pan.y) / app.zoom,
};
let canvas = crate::diagram::DiagramCanvas {
selection: None,
project: app.project(),
view_index: None,
diagram: app.active_diagram.unwrap(),
@@ -230,6 +231,7 @@ fn architecture_canvas_left_click_uses_world_coordinate_once_at_zoom_and_pan() {
pan: app.pan,
context: app.route_context().unwrap(),
selected_bend: None,
selected_attachment: None,
editable: true,
placing: true,
connection: None,
@@ -297,6 +299,7 @@ fn architecture_f2_rename_preserves_identity_placement_and_atomic_history() {
let diagrams = app.project().diagrams.clone();
let camera = (app.zoom, app.pan);
let canvas = crate::diagram::DiagramCanvas {
selection: None,
project: app.project(),
view_index: None,
diagram: app.active_diagram.unwrap(),
@@ -306,6 +309,7 @@ fn architecture_f2_rename_preserves_identity_placement_and_atomic_history() {
pan: app.pan,
context: app.route_context().unwrap(),
selected_bend: None,
selected_attachment: None,
editable: true,
placing: false,
connection: None,
@@ -154,3 +154,440 @@ async fn mounted_f2_requires_canvas_focus_and_yields_to_clean_inspector() {
}
}
}
async fn modifier_focus_fixture() -> (App, Mounted, Id, Id, Id) {
let mut project = Project::empty();
let a = project
.add_element(Kind::Entity, Layer::Oa, "First team", None)
.unwrap();
let b = project
.add_element(Kind::Entity, Layer::Oa, "Second team", None)
.unwrap();
let diagram = project.ensure_diagram(Layer::Oa);
let first = project.place(diagram, a, Position { x: 80., y: 80. });
let second = project.place(diagram, b, Position { x: 500., y: 80. });
let mut app = App::with_project(project);
app.page = Page::Diagram;
app.layer = Layer::Oa;
app.active_diagram = Some(diagram);
app.window_size = Size::new(2000., 1400.);
let _ = app.update(Message::Select(a));
let mut mounted = Mounted::new(&mut app).await;
app.zoom = 1.;
app.pan = Vector::ZERO;
app.fit_to_view = false;
mounted.settle(&mut app).await;
(app, mounted, diagram, first, second)
}
fn modifier_node_point(mounted: &mut Mounted, app: &App, diagram: Id, placement: Id) -> Point {
let bounds = mounted
.geometry(app)
.bounds(crate::allocation_input::CANVAS_ID);
let node = projection::project_view(app.project(), diagram)
.nodes
.into_iter()
.find(|n| n.id == placement)
.unwrap();
Point::new(
bounds.x + node.position.x + 40.,
bounds.y + node.position.y + 35.,
)
}
fn modifier_canvas_focused(mounted: &mut Mounted, app: &App) -> bool {
mounted
.geometry(app)
.focused
.iter()
.find(|(id, _, _)| {
id.as_ref() == Some(&iced::advanced::widget::Id::from("diagram-keyboard-owner"))
})
.expect("production focus::Owner is mounted")
.2
}
fn modifier_event(shift: bool) -> iced::Event {
iced::Event::Keyboard(iced::keyboard::Event::ModifiersChanged(if shift {
iced::keyboard::Modifiers::SHIFT
} else {
iced::keyboard::Modifiers::default()
}))
}
#[tokio::test]
async fn mounted_shift_before_canvas_focus_toggles_exact_node_without_granting_keyboard_focus() {
use crate::diagram_usage::Target;
let (mut app, mut mounted, diagram, first, second) = modifier_focus_fixture().await;
let before = app.document.snapshot();
let field = mounted
.geometry(&app)
.text_inputs
.into_iter()
.filter(|bounds| bounds.x > app.window_size.width - 320.)
.min_by(|a, b| a.y.total_cmp(&b.y))
.expect("clean Inspector name input");
mounted.operate(&app, &mut FocusField(field));
assert!(!modifier_canvas_focused(&mut mounted, &app));
assert_eq!(
app.multiselect_snapshot().unwrap().targets(),
&[Target::Placement(first)]
);
mounted
.batch(
&mut app,
&[modifier_event(true)],
mouse::Cursor::Unavailable,
)
.await;
assert!(
!modifier_canvas_focused(&mut mounted, &app),
"modifier tracking must not acquire focus"
);
let at = modifier_node_point(&mut mounted, &app, diagram, second);
let messages = mounted.click(&mut app, at).await;
assert!(messages.iter().any(|m| matches!(m,
Message::DiagramSelection(multiselect::Event::Choose(_, Target::Placement(id), multiselect::Choice::Toggle)) if *id == second
)), "Shift already held before the first canvas press must reach its exact-target adapter");
assert_eq!(
app.multiselect_snapshot().unwrap().targets(),
&[Target::Placement(first), Target::Placement(second)]
);
assert_eq!(app.selected_node, Some(second));
assert_eq!(app.project(), &before.decoded.project);
assert_eq!(app.document.bundle(), &before.bundle);
assert!(!app.document.can_undo());
assert!(!app.draft_dirty());
}
#[tokio::test]
async fn mounted_unfocused_typing_and_delete_remain_denied_after_passive_modifiers() {
use crate::diagram_usage::Target;
let (mut app, mut mounted, diagram, first, second) = modifier_focus_fixture().await;
mounted
.batch(
&mut app,
&[modifier_event(true)],
mouse::Cursor::Unavailable,
)
.await;
let at = modifier_node_point(&mut mounted, &app, diagram, second);
mounted.click(&mut app, at).await;
assert_eq!(
app.multiselect_snapshot().unwrap().targets(),
&[Target::Placement(first), Target::Placement(second)]
);
// Revoke every field's focus through the real widget operation. No widget
// has this off-window rectangle, so neither Inspector nor canvas owns keys.
mounted.operate(
&app,
&mut FocusField(Rectangle {
x: -1000.,
y: -1000.,
width: 1.,
height: 1.,
}),
);
assert!(!modifier_canvas_focused(&mut mounted, &app));
let before = app.document.snapshot();
let selection = app.multiselect_snapshot().unwrap();
let messages = mounted
.batch(
&mut app,
&[
modifier_event(false),
crate::behavior::canvas_workspace::tests::character("x"),
key(Named::Delete),
key(Named::F2),
],
mouse::Cursor::Unavailable,
)
.await;
assert!(!messages.iter().any(|m| matches!(
m,
Message::DiagramSelection(_)
| Message::DiagramRemove(_)
| Message::HideSelected
| Message::Delete
)));
assert!(!rename_emitted(&messages));
assert!(!modifier_canvas_focused(&mut mounted, &app));
assert_eq!(
app.multiselect_snapshot().unwrap().targets(),
selection.targets()
);
assert_eq!(app.project(), &before.decoded.project);
assert_eq!(app.document.bundle(), &before.bundle);
assert!(!app.document.can_undo());
assert!(!app.draft_dirty());
assert!(!app.delete_pending);
}
#[tokio::test]
async fn mounted_suspended_modifiers_are_passive_and_survive_until_resumed_click() {
use crate::diagram_usage::Target;
let (mut app, mut mounted, diagram, first, second) = modifier_focus_fixture().await;
let before = app.document.snapshot();
let at = modifier_node_point(&mut mounted, &app, diagram, second);
app.busy = true;
mounted.settle(&mut app).await;
assert!(!modifier_canvas_focused(&mut mounted, &app));
mounted
.batch(
&mut app,
&[modifier_event(true), key(Named::Delete)],
mouse::Cursor::Unavailable,
)
.await;
mounted.click(&mut app, at).await;
assert!(
!modifier_canvas_focused(&mut mounted, &app),
"passive I/O cannot grant canvas focus"
);
assert_eq!(
app.multiselect_snapshot().unwrap().targets(),
&[Target::Placement(first)]
);
assert_eq!(app.document.bundle(), &before.bundle);
app.busy = false;
mounted.settle(&mut app).await;
assert!(!modifier_canvas_focused(&mut mounted, &app));
mounted.click(&mut app, at).await;
assert_eq!(
app.multiselect_snapshot().unwrap().targets(),
&[Target::Placement(first), Target::Placement(second)]
);
assert_eq!(app.project(), &before.decoded.project);
assert_eq!(app.document.bundle(), &before.bundle);
assert!(!app.document.can_undo());
}
#[tokio::test]
async fn mounted_shift_held_before_suspension_survives_without_another_modifier_event() {
use crate::diagram_usage::Target;
let (mut app, mut mounted, diagram, first, second) = modifier_focus_fixture().await;
let before = app.document.snapshot();
let field = mounted
.geometry(&app)
.text_inputs
.into_iter()
.filter(|bounds| bounds.x > app.window_size.width - 320.)
.min_by(|a, b| a.y.total_cmp(&b.y))
.expect("clean Inspector name input");
mounted.operate(&app, &mut FocusField(field));
mounted
.batch(
&mut app,
&[modifier_event(true)],
mouse::Cursor::Unavailable,
)
.await;
assert!(!modifier_canvas_focused(&mut mounted, &app));
app.busy = true;
mounted.settle(&mut app).await;
assert!(!modifier_canvas_focused(&mut mounted, &app));
app.busy = false;
mounted.settle(&mut app).await;
assert!(
!modifier_canvas_focused(&mut mounted, &app),
"restoring passive modifiers must not grant keyboard focus"
);
assert_eq!(
app.multiselect_snapshot().unwrap().targets(),
&[Target::Placement(first)]
);
// No modifier transition follows the subtree replacement during I/O.
let at = modifier_node_point(&mut mounted, &app, diagram, second);
let messages = mounted.click(&mut app, at).await;
assert!(messages.iter().any(|m| matches!(m,
Message::DiagramSelection(multiselect::Event::Choose(_, Target::Placement(id), multiselect::Choice::Toggle)) if *id == second
)));
assert_eq!(
app.multiselect_snapshot().unwrap().targets(),
&[Target::Placement(first), Target::Placement(second)]
);
assert_eq!(app.project(), &before.decoded.project);
assert_eq!(app.document.bundle(), &before.bundle);
assert!(!app.document.can_undo());
assert!(!app.draft_dirty());
}
#[tokio::test]
async fn mounted_shift_release_or_window_unfocus_during_suspension_does_not_restore_stale_shift() {
use crate::diagram_usage::Target;
for release in [
modifier_event(false),
iced::Event::Window(iced::window::Event::Unfocused),
] {
let (mut app, mut mounted, diagram, first, second) = modifier_focus_fixture().await;
let before = app.document.snapshot();
mounted
.batch(
&mut app,
&[modifier_event(true)],
mouse::Cursor::Unavailable,
)
.await;
app.busy = true;
mounted.settle(&mut app).await;
mounted
.batch(&mut app, &[release], mouse::Cursor::Unavailable)
.await;
assert!(!modifier_canvas_focused(&mut mounted, &app));
app.busy = false;
mounted.settle(&mut app).await;
// A further I/O cycle must restore the updated owner state too.
app.busy = true;
mounted.settle(&mut app).await;
app.busy = false;
mounted.settle(&mut app).await;
assert!(!modifier_canvas_focused(&mut mounted, &app));
assert_eq!(
app.multiselect_snapshot().unwrap().targets(),
&[Target::Placement(first)]
);
let at = modifier_node_point(&mut mounted, &app, diagram, second);
let messages = mounted.click(&mut app, at).await;
assert!(!messages.iter().any(|m| matches!(
m,
Message::DiagramSelection(multiselect::Event::Choose(
_,
_,
multiselect::Choice::Toggle
))
)));
assert_eq!(
app.multiselect_snapshot().unwrap().targets(),
&[Target::Placement(second)]
);
assert_eq!(app.project(), &before.decoded.project);
assert_eq!(app.document.bundle(), &before.bundle);
assert!(!app.document.can_undo());
assert!(!app.draft_dirty());
}
}
#[tokio::test]
async fn mounted_window_unfocus_resets_held_shift_before_next_canvas_press() {
use crate::diagram_usage::Target;
let (mut app, mut mounted, diagram, first, second) = modifier_focus_fixture().await;
let at_first = modifier_node_point(&mut mounted, &app, diagram, first);
mounted.click(&mut app, at_first).await;
let before = app.document.snapshot();
mounted
.batch(
&mut app,
&[
modifier_event(true),
iced::Event::Window(iced::window::Event::Unfocused),
],
mouse::Cursor::Unavailable,
)
.await;
assert!(!modifier_canvas_focused(&mut mounted, &app));
// No ModifiersChanged(empty) follows: a lost window must itself reset it.
let at_second = modifier_node_point(&mut mounted, &app, diagram, second);
mounted.click(&mut app, at_second).await;
assert_eq!(
app.multiselect_snapshot().unwrap().targets(),
&[Target::Placement(second)]
);
assert_eq!(app.project(), &before.decoded.project);
assert_eq!(app.document.bundle(), &before.bundle);
assert!(!app.document.can_undo());
}
#[tokio::test]
async fn mounted_selection_strip_keeps_canvas_and_fitted_camera_fixed_for_empty_single_group_and_mixed()
{
use crate::diagram_usage::Target;
let mut project = Project::empty();
let a = project
.add_element(Kind::Activity, Layer::Oa, "First activity", None)
.unwrap();
let b = project
.add_element(
Kind::Activity,
Layer::Oa,
&format!(
"Second activity {}",
"with a very long primary name ".repeat(20)
),
None,
)
.unwrap();
let edge = project.relate(RelationKind::Interaction, "Activity exchange", a, b);
let diagram = project.ensure_diagram(Layer::Oa);
project.diagrams.get_mut(&diagram).unwrap().projection = ProjectionKind::Overview;
let first = project.place(diagram, a, Position { x: 80., y: 80. });
let second = project.place(diagram, b, Position { x: 500., y: 80. });
let mut app = App::with_project(project);
app.page = Page::Diagram;
app.layer = Layer::Oa;
app.active_diagram = Some(diagram);
app.window_size = Size::new(1442., 932.);
let _ = app.update(Message::Select(diagram));
app.fit_to_view = true;
let mut mounted = Mounted::new(&mut app).await;
let baseline = app.document.snapshot();
let bounds = mounted
.geometry(&app)
.bounds(crate::allocation_input::CANVAS_ID);
let camera = (app.zoom, app.pan, app.fit_to_view, app.canvas_size);
assert!(camera.2, "exercise the native resize-triggered refit path");
assert!(app.multiselect_snapshot().unwrap().targets().is_empty());
assert!(bounds.width > 500. && bounds.height > 300.);
for (target, expected) in [
(
Some(Target::Placement(first)),
vec![Target::Placement(first)],
),
(
Some(Target::Placement(second)),
vec![Target::Placement(first), Target::Placement(second)],
),
(
Some(Target::Edge(edge)),
vec![
Target::Placement(first),
Target::Placement(second),
Target::Edge(edge),
],
),
(
Some(Target::Edge(edge)),
vec![Target::Placement(first), Target::Placement(second)],
),
(
Some(Target::Placement(second)),
vec![Target::Placement(first)],
),
(None, vec![]),
] {
let context = app.multiselect_context().unwrap();
let event = match target {
Some(target) => {
multiselect::Event::Choose(context, target, multiselect::Choice::Toggle)
}
None => multiselect::Event::Clear(context),
};
let task = app.update(Message::DiagramSelection(event));
mounted.perform(&mut app, task).await;
mounted.settle(&mut app).await;
assert_eq!(app.multiselect_snapshot().unwrap().targets(), expected);
assert_eq!(
mounted
.geometry(&app)
.bounds(crate::allocation_input::CANVAS_ID),
bounds,
"selection feedback must not change the measured canvas rectangle"
);
assert_eq!(
(app.zoom, app.pan, app.fit_to_view, app.canvas_size),
camera
);
assert_eq!(app.project(), &baseline.decoded.project);
assert_eq!(app.document.bundle(), &baseline.bundle);
assert!(!app.document.can_undo());
assert!(!app.draft_dirty());
}
}
@@ -924,6 +924,8 @@ async fn mounted_bend_delete_has_priority_and_semantic_edge_delete_is_confirmed(
diagram,
route: EdgeRoute {
edge,
source_attachment: None,
target_attachment: None,
waypoints: vec![Position { x: 400., y: -100. }],
label: None,
},
+433
View File
@@ -0,0 +1,433 @@
//! Exact saved-view selection and atomic group commands.
use super::*;
use crate::diagram_selection::{Context, Scope, Selection, Snapshot};
use crate::diagram_usage::{Surface, Target};
#[derive(Debug, Clone, Copy)]
pub(crate) enum Choice {
Replace,
Add,
Remove,
Toggle,
Inspect,
}
#[derive(Debug, Clone)]
pub(crate) enum Event {
Choose(Context, Target, Choice),
Collapse(Snapshot, Target),
Clear(Context),
Move(Snapshot, Position),
Hide(Snapshot),
Refuse(Snapshot, String),
}
pub(crate) fn context(c: crate::route_edit::Context) -> Context {
Context {
scope: Scope {
document: c.document,
surface: Surface::Saved(c.diagram),
},
revision: c.revision,
epoch: c.epoch,
}
}
/// The entire typed set must support a placement action. Never filter a mixture.
pub(crate) fn placements(snapshot: &Snapshot) -> Option<std::collections::BTreeSet<Id>> {
snapshot.primary()?;
if snapshot.targets().is_empty() {
return None;
}
snapshot
.targets()
.iter()
.map(|target| match target {
Target::Placement(id) => Some(*id),
_ => None,
})
.collect()
}
fn visible(
project: &Project,
diagram: Id,
view: &projection::ProjectedView,
target: &Target,
) -> bool {
match target {
Target::Placement(id) => view.nodes.iter().any(|n| n.id == *id),
Target::Port { port, placement } => view
.ports
.iter()
.any(|p| p.element == *port && p.owner == *placement),
Target::Edge(id) => view.edges.iter().any(|e| e.id == *id),
Target::StateRow {
placement,
relation,
} => crate::behavior::state_canvas::visible_row_target(
project, diagram, view, *placement, *relation,
),
_ => false,
}
}
impl App {
pub(crate) fn multiselect_boundary(&self) -> impl PartialEq + use<> {
(
self.canvas_document,
self.document.revision(),
self.page,
self.active_diagram,
self.selected,
self.selected_node,
(self.page == Page::Behavior).then(|| self.state_view_identity()),
(self.page == Page::Behavior).then_some(self.behavior.diagram.placement),
)
}
pub(crate) fn multiselect_context(&self) -> Option<Context> {
match self.page {
Page::Diagram => self.route_context().map(context),
Page::Behavior => {
let current = self.state_context()?;
crate::behavior::state_edit::validate_context(self.project(), current, current)
.ok()?;
Some(context(current))
}
_ => None,
}
}
pub(crate) fn multiselect_target_visible(&self, target: &Target) -> bool {
let Some(Context {
scope:
Scope {
surface: Surface::Saved(diagram),
..
},
..
}) = self.multiselect_context()
else {
return false;
};
let view = projection::project_view_with_index(
self.project(),
diagram,
self.document.view_index(),
);
visible(self.project(), diagram, &view, target)
}
pub(crate) fn multiselect_snapshot(&self) -> Option<Snapshot> {
self.diagram_selection
.as_ref()?
.snapshot(self.multiselect_context()?)
}
pub(crate) fn multiselect_envelope(&self, event: &Event) -> bool {
let Some(current) = self.multiselect_context() else {
return false;
};
match event {
Event::Choose(c, ..) | Event::Clear(c) => *c == current,
Event::Move(s, _) | Event::Hide(s) | Event::Refuse(s, _) | Event::Collapse(s, _) => {
self.diagram_selection
.as_ref()
.is_some_and(|selection| selection.matches(s, current))
}
}
}
fn legacy_target(&self, diagram: Id, view: &projection::ProjectedView) -> Option<Target> {
let placement = if self.page == Page::Behavior {
self.behavior.diagram.placement
} else {
self.selected_node
};
if let Some(id) = placement
&& view.nodes.iter().any(|node| {
node.id == id && (node.element == self.selected || node.id == self.selected)
})
{
return Some(Target::Placement(id));
}
if self.page == Page::Behavior {
// Keep a row's exact occurrence while inspecting its relation.
// Its owner placement is never a node-inspection or Hide target.
if let Some(target @ Target::StateRow { relation, .. }) =
self.diagram_selection.as_ref().and_then(|s| s.primary())
&& *relation == self.selected
&& visible(self.project(), diagram, view, target)
{
return Some(target.clone());
}
if let Some(node) = view.nodes.iter().find(|node| {
crate::behavior::state_canvas::visible_row_target(
self.project(),
diagram,
view,
node.id,
self.selected,
)
}) {
return Some(Target::StateRow {
placement: node.id,
relation: self.selected,
});
}
}
if let Some(port) = view.ports.iter().find(|port| port.element == self.selected) {
return Some(Target::Port {
port: port.element,
placement: port.owner,
});
}
view.edges
.iter()
.any(|edge| edge.id == self.selected)
.then_some(Target::Edge(self.selected))
}
pub(crate) fn reconcile_multiselect(&mut self) {
let Some(current) = self.multiselect_context() else {
self.diagram_selection = None;
return;
};
let Surface::Saved(diagram) = current.scope.surface else {
return;
};
let view = projection::project_view_with_index(
self.project(),
diagram,
self.document.view_index(),
);
let legacy = self.legacy_target(diagram, &view);
let lost_primary = self
.diagram_selection
.as_ref()
.filter(|s| s.scope() == current.scope)
.and_then(|s| s.primary())
.is_some_and(|target| !visible(self.project(), diagram, &view, target));
let primary_placement = if self.page == Page::Behavior {
self.behavior.diagram.placement
} else {
self.selected_node
};
let held_primary = self
.diagram_selection
.as_ref()
.filter(|s| s.scope() == current.scope)
.and_then(|s| s.primary())
.is_some_and(|target| match target {
Target::Placement(id) => primary_placement == Some(*id),
Target::Port { port, .. } => self.selected == *port,
Target::Edge(id) => self.selected == *id,
Target::StateRow { relation, .. } => {
self.selected == *relation && primary_placement.is_none()
}
_ => false,
});
let project = self.document.project();
let selection = self
.diagram_selection
.get_or_insert_with(|| Selection::new(current.scope));
selection.reconcile(current.scope, |target| {
visible(project, diagram, &view, target)
});
// Navigation/property selection remains explicit. A removed exact target
// is never rebound through its semantic identity to a different placement.
if held_primary || lost_primary {
if selection.primary() != legacy.as_ref() {
self.inspect_multiselect_primary();
}
} else if selection.primary() != legacy.as_ref() {
if let Some(target) = legacy {
selection.replace(target, |target| visible(project, diagram, &view, target));
} else {
selection.clear();
}
}
}
fn inspect_multiselect_primary(&mut self) {
let diagram = self
.multiselect_context()
.and_then(|c| match c.scope.surface {
Surface::Saved(diagram) => Some(diagram),
_ => None,
});
let primary = self
.diagram_selection
.as_ref()
.and_then(|s| s.primary())
.cloned();
self.selected_node = None;
if self.page == Page::Behavior {
self.behavior.diagram.placement = None;
}
match primary {
Some(Target::Placement(id)) => {
if let Some(node) = diagram
.and_then(|d| self.project().diagrams.get(&d))
.and_then(|d| d.nodes.get(&id))
{
self.selected = node.element;
if self.page == Page::Behavior {
self.behavior.diagram.placement = Some(id);
} else {
self.selected_node = Some(id);
}
}
}
Some(Target::Port { port, .. }) => self.selected = port,
Some(Target::Edge(id)) => self.selected = id,
Some(Target::StateRow { relation, .. }) => self.selected = relation,
_ => self.selected = diagram.unwrap_or(self.project().id),
}
self.selected_bend = None;
self.selected_attachment = None;
self.refresh_draft();
}
pub(crate) fn multiselect_event(&mut self, event: Event) {
if !self.multiselect_envelope(&event) {
return;
}
if !diagram_removal::enabled(self)
|| self.architecture.is_some()
|| self.connection.is_some()
|| (self.page == Page::Behavior
&& self.behavior.diagram.tool != crate::behavior::state_edit::Tool::Select)
{
self.status =
"Finish or cancel the active edit before changing the diagram selection.".into();
return;
}
let current = self.multiselect_context().unwrap();
let Surface::Saved(diagram) = current.scope.surface else {
return;
};
let view = projection::project_view_with_index(
self.project(),
diagram,
self.document.view_index(),
);
match event {
Event::Collapse(_, target) => {
if !visible(self.project(), diagram, &view, &target) {
return;
}
let project = self.document.project();
self.diagram_selection
.as_mut()
.unwrap()
.replace(target, |target| visible(project, diagram, &view, target));
self.inspect_multiselect_primary();
}
Event::Choose(_, target, choice) => {
if !visible(self.project(), diagram, &view, &target) {
return;
}
self.reconcile_multiselect();
let project = self.document.project();
let selection = self.diagram_selection.as_mut().unwrap();
let valid = |target: &Target| visible(project, diagram, &view, target);
match choice {
Choice::Replace => {
selection.replace(target, valid);
}
Choice::Add => {
selection.add(target, valid);
}
Choice::Remove => {
selection.remove(&target);
}
Choice::Toggle => {
selection.toggle(target, valid);
}
Choice::Inspect => {
selection.set_primary(&target);
}
}
self.inspect_multiselect_primary();
}
Event::Clear(_) => {
if let Some(selection) = &mut self.diagram_selection {
selection.clear();
}
self.inspect_multiselect_primary();
}
Event::Refuse(_, reason) => self.status = reason,
Event::Move(snapshot, delta) => {
let Some(ids) = placements(&snapshot) else {
self.status = "Move requires only placed elements. Remove ports, rows and connections from the selection.".into();
return;
};
if !snapshot
.targets()
.iter()
.all(|target| visible(self.project(), diagram, &view, target))
{
return;
}
if self.project().diagrams[&diagram].projection == ProjectionKind::Allocation
&& ids.iter().any(|id| {
self.project().elements
[&self.project().diagrams[&diagram].nodes[id].element]
.kind()
.is_work()
})
{
self.status =
"Group allocation drag is not available yet. The selection was retained."
.into();
return;
}
if delta.x == 0. && delta.y == 0. {
return;
}
match oa_core::diagram_edit::translate_placements(
self.project(),
diagram,
&ids,
delta,
) {
Ok(command) => {
if self.execute(command) {
self.inspect_multiselect_primary();
self.status = format!(
"Moved {} selected elements · Undo restores the group",
ids.len()
);
}
}
Err(error) => self.status = error.to_string(),
}
}
Event::Hide(snapshot) => {
let Some(ids) = placements(&snapshot) else {
self.status =
"Hide requires only placed elements. Model objects remain unchanged."
.into();
return;
};
if !snapshot
.targets()
.iter()
.all(|target| visible(self.project(), diagram, &view, target))
{
return;
}
if self.execute(Command::Batch(
ids.iter().copied().map(Command::Delete).collect(),
)) {
if let Some(selection) = &mut self.diagram_selection {
selection.clear();
}
self.inspect_multiselect_primary();
self.status = format!("Hid {} placements · Model objects retained", ids.len());
}
}
}
}
}
@@ -0,0 +1,602 @@
//! Saved-view group operations through the real App message boundary.
use super::multiselect::{Choice as GroupChoice, Event as GroupEvent};
use super::*;
use crate::diagram_usage::Target;
fn point(x: f32, y: f32) -> Position {
Position { x, y }
}
struct Fixture {
app: App,
diagram: Id,
a: Id,
b: Id,
child: Id,
edge: Id,
}
fn fixture() -> Fixture {
let mut p = Project::empty();
let a = p
.add_element(Kind::Function, Layer::Sa, "Send", None)
.unwrap();
let b = p
.add_element(Kind::Function, Layer::Sa, "Receive", None)
.unwrap();
let child = p
.add_element(Kind::Function, Layer::Sa, "Prepare", Some(a))
.unwrap();
let edge = p.relate(RelationKind::FunctionalExchange, "Signal", a, b);
let diagram = p.ensure_diagram(Layer::Sa);
p.diagrams.get_mut(&diagram).unwrap().projection = ProjectionKind::FunctionalDataflow;
let a = p.place(diagram, a, point(10., 20.));
let b = p.place(diagram, b, point(500., 20.));
let child = p.place(diagram, child, point(40., 100.));
p.diagrams
.get_mut(&diagram)
.unwrap()
.routes
.push(EdgeRoute {
edge,
source_attachment: Some(EdgeAttachment {
side: PortAnchorSide::Bottom,
offset: 0.3,
}),
target_attachment: Some(EdgeAttachment {
side: PortAnchorSide::Top,
offset: 0.7,
}),
waypoints: vec![point(370., 150.)],
label: Some(EdgeLabel {
fraction: 0.4,
offset: point(5., -9.),
}),
});
let other = p
.add_element(Kind::Activity, Layer::Oa, "Operational work", None)
.unwrap();
let other_diagram = p.ensure_diagram(Layer::Oa);
p.place(other_diagram, other, point(-50., -100.));
let mut app = App::with_project(p);
app.page = Page::Diagram;
app.layer = Layer::Sa;
app.active_diagram = Some(diagram);
app.selected = diagram;
app.selected_node = None;
app.refresh_draft();
app.reconcile_multiselect();
Fixture {
app,
diagram,
a,
b,
child,
edge,
}
}
fn dispatch(app: &mut App, event: GroupEvent) {
let _ = app.update(Message::DiagramSelection(event));
}
fn choose(app: &mut App, target: Target, choice: GroupChoice) {
let context = app.multiselect_context().unwrap();
dispatch(app, GroupEvent::Choose(context, target, choice));
}
fn select_pair(f: &mut Fixture) {
choose(&mut f.app, Target::Placement(f.a), GroupChoice::Replace);
choose(&mut f.app, Target::Placement(f.b), GroupChoice::Toggle);
assert_eq!(
f.app.multiselect_snapshot().unwrap().targets(),
&[Target::Placement(f.a), Target::Placement(f.b)]
);
}
#[test]
fn queued_singleton_move_or_delete_cannot_partially_edit_a_new_group() {
for model in [false, true] {
let mut f = fixture();
select_pair(&mut f);
let before = f.app.document.snapshot();
let selected = f.app.multiselect_snapshot().unwrap();
let object = f.app.project().diagrams[&f.diagram].nodes[&f.b].element;
for message in [
Message::MoveNode(f.b, point(700., 600.)),
Message::DiagramRemove(super::diagram_removal::Intent {
context: f.app.route_context().unwrap(),
object,
placement: Some(f.b),
model,
}),
Message::Delete,
] {
let _ = f.app.update(message);
assert_eq!(f.app.project(), &before.decoded.project);
assert_eq!(f.app.document.bundle(), &before.bundle);
assert_eq!(
f.app.multiselect_snapshot().unwrap().targets(),
selected.targets()
);
assert!(!f.app.delete_pending);
assert!(!f.app.document.can_undo());
}
}
}
#[test]
fn legacy_hide_action_applies_to_the_complete_visible_group() {
let mut f = fixture();
select_pair(&mut f);
let before = f.app.document.snapshot();
let mut expected = f.app.project().clone();
let view = expected.diagrams.get_mut(&f.diagram).unwrap();
view.nodes.remove(&f.a);
view.nodes.remove(&f.b);
let _ = f.app.update(Message::HideSelected);
assert_eq!(f.app.project(), &expected);
assert_source_matches_model(&f.app);
let _ = f.app.update(Message::Undo);
assert_eq!(f.app.document.bundle(), &before.bundle);
assert!(!f.app.document.can_undo());
}
fn assert_source_matches_model(app: &App) {
let compiled = oa_core::source_project::compile_bundle(app.document.bundle(), LIMITS).unwrap();
assert_eq!(&compiled.decoded.project, app.project());
assert_eq!(&compiled.bundle, app.document.bundle());
}
#[test]
fn toggled_group_moves_model_and_source_once_and_keeps_primary_through_history() {
let mut f = fixture();
select_pair(&mut f);
let before = f.app.document.snapshot();
let snapshot = f.app.multiselect_snapshot().unwrap();
assert_eq!(snapshot.primary(), Some(&Target::Placement(f.b)));
let mut expected = f.app.project().clone();
let view = expected.diagrams.get_mut(&f.diagram).unwrap();
view.nodes.get_mut(&f.a).unwrap().position = point(50., 80.);
view.nodes.get_mut(&f.b).unwrap().position = point(540., 80.);
view.nodes.get_mut(&f.child).unwrap().position = point(80., 160.);
view.routes[0].waypoints = vec![point(410., 210.)];
dispatch(
&mut f.app,
GroupEvent::Move(snapshot.clone(), point(40., 60.)),
);
assert_eq!(f.app.project(), &expected);
assert_source_matches_model(&f.app);
assert_eq!(f.app.selected_node, Some(f.b));
assert_eq!(
f.app.multiselect_snapshot().unwrap().targets(),
snapshot.targets()
);
let after = f.app.document.snapshot();
let _ = f.app.update(Message::Undo);
assert_eq!(f.app.project(), &before.decoded.project);
assert_eq!(f.app.document.bundle(), &before.bundle);
assert!(!f.app.document.can_undo());
let _ = f.app.update(Message::Redo);
assert_eq!(f.app.project(), &expected);
assert_eq!(f.app.document.bundle(), &after.bundle);
assert!(!f.app.document.can_redo());
assert_eq!(f.app.selected_node, Some(f.b));
}
#[test]
fn ancestor_and_child_selection_moves_child_exactly_once() {
let mut f = fixture();
choose(&mut f.app, Target::Placement(f.a), GroupChoice::Replace);
choose(&mut f.app, Target::Placement(f.child), GroupChoice::Toggle);
let snapshot = f.app.multiselect_snapshot().unwrap();
assert_eq!(snapshot.targets().len(), 2);
let mut expected = f.app.project().clone();
let view = expected.diagrams.get_mut(&f.diagram).unwrap();
view.nodes.get_mut(&f.a).unwrap().position = point(0., 45.);
view.nodes.get_mut(&f.child).unwrap().position = point(30., 125.);
dispatch(&mut f.app, GroupEvent::Move(snapshot, point(-10., 25.)));
assert_eq!(f.app.project(), &expected);
assert_eq!(f.app.selected_node, Some(f.child));
assert_source_matches_model(&f.app);
}
#[test]
fn mixed_edge_or_port_selection_refuses_the_entire_move() {
for include_port in [false, true] {
let mut f = fixture();
choose(&mut f.app, Target::Placement(f.a), GroupChoice::Replace);
let extra = if include_port {
let view = projection::project_view(f.app.project(), f.diagram);
let port = view
.ports
.iter()
.find(|p| p.owner == f.a)
.expect("functional exchange exposes its typed owner port");
Target::Port {
port: port.element,
placement: port.owner,
}
} else {
Target::Edge(f.edge)
};
choose(&mut f.app, extra.clone(), GroupChoice::Toggle);
let selection = f.app.multiselect_snapshot().unwrap();
assert_eq!(selection.targets(), &[Target::Placement(f.a), extra]);
let before = f.app.document.snapshot();
let revision = f.app.document.revision();
dispatch(
&mut f.app,
GroupEvent::Move(selection.clone(), point(40., 60.)),
);
assert_eq!(f.app.project(), &before.decoded.project);
assert_eq!(f.app.document.bundle(), &before.bundle);
assert_eq!(f.app.document.revision(), revision);
assert!(!f.app.document.can_undo());
assert_eq!(
f.app.multiselect_snapshot().unwrap().targets(),
selection.targets()
);
assert!(f.app.status.contains("Move requires only placed elements"));
}
}
#[test]
fn stale_operation_envelopes_have_no_selection_history_status_or_allocation_side_effects() {
// Isolate each lifetime component, including the same visible set recreated
// by a remove/re-add cycle. Accepted setup changes are outside the assertion.
for stale_by in ["epoch", "revision", "document", "selection generation"] {
for action in ["move", "hide", "refuse", "collapse"] {
let mut f = fixture();
select_pair(&mut f);
let stale = f.app.multiselect_snapshot().unwrap();
match stale_by {
"epoch" => f.app.canvas_epoch += 1,
"revision" => {
let semantic = f.app.project().diagrams[&f.diagram].nodes[&f.a].element;
// Advance only the document revision, retaining this canvas
// and selection epoch to independently test revision checking.
f.app
.document
.execute(Command::Describe(semantic, "New revision".into()))
.unwrap();
}
"document" => f.app.canvas_document = Id::new_v4(),
_ => {
choose(&mut f.app, Target::Placement(f.b), GroupChoice::Toggle);
choose(&mut f.app, Target::Placement(f.b), GroupChoice::Toggle);
assert_eq!(
f.app.multiselect_snapshot().unwrap().targets(),
stale.targets()
);
}
}
f.app.status = "Current feedback must survive".into();
f.app.allocation_drag.arrange_next = true;
let before = f.app.document.snapshot();
let selection = f.app.multiselect_snapshot();
let legacy = (f.app.selected, f.app.selected_node);
let revision = f.app.document.revision();
let epochs = (
f.app.canvas_epoch,
f.app.camera_epoch,
f.app.allocation_drag.epoch,
);
let history = (f.app.document.can_undo(), f.app.document.can_redo());
let draft = f
.app
.draft
.as_ref()
.map(|d| serde_json::to_value(d.recovery_snapshot()).unwrap());
let event = match action {
"move" => GroupEvent::Move(stale, point(99., 100.)),
"hide" => GroupEvent::Hide(stale),
"collapse" => GroupEvent::Collapse(stale, Target::Placement(f.a)),
_ => GroupEvent::Refuse(stale, "Obsolete refusal".into()),
};
dispatch(&mut f.app, event);
assert_eq!(
f.app.project(),
&before.decoded.project,
"{stale_by}/{action}"
);
assert_eq!(f.app.document.bundle(), &before.bundle);
assert_eq!(f.app.document.revision(), revision);
assert_eq!(f.app.multiselect_snapshot(), selection);
assert_eq!((f.app.selected, f.app.selected_node), legacy);
assert_eq!(
(
f.app.canvas_epoch,
f.app.camera_epoch,
f.app.allocation_drag.epoch
),
epochs
);
assert_eq!(
(f.app.document.can_undo(), f.app.document.can_redo()),
history
);
assert_eq!(f.app.status, "Current feedback must survive");
assert!(f.app.allocation_drag.arrange_next);
assert_eq!(
f.app
.draft
.as_ref()
.map(|d| serde_json::to_value(d.recovery_snapshot()).unwrap()),
draft
);
}
}
}
#[test]
fn pending_valid_and_invalid_property_drafts_refuse_group_edits_without_applying_them() {
for (path, value) in [("/name", "Unapplied name"), ("/id", "-")] {
for hide in [false, true] {
let mut f = fixture();
select_pair(&mut f);
let _ = f.app.update(Message::Field(path.into(), value.into()));
assert!(f.app.draft_dirty());
let snapshot = f.app.multiselect_snapshot().unwrap();
let before = f.app.document.snapshot();
let draft =
serde_json::to_value(f.app.draft.as_ref().unwrap().recovery_snapshot()).unwrap();
let event = if hide {
GroupEvent::Hide(snapshot.clone())
} else {
GroupEvent::Move(snapshot.clone(), point(20., 30.))
};
dispatch(&mut f.app, event);
assert_eq!(f.app.project(), &before.decoded.project);
assert_eq!(f.app.document.bundle(), &before.bundle);
assert!(!f.app.document.can_undo());
assert_eq!(
f.app.multiselect_snapshot().unwrap().targets(),
snapshot.targets()
);
assert_eq!(
serde_json::to_value(f.app.draft.as_ref().unwrap().recovery_snapshot()).unwrap(),
draft
);
assert!(f.app.status.contains("Finish or cancel"));
}
}
}
#[test]
fn hiding_a_group_removes_only_exact_placements_and_roundtrips_in_one_history_step() {
let mut f = fixture();
select_pair(&mut f);
let snapshot = f.app.multiselect_snapshot().unwrap();
let before = f.app.document.snapshot();
let mut expected = f.app.project().clone();
let view = expected.diagrams.get_mut(&f.diagram).unwrap();
view.nodes.remove(&f.a);
view.nodes.remove(&f.b);
dispatch(&mut f.app, GroupEvent::Hide(snapshot));
assert_eq!(
f.app.project(),
&expected,
"engineering objects, descendant placement, authored route and opposite-layer view survive"
);
assert!(f.app.multiselect_snapshot().unwrap().targets().is_empty());
assert_source_matches_model(&f.app);
let after = f.app.document.snapshot();
let _ = f.app.update(Message::Undo);
assert_eq!(f.app.project(), &before.decoded.project);
assert_eq!(f.app.document.bundle(), &before.bundle);
assert!(!f.app.document.can_undo());
let _ = f.app.update(Message::Redo);
assert_eq!(f.app.project(), &expected);
assert_eq!(f.app.document.bundle(), &after.bundle);
assert!(!f.app.document.can_redo());
}
fn assert_surviving_primary(f: &Fixture) {
let selection = f.app.multiselect_snapshot().unwrap();
assert_eq!(selection.targets(), &[Target::Placement(f.a)]);
assert_eq!(selection.primary(), Some(&Target::Placement(f.a)));
assert_eq!(f.app.selected_node, Some(f.a));
assert_eq!(
f.app.selected,
f.app.project().diagrams[&f.diagram].nodes[&f.a].element
);
}
#[test]
fn removing_primary_placement_from_model_preserves_exact_survivor_after_undo() {
let mut f = fixture();
select_pair(&mut f);
let before = f.app.document.snapshot();
let _ = f.app.update(Message::Command(Command::Delete(f.b)));
assert!(
!f.app.project().diagrams[&f.diagram]
.nodes
.contains_key(&f.b)
);
assert_surviving_primary(&f);
let _ = f.app.update(Message::Undo);
assert_eq!(f.app.project(), &before.decoded.project);
assert_eq!(f.app.document.bundle(), &before.bundle);
assert_surviving_primary(&f);
let _ = f.app.update(Message::Redo);
assert_surviving_primary(&f);
}
#[test]
fn deleting_primary_model_object_then_undo_does_not_restore_removed_selection_membership() {
let mut f = fixture();
select_pair(&mut f);
let before = f.app.document.snapshot();
let semantic = f.app.project().diagrams[&f.diagram].nodes[&f.b].element;
let _ = f.app.update(Message::Command(Command::Delete(semantic)));
assert!(!f.app.project().elements.contains_key(&semantic));
assert_surviving_primary(&f);
let _ = f.app.update(Message::Undo);
assert_eq!(f.app.project(), &before.decoded.project);
assert_eq!(f.app.document.bundle(), &before.bundle);
assert_surviving_primary(&f);
let _ = f.app.update(Message::Redo);
assert_surviving_primary(&f);
}
#[test]
fn replacing_primary_occurrence_with_same_semantic_placement_does_not_retarget_selection() {
let mut f = fixture();
select_pair(&mut f);
let mut candidate = f.app.project().clone();
let view = candidate.diagrams.get_mut(&f.diagram).unwrap();
let mut replacement = view.nodes.remove(&f.b).unwrap();
let replacement_id = Id::new_v4();
replacement.id = replacement_id;
replacement.alias = "replacement_receive".into();
replacement.position = point(800., 300.);
view.nodes.insert(replacement_id, replacement);
// Replace preserves the semantic inspector identity. Its normal sync used
// to rediscover the new occurrence through that identity and steal the set.
let _ = f
.app
.update(Message::Command(Command::Replace(candidate.clone())));
assert_eq!(f.app.project(), &candidate);
assert_surviving_primary(&f);
assert!(
!f.app
.multiselect_snapshot()
.unwrap()
.targets()
.contains(&Target::Placement(replacement_id))
);
assert_source_matches_model(&f.app);
let _ = f.app.update(Message::Undo);
assert!(
f.app.project().diagrams[&f.diagram]
.nodes
.contains_key(&f.b)
);
assert_surviving_primary(&f);
}
#[test]
fn explicit_surface_navigation_and_return_never_resurrect_the_group() {
for destination in [Page::Catalog, Page::Behavior] {
let mut f = fixture();
select_pair(&mut f);
let before = f.app.document.snapshot();
let stale = f.app.multiselect_snapshot().unwrap();
let _ = f.app.update(Message::Page(destination));
assert!(f.app.multiselect_snapshot().is_none());
let _ = f.app.update(Message::Page(Page::Diagram));
let live = f.app.multiselect_snapshot().unwrap();
assert!(
live.targets().len() <= 1,
"navigation may inspect one exact member but must not restore the group"
);
dispatch(&mut f.app, GroupEvent::Move(stale, point(400., 500.)));
assert_eq!(f.app.project(), &before.decoded.project);
assert_eq!(f.app.document.bundle(), &before.bundle);
assert!(!f.app.document.can_undo());
}
}
fn open_placement_menu(f: &mut Fixture, placement: Id) {
let semantic = f.app.project().diagrams[&f.diagram].nodes[&placement].element;
let mut request = crate::model_menu::Request::object(semantic, iced::Point::new(100., 100.));
request.placement = Some(placement);
request.canvas = f.app.route_context();
let _ = f
.app
.update(Message::Menu(crate::model_menu::Event::Open(request)));
assert!(f.app.model_menu.is_some());
}
fn menu_action(app: &App, caption: &str) -> Message {
crate::model_menu::popup(app)
.unwrap()
.entries
.into_iter()
.find_map(|entry| match entry {
crate::context_menu::Entry::Action { label, message, .. } if label == caption => {
message
}
_ => None,
})
.unwrap_or_else(|| panic!("enabled menu action {caption:?} must exist"))
}
#[test]
fn context_menu_dismissal_preserves_group_and_add_keeps_pre_menu_members() {
let mut f = fixture();
select_pair(&mut f);
let before = f.app.document.snapshot();
let selected = f.app.multiselect_snapshot().unwrap();
let child = f.child;
open_placement_menu(&mut f, child);
assert_eq!(
f.app.multiselect_snapshot().unwrap().targets(),
selected.targets()
);
assert_eq!(
f.app.multiselect_snapshot().unwrap().primary(),
selected.primary()
);
let _ = f.app.update(Message::Menu(crate::model_menu::Event::Close));
assert_eq!(
f.app.multiselect_snapshot().unwrap().targets(),
selected.targets()
);
assert_eq!(
f.app.multiselect_snapshot().unwrap().primary(),
selected.primary()
);
open_placement_menu(&mut f, child);
let add = menu_action(&f.app, "Add to selection");
let _ = f.app.update(add);
let selected = f.app.multiselect_snapshot().unwrap();
assert_eq!(
selected.targets(),
&[
Target::Placement(f.a),
Target::Placement(f.b),
Target::Placement(child)
]
);
assert_eq!(selected.primary(), Some(&Target::Placement(child)));
assert_eq!(f.app.project(), &before.decoded.project);
assert_eq!(f.app.document.bundle(), &before.bundle);
assert!(!f.app.document.can_undo());
}
#[test]
fn selected_member_context_menu_exposes_and_executes_group_hide_not_singleton_deletion() {
let mut f = fixture();
select_pair(&mut f);
let before = f.app.document.snapshot();
let member = f.a;
open_placement_menu(&mut f, member);
let popup = crate::model_menu::popup(&f.app).unwrap();
for entry in &popup.entries {
if let crate::context_menu::Entry::Action {
message: Some(Message::Menu(crate::model_menu::Event::Invoke(_, action))),
..
} = entry
{
assert!(
!matches!(
action.as_ref(),
Message::HideSelected | Message::Delete | Message::DiagramRemove(_)
),
"a selected-member group menu must not expose singleton removal"
);
}
}
let hide = menu_action(&f.app, "Hide selected placements");
let _ = f.app.update(hide);
let mut expected = before.decoded.project.clone();
let view = expected.diagrams.get_mut(&f.diagram).unwrap();
view.nodes.remove(&f.a);
view.nodes.remove(&f.b);
assert_eq!(f.app.project(), &expected);
assert!(!f.app.delete_pending);
assert!(f.app.model_menu.is_none());
assert_source_matches_model(&f.app);
let _ = f.app.update(Message::Undo);
assert_eq!(f.app.project(), &before.decoded.project);
assert_eq!(f.app.document.bundle(), &before.bundle);
assert!(!f.app.document.can_undo());
}
@@ -0,0 +1,161 @@
//! Production App tree and its actual keyboard subscription, without a canvas click.
use super::*;
use crate::behavior::canvas_workspace::tests::{Mounted, key};
use iced::{
advanced::subscription,
futures::{StreamExt, stream, task::noop_waker_ref},
keyboard::key::Named,
mouse,
};
use std::task::{Context as PollContext, Poll};
async fn press(mounted: &mut Mounted, app: &mut App, named: Named) -> Vec<Message> {
let input = key(named);
let (mut messages, statuses) = mounted.input_with_status(
app,
std::slice::from_ref(&input),
mouse::Cursor::Unavailable,
);
let interaction = subscription::Event::Interaction {
window: iced::window::Id::unique(),
event: input,
status: statuses[0],
};
for recipe in subscription::into_recipes(app.subscription()) {
let mut output = recipe.stream(Box::pin(stream::iter([interaction.clone()])));
let mut cx = PollContext::from_waker(noop_waker_ref());
while let Poll::Ready(Some(message)) = output.poll_next_unpin(&mut cx) {
messages.push(message);
}
}
for message in messages.iter().cloned() {
let task = app.update(message);
mounted.perform(app, task).await;
}
mounted.settle(app).await;
messages
}
async fn armed() -> (App, Mounted, Id) {
let mut f = fixture();
let diagram = create_view(&mut f).diagram;
let mut project = f.app.project().clone();
let relation = project.relate(RelationKind::StateTransition, "Switch", f.a, f.b);
let mut app = App::with_project(project);
app.window_size = iced::Size::new(1800., 1100.);
send(&mut app, Message::RevealInDiagram(diagram, relation));
let mut mounted = Mounted::new(&mut app).await;
app.behavior.diagram.fit = false;
let context = app.state_context().unwrap();
event(
&mut app,
Event::ContextMenu {
context,
object: relation,
placement: None,
selection_target: None,
anchor: iced::Point::new(450., 400.),
bend: None,
route_segment: None,
},
);
mounted.settle(&mut app).await;
let token = app.model_menu.as_ref().unwrap().token;
send(
&mut app,
Message::Menu(crate::model_menu::Event::Invoke(
token,
Box::new(Message::StateDiagram(Event::Choose(
Tool::AdjustAttachment {
relation,
end: EdgeEnd::Source,
},
))),
)),
);
mounted.settle(&mut app).await;
assert!(app.model_menu.is_none());
assert_eq!(
app.behavior.diagram.tool,
Tool::AdjustAttachment {
relation,
end: EdgeEnd::Source
}
);
(app, mounted, relation)
}
fn canvas_focus(mounted: &mut Mounted, app: &App) -> (iced::Rectangle, bool) {
let id = iced::advanced::widget::Id::from("diagram-keyboard-owner");
let (_, bounds, focused) = mounted
.geometry(app)
.focused
.into_iter()
.find(|(candidate, _, _)| candidate.as_ref() == Some(&id))
.unwrap();
(bounds, focused)
}
#[tokio::test]
async fn mounted_menu_armed_attachment_escape_without_canvas_click_preserves_document_and_focus() {
let (mut app, mut mounted, _) = armed().await;
let before = app.document.snapshot();
let history = (app.document.can_undo(), app.document.can_redo());
let camera = (
app.behavior.diagram.zoom,
app.behavior.diagram.pan,
app.behavior.diagram.fit,
);
assert!(!canvas_focus(&mut mounted, &app).1);
let messages = press(&mut mounted, &mut app, Named::Escape).await;
assert!(messages.iter().any(|m| matches!(m, Message::Escape)));
assert_eq!(app.behavior.diagram.tool, Tool::Select);
assert!(!canvas_focus(&mut mounted, &app).1);
press(&mut mounted, &mut app, Named::Delete).await;
assert!(
!app.delete_pending,
"Escape must not grant canvas Delete ownership"
);
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
assert_eq!(app.document.bundle(), &before.bundle);
assert_eq!(app.project(), &before.decoded.project);
assert_eq!((app.document.can_undo(), app.document.can_redo()), history);
assert_eq!(
(
app.behavior.diagram.zoom,
app.behavior.diagram.pan,
app.behavior.diagram.fit
),
camera
);
}
#[tokio::test]
async fn mounted_attachment_escape_preserves_modal_priority_and_focused_canvas_control() {
let (mut app, mut mounted, _) = armed().await;
let before = app.document.snapshot();
// Existing modal Escape consumes the first key without canceling the tool underneath.
send(&mut app, Message::Delete);
assert!(app.delete_pending && app.delete_target.is_some());
mounted.settle(&mut app).await;
press(&mut mounted, &mut app, Named::Escape).await;
assert!(!app.delete_pending);
assert!(matches!(
app.behavior.diagram.tool,
Tool::AdjustAttachment { .. }
));
// A real canvas click grants ownership; inner-canvas Escape remains live.
let bounds = canvas_focus(&mut mounted, &app).0;
mounted
.click(&mut app, iced::Point::new(bounds.x + 10., bounds.y + 10.))
.await;
assert!(canvas_focus(&mut mounted, &app).1);
let messages = press(&mut mounted, &mut app, Named::Escape).await;
assert!(
messages
.iter()
.any(|m| matches!(m, Message::StateDiagram(Event::Choose(Tool::Select))))
);
assert_eq!(app.behavior.diagram.tool, Tool::Select);
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
}
@@ -0,0 +1,481 @@
use super::*;
#[test]
fn state_bend_delayed_commit_after_same_view_selection_is_inert_and_live_commit_works() {
let (mut app, _, relation, _) = setup();
let context = app.state_context().unwrap();
let next = app.project().relations[&relation].target();
let waypoints = vec![Position { x: 715., y: -425. }];
let delayed = Event::SetBends {
context,
relation,
waypoints: waypoints.clone(),
};
send(&mut app, Message::Select(next));
assert_eq!(app.selected, next);
assert_eq!(
app.state_context(),
Some(context),
"same-view selection does not retire the context stamp"
);
let before = app.document.snapshot();
let camera = (
app.behavior.diagram.zoom,
app.behavior.diagram.pan,
app.behavior.diagram.fit,
);
event(&mut app, delayed);
assert_eq!(app.selected, next);
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
assert_eq!(app.project(), &before.decoded.project);
assert_eq!(app.document.bundle(), &before.bundle);
assert!(!app.document.can_undo());
assert_eq!(
(
app.behavior.diagram.zoom,
app.behavior.diagram.pan,
app.behavior.diagram.fit
),
camera
);
send(&mut app, Message::Select(relation));
let context = app.state_context().unwrap();
event(
&mut app,
Event::SetBends {
context,
relation,
waypoints: waypoints.clone(),
},
);
let mut expected = before.decoded.project.clone();
let route = expected
.diagrams
.get_mut(&context.diagram)
.unwrap()
.routes
.iter_mut()
.find(|route| route.edge == relation)
.unwrap();
route.waypoints = waypoints;
assert_eq!(app.project(), &expected);
assert_source_model_agree(&app);
send(&mut app, Message::Undo);
assert_eq!(app.document.bundle(), &before.bundle);
assert!(
!app.document.can_undo(),
"live drag remains a single transaction"
);
}
#[test]
fn state_bend_menu_carries_exact_segment_and_pin_without_changing_delete_action() {
let (mut app, _, relation, _) = setup();
let context = app.state_context().unwrap();
let point = Position { x: 500., y: -400. };
event(
&mut app,
Event::ContextMenu {
context,
object: relation,
placement: None,
selection_target: None,
anchor: iced::Point::ORIGIN,
bend: Some(1),
route_segment: Some((2, point)),
},
);
let menu = crate::model_menu::popup(&app).unwrap();
let mut add = false;
let mut remove = false;
let mut delete = false;
for entry in menu.entries {
if let crate::context_menu::Entry::Action {
message: Some(Message::Menu(crate::model_menu::Event::Invoke(_, message))),
..
} = entry
{
match *message {
Message::StateDiagram(Event::AddBend {
relation: id,
segment: Some((2, at)),
..
}) => {
assert_eq!(id, relation);
assert_eq!(at, point);
add = true;
}
Message::StateDiagram(Event::RemoveBend {
relation: id,
index: 1,
..
}) => {
assert_eq!(id, relation);
remove = true;
}
Message::Delete => delete = true,
_ => {}
}
}
}
assert!(add && remove && delete);
}
fn setup() -> (App, Id, Id, Id) {
let mut f = fixture();
let diagram = create_view(&mut f).diagram;
let mut project = f.app.project().clone();
let relation = project.relate(RelationKind::StateTransition, "Bend trial", f.leaf, f.b);
let second = state_edit::create_view(&project, f.machine).unwrap();
project = prepare_command(&project, &second.command).unwrap().project;
project
.diagrams
.get_mut(&diagram)
.unwrap()
.routes
.push(EdgeRoute {
edge: relation,
waypoints: vec![
Position { x: 650., y: -400. },
Position { x: 300., y: -400. },
Position { x: 650., y: -400. },
],
label: Some(EdgeLabel {
fraction: 0.35,
offset: Position { x: 9., y: -23. },
}),
source_attachment: Some(EdgeAttachment {
side: PortAnchorSide::Top,
offset: 0.234567,
}),
target_attachment: Some(EdgeAttachment {
side: PortAnchorSide::Bottom,
offset: 0.75,
}),
});
let mut app = App::with_project(project);
send(&mut app, Message::RevealInDiagram(diagram, relation));
(app, diagram, relation, f.a)
}
#[test]
fn state_bend_commands_preserve_other_fields_views_and_exact_history() {
let (mut app, diagram, relation, _) = setup();
let baseline = app.document.snapshot();
let points = vec![Position { x: 715., y: 425. }];
let mut expected = app.project().clone();
expected.diagrams.get_mut(&diagram).unwrap().routes[0].waypoints = points.clone();
let context = app.state_context().unwrap();
event(
&mut app,
Event::SetBends {
context,
relation,
waypoints: points.clone(),
},
);
assert_eq!(app.project(), &expected);
assert_source_model_agree(&app);
let changed = app.document.snapshot();
let context = app.state_context().unwrap();
event(
&mut app,
Event::SetBends {
context,
relation,
waypoints: points,
},
);
assert!(
Arc::ptr_eq(&changed, &app.document.snapshot()),
"equal bends must not add history"
);
send(&mut app, Message::Undo);
assert_eq!(app.project(), &baseline.decoded.project);
assert_eq!(app.document.bundle(), &baseline.bundle);
assert!(!app.document.can_undo());
send(&mut app, Message::Redo);
assert_eq!(app.project(), &expected);
assert_eq!(app.document.bundle(), &changed.bundle);
let context = app.state_context().unwrap();
event(&mut app, Event::ClearBends { context, relation });
expected.diagrams.get_mut(&diagram).unwrap().routes[0]
.waypoints
.clear();
assert_eq!(app.project(), &expected);
let cleared = app.document.snapshot();
let context = app.state_context().unwrap();
event(&mut app, Event::ClearBends { context, relation });
assert!(Arc::ptr_eq(&cleared, &app.document.snapshot()));
}
#[test]
fn state_bend_self_loop_add_move_remove_clear_preserves_project_and_source_history() {
let (app, diagram, relation, _) = setup();
let mut project = app.project().clone();
let RelationBody::StateTransition { source, target, .. } =
&mut project.relations.get_mut(&relation).unwrap().body
else {
panic!("State transition fixture")
};
*target = *source;
project.diagrams.get_mut(&diagram).unwrap().routes[0]
.waypoints
.clear();
oa_core::validate_candidate(&project).unwrap();
let mut app = App::with_project(project);
send(&mut app, Message::RevealInDiagram(diagram, relation));
let baseline = app.document.snapshot();
let geometry = state_edit::bend_route(app.project(), diagram, relation).unwrap();
assert!(!geometry.custom_suspended);
assert!(geometry.pins.is_empty());
assert!(
geometry.points.len() >= 4,
"actual external self-loop routing"
);
let segment = 1;
let point = Position {
x: (geometry.points[segment].x + geometry.points[segment + 1].x) / 2.,
y: (geometry.points[segment].y + geometry.points[segment + 1].y) / 2.,
};
// Each operation must alter only the complete Project's waypoint list;
// Undo and Redo must restore exact serialized source, not just geometry.
fn check(app: &mut App, diagram: Id, mutation: Event, points: Vec<Position>) {
let before = app.document.snapshot();
let mut expected = before.decoded.project.clone();
expected.diagrams.get_mut(&diagram).unwrap().routes[0].waypoints = points;
event(app, mutation);
assert_eq!(app.project(), &expected);
assert_source_model_agree(app);
let after = app.document.snapshot();
assert_ne!(after.bundle, before.bundle);
send(app, Message::Undo);
assert_eq!(app.project(), &before.decoded.project);
assert_eq!(app.document.bundle(), &before.bundle);
send(app, Message::Redo);
assert_eq!(app.project(), &expected);
assert_eq!(app.document.bundle(), &after.bundle);
assert_source_model_agree(app);
}
let context = app.state_context().unwrap();
check(
&mut app,
diagram,
Event::AddBend {
context,
relation,
segment: Some((segment, point)),
},
vec![point],
);
let moved = Position {
x: point.x + 45.,
y: point.y - 35.,
};
let context = app.state_context().unwrap();
check(
&mut app,
diagram,
Event::SetBends {
context,
relation,
waypoints: vec![moved],
},
vec![moved],
);
let context = app.state_context().unwrap();
check(
&mut app,
diagram,
Event::RemoveBend {
context,
relation,
index: 0,
},
vec![],
);
assert_eq!(app.project(), &baseline.decoded.project);
send(&mut app, Message::Undo);
let context = app.state_context().unwrap();
check(
&mut app,
diagram,
Event::ClearBends { context, relation },
vec![],
);
assert_eq!(app.project(), &baseline.decoded.project);
assert_source_model_agree(&app);
// Clear is a new source-preserving edit and may retain empty-list trivia.
// Exact original bytes are restored by history, not by reauthoring a value.
for _ in 0..3 {
assert!(app.document.can_undo());
send(&mut app, Message::Undo);
}
assert_eq!(app.project(), &baseline.decoded.project);
assert_eq!(app.document.bundle(), &baseline.bundle);
assert!(!app.document.can_undo());
}
#[test]
fn state_bend_insertion_uses_captured_traversal_segment_and_removal_only_changes_pins() {
let (app, diagram, relation, _) = setup();
let context = app.state_context().unwrap();
let p = app.project();
let geometry = state_edit::bend_route(p, diagram, relation).unwrap();
let original = &p.diagrams[&diagram].routes[0];
let reverse = geometry
.points
.windows(2)
.position(|pair| pair[0] == original.waypoints[0] && pair[1] == original.waypoints[1])
.unwrap();
for (segment, expected_index) in [(reverse, 1), (reverse + 1, 2)] {
let point = Position { x: 500., y: -400. };
let planned = state_edit::plan(
p,
context,
&Event::AddBend {
context,
relation,
segment: Some((segment, point)),
},
)
.unwrap();
let actual = prepare_command(p, &planned.command).unwrap().project;
let mut expected = p.clone();
expected.diagrams.get_mut(&diagram).unwrap().routes[0]
.waypoints
.insert(expected_index, point);
assert_eq!(actual, expected);
let removed = state_edit::plan(
&actual,
context,
&Event::RemoveBend {
context,
relation,
index: expected_index,
},
)
.unwrap();
assert_eq!(
prepare_command(&actual, &removed.command).unwrap().project,
*p
);
}
let default = state_edit::plan(
p,
context,
&Event::AddBend {
context,
relation,
segment: None,
},
)
.unwrap();
let Command::SetEdgeRoute { route, .. } = default.command else {
panic!("route command")
};
assert_eq!(route.waypoints.len(), original.waypoints.len() + 1);
assert_eq!(route.source_attachment, original.source_attachment);
assert_eq!(route.target_attachment, original.target_attachment);
assert_eq!(route.label, original.label);
}
#[test]
fn state_bend_stale_nonfinite_capacity_hidden_and_folded_requests_are_inert() {
let (mut app, diagram, relation, composite) = setup();
let context = app.state_context().unwrap();
let baseline = app.document.snapshot();
for mutation in [
Event::SetBends {
context: Context {
epoch: context.epoch + 1,
..context
},
relation,
waypoints: vec![],
},
Event::SetBends {
context,
relation,
waypoints: vec![Position { x: f32::NAN, y: 0. }],
},
Event::SetBends {
context,
relation,
waypoints: vec![Position { x: 0., y: 0. }; 129],
},
Event::AddBend {
context,
relation,
segment: Some((usize::MAX, Position { x: 0., y: 0. })),
},
Event::RemoveBend {
context,
relation,
index: usize::MAX,
},
] {
event(&mut app, mutation);
assert!(Arc::ptr_eq(&baseline, &app.document.snapshot()));
}
let mut p = app.project().clone();
p.diagrams.get_mut(&diagram).unwrap().routes[0].waypoints =
vec![Position { x: 500., y: -400. }; 128];
assert!(
state_edit::plan(
&p,
context,
&Event::AddBend {
context,
relation,
segment: Some((1, Position { x: 500., y: -400. }))
}
)
.is_err()
);
let mut hidden = app.project().clone();
hidden
.diagrams
.get_mut(&diagram)
.unwrap()
.view_options
.hidden_edges
.push(relation);
assert!(state_edit::bend_route(&hidden, diagram, relation).is_none());
assert!(state_edit::plan(&hidden, context, &Event::ClearBends { context, relation }).is_err());
let owner = node(&app, diagram, composite);
let mut folded = app.project().clone();
folded
.diagrams
.get_mut(&diagram)
.unwrap()
.nodes
.get_mut(&owner)
.unwrap()
.collapsed = true;
assert!(state_edit::bend_route(&folded, diagram, relation).is_none());
assert!(state_edit::plan(&folded, context, &Event::ClearBends { context, relation }).is_err());
let mut internal = app.project().clone();
internal.diagrams.get_mut(&diagram).unwrap().routes.clear();
if let RelationBody::StateTransition {
source,
target,
transition_kind,
..
} = &mut internal.relations.get_mut(&relation).unwrap().body
{
*target = *source;
*transition_kind = TransitionKind::Internal;
}
assert!(state_edit::bend_route(&internal, diagram, relation).is_none());
send(
&mut app,
Message::Field("/name".into(), "Unapplied bend owner name".into()),
);
let draft = app.draft.as_ref().unwrap().value.clone();
event(&mut app, Event::ClearBends { context, relation });
assert!(Arc::ptr_eq(&baseline, &app.document.snapshot()));
assert_eq!(app.draft.as_ref().unwrap().value, draft);
}
@@ -114,6 +114,7 @@ impl App {
}
}
match event {
Event::Selection(event) => self.multiselect_event(event),
Event::Remove(intent) => self.remove_from_diagram(intent),
Event::Viewport(_, size) => {
if size.width.is_finite()
@@ -156,11 +157,17 @@ impl App {
context,
object,
placement,
selection_target,
anchor,
bend,
route_segment,
} => {
let mut request = crate::model_menu::Request::object(object, anchor);
request.canvas = Some(context);
request.placement = placement;
request.selection_target = selection_target.map(Box::new);
request.bend = bend;
request.route_segment = route_segment;
return self.handle(Message::Menu(crate::model_menu::Event::Open(request)));
}
Event::EditTransition(_, id) => {
@@ -172,6 +179,24 @@ impl App {
)));
}
Event::Choose(tool) => {
if let Tool::AdjustAttachment { relation, end } = tool {
if self.behavior.has_pending() || self.draft_dirty() || self.source_dirty() {
self.status =
"Apply or cancel pending edits before adjusting an attachment.".into();
return Task::none();
}
let Some(context) = self.state_context() else {
return Task::none();
};
if state_edit::attachment_node(self.project(), context.diagram, relation, end)
.is_none()
{
return Task::none();
}
self.selected = relation;
self.selected_node = None;
self.refresh_draft();
}
if !self.apply() {
return Task::none();
}
@@ -210,8 +235,37 @@ impl App {
let Some(context) = self.state_context() else {
return Task::none();
};
if let Event::SetBends { relation, .. } = &mutation
&& *relation != self.selected
{
return Task::none();
}
if let Event::SetAttachment {
relation,
end,
attachment,
..
} = &mutation
{
let existing = self.project().diagrams[&context.diagram]
.routes
.iter()
.find(|route| route.edge == *relation)
.and_then(|route| route.attachment(*end));
if existing == *attachment {
return Task::none();
}
}
match state_edit::plan(self.project(), context, &mutation) {
Ok(plan) => {
if let Command::SetEdgeRoute { diagram, route } = &plan.command
&& crate::route_edit::stored_route(
&self.project().diagrams[diagram],
route.edge,
) == *route
{
return Task::none();
}
if self.execute(plan.command) {
self.behavior.diagram.diagram = Some(plan.diagram);
self.behavior.diagram.placement = plan.placement;
@@ -1,5 +1,9 @@
//! App transaction and draft boundaries for captured state-canvas messages.
use super::*;
#[path = "state_attachment_escape_tests.rs"]
mod attachment_escape;
#[path = "state_bend_tests.rs"]
mod bends;
use crate::behavior::{
self,
state_edit::{self, Context, Endpoint, Event, Tool},
@@ -0,0 +1,374 @@
//! State selection layout and modifier admission on the mounted production App.
use super::*;
use crate::behavior::{
canvas_workspace::tests::{Mounted, key},
state_edit,
};
use crate::diagram_usage::Target;
use iced::advanced::widget::{Operation, operation};
use iced::{Point, Rectangle, Size, Vector, keyboard, mouse};
struct Fixture {
app: App,
diagram: Id,
a: Id,
b: Id,
a_element: Id,
b_element: Id,
edge: Id,
action: Id,
}
fn fixture() -> Fixture {
let mut p = Project::empty();
let machine = p
.add_element(Kind::StateMachine, Layer::Oa, "Operating modes", None)
.unwrap();
let region = p
.add_element(Kind::Region, Layer::Oa, "Main region", Some(machine))
.unwrap();
let a_element = p
.add_element(Kind::State, Layer::Oa, "First state", Some(region))
.unwrap();
let b_element = p
.add_element(
Kind::State,
Layer::Oa,
&format!("Second state {}", "with a long primary name ".repeat(20)),
Some(region),
)
.unwrap();
let work = p
.add_element(Kind::Activity, Layer::Oa, "Reusable work", None)
.unwrap();
let action = p.relate(RelationKind::StateAction, "On entry", a_element, work);
let edge = p.relate(
RelationKind::StateTransition,
"Next state",
a_element,
b_element,
);
let diagram = p.ensure_diagram(Layer::Oa);
p.diagrams.get_mut(&diagram).unwrap().projection = ProjectionKind::StateMachine { machine };
let mut placements = vec![];
// All State elements, including machine/Region, are initially placed.
// A fixture already missing its containers would not test picker insertion.
for (element, x, y, width, height) in [
(machine, 0., 0., 800., 500.),
(region, 20., 70., 760., 410.),
(a_element, 60., 160., 230., 180.),
(b_element, 430., 160., 260., 180.),
] {
let node = p.place(diagram, element, Position { x, y });
p.diagrams
.get_mut(&diagram)
.unwrap()
.nodes
.get_mut(&node)
.unwrap()
.size = oa_core::Size { width, height };
placements.push(node);
}
let a = placements[2];
let b = placements[3];
validate_candidate(&p).unwrap();
assert!(state_edit::unplaced(&p, diagram).is_empty());
let mut app = App::with_project(p);
app.page = Page::Behavior;
app.layer = Layer::Oa;
app.behavior.active_owner = Some(machine);
app.behavior.diagram = crate::behavior::state_view::State {
diagram: Some(diagram),
fit: true,
..Default::default()
};
app.selected = diagram;
app.selected_node = None;
app.window_size = Size::new(1442., 932.);
app.refresh_draft();
app.reconcile_multiselect();
assert_eq!(app.state_context().unwrap().diagram, diagram);
Fixture {
app,
diagram,
a,
b,
a_element,
b_element,
edge,
action,
}
}
fn owner(mounted: &mut Mounted, app: &App) -> (Rectangle, bool) {
let geometry = mounted.geometry(app);
let (_, bounds, focused) = geometry
.focused
.into_iter()
.find(|(id, _, _)| {
id.as_ref() == Some(&iced::advanced::widget::Id::from("diagram-keyboard-owner"))
})
.expect("real State focus::Owner rectangle must be measurable");
(bounds, focused)
}
fn camera(app: &App) -> (f32, Vector, bool, Option<Size>) {
let state = &app.behavior.diagram;
(state.zoom, state.pan, state.fit, state.viewport)
}
async fn selection(mounted: &mut Mounted, app: &mut App, target: Option<Target>) {
let context = app.multiselect_context().unwrap();
let event = match target {
Some(target) => multiselect::Event::Choose(context, target, multiselect::Choice::Toggle),
None => multiselect::Event::Clear(context),
};
let task = app.update(Message::DiagramSelection(event));
mounted.perform(app, task).await;
mounted.settle(app).await;
}
fn node_point(mounted: &mut Mounted, f: &Fixture, placement: Id) -> Point {
let bounds = owner(mounted, &f.app).0;
let node = &f.app.project().diagrams[&f.diagram].nodes[&placement];
let state = &f.app.behavior.diagram;
Point::new(
bounds.x + (node.position.x + 40.) * state.zoom + state.pan.x,
bounds.y + (node.position.y + 25.) * state.zoom + state.pan.y,
)
}
fn modifiers(shift: bool) -> iced::Event {
iced::Event::Keyboard(keyboard::Event::ModifiersChanged(if shift {
keyboard::Modifiers::SHIFT
} else {
keyboard::Modifiers::default()
}))
}
struct FocusField(Rectangle);
impl Operation for FocusField {
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn Operation)) {
visit(self);
}
fn focusable(
&mut self,
_: Option<&iced::advanced::widget::Id>,
bounds: Rectangle,
state: &mut dyn operation::Focusable,
) {
if bounds == self.0 {
state.focus();
} else {
state.unfocus();
}
}
}
#[tokio::test]
async fn mounted_state_selection_and_missing_picker_never_resize_or_refit_the_canvas() {
let mut f = fixture();
let mut mounted = Mounted::new(&mut f.app).await;
let baseline = f.app.document.snapshot();
let bounds = owner(&mut mounted, &f.app).0;
let fitted = camera(&f.app);
assert!(
fitted.2,
"keep Fit active to expose any resize-triggered camera jump"
);
assert!(fitted.3.is_some());
assert!(bounds.width > 400. && bounds.height > 200.);
assert!(state_edit::unplaced(f.app.project(), f.diagram).is_empty());
assert!(f.app.multiselect_snapshot().unwrap().targets().is_empty());
for (target, expected) in [
(Some(Target::Placement(f.a)), vec![Target::Placement(f.a)]),
(
Some(Target::Placement(f.b)),
vec![Target::Placement(f.a), Target::Placement(f.b)],
),
(
Some(Target::Edge(f.edge)),
vec![
Target::Placement(f.a),
Target::Placement(f.b),
Target::Edge(f.edge),
],
),
(
Some(Target::Edge(f.edge)),
vec![Target::Placement(f.a), Target::Placement(f.b)],
),
(
Some(Target::StateRow {
placement: f.a,
relation: f.action,
}),
vec![
Target::Placement(f.a),
Target::Placement(f.b),
Target::StateRow {
placement: f.a,
relation: f.action,
},
],
),
(None, vec![]),
] {
selection(&mut mounted, &mut f.app, target).await;
assert_eq!(f.app.multiselect_snapshot().unwrap().targets(), expected);
assert_eq!(
owner(&mut mounted, &f.app).0,
bounds,
"selection strip changed the measured State canvas rectangle"
);
assert_eq!(
camera(&f.app),
fitted,
"selection feedback silently refitted the camera"
);
assert_eq!(f.app.project(), &baseline.decoded.project);
assert_eq!(f.app.document.bundle(), &baseline.bundle);
assert!(!f.app.document.can_undo());
assert!(!f.app.draft_dirty());
}
selection(&mut mounted, &mut f.app, Some(Target::Placement(f.a))).await;
selection(&mut mounted, &mut f.app, Some(Target::Placement(f.b))).await;
let captured = f.app.multiselect_snapshot().unwrap();
let task = f
.app
.update(Message::DiagramSelection(multiselect::Event::Hide(
captured,
)));
mounted.perform(&mut f.app, task).await;
mounted.settle(&mut f.app).await;
let missing: std::collections::BTreeSet<_> = state_edit::unplaced(f.app.project(), f.diagram)
.into_iter()
.collect();
assert_eq!(
missing,
std::collections::BTreeSet::from([f.a_element, f.b_element])
);
let mut expected = baseline.decoded.project.clone();
expected
.diagrams
.get_mut(&f.diagram)
.unwrap()
.nodes
.remove(&f.a);
expected
.diagrams
.get_mut(&f.diagram)
.unwrap()
.nodes
.remove(&f.b);
assert_eq!(f.app.project(), &expected);
assert_eq!(
owner(&mut mounted, &f.app).0,
bounds,
"the first unplaced picker changed canvas bounds"
);
assert_eq!(camera(&f.app), fitted);
let hidden_source = f.app.document.bundle().clone();
let task = f.app.update(Message::Undo);
mounted.perform(&mut f.app, task).await;
mounted.settle(&mut f.app).await;
assert!(state_edit::unplaced(f.app.project(), f.diagram).is_empty());
assert_eq!(f.app.project(), &baseline.decoded.project);
assert_eq!(f.app.document.bundle(), &baseline.bundle);
assert!(!f.app.document.can_undo());
assert_eq!(
owner(&mut mounted, &f.app).0,
bounds,
"removing the unplaced picker changed canvas bounds"
);
assert_eq!(camera(&f.app), fitted);
let task = f.app.update(Message::Redo);
mounted.perform(&mut f.app, task).await;
mounted.settle(&mut f.app).await;
assert_eq!(f.app.project(), &expected);
assert_eq!(f.app.document.bundle(), &hidden_source);
assert_eq!(owner(&mut mounted, &f.app).0, bounds);
assert_eq!(camera(&f.app), fitted);
}
#[tokio::test]
async fn mounted_state_shift_held_in_clean_inspector_toggles_on_first_canvas_press() {
let mut f = fixture();
let mut mounted = Mounted::new(&mut f.app).await;
selection(&mut mounted, &mut f.app, Some(Target::Placement(f.a))).await;
let field = mounted
.geometry(&f.app)
.text_inputs
.into_iter()
.filter(|bounds| bounds.x > f.app.window_size.width - 320.)
.min_by(|a, b| a.y.total_cmp(&b.y))
.expect("State Inspector field is mounted");
mounted.operate(&f.app, &mut FocusField(field));
assert!(!owner(&mut mounted, &f.app).1);
let before = f.app.document.snapshot();
mounted
.batch(&mut f.app, &[modifiers(true)], mouse::Cursor::Unavailable)
.await;
assert!(
!owner(&mut mounted, &f.app).1,
"passive Shift must not acquire State canvas focus"
);
let at = node_point(&mut mounted, &f, f.b);
let messages = mounted.click(&mut f.app, at).await;
assert!(messages.iter().any(|message| matches!(message,
Message::DiagramSelection(multiselect::Event::Choose(_, Target::Placement(id), multiselect::Choice::Toggle)) if *id == f.b
)), "StateCanvas must map its typed selection event to the common App boundary");
assert_eq!(
f.app.multiselect_snapshot().unwrap().targets(),
&[Target::Placement(f.a), Target::Placement(f.b)]
);
assert_eq!(f.app.behavior.diagram.placement, Some(f.b));
assert_eq!(f.app.selected_node, None);
assert_eq!(f.app.project(), &before.decoded.project);
assert_eq!(f.app.document.bundle(), &before.bundle);
assert!(!f.app.document.can_undo());
assert!(!f.app.draft_dirty());
}
#[tokio::test]
async fn mounted_state_busy_suspension_tracks_shift_without_granting_focus_or_commands() {
let mut f = fixture();
let mut mounted = Mounted::new(&mut f.app).await;
selection(&mut mounted, &mut f.app, Some(Target::Placement(f.a))).await;
// Explicitly revoke focus rather than relying on incidental initial focus.
mounted.operate(
&f.app,
&mut FocusField(Rectangle {
x: -1000.,
y: -1000.,
width: 1.,
height: 1.,
}),
);
let before = f.app.document.snapshot();
let at = node_point(&mut mounted, &f, f.b);
f.app.busy = true;
mounted.settle(&mut f.app).await;
mounted
.batch(
&mut f.app,
&[modifiers(true), key(keyboard::key::Named::Delete)],
mouse::Cursor::Unavailable,
)
.await;
mounted.click(&mut f.app, at).await;
assert!(!owner(&mut mounted, &f.app).1);
assert_eq!(
f.app.multiselect_snapshot().unwrap().targets(),
&[Target::Placement(f.a)]
);
assert_eq!(f.app.document.bundle(), &before.bundle);
f.app.busy = false;
mounted.settle(&mut f.app).await;
assert!(!owner(&mut mounted, &f.app).1);
// No second modifier event: the passive suspended update must survive.
mounted.click(&mut f.app, at).await;
assert_eq!(
f.app.multiselect_snapshot().unwrap().targets(),
&[Target::Placement(f.a), Target::Placement(f.b)]
);
assert_eq!(f.app.project(), &before.decoded.project);
assert_eq!(f.app.document.bundle(), &before.bundle);
assert!(!f.app.document.can_undo());
assert!(!f.app.delete_pending);
}
File diff suppressed because it is too large Load Diff
@@ -158,6 +158,14 @@ impl Mounted {
events: &[iced::Event],
cursor: mouse::Cursor,
) -> Vec<Message> {
self.input_with_status(app, events, cursor).0
}
pub fn input_with_status(
&mut self,
app: &App,
events: &[iced::Event],
cursor: mouse::Cursor,
) -> (Vec<Message>, Vec<iced::event::Status>) {
let mut messages = vec![];
let mut ui = UserInterface::build(
app.view(),
@@ -165,7 +173,7 @@ impl Mounted {
std::mem::take(&mut self.cache),
&mut self.renderer,
);
let _ = ui.update(
let (_, statuses) = ui.update(
events,
cursor,
&mut self.renderer,
@@ -173,7 +181,7 @@ impl Mounted {
&mut messages,
);
self.cache = ui.into_cache();
messages
(messages, statuses)
}
pub async fn perform(&mut self, app: &mut App, task: iced::Task<Message>) {
let mut ready = vec![];
@@ -0,0 +1,656 @@
// Included in state_canvas::tests: exercise the real canvas event path.
fn bend_fixture(authored: bool) -> (Fixture, Id) {
let mut f = fixture();
let relation = f.project.relate(
RelationKind::StateTransition,
"Standby to stopped",
f.b,
f.c,
);
if authored {
f.project
.diagrams
.get_mut(&f.diagram)
.unwrap()
.routes
.push(EdgeRoute {
edge: relation,
waypoints: vec![Position {
x: 650.25,
y: 340.75,
}],
source_attachment: Some(EdgeAttachment {
side: PortAnchorSide::Bottom,
offset: 0.5,
}),
target_attachment: Some(EdgeAttachment {
side: PortAnchorSide::Top,
offset: 0.25,
}),
label: Some(EdgeLabel {
fraction: 0.43,
offset: Position { x: 28., y: -16. },
}),
});
}
validate_candidate(&f.project).unwrap();
(f, relation)
}
fn bend_segment(scene: &Scene, relation: Id) -> (usize, Point) {
let edge = &scene.routed.edges[&relation];
edge.points
.windows(2)
.enumerate()
.filter(|(i, _)| *i > 0 && *i + 2 < edge.points.len())
.map(|(i, p)| {
(
i,
Point::new((p[0].x + p[1].x) / 2., (p[0].y + p[1].y) / 2.),
(p[1].x - p[0].x).abs() + (p[1].y - p[0].y).abs(),
)
})
.filter(|(_, p, length)| {
*length > 40. && edge.pins.iter().all(|pin| p.distance(point(*pin)) > 20.)
})
.max_by(|a, b| a.2.total_cmp(&b.2))
.map(|(i, p, _)| (i, p))
.expect("fixture has an exposed interior route segment")
}
fn bend_press(canvas: &StateCanvas<'_>, state: &mut State, at: Point) {
motion(canvas, state, at, at);
let event = input(
canvas,
state,
mouse::Event::ButtonPressed(mouse::Button::Left),
at,
);
assert!(
!matches!(event, Some(Event::SetBends { .. })),
"press alone cannot commit"
);
}
#[test]
fn state_bends_segment_drag_inserts_one_pin_using_ordered_pointer_at_three_zooms() {
for zoom in [0.5, 1., 2.] {
let (f, relation) = bend_fixture(false);
let mut canvas = canvas(&f, Tool::Select, zoom);
canvas.selected = relation;
let mut state = State::default();
let baseline = canvas.scene(&state).unwrap();
let (_, midpoint) = bend_segment(&baseline, relation);
let start = screen(&canvas, midpoint);
let destination = Position {
x: ((midpoint.x + 45.) / 5.).round() * 5.,
y: ((midpoint.y + 30.) / 5.).round() * 5.,
};
let release = screen(&canvas, point(destination));
let later_batch_cursor = release + Vector::new(137., 83.);
motion(&canvas, &mut state, start, later_batch_cursor);
input(
&canvas,
&mut state,
mouse::Event::ButtonPressed(mouse::Button::Left),
later_batch_cursor,
);
assert!(f.project.diagrams[&f.diagram].routes.is_empty());
motion(&canvas, &mut state, release, later_batch_cursor);
assert_eq!(
state.bend_preview.as_ref().unwrap().waypoints,
vec![destination]
);
assert_eq!(
canvas.scene(&state).unwrap().routed.edges[&relation].pins,
vec![destination]
);
let event = input(
&canvas,
&mut state,
mouse::Event::ButtonReleased(mouse::Button::Left),
later_batch_cursor,
)
.unwrap();
assert!(
matches!(&event, Event::SetBends { context, relation: id, waypoints }
if *context == canvas.context && *id == relation && *waypoints == vec![destination])
);
assert!(state.bend_preview.is_none());
assert!(
input(
&canvas,
&mut state,
mouse::Event::ButtonReleased(mouse::Button::Left),
later_batch_cursor
)
.is_none()
);
let after = prepare_command(
&f.project,
&state_edit::plan(&f.project, canvas.context, &event)
.unwrap()
.command,
)
.unwrap()
.project;
let stored = &after.diagrams[&f.diagram].routes[0];
assert_eq!(stored.waypoints, vec![destination]);
assert!(
stored.source_attachment.is_none()
&& stored.target_attachment.is_none()
&& stored.label.is_none()
);
assert_eq!(after.relations, f.project.relations);
}
}
#[test]
fn state_bends_authored_pin_move_preserves_full_project_and_exact_undo_redo() {
for zoom in [0.5, 1., 2.] {
let (mut f, relation) = bend_fixture(true);
let mut other = f.project.diagrams[&f.diagram].clone();
other.id = Id::new_v4();
other.alias = "bend_reference_view".into();
other.nodes = other
.nodes
.into_values()
.map(|mut node| {
node.id = Id::new_v4();
node.alias = format!("bend_reference_{}", node.alias);
(node.id, node)
})
.collect();
let other_id = other.id;
f.project.diagrams.insert(other_id, other.clone());
validate_candidate(&f.project).unwrap();
let original = f.project.diagrams[&f.diagram].routes[0].clone();
let mut canvas = canvas(&f, Tool::Select, zoom);
canvas.selected = relation;
let mut state = State::default();
let start = screen(&canvas, point(original.waypoints[0]));
let destination = Position { x: 690., y: 365. };
let release = screen(&canvas, point(destination));
bend_press(&canvas, &mut state, start);
motion(&canvas, &mut state, release, release);
let preview = state.bend_preview.as_ref().unwrap();
let mut intended = original.clone();
intended.waypoints[0] = destination;
assert_eq!(preview, &intended);
assert_eq!(
f.project.diagrams[&f.diagram].routes[0], original,
"preview is transient"
);
let preview_scene = canvas.scene(&state).unwrap();
let event = input(
&canvas,
&mut state,
mouse::Event::ButtonReleased(mouse::Button::Left),
release,
)
.unwrap();
assert!(
matches!(&event, Event::SetBends { waypoints, .. } if waypoints == &intended.waypoints)
);
let plan = state_edit::plan(&f.project, canvas.context, &event).unwrap();
let mut expected = f.project.clone();
expected.diagrams.get_mut(&f.diagram).unwrap().routes[0] = intended;
let mut document = Document::from_project(f.project.clone(), Default::default()).unwrap();
let before = document.bundle().clone();
document.execute(plan.command).unwrap();
assert_eq!(document.project(), &expected);
assert_eq!(document.project().diagrams[&other_id], other);
let committed_view = projection::project_view(document.project(), f.diagram);
let committed = routing::route_diagram(
document.project(),
&document.project().diagrams[&f.diagram],
&committed_view,
);
let preview_edge = &preview_scene.routed.edges[&relation];
let committed_edge = &committed.edges[&relation];
assert_eq!(preview_edge.points, committed_edge.points);
assert_eq!(preview_edge.pin_fractions, committed_edge.pin_fractions);
assert_eq!(preview_edge.warnings, committed_edge.warnings);
let edited = document.bundle().clone();
assert!(document.undo());
assert_eq!(document.project(), &f.project);
assert_eq!(document.bundle(), &before);
assert!(!document.undo(), "one drag adds one history operation");
assert!(document.redo());
assert_eq!(document.project(), &expected);
assert_eq!(document.bundle(), &edited);
assert!(!document.redo());
}
}
#[test]
fn state_bends_stationary_and_out_and_back_restore_off_grid_or_automatic_route() {
for authored in [false, true] {
for zoom in [0.5, 1., 2.] {
for travel in [false, true] {
let (f, relation) = bend_fixture(authored);
let mut canvas = canvas(&f, Tool::Select, zoom);
canvas.selected = relation;
let mut state = State::default();
let scene = canvas.scene(&state).unwrap();
let world = if authored {
point(scene.routed.edges[&relation].pins[0])
} else {
bend_segment(&scene, relation).1
};
let start = screen(&canvas, world);
bend_press(&canvas, &mut state, start);
if travel {
let away = start + Vector::new(45., 30.) * zoom;
motion(&canvas, &mut state, away, away);
assert!(state.bend_preview.is_some());
motion(&canvas, &mut state, start, start);
}
assert!(
input(
&canvas,
&mut state,
mouse::Event::ButtonReleased(mouse::Button::Left),
start
)
.is_none()
);
assert!(state.bend_preview.is_none());
assert_eq!(
canvas.scene(&state).unwrap().routed.edges[&relation].points,
scene.routed.edges[&relation].points
);
assert_eq!(
f.project.diagrams[&f.diagram].routes.len(),
usize::from(authored)
);
if authored {
assert_eq!(
f.project.diagrams[&f.diagram].routes[0].waypoints,
vec![Position {
x: 650.25,
y: 340.75
}]
);
}
}
}
}
}
#[test]
fn state_bends_cancel_on_camera_context_focus_and_pointer_interruptions() {
for cancellation in 0..16 {
let (f, relation) = bend_fixture(true);
let mut canvas = canvas(&f, Tool::Select, 1.);
canvas.selected = relation;
let mut state = State::default();
let original = f.project.diagrams[&f.diagram].routes[0].clone();
let start = screen(&canvas, point(original.waypoints[0]));
let release = start + Vector::new(45., 30.);
bend_press(&canvas, &mut state, start);
motion(&canvas, &mut state, release, release);
assert!(state.bend_preview.is_some(), "case {cancellation}");
let mut release_bounds = bounds();
match cancellation {
0 => canvas.zoom = 2.,
1 => canvas.pan.x += 10.,
2 => canvas.context.epoch += 1,
3 => canvas.context.revision += 1,
4 => canvas.context.document = Id::new_v4(),
5 => canvas.tool = Tool::Transition,
6 => canvas.editable = false,
7 | 8 => {
input(
&canvas,
&mut state,
mouse::Event::ButtonPressed(if cancellation == 7 {
mouse::Button::Right
} else {
mouse::Button::Middle
}),
release,
);
}
9 => {
canvas::Program::update(
&canvas,
&mut state,
&canvas::Event::Window(iced::window::Event::Unfocused),
bounds(),
mouse::Cursor::Available(release),
);
}
10 => {
input(
&canvas,
&mut state,
mouse::Event::WheelScrolled {
delta: mouse::ScrollDelta::Lines { x: 0., y: 1. },
},
release,
);
}
11 => {
input(&canvas, &mut state, mouse::Event::CursorLeft, release);
}
12 => {
motion(&canvas, &mut state, Point::new(-100., -100.), release);
}
13 => release_bounds.x += 25.,
15 => canvas.selected = f.b,
_ => {
let escape = canvas::Event::Keyboard(keyboard::Event::KeyPressed {
key: keyboard::Key::Named(keyboard::key::Named::Escape),
modified_key: keyboard::Key::Named(keyboard::key::Named::Escape),
physical_key: keyboard::key::Physical::Code(keyboard::key::Code::Escape),
location: keyboard::Location::Standard,
modifiers: keyboard::Modifiers::empty(),
text: None,
repeat: false,
});
let event = canvas::Program::update(
&canvas,
&mut state,
&escape,
bounds(),
mouse::Cursor::Available(release),
)
.and_then(|action| action.into_inner().0);
assert!(matches!(event, Some(Event::Choose(Tool::Select))));
}
}
let event = input_at_bounds(
&canvas,
&mut state,
mouse::Event::ButtonReleased(mouse::Button::Left),
release,
release_bounds,
);
assert!(
!matches!(event, Some(Event::SetBends { .. })),
"late release committed case {cancellation}"
);
assert!(
state.bend_preview.is_none(),
"stale preview survived case {cancellation}"
);
assert_eq!(f.project.diagrams[&f.diagram].routes[0], original);
}
}
#[test]
fn state_bends_context_menu_distinguishes_authored_pin_from_computed_segment() {
let (f, relation) = bend_fixture(true);
let mut canvas = canvas(&f, Tool::Select, 1.);
canvas.selected = relation;
let mut state = State::default();
let scene = canvas.scene(&state).unwrap();
let pin = screen(&canvas, point(scene.routed.edges[&relation].pins[0]));
motion(&canvas, &mut state, pin, pin);
assert!(
matches!(input(&canvas, &mut state, mouse::Event::ButtonPressed(mouse::Button::Right), pin),
Some(Event::ContextMenu { context, object, placement: None, anchor, bend: Some(0), route_segment: None, .. })
if context == canvas.context && object == relation && anchor == pin)
);
let (index, midpoint) = bend_segment(&scene, relation);
let at = screen(&canvas, midpoint);
motion(&canvas, &mut state, at, at);
assert!(
matches!(input(&canvas, &mut state, mouse::Event::ButtonPressed(mouse::Button::Right), at),
Some(Event::ContextMenu { context, object, placement: None, anchor, bend: None, route_segment: Some((segment, world)), .. })
if context == canvas.context && object == relation && anchor == at && segment == index && point(world).distance(midpoint) < 0.001)
);
}
#[test]
fn state_bends_internal_rows_and_folded_proxies_cannot_acquire_pins() {
for internal in [false, true] {
let mut f = fixture();
let relation = if internal {
let id = f
.project
.relate(RelationKind::StateTransition, "Internal check", f.a, f.a);
if let RelationBody::StateTransition {
transition_kind, ..
} = &mut f.project.relations.get_mut(&id).unwrap().body
{
*transition_kind = TransitionKind::Internal;
}
id
} else {
let id = f.project.relate(
RelationKind::StateTransition,
"Hidden child to standby",
f.leaf,
f.b,
);
let parent = placement(&f, f.a);
f.project
.diagrams
.get_mut(&f.diagram)
.unwrap()
.nodes
.get_mut(&parent)
.unwrap()
.collapsed = true;
id
};
let mut canvas = canvas(&f, Tool::Select, 1.);
canvas.selected = relation;
let mut state = State::default();
let scene = canvas.scene(&state).unwrap();
let world = if internal {
assert!(!scene.routed.edges.contains_key(&relation));
let node = scene.view.nodes.iter().find(|n| n.element == f.a).unwrap();
internal_rows(&f.project, node)
.into_iter()
.find(|(r, _)| r.id == relation)
.unwrap()
.1
.center()
} else {
assert!(scene.routed.edges[&relation].custom_suspended);
bend_segment(&scene, relation).1
};
let start = screen(&canvas, world);
motion(&canvas, &mut state, start, start);
assert!(matches!(
input(
&canvas,
&mut state,
mouse::Event::ButtonPressed(mouse::Button::Right),
start
),
Some(Event::ContextMenu {
bend: None,
route_segment: None,
..
})
));
bend_press(&canvas, &mut state, start);
let away = start + Vector::new(30., 20.);
motion(&canvas, &mut state, away, away);
assert!(state.bend_preview.is_none());
assert!(!matches!(
input(
&canvas,
&mut state,
mouse::Event::ButtonReleased(mouse::Button::Left),
away
),
Some(Event::SetBends { .. })
));
assert!(f.project.diagrams[&f.diagram].routes.is_empty());
}
}
#[test]
fn state_bends_right_click_discards_provisional_indexes_before_building_menu() {
for click_pin in [true, false] {
let (mut f, relation) = bend_fixture(true);
let route = &mut f.project.diagrams.get_mut(&f.diagram).unwrap().routes[0];
route.waypoints = vec![Position { x: 650., y: 340. }, Position { x: 650., y: 390. }];
validate_candidate(&f.project).unwrap();
let original = f.project.diagrams[&f.diagram].routes[0].clone();
let mut canvas = canvas(&f, Tool::Select, 1.);
canvas.selected = relation;
let mut state = State::default();
let baseline = canvas.scene(&state).unwrap();
let edge = &baseline.routed.edges[&relation];
let candidates: Vec<_> = edge.points.windows(2).enumerate()
.filter(|(i, _)| *i > 0 && *i + 2 < edge.points.len())
.map(|(i, p)| (i, Point::new((p[0].x + p[1].x) / 2., (p[0].y + p[1].y) / 2.)))
.filter(|(_, p)| edge.pins.iter().all(|pin| p.distance(point(*pin)) > 20.))
.filter(|(_, p)| matches!(canvas.hit(&baseline, *p), Some(Hit::Transition(id)) if id == relation))
.filter(|(_, p)| !Rectangle::new(point(edge.label.bounds.position), Size::new(edge.label.bounds.size.width, edge.label.bounds.size.height)).contains(*p))
.collect();
let (_, insertion_at) = candidates
.iter()
.copied()
.find(|(i, p)| routing::pin_insertion_index(edge, *i, position(*p)) == 0)
.expect("exposed segment before both authored pins");
let menu_segment = candidates
.iter()
.copied()
.find(|(_, p)| p.distance(insertion_at) > 30.)
.expect("a distinct committed segment for the menu control");
let start = screen(&canvas, insertion_at);
bend_press(&canvas, &mut state, start);
let away = start + Vector::new(45., 30.);
motion(&canvas, &mut state, away, away);
let preview = state.bend_preview.as_ref().unwrap();
assert_eq!(preview.waypoints.len(), 3);
assert_eq!(
&preview.waypoints[1..],
original.waypoints.as_slice(),
"provisional insertion shifts both stored indices"
);
// With the left gesture still active, moving to the last stored pin also
// moves provisional pin0 there. The preview's first hit is therefore
// pin0, but a menu built after cancellation must identify stored pin1.
let menu_world = if click_pin {
point(original.waypoints[1])
} else {
menu_segment.1
};
let menu_window = screen(&canvas, menu_world);
motion(&canvas, &mut state, menu_window, menu_window);
assert!(state.bend_preview.is_some());
let menu = input(
&canvas,
&mut state,
mouse::Event::ButtonPressed(mouse::Button::Right),
menu_window,
)
.unwrap();
let Event::ContextMenu {
context,
object,
bend,
route_segment,
..
} = menu
else {
panic!("expected context menu after cancelling drag")
};
assert_eq!(context, canvas.context);
assert_eq!(object, relation);
assert!(state.bend_preview.is_none());
assert_eq!(
canvas.scene(&state).unwrap().routed.edges[&relation].points,
edge.points
);
if click_pin {
assert_eq!(
bend,
Some(1),
"menu index must address the stored route, not the provisional route"
);
assert!(route_segment.is_none());
let remove = Event::RemoveBend {
context,
relation,
index: bend.unwrap(),
};
let plan = state_edit::plan(&f.project, context, &remove).unwrap();
let after = prepare_command(&f.project, &plan.command).unwrap().project;
let mut expected = f.project.clone();
expected.diagrams.get_mut(&f.diagram).unwrap().routes[0]
.waypoints
.remove(1);
assert_eq!(
after, expected,
"remove menu action must delete exactly the addressed original pin"
);
} else {
assert!(
bend.is_none(),
"provisional pin under cursor must not leak into menu"
);
let (segment, at) = route_segment.expect("committed route segment remains actionable");
assert_eq!(segment, menu_segment.0);
assert!(point(at).distance(menu_world) < 0.001);
}
assert!(
input(
&canvas,
&mut state,
mouse::Event::ButtonReleased(mouse::Button::Left),
menu_window
)
.is_none()
);
assert_eq!(f.project.diagrams[&f.diagram].routes[0], original);
}
}
#[test]
fn state_bends_draw_before_update_excludes_preview_after_camera_or_bounds_change() {
for change in 0..4 {
let (f, relation) = bend_fixture(true);
let mut canvas = canvas(&f, Tool::Select, 1.);
canvas.selected = relation;
let mut state = State::default();
let baseline = canvas.scene_with_bounds(&state, Some(bounds())).unwrap();
let start = screen(&canvas, point(baseline.routed.edges[&relation].pins[0]));
let release = start + Vector::new(45., 30.);
bend_press(&canvas, &mut state, start);
motion(&canvas, &mut state, release, release);
let displayed_preview = canvas.scene_with_bounds(&state, Some(bounds())).unwrap();
assert_ne!(
displayed_preview.routed.edges[&relation].pins,
baseline.routed.edges[&relation].pins
);
let mut changed_bounds = bounds();
match change {
0 => canvas.zoom = 2.,
1 => canvas.pan.y += 25.,
2 => changed_bounds.x += 35.,
_ => changed_bounds.width -= 90.,
}
// No update event between camera/layout change and this draw-time read.
let shown = canvas
.scene_with_bounds(&state, Some(changed_bounds))
.unwrap();
assert_eq!(
shown.routed.edges[&relation].pins, baseline.routed.edges[&relation].pins,
"stale draw-time pins for case {change}"
);
assert_eq!(
shown.routed.edges[&relation].points, baseline.routed.edges[&relation].points,
"stale cached preview for case {change}"
);
let event = input_at_bounds(
&canvas,
&mut state,
mouse::Event::ButtonReleased(mouse::Button::Left),
release,
changed_bounds,
);
assert!(!matches!(event, Some(Event::SetBends { .. })));
assert!(state.bend_preview.is_none());
}
}
File diff suppressed because it is too large Load Diff
+244 -2
View File
@@ -20,10 +20,12 @@ pub enum Tool {
Region,
Transition,
Reconnect { relation: Id, endpoint: Endpoint },
AdjustAttachment { relation: Id, end: EdgeEnd },
}
#[derive(Debug, Clone)]
pub enum Event {
Selection(crate::app::multiselect::Event),
Remove(crate::app::diagram_removal::Intent),
Choose(Tool),
CreateView(Id),
@@ -37,7 +39,10 @@ pub enum Event {
context: Context,
object: Id,
placement: Option<Id>,
selection_target: Option<crate::diagram_usage::Target>,
anchor: iced::Point,
bend: Option<usize>,
route_segment: Option<(usize, Position)>,
},
CreateAt {
context: Context,
@@ -66,13 +71,40 @@ pub enum Event {
endpoint: Endpoint,
vertex: Id,
},
SetAttachment {
context: Context,
relation: Id,
end: EdgeEnd,
placement: Id,
attachment: Option<EdgeAttachment>,
},
SetBends {
context: Context,
relation: Id,
waypoints: Vec<Position>,
},
AddBend {
context: Context,
relation: Id,
segment: Option<(usize, Position)>,
},
RemoveBend {
context: Context,
relation: Id,
index: usize,
},
ClearBends {
context: Context,
relation: Id,
},
}
impl Event {
pub fn context(&self) -> Option<Context> {
match self {
Self::Remove(intent) => Some(intent.context),
Self::Choose(_) | Self::CreateView(_) => None,
// Group events carry their own exact selection envelope.
Self::Selection(_) | Self::Choose(_) | Self::CreateView(_) => None,
Self::Select(c, _)
| Self::Pan(c, _)
| Self::Zoom(c, _)
@@ -84,7 +116,12 @@ impl Event {
| Self::Place { context: c, .. }
| Self::Move { context: c, .. }
| Self::Connect { context: c, .. }
| Self::Reconnect { context: c, .. } => Some(*c),
| Self::Reconnect { context: c, .. }
| Self::SetAttachment { context: c, .. }
| Self::SetBends { context: c, .. }
| Self::AddBend { context: c, .. }
| Self::RemoveBend { context: c, .. }
| Self::ClearBends { context: c, .. } => Some(*c),
}
}
}
@@ -249,6 +286,54 @@ pub fn can_reconnect(
.is_ok()
}
/// Only the actual visible transition owner can receive a visual attachment.
/// Folded promotion, semantic ports and internal-transition rows are excluded.
pub fn attachment_node(
project: &Project,
diagram: Id,
relation: Id,
end: EdgeEnd,
) -> Option<projection::ProjectedNode> {
machine(project, diagram).ok()?;
let relation_record = project.relations.get(&relation)?;
if relation_record.kind() != RelationKind::StateTransition
|| oa_core::routing::semantic_port_endpoint(project, relation, end).is_some()
{
return None;
}
let view = projection::project_view(project, diagram);
let edge = view.edges.iter().find(|edge| edge.id == relation)?;
let stored = project.diagrams.get(&diagram)?;
if projection::edge_endpoint_placements(project, stored, relation)?
!= (edge.source, edge.target)
{
return None;
}
let owner = match end {
EdgeEnd::Source => edge.source,
EdgeEnd::Target => edge.target,
};
view.nodes.iter().find(|node| node.id == owner).cloned()
}
/// Actual visible State transition geometry, never an internal row or folded proxy.
pub fn bend_route(
project: &Project,
diagram: Id,
relation: Id,
) -> Option<oa_core::routing::RoutedEdge> {
machine(project, diagram).ok()?;
if project.relations.get(&relation)?.kind() != RelationKind::StateTransition {
return None;
}
let stored = project.diagrams.get(&diagram)?;
let view = projection::project_diagram(project, stored);
let geometry = oa_core::routing::route_diagram(project, stored, &view)
.edges
.remove(&relation)?;
(!geometry.custom_suspended).then_some(geometry)
}
fn checked(project: &Project, plan: Plan) -> Result<Plan, ModelError> {
prepare_command(project, &plan.command)?;
Ok(plan)
@@ -492,6 +577,82 @@ pub fn plan(project: &Project, current: Context, event: &Event) -> Result<Plan,
relation,
None,
),
Event::SetAttachment {
relation,
end,
placement,
attachment,
..
} => {
attachment_node(project, diagram, relation, end)
.filter(|owner| owner.id == placement)
.ok_or_else(|| {
error("Choose the actual visible transition endpoint in this view.")
})?;
let mut route = crate::route_edit::stored_route(&project.diagrams[&diagram], relation);
route.set_attachment(end, attachment);
(Command::SetEdgeRoute { diagram, route }, relation, None)
}
Event::SetBends { relation, .. }
| Event::AddBend { relation, .. }
| Event::RemoveBend { relation, .. }
| Event::ClearBends { relation, .. } => {
let geometry = bend_route(project, diagram, relation)
.ok_or_else(|| error("Choose an actual visible transition route in this view."))?;
let mut route = crate::route_edit::stored_route(&project.diagrams[&diagram], relation);
match event {
Event::SetBends { waypoints, .. } => {
if waypoints.len() > 128 {
return Err(error("A route supports at most 128 bends."));
}
for &point in waypoints {
finite(point)?;
}
route.waypoints = waypoints.clone();
}
Event::AddBend { segment, .. } => {
let (segment, point) = segment
.or_else(|| {
geometry
.points
.windows(2)
.enumerate()
.filter(|(index, _)| {
*index > 0 && index + 2 < geometry.points.len()
})
.max_by(|(_, a), (_, b)| {
let length = |s: &[Position]| {
(s[1].x - s[0].x).abs() + (s[1].y - s[0].y).abs()
};
length(a).total_cmp(&length(b))
})
.map(|(index, span)| {
(
index,
Position {
x: (span[0].x + span[1].x) / 2.,
y: (span[0].y + span[1].y) / 2.,
},
)
})
})
.ok_or_else(|| error("This route has no interior segment for a bend."))?;
crate::route_edit::insert_pin_on_segment(&mut route, &geometry, segment, point)
.ok_or_else(|| {
error("The bend segment is stale, invalid, or at capacity.")
})?;
}
Event::RemoveBend { index, .. } => {
if *index >= route.waypoints.len() {
return Err(error("This bend no longer exists."));
}
route.waypoints.remove(*index);
}
Event::ClearBends { .. } => route.waypoints.clear(),
_ => unreachable!(),
}
(Command::SetEdgeRoute { diagram, route }, relation, None)
}
_ => {
return Err(error(
"Handle this navigation or tool request without a model command.",
@@ -580,6 +741,85 @@ mod tests {
plan.diagram
}
#[test]
fn attachment_reset_preserves_other_end_pins_label_and_rejects_ineligible_owners() {
let mut f = fixture();
let diagram = create(&mut f);
let c = context(diagram);
let edge = f.project.relate(
RelationKind::StateTransition,
"Leave work",
f.leaf,
f.target,
);
let attachment = EdgeAttachment {
side: PortAnchorSide::Top,
offset: 0.3,
};
let mut route = crate::route_edit::stored_route(&f.project.diagrams[&diagram], edge);
route.source_attachment = Some(attachment);
route.target_attachment = Some(attachment);
route.waypoints.push(Position { x: 700., y: 700. });
// Retain the exact label value alongside the other route components.
route.label = Some(EdgeLabel {
fraction: 0.5,
offset: Position { x: 10., y: 20. },
});
f.project
.diagrams
.get_mut(&diagram)
.unwrap()
.routes
.push(route.clone());
let owner = attachment_node(&f.project, diagram, edge, EdgeEnd::Source).unwrap();
let event = Event::SetAttachment {
context: c,
relation: edge,
end: EdgeEnd::Source,
placement: owner.id,
attachment: None,
};
let accepted = prepare_command(&f.project, &plan(&f.project, c, &event).unwrap().command)
.unwrap()
.project;
route.source_attachment = None;
assert_eq!(accepted.diagrams[&diagram].routes, vec![route]);
assert_eq!(accepted.relations, f.project.relations);
let wrong = Event::SetAttachment {
context: c,
relation: edge,
end: EdgeEnd::Source,
placement: node(&f.project, diagram, f.target),
attachment: Some(attachment),
};
assert!(plan(&f.project, c, &wrong).is_err());
let composite = node(&f.project, diagram, f.composite);
f.project
.diagrams
.get_mut(&diagram)
.unwrap()
.nodes
.get_mut(&composite)
.unwrap()
.collapsed = true;
assert!(attachment_node(&f.project, diagram, edge, EdgeEnd::Source).is_none());
assert!(plan(&f.project, c, &event).is_err());
let internal = f.project.relate(
RelationKind::StateTransition,
"Internal",
f.target,
f.target,
);
if let RelationBody::StateTransition {
transition_kind, ..
} = &mut f.project.relations.get_mut(&internal).unwrap().body
{
*transition_kind = TransitionKind::Internal;
}
assert!(attachment_node(&f.project, diagram, internal, EdgeEnd::Source).is_none());
assert!(attachment_node(&f.project, diagram, internal, EdgeEnd::Target).is_none());
}
#[test]
fn view_initializes_only_its_machine_in_one_source_and_model_history_entry() {
let f = fixture();
@@ -763,6 +1003,8 @@ mod tests {
.routes
.push(EdgeRoute {
edge,
source_attachment: None,
target_attachment: None,
waypoints: vec![pin],
label: None,
});
@@ -161,6 +161,53 @@ pub fn popup(app: &App, opened: &Opened, clean: bool) -> Option<Menu<Message>> {
);
entries.push(item("Edit transition", edit, true));
if let Some(context) = context {
let bend_geometry = state_edit::bend_route(project, context.diagram, id);
let bend_count = bend_geometry.as_ref().map_or(0, |route| route.pins.len());
let can_add = bend_geometry.as_ref().is_some_and(|route| {
bend_count < 128
&& request.route_segment.map_or_else(
|| route.points.len() >= 4,
|(index, point)| {
index
.checked_add(2)
.is_some_and(|end| end <= route.points.len())
&& point.x.is_finite()
&& point.y.is_finite()
},
)
});
entries.push(item(
if request.route_segment.is_some() {
"Add bend here"
} else {
"Add bend"
},
diagram_event(Event::AddBend {
context,
relation: id,
segment: request.route_segment,
}),
can_add,
));
if let Some(index) = request.bend {
entries.push(item(
"Remove bend",
diagram_event(Event::RemoveBend {
context,
relation: id,
index,
}),
bend_geometry.is_some() && index < bend_count,
));
}
entries.push(item(
"Clear bends",
diagram_event(Event::ClearBends {
context,
relation: id,
}),
bend_geometry.is_some() && bend_count > 0,
));
let visible = projection::project_view(project, context.diagram)
.edges
.iter()
@@ -178,6 +225,44 @@ pub fn popup(app: &App, opened: &Opened, clean: bool) -> Option<Menu<Message>> {
visible,
));
}
for (caption, reset, end) in [
(
"Adjust source attachment",
"Reset source attachment",
EdgeEnd::Source,
),
(
"Adjust target attachment",
"Reset target attachment",
EdgeEnd::Target,
),
] {
let owner = state_edit::attachment_node(project, context.diagram, id, end);
entries.push(item(
caption,
diagram_event(Event::Choose(Tool::AdjustAttachment { relation: id, end })),
owner.is_some(),
));
if let Some(owner) = owner {
let authored = project.diagrams[&context.diagram]
.routes
.iter()
.find(|route| route.edge == id)
.and_then(|route| route.attachment(end))
.is_some();
entries.push(item(
reset,
diagram_event(Event::SetAttachment {
context,
relation: id,
end,
placement: owner.id,
attachment: None,
}),
authored,
));
}
}
if !visible {
entries.push(Entry::title(
"Use transition details for hidden or internal endpoints",
@@ -0,0 +1,395 @@
// Included in state_canvas::tests. Drive production Program input and projection.
#[test]
fn state_singleton_selection_bend_uses_five_unit_grid_and_escape_keeps_selection() {
for zoom in [0.5, 1., 2.] {
for cancel in [false, true] {
let (f, relation) = bend_fixture(true);
let original = f.project.diagrams[&f.diagram].routes[0].clone();
let mut canvas = canvas(&f, Tool::Select, zoom);
canvas.selected = relation;
let selection = group_selection(&canvas, [Target::Edge(relation)]);
let snapshot = selection.snapshot(selection_context(canvas.context)).unwrap();
canvas.selection = Some(&selection);
let mut state = State::default();
let baseline = canvas.scene(&state).unwrap();
let start = screen(&canvas, point(original.waypoints[0]));
// A fractional pointer target distinguishes route snapping from the
// unsnapped State placement policy, at every supported test zoom.
let release = screen(&canvas, Point::new(683.75, 362.25));
let expected_pin = Position { x: 685., y: 360. };
motion(&canvas, &mut state, start, release);
assert!(input(&canvas, &mut state,
mouse::Event::ButtonPressed(mouse::Button::Left), release).is_none());
assert!(matches!(state.press, Some(Press::Bend { relation: id }) if id == relation));
assert!(state.group.is_none());
motion(&canvas, &mut state, release, release);
let mut expected_route = original.clone();
expected_route.waypoints[0] = expected_pin;
assert_eq!(state.bend_preview.as_ref(), Some(&expected_route));
assert_eq!(canvas.scene(&state).unwrap().routed.edges[&relation].pins, vec![expected_pin]);
assert_eq!(f.project.diagrams[&f.diagram].routes[0], original);
if cancel {
let escape = group_key(&canvas, &mut state, keyboard::key::Named::Escape,
keyboard::Modifiers::empty());
assert!(matches!(escape, Some(Event::Choose(Tool::Select))),
"Escape cancels the bend before it can emit selection Clear: {escape:?}");
assert!(state.press.is_none() && state.bend.is_none() && state.bend_preview.is_none());
assert_eq!(canvas.scene(&state).unwrap().routed.edges[&relation].points,
baseline.routed.edges[&relation].points);
assert!(input(&canvas, &mut state,
mouse::Event::ButtonReleased(mouse::Button::Left), release).is_none());
} else {
let event = input(&canvas, &mut state,
mouse::Event::ButtonReleased(mouse::Button::Left), release).unwrap();
assert!(matches!(&event, Event::SetBends { context, relation: id, waypoints }
if *context == canvas.context && *id == relation && *waypoints == vec![expected_pin]));
let plan = state_edit::plan(&f.project, canvas.context, &event).unwrap();
let actual = prepare_command(&f.project, &plan.command).unwrap().project;
let mut expected = f.project.clone();
expected.diagrams.get_mut(&f.diagram).unwrap().routes[0] = expected_route;
assert_eq!(actual, expected);
}
assert_eq!(selection.snapshot(selection_context(canvas.context)).unwrap(), snapshot);
assert!(state.group.is_none());
}
}
}
#[test]
fn state_singleton_selection_endpoint_reconnect_stationary_cancel_and_commit() {
for outcome in 0..3 {
let mut f = fixture();
let relation = f.project.relate(RelationKind::StateTransition, "Go standby", f.a, f.b);
let mut canvas = canvas(&f, Tool::Select, 1.);
canvas.selected = relation;
let selection = group_selection(&canvas, [Target::Edge(relation)]);
let snapshot = selection.snapshot(selection_context(canvas.context)).unwrap();
canvas.selection = Some(&selection);
let mut state = State::default();
let baseline = canvas.scene(&state).unwrap();
let handle = screen(&canvas, point(*baseline.routed.edges[&relation].points.last().unwrap()));
let target = screen(&canvas, header(&canvas, f.c));
motion(&canvas, &mut state, handle, target);
assert!(input(&canvas, &mut state,
mouse::Event::ButtonPressed(mouse::Button::Left), target).is_none());
assert!(matches!(state.press,
Some(Press::Reconnect { relation: id, endpoint: Endpoint::Target, start: Some(at) })
if id == relation && at == handle));
assert!(state.group.is_none() && state.bend.is_none());
if outcome > 0 { motion(&canvas, &mut state, target, target); }
if outcome == 1 {
let escape = group_key(&canvas, &mut state, keyboard::key::Named::Escape,
keyboard::Modifiers::empty());
assert!(matches!(escape, Some(Event::Choose(Tool::Select))),
"Escape cancels reconnect without clearing the exact edge: {escape:?}");
assert!(state.press.is_none());
}
let event = input(&canvas, &mut state,
mouse::Event::ButtonReleased(mouse::Button::Left), target);
if outcome < 2 {
assert!(event.is_none(), "stationary/cancelled reconnect produced {event:?}");
} else {
let event = event.unwrap();
assert!(matches!(&event, Event::Reconnect { context, relation: id, endpoint: Endpoint::Target, vertex }
if *context == canvas.context && *id == relation && *vertex == f.c));
let plan = state_edit::plan(&f.project, canvas.context, &event).unwrap();
let actual = prepare_command(&f.project, &plan.command).unwrap().project;
assert_eq!(plan.selected, relation);
assert_eq!(actual.relations[&relation].source(), f.a);
assert_eq!(actual.relations[&relation].target(), f.c);
assert_eq!(actual.elements, f.project.elements);
}
assert_eq!(f.project.relations[&relation].target(), f.b);
assert_eq!(selection.snapshot(selection_context(canvas.context)).unwrap(), snapshot);
assert!(state.group.is_none() && state.press.is_none());
}
}
#[test]
fn state_singleton_selection_shift_handles_toggle_exact_edge_before_route_tools() {
let (f, relation) = bend_fixture(true);
let mut canvas = canvas(&f, Tool::Select, 1.);
canvas.selected = relation;
let selection = group_selection(&canvas, [Target::Edge(relation)]);
canvas.selection = Some(&selection);
let baseline = canvas.scene(&State::default()).unwrap();
let edge = &baseline.routed.edges[&relation];
for world in [edge.pins[0], *edge.points.last().unwrap()] {
let mut state = State::default();
assert!(canvas::Program::update(&canvas, &mut state,
&canvas::Event::Keyboard(keyboard::Event::ModifiersChanged(keyboard::Modifiers::SHIFT)),
bounds(), mouse::Cursor::Unavailable).is_none());
let at = screen(&canvas, point(world));
motion(&canvas, &mut state, at, at);
assert!(matches!(input(&canvas, &mut state,
mouse::Event::ButtonPressed(mouse::Button::Left), at),
Some(Event::Selection(SelectionEvent::Choose(context, Target::Edge(id), Choice::Toggle)))
if context == selection_context(canvas.context) && id == relation));
assert!(state.group.is_none() && state.press.is_none() && state.bend.is_none());
let away = at + Vector::new(35., 20.);
motion(&canvas, &mut state, away, away);
assert!(input(&canvas, &mut state,
mouse::Event::ButtonReleased(mouse::Button::Left), away).is_none());
assert!(state.bend_preview.is_none());
assert_eq!(canvas.scene(&state).unwrap().routed.edges[&relation].points, edge.points);
}
}
fn group_selection(canvas: &StateCanvas<'_>, targets: impl IntoIterator<Item = Target>) -> Selection {
let mut selection = Selection::new(selection_context(canvas.context).scope);
for target in targets { selection.add(target, |_| true); }
selection
}
fn group_key(canvas: &StateCanvas<'_>, state: &mut State, key: keyboard::key::Named,
modifiers: keyboard::Modifiers) -> Option<Event>
{
canvas::Program::update(canvas, state, &canvas::Event::Keyboard(keyboard::Event::KeyPressed {
key: keyboard::Key::Named(key), modified_key: keyboard::Key::Named(key),
physical_key: keyboard::key::Physical::Code(keyboard::key::Code::Delete),
location: keyboard::Location::Standard, modifiers, text: None, repeat: false,
}), bounds(), mouse::Cursor::Unavailable).and_then(|action| action.into_inner().0)
}
#[test]
fn state_group_ordered_press_preview_and_fractional_release_preserve_primary() {
let (f, relation) = bend_fixture(true);
for zoom in [0.5, 1., 2.] {
let mut canvas = canvas(&f, Tool::Select, zoom);
let b = placement(&f, f.b);
let c = placement(&f, f.c);
let selection = group_selection(&canvas, [Target::Placement(b), Target::Placement(c)]);
canvas.selection = Some(&selection);
canvas.selected = f.c;
let before = selection.snapshot(selection_context(canvas.context)).unwrap();
let mut state = State::default();
let start = screen(&canvas, header(&canvas, f.b));
let end = start + Vector::new(7.25 * zoom, 10.5 * zoom);
motion(&canvas, &mut state, start, end);
assert!(input(&canvas, &mut state, mouse::Event::ButtonPressed(mouse::Button::Left), end).is_none());
assert!(state.group.is_some() && state.press.is_none());
assert_eq!(selection.primary(), Some(&Target::Placement(c)));
motion(&canvas, &mut state, end, end);
let scene = canvas.scene_with_bounds(&state, Some(bounds())).unwrap();
for id in [b, c] {
let node = scene.view.nodes.iter().find(|n| n.id == id).unwrap();
let original = &f.project.diagrams[&f.diagram].nodes[&id];
assert_eq!(node.position, Position { x: original.position.x + 7.25, y: original.position.y + 10.5 });
}
assert_eq!(scene.routed.edges[&relation].pins, vec![Position { x: 657.5, y: 351.25 }]);
assert!(matches!(input(&canvas, &mut state, mouse::Event::ButtonReleased(mouse::Button::Left), start),
Some(Event::Selection(SelectionEvent::Move(snapshot, Position { x: 7.25, y: 10.5 }))) if snapshot == before));
assert_eq!(selection.snapshot(selection_context(canvas.context)).unwrap(), before);
}
}
#[test]
fn state_group_stationary_collapses_but_out_and_back_keeps_group() {
let f = fixture();
let mut canvas = canvas(&f, Tool::Select, 1.);
let a = placement(&f, f.a);
let b = placement(&f, f.b);
let selection = group_selection(&canvas, [Target::Placement(a), Target::Placement(b)]);
canvas.selection = Some(&selection);
let start = screen(&canvas, header(&canvas, f.a));
for dragged in [false, true] {
let mut state = State::default();
motion(&canvas, &mut state, start, start);
assert!(input(&canvas, &mut state, mouse::Event::ButtonPressed(mouse::Button::Left), start).is_none());
if dragged {
motion(&canvas, &mut state, start + Vector::new(20., 0.), start);
motion(&canvas, &mut state, start, start);
}
let event = input(&canvas, &mut state, mouse::Event::ButtonReleased(mouse::Button::Left), start);
if dragged {
assert!(matches!(event, Some(Event::Selection(SelectionEvent::Move(_, Position { x: 0., y: 0. })))));
} else {
assert!(matches!(event, Some(Event::Selection(SelectionEvent::Collapse(_, Target::Placement(id)))) if id == a));
}
}
}
#[test]
fn state_group_changed_authority_viewport_or_membership_retires_preview_and_release() {
let f = fixture();
for change in 0..7 {
let mut canvas = canvas(&f, Tool::Select, 1.);
let a = placement(&f, f.a);
let b = placement(&f, f.b);
let selection = group_selection(&canvas, [Target::Placement(a), Target::Placement(b)]);
let mut replacement = group_selection(&canvas, [Target::Placement(a), Target::Placement(b)]);
replacement.remove(&Target::Placement(a));
replacement.add(Target::Placement(a), |_| true);
canvas.selection = Some(&selection);
let mut state = State::default();
let start = screen(&canvas, header(&canvas, f.a));
motion(&canvas, &mut state, start, start);
input(&canvas, &mut state, mouse::Event::ButtonPressed(mouse::Button::Left), start);
motion(&canvas, &mut state, start + Vector::new(12., 8.), start);
assert!(canvas.group_preview_key(&state, Some(bounds())).is_some());
let mut changed_bounds = bounds();
match change {
0 => canvas.context.revision += 1,
1 => canvas.context.epoch += 1,
2 => canvas.context.document = Id::new_v4(),
3 => canvas.zoom = 1.1,
4 => canvas.pan.x += 1.,
5 => changed_bounds.x += 1.,
_ => canvas.selection = Some(&replacement),
}
assert!(canvas.group_preview_key(&state, Some(changed_bounds)).is_none());
if change < 3 {
assert!(group_key(&canvas, &mut state, keyboard::key::Named::Delete, keyboard::Modifiers::empty()).is_none());
}
assert!(input_at_bounds(&canvas, &mut state, mouse::Event::ButtonReleased(mouse::Button::Left), start, changed_bounds).is_none());
assert!(state.group.is_none());
}
}
#[test]
fn state_group_mixed_route_handle_refuses_drag_and_preserves_stationary_collapse() {
let (f, relation) = bend_fixture(true);
let mut canvas = canvas(&f, Tool::Select, 1.);
let selection = group_selection(&canvas, [Target::Placement(placement(&f, f.b)), Target::Edge(relation)]);
canvas.selection = Some(&selection);
canvas.selected = relation;
let scene = canvas.scene(&State::default()).unwrap();
let at = screen(&canvas, point(scene.routed.edges[&relation].pins[0]));
for dragged in [false, true] {
let mut state = State::default();
motion(&canvas, &mut state, at, at);
input(&canvas, &mut state, mouse::Event::ButtonPressed(mouse::Button::Left), at);
assert!(state.group.is_some() && state.bend.is_none() && state.press.is_none());
if dragged {
motion(&canvas, &mut state, at + Vector::new(25., 10.), at);
motion(&canvas, &mut state, at, at);
}
let event = input(&canvas, &mut state, mouse::Event::ButtonReleased(mouse::Button::Left), at);
if dragged {
assert!(matches!(event, Some(Event::Selection(SelectionEvent::Refuse(_, _)))));
} else {
assert!(matches!(event, Some(Event::Selection(SelectionEvent::Collapse(_, Target::Edge(id)))) if id == relation));
}
assert!(state.bend_preview.is_none());
}
}
#[test]
fn state_group_shift_row_hit_menu_and_reassignment_keep_exact_identity() {
let mut f = fixture();
let work = f.project.add_element(Kind::Function, Layer::Sa, "Action work", None).unwrap();
let action = f.project.relate(RelationKind::StateAction, "Entry", f.a, work);
let internal = f.project.relate(RelationKind::StateTransition, "Internal", f.a, f.a);
if let RelationBody::StateTransition { transition_kind, .. } = &mut f.project.relations.get_mut(&internal).unwrap().body {
*transition_kind = TransitionKind::Internal;
}
let a = placement(&f, f.a);
for relation in [action, internal] {
let mut canvas = canvas(&f, Tool::Select, 1.);
let selection = group_selection(&canvas, [Target::Placement(placement(&f, f.b))]);
canvas.selection = Some(&selection);
let scene = canvas.scene(&State::default()).unwrap();
let node = scene.view.nodes.iter().find(|n| n.id == a).unwrap();
let rect = state_action_rows(&f.project, node).into_iter().chain(internal_rows(&f.project, node))
.find(|(row, _)| row.id == relation).unwrap().1;
assert!(visible_row_target(&f.project, f.diagram, &scene.view, a, relation));
let target = Target::StateRow { placement: a, relation };
assert_eq!(canvas.hit(&scene, rect.center()).unwrap().target(), target);
let mut state = State::default();
let modifiers = canvas::Program::update(&canvas, &mut state,
&canvas::Event::Keyboard(keyboard::Event::ModifiersChanged(keyboard::Modifiers::SHIFT)),
bounds(), mouse::Cursor::Unavailable);
assert!(modifiers.is_none());
let at = screen(&canvas, rect.center());
motion(&canvas, &mut state, at, at);
assert!(matches!(input(&canvas, &mut state, mouse::Event::ButtonPressed(mouse::Button::Left), at),
Some(Event::Selection(SelectionEvent::Choose(_, actual, Choice::Toggle))) if actual == target));
assert!(matches!(input(&canvas, &mut state, mouse::Event::ButtonPressed(mouse::Button::Right), at),
Some(Event::ContextMenu { object, placement: None, selection_target: Some(actual), .. }) if object == relation && actual == target));
canvas::Program::update(&canvas, &mut state, &canvas::Event::Window(iced::window::Event::Unfocused), bounds(), mouse::Cursor::Unavailable);
assert!(state.modifiers.is_empty());
}
if let RelationBody::StateAction { source, .. } = &mut f.project.relations.get_mut(&action).unwrap().body { *source = f.b; }
if let RelationBody::StateTransition { source, target, .. } = &mut f.project.relations.get_mut(&internal).unwrap().body { *source = f.b; *target = f.b; }
let view = projection::project_diagram(&f.project, &f.project.diagrams[&f.diagram]);
for relation in [action, internal] {
assert!(!visible_row_target(&f.project, f.diagram, &view, a, relation));
assert!(visible_row_target(&f.project, f.diagram, &view, placement(&f, f.b), relation));
}
}
#[test]
fn state_group_delete_and_escape_respect_held_gesture_and_full_set() {
let f = fixture();
let mut canvas = canvas(&f, Tool::Select, 1.);
let selection = group_selection(&canvas, [Target::Placement(placement(&f, f.a)), Target::Placement(placement(&f, f.b))]);
canvas.selection = Some(&selection);
let mut state = State::default();
use keyboard::key::Named;
assert!(matches!(group_key(&canvas, &mut state, Named::Delete, keyboard::Modifiers::empty()), Some(Event::Selection(SelectionEvent::Hide(_)))));
assert!(matches!(group_key(&canvas, &mut state, Named::Delete, keyboard::Modifiers::SHIFT), Some(Event::Selection(SelectionEvent::Refuse(_, _)))));
let at = screen(&canvas, header(&canvas, f.a));
motion(&canvas, &mut state, at, at);
input(&canvas, &mut state, mouse::Event::ButtonPressed(mouse::Button::Left), at);
assert!(group_key(&canvas, &mut state, Named::Delete, keyboard::Modifiers::empty()).is_none());
assert!(state.group.is_some());
assert!(group_key(&canvas, &mut state, Named::Escape, keyboard::Modifiers::empty()).is_none());
assert!(state.group.is_none());
assert!(matches!(group_key(&canvas, &mut state, Named::Escape, keyboard::Modifiers::empty()), Some(Event::Selection(SelectionEvent::Clear(_)))));
input(&canvas, &mut state, mouse::Event::ButtonPressed(mouse::Button::Middle), at);
input(&canvas, &mut state, mouse::Event::ButtonPressed(mouse::Button::Left), at);
assert!(state.group.is_none() && state.press.is_none() && state.pan_from.is_some());
assert!(matches!(motion(&canvas, &mut state, at + Vector::new(20., 10.), at), Some(Event::Pan(_, _))));
}
#[test]
fn state_group_entry_anchor_uses_authored_position_with_unsnapped_delta() {
let mut f = fixture();
let inner = f.project.elements[&f.leaf].parent.unwrap();
let entry = f.project.add_element(Kind::EntryPoint, Layer::Sa, "Enter", Some(inner)).unwrap();
let original = Position { x: 450.25, y: 410.75 };
let entry_node = f.project.place(f.diagram, entry, original);
let mut canvas = canvas(&f, Tool::Select, 1.);
let selection = group_selection(&canvas, [Target::Placement(entry_node), Target::Placement(placement(&f, f.leaf))]);
canvas.selection = Some(&selection);
let scene = canvas.scene(&State::default()).unwrap();
let shown = scene.view.nodes.iter().find(|n| n.id == entry_node).unwrap();
assert_ne!(shown.position.x, original.x);
let at = screen(&canvas, rectangle(shown).center());
let mut state = State::default();
motion(&canvas, &mut state, at, at);
input(&canvas, &mut state, mouse::Event::ButtonPressed(mouse::Button::Left), at);
assert!(state.group.is_some());
let end = at + Vector::new(7.25, 8.5);
motion(&canvas, &mut state, end, end);
assert!(matches!(input(&canvas, &mut state, mouse::Event::ButtonReleased(mouse::Button::Left), end),
Some(Event::Selection(SelectionEvent::Move(_, Position { x: 7.25, y: 8.5 })))));
assert_eq!(f.project.diagrams[&f.diagram].nodes[&entry_node].position, original);
}
#[test]
fn state_group_row_and_port_mixtures_refuse_complete_set_without_owner_promotion() {
let mut f = fixture();
let work = f.project.add_element(Kind::Function, Layer::Sa, "Action work", None).unwrap();
let action = f.project.relate(RelationKind::StateAction, "Entry", f.a, work);
let a = placement(&f, f.a);
for other in [Target::StateRow { placement: a, relation: action }, Target::Port { placement: a, port: work }] {
let mut canvas = canvas(&f, Tool::Select, 1.);
let selection = group_selection(&canvas, [Target::Placement(a), other]);
canvas.selection = Some(&selection);
let mut state = State::default();
assert!(matches!(group_key(&canvas, &mut state, keyboard::key::Named::Delete, keyboard::Modifiers::empty()),
Some(Event::Selection(SelectionEvent::Refuse(_, _)))));
let at = screen(&canvas, header(&canvas, f.a));
motion(&canvas, &mut state, at, at);
input(&canvas, &mut state, mouse::Event::ButtonPressed(mouse::Button::Left), at);
assert!(state.group.as_ref().is_some_and(|g| g.movement.is_none()));
let end = at + Vector::new(12., 8.);
motion(&canvas, &mut state, end, end);
assert!(canvas.group_preview_key(&state, Some(bounds())).is_none());
assert!(matches!(input(&canvas, &mut state, mouse::Event::ButtonReleased(mouse::Button::Left), end),
Some(Event::Selection(SelectionEvent::Refuse(snapshot, _))) if snapshot.targets().len() == 2));
assert!(state.press.is_none() && state.preview.is_none());
}
}
+51 -4
View File
@@ -41,7 +41,10 @@ pub fn diagram(app: &App, machine: Id) -> Option<Id> {
}
fn event(e: Event) -> Message {
Message::StateDiagram(e)
match e {
Event::Selection(selection) => Message::DiagramSelection(selection),
other => Message::StateDiagram(other),
}
}
pub fn view<'a>(app: &'a App, machine: Id, mut out: Column<'a, Message>) -> Element<'a, Message> {
@@ -143,8 +146,9 @@ pub fn view<'a>(app: &'a App, machine: Id, mut out: Column<'a, Message>) -> Elem
.collect::<Vec<_>>();
// Keep the optional picker in its own stable slot. Inserting it as a
// sibling before the canvas would replace the positional Iced focus tree.
let missing_spacing = if missing.is_empty() { 0.0_f32 } else { 8.0 };
let mut missing_controls = Column::new();
// Reserve the picker height even when all elements are placed. Group Hide
// and Undo must not resize the canvas and silently refit its camera.
let mut missing_controls = Column::new().height(34);
if !missing.is_empty() {
let position = Position {
x: (48. - state.pan.x) / state.zoom,
@@ -169,6 +173,7 @@ pub fn view<'a>(app: &'a App, machine: Id, mut out: Column<'a, Message>) -> Elem
diagram: id,
context,
selected: app.selected,
selection: app.diagram_selection.as_ref(),
navigation_focus: app
.diagram_focus(crate::diagram_usage::Surface::Saved(id))
.map(|usage| usage.bounds),
@@ -183,6 +188,7 @@ pub fn view<'a>(app: &'a App, machine: Id, mut out: Column<'a, Message>) -> Elem
out = out.push(
column![
missing_controls,
selection_controls(app),
crate::diagram::focus::wrap(
canvas.map(event),
context,
@@ -191,7 +197,7 @@ pub fn view<'a>(app: &'a App, machine: Id, mut out: Column<'a, Message>) -> Elem
Message::DiagramTraverse,
)
]
.spacing(missing_spacing)
.spacing(8)
.height(Fill),
);
let base: Element<'_, Message> = container(out.spacing(8))
@@ -202,6 +208,47 @@ pub fn view<'a>(app: &'a App, machine: Id, mut out: Column<'a, Message>) -> Elem
with_form(app, machine, base)
}
fn selection_controls(app: &App) -> Element<'_, Message> {
use crate::app::multiselect::{Event as SelectionEvent, placements};
let snapshot = app.multiselect_snapshot();
let count = snapshot.as_ref().map_or(0, |s| s.targets().len());
let mut controls = row![label(format!("{count} selected"))]
.spacing(7)
.align_y(iced::Center)
.height(32);
if let Some(snapshot) = snapshot.filter(|s| s.targets().len() > 1) {
if placements(&snapshot).is_some() {
controls = controls.push(action(
"Hide selected",
Message::DiagramSelection(SelectionEvent::Hide(snapshot.clone())),
));
}
controls = controls
.push(action(
"Clear selection",
Message::DiagramSelection(SelectionEvent::Clear(snapshot.context())),
))
.push(
label(format!(
"Properties edit only {}",
crate::views::short(app.project().named(app.selected), 18)
))
.wrapping(iced::widget::text::Wrapping::None),
);
}
let hint = if app.behavior.diagram.tool == Tool::Select {
"Shift+click adds or removes from selection"
} else {
"Use Select / move to select multiple elements"
};
column![
controls,
label(hint).wrapping(iced::widget::text::Wrapping::None)
]
.height(56)
.into()
}
fn transition_details(machine: Id, selected: Id) -> Element<'static, Message> {
action(
"New transition with details…",
+232 -12
View File
@@ -22,6 +22,9 @@ use std::{
sync::Arc,
};
#[cfg(test)]
#[path = "diagram/attachment_tests.rs"]
mod attachment_tests;
#[cfg(test)]
#[path = "diagram/lifecycle_tests.rs"]
mod lifecycle_tests;
#[cfg(test)]
@@ -36,12 +39,17 @@ mod port_labels;
pub use port_labels::PortLabels;
#[path = "diagram/focus.rs"]
pub(crate) mod focus;
pub(crate) mod group_gesture;
mod multiselect;
#[cfg(test)]
mod multiselect_tests;
#[path = "diagram/work_drag.rs"]
mod work_drag;
pub use work_drag::WorkFeedback;
#[derive(Clone, Copy)]
pub struct DiagramCanvas<'a> {
pub selection: Option<&'a crate::diagram_selection::Selection>,
pub allocation: Option<crate::app::allocation_drag::Capability<'a>>,
pub project: &'a Project,
pub view_index: Option<&'a oa_core::view_options::ViewIndex>,
@@ -53,12 +61,15 @@ pub struct DiagramCanvas<'a> {
pub pan: Vector,
pub context: Context,
pub selected_bend: Option<usize>,
pub selected_attachment: Option<EdgeEnd>,
pub editable: bool,
pub placing: bool,
pub connection: Option<&'a crate::connection_edit::Session>,
}
#[derive(Default)]
pub struct State {
modifiers: iced::keyboard::Modifiers,
group: Option<multiselect::Press>,
work: Option<Arc<crate::app::allocation_drag::Press>>,
work_cache: RefCell<Option<Arc<Scene>>>,
pointer: Option<Point>,
@@ -67,6 +78,7 @@ pub struct State {
pan_start: Option<(mouse::Button, Point)>,
context: Option<Context>,
route_drag: Option<Gesture>,
attachment_viewport: Option<(f32, Vector, Rectangle)>,
connection_press: Option<(Id, Point)>,
connection_hover: Option<Point>,
port_drag: Option<crate::port_edit::Gesture>,
@@ -294,6 +306,11 @@ impl DiagramCanvas<'_> {
);
request.canvas = Some(self.context);
request.creation_point = Some(position(world));
if self.attachment_at(&scene, world).is_some() {
request.object = self.selected;
request.route_point = Some(position(world));
return request;
}
if let Some(edge) = scene.routed.edges.get(&self.selected)
&& !edge.custom_suspended
&& let Some(index) = edge
@@ -371,6 +388,39 @@ impl DiagramCanvas<'_> {
})
.map(|(id, segment, _)| (id, segment))
}
fn attachment_at<'b>(
&self,
scene: &'b Scene,
world: Point,
) -> Option<(EdgeEnd, &'b ProjectedNode)> {
let edge = scene.routed.edges.get(&self.selected)?;
if edge.custom_suspended {
return None;
}
[
(EdgeEnd::Source, edge.points.first()),
(EdgeEnd::Target, edge.points.last()),
]
.into_iter()
.filter_map(|(end, at)| {
let distance = world.distance(point(*at?));
let owner = route_edit::attachment_node(
self.project,
&self.project.diagrams[&self.diagram],
&scene.view,
self.selected,
end,
)?;
(distance <= 8. / self.zoom).then_some((end, owner, distance))
})
.min_by(|a, b| {
a.2.total_cmp(&b.2).then_with(|| {
(Some(a.0) != self.selected_attachment)
.cmp(&(Some(b.0) != self.selected_attachment))
})
})
.map(|(end, owner, _)| (end, owner))
}
fn hit_label(&self, routed: &RoutedView, p: Point) -> Option<Id> {
let p = self.world(p);
routed
@@ -419,6 +469,7 @@ impl DiagramCanvas<'_> {
}
fn preview_diagram(&self, state: &State) -> Diagram {
let mut diagram = self.project.diagrams[&self.diagram].clone();
self.group_preview(state, &mut diagram);
if let (Some((id, _, original)), Some(preview)) = (state.drag, state.preview) {
let delta = Position {
x: preview.x - original.x,
@@ -561,6 +612,31 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
bounds: Rectangle,
cursor: mouse::Cursor,
) -> Option<Action<Message>> {
if let canvas::Event::Keyboard(iced::keyboard::Event::ModifiersChanged(modifiers)) = event {
state.modifiers = *modifiers;
return None;
}
if matches!(event, canvas::Event::Window(iced::window::Event::Unfocused)) {
state.modifiers = iced::keyboard::Modifiers::default();
}
// An attachment belongs to the boundary geometry captured at press.
// Camera/layout changes must not reinterpret its pointer in another space.
if matches!(
state.route_drag.as_ref().map(|gesture| &gesture.handle),
Some(Handle::Attachment { .. })
) && (state.attachment_viewport != Some((self.zoom, self.pan, bounds))
|| matches!(
event,
canvas::Event::Window(
iced::window::Event::Unfocused | iced::window::Event::Resized(_)
) | canvas::Event::Mouse(mouse::Event::CursorLeft)
))
{
state.route_drag = None;
state.attachment_viewport = None;
state.pointer = None;
return Some(Action::request_redraw());
}
// A completed work click retires App's allocation epoch before the
// retained canvas sees a redraw. Its already-idle press must not eat
// the first Delete. A live or not-yet-dispatched press stays guarded.
@@ -580,6 +656,7 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
return Some(action);
}
if state.context != Some(self.context) {
state.group = None;
state.work = None;
state.pointer = None;
state.port_drag = None;
@@ -646,6 +723,9 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
let local = cursor
.position()
.map(|p| Point::new(p.x - bounds.x, p.y - bounds.y));
if let Some(action) = self.group_input(state, event, bounds, cursor) {
return Some(action);
}
match event {
canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Right))
if cursor.is_over(bounds) =>
@@ -840,6 +920,21 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
&self.project.diagrams[&self.diagram],
self.selected,
);
if let Some((end, owner)) = self.attachment_at(&scene, world) {
let original = route.attachment(end);
self.begin_route(
state,
world,
Handle::Attachment {
end,
owner: owner.clone(),
original,
},
route,
);
state.attachment_viewport = Some((self.zoom, self.pan, bounds));
return Some(Action::capture());
}
if let Some(index) = edge
.pins
.iter()
@@ -963,7 +1058,7 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
return Some(Action::request_redraw());
}
if let Some(gesture) = &mut state.route_drag {
gesture.move_to(self.world(p), self.zoom);
gesture.move_attachment_to(self.world(p), self.zoom);
return Some(Action::request_redraw().and_capture());
}
if let Some((_, start, original)) = state.drag {
@@ -1040,17 +1135,43 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
return Some(Action::capture());
}
if let Some(mut gesture) = state.route_drag.take() {
if matches!(gesture.handle, Handle::Attachment { .. }) {
state.attachment_viewport = None;
if !cursor.is_over(bounds) {
return Some(Action::request_redraw().and_capture());
}
}
// `local` uses the ordered pointer position at release,
// before any later motion in the same native event batch.
if let Some(p) = local {
gesture.move_to(self.world(p), self.zoom);
gesture.move_attachment_to(self.world(p), self.zoom);
}
if !gesture.changed {
return Some(Action::request_redraw().and_capture());
}
if let Handle::Attachment { end, owner, .. } = &gesture.handle {
if gesture.route
== route_edit::stored_route(
&self.project.diagrams[&self.diagram],
self.selected,
)
{
return Some(Action::request_redraw().and_capture());
}
return Some(
Action::publish(Message::Route(RouteEvent::CommitAttachment(
gesture.context,
gesture.route,
*end,
owner.id,
)))
.and_capture(),
);
}
let bend = match gesture.handle {
Handle::Bend(index) => Some(index),
Handle::Label { .. } => None,
Handle::Attachment { .. } => unreachable!(),
};
return Some(
Action::publish(Message::Route(RouteEvent::Commit(
@@ -1094,6 +1215,15 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
canvas::Event::Mouse(mouse::Event::WheelScrolled { delta })
if cursor.is_over(bounds) =>
{
// Cancel before publishing Zoom: a release may arrive in this
// same event batch, before App applies the new camera.
if matches!(
state.route_drag.as_ref().map(|gesture| &gesture.handle),
Some(Handle::Attachment { .. })
) {
state.route_drag = None;
state.attachment_viewport = None;
}
let y = match delta {
mouse::ScrollDelta::Lines { y, .. } | mouse::ScrollDelta::Pixels { y, .. } => {
*y
@@ -1151,7 +1281,17 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
.collect::<BTreeSet<_>>();
for n in &view.nodes {
if containers.contains(&n.id) {
paint_node(&mut f, project, n, self.selected, true);
paint_node(
&mut f,
project,
n,
if self.selection_contains(crate::diagram_usage::Target::Placement(n.id)) {
n.element
} else {
self.selected
},
true,
);
}
}
let highlighted = highlighted_exchanges(project, self.selected);
@@ -1163,7 +1303,9 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
continue;
};
let end = point(end);
let active = edge.id == self.selected || highlighted.contains(&edge.id);
let active = edge.id == self.selected
|| highlighted.contains(&edge.id)
|| self.selection_contains(crate::diagram_usage::Target::Edge(edge.id));
let color = if active {
style::TEAL
} else if routed
@@ -1259,17 +1401,42 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
}
for n in &view.nodes {
if !containers.contains(&n.id) {
paint_node(&mut f, project, n, self.selected, false);
paint_node(
&mut f,
project,
n,
if self.selection_contains(crate::diagram_usage::Target::Placement(n.id)) {
n.element
} else {
self.selected
},
false,
);
} else {
// Container headers stay readable when relationships traverse
// the allocation enclosure. Children remain on top.
let mut header = n.clone();
header.size.height = 62.;
paint_node(&mut f, project, &header, self.selected, true);
paint_node(
&mut f,
project,
&header,
if self.selection_contains(crate::diagram_usage::Target::Placement(n.id)) {
n.element
} else {
self.selected
},
true,
);
}
}
for port in &view.ports {
let color = if self.selected == port.element {
let selected = self.selected == port.element
|| self.selection_contains(crate::diagram_usage::Target::Port {
port: port.element,
placement: port.owner,
});
let color = if selected {
style::TEAL
} else {
style::kind_color(
@@ -1297,11 +1464,7 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
let mut stroke =
Stroke::default()
.with_color(color)
.with_width(if self.selected == port.element {
2.5
} else {
1.5
});
.with_width(if selected { 2.5 } else { 1.5 });
if glyph == PortGlyph::Legacy {
stroke.line_dash = canvas::LineDash {
segments: &[3., 2.],
@@ -1336,6 +1499,41 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
if let Some(edge) = scene.routed.edges.get(&self.selected)
&& !edge.custom_suspended
{
for (end, at) in [
(EdgeEnd::Source, edge.points.first()),
(EdgeEnd::Target, edge.points.last()),
] {
if let Some(at) = at
&& route_edit::attachment_node(
project,
&project.diagrams[&self.diagram],
view,
self.selected,
end,
)
.is_some()
{
let at = point(*at);
let handle = Path::rectangle(
at - Vector::new(5.5 / self.zoom, 5.5 / self.zoom),
Size::new(11. / self.zoom, 11. / self.zoom),
);
f.fill(
&handle,
if self.selected_attachment == Some(end) {
style::TEAL
} else {
Color::WHITE
},
);
f.stroke(
&handle,
Stroke::default()
.with_color(style::TEAL)
.with_width(1.7 / self.zoom),
);
}
}
for (index, pin) in edge.pins.iter().enumerate() {
let handle = Path::circle(point(*pin), 5.5 / self.zoom);
f.fill(
@@ -2033,6 +2231,7 @@ mod tests {
y: original.y - 200.,
};
let canvas = DiagramCanvas {
selection: None,
project: &p,
view_index: None,
context: Context {
@@ -2042,6 +2241,7 @@ mod tests {
epoch: 0,
},
selected_bend: None,
selected_attachment: None,
editable: true,
placing: false,
connection: None,
@@ -2109,6 +2309,7 @@ mod tests {
.unwrap()
.id;
let canvas = DiagramCanvas {
selection: None,
project: &p,
view_index: None,
context: Context {
@@ -2118,6 +2319,7 @@ mod tests {
epoch: 0,
},
selected_bend: None,
selected_attachment: None,
editable: true,
placing: false,
connection: None,
@@ -2236,6 +2438,7 @@ mod tests {
};
let session = Session::new(&p, &view, context, Tool::FunctionalExchange);
let canvas = DiagramCanvas {
selection: None,
project: &p,
view_index: None,
diagram,
@@ -2243,6 +2446,7 @@ mod tests {
selected: p.id,
navigation_focus: None,
selected_bend: None,
selected_attachment: None,
zoom,
pan: Vector::new(20., 30.),
editable: true,
@@ -2380,6 +2584,8 @@ mod tests {
diagram,
route: EdgeRoute {
edge,
source_attachment: None,
target_attachment: None,
waypoints: vec![Position { x: 400., y: -100. }],
label: None,
},
@@ -2408,6 +2614,7 @@ mod tests {
for zoom in [0.5, 1., 1.5] {
let (p, diagram, edge) = route_fixture();
let canvas = DiagramCanvas {
selection: None,
project: &p,
view_index: None,
diagram,
@@ -2422,6 +2629,7 @@ mod tests {
epoch: 0,
},
selected_bend: None,
selected_attachment: None,
editable: true,
placing: false,
connection: None,
@@ -2485,6 +2693,7 @@ mod tests {
fn fast_drags_commit_the_release_cursor_without_a_final_motion_event() {
let (p, diagram, edge) = route_fixture();
let canvas = DiagramCanvas {
selection: None,
project: &p,
view_index: None,
diagram,
@@ -2499,6 +2708,7 @@ mod tests {
epoch: 0,
},
selected_bend: None,
selected_attachment: None,
editable: true,
placing: false,
connection: None,
@@ -2583,6 +2793,7 @@ mod tests {
fn route_cache_ignores_pan_zoom_and_cancelled_drag_cannot_commit() {
let (p, diagram, edge) = route_fixture();
let mut canvas = DiagramCanvas {
selection: None,
project: &p,
view_index: None,
diagram,
@@ -2597,6 +2808,7 @@ mod tests {
epoch: 0,
},
selected_bend: None,
selected_attachment: None,
editable: true,
placing: false,
connection: None,
@@ -2712,6 +2924,7 @@ mod tests {
assert!(!svg.contains("data-navigation=\"source\""));
assert!(svg.contains("data-navigation=\"target\""));
let mut canvas = DiagramCanvas {
selection: None,
project: &p,
view_index: None,
diagram,
@@ -2726,6 +2939,7 @@ mod tests {
epoch: 0,
},
selected_bend: None,
selected_attachment: None,
editable: true,
placing: false,
connection: None,
@@ -2767,6 +2981,7 @@ mod tests {
offset: Position { x: 0., y: -160. },
});
let mut canvas = DiagramCanvas {
selection: None,
project: &p,
view_index: None,
diagram,
@@ -2781,6 +2996,7 @@ mod tests {
epoch: 0,
},
selected_bend: None,
selected_attachment: None,
editable: true,
placing: false,
connection: None,
@@ -2842,6 +3058,7 @@ mod tests {
},
});
let canvas = DiagramCanvas {
selection: None,
project: &p,
view_index: None,
diagram,
@@ -2856,6 +3073,7 @@ mod tests {
epoch: 0,
},
selected_bend: None,
selected_attachment: None,
editable: true,
placing: false,
connection: None,
@@ -2876,6 +3094,7 @@ mod tests {
routed.edges.insert(duplicate, duplicate_route);
let selected = edge.max(duplicate); // UUID ordering must not override selection.
let canvas = DiagramCanvas {
selection: None,
project: &p,
view_index: None,
diagram,
@@ -2890,6 +3109,7 @@ mod tests {
epoch: 0,
},
selected_bend: None,
selected_attachment: None,
editable: true,
placing: false,
connection: None,
@@ -0,0 +1,447 @@
use super::*;
use crate::app::{App, Page};
use iced::widget::canvas::Program;
fn fixture() -> App {
let mut p = Project::empty();
let a = p.add(Kind::Activity, "Send", 100., 150.);
let b = p.add(Kind::Activity, "Receive", 650., 150.);
let edge = p.relate(RelationKind::Interaction, "Report", a, b);
let diagram = p.ensure_diagram(Layer::Oa);
p.diagrams
.get_mut(&diagram)
.unwrap()
.routes
.push(EdgeRoute {
edge,
waypoints: vec![Position { x: 450., y: 350. }],
label: Some(EdgeLabel {
fraction: 0.4,
offset: Position { x: 5., y: 10. },
}),
source_attachment: None,
target_attachment: Some(EdgeAttachment {
side: PortAnchorSide::Left,
offset: 0.6,
}),
});
let mut app = App::with_project(p);
app.page = Page::Diagram;
app.active_diagram = Some(diagram);
let _ = app.update(Message::Select(edge));
app
}
fn canvas(app: &App, zoom: f32) -> DiagramCanvas<'_> {
DiagramCanvas {
selection: None,
allocation: None,
project: app.project(),
view_index: None,
diagram: app.active_diagram.unwrap(),
selected: app.selected,
navigation_focus: None,
zoom,
pan: Vector::new(40., 50.),
context: app.route_context().unwrap(),
selected_bend: None,
selected_attachment: app
.selected_attachment
.filter(|(c, e, _)| Some(*c) == app.route_context() && *e == app.selected)
.map(|(_, _, end)| end),
editable: !app.draft_dirty() && !app.source_dirty(),
placing: false,
connection: None,
}
}
fn bounds() -> Rectangle {
Rectangle::new(Point::new(120., 80.), Size::new(2400., 1800.))
}
fn screen(c: &DiagramCanvas<'_>, p: Position) -> Point {
Point::new(
bounds().x + p.x * c.zoom + c.pan.x,
bounds().y + p.y * c.zoom + c.pan.y,
)
}
fn send(
c: &DiagramCanvas<'_>,
state: &mut State,
event: mouse::Event,
final_cursor: Point,
) -> Option<Message> {
c.update(
state,
&canvas::Event::Mouse(event),
bounds(),
mouse::Cursor::Available(final_cursor),
)
.and_then(|a| a.into_inner().0)
}
fn positions(c: &DiagramCanvas<'_>) -> (Point, Point) {
let scene = c.scene(&State::default());
let start = *scene.routed.edges[&c.selected].points.first().unwrap();
let node = route_edit::attachment_node(
c.project,
&c.project.diagrams[&c.diagram],
&scene.view,
c.selected,
EdgeEnd::Source,
)
.unwrap();
let end = oa_core::routing::attachment_point(
c.project,
&c.project.diagrams[&c.diagram],
node,
&EdgeAttachment {
side: PortAnchorSide::Top,
offset: 0.25,
},
);
(screen(c, start), screen(c, end))
}
fn drag(app: &App, zoom: f32) -> Message {
let c = canvas(app, zoom);
let (start, end) = positions(&c);
let mut state = State::default();
let messages = [
mouse::Event::CursorMoved { position: start },
mouse::Event::ButtonPressed(mouse::Button::Left),
mouse::Event::CursorMoved { position: end },
mouse::Event::ButtonReleased(mouse::Button::Left),
]
.into_iter()
.filter_map(|e| send(&c, &mut state, e, end))
.collect::<Vec<_>>();
assert_eq!(messages.len(), 1);
messages.into_iter().next().unwrap()
}
#[test]
fn attachment_event_batches_commit_one_source_history_change_at_three_zooms() {
for zoom in [0.4, 1., 2.] {
let mut app = fixture();
let before = app.document.bundle().clone();
let project = app.project().clone();
let revision = app.document.revision();
let message = drag(&app, zoom);
let Message::Route(RouteEvent::CommitAttachment(_, ref route, EdgeEnd::Source, _)) =
message
else {
panic!("attachment commit required")
};
assert_eq!(
route.source_attachment,
Some(EdgeAttachment {
side: PortAnchorSide::Top,
offset: 0.25
})
);
let intended = route.clone();
let _ = app.update(message);
assert_eq!(app.document.revision(), revision + 1);
let mut expected = project;
route_edit::with_route(
expected
.diagrams
.get_mut(&app.active_diagram.unwrap())
.unwrap(),
intended,
);
assert_eq!(
*app.project(),
expected,
"only the intended view attachment changes"
);
let after = app.document.bundle().clone();
assert_eq!(
oa_core::source_project::compile_bundle(&after, Default::default())
.unwrap()
.decoded
.project,
expected
);
let _ = app.update(Message::Undo);
assert_eq!(app.document.bundle(), &before);
let _ = app.update(Message::Redo);
assert_eq!(app.document.bundle(), &after);
}
}
#[test]
fn attachment_stationary_return_and_escape_leave_source_unchanged() {
let mut app = fixture();
let before = app.document.bundle().clone();
let c = canvas(&app, 1.);
let (start, end) = positions(&c);
let mut state = State::default();
for event in [
mouse::Event::CursorMoved { position: start },
mouse::Event::ButtonPressed(mouse::Button::Left),
mouse::Event::CursorMoved { position: end },
mouse::Event::CursorMoved { position: start },
mouse::Event::ButtonReleased(mouse::Button::Left),
] {
assert!(send(&c, &mut state, event, start).is_none());
}
for event in [
mouse::Event::CursorMoved { position: start },
mouse::Event::ButtonPressed(mouse::Button::Left),
mouse::Event::CursorMoved { position: end },
] {
assert!(send(&c, &mut state, event, end).is_none());
}
let _ = app.update(Message::Escape);
assert!(
send(
&canvas(&app, 1.),
&mut state,
mouse::Event::ButtonReleased(mouse::Button::Left),
end
)
.is_none()
);
assert_eq!(app.document.bundle(), &before);
}
#[test]
fn attachment_camera_layout_focus_and_outside_release_cancel_after_motion() {
for cancel in 0..8 {
let app = fixture();
let before = app.document.bundle().clone();
let mut c = canvas(&app, 1.);
let (start, end) = positions(&c);
let mut state = State::default();
for event in [
mouse::Event::CursorMoved { position: start },
mouse::Event::ButtonPressed(mouse::Button::Left),
mouse::Event::CursorMoved { position: end },
] {
assert!(send(&c, &mut state, event, end).is_none());
}
assert!(state.route_drag.as_ref().is_some_and(|g| g.changed));
let mut current_bounds = bounds();
let mut release = end;
match cancel {
0 => c.zoom = 2.,
1 => c.pan.x += 20.,
2 => current_bounds.x += 20.,
3 => current_bounds.height -= 100.,
4..=6 => {
let event = match cancel {
4 => canvas::Event::Window(iced::window::Event::Unfocused),
5 => canvas::Event::Window(iced::window::Event::Resized(Size::new(800., 600.))),
_ => canvas::Event::Mouse(mouse::Event::CursorLeft),
};
let message = c
.update(
&mut state,
&event,
current_bounds,
mouse::Cursor::Available(end),
)
.and_then(|a| a.into_inner().0);
assert!(message.is_none());
}
_ => {
release = Point::new(-100., -100.);
assert!(
send(
&c,
&mut state,
mouse::Event::CursorMoved { position: release },
release
)
.is_none()
);
}
}
let message = c
.update(
&mut state,
&canvas::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)),
current_bounds,
mouse::Cursor::Available(release),
)
.and_then(|a| a.into_inner().0);
assert!(message.is_none(), "cancellation case {cancel}");
assert!(state.route_drag.is_none(), "cancellation case {cancel}");
assert_eq!(app.document.bundle(), &before);
}
}
#[test]
fn attachment_wheel_and_middle_pan_cancel_before_same_batch_left_release() {
for wheel in [true, false] {
let mut app = fixture();
let before = app.document.bundle().clone();
let revision = app.document.revision();
let c = canvas(&app, 1.);
let (start, end) = positions(&c);
let mut state = State::default();
for event in [
mouse::Event::CursorMoved { position: start },
mouse::Event::ButtonPressed(mouse::Button::Left),
mouse::Event::CursorMoved { position: end },
] {
assert!(send(&c, &mut state, event, end).is_none());
}
let camera_message = send(
&c,
&mut state,
if wheel {
mouse::Event::WheelScrolled {
delta: mouse::ScrollDelta::Lines { x: 0., y: 1. },
}
} else {
mouse::Event::ButtonPressed(mouse::Button::Middle)
},
end,
);
assert_eq!(camera_message.is_some(), wheel);
assert!(state.route_drag.is_none());
// Deliberately retain the same canvas and defer App camera dispatch.
assert!(
send(
&c,
&mut state,
mouse::Event::ButtonReleased(mouse::Button::Left),
end
)
.is_none()
);
if !wheel {
assert!(
send(
&c,
&mut state,
mouse::Event::ButtonReleased(mouse::Button::Middle),
end
)
.is_none()
);
}
if let Some(message) = camera_message {
assert!(matches!(message, Message::Zoom(_)));
let _ = app.update(message);
}
assert_eq!(app.document.revision(), revision);
assert_eq!(app.document.bundle(), &before);
}
}
#[test]
fn attachment_fast_release_uses_release_position_and_stays_in_one_view() {
let mut app = fixture();
let original = app.active_diagram.unwrap();
let _ = app.update(Message::DuplicateDiagram(original));
let other = app.active_diagram.unwrap();
assert_ne!(original, other);
app.active_diagram = Some(original);
let edge = app.project().diagrams[&original].routes[0].edge;
let _ = app.update(Message::Select(edge));
let untouched = app.project().diagrams[&other].clone();
let c = canvas(&app, 0.7);
let (start, end) = positions(&c);
let mut state = State::default();
assert!(
send(
&c,
&mut state,
mouse::Event::ButtonPressed(mouse::Button::Left),
start
)
.is_none()
);
let message = send(
&c,
&mut state,
mouse::Event::ButtonReleased(mouse::Button::Left),
end,
)
.expect("release completes drag without final move");
let _ = app.update(message);
assert_eq!(
app.project().diagrams[&original].routes[0].source_attachment,
Some(EdgeAttachment {
side: PortAnchorSide::Top,
offset: 0.25
})
);
assert_eq!(app.project().diagrams[&other], untouched);
}
#[test]
fn attachment_stale_context_wrong_placement_and_unowned_route_fields_are_rejected() {
let mut app = fixture();
let before = app.document.bundle().clone();
let message = drag(&app, 1.);
let Message::Route(RouteEvent::CommitAttachment(context, route, end, node)) = message else {
panic!()
};
let mut changed = route.clone();
changed.waypoints.clear();
let _ = app.update(Message::Route(RouteEvent::CommitAttachment(
context, changed, end, node,
)));
let _ = app.update(Message::Route(RouteEvent::CommitAttachment(
context,
route.clone(),
end,
Id::new_v4(),
)));
let _ = app.update(Message::Escape);
let _ = app.update(Message::Route(RouteEvent::CommitAttachment(
context, route, end, node,
)));
assert_eq!(app.document.bundle(), &before);
}
#[test]
fn attachment_reset_preserves_opposite_end_pins_label_and_semantics() {
let mut app = fixture();
let before = app.project().clone();
let _ = app.update(Message::Route(RouteEvent::ResetAttachment(EdgeEnd::Target)));
let mut expected = before;
expected
.diagrams
.get_mut(&app.active_diagram.unwrap())
.unwrap()
.routes[0]
.target_attachment = None;
assert_eq!(*app.project(), expected);
let revision = app.document.revision();
let _ = app.update(Message::Route(RouteEvent::ResetAttachment(EdgeEnd::Target)));
assert_eq!(app.document.revision(), revision);
}
#[test]
fn coincident_attachment_handles_honor_explicit_end_choice() {
let mut app = fixture();
let _ = app.update(Message::Route(RouteEvent::ChooseAttachment(
EdgeEnd::Target,
)));
let c = canvas(&app, 1.);
let mut scene = Scene {
work: None,
view: projection::project_view(c.project, c.diagram),
routed: Arc::new(route_diagram(
c.project,
&c.project.diagrams[&c.diagram],
&projection::project_view(c.project, c.diagram),
)),
};
let routed = Arc::make_mut(&mut scene.routed);
let edge = routed.edges.get_mut(&c.selected).unwrap();
let start = *edge.points.first().unwrap();
*edge.points.last_mut().unwrap() = start;
assert_eq!(
c.attachment_at(&scene, point(start)).unwrap().0,
EdgeEnd::Target
);
let mut source = c;
source.selected_attachment = Some(EdgeEnd::Source);
assert_eq!(
source.attachment_at(&scene, point(start)).unwrap().0,
EdgeEnd::Source
);
}
+42 -3
View File
@@ -12,6 +12,8 @@ struct Input {
suspended: bool,
traversing: bool,
pointer: Option<Point>,
modifiers: iced::keyboard::Modifiers,
restore_modifiers: bool,
}
impl operation::Focusable for Input {
fn is_focused(&self) -> bool {
@@ -78,7 +80,11 @@ impl<Message, R: renderer::Renderer> Widget<Message, Theme, R> for Owner<'_, Mes
}
if state.context.is_none_or(|previous| {
(previous.document, previous.diagram, previous.epoch)
!= (self.context.document, self.context.diagram, self.context.epoch)
!= (
self.context.document,
self.context.diagram,
self.context.epoch,
)
}) || !self.enabled
{
state.focused = false;
@@ -91,6 +97,7 @@ impl<Message, R: renderer::Renderer> Widget<Message, Theme, R> for Owner<'_, Mes
// canvas's transient gestures without retiring this focus owner.
state.pointer = None;
tree.children[0] = Tree::new(self.content.as_widget());
state.restore_modifiers = true;
} else {
tree.diff_children(std::slice::from_ref(&self.content));
}
@@ -139,6 +146,14 @@ impl<Message, R: renderer::Renderer> Widget<Message, Theme, R> for Owner<'_, Mes
viewport: &Rectangle,
) {
let state = tree.state.downcast_mut::<Input>();
if let InputEvent::Keyboard(iced::keyboard::Event::ModifiersChanged(modifiers)) = event {
state.modifiers = *modifiers;
state.restore_modifiers = false;
}
if matches!(event, InputEvent::Window(iced::window::Event::Unfocused)) {
state.modifiers = iced::keyboard::Modifiers::default();
state.restore_modifiers = true;
}
if let InputEvent::Mouse(mouse::Event::CursorMoved { position }) = event {
state.pointer = Some(*position);
}
@@ -167,7 +182,14 @@ impl<Message, R: renderer::Renderer> Widget<Message, Theme, R> for Owner<'_, Mes
}
// Save/export I/O denies commands and acquisition, while real focus
// operations and outside clicks can still revoke the retained owner.
if self.suspended && matches!(event, InputEvent::Keyboard(_) | InputEvent::Mouse(_)) {
let passive_modifiers = matches!(
event,
InputEvent::Keyboard(iced::keyboard::Event::ModifiersChanged(_))
);
if self.suspended
&& !passive_modifiers
&& matches!(event, InputEvent::Keyboard(_) | InputEvent::Mouse(_))
{
return;
}
if let InputEvent::Keyboard(iced::keyboard::Event::KeyPressed {
@@ -189,13 +211,30 @@ impl<Message, R: renderer::Renderer> Widget<Message, Theme, R> for Owner<'_, Mes
return;
}
if matches!(event, InputEvent::Keyboard(_))
&& !passive_modifiers
&& (!state.focused || state.traversing || !self.enabled)
{
return;
}
if shell.is_event_captured() {
if shell.is_event_captured() && !passive_modifiers {
return;
}
if state.restore_modifiers {
// Suspending I/O replaces the child to retire pointer gestures.
// Held modifiers belong to this longer-lived passive owner, so
// restore them before the first admitted event reaches the child.
state.restore_modifiers = false;
self.content.as_widget_mut().update(
&mut tree.children[0],
&InputEvent::Keyboard(iced::keyboard::Event::ModifiersChanged(state.modifiers)),
layout,
cursor,
renderer,
clipboard,
shell,
viewport,
);
}
self.content.as_widget_mut().update(
&mut tree.children[0],
event,
@@ -0,0 +1,685 @@
//! Saved-view pointer geometry. Selection, capability and model admission remain
//! with the caller; this module never applies a selection or a model command.
use crate::diagram_selection::Snapshot;
use crate::diagram_usage::{Surface, Target};
use iced::{Point, Rectangle, Vector};
use oa_core::{Id, Position};
#[derive(Debug, Clone, Copy, PartialEq)]
pub(crate) struct Viewport {
pub zoom: f32,
pub pan: Vector,
pub bounds: Rectangle,
}
/// Node displacement policy; route-bend snapping remains outside this helper.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum DisplacementPolicy {
/// Preserve the ordinary saved-view ten-unit anchor grid.
Grid10,
/// Preserve State views' fractional authored placement displacement.
Unsnapped,
}
#[derive(Debug)]
pub(crate) struct Gesture {
snapshot: Snapshot,
anchor: Id,
original: Position,
press: Point,
viewport: Viewport,
displacement: DisplacementPolicy,
crossed_threshold: bool,
delta: Position,
retired: bool,
}
#[derive(Debug, PartialEq)]
pub(crate) enum Outcome {
Click(Id),
Move(Snapshot, Position),
}
impl Gesture {
pub fn new(
snapshot: Snapshot,
anchor: Id,
original: Position,
press: Point,
viewport: Viewport,
) -> Option<Self> {
Self::new_with_policy(
snapshot,
anchor,
original,
press,
viewport,
DisplacementPolicy::Grid10,
)
}
/// `original` is the authored placement position, not its projected symbol
/// position (State entry/exit symbols can be displayed on another boundary).
/// Pointer displacement comes from frozen screen geometry and zoom.
pub fn new_with_policy(
snapshot: Snapshot,
anchor: Id,
original: Position,
press: Point,
viewport: Viewport,
displacement: DisplacementPolicy,
) -> Option<Self> {
if !matches!(snapshot.context().scope.surface, Surface::Saved(_))
|| !snapshot.targets().contains(&Target::Placement(anchor))
|| !snapshot
.targets()
.iter()
.all(|target| matches!(target, Target::Placement(_)))
|| !viewport.valid()
|| !finite_position(original)
|| !finite_point(press)
|| !viewport.bounds.contains(press)
{
return None;
}
Some(Self {
snapshot,
anchor,
original,
press,
viewport,
displacement,
crossed_threshold: false,
delta: Position { x: 0., y: 0. },
retired: false,
})
}
pub fn snapshot(&self) -> &Snapshot {
&self.snapshot
}
#[cfg(test)]
pub fn anchor(&self) -> Id {
self.anchor
}
/// Compare before processing each event. On mismatch, the caller must drop
/// this gesture, just as on Escape, focus loss or changed model authority.
pub fn viewport_matches(&self, viewport: Viewport) -> bool {
!self.retired && self.viewport == viewport
}
pub fn crossed_threshold(&self) -> bool {
self.crossed_threshold
}
/// Common policy-adjusted world delta for preview and release; zero before a drag.
pub fn delta(&self) -> Position {
self.delta
}
/// Process an absolute screen position. False permanently retires the
/// gesture; the caller must remove its preview and produce no operation.
pub fn move_to(&mut self, pointer: Point) -> bool {
if self.retired {
return false;
}
if !finite_point(pointer) || !self.viewport.bounds.contains(pointer) {
self.retire();
return false;
}
let screen = pointer - self.press;
self.crossed_threshold |= screen.x.hypot(screen.y) > 3.;
if !self.crossed_threshold {
return true;
}
let unsnapped = Position {
x: screen.x / self.viewport.zoom,
y: screen.y / self.viewport.zoom,
};
let desired = Position {
x: self.original.x + unsnapped.x,
y: self.original.y + unsnapped.y,
};
if !finite_position(desired) {
self.retire();
return false;
}
// Returning to the original position must not align an off-grid anchor
// as an accidental edit. It is still a drag, so selection stays grouped.
let delta = match self.displacement {
// Use the pointer delta directly, avoiding precision loss from
// subtracting a large authored original from its desired position.
DisplacementPolicy::Unsnapped => unsnapped,
DisplacementPolicy::Grid10 if desired == self.original => Position { x: 0., y: 0. },
DisplacementPolicy::Grid10 => Position {
x: (desired.x / 10.).round() * 10. - self.original.x,
y: (desired.y / 10.).round() * 10. - self.original.y,
},
};
if !finite_position(delta) {
self.retire();
return false;
}
self.delta = delta;
true
}
/// Consume the final pointer too: a release can be the first displaced
/// event. The caller must validate snapshot and viewport before acting on
/// either outcome. Cancelling is dropping the gesture without calling this.
pub fn release(mut self, pointer: Point) -> Option<Outcome> {
if !self.move_to(pointer) {
return None;
}
Some(if self.crossed_threshold {
Outcome::Move(self.snapshot, self.delta)
} else {
Outcome::Click(self.anchor)
})
}
fn retire(&mut self) {
self.retired = true;
self.delta = Position { x: 0., y: 0. };
}
}
impl Viewport {
fn valid(self) -> bool {
self.zoom.is_finite()
&& self.zoom > 0.
&& self.pan.x.is_finite()
&& self.pan.y.is_finite()
&& self.bounds.x.is_finite()
&& self.bounds.y.is_finite()
&& self.bounds.width.is_finite()
&& self.bounds.height.is_finite()
&& self.bounds.width > 0.
&& self.bounds.height > 0.
&& (self.bounds.x + self.bounds.width).is_finite()
&& (self.bounds.y + self.bounds.height).is_finite()
}
}
fn finite_point(point: Point) -> bool {
point.x.is_finite() && point.y.is_finite()
}
fn finite_position(position: Position) -> bool {
position.x.is_finite() && position.y.is_finite()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::diagram_selection::{Context, Scope, Selection};
fn id(value: u128) -> Id {
Id::from_u128(value)
}
fn context() -> Context {
Context {
scope: Scope {
document: id(1),
surface: Surface::Saved(id(2)),
},
revision: 3,
epoch: 4,
}
}
fn snapshot(targets: impl IntoIterator<Item = Target>) -> Snapshot {
let mut selection = Selection::new(context().scope);
for target in targets {
selection.add(target, |_| true);
}
selection.snapshot(context()).unwrap()
}
fn viewport() -> Viewport {
Viewport {
zoom: 1.,
pan: Vector::new(17., -8.),
bounds: Rectangle {
x: 100.,
y: 50.,
width: 400.,
height: 300.,
},
}
}
fn press() -> Point {
Point::new(150., 120.)
}
fn gesture(original: Position) -> Gesture {
Gesture::new(
snapshot([Target::Placement(id(10)), Target::Placement(id(11))]),
id(10),
original,
press(),
viewport(),
)
.unwrap()
}
#[test]
fn stationary_or_exact_threshold_release_is_click_without_snapping() {
for pointer in [press(), Point::new(153., 120.), Point::new(152., 122.)] {
let mut gesture = gesture(Position { x: 13., y: -7. });
assert!(gesture.move_to(pointer));
assert!(!gesture.crossed_threshold());
assert_eq!(gesture.delta(), Position { x: 0., y: 0. });
assert_eq!(gesture.release(pointer), Some(Outcome::Click(id(10))));
}
}
#[test]
fn threshold_latches_and_return_to_offgrid_origin_is_zero_move() {
let mut gesture = gesture(Position { x: 13., y: -7. });
let captured = gesture.snapshot().clone();
assert!(gesture.move_to(Point::new(170., 140.)));
assert!(gesture.crossed_threshold());
assert!(gesture.move_to(press()));
assert!(gesture.crossed_threshold());
assert_eq!(gesture.delta(), Position { x: 0., y: 0. });
assert_eq!(
gesture.release(press()),
Some(Outcome::Move(captured, Position { x: 0., y: 0. }))
);
}
#[test]
fn release_only_displacement_that_snaps_to_zero_still_preserves_group() {
let gesture = gesture(Position { x: 20., y: 40. });
let captured = gesture.snapshot().clone();
assert_eq!(
gesture.release(Point::new(154., 120.)),
Some(Outcome::Move(captured, Position { x: 0., y: 0. }))
);
}
#[test]
fn offgrid_anchor_yields_one_shared_delta_and_release_matches_preview() {
let mut gesture = gesture(Position { x: 13., y: -7. });
let captured = gesture.snapshot().clone();
assert_eq!(gesture.anchor(), id(10));
let pointer = Point::new(167., 133.);
assert!(gesture.move_to(pointer));
// Desired anchor (30,6) snaps to (30,10), hence shared delta (17,17).
assert_eq!(gesture.delta(), Position { x: 17., y: 17. });
let sibling = Position { x: -6., y: 24. };
let translated = Position {
x: sibling.x + gesture.delta().x,
y: sibling.y + gesture.delta().y,
};
assert_eq!(translated, Position { x: 11., y: 41. });
assert_eq!(
gesture.release(pointer),
Some(Outcome::Move(captured, Position { x: 17., y: 17. }))
);
}
#[test]
fn zoom_scales_world_delta_but_threshold_remains_in_screen_pixels() {
let mut gesture = Gesture::new(
snapshot([Target::Placement(id(10))]),
id(10),
Position { x: 20., y: 40. },
press(),
Viewport {
zoom: 2.,
..viewport()
},
)
.unwrap();
assert!(gesture.move_to(Point::new(153., 120.)));
assert!(!gesture.crossed_threshold());
assert!(gesture.move_to(Point::new(168., 112.)));
// Screen (18,-8) means world (9,-4), snapped anchor (30,40).
assert_eq!(gesture.delta(), Position { x: 10., y: 0. });
assert!(gesture.crossed_threshold());
}
#[test]
fn unsnapped_fractional_pointer_delta_preserves_authored_offset() {
let authored = Position {
x: 1024.125,
y: -321.5,
};
let mut gesture = Gesture::new_with_policy(
snapshot([Target::Placement(id(10)), Target::Placement(id(11))]),
id(10),
authored,
press(),
Viewport {
zoom: 2.,
..viewport()
},
DisplacementPolicy::Unsnapped,
)
.unwrap();
let captured = gesture.snapshot().clone();
let pointer = Point::new(164.5, 114.5);
assert!(gesture.move_to(pointer));
assert_eq!(gesture.delta(), Position { x: 7.25, y: -2.75 });
let translated_authored = Position {
x: authored.x + gesture.delta().x,
y: authored.y + gesture.delta().y,
};
assert_eq!(
translated_authored,
Position {
x: 1031.375,
y: -324.25
}
);
assert_eq!(
gesture.release(pointer),
Some(Outcome::Move(captured, Position { x: 7.25, y: -2.75 })),
);
}
#[test]
fn unsnapped_threshold_and_return_to_origin_preserve_group_lifecycle() {
let make = || {
Gesture::new_with_policy(
snapshot([Target::Placement(id(10)), Target::Placement(id(11))]),
id(10),
Position { x: 13.25, y: -7.5 },
press(),
viewport(),
DisplacementPolicy::Unsnapped,
)
.unwrap()
};
let mut click = make();
assert!(click.move_to(Point::new(153., 120.)));
assert!(!click.crossed_threshold());
assert_eq!(click.delta(), Position { x: 0., y: 0. });
assert_eq!(
click.release(Point::new(153., 120.)),
Some(Outcome::Click(id(10)))
);
let mut drag = make();
let captured = drag.snapshot().clone();
assert!(drag.move_to(Point::new(157.25, 117.25)));
assert_eq!(drag.delta(), Position { x: 7.25, y: -2.75 });
assert!(drag.move_to(press()));
assert!(drag.crossed_threshold());
assert_eq!(drag.delta(), Position { x: 0., y: 0. });
assert_eq!(
drag.release(press()),
Some(Outcome::Move(captured, Position { x: 0., y: 0. }))
);
}
#[test]
fn explicit_grid_and_default_constructor_keep_existing_quantization() {
let original = Position { x: 13., y: -7. };
let mut default = gesture(original);
let mut explicit = Gesture::new_with_policy(
default.snapshot().clone(),
id(10),
original,
press(),
viewport(),
DisplacementPolicy::Grid10,
)
.unwrap();
let pointer = Point::new(167., 133.);
assert!(default.move_to(pointer));
assert!(explicit.move_to(pointer));
// Existing ordinary behavior: desired (30,6), grid (30,10).
assert_eq!(default.delta(), Position { x: 17., y: 17. });
assert_eq!(explicit.delta(), Position { x: 17., y: 17. });
assert_eq!(default.release(pointer), explicit.release(pointer));
}
#[test]
fn unsnapped_release_only_movement_and_invalid_pointer_retirement() {
let make = || {
Gesture::new_with_policy(
snapshot([Target::Placement(id(10))]),
id(10),
Position {
x: 20.125,
y: 40.25,
},
press(),
viewport(),
DisplacementPolicy::Unsnapped,
)
.unwrap()
};
let release_only = make();
let captured = release_only.snapshot().clone();
assert_eq!(
release_only.release(Point::new(154.25, 120.)),
Some(Outcome::Move(captured, Position { x: 4.25, y: 0. })),
);
for invalid in [Point::new(f32::NAN, 120.), Point::new(501., 120.)] {
let mut gesture = make();
assert!(gesture.move_to(Point::new(154.25, 120.)));
assert!(!gesture.move_to(invalid));
assert!(!gesture.viewport_matches(viewport()));
assert_eq!(gesture.delta(), Position { x: 0., y: 0. });
assert_eq!(gesture.release(press()), None);
}
}
#[test]
fn full_typed_selection_and_saved_surface_are_required() {
for targets in [
vec![],
vec![Target::Placement(id(11))],
vec![Target::Placement(id(10)), Target::Edge(id(20))],
vec![
Target::Placement(id(10)),
Target::Port {
port: id(20),
placement: id(10),
},
],
] {
assert!(
Gesture::new(
snapshot(targets),
id(10),
Position { x: 0., y: 0. },
press(),
viewport()
)
.is_none()
);
}
let other = Context {
scope: Scope {
surface: Surface::Process(id(2)),
..context().scope
},
..context()
};
let mut selection = Selection::new(other.scope);
selection.add(Target::Placement(id(10)), |_| true);
assert!(
Gesture::new(
selection.snapshot(other).unwrap(),
id(10),
Position { x: 0., y: 0. },
press(),
viewport()
)
.is_none()
);
}
#[test]
fn any_frozen_viewport_change_is_detected() {
let gesture = gesture(Position { x: 0., y: 0. });
assert!(gesture.viewport_matches(viewport()));
for changed in [
Viewport {
zoom: 2.,
..viewport()
},
Viewport {
pan: Vector::new(18., -8.),
..viewport()
},
Viewport {
pan: Vector::new(17., -7.),
..viewport()
},
Viewport {
bounds: Rectangle {
x: 101.,
..viewport().bounds
},
..viewport()
},
Viewport {
bounds: Rectangle {
y: 51.,
..viewport().bounds
},
..viewport()
},
Viewport {
bounds: Rectangle {
width: 401.,
..viewport().bounds
},
..viewport()
},
Viewport {
bounds: Rectangle {
height: 301.,
..viewport().bounds
},
..viewport()
},
] {
assert!(!gesture.viewport_matches(changed));
}
}
#[test]
fn invalid_initial_geometry_cannot_start() {
let saved = snapshot([Target::Placement(id(10))]);
for invalid in [
Viewport {
zoom: 0.,
..viewport()
},
Viewport {
zoom: -1.,
..viewport()
},
Viewport {
zoom: f32::NAN,
..viewport()
},
Viewport {
zoom: f32::INFINITY,
..viewport()
},
Viewport {
pan: Vector::new(f32::NAN, 0.),
..viewport()
},
Viewport {
bounds: Rectangle {
width: 0.,
..viewport().bounds
},
..viewport()
},
Viewport {
bounds: Rectangle {
height: -1.,
..viewport().bounds
},
..viewport()
},
Viewport {
bounds: Rectangle {
x: f32::INFINITY,
..viewport().bounds
},
..viewport()
},
] {
assert!(
Gesture::new(
saved.clone(),
id(10),
Position { x: 0., y: 0. },
press(),
invalid
)
.is_none()
);
}
assert!(
Gesture::new(
saved.clone(),
id(10),
Position { x: f32::NAN, y: 0. },
press(),
viewport()
)
.is_none()
);
for invalid in [Point::new(f32::NAN, 120.), Point::new(99., 120.)] {
assert!(
Gesture::new(
saved.clone(),
id(10),
Position { x: 0., y: 0. },
invalid,
viewport()
)
.is_none()
);
}
}
#[test]
fn invalid_pointer_permanently_retires_even_if_pointer_returns() {
for invalid in [Point::new(f32::NAN, 120.), Point::new(501., 120.)] {
let mut gesture = gesture(Position { x: 20., y: 40. });
assert!(gesture.move_to(Point::new(170., 140.)));
assert!(!gesture.move_to(invalid));
assert_eq!(gesture.delta(), Position { x: 0., y: 0. });
assert!(!gesture.move_to(press()));
assert!(!gesture.viewport_matches(viewport()));
assert_eq!(gesture.release(press()), None);
}
assert_eq!(
gesture(Position { x: 20., y: 40. }).release(Point::new(501., 120.)),
None
);
}
#[test]
fn finite_inputs_that_overflow_world_geometry_retire_without_an_operation() {
let mut gesture = Gesture::new(
snapshot([Target::Placement(id(10))]),
id(10),
Position { x: 0., y: 0. },
press(),
Viewport {
zoom: f32::MIN_POSITIVE,
..viewport()
},
)
.unwrap();
assert!(!gesture.move_to(Point::new(170., 140.)));
assert_eq!(gesture.release(press()), None);
}
}
@@ -53,6 +53,7 @@ fn bounds() -> Rectangle {
}
fn canvas(app: &App) -> DiagramCanvas<'_> {
DiagramCanvas {
selection: None,
project: app.project(),
view_index: None,
diagram: app.active_diagram.unwrap(),
@@ -62,6 +63,7 @@ fn canvas(app: &App) -> DiagramCanvas<'_> {
pan: app.pan,
context: app.route_context().unwrap(),
selected_bend: None,
selected_attachment: None,
editable: true,
placing: false,
connection: app.connection.as_ref(),
@@ -0,0 +1,297 @@
use super::group_gesture::{Gesture as GroupGesture, Outcome, Viewport};
use super::*;
use crate::app::multiselect::{Choice, Event, context};
use crate::diagram_selection::Snapshot;
use crate::diagram_usage::Target;
/// Even an unsupported mixed group must retain an ordinary click lifecycle.
pub(super) struct Press {
snapshot: Snapshot,
target: Target,
start: Point,
viewport: Viewport,
movement: Option<GroupGesture>,
reason: Option<String>,
crossed: bool,
retired: bool,
}
impl Press {
fn snapshot(&self) -> &Snapshot {
self.movement
.as_ref()
.map_or(&self.snapshot, GroupGesture::snapshot)
}
fn viewport_matches(&self, viewport: Viewport) -> bool {
!self.retired
&& self.viewport == viewport
&& self
.movement
.as_ref()
.is_none_or(|movement| movement.viewport_matches(viewport))
}
fn crossed_threshold(&self) -> bool {
self.movement
.as_ref()
.map_or(self.crossed, GroupGesture::crossed_threshold)
}
fn delta(&self) -> Position {
self.movement
.as_ref()
.map_or(Position { x: 0., y: 0. }, GroupGesture::delta)
}
fn move_to(&mut self, pointer: Point) {
if self.retired {
return;
}
if !pointer.x.is_finite()
|| !pointer.y.is_finite()
|| !self.viewport.bounds.contains(pointer)
{
self.retired = true;
self.movement = None;
return;
}
self.crossed |= pointer.distance(self.start) > 3.;
if let Some(movement) = &mut self.movement
&& !movement.move_to(pointer)
{
self.retired = true;
}
}
fn release(mut self, pointer: Point) -> Option<Event> {
self.move_to(pointer);
if self.retired {
return None;
}
if !self.crossed {
return Some(Event::Collapse(self.snapshot, self.target));
}
if let Some(reason) = self.reason {
return Some(Event::Refuse(self.snapshot, reason));
}
match self.movement?.release(pointer)? {
Outcome::Click(id) => Some(Event::Collapse(self.snapshot, Target::Placement(id))),
Outcome::Move(snapshot, delta) => Some(Event::Move(snapshot, delta)),
}
}
}
impl DiagramCanvas<'_> {
pub(super) fn selection_contains(&self, target: Target) -> bool {
self.selection
.is_some_and(|s| s.scope() == context(self.context).scope && s.contains(&target))
}
pub(super) fn group_input(
&self,
state: &mut State,
event: &canvas::Event,
bounds: Rectangle,
cursor: mouse::Cursor,
) -> Option<Action<Message>> {
let viewport = Viewport {
zoom: self.zoom,
pan: self.pan,
bounds,
};
if let Some(gesture) = &state.group {
let valid = gesture.viewport_matches(viewport)
&& self.editable
&& self.connection.is_none()
&& !self.placing
&& self
.selection
.is_some_and(|s| s.matches(gesture.snapshot(), context(self.context)));
let cancelled = matches!(
event,
canvas::Event::Window(
iced::window::Event::Unfocused | iced::window::Event::Resized(_)
) | canvas::Event::Mouse(
mouse::Event::CursorLeft
| mouse::Event::WheelScrolled { .. }
| mouse::Event::ButtonPressed(mouse::Button::Right | mouse::Button::Middle)
) | canvas::Event::Keyboard(iced::keyboard::Event::KeyPressed {
key: iced::keyboard::Key::Named(iced::keyboard::key::Named::Escape),
..
})
);
if !valid || cancelled {
state.group = None;
*state.cache.borrow_mut() = None;
return Some(Action::request_redraw().and_capture());
}
}
if let Some(gesture) = &mut state.group {
match event {
canvas::Event::Mouse(mouse::Event::CursorMoved { position }) => {
gesture.move_to(*position);
*state.cache.borrow_mut() = None;
return Some(Action::request_redraw().and_capture());
}
canvas::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => {
let gesture = state.group.take().unwrap();
*state.cache.borrow_mut() = None;
let outcome = cursor
.position()
.and_then(|pointer| gesture.release(pointer));
return Some(match outcome {
Some(event) => {
Action::publish(Message::DiagramSelection(event)).and_capture()
}
None => Action::request_redraw().and_capture(),
});
}
canvas::Event::Keyboard(_) => return Some(Action::capture()),
_ => {}
}
}
let selection = self
.selection
.filter(|s| s.scope() == context(self.context).scope)?;
if state.pan_start.is_some() {
return None;
}
if self.connection.is_some() || self.placing {
return None;
}
if let canvas::Event::Keyboard(iced::keyboard::Event::KeyPressed {
key, modifiers, ..
}) = event
{
use iced::keyboard::{Key, key::Named};
if *key == Key::Named(Named::Escape)
&& state.drag.is_none()
&& state.route_drag.is_none()
&& state.port_drag.is_none()
&& state.work.is_none()
{
return Some(
Action::publish(Message::DiagramSelection(Event::Clear(context(
self.context,
))))
.and_capture(),
);
}
if selection.targets().len() > 1 && *key == Key::Named(Named::Delete) {
let snapshot = selection.snapshot(context(self.context))?;
let action = if modifiers.is_empty() {
Event::Hide(snapshot)
} else {
Event::Refuse(snapshot, "Use Hide selected to remove placements. Group model-deletion review is not available yet.".into())
};
return Some(Action::publish(Message::DiagramSelection(action)).and_capture());
}
}
if !matches!(
event,
canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left))
) || !cursor.is_over(bounds)
{
return None;
}
let pointer = cursor.position()?;
let local = Point::new(pointer.x - bounds.x, pointer.y - bounds.y);
let scene = self.scene(state);
let target = if let Some(port) = self.hit_port(&scene.view, local) {
Some(Target::Port {
port: port.element,
placement: port.owner,
})
} else if let Some(id) = self.hit(&scene.view, local) {
Some(Target::Placement(id))
} else {
self.hit_label(&scene.routed, local)
.or_else(|| self.hit_association_label(&scene.routed, local))
.or_else(|| self.hit_edge(&scene.routed, local).map(|hit| hit.0))
.map(Target::Edge)
}?;
if state.modifiers.shift() {
state.drag = None;
state.preview = None;
state.port_drag = None;
state.route_drag = None;
return Some(
Action::publish(Message::DiagramSelection(Event::Choose(
context(self.context),
target,
Choice::Toggle,
)))
.and_capture(),
);
}
if selection.targets().len() <= 1 || !selection.contains(&target) {
return None;
}
let snapshot = selection.snapshot(context(self.context))?;
if !self.editable {
return Some(Action::capture());
}
let reason = if crate::app::multiselect::placements(&snapshot).is_none() {
Some(
"Move requires only placed elements. Remove ports and connections from the selection.",
)
} else if snapshot
.targets()
.iter()
.any(|t| matches!(t, Target::Placement(id) if self.work_eligible(*id)))
{
Some("Group allocation drag is not available yet. The selection was retained.")
} else {
None
};
let movement = if reason.is_none()
&& let Target::Placement(anchor) = target
{
let original = self.project.diagrams[&self.diagram].nodes[&anchor].position;
GroupGesture::new(snapshot.clone(), anchor, original, pointer, viewport)
} else {
None
};
if movement.is_none() && reason.is_none() {
return Some(Action::capture());
}
state.group = Some(Press {
snapshot,
target,
start: pointer,
viewport,
movement,
reason: reason.map(str::to_owned),
crossed: false,
retired: false,
});
*state.cache.borrow_mut() = None;
Some(Action::request_redraw().and_capture())
}
pub(super) fn group_preview(&self, state: &State, diagram: &mut Diagram) {
let Some(gesture) = &state.group else {
return;
};
if !gesture.crossed_threshold() {
return;
}
let Some(ids) = crate::app::multiselect::placements(gesture.snapshot()) else {
return;
};
let Ok(Command::Batch(commands)) = oa_core::diagram_edit::translate_placements(
self.project,
self.diagram,
&ids,
gesture.delta(),
) else {
return;
};
// Apply only the planner's presentation records. Full model validation
// and source/history updates occur once, when App accepts the release.
for command in commands {
match command {
Command::EditNode { node, .. } => {
diagram.nodes.insert(node.id, node);
}
Command::SetEdgeRoute { route, .. } => route_edit::with_route(diagram, route),
_ => unreachable!("placement planner returned a non-geometry command"),
}
}
}
}
@@ -0,0 +1,758 @@
//! Production canvas event regressions for exact multi-selection and group gestures.
use super::{DiagramCanvas, State};
use crate::app::{
Message,
multiselect::{Choice, Event as SelectionEvent, context},
};
use crate::diagram_selection::Selection;
use crate::diagram_usage::Target;
use iced::widget::canvas::{self, Program};
use iced::{Point, Rectangle, Size, Vector, event::Status, keyboard, mouse};
use oa_core::*;
fn position(x: f32, y: f32) -> Position {
Position { x, y }
}
fn bounds() -> Rectangle {
Rectangle::new(Point::new(260., 100.), Size::new(2400., 1800.))
}
struct Fixture {
project: Project,
diagram: Id,
a: Id,
b: Id,
child: Id,
internal: Id,
context: crate::route_edit::Context,
}
impl Fixture {
fn new() -> Self {
let mut project = Project::empty();
let a = project
.add_element(Kind::Activity, Layer::Oa, "A", None)
.unwrap();
let b = project
.add_element(Kind::Activity, Layer::Oa, "B", None)
.unwrap();
let c = project
.add_element(Kind::Activity, Layer::Oa, "C", None)
.unwrap();
let child = project
.add_element(Kind::Activity, Layer::Oa, "Child", Some(a))
.unwrap();
let internal = project.relate(RelationKind::Interaction, "Internal", a, b);
let external = project.relate(RelationKind::Interaction, "External", b, c);
let diagram = project.ensure_diagram(Layer::Oa);
project.diagrams.get_mut(&diagram).unwrap().projection = ProjectionKind::Overview;
let a = project.place(diagram, a, position(100., 200.));
let b = project.place(diagram, b, position(500., 200.));
let child = project.place(diagram, child, position(120., 280.));
project.place(diagram, c, position(900., 200.));
let view = project.diagrams.get_mut(&diagram).unwrap();
view.nodes.get_mut(&a).unwrap().collapsed = true;
view.routes = [
(internal, position(400., 320.)),
(external, position(800., 340.)),
]
.into_iter()
.map(|(edge, point)| EdgeRoute {
edge,
source_attachment: None,
target_attachment: None,
waypoints: vec![point],
label: Some(EdgeLabel {
fraction: 0.4,
offset: position(3., -9.),
}),
})
.collect();
validate_candidate(&project).unwrap();
Self {
project,
diagram,
a,
b,
child,
internal,
context: crate::route_edit::Context {
document: Id::new_v4(),
diagram,
revision: 7,
epoch: 11,
},
}
}
fn selection(&self, targets: impl IntoIterator<Item = Target>) -> Selection {
let mut selection = Selection::new(context(self.context).scope);
for target in targets {
assert!(selection.add(target, |_| true));
}
selection
}
fn pair(&self) -> Selection {
self.selection([Target::Placement(self.a), Target::Placement(self.b)])
}
fn canvas<'a>(&'a self, selection: &'a Selection, zoom: f32) -> DiagramCanvas<'a> {
let selected = match selection.primary() {
Some(Target::Placement(id)) => self.project.diagrams[&self.diagram].nodes[id].element,
Some(Target::Edge(id)) => *id,
Some(Target::Port { port, .. }) => *port,
_ => self.diagram,
};
DiagramCanvas {
selection: Some(selection),
project: &self.project,
view_index: None,
diagram: self.diagram,
selected,
navigation_focus: None,
zoom,
pan: Vector::new(20., 30.),
context: self.context,
selected_bend: None,
selected_attachment: None,
editable: true,
placing: false,
connection: None,
allocation: None,
}
}
fn screen(&self, placement: Id, zoom: f32) -> Point {
let p = self.project.diagrams[&self.diagram].nodes[&placement].position;
Point::new(
bounds().x + (p.x + 30.) * zoom + 20.,
bounds().y + (p.y + 25.) * zoom + 30.,
)
}
fn expected_move(&self) -> Diagram {
// Independent literal expectations: the folded child moves with A,
// only the internal edge's authored world pin translates.
let mut expected = self.project.diagrams[&self.diagram].clone();
expected.nodes.get_mut(&self.a).unwrap().position = position(140., 260.);
expected.nodes.get_mut(&self.b).unwrap().position = position(540., 260.);
expected.nodes.get_mut(&self.child).unwrap().position = position(160., 340.);
expected.routes[0].waypoints = vec![position(440., 380.)];
expected
}
}
fn input(
c: &DiagramCanvas<'_>,
state: &mut State,
event: canvas::Event,
viewport: Rectangle,
final_cursor: Point,
) -> (Option<Message>, Status) {
c.update(
state,
&event,
viewport,
mouse::Cursor::Available(final_cursor),
)
.map_or((None, Status::Ignored), |action| {
let (message, _, status) = action.into_inner();
(message, status)
})
}
fn pointer(
c: &DiagramCanvas<'_>,
state: &mut State,
event: mouse::Event,
final_cursor: Point,
) -> (Option<Message>, Status) {
input(
c,
state,
canvas::Event::Mouse(event),
bounds(),
final_cursor,
)
}
fn start(c: &DiagramCanvas<'_>, state: &mut State, press: Point, final_cursor: Point) {
assert!(
pointer(
c,
state,
mouse::Event::CursorMoved { position: press },
final_cursor
)
.0
.is_none()
);
let (message, status) = pointer(
c,
state,
mouse::Event::ButtonPressed(mouse::Button::Left),
final_cursor,
);
assert!(message.is_none());
assert_eq!(status, Status::Captured);
assert!(state.group.is_some());
assert!(state.drag.is_none());
}
fn key(named: keyboard::key::Named) -> canvas::Event {
canvas::Event::Keyboard(keyboard::Event::KeyPressed {
key: keyboard::Key::Named(named),
modified_key: keyboard::Key::Named(named),
physical_key: keyboard::key::Physical::Code(if named == keyboard::key::Named::Escape {
keyboard::key::Code::Escape
} else {
keyboard::key::Code::Delete
}),
location: keyboard::Location::Standard,
modifiers: keyboard::Modifiers::empty(),
text: None,
repeat: false,
})
}
fn expect_move(
message: Option<Message>,
selection: &Selection,
current: crate::route_edit::Context,
expected: Position,
) -> crate::diagram_selection::Snapshot {
let Some(Message::DiagramSelection(SelectionEvent::Move(snapshot, delta))) = message else {
panic!("expected one captured group Move");
};
assert_eq!(delta, expected);
assert!(selection.matches(&snapshot, context(current)));
snapshot
}
#[test]
fn shift_press_uses_ordered_hit_not_native_batch_final_cursor() {
let f = Fixture::new();
let selection = f.selection([Target::Placement(f.b)]);
let before = selection.snapshot(context(f.context)).unwrap();
let c = f.canvas(&selection, 1.);
let mut state = State::default();
let at_a = f.screen(f.a, 1.);
let final_cursor = f.screen(f.b, 1.);
assert!(
input(
&c,
&mut state,
canvas::Event::Keyboard(keyboard::Event::ModifiersChanged(
keyboard::Modifiers::SHIFT
)),
bounds(),
final_cursor
)
.0
.is_none()
);
assert!(
pointer(
&c,
&mut state,
mouse::Event::CursorMoved { position: at_a },
final_cursor
)
.0
.is_none()
);
let (message, status) = pointer(
&c,
&mut state,
mouse::Event::ButtonPressed(mouse::Button::Left),
final_cursor,
);
assert_eq!(status, Status::Captured);
assert!(
matches!(message, Some(Message::DiagramSelection(SelectionEvent::Choose(captured, Target::Placement(id), Choice::Toggle))) if captured == context(f.context) && id == f.a)
);
assert!(
state.group.is_none()
&& state.drag.is_none()
&& state.route_drag.is_none()
&& state.port_drag.is_none()
);
assert!(
pointer(
&c,
&mut state,
mouse::Event::ButtonReleased(mouse::Button::Left),
final_cursor
)
.0
.is_none()
);
assert!(
selection.matches(&before, context(f.context)),
"canvas emits intent without mutating selection"
);
}
#[test]
fn group_canvas_preview_and_release_match_independent_expected_geometry() {
for zoom in [0.5, 1., 2.] {
let f = Fixture::new();
let selection = f.pair();
let captured = selection.snapshot(context(f.context)).unwrap();
let c = f.canvas(&selection, zoom);
let mut state = State::default();
let press = f.screen(f.a, zoom);
let end = press + Vector::new(40. * zoom, 60. * zoom);
start(&c, &mut state, press, end);
assert_eq!(c.preview_diagram(&state), f.project.diagrams[&f.diagram]);
let (message, status) = pointer(
&c,
&mut state,
mouse::Event::CursorMoved { position: end },
end,
);
assert!(message.is_none());
assert_eq!(status, Status::Captured);
assert_eq!(c.preview_diagram(&state), f.expected_move());
let (message, status) = pointer(
&c,
&mut state,
mouse::Event::ButtonReleased(mouse::Button::Left),
end,
);
assert_eq!(status, Status::Captured);
let snapshot = expect_move(message, &selection, f.context, position(40., 60.));
assert_eq!(snapshot, captured);
assert!(state.group.is_none());
let ids = crate::app::multiselect::placements(&snapshot).unwrap();
let command =
diagram_edit::translate_placements(&f.project, f.diagram, &ids, position(40., 60.))
.unwrap();
let actual = prepare_command(&f.project, &command).unwrap().project;
let mut expected = f.project.clone();
expected.diagrams.insert(f.diagram, f.expected_move());
assert_eq!(actual, expected);
assert!(selection.matches(&captured, context(f.context)));
}
}
#[test]
fn stationary_group_click_does_not_use_motion_after_release_in_same_batch() {
let f = Fixture::new();
let selection = f.pair();
let c = f.canvas(&selection, 1.);
let mut state = State::default();
let press = f.screen(f.a, 1.);
let end = press + Vector::new(80., 70.);
start(&c, &mut state, press, end);
let (message, _) = pointer(
&c,
&mut state,
mouse::Event::ButtonReleased(mouse::Button::Left),
end,
);
assert!(
matches!(message, Some(Message::DiagramSelection(SelectionEvent::Collapse(captured, Target::Placement(id)))) if selection.matches(&captured, context(f.context)) && id == f.a)
);
assert!(
pointer(
&c,
&mut state,
mouse::Event::CursorMoved { position: end },
end
)
.0
.is_none()
);
assert_eq!(c.preview_diagram(&state), f.project.diagrams[&f.diagram]);
}
#[test]
fn mixed_set_keeps_click_lifecycle_and_refuses_only_actual_drag() {
for moved in [false, true] {
let f = Fixture::new();
let selection = f.selection([Target::Placement(f.a), Target::Edge(f.internal)]);
let captured = selection.snapshot(context(f.context)).unwrap();
let c = f.canvas(&selection, 1.);
let mut state = State::default();
let press = f.screen(f.a, 1.);
let end = if moved {
press + Vector::new(40., 60.)
} else {
press + Vector::new(2., 1.)
};
start(&c, &mut state, press, end);
assert!(
pointer(
&c,
&mut state,
mouse::Event::CursorMoved { position: end },
end
)
.0
.is_none()
);
assert_eq!(c.preview_diagram(&state), f.project.diagrams[&f.diagram]);
let (message, status) = pointer(
&c,
&mut state,
mouse::Event::ButtonReleased(mouse::Button::Left),
end,
);
assert_eq!(status, Status::Captured);
if moved {
assert!(
matches!(message, Some(Message::DiagramSelection(SelectionEvent::Refuse(snapshot, reason))) if snapshot == captured && reason.contains("only placed elements"))
);
} else {
assert!(
matches!(message, Some(Message::DiagramSelection(SelectionEvent::Collapse(current, Target::Placement(id)))) if current == captured && id == f.a)
);
}
assert!(state.group.is_none());
assert!(selection.matches(&captured, context(f.context)));
}
}
#[test]
fn escape_cancels_live_group_without_clearing_or_publishing_a_model_intent() {
let f = Fixture::new();
let selection = f.pair();
let captured = selection.snapshot(context(f.context)).unwrap();
let c = f.canvas(&selection, 1.);
let mut state = State::default();
let press = f.screen(f.a, 1.);
let end = press + Vector::new(40., 60.);
start(&c, &mut state, press, end);
assert!(
pointer(
&c,
&mut state,
mouse::Event::CursorMoved { position: end },
end
)
.0
.is_none()
);
assert_eq!(c.preview_diagram(&state), f.expected_move());
let (message, status) = input(
&c,
&mut state,
key(keyboard::key::Named::Escape),
bounds(),
end,
);
assert!(message.is_none());
assert_eq!(status, Status::Captured);
assert!(state.group.is_none());
assert_eq!(c.preview_diagram(&state), f.project.diagrams[&f.diagram]);
assert!(
pointer(
&c,
&mut state,
mouse::Event::ButtonReleased(mouse::Button::Left),
end
)
.0
.is_none()
);
assert!(selection.matches(&captured, context(f.context)));
// A subsequent Escape, after the gesture is idle, may clear selection.
assert!(matches!(
input(
&c,
&mut state,
key(keyboard::key::Named::Escape),
bounds(),
end
)
.0,
Some(Message::DiagramSelection(SelectionEvent::Clear(_)))
));
}
#[test]
fn delete_is_captured_without_hiding_during_live_group_then_release_moves_once() {
let f = Fixture::new();
let selection = f.pair();
let c = f.canvas(&selection, 1.);
let mut state = State::default();
let press = f.screen(f.a, 1.);
let end = press + Vector::new(40., 60.);
start(&c, &mut state, press, end);
assert!(
pointer(
&c,
&mut state,
mouse::Event::CursorMoved { position: end },
end
)
.0
.is_none()
);
let (message, status) = input(
&c,
&mut state,
key(keyboard::key::Named::Delete),
bounds(),
end,
);
assert!(message.is_none());
assert_eq!(status, Status::Captured);
assert!(state.group.is_some());
assert_eq!(c.preview_diagram(&state), f.expected_move());
expect_move(
pointer(
&c,
&mut state,
mouse::Event::ButtonReleased(mouse::Button::Left),
end,
)
.0,
&selection,
f.context,
position(40., 60.),
);
}
#[test]
fn middle_pan_excludes_group_start_and_delete_even_when_left_is_pressed() {
let f = Fixture::new();
let selection = f.pair();
let captured = selection.snapshot(context(f.context)).unwrap();
let c = f.canvas(&selection, 1.);
let mut state = State::default();
let press = f.screen(f.a, 1.);
let end = press + Vector::new(40., 60.);
assert!(
pointer(
&c,
&mut state,
mouse::Event::CursorMoved { position: press },
end
)
.0
.is_none()
);
assert!(
pointer(
&c,
&mut state,
mouse::Event::ButtonPressed(mouse::Button::Middle),
end
)
.0
.is_none()
);
let (message, status) = pointer(
&c,
&mut state,
mouse::Event::ButtonPressed(mouse::Button::Left),
end,
);
assert!(message.is_none());
assert_eq!(status, Status::Captured);
assert!(state.group.is_none() && state.drag.is_none());
assert!(
input(
&c,
&mut state,
key(keyboard::key::Named::Delete),
bounds(),
end
)
.0
.is_none()
);
assert!(
matches!(pointer(&c, &mut state, mouse::Event::CursorMoved { position: end }, end).0, Some(Message::Pan(delta)) if delta == Vector::new(40., 60.))
);
assert!(
pointer(
&c,
&mut state,
mouse::Event::ButtonReleased(mouse::Button::Left),
end
)
.0
.is_none()
);
assert!(
pointer(
&c,
&mut state,
mouse::Event::ButtonReleased(mouse::Button::Middle),
end
)
.0
.is_none()
);
assert_eq!(c.preview_diagram(&state), f.project.diagrams[&f.diagram]);
assert!(selection.matches(&captured, context(f.context)));
}
#[test]
fn changed_viewport_or_context_retires_group_without_a_release_intent() {
for change in 0..7 {
let f = Fixture::new();
let selection = f.pair();
let c = f.canvas(&selection, 1.);
let mut state = State::default();
let press = f.screen(f.a, 1.);
let end = press + Vector::new(40., 60.);
start(&c, &mut state, press, end);
assert!(
pointer(
&c,
&mut state,
mouse::Event::CursorMoved { position: end },
end
)
.0
.is_none()
);
let mut changed = c;
let mut viewport = bounds();
match change {
0 => viewport.x += 1.,
1 => viewport.width -= 1.,
2 => changed.pan.x += 1.,
3 => changed.zoom = 2.,
4 => changed.context.epoch += 1,
5 => changed.context.revision += 1,
_ => changed.context.document = Id::new_v4(),
}
let (message, _) = input(
&changed,
&mut state,
canvas::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)),
viewport,
end,
);
assert!(
message.is_none(),
"changed viewport/context {change} must not publish a commit or click"
);
assert!(state.group.is_none());
assert_eq!(
changed.preview_diagram(&state),
f.project.diagrams[&f.diagram]
);
}
}
#[test]
fn changed_selection_generation_rejects_press_even_after_membership_is_restored() {
let f = Fixture::new();
let mut selection = f.pair();
let press = f.screen(f.a, 1.);
let end = press + Vector::new(40., 60.);
let mut state = State::default();
{
let c = f.canvas(&selection, 1.);
start(&c, &mut state, press, end);
assert!(
pointer(
&c,
&mut state,
mouse::Event::CursorMoved { position: end },
end
)
.0
.is_none()
);
}
assert!(selection.remove(&Target::Placement(f.b)));
assert!(selection.add(Target::Placement(f.b), |_| true));
let c = f.canvas(&selection, 1.);
let (message, status) = pointer(
&c,
&mut state,
mouse::Event::ButtonReleased(mouse::Button::Left),
end,
);
assert!(message.is_none());
assert_eq!(status, Status::Captured);
assert!(state.group.is_none());
assert_eq!(c.preview_diagram(&state), f.project.diagrams[&f.diagram]);
}
#[test]
fn initial_press_without_pointer_history_consumes_release_only_displacement() {
let f = Fixture::new();
let selection = f.pair();
let c = f.canvas(&selection, 1.);
let mut state = State::default();
let press = f.screen(f.a, 1.);
let end = press + Vector::new(40., 60.);
// With no earlier ordered move, the supplied release cursor is authoritative.
assert!(
pointer(
&c,
&mut state,
mouse::Event::ButtonPressed(mouse::Button::Left),
press
)
.0
.is_none()
);
assert!(state.group.is_some());
expect_move(
pointer(
&c,
&mut state,
mouse::Event::ButtonReleased(mouse::Button::Left),
end,
)
.0,
&selection,
f.context,
position(40., 60.),
);
}
#[test]
fn threshold_crossing_then_return_to_origin_emits_zero_move_not_singleton_click() {
let f = Fixture::new();
let selection = f.pair();
let c = f.canvas(&selection, 1.);
let mut state = State::default();
let press = f.screen(f.a, 1.);
let end = press + Vector::new(40., 60.);
start(&c, &mut state, press, end);
assert!(
pointer(
&c,
&mut state,
mouse::Event::CursorMoved { position: end },
end
)
.0
.is_none()
);
assert!(
pointer(
&c,
&mut state,
mouse::Event::CursorMoved { position: press },
end
)
.0
.is_none()
);
assert_eq!(c.preview_diagram(&state), f.project.diagrams[&f.diagram]);
expect_move(
pointer(
&c,
&mut state,
mouse::Event::ButtonReleased(mouse::Button::Left),
end,
)
.0,
&selection,
f.context,
position(0., 0.),
);
assert_eq!(selection.targets().len(), 2);
}
@@ -61,6 +61,7 @@ fn screen(p: Position, zoom: f32) -> Point {
}
fn canvas(app: &App, zoom: f32) -> DiagramCanvas<'_> {
DiagramCanvas {
selection: None,
project: app.project(),
view_index: None,
diagram: app.active_diagram.unwrap(),
@@ -70,6 +71,7 @@ fn canvas(app: &App, zoom: f32) -> DiagramCanvas<'_> {
pan: app.pan,
context: app.route_context().unwrap(),
selected_bend: None,
selected_attachment: None,
editable: true,
placing: false,
connection: app.connection.as_ref(),
@@ -2,6 +2,7 @@ use super::*;
fn canvas(project: &Project, diagram: Id, zoom: f32) -> DiagramCanvas<'_> {
DiagramCanvas {
selection: None,
project,
view_index: None,
diagram,
@@ -16,6 +17,7 @@ fn canvas(project: &Project, diagram: Id, zoom: f32) -> DiagramCanvas<'_> {
epoch: 0,
},
selected_bend: None,
selected_attachment: None,
editable: true,
placing: false,
connection: None,
@@ -177,6 +177,7 @@ mod tests {
fn program(app: &App) -> PortLabels<'_> {
PortLabels(DiagramCanvas {
selection: None,
project: app.project(),
view_index: None,
diagram: app.active_diagram.unwrap(),
@@ -186,6 +187,7 @@ mod tests {
pan: Vector::ZERO,
context: app.route_context().unwrap(),
selected_bend: None,
selected_attachment: None,
editable: true,
placing: false,
connection: None,
+416
View File
@@ -0,0 +1,416 @@
//! Transient exact-target selection; admission and model operations belong to adapters.
use crate::diagram_usage::{Surface, Target};
use oa_core::Id;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct Scope {
/// Document-instance identity, not the persisted project UUID.
pub document: Id,
pub surface: Surface,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct Context {
pub scope: Scope,
pub revision: u64,
pub epoch: u64,
}
/// Insertion-ordered unique targets. Membership does not grant edit capability.
#[derive(Debug)]
pub(crate) struct Selection {
scope: Scope,
targets: Vec<Target>,
primary: Option<Target>,
generation: u64,
}
/// Immutable operation input, including the selection lifetime that captured it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct Snapshot {
context: Context,
generation: u64,
targets: Vec<Target>,
primary: Option<Target>,
}
impl Selection {
pub fn new(scope: Scope) -> Self {
Self {
scope,
targets: Vec::new(),
primary: None,
generation: 0,
}
}
pub fn scope(&self) -> Scope {
self.scope
}
pub fn targets(&self) -> &[Target] {
&self.targets
}
pub fn primary(&self) -> Option<&Target> {
self.primary.as_ref()
}
pub fn contains(&self, target: &Target) -> bool {
self.targets.contains(target)
}
/// Replace with one valid target. Rejected admission preserves the old set.
pub fn replace(&mut self, target: Target, valid: impl FnOnce(&Target) -> bool) -> bool {
if !admitted(&target, valid) || (self.targets.len() == 1 && self.contains(&target)) {
return false;
}
self.targets = vec![target.clone()];
self.primary = Some(target);
self.changed();
true
}
/// Add a new member as primary. Adding an existing exact target is a no-op.
pub fn add(&mut self, target: Target, valid: impl FnOnce(&Target) -> bool) -> bool {
if !admitted(&target, valid) || self.contains(&target) {
return false;
}
self.targets.push(target.clone());
self.primary = Some(target);
self.changed();
true
}
/// Removal is allowed even if the target is no longer valid in the model.
pub fn remove(&mut self, target: &Target) -> bool {
let Some(index) = self.targets.iter().position(|member| member == target) else {
return false;
};
self.targets.remove(index);
if self.primary.as_ref() == Some(target) {
self.primary = self.targets.last().cloned();
}
self.changed();
true
}
pub fn toggle(&mut self, target: Target, valid: impl FnOnce(&Target) -> bool) -> bool {
if self.contains(&target) {
self.remove(&target)
} else {
self.add(target, valid)
}
}
/// Inspect another selected member without changing membership or its order.
pub fn set_primary(&mut self, target: &Target) -> bool {
if !self.contains(target) || self.primary.as_ref() == Some(target) {
return false;
}
self.primary = Some(target.clone());
self.changed();
true
}
pub fn clear(&mut self) -> bool {
if self.targets.is_empty() {
return false;
}
self.targets.clear();
self.primary = None;
self.changed();
true
}
/// Change owner or prune exact targets against the current rendered surface.
/// The predicate checks identity/visibility, not eligibility for one action.
/// Revision-only changes may retain survivors; no semantic retargeting occurs.
pub fn reconcile(&mut self, scope: Scope, valid: impl FnMut(&Target) -> bool) -> bool {
if self.scope != scope {
self.scope = scope;
self.targets.clear();
self.primary = None;
self.changed();
return true;
}
let previous_len = self.targets.len();
self.targets.retain(valid);
if self.targets.len() == previous_len {
return false;
}
if !self
.primary
.as_ref()
.is_some_and(|target| self.contains(target))
{
self.primary = self.targets.last().cloned();
}
self.changed();
true
}
/// Capture input, including an empty selection before a first pointer press.
/// Does not grant permission to mutate; adapters still guard drafts, modals,
/// capability and geometry. A mismatched owner cannot capture a snapshot.
pub fn snapshot(&self, context: Context) -> Option<Snapshot> {
if context.scope != self.scope {
return None;
}
Some(Snapshot {
context,
generation: self.generation,
targets: self.targets.clone(),
primary: self.primary.clone(),
})
}
/// Check before any operation side effect, using the caller's live context.
/// Generation rejects remove/re-add and primary-change/restore ABA cycles.
pub fn matches(&self, snapshot: &Snapshot, current: Context) -> bool {
snapshot.context == current
&& current.scope == self.scope
&& snapshot.generation == self.generation
&& snapshot.targets == self.targets
&& self.primary == snapshot.primary
}
fn changed(&mut self) {
self.generation = self
.generation
.checked_add(1)
.expect("selection generation exhausted");
}
}
impl Snapshot {
pub fn context(&self) -> Context {
self.context
}
pub fn targets(&self) -> &[Target] {
&self.targets
}
pub fn primary(&self) -> Option<&Target> {
self.primary.as_ref()
}
}
fn admitted(target: &Target, valid: impl FnOnce(&Target) -> bool) -> bool {
!matches!(target, Target::Whole) && valid(target)
}
#[cfg(test)]
mod tests {
use super::*;
use oa_core::OccurrenceEndpoint;
fn id(value: u128) -> Id {
Id::from_u128(value)
}
fn scope() -> Scope {
Scope {
document: id(1),
surface: Surface::Saved(id(2)),
}
}
fn context() -> Context {
Context {
scope: scope(),
revision: 7,
epoch: 11,
}
}
fn node(value: u128) -> Target {
Target::Placement(id(value))
}
#[test]
fn exact_duplicates_are_noops_but_distinct_visual_identities_survive() {
let mut selection = Selection::new(scope());
let port_a = Target::Port {
port: id(30),
placement: id(3),
};
let port_b = Target::Port {
port: id(30),
placement: id(4),
};
assert!(selection.add(node(3), |_| true));
assert!(selection.add(port_a.clone(), |_| true));
let captured = selection.snapshot(context()).unwrap();
assert!(!selection.add(node(3), |_| true));
assert!(!selection.add(port_a.clone(), |_| true));
assert!(selection.matches(&captured, context()));
assert!(selection.add(port_b.clone(), |_| true));
assert_eq!(selection.targets(), &[node(3), port_a, port_b.clone()]);
assert_eq!(selection.primary(), Some(&port_b));
assert_eq!(
captured.targets(),
&[
node(3),
Target::Port {
port: id(30),
placement: id(3)
}
]
);
}
#[test]
fn primary_removal_uses_latest_survivor_without_reordering() {
let mut selection = Selection::new(scope());
for value in [3, 4, 5] {
selection.add(node(value), |_| true);
}
assert!(selection.set_primary(&node(3)));
assert_eq!(selection.targets(), &[node(3), node(4), node(5)]);
assert!(!selection.set_primary(&node(99)));
assert!(selection.remove(&node(4)));
assert_eq!(selection.primary(), Some(&node(3)));
assert!(selection.remove(&node(3)));
assert_eq!(selection.primary(), Some(&node(5)));
// A now-invalid member remains removable; validity applies to additions.
assert!(selection.toggle(node(5), |_| false));
assert!(selection.targets().is_empty());
assert_eq!(selection.primary(), None);
let empty = selection.snapshot(context()).unwrap();
assert!(empty.targets().is_empty());
assert_eq!(empty.primary(), None);
assert!(selection.matches(&empty, context()));
selection.add(node(5), |_| true);
assert!(!selection.matches(&empty, context()));
}
#[test]
fn whole_and_rejected_targets_cannot_replace_an_existing_selection() {
let mut selection = Selection::new(scope());
selection.add(node(3), |_| true);
let captured = selection.snapshot(context()).unwrap();
assert!(!selection.replace(Target::Whole, |_| true));
assert!(!selection.add(Target::Whole, |_| true));
assert!(!selection.toggle(Target::Whole, |_| true));
assert!(!selection.replace(node(4), |_| false));
assert!(!selection.toggle(node(4), |_| false));
assert!(selection.matches(&captured, context()));
assert!(selection.replace(node(4), |_| true));
assert_eq!(selection.targets(), &[node(4)]);
assert_eq!(selection.primary(), Some(&node(4)));
assert!(!selection.replace(node(4), |_| true));
assert!(selection.clear());
assert!(!selection.clear());
assert_eq!(selection.primary(), None);
}
#[test]
fn reused_process_occurrences_reconcile_by_full_path_never_definition() {
let owner = Scope {
surface: Surface::Process(id(2)),
..scope()
};
let mut selection = Selection::new(owner);
let occurrence = |path| {
Target::ProcessOccurrence(OccurrenceEndpoint {
references: vec![id(path), id(50)],
occurrence: id(60),
})
};
let first = occurrence(10);
let second = occurrence(20);
selection.add(first.clone(), |_| true);
selection.add(second.clone(), |_| true);
assert_eq!(selection.targets(), &[first.clone(), second.clone()]);
assert!(selection.reconcile(owner, |target| target == &first));
assert_eq!(selection.targets(), std::slice::from_ref(&first));
assert_eq!(selection.primary(), Some(&first));
// A new call path with the same definition is not a replacement identity.
let replacement = occurrence(70);
assert!(selection.reconcile(owner, |target| target == &replacement));
assert!(selection.targets().is_empty());
assert_eq!(selection.primary(), None);
}
#[test]
fn revision_reconciliation_retains_exact_survivors_but_invalidates_operations() {
let mut selection = Selection::new(scope());
selection.add(node(3), |_| true);
selection.add(node(4), |_| true);
selection.set_primary(&node(3));
let captured = selection.snapshot(context()).unwrap();
let next = Context {
revision: 8,
..context()
};
assert!(!selection.reconcile(scope(), |_| true));
assert_eq!(selection.targets(), &[node(3), node(4)]);
assert_eq!(selection.primary(), Some(&node(3)));
assert!(!selection.matches(&captured, next));
let accepted = selection.snapshot(next).unwrap();
assert!(selection.matches(&accepted, next));
assert!(selection.reconcile(scope(), |target| target != &node(4)));
assert_eq!(selection.primary(), Some(&node(3)));
assert!(!selection.matches(&accepted, next));
}
#[test]
fn changing_document_or_surface_clears_even_identical_target_ids() {
for changed in [
Scope {
document: id(99),
..scope()
},
Scope {
surface: Surface::Saved(id(99)),
..scope()
},
Scope {
surface: Surface::Scenario(id(2)),
..scope()
},
] {
let mut selection = Selection::new(scope());
selection.add(node(3), |_| true);
let captured = selection.snapshot(context()).unwrap();
assert!(selection.reconcile(changed, |_| true));
assert_eq!(selection.scope(), changed);
assert!(selection.targets().is_empty());
assert_eq!(selection.primary(), None);
assert!(!selection.matches(&captured, context()));
assert!(selection.snapshot(context()).is_none());
selection.reconcile(scope(), |_| true);
selection.add(node(3), |_| true);
assert!(!selection.matches(&captured, context()));
}
}
#[test]
fn stale_epoch_selection_and_primary_cycles_do_not_regain_authority() {
let mut selection = Selection::new(scope());
selection.add(node(3), |_| true);
selection.add(node(4), |_| true);
let captured = selection.snapshot(context()).unwrap();
assert_eq!(captured.context(), context());
assert_eq!(captured.primary(), Some(&node(4)));
assert!(!selection.matches(
&captured,
Context {
epoch: 12,
..context()
}
));
selection.set_primary(&node(3));
selection.set_primary(&node(4));
assert!(!selection.matches(&captured, context()));
let restored_primary = selection.snapshot(context()).unwrap();
selection.remove(&node(4));
selection.add(node(4), |_| true);
assert_eq!(selection.targets(), captured.targets());
assert_eq!(selection.primary(), captured.primary());
assert!(!selection.matches(&restored_primary, context()));
assert!(selection.matches(&selection.snapshot(context()).unwrap(), context()));
}
}
+3 -2
View File
@@ -344,7 +344,7 @@ fn article(topic: Topic) -> Article {
&[
"Select an architecture connection and drag a segment to add a bend. Right-click a line for Add bend here; right-click a circle for Remove bend. With the canvas focused, Insert adds a bend and Delete removes the selected bend. Drag the label independently. Escape cancels a gesture; Undo reverses one completed drag. Shift+F10 opens the selected diagram item's context menu; use arrow keys and Enter to choose an action.",
"Automatic orthogonal routing avoids cards and container headers where space permits. Amber connections need attention: move an obstructing element or a bend. In Select / move, drag a port to any border of its owner. Manual positions are per diagram; right-click Reset automatic position restores connection-aware placement. Ports remain attached to their actual owners. Manual routes are saved separately for each diagram and appear in source, API and SVG export.",
"Right-click a connection and choose Reset routing to clear its pins and label position in this view. The Properties Actions menu provides the same commands. Auto-layout preserves pins. Moving one endpoint keeps pins fixed; moving a container carrying both endpoints translates their pins. When folding promotes an endpoint, expand it again to resume and edit its saved route.",
"Select a connection and drag a square endpoint handle along its element boundary to move its attachment in this view without changing what it connects. If handles overlap, right-click the selected connection or use its Actions menu and choose Move source attachment or Move target attachment, then drag the handle. Connections to semantic ports retain their port attachment; move the port instead. Reset source attachment or Reset target attachment restores that end's automatic placement while preserving the other end, pins and label. Reset routing clears all these route overrides in this view. Auto-layout preserves pins. Moving one endpoint keeps pins fixed; moving a container carrying both endpoints translates their pins. When folding promotes an endpoint, expand it again to resume and edit its saved route.",
],
),
explain(
@@ -564,7 +564,8 @@ fn article(topic: Topic) -> Article {
"Optionally select Create the first state view, then Create and open. The new machine starts without regions or states. For an existing machine, Create diagram places its existing regions and vertices in a saved view; + View creates another independent layout.",
"Choose + Region and click the highlighted machine, or a State/Mode for a nested region. Choose Initial, State, Mode or another kind from + State / symbol, then click its highlighted owning region. Rename the new element in Properties.",
"Choose Transition and drag from a source vertex to a compatible target, or click the source and then the target. In Select / move, drag states or containers to arrange them; a container moves its descendant placements without changing ownership.",
"Select a drawn transition and drag its endpoint handle to reconnect it. Its right-click menu also offers Reconnect source and Reconnect target; choose one, then click a compatible vertex.",
"Select a drawn transition and drag its endpoint handle to reconnect it. Its right-click menu also offers Reconnect source and Reconnect target; choose one, then click a compatible vertex. To change only where a line touches its existing vertex, choose Adjust source attachment or Adjust target attachment, then drag the highlighted tip along the boundary. Reset source attachment or Reset target attachment restores automatic placement for that end without changing the transition's meaning or other route settings. Escape cancels an attachment drag.",
"To shape a selected transition, drag an exposed line segment to add a bend, or drag a square bend handle to move it. Right-click a square and choose Remove bend. The transition's Actions menu also offers Add bend and Clear bends; clearing bends keeps its attachment points and label placement. Bends belong to this saved view. A click alone changes nothing, Escape cancels a drag, and Undo restores the complete edit. Internal transition rows and folded connections do not expose bend editing while their route is hidden.",
"Right-click a state or the background for New transition with details… to create Local, Internal or timed behavior. Right-click an existing transition and choose Edit transition to edit its kind, triggers, guard and effects. EntryPoint outgoing transitions use Local. Apply accepts the complete form; invalid drafts remain available for correction.",
],
),
+1
View File
@@ -10,6 +10,7 @@ mod data_values;
mod data_view;
mod diagnostic_input;
mod diagram;
mod diagram_selection;
mod diagram_usage;
mod document;
mod explorer;
+208 -4
View File
@@ -12,8 +12,10 @@ pub struct Request {
pub anchor: Point,
pub object: Id,
pub placement: Option<Id>,
pub selection_target: Option<Box<crate::diagram_usage::Target>>,
pub bend: Option<usize>,
pub route_point: Option<Position>,
pub route_segment: Option<(usize, Position)>,
pub creation_point: Option<Position>,
pub canvas: Option<route_edit::Context>,
pub source_file: Option<String>,
@@ -24,8 +26,10 @@ impl Request {
anchor,
object,
placement: None,
selection_target: None,
bend: None,
route_point: None,
route_segment: None,
creation_point: None,
canvas: None,
source_file: None,
@@ -53,6 +57,7 @@ pub struct Opened {
impl Opened {
pub fn new(app: &App, request: Request, token: u64) -> Option<Self> {
if !app.project().contains(request.object)
|| !target_matches(app, &request)
|| request
.canvas
.is_some_and(|c| Some(c) != current_canvas(app))
@@ -78,6 +83,7 @@ impl Opened {
&& self.layer == app.layer
&& self.diagram == current_diagram(app)
&& app.project().contains(self.request.object)
&& target_matches(app, &self.request)
&& self
.request
.canvas
@@ -90,6 +96,32 @@ impl Opened {
}
}
fn target_matches(app: &App, request: &Request) -> bool {
use crate::diagram_usage::Target;
let Some(target) = request.selection_target.as_deref() else {
return true;
};
if request.canvas.is_none() || !app.multiselect_target_visible(target) {
return false;
}
match target {
Target::Placement(id) => {
request.placement == Some(*id)
&& request
.canvas
.and_then(|context| app.project().diagrams.get(&context.diagram))
.and_then(|diagram| diagram.nodes.get(id))
.is_some_and(|node| node.element == request.object || node.id == request.object)
}
Target::Port { port, .. } => request.placement.is_none() && *port == request.object,
Target::Edge(id) => request.placement.is_none() && *id == request.object,
Target::StateRow { relation, .. } => {
request.placement.is_none() && *relation == request.object
}
_ => false,
}
}
fn current_canvas(app: &App) -> Option<route_edit::Context> {
if app.page == Page::Behavior {
app.state_context()
@@ -124,7 +156,22 @@ pub fn trigger(app: &App) -> iced::Element<'_, Message> {
request.canvas = app.route_context();
} else if app.page == Page::Behavior {
request.canvas = app.state_context();
if let Some(context) = request.canvas
if let Some(target) = app.diagram_selection.as_ref().and_then(|s| s.primary()) {
request.selection_target = Some(Box::new(target.clone()));
request.placement = match target {
crate::diagram_usage::Target::Placement(id) => {
if let Some(node) = request
.canvas
.and_then(|c| app.project().diagrams.get(&c.diagram))
.and_then(|d| d.nodes.get(id))
{
request.object = node.element;
}
Some(*id)
}
_ => None,
};
} else if let Some(context) = request.canvas
&& let Some(node) = app.project().diagrams[&context.diagram]
.nodes
.values()
@@ -185,12 +232,112 @@ pub fn popup(app: &App) -> Option<Menu<Message>> {
on_close: Message::Menu(Event::Close),
});
}
if app.page == Page::Behavior
&& let Some(menu) = crate::behavior::state_menu::popup(app, opened, clean)
let mut entries = vec![Entry::title(crate::views::short(p.named(id), 38))];
if request.canvas.is_some()
&& let Some(context) = app.multiselect_context()
{
use crate::app::multiselect::{Choice, Event as SelectionEvent};
use crate::diagram_usage::Target;
let view = request
.canvas
.map(|context| context.diagram)
.map(|d| projection::project_view_with_index(p, d, app.document.view_index()));
let target = request
.selection_target
.as_deref()
.cloned()
.or_else(|| request.placement.map(Target::Placement))
.or_else(|| {
view.as_ref()?
.ports
.iter()
.find(|port| port.element == id)
.map(|port| Target::Port {
port: port.element,
placement: port.owner,
})
})
.or_else(|| {
view.as_ref()?
.edges
.iter()
.any(|edge| edge.id == id)
.then_some(Target::Edge(id))
});
if let Some(target) = target {
let selected = app
.diagram_selection
.as_ref()
.is_some_and(|s| s.contains(&target));
entries.push(item(
if selected {
"Remove from selection"
} else {
"Add to selection"
},
Message::DiagramSelection(SelectionEvent::Choose(
context,
target.clone(),
if selected {
Choice::Remove
} else {
Choice::Add
},
)),
true,
));
if let Some(snapshot) = app.multiselect_snapshot().filter(|s| s.targets().len() > 1)
&& selected
{
entries.push(Entry::title(format!(
"{} selected",
snapshot.targets().len()
)));
entries.push(item(
"Inspect this element",
Message::DiagramSelection(SelectionEvent::Choose(
context,
target.clone(),
Choice::Inspect,
)),
true,
));
entries.push(item(
"Select only this element",
Message::DiagramSelection(SelectionEvent::Choose(
context,
target,
Choice::Replace,
)),
true,
));
entries.push(item(
"Hide selected placements",
Message::DiagramSelection(SelectionEvent::Hide(snapshot.clone())),
crate::app::multiselect::placements(&snapshot).is_some(),
));
entries.push(Entry::disabled(
"Delete selected from model (not yet available)",
));
return Some(Menu {
token: opened.token,
anchor: request.anchor,
entries,
on_close: Message::Menu(Event::Close),
});
}
entries.push(Entry::separator());
}
}
if app.page == Page::Behavior
&& let Some(mut menu) = crate::behavior::state_menu::popup(app, opened, clean)
{
if entries.len() > 1 {
entries.extend(menu.entries.into_iter().skip(1));
menu.entries = entries;
}
return Some(menu);
}
let mut entries = vec![Entry::title(crate::views::short(p.named(id), 38))];
if !clean {
entries.push(Entry::title("Apply or revert pending edits first"));
}
@@ -240,6 +387,33 @@ pub fn popup(app: &App) -> Option<Menu<Message>> {
.is_none_or(|(s, t)| s == e.source && t == e.target)
});
let route = diagram.and_then(|d| d.routes.iter().find(|r| r.edge == id));
if let Some((diagram, view)) = diagram.zip(view.as_ref()) {
for (end, choose, reset) in [
(
EdgeEnd::Source,
"Move source attachment",
"Reset source attachment",
),
(
EdgeEnd::Target,
"Move target attachment",
"Reset target attachment",
),
] {
let eligible = editable
&& route_edit::attachment_node(p, diagram, view, id, end).is_some();
entries.push(item(
choose,
Message::Route(route_edit::Event::ChooseAttachment(end)),
eligible,
));
entries.push(item(
reset,
Message::Route(route_edit::Event::ResetAttachment(end)),
eligible && route.is_some_and(|route| route.attachment(end).is_some()),
));
}
}
if let Some(point) = request.route_point {
entries.push(
item(
@@ -900,6 +1074,36 @@ mod tests {
assert_eq!(app.document.bundle(), &changed);
}
#[test]
fn attachment_end_choice_is_captured_without_mutating_source_and_stale_menu_is_rejected() {
let mut project = Project::empty();
let source = project.add(Kind::Activity, "Send", 0., 0.);
let target = project.add(Kind::Activity, "Receive", 650., 0.);
let edge = project.relate(RelationKind::Interaction, "Report", source, target);
let mut app = App::with_project(project);
let before = app.document.bundle().clone();
let mut request = Request::object(edge, Point::ORIGIN);
request.canvas = app.route_context();
send(&mut app, Message::Menu(Event::Open(request.clone())));
let action = choose(&app, "Move target attachment");
send(&mut app, action);
assert_eq!(
app.selected_attachment,
Some((app.route_context().unwrap(), edge, EdgeEnd::Target))
);
assert_eq!(app.document.bundle(), &before);
request.canvas = app.route_context();
send(&mut app, Message::Menu(Event::Open(request)));
let stale = choose(&app, "Move source attachment");
send(&mut app, Message::Escape);
send(&mut app, stale);
assert!(
app.selected_attachment
.is_none_or(|(context, _, _)| Some(context) != app.route_context())
);
assert_eq!(app.document.bundle(), &before);
}
#[test]
fn show_from_explorer_places_unfolds_and_keeps_semantic_selection() {
let mut p = Project::empty();
+90 -1
View File
@@ -14,6 +14,9 @@ pub struct Context {
pub enum Event {
SelectBend(Id, usize),
Commit(Context, EdgeRoute, Option<usize>),
CommitAttachment(Context, EdgeRoute, EdgeEnd, Id),
ChooseAttachment(EdgeEnd),
ResetAttachment(EdgeEnd),
AddBend,
AddBendAt(oa_core::Position),
RemoveBend,
@@ -23,7 +26,14 @@ pub enum Event {
#[derive(Debug, Clone)]
pub enum Handle {
Bend(usize),
Label { original: Position },
Label {
original: Position,
},
Attachment {
end: EdgeEnd,
owner: projection::ProjectedNode,
original: Option<EdgeAttachment>,
},
}
#[derive(Debug, Clone)]
@@ -58,8 +68,57 @@ impl Gesture {
};
}
}
Handle::Attachment { .. } => {}
}
}
pub fn move_attachment_to(&mut self, point: Point, zoom: f32) {
let Handle::Attachment {
end,
ref owner,
original,
} = self.handle
else {
self.move_to(point, zoom);
return;
};
if point.distance(self.start) * zoom < 3. {
self.route.set_attachment(end, original);
self.changed = false;
return;
}
self.changed = true;
let attachment = attachment_from_point(
owner,
position(point),
self.route.attachment(end).map(|a| a.side),
6. / zoom,
);
self.route.set_attachment(end, Some(attachment));
}
}
/// The painted endpoint must still be the actual unported placement, not a folded proxy.
pub fn attachment_node<'a>(
project: &Project,
diagram: &Diagram,
view: &'a projection::ProjectedView,
edge: Id,
end: EdgeEnd,
) -> Option<&'a projection::ProjectedNode> {
if semantic_port_endpoint(project, edge, end).is_some() {
return None;
}
let projected = view.edges.iter().find(|e| e.id == edge)?;
let actual = projection::edge_endpoint_placements(project, diagram, edge)?;
if actual != (projected.source, projected.target) {
return None;
}
let placement = match end {
EdgeEnd::Source => projected.source,
EdgeEnd::Target => projected.target,
};
view.nodes.iter().find(|node| node.id == placement)
}
pub fn stored_route(diagram: &Diagram, edge: Id) -> EdgeRoute {
@@ -72,10 +131,40 @@ pub fn stored_route(diagram: &Diagram, edge: Id) -> EdgeRoute {
edge,
waypoints: Vec::new(),
label: None,
source_attachment: None,
target_attachment: None,
})
}
/// Insert after the pins preceding the clicked segment along the routed path.
pub fn insert_pin_on_segment(
route: &mut EdgeRoute,
geometry: &RoutedEdge,
segment: usize,
point: Position,
) -> Option<usize> {
if route.edge != geometry.id
|| geometry.custom_suspended
|| route.waypoints != geometry.pins
|| route.waypoints.len() >= 128
|| !point.x.is_finite()
|| !point.y.is_finite()
|| geometry
.points
.get(segment..segment.checked_add(2)?)
.is_none()
{
return None;
}
let index = pin_insertion_index(geometry, segment, point);
if index > route.waypoints.len() {
return None;
}
route.waypoints.insert(index, point);
Some(index)
}
/// Find a segment when an ordinary caller has only a point, not a captured hit.
pub fn insert_pin(route: &mut EdgeRoute, geometry: &RoutedEdge, point: Position) -> usize {
let segment = geometry
.points
+2
View File
@@ -329,6 +329,8 @@ fn reopening_canvas_for_a_modal_preserves_viewport_after_route_edits() {
diagram,
route: EdgeRoute {
edge,
source_attachment: None,
target_attachment: None,
waypoints: vec![Position { x: 400., y: 400. }],
label: Some(EdgeLabel {
fraction: 0.5,
+2
View File
@@ -112,6 +112,8 @@ mod tests {
});
view.routes.push(EdgeRoute {
edge: exchange,
source_attachment: None,
target_attachment: None,
waypoints: vec![Position { x: 420., y: 440. }],
label: Some(EdgeLabel {
fraction: 0.4,
+63 -4
View File
@@ -455,6 +455,7 @@ fn diagram(app: &App) -> View<'_, Message> {
.align_y(iced::Center);
let canvas: View<'_, Message> = if let Some(id) = app.active_diagram {
let program = DiagramCanvas {
selection: app.diagram_selection.as_ref(),
allocation: Some(app.allocation_capability()),
project: app.project(),
view_index: Some(app.document.view_index()),
@@ -466,6 +467,12 @@ fn diagram(app: &App) -> View<'_, Message> {
zoom: app.zoom,
pan: app.pan,
context: app.route_context().unwrap(),
selected_attachment: app
.selected_attachment
.filter(|(context, edge, _)| {
Some(*context) == app.route_context() && *edge == app.selected
})
.map(|(_, _, end)| end),
selected_bend: app
.selected_bend
.filter(|(edge, _)| *edge == app.selected)
@@ -602,6 +609,37 @@ fn diagram(app: &App) -> View<'_, Message> {
)
.is_some_and(|(source, target)| source != edge.source || target != edge.target)
});
let selection = app.multiselect_snapshot();
let count = selection.as_ref().map_or(0, |s| s.targets().len());
let mut selection_controls =
row![label(format!("{count} selected")).wrapping(widget::text::Wrapping::None)]
.spacing(7)
.align_y(iced::Center)
.height(32);
if let Some(snapshot) = selection.as_ref().filter(|s| s.targets().len() > 1) {
if crate::app::multiselect::placements(snapshot).is_some() {
selection_controls = selection_controls.push(action(
"Hide selected",
Message::DiagramSelection(crate::app::multiselect::Event::Hide(
snapshot.clone(),
)),
));
}
selection_controls = selection_controls
.push(action(
"Clear selection",
Message::DiagramSelection(crate::app::multiselect::Event::Clear(
snapshot.context(),
)),
))
.push(
label(format!(
"Properties edit only {}",
short(app.project().named(app.selected), 36)
))
.wrapping(widget::text::Wrapping::None),
);
}
let hint = if let Some(feedback) = &app.allocation_drag.feedback {
feedback.text.as_str()
} else if let Some(session) = &app.connection {
@@ -614,16 +652,37 @@ fn diagram(app: &App) -> View<'_, Message> {
} else {
session.tool.hint()
}
} else if count > 1 {
if selection
.as_ref()
.is_some_and(|s| crate::app::multiselect::placements(s).is_some())
&& projection != Some(ProjectionKind::Allocation)
{
"Shift-click to add/remove · drag selected to move · right-click for actions · scroll to zoom"
} else {
"Shift-click to add/remove · right-click for selection actions · Esc clears selection"
}
} else if promoted {
"Expand endpoint containers to edit this connection route"
} else if edge.is_some() {
"Right-click for routing · drag line / bend / label · Insert adds a bend · Esc cancels"
} else {
"Right-click for actions · drag to arrange · scroll to zoom"
"Shift-click to add/remove · right-click for actions · drag to arrange · scroll to zoom"
};
// Status is outside the measured canvas. Clipping prevents long text
// from painting over work headers or the dedicated destination slot.
tools = tools.push(container(label(hint)).height(30).width(Fill).clip(true));
// Selection changes must not resize the measured canvas or refit its
// camera. Reserve both rows even when no group actions are available.
tools = tools.push(
container(
column![
selection_controls,
label(hint).wrapping(widget::text::Wrapping::None)
]
.spacing(3),
)
.height(56)
.width(Fill)
.clip(true),
);
}
column![container(tools).padding(14), canvas].into()
}
@@ -234,6 +234,8 @@ fn basic_transition_reconcile_keeps_existing_selected_view_placement_and_route_e
.routes
.push(EdgeRoute {
edge: trace,
source_attachment: None,
target_attachment: None,
waypoints: vec![Position { x: 301., y: 507. }],
label: Some(EdgeLabel {
fraction: 0.35,
+2
View File
@@ -12,6 +12,8 @@ use tokio::{sync::oneshot, task::JoinHandle};
mod communication_limits;
#[path = "http/data_contracts.rs"]
mod data_contracts;
#[path = "http/edge_attachments.rs"]
mod edge_attachments;
#[path = "http/port_anchors.rs"]
mod port_anchors;
#[path = "http/semantic_transitions.rs"]
@@ -0,0 +1,344 @@
use super::*;
use oa_core::{
EdgeAttachment, PortAnchorSide,
source_project::{ProjectLimits, SourceBundle, compile_bundle},
};
use std::collections::BTreeMap;
fn source_bundle() -> SourceBundle {
compile_bundle(&SourceBundle {
entry: "project.arcadia".into(),
files: BTreeMap::from([
("project.arcadia".into(), "// project rationale\narcadia 7; project attachments \"Attachments\" { include \"model.arcadia\"; include \"views.arcadia\"; }\n".into()),
("model.arcadia".into(), "// semantic ownership\noa_activity first \"First\" {}\noa_activity second \"Second\" {}\ninteraction signal \"Signal\" { source @first; target @second; }\n".into()),
("views.arcadia".into(), "// view rationale\ndiagram view \"View\" { routes [{ edge @signal; waypoints [{ x 120; y 80; }]; label { fraction 0.5; offset { x 3; y -7; }; }; }]; }\ndiagram other \"Other\" { routes [{ edge @signal; }]; }\n".into()),
]),
}, ProjectLimits::default()).unwrap().bundle
}
fn id(project: &Project, alias: &str) -> Id {
project
.identities()
.into_iter()
.find(|(_, a)| *a == alias)
.unwrap()
.0
}
async fn import(server: &TestServer) -> StoredProject {
let response = server
.client
.post(server.url("/projects/import-source-project"))
.json(&source_bundle())
.send()
.await
.unwrap();
let status = response.status();
let value: Value = response.json().await.unwrap();
assert_eq!(status, StatusCode::CREATED, "{value}");
server
.get(value["resource_id"].as_str().unwrap().parse().unwrap())
.await
}
async fn transact(
server: &TestServer,
current: &StoredProject,
commands: Value,
dry_run: bool,
) -> reqwest::Response {
server
.client
.post(server.url(&format!("/projects/{}/transactions", current.resource_id)))
.header("if-match", current.etag())
.json(&json!({"commands":commands,"dry_run":dry_run}))
.send()
.await
.unwrap()
}
fn assert_pair(current: &StoredProject) {
let compiled = compile_bundle(&current.source_project, ProjectLimits::default()).unwrap();
assert_eq!(compiled.decoded.project, current.model);
assert_eq!(compiled.bundle, current.source_project);
}
async fn assert_unchanged(server: &TestServer, expected: &StoredProject) {
let actual = server.get(expected.resource_id).await;
assert_eq!(actual.etag(), expected.etag());
assert_eq!(
serde_json::to_value(actual).unwrap(),
serde_json::to_value(expected).unwrap()
);
}
#[tokio::test]
async fn edge_attachments_http_dry_run_etags_source_parity_and_restart() {
let directory = tempfile::tempdir().unwrap();
let server = TestServer::start(directory.path(), None).await;
let original = import(&server).await;
let diagram = id(&original.model, "view");
let mut expected = original.model.clone();
let route = &mut expected.diagrams.get_mut(&diagram).unwrap().routes[0];
route.source_attachment = Some(EdgeAttachment {
side: PortAnchorSide::Left,
offset: 0.25,
});
route.target_attachment = Some(EdgeAttachment {
side: PortAnchorSide::Bottom,
offset: 1.0,
});
let commands = json!([Command::SetEdgeRoute {
diagram,
route: route.clone()
}]);
let preview = transact(&server, &original, commands.clone(), true).await;
assert_eq!(preview.status(), StatusCode::OK);
let preview: Value = preview.json().await.unwrap();
assert_eq!(
serde_json::from_value::<Project>(preview["model"].clone()).unwrap(),
expected
);
let proposed: SourceBundle = serde_json::from_value(preview["source_project"].clone()).unwrap();
assert_eq!(
compile_bundle(&proposed, ProjectLimits::default())
.unwrap()
.decoded
.project,
expected
);
assert_unchanged(&server, &original).await;
let applied = transact(&server, &original, commands.clone(), false).await;
assert_eq!(applied.status(), StatusCode::OK);
let mut current = server.get(original.resource_id).await;
assert_eq!(applied.headers()["etag"], current.etag());
assert_eq!(current.revision, original.revision + 1);
assert_eq!(
current.model, expected,
"only the two attachment fields in this view change"
);
assert_eq!(current.source_project, proposed);
assert_pair(&current);
for path in ["project.arcadia", "model.arcadia"] {
assert_eq!(
current.source_project.files[path],
original.source_project.files[path]
);
}
assert!(current.source_project.files["views.arcadia"].contains("// view rationale"));
let noop = transact(&server, &current, commands.clone(), false).await;
assert_eq!(noop.status(), StatusCode::OK);
assert_eq!(noop.headers()["etag"], current.etag());
assert_eq!(noop.json::<Value>().await.unwrap()["changed"], false);
let stale = json!([
Command::Rename(id(&current.model, "first"), "Must not persist".into()),
commands[0].clone()
]);
assert_eq!(
transact(&server, &original, stale, false).await.status(),
StatusCode::PRECONDITION_FAILED
);
assert_unchanged(&server, &current).await;
let mut edited = current.source_project.clone();
let text = edited.files.get_mut("views.arcadia").unwrap();
assert_eq!(text.matches("offset 0.25;").count(), 1);
*text = text.replace("offset 0.25;", "offset 0.375;");
expected.diagrams.get_mut(&diagram).unwrap().routes[0]
.source_attachment
.as_mut()
.unwrap()
.offset = 0.375;
let response = server
.client
.put(server.url(&format!("/projects/{}/source-project", current.resource_id)))
.header("if-match", current.etag())
.json(&edited)
.send()
.await
.unwrap();
assert_eq!(
response.status(),
StatusCode::OK,
"{}",
response.text().await.unwrap()
);
current = server.get(current.resource_id).await;
assert_eq!(current.model, expected);
assert_eq!(current.source_project, edited);
assert_pair(&current);
let source = server
.client
.get(server.url(&format!("/projects/{}/source", current.resource_id)))
.send()
.await
.unwrap();
assert_eq!(source.status(), StatusCode::OK);
assert_eq!(source.headers()["etag"], current.etag());
assert_eq!(
oa_core::language::parse(&source.text().await.unwrap()).unwrap(),
expected
);
let bundle = server
.client
.get(server.url(&format!("/projects/{}/source-project", current.resource_id)))
.send()
.await
.unwrap();
assert_eq!(bundle.status(), StatusCode::OK);
assert_eq!(bundle.headers()["etag"], current.etag());
assert_eq!(
bundle.json::<SourceBundle>().await.unwrap(),
current.source_project
);
server.stop().await;
let server = TestServer::start(directory.path(), None).await;
assert_unchanged(&server, &current).await;
let mut route = expected.diagrams[&diagram].routes[0].clone();
route.source_attachment = None;
let reset = json!([Command::SetEdgeRoute {
diagram,
route: route.clone()
}]);
assert_eq!(
transact(&server, &current, reset.clone(), false)
.await
.status(),
StatusCode::OK
);
expected.diagrams.get_mut(&diagram).unwrap().routes[0] = route;
let reset_state = server.get(current.resource_id).await;
assert_eq!(
reset_state.model, expected,
"reset preserves pins, label, opposite end, other view and semantics"
);
assert_pair(&reset_state);
let noop = transact(&server, &reset_state, reset, false).await;
assert_eq!(noop.status(), StatusCode::OK);
assert_eq!(noop.headers()["etag"], reset_state.etag());
assert_eq!(noop.json::<Value>().await.unwrap()["changed"], false);
server.stop().await;
}
#[tokio::test]
async fn edge_attachments_http_malformed_and_invalid_offsets_reject_atomically() {
let directory = tempfile::tempdir().unwrap();
let server = TestServer::start(directory.path(), None).await;
let current = import(&server).await;
let diagram = id(&current.model, "view");
let first = id(&current.model, "first");
for (bad, status) in [
(json!({}), StatusCode::BAD_REQUEST),
(json!({"side":"Top"}), StatusCode::BAD_REQUEST),
(json!({"offset":0.5}), StatusCode::BAD_REQUEST),
(
json!({"side":"Diagonal","offset":0.5}),
StatusCode::BAD_REQUEST,
),
(json!({"side":"Top","offset":null}), StatusCode::BAD_REQUEST),
(
json!({"side":"Top","offset":"NaN"}),
StatusCode::BAD_REQUEST,
),
(
json!({"side":"Top","offset":0.5,"unknown":true}),
StatusCode::BAD_REQUEST,
),
(
json!({"side":"Top","offset":-0.01}),
StatusCode::UNPROCESSABLE_ENTITY,
),
(
json!({"side":"Top","offset":1.01}),
StatusCode::UNPROCESSABLE_ENTITY,
),
] {
for field in ["source_attachment", "target_attachment"] {
let mut route =
serde_json::to_value(&current.model.diagrams[&diagram].routes[0]).unwrap();
route[field] = bad.clone();
let commands = json!([Command::Rename(first, "Must roll back".into()), {"SetEdgeRoute":{"diagram":diagram,"route":route}}]);
let response = transact(&server, &current, commands, false).await;
assert_eq!(
response.status(),
status,
"{field} {bad}: {}",
response.text().await.unwrap()
);
assert_unchanged(&server, &current).await;
}
}
let mut invalid = current.model.clone();
invalid.diagrams.get_mut(&diagram).unwrap().routes[0].target_attachment =
Some(EdgeAttachment {
side: PortAnchorSide::Right,
offset: 2.0,
});
let response = server
.client
.put(server.url(&format!("/projects/{}/model", current.resource_id)))
.header("if-match", current.etag())
.json(&invalid)
.send()
.await
.unwrap();
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
assert_unchanged(&server, &current).await;
server.stop().await;
}
#[tokio::test]
async fn edge_attachments_served_openapi_matches_required_nested_records_and_optional_ends() {
let directory = tempfile::tempdir().unwrap();
let server = TestServer::start(directory.path(), None).await;
let response = server
.client
.get(server.url("/openapi.json"))
.send()
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let schema: Value = response.json().await.unwrap();
let schemas = &schema["components"]["schemas"];
let attachment = &schemas["EdgeAttachment"];
assert_eq!(attachment["required"], json!(["side", "offset"]));
assert_eq!(attachment["additionalProperties"], false);
assert_eq!(
attachment["properties"]["side"]["$ref"],
"#/components/schemas/PortAnchorSide"
);
assert_eq!(attachment["properties"]["offset"]["minimum"], 0);
assert_eq!(attachment["properties"]["offset"]["maximum"], 1);
for field in ["source_attachment", "target_attachment"] {
assert!(
!schemas["EdgeRoute"]["required"]
.as_array()
.unwrap()
.contains(&json!(field))
);
let optional = &schemas["EdgeRoute"]["properties"][field];
assert_eq!(optional["default"], Value::Null);
assert_eq!(
optional["oneOf"],
json!([{"$ref":"#/components/schemas/EdgeAttachment"},{"type":"null"}])
);
}
let compiled = compile_bundle(&source_bundle(), ProjectLimits::default()).unwrap();
let diagram = id(&compiled.decoded.project, "view");
let mut route = compiled.decoded.project.diagrams[&diagram].routes[0].clone();
let omitted = serde_json::to_value(&route).unwrap();
assert!(omitted.get("source_attachment").is_none());
assert!(omitted.get("target_attachment").is_none());
route.target_attachment = Some(EdgeAttachment {
side: PortAnchorSide::Right,
offset: 0.0,
});
let encoded = serde_json::to_value(Command::SetEdgeRoute { diagram, route }).unwrap();
assert_eq!(
encoded["SetEdgeRoute"]["route"]["target_attachment"],
json!({"side":"Right","offset":0.0})
);
server.stop().await;
}
+28 -1
View File
@@ -2,7 +2,7 @@
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
<head>
<meta charset="utf-8" />
<meta name="generator" content="Docutils 0.22.2: https://docutils.sourceforge.io/" />
<meta name="generator" content="Docutils 0.23: https://docutils.sourceforge.io/" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Arcadia Studio model REST API</title>
<style type="text/css">
@@ -947,6 +947,33 @@ authored files and revision. JSON/source imports may omit <span class="docutils
it defaults to <span class="docutils literal">[]</span> and empty lists are omitted on export. The normative
diagram semantics and source spelling are in <span class="docutils literal">language.rst</span> section 5.</p>
</section>
<section id="diagram-connection-attachments">
<h2>Diagram connection attachments</h2>
<p><span class="docutils literal">EdgeRoute.source_attachment</span> and <span class="docutils literal">target_attachment</span> are optional
<span class="docutils literal"><span class="pre">{&quot;side&quot;:&quot;Bottom&quot;,&quot;offset&quot;:0.25}</span></span> records. Side and a finite offset in
<span class="docutils literal">[0,1]</span> are required within a supplied record; unknown fields are rejected.
Absence/null means automatic placement. Omitted fields in older schema-3
models retain that meaning. The normal transaction endpoint accepts:</p>
<pre class="literal-block">{&quot;commands&quot;:[{&quot;SetEdgeRoute&quot;:{
&quot;diagram&quot;:&quot;&lt;diagram UUID&gt;&quot;,
&quot;route&quot;:{&quot;edge&quot;:&quot;&lt;relation or trace UUID&gt;&quot;,
&quot;source_attachment&quot;:{&quot;side&quot;:&quot;Bottom&quot;,&quot;offset&quot;:0.25},
&quot;waypoints&quot;:[],&quot;label&quot;:null}
}}]}</pre>
<p><span class="docutils literal">SetEdgeRoute</span> replaces the complete route entry. Clients changing one end
must retain its other attachment, bends and label; omit/null only the intended
attachment to restore that end's automatic position. <span class="docutils literal">RemoveEdgeRoute</span> resets
all presentation for that edge. Both support ETag checking, dry runs, atomic
validation and source-project writeback. Invalid offsets reject the whole
transaction without changing files or revision.</p>
<p>These are per-view presentation preferences, not a semantic reconnect. Fractions
follow the projected shape, including moves/resizes; semantic reconnect retains
them as preferences of the same edge-end slots. Real semantic ports take
precedence, and promoted folded endpoints suspend authored routing. The complete
boundary mapping, lifecycle and generated-view limitations are normative in
<span class="docutils literal">language.rst</span> section 5. GET model/source, PUT source, desktop and transactions
share this representation; no separate attachment endpoint exists.</p>
</section>
<section id="contextual-process-and-chain-topology">
<h2>Contextual process and chain topology</h2>
<p>Operational processes and system functional chains can combine work
+31
View File
@@ -329,6 +329,37 @@ authored files and revision. JSON/source imports may omit ``port_anchors``;
it defaults to ``[]`` and empty lists are omitted on export. The normative
diagram semantics and source spelling are in ``language.rst`` section 5.
Diagram connection attachments
-------------------------------
``EdgeRoute.source_attachment`` and ``target_attachment`` are optional
``{"side":"Bottom","offset":0.25}`` records. Side and a finite offset in
``[0,1]`` are required within a supplied record; unknown fields are rejected.
Absence/null means automatic placement. Omitted fields in older schema-3
models retain that meaning. The normal transaction endpoint accepts::
{"commands":[{"SetEdgeRoute":{
"diagram":"<diagram UUID>",
"route":{"edge":"<relation or trace UUID>",
"source_attachment":{"side":"Bottom","offset":0.25},
"waypoints":[],"label":null}
}}]}
``SetEdgeRoute`` replaces the complete route entry. Clients changing one end
must retain its other attachment, bends and label; omit/null only the intended
attachment to restore that end's automatic position. ``RemoveEdgeRoute`` resets
all presentation for that edge. Both support ETag checking, dry runs, atomic
validation and source-project writeback. Invalid offsets reject the whole
transaction without changing files or revision.
These are per-view presentation preferences, not a semantic reconnect. Fractions
follow the projected shape, including moves/resizes; semantic reconnect retains
them as preferences of the same edge-end slots. Real semantic ports take
precedence, and promoted folded endpoints suspend authored routing. The complete
boundary mapping, lifecycle and generated-view limitations are normative in
``language.rst`` section 5. GET model/source, PUT source, desktop and transactions
share this representation; no separate attachment endpoint exists.
Contextual process and chain topology
-------------------------------------
+45 -2
View File
@@ -1785,14 +1785,16 @@ the Reparent command. Moving, resizing, arranging, or folding an owner retains
its authored sides and fractions. Frozen schema-1/schema-2 readers do not accept
<span class="docutils literal">port_anchors</span>; their explicit migration supplies an empty list.</p>
<p>Each <span class="docutils literal">routes</span> entry is an EdgeRoute record with a required <span class="docutils literal">edge</span> reference
to an existing Relation or Trace, <span class="docutils literal">waypoints</span> (default <span class="docutils literal">[]</span>), and <span class="docutils literal">label</span>
(default <span class="docutils literal">none</span>). Route records have no separate UUID or alias. An edge occurs
to an existing Relation or Trace, <span class="docutils literal">waypoints</span> (default <span class="docutils literal">[]</span>), <span class="docutils literal">label</span>
(default <span class="docutils literal">none</span>), and <span class="docutils literal">source_attachment</span> / <span class="docutils literal">target_attachment</span> (each
default <span class="docutils literal">none</span>). Route records have no separate UUID or alias. An edge occurs
at most once in a diagram's route list; different diagrams MAY store independent
routes for the same edge. List order and explicit empty entries are retained by
source and JSON round trips, although list order has no routing meaning. Edges
need not currently be visible in their diagram.</p>
<pre class="code text literal-block"><code>routes [
{ edge &#64;report_exchange;
source_attachment { side Bottom; offset 0.25; };
waypoints [{ x 420; y 260; }, { x 690; y 260; }];
label { fraction 0.55; offset { x 0; y -16; }; };
}
@@ -1809,6 +1811,47 @@ are structural errors. A pinned point that obstructs routing remains stored;
geometric routing difficulty is reported separately from structural validity.
Each EdgeRoute counts as one record against <span class="docutils literal">ParseLimits.max_records</span>; pinned
points are bounded by the per-route limit and aggregate input byte limits.</p>
<p>An attachment is an EdgeAttachment record with required <span class="docutils literal">side</span> (Left, Right,
Top, Bottom) and required finite binary32 <span class="docutils literal">offset</span> from zero through one.
Unknown fields, omitted nested fields and out-of-range offsets are structural
errors (<span class="docutils literal">invalid_attachment_offset</span> for the latter). Attachments are bounded
inline values, at most two per route, not additional graph objects or records.
JSON uses the same field names, enum strings and number values. Omitted or null
attachments mean automatic positioning; canonical compact source and JSON omit
absent attachments. Earlier schema-3 input hydrates both fields as absent.</p>
<p>The end names designate stored source/target slots, even for undirected edges.
These values MUST NOT change semantic endpoints, ports, ownership, type,
orientation or identity. Each preference belongs to that edge end in that view:
another view remains independent. It follows the current endpoint's projected
boundary under movement, resize or semantic reconnect; reconnect preserves the
preference, as it preserves authored bends. The fraction does not identify the
previous semantic endpoint. Deletion/reidentification of the edge follows the
existing whole-route rules; moving, hiding or replacing its placement retains
the preference. Removing one attachment restores that end's automatic position
without changing bends, labels or the opposite attachment.</p>
<p>For Left/Right, offset gives the Y fraction of the complete projected bounding
box; for Top/Bottom it gives the X fraction. The attachment intersects the painted
shape along that cardinal scan line. Rounded cards use radius 10, limited by half
their width/height; Regions and fork/join bars are rectangles. State circles use
their ellipse boundary and Choice uses its diamond boundary. More precisely, let
<span class="docutils literal">t = 2 * offset - 1</span> and <span class="docutils literal">D</span> be width for Left/Right or height for Top/Bottom.
The inset from the selected bounding side is <span class="docutils literal">D/2 * (1 - <span class="pre">sqrt(1-t*t))</span></span> for an
ellipse and <span class="docutils literal">D/2 * abs(t)</span> for a diamond. A Terminate X uses the outermost
painted-stroke intersection, inset <span class="docutils literal">D * min(offset, <span class="pre">1-offset)</span></span>. Zero and one
are valid; different side/fraction pairs can describe the same painted point.
Routing leads extend in the selected cardinal direction beyond the bounding box
before turning. Source/API fractions retain their precision; pointer editing
snaps fractions to hundredths without changing the stored semantic record.</p>
<p>An actual semantic Port remains authoritative, including when projection hides
its port marker. An edge attachment on that end is retained but dormant; use
the port's own placement anchor to move a port. When folding promotes an endpoint
to an ancestor, all authored route data is suspended and resumes on expansion.
Geometric difficulty MUST NOT discard preferences. Coincident authored self-loop
tips retain their positions with an external loop and a routing warning; the
editor must offer an explicit way to choose either end even when handles overlap.
Internal state transitions have no external connection. Process's generated
topology and Scenario's lifeline/activation/time-derived tips are not consumers
of these diagram route fields; this feature does not redefine their semantics.</p>
<p><span class="docutils literal">SetEdgeRoute { diagram, route }</span> upserts an entry by edge identity, retaining
the order and contents of unrelated entries. <span class="docutils literal">RemoveEdgeRoute { diagram, edge }</span>
removes it; removing an absent entry is a no-op for an existing diagram and edge.
+49 -2
View File
@@ -1167,8 +1167,9 @@ its authored sides and fractions. Frozen schema-1/schema-2 readers do not accept
``port_anchors``; their explicit migration supplies an empty list.
Each ``routes`` entry is an EdgeRoute record with a required ``edge`` reference
to an existing Relation or Trace, ``waypoints`` (default ``[]``), and ``label``
(default ``none``). Route records have no separate UUID or alias. An edge occurs
to an existing Relation or Trace, ``waypoints`` (default ``[]``), ``label``
(default ``none``), and ``source_attachment`` / ``target_attachment`` (each
default ``none``). Route records have no separate UUID or alias. An edge occurs
at most once in a diagram's route list; different diagrams MAY store independent
routes for the same edge. List order and explicit empty entries are retained by
source and JSON round trips, although list order has no routing meaning. Edges
@@ -1178,6 +1179,7 @@ need not currently be visible in their diagram.
routes [
{ edge @report_exchange;
source_attachment { side Bottom; offset 0.25; };
waypoints [{ x 420; y 260; }, { x 690; y 260; }];
label { fraction 0.55; offset { x 0; y -16; }; };
}
@@ -1196,6 +1198,51 @@ geometric routing difficulty is reported separately from structural validity.
Each EdgeRoute counts as one record against ``ParseLimits.max_records``; pinned
points are bounded by the per-route limit and aggregate input byte limits.
An attachment is an EdgeAttachment record with required ``side`` (Left, Right,
Top, Bottom) and required finite binary32 ``offset`` from zero through one.
Unknown fields, omitted nested fields and out-of-range offsets are structural
errors (``invalid_attachment_offset`` for the latter). Attachments are bounded
inline values, at most two per route, not additional graph objects or records.
JSON uses the same field names, enum strings and number values. Omitted or null
attachments mean automatic positioning; canonical compact source and JSON omit
absent attachments. Earlier schema-3 input hydrates both fields as absent.
The end names designate stored source/target slots, even for undirected edges.
These values MUST NOT change semantic endpoints, ports, ownership, type,
orientation or identity. Each preference belongs to that edge end in that view:
another view remains independent. It follows the current endpoint's projected
boundary under movement, resize or semantic reconnect; reconnect preserves the
preference, as it preserves authored bends. The fraction does not identify the
previous semantic endpoint. Deletion/reidentification of the edge follows the
existing whole-route rules; moving, hiding or replacing its placement retains
the preference. Removing one attachment restores that end's automatic position
without changing bends, labels or the opposite attachment.
For Left/Right, offset gives the Y fraction of the complete projected bounding
box; for Top/Bottom it gives the X fraction. The attachment intersects the painted
shape along that cardinal scan line. Rounded cards use radius 10, limited by half
their width/height; Regions and fork/join bars are rectangles. State circles use
their ellipse boundary and Choice uses its diamond boundary. More precisely, let
``t = 2 * offset - 1`` and ``D`` be width for Left/Right or height for Top/Bottom.
The inset from the selected bounding side is ``D/2 * (1 - sqrt(1-t*t))`` for an
ellipse and ``D/2 * abs(t)`` for a diamond. A Terminate X uses the outermost
painted-stroke intersection, inset ``D * min(offset, 1-offset)``. Zero and one
are valid; different side/fraction pairs can describe the same painted point.
Routing leads extend in the selected cardinal direction beyond the bounding box
before turning. Source/API fractions retain their precision; pointer editing
snaps fractions to hundredths without changing the stored semantic record.
An actual semantic Port remains authoritative, including when projection hides
its port marker. An edge attachment on that end is retained but dormant; use
the port's own placement anchor to move a port. When folding promotes an endpoint
to an ancestor, all authored route data is suspended and resumes on expansion.
Geometric difficulty MUST NOT discard preferences. Coincident authored self-loop
tips retain their positions with an external loop and a routing warning; the
editor must offer an explicit way to choose either end even when handles overlap.
Internal state transitions have no external connection. Process's generated
topology and Scenario's lifeline/activation/time-derived tips are not consumers
of these diagram route fields; this feature does not redefine their semantics.
``SetEdgeRoute { diagram, route }`` upserts an entry by edge identity, retaining
the order and contents of unrelated entries. ``RemoveEdgeRoute { diagram, edge }``
removes it; removing an absent entry is a no-op for an existing diagram and edge.
+45 -1
View File
@@ -2137,15 +2137,59 @@
],
"additionalProperties": false
},
"EdgeAttachment": {
"type": "object",
"description": "Per-view preference on one stored connection end, not a semantic endpoint. Offset is finite and measured along the complete projected bounding-box side. Real semantic ports take precedence; hidden/promoted geometry retains the preference. Reconnect preserves the preference on its source/target slot.",
"properties": {
"side": {
"$ref": "#/components/schemas/PortAnchorSide"
},
"offset": {
"type": "number",
"minimum": 0,
"maximum": 1,
"description": "Left/Right: top-to-bottom fraction. Top/Bottom: left-to-right fraction. Shape-aware geometry derives the actual contour point."
}
},
"required": [
"side",
"offset"
],
"additionalProperties": false
},
"EdgeRoute": {
"type": "object",
"description": "One diagram's authored route for an existing Relation or Trace, including currently hidden edges. No separate identity. Only pinned points are stored; explicit empty routes are retained.",
"description": "One diagram's authored presentation for an existing Relation or Trace, including currently hidden edges. No separate identity. Stores optional endpoint preferences, pinned points and label placement; explicit empty routes are retained. Removing the route resets all authored presentation.",
"properties": {
"edge": {
"type": "string",
"format": "uuid",
"description": "Existing Relation or Trace identity, never a node placement."
},
"source_attachment": {
"oneOf": [
{
"$ref": "#/components/schemas/EdgeAttachment"
},
{
"type": "null"
}
],
"default": null,
"description": "Absent or null means automatic. Omitted when serializing automatic presentation. Resetting this field preserves the other end, pins and label."
},
"target_attachment": {
"oneOf": [
{
"$ref": "#/components/schemas/EdgeAttachment"
},
{
"type": "null"
}
],
"default": null,
"description": "Absent or null means automatic. Omitted when serializing automatic presentation. Resetting this field preserves the other end, pins and label."
},
"waypoints": {
"type": "array",
"items": {
+9
View File
@@ -456,3 +456,12 @@ Missing global and scoped parameter IDs remain explicit stabilization metadata,
not new random identities assigned on each parse. Public source8 integration must
thread the same accounting through the outer project and included files; this
private checkpoint does not itself admit a complete version8 source bundle.
## Connection-end presentation preferences — 2026-09-22
Issue #33 uses optional side/fraction attachments on each view's `EdgeRoute`,
with semantic Port precedence and shared shape-aware routing. Visual attachment
editing remains separate from semantic reconnect. The [decision and evidence
map](edge-attachments-20260922.md) records alternatives, lifecycle rules,
independent falsifiers, evaluators and the Process/Scenario applicability limits.
This is branch-specific work; it does not establish an integrated release.
+103
View File
@@ -0,0 +1,103 @@
# Connection attachment editing — issue 33
Branch-specific work from `3f9e6a18af96b5cd10940da4093be8da28b9c835` on
`codex/edge-anchors-ytr83r-20260922`, owned by Codex-ytr83r/root.
[Live scope and handoff](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/33)
remain in Gitea. This is not an integrated release checkpoint.
The user needs to move where a connection touches an element without changing
the relationship. The existing ordinary route editor offered bends and labels;
unported ends were selected from four side midpoints. SA semantic ports already
had per-placement anchors. State endpoint handles instead performed semantic
reconnect. These are different operations and remain distinct.
## Decision and alternatives
Store optional `source_attachment` and `target_attachment` values on `EdgeRoute`.
Each contains a side and normalized fraction, interpreted against the actual
projected shape. No additional semantic object, ID or reference is introduced.
The precise format, geometry, Port precedence, folding, reconnect and reset rules
are in [the normative language contract](../docs/language.rst); REST uses the
same data and existing `SetEdgeRoute` transaction.
Compared alternatives:
- Absolute endpoint coordinates are easy to store but become detached from the
owner after movement/resizing and duplicate derived geometry.
- Reusing semantic Port anchors would invent ports for OA/state relationships
and conflate each connection's presentation with a shared port's identity.
- Binding an attachment to an additional semantic endpoint/placement reference
would preserve the old owner's identity after reconnect, but needs stale
reference cleanup and a dormant preference lifecycle without a current user
need. The selected rule retains the edge-end preference across reconnect,
just as authored bends are retained; it is documented, not inferred silently.
Fractions remain precise in source/API; pointer edits snap to hundredths.
Automatic absence and authored values are distinct. Press/release without a
drag, Escape, stale context, focus/camera/layout change and outside release do
not author a preference. Returning within three screen pixels of the press
restores the exact initial optional value, including its original precision.
Successful editing changes one field in one route in one view in one history
operation. Per-end reset preserves the other end, bends and label.
Ordinary diagrams expose eligible selected-end handles and explicit source/target
menu choice for overlapping handles. State menus expose separately labelled
Adjust attachment and Reconnect actions; selecting a transition still retains
the existing reconnect gesture. Ports keep their existing movement policy.
This work does not adopt or alter the separate #16 resize/port-capacity policy.
## Review-driven corrections and evaluators
Independent contract review identified projection-filtered Port markers as an
unsafe authority: eligibility and routing now consult semantic endpoint identity.
Attachment metadata participates in the route-preview cache key. Inset contours
use actual lead lengths for path costs and pin fractions; shape scan-line
intersections are shared by routing and pointer gestures. Side choice uses the
nearest cardinal bounding segment, avoiding coincident circle scan-line points
that otherwise retain the wrong exit direction. Hysteresis is capped by symbol
size so small circles can change sides at low zoom; an explicit regression
covers midpoint, overshoot and near-corner intent.
The first frozen gate exposed a remaining overshoot case: distances measured
from far outside a small symbol converged, keeping the previous perpendicular
side. Measuring side intent at the cursor clamped to the bounding box preserves
the intended side under overshoot. The failed gate is retained separately.
Valid Port-to-Port Item traces also omit projected port IDs; routing now resolves
the semantic port before looking up its visible, owner-qualified marker. An
actual trace regression checks moved anchors and unchanged trace metadata.
Coincident authored self ends previously threatened an empty candidate list.
They retain their positions and receive an explicit loop warning. A critic then
showed a fixed invented detour point could collide with a neighboring card even
though the opposite side was clear. Computed detours now consider both directions
and multiple bounded extents, preferring clear candidates. These points never
become persisted hard pins. Geometry tests exercise blockers on either side.
Cross-review found ordinary attachment drags survived camera/focus/layout
changes and outside release. They now carry a viewport stamp and cancel on those
changes, with an actual pointer-event cancellation matrix. The same review
reconciled return-to-press behavior across ordinary and State canvases.
Meaningful evaluators are `oa-core` integration tests `attachment_geometry`,
`attachment_lifecycle`, `attachment_trace_ports`, `attachment_workshop`,
`edge_attachments` and the existing routing/port/state suites; desktop ordinary
attachment and State pointer/planner tests; server `http::edge_attachments`
conditional transactions, exact source exports and restart. Tests compare complete
projects/source where applicable, not just attachment fields. Native acceptance
uses the [fixture and workflow plan](experiments/edge-attachments-20260922/native-plan.md).
Final test/native results and exact tested commit belong in the publication
handoff, not an unverified claim in this decision record.
## Applicability and integration limits
Process topology uses generated cards and qualified contextual addresses.
Scenario endpoints derive from lifelines, activations, lifecycle and time/order
notation. Neither renderer consumes ordinary `Diagram.routes`; their visual
editing requires separate notation-aware contracts. This change does not claim
to implement arbitrary attachment dragging there, or to resolve automatic
arrangement (#32) or State bend editing (#23).
The base includes ordinary Text work, not the separately published Scenario
spacing/inspector branches or the latest transition lifecycle branch. Integration
owner #27 must compose actual pushed commits and verify the combined result.
Linux/WSL verification remains paused by the user.
@@ -0,0 +1,53 @@
// Immutable acceptance baseline: copy before editing. Stable IDs are deliberate.
arcadia 7;
project endpoint_acceptance "Harbor dispatch — attachment acceptance" id "ea202922-0033-4000-8000-000000000001" {
description "Small native fixture for presentation-only endpoint editing. Names describe stored source-to-target direction.";
oa_activity receive_report "Receive distress report" id "ea202922-0033-4000-8000-000000000010" { }
oa_activity dispatch_crew "Dispatch rescue crew" id "ea202922-0033-4000-8000-000000000011" { }
interaction dispatch_request "Dispatch request: Receive → Dispatch" id "ea202922-0033-4000-8000-000000000020" { source @receive_report; target @dispatch_crew; description "Semantic sentinel: retain this description and direction."; }
sa_system controller "Rescue readiness controller" id "ea202922-0033-4000-8000-000000000030" { }
sa_state_machine readiness "Rescue readiness" id "ea202922-0033-4000-8000-000000000031" { parent @controller; }
sa_region main_region "Readiness region" id "ea202922-0033-4000-8000-000000000032" { parent @readiness; }
sa_initial start "Startup" id "ea202922-0033-4000-8000-000000000033" { parent @main_region; }
sa_state ready "Ready" id "ea202922-0033-4000-8000-000000000034" { parent @main_region; }
sa_state active "Active" id "ea202922-0033-4000-8000-000000000035" { parent @main_region; }
sa_choice decision "Dispatch decision" id "ea202922-0033-4000-8000-000000000036" { parent @main_region; }
sa_final finished "Shutdown complete" id "ea202922-0033-4000-8000-000000000037" { parent @main_region; }
state_transition initialize "Initialize: Startup → Ready" id "ea202922-0033-4000-8000-000000000040" { source @start; target @ready; transition_kind External; triggers []; trigger ""; guard ""; effects []; effect ""; }
state_transition activate "Activate: Ready → Active" id "ea202922-0033-4000-8000-000000000041" { source @ready; target @active; transition_kind External; triggers []; trigger ""; guard "crew_assigned"; effects []; effect ""; }
state_transition assess "Assess: Active → Decision" id "ea202922-0033-4000-8000-000000000042" { source @active; target @decision; transition_kind External; triggers []; trigger ""; guard ""; effects []; effect ""; }
state_transition retry "Retry: Decision → Ready" id "ea202922-0033-4000-8000-000000000043" { source @decision; target @ready; transition_kind External; triggers []; trigger ""; guard "retry_requested"; effects []; effect ""; }
state_transition finish "Finish: Decision → Shutdown" id "ea202922-0033-4000-8000-000000000044" { source @decision; target @finished; transition_kind External; triggers []; trigger ""; guard "else"; effects []; effect ""; }
state_transition refresh "Refresh: Active → Active" id "ea202922-0033-4000-8000-000000000045" { source @active; target @active; transition_kind External; triggers []; trigger ""; guard "refresh_requested"; effects []; effect ""; }
state_transition internal_check "Internal status check" id "ea202922-0033-4000-8000-000000000046" { source @active; target @active; transition_kind Internal; triggers []; trigger ""; guard ""; effects []; effect "Status unchanged"; }
sa_function emit_status "Emit readiness status" id "ea202922-0033-4000-8000-000000000050" { }
sa_function display_status "Display readiness status" id "ea202922-0033-4000-8000-000000000051" { }
sa_port status_out "Status output" id "ea202922-0033-4000-8000-000000000052" { parent @emit_status; contract { FunctionOutput { outgoing_items []; }; }; }
sa_port status_in "Status input" id "ea202922-0033-4000-8000-000000000053" { parent @display_status; contract { FunctionInput { incoming_items []; }; }; }
functional_exchange status_exchange "Readiness status: Emit → Display" id "ea202922-0033-4000-8000-000000000054" { source @emit_status; target @display_status; source_port @status_out; target_port @status_in; items []; }
diagram dispatch_view "01 Dispatch — edit attachments" id "ea202922-0033-4000-8000-000000000100" { layer Oa; projection FunctionalDataflow; routes [{ edge @dispatch_request; waypoints [{ x 440; y 340; }]; label { fraction 0.5; offset { x 12; y -18; }; }; source_attachment { side Right; offset 0.5; }; target_attachment { side Left; offset 0.6; }; }]; }
diagram dispatch_reference "02 Dispatch — untouched reference" id "ea202922-0033-4000-8000-000000000101" { layer Oa; projection FunctionalDataflow; routes [{ edge @dispatch_request; target_attachment { side Bottom; offset 0.25; }; }]; }
node dispatch_view_receive id "ea202922-0033-4000-8000-000000000110" { diagram @dispatch_view; element @receive_report; position { x 100; y 180; }; size { width 240; height 120; }; collapsed false; }
node dispatch_view_dispatch id "ea202922-0033-4000-8000-000000000111" { diagram @dispatch_view; element @dispatch_crew; position { x 600; y 180; }; size { width 240; height 120; }; collapsed false; }
node dispatch_reference_receive id "ea202922-0033-4000-8000-000000000120" { diagram @dispatch_reference; element @receive_report; position { x 100; y 180; }; size { width 240; height 120; }; collapsed false; }
node dispatch_reference_dispatch id "ea202922-0033-4000-8000-000000000121" { diagram @dispatch_reference; element @dispatch_crew; position { x 600; y 180; }; size { width 240; height 120; }; collapsed false; }
diagram readiness_view "03 Readiness — edit attachments" id "ea202922-0033-4000-8000-000000000200" { layer Sa; projection { StateMachine { machine @readiness; }; }; routes [{ edge @activate; waypoints [{ x 500; y 310; }]; label { fraction 0.5; offset { x 8; y -16; }; }; source_attachment { side Right; offset 0.5; }; target_attachment { side Left; offset 0.6; }; }, { edge @initialize; source_attachment { side Right; offset 0.5; }; }, { edge @finish; source_attachment { side Right; offset 0.5; }; }]; }
diagram readiness_reference "04 Readiness — untouched reference" id "ea202922-0033-4000-8000-000000000201" { layer Sa; projection { StateMachine { machine @readiness; }; }; routes []; }
node readiness_view_readiness id "ea202922-0033-4000-8000-000000000210" { diagram @readiness_view; element @readiness; position { x 40; y 40; }; size { width 980; height 590; }; collapsed false; }
node readiness_view_main_region id "ea202922-0033-4000-8000-000000000211" { diagram @readiness_view; element @main_region; position { x 64; y 104; }; size { width 932; height 500; }; collapsed false; }
node readiness_view_start id "ea202922-0033-4000-8000-000000000212" { diagram @readiness_view; element @start; position { x 110; y 252; }; size { width 20; height 20; }; collapsed false; }
node readiness_view_ready id "ea202922-0033-4000-8000-000000000213" { diagram @readiness_view; element @ready; position { x 190; y 210; }; size { width 240; height 120; }; collapsed false; }
node readiness_view_active id "ea202922-0033-4000-8000-000000000214" { diagram @readiness_view; element @active; position { x 570; y 210; }; size { width 240; height 140; }; collapsed false; }
node readiness_view_decision id "ea202922-0033-4000-8000-000000000215" { diagram @readiness_view; element @decision; position { x 670; y 430; }; size { width 40; height 40; }; collapsed false; }
node readiness_view_finished id "ea202922-0033-4000-8000-000000000216" { diagram @readiness_view; element @finished; position { x 880; y 440; }; size { width 24; height 24; }; collapsed false; }
node readiness_reference_readiness id "ea202922-0033-4000-8000-000000000230" { diagram @readiness_reference; element @readiness; position { x 40; y 40; }; size { width 980; height 590; }; collapsed false; }
node readiness_reference_main_region id "ea202922-0033-4000-8000-000000000231" { diagram @readiness_reference; element @main_region; position { x 64; y 104; }; size { width 932; height 500; }; collapsed false; }
node readiness_reference_start id "ea202922-0033-4000-8000-000000000232" { diagram @readiness_reference; element @start; position { x 110; y 212; }; size { width 20; height 20; }; collapsed false; }
node readiness_reference_ready id "ea202922-0033-4000-8000-000000000233" { diagram @readiness_reference; element @ready; position { x 190; y 170; }; size { width 240; height 120; }; collapsed false; }
node readiness_reference_active id "ea202922-0033-4000-8000-000000000234" { diagram @readiness_reference; element @active; position { x 570; y 170; }; size { width 240; height 140; }; collapsed false; }
node readiness_reference_decision id "ea202922-0033-4000-8000-000000000235" { diagram @readiness_reference; element @decision; position { x 670; y 430; }; size { width 40; height 40; }; collapsed false; }
node readiness_reference_finished id "ea202922-0033-4000-8000-000000000236" { diagram @readiness_reference; element @finished; position { x 880; y 440; }; size { width 24; height 24; }; collapsed false; }
diagram ports_view "05 Status — semantic ports regression" id "ea202922-0033-4000-8000-000000000300" { layer Sa; projection FunctionalDataflow; routes []; }
node ports_emit id "ea202922-0033-4000-8000-000000000310" { diagram @ports_view; element @emit_status; position { x 100; y 180; }; size { width 240; height 120; }; collapsed false; port_anchors [{ port @status_out; side Right; offset 0.5; }]; }
node ports_display id "ea202922-0033-4000-8000-000000000311" { diagram @ports_view; element @display_status; position { x 600; y 180; }; size { width 240; height 120; }; collapsed false; port_anchors [{ port @status_in; side Left; offset 0.5; }]; }
}
@@ -0,0 +1,70 @@
# Native endpoint attachment acceptance
This is a prepared plan, not a record of passed native checks. `native-fixture.arcadia` is a deliberately small schema-7 source fixture with 49 explicit stable UUIDs. The parent must parse and validate it with the candidate before staging a working copy. No GUI, compilation, or evaluator has been run for this preparation.
## Bind the run
Record the exact candidate commit, source/worktree cleanliness, GUI/runtime hashes, successful gate receipts, fixture hash and recipe hash. Keep an immutable baseline and a separate mutable working source; use an isolated profile. Record launch, normal close, stdout/stderr, saved checkpoints, and captures. Compile baseline and checkpoints to full typed Project values with the same pinned source evaluator. Use a baseline-derived expected Project with only the named field changes; compare the entire Project, not a JSON subset or screenshot. Source text formatting may differ after save; source must parse to the exact expected Project. Save/reopen comparisons must use the same expected Project. Preserve raw files and integer precision.
The fixture contains two OA activities and their one interaction, one small SA state machine, and a separate two-function semantic-port regression view. Its controller is semantic ownership, not a large canvas placement. The State view places the machine, region, and all five vertices. Two saved views per edited graph expose view-isolation failures. View 05 is separate to keep the main workflows uncluttered.
## Identities and geometry
Every ID has prefix `ea202922-0033-4000-8000-`; the twelve-digit suffix below is decimal-padded. The source file is authoritative for all fields.
| Object / alias | ID suffix | Purpose |
|---|---:|---|
| `dispatch_request` | 20 | Stored source `receive_report` (10), target `dispatch_crew` (11) |
| `dispatch_view` / `dispatch_reference` | 100 / 101 | Edited OA view / untouched OA view |
| `readiness` / `main_region` | 31 / 32 | Machine / region membership |
| `start`, `ready`, `active`, `decision`, `finished` | 33–37 | Initial, two stable states, choice, final |
| `activate` | 41 | Stored Ready source → Active target |
| `initialize` / `finish` | 40 / 44 | Circle-source / diamond-source contour checks |
| `refresh` / `internal_check` | 45 / 46 | External self-transition / internal row |
| `readiness_view` / `readiness_reference` | 200 / 201 | Edited State view / untouched State view |
| `status_exchange` / `ports_view` | 54 / 300 | Real semantic ports regression |
Coordinates below are **diagram coordinates**, not assumed native screen coordinates. After Fit, identify bounds and zoom/pan from actual UI; map `window = canvas origin + pan + zoom × diagram`. State canvas includes a guidance strip in its offset. Confirm painted handles before pressing; retain screenshots documenting the mapping. Source and target describe stored ends, regardless of arrow appearance.
| View / endpoint | Baseline attachment / contour point | Intended destination |
|---|---|---|
| OA `dispatch_request` source | Right 0.5 → (340,240) | Top 0.25 → (160,180) |
| OA `dispatch_request` target | Left 0.6 → (600,252) | Bottom 0.75 → (780,300) |
| State `activate` source | Right 0.5 → (430,270) | Top 0.25 → (250,210) |
| State `activate` target | Left 0.6 → (570,294) | Bottom 0.75 → (750,350) |
| State `initialize` initial source | Right 0.5 → (126,260) | Top 0.5 → (118,252) |
| State `finish` choice source | Right 0.5 → (698,444) | Top 0.5 → (684,430) |
| Real `status_out` / `status_in` | Right 0.5 → (340,240) / Left 0.5 → (600,240) | No attachment editing permitted |
For rounded corners, the quarter-width points are on the straight section. Circle and diamond checks intentionally use contour extrema with unambiguous 0.5 offsets. Native drag input may quantize to adjacent hundredths: if the intended fraction is not attained, do not change the expected oracle to match output. Repeat from the prior checkpoint using the painted geometry, or record the observed limitation and narrow the claim.
## OA workflow and exact expectations
1. Open view **01 Dispatch — edit attachments** and Fit. Select `Dispatch request` on its line. Confirm distinct endpoint handles and context actions. Save baseline checkpoint `00` before edits; full Project equals baseline B.
2. Drag source handle from (340,240) to (160,180), inspect live preview and release. Save `01`. Expected O1 = B with **only** `diagrams[100].routes[edge20].source_attachment = {Top,0.25}`. Target sentinel `{Left,0.6}`, waypoint `(440,340)`, label `{fraction:0.5,offset:(12,-18)}`, relation description/direction, nodes and all other views remain exact.
3. Undo and save `02`: B exactly. Redo and save `03`: O1 exactly. One Undo must remove the entire drag, not a partial movement.
4. Drag target to (780,300), save `04`: O2 = O1 with only target `{Bottom,0.75}`. Open source and target context actions to ensure end identity is clear. Use **Reset source attachment**, save `05`: O3 = O2 with source None; preserve target, waypoint and label. Undo returns O2, redo returns O3.
5. Start a drag, move away and return within three screen pixels of the press, then release: no source/history change. Repeat a drag canceled with Escape; repeat with right click, middle pan, wheel/camera change, focus loss, and release outside. Save a checkpoint after each meaningful cancellation; expect O3 exactly. Camera-only state is outside Project. After these checks, one Undo still reverses the last real reset (O2), showing no hidden history entry; Redo restores O3.
6. Open view **02 Dispatch — untouched reference**: its independent target `{Bottom,0.25}` and placements remain baseline. Return to 01, save, close normally, reopen the saved working file and save `06`: O3 exactly. Confirm the saved source contains the attachment and existing waypoint/label.
## State workflow and exact expectations
Continue from saved O3 so the State trial also proves preservation of the prior OA edits. The frozen expected catalog composes State changes on O3; B remains the unchanged comparison for view 201.
1. Open **Rescue readiness** in Behavior / State diagram and choose **03 Readiness — edit attachments**. Check source-defined membership is visible, with Ready and Active readable and no nested hierarchy. Select `Activate`; choose **Adjust source attachment** from its actions, then drag the highlighted source to (250,210). Save `S01`: S1 = O3 with only `diagrams[200].routes[edge41].source_attachment={Top,0.25}`. Preserve target `{Left,0.6}`, waypoint `(500,310)`, label `{fraction:0.5,offset:(8,-16)}`, all semantic transitions and view 201.
2. Undo → O3, redo → S1. Adjust target to (750,350), save `S02`: S2 adds only target `{Bottom,0.75}`. Reset target and save `S03`: S3 has target None, preserves source/bend/label. Cancel an attachment gesture with Escape and perform away-return and wheel interruption; checkpoints remain S3. View **04 Readiness — untouched reference** stays B exactly.
3. Choose `Initialize` → **Adjust source attachment**, drag the initial circle source to (118,252), save `S04`: S4 = S3 with only route edge40 source `{Top,0.5}`. Choose `Finish` → Adjust source, drag the choice source to (684,430), save `S05`: S5 = S4 with only route edge44 source `{Top,0.5}`. Capture both painted contours; the typed checkpoint is the persistence oracle. Undo/redo each as practical. Save/normal-close/reopen final checkpoint: S5 exactly.
4. Inspect external `Refresh: Active → Active`: both Adjust source and Adjust target must be discoverable and identify the corresponding end even on a self-transition. Inspect `Internal status check` row: it must not offer external attachment handles/actions. Do not infer every symbol was exercised from these checks.
5. **Separate reversible reconnect trial from S5:** with Select / move, drag the selected `Activate` target handle from Active to `Dispatch decision` (or use explicitly labelled Reconnect target). Expected R1 differs from S5 only in relation41's semantic target: 35 → 36. Route presentation fields and every other relation field remain unchanged. Undo must restore S5. This distinguishes semantic reconnect from Adjust; do not mix its checkpoint with S5. If cardinality/validity blocks the trial, record it and do not claim reconnect success without a valid alternative approved against source rules.
## Real semantic ports regression
Open **05 Status — semantic ports regression**. Select the `Readiness status` functional exchange. Both attachment Move/Reset actions must be disabled or absent because source_port52 and target_port53 remain authoritative. Drag Status output to its owner's Top/0.25 position through the established port-layout interaction, then Undo. That trial may change only the emitting node's port_anchor and must restore S5 exactly on Undo; relation/source_port/target_port identities must never change. No edge-attachment record should be created by port dragging. Record UI hit selection and full saved Project, then leave this trial out of the final OA/State checkpoint.
## Acceptance and limits
Require exact full-Project checks for every named checkpoint, normal-close/reopen retention, source/target discoverability, useful previews, and cancellation without hidden history. Inspect native fit/readability and any port/label/handle overlap; reduce claim or fix defects rather than interpreting screenshots as model proof. This fixture does not establish Process/Scenario authoring support, all state symbols, every projection, promoted/folded endpoints, continuous held-motion performance, or exact camera persistence. Those require separate evidence; targeted automated tests remain complementary. Runtime source parsing/validation and native execution are pending parent gates.
### Prepared layout clearance correction
The edited State view places Ready and Active at y=210 and Startup at y=252. The reference view intentionally retains its earlier positions. Region header clearance reaches y=180 (104 + 64 + 12); the planned Ready top lead now ends at y=192 (210 - 18), above the card but below the header obstacle. This changes only the three edited-view placements; the manual waypoint (500,310), both route labels, semantic objects, and reference view are preserved. `crates/oa-core/tests/attachment_workshop.rs` validates the fixture and routes the named edited edges at every planned checkpoint. Test execution is pending the parent gate; no clear-path claim is made from static inspection alone.
@@ -0,0 +1,3 @@
# Preserve the exact pinned evidence and verifier bytes through Git round trips.
* -text whitespace=cr-at-eol
incidents/**/*.arcadia whitespace=-blank-at-eof,cr-at-eol
@@ -0,0 +1,96 @@
# Closed multiselect gate evidence
This package preserves all 19 closed captures selected from
`.local/group-movement-gates`: 14 recorded exit 0, and five recorded exit 101.
Every run reports unchanged captured inputs. The five failures remain failures;
their complete original receipts and streams are included. See the separate
[saved-view report](../../../multiselect-saved-view-20260923.md) for scope and
native observations. These standard-library Python 3.10+ tools do not run Cargo,
Git, archived source or executables.
| Run | Recorded exit | Input files |
| --- | ---: | ---: |
| core-1 | 0 | 722 |
| core-2 | 101 | 729 |
| core-3 | 0 | 729 |
| desktop-1 | 101 | 727 |
| desktop-2 | 101 | 728 |
| desktop-3 | 0 | 729 |
| desktop-4 | 0 | 729 |
| desktop-5 | 0 | 729 |
| format-1 | 0 | 729 |
| format-2 | 0 | 729 |
| lint-1 | 101 | 729 |
| lint-2 | 0 | 729 |
| lint-3 | 0 | 729 |
| native-build-1 | 0 | 729 |
| native-build-2 | 0 | 729 |
| oracle-2 | 101 | 729 |
| oracle-3 | 0 | 729 |
| windows-build-1 | 0 | 729 |
| windows-build-2 | 0 | 729 |
Core-3, desktop-4, format-1, lint-2, native-build-1 and windows-build-1 share the
729-file map `55a5f354bb5049a8c77ba9bafa833438ebf754649b0cf5d175d0c8dcfcedd7bb`.
Desktop-5, format-2, lint-3, native-build-2 and windows-build-2 share the distinct
729-file map `99dc46645236e674244e7c4867595a6000d3d85b54bcc9ab2b0c601923ecf66c`.
The [source identity verifier](../source/README.md) compares those maps with Git
commits `12a7352ddef5f69a144ead8219a50916233d63a9` and
`f6a6010baed9e6164a1fcc7ecd58256a562827d7`, respectively. Earlier maps retain
their own evidence scope; they are not reruns against the later source.
To validate this exact raw run list without writing a package, from the checkout
root run:
```text
python -B harness/experiments/multiselect-20260923/checks/prepare.py --root . --runs core-1 core-2 core-3 desktop-1 desktop-2 desktop-3 desktop-4 desktop-5 format-1 format-2 lint-1 lint-2 lint-3 native-build-1 native-build-2 oracle-2 oracle-3 windows-build-1 windows-build-2 --check-only
```
Missing closure, changed inputs, inconsistent receipts, unavailable exact helpers,
and extra/unexpected run files are refused. Run names must be supplied, must be
unique, and cannot contain path separators.
To create a package, use the same explicit `--runs` list, omit `--check-only`, and
optionally supply `--output PATH`. The default output is this checks directory.
An output package is not overwritten. `--helper PATH` can be repeated to supply
an older gate helper whose exact pinned bytes are no longer the current helper.
Default helper candidates are `.local/group-movement-gates/*.py`.
Verify a completed package anywhere, without the original checkout:
```text
python -B PATH_TO_PACKAGE/verify.py
```
The packager preserves the seven original files per run: started/process/result
receipts, stdout/stderr logs, the input map, and its source ZIP. It preserves the
exact helper bytes named by each receipt. Only byte-identical input maps and ZIPs
are deduplicated into hash-named capsules; their per-run logical mapping remains.
ZIP member ordering and timestamps are fixed. Raw input/log bytes are unchanged.
`manifest.json` records the explicit run list, outcomes, commands, recorded HEADs,
input counts, helper paths, logical mapping and every stored member pin.
`files.json` pins `prepare.py`, `verify.py`, this README, the manifest and archive.
The verifier checks safe exact ZIP membership (including nested source ZIPs),
every recorded byte count/hash, input counts, the 71 vendored iced_widget files,
start/process/finish order, receipt relationships, unchanged-input flags, helper
identity, and exact ordered executable-artifact records parsed from Cargo JSON
stdout. Missing JSON artifact records remain missing; they are not synthesized
from paths or inferred from a successful exit. Non-null artifact readback pins
are checked structurally; executable bytes are not included or independently
verified by this package.
The source capsules are **local historical input snapshots**, including captured
uncommitted work. A recorded HEAD alone does not identify their contents. They
are not an integrated release, a hermetic toolchain distribution, or a claim that
one run's gate applies to a later input map. Commands, paths, PIDs, times and
artifact hashes are historical receipts, not authenticated execution records.
The verifier checks their internal consistency and does not access historical
absolute paths, contact services, extract archives or rerun tests. Pin integrity
is not an external signature or proof against coordinated receipt alteration.
This package has no native interaction, focus, selection, movement, saved-model,
Undo/Redo or full #29 acceptance implication. The native evidence and its
independent evaluator belong in their separate reviewed record. The verification
summary reports captured outcomes, including failures; it does not declare the
feature or all captured gates successful.
@@ -0,0 +1,22 @@
{
"prepare.py": {
"bytes": 4818,
"sha256": "29eedcec091874b26572530517f015ab690ed1a9040abea52248e99603ba5f25"
},
"verify.py": {
"bytes": 8873,
"sha256": "c03513407073bef092d57b968f982b8ffc0c58e095f834fe7464c6a0e8454a9a"
},
"README.md": {
"bytes": 5001,
"sha256": "3f5232af2ebb12003c833e6bc6643b4858950ebf015435a9b850c6a826fe27d0"
},
"manifest.json": {
"bytes": 37083,
"sha256": "c23269443cd059895c672330720a10508ab8ecade0d3686ec3d82ff792c9b457"
},
"evidence.zip": {
"bytes": 25914867,
"sha256": "261660dd8a5e93d917c1d36cad19d1eec0b93e312d1e0dd15f9ea16f5e11c864"
}
}
@@ -0,0 +1,935 @@
{
"schema": 1,
"kind": "multiselect-cargo-gates",
"runs": {
"core-1": {
"command": [
"cargo",
"test",
"--locked",
"--offline",
"-p",
"oa-core",
"--test",
"group_movement",
"--test",
"state_diagrams",
"--",
"--nocapture"
],
"head": "b8685416f56e53e30bd11ecf93a54cea10ac8458",
"exitCode": 0,
"inputCount": 722,
"helper": "helpers/6b8fcf64a1eb0869ae29abd66b4e89564a1813fef5898b54f2a17a3230dbaa06.py"
},
"core-2": {
"command": [
"cargo",
"test",
"--locked",
"--offline",
"-p",
"oa-core",
"--test",
"group_movement",
"--test",
"state_diagrams",
"--example",
"multiselect_native",
"--",
"--nocapture"
],
"head": "b8685416f56e53e30bd11ecf93a54cea10ac8458",
"exitCode": 101,
"inputCount": 729,
"helper": "helpers/6b8fcf64a1eb0869ae29abd66b4e89564a1813fef5898b54f2a17a3230dbaa06.py"
},
"core-3": {
"command": [
"cargo",
"test",
"--locked",
"--offline",
"-p",
"oa-core",
"--test",
"group_movement",
"--test",
"state_diagrams",
"--example",
"multiselect_native",
"--",
"--nocapture"
],
"head": "b8685416f56e53e30bd11ecf93a54cea10ac8458",
"exitCode": 0,
"inputCount": 729,
"helper": "helpers/6b8fcf64a1eb0869ae29abd66b4e89564a1813fef5898b54f2a17a3230dbaa06.py"
},
"desktop-1": {
"command": [
"cargo",
"test",
"--locked",
"--offline",
"-p",
"oa-desktop",
"multiselect",
"--",
"--nocapture"
],
"head": "b8685416f56e53e30bd11ecf93a54cea10ac8458",
"exitCode": 101,
"inputCount": 727,
"helper": "helpers/6b8fcf64a1eb0869ae29abd66b4e89564a1813fef5898b54f2a17a3230dbaa06.py"
},
"desktop-2": {
"command": [
"cargo",
"test",
"--locked",
"--offline",
"-p",
"oa-desktop",
"--",
"--nocapture"
],
"head": "b8685416f56e53e30bd11ecf93a54cea10ac8458",
"exitCode": 101,
"inputCount": 728,
"helper": "helpers/6b8fcf64a1eb0869ae29abd66b4e89564a1813fef5898b54f2a17a3230dbaa06.py"
},
"desktop-3": {
"command": [
"cargo",
"test",
"--locked",
"--offline",
"-p",
"oa-desktop",
"--",
"--nocapture"
],
"head": "b8685416f56e53e30bd11ecf93a54cea10ac8458",
"exitCode": 0,
"inputCount": 729,
"helper": "helpers/6b8fcf64a1eb0869ae29abd66b4e89564a1813fef5898b54f2a17a3230dbaa06.py"
},
"desktop-4": {
"command": [
"cargo",
"test",
"--locked",
"--offline",
"-p",
"oa-desktop",
"--",
"--nocapture"
],
"head": "b8685416f56e53e30bd11ecf93a54cea10ac8458",
"exitCode": 0,
"inputCount": 729,
"helper": "helpers/6b8fcf64a1eb0869ae29abd66b4e89564a1813fef5898b54f2a17a3230dbaa06.py"
},
"desktop-5": {
"command": [
"cargo",
"test",
"--locked",
"--offline",
"-p",
"oa-desktop",
"--",
"--nocapture"
],
"head": "12a7352ddef5f69a144ead8219a50916233d63a9",
"exitCode": 0,
"inputCount": 729,
"helper": "helpers/6b8fcf64a1eb0869ae29abd66b4e89564a1813fef5898b54f2a17a3230dbaa06.py"
},
"format-1": {
"command": [
"cargo",
"fmt",
"--all",
"--",
"--check"
],
"head": "12a7352ddef5f69a144ead8219a50916233d63a9",
"exitCode": 0,
"inputCount": 729,
"helper": "helpers/6b8fcf64a1eb0869ae29abd66b4e89564a1813fef5898b54f2a17a3230dbaa06.py"
},
"format-2": {
"command": [
"cargo",
"fmt",
"--all",
"--",
"--check"
],
"head": "f6a6010baed9e6164a1fcc7ecd58256a562827d7",
"exitCode": 0,
"inputCount": 729,
"helper": "helpers/6b8fcf64a1eb0869ae29abd66b4e89564a1813fef5898b54f2a17a3230dbaa06.py"
},
"lint-1": {
"command": [
"cargo",
"clippy",
"--locked",
"--offline",
"-p",
"oa-core",
"-p",
"oa-desktop",
"--all-targets",
"--",
"-D",
"warnings"
],
"head": "b8685416f56e53e30bd11ecf93a54cea10ac8458",
"exitCode": 101,
"inputCount": 729,
"helper": "helpers/6b8fcf64a1eb0869ae29abd66b4e89564a1813fef5898b54f2a17a3230dbaa06.py"
},
"lint-2": {
"command": [
"cargo",
"clippy",
"--locked",
"--offline",
"-p",
"oa-core",
"-p",
"oa-desktop",
"--all-targets",
"--",
"-D",
"warnings"
],
"head": "b8685416f56e53e30bd11ecf93a54cea10ac8458",
"exitCode": 0,
"inputCount": 729,
"helper": "helpers/6b8fcf64a1eb0869ae29abd66b4e89564a1813fef5898b54f2a17a3230dbaa06.py"
},
"lint-3": {
"command": [
"cargo",
"clippy",
"--locked",
"--offline",
"-p",
"oa-core",
"-p",
"oa-desktop",
"--all-targets",
"--",
"-D",
"warnings"
],
"head": "12a7352ddef5f69a144ead8219a50916233d63a9",
"exitCode": 0,
"inputCount": 729,
"helper": "helpers/6b8fcf64a1eb0869ae29abd66b4e89564a1813fef5898b54f2a17a3230dbaa06.py"
},
"native-build-1": {
"command": [
"cargo",
"build",
"--locked",
"--offline",
"-p",
"oa-core",
"--example",
"multiselect_native",
"--message-format=json"
],
"head": "12a7352ddef5f69a144ead8219a50916233d63a9",
"exitCode": 0,
"inputCount": 729,
"helper": "helpers/6b8fcf64a1eb0869ae29abd66b4e89564a1813fef5898b54f2a17a3230dbaa06.py"
},
"native-build-2": {
"command": [
"cargo",
"build",
"--locked",
"--offline",
"-p",
"oa-core",
"--example",
"multiselect_native",
"--message-format=json"
],
"head": "f6a6010baed9e6164a1fcc7ecd58256a562827d7",
"exitCode": 0,
"inputCount": 729,
"helper": "helpers/6b8fcf64a1eb0869ae29abd66b4e89564a1813fef5898b54f2a17a3230dbaa06.py"
},
"oracle-2": {
"command": [
"cargo",
"test",
"--locked",
"--offline",
"-p",
"oa-core",
"--example",
"multiselect_native",
"--",
"--nocapture"
],
"head": "b8685416f56e53e30bd11ecf93a54cea10ac8458",
"exitCode": 101,
"inputCount": 729,
"helper": "helpers/6b8fcf64a1eb0869ae29abd66b4e89564a1813fef5898b54f2a17a3230dbaa06.py"
},
"oracle-3": {
"command": [
"cargo",
"test",
"--locked",
"--offline",
"-p",
"oa-core",
"--example",
"multiselect_native",
"--",
"--nocapture"
],
"head": "b8685416f56e53e30bd11ecf93a54cea10ac8458",
"exitCode": 0,
"inputCount": 729,
"helper": "helpers/6b8fcf64a1eb0869ae29abd66b4e89564a1813fef5898b54f2a17a3230dbaa06.py"
},
"windows-build-1": {
"command": [
"cargo",
"build",
"--locked",
"--offline",
"-p",
"oa-desktop",
"--bin",
"arcadia-studio",
"--message-format=json"
],
"head": "12a7352ddef5f69a144ead8219a50916233d63a9",
"exitCode": 0,
"inputCount": 729,
"helper": "helpers/6b8fcf64a1eb0869ae29abd66b4e89564a1813fef5898b54f2a17a3230dbaa06.py"
},
"windows-build-2": {
"command": [
"cargo",
"build",
"--locked",
"--offline",
"-p",
"oa-desktop",
"--bin",
"arcadia-studio",
"--message-format=json"
],
"head": "f6a6010baed9e6164a1fcc7ecd58256a562827d7",
"exitCode": 0,
"inputCount": 729,
"helper": "helpers/6b8fcf64a1eb0869ae29abd66b4e89564a1813fef5898b54f2a17a3230dbaa06.py"
}
},
"logicalFiles": {
"cargo/core-1/inputs.json": "capsules/a73d565f90b14c986e0f87a7ac3d368c93e053b7e591cffdfa20d8e81e2eae08.json",
"cargo/core-1/inputs.zip": "capsules/2dcb54b1dd51a05cdd983432b114da8a08dce326c42b6e77a6b8772e75d2282f.zip",
"cargo/core-1/process.json": "cargo/core-1/process.json",
"cargo/core-1/result.json": "cargo/core-1/result.json",
"cargo/core-1/started.json": "cargo/core-1/started.json",
"cargo/core-1/stderr.log": "cargo/core-1/stderr.log",
"cargo/core-1/stdout.log": "cargo/core-1/stdout.log",
"cargo/core-2/inputs.json": "capsules/c5b23cd340869d57bd1bc4400041d499ea06a1a7619a6fd6b51e75278c1755e1.json",
"cargo/core-2/inputs.zip": "capsules/df441a733bf28d1c59eab52063ca4b2645067b490b0a2f0072f98fc9225ea2e8.zip",
"cargo/core-2/process.json": "cargo/core-2/process.json",
"cargo/core-2/result.json": "cargo/core-2/result.json",
"cargo/core-2/started.json": "cargo/core-2/started.json",
"cargo/core-2/stderr.log": "cargo/core-2/stderr.log",
"cargo/core-2/stdout.log": "cargo/core-2/stdout.log",
"cargo/core-3/inputs.json": "capsules/55a5f354bb5049a8c77ba9bafa833438ebf754649b0cf5d175d0c8dcfcedd7bb.json",
"cargo/core-3/inputs.zip": "capsules/e1c7f3abe7b32616d1081b4a2bb8af2d6d4b071bc2a7cbdd61e96a92ffdc0533.zip",
"cargo/core-3/process.json": "cargo/core-3/process.json",
"cargo/core-3/result.json": "cargo/core-3/result.json",
"cargo/core-3/started.json": "cargo/core-3/started.json",
"cargo/core-3/stderr.log": "cargo/core-3/stderr.log",
"cargo/core-3/stdout.log": "cargo/core-3/stdout.log",
"cargo/desktop-1/inputs.json": "capsules/c1083b630ad79ed1fea6933de3221f05b8eb9200694fb0e5dbd637527803579c.json",
"cargo/desktop-1/inputs.zip": "capsules/8d6b08c2b1220cdb48d32cc74004cc9b82e4385f6f50a764f76c4a8630d070bf.zip",
"cargo/desktop-1/process.json": "cargo/desktop-1/process.json",
"cargo/desktop-1/result.json": "cargo/desktop-1/result.json",
"cargo/desktop-1/started.json": "cargo/desktop-1/started.json",
"cargo/desktop-1/stderr.log": "cargo/desktop-1/stderr.log",
"cargo/desktop-1/stdout.log": "cargo/desktop-1/stdout.log",
"cargo/desktop-2/inputs.json": "capsules/5f049dbbee443af86cde70efc74027f290a9bfbd434e5d644aa74ddb4e97566b.json",
"cargo/desktop-2/inputs.zip": "capsules/bf7dc41142e6d087f213b2914e0ffb0e9cd52294f5830301e5e5b78c3470f725.zip",
"cargo/desktop-2/process.json": "cargo/desktop-2/process.json",
"cargo/desktop-2/result.json": "cargo/desktop-2/result.json",
"cargo/desktop-2/started.json": "cargo/desktop-2/started.json",
"cargo/desktop-2/stderr.log": "cargo/desktop-2/stderr.log",
"cargo/desktop-2/stdout.log": "cargo/desktop-2/stdout.log",
"cargo/desktop-3/inputs.json": "capsules/6f8eae7613666bca412e62f67151d6fee9f5bd32fb50363d0ff9b9ac8b190acf.json",
"cargo/desktop-3/inputs.zip": "capsules/cc6233ddec2db0e322659a73a55e1681f5cb3554750ba183a756a29dadf8a8c9.zip",
"cargo/desktop-3/process.json": "cargo/desktop-3/process.json",
"cargo/desktop-3/result.json": "cargo/desktop-3/result.json",
"cargo/desktop-3/started.json": "cargo/desktop-3/started.json",
"cargo/desktop-3/stderr.log": "cargo/desktop-3/stderr.log",
"cargo/desktop-3/stdout.log": "cargo/desktop-3/stdout.log",
"cargo/desktop-4/inputs.json": "capsules/55a5f354bb5049a8c77ba9bafa833438ebf754649b0cf5d175d0c8dcfcedd7bb.json",
"cargo/desktop-4/inputs.zip": "capsules/e1c7f3abe7b32616d1081b4a2bb8af2d6d4b071bc2a7cbdd61e96a92ffdc0533.zip",
"cargo/desktop-4/process.json": "cargo/desktop-4/process.json",
"cargo/desktop-4/result.json": "cargo/desktop-4/result.json",
"cargo/desktop-4/started.json": "cargo/desktop-4/started.json",
"cargo/desktop-4/stderr.log": "cargo/desktop-4/stderr.log",
"cargo/desktop-4/stdout.log": "cargo/desktop-4/stdout.log",
"cargo/desktop-5/inputs.json": "capsules/99dc46645236e674244e7c4867595a6000d3d85b54bcc9ab2b0c601923ecf66c.json",
"cargo/desktop-5/inputs.zip": "capsules/aa8bb3d7fbd1f73c36913235b0b1d3aa79f6ac418b6c07ade94a7dee4a617bd9.zip",
"cargo/desktop-5/process.json": "cargo/desktop-5/process.json",
"cargo/desktop-5/result.json": "cargo/desktop-5/result.json",
"cargo/desktop-5/started.json": "cargo/desktop-5/started.json",
"cargo/desktop-5/stderr.log": "cargo/desktop-5/stderr.log",
"cargo/desktop-5/stdout.log": "cargo/desktop-5/stdout.log",
"cargo/format-1/inputs.json": "capsules/55a5f354bb5049a8c77ba9bafa833438ebf754649b0cf5d175d0c8dcfcedd7bb.json",
"cargo/format-1/inputs.zip": "capsules/e1c7f3abe7b32616d1081b4a2bb8af2d6d4b071bc2a7cbdd61e96a92ffdc0533.zip",
"cargo/format-1/process.json": "cargo/format-1/process.json",
"cargo/format-1/result.json": "cargo/format-1/result.json",
"cargo/format-1/started.json": "cargo/format-1/started.json",
"cargo/format-1/stderr.log": "cargo/format-1/stderr.log",
"cargo/format-1/stdout.log": "cargo/format-1/stdout.log",
"cargo/format-2/inputs.json": "capsules/99dc46645236e674244e7c4867595a6000d3d85b54bcc9ab2b0c601923ecf66c.json",
"cargo/format-2/inputs.zip": "capsules/aa8bb3d7fbd1f73c36913235b0b1d3aa79f6ac418b6c07ade94a7dee4a617bd9.zip",
"cargo/format-2/process.json": "cargo/format-2/process.json",
"cargo/format-2/result.json": "cargo/format-2/result.json",
"cargo/format-2/started.json": "cargo/format-2/started.json",
"cargo/format-2/stderr.log": "cargo/format-2/stderr.log",
"cargo/format-2/stdout.log": "cargo/format-2/stdout.log",
"cargo/lint-1/inputs.json": "capsules/292f6139450109875549801a1da337dac08a8bca4cfa0124139a5d8d042386b9.json",
"cargo/lint-1/inputs.zip": "capsules/d2ec5025067db2c05736bb1c9c9b6250d9199f1b7190e8c09f5a777424790c10.zip",
"cargo/lint-1/process.json": "cargo/lint-1/process.json",
"cargo/lint-1/result.json": "cargo/lint-1/result.json",
"cargo/lint-1/started.json": "cargo/lint-1/started.json",
"cargo/lint-1/stderr.log": "cargo/lint-1/stderr.log",
"cargo/lint-1/stdout.log": "cargo/lint-1/stdout.log",
"cargo/lint-2/inputs.json": "capsules/55a5f354bb5049a8c77ba9bafa833438ebf754649b0cf5d175d0c8dcfcedd7bb.json",
"cargo/lint-2/inputs.zip": "capsules/e1c7f3abe7b32616d1081b4a2bb8af2d6d4b071bc2a7cbdd61e96a92ffdc0533.zip",
"cargo/lint-2/process.json": "cargo/lint-2/process.json",
"cargo/lint-2/result.json": "cargo/lint-2/result.json",
"cargo/lint-2/started.json": "cargo/lint-2/started.json",
"cargo/lint-2/stderr.log": "cargo/lint-2/stderr.log",
"cargo/lint-2/stdout.log": "cargo/lint-2/stdout.log",
"cargo/lint-3/inputs.json": "capsules/99dc46645236e674244e7c4867595a6000d3d85b54bcc9ab2b0c601923ecf66c.json",
"cargo/lint-3/inputs.zip": "capsules/aa8bb3d7fbd1f73c36913235b0b1d3aa79f6ac418b6c07ade94a7dee4a617bd9.zip",
"cargo/lint-3/process.json": "cargo/lint-3/process.json",
"cargo/lint-3/result.json": "cargo/lint-3/result.json",
"cargo/lint-3/started.json": "cargo/lint-3/started.json",
"cargo/lint-3/stderr.log": "cargo/lint-3/stderr.log",
"cargo/lint-3/stdout.log": "cargo/lint-3/stdout.log",
"cargo/native-build-1/inputs.json": "capsules/55a5f354bb5049a8c77ba9bafa833438ebf754649b0cf5d175d0c8dcfcedd7bb.json",
"cargo/native-build-1/inputs.zip": "capsules/e1c7f3abe7b32616d1081b4a2bb8af2d6d4b071bc2a7cbdd61e96a92ffdc0533.zip",
"cargo/native-build-1/process.json": "cargo/native-build-1/process.json",
"cargo/native-build-1/result.json": "cargo/native-build-1/result.json",
"cargo/native-build-1/started.json": "cargo/native-build-1/started.json",
"cargo/native-build-1/stderr.log": "cargo/native-build-1/stderr.log",
"cargo/native-build-1/stdout.log": "cargo/native-build-1/stdout.log",
"cargo/native-build-2/inputs.json": "capsules/99dc46645236e674244e7c4867595a6000d3d85b54bcc9ab2b0c601923ecf66c.json",
"cargo/native-build-2/inputs.zip": "capsules/aa8bb3d7fbd1f73c36913235b0b1d3aa79f6ac418b6c07ade94a7dee4a617bd9.zip",
"cargo/native-build-2/process.json": "cargo/native-build-2/process.json",
"cargo/native-build-2/result.json": "cargo/native-build-2/result.json",
"cargo/native-build-2/started.json": "cargo/native-build-2/started.json",
"cargo/native-build-2/stderr.log": "cargo/native-build-2/stderr.log",
"cargo/native-build-2/stdout.log": "cargo/native-build-2/stdout.log",
"cargo/oracle-2/inputs.json": "capsules/ae16337dbc617e26b23468adb86d963e3bcf7eddf177e8fb4654c69019904548.json",
"cargo/oracle-2/inputs.zip": "capsules/e99bcaf1ab2becbcad0a005bdd0a4eb14fad2075bd7a38433857df4d3cad5e4d.zip",
"cargo/oracle-2/process.json": "cargo/oracle-2/process.json",
"cargo/oracle-2/result.json": "cargo/oracle-2/result.json",
"cargo/oracle-2/started.json": "cargo/oracle-2/started.json",
"cargo/oracle-2/stderr.log": "cargo/oracle-2/stderr.log",
"cargo/oracle-2/stdout.log": "cargo/oracle-2/stdout.log",
"cargo/oracle-3/inputs.json": "capsules/292f6139450109875549801a1da337dac08a8bca4cfa0124139a5d8d042386b9.json",
"cargo/oracle-3/inputs.zip": "capsules/d2ec5025067db2c05736bb1c9c9b6250d9199f1b7190e8c09f5a777424790c10.zip",
"cargo/oracle-3/process.json": "cargo/oracle-3/process.json",
"cargo/oracle-3/result.json": "cargo/oracle-3/result.json",
"cargo/oracle-3/started.json": "cargo/oracle-3/started.json",
"cargo/oracle-3/stderr.log": "cargo/oracle-3/stderr.log",
"cargo/oracle-3/stdout.log": "cargo/oracle-3/stdout.log",
"cargo/windows-build-1/inputs.json": "capsules/55a5f354bb5049a8c77ba9bafa833438ebf754649b0cf5d175d0c8dcfcedd7bb.json",
"cargo/windows-build-1/inputs.zip": "capsules/e1c7f3abe7b32616d1081b4a2bb8af2d6d4b071bc2a7cbdd61e96a92ffdc0533.zip",
"cargo/windows-build-1/process.json": "cargo/windows-build-1/process.json",
"cargo/windows-build-1/result.json": "cargo/windows-build-1/result.json",
"cargo/windows-build-1/started.json": "cargo/windows-build-1/started.json",
"cargo/windows-build-1/stderr.log": "cargo/windows-build-1/stderr.log",
"cargo/windows-build-1/stdout.log": "cargo/windows-build-1/stdout.log",
"cargo/windows-build-2/inputs.json": "capsules/99dc46645236e674244e7c4867595a6000d3d85b54bcc9ab2b0c601923ecf66c.json",
"cargo/windows-build-2/inputs.zip": "capsules/aa8bb3d7fbd1f73c36913235b0b1d3aa79f6ac418b6c07ade94a7dee4a617bd9.zip",
"cargo/windows-build-2/process.json": "cargo/windows-build-2/process.json",
"cargo/windows-build-2/result.json": "cargo/windows-build-2/result.json",
"cargo/windows-build-2/started.json": "cargo/windows-build-2/started.json",
"cargo/windows-build-2/stderr.log": "cargo/windows-build-2/stderr.log",
"cargo/windows-build-2/stdout.log": "cargo/windows-build-2/stdout.log",
"helpers/6b8fcf64a1eb0869ae29abd66b4e89564a1813fef5898b54f2a17a3230dbaa06.py": "helpers/6b8fcf64a1eb0869ae29abd66b4e89564a1813fef5898b54f2a17a3230dbaa06.py"
},
"members": {
"capsules/292f6139450109875549801a1da337dac08a8bca4cfa0124139a5d8d042386b9.json": {
"bytes": 121985,
"sha256": "292f6139450109875549801a1da337dac08a8bca4cfa0124139a5d8d042386b9"
},
"capsules/2dcb54b1dd51a05cdd983432b114da8a08dce326c42b6e77a6b8772e75d2282f.zip": {
"bytes": 2885634,
"sha256": "2dcb54b1dd51a05cdd983432b114da8a08dce326c42b6e77a6b8772e75d2282f"
},
"capsules/55a5f354bb5049a8c77ba9bafa833438ebf754649b0cf5d175d0c8dcfcedd7bb.json": {
"bytes": 121985,
"sha256": "55a5f354bb5049a8c77ba9bafa833438ebf754649b0cf5d175d0c8dcfcedd7bb"
},
"capsules/5f049dbbee443af86cde70efc74027f290a9bfbd434e5d644aa74ddb4e97566b.json": {
"bytes": 121820,
"sha256": "5f049dbbee443af86cde70efc74027f290a9bfbd434e5d644aa74ddb4e97566b"
},
"capsules/6f8eae7613666bca412e62f67151d6fee9f5bd32fb50363d0ff9b9ac8b190acf.json": {
"bytes": 121984,
"sha256": "6f8eae7613666bca412e62f67151d6fee9f5bd32fb50363d0ff9b9ac8b190acf"
},
"capsules/8d6b08c2b1220cdb48d32cc74004cc9b82e4385f6f50a764f76c4a8630d070bf.zip": {
"bytes": 2903240,
"sha256": "8d6b08c2b1220cdb48d32cc74004cc9b82e4385f6f50a764f76c4a8630d070bf"
},
"capsules/99dc46645236e674244e7c4867595a6000d3d85b54bcc9ab2b0c601923ecf66c.json": {
"bytes": 121985,
"sha256": "99dc46645236e674244e7c4867595a6000d3d85b54bcc9ab2b0c601923ecf66c"
},
"capsules/a73d565f90b14c986e0f87a7ac3d368c93e053b7e591cffdfa20d8e81e2eae08.json": {
"bytes": 120844,
"sha256": "a73d565f90b14c986e0f87a7ac3d368c93e053b7e591cffdfa20d8e81e2eae08"
},
"capsules/aa8bb3d7fbd1f73c36913235b0b1d3aa79f6ac418b6c07ade94a7dee4a617bd9.zip": {
"bytes": 2918930,
"sha256": "aa8bb3d7fbd1f73c36913235b0b1d3aa79f6ac418b6c07ade94a7dee4a617bd9"
},
"capsules/ae16337dbc617e26b23468adb86d963e3bcf7eddf177e8fb4654c69019904548.json": {
"bytes": 121984,
"sha256": "ae16337dbc617e26b23468adb86d963e3bcf7eddf177e8fb4654c69019904548"
},
"capsules/bf7dc41142e6d087f213b2914e0ffb0e9cd52294f5830301e5e5b78c3470f725.zip": {
"bytes": 2909281,
"sha256": "bf7dc41142e6d087f213b2914e0ffb0e9cd52294f5830301e5e5b78c3470f725"
},
"capsules/c1083b630ad79ed1fea6933de3221f05b8eb9200694fb0e5dbd637527803579c.json": {
"bytes": 121652,
"sha256": "c1083b630ad79ed1fea6933de3221f05b8eb9200694fb0e5dbd637527803579c"
},
"capsules/c5b23cd340869d57bd1bc4400041d499ea06a1a7619a6fd6b51e75278c1755e1.json": {
"bytes": 121984,
"sha256": "c5b23cd340869d57bd1bc4400041d499ea06a1a7619a6fd6b51e75278c1755e1"
},
"capsules/cc6233ddec2db0e322659a73a55e1681f5cb3554750ba183a756a29dadf8a8c9.zip": {
"bytes": 2917494,
"sha256": "cc6233ddec2db0e322659a73a55e1681f5cb3554750ba183a756a29dadf8a8c9"
},
"capsules/d2ec5025067db2c05736bb1c9c9b6250d9199f1b7190e8c09f5a777424790c10.zip": {
"bytes": 2918296,
"sha256": "d2ec5025067db2c05736bb1c9c9b6250d9199f1b7190e8c09f5a777424790c10"
},
"capsules/df441a733bf28d1c59eab52063ca4b2645067b490b0a2f0072f98fc9225ea2e8.zip": {
"bytes": 2917518,
"sha256": "df441a733bf28d1c59eab52063ca4b2645067b490b0a2f0072f98fc9225ea2e8"
},
"capsules/e1c7f3abe7b32616d1081b4a2bb8af2d6d4b071bc2a7cbdd61e96a92ffdc0533.zip": {
"bytes": 2918309,
"sha256": "e1c7f3abe7b32616d1081b4a2bb8af2d6d4b071bc2a7cbdd61e96a92ffdc0533"
},
"capsules/e99bcaf1ab2becbcad0a005bdd0a4eb14fad2075bd7a38433857df4d3cad5e4d.zip": {
"bytes": 2917719,
"sha256": "e99bcaf1ab2becbcad0a005bdd0a4eb14fad2075bd7a38433857df4d3cad5e4d"
},
"cargo/core-1/process.json": {
"bytes": 210,
"sha256": "92e5a88b5b86400c13bad4d0a9d490466bb2ef0b6077d14930ac5084187b5506"
},
"cargo/core-1/result.json": {
"bytes": 1821,
"sha256": "c90e020e6d912516fbc5ef5b4bf6a5df5fc668e5f5e19a9108c29bf3723cea9c"
},
"cargo/core-1/started.json": {
"bytes": 1428,
"sha256": "230ea369285569a0fa179fe420b0b2133e7fcb2349601a53d69881e9a66bd39b"
},
"cargo/core-1/stderr.log": {
"bytes": 434,
"sha256": "84a1b0b7352dcde2fe51293feabbddf8e94206d9278e4a38e6d354af8a1bcd48"
},
"cargo/core-1/stdout.log": {
"bytes": 1671,
"sha256": "eb04561c11e394cc368ea1c5e38aa4b1dc8b5c3c44acb168469dd941f80f14e8"
},
"cargo/core-2/process.json": {
"bytes": 245,
"sha256": "020d6cb0ce6e4b9122ac2dcbd86c9c1719479f29d3690247182ade858caba12d"
},
"cargo/core-2/result.json": {
"bytes": 1870,
"sha256": "c97cbcd85b60a3476135bf9026d41868ea091061c12ce4bbc18a40c6023c5b3e"
},
"cargo/core-2/started.json": {
"bytes": 1473,
"sha256": "f367b289c9d378975514fa450e4fa25049f4461156417ba5bd6e79836faf401e"
},
"cargo/core-2/stderr.log": {
"bytes": 5287,
"sha256": "4ea6f38eef014ab8176c3ab1ed9d3be3addb4463a8f073425f1b5493b33a7c86"
},
"cargo/core-2/stdout.log": {
"bytes": 4138,
"sha256": "a2dfdb0d7c6c7ba7e9ef86063a815f43111188a44ae4b0e20717cc3e518b7c3d"
},
"cargo/core-3/process.json": {
"bytes": 245,
"sha256": "b26f22b0aed07e55906b024d1fd94198da1ab1d56363fc5ad66868940ae1c9a4"
},
"cargo/core-3/result.json": {
"bytes": 1867,
"sha256": "6d04a2818750491cb73ebb1b257d214316c048e1ac6165a119a27534d636406c"
},
"cargo/core-3/started.json": {
"bytes": 1473,
"sha256": "49e9d637eb063277925c577266a63e5fd06723ca24726d609dc0e458596e8316"
},
"cargo/core-3/stderr.log": {
"bytes": 539,
"sha256": "f05f7ab2f0244968100eb0e0a82808af8e42dceb5b5de70d034b7ae56bad9489"
},
"cargo/core-3/stdout.log": {
"bytes": 4797,
"sha256": "d0f2180aaecc3b253b473200b77348a19a21594e193ce45c21f040b7242be253"
},
"cargo/desktop-1/process.json": {
"bytes": 172,
"sha256": "d9ba2b6a0968b702af0aa857e7bb8667680eeddc32a0403781cd3d5e2467c067"
},
"cargo/desktop-1/result.json": {
"bytes": 1769,
"sha256": "2384c076e4bbc0e15951671e3a830725213c31cfcded0a27e1bb72802c576dbc"
},
"cargo/desktop-1/started.json": {
"bytes": 1375,
"sha256": "6b1ba478ebe5e14d5ee76199a56874a6a10156c2dd7e9d3830fd5ba5311094d6"
},
"cargo/desktop-1/stderr.log": {
"bytes": 4604,
"sha256": "6a47286c4daaf9f4ac8ac8cca3e98931f7812a44b192128aa67880c1ac930b87"
},
"cargo/desktop-1/stdout.log": {
"bytes": 0,
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
},
"cargo/desktop-2/process.json": {
"bytes": 157,
"sha256": "cc037bab1ec44bd2e4095e165597f5e1d077bae5fce7a96c6a5fd814470d4244"
},
"cargo/desktop-2/result.json": {
"bytes": 1753,
"sha256": "9c037405572273086737e22b60796eff0745f4746ee3f25529f928da53c98fbc"
},
"cargo/desktop-2/started.json": {
"bytes": 1355,
"sha256": "18f71e571722bdb3696195623b3af3a2b9a45d38f98307a68049aacff3fbad9c"
},
"cargo/desktop-2/stderr.log": {
"bytes": 1085,
"sha256": "316e0179675fb071d7d9773d7073d771438ecd70e66c99559f4239e0638a3238"
},
"cargo/desktop-2/stdout.log": {
"bytes": 145051,
"sha256": "7c907317d106ff63347456b5ad2015fa8a9974ffbb7003d0a123afc99879ce42"
},
"cargo/desktop-3/process.json": {
"bytes": 157,
"sha256": "368d265e1ee5cceeb5995172c662dc3bc551591b9600a781c63ed64579760c24"
},
"cargo/desktop-3/result.json": {
"bytes": 1751,
"sha256": "32076fb49a6ddfcef63e77ca9f1edec34700ca80d36f32890581d56035f80054"
},
"cargo/desktop-3/started.json": {
"bytes": 1355,
"sha256": "da1ccd8352380b357d5954a70d86f2c51c8a2da8d1c4b553ca7b281d3000eab9"
},
"cargo/desktop-3/stderr.log": {
"bytes": 626,
"sha256": "5333e25e78f515449877867d0a2bf714b7cb3302c9497d4f4343a1e05e0f8b03"
},
"cargo/desktop-3/stdout.log": {
"bytes": 145562,
"sha256": "dc7e40a7d2ab1e78c74f0ee0344c4b2e5b1402bc6367e93a40780774000e4f81"
},
"cargo/desktop-4/process.json": {
"bytes": 157,
"sha256": "0823d0fbda95fe5efb08a7c11c98a7ac2409b5b3d170cf2e41b702895211abf4"
},
"cargo/desktop-4/result.json": {
"bytes": 1750,
"sha256": "d88b177e9d181f7c9c3685100c8676c0c8954cd0b071344ff5b4bb7a8e498865"
},
"cargo/desktop-4/started.json": {
"bytes": 1355,
"sha256": "0f9927d5c0b09c7fcc8922c68a2c88a4d840e0420a7be90193f566b02312a3d3"
},
"cargo/desktop-4/stderr.log": {
"bytes": 628,
"sha256": "992db238282b7acf1bbfeee7f2c459f9afe4bce814761d70df789e4f6239afcf"
},
"cargo/desktop-4/stdout.log": {
"bytes": 145802,
"sha256": "70c38e8c98cac4f220999481908beb549e5377f6f48c31931f9412ed25a67a41"
},
"cargo/desktop-5/process.json": {
"bytes": 157,
"sha256": "83bf9efc58fa5692f3ed038c107c081e5483d91117c73eddba62d3eb45cc7267"
},
"cargo/desktop-5/result.json": {
"bytes": 1751,
"sha256": "7cbbae8bbceec07cebb4696b999701cd29d46bfd047351aa3e20ea49d7c67ae6"
},
"cargo/desktop-5/started.json": {
"bytes": 1355,
"sha256": "0bfd2a6c185afb1b301e3b6b5f746d0e37251f147590aa2e9f25e370e99f53e8"
},
"cargo/desktop-5/stderr.log": {
"bytes": 631,
"sha256": "466f26c41956ed05629b633827d7ffca497c57c87ff67acff6351043c7bdefd4"
},
"cargo/desktop-5/stdout.log": {
"bytes": 146577,
"sha256": "e89cf5f2375fd40ae70261471d797cbaca5127cb27730e6094a616afa12514ae"
},
"cargo/format-1/process.json": {
"bytes": 116,
"sha256": "bb8e0a7620b40bb5f0932643848a57b0d207dde39006a9a20141c00f13d94284"
},
"cargo/format-1/result.json": {
"bytes": 1687,
"sha256": "967eeddb3fc6a0dc0fdf9a75f575d75fa8f8b0b5ea9c8a422590bcbece6de77b"
},
"cargo/format-1/started.json": {
"bytes": 1299,
"sha256": "ceb354dfbc4a41d72e496342e28475ddcce5a3c17a4e1553f0d699404c374657"
},
"cargo/format-1/stderr.log": {
"bytes": 0,
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
},
"cargo/format-1/stdout.log": {
"bytes": 0,
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
},
"cargo/format-2/process.json": {
"bytes": 116,
"sha256": "aa31b91d224845b7dd1df777e865c88c5b8f55b66f173053be318132d1bc3870"
},
"cargo/format-2/result.json": {
"bytes": 1687,
"sha256": "c4d5529c8d2feae1b805bd8d404a3817053a5a0cd4ec341aa8c1e0a5c05eed5b"
},
"cargo/format-2/started.json": {
"bytes": 1299,
"sha256": "2ea70bb29d7fef73a194acd7aa36836fd4a54ae8a4be173f919cd2fc32e97919"
},
"cargo/format-2/stderr.log": {
"bytes": 0,
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
},
"cargo/format-2/stdout.log": {
"bytes": 0,
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
},
"cargo/lint-1/process.json": {
"bytes": 196,
"sha256": "d30e400277dc0bb4a1618689b16ab86be7838cc1b42688185d655df235080286"
},
"cargo/lint-1/result.json": {
"bytes": 1808,
"sha256": "7ffb361d398def4520afc48683a69effbb8b066efd9442ba3b356c8124aae6d9"
},
"cargo/lint-1/started.json": {
"bytes": 1414,
"sha256": "771b5bdc31662fb3b4f76735e92b0e89d31a02188fbce35c5cfb0aea4c37c0ca"
},
"cargo/lint-1/stderr.log": {
"bytes": 1023,
"sha256": "9cbab357d810ce8164a0e614491f53529e61c1cad79793ca429e13897a8dc5ed"
},
"cargo/lint-1/stdout.log": {
"bytes": 0,
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
},
"cargo/lint-2/process.json": {
"bytes": 196,
"sha256": "f4aed65014ed120dc72bd08160559ae0285bc5c4968305a5fb86cf29e8c0d347"
},
"cargo/lint-2/result.json": {
"bytes": 1805,
"sha256": "b873add4777fd8f7516513e2423f9d3eb4a40505a3b1fa7edfbee89fb5c7e0d3"
},
"cargo/lint-2/started.json": {
"bytes": 1414,
"sha256": "0d01a20ec0f7cd796c066c95936c74df0abf8947fca655d900a5bdd4f8dbd610"
},
"cargo/lint-2/stderr.log": {
"bytes": 207,
"sha256": "e6f76746d5864a4de1e9d238c74799e2080e2c6214f51b68a4692d5e15f5b107"
},
"cargo/lint-2/stdout.log": {
"bytes": 0,
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
},
"cargo/lint-3/process.json": {
"bytes": 196,
"sha256": "620094b52dab8f23f389687cf72b8b3aa4effa55697842206a618293537eb869"
},
"cargo/lint-3/result.json": {
"bytes": 1805,
"sha256": "1e7dc472c03d653094850ed653599cee41e50d1639389bbc3fee45e40a754b5c"
},
"cargo/lint-3/started.json": {
"bytes": 1414,
"sha256": "b921ee7102386187c288c1b25aea96a3019ff00a4a508065aefbc4499b7c7818"
},
"cargo/lint-3/stderr.log": {
"bytes": 137,
"sha256": "d19e47547a69ce7956c74f2a65cd1064571985d073bcf497a11a0b0eebcfbd04"
},
"cargo/lint-3/stdout.log": {
"bytes": 0,
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
},
"cargo/native-build-1/process.json": {
"bytes": 194,
"sha256": "390cb979d680b9fdb7e0afa05d6b9d10a25dc27abaff16596d113bb0b2142b43"
},
"cargo/native-build-1/result.json": {
"bytes": 3132,
"sha256": "35f76499ac2a49446b896932c963fd8734a5755e00e3511cedf1e584e7b17704"
},
"cargo/native-build-1/started.json": {
"bytes": 1397,
"sha256": "5f37fc095aa00eae8fae043651a29f387f484bf4a0cc454e3919ea4f49567126"
},
"cargo/native-build-1/stderr.log": {
"bytes": 130,
"sha256": "731d61ee089d75c107edcd02d2cdedcddaad57ec06607ccd0822d9dc71a72720"
},
"cargo/native-build-1/stdout.log": {
"bytes": 41700,
"sha256": "832dc599cad2c9ba28ff9153537fb428390d8f25959f5b4a080cd432f6bea0e8"
},
"cargo/native-build-2/process.json": {
"bytes": 194,
"sha256": "46ff130840d8b12272be43f479adeb0b2ace94ee1d5389c5638a61b22d732496"
},
"cargo/native-build-2/result.json": {
"bytes": 3131,
"sha256": "fbecac3b0f7913db5e2ad19043c7df167a92287833ddf3ea4f24766e86ae523b"
},
"cargo/native-build-2/started.json": {
"bytes": 1397,
"sha256": "cbd2c7ad98baddc3719a5c03bef103747d4cb5c560d631376149f116ede49584"
},
"cargo/native-build-2/stderr.log": {
"bytes": 60,
"sha256": "b2558545bbfa50a0f0c26623c0982eb5f96d71ea228d7c56180edbac0d0b295d"
},
"cargo/native-build-2/stdout.log": {
"bytes": 41699,
"sha256": "7884b8ea1a92d58c7174657560ec38ff9a073e52f93cc19a78290840f2ab5486"
},
"cargo/oracle-2/process.json": {
"bytes": 189,
"sha256": "1e8ffd7a03b22bf2ec43b0d66ae3cc98016a431c4a07499faf3ca0c635b92337"
},
"cargo/oracle-2/result.json": {
"bytes": 1789,
"sha256": "e0b7331751bae8564ee5c28d05029071f1c8460195e6f9e2f0d00a5ae4e05a16"
},
"cargo/oracle-2/started.json": {
"bytes": 1397,
"sha256": "b7a8278dfa2324f0c3ff6e9616a65ef7a51c99699ad700adfe90e2accc435765"
},
"cargo/oracle-2/stderr.log": {
"bytes": 520,
"sha256": "9dd8b849afdbbb2207a9ad82f1b6c6f07fd9cd0846fe4591f33c40fdadcbb3ac"
},
"cargo/oracle-2/stdout.log": {
"bytes": 0,
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
},
"cargo/oracle-3/process.json": {
"bytes": 189,
"sha256": "a2cb30f512ee390bb03c9fcf5ea8069ca3a05da91f798985efc3f9f54670ace4"
},
"cargo/oracle-3/result.json": {
"bytes": 1791,
"sha256": "f0768fecc9f6711dd3cfc627c60aaf6118216bce47e76faa4cbdda571ac675e4"
},
"cargo/oracle-3/started.json": {
"bytes": 1397,
"sha256": "30cf614b6dede363ae4bdeca56fc4a1ddb7ee19a7e592b1b1616d4a98a53b535"
},
"cargo/oracle-3/stderr.log": {
"bytes": 307,
"sha256": "b4ccc583c5001d81b7208fe6f2e52788d0d40b63c7da034ca1fb90ec2adb18a4"
},
"cargo/oracle-3/stdout.log": {
"bytes": 2968,
"sha256": "e0a62ca8b008370c5a41378eb4c9e2a229a2fcde7bdba55426313c7d23da8aca"
},
"cargo/windows-build-1/process.json": {
"bytes": 189,
"sha256": "4e6b57dc0d5f10ac1b4af991f8b0f7558f4fc5b626e00f1a49c5782ea13abe18"
},
"cargo/windows-build-1/result.json": {
"bytes": 3082,
"sha256": "3cdd09a859288afe71dde6fb51bcc4bca84e47ceb9cb6389d57f6722d9e23fdf"
},
"cargo/windows-build-1/started.json": {
"bytes": 1392,
"sha256": "a3ebcdffc01e93fd5609005a0539eb11bf473b45bc28eee71dd175b943b387eb"
},
"cargo/windows-build-1/stderr.log": {
"bytes": 319,
"sha256": "7201ff3f71fe793907482992a00682023fc6ee1ac0a016f53a048b299ec2fffd"
},
"cargo/windows-build-1/stdout.log": {
"bytes": 284182,
"sha256": "3fc90b8c923415496c7990d33a5fe7211423b83df833a80111079fad71f04a2d"
},
"cargo/windows-build-2/process.json": {
"bytes": 189,
"sha256": "f18acb4c9feede438e1aa67bb86ce2d74f5b3aa0142499ae99b56b23daaf834b"
},
"cargo/windows-build-2/result.json": {
"bytes": 3081,
"sha256": "d7b191ee07c302ffed1017374b0c5d8b86245c0f3348fbac4778ae573397b9de"
},
"cargo/windows-build-2/started.json": {
"bytes": 1392,
"sha256": "b6a6656d966559060f01e87ccbd75837b0cf67e70c77246b20f587167bdbf651"
},
"cargo/windows-build-2/stderr.log": {
"bytes": 137,
"sha256": "aafe81cdd7c1e5558e957d4296d46e3238c72af79ba79d6147c5cc16f938d6ef"
},
"cargo/windows-build-2/stdout.log": {
"bytes": 284179,
"sha256": "cfba733cb79a9496004109bb0bc8b8996df5ec1645691fd79b952063faa519c5"
},
"helpers/6b8fcf64a1eb0869ae29abd66b4e89564a1813fef5898b54f2a17a3230dbaa06.py": {
"bytes": 3502,
"sha256": "6b8fcf64a1eb0869ae29abd66b4e89564a1813fef5898b54f2a17a3230dbaa06"
}
}
}
@@ -0,0 +1,90 @@
"""Package explicitly selected closed gate captures without running Cargo."""
from pathlib import Path
import argparse
import json
import zipfile
from verify import RAW_FILES, pin, parsed, need, run_name, verify_data, verify_package
HERE = Path(__file__).resolve().parent
def collect(root, runs, helpers):
gates = root / ".local/group-movement-gates"
candidates = sorted(gates.glob("*.py")) + helpers
helper_bytes = [path.read_bytes() for path in candidates]
payload, logical, specs = {}, {}, {}
def add(name, data):
stored = ("capsules/" + pin(data)["sha256"] + Path(name).suffix
if name.rsplit("/", 1)[1] in {"inputs.json", "inputs.zip"} else name)
need(stored not in payload or payload[stored] == data, "deduplication collision")
payload[stored] = data
logical[name] = stored
for run in runs:
folder = gates / run
need(folder.is_dir(), "run directory missing: " + run)
paths = list(folder.iterdir())
need(all(path.is_file() and not path.is_symlink() for path in paths)
and {path.name for path in paths} == RAW_FILES, "exact closed raw files required: " + run)
raw = {path.name: path.read_bytes() for path in paths}
result = parsed(raw["result.json"])
need("finish" in result and result["inputsUnchanged"] is True, "unfinished/changed run refused: " + run)
matches = [data for data in helper_bytes if pin(data) == result["helper"]]
need(bool(matches), "exact historical helper unavailable for " + run + "; supply --helper PATH")
helper = "helpers/" + result["helper"]["sha256"] + ".py"
add(helper, matches[0])
for name, data in sorted(raw.items()):
add("cargo/" + run + "/" + name, data)
specs[run] = {key: result[key] for key in ("command", "head", "exitCode", "inputCount")}
specs[run]["helper"] = helper
manifest = {"schema": 1, "kind": "multiselect-cargo-gates", "runs": specs,
"logicalFiles": dict(sorted(logical.items())),
"members": {name: pin(data) for name, data in sorted(payload.items())}}
summary = verify_data(manifest, payload.__getitem__)
return payload, manifest, summary
def write_json(path, value):
with path.open("xb") as output:
output.write((json.dumps(value, indent=2) + "\n").encode("utf-8"))
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--root", type=Path, default=HERE.parents[3], help="checkout holding .local/group-movement-gates")
parser.add_argument("--runs", nargs="+", required=True, help="explicit run names, including failures to retain")
parser.add_argument("--helper", type=Path, action="append", default=[], help="additional exact historical helper (repeatable)")
parser.add_argument("--output", type=Path, default=HERE, help="package directory; existing evidence is never overwritten")
parser.add_argument("--check-only", action="store_true", help="validate raw captures in memory without creating an archive")
args = parser.parse_args()
need(all(run_name(run) for run in args.runs) and len(args.runs) == len(set(args.runs)), "unique safe run names required")
payload, manifest, summary = collect(args.root.resolve(), args.runs, args.helper)
if args.check_only:
print("PASS raw consistency; no package written:", summary)
return
out = args.output.resolve()
out.mkdir(parents=True, exist_ok=True)
for name in ("evidence.zip", "manifest.json", "files.json"):
need(not (out / name).exists(), "existing package output refused: " + name)
for name in ("prepare.py", "verify.py", "README.md"):
data = (HERE / name).read_bytes()
destination = out / name
if destination.exists():
need(destination.read_bytes() == data, "different existing package tool: " + name)
else:
with destination.open("xb") as output:
output.write(data)
with zipfile.ZipFile(out / "evidence.zip", "x", compression=zipfile.ZIP_DEFLATED, compresslevel=9) as archive:
for name, data in sorted(payload.items()):
info = zipfile.ZipInfo(name, date_time=(2026, 9, 23, 0, 0, 0))
info.compress_type = zipfile.ZIP_DEFLATED
info.external_attr = 0o100644 << 16
archive.writestr(info, data)
write_json(out / "manifest.json", manifest)
names = ("prepare.py", "verify.py", "README.md", "manifest.json", "evidence.zip")
write_json(out / "files.json", {name: pin((out / name).read_bytes()) for name in names})
need(verify_package(out) == summary, "written package differs from validated captures")
print("PASS package readback:", out, summary)
if __name__ == "__main__":
main()
@@ -0,0 +1,164 @@
"""Offline consistency checks only; never execute or extract archived inputs."""
from pathlib import Path, PurePosixPath
from datetime import datetime
import argparse
import hashlib
import io
import json
import math
import re
import stat
import zipfile
RAW_FILES = {"started.json", "process.json", "result.json", "stdout.log", "stderr.log", "inputs.json", "inputs.zip"}
OUTER_FILES = {"prepare.py", "verify.py", "README.md", "manifest.json", "evidence.zip"}
def need(condition, message):
if not condition:
raise ValueError(message)
def pin(data):
return {"bytes": len(data), "sha256": hashlib.sha256(data).hexdigest()}
def valid_pin(value):
return (isinstance(value, dict) and set(value) == {"bytes", "sha256"}
and type(value["bytes"]) is int and value["bytes"] >= 0
and isinstance(value["sha256"], str)
and re.fullmatch(r"[0-9a-f]{64}", value["sha256"]) is not None)
def safe(name):
return (isinstance(name, str) and bool(name) and "\\" not in name and ":" not in name
and all(ord(char) >= 32 for char in name)
and not PurePosixPath(name).is_absolute()
and all(part not in ("", ".", "..") for part in name.split("/")))
def run_name(name):
return isinstance(name, str) and re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]*", name) is not None
def parsed(data):
def pairs(items):
result = {}
for key, value in items:
need(key not in result, "duplicate JSON key: " + key)
result[key] = value
return result
def constant(value):
raise ValueError("nonfinite JSON constant: " + value)
return json.loads(data, object_pairs_hook=pairs, parse_constant=constant)
def inventory(archive, expected):
names = archive.namelist()
need(len(names) == len(set(names)) and set(names) == set(expected), "exact ZIP membership")
for entry in archive.infolist():
need(safe(entry.filename) and not entry.is_dir(), "unsafe ZIP member: " + entry.filename)
need(not stat.S_ISLNK(entry.external_attr >> 16), "ZIP symlink: " + entry.filename)
need(valid_pin(expected[entry.filename]), "invalid member pin: " + entry.filename)
need(entry.file_size == expected[entry.filename]["bytes"], "ZIP recorded size: " + entry.filename)
def ordered_times(*values):
times = [datetime.fromisoformat(value) for value in values]
return all(value.utcoffset() is not None for value in times) and times == sorted(times)
def verify_data(manifest, read):
"""Shared read-only verifier for packaged bytes or prepare's in-memory payload."""
need(manifest["schema"] == 1 and manifest["kind"] == "multiselect-cargo-gates", "manifest schema")
runs, logical, stored = manifest["runs"], manifest["logicalFiles"], manifest["members"]
need(isinstance(runs, dict) and bool(runs) and all(run_name(run) for run in runs), "explicit run inventory")
need(all(safe(name) for name in logical) and all(safe(name) for name in stored), "unsafe manifest path")
need(set(logical.values()) == set(stored), "unreferenced or absent stored members")
for name, expected in stored.items():
need(valid_pin(expected) and pin(read(name)) == expected, "stored member hash: " + name)
expected_logical = {"cargo/" + run + "/" + name for run in runs for name in RAW_FILES}
expected_logical.update(spec["helper"] for spec in runs.values())
need(set(logical) == expected_logical, "exact logical raw/helper inventory")
for name, destination in logical.items():
if name.startswith("cargo/") and name.rsplit("/", 1)[1] in {"inputs.json", "inputs.zip"}:
expected = "capsules/" + stored[destination]["sha256"] + PurePosixPath(name).suffix
need(destination == expected, "input capsule mapping: " + name)
else:
need(destination == name, "only input maps/archives may be deduplicated: " + name)
def raw(name):
return read(logical[name])
seen_capsules = set()
for run, spec in runs.items():
prefix = "cargo/" + run + "/"
start = parsed(raw(prefix + "started.json"))
process = parsed(raw(prefix + "process.json"))
result = parsed(raw(prefix + "result.json"))
required_start = {"start", "command", "cwd", "head", "scope", "inputs", "archive",
"inputCount", "environment", "helper", "cargo", "rustc"}
need(required_start <= start.keys(), run + ": incomplete start receipt")
need(all(result.get(key) == value for key, value in start.items()), run + ": start/result relationship")
command = result["command"]
need(isinstance(command, list) and command and command[0] == "cargo"
and all(isinstance(arg, str) for arg in command), run + ": Cargo command")
need(command == process["command"] == spec["command"], run + ": command relationship")
need(result["head"] == spec["head"] and re.fullmatch(r"(?:[0-9a-f]{40}|[0-9a-f]{64})", result["head"]), run + ": HEAD")
need(type(result["exitCode"]) is int and result["exitCode"] == spec["exitCode"], run + ": recorded exit")
need(result["inputsUnchanged"] is True, run + ": changed inputs refused")
need(type(result["seconds"]) in (int, float) and math.isfinite(result["seconds"])
and result["seconds"] >= 0, run + ": duration")
need(type(process["pid"]) is int and process["pid"] > 0, run + ": process identity")
need(ordered_times(start["start"], process["observed"], result["finish"]), run + ": completed timestamp order")
for field, filename in [("inputs", "inputs.json"), ("archive", "inputs.zip"), ("stdout", "stdout.log"), ("stderr", "stderr.log")]:
need(result[field] == pin(raw(prefix + filename)), run + ": " + field + " pin")
need(valid_pin(result["helper"]) and result["helper"] == pin(raw(spec["helper"])), run + ": helper pin")
need(spec["helper"] == "helpers/" + result["helper"]["sha256"] + ".py", run + ": helper mapping")
inputs = parsed(raw(prefix + "inputs.json"))
need(isinstance(inputs, dict) and all(safe(name) for name in inputs), run + ": safe input inventory")
need(type(result["inputCount"]) is int and len(inputs) == result["inputCount"] == spec["inputCount"], run + ": input counts")
need(sum(name.startswith("crates/vendor/iced_widget/") for name in inputs) == 71, run + ": vendor count")
capsule = (logical[prefix + "inputs.json"], logical[prefix + "inputs.zip"])
if capsule not in seen_capsules:
with zipfile.ZipFile(io.BytesIO(raw(prefix + "inputs.zip"))) as source:
inventory(source, inputs)
for name, expected in inputs.items():
need(pin(source.read(name)) == expected, run + ": source hash " + name)
seen_capsules.add(capsule)
artifacts = []
for line in raw(prefix + "stdout.log").decode("utf-8", errors="replace").splitlines():
try:
value = json.loads(line)
except json.JSONDecodeError:
continue
if isinstance(value, dict) and value.get("reason") == "compiler-artifact" and value.get("executable"):
artifacts.append(value)
need(isinstance(result["artifacts"], list), run + ": artifact records")
need(artifacts == [record["cargo"] for record in result["artifacts"]], run + ": Cargo JSON artifact relationship")
for record in result["artifacts"]:
need(set(record) == {"cargo", "readback"}
and (record["readback"] is None or valid_pin(record["readback"])), run + ": artifact readback pin")
print(run, "recorded exit", result["exitCode"], "inputs", len(inputs), "executable records", len(artifacts))
return {"runs": len(runs), "logicalFiles": len(logical), "storedMembers": len(stored), "sourceCapsulePairs": len(seen_capsules)}
def verify_package(root):
files = parsed((root / "files.json").read_bytes())
need(set(files) == OUTER_FILES, "outer file inventory")
for name, expected in files.items():
need(valid_pin(expected) and pin((root / name).read_bytes()) == expected, "outer hash: " + name)
manifest = parsed((root / "manifest.json").read_bytes())
with zipfile.ZipFile(root / "evidence.zip") as archive:
inventory(archive, manifest["members"])
return verify_data(manifest, archive.read)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("package", nargs="?", type=Path, default=Path(__file__).resolve().parent)
args = parser.parse_args()
print("PASS offline consistency:", verify_package(args.package))
print("Historical receipts only; no source/binary execution or native acceptance claim.")
if __name__ == "__main__":
main()
@@ -0,0 +1,35 @@
# Run-2 baseline checker environment incident
The first baseline checker invocation exited `3221225781` (`0xC0000135`) at
2026-09-23 09:07:10 UTC with empty stdout/stderr. The parent reported that this
shell omitted dot-sourcing the toolchain `env.ps1`. After that environment setup,
the same checker command succeeded at 09:08:08 UTC with a complete-Project PASS.
This records a checker-process launch incident, not a failure of the running GUI.
No particular missing DLL was measured or identified.
`run-2-baseline-runtime-environment/` is an exact copy of the failed snapshot:
started/result receipts, empty streams and all nine source files. Its unchanged
command text still names the original `run-2/snapshots/baseline` location; the
parent moved the failed directory aside before retrying. Paths in these records
are historical labels and have not been rewritten.
`retry-baseline/` preserves the successful retry's original started/result
receipts and streams. Direct comparison confirmed identical command, build
receipt, binary, helper, baseline and source pins between attempts. All three
unchanged flags are true in both results. The nine retry source files were
also compared byte for byte with the failed snapshot and are identical; they
are not duplicated here. The successful run's complete snapshots belong in the
separate native evidence package.
`helpers/check-snapshot-2.py` matches the helper pin in both receipts.
`helpers/env.ps1` preserves the environment setup script available when this
incident was packaged. It sets toolchain paths and runtime search paths and
contains no credentials. The checker receipts do not record the environment or
pin `env.ps1`, so the omitted/setup distinction is the parent's invocation
observation, not an independently measured environment comparison. Both scripts
are historical data; this package does not execute them.
The checker binary pin in both attempts is
`4de47a2b62192be6a1394e654f368be19e36fb966d60d6c833a5d51d18247bb1`.
`files.json` pins every included file except itself. Raw bytes are preserved.
No frozen checks, source-identity or native packaging tools were changed.
@@ -0,0 +1,82 @@
{
"helpers/check-snapshot-2.py": {
"bytes": 2494,
"sha256": "1dbfff41f07bd750d4e550660d91ae9790d2442fbfe685dc88b7f63a47f5acb8"
},
"helpers/env.ps1": {
"bytes": 621,
"sha256": "d7eb6249bef73adacdb05cebefe7ca478758c3c38de17a1731bf01f8b25dc379"
},
"README.md": {
"bytes": 2146,
"sha256": "d5a853c62713a6c87d28acfbe28c03bd95459275d0a2eb752d978e5a9b427bc8"
},
"retry-baseline/result.json": {
"bytes": 2758,
"sha256": "ae16cbc54bf5b6054750ef082f47bf0a26ee2dfb7bce46f19c8fd2c1ac622d7f"
},
"retry-baseline/started.json": {
"bytes": 2356,
"sha256": "e172a36f0872d49a8fe0f7200fb56d597b71fd1849dc9902d53ac1813a3c7d4a"
},
"retry-baseline/stderr.log": {
"bytes": 0,
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
},
"retry-baseline/stdout.log": {
"bytes": 441,
"sha256": "12842cc4e483e2546f5c8615b17fe72c5fe07f3fb4440469071433c82d9a9b30"
},
"run-2-baseline-runtime-environment/result.json": {
"bytes": 2765,
"sha256": "539b950952461cc300876dbc45fa9ee02a9ecefb6d712fce6b74bad3fa701b30"
},
"run-2-baseline-runtime-environment/source/oa/activities/activity_activity_a.arcadia": {
"bytes": 474,
"sha256": "9bf7c5637d87c97213cf869118e942e8a29a67b429fa58e4de42235bba0980ee"
},
"run-2-baseline-runtime-environment/source/oa/activities/activity_activity_b.arcadia": {
"bytes": 286,
"sha256": "152007669b7c570674aff99615a48a6f557be50f88bbbb3a5edc20b423c79a1f"
},
"run-2-baseline-runtime-environment/source/oa/activities/activity_activity_c.arcadia": {
"bytes": 153,
"sha256": "d726b6b4e1200edd9ef752bb767e38d3d69dcac8eb6135d5f83c93832ff849b1"
},
"run-2-baseline-runtime-environment/source/project.arcadia": {
"bytes": 597,
"sha256": "e7bd67c5785a78766298255ed3d53ff6d65dcd24bde1570ac8a0ff45cc9dc385"
},
"run-2-baseline-runtime-environment/source/sa/functions/function_sa_function.arcadia": {
"bytes": 162,
"sha256": "1c724a9204784435e729badd4ad164b1a6b891840bb1ddc354ac1e175e3d712c"
},
"run-2-baseline-runtime-environment/source/traceability.arcadia": {
"bytes": 522,
"sha256": "26b0b10715ced7aefcde941e6d1c1a6973f174997eae989e32a25c695dfb7cd1"
},
"run-2-baseline-runtime-environment/source/views/diagram_oa_other.arcadia": {
"bytes": 315,
"sha256": "06ceef9cfdbb3ef229a22db6c48d8bb808388e027c37294648555ecbfb6bfab9"
},
"run-2-baseline-runtime-environment/source/views/diagram_oa_view.arcadia": {
"bytes": 1896,
"sha256": "4e7d47d6a03153070687136c89faf01fc6df45ae89e2695beeab2fe4872dbc88"
},
"run-2-baseline-runtime-environment/source/views/diagram_sa_view.arcadia": {
"bytes": 331,
"sha256": "c75b0fe14496631c9b017a32e277d1e5ad5b25e00e06b804fc54d950d7fb57f6"
},
"run-2-baseline-runtime-environment/started.json": {
"bytes": 2356,
"sha256": "338c14c7dc827bd4ab09009bb974ad3c481b07cdbad2437f5291b1ff67b5ba42"
},
"run-2-baseline-runtime-environment/stderr.log": {
"bytes": 0,
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
},
"run-2-baseline-runtime-environment/stdout.log": {
"bytes": 0,
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
}
}
@@ -0,0 +1,41 @@
"""Copy stable native-saved source and check its complete Project independently."""
from pathlib import Path
import datetime, hashlib, json, re, shutil, subprocess, sys
root = Path('C:/ar-multiselect-9e8r6t')
native = root / '.local/multiselect-native'
label, mode, *rest = sys.argv[1:]
assert not rest and re.fullmatch('[a-z0-9-]+', label)
working = native / 'working-2'
out = native / 'run-2/snapshots' / label
out.mkdir(parents=True, exist_ok=False)
gate_path = root / '.local/group-movement-gates/native-build-2/result.json'
gate = json.loads(gate_path.read_bytes())
assert gate['exitCode'] == 0 and gate['inputsUnchanged']
artifact, = [a for a in gate['artifacts'] if a['cargo']['target']['name'] == 'multiselect_native']
binary = Path(artifact['cargo']['executable'])
baseline = native / 'fixture-2/baseline.json'
def pin(path):
b = path.read_bytes()
return {'bytes': len(b), 'sha256': hashlib.sha256(b).hexdigest()}
def inventory():
return {p.relative_to(working).as_posix():pin(p) for p in sorted(working.rglob('*.arcadia')) if p.is_file()}
def now(): return datetime.datetime.now(datetime.timezone.utc).isoformat()
before = inventory()
assert 'project.arcadia' in before and pin(binary) == artifact['readback']
for name in before:
target = out/'source'/name
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(working/name, target)
assert before == inventory() == {name:pin(out/'source'/name) for name in before}
command = [str(binary), 'check', mode, str(baseline), str(out/'source/project.arcadia')]
start = {'start':now(), 'command':command, 'buildReceipt':pin(gate_path), 'binary':pin(binary), 'helper':pin(Path(__file__)), 'source':before, 'baseline':pin(baseline)}
(out/'started.json').write_text(json.dumps(start,indent=2),encoding='utf-8')
r = subprocess.run(command,cwd=root,capture_output=True)
(out/'stdout.log').write_bytes(r.stdout)
(out/'stderr.log').write_bytes(r.stderr)
result = {**start, 'finish':now(), 'exitCode':r.returncode, 'binaryUnchanged':pin(binary)==artifact['readback'], 'workingSourceUnchanged':before==inventory(), 'baselineUnchanged':pin(baseline)==start['baseline'], 'stdout':pin(out/'stdout.log'), 'stderr':pin(out/'stderr.log')}
(out/'result.json').write_text(json.dumps(result,indent=2),encoding='utf-8')
print(r.stdout.decode(errors='replace'))
print(r.stderr.decode(errors='replace'))
assert result['binaryUnchanged'] and result['workingSourceUnchanged'] and result['baselineUnchanged']
sys.exit(r.returncode)
@@ -0,0 +1,9 @@
$taskRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot)
$env:RUSTUP_HOME = Join-Path $taskRoot '.local\toolchain\rustup'
$env:CARGO_HOME = Join-Path $taskRoot '.local\toolchain\cargo'
$env:RUSTUP_TOOLCHAIN = 'stable-x86_64-pc-windows-gnullvm'
$env:PATH = "$env:CARGO_HOME\bin;$(Join-Path $taskRoot '.local\toolchain\llvm-mingw-20260826-ucrt-x86_64\bin');$env:PATH"
$env:CARGO_TARGET_DIR = Join-Path $taskRoot '.local\target-gnullvm'
# Keep this small development disk from accumulating multi-GiB incremental caches.
$env:CARGO_INCREMENTAL = '0'
$env:CARGO_TARGET_X86_64_PC_WINDOWS_GNULLVM_LINKER = 'clang.exe'
@@ -0,0 +1,77 @@
{
"start": "2026-09-23T09:08:07.906733+00:00",
"command": [
"C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\target-gnullvm\\debug\\examples\\multiselect_native.exe",
"check",
"unchanged",
"C:\\ar-multiselect-9e8r6t\\.local\\multiselect-native\\fixture-2\\baseline.json",
"C:\\ar-multiselect-9e8r6t\\.local\\multiselect-native\\run-2\\snapshots\\baseline\\source\\project.arcadia"
],
"buildReceipt": {
"bytes": 3131,
"sha256": "fbecac3b0f7913db5e2ad19043c7df167a92287833ddf3ea4f24766e86ae523b"
},
"binary": {
"bytes": 22162432,
"sha256": "4de47a2b62192be6a1394e654f368be19e36fb966d60d6c833a5d51d18247bb1"
},
"helper": {
"bytes": 2494,
"sha256": "1dbfff41f07bd750d4e550660d91ae9790d2442fbfe685dc88b7f63a47f5acb8"
},
"source": {
"oa/activities/activity_activity_a.arcadia": {
"bytes": 474,
"sha256": "9bf7c5637d87c97213cf869118e942e8a29a67b429fa58e4de42235bba0980ee"
},
"oa/activities/activity_activity_b.arcadia": {
"bytes": 286,
"sha256": "152007669b7c570674aff99615a48a6f557be50f88bbbb3a5edc20b423c79a1f"
},
"oa/activities/activity_activity_c.arcadia": {
"bytes": 153,
"sha256": "d726b6b4e1200edd9ef752bb767e38d3d69dcac8eb6135d5f83c93832ff849b1"
},
"project.arcadia": {
"bytes": 597,
"sha256": "e7bd67c5785a78766298255ed3d53ff6d65dcd24bde1570ac8a0ff45cc9dc385"
},
"sa/functions/function_sa_function.arcadia": {
"bytes": 162,
"sha256": "1c724a9204784435e729badd4ad164b1a6b891840bb1ddc354ac1e175e3d712c"
},
"traceability.arcadia": {
"bytes": 522,
"sha256": "26b0b10715ced7aefcde941e6d1c1a6973f174997eae989e32a25c695dfb7cd1"
},
"views/diagram_oa_other.arcadia": {
"bytes": 315,
"sha256": "06ceef9cfdbb3ef229a22db6c48d8bb808388e027c37294648555ecbfb6bfab9"
},
"views/diagram_oa_view.arcadia": {
"bytes": 1896,
"sha256": "4e7d47d6a03153070687136c89faf01fc6df45ae89e2695beeab2fe4872dbc88"
},
"views/diagram_sa_view.arcadia": {
"bytes": 331,
"sha256": "c75b0fe14496631c9b017a32e277d1e5ad5b25e00e06b804fc54d950d7fb57f6"
}
},
"baseline": {
"bytes": 8944,
"sha256": "d1278e58cd31e1d61093292eddc1d7b2cc9c8cdc7fe5dd3801de8411af8db89d"
},
"finish": "2026-09-23T09:08:08.158861+00:00",
"exitCode": 0,
"binaryUnchanged": true,
"workingSourceUnchanged": true,
"baselineUnchanged": true,
"stdout": {
"bytes": 441,
"sha256": "12842cc4e483e2546f5c8615b17fe72c5fe07f3fb4440469071433c82d9a9b30"
},
"stderr": {
"bytes": 0,
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
}
}
@@ -0,0 +1,64 @@
{
"start": "2026-09-23T09:08:07.906733+00:00",
"command": [
"C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\target-gnullvm\\debug\\examples\\multiselect_native.exe",
"check",
"unchanged",
"C:\\ar-multiselect-9e8r6t\\.local\\multiselect-native\\fixture-2\\baseline.json",
"C:\\ar-multiselect-9e8r6t\\.local\\multiselect-native\\run-2\\snapshots\\baseline\\source\\project.arcadia"
],
"buildReceipt": {
"bytes": 3131,
"sha256": "fbecac3b0f7913db5e2ad19043c7df167a92287833ddf3ea4f24766e86ae523b"
},
"binary": {
"bytes": 22162432,
"sha256": "4de47a2b62192be6a1394e654f368be19e36fb966d60d6c833a5d51d18247bb1"
},
"helper": {
"bytes": 2494,
"sha256": "1dbfff41f07bd750d4e550660d91ae9790d2442fbfe685dc88b7f63a47f5acb8"
},
"source": {
"oa/activities/activity_activity_a.arcadia": {
"bytes": 474,
"sha256": "9bf7c5637d87c97213cf869118e942e8a29a67b429fa58e4de42235bba0980ee"
},
"oa/activities/activity_activity_b.arcadia": {
"bytes": 286,
"sha256": "152007669b7c570674aff99615a48a6f557be50f88bbbb3a5edc20b423c79a1f"
},
"oa/activities/activity_activity_c.arcadia": {
"bytes": 153,
"sha256": "d726b6b4e1200edd9ef752bb767e38d3d69dcac8eb6135d5f83c93832ff849b1"
},
"project.arcadia": {
"bytes": 597,
"sha256": "e7bd67c5785a78766298255ed3d53ff6d65dcd24bde1570ac8a0ff45cc9dc385"
},
"sa/functions/function_sa_function.arcadia": {
"bytes": 162,
"sha256": "1c724a9204784435e729badd4ad164b1a6b891840bb1ddc354ac1e175e3d712c"
},
"traceability.arcadia": {
"bytes": 522,
"sha256": "26b0b10715ced7aefcde941e6d1c1a6973f174997eae989e32a25c695dfb7cd1"
},
"views/diagram_oa_other.arcadia": {
"bytes": 315,
"sha256": "06ceef9cfdbb3ef229a22db6c48d8bb808388e027c37294648555ecbfb6bfab9"
},
"views/diagram_oa_view.arcadia": {
"bytes": 1896,
"sha256": "4e7d47d6a03153070687136c89faf01fc6df45ae89e2695beeab2fe4872dbc88"
},
"views/diagram_sa_view.arcadia": {
"bytes": 331,
"sha256": "c75b0fe14496631c9b017a32e277d1e5ad5b25e00e06b804fc54d950d7fb57f6"
}
},
"baseline": {
"bytes": 8944,
"sha256": "d1278e58cd31e1d61093292eddc1d7b2cc9c8cdc7fe5dd3801de8411af8db89d"
}
}
@@ -0,0 +1,9 @@
{
"baseline": "C:\\ar-multiselect-9e8r6t\\.local\\multiselect-native\\fixture-2\\baseline.json",
"comparison": "complete Project equality",
"inferred_common_delta": null,
"limits": "Invariant evaluator only; no pointer/action/focus/Undo/source-byte acceptance claim",
"mode": "unchanged",
"result": "PASS",
"saved": "C:\\ar-multiselect-9e8r6t\\.local\\multiselect-native\\run-2\\snapshots\\baseline\\source\\project.arcadia"
}
@@ -0,0 +1,77 @@
{
"start": "2026-09-23T09:07:10.546708+00:00",
"command": [
"C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\target-gnullvm\\debug\\examples\\multiselect_native.exe",
"check",
"unchanged",
"C:\\ar-multiselect-9e8r6t\\.local\\multiselect-native\\fixture-2\\baseline.json",
"C:\\ar-multiselect-9e8r6t\\.local\\multiselect-native\\run-2\\snapshots\\baseline\\source\\project.arcadia"
],
"buildReceipt": {
"bytes": 3131,
"sha256": "fbecac3b0f7913db5e2ad19043c7df167a92287833ddf3ea4f24766e86ae523b"
},
"binary": {
"bytes": 22162432,
"sha256": "4de47a2b62192be6a1394e654f368be19e36fb966d60d6c833a5d51d18247bb1"
},
"helper": {
"bytes": 2494,
"sha256": "1dbfff41f07bd750d4e550660d91ae9790d2442fbfe685dc88b7f63a47f5acb8"
},
"source": {
"oa/activities/activity_activity_a.arcadia": {
"bytes": 474,
"sha256": "9bf7c5637d87c97213cf869118e942e8a29a67b429fa58e4de42235bba0980ee"
},
"oa/activities/activity_activity_b.arcadia": {
"bytes": 286,
"sha256": "152007669b7c570674aff99615a48a6f557be50f88bbbb3a5edc20b423c79a1f"
},
"oa/activities/activity_activity_c.arcadia": {
"bytes": 153,
"sha256": "d726b6b4e1200edd9ef752bb767e38d3d69dcac8eb6135d5f83c93832ff849b1"
},
"project.arcadia": {
"bytes": 597,
"sha256": "e7bd67c5785a78766298255ed3d53ff6d65dcd24bde1570ac8a0ff45cc9dc385"
},
"sa/functions/function_sa_function.arcadia": {
"bytes": 162,
"sha256": "1c724a9204784435e729badd4ad164b1a6b891840bb1ddc354ac1e175e3d712c"
},
"traceability.arcadia": {
"bytes": 522,
"sha256": "26b0b10715ced7aefcde941e6d1c1a6973f174997eae989e32a25c695dfb7cd1"
},
"views/diagram_oa_other.arcadia": {
"bytes": 315,
"sha256": "06ceef9cfdbb3ef229a22db6c48d8bb808388e027c37294648555ecbfb6bfab9"
},
"views/diagram_oa_view.arcadia": {
"bytes": 1896,
"sha256": "4e7d47d6a03153070687136c89faf01fc6df45ae89e2695beeab2fe4872dbc88"
},
"views/diagram_sa_view.arcadia": {
"bytes": 331,
"sha256": "c75b0fe14496631c9b017a32e277d1e5ad5b25e00e06b804fc54d950d7fb57f6"
}
},
"baseline": {
"bytes": 8944,
"sha256": "d1278e58cd31e1d61093292eddc1d7b2cc9c8cdc7fe5dd3801de8411af8db89d"
},
"finish": "2026-09-23T09:07:10.828848+00:00",
"exitCode": 3221225781,
"binaryUnchanged": true,
"workingSourceUnchanged": true,
"baselineUnchanged": true,
"stdout": {
"bytes": 0,
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
},
"stderr": {
"bytes": 0,
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
}
}
@@ -0,0 +1,14 @@
oa_activity activity_a "A - receive request" id "f0149281-4798-58d3-acff-131eed250959" {
description "Preserve engineering record activity_a.";
}
oa_activity activity_child "A.1 - classify request" id "5a80d3af-c7ee-59eb-9680-eb80d60cb94f" {
description "Preserve engineering record activity_child.";
parent @activity_a;
}
interaction internal "Request assessment" id "be7a39d9-3463-534e-b37b-76a74077d434" {
source @activity_a;
target @activity_b;
}
@@ -0,0 +1,9 @@
oa_activity activity_b "B - assess request" id "ecf076b7-e8be-5f64-8808-6a2edae5b9e2" {
description "Preserve engineering record activity_b.";
}
interaction external "Response decision" id "927dfa82-40d2-5c5f-a525-6a884725ea1d" {
source @activity_b;
target @activity_c;
}
@@ -0,0 +1,4 @@
oa_activity activity_c "C - dispatch response" id "3b258d00-854d-5ddd-a057-16eedbe31714" {
description "Preserve engineering record activity_c.";
}
@@ -0,0 +1,12 @@
arcadia 7;
project multiselect_native "Multiselect native" id "c1deb118-0fb0-5692-8b80-d24339e5b6c3" {
description "Shift-select A and B; move together, retain C and engineering semantics.";
include "oa/activities/activity_activity_a.arcadia";
include "oa/activities/activity_activity_b.arcadia";
include "oa/activities/activity_activity_c.arcadia";
include "sa/functions/function_sa_function.arcadia";
include "traceability.arcadia";
include "views/diagram_oa_other.arcadia";
include "views/diagram_oa_view.arcadia";
include "views/diagram_sa_view.arcadia";
}
@@ -0,0 +1,4 @@
sa_function sa_function "Technical request processing" id "878a1b33-a7c2-5a68-a8b6-d8273c6354e0" {
description "Preserve engineering record sa_function.";
}
@@ -0,0 +1,16 @@
trace preserved_refinement "Reviewed request refinement" id "b42fb6e3-db90-5349-b957-faeba373ba91" {
baseline {
oa {
description "Preserve engineering record activity_a.";
name "A - receive request";
};
sa {
description "Preserve engineering record sa_function.";
name "Technical request processing";
};
};
description "Group geometry and Hide preserve this trace and both endpoints.";
oa @activity_a;
sa @sa_function;
}
@@ -0,0 +1,13 @@
diagram oa_other "Other OA view - preserve" id "c4a58011-d2d7-59b1-ad58-feacef0e2d78" {
description "Selection changes placement geometry only.";
}
node other_a id "9aa9a68b-4c26-5d21-bc35-63d3f0f311d6" {
diagram @oa_other;
element @activity_a;
position {
x 40.0;
y 60.0;
};
}

Some files were not shown because too many files have changed in this diff Show More