Compare commits

..
275 changed files with 8288 additions and 35845 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.
+1 -3
View File
@@ -94,13 +94,11 @@ 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 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.
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.
## Edit the same model as text
@@ -1,27 +0,0 @@
//! Read and validate a saved acceptance fixture without rewriting its source.
//! cargo run --locked -p oa-core --example attachment_snapshot -- input.arcadia output.json
use oa_core::{persistence, validate_candidate};
use std::{fs::OpenOptions, io::Write, path::Path};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let args: Vec<_> = std::env::args_os().skip(1).collect();
if args.len() != 2 {
return Err("Expected input source/JSON and a new output JSON path".into());
}
let project = persistence::load(Path::new(&args[0]))?;
validate_candidate(&project)?;
let bytes = serde_json::to_vec_pretty(&project)?;
let mut output = OpenOptions::new()
.write(true)
.create_new(true)
.open(&args[1])?;
output.write_all(&bytes)?;
output.write_all(b"\n")?;
output.sync_all()?;
println!(
"Validated {} objects and {} views; saved a complete Project snapshot",
project.identities().len(),
project.diagrams.len()
);
Ok(())
}
@@ -1,716 +0,0 @@
//! 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",
);
}
}
@@ -1,548 +0,0 @@
//! 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();
}
}
}
}
@@ -1,101 +0,0 @@
//! Read-only saved-State geometry evidence for a predeclared native bend trial.
//!
//! Usage:
//! state_bend_routing_snapshot INPUT.arcadia NEW_OUTPUT.json
//!
//! Loads and validates the complete Project, then independently projects/routes
//! every saved State view through core APIs. The complete Project permits exact
//! semantic and presentation comparison; derived geometry permits declaring a
//! segment and insertion position before native actions. No desktop command,
//! planner, gesture, or pin insertion helper is called. Input is never written;
//! output uses create_new and fails rather than overwriting an existing file.
//!
//! Limits: this shares production core routing, so it is not an independent
//! mathematical test of that router, nor evidence of UI picking/gesture success.
//! It does not generate expected mutations or adapt an oracle to observed edits.
//! The acceptance recipe must declare the intended segment/index/point and exact
//! Project mutation before UI execution, and bind this executable/source/input
//! and output hashes externally. Warning strings are diagnostic Debug output,
//! not a stable interchange schema. Missing/hidden/internal transitions have no
//! routed edge; their semantic records remain in the complete Project snapshot.
use oa_core::{persistence, projection, routing, validate_candidate};
use serde_json::json;
use std::{fs::OpenOptions, io::Write, path::Path};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let args: Vec<_> = std::env::args_os().skip(1).collect();
if args.len() != 2 {
return Err("Expected input source/JSON and a new output JSON path".into());
}
let project = persistence::load(Path::new(&args[0]))?;
validate_candidate(&project)?;
let mut views = Vec::new();
for (diagram_id, diagram) in &project.diagrams {
let Some(machine) = diagram.projection.state_machine() else {
continue;
};
let projected = projection::project_diagram(&project, diagram);
let routed = routing::route_diagram(&project, diagram, &projected);
let edges: Vec<_> = routed
.edges
.iter()
.map(|(edge_id, edge)| {
json!({
"edge": edge_id,
"id": edge.id,
"points": edge.points,
"pins": edge.pins,
"pin_fractions": edge.pin_fractions,
"custom_suspended": edge.custom_suspended,
"warnings": edge.warnings.iter().map(|warning| format!("{warning:?}")).collect::<Vec<_>>(),
"label": {
"text": edge.label.text,
"fraction": edge.label.fraction,
"anchor": edge.label.anchor,
"center": edge.label.center,
"bounds": {
"position": edge.label.bounds.position,
"size": edge.label.bounds.size
}
}
})
})
.collect();
views.push(json!({
"diagram": diagram_id,
"machine": machine,
"projected": projected,
"routed_bounds": {
"position": routed.bounds.position,
"size": routed.bounds.size
},
"routed_edges": edges
}));
}
// Preserve the canonical typed Project serialization (including f32
// formatting). Serializing it through Value directly promotes f32 to f64.
let project_json: serde_json::Value = serde_json::from_slice(&serde_json::to_vec(&project)?)?;
let snapshot = json!({
"schema": "state-bend-routing-snapshot/v1",
"project": project_json,
"state_views": views
});
// Finish all reading, validation, routing and serialization before creating
// the new evidence file. A failed load cannot leave an apparent snapshot.
let mut bytes = serde_json::to_vec_pretty(&snapshot)?;
bytes.push(b'\n');
let mut output = OpenOptions::new()
.write(true)
.create_new(true)
.open(&args[1])?;
output.write_all(&bytes)?;
output.sync_all()?;
println!(
"Validated {} objects and {} saved views; snapshotted {} State views and the complete Project",
project.identities().len(),
project.diagrams.len(),
views.len()
);
Ok(())
}
@@ -1,911 +0,0 @@
//! 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
@@ -1,91 +0,0 @@
//! 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,7 +1,6 @@
//! Framework-independent OA/SA model, transactional commands and editing services.
pub mod analysis_numeric;
pub mod data;
pub mod diagram_edit;
pub mod language;
pub mod model;
pub mod operations;
-34
View File
@@ -1502,45 +1502,11 @@ pub struct Diagram {
#[serde(deny_unknown_fields)]
pub struct EdgeRoute {
pub edge: Id,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source_attachment: Option<EdgeAttachment>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub target_attachment: Option<EdgeAttachment>,
#[serde(default)]
pub waypoints: Vec<Position>,
#[serde(default)]
pub label: Option<EdgeLabel>,
}
/// Presentation preference on one end of a connection. Real ports take precedence.
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct EdgeAttachment {
pub side: PortAnchorSide,
pub offset: f32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EdgeEnd {
Source,
Target,
}
impl EdgeRoute {
pub fn attachment(&self, end: EdgeEnd) -> Option<EdgeAttachment> {
match end {
EdgeEnd::Source => self.source_attachment,
EdgeEnd::Target => self.target_attachment,
}
}
pub fn set_attachment(&mut self, end: EdgeEnd, attachment: Option<EdgeAttachment>) {
match end {
EdgeEnd::Source => self.source_attachment = attachment,
EdgeEnd::Target => self.target_attachment = attachment,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct EdgeLabel {
+38 -132
View File
@@ -1,12 +1,7 @@
//! Deterministic, bounded diagram geometry. Only authored pins belong in the model;
//! callers share this derived geometry for drawing, picking, fitting and export.
use crate::projection::{self, ProjectedEdge, ProjectedNode, ProjectedView};
use crate::{
Diagram, EdgeAttachment, EdgeEnd, Id, PortAnchorSide, Position, Project, Relation,
RelationBody, RelationKind, Size,
};
mod attachments;
pub use attachments::{attachment_from_point, attachment_point, semantic_port_endpoint};
use crate::{Diagram, Id, Position, Project, Relation, RelationBody, RelationKind, Size};
use std::cmp::Ordering;
use std::collections::{BTreeMap, BTreeSet, BinaryHeap};
use std::fmt;
@@ -46,7 +41,6 @@ pub struct RoutedEdge {
// Derived solver state, never persisted. A preview must inherit the same
// remaining work budget as this edge had in stable-ID settled traversal.
search_budget_before: usize,
attachments: [Option<EdgeAttachment>; 2],
}
#[derive(Debug, Clone, PartialEq)]
@@ -179,7 +173,6 @@ pub fn association_end_geometry(
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RouteWarning {
CoincidentAttachments,
PinInsideObstacle { pin: usize, obstacle: Id },
NoClearPath,
SearchBudgetExhausted,
@@ -188,9 +181,6 @@ pub enum RouteWarning {
impl fmt::Display for RouteWarning {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::CoincidentAttachments => f.write_str(
"The two attachments coincide; move either end to separate the loop tips.",
),
Self::PinInsideObstacle { pin, .. } => {
write!(f, "Bend {} is inside a card or its clearance", pin + 1)
}
@@ -384,14 +374,7 @@ struct Endpoint {
point: Position,
stub: Position,
}
fn endpoints(
project: &Project,
diagram: &Diagram,
node: &ProjectedNode,
port: Option<Id>,
view: &ProjectedView,
attachment: Option<EdgeAttachment>,
) -> Vec<Endpoint> {
fn endpoints(node: &ProjectedNode, port: Option<Id>, view: &ProjectedView) -> Vec<Endpoint> {
if let Some(port) = port.and_then(|id| {
view.ports
.iter()
@@ -406,18 +389,26 @@ fn endpoints(
),
}];
}
if let Some(attachment) = attachment {
return vec![attachments::endpoint(project, diagram, node, attachment)];
}
[
PortAnchorSide::Left,
PortAnchorSide::Right,
PortAnchorSide::Top,
PortAnchorSide::Bottom,
let r = Rect::node(node);
let mid = pos((r.left + r.right) / 2.0, (r.top + r.bottom) / 2.0);
vec![
Endpoint {
point: pos(r.left, mid.y),
stub: pos(r.left - PORT_STUB, mid.y),
},
Endpoint {
point: pos(r.right, mid.y),
stub: pos(r.right + PORT_STUB, mid.y),
},
Endpoint {
point: pos(mid.x, r.top),
stub: pos(mid.x, r.top - PORT_STUB),
},
Endpoint {
point: pos(mid.x, r.bottom),
stub: pos(mid.x, r.bottom + PORT_STUB),
},
]
.into_iter()
.map(|side| attachments::endpoint(project, diagram, node, EdgeAttachment { side, offset: 0.5 }))
.collect()
}
fn ancestors(id: Id, nodes: &BTreeMap<Id, &ProjectedNode>, out: &mut BTreeSet<Id>) {
@@ -516,12 +507,6 @@ fn route_diagram_impl(
.is_some_and(|(source, target)| source != edge.source || target != edge.target);
let custom_suspended = promoted;
let route = if promoted { None } else { stored };
let attachments = [EdgeEnd::Source, EdgeEnd::Target].map(|end| {
semantic_port_endpoint(project, edge.id, end)
.is_none()
.then(|| route.and_then(|r| r.attachment(end)))
.flatten()
});
let pins = route.map_or_else(Vec::new, |r| r.waypoints.clone());
let obstacles = edge_obstacles(edge, &nodes, &rectangles, &headers);
@@ -532,10 +517,7 @@ fn route_diagram_impl(
});
if let Some(cached) = cached {
budget = cached.search_budget_before;
if cached.pins == pins
&& cached.attachments == attachments
&& cached.custom_suspended == custom_suspended
{
if cached.pins == pins && cached.custom_suspended == custom_suspended {
// Label-only editing cannot change the connection beneath the
// pointer, including a settled fallback with routing warnings.
for &point in &cached.points {
@@ -561,22 +543,9 @@ fn route_diagram_impl(
}
let (points, pin_lengths, failure) = route_edge(
edge,
&endpoints(
project,
diagram,
source,
semantic_port_endpoint(project, edge.id, EdgeEnd::Source).or(edge.source_port),
view,
attachments[0],
),
&endpoints(
project,
diagram,
target,
semantic_port_endpoint(project, edge.id, EdgeEnd::Target).or(edge.target_port),
view,
attachments[1],
),
source,
target,
view,
&pins,
&obstacles,
&previous,
@@ -605,7 +574,6 @@ fn route_diagram_impl(
warnings,
custom_suspended,
search_budget_before,
attachments,
},
);
}
@@ -711,46 +679,6 @@ struct RoutingSpace<'a> {
xs: Vec<f32>,
ys: Vec<f32>,
}
fn coincident_detour(a: Endpoint, obstacles: &[Rect]) -> [Position; 3] {
let direction = |delta: f32| if delta == 0. { 0. } else { delta.signum() };
let normal = pos(
direction(a.stub.x - a.point.x),
direction(a.stub.y - a.point.y),
);
let tangent = pos(-normal.y, normal.x);
let candidates: Vec<_> = [36., 72., 144., 288.]
.into_iter()
.flat_map(|extent| {
[-1., 1.].into_iter().map(move |side| {
[
pos(a.stub.x + normal.x * extent, a.stub.y + normal.y * extent),
pos(
a.stub.x + (normal.x + tangent.x * side) * extent,
a.stub.y + (normal.y + tangent.y * side) * extent,
),
pos(
a.stub.x + tangent.x * side * extent,
a.stub.y + tangent.y * side * extent,
),
]
})
})
.collect();
// These are computed loop guards, not authored hard pins. Prefer a clear
// orientation/size before asking the bounded router to connect them.
candidates
.iter()
.find(|p| clear_path(&[a.stub, p[0], p[1], p[2], a.stub], obstacles))
.or_else(|| {
candidates.iter().find(|p| {
p.iter()
.all(|point| !obstacles.iter().any(|r| r.contains(*point)))
})
})
.copied()
.unwrap_or(candidates[0])
}
impl<'a> RoutingSpace<'a> {
fn new(obstacles: &'a [Rect]) -> Self {
Self {
@@ -764,41 +692,24 @@ impl<'a> RoutingSpace<'a> {
#[allow(clippy::too_many_arguments)]
fn route_edge(
edge: &ProjectedEdge,
source_ends: &[Endpoint],
target_ends: &[Endpoint],
source: &ProjectedNode,
target: &ProjectedNode,
view: &ProjectedView,
pins: &[Position],
obstacles: &[Rect],
previous: &[Segment],
budget: &mut usize,
) -> EdgePath {
// Coincident authored tips are valid presentation, including on self loops.
// Preserve them and add an external detour; never index an empty candidate
// list or turn the connection into an out-and-back degenerate stub.
let coincident = edge.source == edge.target
&& source_ends.len() == 1
&& target_ends.len() == 1
&& source_ends[0].point == target_ends[0].point
&& source_ends[0].stub == target_ends[0].stub;
let authored_pin_count = pins.len();
let mut loop_pins = Vec::new();
if coincident {
let a = source_ends[0];
loop_pins.extend_from_slice(pins);
loop_pins.extend(coincident_detour(a, obstacles));
}
let pins = if coincident { &loop_pins[..] } else { pins };
let space = RoutingSpace::new(obstacles);
let mut candidates = Vec::new();
for &a in source_ends {
for &b in target_ends {
for a in endpoints(source, edge.source_port, view) {
for b in endpoints(target, edge.target_port, view) {
// A real self-relation must leave and return at distinct anchors.
// Reusing one anchor makes the cheapest path a degenerate stub.
if edge.source == edge.target
&& edge.source_port.is_none()
&& edge.target_port.is_none()
&& a.point == b.point
&& a.stub == b.stub
&& !coincident
{
continue;
}
@@ -818,9 +729,10 @@ fn route_edge(
// Try all side combinations cheaply before spending the bounded grid budget.
for search in [false, true] {
for &(estimate, a, b) in &candidates {
if best.as_ref().is_some_and(|(cost, _, _)| {
estimate + manhattan(a.point, a.stub) + manhattan(b.point, b.stub) > *cost
}) {
if best
.as_ref()
.is_some_and(|(cost, _, _)| estimate + 2.0 * PORT_STUB > *cost)
{
continue;
}
if obstacles
@@ -836,7 +748,7 @@ fn route_edge(
let mut occupied = previous.to_vec();
record_segments(&mut occupied, &points);
let mut pin_lengths = Vec::new();
let mut distance = manhattan(a.point, a.stub);
let mut distance = PORT_STUB;
let mut start = a.stub;
let mut failed = false;
for (index, end) in pins
@@ -880,20 +792,15 @@ fn route_edge(
break;
}
}
if let Some((_, points, mut pin_lengths)) = best {
pin_lengths.truncate(authored_pin_count);
return (
points,
pin_lengths,
coincident.then_some(RouteWarning::CoincidentAttachments),
);
if let Some((_, points, pin_lengths)) = best {
return (points, pin_lengths, None);
}
// Recoverable fallback retains every hard pin, in order, and the actual port
// leads. Its warning is deliberately visible to consumers.
let (_, a, b) = candidates[0];
let mut points = vec![a.point, a.stub];
let mut pin_lengths = Vec::new();
let mut distance = manhattan(a.point, a.stub);
let mut distance = PORT_STUB;
let mut start = a.stub;
for (index, end) in pins
.iter()
@@ -926,7 +833,6 @@ fn route_edge(
start = end;
}
points.push(b.point);
pin_lengths.truncate(authored_pin_count);
(
points,
pin_lengths,
-160
View File
@@ -1,160 +0,0 @@
//! One boundary definition shared by persisted routing and attachment gestures.
use super::*;
use crate::{EdgeAttachment, EdgeEnd, Kind, PortAnchorSide};
/// Semantic authority, independent of whether projection currently exposes a port.
pub fn semantic_port_endpoint(project: &Project, edge: Id, end: EdgeEnd) -> Option<Id> {
let endpoint = if let Some(relation) = project.relations.get(&edge) {
match (&relation.body, end) {
(RelationBody::FunctionalExchange { source_port, .. }, EdgeEnd::Source) => *source_port,
(RelationBody::FunctionalExchange { target_port, .. }, EdgeEnd::Target) => *target_port,
(_, EdgeEnd::Source) => relation.source(),
(_, EdgeEnd::Target) => relation.target(),
}
} else {
let trace = project.traces.get(&edge)?;
match end {
EdgeEnd::Source => trace.oa,
EdgeEnd::Target => trace.sa,
}
};
project
.elements
.get(&endpoint)
.filter(|e| e.kind() == Kind::Port)
.map(|_| endpoint)
}
/// Intersect the chosen cardinal scan line with the painted shape. Offset is
/// measured over the complete projected bounding side, including its corners.
/// Terminate's X uses its outermost stroke intersection rather than a contour.
pub fn attachment_point(
project: &Project,
diagram: &Diagram,
node: &ProjectedNode,
attachment: &EdgeAttachment,
) -> Position {
let vertical = matches!(
attachment.side,
PortAnchorSide::Left | PortAnchorSide::Right
);
let (extent, depth) = if vertical {
(node.size.height, node.size.width)
} else {
(node.size.width, node.size.height)
};
let along = attachment.offset.clamp(0., 1.) * extent;
let t = 2. * attachment.offset.clamp(0., 1.) - 1.;
let kind = diagram
.projection
.state_machine()
.and_then(|_| project.elements.get(&node.element))
.map(|e| e.kind());
let inset = match kind {
Some(Kind::Choice) => depth * 0.5 * t.abs(),
Some(Kind::Terminate) => {
depth
* attachment
.offset
.clamp(0., 1.)
.min(1. - attachment.offset.clamp(0., 1.))
}
Some(
Kind::Initial
| Kind::Junction
| Kind::Final
| Kind::ShallowHistory
| Kind::DeepHistory
| Kind::EntryPoint
| Kind::ExitPoint,
) => depth * 0.5 * (1. - (1. - t * t).max(0.).sqrt()),
Some(Kind::Region | Kind::Fork | Kind::Join) => 0.,
_ => {
let radius = 10_f32.min(extent / 2.).min(depth / 2.);
let corner = (radius - along.min(extent - along)).max(0.);
radius - (radius * radius - corner * corner).max(0.).sqrt()
}
};
match attachment.side {
PortAnchorSide::Left => pos(node.position.x + inset, node.position.y + along),
PortAnchorSide::Right => pos(
node.position.x + node.size.width - inset,
node.position.y + along,
),
PortAnchorSide::Top => pos(node.position.x + along, node.position.y + inset),
PortAnchorSide::Bottom => pos(
node.position.x + along,
node.position.y + node.size.height - inset,
),
}
}
/// Choose a cardinal bounding side, then map its fraction onto the painted
/// contour. Contour distances cannot distinguish the sides of a circle at its
/// extrema: Right/0 and Top/0.5 denote the same point but different lead directions.
/// Hysteresis is in diagram units, capped for small symbols. Only UI snapping
/// rounds to hundredths: source/API values retain their complete precision.
pub fn attachment_from_point(
node: &ProjectedNode,
cursor: Position,
previous: Option<PortAnchorSide>,
hysteresis: f32,
) -> EdgeAttachment {
let candidate = |side| EdgeAttachment {
side,
offset: projection::port_anchor_offset(node, side, cursor),
};
// Measure side intent at the box, not from a distant overshoot: Euclidean
// distance differences converge outside a small symbol and would otherwise
// let hysteresis retain a perpendicular side indefinitely.
let boundary_cursor = pos(
cursor
.x
.clamp(node.position.x, node.position.x + node.size.width),
cursor
.y
.clamp(node.position.y, node.position.y + node.size.height),
);
let distance = |side| {
length(
projection::port_anchor_position(node, side, candidate(side).offset),
boundary_cursor,
)
};
let mut side = [
PortAnchorSide::Left,
PortAnchorSide::Right,
PortAnchorSide::Top,
PortAnchorSide::Bottom,
]
.into_iter()
.min_by(|a, b| distance(*a).total_cmp(&distance(*b)))
.unwrap();
let hysteresis = hysteresis
.max(0.)
.min(node.size.width.min(node.size.height) * 0.2);
if let Some(old) = previous
&& distance(old) <= distance(side) + hysteresis
{
side = old;
}
let mut attachment = candidate(side);
attachment.offset = (attachment.offset * 100.).round() / 100.;
attachment
}
pub(super) fn endpoint(
project: &Project,
diagram: &Diagram,
node: &ProjectedNode,
attachment: EdgeAttachment,
) -> Endpoint {
let point = attachment_point(project, diagram, node, &attachment);
let stub = match attachment.side {
PortAnchorSide::Left => pos(node.position.x - PORT_STUB, point.y),
PortAnchorSide::Right => pos(node.position.x + node.size.width + PORT_STUB, point.y),
PortAnchorSide::Top => pos(point.x, node.position.y - PORT_STUB),
PortAnchorSide::Bottom => pos(point.x, node.position.y + node.size.height + PORT_STUB),
};
Endpoint { point, stub }
}
+1 -18
View File
@@ -93,18 +93,6 @@ fn restore_serialization_skips(context: Context, fields: &mut Map<String, Value>
.or_insert(Value::Null);
}
}
Context::Diagram => {
if let Some(routes) = fields.get_mut("routes").and_then(Value::as_array_mut) {
for route in routes.iter_mut().filter_map(Value::as_object_mut) {
route
.entry("source_attachment".to_owned())
.or_insert(Value::Null);
route
.entry("target_attachment".to_owned())
.or_insert(Value::Null);
}
}
}
Context::Node => {
fields.entry("port_anchors".to_owned()).or_insert(json!([]));
}
@@ -256,12 +244,7 @@ fn compact_nested(context: Context, fields: &mut Map<String, Value>) {
if let Some(routes) = fields.get_mut("routes").and_then(Value::as_array_mut) {
for route in routes {
if let Some(route) = route.as_object_mut() {
omit_defaults(
route,
&object(
json!({"waypoints": [], "label": null, "source_attachment": null, "target_attachment": null}),
),
);
omit_defaults(route, &object(json!({"waypoints": [], "label": null})));
}
}
}
+12 -2
View File
@@ -750,7 +750,7 @@ pub fn plan_transition(
);
continue;
}
if !compatible_existing(project, *source, target, request.direction) {
if !counterpart_compatible(project, *source, target, request.direction) {
conflict(
&mut plan,
Some(*source),
@@ -1317,12 +1317,22 @@ fn compatible_kinds(source: Kind, target: Kind, direction: TransitionDirection)
}
inferred_kind(source, direction) == Some(target)
}
fn compatible_existing(
/// Whether existing records have compatible layers, categories and kinds for
/// an ordinary transition. This is candidate eligibility, not approval of a
/// complete plan: properties, dependencies and structural conflicts still need
/// the normal preview and apply validation. Manual traces are not restricted
/// by this predicate.
pub fn counterpart_compatible(
p: &Project,
source: Id,
target: Id,
direction: TransitionDirection,
) -> bool {
if p.layer_of(source) != Some(direction.source_layer())
|| p.layer_of(target) != Some(direction.target_layer())
{
return false;
}
match (p.elements.get(&source), p.elements.get(&target)) {
(Some(a), Some(b)) => compatible_kinds(a.kind(), b.kind(), direction),
_ => match (p.relations.get(&source), p.relations.get(&target)) {
-14
View File
@@ -643,20 +643,6 @@ pub fn validate(p: &Project) -> Vec<Issue> {
}
let mut routed_edges = BTreeSet::new();
for (index, route) in d.routes.iter().enumerate() {
for attachment in [route.source_attachment, route.target_attachment]
.into_iter()
.flatten()
{
if !attachment.offset.is_finite() || !(0.0..=1.0).contains(&attachment.offset) {
error(
&mut out,
d.id,
"invalid_attachment_offset",
"routes",
"Connection attachment offset must be finite and between zero and one.",
);
}
}
if !routed_edges.insert(route.edge) {
error(
&mut out,
-385
View File
@@ -1,385 +0,0 @@
use oa_core::{projection::*, routing::*, *};
fn pos(x: f32, y: f32) -> Position {
Position { x, y }
}
fn anchor(side: PortAnchorSide, offset: f32) -> EdgeAttachment {
EdgeAttachment { side, offset }
}
fn fixture() -> (Project, Id, Id, Id, Id) {
let mut p = Project::empty();
let a = p
.add_element(Kind::Activity, Layer::Oa, "Report", None)
.unwrap();
let b = p
.add_element(Kind::Activity, Layer::Oa, "Receive", None)
.unwrap();
let edge = p.relate(RelationKind::Interaction, "Report status", a, b);
let d = p.ensure_diagram(Layer::Oa);
p.place(d, a, pos(50., 50.));
p.place(d, b, pos(750., 200.));
(p, d, a, b, edge)
}
fn stored(edge: Id) -> EdgeRoute {
EdgeRoute {
edge,
source_attachment: None,
target_attachment: None,
waypoints: vec![],
label: None,
}
}
fn routed(p: &Project, d: Id) -> RoutedView {
let diagram = &p.diagrams[&d];
route_diagram(p, diagram, &project_diagram(p, diagram))
}
fn near(a: f32, b: f32) {
assert!((a - b).abs() < 0.002, "{a} != {b}");
}
fn orthogonal(points: &[Position]) {
assert!(points.len() >= 2);
for span in points.windows(2) {
assert!(span[0].x == span[1].x || span[0].y == span[1].y, "{span:?}");
}
}
fn length(points: &[Position]) -> f32 {
points
.windows(2)
.map(|p| (p[0].x - p[1].x).abs() + (p[0].y - p[1].y).abs())
.sum()
}
#[test]
fn authored_boundary_follows_projected_owner_and_preview_matches_committed_geometry() {
let (mut p, d, a, _, edge) = fixture();
let initial = routed(&p, d);
let mut route = stored(edge);
route.source_attachment = Some(anchor(PortAnchorSide::Bottom, 0.25));
route.target_attachment = Some(anchor(PortAnchorSide::Top, 0.75));
route.waypoints = vec![pos(460., 350.)];
p.diagrams.get_mut(&d).unwrap().routes.push(route.clone());
let view = project_diagram(&p, &p.diagrams[&d]);
let preview = route_edge_preview(&p, &p.diagrams[&d], &view, &initial, edge);
let full = routed(&p, d);
assert_eq!(
preview, full,
"attachment-only changes must invalidate the label-only path cache"
);
let n = view.nodes.iter().find(|n| n.element == a).unwrap();
assert_eq!(
full.edges[&edge].points[0],
pos(
n.position.x + n.size.width * 0.25,
n.position.y + n.size.height
)
);
assert_eq!(full.edges[&edge].pins, route.waypoints);
orthogonal(&full.edges[&edge].points);
let node = p
.diagrams
.get_mut(&d)
.unwrap()
.nodes
.values_mut()
.find(|n| n.element == a)
.unwrap();
node.position.x += 75.;
node.size.width += 100.;
node.size.height += 50.;
let moved = routed(&p, d);
near(
moved.edges[&edge].points[0].x - full.edges[&edge].points[0].x,
100.,
);
near(
moved.edges[&edge].points[0].y - full.edges[&edge].points[0].y,
50.,
);
assert_eq!(p.diagrams[&d].routes[0], route);
}
#[test]
fn attachment_only_preview_cannot_reuse_old_endpoints_with_unchanged_pins() {
let (mut p, d, _, _, edge) = fixture();
let baseline = routed(&p, d);
let mut route = stored(edge);
route.source_attachment = Some(anchor(PortAnchorSide::Top, 0.2));
p.diagrams.get_mut(&d).unwrap().routes.push(route);
let view = project_diagram(&p, &p.diagrams[&d]);
let preview = route_edge_preview(&p, &p.diagrams[&d], &view, &baseline, edge);
assert_ne!(preview.edges[&edge].points, baseline.edges[&edge].points);
assert_eq!(preview, routed(&p, d));
}
#[test]
fn ports_remain_authoritative_even_when_projection_filters_the_port() {
let mut p = Project::empty();
let a = p
.add_element(Kind::Function, Layer::Sa, "Send", None)
.unwrap();
let b = p
.add_element(Kind::Function, Layer::Sa, "Receive", None)
.unwrap();
let edge = p.relate(RelationKind::FunctionalExchange, "Data", a, b);
let d = p.ensure_diagram(Layer::Sa);
p.place(d, a, pos(0., 0.));
p.place(d, b, pos(600., 0.));
let diagram = p.diagrams[&d].clone();
let view = project_diagram(&p, &diagram);
let expected = route_diagram(&p, &diagram, &view);
assert!(semantic_port_endpoint(&p, edge, EdgeEnd::Source).is_some());
let mut route = stored(edge);
route.source_attachment = Some(anchor(PortAnchorSide::Bottom, 0.1));
route.target_attachment = Some(anchor(PortAnchorSide::Top, 0.9));
p.diagrams.get_mut(&d).unwrap().routes.push(route);
assert_eq!(routed(&p, d), expected);
let mut hidden = view.clone();
hidden.ports.clear();
for e in &mut hidden.edges {
e.source_port = None;
e.target_port = None;
}
let absent = route_diagram(&p, &diagram, &hidden);
assert_eq!(
route_diagram(&p, &p.diagrams[&d], &hidden),
absent,
"hidden ports must not activate a dormant node preference"
);
}
fn state(kind: Kind) -> (Project, Id, Id, Id) {
let mut p = Project::empty();
let owner = p
.add_element(Kind::Entity, Layer::Oa, "Crew", None)
.unwrap();
let machine = p
.add_element(Kind::StateMachine, Layer::Oa, "Modes", Some(owner))
.unwrap();
let region = p
.add_element(Kind::Region, Layer::Oa, "Main", Some(machine))
.unwrap();
let a = p.add_element(kind, Layer::Oa, "A", Some(region)).unwrap();
let b = p
.add_element(Kind::State, Layer::Oa, "B", Some(region))
.unwrap();
let edge = p.relate(RelationKind::StateTransition, "Next", a, b);
let d = p.ensure_diagram(Layer::Oa);
p.diagrams.get_mut(&d).unwrap().projection = ProjectionKind::StateMachine { machine };
p.place(d, a, pos(100., 100.));
p.place(d, b, pos(600., 100.));
(p, d, a, edge)
}
#[test]
fn nonrectangular_state_attachments_intersect_the_painted_shape() {
for kind in [
Kind::Initial,
Kind::Junction,
Kind::Final,
Kind::Choice,
Kind::ShallowHistory,
Kind::DeepHistory,
Kind::EntryPoint,
Kind::ExitPoint,
Kind::Terminate,
Kind::Fork,
Kind::Join,
Kind::State,
] {
let (p, d, a, _) = state(kind);
let diagram = &p.diagrams[&d];
let view = project_diagram(&p, diagram);
let n = view.nodes.iter().find(|n| n.element == a).unwrap();
for side in [
PortAnchorSide::Left,
PortAnchorSide::Right,
PortAnchorSide::Top,
PortAnchorSide::Bottom,
] {
for offset in [0., 0.2, 0.5, 0.8, 1.] {
let at = attachment_point(&p, diagram, n, &anchor(side, offset));
let x = (at.x - n.position.x) / n.size.width;
let y = (at.y - n.position.y) / n.size.height;
match kind {
Kind::Choice => near((2. * x - 1.).abs() + (2. * y - 1.).abs(), 1.),
Kind::Terminate => near((x - y).abs().min((x + y - 1.).abs()), 0.),
Kind::Fork | Kind::Join => near(x.min(1. - x).min(y.min(1. - y)), 0.),
Kind::State => {
assert!((0. ..=1.).contains(&x) && (0. ..=1.).contains(&y));
if offset == 0. || offset == 1. {
assert!(
x != 0. && x != 1. || y != 0. && y != 1.,
"rounded corner cannot use bounding-box corner"
);
}
}
_ => near((2. * x - 1.).powi(2) + (2. * y - 1.).powi(2), 1.),
}
}
}
}
}
#[test]
fn inset_contour_leads_keep_pin_fractions_and_labels_on_the_complete_path() {
let (mut p, d, _, edge) = state(Kind::Choice);
let mut route = stored(edge);
route.source_attachment = Some(anchor(PortAnchorSide::Right, 0.));
route.waypoints = vec![pos(380., 40.)];
route.label = Some(EdgeLabel {
fraction: 0.3,
offset: pos(0., 0.),
});
p.diagrams.get_mut(&d).unwrap().routes.push(route);
let result = routed(&p, d);
let r = &result.edges[&edge];
orthogonal(&r.points);
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
let pin = r.pins[0];
let pin_index = r.points.iter().position(|p| *p == pin).unwrap();
near(
r.pin_fractions[0],
length(&r.points[..=pin_index]) / length(&r.points),
);
assert_eq!(
r.label.anchor,
point_along_polyline(&r.points, 0.3).unwrap()
);
}
#[test]
fn small_symbol_drag_chooses_the_intended_exit_side_at_low_zoom() {
for kind in [
Kind::Initial,
Kind::Choice,
Kind::Terminate,
Kind::Fork,
Kind::State,
] {
let (p, d, a, _) = state(kind);
let view = project_diagram(&p, &p.diagrams[&d]);
let n = view.nodes.iter().find(|n| n.element == a).unwrap();
for zoom in [0.5, 1., 2.] {
for overshoot in [0., 20.] {
let cursor = pos(n.position.x + n.size.width / 2., n.position.y - overshoot);
let chosen =
attachment_from_point(n, cursor, Some(PortAnchorSide::Right), 6. / zoom);
assert_eq!(
chosen,
anchor(PortAnchorSide::Top, 0.5),
"{kind:?}, zoom {zoom}, overshoot {overshoot}"
);
let painted = attachment_point(&p, &p.diagrams[&d], n, &chosen);
near(painted.x, n.position.x + n.size.width / 2.);
near(
painted.y,
n.position.y
+ if kind == Kind::Terminate {
n.size.height / 2.
} else {
0.
},
);
}
let near_corner = pos(n.position.x + n.size.width - 1., n.position.y + 1.);
let stable =
attachment_from_point(n, near_corner, Some(PortAnchorSide::Right), 6. / zoom);
assert_eq!(
stable.side,
PortAnchorSide::Right,
"corner hysteresis stays stable"
);
}
}
}
#[test]
fn coincident_self_attachments_retain_hard_pins_and_form_a_visible_loop() {
let (mut p, d, a, edge) = state(Kind::State);
if let RelationBody::StateTransition { target, .. } =
&mut p.relations.get_mut(&edge).unwrap().body
{
*target = a;
}
for pins in [vec![], vec![pos(400., 30.)]] {
let mut route = stored(edge);
route.source_attachment = Some(anchor(PortAnchorSide::Top, 0.5));
route.target_attachment = route.source_attachment;
route.waypoints = pins.clone();
p.diagrams.get_mut(&d).unwrap().routes = vec![route.clone()];
let result = routed(&p, d);
let r = &result.edges[&edge];
orthogonal(&r.points);
assert_eq!(r.points.first(), r.points.last());
assert!(length(&r.points) > 2. * PORT_STUB + 70.);
assert_eq!(r.pins, pins);
assert_eq!(r.pin_fractions.len(), pins.len());
assert!(
r.warnings.contains(&RouteWarning::CoincidentAttachments),
"{:?}",
r.warnings
);
assert_eq!(routed(&p, d), result, "deterministic fallback");
assert_eq!(p.diagrams[&d].routes[0], route);
}
}
#[test]
fn coincident_loop_chooses_clear_side_instead_of_inventing_an_obstructed_pin() {
for blocker_side in [-1., 1.] {
let (mut p, d, a, edge) = state(Kind::State);
if let RelationBody::StateTransition { target, .. } =
&mut p.relations.get_mut(&edge).unwrap().body
{
*target = a;
}
let mut route = stored(edge);
route.source_attachment = Some(anchor(PortAnchorSide::Top, 0.5));
route.target_attachment = route.source_attachment;
p.diagrams.get_mut(&d).unwrap().routes.push(route);
let view = project_diagram(&p, &p.diagrams[&d]);
let source = view.nodes.iter().find(|n| n.element == a).unwrap();
let tip = attachment_point(
&p,
&p.diagrams[&d],
source,
&anchor(PortAnchorSide::Top, 0.5),
);
let region = p.elements[&a].parent;
let blocker = p
.add_element(Kind::State, Layer::Oa, "Detour obstruction", region)
.unwrap();
let left = if blocker_side > 0. {
tip.x + 30.
} else {
tip.x - 30. - 224.
};
p.place(d, blocker, pos(left, tip.y - 144.));
let result = routed(&p, d);
let r = &result.edges[&edge];
assert_eq!(r.warnings, vec![RouteWarning::CoincidentAttachments]);
orthogonal(&r.points);
let view = project_diagram(&p, &p.diagrams[&d]);
let n = view.nodes.iter().find(|n| n.element == blocker).unwrap();
for segment in r.points.windows(2) {
for step in 0..=100 {
let t = step as f32 / 100.;
let q = pos(
segment[0].x + (segment[1].x - segment[0].x) * t,
segment[0].y + (segment[1].y - segment[0].y) * t,
);
assert!(
!(q.x > n.position.x - CLEARANCE
&& q.x < n.position.x + n.size.width + CLEARANCE
&& q.y > n.position.y - CLEARANCE
&& q.y < n.position.y + n.size.height + CLEARANCE),
"route crosses blocker: {q:?}"
);
}
}
assert!(
r.pins.is_empty(),
"generated detours must never become authored pins"
);
}
}
@@ -1,272 +0,0 @@
use oa_core::{language, projection::*, routing::*, *};
fn pos(x: f32, y: f32) -> Position {
Position { x, y }
}
fn id(project: &Project, alias: &str) -> Id {
project
.identities()
.into_iter()
.find(|(_, a)| *a == alias)
.unwrap()
.0
}
fn authored(edge: Id) -> EdgeRoute {
EdgeRoute {
edge,
source_attachment: Some(EdgeAttachment {
side: PortAnchorSide::Bottom,
offset: 0.2,
}),
target_attachment: Some(EdgeAttachment {
side: PortAnchorSide::Top,
offset: 0.8,
}),
waypoints: vec![pos(520., 420.)],
label: Some(EdgeLabel {
fraction: 0.35,
offset: pos(17., -23.),
}),
}
}
fn routed(project: &Project, diagram: Id) -> RoutedView {
let d = &project.diagrams[&diagram];
route_diagram(project, d, &project_diagram(project, d))
}
fn fixture() -> (Project, Id, Id, Id, Id, Id, Id) {
let mut p = language::parse(
r#"arcadia 7;
project lifecycle "Attachment lifecycle" {
oa_activity group "Group" { oa_activity first "First" {} }
oa_activity second "Second" {}
oa_activity replacement "Replacement" {}
interaction signal "Signal" { source @first; target @second; }
diagram view "View" {}
diagram other "Other" {}
}"#,
)
.unwrap();
let (d, other, group, first, second, replacement, edge) = (
id(&p, "view"),
id(&p, "other"),
id(&p, "group"),
id(&p, "first"),
id(&p, "second"),
id(&p, "replacement"),
id(&p, "signal"),
);
for diagram in [d, other] {
p.place(diagram, group, pos(20., 20.));
p.place(diagram, first, pos(100., 130.));
p.place(diagram, second, pos(750., 150.));
p.place(diagram, replacement, pos(750., 570.));
p.diagrams
.get_mut(&diagram)
.unwrap()
.routes
.push(authored(edge));
}
(p, d, other, group, first, replacement, edge)
}
#[test]
fn attachment_fold_promotion_suspends_the_complete_route_and_expand_restores_it() {
let (mut p, d, other, group, _, _, edge) = fixture();
assert_eq!(semantic_port_endpoint(&p, edge, EdgeEnd::Source), None);
assert_eq!(semantic_port_endpoint(&p, edge, EdgeEnd::Target), None);
let before = p.clone();
let expanded = routed(&p, d);
let other_geometry = routed(&p, other);
let parent = p.diagrams[&d]
.nodes
.values()
.find(|n| n.element == group)
.unwrap()
.id;
p.diagrams
.get_mut(&d)
.unwrap()
.nodes
.get_mut(&parent)
.unwrap()
.collapsed = true;
let folded = routed(&p, d);
assert!(folded.edges[&edge].custom_suspended);
assert_eq!(folded.edges[&edge].pins, []);
assert!(
folded.edges[&edge]
.warnings
.contains(&RouteWarning::PromotedEndpoints)
);
assert_eq!(p.diagrams[&d].routes, before.diagrams[&d].routes);
let mut automatic = p.clone();
automatic.diagrams.get_mut(&d).unwrap().routes.clear();
assert_eq!(
folded,
routed(&automatic, d),
"attachments, pins and label must all be dormant on promotion"
);
assert_eq!(p.diagrams[&other], before.diagrams[&other]);
assert_eq!(routed(&p, other), other_geometry);
p.diagrams
.get_mut(&d)
.unwrap()
.nodes
.get_mut(&parent)
.unwrap()
.collapsed = false;
assert_eq!(p, before);
assert_eq!(routed(&p, d), expanded);
}
#[test]
fn attachment_reconnect_retains_slot_preferences_and_other_view_records_with_exact_history() {
let (p, d, other, _, _, replacement, edge) = fixture();
let before = routed(&p, d);
let mut relation = p.relations[&edge].clone();
let RelationBody::Interaction { target, .. } = &mut relation.body else {
unreachable!()
};
*target = replacement;
let mut expected = p.clone();
expected.relations.insert(edge, relation.clone());
let mut session = Session::new(p.clone());
session
.execute(Command::EditRelation(edge, relation))
.unwrap();
assert_eq!(
session.project(),
&expected,
"reconnect changes only the semantic endpoint; all per-view records remain exact"
);
assert_eq!(session.project().diagrams[&other], p.diagrams[&other]);
for diagram in [d, other] {
let current = session.project();
let view = project_diagram(current, &current.diagrams[&diagram]);
let node = view
.nodes
.iter()
.find(|n| n.element == replacement)
.unwrap();
let preference = current.diagrams[&diagram].routes[0]
.target_attachment
.unwrap();
let expected_tip =
attachment_point(current, &current.diagrams[&diagram], node, &preference);
let result = routed(current, diagram);
assert_eq!(result.edges[&edge].points.last(), Some(&expected_tip));
assert_eq!(
result.edges[&edge].pins,
p.diagrams[&diagram].routes[0].waypoints
);
}
assert_ne!(
routed(session.project(), d).edges[&edge].points.last(),
before.edges[&edge].points.last()
);
session.undo();
assert_eq!(session.project(), &p);
assert_eq!(routed(session.project(), d), before);
session.redo();
assert_eq!(session.project(), &expected);
}
#[test]
fn attachment_direct_port_endpoints_remain_authoritative_when_their_glyphs_are_filtered() {
let mut p = Project::empty();
let system = p
.add_element(Kind::System, Layer::Sa, "System", None)
.unwrap();
let actor = p
.add_element(Kind::SystemActor, Layer::Sa, "Actor", None)
.unwrap();
let function = p
.add_element(Kind::Function, Layer::Sa, "Send", None)
.unwrap();
let mut ports = Vec::new();
for (owner, name, contract) in [
(
system,
"Output",
PortContract::Component {
kind: ComponentPortKind::Flow,
orientation: ComponentPortOrientation::Out,
},
),
(
actor,
"Input",
PortContract::Component {
kind: ComponentPortKind::Flow,
orientation: ComponentPortOrientation::In,
},
),
(
function,
"Function output",
PortContract::FunctionOutput {
outgoing_items: vec![],
},
),
] {
let port = Id::new_v4();
p.elements
.insert(port, make_port(&p, port, owner, name, contract).unwrap());
ports.push(port);
}
p.relate(
RelationKind::Allocation,
"Function allocation",
system,
function,
);
let boundary = p.relate(
RelationKind::BoundaryExchange,
"Boundary",
ports[0],
ports[1],
);
let allocation = p.relate(
RelationKind::PortAllocation,
"Port allocation",
ports[0],
ports[2],
);
let d = p.ensure_diagram(Layer::Sa);
p.place(d, system, pos(0., 0.));
p.place(d, actor, pos(700., 0.));
p.place(d, function, pos(0., 450.));
let original = p.clone();
let baseline = routed(&p, d);
for (edge, target) in [(boundary, ports[1]), (allocation, ports[2])] {
assert!(baseline.edges.contains_key(&edge));
assert_eq!(
semantic_port_endpoint(&p, edge, EdgeEnd::Source),
Some(ports[0])
);
assert_eq!(
semantic_port_endpoint(&p, edge, EdgeEnd::Target),
Some(target)
);
let mut route = authored(edge);
route.waypoints.clear();
route.label = None;
p.diagrams.get_mut(&d).unwrap().routes.push(route);
}
assert_eq!(routed(&p, d), baseline);
let mut hidden = project_diagram(&p, &p.diagrams[&d]);
hidden.ports.clear();
for edge in &mut hidden.edges {
edge.source_port = None;
edge.target_port = None;
}
assert_eq!(
route_diagram(&p, &p.diagrams[&d], &hidden),
route_diagram(&original, &original.diagrams[&d], &hidden),
"filtered direct Port endpoints must not activate their stored preferences"
);
}
@@ -1,136 +0,0 @@
use oa_core::{projection::*, routing::*, *};
#[test]
fn valid_item_trace_follows_manual_port_anchors_and_ignores_dormant_attachments() {
let mut project = Project::empty();
let oa = project
.add_element(Kind::Activity, Layer::Oa, "Send", None)
.unwrap();
let sa = project
.add_element(Kind::Function, Layer::Sa, "Receive", None)
.unwrap();
let ports = [(oa, Layer::Oa), (sa, Layer::Sa)].map(|(owner, layer)| {
let id = Id::new_v4();
project.elements.insert(
id,
Element {
id,
alias: format!("port_{}", id.simple()),
name: "Status port".into(),
description: "Existing legacy port retained by Item trace".into(),
parent: Some(owner),
body: ElementBody::shared_in(
layer,
SharedBody::Port {
contract: PortContract::Legacy,
legacy: Some(LegacyPortContract {
direction: PortDirection::InOut,
interface: None,
}),
},
),
},
);
id
});
let trace = Trace {
id: Id::new_v4(),
alias: "status_correspondence".into(),
name: "Status correspondence".into(),
description: "Preserve this semantic evidence".into(),
oa: ports[0],
sa: ports[1],
kind: TraceKind::Item,
baseline: None,
};
project.traces.insert(trace.id, trace.clone());
let diagram = project.ensure_diagram(Layer::Oa);
project.diagrams.get_mut(&diagram).unwrap().projection = ProjectionKind::Traceability;
let owners = [
project.place(diagram, oa, Position { x: 100., y: 100. }),
project.place(diagram, sa, Position { x: 800., y: 300. }),
];
validate_candidate(&project).unwrap();
let semantic_elements = project.elements.clone();
let semantic_relations = project.relations.clone();
let semantic_traces = project.traces.clone();
let view = project_diagram(&project, &project.diagrams[&diagram]);
let edge = view.edges.iter().find(|edge| edge.id == trace.id).unwrap();
assert!(edge.trace);
assert_eq!((edge.source, edge.target), (owners[0], owners[1]));
assert_eq!((edge.source_port, edge.target_port), (None, None));
let initial = route_diagram(&project, &project.diagrams[&diagram], &view);
for (index, (side, offset)) in [(PortAnchorSide::Top, 0.25), (PortAnchorSide::Bottom, 0.75)]
.into_iter()
.enumerate()
{
project
.diagrams
.get_mut(&diagram)
.unwrap()
.nodes
.get_mut(&owners[index])
.unwrap()
.port_anchors
.push(PortAnchor {
port: ports[index],
side,
offset,
});
}
validate_candidate(&project).unwrap();
let moved_view = project_diagram(&project, &project.diagrams[&diagram]);
let moved = route_diagram(&project, &project.diagrams[&diagram], &moved_view);
for index in 0..2 {
let marker = moved_view
.ports
.iter()
.find(|port| port.element == ports[index] && port.owner == owners[index])
.unwrap();
let points = &moved.edges[&trace.id].points;
let tip = if index == 0 {
points[0]
} else {
*points.last().unwrap()
};
assert_eq!(tip, marker.position);
let old = &initial.edges[&trace.id].points;
assert_ne!(
tip,
if index == 0 {
old[0]
} else {
*old.last().unwrap()
}
);
}
project
.diagrams
.get_mut(&diagram)
.unwrap()
.routes
.push(EdgeRoute {
edge: trace.id,
waypoints: vec![],
label: None,
source_attachment: Some(EdgeAttachment {
side: PortAnchorSide::Bottom,
offset: 0.9,
}),
target_attachment: Some(EdgeAttachment {
side: PortAnchorSide::Top,
offset: 0.1,
}),
});
validate_candidate(&project).unwrap();
let dormant_view = project_diagram(&project, &project.diagrams[&diagram]);
assert_eq!(dormant_view, moved_view);
assert_eq!(
route_diagram(&project, &project.diagrams[&diagram], &dormant_view),
moved
);
assert_eq!(project.elements, semantic_elements);
assert_eq!(project.relations, semantic_relations);
assert_eq!(project.traces, semantic_traces);
assert_eq!(project.traces[&trace.id], trace);
}
-154
View File
@@ -1,154 +0,0 @@
//! Route the exact native acceptance fixture and intended saved checkpoints.
use oa_core::{projection::project_diagram, routing::route_diagram, *};
const FIXTURE: &str =
include_str!("../../../harness/experiments/edge-attachments-20260922/native-fixture.arcadia");
fn id(project: &Project, alias: &str) -> Id {
project
.identities()
.into_iter()
.find(|(_, name)| *name == alias)
.unwrap()
.0
}
fn set(
project: &mut Project,
view: &str,
edge: &str,
end: EdgeEnd,
attachment: Option<(PortAnchorSide, f32)>,
) {
let view = id(project, view);
let edge = id(project, edge);
let route = project
.diagrams
.get_mut(&view)
.unwrap()
.routes
.iter_mut()
.find(|route| route.edge == edge)
.unwrap();
route.set_attachment(
end,
attachment.map(|(side, offset)| EdgeAttachment { side, offset }),
);
}
fn clear(project: &Project, checkpoint: &str) {
validate_candidate(project).unwrap();
for (view, edges) in [
("dispatch_view", &["dispatch_request"][..]),
("readiness_view", &["activate", "initialize", "finish"][..]),
] {
let diagram = &project.diagrams[&id(project, view)];
let routed = route_diagram(project, diagram, &project_diagram(project, diagram));
for alias in edges {
let edge = &routed.edges[&id(project, alias)];
assert!(
edge.warnings.is_empty(),
"{checkpoint}/{view}/{alias}: {:?}",
edge.warnings
);
let stored = diagram
.routes
.iter()
.find(|r| r.edge == id(project, alias))
.unwrap();
assert_eq!(
edge.pins, stored.waypoints,
"{checkpoint}/{alias}: manual pins retained"
);
}
}
}
#[test]
fn native_workshop_baseline_and_planned_attachments_have_clear_routes() {
use EdgeEnd::{Source, Target};
use PortAnchorSide::{Bottom, Top};
let mut project = language::parse(FIXTURE).unwrap();
let baseline = project.clone();
clear(&project, "B");
set(
&mut project,
"dispatch_view",
"dispatch_request",
Source,
Some((Top, 0.25)),
);
clear(&project, "O1");
set(
&mut project,
"dispatch_view",
"dispatch_request",
Target,
Some((Bottom, 0.75)),
);
clear(&project, "O2");
set(
&mut project,
"dispatch_view",
"dispatch_request",
Source,
None,
);
clear(&project, "O3");
set(
&mut project,
"readiness_view",
"activate",
Source,
Some((Top, 0.25)),
);
clear(&project, "S1");
set(
&mut project,
"readiness_view",
"activate",
Target,
Some((Bottom, 0.75)),
);
clear(&project, "S2");
set(&mut project, "readiness_view", "activate", Target, None);
clear(&project, "S3");
set(
&mut project,
"readiness_view",
"initialize",
Source,
Some((Top, 0.5)),
);
clear(&project, "S4");
set(
&mut project,
"readiness_view",
"finish",
Source,
Some((Top, 0.5)),
);
clear(&project, "S5");
assert_eq!(project.elements, baseline.elements);
assert_eq!(project.relations, baseline.relations);
for alias in ["dispatch_reference", "readiness_reference", "ports_view"] {
let view = id(&project, alias);
assert_eq!(project.diagrams[&view], baseline.diagrams[&view]);
}
for alias in ["dispatch_view", "readiness_view"] {
let view = id(&project, alias);
assert_eq!(
project.diagrams[&view].nodes,
baseline.diagrams[&view].nodes
);
for route in &project.diagrams[&view].routes {
let original = baseline.diagrams[&view]
.routes
.iter()
.find(|r| r.edge == route.edge)
.unwrap();
assert_eq!(route.waypoints, original.waypoints);
assert_eq!(route.label, original.label);
}
}
}
-290
View File
@@ -1,290 +0,0 @@
use oa_core::{language, persistence, source_project::*, *};
use serde_json::{Value, json};
use std::collections::BTreeMap;
const SOURCE: &str = r#"arcadia 7;
project attachments "Connection ends" id "70000000-0000-4000-8000-000000000001" {
oa_activity first "First" id "70000000-0000-4000-8000-000000000002" {}
oa_activity second "Second" id "70000000-0000-4000-8000-000000000003" {}
interaction signal "Signal" id "70000000-0000-4000-8000-000000000004" { source @first; target @second; }
diagram view "View" id "70000000-0000-4000-8000-000000000005" {
routes [{ edge @signal; waypoints [{ x 120; y 80; }];
label { fraction 0.5; offset { x 3; y -7; }; };
source_attachment { side Left; offset 0.25; };
target_attachment { side Bottom; offset 1; };
}];
}
diagram other "Other" id "70000000-0000-4000-8000-000000000006" { routes [{ edge @signal; }]; }
}
"#;
fn id(project: &Project, alias: &str) -> Id {
project
.identities()
.into_iter()
.find(|(_, a)| *a == alias)
.unwrap()
.0
}
fn original() -> Project {
language::parse(SOURCE).unwrap()
}
fn set(diagram: Id, route: EdgeRoute) -> Command {
Command::SetEdgeRoute { diagram, route }
}
#[test]
fn edge_attachments_roundtrip_source_json_bundle_and_automatic_defaults() {
let project = original();
let view = id(&project, "view");
let route = &project.diagrams[&view].routes[0];
assert_eq!(
route.source_attachment,
Some(EdgeAttachment {
side: PortAnchorSide::Left,
offset: 0.25
})
);
assert_eq!(
route.target_attachment,
Some(EdgeAttachment {
side: PortAnchorSide::Bottom,
offset: 1.0
})
);
for text in [SOURCE.to_owned(), language::format(&project)] {
assert_eq!(language::parse(&text).unwrap(), project);
}
assert_eq!(
persistence::decode(&serde_json::to_vec(&project).unwrap()).unwrap(),
project
);
assert_eq!(
compile_bundle(&format_bundle(&project), ProjectLimits::default())
.unwrap()
.decoded
.project,
project
);
let automatic = &project.diagrams[&id(&project, "other")].routes[0];
assert_eq!(automatic.source_attachment, None);
assert_eq!(automatic.target_attachment, None);
let encoded = serde_json::to_value(automatic).unwrap();
assert!(encoded.get("source_attachment").is_none());
assert!(encoded.get("target_attachment").is_none());
let mut with_null = encoded.clone();
with_null["source_attachment"] = Value::Null;
with_null["target_attachment"] = Value::Null;
assert_eq!(
serde_json::from_value::<EdgeRoute>(with_null).unwrap(),
*automatic
);
assert_eq!(
serde_json::from_value::<EdgeRoute>(encoded).unwrap(),
*automatic
);
// Old source without either new field still means automatic presentation.
let old = SOURCE
.replace(" source_attachment { side Left; offset 0.25; };\n", "")
.replace(" target_attachment { side Bottom; offset 1; };\n", "");
let old = language::parse(&old).unwrap();
assert!(
old.diagrams
.values()
.flat_map(|d| &d.routes)
.all(|r| r.source_attachment.is_none() && r.target_attachment.is_none())
);
assert!(!language::format(&old).contains("_attachment"));
assert_eq!(
persistence::decode(&serde_json::to_vec(&old).unwrap()).unwrap(),
old
);
}
#[test]
fn edge_attachments_reject_malformed_nested_source_and_json_without_defaults() {
let project = original();
let edge = id(&project, "signal");
for bad in [
json!({}),
json!({"side":"Top"}),
json!({"offset":0.5}),
json!({"side":"Diagonal","offset":0.5}),
json!({"side":"Top","offset":null}),
json!({"side":"Top","offset":0.5,"unknown":true}),
json!({"side":"Top","offset":"NaN"}),
] {
for field in ["source_attachment", "target_attachment"] {
let mut route = json!({"edge":edge});
route[field] = bad.clone();
assert!(
serde_json::from_value::<EdgeRoute>(route).is_err(),
"{field}: {bad}"
);
}
}
for bad in [
"{}",
"{ side Top; }",
"{ offset 0.5; }",
"{ side Diagonal; offset 0.5; }",
"{ side Top; offset null; }",
"{ side Top; offset 0.5; unknown true; }",
"{ side Top; offset -0.01; }",
"{ side Top; offset 1.01; }",
"{ side Top; offset NaN; }",
"{ side Top; offset 1e999; }",
] {
let source = SOURCE.replace("{ side Left; offset 0.25; }", bad);
assert!(language::parse(&source).is_err(), "{bad}");
let bundle = SourceBundle {
entry: "p.arcadia".into(),
files: BTreeMap::from([("p.arcadia".into(), source)]),
};
assert!(
compile_bundle(&bundle, ProjectLimits::default()).is_err(),
"{bad}"
);
}
for version in [1, 2] {
let legacy =
format!("arcadia {version}; project p \"P\" {{ diagram v \"V\" {{ routes []; }} }}");
assert!(
language::parse(&legacy).is_err(),
"legacy route extension must not be silently admitted"
);
}
}
#[test]
fn edge_attachments_nonfinite_and_out_of_range_commands_reject_atomically() {
let project = original();
let view = id(&project, "view");
let first = id(&project, "first");
for offset in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, -0.01, 1.01] {
for end in [EdgeEnd::Source, EdgeEnd::Target] {
let mut route = project.diagrams[&view].routes[0].clone();
route.set_attachment(
end,
Some(EdgeAttachment {
side: PortAnchorSide::Top,
offset,
}),
);
let mut session = Session::new(project.clone());
assert!(
session
.execute(Command::Batch(vec![
Command::Rename(first, "Must roll back".into()),
set(view, route)
]))
.is_err()
);
assert_eq!(session.project(), &project);
assert!(!session.can_undo() && !session.can_redo());
}
}
for offset in [-0.01, 1.01] {
let mut value = serde_json::to_value(&project).unwrap();
value["diagrams"][view.to_string()]["routes"][0]["source_attachment"]["offset"] =
json!(offset);
assert!(persistence::decode(&serde_json::to_vec(&value).unwrap()).is_err());
}
}
#[test]
fn edge_attachments_reset_one_end_and_all_routing_have_exact_history_and_view_scope() {
let project = original();
let view = id(&project, "view");
let edge = id(&project, "signal");
let mut session = Session::new(project.clone());
let mut route = project.diagrams[&view].routes[0].clone();
route.source_attachment = Some(EdgeAttachment {
side: PortAnchorSide::Right,
offset: 0.75,
});
let command = set(view, route.clone());
assert_eq!(
serde_json::from_value::<Command>(serde_json::to_value(&command).unwrap()).unwrap(),
command
);
session.execute(command).unwrap();
let mut expected = project.clone();
expected.diagrams.get_mut(&view).unwrap().routes[0] = route.clone();
assert_eq!(
session.project(),
&expected,
"only this view/end changes; semantic fields and other view stay exact"
);
session.undo();
assert_eq!(session.project(), &project);
assert!(!session.can_undo() && session.can_redo());
session.redo();
assert_eq!(session.project(), &expected);
route.source_attachment = None;
session.execute(set(view, route.clone())).unwrap();
let mut reset = expected.clone();
reset.diagrams.get_mut(&view).unwrap().routes[0] = route;
assert_eq!(
session.project(),
&reset,
"opposite attachment, pins and label survive endpoint reset"
);
session.undo();
assert_eq!(session.project(), &expected);
session
.execute(Command::RemoveEdgeRoute {
diagram: view,
edge,
})
.unwrap();
let mut removed = expected.clone();
removed.diagrams.get_mut(&view).unwrap().routes.clear();
assert_eq!(session.project(), &removed);
session.undo();
assert_eq!(session.project(), &expected);
}
#[test]
fn edge_attachments_nested_writeback_preserves_comments_adjacent_tokens_and_other_files() {
let current = compile_bundle(&SourceBundle {
entry: "p.arcadia".into(),
files: BTreeMap::from([
("p.arcadia".into(), "// project rationale\narcadia 7; project p \"P\" { include \"model.arcadia\"; include \"views.arcadia\"; }\n".into()),
("model.arcadia".into(), "// untouched semantic ownership\noa_activity a \"A\" {}\noa_activity b \"B\" {}\ninteraction signal \"Signal\" { source @a; target @b; }\n".into()),
("views.arcadia".into(), "// view rationale\r\ndiagram view \"View\" {\r\n routes [ { edge @signal; // endpoint explanation\r\n source_attachment { side Left; offset 0.25 ; // exact nested rationale\r\n }; target_attachment { side Bottom; offset 1; };\r\n waypoints [{ x 120; y 80; }]; label { fraction 0.5; offset { x 3; y -7; }; }; } ];\r\n}\r\ndiagram other \"Other\" { routes [{ edge @signal; }]; } // adjacent view\r\n".into()),
]),
}, ProjectLimits::default()).unwrap();
let mut project = current.decoded.project.clone();
let view = id(&project, "view");
project.diagrams.get_mut(&view).unwrap().routes[0]
.source_attachment
.as_mut()
.unwrap()
.offset = 0.375;
let changed = update_bundle(&current, &project, ProjectLimits::default()).unwrap();
assert_eq!(changed.decoded.project, project);
for path in ["p.arcadia", "model.arcadia"] {
assert_eq!(changed.bundle.files[path], current.bundle.files[path]);
}
assert_eq!(
changed.bundle.files["views.arcadia"],
current.bundle.files["views.arcadia"].replace("offset 0.25 ;", "offset 0.375 ;")
);
assert_eq!(
compile_bundle(&changed.bundle, ProjectLimits::default())
.unwrap()
.bundle,
changed.bundle
);
assert_eq!(
update_bundle(&changed, &project, ProjectLimits::default())
.unwrap()
.bundle,
changed.bundle
);
}
-427
View File
@@ -1,427 +0,0 @@
use oa_core::{diagram_edit::translate_placements, source_project::*, *};
use std::collections::BTreeSet;
fn point(x: f32, y: f32) -> Position {
Position { x, y }
}
fn route(edge: Id, x: f32, y: f32) -> EdgeRoute {
EdgeRoute {
edge,
source_attachment: Some(EdgeAttachment {
side: PortAnchorSide::Bottom,
offset: 0.2,
}),
target_attachment: Some(EdgeAttachment {
side: PortAnchorSide::Top,
offset: 0.8,
}),
waypoints: vec![point(x, y)],
label: Some(EdgeLabel {
fraction: 0.4,
offset: point(3., -9.),
}),
}
}
struct Fixture {
project: Project,
diagram: Id,
a: Id,
b: Id,
child: Id,
foreign: Id,
}
fn fixture() -> Fixture {
let mut p = Project::empty();
let a = p.add_element(Kind::Activity, Layer::Oa, "A", None).unwrap();
let b = p.add_element(Kind::Activity, Layer::Oa, "B", None).unwrap();
let c = p.add_element(Kind::Activity, Layer::Oa, "C", None).unwrap();
let child = p
.add_element(Kind::Activity, Layer::Oa, "Child", Some(a))
.unwrap();
let internal = p.relate(RelationKind::Interaction, "Internal", a, b);
let external = p.relate(RelationKind::Interaction, "External", b, c);
let d = p.ensure_diagram(Layer::Oa);
let na = p.place(d, a, point(10., 20.));
let nb = p.place(d, b, point(400., 20.));
let nc = p.place(d, child, point(30., 80.));
p.place(d, c, point(800., 20.));
p.diagrams.get_mut(&d).unwrap().routes =
vec![route(internal, 320., 150.), route(external, 720., 170.)];
p.diagrams
.get_mut(&d)
.unwrap()
.nodes
.get_mut(&na)
.unwrap()
.collapsed = true;
let other = Id::new_v4();
let mut other_view = p.diagrams[&d].clone();
other_view.id = other;
other_view.alias = "other_view".into();
other_view.nodes.clear();
other_view.routes.clear();
p.diagrams.insert(other, other_view);
let foreign = p.place(other, a, point(-40., -50.));
validate_candidate(&p).unwrap();
Fixture {
project: p,
diagram: d,
a: na,
b: nb,
child: nc,
foreign,
}
}
#[test]
fn simultaneous_siblings_translate_internal_pins_once_and_preserve_complete_project() {
let f = fixture();
let before = f.project.clone();
let command = translate_placements(
&before,
f.diagram,
&BTreeSet::from([f.a, f.b]),
point(40., 60.),
)
.unwrap();
assert_eq!(
before, f.project,
"command construction must not mutate input"
);
// Independently enumerated expected edits, without the projection helpers.
let mut expected = before.clone();
let view = expected.diagrams.get_mut(&f.diagram).unwrap();
view.nodes.get_mut(&f.a).unwrap().position = point(50., 80.);
view.nodes.get_mut(&f.b).unwrap().position = point(440., 80.);
view.nodes.get_mut(&f.child).unwrap().position = point(70., 140.);
view.routes[0].waypoints = vec![point(360., 210.)];
let mut session = Session::new(before.clone());
session.execute(command.clone()).unwrap();
assert_eq!(session.project(), &expected);
session.undo();
assert_eq!(session.project(), &before);
assert!(
!session.can_undo(),
"one movement must be one history entry"
);
session.redo();
assert_eq!(session.project(), &expected);
assert!(!session.can_redo());
// Independent one-node moves cannot see the shared endpoint displacement.
let mut sequential = Session::new(before.clone());
for (node, position) in [(f.a, point(50., 80.)), (f.b, point(440., 80.))] {
sequential
.execute(Command::MoveNode {
diagram: f.diagram,
node,
position,
with_descendants: true,
})
.unwrap();
}
assert_eq!(
sequential.project().diagrams[&f.diagram].routes[0],
before.diagrams[&f.diagram].routes[0]
);
assert_ne!(sequential.project(), &expected);
let json = serde_json::to_string(&expected).unwrap();
assert_eq!(serde_json::from_str::<Project>(&json).unwrap(), expected);
let mut bundle = format_bundle(&before);
bundle
.files
.get_mut(&bundle.entry.clone())
.unwrap()
.insert_str(0, "// preserve movement rationale\n");
let original = compile_bundle(&bundle, ProjectLimits::default()).unwrap();
let prepared = prepare_command(&before, &command).unwrap();
let updated = update_bundle_with_identity_map(
&original,
&prepared.project,
&prepared.identity_map,
ProjectLimits::default(),
)
.unwrap();
assert_eq!(updated.decoded.project, expected);
assert!(updated.bundle.files[&bundle.entry].starts_with("// preserve movement rationale\n"));
let reread = compile_bundle(&updated.bundle, ProjectLimits::default()).unwrap();
assert_eq!(reread.decoded.project, expected);
assert_eq!(reread.bundle, updated.bundle);
}
#[test]
fn selecting_folded_ancestor_and_hidden_descendant_moves_each_once() {
let f = fixture();
assert!(
!projection::project_view(&f.project, f.diagram)
.nodes
.iter()
.any(|n| n.id == f.child)
);
let ancestor = translate_placements(
&f.project,
f.diagram,
&BTreeSet::from([f.a]),
point(-15., 25.),
)
.unwrap();
let overlap = translate_placements(
&f.project,
f.diagram,
&BTreeSet::from([f.a, f.child]),
point(-15., 25.),
)
.unwrap();
assert_eq!(overlap, ancestor);
let after = prepare_command(&f.project, &overlap).unwrap().project;
assert_eq!(
after.diagrams[&f.diagram].nodes[&f.child].position,
point(15., 105.)
);
assert_eq!(
after.diagrams[&f.diagram].routes,
f.project.diagrams[&f.diagram].routes
);
}
#[test]
fn invalid_movements_refuse_atomically_and_zero_has_no_history() {
let f = fixture();
let selected = BTreeSet::from([f.a, f.b]);
for (diagram, ids, delta) in [
(Id::new_v4(), selected.clone(), point(1., 1.)),
(f.diagram, BTreeSet::new(), point(1., 1.)),
(
f.diagram,
BTreeSet::from([f.a, Id::new_v4()]),
point(1., 1.),
),
(f.diagram, BTreeSet::from([f.a, f.foreign]), point(0., 0.)),
(f.diagram, selected.clone(), point(f32::NAN, 1.)),
(f.diagram, selected.clone(), point(1., f32::INFINITY)),
(f.diagram, selected.clone(), point(MAX_COORDINATE, 0.)),
(f.diagram, selected.clone(), point(f32::MAX, f32::MAX)),
] {
let before = f.project.clone();
assert!(translate_placements(&f.project, diagram, &ids, delta).is_err());
assert_eq!(f.project, before);
}
let mut bad_route = f.project.clone();
bad_route.diagrams.get_mut(&f.diagram).unwrap().routes[0].waypoints =
vec![point(MAX_COORDINATE, 0.)];
validate_candidate(&bad_route).unwrap();
let snapshot = bad_route.clone();
assert!(translate_placements(&bad_route, f.diagram, &selected, point(1., 0.)).is_err());
assert_eq!(bad_route, snapshot);
let zero = translate_placements(&f.project, f.diagram, &selected, point(0., -0.)).unwrap();
assert_eq!(zero, Command::Batch(vec![]));
let mut session = Session::new(f.project.clone());
session.execute(zero).unwrap();
assert_eq!(session.project(), &f.project);
assert!(!session.can_undo());
assert_eq!(session.revision(), 0);
}
#[test]
fn typed_component_port_endpoints_follow_their_owner_placements() {
let mut p = Project::empty();
let a = p
.add_element(Kind::System, Layer::Sa, "System", None)
.unwrap();
let b = p
.add_element(Kind::SystemActor, Layer::Sa, "Actor", None)
.unwrap();
let output = Id::new_v4();
let input = Id::new_v4();
for (id, owner, name, orientation) in [
(output, a, "Output", ComponentPortOrientation::Out),
(input, b, "Input", ComponentPortOrientation::In),
] {
let port = make_port(
&p,
id,
owner,
name,
PortContract::Component {
kind: ComponentPortKind::Flow,
orientation,
},
)
.unwrap();
p.elements.insert(id, port);
}
let edge = p.relate(RelationKind::BoundaryExchange, "Boundary", output, input);
let d = p.ensure_diagram(Layer::Sa);
let na = p.place(d, a, point(0., 0.));
let nb = p.place(d, b, point(400., 0.));
for (node, port, side, offset) in [
(na, output, PortAnchorSide::Bottom, 0.3),
(nb, input, PortAnchorSide::Top, 0.7),
] {
p.diagrams
.get_mut(&d)
.unwrap()
.nodes
.get_mut(&node)
.unwrap()
.port_anchors
.push(PortAnchor { port, side, offset });
}
p.diagrams
.get_mut(&d)
.unwrap()
.routes
.push(route(edge, 300., 100.));
validate_candidate(&p).unwrap();
let command = translate_placements(&p, d, &BTreeSet::from([na, nb]), point(10., -20.)).unwrap();
let after = prepare_command(&p, &command).unwrap().project;
let mut expected = p.clone();
let view = expected.diagrams.get_mut(&d).unwrap();
view.nodes.get_mut(&na).unwrap().position = point(10., -20.);
view.nodes.get_mut(&nb).unwrap().position = point(410., -20.);
view.routes[0].waypoints = vec![point(310., 80.)];
assert_eq!(after, expected);
}
#[test]
fn allocation_movement_follows_visual_performers_and_same_performer_work_groups() {
let mut p = Project::empty();
let performer_a = p
.add_element(Kind::Entity, Layer::Oa, "Performer A", None)
.unwrap();
let performer_b = p
.add_element(Kind::Entity, Layer::Oa, "Performer B", None)
.unwrap();
let mixed = p
.add_element(Kind::Activity, Layer::Oa, "Mixed work", None)
.unwrap();
let branch = p
.add_element(Kind::Activity, Layer::Oa, "A work", Some(mixed))
.unwrap();
let leaf_a = p
.add_element(Kind::Activity, Layer::Oa, "A leaf", Some(branch))
.unwrap();
let leaf_b = p
.add_element(Kind::Activity, Layer::Oa, "B leaf", Some(mixed))
.unwrap();
p.relate(RelationKind::Allocation, "A performs", performer_a, leaf_a);
p.relate(RelationKind::Allocation, "B performs", performer_b, leaf_b);
let d = p.ensure_diagram(Layer::Oa);
p.diagrams.get_mut(&d).unwrap().projection = ProjectionKind::Allocation;
let na = p.place(d, performer_a, point(0., 0.));
p.place(d, performer_b, point(600., 0.));
let nm = p.place(d, mixed, point(1000., 0.));
let branch_node = p.place(d, branch, point(20., 80.));
let leaf_node = p.place(d, leaf_a, point(40., 160.));
p.place(d, leaf_b, point(620., 80.));
validate_candidate(&p).unwrap();
// Mixed work retains semantic ownership of both branches, but its leaves
// have different performers. A work is visually nested under Performer A,
// with its own same-performer leaf nested inside it.
let command =
translate_placements(&p, d, &BTreeSet::from([na, branch_node]), point(15., -25.)).unwrap();
let after = prepare_command(&p, &command).unwrap().project;
let mut expected = p.clone();
let view = expected.diagrams.get_mut(&d).unwrap();
view.nodes.get_mut(&na).unwrap().position = point(15., -25.);
view.nodes.get_mut(&branch_node).unwrap().position = point(35., 55.);
view.nodes.get_mut(&leaf_node).unwrap().position = point(55., 135.);
assert_eq!(after, expected);
// Moving the semantic parent does not move children displayed elsewhere.
let command = translate_placements(&p, d, &BTreeSet::from([nm]), point(15., -25.)).unwrap();
let after = prepare_command(&p, &command).unwrap().project;
let mut expected = p.clone();
expected
.diagrams
.get_mut(&d)
.unwrap()
.nodes
.get_mut(&nm)
.unwrap()
.position = point(1015., -25.);
assert_eq!(after, expected);
}
#[test]
fn traceability_movement_is_flat_even_for_a_semantic_parent() {
let mut f = fixture();
f.project.diagrams.get_mut(&f.diagram).unwrap().projection = ProjectionKind::Traceability;
validate_candidate(&f.project).unwrap();
let command = translate_placements(
&f.project,
f.diagram,
&BTreeSet::from([f.a]),
point(40., 60.),
)
.unwrap();
let after = prepare_command(&f.project, &command).unwrap().project;
let mut expected = f.project.clone();
expected
.diagrams
.get_mut(&f.diagram)
.unwrap()
.nodes
.get_mut(&f.a)
.unwrap()
.position = point(50., 80.);
assert_eq!(
after, expected,
"flat view movement must preserve semantic descendants, routes, other views and all semantics"
);
}
#[test]
fn state_entry_pins_follow_the_boundary_only_when_it_also_moves() {
let mut p = Project::empty();
let machine = p
.add_element(Kind::StateMachine, Layer::Oa, "Machine", None)
.unwrap();
let region = p
.add_element(Kind::Region, Layer::Oa, "Region", Some(machine))
.unwrap();
let composite = p
.add_element(Kind::State, Layer::Oa, "Composite", Some(region))
.unwrap();
let inner = p
.add_element(Kind::Region, Layer::Oa, "Inner", Some(composite))
.unwrap();
let entry = p
.add_element(Kind::EntryPoint, Layer::Oa, "Entry", Some(inner))
.unwrap();
let leaf = p
.add_element(Kind::State, Layer::Oa, "Leaf", Some(inner))
.unwrap();
let edge = p.relate(RelationKind::StateTransition, "Enter", entry, leaf);
if let RelationBody::StateTransition {
transition_kind, ..
} = &mut p.relations.get_mut(&edge).unwrap().body
{
*transition_kind = TransitionKind::Local;
}
let d = p.ensure_diagram(Layer::Oa);
p.diagrams.get_mut(&d).unwrap().projection = ProjectionKind::StateMachine { machine };
let boundary = p.place(d, composite, point(0., 0.));
let ne = p.place(d, entry, point(20., 80.));
let nl = p.place(d, leaf, point(100., 80.));
p.diagrams
.get_mut(&d)
.unwrap()
.routes
.push(route(edge, 90., 100.));
validate_candidate(&p).unwrap();
let endpoints =
translate_placements(&p, d, &BTreeSet::from([ne, nl]), point(10., 20.)).unwrap();
let after = prepare_command(&p, &endpoints).unwrap().project;
assert_eq!(after.diagrams[&d].routes, p.diagrams[&d].routes);
let with_boundary =
translate_placements(&p, d, &BTreeSet::from([boundary, ne, nl]), point(10., 20.)).unwrap();
let after = prepare_command(&p, &with_boundary).unwrap().project;
assert_eq!(
after.diagrams[&d].routes[0].waypoints,
vec![point(100., 120.)]
);
assert_eq!(after.diagrams[&d].nodes[&ne].position, point(30., 100.));
}
-2
View File
@@ -441,8 +441,6 @@ fn nested_owner_bounds_group_movement_and_authored_routes_share_the_same_geometr
diagram: d,
route: EdgeRoute {
edge,
source_attachment: None,
target_attachment: None,
waypoints: vec![pos(500., 420.)],
label: None,
},
-4
View File
@@ -70,8 +70,6 @@ fn fixture() -> Fixture {
fn route(edge: Id) -> EdgeRoute {
EdgeRoute {
edge,
source_attachment: None,
target_attachment: None,
waypoints: vec![Position { x: 550., y: 240. }, Position { x: 550., y: 360. }],
label: Some(EdgeLabel {
fraction: 0.4,
@@ -691,8 +689,6 @@ fn old_models_get_empty_routes_and_route_json_is_strict() {
serde_json::from_value::<EdgeRoute>(minimal).unwrap(),
EdgeRoute {
edge: f.edge,
source_attachment: None,
target_attachment: None,
waypoints: vec![],
label: None
}
-2
View File
@@ -27,8 +27,6 @@ fn route(p: &Project, d: Id) -> RoutedView {
fn set_route(p: &mut Project, d: Id, edge: Id, pins: Vec<Position>, label: Option<EdgeLabel>) {
p.diagrams.get_mut(&d).unwrap().routes.push(EdgeRoute {
edge,
source_attachment: None,
target_attachment: None,
waypoints: pins,
label,
});
-2
View File
@@ -117,8 +117,6 @@ fn authored_overlap_is_exact_and_reset_only_repositions_automatic_text() {
.routes
.push(EdgeRoute {
edge: fe,
source_attachment: None,
target_attachment: None,
waypoints: vec![],
label: Some(authored.clone()),
});
-16
View File
@@ -301,8 +301,6 @@ fn folding_retains_authored_routes_without_false_self_transitions() {
let pins = vec![point(690., 480.), point(690., 230.)];
f.p.diagrams.get_mut(&f.d).unwrap().routes.push(EdgeRoute {
edge: out,
source_attachment: None,
target_attachment: None,
waypoints: pins.clone(),
label: None,
});
@@ -364,15 +362,11 @@ fn grouped_moves_translate_hidden_descendants_and_pins_only_in_one_view() {
let pins = vec![point(570., 460.)];
f.p.diagrams.get_mut(&f.d).unwrap().routes.push(EdgeRoute {
edge: f.local,
source_attachment: None,
target_attachment: None,
waypoints: pins.clone(),
label: Some(label.clone()),
});
f.p.diagrams.get_mut(&f.d).unwrap().routes.push(EdgeRoute {
edge: f.external,
source_attachment: None,
target_attachment: None,
waypoints: vec![point(690., 230.)],
label: None,
});
@@ -428,8 +422,6 @@ fn cross_machine_reparent_requires_atomic_placement_and_route_repair() {
let placement = f.p.place(f.d, orphan, point(30., 750.));
f.p.diagrams.get_mut(&f.d).unwrap().routes.push(EdgeRoute {
edge,
source_attachment: None,
target_attachment: None,
waypoints: vec![],
label: None,
});
@@ -464,8 +456,6 @@ fn cross_machine_reparent_requires_atomic_placement_and_route_repair() {
diagram: other,
route: EdgeRoute {
edge,
source_attachment: None,
target_attachment: None,
waypoints: vec![],
label: None,
},
@@ -593,8 +583,6 @@ fn layout_reserves_internal_rows_and_state_routes_retain_hard_pins() {
let pins = vec![point(730., 130.), point(1060., 130.)];
f.p.diagrams.get_mut(&f.d).unwrap().routes.push(EdgeRoute {
edge: f.self_edge,
source_attachment: None,
target_attachment: None,
waypoints: pins.clone(),
label: Some(EdgeLabel {
fraction: 0.25,
@@ -664,8 +652,6 @@ fn moving_a_region_does_not_translate_pins_attached_to_a_stationary_state_bounda
let pins = vec![point(280., 390.), point(280., 470.)];
f.p.diagrams.get_mut(&f.d).unwrap().routes.push(EdgeRoute {
edge: f.local,
source_attachment: None,
target_attachment: None,
waypoints: pins.clone(),
label: None,
});
@@ -744,8 +730,6 @@ fn initial_transition_attaches_to_the_compact_symbol_and_preview_keeps_settled_g
);
f.p.diagrams.get_mut(&f.d).unwrap().routes.push(EdgeRoute {
edge: f.self_edge,
source_attachment: None,
target_attachment: None,
waypoints: vec![],
label: Some(EdgeLabel {
fraction: 0.3,
@@ -0,0 +1,353 @@
//! Existing counterpart eligibility is a layer/category/kind decision, not
//! validation or acceptance of an entire transition plan. The catalog fixtures
//! below intentionally contain partial records (unowned states, etc.). The
//! final regression separately starts from a structurally valid real project.
use oa_core::{transitions::*, *};
use std::collections::{BTreeMap, BTreeSet};
fn element(p: &mut Project, layer: Layer, kind: Kind) -> Id {
let id = Id::new_v4();
let body = if kind == Kind::Port {
// Legacy ports can exist in preserved OA models. This does not offer
// new typed SA implementation ports for creation in portless OA.
ElementBody::shared_in(
layer,
SharedBody::Port {
contract: PortContract::Legacy,
legacy: None,
},
)
} else {
ElementBody::new(kind, layer).unwrap()
};
p.elements.insert(
id,
Element {
id,
alias: format!("candidate_{}", id.simple()),
name: "Identical candidate label".into(),
description: String::new(),
parent: None,
body,
},
);
id
}
#[test]
fn existing_element_candidates_obey_explicit_engineering_families_in_both_directions() {
// Contract: language.rst §5 trace families and the explicit capability /
// mission alternative. State constructors remain eligible for explicit
// review across the state family; selecting a candidate does not accept a
// constructor change or make invalid ownership legal.
let mut allowed: BTreeSet<(Kind, Kind)> = [
(Kind::Activity, Kind::Function),
(Kind::Entity, Kind::System),
(Kind::Entity, Kind::SystemActor),
(Kind::Actor, Kind::System),
(Kind::Actor, Kind::SystemActor),
(Kind::Capability, Kind::SystemCapability),
(Kind::OperationalMission, Kind::Mission),
(Kind::Capability, Kind::Mission),
(Kind::Process, Kind::FunctionalChain),
]
.into_iter()
.collect();
for kind in [
Kind::ExchangeItem,
Kind::ExchangeCategory,
Kind::DataType,
Kind::DataField,
Kind::Interface,
Kind::Port,
Kind::Scenario,
Kind::Lifeline,
Kind::Occurrence,
Kind::StateMachine,
Kind::Region,
Kind::Event,
Kind::Constraint,
] {
allowed.insert((kind, kind));
}
let vertices = [
Kind::State,
Kind::Mode,
Kind::Initial,
Kind::Final,
Kind::Choice,
Kind::Junction,
Kind::Fork,
Kind::Join,
Kind::ShallowHistory,
Kind::DeepHistory,
Kind::EntryPoint,
Kind::ExitPoint,
Kind::Terminate,
];
for oa in vertices {
for sa in vertices {
allowed.insert((oa, sa));
}
}
let mut p = Project::empty();
let oa: BTreeMap<_, _> = Kind::ALL
.into_iter()
.filter(|kind| kind.supports(Layer::Oa))
.map(|kind| (kind, element(&mut p, Layer::Oa, kind)))
.collect();
let sa: BTreeMap<_, _> = Kind::ALL
.into_iter()
.filter(|kind| kind.supports(Layer::Sa))
.map(|kind| (kind, element(&mut p, Layer::Sa, kind)))
.collect();
let original = p.clone();
for (&oa_kind, &oa_id) in &oa {
for (&sa_kind, &sa_id) in &sa {
let expected = allowed.contains(&(oa_kind, sa_kind));
assert_eq!(
counterpart_compatible(&p, oa_id, sa_id, TransitionDirection::OaToSa),
expected,
"OA {oa_kind:?} → SA {sa_kind:?}"
);
assert_eq!(
counterpart_compatible(&p, sa_id, oa_id, TransitionDirection::SaToOa),
expected,
"SA {sa_kind:?} → OA {oa_kind:?}"
);
}
}
assert_eq!(
p, original,
"Eligibility must not establish correspondence or edit records"
);
}
fn relation(p: &mut Project, kind: RelationKind, anchor: Id) -> Id {
let id = Id::new_v4();
// Only the semantic layer and relation category participate in candidate
// eligibility. Endpoint compatibility belongs to full plan validation.
p.relations.insert(
id,
Relation {
id,
alias: format!("relation_{}", id.simple()),
name: "Identical relation label".into(),
description: String::new(),
body: RelationBody::new(kind, anchor, anchor),
},
);
id
}
#[test]
fn existing_relation_candidates_distinguish_work_boundary_and_shared_relations() {
// Work and boundary exchanges are different families. The boundary /
// boundary pair permits preserved legacy OA boundary records; ordinary
// portless OA communication means map to SA boundary exchanges.
let mut allowed = vec![
(RelationKind::Interaction, RelationKind::FunctionalExchange),
(
RelationKind::CommunicationMean,
RelationKind::BoundaryExchange,
),
(
RelationKind::BoundaryExchange,
RelationKind::BoundaryExchange,
),
];
let shared = [
RelationKind::Allocation,
RelationKind::InterfaceItemAllocation,
RelationKind::DataAssociation,
RelationKind::ExchangeAllocation,
RelationKind::CapabilityInvolvement,
RelationKind::CapabilityInclude,
RelationKind::CapabilityExtend,
RelationKind::CapabilityGeneralization,
RelationKind::MissionInvolvement,
RelationKind::MissionCapability,
RelationKind::SequenceLink,
RelationKind::ContextualLink,
RelationKind::ChainInvolvement,
RelationKind::InterfaceProvision,
RelationKind::InterfaceRequirement,
RelationKind::StateTransition,
RelationKind::Availability,
RelationKind::StateAction,
];
for kind in shared {
allowed.push((kind, kind));
}
let mut p = Project::empty();
let oa_anchor = element(&mut p, Layer::Oa, Kind::Activity);
let sa_anchor = element(&mut p, Layer::Sa, Kind::Function);
let oa: Vec<_> = shared
.into_iter()
.chain([
RelationKind::Interaction,
RelationKind::CommunicationMean,
RelationKind::BoundaryExchange,
RelationKind::RoleRealization,
RelationKind::RoleAssignment,
])
.map(|kind| (kind, relation(&mut p, kind, oa_anchor)))
.collect();
let sa: Vec<_> = shared
.into_iter()
.chain([
RelationKind::FunctionalExchange,
RelationKind::BoundaryExchange,
RelationKind::PortAllocation,
])
.map(|kind| (kind, relation(&mut p, kind, sa_anchor)))
.collect();
for &(oa_kind, oa_id) in &oa {
for &(sa_kind, sa_id) in &sa {
let expected = allowed.contains(&(oa_kind, sa_kind));
assert_eq!(
counterpart_compatible(&p, oa_id, sa_id, TransitionDirection::OaToSa),
expected,
"OA {oa_kind:?} → SA {sa_kind:?}"
);
assert_eq!(
counterpart_compatible(&p, sa_id, oa_id, TransitionDirection::SaToOa),
expected,
"SA {sa_kind:?} → OA {oa_kind:?}"
);
}
}
}
#[test]
fn candidate_eligibility_rejects_wrong_layers_categories_and_missing_identities() {
let mut p = Project::empty();
let oa = element(&mut p, Layer::Oa, Kind::Activity);
let sa = element(&mut p, Layer::Sa, Kind::Function);
let oa_exchange = relation(&mut p, RelationKind::Interaction, oa);
let sa_exchange = relation(&mut p, RelationKind::FunctionalExchange, sa);
let diagram = p.ensure_diagram(Layer::Sa);
let node = p.place(diagram, sa, Position { x: 1., y: 2. });
for (direction, source, target, source_relation, target_relation) in [
(
TransitionDirection::OaToSa,
oa,
sa,
oa_exchange,
sa_exchange,
),
(
TransitionDirection::SaToOa,
sa,
oa,
sa_exchange,
oa_exchange,
),
] {
assert!(counterpart_compatible(&p, source, target, direction));
assert!(counterpart_compatible(
&p,
source_relation,
target_relation,
direction
));
for (a, b) in [
(target, source),
(source, source),
(target, target),
(target_relation, source_relation),
(source_relation, source_relation),
(source, target_relation),
(source_relation, target),
] {
assert!(!counterpart_compatible(&p, a, b, direction));
}
for absent_or_nonsemantic in [Id::nil(), Id::new_v4(), p.id, diagram, node] {
assert!(!counterpart_compatible(
&p,
source,
absent_or_nonsemantic,
direction
));
assert!(!counterpart_compatible(
&p,
absent_or_nonsemantic,
target,
direction
));
}
}
p.elements.remove(&sa);
assert!(!counterpart_compatible(
&p,
oa,
sa,
TransitionDirection::OaToSa
));
assert!(!counterpart_compatible(
&p,
sa,
oa,
TransitionDirection::SaToOa
));
// The retained relation header cannot borrow a layer from a deleted owner.
assert!(!counterpart_compatible(
&p,
oa_exchange,
sa_exchange,
TransitionDirection::OaToSa
));
}
#[test]
fn activity_picker_rejects_real_sa_port_and_agrees_with_explicit_plan_refusal() {
let p = Project::demo();
let original = p.clone();
assert!(validation::validate(&p).is_empty());
let source = p
.elements
.values()
.find(|e| e.kind() == Kind::Activity)
.unwrap()
.id;
let port = p
.elements
.values()
.find(|e| e.layer() == Layer::Sa && e.kind() == Kind::Port)
.unwrap()
.id;
let function = p
.elements
.values()
.find(|e| e.kind() == Kind::Function)
.unwrap()
.id;
assert!(counterpart_compatible(
&p,
source,
function,
TransitionDirection::OaToSa
));
assert!(!counterpart_compatible(
&p,
source,
port,
TransitionDirection::OaToSa
));
let mut request = TransitionRequest::new(TransitionDirection::OaToSa, vec![source]);
request.mappings.push(TransitionMapping {
source,
target: Some(port),
target_kind: None,
});
let plan = plan_transition(&p, &request).unwrap();
assert!(
plan.conflicts
.iter()
.any(|conflict| conflict.source == Some(source)
&& conflict.target == Some(port)
&& conflict.field.is_none())
);
assert!(apply_transition(&p, &plan).is_err());
assert_eq!(p, original);
}
@@ -0,0 +1,628 @@
//! Endpoint lifecycle acceptance for the ordinary, prose-reviewed transition API.
//! OA is portless; the SA half contains typed ports. Deletion expectations below
//! are fixture-owned identity lists, never results of the deletion implementation.
use oa_core::{
language, persistence,
source_project::{ProjectLimits, SourceBundle, compile_bundle},
transitions::*,
validation::{self, Severity},
*,
};
use std::collections::BTreeMap;
struct Side {
root: Id,
elements: Vec<Id>,
exchange: Id,
diagram: Id,
nodes: Vec<Id>,
artifacts: Vec<Id>,
}
struct Fixture {
project: Project,
source: Side,
target: Side,
trace: Id,
trace_artifact: Id,
}
fn artifact(p: &mut Project, owner: Id, label: &str) -> Id {
let id = Id::new_v4();
p.artifact_links.insert(
id,
ArtifactLink {
id,
alias: p.fresh_alias(label, "evidence", id),
name: label.into(),
description: "Independently authored rationale".into(),
owner,
relation: ArtifactRelation::References,
uri: "git:engineering/design.md".into(),
revision: Some("review-17".into()),
fragment: Some("retained-decision".into()),
},
);
id
}
fn side(p: &mut Project, layer: Layer) -> Side {
let (kind, prefix, relation) = match layer {
Layer::Oa => (Kind::Activity, "Operational", RelationKind::Interaction),
Layer::Sa => (
Kind::Function,
"Technical",
RelationKind::FunctionalExchange,
),
};
let root = p
.add_element(kind, layer, &format!("{prefix} design"), None)
.unwrap();
p.elements.get_mut(&root).unwrap().description =
format!("{prefix} independently reviewed intent");
let send = p
.add_element(kind, layer, &format!("{prefix} send"), Some(root))
.unwrap();
let receive = p
.add_element(kind, layer, &format!("{prefix} receive"), Some(root))
.unwrap();
p.elements.get_mut(&send).unwrap().description =
"Preserve the detailed sending responsibility".into();
p.elements.get_mut(&receive).unwrap().description =
"Preserve the detailed receiving responsibility".into();
// A shared, separately owned definition survives deletion of its users.
let item = p
.add_element(
Kind::ExchangeItem,
layer,
&format!("{prefix} information"),
None,
)
.unwrap();
*p.elements.get_mut(&item).unwrap().shared_mut().unwrap() = SharedBody::ExchangeItem {
kind: ExchangeItemKind::Information,
mechanism: ExchangeMechanism::Flow,
supertype: None,
};
p.add_element(
Kind::DataField,
layer,
&format!("{prefix} payload"),
Some(item),
)
.unwrap();
let exchange = p.relate(relation, &format!("{prefix} delivery"), send, receive);
let mut elements = vec![root, send, receive];
match &mut p.relations.get_mut(&exchange).unwrap().body {
RelationBody::Interaction { items, .. } => *items = vec![item],
RelationBody::FunctionalExchange {
items,
source_port,
target_port,
..
} => {
*items = vec![item];
let (output, input) = (*source_port, *target_port);
*p.elements.get_mut(&output).unwrap().shared_mut().unwrap() = SharedBody::Port {
contract: PortContract::FunctionOutput {
outgoing_items: vec![item],
},
legacy: None,
};
*p.elements.get_mut(&input).unwrap().shared_mut().unwrap() = SharedBody::Port {
contract: PortContract::FunctionInput {
incoming_items: vec![item],
},
legacy: None,
};
elements.extend([output, input]);
}
_ => unreachable!(),
}
p.relations.get_mut(&exchange).unwrap().description =
"Retain direction, item contract and relation identity".into();
let diagram = p.ensure_diagram(layer);
p.diagrams.get_mut(&diagram).unwrap().projection = ProjectionKind::Decomposition;
let nodes = [root, send, receive]
.into_iter()
.enumerate()
.map(|(index, element)| {
p.place(
diagram,
element,
Position {
x: 43.25 + index as f32 * 271.,
y: 97.5 + index as f32 * 83.,
},
)
})
.collect::<Vec<_>>();
let root_node = p
.diagrams
.get_mut(&diagram)
.unwrap()
.nodes
.get_mut(&nodes[0])
.unwrap();
root_node.size = Size {
width: 611.,
height: 319.,
};
root_node.collapsed = true;
p.diagrams
.get_mut(&diagram)
.unwrap()
.routes
.push(EdgeRoute {
edge: exchange,
waypoints: vec![Position {
x: 337.5,
y: 423.25,
}],
label: Some(EdgeLabel {
fraction: 0.7,
offset: Position { x: 11., y: -17. },
}),
});
let artifacts = [root, send, exchange, nodes[0]]
.into_iter()
.enumerate()
.map(|(index, owner)| artifact(p, owner, &format!("{prefix} evidence {index}")))
.collect();
artifact(p, item, &format!("{prefix} shared item evidence"));
Side {
root,
elements,
exchange,
diagram,
nodes,
artifacts,
}
}
fn accepted(p: &Project) {
let issues = validation::validate(p);
// This fixture deliberately isolates decomposition and correspondence from
// performer allocation and process/chain/scenario inclusion. Each surviving
// leaf (including a newly transitioned root) must have exactly those two
// incomplete-work warnings; each surviving local exchange lacks a behavior
// context. Check exact record IDs and fields, including duplicate warnings.
// The system of interest and item contracts are complete fixture facts.
assert!(
issues
.iter()
.all(|issue| issue.severity == Severity::Warning),
"{issues:?}"
);
let mut expected = Vec::new();
for element in p.elements.values().filter(|element| {
matches!(element.kind(), Kind::Activity | Kind::Function)
&& !p
.elements
.values()
.any(|child| child.parent == Some(element.id) && child.kind() == element.kind())
}) {
expected.push(("unallocated_leaf", Some(element.id), Some("allocation")));
expected.push(("work_without_behavior_context", Some(element.id), None));
}
for relation in p.relations.values().filter(|relation| {
matches!(
relation.kind(),
RelationKind::Interaction | RelationKind::FunctionalExchange
)
}) {
expected.push(("exchange_without_behavior_context", Some(relation.id), None));
}
let mut actual = issues
.iter()
.map(|issue| (issue.code.as_str(), issue.element, issue.field.as_deref()))
.collect::<Vec<_>>();
expected.sort();
actual.sort();
assert_eq!(
actual, expected,
"Unexpected fixture diagnostics: {issues:?}"
);
}
fn round_trip(p: &Project) -> String {
accepted(p);
let json = serde_json::to_vec_pretty(p).unwrap();
assert_eq!(persistence::decode(&json).unwrap(), *p);
let source = language::format(p);
let compiled = compile_bundle(
&SourceBundle {
entry: "project.arcadia".into(),
files: BTreeMap::from([("project.arcadia".into(), source.clone())]),
},
ProjectLimits::default(),
)
.unwrap();
assert_eq!(compiled.decoded.project, *p);
assert_eq!(language::format(&compiled.decoded.project), source);
source
}
fn explicit(direction: TransitionDirection, source: Id, target: Id) -> TransitionRequest {
let mut request = TransitionRequest::new(direction, vec![source]);
request.mappings.push(TransitionMapping {
source,
target: Some(target),
target_kind: None,
});
request
}
fn fixture(direction: TransitionDirection) -> Fixture {
let mut project = Project::empty();
project.name = "Endpoint lifecycle conservation".into();
project
.add_element(Kind::System, Layer::Sa, "System of interest", None)
.unwrap();
let oa = side(&mut project, Layer::Oa);
let sa = side(&mut project, Layer::Sa);
let (source, target) = match direction {
TransitionDirection::OaToSa => (oa, sa),
TransitionDirection::SaToOa => (sa, oa),
};
accepted(&project);
let before = project.clone();
let mut request = explicit(direction, source.root, target.root);
for field in [TransitionField::Name, TransitionField::Description] {
request.resolutions.push(FieldResolution {
source: source.root,
field,
decision: FieldDecision::KeepTarget,
});
}
let plan = plan_transition(&project, &request).unwrap();
assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts);
project = apply_transition(&project, &plan).unwrap();
let trace = assert_only_new_trace(&before, &project, direction, source.root, target.root);
let mut without_trace = project.clone();
without_trace.traces.remove(&trace);
assert_eq!(
without_trace, before,
"Initial review must only add correspondence"
);
let trace_artifact = artifact(&mut project, trace, "Correspondence rationale");
round_trip(&project);
Fixture {
project,
source,
target,
trace,
trace_artifact,
}
}
fn assert_only_new_trace(
before: &Project,
after: &Project,
direction: TransitionDirection,
source: Id,
target: Id,
) -> Id {
let added = after
.traces
.values()
.filter(|trace| !before.traces.contains_key(&trace.id))
.collect::<Vec<_>>();
assert_eq!(added.len(), 1);
assert_eq!(after.traces.len(), before.traces.len() + 1);
let trace = added[0];
let (oa, sa) = match direction {
TransitionDirection::OaToSa => (source, target),
TransitionDirection::SaToOa => (target, source),
};
assert_eq!(
(trace.oa, trace.sa, trace.kind),
(oa, sa, TraceKind::Refinement)
);
assert_eq!(
trace.baseline,
Some(TraceBaseline {
oa: after.properties(oa).unwrap(),
sa: after.properties(sa).unwrap(),
semantic: None,
})
);
assert_eq!(trace_drift(after, trace), Drift::Unchanged);
trace.id
}
fn expected_deletion(fixture: &Fixture, side: &Side) -> Project {
let mut expected = fixture.project.clone();
for id in &side.elements {
assert!(expected.elements.remove(id).is_some());
}
assert!(expected.relations.remove(&side.exchange).is_some());
assert!(expected.traces.remove(&fixture.trace).is_some());
for id in side.artifacts.iter().chain([&fixture.trace_artifact]) {
assert!(expected.artifact_links.remove(id).is_some());
}
let diagram = expected.diagrams.get_mut(&side.diagram).unwrap();
for id in &side.nodes {
assert!(diagram.nodes.remove(id).is_some());
}
// This fixture authored exactly one route, for its removed local exchange.
assert_eq!(diagram.routes.len(), 1);
assert_eq!(diagram.routes[0].edge, side.exchange);
diagram.routes.clear();
expected
}
fn unchanged_retry(p: &Project, direction: TransitionDirection, source: Id, target: Id) {
let request = TransitionRequest::new(direction, vec![source]);
let plan = plan_transition(p, &request).unwrap();
assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts);
assert_eq!(plan.changes.len(), 1);
assert_eq!(
(
plan.changes[0].source,
plan.changes[0].target,
plan.changes[0].action
),
(source, target, TransitionAction::Reuse)
);
assert!(plan.changes[0].fields.is_empty());
assert!(plan.commands.is_empty(), "{:?}", plan.commands);
let mut session = Session::new(p.clone());
let revision = session.revision();
session
.execute(Command::Replace(apply_transition(p, &plan).unwrap()))
.unwrap();
assert_eq!(session.project(), p);
assert_eq!(session.revision(), revision);
assert!(!session.can_undo());
}
fn source_deletion(direction: TransitionDirection) {
let fixture = fixture(direction);
let original_source = round_trip(&fixture.project);
let expected = expected_deletion(&fixture, &fixture.source);
let mut session = Session::new(fixture.project.clone());
session
.execute(Command::Delete(fixture.source.root))
.unwrap();
// Whole-project equality covers every retained target child, typed SA port,
// exchange, placement, route, artifact, shared item and unchanged identity.
assert_eq!(session.project(), &expected);
let deleted_source = round_trip(session.project());
session.undo();
assert_eq!(session.project(), &fixture.project);
assert_eq!(round_trip(session.project()), original_source);
assert!(!session.can_undo(), "Deletion is one history transaction");
session.redo();
assert_eq!(session.project(), &expected);
assert_eq!(round_trip(session.project()), deleted_source);
}
fn implicit_creation(
p: &Project,
direction: TransitionDirection,
source: Id,
forbidden_target: Id,
) -> (Project, Id) {
let request = TransitionRequest::new(direction, vec![source]);
let plan = plan_transition(p, &request).unwrap();
assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts);
assert_eq!(plan.changes.len(), 1);
let change = &plan.changes[0];
assert_eq!(change.source, source);
assert_eq!(change.action, TransitionAction::Create);
let target = change.target;
assert_ne!(target, forbidden_target);
assert!(!p.contains(target));
let after = apply_transition(p, &plan).unwrap();
let trace = assert_only_new_trace(p, &after, direction, source, target);
let created = &after.elements[&target];
assert_eq!(created.id, target, "Apply preserves the preview identity");
assert_eq!(created.name, p.elements[&source].name);
assert_eq!(created.description, p.elements[&source].description);
assert_eq!(created.parent, None);
assert_eq!(
created.body,
match direction {
TransitionDirection::OaToSa => ElementBody::Sa(SaBody::Function),
TransitionDirection::SaToOa => ElementBody::Oa(OaBody::Activity),
}
);
assert!(!created.alias.is_empty());
assert!(
!p.identities()
.iter()
.any(|(_, alias)| *alias == created.alias)
);
// Remove only the independently expected two additions. Any incidental
// mutation, clone of target children or extra correspondence fails here.
let mut retained = after.clone();
retained.elements.remove(&target);
retained.traces.remove(&trace);
assert_eq!(retained, *p);
round_trip(&after);
// Replaying a creation plan after acceptance violates its snapshot, while
// a fresh unchanged request reuses the accepted counterpart.
assert!(
apply_transition(&after, &plan)
.unwrap_err()
.0
.contains("stale")
);
unchanged_retry(&after, direction, source, target);
(after, target)
}
fn target_deletion(direction: TransitionDirection) {
let fixture = fixture(direction);
let request = TransitionRequest::new(direction, vec![fixture.source.root]);
let old_plan = plan_transition(&fixture.project, &request).unwrap();
assert!(old_plan.conflicts.is_empty());
let expected = expected_deletion(&fixture, &fixture.target);
let mut session = Session::new(fixture.project.clone());
session
.execute(Command::Delete(fixture.target.root))
.unwrap();
assert_eq!(session.project(), &expected);
round_trip(session.project());
let revision = session.revision();
assert!(
apply_transition(session.project(), &old_plan)
.unwrap_err()
.0
.contains("stale")
);
let explicit_plan = plan_transition(
session.project(),
&explicit(direction, fixture.source.root, fixture.target.root),
)
.unwrap();
assert!(
explicit_plan
.conflicts
.iter()
.any(|conflict| conflict.source == Some(fixture.source.root)
&& conflict.target == Some(fixture.target.root)
&& conflict.field.is_none())
);
assert!(apply_transition(session.project(), &explicit_plan).is_err());
assert_eq!(session.project(), &expected);
assert_eq!(session.revision(), revision);
let (created, _) = implicit_creation(
session.project(),
direction,
fixture.source.root,
fixture.target.root,
);
session.execute(Command::Replace(created.clone())).unwrap();
session.undo();
assert_eq!(session.project(), &expected);
session.undo();
assert_eq!(session.project(), &fixture.project);
assert!(!session.can_undo());
session.redo();
session.redo();
assert_eq!(session.project(), &created);
round_trip(session.project());
}
fn source_recreation(direction: TransitionDirection) {
let fixture = fixture(direction);
let mut replacement = fixture.project.elements[&fixture.source.root].clone();
replacement.id = Id::new_v4();
let source = replacement.id;
// Same alias, name and prose, different UUID: name matching must never
// silently relink the retained engineering design.
assert_ne!(source, fixture.source.root);
let deleted = execute_batch(&fixture.project, &[Command::Delete(fixture.source.root)]).unwrap();
assert_eq!(deleted, expected_deletion(&fixture, &fixture.source));
let recreated =
execute_batch(&deleted, &[Command::CreateElement(replacement.clone())]).unwrap();
let mut expected = deleted.clone();
expected.elements.insert(source, replacement);
assert_eq!(recreated, expected);
round_trip(&recreated);
let (implicit, new_target) =
implicit_creation(&recreated, direction, source, fixture.target.root);
assert!(implicit.elements.contains_key(&fixture.target.root));
assert_ne!(new_target, fixture.target.root);
// The explicit branch starts from exactly the same recreated project.
let mut request = explicit(direction, source, fixture.target.root);
let unreviewed = plan_transition(&recreated, &request).unwrap();
for field in [TransitionField::Name, TransitionField::Description] {
assert!(
unreviewed
.conflicts
.iter()
.any(|conflict| conflict.source == Some(source)
&& conflict.target == Some(fixture.target.root)
&& conflict.field == Some(field))
);
}
assert!(apply_transition(&recreated, &unreviewed).is_err());
request.resolutions = vec![
FieldResolution {
source,
field: TransitionField::Name,
decision: FieldDecision::TakeSource,
},
FieldResolution {
source,
field: TransitionField::Description,
decision: FieldDecision::KeepTarget,
},
];
let plan = plan_transition(&recreated, &request).unwrap();
assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts);
assert_eq!(plan.changes.len(), 1);
assert_eq!(
(plan.changes[0].target, plan.changes[0].action),
(fixture.target.root, TransitionAction::Update)
);
assert_eq!(
plan.changes[0].fields,
vec![FieldChange {
field: TransitionField::Name,
before: recreated.elements[&fixture.target.root].name.clone(),
after: recreated.elements[&source].name.clone(),
}]
);
let relinked = apply_transition(&recreated, &plan).unwrap();
let new_trace = assert_only_new_trace(
&recreated,
&relinked,
direction,
source,
fixture.target.root,
);
assert_ne!(
new_trace, fixture.trace,
"Deleted correspondence is not silently resurrected"
);
let mut expected = recreated.clone();
expected
.elements
.get_mut(&fixture.target.root)
.unwrap()
.name = recreated.elements[&source].name.clone();
let mut without_trace = relinked.clone();
without_trace.traces.remove(&new_trace);
assert_eq!(
without_trace, expected,
"Only the reviewed target name may change; rich target design survives"
);
round_trip(&relinked);
unchanged_retry(&relinked, direction, source, fixture.target.root);
let original_source = round_trip(&recreated);
let mut session = Session::new(recreated.clone());
session.execute(Command::Replace(relinked)).unwrap();
session.undo();
assert_eq!(session.project(), &recreated);
assert_eq!(round_trip(session.project()), original_source);
}
#[test]
fn oa_source_deletion_preserves_rich_sa_design_and_exact_undo() {
source_deletion(TransitionDirection::OaToSa);
}
#[test]
fn sa_source_deletion_preserves_rich_portless_oa_design_and_exact_undo() {
source_deletion(TransitionDirection::SaToOa);
}
#[test]
fn deleted_sa_target_refuses_old_plans_and_implicit_retry_creates_once() {
target_deletion(TransitionDirection::OaToSa);
}
#[test]
fn deleted_oa_target_refuses_old_plans_and_implicit_retry_creates_once() {
target_deletion(TransitionDirection::SaToOa);
}
#[test]
fn recreated_oa_identity_requires_explicit_relink_to_retained_sa_design() {
source_recreation(TransitionDirection::OaToSa);
}
#[test]
fn recreated_sa_identity_requires_explicit_relink_to_retained_oa_design() {
source_recreation(TransitionDirection::SaToOa);
}
@@ -748,8 +748,6 @@ fn allocation_artifact_and_manual_trace_cascades_are_named_and_refused() {
fn route(edge: Id) -> EdgeRoute {
EdgeRoute {
edge,
source_attachment: None,
target_attachment: None,
waypoints: vec![xy(420., 400.), xy(420., 550.)],
label: Some(EdgeLabel {
fraction: 0.4,
@@ -60,7 +60,6 @@ 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()),
@@ -71,7 +70,6 @@ impl Root<'_> {
pan: self.app.pan,
context: self.app.route_context().unwrap(),
selected_bend: None,
selected_attachment: None,
editable: false,
placing: false,
connection: None,
+5 -256
View File
@@ -15,18 +15,11 @@ mod diagram_reveal;
mod diagram_selection_tests;
mod diagram_usages;
mod hosted;
pub(crate) mod multiselect;
#[cfg(test)]
mod multiselect_tests;
mod reference_authoring;
#[cfg(test)]
mod review_navigation_tests;
pub(crate) mod source_rename;
mod source_symbols;
#[cfg(test)]
mod state_multiselect_focus_tests;
#[cfg(test)]
mod state_multiselect_tests;
pub(crate) mod text_workspace;
#[cfg(test)]
mod transition_context_tests;
@@ -268,7 +261,6 @@ 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;
@@ -372,7 +364,6 @@ pub enum Message {
Select(Id),
EditProperties(Id),
SelectNode(Id),
DiagramSelection(multiselect::Event),
SelectProject,
Search(String),
StartTransition,
@@ -502,7 +493,6 @@ pub struct App {
pub camera_epoch: u64,
camera_boundary_epoch: u64,
pub selected_bend: Option<(Id, usize)>,
pub selected_attachment: Option<(crate::route_edit::Context, Id, EdgeEnd)>,
pub exact_strings: bool,
pub document: Document,
pub page: Page,
@@ -516,7 +506,6 @@ 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,
@@ -624,7 +613,6 @@ impl App {
camera_epoch: 0,
camera_boundary_epoch: 0,
selected_bend: None,
selected_attachment: None,
exact_strings: false,
document,
page: Page::Diagram,
@@ -637,7 +625,6 @@ impl App {
layer,
selected,
selected_node: None,
diagram_selection: None,
draft,
active_diagram,
search: String::new(),
@@ -819,9 +806,6 @@ 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
{
@@ -1119,13 +1103,7 @@ 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 && !process_preview {
if self.selected == id {
return;
}
if !self.apply() {
@@ -1134,10 +1112,6 @@ 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;
@@ -1305,85 +1279,6 @@ 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::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 {
@@ -1436,11 +1331,7 @@ impl App {
self.reconcile_pending_diagram_reveal(&message);
self.allocation_before(&message);
if let Message::WorkDrag(event) = message {
let task = self.allocation_event(event);
if self.multiselect_boundary() != selection_boundary {
self.reconcile_multiselect();
}
return task;
return self.allocation_event(event);
}
let allocation_before = self.allocation_boundary();
if let Message::BehaviorControl(intent) = message {
@@ -1473,14 +1364,6 @@ 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> {
@@ -1844,7 +1727,6 @@ 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;
}
@@ -2394,9 +2276,6 @@ 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) => {
@@ -2426,20 +2305,8 @@ impl App {
"Apply or revert pending edits before using modeling actions.".into();
return Task::none();
}
if matches!(*action, Message::DiagramSelection(_))
|| (menu.request.process.is_some()
&& matches!(*action, Message::TopologyCanvas(_)))
{
return self.update(*action);
}
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_node = menu.request.placement;
self.selected_bend = menu.request.bend.map(|i| (menu.request.object, i));
return self.update(*action);
}
@@ -2462,15 +2329,6 @@ impl App {
} else if self.behavior.creation.has_pending() {
self.behavior.creation.acknowledge_commit();
self.status = "Behavior creation cancelled".into();
} else if self.page == Page::Behavior
&& matches!(
self.behavior.diagram.tool,
behavior::state_edit::Tool::AdjustAttachment { .. }
)
{
// Menu arming invalidates canvas keyboard ownership. Escape
// must also cancel before the next canvas click grants it.
self.behavior.diagram.tool = behavior::state_edit::Tool::Select;
}
if self.connection.take().is_some() {
self.status = "Drawing cancelled · Select / move is active".into();
@@ -2597,7 +2455,6 @@ 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;
@@ -3255,21 +3112,7 @@ impl App {
}
}
}
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::DiagramRemove(intent) => self.remove_from_diagram(intent),
Message::DiagramTraverse(back) => {
return if back {
iced::widget::operation::focus_previous()
@@ -3278,17 +3121,6 @@ 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);
}
@@ -3420,13 +3252,6 @@ 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
{
@@ -3456,13 +3281,6 @@ 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
@@ -4705,7 +4523,7 @@ impl App {
}
fn edit_route(&mut self, event: crate::route_edit::Event) {
use crate::route_edit::{Event, insert_pin, stored_route};
if self.connection.is_some() || self.page != Page::Diagram {
if self.connection.is_some() {
return;
}
let Some(context) = self.route_context() else {
@@ -4733,75 +4551,6 @@ impl App {
.filter(|(id, _)| *id == self.selected)
.map(|(_, i)| i);
let command = match event {
Event::ChooseAttachment(end) => {
if crate::route_edit::attachment_node(
self.project(),
diagram,
&view,
self.selected,
end,
)
.is_some()
{
self.selected_attachment = Some((context, self.selected, end));
self.selected_bend = None;
self.status = format!(
"Drag the {} attachment handle along its element boundary · Esc cancels",
if end == EdgeEnd::Source {
"source"
} else {
"target"
}
);
}
return;
}
Event::ResetAttachment(end) => {
if crate::route_edit::attachment_node(
self.project(),
diagram,
&view,
self.selected,
end,
)
.is_none()
|| route.attachment(end).is_none()
{
return;
}
route.set_attachment(end, None);
selected_bend = None;
Command::SetEdgeRoute {
diagram: context.diagram,
route,
}
}
Event::CommitAttachment(expected, changed, end, owner) => {
if expected != context
|| changed.edge != self.selected
|| crate::route_edit::attachment_node(
self.project(),
diagram,
&view,
self.selected,
end,
)
.is_none_or(|node| node.id != owner)
{
return;
}
// A boundary gesture owns exactly one presentation field.
let mut intended = route.clone();
intended.set_attachment(end, changed.attachment(end));
if changed != intended || intended == route {
return;
}
selected_bend = None;
Command::SetEdgeRoute {
diagram: context.diagram,
route: intended,
}
}
Event::SelectBend(id, index) => {
if id == self.selected && index < route.waypoints.len() {
self.selected_bend = Some((id, index));
@@ -59,7 +59,6 @@ fn fixture(layer: Layer) -> Fixture {
}
fn canvas(app: &App) -> DiagramCanvas<'_> {
DiagramCanvas {
selection: None,
allocation: Some(app.allocation_capability()),
project: app.project(),
view_index: Some(app.document.view_index()),
@@ -70,7 +69,6 @@ fn canvas(app: &App) -> DiagramCanvas<'_> {
pan: app.pan,
context: app.route_context().unwrap(),
selected_bend: None,
selected_attachment: None,
editable: !app.draft_dirty() && !app.source_dirty(),
placing: false,
connection: None,
@@ -66,7 +66,6 @@ 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(),
@@ -76,7 +75,6 @@ fn architecture_f2_routes_selected_node_to_inline_rename_and_exact_undo() {
pan: app.pan,
context: app.route_context().unwrap(),
selected_bend: None,
selected_attachment: None,
editable: true,
placing: false,
connection: None,
@@ -221,7 +221,6 @@ 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(),
@@ -231,7 +230,6 @@ fn architecture_canvas_left_click_uses_world_coordinate_once_at_zoom_and_pan() {
pan: app.pan,
context: app.route_context().unwrap(),
selected_bend: None,
selected_attachment: None,
editable: true,
placing: true,
connection: None,
@@ -299,7 +297,6 @@ 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(),
@@ -309,7 +306,6 @@ fn architecture_f2_rename_preserves_identity_placement_and_atomic_history() {
pan: app.pan,
context: app.route_context().unwrap(),
selected_bend: None,
selected_attachment: None,
editable: true,
placing: false,
connection: None,
@@ -518,16 +518,9 @@ async fn mounted_qualified_process_child_delete_refuses_outer_reference_and_shar
.await;
assert!(messages.iter().any(|message| matches!(
message,
Message::DiagramSelection(multiselect::Event::Choose(
_, crate::diagram_usage::Target::ProcessOccurrence(actual), multiselect::Choice::Replace
)) if actual == &endpoint
Message::TopologyCanvas(topology::CanvasEvent::SelectQualified(..))
)));
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_eq!(app.selected, references[0]);
assert!(
app.behavior
.topology
@@ -154,440 +154,3 @@ async fn mounted_f2_requires_canvas_focus_and_yields_to_clean_inspector() {
}
}
}
async fn modifier_focus_fixture() -> (App, Mounted, Id, Id, Id) {
let mut project = Project::empty();
let a = project
.add_element(Kind::Entity, Layer::Oa, "First team", None)
.unwrap();
let b = project
.add_element(Kind::Entity, Layer::Oa, "Second team", None)
.unwrap();
let diagram = project.ensure_diagram(Layer::Oa);
let first = project.place(diagram, a, Position { x: 80., y: 80. });
let second = project.place(diagram, b, Position { x: 500., y: 80. });
let mut app = App::with_project(project);
app.page = Page::Diagram;
app.layer = Layer::Oa;
app.active_diagram = Some(diagram);
app.window_size = Size::new(2000., 1400.);
let _ = app.update(Message::Select(a));
let mut mounted = Mounted::new(&mut app).await;
app.zoom = 1.;
app.pan = Vector::ZERO;
app.fit_to_view = false;
mounted.settle(&mut app).await;
(app, mounted, diagram, first, second)
}
fn modifier_node_point(mounted: &mut Mounted, app: &App, diagram: Id, placement: Id) -> Point {
let bounds = mounted
.geometry(app)
.bounds(crate::allocation_input::CANVAS_ID);
let node = projection::project_view(app.project(), diagram)
.nodes
.into_iter()
.find(|n| n.id == placement)
.unwrap();
Point::new(
bounds.x + node.position.x + 40.,
bounds.y + node.position.y + 35.,
)
}
fn modifier_canvas_focused(mounted: &mut Mounted, app: &App) -> bool {
mounted
.geometry(app)
.focused
.iter()
.find(|(id, _, _)| {
id.as_ref() == Some(&iced::advanced::widget::Id::from("diagram-keyboard-owner"))
})
.expect("production focus::Owner is mounted")
.2
}
fn modifier_event(shift: bool) -> iced::Event {
iced::Event::Keyboard(iced::keyboard::Event::ModifiersChanged(if shift {
iced::keyboard::Modifiers::SHIFT
} else {
iced::keyboard::Modifiers::default()
}))
}
#[tokio::test]
async fn mounted_shift_before_canvas_focus_toggles_exact_node_without_granting_keyboard_focus() {
use crate::diagram_usage::Target;
let (mut app, mut mounted, diagram, first, second) = modifier_focus_fixture().await;
let before = app.document.snapshot();
let field = mounted
.geometry(&app)
.text_inputs
.into_iter()
.filter(|bounds| bounds.x > app.window_size.width - 320.)
.min_by(|a, b| a.y.total_cmp(&b.y))
.expect("clean Inspector name input");
mounted.operate(&app, &mut FocusField(field));
assert!(!modifier_canvas_focused(&mut mounted, &app));
assert_eq!(
app.multiselect_snapshot().unwrap().targets(),
&[Target::Placement(first)]
);
mounted
.batch(
&mut app,
&[modifier_event(true)],
mouse::Cursor::Unavailable,
)
.await;
assert!(
!modifier_canvas_focused(&mut mounted, &app),
"modifier tracking must not acquire focus"
);
let at = modifier_node_point(&mut mounted, &app, diagram, second);
let messages = mounted.click(&mut app, at).await;
assert!(messages.iter().any(|m| matches!(m,
Message::DiagramSelection(multiselect::Event::Choose(_, Target::Placement(id), multiselect::Choice::Toggle)) if *id == second
)), "Shift already held before the first canvas press must reach its exact-target adapter");
assert_eq!(
app.multiselect_snapshot().unwrap().targets(),
&[Target::Placement(first), Target::Placement(second)]
);
assert_eq!(app.selected_node, Some(second));
assert_eq!(app.project(), &before.decoded.project);
assert_eq!(app.document.bundle(), &before.bundle);
assert!(!app.document.can_undo());
assert!(!app.draft_dirty());
}
#[tokio::test]
async fn mounted_unfocused_typing_and_delete_remain_denied_after_passive_modifiers() {
use crate::diagram_usage::Target;
let (mut app, mut mounted, diagram, first, second) = modifier_focus_fixture().await;
mounted
.batch(
&mut app,
&[modifier_event(true)],
mouse::Cursor::Unavailable,
)
.await;
let at = modifier_node_point(&mut mounted, &app, diagram, second);
mounted.click(&mut app, at).await;
assert_eq!(
app.multiselect_snapshot().unwrap().targets(),
&[Target::Placement(first), Target::Placement(second)]
);
// Revoke every field's focus through the real widget operation. No widget
// has this off-window rectangle, so neither Inspector nor canvas owns keys.
mounted.operate(
&app,
&mut FocusField(Rectangle {
x: -1000.,
y: -1000.,
width: 1.,
height: 1.,
}),
);
assert!(!modifier_canvas_focused(&mut mounted, &app));
let before = app.document.snapshot();
let selection = app.multiselect_snapshot().unwrap();
let messages = mounted
.batch(
&mut app,
&[
modifier_event(false),
crate::behavior::canvas_workspace::tests::character("x"),
key(Named::Delete),
key(Named::F2),
],
mouse::Cursor::Unavailable,
)
.await;
assert!(!messages.iter().any(|m| matches!(
m,
Message::DiagramSelection(_)
| Message::DiagramRemove(_)
| Message::HideSelected
| Message::Delete
)));
assert!(!rename_emitted(&messages));
assert!(!modifier_canvas_focused(&mut mounted, &app));
assert_eq!(
app.multiselect_snapshot().unwrap().targets(),
selection.targets()
);
assert_eq!(app.project(), &before.decoded.project);
assert_eq!(app.document.bundle(), &before.bundle);
assert!(!app.document.can_undo());
assert!(!app.draft_dirty());
assert!(!app.delete_pending);
}
#[tokio::test]
async fn mounted_suspended_modifiers_are_passive_and_survive_until_resumed_click() {
use crate::diagram_usage::Target;
let (mut app, mut mounted, diagram, first, second) = modifier_focus_fixture().await;
let before = app.document.snapshot();
let at = modifier_node_point(&mut mounted, &app, diagram, second);
app.busy = true;
mounted.settle(&mut app).await;
assert!(!modifier_canvas_focused(&mut mounted, &app));
mounted
.batch(
&mut app,
&[modifier_event(true), key(Named::Delete)],
mouse::Cursor::Unavailable,
)
.await;
mounted.click(&mut app, at).await;
assert!(
!modifier_canvas_focused(&mut mounted, &app),
"passive I/O cannot grant canvas focus"
);
assert_eq!(
app.multiselect_snapshot().unwrap().targets(),
&[Target::Placement(first)]
);
assert_eq!(app.document.bundle(), &before.bundle);
app.busy = false;
mounted.settle(&mut app).await;
assert!(!modifier_canvas_focused(&mut mounted, &app));
mounted.click(&mut app, at).await;
assert_eq!(
app.multiselect_snapshot().unwrap().targets(),
&[Target::Placement(first), Target::Placement(second)]
);
assert_eq!(app.project(), &before.decoded.project);
assert_eq!(app.document.bundle(), &before.bundle);
assert!(!app.document.can_undo());
}
#[tokio::test]
async fn mounted_shift_held_before_suspension_survives_without_another_modifier_event() {
use crate::diagram_usage::Target;
let (mut app, mut mounted, diagram, first, second) = modifier_focus_fixture().await;
let before = app.document.snapshot();
let field = mounted
.geometry(&app)
.text_inputs
.into_iter()
.filter(|bounds| bounds.x > app.window_size.width - 320.)
.min_by(|a, b| a.y.total_cmp(&b.y))
.expect("clean Inspector name input");
mounted.operate(&app, &mut FocusField(field));
mounted
.batch(
&mut app,
&[modifier_event(true)],
mouse::Cursor::Unavailable,
)
.await;
assert!(!modifier_canvas_focused(&mut mounted, &app));
app.busy = true;
mounted.settle(&mut app).await;
assert!(!modifier_canvas_focused(&mut mounted, &app));
app.busy = false;
mounted.settle(&mut app).await;
assert!(
!modifier_canvas_focused(&mut mounted, &app),
"restoring passive modifiers must not grant keyboard focus"
);
assert_eq!(
app.multiselect_snapshot().unwrap().targets(),
&[Target::Placement(first)]
);
// No modifier transition follows the subtree replacement during I/O.
let at = modifier_node_point(&mut mounted, &app, diagram, second);
let messages = mounted.click(&mut app, at).await;
assert!(messages.iter().any(|m| matches!(m,
Message::DiagramSelection(multiselect::Event::Choose(_, Target::Placement(id), multiselect::Choice::Toggle)) if *id == second
)));
assert_eq!(
app.multiselect_snapshot().unwrap().targets(),
&[Target::Placement(first), Target::Placement(second)]
);
assert_eq!(app.project(), &before.decoded.project);
assert_eq!(app.document.bundle(), &before.bundle);
assert!(!app.document.can_undo());
assert!(!app.draft_dirty());
}
#[tokio::test]
async fn mounted_shift_release_or_window_unfocus_during_suspension_does_not_restore_stale_shift() {
use crate::diagram_usage::Target;
for release in [
modifier_event(false),
iced::Event::Window(iced::window::Event::Unfocused),
] {
let (mut app, mut mounted, diagram, first, second) = modifier_focus_fixture().await;
let before = app.document.snapshot();
mounted
.batch(
&mut app,
&[modifier_event(true)],
mouse::Cursor::Unavailable,
)
.await;
app.busy = true;
mounted.settle(&mut app).await;
mounted
.batch(&mut app, &[release], mouse::Cursor::Unavailable)
.await;
assert!(!modifier_canvas_focused(&mut mounted, &app));
app.busy = false;
mounted.settle(&mut app).await;
// A further I/O cycle must restore the updated owner state too.
app.busy = true;
mounted.settle(&mut app).await;
app.busy = false;
mounted.settle(&mut app).await;
assert!(!modifier_canvas_focused(&mut mounted, &app));
assert_eq!(
app.multiselect_snapshot().unwrap().targets(),
&[Target::Placement(first)]
);
let at = modifier_node_point(&mut mounted, &app, diagram, second);
let messages = mounted.click(&mut app, at).await;
assert!(!messages.iter().any(|m| matches!(
m,
Message::DiagramSelection(multiselect::Event::Choose(
_,
_,
multiselect::Choice::Toggle
))
)));
assert_eq!(
app.multiselect_snapshot().unwrap().targets(),
&[Target::Placement(second)]
);
assert_eq!(app.project(), &before.decoded.project);
assert_eq!(app.document.bundle(), &before.bundle);
assert!(!app.document.can_undo());
assert!(!app.draft_dirty());
}
}
#[tokio::test]
async fn mounted_window_unfocus_resets_held_shift_before_next_canvas_press() {
use crate::diagram_usage::Target;
let (mut app, mut mounted, diagram, first, second) = modifier_focus_fixture().await;
let at_first = modifier_node_point(&mut mounted, &app, diagram, first);
mounted.click(&mut app, at_first).await;
let before = app.document.snapshot();
mounted
.batch(
&mut app,
&[
modifier_event(true),
iced::Event::Window(iced::window::Event::Unfocused),
],
mouse::Cursor::Unavailable,
)
.await;
assert!(!modifier_canvas_focused(&mut mounted, &app));
// No ModifiersChanged(empty) follows: a lost window must itself reset it.
let at_second = modifier_node_point(&mut mounted, &app, diagram, second);
mounted.click(&mut app, at_second).await;
assert_eq!(
app.multiselect_snapshot().unwrap().targets(),
&[Target::Placement(second)]
);
assert_eq!(app.project(), &before.decoded.project);
assert_eq!(app.document.bundle(), &before.bundle);
assert!(!app.document.can_undo());
}
#[tokio::test]
async fn mounted_selection_strip_keeps_canvas_and_fitted_camera_fixed_for_empty_single_group_and_mixed()
{
use crate::diagram_usage::Target;
let mut project = Project::empty();
let a = project
.add_element(Kind::Activity, Layer::Oa, "First activity", None)
.unwrap();
let b = project
.add_element(
Kind::Activity,
Layer::Oa,
&format!(
"Second activity {}",
"with a very long primary name ".repeat(20)
),
None,
)
.unwrap();
let edge = project.relate(RelationKind::Interaction, "Activity exchange", a, b);
let diagram = project.ensure_diagram(Layer::Oa);
project.diagrams.get_mut(&diagram).unwrap().projection = ProjectionKind::Overview;
let first = project.place(diagram, a, Position { x: 80., y: 80. });
let second = project.place(diagram, b, Position { x: 500., y: 80. });
let mut app = App::with_project(project);
app.page = Page::Diagram;
app.layer = Layer::Oa;
app.active_diagram = Some(diagram);
app.window_size = Size::new(1442., 932.);
let _ = app.update(Message::Select(diagram));
app.fit_to_view = true;
let mut mounted = Mounted::new(&mut app).await;
let baseline = app.document.snapshot();
let bounds = mounted
.geometry(&app)
.bounds(crate::allocation_input::CANVAS_ID);
let camera = (app.zoom, app.pan, app.fit_to_view, app.canvas_size);
assert!(camera.2, "exercise the native resize-triggered refit path");
assert!(app.multiselect_snapshot().unwrap().targets().is_empty());
assert!(bounds.width > 500. && bounds.height > 300.);
for (target, expected) in [
(
Some(Target::Placement(first)),
vec![Target::Placement(first)],
),
(
Some(Target::Placement(second)),
vec![Target::Placement(first), Target::Placement(second)],
),
(
Some(Target::Edge(edge)),
vec![
Target::Placement(first),
Target::Placement(second),
Target::Edge(edge),
],
),
(
Some(Target::Edge(edge)),
vec![Target::Placement(first), Target::Placement(second)],
),
(
Some(Target::Placement(second)),
vec![Target::Placement(first)],
),
(None, vec![]),
] {
let context = app.multiselect_context().unwrap();
let event = match target {
Some(target) => {
multiselect::Event::Choose(context, target, multiselect::Choice::Toggle)
}
None => multiselect::Event::Clear(context),
};
let task = app.update(Message::DiagramSelection(event));
mounted.perform(&mut app, task).await;
mounted.settle(&mut app).await;
assert_eq!(app.multiselect_snapshot().unwrap().targets(), expected);
assert_eq!(
mounted
.geometry(&app)
.bounds(crate::allocation_input::CANVAS_ID),
bounds,
"selection feedback must not change the measured canvas rectangle"
);
assert_eq!(
(app.zoom, app.pan, app.fit_to_view, app.canvas_size),
camera
);
assert_eq!(app.project(), &baseline.decoded.project);
assert_eq!(app.document.bundle(), &baseline.bundle);
assert!(!app.document.can_undo());
assert!(!app.draft_dirty());
}
}
@@ -924,8 +924,6 @@ async fn mounted_bend_delete_has_priority_and_semantic_edge_delete_is_confirmed(
diagram,
route: EdgeRoute {
edge,
source_attachment: None,
target_attachment: None,
waypoints: vec![Position { x: 400., y: -100. }],
label: None,
},
+8 -1
View File
@@ -319,7 +319,14 @@ impl App {
| Target::ScenarioObject(id)
| Target::AllocationGrouping { relation: id, .. }
| Target::ProcessPreviewLink { relation: id, .. } => (*id, None),
Target::ProcessOccurrence(endpoint) => (endpoint.occurrence, None),
Target::ProcessOccurrence(endpoint) => (
endpoint
.references
.first()
.copied()
.unwrap_or(endpoint.occurrence),
None,
),
};
match usage.surface {
Surface::Saved(id) => {
@@ -881,32 +881,7 @@ fn repeated_process_calls_reveal_the_chosen_caller_and_distinct_tile_camera() {
.unwrap()
.clone();
choose(&mut app, token, row);
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.selected, first);
assert_eq!(app.page, Page::Behavior);
assert_eq!(app.behavior.active_owner, Some(fixture.owner));
let first_camera = app.behavior.topology.camera_boundary();
@@ -923,33 +898,7 @@ fn repeated_process_calls_reveal_the_chosen_caller_and_distinct_tile_camera() {
})
.unwrap();
choose(&mut app, token, row);
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, second);
assert_eq!(app.selected_node, None);
assert!(app.behavior.topology.camera_boundary() != first_camera);
assert_eq!(authored(&app), before);
-476
View File
@@ -1,476 +0,0 @@
//! 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()
}
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();
}
}
}
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 =
"Group allocation drag is not available yet. The selection was retained."
.into();
return;
}
if delta.x == 0. && delta.y == 0. {
return;
}
match oa_core::diagram_edit::translate_placements(
self.project(),
diagram,
&ids,
delta,
) {
Ok(command) => {
if self.execute(command) {
self.inspect_multiselect_primary();
self.status = format!(
"Moved {} selected elements · Undo restores the group",
ids.len()
);
}
}
Err(error) => self.status = error.to_string(),
}
}
Event::Hide(snapshot) => {
let Some(ids) = placements(&snapshot) else {
self.status =
"Hide requires only placed elements. Model objects remain unchanged."
.into();
return;
};
if !snapshot
.targets()
.iter()
.all(|target| visible(self.project(), diagram, &view, target))
{
return;
}
if self.execute(Command::Batch(
ids.iter().copied().map(Command::Delete).collect(),
)) {
if let Some(selection) = &mut self.diagram_selection {
selection.clear();
}
self.inspect_multiselect_primary();
self.status = format!("Hid {} placements · Model objects retained", ids.len());
}
}
}
}
}
@@ -1,602 +0,0 @@
//! 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());
}
+18 -31
View File
@@ -128,12 +128,6 @@ 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
@@ -143,18 +137,27 @@ 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) => {
self.multiselect_event(multiselect::Event::Choose(
multiselect::process_context(context),
crate::diagram_usage::Target::ProcessOccurrence(endpoint),
multiselect::Choice::Replace,
));
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);
}
}
CanvasEvent::Select(_, id) => {
if !self.behavior.has_pending()
@@ -195,9 +198,6 @@ 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,20 +222,8 @@ impl App {
self.behavior.topology = state;
if let Some((owner, occurrence)) = back {
self.behavior.active_owner = Some(owner);
// 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.select(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 => {
@@ -305,9 +293,8 @@ impl App {
occurrence, before, ..
} => {
match topology::reorder(self.project(), context.owner, occurrence, before) {
Ok(topology::group_reorder::Plan::Noop) => {}
Ok(topology::group_reorder::Plan::Change(command)) => {
if self.execute(*command) {
Ok(command) => {
if self.execute(command) {
self.selected = occurrence;
self.refresh_draft();
return self.reveal_process_selection();
@@ -1,260 +0,0 @@
//! 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;
@@ -1,979 +0,0 @@
//! 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()));
}
}
@@ -1,161 +0,0 @@
//! Production App tree and its actual keyboard subscription, without a canvas click.
use super::*;
use crate::behavior::canvas_workspace::tests::{Mounted, key};
use iced::{
advanced::subscription,
futures::{StreamExt, stream, task::noop_waker_ref},
keyboard::key::Named,
mouse,
};
use std::task::{Context as PollContext, Poll};
async fn press(mounted: &mut Mounted, app: &mut App, named: Named) -> Vec<Message> {
let input = key(named);
let (mut messages, statuses) = mounted.input_with_status(
app,
std::slice::from_ref(&input),
mouse::Cursor::Unavailable,
);
let interaction = subscription::Event::Interaction {
window: iced::window::Id::unique(),
event: input,
status: statuses[0],
};
for recipe in subscription::into_recipes(app.subscription()) {
let mut output = recipe.stream(Box::pin(stream::iter([interaction.clone()])));
let mut cx = PollContext::from_waker(noop_waker_ref());
while let Poll::Ready(Some(message)) = output.poll_next_unpin(&mut cx) {
messages.push(message);
}
}
for message in messages.iter().cloned() {
let task = app.update(message);
mounted.perform(app, task).await;
}
mounted.settle(app).await;
messages
}
async fn armed() -> (App, Mounted, Id) {
let mut f = fixture();
let diagram = create_view(&mut f).diagram;
let mut project = f.app.project().clone();
let relation = project.relate(RelationKind::StateTransition, "Switch", f.a, f.b);
let mut app = App::with_project(project);
app.window_size = iced::Size::new(1800., 1100.);
send(&mut app, Message::RevealInDiagram(diagram, relation));
let mut mounted = Mounted::new(&mut app).await;
app.behavior.diagram.fit = false;
let context = app.state_context().unwrap();
event(
&mut app,
Event::ContextMenu {
context,
object: relation,
placement: None,
selection_target: None,
anchor: iced::Point::new(450., 400.),
bend: None,
route_segment: None,
},
);
mounted.settle(&mut app).await;
let token = app.model_menu.as_ref().unwrap().token;
send(
&mut app,
Message::Menu(crate::model_menu::Event::Invoke(
token,
Box::new(Message::StateDiagram(Event::Choose(
Tool::AdjustAttachment {
relation,
end: EdgeEnd::Source,
},
))),
)),
);
mounted.settle(&mut app).await;
assert!(app.model_menu.is_none());
assert_eq!(
app.behavior.diagram.tool,
Tool::AdjustAttachment {
relation,
end: EdgeEnd::Source
}
);
(app, mounted, relation)
}
fn canvas_focus(mounted: &mut Mounted, app: &App) -> (iced::Rectangle, bool) {
let id = iced::advanced::widget::Id::from("diagram-keyboard-owner");
let (_, bounds, focused) = mounted
.geometry(app)
.focused
.into_iter()
.find(|(candidate, _, _)| candidate.as_ref() == Some(&id))
.unwrap();
(bounds, focused)
}
#[tokio::test]
async fn mounted_menu_armed_attachment_escape_without_canvas_click_preserves_document_and_focus() {
let (mut app, mut mounted, _) = armed().await;
let before = app.document.snapshot();
let history = (app.document.can_undo(), app.document.can_redo());
let camera = (
app.behavior.diagram.zoom,
app.behavior.diagram.pan,
app.behavior.diagram.fit,
);
assert!(!canvas_focus(&mut mounted, &app).1);
let messages = press(&mut mounted, &mut app, Named::Escape).await;
assert!(messages.iter().any(|m| matches!(m, Message::Escape)));
assert_eq!(app.behavior.diagram.tool, Tool::Select);
assert!(!canvas_focus(&mut mounted, &app).1);
press(&mut mounted, &mut app, Named::Delete).await;
assert!(
!app.delete_pending,
"Escape must not grant canvas Delete ownership"
);
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
assert_eq!(app.document.bundle(), &before.bundle);
assert_eq!(app.project(), &before.decoded.project);
assert_eq!((app.document.can_undo(), app.document.can_redo()), history);
assert_eq!(
(
app.behavior.diagram.zoom,
app.behavior.diagram.pan,
app.behavior.diagram.fit
),
camera
);
}
#[tokio::test]
async fn mounted_attachment_escape_preserves_modal_priority_and_focused_canvas_control() {
let (mut app, mut mounted, _) = armed().await;
let before = app.document.snapshot();
// Existing modal Escape consumes the first key without canceling the tool underneath.
send(&mut app, Message::Delete);
assert!(app.delete_pending && app.delete_target.is_some());
mounted.settle(&mut app).await;
press(&mut mounted, &mut app, Named::Escape).await;
assert!(!app.delete_pending);
assert!(matches!(
app.behavior.diagram.tool,
Tool::AdjustAttachment { .. }
));
// A real canvas click grants ownership; inner-canvas Escape remains live.
let bounds = canvas_focus(&mut mounted, &app).0;
mounted
.click(&mut app, iced::Point::new(bounds.x + 10., bounds.y + 10.))
.await;
assert!(canvas_focus(&mut mounted, &app).1);
let messages = press(&mut mounted, &mut app, Named::Escape).await;
assert!(
messages
.iter()
.any(|m| matches!(m, Message::StateDiagram(Event::Choose(Tool::Select))))
);
assert_eq!(app.behavior.diagram.tool, Tool::Select);
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
}
@@ -1,481 +0,0 @@
use super::*;
#[test]
fn state_bend_delayed_commit_after_same_view_selection_is_inert_and_live_commit_works() {
let (mut app, _, relation, _) = setup();
let context = app.state_context().unwrap();
let next = app.project().relations[&relation].target();
let waypoints = vec![Position { x: 715., y: -425. }];
let delayed = Event::SetBends {
context,
relation,
waypoints: waypoints.clone(),
};
send(&mut app, Message::Select(next));
assert_eq!(app.selected, next);
assert_eq!(
app.state_context(),
Some(context),
"same-view selection does not retire the context stamp"
);
let before = app.document.snapshot();
let camera = (
app.behavior.diagram.zoom,
app.behavior.diagram.pan,
app.behavior.diagram.fit,
);
event(&mut app, delayed);
assert_eq!(app.selected, next);
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
assert_eq!(app.project(), &before.decoded.project);
assert_eq!(app.document.bundle(), &before.bundle);
assert!(!app.document.can_undo());
assert_eq!(
(
app.behavior.diagram.zoom,
app.behavior.diagram.pan,
app.behavior.diagram.fit
),
camera
);
send(&mut app, Message::Select(relation));
let context = app.state_context().unwrap();
event(
&mut app,
Event::SetBends {
context,
relation,
waypoints: waypoints.clone(),
},
);
let mut expected = before.decoded.project.clone();
let route = expected
.diagrams
.get_mut(&context.diagram)
.unwrap()
.routes
.iter_mut()
.find(|route| route.edge == relation)
.unwrap();
route.waypoints = waypoints;
assert_eq!(app.project(), &expected);
assert_source_model_agree(&app);
send(&mut app, Message::Undo);
assert_eq!(app.document.bundle(), &before.bundle);
assert!(
!app.document.can_undo(),
"live drag remains a single transaction"
);
}
#[test]
fn state_bend_menu_carries_exact_segment_and_pin_without_changing_delete_action() {
let (mut app, _, relation, _) = setup();
let context = app.state_context().unwrap();
let point = Position { x: 500., y: -400. };
event(
&mut app,
Event::ContextMenu {
context,
object: relation,
placement: None,
selection_target: None,
anchor: iced::Point::ORIGIN,
bend: Some(1),
route_segment: Some((2, point)),
},
);
let menu = crate::model_menu::popup(&app).unwrap();
let mut add = false;
let mut remove = false;
let mut delete = false;
for entry in menu.entries {
if let crate::context_menu::Entry::Action {
message: Some(Message::Menu(crate::model_menu::Event::Invoke(_, message))),
..
} = entry
{
match *message {
Message::StateDiagram(Event::AddBend {
relation: id,
segment: Some((2, at)),
..
}) => {
assert_eq!(id, relation);
assert_eq!(at, point);
add = true;
}
Message::StateDiagram(Event::RemoveBend {
relation: id,
index: 1,
..
}) => {
assert_eq!(id, relation);
remove = true;
}
Message::Delete => delete = true,
_ => {}
}
}
}
assert!(add && remove && delete);
}
fn setup() -> (App, Id, Id, Id) {
let mut f = fixture();
let diagram = create_view(&mut f).diagram;
let mut project = f.app.project().clone();
let relation = project.relate(RelationKind::StateTransition, "Bend trial", f.leaf, f.b);
let second = state_edit::create_view(&project, f.machine).unwrap();
project = prepare_command(&project, &second.command).unwrap().project;
project
.diagrams
.get_mut(&diagram)
.unwrap()
.routes
.push(EdgeRoute {
edge: relation,
waypoints: vec![
Position { x: 650., y: -400. },
Position { x: 300., y: -400. },
Position { x: 650., y: -400. },
],
label: Some(EdgeLabel {
fraction: 0.35,
offset: Position { x: 9., y: -23. },
}),
source_attachment: Some(EdgeAttachment {
side: PortAnchorSide::Top,
offset: 0.234567,
}),
target_attachment: Some(EdgeAttachment {
side: PortAnchorSide::Bottom,
offset: 0.75,
}),
});
let mut app = App::with_project(project);
send(&mut app, Message::RevealInDiagram(diagram, relation));
(app, diagram, relation, f.a)
}
#[test]
fn state_bend_commands_preserve_other_fields_views_and_exact_history() {
let (mut app, diagram, relation, _) = setup();
let baseline = app.document.snapshot();
let points = vec![Position { x: 715., y: 425. }];
let mut expected = app.project().clone();
expected.diagrams.get_mut(&diagram).unwrap().routes[0].waypoints = points.clone();
let context = app.state_context().unwrap();
event(
&mut app,
Event::SetBends {
context,
relation,
waypoints: points.clone(),
},
);
assert_eq!(app.project(), &expected);
assert_source_model_agree(&app);
let changed = app.document.snapshot();
let context = app.state_context().unwrap();
event(
&mut app,
Event::SetBends {
context,
relation,
waypoints: points,
},
);
assert!(
Arc::ptr_eq(&changed, &app.document.snapshot()),
"equal bends must not add history"
);
send(&mut app, Message::Undo);
assert_eq!(app.project(), &baseline.decoded.project);
assert_eq!(app.document.bundle(), &baseline.bundle);
assert!(!app.document.can_undo());
send(&mut app, Message::Redo);
assert_eq!(app.project(), &expected);
assert_eq!(app.document.bundle(), &changed.bundle);
let context = app.state_context().unwrap();
event(&mut app, Event::ClearBends { context, relation });
expected.diagrams.get_mut(&diagram).unwrap().routes[0]
.waypoints
.clear();
assert_eq!(app.project(), &expected);
let cleared = app.document.snapshot();
let context = app.state_context().unwrap();
event(&mut app, Event::ClearBends { context, relation });
assert!(Arc::ptr_eq(&cleared, &app.document.snapshot()));
}
#[test]
fn state_bend_self_loop_add_move_remove_clear_preserves_project_and_source_history() {
let (app, diagram, relation, _) = setup();
let mut project = app.project().clone();
let RelationBody::StateTransition { source, target, .. } =
&mut project.relations.get_mut(&relation).unwrap().body
else {
panic!("State transition fixture")
};
*target = *source;
project.diagrams.get_mut(&diagram).unwrap().routes[0]
.waypoints
.clear();
oa_core::validate_candidate(&project).unwrap();
let mut app = App::with_project(project);
send(&mut app, Message::RevealInDiagram(diagram, relation));
let baseline = app.document.snapshot();
let geometry = state_edit::bend_route(app.project(), diagram, relation).unwrap();
assert!(!geometry.custom_suspended);
assert!(geometry.pins.is_empty());
assert!(
geometry.points.len() >= 4,
"actual external self-loop routing"
);
let segment = 1;
let point = Position {
x: (geometry.points[segment].x + geometry.points[segment + 1].x) / 2.,
y: (geometry.points[segment].y + geometry.points[segment + 1].y) / 2.,
};
// Each operation must alter only the complete Project's waypoint list;
// Undo and Redo must restore exact serialized source, not just geometry.
fn check(app: &mut App, diagram: Id, mutation: Event, points: Vec<Position>) {
let before = app.document.snapshot();
let mut expected = before.decoded.project.clone();
expected.diagrams.get_mut(&diagram).unwrap().routes[0].waypoints = points;
event(app, mutation);
assert_eq!(app.project(), &expected);
assert_source_model_agree(app);
let after = app.document.snapshot();
assert_ne!(after.bundle, before.bundle);
send(app, Message::Undo);
assert_eq!(app.project(), &before.decoded.project);
assert_eq!(app.document.bundle(), &before.bundle);
send(app, Message::Redo);
assert_eq!(app.project(), &expected);
assert_eq!(app.document.bundle(), &after.bundle);
assert_source_model_agree(app);
}
let context = app.state_context().unwrap();
check(
&mut app,
diagram,
Event::AddBend {
context,
relation,
segment: Some((segment, point)),
},
vec![point],
);
let moved = Position {
x: point.x + 45.,
y: point.y - 35.,
};
let context = app.state_context().unwrap();
check(
&mut app,
diagram,
Event::SetBends {
context,
relation,
waypoints: vec![moved],
},
vec![moved],
);
let context = app.state_context().unwrap();
check(
&mut app,
diagram,
Event::RemoveBend {
context,
relation,
index: 0,
},
vec![],
);
assert_eq!(app.project(), &baseline.decoded.project);
send(&mut app, Message::Undo);
let context = app.state_context().unwrap();
check(
&mut app,
diagram,
Event::ClearBends { context, relation },
vec![],
);
assert_eq!(app.project(), &baseline.decoded.project);
assert_source_model_agree(&app);
// Clear is a new source-preserving edit and may retain empty-list trivia.
// Exact original bytes are restored by history, not by reauthoring a value.
for _ in 0..3 {
assert!(app.document.can_undo());
send(&mut app, Message::Undo);
}
assert_eq!(app.project(), &baseline.decoded.project);
assert_eq!(app.document.bundle(), &baseline.bundle);
assert!(!app.document.can_undo());
}
#[test]
fn state_bend_insertion_uses_captured_traversal_segment_and_removal_only_changes_pins() {
let (app, diagram, relation, _) = setup();
let context = app.state_context().unwrap();
let p = app.project();
let geometry = state_edit::bend_route(p, diagram, relation).unwrap();
let original = &p.diagrams[&diagram].routes[0];
let reverse = geometry
.points
.windows(2)
.position(|pair| pair[0] == original.waypoints[0] && pair[1] == original.waypoints[1])
.unwrap();
for (segment, expected_index) in [(reverse, 1), (reverse + 1, 2)] {
let point = Position { x: 500., y: -400. };
let planned = state_edit::plan(
p,
context,
&Event::AddBend {
context,
relation,
segment: Some((segment, point)),
},
)
.unwrap();
let actual = prepare_command(p, &planned.command).unwrap().project;
let mut expected = p.clone();
expected.diagrams.get_mut(&diagram).unwrap().routes[0]
.waypoints
.insert(expected_index, point);
assert_eq!(actual, expected);
let removed = state_edit::plan(
&actual,
context,
&Event::RemoveBend {
context,
relation,
index: expected_index,
},
)
.unwrap();
assert_eq!(
prepare_command(&actual, &removed.command).unwrap().project,
*p
);
}
let default = state_edit::plan(
p,
context,
&Event::AddBend {
context,
relation,
segment: None,
},
)
.unwrap();
let Command::SetEdgeRoute { route, .. } = default.command else {
panic!("route command")
};
assert_eq!(route.waypoints.len(), original.waypoints.len() + 1);
assert_eq!(route.source_attachment, original.source_attachment);
assert_eq!(route.target_attachment, original.target_attachment);
assert_eq!(route.label, original.label);
}
#[test]
fn state_bend_stale_nonfinite_capacity_hidden_and_folded_requests_are_inert() {
let (mut app, diagram, relation, composite) = setup();
let context = app.state_context().unwrap();
let baseline = app.document.snapshot();
for mutation in [
Event::SetBends {
context: Context {
epoch: context.epoch + 1,
..context
},
relation,
waypoints: vec![],
},
Event::SetBends {
context,
relation,
waypoints: vec![Position { x: f32::NAN, y: 0. }],
},
Event::SetBends {
context,
relation,
waypoints: vec![Position { x: 0., y: 0. }; 129],
},
Event::AddBend {
context,
relation,
segment: Some((usize::MAX, Position { x: 0., y: 0. })),
},
Event::RemoveBend {
context,
relation,
index: usize::MAX,
},
] {
event(&mut app, mutation);
assert!(Arc::ptr_eq(&baseline, &app.document.snapshot()));
}
let mut p = app.project().clone();
p.diagrams.get_mut(&diagram).unwrap().routes[0].waypoints =
vec![Position { x: 500., y: -400. }; 128];
assert!(
state_edit::plan(
&p,
context,
&Event::AddBend {
context,
relation,
segment: Some((1, Position { x: 500., y: -400. }))
}
)
.is_err()
);
let mut hidden = app.project().clone();
hidden
.diagrams
.get_mut(&diagram)
.unwrap()
.view_options
.hidden_edges
.push(relation);
assert!(state_edit::bend_route(&hidden, diagram, relation).is_none());
assert!(state_edit::plan(&hidden, context, &Event::ClearBends { context, relation }).is_err());
let owner = node(&app, diagram, composite);
let mut folded = app.project().clone();
folded
.diagrams
.get_mut(&diagram)
.unwrap()
.nodes
.get_mut(&owner)
.unwrap()
.collapsed = true;
assert!(state_edit::bend_route(&folded, diagram, relation).is_none());
assert!(state_edit::plan(&folded, context, &Event::ClearBends { context, relation }).is_err());
let mut internal = app.project().clone();
internal.diagrams.get_mut(&diagram).unwrap().routes.clear();
if let RelationBody::StateTransition {
source,
target,
transition_kind,
..
} = &mut internal.relations.get_mut(&relation).unwrap().body
{
*target = *source;
*transition_kind = TransitionKind::Internal;
}
assert!(state_edit::bend_route(&internal, diagram, relation).is_none());
send(
&mut app,
Message::Field("/name".into(), "Unapplied bend owner name".into()),
);
let draft = app.draft.as_ref().unwrap().value.clone();
event(&mut app, Event::ClearBends { context, relation });
assert!(Arc::ptr_eq(&baseline, &app.document.snapshot()));
assert_eq!(app.draft.as_ref().unwrap().value, draft);
}
@@ -114,7 +114,6 @@ 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()
@@ -157,17 +156,11 @@ impl App {
context,
object,
placement,
selection_target,
anchor,
bend,
route_segment,
} => {
let mut request = crate::model_menu::Request::object(object, anchor);
request.canvas = Some(context);
request.placement = placement;
request.selection_target = selection_target.map(Box::new);
request.bend = bend;
request.route_segment = route_segment;
return self.handle(Message::Menu(crate::model_menu::Event::Open(request)));
}
Event::EditTransition(_, id) => {
@@ -179,24 +172,6 @@ impl App {
)));
}
Event::Choose(tool) => {
if let Tool::AdjustAttachment { relation, end } = tool {
if self.behavior.has_pending() || self.draft_dirty() || self.source_dirty() {
self.status =
"Apply or cancel pending edits before adjusting an attachment.".into();
return Task::none();
}
let Some(context) = self.state_context() else {
return Task::none();
};
if state_edit::attachment_node(self.project(), context.diagram, relation, end)
.is_none()
{
return Task::none();
}
self.selected = relation;
self.selected_node = None;
self.refresh_draft();
}
if !self.apply() {
return Task::none();
}
@@ -235,37 +210,8 @@ impl App {
let Some(context) = self.state_context() else {
return Task::none();
};
if let Event::SetBends { relation, .. } = &mutation
&& *relation != self.selected
{
return Task::none();
}
if let Event::SetAttachment {
relation,
end,
attachment,
..
} = &mutation
{
let existing = self.project().diagrams[&context.diagram]
.routes
.iter()
.find(|route| route.edge == *relation)
.and_then(|route| route.attachment(*end));
if existing == *attachment {
return Task::none();
}
}
match state_edit::plan(self.project(), context, &mutation) {
Ok(plan) => {
if let Command::SetEdgeRoute { diagram, route } = &plan.command
&& crate::route_edit::stored_route(
&self.project().diagrams[diagram],
route.edge,
) == *route
{
return Task::none();
}
if self.execute(plan.command) {
self.behavior.diagram.diagram = Some(plan.diagram);
self.behavior.diagram.placement = plan.placement;
@@ -1,9 +1,5 @@
//! App transaction and draft boundaries for captured state-canvas messages.
use super::*;
#[path = "state_attachment_escape_tests.rs"]
mod attachment_escape;
#[path = "state_bend_tests.rs"]
mod bends;
use crate::behavior::{
self,
state_edit::{self, Context, Endpoint, Event, Tool},
@@ -1,374 +0,0 @@
//! State selection layout and modifier admission on the mounted production App.
use super::*;
use crate::behavior::{
canvas_workspace::tests::{Mounted, key},
state_edit,
};
use crate::diagram_usage::Target;
use iced::advanced::widget::{Operation, operation};
use iced::{Point, Rectangle, Size, Vector, keyboard, mouse};
struct Fixture {
app: App,
diagram: Id,
a: Id,
b: Id,
a_element: Id,
b_element: Id,
edge: Id,
action: Id,
}
fn fixture() -> Fixture {
let mut p = Project::empty();
let machine = p
.add_element(Kind::StateMachine, Layer::Oa, "Operating modes", None)
.unwrap();
let region = p
.add_element(Kind::Region, Layer::Oa, "Main region", Some(machine))
.unwrap();
let a_element = p
.add_element(Kind::State, Layer::Oa, "First state", Some(region))
.unwrap();
let b_element = p
.add_element(
Kind::State,
Layer::Oa,
&format!("Second state {}", "with a long primary name ".repeat(20)),
Some(region),
)
.unwrap();
let work = p
.add_element(Kind::Activity, Layer::Oa, "Reusable work", None)
.unwrap();
let action = p.relate(RelationKind::StateAction, "On entry", a_element, work);
let edge = p.relate(
RelationKind::StateTransition,
"Next state",
a_element,
b_element,
);
let diagram = p.ensure_diagram(Layer::Oa);
p.diagrams.get_mut(&diagram).unwrap().projection = ProjectionKind::StateMachine { machine };
let mut placements = vec![];
// All State elements, including machine/Region, are initially placed.
// A fixture already missing its containers would not test picker insertion.
for (element, x, y, width, height) in [
(machine, 0., 0., 800., 500.),
(region, 20., 70., 760., 410.),
(a_element, 60., 160., 230., 180.),
(b_element, 430., 160., 260., 180.),
] {
let node = p.place(diagram, element, Position { x, y });
p.diagrams
.get_mut(&diagram)
.unwrap()
.nodes
.get_mut(&node)
.unwrap()
.size = oa_core::Size { width, height };
placements.push(node);
}
let a = placements[2];
let b = placements[3];
validate_candidate(&p).unwrap();
assert!(state_edit::unplaced(&p, diagram).is_empty());
let mut app = App::with_project(p);
app.page = Page::Behavior;
app.layer = Layer::Oa;
app.behavior.active_owner = Some(machine);
app.behavior.diagram = crate::behavior::state_view::State {
diagram: Some(diagram),
fit: true,
..Default::default()
};
app.selected = diagram;
app.selected_node = None;
app.window_size = Size::new(1442., 932.);
app.refresh_draft();
app.reconcile_multiselect();
assert_eq!(app.state_context().unwrap().diagram, diagram);
Fixture {
app,
diagram,
a,
b,
a_element,
b_element,
edge,
action,
}
}
fn owner(mounted: &mut Mounted, app: &App) -> (Rectangle, bool) {
let geometry = mounted.geometry(app);
let (_, bounds, focused) = geometry
.focused
.into_iter()
.find(|(id, _, _)| {
id.as_ref() == Some(&iced::advanced::widget::Id::from("diagram-keyboard-owner"))
})
.expect("real State focus::Owner rectangle must be measurable");
(bounds, focused)
}
fn camera(app: &App) -> (f32, Vector, bool, Option<Size>) {
let state = &app.behavior.diagram;
(state.zoom, state.pan, state.fit, state.viewport)
}
async fn selection(mounted: &mut Mounted, app: &mut App, target: Option<Target>) {
let context = app.multiselect_context().unwrap();
let event = match target {
Some(target) => multiselect::Event::Choose(context, target, multiselect::Choice::Toggle),
None => multiselect::Event::Clear(context),
};
let task = app.update(Message::DiagramSelection(event));
mounted.perform(app, task).await;
mounted.settle(app).await;
}
fn node_point(mounted: &mut Mounted, f: &Fixture, placement: Id) -> Point {
let bounds = owner(mounted, &f.app).0;
let node = &f.app.project().diagrams[&f.diagram].nodes[&placement];
let state = &f.app.behavior.diagram;
Point::new(
bounds.x + (node.position.x + 40.) * state.zoom + state.pan.x,
bounds.y + (node.position.y + 25.) * state.zoom + state.pan.y,
)
}
fn modifiers(shift: bool) -> iced::Event {
iced::Event::Keyboard(keyboard::Event::ModifiersChanged(if shift {
keyboard::Modifiers::SHIFT
} else {
keyboard::Modifiers::default()
}))
}
struct FocusField(Rectangle);
impl Operation for FocusField {
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn Operation)) {
visit(self);
}
fn focusable(
&mut self,
_: Option<&iced::advanced::widget::Id>,
bounds: Rectangle,
state: &mut dyn operation::Focusable,
) {
if bounds == self.0 {
state.focus();
} else {
state.unfocus();
}
}
}
#[tokio::test]
async fn mounted_state_selection_and_missing_picker_never_resize_or_refit_the_canvas() {
let mut f = fixture();
let mut mounted = Mounted::new(&mut f.app).await;
let baseline = f.app.document.snapshot();
let bounds = owner(&mut mounted, &f.app).0;
let fitted = camera(&f.app);
assert!(
fitted.2,
"keep Fit active to expose any resize-triggered camera jump"
);
assert!(fitted.3.is_some());
assert!(bounds.width > 400. && bounds.height > 200.);
assert!(state_edit::unplaced(f.app.project(), f.diagram).is_empty());
assert!(f.app.multiselect_snapshot().unwrap().targets().is_empty());
for (target, expected) in [
(Some(Target::Placement(f.a)), vec![Target::Placement(f.a)]),
(
Some(Target::Placement(f.b)),
vec![Target::Placement(f.a), Target::Placement(f.b)],
),
(
Some(Target::Edge(f.edge)),
vec![
Target::Placement(f.a),
Target::Placement(f.b),
Target::Edge(f.edge),
],
),
(
Some(Target::Edge(f.edge)),
vec![Target::Placement(f.a), Target::Placement(f.b)],
),
(
Some(Target::StateRow {
placement: f.a,
relation: f.action,
}),
vec![
Target::Placement(f.a),
Target::Placement(f.b),
Target::StateRow {
placement: f.a,
relation: f.action,
},
],
),
(None, vec![]),
] {
selection(&mut mounted, &mut f.app, target).await;
assert_eq!(f.app.multiselect_snapshot().unwrap().targets(), expected);
assert_eq!(
owner(&mut mounted, &f.app).0,
bounds,
"selection strip changed the measured State canvas rectangle"
);
assert_eq!(
camera(&f.app),
fitted,
"selection feedback silently refitted the camera"
);
assert_eq!(f.app.project(), &baseline.decoded.project);
assert_eq!(f.app.document.bundle(), &baseline.bundle);
assert!(!f.app.document.can_undo());
assert!(!f.app.draft_dirty());
}
selection(&mut mounted, &mut f.app, Some(Target::Placement(f.a))).await;
selection(&mut mounted, &mut f.app, Some(Target::Placement(f.b))).await;
let captured = f.app.multiselect_snapshot().unwrap();
let task = f
.app
.update(Message::DiagramSelection(multiselect::Event::Hide(
captured,
)));
mounted.perform(&mut f.app, task).await;
mounted.settle(&mut f.app).await;
let missing: std::collections::BTreeSet<_> = state_edit::unplaced(f.app.project(), f.diagram)
.into_iter()
.collect();
assert_eq!(
missing,
std::collections::BTreeSet::from([f.a_element, f.b_element])
);
let mut expected = baseline.decoded.project.clone();
expected
.diagrams
.get_mut(&f.diagram)
.unwrap()
.nodes
.remove(&f.a);
expected
.diagrams
.get_mut(&f.diagram)
.unwrap()
.nodes
.remove(&f.b);
assert_eq!(f.app.project(), &expected);
assert_eq!(
owner(&mut mounted, &f.app).0,
bounds,
"the first unplaced picker changed canvas bounds"
);
assert_eq!(camera(&f.app), fitted);
let hidden_source = f.app.document.bundle().clone();
let task = f.app.update(Message::Undo);
mounted.perform(&mut f.app, task).await;
mounted.settle(&mut f.app).await;
assert!(state_edit::unplaced(f.app.project(), f.diagram).is_empty());
assert_eq!(f.app.project(), &baseline.decoded.project);
assert_eq!(f.app.document.bundle(), &baseline.bundle);
assert!(!f.app.document.can_undo());
assert_eq!(
owner(&mut mounted, &f.app).0,
bounds,
"removing the unplaced picker changed canvas bounds"
);
assert_eq!(camera(&f.app), fitted);
let task = f.app.update(Message::Redo);
mounted.perform(&mut f.app, task).await;
mounted.settle(&mut f.app).await;
assert_eq!(f.app.project(), &expected);
assert_eq!(f.app.document.bundle(), &hidden_source);
assert_eq!(owner(&mut mounted, &f.app).0, bounds);
assert_eq!(camera(&f.app), fitted);
}
#[tokio::test]
async fn mounted_state_shift_held_in_clean_inspector_toggles_on_first_canvas_press() {
let mut f = fixture();
let mut mounted = Mounted::new(&mut f.app).await;
selection(&mut mounted, &mut f.app, Some(Target::Placement(f.a))).await;
let field = mounted
.geometry(&f.app)
.text_inputs
.into_iter()
.filter(|bounds| bounds.x > f.app.window_size.width - 320.)
.min_by(|a, b| a.y.total_cmp(&b.y))
.expect("State Inspector field is mounted");
mounted.operate(&f.app, &mut FocusField(field));
assert!(!owner(&mut mounted, &f.app).1);
let before = f.app.document.snapshot();
mounted
.batch(&mut f.app, &[modifiers(true)], mouse::Cursor::Unavailable)
.await;
assert!(
!owner(&mut mounted, &f.app).1,
"passive Shift must not acquire State canvas focus"
);
let at = node_point(&mut mounted, &f, f.b);
let messages = mounted.click(&mut f.app, at).await;
assert!(messages.iter().any(|message| matches!(message,
Message::DiagramSelection(multiselect::Event::Choose(_, Target::Placement(id), multiselect::Choice::Toggle)) if *id == f.b
)), "StateCanvas must map its typed selection event to the common App boundary");
assert_eq!(
f.app.multiselect_snapshot().unwrap().targets(),
&[Target::Placement(f.a), Target::Placement(f.b)]
);
assert_eq!(f.app.behavior.diagram.placement, Some(f.b));
assert_eq!(f.app.selected_node, None);
assert_eq!(f.app.project(), &before.decoded.project);
assert_eq!(f.app.document.bundle(), &before.bundle);
assert!(!f.app.document.can_undo());
assert!(!f.app.draft_dirty());
}
#[tokio::test]
async fn mounted_state_busy_suspension_tracks_shift_without_granting_focus_or_commands() {
let mut f = fixture();
let mut mounted = Mounted::new(&mut f.app).await;
selection(&mut mounted, &mut f.app, Some(Target::Placement(f.a))).await;
// Explicitly revoke focus rather than relying on incidental initial focus.
mounted.operate(
&f.app,
&mut FocusField(Rectangle {
x: -1000.,
y: -1000.,
width: 1.,
height: 1.,
}),
);
let before = f.app.document.snapshot();
let at = node_point(&mut mounted, &f, f.b);
f.app.busy = true;
mounted.settle(&mut f.app).await;
mounted
.batch(
&mut f.app,
&[modifiers(true), key(keyboard::key::Named::Delete)],
mouse::Cursor::Unavailable,
)
.await;
mounted.click(&mut f.app, at).await;
assert!(!owner(&mut mounted, &f.app).1);
assert_eq!(
f.app.multiselect_snapshot().unwrap().targets(),
&[Target::Placement(f.a)]
);
assert_eq!(f.app.document.bundle(), &before.bundle);
f.app.busy = false;
mounted.settle(&mut f.app).await;
assert!(!owner(&mut mounted, &f.app).1);
// No second modifier event: the passive suspended update must survive.
mounted.click(&mut f.app, at).await;
assert_eq!(
f.app.multiselect_snapshot().unwrap().targets(),
&[Target::Placement(f.a), Target::Placement(f.b)]
);
assert_eq!(f.app.project(), &before.decoded.project);
assert_eq!(f.app.document.bundle(), &before.bundle);
assert!(!f.app.document.can_undo());
assert!(!f.app.delete_pending);
}
File diff suppressed because it is too large Load Diff
@@ -158,14 +158,6 @@ impl Mounted {
events: &[iced::Event],
cursor: mouse::Cursor,
) -> Vec<Message> {
self.input_with_status(app, events, cursor).0
}
pub fn input_with_status(
&mut self,
app: &App,
events: &[iced::Event],
cursor: mouse::Cursor,
) -> (Vec<Message>, Vec<iced::event::Status>) {
let mut messages = vec![];
let mut ui = UserInterface::build(
app.view(),
@@ -173,7 +165,7 @@ impl Mounted {
std::mem::take(&mut self.cache),
&mut self.renderer,
);
let (_, statuses) = ui.update(
let _ = ui.update(
events,
cursor,
&mut self.renderer,
@@ -181,7 +173,7 @@ impl Mounted {
&mut messages,
);
self.cache = ui.into_cache();
(messages, statuses)
messages
}
pub async fn perform(&mut self, app: &mut App, task: iced::Task<Message>) {
let mut ready = vec![];
@@ -15,7 +15,6 @@ 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 {
@@ -84,11 +83,7 @@ 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)
}) {
let modifiers = state.modifiers;
*state = Input {
modifiers,
..Input::default()
};
*state = Input::default();
}
if state
.scope
@@ -148,29 +143,10 @@ 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(_)) {
@@ -358,20 +334,6 @@ 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,
@@ -3,58 +3,6 @@ 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};
@@ -1,656 +0,0 @@
// Included in state_canvas::tests: exercise the real canvas event path.
fn bend_fixture(authored: bool) -> (Fixture, Id) {
let mut f = fixture();
let relation = f.project.relate(
RelationKind::StateTransition,
"Standby to stopped",
f.b,
f.c,
);
if authored {
f.project
.diagrams
.get_mut(&f.diagram)
.unwrap()
.routes
.push(EdgeRoute {
edge: relation,
waypoints: vec![Position {
x: 650.25,
y: 340.75,
}],
source_attachment: Some(EdgeAttachment {
side: PortAnchorSide::Bottom,
offset: 0.5,
}),
target_attachment: Some(EdgeAttachment {
side: PortAnchorSide::Top,
offset: 0.25,
}),
label: Some(EdgeLabel {
fraction: 0.43,
offset: Position { x: 28., y: -16. },
}),
});
}
validate_candidate(&f.project).unwrap();
(f, relation)
}
fn bend_segment(scene: &Scene, relation: Id) -> (usize, Point) {
let edge = &scene.routed.edges[&relation];
edge.points
.windows(2)
.enumerate()
.filter(|(i, _)| *i > 0 && *i + 2 < edge.points.len())
.map(|(i, p)| {
(
i,
Point::new((p[0].x + p[1].x) / 2., (p[0].y + p[1].y) / 2.),
(p[1].x - p[0].x).abs() + (p[1].y - p[0].y).abs(),
)
})
.filter(|(_, p, length)| {
*length > 40. && edge.pins.iter().all(|pin| p.distance(point(*pin)) > 20.)
})
.max_by(|a, b| a.2.total_cmp(&b.2))
.map(|(i, p, _)| (i, p))
.expect("fixture has an exposed interior route segment")
}
fn bend_press(canvas: &StateCanvas<'_>, state: &mut State, at: Point) {
motion(canvas, state, at, at);
let event = input(
canvas,
state,
mouse::Event::ButtonPressed(mouse::Button::Left),
at,
);
assert!(
!matches!(event, Some(Event::SetBends { .. })),
"press alone cannot commit"
);
}
#[test]
fn state_bends_segment_drag_inserts_one_pin_using_ordered_pointer_at_three_zooms() {
for zoom in [0.5, 1., 2.] {
let (f, relation) = bend_fixture(false);
let mut canvas = canvas(&f, Tool::Select, zoom);
canvas.selected = relation;
let mut state = State::default();
let baseline = canvas.scene(&state).unwrap();
let (_, midpoint) = bend_segment(&baseline, relation);
let start = screen(&canvas, midpoint);
let destination = Position {
x: ((midpoint.x + 45.) / 5.).round() * 5.,
y: ((midpoint.y + 30.) / 5.).round() * 5.,
};
let release = screen(&canvas, point(destination));
let later_batch_cursor = release + Vector::new(137., 83.);
motion(&canvas, &mut state, start, later_batch_cursor);
input(
&canvas,
&mut state,
mouse::Event::ButtonPressed(mouse::Button::Left),
later_batch_cursor,
);
assert!(f.project.diagrams[&f.diagram].routes.is_empty());
motion(&canvas, &mut state, release, later_batch_cursor);
assert_eq!(
state.bend_preview.as_ref().unwrap().waypoints,
vec![destination]
);
assert_eq!(
canvas.scene(&state).unwrap().routed.edges[&relation].pins,
vec![destination]
);
let event = input(
&canvas,
&mut state,
mouse::Event::ButtonReleased(mouse::Button::Left),
later_batch_cursor,
)
.unwrap();
assert!(
matches!(&event, Event::SetBends { context, relation: id, waypoints }
if *context == canvas.context && *id == relation && *waypoints == vec![destination])
);
assert!(state.bend_preview.is_none());
assert!(
input(
&canvas,
&mut state,
mouse::Event::ButtonReleased(mouse::Button::Left),
later_batch_cursor
)
.is_none()
);
let after = prepare_command(
&f.project,
&state_edit::plan(&f.project, canvas.context, &event)
.unwrap()
.command,
)
.unwrap()
.project;
let stored = &after.diagrams[&f.diagram].routes[0];
assert_eq!(stored.waypoints, vec![destination]);
assert!(
stored.source_attachment.is_none()
&& stored.target_attachment.is_none()
&& stored.label.is_none()
);
assert_eq!(after.relations, f.project.relations);
}
}
#[test]
fn state_bends_authored_pin_move_preserves_full_project_and_exact_undo_redo() {
for zoom in [0.5, 1., 2.] {
let (mut f, relation) = bend_fixture(true);
let mut other = f.project.diagrams[&f.diagram].clone();
other.id = Id::new_v4();
other.alias = "bend_reference_view".into();
other.nodes = other
.nodes
.into_values()
.map(|mut node| {
node.id = Id::new_v4();
node.alias = format!("bend_reference_{}", node.alias);
(node.id, node)
})
.collect();
let other_id = other.id;
f.project.diagrams.insert(other_id, other.clone());
validate_candidate(&f.project).unwrap();
let original = f.project.diagrams[&f.diagram].routes[0].clone();
let mut canvas = canvas(&f, Tool::Select, zoom);
canvas.selected = relation;
let mut state = State::default();
let start = screen(&canvas, point(original.waypoints[0]));
let destination = Position { x: 690., y: 365. };
let release = screen(&canvas, point(destination));
bend_press(&canvas, &mut state, start);
motion(&canvas, &mut state, release, release);
let preview = state.bend_preview.as_ref().unwrap();
let mut intended = original.clone();
intended.waypoints[0] = destination;
assert_eq!(preview, &intended);
assert_eq!(
f.project.diagrams[&f.diagram].routes[0], original,
"preview is transient"
);
let preview_scene = canvas.scene(&state).unwrap();
let event = input(
&canvas,
&mut state,
mouse::Event::ButtonReleased(mouse::Button::Left),
release,
)
.unwrap();
assert!(
matches!(&event, Event::SetBends { waypoints, .. } if waypoints == &intended.waypoints)
);
let plan = state_edit::plan(&f.project, canvas.context, &event).unwrap();
let mut expected = f.project.clone();
expected.diagrams.get_mut(&f.diagram).unwrap().routes[0] = intended;
let mut document = Document::from_project(f.project.clone(), Default::default()).unwrap();
let before = document.bundle().clone();
document.execute(plan.command).unwrap();
assert_eq!(document.project(), &expected);
assert_eq!(document.project().diagrams[&other_id], other);
let committed_view = projection::project_view(document.project(), f.diagram);
let committed = routing::route_diagram(
document.project(),
&document.project().diagrams[&f.diagram],
&committed_view,
);
let preview_edge = &preview_scene.routed.edges[&relation];
let committed_edge = &committed.edges[&relation];
assert_eq!(preview_edge.points, committed_edge.points);
assert_eq!(preview_edge.pin_fractions, committed_edge.pin_fractions);
assert_eq!(preview_edge.warnings, committed_edge.warnings);
let edited = document.bundle().clone();
assert!(document.undo());
assert_eq!(document.project(), &f.project);
assert_eq!(document.bundle(), &before);
assert!(!document.undo(), "one drag adds one history operation");
assert!(document.redo());
assert_eq!(document.project(), &expected);
assert_eq!(document.bundle(), &edited);
assert!(!document.redo());
}
}
#[test]
fn state_bends_stationary_and_out_and_back_restore_off_grid_or_automatic_route() {
for authored in [false, true] {
for zoom in [0.5, 1., 2.] {
for travel in [false, true] {
let (f, relation) = bend_fixture(authored);
let mut canvas = canvas(&f, Tool::Select, zoom);
canvas.selected = relation;
let mut state = State::default();
let scene = canvas.scene(&state).unwrap();
let world = if authored {
point(scene.routed.edges[&relation].pins[0])
} else {
bend_segment(&scene, relation).1
};
let start = screen(&canvas, world);
bend_press(&canvas, &mut state, start);
if travel {
let away = start + Vector::new(45., 30.) * zoom;
motion(&canvas, &mut state, away, away);
assert!(state.bend_preview.is_some());
motion(&canvas, &mut state, start, start);
}
assert!(
input(
&canvas,
&mut state,
mouse::Event::ButtonReleased(mouse::Button::Left),
start
)
.is_none()
);
assert!(state.bend_preview.is_none());
assert_eq!(
canvas.scene(&state).unwrap().routed.edges[&relation].points,
scene.routed.edges[&relation].points
);
assert_eq!(
f.project.diagrams[&f.diagram].routes.len(),
usize::from(authored)
);
if authored {
assert_eq!(
f.project.diagrams[&f.diagram].routes[0].waypoints,
vec![Position {
x: 650.25,
y: 340.75
}]
);
}
}
}
}
}
#[test]
fn state_bends_cancel_on_camera_context_focus_and_pointer_interruptions() {
for cancellation in 0..16 {
let (f, relation) = bend_fixture(true);
let mut canvas = canvas(&f, Tool::Select, 1.);
canvas.selected = relation;
let mut state = State::default();
let original = f.project.diagrams[&f.diagram].routes[0].clone();
let start = screen(&canvas, point(original.waypoints[0]));
let release = start + Vector::new(45., 30.);
bend_press(&canvas, &mut state, start);
motion(&canvas, &mut state, release, release);
assert!(state.bend_preview.is_some(), "case {cancellation}");
let mut release_bounds = bounds();
match cancellation {
0 => canvas.zoom = 2.,
1 => canvas.pan.x += 10.,
2 => canvas.context.epoch += 1,
3 => canvas.context.revision += 1,
4 => canvas.context.document = Id::new_v4(),
5 => canvas.tool = Tool::Transition,
6 => canvas.editable = false,
7 | 8 => {
input(
&canvas,
&mut state,
mouse::Event::ButtonPressed(if cancellation == 7 {
mouse::Button::Right
} else {
mouse::Button::Middle
}),
release,
);
}
9 => {
canvas::Program::update(
&canvas,
&mut state,
&canvas::Event::Window(iced::window::Event::Unfocused),
bounds(),
mouse::Cursor::Available(release),
);
}
10 => {
input(
&canvas,
&mut state,
mouse::Event::WheelScrolled {
delta: mouse::ScrollDelta::Lines { x: 0., y: 1. },
},
release,
);
}
11 => {
input(&canvas, &mut state, mouse::Event::CursorLeft, release);
}
12 => {
motion(&canvas, &mut state, Point::new(-100., -100.), release);
}
13 => release_bounds.x += 25.,
15 => canvas.selected = f.b,
_ => {
let escape = canvas::Event::Keyboard(keyboard::Event::KeyPressed {
key: keyboard::Key::Named(keyboard::key::Named::Escape),
modified_key: keyboard::Key::Named(keyboard::key::Named::Escape),
physical_key: keyboard::key::Physical::Code(keyboard::key::Code::Escape),
location: keyboard::Location::Standard,
modifiers: keyboard::Modifiers::empty(),
text: None,
repeat: false,
});
let event = canvas::Program::update(
&canvas,
&mut state,
&escape,
bounds(),
mouse::Cursor::Available(release),
)
.and_then(|action| action.into_inner().0);
assert!(matches!(event, Some(Event::Choose(Tool::Select))));
}
}
let event = input_at_bounds(
&canvas,
&mut state,
mouse::Event::ButtonReleased(mouse::Button::Left),
release,
release_bounds,
);
assert!(
!matches!(event, Some(Event::SetBends { .. })),
"late release committed case {cancellation}"
);
assert!(
state.bend_preview.is_none(),
"stale preview survived case {cancellation}"
);
assert_eq!(f.project.diagrams[&f.diagram].routes[0], original);
}
}
#[test]
fn state_bends_context_menu_distinguishes_authored_pin_from_computed_segment() {
let (f, relation) = bend_fixture(true);
let mut canvas = canvas(&f, Tool::Select, 1.);
canvas.selected = relation;
let mut state = State::default();
let scene = canvas.scene(&state).unwrap();
let pin = screen(&canvas, point(scene.routed.edges[&relation].pins[0]));
motion(&canvas, &mut state, pin, pin);
assert!(
matches!(input(&canvas, &mut state, mouse::Event::ButtonPressed(mouse::Button::Right), pin),
Some(Event::ContextMenu { context, object, placement: None, anchor, bend: Some(0), route_segment: None, .. })
if context == canvas.context && object == relation && anchor == pin)
);
let (index, midpoint) = bend_segment(&scene, relation);
let at = screen(&canvas, midpoint);
motion(&canvas, &mut state, at, at);
assert!(
matches!(input(&canvas, &mut state, mouse::Event::ButtonPressed(mouse::Button::Right), at),
Some(Event::ContextMenu { context, object, placement: None, anchor, bend: None, route_segment: Some((segment, world)), .. })
if context == canvas.context && object == relation && anchor == at && segment == index && point(world).distance(midpoint) < 0.001)
);
}
#[test]
fn state_bends_internal_rows_and_folded_proxies_cannot_acquire_pins() {
for internal in [false, true] {
let mut f = fixture();
let relation = if internal {
let id = f
.project
.relate(RelationKind::StateTransition, "Internal check", f.a, f.a);
if let RelationBody::StateTransition {
transition_kind, ..
} = &mut f.project.relations.get_mut(&id).unwrap().body
{
*transition_kind = TransitionKind::Internal;
}
id
} else {
let id = f.project.relate(
RelationKind::StateTransition,
"Hidden child to standby",
f.leaf,
f.b,
);
let parent = placement(&f, f.a);
f.project
.diagrams
.get_mut(&f.diagram)
.unwrap()
.nodes
.get_mut(&parent)
.unwrap()
.collapsed = true;
id
};
let mut canvas = canvas(&f, Tool::Select, 1.);
canvas.selected = relation;
let mut state = State::default();
let scene = canvas.scene(&state).unwrap();
let world = if internal {
assert!(!scene.routed.edges.contains_key(&relation));
let node = scene.view.nodes.iter().find(|n| n.element == f.a).unwrap();
internal_rows(&f.project, node)
.into_iter()
.find(|(r, _)| r.id == relation)
.unwrap()
.1
.center()
} else {
assert!(scene.routed.edges[&relation].custom_suspended);
bend_segment(&scene, relation).1
};
let start = screen(&canvas, world);
motion(&canvas, &mut state, start, start);
assert!(matches!(
input(
&canvas,
&mut state,
mouse::Event::ButtonPressed(mouse::Button::Right),
start
),
Some(Event::ContextMenu {
bend: None,
route_segment: None,
..
})
));
bend_press(&canvas, &mut state, start);
let away = start + Vector::new(30., 20.);
motion(&canvas, &mut state, away, away);
assert!(state.bend_preview.is_none());
assert!(!matches!(
input(
&canvas,
&mut state,
mouse::Event::ButtonReleased(mouse::Button::Left),
away
),
Some(Event::SetBends { .. })
));
assert!(f.project.diagrams[&f.diagram].routes.is_empty());
}
}
#[test]
fn state_bends_right_click_discards_provisional_indexes_before_building_menu() {
for click_pin in [true, false] {
let (mut f, relation) = bend_fixture(true);
let route = &mut f.project.diagrams.get_mut(&f.diagram).unwrap().routes[0];
route.waypoints = vec![Position { x: 650., y: 340. }, Position { x: 650., y: 390. }];
validate_candidate(&f.project).unwrap();
let original = f.project.diagrams[&f.diagram].routes[0].clone();
let mut canvas = canvas(&f, Tool::Select, 1.);
canvas.selected = relation;
let mut state = State::default();
let baseline = canvas.scene(&state).unwrap();
let edge = &baseline.routed.edges[&relation];
let candidates: Vec<_> = edge.points.windows(2).enumerate()
.filter(|(i, _)| *i > 0 && *i + 2 < edge.points.len())
.map(|(i, p)| (i, Point::new((p[0].x + p[1].x) / 2., (p[0].y + p[1].y) / 2.)))
.filter(|(_, p)| edge.pins.iter().all(|pin| p.distance(point(*pin)) > 20.))
.filter(|(_, p)| matches!(canvas.hit(&baseline, *p), Some(Hit::Transition(id)) if id == relation))
.filter(|(_, p)| !Rectangle::new(point(edge.label.bounds.position), Size::new(edge.label.bounds.size.width, edge.label.bounds.size.height)).contains(*p))
.collect();
let (_, insertion_at) = candidates
.iter()
.copied()
.find(|(i, p)| routing::pin_insertion_index(edge, *i, position(*p)) == 0)
.expect("exposed segment before both authored pins");
let menu_segment = candidates
.iter()
.copied()
.find(|(_, p)| p.distance(insertion_at) > 30.)
.expect("a distinct committed segment for the menu control");
let start = screen(&canvas, insertion_at);
bend_press(&canvas, &mut state, start);
let away = start + Vector::new(45., 30.);
motion(&canvas, &mut state, away, away);
let preview = state.bend_preview.as_ref().unwrap();
assert_eq!(preview.waypoints.len(), 3);
assert_eq!(
&preview.waypoints[1..],
original.waypoints.as_slice(),
"provisional insertion shifts both stored indices"
);
// With the left gesture still active, moving to the last stored pin also
// moves provisional pin0 there. The preview's first hit is therefore
// pin0, but a menu built after cancellation must identify stored pin1.
let menu_world = if click_pin {
point(original.waypoints[1])
} else {
menu_segment.1
};
let menu_window = screen(&canvas, menu_world);
motion(&canvas, &mut state, menu_window, menu_window);
assert!(state.bend_preview.is_some());
let menu = input(
&canvas,
&mut state,
mouse::Event::ButtonPressed(mouse::Button::Right),
menu_window,
)
.unwrap();
let Event::ContextMenu {
context,
object,
bend,
route_segment,
..
} = menu
else {
panic!("expected context menu after cancelling drag")
};
assert_eq!(context, canvas.context);
assert_eq!(object, relation);
assert!(state.bend_preview.is_none());
assert_eq!(
canvas.scene(&state).unwrap().routed.edges[&relation].points,
edge.points
);
if click_pin {
assert_eq!(
bend,
Some(1),
"menu index must address the stored route, not the provisional route"
);
assert!(route_segment.is_none());
let remove = Event::RemoveBend {
context,
relation,
index: bend.unwrap(),
};
let plan = state_edit::plan(&f.project, context, &remove).unwrap();
let after = prepare_command(&f.project, &plan.command).unwrap().project;
let mut expected = f.project.clone();
expected.diagrams.get_mut(&f.diagram).unwrap().routes[0]
.waypoints
.remove(1);
assert_eq!(
after, expected,
"remove menu action must delete exactly the addressed original pin"
);
} else {
assert!(
bend.is_none(),
"provisional pin under cursor must not leak into menu"
);
let (segment, at) = route_segment.expect("committed route segment remains actionable");
assert_eq!(segment, menu_segment.0);
assert!(point(at).distance(menu_world) < 0.001);
}
assert!(
input(
&canvas,
&mut state,
mouse::Event::ButtonReleased(mouse::Button::Left),
menu_window
)
.is_none()
);
assert_eq!(f.project.diagrams[&f.diagram].routes[0], original);
}
}
#[test]
fn state_bends_draw_before_update_excludes_preview_after_camera_or_bounds_change() {
for change in 0..4 {
let (f, relation) = bend_fixture(true);
let mut canvas = canvas(&f, Tool::Select, 1.);
canvas.selected = relation;
let mut state = State::default();
let baseline = canvas.scene_with_bounds(&state, Some(bounds())).unwrap();
let start = screen(&canvas, point(baseline.routed.edges[&relation].pins[0]));
let release = start + Vector::new(45., 30.);
bend_press(&canvas, &mut state, start);
motion(&canvas, &mut state, release, release);
let displayed_preview = canvas.scene_with_bounds(&state, Some(bounds())).unwrap();
assert_ne!(
displayed_preview.routed.edges[&relation].pins,
baseline.routed.edges[&relation].pins
);
let mut changed_bounds = bounds();
match change {
0 => canvas.zoom = 2.,
1 => canvas.pan.y += 25.,
2 => changed_bounds.x += 35.,
_ => changed_bounds.width -= 90.,
}
// No update event between camera/layout change and this draw-time read.
let shown = canvas
.scene_with_bounds(&state, Some(changed_bounds))
.unwrap();
assert_eq!(
shown.routed.edges[&relation].pins, baseline.routed.edges[&relation].pins,
"stale draw-time pins for case {change}"
);
assert_eq!(
shown.routed.edges[&relation].points, baseline.routed.edges[&relation].points,
"stale cached preview for case {change}"
);
let event = input_at_bounds(
&canvas,
&mut state,
mouse::Event::ButtonReleased(mouse::Button::Left),
release,
changed_bounds,
);
assert!(!matches!(event, Some(Event::SetBends { .. })));
assert!(state.bend_preview.is_none());
}
}
File diff suppressed because it is too large Load Diff
+2 -244
View File
@@ -20,12 +20,10 @@ pub enum Tool {
Region,
Transition,
Reconnect { relation: Id, endpoint: Endpoint },
AdjustAttachment { relation: Id, end: EdgeEnd },
}
#[derive(Debug, Clone)]
pub enum Event {
Selection(crate::app::multiselect::Event),
Remove(crate::app::diagram_removal::Intent),
Choose(Tool),
CreateView(Id),
@@ -39,10 +37,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)>,
},
CreateAt {
context: Context,
@@ -71,40 +66,13 @@ pub enum Event {
endpoint: Endpoint,
vertex: Id,
},
SetAttachment {
context: Context,
relation: Id,
end: EdgeEnd,
placement: Id,
attachment: Option<EdgeAttachment>,
},
SetBends {
context: Context,
relation: Id,
waypoints: Vec<Position>,
},
AddBend {
context: Context,
relation: Id,
segment: Option<(usize, Position)>,
},
RemoveBend {
context: Context,
relation: Id,
index: usize,
},
ClearBends {
context: Context,
relation: Id,
},
}
impl Event {
pub fn context(&self) -> Option<Context> {
match self {
Self::Remove(intent) => Some(intent.context),
// Group events carry their own exact selection envelope.
Self::Selection(_) | Self::Choose(_) | Self::CreateView(_) => None,
Self::Choose(_) | Self::CreateView(_) => None,
Self::Select(c, _)
| Self::Pan(c, _)
| Self::Zoom(c, _)
@@ -116,12 +84,7 @@ impl Event {
| Self::Place { context: c, .. }
| Self::Move { context: c, .. }
| Self::Connect { context: c, .. }
| Self::Reconnect { context: c, .. }
| Self::SetAttachment { context: c, .. }
| Self::SetBends { context: c, .. }
| Self::AddBend { context: c, .. }
| Self::RemoveBend { context: c, .. }
| Self::ClearBends { context: c, .. } => Some(*c),
| Self::Reconnect { context: c, .. } => Some(*c),
}
}
}
@@ -286,54 +249,6 @@ pub fn can_reconnect(
.is_ok()
}
/// Only the actual visible transition owner can receive a visual attachment.
/// Folded promotion, semantic ports and internal-transition rows are excluded.
pub fn attachment_node(
project: &Project,
diagram: Id,
relation: Id,
end: EdgeEnd,
) -> Option<projection::ProjectedNode> {
machine(project, diagram).ok()?;
let relation_record = project.relations.get(&relation)?;
if relation_record.kind() != RelationKind::StateTransition
|| oa_core::routing::semantic_port_endpoint(project, relation, end).is_some()
{
return None;
}
let view = projection::project_view(project, diagram);
let edge = view.edges.iter().find(|edge| edge.id == relation)?;
let stored = project.diagrams.get(&diagram)?;
if projection::edge_endpoint_placements(project, stored, relation)?
!= (edge.source, edge.target)
{
return None;
}
let owner = match end {
EdgeEnd::Source => edge.source,
EdgeEnd::Target => edge.target,
};
view.nodes.iter().find(|node| node.id == owner).cloned()
}
/// Actual visible State transition geometry, never an internal row or folded proxy.
pub fn bend_route(
project: &Project,
diagram: Id,
relation: Id,
) -> Option<oa_core::routing::RoutedEdge> {
machine(project, diagram).ok()?;
if project.relations.get(&relation)?.kind() != RelationKind::StateTransition {
return None;
}
let stored = project.diagrams.get(&diagram)?;
let view = projection::project_diagram(project, stored);
let geometry = oa_core::routing::route_diagram(project, stored, &view)
.edges
.remove(&relation)?;
(!geometry.custom_suspended).then_some(geometry)
}
fn checked(project: &Project, plan: Plan) -> Result<Plan, ModelError> {
prepare_command(project, &plan.command)?;
Ok(plan)
@@ -577,82 +492,6 @@ pub fn plan(project: &Project, current: Context, event: &Event) -> Result<Plan,
relation,
None,
),
Event::SetAttachment {
relation,
end,
placement,
attachment,
..
} => {
attachment_node(project, diagram, relation, end)
.filter(|owner| owner.id == placement)
.ok_or_else(|| {
error("Choose the actual visible transition endpoint in this view.")
})?;
let mut route = crate::route_edit::stored_route(&project.diagrams[&diagram], relation);
route.set_attachment(end, attachment);
(Command::SetEdgeRoute { diagram, route }, relation, None)
}
Event::SetBends { relation, .. }
| Event::AddBend { relation, .. }
| Event::RemoveBend { relation, .. }
| Event::ClearBends { relation, .. } => {
let geometry = bend_route(project, diagram, relation)
.ok_or_else(|| error("Choose an actual visible transition route in this view."))?;
let mut route = crate::route_edit::stored_route(&project.diagrams[&diagram], relation);
match event {
Event::SetBends { waypoints, .. } => {
if waypoints.len() > 128 {
return Err(error("A route supports at most 128 bends."));
}
for &point in waypoints {
finite(point)?;
}
route.waypoints = waypoints.clone();
}
Event::AddBend { segment, .. } => {
let (segment, point) = segment
.or_else(|| {
geometry
.points
.windows(2)
.enumerate()
.filter(|(index, _)| {
*index > 0 && index + 2 < geometry.points.len()
})
.max_by(|(_, a), (_, b)| {
let length = |s: &[Position]| {
(s[1].x - s[0].x).abs() + (s[1].y - s[0].y).abs()
};
length(a).total_cmp(&length(b))
})
.map(|(index, span)| {
(
index,
Position {
x: (span[0].x + span[1].x) / 2.,
y: (span[0].y + span[1].y) / 2.,
},
)
})
})
.ok_or_else(|| error("This route has no interior segment for a bend."))?;
crate::route_edit::insert_pin_on_segment(&mut route, &geometry, segment, point)
.ok_or_else(|| {
error("The bend segment is stale, invalid, or at capacity.")
})?;
}
Event::RemoveBend { index, .. } => {
if *index >= route.waypoints.len() {
return Err(error("This bend no longer exists."));
}
route.waypoints.remove(*index);
}
Event::ClearBends { .. } => route.waypoints.clear(),
_ => unreachable!(),
}
(Command::SetEdgeRoute { diagram, route }, relation, None)
}
_ => {
return Err(error(
"Handle this navigation or tool request without a model command.",
@@ -741,85 +580,6 @@ mod tests {
plan.diagram
}
#[test]
fn attachment_reset_preserves_other_end_pins_label_and_rejects_ineligible_owners() {
let mut f = fixture();
let diagram = create(&mut f);
let c = context(diagram);
let edge = f.project.relate(
RelationKind::StateTransition,
"Leave work",
f.leaf,
f.target,
);
let attachment = EdgeAttachment {
side: PortAnchorSide::Top,
offset: 0.3,
};
let mut route = crate::route_edit::stored_route(&f.project.diagrams[&diagram], edge);
route.source_attachment = Some(attachment);
route.target_attachment = Some(attachment);
route.waypoints.push(Position { x: 700., y: 700. });
// Retain the exact label value alongside the other route components.
route.label = Some(EdgeLabel {
fraction: 0.5,
offset: Position { x: 10., y: 20. },
});
f.project
.diagrams
.get_mut(&diagram)
.unwrap()
.routes
.push(route.clone());
let owner = attachment_node(&f.project, diagram, edge, EdgeEnd::Source).unwrap();
let event = Event::SetAttachment {
context: c,
relation: edge,
end: EdgeEnd::Source,
placement: owner.id,
attachment: None,
};
let accepted = prepare_command(&f.project, &plan(&f.project, c, &event).unwrap().command)
.unwrap()
.project;
route.source_attachment = None;
assert_eq!(accepted.diagrams[&diagram].routes, vec![route]);
assert_eq!(accepted.relations, f.project.relations);
let wrong = Event::SetAttachment {
context: c,
relation: edge,
end: EdgeEnd::Source,
placement: node(&f.project, diagram, f.target),
attachment: Some(attachment),
};
assert!(plan(&f.project, c, &wrong).is_err());
let composite = node(&f.project, diagram, f.composite);
f.project
.diagrams
.get_mut(&diagram)
.unwrap()
.nodes
.get_mut(&composite)
.unwrap()
.collapsed = true;
assert!(attachment_node(&f.project, diagram, edge, EdgeEnd::Source).is_none());
assert!(plan(&f.project, c, &event).is_err());
let internal = f.project.relate(
RelationKind::StateTransition,
"Internal",
f.target,
f.target,
);
if let RelationBody::StateTransition {
transition_kind, ..
} = &mut f.project.relations.get_mut(&internal).unwrap().body
{
*transition_kind = TransitionKind::Internal;
}
assert!(attachment_node(&f.project, diagram, internal, EdgeEnd::Source).is_none());
assert!(attachment_node(&f.project, diagram, internal, EdgeEnd::Target).is_none());
}
#[test]
fn view_initializes_only_its_machine_in_one_source_and_model_history_entry() {
let f = fixture();
@@ -1003,8 +763,6 @@ mod tests {
.routes
.push(EdgeRoute {
edge,
source_attachment: None,
target_attachment: None,
waypoints: vec![pin],
label: None,
});
@@ -161,53 +161,6 @@ pub fn popup(app: &App, opened: &Opened, clean: bool) -> Option<Menu<Message>> {
);
entries.push(item("Edit transition", edit, true));
if let Some(context) = context {
let bend_geometry = state_edit::bend_route(project, context.diagram, id);
let bend_count = bend_geometry.as_ref().map_or(0, |route| route.pins.len());
let can_add = bend_geometry.as_ref().is_some_and(|route| {
bend_count < 128
&& request.route_segment.map_or_else(
|| route.points.len() >= 4,
|(index, point)| {
index
.checked_add(2)
.is_some_and(|end| end <= route.points.len())
&& point.x.is_finite()
&& point.y.is_finite()
},
)
});
entries.push(item(
if request.route_segment.is_some() {
"Add bend here"
} else {
"Add bend"
},
diagram_event(Event::AddBend {
context,
relation: id,
segment: request.route_segment,
}),
can_add,
));
if let Some(index) = request.bend {
entries.push(item(
"Remove bend",
diagram_event(Event::RemoveBend {
context,
relation: id,
index,
}),
bend_geometry.is_some() && index < bend_count,
));
}
entries.push(item(
"Clear bends",
diagram_event(Event::ClearBends {
context,
relation: id,
}),
bend_geometry.is_some() && bend_count > 0,
));
let visible = projection::project_view(project, context.diagram)
.edges
.iter()
@@ -225,44 +178,6 @@ pub fn popup(app: &App, opened: &Opened, clean: bool) -> Option<Menu<Message>> {
visible,
));
}
for (caption, reset, end) in [
(
"Adjust source attachment",
"Reset source attachment",
EdgeEnd::Source,
),
(
"Adjust target attachment",
"Reset target attachment",
EdgeEnd::Target,
),
] {
let owner = state_edit::attachment_node(project, context.diagram, id, end);
entries.push(item(
caption,
diagram_event(Event::Choose(Tool::AdjustAttachment { relation: id, end })),
owner.is_some(),
));
if let Some(owner) = owner {
let authored = project.diagrams[&context.diagram]
.routes
.iter()
.find(|route| route.edge == id)
.and_then(|route| route.attachment(end))
.is_some();
entries.push(item(
reset,
diagram_event(Event::SetAttachment {
context,
relation: id,
end,
placement: owner.id,
attachment: None,
}),
authored,
));
}
}
if !visible {
entries.push(Entry::title(
"Use transition details for hidden or internal endpoints",
@@ -1,395 +0,0 @@
// 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());
}
}
+4 -51
View File
@@ -41,10 +41,7 @@ pub fn diagram(app: &App, machine: Id) -> Option<Id> {
}
fn event(e: Event) -> Message {
match e {
Event::Selection(selection) => Message::DiagramSelection(selection),
other => Message::StateDiagram(other),
}
Message::StateDiagram(e)
}
pub fn view<'a>(app: &'a App, machine: Id, mut out: Column<'a, Message>) -> Element<'a, Message> {
@@ -146,9 +143,8 @@ 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.
// 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);
let missing_spacing = if missing.is_empty() { 0.0_f32 } else { 8.0 };
let mut missing_controls = Column::new();
if !missing.is_empty() {
let position = Position {
x: (48. - state.pan.x) / state.zoom,
@@ -173,7 +169,6 @@ 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),
@@ -188,7 +183,6 @@ 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,
@@ -197,7 +191,7 @@ pub fn view<'a>(app: &'a App, machine: Id, mut out: Column<'a, Message>) -> Elem
Message::DiagramTraverse,
)
]
.spacing(8)
.spacing(missing_spacing)
.height(Fill),
);
let base: Element<'_, Message> = container(out.spacing(8))
@@ -208,47 +202,6 @@ 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…",
+54 -74
View File
@@ -5,11 +5,8 @@ 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,
@@ -912,53 +909,6 @@ 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,
@@ -1116,7 +1066,6 @@ 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> {
@@ -1135,19 +1084,50 @@ impl<'a> Graph<'a> {
) -> Self {
let context_index = oa_core::topology_context::TopologyIndex::new(p);
let choices = contributions(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 links = sequence_links(p, owner);
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 displayed_order: Vec<_> = choices.iter().map(|choice| choice.id).collect();
let ranks = column_ranks(&displayed_order, &adjacency);
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 mut rows: BTreeMap<usize, f32> = BTreeMap::new();
let mut nodes = vec![];
let mut size: Size = Size::new(640., 220.);
@@ -1258,17 +1238,25 @@ impl<'a> Graph<'a> {
}
let ordered_paths = order
.windows(2)
.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 {
.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 {
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![];
@@ -1412,7 +1400,6 @@ impl<'a> Graph<'a> {
selected,
selected_preview: None,
selected_preview_link: None,
selection: None,
scale: 1.,
}
}
@@ -1531,16 +1518,9 @@ impl canvas::Program<Message> for Graph<'_> {
EdgeLayer::Contextual
}
}) {
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))
},
);
let selected = e.id == self.selected
|| e.source == self.selected
|| e.target == self.selected;
if e.incidence {
f.stroke(
&Path::new(|path| {
@@ -1,7 +1,5 @@
//! 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 {
@@ -447,23 +445,27 @@ 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<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))
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)
}
#[derive(Default)]
@@ -474,8 +476,6 @@ 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,7 +504,6 @@ impl Input {
fn cancel(&mut self) {
self.source = None;
self.press = None;
self.group = None;
}
}
impl Graph<'_> {
@@ -593,9 +592,6 @@ 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)| {
@@ -839,7 +835,6 @@ impl Graph<'_> {
cursor: mouse::Cursor,
bounds: Rectangle,
) {
self.selection_overlay(state, f, bounds);
let Some((context, tool, editable)) = self.interaction else {
return;
};
@@ -940,12 +935,7 @@ impl Graph<'_> {
);
}
}
if tool == CanvasTool::Select
&& self
.selection
.as_ref()
.is_none_or(|s| s.targets().len() == 1)
{
if tool == CanvasTool::Select {
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);
@@ -1,474 +0,0 @@
//! 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;
@@ -1,403 +0,0 @@
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,12 +360,11 @@ 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 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();
let reordered = execute_batch(
&f.p,
&[direct::reorder(&f.p, f.owner, f.nodes[2], f.nodes[0]).unwrap()],
)
.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());
@@ -1,176 +0,0 @@
//! 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;
@@ -1,407 +0,0 @@
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,320 +1,5 @@
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,38 +23,6 @@ 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,8 +66,7 @@ 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.busy && !app.behavior.has_pending() && !app.draft_dirty() && !app.source_dirty();
let clean = !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 {
@@ -184,7 +183,6 @@ 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| {
@@ -197,11 +195,6 @@ 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 {
@@ -262,8 +255,7 @@ 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 = [
@@ -329,7 +321,7 @@ pub(super) fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Ele
)
}),
);
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)))
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)))
.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) {
@@ -559,42 +551,6 @@ 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>,
+12 -232
View File
@@ -22,9 +22,6 @@ use std::{
sync::Arc,
};
#[cfg(test)]
#[path = "diagram/attachment_tests.rs"]
mod attachment_tests;
#[cfg(test)]
#[path = "diagram/lifecycle_tests.rs"]
mod lifecycle_tests;
#[cfg(test)]
@@ -39,17 +36,12 @@ 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>,
@@ -61,15 +53,12 @@ pub struct DiagramCanvas<'a> {
pub pan: Vector,
pub context: Context,
pub selected_bend: Option<usize>,
pub selected_attachment: Option<EdgeEnd>,
pub editable: bool,
pub placing: bool,
pub connection: Option<&'a crate::connection_edit::Session>,
}
#[derive(Default)]
pub struct State {
modifiers: iced::keyboard::Modifiers,
group: Option<multiselect::Press>,
work: Option<Arc<crate::app::allocation_drag::Press>>,
work_cache: RefCell<Option<Arc<Scene>>>,
pointer: Option<Point>,
@@ -78,7 +67,6 @@ pub struct State {
pan_start: Option<(mouse::Button, Point)>,
context: Option<Context>,
route_drag: Option<Gesture>,
attachment_viewport: Option<(f32, Vector, Rectangle)>,
connection_press: Option<(Id, Point)>,
connection_hover: Option<Point>,
port_drag: Option<crate::port_edit::Gesture>,
@@ -306,11 +294,6 @@ impl DiagramCanvas<'_> {
);
request.canvas = Some(self.context);
request.creation_point = Some(position(world));
if self.attachment_at(&scene, world).is_some() {
request.object = self.selected;
request.route_point = Some(position(world));
return request;
}
if let Some(edge) = scene.routed.edges.get(&self.selected)
&& !edge.custom_suspended
&& let Some(index) = edge
@@ -388,39 +371,6 @@ impl DiagramCanvas<'_> {
})
.map(|(id, segment, _)| (id, segment))
}
fn attachment_at<'b>(
&self,
scene: &'b Scene,
world: Point,
) -> Option<(EdgeEnd, &'b ProjectedNode)> {
let edge = scene.routed.edges.get(&self.selected)?;
if edge.custom_suspended {
return None;
}
[
(EdgeEnd::Source, edge.points.first()),
(EdgeEnd::Target, edge.points.last()),
]
.into_iter()
.filter_map(|(end, at)| {
let distance = world.distance(point(*at?));
let owner = route_edit::attachment_node(
self.project,
&self.project.diagrams[&self.diagram],
&scene.view,
self.selected,
end,
)?;
(distance <= 8. / self.zoom).then_some((end, owner, distance))
})
.min_by(|a, b| {
a.2.total_cmp(&b.2).then_with(|| {
(Some(a.0) != self.selected_attachment)
.cmp(&(Some(b.0) != self.selected_attachment))
})
})
.map(|(end, owner, _)| (end, owner))
}
fn hit_label(&self, routed: &RoutedView, p: Point) -> Option<Id> {
let p = self.world(p);
routed
@@ -469,7 +419,6 @@ 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,
@@ -612,31 +561,6 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
bounds: Rectangle,
cursor: mouse::Cursor,
) -> Option<Action<Message>> {
if let canvas::Event::Keyboard(iced::keyboard::Event::ModifiersChanged(modifiers)) = event {
state.modifiers = *modifiers;
return None;
}
if matches!(event, canvas::Event::Window(iced::window::Event::Unfocused)) {
state.modifiers = iced::keyboard::Modifiers::default();
}
// An attachment belongs to the boundary geometry captured at press.
// Camera/layout changes must not reinterpret its pointer in another space.
if matches!(
state.route_drag.as_ref().map(|gesture| &gesture.handle),
Some(Handle::Attachment { .. })
) && (state.attachment_viewport != Some((self.zoom, self.pan, bounds))
|| matches!(
event,
canvas::Event::Window(
iced::window::Event::Unfocused | iced::window::Event::Resized(_)
) | canvas::Event::Mouse(mouse::Event::CursorLeft)
))
{
state.route_drag = None;
state.attachment_viewport = None;
state.pointer = None;
return Some(Action::request_redraw());
}
// A completed work click retires App's allocation epoch before the
// retained canvas sees a redraw. Its already-idle press must not eat
// the first Delete. A live or not-yet-dispatched press stays guarded.
@@ -656,7 +580,6 @@ 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;
@@ -723,9 +646,6 @@ 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) =>
@@ -920,21 +840,6 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
&self.project.diagrams[&self.diagram],
self.selected,
);
if let Some((end, owner)) = self.attachment_at(&scene, world) {
let original = route.attachment(end);
self.begin_route(
state,
world,
Handle::Attachment {
end,
owner: owner.clone(),
original,
},
route,
);
state.attachment_viewport = Some((self.zoom, self.pan, bounds));
return Some(Action::capture());
}
if let Some(index) = edge
.pins
.iter()
@@ -1058,7 +963,7 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
return Some(Action::request_redraw());
}
if let Some(gesture) = &mut state.route_drag {
gesture.move_attachment_to(self.world(p), self.zoom);
gesture.move_to(self.world(p), self.zoom);
return Some(Action::request_redraw().and_capture());
}
if let Some((_, start, original)) = state.drag {
@@ -1135,43 +1040,17 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
return Some(Action::capture());
}
if let Some(mut gesture) = state.route_drag.take() {
if matches!(gesture.handle, Handle::Attachment { .. }) {
state.attachment_viewport = None;
if !cursor.is_over(bounds) {
return Some(Action::request_redraw().and_capture());
}
}
// `local` uses the ordered pointer position at release,
// before any later motion in the same native event batch.
if let Some(p) = local {
gesture.move_attachment_to(self.world(p), self.zoom);
gesture.move_to(self.world(p), self.zoom);
}
if !gesture.changed {
return Some(Action::request_redraw().and_capture());
}
if let Handle::Attachment { end, owner, .. } = &gesture.handle {
if gesture.route
== route_edit::stored_route(
&self.project.diagrams[&self.diagram],
self.selected,
)
{
return Some(Action::request_redraw().and_capture());
}
return Some(
Action::publish(Message::Route(RouteEvent::CommitAttachment(
gesture.context,
gesture.route,
*end,
owner.id,
)))
.and_capture(),
);
}
let bend = match gesture.handle {
Handle::Bend(index) => Some(index),
Handle::Label { .. } => None,
Handle::Attachment { .. } => unreachable!(),
};
return Some(
Action::publish(Message::Route(RouteEvent::Commit(
@@ -1215,15 +1094,6 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
canvas::Event::Mouse(mouse::Event::WheelScrolled { delta })
if cursor.is_over(bounds) =>
{
// Cancel before publishing Zoom: a release may arrive in this
// same event batch, before App applies the new camera.
if matches!(
state.route_drag.as_ref().map(|gesture| &gesture.handle),
Some(Handle::Attachment { .. })
) {
state.route_drag = None;
state.attachment_viewport = None;
}
let y = match delta {
mouse::ScrollDelta::Lines { y, .. } | mouse::ScrollDelta::Pixels { y, .. } => {
*y
@@ -1281,17 +1151,7 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
.collect::<BTreeSet<_>>();
for n in &view.nodes {
if containers.contains(&n.id) {
paint_node(
&mut f,
project,
n,
if self.selection_contains(crate::diagram_usage::Target::Placement(n.id)) {
n.element
} else {
self.selected
},
true,
);
paint_node(&mut f, project, n, self.selected, true);
}
}
let highlighted = highlighted_exchanges(project, self.selected);
@@ -1303,9 +1163,7 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
continue;
};
let end = point(end);
let active = edge.id == self.selected
|| highlighted.contains(&edge.id)
|| self.selection_contains(crate::diagram_usage::Target::Edge(edge.id));
let active = edge.id == self.selected || highlighted.contains(&edge.id);
let color = if active {
style::TEAL
} else if routed
@@ -1401,42 +1259,17 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
}
for n in &view.nodes {
if !containers.contains(&n.id) {
paint_node(
&mut f,
project,
n,
if self.selection_contains(crate::diagram_usage::Target::Placement(n.id)) {
n.element
} else {
self.selected
},
false,
);
paint_node(&mut f, project, n, 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,
if self.selection_contains(crate::diagram_usage::Target::Placement(n.id)) {
n.element
} else {
self.selected
},
true,
);
paint_node(&mut f, project, &header, self.selected, true);
}
}
for port in &view.ports {
let selected = self.selected == port.element
|| self.selection_contains(crate::diagram_usage::Target::Port {
port: port.element,
placement: port.owner,
});
let color = if selected {
let color = if self.selected == port.element {
style::TEAL
} else {
style::kind_color(
@@ -1464,7 +1297,11 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
let mut stroke =
Stroke::default()
.with_color(color)
.with_width(if selected { 2.5 } else { 1.5 });
.with_width(if self.selected == port.element {
2.5
} else {
1.5
});
if glyph == PortGlyph::Legacy {
stroke.line_dash = canvas::LineDash {
segments: &[3., 2.],
@@ -1499,41 +1336,6 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
if let Some(edge) = scene.routed.edges.get(&self.selected)
&& !edge.custom_suspended
{
for (end, at) in [
(EdgeEnd::Source, edge.points.first()),
(EdgeEnd::Target, edge.points.last()),
] {
if let Some(at) = at
&& route_edit::attachment_node(
project,
&project.diagrams[&self.diagram],
view,
self.selected,
end,
)
.is_some()
{
let at = point(*at);
let handle = Path::rectangle(
at - Vector::new(5.5 / self.zoom, 5.5 / self.zoom),
Size::new(11. / self.zoom, 11. / self.zoom),
);
f.fill(
&handle,
if self.selected_attachment == Some(end) {
style::TEAL
} else {
Color::WHITE
},
);
f.stroke(
&handle,
Stroke::default()
.with_color(style::TEAL)
.with_width(1.7 / self.zoom),
);
}
}
for (index, pin) in edge.pins.iter().enumerate() {
let handle = Path::circle(point(*pin), 5.5 / self.zoom);
f.fill(
@@ -2231,7 +2033,6 @@ mod tests {
y: original.y - 200.,
};
let canvas = DiagramCanvas {
selection: None,
project: &p,
view_index: None,
context: Context {
@@ -2241,7 +2042,6 @@ mod tests {
epoch: 0,
},
selected_bend: None,
selected_attachment: None,
editable: true,
placing: false,
connection: None,
@@ -2309,7 +2109,6 @@ mod tests {
.unwrap()
.id;
let canvas = DiagramCanvas {
selection: None,
project: &p,
view_index: None,
context: Context {
@@ -2319,7 +2118,6 @@ mod tests {
epoch: 0,
},
selected_bend: None,
selected_attachment: None,
editable: true,
placing: false,
connection: None,
@@ -2438,7 +2236,6 @@ mod tests {
};
let session = Session::new(&p, &view, context, Tool::FunctionalExchange);
let canvas = DiagramCanvas {
selection: None,
project: &p,
view_index: None,
diagram,
@@ -2446,7 +2243,6 @@ mod tests {
selected: p.id,
navigation_focus: None,
selected_bend: None,
selected_attachment: None,
zoom,
pan: Vector::new(20., 30.),
editable: true,
@@ -2584,8 +2380,6 @@ mod tests {
diagram,
route: EdgeRoute {
edge,
source_attachment: None,
target_attachment: None,
waypoints: vec![Position { x: 400., y: -100. }],
label: None,
},
@@ -2614,7 +2408,6 @@ 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,
@@ -2629,7 +2422,6 @@ mod tests {
epoch: 0,
},
selected_bend: None,
selected_attachment: None,
editable: true,
placing: false,
connection: None,
@@ -2693,7 +2485,6 @@ 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,
@@ -2708,7 +2499,6 @@ mod tests {
epoch: 0,
},
selected_bend: None,
selected_attachment: None,
editable: true,
placing: false,
connection: None,
@@ -2793,7 +2583,6 @@ 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,
@@ -2808,7 +2597,6 @@ mod tests {
epoch: 0,
},
selected_bend: None,
selected_attachment: None,
editable: true,
placing: false,
connection: None,
@@ -2924,7 +2712,6 @@ 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,
@@ -2939,7 +2726,6 @@ mod tests {
epoch: 0,
},
selected_bend: None,
selected_attachment: None,
editable: true,
placing: false,
connection: None,
@@ -2981,7 +2767,6 @@ mod tests {
offset: Position { x: 0., y: -160. },
});
let mut canvas = DiagramCanvas {
selection: None,
project: &p,
view_index: None,
diagram,
@@ -2996,7 +2781,6 @@ mod tests {
epoch: 0,
},
selected_bend: None,
selected_attachment: None,
editable: true,
placing: false,
connection: None,
@@ -3058,7 +2842,6 @@ mod tests {
},
});
let canvas = DiagramCanvas {
selection: None,
project: &p,
view_index: None,
diagram,
@@ -3073,7 +2856,6 @@ mod tests {
epoch: 0,
},
selected_bend: None,
selected_attachment: None,
editable: true,
placing: false,
connection: None,
@@ -3094,7 +2876,6 @@ 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,
@@ -3109,7 +2890,6 @@ mod tests {
epoch: 0,
},
selected_bend: None,
selected_attachment: None,
editable: true,
placing: false,
connection: None,
@@ -1,447 +0,0 @@
use super::*;
use crate::app::{App, Page};
use iced::widget::canvas::Program;
fn fixture() -> App {
let mut p = Project::empty();
let a = p.add(Kind::Activity, "Send", 100., 150.);
let b = p.add(Kind::Activity, "Receive", 650., 150.);
let edge = p.relate(RelationKind::Interaction, "Report", a, b);
let diagram = p.ensure_diagram(Layer::Oa);
p.diagrams
.get_mut(&diagram)
.unwrap()
.routes
.push(EdgeRoute {
edge,
waypoints: vec![Position { x: 450., y: 350. }],
label: Some(EdgeLabel {
fraction: 0.4,
offset: Position { x: 5., y: 10. },
}),
source_attachment: None,
target_attachment: Some(EdgeAttachment {
side: PortAnchorSide::Left,
offset: 0.6,
}),
});
let mut app = App::with_project(p);
app.page = Page::Diagram;
app.active_diagram = Some(diagram);
let _ = app.update(Message::Select(edge));
app
}
fn canvas(app: &App, zoom: f32) -> DiagramCanvas<'_> {
DiagramCanvas {
selection: None,
allocation: None,
project: app.project(),
view_index: None,
diagram: app.active_diagram.unwrap(),
selected: app.selected,
navigation_focus: None,
zoom,
pan: Vector::new(40., 50.),
context: app.route_context().unwrap(),
selected_bend: None,
selected_attachment: app
.selected_attachment
.filter(|(c, e, _)| Some(*c) == app.route_context() && *e == app.selected)
.map(|(_, _, end)| end),
editable: !app.draft_dirty() && !app.source_dirty(),
placing: false,
connection: None,
}
}
fn bounds() -> Rectangle {
Rectangle::new(Point::new(120., 80.), Size::new(2400., 1800.))
}
fn screen(c: &DiagramCanvas<'_>, p: Position) -> Point {
Point::new(
bounds().x + p.x * c.zoom + c.pan.x,
bounds().y + p.y * c.zoom + c.pan.y,
)
}
fn send(
c: &DiagramCanvas<'_>,
state: &mut State,
event: mouse::Event,
final_cursor: Point,
) -> Option<Message> {
c.update(
state,
&canvas::Event::Mouse(event),
bounds(),
mouse::Cursor::Available(final_cursor),
)
.and_then(|a| a.into_inner().0)
}
fn positions(c: &DiagramCanvas<'_>) -> (Point, Point) {
let scene = c.scene(&State::default());
let start = *scene.routed.edges[&c.selected].points.first().unwrap();
let node = route_edit::attachment_node(
c.project,
&c.project.diagrams[&c.diagram],
&scene.view,
c.selected,
EdgeEnd::Source,
)
.unwrap();
let end = oa_core::routing::attachment_point(
c.project,
&c.project.diagrams[&c.diagram],
node,
&EdgeAttachment {
side: PortAnchorSide::Top,
offset: 0.25,
},
);
(screen(c, start), screen(c, end))
}
fn drag(app: &App, zoom: f32) -> Message {
let c = canvas(app, zoom);
let (start, end) = positions(&c);
let mut state = State::default();
let messages = [
mouse::Event::CursorMoved { position: start },
mouse::Event::ButtonPressed(mouse::Button::Left),
mouse::Event::CursorMoved { position: end },
mouse::Event::ButtonReleased(mouse::Button::Left),
]
.into_iter()
.filter_map(|e| send(&c, &mut state, e, end))
.collect::<Vec<_>>();
assert_eq!(messages.len(), 1);
messages.into_iter().next().unwrap()
}
#[test]
fn attachment_event_batches_commit_one_source_history_change_at_three_zooms() {
for zoom in [0.4, 1., 2.] {
let mut app = fixture();
let before = app.document.bundle().clone();
let project = app.project().clone();
let revision = app.document.revision();
let message = drag(&app, zoom);
let Message::Route(RouteEvent::CommitAttachment(_, ref route, EdgeEnd::Source, _)) =
message
else {
panic!("attachment commit required")
};
assert_eq!(
route.source_attachment,
Some(EdgeAttachment {
side: PortAnchorSide::Top,
offset: 0.25
})
);
let intended = route.clone();
let _ = app.update(message);
assert_eq!(app.document.revision(), revision + 1);
let mut expected = project;
route_edit::with_route(
expected
.diagrams
.get_mut(&app.active_diagram.unwrap())
.unwrap(),
intended,
);
assert_eq!(
*app.project(),
expected,
"only the intended view attachment changes"
);
let after = app.document.bundle().clone();
assert_eq!(
oa_core::source_project::compile_bundle(&after, Default::default())
.unwrap()
.decoded
.project,
expected
);
let _ = app.update(Message::Undo);
assert_eq!(app.document.bundle(), &before);
let _ = app.update(Message::Redo);
assert_eq!(app.document.bundle(), &after);
}
}
#[test]
fn attachment_stationary_return_and_escape_leave_source_unchanged() {
let mut app = fixture();
let before = app.document.bundle().clone();
let c = canvas(&app, 1.);
let (start, end) = positions(&c);
let mut state = State::default();
for event in [
mouse::Event::CursorMoved { position: start },
mouse::Event::ButtonPressed(mouse::Button::Left),
mouse::Event::CursorMoved { position: end },
mouse::Event::CursorMoved { position: start },
mouse::Event::ButtonReleased(mouse::Button::Left),
] {
assert!(send(&c, &mut state, event, start).is_none());
}
for event in [
mouse::Event::CursorMoved { position: start },
mouse::Event::ButtonPressed(mouse::Button::Left),
mouse::Event::CursorMoved { position: end },
] {
assert!(send(&c, &mut state, event, end).is_none());
}
let _ = app.update(Message::Escape);
assert!(
send(
&canvas(&app, 1.),
&mut state,
mouse::Event::ButtonReleased(mouse::Button::Left),
end
)
.is_none()
);
assert_eq!(app.document.bundle(), &before);
}
#[test]
fn attachment_camera_layout_focus_and_outside_release_cancel_after_motion() {
for cancel in 0..8 {
let app = fixture();
let before = app.document.bundle().clone();
let mut c = canvas(&app, 1.);
let (start, end) = positions(&c);
let mut state = State::default();
for event in [
mouse::Event::CursorMoved { position: start },
mouse::Event::ButtonPressed(mouse::Button::Left),
mouse::Event::CursorMoved { position: end },
] {
assert!(send(&c, &mut state, event, end).is_none());
}
assert!(state.route_drag.as_ref().is_some_and(|g| g.changed));
let mut current_bounds = bounds();
let mut release = end;
match cancel {
0 => c.zoom = 2.,
1 => c.pan.x += 20.,
2 => current_bounds.x += 20.,
3 => current_bounds.height -= 100.,
4..=6 => {
let event = match cancel {
4 => canvas::Event::Window(iced::window::Event::Unfocused),
5 => canvas::Event::Window(iced::window::Event::Resized(Size::new(800., 600.))),
_ => canvas::Event::Mouse(mouse::Event::CursorLeft),
};
let message = c
.update(
&mut state,
&event,
current_bounds,
mouse::Cursor::Available(end),
)
.and_then(|a| a.into_inner().0);
assert!(message.is_none());
}
_ => {
release = Point::new(-100., -100.);
assert!(
send(
&c,
&mut state,
mouse::Event::CursorMoved { position: release },
release
)
.is_none()
);
}
}
let message = c
.update(
&mut state,
&canvas::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)),
current_bounds,
mouse::Cursor::Available(release),
)
.and_then(|a| a.into_inner().0);
assert!(message.is_none(), "cancellation case {cancel}");
assert!(state.route_drag.is_none(), "cancellation case {cancel}");
assert_eq!(app.document.bundle(), &before);
}
}
#[test]
fn attachment_wheel_and_middle_pan_cancel_before_same_batch_left_release() {
for wheel in [true, false] {
let mut app = fixture();
let before = app.document.bundle().clone();
let revision = app.document.revision();
let c = canvas(&app, 1.);
let (start, end) = positions(&c);
let mut state = State::default();
for event in [
mouse::Event::CursorMoved { position: start },
mouse::Event::ButtonPressed(mouse::Button::Left),
mouse::Event::CursorMoved { position: end },
] {
assert!(send(&c, &mut state, event, end).is_none());
}
let camera_message = send(
&c,
&mut state,
if wheel {
mouse::Event::WheelScrolled {
delta: mouse::ScrollDelta::Lines { x: 0., y: 1. },
}
} else {
mouse::Event::ButtonPressed(mouse::Button::Middle)
},
end,
);
assert_eq!(camera_message.is_some(), wheel);
assert!(state.route_drag.is_none());
// Deliberately retain the same canvas and defer App camera dispatch.
assert!(
send(
&c,
&mut state,
mouse::Event::ButtonReleased(mouse::Button::Left),
end
)
.is_none()
);
if !wheel {
assert!(
send(
&c,
&mut state,
mouse::Event::ButtonReleased(mouse::Button::Middle),
end
)
.is_none()
);
}
if let Some(message) = camera_message {
assert!(matches!(message, Message::Zoom(_)));
let _ = app.update(message);
}
assert_eq!(app.document.revision(), revision);
assert_eq!(app.document.bundle(), &before);
}
}
#[test]
fn attachment_fast_release_uses_release_position_and_stays_in_one_view() {
let mut app = fixture();
let original = app.active_diagram.unwrap();
let _ = app.update(Message::DuplicateDiagram(original));
let other = app.active_diagram.unwrap();
assert_ne!(original, other);
app.active_diagram = Some(original);
let edge = app.project().diagrams[&original].routes[0].edge;
let _ = app.update(Message::Select(edge));
let untouched = app.project().diagrams[&other].clone();
let c = canvas(&app, 0.7);
let (start, end) = positions(&c);
let mut state = State::default();
assert!(
send(
&c,
&mut state,
mouse::Event::ButtonPressed(mouse::Button::Left),
start
)
.is_none()
);
let message = send(
&c,
&mut state,
mouse::Event::ButtonReleased(mouse::Button::Left),
end,
)
.expect("release completes drag without final move");
let _ = app.update(message);
assert_eq!(
app.project().diagrams[&original].routes[0].source_attachment,
Some(EdgeAttachment {
side: PortAnchorSide::Top,
offset: 0.25
})
);
assert_eq!(app.project().diagrams[&other], untouched);
}
#[test]
fn attachment_stale_context_wrong_placement_and_unowned_route_fields_are_rejected() {
let mut app = fixture();
let before = app.document.bundle().clone();
let message = drag(&app, 1.);
let Message::Route(RouteEvent::CommitAttachment(context, route, end, node)) = message else {
panic!()
};
let mut changed = route.clone();
changed.waypoints.clear();
let _ = app.update(Message::Route(RouteEvent::CommitAttachment(
context, changed, end, node,
)));
let _ = app.update(Message::Route(RouteEvent::CommitAttachment(
context,
route.clone(),
end,
Id::new_v4(),
)));
let _ = app.update(Message::Escape);
let _ = app.update(Message::Route(RouteEvent::CommitAttachment(
context, route, end, node,
)));
assert_eq!(app.document.bundle(), &before);
}
#[test]
fn attachment_reset_preserves_opposite_end_pins_label_and_semantics() {
let mut app = fixture();
let before = app.project().clone();
let _ = app.update(Message::Route(RouteEvent::ResetAttachment(EdgeEnd::Target)));
let mut expected = before;
expected
.diagrams
.get_mut(&app.active_diagram.unwrap())
.unwrap()
.routes[0]
.target_attachment = None;
assert_eq!(*app.project(), expected);
let revision = app.document.revision();
let _ = app.update(Message::Route(RouteEvent::ResetAttachment(EdgeEnd::Target)));
assert_eq!(app.document.revision(), revision);
}
#[test]
fn coincident_attachment_handles_honor_explicit_end_choice() {
let mut app = fixture();
let _ = app.update(Message::Route(RouteEvent::ChooseAttachment(
EdgeEnd::Target,
)));
let c = canvas(&app, 1.);
let mut scene = Scene {
work: None,
view: projection::project_view(c.project, c.diagram),
routed: Arc::new(route_diagram(
c.project,
&c.project.diagrams[&c.diagram],
&projection::project_view(c.project, c.diagram),
)),
};
let routed = Arc::make_mut(&mut scene.routed);
let edge = routed.edges.get_mut(&c.selected).unwrap();
let start = *edge.points.first().unwrap();
*edge.points.last_mut().unwrap() = start;
assert_eq!(
c.attachment_at(&scene, point(start)).unwrap().0,
EdgeEnd::Target
);
let mut source = c;
source.selected_attachment = Some(EdgeEnd::Source);
assert_eq!(
source.attachment_at(&scene, point(start)).unwrap().0,
EdgeEnd::Source
);
}
+2 -37
View File
@@ -12,8 +12,6 @@ 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 {
@@ -97,7 +95,6 @@ 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));
}
@@ -146,14 +143,6 @@ 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);
}
@@ -182,14 +171,7 @@ 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.
let passive_modifiers = matches!(
event,
InputEvent::Keyboard(iced::keyboard::Event::ModifiersChanged(_))
);
if self.suspended
&& !passive_modifiers
&& matches!(event, InputEvent::Keyboard(_) | InputEvent::Mouse(_))
{
if self.suspended && matches!(event, InputEvent::Keyboard(_) | InputEvent::Mouse(_)) {
return;
}
if let InputEvent::Keyboard(iced::keyboard::Event::KeyPressed {
@@ -211,30 +193,13 @@ 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() && !passive_modifiers {
if shell.is_event_captured() {
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,
@@ -1,685 +0,0 @@
//! 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,7 +53,6 @@ fn bounds() -> Rectangle {
}
fn canvas(app: &App) -> DiagramCanvas<'_> {
DiagramCanvas {
selection: None,
project: app.project(),
view_index: None,
diagram: app.active_diagram.unwrap(),
@@ -63,7 +62,6 @@ fn canvas(app: &App) -> DiagramCanvas<'_> {
pan: app.pan,
context: app.route_context().unwrap(),
selected_bend: None,
selected_attachment: None,
editable: true,
placing: false,
connection: app.connection.as_ref(),
@@ -1,297 +0,0 @@
use super::group_gesture::{Gesture as GroupGesture, Outcome, Viewport};
use super::*;
use crate::app::multiselect::{Choice, Event, context};
use crate::diagram_selection::Snapshot;
use crate::diagram_usage::Target;
/// Even an unsupported mixed group must retain an ordinary click lifecycle.
pub(super) struct Press {
snapshot: Snapshot,
target: Target,
start: Point,
viewport: Viewport,
movement: Option<GroupGesture>,
reason: Option<String>,
crossed: bool,
retired: bool,
}
impl Press {
fn snapshot(&self) -> &Snapshot {
self.movement
.as_ref()
.map_or(&self.snapshot, GroupGesture::snapshot)
}
fn viewport_matches(&self, viewport: Viewport) -> bool {
!self.retired
&& self.viewport == viewport
&& self
.movement
.as_ref()
.is_none_or(|movement| movement.viewport_matches(viewport))
}
fn crossed_threshold(&self) -> bool {
self.movement
.as_ref()
.map_or(self.crossed, GroupGesture::crossed_threshold)
}
fn delta(&self) -> Position {
self.movement
.as_ref()
.map_or(Position { x: 0., y: 0. }, GroupGesture::delta)
}
fn move_to(&mut self, pointer: Point) {
if self.retired {
return;
}
if !pointer.x.is_finite()
|| !pointer.y.is_finite()
|| !self.viewport.bounds.contains(pointer)
{
self.retired = true;
self.movement = None;
return;
}
self.crossed |= pointer.distance(self.start) > 3.;
if let Some(movement) = &mut self.movement
&& !movement.move_to(pointer)
{
self.retired = true;
}
}
fn release(mut self, pointer: Point) -> Option<Event> {
self.move_to(pointer);
if self.retired {
return None;
}
if !self.crossed {
return Some(Event::Collapse(self.snapshot, self.target));
}
if let Some(reason) = self.reason {
return Some(Event::Refuse(self.snapshot, reason));
}
match self.movement?.release(pointer)? {
Outcome::Click(id) => Some(Event::Collapse(self.snapshot, Target::Placement(id))),
Outcome::Move(snapshot, delta) => Some(Event::Move(snapshot, delta)),
}
}
}
impl DiagramCanvas<'_> {
pub(super) fn selection_contains(&self, target: Target) -> bool {
self.selection
.is_some_and(|s| s.scope() == context(self.context).scope && s.contains(&target))
}
pub(super) fn group_input(
&self,
state: &mut State,
event: &canvas::Event,
bounds: Rectangle,
cursor: mouse::Cursor,
) -> Option<Action<Message>> {
let viewport = Viewport {
zoom: self.zoom,
pan: self.pan,
bounds,
};
if let Some(gesture) = &state.group {
let valid = gesture.viewport_matches(viewport)
&& self.editable
&& self.connection.is_none()
&& !self.placing
&& self
.selection
.is_some_and(|s| s.matches(gesture.snapshot(), context(self.context)));
let cancelled = matches!(
event,
canvas::Event::Window(
iced::window::Event::Unfocused | iced::window::Event::Resized(_)
) | canvas::Event::Mouse(
mouse::Event::CursorLeft
| mouse::Event::WheelScrolled { .. }
| mouse::Event::ButtonPressed(mouse::Button::Right | mouse::Button::Middle)
) | canvas::Event::Keyboard(iced::keyboard::Event::KeyPressed {
key: iced::keyboard::Key::Named(iced::keyboard::key::Named::Escape),
..
})
);
if !valid || cancelled {
state.group = None;
*state.cache.borrow_mut() = None;
return Some(Action::request_redraw().and_capture());
}
}
if let Some(gesture) = &mut state.group {
match event {
canvas::Event::Mouse(mouse::Event::CursorMoved { position }) => {
gesture.move_to(*position);
*state.cache.borrow_mut() = None;
return Some(Action::request_redraw().and_capture());
}
canvas::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => {
let gesture = state.group.take().unwrap();
*state.cache.borrow_mut() = None;
let outcome = cursor
.position()
.and_then(|pointer| gesture.release(pointer));
return Some(match outcome {
Some(event) => {
Action::publish(Message::DiagramSelection(event)).and_capture()
}
None => Action::request_redraw().and_capture(),
});
}
canvas::Event::Keyboard(_) => return Some(Action::capture()),
_ => {}
}
}
let selection = self
.selection
.filter(|s| s.scope() == context(self.context).scope)?;
if state.pan_start.is_some() {
return None;
}
if self.connection.is_some() || self.placing {
return None;
}
if let canvas::Event::Keyboard(iced::keyboard::Event::KeyPressed {
key, modifiers, ..
}) = event
{
use iced::keyboard::{Key, key::Named};
if *key == Key::Named(Named::Escape)
&& state.drag.is_none()
&& state.route_drag.is_none()
&& state.port_drag.is_none()
&& state.work.is_none()
{
return Some(
Action::publish(Message::DiagramSelection(Event::Clear(context(
self.context,
))))
.and_capture(),
);
}
if selection.targets().len() > 1 && *key == Key::Named(Named::Delete) {
let snapshot = selection.snapshot(context(self.context))?;
let action = if modifiers.is_empty() {
Event::Hide(snapshot)
} else {
Event::Refuse(snapshot, "Use Hide selected to remove placements. Group model-deletion review is not available yet.".into())
};
return Some(Action::publish(Message::DiagramSelection(action)).and_capture());
}
}
if !matches!(
event,
canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left))
) || !cursor.is_over(bounds)
{
return None;
}
let pointer = cursor.position()?;
let local = Point::new(pointer.x - bounds.x, pointer.y - bounds.y);
let scene = self.scene(state);
let target = if let Some(port) = self.hit_port(&scene.view, local) {
Some(Target::Port {
port: port.element,
placement: port.owner,
})
} else if let Some(id) = self.hit(&scene.view, local) {
Some(Target::Placement(id))
} else {
self.hit_label(&scene.routed, local)
.or_else(|| self.hit_association_label(&scene.routed, local))
.or_else(|| self.hit_edge(&scene.routed, local).map(|hit| hit.0))
.map(Target::Edge)
}?;
if state.modifiers.shift() {
state.drag = None;
state.preview = None;
state.port_drag = None;
state.route_drag = None;
return Some(
Action::publish(Message::DiagramSelection(Event::Choose(
context(self.context),
target,
Choice::Toggle,
)))
.and_capture(),
);
}
if selection.targets().len() <= 1 || !selection.contains(&target) {
return None;
}
let snapshot = selection.snapshot(context(self.context))?;
if !self.editable {
return Some(Action::capture());
}
let reason = if crate::app::multiselect::placements(&snapshot).is_none() {
Some(
"Move requires only placed elements. Remove ports and connections from the selection.",
)
} else if snapshot
.targets()
.iter()
.any(|t| matches!(t, Target::Placement(id) if self.work_eligible(*id)))
{
Some("Group allocation drag is not available yet. The selection was retained.")
} else {
None
};
let movement = if reason.is_none()
&& let Target::Placement(anchor) = target
{
let original = self.project.diagrams[&self.diagram].nodes[&anchor].position;
GroupGesture::new(snapshot.clone(), anchor, original, pointer, viewport)
} else {
None
};
if movement.is_none() && reason.is_none() {
return Some(Action::capture());
}
state.group = Some(Press {
snapshot,
target,
start: pointer,
viewport,
movement,
reason: reason.map(str::to_owned),
crossed: false,
retired: false,
});
*state.cache.borrow_mut() = None;
Some(Action::request_redraw().and_capture())
}
pub(super) fn group_preview(&self, state: &State, diagram: &mut Diagram) {
let Some(gesture) = &state.group else {
return;
};
if !gesture.crossed_threshold() {
return;
}
let Some(ids) = crate::app::multiselect::placements(gesture.snapshot()) else {
return;
};
let Ok(Command::Batch(commands)) = oa_core::diagram_edit::translate_placements(
self.project,
self.diagram,
&ids,
gesture.delta(),
) else {
return;
};
// Apply only the planner's presentation records. Full model validation
// and source/history updates occur once, when App accepts the release.
for command in commands {
match command {
Command::EditNode { node, .. } => {
diagram.nodes.insert(node.id, node);
}
Command::SetEdgeRoute { route, .. } => route_edit::with_route(diagram, route),
_ => unreachable!("placement planner returned a non-geometry command"),
}
}
}
}
@@ -1,758 +0,0 @@
//! 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,7 +61,6 @@ 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(),
@@ -71,7 +70,6 @@ fn canvas(app: &App, zoom: f32) -> DiagramCanvas<'_> {
pan: app.pan,
context: app.route_context().unwrap(),
selected_bend: None,
selected_attachment: None,
editable: true,
placing: false,
connection: app.connection.as_ref(),
@@ -2,7 +2,6 @@ use super::*;
fn canvas(project: &Project, diagram: Id, zoom: f32) -> DiagramCanvas<'_> {
DiagramCanvas {
selection: None,
project,
view_index: None,
diagram,
@@ -17,7 +16,6 @@ fn canvas(project: &Project, diagram: Id, zoom: f32) -> DiagramCanvas<'_> {
epoch: 0,
},
selected_bend: None,
selected_attachment: None,
editable: true,
placing: false,
connection: None,
@@ -177,7 +177,6 @@ mod tests {
fn program(app: &App) -> PortLabels<'_> {
PortLabels(DiagramCanvas {
selection: None,
project: app.project(),
view_index: None,
diagram: app.active_diagram.unwrap(),
@@ -187,7 +186,6 @@ mod tests {
pan: Vector::ZERO,
context: app.route_context().unwrap(),
selected_bend: None,
selected_attachment: None,
editable: true,
placing: false,
connection: None,
-416
View File
@@ -1,416 +0,0 @@
//! 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()));
}
}
+2 -3
View File
@@ -344,7 +344,7 @@ fn article(topic: Topic) -> Article {
&[
"Select an architecture connection and drag a segment to add a bend. Right-click a line for Add bend here; right-click a circle for Remove bend. With the canvas focused, Insert adds a bend and Delete removes the selected bend. Drag the label independently. Escape cancels a gesture; Undo reverses one completed drag. Shift+F10 opens the selected diagram item's context menu; use arrow keys and Enter to choose an action.",
"Automatic orthogonal routing avoids cards and container headers where space permits. Amber connections need attention: move an obstructing element or a bend. In Select / move, drag a port to any border of its owner. Manual positions are per diagram; right-click Reset automatic position restores connection-aware placement. Ports remain attached to their actual owners. Manual routes are saved separately for each diagram and appear in source, API and SVG export.",
"Select a connection and drag a square endpoint handle along its element boundary to move its attachment in this view without changing what it connects. If handles overlap, right-click the selected connection or use its Actions menu and choose Move source attachment or Move target attachment, then drag the handle. Connections to semantic ports retain their port attachment; move the port instead. Reset source attachment or Reset target attachment restores that end's automatic placement while preserving the other end, pins and label. Reset routing clears all these route overrides in this view. Auto-layout preserves pins. Moving one endpoint keeps pins fixed; moving a container carrying both endpoints translates their pins. When folding promotes an endpoint, expand it again to resume and edit its saved route.",
"Right-click a connection and choose Reset routing to clear its pins and label position in this view. The Properties Actions menu provides the same commands. Auto-layout preserves pins. Moving one endpoint keeps pins fixed; moving a container carrying both endpoints translates their pins. When folding promotes an endpoint, expand it again to resume and edit its saved route.",
],
),
explain(
@@ -564,8 +564,7 @@ fn article(topic: Topic) -> Article {
"Optionally select Create the first state view, then Create and open. The new machine starts without regions or states. For an existing machine, Create diagram places its existing regions and vertices in a saved view; + View creates another independent layout.",
"Choose + Region and click the highlighted machine, or a State/Mode for a nested region. Choose Initial, State, Mode or another kind from + State / symbol, then click its highlighted owning region. Rename the new element in Properties.",
"Choose Transition and drag from a source vertex to a compatible target, or click the source and then the target. In Select / move, drag states or containers to arrange them; a container moves its descendant placements without changing ownership.",
"Select a drawn transition and drag its endpoint handle to reconnect it. Its right-click menu also offers Reconnect source and Reconnect target; choose one, then click a compatible vertex. To change only where a line touches its existing vertex, choose Adjust source attachment or Adjust target attachment, then drag the highlighted tip along the boundary. Reset source attachment or Reset target attachment restores automatic placement for that end without changing the transition's meaning or other route settings. Escape cancels an attachment drag.",
"To shape a selected transition, drag an exposed line segment to add a bend, or drag a square bend handle to move it. Right-click a square and choose Remove bend. The transition's Actions menu also offers Add bend and Clear bends; clearing bends keeps its attachment points and label placement. Bends belong to this saved view. A click alone changes nothing, Escape cancels a drag, and Undo restores the complete edit. Internal transition rows and folded connections do not expose bend editing while their route is hidden.",
"Select a drawn transition and drag its endpoint handle to reconnect it. Its right-click menu also offers Reconnect source and Reconnect target; choose one, then click a compatible vertex.",
"Right-click a state or the background for New transition with details… to create Local, Internal or timed behavior. Right-click an existing transition and choose Edit transition to edit its kind, triggers, guard and effects. EntryPoint outgoing transitions use Local. Apply accepts the complete form; invalid drafts remain available for correction.",
],
),
-1
View File
@@ -10,7 +10,6 @@ mod data_values;
mod data_view;
mod diagnostic_input;
mod diagram;
mod diagram_selection;
mod diagram_usage;
mod document;
mod explorer;
+4 -274
View File
@@ -12,13 +12,10 @@ pub struct Request {
pub anchor: Point,
pub object: Id,
pub placement: Option<Id>,
pub selection_target: Option<Box<crate::diagram_usage::Target>>,
pub bend: Option<usize>,
pub route_point: Option<Position>,
pub route_segment: Option<(usize, Position)>,
pub creation_point: Option<Position>,
pub canvas: Option<route_edit::Context>,
pub process: Option<Box<crate::behavior::topology::Context>>,
pub source_file: Option<String>,
}
impl Request {
@@ -27,13 +24,10 @@ 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,
}
}
@@ -59,14 +53,9 @@ 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;
}
@@ -89,16 +78,10 @@ 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
@@ -107,46 +90,6 @@ 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()
@@ -157,9 +100,7 @@ fn current_canvas(app: &App) -> Option<route_edit::Context> {
fn current_diagram(app: &App) -> Option<Id> {
if app.page == Page::Behavior {
app.topology_context()
.map(|context| context.owner)
.or_else(|| app.state_context().map(|context| context.diagram))
app.state_context().map(|context| context.diagram)
} else {
app.active_diagram
}
@@ -183,23 +124,7 @@ pub fn trigger(app: &App) -> iced::Element<'_, Message> {
request.canvas = app.route_context();
} else if app.page == Page::Behavior {
request.canvas = app.state_context();
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
if let Some(context) = request.canvas
&& let Some(node) = app.project().diagrams[&context.diagram]
.nodes
.values()
@@ -260,150 +185,12 @@ pub fn popup(app: &App) -> Option<Menu<Message>> {
on_close: Message::Menu(Event::Close),
});
}
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)
&& let Some(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"));
}
@@ -453,33 +240,6 @@ pub fn popup(app: &App) -> Option<Menu<Message>> {
.is_none_or(|(s, t)| s == e.source && t == e.target)
});
let route = diagram.and_then(|d| d.routes.iter().find(|r| r.edge == id));
if let Some((diagram, view)) = diagram.zip(view.as_ref()) {
for (end, choose, reset) in [
(
EdgeEnd::Source,
"Move source attachment",
"Reset source attachment",
),
(
EdgeEnd::Target,
"Move target attachment",
"Reset target attachment",
),
] {
let eligible = editable
&& route_edit::attachment_node(p, diagram, view, id, end).is_some();
entries.push(item(
choose,
Message::Route(route_edit::Event::ChooseAttachment(end)),
eligible,
));
entries.push(item(
reset,
Message::Route(route_edit::Event::ResetAttachment(end)),
eligible && route.is_some_and(|route| route.attachment(end).is_some()),
));
}
}
if let Some(point) = request.route_point {
entries.push(
item(
@@ -1140,36 +900,6 @@ mod tests {
assert_eq!(app.document.bundle(), &changed);
}
#[test]
fn attachment_end_choice_is_captured_without_mutating_source_and_stale_menu_is_rejected() {
let mut project = Project::empty();
let source = project.add(Kind::Activity, "Send", 0., 0.);
let target = project.add(Kind::Activity, "Receive", 650., 0.);
let edge = project.relate(RelationKind::Interaction, "Report", source, target);
let mut app = App::with_project(project);
let before = app.document.bundle().clone();
let mut request = Request::object(edge, Point::ORIGIN);
request.canvas = app.route_context();
send(&mut app, Message::Menu(Event::Open(request.clone())));
let action = choose(&app, "Move target attachment");
send(&mut app, action);
assert_eq!(
app.selected_attachment,
Some((app.route_context().unwrap(), edge, EdgeEnd::Target))
);
assert_eq!(app.document.bundle(), &before);
request.canvas = app.route_context();
send(&mut app, Message::Menu(Event::Open(request)));
let stale = choose(&app, "Move source attachment");
send(&mut app, Message::Escape);
send(&mut app, stale);
assert!(
app.selected_attachment
.is_none_or(|(context, _, _)| Some(context) != app.route_context())
);
assert_eq!(app.document.bundle(), &before);
}
#[test]
fn show_from_explorer_places_unfolds_and_keeps_semantic_selection() {
let mut p = Project::empty();
+1 -90
View File
@@ -14,9 +14,6 @@ pub struct Context {
pub enum Event {
SelectBend(Id, usize),
Commit(Context, EdgeRoute, Option<usize>),
CommitAttachment(Context, EdgeRoute, EdgeEnd, Id),
ChooseAttachment(EdgeEnd),
ResetAttachment(EdgeEnd),
AddBend,
AddBendAt(oa_core::Position),
RemoveBend,
@@ -26,14 +23,7 @@ pub enum Event {
#[derive(Debug, Clone)]
pub enum Handle {
Bend(usize),
Label {
original: Position,
},
Attachment {
end: EdgeEnd,
owner: projection::ProjectedNode,
original: Option<EdgeAttachment>,
},
Label { original: Position },
}
#[derive(Debug, Clone)]
@@ -68,57 +58,8 @@ impl Gesture {
};
}
}
Handle::Attachment { .. } => {}
}
}
pub fn move_attachment_to(&mut self, point: Point, zoom: f32) {
let Handle::Attachment {
end,
ref owner,
original,
} = self.handle
else {
self.move_to(point, zoom);
return;
};
if point.distance(self.start) * zoom < 3. {
self.route.set_attachment(end, original);
self.changed = false;
return;
}
self.changed = true;
let attachment = attachment_from_point(
owner,
position(point),
self.route.attachment(end).map(|a| a.side),
6. / zoom,
);
self.route.set_attachment(end, Some(attachment));
}
}
/// The painted endpoint must still be the actual unported placement, not a folded proxy.
pub fn attachment_node<'a>(
project: &Project,
diagram: &Diagram,
view: &'a projection::ProjectedView,
edge: Id,
end: EdgeEnd,
) -> Option<&'a projection::ProjectedNode> {
if semantic_port_endpoint(project, edge, end).is_some() {
return None;
}
let projected = view.edges.iter().find(|e| e.id == edge)?;
let actual = projection::edge_endpoint_placements(project, diagram, edge)?;
if actual != (projected.source, projected.target) {
return None;
}
let placement = match end {
EdgeEnd::Source => projected.source,
EdgeEnd::Target => projected.target,
};
view.nodes.iter().find(|node| node.id == placement)
}
pub fn stored_route(diagram: &Diagram, edge: Id) -> EdgeRoute {
@@ -131,40 +72,10 @@ pub fn stored_route(diagram: &Diagram, edge: Id) -> EdgeRoute {
edge,
waypoints: Vec::new(),
label: None,
source_attachment: None,
target_attachment: None,
})
}
/// Insert after the pins preceding the clicked segment along the routed path.
pub fn insert_pin_on_segment(
route: &mut EdgeRoute,
geometry: &RoutedEdge,
segment: usize,
point: Position,
) -> Option<usize> {
if route.edge != geometry.id
|| geometry.custom_suspended
|| route.waypoints != geometry.pins
|| route.waypoints.len() >= 128
|| !point.x.is_finite()
|| !point.y.is_finite()
|| geometry
.points
.get(segment..segment.checked_add(2)?)
.is_none()
{
return None;
}
let index = pin_insertion_index(geometry, segment, point);
if index > route.waypoints.len() {
return None;
}
route.waypoints.insert(index, point);
Some(index)
}
/// Find a segment when an ordinary caller has only a point, not a captured hit.
pub fn insert_pin(route: &mut EdgeRoute, geometry: &RoutedEdge, point: Position) -> usize {
let segment = geometry
.points
-2
View File
@@ -329,8 +329,6 @@ fn reopening_canvas_for_a_modal_preserves_viewport_after_route_edits() {
diagram,
route: EdgeRoute {
edge,
source_attachment: None,
target_attachment: None,
waypoints: vec![Position { x: 400., y: 400. }],
label: Some(EdgeLabel {
fraction: 0.5,
-2
View File
@@ -112,8 +112,6 @@ mod tests {
});
view.routes.push(EdgeRoute {
edge: exchange,
source_attachment: None,
target_attachment: None,
waypoints: vec![Position { x: 420., y: 440. }],
label: Some(EdgeLabel {
fraction: 0.4,
+4 -91
View File
@@ -455,7 +455,6 @@ 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()),
@@ -467,12 +466,6 @@ fn diagram(app: &App) -> View<'_, Message> {
zoom: app.zoom,
pan: app.pan,
context: app.route_context().unwrap(),
selected_attachment: app
.selected_attachment
.filter(|(context, edge, _)| {
Some(*context) == app.route_context() && *edge == app.selected
})
.map(|(_, _, end)| end),
selected_bend: app
.selected_bend
.filter(|(edge, _)| *edge == app.selected)
@@ -609,37 +602,6 @@ 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 {
@@ -652,71 +614,22 @@ 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 {
"Shift-click to add/remove · right-click for actions · drag to arrange · scroll to zoom"
"Right-click for actions · drag to arrange · scroll to zoom"
};
// 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),
);
// 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));
}
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),
+88 -58
View File
@@ -13,6 +13,7 @@ use iced::{
use oa_core::{transitions::*, *};
use std::collections::BTreeSet;
mod communication;
mod counterparts;
pub(crate) mod recovery;
#[cfg(test)]
mod transition_tests;
@@ -34,6 +35,7 @@ pub enum Event {
Scope(String),
Destination(Choice),
MappingTarget(Id, Choice),
MappingQuery(Id, String),
MappingKind(Id, Kind),
CommunicationPort(Id, bool, communication::PortMenuChoice),
OperationOmission(Id, bool),
@@ -274,6 +276,7 @@ pub struct Transition {
pub request: TransitionRequest,
pub plan: TransitionPlan,
communication_ports: std::collections::BTreeMap<Id, communication::PortDraft>,
counterpart_queries: std::collections::BTreeMap<Id, String>,
}
impl Transition {
fn presentation(&self, p: &Project) -> (Project, Vec<Id>) {
@@ -515,6 +518,7 @@ impl Workflow {
request,
plan,
communication_ports: Default::default(),
counterpart_queries: Default::default(),
})))
}
/// Return a complete accepted candidate only on explicit Apply. Intermediate
@@ -619,6 +623,12 @@ impl Workflow {
_ => {}
},
Self::Transition(d) => {
if let Event::MappingQuery(source, query) = event {
d.counterpart_queries.insert(source, query);
// Searching is presentation only: preserve reviewed decisions,
// preview identities and the exact recovery snapshot.
return Ok(None);
}
match &event {
Event::OperationOmission(source, enabled) => {
d.request
@@ -1148,48 +1158,17 @@ impl Workflow {
content=content.push(text("The SA operation remains unchanged. Correlated replies derive this Call's abstraction.").size(11));
}
let mapping = d.request.mappings.iter().find(|m| m.source == source);
let existing = p
.traces
.values()
.filter_map(|t| {
if d.request.direction == TransitionDirection::OaToSa && t.oa == source
{
Some(t.sa)
} else if d.request.direction == TransitionDirection::SaToOa
&& t.sa == source
{
Some(t.oa)
} else {
None
}
})
.collect::<Vec<_>>();
let target = mapping.map(|m| m.target).unwrap_or_else(|| {
if existing.len() == 1 {
Some(existing[0])
} else {
None
}
let target_choice = counterparts::target(p, source, &d.request);
let target = target_choice.flatten();
let query = d
.counterpart_queries
.get(&source)
.map_or("", String::as_str);
let targets = counterparts::choices(p, source, d.request.direction, query);
let no_matches = targets.len() == 1;
let selected = target_choice.map(|target| {
counterparts::selected(p, source, d.request.direction, target)
});
let mut targets = vec![Choice {
id: Id::nil(),
label: "Create new counterpart".into(),
}];
targets.extend(
p.elements
.keys()
.chain(p.relations.keys())
.filter(|id| {
p.layer_of(**id) == Some(d.request.direction.target_layer())
&& p.elements.contains_key(&source)
== p.elements.contains_key(id)
})
.map(|id| choice(p, *id)),
);
let selected = targets
.iter()
.find(|c| Some(c.id) == target.or(Some(Id::nil())))
.cloned();
let mut item = column![
text(format!(
"{} · {}",
@@ -1200,14 +1179,29 @@ impl Workflow {
.unwrap_or("Relationship")
))
.size(14),
text_input("Find counterpart by name or @alias", query)
.on_input(move |query| Message::Workflow(Event::MappingQuery(
source, query
)))
.size(12)
.padding(8),
pick_list(targets, selected, move |v| Message::Workflow(
Event::MappingTarget(source, v)
))
.placeholder("Choose a counterpart")
.text_size(12)
.padding(8)
.width(Fill)
]
.spacing(7);
if no_matches {
let message = if query.trim().is_empty() {
"No compatible existing counterparts. Create a new one."
} else {
"No matching existing counterparts. Clear the search or create a new one."
};
item = item.push(text(message).size(11));
}
if target.is_none()
&& let Some(e) = p.elements.get(&source)
{
@@ -1413,16 +1407,6 @@ impl Workflow {
.color(style::MUTED),
);
}
content = content.push(
text(format!(
"{} changes · {} required boundary choices · {} unresolved conflicts · {} modeling warnings",
d.plan.changes.len(),
d.plan.conflicts.iter().filter(|c| d.boundary_choice(p, c)).count(),
d.plan.conflicts.iter().filter(|c| !d.boundary_choice(p, c)).count(),
d.plan.warnings.len()
))
.size(12),
);
}
}
let enabled = match self {
@@ -1439,12 +1423,50 @@ impl Workflow {
.color(iced::Color::from_rgb8(178, 54, 58)),
);
}
column![
scrollable(content.padding(iced::Padding {
right: 14.,
..Default::default()
}))
.height(480),
let mut footer = column![].spacing(8);
if let Self::Transition(d) = self {
let boundary_choices = d
.plan
.conflicts
.iter()
.filter(|c| d.boundary_choice(p, c))
.count();
footer = footer.push(
text(format!(
"{} reviewed items · {} boundary choices · {} conflicts · {} warnings",
d.plan.changes.len(),
boundary_choices,
d.plan.conflicts.len() - boundary_choices,
d.plan.warnings.len()
))
.size(12),
);
let guidance = if d.destination_error(p).is_some() {
Some("Choose a compatible diagram in Show result in, above the mappings.".into())
} else if boundary_choices > 0 {
Some("Choose the counterpart kind in each mapping that needs a system boundary decision.".into())
} else if let Some(field) = d.plan.conflicts.iter().find_map(|c| c.field) {
let field = match field {
TransitionField::Name => "Name",
TransitionField::Description => "Description",
};
Some(format!(
"Review {field} below the mappings: choose Keep target, Take source, or merged text."
))
} else if !d.plan.conflicts.is_empty() {
Some("Review the conflict messages below the mappings and complete the required decisions.".into())
} else {
None
};
if let Some(guidance) = guidance {
footer = footer.push(
text(guidance)
.size(12)
.color(iced::Color::from_rgb8(178, 54, 58)),
);
}
}
footer = footer.push(
row![
Space::new().width(Fill),
button(text("Cancel").size(12))
@@ -1464,7 +1486,15 @@ impl Workflow {
.padding(10)
.on_press_maybe(enabled.then_some(Message::Workflow(Event::Apply)))
]
.spacing(10)
.spacing(10),
);
column![
scrollable(content.padding(iced::Padding {
right: 14.,
..Default::default()
}))
.height(480),
footer
]
.spacing(18)
.into()
@@ -0,0 +1,315 @@
//! Search is separate from the committed counterpart mapping.
use super::*;
/// Outer None requires a choice between distinct traced targets; inner None
/// means creation (explicit, or no existing correspondence).
pub(super) fn target(p: &Project, source: Id, request: &TransitionRequest) -> Option<Option<Id>> {
if let Some(mapping) = request.mappings.iter().find(|m| m.source == source) {
return Some(mapping.target);
}
let existing = p
.traces
.values()
.filter_map(|trace| match request.direction {
TransitionDirection::OaToSa if trace.oa == source => Some(trace.sa),
TransitionDirection::SaToOa if trace.sa == source => Some(trace.oa),
_ => None,
})
.collect::<BTreeSet<_>>();
match existing.len() {
0 => Some(None),
1 => Some(existing.first().copied()),
_ => None,
}
}
fn new_choice() -> Choice {
Choice {
id: Id::nil(),
label: "Create new counterpart".into(),
}
}
fn identity(p: &Project, id: Id) -> Option<(&str, &str, String)> {
if let Some(e) = p.elements.get(&id) {
Some((&e.name, &e.alias, e.kind().label().into()))
} else {
p.relations
.get(&id)
.map(|r| (r.name.as_str(), r.alias.as_str(), r.kind().to_string()))
}
}
fn label(p: &Project, id: Id) -> Choice {
let label = match identity(p, id) {
Some((name, alias, kind)) => format!("{} · {kind} · @{alias}", short(name, 48)),
None => format!("Missing counterpart · {id}"),
};
Choice { id, label }
}
pub(super) fn selected(
p: &Project,
source: Id,
direction: TransitionDirection,
target: Option<Id>,
) -> Choice {
let Some(target) = target else {
return new_choice();
};
let mut choice = label(p, target);
if p.contains(target) && !counterpart_compatible(p, source, target, direction) {
choice.label.push_str(" (incompatible)");
}
choice
}
pub(super) fn choices(
p: &Project,
source: Id,
direction: TransitionDirection,
query: &str,
) -> Vec<Choice> {
let query = query.trim().to_lowercase();
let mut candidates = p
.elements
.keys()
.chain(p.relations.keys())
.copied()
.filter(|id| counterpart_compatible(p, source, *id, direction))
.filter_map(|id| {
let (name, alias, _) = identity(p, id)?;
let name = name.to_lowercase();
let alias = alias.to_lowercase();
let matches = if let Some(query) = query.strip_prefix('@') {
alias.contains(query)
} else {
name.contains(&query) || alias.contains(&query)
};
matches.then_some((name, alias, id))
})
.collect::<Vec<_>>();
candidates.sort();
std::iter::once(new_choice())
.chain(candidates.into_iter().map(|(_, _, id)| label(p, id)))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn fixture() -> (Project, Id, Id, Id, Id) {
let mut p = Project::empty();
let source = p
.add_element(Kind::Activity, Layer::Oa, "Assess pressure", None)
.unwrap();
let first = p
.add_element(Kind::Function, Layer::Sa, "Assess pressure", None)
.unwrap();
let second = p
.add_element(Kind::Function, Layer::Sa, "Assess pressure", None)
.unwrap();
let incompatible = p
.add_element(Kind::SystemActor, Layer::Sa, "Assess pressure", None)
.unwrap();
p.elements.get_mut(&first).unwrap().alias = "alpha_assess".into();
p.elements.get_mut(&second).unwrap().alias = "beta_assess".into();
(p, source, first, second, incompatible)
}
fn ids(choices: Vec<Choice>) -> Vec<Id> {
choices.into_iter().map(|c| c.id).collect()
}
#[test]
fn search_disambiguates_names_with_aliases_and_retains_creation() {
let (p, source, first, second, _) = fixture();
let all = choices(&p, source, TransitionDirection::OaToSa, "");
assert_eq!(
all.iter().map(|c| c.id).collect::<Vec<_>>(),
vec![Id::nil(), first, second]
);
assert!(all[1].label.contains("@alpha_assess"));
assert!(all[2].label.contains("@beta_assess"));
assert_ne!(all[1].label, all[2].label);
for (query, expected) in [
(" ASSESS PRESSURE ", vec![Id::nil(), first, second]),
(" @BETA_ ", vec![Id::nil(), second]),
("ALPHA", vec![Id::nil(), first]),
("@pressure", vec![Id::nil()]),
("no match", vec![Id::nil()]),
] {
assert_eq!(
ids(choices(&p, source, TransitionDirection::OaToSa, query)),
expected,
"{query}"
);
}
assert_eq!(
ids(choices(&p, first, TransitionDirection::SaToOa, "PRESSURE")),
vec![Id::nil(), source]
);
}
#[test]
fn query_preserves_reviewed_request_plan_ids_and_recovery_exactly() {
let (mut p, source, first, _, _) = fixture();
let second = p
.add_element(Kind::Activity, Layer::Oa, "Second source card", None)
.unwrap();
let before_project = p.clone();
// Exercise both an existing reviewed target and generated preview IDs.
for chosen in [Some(first), None] {
let mut w = Workflow::transition(&p, source).unwrap();
let Workflow::Transition(t) = &mut w else {
panic!()
};
t.request.sources.push(second);
t.plan = plan_transition(&p, &t.request).unwrap();
w.update(
&p,
Event::MappingTarget(
source,
selected(&p, source, TransitionDirection::OaToSa, chosen),
),
)
.unwrap();
w.update(
&p,
Event::Resolve(
source,
TransitionField::Name,
FieldDecision::Value("Reviewed name".into()),
),
)
.unwrap();
let saved = w.recovery_snapshot().unwrap();
let Workflow::Transition(t) = &w else {
panic!()
};
let plan = t.plan.clone();
let request = t.request.clone();
for query in ["assess", "@beta", "no match", ""] {
assert!(
w.update(&p, Event::MappingQuery(source, query.into()))
.unwrap()
.is_none()
);
assert_eq!(w.recovery_snapshot().unwrap(), saved);
let Workflow::Transition(t) = &w else {
panic!()
};
assert_eq!(t.plan, plan);
assert_eq!(t.request, request);
assert_eq!(t.counterpart_queries[&source], query);
assert_eq!(target(&p, source, &t.request), Some(chosen));
assert_eq!(
selected(&p, source, t.request.direction, chosen).id,
chosen.unwrap_or(Id::nil())
);
}
w.update(&p, Event::MappingQuery(second, "other card".into()))
.unwrap();
let Workflow::Transition(t) = &w else {
panic!()
};
assert_eq!(t.counterpart_queries[&source], "");
assert_eq!(t.counterpart_queries[&second], "other card");
assert_eq!(t.plan, plan);
let restored = Workflow::from_recovery(&p, saved.clone()).unwrap();
assert_eq!(restored.recovery_snapshot().unwrap(), saved);
let Workflow::Transition(t) = restored else {
panic!()
};
assert!(t.counterpart_queries.is_empty());
assert_eq!(t.request, request);
}
assert_eq!(p, before_project);
}
#[test]
fn recovered_invalid_or_missing_mapping_stays_visible_and_refuses_apply() {
let (p, source, _, _, incompatible) = fixture();
for chosen in [incompatible, Id::from_u128(987654321)] {
let mut w = Workflow::transition(&p, source).unwrap();
w.update(
&p,
Event::MappingTarget(
source,
Choice {
id: chosen,
label: "Recovered choice".into(),
},
),
)
.unwrap();
let saved = w.recovery_snapshot().unwrap();
let mut restored = Workflow::from_recovery(&p, saved.clone()).unwrap();
restored
.update(&p, Event::MappingQuery(source, "no match".into()))
.unwrap();
assert_eq!(restored.recovery_snapshot().unwrap(), saved);
let Workflow::Transition(t) = &restored else {
panic!()
};
assert_eq!(target(&p, source, &t.request), Some(Some(chosen)));
let display = selected(&p, source, t.request.direction, Some(chosen));
assert_eq!(display.id, chosen);
assert!(display.label.contains(if chosen == incompatible {
"incompatible"
} else {
"Missing counterpart"
}));
assert!(
!choices(&p, source, t.request.direction, "")
.iter()
.any(|c| c.id == chosen)
);
assert!(restored.update(&p, Event::Apply).is_err());
}
}
#[test]
fn trace_inference_counts_distinct_targets_and_explicit_mapping_wins() {
let (mut p, source, first, second, _) = fixture();
let mut request = TransitionRequest::new(TransitionDirection::OaToSa, vec![source]);
assert_eq!(target(&p, source, &request), Some(None));
for (i, counterpart) in [first, first, second].into_iter().enumerate() {
let id = Id::from_u128(100 + i as u128);
p.traces.insert(
id,
Trace {
id,
alias: format!("trace_{i}"),
name: format!("Trace {i}"),
description: String::new(),
oa: source,
sa: counterpart,
kind: TraceKind::Manual,
baseline: None,
},
);
assert_eq!(
target(&p, source, &request),
if i < 2 { Some(Some(first)) } else { None }
);
}
assert_eq!(
target(
&p,
first,
&TransitionRequest::new(TransitionDirection::SaToOa, vec![first])
),
Some(Some(source))
);
for chosen in [Some(second), None] {
request.mappings = vec![TransitionMapping {
source,
target: chosen,
target_kind: None,
}];
assert_eq!(target(&p, source, &request), Some(chosen));
}
}
}
@@ -202,6 +202,7 @@ impl Workflow {
request,
plan,
communication_ports,
counterpart_queries: Default::default(),
};
transition.sync_communication_ports();
transition.plan =
@@ -234,8 +234,6 @@ fn basic_transition_reconcile_keeps_existing_selected_view_placement_and_route_e
.routes
.push(EdgeRoute {
edge: trace,
source_attachment: None,
target_attachment: None,
waypoints: vec![Position { x: 301., y: 507. }],
label: Some(EdgeLabel {
fraction: 0.35,
-2
View File
@@ -12,8 +12,6 @@ use tokio::{sync::oneshot, task::JoinHandle};
mod communication_limits;
#[path = "http/data_contracts.rs"]
mod data_contracts;
#[path = "http/edge_attachments.rs"]
mod edge_attachments;
#[path = "http/port_anchors.rs"]
mod port_anchors;
#[path = "http/semantic_transitions.rs"]
@@ -1,344 +0,0 @@
use super::*;
use oa_core::{
EdgeAttachment, PortAnchorSide,
source_project::{ProjectLimits, SourceBundle, compile_bundle},
};
use std::collections::BTreeMap;
fn source_bundle() -> SourceBundle {
compile_bundle(&SourceBundle {
entry: "project.arcadia".into(),
files: BTreeMap::from([
("project.arcadia".into(), "// project rationale\narcadia 7; project attachments \"Attachments\" { include \"model.arcadia\"; include \"views.arcadia\"; }\n".into()),
("model.arcadia".into(), "// semantic ownership\noa_activity first \"First\" {}\noa_activity second \"Second\" {}\ninteraction signal \"Signal\" { source @first; target @second; }\n".into()),
("views.arcadia".into(), "// view rationale\ndiagram view \"View\" { routes [{ edge @signal; waypoints [{ x 120; y 80; }]; label { fraction 0.5; offset { x 3; y -7; }; }; }]; }\ndiagram other \"Other\" { routes [{ edge @signal; }]; }\n".into()),
]),
}, ProjectLimits::default()).unwrap().bundle
}
fn id(project: &Project, alias: &str) -> Id {
project
.identities()
.into_iter()
.find(|(_, a)| *a == alias)
.unwrap()
.0
}
async fn import(server: &TestServer) -> StoredProject {
let response = server
.client
.post(server.url("/projects/import-source-project"))
.json(&source_bundle())
.send()
.await
.unwrap();
let status = response.status();
let value: Value = response.json().await.unwrap();
assert_eq!(status, StatusCode::CREATED, "{value}");
server
.get(value["resource_id"].as_str().unwrap().parse().unwrap())
.await
}
async fn transact(
server: &TestServer,
current: &StoredProject,
commands: Value,
dry_run: bool,
) -> reqwest::Response {
server
.client
.post(server.url(&format!("/projects/{}/transactions", current.resource_id)))
.header("if-match", current.etag())
.json(&json!({"commands":commands,"dry_run":dry_run}))
.send()
.await
.unwrap()
}
fn assert_pair(current: &StoredProject) {
let compiled = compile_bundle(&current.source_project, ProjectLimits::default()).unwrap();
assert_eq!(compiled.decoded.project, current.model);
assert_eq!(compiled.bundle, current.source_project);
}
async fn assert_unchanged(server: &TestServer, expected: &StoredProject) {
let actual = server.get(expected.resource_id).await;
assert_eq!(actual.etag(), expected.etag());
assert_eq!(
serde_json::to_value(actual).unwrap(),
serde_json::to_value(expected).unwrap()
);
}
#[tokio::test]
async fn edge_attachments_http_dry_run_etags_source_parity_and_restart() {
let directory = tempfile::tempdir().unwrap();
let server = TestServer::start(directory.path(), None).await;
let original = import(&server).await;
let diagram = id(&original.model, "view");
let mut expected = original.model.clone();
let route = &mut expected.diagrams.get_mut(&diagram).unwrap().routes[0];
route.source_attachment = Some(EdgeAttachment {
side: PortAnchorSide::Left,
offset: 0.25,
});
route.target_attachment = Some(EdgeAttachment {
side: PortAnchorSide::Bottom,
offset: 1.0,
});
let commands = json!([Command::SetEdgeRoute {
diagram,
route: route.clone()
}]);
let preview = transact(&server, &original, commands.clone(), true).await;
assert_eq!(preview.status(), StatusCode::OK);
let preview: Value = preview.json().await.unwrap();
assert_eq!(
serde_json::from_value::<Project>(preview["model"].clone()).unwrap(),
expected
);
let proposed: SourceBundle = serde_json::from_value(preview["source_project"].clone()).unwrap();
assert_eq!(
compile_bundle(&proposed, ProjectLimits::default())
.unwrap()
.decoded
.project,
expected
);
assert_unchanged(&server, &original).await;
let applied = transact(&server, &original, commands.clone(), false).await;
assert_eq!(applied.status(), StatusCode::OK);
let mut current = server.get(original.resource_id).await;
assert_eq!(applied.headers()["etag"], current.etag());
assert_eq!(current.revision, original.revision + 1);
assert_eq!(
current.model, expected,
"only the two attachment fields in this view change"
);
assert_eq!(current.source_project, proposed);
assert_pair(&current);
for path in ["project.arcadia", "model.arcadia"] {
assert_eq!(
current.source_project.files[path],
original.source_project.files[path]
);
}
assert!(current.source_project.files["views.arcadia"].contains("// view rationale"));
let noop = transact(&server, &current, commands.clone(), false).await;
assert_eq!(noop.status(), StatusCode::OK);
assert_eq!(noop.headers()["etag"], current.etag());
assert_eq!(noop.json::<Value>().await.unwrap()["changed"], false);
let stale = json!([
Command::Rename(id(&current.model, "first"), "Must not persist".into()),
commands[0].clone()
]);
assert_eq!(
transact(&server, &original, stale, false).await.status(),
StatusCode::PRECONDITION_FAILED
);
assert_unchanged(&server, &current).await;
let mut edited = current.source_project.clone();
let text = edited.files.get_mut("views.arcadia").unwrap();
assert_eq!(text.matches("offset 0.25;").count(), 1);
*text = text.replace("offset 0.25;", "offset 0.375;");
expected.diagrams.get_mut(&diagram).unwrap().routes[0]
.source_attachment
.as_mut()
.unwrap()
.offset = 0.375;
let response = server
.client
.put(server.url(&format!("/projects/{}/source-project", current.resource_id)))
.header("if-match", current.etag())
.json(&edited)
.send()
.await
.unwrap();
assert_eq!(
response.status(),
StatusCode::OK,
"{}",
response.text().await.unwrap()
);
current = server.get(current.resource_id).await;
assert_eq!(current.model, expected);
assert_eq!(current.source_project, edited);
assert_pair(&current);
let source = server
.client
.get(server.url(&format!("/projects/{}/source", current.resource_id)))
.send()
.await
.unwrap();
assert_eq!(source.status(), StatusCode::OK);
assert_eq!(source.headers()["etag"], current.etag());
assert_eq!(
oa_core::language::parse(&source.text().await.unwrap()).unwrap(),
expected
);
let bundle = server
.client
.get(server.url(&format!("/projects/{}/source-project", current.resource_id)))
.send()
.await
.unwrap();
assert_eq!(bundle.status(), StatusCode::OK);
assert_eq!(bundle.headers()["etag"], current.etag());
assert_eq!(
bundle.json::<SourceBundle>().await.unwrap(),
current.source_project
);
server.stop().await;
let server = TestServer::start(directory.path(), None).await;
assert_unchanged(&server, &current).await;
let mut route = expected.diagrams[&diagram].routes[0].clone();
route.source_attachment = None;
let reset = json!([Command::SetEdgeRoute {
diagram,
route: route.clone()
}]);
assert_eq!(
transact(&server, &current, reset.clone(), false)
.await
.status(),
StatusCode::OK
);
expected.diagrams.get_mut(&diagram).unwrap().routes[0] = route;
let reset_state = server.get(current.resource_id).await;
assert_eq!(
reset_state.model, expected,
"reset preserves pins, label, opposite end, other view and semantics"
);
assert_pair(&reset_state);
let noop = transact(&server, &reset_state, reset, false).await;
assert_eq!(noop.status(), StatusCode::OK);
assert_eq!(noop.headers()["etag"], reset_state.etag());
assert_eq!(noop.json::<Value>().await.unwrap()["changed"], false);
server.stop().await;
}
#[tokio::test]
async fn edge_attachments_http_malformed_and_invalid_offsets_reject_atomically() {
let directory = tempfile::tempdir().unwrap();
let server = TestServer::start(directory.path(), None).await;
let current = import(&server).await;
let diagram = id(&current.model, "view");
let first = id(&current.model, "first");
for (bad, status) in [
(json!({}), StatusCode::BAD_REQUEST),
(json!({"side":"Top"}), StatusCode::BAD_REQUEST),
(json!({"offset":0.5}), StatusCode::BAD_REQUEST),
(
json!({"side":"Diagonal","offset":0.5}),
StatusCode::BAD_REQUEST,
),
(json!({"side":"Top","offset":null}), StatusCode::BAD_REQUEST),
(
json!({"side":"Top","offset":"NaN"}),
StatusCode::BAD_REQUEST,
),
(
json!({"side":"Top","offset":0.5,"unknown":true}),
StatusCode::BAD_REQUEST,
),
(
json!({"side":"Top","offset":-0.01}),
StatusCode::UNPROCESSABLE_ENTITY,
),
(
json!({"side":"Top","offset":1.01}),
StatusCode::UNPROCESSABLE_ENTITY,
),
] {
for field in ["source_attachment", "target_attachment"] {
let mut route =
serde_json::to_value(&current.model.diagrams[&diagram].routes[0]).unwrap();
route[field] = bad.clone();
let commands = json!([Command::Rename(first, "Must roll back".into()), {"SetEdgeRoute":{"diagram":diagram,"route":route}}]);
let response = transact(&server, &current, commands, false).await;
assert_eq!(
response.status(),
status,
"{field} {bad}: {}",
response.text().await.unwrap()
);
assert_unchanged(&server, &current).await;
}
}
let mut invalid = current.model.clone();
invalid.diagrams.get_mut(&diagram).unwrap().routes[0].target_attachment =
Some(EdgeAttachment {
side: PortAnchorSide::Right,
offset: 2.0,
});
let response = server
.client
.put(server.url(&format!("/projects/{}/model", current.resource_id)))
.header("if-match", current.etag())
.json(&invalid)
.send()
.await
.unwrap();
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
assert_unchanged(&server, &current).await;
server.stop().await;
}
#[tokio::test]
async fn edge_attachments_served_openapi_matches_required_nested_records_and_optional_ends() {
let directory = tempfile::tempdir().unwrap();
let server = TestServer::start(directory.path(), None).await;
let response = server
.client
.get(server.url("/openapi.json"))
.send()
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let schema: Value = response.json().await.unwrap();
let schemas = &schema["components"]["schemas"];
let attachment = &schemas["EdgeAttachment"];
assert_eq!(attachment["required"], json!(["side", "offset"]));
assert_eq!(attachment["additionalProperties"], false);
assert_eq!(
attachment["properties"]["side"]["$ref"],
"#/components/schemas/PortAnchorSide"
);
assert_eq!(attachment["properties"]["offset"]["minimum"], 0);
assert_eq!(attachment["properties"]["offset"]["maximum"], 1);
for field in ["source_attachment", "target_attachment"] {
assert!(
!schemas["EdgeRoute"]["required"]
.as_array()
.unwrap()
.contains(&json!(field))
);
let optional = &schemas["EdgeRoute"]["properties"][field];
assert_eq!(optional["default"], Value::Null);
assert_eq!(
optional["oneOf"],
json!([{"$ref":"#/components/schemas/EdgeAttachment"},{"type":"null"}])
);
}
let compiled = compile_bundle(&source_bundle(), ProjectLimits::default()).unwrap();
let diagram = id(&compiled.decoded.project, "view");
let mut route = compiled.decoded.project.diagrams[&diagram].routes[0].clone();
let omitted = serde_json::to_value(&route).unwrap();
assert!(omitted.get("source_attachment").is_none());
assert!(omitted.get("target_attachment").is_none());
route.target_attachment = Some(EdgeAttachment {
side: PortAnchorSide::Right,
offset: 0.0,
});
let encoded = serde_json::to_value(Command::SetEdgeRoute { diagram, route }).unwrap();
assert_eq!(
encoded["SetEdgeRoute"]["route"]["target_attachment"],
json!({"side":"Right","offset":0.0})
);
server.stop().await;
}
+1 -28
View File
@@ -2,7 +2,7 @@
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
<head>
<meta charset="utf-8" />
<meta name="generator" content="Docutils 0.23: https://docutils.sourceforge.io/" />
<meta name="generator" content="Docutils 0.22.2: https://docutils.sourceforge.io/" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Arcadia Studio model REST API</title>
<style type="text/css">
@@ -947,33 +947,6 @@ authored files and revision. JSON/source imports may omit <span class="docutils
it defaults to <span class="docutils literal">[]</span> and empty lists are omitted on export. The normative
diagram semantics and source spelling are in <span class="docutils literal">language.rst</span> section 5.</p>
</section>
<section id="diagram-connection-attachments">
<h2>Diagram connection attachments</h2>
<p><span class="docutils literal">EdgeRoute.source_attachment</span> and <span class="docutils literal">target_attachment</span> are optional
<span class="docutils literal"><span class="pre">{&quot;side&quot;:&quot;Bottom&quot;,&quot;offset&quot;:0.25}</span></span> records. Side and a finite offset in
<span class="docutils literal">[0,1]</span> are required within a supplied record; unknown fields are rejected.
Absence/null means automatic placement. Omitted fields in older schema-3
models retain that meaning. The normal transaction endpoint accepts:</p>
<pre class="literal-block">{&quot;commands&quot;:[{&quot;SetEdgeRoute&quot;:{
&quot;diagram&quot;:&quot;&lt;diagram UUID&gt;&quot;,
&quot;route&quot;:{&quot;edge&quot;:&quot;&lt;relation or trace UUID&gt;&quot;,
&quot;source_attachment&quot;:{&quot;side&quot;:&quot;Bottom&quot;,&quot;offset&quot;:0.25},
&quot;waypoints&quot;:[],&quot;label&quot;:null}
}}]}</pre>
<p><span class="docutils literal">SetEdgeRoute</span> replaces the complete route entry. Clients changing one end
must retain its other attachment, bends and label; omit/null only the intended
attachment to restore that end's automatic position. <span class="docutils literal">RemoveEdgeRoute</span> resets
all presentation for that edge. Both support ETag checking, dry runs, atomic
validation and source-project writeback. Invalid offsets reject the whole
transaction without changing files or revision.</p>
<p>These are per-view presentation preferences, not a semantic reconnect. Fractions
follow the projected shape, including moves/resizes; semantic reconnect retains
them as preferences of the same edge-end slots. Real semantic ports take
precedence, and promoted folded endpoints suspend authored routing. The complete
boundary mapping, lifecycle and generated-view limitations are normative in
<span class="docutils literal">language.rst</span> section 5. GET model/source, PUT source, desktop and transactions
share this representation; no separate attachment endpoint exists.</p>
</section>
<section id="contextual-process-and-chain-topology">
<h2>Contextual process and chain topology</h2>
<p>Operational processes and system functional chains can combine work
-31
View File
@@ -329,37 +329,6 @@ authored files and revision. JSON/source imports may omit ``port_anchors``;
it defaults to ``[]`` and empty lists are omitted on export. The normative
diagram semantics and source spelling are in ``language.rst`` section 5.
Diagram connection attachments
-------------------------------
``EdgeRoute.source_attachment`` and ``target_attachment`` are optional
``{"side":"Bottom","offset":0.25}`` records. Side and a finite offset in
``[0,1]`` are required within a supplied record; unknown fields are rejected.
Absence/null means automatic placement. Omitted fields in older schema-3
models retain that meaning. The normal transaction endpoint accepts::
{"commands":[{"SetEdgeRoute":{
"diagram":"<diagram UUID>",
"route":{"edge":"<relation or trace UUID>",
"source_attachment":{"side":"Bottom","offset":0.25},
"waypoints":[],"label":null}
}}]}
``SetEdgeRoute`` replaces the complete route entry. Clients changing one end
must retain its other attachment, bends and label; omit/null only the intended
attachment to restore that end's automatic position. ``RemoveEdgeRoute`` resets
all presentation for that edge. Both support ETag checking, dry runs, atomic
validation and source-project writeback. Invalid offsets reject the whole
transaction without changing files or revision.
These are per-view presentation preferences, not a semantic reconnect. Fractions
follow the projected shape, including moves/resizes; semantic reconnect retains
them as preferences of the same edge-end slots. Real semantic ports take
precedence, and promoted folded endpoints suspend authored routing. The complete
boundary mapping, lifecycle and generated-view limitations are normative in
``language.rst`` section 5. GET model/source, PUT source, desktop and transactions
share this representation; no separate attachment endpoint exists.
Contextual process and chain topology
-------------------------------------
+2 -45
View File
@@ -1785,16 +1785,14 @@ the Reparent command. Moving, resizing, arranging, or folding an owner retains
its authored sides and fractions. Frozen schema-1/schema-2 readers do not accept
<span class="docutils literal">port_anchors</span>; their explicit migration supplies an empty list.</p>
<p>Each <span class="docutils literal">routes</span> entry is an EdgeRoute record with a required <span class="docutils literal">edge</span> reference
to an existing Relation or Trace, <span class="docutils literal">waypoints</span> (default <span class="docutils literal">[]</span>), <span class="docutils literal">label</span>
(default <span class="docutils literal">none</span>), and <span class="docutils literal">source_attachment</span> / <span class="docutils literal">target_attachment</span> (each
default <span class="docutils literal">none</span>). Route records have no separate UUID or alias. An edge occurs
to an existing Relation or Trace, <span class="docutils literal">waypoints</span> (default <span class="docutils literal">[]</span>), and <span class="docutils literal">label</span>
(default <span class="docutils literal">none</span>). Route records have no separate UUID or alias. An edge occurs
at most once in a diagram's route list; different diagrams MAY store independent
routes for the same edge. List order and explicit empty entries are retained by
source and JSON round trips, although list order has no routing meaning. Edges
need not currently be visible in their diagram.</p>
<pre class="code text literal-block"><code>routes [
{ edge &#64;report_exchange;
source_attachment { side Bottom; offset 0.25; };
waypoints [{ x 420; y 260; }, { x 690; y 260; }];
label { fraction 0.55; offset { x 0; y -16; }; };
}
@@ -1811,47 +1809,6 @@ are structural errors. A pinned point that obstructs routing remains stored;
geometric routing difficulty is reported separately from structural validity.
Each EdgeRoute counts as one record against <span class="docutils literal">ParseLimits.max_records</span>; pinned
points are bounded by the per-route limit and aggregate input byte limits.</p>
<p>An attachment is an EdgeAttachment record with required <span class="docutils literal">side</span> (Left, Right,
Top, Bottom) and required finite binary32 <span class="docutils literal">offset</span> from zero through one.
Unknown fields, omitted nested fields and out-of-range offsets are structural
errors (<span class="docutils literal">invalid_attachment_offset</span> for the latter). Attachments are bounded
inline values, at most two per route, not additional graph objects or records.
JSON uses the same field names, enum strings and number values. Omitted or null
attachments mean automatic positioning; canonical compact source and JSON omit
absent attachments. Earlier schema-3 input hydrates both fields as absent.</p>
<p>The end names designate stored source/target slots, even for undirected edges.
These values MUST NOT change semantic endpoints, ports, ownership, type,
orientation or identity. Each preference belongs to that edge end in that view:
another view remains independent. It follows the current endpoint's projected
boundary under movement, resize or semantic reconnect; reconnect preserves the
preference, as it preserves authored bends. The fraction does not identify the
previous semantic endpoint. Deletion/reidentification of the edge follows the
existing whole-route rules; moving, hiding or replacing its placement retains
the preference. Removing one attachment restores that end's automatic position
without changing bends, labels or the opposite attachment.</p>
<p>For Left/Right, offset gives the Y fraction of the complete projected bounding
box; for Top/Bottom it gives the X fraction. The attachment intersects the painted
shape along that cardinal scan line. Rounded cards use radius 10, limited by half
their width/height; Regions and fork/join bars are rectangles. State circles use
their ellipse boundary and Choice uses its diamond boundary. More precisely, let
<span class="docutils literal">t = 2 * offset - 1</span> and <span class="docutils literal">D</span> be width for Left/Right or height for Top/Bottom.
The inset from the selected bounding side is <span class="docutils literal">D/2 * (1 - <span class="pre">sqrt(1-t*t))</span></span> for an
ellipse and <span class="docutils literal">D/2 * abs(t)</span> for a diamond. A Terminate X uses the outermost
painted-stroke intersection, inset <span class="docutils literal">D * min(offset, <span class="pre">1-offset)</span></span>. Zero and one
are valid; different side/fraction pairs can describe the same painted point.
Routing leads extend in the selected cardinal direction beyond the bounding box
before turning. Source/API fractions retain their precision; pointer editing
snaps fractions to hundredths without changing the stored semantic record.</p>
<p>An actual semantic Port remains authoritative, including when projection hides
its port marker. An edge attachment on that end is retained but dormant; use
the port's own placement anchor to move a port. When folding promotes an endpoint
to an ancestor, all authored route data is suspended and resumes on expansion.
Geometric difficulty MUST NOT discard preferences. Coincident authored self-loop
tips retain their positions with an external loop and a routing warning; the
editor must offer an explicit way to choose either end even when handles overlap.
Internal state transitions have no external connection. Process's generated
topology and Scenario's lifeline/activation/time-derived tips are not consumers
of these diagram route fields; this feature does not redefine their semantics.</p>
<p><span class="docutils literal">SetEdgeRoute { diagram, route }</span> upserts an entry by edge identity, retaining
the order and contents of unrelated entries. <span class="docutils literal">RemoveEdgeRoute { diagram, edge }</span>
removes it; removing an absent entry is a no-op for an existing diagram and edge.
+2 -49
View File
@@ -1167,9 +1167,8 @@ its authored sides and fractions. Frozen schema-1/schema-2 readers do not accept
``port_anchors``; their explicit migration supplies an empty list.
Each ``routes`` entry is an EdgeRoute record with a required ``edge`` reference
to an existing Relation or Trace, ``waypoints`` (default ``[]``), ``label``
(default ``none``), and ``source_attachment`` / ``target_attachment`` (each
default ``none``). Route records have no separate UUID or alias. An edge occurs
to an existing Relation or Trace, ``waypoints`` (default ``[]``), and ``label``
(default ``none``). Route records have no separate UUID or alias. An edge occurs
at most once in a diagram's route list; different diagrams MAY store independent
routes for the same edge. List order and explicit empty entries are retained by
source and JSON round trips, although list order has no routing meaning. Edges
@@ -1179,7 +1178,6 @@ need not currently be visible in their diagram.
routes [
{ edge @report_exchange;
source_attachment { side Bottom; offset 0.25; };
waypoints [{ x 420; y 260; }, { x 690; y 260; }];
label { fraction 0.55; offset { x 0; y -16; }; };
}
@@ -1198,51 +1196,6 @@ geometric routing difficulty is reported separately from structural validity.
Each EdgeRoute counts as one record against ``ParseLimits.max_records``; pinned
points are bounded by the per-route limit and aggregate input byte limits.
An attachment is an EdgeAttachment record with required ``side`` (Left, Right,
Top, Bottom) and required finite binary32 ``offset`` from zero through one.
Unknown fields, omitted nested fields and out-of-range offsets are structural
errors (``invalid_attachment_offset`` for the latter). Attachments are bounded
inline values, at most two per route, not additional graph objects or records.
JSON uses the same field names, enum strings and number values. Omitted or null
attachments mean automatic positioning; canonical compact source and JSON omit
absent attachments. Earlier schema-3 input hydrates both fields as absent.
The end names designate stored source/target slots, even for undirected edges.
These values MUST NOT change semantic endpoints, ports, ownership, type,
orientation or identity. Each preference belongs to that edge end in that view:
another view remains independent. It follows the current endpoint's projected
boundary under movement, resize or semantic reconnect; reconnect preserves the
preference, as it preserves authored bends. The fraction does not identify the
previous semantic endpoint. Deletion/reidentification of the edge follows the
existing whole-route rules; moving, hiding or replacing its placement retains
the preference. Removing one attachment restores that end's automatic position
without changing bends, labels or the opposite attachment.
For Left/Right, offset gives the Y fraction of the complete projected bounding
box; for Top/Bottom it gives the X fraction. The attachment intersects the painted
shape along that cardinal scan line. Rounded cards use radius 10, limited by half
their width/height; Regions and fork/join bars are rectangles. State circles use
their ellipse boundary and Choice uses its diamond boundary. More precisely, let
``t = 2 * offset - 1`` and ``D`` be width for Left/Right or height for Top/Bottom.
The inset from the selected bounding side is ``D/2 * (1 - sqrt(1-t*t))`` for an
ellipse and ``D/2 * abs(t)`` for a diamond. A Terminate X uses the outermost
painted-stroke intersection, inset ``D * min(offset, 1-offset)``. Zero and one
are valid; different side/fraction pairs can describe the same painted point.
Routing leads extend in the selected cardinal direction beyond the bounding box
before turning. Source/API fractions retain their precision; pointer editing
snaps fractions to hundredths without changing the stored semantic record.
An actual semantic Port remains authoritative, including when projection hides
its port marker. An edge attachment on that end is retained but dormant; use
the port's own placement anchor to move a port. When folding promotes an endpoint
to an ancestor, all authored route data is suspended and resumes on expansion.
Geometric difficulty MUST NOT discard preferences. Coincident authored self-loop
tips retain their positions with an external loop and a routing warning; the
editor must offer an explicit way to choose either end even when handles overlap.
Internal state transitions have no external connection. Process's generated
topology and Scenario's lifeline/activation/time-derived tips are not consumers
of these diagram route fields; this feature does not redefine their semantics.
``SetEdgeRoute { diagram, route }`` upserts an entry by edge identity, retaining
the order and contents of unrelated entries. ``RemoveEdgeRoute { diagram, edge }``
removes it; removing an absent entry is a no-op for an existing diagram and edge.
+1 -45
View File
@@ -2137,59 +2137,15 @@
],
"additionalProperties": false
},
"EdgeAttachment": {
"type": "object",
"description": "Per-view preference on one stored connection end, not a semantic endpoint. Offset is finite and measured along the complete projected bounding-box side. Real semantic ports take precedence; hidden/promoted geometry retains the preference. Reconnect preserves the preference on its source/target slot.",
"properties": {
"side": {
"$ref": "#/components/schemas/PortAnchorSide"
},
"offset": {
"type": "number",
"minimum": 0,
"maximum": 1,
"description": "Left/Right: top-to-bottom fraction. Top/Bottom: left-to-right fraction. Shape-aware geometry derives the actual contour point."
}
},
"required": [
"side",
"offset"
],
"additionalProperties": false
},
"EdgeRoute": {
"type": "object",
"description": "One diagram's authored presentation for an existing Relation or Trace, including currently hidden edges. No separate identity. Stores optional endpoint preferences, pinned points and label placement; explicit empty routes are retained. Removing the route resets all authored presentation.",
"description": "One diagram's authored route for an existing Relation or Trace, including currently hidden edges. No separate identity. Only pinned points are stored; explicit empty routes are retained.",
"properties": {
"edge": {
"type": "string",
"format": "uuid",
"description": "Existing Relation or Trace identity, never a node placement."
},
"source_attachment": {
"oneOf": [
{
"$ref": "#/components/schemas/EdgeAttachment"
},
{
"type": "null"
}
],
"default": null,
"description": "Absent or null means automatic. Omitted when serializing automatic presentation. Resetting this field preserves the other end, pins and label."
},
"target_attachment": {
"oneOf": [
{
"$ref": "#/components/schemas/EdgeAttachment"
},
{
"type": "null"
}
],
"default": null,
"description": "Absent or null means automatic. Omitted when serializing automatic presentation. Resetting this field preserves the other end, pins and label."
},
"waypoints": {
"type": "array",
"items": {
+2
View File
@@ -49,6 +49,8 @@ P1 extension to frozen relation interface: FunctionalExchange now has `source_po
`transitions::counterpart_kinds(source: Kind, direction: TransitionDirection) -> Vec<Kind>` is the shared choice policy for UI and other clients. It returns supported explicit element counterpart kinds in stable `Kind::ALL` order using the same compatibility predicate as transition validation; an empty list means no automatic element mapping.
`transitions::counterpart_compatible(&Project, source: Id, target: Id, direction: TransitionDirection) -> bool` checks existing-record candidate eligibility: both IDs must exist in the direction's respective layers, belong to the same element/relation category, and have compatible transition kinds. It does not approve properties, dependencies or structural changes; clients still preview and apply a complete plan. Manual trace creation retains its separate policy. The desktop counterpart search uses this predicate, filters by name or `@alias`, and sorts by name then alias and ID. Search is presentation state: it does not replan, change mappings or reviewed field decisions, or enter workflow recovery data. A selected target remains visible when filtered out; invalid or missing recovered targets remain visible with their error and require correction. Multiple traces to the same target count as one inferred counterpart; distinct traced targets require an explicit choice.
`TransitionRequest {direction:OaToSa|SaToOa, sources:Vec<Id>, scope:SelectedOnly|Required|Subtree, mappings:Vec<TransitionMapping>, fields:Vec<TransitionField>, resolutions:Vec<FieldResolution>, destination_diagram:Option<Id>}`. `TransitionRequest::new(direction,sources)` defaults to selected-only and name/description fields. `TransitionMapping {source,target:Option<Id>,target_kind:Option<Kind>}` selects reuse or explicit creation kind. An explicit mapping with target=None means create a new counterpart even if another trace exists. Without mapping, one existing trace is reused; multiple traces require selection.
`FieldResolution {source,field:Name|Description,decision:TakeSource|KeepTarget|Value(String)}`. `TransitionPlan {base_fingerprint,request,changes,conflicts,warnings,commands}`. Changes carry source/target IDs, Create/Update/Reuse and before/after field changes. Conflicts have optional source/target/field and message. Conflicts prevent apply. The deterministic fingerprint guards stale plans, is not a security hash, and does not introduce persistent project revision fields. Planned IDs and aliases remain unchanged during apply. Server owns request idempotency/revision storage. Apply is one candidate transaction; Session adoption can use Command::Replace(candidate) for one Undo.
-9
View File
@@ -456,12 +456,3 @@ Missing global and scoped parameter IDs remain explicit stabilization metadata,
not new random identities assigned on each parse. Public source8 integration must
thread the same accounting through the outer project and included files; this
private checkpoint does not itself admit a complete version8 source bundle.
## Connection-end presentation preferences — 2026-09-22
Issue #33 uses optional side/fraction attachments on each view's `EdgeRoute`,
with semantic Port precedence and shared shape-aware routing. Visual attachment
editing remains separate from semantic reconnect. The [decision and evidence
map](edge-attachments-20260922.md) records alternatives, lifecycle rules,
independent falsifiers, evaluators and the Process/Scenario applicability limits.
This is branch-specific work; it does not establish an integrated release.

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