Compare commits

..
976 changed files with 7111 additions and 238370 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.
+2 -2
View File
@@ -17,14 +17,14 @@ Apply these criteria to the scope requested by the user:
- Consequential decisions have evidence and rationale. Major assumptions have been validated, explicitly accepted, or isolated without compromising required behavior.
- The result builds, runs, and operates on the required platforms. Relevant tests and evaluators pass; integrations, edge cases, and failure states have been exercised against the actual result.
- User-facing behavior and usability have been exercised directly, using representative data and workflows.
- A fresh independent review covers requirements, domain / semantic consistency, usability, architecture, correctness, regressions, runtime behavior, documentation, and remaining risks. Valid material findings are addressed, blocking and high-impact issues resolved, and affected behavior reverified; no known blocker or high-severity defect remains.
- A fresh independent review covers requirements, domain/semantic consistency, usability, architecture, correctness, regressions, runtime behavior, documentation, and remaining risks. Valid material findings are addressed, blocking and high-impact issues resolved, and affected behavior reverified; no known blocker or high-severity defect remains.
- Deliverables are maintainable, appropriately extensible, documented, and published as required by `AGENTS.md`. Obsolete scaffolding is removed; documentation and shared state accurately describe the result. Another engineer or agent can reproduce the verification and continue the work.
If required work or verification is blocked, record the specific gap and continue independent useful work without claiming completion.
## Final handoff
Link the delivered result and summarize capabilities, discoveries, architecture and decisions, relevant rejected approaches, verification and independent review, remaining limitations, and exact build / run / deployment / verification instructions. Refer to authoritative records instead of repeating the project history.
Link the delivered result and summarize capabilities, discoveries, architecture and decisions, relevant rejected approaches, verification and independent review, remaining limitations, and exact build/run/deployment/verification instructions. Refer to authoritative records instead of repeating the project history.
## USER-OWNED APPLICATION SPECIFICATION
+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,717 +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(),
layout: None,
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",
);
}
}
@@ -230,7 +230,7 @@ fn fixture() -> (Project, Id, Vec<Id>, Vec<Id>, Vec<Id>) {
)
})
.collect();
p.diagrams.insert(diagram, Diagram { id: diagram, alias: "forty_exchange_view".into(), name: "Forty service exchanges · all concerns".into(), description: "Start with All. Compare first category, overlapping concerns, global coverage and chosen processes. No hand-routed edges.".into(), layer: Layer::Oa, projection: ProjectionKind::FunctionalDataflow, view_options: ViewOptions::default(), nodes, layout: None, routes: vec![] });
p.diagrams.insert(diagram, Diagram { id: diagram, alias: "forty_exchange_view".into(), name: "Forty service exchanges · all concerns".into(), description: "Start with All. Compare first category, overlapping concerns, global coverage and chosen processes. No hand-routed edges.".into(), layer: Layer::Oa, projection: ProjectionKind::FunctionalDataflow, view_options: ViewOptions::default(), nodes, routes: vec![] });
(p, diagram, exchanges, categories, contexts)
}
@@ -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,912 +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(),
layout: None,
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}");
}
}
}
-1
View File
@@ -81,7 +81,6 @@ fn diagram(
projection,
view_options: Default::default(),
nodes: BTreeMap::new(),
layout: None,
routes: vec![],
},
);
-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))
}
-5
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;
@@ -10,7 +9,6 @@ pub mod ports;
pub mod projection;
pub mod references;
pub mod routing;
pub mod scenario_layout;
pub mod semantic;
pub mod session;
mod source_defaults;
@@ -22,9 +20,6 @@ pub mod validation;
pub mod view_options;
pub use model::*;
pub use ports::*;
pub use scenario_layout::{
DiagramLayout, ScenarioLanePosition, ScenarioLayout, ScenarioMessagePosition,
};
pub use semantic::*;
pub use session::*;
mod demo;
-61
View File
@@ -1436,7 +1436,6 @@ pub enum ProjectionKind {
FunctionalDataflow,
Capabilities,
StateMachine { machine: Id },
Scenario { scenario: Id },
}
impl ProjectionKind {
pub fn state_machine(self) -> Option<Id> {
@@ -1446,30 +1445,8 @@ impl ProjectionKind {
}
}
/// The semantic owner of a saved Scenario representation.
pub fn scenario(self) -> Option<Id> {
match self {
Self::Scenario { scenario } => Some(scenario),
_ => None,
}
}
/// Explicitly classify architecture views; absence of a state machine is insufficient.
pub fn is_architecture(self) -> bool {
match self {
Self::Overview
| Self::Decomposition
| Self::Allocation
| Self::Traceability
| Self::FunctionalDataflow
| Self::Capabilities => true,
Self::StateMachine { .. } | Self::Scenario { .. } => false,
}
}
pub fn includes(self, kind: Kind) -> bool {
match self {
Self::Scenario { .. } => false,
Self::StateMachine { .. } => {
matches!(kind, Kind::StateMachine | Kind::Region) || kind.is_state()
}
@@ -1494,7 +1471,6 @@ impl std::fmt::Display for ProjectionKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
Self::StateMachine { .. } => "State machine",
Self::Scenario { .. } => "Scenario",
Self::Allocation => "Architecture · allocated work",
Self::FunctionalDataflow => "Functional dataflow",
Self::Capabilities => "Missions & capabilities",
@@ -1519,8 +1495,6 @@ pub struct Diagram {
pub nodes: BTreeMap<Id, NodePlacement>,
#[serde(default)]
pub routes: Vec<EdgeRoute>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub layout: Option<crate::scenario_layout::DiagramLayout>,
}
/// Authored presentation for one existing relation or trace in one diagram.
/// Computed endpoints and automatic routing elbows are never persisted here.
@@ -1528,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 {
@@ -1981,7 +1921,6 @@ impl Project {
view_options: crate::view_options::ViewOptions::default(),
nodes: BTreeMap::new(),
routes: vec![],
layout: None,
},
);
id
-6
View File
@@ -278,11 +278,6 @@ fn project_diagram_impl(
mut availability: Option<&mut BTreeMap<Id, crate::view_options::EdgeAvailability>>,
index: Option<&crate::view_options::ViewIndex>,
) -> ProjectedView {
// Saved Scenario layout is not an architecture/state projection. Its dedicated
// renderer is not wired yet; never interpret its payload through this fallback.
if d.projection.scenario().is_some() {
return ProjectedView::default();
}
let parents = parents(p, d);
let mut nodes = Vec::new();
for n in d.nodes.values() {
@@ -399,7 +394,6 @@ fn project_diagram_impl(
continue;
}
let included = match d.projection {
ProjectionKind::Scenario { .. } => false,
ProjectionKind::StateMachine { machine } => matches!(&r.body,
RelationBody::StateTransition { transition_kind, .. }
if *transition_kind != TransitionKind::Internal
+23 -84
View File
@@ -203,81 +203,7 @@ pub fn visit_relation_refs(r: &mut Relation, mut visit: impl FnMut(&mut Id)) {
_ => {}
}
}
/// Every semantic/presentation reference in a diagram, excluding its own identities.
pub fn diagram_references(d: &Diagram) -> Vec<Id> {
let mut copy = d.clone();
let mut refs = Vec::new();
visit_diagram_refs(&mut copy, |id| refs.push(*id));
refs
}
pub fn visit_diagram_refs(d: &mut Diagram, mut visit: impl FnMut(&mut Id)) {
d.view_options.visit_references(&mut visit);
match &mut d.projection {
ProjectionKind::StateMachine { machine } => visit(machine),
ProjectionKind::Scenario { scenario } => visit(scenario),
_ => {}
}
if let Some(layout) = &mut d.layout {
layout.visit_references(&mut visit);
}
for route in &mut d.routes {
visit(&mut route.edge);
}
for node in d.nodes.values_mut() {
for anchor in &mut node.port_anchors {
visit(&mut anchor.port);
}
visit(&mut node.element);
}
}
/// Preflight reference-key collisions before the caller changes object identities.
/// Fresh targets are allowed. This is not whole-project admission: unrelated
/// invalid records remain repairable within a transaction. Future typed reference
/// domains must compose their own preflight here before any remap mutation.
pub fn check_remap(p: &Project, old: Id, new: Id) -> Result<(), ModelError> {
if old == new {
return Ok(());
}
let remap = |id: Id| if id == old { new } else { id };
for diagram in p.diagrams.values() {
let Some(DiagramLayout::Scenario(layout)) = &diagram.layout else {
continue;
};
let mut lanes = std::collections::BTreeMap::new();
for lane in &layout.lanes {
let affected = lane.lifeline == old;
if let Some(previous_affected) = lanes.insert(remap(lane.lifeline), affected)
&& (affected || previous_affected)
{
return Err(ModelError(
"Identity remap would collide with a Scenario lane position.".into(),
));
}
}
let mut messages = std::collections::BTreeMap::new();
for message in &layout.messages {
let mut address = message.address.clone();
let affected = address.occurrence == old || address.references.contains(&old);
address.occurrence = remap(address.occurrence);
for reference in &mut address.references {
*reference = remap(*reference);
}
if let Some(previous_affected) = messages.insert(address, affected)
&& (affected || previous_affected)
{
return Err(ModelError(
"Identity remap would collide with a Scenario message position.".into(),
));
}
}
}
Ok(())
}
pub fn remap_references(p: &mut Project, old: Id, new: Id) -> Result<(), ModelError> {
check_remap(p, old, new)?;
pub fn remap_references(p: &mut Project, old: Id, new: Id) {
for e in p.elements.values_mut() {
visit_element_refs(e, |id| {
if *id == old {
@@ -293,11 +219,31 @@ pub fn remap_references(p: &mut Project, old: Id, new: Id) -> Result<(), ModelEr
});
}
for d in p.diagrams.values_mut() {
visit_diagram_refs(d, |id| {
d.view_options.visit_references(|id| {
if *id == old {
*id = new;
}
});
if let ProjectionKind::StateMachine { machine } = &mut d.projection
&& *machine == old
{
*machine = new;
}
for route in &mut d.routes {
if route.edge == old {
route.edge = new;
}
}
for n in d.nodes.values_mut() {
for anchor in &mut n.port_anchors {
if anchor.port == old {
anchor.port = new;
}
}
if n.element == old {
n.element = new;
}
}
}
for a in p.artifact_links.values_mut() {
if a.owner == old {
@@ -319,7 +265,6 @@ pub fn remap_references(p: &mut Project, old: Id, new: Id) -> Result<(), ModelEr
});
}
}
Ok(())
}
/// IDs removed by a cascade. Cross-layer traced counterparts are never included.
pub fn deletion_impact(p: &Project, id: Id) -> BTreeSet<Id> {
@@ -394,8 +339,7 @@ fn deletion_impact_inner(p: &Project, id: Id, defer_exchange_allocations: bool)
for d in p.diagrams.values() {
if d.projection
.state_machine()
.or_else(|| d.projection.scenario())
.is_some_and(|owner| removed.contains(&owner))
.is_some_and(|machine| removed.contains(&machine))
{
removed.insert(d.id);
}
@@ -468,7 +412,6 @@ fn remove_objects(p: &mut Project, removed: BTreeSet<Id>) {
p.artifact_links.retain(|id, _| !removed.contains(id));
for d in p.diagrams.values_mut() {
d.view_options.retain_references(|id| !removed.contains(id));
crate::scenario_layout::prune_deleted(d, &removed);
d.nodes.retain(|id, _| !removed.contains(id));
d.routes.retain(|route| !removed.contains(&route.edge));
for node in d.nodes.values_mut() {
@@ -646,7 +589,3 @@ fn remove_objects(p: &mut Project, removed: BTreeSet<Id>) {
}
}
}
#[cfg(test)]
#[path = "references/scenario_layout_tests.rs"]
mod scenario_layout_tests;
@@ -1,388 +0,0 @@
use super::*;
use std::collections::BTreeMap;
struct Fixture {
project: Project,
owner: Id,
lanes: [Id; 2],
messages: [Id; 2],
views: [Id; 2],
}
fn address(occurrence: Id) -> OccurrenceEndpoint {
OccurrenceEndpoint {
references: vec![],
occurrence,
}
}
fn layout(project: &Project, view: Id) -> &ScenarioLayout {
let Some(DiagramLayout::Scenario(layout)) = &project.diagrams[&view].layout else {
panic!("expected authored Scenario positions")
};
layout
}
fn layout_mut(project: &mut Project, view: Id) -> &mut ScenarioLayout {
let Some(DiagramLayout::Scenario(layout)) =
&mut project.diagrams.get_mut(&view).unwrap().layout
else {
panic!("expected authored Scenario positions")
};
layout
}
fn admitted(project: &Project) {
let errors: Vec<_> = crate::validation::validate(project)
.into_iter()
.filter(|issue| issue.severity == crate::validation::Severity::Error)
.collect();
assert!(errors.is_empty(), "{errors:?}");
}
fn fixture() -> Fixture {
let mut project = Project::empty();
let owner = project
.add_element(Kind::Scenario, Layer::Oa, "Delivery", None)
.unwrap();
let lanes = ["Sender", "Receiver"].map(|name| {
project
.add_element(Kind::Lifeline, Layer::Oa, name, Some(owner))
.unwrap()
});
let messages = ["First message", "Second message"].map(|name| {
let id = project
.add_element(Kind::Occurrence, Layer::Oa, name, Some(owner))
.unwrap();
*project.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::Occurrence {
body: OccurrenceBody::Exchange {
exchange: None,
sender: Some(lanes[0]),
receiver: Some(lanes[1]),
message_kind: MessageKind::Asynchronous,
reply_to: None,
values: vec![],
operation: None,
},
precondition: String::new(),
postcondition: String::new(),
};
id
});
*project
.elements
.get_mut(&owner)
.unwrap()
.steps_mut()
.unwrap() = messages.to_vec();
let views = ["delivery_view", "review_view"].map(|alias| {
let id = Id::new_v4();
project.diagrams.insert(
id,
Diagram {
id,
alias: alias.into(),
name: alias.into(),
description: String::new(),
layer: Layer::Oa,
projection: ProjectionKind::Scenario { scenario: owner },
view_options: Default::default(),
nodes: BTreeMap::new(),
routes: vec![],
layout: Some(DiagramLayout::Scenario(ScenarioLayout {
lanes: vec![
ScenarioLanePosition {
lifeline: lanes[0],
x: 120.,
},
ScenarioLanePosition {
lifeline: lanes[1],
x: 420.,
},
],
messages: vec![
ScenarioMessagePosition {
address: address(messages[0]),
send_y: 220.,
},
ScenarioMessagePosition {
address: address(messages[1]),
send_y: 360.,
},
],
})),
},
);
id
});
admitted(&project);
Fixture {
project,
owner,
lanes,
messages,
views,
}
}
#[test]
fn fresh_identity_swap_preserves_coordinates_and_semantic_sequence() {
let mut f = fixture();
let before = f.project.clone();
let temporary = Id::new_v4();
for ids in [f.lanes, f.messages] {
f.project = crate::execute_batch(
&f.project,
&[
Command::Reidentify(ids[0], temporary),
Command::Reidentify(ids[1], ids[0]),
Command::Reidentify(temporary, ids[1]),
],
)
.unwrap();
}
admitted(&f.project);
assert_eq!(
f.project.elements[&f.owner].steps().unwrap(),
&vec![f.messages[1], f.messages[0]]
);
for view in f.views {
let rows = layout(&f.project, view);
assert_eq!(
rows.lanes,
vec![
ScenarioLanePosition {
lifeline: f.lanes[1],
x: 120.
},
ScenarioLanePosition {
lifeline: f.lanes[0],
x: 420.
}
]
);
assert_eq!(
rows.messages,
vec![
ScenarioMessagePosition {
address: address(f.messages[1]),
send_y: 220.
},
ScenarioMessagePosition {
address: address(f.messages[0]),
send_y: 360.
}
]
);
}
// Reverse the swaps: no values or identities other than those requested drift.
for ids in [f.lanes, f.messages] {
f.project = crate::execute_batch(
&f.project,
&[
Command::Reidentify(ids[0], temporary),
Command::Reidentify(ids[1], ids[0]),
Command::Reidentify(temporary, ids[1]),
],
)
.unwrap();
}
assert_eq!(f.project, before);
}
#[test]
fn colliding_lane_or_message_remap_rejects_before_any_reference_changes() {
for message in [false, true] {
let mut f = fixture();
let [old, new] = if message { f.messages } else { f.lanes };
let before = f.project.clone();
assert!(check_remap(&f.project, old, new).is_err());
assert!(remap_references(&mut f.project, old, new).is_err());
assert_eq!(f.project, before);
}
}
#[test]
fn reidentify_preflight_precedes_object_map_mutation_for_fresh_colliding_target() {
let mut f = fixture();
let fresh = Id::new_v4();
// A repair candidate can already have a dangling coordinate key. It must not
// silently merge with the live lane, even though the destination ID is vacant.
layout_mut(&mut f.project, f.views[0]).lanes[1].lifeline = fresh;
let before = f.project.clone();
assert!(crate::session::reidentify(&mut f.project, f.lanes[0], fresh).is_err());
assert_eq!(f.project, before);
}
#[test]
fn full_address_paths_are_walked_and_collision_checked_without_admitting_expansion() {
let mut f = fixture();
let [old, new] = [Id::new_v4(), Id::new_v4()];
let rows = layout_mut(&mut f.project, f.views[0]);
// Future/invalid expanded candidates still participate in reference safety.
rows.messages[0].address.references = vec![old];
rows.messages[1].address = OccurrenceEndpoint {
references: vec![new],
occurrence: f.messages[0],
};
let refs = diagram_references(&f.project.diagrams[&f.views[0]]);
assert!(refs.contains(&f.owner) && refs.contains(&old) && refs.contains(&new));
let before = f.project.clone();
assert!(remap_references(&mut f.project, old, new).is_err());
assert_eq!(f.project, before);
let fresh = Id::new_v4();
remap_references(&mut f.project, old, fresh).unwrap();
let rows = layout(&f.project, f.views[0]);
assert_eq!(rows.messages[0].address.references, vec![fresh]);
assert_eq!(rows.messages[0].address.occurrence, f.messages[0]);
assert_eq!(rows.messages[1].address.references, vec![new]);
}
#[test]
fn owner_reidentify_and_delete_cover_all_saved_views_without_deleting_other_owners() {
let mut f = fixture();
let other_owner = f
.project
.add_element(Kind::Scenario, Layer::Oa, "Independent", None)
.unwrap();
let mut other_view = f.project.diagrams[&f.views[0]].clone();
other_view.id = Id::new_v4();
other_view.alias = "independent_view".into();
other_view.projection = ProjectionKind::Scenario {
scenario: other_owner,
};
other_view.layout = None;
f.project.diagrams.insert(other_view.id, other_view.clone());
admitted(&f.project);
let fresh_owner = Id::new_v4();
f.project = crate::prepare_command(&f.project, &Command::Reidentify(f.owner, fresh_owner))
.unwrap()
.project;
for view in f.views {
assert_eq!(
f.project.diagrams[&view].projection.scenario(),
Some(fresh_owner)
);
}
let impact = deletion_impact(&f.project, fresh_owner);
assert!(
f.views
.iter()
.chain(&f.lanes)
.chain(&f.messages)
.all(|id| impact.contains(id))
);
assert!(!impact.contains(&other_owner) && !impact.contains(&other_view.id));
delete_cascade(&mut f.project, fresh_owner);
assert_eq!(f.project.diagrams[&other_view.id], other_view);
assert!(
f.views
.iter()
.all(|id| !f.project.diagrams.contains_key(id))
);
admitted(&f.project);
}
#[test]
fn deletion_then_recreation_never_resurrects_lane_or_message_coordinates() {
let mut f = fixture();
let lane = f.project.elements[&f.lanes[0]].clone();
let message = f.project.elements[&f.messages[0]].clone();
delete_cascade(&mut f.project, lane.id);
for view in f.views {
let rows = layout(&f.project, view);
assert_eq!(rows.lanes.len(), 1);
assert_eq!(rows.lanes[0].lifeline, f.lanes[1]);
// Missing endpoint assignment does not erase a still-live message's spacing.
assert_eq!(rows.messages.len(), 2);
}
let mut pending = BTreeSet::new();
delete_cascade_deferred(&mut f.project, message.id, &mut pending);
// Pruning happens during deletion, before later batch commands can recreate IDs.
assert!(
layout(&f.project, f.views[0])
.messages
.iter()
.all(|row| row.address.occurrence != message.id)
);
f.project.elements.insert(lane.id, lane);
f.project.elements.insert(message.id, message);
f.project
.elements
.get_mut(&f.owner)
.unwrap()
.steps_mut()
.unwrap()
.insert(0, f.messages[0]);
finish_deletions(&mut f.project, &pending);
for view in f.views {
let rows = layout(&f.project, view);
assert_eq!(rows.lanes.len(), 1);
assert_eq!(
rows.messages,
vec![ScenarioMessagePosition {
address: address(f.messages[1]),
send_y: 360.
}]
);
}
admitted(&f.project);
}
#[test]
fn unrelated_invalid_records_do_not_block_scoped_remap_or_get_repaired_implicitly() {
let mut f = fixture();
let unrelated = Id::new_v4();
let rows = layout_mut(&mut f.project, f.views[1]);
let invalid = ScenarioLanePosition {
lifeline: unrelated,
x: MAX_COORDINATE * 2.,
};
rows.lanes.extend([invalid.clone(), invalid]);
let fresh = Id::new_v4();
let untouched = f.project.diagrams[&f.views[1]].clone();
let before = f.project.clone();
remap_references(&mut f.project, f.owner, f.owner).unwrap();
assert_eq!(f.project, before);
// The duplicate lane records do not involve the remapped message identity.
remap_references(&mut f.project, f.messages[0], fresh).unwrap();
assert_eq!(
layout(&f.project, f.views[1]).lanes,
layout_from(&untouched).lanes
);
assert_eq!(
layout(&f.project, f.views[1]).messages[0]
.address
.occurrence,
fresh
);
assert_eq!(layout(&f.project, f.views[1]).messages[0].send_y, 220.);
}
fn layout_from(diagram: &Diagram) -> &ScenarioLayout {
let Some(DiagramLayout::Scenario(layout)) = &diagram.layout else {
panic!("missing layout")
};
layout
}
#[test]
fn deletion_prunes_an_explicit_address_path_without_pruning_other_message_rows() {
let mut f = fixture();
// Expanded addresses are not admitted in this slice, but deleting their
// explicit references must still make an intermediate repair candidate safe.
layout_mut(&mut f.project, f.views[0]).messages[0]
.address
.references = vec![f.lanes[0]];
delete_cascade(&mut f.project, f.lanes[0]);
assert_eq!(
layout(&f.project, f.views[0]).messages,
vec![ScenarioMessagePosition {
address: address(f.messages[1]),
send_y: 360.
}]
);
assert_eq!(layout(&f.project, f.views[1]).messages.len(), 2);
assert!(f.project.elements.contains_key(&f.messages[0]));
admitted(&f.project);
}
+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 }
}
-429
View File
@@ -1,429 +0,0 @@
//! Authored Scenario presentation. Structural admission never changes event order.
use crate::{
Diagram, Id, Kind, Layer, MAX_COORDINATE, ModelError, OccurrenceBody, OccurrenceEndpoint,
Project, SharedBody, validation,
};
use serde::{Deserialize, Serialize};
use std::collections::BTreeSet;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub enum DiagramLayout {
Scenario(ScenarioLayout),
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct ScenarioLayout {
#[serde(default)]
pub lanes: Vec<ScenarioLanePosition>,
#[serde(default)]
pub messages: Vec<ScenarioMessagePosition>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ScenarioLanePosition {
pub lifeline: Id,
pub x: f32,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ScenarioMessagePosition {
pub address: OccurrenceEndpoint,
/// Actual send endpoint, not the renderer's receive-oriented lifecycle row.
pub send_y: f32,
}
impl DiagramLayout {
/// Visit every explicit reference, including unsupported paths in a candidate.
/// The caller owns remap preflight and final atomic validation.
pub fn visit_references(&mut self, mut visit: impl FnMut(&mut Id)) {
match self {
Self::Scenario(layout) => {
for lane in &mut layout.lanes {
visit(&mut lane.lifeline);
}
for message in &mut layout.messages {
for reference in &mut message.address.references {
visit(reference);
}
visit(&mut message.address.occurrence);
}
}
}
}
}
/// Proposed aggregate accounting contribution; the shared admission owner applies it.
/// One payload plus one record per lane/message. No graph objects are introduced.
pub fn record_count(diagram: &Diagram) -> usize {
match &diagram.layout {
None => 0,
Some(DiagramLayout::Scenario(layout)) => 1usize
.saturating_add(layout.lanes.len())
.saturating_add(layout.messages.len()),
}
}
fn coordinate(value: f32) -> bool {
value.is_finite() && value.abs() <= MAX_COORDINATE
}
fn owner(p: &Project, diagram: &Diagram) -> Result<Id, ModelError> {
let scenario = diagram
.projection
.scenario()
.ok_or_else(|| ModelError("Scenario layout requires a Scenario projection.".into()))?;
if !p
.elements
.get(&scenario)
.is_some_and(|element| element.kind() == Kind::Scenario && element.layer() == diagram.layer)
{
return Err(ModelError(
"Scenario projection requires an existing Scenario in the diagram layer.".into(),
));
}
Ok(scenario)
}
fn lane_member(p: &Project, owner: Id, layer: Layer, lifeline: Id) -> bool {
p.elements.get(&lifeline).is_some_and(|element| {
element.kind() == Kind::Lifeline
&& element.parent == Some(owner)
&& element.layer() == layer
})
}
/// Only actual steps of this Scenario and its own Fragment/Branch sequences.
/// ScenarioUse/SequenceReference are deliberately never expanded. Invalid graph
/// cycles terminate, and malformed parent/step links do not manufacture membership.
fn direct_messages(p: &Project, owner: Id, layer: Layer) -> BTreeSet<Id> {
let mut messages = BTreeSet::new();
let mut visited = BTreeSet::new();
let mut pending = vec![owner];
while let Some(scope) = pending.pop() {
if !visited.insert(scope) {
continue;
}
let Some(element) = p.elements.get(&scope) else {
continue;
};
for id in element.steps().into_iter().flatten() {
let Some(item) = p.elements.get(id) else {
continue;
};
if item.parent != Some(scope) || item.layer() != layer {
continue;
}
match item.shared() {
Some(SharedBody::Occurrence {
body: OccurrenceBody::Exchange { .. },
..
}) => {
messages.insert(*id);
}
Some(SharedBody::Occurrence {
body: OccurrenceBody::Fragment { branches, .. },
..
}) => {
for branch in branches {
if p.elements.get(branch).is_some_and(|operand| {
operand.parent == Some(*id)
&& operand.layer() == layer
&& matches!(
operand.shared(),
Some(SharedBody::Occurrence {
body: OccurrenceBody::Branch { .. },
..
})
)
}) {
pending.push(*branch);
}
}
}
_ => {}
}
}
}
messages
}
pub(crate) fn validate_diagram(p: &Project, diagram: &Diagram, out: &mut Vec<validation::Issue>) {
if diagram.projection.scenario().is_none() {
if diagram.layout.is_some() {
validation::error(
out,
diagram.id,
"scenario_layout_projection",
"layout",
"Only a Scenario projection accepts a Scenario layout, including an empty payload.",
);
}
return;
}
let scenario = match owner(p, diagram) {
Ok(owner) => owner,
Err(error) => {
validation::error(
out,
diagram.id,
"scenario_diagram_context",
"projection",
error.0,
);
return;
}
};
if !diagram.nodes.is_empty() || !diagram.routes.is_empty() {
validation::error(
out,
diagram.id,
"scenario_diagram_content",
"layout",
"Scenario views do not accept architecture node placements or edge routes.",
);
}
let Some(DiagramLayout::Scenario(layout)) = &diagram.layout else {
return;
};
let mut lanes = BTreeSet::new();
for lane in &layout.lanes {
if !lanes.insert(lane.lifeline) {
validation::error(
out,
diagram.id,
"duplicate_scenario_lane",
"layout",
"A Scenario layout has at most one position per lifeline.",
);
}
if !lane_member(p, scenario, diagram.layer, lane.lifeline) {
validation::error(
out,
diagram.id,
"scenario_layout_lifeline",
"layout",
"Lane positions reference a Lifeline directly owned by the projected Scenario.",
);
}
if !coordinate(lane.x) {
validation::error(
out,
diagram.id,
"scenario_layout_coordinate",
"layout",
"Scenario positions must be finite and within the shared coordinate bounds.",
);
}
}
let members = direct_messages(p, scenario, diagram.layer);
let mut addresses = BTreeSet::new();
for message in &layout.messages {
// Reject expanded paths without cloning or indexing their potentially large contents.
if !message.address.references.is_empty() {
validation::error(
out,
diagram.id,
"scenario_layout_address",
"layout",
"Expanded-reference message positions are not supported; references must be empty.",
);
} else if !addresses.insert(message.address.occurrence) {
validation::error(
out,
diagram.id,
"duplicate_scenario_message",
"layout",
"A Scenario layout has at most one position per message address.",
);
}
if !members.contains(&message.address.occurrence) {
validation::error(
out,
diagram.id,
"scenario_layout_message",
"layout",
"Message positions reference Exchange occurrences in the Scenario's own ordered sequence tree.",
);
}
if !coordinate(message.send_y) {
validation::error(
out,
diagram.id,
"scenario_layout_coordinate",
"layout",
"Scenario positions must be finite and within the shared coordinate bounds.",
);
}
}
}
fn editable(p: &Project, id: Id) -> Result<(&Diagram, Id), ModelError> {
let diagram = p
.diagrams
.get(&id)
.ok_or_else(|| ModelError("Scenario diagram does not exist.".into()))?;
let scenario = owner(p, diagram)?;
if !diagram.nodes.is_empty() || !diagram.routes.is_empty() {
return Err(ModelError(
"Scenario views do not accept architecture node placements or edge routes.".into(),
));
}
Ok((diagram, scenario))
}
fn check_message(
p: &Project,
diagram: &Diagram,
owner: Id,
address: &OccurrenceEndpoint,
) -> Result<(), ModelError> {
if !address.references.is_empty() {
return Err(ModelError(
"Expanded-reference message positions are not supported; references must be empty."
.into(),
));
}
if !direct_messages(p, owner, diagram.layer).contains(&address.occurrence) {
return Err(ModelError("Message position requires an Exchange occurrence in the Scenario's own ordered sequence tree.".into()));
}
Ok(())
}
fn payload(diagram: &mut Diagram) -> &mut ScenarioLayout {
let DiagramLayout::Scenario(layout) = diagram
.layout
.get_or_insert_with(|| DiagramLayout::Scenario(ScenarioLayout::default()));
layout
}
/// Target-scoped preparation, not whole-project or whole-layout validation.
/// Unrelated intermediate errors remain repairable by later commands in a batch.
pub fn set_lane(
p: &Project,
diagram: Id,
position: ScenarioLanePosition,
) -> Result<Diagram, ModelError> {
let (current, owner) = editable(p, diagram)?;
if !lane_member(p, owner, current.layer, position.lifeline) || !coordinate(position.x) {
return Err(ModelError(
"Lane position requires an owned Lifeline and a finite bounded x coordinate.".into(),
));
}
let mut result = current.clone();
let layout = payload(&mut result);
if layout
.lanes
.iter()
.filter(|row| row.lifeline == position.lifeline)
.count()
> 1
{
return Err(ModelError(
"Cannot set an ambiguous duplicate lane position.".into(),
));
}
if let Some(existing) = layout
.lanes
.iter_mut()
.find(|row| row.lifeline == position.lifeline)
{
if existing.x != position.x {
*existing = position;
}
} else {
layout.lanes.push(position);
}
Ok(result)
}
pub fn remove_lane(p: &Project, diagram: Id, lifeline: Id) -> Result<Diagram, ModelError> {
let (current, owner) = editable(p, diagram)?;
if !lane_member(p, owner, current.layer, lifeline) {
return Err(ModelError(
"Removing a lane position requires an owned Lifeline.".into(),
));
}
let mut result = current.clone();
if let Some(DiagramLayout::Scenario(layout)) = &mut result.layout {
layout.lanes.retain(|row| row.lifeline != lifeline);
}
Ok(result)
}
pub fn set_message(
p: &Project,
diagram: Id,
position: ScenarioMessagePosition,
) -> Result<Diagram, ModelError> {
let (current, owner) = editable(p, diagram)?;
check_message(p, current, owner, &position.address)?;
if !coordinate(position.send_y) {
return Err(ModelError(
"Message position requires a finite bounded send_y coordinate.".into(),
));
}
let mut result = current.clone();
let layout = payload(&mut result);
if layout
.messages
.iter()
.filter(|row| row.address == position.address)
.count()
> 1
{
return Err(ModelError(
"Cannot set an ambiguous duplicate message position.".into(),
));
}
if let Some(existing) = layout
.messages
.iter_mut()
.find(|row| row.address == position.address)
{
if existing.send_y != position.send_y {
*existing = position;
}
} else {
layout.messages.push(position);
}
Ok(result)
}
pub fn remove_message(
p: &Project,
diagram: Id,
address: &OccurrenceEndpoint,
) -> Result<Diagram, ModelError> {
let (current, owner) = editable(p, diagram)?;
check_message(p, current, owner, address)?;
let mut result = current.clone();
if let Some(DiagramLayout::Scenario(layout)) = &mut result.layout {
layout.messages.retain(|row| &row.address != address);
}
Ok(result)
}
/// Prune only identities in the caller's actual deletion closure, never visibility
/// or missing endpoint assignments. Owner-view cascade remains the caller's job.
pub fn prune_deleted(diagram: &mut Diagram, deleted: &BTreeSet<Id>) {
if let Some(DiagramLayout::Scenario(layout)) = &mut diagram.layout {
layout
.lanes
.retain(|lane| !deleted.contains(&lane.lifeline));
layout.messages.retain(|message| {
!deleted.contains(&message.address.occurrence)
&& !message
.address
.references
.iter()
.any(|id| deleted.contains(id))
});
}
}
#[cfg(test)]
mod tests;
-802
View File
@@ -1,802 +0,0 @@
use super::*;
use crate::{
Element, FragmentOperator, MessageKind, ProjectionKind, Relation, RelationBody, RelationKind,
ScenarioViewpoint,
};
use std::collections::BTreeMap;
struct Fixture {
project: Project,
owner: Id,
performer: Id,
lane: Id,
repeated_lane: Id,
first: Id,
second: Id,
nested: Id,
fragment: Id,
branch: Id,
view: Id,
other_view: Id,
exchange: Id,
}
fn set_body(element: &mut Element, body: OccurrenceBody) {
*element.shared_mut().unwrap() = SharedBody::Occurrence {
body,
precondition: String::new(),
postcondition: String::new(),
};
}
fn address(occurrence: Id) -> OccurrenceEndpoint {
OccurrenceEndpoint {
references: vec![],
occurrence,
}
}
fn diagram(owner: Id, alias: &str) -> Diagram {
Diagram {
id: Id::new_v4(),
alias: alias.into(),
name: alias.into(),
description: String::new(),
layer: Layer::Oa,
projection: ProjectionKind::Scenario { scenario: owner },
view_options: Default::default(),
nodes: BTreeMap::new(),
routes: vec![],
layout: None,
}
}
fn assert_admitted(project: &Project) {
let errors: Vec<_> = validation::validate(project)
.into_iter()
.filter(|issue| issue.severity == validation::Severity::Error)
.collect();
assert!(errors.is_empty(), "{errors:?}");
}
fn has_code(project: &Project, code: &str) -> bool {
validation::validate(project)
.iter()
.any(|issue| issue.code == code)
}
fn layout(diagram: &Diagram) -> &ScenarioLayout {
let Some(DiagramLayout::Scenario(layout)) = &diagram.layout else {
panic!("expected saved payload")
};
layout
}
fn save(project: &mut Project, replacement: Diagram) {
project.diagrams.insert(replacement.id, replacement);
}
fn fixture() -> Fixture {
let mut project = Project::empty();
let owner = project
.add_element(Kind::Scenario, Layer::Oa, "Nominal", None)
.unwrap();
let Some(SharedBody::Scenario { viewpoint, .. }) =
project.elements.get_mut(&owner).unwrap().shared_mut()
else {
panic!("Scenario fixture body");
};
*viewpoint = ScenarioViewpoint::ParticipantInteraction;
let performer = project
.add_element(Kind::Entity, Layer::Oa, "Controller", None)
.unwrap();
let receiver = project
.add_element(Kind::Entity, Layer::Oa, "Provider", None)
.unwrap();
let lane = project
.add_element(Kind::Lifeline, Layer::Oa, "Controller lane", Some(owner))
.unwrap();
let repeated_lane = project
.add_element(
Kind::Lifeline,
Layer::Oa,
"Controller review lane",
Some(owner),
)
.unwrap();
let receiver_lane = project
.add_element(Kind::Lifeline, Layer::Oa, "Provider lane", Some(owner))
.unwrap();
for (id, represented) in [
(lane, performer),
(repeated_lane, performer),
(receiver_lane, receiver),
] {
*project.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::Lifeline {
represented: Some(represented),
};
}
let exchange = Id::new_v4();
project.relations.insert(
exchange,
Relation {
id: exchange,
alias: "delivery_channel".into(),
name: "Delivery".into(),
description: String::new(),
body: RelationBody::new(RelationKind::CommunicationMean, performer, receiver),
},
);
let first = project
.add_element(Kind::Occurrence, Layer::Oa, "First delivery", Some(owner))
.unwrap();
let second = project
.add_element(Kind::Occurrence, Layer::Oa, "Second delivery", Some(owner))
.unwrap();
for (id, sender) in [(first, lane), (second, repeated_lane)] {
set_body(
project.elements.get_mut(&id).unwrap(),
OccurrenceBody::Exchange {
exchange: Some(exchange),
sender: Some(sender),
receiver: Some(receiver_lane),
message_kind: MessageKind::Asynchronous,
reply_to: None,
values: vec![],
operation: None,
},
);
}
let fragment = project
.add_element(
Kind::Occurrence,
Layer::Oa,
"Optional delivery",
Some(owner),
)
.unwrap();
set_body(
project.elements.get_mut(&fragment).unwrap(),
OccurrenceBody::Fragment {
operator: FragmentOperator::Optional,
branches: vec![],
},
);
let branch = project
.add_element(Kind::Occurrence, Layer::Oa, "Option body", Some(fragment))
.unwrap();
let nested = project
.add_element(
Kind::Occurrence,
Layer::Oa,
"Unfinished nested delivery",
Some(branch),
)
.unwrap();
set_body(
project.elements.get_mut(&nested).unwrap(),
OccurrenceBody::Exchange {
exchange: None,
sender: None,
receiver: None,
message_kind: MessageKind::Asynchronous,
reply_to: None,
values: vec![],
operation: None,
},
);
set_body(
project.elements.get_mut(&branch).unwrap(),
OccurrenceBody::Branch {
guard: "requested".into(),
occurrences: vec![nested],
},
);
set_body(
project.elements.get_mut(&fragment).unwrap(),
OccurrenceBody::Fragment {
operator: FragmentOperator::Optional,
branches: vec![branch],
},
);
*project
.elements
.get_mut(&owner)
.unwrap()
.steps_mut()
.unwrap() = vec![first, second, fragment];
let view_record = diagram(owner, "nominal_view");
let other_record = diagram(owner, "review_view");
let view = view_record.id;
let other_view = other_record.id;
save(&mut project, view_record);
save(&mut project, other_record);
assert_admitted(&project);
Fixture {
project,
owner,
performer,
lane,
repeated_lane,
first,
second,
nested,
fragment,
branch,
view,
other_view,
exchange,
}
}
#[test]
fn two_views_and_repeated_semantic_definitions_keep_independent_overrides() {
let mut f = fixture();
let semantics = (f.project.elements.clone(), f.project.relations.clone());
for (view, line, x) in [
(f.view, f.lane, 420.),
(f.view, f.repeated_lane, 680.),
(f.other_view, f.lane, 900.),
] {
let result =
set_lane(&f.project, view, ScenarioLanePosition { lifeline: line, x }).unwrap();
save(&mut f.project, result);
}
for (view, id, y) in [
(f.view, f.first, 200.),
(f.view, f.second, 350.),
(f.other_view, f.first, 260.),
] {
let result = set_message(
&f.project,
view,
ScenarioMessagePosition {
address: address(id),
send_y: y,
},
)
.unwrap();
save(&mut f.project, result);
}
assert_eq!(
layout(&f.project.diagrams[&f.view]).lanes,
vec![
ScenarioLanePosition {
lifeline: f.lane,
x: 420.
},
ScenarioLanePosition {
lifeline: f.repeated_lane,
x: 680.
},
]
);
assert_eq!(
layout(&f.project.diagrams[&f.view])
.messages
.iter()
.map(|r| (r.address.occurrence, r.send_y))
.collect::<Vec<_>>(),
vec![(f.first, 200.), (f.second, 350.)]
);
assert_eq!(
layout(&f.project.diagrams[&f.other_view]).messages[0].send_y,
260.
);
assert_eq!(layout(&f.project.diagrams[&f.other_view]).lanes[0].x, 900.);
assert_eq!(
(f.project.elements.clone(), f.project.relations.clone()),
semantics
);
assert_admitted(&f.project);
}
#[test]
fn own_nested_unfinished_message_is_admitted_but_expansion_is_not() {
let mut f = fixture();
let result = set_message(
&f.project,
f.view,
ScenarioMessagePosition {
address: address(f.nested),
send_y: -80.,
},
)
.unwrap();
save(&mut f.project, result);
assert_admitted(&f.project);
let before = f.project.clone();
let expanded = OccurrenceEndpoint {
references: vec![f.fragment],
occurrence: f.nested,
};
assert!(
set_message(
&f.project,
f.view,
ScenarioMessagePosition {
address: expanded.clone(),
send_y: 400.
}
)
.is_err()
);
assert!(remove_message(&f.project, f.view, &expanded).is_err());
assert_eq!(f.project, before);
payload(f.project.diagrams.get_mut(&f.view).unwrap()).messages[0].address = expanded;
assert!(has_code(&f.project, "scenario_layout_address"));
}
#[test]
fn only_ordered_message_appearances_and_owned_lifelines_are_targets() {
let mut f = fixture();
assert!(
set_lane(
&f.project,
f.view,
ScenarioLanePosition {
lifeline: f.performer,
x: 10.
}
)
.is_err()
);
for id in [f.exchange, f.fragment, f.branch, f.owner, Id::new_v4()] {
assert!(
set_message(
&f.project,
f.view,
ScenarioMessagePosition {
address: address(id),
send_y: 100.
}
)
.is_err()
);
}
let foreign = f
.project
.add_element(Kind::Scenario, Layer::Oa, "Foreign", None)
.unwrap();
let foreign_lane = f
.project
.add_element(Kind::Lifeline, Layer::Oa, "Foreign lane", Some(foreign))
.unwrap();
assert!(remove_lane(&f.project, f.view, foreign_lane).is_err());
f.project.elements.get_mut(&f.first).unwrap().parent = Some(foreign);
assert!(
set_message(
&f.project,
f.view,
ScenarioMessagePosition {
address: address(f.first),
send_y: 100.
}
)
.is_err()
);
f.project.elements.get_mut(&f.first).unwrap().parent = Some(f.owner);
f.project
.elements
.get_mut(&f.owner)
.unwrap()
.steps_mut()
.unwrap()
.retain(|id| *id != f.first);
assert!(
set_message(
&f.project,
f.view,
ScenarioMessagePosition {
address: address(f.first),
send_y: 100.
}
)
.is_err()
);
}
#[test]
fn duplicate_positions_reject_even_when_values_match() {
let mut f = fixture();
let lane = ScenarioLanePosition {
lifeline: f.lane,
x: 10.,
};
let message = ScenarioMessagePosition {
address: address(f.first),
send_y: 100.,
};
let entries = payload(f.project.diagrams.get_mut(&f.view).unwrap());
entries.lanes = vec![lane.clone(), lane.clone()];
entries.messages = vec![message.clone(), message.clone()];
assert!(has_code(&f.project, "duplicate_scenario_lane"));
assert!(has_code(&f.project, "duplicate_scenario_message"));
assert!(set_lane(&f.project, f.view, lane).is_err());
assert!(set_message(&f.project, f.view, message).is_err());
// Explicit removal can repair all duplicates of the targeted identity.
let result = remove_lane(&f.project, f.view, f.lane).unwrap();
save(&mut f.project, result);
let result = remove_message(&f.project, f.view, &address(f.first)).unwrap();
save(&mut f.project, result);
assert_admitted(&f.project);
}
#[test]
fn sparse_absence_empty_payload_and_signed_zero_are_preserved() {
let mut f = fixture();
let absent = f.project.diagrams[&f.view].clone();
assert_eq!(remove_lane(&f.project, f.view, f.lane).unwrap(), absent);
assert_eq!(
remove_message(&f.project, f.view, &address(f.first)).unwrap(),
absent
);
assert!(
serde_json::to_value(&absent)
.unwrap()
.get("layout")
.is_none()
);
let result = set_lane(
&f.project,
f.view,
ScenarioLanePosition {
lifeline: f.lane,
x: -0.,
},
)
.unwrap();
save(&mut f.project, result);
let result = set_message(
&f.project,
f.view,
ScenarioMessagePosition {
address: address(f.first),
send_y: -0.,
},
)
.unwrap();
save(&mut f.project, result);
let result = set_lane(
&f.project,
f.view,
ScenarioLanePosition {
lifeline: f.lane,
x: 0.,
},
)
.unwrap();
assert_eq!(layout(&result).lanes[0].x.to_bits(), (-0.0_f32).to_bits());
let result = set_message(
&f.project,
f.view,
ScenarioMessagePosition {
address: address(f.first),
send_y: 0.,
},
)
.unwrap();
assert_eq!(
layout(&result).messages[0].send_y.to_bits(),
(-0.0_f32).to_bits()
);
assert_eq!(
serde_json::to_string(&result).unwrap(),
serde_json::to_string(&f.project.diagrams[&f.view]).unwrap()
);
let result = remove_lane(&f.project, f.view, f.lane).unwrap();
save(&mut f.project, result);
let result = remove_message(&f.project, f.view, &address(f.first)).unwrap();
assert_eq!(
result.layout,
Some(DiagramLayout::Scenario(ScenarioLayout::default()))
);
assert_ne!(result, absent);
}
#[test]
fn bounds_are_structural_and_rejected_helpers_do_not_mutate_project() {
let mut f = fixture();
for value in [-MAX_COORDINATE, MAX_COORDINATE, -7., 0.] {
let result = set_lane(
&f.project,
f.view,
ScenarioLanePosition {
lifeline: f.lane,
x: value,
},
)
.unwrap();
save(&mut f.project, result);
let result = set_message(
&f.project,
f.view,
ScenarioMessagePosition {
address: address(f.first),
send_y: value,
},
)
.unwrap();
save(&mut f.project, result);
assert_admitted(&f.project);
}
let before = serde_json::to_string(&f.project).unwrap();
for value in [
f32::NAN,
f32::INFINITY,
f32::NEG_INFINITY,
MAX_COORDINATE + 1.,
] {
assert!(
set_lane(
&f.project,
f.view,
ScenarioLanePosition {
lifeline: f.lane,
x: value
}
)
.is_err()
);
assert!(
set_message(
&f.project,
f.view,
ScenarioMessagePosition {
address: address(f.first),
send_y: value
}
)
.is_err()
);
}
assert_eq!(serde_json::to_string(&f.project).unwrap(), before);
payload(f.project.diagrams.get_mut(&f.view).unwrap()).messages[0].send_y = f32::NAN;
assert!(has_code(&f.project, "scenario_layout_coordinate"));
}
#[test]
fn helpers_do_not_validate_unrelated_transient_records_in_repair_batches() {
let mut f = fixture();
payload(f.project.diagrams.get_mut(&f.view).unwrap())
.messages
.push(ScenarioMessagePosition {
address: address(f.second),
send_y: f32::INFINITY,
});
let result = set_lane(
&f.project,
f.view,
ScenarioLanePosition {
lifeline: f.lane,
x: 420.,
},
)
.unwrap();
save(&mut f.project, result);
assert!(has_code(&f.project, "scenario_layout_coordinate"));
let result = set_message(
&f.project,
f.view,
ScenarioMessagePosition {
address: address(f.second),
send_y: 300.,
},
)
.unwrap();
save(&mut f.project, result);
assert_admitted(&f.project);
}
#[test]
fn projection_context_and_architecture_payloads_are_explicitly_separate() {
let mut f = fixture();
let scenario = f.project.diagrams[&f.view].projection;
assert_eq!(scenario.scenario(), Some(f.owner));
assert!(!scenario.is_architecture());
assert!(!scenario.includes(Kind::Lifeline));
assert!(ProjectionKind::Overview.is_architecture());
assert!(!ProjectionKind::StateMachine { machine: f.owner }.is_architecture());
f.project.diagrams.get_mut(&f.view).unwrap().layer = Layer::Sa;
assert!(has_code(&f.project, "scenario_diagram_context"));
assert!(remove_lane(&f.project, f.view, f.lane).is_err());
f.project.diagrams.get_mut(&f.view).unwrap().layer = Layer::Oa;
f.project
.diagrams
.get_mut(&f.view)
.unwrap()
.routes
.push(crate::EdgeRoute {
edge: f.exchange,
source_attachment: None,
target_attachment: None,
waypoints: vec![],
label: None,
});
assert!(has_code(&f.project, "scenario_diagram_content"));
f.project.diagrams.get_mut(&f.view).unwrap().routes.clear();
payload(f.project.diagrams.get_mut(&f.view).unwrap());
f.project.diagrams.get_mut(&f.view).unwrap().projection = ProjectionKind::Overview;
assert!(has_code(&f.project, "scenario_layout_projection"));
assert!(
set_lane(
&f.project,
f.view,
ScenarioLanePosition {
lifeline: f.lane,
x: 10.
}
)
.is_err()
);
}
#[test]
fn typed_json_records_round_trip_and_reject_unknown_or_missing_fields() {
let mut f = fixture();
let result = set_message(
&f.project,
f.view,
ScenarioMessagePosition {
address: address(f.first),
send_y: 200.,
},
)
.unwrap();
save(&mut f.project, result);
let encoded = serde_json::to_value(&f.project.diagrams[&f.view]).unwrap();
assert_eq!(
serde_json::from_value::<Diagram>(encoded.clone()).unwrap(),
f.project.diagrams[&f.view]
);
assert_eq!(
encoded["layout"]["Scenario"]["messages"][0]["address"]["occurrence"],
serde_json::json!(f.first)
);
let mut unknown = encoded.clone();
unknown["layout"]["Scenario"]["messages"][0]["extra"] = serde_json::json!(1);
assert!(serde_json::from_value::<Diagram>(unknown).is_err());
let mut missing = encoded;
missing["layout"]["Scenario"]["messages"][0]["address"]
.as_object_mut()
.unwrap()
.remove("references");
assert!(serde_json::from_value::<Diagram>(missing).is_err());
}
#[test]
fn deleting_only_actual_ids_preserves_latent_message_positions_and_other_views() {
let mut f = fixture();
let result = set_lane(
&f.project,
f.view,
ScenarioLanePosition {
lifeline: f.lane,
x: 420.,
},
)
.unwrap();
save(&mut f.project, result);
let result = set_message(
&f.project,
f.view,
ScenarioMessagePosition {
address: address(f.first),
send_y: 200.,
},
)
.unwrap();
save(&mut f.project, result);
let mut copied = f.project.diagrams[&f.view].clone();
copied.id = f.other_view;
copied.alias = "review_view".into();
copied.name = "Review view".into();
save(&mut f.project, copied.clone());
assert_admitted(&f.project);
assert_eq!(layout(&copied).messages[0].address.occurrence, f.first);
let mut candidate = f.project.diagrams[&f.view].clone();
prune_deleted(&mut candidate, &BTreeSet::from([f.lane, f.exchange]));
assert!(layout(&candidate).lanes.is_empty());
assert_eq!(layout(&candidate).messages.len(), 1);
prune_deleted(&mut candidate, &BTreeSet::from([f.first]));
assert!(layout(&candidate).messages.is_empty());
assert!(candidate.layout.is_some());
assert_eq!(f.project.diagrams[&f.other_view], copied);
}
#[test]
fn reference_visitor_and_record_count_cover_explicit_rows() {
let f = fixture();
let mut d = f.project.diagrams[&f.view].clone();
assert_eq!(record_count(&d), 0);
let rows = payload(&mut d);
rows.lanes.push(ScenarioLanePosition {
lifeline: f.lane,
x: 50.,
});
rows.messages.push(ScenarioMessagePosition {
address: address(f.first),
send_y: 100.,
});
assert_eq!(record_count(&d), 3);
let replacement = Id::new_v4();
d.layout.as_mut().unwrap().visit_references(|id| {
if *id == f.first {
*id = replacement;
}
});
assert_eq!(layout(&d).messages[0].address.occurrence, replacement);
assert_eq!(layout(&d).messages[0].send_y, 100.);
assert_eq!(layout(&d).lanes[0].lifeline, f.lane);
}
#[test]
fn a_real_scenario_use_never_imports_its_called_message_as_a_direct_target() {
let mut f = fixture();
let called = f
.project
.add_element(Kind::Scenario, Layer::Oa, "Called sequence", None)
.unwrap();
let Some(SharedBody::Scenario { viewpoint, .. }) =
f.project.elements.get_mut(&called).unwrap().shared_mut()
else {
panic!("Called Scenario fixture body");
};
*viewpoint = ScenarioViewpoint::ParticipantInteraction;
let called_message = f
.project
.add_element(Kind::Occurrence, Layer::Oa, "Called delivery", Some(called))
.unwrap();
set_body(
f.project.elements.get_mut(&called_message).unwrap(),
OccurrenceBody::Exchange {
exchange: None,
sender: None,
receiver: None,
message_kind: MessageKind::Asynchronous,
reply_to: None,
values: vec![],
operation: None,
},
);
*f.project
.elements
.get_mut(&called)
.unwrap()
.steps_mut()
.unwrap() = vec![called_message];
let usage = f
.project
.add_element(Kind::Occurrence, Layer::Oa, "Call sequence", Some(f.owner))
.unwrap();
set_body(
f.project.elements.get_mut(&usage).unwrap(),
OccurrenceBody::ScenarioUse {
scenario: Some(called),
bindings: vec![],
},
);
f.project
.elements
.get_mut(&f.owner)
.unwrap()
.steps_mut()
.unwrap()
.push(usage);
assert_admitted(&f.project);
for target in [
address(called_message),
address(usage),
OccurrenceEndpoint {
references: vec![usage],
occurrence: called_message,
},
] {
assert!(
set_message(
&f.project,
f.view,
ScenarioMessagePosition {
address: target,
send_y: 200.
}
)
.is_err()
);
}
}
mod source_lifecycle;
@@ -1,302 +0,0 @@
//! Source/command lifecycle checks for the candidate payload. Public version
//! assignment remains the coordinated codec integration gate.
use super::*;
use crate::{Command, Session, language, prepare_command, source_project::*};
fn authored(f: &Fixture) -> CompiledSourceProject {
let mut bundle = format_bundle(&f.project);
let initial = compile_bundle(&bundle, ProjectLimits::default()).unwrap();
let location = &initial.index.declarations[&f.view];
let source = bundle.files.get_mut(&location.path).unwrap();
let text = format!(
r#"diagram nominal_view "Nominal review" id "{}" {{
projection {{ Scenario {{ scenario @{}; }}; }};
layout {{ Scenario {{
lanes [
// Controller spacing rationale.
{{ lifeline @ // first lane identity
{}; x 4.20e2; }},
// Repeated performer has its own lane.
{{ lifeline @{}; x 680; }}
];
messages [
// First delivery timing rationale.
{{ address {{ references []; occurrence @{}; }}; send_y 2.00e2; }},
// Second delivery timing rationale.
{{ address {{ references []; occurrence @{}; }}; send_y 350; }}
];
}}; }};
}}
"#,
f.view,
f.project.elements[&f.owner].alias,
f.project.elements[&f.lane].alias,
f.project.elements[&f.repeated_lane].alias,
f.project.elements[&f.first].alias,
f.project.elements[&f.second].alias
);
source.replace_range(location.span.start..location.span.end, &text);
compile_bundle(&bundle, ProjectLimits::default()).unwrap()
}
#[test]
fn source_rows_follow_identity_order_and_keep_comments_numeric_spelling_and_file_ownership() {
let f = fixture();
let original = authored(&f);
let mut changed = original.decoded.project.clone();
let rows = payload(changed.diagrams.get_mut(&f.view).unwrap());
rows.lanes.swap(0, 1);
rows.messages.swap(0, 1);
rows.messages[0].send_y = 390.;
changed.elements.get_mut(&f.lane).unwrap().alias = "renamed_controller_lane".into();
let updated = update_bundle(&original, &changed, ProjectLimits::default()).unwrap();
assert_eq!(updated.decoded.project, changed);
let path = &original.index.declarations[&f.view].path;
assert_eq!(&updated.index.declarations[&f.view].path, path);
let text = &updated.bundle.files[path];
assert!(
text.find("// Repeated performer").unwrap() < text.find("// Controller spacing").unwrap(),
"{text}"
);
assert!(
text.find("// Second delivery").unwrap() < text.find("// First delivery").unwrap(),
"{text}"
);
assert!(
text.contains(
"lifeline @ // first lane identity\n renamed_controller_lane; x 4.20e2;"
),
"{text}"
);
assert!(text.contains("send_y 2.00e2"), "{text}");
assert!(text.contains("send_y 390"), "{text}");
for (other, bytes) in &original.bundle.files {
if other != path && !bytes.contains(&f.project.elements[&f.lane].alias) {
assert_eq!(&updated.bundle.files[other], bytes);
}
}
assert_eq!(
compile_bundle(&updated.bundle, ProjectLimits::default())
.unwrap()
.decoded
.project,
changed
);
}
#[test]
fn source_identity_swap_keeps_each_coordinate_comment_with_its_original_object() {
let f = fixture();
let original = authored(&f);
let temporary = Id::new_v4();
let command = Command::Batch(vec![
Command::Reidentify(f.first, temporary),
Command::Reidentify(f.second, f.first),
Command::Reidentify(temporary, f.second),
]);
let prepared = prepare_command(&original.decoded.project, &command).unwrap();
let mut changed = prepared.project;
payload(changed.diagrams.get_mut(&f.view).unwrap())
.messages
.swap(0, 1);
let updated = update_bundle_with_identity_map(
&original,
&changed,
&prepared.identity_map,
ProjectLimits::default(),
)
.unwrap();
assert_eq!(updated.decoded.project, changed);
let path = &original.index.declarations[&f.view].path;
let text = &updated.bundle.files[path];
assert!(
text.find("// Second delivery").unwrap() < text.find("// First delivery").unwrap(),
"{text}"
);
let first_comment = text.find("// First delivery").unwrap();
assert!(text[first_comment..].contains(&format!(
"occurrence @{}",
changed.elements[&f.second].alias
)));
assert!(text[first_comment..].contains("send_y 2.00e2"));
}
#[test]
fn coordinate_batch_is_one_undo_and_numeric_noop_keeps_source_exact() {
let f = fixture();
let original = authored(&f);
let mut session = Session::new(original.decoded.project.clone());
session
.execute(Command::Batch(vec![
Command::SetScenarioLanePosition {
diagram: f.view,
position: ScenarioLanePosition {
lifeline: f.lane,
x: 510.,
},
},
Command::SetScenarioMessagePosition {
diagram: f.view,
position: ScenarioMessagePosition {
address: address(f.second),
send_y: 430.,
},
},
]))
.unwrap();
let changed = session.project().clone();
assert_eq!(session.revision(), 1);
assert_eq!(changed.elements, original.decoded.project.elements);
assert_eq!(changed.relations, original.decoded.project.relations);
let updated = update_bundle(&original, &changed, ProjectLimits::default()).unwrap();
session.undo();
assert_eq!(session.project(), &original.decoded.project);
assert!(!session.can_undo());
session.redo();
assert_eq!(session.project(), &changed);
session
.execute(Command::SetScenarioMessagePosition {
diagram: f.view,
position: ScenarioMessagePosition {
address: address(f.second),
send_y: 430.,
},
})
.unwrap();
assert_eq!(session.revision(), 3);
let noop = update_bundle(&updated, session.project(), ProjectLimits::default()).unwrap();
assert_eq!(noop.bundle, updated.bundle);
}
#[test]
fn absent_and_empty_payload_round_trip_distinctly_and_remove_does_not_materialize_absence() {
let f = fixture();
let before = language::format(&f.project);
let mut session = Session::new(f.project.clone());
session
.execute(Command::RemoveScenarioLanePosition {
diagram: f.view,
lifeline: f.lane,
})
.unwrap();
assert_eq!(session.project(), &f.project);
assert!(!session.can_undo());
assert_eq!(language::format(session.project()), before);
let mut present = f.project.clone();
present.diagrams.get_mut(&f.view).unwrap().layout =
Some(DiagramLayout::Scenario(ScenarioLayout::default()));
let text = language::format(&present);
assert_ne!(text, before);
assert_eq!(language::parse(&text).unwrap(), present);
assert_eq!(language::parse(&before).unwrap(), f.project);
}
#[test]
fn invalid_layout_source_rejects_without_changing_last_compiled_workspace() {
let f = fixture();
let original = authored(&f);
let before = original.bundle.clone();
let path = original.index.declarations[&f.view].path.clone();
for replacement in ["x 1e30", "x not_a_number"] {
let mut candidate = before.clone();
let source = candidate.files.get_mut(&path).unwrap();
*source = source.replace("x 4.20e2", replacement);
assert!(compile_bundle(&candidate, ProjectLimits::default()).is_err());
assert_eq!(original.bundle, before);
}
let mut candidate = before.clone();
let source = candidate.files.get_mut(&path).unwrap();
*source = source.replace(
&format!("occurrence @{}", f.project.elements[&f.first].alias),
&format!("occurrence @{}", f.project.elements[&f.performer].alias),
);
assert!(compile_bundle(&candidate, ProjectLimits::default()).is_err());
assert_eq!(original.bundle, before);
}
#[test]
fn explicit_none_survives_other_edits_while_real_payload_clear_removes_values() {
let f = fixture();
let mut bundle = format_bundle(&f.project);
let compiled = compile_bundle(&bundle, ProjectLimits::default()).unwrap();
let location = &compiled.index.declarations[&f.view];
let path = location.path.clone();
bundle.files.get_mut(&path).unwrap().insert_str(
location.span.end - 1,
"\n layout // explicitly automatic\n none;\n",
);
let original = compile_bundle(&bundle, ProjectLimits::default()).unwrap();
let mut described = original.decoded.project.clone();
described.diagrams.get_mut(&f.view).unwrap().description =
"Review spacing independently".into();
let updated = update_bundle(&original, &described, ProjectLimits::default()).unwrap();
assert!(updated.bundle.files[&path].contains("layout // explicitly automatic\n none;"));
assert_eq!(updated.decoded.project, described);
let mut empty = described.clone();
empty.diagrams.get_mut(&f.view).unwrap().layout =
Some(DiagramLayout::Scenario(ScenarioLayout::default()));
let present = update_bundle(&updated, &empty, ProjectLimits::default()).unwrap();
assert_eq!(present.decoded.project, empty);
assert!(present.bundle.files[&path].contains("// explicitly automatic"));
let cleared = update_bundle(&present, &described, ProjectLimits::default()).unwrap();
assert_eq!(cleared.decoded.project, described);
assert!(!cleared.bundle.files[&path].contains("layout"));
assert!(cleared.bundle.files[&path].contains("// explicitly automatic"));
}
#[test]
fn compact_defaults_and_scenario_row_identity_compose_through_rename_and_clear() {
let f = fixture();
let initial = authored(&f);
let mut bundle = initial.bundle.clone();
let location = &initial.index.declarations[&f.performer];
bundle.files.get_mut(&location.path).unwrap().insert_str(
location.span.end - 1,
"\n description // explicitly default prose\n \"\";\n",
);
let original = compile_bundle(&bundle, ProjectLimits::default()).unwrap();
let mut expected = original.decoded.project.clone();
let performer = expected.elements.get_mut(&f.performer).unwrap();
performer.name = "Reviewed controller".into();
performer.alias = "reviewed_controller".into();
expected.elements.get_mut(&f.lane).unwrap().alias = "reviewed_lane".into();
let rows = payload(expected.diagrams.get_mut(&f.view).unwrap());
rows.lanes.swap(0, 1);
rows.messages.swap(0, 1);
rows.messages[0].send_y = 390.;
let updated = update_bundle(&original, &expected, ProjectLimits::default()).unwrap();
assert_eq!(updated.decoded.project, expected);
let element = &updated.index.declarations[&f.performer];
let text = &updated.bundle.files[&element.path][element.span.start..element.span.end];
assert!(
text.contains("description // explicitly default prose\n \"\";"),
"{text}"
);
assert!(
!text.contains("categories"),
"an omitted default must stay omitted: {text}"
);
let diagram = &updated.index.declarations[&f.view];
let text = &updated.bundle.files[&diagram.path][diagram.span.start..diagram.span.end];
assert!(
text.find("// Repeated performer").unwrap() < text.find("// Controller spacing").unwrap()
);
assert!(text.contains("reviewed_lane; x 4.20e2;"), "{text}");
assert!(text.find("// Second delivery").unwrap() < text.find("// First delivery").unwrap());
assert!(text.contains("send_y 2.00e2"), "{text}");
assert!(!text.contains("view_options"), "{text}");
expected.diagrams.get_mut(&f.view).unwrap().layout = None;
let cleared = update_bundle(&updated, &expected, ProjectLimits::default()).unwrap();
assert_eq!(cleared.decoded.project, expected);
let declaration = &cleared.index.declarations[&f.view];
let text =
&cleared.bundle.files[&declaration.path][declaration.span.start..declaration.span.end];
assert!(!text.contains("layout"), "{text}");
assert!(text.contains("// Controller spacing rationale."), "{text}");
assert_eq!(
update_bundle(&cleared, &expected, ProjectLimits::default())
.unwrap()
.bundle,
cleared.bundle,
);
}
+1 -43
View File
@@ -23,22 +23,6 @@ pub enum Command {
EditRelation(Id, Relation),
CreateDiagram(Diagram),
EditDiagram(Id, Diagram),
SetScenarioLanePosition {
diagram: Id,
position: ScenarioLanePosition,
},
RemoveScenarioLanePosition {
diagram: Id,
lifeline: Id,
},
SetScenarioMessagePosition {
diagram: Id,
position: ScenarioMessagePosition,
},
RemoveScenarioMessagePosition {
diagram: Id,
address: OccurrenceEndpoint,
},
CreateArtifact(ArtifactLink),
EditArtifact(Id, ArtifactLink),
CreateTrace(Trace),
@@ -504,26 +488,6 @@ fn apply_command(
reidentify_tracked(p, *old, value.id, provenance)?;
Some(value.id)
}
Command::SetScenarioLanePosition { diagram, position } => {
let replacement = crate::scenario_layout::set_lane(p, *diagram, position.clone())?;
p.diagrams.insert(*diagram, replacement);
Some(*diagram)
}
Command::RemoveScenarioLanePosition { diagram, lifeline } => {
let replacement = crate::scenario_layout::remove_lane(p, *diagram, *lifeline)?;
p.diagrams.insert(*diagram, replacement);
Some(*diagram)
}
Command::SetScenarioMessagePosition { diagram, position } => {
let replacement = crate::scenario_layout::set_message(p, *diagram, position.clone())?;
p.diagrams.insert(*diagram, replacement);
Some(*diagram)
}
Command::RemoveScenarioMessagePosition { diagram, address } => {
let replacement = crate::scenario_layout::remove_message(p, *diagram, address)?;
p.diagrams.insert(*diagram, replacement);
Some(*diagram)
}
Command::CreateArtifact(value) => {
vacant(p, value.id)?;
p.artifact_links.insert(value.id, value.clone());
@@ -914,7 +878,6 @@ pub fn reidentify(p: &mut Project, old: Id, new: Id) -> Result<(), ModelError> {
return Ok(());
}
vacant(p, new)?;
crate::references::check_remap(p, old, new)?;
if p.id == old {
p.id = new;
} else if let Some(mut v) = p.elements.remove(&old) {
@@ -941,17 +904,12 @@ pub fn reidentify(p: &mut Project, old: Id, new: Id) -> Result<(), ModelError> {
}
}
}
crate::references::remap_references(p, old, new)?;
crate::references::remap_references(p, old, new);
Ok(())
}
fn layout(p: &mut Project, diagram: Id) -> Result<(), ModelError> {
use std::collections::{BTreeMap, BTreeSet};
let d = p.diagrams.get(&diagram).ok_or_else(missing)?;
if d.projection.scenario().is_some() {
return Err(ModelError(
"Scenario layout requires the Scenario geometry operation.".into(),
));
}
let parents = crate::projection::parents(p, d);
let mut children: BTreeMap<Option<Id>, Vec<Id>> = BTreeMap::new();
for (id, parent) in &parents {
+2 -25
View File
@@ -52,13 +52,7 @@ pub(crate) fn parser_fields(context: Context) -> Map<String, Value> {
_ => &["source", "target"],
}
}
Context::Diagram => {
// Admit the optional payload without restoring a synthetic null in
// CompleteForEdit: absence there distinguishes a real layout clear
// from an unrelated edit beside an authored explicit `layout none`.
fields.insert("layout".into(), Value::Null);
&[]
}
Context::Diagram => &[],
Context::Node => &["element"],
Context::Artifact => &["owner"],
Context::Trace => &["oa", "sa"],
@@ -99,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!([]));
}
@@ -262,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})));
}
}
}
+7 -109
View File
@@ -972,18 +972,7 @@ pub fn update_bundle_with_identity_map(
};
let local = edits.entry(path.clone()).or_default();
update.header(&actual.header, &previous.header, &next.header, local);
let context = if old.diagrams.contains_key(&id) {
EditContext::Diagram
} else {
EditContext::Other
};
update.value(
&actual.fields,
Some(&previous.fields),
&next.fields,
context,
local,
);
update.value(&actual.fields, Some(&previous.fields), &next.fields, local);
}
}
if old.id != project.id || old.alias != project.alias || old.name != project.name {
@@ -1235,29 +1224,6 @@ impl<'a> InsertionSyntax<'a> {
}
}
// Schema context is explicit: similarly named fields in arbitrary records must
// never acquire Scenario row identity or move their authored trivia.
#[derive(Clone, Copy)]
enum EditContext {
Other,
Diagram,
Layout,
Scenario,
Lanes,
Messages,
}
impl EditContext {
fn field(self, name: &str) -> Self {
match (self, name) {
(Self::Diagram, "layout") => Self::Layout,
(Self::Layout, "Scenario") => Self::Scenario,
(Self::Scenario, "lanes") => Self::Lanes,
(Self::Scenario, "messages") => Self::Messages,
_ => Self::Other,
}
}
}
struct SyntaxUpdate<'a> {
source: &'a str,
before: &'a str,
@@ -1335,7 +1301,6 @@ impl SyntaxUpdate<'_> {
actual: &language::EditValue,
before: Option<&language::EditValue>,
after: &language::EditValue,
context: EditContext,
edits: &mut LocalEdits,
) {
use language::EditKind;
@@ -1351,7 +1316,7 @@ impl SyntaxUpdate<'_> {
None
}
});
self.record(actual.span, fields, previous, next, context, edits);
self.record(actual.span, fields, previous, next, edits);
}
(EditKind::List(items), EditKind::List(next)) => {
let previous = before.and_then(|old| {
@@ -1361,7 +1326,7 @@ impl SyntaxUpdate<'_> {
None
}
});
self.list(actual.span, items, previous, next, context, edits);
self.list(actual.span, items, previous, next, edits);
}
(EditKind::Reference(_), EditKind::Reference(alias)) => {
let token = significant(&self.source[actual.span.start..actual.span.end])
@@ -1414,7 +1379,6 @@ impl SyntaxUpdate<'_> {
actual: &BTreeMap<String, language::EditField>,
before: Option<&BTreeMap<String, language::EditField>>,
after: &BTreeMap<String, language::EditField>,
context: EditContext,
edits: &mut LocalEdits,
) {
// A typed constructor tag change retains shared fields and their trivia.
@@ -1438,13 +1402,7 @@ impl SyntaxUpdate<'_> {
},
new_key.clone(),
));
self.value(
&field.value,
Some(&old.value),
&new.value,
context.field(new_key),
edits,
);
self.value(&field.value, Some(&old.value), &new.value, edits);
return;
}
}
@@ -1456,16 +1414,9 @@ impl SyntaxUpdate<'_> {
.and_then(|fields| fields.get(key))
.map(|field| &field.value),
&next.value,
context.field(key),
edits,
);
} else if !(matches!(context, EditContext::Diagram)
&& key == "layout"
&& before.is_some_and(|fields| !fields.contains_key(key)))
{
// Canonical None omits layout. Preserve an authored explicit
// none while another field changes; a real Some->None clear
// still removes the former payload and retains its trivia.
} else {
erase_syntax(self.source, field.span, edits);
}
}
@@ -1519,7 +1470,6 @@ impl SyntaxUpdate<'_> {
actual: &[language::EditValue],
before: Option<&[language::EditValue]>,
after: &[language::EditValue],
context: EditContext,
edits: &mut LocalEdits,
) {
let previous = before.unwrap_or(&[]);
@@ -1532,7 +1482,6 @@ impl SyntaxUpdate<'_> {
self.old_aliases,
self.legacy_value_paths,
None,
context,
)
})
.collect();
@@ -1545,7 +1494,6 @@ impl SyntaxUpdate<'_> {
self.new_aliases,
self.legacy_value_paths,
self.identity_origins,
context,
)
})
.collect();
@@ -1574,7 +1522,7 @@ impl SyntaxUpdate<'_> {
.all(|(i, source)| *source == Some(i))
{
for (i, (current, next)) in actual.iter().zip(after).enumerate() {
self.value(current, previous.get(i), next, EditContext::Other, edits);
self.value(current, previous.get(i), next, edits);
}
return;
}
@@ -1607,13 +1555,7 @@ impl SyntaxUpdate<'_> {
let chunk = chunks[index];
text.push_str(&self.source[chunk.start..current.span.start]);
let mut local = Vec::new();
self.value(
current,
previous.get(index),
next,
EditContext::Other,
&mut local,
);
self.value(current, previous.get(index), next, &mut local);
text.push_str(&changed_text(self.source, current.span, local));
suffix = &self.source[current.span.end..chunk.end];
} else {
@@ -1652,58 +1594,14 @@ impl SyntaxUpdate<'_> {
}
}
fn scenario_item_key(
value: &language::EditValue,
aliases: &BTreeMap<String, String>,
context: EditContext,
) -> Option<String> {
use language::EditKind;
let EditKind::Record(fields) = &value.kind else {
return None;
};
let reference = |value: &language::EditValue| {
if let EditKind::Reference(alias) = &value.kind {
aliases.get(alias).cloned()
} else {
None
}
};
match context {
EditContext::Lanes => {
reference(&fields.get("lifeline")?.value).map(|id| format!("scenario-lane:{id}"))
}
EditContext::Messages => {
let EditKind::Record(address) = &fields.get("address")?.value.kind else {
return None;
};
let occurrence = reference(&address.get("occurrence")?.value)?;
let path: Vec<_> = match address.get("references") {
None => Vec::new(),
Some(field) => {
let EditKind::List(path) = &field.value.kind else {
return None;
};
path.iter().map(reference).collect::<Option<Vec<_>>>()?
}
};
Some(format!("scenario-message:{}:{occurrence}", path.join("/")))
}
_ => None,
}
}
fn item_key(
value: &language::EditValue,
source: &str,
aliases: &BTreeMap<String, String>,
legacy_value_paths: bool,
origins: Option<&BTreeMap<Id, Id>>,
context: EditContext,
) -> Option<String> {
use language::EditKind;
if matches!(context, EditContext::Lanes | EditContext::Messages) {
return scenario_item_key(value, aliases, context);
}
if matches!(value.kind, EditKind::Record(_))
&& let Some(key) = language::semantic_row_key(
&source[value.span.start..value.span.end],
@@ -72,11 +72,6 @@ pub(super) fn upgrade(
&actual.fields,
Some(&actual.fields),
&next.fields,
if project.diagrams.contains_key(&id) {
EditContext::Diagram
} else {
EditContext::Other
},
edits.entry(path.clone()).or_default(),
);
}
-15
View File
@@ -619,7 +619,6 @@ pub fn validate(p: &Project) -> Vec<Issue> {
}
text(&mut out, d.id, "name", &d.name);
text(&mut out, d.id, "description", &d.description);
crate::scenario_layout::validate_diagram(p, d, &mut out);
let option_issues = if d.projection == ProjectionKind::Traceability {
crate::view_options::validate_options_cross_layer(p, &d.view_options)
} else {
@@ -644,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);
}
}
}
@@ -231,7 +231,6 @@ fn node_project() -> Project {
},
)]),
routes: vec![],
layout: None,
},
);
expected
-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,
},
-5
View File
@@ -41,7 +41,6 @@ fn fixture() -> Fixture {
projection: ProjectionKind::Overview,
view_options: Default::default(),
nodes: BTreeMap::new(),
layout: None,
routes: vec![],
},
);
@@ -71,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,
@@ -692,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()),
});
+1 -1
View File
@@ -261,7 +261,7 @@ fn source7_context_lifecycle_source_history_and_closed_admission() {
assert_eq!(session.project(), &after);
let fresh = Id::new_v4();
let mut reidentified = after.clone();
references::remap_references(&mut reidentified, f.r1, fresh).unwrap();
references::remap_references(&mut reidentified, f.r1, fresh);
let mut e = reidentified.elements.remove(&f.r1).unwrap();
e.id = fresh;
reidentified.elements.insert(fresh, e);
-17
View File
@@ -20,7 +20,6 @@ fn view(p: &mut Project, machine: Id, alias: &str) -> Id {
projection: ProjectionKind::StateMachine { machine },
view_options: Default::default(),
nodes: BTreeMap::new(),
layout: None,
routes: vec![],
},
);
@@ -302,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,
});
@@ -365,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,
});
@@ -429,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,
});
@@ -465,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,
},
@@ -594,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,
@@ -665,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,
});
@@ -745,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,
@@ -155,16 +155,6 @@ fn side(p: &mut Project, layer: Layer) -> Side {
.routes
.push(EdgeRoute {
edge: exchange,
// Both directions must preserve authored endpoint preferences,
// including dormant preferences on the SA semantic-port edge.
source_attachment: Some(EdgeAttachment {
side: PortAnchorSide::Bottom,
offset: 0.2,
}),
target_attachment: Some(EdgeAttachment {
side: PortAnchorSide::Top,
offset: 0.8,
}),
waypoints: vec![Position {
x: 337.5,
y: 423.25,
@@ -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,
File diff suppressed because it is too large Load Diff
@@ -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,
@@ -138,7 +138,7 @@ pub fn validate_saved(p: &Project, s: &Saved) -> Result<(), String> {
.diagrams
.get(&s.diagram)
.ok_or("The creation draft's diagram is missing.")?;
if !d.projection.is_architecture()
if matches!(d.projection, ProjectionKind::StateMachine { .. })
|| s.query.len() > 16384
|| s.page > 1_000_000
|| s.point
@@ -180,7 +180,7 @@ pub fn validate_saved(p: &Project, s: &Saved) -> Result<(), String> {
}
fn context_kinds(p: &Project, d: &Diagram) -> Vec<Kind> {
let mut values = match (d.layer, d.projection) {
(_, ProjectionKind::StateMachine { .. } | ProjectionKind::Scenario { .. }) => vec![],
(_, ProjectionKind::StateMachine { .. }) => vec![],
(Layer::Oa, ProjectionKind::FunctionalDataflow) => vec![Kind::Activity],
(Layer::Sa, ProjectionKind::FunctionalDataflow) => vec![Kind::Function],
(Layer::Oa, ProjectionKind::Capabilities) => vec![
@@ -208,12 +208,11 @@ pub fn kinds(app: &App) -> Vec<Kind> {
.unwrap_or_default()
}
fn compatible(p: &Project, d: &Diagram, id: Id) -> bool {
d.projection.is_architecture()
&& p.elements.get(&id).is_some_and(|e| {
e.kind() != Kind::Port
&& d.projection.includes(e.kind())
&& (e.layer() == d.layer || d.projection == ProjectionKind::Traceability)
})
p.elements.get(&id).is_some_and(|e| {
e.kind() != Kind::Port
&& d.projection.includes(e.kind())
&& (e.layer() == d.layer || d.projection == ProjectionKind::Traceability)
})
}
fn suggested(p: &Project, d: &Diagram, kind: Kind) -> bool {
// Existing System remains a useful choice even when the singleton exists.
@@ -258,17 +257,6 @@ pub fn candidates(app: &App) -> Vec<(Id, String, bool)> {
}
impl App {
pub(super) fn architecture_event(&mut self, event: Event) -> Task<Message> {
if matches!(
&event,
Event::Start(..) | Event::Allocate(..) | Event::Existing(..)
) && !self
.active_diagram
.and_then(|id| self.project().diagrams.get(&id))
.is_some_and(|d| d.projection.is_architecture())
{
self.status = "Choose an architecture view before adding architecture content.".into();
return Task::none();
}
match event {
Event::Start(_, point) | Event::Allocate(_, _, point) | Event::Existing(point) => {
if self.architecture.is_some() {
@@ -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,
@@ -180,36 +180,10 @@ async fn mounted_scenario_use_delete_keeps_definition_and_second_use_and_rejects
send(&mut mounted, &mut app, Message::ConfirmDelete(stale)).await;
assert!(app.delete_pending, "old token does not cancel new dialog");
// Keep selection and epoch deliberately unchanged: owner is an independent witness.
let captured = app.scenario_context().unwrap();
assert_eq!(captured.scenario, owners[0]);
assert_eq!(
app.delete_target.as_ref().unwrap().behavior_owner,
Some(owners[0])
);
app.behavior.active_owner = Some(owners[1]);
app.reconcile_scenario_workspace();
let other = app.scenario_context().unwrap();
assert_eq!(other.scenario, owners[1]);
assert_eq!(
other.representation,
crate::behavior::scenario_layout::Representation::Derived { owner: owners[1] }
);
assert_eq!(other.epoch, captured.epoch);
assert_eq!(app.selected, uses[0]);
assert!(
app.delete_target.as_ref().unwrap().snapshot.valid(&app),
"the independent owner guard must reject an otherwise current token"
);
send(&mut mounted, &mut app, Message::ConfirmDelete(token)).await;
assert_eq!(app.document.bundle(), &original);
assert!(!app.delete_pending && app.delete_target.is_none());
assert!(!app.document.can_undo() && !app.document.can_redo());
app.behavior.active_owner = Some(owners[0]);
// Restore both owner and qualified presentation before mounting pointer input.
// Passive reports from the previous owner cannot repair an inconsistent fixture.
app.reconcile_scenario_workspace();
assert_eq!(app.scenario_context().unwrap().scenario, owners[0]);
assert_eq!(app.selected, uses[0]);
mounted.settle(&mut app).await;
select(&mut mounted, &mut app, uses[0]).await;
let token = delete(&mut mounted, &mut app, false).await;
@@ -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,
},
+2 -17
View File
@@ -1,6 +1,5 @@
//! Complete a navigation camera after the destination reports its real viewport.
use super::*;
use crate::behavior::scenario_layout::Representation;
use crate::diagram_usage::{Context, Surface};
#[cfg(test)]
mod tests;
@@ -48,23 +47,11 @@ impl App {
&& c.epoch == *epoch,
),
Message::ScenarioCanvas(behavior::scenario::CanvasEvent::Viewport(c, size)) => (
match c.representation {
Representation::Derived { owner } => Surface::Scenario(owner),
Representation::Saved { diagram } => Surface::Saved(diagram),
},
Surface::Scenario(c.scenario),
*size,
c.document == context.document
&& c.revision == context.revision
&& c.epoch == *epoch
&& self.pending_scenario_reveal_visit == Some(c.visit)
&& self.page == Page::Behavior
&& self.behavior.active_owner == Some(c.scenario)
&& self.behavior.scenario.context(
self.canvas_document,
c.scenario,
self.document.revision(),
self.canvas_epoch,
) == Some(*c),
&& c.epoch == *epoch,
),
Message::StateDiagram(behavior::state_edit::Event::Viewport(c, size)) => (
Surface::Saved(c.diagram),
@@ -106,7 +93,6 @@ impl App {
| Message::StateDiagram(behavior::state_edit::Event::Viewport(..))
) {
self.pending_diagram_reveal = None;
self.pending_scenario_reveal_visit = None;
if !Self::is_navigation_camera(message) {
self.diagram_navigation_focus = None;
}
@@ -156,6 +142,5 @@ impl App {
self.reveal_diagram_usage(&usage);
}
self.pending_diagram_reveal = None;
self.pending_scenario_reveal_visit = None;
}
}
@@ -98,7 +98,6 @@ fn fixture(canvas: Canvas) -> (App, Usage) {
layer: Layer::Oa,
projection: ProjectionKind::StateMachine { machine },
nodes: BTreeMap::new(),
layout: None,
routes: vec![],
view_options: Default::default(),
},
@@ -144,7 +143,6 @@ fn measurement(
epoch: u64,
surface: Surface,
size: Size,
visit: Id,
) -> Message {
match canvas {
Canvas::Architecture => Message::DiagramRevealViewport(context, epoch, size),
@@ -161,10 +159,6 @@ fn measurement(
behavior::scenario::Context {
document: context.document,
scenario: surface.id(),
representation: Representation::Derived {
owner: surface.id(),
},
visit,
revision: context.revision,
epoch,
},
@@ -182,16 +176,12 @@ fn measurement(
}
}
fn stamp(app: &App) -> (Context, u64, Id) {
fn stamp(app: &App) -> (Context, u64) {
let (context, _, epoch) = app
.pending_diagram_reveal
.as_ref()
.expect("reveal awaits measured destination");
(
*context,
*epoch,
app.pending_scenario_reveal_visit.unwrap_or(Id::nil()),
)
(*context, *epoch)
}
fn assert_centered(app: &App, canvas: Canvas, target: &Usage) {
@@ -229,11 +219,7 @@ fn assert_centered(app: &App, canvas: Canvas, target: &Usage) {
expected.choose_tool(target.surface.id(), behavior::scenario::CanvasTool::Select);
expected.viewport(ACTUAL);
expected.zoom(Some(zoom));
expected.camera(
app.project(),
target.surface.id(),
behavior::canvas_workspace::Event::Pan(pan),
);
expected.camera(behavior::canvas_workspace::Event::Pan(pan));
assert!(app.behavior.scenario.camera_boundary() == expected.camera_boundary());
}
}
@@ -252,10 +238,10 @@ fn all_reveal_surfaces_recenter_once_after_the_real_viewport_and_preserve_author
app.document.dirty(),
);
assert!(app.reveal_diagram_usage(&target));
let (context, epoch, visit) = stamp(&app);
let (context, epoch) = stamp(&app);
send(
&mut app,
measurement(canvas, context, epoch, target.surface, ACTUAL, visit),
measurement(canvas, context, epoch, target.surface, ACTUAL),
);
assert!(app.pending_diagram_reveal.is_none());
assert_centered(&app, canvas, &target);
@@ -274,7 +260,7 @@ fn all_reveal_surfaces_recenter_once_after_the_real_viewport_and_preserve_author
let camera = app.camera_boundary();
send(
&mut app,
measurement(canvas, context, epoch, target.surface, ACTUAL, visit),
measurement(canvas, context, epoch, target.surface, ACTUAL),
);
assert!(app.pending_diagram_reveal.is_none());
assert!(app.camera_boundary() == camera);
@@ -288,7 +274,7 @@ fn malformed_or_wrong_stamp_measurements_cannot_poison_a_pending_reveal() {
for invalid in 0..6 {
let (mut app, target) = fixture(canvas);
assert!(app.reveal_diagram_usage(&target));
let (context, epoch, visit) = stamp(&app);
let (context, epoch) = stamp(&app);
let camera = app.camera_boundary();
let mut stale = context;
let mut wrong_epoch = epoch;
@@ -303,13 +289,13 @@ fn malformed_or_wrong_stamp_measurements_cannot_poison_a_pending_reveal() {
}
send(
&mut app,
measurement(canvas, stale, wrong_epoch, target.surface, size, visit),
measurement(canvas, stale, wrong_epoch, target.surface, size),
);
assert!(
app.pending_diagram_reveal.is_some(),
"{canvas:?} invalid case {invalid}"
);
assert_eq!(stamp(&app), (context, epoch, visit));
assert_eq!(stamp(&app), (context, epoch));
assert_eq!(app.canvas_epoch, epoch);
assert!(
app.camera_boundary() == camera,
@@ -317,7 +303,7 @@ fn malformed_or_wrong_stamp_measurements_cannot_poison_a_pending_reveal() {
);
send(
&mut app,
measurement(canvas, context, epoch, target.surface, ACTUAL, visit),
measurement(canvas, context, epoch, target.surface, ACTUAL),
);
assert!(app.pending_diagram_reveal.is_none());
assert_centered(&app, canvas, &target);
@@ -331,10 +317,9 @@ fn actual_window_resize_before_first_measurement_retains_focus_for_the_new_conte
let (mut app, target) = fixture(canvas);
let accepted = app.document.snapshot();
assert!(app.reveal_diagram_usage(&target));
let (context, old_epoch, visit) = stamp(&app);
let (context, old_epoch) = stamp(&app);
send(&mut app, Message::WindowResized(Size::new(1100., 700.)));
let (current, epoch, current_visit) = stamp(&app);
assert_eq!(current_visit, visit);
let (current, epoch) = stamp(&app);
assert_eq!(current, context);
assert_eq!(epoch, app.canvas_epoch);
if matches!(canvas, Canvas::Process | Canvas::Scenario) {
@@ -342,14 +327,14 @@ fn actual_window_resize_before_first_measurement_retains_focus_for_the_new_conte
let camera = app.camera_boundary();
send(
&mut app,
measurement(canvas, context, old_epoch, target.surface, ACTUAL, visit),
measurement(canvas, context, old_epoch, target.surface, ACTUAL),
);
assert_eq!(stamp(&app), (current, epoch, visit));
assert_eq!(stamp(&app), (current, epoch));
assert!(app.camera_boundary() == camera);
}
send(
&mut app,
measurement(canvas, current, epoch, target.surface, ACTUAL, visit),
measurement(canvas, current, epoch, target.surface, ACTUAL),
);
assert!(app.pending_diagram_reveal.is_none());
assert_centered(&app, canvas, &target);
@@ -364,7 +349,7 @@ fn user_camera_edit_and_navigation_cancel_deferred_focus_without_late_snapping()
let (mut app, target) = fixture(canvas);
let accepted = app.document.snapshot();
assert!(app.reveal_diagram_usage(&target));
let (context, epoch, visit) = stamp(&app);
let (context, epoch) = stamp(&app);
let event = match action {
0 => match canvas {
Canvas::Architecture => Message::Pan(Vector::new(31., -17.)),
@@ -401,7 +386,7 @@ fn user_camera_edit_and_navigation_cancel_deferred_focus_without_late_snapping()
// restore the old navigation camera after deliberate user input.
send(
&mut app,
measurement(canvas, context, epoch, target.surface, WIDE, visit),
measurement(canvas, context, epoch, target.surface, WIDE),
);
assert!(app.pending_diagram_reveal.is_none());
assert!(
+10 -99
View File
@@ -1,6 +1,5 @@
//! Navigation-only adapter: validates destinations before changing presentation.
use super::*;
use crate::behavior::scenario_layout::{Representation, presentation};
use crate::diagram_usage::{self as usage, Context, Event, Panel, Surface, Target, Usage};
#[cfg(test)]
mod tests;
@@ -182,7 +181,6 @@ impl App {
// Only an accepted new chooser supersedes the previous reveal.
// Rejected/stale panel envelopes leave its first measurement intact.
self.pending_diagram_reveal = None;
self.pending_scenario_reveal_visit = None;
self.diagram_navigation_focus = None;
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
return Task::batch([
@@ -293,78 +291,11 @@ impl App {
Task::none()
}
/// Explicit saved-Scenario reveal runs before generic Apply. An absent target
/// never selects the owner or another saved arrangement as a fallback.
pub(super) fn reveal_saved_scenario(&mut self, diagram: Id, selected: Id) -> Task<Message> {
if self.diagram_usages.is_some() || !self.usage_navigation_available() {
self.status = "Apply or revert pending edits before revealing a saved Scenario. Your inputs are retained.".into();
return Task::none();
}
if presentation::checked_owner(self.project(), Representation::Saved { diagram }).is_none()
{
return Task::none();
}
let Some(found) = self
.current_surface_usages(selected, Surface::Saved(diagram))
.into_iter()
.find(|row| matches!(row.target, Target::Whole | Target::ScenarioObject(_)))
else {
self.status = "This object has no visible target in that saved Scenario.".into();
return Task::none();
};
self.reveal_diagram_usage(&found);
Task::none()
}
pub(super) fn reveal_diagram_usage(&mut self, usage: &Usage) -> bool {
if !self.usage_navigation_available() {
return false;
}
let previous_layer = self.layer;
let scenario_representation = match usage.surface {
Surface::Saved(diagram) if self.saved_scenario_diagram(diagram) => {
Some(Representation::Saved { diagram })
}
Surface::Scenario(owner) => Some(Representation::Derived { owner }),
_ => None,
};
if let Some(representation) = scenario_representation {
let Some(owner) = presentation::checked_owner(self.project(), representation) else {
return false;
};
if !self
.behavior
.scenario
.representation_usages(self.project(), representation, usage.subject)
.iter()
.any(|row| row == usage)
{
return false;
}
// Finish target/camera admission before changing the App route. The
// checked immutable owner also makes the following entry infallible.
let revealed = match representation {
Representation::Derived { .. } => self.behavior.scenario.reveal_usage(
self.document.project(),
self.canvas_document,
owner,
&usage.target,
),
Representation::Saved { .. } => self.behavior.scenario.reveal_representation_usage(
self.document.project(),
self.canvas_document,
owner,
representation,
&usage.target,
),
};
if !revealed {
return false;
}
if !self.enter_scenario_representation(representation) {
return false;
}
}
let p = self.document.project();
let (selected, placement) = match &usage.target {
Target::Whole => (usage.surface.id(), None),
@@ -404,10 +335,7 @@ impl App {
};
self.layer = diagram.layer;
self.active_diagram = Some(id);
if let Some(owner) = diagram.projection.scenario() {
self.behavior.active_owner = Some(owner);
self.page = Page::Behavior;
} else if let Some(machine) = diagram.projection.state_machine() {
if let Some(machine) = diagram.projection.state_machine() {
let viewport = self.behavior.diagram.viewport.unwrap_or(iced::Size::new(
(self.window_size.width - 610.).max(280.),
(self.window_size.height - 320.).max(240.),
@@ -449,14 +377,19 @@ impl App {
self.page = Page::Behavior;
}
Surface::Scenario(owner) => {
if !self.behavior.scenario.reveal_usage(
p,
self.canvas_document,
owner,
&usage.target,
) {
return false;
}
self.layer = p.elements[&owner].layer();
self.behavior.active_owner = Some(owner);
self.page = Page::Behavior;
}
}
if scenario_representation.is_none() {
self.behavior.scenario.forget_view();
}
self.selected = selected;
self.selected_node = placement;
self.model_menu = None;
@@ -483,18 +416,6 @@ impl App {
usage: usage.clone(),
selected,
});
self.pending_scenario_reveal_visit = scenario_representation.and_then(|representation| {
let owner = presentation::checked_owner(self.project(), representation)?;
self.behavior
.scenario
.context(
self.canvas_document,
owner,
self.document.revision(),
self.canvas_epoch,
)
.map(|context| context.visit)
});
self.pending_diagram_reveal = Some((
Context {
document: self.canvas_document,
@@ -515,20 +436,10 @@ impl App {
Surface::Saved(id) => {
self.active_diagram == Some(id)
&& matches!(self.page, Page::Diagram | Page::Behavior)
&& (!self.saved_scenario_diagram(id)
|| (self.page == Page::Behavior
&& self.behavior.scenario.representation()
== Some(Representation::Saved { diagram: id })))
}
Surface::Process(owner) => {
Surface::Process(owner) | Surface::Scenario(owner) => {
self.page == Page::Behavior && self.behavior.active_owner == Some(owner)
}
Surface::Scenario(owner) => {
self.page == Page::Behavior
&& self.behavior.active_owner == Some(owner)
&& self.behavior.scenario.representation()
== Some(Representation::Derived { owner })
}
};
(current_surface
&& focus.usage.surface == surface
@@ -1400,5 +1400,3 @@ fn completion_releases_worker_behind_busy_modal_without_navigation() {
assert_eq!(location(&app), prior);
assert_eq!(authored(&app), before);
}
mod saved_scenario;
@@ -1,108 +0,0 @@
arcadia 7;
project incident_workspace "Incident report review" id "70200000-0000-4000-8000-000000000001" {
// Two operational activities, with one lifeline appearance per Scenario.
oa_activity report_incident "Report incident" id "70200000-0000-4000-8000-000000000002" {}
oa_activity assess_incident "Assess incident" id "70200000-0000-4000-8000-000000000003" {}
interaction incident_report "Incident report" id "70200000-0000-4000-8000-000000000004" {
source @report_incident;
target @assess_incident;
}
oa_scenario incident_response "Review a reported incident" id "70200000-0000-4000-8000-000000000010" {
viewpoint OperationalActivity;
steps [@initial_report, @updated_report, @assess_call, @assess_reply, @unfinished_notice, @use_review];
}
oa_lifeline reporter "Reporting activity" id "70200000-0000-4000-8000-000000000011" {
parent @incident_response; represented @report_incident;
}
oa_lifeline assessor "Assessment activity" id "70200000-0000-4000-8000-000000000012" {
parent @incident_response; represented @assess_incident;
}
// Both appearances reuse one definition. Navigation must retain the occurrence.
oa_occurrence initial_report "Initial incident report" id "70200000-0000-4000-8000-000000000020" {
parent @incident_response;
body { Exchange { exchange @incident_report; sender @reporter; receiver @assessor; message_kind Asynchronous; }; };
}
oa_occurrence updated_report "Updated incident report" id "70200000-0000-4000-8000-000000000021" {
parent @incident_response;
body { Exchange { exchange @incident_report; sender @reporter; receiver @assessor; message_kind Asynchronous; }; };
}
// A correlated self call/reply with deliberately unassigned exchange definitions.
oa_occurrence assess_call "Assess report details" id "70200000-0000-4000-8000-000000000022" {
parent @incident_response;
body { Exchange { exchange none; sender @assessor; receiver @assessor; message_kind Call; }; };
}
oa_occurrence assess_reply "Assessment ready" id "70200000-0000-4000-8000-000000000023" {
parent @incident_response;
body { Exchange { exchange none; sender @assessor; receiver @assessor; message_kind Reply; reply_to @assess_call; }; };
}
// Intentional unfinished work: no receiver and therefore no fabricated arrow.
oa_occurrence unfinished_notice "Follow-up recipient undecided" id "70200000-0000-4000-8000-000000000024" {
parent @incident_response;
body { Exchange { exchange @incident_report; sender @reporter; receiver none; message_kind Asynchronous; }; };
}
oa_occurrence use_review "Use the reviewed reporting procedure" id "70200000-0000-4000-8000-000000000025" {
parent @incident_response;
body { ScenarioUse {
bindings [{ local @reporter; referenced @review_reporter; }, { local @assessor; referenced @review_assessor; }];
scenario @reporting_review;
}; };
}
// This definition stays opaque in the caller. Open it, then Back to the saved view.
oa_scenario reporting_review "Reporting procedure detail" id "70200000-0000-4000-8000-000000000030" {
viewpoint OperationalActivity;
steps [@review_delivery];
}
oa_lifeline review_reporter "Reporting activity in detail" id "70200000-0000-4000-8000-000000000031" {
parent @reporting_review; represented @report_incident;
}
oa_lifeline review_assessor "Assessment activity in detail" id "70200000-0000-4000-8000-000000000032" {
parent @reporting_review; represented @assess_incident;
}
oa_occurrence review_delivery "Check the incident report" id "70200000-0000-4000-8000-000000000033" {
parent @reporting_review;
body { Exchange { exchange @incident_report; sender @review_reporter; receiver @review_assessor; message_kind Asynchronous; }; };
}
diagram incident_readable "A - Incident review readable" id "70200000-0000-4000-8000-000000000040" {
layer Oa;
projection { Scenario { scenario @incident_response; }; };
layout { Scenario {
lanes [{ lifeline @reporter; x 160; }, { lifeline @assessor; x 640; }];
messages [
{ address { references []; occurrence @initial_report; }; send_y 160; },
{ address { references []; occurrence @updated_report; }; send_y 252; },
{ address { references []; occurrence @assess_call; }; send_y 344; },
{ address { references []; occurrence @assess_reply; }; send_y 436; }
];
}; };
}
diagram incident_wide "B - Incident review negative and far" id "70200000-0000-4000-8000-000000000041" {
layer Oa;
projection { Scenario { scenario @incident_response; }; };
layout { Scenario {
lanes [{ lifeline @reporter; x -500; }, { lifeline @assessor; x 50000; }];
messages [
{ address { references []; occurrence @initial_report; }; send_y 160; },
{ address { references []; occurrence @updated_report; }; send_y 252; },
{ address { references []; occurrence @assess_call; }; send_y 344; },
{ address { references []; occurrence @assess_reply; }; send_y 436; }
];
}; };
}
diagram incident_repair "C - Incident review needs layout repair" id "70200000-0000-4000-8000-000000000042" {
layer Oa;
projection { Scenario { scenario @incident_response; }; };
layout { Scenario {
// These valid authored lanes must remain saved during complete fallback.
lanes [{ lifeline @reporter; x 900; }, { lifeline @assessor; x -500; }];
messages [
// REPAIR: change only this 300 to 424, then Undo and Redo the edit.
{ address { references []; occurrence @assess_reply; }; send_y 300; },
// This authored value stays latent while the receiver is missing.
{ address { references []; occurrence @unfinished_notice; }; send_y 2000; }
];
}; };
}
}
@@ -1,362 +0,0 @@
//! Actual App chooser/explicit-reveal checks for saved Scenario identity and source history.
use super::*;
use crate::behavior::scenario::{CanvasEvent, State};
use crate::behavior::scenario_layout::{
Representation,
presentation::{self, PresentationStatus},
};
use iced::{Size, Vector};
fn fixture() -> App {
let p = language::parse(include_str!("saved_scenario.arcadia")).unwrap();
let errors: Vec<_> = oa_core::validation::validate(&p)
.into_iter()
.filter(|d| d.severity == oa_core::validation::Severity::Error)
.collect();
assert!(errors.is_empty(), "{errors:?}");
App::with_project(p)
}
fn id(app: &App, alias: &str) -> Id {
app.project()
.identities()
.into_iter()
.find(|(_, a)| *a == alias)
.unwrap()
.0
}
fn representation(view: Id) -> Representation {
Representation::Saved { diagram: view }
}
fn find(app: &App, subject: Id, view: Id) -> Usage {
usage::saved_usages_for(app.project(), subject, Some(view))
.into_iter()
.find(|row| row.target == Target::ScenarioObject(subject))
.expect("exact saved occurrence")
}
fn context(app: &App) -> behavior::scenario::Context {
let owner = app.behavior.active_owner.unwrap();
app.behavior
.scenario
.context(
app.canvas_document,
owner,
app.document.revision(),
app.canvas_epoch,
)
.unwrap()
}
fn navigate(app: &mut App, subject: Id, view: Id) -> Id {
let (token, rows) = open(app, subject);
let found = rows
.into_iter()
.find(|u| u.surface == Surface::Saved(view) && u.target == Target::ScenarioObject(subject))
.expect("saved chooser destination");
choose(app, token, found);
assert!(app.diagram_usages.is_none());
assert_eq!(app.page, Page::Behavior);
assert_eq!(app.active_diagram, Some(view));
assert_eq!(
app.behavior.scenario.representation(),
Some(representation(view))
);
assert_eq!(app.selected, subject);
token
}
#[test]
fn saved_queries_use_each_actual_presentation_and_keep_repeated_appearances_and_references_opaque()
{
let app = fixture();
let owner = id(&app, "incident_response");
let first = id(&app, "initial_report");
let second = id(&app, "updated_report");
let exchange = id(&app, "incident_report");
let latent = id(&app, "unfinished_notice");
let callee_message = id(&app, "review_delivery");
let views = ["incident_readable", "incident_wide", "incident_repair"].map(|a| id(&app, a));
let derived = app
.behavior
.scenario
.diagram_usages(app.project(), owner, first)
.pop()
.unwrap();
let rows = views.map(|view| find(&app, first, view));
assert_eq!(rows[0].bounds.y - derived.bounds.y, 12.);
assert_ne!(rows[0].bounds, rows[1].bounds);
assert_eq!(
rows[2].bounds, derived.bounds,
"complete fallback cannot retain the authored lane override"
);
for view in views {
let represented = usage::saved_usages_for(app.project(), exchange, Some(view));
assert_eq!(represented.len(), 2);
assert!(
represented
.iter()
.any(|u| u.target == Target::ScenarioObject(first))
);
assert!(
represented
.iter()
.any(|u| u.target == Target::ScenarioObject(second))
);
assert!(
represented
.iter()
.all(|u| u.surface == Surface::Saved(view))
);
assert!(usage::saved_usages_for(app.project(), callee_message, Some(view)).is_empty());
assert_eq!(
find(&app, latent, view).target,
Target::ScenarioObject(latent)
);
let shown = presentation::present(app.project(), representation(view), owner);
let whole = usage::saved_usages_for(app.project(), view, Some(view));
assert_eq!(whole.len(), 1);
assert_eq!(whole[0].target, Target::Whole);
assert_eq!(Some(whole[0].bounds), shown.bounds());
}
let wide = presentation::present(app.project(), representation(views[1]), owner)
.bounds()
.unwrap();
assert!(wide.x < -500. && wide.x + wide.width > 50000.);
let fallback = presentation::present(app.project(), representation(views[2]), owner);
assert!(matches!(
fallback.status(),
PresentationStatus::Fallback { .. }
));
assert_eq!(fallback.latent_messages(), &[latent]);
assert!(!fallback.capabilities().coordinate_input);
}
#[test]
fn saved_chooser_and_explicit_reveal_preserve_complete_source_and_actual_undo_redo() {
for alias in ["incident_readable", "incident_wide", "incident_repair"] {
let mut app = fixture();
let view = id(&app, alias);
let first = id(&app, "initial_report");
let owner = id(&app, "incident_response");
let original = app.document.snapshot();
app.document
.execute(Command::Rename(first, "Reviewed incident report".into()))
.unwrap();
let edited = app.document.snapshot();
app.sync_source();
app.refresh_draft();
let before = authored(&app);
navigate(&mut app, first, view);
assert_eq!(app.behavior.active_owner, Some(owner));
assert_eq!(authored(&app), before);
send(&mut app, Message::RevealInDiagram(view, first));
assert_eq!(app.active_diagram, Some(view));
assert_eq!(authored(&app), before);
assert!(app.document.undo());
assert_eq!(app.project(), &original.decoded.project);
assert_eq!(app.document.bundle(), &original.bundle);
assert!(app.document.redo());
assert_eq!(app.project(), &edited.decoded.project);
assert_eq!(app.document.bundle(), &edited.bundle);
}
}
#[test]
fn saved_reveal_and_chooser_refuse_source_property_and_behavior_drafts() {
for chooser in [false, true] {
for block in 0..3 {
let mut app = fixture();
let view = id(&app, "incident_repair");
let first = id(&app, "initial_report");
let choice = chooser.then(|| {
let (token, rows) = open(&mut app, first);
(
token,
rows.into_iter()
.find(|u| u.surface == Surface::Saved(view))
.unwrap(),
)
});
match block {
0 => {
let cursor = app.source_editor.cursor();
app.source_workspace
.edit("? preserve unfinished source".into(), cursor);
}
1 => {
app.draft
.as_mut()
.unwrap()
.input("/name", "Unapplied inspector value".into());
assert!(app.draft_dirty());
}
_ => {
let p = app.project().clone();
app.behavior
.creation
.update(&p, behavior::creation::Event::Open(Layer::Oa))
.unwrap();
assert!(app.behavior.has_pending());
}
}
let before = authored(&app);
let prior = location(&app);
let draft = app
.draft
.as_ref()
.map(|d| serde_json::to_value(d.recovery_snapshot()).unwrap());
if let Some((token, row)) = choice {
choose(&mut app, token, row);
} else {
send(&mut app, Message::RevealInDiagram(view, first));
}
assert_eq!(authored(&app), before);
assert_eq!(location(&app), prior);
assert_eq!(
app.draft
.as_ref()
.map(|d| serde_json::to_value(d.recovery_snapshot()).unwrap()),
draft
);
}
}
}
#[test]
fn saved_a_b_a_rejects_old_visit_and_panel_callbacks_before_deferred_measurement() {
let mut app = fixture();
let a = id(&app, "incident_readable");
let b = id(&app, "incident_wide");
let first = id(&app, "initial_report");
let before = authored(&app);
let old_token = navigate(&mut app, first, a);
let old = context(&app);
navigate(&mut app, first, b);
navigate(&mut app, first, a);
let current = context(&app);
assert_ne!(old.visit, current.visit);
let held = behavior::scenario::Context {
epoch: current.epoch,
..old
};
let camera = app.behavior.scenario.camera_boundary();
send(
&mut app,
Message::ScenarioCanvas(CanvasEvent::Viewport(held, Size::new(900., 900.))),
);
send(
&mut app,
Message::ScenarioCanvas(CanvasEvent::Shelf(
held,
crate::behavior::scenario::Shelf::Selection,
)),
);
send(
&mut app,
Message::DiagramUsage(Event::Search(old_token, "obsolete filter".into())),
);
send(&mut app, Message::DiagramUsage(Event::Close(old_token)));
assert_eq!(app.pending_scenario_reveal_visit, Some(current.visit));
assert!(app.pending_diagram_reveal.is_some());
assert!(app.behavior.scenario.camera_boundary() == camera);
let bounds = find(&app, first, a).bounds;
send(
&mut app,
Message::ScenarioCanvas(CanvasEvent::Viewport(current, Size::new(500., 300.))),
);
assert!(app.pending_diagram_reveal.is_none());
assert_eq!(context(&app).visit, current.visit);
let mut expected = State::default();
expected.enter_representation(app.canvas_document, current.scenario, representation(a));
expected.viewport(Size::new(500., 300.));
expected.zoom(Some(1.25));
expected.camera(
app.project(),
current.scenario,
behavior::canvas_workspace::Event::Pan(Vector::new(
250. - bounds.center_x() * 1.25,
150. - bounds.center_y() * 1.25,
)),
);
assert!(app.behavior.scenario.camera_boundary() == expected.camera_boundary());
assert_eq!(authored(&app), before);
}
#[test]
fn retargeted_saved_view_cannot_reveal_a_former_owner_occurrence() {
let mut app = fixture();
let first = id(&app, "initial_report");
let view = id(&app, "incident_readable");
let other = id(&app, "reporting_review");
let (token, rows) = open(&mut app, first);
let old = rows
.into_iter()
.find(|u| u.surface == Surface::Saved(view))
.unwrap();
let mut replacement = app.project().diagrams[&view].clone();
replacement.projection = ProjectionKind::Scenario { scenario: other };
replacement.layout = None;
app.document
.execute(Command::EditDiagram(view, replacement))
.unwrap();
app.sync_source();
app.refresh_draft();
let before = authored(&app);
let prior = location(&app);
choose(&mut app, token, old);
assert_eq!(location(&app), prior);
assert_eq!(authored(&app), before);
finish_lookup(&mut app);
let token = app.diagram_usages.as_ref().unwrap().token;
assert!(
app.diagram_usages
.as_ref()
.unwrap()
.usages
.iter()
.all(|u| u.surface != Surface::Saved(view))
);
send(&mut app, Message::DiagramUsage(Event::Close(token)));
send(&mut app, Message::RevealInDiagram(view, first));
assert_eq!(location(&app), prior);
assert_eq!(authored(&app), before);
}
#[test]
fn explicit_saved_layout_repair_undo_redo_requeries_authored_and_fallback_geometry_without_extra_history()
{
let mut app = fixture();
let view = id(&app, "incident_repair");
let first = id(&app, "initial_report");
let reply = id(&app, "assess_reply");
let before = app.document.snapshot();
let fallback = find(&app, first, view);
app.document
.execute(Command::SetScenarioMessagePosition {
diagram: view,
position: oa_core::ScenarioMessagePosition {
address: oa_core::OccurrenceEndpoint {
references: vec![],
occurrence: reply,
},
send_y: 424.,
},
})
.unwrap();
app.sync_source();
app.refresh_draft();
let repaired = app.document.snapshot();
let authored_row = find(&app, first, view);
assert_ne!(authored_row.bounds, fallback.bounds);
navigate(&mut app, first, view);
assert!(app.document.undo());
app.sync_source();
app.refresh_draft();
assert_eq!(app.project(), &before.decoded.project);
assert_eq!(app.document.bundle(), &before.bundle);
assert_eq!(find(&app, first, view).bounds, fallback.bounds);
assert!(app.document.redo());
app.sync_source();
app.refresh_draft();
assert_eq!(app.project(), &repaired.decoded.project);
assert_eq!(app.document.bundle(), &repaired.bundle);
assert_eq!(find(&app, first, view).bounds, authored_row.bounds);
}
-433
View File
@@ -1,433 +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),
Refuse(Snapshot, String),
}
pub(crate) fn context(c: crate::route_edit::Context) -> Context {
Context {
scope: Scope {
document: c.document,
surface: Surface::Saved(c.diagram),
},
revision: c.revision,
epoch: c.epoch,
}
}
/// The entire typed set must support a placement action. Never filter a mixture.
pub(crate) fn placements(snapshot: &Snapshot) -> Option<std::collections::BTreeSet<Id>> {
snapshot.primary()?;
if snapshot.targets().is_empty() {
return None;
}
snapshot
.targets()
.iter()
.map(|target| match target {
Target::Placement(id) => Some(*id),
_ => None,
})
.collect()
}
fn visible(
project: &Project,
diagram: Id,
view: &projection::ProjectedView,
target: &Target,
) -> bool {
match target {
Target::Placement(id) => view.nodes.iter().any(|n| n.id == *id),
Target::Port { port, placement } => view
.ports
.iter()
.any(|p| p.element == *port && p.owner == *placement),
Target::Edge(id) => view.edges.iter().any(|e| e.id == *id),
Target::StateRow {
placement,
relation,
} => crate::behavior::state_canvas::visible_row_target(
project, diagram, view, *placement, *relation,
),
_ => false,
}
}
impl App {
pub(crate) fn multiselect_boundary(&self) -> impl PartialEq + use<> {
(
self.canvas_document,
self.document.revision(),
self.page,
self.active_diagram,
self.selected,
self.selected_node,
(self.page == Page::Behavior).then(|| self.state_view_identity()),
(self.page == Page::Behavior).then_some(self.behavior.diagram.placement),
)
}
pub(crate) fn multiselect_context(&self) -> Option<Context> {
match self.page {
Page::Diagram => self.route_context().map(context),
Page::Behavior => {
let current = self.state_context()?;
crate::behavior::state_edit::validate_context(self.project(), current, current)
.ok()?;
Some(context(current))
}
_ => None,
}
}
pub(crate) fn multiselect_target_visible(&self, target: &Target) -> bool {
let Some(Context {
scope:
Scope {
surface: Surface::Saved(diagram),
..
},
..
}) = self.multiselect_context()
else {
return false;
};
let view = projection::project_view_with_index(
self.project(),
diagram,
self.document.view_index(),
);
visible(self.project(), diagram, &view, target)
}
pub(crate) fn multiselect_snapshot(&self) -> Option<Snapshot> {
self.diagram_selection
.as_ref()?
.snapshot(self.multiselect_context()?)
}
pub(crate) fn multiselect_envelope(&self, event: &Event) -> bool {
let Some(current) = self.multiselect_context() else {
return false;
};
match event {
Event::Choose(c, ..) | Event::Clear(c) => *c == current,
Event::Move(s, _) | Event::Hide(s) | Event::Refuse(s, _) | Event::Collapse(s, _) => {
self.diagram_selection
.as_ref()
.is_some_and(|selection| selection.matches(s, current))
}
}
}
fn legacy_target(&self, diagram: Id, view: &projection::ProjectedView) -> Option<Target> {
let placement = if self.page == Page::Behavior {
self.behavior.diagram.placement
} else {
self.selected_node
};
if let Some(id) = placement
&& view.nodes.iter().any(|node| {
node.id == id && (node.element == self.selected || node.id == self.selected)
})
{
return Some(Target::Placement(id));
}
if self.page == Page::Behavior {
// Keep a row's exact occurrence while inspecting its relation.
// Its owner placement is never a node-inspection or Hide target.
if let Some(target @ Target::StateRow { relation, .. }) =
self.diagram_selection.as_ref().and_then(|s| s.primary())
&& *relation == self.selected
&& visible(self.project(), diagram, view, target)
{
return Some(target.clone());
}
if let Some(node) = view.nodes.iter().find(|node| {
crate::behavior::state_canvas::visible_row_target(
self.project(),
diagram,
view,
node.id,
self.selected,
)
}) {
return Some(Target::StateRow {
placement: node.id,
relation: self.selected,
});
}
}
if let Some(port) = view.ports.iter().find(|port| port.element == self.selected) {
return Some(Target::Port {
port: port.element,
placement: port.owner,
});
}
view.edges
.iter()
.any(|edge| edge.id == self.selected)
.then_some(Target::Edge(self.selected))
}
pub(crate) fn reconcile_multiselect(&mut self) {
let Some(current) = self.multiselect_context() else {
self.diagram_selection = None;
return;
};
let Surface::Saved(diagram) = current.scope.surface else {
return;
};
let view = projection::project_view_with_index(
self.project(),
diagram,
self.document.view_index(),
);
let legacy = self.legacy_target(diagram, &view);
let lost_primary = self
.diagram_selection
.as_ref()
.filter(|s| s.scope() == current.scope)
.and_then(|s| s.primary())
.is_some_and(|target| !visible(self.project(), diagram, &view, target));
let primary_placement = if self.page == Page::Behavior {
self.behavior.diagram.placement
} else {
self.selected_node
};
let held_primary = self
.diagram_selection
.as_ref()
.filter(|s| s.scope() == current.scope)
.and_then(|s| s.primary())
.is_some_and(|target| match target {
Target::Placement(id) => primary_placement == Some(*id),
Target::Port { port, .. } => self.selected == *port,
Target::Edge(id) => self.selected == *id,
Target::StateRow { relation, .. } => {
self.selected == *relation && primary_placement.is_none()
}
_ => false,
});
let project = self.document.project();
let selection = self
.diagram_selection
.get_or_insert_with(|| Selection::new(current.scope));
selection.reconcile(current.scope, |target| {
visible(project, diagram, &view, target)
});
// Navigation/property selection remains explicit. A removed exact target
// is never rebound through its semantic identity to a different placement.
if held_primary || lost_primary {
if selection.primary() != legacy.as_ref() {
self.inspect_multiselect_primary();
}
} else if selection.primary() != legacy.as_ref() {
if let Some(target) = legacy {
selection.replace(target, |target| visible(project, diagram, &view, target));
} else {
selection.clear();
}
}
}
fn inspect_multiselect_primary(&mut self) {
let diagram = self
.multiselect_context()
.and_then(|c| match c.scope.surface {
Surface::Saved(diagram) => Some(diagram),
_ => None,
});
let primary = self
.diagram_selection
.as_ref()
.and_then(|s| s.primary())
.cloned();
self.selected_node = None;
if self.page == Page::Behavior {
self.behavior.diagram.placement = None;
}
match primary {
Some(Target::Placement(id)) => {
if let Some(node) = diagram
.and_then(|d| self.project().diagrams.get(&d))
.and_then(|d| d.nodes.get(&id))
{
self.selected = node.element;
if self.page == Page::Behavior {
self.behavior.diagram.placement = Some(id);
} else {
self.selected_node = Some(id);
}
}
}
Some(Target::Port { port, .. }) => self.selected = port,
Some(Target::Edge(id)) => self.selected = id,
Some(Target::StateRow { relation, .. }) => self.selected = relation,
_ => self.selected = diagram.unwrap_or(self.project().id),
}
self.selected_bend = None;
self.selected_attachment = None;
self.refresh_draft();
}
pub(crate) fn multiselect_event(&mut self, event: Event) {
if !self.multiselect_envelope(&event) {
return;
}
if !diagram_removal::enabled(self)
|| self.architecture.is_some()
|| self.connection.is_some()
|| (self.page == Page::Behavior
&& self.behavior.diagram.tool != crate::behavior::state_edit::Tool::Select)
{
self.status =
"Finish or cancel the active edit before changing the diagram selection.".into();
return;
}
let current = self.multiselect_context().unwrap();
let Surface::Saved(diagram) = current.scope.surface else {
return;
};
let view = projection::project_view_with_index(
self.project(),
diagram,
self.document.view_index(),
);
match event {
Event::Collapse(_, target) => {
if !visible(self.project(), diagram, &view, &target) {
return;
}
let project = self.document.project();
self.diagram_selection
.as_mut()
.unwrap()
.replace(target, |target| visible(project, diagram, &view, target));
self.inspect_multiselect_primary();
}
Event::Choose(_, target, choice) => {
if !visible(self.project(), diagram, &view, &target) {
return;
}
self.reconcile_multiselect();
let project = self.document.project();
let selection = self.diagram_selection.as_mut().unwrap();
let valid = |target: &Target| visible(project, diagram, &view, target);
match choice {
Choice::Replace => {
selection.replace(target, valid);
}
Choice::Add => {
selection.add(target, valid);
}
Choice::Remove => {
selection.remove(&target);
}
Choice::Toggle => {
selection.toggle(target, valid);
}
Choice::Inspect => {
selection.set_primary(&target);
}
}
self.inspect_multiselect_primary();
}
Event::Clear(_) => {
if let Some(selection) = &mut self.diagram_selection {
selection.clear();
}
self.inspect_multiselect_primary();
}
Event::Refuse(_, reason) => self.status = reason,
Event::Move(snapshot, delta) => {
let Some(ids) = placements(&snapshot) else {
self.status = "Move requires only placed elements. Remove ports, rows and connections from the selection.".into();
return;
};
if !snapshot
.targets()
.iter()
.all(|target| visible(self.project(), diagram, &view, target))
{
return;
}
if self.project().diagrams[&diagram].projection == ProjectionKind::Allocation
&& ids.iter().any(|id| {
self.project().elements
[&self.project().diagrams[&diagram].nodes[id].element]
.kind()
.is_work()
})
{
self.status =
"Group allocation drag is not available yet. The selection was retained."
.into();
return;
}
if delta.x == 0. && delta.y == 0. {
return;
}
match oa_core::diagram_edit::translate_placements(
self.project(),
diagram,
&ids,
delta,
) {
Ok(command) => {
if self.execute(command) {
self.inspect_multiselect_primary();
self.status = format!(
"Moved {} selected elements · Undo restores the group",
ids.len()
);
}
}
Err(error) => self.status = error.to_string(),
}
}
Event::Hide(snapshot) => {
let Some(ids) = placements(&snapshot) else {
self.status =
"Hide requires only placed elements. Model objects remain unchanged."
.into();
return;
};
if !snapshot
.targets()
.iter()
.all(|target| visible(self.project(), diagram, &view, target))
{
return;
}
if self.execute(Command::Batch(
ids.iter().copied().map(Command::Delete).collect(),
)) {
if let Some(selection) = &mut self.diagram_selection {
selection.clear();
}
self.inspect_multiselect_primary();
self.status = format!("Hid {} placements · Model objects retained", ids.len());
}
}
}
}
}
@@ -1,798 +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());
}
fn text_apply_group_fixture(state: bool) -> (App, Id, Id, Id) {
let ordinary = fixture();
if !state {
return (ordinary.app, ordinary.diagram, ordinary.a, ordinary.b);
}
// Keep the ordinary diagram, routes and opposite-layer view in the same
// Project so the complete-model oracle also protects non-active surfaces.
let mut project = ordinary.app.project().clone();
let ordinary_view = project.diagrams[&ordinary.diagram].clone();
let system = project
.add_element(Kind::System, Layer::Sa, "System", None)
.unwrap();
let machine = project
.add_element(Kind::StateMachine, Layer::Sa, "Modes", Some(system))
.unwrap();
let region = project
.add_element(Kind::Region, Layer::Sa, "Region", Some(machine))
.unwrap();
let first = project
.add_element(Kind::State, Layer::Sa, "Before text", Some(region))
.unwrap();
let second = project
.add_element(Kind::State, Layer::Sa, "Other state", Some(region))
.unwrap();
let diagram = project.ensure_diagram(Layer::Sa);
assert_ne!(diagram, ordinary.diagram);
project.diagrams.get_mut(&diagram).unwrap().projection =
ProjectionKind::StateMachine { machine };
let a = project.place(diagram, first, point(20., 30.));
let b = project.place(diagram, second, point(420., 30.));
assert_eq!(project.diagrams[&ordinary.diagram], ordinary_view);
let mut app = App::with_project(project);
app.page = Page::Behavior;
app.layer = Layer::Sa;
app.active_diagram = Some(ordinary.diagram);
app.behavior.active_owner = Some(machine);
app.behavior.diagram = crate::behavior::state_view::State {
diagram: Some(diagram),
..Default::default()
};
app.selected = diagram;
app.selected_node = None;
app.refresh_draft();
app.reconcile_multiselect();
assert_eq!(app.state_context().unwrap().diagram, diagram);
(app, diagram, a, b)
}
#[test]
fn completed_text_apply_rejects_captured_ordinary_and_state_group_actions() {
for state in [false, true] {
let (mut app, diagram, a, b) = text_apply_group_fixture(state);
choose(&mut app, Target::Placement(a), GroupChoice::Replace);
choose(&mut app, Target::Placement(b), GroupChoice::Add);
let stale = app.multiselect_snapshot().unwrap();
let before = app.document.snapshot();
assert!(!app.document.can_undo());
let element = app.project().diagrams[&diagram].nodes[&a].element;
let location = app.document.compiled().index.declarations[&element].clone();
let old_name = if state { "Before text" } else { "Send" };
assert_eq!(app.project().elements[&element].name, old_name);
let quoted_name = format!("\"{old_name}\"");
let original = &before.bundle.files[&location.path];
assert_eq!(original.matches(quoted_name.as_str()).count(), 1);
let edited = original.replace(quoted_name.as_str(), "\"Changed through Text\"");
let mut expected_model = before.decoded.project.clone();
expected_model.elements.get_mut(&element).unwrap().name = "Changed through Text".into();
let mut expected_bundle = before.bundle.clone();
expected_bundle
.files
.insert(location.path.clone(), edited.clone());
let _ = app.update(Message::Page(Page::Text));
let _ = app.update(Message::SourceFile(location.path));
let _ = app.update(Message::SourceEdit(text_editor::Action::SelectAll));
let _ = app.update(Message::SourceEdit(text_editor::Action::Edit(
text_editor::Edit::Paste(Arc::new(edited.clone())),
)));
assert!(app.source_dirty());
assert_eq!(app.source_editor.text(), edited);
assert_eq!(app.project(), &before.decoded.project);
assert_eq!(app.document.bundle(), &before.bundle);
let _ = app.update(Message::ApplySource);
assert!(!app.source_dirty(), "{}", app.status);
assert_eq!(app.project(), &expected_model);
assert_eq!(app.document.bundle(), &expected_bundle);
assert!(app.document.can_undo());
assert!(!app.document.can_redo());
assert_source_matches_model(&app);
let _ = app.update(Message::Page(if state {
Page::Behavior
} else {
Page::Diagram
}));
choose(&mut app, Target::Placement(a), GroupChoice::Replace);
choose(&mut app, Target::Placement(b), GroupChoice::Add);
let current = app.multiselect_snapshot().unwrap();
assert_eq!(
current.targets(),
&[Target::Placement(a), Target::Placement(b)]
);
assert_eq!(current.primary(), Some(&Target::Placement(b)));
assert_ne!(current.context(), stale.context());
assert!(app.multiselect_envelope(&GroupEvent::Hide(current.clone())));
app.status = "Feedback after completed Text Apply".into();
app.allocation_drag.arrange_next = true;
let revision = app.document.revision();
let epochs = (
app.canvas_epoch,
app.camera_epoch,
app.allocation_drag.epoch,
);
let legacy = (
app.selected,
app.selected_node,
app.behavior.diagram.placement,
);
let camera = (app.zoom, app.pan, app.fit_to_view, app.canvas_size);
let state_camera = (
app.behavior.diagram.zoom,
app.behavior.diagram.pan,
app.behavior.diagram.fit,
app.behavior.diagram.viewport,
);
let draft = app
.draft
.as_ref()
.map(|d| serde_json::to_value(d.recovery_snapshot()).unwrap());
for event in [
GroupEvent::Move(stale.clone(), point(900., 800.)),
GroupEvent::Hide(stale.clone()),
GroupEvent::Collapse(stale.clone(), Target::Placement(a)),
GroupEvent::Refuse(stale.clone(), "Obsolete refusal".into()),
] {
dispatch(&mut app, event);
assert_eq!(app.project(), &expected_model);
assert_eq!(app.document.bundle(), &expected_bundle);
assert_eq!(app.document.revision(), revision);
assert_eq!(app.multiselect_snapshot(), Some(current.clone()));
assert_eq!(
(
app.selected,
app.selected_node,
app.behavior.diagram.placement
),
legacy
);
assert_eq!(
(
app.canvas_epoch,
app.camera_epoch,
app.allocation_drag.epoch
),
epochs
);
assert_eq!(
(app.zoom, app.pan, app.fit_to_view, app.canvas_size),
camera
);
assert_eq!(
(
app.behavior.diagram.zoom,
app.behavior.diagram.pan,
app.behavior.diagram.fit,
app.behavior.diagram.viewport
),
state_camera
);
assert_eq!(app.status, "Feedback after completed Text Apply");
assert!(app.allocation_drag.arrange_next);
assert!(!app.source_dirty());
assert_eq!(
app.draft
.as_ref()
.map(|d| serde_json::to_value(d.recovery_snapshot()).unwrap()),
draft
);
assert!(app.document.can_undo());
assert!(!app.document.can_redo());
}
// Actual Text Apply is the sole history entry; no stale group action
// can create a hidden second edit or damage either round-trip snapshot.
let _ = app.update(Message::Undo);
assert_eq!(app.project(), &before.decoded.project);
assert_eq!(app.document.bundle(), &before.bundle);
assert!(!app.document.can_undo());
assert!(app.document.can_redo());
let _ = app.update(Message::Redo);
assert_eq!(app.project(), &expected_model);
assert_eq!(app.document.bundle(), &expected_bundle);
assert!(app.document.can_undo());
assert!(!app.document.can_redo());
}
}
#[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());
}
@@ -65,7 +65,6 @@ impl App {
owner: context.owner,
revision: context.revision,
epoch: lifetime,
scenario_view: None,
};
if self.behavior.has_pending()
|| self.draft_dirty()
@@ -1,279 +0,0 @@
//! Actual arrangement selection and current-record Details admission.
use super::*;
use crate::behavior::canvas_workspace::tests::Mounted;
use crate::behavior::scenario::{CanvasEvent, Context, Shelf};
use crate::behavior::scenario_layout::Representation;
use iced::{Point, Size, advanced::widget};
const FIXTURE: &str = include_str!("diagram_usages/tests/saved_scenario.arcadia");
fn id(app: &App, alias: &str) -> Id {
app.project()
.identities()
.into_iter()
.find(|(_, name)| *name == alias)
.unwrap()
.0
}
fn setup() -> (App, Id, Id, Id) {
let mut app = App::with_project(oa_core::language::parse(FIXTURE).unwrap());
let a = id(&app, "incident_readable");
let b = id(&app, "incident_wide");
let owner = id(&app, "incident_response");
let _ = app.update(Message::Select(owner));
let _ = app.update(Message::Page(Page::Behavior));
choose(&mut app, Representation::Saved { diagram: a });
let _ = app.update(Message::Select(a));
app.window_size = Size::new(1440., 1000.);
(app, a, b, owner)
}
fn send(app: &mut App, event: impl FnOnce(Context) -> CanvasEvent) {
let context = app.scenario_context().unwrap();
let _ = app.update(Message::ScenarioCanvas(event(context)));
}
fn choose(app: &mut App, representation: Representation) {
send(app, |context| {
CanvasEvent::Representation(context, representation)
});
}
fn model_state(app: &App) -> (u64, bool, bool, bool) {
(
app.document.revision(),
app.document.dirty(),
app.document.can_undo(),
app.document.can_redo(),
)
}
#[tokio::test]
async fn actual_arrangement_picker_follows_selected_saved_record_and_opens_b_properties() {
let (mut app, a, b, _) = setup();
let mut mounted = Mounted::new(&mut app).await;
let snapshot = app.document.snapshot();
let before = model_state(&app);
let captured = app.scenario_context().unwrap();
let picker = mounted.geometry(&app).bounds("scenario-arrangement-picker");
assert!(
app.window_size.height - (picker.y + picker.height) > picker.y,
"fixture has room for Iced's menu below the actual picker"
);
assert_eq!(app.selected, a);
mounted.click(&mut app, picker.center()).await;
// iced_widget 0.14.2 overlay/menu.rs uses default line height plus
// vertical padding for each row. The real workspace orders Automatic,
// then the fixture's A/B/C Diagram IDs. Select B from the actual overlay.
let row_height =
f32::from(iced::advanced::text::LineHeight::default().to_absolute(12.into())) + 10.;
let b_row = Point::new(
picker.center_x(),
picker.y + picker.height + row_height * 2.5,
);
assert!(b_row.y < app.window_size.height);
let events = mounted.click(&mut app, b_row).await;
assert!(events.iter().any(|event| matches!(event,
Message::ScenarioCanvas(CanvasEvent::Representation(context, Representation::Saved { diagram }))
if *context == captured && *diagram == b)), "must exercise the actual arrangement callback");
assert_eq!(app.selected, b);
assert_eq!(app.draft.as_ref().unwrap().original, b);
assert_eq!(
app.draft.as_ref().unwrap().value["name"],
"B - Incident review negative and far"
);
assert_eq!(app.active_diagram, Some(b));
assert_eq!(
app.scenario_context().unwrap().representation,
Representation::Saved { diagram: b }
);
assert_ne!(app.scenario_context().unwrap().visit, captured.visit);
send(&mut app, |context| {
CanvasEvent::Shelf(context, Shelf::Selection)
});
mounted.settle(&mut app).await;
let geometry = mounted.geometry(&app);
let (details, offset) = geometry.scrolls[&widget::Id::new("scenario-details")];
let point = geometry.bounds("scenario-properties").center() - offset;
assert!(details.contains(point));
let events = mounted.click(&mut app, point).await;
assert!(events.iter().any(|event| matches!(event,
Message::ScenarioCanvas(CanvasEvent::Properties(_, selected)) if *selected == b)));
mounted.settle(&mut app).await;
let geometry = mounted.geometry(&app);
let (inspector, offset) = geometry.scrolls[&widget::Id::new("inspector")];
assert!(
geometry
.text_inputs
.iter()
.any(|bounds| inspector.contains(bounds.center() - offset)),
"current B Properties must expose an actual editable field"
);
assert!(
!geometry
.scrolls
.contains_key(&widget::Id::new("scenario-details"))
);
assert_eq!(
app.inspector_tab,
crate::navigation::InspectorTab::Properties
);
assert_eq!(app.selected, b);
assert_eq!(app.draft.as_ref().unwrap().original, b);
assert!(Arc::ptr_eq(&snapshot, &app.document.snapshot()));
assert_eq!(app.project(), &snapshot.decoded.project);
assert_eq!(app.document.bundle(), &snapshot.bundle);
assert_eq!(model_state(&app), before);
}
#[test]
fn arrangement_change_preserves_independent_semantic_and_foreign_view_selection() {
for alias in [
"initial_report",
"reporter",
"incident_response",
"incident_repair",
] {
let (mut app, _, b, _) = setup();
let selected = id(&app, alias);
let _ = app.update(Message::Select(selected));
let snapshot = app.document.snapshot();
let before = model_state(&app);
choose(&mut app, Representation::Saved { diagram: b });
assert_eq!(app.selected, selected, "independent selection {alias}");
assert_eq!(app.draft.as_ref().unwrap().original, selected);
assert_eq!(app.active_diagram, Some(b));
assert!(Arc::ptr_eq(&snapshot, &app.document.snapshot()));
assert_eq!(app.document.bundle(), &snapshot.bundle);
assert_eq!(model_state(&app), before);
if alias == "incident_repair" {
assert!(!behavior::scenario::properties_target(
app.project(),
app.scenario_context().unwrap(),
selected
));
}
}
}
#[test]
fn arrangement_follows_view_record_to_automatic_owner_but_not_owner_back_to_saved() {
let (mut app, _, b, owner) = setup();
let snapshot = app.document.snapshot();
let before = model_state(&app);
choose(&mut app, Representation::Derived { owner });
assert_eq!(app.selected, owner);
assert_eq!(app.draft.as_ref().unwrap().original, owner);
assert_eq!(app.active_diagram, None);
assert!(behavior::scenario::properties_target(
app.project(),
app.scenario_context().unwrap(),
owner
));
choose(&mut app, Representation::Saved { diagram: b });
assert_eq!(
app.selected, owner,
"Scenario owner is now independent semantic selection"
);
assert_eq!(app.draft.as_ref().unwrap().original, owner);
assert_eq!(app.active_diagram, Some(b));
assert!(Arc::ptr_eq(&snapshot, &app.document.snapshot()));
assert_eq!(app.document.bundle(), &snapshot.bundle);
assert_eq!(model_state(&app), before);
}
#[test]
fn arrangement_applies_only_the_old_view_draft_then_follows_b_with_one_undo() {
let (mut app, a, b, _) = setup();
let snapshot = app.document.snapshot();
let mut expected = app.project().clone();
expected.diagrams.get_mut(&a).unwrap().name = "Reviewed A".into();
let revision = app.document.revision();
let _ = app.update(Message::Field("/name".into(), "Reviewed A".into()));
choose(&mut app, Representation::Saved { diagram: b });
assert_eq!(app.project(), &expected);
assert_eq!(app.selected, b);
assert_eq!(app.draft.as_ref().unwrap().original, b);
assert!(!app.draft_dirty());
assert_eq!(app.document.revision(), revision + 1);
let accepted = app.document.snapshot();
let _ = app.update(Message::Undo);
assert_eq!(app.project(), &snapshot.decoded.project);
assert_eq!(app.document.bundle(), &snapshot.bundle);
assert!(!app.document.can_undo() && app.document.can_redo());
let _ = app.update(Message::Redo);
assert_eq!(app.project(), &expected);
assert_eq!(app.document.bundle(), &accepted.bundle);
assert!(app.document.can_undo() && !app.document.can_redo());
}
#[test]
fn same_arrangement_uuid_edit_follows_the_reidentified_record_and_round_trips_source() {
let (mut app, a, _, _) = setup();
let snapshot = app.document.snapshot();
let replacement = Id::parse_str("70200000-0000-4000-8000-000000000099").unwrap();
let mut expected = app.project().clone();
let mut diagram = expected.diagrams.remove(&a).unwrap();
diagram.id = replacement;
expected.diagrams.insert(replacement, diagram);
let _ = app.update(Message::Field("/id".into(), replacement.to_string()));
choose(&mut app, Representation::Saved { diagram: a });
assert_eq!(app.project(), &expected);
assert_eq!(app.selected, replacement);
assert_eq!(app.draft.as_ref().unwrap().original, replacement);
assert_eq!(app.active_diagram, Some(replacement));
assert_eq!(
app.scenario_context().unwrap().representation,
Representation::Saved {
diagram: replacement
}
);
let accepted = app.document.snapshot();
let _ = app.update(Message::Undo);
assert_eq!(app.project(), &snapshot.decoded.project);
assert_eq!(app.document.bundle(), &snapshot.bundle);
assert!(!app.document.can_undo() && app.document.can_redo());
let _ = app.update(Message::Redo);
assert_eq!(app.project(), &expected);
assert_eq!(app.document.bundle(), &accepted.bundle);
assert!(!app.document.can_redo());
}
#[test]
fn invalid_draft_and_stale_arrangement_preserve_selection_inputs_and_accepted_state() {
let (mut app, a, b, _) = setup();
let old = app.scenario_context().unwrap();
let snapshot = app.document.snapshot();
let before = model_state(&app);
let _ = app.update(Message::Field("/id".into(), "not-a-uuid".into()));
let draft_value = app.draft.as_ref().unwrap().value.clone();
choose(&mut app, Representation::Saved { diagram: b });
assert_eq!(app.selected, a);
assert_eq!(app.active_diagram, Some(a));
assert_eq!(
app.scenario_context().unwrap().representation,
old.representation
);
assert_eq!(app.draft.as_ref().unwrap().value, draft_value);
assert_eq!(app.draft.as_ref().unwrap().inputs["/id"].raw, "not-a-uuid");
assert!(app.draft_dirty());
assert!(Arc::ptr_eq(&snapshot, &app.document.snapshot()));
assert_eq!(model_state(&app), before);
let _ = app.update(Message::RevertProperties);
choose(&mut app, Representation::Saved { diagram: b });
let current = app.scenario_context().unwrap();
let _ = app.update(Message::Field("/name".into(), "Retained B input".into()));
let selected = app.selected;
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Representation(
old,
Representation::Saved { diagram: a },
)));
assert_eq!(app.selected, selected);
assert_eq!(app.active_diagram, Some(b));
assert_eq!(app.scenario_context().unwrap().visit, current.visit);
assert_eq!(
app.draft.as_ref().unwrap().inputs["/name"].raw,
"Retained B input"
);
assert!(Arc::ptr_eq(&snapshot, &app.document.snapshot()));
assert_eq!(app.document.bundle(), &snapshot.bundle);
assert_eq!(model_state(&app), before);
}
@@ -1,96 +0,0 @@
//! Saved Scenario navigation is presentation state, never a document edit.
use super::*;
use crate::behavior::scenario_layout::{Representation, presentation::checked_owner};
impl App {
pub(super) fn enter_scenario_representation(&mut self, representation: Representation) -> bool {
let Some(owner) = checked_owner(self.project(), representation) else {
self.status =
"This Scenario view is no longer available or has an incompatible owner.".into();
return false;
};
self.layer = self.project().elements[&owner].layer();
self.page = Page::Behavior;
self.behavior.active_owner = Some(owner);
match representation {
Representation::Saved { diagram } => self.active_diagram = Some(diagram),
Representation::Derived { .. } => {
if self
.active_diagram
.is_some_and(|id| self.saved_scenario_diagram(id))
{
self.active_diagram = None;
}
}
}
self.selected_node = None;
if self
.behavior
.scenario
.enter_representation(self.canvas_document, owner, representation)
{
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
}
true
}
/// Reconcile after navigation/adoption without selecting or applying a draft.
/// A retained invalid Saved identity remains unavailable; it must not silently
/// turn into a different automatic Scenario or another saved arrangement.
pub(super) fn reconcile_scenario_workspace(&mut self) {
if self.page == Page::Diagram
&& !self.behavior.has_pending()
&& let Some(diagram) = self
.active_diagram
.filter(|id| self.saved_scenario_diagram(*id))
{
self.enter_scenario_representation(Representation::Saved { diagram });
}
if self.page != Page::Behavior {
self.behavior.scenario.leave();
return;
}
if let Some(rep @ Representation::Saved { diagram }) =
self.behavior.scenario.representation()
{
// The requested view is authoritative until explicit navigation
// retires it. This also recovers the same view when Undo restores it.
if let Some(owner) = checked_owner(self.project(), rep) {
self.active_diagram = Some(diagram);
self.layer = self.project().elements[&owner].layer();
self.behavior.active_owner = Some(owner);
self.behavior
.scenario
.enter_representation(self.canvas_document, owner, rep);
} else if let Some(owner) = self.behavior.scenario.owner() {
self.behavior
.scenario
.enter_representation(self.canvas_document, owner, rep);
}
return;
}
let Some(owner) = behavior::selected_owner(self) else {
self.behavior.scenario.leave();
return;
};
if self
.project()
.elements
.get(&owner)
.is_some_and(|e| e.kind() == Kind::Scenario)
{
self.behavior.scenario.enter(self.canvas_document, owner);
} else {
self.behavior.scenario.leave();
}
}
}
#[cfg(test)]
mod tests;
#[cfg(test)]
mod adoption_tests;
#[cfg(test)]
mod owner_navigation_tests;
@@ -1,264 +0,0 @@
//! Independent adoption oracles: expected Projects are edited directly in the
//! fixture, then admitted through the actual source editor/Document transaction.
//! Navigation is checked separately from the expected authored mutation.
use super::*;
use crate::behavior::canvas_workspace::tests::Mounted;
use iced::advanced::widget;
use iced::widget::text_editor;
use oa_core::language;
use std::sync::Arc;
struct Fixture {
app: App,
owner: Id,
same_layer: Id,
other_layer: Id,
view: Id,
spare_view: Id,
}
fn fixture() -> Fixture {
let mut project = Project::empty();
let owner = project
.add_element(Kind::Scenario, Layer::Oa, "Original owner", None)
.unwrap();
let same_layer = project
.add_element(Kind::Scenario, Layer::Oa, "Other OA owner", None)
.unwrap();
let other_layer = project
.add_element(Kind::Scenario, Layer::Sa, "SA owner", None)
.unwrap();
// A remaining topology makes silent fallback observably different from the
// required unavailable Saved view after its owner is deleted.
project
.add_element(Kind::Process, Layer::Oa, "Remaining process", None)
.unwrap();
let mut make_view = |alias: &str, name: &str, owner| {
let id = Id::new_v4();
project.diagrams.insert(
id,
Diagram {
id,
alias: alias.into(),
name: name.into(),
description: String::new(),
layer: Layer::Oa,
projection: ProjectionKind::Scenario { scenario: owner },
layout: None,
nodes: Default::default(),
routes: vec![],
view_options: Default::default(),
},
);
id
};
let view = make_view("retained_view", "Retained arrangement", owner);
let spare_view = make_view("spare_view", "Spare arrangement", same_layer);
let mut app = App::with_project(project);
let _ = app.update(Message::Select(owner));
choose(&mut app, view);
assert_saved(&app, view, owner, Layer::Oa);
assert!(!app.document.can_undo());
Fixture {
app,
owner,
same_layer,
other_layer,
view,
spare_view,
}
}
fn choose(app: &mut App, id: Id) {
let _ = app.update(Message::Diagram(Choice {
id,
label: "Previously captured picker item".into(),
}));
}
fn assert_saved(app: &App, view: Id, owner: Id, layer: Layer) {
assert_eq!(app.page, Page::Behavior);
assert_eq!(app.layer, layer);
assert_eq!(app.active_diagram, Some(view));
let context = app
.scenario_context()
.expect("saved owner remains the visible context");
assert_eq!(context.scenario, owner);
assert_eq!(
context.representation,
Representation::Saved { diagram: view }
);
assert!(!context.visit.is_nil());
}
fn adopt_from_source(app: &mut App, expected: &Project) {
let text = language::format(expected);
// Parser equality is a fixture precondition, not the navigation oracle.
assert_eq!(language::parse(&text).unwrap(), *expected);
let _ = app.update(Message::Page(Page::Text));
let _ = app.update(Message::SourceEdit(text_editor::Action::SelectAll));
let _ = app.update(Message::SourceEdit(text_editor::Action::Edit(
text_editor::Edit::Paste(Arc::new(text)),
)));
assert!(app.source_dirty());
let _ = app.update(Message::ApplySource);
assert!(!app.source_dirty(), "{}", app.status);
assert_eq!(
app.project(),
expected,
"actual source transaction must adopt exactly the fixture change"
);
let _ = app.update(Message::Page(Page::Behavior));
}
fn retarget(cross_layer: bool) {
let mut f = fixture();
let before = f.app.document.snapshot();
let old_context = f.app.scenario_context().unwrap();
let revision = f.app.document.revision();
let target = if cross_layer {
f.other_layer
} else {
f.same_layer
};
let layer = if cross_layer { Layer::Sa } else { Layer::Oa };
let mut expected = before.decoded.project.clone();
let view = expected.diagrams.get_mut(&f.view).unwrap();
view.projection = ProjectionKind::Scenario { scenario: target };
view.layer = layer;
adopt_from_source(&mut f.app, &expected);
assert_saved(&f.app, f.view, target, layer);
assert_ne!(f.app.scenario_context().unwrap().visit, old_context.visit);
assert_eq!(
f.app.document.revision(),
revision + 1,
"routing must not create a second transaction"
);
assert_eq!(
f.app.project().diagrams[&f.spare_view],
before.decoded.project.diagrams[&f.spare_view]
);
let adopted_bundle = f.app.document.bundle().clone();
let _ = f.app.update(Message::Undo);
assert_saved(&f.app, f.view, f.owner, Layer::Oa);
assert_eq!(f.app.project(), &before.decoded.project);
assert_eq!(f.app.document.bundle(), &before.bundle);
assert!(
!f.app.document.can_undo(),
"exactly one authored adoption must be undone"
);
let _ = f.app.update(Message::Redo);
assert_saved(&f.app, f.view, target, layer);
assert_eq!(f.app.project(), &expected);
assert_eq!(f.app.document.bundle(), &adopted_bundle);
assert!(!f.app.document.can_redo());
}
#[test]
fn source_same_layer_owner_retarget_undo_redo_keeps_requested_saved_uuid() {
retarget(false);
}
#[test]
fn source_cross_layer_owner_retarget_undo_redo_keeps_requested_saved_uuid() {
retarget(true);
}
#[tokio::test]
async fn source_deleted_owner_and_saved_view_mount_unavailable_instead_of_another_behavior() {
let mut f = fixture();
let before = f.app.document.snapshot();
let mut expected = before.decoded.project.clone();
assert!(expected.elements.remove(&f.owner).is_some());
assert!(expected.diagrams.remove(&f.view).is_some());
assert!(expected.elements.contains_key(&f.same_layer));
assert!(
expected
.elements
.values()
.any(|e| e.kind() == Kind::Process)
);
adopt_from_source(&mut f.app, &expected);
assert_eq!(
f.app.behavior.scenario.representation(),
Some(Representation::Saved { diagram: f.view })
);
let adopted = f.app.document.snapshot();
let revision = f.app.document.revision();
let mut mounted = Mounted::new(&mut f.app).await;
let geometry = mounted.geometry(&f.app);
assert!(
geometry
.containers
.contains_key(&widget::Id::from("scenario-viewport-unavailable"))
);
assert!(
!geometry
.containers
.contains_key(&widget::Id::from("scenario-viewport"))
);
assert_eq!(f.app.project(), &expected);
assert_eq!(f.app.document.bundle(), &adopted.bundle);
assert_eq!(
f.app.document.revision(),
revision,
"mounting unavailable must remain passive"
);
let _ = f.app.update(Message::Undo);
assert_saved(&f.app, f.view, f.owner, Layer::Oa);
assert_eq!(f.app.document.bundle(), &before.bundle);
let _ = f.app.update(Message::Redo);
assert_eq!(
f.app.behavior.scenario.representation(),
Some(Representation::Saved { diagram: f.view })
);
mounted.settle(&mut f.app).await;
assert!(
mounted
.geometry(&f.app)
.containers
.contains_key(&widget::Id::from("scenario-viewport-unavailable"))
);
assert_eq!(f.app.project(), &expected);
assert_eq!(f.app.document.bundle(), &adopted.bundle);
}
#[test]
fn missing_and_deleted_picker_ids_cannot_apply_an_unrelated_valid_property_draft() {
let mut f = fixture();
let mut expected = f.app.project().clone();
expected.diagrams.remove(&f.spare_view).unwrap();
adopt_from_source(&mut f.app, &expected);
let _ = f.app.update(Message::Select(f.owner));
let _ = f.app.update(Message::Field(
"/name".into(),
"Pending valid owner rename".into(),
));
assert!(f.app.draft_dirty());
let before = f.app.document.snapshot();
let revision = f.app.document.revision();
let selected = f.app.selected;
for invalid in [Id::nil(), f.spare_view] {
choose(&mut f.app, invalid);
assert_saved(&f.app, f.view, f.owner, Layer::Oa);
assert_eq!(f.app.selected, selected);
assert!(f.app.draft_dirty());
assert_eq!(f.app.project(), &before.decoded.project);
assert_eq!(f.app.document.bundle(), &before.bundle);
assert_eq!(f.app.document.revision(), revision);
}
// Establish that the retained draft really was valid, not merely refused by
// the property validator: explicit Apply commits it in exactly one step.
assert!(f.app.apply(), "{}", f.app.status);
assert_eq!(
f.app.project().elements[&f.owner].name,
"Pending valid owner rename"
);
assert_eq!(f.app.document.revision(), revision + 1);
let _ = f.app.update(Message::Undo);
assert_eq!(f.app.project(), &before.decoded.project);
assert_eq!(f.app.document.bundle(), &before.bundle);
}
@@ -1,116 +0,0 @@
//! Explicit owner navigation must retire a retained Saved Scenario, including
//! when selection was made on a different page. These are real App events.
use super::*;
use crate::behavior::scenario::CanvasEvent;
fn setup(kind: Kind) -> (App, Id, Id) {
let mut project = Project::empty();
let scenario = project
.add_element(Kind::Scenario, Layer::Oa, "Incident review", None)
.unwrap();
let target = project
.add_element(kind, Layer::Oa, "Explicit destination", None)
.unwrap();
let view = Id::new_v4();
project.diagrams.insert(
view,
Diagram {
id: view,
alias: "incident_saved".into(),
name: "Incident saved arrangement".into(),
description: String::new(),
layer: Layer::Oa,
projection: ProjectionKind::Scenario { scenario },
layout: None,
nodes: Default::default(),
routes: vec![],
view_options: Default::default(),
},
);
let errors: Vec<_> = oa_core::validation::validate(&project)
.into_iter()
.filter(|issue| issue.severity == oa_core::validation::Severity::Error)
.collect();
assert!(errors.is_empty(), "{errors:?}");
let mut app = App::with_project(project);
let _ = app.update(Message::Select(scenario));
let _ = app.update(Message::Diagram(Choice {
id: view,
label: "Incident saved arrangement".into(),
}));
assert_eq!(app.page, Page::Behavior);
assert_eq!(
app.scenario_context().unwrap().representation,
Representation::Saved { diagram: view }
);
assert_eq!(app.selected, scenario);
(app, scenario, target)
}
fn assert_destination(app: &App, target: Id) {
assert_eq!(app.page, Page::Behavior);
assert_eq!(app.selected, target);
assert_eq!(app.behavior.active_owner, Some(target));
assert_eq!(behavior::selected_owner(app), Some(target));
assert_eq!(
app.behavior.scenario.representation(),
None,
"explicit non-Scenario destination must not retain an overriding Saved request"
);
assert!(app.scenario_context().is_none());
}
fn exercise(via_guide: bool) {
for kind in [Kind::Process, Kind::StateMachine] {
let (mut app, scenario, target) = setup(kind);
assert_ne!(scenario, target);
let held = app.scenario_context().unwrap();
let accepted = app.document.snapshot();
let drafts = app.source_workspace.bundle();
let revision = app.document.revision();
let dirty = (app.document.dirty(), app.dirty(), app.source_dirty());
assert!(!app.document.can_undo() && !app.document.can_redo());
if via_guide {
let _ = app.update(Message::Page(Page::Guide));
assert_eq!(app.page, Page::Guide);
assert!(app.scenario_context().is_none());
}
let _ = app.update(Message::Select(target));
if via_guide {
assert_eq!(
app.page,
Page::Guide,
"explorer selection must not itself switch the page"
);
let _ = app.update(Message::Page(Page::Behavior));
}
assert_destination(&app, target);
let camera = app.camera_boundary();
// The prior saved canvas cannot open its outline after either owner route.
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Shelf(
held,
crate::behavior::scenario::Shelf::Selection,
)));
assert_destination(&app, target);
assert!(app.camera_boundary() == camera);
assert_eq!(app.project(), &accepted.decoded.project);
assert_eq!(app.document.bundle(), &accepted.bundle);
assert_eq!(app.source_workspace.bundle(), drafts);
assert_eq!(app.document.revision(), revision);
assert_eq!(
(app.document.dirty(), app.dirty(), app.source_dirty()),
dirty
);
assert!(!app.document.can_undo() && !app.document.can_redo());
}
}
#[test]
fn saved_scenario_guide_selection_returns_to_explicit_process_or_state_owner() {
exercise(true);
}
#[test]
fn saved_scenario_direct_behavior_selection_enters_explicit_process_or_state_owner() {
exercise(false);
}
@@ -1,205 +0,0 @@
use super::*;
use crate::behavior::scenario::{CanvasEvent, ScrollArea};
fn saved(p: &mut Project, owner: Id, name: &str) -> Id {
let id = Id::new_v4();
p.diagrams.insert(
id,
Diagram {
id,
alias: format!("view_{}", p.diagrams.len()),
name: name.into(),
description: String::new(),
layer: Layer::Oa,
projection: ProjectionKind::Scenario { scenario: owner },
layout: None,
routes: vec![],
nodes: Default::default(),
view_options: Default::default(),
},
);
id
}
fn setup() -> (App, Id, Id, Id, Id) {
let (mut p, [owner, callee, _], [reference, _, _]) =
behavior::scenario::direct_tests::navigation_fixture();
let a = saved(&mut p, owner, "Review A");
let b = saved(&mut p, owner, "Review B");
let mut app = App::with_project(p);
app.select(owner);
(app, a, b, reference, callee)
}
fn choose(app: &mut App, diagram: Id) {
let _ = app.update(Message::Diagram(Choice {
id: diagram,
label: "Captured view".into(),
}));
}
fn event(app: &mut App, f: impl FnOnce(behavior::scenario::Context) -> CanvasEvent) {
let context = app.scenario_context().unwrap();
let _ = app.update(Message::ScenarioCanvas(f(context)));
}
#[test]
fn saved_view_switches_are_distinct_passive_visits_and_preserve_actual_history() {
let (mut app, a, b, _, _) = setup();
let original = app.document.snapshot();
let owner = app.selected;
assert!(app.execute(Command::Rename(owner, "Previously edited caller".into())));
let edited = app.document.snapshot();
let edited_revision = app.document.revision();
choose(&mut app, a);
let first = app.scenario_context().unwrap();
choose(&mut app, b);
let second = app.scenario_context().unwrap();
choose(&mut app, a);
let third = app.scenario_context().unwrap();
assert_eq!(first.scenario, owner);
assert_eq!(first.representation, Representation::Saved { diagram: a });
assert_eq!(second.representation, Representation::Saved { diagram: b });
assert_ne!(first.visit, second.visit);
assert_ne!(first.visit, third.visit);
assert_eq!(app.page, Page::Behavior);
assert_eq!(app.selected, owner);
assert_eq!(app.document.bundle(), &edited.bundle);
assert_eq!(app.project(), &edited.decoded.project);
assert_eq!(app.document.revision(), edited_revision);
let _ = app.update(Message::Undo);
assert_eq!(app.document.bundle(), &original.bundle);
assert_eq!(app.project(), &original.decoded.project);
let _ = app.update(Message::Redo);
assert_eq!(app.document.bundle(), &edited.bundle);
assert_eq!(app.project(), &edited.decoded.project);
}
#[test]
fn saved_view_return_rejects_old_passive_events_without_an_intermediate_frame() {
let (mut app, a, b, _, _) = setup();
choose(&mut app, a);
let old = app.scenario_context().unwrap();
choose(&mut app, b);
choose(&mut app, a);
let current = app.scenario_context().unwrap();
let camera = app.behavior.scenario.camera_boundary();
let before = app.document.snapshot();
for stale in [
CanvasEvent::Viewport(old, iced::Size::new(432., 321.)),
CanvasEvent::Scrolled(old, ScrollArea::Page, iced::Vector::new(0., 800.)),
CanvasEvent::FormShown(old, Id::new_v4()),
CanvasEvent::Restored(
old,
Id::new_v4(),
ScrollArea::Page,
iced::Vector::new(0., 800.),
),
CanvasEvent::Camera(
old,
app.camera_epoch,
behavior::canvas_workspace::Event::Pan(iced::Vector::new(90., 80.)),
),
] {
let _ = app.update(Message::ScenarioCanvas(stale));
assert_eq!(app.scenario_context(), Some(current));
assert!(app.behavior.scenario.camera_boundary() == camera);
assert_eq!(app.document.bundle(), &before.bundle);
}
}
#[test]
fn saved_reference_back_restores_exact_view_with_a_fresh_visit() {
let (mut app, a, _, reference, callee) = setup();
choose(&mut app, a);
let caller = app.scenario_context().unwrap();
let before = app.document.snapshot();
event(&mut app, |ctx| CanvasEvent::OpenUse(ctx, reference));
let target = app.scenario_context().unwrap();
assert_eq!(target.scenario, callee);
assert_eq!(
target.representation,
Representation::Derived { owner: callee }
);
assert_eq!(app.active_diagram, None);
event(&mut app, CanvasEvent::Back);
let returned = app.scenario_context().unwrap();
assert_eq!(returned.representation, caller.representation);
assert_eq!(app.active_diagram, Some(a));
assert_eq!(app.selected, reference);
assert_ne!(returned.visit, caller.visit);
assert_eq!(app.document.bundle(), &before.bundle);
assert_eq!(app.project(), &before.decoded.project);
assert!(!app.document.can_undo());
}
#[test]
fn saved_picker_applies_valid_old_draft_and_uuid_edit_follows_that_same_view() {
let (mut app, a, b, _, _) = setup();
choose(&mut app, a);
let _ = app.update(Message::Select(a));
let before = app.document.snapshot();
let _ = app.update(Message::Field("/name".into(), "Renamed A".into()));
choose(&mut app, b);
assert_eq!(app.project().diagrams[&a].name, "Renamed A");
assert_eq!(app.selected, b);
assert_eq!(
app.scenario_context().unwrap().representation,
Representation::Saved { diagram: b }
);
let _ = app.update(Message::Undo);
assert_eq!(app.document.bundle(), &before.bundle);
choose(&mut app, a);
let replacement = Id::new_v4();
let _ = app.update(Message::Field("/id".into(), replacement.to_string()));
choose(&mut app, a);
assert!(!app.project().diagrams.contains_key(&a));
assert_eq!(app.active_diagram, Some(replacement));
assert_eq!(app.selected, replacement);
assert_eq!(
app.scenario_context().unwrap().representation,
Representation::Saved {
diagram: replacement
}
);
}
#[test]
fn leaving_and_reentering_saved_view_preserves_source_and_retires_passive_visit() {
let (mut app, a, _, _, _) = setup();
choose(&mut app, a);
let old = app.scenario_context().unwrap();
let before = app.document.snapshot();
let _ = app.update(Message::Page(Page::Guide));
assert!(app.scenario_context().is_none());
let _ = app.update(Message::Page(Page::Behavior));
let fresh = app.scenario_context().unwrap();
assert_eq!(fresh.representation, old.representation);
assert_ne!(fresh.visit, old.visit);
assert_eq!(app.document.bundle(), &before.bundle);
}
#[test]
fn arrangement_picker_rejects_previous_visit_and_switches_automatic_without_model_changes() {
let (mut app, a, b, _, _) = setup();
choose(&mut app, a);
let stale = app.scenario_context().unwrap();
choose(&mut app, b);
let current = app.scenario_context().unwrap();
let before = app.document.snapshot();
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Representation(
stale,
Representation::Saved { diagram: a },
)));
assert_eq!(app.scenario_context(), Some(current));
event(&mut app, |context| {
CanvasEvent::Representation(
context,
Representation::Derived {
owner: context.scenario,
},
)
});
assert_eq!(
app.scenario_context().unwrap().representation,
Representation::Derived {
owner: current.scenario
}
);
assert_eq!(app.active_diagram, None);
assert_eq!(app.document.bundle(), &before.bundle);
assert_eq!(app.project(), &before.decoded.project);
assert!(!app.document.can_undo());
}
@@ -1,88 +0,0 @@
//! Admit spacing packets before ordinary navigation can retire a newer reveal.
use super::*;
use behavior::scenario::spacing::{Event, controller::Finish};
#[cfg(test)]
mod tests;
impl App {
pub(super) fn spacing_enabled(&self) -> bool {
self.pending_diagram_reveal.is_none()
&& !self.behavior.has_pending()
&& !self.draft_dirty()
&& !self.source_dirty()
&& !self.source_rename.open()
&& behavior::canvas_workspace::keyboard_enabled(self)
}
pub(super) fn scenario_spacing(&mut self, event: Event) -> Task<Message> {
let stamp = event.stamp();
if self.scenario_context() != Some(stamp.context) || self.camera_epoch != stamp.camera_epoch
{
return Task::none();
}
let state = &self.behavior.scenario;
let matches =
state.spacing_token() == Some(event.token()) && state.spacing_stamp() == Some(stamp);
if match event {
Event::Begin { token, .. } => token.is_nil() || state.spacing_token().is_some(),
_ => !matches,
} {
return Task::none();
}
if matches!(event, Event::Cancel { .. }) || !self.spacing_enabled() {
if self.behavior.scenario.cancel_spacing() {
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
}
return Task::none();
}
match event {
Event::Begin { .. } => {
let mut state = std::mem::take(&mut self.behavior.scenario);
let result = state.begin_spacing(self.project(), event);
self.behavior.scenario = state;
if result.is_err() {
self.status = "The drag could not start in this view. Try again.".into();
}
}
Event::Move { screen, .. } => self.behavior.scenario.move_spacing(screen),
Event::Finish { screen, .. } => {
let finished = self.behavior.scenario.finish_spacing(screen);
// Retire queued packets even for zero motion or a refused finish.
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
match finished {
Ok(Finish::Click(target)) => self.select(target),
Ok(Finish::Noop) => {}
Ok(Finish::Commit {
command,
selected,
scale,
pan,
}) => {
// Document prepares and writes the source candidate before
// committing its snapshot. Failure leaves camera/selection
// and source/history at their accepted values.
match self.document.execute(*command) {
Ok(_) => {
self.selected = selected;
self.selected_node = None;
self.diagram_navigation_focus = None;
self.behavior.scenario.accept_spacing_camera(scale, pan);
self.sync();
self.status = "Scenario spacing updated.".into();
}
Err(error) => self.status = format!("Spacing was not applied: {error}"),
}
}
Err(_) => {
self.status =
"That position cannot be applied. The saved arrangement is unchanged."
.into()
}
}
}
Event::Cancel { .. } => unreachable!(),
}
Task::none()
}
}
@@ -1,393 +0,0 @@
use super::*;
use behavior::scenario::{
CanvasEvent,
spacing::{
Target,
controller::tests::{fixture, packet},
},
};
use iced::{Point, Size, Vector};
fn setup() -> (App, Id, Id) {
let (document, owner, diagram, lane, message) = fixture();
let mut app = App::with_project(document.project().clone());
let _ = app.update(Message::Select(owner));
let _ = app.update(Message::Diagram(Choice {
id: diagram,
label: "Saved".into(),
}));
let c = app.scenario_context().unwrap();
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Viewport(
c,
Size::new(900., 600.),
)));
assert!(app.spacing_enabled());
(app, lane, message)
}
#[test]
fn stale_text_and_scenario_messages_do_not_retire_a_new_saved_spacing_gesture() {
let (mut app, lane, _) = setup();
let previous = app.scenario_context().unwrap();
let diagram = match previous.representation {
behavior::scenario_layout::Representation::Saved { diagram } => diagram,
_ => unreachable!(),
};
let _ = app.update(Message::Page(Page::Text));
let old_indent = Message::SourceIndent(app.source_symbol_context(), false);
let old_drawer = Message::TextWorkspace(text_workspace::Event::ShowDrawer(
app.text_drawer_context(),
text_workspace::DrawerTab::Problems,
));
let old_command = app.text_command_message(text_workspace::Command::ShowStatus);
let _ = app.update(Message::Diagram(Choice {
id: diagram,
label: "Saved".into(),
}));
let context = app.scenario_context().unwrap();
assert_ne!(context.visit, previous.visit);
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Viewport(
context,
Size::new(900., 600.),
)));
let context = app.scenario_context().unwrap();
let (event, start, _) = begin(&mut app, Target::Lane(lane));
move_to(&mut app, event, start + Vector::new(24., 0.));
let accepted = app.document.snapshot();
let source = app.source_workspace.bundle();
let source_version = app.source_version;
let camera = app.camera_boundary();
let selected = app.selected;
let events = [
old_indent,
old_drawer,
old_command,
Message::ScenarioCanvas(CanvasEvent::Shelf(
previous,
behavior::scenario::Shelf::Selection,
)),
Message::ScenarioCanvas(CanvasEvent::Properties(previous, lane)),
];
for rejected in events {
let _ = app.update(rejected);
assert_eq!(app.scenario_context(), Some(context));
assert_eq!(app.behavior.scenario.spacing_token(), Some(event.token()));
assert_eq!(app.behavior.scenario.spacing_stamp(), Some(event.stamp()));
assert!(app.camera_boundary() == camera);
assert_eq!(app.selected, selected);
assert_eq!(app.source_version, source_version);
assert_eq!(app.source_workspace.bundle(), source);
assert_eq!(app.document.bundle(), &accepted.bundle);
assert_eq!(app.project(), &accepted.decoded.project);
assert!(app.text_workspace.drawer().is_none());
assert!(!app.document.can_undo() && !app.document.can_redo());
}
finish(&mut app, event, start + Vector::new(24., 0.));
assert!(
app.document.can_undo(),
"the current gesture remains usable"
);
}
fn begin(app: &mut App, target: Target) -> (Event, Point, f32) {
let event = packet(
app.project(),
&app.behavior.scenario,
app.scenario_context().unwrap(),
app.camera_epoch,
target,
);
let Event::Begin {
press_screen,
scale,
..
} = event
else {
unreachable!()
};
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Spacing(event)));
assert_eq!(app.behavior.scenario.spacing_token(), Some(event.token()));
(event, press_screen, scale)
}
fn send(app: &mut App, event: Event) {
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Spacing(event)));
}
fn move_to(app: &mut App, begin: Event, screen: Point) {
send(
app,
Event::Move {
stamp: begin.stamp(),
token: begin.token(),
screen,
},
);
}
fn finish(app: &mut App, begin: Event, screen: Point) {
send(
app,
Event::Finish {
stamp: begin.stamp(),
token: begin.token(),
screen,
},
);
}
#[test]
fn same_batch_preview_is_immutable_and_one_commit_undo_redo_has_exact_source_parity() {
let (mut app, lane, _) = setup();
let original = app.document.snapshot();
let source = app.source_workspace.bundle();
let selected = app.selected;
let epoch = app.canvas_epoch;
let (event, start, scale) = begin(&mut app, Target::Lane(lane));
move_to(&mut app, event, start + Vector::new(40., 300.));
assert_eq!(app.selected, selected);
assert_eq!(app.canvas_epoch, epoch);
assert_eq!(app.project(), &original.decoded.project);
assert_eq!(app.source_workspace.bundle(), source);
assert!(!app.document.can_undo() && !app.document.can_redo());
finish(&mut app, event, start + Vector::new(40., 300.));
assert_eq!(app.selected, lane);
assert!(app.document.can_undo() && !app.document.can_redo());
let accepted = app.document.snapshot();
assert_ne!(accepted.bundle, original.bundle);
let expected = behavior::scenario::spacing::controller::tests::expected(
&original.decoded.project,
match event.stamp().context.representation {
behavior::scenario_layout::Representation::Saved { diagram } => diagram,
_ => unreachable!(),
},
Target::Lane(lane),
40. / scale,
);
assert_eq!(app.project(), &expected);
let _ = app.update(Message::ShortcutUndo(false));
assert_eq!(app.project(), &original.decoded.project);
assert_eq!(app.document.bundle(), &original.bundle);
assert!(!app.document.can_undo() && app.document.can_redo());
let _ = app.update(Message::ShortcutUndo(true));
assert_eq!(app.project(), &accepted.decoded.project);
assert_eq!(app.document.bundle(), &accepted.bundle);
}
#[test]
fn click_selects_but_crossed_return_and_cancel_preserve_selection_camera_and_history() {
let (mut app, lane, message) = setup();
let original = app.document.snapshot();
let camera = app.behavior.scenario.camera_boundary();
let (event, start, _) = begin(&mut app, Target::Message(message));
finish(&mut app, event, start + Vector::new(30., 2.));
assert_eq!(app.selected, message);
assert!(camera == app.behavior.scenario.camera_boundary());
for cancel in [false, true] {
let (event, start, _) = begin(&mut app, Target::Lane(lane));
move_to(&mut app, event, start + Vector::new(30., 0.));
if cancel {
send(
&mut app,
Event::Cancel {
stamp: event.stamp(),
token: event.token(),
},
);
} else {
finish(&mut app, event, start);
}
assert_eq!(app.selected, message);
assert!(camera == app.behavior.scenario.camera_boundary());
assert_eq!(app.document.bundle(), &original.bundle);
assert_eq!(app.project(), &original.decoded.project);
assert!(!app.document.can_undo() && !app.document.can_redo());
}
}
#[test]
fn stale_finish_after_new_view_and_forged_token_are_inert_before_reveal_reconciliation() {
let (mut app, lane, _) = setup();
let (event, start, _) = begin(&mut app, Target::Lane(lane));
let held_epoch = app.canvas_epoch;
send(
&mut app,
Event::Finish {
stamp: event.stamp(),
token: Id::new_v4(),
screen: start + Vector::new(30., 0.),
},
);
assert_eq!(app.canvas_epoch, held_epoch);
assert_eq!(app.behavior.scenario.spacing_token(), Some(event.token()));
let owner = event.stamp().context.scenario;
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Representation(
event.stamp().context,
behavior::scenario_layout::Representation::Derived { owner },
)));
let current = app.scenario_context();
let epoch = app.canvas_epoch;
let original = app.document.snapshot();
finish(&mut app, event, start + Vector::new(50., 0.));
assert_eq!(app.scenario_context(), current);
assert_eq!(app.canvas_epoch, epoch);
assert!(app.behavior.scenario.spacing_token().is_none());
assert_eq!(app.document.bundle(), &original.bundle);
}
#[test]
fn source_write_failure_cannot_commit_candidate_or_materialize_camera() {
let (mut app, lane, _) = setup();
let bundle = app.document.bundle().clone();
let mut limits = oa_core::source_project::ProjectLimits::default();
limits.model.max_source_bytes = bundle.files.values().map(String::len).sum();
app.document = Document::from_bundle(bundle, limits).unwrap();
app.sync();
let original = app.document.snapshot();
let selected = app.selected;
let camera = app.behavior.scenario.camera_boundary();
let (event, start, _) = begin(&mut app, Target::Lane(lane));
move_to(&mut app, event, start + Vector::new(40., 0.));
finish(&mut app, event, start + Vector::new(40., 0.));
assert!(
app.status.starts_with("Spacing was not applied:"),
"{}",
app.status
);
assert_eq!(app.selected, selected);
assert!(camera == app.behavior.scenario.camera_boundary());
assert_eq!(app.project(), &original.decoded.project);
assert_eq!(app.document.bundle(), &original.bundle);
assert!(!app.document.can_undo() && !app.document.can_redo());
assert!(app.behavior.scenario.spacing_token().is_none());
}
#[test]
fn pending_semantic_draft_and_source_edit_cancel_and_reject_held_finish() {
for source in [false, true] {
let (mut app, lane, _) = setup();
let original = app.document.snapshot();
let selected = app.selected;
let (event, start, _) = begin(&mut app, Target::Lane(lane));
move_to(&mut app, event, start + Vector::new(30., 0.));
if source {
let _ = app.update(Message::SourceEdit(text_editor::Action::Edit(
text_editor::Edit::Insert(' '),
)));
assert!(app.source_dirty());
} else {
let _ = app.update(Message::Behavior(behavior::Event::Scenario(
behavior::scenario::Event::Start(
event.stamp().context.scenario,
behavior::scenario::Tool::Lifeline,
),
)));
assert!(app.behavior.has_pending());
}
let draft = app.source_workspace.bundle();
assert!(app.behavior.scenario.spacing_token().is_none());
finish(&mut app, event, start + Vector::new(30., 0.));
assert_eq!(app.project(), &original.decoded.project);
assert_eq!(app.document.bundle(), &original.bundle);
assert_eq!(app.source_workspace.bundle(), draft);
assert_eq!(app.selected, selected);
assert!(!app.document.can_undo());
}
}
#[test]
fn stale_finish_cannot_remove_new_same_view_deferred_reveal() {
let (mut app, lane, message) = setup();
let (event, start, _) = begin(&mut app, Target::Lane(lane));
let behavior::scenario_layout::Representation::Saved { diagram } =
event.stamp().context.representation
else {
unreachable!()
};
let _ = app.update(Message::RevealInDiagram(diagram, message));
assert!(app.pending_diagram_reveal.is_some());
let epoch = app.canvas_epoch;
let selected = app.selected;
let visit = app.pending_scenario_reveal_visit;
let original = app.document.snapshot();
finish(&mut app, event, start + Vector::new(50., 0.));
assert_eq!(app.canvas_epoch, epoch);
assert_eq!(app.pending_scenario_reveal_visit, visit);
assert!(app.pending_diagram_reveal.is_some());
assert_eq!(app.selected, selected);
assert_eq!(app.document.bundle(), &original.bundle);
assert!(!app.document.can_undo());
}
#[test]
fn stale_active_actions_cannot_remove_new_same_visit_deferred_reveal() {
let (mut app, lane, message) = setup();
let old = app.scenario_context().unwrap();
let behavior::scenario_layout::Representation::Saved { diagram } = old.representation else {
unreachable!()
};
let accepted = app.document.snapshot();
let source = app.source_workspace.bundle();
let _ = app.update(Message::RevealInDiagram(diagram, message));
let current = app.scenario_context().unwrap();
assert_eq!(current.visit, old.visit);
assert_eq!(current.representation, old.representation);
assert_eq!(current.revision, old.revision);
assert_ne!(current.epoch, old.epoch);
let pending = app.pending_diagram_reveal.clone();
assert!(pending.is_some());
let focus = app.diagram_navigation_focus.as_ref().unwrap();
let focus = (focus.context, focus.usage.clone(), focus.selected);
let camera = app.camera_boundary();
for event in [
CanvasEvent::Representation(old, old.representation),
CanvasEvent::Shelf(old, behavior::scenario::Shelf::Selection),
CanvasEvent::Select(old, lane),
CanvasEvent::Zoom(old, Some(1.)),
] {
let _ = app.update(Message::ScenarioCanvas(event));
assert_eq!(app.scenario_context(), Some(current));
assert_eq!(app.pending_diagram_reveal, pending);
assert_eq!(app.pending_scenario_reveal_visit, Some(current.visit));
let actual = app.diagram_navigation_focus.as_ref().unwrap();
assert_eq!(
(actual.context, actual.usage.clone(), actual.selected),
focus
);
assert!(app.camera_boundary() == camera);
assert_eq!(app.selected, message);
assert_eq!(app.document.bundle(), &accepted.bundle);
assert_eq!(app.project(), &accepted.decoded.project);
assert_eq!(app.source_workspace.bundle(), source);
assert!(!app.document.can_undo() && !app.document.can_redo());
}
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Viewport(
current,
Size::new(800., 500.),
)));
assert!(app.pending_diagram_reveal.is_none());
assert!(app.pending_scenario_reveal_visit.is_none());
assert!(
app.diagram_focus(crate::diagram_usage::Surface::Saved(diagram))
.is_some()
);
assert_eq!(app.selected, message);
assert_eq!(app.document.bundle(), &accepted.bundle);
assert!(!app.document.can_undo() && !app.document.can_redo());
}
#[test]
fn modal_open_cancels_preview_and_captured_begin_stays_retired_after_close() {
let (mut app, lane, _) = setup();
let original = app.document.snapshot();
let camera = app.behavior.scenario.camera_boundary();
let (event, start, _) = begin(&mut app, Target::Lane(lane));
move_to(&mut app, event, start + Vector::new(30., 0.));
let _ = app.update(Message::Delete);
assert!(app.delete_pending);
assert!(app.behavior.scenario.spacing_token().is_none());
finish(&mut app, event, start + Vector::new(30., 0.));
let _ = app.update(Message::CancelDelete);
send(&mut app, event);
assert!(app.behavior.scenario.spacing_token().is_none());
assert_eq!(app.project(), &original.decoded.project);
assert_eq!(app.document.bundle(), &original.bundle);
assert!(camera == app.behavior.scenario.camera_boundary());
assert!(!app.document.can_undo());
}
@@ -1,77 +0,0 @@
//! Controller acceptance: one indentation gesture is one draft transaction.
use super::*;
fn fixture(source: &str) -> App {
let mut app = App::with_project(Project::empty());
app.page = Page::Text;
app.source_workspace = Workspace::new(
&oa_core::source_project::SourceBundle {
entry: "project.arcadia".into(),
files: std::collections::BTreeMap::from([("project.arcadia".into(), source.into())]),
},
true,
);
app.show_source_file(false);
app
}
fn indent(app: &mut App, outdent: bool) {
let message = Message::SourceIndent(app.source_symbol_context(), outdent);
let _ = app.update(message);
}
#[test]
fn indentation_updates_draft_snapshot_version_and_single_undo_redo_without_model_change() {
let source = "雨\r\n🚀\r\n";
let mut app = fixture(source);
app.source_editor.perform(text_editor::Action::SelectAll);
let version = app.source_version;
let revision = app.source_workspace.revision();
let model = app.document.revision();
indent(&mut app, false);
let after = " 雨\r\n 🚀\r\n";
assert_eq!(app.source_editor.text(), after);
assert_eq!(app.source_snapshot.as_ref(), after);
assert_eq!(app.source_workspace.files["project.arcadia"].text, after);
assert!(app.source_workspace.has_edits());
assert_eq!(app.source_version, version + 1);
assert_eq!(app.source_workspace.revision(), revision + 1);
assert_eq!(app.document.revision(), model);
let _ = app.update(Message::SourceUndo);
assert_eq!(app.source_editor.text(), source);
assert!(!app.source_workspace.has_edits());
let _ = app.update(Message::SourceRedo);
assert_eq!(app.source_editor.text(), after);
assert_eq!(app.document.revision(), model);
}
#[test]
fn stale_hidden_and_modal_indentation_is_rejected_before_navigation_invalidation() {
for rejected in 0..7 {
let mut app = fixture("alpha");
let mut context = app.source_symbol_context();
match rejected {
0 => context.path = "elsewhere.arcadia".into(),
1 => context.session = Id::new_v4(),
2 => app.page = Page::Home,
3 => app.busy = true,
4 => app.delete_pending = true,
5 => app.hosted.visible = true,
_ => app.pending = Some(Pending::New),
}
let interaction = app.source_symbols.interaction;
let version = app.source_version;
let _ = app.update(Message::SourceIndent(context, false));
assert_eq!(app.source_editor.text(), "alpha");
assert_eq!(app.source_version, version);
assert_eq!(app.source_symbols.interaction, interaction);
}
}
#[test]
fn no_op_outdent_does_not_add_history_or_increment_source_version() {
let mut app = fixture("alpha");
let version = app.source_version;
indent(&mut app, true);
assert_eq!(app.source_version, version);
assert!(!app.source_workspace.undo(false));
}
@@ -33,9 +33,6 @@ fn admission_notice(error: query::AdmissionError) -> String {
impl App {
pub(super) fn source_input_envelope(&self, message: &Message) -> bool {
match message {
Message::SourceIndent(context, _) => {
context == &self.source_symbol_context() && self.source_navigation_available()
}
Message::SourceSymbols(context, event) => {
context.session == self.source_workspace.session()
&& context.path == self.source_workspace.active
@@ -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);
}
@@ -24,7 +24,6 @@ impl App {
if self.behavior.has_pending() {
return;
}
self.behavior.scenario.forget_view();
self.layer = layer;
self.page = Page::Behavior;
self.behavior.active_owner = Some(machine);
@@ -115,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()
@@ -158,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) => {
@@ -180,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();
}
@@ -236,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
@@ -49,28 +49,11 @@ fn fixture(layer: Layer, kind: Kind) -> (App, Id, Id, Id) {
p.diagrams.get_mut(&view).unwrap().name = "Unrelated architecture".into();
p.place(view, reused, Position { x: 873., y: 421. });
let mut app = App::with_project(p);
let accepted = app.document.snapshot();
// Enter through the actual navigation path: assigning Page/owner alone does
// not create the representation and live visit required by canvas events.
send(&mut app, Message::Select(root));
send(&mut app, Message::Page(Page::Behavior));
assert_eq!(app.layer, layer);
assert_eq!(app.page, Page::Behavior);
assert_eq!(app.selected, root);
assert_eq!(app.behavior.active_owner, Some(root));
if kind == Kind::Scenario {
let context = app.scenario_context().unwrap();
assert_eq!(context.document, app.canvas_document);
assert_eq!(context.scenario, root);
assert_eq!(
context.representation,
crate::behavior::scenario_layout::Representation::Derived { owner: root }
);
assert!(!context.visit.is_nil());
}
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
assert_eq!(app.source_workspace.bundle(), accepted.bundle);
assert!(!app.document.can_undo() && !app.document.can_redo());
app.layer = layer;
app.page = Page::Behavior;
app.selected = root;
app.behavior.active_owner = Some(root);
app.refresh_draft();
(app, root, work, reused)
}
+45 -372
View File
@@ -21,11 +21,6 @@ pub(crate) mod creation;
mod form_session;
pub(crate) mod scenario;
mod scenario_activation;
#[cfg(test)]
mod scenario_hit_tests;
// Pure saved-geometry bridge; workspace integration is a separate checkpoint.
#[allow(dead_code)]
pub(crate) mod scenario_layout;
mod scenario_lifecycle;
pub(crate) mod source_draft;
pub(crate) mod state_canvas;
@@ -64,18 +59,9 @@ mod scenario_scroll {
process: bool,
restore: Option<scenario::ScrollRestore>,
}
type Identity = (Id, Id, scenario_layout::Representation, Id);
fn identity(context: scenario::Context) -> Identity {
(
context.document,
context.scenario,
context.representation,
context.visit,
)
}
type Report = Option<(bool, Identity, Vector)>;
type Report = Option<(bool, Id, Id, Vector)>;
struct ObservedScroll {
identity: Identity,
identity: (Id, Id),
report: Report,
restored: Option<Id>,
}
@@ -126,12 +112,8 @@ mod scenario_scroll {
) {
let restore = self.restore.filter(|request| {
!self.process
&& (
request.document,
request.owner,
request.representation,
request.visit,
) == identity(self.context)
&& (request.document, request.owner)
== (self.context.document, self.context.scenario)
&& tree.state.downcast_ref::<ObservedScroll>().restored != Some(request.token)
});
let receipt = restore.and_then(|request| {
@@ -155,7 +137,12 @@ mod scenario_scroll {
&mut offset,
);
if let Some(offset) = offset.0 {
let next = Some((self.process, identity(self.context), offset));
let next = Some((
self.process,
self.context.document,
self.context.scenario,
offset,
));
let state = tree.state.downcast_mut::<ObservedScroll>();
if let Some(token) = receipt {
state.restored = Some(token);
@@ -199,7 +186,7 @@ mod scenario_scroll {
}
fn state(&self) -> widget::tree::State {
widget::tree::State::new(ObservedScroll {
identity: identity(self.context),
identity: (self.context.document, self.context.scenario),
report: None,
restored: None,
})
@@ -208,7 +195,9 @@ mod scenario_scroll {
vec![widget::Tree::new(self.content.as_widget())]
}
fn diff(&self, tree: &mut widget::Tree) {
if tree.state.downcast_ref::<ObservedScroll>().identity != identity(self.context) {
if tree.state.downcast_ref::<ObservedScroll>().identity
!= (self.context.document, self.context.scenario)
{
// A new owner/document needs fresh real scroll state, not merely
// cleared mirrored offsets. Edits and tool/form changes retain it.
tree.state = self.state();
@@ -497,25 +486,17 @@ mod scenario_scroll {
}
#[test]
fn direct_scenario_restore_cannot_scroll_another_document_owner_or_process_view() {
for mismatch in 0..5 {
for mismatch in 0..3 {
let context = scenario::direct_tests::context(Id::new_v4());
let mut request = scenario::ScrollRestore {
document: context.document,
owner: context.scenario,
representation: context.representation,
visit: context.visit,
token: Id::new_v4(),
offset: Vector::new(0., 180.),
};
match mismatch {
0 => request.document = Id::new_v4(),
1 => request.owner = Id::new_v4(),
3 => {
request.representation = scenario_layout::Representation::Saved {
diagram: Id::new_v4(),
}
}
4 => request.visit = Id::new_v4(),
_ => {}
}
let mut view = observer(600., context);
@@ -602,7 +583,7 @@ mod scenario_scroll {
use widget::operation::scrollable::{AbsoluteOffset, scroll_to};
for process in [false, true] {
let area = scenario::ScrollArea::Page;
for change in 0..6 {
for change in 0..4 {
let context = scenario::direct_tests::context(Id::new_v4());
let mut view = observer(600., context);
view.process = process;
@@ -631,12 +612,6 @@ mod scenario_scroll {
next.revision += 1;
next.epoch += 5;
}
4 => {
next.representation = scenario_layout::Representation::Saved {
diagram: Id::new_v4(),
}
}
5 => next.visit = Id::new_v4(),
_ => {}
}
view = observer(if change == 3 { 800. } else { 600. }, next);
@@ -684,7 +659,7 @@ mod scenario_scroll {
.collect::<Vec<_>>();
assert_eq!(
actual,
vec![if !(2..4).contains(&change) {
vec![if change < 2 {
Vector::ZERO
} else {
Vector::new(0., 180.)
@@ -998,19 +973,6 @@ pub fn view(app: &App) -> Element<'_, Message> {
return container(out).padding(16).width(Fill).height(Fill).into();
}
}
// A requested saved view remains the destination even after its model is removed.
// The shared display adapter reports unavailable instead of choosing another behavior.
if matches!(
app.behavior.scenario.representation(),
Some(scenario_layout::Representation::Saved { .. })
) && let Some(owner) = app.behavior.scenario.owner()
&& !p
.elements
.get(&owner)
.is_some_and(|element| element.kind() == Kind::Scenario && element.layer() == app.layer)
{
return scenario::view(app, owner, column![].spacing(6));
}
let choices = p
.elements
.values()
@@ -1022,14 +984,7 @@ pub fn view(app: &App) -> Element<'_, Message> {
label: format!("{} · {}", e.kind(), e.name),
})
.collect::<Vec<_>>();
let owner = if matches!(
app.behavior.scenario.representation(),
Some(scenario_layout::Representation::Saved { .. })
) {
app.behavior.scenario.owner()
} else {
selected_owner(app)
};
let owner = selected_owner(app);
let selected = owner
.and_then(|id| choices.iter().find(|c| c.id == id))
.or(choices.first())
@@ -1163,27 +1118,8 @@ fn in_machine(p: &Project, id: Id, machine: Id) -> bool {
false
}
/// Identifies the drawn primitive that won selection, not an authoring permission.
/// In particular an activation and its message can share an occurrence UUID.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum SceneHitRole {
LifelineHeader,
MessageBody,
MessageLabel,
Activation,
LifecycleMarker,
LifelineGuide,
Box,
OtherLine,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct SceneHit {
id: Id,
role: SceneHitRole,
}
struct BoxItem {
id: Id,
role: SceneHitRole,
rect: Rectangle,
title: String,
detail: String,
@@ -1216,7 +1152,6 @@ fn box_text_clip(
struct Line {
id: Id,
role: SceneHitRole,
points: Vec<Point>,
label: String,
label_at: Point,
@@ -1237,17 +1172,12 @@ struct Scene {
boxes: Vec<BoxItem>,
lines: Vec<Line>,
size: Size,
layout_origin: Point,
selected: Id,
scale: f32,
camera_pan: iced::Vector,
interaction: Option<(scenario::Context, scenario::CanvasTool, bool)>,
spacing_stamp: Option<scenario::spacing::Stamp>,
coordinate_input: bool,
pending_placement: Option<(scenario::Slot, Option<Id>)>,
lanes: BTreeMap<Id, f32>,
// Exact render coordinates include unassigned lanes; do not derive from cards.
render_lanes: BTreeMap<Id, f32>,
slots: Vec<SequenceSlot>,
rows: BTreeMap<Id, (Id, f32)>,
activations: Vec<scenario_activation::Segment>,
@@ -1271,10 +1201,6 @@ struct SequenceInput {
}
#[derive(Clone, Copy)]
enum SequencePress {
Spacing {
stamp: scenario::spacing::Stamp,
token: Id,
},
Occurrence {
tool: scenario::Tool,
slot: scenario::Slot,
@@ -1295,19 +1221,6 @@ enum SequencePress {
},
}
impl SequenceInput {
fn cancel_spacing(&mut self) -> Option<Action<Message>> {
let cancel = match self.press {
Some(SequencePress::Spacing { stamp, token }) => Some(
Action::publish(Message::ScenarioCanvas(scenario::CanvasEvent::Spacing(
scenario::spacing::Event::Cancel { stamp, token },
)))
.and_capture(),
),
_ => None,
};
self.cancel();
cancel
}
fn cancel(&mut self) {
self.source = None;
self.press = None;
@@ -1319,31 +1232,17 @@ impl Scene {
&& (self.boxes.iter().any(|item| item.id == id)
|| self.lines.iter().any(|item| item.id == id))
}
// The integrated removal guard also needs the derived semantic hit set.
fn sequence(p: &Project, owner: Id, selected: Id) -> Self {
Self::sequence_geometry(p, owner, selected, None, None)
}
fn sequence_geometry(
p: &Project,
owner: Id,
selected: Id,
lane_override: Option<&BTreeMap<Id, f32>>,
plan: Option<&scenario_layout::Plan>,
) -> Self {
let mut scene = Self {
scale: 1.,
camera_pan: iced::Vector::ZERO,
boxes: vec![],
lines: vec![],
size: Size::new(700., 200.),
layout_origin: Point::ORIGIN,
selected,
interaction: None,
spacing_stamp: None,
coordinate_input: true,
pending_placement: None,
lanes: BTreeMap::new(),
render_lanes: BTreeMap::new(),
slots: vec![],
rows: BTreeMap::new(),
activations: vec![],
@@ -1414,32 +1313,12 @@ impl Scene {
})
.collect();
}
let mut lane_x = lanes
let lane_x = lanes
.iter()
.enumerate()
.map(|(i, (id, ..))| (*id, 160. + i as f32 * 240.))
.collect::<BTreeMap<_, _>>();
if let Some(values) = lane_override {
for (id, x) in values {
if let Some(lane) = lane_x.get_mut(id) {
*lane = *x;
}
}
}
scene.render_lanes = lane_x.clone();
scene.size.width = (lanes.len() as f32 * 240. + 80.).max(680.);
let layout_left = if lane_override.is_some() {
lane_x.values().fold(0_f32, |left, x| left.min(*x - 160.))
} else {
0.
};
scene.layout_origin.x = layout_left;
if lane_override.is_some() {
scene.size.width = lane_x
.values()
.fold(scene.size.width, |right, x| right.max(*x + 240.))
- layout_left;
}
if scenario {
scene.lanes = lane_x
.iter()
@@ -1455,26 +1334,8 @@ impl Scene {
.collect();
}
let mut y = if scenario { 148. } else { 120. };
scene.occurrences(p, owner, scenario, &lane_x, &mut y, 0, plan);
if layout_left != 0. {
for item in &mut scene.boxes {
let body = scenario_layout::occurrence(p, item.id);
if matches!(
body,
Some(
OccurrenceBody::Branch { .. }
| OccurrenceBody::ScenarioUse { .. }
| OccurrenceBody::Control { .. }
| OccurrenceBody::SequenceReference { .. }
| OccurrenceBody::Exchange { .. }
)
) || (plan.is_none() && matches!(body, Some(OccurrenceBody::Fragment { .. })))
{
item.rect.x += layout_left;
}
}
}
scene.size.height = plan.map_or((y + 45.).max(210.), |plan| plan.height);
scene.occurrences(p, owner, scenario, &lane_x, &mut y, 0);
scene.size.height = (y + 45.).max(210.);
if scenario {
scene.lifecycle = scenario_lifecycle::render::Rendering::new(
p,
@@ -1485,9 +1346,6 @@ impl Scene {
scene.size.height,
);
scene.lifecycle.align_messages(&mut scene.lines);
if let Some(plan) = plan {
plan.align_message_anchors(&mut scene.lines);
}
scene.layout_activations(p, &lane_x);
}
for (id, name, detail) in lanes {
@@ -1497,7 +1355,6 @@ impl Scene {
0,
Line {
id,
role: SceneHitRole::LifelineGuide,
points: vec![Point::new(x, 85.), Point::new(x, scene.size.height - 20.)],
label: String::new(),
label_at: Point::ORIGIN,
@@ -1507,11 +1364,6 @@ impl Scene {
}
scene.boxes.push(BoxItem {
id,
role: if scenario {
SceneHitRole::LifelineHeader
} else {
SceneHitRole::Box
},
rect: Rectangle::new(Point::new(x - 102., 15.), Size::new(204., 70.)),
title: name,
detail,
@@ -1520,7 +1372,6 @@ impl Scene {
}
scene
}
#[allow(clippy::too_many_arguments)] // Explicit complete plan replaces ambient geometry state.
fn occurrences(
&mut self,
p: &Project,
@@ -1529,7 +1380,6 @@ impl Scene {
lanes: &BTreeMap<Id, f32>,
y: &mut f32,
depth: usize,
plan: Option<&scenario_layout::Plan>,
) {
let steps = p
.elements
@@ -1569,9 +1419,6 @@ impl Scene {
}
#[cfg(test)]
scenario_lifecycle::render::apply_test_row_offset(*id, y);
if let Some(plan) = plan {
*y = plan.anchors[id].y;
}
if ordered {
let slot = scenario::Slot {
parent: owner,
@@ -1588,7 +1435,7 @@ impl Scene {
OccurrenceBody::Fragment { operator, .. } => {
let top = *y;
*y += 55.;
self.occurrences(p, *id, scenario, lanes, y, depth + 1, plan);
self.occurrences(p, *id, scenario, lanes, y, depth + 1);
let rect = Rectangle::new(
Point::new(18. + depth as f32 * 10., top),
Size::new(
@@ -1596,12 +1443,10 @@ impl Scene {
(*y - top).max(95.),
),
);
let rect = plan.map_or(rect, |plan| plan.frames[id]);
self.boxes.insert(
0,
BoxItem {
id: *id,
role: SceneHitRole::Box,
rect,
title: format!("{operator:?} · {}", e.name),
detail: String::new(),
@@ -1613,7 +1458,6 @@ impl Scene {
OccurrenceBody::Branch { guard, .. } => {
self.boxes.push(BoxItem {
id: *id,
role: SceneHitRole::Box,
rect: Rectangle::new(
Point::new(36. + depth as f32 * 10., *y),
Size::new(self.size.width - 85. - depth as f32 * 20., 43.),
@@ -1630,7 +1474,7 @@ impl Scene {
marker: Some(Kind::Occurrence),
});
*y += if scenario { 90. } else { 62. };
self.occurrences(p, *id, scenario, lanes, y, depth + 1, plan);
self.occurrences(p, *id, scenario, lanes, y, depth + 1);
}
OccurrenceBody::Exchange {
exchange,
@@ -1658,7 +1502,7 @@ impl Scene {
source.and_then(|id| lanes.get(&id)),
target.and_then(|id| lanes.get(&id)),
) {
let points = if source == target {
let points = if a == b {
vec![
Point::new(*a, *y),
Point::new(a + 75., *y),
@@ -1697,7 +1541,6 @@ impl Scene {
}
self.lines.push(Line {
id: *id,
role: SceneHitRole::MessageBody,
points,
label: format!("{}. {kind}{}", index + 1, message_label),
label_at: Point::new(a.min(*b) + 12., *y - 26.),
@@ -1706,7 +1549,6 @@ impl Scene {
} else {
self.boxes.push(BoxItem {
id: *id,
role: SceneHitRole::Box,
rect: Rectangle::new(
Point::new(65., *y - 20.),
Size::new(self.size.width - 130., 65.),
@@ -1729,7 +1571,6 @@ impl Scene {
.unwrap_or(180.);
self.boxes.push(BoxItem {
id: *id,
role: SceneHitRole::Box,
rect: Rectangle::new(Point::new(x - 110., *y - 20.), Size::new(220., 70.)),
title: e.name.clone(),
detail: action
@@ -1742,7 +1583,6 @@ impl Scene {
OccurrenceBody::Control { kind } => {
self.boxes.push(BoxItem {
id: *id,
role: SceneHitRole::Box,
rect: Rectangle::new(Point::new(65., *y - 20.), Size::new(240., 70.)),
title: format!("{kind:?} · {}", e.name),
detail: "Contextual process control".into(),
@@ -1753,7 +1593,6 @@ impl Scene {
OccurrenceBody::SequenceReference { sequence } => {
self.boxes.push(BoxItem {
id: *id,
role: SceneHitRole::Box,
rect: Rectangle::new(Point::new(65., *y - 20.), Size::new(280., 70.)),
title: e.name.clone(),
detail: sequence
@@ -1766,7 +1605,6 @@ impl Scene {
OccurrenceBody::ScenarioUse { scenario, bindings } => {
self.boxes.push(BoxItem {
id: *id,
role: SceneHitRole::Box,
rect: Rectangle::new(
Point::new(65., *y - 20.),
Size::new(self.size.width - 130., 70.),
@@ -1789,10 +1627,6 @@ impl Scene {
}
if ordered {
self.sequence_bounds.insert(owner, (sequence_top, *y - 42.));
if let Some(plan) = plan {
self.sequence_bounds.insert(owner, plan.scopes[&owner]);
*y = plan.scopes[&owner].1 + 42.;
}
let slot = scenario::Slot {
parent: owner,
before: None,
@@ -1832,21 +1666,11 @@ impl Scene {
))
}
fn hit(&self, point: Point) -> Option<Id> {
self.hit_item(point).map(|hit| hit.id)
}
/// Preserve the semantic selection order and tolerance while retaining the
/// primitive's identity. Future spacing admission must also validate the
/// saved view, owner and core occurrence kind; a hit alone grants no edit.
fn hit_item(&self, point: Point) -> Option<SceneHit> {
if !point.x.is_finite() || !point.y.is_finite() {
return None;
}
if let Some(id) = self.lifecycle.marker_at(point) {
return Some(SceneHit {
id,
role: SceneHitRole::LifecycleMarker,
});
return Some(id);
}
self.boxes
.iter()
@@ -1856,10 +1680,7 @@ impl Scene {
&& b.rect.contains(point)
&& (b.marker != Some(Kind::Occurrence) || point.y < b.rect.y + 38.)
})
.map(|b| SceneHit {
id: b.id,
role: b.role,
})
.map(|b| b.id)
.or_else(|| {
self.lines
.iter()
@@ -1869,51 +1690,26 @@ impl Scene {
crate::diagram::distance_to_segment(point, p[0], p[1]) < 1.5
})
})
.map(|line| SceneHit {
id: line.id,
role: line.role,
})
.map(|line| line.id)
})
.or_else(|| {
self.activations
.iter()
.rev()
.find(|b| b.rect.contains(point))
.map(|b| SceneHit {
id: b.call,
role: SceneHitRole::Activation,
})
.map(|b| b.call)
})
.or_else(|| {
self.lines
.iter()
.find(|line| !line.label.is_empty() && line.label_bounds().contains(point))
.map(|line| SceneHit {
id: line.id,
role: if line.role == SceneHitRole::MessageBody {
SceneHitRole::MessageLabel
} else {
SceneHitRole::OtherLine
},
})
.map(|line| line.id)
})
// The generous message tolerance must not steal the exposed
// shoulder of another call's execution when the view is zoomed out.
.or_else(|| self.near_sequence_item(point))
.or_else(|| {
self.lifecycle
.lane_at(point, self.scale)
.map(|id| SceneHit {
id,
role: SceneHitRole::LifelineGuide,
})
})
.or_else(|| {
self.lane_at(point).map(|id| SceneHit {
id,
role: SceneHitRole::LifelineGuide,
})
})
.or_else(|| self.lifecycle.lane_at(point, self.scale))
.or_else(|| self.lane_at(point))
.or_else(|| {
self.lines
.iter()
@@ -1924,14 +1720,11 @@ impl Scene {
< 8. / self.scale
})
})
.map(|line| SceneHit {
id: line.id,
role: line.role,
})
.map(|line| line.id)
})
}
fn near_sequence_item(&self, point: Point) -> Option<SceneHit> {
fn near_sequence_item(&self, point: Point) -> Option<Id> {
if !self.scale.is_finite() || self.scale <= 0. {
return None;
}
@@ -1945,32 +1738,17 @@ impl Scene {
.windows(2)
.map(|p| crate::diagram::distance_to_segment(point, p[0], p[1]))
.fold(f32::INFINITY, f32::min);
(
SceneHit {
id: line.id,
role: line.role,
},
distance,
)
(line.id, distance)
});
// Compare broad message and lifecycle-marker tolerance together after
// exact shapes, so dense minimum-Fit rows choose the nearer item.
self.lifecycle
.nearest_marker(point)
.map(|(id, distance)| {
(
SceneHit {
id,
role: SceneHitRole::LifecycleMarker,
},
distance,
)
})
.into_iter()
.chain(messages)
.filter(|(_, distance)| *distance <= 8. / self.scale)
.min_by(|a, b| a.1.total_cmp(&b.1).then_with(|| a.0.id.cmp(&b.0.id)))
.map(|(hit, _)| hit)
.min_by(|a, b| a.1.total_cmp(&b.1).then_with(|| a.0.cmp(&b.0)))
.map(|(id, _)| id)
}
}
impl canvas::Program<Message> for Scene {
@@ -1998,33 +1776,15 @@ impl canvas::Program<Message> for Scene {
// captured insertion slot belongs to the previous view, even if the
// pointer itself did not move and the semantic context is unchanged.
if state.bounds.is_some_and(|previous| previous != bounds) {
let cancelled = state.cancel_spacing();
state.cancel();
state.pointer = None;
state.bounds = Some(bounds);
if cancelled.is_some() {
return cancelled;
}
}
state.bounds = Some(bounds);
if (!editable || !self.coordinate_input)
&& let Some(cancelled) = state.cancel_spacing()
{
return Some(cancelled);
if !editable {
state.cancel();
}
// The viewport masks input outside its visible canvas bounds.
let visible = cursor.position().is_some();
if !visible
&& matches!(state.press, Some(SequencePress::Spacing { .. }))
&& matches!(
event,
canvas::Event::Mouse(
mouse::Event::CursorMoved { .. }
| mouse::Event::ButtonReleased(mouse::Button::Left)
)
)
{
return state.cancel_spacing();
}
if !visible
&& matches!(
event,
@@ -2043,7 +1803,7 @@ impl canvas::Program<Message> for Scene {
| SequencePress::Reorder { start, moved, .. } => {
*moved |= position.distance(*start) >= 3.
}
SequencePress::Occurrence { .. } | SequencePress::Spacing { .. } => {}
SequencePress::Occurrence { .. } => {}
}
}
mouse::Cursor::Available(*position)
@@ -2052,9 +1812,9 @@ impl canvas::Program<Message> for Scene {
| canvas::Event::Window(iced::window::Event::Resized(_))
| canvas::Event::Mouse(mouse::Event::CursorLeft)
| canvas::Event::Window(iced::window::Event::Unfocused) => {
let cancelled = state.cancel_spacing();
state.cancel();
state.pointer = None;
return cancelled.or_else(|| Some(Action::request_redraw()));
return Some(Action::request_redraw());
}
canvas::Event::Mouse(
mouse::Event::ButtonPressed(_) | mouse::Event::ButtonReleased(_),
@@ -2090,9 +1850,8 @@ impl canvas::Program<Message> for Scene {
key: iced::keyboard::Key::Named(iced::keyboard::key::Named::Escape),
..
}) if state.source.is_some() || state.press.is_some() => {
return state
.cancel_spacing()
.or_else(|| Some(Action::request_redraw().and_capture()));
state.cancel();
return Some(Action::request_redraw().and_capture());
}
_ => cursor,
};
@@ -2112,14 +1871,6 @@ impl canvas::Program<Message> for Scene {
if !editable {
return None;
}
if !self.coordinate_input {
return self.hit(point).map(|id| {
Action::publish(Message::ScenarioCanvas(CanvasEvent::Select(
context, id,
)))
.and_capture()
});
}
if let CanvasTool::Message(_) = tool {
let first = state.source.is_none();
let (source, slot) = state.source.or_else(|| {
@@ -2146,48 +1897,6 @@ impl canvas::Program<Message> for Scene {
});
return Some(Action::request_redraw().and_capture());
}
if matches!(
context.representation,
scenario_layout::Representation::Saved { .. }
) {
let hit = self.hit_item(point)?;
let target = match hit.role {
SceneHitRole::LifelineHeader => {
Some(scenario::spacing::Target::Lane(hit.id))
}
SceneHitRole::MessageBody | SceneHitRole::MessageLabel => {
Some(scenario::spacing::Target::Message(hit.id))
}
_ => None,
};
if let Some((target, stamp)) = target.zip(self.spacing_stamp)
&& stamp.context == context
{
let token = Id::new_v4();
state.press = Some(SequencePress::Spacing { stamp, token });
return Some(
Action::publish(Message::ScenarioCanvas(CanvasEvent::Spacing(
scenario::spacing::Event::Begin {
stamp,
token,
target,
press_screen: cursor.position()?,
canvas_bounds: bounds,
scale: self.scale,
pan: self.camera_pan,
},
)))
.and_capture(),
);
}
// Saved spacing never silently changes the shared Scenario order.
return Some(
Action::publish(Message::ScenarioCanvas(CanvasEvent::Select(
context, hit.id,
)))
.and_capture(),
);
}
if let Some(occurrence) = self
.hit(point)
.filter(|id| self.rows.contains_key(id))
@@ -2217,22 +1926,6 @@ impl canvas::Program<Message> for Scene {
let press = state.press.take()?;
let context = context?;
match press {
SequencePress::Spacing { stamp, token } => {
state.source = None;
let event = if world.is_some() {
scenario::spacing::Event::Finish {
stamp,
token,
screen: cursor.position()?,
}
} else {
scenario::spacing::Event::Cancel { stamp, token }
};
return Some(
Action::publish(Message::ScenarioCanvas(CanvasEvent::Spacing(event)))
.and_capture(),
);
}
SequencePress::Occurrence {
tool: captured,
slot,
@@ -2324,18 +2017,6 @@ impl canvas::Program<Message> for Scene {
Some(Action::request_redraw().and_capture())
}
canvas::Event::Mouse(mouse::Event::CursorMoved { .. }) if context.is_some() => {
if let Some(SequencePress::Spacing { stamp, token }) = state.press {
return Some(
Action::publish(Message::ScenarioCanvas(CanvasEvent::Spacing(
scenario::spacing::Event::Move {
stamp,
token,
screen: cursor.position()?,
},
)))
.and_capture(),
);
}
Some(Action::request_redraw())
}
_ => None,
@@ -2517,10 +2198,7 @@ impl canvas::Program<Message> for Scene {
self.lifecycle
.draw_markers(&mut f, self.selected, self.scale, self.camera_pan);
if let Some((context, tool, editable)) = self.interaction {
let active = editable
&& self.coordinate_input
&& state.context == Some(context)
&& state.tool == tool;
let active = editable && state.context == Some(context) && state.tool == tool;
let point = cursor.position_in(bounds).map(|p| {
Point::new(
(p.x - self.camera_pan.x) / self.scale,
@@ -2536,12 +2214,7 @@ impl canvas::Program<Message> for Scene {
}
});
if editable
&& self.coordinate_input
&& tool == scenario::CanvasTool::Select
&& matches!(
context.representation,
scenario_layout::Representation::Derived { .. }
)
&& let Some(handle) = self.reorder_handle()
{
f.fill_rectangle(handle.position(), handle.size(), style::TEAL);
@@ -50,7 +50,7 @@ pub fn controls<'a>(
control("Details", "Details", Some(details)),
control("Fit", "Fit", Some(zoom(None))),
control("ZoomOut", "−", Some(zoom(Some(scale / 1.25)))),
text(zoom_label(scale)).size(12),
text(format!("{:.0}%", scale * 100.)).size(12),
control("ZoomIn", "+", Some(zoom(Some(scale * 1.25)))),
control("ActualSize", "100%", Some(zoom(Some(1.)))),
]
@@ -65,7 +65,6 @@ pub fn controls<'a>(
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Scope {
pub(crate) scenario_view: Option<(super::scenario_layout::Representation, Id)>,
pub document: Id,
pub owner: Id,
pub revision: u64,
@@ -77,7 +76,6 @@ impl Scope {
Some(Self {
document: context.document,
owner: context.scenario,
scenario_view: Some((context.representation, context.visit)),
revision: context.revision,
epoch: app.camera_epoch,
})
@@ -86,7 +84,6 @@ impl Scope {
Some(Self {
document: context.document,
owner: context.owner,
scenario_view: None,
revision: context.revision,
epoch: app.camera_epoch,
})
@@ -106,8 +103,8 @@ impl Scope {
pub fn control_id(self, name: &str) -> iced::advanced::widget::Id {
iced::advanced::widget::Id::from(format!(
"behavior-control:{}:{}:{}:{:?}:{name}",
self.document, self.owner, self.revision, self.scenario_view
"behavior-control:{}:{}:{}:{name}",
self.document, self.owner, self.revision
))
}
}
@@ -140,20 +137,6 @@ pub enum Event {
pub use focus::{focus_task, root as focus_root};
/// Fold relative input in event order. The base scale is used only on leaving Fit.
pub fn change(pan: &mut Vector, zoom: &mut Option<f32>, fit: &mut bool, event: Event) -> bool {
change_with_minimum(pan, zoom, fit, event, 0.05)
}
/// Scenario extents can require scales below the topology canvas's 5% floor.
pub(super) fn change_with_minimum(
pan: &mut Vector,
zoom: &mut Option<f32>,
fit: &mut bool,
event: Event,
minimum: f32,
) -> bool {
if !minimum.is_finite() || minimum <= 0. || minimum > 3. {
return false;
}
match event {
Event::Pan(delta) if delta.x.is_finite() && delta.y.is_finite() => {
let next = *pan + delta;
@@ -173,10 +156,7 @@ pub(super) fn change_with_minimum(
&& at.y.is_finite() =>
{
let old = if *fit { base } else { zoom.unwrap_or(base) };
if !old.is_finite() || old <= 0. {
return false;
}
let next = (old * factor).clamp(minimum, 3.);
let next = (old * factor).clamp(0.05, 3.);
if old == next {
return false;
}
@@ -194,51 +174,6 @@ pub(super) fn change_with_minimum(
}
}
fn zoom_label(scale: f32) -> String {
let percent = f64::from(scale) * 100.;
if !percent.is_finite() || percent <= 0. {
return "—".into();
}
if percent >= 1. {
format!("{percent:.0}%")
} else if percent >= 0.01 {
format!("{percent:.3}%")
} else {
format!("{percent:.2e}%")
}
}
#[cfg(test)]
mod camera_policy_tests {
use super::*;
#[test]
fn topology_retains_five_percent_floor() {
let (mut pan, mut zoom, mut fit) = (Vector::ZERO, Some(0.06), false);
assert!(change(
&mut pan,
&mut zoom,
&mut fit,
Event::Zoom {
factor: 0.1,
at: Point::ORIGIN,
base: 0.06,
}
));
assert_eq!(zoom, Some(0.05));
}
#[test]
fn tiny_zoom_label_is_nonzero_and_changes_with_zoom() {
assert_eq!(zoom_label(1.), "100%");
assert_eq!(zoom_label(0.05), "5%");
assert_eq!(zoom_label(0.00034193), "0.034%");
assert_eq!(zoom_label(0.00034193 * 1.25), "0.043%");
assert_eq!(zoom_label(1e-7), "1.00e-5%");
assert_ne!(zoom_label(f32::from_bits(1)), "0%");
}
}
/// Constant-width labelled palette. Arming a tool never reallocates the canvas.
pub fn palette<'a>(
scope: Scope,
@@ -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![];
@@ -379,7 +371,6 @@ fn relative_camera_zoom_keeps_the_cursor_world_point_and_rejects_nonfinite_input
fn viewport_ordered_batch_uses_each_event_position_and_cancels_before_camera_motion() {
use iced::widget::Space;
let scope = Scope {
scenario_view: None,
document: Id::new_v4(),
owner: Id::new_v4(),
revision: 2,
@@ -439,7 +430,6 @@ pub(crate) fn scope(app: &App) -> Scope {
Scope {
document: context.document,
owner: context.scenario,
scenario_view: Some((context.representation, context.visit)),
revision: context.revision,
epoch: app.camera_epoch,
}
@@ -448,7 +438,6 @@ pub(crate) fn scope(app: &App) -> Scope {
Scope {
document: context.document,
owner: context.owner,
scenario_view: None,
revision: context.revision,
epoch: app.camera_epoch,
}
@@ -893,14 +893,11 @@ fn contextual_source_empty_palette_lifeline_cannot_open_a_previous_owner_or_laye
.add_element(Kind::Scenario, Layer::Oa, "Second scenario", None)
.unwrap();
let mut app = App::with_project(p.clone());
// Capture the context created by actual navigation, including its visit.
// Merely assigning active_owner/selected does not mount a Scenario anymore.
let _ = app.update(Message::Select(a));
let _ = app.update(Message::Page(Page::Behavior));
app.page = Page::Behavior;
app.layer = Layer::Oa;
app.behavior.active_owner = Some(a);
app.selected = a;
let context = app.scenario_context().unwrap();
assert_eq!(context.scenario, a);
assert!(!context.visit.is_nil());
let snapshot = app.document.snapshot();
let source = app.document.bundle().clone();
let begin = |context| scenario::CanvasEvent::BeginOccurrence {
context,
@@ -911,40 +908,23 @@ fn contextual_source_empty_palette_lifeline_cannot_open_a_previous_owner_or_laye
},
lifeline: None,
};
let _ = app.update(Message::Select(b));
let second = app.scenario_context().unwrap();
assert_eq!(second.scenario, b);
assert_ne!(second.visit, context.visit);
app.behavior.active_owner = Some(b);
app.selected = b;
let _ = app.update(Message::ScenarioCanvas(begin(context)));
assert!(!app.behavior.has_pending());
assert_eq!(app.project(), &p);
let _ = app.update(Message::Select(a));
let before_layer_change = app.scenario_context().unwrap();
assert_eq!(before_layer_change.scenario, a);
assert_ne!(before_layer_change.visit, context.visit);
let _ = app.update(Message::Layer(Layer::Sa));
assert_eq!(app.layer, Layer::Sa);
assert!(app.scenario_context().is_none());
let _ = app.update(Message::ScenarioCanvas(begin(before_layer_change)));
app.behavior.active_owner = Some(a);
app.selected = a;
app.layer = Layer::Sa;
let _ = app.update(Message::ScenarioCanvas(begin(context)));
assert!(!app.behavior.has_pending());
let _ = app.update(Message::Layer(Layer::Oa));
let _ = app.update(Message::Select(a));
app.layer = Layer::Oa;
let current = app.scenario_context().unwrap();
assert_eq!(current.scenario, a);
assert_ne!(current.visit, before_layer_change.visit);
// Returning to the same owner/layer must not revive either retired packet.
for retired in [context, before_layer_change] {
let _ = app.update(Message::ScenarioCanvas(begin(retired)));
assert!(!app.behavior.has_pending());
}
assert!(std::sync::Arc::ptr_eq(&snapshot, &app.document.snapshot()));
let _ = app.update(Message::ScenarioCanvas(begin(current)));
assert_eq!(app.behavior.pending_owner(), Some(a));
assert_eq!(app.project(), &p);
assert_eq!(app.document.bundle(), &source);
assert!(!app.document.can_undo() && !app.document.can_redo());
assert!(!app.document.can_undo());
}
#[test]
+21 -182
View File
@@ -4,28 +4,12 @@ use super::topology::{occurrence, unique_batch};
use super::*;
use iced::widget::{Column, column, text_input};
use std::{cell::RefCell, collections::BTreeSet};
mod camera;
pub(crate) mod focus;
pub(crate) mod spacing;
use super::scenario_layout::{
Representation,
presentation::{self, ScenarioPresentation},
};
#[cfg(test)]
mod presentation_tests;
mod usage;
mod workspace;
pub(crate) use workspace::NAME_INPUT;
pub use workspace::{Shelf, view};
// A picker may select the Saved diagram record itself. Another view of the
// same Scenario is not the property target of this displayed representation.
pub(crate) fn properties_target(p: &Project, context: Context, id: Id) -> bool {
super::selected_behavior(p, id) == Some(context.scenario)
|| matches!(context.representation, Representation::Saved { diagram } if diagram == id)
&& presentation::checked_owner(p, context.representation) == Some(context.scenario)
}
#[cfg(test)]
#[path = "scenario_operation_tests.rs"]
mod operation_tests;
@@ -115,8 +99,6 @@ pub(crate) fn removal_target(p: &Project, owner: Id, id: Id) -> bool {
pub struct Context {
pub document: Id,
pub scenario: Id,
pub representation: Representation,
pub visit: Id,
pub revision: u64,
pub epoch: u64,
}
@@ -143,7 +125,6 @@ impl std::fmt::Display for CanvasTool {
}
#[derive(Debug, Clone)]
pub enum CanvasEvent {
Spacing(spacing::Event),
Scrolled(Context, ScrollArea, iced::Vector),
Restored(Context, Id, ScrollArea, iced::Vector),
Viewport(Context, Size),
@@ -151,11 +132,9 @@ pub enum CanvasEvent {
Zoom(Context, Option<f32>),
Camera(Context, u64, super::canvas_workspace::Event),
Shelf(Context, Shelf),
Properties(Context, Id),
OpenUse(Context, Id),
Back(Context),
Choose(Context, CanvasTool),
Representation(Context, Representation),
Select(Context, Id),
Remove(Context, Id),
Begin {
@@ -185,7 +164,6 @@ pub enum ScrollArea {
impl CanvasEvent {
pub fn context(&self) -> Context {
match self {
Self::Spacing(event) => event.stamp().context,
Self::Scrolled(context, _, _)
| Self::Restored(context, ..)
| Self::Viewport(context, _)
@@ -193,11 +171,9 @@ impl CanvasEvent {
| Self::Zoom(context, _)
| Self::Camera(context, ..)
| Self::Shelf(context, _)
| Self::Properties(context, _)
| Self::OpenUse(context, _)
| Self::Back(context)
| Self::Choose(context, _)
| Self::Representation(context, _)
| Self::Select(context, _)
| Self::Remove(context, _)
| Self::Begin { context, .. }
@@ -255,7 +231,6 @@ pub enum ValueSection {
const VALUE_PAGE_SIZE: usize = 24;
#[derive(Default)]
pub struct State {
spacing: Option<spacing::controller::Session>,
draft: Option<Draft>,
form_session: Id,
form_mount: Id,
@@ -271,7 +246,6 @@ const CALLER_LIMIT: usize = 32;
pub(crate) struct Caller {
document: Id,
pub owner: Id,
pub representation: Representation,
pub occurrence: Id,
target: Id,
tool: CanvasTool,
@@ -281,8 +255,6 @@ pub(crate) struct Caller {
struct DetailsRestore {
document: Id,
owner: Id,
representation: Representation,
visit: Id,
token: Id,
offset: iced::Vector,
}
@@ -290,8 +262,6 @@ struct DetailsRestore {
pub(super) struct ScrollRestore {
pub document: Id,
pub owner: Id,
pub representation: Representation,
pub visit: Id,
pub token: Id,
pub offset: iced::Vector,
}
@@ -384,158 +354,46 @@ impl State {
}
pub(crate) fn enter(&mut self, document: Id, owner: Id) {
self.enter_representation(document, owner, Representation::Derived { owner });
}
/// App validates requested identity with checked_owner before entering.
pub(crate) fn enter_representation(
&mut self,
document: Id,
owner: Id,
representation: Representation,
) -> bool {
if matches!(representation, Representation::Derived { owner: requested } if requested != owner)
{
return false;
}
let changed = self.presentation.identity != Some((document, owner))
|| self.presentation.representation != Some(representation);
if changed {
if self.presentation.identity != Some((document, owner)) {
self.presentation = workspace::Presentation {
identity: Some((document, owner)),
representation: Some(representation),
fit: matches!(representation, Representation::Saved { .. }),
..Default::default()
};
self.details_offset = iced::Vector::ZERO;
self.canvas_tool = Some((owner, CanvasTool::Select));
}
if changed || self.presentation.visit.is_nil() {
self.spacing = None;
self.presentation.visit = Id::new_v4();
self.restore = None;
return true;
}
false
}
/// Suspend captured events while preserving this destination's camera state.
pub(crate) fn leave(&mut self) {
self.spacing = None;
self.presentation.visit = Id::nil();
self.restore = None;
}
pub(crate) fn forget_view(&mut self) {
self.spacing = None;
self.presentation.identity = None;
self.presentation.representation = None;
self.presentation.visit = Id::nil();
self.restore = None;
}
pub(crate) fn representation(&self) -> Option<Representation> {
self.presentation.representation
}
pub(crate) fn owner(&self) -> Option<Id> {
self.presentation.identity.map(|(_, owner)| owner)
}
pub(crate) fn context(
&self,
document: Id,
owner: Id,
revision: u64,
epoch: u64,
) -> Option<Context> {
if self.presentation.identity != Some((document, owner)) || self.presentation.visit.is_nil()
{
return None;
}
Some(Context {
document,
scenario: owner,
representation: self.representation()?,
visit: self.presentation.visit,
revision,
epoch,
})
}
pub(crate) fn display(
&self,
p: &Project,
owner: Id,
selected: Id,
) -> Option<ScenarioPresentation> {
let (_, current_owner) = self.presentation.identity?;
let representation = self.representation()?;
(current_owner == owner).then(|| presentation::present(p, representation, selected))
}
pub(crate) fn coordinate_input(&self, p: &Project, context: Context) -> bool {
self.context(
context.document,
context.scenario,
context.revision,
context.epoch,
) == Some(context)
&& self
.display(p, context.scenario, context.scenario)
.is_some_and(|shown| shown.capabilities().coordinate_input)
}
pub(crate) fn viewport(&mut self, size: Size) -> bool {
self.presentation.viewport(size)
}
pub(crate) fn camera(
&mut self,
project: &Project,
owner: Id,
event: super::canvas_workspace::Event,
) -> bool {
let Some(bounds) = self
.display(project, owner, owner)
.and_then(|shown| shown.bounds())
else {
return false;
};
pub(crate) fn camera(&mut self, event: super::canvas_workspace::Event) -> bool {
let p = &mut self.presentation;
camera::change(
&mut p.pan,
&mut p.zoom,
&mut p.fit,
p.viewport,
bounds,
event,
)
super::canvas_workspace::change(&mut p.pan, &mut p.zoom, &mut p.fit, event)
}
pub(crate) fn zoom(&mut self, scale: Option<f32>) -> bool {
self.presentation.zoom(scale)
}
pub(crate) fn camera_zoom(&mut self, p: &Project, owner: Id, zoom: Option<f32>) -> bool {
let Some(bounds) = self
.display(p, owner, owner)
.and_then(|shown| shown.bounds())
else {
return false;
};
let size = Scene::sequence(p, owner, owner).size;
let old = self.presentation.scale(size);
if let Some(next) = zoom {
let current = self.presentation.camera(bounds);
let viewport = self.presentation.viewport.unwrap_or(Size::new(500., 300.));
self.camera(
p,
owner,
super::canvas_workspace::Event::Zoom {
factor: next / current.scale,
at: Point::new(viewport.width / 2., viewport.height / 2.),
base: current.scale,
},
)
self.camera(super::canvas_workspace::Event::Zoom {
factor: next / old,
at: Point::new(viewport.width / 2., viewport.height / 2.),
base: old,
})
} else {
self.zoom(None)
self.zoom(None);
let fitted = self.presentation.scale(size);
self.zoom(Some(fitted));
self.presentation.pan = iced::Vector::ZERO;
true
}
}
pub(crate) fn toggle_shelf(&mut self, shelf: Shelf) {
self.presentation.shelf = (self.presentation.shelf != Some(shelf)).then_some(shelf);
}
pub(crate) fn show_properties(&mut self) {
// Panels remain available. Dismiss the competing outline without
// replacing this view or its camera.
self.presentation.shelf = None;
}
pub(crate) fn open_use(
&mut self,
p: &Project,
@@ -552,7 +410,6 @@ impl State {
self.return_to.push(Caller {
document: context.document,
owner: context.scenario,
representation: context.representation,
occurrence,
target,
tool: self.canvas_tool(context.scenario),
@@ -567,7 +424,6 @@ impl State {
fn valid_caller(p: &Project, context: Context, frame: &Caller) -> bool {
frame.document == context.document
&& frame.target == context.scenario
&& presentation::checked_owner(p, frame.representation) == Some(frame.owner)
&& referenced_scenario(p, frame.owner, frame.occurrence) == Some(frame.target)
}
pub(super) fn can_back(&self, p: &Project, context: Context) -> bool {
@@ -584,23 +440,16 @@ impl State {
None
}
pub(crate) fn restore_caller(&mut self, context: Context, frame: Caller) {
if context.document != frame.document
|| context.scenario != frame.owner
|| context.representation != frame.representation
{
if context.document != frame.document || context.scenario != frame.owner {
return;
}
self.canvas_tool = Some((frame.owner, frame.tool));
self.details_offset = frame.details_offset;
self.presentation = frame.presentation;
self.presentation.identity = Some((context.document, context.scenario));
self.presentation.representation = Some(frame.representation);
self.presentation.visit = context.visit;
self.restore = Some(DetailsRestore {
document: context.document,
owner: frame.owner,
representation: frame.representation,
visit: context.visit,
token: Id::new_v4(),
offset: frame.details_offset,
});
@@ -610,17 +459,13 @@ impl State {
context: Context,
ScrollArea::Page: ScrollArea,
) -> Option<ScrollRestore> {
let restore = self.restore.as_ref().filter(|r| {
r.document == context.document
&& r.owner == context.scenario
&& r.representation == context.representation
&& r.visit == context.visit
})?;
let restore = self
.restore
.as_ref()
.filter(|r| r.document == context.document && r.owner == context.scenario)?;
Some(ScrollRestore {
document: restore.document,
owner: restore.owner,
representation: restore.representation,
visit: restore.visit,
token: restore.token,
offset: restore.offset,
})
@@ -633,11 +478,7 @@ impl State {
offset: iced::Vector,
) -> bool {
if !self.restore.as_ref().is_some_and(|r| {
r.document == context.document
&& r.owner == context.scenario
&& r.representation == context.representation
&& r.visit == context.visit
&& r.token == token
r.document == context.document && r.owner == context.scenario && r.token == token
}) {
return false;
}
@@ -2110,8 +1951,6 @@ pub(crate) mod direct_tests {
Context {
document: Id::new_v4(),
scenario: owner,
representation: Representation::Derived { owner },
visit: Id::new_v4(),
revision: 0,
epoch: 0,
}
@@ -1,284 +0,0 @@
//! Scenario camera policy. Bounds are from the exact displayed presentation,
//! including its origin and automatic fallback; no model geometry is authored.
use crate::behavior::canvas_workspace::{self, Event};
use iced::{Rectangle, Size, Vector};
pub(super) const MIN_SCALE: f32 = f32::from_bits(1);
#[derive(Clone, Copy, Debug, PartialEq)]
pub(super) struct Camera {
pub(super) scale: f32,
pub(super) pan: Vector,
}
/// Fit never enlarges above 100%. Use f64 intermediates so a valid f32 origin
/// and size cannot overflow while finding the center. Unrepresentable cameras
/// are refused rather than silently clamped to an unrelated minimum scale.
pub(super) fn fit(bounds: Rectangle, viewport: Size) -> Option<Camera> {
if [
bounds.x,
bounds.y,
bounds.width,
bounds.height,
viewport.width,
viewport.height,
]
.iter()
.any(|n| !n.is_finite())
|| bounds.width < 0.
|| bounds.height < 0.
|| viewport.width <= 0.
|| viewport.height <= 0.
{
return None;
}
let width = f64::from(viewport.width);
let height = f64::from(viewport.height);
let scale = ((width - 2. * (width / 4.).min(8.)) / f64::from(bounds.width).max(1.))
.min((height - 2. * (height / 4.).min(8.)) / f64::from(bounds.height).max(1.))
.min(1.) as f32;
if !scale.is_finite() || scale <= 0. {
return None;
}
let x = width / 2. - (f64::from(bounds.x) + f64::from(bounds.width) / 2.) * f64::from(scale);
let y = height / 2. - (f64::from(bounds.y) + f64::from(bounds.height) / 2.) * f64::from(scale);
let pan = Vector::new(x as f32, y as f32);
(pan.x.is_finite() && pan.y.is_finite()).then_some(Camera { scale, pan })
}
pub(super) fn effective_camera(
pan: Vector,
zoom: Option<f32>,
fitting: bool,
viewport: Option<Size>,
bounds: Rectangle,
) -> Camera {
if fitting && let Some(camera) = viewport.and_then(|size| fit(bounds, size)) {
return camera;
}
Camera {
scale: zoom.unwrap_or(1.),
pan,
}
}
/// None preserves Fit as a mode, including before the first viewport arrives.
pub(super) fn set_zoom(
pan: &mut Vector,
zoom: &mut Option<f32>,
fitting: &mut bool,
scale: Option<f32>,
) -> bool {
if scale.is_some_and(|value| !value.is_finite() || value <= 0.) {
return false;
}
let next = scale.map(|value| value.min(3.));
let next_pan = if scale.is_none() { Vector::ZERO } else { *pan };
let changed = *zoom != next || *fitting != scale.is_none() || *pan != next_pan;
*zoom = next;
*fitting = scale.is_none();
*pan = next_pan;
changed
}
/// Materialize the visible fitted camera only when a camera gesture succeeds.
/// A zero-motion or rejected event preserves the original mode and fields.
pub(super) fn change(
pan: &mut Vector,
zoom: &mut Option<f32>,
fitting: &mut bool,
viewport: Option<Size>,
bounds: Rectangle,
event: Event,
) -> bool {
let camera = effective_camera(*pan, *zoom, *fitting, viewport, bounds);
let (mut next_pan, mut next_zoom, mut next_fit) = (camera.pan, Some(camera.scale), false);
let event = match event {
Event::Zoom { base, .. } if !base.is_finite() || base <= 0. => return false,
Event::Zoom { factor, at, .. } => Event::Zoom {
factor,
at,
base: camera.scale,
},
event => event,
};
if !canvas_workspace::change_with_minimum(
&mut next_pan,
&mut next_zoom,
&mut next_fit,
event,
MIN_SCALE,
) {
return false;
}
*pan = next_pan;
*zoom = next_zoom;
*fitting = next_fit;
true
}
#[cfg(test)]
mod tests {
use super::*;
use iced::Point;
fn bounds() -> Rectangle {
Rectangle {
x: -1_000_160.,
y: -200.,
width: 2_000_400.,
height: 1_000.,
}
}
fn near(actual: f32, expected: f32, tolerance: f32) {
assert!(
(actual - expected).abs() <= tolerance,
"{actual} != {expected}"
);
}
fn projected(camera: Camera, point: Point) -> Point {
Point::new(
point.x * camera.scale + camera.pan.x,
point.y * camera.scale + camera.pan.y,
)
}
#[test]
fn negative_origin_fit_contains_independently_known_extremes() {
let camera = fit(bounds(), Size::new(700., 500.)).unwrap();
near(camera.scale, 0.0003419316, 1e-10);
near(camera.pan.x, 349.98633, 0.0001);
near(
projected(camera, Point::new(-1_000_160., -200.)).x,
8.,
0.0001,
);
near(
projected(camera, Point::new(1_000_240., 800.)).x,
692.,
0.0001,
);
near(projected(camera, Point::new(40., 300.)).y, 250., 0.0001);
}
#[test]
fn fit_remains_active_across_first_and_changed_viewport() {
let (mut pan, mut zoom, mut fitting) = (Vector::new(50., 80.), Some(0.8), false);
assert!(set_zoom(&mut pan, &mut zoom, &mut fitting, None));
let retained = effective_camera(pan, zoom, fitting, Some(Size::new(1200., 500.)), bounds());
let actual = effective_camera(pan, zoom, fitting, Some(Size::new(500., 500.)), bounds());
assert!(actual.scale < retained.scale);
near(
projected(actual, Point::new(-1_000_160., -200.)).x,
8.,
0.0001,
);
near(
projected(actual, Point::new(1_000_240., 800.)).x,
492.,
0.0001,
);
assert_eq!((pan, zoom, fitting), (Vector::ZERO, None, true));
}
#[test]
fn tiny_fit_zoom_in_out_preserves_pointer_world_anchor_without_floor_jump() {
let size = Size::new(700., 500.);
let viewport = Some(size);
let start = fit(bounds(), size).unwrap();
let (mut pan, mut zoom, mut fitting) = (Vector::ZERO, None, true);
let at = Point::new(210., 180.);
let world = Point::new(
(at.x - start.pan.x) / start.scale,
(at.y - start.pan.y) / start.scale,
);
for factor in [1.25, 0.8] {
let prior_scale = zoom.unwrap_or(start.scale);
assert!(change(
&mut pan,
&mut zoom,
&mut fitting,
viewport,
bounds(),
Event::Zoom {
factor,
at,
base: start.scale,
}
));
let camera = effective_camera(pan, zoom, fitting, viewport, bounds());
near(camera.scale, prior_scale * factor, 1e-10);
assert!(camera.scale < 0.001);
near(projected(camera, world).x, at.x, 0.0001);
near(projected(camera, world).y, at.y, 0.0001);
}
near(zoom.unwrap(), start.scale, 1e-10);
near(pan.x, start.pan.x, 0.0001);
assert!(!fitting);
}
#[test]
fn pan_leaves_fit_from_displayed_position_and_zero_motion_preserves_fit() {
let size = Size::new(700., 500.);
let viewport = Some(size);
let (mut pan, mut zoom, mut fitting) = (Vector::ZERO, None, true);
for event in [
Event::Pan(Vector::ZERO),
Event::Zoom {
factor: 1.,
at: Point::ORIGIN,
base: 1.,
},
] {
assert!(!change(
&mut pan,
&mut zoom,
&mut fitting,
viewport,
bounds(),
event
));
assert_eq!((pan, zoom, fitting), (Vector::ZERO, None, true));
}
let start = fit(bounds(), size).unwrap();
assert!(change(
&mut pan,
&mut zoom,
&mut fitting,
viewport,
bounds(),
Event::Pan(Vector::new(20., -30.))
));
assert_eq!(pan, start.pan + Vector::new(20., -30.));
assert_eq!(zoom, Some(start.scale));
assert!(!fitting);
}
#[test]
fn invalid_inputs_are_passive_and_large_finite_bounds_do_not_overflow_center() {
assert!(fit(bounds(), Size::new(f32::NAN, 500.)).is_none());
let extreme = Rectangle {
x: f32::MAX,
y: 0.,
width: f32::MAX,
height: 100.,
};
let camera = fit(extreme, Size::new(700., 500.)).unwrap();
assert!(camera.scale > 0. && camera.scale < 1e-30 && camera.pan.x.is_finite());
let (mut pan, mut zoom, mut fitting) = (Vector::ZERO, None, true);
assert!(!set_zoom(&mut pan, &mut zoom, &mut fitting, Some(f32::NAN)));
assert!(!change(
&mut pan,
&mut zoom,
&mut fitting,
Some(Size::new(700., 500.)),
bounds(),
Event::Zoom {
factor: 1.25,
at: Point::ORIGIN,
base: f32::NAN
}
));
assert_eq!((pan, zoom, fitting), (Vector::ZERO, None, true));
}
}
@@ -11,8 +11,6 @@ use std::collections::BTreeMap;
pub(crate) struct Stamp {
document: ModelId,
owner: ModelId,
representation: super::Representation,
visit: ModelId,
revision: u64,
session: ModelId,
mount: ModelId,
@@ -22,8 +20,6 @@ impl Stamp {
Self {
document: context.document,
owner: context.scenario,
representation: context.representation,
visit: context.visit,
revision: context.revision,
session,
mount,
@@ -32,12 +28,10 @@ impl Stamp {
}
pub(crate) fn name_id(stamp: Stamp) -> Id {
Id::from(format!(
"{}:{}:{}:{:?}:{}:{}:{}:{}",
"{}:{}:{}:{}:{}:{}",
super::NAME_INPUT,
stamp.document,
stamp.owner,
stamp.representation,
stamp.visit,
stamp.revision,
stamp.session,
stamp.mount
@@ -1,348 +0,0 @@
//! Controller identity and actual presentation input, independent of App routing.
use super::*;
use oa_core::{Diagram, DiagramLayout, ProjectionKind, ScenarioLanePosition, ScenarioLayout};
fn saved(p: &mut Project, owner: Id, lanes: &[(Id, f32)]) -> Representation {
let id = Id::new_v4();
p.diagrams.insert(
id,
Diagram {
id,
alias: format!("saved_{}", p.diagrams.len()),
name: "Saved scenario".into(),
description: String::new(),
layer: p.elements[&owner].layer(),
projection: ProjectionKind::Scenario { scenario: owner },
view_options: Default::default(),
nodes: Default::default(),
routes: vec![],
layout: Some(DiagramLayout::Scenario(ScenarioLayout {
lanes: lanes
.iter()
.map(|&(lifeline, x)| ScenarioLanePosition { lifeline, x })
.collect(),
messages: vec![],
})),
},
);
let errors: Vec<_> = oa_core::validation::validate(p)
.into_iter()
.filter(|issue| issue.severity == oa_core::validation::Severity::Error)
.collect();
assert!(errors.is_empty(), "{errors:?}");
Representation::Saved { diagram: id }
}
#[test]
fn saved_visits_distinguish_same_owner_roundtrips_and_suspended_return_without_editing() {
let mut f = direct_tests::fixture();
let a = saved(&mut f.project, f.owner, &[(f.sender, -500.)]);
let b = saved(&mut f.project, f.owner, &[(f.sender, 900.)]);
let original = f.project.clone();
let document = Id::new_v4();
let mut state = State::default();
assert!(state.enter_representation(document, f.owner, a));
let first = state.context(document, f.owner, 7, 9).unwrap();
assert!(!state.enter_representation(document, f.owner, a));
assert_eq!(state.context(document, f.owner, 7, 9), Some(first));
state.presentation.pan = iced::Vector::new(23., -17.);
state.leave();
assert_eq!(state.representation(), Some(a));
assert_eq!(state.owner(), Some(f.owner));
assert!(state.context(document, f.owner, 7, 9).is_none());
assert!(state.enter_representation(document, f.owner, a));
let returned = state.context(document, f.owner, 7, 9).unwrap();
assert_ne!(returned.visit, first.visit);
assert_eq!(state.presentation.pan, iced::Vector::new(23., -17.));
assert!(state.enter_representation(document, f.owner, b));
let middle = state.context(document, f.owner, 7, 9).unwrap();
assert!(state.enter_representation(document, f.owner, a));
let last = state.context(document, f.owner, 7, 9).unwrap();
assert_ne!(last.visit, returned.visit);
assert_ne!(last.visit, middle.visit);
assert!(!state.coordinate_input(&f.project, first));
assert!(state.coordinate_input(&f.project, last));
let session = Id::new_v4();
let mount = Id::new_v4();
assert_ne!(
focus::Stamp::new(first, session, mount),
focus::Stamp::new(last, session, mount)
);
assert_eq!(f.project, original);
state.forget_view();
assert_eq!(state.representation(), None);
assert_eq!(state.owner(), None);
assert!(state.context(document, f.owner, 7, 9).is_none());
}
#[test]
fn saved_caller_restores_exact_representation_with_fresh_visit_and_rejects_deleted_view() {
let (mut project, [a, b, _], [reference, _, _]) = direct_tests::navigation_fixture();
let view = saved(&mut project, a, &[]);
let original = project.clone();
let document = Id::new_v4();
let mut state = State::default();
state.enter_representation(document, a, view);
let initial = state.context(document, a, 0, 0).unwrap();
state.presentation.pan = iced::Vector::new(11., 29.);
state.details_offset = iced::Vector::new(0., 71.);
assert_eq!(state.open_use(&project, initial, reference).unwrap(), b);
state.enter(document, b);
let callee = state.context(document, b, 0, 0).unwrap();
let frame = state.back(&project, callee).unwrap();
assert_eq!(frame.representation, view);
state.enter_representation(document, a, frame.representation);
let returned = state.context(document, a, 0, 0).unwrap();
state.restore_caller(returned, frame);
assert_ne!(returned.visit, initial.visit);
assert_eq!(state.representation(), Some(view));
assert_eq!(state.presentation.pan, iced::Vector::new(11., 29.));
assert!(state.scroll_restore(initial, ScrollArea::Page).is_none());
assert_eq!(
state
.scroll_restore(returned, ScrollArea::Page)
.unwrap()
.offset,
iced::Vector::new(0., 71.)
);
assert_eq!(project, original);
state.open_use(&project, returned, reference).unwrap();
state.enter(document, b);
let Representation::Saved { diagram } = view else {
unreachable!()
};
project.diagrams.remove(&diagram);
let callee = state.context(document, b, 0, 0).unwrap();
assert!(state.back(&project, callee).is_none());
}
#[test]
fn complete_fallback_scene_selects_but_cannot_capture_or_finish_coordinate_gestures() {
let mut f = direct_tests::fixture();
// Equal authored lane centers are structurally valid, but cannot render a distinct-endpoint call.
let rep = saved(
&mut f.project,
f.owner,
&[(f.sender, 900.), (f.receiver, 900.)],
);
let original = f.project.clone();
let mut state = State::default();
state.enter_representation(Id::new_v4(), f.owner, rep);
let document = state.presentation.identity.unwrap().0;
let context = state.context(document, f.owner, 0, 0).unwrap();
let shown = state.display(&f.project, f.owner, f.call).unwrap();
assert!(matches!(
shown.status(),
presentation::PresentationStatus::Fallback { .. }
));
assert!(!state.coordinate_input(&f.project, context));
let automatic = presentation::present(
&f.project,
Representation::Derived { owner: f.owner },
f.call,
);
let automatic = automatic.scene().unwrap();
assert_eq!(shown.scene().unwrap().render_lanes, automatic.render_lanes);
assert_eq!(shown.scene().unwrap().activations, automatic.activations);
assert!(!automatic.activations.is_empty());
let mut scene = shown.into_scene().unwrap();
scene.coordinate_input = false;
let line = scene.lines.iter().find(|line| line.id == f.call).unwrap();
let point = Point::new(
(line.points[0].x + line.points.last().unwrap().x) / 2.,
line.points[0].y,
);
assert_eq!(scene.hit(point), Some(f.call));
let bounds = Rectangle::with_size(Size::new(1600., 1000.));
for tool in [
CanvasTool::Select,
CanvasTool::Message(MessageStyle::CallReply),
CanvasTool::Add(Tool::Reuse),
] {
scene.interaction = Some((context, tool, true));
let mut input = SequenceInput::default();
let mut messages = vec![];
for event in [
mouse::Event::CursorMoved { position: point },
mouse::Event::ButtonPressed(mouse::Button::Left),
mouse::Event::CursorMoved {
position: Point::new(point.x, point.y + 100.),
},
mouse::Event::ButtonReleased(mouse::Button::Left),
] {
if let Some(message) = canvas::Program::update(
&scene,
&mut input,
&canvas::Event::Mouse(event),
bounds,
mouse::Cursor::Available(point),
)
.and_then(|action| action.into_inner().0)
{
messages.push(message);
}
}
assert_eq!(messages.len(), 1);
assert!(
matches!(messages[0], Message::ScenarioCanvas(CanvasEvent::Select(c, id)) if c == context && id == f.call)
);
assert!(input.press.is_none() && input.source.is_none());
}
// Capture from an actual Ready presentation, then revoke coordinate input
// with the complete fallback frame of the same Saved representation.
let Representation::Saved { diagram } = rep else {
unreachable!()
};
let mut ready_project = f.project.clone();
ready_project.diagrams.get_mut(&diagram).unwrap().layout =
Some(DiagramLayout::Scenario(ScenarioLayout::default()));
let ready_original = ready_project.clone();
let ready = state.display(&ready_project, f.owner, f.call).unwrap();
assert!(matches!(
ready.status(),
presentation::PresentationStatus::Ready
));
let mut ready_scene = ready.into_scene().unwrap();
let line = ready_scene
.lines
.iter()
.find(|line| line.id == f.call)
.unwrap();
let ready_point = Point::new(
(line.points[0].x + line.points.last().unwrap().x) / 2.,
line.points[0].y,
);
let hit = ready_scene.hit_item(ready_point).unwrap();
assert_eq!(hit.id, f.call);
assert_eq!(hit.role, SceneHitRole::MessageBody);
let stamp = spacing::Stamp {
context,
camera_epoch: 17,
};
ready_scene.coordinate_input = true;
ready_scene.interaction = Some((context, CanvasTool::Select, true));
ready_scene.spacing_stamp = Some(stamp);
let mut input = SequenceInput::default();
let begin = canvas::Program::update(
&ready_scene,
&mut input,
&canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
bounds,
mouse::Cursor::Available(ready_point),
)
.and_then(|action| action.into_inner().0)
.unwrap();
let Message::ScenarioCanvas(CanvasEvent::Spacing(spacing::Event::Begin {
stamp: captured_stamp,
token,
target: spacing::Target::Message(target),
..
})) = begin
else {
panic!("Ready Saved message must begin a stamped spacing gesture");
};
assert_eq!(captured_stamp, stamp);
assert_eq!(target, f.call);
assert!(matches!(input.press, Some(SequencePress::Spacing {
stamp: held_stamp, token: held_token
}) if held_stamp == stamp && held_token == token));
scene.interaction = Some((context, CanvasTool::Select, true));
assert!(!scene.coordinate_input && scene.spacing_stamp.is_none());
let release = canvas::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left));
let result = canvas::Program::update(
&scene,
&mut input,
&release,
bounds,
mouse::Cursor::Available(ready_point),
)
.and_then(|action| action.into_inner().0);
assert!(
matches!(result, Some(Message::ScenarioCanvas(CanvasEvent::Spacing(
spacing::Event::Cancel { stamp: cancelled_stamp, token: cancelled_token }
))) if cancelled_stamp == stamp && cancelled_token == token)
);
assert!(input.press.is_none() && input.source.is_none());
assert!(
canvas::Program::update(
&scene,
&mut input,
&release,
bounds,
mouse::Cursor::Available(ready_point),
)
.and_then(|action| action.into_inner().0)
.is_none()
);
assert_eq!(ready_project, ready_original);
assert_eq!(f.project, original);
}
#[test]
fn saved_camera_fits_actual_negative_origin_and_tiny_scale_without_authored_mutation() {
let mut f = direct_tests::fixture();
let rep = saved(
&mut f.project,
f.owner,
&[(f.sender, -1_000_000.), (f.receiver, 1_000_000.)],
);
let original = f.project.clone();
let mut state = State::default();
state.enter_representation(Id::new_v4(), f.owner, rep);
state.viewport(Size::new(700., 500.));
assert!(
state.presentation.fit,
"new saved views await their real viewport in Fit mode"
);
assert!(
!state.camera_zoom(&f.project, f.owner, None),
"already-active Fit is idempotent"
);
let shown = state.display(&f.project, f.owner, f.owner).unwrap();
assert!(matches!(
shown.status(),
presentation::PresentationStatus::Ready
));
let bounds = shown.bounds().unwrap();
assert!(bounds.x < -1_000_000. && bounds.x + bounds.width > 1_000_000.);
let fitted = state.presentation.camera(bounds);
assert!(fitted.scale > 0. && fitted.scale < 0.001);
let left = bounds.x * fitted.scale + fitted.pan.x;
let right = (bounds.x + bounds.width) * fitted.scale + fitted.pan.x;
assert!((left - 8.).abs() < 0.001 && (right - 692.).abs() < 0.001);
assert!(state.camera(
&f.project,
f.owner,
super::super::canvas_workspace::Event::Pan(iced::Vector::new(13., -7.))
));
let moved = state.presentation.camera(bounds);
assert_eq!(moved.scale, fitted.scale);
assert_eq!(moved.pan, fitted.pan + iced::Vector::new(13., -7.));
assert!(!state.presentation.fit);
assert_eq!(f.project, original);
}
#[test]
fn deleted_saved_identity_remains_unavailable_and_never_falls_into_derived_geometry() {
let mut f = direct_tests::fixture();
let rep = saved(&mut f.project, f.owner, &[]);
let mut state = State::default();
let document = Id::new_v4();
state.enter_representation(document, f.owner, rep);
let Representation::Saved { diagram } = rep else {
unreachable!()
};
f.project.diagrams.remove(&diagram);
let original = f.project.clone();
let shown = state.display(&f.project, f.owner, f.owner).unwrap();
assert_eq!(shown.requested(), rep);
assert!(matches!(
shown.status(),
presentation::PresentationStatus::Unavailable { .. }
));
assert!(shown.scene().is_none() && shown.bounds().is_none());
assert_eq!(state.owner(), Some(f.owner));
assert!(state.context(document, f.owner, 0, 0).is_some());
assert!(!state.camera_zoom(&f.project, f.owner, None));
assert_eq!(f.project, original);
}
@@ -1,160 +0,0 @@
//! Pure Saved-only spacing preparation. Delta is measured from the original
//! pointer in the captured camera's world coordinates, never from the last
//! preview. The controller must own document/revision/visit, camera and hit-role
//! admission; this module neither dispatches commands nor changes selection.
use crate::behavior::scenario_layout::{
GeometryIssue, Representation, ResolvedScenarioPlan, presentation::checked_owner, resolve,
};
use oa_core::{
Command, Id, Kind, OccurrenceBody, OccurrenceEndpoint, Project, ScenarioLanePosition,
ScenarioMessagePosition, SharedBody, execute_batch,
};
pub(crate) mod controller;
#[cfg(test)]
mod tests;
pub(crate) use controller::{Event, Stamp};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Target {
Lane(Id),
Message(Id),
}
impl Target {
pub(crate) fn id(self) -> Id {
match self {
Self::Lane(id) | Self::Message(id) => id,
}
}
}
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum Refusal {
InvalidSavedView(Id),
TargetNotRendered(Target),
InvalidDelta,
InvalidGesture,
CameraChanged,
TargetNotHit(Target),
Command(String),
Geometry(Vec<GeometryIssue>),
}
pub(super) struct Drag {
original: Project,
diagram: Id,
target: Target,
origin: f32,
original_geometry: ResolvedScenarioPlan,
}
pub(super) struct Preview {
pub(super) command: Command,
#[cfg(test)]
pub(super) project: Project,
pub(super) geometry: ResolvedScenarioPlan,
}
/// Capture an accepted, actually drawn target. A Saved view displaying automatic
/// fallback is not an editable original, even if the target appears in fallback.
pub(super) fn capture(project: &Project, diagram: Id, target: Target) -> Result<Drag, Refusal> {
let representation = Representation::Saved { diagram };
let owner = checked_owner(project, representation).ok_or(Refusal::InvalidSavedView(diagram))?;
let geometry = resolve(project, representation, target.id()).map_err(Refusal::Geometry)?;
let element = project.elements.get(&target.id());
let origin = match target {
Target::Lane(id)
if element.is_some_and(|e| e.kind() == Kind::Lifeline && e.parent == Some(owner)) =>
{
// Unassigned lifelines still have drawn headers. `scene.lanes`
// excludes them only from semantic message-endpoint hit testing.
geometry.scene.render_lanes.get(&id).copied()
}
Target::Message(id)
if element.is_some_and(|e| {
matches!(
e.shared(),
Some(SharedBody::Occurrence {
body: OccurrenceBody::Exchange { .. },
..
})
)
}) =>
{
// A lifecycle row can denote receive y; the public position means
// send y. Read the actual outgoing endpoint, including self calls.
geometry
.scene
.lines
.iter()
.find(|line| line.id == id)
.and_then(|line| line.points.first())
.map(|point| point.y)
}
_ => None,
}
.ok_or(Refusal::TargetNotRendered(target))?;
if !origin.is_finite() {
return Err(Refusal::TargetNotRendered(target));
}
Ok(Drag {
original: project.clone(),
diagram,
target,
origin,
original_geometry: geometry,
})
}
impl Drag {
/// None means there is no command to commit. In particular +0/-0, return to
/// start, and sub-precision motion never materialize a missing override or
/// normalize an existing explicit value. Document/redo are never touched.
pub(super) fn prepare(&self, original_delta: f32) -> Result<Option<Preview>, Refusal> {
if !original_delta.is_finite() {
return Err(Refusal::InvalidDelta);
}
if original_delta == 0. {
return Ok(None);
}
let position = (f64::from(self.origin) + f64::from(original_delta)) as f32;
if position == self.origin {
return Ok(None);
}
let command = match self.target {
Target::Lane(lifeline) => Command::SetScenarioLanePosition {
diagram: self.diagram,
position: ScenarioLanePosition {
lifeline,
x: position,
},
},
Target::Message(occurrence) => Command::SetScenarioMessagePosition {
diagram: self.diagram,
position: ScenarioMessagePosition {
address: OccurrenceEndpoint {
references: vec![],
occurrence,
},
send_y: position,
},
},
};
let project = execute_batch(&self.original, std::slice::from_ref(&command))
.map_err(|error| Refusal::Command(error.to_string()))?;
let geometry = resolve(
&project,
Representation::Saved {
diagram: self.diagram,
},
self.target.id(),
)
.map_err(Refusal::Geometry)?;
Ok(Some(Preview {
command,
#[cfg(test)]
project,
geometry,
}))
}
}
@@ -1,300 +0,0 @@
//! One press owns one immutable accepted Project and one frozen display camera.
use super::*;
use crate::behavior::{
Scene, SceneHitRole,
scenario::{CanvasTool, Context, State},
scenario_layout::Diagnostic,
};
use iced::{Point, Rectangle, Vector};
#[cfg(test)]
pub(crate) mod tests;
#[cfg(test)]
mod timing_tests;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Stamp {
pub(crate) context: Context,
pub(crate) camera_epoch: u64,
}
#[derive(Clone, Copy, Debug)]
pub enum Event {
Begin {
stamp: Stamp,
token: Id,
target: Target,
press_screen: Point,
canvas_bounds: Rectangle,
scale: f32,
pan: Vector,
},
Move {
stamp: Stamp,
token: Id,
screen: Point,
},
Finish {
stamp: Stamp,
token: Id,
screen: Point,
},
Cancel {
stamp: Stamp,
token: Id,
},
}
impl Event {
pub(crate) fn stamp(self) -> Stamp {
match self {
Self::Begin { stamp, .. }
| Self::Move { stamp, .. }
| Self::Finish { stamp, .. }
| Self::Cancel { stamp, .. } => stamp,
}
}
pub(crate) fn token(self) -> Id {
match self {
Self::Begin { token, .. }
| Self::Move { token, .. }
| Self::Finish { token, .. }
| Self::Cancel { token, .. } => token,
}
}
}
pub(crate) struct Session {
stamp: Stamp,
token: Id,
press_screen: Point,
scale: f32,
pan: Vector,
drag: Drag,
preview: Option<Preview>,
refusal: Option<Refusal>,
ever_crossed: bool,
}
pub(in crate::behavior) struct Paint<'a> {
pub(in crate::behavior) scene: &'a Scene,
#[cfg(test)]
pub(in crate::behavior) bounds: Rectangle,
pub(in crate::behavior) refusal: Option<&'a Refusal>,
pub(in crate::behavior) original_diagnostics: &'a [Diagnostic],
pub(in crate::behavior) original_latent: &'a [Id],
pub(in crate::behavior) diagnostics: &'a [Diagnostic],
}
pub(crate) enum Finish {
Click(Id),
Noop,
Commit {
command: Box<Command>,
selected: Id,
scale: f32,
pan: Vector,
},
}
impl Session {
fn configure(&self, scene: &mut Scene) {
scene.selected = self.drag.target.id();
scene.scale = self.scale;
scene.camera_pan = self.pan;
scene.interaction = Some((self.stamp.context, CanvasTool::Select, true));
scene.spacing_stamp = Some(self.stamp);
scene.coordinate_input = true;
scene.pending_placement = None;
}
fn update(&mut self, screen: Point) {
self.preview = None;
self.refusal = None;
if !screen.x.is_finite() || !screen.y.is_finite() {
self.refusal = Some(Refusal::InvalidDelta);
return;
}
let distance = match self.drag.target {
Target::Lane(_) => screen.x - self.press_screen.x,
Target::Message(_) => screen.y - self.press_screen.y,
};
self.ever_crossed |= distance.abs() >= 3.;
if !self.ever_crossed {
return;
}
match self.drag.prepare(distance / self.scale) {
Ok(Some(mut preview)) => {
self.configure(&mut preview.geometry.scene);
self.preview = Some(preview);
}
Ok(None) => {}
Err(error) => self.refusal = Some(error),
}
}
}
impl State {
pub(crate) fn spacing_token(&self) -> Option<Id> {
self.spacing.as_ref().map(|s| s.token)
}
pub(crate) fn spacing_stamp(&self) -> Option<Stamp> {
self.spacing.as_ref().map(|s| s.stamp)
}
pub(crate) fn cancel_spacing(&mut self) -> bool {
self.spacing.take().is_some()
}
pub(in crate::behavior) fn spacing_paint(
&self,
context: Context,
camera_epoch: u64,
) -> Option<Paint<'_>> {
let session = self.spacing.as_ref()?;
if session.stamp
!= (Stamp {
context,
camera_epoch,
})
{
return None;
}
let original = &session.drag.original_geometry;
let shown = session.preview.as_ref().map_or(original, |p| &p.geometry);
Some(Paint {
scene: &shown.scene,
#[cfg(test)]
bounds: shown.bounds,
refusal: session.refusal.as_ref(),
original_diagnostics: &original.plan.diagnostics,
original_latent: &original.latent_messages,
diagnostics: &shown.plan.diagnostics,
})
}
pub(crate) fn begin_spacing(&mut self, project: &Project, event: Event) -> Result<(), Refusal> {
let Event::Begin {
stamp,
token,
target,
press_screen,
canvas_bounds,
scale,
pan,
} = event
else {
return Err(Refusal::InvalidGesture);
};
let context = stamp.context;
if self.spacing.is_some()
|| token.is_nil()
|| self.context(
context.document,
context.scenario,
context.revision,
context.epoch,
) != Some(context)
|| self.canvas_tool(context.scenario) != CanvasTool::Select
|| ![
press_screen.x,
press_screen.y,
canvas_bounds.x,
canvas_bounds.y,
canvas_bounds.width,
canvas_bounds.height,
scale,
pan.x,
pan.y,
]
.into_iter()
.all(f32::is_finite)
|| scale <= 0.
|| canvas_bounds.width <= 0.
|| canvas_bounds.height <= 0.
|| !canvas_bounds.contains(press_screen)
{
return Err(Refusal::InvalidGesture);
}
let Representation::Saved { diagram } = context.representation else {
return Err(Refusal::InvalidGesture);
};
let mut drag = capture(project, diagram, target)?;
let camera = self.presentation.camera(drag.original_geometry.bounds);
if self.presentation.viewport != Some(canvas_bounds.size())
|| camera.scale != scale
|| camera.pan != pan
{
return Err(Refusal::CameraChanged);
}
let world = Point::new(
(press_screen.x - canvas_bounds.x - pan.x) / scale,
(press_screen.y - canvas_bounds.y - pan.y) / scale,
);
// Hit tolerance is measured in displayed pixels, just like the widget.
drag.original_geometry.scene.scale = scale;
drag.original_geometry.scene.camera_pan = pan;
let hit = drag.original_geometry.scene.hit_item(world);
if !hit.is_some_and(|hit| {
hit.id == target.id()
&& match target {
Target::Lane(_) => hit.role == SceneHitRole::LifelineHeader,
Target::Message(_) => matches!(
hit.role,
SceneHitRole::MessageBody | SceneHitRole::MessageLabel
),
}
}) {
return Err(Refusal::TargetNotHit(target));
}
// Configure baseline without changing the persistent camera or selection.
let scene = &mut drag.original_geometry.scene;
scene.selected = target.id();
scene.scale = scale;
scene.camera_pan = pan;
scene.interaction = Some((context, CanvasTool::Select, true));
scene.spacing_stamp = Some(stamp);
scene.coordinate_input = true;
scene.pending_placement = None;
self.spacing = Some(Session {
stamp,
token,
press_screen,
scale,
pan,
drag,
preview: None,
refusal: None,
ever_crossed: false,
});
Ok(())
}
pub(crate) fn move_spacing(&mut self, screen: Point) {
if let Some(session) = &mut self.spacing {
session.update(screen);
}
}
pub(crate) fn finish_spacing(&mut self, screen: Point) -> Result<Finish, Refusal> {
let Some(mut session) = self.spacing.take() else {
return Err(Refusal::InvalidGesture);
};
session.update(screen);
if let Some(error) = session.refusal {
return Err(error);
}
if !session.ever_crossed {
return Ok(Finish::Click(session.drag.target.id()));
}
Ok(match session.preview {
None => Finish::Noop,
Some(preview) => Finish::Commit {
command: Box::new(preview.command),
selected: session.drag.target.id(),
scale: session.scale,
pan: session.pan,
},
})
}
pub(crate) fn accept_spacing_camera(&mut self, scale: f32, pan: Vector) {
self.presentation.zoom = Some(scale);
self.presentation.pan = pan;
self.presentation.fit = false;
}
}
@@ -1,421 +0,0 @@
use super::*;
use crate::document::Document;
use iced::Size;
use oa_core::{Diagram, Layer, ProjectionKind, language, source_project::ProjectLimits};
pub(crate) fn fixture() -> (Document, Id, Id, Id, Id) {
let mut p = language::parse(r#"arcadia 7;
project spacing "Spacing" {
oa_activity work "Work" {}
oa_activity response "Response" {}
oa_scenario incident "Incident" { viewpoint OperationalActivity; steps [@first, @second]; }
oa_lifeline reporter "Reporter" { parent @incident; represented @work; }
oa_lifeline operator "Operator" { parent @incident; represented @response; }
oa_occurrence first "First" { parent @incident; body { Exchange { exchange none; sender @reporter; receiver @operator; message_kind Asynchronous; }; }; }
oa_occurrence second "Second" { parent @incident; body { Exchange { exchange none; sender @operator; receiver @reporter; message_kind Asynchronous; }; }; }
}"#).unwrap();
let id = |name: &str| {
p.identities()
.into_iter()
.find(|(_, alias)| *alias == name)
.unwrap()
.0
};
let (owner, lane, message) = (id("incident"), id("reporter"), id("first"));
let diagram = Id::new_v4();
p.diagrams.insert(
diagram,
Diagram {
id: diagram,
alias: "saved".into(),
name: "Saved".into(),
description: String::new(),
layer: Layer::Oa,
projection: ProjectionKind::Scenario { scenario: owner },
nodes: Default::default(),
routes: vec![],
view_options: Default::default(),
layout: None,
},
);
(
Document::from_project(p, ProjectLimits::default()).unwrap(),
owner,
diagram,
lane,
message,
)
}
pub(crate) fn expected(p: &Project, diagram: Id, target: Target, delta: f32) -> Project {
capture(p, diagram, target)
.unwrap()
.prepare(delta)
.unwrap()
.unwrap()
.project
}
pub(crate) fn packet(
p: &Project,
state: &State,
context: Context,
camera_epoch: u64,
target: Target,
) -> Event {
let geometry = resolve(p, context.representation, target.id()).unwrap();
let world = match target {
Target::Lane(id) => geometry
.scene
.boxes
.iter()
.find(|item| item.id == id && item.role == SceneHitRole::LifelineHeader)
.unwrap()
.rect
.center(),
Target::Message(id) => {
let line = geometry
.scene
.lines
.iter()
.find(|line| line.id == id)
.unwrap();
Point::new((line.points[0].x + line.points[1].x) / 2., line.points[0].y)
}
};
assert!(
geometry
.scene
.hit_item(world)
.is_some_and(|hit| hit.id == target.id())
);
let camera = state.presentation.camera(geometry.bounds);
let canvas_bounds = Rectangle::new(Point::new(17., 23.), state.presentation.viewport.unwrap());
Event::Begin {
stamp: Stamp {
context,
camera_epoch,
},
token: Id::new_v4(),
target,
press_screen: Point::new(
canvas_bounds.x + world.x * camera.scale + camera.pan.x,
canvas_bounds.y + world.y * camera.scale + camera.pan.y,
),
canvas_bounds,
scale: camera.scale,
pan: camera.pan,
}
}
fn setup(target_message: bool) -> (Document, State, Event) {
let (doc, owner, diagram, lane, message) = fixture();
let mut state = State::default();
state.enter_representation(Id::new_v4(), owner, Representation::Saved { diagram });
state.presentation.viewport = Some(Size::new(900., 600.));
let context = state
.context(
state.presentation.identity.unwrap().0,
owner,
doc.revision(),
4,
)
.unwrap();
let target = if target_message {
Target::Message(message)
} else {
Target::Lane(lane)
};
let event = packet(doc.project(), &state, context, 9, target);
(doc, state, event)
}
fn press(event: Event) -> Point {
let Event::Begin { press_screen, .. } = event else {
unreachable!()
};
press_screen
}
#[test]
fn real_preview_moves_only_active_axis_from_original_and_never_changes_document() {
for message in [false, true] {
let (doc, mut state, begin) = setup(message);
let original = doc.snapshot();
let camera = state.camera_boundary();
let start = press(begin);
state.begin_spacing(doc.project(), begin).unwrap();
let first = if message {
Vector::new(500., 30.)
} else {
Vector::new(30., 500.)
};
state.move_spacing(start + first);
let session = state.spacing.as_ref().unwrap();
let candidate = session.preview.as_ref().unwrap();
let delta = 30. / session.scale;
let actual_anchor = match session.drag.target {
Target::Lane(id) => candidate.geometry.scene.render_lanes[&id],
Target::Message(id) => {
candidate
.geometry
.scene
.lines
.iter()
.find(|line| line.id == id)
.unwrap()
.points[0]
.y
}
};
let wanted = (f64::from(session.drag.origin) + f64::from(delta)) as f32;
assert_eq!(actual_anchor.to_bits(), wanted.to_bits());
let expected = session.drag.prepare(delta).unwrap().unwrap();
assert_eq!(candidate.project, expected.project);
assert_eq!(candidate.geometry.scene.rows, expected.geometry.scene.rows);
assert_eq!(
candidate.geometry.scene.render_lanes,
expected.geometry.scene.render_lanes
);
state.move_spacing(start + first * 2.);
let session = state.spacing.as_ref().unwrap();
assert_eq!(
session.preview.as_ref().unwrap().project,
session.drag.prepare(delta * 2.).unwrap().unwrap().project
);
assert_eq!(doc.project(), &original.decoded.project);
assert_eq!(doc.compiled().bundle, original.bundle);
assert!(!doc.dirty() && !doc.can_undo() && !doc.can_redo());
assert!(camera == state.camera_boundary());
}
}
#[test]
fn click_threshold_is_active_axis_and_return_to_zero_is_not_a_click() {
let (doc, mut state, begin) = setup(false);
let start = press(begin);
state.begin_spacing(doc.project(), begin).unwrap();
state.move_spacing(start + Vector::new(2., 100.));
assert!(matches!(
state.finish_spacing(start).unwrap(),
Finish::Click(_)
));
state.begin_spacing(doc.project(), begin).unwrap();
state.move_spacing(start + Vector::new(4., 0.));
assert!(matches!(state.finish_spacing(start).unwrap(), Finish::Noop));
assert!(!doc.can_undo());
}
#[test]
fn invalid_move_discards_prior_candidate_and_finish_rechecks_final_pointer() {
let (doc, mut state, begin) = setup(false);
let start = press(begin);
state.begin_spacing(doc.project(), begin).unwrap();
state.move_spacing(start + Vector::new(20., 0.));
state.move_spacing(Point::new(f32::NAN, start.y));
let paint = state.spacing_paint(begin.stamp().context, 9).unwrap();
assert!(matches!(paint.refusal, Some(Refusal::InvalidDelta)));
assert_eq!(
paint.scene.render_lanes,
state
.spacing
.as_ref()
.unwrap()
.drag
.original_geometry
.scene
.render_lanes
);
assert!(matches!(
state.finish_spacing(Point::new(f32::NAN, start.y)),
Err(Refusal::InvalidDelta)
));
assert!(state.spacing_token().is_none());
}
#[test]
fn forged_camera_hit_role_and_retired_visit_cannot_capture() {
let (doc, mut state, begin) = setup(false);
let Event::Begin {
stamp,
token,
target,
press_screen,
canvas_bounds,
scale,
pan,
} = begin
else {
unreachable!()
};
assert!(matches!(
state.begin_spacing(
doc.project(),
Event::Begin {
stamp,
token,
target,
press_screen,
canvas_bounds,
scale: scale * 2.,
pan
}
),
Err(Refusal::CameraChanged)
));
assert!(matches!(
state.begin_spacing(
doc.project(),
Event::Begin {
stamp,
token,
target,
press_screen: canvas_bounds.center(),
canvas_bounds,
scale,
pan
}
),
Err(Refusal::TargetNotHit(_))
));
state.leave();
assert!(matches!(
state.begin_spacing(doc.project(), begin),
Err(Refusal::InvalidGesture)
));
}
#[test]
fn zoomed_out_message_tolerance_matches_actual_canvas_hit() {
let (doc, mut state, _) = setup(true);
state.presentation.fit = false;
state.presentation.zoom = Some(0.25);
state.presentation.pan = Vector::ZERO;
let context = state
.context(
state.presentation.identity.unwrap().0,
state.owner().unwrap(),
doc.revision(),
4,
)
.unwrap();
let message = doc
.project()
.identities()
.into_iter()
.find(|(_, alias)| *alias == "first")
.map(|(id, _)| id)
.unwrap();
let Event::Begin {
stamp,
token,
target,
press_screen,
canvas_bounds,
scale,
pan,
} = packet(doc.project(), &state, context, 9, Target::Message(message))
else {
unreachable!()
};
let event = Event::Begin {
stamp,
token,
target,
press_screen: press_screen + Vector::new(0., 3.),
canvas_bounds,
scale,
pan,
};
state.begin_spacing(doc.project(), event).unwrap();
assert_eq!(state.spacing_token(), Some(token));
}
#[test]
fn fractional_screen_packet_preserves_subpixel_motion_in_preview_and_finish() {
let (doc, owner, diagram, _, message) = fixture();
let original = doc.snapshot();
assert!(doc.project().diagrams[&diagram].layout.is_none());
let mut state = State::default();
let document = Id::new_v4();
state.enter_representation(document, owner, Representation::Saved { diagram });
state.presentation.viewport = Some(Size::new(900., 600.));
state.presentation.fit = false;
state.presentation.zoom = Some(0.5);
// 406 + 2^-15 puts the press just below the 512 screen-coordinate boundary.
state.presentation.pan = Vector::new(0., f32::from_bits(0x43cb_0001));
let camera = state.camera_boundary();
let context = state.context(document, owner, doc.revision(), 4).unwrap();
let baseline = resolve(doc.project(), context.representation, message).unwrap();
let line = baseline
.scene
.lines
.iter()
.find(|line| line.id == message)
.unwrap();
assert_eq!(line.points[0].y, 148.);
let begin = packet(doc.project(), &state, context, 9, Target::Message(message));
let Event::Begin {
press_screen,
canvas_bounds,
scale,
pan,
..
} = begin
else {
unreachable!()
};
assert_eq!(canvas_bounds.y, 23.);
assert_eq!(scale, 0.5);
assert_eq!(pan.y.to_bits(), 0x43cb_0001);
assert_eq!(press_screen.y.to_bits(), 0x43fb_8001); // 503 + 2^-15.
let end = press_screen + Vector::new(0., 10.);
assert_eq!(end.y, 513.);
assert!(canvas_bounds.contains(press_screen) && canvas_bounds.contains(end));
// The submitted f32 displacement is 10 - 2^-15, not exactly 10.
assert_eq!((end.y - press_screen.y).to_bits(), 0x411f_ffe0);
assert_eq!(((end.y - press_screen.y) / scale).to_bits(), 0x419f_ffe0);
// Independent oracle: 148 + (10 - 2^-15) / .5 = 168 - 2^-14.
let expected_y = f32::from_bits(0x4327_fffc);
let mut expected = original.decoded.project.clone();
expected.diagrams.get_mut(&diagram).unwrap().layout =
Some(oa_core::DiagramLayout::Scenario(oa_core::ScenarioLayout {
lanes: vec![],
messages: vec![oa_core::ScenarioMessagePosition {
address: oa_core::OccurrenceEndpoint {
references: vec![],
occurrence: message,
},
send_y: expected_y,
}],
}));
state.begin_spacing(doc.project(), begin).unwrap();
state.move_spacing(end);
let preview = state.spacing.as_ref().unwrap().preview.as_ref().unwrap();
assert_eq!(preview.project, expected);
let line = preview
.geometry
.scene
.lines
.iter()
.find(|line| line.id == message)
.unwrap();
assert_eq!(line.points[0].y.to_bits(), expected_y.to_bits());
let Finish::Commit {
command,
selected,
scale,
pan,
} = state.finish_spacing(end).unwrap()
else {
panic!("fractional motion must remain a commit")
};
assert_eq!(selected, message);
assert_eq!(scale, 0.5);
assert_eq!(pan.y.to_bits(), 0x43cb_0001);
let committed = oa_core::execute_batch(doc.project(), &[*command]).unwrap();
assert_eq!(committed, expected);
assert!(state.spacing_token().is_none());
assert_eq!(doc.project(), &original.decoded.project);
assert_eq!(doc.compiled().bundle, original.bundle);
assert!(!doc.dirty() && !doc.can_undo() && !doc.can_redo());
assert!(camera == state.camera_boundary());
}
@@ -1,304 +0,0 @@
//! Diagnostic only: production capture/prepare, excluding fixture construction,
//! oracle work, App source writes, widget rebuilds, text shaping and GPU paint.
use super::*;
use crate::document::Document;
use oa_core::{
DiagramLayout, Layer, MessageKind, RelationKind, ScenarioLayout, source_project::ProjectLimits,
};
use serde_json::{Value, json};
use std::{collections::BTreeSet, sync::Arc, time::Instant};
const MESSAGE_COUNT: usize = 1000;
const TARGET_INDEX: usize = 500;
fn large_fixture() -> (Document, Id, [Id; 3], Vec<Id>, Vec<Id>) {
let (seed, owner, a, reporter, first) = super::tests::fixture();
let mut p = seed.project().clone();
let old_steps = p.elements[&owner].steps().unwrap().clone();
let template = p.elements[&first].clone();
let work = |alias: &str| {
p.identities()
.into_iter()
.find(|(_, name)| *name == alias)
.unwrap()
.0
};
let (source, target) = (work("work"), work("response"));
let operator = work("operator");
let relation = p.relate(
RelationKind::Interaction,
"Repeated incident report",
source,
target,
);
let mut lanes = vec![];
for index in 0..20 {
let original = if index < 10 { reporter } else { operator };
let mut lane = p.elements[&original].clone();
lane.id = Id::new_v4();
lane.alias = format!("timing_lane_{index:02}");
lane.name = format!("Review participant {index:02}");
lanes.push(lane.id);
p.elements.insert(lane.id, lane);
}
p.elements.remove(&reporter);
p.elements.remove(&operator);
for id in old_steps {
p.elements.remove(&id);
}
let mut messages = vec![];
for index in 0..MESSAGE_COUNT {
let mut occurrence = template.clone();
occurrence.id = Id::new_v4();
occurrence.alias = format!("timing_message_{index:04}");
occurrence.name = format!("Validate incident report {index:04}");
*occurrence.shared_mut().unwrap() = SharedBody::Occurrence {
body: OccurrenceBody::Exchange {
exchange: Some(relation),
sender: Some(lanes[index % 10]),
receiver: Some(lanes[10 + index % 10]),
message_kind: MessageKind::Asynchronous,
operation: None,
reply_to: None,
values: vec![],
},
precondition: String::new(),
postcondition: String::new(),
};
messages.push(occurrence.id);
p.elements.insert(occurrence.id, occurrence);
}
*p.elements.get_mut(&owner).unwrap().steps_mut().unwrap() = messages.clone();
p.diagrams.get_mut(&a).unwrap().layout = Some(DiagramLayout::Scenario(ScenarioLayout {
lanes: lanes
.iter()
.enumerate()
.map(|(i, &lifeline)| ScenarioLanePosition {
lifeline,
x: 160. + i as f32 * 240.,
})
.collect(),
messages: vec![],
}));
let mut views = [a, Id::nil(), Id::nil()];
for (index, x, offset) in [(1, -500., 12.), (2, 6500., -12.)] {
let mut view = p.diagrams[&a].clone();
view.id = Id::new_v4();
view.alias = format!("timing_saved_{index}");
view.name = format!("Review arrangement {index}");
view.layout = Some(DiagramLayout::Scenario(ScenarioLayout {
lanes: vec![ScenarioLanePosition {
lifeline: lanes[0],
x,
}],
messages: vec![ScenarioMessagePosition {
address: OccurrenceEndpoint {
references: vec![],
occurrence: messages[TARGET_INDEX],
},
send_y: 148. + TARGET_INDEX as f32 * 92. + offset,
}],
}));
views[index] = view.id;
p.diagrams.insert(view.id, view);
}
let document = Document::from_project(p, ProjectLimits::default()).unwrap();
assert_eq!(
document
.project()
.children(owner)
.filter(|e| e.kind() == Kind::Lifeline)
.count(),
20
);
assert_eq!(document.project().elements[&owner].layer(), Layer::Oa);
assert_eq!(document.project().diagrams.len(), 3);
(document, owner, views, lanes, messages)
}
fn assert_scene(
scene: &Scene,
lanes: &[Id],
messages: &[Id],
moved_message: Option<(Id, f32)>,
moved_lane: Option<(Id, f32)>,
) {
assert_eq!(scene.render_lanes.len(), 20);
assert_eq!(
scene
.boxes
.iter()
.filter(|b| b.role == SceneHitRole::LifelineHeader)
.count(),
20
);
assert_eq!(scene.lines.len(), MESSAGE_COUNT);
assert_eq!(
scene
.lines
.iter()
.map(|line| line.id)
.collect::<BTreeSet<_>>(),
messages.iter().copied().collect::<BTreeSet<_>>()
);
assert!(scene.activations.is_empty() && scene.open_calls.is_empty());
let x = |index: usize| {
160. + index as f32 * 240.
+ moved_lane
.filter(|(id, _)| *id == lanes[index])
.map_or(0., |(_, delta)| delta)
};
for (index, &lane) in lanes.iter().enumerate() {
assert_eq!(scene.render_lanes[&lane], x(index));
}
for (index, (line, &id)) in scene.lines.iter().zip(messages).enumerate() {
let y = 148.
+ index as f32 * 92.
+ moved_message
.filter(|(target, _)| *target == id)
.map_or(0., |(_, delta)| delta);
assert_eq!(line.id, id);
assert_eq!(
line.points,
vec![
Point::new(x(index % 10), y),
Point::new(x(10 + index % 10), y)
]
);
}
}
fn summary(samples: &[Value], phase: &str) -> Value {
let mut times: Vec<u64> = samples
.iter()
.filter(|sample| sample["phase"] == phase)
.map(|sample| sample["elapsed_ns"].as_u64().unwrap())
.collect();
times.sort_unstable();
let n = times.len();
let median = if n.is_multiple_of(2) {
((u128::from(times[n / 2 - 1]) + u128::from(times[n / 2])) / 2) as u64
} else {
times[n / 2]
};
json!({"samples": n, "min_ns": times[0], "median_ns": median, "max_ns": times[n-1]})
}
#[test]
#[ignore = "Diagnostic timing only; run alone with --exact --ignored --nocapture after correctness gates; no latency acceptance threshold"]
fn saved_spacing_capture_prepare_20_lanes_1000_messages_3_views() {
let (document, owner, views, lanes, messages) = large_fixture();
let original = document.snapshot();
let target = messages[TARGET_INDEX];
let mut samples = vec![];
let mut retained = None;
for iteration in 0..4 {
let start = Instant::now();
let result = capture(document.project(), views[0], Target::Message(target));
let elapsed_ns = u64::try_from(start.elapsed().as_nanos()).unwrap();
let drag = result.expect("complete asynchronous fixture must resolve");
samples.push(json!({"phase":"capture_message", "iteration":iteration,
"first_in_probe":iteration==0, "elapsed_ns":elapsed_ns, "outcome":"captured"}));
assert_scene(&drag.original_geometry.scene, &lanes, &messages, None, None);
if iteration == 0 {
retained = Some(drag);
}
}
let drag = retained.unwrap();
// Every sample is relative to the original press, including duplicate,
// reverse and return-to-start samples. Oracle work is outside each timer.
let deltas = [
4., 8., 12., 16., 20., 24., 28., 32., 32., 28., 24., 20., 16., 12., 8., 4., -4., -8., -4.,
0.,
];
for (iteration, delta) in deltas.into_iter().enumerate() {
let start = Instant::now();
let result = drag.prepare(delta);
let elapsed_ns = u64::try_from(start.elapsed().as_nanos()).unwrap();
let candidate = result.expect("these isolated asynchronous offsets are feasible");
samples.push(json!({"phase":"prepare_message", "iteration":iteration,
"delta_world":delta, "elapsed_ns":elapsed_ns, "outcome":if candidate.is_some(){"preview"}else{"noop"}}));
if delta == 0. {
assert!(candidate.is_none());
} else {
let candidate = candidate.unwrap();
let mut expected = original.decoded.project.clone();
let Some(DiagramLayout::Scenario(layout)) =
&mut expected.diagrams.get_mut(&views[0]).unwrap().layout
else {
panic!()
};
layout.messages.push(ScenarioMessagePosition {
address: OccurrenceEndpoint {
references: vec![],
occurrence: target,
},
send_y: 148. + TARGET_INDEX as f32 * 92. + delta,
});
assert_eq!(candidate.project, expected);
assert_scene(
&candidate.geometry.scene,
&lanes,
&messages,
Some((target, delta)),
None,
);
}
assert_eq!(
document.project().elements[&owner].steps().unwrap(),
&messages
);
assert_eq!(document.project(), &original.decoded.project);
assert!(Arc::ptr_eq(&original, &document.snapshot()));
}
let lane = lanes[10];
let start = Instant::now();
let result = capture(document.project(), views[0], Target::Lane(lane));
let elapsed_ns = u64::try_from(start.elapsed().as_nanos()).unwrap();
let drag = result.unwrap();
samples.push(json!({"phase":"capture_lane", "iteration":0, "elapsed_ns":elapsed_ns, "outcome":"captured"}));
for (iteration, delta) in [20., 40., -20.].into_iter().enumerate() {
let start = Instant::now();
let result = drag.prepare(delta);
let elapsed_ns = u64::try_from(start.elapsed().as_nanos()).unwrap();
let candidate = result.unwrap().unwrap();
samples.push(json!({"phase":"prepare_lane", "iteration":iteration,
"delta_world":delta, "elapsed_ns":elapsed_ns, "outcome":"preview"}));
let mut expected = original.decoded.project.clone();
let Some(DiagramLayout::Scenario(layout)) =
&mut expected.diagrams.get_mut(&views[0]).unwrap().layout
else {
panic!()
};
layout.lanes[10].x = 2560. + delta;
assert_eq!(candidate.project, expected);
assert_scene(
&candidate.geometry.scene,
&lanes,
&messages,
None,
Some((lane, delta)),
);
}
assert_eq!(document.project(), &original.decoded.project);
assert_eq!(document.bundle(), &original.bundle);
for view in views {
assert_eq!(
document.project().diagrams[&view],
original.decoded.project.diagrams[&view]
);
}
assert_eq!(document.revision(), 0);
assert!(!document.dirty() && !document.can_undo() && !document.can_redo());
let report = json!({
"schema":"scenario_spacing_timing_v1",
"fixture":{"lanes":20,"messages":MESSAGE_COUNT,"saved_views":3,"target_index":TARGET_INDEX,"distinct_message_ids":messages.iter().copied().collect::<BTreeSet<_>>().len()},
"profile_context":{"debug_assertions":cfg!(debug_assertions),"os":std::env::consts::OS,"arch":std::env::consts::ARCH,
"source_pin_from_runner":std::env::var("SCENARIO_SPACING_SOURCE_PIN").ok(),"source_binary_toolchain_binding":"external gate receipt required"},
"summaries":{"capture_message":summary(&samples,"capture_message"),"prepare_message":summary(&samples,"prepare_message"),"capture_lane":summary(&samples,"capture_lane"),"prepare_lane":summary(&samples,"prepare_lane")},
"samples":samples,
"invariant_status":"passed",
"limits":"First capture is first in this probe, not cold OS caches. Timers exclude construction/oracles, prior-preview disposal, source write/history, widget rebuild, label shaping, GPU paint and queued input; no responsiveness acceptance or latency threshold. All messages asynchronous; no nested frame/lifecycle stress."
});
println!("SPACING_TIMING_JSON={report}");
}
@@ -1,522 +0,0 @@
use super::*;
use crate::behavior::scenario_layout::Diagnostic;
use crate::document::Document;
use oa_core::{
Diagram, DiagramLayout, ProjectionKind, ScenarioLayout, language, source_project::ProjectLimits,
};
fn id(project: &Project, alias: &str) -> Id {
project
.identities()
.into_iter()
.find(|(_, name)| *name == alias)
.unwrap()
.0
}
fn fixture(layout: Option<ScenarioLayout>) -> (Document, Id, Id) {
let mut project = language::parse(FIXTURE).unwrap();
let owner = id(&project, "conversation");
let mut add = |alias: &str, layout: Option<ScenarioLayout>| {
let diagram = Id::new_v4();
project.diagrams.insert(
diagram,
Diagram {
id: diagram,
alias: alias.into(),
name: alias.into(),
description: String::new(),
layer: oa_core::Layer::Oa,
projection: ProjectionKind::Scenario { scenario: owner },
nodes: Default::default(),
routes: vec![],
view_options: Default::default(),
layout: layout.map(DiagramLayout::Scenario),
},
);
diagram
};
let a = add("saved_a", layout);
let b = add("saved_b", Some(ScenarioLayout::default()));
let document = Document::from_project(project, ProjectLimits::default()).unwrap();
(document, a, b)
}
fn resolved(project: &Project, diagram: Id, selected: Id) -> ResolvedScenarioPlan {
resolve(project, Representation::Saved { diagram }, selected).unwrap()
}
fn same_geometry(a: &ResolvedScenarioPlan, b: &ResolvedScenarioPlan) {
assert_eq!(a.bounds, b.bounds);
assert_eq!(a.lanes, b.lanes);
assert_eq!(a.plan.frames, b.plan.frames);
assert_eq!(a.plan.scopes, b.plan.scopes);
let (a, b) = (&a.scene, &b.scene);
assert_eq!(a.rows, b.rows);
assert_eq!(a.sequence_bounds, b.sequence_bounds);
assert_eq!(a.size, b.size);
assert_eq!(a.layout_origin, b.layout_origin);
assert_eq!(a.lanes, b.lanes);
assert_eq!(a.render_lanes, b.render_lanes);
assert_eq!(a.activations, b.activations);
assert_eq!(a.lifecycle, b.lifecycle);
assert_eq!(a.open_calls, b.open_calls);
let slots = |scene: &crate::behavior::Scene| {
scene
.slots
.iter()
.map(|item| {
(
item.slot.parent,
item.slot.before,
item.y,
item.label.clone(),
)
})
.collect::<Vec<_>>()
};
assert_eq!(slots(a), slots(b));
let lines = |scene: &crate::behavior::Scene| {
scene
.lines
.iter()
.map(|line| {
(
line.id,
line.points.clone(),
line.label_at,
line.label.clone(),
line.dashed,
)
})
.collect::<Vec<_>>()
};
let boxes = |scene: &crate::behavior::Scene| {
scene
.boxes
.iter()
.map(|item| {
(
item.id,
item.rect,
item.title.clone(),
item.detail.clone(),
item.marker,
)
})
.collect::<Vec<_>>()
};
assert_eq!(lines(a), lines(b));
assert_eq!(boxes(a), boxes(b));
}
fn expected_message(project: &Project, diagram: Id, occurrence: Id, y: f32) -> Project {
// Independent expected mutation: tests begin with no message overrides.
let mut expected = project.clone();
expected.diagrams.get_mut(&diagram).unwrap().layout =
Some(DiagramLayout::Scenario(ScenarioLayout {
lanes: vec![],
messages: vec![ScenarioMessagePosition {
address: OccurrenceEndpoint {
references: vec![],
occurrence,
},
send_y: y,
}],
}));
expected
}
#[test]
fn one_repeated_message_moves_in_one_view_with_exact_document_undo_redo_and_reopen() {
let (mut document, a, b) = fixture(None);
let original = document.snapshot();
let before = document.project().clone();
let first = id(&before, "first");
let second = id(&before, "second");
let baseline = resolved(&before, a, second);
assert_eq!(baseline.scene.rows[&first].1, 148.);
assert_eq!(baseline.scene.rows[&second].1, 240.);
let untouched = resolved(&before, b, second);
let drag = capture(&before, a, Target::Message(second)).unwrap();
let preview = drag.prepare(-40.).unwrap().unwrap();
let expected = expected_message(&before, a, second, 200.);
assert_eq!(preview.project, expected);
assert_eq!(preview.geometry.scene.rows[&first].1, 148.);
assert_eq!(preview.geometry.scene.rows[&second].1, 200.);
let line = preview
.geometry
.scene
.lines
.iter()
.find(|line| line.id == second)
.unwrap();
assert_eq!(line.points.first().unwrap().y, 200.);
assert_eq!(line.points.last().unwrap().y, 200.);
assert_eq!(
preview.geometry.scene.activations,
baseline.scene.activations
);
assert_eq!(preview.geometry.scene.lifecycle, baseline.scene.lifecycle);
same_geometry(&resolved(&preview.project, b, second), &untouched);
assert_eq!(drag.original, before);
assert!(std::sync::Arc::ptr_eq(&original, &document.snapshot()));
assert!(!document.can_undo());
let returned_selection = document.execute(preview.command).unwrap();
assert_eq!(
returned_selection,
Some(a),
"controller must preserve the dragged semantic selection separately"
);
assert_eq!(document.project(), &expected);
assert_eq!(document.revision(), 1);
let accepted = document.bundle().clone();
same_geometry(&resolved(document.project(), a, second), &preview.geometry);
assert!(document.undo());
assert_eq!(document.project(), &before);
assert_eq!(document.bundle(), &original.bundle);
assert!(!document.can_undo());
assert!(document.redo());
assert_eq!(document.project(), &expected);
assert_eq!(document.bundle(), &accepted);
let reopened = Document::from_bundle(accepted, ProjectLimits::default()).unwrap();
assert_eq!(reopened.project(), &expected);
same_geometry(&resolved(reopened.project(), a, second), &preview.geometry);
assert!(!reopened.dirty() && !reopened.can_undo());
}
#[test]
fn horizontal_lane_move_changes_actual_header_and_attached_endpoints_only_in_selected_view() {
let (document, a, b) = fixture(None);
let before = document.project();
let right = id(before, "right");
let first = id(before, "first");
let other = resolved(before, b, right);
let preview = capture(before, a, Target::Lane(right))
.unwrap()
.prepare(100.)
.unwrap()
.unwrap();
let mut expected = before.clone();
expected.diagrams.get_mut(&a).unwrap().layout = Some(DiagramLayout::Scenario(ScenarioLayout {
lanes: vec![ScenarioLanePosition {
lifeline: right,
x: 500.,
}],
messages: vec![],
}));
assert_eq!(preview.project, expected);
assert_eq!(preview.geometry.scene.lanes[&right], 500.);
let header = preview
.geometry
.scene
.boxes
.iter()
.find(|item| item.id == right)
.unwrap();
assert_eq!(header.rect.center_x(), 500.);
let line = preview
.geometry
.scene
.lines
.iter()
.find(|line| line.id == first)
.unwrap();
assert_eq!(line.points.first().unwrap().x, 160.);
assert_eq!(line.points.last().unwrap().x, 500.);
assert!(line.points.iter().all(|point| point.y == 148.));
same_geometry(&resolved(&preview.project, b, right), &other);
assert_eq!(document.project(), before);
assert!(!document.dirty() && !document.can_undo());
}
#[test]
fn drawn_unassigned_lifeline_can_move_without_assigning_a_semantic_participant() {
let (document, a, _) = fixture(None);
let mut project = document.project().clone();
let owner = id(&project, "conversation");
let lane = project
.add_element(
Kind::Lifeline,
oa_core::Layer::Oa,
"Unassigned header",
Some(owner),
)
.unwrap();
let document = Document::from_project(project, ProjectLimits::default()).unwrap();
let baseline = resolved(document.project(), a, lane);
assert!(!baseline.scene.lanes.contains_key(&lane));
let before_header = baseline
.scene
.boxes
.iter()
.find(|item| item.id == lane)
.unwrap()
.rect;
let preview = capture(document.project(), a, Target::Lane(lane))
.unwrap()
.prepare(70.)
.unwrap()
.unwrap();
let after_header = preview
.geometry
.scene
.boxes
.iter()
.find(|item| item.id == lane)
.unwrap()
.rect;
assert_eq!(after_header.x, before_header.x + 70.);
assert_eq!(after_header.y, before_header.y);
assert_eq!(after_header.size(), before_header.size());
assert_eq!(preview.project.elements, document.project().elements);
assert_eq!(preview.project.relations, document.project().relations);
assert!(!document.can_undo());
}
#[test]
fn self_call_spacing_uses_send_y_and_preserves_reply_and_actual_activation_finish() {
let (document, a, _) = fixture(None);
let before = document.project();
let call = id(before, "call");
let reply = id(before, "reply");
let baseline = resolved(before, a, call);
let preview = capture(before, a, Target::Message(call))
.unwrap()
.prepare(-22.)
.unwrap()
.unwrap();
assert_eq!(preview.project, expected_message(before, a, call, 310.));
let line = preview
.geometry
.scene
.lines
.iter()
.find(|line| line.id == call)
.unwrap();
assert_eq!(line.points.first().unwrap().y, 310.);
assert_eq!(line.points.last().unwrap().y, 334.);
assert_eq!(preview.geometry.scene.rows[&reply].1, 424.);
assert_eq!(preview.geometry.scene.activations.len(), 1);
let mut expected_activation = baseline.scene.activations.clone();
expected_activation[0].rect.y = 334.;
expected_activation[0].rect.height = 90.;
assert_eq!(preview.geometry.scene.activations, expected_activation);
assert_eq!(preview.geometry.scene.lifecycle, baseline.scene.lifecycle);
}
#[test]
fn feasible_spacing_crossing_remains_authored_with_diagnostic_and_unchanged_steps() {
let (document, a, _) = fixture(None);
let before = document.project();
let first = id(before, "first");
let second = id(before, "second");
let owner = id(before, "conversation");
let preview = capture(before, a, Target::Message(second))
.unwrap()
.prepare(-120.)
.unwrap()
.unwrap();
assert_eq!(preview.project, expected_message(before, a, second, 120.));
assert_eq!(preview.geometry.scene.rows[&second].1, 120.);
assert_eq!(preview.geometry.scene.rows[&first].1, 148.);
assert_eq!(
preview.project.elements[&owner].steps(),
before.elements[&owner].steps()
);
let shown = crate::behavior::scenario_layout::presentation::present(
&preview.project,
Representation::Saved { diagram: a },
second,
);
assert!(shown.capabilities().coordinate_input);
assert!(
shown
.diagnostics()
.contains(&Diagnostic::SourceOrderCrossing {
scope: owner,
before: first,
after: second
})
);
}
#[test]
fn unsupported_self_call_move_is_refused_and_automatic_fallback_is_not_editable_capture() {
let (document, a, _) = fixture(None);
let before = document.project();
let call = id(before, "call");
let drag = capture(before, a, Target::Message(call)).unwrap();
let Err(Refusal::Geometry(issues)) = drag.prepare(118.) else {
panic!("send450 / receive474 after reply424 must be refused")
};
assert!(issues.iter().any(|issue| matches!(issue, GeometryIssue::TypedActivationCrossingNotSupported { call: id, .. } if *id == call)));
assert_eq!(&drag.original, before);
let invalid_geometry = expected_message(before, a, call, 450.);
let shown = crate::behavior::scenario_layout::presentation::present(
&invalid_geometry,
Representation::Saved { diagram: a },
call,
);
assert!(shown.capabilities().geometry_navigation && !shown.capabilities().coordinate_input);
assert!(matches!(
capture(&invalid_geometry, a, Target::Message(call)),
Err(Refusal::Geometry(_))
));
assert!(!document.dirty() && !document.can_undo());
}
#[test]
fn invalid_view_foreign_identity_and_latent_message_are_not_spacing_targets() {
let (document, a, b) = fixture(None);
let p = document.project();
let unfinished = id(p, "unfinished");
let source = id(p, "source");
assert!(matches!(
capture(p, Id::nil(), Target::Lane(source)),
Err(Refusal::InvalidSavedView(_))
));
for target in [
Target::Lane(source),
Target::Lane(b),
Target::Message(source),
Target::Message(unfinished),
] {
assert!(
matches!(capture(p, a, target), Err(Refusal::TargetNotRendered(found)) if found == target)
);
}
// A stored latent coordinate is not permission to fabricate a painted arrow.
let mut latent = expected_message(p, a, unfinished, 2000.);
let foreign_owner = latent
.add_element(Kind::Scenario, oa_core::Layer::Oa, "Foreign", None)
.unwrap();
let foreign_lane = latent
.add_element(
Kind::Lifeline,
oa_core::Layer::Oa,
"Foreign lane",
Some(foreign_owner),
)
.unwrap();
*latent
.elements
.get_mut(&foreign_lane)
.unwrap()
.shared_mut()
.unwrap() = SharedBody::Lifeline {
represented: Some(source),
};
let latent_document = Document::from_project(latent, ProjectLimits::default()).unwrap();
assert!(matches!(
capture(latent_document.project(), a, Target::Message(unfinished)),
Err(Refusal::TargetNotRendered(_))
));
assert!(matches!(
capture(latent_document.project(), a, Target::Lane(foreign_lane)),
Err(Refusal::TargetNotRendered(_))
));
}
#[test]
fn zero_motion_return_to_start_and_subprecision_preserve_sparse_intent_and_real_redo() {
let parsed = language::parse(FIXTURE).unwrap();
let left = id(&parsed, "left");
let second = id(&parsed, "second");
let layouts = [
None,
Some(ScenarioLayout::default()),
Some(ScenarioLayout {
lanes: vec![],
messages: vec![ScenarioMessagePosition {
address: OccurrenceEndpoint {
references: vec![],
occurrence: second,
},
send_y: 240.,
}],
}),
Some(ScenarioLayout {
lanes: vec![ScenarioLanePosition {
lifeline: left,
x: -0.,
}],
messages: vec![],
}),
];
for (index, layout) in layouts.into_iter().enumerate() {
let (mut document, a, _) = fixture(layout);
let owner = id(document.project(), "conversation");
document
.execute(Command::Rename(owner, "A real redo entry".into()))
.unwrap();
let redo = document.snapshot();
assert!(document.undo());
let before = document.snapshot();
let revision = document.revision();
let target = if index == 3 {
Target::Lane(left)
} else {
Target::Message(second)
};
let drag = capture(document.project(), a, target).unwrap();
if index == 3 {
let Some(DiagramLayout::Scenario(layout)) = &drag.original.diagrams[&a].layout else {
panic!()
};
assert_eq!(layout.lanes[0].x.to_bits(), (-0_f32).to_bits());
}
assert!(drag.prepare(10.).unwrap().is_some());
for delta in [0., -0.] {
assert!(drag.prepare(delta).unwrap().is_none());
}
if index != 3 {
assert!(drag.prepare(f32::from_bits(1)).unwrap().is_none());
}
assert_eq!(
serde_json::to_vec(&drag.original).unwrap(),
serde_json::to_vec(document.project()).unwrap()
);
assert!(std::sync::Arc::ptr_eq(&before, &document.snapshot()));
assert_eq!(document.bundle(), &before.bundle);
assert_eq!(document.revision(), revision);
assert!(document.can_redo());
assert!(document.redo());
assert_eq!(document.project(), &redo.decoded.project);
assert_eq!(document.bundle(), &redo.bundle);
}
}
#[test]
fn malformed_or_out_of_range_motion_cannot_mutate_original() {
let (document, a, _) = fixture(None);
let drag = capture(
document.project(),
a,
Target::Message(id(document.project(), "second")),
)
.unwrap();
for delta in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] {
assert!(matches!(drag.prepare(delta), Err(Refusal::InvalidDelta)));
}
assert!(matches!(drag.prepare(f32::MAX), Err(Refusal::Command(_))));
assert_eq!(&drag.original, document.project());
assert!(!document.dirty() && !document.can_undo());
}
const FIXTURE: &str = r#"arcadia 7;
project spacing_probe "Saved spacing probe" id "71000000-0000-4000-8000-000000000001" {
oa_activity source "Source" id "71000000-0000-4000-8000-000000000002" {}
oa_activity target "Target" id "71000000-0000-4000-8000-000000000003" {}
interaction exchange "Repeated exchange" id "71000000-0000-4000-8000-000000000004" { source @source; target @target; }
oa_scenario conversation "Conversation" id "71000000-0000-4000-8000-000000000005" { viewpoint OperationalActivity; steps [@first, @second, @call, @reply, @unfinished]; }
oa_lifeline left "Left" id "71000000-0000-4000-8000-000000000006" { parent @conversation; represented @source; }
oa_lifeline right "Right" id "71000000-0000-4000-8000-000000000007" { parent @conversation; represented @target; }
oa_occurrence first "First" id "71000000-0000-4000-8000-000000000008" { parent @conversation; body { Exchange { exchange @exchange; sender @left; receiver @right; message_kind Asynchronous; }; }; }
oa_occurrence second "Second" id "71000000-0000-4000-8000-000000000009" { parent @conversation; body { Exchange { exchange @exchange; sender @left; receiver @right; message_kind Asynchronous; }; }; }
oa_occurrence call "Call" id "71000000-0000-4000-8000-00000000000a" { parent @conversation; body { Exchange { exchange none; sender @right; receiver @right; message_kind Call; }; }; }
oa_occurrence reply "Reply" id "71000000-0000-4000-8000-00000000000b" { parent @conversation; body { Exchange { exchange none; sender @right; receiver @right; message_kind Reply; reply_to @call; }; }; }
oa_occurrence unfinished "Unfinished" id "71000000-0000-4000-8000-00000000000c" { parent @conversation; body { Exchange { exchange @exchange; sender @left; receiver none; message_kind Asynchronous; }; }; }
}
"#;
@@ -1,6 +1,5 @@
//! Navigation is derived from the same opaque-reference scene that is painted.
use super::*;
use crate::behavior::scenario_layout::{Representation, presentation};
use crate::diagram_usage::{Surface, Target, Usage};
/// The renderer collapses whitespace before shortening. Require at least one
@@ -23,14 +22,7 @@ fn label_field_visible(full: &str, prefix: &str, value: &str) -> bool {
start < retained
}
fn usages(
p: &Project,
owner: Id,
subject: Id,
surface: Surface,
bounds: Rectangle,
scene: &Scene,
) -> Vec<Usage> {
fn usages(p: &Project, owner: Id, subject: Id, scene: &Scene) -> Vec<Usage> {
let mut result = Vec::new();
let mut add = |id, bounds, role: &str| {
// Activation bars share the occurrence identity with a labelled message;
@@ -41,7 +33,7 @@ fn usages(
{
result.push(Usage {
subject,
surface,
surface: Surface::Scenario(owner),
target: Target::ScenarioObject(id),
role: role.into(),
bounds,
@@ -145,15 +137,15 @@ fn usages(
}
}
}
if subject == owner || subject == surface.id() {
if subject == owner {
result.insert(
0,
Usage {
subject,
surface,
surface: Surface::Scenario(owner),
target: Target::Whole,
role: "scenario".into(),
bounds,
bounds: Rectangle::new(Point::ORIGIN, scene.size),
},
);
}
@@ -161,29 +153,15 @@ fn usages(
}
impl State {
/// Membership comes from the complete presentation chosen for this exact view,
/// including automatic fallback geometry while retaining its Saved identity.
pub(crate) fn representation_usages(
&self,
p: &Project,
representation: Representation,
subject: Id,
) -> Vec<Usage> {
let shown = presentation::present(p, representation, subject);
let (Some(owner), Some(bounds), Some(scene)) =
(shown.owner(), shown.bounds(), shown.scene())
else {
return vec![];
};
let surface = match representation {
Representation::Derived { owner } => Surface::Scenario(owner),
Representation::Saved { diagram } => Surface::Saved(diagram),
};
usages(p, owner, subject, surface, bounds, scene)
}
pub(crate) fn diagram_usages(&self, p: &Project, owner: Id, subject: Id) -> Vec<Usage> {
self.representation_usages(p, Representation::Derived { owner }, subject)
if !p
.elements
.get(&owner)
.is_some_and(|e| e.kind() == Kind::Scenario)
{
return vec![];
}
usages(p, owner, subject, &Scene::sequence(p, owner, subject))
}
pub(crate) fn reveal_usage(
@@ -193,24 +171,12 @@ impl State {
owner: Id,
target: &Target,
) -> bool {
self.reveal_representation_usage(
p,
document,
owner,
Representation::Derived { owner },
target,
)
}
pub(crate) fn reveal_representation_usage(
&mut self,
p: &Project,
document: Id,
owner: Id,
representation: Representation,
target: &Target,
) -> bool {
if self.draft.is_some() || presentation::checked_owner(p, representation) != Some(owner) {
if self.draft.is_some()
|| !p
.elements
.get(&owner)
.is_some_and(|e| e.kind() == Kind::Scenario)
{
return false;
}
let id = match target {
@@ -219,7 +185,7 @@ impl State {
_ => return false,
};
let Some(usage) = self
.representation_usages(p, representation, id)
.diagram_usages(p, owner, id)
.into_iter()
.find(|u| &u.target == target)
else {
@@ -227,29 +193,11 @@ impl State {
};
// Preserve an existing mounted viewport across enter's owner reset.
let viewport = self.presentation.viewport.unwrap_or(Size::new(500., 300.));
if !viewport.width.is_finite()
|| !viewport.height.is_finite()
|| viewport.width <= 0.
|| viewport.height <= 0.
{
return false;
}
let (zoom, pan) = if matches!(target, Target::Whole) {
let Some(camera) = super::camera::fit(usage.bounds, viewport) else {
return false;
};
(None, camera.pan)
} else {
let (zoom, pan) = crate::diagram_usage::camera_for(target, usage.bounds, viewport);
if !zoom.is_finite() || !pan.x.is_finite() || !pan.y.is_finite() {
return false;
}
(Some(zoom), pan)
};
self.enter_representation(document, owner, representation);
self.enter(document, owner);
self.presentation.viewport = Some(viewport);
self.canvas_tool = Some((owner, CanvasTool::Select));
self.presentation.zoom(zoom);
let (zoom, pan) = crate::diagram_usage::camera_for(target, usage.bounds, viewport);
self.presentation.zoom(Some(zoom));
self.presentation.pan = pan;
true
}
@@ -13,14 +13,13 @@ pub enum Shelf {
#[derive(Clone, Default)]
pub(super) struct Presentation {
pub(super) identity: Option<(Id, Id)>,
pub(super) representation: Option<Representation>,
pub(super) visit: Id,
pub(super) viewport: Option<Size>,
pub(super) shelf: Option<Shelf>,
pub(super) pan: iced::Vector,
pub(super) zoom: Option<f32>,
pub(super) fit: bool,
}
const MIN_SCALE: f32 = 0.05;
impl Presentation {
pub(super) fn viewport(&mut self, size: Size) -> bool {
if !size.width.is_finite()
@@ -35,26 +34,29 @@ impl Presentation {
true
}
pub(super) fn zoom(&mut self, scale: Option<f32>) -> bool {
camera::set_zoom(&mut self.pan, &mut self.zoom, &mut self.fit, scale)
if scale.is_some_and(|value| !value.is_finite() || value <= 0.) {
return false;
}
let next = scale.map(|value| value.clamp(MIN_SCALE, 3.));
let changed = self.zoom != next || self.fit != scale.is_none();
self.zoom = next;
self.fit = scale.is_none();
changed
}
pub(super) fn camera(&self, bounds: Rectangle) -> camera::Camera {
camera::effective_camera(self.pan, self.zoom, self.fit, self.viewport, bounds)
fn fit_scale(&self, scene: Size) -> Option<f32> {
let viewport = self.viewport?;
// Fit uses the actual mounted viewport with a small visual gutter.
Some(
((viewport.width - 16.).max(1.) / scene.width.max(1.))
.min((viewport.height - 16.).max(1.) / scene.height.max(1.)),
)
}
// Legacy tests call size-only scale; actual display always passes full bounds.
#[cfg(test)]
pub(super) fn scale(&self, size: Size) -> f32 {
self.camera(Rectangle::new(Point::ORIGIN, size)).scale
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
struct ArrangementChoice {
representation: Representation,
label: String,
}
impl std::fmt::Display for ArrangementChoice {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.label)
pub(super) fn scale(&self, scene: Size) -> f32 {
if self.fit {
self.fit_scale(scene).unwrap_or(1.).clamp(MIN_SCALE, 1.)
} else {
self.zoom.unwrap_or(1.)
}
}
}
@@ -66,136 +68,43 @@ pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a
.draft
.as_ref()
.is_some_and(|draft| draft.owner == owner);
let Some(context) = app.scenario_context() else {
return column![
out,
text("Scenario view is unavailable."),
button("Open selected source").on_press(Message::SourceForSelection)
]
.spacing(8)
.into();
};
let context = app.scenario_context().expect("visible scenario context");
let tool = state.canvas_tool(owner);
let clean = !app.behavior.has_pending() && !app.draft_dirty() && !app.source_dirty();
// Resolve once per accepted view. An active spacing session owns its exact
// prepared geometry; borrowing it also retains the canvas input state.
let preview = state.spacing_paint(context, app.camera_epoch);
let shown = preview
.is_none()
.then(|| presentation::present(p, context.representation, app.selected));
let capabilities = shown.as_ref().map_or(
presentation::PresentationCapabilities {
geometry_navigation: true,
coordinate_input: true,
},
ScenarioPresentation::capabilities,
);
let coordinate_input = clean && capabilities.coordinate_input;
let notice = if let Some(paint) = &preview {
display_notice_parts(
p,
context.representation,
&presentation::PresentationStatus::Ready,
paint.original_diagnostics,
paint.original_latent,
)
} else {
display_notice(p, shown.as_ref().expect("normal presentation"))
};
let camera_state = preview
.as_ref()
.map(|paint| camera::Camera {
scale: paint.scene.scale,
pan: paint.scene.camera_pan,
})
.or_else(|| {
shown
.as_ref()
.and_then(ScenarioPresentation::bounds)
.map(|bounds| presentation.camera(bounds))
});
let scale = camera_state.map_or(1., |camera| camera.scale);
let mut scene = Scene::sequence(p, owner, app.selected);
let scale = presentation.scale(scene.size);
scene.scale = scale;
scene.camera_pan = presentation.pan;
scene.interaction = Some((context, tool, clean));
scene.pending_placement = state.pending_placement(owner);
let scope = super::super::canvas_workspace::Scope {
document: context.document,
owner,
revision: context.revision,
epoch: app.camera_epoch,
scenario_view: Some((context.representation, context.visit)),
};
let keyboard = clean
&& capabilities.geometry_navigation
&& super::super::canvas_workspace::keyboard_enabled(app);
let keyboard = clean && super::super::canvas_workspace::keyboard_enabled(app);
let camera = move |e| Message::ScenarioCanvas(CanvasEvent::Camera(context, scope.epoch, e));
let raw_canvas: Option<Element<'a, Message>> = if let Some(paint) = &preview {
Some(
canvas::Canvas::new(paint.scene)
.width(Fill)
.height(Fill)
.into(),
)
} else if let Some(mut scene) = shown.and_then(ScenarioPresentation::into_scene) {
let camera_state = camera_state.expect("displayed Scene has complete bounds");
scene.scale = camera_state.scale;
scene.camera_pan = camera_state.pan;
scene.interaction = Some((context, tool, clean));
scene.spacing_stamp = (coordinate_input
&& !app.source_rename.open()
&& keyboard
&& matches!(context.representation, Representation::Saved { .. }))
.then_some(spacing::Stamp {
context,
camera_epoch: app.camera_epoch,
});
scene.coordinate_input = capabilities.coordinate_input;
scene.pending_placement = coordinate_input
.then(|| state.pending_placement(owner))
.flatten();
Some(canvas::Canvas::new(scene).width(Fill).height(Fill).into())
} else {
None
};
let canvas: Element<'a, Message> = if let Some(raw_canvas) = raw_canvas {
// Keep this Stack in both branches: replacing the Canvas with a wrapper
// on Begin would discard its pointer capture before Move/Finish.
let mut layers = iced::widget::stack![raw_canvas];
if let Some(paint) = &preview {
let feedback = spacing_feedback(p, paint.refusal, paint.diagnostics);
layers = layers.push(
container(
container(text(feedback).size(12))
.padding(6)
.width(Fill)
.style(style::card),
)
.width(Fill)
.align_bottom(Fill)
.padding(8),
);
}
container(
sensor(
super::super::canvas_workspace::Viewport::new(
layers, scope, scale, keyboard, camera,
)
.pan_background(tool == CanvasTool::Select || !capabilities.coordinate_input),
let canvas = container(
sensor(
super::super::canvas_workspace::Viewport::new(
canvas::Canvas::new(scene).width(Fill).height(Fill),
scope,
scale,
keyboard,
camera,
)
.key((
context.document,
format!("{:?}", context.representation),
context.visit,
))
.on_show(move |size| Message::ScenarioCanvas(CanvasEvent::Viewport(context, size)))
.on_resize(move |size| Message::ScenarioCanvas(CanvasEvent::Viewport(context, size))),
.pan_background(tool == CanvasTool::Select),
)
.id("scenario-viewport")
.width(Fill)
.height(Fill)
.style(style::card)
.into()
} else {
container(text("No supported canvas geometry is available. Inspect the model or open its source to repair this view."))
.id("scenario-viewport-unavailable").padding(16).width(Fill).height(Fill).style(style::card).into()
};
.key((context.document, owner))
.on_show(move |size| Message::ScenarioCanvas(CanvasEvent::Viewport(context, size)))
.on_resize(move |size| Message::ScenarioCanvas(CanvasEvent::Viewport(context, size))),
)
.id("scenario-viewport")
.width(Fill)
.height(Fill)
.style(style::card)
.into();
let mut navigation = row![out.width(Fill)].spacing(6);
if state.can_back(p, context) {
navigation =
@@ -203,72 +112,20 @@ pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a
clean.then_some(Message::ScenarioCanvas(CanvasEvent::Back(context))),
));
}
let mut header = column![navigation, notice].spacing(4);
let saved_views = p
.diagrams
.values()
.filter(|diagram| {
presentation::checked_owner(
p,
Representation::Saved {
diagram: diagram.id,
},
) == Some(owner)
})
.map(|diagram| ArrangementChoice {
representation: Representation::Saved {
diagram: diagram.id,
},
label: short(&diagram.name, 64),
})
.collect::<Vec<_>>();
if !saved_views.is_empty() {
let mut choices = vec![ArrangementChoice {
representation: Representation::Derived { owner },
label: "Automatic arrangement".into(),
}];
choices.extend(saved_views);
let selected = choices
.iter()
.find(|choice| choice.representation == context.representation)
.cloned();
header = header.push(
container(
pick_list(choices, selected, move |choice: ArrangementChoice| {
Message::ScenarioCanvas(CanvasEvent::Representation(
context,
choice.representation,
))
})
.placeholder("Choose arrangement")
.text_size(12)
.padding(5)
.width(Fill),
)
.width(Fill)
.id("scenario-arrangement-picker"),
);
}
if capabilities.geometry_navigation {
header = header.push(super::super::canvas_workspace::controls(
let header = column![
navigation,
super::super::canvas_workspace::controls(
scope,
keyboard,
scale,
Message::ScenarioCanvas(CanvasEvent::Shelf(context, Shelf::Selection)),
move |zoom| Message::ScenarioCanvas(CanvasEvent::Zoom(context, zoom)),
camera,
));
}
camera
)
]
.spacing(4);
let entries = [
(
"S",
if matches!(context.representation, Representation::Saved { .. }) {
"Select / move messages and lifelines"
} else {
"Select / reorder occurrences"
},
CanvasTool::Select,
),
("S", "Select / reorder occurrences", CanvasTool::Select),
(
"L",
"Lifeline: click to choose represented work or participant",
@@ -335,21 +192,16 @@ pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a
glyph,
label,
choice == tool,
(clean && (choice == CanvasTool::Select || capabilities.coordinate_input))
.then_some(Message::ScenarioCanvas(CanvasEvent::Choose(
context, choice,
))),
clean.then_some(Message::ScenarioCanvas(CanvasEvent::Choose(
context, choice,
))),
)
}),
);
let mut details = column![
text(format!("Selected: {}", p.named(app.selected))).size(12),
container(button(text("Properties").size(12)).on_press_maybe(
(clean && properties_target(p, context, app.selected)).then_some(
Message::ScenarioCanvas(CanvasEvent::Properties(context, app.selected)),
),
))
.id("scenario-properties")
button(text("Properties").size(12))
.on_press_maybe(clean.then_some(Message::EditProperties(app.selected)))
]
.spacing(6);
if referenced_scenario(p, owner, app.selected).is_some() {
@@ -360,10 +212,6 @@ pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a
))),
));
}
details = details.push(text("Scenario order").size(13));
if matches!(context.representation, Representation::Saved { .. }) {
details = details.push(text("↑ and ↓ change the shared Scenario order. Saved message positions are retained; automatically placed items may move.").size(11));
}
let details = details_pane(
context,
state.scroll_restore(context, ScrollArea::Page),
@@ -693,153 +541,6 @@ pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a
super::super::canvas_workspace::host(header.into(), palette, canvas, editor)
}
fn display_notice<'a>(p: &Project, shown: &ScenarioPresentation) -> Element<'a, Message> {
display_notice_parts(
p,
shown.requested(),
shown.status(),
shown.diagnostics(),
shown.latent_messages(),
)
}
fn display_notice_parts<'a>(
p: &Project,
requested: Representation,
status: &presentation::PresentationStatus,
diagnostics: &[super::super::scenario_layout::Diagnostic],
latent_messages: &[Id],
) -> Element<'a, Message> {
use super::super::scenario_layout::{Diagnostic, presentation::PresentationStatus};
let title = match requested {
Representation::Saved { diagram } => p.diagrams.get(&diagram).map_or_else(
|| "Unavailable saved Scenario view".into(),
|view| format!("Saved view · {}", view.name),
),
Representation::Derived { owner } => format!("Automatic view · {}", p.named(owner)),
};
let mut notice = column![text(title).size(14)].spacing(4);
match status {
PresentationStatus::Ready => {
for diagnostic in diagnostics.iter().take(3) {
let explanation = match diagnostic {
Diagnostic::SourceOrderCrossing { before, after, .. } => format!(
"Saved spacing places '{}' below '{}'. The Scenario order is unchanged.",
p.named(*before),
p.named(*after)
),
Diagnostic::FrameExtendsPastFollowingAnchor { frame, following } => format!(
"Saved spacing makes '{}' overlap '{}'. Both keep their saved positions.",
p.named(*frame),
p.named(*following)
),
};
notice = notice.push(text(explanation).size(12));
}
if diagnostics.len() > 3 {
notice = notice.push(
text(format!(
"{} additional spacing notices are retained.",
diagnostics.len() - 3
))
.size(12),
);
}
}
PresentationStatus::Fallback { requested_issues } => {
notice = notice.push(text("Showing the complete automatic arrangement because the saved positions cannot currently be displayed. Saved positions are preserved.").size(12));
if let Some(issue) = requested_issues.first() {
notice = notice.push(text(geometry_problem(p, issue)).size(12));
}
notice = notice.push(text("Selection, navigation and Fit remain available. Canvas insertion and reordering are unavailable until the saved positions are repaired.").size(12));
}
PresentationStatus::Unavailable { .. } => {
notice = notice.push(text("This view is unavailable. Its identity or automatic geometry cannot be resolved; the model and source remain available for inspection and repair.").size(12));
}
}
if !latent_messages.is_empty() {
notice = notice.push(text(format!("{} unfinished message position(s) are retained until their endpoints are complete.", latent_messages.len())).size(12));
}
notice = notice.push(button("Open selected source").on_press(Message::SourceForSelection));
container(notice)
.id("scenario-display-notice")
.padding(6)
.width(Fill)
.into()
}
fn spacing_feedback(
p: &Project,
refusal: Option<&spacing::Refusal>,
diagnostics: &[super::super::scenario_layout::Diagnostic],
) -> String {
if let Some(refusal) = refusal {
let reason = match refusal {
spacing::Refusal::Geometry(issues) => issues
.first()
.map(|issue| geometry_problem(p, issue))
.unwrap_or_else(|| "This position cannot be displayed.".into()),
spacing::Refusal::Command(error) => error.clone(),
_ => "This position is unavailable in the current view.".into(),
};
return format!(
"Position unavailable · {reason} Move back to a supported position, or release to cancel."
);
}
let explanation = match diagnostics.first() {
Some(super::super::scenario_layout::Diagnostic::SourceOrderCrossing {
before,
after,
..
}) => format!(
" '{}' is below '{}'; Scenario order stays unchanged.",
p.named(*before),
p.named(*after)
),
Some(super::super::scenario_layout::Diagnostic::FrameExtendsPastFollowingAnchor {
frame,
following,
}) => format!(" '{}' overlaps '{}'.", p.named(*frame), p.named(*following)),
None => " Scenario order stays unchanged.".into(),
};
format!("Spacing preview · Release to finish; Escape cancels.{explanation}")
}
fn geometry_problem(p: &Project, issue: &super::super::scenario_layout::GeometryIssue) -> String {
use super::super::scenario_layout::GeometryIssue;
match issue {
GeometryIssue::TypedActivationCrossingNotSupported { call, event } => format!(
"The position of '{}' conflicts with the execution of '{}'.",
p.named(*event),
p.named(*call)
),
GeometryIssue::CoincidentDistinctEndpoints(message) => format!(
"The distinct endpoints of '{}' have the same horizontal position.",
p.named(*message)
),
GeometryIssue::TypedActivationOutsideLifetime { call, .. }
| GeometryIssue::TypedActivationOutsideScope { call, .. } => format!(
"The position of '{}' falls outside its supported execution interval.",
p.named(*call)
),
GeometryIssue::FixedFrameHeaderWouldMove { frame } => format!(
"The saved spacing would move the fixed header of '{}'.",
p.named(*frame)
),
GeometryIssue::OperandScopeOutsideEnclosingInterval { operand, .. } => format!(
"The spacing of '{}' extends outside its enclosing frame.",
p.named(*operand)
),
GeometryIssue::LifecycleGeometryNotSupported { .. }
| GeometryIssue::LifecycleAnchorOutsideRenderScope { .. }
| GeometryIssue::LifetimeInversionNotSupported { .. } => {
"The saved spacing cannot preserve the participant lifetime geometry.".into()
}
_ => "These saved positions are outside the geometry currently supported by the canvas."
.into(),
}
}
fn sequence_action<'a>(
title: &str,
message: Message,
@@ -896,12 +597,6 @@ fn editor_panel<'a, R: iced::advanced::text::Renderer + 'a>(
#[cfg(all(test, debug_assertions))]
mod tests;
#[cfg(all(test, debug_assertions))]
mod spacing_tests;
#[cfg(all(test, debug_assertions))]
mod details_tests;
fn sequence_rows<'a>(
p: &'a Project,
context: Context,
@@ -1,445 +0,0 @@
//! Mounted Scenario Details → inspector regression and retained-intent guards.
use super::*;
use crate::behavior::canvas_workspace::tests::Mounted;
use crate::behavior::scenario_layout::resolve;
use iced::advanced::widget;
use iced::{Vector, keyboard};
use std::sync::Arc;
const FIXTURE: &str = include_str!("../../../app/diagram_usages/tests/saved_scenario.arcadia");
fn id(app: &App, alias: &str) -> Id {
app.project()
.identities()
.into_iter()
.find(|(_, name)| *name == alias)
.unwrap()
.0
}
fn send(app: &mut App, event: impl FnOnce(Context) -> CanvasEvent) {
let context = app.scenario_context().unwrap();
let _ = app.update(Message::ScenarioCanvas(event(context)));
}
fn setup() -> (App, Id, Id) {
let mut app = App::with_project(oa_core::language::parse(FIXTURE).unwrap());
let owner = id(&app, "incident_response");
let diagram = id(&app, "incident_readable");
let message = id(&app, "initial_report");
let _ = app.update(Message::Select(owner));
let _ = app.update(Message::Diagram(crate::app::Choice {
id: diagram,
label: "A".into(),
}));
send(&mut app, |c| CanvasEvent::Select(c, message));
app.window_size = Size::new(1440., 1000.);
(app, diagram, message)
}
fn open_details(app: &mut App) {
if app.behavior.scenario.presentation.shelf.is_none() {
send(app, |c| CanvasEvent::Shelf(c, Shelf::Selection));
}
}
fn stored_camera(app: &App) -> (Vector, Option<f32>, bool) {
let p = &app.behavior.scenario.presentation;
(p.pan, p.zoom, p.fit)
}
#[tokio::test]
async fn saved_details_opens_real_editable_inspector_and_returns_to_same_view() {
let (mut app, diagram, message) = setup();
let _ = app.update(Message::InspectorTab(
crate::navigation::InspectorTab::Connections,
));
let mut mounted = Mounted::new(&mut app).await;
open_details(&mut app);
mounted.settle(&mut app).await;
let before = mounted.geometry(&app);
assert_eq!(
app.inspector_tab,
crate::navigation::InspectorTab::Connections
);
assert!(!before.scrolls.contains_key(&widget::Id::new("inspector")));
let (details, offset) = before.scrolls[&widget::Id::new("scenario-details")];
let button = before.bounds("scenario-properties").center() - offset;
assert!(
details.contains(button),
"Properties must be actually visible"
);
let context = app.scenario_context().unwrap();
let camera = stored_camera(&app);
let original = app.document.snapshot();
let bundle = app.document.bundle().clone();
let messages = mounted.click(&mut app, button).await;
assert!(messages.iter().any(|event| matches!(event,
Message::ScenarioCanvas(CanvasEvent::Properties(c, selected))
if *c == context && *selected == message)));
mounted.settle(&mut app).await;
assert!(app.behavior.scenario.presentation.shelf.is_none());
assert_eq!(
app.inspector_tab,
crate::navigation::InspectorTab::Properties
);
assert_eq!(app.selected, message);
assert_eq!(app.active_diagram, Some(diagram));
assert_eq!(app.page, crate::app::Page::Behavior);
let current = app.scenario_context().unwrap();
assert_eq!(current.representation, context.representation);
assert_eq!(current.visit, context.visit);
assert_eq!(
stored_camera(&app),
camera,
"opening Properties retains camera intent"
);
assert!(Arc::ptr_eq(&original, &app.document.snapshot()));
assert_eq!(app.document.bundle(), &bundle);
assert!(!app.document.can_undo() && !app.document.can_redo());
let shown = mounted.geometry(&app);
assert!(
!shown
.scrolls
.contains_key(&widget::Id::new("scenario-details"))
);
let (inspector, offset) = shown.scrolls[&widget::Id::new("inspector")];
assert!(inspector.width > 250. && inspector.height > 500.);
// Production Properties places Name before the other scalar text inputs.
// Account for the retained inspector scroll, rather than clicking content coordinates.
let input = shown
.text_inputs
.iter()
.map(|bounds| bounds.center() - offset)
.filter(|point| inspector.contains(*point))
.min_by(|a, b| a.y.total_cmp(&b.y))
.expect("visible editable property");
mounted.click(&mut app, input).await;
let typed = mounted
.batch(
&mut app,
&[iced::Event::Keyboard(keyboard::Event::KeyPressed {
key: keyboard::Key::Character("x".into()),
modified_key: keyboard::Key::Character("x".into()),
physical_key: keyboard::key::Physical::Unidentified(
keyboard::key::NativeCode::Unidentified,
),
location: keyboard::Location::Standard,
modifiers: keyboard::Modifiers::empty(),
text: Some("x".into()),
repeat: false,
})],
mouse::Cursor::Available(input),
)
.await;
assert!(
typed
.iter()
.any(|message| matches!(message, Message::Field(path, _) if path == "/name")),
"real Name editor must publish its edit: {typed:?}"
);
assert!(app.draft_dirty());
assert!(
Arc::ptr_eq(&original, &app.document.snapshot()),
"typing is only a draft"
);
let _ = app.update(Message::RevertProperties);
assert!(!app.draft_dirty());
mounted.settle(&mut app).await;
let returned = mounted.geometry(&app);
assert!(returned.scrolls.contains_key(&widget::Id::new("inspector")));
let viewport = returned.bounds("scenario-viewport");
assert_eq!(app.selected, message);
assert_eq!(app.scenario_context().unwrap().visit, context.visit);
assert_eq!(
app.scenario_context().unwrap().representation,
context.representation
);
assert_eq!(stored_camera(&app), camera);
let geometry = resolve(app.project(), context.representation, message).unwrap();
let line = geometry
.scene
.lines
.iter()
.find(|line| line.id == message)
.unwrap();
let effective = app.behavior.scenario.presentation.camera(geometry.bounds);
let point = viewport.position()
+ effective.pan
+ Vector::new(line.points[0].x, line.points[0].y) * effective.scale;
assert!(
viewport.contains(point),
"the same selected message remains in fitted view"
);
assert!(Arc::ptr_eq(&original, &app.document.snapshot()));
assert_eq!(app.document.bundle(), &bundle);
assert!(!app.document.can_undo() && !app.document.can_redo());
}
#[test]
fn details_properties_refuses_pending_property_source_and_behavior_drafts_without_apply() {
for kind in 0..3 {
let (mut app, _, message) = setup();
open_details(&mut app);
match kind {
0 => {
let _ = app.update(Message::Field(
"/name".into(),
"Retained property draft".into(),
));
assert!(app.draft_dirty());
}
1 => {
let _ = app.update(Message::SourceEdit(
iced::widget::text_editor::Action::Edit(
iced::widget::text_editor::Edit::Insert('x'),
),
));
assert!(app.source_dirty());
}
_ => {
let owner = app.scenario_context().unwrap().scenario;
let _ = app.update(Message::Behavior(super::super::super::Event::Scenario(
Event::Start(owner, Tool::Lifeline),
)));
assert!(app.behavior.has_pending());
}
}
let snapshot = app.document.snapshot();
let bundle = app.document.bundle().clone();
let property = app.draft.as_ref().unwrap().materialize();
let source = app.source_editor.text();
let behavior = app.behavior.recovery_snapshot();
let camera = app.behavior.scenario.camera_boundary();
let context = app.scenario_context().unwrap();
let epochs = (app.canvas_epoch, app.camera_epoch);
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Properties(
context, message,
)));
assert!(app.behavior.scenario.camera_boundary() == camera);
assert_eq!(app.scenario_context(), Some(context));
assert_eq!((app.canvas_epoch, app.camera_epoch), epochs);
assert_eq!(app.draft.as_ref().unwrap().materialize(), property);
assert_eq!(app.source_editor.text(), source);
assert_eq!(app.behavior.recovery_snapshot(), behavior);
assert_eq!(app.selected, message);
assert!(Arc::ptr_eq(&snapshot, &app.document.snapshot()));
assert_eq!(app.document.bundle(), &bundle);
assert!(!app.document.can_undo() && !app.document.can_redo());
}
}
#[tokio::test]
async fn details_opens_current_saved_view_properties_after_following_diagram_picker() {
let (mut app, a, _) = setup();
let b = id(&app, "incident_wide");
// Selecting the current view record makes the real picker follow that
// record selection to B. Do not seed selected=B or its property draft.
let _ = app.update(Message::Select(a));
assert_eq!(app.selected, a);
let _ = app.update(Message::Diagram(crate::app::Choice {
id: b,
label: "B".into(),
}));
assert_eq!(app.selected, b);
assert_eq!(app.draft.as_ref().unwrap().original, b);
assert_eq!(
app.scenario_context().unwrap().representation,
Representation::Saved { diagram: b }
);
let _ = app.update(Message::InspectorTab(
crate::navigation::InspectorTab::Connections,
));
let mut mounted = Mounted::new(&mut app).await;
open_details(&mut app);
mounted.settle(&mut app).await;
let context = app.scenario_context().unwrap();
let camera = stored_camera(&app);
let snapshot = app.document.snapshot();
let bundle = app.document.bundle().clone();
let before = mounted.geometry(&app);
assert_eq!(
app.inspector_tab,
crate::navigation::InspectorTab::Connections
);
assert!(!before.scrolls.contains_key(&widget::Id::new("inspector")));
let (details, offset) = before.scrolls[&widget::Id::new("scenario-details")];
let point = before.bounds("scenario-properties").center() - offset;
assert!(details.contains(point));
let events = mounted.click(&mut app, point).await;
assert!(events.iter().any(|event| matches!(event,
Message::ScenarioCanvas(CanvasEvent::Properties(c, selected))
if *c == context && *selected == b)));
mounted.settle(&mut app).await;
let shown = mounted.geometry(&app);
assert!(
!shown
.scrolls
.contains_key(&widget::Id::new("scenario-details"))
);
let (inspector, offset) = shown.scrolls[&widget::Id::new("inspector")];
assert!(
shown
.text_inputs
.iter()
.any(|bounds| inspector.contains(bounds.center() - offset)),
"current Saved diagram has a visible editable inspector"
);
assert_eq!(
app.inspector_tab,
crate::navigation::InspectorTab::Properties
);
assert_eq!(app.selected, b);
assert_eq!(app.draft.as_ref().unwrap().original, b);
assert_eq!(app.active_diagram, Some(b));
assert_eq!(app.page, crate::app::Page::Behavior);
assert_eq!(
app.scenario_context().unwrap().representation,
context.representation
);
assert_eq!(app.scenario_context().unwrap().visit, context.visit);
assert_eq!(stored_camera(&app), camera);
assert!(Arc::ptr_eq(&snapshot, &app.document.snapshot()));
assert_eq!(app.document.bundle(), &bundle);
assert!(!app.document.can_undo() && !app.document.can_redo());
}
#[test]
fn details_properties_refuses_another_saved_view_even_with_the_same_scenario_owner() {
let (mut app, a, _) = setup();
let b = id(&app, "incident_wide");
let _ = app.update(Message::Diagram(crate::app::Choice {
id: b,
label: "B".into(),
}));
let _ = app.update(Message::Select(a));
assert_eq!(app.selected, a);
open_details(&mut app);
let context = app.scenario_context().unwrap();
assert_eq!(context.representation, Representation::Saved { diagram: b });
assert_eq!(
presentation::checked_owner(app.project(), Representation::Saved { diagram: a }),
Some(context.scenario)
);
assert!(!properties_target(app.project(), context, a));
let camera = app.behavior.scenario.camera_boundary();
let epochs = (app.canvas_epoch, app.camera_epoch);
let snapshot = app.document.snapshot();
let bundle = app.document.bundle().clone();
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Properties(context, a)));
assert!(app.behavior.scenario.camera_boundary() == camera);
assert_eq!((app.canvas_epoch, app.camera_epoch), epochs);
assert_eq!(app.scenario_context(), Some(context));
assert_eq!(app.selected, a);
assert_eq!(app.draft.as_ref().unwrap().original, a);
assert!(Arc::ptr_eq(&snapshot, &app.document.snapshot()));
assert_eq!(app.document.bundle(), &bundle);
assert!(!app.document.can_undo() && !app.document.can_redo());
}
#[test]
fn details_properties_rejects_retired_view_selection_and_epoch_without_changing_panels() {
for kind in 0..3 {
let (mut app, diagram, message) = setup();
open_details(&mut app);
let captured = app.scenario_context().unwrap();
match kind {
0 => {
let other = id(&app, "incident_wide");
for view in [other, diagram] {
let _ = app.update(Message::Diagram(crate::app::Choice {
id: view,
label: "view".into(),
}));
}
open_details(&mut app);
assert_ne!(app.scenario_context().unwrap().visit, captured.visit);
}
1 => {
let other = id(&app, "updated_report");
send(&mut app, |c| CanvasEvent::Select(c, other));
}
_ => {
send(&mut app, |c| CanvasEvent::Zoom(c, Some(0.5)));
}
}
let camera = app.behavior.scenario.camera_boundary();
let context = app.scenario_context();
let selected = app.selected;
let tab = app.inspector_tab;
let epochs = (app.canvas_epoch, app.camera_epoch);
let snapshot = app.document.snapshot();
let bundle = app.document.bundle().clone();
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Properties(
captured, message,
)));
assert!(app.behavior.scenario.camera_boundary() == camera);
assert_eq!(app.scenario_context(), context);
assert_eq!(app.selected, selected);
assert_eq!(app.inspector_tab, tab);
assert_eq!((app.canvas_epoch, app.camera_epoch), epochs);
assert!(Arc::ptr_eq(&snapshot, &app.document.snapshot()));
assert_eq!(app.document.bundle(), &bundle);
}
}
#[test]
fn opening_scenario_properties_cancels_held_spacing_and_rejects_its_late_finish() {
use super::super::spacing::{Event as Spacing, Target, controller::tests::packet};
let (mut app, _, message) = setup();
send(&mut app, |c| {
CanvasEvent::Viewport(c, Size::new(900., 600.))
});
open_details(&mut app);
let begin = packet(
app.project(),
&app.behavior.scenario,
app.scenario_context().unwrap(),
app.camera_epoch,
Target::Message(message),
);
let Spacing::Begin { press_screen, .. } = begin else {
panic!()
};
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Spacing(begin)));
assert_eq!(app.behavior.scenario.spacing_token(), Some(begin.token()));
let snapshot = app.document.snapshot();
let end = press_screen + Vector::new(0., 12.);
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Spacing(
Spacing::Move {
stamp: begin.stamp(),
token: begin.token(),
screen: end,
},
)));
let paint = app
.behavior
.scenario
.spacing_paint(begin.stamp().context, begin.stamp().camera_epoch)
.unwrap();
assert!(paint.refusal.is_none());
let line = paint
.scene
.lines
.iter()
.find(|line| line.id == message)
.unwrap();
assert!(
line.points[0].y > 160.,
"a real proposed scene moved the first message"
);
assert!(
Arc::ptr_eq(&snapshot, &app.document.snapshot()),
"preview is not authored"
);
let camera = stored_camera(&app);
send(&mut app, |c| CanvasEvent::Properties(c, message));
assert!(app.behavior.scenario.spacing_token().is_none());
assert_eq!(stored_camera(&app), camera);
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Spacing(
Spacing::Finish {
stamp: begin.stamp(),
token: begin.token(),
screen: end,
},
)));
assert!(Arc::ptr_eq(&snapshot, &app.document.snapshot()));
assert!(!app.document.can_undo() && !app.document.can_redo());
}
@@ -1,438 +0,0 @@
//! Real mounted widgets dispatch the gesture; expectations name authored fields
//! and actual displayed anchors independently of controller command results.
use super::*;
use crate::behavior::canvas_workspace::tests::{Mounted, key};
use crate::behavior::scenario_layout::{ResolvedScenarioPlan, resolve};
use crate::document::Document;
use iced::Vector;
use std::sync::Arc;
const FIXTURE: &str = include_str!("../../../app/diagram_usages/tests/saved_scenario.arcadia");
fn id(app: &App, alias: &str) -> Id {
app.project()
.identities()
.into_iter()
.find(|(_, name)| *name == alias)
.unwrap()
.0
}
async fn setup(fit: bool) -> (App, Mounted, Id) {
let mut app = App::with_project(oa_core::language::parse(FIXTURE).unwrap());
let owner = id(&app, "incident_response");
let diagram = id(&app, "incident_readable");
let _ = app.update(Message::Select(owner));
let _ = app.update(Message::Diagram(crate::app::Choice {
id: diagram,
label: "A - Incident review readable".into(),
}));
app.window_size = Size::new(1440., 900.);
let mut mounted = Mounted::new(&mut app).await;
if !fit {
let context = app.scenario_context().unwrap();
let task = app.update(Message::ScenarioCanvas(CanvasEvent::Zoom(
context,
Some(0.5),
)));
mounted.perform(&mut app, task).await;
mounted.settle(&mut app).await;
}
assert_eq!(
app.scenario_context().unwrap().representation,
Representation::Saved { diagram }
);
assert!(!app.document.can_undo() && !app.document.can_redo());
(app, mounted, diagram)
}
fn resolved(app: &App) -> ResolvedScenarioPlan {
resolve(
app.project(),
app.scenario_context().unwrap().representation,
app.selected,
)
.unwrap()
}
fn stored_camera(app: &App) -> (Vector, Option<f32>, bool) {
let p = &app.behavior.scenario.presentation;
(p.pan, p.zoom, p.fit)
}
fn screen(mounted: &mut Mounted, app: &App, world: Point) -> Point {
let bounds = mounted.geometry(app).bounds("scenario-viewport");
let geometry = resolved(app);
let camera = app.behavior.scenario.presentation.camera(geometry.bounds);
let point = bounds.position() + camera.pan + Vector::new(world.x, world.y) * camera.scale;
assert!(
bounds.contains(point),
"actual visible world {world:?}: {point:?} in {bounds:?}"
);
point
}
fn mouse_event(event: mouse::Event) -> iced::Event {
iced::Event::Mouse(event)
}
async fn press(mounted: &mut Mounted, app: &mut App, point: Point) -> Vec<Message> {
mounted
.batch(
app,
&[
mouse_event(mouse::Event::CursorMoved { position: point }),
mouse_event(mouse::Event::ButtonPressed(mouse::Button::Left)),
],
mouse::Cursor::Available(point),
)
.await
}
async fn move_to(mounted: &mut Mounted, app: &mut App, point: Point) -> Vec<Message> {
mounted
.batch(
app,
&[mouse_event(mouse::Event::CursorMoved { position: point })],
mouse::Cursor::Available(point),
)
.await
}
async fn release(mounted: &mut Mounted, app: &mut App, point: Point) -> Vec<Message> {
mounted
.batch(
app,
&[mouse_event(mouse::Event::ButtonReleased(
mouse::Button::Left,
))],
mouse::Cursor::Available(point),
)
.await
}
fn set_expected_message(project: &mut oa_core::Project, diagram: Id, occurrence: Id, y: f32) {
let Some(oa_core::DiagramLayout::Scenario(layout)) =
&mut project.diagrams.get_mut(&diagram).unwrap().layout
else {
panic!()
};
layout
.messages
.iter_mut()
.find(|m| m.address.occurrence == occurrence)
.unwrap()
.send_y = y;
}
fn set_expected_lane(project: &mut oa_core::Project, diagram: Id, lifeline: Id, x: f32) {
let Some(oa_core::DiagramLayout::Scenario(layout)) =
&mut project.diagrams.get_mut(&diagram).unwrap().layout
else {
panic!()
};
layout
.lanes
.iter_mut()
.find(|lane| lane.lifeline == lifeline)
.unwrap()
.x = x;
}
fn assert_reopens_exactly(app: &App) {
let reopened = Document::from_bundle(
app.document.bundle().clone(),
oa_core::source_project::ProjectLimits::default(),
)
.unwrap();
assert_eq!(reopened.project(), app.project());
assert_eq!(reopened.bundle(), app.document.bundle());
}
#[tokio::test]
async fn saved_message_same_batch_pointer_events_commit_once_and_one_undo_redo_restores_exact_source()
{
let (mut app, mut mounted, diagram) = setup(false).await;
let target = id(&app, "updated_report");
let before = app.document.snapshot();
let revision = app.document.revision();
let projected = screen(&mut mounted, &app, Point::new(400., 252.));
// This fixture asks for exactly +20 world units. A fractional start near a
// screen-coordinate exponent boundary need not retain an exact +10 delta.
let start = Point::new(projected.x.round(), projected.y.round());
let mut geometry = resolved(&app);
let camera = app.behavior.scenario.presentation.camera(geometry.bounds);
assert_eq!(camera.scale, 0.5);
let bounds = mounted.geometry(&app).bounds("scenario-viewport");
let world = Point::new(
(start.x - bounds.x - camera.pan.x) / camera.scale,
(start.y - bounds.y - camera.pan.y) / camera.scale,
);
geometry.scene.scale = camera.scale;
geometry.scene.camera_pan = camera.pan;
let hit = geometry.scene.hit_item(world).unwrap();
assert_eq!(hit.id, target);
assert_eq!(hit.role, crate::behavior::SceneHitRole::MessageBody);
let end = start + Vector::new(0., 10.); // .5 display scale: +20 authored y.
assert_eq!(end.y - start.y, 10.);
assert!(bounds.contains(start) && bounds.contains(end));
let messages = mounted
.batch(
&mut app,
&[
mouse_event(mouse::Event::CursorMoved { position: start }),
mouse_event(mouse::Event::ButtonPressed(mouse::Button::Left)),
mouse_event(mouse::Event::CursorMoved { position: end }),
mouse_event(mouse::Event::ButtonReleased(mouse::Button::Left)),
],
mouse::Cursor::Available(end),
)
.await;
assert_eq!(
messages
.iter()
.filter(|m| matches!(
m,
Message::ScenarioCanvas(CanvasEvent::Spacing(spacing::Event::Begin { .. }))
))
.count(),
1
);
assert_eq!(
messages
.iter()
.filter(|m| matches!(
m,
Message::ScenarioCanvas(CanvasEvent::Spacing(spacing::Event::Finish { .. }))
))
.count(),
1
);
let mut expected = before.decoded.project.clone();
set_expected_message(&mut expected, diagram, target, 272.);
assert_eq!(
app.project(),
&expected,
"Only Saved A's addressed y changes; steps, other occurrences and B/C are exact"
);
assert_eq!(app.document.revision(), revision + 1);
assert_eq!(app.selected, target);
assert!(app.behavior.scenario.spacing_token().is_none());
assert_reopens_exactly(&app);
let after = app.document.snapshot();
let task = app.update(Message::Undo);
mounted.perform(&mut app, task).await;
assert_eq!(app.project(), &before.decoded.project);
assert_eq!(app.document.bundle(), &before.bundle);
assert!(!app.document.can_undo() && app.document.can_redo());
let task = app.update(Message::Redo);
mounted.perform(&mut app, task).await;
assert_eq!(app.project(), &after.decoded.project);
assert_eq!(app.document.bundle(), &after.bundle);
}
#[tokio::test]
async fn saved_header_preview_survives_frames_and_freezes_fit_while_actual_bounds_expand() {
let (mut app, mut mounted, diagram) = setup(true).await;
let assessor = id(&app, "assessor");
let before = app.document.snapshot();
let original = resolved(&app);
let original_camera = app.behavior.scenario.presentation.camera(original.bounds);
let stored = stored_camera(&app);
assert!(stored.2, "test must exercise original Fit");
let start = screen(&mut mounted, &app, Point::new(640., 45.));
press(&mut mounted, &mut app, start).await;
let token = app
.behavior
.scenario
.spacing_token()
.expect("actual header press begins");
for x in [670., 700.] {
let end = start + Vector::new((x - 640.) * original_camera.scale, 0.);
move_to(&mut mounted, &mut app, end).await;
mounted.settle(&mut app).await; // Actual borrowed Scene/Stack tree is rebuilt.
assert_eq!(app.behavior.scenario.spacing_token(), Some(token));
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
assert_eq!(stored_camera(&app), stored);
let paint = app
.behavior
.scenario
.spacing_paint(app.scenario_context().unwrap(), app.camera_epoch)
.unwrap();
assert!(paint.refusal.is_none());
assert!((paint.scene.render_lanes[&assessor] - x).abs() < 0.001);
assert!(paint.bounds.width > original.bounds.width);
assert_eq!(paint.scene.scale, original_camera.scale);
assert_eq!(paint.scene.camera_pan, original_camera.pan);
let header = paint.scene.boxes.iter().find(|b| b.id == assessor).unwrap();
let displayed = Vector::new(header.rect.center().x, header.rect.center().y)
* paint.scene.scale
+ paint.scene.camera_pan;
let baseline = Vector::new(640., 50.) * original_camera.scale + original_camera.pan;
assert!((displayed.x - baseline.x - (x - 640.) * original_camera.scale).abs() < 0.001);
assert_eq!(displayed.y, baseline.y);
}
let end = start + Vector::new(60. * original_camera.scale, 0.);
release(&mut mounted, &mut app, end).await;
let final_geometry = resolved(&app);
let actual_x = final_geometry.scene.render_lanes[&assessor];
assert!((actual_x - 700.).abs() < 0.001);
let mut expected = before.decoded.project.clone();
set_expected_lane(&mut expected, diagram, assessor, actual_x);
assert_eq!(app.project(), &expected);
let final_camera = app
.behavior
.scenario
.presentation
.camera(final_geometry.bounds);
assert_eq!(
final_camera, original_camera,
"commit must not refit underneath pointer"
);
assert!(!app.behavior.scenario.presentation.fit);
assert_eq!(app.selected, assessor);
assert_reopens_exactly(&app);
}
#[tokio::test]
async fn saved_body_and_header_clicks_select_without_materializing_spacing() {
let (mut app, mut mounted, _) = setup(false).await;
let before = app.document.snapshot();
let original_camera = stored_camera(&app);
for (alias, world) in [
("updated_report", Point::new(400., 252.)),
("assessor", Point::new(640., 45.)),
] {
let target = id(&app, alias);
let point = screen(&mut mounted, &app, world);
mounted.click(&mut app, point).await;
assert_eq!(app.selected, target);
assert!(app.behavior.scenario.spacing_token().is_none());
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
assert_eq!(stored_camera(&app), original_camera);
}
assert!(!app.document.can_undo() && !app.document.can_redo());
}
#[tokio::test]
async fn saved_valid_preview_then_refused_self_call_never_commits_last_valid_candidate() {
let (mut app, mut mounted, _) = setup(false).await;
let target = id(&app, "assess_call");
let before = app.document.snapshot();
let camera = stored_camera(&app);
let start = screen(&mut mounted, &app, Point::new(680., 344.));
press(&mut mounted, &mut app, start).await;
let valid = start + Vector::new(0., -10.); // send324, receive348, reply436 unchanged.
move_to(&mut mounted, &mut app, valid).await;
mounted.settle(&mut app).await;
let paint = app
.behavior
.scenario
.spacing_paint(app.scenario_context().unwrap(), app.camera_epoch)
.unwrap();
assert!(paint.refusal.is_none());
let line = paint.scene.lines.iter().find(|l| l.id == target).unwrap();
assert_eq!(line.points[0].y, 324.);
assert_eq!(line.points.last().unwrap().y, 348.);
let refused = start + Vector::new(0., 53.); // send450, receive474 would follow reply436.
move_to(&mut mounted, &mut app, refused).await;
mounted.settle(&mut app).await;
let paint = app
.behavior
.scenario
.spacing_paint(app.scenario_context().unwrap(), app.camera_epoch)
.unwrap();
assert!(paint.refusal.is_some());
assert_eq!(
paint
.scene
.lines
.iter()
.find(|l| l.id == target)
.unwrap()
.points[0]
.y,
344.
);
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
release(&mut mounted, &mut app, refused).await;
assert!(app.behavior.scenario.spacing_token().is_none());
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
assert_eq!(stored_camera(&app), camera);
assert!(!app.document.can_undo() && !app.document.can_redo());
}
#[tokio::test]
async fn saved_escape_and_outside_pointer_cancel_actual_held_preview() {
for escape in [true, false] {
let (mut app, mut mounted, _) = setup(false).await;
let before = app.document.snapshot();
let camera = stored_camera(&app);
let start = screen(&mut mounted, &app, Point::new(400., 252.));
let end = start + Vector::new(0., 10.);
press(&mut mounted, &mut app, start).await;
move_to(&mut mounted, &mut app, end).await;
mounted.settle(&mut app).await;
assert!(app.behavior.scenario.spacing_token().is_some());
let release_at = if escape {
mounted
.batch(
&mut app,
&[key(iced::keyboard::key::Named::Escape)],
mouse::Cursor::Available(end),
)
.await;
end
} else {
let bounds = mounted.geometry(&app).bounds("scenario-viewport");
let outside = Point::new(bounds.x - 5., bounds.y + 100.);
move_to(&mut mounted, &mut app, outside).await;
outside
};
assert!(app.behavior.scenario.spacing_token().is_none());
release(&mut mounted, &mut app, release_at).await;
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
assert_eq!(stored_camera(&app), camera);
assert!(!app.document.can_undo() && !app.document.can_redo());
}
}
#[tokio::test]
async fn saved_drag_return_to_origin_preserves_redo_selection_and_original_fit_camera() {
let (mut app, mut mounted, _) = setup(true).await;
let owner = id(&app, "incident_response");
app.document
.execute(oa_core::Command::Rename(
owner,
"Previously edited owner".into(),
))
.unwrap();
let redo = app.document.snapshot();
let task = app.update(Message::Undo);
mounted.perform(&mut app, task).await;
mounted.settle(&mut app).await;
assert!(app.document.can_redo());
let before = app.document.snapshot();
let camera = stored_camera(&app);
let selected = app.selected;
let revision = app.document.revision();
let start = screen(&mut mounted, &app, Point::new(400., 252.));
press(&mut mounted, &mut app, start).await;
move_to(&mut mounted, &mut app, start + Vector::new(0., 12.)).await;
mounted.settle(&mut app).await;
assert!(app.behavior.scenario.spacing_token().is_some());
move_to(&mut mounted, &mut app, start).await;
mounted.settle(&mut app).await;
release(&mut mounted, &mut app, start).await;
assert!(app.behavior.scenario.spacing_token().is_none());
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
assert_eq!(app.document.bundle(), &before.bundle);
assert_eq!(app.document.revision(), revision);
assert_eq!(stored_camera(&app), camera);
assert_eq!(app.selected, selected);
assert!(app.document.can_redo());
let task = app.update(Message::Redo);
mounted.perform(&mut app, task).await;
assert_eq!(app.project(), &redo.decoded.project);
assert_eq!(app.document.bundle(), &redo.bundle);
}
@@ -57,28 +57,8 @@ async fn production_scenario_canvas_keeps_bounds_draft_identity_and_camera_throu
mounted.pan(&mut app, start, start + delta).await;
let diff = app.behavior.scenario.presentation.pan - next_pan;
assert!(diff.x.hypot(diff.y) < 0.01);
// Iced operations report palette children in content coordinates.
// The preceding Action focus may have scrolled this retained palette.
let geometry = mounted.geometry(&app);
let scope = canvas_workspace::Scope::current(&app).unwrap();
let (palette, offset) = geometry.scrolls[&scope.control_id("palette-scroll")];
let tool = geometry.bounds("behavior-tool:A").center() - offset;
assert!(
palette.contains(tool),
"visible Action at {tool:?}: {palette:?}"
);
let chosen = mounted.click(&mut app, tool).await;
assert!(
chosen.iter().any(|message| matches!(
canvas_workspace::tests::intent_action(message),
Message::ScenarioCanvas(CanvasEvent::Choose(_, CanvasTool::Add(Tool::Action)))
)),
"Action palette click at scale {scale}, offset {offset:?}: {chosen:?}"
);
assert_eq!(
app.behavior.scenario.canvas_tool(f.owner),
CanvasTool::Add(Tool::Action)
);
let tool = mounted.geometry(&app).bounds("behavior-tool:A").center();
mounted.click(&mut app, tool).await;
let messages = mounted.click(&mut app, bounds.position() + desired).await;
assert!(
messages.iter().any(
@@ -86,7 +66,7 @@ async fn production_scenario_canvas_keeps_bounds_draft_identity_and_camera_throu
tool: Tool::Action, slot, lifeline: Some(line), ..
}) if *slot == expected && *line == f.sender)
),
"scale {scale}, expected slot {expected:?}: {messages:?}"
"scale {scale}: {messages:?}"
);
let session = app.behavior.scenario.form_session().unwrap();
let invocation = app.behavior.scenario.draft.as_ref().unwrap().invocation;
@@ -1368,18 +1348,7 @@ async fn production_scenario_camera_external_controls_bounds_and_retained_deltas
let bounds = mounted.geometry(&app).bounds("scenario-viewport");
let point = bounds.position() + iced::Vector::new(95., 90.);
let lifetime = app.camera_epoch;
// Fit stores no pan offset. A real drag starts from the displayed fitted
// camera, then materializes it before applying its ordered screen deltas.
let shown = app
.behavior
.scenario
.display(app.project(), f.owner, app.selected)
.unwrap();
let before_camera = app
.behavior
.scenario
.presentation
.camera(shown.bounds().unwrap());
let pan = app.behavior.scenario.presentation.pan;
let first = point + iced::Vector::new(13., 9.);
let last = first + iced::Vector::new(17., -3.);
let emitted = mounted
@@ -1394,21 +1363,7 @@ async fn production_scenario_camera_external_controls_bounds_and_retained_deltas
mouse::Cursor::Available(last),
)
.await;
let expected_pan = before_camera.pan + (last - point);
let actual_pan = app.behavior.scenario.presentation.pan;
let difference = actual_pan - expected_pan;
assert!(
difference.x.hypot(difference.y) < 0.001,
"{boundary}: displayed pan {actual_pan:?} must follow {expected_pan:?}"
);
assert_eq!(
app.behavior.scenario.presentation.zoom,
Some(before_camera.scale)
);
assert!(
!app.behavior.scenario.presentation.fit,
"drag materializes the visible Fit camera"
);
assert_eq!(app.behavior.scenario.presentation.pan, pan + (last - point));
assert_eq!(
app.camera_epoch, lifetime,
"own ordered deltas retain their lifetime"
@@ -1813,19 +1768,14 @@ fn scenario_workspace_fit_uses_measured_inner_viewport_and_rejects_nonfinite_inp
assert!(!presentation.viewport(Size::new(484., 204.)));
let scale = presentation.scale(graph);
assert!(graph.width * scale <= 468. && graph.height * scale <= 188.);
assert!(scale > camera::MIN_SCALE);
assert!(scale > MIN_SCALE);
for bad in [f32::NAN, f32::INFINITY, 0., -1.] {
assert!(!presentation.zoom(Some(bad)));
assert!(!presentation.viewport(Size::new(bad, 100.)));
assert!(!presentation.viewport(Size::new(100., bad)));
assert_eq!(presentation.scale(graph), scale);
}
let tall = Rectangle::with_size(Size::new(900., 10000.));
let camera = presentation.camera(tall);
assert!(camera.scale < 0.05 && camera.scale > 0.);
assert!((camera.scale - 188. / 10000.).abs() < 1e-8);
assert!((tall.height * camera.scale + camera.pan.y - 196.).abs() < 1e-4);
assert!((camera.pan.y - 8.).abs() < 1e-4);
assert_eq!(presentation.scale(Size::new(900., 10000.)), MIN_SCALE);
assert!(presentation.zoom(Some(1.25)));
presentation.viewport(Size::new(200., 100.));
assert_eq!(
@@ -1,386 +0,0 @@
//! Actual Scene primitives, with independently chosen points and semantic IDs.
//! These preserve the existing label hit band; they do not establish ink bounds
//! or authoring eligibility, and do not dispatch a spacing gesture.
use super::*;
use crate::document::Document;
struct Fixture {
project: Project,
owner: Id,
sender: Id,
receiver: Id,
call: Id,
reply: Id,
exchange: Id,
}
fn fixture() -> Fixture {
let project = language::parse(r#"arcadia 7;
project hits "Hit roles" {
oa_activity source "Source" {}
oa_activity target "Target" {}
interaction exchange "Contract" { source @source; target @target; }
oa_scenario conversation "Conversation" { viewpoint OperationalActivity; steps [@call, @reply]; }
oa_lifeline left "Left" { parent @conversation; represented @source; }
oa_lifeline right "Right" { parent @conversation; represented @target; }
oa_occurrence call "Call" { parent @conversation; body { Exchange { exchange @exchange; sender @left; receiver @right; message_kind Call; }; }; }
oa_occurrence reply "Reply" { parent @conversation; body { Exchange { exchange @exchange; sender @right; receiver @left; message_kind Reply; reply_to @call; }; }; }
}
"#).unwrap();
let id = |alias: &str| {
project
.identities()
.into_iter()
.find(|(_, name)| *name == alias)
.unwrap()
.0
};
Fixture {
owner: id("conversation"),
sender: id("left"),
receiver: id("right"),
call: id("call"),
reply: id("reply"),
exchange: id("exchange"),
project,
}
}
fn scene(f: &Fixture) -> Scene {
let document =
Document::from_project(f.project.clone(), source_project::ProjectLimits::default())
.unwrap();
let before = document.snapshot();
let result = Scene::sequence_geometry(
document.project(),
f.owner,
f.call,
Some(&BTreeMap::from([(f.sender, 160.), (f.receiver, 400.)])),
None,
);
assert_eq!(document.project(), &before.decoded.project);
assert_eq!(document.bundle(), &before.bundle);
assert_eq!(document.revision(), 0);
assert!(!document.can_undo() && !document.can_redo() && !document.dirty());
result
}
fn hit(scene: &Scene, point: Point, id: Id, role: SceneHitRole) {
assert_eq!(scene.hit_item(point), Some(SceneHit { id, role }));
assert_eq!(scene.hit(point), Some(id));
}
fn occurrence(p: &mut Project, parent: Id, body: OccurrenceBody) -> Id {
let mut element = p
.make_element(Kind::Occurrence, Layer::Oa, "Hit target", Some(parent))
.unwrap();
let id = element.id;
*element.shared_mut().unwrap() = SharedBody::Occurrence {
body,
precondition: String::new(),
postcondition: String::new(),
};
p.elements.insert(id, element);
p.elements
.get_mut(&parent)
.unwrap()
.steps_mut()
.unwrap()
.push(id);
id
}
fn exchange_mut(p: &mut Project, id: Id) -> &mut OccurrenceBody {
let SharedBody::Occurrence { body, .. } =
p.elements.get_mut(&id).unwrap().shared_mut().unwrap()
else {
panic!("expected occurrence")
};
body
}
#[test]
fn exact_headers_include_unassigned_lifelines_but_guides_are_not_headers() {
let mut f = fixture();
*f.project
.elements
.get_mut(&f.receiver)
.unwrap()
.shared_mut()
.unwrap() = SharedBody::Lifeline { represented: None };
let s = scene(&f);
assert!(!s.lanes.contains_key(&f.receiver));
assert_eq!(s.render_lanes[&f.receiver], 400.);
for (id, x) in [(f.sender, 160.), (f.receiver, 400.)] {
// The actual header is x-102..x+102, y15..85, including its padding.
for point in [Point::new(x - 101., 16.), Point::new(x + 101., 84.)] {
hit(&s, point, id, SceneHitRole::LifelineHeader);
}
hit(&s, Point::new(x, 95.), id, SceneHitRole::LifelineGuide);
}
}
#[test]
fn repeated_exchange_occurrences_keep_distinct_message_body_and_label_hits() {
let f = fixture();
let s = scene(&f);
assert_eq!(s.rows[&f.call].1, 148.);
assert_eq!(s.rows[&f.reply].1, 240.);
for (id, y) in [(f.call, 148.), (f.reply, 240.)] {
hit(&s, Point::new(280., y), id, SceneHitRole::MessageBody);
hit(
&s,
Point::new(180., y - 16.),
id,
SceneHitRole::MessageLabel,
);
assert_ne!(id, f.exchange);
}
}
#[test]
fn self_call_elbow_and_receive_endpoint_are_messages_not_execution_hits() {
let mut f = fixture();
for id in [f.call, f.reply] {
let OccurrenceBody::Exchange {
sender,
receiver,
exchange,
..
} = exchange_mut(&mut f.project, id)
else {
unreachable!()
};
*sender = Some(f.sender);
*receiver = Some(f.sender);
*exchange = None;
}
let s = scene(&f);
let call = s.lines.iter().find(|l| l.id == f.call).unwrap();
assert_eq!(call.points.len(), 4);
assert_eq!(call.points[0].y, 148.);
assert_eq!(call.points[3].y, 172.);
hit(
&s,
Point::new(call.points[1].x, 160.),
f.call,
SceneHitRole::MessageBody,
);
hit(&s, call.points[3], f.call, SceneHitRole::MessageBody);
hit(
&s,
Point::new(180., 132.),
f.call,
SceneHitRole::MessageLabel,
);
let bar = s.activations.iter().find(|a| a.call == f.call).unwrap();
hit(
&s,
Point::new(bar.rect.x + 2., 190.),
f.call,
SceneHitRole::Activation,
);
}
#[test]
fn activation_shoulder_beats_another_calls_label_and_broad_message_tolerance() {
let mut f = fixture();
let mut inner = f.project.elements[&f.call].clone();
inner.id = Id::new_v4();
inner.alias = "inner_call_hit".into();
inner.name = "Pressure verification with a descriptive operation label".into();
let inner_id = inner.id;
f.project.elements.insert(inner_id, inner);
let mut inner_reply = f.project.elements[&f.reply].clone();
inner_reply.id = Id::new_v4();
inner_reply.alias = "inner_reply_hit".into();
let reply_id = inner_reply.id;
f.project.elements.insert(reply_id, inner_reply);
let OccurrenceBody::Exchange { reply_to, .. } = exchange_mut(&mut f.project, reply_id) else {
unreachable!()
};
*reply_to = Some(inner_id);
*f.project
.elements
.get_mut(&f.owner)
.unwrap()
.steps_mut()
.unwrap() = vec![f.call, inner_id, reply_id, f.reply];
let mut s = scene(&f);
let inner = s.lines.iter().find(|l| l.id == inner_id).unwrap();
let outer = s.activations.iter().find(|a| a.call == f.call).unwrap();
let shoulder = Point::new(outer.rect.x + 1., 225.);
assert!(outer.rect.contains(shoulder));
assert!(inner.label_bounds().contains(shoulder));
let endpoint = *inner.points.last().unwrap();
for scale in [1., 0.05, 0.0003] {
s.scale = scale;
hit(&s, shoulder, f.call, SceneHitRole::Activation);
hit(&s, endpoint, inner_id, SceneHitRole::MessageBody);
hit(
&s,
Point::new(180., 225.),
inner_id,
SceneHitRole::MessageLabel,
);
}
}
#[test]
fn create_delete_markers_are_distinct_from_their_own_message_body_and_label() {
let mut f = fixture();
for (id, kind) in [
(f.call, MessageKind::Create),
(f.reply, MessageKind::Delete),
] {
let OccurrenceBody::Exchange {
sender,
receiver,
message_kind,
reply_to,
..
} = exchange_mut(&mut f.project, id)
else {
unreachable!()
};
*sender = Some(f.sender);
*receiver = Some(f.receiver);
*message_kind = kind;
*reply_to = None;
}
let s = scene(&f);
for (id, y, label_y) in [(f.call, 148., 118.), (f.reply, 240., 224.)] {
hit(&s, Point::new(400., y), id, SceneHitRole::LifecycleMarker);
hit(&s, Point::new(280., y), id, SceneHitRole::MessageBody);
hit(
&s,
Point::new(180., label_y),
id,
SceneHitRole::MessageLabel,
);
}
// Capsule boundary owns its contact with the incoming Create arrow.
hit(
&s,
Point::new(330., 148.),
f.call,
SceneHitRole::LifecycleMarker,
);
}
#[test]
fn tiny_scale_nearest_hit_retains_the_winning_marker_or_message_role() {
let mut f = fixture();
let OccurrenceBody::Exchange {
sender,
receiver,
message_kind,
reply_to,
..
} = exchange_mut(&mut f.project, f.reply)
else {
unreachable!()
};
*sender = Some(f.sender);
*receiver = Some(f.receiver);
*message_kind = MessageKind::Delete;
*reply_to = None;
let mut s = scene(&f);
s.scale = 0.0003;
// Both are outside exact strokes/labels. Comparing all broad candidates
// chooses the actual nearer shape, even when their UUID is identical.
hit(
&s,
Point::new(280., 246.),
f.reply,
SceneHitRole::MessageBody,
);
hit(
&s,
Point::new(400., 265.),
f.reply,
SceneHitRole::LifecycleMarker,
);
hit(
&s,
Point::new(280., 154.),
f.call,
SceneHitRole::MessageBody,
);
}
#[test]
fn action_frame_and_use_boxes_do_not_advertise_message_or_header_roles() {
for body in [
OccurrenceBody::Action {
action: None,
lifeline: None,
},
OccurrenceBody::Fragment {
operator: FragmentOperator::Alternative,
branches: vec![],
},
OccurrenceBody::ScenarioUse {
scenario: None,
bindings: vec![],
},
OccurrenceBody::Exchange {
exchange: None,
sender: None,
receiver: None,
message_kind: MessageKind::Asynchronous,
operation: None,
reply_to: None,
values: vec![],
},
] {
let mut f = fixture();
let id = occurrence(&mut f.project, f.owner, body);
let s = scene(&f);
let item = s.boxes.iter().find(|b| b.id == id).unwrap();
hit(
&s,
item.rect.position() + iced::Vector::new(15., 15.),
id,
SceneHitRole::Box,
);
}
}
#[test]
fn winning_primitive_role_does_not_come_from_its_shared_uuid() {
let f = fixture();
let mut s = scene(&f);
// A controlled overlapping primitive verifies reverse box order without
// inventing a core alias. Its ID intentionally equals a real header's ID.
s.boxes.push(BoxItem {
id: f.sender,
role: SceneHitRole::Box,
rect: Rectangle::new(Point::new(150., 20.), Size::new(20., 20.)),
title: "Overlay control".into(),
detail: String::new(),
marker: None,
});
hit(&s, Point::new(160., 30.), f.sender, SceneHitRole::Box);
hit(
&s,
Point::new(180., 30.),
f.sender,
SceneHitRole::LifelineHeader,
);
}
#[test]
fn invalid_points_never_produce_a_semantic_or_spacing_hit() {
let f = fixture();
let s = scene(&f);
for point in [
Point::new(f32::NAN, 148.),
Point::new(280., f32::NAN),
Point::new(f32::INFINITY, 148.),
Point::new(280., f32::NEG_INFINITY),
] {
assert_eq!(s.hit_item(point), None);
assert_eq!(s.hit(point), None);
}
}
@@ -1,430 +0,0 @@
//! Unwired single-line scenario label experiment. No renderer or schema is changed.
//!
//! Iced 0.14.0 WGPU `geometry::Frame::fill_text` shapes positive uniform-scale
//! canvas text at `size * zoom`; its `text::prepare` uses graphics text::Cache.
//! This adapter uses that same allocator/font system/attributes, not a character
//! width estimate. Canvas Text::max_width is wrapping, so it stays infinite.
//! Experimental parity requires settled fonts and a fresh renderer/cache after
//! font loading. Iced's WGPU text cache key omits FontSystem::Version, so merely
//! remeasuring here cannot repair its cached glyph buffers after dynamic loading.
//! A RenderSession is caller attestation of that lifecycle, not its enforcement.
//! No fonts may be loaded between measurement, guarded access and actual draw.
//!
//! Integration seam: after lifecycle/activation endpoint alignment, a caller
//! supplies a usable text width in world units and retains one cache per live
//! labelled occurrence across Scene rebuilds. Paint, hit testing, navigation and extents
//! consume the returned Label. Position/color do not invalidate measurement.
//! This does NOT choose a self-call, reversed-arrow or obstacle corridor policy.
//! It measures layout advances, not raster ink; overhang/clip/activation-border
//! acceptance still requires the real WGPU evaluator. The old 32-grapheme/225px
//! renderer is deliberately untouched for a later baseline comparison.
use iced::advanced::{
graphics::text as graphics_text,
text::{Alignment, LineHeight, Shaping},
};
use iced::{Color, Font, Pixels, Point, Rectangle, Size, widget::canvas::Text};
use std::{collections::VecDeque, ops::Range, sync::Arc};
use unicode_segmentation::UnicodeSegmentation;
const MAX_SOURCE_BYTES: usize = 64 * 1024;
const MAX_GRAPHEMES: usize = 4096;
const MAX_ENTRIES: usize = 2;
#[derive(Clone, Copy, Debug, PartialEq)]
pub(super) struct Style {
pub font: Font,
pub size: Pixels,
pub line_height: LineHeight,
pub shaping: Shaping,
}
impl Default for Style {
fn default() -> Self {
let text = Text::default();
Self {
font: text.font,
size: Pixels(12.),
line_height: text.line_height,
shaping: text.shaping,
}
}
}
impl Style {
fn scaled(self, zoom: f32) -> (f32, f32) {
let size = Pixels(self.size.0 * zoom);
let line_height = match self.line_height {
LineHeight::Absolute(value) => LineHeight::Absolute(Pixels(value.0 * zoom)),
LineHeight::Relative(value) => LineHeight::Relative(value),
};
(size.0, line_height.to_absolute(size).0)
}
}
/// Explicit experimental contract: the caller finishes loading fonts and starts
/// a fresh renderer/cache BEFORE capturing this session, then keeps fonts fixed.
/// This module cannot inspect or reset the actual renderer's text cache.
#[derive(Clone, Debug)]
pub(super) struct RenderSession {
version: graphics_text::Version,
identity: Arc<()>,
}
impl RenderSession {
pub fn after_fonts_settled_and_renderer_reset() -> Result<Self, Error> {
let fonts = graphics_text::font_system()
.read()
.map_err(|_| Error::FontSystemUnavailable)?;
Ok(Self {
version: fonts.version(),
identity: Arc::new(()),
})
}
}
#[derive(Clone, Debug)]
pub(super) struct Label {
source: String,
displayed: String,
/// Original-label bytes represented before any added ellipsis. Includes
/// collapsed whitespace, but `retains_any` never counts whitespace as text.
retained_source: Range<usize>,
/// World-space layout advances, at origin; not glyph-ink containment.
bounds: Rectangle,
style: Style,
measured_session: RenderSession,
measured_zoom: f32,
}
impl Label {
fn check(&self, session: &RenderSession, zoom: f32) -> Result<(), Error> {
if !Arc::ptr_eq(&self.measured_session.identity, &session.identity) {
return Err(Error::StaleRenderSession);
}
if !zoom.is_finite() || zoom != self.measured_zoom {
return Err(Error::StaleZoom);
}
let fonts = graphics_text::font_system()
.read()
.map_err(|_| Error::FontSystemUnavailable)?;
if fonts.version() != self.measured_session.version || fonts.version() != session.version {
return Err(Error::FontSetChanged);
}
Ok(())
}
pub fn displayed(&self, session: &RenderSession, zoom: f32) -> Result<&str, Error> {
self.check(session, zoom)?;
Ok(&self.displayed)
}
pub fn retained_source(
&self,
session: &RenderSession,
zoom: f32,
) -> Result<Range<usize>, Error> {
self.check(session, zoom)?;
Ok(self.retained_source.clone())
}
pub fn retains_any(
&self,
session: &RenderSession,
zoom: f32,
field: Range<usize>,
) -> Result<bool, Error> {
self.check(session, zoom)?;
if field.start > field.end || self.source.get(field.clone()).is_none() {
return Ok(false);
}
let end = field.end.min(self.retained_source.end);
Ok(field.start < end
&& self
.source
.get(field.start..end)
.is_some_and(|text| text.chars().any(|c| !c.is_whitespace())))
}
/// Validate finite world position and rectangle endpoints only. Camera
/// transforms and raster bounds remain a separate integration contract.
pub fn bounds_at(
&self,
session: &RenderSession,
zoom: f32,
position: Point,
) -> Result<Rectangle, Error> {
self.check(session, zoom)?;
translated_bounds(position, self.bounds.size())
}
/// Paint under the same positive uniform zoom used for `Cache::resolve`.
/// No per-label wrapping or implicit text maximum is introduced.
/// The returned Text must be consumed immediately in this session/zoom;
/// retaining raw Text past another font load/zoom change bypasses the guard.
pub fn canvas_text(
&self,
session: &RenderSession,
zoom: f32,
position: Point,
color: Color,
) -> Result<Text, Error> {
self.bounds_at(session, zoom, position)?;
Ok(Text {
content: self.displayed.clone(),
position,
color,
size: self.style.size,
line_height: self.style.line_height,
font: self.style.font,
shaping: self.style.shaping,
max_width: f32::INFINITY,
..Text::default()
})
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum Error {
InvalidMetrics,
SourceBudget,
GraphemeBudget,
FontSystemUnavailable,
InvalidMeasurement,
InvalidPosition,
StaleZoom,
StaleRenderSession,
FontSetChanged,
}
struct Entry {
source: String,
style: Style,
zoom: f32,
width: f32,
label: Arc<Label>,
}
#[derive(Default)]
/// Two recent resolutions for ONE live labelled occurrence. A global small LRU
/// would thrash when a larger Scene is rebuilt in order. The integration owner
/// must retain these by occurrence identity and prune on removal/document change.
pub(super) struct Cache {
version: Option<graphics_text::Version>,
session: Option<Arc<()>>,
entries: VecDeque<Entry>,
#[cfg(test)]
allocations: usize,
}
impl Cache {
/// `width` excludes any caller-chosen backplate padding. Zero is valid.
/// Cache ownership must survive redraws; constructing one per Scene defeats
/// reuse. Changed fonts invalidate the SESSION: the caller must establish
/// a fresh renderer and a new settled session, not just remeasure this cache.
/// Error handling/fallback for these explicit experiment budgets is an
/// integration decision; never silently paint the old truncated contract.
pub fn resolve(
&mut self,
session: &RenderSession,
source: &str,
width: f32,
zoom: f32,
style: Style,
) -> Result<Arc<Label>, Error> {
let (size, line_height) = style.scaled(zoom);
if !width.is_finite()
|| width < 0.
|| !zoom.is_finite()
|| zoom <= 0.
|| !size.is_finite()
|| size <= 0.
|| !line_height.is_finite()
|| line_height <= 0.
|| !(width * zoom).is_finite()
|| !(size / zoom).is_finite()
|| !(line_height / zoom).is_finite()
{
return Err(Error::InvalidMetrics);
}
if source.len() > MAX_SOURCE_BYTES {
return Err(Error::SourceBudget);
}
let mut fonts = graphics_text::font_system()
.write()
.map_err(|_| Error::FontSystemUnavailable)?;
if session.version != fonts.version() {
return Err(Error::FontSetChanged);
}
if self.version != Some(fonts.version())
|| self
.session
.as_ref()
.is_none_or(|identity| !Arc::ptr_eq(identity, &session.identity))
{
self.entries.clear();
self.version = Some(fonts.version());
self.session = Some(session.identity.clone());
}
if let Some(index) = self.entries.iter().position(|entry| {
entry.source == source
&& entry.style == style
&& entry.zoom == zoom
&& entry.width == width
}) {
let entry = self.entries.remove(index).unwrap();
let label = entry.label.clone();
self.entries.push_back(entry);
return Ok(label);
}
let normalized = Normalized::new(source);
let cuts: Vec<usize> = normalized
.text
.grapheme_indices(true)
.map(|(i, _)| i)
.chain(std::iter::once(normalized.text.len()))
.collect();
if cuts.len().saturating_sub(1) > MAX_GRAPHEMES {
return Err(Error::GraphemeBudget);
}
// Candidate buffers are temporary. Only the resolved strings/metrics
// survive, bounded by MAX_ENTRIES; renderer caching remains independent.
let mut shapes = graphics_text::Cache::new();
let mut measure = |content: &str| -> Result<Size, Error> {
if content.is_empty() {
return Ok(Size::ZERO);
}
#[cfg(test)]
{
self.allocations += 1;
}
let (_, entry) = shapes.allocate(
fonts.raw(),
graphics_text::cache::Key {
content,
size,
line_height,
font: style.font,
bounds: Size::INFINITE,
shaping: style.shaping,
align_x: Alignment::Default,
},
);
if entry.buffer.layout_runs().count() != 1
|| !entry.min_bounds.width.is_finite()
|| !entry.min_bounds.height.is_finite()
{
return Err(Error::InvalidMeasurement);
}
world_size(entry.min_bounds, zoom)
};
let full = measure(&normalized.text)?;
let (displayed, retained, measured) = if full.width <= width {
(normalized.text.clone(), normalized.text.len(), full)
} else {
let ellipsis = measure("…")?;
if ellipsis.width > width {
(String::new(), 0, Size::ZERO)
} else {
// Every accepted candidate is actually shaped and fits. Search
// is bounded/logarithmic; contextual shaping is not universally
// monotone, so this does NOT promise the globally longest prefix.
// Full content is always checked first and never needlessly cut.
let mut best = ("…".to_owned(), 0, ellipsis);
let (mut low, mut high) = (1, cuts.len().saturating_sub(1));
while low < high {
let middle = low + (high - low) / 2;
let candidate = format!("{}…", &normalized.text[..cuts[middle]]);
let measured = measure(&candidate)?;
if measured.width <= width {
best = (candidate, cuts[middle], measured);
low = middle + 1;
} else {
high = middle;
}
}
best
}
};
let label = Arc::new(Label {
source: source.into(),
displayed,
retained_source: 0..normalized.source_end(retained),
bounds: Rectangle::with_size(measured),
style,
measured_session: session.clone(),
measured_zoom: zoom,
});
if self.entries.len() == MAX_ENTRIES {
self.entries.pop_front();
}
self.entries.push_back(Entry {
source: source.into(),
style,
zoom,
width,
label: label.clone(),
});
Ok(label)
}
}
fn world_size(scaled: Size, zoom: f32) -> Result<Size, Error> {
let world = Size::new(scaled.width / zoom, scaled.height / zoom);
if !world.width.is_finite()
|| !world.height.is_finite()
|| world.width < 0.
|| world.height < 0.
{
return Err(Error::InvalidMeasurement);
}
Ok(world)
}
fn translated_bounds(position: Point, size: Size) -> Result<Rectangle, Error> {
if !position.x.is_finite()
|| !position.y.is_finite()
|| !(position.x + size.width).is_finite()
|| !(position.y + size.height).is_finite()
{
return Err(Error::InvalidPosition);
}
Ok(Rectangle::new(position, size))
}
struct Normalized {
text: String,
ends: Vec<(usize, usize)>,
}
impl Normalized {
fn new(source: &str) -> Self {
let mut result = Self {
text: String::new(),
ends: vec![(0, 0)],
};
let mut separator = false;
for (start, c) in source.char_indices() {
if c.is_whitespace() {
separator = !result.text.is_empty();
continue;
}
if separator {
result.text.push(' ');
result.ends.push((result.text.len(), start));
separator = false;
}
result.text.push(c);
result.ends.push((result.text.len(), start + c.len_utf8()));
}
result
}
fn source_end(&self, normalized_end: usize) -> usize {
self.ends
.binary_search_by_key(&normalized_end, |(end, _)| *end)
.map(|index| self.ends[index].1)
.expect("grapheme cuts are character boundaries")
}
}
#[cfg(test)]
#[path = "scenario_label/tests.rs"]
mod tests;
@@ -1,231 +0,0 @@
//! Unwired corridor-policy candidate for the measured scenario-label experiment.
//!
//! The caller supplies a finite structural label band AFTER endpoint/activation
//! alignment, with padding already removed from its edges. Blockers include
//! padded activation/card/header bounds over the complete text band, including
//! stroke/ink clearance. This function does not infer a self-message corridor,
//! alter scene extents, change zoom, or establish raster-ink containment.
use iced::Rectangle;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) struct InvalidGeometry;
fn edges(rect: Rectangle) -> Result<(f32, f32, f32, f32), InvalidGeometry> {
let right = rect.x + rect.width;
let bottom = rect.y + rect.height;
if [rect.x, rect.y, rect.width, rect.height, right, bottom]
.into_iter()
.any(|value| !value.is_finite())
|| rect.width < 0.
|| rect.height < 0.
{
return Err(InvalidGeometry);
}
Ok((rect.x, right, rect.y, bottom))
}
/// Select one connected clear interval; never assemble text across blockers.
/// Prefer greater width, then proximity to the previous label anchor, then the
/// leftmost interval. Input order has no influence. A zero-area band has no
/// corridor. Touching the padded vertical band is conservatively obstructing;
/// a zero-area blocker has no area and must receive stroke padding upstream.
pub(super) fn widest_clear(
band: Rectangle,
blockers: &[Rectangle],
preferred_anchor: f32,
) -> Result<Option<Rectangle>, InvalidGeometry> {
let (left, right, top, bottom) = edges(band)?;
if !preferred_anchor.is_finite() {
return Err(InvalidGeometry);
}
let mut blocked = Vec::new();
for &blocker in blockers {
let (x0, x1, y0, y1) = edges(blocker)?;
if blocker.width > 0. && blocker.height > 0. && y1 >= top && y0 <= bottom {
let interval = (x0.max(left), x1.min(right));
if interval.0 < interval.1 {
blocked.push(interval);
}
}
}
if band.width == 0. || band.height == 0. {
return Ok(None);
}
blocked.sort_by(|a, b| a.0.total_cmp(&b.0).then(a.1.total_cmp(&b.1)));
let mut best: Option<Rectangle> = None;
let mut consider = |start: f32, end: f32| {
if start >= end {
return;
}
let candidate = Rectangle {
x: start,
width: contained_width(start, end),
..band
};
// Use f64 only for comparison distance: subtracting two admitted f32
// world coordinates can overflow even though each rectangle is finite.
let distance = |x: f32| (f64::from(x) - f64::from(preferred_anchor)).abs();
if best.is_none_or(|old| {
candidate.width > old.width
|| (candidate.width == old.width
&& (distance(candidate.x) < distance(old.x)
|| (distance(candidate.x) == distance(old.x) && candidate.x < old.x)))
}) {
best = Some(candidate);
}
};
let mut cursor = left;
for (start, end) in blocked {
consider(cursor, start);
cursor = cursor.max(end);
}
consider(cursor, right);
Ok(best)
}
/// Finite f32 endpoints can subtract to infinity or round across a blocker.
/// Compute in f64, cap to finite f32, and round inward if nearest rounding
/// overshot the admitted interval's end.
fn contained_width(start: f32, end: f32) -> f32 {
let exact = f64::from(end) - f64::from(start);
let mut width = exact.min(f64::from(f32::MAX)) as f32;
if f64::from(start) + f64::from(width) > f64::from(end) {
width = width.next_down();
}
width
}
#[cfg(test)]
mod tests {
use super::*;
fn rect(x: f32, y: f32, width: f32, height: f32) -> Rectangle {
Rectangle {
x,
y,
width,
height,
}
}
#[test]
fn long_message_does_not_cross_an_intermediate_execution() {
let band = rect(20., 100., 700., 22.);
let blockers = [rect(250., 80., 16., 80.), rect(490., 90., 28., 140.)];
let result = widest_clear(band, &blockers, band.x).unwrap().unwrap();
assert_eq!(result, rect(20., 100., 230., 22.));
for blocker in blockers {
assert!(result.x + result.width <= blocker.x || result.x >= blocker.x + blocker.width);
}
assert_eq!(widest_clear(band, &[], band.x).unwrap(), Some(band));
}
#[test]
fn overlapping_unsorted_blockers_merge_and_widest_gap_wins() {
let band = rect(-100., 10., 600., 20.);
let blockers = [
rect(50., 0., 80., 60.),
rect(0., 5., 70., 30.),
rect(450., 0., 100., 60.),
];
let expected = Some(rect(130., 10., 320., 20.));
assert_eq!(widest_clear(band, &blockers, -100.).unwrap(), expected);
let mut reversed = blockers;
reversed.reverse();
assert_eq!(widest_clear(band, &reversed, -100.).unwrap(), expected);
}
#[test]
fn complete_padded_band_and_touching_boundaries_are_obstructed() {
let band = rect(0., 100., 200., 20.);
// The first blocker ends at the band top: checking only a baseline
// would miss it. The second starts at the band bottom.
let blockers = [rect(0., 70., 60., 30.), rect(150., 120., 50., 30.)];
assert_eq!(
widest_clear(band, &blockers, 0.).unwrap(),
Some(rect(60., 100., 90., 20.))
);
let outside = [rect(0., 70., 200., 29.), rect(0., 121., 200., 30.)];
assert_eq!(widest_clear(band, &outside, 0.).unwrap(), Some(band));
}
#[test]
fn ties_keep_the_nearest_anchor_then_choose_leftmost() {
let band = rect(0., 0., 300., 20.);
let blockers = [rect(100., 0., 100., 20.)];
assert_eq!(
widest_clear(band, &blockers, 190.).unwrap(),
Some(rect(200., 0., 100., 20.))
);
assert_eq!(
widest_clear(band, &blockers, 100.).unwrap(),
Some(rect(0., 0., 100., 20.))
);
}
#[test]
fn caller_defined_self_and_reversed_corridors_keep_their_geometry() {
// Direction and self-loop policy are explicit caller inputs. Neither
// a zero endpoint gap nor screen width can silently supply a corridor.
for band in [rect(420., 40., 180., 22.), rect(-400., 90., 350., 22.)] {
assert_eq!(widest_clear(band, &[], band.x).unwrap(), Some(band));
}
assert_eq!(
widest_clear(rect(420., 40., 0., 22.), &[], 420.).unwrap(),
None
);
assert_eq!(
widest_clear(rect(420., 40., 180., 0.), &[], 420.).unwrap(),
None
);
assert_eq!(
widest_clear(rect(0., 0., 100., 20.), &[rect(-10., -10., 200., 50.)], 0.).unwrap(),
None
);
}
#[test]
fn invalid_or_overflowing_geometry_never_enters_a_corridor() {
let band = rect(0., 0., 100., 20.);
for invalid in [
rect(f32::NAN, 0., 10., 20.),
rect(0., 0., -1., 20.),
rect(f32::MAX, 0., f32::MAX, 20.),
rect(0., f32::MAX, 10., f32::MAX),
] {
assert_eq!(widest_clear(invalid, &[], 0.), Err(InvalidGeometry));
assert_eq!(widest_clear(band, &[invalid], 0.), Err(InvalidGeometry));
}
assert_eq!(widest_clear(band, &[], f32::INFINITY), Err(InvalidGeometry));
}
#[test]
fn finite_band_cannot_reconstruct_an_infinite_width() {
let band = rect(f32::from_bits(0xfe79636e), 0., f32::MAX, 20.);
let right = band.x + band.width;
assert!(right.is_finite());
assert!(
(right - band.x).is_infinite(),
"counterexample must exercise f32 subtraction overflow"
);
let result = widest_clear(band, &[], 0.).unwrap().unwrap();
assert!(result.width.is_finite() && result.width > 0.);
assert!(result.x + result.width <= right);
assert!(f64::from(result.x) + f64::from(result.width) <= f64::from(right));
}
#[test]
fn reconstructed_width_rounds_inward_at_a_blocker() {
let band = rect(-999_948.562_5, 0., 1_000_000., 20.);
let blocker = rect(2.1, 0., 10., 20.);
assert!(
band.x + (blocker.x - band.x) > blocker.x,
"counterexample must cross the blocker under naive reconstruction"
);
let result = widest_clear(band, &[blocker], 0.).unwrap().unwrap();
assert_eq!(result.x, band.x);
assert!(result.width > 999_000.);
assert!(result.x + result.width <= blocker.x);
assert!(f64::from(result.x) + f64::from(result.width) <= f64::from(blocker.x));
}
}
@@ -1,487 +0,0 @@
//! Unexecuted evaluator candidates; the module is intentionally not wired yet.
use super::*;
use iced::advanced::{
graphics::text::Paragraph,
text::{Paragraph as _, Text as ParagraphText, Wrapping},
};
// Independent paragraph construction: no candidate cache, normalization, search,
// or character-width estimate is used by this measurement oracle.
fn paragraph_size(content: &str, zoom: f32, style: Style) -> Size {
let size = Pixels(style.size.0 * zoom);
let line_height = match style.line_height {
LineHeight::Relative(factor) => LineHeight::Relative(factor),
LineHeight::Absolute(pixels) => LineHeight::Absolute(Pixels(pixels.0 * zoom)),
};
let paragraph = Paragraph::with_text(ParagraphText {
content,
bounds: Size::INFINITE,
size,
line_height,
font: style.font,
align_x: Alignment::Default,
align_y: iced::alignment::Vertical::Top,
shaping: style.shaping,
wrapping: Wrapping::default(),
});
assert_eq!(paragraph.buffer().layout_runs().count(), 1);
Size::new(paragraph.min_width() / zoom, paragraph.min_height() / zoom)
}
#[test]
fn adequate_corridor_reveals_full_realistic_names_and_distinguishing_suffixes() {
let mut cache = Cache::default();
let session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
let style = Style::default();
for text in [
"1. call · Request valve repair · Technician service conversation",
"2. reply · Return pressure after repair · Technician service conversation",
"1. call · Request redundant hydraulic pressure calibration channel A",
"1. call · Request redundant hydraulic pressure calibration channel B",
] {
let wide = cache.resolve(&session, text, 1600., 1., style).unwrap();
assert_eq!(wide.displayed(&session, 1.).unwrap(), text);
assert_eq!(wide.retained_source(&session, 1.).unwrap(), 0..text.len());
assert_eq!(wide.bounds.size(), paragraph_size(text, 1., style));
let narrow = cache.resolve(&session, text, 80., 1., style).unwrap();
assert_ne!(narrow.displayed, text);
assert!(narrow.bounds.width <= 80.);
assert_eq!(
narrow.bounds.size(),
paragraph_size(&narrow.displayed, 1., style)
);
}
}
#[test]
fn normalized_visible_prefix_maps_to_original_bytes_without_claiming_hidden_operation() {
let raw = " \t1. call · Pump\r\n · Operation: Need\u{301}le ";
let expected = "1. call · Pump · Operation: Need\u{301}le";
let n = Normalized::new(raw);
assert_eq!(n.text, expected);
let prefix = "1. call · Pump ";
assert_eq!(n.source_end(prefix.len()), raw.find("· Operation").unwrap());
let mut cache = Cache::default();
let session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
let wide = cache
.resolve(&session, raw, 1600., 1., Style::default())
.unwrap();
assert_eq!(wide.displayed, expected);
assert_eq!(wide.retained_source.end, raw.trim_end().len());
let field = raw.find("Need").unwrap()..raw.trim_end().len();
assert!(wide.retains_any(&session, 1., field.clone()).unwrap());
assert!(
!wide.retains_any(&session, 1., 0..2).unwrap(),
"normalized whitespace is not a visible field"
);
let small = cache
.resolve(&session, raw, 70., 1., Style::default())
.unwrap();
assert!(!small.retains_any(&session, 1., field).unwrap());
assert!(small.retained_source.end < raw.find("Operation").unwrap());
}
#[test]
fn grapheme_prefixes_and_zoom_match_actual_paragraph_layout() {
let text = "call · 戻り値 圧力 e\u{301} 👩‍🔬 safety valve verification";
let mut cache = Cache::default();
let session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
for zoom in [0.66, 1., 1.75] {
let label = cache
.resolve(&session, text, 105., zoom, Style::default())
.unwrap();
assert!(label.bounds.width <= 105.);
assert_eq!(
label.bounds.size(),
paragraph_size(&label.displayed, zoom, Style::default())
);
assert!(
text.grapheme_indices(true)
.any(|(i, _)| i == label.retained_source.end)
|| label.retained_source.end == text.len()
);
let painted = label
.canvas_text(&session, zoom, Point::new(400., 90.), Color::BLACK)
.unwrap();
assert_eq!(painted.content, label.displayed);
assert!(painted.max_width.is_infinite());
assert_eq!(
label
.bounds_at(&session, zoom, painted.position)
.unwrap()
.size(),
label.bounds.size()
);
}
}
#[test]
fn empty_and_too_narrow_labels_never_claim_source_visibility() {
let mut cache = Cache::default();
let session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
for text in ["", " \t\n", "Operation: Pump"] {
let label = cache
.resolve(&session, text, 0., 1., Style::default())
.unwrap();
assert_eq!(label.displayed, "");
assert_eq!(label.retained_source, 0..0);
assert_eq!(label.bounds.size(), Size::ZERO);
assert!(!label.retains_any(&session, 1., 0..text.len()).unwrap());
}
}
#[test]
fn redraws_reuse_resolution_but_width_zoom_and_style_are_dependencies() {
let mut cache = Cache::default();
let session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
let text = "call · Pressure verification with a descriptive operation label";
let style = Style::default();
let a = cache.resolve(&session, text, 100., 1., style).unwrap();
let measured = cache.allocations;
let b = cache.resolve(&session, text, 100., 1., style).unwrap();
assert!(Arc::ptr_eq(&a, &b));
assert_eq!(cache.allocations, measured);
for (width, zoom, style) in [
(120., 1., style),
(100., 0.66, style),
(
100.,
1.,
Style {
size: Pixels(14.),
..style
},
),
] {
let other = cache.resolve(&session, text, width, zoom, style).unwrap();
assert!(!Arc::ptr_eq(&a, &other));
}
}
#[test]
fn resident_entry_is_remeasured_for_a_fresh_settled_render_session() {
let mut cache = Cache::default();
let old_session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
let text = "call · Pressure verification";
let a = cache
.resolve(&old_session, text, 100., 1., Style::default())
.unwrap();
assert_eq!(cache.entries.len(), 1);
assert!(Arc::ptr_eq(&cache.entries.front().unwrap().label, &a));
let measured = cache.allocations;
assert!(measured > 0);
assert!(Arc::ptr_eq(
&a,
&cache
.resolve(&old_session, text, 100., 1., Style::default())
.unwrap()
));
assert_eq!(cache.allocations, measured);
// A fresh renderer session invalidates a still-resident entry. This does
// not mutate global fonts or purport to test dynamic WGPU font loading.
let new_session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
let refreshed = cache
.resolve(&new_session, text, 100., 1., Style::default())
.unwrap();
assert!(!Arc::ptr_eq(&a, &refreshed));
assert!(cache.allocations > measured);
assert_eq!(cache.entries.len(), 1);
assert!(Arc::ptr_eq(
&cache.entries.front().unwrap().label,
&refreshed
));
}
#[test]
fn resident_entry_remeasures_after_local_font_epoch_invalidation() {
let mut cache = Cache::default();
let session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
let text = "call · Resident pressure label";
let a = cache
.resolve(&session, text, 100., 1., Style::default())
.unwrap();
assert_eq!(cache.entries.len(), 1);
assert!(Arc::ptr_eq(&cache.entries.front().unwrap().label, &a));
let measured = cache.allocations;
assert!(measured > 0);
// Exercise the local memo epoch branch while a is still resident. No
// intervening width/zoom/style resolution can cause incidental eviction.
// This is not an actual font-load or renderer-cache acceptance test.
cache.version = None;
let refreshed = cache
.resolve(&session, text, 100., 1., Style::default())
.unwrap();
assert!(!Arc::ptr_eq(&a, &refreshed));
assert!(cache.allocations > measured);
assert_eq!(cache.entries.len(), 1);
assert!(Arc::ptr_eq(
&cache.entries.front().unwrap().label,
&refreshed
));
}
#[test]
fn retained_artifact_rejects_other_zoom_and_render_session_for_every_consumer() {
let mut cache = Cache::default();
let session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
let label = cache
.resolve(&session, "Operation: Pressure", 200., 1., Style::default())
.unwrap();
let other = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
for (context, zoom, expected) in [
(&session, 0.66, Error::StaleZoom),
(&other, 1., Error::StaleRenderSession),
] {
assert_eq!(label.displayed(context, zoom).unwrap_err(), expected);
assert_eq!(label.retained_source(context, zoom).unwrap_err(), expected);
assert_eq!(
label.retains_any(context, zoom, 0..9).unwrap_err(),
expected
);
assert_eq!(
label.bounds_at(context, zoom, Point::ORIGIN).unwrap_err(),
expected
);
assert_eq!(
label
.canvas_text(context, zoom, Point::ORIGIN, Color::BLACK)
.unwrap_err(),
expected
);
}
assert!(
label
.canvas_text(&session, 1., Point::ORIGIN, Color::BLACK)
.is_ok()
);
}
#[test]
fn finite_scaled_metrics_cannot_escape_as_infinite_world_dimensions() {
let mut cache = Cache::default();
let session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
let style = Style {
size: Pixels(2.),
line_height: LineHeight::Relative(f32::MAX),
..Style::default()
};
// At zoom 0.5 the scaled size=1 and line height=MAX are finite. The
// required world line height is not; reject before passing it to shaping.
let scaled = style.scaled(0.5);
assert!(scaled.0.is_finite() && scaled.1.is_finite());
assert_eq!(
cache
.resolve(&session, "call", 100., 0.5, style)
.unwrap_err(),
Error::InvalidMetrics
);
assert_eq!(cache.allocations, 0);
assert!(cache.entries.is_empty());
// Independently exercise validation after division for actual measured
// dimensions: finite scaled width/height do not imply finite world values.
assert_eq!(
world_size(Size::new(f32::MAX, 1.), 0.5),
Err(Error::InvalidMeasurement)
);
assert_eq!(
world_size(Size::new(1., f32::MAX), 0.5),
Err(Error::InvalidMeasurement)
);
assert_eq!(
world_size(Size::new(20., 8.), 0.5).unwrap(),
Size::new(40., 16.)
);
}
#[test]
fn malformed_metrics_and_explicit_experiment_budgets_fail_without_a_cached_candidate() {
let mut cache = Cache::default();
let session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
for (width, zoom) in [(f32::NAN, 1.), (-1., 1.), (100., 0.), (100., f32::INFINITY)] {
assert_eq!(
cache
.resolve(&session, "call", width, zoom, Style::default())
.unwrap_err(),
Error::InvalidMetrics
);
}
assert_eq!(
cache
.resolve(
&session,
&"x".repeat(MAX_SOURCE_BYTES + 1),
100.,
1.,
Style::default()
)
.unwrap_err(),
Error::SourceBudget
);
assert_eq!(
cache
.resolve(
&session,
&"x".repeat(MAX_GRAPHEMES + 1),
100.,
1.,
Style::default()
)
.unwrap_err(),
Error::GraphemeBudget
);
assert!(cache.entries.is_empty());
}
#[test]
fn bounds_translation_rejects_nonfinite_world_positions_and_endpoints() {
let mut cache = Cache::default();
let session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
let label = cache
.resolve(&session, "call", 100., 1., Style::default())
.unwrap();
for position in [Point::new(f32::NAN, 0.), Point::new(0., f32::INFINITY)] {
assert_eq!(
label.bounds_at(&session, 1., position).unwrap_err(),
Error::InvalidPosition
);
assert_eq!(
label
.canvas_text(&session, 1., position, Color::BLACK)
.unwrap_err(),
Error::InvalidPosition
);
}
assert_eq!(
translated_bounds(Point::new(f32::MAX, 0.), Size::new(f32::MAX, 1.)),
Err(Error::InvalidPosition)
);
assert_eq!(
translated_bounds(Point::new(0., f32::MAX), Size::new(1., f32::MAX)),
Err(Error::InvalidPosition)
);
assert_eq!(
label
.bounds_at(&session, 1., Point::new(-20., -10.))
.unwrap()
.position(),
Point::new(-20., -10.)
);
}
/// Must run as the ONLY selected test in a dedicated, fresh test process.
/// The ignored marker keeps this global-font mutation out of normal parallel
/// desktop tests. See the evidence directory's exact executable runner.
#[test]
#[ignore = "mutates the global font database; run only this exact test in a fresh process"]
fn actual_font_load_rejects_retained_label_and_old_session() {
use graphics_text::cosmic_text::fontdb;
use std::borrow::Cow;
let mut cache = Cache::default();
let session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
let source = "Operation: Pressure";
let label = cache
.resolve(&session, source, 200., 1., Style::default())
.unwrap();
assert_eq!(label.displayed(&session, 1.).unwrap(), source);
assert!(
label
.canvas_text(&session, 1., Point::ORIGIN, Color::BLACK)
.is_ok()
);
let measured = cache.allocations;
assert!(measured > 0);
assert!(Arc::ptr_eq(&cache.entries.front().unwrap().label, &label));
let (version_before, version_after, faces_before, faces_after) = {
let mut fonts = graphics_text::font_system().write().unwrap();
let face = fonts
.raw()
.db()
.faces()
.find(|face| {
face.families
.iter()
.any(|(family, _)| family == "Iced-Icons")
})
.expect("Iced 0.14 embeds and loads its known Iced-Icons.ttf face")
.id;
let bytes = fonts
.raw()
.db()
.with_face_data(face, |data, _| data.to_vec())
.expect("the built-in binary font has accessible bytes");
assert!(!bytes.is_empty());
// load_font increments Version even for invalid bytes. Independently
// establish actual face admission so this cannot pass with invalid data.
let mut probe = fontdb::Database::new();
probe.load_font_data(bytes.clone());
let admitted = probe
.faces()
.filter(|face| {
face.families
.iter()
.any(|(family, _)| family == "Iced-Icons")
})
.count();
assert!(
admitted > 0,
"the extracted bytes parse as the expected real font"
);
let before = fonts.version();
assert_eq!(before, session.version);
let count = fonts.raw().db().faces().count();
// Owned bytes intentionally bypass Borrowed-address deduplication.
// This is an actual valid duplicate-face load, not a claim the chosen
// face or rendered metrics change; the stale-epoch rule is the oracle.
fonts.load_font(Cow::Owned(bytes));
(
before,
fonts.version(),
count,
fonts.raw().db().faces().count(),
)
};
assert_ne!(version_before, version_after);
assert!(
faces_after > faces_before,
"actual font admission must accompany the version increment"
);
assert_eq!(
label.displayed(&session, 1.).unwrap_err(),
Error::FontSetChanged
);
assert_eq!(
label.retained_source(&session, 1.).unwrap_err(),
Error::FontSetChanged
);
assert_eq!(
label.retains_any(&session, 1., 0..9).unwrap_err(),
Error::FontSetChanged
);
assert_eq!(
label.bounds_at(&session, 1., Point::ORIGIN).unwrap_err(),
Error::FontSetChanged
);
assert_eq!(
label
.canvas_text(&session, 1., Point::ORIGIN, Color::BLACK)
.unwrap_err(),
Error::FontSetChanged
);
assert_eq!(
cache
.resolve(&session, source, 200., 1., Style::default())
.unwrap_err(),
Error::FontSetChanged
);
assert_eq!(
cache.allocations, measured,
"an invalid session must not remeasure and imply renderer repair"
);
println!(
"Valid Iced-Icons load: faces {faces_before}->{faces_after}, font Version {version_before:?}->{version_after:?}; old label and session rejected"
);
// Do not create a fresh session or claim dynamic WGPU cache recovery here.
// Process exit is the isolation boundary; no other test may run afterward.
}
@@ -1,816 +0,0 @@
//! Pure complete Scenario geometry. No persistence, gesture or fallback policy.
//! The only renderer is Scene::sequence_geometry; this module resolves its inputs.
use super::*;
use oa_core::{DiagramLayout, ScenarioLayout};
#[cfg(test)]
mod tests;
pub(crate) mod presentation;
type Candidate = BTreeMap<Id, f32>;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum AnchorRole {
MessageSend,
MessageReceive,
Observation,
FrameStart,
OperandStart,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub(super) struct Anchor {
pub(super) y: f32,
role: AnchorRole,
}
#[derive(Clone, Copy, Debug, PartialEq)]
struct MessageAnchors {
send_y: f32,
receive_y: f32,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) enum Diagnostic {
SourceOrderCrossing { scope: Id, before: Id, after: Id },
FrameExtendsPastFollowingAnchor { frame: Id, following: Id },
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) enum GeometryIssue {
UnknownAnchor(Id),
LifecycleGeometryNotSupported { lifeline: Id, scope: Id },
InvalidRepresentation(Id),
InvalidCoordinate(Id),
DuplicateCoordinate(Id),
ExpandedAddressNotSupported(Id),
CoincidentDistinctEndpoints(Id),
TypedActivationOutsideScope { call: Id, scope: Id },
TypedActivationOutsideLifetime { call: Id, receiver: Id },
TypedActivationCrossingNotSupported { call: Id, event: Id },
NonFinite(Id),
CompoundMoveNotImplemented(Id),
FixedFrameHeaderWouldMove { frame: Id },
OperandScopeOutsideEnclosingInterval { operand: Id, fragment: Id },
LifecycleAnchorOutsideRenderScope { occurrence: Id, scope: Id },
LifetimeInversionNotSupported { create: Id, delete: Id },
}
#[derive(Clone, Debug)]
pub(super) struct Plan {
pub(super) anchors: BTreeMap<Id, Anchor>,
messages: BTreeMap<Id, MessageAnchors>,
pub(super) frames: BTreeMap<Id, Rectangle>,
pub(super) scopes: BTreeMap<Id, (f32, f32)>,
pub(super) height: f32,
pub(super) diagnostics: Vec<Diagnostic>,
}
pub(super) fn occurrence(p: &Project, id: Id) -> Option<&OccurrenceBody> {
match p.elements.get(&id)?.shared()? {
SharedBody::Occurrence { body, .. } => Some(body),
_ => None,
}
}
fn role(body: &OccurrenceBody) -> AnchorRole {
match body {
OccurrenceBody::Exchange {
message_kind: MessageKind::Create | MessageKind::Delete,
..
} => AnchorRole::MessageReceive,
OccurrenceBody::Exchange { .. } => AnchorRole::MessageSend,
OccurrenceBody::Fragment { .. } => AnchorRole::FrameStart,
OccurrenceBody::Branch { .. } => AnchorRole::OperandStart,
_ => AnchorRole::Observation,
}
}
fn inside(p: &Project, id: Id, owner: Id) -> bool {
let mut current = p.elements.get(&id).and_then(|e| e.parent);
for _ in 0..p.elements.len() {
match current {
Some(parent) if parent == owner => return true,
Some(parent) => current = p.elements.get(&parent).and_then(|e| e.parent),
None => return false,
}
}
false
}
fn depth(p: &Project, id: Id) -> usize {
let mut current = p.elements.get(&id).and_then(|e| e.parent);
let mut result = 0;
while let Some(parent) = current {
result += 1;
if result > p.elements.len() {
break;
}
current = p.elements.get(&parent).and_then(|e| e.parent);
}
result
}
pub(in crate::behavior) fn union(a: Rectangle, b: Rectangle) -> Rectangle {
let x = a.x.min(b.x);
let y = a.y.min(b.y);
Rectangle::new(
Point::new(x, y),
Size::new(
(a.x + a.width).max(b.x + b.width) - x,
(a.y + a.height).max(b.y + b.height) - y,
),
)
}
/// Actual named shapes/message paths supply footprint measurements; activation
/// bars are intentionally excluded because current Scene derives them later.
fn footprints(
scene: &Scene,
anchors: &BTreeMap<Id, Anchor>,
seed: &BTreeMap<Id, Anchor>,
) -> BTreeMap<Id, Rectangle> {
let mut out = BTreeMap::new();
for item in scene.boxes.iter().filter(|item| !item.title.is_empty()) {
let Some(anchor) = anchors.get(&item.id) else {
continue;
};
let mut rect = item.rect;
rect.y += anchor.y - seed[&item.id].y;
out.insert(item.id, rect);
}
for line in &scene.lines {
let Some(anchor) = anchors.get(&line.id) else {
continue;
};
let delta = anchor.y - seed[&line.id].y;
let mut rect = line.label_bounds();
rect.y += delta;
for point in &line.points {
rect = union(
rect,
Rectangle::new(Point::new(point.x, point.y + delta), Size::ZERO),
);
}
out.entry(line.id)
.and_modify(|old| *old = union(*old, rect))
.or_insert(rect);
}
out
}
fn include_ancestors(p: &Project, id: Id, rect: Rectangle, covered: &mut BTreeMap<Id, Rectangle>) {
let mut current = p.elements.get(&id).and_then(|e| e.parent);
for _ in 0..p.elements.len() {
let Some(parent) = current else {
break;
};
covered
.entry(parent)
.and_modify(|old| *old = union(*old, rect))
.or_insert(rect);
current = p.elements.get(&parent).and_then(|e| e.parent);
}
}
fn coverage(p: &Project, footprints: &BTreeMap<Id, Rectangle>) -> BTreeMap<Id, Rectangle> {
let mut covered = BTreeMap::new();
for (&id, &rect) in footprints {
include_ancestors(p, id, rect, &mut covered);
}
covered
}
impl Plan {
/// Authored endpoint values are primary inputs, not deltas reconstructed from
/// rounded rows. Lifecycle alignment owns x and marker attachment; this final
/// y alignment precedes typed activation derivation and preserves send bits.
pub(super) fn align_message_anchors(&self, lines: &mut [Line]) {
for line in lines {
let Some(message) = self.messages.get(&line.id) else {
continue;
};
let previous_send = line.points.first().map_or(message.send_y, |point| point.y);
line.label_at.y += message.send_y - previous_send;
let last = line.points.len().saturating_sub(1);
for (index, point) in line.points.iter_mut().enumerate() {
point.y = if index == last || (last == 3 && index == 2) {
message.receive_y
} else {
message.send_y
};
}
}
}
fn build(
p: &Project,
owner: Id,
baseline: &Scene,
desired: &Candidate,
) -> Result<Self, GeometryIssue> {
let mut seed: BTreeMap<_, _> = baseline
.rows
.iter()
.map(|(id, (_, y))| {
(
*id,
Anchor {
y: *y,
role: role(occurrence(p, *id).expect("Scene occurrence")),
},
)
})
.collect();
// Operand headers are real Scene boxes, but are not entries in rows.
for item in &baseline.boxes {
if matches!(occurrence(p, item.id), Some(OccurrenceBody::Branch { .. })) {
seed.insert(
item.id,
Anchor {
y: item.rect.y,
role: AnchorRole::OperandStart,
},
);
}
}
let mut anchors = seed.clone();
for (&id, &y) in desired {
let anchor = anchors
.get_mut(&id)
.ok_or(GeometryIssue::UnknownAnchor(id))?;
if !y.is_finite() {
return Err(GeometryIssue::NonFinite(id));
}
if matches!(
anchor.role,
AnchorRole::FrameStart | AnchorRole::OperandStart
) && y != anchor.y
{
return Err(GeometryIssue::CompoundMoveNotImplemented(id));
}
anchor.y = y;
}
let mut messages = BTreeMap::new();
for line in &baseline.lines {
if let (Some(anchor), Some(first), Some(last)) = (
anchors.get(&line.id),
line.points.first(),
line.points.last(),
) {
let message = if desired.contains_key(&line.id) {
let self_message = matches!(occurrence(p, line.id), Some(OccurrenceBody::Exchange {
sender: Some(sender), receiver, message_kind, ..
}) if matches!(message_kind, MessageKind::Timer { .. }) || *receiver == Some(*sender));
let lifecycle = matches!(
occurrence(p, line.id),
Some(OccurrenceBody::Exchange {
message_kind: MessageKind::Create | MessageKind::Delete,
..
})
);
if self_message && lifecycle {
MessageAnchors {
send_y: anchor.y - 24.,
receive_y: anchor.y,
}
} else {
MessageAnchors {
send_y: anchor.y,
receive_y: if self_message {
anchor.y + 24.
} else {
anchor.y
},
}
}
} else {
MessageAnchors {
send_y: first.y,
receive_y: last.y,
}
};
messages.insert(line.id, message);
}
}
// Track actual semantic lifecycle order per receiver and scope. In particular,
// a later recreation must not be compared with an earlier deletion.
for element in p
.elements
.values()
.filter(|e| e.id == owner || inside(p, e.id, owner))
{
let Some(steps) = element.steps() else {
continue;
};
let mut last = BTreeMap::new();
for id in steps {
if let (
Some(message),
Some(OccurrenceBody::Exchange {
receiver: Some(lane),
message_kind: MessageKind::Create | MessageKind::Delete,
..
}),
) = (messages.get(id), occurrence(p, *id))
&& let Some((previous, previous_y)) =
last.insert(*lane, (*id, message.receive_y))
&& previous_y > message.receive_y
{
return Err(GeometryIssue::LifetimeInversionNotSupported {
create: previous,
delete: *id,
});
}
}
}
let old_footprints = footprints(baseline, &seed, &seed);
let mut drawn = footprints(baseline, &anchors, &seed);
let old_coverage = coverage(p, &old_footprints);
let mut new_coverage = coverage(p, &drawn);
let mut frames = BTreeMap::new();
let mut containers: Vec<_> = seed
.keys()
.copied()
.filter(|id| matches!(occurrence(p, *id), Some(OccurrenceBody::Fragment { .. })))
.collect();
containers.sort_by_key(|id| std::cmp::Reverse(depth(p, *id)));
for id in containers {
let original = old_footprints[&id];
let mut rect = original;
if let Some(covered) = new_coverage.get(&id).copied() {
// Preserve automatic extents and their existing descendant padding.
// Upward envelope growth would move the painted frame header
// while its fixed FrameStart/row/slot remained at the seed.
// Reject that unsupported compound change, not mere overlap.
if covered.y < rect.y {
return Err(GeometryIssue::FixedFrameHeaderWouldMove { frame: id });
}
let old_covered = old_coverage[&id];
let padding =
(original.y + original.height - old_covered.y - old_covered.height).max(0.);
// Preserve padding before the footprint itself reaches the old
// frame edge: operand scopes already retain their own padding.
rect.height = rect
.height
.max(covered.y + covered.height + padding - rect.y);
}
frames.insert(id, rect);
drawn.insert(id, rect);
include_ancestors(p, id, rect, &mut new_coverage);
}
let mut scopes = baseline.sequence_bounds.clone();
for (&scope, bounds) in &mut scopes {
if let (Some(old), Some(new)) = (
old_coverage.get(&scope).copied(),
new_coverage.get(&scope).copied(),
) {
let top_padding = (old.y - bounds.0).max(0.);
let bottom_padding = (bounds.1 - old.y - old.height).max(0.);
if new.y < bounds.0 {
bounds.0 = new.y - top_padding;
}
bounds.1 = bounds.1.max(new.y + new.height + bottom_padding);
}
}
let tail = baseline.size.height - baseline.sequence_bounds[&owner].1;
let height = baseline.size.height.max(scopes[&owner].1 + tail);
for (&operand, &(start, end)) in &scopes {
if !matches!(occurrence(p, operand), Some(OccurrenceBody::Branch { .. })) {
continue;
}
let fragment = p.elements[&operand].parent.expect("operand fragment");
let caller = p.elements[&fragment]
.parent
.expect("fragment caller sequence");
let enclosing_frame = frames[&fragment];
let (caller_start, caller_end) = if caller == owner {
(85., height - 20.)
} else {
let (a, b) = scopes[&caller];
(a, if a == b { a + 26. } else { b })
};
let enclosing_start = anchors[&fragment].y.max(caller_start);
let enclosing_end = (enclosing_frame.y + enclosing_frame.height).min(caller_end);
let effective_end = if start == end { start + 26. } else { end };
// Actual lifecycle traversal cannot enter an operand that escapes
// this enclosing interval. This guards bridge feasibility only;
// it is not a generic event-spacing or sibling-ordering policy.
if start < enclosing_start || effective_end > enclosing_end {
return Err(GeometryIssue::OperandScopeOutsideEnclosingInterval {
operand,
fragment,
});
}
}
for (&id, message) in &messages {
if matches!(
occurrence(p, id),
Some(OccurrenceBody::Exchange {
message_kind: MessageKind::Create | MessageKind::Delete,
..
})
) {
let scope = p.elements[&id].parent.expect("lifecycle sequence owner");
// Match Rendering::new's effective scope, not the insertion
// scope: it overrides the root span to 85..height-20 and gives
// empty non-root scopes the existing 26px lifetime allowance.
let (start, end) = if scope == owner {
(85., height - 20.)
} else if let Some(&(start, end)) = scopes.get(&scope) {
(start, if start == end { start + 26. } else { end })
} else {
return Err(GeometryIssue::LifecycleAnchorOutsideRenderScope {
occurrence: id,
scope,
});
};
if message.receive_y < start || message.receive_y > end {
return Err(GeometryIssue::LifecycleAnchorOutsideRenderScope {
occurrence: id,
scope,
});
}
}
}
let mut diagnostics = Vec::new();
for element in p
.elements
.values()
.filter(|e| e.id == owner || inside(p, e.id, owner))
{
if let Some(steps) = element.steps() {
for pair in steps.windows(2) {
if let (Some(a), Some(b)) = (anchors.get(&pair[0]), anchors.get(&pair[1])) {
if a.y > b.y {
diagnostics.push(Diagnostic::SourceOrderCrossing {
scope: element.id,
before: pair[0],
after: pair[1],
});
}
if let Some(frame) = frames.get(&pair[0])
&& frame.y + frame.height > b.y
{
diagnostics.push(Diagnostic::FrameExtendsPastFollowingAnchor {
frame: pair[0],
following: pair[1],
});
}
}
}
}
}
Ok(Self {
anchors,
messages,
frames,
scopes,
height,
diagnostics,
})
}
}
/// An appearance identity, never inferred from selection or from the owner alone.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum Representation {
Derived { owner: Id },
Saved { diagram: Id },
}
/// Complete immutable geometry produced from one Project borrow. Scene is retained
/// with its plan so a caller cannot accidentally render this plan against another
/// Project or representation. Document/revision freshness belongs to that caller.
pub(super) struct ResolvedScenarioPlan {
pub(super) representation: Representation,
pub(super) owner: Id,
pub(super) lanes: BTreeMap<Id, f32>,
pub(super) plan: Plan,
pub(super) latent_messages: Vec<Id>,
pub(super) bounds: Rectangle,
pub(super) scene: Scene,
}
pub(super) fn resolve(
p: &Project,
representation: Representation,
selected: Id,
) -> Result<ResolvedScenarioPlan, Vec<GeometryIssue>> {
let (owner, layout) = match representation {
Representation::Derived { owner } => (owner, None),
Representation::Saved { diagram } => {
let view = p
.diagrams
.get(&diagram)
.ok_or_else(|| vec![GeometryIssue::InvalidRepresentation(diagram)])?;
let owner = view
.projection
.scenario()
.ok_or_else(|| vec![GeometryIssue::InvalidRepresentation(diagram)])?;
if p.elements
.get(&owner)
.is_none_or(|e| e.layer() != view.layer)
{
return Err(vec![GeometryIssue::InvalidRepresentation(diagram)]);
}
let layout = view
.layout
.as_ref()
.map(|DiagramLayout::Scenario(layout)| layout);
(owner, layout)
}
};
if p.elements
.get(&owner)
.is_none_or(|e| e.kind() != Kind::Scenario)
{
return Err(vec![GeometryIssue::InvalidRepresentation(owner)]);
}
let empty = ScenarioLayout::default();
let layout = layout.unwrap_or(&empty);
let mut lanes = BTreeMap::new();
let mut issues = Vec::new();
for lane in &layout.lanes {
if p.elements
.get(&lane.lifeline)
.is_none_or(|e| e.kind() != Kind::Lifeline || e.parent != Some(owner))
{
issues.push(GeometryIssue::UnknownAnchor(lane.lifeline));
}
if !lane.x.is_finite() || lane.x.abs() > oa_core::MAX_COORDINATE {
issues.push(GeometryIssue::InvalidCoordinate(lane.lifeline));
}
if lanes.insert(lane.lifeline, lane.x).is_some() {
issues.push(GeometryIssue::DuplicateCoordinate(lane.lifeline));
}
}
if !issues.is_empty() {
return Err(issues);
}
// The actual automatic traversal runs first, with authored lane centers already
// applied. It supplies every untouched anchor, shape, insertion scope and label.
let baseline = Scene::sequence_geometry(
p,
owner,
selected,
(!lanes.is_empty()).then_some(&lanes),
None,
);
let mut desired = BTreeMap::new();
let mut seen = std::collections::BTreeSet::new();
let mut latent_messages = Vec::new();
let line_ids: std::collections::BTreeSet<_> =
baseline.lines.iter().map(|line| line.id).collect();
for message in &layout.messages {
let id = message.address.occurrence;
if !seen.insert(id) {
issues.push(GeometryIssue::DuplicateCoordinate(id));
}
if !message.address.references.is_empty() {
issues.push(GeometryIssue::ExpandedAddressNotSupported(id));
}
if !message.send_y.is_finite() || message.send_y.abs() > oa_core::MAX_COORDINATE {
issues.push(GeometryIssue::InvalidCoordinate(id));
}
let Some(OccurrenceBody::Exchange {
sender,
receiver,
message_kind,
..
}) = occurrence(p, id)
else {
issues.push(GeometryIssue::UnknownAnchor(id));
continue;
};
if !baseline.rows.contains_key(&id) {
issues.push(GeometryIssue::UnknownAnchor(id));
continue;
}
if !line_ids.contains(&id) {
latent_messages.push(id);
continue;
}
// Legacy lifecycle rows denote receipt, whereas the saved field denotes send.
let row = if sender.is_some()
&& sender == receiver
&& matches!(message_kind, MessageKind::Create | MessageKind::Delete)
{
message.send_y + 24.
} else {
message.send_y
};
desired.insert(id, row);
}
let actual_lanes = &baseline.render_lanes;
// A coincident pair is not a self message. Do not invent an arrow or lane order.
for line in &baseline.lines {
if let Some(OccurrenceBody::Exchange {
sender: Some(sender),
receiver,
message_kind,
..
}) = occurrence(p, line.id)
{
let target = if matches!(message_kind, MessageKind::Timer { .. }) {
Some(*sender)
} else {
*receiver
};
if target.is_some_and(|target| target != *sender)
&& target
.is_some_and(|target| actual_lanes.get(sender) == actual_lanes.get(&target))
{
issues.push(GeometryIssue::CoincidentDistinctEndpoints(line.id));
}
}
}
if !issues.is_empty() {
return Err(issues);
}
let mut plan = Plan::build(p, owner, &baseline, &desired).map_err(|issue| vec![issue])?;
// Keep the schema's exact send value; send+24 is performed once. In particular,
// a self Create/Delete receive row must never round-trip back into saved send.
for message in &layout.messages {
let id = message.address.occurrence;
if let Some(anchors) = plan.messages.get_mut(&id) {
let self_message = matches!(occurrence(p, id), Some(OccurrenceBody::Exchange {
sender: Some(sender), receiver, message_kind, ..
}) if matches!(message_kind, MessageKind::Timer { .. }) || *receiver == Some(*sender));
*anchors = MessageAnchors {
send_y: message.send_y,
receive_y: if self_message {
message.send_y + 24.
} else {
message.send_y
},
};
}
}
// Typed bars require a positive receiver interval. This is a narrow renderer
// limitation, not a global ban on reordered/negative asynchronous anchors.
for (&id, message) in &plan.messages {
match occurrence(p, id) {
Some(OccurrenceBody::Exchange {
message_kind: MessageKind::Call,
..
}) => {
let scope = baseline.rows[&id].0;
let (start, end) = if scope == owner {
(85., plan.height - 20.)
} else {
plan.scopes[&scope]
};
if desired.contains_key(&id) && (message.send_y < start || message.receive_y >= end)
{
issues.push(GeometryIssue::TypedActivationOutsideScope { call: id, scope });
}
}
Some(OccurrenceBody::Exchange {
message_kind: MessageKind::Reply,
reply_to: Some(call),
..
}) => {
if let Some(start) = plan.messages.get(call)
&& (desired.contains_key(&id) || desired.contains_key(call))
&& message.send_y <= start.receive_y
{
issues.push(GeometryIssue::TypedActivationCrossingNotSupported {
call: *call,
event: id,
});
}
}
_ => {}
}
}
if !issues.is_empty() {
return Err(issues);
}
for segment in &baseline.activations {
if baseline
.rows
.get(&segment.call)
.is_none_or(|(scope, _)| *scope != segment.scope)
{
continue;
}
let Some(start) = plan.messages.get(&segment.call) else {
continue;
};
let event = match segment.finish {
scenario_activation::Finish::Reply(event)
| scenario_activation::Finish::Terminated(event) => Some(event),
_ => None,
};
if let Some(event) = event
&& (desired.contains_key(&event) || desired.contains_key(&segment.call))
&& plan.anchors[&event].y <= start.receive_y
{
issues.push(GeometryIssue::TypedActivationCrossingNotSupported {
call: segment.call,
event,
});
}
}
if !issues.is_empty() {
return Err(issues);
}
let scene = Scene::sequence_geometry(
p,
owner,
selected,
(!lanes.is_empty()).then_some(&lanes),
Some(&plan),
);
// Compare the actual projector, whose cursor rules include sibling scopes and
// lifecycle/fragment events. Retain existing incomplete-model uncertainty;
// reject only newly missing geometry introduced by this presentation.
let previous_missing = baseline.lifecycle.missing_geometry();
for (lifeline, scope) in scene
.lifecycle
.missing_geometry()
.difference(&previous_missing)
{
issues.push(GeometryIssue::LifecycleGeometryNotSupported {
lifeline: *lifeline,
scope: *scope,
});
}
if !issues.is_empty() {
return Err(issues);
}
for segment in &baseline.activations {
let Some(seed) = baseline
.lines
.iter()
.find(|line| line.id == segment.call)
.and_then(|line| line.points.last())
else {
continue;
};
if segment.rect.y != seed.y || baseline.rows[&segment.call].0 != segment.scope {
continue;
}
let receive = plan.messages[&segment.call].receive_y;
if !desired.is_empty()
&& !scene.activations.iter().any(|current| {
current.call == segment.call
&& current.scope == segment.scope
&& current.rect.y == receive
})
{
issues.push(GeometryIssue::TypedActivationOutsideLifetime {
call: segment.call,
receiver: segment.receiver,
});
}
}
if !issues.is_empty() {
return Err(issues);
}
let bounds = structural_bounds(&scene);
let all_lanes = scene.render_lanes.clone();
Ok(ResolvedScenarioPlan {
representation,
owner,
lanes: all_lanes,
plan,
latent_messages,
bounds,
scene,
})
}
fn structural_bounds(scene: &Scene) -> Rectangle {
let mut bounds = Rectangle::new(scene.layout_origin, scene.size);
for item in &scene.boxes {
bounds = union(bounds, item.rect);
}
for line in &scene.lines {
if !line.label.is_empty() {
bounds = union(bounds, line.label_bounds());
}
for point in &line.points {
// Arrow wings extend beyond the centerline in either direction.
bounds = union(
bounds,
Rectangle::new(Point::new(point.x - 8., point.y - 8.), Size::new(16., 16.)),
);
}
}
for segment in &scene.activations {
bounds = union(bounds, segment.rect);
}
for point in &scene.open_calls {
bounds = union(
bounds,
Rectangle::new(Point::new(point.x - 8., point.y - 8.), Size::new(16., 16.)),
);
}
if let Some(lifetime) = scene.lifecycle.bounds() {
bounds = union(bounds, lifetime);
}
bounds
}
@@ -1,234 +0,0 @@
//! Display policy for one requested Scenario representation, without authoring.
//! The resolver remains the sole renderer. Freshness and editor admission belong
//! to the caller; a capability here never bypasses those guards.
use super::{Diagnostic, GeometryIssue, Representation, ResolvedScenarioPlan, resolve};
use crate::behavior::Scene;
use iced::Rectangle;
use oa_core::{DiagramLayout, Id, Kind, OccurrenceBody, Project, SharedBody};
use std::collections::BTreeSet;
#[cfg(test)]
mod tests;
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) enum PresentationStatus {
Ready,
Fallback {
requested_issues: Vec<GeometryIssue>,
},
Unavailable {
requested_issues: Vec<GeometryIssue>,
automatic_issues: Vec<GeometryIssue>,
},
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct PresentationCapabilities {
/// Actual geometry exists for inspection, Fit and geometry-based navigation.
pub(crate) geometry_navigation: bool,
/// Still subject to document, gesture, editor and view-visit admission.
pub(crate) coordinate_input: bool,
}
pub(crate) struct ScenarioPresentation {
requested: Representation,
owner: Option<Id>,
status: PresentationStatus,
geometry: Option<ResolvedScenarioPlan>,
// Saved metadata must survive a complete automatic fallback.
latent_messages: Vec<Id>,
}
impl ScenarioPresentation {
pub(crate) fn requested(&self) -> Representation {
self.requested
}
pub(crate) fn owner(&self) -> Option<Id> {
self.owner
}
pub(crate) fn status(&self) -> &PresentationStatus {
&self.status
}
pub(crate) fn geometry_representation(&self) -> Option<Representation> {
self.geometry
.as_ref()
.map(|geometry| geometry.representation)
}
pub(crate) fn bounds(&self) -> Option<Rectangle> {
self.geometry.as_ref().map(|geometry| geometry.bounds)
}
/// Diagnostics describe the actual displayed geometry. Requested hard failures
/// are retained separately in status(), never presented as automatic warnings.
pub(crate) fn diagnostics(&self) -> &[Diagnostic] {
self.geometry
.as_ref()
.map_or(&[], |geometry| &geometry.plan.diagnostics)
}
pub(crate) fn latent_messages(&self) -> &[Id] {
&self.latent_messages
}
pub(crate) fn capabilities(&self) -> PresentationCapabilities {
PresentationCapabilities {
geometry_navigation: self.geometry.is_some(),
coordinate_input: matches!(self.status, PresentationStatus::Ready),
}
}
pub(in crate::behavior) fn scene(&self) -> Option<&Scene> {
self.geometry.as_ref().map(|geometry| &geometry.scene)
}
pub(in crate::behavior) fn into_scene(self) -> Option<Scene> {
self.geometry.map(|geometry| geometry.scene)
}
}
/// Resolve a structurally accepted Project for passive display. This is not a
/// second model validator: identity and resolver-reported structural failures
/// cannot be converted into a more permissive automatic representation.
pub(crate) fn present(
project: &Project,
requested: Representation,
selected: Id,
) -> ScenarioPresentation {
let owner = checked_owner(project, requested);
let Some(owner_id) = owner else {
let id = match requested {
Representation::Derived { owner } => owner,
Representation::Saved { diagram } => diagram,
};
return ScenarioPresentation {
requested,
owner: None,
geometry: None,
latent_messages: vec![],
status: PresentationStatus::Unavailable {
requested_issues: vec![GeometryIssue::InvalidRepresentation(id)],
automatic_issues: vec![],
},
};
};
match resolve(project, requested, selected) {
Ok(geometry) => ScenarioPresentation {
requested,
owner,
status: PresentationStatus::Ready,
latent_messages: geometry.latent_messages.clone(),
geometry: Some(geometry),
},
Err(requested_issues) => {
if matches!(requested, Representation::Derived { .. })
|| requested_issues.iter().any(structural_failure)
{
return ScenarioPresentation {
requested,
owner,
geometry: None,
latent_messages: vec![],
status: PresentationStatus::Unavailable {
requested_issues,
automatic_issues: vec![],
},
};
}
match resolve(
project,
Representation::Derived { owner: owner_id },
selected,
) {
Ok(geometry) => {
let latent_messages =
saved_latent_messages(project, requested, &geometry.scene);
ScenarioPresentation {
requested,
owner,
status: PresentationStatus::Fallback { requested_issues },
geometry: Some(geometry),
latent_messages,
}
}
Err(automatic_issues) => ScenarioPresentation {
requested,
owner,
geometry: None,
latent_messages: vec![],
status: PresentationStatus::Unavailable {
requested_issues,
automatic_issues,
},
},
}
}
}
}
pub(crate) fn checked_owner(project: &Project, requested: Representation) -> Option<Id> {
let owner = match requested {
Representation::Derived { owner } => owner,
Representation::Saved { diagram } => {
let view = project.diagrams.get(&diagram)?;
let owner = view.projection.scenario()?;
if view.id != diagram || project.elements.get(&owner)?.layer() != view.layer {
return None;
}
owner
}
};
project
.elements
.get(&owner)
.is_some_and(|element| element.kind() == Kind::Scenario)
.then_some(owner)
}
fn structural_failure(issue: &GeometryIssue) -> bool {
// These violate the existing identity/address/finite-coordinate admission
// contract. Geometry support failures below do not invalidate stored intent.
match issue {
GeometryIssue::InvalidRepresentation(_)
| GeometryIssue::UnknownAnchor(_)
| GeometryIssue::InvalidCoordinate(_)
| GeometryIssue::DuplicateCoordinate(_)
| GeometryIssue::ExpandedAddressNotSupported(_)
| GeometryIssue::NonFinite(_) => true,
GeometryIssue::LifecycleGeometryNotSupported { .. }
| GeometryIssue::CoincidentDistinctEndpoints(_)
| GeometryIssue::TypedActivationOutsideScope { .. }
| GeometryIssue::TypedActivationOutsideLifetime { .. }
| GeometryIssue::TypedActivationCrossingNotSupported { .. }
| GeometryIssue::CompoundMoveNotImplemented(_)
| GeometryIssue::FixedFrameHeaderWouldMove { .. }
| GeometryIssue::OperandScopeOutsideEnclosingInterval { .. }
| GeometryIssue::LifecycleAnchorOutsideRenderScope { .. }
| GeometryIssue::LifetimeInversionNotSupported { .. } => false,
}
}
fn saved_latent_messages(project: &Project, requested: Representation, scene: &Scene) -> Vec<Id> {
let Representation::Saved { diagram } = requested else {
return vec![];
};
let Some(DiagramLayout::Scenario(layout)) = project.diagrams[&diagram].layout.as_ref() else {
return vec![];
};
let lines: BTreeSet<_> = scene.lines.iter().map(|line| line.id).collect();
layout
.messages
.iter()
.filter_map(|message| {
let id = message.address.occurrence;
(message.address.references.is_empty()
&& scene.rows.contains_key(&id)
&& !lines.contains(&id)
&& matches!(
project
.elements
.get(&id)
.and_then(|element| element.shared()),
Some(SharedBody::Occurrence {
body: OccurrenceBody::Exchange { .. },
..
})
))
.then_some(id)
})
.collect()
}
@@ -1,351 +0,0 @@
use super::*;
use crate::document::Document;
use oa_core::{
Command, Diagram, OccurrenceEndpoint, ProjectionKind, ScenarioLanePosition, ScenarioLayout,
ScenarioMessagePosition, language, validation,
};
fn fixture() -> (Project, Id) {
let project = language::parse(FIXTURE).unwrap();
assert_admitted(&project);
let owner = named(&project, "conversation");
(project, owner)
}
fn named(project: &Project, alias: &str) -> Id {
project
.identities()
.into_iter()
.find(|(_, name)| *name == alias)
.unwrap()
.0
}
fn assert_admitted(project: &Project) {
let errors: Vec<_> = validation::validate(project)
.into_iter()
.filter(|issue| issue.severity == validation::Severity::Error)
.collect();
assert!(errors.is_empty(), "{errors:?}");
}
fn save(project: &mut Project, owner: Id, layout: Option<ScenarioLayout>) -> Id {
let id = Id::new_v4();
project.diagrams.insert(
id,
Diagram {
id,
alias: format!("view_{}", project.diagrams.len()),
name: "Saved conversation".into(),
description: String::new(),
layer: project.elements[&owner].layer(),
projection: ProjectionKind::Scenario { scenario: owner },
view_options: Default::default(),
nodes: Default::default(),
routes: vec![],
layout: layout.map(DiagramLayout::Scenario),
},
);
id
}
fn layout(messages: &[(Id, f32)], lanes: &[(Id, f32)]) -> ScenarioLayout {
ScenarioLayout {
lanes: lanes
.iter()
.map(|&(lifeline, x)| ScenarioLanePosition { lifeline, x })
.collect(),
messages: messages
.iter()
.map(|&(occurrence, send_y)| ScenarioMessagePosition {
address: OccurrenceEndpoint {
occurrence,
references: vec![],
},
send_y,
})
.collect(),
}
}
fn same_geometry(a: &Scene, b: &Scene) {
assert!(
!a.activations.is_empty(),
"fallback must include real typed executions"
);
assert_eq!(a.activations, b.activations);
assert_eq!(a.lifecycle, b.lifecycle);
assert_eq!(a.rows, b.rows);
assert_eq!(a.sequence_bounds, b.sequence_bounds);
assert_eq!(a.render_lanes, b.render_lanes);
assert_eq!(a.lanes, b.lanes);
assert_eq!(a.open_calls, b.open_calls);
assert_eq!((a.size, a.layout_origin), (b.size, b.layout_origin));
let boxes = |scene: &Scene| {
scene
.boxes
.iter()
.map(|item| {
(
item.id,
item.rect,
item.title.clone(),
item.detail.clone(),
item.marker,
)
})
.collect::<Vec<_>>()
};
let lines = |scene: &Scene| {
scene
.lines
.iter()
.map(|line| {
(
line.id,
line.points.clone(),
line.label.clone(),
line.label_at,
line.dashed,
)
})
.collect::<Vec<_>>()
};
let slots = |scene: &Scene| {
scene
.slots
.iter()
.map(|slot| {
(
slot.slot.parent,
slot.slot.before,
slot.y,
slot.label.clone(),
)
})
.collect::<Vec<_>>()
};
assert_eq!(boxes(a), boxes(b));
assert_eq!(lines(a), lines(b));
assert_eq!(slots(a), slots(b));
}
#[test]
fn absent_and_empty_saved_layouts_remain_saved_ready_representations() {
let (mut project, owner) = fixture();
let absent = save(&mut project, owner, None);
let empty = save(&mut project, owner, Some(ScenarioLayout::default()));
assert_admitted(&project);
let automatic = present(&project, Representation::Derived { owner }, owner);
assert_eq!(automatic.status(), &PresentationStatus::Ready);
for diagram in [absent, empty] {
let requested = Representation::Saved { diagram };
let displayed = present(&project, requested, owner);
assert_eq!(displayed.requested(), requested);
assert_eq!(displayed.geometry_representation(), Some(requested));
assert_eq!(displayed.owner(), Some(owner));
assert_eq!(displayed.status(), &PresentationStatus::Ready);
assert!(displayed.capabilities().coordinate_input);
assert_eq!(displayed.bounds(), automatic.bounds());
same_geometry(displayed.scene().unwrap(), automatic.scene().unwrap());
}
}
#[test]
fn successful_crossing_diagnostic_keeps_authored_geometry_and_latent_identity() {
let (mut project, owner) = fixture();
let first = named(&project, "first");
let second = named(&project, "second");
let left = named(&project, "left");
let unfinished = named(&project, "unfinished");
let diagram = save(
&mut project,
owner,
Some(layout(
&[(first, 1000.), (unfinished, 2000.)],
&[(left, 900.)],
)),
);
assert_admitted(&project);
let shown = present(&project, Representation::Saved { diagram }, first);
assert_eq!(shown.status(), &PresentationStatus::Ready);
assert_eq!(shown.scene().unwrap().rows[&first].1, 1000.);
assert_eq!(shown.scene().unwrap().rows[&second].1, 240.);
assert_eq!(shown.scene().unwrap().render_lanes[&left], 900.);
assert!(
shown
.diagnostics()
.contains(&Diagnostic::SourceOrderCrossing {
scope: owner,
before: first,
after: second
})
);
assert_eq!(shown.latent_messages(), &[unfinished]);
assert_eq!(shown.scene().unwrap().rows[&unfinished].1, 516.);
}
#[test]
fn hard_failure_uses_complete_automatic_geometry_but_retains_each_saved_request_and_latent_notice()
{
let (mut project, owner) = fixture();
let call = named(&project, "call");
let reply = named(&project, "reply");
let unfinished = named(&project, "unfinished");
let left = named(&project, "left");
let right = named(&project, "right");
let authored = layout(
&[(reply, 300.), (unfinished, 2000.)],
&[(left, 900.), (right, -500.)],
);
let a = save(&mut project, owner, Some(authored.clone()));
let b = save(&mut project, owner, Some(authored));
assert_admitted(&project);
let before = project.clone();
let automatic = present(&project, Representation::Derived { owner }, call);
let control = automatic.scene().unwrap();
assert_eq!(control.render_lanes[&left], 160.);
assert_eq!(control.render_lanes[&right], 400.);
assert_eq!(control.rows[&reply].1, 424.);
assert_eq!(control.activations.len(), 1);
assert_eq!(
(
control.activations[0].rect.y,
control.activations[0].rect.height
),
(356., 68.)
);
for diagram in [a, b] {
let request = Representation::Saved { diagram };
let shown = present(&project, request, call);
assert_eq!(shown.requested(), request);
assert_eq!(
shown.geometry_representation(),
Some(Representation::Derived { owner })
);
assert_eq!(
shown.status(),
&PresentationStatus::Fallback {
requested_issues: vec![GeometryIssue::TypedActivationCrossingNotSupported {
call,
event: reply
}],
}
);
assert_eq!(
shown.capabilities(),
PresentationCapabilities {
geometry_navigation: true,
coordinate_input: false
}
);
assert_eq!(shown.latent_messages(), &[unfinished]);
assert!(shown.diagnostics().is_empty());
assert_eq!(shown.bounds(), automatic.bounds());
same_geometry(shown.scene().unwrap(), control);
}
assert_eq!(project, before);
}
#[test]
fn missing_or_incompatible_identity_and_structural_coordinate_errors_are_unavailable() {
let (mut project, owner) = fixture();
let left = named(&project, "left");
let diagram = save(&mut project, owner, None);
let missing = Id::new_v4();
for request in [
Representation::Saved { diagram: missing },
Representation::Derived { owner: left },
] {
let shown = present(&project, request, owner);
assert_eq!(shown.requested(), request);
assert_eq!(shown.owner(), None);
assert!(
matches!(shown.status(), PresentationStatus::Unavailable { automatic_issues, .. } if automatic_issues.is_empty())
);
assert!(shown.scene().is_none() && shown.bounds().is_none());
assert_eq!(
shown.capabilities(),
PresentationCapabilities {
geometry_navigation: false,
coordinate_input: false
}
);
}
project.diagrams.get_mut(&diagram).unwrap().projection =
ProjectionKind::Scenario { scenario: missing };
assert!(matches!(
present(&project, Representation::Saved { diagram }, owner).status(),
PresentationStatus::Unavailable { .. }
));
// Deliberately invalid-model control, not a warning-admitted geometry failure.
project.diagrams.get_mut(&diagram).unwrap().projection =
ProjectionKind::Scenario { scenario: owner };
project.diagrams.get_mut(&diagram).unwrap().layout = Some(DiagramLayout::Scenario(layout(
&[],
&[(left, oa_core::MAX_COORDINATE + 1.)],
)));
assert!(
validation::validate(&project)
.iter()
.any(|issue| issue.severity == validation::Severity::Error)
);
let before = project.clone();
let shown = present(&project, Representation::Saved { diagram }, owner);
assert!(
matches!(shown.status(), PresentationStatus::Unavailable { requested_issues, automatic_issues }
if requested_issues == &[GeometryIssue::InvalidCoordinate(left)] && automatic_issues.is_empty())
);
assert!(shown.scene().is_none());
assert_eq!(project, before);
}
#[test]
fn passive_fallback_preserves_exact_document_source_and_one_step_undo_redo() {
let (mut project, owner) = fixture();
let first = named(&project, "first");
let reply = named(&project, "reply");
let view = save(&mut project, owner, Some(layout(&[(reply, 300.)], &[])));
assert_admitted(&project);
let mut document = Document::from_project(project, Default::default()).unwrap();
let initial_project = document.project().clone();
let initial_source = document.bundle().clone();
document
.execute(Command::Rename(first, "An intentional prior edit".into()))
.unwrap();
let accepted_project = document.project().clone();
let accepted_source = document.bundle().clone();
let revision = document.revision();
{
let shown = present(
document.project(),
Representation::Saved { diagram: view },
first,
);
assert!(matches!(
shown.status(),
PresentationStatus::Fallback { .. }
));
assert!(shown.scene().is_some());
}
assert_eq!(document.project(), &accepted_project);
assert_eq!(document.bundle(), &accepted_source);
assert_eq!(document.revision(), revision);
assert!(document.undo());
assert_eq!(document.project(), &initial_project);
assert_eq!(document.bundle(), &initial_source);
assert!(document.redo());
assert_eq!(document.project(), &accepted_project);
assert_eq!(document.bundle(), &accepted_source);
}
const FIXTURE: &str = r#"arcadia 7;
project display_probe "Scenario display probe" id "70000000-0000-4000-8000-000000000001" {
oa_activity source "Source" id "70000000-0000-4000-8000-000000000002" {}
oa_activity target "Target" id "70000000-0000-4000-8000-000000000003" {}
interaction exchange "Repeated exchange" id "70000000-0000-4000-8000-000000000004" { source @source; target @target; }
oa_scenario conversation "Conversation" id "70000000-0000-4000-8000-000000000005" { viewpoint OperationalActivity; steps [@first, @second, @call, @reply, @unfinished]; }
oa_lifeline left "Left" id "70000000-0000-4000-8000-000000000006" { parent @conversation; represented @source; }
oa_lifeline right "Right" id "70000000-0000-4000-8000-000000000007" { parent @conversation; represented @target; }
oa_occurrence first "First" id "70000000-0000-4000-8000-000000000008" { parent @conversation; body { Exchange { exchange @exchange; sender @left; receiver @right; message_kind Asynchronous; }; }; }
oa_occurrence second "Second" id "70000000-0000-4000-8000-000000000009" { parent @conversation; body { Exchange { exchange @exchange; sender @left; receiver @right; message_kind Asynchronous; }; }; }
oa_occurrence call "Call" id "70000000-0000-4000-8000-00000000000a" { parent @conversation; body { Exchange { exchange none; sender @right; receiver @right; message_kind Call; }; }; }
oa_occurrence reply "Reply" id "70000000-0000-4000-8000-00000000000b" { parent @conversation; body { Exchange { exchange none; sender @right; receiver @right; message_kind Reply; reply_to @call; }; }; }
oa_occurrence unfinished "Unfinished" id "70000000-0000-4000-8000-00000000000c" { parent @conversation; body { Exchange { exchange @exchange; sender @left; receiver none; message_kind Asynchronous; }; }; }
}
"#;
File diff suppressed because it is too large Load Diff

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