Compare commits

..
Author SHA1 Message Date
sorokin.oleg.edu 93d2a6f9af docs: publish verified route admission evidence and retained failures 2026-09-23 23:39:33 -07:00
sorokin.oleg.edu e68a839599 test: qualify route fixture projection helper 2026-09-23 23:29:50 -07:00
sorokin.oleg.edu c655c5eeaa docs: publish prerelease admission reviews and initial gate evidence 2026-09-23 23:10:54 -07:00
sorokin.oleg.edu 6f247af795 fix(desktop): reject stale route owners before drag retirement 2026-09-23 23:07:23 -07:00
sorokin.oleg.edu 72b1bb7aa7 Fix regression module path and isolate live drawing context admission 2026-09-23 22:30:11 -07:00
sorokin.oleg.edu 44217307f9 Reject stale drawing packets before retiring Allocation gestures 2026-09-23 22:24:14 -07:00
sorokin.oleg.edu 340a5c56b0 Provide prerelease human checks and immutable test models 2026-09-23 22:00:04 -07:00
sorokin.oleg.edu 57aec979f6 Record Allocation group App and native acceptance evidence 2026-09-23 21:41:24 -07:00
sorokin.oleg.edu 495464e84b Support atomic Allocation group assignment and Arrange gestures 2026-09-23 20:25:26 -07:00
sorokin.oleg.edu 16b30a6760 Record verified Allocation assignment planner and portable evidence 2026-09-23 11:29:58 -07:00
sorokin.oleg.edu 33e880103c Plan Allocation work-group assignments as one exact transaction 2026-09-23 11:20:58 -07:00
sorokin.oleg.edu eed80ffd6e Document Allocation Arrange amendment and publish bounded planner evidence 2026-09-23 07:56:43 -07:00
sorokin.oleg.edu 9b83f7ca0d Add pure combined Allocation Arrange planner with exact source and geometry checks 2026-09-23 07:51:43 -07:00
sorokin.oleg.edu 8dbbaf262e Verify OA and SA Arrange policy boundaries with focused production modules 2026-09-23 07:41:54 -07:00
sorokin.oleg.edu 3a7923b96d experiment: test distributed Allocation arrangement with actual projection 2026-09-23 07:27:42 -07:00
sorokin.oleg.edu 6d99aacab8 docs: publish Process multiselect verification and native correction evidence 2026-09-23 06:51:41 -07:00
sorokin.oleg.edu b743a10d51 Make native group reorder discriminate singleton fallback
Prescribe inspected and pressed Card C after reverse membership selection. A singleton C drop is unchanged while the group changes order; preserve prior evaluator scope and test the unchanged negative outcome explicitly.
2026-09-23 06:27:39 -07:00
sorokin.oleg.edu ff712b2419 Preserve exact Process selection across definition navigation
Keep caller identity and property subjects aligned when opening and returning from qualified contributions, and when explicitly navigating to local references. Validate camera, source, history and read-only boundaries.
2026-09-23 06:25:50 -07:00
sorokin.oleg.edu b15345123a Bound Process payload sizes and capture rendered order feedback
Box the optional Process menu context and changed order command without changing identity or command semantics. Add opt-in actual WGPU readbacks to the split-event OA/SA reorder evaluator. Strict all-target lint, format and affected desktop paths pass; native acceptance follows.
2026-09-23 05:47:08 -07:00
sorokin.oleg.edu 1711bff650 Add exact Process selection and atomic column-slot group reorder
Preserve the full-desktop-1 captured source. Qualified previews inspect their exact subject read-only; group movement preserves source order and other columns. Native acceptance and strict-lint payload-size corrections follow separately.
2026-09-23 05:39:10 -07:00
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
322 changed files with 47836 additions and 2910 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,767 @@
//! Deterministic native fixture and literal complete-Project Allocation oracle.
//! No desktop planner, inferred output delta, or GUI input is used by this oracle.
use oa_core::{source_project::*, *};
use std::{collections::BTreeMap, fs, io::Write, path::Path};
type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
const MODES: [&str; 6] = [
"unchanged",
"oa-assigned",
"sa-assigned",
"oa-arranged",
"sa-arranged",
"oa-performers",
];
fn id(alias: &str) -> Id {
if alias == "oa_assign" {
return Id::from_u128(0x292609230001);
}
Id::new_v5(
&Id::from_u128(0xc1192026_0923_5000_8000_000000002931),
alias.as_bytes(),
)
}
fn xy(x: f32, y: f32) -> Position {
Position { x, y }
}
fn named(scope: &str, suffix: &str) -> String {
format!("{scope}_{suffix}")
}
fn need(ok: bool, message: impl Into<String>) -> Result<()> {
if ok {
Ok(())
} else {
Err(message.into().into())
}
}
fn element(p: &mut Project, alias: &str, name: &str, kind: Kind, layer: Layer) {
p.elements.insert(
id(alias),
Element {
id: id(alias),
alias: alias.into(),
name: name.into(),
description: format!("Preserve engineering record {alias}."),
parent: None,
body: ElementBody::new(kind, layer).unwrap(),
},
);
}
fn diagram(p: &mut Project, alias: &str, name: &str, layer: Layer, projection: ProjectionKind) {
p.diagrams.insert(
id(alias),
Diagram {
id: id(alias),
alias: alias.into(),
name: name.into(),
description: "Native group fixture; preserve unrelated records exactly.".into(),
layer,
projection,
view_options: Default::default(),
nodes: Default::default(),
routes: vec![],
},
);
}
fn place(p: &mut Project, scope: &str, suffix: &str, position: Position) {
let alias = named(scope, &format!("node_{suffix}"));
p.diagrams.get_mut(&id(scope)).unwrap().nodes.insert(
id(&alias),
NodePlacement {
id: id(&alias),
alias,
element: id(&named(scope, suffix)),
position,
size: Size {
width: 224.,
height: 94.,
},
collapsed: false,
port_anchors: vec![],
},
);
}
fn relation(
p: &mut Project,
scope: &str,
suffix: &str,
kind: RelationKind,
from: &str,
to: &str,
) -> Result<()> {
let alias = named(scope, suffix);
let old = p.relate(
kind,
&format!("{scope} {suffix}"),
id(&named(scope, from)),
id(&named(scope, to)),
);
reidentify(p, old, id(&alias))?;
let r = p.relations.get_mut(&id(&alias)).unwrap();
r.alias = alias.clone();
r.description = format!("Reviewed identity and provenance {alias} must survive.");
// The actual constructor creates proper SA function contracts. Normalize
// every generated identity/alias; no random UUID can leak into the fixture.
let ports = match r.body {
RelationBody::FunctionalExchange {
source_port,
target_port,
..
} => Some([source_port, target_port]),
_ => None,
};
if let Some(ports) = ports {
for (old_port, suffix) in ports.into_iter().zip(["output", "input"]) {
let alias = format!("{alias}_{suffix}");
reidentify(p, old_port, id(&alias))?;
let port = p.elements.get_mut(&id(&alias)).unwrap();
port.alias = alias.clone();
port.name = format!("{scope} {suffix}");
port.description = format!("Preserved typed endpoint {alias}.");
}
}
Ok(())
}
fn route(scope: &str, pins: [Position; 2]) -> EdgeRoute {
EdgeRoute {
edge: id(&named(scope, "exchange")),
waypoints: pins.to_vec(),
source_attachment: None,
target_attachment: None,
label: Some(EdgeLabel {
fraction: 0.45,
offset: xy(7., -19.),
}),
}
}
fn fixture() -> Result<Project> {
let mut p = Project::empty();
p.id = id("project");
p.alias = "allocation_multiselect_native".into();
p.name = "Allocation group native".into();
p.description = "Four independent compact OA/SA assignment and Arrange trials; ordinary performers use fresh OA assign baseline.".into();
for (layer, prefix, work, performer) in [
(Layer::Oa, "oa", Kind::Activity, Kind::Entity),
(Layer::Sa, "sa", Kind::Function, Kind::SystemActor),
] {
for arrange in [false, true] {
let scope = format!("{prefix}_{}", if arrange { "arrange" } else { "assign" });
let number = match (prefix, arrange) {
("oa", false) => "01",
("sa", false) => "02",
("oa", true) => "03",
_ => "04",
};
diagram(
&mut p,
&scope,
&format!(
"{number} {} {}",
prefix.to_uppercase(),
if arrange { "arrange" } else { "assign" }
),
layer,
ProjectionKind::Allocation,
);
let records: Vec<_> = if arrange {
vec![
("p", "P - team", performer, xy(40., 40.)),
("u", "U - contained work", work, xy(80., 140.)),
("v", "V - independent work", work, xy(440., 140.)),
]
} else {
vec![
("a", "A - origin team", performer, xy(43., 47.)),
// Leave room for SA Y's automatic top port stub below
// B's 64-unit header plus 12-unit routing clearance.
("b", "B - other team", performer, xy(43., 337.)),
("c", "C - destination", performer, xy(500., 40.)),
("x", "X - receive", work, xy(80., 140.)),
("y", "Y - assess", work, xy(80., 440.)),
]
};
for (suffix, label, kind, pos) in records {
// SA admits one System for the entire project, not one per view.
let kind = if layer == Layer::Sa && !arrange && suffix == "a" {
Kind::System
} else {
kind
};
element(&mut p, &named(&scope, suffix), label, kind, layer);
place(&mut p, &scope, suffix, pos);
}
let (from, to, pins) = if arrange {
relation(&mut p, &scope, "pu", RelationKind::Allocation, "p", "u")?;
("u", "v", [xy(360., 180.), xy(360., 230.)])
} else {
p.diagrams
.get_mut(&id(&scope))
.unwrap()
.nodes
.get_mut(&id(&named(&scope, "node_c")))
.unwrap()
.size = Size {
width: 360.,
height: 600.,
};
relation(&mut p, &scope, "ax", RelationKind::Allocation, "a", "x")?;
relation(&mut p, &scope, "by", RelationKind::Allocation, "b", "y")?;
("x", "y", [xy(340., 260.), xy(340., 400.)])
};
relation(
&mut p,
&scope,
"exchange",
if layer == Layer::Oa {
RelationKind::Interaction
} else {
RelationKind::FunctionalExchange
},
from,
to,
)?;
p.diagrams
.get_mut(&id(&scope))
.unwrap()
.routes
.push(route(&scope, pins));
}
}
diagram(
&mut p,
"other_view",
"05 Other saved view - preserve",
Layer::Oa,
ProjectionKind::Overview,
);
for (suffix, pos) in [("x", xy(40., 60.)), ("y", xy(410., 180.))] {
let alias = named("other_view", suffix);
p.diagrams.get_mut(&id("other_view")).unwrap().nodes.insert(
id(&alias),
NodePlacement {
id: id(&alias),
alias,
element: id(&named("oa_assign", suffix)),
position: pos,
size: Size {
width: 224.,
height: 94.,
},
collapsed: false,
port_anchors: vec![],
},
);
}
validate_candidate(&p)?;
Ok(p)
}
fn node_mut<'a>(p: &'a mut Project, scope: &str, suffix: &str) -> &'a mut NodePlacement {
p.diagrams
.get_mut(&id(scope))
.unwrap()
.nodes
.get_mut(&id(&named(scope, &format!("node_{suffix}"))))
.unwrap()
}
fn expected(before: &Project, mode: &str) -> Result<Project> {
let mut p = before.clone();
match mode {
"unchanged" => {}
"oa-assigned" | "sa-assigned" => {
let scope = if mode == "oa-assigned" {
"oa_assign"
} else {
"sa_assign"
};
for (rel, work) in [("ax", "x"), ("by", "y")] {
p.relations.get_mut(&id(&named(scope, rel))).unwrap().body =
RelationBody::Allocation {
source: id(&named(scope, "c")),
target: id(&named(scope, work)),
};
}
node_mut(&mut p, scope, "x").position = xy(560., 140.);
node_mut(&mut p, scope, "y").position = xy(560., 440.);
p.diagrams.get_mut(&id(scope)).unwrap().routes[0].waypoints =
vec![xy(820., 260.), xy(820., 400.)];
}
"oa-arranged" | "sa-arranged" => {
let scope = if mode == "oa-arranged" {
"oa_arrange"
} else {
"sa_arrange"
};
for (suffix, pos) in [
("p", xy(40., 280.)),
("u", xy(80., 380.)),
("v", xy(440., 380.)),
] {
node_mut(&mut p, scope, suffix).position = pos;
}
p.diagrams.get_mut(&id(scope)).unwrap().routes[0].waypoints =
vec![xy(360., 420.), xy(360., 470.)];
}
"oa-performers" => {
for (suffix, pos) in [
("a", xy(120., 90.)),
("b", xy(120., 380.)),
("x", xy(157., 183.)),
("y", xy(157., 483.)),
] {
node_mut(&mut p, "oa_assign", suffix).position = pos;
}
p.diagrams.get_mut(&id("oa_assign")).unwrap().routes[0].waypoints =
vec![xy(417., 303.), xy(417., 443.)];
}
_ => return Err(format!("Unknown mode {mode}; expected {MODES:?}").into()),
}
Ok(p)
}
fn ancestor(view: &projection::ProjectedView, parent: Id, child: Id) -> bool {
let mut next = view
.nodes
.iter()
.find(|n| n.id == child)
.and_then(|n| n.visual_parent);
for _ in 0..view.nodes.len() {
let Some(current) = next else {
return false;
};
if current == parent {
return true;
}
next = view
.nodes
.iter()
.find(|n| n.id == current)
.and_then(|n| n.visual_parent);
}
false
}
/// Fixture-usability preflight only: never calculates expected mutations.
fn preflight(p: &Project) -> Result<serde_json::Value> {
validate_candidate(p)?;
let mut scenes = Vec::new();
for scope in ["oa_assign", "sa_assign", "oa_arrange", "sa_arrange"] {
let d = &p.diagrams[&id(scope)];
let view = projection::project_view(p, d.id);
need(
view.nodes.len() == d.nodes.len(),
format!("{scope}: hidden/missing fixture placement"),
)?;
for n in &view.nodes {
need(
n.position == d.nodes[&n.id].position,
format!("{scope}: unexpectedly shifted projected origin {}", n.id),
)?;
need(
n.position.x >= 0.
&& n.position.y >= 0.
&& n.position.x + n.size.width <= 900.
&& n.position.y + n.size.height <= 750.,
format!("{scope}: fixture exceeds declared compact world envelope: {n:?}"),
)?;
if p.elements[&n.element].kind().is_work() {
let height = if d.layer == Layer::Sa { 132. } else { 94. };
need(
n.size
== Size {
width: 224.,
height,
},
format!("{scope}: unexpected work/port extent {n:?}"),
)?;
}
}
for (i, a) in view.nodes.iter().enumerate() {
for b in &view.nodes[i + 1..] {
if ancestor(&view, a.id, b.id) || ancestor(&view, b.id, a.id) {
continue;
}
let width = (a.position.x + a.size.width).min(b.position.x + b.size.width)
- a.position.x.max(b.position.x);
let height = (a.position.y + a.size.height).min(b.position.y + b.size.height)
- a.position.y.max(b.position.y);
need(
width <= 0. || height <= 0.,
format!("{scope}: unrelated projected overlap {} {}", a.id, b.id),
)?;
}
}
let routed = routing::route_diagram(p, d, &view);
let edge = id(&named(scope, "exchange"));
let r = routed
.edges
.get(&edge)
.ok_or("Authored exchange route is missing")?;
need(
!r.custom_suspended && r.pins == d.routes[0].waypoints,
format!("{scope}: authored pins are not active"),
)?;
need(
r.warnings.is_empty(),
format!(
"{scope}: route warnings require fixture review: {:?}; ports={:?}; points={:?}; nodes={:?}",
r.warnings, view.ports, r.points, view.nodes
),
)?;
need(
view.ports.len() == if d.layer == Layer::Sa { 2 } else { 0 },
format!("{scope}: unexpected visible typed ports"),
)?;
scenes.push(serde_json::json!({"diagram":scope,"nodes":view.nodes,"ports":view.ports,"active_pins":r.pins,"route_warnings":r.warnings.iter().map(ToString::to_string).collect::<Vec<_>>()}));
}
Ok(serde_json::json!(scenes))
}
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())?;
need(
!disk
.diagnostics
.iter()
.any(|d| d.diagnostic.severity == validation::Severity::Error),
format!("Source discovery errors: {:?}", disk.diagnostics),
)?;
let compiled = compile_bundle(&disk.reachable_bundle(), ProjectLimits::default())
.map_err(|e| format!("Source compile: {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 mut bundle = format_bundle(p);
for (path, text) in &mut bundle.files {
*text = format!("// Native preservation sentinel: {path}\n{text}");
}
let parsed = compile_bundle(&bundle, ProjectLimits::default())
.map_err(|e| format!("Source roundtrip: {e:?}"))?;
equal(p, &parsed.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<()> {
need(!out.exists(), "Output exists; choose a fresh directory")?;
let p = fixture()?;
let bundle = roundtrip(&p)?;
let mut geometry = BTreeMap::new();
// All literal candidates must satisfy the same compact-fixture prerequisites
// before any bytes are published for a native run.
for mode in MODES {
let candidate = expected(&p, mode)?;
roundtrip(&candidate)?;
geometry.insert(mode, preflight(&candidate)?);
}
let mut ids = BTreeMap::from([(p.alias.clone(), p.id)]);
ids.extend(p.elements.values().map(|v| (v.alias.clone(), v.id)));
ids.extend(p.relations.values().map(|v| (v.alias.clone(), v.id)));
ids.extend(p.diagrams.values().map(|v| (v.alias.clone(), v.id)));
ids.extend(
p.diagrams
.values()
.flat_map(|d| d.nodes.values().map(|v| (v.alias.clone(), v.id))),
);
write_new(&out.join("baseline.json"), &serde_json::to_vec_pretty(&p)?)?;
for (path, text) in &bundle.files {
write_new(&out.join("source").join(path), text.as_bytes())?;
}
let paths: BTreeMap<_, _> = ids
.iter()
.map(|(alias, id)| {
let needle = id.to_string();
let files: Vec<_> = bundle
.files
.iter()
.filter(|(_, text)| text.contains(&needle))
.map(|(path, _)| path.clone())
.collect();
(alias.clone(), files)
})
.collect();
write_new(
&out.join("manifest.json"),
&serde_json::to_vec_pretty(&serde_json::json!({
"fixture":"allocation-multiselect-native-20260923","source_entry":bundle.entry,"ids":ids,
"source_files":bundle.files.keys().collect::<Vec<_>>(),"id_text_occurrence_files":paths,
"modes":MODES,"geometry_preflight":geometry,
"literal_deltas":{"oa-assigned":[480,0],"sa-assigned":[480,0],"oa-arranged":[0,240],"sa-arranged":[0,240],"oa-performers":[77,43]},
"selection":{"assigned":["X","Y"],"arranged":["P","V"],"ordinary_performers":["A","B"]},
"ordinary_anchor_target":[120,90],
"evaluation":"Literal complete Project oracle, never inferred from saved geometry; baseline pinned to deterministic original",
"limits":"Geometry preflight is not GUI/planner admission. Source comments/history bytes, focus, exact selection, pointer delivery and one Undo/Redo require separate observation/checks. Each mode is one operation from baseline."
}))?,
)?;
equal(&p, &load(&out.join("baseline.json"))?)?;
equal(&p, &load(&out.join("source").join(&bundle.entry))?)?;
println!("PASS generated and reloaded fixture: {}", out.display());
Ok(())
}
fn check(mode: &str, baseline: &Path, saved: &Path) -> Result<()> {
let before = load(baseline)?;
equal(&fixture()?, &before).map_err(|e| format!("Baseline is not immutable original: {e}"))?;
let actual = load(saved)?;
validate_candidate(&actual)?;
equal(&expected(&before, mode)?, &actual)?;
roundtrip(&actual)?;
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
"result":"PASS","mode":mode,"baseline":baseline,"saved":saved,
"comparison":"complete Project equality; literal fixed record edits",
"limits":"No pointer, selection, source-byte preservation, focus, Undo/Redo, or native 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(format!("Usage: allocation_multiselect_native generate DIRECTORY | check MODE BASELINE SAVED; modes={MODES:?}").into()),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn through_json(mode: &str, before: &Project, actual: &Project) -> Result<()> {
validate_candidate(before).expect("baseline valid");
validate_candidate(actual).expect("falsifier must remain core-valid");
let dir = tempfile::tempdir()?;
let baseline = dir.path().join("baseline.json");
let saved = dir.path().join("saved.json");
persistence::save(&baseline, before)?;
persistence::save(&saved, actual)?;
check(mode, &baseline, &saved)
}
fn mismatch(mode: &str, before: &Project, actual: &Project) {
let error = through_json(mode, before, actual).unwrap_err().to_string();
assert!(
error.contains("Whole Project mismatch"),
"wrong failure cause: {error}"
);
}
#[test]
fn deterministic_create_only_generation_and_all_literal_modes() {
let p = fixture().unwrap();
assert_eq!(p, fixture().unwrap());
let dir = tempfile::tempdir().unwrap();
let out = dir.path().join("fixture");
generate(&out).unwrap();
let before = fs::read(out.join("baseline.json")).unwrap();
assert!(generate(&out).is_err());
assert_eq!(before, fs::read(out.join("baseline.json")).unwrap());
for mode in MODES {
let candidate = expected(&p, mode).unwrap();
preflight(&candidate).unwrap();
through_json(mode, &p, &candidate).unwrap();
if mode != "unchanged" {
mismatch(mode, &p, &p);
}
}
assert!(expected(&p, "invented").is_err());
let changed = expected(&p, "oa-assigned").unwrap();
assert!(
through_json("unchanged", &changed, &changed)
.unwrap_err()
.to_string()
.contains("immutable original")
);
}
#[test]
fn actual_core_assignment_batch_matches_literal_oracle_and_one_step_history() {
let before = fixture().unwrap();
for (mode, scope) in [("oa-assigned", "oa_assign"), ("sa-assigned", "sa_assign")] {
let mut commands = Vec::new();
for (alias, work) in [("ax", "x"), ("by", "y")] {
let relation = id(&named(scope, alias));
let mut value = before.relations[&relation].clone();
value.body = RelationBody::Allocation {
source: id(&named(scope, "c")),
target: id(&named(scope, work)),
};
commands.push(Command::EditRelation(relation, value));
}
for (suffix, position) in [("x", xy(560., 140.)), ("y", xy(560., 440.))] {
let original = id(&named(scope, &format!("node_{suffix}")));
let mut node = before.diagrams[&id(scope)].nodes[&original].clone();
node.position = position;
commands.push(Command::EditNode {
diagram: id(scope),
original,
node,
});
}
let mut route = before.diagrams[&id(scope)].routes[0].clone();
route.waypoints = vec![xy(820., 260.), xy(820., 400.)];
commands.push(Command::SetEdgeRoute {
diagram: id(scope),
route,
});
let mut session = Session::new(before.clone());
session.execute(Command::Batch(commands)).unwrap();
through_json(mode, &before, session.project()).unwrap();
let after = session.project().clone();
session.undo();
assert_eq!(session.project(), &before);
assert!(!session.can_undo());
session.redo();
assert_eq!(session.project(), &after);
assert!(!session.can_redo());
}
}
#[test]
fn assignment_rejects_singleton_semantic_identity_and_route_drift() {
let before = fixture().unwrap();
for mode in ["oa-assigned", "sa-assigned"] {
let scope = if mode == "oa-assigned" {
"oa_assign"
} else {
"sa_assign"
};
let good = expected(&before, mode).unwrap();
let mut bad = good.clone();
node_mut(&mut bad, scope, "y").position = xy(80., 440.);
mismatch(mode, &before, &bad);
let mut bad = good.clone();
bad.relations.insert(
id(&named(scope, "by")),
before.relations[&id(&named(scope, "by"))].clone(),
);
mismatch(mode, &before, &bad);
let mut bad = good.clone();
reidentify(
&mut bad,
id(&named(scope, "ax")),
id("replacement_allocation"),
)
.unwrap();
mismatch(mode, &before, &bad);
let mut bad = good.clone();
bad.relations
.get_mut(&id(&named(scope, "ax")))
.unwrap()
.description
.push_str(" drift");
mismatch(mode, &before, &bad);
for pins in [
vec![xy(340., 260.), xy(340., 400.)],
vec![xy(1300., 260.), xy(1300., 400.)],
] {
let mut bad = good.clone();
bad.diagrams.get_mut(&id(scope)).unwrap().routes[0].waypoints = pins;
mismatch(mode, &before, &bad);
}
}
}
#[test]
fn arrange_and_ordinary_closure_falsifiers_remain_core_valid() {
let before = fixture().unwrap();
for mode in ["oa-arranged", "sa-arranged"] {
let scope = if mode == "oa-arranged" {
"oa_arrange"
} else {
"sa_arrange"
};
let good = expected(&before, mode).unwrap();
for (suffix, pos) in [("u", xy(80., 140.)), ("v", xy(440., 140.))] {
let mut bad = good.clone();
node_mut(&mut bad, scope, suffix).position = pos;
mismatch(mode, &before, &bad);
}
let mut bad = good.clone();
bad.relations.remove(&id(&named(scope, "pu")));
mismatch(mode, &before, &bad);
}
let good = expected(&before, "oa-performers").unwrap();
let mut bad = good.clone();
node_mut(&mut bad, "oa_assign", "x").position = xy(80., 140.);
mismatch("oa-performers", &before, &bad);
let mut bad = good;
bad.relations.get_mut(&id("oa_assign_ax")).unwrap().body = RelationBody::Allocation {
source: id("oa_assign_c"),
target: id("oa_assign_x"),
};
mismatch("oa-performers", &before, &bad);
}
#[test]
fn whole_project_sentinels_and_real_saved_source_are_checked() {
let before = fixture().unwrap();
let dir = tempfile::tempdir().unwrap();
let baseline = dir.path().join("baseline.json");
persistence::save(&baseline, &before).unwrap();
for mode in MODES {
let good = expected(&before, mode).unwrap();
for corrupt in [false, true] {
let mut actual = good.clone();
if corrupt {
actual
.elements
.get_mut(&id("sa_arrange_v"))
.unwrap()
.description
.push_str(" drift");
}
let bundle = roundtrip(&actual).unwrap();
let folder = dir.path().join(format!("{mode}-{corrupt}"));
for (path, text) in &bundle.files {
write_new(&folder.join(path), text.as_bytes()).unwrap();
}
let result = check(mode, &baseline, &folder.join(&bundle.entry));
if corrupt {
assert!(
result
.unwrap_err()
.to_string()
.contains("Whole Project mismatch")
);
} else {
result.unwrap();
}
}
let mut bad = good;
bad.diagrams
.get_mut(&id("other_view"))
.unwrap()
.nodes
.get_mut(&id("other_view_x"))
.unwrap()
.position
.x += 10.;
mismatch(mode, &before, &bad);
}
let invalid = dir.path().join("invalid.arcadia");
write_new(&invalid, b"project {").unwrap();
assert!(load(&invalid).is_err());
}
}
@@ -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,548 @@
//! Deterministic Process native fixture and independent complete-Project oracle.
//! Expected order is literal; no desktop reorder/layout planner is used here.
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 {
Id::new_v5(
&Id::from_u128(0xc1192026_0923_5000_8000_000000002930),
alias.as_bytes(),
)
}
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 occurrence(
p: &mut Project,
alias: &str,
name: &str,
layer: Layer,
owner: &str,
value: OccurrenceBody,
) {
element(p, alias, name, Kind::Occurrence, layer, Some(id(owner)));
let Some(SharedBody::Occurrence { body, .. }) =
p.elements.get_mut(&id(alias)).unwrap().shared_mut()
else {
panic!("Occurrence body");
};
*body = value;
}
fn set_steps(p: &mut Project, owner: &str, aliases: &[&str]) {
*p.elements.get_mut(&id(owner)).unwrap().steps_mut().unwrap() =
aliases.iter().map(|alias| id(alias)).collect();
}
fn sequence_link(
p: &mut Project,
alias: &str,
name: &str,
source: &str,
target: &str,
) -> Result<()> {
let old = p.relate(RelationKind::SequenceLink, 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 link {alias} exactly.");
if let RelationBody::SequenceLink { guard, .. } = &mut relation.body {
*guard = "ready".into();
}
Ok(())
}
fn fixture() -> Result<Project> {
let mut p = Project::empty();
p.id = id("project");
p.alias = "process_multiselect_native".into();
p.name = "Process group native".into();
p.description = "Select Card C then Shift-select Card A; drop after Card B. Other columns and reusable engineering records remain exact.".into();
for (layer, prefix, owner, name, owner_kind, work_kind) in [
(
Layer::Oa,
"oa",
"oa_process",
"01 OA group order",
Kind::Process,
Kind::Activity,
),
(
Layer::Sa,
"sa",
"sa_chain",
"SA chain - preserve",
Kind::FunctionalChain,
Kind::Function,
),
] {
element(&mut p, owner, name, owner_kind, layer, None);
let shared = format!("{prefix}_shared_work");
let downstream = format!("{prefix}_downstream_work");
element(
&mut p,
&shared,
"Shared request work",
work_kind,
layer,
None,
);
element(
&mut p,
&downstream,
"Downstream verification",
work_kind,
layer,
None,
);
for (suffix, label) in [
("a", "Card A"),
("u", "Downstream U"),
("b", "Card B"),
("v", "Downstream V"),
("c", "Card C"),
] {
let body = if suffix == "b" {
OccurrenceBody::Control {
kind: ControlKind::Choice,
}
} else {
OccurrenceBody::Action {
action: Some(id(if suffix == "a" || suffix == "c" {
&shared
} else {
&downstream
})),
lifeline: None,
}
};
occurrence(
&mut p,
&format!("{prefix}_{suffix}"),
label,
layer,
owner,
body,
);
}
let order: Vec<_> = ["a", "u", "b", "v", "c"]
.map(|suffix| format!("{prefix}_{suffix}"))
.into_iter()
.collect();
set_steps(
&mut p,
owner,
&order.iter().map(String::as_str).collect::<Vec<_>>(),
);
sequence_link(
&mut p,
&format!("{prefix}_a_u"),
"A to U",
&format!("{prefix}_a"),
&format!("{prefix}_u"),
)?;
sequence_link(
&mut p,
&format!("{prefix}_b_v"),
"B to V",
&format!("{prefix}_b"),
&format!("{prefix}_v"),
)?;
}
element(
&mut p,
"reusable",
"Reusable request detail",
Kind::Process,
Layer::Oa,
None,
);
occurrence(
&mut p,
"reused_leaf",
"Shared detail",
Layer::Oa,
"reusable",
OccurrenceBody::Action {
action: Some(id("oa_shared_work")),
lifeline: None,
},
);
set_steps(&mut p, "reusable", &["reused_leaf"]);
element(
&mut p,
"reuse_trial",
"02 Read-only preview trial",
Kind::Process,
Layer::Oa,
None,
);
for (alias, name) in [
("reference_one", "First use"),
("reference_two", "Second use"),
] {
occurrence(
&mut p,
alias,
name,
Layer::Oa,
"reuse_trial",
OccurrenceBody::SequenceReference {
sequence: Some(id("reusable")),
},
);
}
set_steps(&mut p, "reuse_trial", &["reference_one", "reference_two"]);
p.traces.insert(
id("trace"),
Trace {
id: id("trace"),
alias: "preserved_work_refinement".into(),
name: "Shared request refinement".into(),
description: "Process arrangement must preserve cross-layer correspondence.".into(),
oa: id("oa_shared_work"),
sa: id("sa_shared_work"),
kind: TraceKind::Refinement,
baseline: Some(TraceBaseline {
oa: p.properties(id("oa_shared_work")).unwrap(),
sa: p.properties(id("sa_shared_work")).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())?;
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 expected = serde_json::to_value(expected)?;
let actual = serde_json::to_value(actual)?;
let fields: Vec<_> = expected
.as_object()
.unwrap()
.keys()
.filter(|key| expected[*key] != actual[*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 ids: std::collections::BTreeMap<_, _> = [
"project",
"oa_process",
"oa_a",
"oa_u",
"oa_b",
"oa_v",
"oa_c",
"oa_a_u",
"oa_b_v",
"oa_shared_work",
"oa_downstream_work",
"sa_chain",
"sa_shared_work",
"sa_downstream_work",
"sa_a",
"sa_u",
"sa_b",
"sa_v",
"sa_c",
"sa_a_u",
"sa_b_v",
"trace",
"reusable",
"reused_leaf",
"reuse_trial",
"reference_one",
"reference_two",
]
.into_iter()
.map(|name| (name, id(name)))
.collect();
write_new(
&out.join("manifest.json"),
&serde_json::to_vec_pretty(&serde_json::json!({
"fixture": "process-multiselect-native-20260923", "ids": ids, "source_entry": bundle.entry,
"original_oa_steps": ["oa_a", "oa_u", "oa_b", "oa_v", "oa_c"],
"reordered_oa_steps": ["oa_b", "oa_u", "oa_a", "oa_v", "oa_c"],
"gesture": "Select Card C then add Card A; Inspect Card C without changing membership, then drag Card C to After Card B",
"fixed_slots": {"1": "oa_u", "3": "oa_v"},
"read_only_trial": "02 Read-only preview trial has two qualified tiles of reused_leaf through distinct references",
"receipt": "Pin baseline.json, manifest.json and generation command externally before interaction. Never replace baseline with a later saved snapshot.",
"evaluation": "Literal expected OA steps and complete Project equality; all SA/reuse/trace records exact",
"limits": "No pointer, focus, selection-paint, history or source-byte acceptance claim",
}))?,
)?;
equal(&p, &load(&out.join("baseline.json"))?)?;
equal(&p, &load(&out.join("source").join(&bundle.entry))?)?;
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)?;
// Pin the exact deterministic original, not merely a reusable project UUID.
equal(&fixture()?, &before)
.map_err(|e| format!("Baseline is not the immutable original: {e}"))?;
let mut expected = before;
match mode {
"unchanged" => {}
"reordered" => set_steps(
&mut expected,
"oa_process",
&["oa_b", "oa_u", "oa_a", "oa_v", "oa_c"],
),
_ => return Err("Mode must be reordered 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; literal expected order",
"limits": "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: process_multiselect_native generate DIRECTORY | check reordered|unchanged BASELINE SAVED".into()),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn reordered(before: &Project) -> Project {
let mut session = Session::new(before.clone());
session
.execute(Command::SetSteps(
id("oa_process"),
["oa_b", "oa_u", "oa_a", "oa_v", "oa_c"].map(id).to_vec(),
))
.unwrap();
session.project().clone()
}
fn through_json(mode: &str, baseline: &Project, actual: &Project) -> Result<()> {
validate_candidate(baseline).expect("Baseline must be valid");
validate_candidate(actual).expect("Corruption must remain semantically valid");
let dir = tempfile::tempdir()?;
let before = dir.path().join("before.json");
let saved = dir.path().join("saved.json");
persistence::save(&before, baseline)?;
persistence::save(&saved, actual)?;
check(mode, &before, &saved)
}
fn mismatch(before: &Project, actual: &Project) {
let error = through_json("reordered", before, actual)
.unwrap_err()
.to_string();
assert!(
error.contains("Whole Project mismatch"),
"Unexpected rejection: {error}"
);
}
#[test]
fn generate_is_deterministic_create_only_and_checks_real_source() {
equal(&fixture().unwrap(), &fixture().unwrap()).unwrap();
let dir = tempfile::tempdir().unwrap();
let out = dir.path().join("fixture");
generate(&out).unwrap();
let baseline = out.join("baseline.json");
let bytes = fs::read(&baseline).unwrap();
check("unchanged", &baseline, &out.join("source/project.arcadia")).unwrap();
assert!(generate(&out).is_err());
assert_eq!(fs::read(&baseline).unwrap(), bytes);
let invalid = dir.path().join("invalid.arcadia");
fs::write(&invalid, "project {").unwrap();
assert!(load(&invalid).is_err());
}
#[test]
fn real_set_steps_passes_but_unchanged_and_mutated_baseline_do_not() {
let before = fixture().unwrap();
let actual = reordered(&before);
through_json("reordered", &before, &actual).unwrap();
through_json("unchanged", &before, &before).unwrap();
mismatch(&before, &before);
assert!(through_json("unchanged", &before, &actual).is_err());
let error = through_json("unchanged", &actual, &actual)
.unwrap_err()
.to_string();
assert!(error.contains("immutable original"), "{error}");
}
#[test]
fn global_splice_reversed_block_and_singleton_c_results_are_rejected() {
let before = fixture().unwrap();
// Native primary/pressed C is already after B, so a singleton C move
// is unchanged. Group {C,A} instead puts B before both selected cards.
// If A were primary, its singleton result could equal the group result.
mismatch(&before, &before);
for order in [
["oa_u", "oa_b", "oa_a", "oa_c", "oa_v"],
["oa_b", "oa_u", "oa_c", "oa_v", "oa_a"],
["oa_a", "oa_u", "oa_b", "oa_c", "oa_v"],
] {
let mut actual = before.clone();
set_steps(&mut actual, "oa_process", &order);
mismatch(&before, &actual);
}
}
#[test]
fn semantic_link_work_sa_reuse_and_trace_changes_are_rejected() {
let before = fixture().unwrap();
let correct = reordered(&before);
let mutations: [fn(&mut Project); 7] = [
|p| {
if let RelationBody::SequenceLink { guard, .. } =
&mut p.relations.get_mut(&id("oa_a_u")).unwrap().body
{
*guard = "changed".into();
}
},
|p| {
if let RelationBody::SequenceLink { target, .. } =
&mut p.relations.get_mut(&id("oa_a_u")).unwrap().body
{
*target = id("oa_v");
}
},
|p| {
p.elements
.get_mut(&id("oa_shared_work"))
.unwrap()
.name
.push_str(" drift");
},
|p| {
p.elements
.get_mut(&id("sa_shared_work"))
.unwrap()
.description
.push_str(" drift");
},
|p| {
set_steps(p, "sa_chain", &["sa_b", "sa_u", "sa_a", "sa_v", "sa_c"]);
},
|p| {
p.elements
.get_mut(&id("reused_leaf"))
.unwrap()
.description
.push_str(" drift");
},
|p| {
p.traces.get_mut(&id("trace")).unwrap().baseline = None;
},
];
for mutate in mutations {
let mut bad = correct.clone();
mutate(&mut bad);
mismatch(&before, &bad);
}
}
#[test]
fn changed_source_roundtrip_preserves_both_layers_and_checker_rejects_source_drift() {
let before = fixture().unwrap();
let actual = reordered(&before);
let dir = tempfile::tempdir().unwrap();
let baseline = dir.path().join("baseline.json");
persistence::save(&baseline, &before).unwrap();
for corrupt in [false, true] {
let mut saved = actual.clone();
if corrupt {
saved
.elements
.get_mut(&id("sa_c"))
.unwrap()
.name
.push_str(" drift");
}
let bundle = roundtrip(&saved).unwrap();
let folder = dir.path().join(if corrupt { "corrupt" } else { "saved" });
for (path, text) in &bundle.files {
write_new(&folder.join(path), text.as_bytes()).unwrap();
}
let parsed = load(&folder.join(&bundle.entry)).unwrap();
assert_eq!(parsed.elements[&id("oa_process")].kind(), Kind::Process);
assert_eq!(
parsed.elements[&id("sa_chain")].kind(),
Kind::FunctionalChain
);
let result = check("reordered", &baseline, &folder.join(&bundle.entry));
if corrupt {
assert!(
result
.unwrap_err()
.to_string()
.contains("Whole Project mismatch")
);
} else {
result.unwrap();
}
}
}
}
@@ -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;
+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.));
}
@@ -0,0 +1,14 @@
//! Focused test harness for the production Allocation planner and Document.
//! This does not exercise App input, painting, or native gestures.
#[cfg(test)]
#[allow(dead_code)] // App-facing entry points are intentionally absent here.
#[path = "../src/allocation_drag.rs"]
mod allocation_drag;
#[cfg(test)]
#[allow(dead_code, unused_imports)] // App-facing API and reexports are absent here.
#[path = "../src/document.rs"]
mod document;
fn main() {}
+122 -110
View File
@@ -13,6 +13,19 @@ use oa_core::{
};
use std::collections::{BTreeMap, BTreeSet};
// The pure planner is verified before its App gesture adapter is connected.
#[allow(dead_code)]
#[path = "allocation_drag/arrange.rs"]
pub mod arrange;
#[path = "allocation_drag/geometry.rs"]
mod geometry;
// The combined semantic planner is verified before connecting its App adapter.
#[allow(dead_code)]
#[path = "allocation_drag/assignment.rs"]
pub mod assignment;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Destination {
LayoutOnly,
@@ -76,6 +89,11 @@ pub enum Refusal {
pub fn capture(p: &Project, diagram: Id, work: Id, placement: Id) -> Result<Scope, Refusal> {
validate_candidate(p).map_err(|e| Refusal::InvalidCandidate(e.to_string()))?;
capture_validated(p, diagram, work, placement)
}
/// Collect work facts after the caller's single complete Project validation.
fn capture_validated(p: &Project, diagram: Id, work: Id, placement: Id) -> Result<Scope, Refusal> {
let d = p.diagrams.get(&diagram).ok_or(Refusal::StaleScope)?;
let root = p.elements.get(&work).ok_or(Refusal::StaleScope)?;
if !root.kind().is_work()
@@ -232,8 +250,93 @@ pub fn plan_semantics(
let same_performer =
matches!(destination, Destination::Performer(id) if scope.uniform == Uniform::Assigned(id));
let mut expected = p.clone();
let mut commands = Vec::new();
let mut deleted = BTreeSet::new();
let mut edits = IntendedEdits::default();
append_semantic_edits(
p,
scope,
destination,
allocation_ids,
&mut expected,
&mut edits,
)?;
// A route cannot survive the intentional deletion of its own edge, even in
// another diagram. No other saved-view cleanup is authorized by this drag.
for diagram in expected.diagrams.values_mut() {
diagram.routes.retain(|r| !edits.deleted.contains(&r.edge));
}
let actual = prepare_command(p, &Command::Batch(edits.commands.clone()))
.map_err(|e| Refusal::InvalidCandidate(e.to_string()))?
.project;
exact_project(&expected, &actual)?;
let retained_roles = scope
.leaves
.values()
.any(|l| !l.role_assignments.is_empty());
let label = match destination {
Destination::LayoutOnly => "Arrange only".into(),
Destination::Performer(id) if same_performer => format!("Arrange within {}", p.named(id)),
Destination::Performer(id) => format!(
"Assign {} leaf {} to {}{}",
scope.leaves.len(),
match (scope.diagram.layer, scope.leaves.len()) {
(Layer::Oa, 1) => "activity",
(Layer::Oa, _) => "activities",
(Layer::Sa, 1) => "function",
(Layer::Sa, _) => "functions",
},
p.named(id),
if retained_roles {
" — explicit assignment; role responsibility retained"
} else {
""
}
),
Destination::OutsidePerformers if edits.commands.is_empty() => {
"Arrange unassigned work — decomposition retained".into()
}
Destination::OutsidePerformers => "Remove assignment — decomposition retained".into(),
};
let warnings = validation::validate(&actual)
.into_iter()
.filter(|i| i.severity == Severity::Warning)
.collect();
let index = ViewIndex::new(&actual);
Ok(SemanticPlan {
scope: scope.clone(),
destination,
label,
warnings,
accepted: p.clone(),
candidate: actual,
index,
commands: edits.commands,
before_visible,
before_expanded,
})
}
#[derive(Default)]
struct IntendedEdits {
commands: Vec<Command>,
deleted: BTreeSet<Id>,
changed: BTreeSet<Id>,
}
/// Read each root's accepted responsibility while accumulating one intended
/// Project, reserving relation identities and aliases across every root.
fn append_semantic_edits(
p: &Project,
scope: &Scope,
destination: Destination,
allocation_ids: &BTreeMap<Id, Id>,
expected: &mut Project,
edits: &mut IntendedEdits,
) -> Result<(), Refusal> {
let same_performer =
matches!(destination, Destination::Performer(id) if scope.uniform == Uniform::Assigned(id));
let commands = &mut edits.commands;
let deleted = &mut edits.deleted;
let changed = &mut edits.changed;
if destination != Destination::LayoutOnly && !same_performer {
for (work, leaf) in &scope.leaves {
match destination {
@@ -246,6 +349,7 @@ pub fn plan_semantics(
target: *work,
};
expected.relations.insert(edited.id, edited.clone());
changed.insert(*work);
commands.push(Command::EditRelation(edited.id, edited));
}
} else {
@@ -269,6 +373,7 @@ pub fn plan_semantics(
},
};
expected.relations.insert(id, relation.clone());
changed.insert(*work);
commands.push(Command::CreateRelation(relation));
}
}
@@ -327,6 +432,7 @@ pub fn plan_semantics(
if let Some(existing) = &leaf.direct {
expected.relations.remove(&existing.id);
deleted.insert(existing.id);
changed.insert(*work);
commands.push(Command::Delete(existing.id));
}
}
@@ -334,60 +440,7 @@ pub fn plan_semantics(
}
}
}
// A route cannot survive the intentional deletion of its own edge, even in
// another diagram. No other saved-view cleanup is authorized by this drag.
for diagram in expected.diagrams.values_mut() {
diagram.routes.retain(|r| !deleted.contains(&r.edge));
}
let actual = prepare_command(p, &Command::Batch(commands.clone()))
.map_err(|e| Refusal::InvalidCandidate(e.to_string()))?
.project;
exact_project(&expected, &actual)?;
let retained_roles = scope
.leaves
.values()
.any(|l| !l.role_assignments.is_empty());
let label = match destination {
Destination::LayoutOnly => "Arrange only".into(),
Destination::Performer(id) if same_performer => format!("Arrange within {}", p.named(id)),
Destination::Performer(id) => format!(
"Assign {} leaf {} to {}{}",
scope.leaves.len(),
match (scope.diagram.layer, scope.leaves.len()) {
(Layer::Oa, 1) => "activity",
(Layer::Oa, _) => "activities",
(Layer::Sa, 1) => "function",
(Layer::Sa, _) => "functions",
},
p.named(id),
if retained_roles {
" — explicit assignment; role responsibility retained"
} else {
""
}
),
Destination::OutsidePerformers if commands.is_empty() => {
"Arrange unassigned work — decomposition retained".into()
}
Destination::OutsidePerformers => "Remove assignment — decomposition retained".into(),
};
let warnings = validation::validate(&actual)
.into_iter()
.filter(|i| i.severity == Severity::Warning)
.collect();
let index = ViewIndex::new(&actual);
Ok(SemanticPlan {
scope: scope.clone(),
destination,
label,
warnings,
accepted: p.clone(),
candidate: actual,
index,
commands,
before_visible,
before_expanded,
})
Ok(())
}
/// World-space rectangle in the gesture's frozen transform. The UI resolver
@@ -462,60 +515,18 @@ pub fn place(
"Make room or adjust the view, then drag again.".into(),
));
}
let root = &plan.scope.placements[&plan.scope.placement];
let delta = Position {
x: (requested.x / 10.).round() * 10. - root.position.x,
y: (requested.y / 10.).round() * 10. - root.position.y,
};
let mut diagram = plan.candidate.diagrams[&plan.scope.diagram.id].clone();
let mut commands = Vec::new();
let mut deltas = BTreeMap::new();
for (id, original) in &plan.scope.placements {
let node = diagram.nodes.get_mut(id).ok_or(Refusal::StaleScope)?;
node.position = Position {
x: original.position.x + delta.x,
y: original.position.y + delta.y,
};
if !(Rect {
position: node.position,
size: node.size,
})
.valid()
{
return Err(Refusal::InvalidCandidate(
"Moved coordinates exceed the model bounds.".into(),
));
}
deltas.insert(*id, delta);
if node != original {
commands.push(Command::EditNode {
diagram: diagram.id,
original: *id,
node: node.clone(),
});
}
}
// Resolve endpoints using the captured accepted semantics, not a newly
// regrouped descendant walk. Own-edge deletions were removed above already.
for route in projection::translated_routes(&plan.accepted, &plan.scope.diagram, &deltas) {
if let Some(existing) = diagram.routes.iter_mut().find(|r| r.edge == route.edge) {
if route.waypoints.iter().any(|p| {
!p.x.is_finite()
|| !p.y.is_finite()
|| p.x.abs() > MAX_COORDINATE
|| p.y.abs() > MAX_COORDINATE
}) {
return Err(Refusal::InvalidCandidate(
"Moved route exceeds the model bounds.".into(),
));
}
*existing = route.clone();
commands.push(Command::SetEdgeRoute {
diagram: diagram.id,
route,
});
}
}
let geometry::Translation {
mut diagram,
mut commands,
delta,
} = geometry::translate(
&plan.accepted,
&plan.scope.diagram,
&plan.candidate.diagrams[&plan.scope.diagram.id],
&plan.scope.placements,
plan.scope.placement,
requested,
)?;
let mut unfolded = None;
if let Destination::Performer(performer) = plan.destination {
let target = presentation.target.ok_or_else(|| {
@@ -833,4 +844,5 @@ fn exact_project(expected: &Project, actual: &Project) -> Result<(), Refusal> {
}
#[cfg(test)]
#[path = "allocation_drag/tests.rs"]
mod tests;
@@ -0,0 +1,232 @@
//! Explicit, semantics-preserving arrangement of one or more Allocation cards.
//!
//! The App must admit the entire original typed selection before converting it
//! to placement/element identities. These values do not grant input authority.
use super::*;
#[derive(Debug)]
pub struct Plan {
accepted: Project,
diagram: Id,
selected: BTreeMap<Id, Id>,
anchor: Id,
placements: BTreeMap<Id, NodePlacement>,
expanded_ids: BTreeSet<Id>,
index: ViewIndex,
before_visible: ProjectedView,
before_expanded: ProjectedView,
}
impl Plan {
pub fn project(&self) -> &Project {
&self.accepted
}
pub fn index(&self) -> &ViewIndex {
&self.index
}
pub fn affected(&self) -> &BTreeMap<Id, NodePlacement> {
&self.placements
}
pub fn selected(&self) -> &BTreeMap<Id, Id> {
&self.selected
}
pub fn anchor(&self) -> Id {
self.anchor
}
pub fn diagram(&self) -> &Diagram {
&self.accepted.diagrams[&self.diagram]
}
}
/// Capture every original placement identity before normalizing semantic roots.
/// Document/revision/selection-generation and clean-owner guards belong to App.
pub fn capture(
p: &Project,
diagram: Id,
selected: &BTreeMap<Id, Id>,
anchor: Id,
) -> Result<Plan, Refusal> {
validate_candidate(p).map_err(|e| Refusal::InvalidCandidate(e.to_string()))?;
let d = p.diagrams.get(&diagram).ok_or(Refusal::StaleScope)?;
if d.projection != ProjectionKind::Allocation
|| selected.is_empty()
|| !selected.contains_key(&anchor)
{
return Err(Refusal::Ineligible(
"Arrange requires selected placements and a selected anchor in an Allocation view."
.into(),
));
}
let index = ViewIndex::new(p);
let before_visible = projection::project_diagram_with_index(p, d, &index);
let visible: BTreeMap<_, _> = before_visible
.nodes
.iter()
.map(|n| (n.id, n.element))
.collect();
let mut work = BTreeMap::new();
let mut performers = Vec::new();
for (placement, element) in selected {
let node = d.nodes.get(placement).ok_or(Refusal::StaleScope)?;
let record = p.elements.get(element).ok_or(Refusal::StaleScope)?;
if node.element != *element || visible.get(placement) != Some(element) {
return Err(Refusal::StaleScope);
}
if record.layer() != d.layer || !(record.kind().is_work() || record.kind().is_performer()) {
return Err(Refusal::Ineligible(
"Arrange requires only activities, functions or performers in this layer.".into(),
));
}
if record.kind().is_work() {
work.insert(*element, *placement);
} else {
performers.push(*placement);
}
}
let mut placements = BTreeMap::new();
for (element, placement) in &work {
let root = &p.elements[element];
let mut ancestor = root.parent;
let mut covered = false;
let mut seen = BTreeSet::new();
while let Some(id) = ancestor {
if !seen.insert(id) {
return Err(Refusal::StaleScope);
}
let parent = p.elements.get(&id).ok_or(Refusal::StaleScope)?;
if parent.kind() != root.kind() {
break;
}
if work.contains_key(&id) {
covered = true;
break;
}
ancestor = parent.parent;
}
if !covered {
placements.extend(capture_validated(p, diagram, *element, *placement)?.placements);
}
}
for performer in performers {
for id in
std::iter::once(performer).chain(projection::descendant_nodes(p, diagram, performer))
{
placements.insert(id, d.nodes.get(&id).ok_or(Refusal::StaleScope)?.clone());
}
}
// Arrange preserves ancestry, so accepted diagnostic identities also apply
// to every translated candidate.
let expanded_ids = geometry::expansion_ids(p, d, &index, &placements)?;
let before_expanded = geometry::expanded_view(p, d, &index, &expanded_ids);
Ok(Plan {
accepted: p.clone(),
diagram,
selected: selected.clone(),
anchor,
placements,
expanded_ids,
index,
before_visible,
before_expanded,
})
}
/// Snap the captured anchor once; preview the complete union without cloning or
/// validating a Project, rebuilding its semantic index, or expanding scope.
pub fn place(
plan: &Plan,
requested: Position,
presentation: PresentationContext,
) -> Result<PlacementPlan, Refusal> {
if presentation.target.is_some() {
return Err(Refusal::Ineligible(
"Arrange only has no performer destination.".into(),
));
}
if !requested.x.is_finite()
|| !requested.y.is_finite()
|| !presentation.viewport.valid()
|| !presentation.zoom.is_finite()
|| presentation.zoom <= 0.
{
return Err(Refusal::LayoutRequired(
"Make room or adjust the view, then drag again.".into(),
));
}
let geometry::Translation {
diagram,
commands,
delta,
} = geometry::translate(
&plan.accepted,
plan.diagram(),
plan.diagram(),
&plan.placements,
plan.anchor,
requested,
)?;
let view = projection::project_diagram_with_index(&plan.accepted, &diagram, &plan.index);
let expanded =
geometry::expanded_view(&plan.accepted, &diagram, &plan.index, &plan.expanded_ids);
// Preserve Arrange's refusal precedence: projected bounds, then rigidity,
// then overlap growth. The shared scene check combines bounds and overlaps.
let scenes = match geometry::check_scenes(
&plan.before_visible,
&plan.before_expanded,
&view,
&expanded,
) {
Err(error @ Refusal::InvalidCandidate(_)) => return Err(error),
result => result,
};
geometry::check_rigid(&plan.before_expanded, &expanded, &plan.placements, delta)?;
scenes?;
geometry::check_headers(&view, &plan.selected, presentation.viewport)?;
Ok(PlacementPlan {
diagram,
view,
delta,
unfolded: None,
requested,
presentation,
commands,
})
}
/// Recompute the candidate and compare the complete expected Project before the
/// caller performs one source-admitted transaction. None means no history entry.
pub fn finish(
plan: &Plan,
live: &Project,
placement: &PlacementPlan,
) -> Result<Option<Command>, Refusal> {
if live != &plan.accepted {
return Err(Refusal::StaleScope);
}
let checked = place(plan, placement.requested, placement.presentation)?;
if checked.diagram != placement.diagram
|| checked.view != placement.view
|| checked.delta != placement.delta
|| checked.unfolded != placement.unfolded
|| checked.commands != placement.commands
{
return Err(Refusal::StaleScope);
}
let mut expected = plan.accepted.clone();
expected
.diagrams
.insert(checked.diagram.id, checked.diagram);
let command = Command::Batch(checked.commands);
let actual = prepare_command(live, &command)
.map_err(|e| Refusal::InvalidCandidate(e.to_string()))?
.project;
exact_project(&expected, &actual)?;
Ok((actual != *live).then_some(command))
}
@@ -0,0 +1,443 @@
use super::*;
struct Distributed {
f: Fixture,
a: Id,
b: Id,
x: Id,
y: Id,
internal: Id,
}
fn distributed(layer: Layer, folded: bool) -> Distributed {
let mut f = fixture(layer);
let kind = f.p.elements[&f.w].kind();
let x = f.p.add_element(kind, layer, "X", Some(f.w)).unwrap();
let y = f.p.add_element(kind, layer, "Y", Some(f.w)).unwrap();
let outside = f.p.add_element(kind, layer, "Outside", None).unwrap();
let xn = f.p.place(f.d, x, xy(140., 200.));
let yn = f.p.place(f.d, y, xy(940., 200.));
f.p.place(f.d, outside, xy(1800., 800.));
f.p.relate(RelationKind::Allocation, "A owns X", f.a, x);
f.p.relate(RelationKind::Allocation, "B owns Y", f.b, y);
let exchange = match layer {
Layer::Oa => RelationKind::Interaction,
Layer::Sa => RelationKind::FunctionalExchange,
};
let internal = f.p.relate(exchange, "X to Y", x, y);
let external = f.p.relate(exchange, "Y to Outside", y, outside);
let d = f.p.diagrams.get_mut(&f.d).unwrap();
let a = d.nodes.values().find(|n| n.element == f.a).unwrap().id;
let b = d.nodes.values().find(|n| n.element == f.b).unwrap().id;
d.nodes.get_mut(&a).unwrap().collapsed = folded;
d.nodes.get_mut(&f.n).unwrap().position = xy(100., 800.);
d.routes = vec![route(internal), route(external)];
validate_candidate(&f.p).unwrap();
Distributed {
f,
a,
b,
x: xn,
y: yn,
internal,
}
}
fn identities(m: &Distributed, ids: &[Id]) -> BTreeMap<Id, Id> {
ids.iter()
.map(|id| (*id, m.f.p.diagrams[&m.f.d].nodes[id].element))
.collect()
}
fn expected(m: &Distributed, include_a: bool) -> Project {
let mut p = m.f.p.clone();
let d = p.diagrams.get_mut(&m.f.d).unwrap();
for (id, position) in [
(m.f.n, xy(500., 800.)),
(m.x, xy(540., 200.)),
(m.y, xy(1340., 200.)),
] {
d.nodes.get_mut(&id).unwrap().position = position;
}
if include_a {
d.nodes.get_mut(&m.a).unwrap().position = xy(500., 100.);
}
d.routes
.iter_mut()
.find(|r| r.edge == m.internal)
.unwrap()
.waypoints = vec![xy(820., 400.), xy(820., 550.)];
p
}
#[test]
fn explicit_arrange_distributed_oa_sa_exact_scope_routes_source_history_and_anchor() {
for layer in [Layer::Oa, Layer::Sa] {
for folded in [false, true] {
for include_a in [false, true] {
let m = distributed(layer, folded);
let members = if include_a {
vec![m.f.n, m.a]
} else {
vec![m.f.n]
};
let selected = identities(&m, &members);
for anchor in selected.keys().copied() {
let plan = arrange::capture(&m.f.p, m.f.d, &selected, anchor).unwrap();
assert_eq!(plan.selected(), &selected);
let mut affected = BTreeSet::from([m.f.n, m.x, m.y]);
if include_a {
affected.insert(m.a);
}
assert_eq!(
plan.affected().keys().copied().collect::<BTreeSet<_>>(),
affected
);
let requested = if anchor == m.a {
xy(503., 102.)
} else {
xy(503., 802.)
};
let placed =
arrange::place(&plan, requested, presentation(&m.f.p, m.f.d, None))
.unwrap();
assert_eq!(placed.delta, xy(400., 0.));
assert_eq!(placed.unfolded, None);
let command = arrange::finish(&plan, &m.f.p, &placed).unwrap().unwrap();
assert_eq!(roundtrip(&m.f.p, command), expected(&m, include_a));
// Explicit Arrange does not widen ordinary distributed assignment.
assert!(matches!(
plan_semantics(
&m.f.p,
&scope(&m.f),
Destination::Performer(m.f.a),
&BTreeMap::new()
),
Err(Refusal::MixedLayoutRequired)
));
}
}
}
}
}
#[test]
fn explicit_arrange_performer_closure_does_not_acquire_incidental_semantic_root() {
for layer in [Layer::Oa, Layer::Sa] {
let m = distributed(layer, true);
let selected = identities(&m, &[m.a]);
let plan = arrange::capture(&m.f.p, m.f.d, &selected, m.a).unwrap();
assert_eq!(
plan.affected().keys().copied().collect::<BTreeSet<_>>(),
BTreeSet::from([m.a, m.x])
);
let placed =
arrange::place(&plan, xy(500., 100.), presentation(&m.f.p, m.f.d, None)).unwrap();
let mut expected = m.f.p.clone();
let d = expected.diagrams.get_mut(&m.f.d).unwrap();
d.nodes.get_mut(&m.a).unwrap().position = xy(500., 100.);
d.nodes.get_mut(&m.x).unwrap().position = xy(540., 200.);
// X-to-Y is now one-ended; all saved pins remain byte-for-byte unchanged.
assert_eq!(
roundtrip(
&m.f.p,
arrange::finish(&plan, &m.f.p, &placed).unwrap().unwrap()
),
expected
);
}
}
#[test]
fn explicit_arrange_validates_all_original_members_before_normalizing() {
let mut m = distributed(Layer::Oa, false);
let selected = identities(&m, &[m.f.n, m.x]);
let plan = arrange::capture(&m.f.p, m.f.d, &selected, m.x).unwrap();
assert_eq!(plan.selected(), &selected);
assert_eq!(plan.affected().len(), 3);
let placed = arrange::place(&plan, xy(540., 200.), presentation(&m.f.p, m.f.d, None)).unwrap();
assert_eq!(
roundtrip(
&m.f.p,
arrange::finish(&plan, &m.f.p, &placed).unwrap().unwrap()
),
expected(&m, false)
);
let mut stale = selected.clone();
stale.insert(m.x, m.f.w);
assert!(arrange::capture(&m.f.p, m.f.d, &stale, m.f.n).is_err());
m.f.p
.diagrams
.get_mut(&m.f.d)
.unwrap()
.nodes
.get_mut(&m.a)
.unwrap()
.collapsed = true;
assert!(
arrange::capture(&m.f.p, m.f.d, &selected, m.f.n).is_err(),
"covered selected X is hidden and cannot disappear in normalization"
);
assert!(arrange::capture(&m.f.p, m.f.d, &BTreeMap::new(), m.f.n).is_err());
assert!(arrange::capture(&m.f.p, m.f.d, &identities(&m, &[m.f.n]), m.a).is_err());
assert!(arrange::capture(&m.f.p, m.f.second, &identities(&m, &[m.f.n]), m.f.n).is_err());
let foreign = Id::new_v4();
assert!(
arrange::capture(
&m.f.p,
m.f.d,
&BTreeMap::from([(m.f.n, m.f.w), (foreign, m.f.w)]),
m.f.n
)
.is_err()
);
m.f.p.diagrams.get_mut(&m.f.d).unwrap().projection = ProjectionKind::Overview;
assert!(arrange::capture(&m.f.p, m.f.d, &identities(&m, &[m.f.n]), m.f.n).is_err());
}
#[test]
fn explicit_arrange_simultaneous_roots_may_enter_each_others_vacated_bounds() {
for layer in [Layer::Oa, Layer::Sa] {
let mut f = fixture(layer);
let kind = f.p.elements[&f.w].kind();
let y = f.p.add_element(kind, layer, "Second root", None).unwrap();
let yn = f.p.place(f.d, y, xy(400., 500.));
let relation = f.p.relate(
if layer == Layer::Oa {
RelationKind::Interaction
} else {
RelationKind::FunctionalExchange
},
"Between roots",
f.w,
y,
);
f.p.diagrams.get_mut(&f.d).unwrap().routes = vec![route(relation)];
let context = presentation(&f.p, f.d, None);
let single = arrange::capture(&f.p, f.d, &BTreeMap::from([(f.n, f.w)]), f.n).unwrap();
assert!(
arrange::place(&single, xy(400., 500.), context).is_err(),
"singleton would collide with stationary Y"
);
let plan =
arrange::capture(&f.p, f.d, &BTreeMap::from([(f.n, f.w), (yn, y)]), f.n).unwrap();
let placed = arrange::place(&plan, xy(400., 500.), context).unwrap();
let mut expected = f.p.clone();
let d = expected.diagrams.get_mut(&f.d).unwrap();
d.nodes.get_mut(&f.n).unwrap().position = xy(400., 500.);
d.nodes.get_mut(&yn).unwrap().position = xy(700., 500.);
d.routes[0].waypoints = vec![xy(720., 400.), xy(720., 550.)];
assert_eq!(
roundtrip(
&f.p,
arrange::finish(&plan, &f.p, &placed).unwrap().unwrap()
),
expected
);
}
}
#[test]
fn explicit_arrange_preserves_roles_and_does_not_invent_unplaced_descendants() {
let mut f = fixture(Layer::Oa);
let a = f.a;
role(&mut f, Some(a));
f.p.diagrams
.get_mut(&f.d)
.unwrap()
.nodes
.get_mut(&f.n)
.unwrap()
.position = xy(150., 200.);
let plan = arrange::capture(&f.p, f.d, &BTreeMap::from([(f.n, f.w)]), f.n).unwrap();
let placed = arrange::place(&plan, xy(160., 210.), presentation(&f.p, f.d, None)).unwrap();
let mut role_expected = f.p.clone();
role_expected
.diagrams
.get_mut(&f.d)
.unwrap()
.nodes
.get_mut(&f.n)
.unwrap()
.position = xy(160., 210.);
assert_eq!(
roundtrip(
&f.p,
arrange::finish(&plan, &f.p, &placed).unwrap().unwrap()
),
role_expected
);
for layer in [Layer::Oa, Layer::Sa] {
let mut m = distributed(layer, true);
let kind = m.f.p.elements[&m.f.w].kind();
m.f.p
.add_element(kind, layer, "Unplaced leaf", Some(m.f.w))
.unwrap();
let plan = arrange::capture(&m.f.p, m.f.d, &identities(&m, &[m.f.n]), m.f.n).unwrap();
assert_eq!(plan.affected().len(), 3);
let placed =
arrange::place(&plan, xy(500., 800.), presentation(&m.f.p, m.f.d, None)).unwrap();
assert_eq!(
roundtrip(
&m.f.p,
arrange::finish(&plan, &m.f.p, &placed).unwrap().unwrap()
),
expected(&m, false)
);
}
}
#[test]
fn explicit_arrange_detects_hidden_moved_child_and_stationary_enclosure_growth() {
for layer in [Layer::Oa, Layer::Sa] {
for hidden in [false, true] {
let mut m = distributed(layer, hidden);
let kind = m.f.p.elements[&m.f.w].kind();
let z = m.f.p.add_element(kind, layer, "Obstacle", None).unwrap();
let zn = m.f.p.place(
m.f.d,
z,
if hidden {
xy(550., 230.)
} else {
xy(1200., 100.)
},
);
// The independent candidate differs only by literal admitted edits.
// Establish which geometric case the real planner must reject.
let candidate = expected(&m, true);
let before = projection::project_view(&m.f.p, m.f.d);
let after = projection::project_view(&candidate, m.f.d);
let find =
|view: &ProjectedView, id| view.nodes.iter().find(|n| n.id == id).unwrap().clone();
if hidden {
assert!(!overlaps(&before, &after));
assert!(!after.nodes.iter().any(|n| n.id == m.x));
let mut diagnostic = candidate.diagrams[&m.f.d].clone();
diagnostic.nodes.get_mut(&m.a).unwrap().collapsed = false;
let expanded = projection::project_diagram(&candidate, &diagnostic);
assert_eq!(
overlap(&find(&expanded, m.x), &find(&expanded, zn)),
if layer == Layer::Oa { 13696. } else { 20116. }
);
} else {
assert_eq!(overlap(&find(&before, m.b), &find(&before, zn)), 0.);
assert_eq!(overlap(&find(&after, m.b), &find(&after, zn)), 21056.);
for id in [m.a, m.f.n, m.x, m.y] {
assert_eq!(overlap(&find(&after, id), &find(&after, zn)), 0.);
}
assert_eq!(
candidate.diagrams[&m.f.d].nodes[&m.b],
m.f.p.diagrams[&m.f.d].nodes[&m.b]
);
}
let plan =
arrange::capture(&m.f.p, m.f.d, &identities(&m, &[m.a, m.f.n]), m.f.n).unwrap();
assert!(matches!(
arrange::place(&plan, xy(500., 800.), presentation(&m.f.p, m.f.d, None)),
Err(Refusal::LayoutRequired(_))
));
}
}
}
#[test]
fn explicit_arrange_preserves_existing_overlap_but_refuses_its_growth() {
let mut f = fixture(Layer::Oa);
let y =
f.p.add_element(Kind::Activity, Layer::Oa, "Existing overlap", None)
.unwrap();
let yn = f.p.place(f.d, y, xy(250., 500.));
let context = presentation(&f.p, f.d, None);
let single = arrange::capture(&f.p, f.d, &BTreeMap::from([(f.n, f.w)]), f.n).unwrap();
assert!(arrange::place(&single, xy(110., 500.), context).is_err());
let plan = arrange::capture(&f.p, f.d, &BTreeMap::from([(f.n, f.w), (yn, y)]), f.n).unwrap();
let placed = arrange::place(&plan, xy(400., 500.), context).unwrap();
let mut expected = f.p.clone();
let d = expected.diagrams.get_mut(&f.d).unwrap();
d.nodes.get_mut(&f.n).unwrap().position = xy(400., 500.);
d.nodes.get_mut(&yn).unwrap().position = xy(550., 500.);
assert_eq!(
roundtrip(
&f.p,
arrange::finish(&plan, &f.p, &placed).unwrap().unwrap()
),
expected
);
}
#[test]
fn explicit_arrange_preserves_unrelated_folded_interior_diagnostic_boundary() {
for layer in [Layer::Oa, Layer::Sa] {
let mut m = distributed(layer, true);
let performer = if layer == Layer::Oa {
Kind::Actor
} else {
Kind::SystemActor
};
let d = m.f.p.add_element(performer, layer, "D", None).unwrap();
let z =
m.f.p
.add_element(m.f.p.elements[&m.f.w].kind(), layer, "Hidden Z", None)
.unwrap();
m.f.p.relate(RelationKind::Allocation, "D owns Z", d, z);
let dn = m.f.p.place(m.f.d, d, xy(2500., 100.));
m.f.p.place(m.f.d, z, xy(550., 230.));
m.f.p
.diagrams
.get_mut(&m.f.d)
.unwrap()
.nodes
.get_mut(&dn)
.unwrap()
.collapsed = true;
let plan = arrange::capture(&m.f.p, m.f.d, &identities(&m, &[m.a, m.f.n]), m.f.n).unwrap();
let placed =
arrange::place(&plan, xy(500., 800.), presentation(&m.f.p, m.f.d, None)).unwrap();
assert_eq!(
roundtrip(
&m.f.p,
arrange::finish(&plan, &m.f.p, &placed).unwrap().unwrap()
),
expected(&m, true)
);
}
}
#[test]
fn explicit_arrange_noop_stale_tamper_target_and_all_selected_headers() {
let m = distributed(Layer::Sa, false);
let selected = identities(&m, &[m.a, m.f.n]);
let plan = arrange::capture(&m.f.p, m.f.d, &selected, m.f.n).unwrap();
let context = presentation(&m.f.p, m.f.d, None);
let noop = arrange::place(&plan, xy(103., 802.), context).unwrap();
assert!(arrange::finish(&plan, &m.f.p, &noop).unwrap().is_none());
let mut placed = arrange::place(&plan, xy(500., 800.), context).unwrap();
let mut stale = m.f.p.clone();
stale.name.push_str(" changed");
assert!(matches!(
arrange::finish(&plan, &stale, &placed),
Err(Refusal::StaleScope)
));
placed.diagram.nodes.get_mut(&m.b).unwrap().position.x += 1.;
assert!(matches!(
arrange::finish(&plan, &m.f.p, &placed),
Err(Refusal::StaleScope)
));
assert!(
arrange::place(
&plan,
xy(500., 800.),
presentation(&m.f.p, m.f.d, Some(m.f.b))
)
.is_err()
);
let mut clipped = context;
clipped.viewport.position = xy(400., 700.);
assert!(
arrange::place(&plan, xy(500., 800.), clipped).is_err(),
"anchor C visible, other selected A header clipped"
);
assert!(arrange::place(&plan, xy(f32::NAN, 800.), context).is_err());
assert!(arrange::place(&plan, xy(MAX_COORDINATE, 800.), context).is_err());
}
@@ -0,0 +1,444 @@
//! Ordinary work-only group assignment, with one combined semantic/geometry plan.
//! App retains authority over the complete typed selection and gesture lifetime.
use super::*;
use std::sync::Arc;
#[derive(Debug)]
struct Captured {
accepted: Project,
diagram: Id,
selected: BTreeMap<Id, Id>,
anchor: Id,
roots: Vec<super::Scope>,
placements: BTreeMap<Id, NodePlacement>,
leaves: BTreeMap<Id, Leaf>,
index: ViewIndex,
before_visible: ProjectedView,
before_expanded: ProjectedView,
}
/// Destination plans share one immutable accepted capture.
#[derive(Debug, Clone)]
pub struct Scope(Arc<Captured>);
impl Scope {
pub fn project(&self) -> &Project {
&self.0.accepted
}
pub fn index(&self) -> &ViewIndex {
&self.0.index
}
pub fn diagram(&self) -> &Diagram {
&self.0.accepted.diagrams[&self.0.diagram]
}
pub fn selected(&self) -> &BTreeMap<Id, Id> {
&self.0.selected
}
pub fn affected(&self) -> &BTreeMap<Id, NodePlacement> {
&self.0.placements
}
pub fn anchor(&self) -> Id {
self.0.anchor
}
pub fn leaves(&self) -> &BTreeMap<Id, Leaf> {
&self.0.leaves
}
}
/// App must reject non-placement members before constructing this complete map.
/// Every original identity is checked before redundant work roots are removed.
pub fn capture(
p: &Project,
diagram: Id,
selected: &BTreeMap<Id, Id>,
anchor: Id,
) -> Result<Scope, Refusal> {
validate_candidate(p).map_err(|e| Refusal::InvalidCandidate(e.to_string()))?;
let d = p.diagrams.get(&diagram).ok_or(Refusal::StaleScope)?;
if d.projection != ProjectionKind::Allocation
|| selected.is_empty()
|| !selected.contains_key(&anchor)
{
return Err(Refusal::Ineligible(
"Assignment requires selected work placements and a selected anchor in an Allocation view."
.into(),
));
}
let index = ViewIndex::new(p);
let before_visible = projection::project_diagram_with_index(p, d, &index);
let visible: BTreeMap<_, _> = before_visible
.nodes
.iter()
.map(|node| (node.id, node.element))
.collect();
let mut work = BTreeMap::new();
for (placement, element) in selected {
let node = d.nodes.get(placement).ok_or(Refusal::StaleScope)?;
let record = p.elements.get(element).ok_or(Refusal::StaleScope)?;
if node.element != *element || visible.get(placement) != Some(element) {
return Err(Refusal::StaleScope);
}
if !record.kind().is_work() || record.layer() != d.layer {
return Err(Refusal::Ineligible(
"Assignment requires only activities or functions in this layer. Use Arrange only for a supported mixed selection."
.into(),
));
}
work.insert(*element, *placement);
}
let mut roots = Vec::new();
let mut placements = BTreeMap::new();
let mut leaves = BTreeMap::new();
for (element, placement) in &work {
let record = &p.elements[element];
let mut ancestor = record.parent;
let mut seen = BTreeSet::new();
let mut covered = false;
while let Some(id) = ancestor {
if !seen.insert(id) {
return Err(Refusal::StaleScope);
}
let parent = p.elements.get(&id).ok_or(Refusal::StaleScope)?;
if parent.kind() != record.kind() {
break;
}
if work.contains_key(&id) {
covered = true;
break;
}
ancestor = parent.parent;
}
if covered {
continue;
}
let root = capture_validated(p, diagram, *element, *placement)?;
if root.work_records.len() > 1 && root.uniform == Uniform::MixedOrUnresolved {
return Err(Refusal::MixedLayoutRequired);
}
for (id, leaf) in &root.leaves {
if leaves.insert(*id, leaf.clone()).is_some() {
return Err(Refusal::StaleScope);
}
}
placements.extend(root.placements.clone());
roots.push(root);
}
let expansion = geometry::expansion_ids(p, d, &index, &placements)?;
let before_expanded = geometry::expanded_view(p, d, &index, &expansion);
if roots
.iter()
.any(|root| root.work_records.len() > 1 && !coherent(root, &before_expanded))
{
return Err(Refusal::MixedLayoutRequired);
}
Ok(Scope(Arc::new(Captured {
accepted: p.clone(),
diagram,
selected: selected.clone(),
anchor,
roots,
placements,
leaves,
index,
before_visible,
before_expanded,
})))
}
#[derive(Debug)]
pub struct SemanticPlan {
scope: Scope,
destination: Destination,
candidate: Project,
index: ViewIndex,
commands: Vec<Command>,
changed: BTreeSet<Id>,
expanded_ids: BTreeSet<Id>,
label: String,
warnings: Vec<Issue>,
}
impl SemanticPlan {
pub fn scope(&self) -> &Scope {
&self.scope
}
pub fn project(&self) -> &Project {
&self.candidate
}
pub fn index(&self) -> &ViewIndex {
&self.index
}
pub fn destination(&self) -> Destination {
self.destination
}
pub fn label(&self) -> &str {
&self.label
}
pub fn warnings(&self) -> &[Issue] {
&self.warnings
}
pub fn changed_count(&self) -> usize {
self.changed.len()
}
/// Already effective at the performer, or already unassigned for Outside.
pub fn already_correct_count(&self) -> usize {
self.scope.leaves().len() - self.changed.len()
}
}
/// Build one destination candidate. Per-root decisions read accepted facts;
/// aliases and new identities are reserved in the combined expected Project.
pub fn plan_semantics(
scope: &Scope,
destination: Destination,
allocation_ids: &BTreeMap<Id, Id>,
) -> Result<SemanticPlan, Refusal> {
let accepted = scope.project();
match destination {
Destination::LayoutOnly => {
return Err(Refusal::Ineligible(
"Use the explicit Arrange only planner for presentation-only movement.".into(),
));
}
Destination::Performer(id) => {
if !accepted.elements.get(&id).is_some_and(|element| {
element.kind().is_performer() && element.layer() == scope.diagram().layer
}) {
return Err(Refusal::Ineligible(
"Choose a performer in this layer.".into(),
));
}
}
Destination::OutsidePerformers => {}
}
let mut expected = accepted.clone();
let mut edits = IntendedEdits::default();
for root in &scope.0.roots {
append_semantic_edits(
accepted,
root,
destination,
allocation_ids,
&mut expected,
&mut edits,
)?;
}
for diagram in expected.diagrams.values_mut() {
diagram
.routes
.retain(|route| !edits.deleted.contains(&route.edge));
}
let candidate = prepare_command(accepted, &Command::Batch(edits.commands.clone()))
.map_err(|e| Refusal::InvalidCandidate(e.to_string()))?
.project;
exact_project(&expected, &candidate)?;
let warnings = validation::validate(&candidate)
.into_iter()
.filter(|issue| issue.severity == Severity::Warning)
.collect();
let index = ViewIndex::new(&candidate);
// Assignment may change visual ancestry: never reuse accepted expansion IDs.
let expanded_ids = geometry::expansion_ids(
&candidate,
&candidate.diagrams[&scope.0.diagram],
&index,
scope.affected(),
)?;
let total = scope.leaves().len();
let changed = edits.changed.len();
let already_correct = total - changed;
let noun = match (scope.diagram().layer, total) {
(Layer::Oa, 1) => "activity",
(Layer::Oa, _) => "activities",
(Layer::Sa, 1) => "function",
(Layer::Sa, _) => "functions",
};
let retained_roles = scope
.leaves()
.values()
.any(|leaf| !leaf.role_assignments.is_empty());
let label = match destination {
Destination::Performer(id) if changed == 0 => {
format!(
"Arrange within {} — all {total} leaf {noun} already assigned",
accepted.named(id)
)
}
Destination::Performer(id) => format!(
"Assign {changed} of {total} leaf {noun} to {}{}{}",
accepted.named(id),
if already_correct > 0 {
format!(" · {already_correct} already assigned")
} else {
String::new()
},
if retained_roles {
" — role responsibility retained"
} else {
""
},
),
Destination::OutsidePerformers if changed == 0 => {
"Arrange unassigned work — decomposition retained".into()
}
Destination::OutsidePerformers => format!(
"Remove assignments from {changed} of {total} leaf {noun} — decomposition retained"
),
Destination::LayoutOnly => unreachable!(),
};
Ok(SemanticPlan {
scope: scope.clone(),
destination,
candidate,
index,
commands: edits.commands,
changed: edits.changed,
expanded_ids,
label,
warnings,
})
}
/// Translate the entire captured Q once, then evaluate the combined candidate.
/// This path does not clone/validate a Project or rebuild semantic indexes.
pub fn place(
plan: &SemanticPlan,
requested: Position,
presentation: PresentationContext,
) -> Result<PlacementPlan, Refusal> {
if !requested.x.is_finite()
|| !requested.y.is_finite()
|| !presentation.viewport.valid()
|| !presentation.zoom.is_finite()
|| presentation.zoom <= 0.
{
return Err(Refusal::LayoutRequired(
"Make room or adjust the view, then drag again.".into(),
));
}
let scope = &plan.scope;
let geometry::Translation {
mut diagram,
mut commands,
delta,
} = geometry::translate(
scope.project(),
scope.diagram(),
&plan.candidate.diagrams[&scope.0.diagram],
scope.affected(),
scope.anchor(),
requested,
)?;
let mut unfolded = None;
if let Destination::Performer(performer) = plan.destination {
let target = presentation.target.ok_or_else(|| {
Refusal::LayoutRequired("A visible accepted performer target is required.".into())
})?;
if !scope
.0
.before_visible
.nodes
.iter()
.any(|node| node.id == target.placement && node.element == performer)
{
return Err(Refusal::LayoutRequired(
"Make room or adjust the view, then drag again.".into(),
));
}
let target_node = diagram
.nodes
.get_mut(&target.placement)
.ok_or(Refusal::StaleScope)?;
if target_node.element != performer {
return Err(Refusal::StaleScope);
}
if !plan.changed.is_empty() && target_node.collapsed {
target_node.collapsed = false;
unfolded = Some(target_node.id);
commands.push(Command::EditNode {
diagram: diagram.id,
original: target_node.id,
node: target_node.clone(),
});
}
} else if presentation.target.is_some() {
return Err(Refusal::Ineligible(
"An outside destination has no performer target.".into(),
));
}
let view = projection::project_diagram_with_index(&plan.candidate, &diagram, &plan.index);
let expanded =
geometry::expanded_view(&plan.candidate, &diagram, &plan.index, &plan.expanded_ids);
if scope
.0
.roots
.iter()
.any(|root| root.work_records.len() > 1 && !coherent(root, &expanded))
{
return Err(Refusal::MixedLayoutRequired);
}
geometry::check_scenes(
&scope.0.before_visible,
&scope.0.before_expanded,
&view,
&expanded,
)?;
geometry::check_rigid(&scope.0.before_expanded, &expanded, scope.affected(), delta)?;
geometry::check_headers(&view, scope.selected(), presentation.viewport)?;
Ok(PlacementPlan {
diagram,
view,
delta,
unfolded,
requested,
presentation,
commands,
})
}
/// Recheck immutable facts and recompute geometry before one final source batch.
/// Zero delta remains a transaction when assignment semantics changed.
pub fn finish(
plan: &SemanticPlan,
live: &Project,
placement: &PlacementPlan,
) -> Result<Option<Command>, Refusal> {
if live != plan.scope.project() {
return Err(Refusal::StaleScope);
}
let checked = place(plan, placement.requested, placement.presentation)?;
if checked.diagram != placement.diagram
|| checked.view != placement.view
|| checked.delta != placement.delta
|| checked.unfolded != placement.unfolded
|| checked.commands != placement.commands
{
return Err(Refusal::StaleScope);
}
let mut expected = plan.candidate.clone();
expected
.diagrams
.insert(checked.diagram.id, checked.diagram);
let mut commands = plan.commands.clone();
commands.extend(checked.commands);
let command = Command::Batch(commands);
let actual = prepare_command(live, &command)
.map_err(|e| Refusal::InvalidCandidate(e.to_string()))?
.project;
exact_project(&expected, &actual)?;
Ok((actual != *live).then_some(command))
}
@@ -0,0 +1,497 @@
use super::*;
struct Composite {
f: Fixture,
child: Id,
child_node: Id,
unplaced: Id,
}
fn composite(layer: Layer) -> Composite {
let mut f = fixture(layer);
let kind = f.p.elements[&f.w].kind();
let child =
f.p.add_element(kind, layer, "Placed child", Some(f.w))
.unwrap();
let unplaced =
f.p.add_element(kind, layer, "Unplaced leaf", Some(f.w))
.unwrap();
let child_node = f.p.place(f.d, child, xy(140., 600.));
validate_candidate(&f.p).unwrap();
Composite {
f,
child,
child_node,
unplaced,
}
}
fn expected_allocation(id: Id, alias: &str, name: &str, performer: Id, work: Id) -> Relation {
Relation {
id,
alias: alias.into(),
name: name.into(),
description: String::new(),
body: RelationBody::Allocation {
source: performer,
target: work,
},
}
}
#[test]
fn group_assignment_checks_original_identity_and_visibility_before_normalization() {
for layer in [Layer::Oa, Layer::Sa] {
let c = composite(layer);
let valid = BTreeMap::from([(c.f.n, c.f.w), (c.child_node, c.child)]);
let admitted = assignment::capture(&c.f.p, c.f.d, &valid, c.f.n).unwrap();
assert_eq!(admitted.selected(), &valid);
let foreign = c.f.p.diagrams[&c.f.second]
.nodes
.values()
.find(|node| node.element == c.f.w)
.unwrap()
.id;
// Each extra member is semantically covered by the valid selected parent.
// Normalizing first would incorrectly hide its invalid original identity.
for invalid in [
BTreeMap::from([(c.f.n, c.f.w), (c.child_node, c.unplaced)]),
BTreeMap::from([(c.f.n, c.f.w), (Id::new_v4(), c.child)]),
BTreeMap::from([(c.f.n, c.f.w), (foreign, c.f.w)]),
] {
assert!(matches!(
assignment::capture(&c.f.p, c.f.d, &invalid, c.f.n),
Err(Refusal::StaleScope)
));
}
let mut folded = c.f.p.clone();
folded
.diagrams
.get_mut(&c.f.d)
.unwrap()
.nodes
.get_mut(&c.f.n)
.unwrap()
.collapsed = true;
validate_candidate(&folded).unwrap();
assert!(
!projection::project_view(&folded, c.f.d)
.nodes
.iter()
.any(|node| node.id == c.child_node)
);
// Hidden descendants remain in Q when only the visible parent is selected.
let parent_only =
assignment::capture(&folded, c.f.d, &BTreeMap::from([(c.f.n, c.f.w)]), c.f.n).unwrap();
assert!(parent_only.affected().contains_key(&c.child_node));
assert!(matches!(
assignment::capture(&folded, c.f.d, &valid, c.f.n),
Err(Refusal::StaleScope)
));
}
}
#[test]
fn group_assignment_normalizes_parent_child_and_assigns_unplaced_leaf_once() {
for layer in [Layer::Oa, Layer::Sa] {
let c = composite(layer);
let selected = BTreeMap::from([(c.f.n, c.f.w), (c.child_node, c.child)]);
let scope = assignment::capture(&c.f.p, c.f.d, &selected, c.f.n).unwrap();
assert_eq!(scope.selected(), &selected);
assert_eq!(
scope.affected().keys().copied().collect::<BTreeSet<_>>(),
BTreeSet::from([c.f.n, c.child_node])
);
assert_eq!(
scope.leaves().keys().copied().collect::<BTreeSet<_>>(),
BTreeSet::from([c.child, c.unplaced])
);
let child_allocation = Id::new_v4();
let unplaced_allocation = Id::new_v4();
let ids = BTreeMap::from([
(c.child, child_allocation),
(c.unplaced, unplaced_allocation),
]);
let plan = assignment::plan_semantics(&scope, Destination::Performer(c.f.a), &ids).unwrap();
assert_eq!(plan.changed_count(), 2);
let mut expected = c.f.p.clone();
expected.relations.insert(
child_allocation,
expected_allocation(
child_allocation,
"a_performs_placed_child",
"A performs Placed child",
c.f.a,
c.child,
),
);
expected.relations.insert(
unplaced_allocation,
expected_allocation(
unplaced_allocation,
"a_performs_unplaced_leaf",
"A performs Unplaced leaf",
c.f.a,
c.unplaced,
),
);
assert_eq!(plan.project(), &expected);
let placed = assignment::place(
&plan,
xy(140., 200.),
presentation(&c.f.p, c.f.d, Some(c.f.a)),
)
.unwrap();
let mut clipped = presentation(&c.f.p, c.f.d, Some(c.f.a));
clipped.viewport.size.height = 450.; // bottom350: parent header fits; selected child ends362.
for (id, should_fit) in [(c.f.n, true), (c.child_node, false)] {
let node = placed.view.nodes.iter().find(|node| node.id == id).unwrap();
let header = Rect {
position: node.position,
size: Size {
width: node.size.width,
height: 62.,
},
};
assert_eq!(clipped.viewport.contains(header), should_fit);
}
assert!(
matches!(
assignment::place(&plan, xy(140., 200.), clipped),
Err(Refusal::LayoutRequired(_))
),
"normalized-away selected child still requires its header"
);
let d = expected.diagrams.get_mut(&c.f.d).unwrap();
d.nodes.get_mut(&c.f.n).unwrap().position = xy(140., 200.);
d.nodes.get_mut(&c.child_node).unwrap().position = xy(180., 300.);
assert_eq!(placed.delta, xy(40., -300.));
assert_eq!(
roundtrip(
&c.f.p,
assignment::finish(&plan, &c.f.p, &placed).unwrap().unwrap()
),
expected
);
assert_eq!(
expected.diagrams[&c.f.d].nodes.len(),
c.f.p.diagrams[&c.f.d].nodes.len()
);
assert!(
!expected.diagrams[&c.f.d]
.nodes
.values()
.any(|node| node.element == c.unplaced)
);
assert!(
!expected
.relations
.values()
.any(|relation| relation.kind() == RelationKind::Allocation
&& relation.target() == c.f.w)
);
}
}
#[test]
fn group_assignment_distributed_parent_refuses_but_its_independent_leaves_can_reassign() {
for layer in [Layer::Oa, Layer::Sa] {
let mut c = composite(layer);
let second_node = c.f.p.place(c.f.d, c.unplaced, xy(940., 200.));
let performer =
c.f.p
.add_element(
if layer == Layer::Oa {
Kind::Actor
} else {
Kind::SystemActor
},
layer,
"C",
None,
)
.unwrap();
c.f.p.place(c.f.d, performer, xy(1700., 100.));
let d = c.f.p.diagrams.get_mut(&c.f.d).unwrap();
d.nodes.get_mut(&c.f.n).unwrap().position = xy(1700., 800.);
d.nodes.get_mut(&c.child_node).unwrap().position = xy(140., 200.);
let ax =
c.f.p
.relate(RelationKind::Allocation, "A child", c.f.a, c.child);
let by =
c.f.p
.relate(RelationKind::Allocation, "B child", c.f.b, c.unplaced);
validate_candidate(&c.f.p).unwrap();
let visible = projection::project_view(&c.f.p, c.f.d);
for id in [c.f.n, c.child_node, second_node] {
assert!(visible.nodes.iter().any(|node| node.id == id));
}
assert_eq!(scope(&c.f).uniform, Uniform::MixedOrUnresolved);
assert!(matches!(
assignment::capture(&c.f.p, c.f.d, &BTreeMap::from([(c.f.n, c.f.w)]), c.f.n,),
Err(Refusal::MixedLayoutRequired)
));
let selected = BTreeMap::from([(c.child_node, c.child), (second_node, c.unplaced)]);
let captured = assignment::capture(&c.f.p, c.f.d, &selected, c.child_node).unwrap();
let plan = assignment::plan_semantics(
&captured,
Destination::Performer(performer),
&BTreeMap::new(),
)
.unwrap();
let mut expected = c.f.p.clone();
expected.relations.get_mut(&ax).unwrap().body = RelationBody::Allocation {
source: performer,
target: c.child,
};
expected.relations.get_mut(&by).unwrap().body = RelationBody::Allocation {
source: performer,
target: c.unplaced,
};
assert_eq!(plan.project(), &expected);
// One delta moves both leaves. The unselected semantic parent remains
// authored at (1700,800), while its derived enclosure may change.
let placed = assignment::place(
&plan,
xy(1740., 120.),
presentation(&c.f.p, c.f.d, Some(performer)),
)
.unwrap();
let d = expected.diagrams.get_mut(&c.f.d).unwrap();
d.nodes.get_mut(&c.child_node).unwrap().position = xy(1740., 120.);
d.nodes.get_mut(&second_node).unwrap().position = xy(2540., 120.);
assert_eq!(
roundtrip(
&c.f.p,
assignment::finish(&plan, &c.f.p, &placed).unwrap().unwrap()
),
expected
);
}
}
fn separate_unassigned(layer: Layer) -> (Fixture, Id, Id) {
let mut f = fixture(layer);
let companion =
f.p.add_element(f.p.elements[&f.w].kind(), layer, "Companion", None)
.unwrap();
let companion_node = f.p.place(f.d, companion, xy(100., 1000.));
validate_candidate(&f.p).unwrap();
(f, companion, companion_node)
}
#[test]
fn group_assignment_finish_rejects_stale_project_and_each_tampered_result() {
let (f, companion, companion_node) = separate_unassigned(Layer::Oa);
let selected = BTreeMap::from([(f.n, f.w), (companion_node, companion)]);
let captured = assignment::capture(&f.p, f.d, &selected, f.n).unwrap();
let plan =
assignment::plan_semantics(&captured, Destination::OutsidePerformers, &BTreeMap::new())
.unwrap();
let context = presentation(&f.p, f.d, None);
let valid = assignment::place(&plan, xy(500., 500.), context).unwrap();
let mut expected = f.p.clone();
expected
.diagrams
.get_mut(&f.d)
.unwrap()
.nodes
.get_mut(&f.n)
.unwrap()
.position = xy(500., 500.);
expected
.diagrams
.get_mut(&f.d)
.unwrap()
.nodes
.get_mut(&companion_node)
.unwrap()
.position = xy(500., 1000.);
assert_eq!(
roundtrip(
&f.p,
assignment::finish(&plan, &f.p, &valid).unwrap().unwrap()
),
expected
);
let mut stale = f.p.clone();
stale.elements.get_mut(&f.b).unwrap().description = "Changed unrelated accepted record".into();
validate_candidate(&stale).unwrap();
assert!(matches!(
assignment::finish(&plan, &stale, &valid),
Err(Refusal::StaleScope)
));
for field in 0..6 {
let mut altered = assignment::place(&plan, xy(500., 500.), context).unwrap();
match field {
0 => altered.diagram.nodes.get_mut(&f.n).unwrap().position.x += 20.,
1 => {
altered
.view
.nodes
.iter_mut()
.find(|node| node.id == f.n)
.unwrap()
.position
.x += 20.
}
2 => altered.delta.x += 20.,
3 => altered.unfolded = Some(f.n),
4 => altered.commands.clear(),
5 => altered.requested.x += 10.,
_ => unreachable!(),
}
assert!(
matches!(
assignment::finish(&plan, &f.p, &altered),
Err(Refusal::StaleScope)
),
"tampered result field {field}"
);
}
}
#[test]
fn group_assignment_requires_non_anchor_selected_header_inside_viewport() {
let (f, companion, companion_node) = separate_unassigned(Layer::Oa);
let captured = assignment::capture(
&f.p,
f.d,
&BTreeMap::from([(f.n, f.w), (companion_node, companion)]),
f.n,
)
.unwrap();
let plan =
assignment::plan_semantics(&captured, Destination::OutsidePerformers, &BTreeMap::new())
.unwrap();
let wide = presentation(&f.p, f.d, None);
let placed = assignment::place(&plan, xy(500., 500.), wide).unwrap();
let mut narrow = wide;
narrow.viewport.size.height = 800.; // world bottom700: anchor header fits, companion does not.
let header = |id| {
let node = placed.view.nodes.iter().find(|node| node.id == id).unwrap();
Rect {
position: node.position,
size: Size {
width: node.size.width,
height: 62.,
},
}
};
assert!(narrow.viewport.contains(header(f.n)));
assert!(!narrow.viewport.contains(header(companion_node)));
// Only the viewport differs from the already admitted identical geometry.
assert!(matches!(
assignment::place(&plan, xy(500., 500.), narrow),
Err(Refusal::LayoutRequired(_))
));
}
#[test]
fn group_assignment_unresolved_oa_role_leaf_accepts_explicit_target_but_not_outside() {
for ambiguous in [false, true] {
let (mut f, companion, companion_node) = separate_unassigned(Layer::Oa);
let (role_id, role_assignment) = role(&mut f, None);
if ambiguous {
f.p.relate(
RelationKind::RoleRealization,
"A realizes responsibility",
f.a,
role_id,
);
f.p.relate(
RelationKind::RoleRealization,
"B realizes responsibility",
f.b,
role_id,
);
}
validate_candidate(&f.p).unwrap();
assert_eq!(validation::effective_performer(&f.p, f.w), None);
let captured = assignment::capture(
&f.p,
f.d,
&BTreeMap::from([(f.n, f.w), (companion_node, companion)]),
f.n,
)
.unwrap();
let Refusal::RoleExitRequired {
work,
roles,
performers,
..
} = assignment::plan_semantics(&captured, Destination::OutsidePerformers, &BTreeMap::new())
.unwrap_err()
else {
panic!("unresolved role must produce its specific outward refusal");
};
assert_eq!(work, f.w);
assert_eq!(roles, vec![role_id]);
assert_eq!(
performers.into_iter().collect::<BTreeSet<_>>(),
if ambiguous {
BTreeSet::from([f.a, f.b])
} else {
BTreeSet::new()
}
);
let work_allocation = Id::new_v4();
let companion_allocation = Id::new_v4();
let ids = BTreeMap::from([(f.w, work_allocation), (companion, companion_allocation)]);
let plan =
assignment::plan_semantics(&captured, Destination::Performer(f.b), &ids).unwrap();
assert_eq!(plan.changed_count(), 2);
assert!(plan.label().contains("role responsibility retained"));
let mut expected = f.p.clone();
expected.relations.insert(
work_allocation,
expected_allocation(
work_allocation,
"b_performs_work",
"B performs Work",
f.b,
f.w,
),
);
expected.relations.insert(
companion_allocation,
expected_allocation(
companion_allocation,
"b_performs_companion",
"B performs Companion",
f.b,
companion,
),
);
assert_eq!(plan.project(), &expected);
assert_eq!(
plan.project().relations[&role_assignment],
f.p.relations[&role_assignment]
);
let placed =
assignment::place(&plan, xy(940., 200.), presentation(&f.p, f.d, Some(f.b))).unwrap();
expected
.diagrams
.get_mut(&f.d)
.unwrap()
.nodes
.get_mut(&f.n)
.unwrap()
.position = xy(940., 200.);
expected
.diagrams
.get_mut(&f.d)
.unwrap()
.nodes
.get_mut(&companion_node)
.unwrap()
.position = xy(940., 700.);
assert_eq!(
roundtrip(
&f.p,
assignment::finish(&plan, &f.p, &placed).unwrap().unwrap()
),
expected
);
}
}
@@ -0,0 +1,606 @@
use super::*;
struct Roots {
f: Fixture,
c: Id,
cn: Id,
y: Id,
yn: Id,
z: Id,
zn: Id,
ax: Id,
by: Id,
}
fn roots(layer: Layer) -> Roots {
let mut f = fixture(layer);
let kind = f.p.elements[&f.w].kind();
let c =
f.p.add_element(
if layer == Layer::Oa {
Kind::Actor
} else {
Kind::SystemActor
},
layer,
"C",
None,
)
.unwrap();
let y = f.p.add_element(kind, layer, "Y", None).unwrap();
let z = f.p.add_element(kind, layer, "Z", None).unwrap();
let cn = f.p.place(f.d, c, xy(1500., 100.));
let yn = f.p.place(f.d, y, xy(140., 800.));
let zn = f.p.place(f.d, z, xy(1540., 1400.));
let d = f.p.diagrams.get_mut(&f.d).unwrap();
d.nodes.get_mut(&f.n).unwrap().position = xy(140., 200.);
d.nodes
.values_mut()
.find(|n| n.element == f.b)
.unwrap()
.position = xy(100., 700.);
let ax = f.p.relate(
RelationKind::Allocation,
"Original X responsibility",
f.a,
f.w,
);
let by = f.p.relate(
RelationKind::Allocation,
"Original Y responsibility",
f.b,
y,
);
f.p.relations.get_mut(&ax).unwrap().description = "Reviewed X identity must survive".into();
f.p.relations.get_mut(&by).unwrap().description = "Reviewed Y identity must survive".into();
if layer == Layer::Oa {
let r =
f.p.add_element(Kind::Role, layer, "Responsibility", None)
.unwrap();
f.p.relate(RelationKind::RoleAssignment, "Z role", r, z);
f.p.relate(RelationKind::RoleRealization, "C role", c, r);
} else {
f.p.relate(RelationKind::Allocation, "Z already assigned", c, z);
}
validate_candidate(&f.p).unwrap();
Roots {
f,
c,
cn,
y,
yn,
z,
zn,
ax,
by,
}
}
fn wide(p: &Project, d: Id, target: Option<Id>) -> PresentationContext {
let mut context = presentation(p, d, target);
context.viewport.size = Size {
width: 5000.,
height: 3000.,
};
context
}
fn selected(r: &Roots, with_z: bool) -> BTreeMap<Id, Id> {
let mut ids = BTreeMap::from([(r.f.n, r.f.w), (r.yn, r.y)]);
if with_z {
ids.insert(r.zn, r.z);
}
ids
}
fn retarget(p: &mut Project, relation: Id, target: Id) {
let r = p.relations.get_mut(&relation).unwrap();
r.body = RelationBody::Allocation {
source: target,
target: r.target(),
};
}
#[test]
fn group_assignment_independent_performers_preserves_already_correct_provenance() {
for layer in [Layer::Oa, Layer::Sa] {
let r = roots(layer);
let selected = selected(&r, true);
let scope = assignment::capture(&r.f.p, r.f.d, &selected, r.f.n).unwrap();
let plan =
assignment::plan_semantics(&scope, Destination::Performer(r.c), &BTreeMap::new())
.unwrap();
assert_eq!(plan.changed_count(), 2);
assert_eq!(plan.already_correct_count(), 1);
let placed =
assignment::place(&plan, xy(1540., 200.), wide(&r.f.p, r.f.d, Some(r.c))).unwrap();
let mut expected = r.f.p.clone();
retarget(&mut expected, r.ax, r.c);
retarget(&mut expected, r.by, r.c);
let d = expected.diagrams.get_mut(&r.f.d).unwrap();
d.nodes.get_mut(&r.f.n).unwrap().position = xy(1540., 200.);
d.nodes.get_mut(&r.yn).unwrap().position = xy(1540., 800.);
d.nodes.get_mut(&r.zn).unwrap().position = xy(2940., 1400.);
assert_eq!(
roundtrip(
&r.f.p,
assignment::finish(&plan, &r.f.p, &placed).unwrap().unwrap()
),
expected
);
if layer == Layer::Oa {
assert!(
!plan
.project()
.relations
.values()
.any(|relation| relation.kind() == RelationKind::Allocation
&& relation.target() == r.z)
);
assert!(matches!(
assignment::plan_semantics(
&scope,
Destination::OutsidePerformers,
&BTreeMap::new()
),
Err(Refusal::RoleExitRequired { .. })
));
}
assert!(
assignment::plan_semantics(&scope, Destination::LayoutOnly, &BTreeMap::new()).is_err()
);
}
}
#[test]
fn group_assignment_zero_delta_changes_semantics_once_and_then_is_noop() {
for layer in [Layer::Oa, Layer::Sa] {
let mut f = fixture(layer);
let d = f.p.diagrams.get_mut(&f.d).unwrap();
d.nodes
.values_mut()
.find(|n| n.element == f.a)
.unwrap()
.size = Size {
width: 600.,
height: 400.,
};
d.nodes.get_mut(&f.n).unwrap().position = xy(140., 200.);
let scope = assignment::capture(&f.p, f.d, &BTreeMap::from([(f.n, f.w)]), f.n).unwrap();
let id = Id::new_v4();
let plan = assignment::plan_semantics(
&scope,
Destination::Performer(f.a),
&BTreeMap::from([(f.w, id)]),
)
.unwrap();
let placed = assignment::place(&plan, xy(140., 200.), wide(&f.p, f.d, Some(f.a))).unwrap();
assert_eq!(placed.delta, xy(0., 0.));
let mut expected = f.p.clone();
expected.relations.insert(
id,
Relation {
id,
alias: "a_performs_work".into(),
name: "A performs Work".into(),
description: String::new(),
body: RelationBody::Allocation {
source: f.a,
target: f.w,
},
},
);
assert_eq!(
roundtrip(
&f.p,
assignment::finish(&plan, &f.p, &placed).unwrap().unwrap()
),
expected
);
let scope =
assignment::capture(&expected, f.d, &BTreeMap::from([(f.n, f.w)]), f.n).unwrap();
let noop =
assignment::plan_semantics(&scope, Destination::Performer(f.a), &BTreeMap::new())
.unwrap();
let placed =
assignment::place(&noop, xy(140., 200.), wide(&expected, f.d, Some(f.a))).unwrap();
assert!(
assignment::finish(&noop, &expected, &placed)
.unwrap()
.is_none()
);
}
}
#[test]
fn group_assignment_unfolds_only_changed_destination_and_refuses_newly_exposed_overlap() {
for layer in [Layer::Oa, Layer::Sa] {
for obstacle in [false, true] {
let mut r = roots(layer);
let d = r.f.p.diagrams.get_mut(&r.f.d).unwrap();
d.nodes
.values_mut()
.find(|n| n.element == r.f.b)
.unwrap()
.position = xy(100., 400.);
d.nodes.get_mut(&r.yn).unwrap().position = xy(140., 500.);
d.nodes.get_mut(&r.zn).unwrap().position = xy(1540., 800.);
d.nodes.get_mut(&r.cn).unwrap().collapsed = true;
if obstacle {
let k =
r.f.p
.add_element(r.f.p.elements[&r.f.w].kind(), layer, "Hidden overlap", None)
.unwrap();
r.f.p
.relate(RelationKind::Allocation, "C also owns K", r.c, k);
r.f.p.place(r.f.d, k, xy(1540., 800.));
}
assert!(
!projection::project_view(&r.f.p, r.f.d)
.nodes
.iter()
.any(|n| n.id == r.zn)
);
assert!(
assignment::capture(&r.f.p, r.f.d, &selected(&r, true), r.f.n).is_err(),
"hidden selected member is not normalized away"
);
let scope = assignment::capture(&r.f.p, r.f.d, &selected(&r, false), r.f.n).unwrap();
let plan =
assignment::plan_semantics(&scope, Destination::Performer(r.c), &BTreeMap::new())
.unwrap();
let placed = assignment::place(&plan, xy(1540., 120.), wide(&r.f.p, r.f.d, Some(r.c)));
if obstacle {
assert!(matches!(placed, Err(Refusal::LayoutRequired(_))));
continue;
}
let placed = placed.unwrap();
assert_eq!(placed.unfolded, Some(r.cn));
let mut expected = r.f.p.clone();
retarget(&mut expected, r.ax, r.c);
retarget(&mut expected, r.by, r.c);
let d = expected.diagrams.get_mut(&r.f.d).unwrap();
d.nodes.get_mut(&r.cn).unwrap().collapsed = false;
d.nodes.get_mut(&r.f.n).unwrap().position = xy(1540., 120.);
d.nodes.get_mut(&r.yn).unwrap().position = xy(1540., 420.);
assert_eq!(
roundtrip(
&r.f.p,
assignment::finish(&plan, &r.f.p, &placed).unwrap().unwrap()
),
expected
);
}
}
}
#[test]
fn group_assignment_accumulates_stable_ids_and_aliases_without_partial_reuse() {
let mut f = fixture(Layer::Oa);
let y =
f.p.add_element(Kind::Activity, Layer::Oa, "Work", None)
.unwrap();
let yn = f.p.place(f.d, y, xy(400., 500.));
let selected = BTreeMap::from([(f.n, f.w), (yn, y)]);
let scope = assignment::capture(&f.p, f.d, &selected, f.n).unwrap();
let ids = BTreeMap::from([(f.w, Id::new_v4()), (y, Id::new_v4())]);
let first = assignment::plan_semantics(&scope, Destination::Performer(f.a), &ids).unwrap();
let other = assignment::plan_semantics(&scope, Destination::Performer(f.b), &ids).unwrap();
let repeated = assignment::plan_semantics(&scope, Destination::Performer(f.a), &ids).unwrap();
assert_eq!(first.project(), repeated.project());
assert_eq!(first.changed_count(), 2);
let mut expected = f.p.clone();
let aliases: BTreeSet<_> = ids
.values()
.map(|id| first.project().relations[id].alias.clone())
.collect();
assert_eq!(aliases.len(), 2);
assert!(aliases.contains("a_performs_work"));
for (ordinal, (work, id)) in ids.iter().enumerate() {
let relation = &first.project().relations[id];
let expected_relation = Relation {
id: *id,
alias: if ordinal == 0 {
"a_performs_work".into()
} else {
format!("a_performs_work_{}", &id.simple().to_string()[..8])
},
name: "A performs Work".into(),
description: String::new(),
body: RelationBody::Allocation {
source: f.a,
target: *work,
},
};
assert_eq!(relation, &expected_relation);
assert_eq!(
other.project().relations[id].body,
RelationBody::Allocation {
source: f.b,
target: *work
}
);
expected.relations.insert(*id, expected_relation);
}
assert_eq!(first.project(), &expected);
let duplicate = Id::new_v4();
for bad in [
BTreeMap::from([(f.w, duplicate), (y, duplicate)]),
BTreeMap::from([(f.w, Id::nil()), (y, ids[&y])]),
BTreeMap::from([(f.w, f.a), (y, ids[&y])]),
] {
assert!(assignment::plan_semantics(&scope, Destination::Performer(f.a), &bad).is_err());
}
}
#[test]
fn group_assignment_source_failure_preserves_existing_history_and_retry_ids() {
for layer in [Layer::Oa, Layer::Sa] {
let mut f = fixture(layer);
let y =
f.p.add_element(f.p.elements[&f.w].kind(), layer, "Y", None)
.unwrap();
let yn = f.p.place(f.d, y, xy(400., 800.));
let mut bundle = format_bundle(&f.p);
bundle
.files
.get_mut(&bundle.entry.clone())
.unwrap()
.insert_str(
0,
"// Keep reviewed source through a refused group assignment.\n",
);
let mut limits = ProjectLimits::default();
limits.model.max_source_bytes = bundle.files.values().map(String::len).sum::<usize>() + 128;
let mut document = Document::from_bundle(bundle, limits).unwrap();
document
.execute(Command::Describe(f.w, "Reviewed".into()))
.unwrap();
document
.execute(Command::Rename(y, "Future Y".into()))
.unwrap();
let redo_project = document.project().clone();
let redo_source = document.bundle().clone();
assert!(document.undo());
assert!(document.can_undo() && document.can_redo());
let accepted = document.project().clone();
let source = document.bundle().clone();
let revision = document.revision();
let ids = BTreeMap::from([(f.w, Id::new_v4()), (y, Id::new_v4())]);
let scope =
assignment::capture(&accepted, f.d, &BTreeMap::from([(f.n, f.w), (yn, y)]), f.n)
.unwrap();
let plan = assignment::plan_semantics(&scope, Destination::Performer(f.a), &ids).unwrap();
let placed =
assignment::place(&plan, xy(150., 200.), wide(&accepted, f.d, Some(f.a))).unwrap();
let command = assignment::finish(&plan, &accepted, &placed)
.unwrap()
.unwrap();
assert!(matches!(
document.execute(command.clone()),
Err(crate::document::DocumentError::Source(_))
));
assert_eq!(document.project(), &accepted);
assert_eq!(document.bundle(), &source);
assert_eq!(document.revision(), revision);
assert!(document.can_undo() && document.can_redo());
assert!(document.redo());
assert_eq!(document.project(), &redo_project);
assert_eq!(document.bundle(), &redo_source);
assert!(document.undo());
assert_eq!(document.project(), &accepted);
assert_eq!(document.bundle(), &source);
let mut expected = accepted.clone();
for (work, name, alias) in [
(f.w, "A performs Work", "a_performs_work"),
(y, "A performs Y", "a_performs_y"),
] {
expected.relations.insert(
ids[&work],
Relation {
id: ids[&work],
alias: alias.into(),
name: name.into(),
description: String::new(),
body: RelationBody::Allocation {
source: f.a,
target: work,
},
},
);
}
let d = expected.diagrams.get_mut(&f.d).unwrap();
d.nodes.get_mut(&f.n).unwrap().position = xy(150., 200.);
d.nodes.get_mut(&yn).unwrap().position = xy(450., 500.);
assert_eq!(roundtrip(&accepted, command), expected);
}
}
#[test]
fn group_assignment_collateral_surviving_constraint_refuses_whole_semantic_candidate() {
for layer in [Layer::Oa, Layer::Sa] {
let mut r = roots(layer);
let k =
r.f.p
.add_element(Kind::Constraint, layer, "Keep reviewed assignment", None)
.unwrap();
*r.f.p.elements.get_mut(&k).unwrap().shared_mut().unwrap() = SharedBody::Constraint {
expression: "reviewed assignment".into(),
constrained: vec![r.ax, r.f.w],
};
let core = prepare_command(&r.f.p, &Command::Delete(r.ax))
.unwrap()
.project;
validate_candidate(&core).unwrap();
assert!(core.elements.contains_key(&k));
assert_ne!(core.elements[&k], r.f.p.elements[&k]);
let scope = assignment::capture(&r.f.p, r.f.d, &selected(&r, false), r.f.n).unwrap();
let Err(Refusal::CollateralChangeRequired(changed)) =
assignment::plan_semantics(&scope, Destination::OutsidePerformers, &BTreeMap::new())
else {
panic!("surviving constraint edit must refuse the entire plan");
};
assert!(changed.iter().any(|record| record.id == k));
}
}
#[test]
fn group_assignment_uses_vacated_space_and_one_complete_route_owner_map() {
for layer in [Layer::Oa, Layer::Sa] {
let mut f = fixture(layer);
let kind = f.p.elements[&f.w].kind();
let y = f.p.add_element(kind, layer, "Y", None).unwrap();
let outside = f.p.add_element(kind, layer, "Outside", None).unwrap();
let yn = f.p.place(f.d, y, xy(400., 500.));
f.p.place(f.d, outside, xy(1600., 500.));
let d = f.p.diagrams.get_mut(&f.d).unwrap();
let a = d.nodes.values_mut().find(|n| n.element == f.a).unwrap();
a.position = xy(350., 350.);
a.size = Size {
width: 1000.,
height: 600.,
};
let edge_kind = if layer == Layer::Oa {
RelationKind::Interaction
} else {
RelationKind::FunctionalExchange
};
let internal = f.p.relate(edge_kind, "Internal", f.w, y);
let external = f.p.relate(edge_kind, "External", y, outside);
let mut internal_route = route(internal);
internal_route.waypoints = vec![xy(370., 550.), xy(370., 650.)];
internal_route.source_attachment = Some(EdgeAttachment {
side: PortAnchorSide::Bottom,
offset: 0.3,
});
internal_route.target_attachment = Some(EdgeAttachment {
side: PortAnchorSide::Top,
offset: 0.7,
});
let mut external_route = internal_route.clone();
external_route.edge = external;
external_route.waypoints = vec![xy(1200., 550.), xy(1300., 650.)];
for diagram in [f.d, f.second] {
f.p.diagrams.get_mut(&diagram).unwrap().routes =
vec![internal_route.clone(), external_route.clone()];
}
if let RelationBody::FunctionalExchange { source_port, .. } = f.p.relations[&internal].body
{
f.p.diagrams
.get_mut(&f.d)
.unwrap()
.nodes
.get_mut(&f.n)
.unwrap()
.port_anchors
.push(PortAnchor {
port: source_port,
side: PortAnchorSide::Bottom,
offset: 0.4,
});
}
validate_candidate(&f.p).unwrap();
let ids = BTreeMap::from([(f.w, Id::new_v4()), (y, Id::new_v4())]);
// The singleton candidate really occupies stationary Y's card. The
// combined candidate must not inherit that sequential-planning veto.
let mut singleton = f.p.clone();
singleton
.diagrams
.get_mut(&f.d)
.unwrap()
.nodes
.get_mut(&f.n)
.unwrap()
.position = xy(400., 500.);
let projected = projection::project_view(&singleton, f.d);
let x_card = projected.nodes.iter().find(|n| n.id == f.n).unwrap();
let y_card = projected.nodes.iter().find(|n| n.id == yn).unwrap();
assert_eq!(
overlap(x_card, y_card),
if layer == Layer::Oa { 21056. } else { 29568. }
);
let single = assignment::capture(&f.p, f.d, &BTreeMap::from([(f.n, f.w)]), f.n).unwrap();
let single =
assignment::plan_semantics(&single, Destination::Performer(f.a), &ids).unwrap();
assert!(matches!(
assignment::place(&single, xy(400., 500.), wide(&f.p, f.d, Some(f.a))),
Err(Refusal::LayoutRequired(_))
));
let scope =
assignment::capture(&f.p, f.d, &BTreeMap::from([(f.n, f.w), (yn, y)]), f.n).unwrap();
let plan = assignment::plan_semantics(&scope, Destination::Performer(f.a), &ids).unwrap();
let placed = assignment::place(&plan, xy(400., 500.), wide(&f.p, f.d, Some(f.a))).unwrap();
let mut expected = f.p.clone();
for (work, name, alias) in [
(f.w, "A performs Work", "a_performs_work"),
(y, "A performs Y", "a_performs_y"),
] {
expected.relations.insert(
ids[&work],
Relation {
id: ids[&work],
alias: alias.into(),
name: name.into(),
description: String::new(),
body: RelationBody::Allocation {
source: f.a,
target: work,
},
},
);
}
let d = expected.diagrams.get_mut(&f.d).unwrap();
d.nodes.get_mut(&f.n).unwrap().position = xy(400., 500.);
d.nodes.get_mut(&yn).unwrap().position = xy(700., 500.);
d.routes[0].waypoints = vec![xy(670., 550.), xy(670., 650.)];
assert_eq!(placed.diagram.routes[1], external_route);
assert_eq!(
roundtrip(
&f.p,
assignment::finish(&plan, &f.p, &placed).unwrap().unwrap()
),
expected
);
}
}
#[test]
fn group_assignment_deletes_only_own_routes_across_views_and_keeps_internal_flow() {
for layer in [Layer::Oa, Layer::Sa] {
let mut r = roots(layer);
let kind = if layer == Layer::Oa {
RelationKind::Interaction
} else {
RelationKind::FunctionalExchange
};
let internal = r.f.p.relate(kind, "Keep internal flow", r.f.w, r.y);
for diagram in [r.f.d, r.f.second] {
r.f.p.diagrams.get_mut(&diagram).unwrap().routes =
vec![route(r.ax), route(internal), route(r.by)];
}
validate_candidate(&r.f.p).unwrap();
let scope = assignment::capture(&r.f.p, r.f.d, &selected(&r, false), r.f.n).unwrap();
let plan =
assignment::plan_semantics(&scope, Destination::OutsidePerformers, &BTreeMap::new())
.unwrap();
assert_eq!(plan.changed_count(), 2);
let placed = assignment::place(&plan, xy(640., 200.), wide(&r.f.p, r.f.d, None)).unwrap();
let mut expected = r.f.p.clone();
expected.relations.remove(&r.ax);
expected.relations.remove(&r.by);
for diagram in [r.f.d, r.f.second] {
expected.diagrams.get_mut(&diagram).unwrap().routes = vec![route(internal)];
}
let d = expected.diagrams.get_mut(&r.f.d).unwrap();
d.nodes.get_mut(&r.f.n).unwrap().position = xy(640., 200.);
d.nodes.get_mut(&r.yn).unwrap().position = xy(640., 800.);
d.routes[0].waypoints = vec![xy(920., 400.), xy(920., 550.)];
assert_eq!(
roundtrip(
&r.f.p,
assignment::finish(&plan, &r.f.p, &placed).unwrap().unwrap()
),
expected
);
}
}
@@ -0,0 +1,195 @@
//! Geometry operations over an explicitly captured placement set.
use super::*;
pub(super) struct Translation {
pub diagram: Diagram,
pub commands: Vec<Command>,
pub delta: Position,
}
/// Translate exactly `placements` using one snapped anchor delta. `base` must
/// come from an exact, validated semantic candidate; absent routes are permitted
/// only because that candidate has already admitted their semantic deletion.
pub(super) fn translate(
accepted: &Project,
original: &Diagram,
base: &Diagram,
placements: &BTreeMap<Id, NodePlacement>,
anchor: Id,
requested: Position,
) -> Result<Translation, Refusal> {
let anchor = placements.get(&anchor).ok_or(Refusal::StaleScope)?;
let delta = Position {
x: (requested.x / 10.).round() * 10. - anchor.position.x,
y: (requested.y / 10.).round() * 10. - anchor.position.y,
};
let mut diagram = base.clone();
let mut commands = Vec::new();
let mut deltas = BTreeMap::new();
for (id, captured) in placements {
let node = diagram.nodes.get_mut(id).ok_or(Refusal::StaleScope)?;
node.position = Position {
x: captured.position.x + delta.x,
y: captured.position.y + delta.y,
};
if !(Rect {
position: node.position,
size: node.size,
})
.valid()
{
return Err(Refusal::InvalidCandidate(
"Moved coordinates exceed the model bounds.".into(),
));
}
deltas.insert(*id, delta);
if node != captured {
commands.push(Command::EditNode {
diagram: diagram.id,
original: *id,
node: node.clone(),
});
}
}
// Resolve actual endpoint owners once against accepted semantics and the
// complete delta map. Never reinsert a semantically deleted saved route.
for route in projection::translated_routes(accepted, original, &deltas) {
if let Some(stored) = diagram.routes.iter_mut().find(|r| r.edge == route.edge) {
if route.waypoints.iter().any(|point| {
!point.x.is_finite()
|| !point.y.is_finite()
|| point.x.abs() > MAX_COORDINATE
|| point.y.abs() > MAX_COORDINATE
}) {
return Err(Refusal::InvalidCandidate(
"Moved route exceeds the model bounds.".into(),
));
}
*stored = route.clone();
commands.push(Command::SetEdgeRoute {
diagram: diagram.id,
route,
});
}
}
Ok(Translation {
diagram,
commands,
delta,
})
}
/// Recover hidden ancestry for identity lookup only. The all-unfolded scene is
/// never used for collision admission; only affected nodes and their ancestors
/// enter the required diagnostic expansion.
pub(super) fn expansion_ids(
p: &Project,
d: &Diagram,
index: &ViewIndex,
placements: &BTreeMap<Id, NodePlacement>,
) -> Result<BTreeSet<Id>, Refusal> {
let mut all_unfolded = d.clone();
for node in all_unfolded.nodes.values_mut() {
node.collapsed = false;
}
let all_view = projection::project_diagram_with_index(p, &all_unfolded, index);
let ancestry = ancestors(&all_view);
let mut ids: BTreeSet<_> = placements.keys().copied().collect();
for id in placements.keys() {
ids.extend(ancestry.get(id).ok_or(Refusal::StaleScope)?.iter().copied());
}
Ok(ids)
}
pub(super) fn expanded_view(
p: &Project,
d: &Diagram,
index: &ViewIndex,
ids: &BTreeSet<Id>,
) -> ProjectedView {
let mut expanded = d.clone();
for id in ids {
if let Some(node) = expanded.nodes.get_mut(id) {
node.collapsed = false;
}
}
projection::project_diagram_with_index(p, &expanded, index)
}
pub(super) fn check_scenes(
before_visible: &ProjectedView,
before_expanded: &ProjectedView,
visible: &ProjectedView,
expanded: &ProjectedView,
) -> Result<(), Refusal> {
if visible.nodes.iter().chain(&expanded.nodes).any(|node| {
!(Rect {
position: node.position,
size: node.size,
})
.valid()
}) {
return Err(Refusal::InvalidCandidate(
"The projected enclosure exceeds the model bounds.".into(),
));
}
if overlaps(before_visible, visible) || overlaps(before_expanded, expanded) {
return Err(Refusal::LayoutRequired(
"Make room before arranging: the candidate would introduce or enlarge overlapping cards."
.into(),
));
}
Ok(())
}
pub(super) fn check_rigid(
before: &ProjectedView,
after: &ProjectedView,
placements: &BTreeMap<Id, NodePlacement>,
delta: Position,
) -> Result<(), Refusal> {
let before: BTreeMap<_, _> = before.nodes.iter().map(|n| (n.id, n)).collect();
let after: BTreeMap<_, _> = after.nodes.iter().map(|n| (n.id, n)).collect();
for id in placements.keys() {
let old = before.get(id).ok_or(Refusal::StaleScope)?;
let new = after.get(id).ok_or(Refusal::StaleScope)?;
if (new.position.x - old.position.x - delta.x).abs() > 0.01
|| (new.position.y - old.position.y - delta.y).abs() > 0.01
|| (new.size.width - old.size.width).abs() > 0.01
|| (new.size.height - old.size.height).abs() > 0.01
{
return Err(Refusal::LayoutRequired(
"This selection requires an explicit layout; its footprint cannot be translated unchanged."
.into(),
));
}
}
Ok(())
}
pub(super) fn check_headers(
view: &ProjectedView,
selected: &BTreeMap<Id, Id>,
viewport: Rect,
) -> Result<(), Refusal> {
for (id, element) in selected {
let node = view
.nodes
.iter()
.find(|node| node.id == *id && node.element == *element)
.ok_or_else(|| Refusal::LayoutRequired("A selected header would be hidden.".into()))?;
let header = Rect {
position: node.position,
size: Size {
width: node.size.width,
height: node.size.height.min(62.),
},
};
if !viewport.contains(header) {
return Err(Refusal::LayoutRequired(
"Make room or adjust the view, then drag again.".into(),
));
}
}
Ok(())
}
@@ -0,0 +1,450 @@
//! Reviewed policy experiment using the production projection and translator.
//! No production admission or atomic-refusal behavior is tested here.
use super::*;
struct Mixed {
f: Fixture,
an: Id,
xn: Id,
yn: Id,
internal: Id,
include_performer: bool,
}
fn mixed(layer: Layer, collapsed: bool, obstacle: bool, include_performer: bool) -> Mixed {
let mut f = fixture(layer);
let work = f.p.elements[&f.w].kind();
let exchange = match layer {
Layer::Oa => RelationKind::Interaction,
Layer::Sa => RelationKind::FunctionalExchange,
};
let x = f.p.add_element(work, layer, "X", Some(f.w)).unwrap();
let y = f.p.add_element(work, layer, "Y", Some(f.w)).unwrap();
let outside = f.p.add_element(work, layer, "Outside", None).unwrap();
let xn = f.p.place(f.d, x, xy(140., 200.));
let yn = f.p.place(f.d, y, xy(940., 200.));
f.p.place(f.d, outside, xy(1800., 800.));
f.p.relate(RelationKind::Allocation, "A owns X", f.a, x);
f.p.relate(RelationKind::Allocation, "B owns Y", f.b, y);
// FunctionalExchange's actual builder creates valid function input/output
// ports; do not manufacture Interaction records between SA Functions.
let internal = f.p.relate(exchange, "X to Y", x, y);
let external = f.p.relate(exchange, "Y to Outside", y, outside);
let an = f.p.diagrams[&f.d]
.nodes
.values()
.find(|n| n.element == f.a)
.unwrap()
.id;
let d = f.p.diagrams.get_mut(&f.d).unwrap();
d.nodes.get_mut(&f.n).unwrap().position = xy(100., 800.);
d.nodes.get_mut(&an).unwrap().collapsed = collapsed;
d.routes = vec![route(internal), route(external)];
if obstacle {
let id =
f.p.add_element(work, layer, "Stationary obstacle", None)
.unwrap();
f.p.place(f.d, id, xy(550., 230.));
}
validate_candidate(&f.p).unwrap();
let captured = scope(&f);
assert_eq!(captured.uniform, Uniform::MixedOrUnresolved);
assert_eq!(
captured.placements.keys().copied().collect::<BTreeSet<_>>(),
BTreeSet::from([f.n, xn, yn])
);
// Explicit control: the real current API still refuses Arrange for this root.
assert!(matches!(
plan_semantics(&f.p, &captured, Destination::LayoutOnly, &BTreeMap::new()),
Err(Refusal::MixedLayoutRequired)
));
assert!(matches!(
plan_semantics(
&f.p,
&captured,
Destination::Performer(f.a),
&BTreeMap::new()
),
Err(Refusal::MixedLayoutRequired)
));
Mixed {
f,
an,
xn,
yn,
internal,
include_performer,
}
}
fn affected(m: &Mixed) -> BTreeSet<Id> {
let mut ids: BTreeSet<_> = scope(&m.f).placements.keys().copied().collect();
let mut expected = BTreeSet::from([m.f.n, m.xn, m.yn]);
if m.include_performer {
ids.insert(m.an);
ids.extend(projection::descendant_nodes(&m.f.p, m.f.d, m.an));
expected.insert(m.an);
}
assert_eq!(ids, expected);
ids
}
// Diagnostic expansion changes only a temporary Diagram. Include affected
// descendants and ancestors even when the normal projection hides them.
// Unrelated folded interiors are intentionally OUTSIDE this diagnostic domain.
fn diagnostic(p: &Project, diagram: Id, ids: &BTreeSet<Id>) -> ProjectedView {
let mut d = p.diagrams[&diagram].clone();
for (id, node) in &mut d.nodes {
if ids.contains(id)
|| projection::descendant_nodes(p, diagram, *id)
.iter()
.any(|n| ids.contains(n))
{
node.collapsed = false;
}
}
projection::project_diagram(p, &d)
}
fn rigid(before: &ProjectedView, after: &ProjectedView, ids: &BTreeSet<Id>) {
for id in ids {
let old = before.nodes.iter().find(|n| n.id == *id).unwrap();
let new = after.nodes.iter().find(|n| n.id == *id).unwrap();
assert_eq!(new.position, xy(old.position.x + 400., old.position.y));
assert_eq!(new.size, old.size, "no reflow of an affected footprint");
}
}
// The only experimental bypass: use the pure general geometry command instead
// of plan_semantics(LayoutOnly), whose mixed-root pre-veto is asserted above.
// This is not a fabricated Scope or a call to singleton check_geometry with a
// falsely enlarged Scope. The combined scene uses the real overlap predicate.
fn candidate(m: &Mixed) -> (Command, Project) {
let ids = affected(m);
let command =
oa_core::diagram_edit::translate_placements(&m.f.p, m.f.d, &ids, xy(400., 0.)).unwrap();
let actual = prepare_command(&m.f.p, &command).unwrap().project;
validate_candidate(&actual).unwrap();
// Independent literal expected Project: no planner or route helper oracle.
let mut expected = m.f.p.clone();
let d = expected.diagrams.get_mut(&m.f.d).unwrap();
for (id, position) in [
(m.f.n, xy(500., 800.)),
(m.xn, xy(540., 200.)),
(m.yn, xy(1340., 200.)),
] {
d.nodes.get_mut(&id).unwrap().position = position;
}
if m.include_performer {
d.nodes.get_mut(&m.an).unwrap().position = xy(500., 100.);
}
let internal = d.routes.iter_mut().find(|r| r.edge == m.internal).unwrap();
internal.waypoints = vec![xy(820., 400.), xy(820., 550.)];
assert_eq!(
actual, expected,
"complete records, including folds, routes, other view and relations"
);
(command, actual)
}
#[test]
fn mixed_arrange_experiment_distributed_root_and_performer_can_translate_rigidly() {
for layer in [Layer::Oa, Layer::Sa] {
let enclosure_height = match layer {
Layer::Oa => 218.,
Layer::Sa => 256.,
};
for include_performer in [false, true] {
for collapsed in [false, true] {
let m = mixed(layer, collapsed, false, include_performer);
let accepted = m.f.p.clone();
let (command, actual) = candidate(&m);
let ids = affected(&m);
let before = projection::project_view(&accepted, m.f.d);
let after = projection::project_view(&actual, m.f.d);
let before_expanded = diagnostic(&accepted, m.f.d, &ids);
let after_expanded = diagnostic(&actual, m.f.d, &ids);
assert!(
!overlaps(&before, &after),
"visible scene must admit the combined move"
);
assert!(
!overlaps(&before_expanded, &after_expanded),
"expanded scene must admit the combined move"
);
rigid(&before_expanded, &after_expanded, &ids);
for node in after.nodes.iter().chain(&after_expanded.nodes) {
assert!(
(Rect {
position: node.position,
size: node.size
})
.valid()
);
}
// Explicit selected headers remain visible. Native clipping/readability
// and complete group gesture admission are intentionally not claimed.
assert!(after.nodes.iter().any(|n| n.id == m.f.n));
if include_performer {
assert!(after.nodes.iter().any(|n| n.id == m.an));
} else {
assert_eq!(
actual.diagrams[&m.f.d].nodes[&m.an],
accepted.diagrams[&m.f.d].nodes[&m.an]
);
assert_eq!(node(&before_expanded, m.an).position, xy(100., 100.));
assert_eq!(
node(&before_expanded, m.an).size,
Size {
width: 288.,
height: enclosure_height
}
);
assert_eq!(node(&after_expanded, m.an).position, xy(100., 100.));
assert_eq!(
node(&after_expanded, m.an).size,
Size {
width: 688.,
height: enclosure_height
}
);
}
assert_eq!(roundtrip(&accepted, command), actual);
assert_eq!(m.f.p, accepted);
}
}
}
}
#[test]
fn mixed_arrange_experiment_detects_moved_folded_child_collision() {
for layer in [Layer::Oa, Layer::Sa] {
let expected_overlap = match layer {
Layer::Oa => 13696.,
Layer::Sa => 20116.,
};
let m = mixed(layer, true, true, true);
let accepted = m.f.p.clone();
let (_, actual) = candidate(&m);
let ids = affected(&m);
let before = projection::project_view(&accepted, m.f.d);
let after = projection::project_view(&actual, m.f.d);
assert!(!before.nodes.iter().any(|n| n.id == m.xn));
assert!(!after.nodes.iter().any(|n| n.id == m.xn));
assert!(
!overlaps(&before, &after),
"must distinguish hidden collision from ordinary visible refusal"
);
let before_expanded = diagnostic(&accepted, m.f.d, &ids);
let after_expanded = diagnostic(&actual, m.f.d, &ids);
rigid(&before_expanded, &after_expanded, &ids);
let obstacle = before_expanded
.nodes
.iter()
.find(|n| n.position == xy(550., 230.))
.unwrap()
.id;
assert_eq!(
overlap(
node(&before_expanded, m.xn),
node(&before_expanded, obstacle)
),
0.
);
assert_eq!(
overlap(node(&after_expanded, m.xn), node(&after_expanded, obstacle)),
expected_overlap
);
assert!(
overlaps(&before_expanded, &after_expanded),
"combined diagnostic scene must refuse"
);
// Detection only: there is no production group admission/commit API here.
assert_eq!(m.f.p, accepted);
}
}
fn node(view: &ProjectedView, id: Id) -> &ProjectedNode {
view.nodes.iter().find(|n| n.id == id).unwrap()
}
#[test]
fn mixed_arrange_experiment_detects_stationary_enclosure_growth_collision() {
for layer in [Layer::Oa, Layer::Sa] {
let enclosure_height = match layer {
Layer::Oa => 218.,
Layer::Sa => 256.,
};
let mut m = mixed(layer, false, false, true);
let kind = m.f.p.elements[&m.f.w].kind();
let z =
m.f.p
.add_element(kind, layer, "Stationary Z", None)
.unwrap();
let zn = m.f.p.place(m.f.d, z, xy(1200., 100.));
validate_candidate(&m.f.p).unwrap();
let accepted = m.f.p.clone();
let (_, actual) = candidate(&m);
let bn = accepted.diagrams[&m.f.d]
.nodes
.values()
.find(|n| n.element == m.f.b)
.unwrap()
.id;
let before = projection::project_view(&accepted, m.f.d);
let after = projection::project_view(&actual, m.f.d);
assert_eq!(node(&before, bn).position, xy(900., 100.));
assert_eq!(
node(&before, bn).size,
Size {
width: 288.,
height: enclosure_height
}
);
assert_eq!(node(&after, bn).position, xy(900., 100.));
assert_eq!(
node(&after, bn).size,
Size {
width: 688.,
height: enclosure_height
}
);
assert_eq!(node(&after, zn).position, xy(1200., 100.));
assert_eq!(
node(&after, zn).size,
Size {
width: 224.,
height: 94.
}
);
assert_eq!(overlap(node(&before, bn), node(&before, zn)), 0.);
assert_eq!(overlap(node(&after, bn), node(&after, zn)), 21056.);
assert_eq!(node(&after, m.yn).position, xy(1340., 200.));
assert_eq!(
node(&after, m.yn).position.y
- (node(&after, zn).position.y + node(&after, zn).size.height),
6.
);
for id in affected(&m) {
assert_eq!(overlap(node(&before, id), node(&before, zn)), 0.);
assert_eq!(overlap(node(&after, id), node(&after, zn)), 0.);
}
assert_eq!(
actual.diagrams[&m.f.d].nodes[&bn],
accepted.diagrams[&m.f.d].nodes[&bn]
);
assert_eq!(
actual.diagrams[&m.f.d].nodes[&zn],
accepted.diagrams[&m.f.d].nodes[&zn]
);
assert!(
overlaps(&before, &after),
"complete scene must detect stationary enclosure growth"
);
let ids = affected(&m);
assert!(overlaps(
&diagnostic(&accepted, m.f.d, &ids),
&diagnostic(&actual, m.f.d, &ids)
));
assert_eq!(m.f.p, accepted);
}
}
#[test]
fn mixed_arrange_experiment_unrelated_folded_interiors_are_outside_diagnostic_domain() {
for layer in [Layer::Oa, Layer::Sa] {
// Independent source-derived literals: SA's auto-created function ports
// raise projected X/Y height to 76 + 28 * (1 + 1), while Z is port-free.
let (work_height, expected_overlap) = match layer {
Layer::Oa => (94., 13696.),
Layer::Sa => (132., 20116.),
};
let mut m = mixed(layer, true, false, true);
let performer = match layer {
Layer::Oa => Kind::Actor,
Layer::Sa => Kind::SystemActor,
};
let kind = m.f.p.elements[&m.f.w].kind();
let d =
m.f.p
.add_element(performer, layer, "Unrelated D", None)
.unwrap();
let z =
m.f.p
.add_element(kind, layer, "Unrelated hidden Z", None)
.unwrap();
m.f.p.relate(RelationKind::Allocation, "D owns Z", d, z);
let dn = m.f.p.place(m.f.d, d, xy(2500., 100.));
let zn = m.f.p.place(m.f.d, z, xy(550., 230.));
m.f.p
.diagrams
.get_mut(&m.f.d)
.unwrap()
.nodes
.get_mut(&dn)
.unwrap()
.collapsed = true;
validate_candidate(&m.f.p).unwrap();
let accepted = m.f.p.clone();
let (_, actual) = candidate(&m);
let ids = affected(&m);
let before = diagnostic(&accepted, m.f.d, &ids);
let after = diagnostic(&actual, m.f.d, &ids);
assert!(!before.nodes.iter().any(|n| n.id == zn));
assert!(!after.nodes.iter().any(|n| n.id == zn));
assert!(
!overlaps(&before, &after),
"chosen diagnostic domain excludes unrelated folded contents"
);
assert!(!overlaps(
&projection::project_view(&accepted, m.f.d),
&projection::project_view(&actual, m.f.d)
));
let all_unfolded = |p: &Project| {
let mut diagram = p.diagrams[&m.f.d].clone();
for n in diagram.nodes.values_mut() {
n.collapsed = false;
}
projection::project_diagram(p, &diagram)
};
let broad_before = all_unfolded(&accepted);
let broad_after = all_unfolded(&actual);
assert_eq!(node(&broad_before, m.xn).position, xy(140., 200.));
assert_eq!(node(&broad_after, m.xn).position, xy(540., 200.));
assert_eq!(
node(&broad_after, m.xn).size,
Size {
width: 224.,
height: work_height
}
);
assert_eq!(node(&broad_before, zn).position, xy(550., 230.));
assert_eq!(node(&broad_after, zn).position, xy(550., 230.));
assert_eq!(
node(&broad_after, zn).size,
Size {
width: 224.,
height: 94.
}
);
assert_eq!(
overlap(node(&broad_before, m.xn), node(&broad_before, zn)),
0.
);
assert_eq!(
overlap(node(&broad_after, m.xn), node(&broad_after, zn)),
expected_overlap
);
// Other newly exposed enclosure pairs may also overlap. X/Z is a
// concrete discriminator, not a claim that it is the only cause.
assert!(overlaps(&broad_before, &broad_after));
for id in [dn, zn] {
assert_eq!(
actual.diagrams[&m.f.d].nodes[&id],
accepted.diagrams[&m.f.d].nodes[&id]
);
}
assert!(actual.diagrams[&m.f.d].nodes[&dn].collapsed);
assert_eq!(m.f.p, accepted);
}
}
@@ -2,6 +2,18 @@ use super::*;
use crate::document::Document;
use oa_core::source_project::{ProjectLimits, compile_bundle, format_bundle};
#[path = "group_arrange_experiment.rs"]
mod group_arrange_experiment;
#[path = "arrange_tests.rs"]
mod arrange_tests;
#[path = "assignment_tests.rs"]
mod assignment_tests;
#[path = "assignment_boundary_tests.rs"]
mod assignment_boundary_tests;
struct Fixture {
p: Project,
d: Id,
@@ -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()),
+220 -93
View File
@@ -14,16 +14,20 @@ mod diagram_reveal;
#[cfg(test)]
mod diagram_selection_tests;
mod diagram_usages;
pub(crate) mod escape;
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;
pub(crate) mod text_workspace;
#[cfg(test)]
mod traceability_navigation_tests;
mod state_multiselect_focus_tests;
#[cfg(test)]
mod state_multiselect_tests;
pub(crate) mod text_workspace;
#[cfg(test)]
mod transition_context_tests;
#[cfg(test)]
@@ -264,6 +268,7 @@ mod direct_scenario_tests {
#[cfg(test)]
mod behavior_lifecycle_tests;
mod process_canvas;
mod process_multiselect;
mod state_diagram;
#[cfg(test)]
mod state_diagram_tests;
@@ -362,12 +367,12 @@ pub enum Message {
Page(Page),
Guide(crate::guide::Event),
Traceability(crate::views::traceability::Event),
ReviewLayerTraces,
Behavior(crate::behavior::Event),
Layer(Layer),
Select(Id),
EditProperties(Id),
SelectNode(Id),
DiagramSelection(multiselect::Event),
SelectProject,
Search(String),
StartTransition,
@@ -511,6 +516,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,
@@ -631,6 +637,7 @@ impl App {
layer,
selected,
selected_node: None,
diagram_selection: None,
draft,
active_diagram,
search: String::new(),
@@ -812,6 +819,9 @@ impl App {
.map(|n| n.id);
}
self.draft = Draft::from_project(self.project(), self.selected);
if self.process_preview_primary().is_some() {
self.draft = None;
}
if self.exact_strings
&& let Some(d) = &mut self.draft
{
@@ -1109,10 +1119,13 @@ impl App {
previous != (self.layer, self.active_diagram)
}
fn select(&mut self, id: Id) {
// An exact Process preview and its defining object share a semantic ID,
// but ordinary Explorer/property navigation must still leave the use.
let process_preview = self.process_preview_primary().is_some();
if id != self.selected {
self.selected_attachment = None;
}
if self.selected == id {
if self.selected == id && !process_preview {
return;
}
if !self.apply() {
@@ -1121,6 +1134,10 @@ impl App {
if !self.project().contains(id) {
return;
}
self.behavior.topology.clear_context();
if process_preview {
self.diagram_navigation_focus = None;
}
let previous_state_view = self.state_view_identity();
self.selected = id;
self.selected_node = None;
@@ -1165,7 +1182,7 @@ impl App {
crate::model_menu::popup(self).or_else(|| self.text_workspace_menu()),
)
.into();
let content = crate::source_navigation::interaction::observe(
crate::source_navigation::interaction::observe(
crate::behavior::scenario::focus::root(
behavior::canvas_workspace::focus_root(
crate::hosted::view::overlay(
@@ -1177,8 +1194,7 @@ impl App {
self.scenario_focus_owner(),
),
self.source_symbols.raw_interaction.clone(),
);
escape::root(self, content)
)
}
pub fn subscription(&self) -> Subscription<Message> {
Subscription::batch([
@@ -1220,6 +1236,7 @@ impl App {
iced::time::every(std::time::Duration::from_secs(10)).map(|_| Message::CheckpointTick),
window::close_requests().map(|id| Message::Request(Pending::Close(id))),
window::resize_events().map(|(_, size)| Message::WindowResized(size)),
architecture_authoring::subscription(self),
iced::event::listen_with(|event, _, _| {
matches!(
event,
@@ -1227,6 +1244,20 @@ impl App {
)
.then_some(Message::WorkDrag(allocation_drag::Event::KeyboardBoundary))
}),
// Use one captured-Escape bridge: a focused Find input consumes
// Escape, while modal priority remains in the ordinary handler.
behavior::creation::subscription(
self.text_workspace.menu().is_none()
&& (self.behavior.creation.has_pending()
|| self.diagram_usages.is_some()
|| self.behavior.source_active()
|| (self.page == Page::Text
&& (self.text_workspace.drawer().is_some()
|| self.source_navigation.open
|| self.source_rename.open())
&& self.architecture.is_none())),
)
.map(|()| Message::Escape),
iced::event::listen_with(|event, status, _| {
if matches!(event, iced::Event::Window(window::Event::Unfocused)) {
return Some(Message::WorkDrag(allocation_drag::Event::Unfocused));
@@ -1274,11 +1305,116 @@ impl App {
])
}
pub fn update(&mut self, message: Message) -> Task<Message> {
let preview_read_only = self.process_preview_primary().is_some();
let selection_boundary = self.multiselect_boundary();
// Reject delayed gestures before they can retire newer input ownership.
if let Message::Connection(
crate::connection_edit::Event::Endpoint(captured, _)
| crate::connection_edit::Event::Restart(captured),
) = &message
&& (self.route_context() != Some(*captured)
|| self
.connection
.as_ref()
.is_none_or(|session| session.context != *captured))
{
return Task::none();
}
// Selection can change without advancing the captured canvas context.
if let Message::Route(
crate::route_edit::Event::Commit(captured, changed, ..)
| crate::route_edit::Event::CommitAttachment(captured, changed, ..),
) = &message
&& (self.route_context() != Some(*captured) || changed.edge != self.selected)
{
return Task::none();
}
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();
}
if let Message::TopologyCanvas(event) = &message {
use crate::behavior::topology::CanvasEvent;
if !matches!(
event,
CanvasEvent::Camera(..) | CanvasEvent::Scrolled(..) | CanvasEvent::Viewport(..)
) && self.topology_context() != Some(event.context())
{
return Task::none();
}
if matches!(
event,
CanvasEvent::Select(..)
| CanvasEvent::SelectQualified(..)
| CanvasEvent::Remove(..)
| CanvasEvent::Reorder { .. }
| CanvasEvent::Reconnect { .. }
) && self
.multiselect_snapshot()
.is_some_and(|s| s.targets().len() > 1)
{
return Task::none();
}
}
if self.process_order_noop(&message) {
return Task::none();
}
if self.process_preview_primary().is_some()
&& matches!(
&message,
Message::Field(..)
| Message::FieldText(..)
| Message::FieldShape(..)
| Message::FieldAppend(..)
| Message::FieldValuePath(..)
| Message::FieldVariant(..)
| Message::FieldArray(..)
| Message::ElementKind(_)
| Message::RelationKind(_)
| Message::Apply
| Message::Delete
| Message::DiagramRemove(_)
)
{
self.status = "Open the referenced definition to edit its contents.".into();
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 {
return self.text_workspace_event(event);
}
// Allocation placement movement requires its captured Press/Active
// lifetime, even after cancellation. A bare queued move has no epoch.
if self.allocation_legacy_move(&message) {
return Task::none();
}
// These handlers reject old owners/tokens without changing state. Apply
// the same admission before shared camera and allocation boundaries.
if !self.source_input_envelope(&message)
@@ -1326,7 +1462,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 {
@@ -1359,6 +1499,14 @@ 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();
}
if preview_read_only && self.process_preview_primary().is_none() && self.draft.is_none() {
self.refresh_draft();
}
task
}
fn behavior_control(&mut self, intent: behavior::canvas_workspace::Intent) -> Task<Message> {
@@ -1722,6 +1870,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;
}
@@ -2271,6 +2420,9 @@ impl App {
self.route_context()
};
}
if request.process.is_some() {
request.process = self.topology_context().map(Box::new);
}
self.model_menu = crate::model_menu::Opened::new(self, request, self.menu_sequence);
}
Message::Menu(crate::model_menu::Event::Close) => {
@@ -2300,31 +2452,20 @@ impl App {
"Apply or revert pending edits before using modeling actions.".into();
return Task::none();
}
// Show acts on the diagram captured by the validated menu.
// Ordinary cross-layer selection would switch to that element's
// preferred same-layer view before the placement command runs.
let show_in_mixed_view = matches!(&*action, Message::PlaceSelected)
&& self
.active_diagram
.and_then(|id| self.project().diagrams.get(&id))
.is_some_and(|d| {
d.projection == ProjectionKind::Traceability
&& self
.project()
.elements
.get(&menu.request.object)
.is_some_and(|e| {
e.kind() != Kind::Port && d.projection.includes(e.kind())
})
});
if show_in_mixed_view {
self.selected = menu.request.object;
self.selected_attachment = None;
self.refresh_draft();
} else {
self.select(menu.request.object);
if matches!(*action, Message::DiagramSelection(_))
|| (menu.request.process.is_some()
&& matches!(*action, Message::TopologyCanvas(_)))
{
return self.update(*action);
}
self.selected_node = menu.request.placement;
self.select(menu.request.object);
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);
}
@@ -2482,6 +2623,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;
@@ -2521,23 +2663,6 @@ impl App {
iced::widget::scrollable::RelativeOffset::START,
);
}
Message::ReviewLayerTraces => {
// Reuse Page navigation so modal, placement and behavior-form
// guards run before changing the transient inventory filter.
let navigation = self.update(Message::Page(Page::Traces));
if self.page == Page::Traces && !self.behavior.has_pending() {
self.traceability.filter = crate::views::traceability::Filter::Traces;
self.traceability.query.clear();
return Task::batch([
navigation,
iced::widget::operation::snap_to(
"traceability-list",
iced::widget::scrollable::RelativeOffset::START,
),
]);
}
return navigation;
}
Message::Search(value) => {
self.search = value;
self.explorer_page = 0;
@@ -3156,7 +3281,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()
@@ -3165,6 +3304,17 @@ impl App {
};
}
Message::Delete => {
if self
.multiselect_snapshot()
.is_some_and(|s| s.targets().len() > 1)
{
self.status = if self.topology_context().is_some() {
"Group removal needs an aggregate review. Select one contribution to remove it individually."
} else {
"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);
}
@@ -3296,6 +3446,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
{
@@ -3325,6 +3482,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
@@ -4491,14 +4655,6 @@ impl App {
if !crate::connection_tool::Tool::for_layer(self.layer).contains(&tool) {
return;
}
if tool == crate::connection_tool::Tool::Allocation
&& !tool.available_in(
self.layer,
self.project().diagrams[&context.diagram].projection,
)
{
return;
}
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
let context = self.route_context().unwrap();
self.connection = Some(Session::new(
@@ -4529,32 +4685,14 @@ impl App {
if let Some(source) = session.source {
match session.tool.command(self.project(), source, id) {
Ok(command) => {
let previous_layer = self.layer;
if session.tool == crate::connection_tool::Tool::Allocation {
// Accepted edits belong to the captured view. A
// retained mixed view can differ from the current
// perspective; sync must not choose another view.
self.layer = self.project().diagrams[&context.diagram].layer;
}
if self.execute(command) {
self.selected_node = None;
self.inspector_tab = Default::default();
self.status = if session.tool
== crate::connection_tool::Tool::Allocation
{
format!(
"Allocated {} to {} · view filters are unchanged · Undo restores the complete operation",
self.project().named(id),
self.project().named(source)
)
} else {
format!(
"{} created · edit its name and carried items in Properties · Undo restores the complete operation",
session.tool.label()
)
};
self.status = format!(
"{} created · edit its name and carried items in Properties · Undo restores the complete operation",
session.tool.label()
);
} else {
self.layer = previous_layer;
self.connection = Some(session);
}
}
@@ -4569,18 +4707,7 @@ impl App {
&project_view(self.project(), context.diagram),
id,
);
if session.tool == crate::connection_tool::Tool::Allocation
&& self.project().diagrams[&context.diagram].projection
== ProjectionKind::Traceability
{
// Mixed views can allocate either perspective. Choosing
// a visible source must not navigate to its preferred view.
self.selected = id;
self.selected_attachment = None;
self.refresh_draft();
} else {
self.select(id);
}
self.select(id);
self.status = if session.candidates.is_empty() {
"No compatible targets are visible. Expand containers or show a compatible endpoint in this view; Esc cancels drawing.".into()
} else {
+558 -128
View File
@@ -1,12 +1,21 @@
//! Allocation work-body input admission. Gesture authority lives here, while
//! the canvas retains ordered native pointer positions and frozen hit geometry.
use super::{App, Message, Page};
use crate::{allocation_drag as plan, route_edit::Context};
use crate::{
allocation_drag as plan,
diagram_selection::Snapshot,
diagram_usage::{Surface, Target as SelectionTarget},
route_edit::Context,
};
use iced::{Point, Rectangle, Task, Vector};
use oa_core::{projection::ProjectedView, routing::RoutedView, *};
use std::{collections::BTreeMap, sync::Arc};
use std::{
collections::{BTreeMap, BTreeSet},
sync::Arc,
};
pub const FINISH_EDIT: &str = "Finish or cancel the current edit before selecting or moving work.";
pub const FINISH_EDIT: &str =
"Finish or cancel the current edit before selecting or moving elements.";
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Stamp {
@@ -65,7 +74,8 @@ pub fn contains(r: Rectangle, p: Point) -> bool {
impl Frozen {
pub fn capture(
project: &Project,
scope: &plan::Scope,
diagram: &Diagram,
affected: &BTreeMap<Id, NodePlacement>,
view: &ProjectedView,
routed: &RoutedView,
zoom: f32,
@@ -73,7 +83,7 @@ impl Frozen {
let mut targets = Vec::new();
let mut cards = Vec::new();
for n in &view.nodes {
if scope.placements.contains_key(&n.id) {
if affected.contains_key(&n.id) {
continue;
}
let region = Rectangle::new(
@@ -84,7 +94,7 @@ impl Frozen {
cards.push(region);
continue;
};
if e.kind().is_performer() && e.layer() == scope.diagram.layer {
if e.kind().is_performer() && e.layer() == diagram.layer {
let mut ancestors = Vec::new();
let mut parent = e.parent;
while let Some(id) = parent {
@@ -95,7 +105,7 @@ impl Frozen {
parent = project.elements.get(&id).and_then(|e| e.parent);
}
let name = project.named(e.id).to_owned();
let collapsed = scope.diagram.nodes[&n.id].collapsed;
let collapsed = diagram.nodes[&n.id].collapsed;
targets.push(Target {
element: e.id,
placement: n.id,
@@ -118,7 +128,7 @@ impl Frozen {
let mut controls: Vec<_> = view
.ports
.iter()
.filter(|port| !scope.placements.contains_key(&port.owner))
.filter(|port| !affected.contains_key(&port.owner))
.map(|port| {
Rectangle::new(
Point::new(port.position.x - radius, port.position.y - radius),
@@ -224,17 +234,23 @@ impl Frozen {
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Mode {
Assign,
Arrange,
}
#[derive(Debug, Clone)]
pub struct Press {
pub(crate) struct Press {
pub stamp: Stamp,
pub token: Id,
pub start: Point,
pub scope: plan::Scope,
pub frozen: Frozen,
pub arrange: bool,
pub selection: Snapshot,
pub target: SelectionTarget,
pub mode: Mode,
}
#[derive(Debug, Clone)]
pub enum Event {
pub(crate) enum Event {
Press(Arc<Press>),
Preview(Stamp, Id, Option<Point>),
Release(Stamp, Id, Option<Point>),
@@ -244,23 +260,266 @@ pub enum Event {
Unfocused,
KeyboardBoundary,
}
pub struct PreviewPlacement {
pub diagram: Diagram,
pub view: ProjectedView,
pub delta: Position,
}
pub struct Preview {
pub token: Id,
pub semantic: Arc<plan::SemanticPlan>,
pub placement: plan::PlacementPlan,
pub candidate: Candidate,
pub placement: PreviewPlacement,
pub routed: Arc<RoutedView>,
}
impl Preview {
pub fn project(&self) -> &Project {
self.candidate.project()
}
#[cfg(test)]
pub fn index(&self) -> &view_options::ViewIndex {
self.candidate.index()
}
}
enum Prepared {
Singleton(Box<plan::Scope>),
Assignment(plan::assignment::Scope),
Arrange(Arc<plan::arrange::Plan>),
Performers(Arc<PerformerPlan>),
Refused(String),
}
#[derive(Clone)]
pub enum Candidate {
Singleton(Arc<plan::SemanticPlan>),
Assignment(Arc<plan::assignment::SemanticPlan>),
Arrange(Arc<plan::arrange::Plan>),
Performers(Arc<PerformerPlan>),
}
enum Placed {
Planned(plan::PlacementPlan),
Performers {
preview: PreviewPlacement,
command: Box<Command>,
requested: Position,
},
}
impl Placed {
fn diagram(&self) -> &Diagram {
match self {
Self::Planned(p) => &p.diagram,
Self::Performers { preview, .. } => &preview.diagram,
}
}
fn view(&self) -> &ProjectedView {
match self {
Self::Planned(p) => &p.view,
Self::Performers { preview, .. } => &preview.view,
}
}
fn into_preview(self) -> PreviewPlacement {
match self {
Self::Planned(p) => PreviewPlacement {
diagram: p.diagram,
view: p.view,
delta: p.delta,
},
Self::Performers { preview, .. } => preview,
}
}
}
/// Ordinary performer movement preserves the established core translation policy.
/// It shares input provenance, not the explicit Arrange geometry restrictions.
pub struct PerformerPlan {
accepted: Project,
index: view_options::ViewIndex,
diagram: Id,
selected: BTreeSet<Id>,
anchor: Id,
affected: BTreeMap<Id, NodePlacement>,
}
impl PerformerPlan {
fn place(&self, requested: Position) -> Result<Placed, plan::Refusal> {
let original = &self.accepted.diagrams[&self.diagram];
let anchor = original.nodes[&self.anchor].position;
let delta = if requested == anchor {
Position { x: 0., y: 0. }
} else {
Position {
x: (requested.x / 10.).round() * 10. - anchor.x,
y: (requested.y / 10.).round() * 10. - anchor.y,
}
};
let command =
diagram_edit::translate_placements(&self.accepted, self.diagram, &self.selected, delta)
.map_err(|e| plan::Refusal::InvalidCandidate(e.to_string()))?;
let mut diagram = original.clone();
let Command::Batch(edits) = &command else {
unreachable!("translation is an atomic batch")
};
for edit in edits {
match edit {
Command::EditNode {
diagram: id,
original,
node,
} if *id == self.diagram => {
diagram.nodes.insert(*original, node.clone());
}
Command::SetEdgeRoute { diagram: id, route } if *id == self.diagram => {
let stored = diagram
.routes
.iter_mut()
.find(|r| r.edge == route.edge)
.ok_or(plan::Refusal::StaleScope)?;
*stored = route.clone();
}
_ => {
return Err(plan::Refusal::InvalidCandidate(
"Unexpected performer movement edit.".into(),
));
}
}
}
let view = projection::project_diagram_with_index(&self.accepted, &diagram, &self.index);
Ok(Placed::Performers {
preview: PreviewPlacement {
diagram,
view,
delta,
},
command: Box::new(command),
requested,
})
}
}
impl Candidate {
pub fn project(&self) -> &Project {
match self {
Self::Singleton(p) => p.project(),
Self::Assignment(p) => p.project(),
Self::Arrange(p) => p.project(),
Self::Performers(p) => &p.accepted,
}
}
#[cfg(test)]
pub fn index(&self) -> &view_options::ViewIndex {
match self {
Self::Singleton(p) => p.index(),
Self::Assignment(p) => p.index(),
Self::Arrange(p) => p.index(),
Self::Performers(p) => &p.index,
}
}
fn place(
&self,
requested: Position,
presentation: plan::PresentationContext,
) -> Result<Placed, plan::Refusal> {
match self {
Self::Singleton(p) => plan::place(p, requested, presentation).map(Placed::Planned),
Self::Assignment(p) => {
plan::assignment::place(p, requested, presentation).map(Placed::Planned)
}
Self::Arrange(p) => {
plan::arrange::place(p, requested, presentation).map(Placed::Planned)
}
Self::Performers(p) => p.place(requested),
}
}
fn finish(&self, live: &Project, placed: &Placed) -> Result<Option<Command>, plan::Refusal> {
match (self, placed) {
(Self::Singleton(p), Placed::Planned(result)) => plan::finish(p, live, result),
(Self::Assignment(p), Placed::Planned(result)) => {
plan::assignment::finish(p, live, result)
}
(Self::Arrange(p), Placed::Planned(result)) => plan::arrange::finish(p, live, result),
(
Self::Performers(p),
Placed::Performers {
preview,
command,
requested,
},
) => {
if live != &p.accepted {
return Err(plan::Refusal::StaleScope);
}
let checked = p.place(*requested)?;
let Placed::Performers {
preview: checked,
command: checked_command,
..
} = checked
else {
unreachable!()
};
if checked.diagram != preview.diagram
|| checked.view != preview.view
|| checked.delta != preview.delta
|| &checked_command != command
{
return Err(plan::Refusal::StaleScope);
}
let mut expected = p.accepted.clone();
expected.diagrams.insert(p.diagram, checked.diagram);
let actual = prepare_command(live, &checked_command)
.map_err(|e| plan::Refusal::InvalidCandidate(e.to_string()))?
.project;
if actual != expected {
return Err(plan::Refusal::InvalidCandidate(
"Performer movement would change unrelated records.".into(),
));
}
Ok((actual != *live).then_some(*checked_command))
}
_ => Err(plan::Refusal::StaleScope),
}
}
fn label(&self) -> String {
let (label, warnings) = match self {
Self::Singleton(p) => (p.label.clone(), p.warnings.len()),
Self::Assignment(p) => (p.label().to_owned(), p.warnings().len()),
Self::Arrange(p) => (
format!(
"Arrange {} selected elements · {} affected placements",
p.selected().len(),
p.affected().len()
),
0,
),
Self::Performers(p) => (
format!("Move {} selected performer(s)", p.selected.len()),
0,
),
};
if warnings == 0 {
label
} else {
format!("{label} · {warnings} model warning(s) retained")
}
}
}
struct Active {
press: Arc<Press>,
selection: Snapshot,
crossed: bool,
prepared: Prepared,
frozen: Option<Arc<Frozen>>,
inspection_targets: Vec<usize>,
ids: BTreeMap<Id, Id>,
cache: Vec<(
plan::Destination,
Result<Arc<plan::SemanticPlan>, plan::Refusal>,
)>,
cache: Vec<(plan::Destination, Result<Candidate, plan::Refusal>)>,
}
pub struct Feedback {
pub(crate) struct Feedback {
pub press: Arc<Press>,
pub frozen: Option<Arc<Frozen>>,
ordinary_performers: bool,
pub targets: Vec<usize>,
/// Last captured target names for inspection only; never resolver input.
pub inspection_targets: Vec<usize>,
@@ -273,7 +532,13 @@ impl Feedback {
let names = self
.inspection_targets
.iter()
.map(|index| self.press.frozen.targets[*index].name.as_str())
.filter_map(|index| {
self.frozen
.as_ref()?
.targets
.get(*index)
.map(|t| t.name.as_str())
})
.collect::<Vec<_>>()
.join(" / ");
format!(
@@ -293,7 +558,9 @@ impl Feedback {
/// Captured accepted names, independent of clipped status/refusal prose.
pub fn destination_identity(&self) -> String {
match self.targets.as_slice() {
[] => if self.press.arrange {
[] => if self.ordinary_performers {
"Move performers"
} else if self.press.mode == Mode::Arrange {
"Arrange only"
} else if self.refused {
"No eligible destination"
@@ -301,12 +568,22 @@ impl Feedback {
"Outside performers"
}
.into(),
[index] => self.press.frozen.targets[*index].name.clone(),
[index] => self
.frozen
.as_ref()
.and_then(|f| f.targets.get(*index))
.map(|t| t.name.clone())
.unwrap_or_else(|| "No eligible destination".into()),
indices => format!(
"Ambiguous: {}",
indices
.iter()
.map(|index| self.press.frozen.targets[*index].name.as_str())
.filter_map(|index| self
.frozen
.as_ref()?
.targets
.get(*index)
.map(|t| t.name.as_str()))
.collect::<Vec<_>>()
.join(" / ")
),
@@ -391,6 +668,18 @@ impl App {
&& self.canvas_size.is_none_or(|s| s == stamp.bounds.size())
&& stamp.bounds.width > 0.
&& stamp.bounds.height > 0.
&& stamp.zoom > 0.
&& [
stamp.bounds.x,
stamp.bounds.y,
stamp.bounds.width,
stamp.bounds.height,
stamp.pan.x,
stamp.pan.y,
stamp.zoom,
]
.iter()
.all(|value| value.is_finite())
}
/// Called before general input dispatch. Even equal-valued actions (Fit,
/// Undo/Redo, tool/modal round trips) retire old render-time capabilities.
@@ -427,20 +716,54 @@ impl App {
pub(super) fn allocation_retire(&mut self) {
self.allocation_drag.retire();
}
fn select_work_clean(&mut self, press: &Press) -> bool {
if !self.allocation_stamp_valid(press.stamp) {
return false;
}
self.selected = press.scope.work;
self.selected_node = Some(press.scope.placement);
self.selected_bend = None;
self.refresh_draft();
true
fn allocation_selection_valid(&self, snapshot: &Snapshot) -> bool {
self.multiselect_context().is_some_and(|current| {
self.diagram_selection
.as_ref()
.is_some_and(|selection| selection.matches(snapshot, current))
})
}
pub(super) fn allocation_press_owns_current_view(&self, press: &Press) -> bool {
self.allocation_drag.active.is_none()
&& self.allocation_stamp_valid(press.stamp)
&& press.arrange == self.allocation_drag.arrange_next
if self.allocation_drag.active.is_some()
|| !self.allocation_stamp_valid(press.stamp)
|| (press.mode == Mode::Arrange) != self.allocation_drag.arrange_next
|| !self.allocation_selection_valid(&press.selection)
|| press.selection.context().scope.surface
!= Surface::Saved(press.stamp.context.diagram)
|| !press.start.x.is_finite()
|| !press.start.y.is_finite()
|| !contains(press.stamp.bounds, press.start)
{
return false;
}
let view = projection::project_view_with_index(
self.project(),
press.stamp.context.diagram,
self.document.view_index(),
);
super::multiselect::visible(
self.project(),
press.stamp.context.diagram,
&view,
&press.target,
)
}
/// Bare Allocation movement has no lifetime provenance. All actual Allocation
/// placement gestures now enter Press/Active, including ordinary performers.
pub(super) fn allocation_legacy_move(&self, message: &Message) -> bool {
let allocation = |diagram| {
self.project()
.diagrams
.get(&diagram)
.is_some_and(|d| d.projection == ProjectionKind::Allocation)
};
match message {
Message::MoveNode(..) => self.active_diagram.is_some_and(allocation),
Message::DiagramSelection(super::multiselect::Event::Move(snapshot, _)) => {
matches!(snapshot.context().scope.surface, Surface::Saved(diagram) if allocation(diagram))
}
_ => false,
}
}
pub(super) fn allocation_event_envelope(&self, event: &Event) -> bool {
match event {
@@ -451,11 +774,64 @@ impl App {
.is_some_and(|active| active.press.token == *token)
}
Event::Refused(epoch, _) => *epoch == self.allocation_drag.epoch,
// Invalid Press packets deliberately report why the view or draft
// changed. Leave their existing validation and feedback intact.
_ => true,
}
}
fn allocation_prepare(&self, press: &Press, selection: &Snapshot) -> Prepared {
let SelectionTarget::Placement(anchor) = &press.target else {
return Prepared::Refused("Move requires only placed work or performers; ports and connections cannot be moved with this selection.".into());
};
let anchor = *anchor;
let Some(ids) = super::multiselect::placements(selection) else {
return Prepared::Refused("Move requires only placed work or performers; remove ports, rows and connections from the selection.".into());
};
let project = self.project();
let diagram = press.stamp.context.diagram;
let d = &project.diagrams[&diagram];
let selected: BTreeMap<_, _> = ids.iter().map(|id| (*id, d.nodes[id].element)).collect();
let result = if press.mode == Mode::Arrange {
plan::arrange::capture(project, diagram, &selected, anchor)
.map(|p| Prepared::Arrange(Arc::new(p)))
} else if selected
.values()
.all(|id| project.elements[id].kind().is_work())
{
if selected.len() == 1 {
plan::capture(project, diagram, selected[&anchor], anchor)
.map(|scope| Prepared::Singleton(Box::new(scope)))
} else {
plan::assignment::capture(project, diagram, &selected, anchor)
.map(Prepared::Assignment)
}
} else if selected
.values()
.all(|id| project.elements[id].kind().is_performer())
{
validate_candidate(project)
.map_err(|e| plan::Refusal::InvalidCandidate(e.to_string()))
.map(|_| {
let mut affected = BTreeMap::new();
for id in &ids {
for member in std::iter::once(*id)
.chain(projection::descendant_nodes(project, diagram, *id))
{
affected.insert(member, d.nodes[&member].clone());
}
}
Prepared::Performers(Arc::new(PerformerPlan {
accepted: project.clone(),
index: view_options::ViewIndex::new(project),
diagram,
selected: ids,
anchor,
affected,
}))
})
} else {
Err(plan::Refusal::Ineligible("Assignment requires only work. Arm Arrange next drag to arrange a supported work/performer selection.".into()))
};
result.unwrap_or_else(|reason| Prepared::Refused(refusal(&reason)))
}
pub(super) fn allocation_event(&mut self, event: Event) -> Task<Message> {
if !self.allocation_event_envelope(&event) {
return Task::none();
@@ -465,7 +841,8 @@ impl App {
self.allocation_drag.retire();
if self.allocation_drag_clean() {
self.allocation_drag.arrange_next = true;
self.status = "Arrange next drag armed · moves work only · Esc cancels".into();
self.status =
"Arrange next drag armed · presentation only · Esc cancels".into();
} else {
self.status = FINISH_EDIT.into();
}
@@ -476,22 +853,13 @@ impl App {
self.allocation_drag.retire();
}
}
Event::Cancel(token) => {
if self
.allocation_drag
.active
.as_ref()
.is_some_and(|a| a.press.token == token)
{
self.allocation_drag.retire();
self.status = "Work drag canceled".into();
}
Event::Cancel(_) => {
self.allocation_drag.retire();
self.status = "Drag canceled".into();
}
Event::Refused(epoch, text) => {
if epoch == self.allocation_drag.epoch {
self.allocation_drag.retire();
self.status = text;
}
Event::Refused(_, text) => {
self.allocation_drag.retire();
self.status = text;
}
Event::Press(press) => {
if self.allocation_drag.active.is_some() {
@@ -505,32 +873,73 @@ impl App {
};
return Task::none();
}
let checked = plan::capture(
self.project(),
press.scope.diagram.id,
press.scope.work,
press.scope.placement,
);
if checked.as_ref() != Ok(&press.scope) || !self.select_work_clean(&press) {
self.status = checked
.err()
.as_ref()
.map(refusal)
.unwrap_or_else(|| "The view changed. Start this drag again.".into());
self.allocation_drag.retire();
// A selected-member press has no selection or inspector effect.
// An unselected press replaces selection atomically in this handler.
if !press.selection.targets().contains(&press.target) {
self.multiselect_event(super::multiselect::Event::Choose(
press.selection.context(),
press.target.clone(),
super::multiselect::Choice::Replace,
));
}
let Some(selection) = self.multiselect_snapshot() else {
return Task::none();
};
if !selection.targets().contains(&press.target)
|| (!press.selection.targets().contains(&press.target)
&& selection.targets() != [press.target.clone()])
{
return Task::none();
}
let ids = press
.scope
.leaves
.keys()
.map(|id| (*id, Id::new_v4()))
.collect();
let diagram = press.stamp.context.diagram;
let view = projection::project_view_with_index(
self.project(),
diagram,
self.document.view_index(),
);
if !selection.targets().iter().all(|target| {
super::multiselect::visible(self.project(), diagram, &view, target)
}) {
self.allocation_drag.retire();
self.status = "The view changed. Start this drag again.".into();
return Task::none();
}
let prepared = self.allocation_prepare(&press, &selection);
let (affected, leaf_ids) = match &prepared {
Prepared::Singleton(scope) => (
Some(&scope.placements),
scope.leaves.keys().copied().collect::<Vec<_>>(),
),
Prepared::Assignment(scope) => (
Some(scope.affected()),
scope.leaves().keys().copied().collect(),
),
Prepared::Arrange(plan) => (Some(plan.affected()), Vec::new()),
Prepared::Performers(plan) => (Some(&plan.affected), Vec::new()),
Prepared::Refused(_) => (None, Vec::new()),
};
let frozen = affected.map(|affected| {
let d = &self.project().diagrams[&diagram];
let routed = routing::route_diagram(self.project(), d, &view);
Arc::new(Frozen::capture(
self.project(),
d,
affected,
&view,
&routed,
press.stamp.zoom,
))
});
let ids = leaf_ids.into_iter().map(|id| (id, Id::new_v4())).collect();
self.allocation_drag.arrange_next = false;
self.allocation_drag.preview = None;
self.allocation_drag.feedback = None;
self.allocation_drag.active = Some(Active {
press,
selection,
crossed: false,
prepared,
frozen,
inspection_targets: Vec::new(),
ids,
cache: Vec::new(),
@@ -552,15 +961,16 @@ impl App {
pointer: Option<Point>,
release: bool,
) {
if self
.allocation_drag
.active
.as_ref()
.is_none_or(|a| a.press.token != token)
{
return; // An old packet cannot cancel a newer gesture.
let Some(active) = self.allocation_drag.active.as_ref() else {
return;
};
if active.press.token != token {
return;
}
if !self.allocation_stamp_valid(stamp) {
if !self.allocation_stamp_valid(stamp)
|| !self.allocation_selection_valid(&active.selection)
|| active.press.stamp != stamp
{
self.allocation_drag.retire();
self.status = if self.allocation_drag_clean() {
"The view changed. Start this drag again.".into()
@@ -569,17 +979,35 @@ impl App {
};
return;
}
let Some(mut active) = self.allocation_drag.active.take() else {
return;
};
if active.press.token != token || active.press.stamp != stamp {
self.allocation_drag.active = Some(active);
let mut active = self.allocation_drag.active.take().unwrap();
let press = active.press.clone();
active.crossed |= pointer.is_some_and(|p| p.distance(press.start) > 3.);
let in_bounds = pointer
.is_some_and(|p| p.x.is_finite() && p.y.is_finite() && contains(stamp.bounds, p));
if !active.crossed && in_bounds {
if release {
self.multiselect_event(super::multiselect::Event::Collapse(
active.selection,
press.target.clone(),
));
self.allocation_drag.retire();
self.status = "Element selected".into();
} else {
self.allocation_drag.active = Some(active);
}
return;
}
let press = active.press.clone();
let moved = pointer.is_some_and(|p| p.distance(press.start) > 3.);
let hit = press.frozen.resolve(stamp, pointer, press.arrange);
if moved {
let ordinary_performers = matches!(&active.prepared, Prepared::Performers(_));
let hit = if !in_bounds {
Hit::OutsideViewport
} else if ordinary_performers {
Hit::Destination(plan::Destination::LayoutOnly, None)
} else {
active.frozen.as_ref().map_or(Hit::Occluded, |f| {
f.resolve(stamp, pointer, press.mode == Mode::Arrange)
})
};
if active.crossed {
match &hit {
Hit::Destination(_, Some(target)) => active.inspection_targets = vec![*target],
Hit::Ambiguous(targets) => active.inspection_targets = targets.clone(),
@@ -588,64 +1016,63 @@ impl App {
}
let mut targets = Vec::new();
let mut preview = None;
let result: Result<String, String> = match hit {
Hit::OutsideViewport => Err(if release {
"Work drag canceled outside the canvas."
} else {
"Pointer outside canvas · return to choose a destination; releasing here cancels the drag."
}.into()),
_ if !moved => Ok("Work selected".into()),
let result: Result<String, String> = if matches!(hit, Hit::OutsideViewport) {
Err(if release { "Drag canceled outside the canvas." }
else { "Pointer outside canvas · return to choose a destination; releasing here cancels the drag." }.into())
} else if let Prepared::Refused(reason) = &active.prepared {
Err(reason.clone())
} else {
match hit {
Hit::OutsideViewport => unreachable!(),
Hit::Occluded => Err("Aim at exposed performer space or empty canvas; this card, port, connection or control blocks the drop.".into()),
Hit::Ambiguous(indices) => {
targets = indices;
Err("Two unrelated performers overlap here. Use an exposed region.".into())
targets = indices; Err("Two unrelated performers overlap here. Use an exposed region.".into())
}
Hit::Destination(destination, target) => {
targets.extend(target);
let semantic = if let Some((_, result)) = active.cache.iter().find(|(d, _)| *d == destination) {
result.clone()
} else {
let result = plan::plan_semantics(self.project(), &press.scope, destination, &active.ids).map(Arc::new);
active.cache.push((destination, result.clone()));
result
let candidate = if let Some((_, cached)) = active.cache.iter().find(|(d, _)| *d == destination) { cached.clone() }
else {
let candidate = match &active.prepared {
Prepared::Singleton(scope) => plan::plan_semantics(self.project(), scope, destination, &active.ids)
.map(|p| Candidate::Singleton(Arc::new(p))),
Prepared::Assignment(scope) => plan::assignment::plan_semantics(scope, destination, &active.ids)
.map(|p| Candidate::Assignment(Arc::new(p))),
Prepared::Arrange(plan) => Ok(Candidate::Arrange(plan.clone())),
Prepared::Performers(plan) => Ok(Candidate::Performers(plan.clone())),
Prepared::Refused(_) => unreachable!(),
};
active.cache.push((destination, candidate.clone())); candidate
};
semantic.map_err(|r| refusal(&r)).and_then(|semantic| {
if stamp.zoom * 16. < 9. {
candidate.map_err(|r| refusal(&r)).and_then(|candidate| {
if !ordinary_performers && stamp.zoom * 16. < 9. {
return Err("Make room or adjust the view, then drag again. Zoom in to read the work and destination.".into());
}
let pointer = pointer.unwrap();
let delta = pointer - press.start;
let original = press.scope.placements[&press.scope.placement].position;
let SelectionTarget::Placement(anchor) = &press.target else { unreachable!("supported placement") };
let original = self.project().diagrams[&stamp.context.diagram].nodes[anchor].position;
let delta = pointer.unwrap() - press.start;
let requested = Position { x: original.x + delta.x / stamp.zoom, y: original.y + delta.y / stamp.zoom };
let presentation = plan::PresentationContext {
viewport: stamp.viewport(), zoom: stamp.zoom,
target: target.map(|i| {
let t = &press.frozen.targets[i];
plan::TargetPresentation { placement: t.placement }
}),
};
let placement = plan::place(&semantic, requested, presentation).map_err(|r| refusal(&r))?;
let label = if semantic.warnings.is_empty() { semantic.label.clone() } else {
format!("{} · {} model warning(s) retained", semantic.label, semantic.warnings.len())
target: target.map(|i| plan::TargetPresentation { placement: active.frozen.as_ref().unwrap().targets[i].placement }),
};
let placed = candidate.place(requested, presentation).map_err(|r| refusal(&r))?;
let label = candidate.label();
if release {
let command = plan::finish(&semantic, self.project(), &placement).map_err(|r| refusal(&r))?;
if let Some(command) = command {
// Bypass applying selectors and unrelated repair helpers. The
// planner's exact Batch enters the authoritative source pipeline.
self.document.execute(command).map_err(|e| format!("{} · retry this drag after correcting the source issue", e))?;
if let Some(command) = candidate.finish(self.project(), &placed).map_err(|r| refusal(&r))? {
self.document.execute(command)
.map_err(|e| format!("{e} · retry this drag after correcting the source issue"))?;
// sync refreshes the retained primary once; no anchor promotion.
self.sync();
self.selected = press.scope.work;
self.selected_node = Some(press.scope.placement);
self.refresh_draft();
self.reconcile_multiselect();
}
} else {
let routed = Arc::new(routing::route_diagram(semantic.project(), &placement.diagram, &placement.view));
preview = Some(Arc::new(Preview { token, semantic, placement, routed }));
let routed = Arc::new(routing::route_diagram(candidate.project(), placed.diagram(), placed.view()));
preview = Some(Arc::new(Preview { token, candidate, placement: placed.into_preview(), routed }));
}
Ok(label)
})
}
}
};
let refused = result.is_err();
let text = result.unwrap_or_else(|text| text);
@@ -654,10 +1081,13 @@ impl App {
self.allocation_drag.retire();
} else {
let inspection_targets = active.inspection_targets.clone();
let frozen = active.frozen.clone();
self.allocation_drag.active = Some(active);
self.allocation_drag.preview = preview;
self.allocation_drag.feedback = Some(Feedback {
press,
frozen,
ordinary_performers,
targets,
inspection_targets,
text,
@@ -4,6 +4,9 @@ use super::*;
use crate::behavior::canvas_workspace::tests::{Mounted, key};
use iced::{Event as Input, keyboard};
#[path = "allocation_drag_tests/control_focus.rs"]
mod control_focus;
fn character(text: &str, modifiers: keyboard::Modifiers) -> Input {
Input::Keyboard(keyboard::Event::KeyPressed {
key: keyboard::Key::Character(text.into()),
@@ -211,7 +214,7 @@ async fn actual_keyboard_arrange_shortcut_consumes_once_escape_cancels_and_dirty
assert!(
messages
.iter()
.any(|m| matches!(m, Message::WorkDrag(Event::Press(p)) if p.arrange))
.any(|m| matches!(m, Message::WorkDrag(Event::Press(p)) if p.mode == Mode::Arrange))
);
assert!(!f.app.allocation_drag.arrange_next);
assert_eq!(f.app.document.revision(), 1, "{}", f.app.status);
@@ -282,7 +285,7 @@ async fn actual_rendered_canvas_pixels_do_not_change_when_toolbar_feedback_text_
.as_ref()
.expect("active frozen destination");
assert!(!feedback.refused, "{}", feedback.text);
let target = &feedback.press.frozen.targets[feedback.targets[0]];
let target = &feedback.frozen.as_ref().unwrap().targets[feedback.targets[0]];
assert!(target.region.y * f.app.zoom + f.app.pan.y < 0.);
assert_eq!(feedback.destination_identity(), "A");
let canvas = canvas_bounds(&mut mounted, &f.app);
@@ -434,7 +437,7 @@ async fn actual_app_tree_exact_incoming_drag_keeps_destination_visible_and_canva
selected_header.x + selected_header.width <= canvas.x + canvas.width
&& selected_header.y + selected_header.height <= canvas.y + canvas.height
);
let mut expected = preview.semantic.project().clone();
let mut expected = preview.project().clone();
expected
.diagrams
.insert(f.diagram, preview.placement.diagram.clone());
@@ -689,7 +692,7 @@ async fn actual_toolbar_hover_inspects_last_full_name_without_authorizing_an_out
.await;
assert!(f.app.allocation_drag.active.is_none());
assert!(f.app.allocation_drag.feedback.is_none());
assert_eq!(f.app.status, "Work drag canceled outside the canvas.");
assert_eq!(f.app.status, "Drag canceled outside the canvas.");
assert_eq!(f.app.document.bundle(), &before);
assert!(!f.app.document.can_undo());
// A fresh gesture cannot inherit the previous inspection name.
@@ -8,6 +8,9 @@ use iced::{
#[path = "allocation_drag_full_tree_tests.rs"]
mod full_tree;
#[path = "allocation_drag_tests/group.rs"]
mod group;
#[path = "allocation_drag_tests/navigation_union.rs"]
mod navigation_union;
@@ -48,6 +51,7 @@ fn fixture(layer: Layer) -> Fixture {
app.zoom = 1.;
app.pan = Vector::ZERO;
app.fit_to_view = false;
app.reconcile_multiselect();
Fixture {
app,
a,
@@ -59,6 +63,7 @@ fn fixture(layer: Layer) -> Fixture {
}
fn canvas(app: &App) -> DiagramCanvas<'_> {
DiagramCanvas {
selection: app.diagram_selection.as_ref(),
allocation: Some(app.allocation_capability()),
project: app.project(),
view_index: Some(app.document.view_index()),
@@ -121,6 +126,18 @@ fn press(f: &Fixture) -> Arc<Press> {
})
.expect("actual clean work-body press")
}
fn admit_press(f: &mut Fixture) -> Arc<Press> {
let p = press(f);
let _ = f.app.update(Message::WorkDrag(Event::Press(p.clone())));
assert!(f.app.allocation_drag.active.is_some(), "{}", f.app.status);
p
}
fn singleton(candidate: &Candidate) -> &Arc<plan::SemanticPlan> {
let Candidate::Singleton(plan) = candidate else {
panic!("legacy singleton candidate")
};
plan
}
fn drag(f: &mut Fixture, to: Point) {
let events = batch(&f.app, start(f), to);
assert!(matches!(
@@ -358,7 +375,7 @@ fn arrange_is_one_gesture_and_snapped_noop_preserves_redo_and_source() {
assert!(f.app.allocation_drag.arrange_next);
let p = press(&f);
let _ = f.app.update(Message::WorkDrag(Event::Press(p.clone())));
assert!(p.arrange);
assert_eq!(p.mode, Mode::Arrange);
assert!(!f.app.allocation_drag.arrange_next);
let _ = f.app.update(Message::WorkDrag(Event::Release(
p.stamp,
@@ -369,7 +386,7 @@ fn arrange_is_one_gesture_and_snapped_noop_preserves_redo_and_source() {
assert_eq!(f.app.document.revision(), revision);
assert!(f.app.document.can_redo());
assert!(f.app.project().relations.is_empty());
assert!(!press(&f).arrange);
assert_eq!(press(&f).mode, Mode::Assign);
}
#[test]
@@ -396,14 +413,15 @@ fn candidate_semantics_are_cached_per_destination_with_stable_ids_and_own_index(
let _ = f
.app
.update(Message::WorkDrag(Event::Preview(p.stamp, p.token, Some(a))));
let first = f
.app
.allocation_drag
.preview
.as_ref()
.expect(&f.app.status)
.semantic
.clone();
let first = singleton(
&f.app
.allocation_drag
.preview
.as_ref()
.expect(&f.app.status)
.candidate,
)
.clone();
let stable = first
.project()
.relations
@@ -418,20 +436,20 @@ fn candidate_semantics_are_cached_per_destination_with_stable_ids_and_own_index(
)));
assert!(Arc::ptr_eq(
&first,
&f.app.allocation_drag.preview.as_ref().unwrap().semantic
singleton(&f.app.allocation_drag.preview.as_ref().unwrap().candidate)
));
let _ = f
.app
.update(Message::WorkDrag(Event::Preview(p.stamp, p.token, Some(b))));
let preview = f.app.allocation_drag.preview.as_ref().expect(&f.app.status);
assert!(!Arc::ptr_eq(&first, &preview.semantic));
assert_eq!(preview.semantic.project().relations[&stable].source(), f.b);
assert!(!Arc::ptr_eq(&first, singleton(&preview.candidate)));
assert_eq!(preview.project().relations[&stable].source(), f.b);
assert_eq!(
preview.placement.view,
projection::project_diagram_with_index(
preview.semantic.project(),
preview.project(),
&preview.placement.diagram,
preview.semantic.index()
preview.index()
)
);
assert_ne!(
@@ -447,7 +465,7 @@ fn candidate_semantics_are_cached_per_destination_with_stable_ids_and_own_index(
.update(Message::WorkDrag(Event::Preview(p.stamp, p.token, Some(a))));
assert!(Arc::ptr_eq(
&first,
&f.app.allocation_drag.preview.as_ref().unwrap().semantic
singleton(&f.app.allocation_drag.preview.as_ref().unwrap().candidate)
));
assert_eq!(
f.app.allocation_drag.active.as_ref().unwrap().cache.len(),
@@ -457,9 +475,19 @@ fn candidate_semantics_are_cached_per_destination_with_stable_ids_and_own_index(
#[test]
fn frozen_targets_use_half_open_semantic_priority_and_refuse_incomparable_overlap() {
let f = fixture(Layer::Oa);
let p = press(&f);
let mut frozen = p.frozen.clone();
let mut f = fixture(Layer::Oa);
let p = admit_press(&mut f);
let mut frozen = f
.app
.allocation_drag
.active
.as_ref()
.unwrap()
.frozen
.as_ref()
.unwrap()
.as_ref()
.clone();
let ai = frozen
.targets
.iter()
@@ -539,6 +567,7 @@ fn release_uses_ordered_native_position_before_later_motion_in_same_batch() {
fn replace(f: &mut Fixture, project: Project) {
f.app.document = crate::document::Document::from_project(project, Default::default()).unwrap();
f.app.sync();
f.app.reconcile_multiselect();
}
#[test]
@@ -587,7 +616,7 @@ fn same_snapped_position_on_opposite_shared_border_rebuilds_semantics_even_if_la
.1
.as_ref()
.unwrap();
assert!(!Arc::ptr_eq(a, b));
assert!(!Arc::ptr_eq(singleton(a), singleton(b)));
let stable = a
.project()
.relations
@@ -654,13 +683,24 @@ fn mounted_role_only_outgoing_refusal_retains_placement_and_same_performer_creat
"{}",
f.app.status
);
let target = press(&f)
let admitted = admit_press(&mut f);
let target = f
.app
.allocation_drag
.active
.as_ref()
.unwrap()
.frozen
.as_ref()
.unwrap()
.targets
.iter()
.find(|t| t.element == f.a)
.unwrap()
.region;
let _ = f
.app
.update(Message::WorkDrag(Event::Cancel(admitted.token)));
drag(&mut f, screen(xy(target.x + 45., target.y + 100.)));
assert_eq!(f.app.document.revision(), 1, "{}", f.app.status);
assert_eq!(f.app.project().relations, relations);
@@ -773,8 +813,18 @@ fn frozen_collapsed_target_uses_only_visible_header_and_port_edge_controls_occlu
.unwrap()
.collapsed = true;
replace(&mut f, project);
let p = press(&f);
let mut frozen = p.frozen.clone();
let p = admit_press(&mut f);
let mut frozen = f
.app
.allocation_drag
.active
.as_ref()
.unwrap()
.frozen
.as_ref()
.unwrap()
.as_ref()
.clone();
let t = frozen.targets.iter().find(|t| t.element == f.a).unwrap();
let bottom = xy(t.region.x + 30., t.region.y + t.region.height);
assert!(t.region.height <= 62.);
@@ -944,7 +994,7 @@ fn toolbar_destination_names_transformed_target_whose_header_is_clipped() {
send(&mut f.app, events);
let feedback = f.app.allocation_drag.feedback.as_ref().unwrap();
assert!(!feedback.refused, "{}", feedback.text);
let target = &feedback.press.frozen.targets[feedback.targets[0]];
let target = &feedback.frozen.as_ref().unwrap().targets[feedback.targets[0]];
assert!(screen(xy(target.region.x, target.region.y)).y < bounds().y);
assert_eq!(feedback.destination_identity(), "A");
assert!(f.app.allocation_drag.preview.is_some());
@@ -1003,7 +1053,7 @@ fn oa_sa_clipped_selected_header_refuses_preview_release_and_retries_without_par
assert!(feedback.refused, "{}", feedback.text);
assert!(feedback.text.contains("Make room or adjust the view"));
assert_eq!(
feedback.press.frozen.targets[feedback.targets[0]].element,
feedback.frozen.as_ref().unwrap().targets[feedback.targets[0]].element,
f.a
);
assert_eq!(f.app.project(), &accepted);
@@ -1028,14 +1078,14 @@ fn oa_sa_clipped_selected_header_refuses_preview_release_and_retries_without_par
send(&mut f.app, events);
let active = f.app.allocation_drag.active.as_ref().unwrap();
let p = active.press.clone();
let semantic = active.cache[0].1.as_ref().unwrap().clone();
let semantic = singleton(active.cache[0].1.as_ref().unwrap()).clone();
let _ = f.app.update(Message::WorkDrag(Event::Preview(
p.stamp,
p.token,
Some(clear),
)));
let preview = f.app.allocation_drag.preview.as_ref().expect(&f.app.status);
assert!(Arc::ptr_eq(&semantic, &preview.semantic));
assert!(Arc::ptr_eq(&semantic, singleton(&preview.candidate)));
assert_eq!(
preview.placement.diagram.nodes[&f.node].position,
xy(120., 160.)
@@ -1131,7 +1181,7 @@ fn oa_sa_toolbar_identity_accepts_exact_receiver_and_collapsed_header_drag() {
assert!(!feedback.refused, "{}", feedback.text);
assert_eq!(feedback.destination_identity(), name);
assert_eq!(
feedback.press.frozen.targets[feedback.targets[0]].placement,
feedback.frozen.as_ref().unwrap().targets[feedback.targets[0]].placement,
target_id
);
let preview = f.app.allocation_drag.preview.as_ref().expect(&f.app.status);
@@ -1143,12 +1193,12 @@ fn oa_sa_toolbar_identity_accepts_exact_receiver_and_collapsed_header_drag() {
assert_eq!(
preview.placement.view,
projection::project_diagram_with_index(
preview.semantic.project(),
preview.project(),
&preview.placement.diagram,
preview.semantic.index()
preview.index()
)
);
let mut expected = preview.semantic.project().clone();
let mut expected = preview.project().clone();
expected
.diagrams
.insert(f.diagram, preview.placement.diagram.clone());
@@ -1183,6 +1233,8 @@ fn destination_identity_does_not_describe_blocked_or_outside_viewport_as_unalloc
let p = press(&f);
let mut feedback = Feedback {
press: p,
frozen: None,
ordinary_performers: false,
targets: vec![],
inspection_targets: vec![],
text: "A card blocks this drop".into(),
@@ -0,0 +1,111 @@
use super::*;
#[tokio::test]
async fn dirty_selected_pin_outer_ring_keeps_real_name_focus_and_caret() {
let mut f = fixture(Layer::Oa);
let mut p = f.app.project().clone();
let peer = p
.add_element(Kind::Activity, Layer::Oa, "Peer", None)
.unwrap();
p.place(f.diagram, peer, xy(700., 500.));
let edge = p.relate(RelationKind::Interaction, "Flow", f.work, peer);
let pin = xy(450., 547.);
p.diagrams
.get_mut(&f.diagram)
.unwrap()
.routes
.push(EdgeRoute {
edge,
source_attachment: Some(EdgeAttachment {
side: PortAnchorSide::Right,
offset: 0.5,
}),
target_attachment: Some(EdgeAttachment {
side: PortAnchorSide::Left,
offset: 0.5,
}),
waypoints: vec![pin],
label: Some(EdgeLabel {
fraction: 0.8,
offset: xy(0., -60.),
}),
});
validate_candidate(&p).unwrap();
let view = projection::project_view(&p, f.diagram);
let routed = routing::route_diagram(&p, &p.diagrams[&f.diagram], &view);
assert_eq!(routed.edges[&edge].pins, vec![pin]);
assert!(!routed.edges[&edge].custom_suspended);
assert!(
routed.edges[&edge]
.points
.iter()
.all(|point| point.y == 547.)
);
f.app = App::with_project(p);
f.app.active_diagram = Some(f.diagram);
f.app.window_size = iced::Size::new(2000., 1400.);
let _ = f.app.update(Message::Select(edge));
f.app.reconcile_multiselect();
let mut mounted = Mounted::new(&mut f.app).await;
f.app.zoom = 1.;
f.app.pan = Vector::ZERO;
f.app.fit_to_view = false;
mounted.settle(&mut f.app).await;
let field = name_field(&mut mounted, &f.app);
mounted.click(&mut f.app, field.center()).await;
mounted
.batch(
&mut f.app,
&[key(keyboard::key::Named::Home)],
mouse::Cursor::Unavailable,
)
.await;
type_key(&mut mounted, &mut f.app, "X").await;
mounted
.batch(
&mut f.app,
&[key(keyboard::key::Named::ArrowLeft)],
mouse::Cursor::Unavailable,
)
.await;
assert_eq!(f.app.draft.as_ref().unwrap().inputs["/name"].raw, "XFlow");
assert!(f.app.draft_dirty());
let accepted = f.app.document.snapshot();
let source = f.app.document.bundle().clone();
let selection = f.app.multiselect_snapshot().unwrap();
let pan = f.app.pan;
// Exposed straight-run bend: outside the 7px line hit, within the 8px handle.
let point = at(&mut mounted, &f, xy(pin.x, pin.y + 7.5));
let messages = mounted.click(&mut f.app, point).await;
assert!(
messages
.iter()
.any(|m| matches!(m, Message::WorkDrag(Event::Refused(..))))
);
assert!(!messages.iter().any(|m| matches!(
m,
Message::Pan(_)
| Message::Select(_)
| Message::SelectNode(_)
| Message::MoveNode(..)
| Message::Route(_)
| Message::DiagramSelection(_)
)));
assert!(Arc::ptr_eq(&accepted, &f.app.document.snapshot()));
assert_eq!(f.app.document.bundle(), &source);
assert_eq!(f.app.multiselect_snapshot().unwrap(), selection);
assert_eq!(f.app.pan, pan);
assert!(
is_focused(&mut mounted, &f.app, field),
"dirty handle press must stop before outside-press unfocus"
);
let messages = type_key(&mut mounted, &mut f.app, "Y").await;
assert!(
messages
.iter()
.any(|m| matches!(m, Message::Field(path, _) if path == "/name"))
);
assert_eq!(f.app.draft.as_ref().unwrap().inputs["/name"].raw, "YXFlow");
assert_eq!(f.app.document.bundle(), &source);
assert!(!f.app.document.can_undo());
}
@@ -0,0 +1,744 @@
use super::*;
use crate::app::multiselect::{self, Choice};
use crate::diagram_usage::Target as SelectionTarget;
#[path = "group/connection_lifetime.rs"]
mod connection_lifetime;
#[path = "group/route_lifetime.rs"]
mod route_lifetime;
struct Group {
f: Fixture,
y: Id,
yn: Id,
c: Id,
cn: Id,
an: Id,
ax: Id,
by: Id,
}
fn group(layer: Layer) -> Group {
let mut f = fixture(layer);
let mut p = f.app.project().clone();
let y = p
.add_element(p.elements[&f.work].kind(), layer, "Y", None)
.unwrap();
let c = p
.add_element(
if layer == Layer::Oa {
Kind::Actor
} else {
Kind::SystemActor
},
layer,
"C",
None,
)
.unwrap();
let yn = p.place(f.diagram, y, xy(140., 600.));
let cn = p.place(f.diagram, c, xy(1500., 100.));
let d = p.diagrams.get_mut(&f.diagram).unwrap();
d.nodes.get_mut(&f.node).unwrap().position = xy(140., 200.);
d.nodes
.values_mut()
.find(|n| n.element == f.b)
.unwrap()
.position = xy(100., 500.);
d.nodes.get_mut(&cn).unwrap().size = Size {
width: 600.,
height: 900.,
};
let an = d.nodes.values().find(|n| n.element == f.a).unwrap().id;
let ax = p.relate(
RelationKind::Allocation,
"Reviewed X responsibility",
f.a,
f.work,
);
let by = p.relate(
RelationKind::Allocation,
"Reviewed Y responsibility",
f.b,
y,
);
p.relations.get_mut(&ax).unwrap().description = "Keep X provenance".into();
p.relations.get_mut(&by).unwrap().description = "Keep Y provenance".into();
validate_candidate(&p).unwrap();
f.app = App::with_project(p);
f.app.active_diagram = Some(f.diagram);
f.app.zoom = 1.;
f.app.pan = Vector::ZERO;
f.app.fit_to_view = false;
f.app.reconcile_multiselect();
Group {
f,
y,
yn,
c,
cn,
an,
ax,
by,
}
}
fn choose(app: &mut App, target: SelectionTarget, choice: Choice) {
let context = multiselect::context(app.route_context().unwrap());
let _ = app.update(Message::DiagramSelection(multiselect::Event::Choose(
context, target, choice,
)));
}
fn select(app: &mut App, ids: &[Id]) {
for (i, id) in ids.iter().enumerate() {
choose(
app,
SelectionTarget::Placement(*id),
if i == 0 { Choice::Replace } else { Choice::Add },
);
}
assert_eq!(
app.diagram_selection.as_ref().unwrap().targets(),
&ids.iter()
.copied()
.map(SelectionTarget::Placement)
.collect::<Vec<_>>()
);
}
fn selection(
app: &App,
) -> (
Vec<SelectionTarget>,
Option<SelectionTarget>,
Id,
Option<Id>,
) {
let s = app.diagram_selection.as_ref().unwrap();
(
s.targets().to_vec(),
s.primary().cloned(),
app.selected,
app.selected_node,
)
}
fn node_point(app: &App, node: Id) -> Point {
let view = projection::project_view(app.project(), app.active_diagram.unwrap());
let n = view.nodes.iter().find(|n| n.id == node).unwrap();
screen(xy(n.position.x + 40., n.position.y + 35.))
}
fn input(app: &mut App, state: &mut CanvasState, event: mouse::Event, at: Point) {
if let Some(message) = pointer(&canvas(app), state, event, at) {
let _ = app.update(message);
}
}
fn begin(app: &mut App, state: &mut CanvasState, from: Point) -> Arc<Press> {
input(
app,
state,
mouse::Event::CursorMoved { position: from },
from,
);
let message = pointer(
&canvas(app),
state,
mouse::Event::ButtonPressed(mouse::Button::Left),
from,
)
.unwrap();
let Message::WorkDrag(Event::Press(packet)) = &message else {
panic!("Allocation placement must enter its captured lifetime: {message:?}");
};
let packet = packet.clone();
let _ = app.update(message);
packet
}
fn move_pointer(app: &mut App, state: &mut CanvasState, to: Point) {
input(app, state, mouse::Event::CursorMoved { position: to }, to);
}
fn release(app: &mut App, state: &mut CanvasState, to: Point) {
input(
app,
state,
mouse::Event::ButtonReleased(mouse::Button::Left),
to,
);
}
fn exact_history(
app: &mut App,
before: &Project,
source: &oa_core::source_project::SourceBundle,
expected: &Project,
) {
assert_eq!(app.project(), expected);
let after = app.document.bundle().clone();
let reopened =
crate::document::Document::from_bundle(after.clone(), Default::default()).unwrap();
assert_eq!(reopened.project(), expected);
let _ = app.update(Message::Undo);
assert_eq!(app.project(), before);
assert_eq!(app.document.bundle(), source);
assert!(
!app.document.can_undo(),
"one group drag is one history step"
);
let _ = app.update(Message::Redo);
assert_eq!(app.project(), expected);
assert_eq!(app.document.bundle(), &after);
assert!(!app.document.can_redo());
}
#[test]
fn group_assignment_split_rebuild_preserves_primary_and_commits_one_complete_candidate() {
for layer in [Layer::Oa, Layer::Sa] {
for anchor_is_primary in [false, true] {
let mut g = group(layer);
select(&mut g.f.app, &[g.f.node, g.yn]);
let held = selection(&g.f.app);
let before = g.f.app.project().clone();
let source = g.f.app.document.bundle().clone();
let anchor = if anchor_is_primary { g.yn } else { g.f.node };
let from = node_point(&g.f.app, anchor);
let to = from + Vector::new(1400., 0.);
let mut state = CanvasState::default();
let packet = begin(&mut g.f.app, &mut state, from);
assert_eq!(packet.target, SelectionTarget::Placement(anchor));
assert_eq!(
selection(&g.f.app),
held,
"member press must not inspect/collapse"
);
assert_eq!(g.f.app.document.bundle(), &source);
move_pointer(&mut g.f.app, &mut state, to);
let preview =
g.f.app
.allocation_drag
.preview
.as_ref()
.expect(&g.f.app.status);
let Candidate::Assignment(plan) = &preview.candidate else {
panic!("group must use combined assignment");
};
assert_eq!(plan.changed_count(), 2);
assert_eq!(plan.already_correct_count(), 0);
assert_eq!(selection(&g.f.app), held);
assert_eq!(g.f.app.project(), &before, "preview is immutable");
release(&mut g.f.app, &mut state, to);
let mut expected = before.clone();
for (relation, work) in [(g.ax, g.f.work), (g.by, g.y)] {
expected.relations.get_mut(&relation).unwrap().body = RelationBody::Allocation {
source: g.c,
target: work,
};
}
let d = expected.diagrams.get_mut(&g.f.diagram).unwrap();
d.nodes.get_mut(&g.f.node).unwrap().position = xy(1540., 200.);
d.nodes.get_mut(&g.yn).unwrap().position = xy(1540., 600.);
assert_eq!(selection(&g.f.app), held);
exact_history(&mut g.f.app, &before, &source, &expected);
}
}
}
#[test]
fn group_arrange_work_and_performer_anchors_preserve_every_responsibility() {
for layer in [Layer::Oa, Layer::Sa] {
for performer_anchor in [false, true] {
let mut g = group(layer);
select(&mut g.f.app, &[g.an, g.yn]);
let held = selection(&g.f.app);
let before = g.f.app.project().clone();
let source = g.f.app.document.bundle().clone();
let _ = g.f.app.update(Message::WorkDrag(Event::ArrangeNext));
assert!(g.f.app.allocation_drag.arrange_next);
let from = node_point(&g.f.app, if performer_anchor { g.an } else { g.yn });
let to = from + Vector::new(400., 0.);
let mut state = CanvasState::default();
let packet = begin(&mut g.f.app, &mut state, from);
assert_eq!(packet.mode, Mode::Arrange);
assert!(!g.f.app.allocation_drag.arrange_next);
assert_eq!(selection(&g.f.app), held);
move_pointer(&mut g.f.app, &mut state, to);
assert!(
matches!(
g.f.app
.allocation_drag
.preview
.as_ref()
.map(|p| &p.candidate),
Some(Candidate::Arrange(_))
),
"{}",
g.f.app.status
);
release(&mut g.f.app, &mut state, to);
let mut expected = before.clone();
let d = expected.diagrams.get_mut(&g.f.diagram).unwrap();
d.nodes.get_mut(&g.an).unwrap().position = xy(500., 100.);
d.nodes.get_mut(&g.f.node).unwrap().position = xy(540., 200.);
d.nodes.get_mut(&g.yn).unwrap().position = xy(540., 600.);
assert_eq!(selection(&g.f.app), held);
exact_history(&mut g.f.app, &before, &source, &expected);
}
}
}
#[test]
fn unsupported_group_click_collapses_but_threshold_crossing_never_becomes_click() {
for performer_anchor in [false, true] {
for distance in [0., 3., 3.25] {
let mut g = group(Layer::Oa);
select(&mut g.f.app, &[g.an, g.f.node]);
let held = selection(&g.f.app);
let before = g.f.app.document.snapshot();
let anchor = if performer_anchor { g.an } else { g.f.node };
let from = node_point(&g.f.app, anchor);
let mut state = CanvasState::default();
begin(&mut g.f.app, &mut state, from);
assert_eq!(selection(&g.f.app), held);
move_pointer(&mut g.f.app, &mut state, from + Vector::new(distance, 0.));
if distance > 3. {
assert!(
g.f.app
.allocation_drag
.feedback
.as_ref()
.is_some_and(|f| f.refused)
);
assert!(g.f.app.allocation_drag.preview.is_none());
}
move_pointer(&mut g.f.app, &mut state, from);
release(&mut g.f.app, &mut state, from);
assert!(g.f.app.allocation_drag.active.is_none());
assert!(Arc::ptr_eq(&before, &g.f.app.document.snapshot()));
if distance > 3. {
assert_eq!(
selection(&g.f.app),
held,
"crossed-and-returned refusal must retain group"
);
} else {
let s = g.f.app.diagram_selection.as_ref().unwrap();
assert_eq!(s.targets(), &[SelectionTarget::Placement(anchor)]);
assert_eq!(s.primary(), Some(&SelectionTarget::Placement(anchor)));
}
}
}
}
#[test]
fn allocation_bare_moves_cannot_bypass_armed_active_or_retired_lifetime() {
for (stage, singleton) in (0..3).flat_map(|stage| [(stage, false), (stage, true)]) {
let mut g = group(Layer::Oa);
let ids = if singleton {
vec![g.an]
} else {
vec![g.an, g.cn]
};
select(&mut g.f.app, &ids);
let snapshot = g.f.app.multiselect_snapshot().unwrap();
let held = selection(&g.f.app);
let before = g.f.app.document.snapshot();
let _ = g.f.app.update(Message::WorkDrag(Event::ArrangeNext));
let mut state = CanvasState::default();
let from = node_point(&g.f.app, g.an);
if stage > 0 {
let press = begin(&mut g.f.app, &mut state, from);
if stage == 2 {
let _ =
g.f.app
.update(Message::WorkDrag(Event::Cancel(press.token)));
}
}
let epoch = g.f.app.allocation_drag.epoch;
let armed = g.f.app.allocation_drag.arrange_next;
let token =
g.f.app
.allocation_drag
.active
.as_ref()
.map(|a| a.press.token);
let _ = g.f.app.update(Message::MoveNode(g.an, xy(500., 100.)));
let _ =
g.f.app
.update(Message::DiagramSelection(multiselect::Event::Move(
snapshot,
xy(400., 0.),
)));
assert!(Arc::ptr_eq(&before, &g.f.app.document.snapshot()));
assert_eq!(selection(&g.f.app), held);
assert_eq!(g.f.app.allocation_drag.epoch, epoch);
assert_eq!(g.f.app.allocation_drag.arrange_next, armed);
assert_eq!(
g.f.app
.allocation_drag
.active
.as_ref()
.map(|a| a.press.token),
token
);
}
// A fresh ordinary performer gesture still uses the existing layout policy.
// C's right edge leaves the viewport: explicit Arrange's header gate must not
// accidentally become a new restriction on ordinary performer movement.
let mut g = group(Layer::Oa);
select(&mut g.f.app, &[g.an, g.cn]);
let held = selection(&g.f.app);
let before = g.f.app.project().clone();
let source = g.f.app.document.bundle().clone();
let _ = g.f.app.update(Message::WorkDrag(Event::ArrangeNext));
let from = node_point(&g.f.app, g.an);
let mut state = CanvasState::default();
let old = begin(&mut g.f.app, &mut state, from);
let _ = g.f.app.update(Message::WorkDrag(Event::Cancel(old.token)));
let mut state = CanvasState::default();
let fresh = begin(&mut g.f.app, &mut state, from);
assert_eq!(fresh.mode, Mode::Assign);
let to = from + Vector::new(300., 0.);
move_pointer(&mut g.f.app, &mut state, to);
assert!(
matches!(
g.f.app
.allocation_drag
.preview
.as_ref()
.map(|p| &p.candidate),
Some(Candidate::Performers(_))
),
"{}",
g.f.app.status
);
release(&mut g.f.app, &mut state, to);
let mut expected = before.clone();
let d = expected.diagrams.get_mut(&g.f.diagram).unwrap();
d.nodes.get_mut(&g.an).unwrap().position = xy(400., 100.);
d.nodes.get_mut(&g.f.node).unwrap().position = xy(440., 200.);
d.nodes.get_mut(&g.cn).unwrap().position = xy(1800., 100.);
assert_eq!(selection(&g.f.app), held);
exact_history(&mut g.f.app, &before, &source, &expected);
}
#[test]
fn idle_group_press_rejects_selection_generation_aba_with_unchanged_other_stamps() {
for membership_aba in [false, true] {
let mut g = group(Layer::Oa);
select(&mut g.f.app, &[g.f.node, g.yn]);
let old = press(&g.f);
let before = g.f.app.document.snapshot();
let held = selection(&g.f.app);
let epoch = g.f.app.allocation_drag.epoch;
// Deliberately isolate selection generation from App message retirement.
// Final selection/primary/context/Document values are unchanged; this
// exercises the snapshot boundary, not an ordinary user-input sequence.
let s = g.f.app.diagram_selection.as_mut().unwrap();
if membership_aba {
assert!(s.remove(&SelectionTarget::Placement(g.yn)));
assert!(s.add(SelectionTarget::Placement(g.yn), |_| true));
} else {
assert!(s.set_primary(&SelectionTarget::Placement(g.f.node)));
assert!(s.set_primary(&SelectionTarget::Placement(g.yn)));
}
assert_eq!(selection(&g.f.app), held);
assert_eq!(g.f.app.allocation_drag.epoch, epoch);
assert_eq!(g.f.app.route_context(), Some(old.stamp.context));
let _ = g.f.app.update(Message::WorkDrag(Event::Press(old.clone())));
assert!(
g.f.app.allocation_drag.active.is_none(),
"generation-only stale selection must not acquire input"
);
let _ = g.f.app.update(Message::WorkDrag(Event::Release(
old.stamp,
old.token,
Some(start(&g.f)),
)));
assert!(Arc::ptr_eq(&before, &g.f.app.document.snapshot()));
assert_eq!(selection(&g.f.app), held);
}
}
#[test]
fn group_selection_aba_and_old_press_cannot_retire_a_new_token() {
let mut g = group(Layer::Oa);
select(&mut g.f.app, &[g.f.node, g.yn]);
let old = press(&g.f);
choose(
&mut g.f.app,
SelectionTarget::Placement(g.f.node),
Choice::Inspect,
);
choose(
&mut g.f.app,
SelectionTarget::Placement(g.yn),
Choice::Inspect,
);
let before = g.f.app.document.snapshot();
let from = node_point(&g.f.app, g.f.node);
let mut state = CanvasState::default();
let fresh = begin(&mut g.f.app, &mut state, from);
let held = selection(&g.f.app);
let epoch = g.f.app.allocation_drag.epoch;
let _ = g.f.app.update(Message::WorkDrag(Event::Press(old.clone())));
let _ = g.f.app.update(Message::WorkDrag(Event::Release(
old.stamp,
old.token,
Some(from + Vector::new(1400., 0.)),
)));
assert_eq!(
g.f.app
.allocation_drag
.active
.as_ref()
.map(|a| a.press.token),
Some(fresh.token)
);
assert_eq!(g.f.app.allocation_drag.epoch, epoch);
assert_eq!(selection(&g.f.app), held);
assert!(Arc::ptr_eq(&before, &g.f.app.document.snapshot()));
release(&mut g.f.app, &mut state, from);
}
fn unassign(g: &mut Group, inside_c: bool) {
let mut project = g.f.app.project().clone();
project.relations.remove(&g.ax);
project.relations.remove(&g.by);
if inside_c {
let d = project.diagrams.get_mut(&g.f.diagram).unwrap();
// These unassigned cards overlap an unrelated empty performer at the
// same depth. Paint C first so the intended work press is visible and
// independent of random UUID ordering; no semantic containment exists.
let mut target = d.nodes.remove(&g.cn).unwrap();
g.cn = Id::from_u128(1);
target.id = g.cn;
assert!(d.nodes.insert(g.cn, target).is_none());
d.nodes.get_mut(&g.f.node).unwrap().position = xy(1540., 200.);
d.nodes.get_mut(&g.yn).unwrap().position = xy(1540., 600.);
}
g.f.app = App::with_project(project);
g.f.app.active_diagram = Some(g.f.diagram);
g.f.app.zoom = 1.;
g.f.app.pan = Vector::ZERO;
g.f.app.fit_to_view = false;
g.f.app.reconcile_multiselect();
}
fn c_allocations(expected: &mut Project, g: &Group, ids: &BTreeMap<Id, Id>) {
for (work, name, alias) in [
(g.f.work, "C performs Work", "c_performs_work"),
(g.y, "C performs Y", "c_performs_y"),
] {
expected.relations.insert(
ids[&work],
Relation {
id: ids[&work],
alias: alias.into(),
name: name.into(),
description: String::new(),
body: RelationBody::Allocation {
source: g.c,
target: work,
},
},
);
}
}
#[test]
fn group_out_and_back_assigns_at_zero_delta_then_true_noop_preserves_redo() {
for layer in [Layer::Oa, Layer::Sa] {
let mut g = group(layer);
unassign(&mut g, true);
select(&mut g.f.app, &[g.f.node, g.yn]);
let held = selection(&g.f.app);
let before = g.f.app.project().clone();
let source = g.f.app.document.bundle().clone();
let from = node_point(&g.f.app, g.f.node);
let mut state = CanvasState::default();
let press = begin(&mut g.f.app, &mut state, from);
assert_eq!(press.target, SelectionTarget::Placement(g.f.node));
assert_eq!(selection(&g.f.app), held);
move_pointer(&mut g.f.app, &mut state, from + Vector::new(4., 0.));
let preview =
g.f.app
.allocation_drag
.preview
.as_ref()
.expect(&g.f.app.status);
assert_eq!(preview.placement.delta, xy(0., 0.));
let Candidate::Assignment(candidate) = &preview.candidate else {
panic!("exact work group must preview assignment");
};
assert_eq!(candidate.destination(), plan::Destination::Performer(g.c));
assert_eq!(candidate.changed_count(), 2);
let ids: BTreeMap<_, _> = preview
.project()
.relations
.values()
.filter(|r| r.kind() == RelationKind::Allocation)
.map(|r| (r.target(), r.id))
.collect();
assert_eq!(ids.len(), 2);
move_pointer(&mut g.f.app, &mut state, from);
release(&mut g.f.app, &mut state, from);
let mut expected = before.clone();
c_allocations(&mut expected, &g, &ids);
assert_eq!(selection(&g.f.app), held);
exact_history(&mut g.f.app, &before, &source, &expected);
g.f.app
.document
.execute(Command::Describe(g.y, "Future rationale".into()))
.unwrap();
g.f.app.sync();
let future = g.f.app.document.bundle().clone();
let _ = g.f.app.update(Message::Undo);
assert!(g.f.app.document.can_undo() && g.f.app.document.can_redo());
let snapshot = g.f.app.document.snapshot();
let revision = g.f.app.document.revision();
let held = selection(&g.f.app);
let from = node_point(&g.f.app, g.f.node);
let mut state = CanvasState::default();
begin(&mut g.f.app, &mut state, from);
move_pointer(&mut g.f.app, &mut state, from + Vector::new(4., 0.));
move_pointer(&mut g.f.app, &mut state, from);
release(&mut g.f.app, &mut state, from);
assert!(
Arc::ptr_eq(&snapshot, &g.f.app.document.snapshot()),
"{}",
g.f.app.status
);
assert_eq!(g.f.app.document.revision(), revision);
assert_eq!(selection(&g.f.app), held);
assert!(g.f.app.document.can_undo() && g.f.app.document.can_redo());
let _ = g.f.app.update(Message::Redo);
assert_eq!(g.f.app.document.bundle(), &future);
}
}
#[test]
fn group_source_refusal_retains_primary_source_and_both_history_paths() {
let mut g = group(Layer::Oa);
unassign(&mut g, false);
let source = g.f.app.document.bundle().clone();
let mut limits = oa_core::source_project::ProjectLimits::default();
limits.model.max_source_bytes = source.files.values().map(String::len).sum::<usize>() + 128;
g.f.app.document = crate::document::Document::from_bundle(source, limits).unwrap();
g.f.app
.document
.execute(Command::Describe(g.f.work, "Reviewed".into()))
.unwrap();
g.f.app
.document
.execute(Command::Rename(g.y, "Future Y".into()))
.unwrap();
let future = g.f.app.document.bundle().clone();
assert!(g.f.app.document.undo());
g.f.app.sync();
g.f.app.reconcile_multiselect();
select(&mut g.f.app, &[g.f.node, g.yn]);
let held = selection(&g.f.app);
let before = g.f.app.document.snapshot();
let source = g.f.app.document.bundle().clone();
let revision = g.f.app.document.revision();
assert!(g.f.app.document.can_undo() && g.f.app.document.can_redo());
let from = node_point(&g.f.app, g.f.node);
let to = from + Vector::new(1400., 0.);
let mut state = CanvasState::default();
begin(&mut g.f.app, &mut state, from);
move_pointer(&mut g.f.app, &mut state, to);
assert!(
g.f.app.allocation_drag.preview.is_some(),
"geometry and semantics pass before source refusal: {}",
g.f.app.status
);
release(&mut g.f.app, &mut state, to);
assert!(g.f.app.status.contains("retry"), "{}", g.f.app.status);
assert!(g.f.app.allocation_drag.active.is_none());
assert!(Arc::ptr_eq(&before, &g.f.app.document.snapshot()));
assert_eq!(g.f.app.document.bundle(), &source);
assert_eq!(g.f.app.document.revision(), revision);
assert_eq!(selection(&g.f.app), held);
assert!(g.f.app.document.can_undo() && g.f.app.document.can_redo());
let _ = g.f.app.update(Message::Redo);
assert_eq!(g.f.app.document.bundle(), &future);
let _ = g.f.app.update(Message::Undo);
assert_eq!(g.f.app.document.bundle(), &source);
}
#[test]
fn ordinary_off_grid_performer_out_and_back_is_noop_with_redo() {
for singleton in [false, true] {
let mut g = group(Layer::Oa);
let mut project = g.f.app.project().clone();
project
.diagrams
.get_mut(&g.f.diagram)
.unwrap()
.nodes
.get_mut(&g.an)
.unwrap()
.position = xy(103., 107.);
g.f.app = App::with_project(project);
g.f.app.active_diagram = Some(g.f.diagram);
g.f.app.zoom = 1.;
g.f.app.pan = Vector::ZERO;
g.f.app.fit_to_view = false;
g.f.app
.document
.execute(Command::Describe(g.f.work, "Reviewed".into()))
.unwrap();
g.f.app
.document
.execute(Command::Describe(g.y, "Future".into()))
.unwrap();
let future = g.f.app.document.bundle().clone();
assert!(g.f.app.document.undo());
g.f.app.sync();
g.f.app.reconcile_multiselect();
select(
&mut g.f.app,
&if singleton {
vec![g.an]
} else {
vec![g.an, g.cn]
},
);
let held = selection(&g.f.app);
let before = g.f.app.document.snapshot();
let source = g.f.app.document.bundle().clone();
let revision = g.f.app.document.revision();
let from = node_point(&g.f.app, g.an);
let mut state = CanvasState::default();
begin(&mut g.f.app, &mut state, from);
move_pointer(&mut g.f.app, &mut state, from + Vector::new(20., 0.));
assert!(
g.f.app.allocation_drag.preview.is_some(),
"{}",
g.f.app.status
);
move_pointer(&mut g.f.app, &mut state, from);
assert_eq!(
g.f.app
.allocation_drag
.preview
.as_ref()
.unwrap()
.placement
.delta,
xy(0., 0.)
);
release(&mut g.f.app, &mut state, from);
assert!(Arc::ptr_eq(&before, &g.f.app.document.snapshot()));
assert_eq!(g.f.app.document.bundle(), &source);
assert_eq!(g.f.app.document.revision(), revision);
assert_eq!(selection(&g.f.app), held);
assert!(g.f.app.document.can_undo() && g.f.app.document.can_redo());
let _ = g.f.app.update(Message::Redo);
assert_eq!(g.f.app.document.bundle(), &future);
}
}
@@ -0,0 +1,292 @@
//! Delayed captured drawing packets must not retire a newer Allocation owner.
use super::*;
use crate::connection_edit::Event as ConnectionEvent;
use crate::connection_tool::Tool;
fn drawing_tool(layer: Layer) -> Tool {
match layer {
Layer::Oa => Tool::Interaction,
Layer::Sa => Tool::FunctionalExchange,
}
}
fn drawing_source(g: &mut Group, layer: Layer) -> crate::route_edit::Context {
let _ =
g.f.app
.update(Message::Connection(ConnectionEvent::Choose(Some(
drawing_tool(layer),
))));
let context = g.f.app.route_context().unwrap();
let _ =
g.f.app
.update(Message::Connection(ConnectionEvent::Endpoint(
context, g.f.work,
)));
let session = g.f.app.connection.as_ref().unwrap();
assert_eq!(session.context, context);
assert_eq!(session.source, Some(g.f.work));
assert!(session.candidates.contains(&g.y));
assert_eq!(g.f.app.route_context(), Some(context));
context
}
fn assert_captured_noop(app: &mut App, captured: crate::route_edit::Context, target: Id) {
for event in [
ConnectionEvent::Endpoint(captured, target),
ConnectionEvent::Restart(captured),
] {
assert_connection_noop(app, event);
}
}
fn assert_connection_noop(app: &mut App, event: ConnectionEvent) {
let document = app.document.snapshot();
let selected = selection(app);
let context = app.route_context();
let camera = (app.pan, app.zoom, app.canvas_epoch, app.camera_epoch);
let ownership = (app.allocation_drag.epoch, app.allocation_drag.arrange_next);
let active = app
.allocation_drag
.active
.as_ref()
.map(|a| (a.press.clone(), a.crossed, a.cache.len()));
let preview = app.allocation_drag.preview.as_ref().map(|p| {
(
p.token,
p.placement.diagram.clone(),
p.placement.delta,
p.project().clone(),
p.routed.clone(),
)
});
let feedback = app.allocation_drag.feedback.as_ref().map(|f| {
(
f.press.token,
f.text.clone(),
f.refused,
f.targets.clone(),
f.inspection_targets.clone(),
)
});
let connection = |app: &App| {
app.connection
.as_ref()
.map(|s| (s.context, s.tool, s.source, s.candidates.clone()))
};
let session = connection(app);
let interaction = app.source_symbols.interaction;
let status = app.status.clone();
let _ = app.update(Message::Connection(event));
assert_eq!(selection(app), selected);
assert_eq!(app.route_context(), context);
assert_eq!(
(app.pan, app.zoom, app.canvas_epoch, app.camera_epoch),
camera
);
assert_eq!(
(app.allocation_drag.epoch, app.allocation_drag.arrange_next),
ownership,
"rejected drawing packet must not retire the newer input owner"
);
match (active, app.allocation_drag.active.as_ref()) {
(Some((press, crossed, cached)), Some(current)) => {
assert!(Arc::ptr_eq(&press, &current.press));
assert_eq!((current.crossed, current.cache.len()), (crossed, cached));
}
(None, None) => {}
_ => panic!("rejected drawing packet changed the active Allocation token"),
}
match (preview, app.allocation_drag.preview.as_ref()) {
(Some((token, diagram, delta, project, routed)), Some(current)) => {
assert_eq!(current.token, token);
assert_eq!(current.placement.diagram, diagram);
assert_eq!(current.placement.delta, delta);
assert_eq!(current.project(), &project);
assert!(Arc::ptr_eq(&current.routed, &routed));
}
(None, None) => {}
_ => panic!("rejected drawing packet changed the Allocation preview"),
}
assert_eq!(
app.allocation_drag.feedback.as_ref().map(|f| (
f.press.token,
f.text.clone(),
f.refused,
f.targets.clone(),
f.inspection_targets.clone(),
)),
feedback
);
assert_eq!(connection(app), session);
assert_eq!(app.source_symbols.interaction, interaction);
assert_eq!(app.status, status);
assert!(Arc::ptr_eq(&document, &app.document.snapshot()));
assert_eq!(app.source_workspace.bundle(), document.bundle);
}
#[test]
fn captured_connection_after_cancel_preserves_arrange_arm_and_live_group() {
for layer in [Layer::Oa, Layer::Sa] {
let mut g = group(layer);
let old = drawing_source(&mut g, layer);
let _ = g.f.app.update(Message::Escape);
assert!(g.f.app.connection.is_none());
select(&mut g.f.app, &[g.an, g.yn]);
let _ = g.f.app.update(Message::WorkDrag(Event::ArrangeNext));
assert!(g.f.app.allocation_drag.arrange_next);
let current = g.f.app.route_context().unwrap();
assert_captured_noop(&mut g.f.app, old, g.y);
// Matching context with no session isolates the missing-owner guard.
assert_captured_noop(&mut g.f.app, current, g.y);
let before = g.f.app.project().clone();
let source = g.f.app.document.bundle().clone();
let held = selection(&g.f.app);
let from = node_point(&g.f.app, g.an);
let to = from + Vector::new(400., 0.);
let mut state = CanvasState::default();
let press = begin(&mut g.f.app, &mut state, from);
assert_eq!(press.mode, Mode::Arrange);
assert!(!g.f.app.allocation_drag.arrange_next);
move_pointer(&mut g.f.app, &mut state, to);
assert!(matches!(
g.f.app
.allocation_drag
.preview
.as_ref()
.map(|p| &p.candidate),
Some(Candidate::Arrange(_))
));
let current = g.f.app.route_context().unwrap();
assert_captured_noop(&mut g.f.app, old, g.y);
assert_captured_noop(&mut g.f.app, current, g.y);
release(&mut g.f.app, &mut state, to);
let mut expected = before.clone();
let diagram = expected.diagrams.get_mut(&g.f.diagram).unwrap();
diagram.nodes.get_mut(&g.an).unwrap().position = xy(500., 100.);
diagram.nodes.get_mut(&g.f.node).unwrap().position = xy(540., 200.);
diagram.nodes.get_mut(&g.yn).unwrap().position = xy(540., 600.);
assert_eq!(selection(&g.f.app), held);
exact_history(&mut g.f.app, &before, &source, &expected);
}
}
#[test]
fn captured_connection_context_mismatches_preserve_the_current_session() {
let mut g = group(Layer::Oa);
let current = drawing_source(&mut g, Layer::Oa);
// Session and current context agree: each old captured field is the sole
// invalid relationship, not hidden behind a missing session.
for captured in [
crate::route_edit::Context {
document: Id::new_v4(),
..current
},
crate::route_edit::Context {
diagram: Id::new_v4(),
..current
},
crate::route_edit::Context {
revision: current.revision + 1,
..current
},
crate::route_edit::Context {
epoch: current.epoch + 1,
..current
},
] {
assert_eq!(g.f.app.connection.as_ref().unwrap().context, current);
assert_captured_noop(&mut g.f.app, captured, g.y);
}
// Captured/session contexts agree, but the rendered view has advanced.
// This isolates the live-route predicate from session ownership.
g.f.app.canvas_epoch += 1;
assert_ne!(g.f.app.route_context(), Some(current));
assert_eq!(g.f.app.connection.as_ref().unwrap().context, current);
assert_captured_noop(&mut g.f.app, current, g.y);
g.f.app.canvas_epoch = current.epoch;
// Conversely, incoming/current contexts agree. Retain obsolete session
// metadata explicitly to isolate the session-context admission branch.
g.f.app.connection.as_mut().unwrap().context.epoch += 1;
assert_eq!(g.f.app.route_context(), Some(current));
assert_captured_noop(&mut g.f.app, current, g.y);
}
#[test]
fn captured_connection_current_restart_and_pair_commit_after_tool_boundary() {
for begin_tool in [false, true] {
let mut g = group(Layer::Oa);
select(&mut g.f.app, &[g.an, g.yn]);
let _ = g.f.app.update(Message::WorkDrag(Event::ArrangeNext));
let from = node_point(&g.f.app, g.an);
let mut state = CanvasState::default();
begin(&mut g.f.app, &mut state, from);
move_pointer(&mut g.f.app, &mut state, from + Vector::new(400., 0.));
assert!(g.f.app.allocation_drag.preview.is_some());
let before = g.f.app.project().clone();
let source = g.f.app.document.bundle().clone();
let epoch = g.f.app.allocation_drag.epoch;
let event = if begin_tool {
ConnectionEvent::Begin(Tool::Interaction, g.f.work)
} else {
ConnectionEvent::Choose(Some(Tool::Interaction))
};
let _ = g.f.app.update(Message::Connection(event));
assert!(g.f.app.allocation_drag.epoch > epoch);
assert!(g.f.app.allocation_drag.active.is_none());
assert!(g.f.app.allocation_drag.preview.is_none());
assert!(!g.f.app.allocation_drag.arrange_next);
let context = g.f.app.route_context().unwrap();
if !begin_tool {
let _ =
g.f.app
.update(Message::Connection(ConnectionEvent::Endpoint(
context, g.f.work,
)));
}
assert_eq!(g.f.app.connection.as_ref().unwrap().source, Some(g.f.work));
assert!(
g.f.app
.connection
.as_ref()
.unwrap()
.candidates
.contains(&g.y)
);
let document = g.f.app.document.snapshot();
let _ =
g.f.app
.update(Message::Connection(ConnectionEvent::Restart(context)));
let context = g.f.app.route_context().unwrap();
let restarted = g.f.app.connection.as_ref().unwrap();
assert_eq!(restarted.context, context);
assert_eq!(restarted.tool, Tool::Interaction);
assert_eq!(restarted.source, None);
assert!(restarted.candidates.contains(&g.f.work));
assert!(Arc::ptr_eq(&document, &g.f.app.document.snapshot()));
let _ =
g.f.app
.update(Message::Connection(ConnectionEvent::Endpoint(
context, g.f.work,
)));
assert_eq!(g.f.app.connection.as_ref().unwrap().source, Some(g.f.work));
let revision = g.f.app.document.revision();
let _ =
g.f.app
.update(Message::Connection(ConnectionEvent::Endpoint(context, g.y)));
assert!(g.f.app.connection.is_none());
assert_eq!(g.f.app.document.revision(), revision + 1);
let relation = &g.f.app.project().relations[&g.f.app.selected];
assert_eq!(relation.kind(), RelationKind::Interaction);
assert_eq!((relation.source(), relation.target()), (g.f.work, g.y));
let mut expected = before.clone();
assert!(
expected
.relations
.insert(relation.id, relation.clone())
.is_none()
);
exact_history(&mut g.f.app, &before, &source, &expected);
}
}
@@ -0,0 +1,274 @@
//! Captured route commits cannot retire a newer Allocation input owner.
use super::*;
use crate::route_edit::{Context, Event as RouteEvent};
fn routed_group() -> (Group, Id) {
let mut g = group(Layer::Oa);
let mut project = g.f.app.project().clone();
let edge = project.relate(
RelationKind::Interaction,
"Reviewed exchange",
g.f.work,
g.y,
);
project
.diagrams
.get_mut(&g.f.diagram)
.unwrap()
.routes
.push(EdgeRoute {
edge,
waypoints: vec![xy(700., 400.)],
label: Some(EdgeLabel {
fraction: 0.4,
offset: xy(5., 10.),
}),
source_attachment: None,
target_attachment: Some(EdgeAttachment {
side: PortAnchorSide::Left,
offset: 0.6,
}),
});
validate_candidate(&project).unwrap();
g.f.app = App::with_project(project);
g.f.app.active_diagram = Some(g.f.diagram);
g.f.app.zoom = 1.;
g.f.app.pan = Vector::ZERO;
g.f.app.fit_to_view = false;
choose(&mut g.f.app, SelectionTarget::Edge(edge), Choice::Replace);
assert_eq!(g.f.app.selected, edge);
let view = projection::project_view(g.f.app.project(), g.f.diagram);
assert!(view.edges.iter().any(|e| e.id == edge));
assert_eq!(
crate::route_edit::attachment_node(
g.f.app.project(),
&g.f.app.project().diagrams[&g.f.diagram],
&view,
edge,
EdgeEnd::Source,
)
.unwrap()
.id,
g.f.node
);
(g, edge)
}
fn packet(g: &Group, edge: Id, captured: Context, attachment: bool) -> RouteEvent {
let mut route =
crate::route_edit::stored_route(&g.f.app.project().diagrams[&g.f.diagram], edge);
if attachment {
route.source_attachment = Some(EdgeAttachment {
side: PortAnchorSide::Top,
offset: 0.25,
});
RouteEvent::CommitAttachment(captured, route, EdgeEnd::Source, g.f.node)
} else {
route.waypoints[0] = xy(750., 450.);
RouteEvent::Commit(captured, route, Some(0))
}
}
fn assert_route_noop(app: &mut App, event: RouteEvent) {
let document = app.document.snapshot();
let selected = selection(app);
let context = app.route_context();
let camera = (app.pan, app.zoom, app.canvas_epoch, app.camera_epoch);
let ownership = (app.allocation_drag.epoch, app.allocation_drag.arrange_next);
let press = app.allocation_drag.active.as_ref().map(|a| a.press.clone());
let preview = app.allocation_drag.preview.clone();
let feedback = app.allocation_drag.feedback.as_ref().map(|f| {
(
f.press.token,
f.text.clone(),
f.refused,
f.targets.clone(),
f.inspection_targets.clone(),
)
});
let handles = (app.selected_bend, app.selected_attachment);
let interaction = app.source_symbols.interaction;
let status = app.status.clone();
let _ = app.update(Message::Route(event));
assert_eq!(selection(app), selected);
assert_eq!(app.route_context(), context);
assert_eq!(
(app.pan, app.zoom, app.canvas_epoch, app.camera_epoch),
camera
);
assert_eq!(
(app.allocation_drag.epoch, app.allocation_drag.arrange_next),
ownership
);
match (press, app.allocation_drag.active.as_ref()) {
(Some(before), Some(after)) => assert!(Arc::ptr_eq(&before, &after.press)),
(None, None) => {}
_ => panic!("stale route packet changed the active Allocation token"),
}
match (preview, app.allocation_drag.preview.as_ref()) {
(Some(before), Some(after)) => assert!(Arc::ptr_eq(&before, after)),
(None, None) => {}
_ => panic!("stale route packet changed the Allocation preview"),
}
assert_eq!(
app.allocation_drag.feedback.as_ref().map(|f| (
f.press.token,
f.text.clone(),
f.refused,
f.targets.clone(),
f.inspection_targets.clone(),
)),
feedback
);
assert_eq!((app.selected_bend, app.selected_attachment), handles);
assert_eq!(app.source_symbols.interaction, interaction);
assert_eq!(app.status, status);
assert!(Arc::ptr_eq(&document, &app.document.snapshot()));
assert_eq!(app.source_workspace.bundle(), document.bundle);
}
fn stale_route_preserves_new_group(attachment: bool, escape: bool) {
let (mut g, edge) = routed_group();
let old = g.f.app.route_context().unwrap();
let delayed = packet(&g, edge, old, attachment);
if escape {
let _ = g.f.app.update(Message::Escape);
assert_ne!(g.f.app.route_context(), Some(old));
}
select(&mut g.f.app, &[g.an, g.yn]);
let _ = g.f.app.update(Message::WorkDrag(Event::ArrangeNext));
assert!(g.f.app.allocation_drag.arrange_next);
if !escape {
assert_eq!(g.f.app.route_context(), Some(old));
assert_ne!(g.f.app.selected, edge);
}
assert_route_noop(&mut g.f.app, delayed.clone());
let before = g.f.app.project().clone();
let source = g.f.app.document.bundle().clone();
let selected = selection(&g.f.app);
let from = node_point(&g.f.app, g.an);
let to = from + Vector::new(400., 0.);
let mut state = CanvasState::default();
let press = begin(&mut g.f.app, &mut state, from);
assert_eq!(press.mode, Mode::Arrange);
move_pointer(&mut g.f.app, &mut state, to);
assert!(g.f.app.allocation_drag.preview.is_some());
if !escape {
assert_eq!(g.f.app.route_context(), Some(old));
assert_ne!(g.f.app.selected, edge);
}
assert_route_noop(&mut g.f.app, delayed);
release(&mut g.f.app, &mut state, to);
let mut expected = before.clone();
let diagram = expected.diagrams.get_mut(&g.f.diagram).unwrap();
diagram.nodes.get_mut(&g.an).unwrap().position = xy(500., 100.);
diagram.nodes.get_mut(&g.f.node).unwrap().position = xy(540., 200.);
diagram.nodes.get_mut(&g.yn).unwrap().position = xy(540., 600.);
// Both endpoint placements move by (400, 0); the saved pin follows once,
// while label offset and endpoint attachment settings stay unchanged.
diagram
.routes
.iter_mut()
.find(|r| r.edge == edge)
.unwrap()
.waypoints = vec![xy(1100., 400.)];
assert_eq!(selection(&g.f.app), selected);
exact_history(&mut g.f.app, &before, &source, &expected);
}
#[test]
fn stale_route_commit_preserves_arrange_arm_and_live_group() {
stale_route_preserves_new_group(false, true);
}
#[test]
fn stale_route_attachment_preserves_arrange_arm_and_live_group() {
stale_route_preserves_new_group(true, true);
}
#[test]
fn same_context_route_commit_after_selection_preserves_arrange_arm_and_live_group() {
stale_route_preserves_new_group(false, false);
}
#[test]
fn same_context_route_attachment_after_selection_preserves_arrange_arm_and_live_group() {
stale_route_preserves_new_group(true, false);
}
#[test]
fn captured_route_context_fields_independently_reject_before_retirement() {
for attachment in [false, true] {
let (mut g, edge) = routed_group();
let current = g.f.app.route_context().unwrap();
// The selected edge and route payload are valid. Each capture mismatch
// is isolated; no nonexistent route session can mask admission.
for captured in [
Context {
document: Id::new_v4(),
..current
},
Context {
diagram: Id::new_v4(),
..current
},
Context {
revision: current.revision + 1,
..current
},
Context {
epoch: current.epoch + 1,
..current
},
] {
let event = packet(&g, edge, captured, attachment);
assert_route_noop(&mut g.f.app, event);
}
// Hold the captured packet fixed and advance the live context instead.
let event = packet(&g, edge, current, attachment);
g.f.app.canvas_epoch += 1;
assert_route_noop(&mut g.f.app, event.clone());
g.f.app.canvas_epoch = current.epoch;
g.f.app.active_diagram = None;
assert_route_noop(&mut g.f.app, event);
}
}
#[test]
fn current_route_commits_without_session_preserve_exact_route_history() {
for attachment in [false, true] {
let (mut g, edge) = routed_group();
let current = g.f.app.route_context().unwrap();
let event = packet(&g, edge, current, attachment);
let before = g.f.app.project().clone();
let source = g.f.app.document.bundle().clone();
let revision = g.f.app.document.revision();
assert!(g.f.app.connection.is_none());
assert!(g.f.app.selected_attachment.is_none());
let _ = g.f.app.update(Message::Route(event));
assert_eq!(g.f.app.document.revision(), revision + 1);
assert_eq!(g.f.app.selected, edge);
assert_eq!(
g.f.app.selected_bend,
if attachment { None } else { Some((edge, 0)) }
);
let mut expected = before.clone();
let route = expected
.diagrams
.get_mut(&g.f.diagram)
.unwrap()
.routes
.iter_mut()
.find(|r| r.edge == edge)
.unwrap();
if attachment {
route.source_attachment = Some(EdgeAttachment {
side: PortAnchorSide::Top,
offset: 0.25,
});
} else {
route.waypoints = vec![xy(750., 450.)];
}
exact_history(&mut g.f.app, &before, &source, &expected);
}
}
@@ -161,7 +161,7 @@ fn stale_gesture_packets_preserve_navigation_and_a_new_live_cancel_still_works()
.update(Message::WorkDrag(Event::Cancel(current.token)));
assert!(f.app.allocation_drag.active.is_none());
assert!(f.app.allocation_drag.epoch > epoch);
assert_eq!(f.app.status, "Work drag canceled");
assert_eq!(f.app.status, "Drag canceled");
assert!(Arc::ptr_eq(&before, &f.app.document.snapshot()));
assert_eq!(f.app.source_workspace.bundle(), before.bundle);
}
@@ -27,11 +27,30 @@ pub enum Event {
Cancel,
}
pub(super) fn captured_escape_message(app: &App) -> Option<Message> {
app.architecture
.as_ref()
.is_some_and(State::inline)
.then_some(Message::Architecture(Event::CapturedEscape))
pub fn subscription(app: &App) -> Subscription<Message> {
if app.architecture.as_ref().is_some_and(State::inline) {
iced::event::listen_with(captured_escape)
} else {
Subscription::none()
}
}
fn captured_escape(
event: iced::Event,
status: iced::event::Status,
_: iced::window::Id,
) -> Option<Message> {
// A focused Iced text input captures Escape while dropping its focus. Only
// inline architecture naming opts into also cancelling that same keypress.
(status == iced::event::Status::Captured
&& matches!(
event,
iced::Event::Keyboard(iced::keyboard::Event::KeyPressed {
key: iced::keyboard::Key::Named(iced::keyboard::key::Named::Escape),
..
})
))
.then_some(Message::Architecture(Event::CapturedEscape))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
@@ -282,17 +301,6 @@ impl App {
_ => return Task::none(),
};
let placing = point.is_none() && matches!(intent, Intent::Create { .. });
let filter = if matches!(intent, Intent::Existing { .. })
&& self
.project()
.diagrams
.get(&diagram)
.is_some_and(|d| d.projection == ProjectionKind::Traceability)
{
Filter::AllCompatible
} else {
Filter::Context
};
let saved = Saved {
version: 1,
base: oa_core::transitions::project_fingerprint(self.project()),
@@ -301,7 +309,7 @@ impl App {
point,
placing,
query: String::new(),
filter,
filter: Filter::Context,
show_placed: false,
page: 0,
};
@@ -229,7 +229,13 @@ pub fn view(app: &App) -> Element<'_, Message> {
content = content.push(group("Connections"));
for connection in crate::connection_tool::Tool::for_layer(app.layer) {
let d = &app.project().diagrams[&app.active_diagram.unwrap()];
if !connection.available_in(app.layer, d.projection) {
let kind = match connection {
crate::connection_tool::Tool::Interaction => Kind::Activity,
crate::connection_tool::Tool::FunctionalExchange => Kind::Function,
crate::connection_tool::Tool::CommunicationMean => Kind::Entity,
crate::connection_tool::Tool::BoundaryExchange => Kind::System,
};
if !d.projection.includes(kind) {
continue;
}
content = content.push(tool(
@@ -1,5 +1,4 @@
use super::*;
mod traceability;
fn setup(layer: Layer) -> App {
let mut app = App::with_project(Project::empty());
@@ -67,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(),
@@ -233,6 +233,65 @@ fn architecture_stale_submit_keeps_create_and_rename_visible_and_exact() {
}
}
fn key_event(named: iced::keyboard::key::Named) -> iced::Event {
use iced::keyboard;
iced::Event::Keyboard(keyboard::Event::KeyPressed {
key: keyboard::Key::Named(named),
modified_key: keyboard::Key::Named(named),
physical_key: keyboard::key::Physical::Code(keyboard::key::Code::Escape),
location: keyboard::Location::Standard,
modifiers: keyboard::Modifiers::empty(),
text: None,
repeat: false,
})
}
fn focused_text_input_escape(name: &str) -> Message {
use iced::advanced::{Layout, Shell, layout, widget};
let mut field: iced::Element<'_, (), iced::Theme, ()> = text_input("Engineering name", name)
.id("architecture-name")
.on_input(|_| ())
.into();
let mut tree = widget::Tree::new(field.as_widget());
let size = iced::Size::new(300., 40.);
let node =
field
.as_widget_mut()
.layout(&mut tree, &(), &layout::Limits::new(iced::Size::ZERO, size));
tree.state
.downcast_mut::<iced::widget::text_input::State<()>>()
.focus();
let escape = key_event(iced::keyboard::key::Named::Escape);
let mut messages = Vec::new();
let mut shell = Shell::new(&mut messages);
field.as_widget_mut().update(
&mut tree,
&escape,
Layout::new(&node),
iced::mouse::Cursor::Unavailable,
&(),
&mut iced::advanced::clipboard::Null,
&mut shell,
&iced::Rectangle::new(iced::Point::ORIGIN, size),
);
assert!(
shell.is_event_captured(),
"Iced consumes focused-input Escape"
);
assert!(
!tree
.state
.downcast_ref::<iced::widget::text_input::State<()>>()
.is_focused()
);
captured_escape(
escape,
iced::event::Status::Captured,
iced::window::Id::unique(),
)
.unwrap()
}
#[test]
fn architecture_first_focused_input_escape_cancels_create_and_rename() {
for rename in [false, true] {
@@ -252,9 +311,8 @@ fn architecture_first_focused_input_escape_cancels_create_and_rename() {
let name = " Unaccepted long engineering name 雨 ";
send(&mut app, Event::Name(name.into()));
assert!(app.architecture.as_ref().unwrap().inline());
let mut messages = crate::app::escape::tests::focused_input_messages(&app);
assert_eq!(messages.len(), 1);
let _ = app.update(messages.remove(0));
let message = focused_text_input_escape(name);
let _ = app.update(message);
assert!(app.architecture.is_none());
assert!(inline_editor(&app).is_none());
assert_eq!(app.status, "Cancelled · Select / move");
@@ -284,8 +342,8 @@ fn architecture_captured_escape_does_not_cancel_other_controls_or_placement() {
let before = app.document.bundle().clone();
let status = app.status.clone();
let epoch = app.canvas_epoch;
let messages = crate::app::escape::tests::focused_input_messages(&app);
assert!(messages.is_empty());
let message = focused_text_input_escape("Other field or existing-element search");
let _ = app.update(message);
assert_eq!(
app.architecture
.as_ref()
@@ -297,7 +355,19 @@ fn architecture_captured_escape_does_not_cancel_other_controls_or_placement() {
assert_eq!(app.canvas_epoch, epoch);
}
assert!(
crate::app::escape::tests::focused_input_key(&app, iced::keyboard::key::Named::Enter,)
.is_empty()
captured_escape(
key_event(iced::keyboard::key::Named::Escape),
iced::event::Status::Ignored,
iced::window::Id::unique(),
)
.is_none()
);
assert!(
captured_escape(
key_event(iced::keyboard::key::Named::Enter),
iced::event::Status::Captured,
iced::window::Id::unique(),
)
.is_none()
);
}
@@ -1,124 +0,0 @@
use super::*;
#[test]
fn traceability_existing_defaults_show_opposite_layer_and_place_only_one_node() {
let mut project = Project::empty();
let sa = project
.add_element(Kind::Function, Layer::Sa, "Deliver report", None)
.unwrap();
let system = project
.add_element(Kind::System, Layer::Sa, "System", None)
.unwrap();
let diagram = project.ensure_diagram(Layer::Oa);
project.diagrams.get_mut(&diagram).unwrap().projection = ProjectionKind::Traceability;
let mut app = App::with_project(project);
app.page = Page::Diagram;
app.layer = Layer::Oa;
app.active_diagram = Some(diagram);
let before = app.document.snapshot();
send(&mut app, Event::Existing(None));
assert_eq!(
app.architecture.as_ref().unwrap().saved.filter,
Filter::AllCompatible
);
let listed = candidates(&app);
assert!(listed.iter().any(|(id, _, shown)| *id == sa && !shown));
assert!(listed.iter().any(|(id, _, shown)| *id == system && !shown));
send(&mut app, Event::Pick(sa));
assert!(app.architecture.as_ref().unwrap().saved.placing);
assert_eq!(app.project(), &before.decoded.project);
let context = app.route_context().unwrap();
let point = Position { x: 345., y: 215. };
send(&mut app, Event::Click(context, point));
assert!(app.architecture.is_none(), "{}", app.status);
assert_eq!(app.active_diagram, Some(diagram));
let placed = app.project().diagrams[&diagram]
.nodes
.values()
.find(|n| n.element == sa)
.unwrap()
.clone();
assert_eq!(placed.position, point);
let mut expected = before.decoded.project.clone();
expected
.diagrams
.get_mut(&diagram)
.unwrap()
.nodes
.insert(placed.id, placed);
assert_eq!(app.project(), &expected);
let accepted = app.document.bundle().clone();
let _ = app.update(Message::Undo);
assert_eq!(app.document.bundle(), &before.bundle);
assert!(!app.document.can_undo());
let _ = app.update(Message::Redo);
assert_eq!(app.project(), &expected);
assert_eq!(app.document.bundle(), &accepted);
}
#[test]
fn traceability_default_does_not_change_creation_or_ordinary_picker_guards() {
let mut app = setup(Layer::Oa);
let diagram = app.active_diagram.unwrap();
send(&mut app, Event::Existing(None));
assert_eq!(
app.architecture.as_ref().unwrap().saved.filter,
Filter::Context
);
send(&mut app, Event::Cancel);
let _ = app.update(Message::Projection(ProjectionKind::Traceability));
send(&mut app, Event::Start(Kind::Entity, None));
assert_eq!(
app.architecture.as_ref().unwrap().saved.filter,
Filter::Context
);
send(&mut app, Event::Cancel);
assert_eq!(app.active_diagram, Some(diagram));
let mut project = app.project().clone();
let sa = project
.add_element(Kind::Function, Layer::Sa, "Other layer", None)
.unwrap();
let port = Id::new_v4();
project.elements.insert(
port,
oa_core::Element {
id: port,
alias: "legacy_port".into(),
name: "Port".into(),
description: String::new(),
parent: Some(sa),
body: ElementBody::shared_in(
Layer::Sa,
SharedBody::Port {
contract: PortContract::Legacy,
legacy: Some(LegacyPortContract {
direction: PortDirection::InOut,
interface: None,
}),
},
),
},
);
oa_core::validate_candidate(&project).unwrap();
for projection in [ProjectionKind::Overview, ProjectionKind::Traceability] {
let mut p = project.clone();
p.diagrams.get_mut(&diagram).unwrap().projection = projection;
let mut trial = App::with_project(p);
trial.page = Page::Diagram;
trial.active_diagram = Some(diagram);
trial.layer = Layer::Oa;
let before = trial.document.bundle().clone();
send(&mut trial, Event::Existing(None));
send(&mut trial, Event::Filter(Filter::AllCompatible));
assert_eq!(
candidates(&trial).iter().any(|(id, _, _)| *id == sa),
projection == ProjectionKind::Traceability
);
assert!(!candidates(&trial).iter().any(|(id, _, _)| *id == port));
send(&mut trial, Event::Pick(port));
assert!(!trial.architecture.as_ref().unwrap().saved.placing);
send(&mut trial, Event::Cancel);
assert_eq!(trial.document.bundle(), &before);
}
}
@@ -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(),
@@ -298,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(),
@@ -518,9 +518,16 @@ async fn mounted_qualified_process_child_delete_refuses_outer_reference_and_shar
.await;
assert!(messages.iter().any(|message| matches!(
message,
Message::TopologyCanvas(topology::CanvasEvent::SelectQualified(..))
Message::DiagramSelection(multiselect::Event::Choose(
_, crate::diagram_usage::Target::ProcessOccurrence(actual), multiselect::Choice::Replace
)) if actual == &endpoint
)));
assert_eq!(app.selected, references[0]);
assert_eq!(app.selected, f.nodes[0]);
assert!(app.draft.is_none(), "Qualified inspection stays read-only");
assert_eq!(
app.behavior.topology.selection_address().unwrap().endpoint,
endpoint
);
assert!(
app.behavior
.topology
@@ -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());
}
}
+1 -8
View File
@@ -319,14 +319,7 @@ impl App {
| Target::ScenarioObject(id)
| Target::AllocationGrouping { relation: id, .. }
| Target::ProcessPreviewLink { relation: id, .. } => (*id, None),
Target::ProcessOccurrence(endpoint) => (
endpoint
.references
.first()
.copied()
.unwrap_or(endpoint.occurrence),
None,
),
Target::ProcessOccurrence(endpoint) => (endpoint.occurrence, None),
};
match usage.surface {
Surface::Saved(id) => {
@@ -881,7 +881,32 @@ fn repeated_process_calls_reveal_the_chosen_caller_and_distinct_tile_camera() {
.unwrap()
.clone();
choose(&mut app, token, row);
assert_eq!(app.selected, first);
assert_eq!(app.selected, leaf);
let context = app.topology_context().unwrap();
send(
&mut app,
Message::TopologyCanvas(behavior::topology::CanvasEvent::Viewport(
context,
iced::Size::new(850., 460.),
)),
);
assert!(app.pending_diagram_reveal.is_none());
assert_eq!(
app.behavior.topology.selection_address().unwrap().endpoint,
OccurrenceEndpoint {
references: vec![first],
occurrence: leaf
}
);
assert_eq!(
app.multiselect_snapshot().unwrap().primary(),
Some(&Target::ProcessOccurrence(OccurrenceEndpoint {
references: vec![first],
occurrence: leaf
}))
);
assert!(app.process_preview_primary().is_some());
assert!(app.draft.is_none());
assert_eq!(app.page, Page::Behavior);
assert_eq!(app.behavior.active_owner, Some(fixture.owner));
let first_camera = app.behavior.topology.camera_boundary();
@@ -898,7 +923,33 @@ fn repeated_process_calls_reveal_the_chosen_caller_and_distinct_tile_camera() {
})
.unwrap();
choose(&mut app, token, row);
assert_eq!(app.selected, second);
assert_eq!(app.selected, leaf);
let context = app.topology_context().unwrap();
send(
&mut app,
Message::TopologyCanvas(behavior::topology::CanvasEvent::Viewport(
context,
iced::Size::new(850., 460.),
)),
);
assert!(app.pending_diagram_reveal.is_none());
assert_eq!(app.behavior.active_owner, Some(fixture.owner));
assert_eq!(
app.behavior.topology.selection_address().unwrap().endpoint,
OccurrenceEndpoint {
references: vec![second],
occurrence: leaf
}
);
assert_eq!(
app.multiselect_snapshot().unwrap().primary(),
Some(&Target::ProcessOccurrence(OccurrenceEndpoint {
references: vec![second],
occurrence: leaf
}))
);
assert!(app.process_preview_primary().is_some());
assert!(app.draft.is_none());
assert_eq!(app.selected_node, None);
assert!(app.behavior.topology.camera_boundary() != first_camera);
assert_eq!(authored(&app), before);
-198
View File
@@ -1,198 +0,0 @@
//! Keep overlay Escape ahead of modal cancellation in the ordinary widget tree.
//!
//! Iced skips root.update when an overlay captures an event. Keeping this
//! wrapper present even when inactive preserves the child's widget state. A
//! current-overlay preflight also handles pickers opened mid-batch. Passive
//! overlays may see an ignored Escape again: the raw-input observer only makes
//! pending focus requests stale; it does not publish modal cancellation.
use super::{App, Message, Page, architecture_authoring};
use iced::advanced::{Clipboard, Layout, Shell, Widget, layout, overlay, renderer, widget};
use iced::{Element, Event, Length, Rectangle, Size, Theme, Vector, keyboard, mouse};
pub(crate) fn root<'a, R: renderer::Renderer + 'a>(
app: &App,
content: Element<'a, Message, Theme, R>,
) -> Element<'a, Message, Theme, R> {
let modal = app.text_workspace.menu().is_none()
&& (app.behavior.creation.has_pending()
|| app.diagram_usages.is_some()
|| app.behavior.source_active()
|| (app.page == Page::Text
&& (app.text_workspace.drawer().is_some()
|| app.source_navigation.open
|| app.source_rename.open())
&& app.architecture.is_none()));
Element::new(Bridge {
content,
messages: [
architecture_authoring::captured_escape_message(app),
modal.then_some(Message::Escape),
],
})
}
struct Bridge<'a, R> {
content: Element<'a, Message, Theme, R>,
messages: [Option<Message>; 2],
}
impl<R: renderer::Renderer> Widget<Message, Theme, R> for Bridge<'_, R> {
fn children(&self) -> Vec<widget::Tree> {
vec![widget::Tree::new(self.content.as_widget())]
}
fn diff(&self, tree: &mut widget::Tree) {
tree.diff_children(std::slice::from_ref(&self.content));
}
fn size(&self) -> Size<Length> {
self.content.as_widget().size()
}
fn size_hint(&self) -> Size<Length> {
self.content.as_widget().size_hint()
}
fn layout(
&mut self,
tree: &mut widget::Tree,
renderer: &R,
limits: &layout::Limits,
) -> layout::Node {
self.content
.as_widget_mut()
.layout(&mut tree.children[0], renderer, limits)
}
fn operate(
&mut self,
tree: &mut widget::Tree,
layout: Layout<'_>,
renderer: &R,
operation: &mut dyn widget::Operation,
) {
self.content
.as_widget_mut()
.operate(&mut tree.children[0], layout, renderer, operation);
}
fn update(
&mut self,
tree: &mut widget::Tree,
event: &Event,
layout: Layout<'_>,
cursor: mouse::Cursor,
renderer: &R,
clipboard: &mut dyn Clipboard,
shell: &mut Shell<'_, Message>,
viewport: &Rectangle,
) {
let escape = matches!(
event,
Event::Keyboard(keyboard::Event::KeyPressed {
key: keyboard::Key::Named(keyboard::key::Named::Escape),
..
})
);
// Iced collects overlays before processing a batch of base events. A
// preceding click in that batch can therefore open a picker whose
// Escape would otherwise reach focused fields underneath it. Offer only
// Escape to the current overlay before updating the ordinary tree.
if escape {
if let Some(element) = self.content.as_widget_mut().overlay(
&mut tree.children[0],
layout,
renderer,
viewport,
Vector::ZERO,
) {
let mut overlay = overlay::Nested::new(element);
let node = overlay.layout(renderer, viewport.size());
overlay.update(
event,
Layout::new(&node),
cursor,
renderer,
clipboard,
shell,
);
}
if shell.is_event_captured() {
return;
}
}
self.content.as_widget_mut().update(
&mut tree.children[0],
event,
layout,
cursor,
renderer,
clipboard,
shell,
viewport,
);
if shell.is_event_captured() && escape {
for message in self.messages.iter().flatten() {
shell.publish(message.clone());
}
}
}
fn draw(
&self,
tree: &widget::Tree,
renderer: &mut R,
theme: &Theme,
style: &renderer::Style,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
) {
self.content.as_widget().draw(
&tree.children[0],
renderer,
theme,
style,
layout,
cursor,
viewport,
);
}
fn mouse_interaction(
&self,
tree: &widget::Tree,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
renderer: &R,
) -> mouse::Interaction {
self.content.as_widget().mouse_interaction(
&tree.children[0],
layout,
cursor,
viewport,
renderer,
)
}
fn overlay<'b>(
&'b mut self,
tree: &'b mut widget::Tree,
layout: Layout<'b>,
renderer: &R,
viewport: &Rectangle,
translation: Vector,
) -> Option<overlay::Element<'b, Message, Theme, R>> {
self.content.as_widget_mut().overlay(
&mut tree.children[0],
layout,
renderer,
viewport,
translation,
)
}
}
#[cfg(test)]
pub(crate) mod tests;
-618
View File
@@ -1,618 +0,0 @@
use super::*;
use crate::behavior::canvas_workspace::tests::key;
use iced::advanced::{subscription, widget::operation};
use iced::futures::{StreamExt, stream, task::noop_waker_ref};
use iced::keyboard::key::Named;
use iced::widget::{column, container, pick_list, text_input};
use iced_runtime::user_interface::{Cache, UserInterface};
use oa_core::{Layer, Project};
use std::task::{Context, Poll};
struct FocusInputs;
impl widget::Operation for FocusInputs {
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn widget::Operation)) {
visit(self);
}
fn focusable(
&mut self,
_: Option<&widget::Id>,
_: Rectangle,
state: &mut dyn operation::Focusable,
) {
state.focus();
}
}
pub(crate) fn subscriptions(
app: &App,
event: Event,
status: iced::event::Status,
messages: &mut Vec<Message>,
) {
let interaction = subscription::Event::Interaction {
window: iced::window::Id::unique(),
event,
status,
};
let mut boundaries = 0;
for recipe in subscription::into_recipes(app.subscription()) {
let mut output = recipe.stream(Box::pin(stream::iter([interaction.clone()])));
let mut cx = Context::from_waker(noop_waker_ref());
while let Poll::Ready(Some(message)) = output.poll_next_unpin(&mut cx) {
if matches!(
message,
Message::WorkDrag(crate::app::allocation_drag::Event::KeyboardBoundary)
) {
boundaries += 1;
} else {
messages.push(message);
}
}
}
assert_eq!(
boundaries, 1,
"one passive allocation keyboard notification"
);
}
/// A real focused text input, production root bridge, UserInterface dispatch,
/// and actual subscriptions. Existing app-policy tests use this instead of
/// pretending every captured event came from a text control.
pub(crate) fn focused_input_messages(app: &App) -> Vec<Message> {
focused_input_key(app, Named::Escape)
}
pub(crate) fn focused_input_key(app: &App, named: Named) -> Vec<Message> {
input_messages(app, named, true)
}
pub(crate) fn input_messages(app: &App, named: Named, focused: bool) -> Vec<Message> {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_time()
.build()
.unwrap();
let _entered = runtime.enter();
let input: Element<'_, Message, Theme, ()> = text_input("Retained input", "Uncommitted 雨")
.on_input(|_| Message::Escape)
.into();
let mut renderer = ();
let mut ui = UserInterface::build(
root(app, input),
Size::new(400., 100.),
Cache::new(),
&mut renderer,
);
if focused {
ui.operate(&renderer, &mut FocusInputs);
}
let event = key(named);
let mut messages = vec![];
let (_, statuses) = ui.update(
std::slice::from_ref(&event),
mouse::Cursor::Unavailable,
&mut renderer,
&mut iced::advanced::clipboard::Null,
&mut messages,
);
if named == Named::Escape {
assert_eq!(
statuses,
[if focused {
iced::event::Status::Captured
} else {
iced::event::Status::Ignored
}]
);
}
subscriptions(app, event, statuses[0], &mut messages);
messages
}
#[derive(Clone, Debug, PartialEq)]
enum PickMessage {
Selected(&'static str),
Opened,
Closed,
}
fn picker(
selected: Option<&'static str>,
notify: bool,
) -> Element<'static, PickMessage, Theme, ()> {
let list = pick_list(["Alpha", "Beta"], selected, PickMessage::Selected)
.width(200)
.padding(10)
.text_size(16)
.on_open(PickMessage::Opened);
if notify {
list.on_close(PickMessage::Closed).into()
} else {
list.into()
}
}
fn update<M>(
ui: &mut UserInterface<'_, M, Theme, ()>,
events: &[Event],
cursor: mouse::Cursor,
) -> (Vec<M>, Vec<iced::event::Status>) {
let mut messages = vec![];
let (_, statuses) = ui.update(
events,
cursor,
&mut (),
&mut iced::advanced::clipboard::Null,
&mut messages,
);
(messages, statuses)
}
fn press_at(point: iced::Point) -> (Vec<Event>, mouse::Cursor) {
(
vec![
Event::Mouse(mouse::Event::CursorMoved { position: point }),
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)),
],
mouse::Cursor::Available(point),
)
}
#[test]
fn picker_overlay_escape_closes_once_reopens_selects_and_retains_outside_click() {
for selected in [None, Some("Alpha")] {
for notify in [false, true] {
let mut ui = UserInterface::build(
picker(selected, notify),
Size::new(400., 300.),
Cache::new(),
&mut (),
);
let (click, cursor) = press_at(iced::Point::new(50., 15.));
assert_eq!(update(&mut ui, &click, cursor).0, [PickMessage::Opened]);
let cache = ui.into_cache();
let mut ui = UserInterface::build(
picker(selected, notify),
Size::new(400., 300.),
cache,
&mut (),
);
let (messages, statuses) = update(
&mut ui,
&[key(Named::Escape), key(Named::Escape)],
mouse::Cursor::Unavailable,
);
assert_eq!(
messages,
if notify {
vec![PickMessage::Closed]
} else {
vec![]
}
);
assert_eq!(
statuses,
[iced::event::Status::Captured, iced::event::Status::Ignored]
);
let cache = ui.into_cache();
let mut ui = UserInterface::build(
picker(selected, notify),
Size::new(400., 300.),
cache,
&mut (),
);
assert_eq!(update(&mut ui, &click, cursor).0, [PickMessage::Opened]);
let cache = ui.into_cache();
let mut ui = UserInterface::build(
picker(selected, notify),
Size::new(400., 300.),
cache,
&mut (),
);
let (outside, cursor) = press_at(iced::Point::new(350., 250.));
assert_eq!(
update(&mut ui, &outside, cursor).0,
if notify {
vec![PickMessage::Closed]
} else {
vec![]
}
);
let cache = ui.into_cache();
let mut ui = UserInterface::build(
picker(selected, notify),
Size::new(400., 300.),
cache,
&mut (),
);
let (click, cursor) = press_at(iced::Point::new(50., 15.));
assert_eq!(update(&mut ui, &click, cursor).0, [PickMessage::Opened]);
let cache = ui.into_cache();
let mut ui = UserInterface::build(
picker(selected, notify),
Size::new(400., 300.),
cache,
&mut (),
);
// Default 16px text uses 1.3 line-height: button/option height 40.8.
let (option, cursor) = press_at(iced::Point::new(50., 100.));
assert_eq!(
update(&mut ui, &option, cursor).0,
[PickMessage::Selected("Beta")]
);
let cache = ui.into_cache();
let mut ui = UserInterface::build(
picker(Some("Beta"), notify),
Size::new(400., 300.),
cache,
&mut (),
);
assert_eq!(
update(&mut ui, &[key(Named::Escape)], mouse::Cursor::Unavailable),
(vec![], vec![iced::event::Status::Ignored])
);
}
}
}
#[test]
fn initially_closed_picker_handles_open_then_escape_in_one_runtime_batch() {
for notify in [false, true] {
let mut ui = UserInterface::build(
picker(Some("Alpha"), notify),
Size::new(400., 300.),
Cache::new(),
&mut (),
);
let (mut events, cursor) = press_at(iced::Point::new(50., 15.));
events.push(key(Named::Escape));
let (messages, statuses) = update(&mut ui, &events, cursor);
assert_eq!(
messages,
if notify {
vec![PickMessage::Opened, PickMessage::Closed]
} else {
vec![PickMessage::Opened]
}
);
assert_eq!(statuses.last(), Some(&iced::event::Status::Captured));
assert_eq!(
update(&mut ui, &[key(Named::Escape)], mouse::Cursor::Unavailable),
(vec![], vec![iced::event::Status::Ignored])
);
}
}
#[test]
fn dismissed_picker_ignores_a_queued_click_in_its_old_option_area() {
let mut ui = UserInterface::build(
picker(Some("Alpha"), true),
Size::new(400., 300.),
Cache::new(),
&mut (),
);
let (click, cursor) = press_at(iced::Point::new(50., 15.));
assert_eq!(update(&mut ui, &click, cursor).0, [PickMessage::Opened]);
let cache = ui.into_cache();
let mut ui = UserInterface::build(
picker(Some("Alpha"), true),
Size::new(400., 300.),
cache,
&mut (),
);
let (click, cursor) = press_at(iced::Point::new(50., 100.));
let mut batch = vec![key(Named::Escape)];
batch.extend(click);
let (messages, _) = update(&mut ui, &batch, cursor);
assert_eq!(
messages,
[PickMessage::Closed],
"queued old-option click must not select Beta"
);
}
fn draft_with_picker(app: &App) -> Element<'_, Message, Theme, ()> {
// This composition deliberately keeps a focused input beneath the actual
// picker overlay. It tests event ownership, not a claimed production form.
root(
app,
crate::source_navigation::interaction::observe(
column![
container(
text_input("Raw draft", "Keep exact bytes 雨").on_input(|_| Message::Escape)
)
.height(40),
pick_list([Layer::Oa, Layer::Sa], Some(Layer::Oa), Message::Layer)
.width(200)
.padding(10),
]
.into(),
app.source_symbols.raw_interaction.clone(),
),
)
}
#[test]
fn picker_overlay_precedes_focused_input_and_creation_source_drafts() {
use crate::behavior::{Event as BehaviorEvent, creation, source_draft};
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_time()
.build()
.unwrap();
let _entered = runtime.enter();
for (source, same_batch) in [(false, false), (true, false), (false, true), (true, true)] {
let mut app = App::with_project(Project::empty());
let send = |app: &mut App, event| {
let _ = app.update(Message::Behavior(BehaviorEvent::Creation(event)));
};
send(&mut app, creation::Event::Open(Layer::Oa));
send(&mut app, creation::Event::Choose(creation::Family::Machine));
send(
&mut app,
creation::Event::Name("Retained machine 雨".into()),
);
if source {
let recovery = app.behavior.creation.recovery_snapshot().unwrap().unwrap();
let invocation = serde_json::from_value(recovery["invocation"].clone()).unwrap();
send(
&mut app,
creation::Event::Source(source_draft::Event::Open(invocation)),
);
let recovery = app.behavior.creation.recovery_snapshot().unwrap().unwrap();
let token =
serde_json::from_value(recovery["source"]["editing"]["token"].clone()).unwrap();
send(
&mut app,
creation::Event::Source(source_draft::Event::Edit(
token,
source_draft::Edit::Name("Uncommitted owner 雨".into()),
)),
);
assert_eq!(
app.behavior.creation.recovery_snapshot().unwrap().unwrap()["source"]["editing"]["name"],
"Uncommitted owner 雨"
);
}
let draft = app.behavior.recovery_snapshot().unwrap();
let project = app.project().clone();
let bundle = app.document.bundle().clone();
let revision = app.document.revision();
let selection = (app.selected, app.selected_node, app.active_diagram);
let camera = (app.pan, app.zoom);
let raw_epoch = app
.source_symbols
.raw_interaction
.load(std::sync::atomic::Ordering::Acquire);
let mut ui = UserInterface::build(
draft_with_picker(&app),
Size::new(500., 400.),
Cache::new(),
&mut (),
);
let event = key(Named::Escape);
let (mut click, cursor) = press_at(iced::Point::new(50., 55.));
let (mut messages, statuses) = if same_batch {
ui.operate(&(), &mut FocusInputs);
click.push(event.clone());
update(&mut ui, &click, cursor)
} else {
assert!(update(&mut ui, &click, cursor).0.is_empty());
let cache = ui.into_cache();
ui = UserInterface::build(
draft_with_picker(&app),
Size::new(500., 400.),
cache,
&mut (),
);
ui.operate(&(), &mut FocusInputs);
update(
&mut ui,
std::slice::from_ref(&event),
mouse::Cursor::Unavailable,
)
};
assert_eq!(statuses.last(), Some(&iced::event::Status::Captured));
subscriptions(&app, event, *statuses.last().unwrap(), &mut messages);
assert!(
messages.is_empty(),
"picker must not forward global modal cancellation"
);
let cache = ui.into_cache();
assert_eq!(app.behavior.recovery_snapshot().unwrap(), draft);
assert!(!crate::source_navigation::interaction::is_current(
&app.source_symbols.raw_interaction,
raw_epoch,
));
let mut ui = UserInterface::build(
draft_with_picker(&app),
Size::new(500., 400.),
cache,
&mut (),
);
let event = key(Named::Escape);
let (mut messages, statuses) = update(
&mut ui,
std::slice::from_ref(&event),
mouse::Cursor::Unavailable,
);
subscriptions(&app, event, statuses[0], &mut messages);
assert_eq!(messages.len(), 1);
assert!(matches!(messages[0], Message::Escape));
drop(ui);
let _ = app.update(messages.remove(0));
if source {
assert!(app.behavior.creation.has_pending());
assert!(!app.behavior.creation.source_open());
assert_eq!(
app.behavior.creation.recovery_snapshot().unwrap().unwrap()["name"],
"Retained machine 雨"
);
} else {
assert!(!app.behavior.creation.has_pending());
}
assert_eq!(app.project(), &project);
assert_eq!(app.document.bundle(), &bundle);
assert_eq!(app.document.revision(), revision);
assert_eq!(
(app.selected, app.selected_node, app.active_diagram),
selection
);
assert_eq!((app.pan, app.zoom), camera);
assert!(!app.document.can_undo() && !app.document.can_redo());
}
}
#[test]
fn ignored_tooltip_escape_preserves_modal_priority_and_rejects_stale_focus() {
use crate::source_navigation::interaction;
use iced::widget::{text, tooltip};
use std::sync::atomic::Ordering;
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_time()
.build()
.unwrap();
let _entered = runtime.enter();
let mut app = App::with_project(Project::empty());
app.page = Page::Text;
app.source_navigation.open = true;
let project = app.project().clone();
let source = app.document.bundle().clone();
let context = (
app.selected,
app.selected_node,
app.active_diagram,
app.pan,
app.zoom,
);
let content = || -> Element<'_, Message, Theme, ()> {
let field = text_input("Find", "Retained query 雨").on_input(|_| Message::Escape);
let tip = tooltip(field, text("Help"), tooltip::Position::Bottom)
.delay(std::time::Duration::ZERO);
root(
&app,
interaction::observe(tip.into(), app.source_symbols.raw_interaction.clone()),
)
};
let mut ui = UserInterface::build(content(), Size::new(400., 180.), Cache::new(), &mut ());
let point = iced::Point::new(10., 10.);
let cursor = mouse::Cursor::Available(point);
assert!(
update(
&mut ui,
&[Event::Mouse(mouse::Event::CursorMoved { position: point })],
cursor
)
.0
.is_empty()
);
let cache = ui.into_cache();
let mut ui = UserInterface::build(content(), Size::new(400., 180.), cache, &mut ());
ui.operate(&(), &mut FocusInputs);
let epoch = app.source_symbols.raw_interaction.load(Ordering::Acquire);
let event = key(Named::Escape);
let (mut messages, statuses) = update(&mut ui, std::slice::from_ref(&event), cursor);
assert_eq!(statuses, [iced::event::Status::Captured]);
subscriptions(&app, event, statuses[0], &mut messages);
assert_eq!(messages.len(), 1);
assert!(matches!(messages[0], Message::Escape));
assert!(!interaction::is_current(
&app.source_symbols.raw_interaction,
epoch
));
drop(ui);
let _ = app.update(messages.remove(0));
assert!(!app.source_navigation.open);
assert_eq!(app.project(), &project);
assert_eq!(app.document.bundle(), &source);
assert_eq!(
(
app.selected,
app.selected_node,
app.active_diagram,
app.pan,
app.zoom
),
context
);
assert!(!app.document.can_undo() && !app.document.can_redo());
}
#[test]
fn toggling_bridge_policy_retains_focused_child_state() {
let mut app = App::with_project(Project::empty());
let content = || -> Element<'static, Message, Theme, ()> {
text_input("Raw field", "Still focused")
.on_input(|_| Message::Escape)
.into()
};
let mut ui = UserInterface::build(
root(&app, content()),
Size::new(400., 100.),
Cache::new(),
&mut (),
);
ui.operate(&(), &mut FocusInputs);
let cache = ui.into_cache();
app.page = Page::Text;
app.source_navigation.open = true;
let mut ui = UserInterface::build(root(&app, content()), Size::new(400., 100.), cache, &mut ());
let (messages, status) = update(&mut ui, &[key(Named::Escape)], mouse::Cursor::Unavailable);
assert_eq!(status, [iced::event::Status::Captured]);
assert_eq!(messages.len(), 1);
assert!(matches!(messages[0], Message::Escape));
}
#[tokio::test]
async fn mounted_creation_name_escape_keeps_pending_modal_priority() {
use crate::behavior::canvas_workspace::tests::Mounted;
use crate::behavior::{Event as BehaviorEvent, creation};
for pending in [false, true] {
let mut app = App::with_project(Project::empty());
for event in [
creation::Event::Open(Layer::Oa),
creation::Event::Choose(creation::Family::Sequence),
creation::Event::Name("Exact unfinished process 雨".into()),
] {
let _ = app.update(Message::Behavior(BehaviorEvent::Creation(event)));
}
app.page = Page::Behavior;
let mut mounted = Mounted::new(&mut app).await;
mounted
.perform(
&mut app,
iced::widget::operation::focus(creation::NAME_INPUT),
)
.await;
if pending {
app.pending = Some(super::super::Pending::Walkthrough);
}
let before = app.document.snapshot();
let draft = app.behavior.recovery_snapshot().unwrap();
let event = key(Named::Escape);
let (mut messages, statuses) = mounted.input_with_status(
&app,
std::slice::from_ref(&event),
mouse::Cursor::Unavailable,
);
subscriptions(&app, event, statuses[0], &mut messages);
assert_eq!(
messages
.iter()
.filter(|message| matches!(message, Message::Escape))
.count(),
1
);
for message in messages {
let _ = app.update(message);
}
if pending {
assert!(app.pending.is_none());
assert_eq!(app.behavior.recovery_snapshot().unwrap(), draft);
} else {
assert!(!app.behavior.creation.has_pending());
}
assert!(std::sync::Arc::ptr_eq(&before, &app.document.snapshot()));
assert!(!app.document.can_undo() && !app.document.can_redo());
}
}
+476
View File
@@ -0,0 +1,476 @@
//! 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),
ProcessReorder(
Snapshot,
crate::behavior::topology::group_reorder::Destination,
),
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,
}
}
pub(crate) fn process_context(c: crate::behavior::topology::Context) -> Context {
Context {
scope: Scope {
document: c.document,
surface: Surface::Process(c.owner),
},
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()
}
pub(super) 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),
self.topology_context(),
self.behavior.topology.selection_address(),
)
}
pub(crate) fn multiselect_context(&self) -> Option<Context> {
match self.page {
Page::Diagram => self.route_context().map(context),
Page::Behavior => {
if let Some(current) = self.topology_context() {
return Some(process_context(current));
}
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 {
if let Some(context) = self.topology_context() {
return self
.behavior
.topology
.selection_targets(self.project(), context.owner)
.contains(target);
}
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::ProcessReorder(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) {
if self.topology_context().is_some() {
self.reconcile_process_multiselect();
return;
}
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();
}
}
}
pub(super) fn inspect_multiselect_primary(&mut self) {
if self.topology_context().is_some() {
self.inspect_process_multiselect_primary();
return;
}
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 self.topology_context().is_some() {
self.process_multiselect_event(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::ProcessReorder(..) => {}
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 =
"Use an Allocation drag to assign this selection, or arm Arrange next drag."
.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());
}
+31 -18
View File
@@ -128,6 +128,12 @@ impl App {
let resolved = oa_core::topology_context::TopologyIndex::new(self.project())
.resolve(context.owner, &endpoint);
if let Ok(resolved) = resolved {
// The definition may be opened from a nonprimary context
// target. Remember that exact invocation, not whichever
// qualified occurrence happened to be primary beforehand.
self.behavior
.topology
.select_context(resolved.address.clone());
self.behavior.topology.remember(
context.owner,
endpoint
@@ -137,27 +143,18 @@ impl App {
.unwrap_or(endpoint.occurrence),
resolved.defining_sequence,
);
self.behavior.topology.clear_context();
self.behavior.active_owner = Some(resolved.defining_sequence);
self.select(endpoint.occurrence);
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
}
}
CanvasEvent::SelectQualified(_, endpoint) => {
if !self.behavior.has_pending()
&& !self.draft_dirty()
&& !self.source_dirty()
&& let Ok(resolved) =
oa_core::topology_context::TopologyIndex::new(self.project())
.resolve(context.owner, &endpoint)
{
let reference = endpoint
.references
.first()
.copied()
.unwrap_or(endpoint.occurrence);
self.select(reference);
self.behavior.topology.select_context(resolved.address);
}
self.multiselect_event(multiselect::Event::Choose(
multiselect::process_context(context),
crate::diagram_usage::Target::ProcessOccurrence(endpoint),
multiselect::Choice::Replace,
));
}
CanvasEvent::Select(_, id) => {
if !self.behavior.has_pending()
@@ -198,6 +195,9 @@ impl App {
if let Some(target) =
topology::referenced(self.project(), context.owner, occurrence)
{
// Opening a local card returns to that local use, even if
// a qualified tile was the previous inspection target.
self.behavior.topology.clear_context();
if self
.project()
.elements
@@ -222,8 +222,20 @@ impl App {
self.behavior.topology = state;
if let Some((owner, occurrence)) = back {
self.behavior.active_owner = Some(owner);
self.select(occurrence);
// Back restores a use, whereas ordinary select navigates
// to a defining object. The validated return address owns
// the exact subject; never infer it from the outer card ID.
self.selected = self
.behavior
.topology
.selection_address()
.filter(|address| address.owner == owner)
.map_or(occurrence, |address| address.endpoint.occurrence);
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
// Synchronize the exact primary and inspector before the
// returned caller is drawn or accepts property edits.
self.reconcile_process_multiselect();
self.inspect_process_multiselect_primary();
}
}
mutation => {
@@ -293,8 +305,9 @@ impl App {
occurrence, before, ..
} => {
match topology::reorder(self.project(), context.owner, occurrence, before) {
Ok(command) => {
if self.execute(command) {
Ok(topology::group_reorder::Plan::Noop) => {}
Ok(topology::group_reorder::Plan::Change(command)) => {
if self.execute(*command) {
self.selected = occurrence;
self.refresh_draft();
return self.reveal_process_selection();
@@ -0,0 +1,260 @@
//! Exact Process presentation selection and one atomic order transaction.
use super::*;
use crate::behavior::topology::group_reorder::{Plan, Prepared};
use crate::diagram_selection::Selection;
use crate::diagram_usage::{Surface, Target};
use multiselect::{Choice, Event};
fn subject(target: &Target) -> Option<Id> {
match target {
Target::ProcessOccurrence(endpoint) => Some(endpoint.occurrence),
Target::ProcessLink(id) | Target::ProcessPreviewLink { relation: id, .. } => Some(*id),
_ => None,
}
}
impl App {
pub(crate) fn process_preview_primary(&self) -> Option<&Target> {
let context = self.topology_context()?;
let selection = self.diagram_selection.as_ref()?;
if selection.scope() != multiselect::process_context(context).scope {
return None;
}
selection
.primary()
.filter(|target| subject(target) == Some(self.selected))
.filter(|target| match target {
Target::ProcessOccurrence(endpoint) => !endpoint.references.is_empty(),
Target::ProcessPreviewLink { .. } => true,
_ => false,
})
}
pub(super) fn reconcile_process_multiselect(&mut self) {
let Some(context) = self.topology_context() else {
return;
};
let current = multiselect::process_context(context);
let visible = self
.behavior
.topology
.selection_targets(self.project(), context.owner);
let old = self
.diagram_selection
.as_ref()
.filter(|s| s.scope() == current.scope)
.and_then(Selection::primary)
.cloned();
let address = self.behavior.topology.selection_address();
let focused = self
.diagram_focus(Surface::Process(context.owner))
.map(|usage| usage.target.clone());
let legacy = focused
.clone()
.or_else(|| {
address
.as_ref()
.filter(|a| a.owner == context.owner && a.endpoint.occurrence == self.selected)
.map(|a| Target::ProcessOccurrence(a.endpoint.clone()))
})
.or_else(|| {
visible
.iter()
.find(|target| match target {
Target::ProcessOccurrence(endpoint) => {
endpoint.references.is_empty() && endpoint.occurrence == self.selected
}
Target::ProcessLink(id) => *id == self.selected,
_ => false,
})
.cloned()
})
.filter(|target| visible.contains(target));
let held = old.as_ref().is_some_and(|target| {
subject(target) == Some(self.selected)
&& focused.as_ref().is_none_or(|focused| focused == target)
&& match target {
Target::ProcessOccurrence(endpoint) if !endpoint.references.is_empty() => {
address
.as_ref()
.is_some_and(|a| a.owner == context.owner && a.endpoint == *endpoint)
}
_ => true,
}
});
let lost = old.as_ref().is_some_and(|target| !visible.contains(target));
let selection = self
.diagram_selection
.get_or_insert_with(|| Selection::new(current.scope));
selection.reconcile(current.scope, |target| visible.contains(target));
if held || lost {
if selection.primary() != old.as_ref() {
self.inspect_process_multiselect_primary();
}
} else if selection.primary() != legacy.as_ref() {
if let Some(target) = legacy {
selection.replace(target, |t| visible.contains(t));
} else {
selection.clear();
}
}
if self.process_preview_primary().is_some() {
self.draft = None;
}
}
pub(super) fn inspect_process_multiselect_primary(&mut self) {
let Some(context) = self.topology_context() else {
return;
};
let primary = self
.diagram_selection
.as_ref()
.and_then(Selection::primary)
.cloned();
self.behavior.topology.clear_context();
self.selected = primary.as_ref().and_then(subject).unwrap_or(context.owner);
self.selected_node = None;
self.selected_bend = None;
self.selected_attachment = None;
self.diagram_navigation_focus = None;
if let Some(Target::ProcessOccurrence(endpoint)) = primary
&& !endpoint.references.is_empty()
&& let Ok(resolved) = oa_core::topology_context::TopologyIndex::new(self.project())
.resolve(context.owner, &endpoint)
{
self.behavior.topology.select_context(resolved.address);
}
self.refresh_draft();
if self.process_preview_primary().is_some() {
self.draft = None;
}
}
pub(super) fn process_multiselect_event(&mut self, event: Event) {
let Some(context) = self.topology_context() else {
return;
};
if let Event::Refuse(_, reason) = &event
&& diagram_removal::enabled(self)
{
self.status = reason.clone();
return;
}
if !diagram_removal::enabled(self) || !self.behavior.topology.selecting() {
self.status =
"Finish or cancel the active edit before changing the process selection.".into();
return;
}
let visible = self
.behavior
.topology
.selection_targets(self.project(), context.owner);
match event {
Event::Choose(_, target, choice) => {
if !visible.contains(&target) {
return;
}
self.reconcile_process_multiselect();
let selection = self.diagram_selection.as_mut().unwrap();
let valid = |t: &Target| visible.contains(t);
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_process_multiselect_primary();
}
Event::Collapse(_, target) => {
if !visible.contains(&target) {
return;
}
self.diagram_selection
.as_mut()
.unwrap()
.replace(target, |t| visible.contains(t));
self.inspect_process_multiselect_primary();
}
Event::Clear(_) => {
self.diagram_selection.as_mut().unwrap().clear();
self.inspect_process_multiselect_primary();
}
Event::Refuse(_, reason) => self.status = reason,
Event::Hide(_) => {
self.status =
"Process cards are model contributions, not placements. Hide is unavailable."
.into()
}
Event::Move(..) => {
self.status =
"Process positions come from their paths. Reorder within one column instead."
.into()
}
Event::ProcessReorder(snapshot, destination) => {
if !snapshot
.targets()
.iter()
.all(|target| visible.contains(target))
{
return;
}
match Prepared::new(self.project(), context.owner, snapshot.targets())
.and_then(|prepared| prepared.plan(destination))
{
Ok(Plan::Noop) => {}
Ok(Plan::Change(command)) => {
if self.execute(*command) {
self.inspect_process_multiselect_primary();
self.status = format!(
"Reordered {} contributions · Undo restores their order",
snapshot.targets().len()
);
}
}
Err(error) => self.status = error,
}
}
}
}
/// A no-op must not retire a pending reveal or allocate a history entry.
pub(super) fn process_order_noop(&self, message: &Message) -> bool {
let Some(context) = self.topology_context() else {
return false;
};
match message {
Message::DiagramSelection(Event::ProcessReorder(snapshot, destination)) => {
Prepared::new(self.project(), context.owner, snapshot.targets())
.and_then(|prepared| prepared.plan(*destination))
.is_ok_and(|p| matches!(p, Plan::Noop))
}
Message::TopologyCanvas(crate::behavior::topology::CanvasEvent::Reorder {
context: captured,
occurrence,
before,
}) if *captured == context => crate::behavior::topology::reorder(
self.project(),
context.owner,
*occurrence,
*before,
)
.is_ok_and(|p| matches!(p, Plan::Noop)),
_ => false,
}
}
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,979 @@
//! Process selection through the real App admission, document and source boundary.
use super::*;
use crate::behavior::topology::group_reorder::Destination;
use crate::behavior::topology::{self, CanvasEvent, CanvasTool};
use oa_core::OccurrenceEndpoint;
struct Fixture {
app: App,
owner: Id,
nodes: [Id; 5],
link: Id,
work: Id,
other_owner: Id,
}
fn local(id: Id) -> Target {
Target::ProcessOccurrence(OccurrenceEndpoint {
references: vec![],
occurrence: id,
})
}
fn add(project: &mut Project, owner: Id, name: &str, body: OccurrenceBody) -> Id {
let mut element = project
.make_element(
Kind::Occurrence,
project.elements[&owner].layer(),
name,
Some(owner),
)
.unwrap();
let Some(SharedBody::Occurrence { body: value, .. }) = element.shared_mut() else {
unreachable!()
};
*value = body;
let id = element.id;
*project = execute_batch(project, &[Command::CreateElement(element)]).unwrap();
id
}
fn open(project: Project, owner: Id) -> App {
let mut app = App::with_project(project);
let _ = app.update(Message::Select(owner));
let _ = app.update(Message::Page(Page::Behavior));
app.reconcile_multiselect();
assert_eq!(app.topology_context().unwrap().owner, owner);
assert_eq!(
app.multiselect_context().unwrap().scope.surface,
Surface::Process(owner)
);
app
}
fn fixture(layer: Layer) -> Fixture {
let (f, _) = topology::direct_tests::empty_exchange_fixture(layer);
let mut project = f.p;
let mut nodes = vec![];
for name in ["a", "u", "b", "v", "c"] {
nodes.push(add(
&mut project,
f.owner,
name,
OccurrenceBody::Action {
action: Some(f.work[0]),
lifeline: None,
},
));
}
let nodes: [Id; 5] = nodes.try_into().unwrap();
let [a, u, b, v, _] = nodes;
let link = project.relate(RelationKind::SequenceLink, "a to u", a, u);
project.relate(RelationKind::SequenceLink, "b to v", b, v);
let other = project
.make_element(
if layer == Layer::Oa {
Kind::Process
} else {
Kind::FunctionalChain
},
layer,
"Other owner",
None,
)
.unwrap();
let other_owner = other.id;
project = execute_batch(&project, &[Command::CreateElement(other)]).unwrap();
validate_candidate(&project).unwrap();
Fixture {
app: open(project, f.owner),
owner: f.owner,
nodes,
link,
work: f.work[0],
other_owner,
}
}
fn send(app: &mut App, event: Event) {
let _ = app.update(Message::DiagramSelection(event));
}
fn choose(app: &mut App, target: Target, choice: Choice) {
let context = app.multiselect_context().unwrap();
send(app, Event::Choose(context, target, choice));
}
fn pair(f: &mut Fixture) {
choose(&mut f.app, local(f.nodes[4]), Choice::Replace);
choose(&mut f.app, local(f.nodes[0]), Choice::Add);
assert_eq!(
f.app.multiselect_snapshot().unwrap().targets(),
&[local(f.nodes[4]), local(f.nodes[0])]
);
}
fn source_matches(app: &App) {
let compiled = source_project::compile_bundle(app.document.bundle(), LIMITS).unwrap();
assert_eq!(&compiled.decoded.project, app.project());
assert_eq!(&compiled.bundle, app.document.bundle());
assert_eq!(
source_workspace::reachable(&app.source_workspace.bundle()).unwrap(),
*app.document.bundle()
);
}
fn marker(app: &mut App, owner: Id, occurrence: Id) {
use crate::diagram_usage as usage;
let target = usage::collect(
app.project(),
occurrence,
&app.behavior.topology,
&app.behavior.scenario,
)
.into_iter()
.find(|u| u.surface == Surface::Process(owner) && u.target == local(occurrence))
.unwrap();
let context = usage::Context {
document: app.canvas_document,
revision: app.document.revision(),
subject: occurrence,
};
app.pending_diagram_reveal = Some((context, target.clone(), app.canvas_epoch));
app.diagram_navigation_focus = Some(usage::Focus {
context,
usage: target,
selected: app.selected,
});
assert!(app.diagram_focus(Surface::Process(owner)).is_some());
}
#[test]
fn process_group_reverse_click_order_changes_only_owner_steps_and_undoes_once() {
for layer in [Layer::Oa, Layer::Sa] {
let mut f = fixture(layer);
let [a, u, b, v, c] = f.nodes;
pair(&mut f);
let before = f.app.document.snapshot();
let selection = f.app.multiselect_snapshot().unwrap();
assert_eq!(selection.primary(), Some(&local(a)));
send(
&mut f.app,
Event::ProcessReorder(selection.clone(), Destination::After(b)),
);
let mut expected = before.decoded.project.clone();
*expected
.elements
.get_mut(&f.owner)
.unwrap()
.steps_mut()
.unwrap() = vec![b, u, a, v, c];
assert_eq!(f.app.project(), &expected);
assert_eq!(f.app.selected, a);
let after_selection = f.app.multiselect_snapshot().unwrap();
assert_eq!(after_selection.targets(), selection.targets());
assert_eq!(after_selection.primary(), selection.primary());
source_matches(&f.app);
let after = f.app.document.snapshot();
let reopened = Document::from_bundle(after.bundle.clone(), LIMITS).unwrap();
assert_eq!(reopened.project(), &expected);
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());
source_matches(&f.app);
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());
source_matches(&f.app);
}
}
#[test]
fn process_noop_group_and_legacy_reorder_preserve_history_camera_and_pending_reveal() {
for group in [true, false] {
let mut f = fixture(Layer::Oa);
if group {
pair(&mut f);
} else {
choose(&mut f.app, local(f.nodes[0]), Choice::Replace);
}
marker(&mut f.app, f.owner, f.nodes[0]);
let before = f.app.document.snapshot();
let selection = f.app.multiselect_snapshot().unwrap();
let pending = f.app.pending_diagram_reveal.clone();
let camera = f.app.behavior.topology.camera_boundary();
let epochs = (f.app.canvas_epoch, f.app.camera_epoch);
f.app.status = "Unchanged order feedback".into();
let message = if group {
Message::DiagramSelection(Event::ProcessReorder(
selection.clone(),
Destination::Before(f.nodes[4]),
))
} else {
Message::TopologyCanvas(CanvasEvent::Reorder {
context: f.app.topology_context().unwrap(),
occurrence: f.nodes[0],
before: f.nodes[2],
})
};
let _ = f.app.update(message);
assert!(Arc::ptr_eq(&before, &f.app.document.snapshot()));
assert_eq!(f.app.multiselect_snapshot().unwrap(), selection);
assert_eq!(f.app.pending_diagram_reveal, pending);
assert!(f.app.behavior.topology.camera_boundary() == camera);
assert_eq!((f.app.canvas_epoch, f.app.camera_epoch), epochs);
assert_eq!(f.app.status, "Unchanged order feedback");
assert!(!f.app.document.can_undo());
assert!(!f.app.document.can_redo());
}
}
#[test]
fn process_mixed_link_and_cross_column_groups_refuse_whole_operation() {
for layer in [Layer::Oa, Layer::Sa] {
for mixed in [true, false] {
let mut f = fixture(layer);
choose(&mut f.app, local(f.nodes[0]), Choice::Replace);
let other = if mixed {
Target::ProcessLink(f.link)
} else {
local(f.nodes[1])
};
choose(&mut f.app, other.clone(), Choice::Add);
let selection = f.app.multiselect_snapshot().unwrap();
assert_eq!(selection.targets(), &[local(f.nodes[0]), other]);
let before = f.app.document.snapshot();
send(
&mut f.app,
Event::ProcessReorder(selection.clone(), Destination::After(f.nodes[2])),
);
assert!(Arc::ptr_eq(&before, &f.app.document.snapshot()));
assert_eq!(f.app.multiselect_snapshot().unwrap(), selection);
assert!(!f.app.document.can_undo());
source_matches(&f.app);
}
}
}
#[test]
fn process_stale_document_revision_owner_epoch_and_selection_aba_reject_before_shared_hooks() {
for stale_by in ["document", "revision", "owner", "epoch", "ABA"] {
let mut f = fixture(Layer::Oa);
pair(&mut f);
let stale = f.app.multiselect_snapshot().unwrap();
match stale_by {
"document" => f.app.canvas_document = Id::new_v4(),
"revision" => {
f.app
.document
.execute(Command::Describe(f.work, "New revision".into()))
.unwrap();
}
"owner" => {
f.app.behavior.active_owner = Some(f.other_owner);
f.app.selected = f.other_owner;
f.app.reconcile_multiselect();
}
"epoch" => f.app.canvas_epoch += 1,
_ => {
choose(&mut f.app, local(f.nodes[0]), Choice::Toggle);
choose(&mut f.app, local(f.nodes[0]), Choice::Toggle);
assert_eq!(
f.app.multiselect_snapshot().unwrap().targets(),
stale.targets()
);
assert_eq!(
f.app.multiselect_snapshot().unwrap().primary(),
stale.primary()
);
}
}
let before = f.app.document.snapshot();
let selection = f.app.multiselect_snapshot();
let camera = f.app.behavior.topology.camera_boundary();
let selected = f.app.selected;
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());
f.app.status = "Current owner feedback".into();
f.app.allocation_drag.arrange_next = true;
send(
&mut f.app,
Event::ProcessReorder(stale, Destination::After(f.nodes[2])),
);
assert!(
Arc::ptr_eq(&before, &f.app.document.snapshot()),
"{stale_by}"
);
assert_eq!(f.app.multiselect_snapshot(), selection);
assert_eq!(f.app.selected, selected);
assert!(f.app.behavior.topology.camera_boundary() == camera);
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 owner feedback");
assert!(f.app.allocation_drag.arrange_next);
}
}
#[test]
fn process_pending_properties_source_form_tool_and_busy_never_implicitly_apply_or_change_group() {
for pending in [
"properties",
"invalid properties",
"source",
"form",
"tool",
"busy",
] {
let mut f = fixture(Layer::Oa);
pair(&mut f);
match pending {
"properties" => {
let _ = f.app.update(Message::Field(
"/name".into(),
"Unapplied contribution".into(),
));
}
"invalid properties" => {
let _ = f
.app
.update(Message::Field("/alias".into(), "invalid alias!".into()));
}
"source" => {
let text = format!("{}\n// Unapplied source\n", f.app.source_workspace.text());
let _ = f
.app
.update(Message::SourceEdit(text_editor::Action::SelectAll));
let _ = f.app.update(Message::SourceEdit(text_editor::Action::Edit(
text_editor::Edit::Paste(Arc::new(text)),
)));
}
"form" => {
let context = f.app.topology_context().unwrap();
let _ = f.app.update(Message::TopologyCanvas(CanvasEvent::Begin {
context,
tool: topology::Tool::Work,
control: ControlKind::Choice,
insertion: topology::Insertion::default(),
}));
let _ = f.app.update(Message::Behavior(behavior::Event::Topology(
topology::Event::Name("Unsubmitted form".into()),
)));
assert!(f.app.behavior.has_pending());
}
"tool" => f.app.behavior.topology.choose(CanvasTool::Connect),
_ => f.app.busy = true,
}
if pending == "source" {
assert!(f.app.source_dirty());
}
if pending.contains("properties") {
assert!(f.app.draft_dirty());
}
let selection = f.app.multiselect_snapshot().unwrap();
let before = f.app.document.snapshot();
let source = f.app.source_workspace.bundle();
let draft = f
.app
.draft
.as_ref()
.map(|d| serde_json::to_value(d.recovery_snapshot()).unwrap());
let form = f.app.behavior.recovery_snapshot();
for event in [
Event::ProcessReorder(selection.clone(), Destination::After(f.nodes[2])),
Event::Clear(selection.context()),
Event::Choose(selection.context(), local(f.nodes[2]), Choice::Replace),
] {
send(&mut f.app, event);
assert!(
Arc::ptr_eq(&before, &f.app.document.snapshot()),
"{pending}"
);
assert_eq!(f.app.source_workspace.bundle(), source);
assert_eq!(
f.app
.draft
.as_ref()
.map(|d| serde_json::to_value(d.recovery_snapshot()).unwrap()),
draft
);
assert_eq!(f.app.behavior.recovery_snapshot(), form);
assert_eq!(f.app.multiselect_snapshot().unwrap(), selection);
assert!(!f.app.document.can_undo());
}
}
}
#[test]
fn process_queued_singleton_selection_remove_reorder_and_reconnect_cannot_retire_current_group() {
let mut f = fixture(Layer::Oa);
pair(&mut f);
marker(&mut f.app, f.owner, f.nodes[0]);
let context = f.app.topology_context().unwrap();
let before = f.app.document.snapshot();
let selection = f.app.multiselect_snapshot().unwrap();
let pending = f.app.pending_diagram_reveal.clone();
let camera = f.app.behavior.topology.camera_boundary();
let epochs = (
f.app.canvas_epoch,
f.app.camera_epoch,
f.app.allocation_drag.epoch,
);
f.app.status = "Group owns input".into();
for event in [
CanvasEvent::Select(context, f.nodes[2]),
CanvasEvent::SelectQualified(
context,
OccurrenceEndpoint {
references: vec![],
occurrence: f.nodes[2],
},
),
CanvasEvent::Remove(context, f.nodes[0]),
CanvasEvent::Reorder {
context,
occurrence: f.nodes[0],
before: f.nodes[4],
},
CanvasEvent::Reconnect {
context,
link: f.link,
end: topology::End::Target,
target: f.nodes[4],
},
] {
let _ = f.app.update(Message::TopologyCanvas(event));
assert!(Arc::ptr_eq(&before, &f.app.document.snapshot()));
assert_eq!(f.app.multiselect_snapshot().unwrap(), selection);
assert_eq!(f.app.pending_diagram_reveal, pending);
assert!(f.app.behavior.topology.camera_boundary() == camera);
assert_eq!(
(
f.app.canvas_epoch,
f.app.camera_epoch,
f.app.allocation_drag.epoch
),
epochs
);
assert_eq!(f.app.status, "Group owns input");
assert!(!f.app.delete_pending);
assert!(!f.app.document.can_undo());
}
}
fn qualified_fixture() -> (Fixture, Id, Id, Id, Id) {
let mut f = fixture(Layer::Oa);
let mut project = f.app.project().clone();
let sequence = project
.make_element(Kind::Process, Layer::Oa, "Reusable checks", None)
.unwrap();
let sequence_id = sequence.id;
project = execute_batch(&project, &[Command::CreateElement(sequence)]).unwrap();
// Collapsed reuse cards still show four preview entries. Keep the tested
// leaf beyond that bound so collapsing really removes its exact target.
for index in 0..4 {
add(
&mut project,
sequence_id,
&format!("Preview prefix {index}"),
OccurrenceBody::Action {
action: Some(f.work),
lifeline: None,
},
);
}
let leaf = add(
&mut project,
sequence_id,
"Shared check",
OccurrenceBody::Action {
action: Some(f.work),
lifeline: None,
},
);
let first = add(
&mut project,
f.owner,
"First call",
OccurrenceBody::SequenceReference {
sequence: Some(sequence_id),
},
);
let second = add(
&mut project,
f.owner,
"Second call",
OccurrenceBody::SequenceReference {
sequence: Some(sequence_id),
},
);
f.app = open(project, f.owner);
let p = f.app.project().clone();
assert!(f.app.behavior.topology.expand(&p, f.owner, first));
assert!(f.app.behavior.topology.expand(&p, f.owner, second));
(f, sequence_id, first, second, leaf)
}
#[test]
fn process_qualified_primary_is_exact_readonly_and_clears_without_semantic_rebinding() {
let (mut f, _, first, second, leaf) = qualified_fixture();
let p = f.app.project().clone();
let endpoint = OccurrenceEndpoint {
references: vec![first],
occurrence: leaf,
};
let target = Target::ProcessOccurrence(endpoint.clone());
assert!(f.app.multiselect_target_visible(&target));
choose(&mut f.app, target.clone(), Choice::Replace);
assert_eq!(
f.app.selected, leaf,
"Inspect exact leaf, not the first caller or shared work"
);
assert_eq!(f.app.process_preview_primary(), Some(&target));
assert_eq!(
f.app
.behavior
.topology
.selection_address()
.unwrap()
.endpoint,
endpoint
);
assert!(f.app.draft.is_none());
let before = f.app.document.snapshot();
for message in [
Message::Field("/name".into(), "Forbidden definition rename".into()),
Message::Apply,
Message::Delete,
] {
let _ = f.app.update(message);
assert!(Arc::ptr_eq(&before, &f.app.document.snapshot()));
assert!(f.app.draft.is_none());
assert!(!f.app.delete_pending);
assert_eq!(f.app.process_preview_primary(), Some(&target));
}
choose(&mut f.app, local(f.nodes[0]), Choice::Add);
let mixed = f.app.multiselect_snapshot().unwrap();
send(
&mut f.app,
Event::ProcessReorder(mixed.clone(), Destination::After(f.nodes[2])),
);
assert!(Arc::ptr_eq(&before, &f.app.document.snapshot()));
assert_eq!(f.app.multiselect_snapshot().unwrap(), mixed);
choose(&mut f.app, target.clone(), Choice::Replace);
let current = f.app.multiselect_context().unwrap();
send(&mut f.app, Event::Clear(current));
assert!(f.app.multiselect_snapshot().unwrap().targets().is_empty());
assert_eq!(f.app.selected, f.owner);
assert!(f.app.behavior.topology.selection_address().is_none());
choose(&mut f.app, target, Choice::Replace);
assert!(f.app.behavior.topology.expand(&p, f.owner, first));
f.app.reconcile_multiselect();
assert!(f.app.multiselect_snapshot().unwrap().targets().is_empty());
assert_eq!(f.app.selected, f.owner);
assert!(f.app.behavior.topology.selection_address().is_none());
assert!(f.app.process_preview_primary().is_none());
assert!(
f.app
.multiselect_target_visible(&Target::ProcessOccurrence(OccurrenceEndpoint {
references: vec![second],
occurrence: leaf
}))
);
assert!(Arc::ptr_eq(&before, &f.app.document.snapshot()));
}
fn menu(app: &mut App, target: Target, label: &str) -> Message {
use crate::context_menu::Entry;
use crate::model_menu;
let object = match &target {
Target::ProcessOccurrence(endpoint) => endpoint.occurrence,
_ => panic!("This fixture opens contribution menus"),
};
let mut request = model_menu::Request::object(object, iced::Point::new(180., 150.));
request.process = app.topology_context().map(Box::new);
request.selection_target = Some(Box::new(target));
let _ = app.update(Message::Menu(model_menu::Event::Open(request)));
let opened = app
.model_menu
.as_ref()
.expect("Captured Process menu must survive the open epoch change");
assert!(opened.valid(app));
assert_eq!(
opened.request.process.as_deref().copied(),
app.topology_context()
);
model_menu::popup(app)
.unwrap()
.entries
.into_iter()
.find_map(|entry| match entry {
Entry::Action {
label: name,
message: Some(message),
..
} if name == label => Some(message),
_ => None,
})
.unwrap_or_else(|| panic!("Missing enabled menu action: {label}"))
}
#[test]
fn process_menu_add_uses_rebound_epoch_and_preserves_existing_primary_membership() {
let mut f = fixture(Layer::Oa);
choose(&mut f.app, local(f.nodes[0]), Choice::Replace);
let before = f.app.document.snapshot();
let old_context = f.app.topology_context().unwrap();
let action = menu(&mut f.app, local(f.nodes[4]), "Add to selection");
assert_ne!(f.app.topology_context().unwrap().epoch, old_context.epoch);
assert_eq!(f.app.selected, f.nodes[0]);
let _ = f.app.update(action);
let selection = f.app.multiselect_snapshot().unwrap();
assert_eq!(selection.targets(), &[local(f.nodes[0]), local(f.nodes[4])]);
assert_eq!(selection.primary(), Some(&local(f.nodes[4])));
assert_eq!(f.app.selected, f.nodes[4]);
assert!(Arc::ptr_eq(&before, &f.app.document.snapshot()));
assert!(!f.app.document.can_undo());
}
#[test]
fn process_qualified_nonprimary_menu_opens_definition_from_caller_and_back_returns() {
let (mut f, definition, first, _, leaf) = qualified_fixture();
choose(&mut f.app, local(f.nodes[0]), Choice::Replace);
let before = f.app.document.snapshot();
let target = Target::ProcessOccurrence(OccurrenceEndpoint {
references: vec![first],
occurrence: leaf,
});
let action = menu(&mut f.app, target, "Open referenced definition");
assert_eq!(f.app.selected, f.nodes[0]);
assert_eq!(f.app.topology_context().unwrap().owner, f.owner);
let _ = f.app.update(action);
assert_eq!(f.app.behavior.active_owner, Some(definition));
assert_eq!(f.app.topology_context().unwrap().owner, definition);
let context = f.app.topology_context().unwrap();
let _ = f
.app
.update(Message::TopologyCanvas(CanvasEvent::Back(context)));
assert_eq!(f.app.behavior.active_owner, Some(f.owner));
assert_eq!(f.app.topology_context().unwrap().owner, f.owner);
assert!(Arc::ptr_eq(&before, &f.app.document.snapshot()));
assert!(!f.app.document.can_undo());
}
#[test]
fn process_qualified_definition_back_restores_exact_invocation_readonly_subject_and_camera() {
for open_nonprimary in [false, true] {
let (mut f, definition, first, second, leaf) = qualified_fixture();
// The shared fixture has already expanded both fifth-tile invocations.
let context = f.app.topology_context().unwrap();
let _ = f.app.update(Message::TopologyCanvas(CanvasEvent::Zoom(
context,
Some(0.75),
)));
let first_target = Target::ProcessOccurrence(OccurrenceEndpoint {
references: vec![first],
occurrence: leaf,
});
let second_target = Target::ProcessOccurrence(OccurrenceEndpoint {
references: vec![second],
occurrence: leaf,
});
if open_nonprimary {
// Keep the requested second path outside the current selection so
// its ordinary definition action is available in its context menu.
choose(&mut f.app, local(f.nodes[0]), Choice::Replace);
choose(&mut f.app, first_target.clone(), Choice::Add);
} else {
choose(&mut f.app, first_target.clone(), Choice::Replace);
choose(&mut f.app, second_target.clone(), Choice::Add);
}
assert_eq!(f.app.multiselect_snapshot().unwrap().targets().len(), 2);
assert_eq!(
f.app.multiselect_snapshot().unwrap().primary(),
Some(if open_nonprimary {
&first_target
} else {
&second_target
})
);
assert_eq!(f.app.selected, leaf);
assert!(f.app.draft.is_none());
let original = f.app.document.snapshot();
let source = f.app.document.bundle().clone();
let camera = f.app.behavior.topology.camera_boundary();
let target = second_target;
let action = if open_nonprimary {
menu(&mut f.app, target.clone(), "Open referenced definition")
} else {
let Target::ProcessOccurrence(endpoint) = &target else {
unreachable!()
};
Message::TopologyCanvas(CanvasEvent::OpenQualified(
f.app.topology_context().unwrap(),
endpoint.clone(),
))
};
let _ = f.app.update(action);
assert_eq!(f.app.topology_context().unwrap().owner, definition);
assert_eq!(f.app.selected, leaf);
assert!(f.app.process_preview_primary().is_none());
assert!(f.app.draft.is_some(), "Definition view is editable");
let context = f.app.topology_context().unwrap();
let _ = f
.app
.update(Message::TopologyCanvas(CanvasEvent::Back(context)));
assert_eq!(f.app.topology_context().unwrap().owner, f.owner);
assert_eq!(f.app.behavior.active_owner, Some(f.owner));
assert_eq!(
f.app.multiselect_snapshot().unwrap().targets(),
std::slice::from_ref(&target)
);
assert_eq!(
f.app.multiselect_snapshot().unwrap().primary(),
Some(&target)
);
assert_eq!(
f.app.selected, leaf,
"Back must not inspect the outer reference"
);
assert_eq!(f.app.process_preview_primary(), Some(&target));
let Target::ProcessOccurrence(endpoint) = &target else {
unreachable!()
};
let address = f.app.behavior.topology.selection_address().unwrap();
assert_eq!(address.owner, f.owner);
assert_eq!(&address.endpoint, endpoint);
assert!(
f.app.multiselect_target_visible(&target),
"Caller expansions survive Back"
);
assert!(
f.app.draft.is_none(),
"Returned qualified inspector stays read-only"
);
assert!(f.app.behavior.topology.camera_boundary() == camera);
let _ = f.app.update(Message::Field(
"/name".into(),
"Must not edit the outer reference or definition".into(),
));
assert!(f.app.draft.is_none());
assert_eq!(f.app.process_preview_primary(), Some(&target));
assert!(Arc::ptr_eq(&original, &f.app.document.snapshot()));
assert_eq!(f.app.document.bundle(), &source);
assert!(!f.app.document.can_undo() && !f.app.document.can_redo());
source_matches(&f.app);
}
}
#[test]
fn process_open_current_qualified_primary_and_catalog_navigation_restore_editable_draft() {
for catalog in [false, true] {
let (mut f, definition, first, _, leaf) = qualified_fixture();
let target = Target::ProcessOccurrence(OccurrenceEndpoint {
references: vec![first],
occurrence: leaf,
});
choose(&mut f.app, target.clone(), Choice::Replace);
assert_eq!(f.app.selected, leaf);
assert!(f.app.draft.is_none());
let before = f.app.document.snapshot();
if catalog {
let _ = f.app.update(Message::Page(Page::Catalog));
assert_eq!(f.app.page, Page::Catalog);
} else {
let action = menu(&mut f.app, target, "Open referenced definition");
let _ = f.app.update(action);
assert_eq!(f.app.topology_context().unwrap().owner, definition);
}
assert_eq!(f.app.selected, leaf);
assert!(f.app.process_preview_primary().is_none());
assert!(
f.app.draft.is_some(),
"Leaving a read-only presentation restores normal property editing"
);
let _ = f.app.update(Message::Field(
"/name".into(),
"Definition edit stays unapplied".into(),
));
assert!(f.app.draft_dirty());
assert!(Arc::ptr_eq(&before, &f.app.document.snapshot()));
assert!(!f.app.document.can_undo());
}
}
#[test]
fn process_explicit_object_navigation_leaves_qualified_use_even_for_same_leaf_id() {
for edit_properties in [false, true] {
for defining_leaf in [false, true] {
let (mut f, definition, first, _, leaf) = qualified_fixture();
let qualified = Target::ProcessOccurrence(OccurrenceEndpoint {
references: vec![first],
occurrence: leaf,
});
choose(&mut f.app, qualified.clone(), Choice::Replace);
assert_eq!(f.app.selected, leaf);
assert_eq!(f.app.process_preview_primary(), Some(&qualified));
assert!(f.app.draft.is_none());
let before = f.app.document.snapshot();
let source = f.app.document.bundle().clone();
let camera = f.app.behavior.topology.camera_boundary();
let target = if defining_leaf { leaf } else { first };
let expected_owner = if defining_leaf { definition } else { f.owner };
let message = if edit_properties {
Message::EditProperties(target)
} else {
Message::Select(target)
};
let _ = f.app.update(message);
assert_eq!(f.app.selected, target);
assert_eq!(f.app.topology_context().unwrap().owner, expected_owner);
assert_eq!(f.app.behavior.active_owner, Some(expected_owner));
assert_eq!(
f.app.multiselect_snapshot().unwrap().targets(),
&[local(target)]
);
assert_eq!(
f.app.multiselect_snapshot().unwrap().primary(),
Some(&local(target))
);
assert!(f.app.behavior.topology.selection_address().is_none());
assert!(f.app.process_preview_primary().is_none());
assert!(
f.app.draft.is_some(),
"Explicit model-object navigation restores its editor"
);
assert!(!f.app.draft_dirty());
if !defining_leaf {
assert!(f.app.behavior.topology.camera_boundary() == camera);
}
assert!(Arc::ptr_eq(&before, &f.app.document.snapshot()));
assert_eq!(f.app.document.bundle(), &source);
assert!(!f.app.document.can_undo() && !f.app.document.can_redo());
source_matches(&f.app);
}
}
}
#[test]
fn process_open_local_reference_after_qualified_inspection_returns_local_reference() {
let (mut f, definition, first, _, leaf) = qualified_fixture();
choose(
&mut f.app,
Target::ProcessOccurrence(OccurrenceEndpoint {
references: vec![first],
occurrence: leaf,
}),
Choice::Replace,
);
let before = f.app.document.snapshot();
let source = f.app.document.bundle().clone();
let camera = f.app.behavior.topology.camera_boundary();
let context = f.app.topology_context().unwrap();
let _ = f
.app
.update(Message::TopologyCanvas(CanvasEvent::Open(context, first)));
assert_eq!(f.app.topology_context().unwrap().owner, definition);
assert_eq!(f.app.selected, definition);
assert!(f.app.behavior.topology.selection_address().is_none());
let context = f.app.topology_context().unwrap();
let _ = f
.app
.update(Message::TopologyCanvas(CanvasEvent::Back(context)));
assert_eq!(f.app.topology_context().unwrap().owner, f.owner);
assert_eq!(f.app.selected, first);
assert_eq!(
f.app.multiselect_snapshot().unwrap().targets(),
&[local(first)]
);
assert!(f.app.behavior.topology.selection_address().is_none());
assert!(f.app.process_preview_primary().is_none());
assert!(f.app.draft.is_some());
assert!(f.app.behavior.topology.camera_boundary() == camera);
assert!(Arc::ptr_eq(&before, &f.app.document.snapshot()));
assert_eq!(f.app.document.bundle(), &source);
assert!(!f.app.document.can_undo() && !f.app.document.can_redo());
source_matches(&f.app);
}
#[test]
fn process_usage_navigation_between_two_preview_paths_keeps_exact_link_identity() {
use crate::diagram_usage as usage;
let (mut f, definition, first, second, leaf) = qualified_fixture();
let mut project = f.app.project().clone();
let next = add(
&mut project,
definition,
"Following check",
OccurrenceBody::Action {
action: Some(f.work),
lifeline: None,
},
);
let relation = project.relate(RelationKind::SequenceLink, "Reusable path", leaf, next);
f.app = open(project, f.owner);
let project = f.app.project().clone();
assert!(f.app.behavior.topology.expand(&project, f.owner, first));
assert!(f.app.behavior.topology.expand(&project, f.owner, second));
let before = f.app.document.snapshot();
for call in [first, second] {
let target = Target::ProcessPreviewLink {
relation,
source: OccurrenceEndpoint {
references: vec![call],
occurrence: leaf,
},
target: OccurrenceEndpoint {
references: vec![call],
occurrence: next,
},
};
let context = usage::Context {
document: f.app.canvas_document,
revision: f.app.document.revision(),
subject: relation,
};
let _ = f
.app
.update(Message::DiagramUsage(usage::Event::Open(context)));
let token = f.app.diagram_usages.as_ref().unwrap().token;
let rows = usage::collect(
f.app.project(),
relation,
&f.app.behavior.topology,
&f.app.behavior.scenario,
);
let row = rows
.iter()
.find(|row| row.surface == Surface::Process(f.owner) && row.target == target)
.unwrap()
.clone();
let _ = f.app.update(Message::DiagramUsagesReady(usage::Completion {
token,
context,
result: Ok(rows),
}));
let _ = f
.app
.update(Message::DiagramUsage(usage::Event::Choose(token, row)));
assert_eq!(f.app.selected, relation);
assert_eq!(
f.app.multiselect_snapshot().unwrap().primary(),
Some(&target)
);
assert_eq!(f.app.process_preview_primary(), Some(&target));
assert!(f.app.draft.is_none());
assert!(Arc::ptr_eq(&before, &f.app.document.snapshot()));
}
}
@@ -754,7 +754,76 @@ fn invalid_open_retains_accepted_document_but_refuses_alias_rename() {
}
fn captured_escape(app: &App) -> Vec<Message> {
crate::app::escape::tests::focused_input_messages(app)
use iced::advanced::{Layout, Shell, layout, subscription, widget};
use iced::futures::{StreamExt, stream, task::noop_waker_ref};
use iced::keyboard::{self, key::Named};
use std::task::{Context as PollContext, Poll};
let mut input: iced::Element<'_, (), iced::Theme, ()> =
iced::widget::text_input("Source alias", "retained_alias")
.on_input(|_| ())
.into();
let mut tree = widget::Tree::new(input.as_widget());
let size = iced::Size::new(300., 40.);
let node =
input
.as_widget_mut()
.layout(&mut tree, &(), &layout::Limits::new(iced::Size::ZERO, size));
tree.state
.downcast_mut::<iced::widget::text_input::State<()>>()
.focus();
let event = iced::Event::Keyboard(keyboard::Event::KeyPressed {
key: keyboard::Key::Named(Named::Escape),
modified_key: keyboard::Key::Named(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 mut widget_messages = Vec::new();
let mut shell = Shell::new(&mut widget_messages);
input.as_widget_mut().update(
&mut tree,
&event,
Layout::new(&node),
iced::mouse::Cursor::Unavailable,
&(),
&mut iced::advanced::clipboard::Null,
&mut shell,
&iced::Rectangle::new(iced::Point::ORIGIN, size),
);
assert!(shell.is_event_captured());
let event = subscription::Event::Interaction {
window: iced::window::Id::unique(),
event,
status: iced::event::Status::Captured,
};
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_time()
.build()
.unwrap();
let _entered = runtime.enter();
let mut messages = Vec::new();
let mut keyboard_boundaries = 0;
for recipe in subscription::into_recipes(app.subscription()) {
let mut output = recipe.stream(Box::pin(stream::iter([event.clone()])));
let mut context = PollContext::from_waker(noop_waker_ref());
while let Poll::Ready(Some(message)) = output.poll_next_unpin(&mut context) {
if matches!(
&message,
Message::WorkDrag(crate::app::allocation_drag::Event::KeyboardBoundary)
) {
keyboard_boundaries += 1;
} else {
messages.push(message);
}
}
}
assert_eq!(
keyboard_boundaries, 1,
"each key has exactly one passive allocation notification"
);
messages
}
#[test]
@@ -864,11 +864,99 @@ fn captured_input_subscription_messages(app: &App) -> Vec<Message> {
}
fn input_subscription_messages(app: &App, focused: bool) -> Vec<Message> {
crate::app::escape::tests::input_messages(app, iced::keyboard::key::Named::Escape, focused)
use iced::advanced::{Layout, Shell, layout, subscription, widget};
use iced::futures::{StreamExt, stream, task::noop_waker_ref};
use iced::keyboard::{self, key::Named};
use std::task::{Context, Poll};
let mut input: iced::Element<'_, (), iced::Theme, ()> =
iced::widget::text_input("Find / modal input", "Retain exact text 雨")
.on_input(|_| ())
.into();
let mut tree = widget::Tree::new(input.as_widget());
let size = iced::Size::new(300., 40.);
let node =
input
.as_widget_mut()
.layout(&mut tree, &(), &layout::Limits::new(iced::Size::ZERO, size));
if focused {
tree.state
.downcast_mut::<iced::widget::text_input::State<()>>()
.focus();
}
let event = iced::Event::Keyboard(keyboard::Event::KeyPressed {
key: keyboard::Key::Named(Named::Escape),
modified_key: keyboard::Key::Named(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 mut input_messages = Vec::new();
let mut shell = Shell::new(&mut input_messages);
input.as_widget_mut().update(
&mut tree,
&event,
Layout::new(&node),
iced::mouse::Cursor::Unavailable,
&(),
&mut iced::advanced::clipboard::Null,
&mut shell,
&iced::Rectangle::new(iced::Point::ORIGIN, size),
);
let captured = shell.is_event_captured();
assert_eq!(captured, focused);
assert!(input_messages.is_empty());
assert!(
!tree
.state
.downcast_ref::<iced::widget::text_input::State<()>>()
.is_focused()
);
let event = subscription::Event::Interaction {
window: iced::window::Id::unique(),
event,
status: if captured {
iced::event::Status::Captured
} else {
iced::event::Status::Ignored
},
};
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_time()
.build()
.unwrap();
let _entered = runtime.enter();
let mut messages = Vec::new();
let mut keyboard_boundaries = 0;
for recipe in subscription::into_recipes(app.subscription()) {
let mut output = recipe.stream(Box::pin(stream::iter([event.clone()])));
let mut context = Context::from_waker(noop_waker_ref());
while let Poll::Ready(Some(message)) = output.poll_next_unpin(&mut context) {
assert!(
!matches!(message, Message::CheckpointTick),
"timer must remain pending"
);
if matches!(
&message,
Message::WorkDrag(crate::app::allocation_drag::Event::KeyboardBoundary)
) {
keyboard_boundaries += 1;
} else {
messages.push(message);
}
}
}
assert_eq!(
keyboard_boundaries, 1,
"each key has exactly one passive allocation notification"
);
messages
}
#[test]
fn captured_find_and_modal_escape_use_one_actual_delivery_and_preserve_pending_work() {
fn captured_find_and_modal_escape_use_one_actual_subscription_and_preserve_pending_work() {
for pending in [false, true] {
for modal in 0..4 {
let mut app = fixture();
@@ -965,7 +1053,7 @@ fn captured_find_and_modal_escape_use_one_actual_delivery_and_preserve_pending_w
}
#[test]
fn captured_delivery_does_not_duplicate_existing_behavior_or_architecture_routes() {
fn captured_subscription_does_not_duplicate_existing_behavior_or_architecture_routes() {
let mut app = fixture();
app.text_workspace.show_drawer(Some(DrawerTab::References));
let project = app.project().clone();
@@ -53,6 +53,7 @@ async fn armed() -> (App, Mounted, Id) {
context,
object: relation,
placement: None,
selection_target: None,
anchor: iced::Point::new(450., 400.),
bend: None,
route_segment: None,
@@ -79,6 +79,7 @@ fn state_bend_menu_carries_exact_segment_and_pin_without_changing_delete_action(
context,
object: relation,
placement: None,
selection_target: None,
anchor: iced::Point::ORIGIN,
bend: Some(1),
route_segment: Some((2, point)),
@@ -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,6 +157,7 @@ impl App {
context,
object,
placement,
selection_target,
anchor,
bend,
route_segment,
@@ -163,6 +165,7 @@ impl App {
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)));
@@ -4,8 +4,6 @@ use super::*;
mod attachment_escape;
#[path = "state_bend_tests.rs"]
mod bends;
#[path = "state_picker_escape_tests.rs"]
mod picker_escape;
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
@@ -1,105 +0,0 @@
//! The actual State workspace picker, App root and subscriptions.
use super::*;
use crate::behavior::canvas_workspace::tests::{Mounted, key};
use iced::{keyboard::key::Named, mouse};
async fn press_escape(mounted: &mut Mounted, app: &mut App) -> Vec<Message> {
let input = key(Named::Escape);
let (mut messages, statuses) = mounted.input_with_status(
app,
std::slice::from_ref(&input),
mouse::Cursor::Unavailable,
);
crate::app::escape::tests::subscriptions(app, input, statuses[0], &mut messages);
for message in messages.iter().cloned() {
let task = app.update(message);
mounted.perform(app, task).await;
}
mounted.settle(app).await;
messages
}
#[tokio::test]
async fn mounted_state_missing_picker_escape_preserves_exact_document_then_reopens_and_places() {
let mut f = fixture();
let diagram = create_view(&mut f).diagram;
let mut project = f.app.project().clone();
project
.diagrams
.get_mut(&diagram)
.unwrap()
.nodes
.retain(|_, node| node.element != f.b);
let mut app = App::with_project(project);
app.window_size = iced::Size::new(1800., 1100.);
send(&mut app, Message::Select(f.a));
send(&mut app, Message::Page(Page::Behavior));
let choice = Choice {
id: diagram,
label: app.project().diagrams[&diagram].name.clone(),
};
send(&mut app, Message::BehaviorDiagram(choice));
let mut mounted = Mounted::new(&mut app).await;
assert_eq!(state_edit::unplaced(app.project(), diagram), vec![f.b]);
let focus_id = iced::advanced::widget::Id::from("diagram-keyboard-owner");
let (_, canvas, _) = mounted
.geometry(&app)
.focused
.into_iter()
.find(|(id, _, _)| id.as_ref() == Some(&focus_id))
.unwrap();
// The existing picker is immediately above the measured canvas, separated
// by 8px. Its 12px text and 8px padding give a 31.6px row.
let picker = iced::Point::new(canvas.center_x(), canvas.y - 24.);
let option = iced::Point::new(picker.x, canvas.y + 8.);
let before = app.document.snapshot();
let original = app.project().clone();
let source = app.document.bundle().clone();
let selection = (
app.selected,
app.selected_node,
app.behavior.diagram.placement,
);
let camera = (
app.behavior.diagram.zoom,
app.behavior.diagram.pan,
app.behavior.diagram.fit,
);
let revision = app.document.revision();
let history = (app.document.can_undo(), app.document.can_redo());
mounted.click(&mut app, picker).await;
assert!(
press_escape(&mut mounted, &mut app).await.is_empty(),
"open picker owns Escape, not the State canvas or App"
);
assert!(std::sync::Arc::ptr_eq(&before, &app.document.snapshot()));
assert_eq!(app.document.bundle(), &source);
assert_eq!(
(
app.selected,
app.selected_node,
app.behavior.diagram.placement
),
selection
);
assert_eq!(
(
app.behavior.diagram.zoom,
app.behavior.diagram.pan,
app.behavior.diagram.fit
),
camera
);
assert_eq!(app.document.revision(), revision);
assert_eq!((app.document.can_undo(), app.document.can_redo()), history);
// If Escape left the menu open this click would close it, and the following
// option click would fail to place. This checks actual overlay dismissal.
mounted.click(&mut app, picker).await;
let messages = mounted.click(&mut app, option).await;
assert!(messages.iter().any(|message| matches!(message, Message::StateDiagram(Event::Place { element, .. }) if *element == f.b)));
assert!(state_edit::unplaced(app.project(), diagram).is_empty());
assert_eq!(app.project().elements, original.elements);
assert_eq!(app.project().relations, original.relations);
let _ = app.update(Message::Undo);
assert_eq!(app.document.bundle(), &source);
}
@@ -1,277 +0,0 @@
use super::*;
use crate::{context_menu::Entry, model_menu, views::traceability::Filter};
fn send(app: &mut App, message: Message) {
let _ = app.update(message);
}
fn fixture() -> (App, Id, Id, Id, Id) {
let mut p = Project::empty();
let oa = p
.add_element(Kind::Activity, Layer::Oa, "Observe", None)
.unwrap();
let sa = p
.add_element(Kind::Function, Layer::Sa, "Detect", None)
.unwrap();
let mixed = p.ensure_diagram(Layer::Oa);
p.diagrams.get_mut(&mixed).unwrap().projection = ProjectionKind::Traceability;
p.place(mixed, oa, Position { x: 300., y: 200. });
let system = p.ensure_diagram(Layer::Sa);
p.place(system, sa, Position { x: 700., y: 200. });
oa_core::validate_candidate(&p).unwrap();
let mut app = App::with_project(p);
app.page = Page::Diagram;
app.layer = Layer::Oa;
app.active_diagram = Some(mixed);
app.selected = oa;
app.refresh_draft();
app.traceability.filter = Filter::Artifacts;
app.traceability.query = "stale artifact query".into();
(app, mixed, system, oa, sa)
}
fn show_action(app: &mut App, element: Id) -> Message {
send(app, model_menu::open(element, iced::Point::ORIGIN));
model_menu::popup(app)
.unwrap()
.entries
.into_iter()
.find_map(|entry| match entry {
Entry::Action { label, message, .. } if label == "Show in this diagram" => {
Some(message)
}
_ => None,
})
.expect("eligible real menu action")
.expect("eligible menu action must be enabled")
}
#[test]
fn review_layer_traces_resets_inventory_without_applying_property_or_source_drafts() {
for source_draft in [false, true] {
let (mut app, mixed, _, oa, _) = fixture();
if source_draft {
send(&mut app, Message::Page(Page::Text));
send(
&mut app,
Message::SourceEdit(iced::widget::text_editor::Action::Edit(
iced::widget::text_editor::Edit::Insert('x'),
)),
);
assert!(app.source_dirty());
} else {
send(
&mut app,
Message::Field("/alias".into(), "invalid alias".into()),
);
assert!(app.draft_dirty());
}
let raw = serde_json::to_value(app.draft.as_ref().unwrap().recovery_snapshot()).unwrap();
let source = app.source_editor.text();
let before = app.document.snapshot();
let camera = (app.zoom, app.pan);
send(&mut app, Message::ReviewLayerTraces);
assert_eq!(app.page, Page::Traces);
assert_eq!(app.traceability.filter, Filter::Traces);
assert!(app.traceability.query.is_empty());
assert_eq!(
(app.layer, app.active_diagram, app.selected),
(Layer::Oa, Some(mixed), oa)
);
assert_eq!((app.zoom, app.pan), camera);
assert_eq!(
serde_json::to_value(app.draft.as_ref().unwrap().recovery_snapshot()).unwrap(),
raw
);
assert_eq!(app.source_editor.text(), source);
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
assert!(!app.document.can_undo());
}
}
#[test]
fn review_layer_traces_respects_busy_modal_and_active_placement_guards() {
for already_traces in [false, true] {
for guard in 0..5 {
let (mut app, mixed, _, oa, _) = fixture();
match guard {
0 => app.busy = true,
1 => app.delete_pending = true,
2 => app.hosted.visible = true,
3 => send(
&mut app,
Message::Architecture(architecture_authoring::Event::Existing(None)),
),
4 => {
app.page = Page::Behavior;
let p = app.project().clone();
app.behavior
.creation
.update(&p, behavior::creation::Event::Open(Layer::Oa))
.unwrap();
assert!(app.behavior.has_pending());
}
_ => unreachable!(),
}
if already_traces {
app.page = Page::Traces;
}
let original_page = app.page;
let before = app.document.snapshot();
let behavior_before = app.behavior.recovery_snapshot().unwrap();
send(&mut app, Message::ReviewLayerTraces);
assert_eq!(
app.page, original_page,
"guard {guard}, already traces {already_traces}"
);
assert_eq!(app.traceability.filter, Filter::Artifacts);
assert_eq!(app.traceability.query, "stale artifact query");
assert_eq!(
(app.layer, app.active_diagram, app.selected),
(Layer::Oa, Some(mixed), oa)
);
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
assert_eq!(app.behavior.recovery_snapshot().unwrap(), behavior_before);
if guard == 3 {
assert!(app.architecture.is_some());
}
}
}
}
#[test]
fn mixed_view_show_keeps_its_context_for_every_diagram_app_and_target_layer() {
for diagram_layer in [Layer::Oa, Layer::Sa] {
for app_layer in [Layer::Oa, Layer::Sa] {
for target_layer in [Layer::Oa, Layer::Sa] {
let mut p = Project::empty();
let oa = p
.add_element(Kind::Activity, Layer::Oa, "Observe", None)
.unwrap();
let sa = p
.add_element(Kind::Function, Layer::Sa, "Detect", None)
.unwrap();
let target = if target_layer == Layer::Oa { oa } else { sa };
let other = if target_layer == Layer::Oa { sa } else { oa };
let mixed = p.ensure_diagram(diagram_layer);
p.diagrams.get_mut(&mixed).unwrap().projection = ProjectionKind::Traceability;
// An attractive same-layer alternative makes accidental
// semantic-selection navigation observable in both directions.
let mut alternative = p.diagrams[&mixed].clone();
alternative.id = Id::new_v4();
alternative.alias = "same_layer_alternative".into();
alternative.layer = target_layer;
alternative.projection = ProjectionKind::Overview;
let alternative_id = alternative.id;
p.diagrams.insert(alternative_id, alternative);
p.place(alternative_id, target, Position { x: 700., y: 200. });
oa_core::validate_candidate(&p).unwrap();
let mut app = App::with_project(p);
app.page = Page::Diagram;
app.layer = app_layer;
app.active_diagram = Some(mixed);
app.selected = other;
app.refresh_draft();
let before = app.project().clone();
let action = show_action(&mut app, target);
send(&mut app, action);
// The existing RevealInDiagram action synchronizes the layer
// to the captured view, even if the prior perspective differed.
assert_eq!(
(app.active_diagram, app.layer, app.selected),
(Some(mixed), diagram_layer, target)
);
let added = app.project().diagrams[&mixed]
.nodes
.values()
.find(|n| n.element == target)
.unwrap()
.clone();
assert_eq!(app.selected_node, Some(added.id));
let mut expected = before;
expected
.diagrams
.get_mut(&mixed)
.unwrap()
.nodes
.insert(added.id, added);
assert_eq!(app.project(), &expected);
}
}
}
}
#[test]
fn mixed_view_show_menu_places_in_captured_view_with_exact_undo_redo() {
let (mut app, mixed, system, _, sa) = fixture();
let before = app.document.snapshot();
let action = show_action(&mut app, sa);
send(&mut app, action);
assert_eq!(
(app.active_diagram, app.layer, app.selected),
(Some(mixed), Layer::Oa, sa)
);
let added = app.project().diagrams[&mixed]
.nodes
.values()
.find(|n| n.element == sa)
.unwrap()
.clone();
assert_eq!(added.position, Position { x: 60., y: 60. });
let mut expected = before.decoded.project.clone();
expected
.diagrams
.get_mut(&mixed)
.unwrap()
.nodes
.insert(added.id, added);
assert_eq!(app.project(), &expected);
assert_eq!(
app.project().diagrams[&system],
before.decoded.project.diagrams[&system]
);
let after = app.document.snapshot();
send(&mut app, Message::Undo);
assert_eq!(app.project(), &before.decoded.project);
assert_eq!(app.document.bundle(), &before.bundle);
assert!(!app.document.can_undo());
send(&mut app, Message::Redo);
assert_eq!(app.project(), &after.decoded.project);
assert_eq!(app.document.bundle(), &after.bundle);
}
#[test]
fn mixed_view_show_menu_rejects_stale_context_and_pending_property_changes() {
for refusal in 0..3 {
let (mut app, mixed, system, oa, sa) = fixture();
let action = show_action(&mut app, sa);
match refusal {
0 => app.active_diagram = Some(system),
1 => app.canvas_epoch = app.canvas_epoch.wrapping_add(1),
2 => {
// A queued action must not apply a draft that became dirty
// after the menu was rendered, even when its context is valid.
app.draft
.as_mut()
.unwrap()
.input("/name", "Unapplied name".into());
assert!(app.draft_dirty());
}
_ => unreachable!(),
}
let before = app.document.snapshot();
let raw = serde_json::to_value(app.draft.as_ref().unwrap().recovery_snapshot()).unwrap();
send(&mut app, action);
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
assert_eq!(app.selected, oa);
assert_eq!(
app.active_diagram,
Some(if refusal == 0 { system } else { mixed })
);
assert_eq!(
serde_json::to_value(app.draft.as_ref().unwrap().recovery_snapshot()).unwrap(),
raw
);
assert!(!app.document.can_undo());
}
}
@@ -15,6 +15,7 @@ struct Input {
pan: Option<(mouse::Button, Point)>,
space: bool,
bounds: Option<Rectangle>,
modifiers: keyboard::Modifiers,
}
impl operation::Focusable for Input {
fn is_focused(&self) -> bool {
@@ -83,7 +84,11 @@ impl<Message, R: renderer::Renderer> Widget<Message, Theme, R> for Viewport<'_,
(s.document, s.owner, s.revision)
!= (self.scope.document, self.scope.owner, self.scope.revision)
}) {
*state = Input::default();
let modifiers = state.modifiers;
*state = Input {
modifiers,
..Input::default()
};
}
if state
.scope
@@ -143,10 +148,29 @@ impl<Message, R: renderer::Renderer> Widget<Message, Theme, R> for Viewport<'_,
shell: &mut Shell<'_, Message>,
viewport: &Rectangle,
) {
let state = tree.state.downcast_mut::<Input>();
if let Event::Keyboard(keyboard::Event::ModifiersChanged(modifiers)) = event {
// Physical modifiers outlive focus, busy-state child replacement,
// and semantic revisions. Observing these emits no camera command.
state.modifiers = *modifiers;
self.content.as_widget_mut().update(
&mut tree.children[0],
event,
layout,
cursor,
renderer,
clipboard,
shell,
viewport,
);
return;
}
if matches!(event, Event::Window(iced::window::Event::Unfocused)) {
state.modifiers = keyboard::Modifiers::default();
}
if shell.is_event_captured() {
return;
}
let state = tree.state.downcast_mut::<Input>();
// A traversal operation is applied after this Iced event batch. Keep
// its focus witness, but retire keyboard admission immediately.
if state.focus_pending && matches!(event, Event::Keyboard(_)) {
@@ -334,6 +358,20 @@ impl<Message, R: renderer::Renderer> Widget<Message, Theme, R> for Viewport<'_,
} else {
mouse::Cursor::Unavailable
};
if matches!(event, Event::Mouse(mouse::Event::ButtonPressed(_))) {
// A canvas may have been rebuilt while focus/commands were denied.
// Restore held (or released) Shift before this new pointer gesture.
self.content.as_widget_mut().update(
&mut tree.children[0],
&Event::Keyboard(keyboard::Event::ModifiersChanged(state.modifiers)),
layout,
child_cursor,
renderer,
clipboard,
shell,
viewport,
);
}
self.content.as_widget_mut().update(
&mut tree.children[0],
event,
@@ -10,6 +10,32 @@ use serde::{Deserialize, Serialize};
const PAGE_SIZE: usize = 6;
pub const NAME_INPUT: &str = "behavior-creation-name";
// A focused text input consumes Escape. Preserve the application's modal
// priority by emitting its ordinary Escape message from this narrow bridge.
pub fn subscription(active: bool) -> iced::Subscription<()> {
if active {
iced::event::listen_with(captured_escape)
} else {
iced::Subscription::none()
}
}
fn captured_escape(
event: iced::Event,
status: iced::event::Status,
_: iced::window::Id,
) -> Option<()> {
(status == iced::event::Status::Captured
&& matches!(
event,
iced::Event::Keyboard(iced::keyboard::Event::KeyPressed {
key: iced::keyboard::Key::Named(iced::keyboard::key::Named::Escape),
..
})
))
.then_some(())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum Family {
Sequence,
@@ -18,11 +18,59 @@ fn begin(app: &mut App, layer: Layer, family: Family, name: &str) {
send(app, Event::Name(name.into()));
}
fn focused_escape(app: &App) -> Message {
let mut messages = crate::app::escape::tests::focused_input_messages(app);
assert_eq!(messages.len(), 1);
assert!(matches!(messages[0], Message::Escape));
messages.remove(0)
fn focused_escape() -> Message {
use iced::advanced::{Layout, Shell, layout, widget};
use iced::keyboard::{self, key::Named};
let mut input: iced::Element<'_, (), iced::Theme, ()> = text_input("Name", "Unfinished name")
.id(NAME_INPUT)
.on_input(|_| ())
.into();
let mut tree = widget::Tree::new(input.as_widget());
let size = iced::Size::new(300., 40.);
let node =
input
.as_widget_mut()
.layout(&mut tree, &(), &layout::Limits::new(iced::Size::ZERO, size));
tree.state
.downcast_mut::<iced::widget::text_input::State<()>>()
.focus();
let event = iced::Event::Keyboard(keyboard::Event::KeyPressed {
key: keyboard::Key::Named(Named::Escape),
modified_key: keyboard::Key::Named(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 mut messages = Vec::new();
let mut shell = Shell::new(&mut messages);
input.as_widget_mut().update(
&mut tree,
&event,
Layout::new(&node),
iced::mouse::Cursor::Unavailable,
&(),
&mut iced::advanced::clipboard::Null,
&mut shell,
&iced::Rectangle::new(iced::Point::ORIGIN, size),
);
assert!(shell.is_event_captured());
assert_eq!(
captured_escape(
event.clone(),
iced::event::Status::Ignored,
iced::window::Id::unique()
),
None
);
captured_escape(
event,
iced::event::Status::Captured,
iced::window::Id::unique(),
)
.unwrap();
Message::Escape
}
#[test]
@@ -46,12 +94,12 @@ fn app_first_captured_escape_dismisses_file_or_pending_overlay_before_creation()
} else {
app.pending = Some(Pending::Walkthrough);
}
let _ = app.update(focused_escape(&app));
let _ = app.update(focused_escape());
assert!(app.file_prompt.is_none());
assert!(app.pending.is_none());
assert_eq!(app.behavior.recovery_snapshot().unwrap(), raw);
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
let _ = app.update(focused_escape(&app));
let _ = app.update(focused_escape());
assert!(!app.behavior.has_pending());
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
assert!(!app.document.can_undo());
@@ -3,6 +3,58 @@ use crate::behavior::canvas_workspace::tests::{Geometry, Mounted};
use iced::Vector;
use iced::advanced::{Layout, layout, widget};
#[tokio::test]
async fn production_scenario_passive_modifiers_do_not_acquire_keyboard_focus_or_mutate_model() {
use crate::behavior::canvas_workspace::tests::{focused, key};
let f = super::super::direct_tests::fixture();
let mut app = App::with_project(f.project.clone());
let _ = app.update(Message::Select(f.owner));
let _ = app.update(Message::Page(crate::app::Page::Behavior));
app.window_size = Size::new(1100., 700.);
let mut mounted = Mounted::new(&mut app).await;
let bounds = mounted.geometry(&app).bounds("scenario-viewport");
let point = bounds.position() + Vector::new(50., 50.);
let camera = app.behavior.scenario.camera_boundary();
let source = app.document.bundle().clone();
let shift = |held| {
iced::Event::Keyboard(iced::keyboard::Event::ModifiersChanged(if held {
iced::keyboard::Modifiers::SHIFT
} else {
iced::keyboard::Modifiers::empty()
}))
};
for busy in [false, true, false] {
app.busy = busy;
mounted.settle(&mut app).await;
let messages = mounted
.batch(
&mut app,
&[
shift(true),
shift(false),
key(iced::keyboard::key::Named::ArrowRight),
],
mouse::Cursor::Available(point),
)
.await;
assert!(
!messages
.iter()
.any(|message| matches!(message, Message::ScenarioCanvas(_)))
);
assert!(!focused(&mut mounted, &app, "viewport"));
assert!(app.behavior.scenario.camera_boundary() == camera);
assert_eq!(app.document.bundle(), &source);
}
mounted
.pan(&mut app, point, point + Vector::new(-15., 25.))
.await;
assert!(app.behavior.scenario.camera_boundary() != camera);
assert_eq!(app.document.bundle(), &source);
assert!(!app.document.can_undo());
assert_eq!(mounted.geometry(&app).bounds("scenario-viewport"), bounds);
}
#[tokio::test]
async fn production_scenario_canvas_keeps_bounds_draft_identity_and_camera_through_editing() {
use crate::behavior::canvas_workspace::{self, tests::Mounted};
@@ -391,7 +391,7 @@ fn state_bends_context_menu_distinguishes_authored_pin_from_computed_segment() {
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 })
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);
@@ -399,7 +399,7 @@ fn state_bends_context_menu_distinguishes_authored_pin_from_computed_segment() {
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)) })
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)
);
}
+473 -27
View File
@@ -1,5 +1,11 @@
//! Persisted state-diagram geometry with context-bound, ordered pointer gestures.
use super::state_edit::{self, Context, Endpoint, Event, Tool};
use crate::app::multiselect::{Choice, Event as SelectionEvent, context as selection_context};
use crate::diagram::group_gesture::{
DisplacementPolicy, Gesture as GroupGesture, Outcome, Viewport,
};
use crate::diagram_selection::{Selection, Snapshot};
use crate::diagram_usage::Target;
use crate::style;
use iced::{
Color, Point, Rectangle, Renderer, Size, Theme, Vector, keyboard, mouse,
@@ -27,6 +33,7 @@ pub struct StateCanvas<'a> {
pub diagram: Id,
pub context: Context,
pub selected: Id,
pub selection: Option<&'a Selection>,
/// Presentation-only navigation marker; never an authoring placement.
pub navigation_focus: Option<Rectangle>,
pub tool: Tool,
@@ -41,6 +48,8 @@ pub struct State {
tool: Option<Tool>,
pointer: Option<Point>,
press: Option<Press>,
group: Option<GroupPress>,
modifiers: keyboard::Modifiers,
source: Option<Id>,
preview: Option<(Id, Position)>,
attachment_preview: Option<AttachmentPreview>,
@@ -113,6 +122,7 @@ struct Scene {
}
struct CachedScene {
context: Context,
group_preview: Option<(Snapshot, Position)>,
preview: Option<(Id, Position)>,
attachment_preview: Option<AttachmentPreview>,
bend_preview: Option<EdgeRoute>,
@@ -121,6 +131,8 @@ struct CachedScene {
#[derive(PartialEq)]
struct PaintKey {
context: Context,
group_preview: Option<(Snapshot, Position)>,
selection: Option<Snapshot>,
preview: Option<(Id, Position)>,
attachment_preview: Option<AttachmentPreview>,
bend_preview: Option<EdgeRoute>,
@@ -132,12 +144,82 @@ struct PaintKey {
enum Hit {
Node { placement: Id, element: Id },
Transition(Id),
StateAction(Id),
Row { placement: Id, relation: Id },
}
impl Hit {
fn target(self) -> Target {
match self {
Self::Node { placement, .. } => Target::Placement(placement),
Self::Transition(id) => Target::Edge(id),
Self::Row {
placement,
relation,
} => Target::StateRow {
placement,
relation,
},
}
}
}
/// Unsupported mixtures retain the same click/cancel lifecycle as placement groups.
struct GroupPress {
snapshot: Snapshot,
target: Target,
start: Point,
viewport: Viewport,
movement: Option<GroupGesture>,
crossed: bool,
retired: bool,
}
impl GroupPress {
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;
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<SelectionEvent> {
self.move_to(pointer);
if self.retired {
return None;
}
if !self.crossed {
return Some(SelectionEvent::Collapse(self.snapshot, self.target));
}
let Some(movement) = self.movement else {
return Some(SelectionEvent::Refuse(self.snapshot,
"Move requires only placed elements. Remove rows, ports and transitions from the selection.".into()));
};
match movement.release(pointer)? {
Outcome::Click(id) => Some(SelectionEvent::Collapse(
self.snapshot,
Target::Placement(id),
)),
Outcome::Move(snapshot, delta) => Some(SelectionEvent::Move(snapshot, delta)),
}
}
}
impl State {
fn cancel_edit(&mut self) {
self.press = None;
self.group = None;
self.preview = None;
self.attachment_preview = None;
self.bend = None;
@@ -485,6 +567,32 @@ pub(crate) fn visible_row_relation(
})
}
/// Validate this occurrence, including the relation's current source. A surviving
/// relation reassigned to another State must not rebound an old row selection.
pub(crate) fn visible_row_target(
project: &Project,
diagram: Id,
view: &ProjectedView,
placement: Id,
relation: Id,
) -> bool {
visible_row_relation(project, diagram, view, relation)
&& view
.nodes
.iter()
.find(|node| node.id == placement)
.is_some_and(|node| {
project
.relations
.get(&relation)
.is_some_and(|row| row.source() == node.element)
&& state_action_rows(project, node)
.into_iter()
.chain(internal_rows(project, node))
.any(|(row, _)| row.id == relation)
})
}
fn state_action_label(project: &Project, relation: &Relation) -> String {
let RelationBody::StateAction { target, phase, .. } = relation.body else {
return String::new();
@@ -663,6 +771,210 @@ pub(crate) fn diagram_usages(
}
impl StateCanvas<'_> {
fn current_selection(&self) -> Option<&Selection> {
self.selection
.filter(|s| s.scope() == selection_context(self.context).scope)
}
fn group_viewport(&self, bounds: Rectangle) -> Viewport {
Viewport {
zoom: self.scale(),
pan: self.pan,
bounds: Rectangle::new(
Point::new(bounds.x, bounds.y + GUIDANCE_HEIGHT),
Self::content(bounds).size(),
),
}
}
fn group_current(&self, group: &GroupPress, bounds: Option<Rectangle>) -> bool {
!group.retired
&& self.editable
&& self.tool == Tool::Select
&& group.viewport.zoom == self.scale()
&& group.viewport.pan == self.pan
&& bounds.is_none_or(|b| group.viewport == self.group_viewport(b))
&& self
.current_selection()
.is_some_and(|s| s.matches(&group.snapshot, selection_context(self.context)))
}
fn group_preview_key(
&self,
state: &State,
bounds: Option<Rectangle>,
) -> Option<(Snapshot, Position)> {
let group = state.group.as_ref()?;
if !self.group_current(group, bounds) || !group.crossed {
return None;
}
Some((group.snapshot.clone(), group.movement.as_ref()?.delta()))
}
fn group_input(
&self,
state: &mut State,
event: &canvas::Event,
bounds: Rectangle,
cursor: mouse::Cursor,
) -> Option<Action<Event>> {
if let Some(group) = &state.group {
let cancelled = matches!(
event,
canvas::Event::Mouse(
mouse::Event::CursorLeft
| mouse::Event::WheelScrolled { .. }
| mouse::Event::ButtonPressed(mouse::Button::Right | mouse::Button::Middle)
) | canvas::Event::Keyboard(keyboard::Event::KeyPressed {
key: keyboard::Key::Named(keyboard::key::Named::Escape),
..
})
);
if !self.group_current(group, Some(bounds)) || cancelled {
state.cancel_edit();
return Some(Action::request_redraw().and_capture());
}
}
if let Some(group) = &mut state.group {
match event {
canvas::Event::Mouse(mouse::Event::CursorMoved { position }) => {
group.move_to(*position);
return Some(Action::request_redraw().and_capture());
}
canvas::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => {
let group = state.group.take().unwrap();
return Some(
cursor
.position()
.and_then(|p| group.release(p))
.map_or_else(
|| Action::request_redraw().and_capture(),
|event| Action::publish(Event::Selection(event)).and_capture(),
),
);
}
canvas::Event::Keyboard(_)
| canvas::Event::Mouse(mouse::Event::ButtonPressed(_)) => {
return Some(Action::capture());
}
_ => {}
}
}
let selection = self.current_selection()?;
if !self.editable || self.tool != Tool::Select || state.pan_from.is_some() {
return None;
}
if let canvas::Event::Keyboard(keyboard::Event::KeyPressed { key, modifiers, .. }) = event {
use keyboard::{Key, key::Named};
if *key == Key::Named(Named::Escape) && state.press.is_none() && state.source.is_none()
{
return Some(
Action::publish(Event::Selection(SelectionEvent::Clear(selection_context(
self.context,
))))
.and_capture(),
);
}
if selection.targets().len() > 1 && *key == Key::Named(Named::Delete) {
let snapshot = selection.snapshot(selection_context(self.context))?;
let event = if modifiers.is_empty()
&& crate::app::multiselect::placements(&snapshot).is_some()
{
SelectionEvent::Hide(snapshot)
} else {
SelectionEvent::Refuse(snapshot, if modifiers.is_empty() {
"Hide requires only placed elements. Rows, ports and transitions cannot be hidden as a placement group."
} else {
"Group model deletion requires an aggregate impact review and is not available yet. Use Hide selected for placements."
}.into())
};
return Some(Action::publish(Event::Selection(event)).and_capture());
}
}
if !matches!(
event,
canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left))
) {
return None;
}
let window = cursor
.position()
.filter(|p| self.group_viewport(bounds).bounds.contains(*p))?;
let local = Point::new(window.x - bounds.x, window.y - bounds.y);
let scene = self.scene(state)?;
let world = self.world(local);
// Route handles belong to the exact edge, including when an endpoint
// overlaps a node. They cannot bypass Shift/group admission.
let handle = self
.endpoint_at(&scene, world)
.map(|(id, _)| Target::Edge(id))
.or_else(|| {
self.bend_at(&scene, world)
.map(|_| Target::Edge(self.selected))
});
let target = handle.or_else(|| self.hit(&scene, world).map(Hit::target))?;
if state.modifiers.shift() {
state.cancel_edit();
return Some(
Action::publish(Event::Selection(SelectionEvent::Choose(
selection_context(self.context),
target,
Choice::Toggle,
)))
.and_capture(),
);
}
if selection.targets().len() <= 1 || !selection.contains(&target) {
return None;
}
let snapshot = selection.snapshot(selection_context(self.context))?;
let viewport = self.group_viewport(bounds);
let movement = if crate::app::multiselect::placements(&snapshot).is_some()
&& let Target::Placement(anchor) = target
{
let original = self
.project
.diagrams
.get(&self.diagram)?
.nodes
.get(&anchor)?
.position;
Some(GroupGesture::new_with_policy(
snapshot.clone(),
anchor,
original,
window,
viewport,
DisplacementPolicy::Unsnapped,
)?)
} else {
None
};
state.cancel_edit();
state.group = Some(GroupPress {
snapshot,
target,
start: window,
viewport,
movement,
crossed: false,
retired: false,
});
Some(Action::request_redraw().and_capture())
}
fn select_event(&self, hit: Hit, semantic: Id) -> Event {
if self.current_selection().is_some() {
Event::Selection(SelectionEvent::Choose(
selection_context(self.context),
hit.target(),
Choice::Replace,
))
} else {
Event::Select(self.context, semantic)
}
}
fn scale(&self) -> f32 {
if self.zoom.is_finite() {
self.zoom.clamp(0.0001, 3.)
@@ -695,6 +1007,7 @@ impl StateCanvas<'_> {
return None;
}
let route_active = self.gesture_active(state) && self.route_viewport_current(state, bounds);
let group_preview = self.group_preview_key(state, bounds);
let preview = self
.gesture_active(state)
.then_some(state.preview)
@@ -703,6 +1016,7 @@ impl StateCanvas<'_> {
let bend_preview = route_active.then(|| state.bend_preview.clone()).flatten();
if let Some(cached) = state.scene.borrow().as_ref()
&& cached.context == self.context
&& cached.group_preview == group_preview
&& cached.preview == preview
&& cached.attachment_preview == attachment_preview
&& cached.bend_preview == bend_preview
@@ -710,6 +1024,27 @@ impl StateCanvas<'_> {
return Some(cached.scene.clone());
}
let mut diagram = self.project.diagrams.get(&self.diagram)?.clone();
if let Some((snapshot, delta)) = &group_preview
&& let Some(ids) = crate::app::multiselect::placements(snapshot)
&& let Ok(Command::Batch(commands)) = oa_core::diagram_edit::translate_placements(
self.project,
self.diagram,
&ids,
*delta,
)
{
for command in commands {
match command {
Command::EditNode { node, .. } => {
diagram.nodes.insert(node.id, node);
}
Command::SetEdgeRoute { route, .. } => {
crate::route_edit::with_route(&mut diagram, route)
}
_ => unreachable!("placement planner returned a non-geometry command"),
}
}
}
if let Some(route) = &bend_preview {
crate::route_edit::with_route(&mut diagram, route.clone());
}
@@ -761,6 +1096,7 @@ impl StateCanvas<'_> {
});
*state.scene.borrow_mut() = Some(CachedScene {
context: self.context,
group_preview,
preview,
attachment_preview,
bend_preview,
@@ -823,13 +1159,19 @@ impl StateCanvas<'_> {
.into_iter()
.find(|(_, r)| r.contains(world))
{
return Some(Hit::StateAction(relation.id));
return Some(Hit::Row {
placement: node.id,
relation: relation.id,
});
}
if let Some((relation, _)) = internal_rows(self.project, node)
.into_iter()
.find(|(_, r)| r.contains(world))
{
return Some(Hit::Transition(relation.id));
return Some(Hit::Row {
placement: node.id,
relation: relation.id,
});
}
let container = self
.project
@@ -1148,7 +1490,13 @@ impl StateCanvas<'_> {
},
_ => match world.and_then(|p| self.hit(scene, p)) {
Some(Hit::Transition(id)) => self.project.relations.get(&id).map(|r| format!("{} · {} · right-click to edit", r.name, routing::state_transition_summary(self.project, r))).unwrap_or_default(),
Some(Hit::StateAction(id)) => self.project.relations.get(&id).map(|r| format!("{} · select the action link to inspect its referenced work", state_action_label(self.project, r))).unwrap_or_default(),
Some(Hit::Row { relation: id, .. }) => self.project.relations.get(&id).map(|r| {
if matches!(r.body, RelationBody::StateAction { .. }) {
format!("{} · select the action link to inspect its referenced work", state_action_label(self.project, r))
} else {
format!("{} · {} · right-click to edit", r.name, routing::state_transition_summary(self.project, r))
}
}).unwrap_or_default(),
_ => "Select / move · drag a vertex · drag selected transition endpoints to reconnect · middle-drag to pan".into(),
},
}
@@ -1172,6 +1520,15 @@ impl canvas::Program<Event> for StateCanvas<'_> {
bounds: Rectangle,
cursor: mouse::Cursor,
) -> Option<Action<Event>> {
if let canvas::Event::Keyboard(keyboard::Event::ModifiersChanged(modifiers)) = event {
// Passive observation must not capture the event or grant focus.
state.modifiers = *modifiers;
return None;
}
let retired_group = state.group.is_some()
&& (state.context != Some(self.context)
|| state.tool != Some(self.tool)
|| !self.editable);
if state.context != Some(self.context) || state.tool != Some(self.tool) {
if state.context != Some(self.context) {
state.viewport = None;
@@ -1226,6 +1583,7 @@ impl canvas::Program<Event> for StateCanvas<'_> {
cursor
}
canvas::Event::Window(iced::window::Event::Unfocused) => {
state.modifiers = keyboard::Modifiers::default();
state.reset();
return None;
}
@@ -1239,6 +1597,14 @@ impl canvas::Program<Event> for StateCanvas<'_> {
let window = cursor.position();
let local = window.map(|p| Point::new(p.x - bounds.x, p.y - bounds.y));
let inside = local.is_some_and(|p| Self::content(bounds).contains(p));
if retired_group {
// The event which retired a held gesture cannot be reinterpreted as
// a fresh keyboard edit against the surviving selection.
return Some(Action::request_redraw().and_capture());
}
if let Some(action) = self.group_input(state, event, bounds, cursor) {
return Some(action);
}
if let canvas::Event::Keyboard(keyboard::Event::KeyPressed { key, modifiers, .. }) = event {
use keyboard::{Key, key::Named};
if *key == Key::Named(Named::Escape) {
@@ -1310,13 +1676,17 @@ impl canvas::Program<Event> for StateCanvas<'_> {
// Menu commands must target the committed route instead.
let scene = self.scene(state)?;
let bend = self.bend_at(&scene, world);
let (object, placement) = if bend.is_some() {
(self.selected, None)
let (object, placement, selection_target) = if bend.is_some() {
(self.selected, None, Some(Target::Edge(self.selected)))
} else {
match self.hit(&scene, world) {
Some(Hit::Node { element, placement }) => (element, Some(placement)),
Some(Hit::Transition(id) | Hit::StateAction(id)) => (id, None),
None => (self.diagram, None),
Some(hit @ Hit::Node { element, placement }) => {
(element, Some(placement), Some(hit.target()))
}
Some(hit @ (Hit::Transition(id) | Hit::Row { relation: id, .. })) => {
(id, None, Some(hit.target()))
}
None => (self.diagram, None, None),
}
};
let route_segment = if bend.is_none() {
@@ -1329,6 +1699,7 @@ impl canvas::Program<Event> for StateCanvas<'_> {
context: self.context,
object,
placement,
selection_target,
anchor: window,
bend,
route_segment,
@@ -1336,12 +1707,17 @@ impl canvas::Program<Event> for StateCanvas<'_> {
.and_capture(),
);
}
if *button != mouse::Button::Left || !self.editable {
if *button != mouse::Button::Left || !self.editable || state.pan_from.is_some() {
return None;
}
match self.tool {
Tool::Select => {
if let Some((relation, endpoint)) = self.endpoint_at(&scene, world) {
let singleton = self
.current_selection()
.is_none_or(|s| s.targets().len() <= 1);
if singleton
&& let Some((relation, endpoint)) = self.endpoint_at(&scene, world)
{
state.press = Some(Press::Reconnect {
relation,
endpoint,
@@ -1349,7 +1725,7 @@ impl canvas::Program<Event> for StateCanvas<'_> {
});
return Some(Action::capture());
}
if let Some(index) = self.bend_at(&scene, world) {
if singleton && let Some(index) = self.bend_at(&scene, world) {
let route = crate::route_edit::stored_route(
&self.project.diagrams[&self.diagram],
self.selected,
@@ -1358,7 +1734,7 @@ impl canvas::Program<Event> for StateCanvas<'_> {
return Some(Action::capture());
}
match self.hit(&scene, world) {
Some(Hit::Node { placement, element }) => {
Some(hit @ Hit::Node { placement, element }) => {
let original = self
.project
.diagrams
@@ -1371,13 +1747,11 @@ impl canvas::Program<Event> for StateCanvas<'_> {
start: window,
original,
});
Some(
Action::publish(Event::Select(self.context, element))
.and_capture(),
)
Some(Action::publish(self.select_event(hit, element)).and_capture())
}
Some(Hit::Transition(id) | Hit::StateAction(id)) => {
if id == self.selected
Some(hit @ (Hit::Transition(id) | Hit::Row { relation: id, .. })) => {
if singleton
&& id == self.selected
&& let Some(edge) = scene.routed.edges.get(&id)
&& !Rectangle::new(
point(edge.label.bounds.position),
@@ -1407,7 +1781,7 @@ impl canvas::Program<Event> for StateCanvas<'_> {
return Some(Action::capture());
}
}
Some(Action::publish(Event::Select(self.context, id)).and_capture())
Some(Action::publish(self.select_event(hit, id)).and_capture())
}
None => {
state.pan_from = Some((mouse::Button::Left, window));
@@ -1643,6 +2017,10 @@ impl canvas::Program<Event> for StateCanvas<'_> {
self.gesture_active(state) && self.route_viewport_current(state, Some(bounds));
let key = PaintKey {
context: self.context,
group_preview: self.group_preview_key(state, Some(bounds)),
selection: self
.current_selection()
.and_then(|s| s.snapshot(selection_context(self.context))),
preview: self
.gesture_active(state)
.then_some(state.preview)
@@ -1671,23 +2049,40 @@ impl canvas::Program<Event> for StateCanvas<'_> {
frame.translate(self.offset());
frame.scale(self.scale());
for node in &scene.view.nodes {
let selected =
self.current_selection()
.map_or(self.selected, |s| match s.primary() {
Some(Target::Placement(id)) if *id == node.id => node.element,
Some(Target::StateRow {
placement,
relation,
}) if *placement == node.id => *relation,
_ => Id::nil(),
});
paint_node(
frame,
self.project,
node,
self.selected,
selected,
scene.symbol_labels.get(&node.id),
);
}
// Containers are already painted. Route bodies have shared
// clearance from state headers and internal-transition rows.
for edge in scene.routed.edges.values() {
let active = edge.id == self.selected;
let active = self
.current_selection()
.map_or(edge.id == self.selected, |s| {
s.contains(&Target::Edge(edge.id))
});
let primary = self
.current_selection()
.map_or(active, |s| s.primary() == Some(&Target::Edge(edge.id)));
let color = if active { style::TEAL } else { style::INK };
frame.stroke(
&polyline(&edge.points),
Stroke::default().with_color(color).with_width(if active {
2.4
if primary { 3.2 } else { 2.0 }
} else {
1.4
}),
@@ -1727,7 +2122,11 @@ impl canvas::Program<Event> for StateCanvas<'_> {
10.,
style::MUTED,
);
if active {
if active
&& self
.current_selection()
.is_none_or(|s| s.targets().len() <= 1)
{
if !edge.custom_suspended && self.tool == Tool::Select {
for pin in &edge.pins {
let radius = 4. / self.scale();
@@ -1758,6 +2157,42 @@ impl canvas::Program<Event> for StateCanvas<'_> {
}
}
}
if let Some(selection) = self.current_selection() {
for target in selection.targets() {
let rect = match target {
Target::Placement(id) => {
scene.view.nodes.iter().find(|n| n.id == *id).map(rectangle)
}
Target::StateRow {
placement,
relation,
} => scene
.view
.nodes
.iter()
.find(|n| n.id == *placement)
.and_then(|node| {
state_action_rows(self.project, node)
.into_iter()
.chain(internal_rows(self.project, node))
.find(|(row, _)| row.id == *relation)
.map(|(_, rect)| rect)
}),
_ => None,
};
if let Some(rect) = rect {
let primary = selection.primary() == Some(target);
let rect =
expanded(rect, if primary { 3. } else { 1.5 } / self.scale());
frame.stroke(
&Path::rectangle(rect.position(), rect.size()),
Stroke::default()
.with_color(style::TEAL)
.with_width(if primary { 3. } else { 1.5 } / self.scale()),
);
}
}
}
});
});
let mut overlay = Frame::new(renderer, bounds.size());
@@ -2159,6 +2594,7 @@ mod tests {
use crate::document::Document;
include!("state_bend_tests.rs");
include!("state_multiselect_canvas_tests.rs");
struct Fixture {
project: Project,
@@ -2312,7 +2748,10 @@ mod tests {
let scene = canvas.scene(&State::default()).unwrap();
assert_eq!(
canvas.hit(&scene, row.bounds.center()),
Some(Hit::StateAction(action))
Some(Hit::Row {
placement,
relation: action
})
);
assert_eq!(f.project, before);
f.project
@@ -2427,6 +2866,7 @@ mod tests {
epoch: 3,
},
selected: f.machine,
selection: None,
navigation_focus: None,
tool,
zoom,
@@ -3245,7 +3685,10 @@ mod tests {
for (relation, row) in rows {
assert_eq!(
canvas.hit(&scene, row.center()),
Some(Hit::StateAction(relation.id))
Some(Hit::Row {
placement: a.id,
relation: relation.id
})
);
let events = click(&canvas, &mut State::default(), row.center());
assert!(
@@ -3306,7 +3749,10 @@ mod tests {
for (relation, rect) in rows {
assert_eq!(
canvas.hit(&scene, rect.center()),
Some(Hit::Transition(relation.id))
Some(Hit::Row {
placement: a.id,
relation: relation.id
})
);
}
let initial = scene
+4 -1
View File
@@ -25,6 +25,7 @@ pub enum Tool {
#[derive(Debug, Clone)]
pub enum Event {
Selection(crate::app::multiselect::Event),
Remove(crate::app::diagram_removal::Intent),
Choose(Tool),
CreateView(Id),
@@ -38,6 +39,7 @@ 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)>,
@@ -101,7 +103,8 @@ 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, _)
@@ -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…",
+74 -54
View File
@@ -5,8 +5,11 @@ use iced::widget::{Column, text_input};
use std::collections::BTreeSet;
pub(crate) mod contextual;
mod direct;
pub(crate) mod group_reorder;
mod usages;
mod view;
#[cfg(test)]
pub(crate) use direct::End;
use direct::Input;
pub(crate) use direct::{
CanvasEvent, CanvasTool, Context, Insertion, reconnect, referenced, reorder,
@@ -909,6 +912,53 @@ fn sequence_links(p: &Project, owner: Id) -> Vec<&Relation> {
})
.collect()
}
/// Integer columns shared by rendering and order planning. Edges whose endpoints
/// are absent from the displayed order cannot participate in its layout.
fn column_ranks(order: &[Id], adjacency: &[(Id, Id)]) -> BTreeMap<Id, usize> {
let ids: BTreeSet<_> = order.iter().copied().collect();
let adjacency: Vec<_> = adjacency
.iter()
.copied()
.filter(|(source, target)| ids.contains(source) && ids.contains(target))
.collect();
let mut incoming: BTreeMap<_, usize> = ids.iter().map(|id| (*id, 0)).collect();
let mut ranks: BTreeMap<_, usize> = ids.iter().map(|id| (*id, 0)).collect();
for &(_, target) in &adjacency {
*incoming.get_mut(&target).expect("Displayed endpoint") += 1;
}
let mut queue: Vec<_> = order
.iter()
.copied()
.filter(|id| incoming[id] == 0)
.collect();
let mut done = BTreeSet::new();
let mut offset = 0;
while offset < queue.len() {
let id = queue[offset];
offset += 1;
if !done.insert(id) {
continue;
}
for &(_, next) in adjacency.iter().filter(|(source, _)| *source == id) {
ranks.insert(next, ranks[&next].max(ranks[&id] + 1));
let count = incoming.get_mut(&next).expect("Displayed endpoint");
*count = count.saturating_sub(1);
if *count == 0 {
queue.push(next);
}
}
}
// Preserve deterministic fallback order for loops and cycle-blocked nodes.
let mut fallback = ranks.values().copied().max().unwrap_or(0) + 1;
for id in order {
if !done.contains(id) {
ranks.insert(*id, fallback);
fallback += 1;
}
}
ranks
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct PreviewLink {
relation: Id,
@@ -1066,6 +1116,7 @@ struct Graph<'a> {
selected: Id,
selected_preview: Option<OccurrenceEndpoint>,
selected_preview_link: Option<PreviewLink>,
selection: Option<crate::diagram_selection::Snapshot>,
scale: f32,
}
impl<'a> Graph<'a> {
@@ -1084,50 +1135,19 @@ impl<'a> Graph<'a> {
) -> Self {
let context_index = oa_core::topology_context::TopologyIndex::new(p);
let choices = contributions(p, owner);
let links = sequence_links(p, owner);
let displayed: BTreeSet<_> = choices.iter().map(|choice| choice.id).collect();
let links: Vec<_> = sequence_links(p, owner)
.into_iter()
.filter(|link| displayed.contains(&link.source()) && displayed.contains(&link.target()))
.collect();
let order = legacy_order(p, owner);
let adjacency: Vec<_> = links
.iter()
.map(|r| (r.source(), r.target()))
.chain(order.windows(2).map(|pair| (pair[0], pair[1])))
.collect();
let ids: BTreeSet<_> = choices.iter().map(|c| c.id).collect();
let mut incoming: BTreeMap<_, usize> = ids.iter().map(|id| (*id, 0)).collect();
let mut ranks: BTreeMap<_, usize> = ids.iter().map(|id| (*id, 0)).collect();
for &(_, target) in &adjacency {
*incoming.entry(target).or_default() += 1;
}
let mut queue: Vec<_> = choices
.iter()
.filter(|c| incoming[&c.id] == 0)
.map(|c| c.id)
.collect();
let mut done = BTreeSet::new();
let mut offset = 0;
while offset < queue.len() {
let id = queue[offset];
offset += 1;
if !done.insert(id) {
continue;
}
for &(_, next) in adjacency.iter().filter(|(source, _)| *source == id) {
ranks.insert(next, ranks[&next].max(ranks[&id] + 1));
let count = incoming.get_mut(&next).expect("Graph endpoint");
*count = count.saturating_sub(1);
if *count == 0 {
queue.push(next);
}
}
}
// Cycles remain explicit edges; deterministic fallback placement must
// terminate even while users are building a loop or incomplete graph.
let mut fallback = ranks.values().copied().max().unwrap_or(0) + 1;
for c in &choices {
if !done.contains(&c.id) {
ranks.insert(c.id, fallback);
fallback += 1;
}
}
let displayed_order: Vec<_> = choices.iter().map(|choice| choice.id).collect();
let ranks = column_ranks(&displayed_order, &adjacency);
let mut rows: BTreeMap<usize, f32> = BTreeMap::new();
let mut nodes = vec![];
let mut size: Size = Size::new(640., 220.);
@@ -1238,25 +1258,17 @@ impl<'a> Graph<'a> {
}
let ordered_paths = order
.windows(2)
.map(|pair| {
let a = nodes
.iter()
.find(|n| n.id == pair[0])
.expect("ordered source")
.rect;
let b = nodes
.iter()
.find(|n| n.id == pair[1])
.expect("ordered target")
.rect;
OrderedPath {
.filter_map(|pair| {
let a = nodes.iter().find(|n| n.id == pair[0])?.rect;
let b = nodes.iter().find(|n| n.id == pair[1])?.rect;
Some(OrderedPath {
source: pair[0],
target: pair[1],
points: vec![
Point::new(a.x + a.width, a.center_y()),
Point::new(b.x, b.center_y()),
],
}
})
})
.collect();
let mut edges = vec![];
@@ -1400,6 +1412,7 @@ impl<'a> Graph<'a> {
selected,
selected_preview: None,
selected_preview_link: None,
selection: None,
scale: 1.,
}
}
@@ -1518,9 +1531,16 @@ impl canvas::Program<Message> for Graph<'_> {
EdgeLayer::Contextual
}
}) {
let selected = e.id == self.selected
|| e.source == self.selected
|| e.target == self.selected;
let selected = self.selection.as_ref().map_or(
e.id == self.selected
|| e.source == self.selected
|| e.target == self.selected,
|selection| {
selection
.targets()
.contains(&crate::diagram_usage::Target::ProcessLink(e.id))
},
);
if e.incidence {
f.stroke(
&Path::new(|path| {
@@ -1,5 +1,7 @@
//! Local pointer state and ordinary command proposals for the derived process graph.
use super::*;
#[path = "direct_selection.rs"]
mod selection;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Context {
@@ -445,27 +447,23 @@ pub(crate) fn reconnect(
execute_batch(p, std::slice::from_ref(&command)).map_err(|e| e.to_string())?;
Ok(command)
}
pub(crate) fn reorder(p: &Project, owner: Id, id: Id, before: Id) -> Result<Command, String> {
if !is_contribution(p, owner, id) || !is_contribution(p, owner, before) {
return Err("Reorder contributions in this process only.".into());
}
let graph = Graph::new(p, owner, id);
let position = |id| graph.nodes.iter().find(|n| n.id == id).map(|n| n.rect.x);
if position(id) != position(before) {
return Err("Reorder within this column; links determine the path columns.".into());
}
let mut steps = p.elements[&owner].steps().unwrap().clone();
if id != before {
steps.retain(|v| *v != id);
let index = steps
.iter()
.position(|v| *v == before)
.ok_or("This slot no longer exists.")?;
steps.insert(index, id);
}
let command = Command::SetSteps(owner, steps);
execute_batch(p, std::slice::from_ref(&command)).map_err(|e| e.to_string())?;
Ok(command)
pub(crate) fn reorder(
p: &Project,
owner: Id,
id: Id,
before: Id,
) -> Result<super::group_reorder::Plan, String> {
super::group_reorder::Prepared::new(
p,
owner,
&[crate::diagram_usage::Target::ProcessOccurrence(
OccurrenceEndpoint {
references: vec![],
occurrence: id,
},
)],
)?
.plan(super::group_reorder::Destination::Before(before))
}
#[derive(Default)]
@@ -476,6 +474,8 @@ pub(super) struct Input {
bounds: Option<Rectangle>,
source: Option<Id>,
press: Option<Press>,
modifiers: iced::keyboard::Modifiers,
group: Option<selection::GroupPress>,
}
#[derive(Clone, Copy)]
enum Press {
@@ -504,6 +504,7 @@ impl Input {
fn cancel(&mut self) {
self.source = None;
self.press = None;
self.group = None;
}
}
impl Graph<'_> {
@@ -592,6 +593,9 @@ impl Graph<'_> {
bounds: Rectangle,
cursor: mouse::Cursor,
) -> Option<Action<Message>> {
if let Some(action) = self.selection_input(state, event, bounds, cursor) {
return Some(action);
}
let (context, tool, editable) = self
.interaction
.map_or((None, CanvasTool::Select, false), |(c, t, e)| {
@@ -835,6 +839,7 @@ impl Graph<'_> {
cursor: mouse::Cursor,
bounds: Rectangle,
) {
self.selection_overlay(state, f, bounds);
let Some((context, tool, editable)) = self.interaction else {
return;
};
@@ -935,7 +940,12 @@ impl Graph<'_> {
);
}
}
if tool == CanvasTool::Select {
if tool == CanvasTool::Select
&& self
.selection
.as_ref()
.is_none_or(|s| s.targets().len() == 1)
{
if let Some(r) = self.handle(self.selected) {
f.fill_rectangle(r.position(), r.size(), Color::WHITE);
draw_text(f, "↕".into(), r.position(), 17., style::TEAL);
@@ -0,0 +1,474 @@
//! Exact-target selection and an order gesture over one immutable selection.
use super::super::group_reorder::{Destination, Plan, Prepared};
use super::*;
use crate::app::multiselect::{Choice, Event as SelectionEvent, process_context};
use crate::diagram_selection::Snapshot;
use crate::diagram_usage::Target;
pub(super) struct GroupPress {
snapshot: Snapshot,
target: Target,
start: Point,
bounds: Rectangle,
pan: iced::Vector,
scale: f32,
crossed: bool,
retired: bool,
prepared: Result<Prepared, String>,
destination: Option<Destination>,
plan: Option<Result<Plan, String>>,
}
impl Graph<'_> {
fn exact_target_at(&self, point: Point) -> Option<Target> {
// Visible singleton reconnect rings are painted over the card/edge.
// Shift and context selection must resolve that same topmost target.
if self.interaction.is_some_and(|(_, tool, editable)| editable && tool == CanvasTool::Select)
&& self.selection.as_ref().is_some_and(|snapshot| {
snapshot.targets().len() == 1
&& matches!(snapshot.primary(), Some(Target::ProcessLink(id)) if *id == self.selected)
})
&& let Some((link, _)) = self.endpoint_at(point)
{
return Some(Target::ProcessLink(link));
}
if let Some(endpoint) = self.qualified_at(point) {
return Some(Target::ProcessOccurrence(endpoint));
}
for node in &self.nodes {
for link in &node.preview_links {
if node
.preview_link_visible_segment(link)
.is_some_and(|[a, b]| {
crate::diagram::distance_to_segment(point, a, b) < 5. / self.scale
})
{
return Some(Target::ProcessPreviewLink {
relation: link.relation,
source: link.source.clone(),
target: link.target.clone(),
});
}
}
}
self.contextual_edge_at(point)
.map(Target::ProcessLink)
.or_else(|| {
self.node_at(point).map(|id| {
Target::ProcessOccurrence(OccurrenceEndpoint {
references: vec![],
occurrence: id,
})
})
})
.or_else(|| self.edge_at(point).map(Target::ProcessLink))
}
fn destination_at(&self, point: Point) -> Option<Destination> {
let id = self.node_at(point)?;
let node = self.nodes.iter().find(|n| n.id == id)?;
Some(if point.y < node.rect.center_y() {
Destination::Before(id)
} else {
Destination::After(id)
})
}
fn update_group(&self, group: &mut GroupPress, pointer: Point) {
if group.retired {
return;
}
if !pointer.x.is_finite() || !pointer.y.is_finite() || !group.bounds.contains(pointer) {
group.retired = true;
group.plan = None;
return;
}
group.crossed |= pointer.distance(group.start) >= 3.;
let world = Point::new(
(pointer.x - group.bounds.x - self.camera_pan.x) / self.scale,
(pointer.y - group.bounds.y - self.camera_pan.y) / self.scale,
);
let destination = self.destination_at(world);
// The captured semantic facts are prepared once. Only a changed object
// anchor/side needs another order plan; motion within a slot is free.
if destination != group.destination {
group.destination = destination;
group.plan = destination.map(|destination| match &group.prepared {
Ok(prepared) => prepared.plan(destination),
Err(reason) => Err(reason.clone()),
});
}
}
pub(super) fn selection_input(
&self,
state: &mut Input,
event: &canvas::Event,
bounds: Rectangle,
cursor: mouse::Cursor,
) -> Option<Action<Message>> {
use iced::keyboard::{Event as KeyEvent, Key, key::Named};
match event {
canvas::Event::Keyboard(KeyEvent::ModifiersChanged(modifiers))
| canvas::Event::Keyboard(KeyEvent::KeyPressed { modifiers, .. })
| canvas::Event::Keyboard(KeyEvent::KeyReleased { modifiers, .. }) => {
state.modifiers = *modifiers
}
canvas::Event::Window(iced::window::Event::Unfocused) => {
state.modifiers = iced::keyboard::Modifiers::empty()
}
_ => {}
}
let Some((context, tool, editable)) = self.interaction else {
state.group = None;
return None;
};
let snapshot = self
.selection
.as_ref()
.filter(|s| s.context() == process_context(context));
if let Some(group) = &state.group {
let cancelled = !editable
|| tool != CanvasTool::Select
|| snapshot != Some(&group.snapshot)
|| group.bounds != bounds
|| group.pan != self.camera_pan
|| group.scale != self.scale
|| 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(KeyEvent::KeyPressed {
key: Key::Named(Named::Escape),
..
})
);
if cancelled {
state.cancel();
state.pointer = None;
return Some(Action::request_redraw().and_capture());
}
}
if let Some(group) = &mut state.group {
match event {
canvas::Event::Mouse(mouse::Event::CursorMoved { position }) => {
state.pointer = Some(*position);
self.update_group(group, *position);
return Some(Action::request_redraw().and_capture());
}
canvas::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => {
let mut group = state.group.take().unwrap();
// An unavailable cursor must never reuse the last inside point.
if let Some(pointer) = cursor.position() {
self.update_group(&mut group, pointer);
} else {
group.retired = true;
}
let result = if group.retired {
None
} else if !group.crossed {
Some(SelectionEvent::Collapse(group.snapshot, group.target))
} else if let Err(reason) = group.prepared {
Some(SelectionEvent::Refuse(group.snapshot, reason))
} else {
match (group.destination, group.plan) {
(Some(destination), Some(Ok(Plan::Change(_)))) => {
Some(SelectionEvent::ProcessReorder(group.snapshot, destination))
}
(_, Some(Err(reason))) => {
Some(SelectionEvent::Refuse(group.snapshot, reason))
}
_ => None,
}
};
return Some(
result
.map_or_else(Action::request_redraw, |event| {
Action::publish(Message::DiagramSelection(event))
})
.and_capture(),
);
}
canvas::Event::Keyboard(iced::keyboard::Event::ModifiersChanged(_)) => return None,
canvas::Event::Keyboard(_) => return Some(Action::capture()),
_ => {}
}
}
let snapshot = snapshot?;
if !editable {
return None;
}
let publish = |event| Some(Action::publish(Message::DiagramSelection(event)).and_capture());
if matches!(
event,
canvas::Event::Keyboard(KeyEvent::KeyPressed {
key: Key::Named(Named::Delete),
repeat: false,
..
})
) && (snapshot.targets().len() > 1
|| snapshot.targets().iter().any(|t| match t {
Target::ProcessPreviewLink { .. } => true,
Target::ProcessOccurrence(endpoint) => !endpoint.references.is_empty(),
_ => false,
}))
{
return publish(SelectionEvent::Refuse(snapshot.clone(), "Remove is unavailable for this selection. Open the referenced definition to edit qualified contributions; group removal needs an aggregate review.".into()));
}
let pointer = match event {
canvas::Event::Mouse(mouse::Event::CursorMoved { position }) => Some(*position),
_ => cursor.position().and(state.pointer.or(cursor.position())),
}?;
if !bounds.contains(pointer) {
return None;
}
let world = Point::new(
(pointer.x - bounds.x - self.camera_pan.x) / self.scale,
(pointer.y - bounds.y - self.camera_pan.y) / self.scale,
);
if matches!(
event,
canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Right))
) {
state.cancel();
let target = self.exact_target_at(world)?;
let object = match &target {
Target::ProcessOccurrence(endpoint) => endpoint.occurrence,
Target::ProcessLink(id) | Target::ProcessPreviewLink { relation: id, .. } => *id,
_ => return None,
};
let mut request = crate::model_menu::Request::object(object, pointer);
request.selection_target = Some(Box::new(target));
request.process = Some(Box::new(context));
return Some(
Action::publish(Message::Menu(crate::model_menu::Event::Open(request)))
.and_capture(),
);
}
if !matches!(
event,
canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left))
) {
return None;
}
if tool == CanvasTool::Connect && snapshot.targets().len() > 1 {
state.cancel();
return publish(SelectionEvent::Refuse(
snapshot.clone(),
"Connecting a selection as a group is unavailable. Select one contribution first."
.into(),
));
}
if tool != CanvasTool::Select {
return None;
}
let target = self.exact_target_at(world);
if state.modifiers.shift() {
state.cancel();
return target
.and_then(|target| {
publish(SelectionEvent::Choose(
process_context(context),
target,
Choice::Toggle,
))
})
.or_else(|| Some(Action::capture()));
}
let Some(target) = target else {
state.cancel();
// Clearing and starting the existing background camera pan can
// share this press; only semantic object hits capture it.
return Some(Action::publish(Message::DiagramSelection(
SelectionEvent::Clear(process_context(context)),
)));
};
// The ordinary endpoint gesture is valid only for an exact singleton
// local link. A mixed selection must never reconnect just its primary.
if snapshot.targets().len() == 1
&& matches!(snapshot.primary(), Some(Target::ProcessLink(id)) if *id == self.selected)
&& self.endpoint_at(world).is_some()
{
return None;
}
if !snapshot.targets().contains(&target) {
state.cancel();
return publish(SelectionEvent::Choose(
process_context(context),
target,
Choice::Replace,
));
}
state.cancel();
state.context = Some(context);
state.tool = tool;
state.bounds = Some(bounds);
state.pointer = Some(pointer);
state.group = Some(GroupPress {
snapshot: snapshot.clone(),
target,
start: pointer,
bounds,
pan: self.camera_pan,
scale: self.scale,
crossed: false,
retired: false,
prepared: Prepared::new(self.project, context.owner, snapshot.targets()),
destination: None,
plan: None,
});
Some(Action::request_redraw().and_capture())
}
pub(super) fn selection_overlay(&self, state: &Input, frame: &mut Frame, bounds: Rectangle) {
if let Some(selection) = &self.selection {
for target in selection.targets() {
let primary = selection.primary() == Some(target);
let width = if primary { 3.5 } else { 2. };
let color = if primary {
style::TEAL
} else {
Color::from_rgb8(64, 123, 188)
};
match target {
Target::ProcessOccurrence(endpoint) => {
let rect = if endpoint.references.is_empty() {
self.nodes
.iter()
.find(|n| n.id == endpoint.occurrence)
.map(|n| n.rect)
} else {
self.nodes
.iter()
.find(|n| n.preview.iter().any(|(shown, _)| shown == endpoint))
.map(|n| n.endpoint_rect(endpoint))
};
if let Some(rect) = rect {
frame.stroke(
&Path::rounded_rectangle(rect.position(), rect.size(), 5.into()),
Stroke::default().with_color(color).with_width(width),
);
}
}
Target::ProcessPreviewLink {
relation,
source,
target,
} => {
let link = PreviewLink {
relation: *relation,
source: source.clone(),
target: target.clone(),
};
for node in &self.nodes {
if node.preview_links.contains(&link)
&& let Some([a, b]) = node.preview_link_visible_segment(&link)
{
frame.stroke(
&Path::line(a, b),
Stroke::default().with_color(color).with_width(width),
);
}
}
}
_ => {}
}
}
}
let Some(group) = &state.group else {
return;
};
if group.retired
|| !group.crossed
|| group.bounds != bounds
|| self.selection.as_ref() != Some(&group.snapshot)
|| group.pan != self.camera_pan
|| group.scale != self.scale
{
return;
}
let reason = group
.prepared
.as_ref()
.err()
.or_else(|| group.plan.as_ref().and_then(|plan| plan.as_ref().err()));
let color = if reason.is_some() {
Color::from_rgb8(178, 49, 49)
} else {
style::TEAL
};
let mut position = state
.pointer
.map(|p| {
Point::new(
(p.x - bounds.x - self.camera_pan.x) / self.scale + 12.,
(p.y - bounds.y - self.camera_pan.y) / self.scale + 18.,
)
})
.unwrap_or(Point::ORIGIN);
if let Some(destination) = group.destination {
let (id, after) = match destination {
Destination::Before(id) => (id, false),
Destination::After(id) => (id, true),
};
if let Some(node) = self.nodes.iter().find(|n| n.id == id) {
let y = if after {
node.rect.y + node.rect.height + 7.
} else {
node.rect.y - 7.
};
frame.stroke(
&Path::line(
Point::new(node.rect.x, y),
Point::new(node.rect.x + node.rect.width, y),
),
Stroke::default().with_color(color).with_width(3.),
);
position = Point::new(node.rect.x, y + 3.);
}
}
let caption = reason.cloned().unwrap_or_else(|| match &group.plan {
Some(Ok(Plan::Noop)) => "Order unchanged".into(),
Some(Ok(Plan::Change(_))) => format!(
"Insert {} in source order · paths unchanged",
group.snapshot.targets().len()
),
_ => "Point at a card's upper / lower half to insert before / after".into(),
});
draw_text(frame, caption, position, 12. / self.scale, color);
if reason.is_none() {
let mut index = 0;
// Graph nodes are in original steps order; these numbers describe
// the block the exact same cached plan will commit on release.
for node in &self.nodes {
if group
.snapshot
.targets()
.contains(&Target::ProcessOccurrence(OccurrenceEndpoint {
references: vec![],
occurrence: node.id,
}))
{
index += 1;
draw_text(
frame,
format!("{index}"),
Point::new(node.rect.x + node.rect.width - 18., node.rect.y + 7.),
13.,
style::TEAL,
);
}
}
}
}
}
#[cfg(test)]
#[path = "direct_selection_tests.rs"]
mod tests;
@@ -0,0 +1,403 @@
use super::*;
use crate::behavior::topology::direct_tests::{Fixture, context, fixture};
use crate::diagram_selection::Selection;
fn local(id: Id) -> Target {
Target::ProcessOccurrence(OccurrenceEndpoint {
references: vec![],
occurrence: id,
})
}
fn peers(layer: Layer) -> Fixture {
let mut fixture = fixture(layer);
fixture.p = execute_batch(&fixture.p, &[Command::Delete(fixture.link)]).unwrap();
fixture
}
fn graph<'a>(fixture: &'a Fixture, targets: &[Target]) -> Graph<'a> {
let context = context(fixture.owner);
let mut selection = Selection::new(process_context(context).scope);
for target in targets {
selection.add(target.clone(), |_| true);
}
let mut graph = Graph::new(&fixture.p, fixture.owner, fixture.owner);
graph.interaction = Some((context, CanvasTool::Select, true));
graph.selection = selection.snapshot(process_context(context));
graph
}
fn bounds() -> Rectangle {
Rectangle::new(Point::ORIGIN, Size::new(1800., 1400.))
}
fn point(graph: &Graph<'_>, id: Id, after: bool) -> Point {
let rect = graph.nodes.iter().find(|node| node.id == id).unwrap().rect;
Point::new(
rect.x + 70.,
rect.y + if after { rect.height - 15. } else { 20. },
)
}
fn send(
graph: &Graph<'_>,
input: &mut Input,
event: canvas::Event,
point: Point,
) -> Option<Message> {
graph
.input(input, &event, bounds(), mouse::Cursor::Available(point))
.and_then(|action| action.into_inner().0)
}
fn press(graph: &Graph<'_>, input: &mut Input, point: Point) -> Option<Message> {
send(
graph,
input,
canvas::Event::Mouse(mouse::Event::CursorMoved { position: point }),
point,
);
send(
graph,
input,
canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
point,
)
}
fn release(graph: &Graph<'_>, input: &mut Input, point: Point) -> Option<Message> {
send(
graph,
input,
canvas::Event::Mouse(mouse::Event::CursorMoved { position: point }),
point,
);
send(
graph,
input,
canvas::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)),
point,
)
}
#[test]
fn exact_shift_toggle_and_stationary_group_click_defer_primary() {
let f = peers(Layer::Oa);
let graph = graph(&f, &[local(f.nodes[2]), local(f.nodes[0])]);
let mut input = Input::default();
let at = point(&graph, f.nodes[2], false);
assert!(press(&graph, &mut input, at).is_none());
assert!(
matches!(release(&graph, &mut input, at), Some(Message::DiagramSelection(SelectionEvent::Collapse(snapshot, target)))
if target == local(f.nodes[2]) && snapshot == graph.selection.clone().unwrap())
);
send(
&graph,
&mut input,
canvas::Event::Keyboard(iced::keyboard::Event::ModifiersChanged(
iced::keyboard::Modifiers::SHIFT,
)),
at,
);
assert!(
matches!(press(&graph, &mut input, at), Some(Message::DiagramSelection(SelectionEvent::Choose(_, target, Choice::Toggle))) if target == local(f.nodes[2]))
);
assert!(input.group.is_none());
send(
&graph,
&mut input,
canvas::Event::Window(iced::window::Event::Unfocused),
at,
);
assert!(!input.modifiers.shift());
}
#[test]
fn group_drag_uses_midpoint_anchors_and_original_order_and_singleton_after_gap() {
for layer in [Layer::Oa, Layer::Sa] {
let f = peers(layer);
for targets in [
vec![local(f.nodes[2]), local(f.nodes[0])],
vec![local(f.nodes[0])],
] {
let graph = graph(&f, &targets);
let mut input = Input::default();
let start = point(&graph, f.nodes[0], false);
let end = point(&graph, f.nodes[1], true);
assert!(press(&graph, &mut input, start).is_none());
let Some(Message::DiagramSelection(SelectionEvent::ProcessReorder(
snapshot,
destination,
))) = release(&graph, &mut input, end)
else {
panic!("admitted after slot");
};
assert_eq!(snapshot, graph.selection.clone().unwrap());
assert_eq!(destination, Destination::After(f.nodes[1]));
let Plan::Change(command) = Prepared::new(&f.p, f.owner, snapshot.targets())
.unwrap()
.plan(destination)
.unwrap()
else {
panic!("changed steps");
};
let Command::SetSteps(owner, steps) = *command else {
panic!("expected SetSteps");
};
assert_eq!(owner, f.owner);
assert_eq!(steps, vec![f.nodes[1], f.nodes[0], f.nodes[2]]);
}
}
}
#[test]
fn selected_anchor_noop_and_irreversible_threshold_never_collapse_after_motion() {
let f = peers(Layer::Oa);
let graph = graph(&f, &[local(f.nodes[0]), local(f.nodes[2])]);
let mut input = Input::default();
let start = point(&graph, f.nodes[0], false);
press(&graph, &mut input, start);
let away = start + iced::Vector::new(10., 0.);
send(
&graph,
&mut input,
canvas::Event::Mouse(mouse::Event::CursorMoved { position: away }),
away,
);
assert!(release(&graph, &mut input, start).is_none());
assert!(input.group.is_none());
press(&graph, &mut input, start);
let outside = Point::new(-1., 100.);
send(
&graph,
&mut input,
canvas::Event::Mouse(mouse::Event::CursorMoved { position: outside }),
outside,
);
assert!(release(&graph, &mut input, point(&graph, f.nodes[1], true)).is_none());
}
#[test]
fn whole_mixed_set_refuses_drag_delete_and_connect_without_singleton_bypass() {
let f = fixture(Layer::Sa);
let mut graph = graph(&f, &[local(f.nodes[0]), Target::ProcessLink(f.link)]);
graph.selected = f.link;
let mut input = Input::default();
let start = point(&graph, f.nodes[0], false);
press(&graph, &mut input, start);
assert!(
matches!(release(&graph, &mut input, point(&graph, f.nodes[2], true)), Some(Message::DiagramSelection(SelectionEvent::Refuse(snapshot, _))) if snapshot.targets().len() == 2)
);
let key = crate::behavior::canvas_workspace::tests::key(iced::keyboard::key::Named::Delete);
let iced::Event::Keyboard(key) = key else {
panic!("keyboard");
};
assert!(matches!(
send(&graph, &mut input, canvas::Event::Keyboard(key), start),
Some(Message::DiagramSelection(SelectionEvent::Refuse(_, _)))
));
graph.interaction.as_mut().unwrap().1 = CanvasTool::Connect;
assert!(matches!(
press(&graph, &mut input, start),
Some(Message::DiagramSelection(SelectionEvent::Refuse(_, _)))
));
}
#[test]
fn camera_busy_context_bounds_and_escape_retire_captured_gestures() {
let f = peers(Layer::Oa);
for change in 0..6 {
let mut graph = graph(&f, &[local(f.nodes[0]), local(f.nodes[2])]);
let mut input = Input::default();
let start = point(&graph, f.nodes[0], false);
press(&graph, &mut input, start);
match change {
0 => graph.camera_pan.x += 1.,
1 => graph.scale = 2.,
2 => graph.interaction.as_mut().unwrap().2 = false,
3 => graph.interaction.as_mut().unwrap().0.epoch += 1,
4 => {
let iced::Event::Keyboard(key) = crate::behavior::canvas_workspace::tests::key(
iced::keyboard::key::Named::Escape,
) else {
panic!("keyboard");
};
send(&graph, &mut input, canvas::Event::Keyboard(key), start);
}
_ => {
graph.input(
&mut input,
&canvas::Event::Mouse(mouse::Event::CursorMoved { position: start }),
Rectangle::new(Point::new(1., 0.), bounds().size()),
mouse::Cursor::Available(start),
);
}
}
assert!(release(&graph, &mut input, point(&graph, f.nodes[1], true)).is_none());
assert!(input.group.is_none());
}
}
#[test]
fn qualified_occurrences_and_visible_preview_links_keep_exact_invocation_paths() {
let mut f = peers(Layer::Oa);
let source =
f.p.make_element(Kind::Process, Layer::Oa, "Reusable", None)
.unwrap();
let source_id = source.id;
f.p = execute_batch(&f.p, &[Command::CreateElement(source)]).unwrap();
let mut leaves = vec![];
for name in ["One", "Two"] {
let leaf = occurrence(
&f.p,
source_id,
name,
OccurrenceBody::Action {
action: Some(f.work[0]),
lifeline: None,
},
);
leaves.push(leaf.id);
f.p = execute_batch(&f.p, &[Command::CreateElement(leaf)]).unwrap();
}
let link = link(&f.p, leaves[0], leaves[1], "");
f.p = execute_batch(&f.p, &[link]).unwrap();
let mut references = vec![];
for name in ["First", "Second"] {
let reference = occurrence(
&f.p,
f.owner,
name,
OccurrenceBody::SequenceReference {
sequence: Some(source_id),
},
);
references.push(reference.id);
f.p = execute_batch(&f.p, &[Command::CreateElement(reference)]).unwrap();
}
let graph = graph(&f, &[]);
let mut targets = vec![];
for reference in references {
let node = graph.nodes.iter().find(|n| n.id == reference).unwrap();
let endpoint = OccurrenceEndpoint {
references: vec![reference],
occurrence: leaves[0],
};
let tile = node.endpoint_rect(&endpoint).center();
assert_eq!(
graph.exact_target_at(tile),
Some(Target::ProcessOccurrence(endpoint.clone()))
);
targets.push(Target::ProcessOccurrence(endpoint));
let link = &node.preview_links[0];
let [a, b] = node.preview_link_visible_segment(link).unwrap();
let at = Point::new((a.x + b.x) / 2., (a.y + b.y) / 2.);
let expected = Target::ProcessPreviewLink {
relation: link.relation,
source: link.source.clone(),
target: link.target.clone(),
};
assert_eq!(graph.exact_target_at(at), Some(expected.clone()));
let mut input = Input::default();
send(
&graph,
&mut input,
canvas::Event::Keyboard(iced::keyboard::Event::ModifiersChanged(
iced::keyboard::Modifiers::SHIFT,
)),
at,
);
assert!(
matches!(press(&graph, &mut input, at), Some(Message::DiagramSelection(SelectionEvent::Choose(_, target, Choice::Toggle))) if target == expected)
);
assert!(
matches!(send(&graph, &mut input, canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Right)), at), Some(Message::Menu(crate::model_menu::Event::Open(request))) if request.selection_target.as_deref() == Some(&expected) && request.process.as_deref().copied() == graph.interaction.map(|(context,_,_)| context))
);
}
assert_ne!(targets[0], targets[1]);
assert!(Prepared::new(&f.p, f.owner, &targets).is_err());
}
// The endpoint ring is drawn last, over the underlying contribution card.
// Probe two pixels inside that card: both semantic shapes occupy the point,
// so these tests establish the visible handle's selection precedence.
fn handle_point(graph: &Graph<'_>, link: Id, end: End) -> Point {
let edge = graph.edges.iter().find(|edge| edge.id == link).unwrap();
match end {
End::Source => edge.points[0] + iced::Vector::new(-2., 0.),
End::Target => *edge.points.last().unwrap() + iced::Vector::new(2., 0.),
}
}
#[test]
fn singleton_process_link_snapshot_reconnects_both_ends_but_stationary_release_is_inert() {
for layer in [Layer::Oa, Layer::Sa] {
let f = fixture(layer);
for end in [End::Source, End::Target] {
let mut graph = graph(&f, &[Target::ProcessLink(f.link)]);
graph.selected = f.link;
let original = graph.selection.clone().unwrap();
let start = handle_point(&graph, f.link, end);
let mut input = Input::default();
assert!(press(&graph, &mut input, start).is_none());
assert!(input.group.is_none());
assert!(
matches!(input.press, Some(Press::Reconnect { link, end: armed, moved: false, .. }) if link == f.link && armed == end)
);
assert!(release(&graph, &mut input, start).is_none());
assert!(input.press.is_none());
assert_eq!(graph.selection.as_ref(), Some(&original));
assert!(press(&graph, &mut input, start).is_none());
let target = point(&graph, f.nodes[2], false);
assert!(matches!(release(&graph, &mut input, target),
Some(Message::TopologyCanvas(CanvasEvent::Reconnect { context, link, end: actual, target }))
if context == graph.interaction.unwrap().0 && link == f.link && actual == end && target == f.nodes[2]));
assert!(input.group.is_none());
assert_eq!(graph.selection.as_ref(), Some(&original));
}
}
}
#[test]
fn shift_on_visible_singleton_process_link_handle_toggles_that_link_not_underlying_card() {
for layer in [Layer::Oa, Layer::Sa] {
let f = fixture(layer);
for (end, underneath) in [(End::Source, f.nodes[0]), (End::Target, f.nodes[1])] {
let mut graph = graph(&f, &[Target::ProcessLink(f.link)]);
graph.selected = f.link;
let start = handle_point(&graph, f.link, end);
assert_eq!(graph.node_at(start), Some(underneath));
let mut input = Input::default();
send(
&graph,
&mut input,
canvas::Event::Keyboard(iced::keyboard::Event::ModifiersChanged(
iced::keyboard::Modifiers::SHIFT,
)),
start,
);
assert!(matches!(press(&graph, &mut input, start),
Some(Message::DiagramSelection(SelectionEvent::Choose(_, Target::ProcessLink(id), Choice::Toggle)))
if id == f.link));
assert!(input.group.is_none() && input.press.is_none());
}
}
}
#[test]
fn mixed_group_at_endpoint_card_refuses_whole_drag_without_hidden_singleton_handle() {
for layer in [Layer::Oa, Layer::Sa] {
let f = fixture(layer);
for (end, underneath) in [(End::Source, f.nodes[0]), (End::Target, f.nodes[1])] {
let mut graph = graph(&f, &[local(underneath), Target::ProcessLink(f.link)]);
graph.selected = f.link;
let snapshot = graph.selection.clone().unwrap();
let start = handle_point(&graph, f.link, end);
// Multiple selections render no reconnect rings. The visible
// selected card owns this point, while the mixed set stays whole.
assert_eq!(graph.exact_target_at(start), Some(local(underneath)));
let mut input = Input::default();
assert!(press(&graph, &mut input, start).is_none());
assert!(input.group.is_some() && input.press.is_none());
assert!(
matches!(release(&graph, &mut input, point(&graph, f.nodes[2], false)),
Some(Message::DiagramSelection(SelectionEvent::Refuse(captured, _))) if captured == snapshot)
);
assert_eq!(graph.selection.as_ref(), Some(&snapshot));
}
}
}
@@ -360,11 +360,12 @@ fn direct_process_reconnect_delete_and_reorder_preserve_other_paths_and_sources(
assert_eq!(doc.bundle(), &reconnected);
doc.undo();
assert_eq!(doc.bundle(), &original);
let reordered = execute_batch(
&f.p,
&[direct::reorder(&f.p, f.owner, f.nodes[2], f.nodes[0]).unwrap()],
)
.unwrap();
let group_reorder::Plan::Change(command) =
direct::reorder(&f.p, f.owner, f.nodes[2], f.nodes[0]).unwrap()
else {
panic!("changed order");
};
let reordered = execute_batch(&f.p, &[*command]).unwrap();
assert_eq!(reordered.elements[&f.owner].steps().unwrap()[0], f.nodes[2]);
assert_eq!(reordered.relations, f.p.relations);
assert!(direct::reorder(&f.p, f.owner, f.nodes[0], f.nodes[1]).is_err());
@@ -0,0 +1,176 @@
//! Immutable, bounded order proposals for local Process/FunctionalChain cards.
use super::*;
use crate::diagram_usage::Target;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Destination {
Before(Id),
After(Id),
}
#[derive(Debug)]
pub(crate) enum Plan {
Noop,
Change(Box<Command>),
}
/// Captured semantic facts only: no project clone, projection or routed scene.
/// The adapter must check its document/revision/selection lifetime before commit
/// and apply the returned command through the ordinary validated transaction.
#[derive(Debug)]
pub(crate) struct Prepared {
owner: Id,
steps: Vec<Id>,
adjacency: Vec<(Id, Id)>,
legacy: bool,
ranks: BTreeMap<Id, usize>,
selected: BTreeSet<Id>,
column: usize,
}
impl Prepared {
pub(crate) fn new(project: &Project, owner: Id, targets: &[Target]) -> Result<Self, String> {
let element = project
.elements
.get(&owner)
.filter(|e| matches!(e.kind(), Kind::Process | Kind::FunctionalChain))
.ok_or("Choose a Process or Functional Chain.")?;
let steps = element
.steps()
.ok_or("This owner has no contribution order.")?;
let mut ids = BTreeSet::new();
for id in steps {
if !is_contribution(project, owner, *id) || !ids.insert(*id) {
return Err(
"Every process step must name a unique local contribution before reordering."
.into(),
);
}
}
let mut adjacency: Vec<_> = sequence_links(project, owner)
.into_iter()
.map(|link| (link.source(), link.target()))
.collect();
// Unlisted local occurrences are legal incomplete source. Rendering can
// omit their links, but order editing must not silently ignore them.
if adjacency
.iter()
.any(|(a, b)| !ids.contains(a) || !ids.contains(b))
{
return Err("A sequence link refers to an unlisted contribution; repair the process order before reordering.".into());
}
let legacy = !legacy_order(project, owner).is_empty();
if legacy {
adjacency.extend(steps.windows(2).map(|pair| (pair[0], pair[1])));
}
if targets.is_empty() {
return Err("Select at least one local contribution.".into());
}
let mut selected = BTreeSet::new();
for target in targets {
let Target::ProcessOccurrence(endpoint) = target else {
return Err(
"Reorder requires local contribution cards only; links cannot move with them."
.into(),
);
};
if !endpoint.references.is_empty() {
return Err("Open the referenced definition to reorder its contents.".into());
}
if !ids.contains(&endpoint.occurrence) || !selected.insert(endpoint.occurrence) {
return Err(
"Every selected contribution must occur exactly once in this process.".into(),
);
}
}
let ranks = column_ranks(steps, &adjacency);
let first = *selected.first().expect("Nonempty selection admitted");
let column = ranks[&first];
if selected.iter().any(|id| ranks[id] != column) {
return Err("Reorder within one column; links determine the path columns.".into());
}
Ok(Self {
owner,
steps: steps.clone(),
adjacency,
legacy,
ranks,
selected,
column,
})
}
pub(crate) fn plan(&self, destination: Destination) -> Result<Plan, String> {
let (anchor, after) = match destination {
Destination::Before(id) => (id, false),
Destination::After(id) => (id, true),
};
if self.ranks.get(&anchor) != Some(&self.column) {
return Err(
"Choose a local contribution in the selected column as the destination.".into(),
);
}
// Complete step, adjacency and typed selection admission already ran.
if self.selected.contains(&anchor) {
return Ok(Plan::Noop);
}
let indices: Vec<_> = self
.steps
.iter()
.enumerate()
.filter_map(|(i, id)| (self.ranks[id] == self.column).then_some(i))
.collect();
let block: Vec<_> = indices
.iter()
.map(|&i| self.steps[i])
.filter(|id| self.selected.contains(id))
.collect();
let mut remaining: Vec<_> = indices
.iter()
.map(|&i| self.steps[i])
.filter(|id| !self.selected.contains(id))
.collect();
let gap = remaining
.iter()
.position(|id| *id == anchor)
.ok_or("The destination slot no longer exists.")?
+ usize::from(after);
remaining.splice(gap..gap, block);
let mut ordered = self.steps.clone();
for (index, id) in indices.into_iter().zip(remaining) {
ordered[index] = id;
}
if ordered == self.steps {
return Ok(Plan::Noop);
}
// Legacy adjacency comes from order itself. In valid legacy layouts each
// card has its own column; retain the check instead of assuming that.
let ranks = if self.legacy {
let adjacency: Vec<_> = ordered.windows(2).map(|pair| (pair[0], pair[1])).collect();
column_ranks(&ordered, &adjacency)
} else {
column_ranks(&ordered, &self.adjacency)
};
if ranks != self.ranks {
return Err(
"This reorder would change process columns; the whole move was refused.".into(),
);
}
Ok(Plan::Change(Box::new(Command::SetSteps(
self.owner, ordered,
))))
}
}
#[cfg(test)]
fn plan(
project: &Project,
owner: Id,
targets: &[Target],
destination: Destination,
) -> Result<Plan, String> {
Prepared::new(project, owner, targets)?.plan(destination)
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,407 @@
use super::*;
use crate::document::Document;
use oa_core::source_project::ProjectLimits;
fn local(id: Id) -> Target {
Target::ProcessOccurrence(OccurrenceEndpoint {
references: vec![],
occurrence: id,
})
}
fn columns(project: &Project, owner: Id) -> BTreeMap<Id, usize> {
let first = project.elements[&owner].steps().unwrap()[0];
Prepared::new(project, owner, &[local(first)])
.unwrap()
.ranks
}
// Literal order [a,u,b,v,c]; a,b,c are peers. u,v are either downstream
// contributions or a separate cycle, so their slots must never change.
fn fixture(layer: Layer, cycle: bool) -> (Project, Id, [Id; 5]) {
let (f, _) = super::super::direct_tests::empty_exchange_fixture(layer);
let mut project = f.p;
let mut ids = vec![];
for name in ["a", "u", "b", "v", "c"] {
let element = occurrence(
&project,
f.owner,
name,
OccurrenceBody::Action {
action: Some(f.work[0]),
lifeline: None,
},
);
ids.push(element.id);
project = execute_batch(&project, &[Command::CreateElement(element)]).unwrap();
}
let ids: [Id; 5] = ids.try_into().unwrap();
let [a, u, b, v, _] = ids;
let pairs = if cycle {
[(u, v), (v, u)]
} else {
[(a, u), (b, v)]
};
// fresh_alias must see the first link before generating the second; two
// commands built from the same snapshot both choose "sequence_link".
for (source, target) in pairs {
let command = link(&project, source, target, "");
project = execute_batch(&project, &[command]).unwrap();
}
assert_eq!(project.elements[&f.owner].steps().unwrap(), &ids);
(project, f.owner, ids)
}
fn changed(project: &Project, owner: Id, targets: &[Target], drop: Destination) -> Command {
let Plan::Change(command) = plan(project, owner, targets, drop).unwrap() else {
panic!("Expected an actual order change");
};
assert!(matches!(command.as_ref(), Command::SetSteps(id, _) if *id == owner));
*command
}
#[test]
fn process_group_columns_reverse_noncontiguous_selection_preserves_project_and_history() {
for layer in [Layer::Oa, Layer::Sa] {
for cycle in [false, true] {
let (project, owner, [a, u, b, v, c]) = fixture(layer, cycle);
assert_eq!(
columns(&project, owner),
BTreeMap::from([
(a, 0),
(u, 1),
(b, 0),
(v, if cycle { 2 } else { 1 }),
(c, 0)
])
);
// Reverse click order must not reverse the source-ordered a,c block.
let command = changed(
&project,
owner,
&[local(c), local(a)],
Destination::After(b),
);
let mut expected = project.clone();
*expected
.elements
.get_mut(&owner)
.unwrap()
.steps_mut()
.unwrap() = vec![b, u, a, v, c];
let result = execute_batch(&project, std::slice::from_ref(&command)).unwrap();
assert_eq!(result, expected, "Nothing outside owner steps may change");
assert_eq!(columns(&result, owner), columns(&project, owner));
let mut doc =
Document::from_project(project.clone(), ProjectLimits::default()).unwrap();
let original = doc.bundle().clone();
doc.execute(command).unwrap();
assert_eq!(doc.project(), &expected);
let after = doc.bundle().clone();
let reopened = Document::from_bundle(after.clone(), ProjectLimits::default()).unwrap();
assert_eq!(reopened.project(), &expected);
assert_eq!(reopened.bundle(), &after);
assert!(doc.undo());
assert_eq!(doc.project(), &project);
assert_eq!(doc.bundle(), &original);
assert!(!doc.can_undo());
assert!(doc.redo());
assert_eq!(doc.project(), &expected);
assert_eq!(doc.bundle(), &after);
assert!(!doc.can_redo());
}
}
}
#[test]
fn process_group_columns_end_gaps_and_selected_anchor_noops() {
for layer in [Layer::Oa, Layer::Sa] {
let (project, owner, [a, u, b, v, c]) = fixture(layer, false);
for (members, drop, expected) in [
(
vec![local(c), local(b)],
Destination::Before(a),
vec![b, u, c, v, a],
),
(
vec![local(b), local(a)],
Destination::After(c),
vec![c, u, a, v, b],
),
] {
let command = changed(&project, owner, &members, drop);
assert_eq!(
execute_batch(&project, &[command]).unwrap().elements[&owner]
.steps()
.unwrap(),
&expected
);
}
let doc = Document::from_project(project.clone(), ProjectLimits::default()).unwrap();
let original = doc.bundle().clone();
for (members, drop) in [
(vec![local(b), local(c)], Destination::After(a)),
(vec![local(c), local(a)], Destination::Before(c)),
(vec![local(a), local(c)], Destination::After(a)),
(vec![local(c), local(b), local(a)], Destination::After(b)),
] {
assert!(matches!(
plan(doc.project(), owner, &members, drop),
Ok(Plan::Noop)
));
assert_eq!(doc.bundle(), &original);
assert!(!doc.can_undo());
}
}
}
#[test]
fn process_group_columns_refuse_entire_invalid_typed_sets_before_noop() {
for layer in [Layer::Oa, Layer::Sa] {
let (project, owner, [a, u, _, _, c]) = fixture(layer, true);
let foreign = super::super::direct_tests::fixture(layer).nodes[0];
let qualified = Target::ProcessOccurrence(OccurrenceEndpoint {
references: vec![a],
occurrence: c,
});
let relation = *project.relations.keys().next().unwrap();
for members in [
vec![],
vec![local(a), local(a)],
vec![local(a), local(u)],
vec![local(a), local(foreign)],
vec![local(a), qualified],
vec![local(a), Target::ProcessLink(relation)],
vec![local(a), Target::Placement(c)],
] {
// A selected anchor must not conceal invalid second members.
assert!(plan(&project, owner, &members, Destination::Before(a)).is_err());
}
assert!(plan(&project, owner, &[local(a)], Destination::After(foreign)).is_err());
assert!(plan(&project, a, &[local(a)], Destination::Before(a)).is_err());
assert!(
plan(
&project,
owner,
&[
local(u),
local(project.elements[&owner].steps().unwrap()[3])
],
Destination::Before(u)
)
.is_err()
);
}
}
#[test]
fn process_group_columns_legacy_exchange_order_refuses_cross_rank_group() {
for layer in [Layer::Oa, Layer::Sa] {
let (f, next) = super::super::direct_tests::empty_exchange_fixture(layer);
let mut project = f.p;
let mut ids = vec![];
for (name, exchange) in [("first", f.exchange), ("second", next)] {
let element = occurrence(
&project,
f.owner,
name,
OccurrenceBody::Exchange {
exchange: Some(exchange),
sender: None,
receiver: None,
message_kind: MessageKind::default(),
reply_to: None,
values: vec![],
operation: None,
},
);
ids.push(element.id);
project = execute_batch(&project, &[Command::CreateElement(element)]).unwrap();
}
assert_eq!(legacy_order(&project, f.owner), ids);
assert!(
plan(
&project,
f.owner,
&[local(ids[0]), local(ids[1])],
Destination::Before(ids[0])
)
.is_err()
);
assert!(matches!(
plan(
&project,
f.owner,
&[local(ids[1])],
Destination::After(ids[1])
),
Ok(Plan::Noop)
));
}
}
#[test]
fn process_group_columns_accept_all_local_card_kinds_and_different_heights() {
for layer in [Layer::Oa, Layer::Sa] {
let (f, _) = super::super::direct_tests::empty_exchange_fixture(layer);
let mut project = f.p;
let source = project
.make_element(
project.elements[&f.owner].kind(),
layer,
"Reusable definition",
None,
)
.unwrap();
let source_id = source.id;
project = execute_batch(&project, &[Command::CreateElement(source)]).unwrap();
let bodies = [
OccurrenceBody::Action {
action: Some(f.work[0]),
lifeline: None,
},
OccurrenceBody::Exchange {
exchange: Some(f.exchange),
sender: None,
receiver: None,
message_kind: MessageKind::default(),
reply_to: None,
values: vec![],
operation: None,
},
OccurrenceBody::Control {
kind: ControlKind::Choice,
},
OccurrenceBody::SequenceReference {
sequence: Some(source_id),
},
OccurrenceBody::Action {
action: Some(f.work[0]),
lifeline: None,
},
];
let mut ids = vec![];
for (index, body) in bodies.into_iter().enumerate() {
let element = occurrence(&project, f.owner, &format!("Card {index}"), body);
ids.push(element.id);
project = execute_batch(&project, &[Command::CreateElement(element)]).unwrap();
}
let graph = Graph::new(&project, f.owner, ids[0]);
assert_ne!(graph.nodes[0].rect.height, graph.nodes[3].rect.height);
let selected = vec![local(ids[3]), local(ids[2]), local(ids[1]), local(ids[0])];
let command = changed(&project, f.owner, &selected, Destination::After(ids[4]));
let mut expected = project.clone();
*expected
.elements
.get_mut(&f.owner)
.unwrap()
.steps_mut()
.unwrap() = vec![ids[4], ids[0], ids[1], ids[2], ids[3]];
assert_eq!(execute_batch(&project, &[command]).unwrap(), expected);
}
}
#[test]
fn process_group_columns_full_order_admission_precedes_selected_anchor_noop() {
for layer in [Layer::Oa, Layer::Sa] {
let (mut project, owner, [a, u, b, v, c]) = fixture(layer, false);
let other = project
.make_element(project.elements[&owner].kind(), layer, "Other", None)
.unwrap();
let other_owner = other.id;
project = execute_batch(&project, &[Command::CreateElement(other)]).unwrap();
let foreign = occurrence(
&project,
other_owner,
"Foreign local occurrence",
OccurrenceBody::Control {
kind: ControlKind::Choice,
},
);
let foreign_id = foreign.id;
project = execute_batch(&project, &[Command::CreateElement(foreign)]).unwrap();
assert!(Prepared::new(&project, owner, &[local(a), local(foreign_id)]).is_err());
let prepared = Prepared::new(&project, owner, &[local(a)]).unwrap();
assert!(prepared.plan(Destination::Before(foreign_id)).is_err());
// Corrupt/incomplete imported orders must fail even when only the valid
// card is selected and the requested destination would be a no-op.
for order in [
vec![a, u, b, v, c, c],
vec![a, u, b, v, Id::new_v4()],
vec![a, u, b, v, foreign_id],
vec![a, b, v, c], // local a -> u remains, but u is unlisted
] {
let mut incomplete = project.clone();
*incomplete
.elements
.get_mut(&owner)
.unwrap()
.steps_mut()
.unwrap() = order;
let original = incomplete.clone();
assert!(plan(&incomplete, owner, &[local(a)], Destination::Before(a)).is_err());
assert_eq!(incomplete, original);
}
}
}
#[test]
fn process_group_columns_renderer_omits_links_to_unlisted_nodes_without_panicking() {
for layer in [Layer::Oa, Layer::Sa] {
let (project, owner, [a, u, b, v, c]) = fixture(layer, false);
for (omitted, expected_ids, expected_ranks) in [
(
u,
vec![a, b, v, c],
BTreeMap::from([(a, 0), (b, 0), (v, 1), (c, 0)]),
),
(
a,
vec![u, b, v, c],
BTreeMap::from([(u, 0), (b, 0), (v, 1), (c, 0)]),
),
] {
// This omission is core-valid incomplete source, not a renderer-only
// manufactured dangling element. The semantic occurrence remains.
let incomplete =
execute_batch(&project, &[Command::SetSteps(owner, expected_ids.clone())]).unwrap();
assert!(incomplete.elements.contains_key(&omitted));
let graph = Graph::new(&incomplete, owner, b);
assert_eq!(
graph.nodes.iter().map(|node| node.id).collect::<Vec<_>>(),
expected_ids
);
assert_eq!(graph.edges.len(), 1);
assert_eq!((graph.edges[0].source, graph.edges[0].target), (b, v));
for node in &graph.nodes {
assert_eq!(node.rect.x, 24. + expected_ranks[&node.id] as f32 * 420.);
}
assert!(Prepared::new(&incomplete, owner, &[local(b)]).is_err());
}
}
}
#[test]
fn process_group_columns_parallel_links_keep_exact_ranks_and_relation_records() {
for layer in [Layer::Oa, Layer::Sa] {
let (mut project, owner, [a, u, b, v, c]) = fixture(layer, false);
project = execute_batch(&project, &[link(&project, a, u, "alternative")]).unwrap();
assert_eq!(
columns(&project, owner),
BTreeMap::from([(a, 0), (u, 1), (b, 0), (v, 1), (c, 0)])
);
let prepared = Prepared::new(&project, owner, &[local(c), local(b)]).unwrap();
let Plan::Change(command) = prepared.plan(Destination::Before(a)).unwrap() else {
panic!("Expected move");
};
let result = execute_batch(&project, &[*command]).unwrap();
assert_eq!(
result.elements[&owner].steps().unwrap(),
&vec![b, u, c, v, a]
);
assert_eq!(result.relations, project.relations);
assert_eq!(columns(&result, owner), prepared.ranks);
assert_eq!(Graph::new(&result, owner, b).edges.len(), 3);
}
}
@@ -1,5 +1,320 @@
use super::*;
#[tokio::test]
async fn production_process_multiselect_modifiers_survive_focus_busy_and_clear_on_window_loss() {
use crate::behavior::canvas_workspace::tests::Mounted;
use iced::keyboard::{Event as KeyEvent, Modifiers};
let mut f = super::super::direct_tests::fixture(Layer::Oa);
f.p = execute_batch(&f.p, &[Command::Delete(f.link)]).unwrap();
let mut app = App::with_project(f.p);
let _ = app.update(Message::Select(f.owner));
let _ = app.update(Message::Page(crate::app::Page::Behavior));
app.window_size = Size::new(1440., 900.);
let mut mounted = Mounted::new(&mut app).await;
let bounds = mounted.geometry(&app).bounds("process-viewport");
let at = |app: &App, id| {
let graph = Graph::new(app.project(), f.owner, id);
let rect = graph.nodes.iter().find(|n| n.id == id).unwrap().rect;
bounds.position()
+ app.behavior.topology.pan
+ iced::Vector::new(rect.x + 60., rect.y + 20.)
* app
.behavior
.topology
.scale(graph.size, (app.window_size.width - 600.).max(200.))
};
let shift = |held| {
iced::Event::Keyboard(KeyEvent::ModifiersChanged(if held {
Modifiers::SHIFT
} else {
Modifiers::empty()
}))
};
let first = at(&app, f.nodes[2]);
mounted
.batch(&mut app, &[shift(true)], mouse::Cursor::Available(first))
.await;
mounted.click(&mut app, first).await;
let second = at(&app, f.nodes[0]);
mounted.click(&mut app, second).await;
assert_eq!(
app.multiselect_snapshot().unwrap().targets().len(),
2,
"Shift before canvas focus reaches both clicks"
);
assert_eq!(mounted.geometry(&app).bounds("process-viewport"), bounds);
app.busy = true;
mounted.settle(&mut app).await;
mounted
.batch(&mut app, &[shift(false)], mouse::Cursor::Available(second))
.await;
app.busy = false;
mounted.settle(&mut app).await;
let third = at(&app, f.nodes[1]);
mounted.click(&mut app, third).await;
assert_eq!(
app.multiselect_snapshot().unwrap().targets().len(),
1,
"Shift released during busy is not resurrected"
);
app.busy = true;
mounted
.batch(&mut app, &[shift(true)], mouse::Cursor::Available(second))
.await;
app.busy = false;
mounted.settle(&mut app).await;
mounted.click(&mut app, second).await;
assert_eq!(
app.multiselect_snapshot().unwrap().targets().len(),
2,
"Shift held through busy survives child lifetime"
);
mounted
.batch(
&mut app,
&[iced::Event::Window(iced::window::Event::Unfocused)],
mouse::Cursor::Unavailable,
)
.await;
mounted.click(&mut app, first).await;
assert_eq!(
app.multiselect_snapshot().unwrap().targets().len(),
1,
"real window loss clears held modifiers"
);
assert_eq!(mounted.geometry(&app).bounds("process-viewport"), bounds);
assert!(!app.document.can_undo());
let pan = app.behavior.topology.pan;
let source = app.document.bundle().clone();
let blank = bounds.position() + iced::Vector::new(bounds.width - 40., bounds.height - 40.);
let end = blank + iced::Vector::new(-25., -20.);
mounted
.batch(
&mut app,
&[
iced::Event::Mouse(mouse::Event::CursorMoved { position: blank }),
iced::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
],
mouse::Cursor::Available(blank),
)
.await;
assert!(app.multiselect_snapshot().unwrap().targets().is_empty());
assert_eq!(app.behavior.topology.pan, pan);
assert_eq!(app.document.bundle(), &source);
// Each batch rebuilds the production tree and applies all App messages.
// Selection Clear must not retire the camera pan started by its press.
assert_eq!(mounted.geometry(&app).bounds("process-viewport"), bounds);
mounted
.batch(
&mut app,
&[iced::Event::Mouse(mouse::Event::CursorMoved {
position: end,
})],
mouse::Cursor::Available(end),
)
.await;
assert_eq!(
app.behavior.topology.pan,
pan + iced::Vector::new(-25., -20.)
);
assert_eq!(app.document.bundle(), &source);
assert_eq!(mounted.geometry(&app).bounds("process-viewport"), bounds);
mounted
.batch(
&mut app,
&[iced::Event::Mouse(mouse::Event::ButtonReleased(
mouse::Button::Left,
))],
mouse::Cursor::Available(end),
)
.await;
assert!(app.multiselect_snapshot().unwrap().targets().is_empty());
assert_eq!(
app.behavior.topology.pan,
pan + iced::Vector::new(-25., -20.)
);
assert_eq!(app.document.bundle(), &source);
assert!(!app.document.can_undo());
}
#[tokio::test]
async fn production_process_multiselect_reverse_click_group_drop_has_one_history_step_and_stable_camera()
{
use crate::behavior::canvas_workspace::tests::Mounted;
for layer in [Layer::Oa, Layer::Sa] {
let mut f = super::super::direct_tests::fixture(layer);
f.p = execute_batch(&f.p, &[Command::Delete(f.link)]).unwrap();
let mut app = App::with_project(f.p);
let _ = app.update(Message::Select(f.owner));
let _ = app.update(Message::Page(crate::app::Page::Behavior));
app.window_size = Size::new(1440., 900.);
let mut mounted = Mounted::new(&mut app).await;
let bounds = mounted.geometry(&app).bounds("process-viewport");
let graph = Graph::new(app.project(), f.owner, f.owner);
let scale = app
.behavior
.topology
.scale(graph.size, (app.window_size.width - 600.).max(200.));
let at = |id, after| {
let rect = graph.nodes.iter().find(|n| n.id == id).unwrap().rect;
bounds.position()
+ iced::Vector::new(
rect.x + 60.,
rect.y + if after { rect.height - 15. } else { 20. },
) * scale
};
let points = [
at(f.nodes[2], false),
at(f.nodes[0], false),
at(f.nodes[1], true),
];
drop(graph);
mounted
.batch(
&mut app,
&[iced::Event::Keyboard(
iced::keyboard::Event::ModifiersChanged(iced::keyboard::Modifiers::SHIFT),
)],
mouse::Cursor::Available(points[0]),
)
.await;
mounted.click(&mut app, points[0]).await;
mounted.click(&mut app, points[1]).await;
mounted
.batch(
&mut app,
&[iced::Event::Keyboard(
iced::keyboard::Event::ModifiersChanged(iced::keyboard::Modifiers::empty()),
)],
mouse::Cursor::Available(points[1]),
)
.await;
let snapshot = app.multiselect_snapshot().unwrap();
let source = app.document.bundle().clone();
let camera = (app.behavior.topology.pan, app.behavior.topology.zoom);
// Reuse the existing opt-in raw WGPU evidence convention. These are
// actual production-tree pixels, not a reconstructed preview image.
let record_feedback = |stage: &str, mounted: &mut Mounted, app: &App, cursor: Point| {
let Some(path) = std::env::var_os("ARCADIA_RENDER_EVIDENCE_DIR") else {
return;
};
let path = std::path::PathBuf::from(path);
std::fs::create_dir_all(&path).unwrap();
let name = format!("process-{layer:?}-{stage}");
let pixels = mounted.pixels_at(app, mouse::Cursor::Available(cursor));
let width = app.window_size.width as u32;
let height = app.window_size.height as u32;
assert_eq!(pixels.len(), width as usize * height as usize * 4);
let mut file = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(path.join(format!("{name}.rgba")))
.unwrap();
std::io::Write::write_all(&mut file, &pixels).unwrap();
let metadata = serde_json::json!({
"width": width, "height": height, "format": "RGBA8",
"stage": stage, "layer": format!("{layer:?}"),
"canvas": [bounds.x, bounds.y, bounds.width, bounds.height],
"cursor": [cursor.x, cursor.y], "scale": scale,
"camera": [app.behavior.topology.pan.x, app.behavior.topology.pan.y],
"selectionCount": snapshot.targets().len(),
"selection": format!("{snapshot:?}"),
"source": app.document.bundle(),
});
let mut file = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(path.join(format!("{name}.json")))
.unwrap();
std::io::Write::write_all(&mut file, &serde_json::to_vec_pretty(&metadata).unwrap())
.unwrap();
assert_eq!(app.document.bundle(), &source);
assert_eq!(app.multiselect_snapshot().unwrap(), snapshot);
assert!(!app.document.can_undo());
};
record_feedback("before-press", &mut mounted, &app, points[1]);
let pressed = mounted
.batch(
&mut app,
&[
iced::Event::Mouse(mouse::Event::CursorMoved {
position: points[1],
}),
iced::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
],
mouse::Cursor::Available(points[1]),
)
.await;
assert!(
!pressed
.iter()
.any(|message| matches!(message, Message::DiagramSelection(_)))
);
assert_eq!(app.multiselect_snapshot().unwrap(), snapshot);
assert_eq!(app.document.bundle(), &source);
assert!(!app.document.can_undo());
assert_eq!(mounted.geometry(&app).bounds("process-viewport"), bounds);
let moved = mounted
.batch(
&mut app,
&[iced::Event::Mouse(mouse::Event::CursorMoved {
position: points[2],
})],
mouse::Cursor::Available(points[2]),
)
.await;
assert!(
!moved
.iter()
.any(|message| matches!(message, Message::DiagramSelection(_)))
);
assert_eq!(app.multiselect_snapshot().unwrap(), snapshot);
assert_eq!(app.document.bundle(), &source);
assert!(!app.document.can_undo());
assert_eq!(mounted.geometry(&app).bounds("process-viewport"), bounds);
assert_eq!(
(app.behavior.topology.pan, app.behavior.topology.zoom),
camera
);
record_feedback("after-motion", &mut mounted, &app, points[2]);
let messages = mounted
.batch(
&mut app,
&[iced::Event::Mouse(mouse::Event::ButtonReleased(
mouse::Button::Left,
))],
mouse::Cursor::Available(points[2]),
)
.await;
assert!(
messages.iter().any(|m| matches!(
m,
Message::DiagramSelection(crate::app::multiselect::Event::ProcessReorder(_, _))
)),
"{messages:?}"
);
assert_eq!(
app.project().elements[&f.owner].steps().unwrap(),
&vec![f.nodes[1], f.nodes[0], f.nodes[2]]
);
assert_eq!(
app.multiselect_snapshot().unwrap().targets(),
snapshot.targets()
);
assert_eq!(mounted.geometry(&app).bounds("process-viewport"), bounds);
assert_eq!(
(app.behavior.topology.pan, app.behavior.topology.zoom),
camera
);
let changed = app.document.bundle().clone();
let _ = app.update(Message::Undo);
assert_eq!(app.document.bundle(), &source);
assert!(!app.document.can_undo());
let _ = app.update(Message::Redo);
assert_eq!(app.document.bundle(), &changed);
}
}
#[tokio::test]
async fn production_process_canvas_scaled_path_creation_preserves_source_and_editor_bounds() {
use crate::behavior::canvas_workspace::{self, tests::Mounted};
@@ -23,6 +23,38 @@ impl UsageSnapshot {
}
impl State {
pub(crate) fn selection_address(&self) -> Option<oa_core::topology_context::ContextAddress> {
self.selected_context.clone()
}
pub(crate) fn selection_targets(&self, p: &Project, owner: Id) -> Vec<Target> {
let graph = self.usage_graph(p, owner);
let mut targets = vec![];
for node in &graph.nodes {
targets.push(Target::ProcessOccurrence(OccurrenceEndpoint {
references: vec![],
occurrence: node.id,
}));
targets.extend(
node.preview
.iter()
.map(|(endpoint, _)| Target::ProcessOccurrence(endpoint.clone())),
);
for link in &node.preview_links {
let target = Target::ProcessPreviewLink {
relation: link.relation,
source: link.source.clone(),
target: link.target.clone(),
};
if graph.usage_bounds(&target).is_some() {
targets.push(target);
}
}
}
targets.extend(graph.edges.iter().map(|edge| Target::ProcessLink(edge.id)));
targets
}
pub(crate) fn usage_snapshot(&self) -> UsageSnapshot {
UsageSnapshot {
identity: self.identity,
@@ -66,7 +66,8 @@ pub(super) fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Ele
let pending = state.draft.is_some();
let ordered = !legacy_order(p, owner).is_empty();
let selected = is_contribution(p, owner, app.selected).then_some(app.selected);
let clean = !app.behavior.has_pending() && !app.draft_dirty() && !app.source_dirty();
let clean =
!app.busy && !app.behavior.has_pending() && !app.draft_dirty() && !app.source_dirty();
// A fixed sibling keeps Iced's canvas scroll state through selection/form changes.
let mut navigation = column![].spacing(6);
if let Some(Relation {
@@ -183,6 +184,7 @@ pub(super) fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Ele
&state.expanded,
&state.contextual_expanded,
);
graph.selection = app.multiselect_snapshot();
graph.selected_preview = state
.qualified_selection(p, owner)
.filter(|address| {
@@ -195,6 +197,11 @@ pub(super) fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Ele
== app.selected
})
.map(|address| address.endpoint.clone());
if let Some(crate::diagram_usage::Target::ProcessOccurrence(endpoint)) =
graph.selection.as_ref().and_then(|s| s.primary())
{
graph.selected_preview = (!endpoint.references.is_empty()).then(|| endpoint.clone());
}
graph.selected_preview_link = app
.diagram_focus(crate::diagram_usage::Surface::Process(owner))
.and_then(|usage| match &usage.target {
@@ -255,7 +262,8 @@ pub(super) fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Ele
event(Event::Shelf(Shelf::Selection)),
move |zoom| Message::TopologyCanvas(CanvasEvent::Zoom(context, zoom)),
camera
)
),
selection_controls(app)
]
.spacing(4);
let entries = [
@@ -321,7 +329,7 @@ pub(super) fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Ele
)
}),
);
let details = scrollable(navigation.push(text("Layout is derived. The reorder handle changes display order within its column; it does not author a position.").size(11)))
let details = scrollable(navigation.push(text("Drag selected local cards to the upper or lower half of a card to insert before or after it in the same column. The group keeps source order; paths stay unchanged. Qualified previews remain read-only.").size(11)))
.id("process-details").width(Fill).height(Fill).into();
let mut form_panel: Element<'_, Message> = iced::widget::Space::new().height(0).into();
if let Some(d) = state.draft.as_ref().filter(|d| d.owner == owner) {
@@ -551,6 +559,42 @@ pub(super) fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Ele
super::super::canvas_workspace::host(header.into(), palette, canvas, editor)
}
fn selection_controls(app: &App) -> Element<'_, Message> {
use crate::app::multiselect::Event as SelectionEvent;
let snapshot = app.multiselect_snapshot();
let enabled = !app.busy
&& !app.behavior.has_pending()
&& !app.draft_dirty()
&& !app.source_dirty()
&& app.behavior.topology.tool == CanvasTool::Select;
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().is_empty()) {
controls = controls.push(button("Clear selection").on_press_maybe(
enabled.then(|| Message::DiagramSelection(SelectionEvent::Clear(snapshot.context()))),
));
controls = controls.push(
label(format!(
"Properties: {}",
short(app.project().named(app.selected), 24)
))
.wrapping(iced::widget::text::Wrapping::None),
);
}
// These two rows exist even with an empty set so selection never moves the
// canvas viewport or changes the camera's interpretation of the pointer.
column![
controls,
label("Shift+click toggles · drag selected cards to reorder · previews are read-only")
.wrapping(iced::widget::text::Wrapping::None)
]
.height(56)
.into()
}
fn editor_panel<'a, R: iced::advanced::text::Renderer + 'a>(
session: Id,
actions: iced::Element<'a, Message, iced::Theme, R>,
@@ -1,480 +0,0 @@
// Included in connection_edit::tests: exercise the real application event path.
use oa_core::projection::project_view;
fn allocation_fixture(layer: Layer, projection: ProjectionKind) -> (App, Id, Id, Id) {
let mut project = Project::empty();
let (performer_kind, work_kind) = match layer {
Layer::Oa => (Kind::Entity, Kind::Activity),
Layer::Sa => (Kind::System, Kind::Function),
};
let performer = project.add(performer_kind, "Assigned performer", 20., 20.);
let work = project.add(work_kind, "Existing leaf work", 420., 20.);
let diagram = project.ensure_diagram(layer);
project.diagrams.get_mut(&diagram).unwrap().projection = projection;
let reference_id = Id::new_v4();
let mut reference = project.diagrams[&diagram].clone();
reference.id = reference_id;
reference.alias = project.fresh_alias("Reference view", "diagram", reference_id);
reference.name = "Reference view".into();
reference.nodes.clear();
project.diagrams.insert(reference_id, reference);
project.place(reference_id, performer, Position { x: 80., y: 100. });
project.place(reference_id, work, Position { x: 500., y: 300. });
let mut app = App::with_project(project);
let _ = app.update(Message::Diagram(crate::app::Choice {
id: diagram,
label: app.project().diagrams[&diagram].name.clone(),
}));
(app, performer, work, diagram)
}
fn start_allocation(app: &mut App, performer: Id) -> Context {
send(app, Event::Choose(Some(Tool::Allocation)));
let context = app.route_context().unwrap();
assert!(
app.connection
.as_ref()
.unwrap()
.candidates
.contains(&performer)
);
send(app, Event::Endpoint(context, performer));
assert_eq!(app.connection.as_ref().unwrap().source, Some(performer));
context
}
#[test]
fn allocation_projection_eligibility_requires_both_endpoint_categories() {
for layer in [Layer::Oa, Layer::Sa] {
for projection in [
ProjectionKind::Overview,
ProjectionKind::Decomposition,
ProjectionKind::Traceability,
ProjectionKind::Allocation,
] {
assert!(Tool::Allocation.available_in(layer, projection));
}
for projection in [
ProjectionKind::FunctionalDataflow,
ProjectionKind::Capabilities,
ProjectionKind::StateMachine {
machine: Id::new_v4(),
},
] {
assert!(!Tool::Allocation.available_in(layer, projection));
}
for projection in [
ProjectionKind::FunctionalDataflow,
ProjectionKind::Capabilities,
] {
let (mut app, _, _, _) = allocation_fixture(layer, projection);
let before = app.document.bundle().clone();
send(&mut app, Event::Choose(Some(Tool::Allocation)));
assert!(app.connection.is_none());
assert_eq!(app.document.bundle(), &before);
}
}
// The new predicate retains the four established exchange-tool policies.
assert!(Tool::Interaction.available_in(Layer::Oa, ProjectionKind::FunctionalDataflow));
assert!(Tool::FunctionalExchange.available_in(Layer::Sa, ProjectionKind::FunctionalDataflow));
assert!(Tool::CommunicationMean.available_in(Layer::Oa, ProjectionKind::Capabilities));
assert!(Tool::BoundaryExchange.available_in(Layer::Sa, ProjectionKind::Capabilities));
assert!(!Tool::FunctionalExchange.available_in(Layer::Oa, ProjectionKind::Overview));
}
#[test]
fn allocation_canvas_commits_only_one_relation_with_exact_source_undo_redo() {
use oa_core::source_project::{ProjectLimits, compile_bundle};
for layer in [Layer::Oa, Layer::Sa] {
for projection in [
ProjectionKind::Overview,
ProjectionKind::Decomposition,
ProjectionKind::Traceability,
ProjectionKind::Allocation,
] {
let (mut app, performer, work, diagram) = allocation_fixture(layer, projection);
let original = app.project().clone();
let before = app.document.bundle().clone();
let revision = app.document.revision();
let context = start_allocation(&mut app, performer);
assert_eq!(app.document.bundle(), &before);
send(&mut app, Event::Endpoint(context, work));
assert!(app.connection.is_none());
assert_eq!(app.document.revision(), revision + 1);
let relation = &app.project().relations[&app.selected];
let name = "Allocation: Assigned performer → Existing leaf work";
let expected_relation = Relation {
id: relation.id,
alias: original.fresh_alias(name, "relation", relation.id),
name: name.into(),
description: String::new(),
body: RelationBody::new(RelationKind::Allocation, performer, work),
};
let relation_id = relation.id;
let mut expected = original.clone();
expected.relations.insert(relation_id, expected_relation);
assert_eq!(app.project(), &expected, "Only the new relation may change");
assert_eq!(app.active_diagram, Some(diagram));
assert!(
app.status
.contains("Allocated Existing leaf work to Assigned performer")
);
let projected = project_view(app.project(), diagram);
if projection == ProjectionKind::Allocation {
assert!(!projected.edges.iter().any(|edge| edge.id == relation_id));
let owner = projected
.nodes
.iter()
.find(|node| node.element == performer)
.unwrap();
let child = projected
.nodes
.iter()
.find(|node| node.element == work)
.unwrap();
assert_eq!(child.visual_parent, Some(owner.id));
} else {
assert!(projected.edges.iter().any(|edge| edge.id == relation_id));
}
let after = app.document.bundle().clone();
assert_eq!(
compile_bundle(&after, ProjectLimits::default())
.unwrap()
.decoded
.project,
expected
);
send(&mut app, Event::Endpoint(context, work));
assert_eq!(
app.document.revision(),
revision + 1,
"Repeated release is not another transaction"
);
let _ = app.update(Message::Undo);
assert_eq!(app.project(), &original);
assert_eq!(app.document.bundle(), &before);
let _ = app.update(Message::Redo);
assert_eq!(app.project(), &expected);
assert_eq!(app.document.bundle(), &after);
}
}
}
#[test]
fn allocation_candidates_reject_nonleaf_occupied_cross_layer_port_and_hidden_work() {
for layer in [Layer::Oa, Layer::Sa] {
let (base, performer, work, diagram) =
allocation_fixture(layer, ProjectionKind::Traceability);
let mut project = base.project().clone();
let work_kind = project.elements[&work].kind();
let composite = project
.add_element(work_kind, layer, "Composite work", None)
.unwrap();
project
.add_element(work_kind, layer, "Child work", Some(composite))
.unwrap();
let occupied = project
.add_element(work_kind, layer, "Occupied work", None)
.unwrap();
let allocation = Tool::Allocation
.command(&project, performer, occupied)
.unwrap();
project = prepare_command(&project, &allocation).unwrap().project;
let hidden = project
.add_element(work_kind, layer, "Unplaced work", None)
.unwrap();
let other_layer = if layer == Layer::Oa {
Layer::Sa
} else {
Layer::Oa
};
let other_kind = if other_layer == Layer::Oa {
Kind::Activity
} else {
Kind::Function
};
let foreign = project
.add_element(other_kind, other_layer, "Other perspective", None)
.unwrap();
let foreign_performer_kind = if other_layer == Layer::Oa {
Kind::Actor
} else {
Kind::SystemActor
};
let foreign_performer = project
.add_element(foreign_performer_kind, other_layer, "Other performer", None)
.unwrap();
for (index, id) in [composite, occupied, foreign, foreign_performer]
.into_iter()
.enumerate()
{
project.place(
diagram,
id,
Position {
x: index as f32 * 300.,
y: 400.,
},
);
}
let port_owner = if layer == Layer::Sa { work } else { foreign };
let port_id = Id::new_v4();
let port = make_port(
&project,
port_id,
port_owner,
"Work input",
PortContract::FunctionInput {
incoming_items: vec![],
},
)
.unwrap();
project.elements.insert(port_id, port);
let mut app = App::with_project(project);
let _ = app.update(Message::Diagram(crate::app::Choice {
id: diagram,
label: app.project().diagrams[&diagram].name.clone(),
}));
let before = app.document.bundle().clone();
let original = app.project().clone();
let revision = app.document.revision();
send(&mut app, Event::Choose(Some(Tool::Allocation)));
assert!(
app.connection
.as_ref()
.unwrap()
.candidates
.contains(&foreign_performer)
);
let context = app.route_context().unwrap();
send(&mut app, Event::Endpoint(context, performer));
assert!(app.connection.as_ref().unwrap().candidates.contains(&work));
assert!(Tool::Allocation.can_connect(app.project(), performer, hidden));
for target in [composite, occupied, foreign, port_id, hidden, performer] {
assert!(
!app.connection
.as_ref()
.unwrap()
.candidates
.contains(&target)
);
send(&mut app, Event::Endpoint(context, target));
assert_eq!(app.document.revision(), revision);
assert_eq!(app.project(), &original);
assert_eq!(app.document.bundle(), &before);
}
for target in [composite, occupied, foreign, port_id, performer] {
assert!(
Tool::Allocation
.command(app.project(), performer, target)
.is_err()
);
}
send(&mut app, Event::Endpoint(context, work));
assert_eq!(
app.document.revision(),
revision + 1,
"Invalid clicks do not prevent recovery"
);
}
}
#[test]
fn allocation_creation_preserves_saved_edge_filters() {
let (base, performer, work, diagram) =
allocation_fixture(Layer::Oa, ProjectionKind::Decomposition);
let mut project = base.project().clone();
project
.diagrams
.get_mut(&diagram)
.unwrap()
.view_options
.relation_kinds = oa_core::view_options::RelationKinds::Only(vec![]);
let diagrams = project.diagrams.clone();
let mut app = App::with_project(project);
let _ = app.update(Message::Diagram(crate::app::Choice {
id: diagram,
label: app.project().diagrams[&diagram].name.clone(),
}));
let context = start_allocation(&mut app, performer);
send(&mut app, Event::Endpoint(context, work));
assert_eq!(
app.project().relations[&app.selected].kind(),
RelationKind::Allocation
);
assert_eq!(app.project().diagrams, diagrams);
assert!(project_view(app.project(), diagram).edges.is_empty());
assert!(app.status.contains("view filters are unchanged"));
}
#[test]
fn allocation_mixed_view_keeps_captured_canvas_for_opposite_layer_pairs() {
for owner_layer in [Layer::Oa, Layer::Sa] {
for app_layer in [Layer::Oa, Layer::Sa] {
let (base, owner_performer, owner_work, diagram) =
allocation_fixture(owner_layer, ProjectionKind::Traceability);
let mut project = base.project().clone();
let layer = if owner_layer == Layer::Oa {
Layer::Sa
} else {
Layer::Oa
};
let (performer_kind, work_kind) = if layer == Layer::Oa {
(Kind::Actor, Kind::Activity)
} else {
(Kind::SystemActor, Kind::Function)
};
let performer = project.add(performer_kind, "Opposite-layer performer", 40., 60.);
let work = project.add(work_kind, "Opposite-layer work", 420., 60.);
project.place(diagram, performer, Position { x: 40., y: 350. });
project.place(diagram, work, Position { x: 420., y: 350. });
let mut app = App::with_project(project);
let _ = app.update(Message::Diagram(crate::app::Choice {
id: diagram,
label: app.project().diagrams[&diagram].name.clone(),
}));
let _ = app.update(Message::Pan(iced::Vector::new(73., 29.)));
app.layer = app_layer;
let camera = (app.pan, app.zoom);
let before = app.document.bundle().clone();
let original = app.project().clone();
let revision = app.document.revision();
let cancelled = start_allocation(&mut app, performer);
let _ = app.update(Message::Escape);
send(&mut app, Event::Endpoint(cancelled, work));
assert!(app.connection.is_none());
assert_eq!(app.layer, app_layer);
assert_eq!(app.active_diagram, Some(diagram));
assert_eq!((app.pan, app.zoom), camera);
assert_eq!(app.document.bundle(), &before);
let context = start_allocation(&mut app, performer);
assert_eq!(app.active_diagram, Some(diagram));
assert_eq!(app.layer, app_layer);
assert_eq!(app.route_context(), Some(context));
assert_eq!((app.pan, app.zoom), camera);
assert_eq!(app.selected, performer);
assert!(app.connection.as_ref().unwrap().candidates.contains(&work));
for wrong_target in [owner_performer, owner_work] {
assert!(
!app.connection
.as_ref()
.unwrap()
.candidates
.contains(&wrong_target)
);
send(&mut app, Event::Endpoint(context, wrong_target));
assert_eq!(app.project(), &original);
assert_eq!(app.layer, app_layer);
assert_eq!(app.active_diagram, Some(diagram));
assert_eq!((app.pan, app.zoom), camera);
assert_eq!(app.document.bundle(), &before);
}
send(&mut app, Event::Endpoint(context, work));
assert!(app.connection.is_none());
assert_eq!(app.document.revision(), revision + 1);
assert_eq!(app.active_diagram, Some(diagram));
assert_eq!(app.layer, owner_layer);
assert_eq!((app.pan, app.zoom), camera);
let relation = &app.project().relations[&app.selected];
assert_eq!(
relation.body,
RelationBody::new(RelationKind::Allocation, performer, work)
);
let mut expected = original.clone();
expected.relations.insert(relation.id, relation.clone());
assert_eq!(app.project(), &expected);
let after = app.document.bundle().clone();
let _ = app.update(Message::Undo);
assert_eq!(app.project(), &original);
assert_eq!(app.document.bundle(), &before);
assert_eq!(app.active_diagram, Some(diagram));
assert_eq!((app.pan, app.zoom), camera);
let _ = app.update(Message::Redo);
assert_eq!(app.project(), &expected);
assert_eq!(app.document.bundle(), &after);
assert_eq!(app.active_diagram, Some(diagram));
assert_eq!((app.pan, app.zoom), camera);
}
}
}
#[test]
fn allocation_cancel_navigation_stale_context_and_pending_edits_preserve_model() {
let (mut app, performer, work, diagram) =
allocation_fixture(Layer::Oa, ProjectionKind::Decomposition);
let original = app.project().clone();
let before = app.document.bundle().clone();
let old = start_allocation(&mut app, performer);
let _ = app.update(Message::Escape);
assert!(app.connection.is_none());
send(&mut app, Event::Endpoint(old, work));
let current = start_allocation(&mut app, performer);
send(&mut app, Event::Endpoint(old, work));
assert_eq!(app.connection.as_ref().unwrap().context, current);
let _ = app.update(Message::Page(Page::Catalog));
assert!(app.connection.is_none());
let _ = app.update(Message::Page(Page::Diagram));
let _ = app.update(Message::Diagram(crate::app::Choice {
id: diagram,
label: app.project().diagrams[&diagram].name.clone(),
}));
start_allocation(&mut app, performer);
send(&mut app, Event::Choose(None));
assert!(app.connection.is_none());
start_allocation(&mut app, performer);
send(&mut app, Event::Choose(Some(Tool::Interaction)));
assert_eq!(app.connection.as_ref().unwrap().tool, Tool::Interaction);
assert_eq!(app.project(), &original);
let current = start_allocation(&mut app, performer);
for stale in [
Context {
document: Id::new_v4(),
..current
},
Context {
diagram: Id::new_v4(),
..current
},
Context {
revision: current.revision + 1,
..current
},
] {
send(&mut app, Event::Endpoint(stale, work));
assert_eq!(app.connection.as_ref().unwrap().context, current);
}
app.delete_pending = true;
send(&mut app, Event::Endpoint(current, work));
assert_eq!(app.project(), &original);
let _ = app.update(Message::CancelDelete);
let _ = app.update(Message::Escape);
let _ = app.update(Message::Select(performer));
let _ = app.update(Message::Field(
"/name".into(),
"Unapplied performer name".into(),
));
send(&mut app, Event::Choose(Some(Tool::Allocation)));
assert!(app.connection.is_none());
assert!(app.draft_dirty());
assert_eq!(app.project(), &original);
assert_eq!(app.document.bundle(), &before);
let (mut source_app, performer, work, _) =
allocation_fixture(Layer::Sa, ProjectionKind::Decomposition);
let before = source_app.document.bundle().clone();
let context = start_allocation(&mut source_app, performer);
use iced::widget::text_editor;
let _ = source_app.update(Message::SourceEdit(text_editor::Action::SelectAll));
let _ = source_app.update(Message::SourceEdit(text_editor::Action::Edit(
text_editor::Edit::Paste(std::sync::Arc::new("invalid raw source retained".into())),
)));
send(&mut source_app, Event::Endpoint(context, work));
send(&mut source_app, Event::Choose(Some(Tool::Allocation)));
assert!(source_app.connection.is_none());
assert!(source_app.source_dirty());
assert_eq!(
source_app.source_editor.text(),
"invalid raw source retained"
);
assert_eq!(source_app.document.bundle(), &before);
}
-2
View File
@@ -52,8 +52,6 @@ mod tests {
use crate::app::{App, Message, Page};
use oa_core::*;
include!("connection_allocation_tests.rs");
fn fixture() -> (App, Id, Id, Id) {
let mut p = Project::empty();
let a = p.add(Kind::Function, "Send command", 0., 0.);
+3 -44
View File
@@ -9,18 +9,13 @@ pub enum Tool {
CommunicationMean,
FunctionalExchange,
BoundaryExchange,
Allocation,
}
impl Tool {
pub fn for_layer(layer: Layer) -> &'static [Self] {
match layer {
Layer::Oa => &[Self::Interaction, Self::CommunicationMean, Self::Allocation],
Layer::Sa => &[
Self::FunctionalExchange,
Self::BoundaryExchange,
Self::Allocation,
],
Layer::Oa => &[Self::Interaction, Self::CommunicationMean],
Layer::Sa => &[Self::FunctionalExchange, Self::BoundaryExchange],
}
}
@@ -30,15 +25,11 @@ impl Tool {
Self::CommunicationMean => "Communication means",
Self::FunctionalExchange => "Functional exchange",
Self::BoundaryExchange => "Boundary exchange",
Self::Allocation => "Allocation",
}
}
pub fn hint(self) -> &'static str {
match self {
Self::Allocation => {
"Click a performer, then highlighted unallocated leaf work. In allocated-work views, allocation appears as nesting rather than a line."
}
Self::Interaction => "Click a source activity, then a target activity.",
Self::CommunicationMean => {
"Click an operational entity or actor, then another participant. Communication is bidirectional unless you change its orientation."
@@ -54,10 +45,6 @@ impl Tool {
pub fn can_start(self, project: &Project, source: Id) -> bool {
match self {
Self::Allocation => project
.elements
.get(&source)
.is_some_and(|e| e.kind().is_performer()),
Self::CommunicationMean => project.elements.get(&source).is_some_and(|e| {
e.layer() == Layer::Oa && matches!(e.kind(), Kind::Entity | Kind::Actor)
}),
@@ -78,9 +65,6 @@ impl Tool {
return false;
}
match self {
Self::Allocation => {
validation::can_connect(project, RelationKind::Allocation, source, target)
}
Self::CommunicationMean => {
validation::can_connect(project, RelationKind::CommunicationMean, source, target)
}
@@ -156,7 +140,6 @@ impl Tool {
}
let name = engineering_name(project, self, source, target);
let body = match self {
Self::Allocation => RelationBody::new(RelationKind::Allocation, source, target),
Self::CommunicationMean => {
RelationBody::new(RelationKind::CommunicationMean, source, target)
}
@@ -179,28 +162,6 @@ impl Tool {
prepare_command(project, &command)?;
Ok(command)
}
/// Both endpoint categories must be supported before offering Allocation.
/// Preserve the established projection eligibility of the exchange tools.
pub fn available_in(self, layer: Layer, projection: ProjectionKind) -> bool {
if !Self::for_layer(layer).contains(&self) {
return false;
}
let kind = match self {
Self::Interaction => Kind::Activity,
Self::CommunicationMean => Kind::Entity,
Self::FunctionalExchange => Kind::Function,
Self::BoundaryExchange => Kind::System,
Self::Allocation => {
let (performer, work) = match layer {
Layer::Oa => (Kind::Entity, Kind::Activity),
Layer::Sa => (Kind::System, Kind::Function),
};
return projection.includes(performer) && projection.includes(work);
}
};
projection.includes(kind)
}
}
/// A clicked port determines its own function owner. It cannot be paired with
@@ -330,7 +291,7 @@ mod tests {
let before = p.clone();
assert_eq!(
Tool::for_layer(Layer::Oa),
&[Tool::Interaction, Tool::CommunicationMean, Tool::Allocation]
&[Tool::Interaction, Tool::CommunicationMean]
);
assert_eq!(
Tool::Interaction.targets(&p, source),
@@ -631,8 +592,6 @@ mod tests {
(Tool::CommunicationMean, oa_team, oa_actor),
(Tool::FunctionalExchange, sa_a, sa_b),
(Tool::BoundaryExchange, boundary_a, boundary_b),
(Tool::Allocation, oa_team, oa_a),
(Tool::Allocation, system, sa_a),
] {
let before = document.bundle().clone();
let original = document.project().clone();
+85 -24
View File
@@ -22,6 +22,9 @@ use std::{
sync::Arc,
};
#[cfg(test)]
#[path = "diagram/allocation_group_tests.rs"]
mod allocation_group_tests;
#[cfg(test)]
#[path = "diagram/attachment_tests.rs"]
mod attachment_tests;
#[cfg(test)]
@@ -39,12 +42,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>,
@@ -63,6 +71,8 @@ pub struct DiagramCanvas<'a> {
}
#[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>,
@@ -462,6 +472,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,
@@ -604,6 +615,13 @@ 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!(
@@ -641,6 +659,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;
@@ -707,6 +726,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) =>
@@ -893,6 +915,15 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
state.pan_start = None;
return Some(Action::publish(Message::Connection(event)).and_capture());
}
// group_input handles production selection first (including
// Shift). This also closes legacy movement when selection or
// Allocation capability is absent: never emit a bare MoveNode.
if self.allocation_surface()
&& let Some(target) = self.allocation_press_target(&scene, p)
&& self.allocation_handles_press(&target)
{
return Some(self.begin_allocation(state, target, cursor.position()?, bounds));
}
if self.editable
&& let Some(edge) = scene.routed.edges.get(&self.selected)
&& !edge.custom_suspended
@@ -965,16 +996,6 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
return Some(Action::publish(Message::Select(port.element)).and_capture());
}
if let Some(id) = self.hit(view, p) {
if self.work_eligible(id) {
return Some(self.begin_work(
state,
id,
cursor.position()?,
bounds,
view,
&scene.routed,
));
}
let position = self
.project
.diagrams
@@ -1250,10 +1271,7 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
f.translate(self.pan);
f.scale(self.zoom);
let scene = self.scene(state);
let project = scene
.work
.as_ref()
.map_or(self.project, |p| p.semantic.project());
let project = scene.work.as_ref().map_or(self.project, |p| p.project());
let view = &scene.view;
let containers = view
.nodes
@@ -1262,7 +1280,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);
@@ -1274,7 +1302,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
@@ -1370,17 +1400,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(
@@ -1408,11 +1463,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.],
@@ -2179,6 +2230,7 @@ mod tests {
y: original.y - 200.,
};
let canvas = DiagramCanvas {
selection: None,
project: &p,
view_index: None,
context: Context {
@@ -2256,6 +2308,7 @@ mod tests {
.unwrap()
.id;
let canvas = DiagramCanvas {
selection: None,
project: &p,
view_index: None,
context: Context {
@@ -2384,6 +2437,7 @@ mod tests {
};
let session = Session::new(&p, &view, context, Tool::FunctionalExchange);
let canvas = DiagramCanvas {
selection: None,
project: &p,
view_index: None,
diagram,
@@ -2559,6 +2613,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,
@@ -2637,6 +2692,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,
@@ -2736,6 +2792,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,
@@ -2866,6 +2923,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,
@@ -2922,6 +2980,7 @@ mod tests {
offset: Position { x: 0., y: -160. },
});
let mut canvas = DiagramCanvas {
selection: None,
project: &p,
view_index: None,
diagram,
@@ -2998,6 +3057,7 @@ mod tests {
},
});
let canvas = DiagramCanvas {
selection: None,
project: &p,
view_index: None,
diagram,
@@ -3033,6 +3093,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,
@@ -0,0 +1,520 @@
//! Exact Allocation dispatch through real canvas events and App rebuilds.
use super::*;
use crate::app::{
App,
allocation_drag::{Event as WorkEvent, Mode},
multiselect::{Choice, Event as SelectionEvent, context},
};
use crate::diagram_usage::Target;
use iced::{keyboard, widget::canvas::Program};
#[derive(Clone, Copy, Debug)]
enum Control {
Attachment,
Bend,
}
struct Fixture {
app: App,
diagram: Id,
work: Id,
edge: Id,
cover: Option<Id>,
at: Position,
}
fn xy(x: f32, y: f32) -> Position {
Position { x, y }
}
fn bounds() -> Rectangle {
Rectangle::new(Point::new(120., 80.), Size::new(2400., 1800.))
}
fn screen(p: Position) -> Point {
Point::new(bounds().x + p.x, bounds().y + p.y)
}
fn fixture(control: Control, cover: Option<Kind>) -> Fixture {
let mut project = Project::empty();
let a = project
.add_element(Kind::Activity, Layer::Oa, "Send", None)
.unwrap();
let b = project
.add_element(Kind::Activity, Layer::Oa, "Receive", None)
.unwrap();
let edge = project.relate(RelationKind::Interaction, "Report", a, b);
let diagram = project.ensure_diagram(Layer::Oa);
project.diagrams.get_mut(&diagram).unwrap().projection = ProjectionKind::Allocation;
let work = project.place(diagram, a, xy(100., 150.));
project.place(diagram, b, xy(700., 150.));
project
.diagrams
.get_mut(&diagram)
.unwrap()
.routes
.push(EdgeRoute {
edge,
waypoints: vec![xy(450., 450.)],
label: None,
source_attachment: Some(EdgeAttachment {
side: PortAnchorSide::Right,
offset: 0.6,
}),
target_attachment: Some(EdgeAttachment {
side: PortAnchorSide::Left,
offset: 0.5,
}),
});
let view = projection::project_view(&project, diagram);
let routed = routing::route_diagram(&project, &project.diagrams[&diagram], &view);
let at = match control {
Control::Attachment => {
let at = *routed.edges[&edge].points.first().unwrap();
// Inside the handle radius but outside its owner's right boundary,
// so the covered-card discriminator does not depend on node order.
xy(at.x + 4., at.y)
}
Control::Bend => routed.edges[&edge].pins[0],
};
let cover = cover.map(|kind| {
let element = project
.add_element(kind, Layer::Oa, "Underlying card", None)
.unwrap();
project.place(diagram, element, xy(at.x - 40., at.y - 35.))
});
validate_candidate(&project).unwrap();
let mut app = App::with_project(project);
app.active_diagram = Some(diagram);
app.zoom = 1.;
app.pan = Vector::ZERO;
let _ = app.update(Message::Zoom(1.));
let _ = app.update(Message::Select(edge));
app.reconcile_multiselect();
Fixture {
app,
diagram,
work,
edge,
cover,
at,
}
}
fn canvas(app: &App) -> DiagramCanvas<'_> {
DiagramCanvas {
selection: app.diagram_selection.as_ref(),
allocation: Some(app.allocation_capability()),
project: app.project(),
view_index: Some(app.document.view_index()),
diagram: app.active_diagram.unwrap(),
selected: app.selected,
navigation_focus: None,
zoom: app.zoom,
pan: app.pan,
context: app.route_context().unwrap(),
selected_bend: app
.selected_bend
.filter(|(edge, _)| *edge == app.selected)
.map(|(_, bend)| bend),
selected_attachment: None,
editable: !app.draft_dirty() && !app.source_dirty(),
placing: false,
connection: None,
}
}
fn input(app: &App, state: &mut State, event: canvas::Event, at: Point) -> Option<Message> {
canvas(app)
.update(state, &event, bounds(), mouse::Cursor::Available(at))
.and_then(|action| action.into_inner().0)
}
fn pointer(app: &App, state: &mut State, event: mouse::Event, at: Point) -> Option<Message> {
input(app, state, canvas::Event::Mouse(event), at)
}
fn mixed(f: &mut Fixture) {
for (target, choice) in [
(Target::Placement(f.work), Choice::Replace),
(Target::Edge(f.edge), Choice::Add),
] {
let current = context(f.app.route_context().unwrap());
let _ = f
.app
.update(Message::DiagramSelection(SelectionEvent::Choose(
current, target, choice,
)));
}
assert_eq!(f.app.multiselect_snapshot().unwrap().targets().len(), 2);
assert_eq!(
f.app.selected, f.edge,
"selected edge exposes its real handles"
);
}
fn assert_control(f: &Fixture, control: Control) {
let c = canvas(&f.app);
let scene = c.scene(&State::default());
let edge = &scene.routed.edges[&f.edge];
assert!(!edge.custom_suspended);
match control {
Control::Attachment => assert!(c.attachment_at(&scene, point(f.at)).is_some()),
Control::Bend => assert!(edge.pins.contains(&f.at)),
}
let local = point(f.at);
if let Some(cover) = f.cover {
assert_eq!(
c.hit(&scene.view, local),
Some(cover),
"fixture actually places a card under the visible control"
);
} else if matches!(control, Control::Bend) {
assert!(
c.hit(&scene.view, local).is_none(),
"isolated bend has no underlying card"
);
}
assert_eq!(
c.allocation_press_target(&scene, local),
Some(Target::Edge(f.edge))
);
}
#[test]
fn allocation_handles_keep_exact_edge_through_shift_and_pending_group_dispatch() {
for control in [Control::Attachment, Control::Bend] {
for cover in [None, Some(Kind::Activity), Some(Kind::Entity)] {
let mut f = fixture(control, cover);
mixed(&mut f);
assert_control(&f, control);
let from = screen(f.at);
let mut state = State::default();
let _ = input(
&f.app,
&mut state,
canvas::Event::Keyboard(keyboard::Event::ModifiersChanged(
keyboard::Modifiers::SHIFT,
)),
from,
);
let _ = pointer(
&f.app,
&mut state,
mouse::Event::CursorMoved { position: from },
from,
);
let message = pointer(
&f.app,
&mut state,
mouse::Event::ButtonPressed(mouse::Button::Left),
from,
)
.unwrap();
assert!(
matches!(&message, Message::DiagramSelection(SelectionEvent::Choose(_, Target::Edge(id), Choice::Toggle)) if *id == f.edge)
);
let _ = f.app.update(message);
assert_eq!(
f.app.multiselect_snapshot().unwrap().targets(),
&[Target::Placement(f.work)]
);
assert!(state.work.is_none() && state.group.is_none() && state.route_drag.is_none());
}
}
}
#[test]
fn allocation_handle_click_collapses_but_out_and_back_refuses_after_app_rebuilds() {
for control in [Control::Attachment, Control::Bend] {
for cover in [None, Some(Kind::Activity), Some(Kind::Entity)] {
for crossed in [false, true] {
let mut f = fixture(control, cover);
mixed(&mut f);
assert_control(&f, control);
let accepted = f.app.project().clone();
let source = f.app.document.bundle().clone();
let revision = f.app.document.revision();
let selection = f.app.multiselect_snapshot().unwrap();
let from = screen(f.at);
let mut state = State::default();
let _ = pointer(
&f.app,
&mut state,
mouse::Event::CursorMoved { position: from },
from,
);
let message = pointer(
&f.app,
&mut state,
mouse::Event::ButtonPressed(mouse::Button::Left),
from,
)
.unwrap();
let Message::WorkDrag(WorkEvent::Press(press)) = &message else {
panic!("exact mixed-set Allocation press")
};
assert_eq!(press.target, Target::Edge(f.edge));
assert_eq!(press.selection, selection);
assert_eq!(press.mode, Mode::Assign);
let _ = f.app.update(message);
assert_eq!(
f.app.multiselect_snapshot().unwrap(),
selection,
"press retains primary and membership"
);
if crossed {
for at in [from + Vector::new(4., 0.), from] {
let message = pointer(
&f.app,
&mut state,
mouse::Event::CursorMoved { position: at },
at,
)
.unwrap();
assert!(matches!(
&message,
Message::WorkDrag(WorkEvent::Preview(..))
));
let _ = f.app.update(message);
}
}
let message = pointer(
&f.app,
&mut state,
mouse::Event::ButtonReleased(mouse::Button::Left),
from,
)
.unwrap();
assert!(matches!(
&message,
Message::WorkDrag(WorkEvent::Release(..))
));
let _ = f.app.update(message);
if crossed {
assert_eq!(
f.app.multiselect_snapshot().unwrap(),
selection,
"crossed return cannot collapse or move an underlying card"
);
} else {
assert_eq!(
f.app.multiselect_snapshot().unwrap().targets(),
&[Target::Edge(f.edge)]
);
}
assert_eq!(f.app.project(), &accepted);
assert_eq!(f.app.document.bundle(), &source);
assert_eq!(f.app.document.revision(), revision);
assert!(!f.app.document.can_undo());
assert!(!f.app.document.can_redo());
assert!(
state.work.is_none() && state.group.is_none() && state.route_drag.is_none()
);
}
}
}
}
#[test]
fn armed_isolated_bend_is_owned_but_unarmed_controls_still_edit_routes() {
for control in [Control::Attachment, Control::Bend] {
for armed in [false, true] {
let mut f = fixture(control, None);
assert_control(&f, control);
if armed {
let _ = f.app.update(Message::WorkDrag(WorkEvent::ArrangeNext));
}
let from = screen(f.at);
let mut state = State::default();
let _ = pointer(
&f.app,
&mut state,
mouse::Event::CursorMoved { position: from },
from,
);
let message = pointer(
&f.app,
&mut state,
mouse::Event::ButtonPressed(mouse::Button::Left),
from,
);
if armed {
let Some(Message::WorkDrag(WorkEvent::Press(press))) = message else {
panic!("armed handle requires Allocation ownership")
};
assert_eq!(press.target, Target::Edge(f.edge));
assert_eq!(press.mode, Mode::Arrange);
assert!(state.work.is_some());
assert!(state.route_drag.is_none());
} else {
assert!(state.work.is_none());
let gesture = state
.route_drag
.as_ref()
.expect("ordinary selected handle remains a route editor");
match control {
Control::Attachment => {
assert!(matches!(gesture.handle, Handle::Attachment { .. }))
}
Control::Bend => assert!(matches!(gesture.handle, Handle::Bend(0))),
}
assert!(!matches!(message, Some(Message::WorkDrag(_))));
}
}
}
}
#[test]
fn ordinary_performer_body_uses_one_token_and_retains_existing_movement_policy() {
let mut f = fixture(Control::Bend, None);
let mut project = f.app.project().clone();
let performer = project
.add_element(Kind::Entity, Layer::Oa, "Mover", None)
.unwrap();
let node = project.place(f.diagram, performer, xy(1200., 800.));
f.app = App::with_project(project);
f.app.active_diagram = Some(f.diagram);
f.app.zoom = 1.;
f.app.pan = Vector::ZERO;
let _ = f.app.update(Message::Zoom(1.));
f.app.reconcile_multiselect();
let accepted = f.app.project().clone();
let source = f.app.document.bundle().clone();
let from = screen(xy(1240., 835.));
let to = from + Vector::new(30., 20.);
let mut state = State::default();
let _ = pointer(
&f.app,
&mut state,
mouse::Event::CursorMoved { position: from },
from,
);
let message = pointer(
&f.app,
&mut state,
mouse::Event::ButtonPressed(mouse::Button::Left),
from,
)
.unwrap();
assert!(
matches!(&message, Message::WorkDrag(WorkEvent::Press(p)) if p.target == Target::Placement(node) && p.mode == Mode::Assign)
);
let _ = f.app.update(message);
assert_eq!(
f.app.selected, performer,
"clean unselected press selects immediately"
);
assert_eq!(
f.app.multiselect_snapshot().unwrap().targets(),
&[Target::Placement(node)]
);
for event in [
mouse::Event::CursorMoved { position: to },
mouse::Event::ButtonReleased(mouse::Button::Left),
] {
let message = pointer(&f.app, &mut state, event, to).unwrap();
assert!(matches!(
&message,
Message::WorkDrag(WorkEvent::Preview(..) | WorkEvent::Release(..))
));
let _ = f.app.update(message);
}
let mut expected = accepted;
expected
.diagrams
.get_mut(&f.diagram)
.unwrap()
.nodes
.get_mut(&node)
.unwrap()
.position = xy(1230., 820.);
assert_eq!(f.app.project(), &expected);
assert_eq!(f.app.document.revision(), 1);
let after = f.app.document.bundle().clone();
let _ = f.app.update(Message::Undo);
assert_eq!(f.app.document.bundle(), &source);
let _ = f.app.update(Message::Redo);
assert_eq!(f.app.document.bundle(), &after);
}
#[test]
fn dirty_selected_pin_outer_ring_is_shielded_without_starting_pan() {
for zoom in [0.5, 1., 2.] {
let mut f = fixture(Control::Bend, None);
let mut project = f.app.project().clone();
let route = &mut project.diagrams.get_mut(&f.diagram).unwrap().routes[0];
route.source_attachment.as_mut().unwrap().offset = 0.5;
route.target_attachment.as_mut().unwrap().offset = 0.5;
let view = projection::project_view(&project, f.diagram);
let routed = routing::route_diagram(&project, &project.diagrams[&f.diagram], &view);
let y = routed.edges[&f.edge].points.first().unwrap().y;
assert_eq!(routed.edges[&f.edge].points.last().unwrap().y, y);
let pin = xy(450., y);
project.diagrams.get_mut(&f.diagram).unwrap().routes[0].waypoints = vec![pin];
f.app = App::with_project(project);
f.app.active_diagram = Some(f.diagram);
f.app.zoom = zoom;
f.app.pan = Vector::ZERO;
let _ = f.app.update(Message::Zoom(1.));
let _ = f.app.update(Message::Select(f.edge));
let _ = f.app.update(Message::Field(
"/name".into(),
"Keep this unfinished relation name".into(),
));
assert!(f.app.draft_dirty());
let c = canvas(&f.app);
assert!(!c.editable);
let scene = c.scene(&State::default());
assert!(!scene.routed.edges[&f.edge].custom_suspended);
assert_eq!(scene.routed.edges[&f.edge].pins, vec![pin]);
let local = Point::new(pin.x * zoom, pin.y * zoom + 7.5);
let at = Point::new(bounds().x + local.x, bounds().y + local.y);
assert!(c.hit(&scene.view, local).is_none());
assert!(c.hit_port(&scene.view, local).is_none());
assert!(
c.hit_edge(&scene.routed, local).is_none(),
"outer ring lies beyond the ordinary 7px edge hit"
);
assert!(c.hit_label(&scene.routed, local).is_none());
assert!(c.hit_association_label(&scene.routed, local).is_none());
assert!(
c.work_dirty_point(bounds(), at),
"the root shield must cover the visible 8px pin handle while editable=false"
);
let project = f.app.project().clone();
let source = f.app.document.bundle().clone();
let selection = f.app.multiselect_snapshot().unwrap();
let draft = f.app.draft.as_ref().unwrap().value.clone();
let revision = f.app.document.revision();
let pan = f.app.pan;
let mut state = State::default();
let _ = pointer(
&f.app,
&mut state,
mouse::Event::CursorMoved { position: at },
at,
);
let message = pointer(
&f.app,
&mut state,
mouse::Event::ButtonPressed(mouse::Button::Left),
at,
)
.unwrap();
assert!(matches!(
&message,
Message::WorkDrag(WorkEvent::Refused(..))
));
let _ = f.app.update(message);
assert!(state.pan_start.is_none() && state.route_drag.is_none() && state.work.is_none());
let moved = at + Vector::new(40., 20.);
for event in [
mouse::Event::CursorMoved { position: moved },
mouse::Event::ButtonReleased(mouse::Button::Left),
] {
let message = pointer(&f.app, &mut state, event, moved);
assert!(
message.is_none(),
"refused handle press must not become background pan or a selector"
);
}
assert_eq!(f.app.project(), &project);
assert_eq!(f.app.document.bundle(), &source);
assert_eq!(f.app.document.revision(), revision);
assert_eq!(f.app.multiselect_snapshot().unwrap(), selection);
assert_eq!(f.app.draft.as_ref().unwrap().value, draft);
assert_eq!(f.app.pan, pan);
}
}
@@ -34,6 +34,7 @@ fn fixture() -> App {
fn canvas(app: &App, zoom: f32) -> DiagramCanvas<'_> {
DiagramCanvas {
selection: None,
allocation: None,
project: app.project(),
view_index: None,
+37 -2
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 {
@@ -95,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));
}
@@ -143,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);
}
@@ -171,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 {
@@ -193,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(),
@@ -0,0 +1,300 @@
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>> {
// The Allocation token owns all ordered motion/release while active.
if state.work.is_some() {
return None;
}
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 self.allocation_surface() {
self.allocation_press_target(&scene, local)
} else 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 self.allocation_handles_press(&target) {
return Some(self.begin_allocation(state, target, pointer, bounds));
}
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 {
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(),
@@ -2,6 +2,7 @@ use super::*;
fn canvas(project: &Project, diagram: Id, zoom: f32) -> DiagramCanvas<'_> {
DiagramCanvas {
selection: None,
project,
view_index: None,
diagram,
@@ -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(),
+108 -30
View File
@@ -1,5 +1,6 @@
use super::*;
use crate::app::allocation_drag::{self as input, Event, Press, Stamp};
use crate::app::allocation_drag::{self as input, Event, Mode, Press, Stamp};
use crate::diagram_usage::Target as SelectionTarget;
impl DiagramCanvas<'_> {
pub(crate) fn work_dirty_point(&self, bounds: Rectangle, screen: Point) -> bool {
@@ -23,14 +24,83 @@ impl DiagramCanvas<'_> {
.then_some(capability.state.preview.as_ref())
.flatten()
}
pub(super) fn work_eligible(&self, placement: Id) -> bool {
self.allocation.is_some()
&& self.project.diagrams[&self.diagram].projection == ProjectionKind::Allocation
&& self.project.diagrams[&self.diagram]
.nodes
.get(&placement)
.and_then(|n| self.project.elements.get(&n.element))
.is_some_and(|e| e.kind().is_work())
pub(super) fn allocation_surface(&self) -> bool {
self.project.diagrams[&self.diagram].projection == ProjectionKind::Allocation
}
// Dirty input must shield the complete visible handle, including its outer
// ring beyond the edge hit tolerance. This test deliberately ignores edit
// permission: the root shield constructs a non-editable canvas.
fn allocation_route_control(
&self,
view: &ProjectedView,
routed: &RoutedView,
world: Point,
) -> bool {
let Some(edge) = routed
.edges
.get(&self.selected)
.filter(|e| !e.custom_suspended)
else {
return false;
};
let radius = 8. / self.zoom;
edge.pins
.iter()
.any(|pin| world.distance(point(*pin)) <= radius)
|| [
(EdgeEnd::Source, edge.points.first()),
(EdgeEnd::Target, edge.points.last()),
]
.into_iter()
.any(|(end, at)| {
at.is_some_and(|at| world.distance(point(*at)) <= radius)
&& route_edit::attachment_node(
self.project,
&self.project.diagrams[&self.diagram],
view,
self.selected,
end,
)
.is_some()
})
}
/// Visible selected-route controls take precedence over underlying cards.
/// Returning the exact edge keeps Shift and owned movement admission aligned.
pub(super) fn allocation_press_target(
&self,
scene: &Scene,
local: Point,
) -> Option<SelectionTarget> {
let world = self.world(local);
if self.editable && self.allocation_route_control(&scene.view, &scene.routed, world) {
return Some(SelectionTarget::Edge(self.selected));
}
if let Some(port) = self.hit_port(&scene.view, local) {
Some(SelectionTarget::Port {
port: port.element,
placement: port.owner,
})
} else if let Some(id) = self.hit(&scene.view, local) {
Some(SelectionTarget::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(SelectionTarget::Edge)
}
}
pub(super) fn allocation_handles_press(&self, target: &SelectionTarget) -> bool {
self.allocation_surface()
&& (self.allocation.is_some_and(|c| c.state.arrange_next)
|| matches!(target, SelectionTarget::Placement(_))
|| self.selection.is_some_and(|selection| {
selection.scope() == crate::app::multiselect::context(self.context).scope
&& selection.targets().len() > 1
&& selection.contains(target)
}))
}
pub(super) fn work_dirty_press(
&self,
@@ -42,26 +112,30 @@ impl DiagramCanvas<'_> {
&& !self.placing
&& self.connection.is_none()
&& self.project.diagrams[&self.diagram].projection == ProjectionKind::Allocation
&& (self.hit(view, local).is_some()
&& (self.allocation_route_control(view, routed, self.world(local))
|| self.hit(view, local).is_some()
|| self.hit_port(view, local).is_some()
|| self.hit_edge(routed, local).is_some()
|| self.hit_label(routed, local).is_some()
|| self.hit_association_label(routed, local).is_some())
}
pub(super) fn begin_work(
pub(super) fn begin_allocation(
&self,
state: &mut State,
node: Id,
target: SelectionTarget,
screen: Point,
bounds: Rectangle,
view: &ProjectedView,
routed: &RoutedView,
) -> Action<Message> {
let capability = self.allocation.unwrap();
state.drag = None;
state.preview = None;
state.port_drag = None;
state.route_drag = None;
state.attachment_viewport = None;
state.group = None;
*state.cache.borrow_mut() = None;
let Some(capability) = self.allocation else {
return Action::capture();
};
if !capability.clean {
return Action::publish(Message::WorkDrag(Event::Refused(
capability.state.epoch,
@@ -69,18 +143,15 @@ impl DiagramCanvas<'_> {
)))
.and_capture();
}
let work = self.project.diagrams[&self.diagram].nodes[&node].element;
let scope = match crate::allocation_drag::capture(self.project, self.diagram, work, node) {
Ok(scope) => scope,
Err(reason) => {
return Action::publish(Message::WorkDrag(Event::Refused(
capability.state.epoch,
input::refusal(&reason),
)))
.and_capture();
}
let Some(selection) = self.selection.and_then(|selection| {
selection.snapshot(crate::app::multiselect::context(self.context))
}) else {
return Action::publish(Message::WorkDrag(Event::Refused(
capability.state.epoch,
"The selection changed. Start this drag again.".into(),
)))
.and_capture();
};
let frozen = input::Frozen::capture(self.project, &scope, view, routed, self.zoom);
let press = Arc::new(Press {
stamp: Stamp {
context: self.context,
@@ -92,9 +163,13 @@ impl DiagramCanvas<'_> {
},
token: Id::new_v4(),
start: screen,
scope,
frozen,
arrange: capability.state.arrange_next,
selection,
target,
mode: if capability.state.arrange_next {
Mode::Arrange
} else {
Mode::Assign
},
});
state.work = Some(press.clone());
Action::publish(Message::WorkDrag(Event::Press(press))).and_capture()
@@ -165,8 +240,11 @@ impl canvas::Program<Message> for WorkFeedback<'_> {
} else {
style::TEAL
};
let Some(frozen) = &feedback.frozen else {
return vec![];
};
for index in &feedback.targets {
let target = &feedback.press.frozen.targets[*index];
let target = &frozen.targets[*index];
let region = target.region;
let top = Point::new(
region.x * self.0.zoom + self.0.pan.x,
+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()));
}
}
+6 -15
View File
@@ -263,8 +263,7 @@ fn article(topic: Topic) -> Article {
&[
"Choose the Operational or System perspective. Create participants using Entity/Actor in OA or System/System actor in SA.",
"In the diagram's side palette, choose Activity in OA or Function in SA, click its position, enter a name and press Enter. Escape cancels without creating an element. Select a card and press F2 to rename it in place. For semantic decomposition, use Model explorer, select existing work and use + In selected to create smaller work units.",
"To allocate existing leaf work in Containment, choose Allocation in the diagram palette. Click a visible performer, then the highlighted Activity or Function it performs. The work must belong to the performer's layer and have no direct performer yet. In a mixed OA ↔ SA traceability view, either layer's performer can be used and the current view stays open. Completing the allocation sets the application perspective to that view's layer; choosing only a source or canceling leaves the prior perspective unchanged. Escape cancels. This creates a responsibility relationship without changing the work's semantic parent.",
"Right-click a participant and choose Add activity or Add function to create new work and its allocation together. In Architecture · allocated work, drag existing work into a performer to assign it, between performers to reassign it, or onto empty canvas to remove its direct assignment. Create relationship… with Allocation and Model explorer's + Allocated provide explicit alternatives.",
"Right-click a participant and choose Add activity or Add function to create new work and its allocation together. In Architecture · allocated work, drag existing work into a performer to assign it, between performers to reassign it, or onto empty canvas to remove its direct assignment. + Relationship with Allocation and Model explorer's + Allocated provide explicit alternatives.",
"Choose Architecture · allocated work to review responsibilities. Choose Functional dataflow to review the activity/function hierarchy and its exchanges independently.",
],
),
@@ -308,8 +307,8 @@ fn article(topic: Topic) -> Article {
"Choose and edit the relationship you mean",
&[
"Use the diagram selector or + View to work with a focused diagram. View properties edits its name, perspective, projection and Saved view visibility. Apply changes commits the view settings.",
"Choose Containment for semantic children, Architecture · allocated work for responsibilities, Functional dataflow for work and exchanges, or Missions & capabilities for outcomes. All relationships explores one layer; OA ↔ SA traceability can show placed elements from both layers and their correspondence lines alongside ordinary relationships.",
"Select an element and edit Semantic parent to change ownership. Use the Allocation palette tool to connect a performer to existing unallocated leaf work in Containment. The relationship can appear as a line there; Architecture · allocated work shows responsibility through nesting instead. Saved visibility filters can hide a line without removing the relationship.",
"Choose Containment for semantic children, Architecture · allocated work for responsibilities, Functional dataflow for work and exchanges, or Missions & capabilities for outcomes. All relationships is a broader exploration view; OA ↔ SA traceability displays counterparts.",
"Select an element and edit Semantic parent to change ownership. Use an Allocation relationship to change responsibility. Apply changes validates the semantic change.",
"Scroll to zoom and use Fit to frame the current view. In allocated-work views, dragging work changes its assignment and placement together. Choose Arrange next drag or press Alt+A to move work without changing responsibility for that one drag; Escape cancels the armed action. Auto-layout arranges the whole view. Export SVG produces a shareable view.",
"Select a diagram node to access Placement, Fold / unfold and Hide. With the canvas focused, Delete hides its placement; Shift+Delete asks to remove the model element. Other views retain a hidden element. Undo restores either change.",
],
@@ -318,21 +317,13 @@ fn article(topic: Topic) -> Article {
"Keep diagrams readable",
"Use separate views for responsibility, decomposition and cross-layer coverage. Place existing elements with Show in this diagram. A placement's Diagram field can relocate it while preserving its identity and artifact references. Do not duplicate a semantic element merely to show it in another view.",
),
walkthrough(
"Show a correspondence between OA and SA",
&[
"Choose OA ↔ SA traceability in View content. Changing this setting does not create counterparts or add their placements, so an existing single-layer diagram can initially look unchanged.",
"Use Review layer traces to inspect named Operational and System endpoints and the meaning of their link. This opens the model's layer-trace list with no search restriction; traces between relationships are reviewed here rather than drawn between element cards.",
"In the chosen architecture view, use Add existing elements. The chooser offers compatible elements from both layers. Select an existing endpoint and click its position in the canvas; repeat for the other end if needed. This adds a placement, not a new semantic element or trace. For port correspondences, place the ports' owners.",
"The view shows correspondence lines alongside ordinary relationships. The shown-line count reflects the current projection and visibility settings, not trace coverage. If a line is hidden, use View visibility and inspect Saved view visibility; placing an endpoint does not remove explicit hides or filters.",
"If the counterpart is already placed, use Include already shown (Locate) in the chooser. Undo restores an added placement; saving preserves each view's placements independently. Use the Traceability page's existing counterpart or manual-trace workflow when the semantic link itself does not yet exist.",
],
),
walkthrough(
"Move work between performers",
&[
"Finish or cancel property and source edits, then drag an Activity or Function body in Architecture · allocated work. Aim at exposed performer space, including a collapsed performer's header. Read Destination above the canvas and the outlined target before releasing; release outside the canvas or press Escape to cancel.",
"The move preserves work ownership and identities. A uniformly assigned work group moves its leaves together; a group distributed across performers asks you to choose a leaf. Role responsibilities remain explicit: an outward drag that would leave a role-derived performer explains why it cannot remove that responsibility.",
"Use Shift-click to add or remove cards from the selection, or right-click a card and choose Add to selection. Drag any selected work card to assign the whole work selection to one performer. Independent work can start on different performers. The inspector keeps its primary element during the drag; right-click a selected card and choose Inspect this element to change that primary without losing the group.",
"The move preserves work ownership and identities. A composite work card moves its leaves together when they have one coherent responsibility; if its own leaves are distributed across performers, choose specific leaves instead. Role responsibilities remain explicit: an outward drag that would leave a role-derived performer explains why it cannot remove that responsibility.",
"To move work and performers together without changing responsibility, select their cards and choose Arrange next drag or press Alt+A. This moves the selected work subtrees and each selected performer's contained cards together, if the complete layout stays readable and clear. Ports and connections cannot join a placement move. Dragging only performer cards normally moves them and their contained cards without changing assignments.",
"Make room when work cards would overlap, and move the view or zoom in if the moving work is not readable. The destination name stays above the canvas while the target outline stays fixed during the drag. Hover Destination to inspect a long name: outside the canvas, the tooltip explicitly identifies the last target, and releasing cancels. Return to the canvas to choose the drop. A refused drop keeps the model and history unchanged. One Undo reverses an accepted assignment and placement together; Save all writes the corresponding source changes.",
],
),
+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;
+217 -8
View File
@@ -12,11 +12,13 @@ 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 process: Option<Box<crate::behavior::topology::Context>>,
pub source_file: Option<String>,
}
impl Request {
@@ -25,11 +27,13 @@ impl Request {
anchor,
object,
placement: None,
selection_target: None,
bend: None,
route_point: None,
route_segment: None,
creation_point: None,
canvas: None,
process: None,
source_file: None,
}
}
@@ -55,9 +59,14 @@ 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))
|| request
.process
.as_deref()
.is_some_and(|c| Some(*c) != app.topology_context())
{
return None;
}
@@ -80,10 +89,16 @@ 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
.is_none_or(|c| Some(c) == current_canvas(app))
&& self
.request
.process
.as_deref()
.is_none_or(|c| Some(*c) == app.topology_context())
&& self
.request
.source_file
@@ -92,6 +107,46 @@ 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() && request.process.is_none())
|| !app.multiselect_target_visible(target)
{
return false;
}
match target {
Target::ProcessOccurrence(endpoint) => {
request.process.is_some()
&& request.canvas.is_none()
&& request.placement.is_none()
&& endpoint.occurrence == request.object
}
Target::ProcessLink(id) | Target::ProcessPreviewLink { relation: id, .. } => {
request.process.is_some()
&& request.canvas.is_none()
&& request.placement.is_none()
&& *id == request.object
}
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()
@@ -102,7 +157,9 @@ fn current_canvas(app: &App) -> Option<route_edit::Context> {
fn current_diagram(app: &App) -> Option<Id> {
if app.page == Page::Behavior {
app.state_context().map(|context| context.diagram)
app.topology_context()
.map(|context| context.owner)
.or_else(|| app.state_context().map(|context| context.diagram))
} else {
app.active_diagram
}
@@ -126,7 +183,23 @@ 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
request.process = app.topology_context().map(Box::new);
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()
@@ -187,12 +260,150 @@ 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() || request.process.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,
));
if request.process.is_some() {
entries.push(Entry::title(
"Drag contributions within one column to reorder",
));
entries.push(Entry::disabled(
"Hide unavailable · cards are model contributions",
));
} else {
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 let Some(context) = request.process.as_deref().copied() {
let endpoint = match request.selection_target.as_deref() {
Some(Target::ProcessOccurrence(endpoint))
if !endpoint.references.is_empty() =>
{
Some(endpoint.clone())
}
Some(Target::ProcessPreviewLink { source, .. }) => Some(source.clone()),
_ => None,
};
if let Some(endpoint) = endpoint {
entries.push(Entry::title("Reused contents · read-only in this process"));
entries.push(item(
"Open referenced definition",
Message::TopologyCanvas(
crate::behavior::topology::CanvasEvent::OpenQualified(
context, endpoint,
),
),
true,
));
return Some(Menu {
token: opened.token,
anchor: request.anchor,
entries,
on_close: Message::Menu(Event::Close),
});
}
}
}
}
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"));
}
@@ -458,8 +669,7 @@ pub fn popup(app: &App) -> Option<Menu<Message>> {
entries.push(item(
"Show in this diagram",
Message::PlaceSelected,
(d.layer == e.layer() || d.projection == ProjectionKind::Traceability)
&& d.projection.includes(e.kind()),
d.layer == e.layer() && d.projection.includes(e.kind()),
));
}
}
@@ -590,7 +800,6 @@ fn relation_actions(kind: Kind) -> &'static [(&'static str, RelationKind, bool)]
#[cfg(test)]
mod tests {
use super::*;
include!("model_menu_traceability_tests.rs");
fn send(app: &mut App, message: Message) {
let _ = app.update(message);
}
@@ -1,62 +0,0 @@
#[test]
fn traceability_show_menu_matches_cross_layer_and_port_placement_eligibility() {
let mut project = Project::empty();
let sa = project
.add_element(Kind::Function, Layer::Sa, "Counterpart", None)
.unwrap();
let port = Id::new_v4();
project.elements.insert(
port,
Element {
id: port,
alias: "legacy_port".into(),
name: "Port".into(),
description: String::new(),
parent: Some(sa),
body: ElementBody::shared_in(
Layer::Sa,
SharedBody::Port {
contract: PortContract::Legacy,
legacy: Some(LegacyPortContract {
direction: PortDirection::InOut,
interface: None,
}),
},
),
},
);
let diagram = project.ensure_diagram(Layer::Oa);
oa_core::validate_candidate(&project).unwrap();
for projection in [ProjectionKind::Overview, ProjectionKind::Traceability] {
let mut p = project.clone();
p.diagrams.get_mut(&diagram).unwrap().projection = projection;
let mut app = App::with_project(p);
app.page = Page::Diagram;
app.layer = Layer::Oa;
app.active_diagram = Some(diagram);
for object in [sa, port] {
send(&mut app, open(object, Point::ORIGIN));
let action = popup(&app)
.unwrap()
.entries
.into_iter()
.find_map(|e| match e {
Entry::Action { label, message, .. } if label == "Show in this diagram" => {
Some(message)
}
_ => None,
});
if object == port {
assert!(
action.is_none(),
"Ports never acquire independent node placements"
);
} else {
assert_eq!(
action.expect("non-port placement entry").is_some(),
projection == ProjectionKind::Traceability
);
}
}
}
}
+85 -26
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()),
@@ -599,28 +600,6 @@ fn diagram(app: &App) -> View<'_, Message> {
}
if let Some(diagram) = app.active_diagram {
let view = oa_core::projection::project_view(app.project(), diagram);
if projection == Some(ProjectionKind::Traceability) {
let correspondence_lines = view.edges.iter().filter(|edge| edge.trace).count();
tools = tools.push(
column![
label("Shows OA and SA elements together, with correspondence lines alongside ordinary relationships."),
label("Place both ends of a trace to show its line. Port traces use their owner cards; traces between relationships are reviewed on the Traceability page."),
row![
label(format!("{correspondence_lines} correspondence lines in this view")),
action(
"Add existing elements…",
Message::Architecture(crate::app::architecture_authoring::Event::Existing(None))
),
action("Review layer traces", Message::ReviewLayerTraces),
action("View visibility…", Message::Select(diagram)),
]
.spacing(7)
.align_y(iced::Center)
.wrap(),
]
.spacing(4),
);
}
let edge = view.edges.iter().find(|edge| edge.id == app.selected);
let promoted = edge.is_some_and(|edge| {
oa_core::projection::edge_endpoint_placements(
@@ -630,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 {
@@ -642,22 +652,71 @@ 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()
}
fn inspector(app: &App) -> View<'_, Message> {
let p = app.project();
if let Some(target) = app.process_preview_primary() {
let endpoint = match target {
crate::diagram_usage::Target::ProcessOccurrence(endpoint) => endpoint.clone(),
crate::diagram_usage::Target::ProcessPreviewLink { source, .. } => source.clone(),
_ => unreachable!(),
};
let context = app.topology_context().unwrap();
return container(
column![
text(p.named(app.selected)).size(17),
label("Reused contents · read-only here"),
label("Open the referenced definition to edit this contribution or connection."),
action(
"Open referenced definition",
Message::TopologyCanvas(crate::behavior::topology::CanvasEvent::OpenQualified(
context, endpoint
))
),
crate::model_menu::trigger(app),
]
.spacing(12),
)
.padding(14)
.width(320)
.height(Fill)
.style(style::panel)
.into();
}
if app.source_dirty() {
let mut notice = column![
text("Source drafts pending").size(17),
+4 -23
View File
@@ -7,10 +7,7 @@ https://raw.githubusercontent.com/iced-rs/iced/54abf81d13fec06d4d9ac754b03ce2c33
SHA-256 `fc9086ba4eba4b77e4c603906362742bc3715ffa2c7810c6074fe7780d3a0398`.
All copied src/assets and Cargo manifests retain their upstream bytes except
the three modules described below. Their original files are identified in
UPSTREAM-MANIFEST.json.
`src/text_editor.rs` adds one documented read-only `Content::with_editor`
`src/text_editor.rs`, which adds one documented read-only `Content::with_editor`
callback. Original file SHA-256:
`cecf8686c90aa92066d55075beba545dfb7dd5a44bad0c1519fcb110a56070e1`.
The callback borrows the existing renderer editor and releases the borrow when
@@ -22,20 +19,6 @@ measurement. The isolated Windows CPU experiment verified the exact painted
editor identity and geometry plus retained Word/Line selection and scroll;
application integration/native validation are separately required.
`src/pick_list.rs` shares its open flag with the menu overlay and supplies a
base-event fallback for a picker opened earlier in the same event batch.
Escape dismisses the open picker, captures the key, requests redraw and sends
its optional close notification without selecting an option.
`src/overlay/menu.rs` adds the private opt-in bridge used by PickList. It handles
Escape before controls underneath the overlay receive it. Once its owner is
closed, the retained overlay ignores later queued events and no longer draws
or reports a mouse interaction. This prevents a queued click from selecting
an option after dismissal and prevents duplicate close notifications. Other
Menu consumers retain their existing behavior unless they opt into the bridge.
The application-level Escape routing and its regression tests are separate
from these vendored modules; source description alone is not runtime evidence.
UPSTREAM-MANIFEST.json records all files from the local pinned registry source,
including registry bookkeeping and the upstream crate lockfile not copied here.
Those omitted files are not package inputs. The workspace root Cargo.lock and
@@ -43,8 +26,6 @@ local patch path identify the resolved dependency; this patched package is not
the unmodified registry checksum. Keep LICENSE and this provenance in source
captures and generated dependency notices.
On upgrades compare all three modules with their pristine upstream versions
and reassess each change. Recheck actual editor layout, selection/input
continuity, annotations, picker dismissal, same-batch queued input and close
notification behavior. Remove each local change when upstream provides its
equivalent behavior.
On upgrades compare the pristine upstream module and replay only this accessor.
Recheck actual editor layout, selection/input continuity and annotation tests.
Remove this patch if upstream provides equivalent read-only layout access.
-49
View File
@@ -1,7 +1,6 @@
//! Build and show dropdown menus.
use crate::core::alignment;
use crate::core::border::{self, Border};
use crate::core::keyboard;
use crate::core::layout::{self, Layout};
use crate::core::mouse;
use crate::core::overlay;
@@ -16,7 +15,6 @@ use crate::core::{
};
use crate::core::{Element, Shell, Widget};
use crate::scrollable::{self, Scrollable};
use std::cell::Cell;
/// A list of selectable options.
pub struct Menu<
@@ -36,7 +34,6 @@ pub struct Menu<
hovered_option: &'a mut Option<usize>,
on_selected: Box<dyn FnMut(T) -> Message + 'a>,
on_option_hovered: Option<&'a dyn Fn(T) -> Message>,
escape: Option<(&'a Cell<bool>, Option<Message>)>,
width: f32,
padding: Padding,
text_size: Option<Pixels>,
@@ -71,7 +68,6 @@ where
hovered_option,
on_selected: Box::new(on_selected),
on_option_hovered,
escape: None,
width: 0.0,
padding: Padding::ZERO,
text_size: None,
@@ -82,17 +78,6 @@ where
}
}
// PickList owns this flag. Other Menu consumers retain their existing
// keyboard behavior unless they explicitly opt into this private bridge.
pub(crate) fn close_on_escape(
mut self,
open: &'a Cell<bool>,
on_close: Option<Message>,
) -> Self {
self.escape = Some((open, on_close));
self
}
/// Sets the width of the [`Menu`].
pub fn width(mut self, width: f32) -> Self {
self.width = width;
@@ -185,7 +170,6 @@ where
viewport: Rectangle,
tree: &'a mut Tree,
list: Scrollable<'a, Message, Theme, Renderer>,
escape: Option<(&'a Cell<bool>, Option<Message>)>,
width: f32,
target_height: f32,
class: &'a <Theme as Catalog>::Class<'b>,
@@ -214,7 +198,6 @@ where
hovered_option,
on_selected,
on_option_hovered,
escape,
width,
padding,
font,
@@ -245,7 +228,6 @@ where
viewport,
tree: &mut state.tree,
list,
escape,
width,
target_height,
class,
@@ -296,31 +278,6 @@ where
clipboard: &mut dyn Clipboard,
shell: &mut Shell<'_, Message>,
) {
// UserInterface may retain this overlay for the rest of an event batch.
// A dismissed owner must not select an option on a queued later click.
if self.escape.as_ref().is_some_and(|(open, _)| !open.get()) {
return;
}
// Consume dismissal in the overlay so Iced never sends the same key
// into focused controls or canvases beneath this picker. The shared
// owner flag also prevents duplicate close notifications in one batch.
if matches!(
event,
Event::Keyboard(keyboard::Event::KeyPressed {
key: keyboard::Key::Named(keyboard::key::Named::Escape),
..
})
) && let Some((open, on_close)) = &mut self.escape
&& open.replace(false)
{
if let Some(message) = on_close.take() {
shell.publish(message);
}
shell.capture_event();
shell.request_redraw();
return;
}
let bounds = layout.bounds();
self.list.update(
@@ -335,9 +292,6 @@ where
cursor: mouse::Cursor,
renderer: &Renderer,
) -> mouse::Interaction {
if self.escape.as_ref().is_some_and(|(open, _)| !open.get()) {
return mouse::Interaction::None;
}
self.list.mouse_interaction(
self.tree,
layout,
@@ -355,9 +309,6 @@ where
layout: Layout<'_>,
cursor: mouse::Cursor,
) {
if self.escape.as_ref().is_some_and(|(open, _)| !open.get()) {
return;
}
let bounds = layout.bounds();
let style = Catalog::style(theme, self.class);
+10 -24
View File
@@ -78,7 +78,6 @@ use crate::core::{
use crate::overlay::menu::{self, Menu};
use std::borrow::Borrow;
use std::cell::Cell;
use std::f32;
/// A widget for selecting a single value from a list of options.
@@ -449,24 +448,12 @@ where
let state = tree.state.downcast_mut::<State<Renderer::Paragraph>>();
match event {
Event::Keyboard(keyboard::Event::KeyPressed {
key: keyboard::Key::Named(keyboard::key::Named::Escape),
..
}) if state.is_open.replace(false) => {
// The picker may have opened earlier in this base-event batch,
// after UserInterface collected its overlays.
if let Some(on_close) = &self.on_close {
shell.publish(on_close.clone());
}
shell.capture_event();
shell.request_redraw();
}
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left))
| Event::Touch(touch::Event::FingerPressed { .. }) => {
if state.is_open.get() {
if state.is_open {
// Event wasn't processed by overlay, so cursor was clicked either outside its
// bounds or on the drop-down, either way we close the overlay.
state.is_open.set(false);
state.is_open = false;
if let Some(on_close) = &self.on_close {
shell.publish(on_close.clone());
@@ -476,7 +463,7 @@ where
} else if cursor.is_over(layout.bounds()) {
let selected = self.selected.as_ref().map(Borrow::borrow);
state.is_open.set(true);
state.is_open = true;
state.hovered_option = self
.options
.borrow()
@@ -495,7 +482,7 @@ where
}) => {
if state.keyboard_modifiers.command()
&& cursor.is_over(layout.bounds())
&& !state.is_open.get()
&& !state.is_open
{
fn find_next<'a, T: PartialEq>(
selected: &'a T,
@@ -541,7 +528,7 @@ where
let status = {
let is_hovered = cursor.is_over(layout.bounds());
if state.is_open.get() {
if state.is_open {
Status::Opened { is_hovered }
} else if is_hovered {
Status::Hovered
@@ -625,7 +612,7 @@ where
shaping,
}) => Some((*font, *code_point, *size, *line_height, *shaping)),
Handle::Dynamic { open, closed } => {
if state.is_open.get() {
if state.is_open {
Some((
open.font,
open.code_point,
@@ -716,7 +703,7 @@ where
let state = tree.state.downcast_mut::<State<Renderer::Paragraph>>();
let font = self.font.unwrap_or_else(|| renderer.default_font());
if state.is_open.get() {
if state.is_open {
let bounds = layout.bounds();
let on_select = &self.on_select;
@@ -726,14 +713,13 @@ where
self.options.borrow(),
&mut state.hovered_option,
|option| {
state.is_open.set(false);
state.is_open = false;
(on_select)(option)
},
None,
&self.menu_class,
)
.close_on_escape(&state.is_open, self.on_close.clone())
.width(bounds.width)
.padding(self.padding)
.font(font)
@@ -777,7 +763,7 @@ where
struct State<P: text::Paragraph> {
menu: menu::State,
keyboard_modifiers: keyboard::Modifiers,
is_open: Cell<bool>,
is_open: bool,
hovered_option: Option<usize>,
options: Vec<paragraph::Plain<P>>,
placeholder: paragraph::Plain<P>,
@@ -789,7 +775,7 @@ impl<P: text::Paragraph> State<P> {
Self {
menu: menu::State::default(),
keyboard_modifiers: keyboard::Modifiers::default(),
is_open: Cell::new(false),
is_open: bool::default(),
hovered_option: Option::default(),
options: Vec::new(),
placeholder: paragraph::Plain::default(),
@@ -0,0 +1,69 @@
# Explicit Allocation Arrange amendment — 2026-09-23
**Status: accepted design; pure planner verified, App integration and native acceptance pending.** Codex-9e8r6t /root adopts this bounded policy after reviewing the actual OA/SA experiment, its independent critique and corrected geometry oracles. No user-owned application specification is changed.
User-derived basis: [PROJECT_GOAL](../PROJECT_GOAL.md), [multiselect contract](multiselect-contract-20260923.md). Existing authority: [Allocation drag v2](release-activity-allocation-drag-contract-v2-20260911.md) and its linked destination-feedback amendment. [Experiment and planner evidence](allocation-arrange-planner-20260923.md) records the decision history, exact source, results and limits.
## Scope of the change
Explicit **Arrange only** supports one or more selected work/performer placements through one declared footprint and one combined geometry check. A distributed work root does not fail Arrange solely because its allocation summary is MixedOrUnresolved or its semantic descendants occupy different visual performer groups. Its affected projected footprint must still translate rigidly and satisfy all geometry/readability checks below.
This narrowly supersedes v2's mixed-composite/coherence pre-veto **only for explicit Arrange**. Ordinary work-body assignment is unchanged: work-only groups use one frozen common destination, normalize work ancestry, and admit each root under existing role/coherence rules. A mixed/distributed composite still refuses ordinary assignment; different valid initial performers on independent roots do not alone require refusal. Same-effective-performer role-only roots gain no direct Allocation, unsupported role exits refuse wholly, and no drag changes semantic work ownership. Ordinary mixed work/performer selection cannot partially assign its eligible members. Offer Arrange only when its own full-set admission is available.
Arrange admission cannot depend on having two selected items. C alone and {A,C} use the same rule; adding A changes the actual movement footprint rather than unlocking an otherwise identical operation. Existing ordinary performer arrangement remains governed by its existing behavior; this amendment defines the explicit mode consistently across cardinalities.
## Exact target and footprint definitions
1. Validate every original typed target before normalization: exact placement/lifetime in the same active Allocation diagram and layer, currently visible, eligible work or performer kind, and core-valid accepted Project. Any stale, missing, foreign, port, edge or grouping-relation member refuses the whole action. Same-element duplicate placement in one diagram is malformed input. Preserve exact selected membership and primary separately from planning roots.
2. Normalize explicitly selected work roots by same-kind semantic ancestry. For each root, S contains its recursive same-kind work closure. Q is all existing active-diagram placements of the union S, including folded descendants. Unplaced work contributes no fabricated placement.
3. V contains each explicitly selected performer placement and its accepted visual descendants, including folded descendants. R = Q ∪ V. Capture this union once; do not acquire semantic ancestors/siblings or new semantic roots merely because a work placement is an incidental visual descendant. Each R placement moves once. Authored placements outside R and every other diagram remain exact.
4. The press anchor must be an exact selected placement. Snap its requested position once on the existing ten-world-unit grid and apply that one delta to all R. Another selected anchor may change the snapped delta but never the mode or R. Preserve authored sizes, fold states and port anchors. Arrange authorizes no destination unfold, reparenting, packing, Fit or automatic topology change.
5. Preserve every engineering record, including Allocations, RoleAssignments, RoleRealizations and semantic parents. Resolve routes using accepted actual endpoint owners and the complete R delta map: internal pins with both owners moving by the same nonzero delta translate once; one-ended pins remain fixed. Relative labels and attachments remain exact.
## Combined geometry and its deliberate diagnostic boundary
Project the complete accepted and candidate diagrams using the appropriate semantic index. Check the entire resulting visible scene, not only moved cards. An unselected performer enclosure may change its derived bounds even though its authored record is untouched. Such growth is allowed only if the complete candidate meets model bounds and the existing unrelated-overlap rule: no new or enlarged positive-area overlap beyond the current 0.01 area tolerance; visual ancestor/descendant containment is exempt.
Also compare accepted/candidate diagnostic scenes. In each scene, temporarily unfold R and the visual ancestors necessary to reveal R; use the corresponding accepted/candidate visual ancestry. Include stationary geometry revealed by that required expansion. These temporary Diagram changes are never committed. A moved hidden child cannot evade collision checking through a selected performer's fold.
**Unrelated stationary folded interiors are outside this diagnostic domain.** Do not unfold every container in the diagram. Their visible collapsed headers still participate in the ordinary scene check. This limit prevents invisible distant contents from becoming an unexplained blocker; it does not promise that future expansion will be free of overlap. A stricter all-unfolded policy is a different candidate, not an implicit interpretation of this amendment.
Every R node's affected diagnostic footprint must preserve size and translate by the common delta. No automatic convergence/reflow is licensed by removing the semantic pre-veto. Check the required originally selected headers for visibility and containment in the captured viewport; a normalized-away selected descendant cannot silently disappear. Any unsupported footprint, unreadable selection, invalid bounds or new/enlarged unrelated overlap refuses the complete action. Show the actual combined candidate and affected counts; do not present independently valid singleton ghosts as evidence of group feasibility.
## Valid and refused examples
| Example | Required result under this amendment |
| --- | --- |
| Composite C contains x on A and y on B; select C; enough room for one rigid delta | Arrange may move Q={C,x,y}, preserving A/B authored positions and every allocation. Their derived enclosures may grow if the complete scenes remain admissible. Ordinary assignment still refuses the distributed root. |
| Same model, select {A,C} | R additionally includes A and its accepted visual descendants. Move each union member once. The operation is not equivalent to C alone. |
| Select parent C and its already covered child x | Validate both selected targets, deduplicate planning scope, move each placement once, retain the two exact selection identities/primary. |
| X's translated bounds enter Y's old bounds, while both selected roots move clear together | Check simultaneous candidate geometry. Do not refuse because an independent singleton-X check treated Y as stationary. |
| Unselected B's projected enclosure grows into stationary Z although no R card intersects Z | Refuse. Unchanged authored B geometry is not evidence that its derived enclosure is harmless. |
| Folded selected A hides moved x, which newly intersects a stationary visible obstacle | Required diagnostic expansion detects the collision and refuses. |
| Unrelated folded D hides Z that intersects moved x only in an all-unfolded alternative | Z alone does not block under this diagnostic policy. Required visible/R-plus-ancestor checks must still pass; retain D/Z authored records and D's fold. Future expansion may reveal overlap. |
| Selection includes a work card plus port, edge or grouping-relation target | Whole-set refusal; no eligible-subset fallback. |
| Snapped zero delta, unchanged semantics/folds/routes | No command and no history entry. Arrange never changes assignment merely because a pointer crosses a performer. |
## Lifecycle, source and history
Retain v2's clean-owner, dirty-pointer shielding, modal/tool priority and frozen camera/input lifetime. ArrangeNext is explicit and consumed once at press, whether the anchor is work or performer. Preserve the selected set and primary on selected-member press while distinguishing stationary release from group drag; a drag cannot become a singleton click after crossing the three-screen-pixel threshold and returning near its origin. Mode and footprint cannot change midway through a token.
Capture exact selection generation with document/revision, active diagram/layer, epoch, bounds/pan/zoom and accepted scope. Dirty source/property/behavior input, owner/tool/lifetime change, Fit/resize, Escape, focus loss or outside/hidden release cancels without partial movement. Retiring a token is irreversible even when numeric values later return. Unsupported mixtures never fall through to legacy MoveNode, assignment, reconnection or deletion.
Preview is immutable. On release, revalidate the same capability/snapshot, recompute combined geometry and compare the complete prepared Project with an independently constructed expected Project containing only the permitted R/route edits. Execute one Batch through normal Document source admission. Source failure/refusal leaves accepted source, selection/primary and history unchanged; successful movement is one Undo/Redo step with exact source roundtrip. Geometry detector assertions alone do not establish any of these transaction/lifecycle properties.
## Choice rationale and rejected alternatives
Retaining strict v2 is the smallest implementation but rejects a semantics-preserving explicit layout solely for distribution; until this amendment is implemented, its refusal remains truthful. Visual-selected-only translation is rejected because it omits required semantic/folded descendants. Sequential singleton planning is rejected because geometry, routes, deduplication and final expected Projects are not compositional. Universal unfolding is deliberately not chosen for this bounded mode; it introduces invisible unrelated geometry into admission. Packing/reflow and arbitrary convergence remain separate layout work.
## Evidence and acceptance still required
The [experiment/planner report](allocation-arrange-planner-20260923.md) is authoritative for exact runs, source identities, independent review and retained failures. It distinguishes actual projection/source/history evidence from policy predictions and App acceptance. Its independent expected geometry accounts for SA function ports; collision detection is not presented as atomic UI refusal.
Production integration still requires:
- Whole original typed-set admission, token/selection-generation, dirty/stale/source-failure, one-shot mode, irreversible threshold, cancellation and minimum-readable-text-scale checks.
- OA/SA native workflows anchored on performer and work members, truthful counts/preview/refusal, one Undo/Redo, Save/reopen and exact unrelated-record preservation.
- Separate ordinary work-only group assignment with per-root responsibility rules and one combined candidate/geometry gate.
This amendment defines a bounded Arrange capability. It does not establish all Allocation group interactions, general convergence, grouping removal, routing usability or completion of the multiselect issue.
@@ -0,0 +1 @@
** -text whitespace=cr-at-eol

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