From 4b782a71fc011defd39331d3d6d5b73c1506275c Mon Sep 17 00:00:00 2001 From: Codex-ytr83r Date: Sat, 12 Sep 2026 17:01:57 -0700 Subject: [PATCH] Test complete scenario anchors against real Scene geometry --- crates/oa-desktop/src/behavior.rs | 12 + .../.gitattributes | 1 + .../behavior-anchor-plan-20260912/README.md | 95 + .../behavior-anchor-plan-20260912/mod.rs | 448 +++ .../prepared-inputs.json | 31 + .../behavior-anchor-plan-20260912/tests.rs | 727 ++++ .../wiring.patch | 51 + .../.gitattributes | 4 + .../README.md | 14 + .../crates/oa-desktop/src/behavior.rs | 3515 +++++++++++++++++ .../behavior-anchor-plan-20260912/README.md | 71 + .../behavior-anchor-plan-20260912/mod.rs | 270 ++ .../behavior-anchor-plan-20260912/tests.rs | 276 ++ .../wiring.patch | 51 + .../attempt1/manifest.json | 31 + .../crates/oa-desktop/src/behavior.rs | 3515 +++++++++++++++++ .../behavior-anchor-plan-20260912/README.md | 79 + .../behavior-anchor-plan-20260912/mod.rs | 297 ++ .../behavior-anchor-plan-20260912/tests.rs | 348 ++ .../wiring.patch | 51 + .../attempt2/manifest.json | 30 + 21 files changed, 9917 insertions(+) create mode 100644 harness/experiments/behavior-anchor-plan-20260912/.gitattributes create mode 100644 harness/experiments/behavior-anchor-plan-20260912/README.md create mode 100644 harness/experiments/behavior-anchor-plan-20260912/mod.rs create mode 100644 harness/experiments/behavior-anchor-plan-20260912/prepared-inputs.json create mode 100644 harness/experiments/behavior-anchor-plan-20260912/tests.rs create mode 100644 harness/experiments/behavior-anchor-plan-20260912/wiring.patch create mode 100644 harness/experiments/behavior-anchor-plan-review-20260912/.gitattributes create mode 100644 harness/experiments/behavior-anchor-plan-review-20260912/README.md create mode 100644 harness/experiments/behavior-anchor-plan-review-20260912/attempt1/crates/oa-desktop/src/behavior.rs create mode 100644 harness/experiments/behavior-anchor-plan-review-20260912/attempt1/harness/experiments/behavior-anchor-plan-20260912/README.md create mode 100644 harness/experiments/behavior-anchor-plan-review-20260912/attempt1/harness/experiments/behavior-anchor-plan-20260912/mod.rs create mode 100644 harness/experiments/behavior-anchor-plan-review-20260912/attempt1/harness/experiments/behavior-anchor-plan-20260912/tests.rs create mode 100644 harness/experiments/behavior-anchor-plan-review-20260912/attempt1/harness/experiments/behavior-anchor-plan-20260912/wiring.patch create mode 100644 harness/experiments/behavior-anchor-plan-review-20260912/attempt1/manifest.json create mode 100644 harness/experiments/behavior-anchor-plan-review-20260912/attempt2/crates/oa-desktop/src/behavior.rs create mode 100644 harness/experiments/behavior-anchor-plan-review-20260912/attempt2/harness/experiments/behavior-anchor-plan-20260912/README.md create mode 100644 harness/experiments/behavior-anchor-plan-review-20260912/attempt2/harness/experiments/behavior-anchor-plan-20260912/mod.rs create mode 100644 harness/experiments/behavior-anchor-plan-review-20260912/attempt2/harness/experiments/behavior-anchor-plan-20260912/tests.rs create mode 100644 harness/experiments/behavior-anchor-plan-review-20260912/attempt2/harness/experiments/behavior-anchor-plan-20260912/wiring.patch create mode 100644 harness/experiments/behavior-anchor-plan-review-20260912/attempt2/manifest.json diff --git a/crates/oa-desktop/src/behavior.rs b/crates/oa-desktop/src/behavior.rs index 3f9fd97e..f1ef5b70 100644 --- a/crates/oa-desktop/src/behavior.rs +++ b/crates/oa-desktop/src/behavior.rs @@ -1121,6 +1121,10 @@ fn in_machine(p: &Project, id: Id, machine: Id) -> bool { #[path = "../../../harness/experiments/behavior-layout-scene-20260912/mod.rs"] mod behavior_layout_scene_experiment; +#[cfg(test)] +#[path = "../../../harness/experiments/behavior-anchor-plan-20260912/mod.rs"] +mod behavior_anchor_plan_experiment; + struct BoxItem { id: Id, rect: Rectangle, @@ -1332,6 +1336,8 @@ impl Scene { let mut y = if scenario { 148. } else { 120. }; scene.occurrences(p, owner, scenario, &lane_x, &mut y, 0); scene.size.height = (y + 45.).max(210.); + #[cfg(test)] + behavior_anchor_plan_experiment::scene_extent(&mut scene); if scenario { scene.lifecycle = scenario_lifecycle::render::Rendering::new( p, @@ -1441,6 +1447,8 @@ impl Scene { } #[cfg(test)] behavior_layout_scene_experiment::resolve_row_anchor(*id, y); + #[cfg(test)] + behavior_anchor_plan_experiment::row_anchor(*id, y); if ordered { let slot = scenario::Slot { parent: owner, @@ -1465,6 +1473,8 @@ impl Scene { (*y - top).max(95.), ), ); + #[cfg(test)] + let rect = behavior_anchor_plan_experiment::frame_bounds(*id, rect); self.boxes.insert( 0, BoxItem { @@ -1649,6 +1659,8 @@ impl Scene { } if ordered { self.sequence_bounds.insert(owner, (sequence_top, *y - 42.)); + #[cfg(test)] + behavior_anchor_plan_experiment::scope_end(owner, y, &mut self.sequence_bounds); let slot = scenario::Slot { parent: owner, before: None, diff --git a/harness/experiments/behavior-anchor-plan-20260912/.gitattributes b/harness/experiments/behavior-anchor-plan-20260912/.gitattributes new file mode 100644 index 00000000..9062ee3c --- /dev/null +++ b/harness/experiments/behavior-anchor-plan-20260912/.gitattributes @@ -0,0 +1 @@ +wiring.patch -text -whitespace diff --git a/harness/experiments/behavior-anchor-plan-20260912/README.md b/harness/experiments/behavior-anchor-plan-20260912/README.md new file mode 100644 index 00000000..b72cefa4 --- /dev/null +++ b/harness/experiments/behavior-anchor-plan-20260912/README.md @@ -0,0 +1,95 @@ +# Complete anchor plan into actual Scene — pending execution + +Prepared 2026-09-12 in isolated branch `codex/behavior-anchor-plan-ytr83r-20260912`, base `66a1f1690f778c8814bfefff7f46fe7f6bbe520e`. Parent owns compilation and publication. **Not compiled or executed.** Only test-only additions to `crates/oa-desktop/src/behavior.rs` and this new directory were authored. The existing floor experiment and its fixtures are unchanged. All new production-file statements are guarded by `cfg(test)`; normal production rendering is unchanged. + +## Bounded question + +Can a complete ephemeral anchor plan drive the actual Scene at exact requested y coordinates without the old running-cursor floor, preserve unspecified anchors and the entire Document, and maintain covered extents and real lifecycle derivation? This isolates the integration seam from the unresolved choice of a product movement policy. + +The old floor candidate imposed existing seed increments on every requested row. This candidate seeds missing anchors from the actual baseline Scene, then replaces explicitly requested coordinates exactly. It does not impose sequence-order constraints or overlap spacing. Source-order crossings are returned as diagnostic data, not silently clamped or converted into semantic Reorder. This is a mechanical projection experiment, not evidence that crossed vertical ordering is acceptable UX. + +`Candidate` is an external map keyed by occurrence UUID. `Plan` is a complete resolved input for the currently visible Scene, including operand headers missing from the ordinary rows map. It contains row roles, derived message send/receive y values, frame bounds, sequence extents and canvas height. It is not a model, parser or REST payload. + +## Actual code boundary and wiring + +`wiring.patch` records the exact production-file delta from the base commit. The five new cfg(test) integration points are: + +1. Include this module as a private child of `behavior`, next to the unchanged floor module. +2. Immediately after the old floor hook, consume the complete plan's exact occurrence anchor, before Scene records rows and constructs the occurrence's existing geometry. +3. Before inserting an existing fragment BoxItem, use the plan's frame bounds. Existing fragment paint and label code remain in place. +4. Immediately after recording a sequence's bounds, substitute planned bounds and update the existing final insertion axis. An earlier event placed lower than the last event cannot truncate the scope merely because traversal ends earlier. +5. Before current lifecycle and activation construction, substitute the covered canvas height. Current `Rendering::new`, message alignment, activation generation and drawing remain responsible for their actual geometry. + +The plan is scoped per thread, inactive by default, restored by Drop during unwinding, and has a deliberate-panic test requiring the exact sentinel payload. Other tests on other threads cannot consume its plan. The original floor hooks remain operational when their own scope is active; the new plan is otherwise absent. + +Footprints are measured from the actual baseline named boxes, message polylines and label bounds, translated by requested row deltas. This avoids cloning the renderer's line or lifecycle algorithms. Empty activation bars are excluded because the real renderer derives those later. Frames conservatively preserve seed extents and can grow downward; growth adds an explicitly experimental 20px presentation margin. A move requiring upward frame growth returns `FixedFrameHeaderWouldMove`, because the candidate cannot move an unspecified painted header while retaining its FrameStart and insertion slot. Mere overlap inside the existing frame is not categorically rejected. Sequence bounds preserve seed extents and measured edge padding while expanding to cover outliers; canvas height uses the original scope-to-canvas margin. Neither these margins nor nonshrinking seed extents are adopted user requirements. This is not a general container-resize solver. + +## Independently expected checks + +Thirteen new test functions use the existing hand-authored fixtures through the actual parser and Document bundle loader. Numeric expectations below are independently stated rather than generated from the new planner: + +| Case | Expected actual Scene result | +| --- | --- | +| Empty complete plan, both fixtures | Same rows, named boxes and marker kinds, lines and labels, scopes, slots, lifecycle, open calls and size as the unmodified baseline Scene | +| Second delivery240→200 | First148; second200; every other occurrence anchor unchanged; both second-message endpoints200; repeated exchange references retain different occurrence IDs | +| Nested action477→457 | Box top437, height70; actual fragment metadata and active header332..370 retained, header hit still resolves the fragment, before-slot290 and label unchanged; operand bounds and all other anchors unchanged | +| Nested action→300 | Proposed footprint280..350 would relocate unspecified frame top332 to260; reject with `FixedFrameHeaderWouldMove` before rendering | +| Nested action→353 / admitted380 overlap | Proposed353 gives footprint333 and operand start311, outside fragment start332; reject with `OperandScopeOutsideEnclosingInterval`. At380 the box starts360 and overlaps the fixed header while scope338..530 stays feasible; actual lifecycle diagnostics remain empty, post-fragment alive span530..845 and later self-call bars remain unchanged | +| Create243→50 or84 | Reject with `LifecycleAnchorOutsideRenderScope`; actual root lifecycle span starts85 even when insertion-scope planning expands higher. At85 the real birth marker and alive span85..427 remain present | +| Death427→380 | Create243 and call335 stay fixed; real alive span243..380; actual bar335..380; death marker/hit moves to380; unresolved-call cue remains, no Reply is invented | +| Self-call636→610 | Actual send610/receive634, using the current24 offset; reply728 unchanged; MessageSend role distinguished from the lifecycle MessageReceive role | +| Signed zero and candidate restoration | Both0 and-0 return the exact original sparse candidate; no first-edit materialization; restoring the original candidate restores Scene geometry | +| First event→1000, last remains728 | First1000/second240, SourceOrderCrossing diagnostic, source order unchanged; root scope end1026 and height1113 cover the earlier outlier rather than the traversal tail | +| Nested action→1000 | Box top980/bottom1050; enclosing frame bottom1070; actual root scope/canvas cover it; following self-call636 remains fixed and overlap is diagnosed | +| Invalid/unsupported input | Unknown anchor, nonfinite y, compound frame movement and same-scope inverted birth/death fail explicitly | +| Scoped execution | Another thread sees baseline geometry; a deliberate panic restores the baseline scope | + +The primary cases compare the entire Project and original SourceBundle and require the real Document to remain clean with no Undo/Redo entry. Canonical parser roundtrip is used, but it covers only the semantic Project: layout stays external. Candidate restoration is a reversible-sidecar check, **not** a production Undo implementation. No command has been added to Document. + +## Explicit limitations and falsifiers + +- Crossing source-ordered events is deliberately drawable with diagnostics. That proves removal of the mechanical floor, not adoption of unrestricted vertical notation. The crossing output remains an independent product-policy falsifier. +- An expanded frame can overlap the next event. Its diagnostic preserves that evidence instead of shifting the next event implicitly. The experiment does not establish a frame/header constraint policy or all feasible layouts. +- Upward growth that would relocate an unspecified frame header is explicitly unsupported. This does not ban arbitrary overlap gaps; the numeric380 example remains admitted. Full compound/header movement policy remains unresolved. +- A proposed operand interval escaping its actual enclosing fragment/caller interval returns `OperandScopeOutsideEnclosingInterval`. The353 example would otherwise cause the real lifecycle traversal to report MissingGeometry and make the later lifetime unresolved, potentially suppressing a self-call bar despite stable row coordinates. No generic gap or sibling-order rule is added. General nested/cross-operand placement remains unproven. +- Lifecycle receive anchors must be within the actual effective Rendering scope: root85..height-20, or its recorded non-root scope with the renderer's existing empty-scope allowance. Outside coordinates are rejected before exposing a Scene whose row exists but whose marker/lifetime is missing. This bounds the inspected bridge; it does not establish general ancestor/operand ordering or cross-operand lifetime support. +- The same-scope creation-after-deletion case returns `LifetimeInversionNotSupported` before rendering. Current lifecycle intervals cannot truthfully depict that inversion without another representation policy. This is an experimental bridge limitation, not permission to reject semantically valid models or a user-approved global movement rule. Cross-operand lifecycle combinations remain unproven. +- Compound frame/operand movement, box resizing, horizontal layout, independent send/receive movement, route editing, drag transaction stamps, pointer capture, dirty-source recovery, persistence, REST and native UI are not implemented. +- Self-call send and receive roles are explicit, but the current activation engine still owns its existing row-based behavior. Peer #21's typed activation integration must consume the actual receive anchor and retain the existing lifetime clipping. These tests do not silently bless the old activation-start discrepancy. +- Scope/containment walking is deliberately straightforward and not a large-model performance implementation. There is no responsiveness claim. +- Some self-message fixture exchanges are unassigned because the current global relation model forbids self edges. The existing fixture's known incomplete-model warnings remain; tests reject semantic errors. + +No storage or parser design should be adopted from this adapter. Coordinate any eventual payload with prospective #7, whose required addressing must preserve diagram/owner/occurrence identity and explicit anchor roles. Coordinate inverse resize with #16 and typed activation/lifetime union with #21 before broad UI work. + +The accumulating frame, scope and lifecycle dependencies show that this is only a bridge for demonstrated feasible positions, **not an adoptable free-editing architecture**. It neither establishes unrestricted placement nor selects a semantic constraint policy. Further fundamental contradictions should become explicit falsifiers for parent replanning, not an expanding list of speculative guards in this prototype. + +Parent reports [peer #21 comment716](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/21#issuecomment-716), posted2026-09-12 23:06UTC: Codex-956atf owns an isolated typed-activation/lifecycle source union. Its intended order is finalized rows/height, lifecycle construction and Create/Delete alignment, then typed Call/Reply attachment, with receiver/scope clipping, logical reply timing distinct from clipped geometry, no resurrection after Delete, and unanswered-call cues preserved. That WIP is not integrated here. Retain the explicit send/receive seam and review the future union against these fixtures; this paragraph records a coordination dependency, not verified acceptance of peer code. + +## Reproduction and handoff + +At the parent's chosen compiler checkpoint, use the configured Windows Rust/linker environment: + +```powershell +cargo test -p oa-desktop --bin arcadia-studio --locked --offline -- behavior_layout_scene_experiment behavior_anchor_plan_experiment --list +cargo test -p oa-desktop --bin arcadia-studio --locked --offline -- behavior_layout_scene_experiment behavior_anchor_plan_experiment --nocapture +``` + +The listing must contain thirteen complete-anchor tests and ten existing floor/lifetime tests. The following execution must report all23 passed,0 failed,0 ignored. Both filter strings are passed to the same test binary. Preserve compile failures, parser failures, numeric mismatches and diagnostics as results. Do not claim success from a zero-test filtered run or from compiling only production targets. An intentional panic should be followed by its restoration test passing; another panic payload is a failure. + +## Static review correction history + +The frozen attempt1 candidate was preserved before these corrections. The [authoritative attempt1 archive](../behavior-anchor-plan-review-20260912/attempt1/manifest.json) preserves all five original files; the adjacent review report records the source-verified counterexamples. The pre-review failures are static, unexecuted findings: nested300 silently moved the frame header to260 while row332/slot290 stayed fixed, and Create50 was omitted as MissingGeometry by the actual root lifetime span starting85. The corrected candidate adds named unsupported errors and independent numeric checks rather than recording either original behavior as acceptance. Production hooks and the original floor experiment remain unchanged. + +Before the third correction, parent preserved all five attempt2 files and matching hashes under `harness/experiments/behavior-anchor-plan-review-20260912/attempt2`. That static, unexecuted candidate still admitted nested353: its operand start311 preceded enclosing fragment332, causing MissingGeometry/Unresolved in the actual lifecycle source path. The third correction rejects that exact unsupported scope escape and replaces the positive overlap example with380, including actual lifecycle diagnostics, post-fragment spans and activation-bar preservation checks. The parent-owned review archive is not modified by this experiment. + +Record exact compiled commit/uncommitted patch, commands and results before publication. Source hashes supplied with the handoff identify prepared bytes, not tested code. Root should publish the complete directory and exact behavior.rs delta so remote peers can reproduce it; workspace-local paths are not remote handoffs. + +## Modeling intent: primary-source comparison + +The [Sirius sequence user guide](https://eclipse.dev/sirius/doc/user/sequences/Sequence%20Diagrams.html#SequenceDiagramsSemantics) explicitly ties displayed event order to semantic order: moving an event across another can update the model. It separately describes horizontal lifeline movement, Create/Delete attachment and blank-space gestures. This is framework evidence; the exact Capella modifier, feedback and saved-file effects were not verified natively. + +The [Sirius event-reorder specification](https://eclipse.dev/sirius/doc/specifier/sequences/Sequence%20Diagrams.html#EventReorderTool) provides separate predecessor information for start/send and finish/receive, including coupled synchronous-message movement. That supports preserving distinct endpoint roles; it does not specify a separate user-selected reorder mode. + +Root inference: the user request for intuitive whole-arrow movement does not by itself require arbitrary cosmetic chronology crossing. Compare two coherent product gestures: presentation movement within semantic order plus an explicit reorder action, versus direct dragging that previews and commits a semantic reorder when crossing an event. Evaluate ordinary spacing, intentional order changes, accidental crossing, invalid call/reply/lifetime moves, cross-operand moves, discoverability, one-step Undo and exact preview/reopen behavior. A semantic reorder may change Project/source; presentation-only adjustment must retain semantic content. The current sidecar experiment tests the latter boundary and is not a universal nonmutation requirement for all future drag operations. No gesture or public schema is selected by this note. + +Root formatting completed for the new Rust modules. Independent correction review found no new blocking source finding after the operand-scope repair, including non-vacuous actual lifetime/bar assertions. Compiler and runtime results remain pending. [Prepared input hashes](prepared-inputs.json) bind the current candidate, not an accepted build. diff --git a/harness/experiments/behavior-anchor-plan-20260912/mod.rs b/harness/experiments/behavior-anchor-plan-20260912/mod.rs new file mode 100644 index 00000000..8cd1d929 --- /dev/null +++ b/harness/experiments/behavior-anchor-plan-20260912/mod.rs @@ -0,0 +1,448 @@ +//! Experimental complete anchor input for the actual Scene, cfg(test) only. +//! No persistence schema and no replacement line/lifecycle/activation renderer. +use super::*; +use std::cell::RefCell; + +mod tests; + +type Candidate = BTreeMap; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum AnchorRole { + MessageSend, + MessageReceive, + Observation, + FrameStart, + OperandStart, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +struct Anchor { + y: f32, + role: AnchorRole, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +struct MessageAnchors { + send_y: f32, + receive_y: f32, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +enum Diagnostic { + SourceOrderCrossing { scope: Id, before: Id, after: Id }, + FrameExtendsPastFollowingAnchor { frame: Id, following: Id }, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +enum PlanError { + UnknownAnchor(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)] +struct Plan { + anchors: BTreeMap, + messages: BTreeMap, + frames: BTreeMap, + scopes: BTreeMap, + height: f32, + diagnostics: Vec, +} + +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 +} + +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, + seed: &BTreeMap, +) -> BTreeMap { + 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 covered_bounds( + p: &Project, + owner: Id, + footprints: &BTreeMap, +) -> Option { + footprints + .iter() + .filter(|(id, _)| inside(p, **id, owner)) + .map(|(_, rect)| *rect) + .reduce(union) +} + +impl Plan { + fn build( + p: &Project, + owner: Id, + baseline: &Scene, + desired: &Candidate, + ) -> Result { + 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(PlanError::UnknownAnchor(id))?; + if !y.is_finite() { + return Err(PlanError::NonFinite(id)); + } + if matches!( + anchor.role, + AnchorRole::FrameStart | AnchorRole::OperandStart + ) && y != anchor.y + { + return Err(PlanError::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 delta = anchor.y - seed[&line.id].y; + messages.insert( + line.id, + MessageAnchors { + send_y: first.y + delta, + receive_y: last.y + delta, + }, + ); + } + } + // The current lifecycle bridge needs positive birth/death intervals. + // This is an explicit unsupported crossing, not a new semantic rule. + for (&create, message) in &messages { + if let Some(OccurrenceBody::Exchange { + message_kind: MessageKind::Create, + receiver: Some(lane), + .. + }) = occurrence(p, create) + { + for (&delete, finish) in &messages { + if p.elements[&create].parent == p.elements[&delete].parent + && matches!(occurrence(p, delete), Some(OccurrenceBody::Exchange { message_kind: MessageKind::Delete, receiver: Some(other), .. }) if other == lane) + && message.receive_y > finish.receive_y + { + return Err(PlanError::LifetimeInversionNotSupported { create, delete }); + } + } + } + } + let old_footprints = footprints(baseline, &seed, &seed); + let mut drawn = footprints(baseline, &anchors, &seed); + 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) = covered_bounds(p, id, &drawn) { + // Keep seed extents where possible; 20px growth padding is an + // explicit experimental presentation preference, not semantics. + // 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(PlanError::FixedFrameHeaderWouldMove { frame: id }); + } + if covered.y + covered.height > rect.y + rect.height { + rect.height = covered.y + covered.height + 20. - rect.y; + } + } + frames.insert(id, rect); + drawn.insert(id, rect); + } + let mut scopes = baseline.sequence_bounds.clone(); + for (&scope, bounds) in &mut scopes { + if let (Some(old), Some(new)) = ( + covered_bounds(p, scope, &old_footprints), + covered_bounds(p, scope, &drawn), + ) { + 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(PlanError::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(PlanError::LifecycleAnchorOutsideRenderScope { + occurrence: id, + scope, + }); + }; + if message.receive_y < start || message.receive_y > end { + return Err(PlanError::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]) { + if frame.y + frame.height > b.y { + diagnostics.push(Diagnostic::FrameExtendsPastFollowingAnchor { + frame: pair[0], + following: pair[1], + }); + } + } + } + } + } + } + Ok(Self { + anchors, + messages, + frames, + scopes, + height, + diagnostics, + }) + } +} + +thread_local! { + static PLAN: RefCell> = const { RefCell::new(None) }; +} + +struct Scope(Option); +impl Scope { + fn install(plan: &Plan) -> Self { + Self(PLAN.with(|slot| slot.replace(Some(plan.clone())))) + } +} +impl Drop for Scope { + fn drop(&mut self) { + PLAN.with(|slot| { + slot.replace(self.0.take()); + }); + } +} + +pub(super) fn row_anchor(id: Id, y: &mut f32) { + PLAN.with(|slot| { + if let Some(plan) = slot.borrow().as_ref() { + *y = plan.anchors.get(&id).expect("complete Scene anchor plan").y; + } + }); +} + +pub(super) fn frame_bounds(id: Id, fallback: Rectangle) -> Rectangle { + PLAN.with(|slot| { + slot.borrow() + .as_ref() + .map_or(fallback, |plan| plan.frames[&id]) + }) +} + +pub(super) fn scope_end(owner: Id, y: &mut f32, bounds: &mut BTreeMap) { + PLAN.with(|slot| { + if let Some(plan) = slot.borrow().as_ref() { + bounds.insert(owner, plan.scopes[&owner]); + *y = plan.scopes[&owner].1 + 42.; // Existing insertion-axis offset. + } + }); +} + +pub(super) fn scene_extent(scene: &mut Scene) { + PLAN.with(|slot| { + if let Some(plan) = slot.borrow().as_ref() { + scene.size.height = plan.height; + } + }); +} + +fn render(p: &Project, owner: Id, plan: &Plan) -> Scene { + let _scope = Scope::install(plan); + Scene::sequence(p, owner, owner) +} + +fn drag(original: &Candidate, frame: &Scene, target: Id, delta_y: f32) -> Candidate { + assert!(delta_y.is_finite()); + if delta_y == 0. { + return original.clone(); + } + let mut result = original.clone(); + result.insert(target, frame.rows[&target].1 + delta_y); + result +} diff --git a/harness/experiments/behavior-anchor-plan-20260912/prepared-inputs.json b/harness/experiments/behavior-anchor-plan-20260912/prepared-inputs.json new file mode 100644 index 00000000..fe4914cb --- /dev/null +++ b/harness/experiments/behavior-anchor-plan-20260912/prepared-inputs.json @@ -0,0 +1,31 @@ +{ + "base": "66a1f1690f778c8814bfefff7f46fe7f6bbe520e", + "status": "Formatted candidate; uncompiled and unexecuted", + "files": [ + { + "path": "crates/oa-desktop/src/behavior.rs", + "bytes": 137409, + "sha256": "5884ba35ad994eb5936ca29c071a5002efc221dc42ef35e2265d50593bdf60ec" + }, + { + "path": "harness/experiments/behavior-anchor-plan-20260912/mod.rs", + "bytes": 15658, + "sha256": "cfa848bcf1f8f48324f5cd746944c9b8b4959e6aa086a7726b922d6a01cbbf3c" + }, + { + "path": "harness/experiments/behavior-anchor-plan-20260912/tests.rs", + "bytes": 25567, + "sha256": "c93b5668872217888c517df549fb6bea99fbc5642fdf8c2e55bdd92537df07b8" + }, + { + "path": "harness/experiments/behavior-anchor-plan-20260912/README.md", + "bytes": 16350, + "sha256": "da062779c90c7b9f0696f697521c1b0055c58d473329ac1817a55b0ba0f88370" + }, + { + "path": "harness/experiments/behavior-anchor-plan-20260912/wiring.patch", + "bytes": 2131, + "sha256": "4ce4bc45fbc419eba6a07b048b82efb8d85525514e3d2e56421948e7733f40fd" + } + ] +} diff --git a/harness/experiments/behavior-anchor-plan-20260912/tests.rs b/harness/experiments/behavior-anchor-plan-20260912/tests.rs new file mode 100644 index 00000000..c6b7dfc2 --- /dev/null +++ b/harness/experiments/behavior-anchor-plan-20260912/tests.rs @@ -0,0 +1,727 @@ +use super::*; +use crate::document::Document; +use oa_core::{ + language, + source_project::{ProjectLimits, SourceBundle}, + validation, +}; + +fn id(p: &Project, alias: &str) -> Id { + p.identities() + .into_iter() + .find(|(_, name)| *name == alias) + .unwrap() + .0 +} + +fn fixture(lifetime: bool) -> (Document, Id) { + let (name, input, owner) = if lifetime { + ( + "lifetime.arcadia", + include_str!("../behavior-layout-scene-20260912/lifetime.arcadia"), + "lifetime_conversation", + ) + } else { + ( + "fixture.arcadia", + include_str!("../behavior-layout-scene-20260912/fixture.arcadia"), + "conversation", + ) + }; + let p = language::parse(input).expect("existing real Arcadia fixture"); + let errors: Vec<_> = validation::validate(&p) + .into_iter() + .filter(|issue| issue.severity == validation::Severity::Error) + .collect(); + assert!(errors.is_empty(), "{errors:?}"); + let bundle = SourceBundle { + entry: name.into(), + files: BTreeMap::from([(name.into(), input.into())]), + }; + let document = Document::from_bundle(bundle, ProjectLimits::default()).unwrap(); + assert_eq!(document.project(), &p); + assert_eq!(language::parse(&language::format(&p)).unwrap(), p); + let owner = id(&p, owner); + (document, owner) +} + +fn passive(document: &Document, before: &Project, source: &SourceBundle) { + assert_eq!(document.project(), before); + assert_eq!(document.bundle(), source); + assert!(!document.dirty()); + assert!(!document.can_undo() && !document.can_redo()); +} + +fn row(scene: &Scene, id: Id) -> f32 { + scene.rows[&id].1 +} +fn line(scene: &Scene, id: Id) -> &Line { + assert_eq!(scene.lines.iter().filter(|line| line.id == id).count(), 1); + scene.lines.iter().find(|line| line.id == id).unwrap() +} +fn named_box(scene: &Scene, id: Id) -> &BoxItem { + scene + .boxes + .iter() + .find(|item| item.id == id && !item.title.is_empty()) + .unwrap() +} + +fn same_scene(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.lanes, b.lanes); + assert_eq!(a.lifecycle, b.lifecycle); + assert_eq!(a.open_calls, b.open_calls); + let boxes = |s: &Scene| { + s.boxes + .iter() + .map(|b| (b.id, b.rect, b.title.clone(), b.detail.clone(), b.marker)) + .collect::>() + }; + let lines = |s: &Scene| { + s.lines + .iter() + .map(|l| { + ( + l.id, + l.points.clone(), + l.label_at, + l.label.clone(), + l.dashed, + ) + }) + .collect::>() + }; + let slots = |s: &Scene| { + s.slots + .iter() + .map(|slot| { + ( + slot.slot.parent, + slot.slot.before, + slot.y, + slot.label.clone(), + ) + }) + .collect::>() + }; + assert_eq!(boxes(a), boxes(b)); + assert_eq!(lines(a), lines(b)); + assert_eq!(slots(a), slots(b)); +} + +fn others_fixed(before: &Scene, after: &Scene, changed: Id) { + assert_eq!(before.rows.len(), after.rows.len()); + for (&id, original) in &before.rows { + if id != changed { + assert_eq!(&after.rows[&id], original, "unexpected movement of {id}"); + } + } +} + +fn fixed_frame_metadata(before: &Scene, after: &Scene, frame: Id) { + let original = named_box(before, frame); + let current = named_box(after, frame); + assert_eq!( + ( + current.id, + current.rect, + ¤t.title, + ¤t.detail, + current.marker + ), + ( + original.id, + original.rect, + &original.title, + &original.detail, + original.marker + ) + ); + assert_eq!(before.rows[&frame], after.rows[&frame]); + assert_eq!(row(after, frame), 332.); + // Scene::hit uses the first38px as the frame's active painted header. + let header = Rectangle::new( + Point::new(original.rect.x, original.rect.y), + Size::new(original.rect.width, 38.), + ); + assert_eq!((header.y, header.y + header.height), (332., 370.)); + let probe = Point::new(header.x + 8., header.y + 8.); + assert_eq!(before.hit(probe), Some(frame)); + assert_eq!(after.hit(probe), Some(frame)); + let original_slot = before + .slots + .iter() + .find(|slot| slot.slot.before == Some(frame)) + .unwrap(); + let current_slot = after + .slots + .iter() + .find(|slot| slot.slot.before == Some(frame)) + .unwrap(); + assert_eq!( + ( + current_slot.slot.parent, + current_slot.slot.before, + current_slot.y, + ¤t_slot.label + ), + ( + original_slot.slot.parent, + original_slot.slot.before, + original_slot.y, + &original_slot.label + ) + ); + assert_eq!(current_slot.y, 290.); +} + +fn actual_lifetime_projection( + p: &Project, + owner: Id, + scene: &Scene, +) -> Vec { + // Same actual rows/scope inputs as Rendering::new, including its root span + // and empty-operand allowance; call the real projector for diagnostics. + let mut scopes = scene.sequence_bounds.clone(); + for (&scope, (start, end)) in &mut scopes { + if scope != owner && *start == *end { + *end = *start + 26.; + } + } + scopes.insert(owner, (85., scene.size.height - 20.)); + scenario_lifecycle::project_lifetimes( + p, + owner, + &scenario_lifecycle::Geometry { + rows: &scene.rows, + scopes: &scopes, + }, + ) +} + +#[test] +fn complete_seed_plan_preserves_both_actual_scene_and_document_baselines() { + for lifetime in [false, true] { + let (document, owner) = fixture(lifetime); + let p = document.project(); + let before = p.clone(); + let source = document.bundle().clone(); + let baseline = Scene::sequence(p, owner, owner); + let plan = Plan::build(p, owner, &baseline, &Candidate::new()).unwrap(); + assert!(plan.diagnostics.is_empty()); + for (&id, (_, y)) in &baseline.rows { + assert_eq!(plan.anchors[&id].y, *y); + } + same_scene(&baseline, &render(p, owner, &plan)); + let reparsed = language::parse(&language::format(p)).unwrap(); + same_scene(&baseline, &render(&reparsed, owner, &plan)); + passive(&document, &before, &source); + } +} + +#[test] +fn upward_second_delivery_reaches_200_and_all_other_occurrence_anchors_stay_fixed() { + let (document, owner) = fixture(false); + let p = document.project(); + let before = p.clone(); + let source = document.bundle().clone(); + let baseline = Scene::sequence(p, owner, owner); + let first = id(p, "first"); + let second = id(p, "second"); + assert_ne!(first, second); + for occurrence_id in [first, second] { + assert!( + matches!(occurrence(p,occurrence_id),Some(OccurrenceBody::Exchange { exchange:Some(edge),.. }) if *edge==id(p,"channel")) + ); + } + assert_eq!( + (row(&baseline, first), row(&baseline, second)), + (148., 240.) + ); + let candidate = drag(&Candidate::new(), &baseline, second, -40.); + assert_eq!(candidate, Candidate::from([(second, 200.)])); + let plan = Plan::build(p, owner, &baseline, &candidate).unwrap(); + let moved = render(p, owner, &plan); + assert_eq!((row(&moved, first), row(&moved, second)), (148., 200.)); + others_fixed(&baseline, &moved, second); + assert_eq!(line(&moved, first).points, line(&baseline, first).points); + let left = moved.lanes[&id(p, "left")]; + let right = moved.lanes[&id(p, "right")]; + assert_eq!( + line(&moved, second).points, + vec![Point::new(left, 200.), Point::new(right, 200.)] + ); + assert_eq!( + plan.messages[&second], + MessageAnchors { + send_y: 200., + receive_y: 200. + } + ); + assert!(plan.diagnostics.is_empty()); + same_scene(&moved, &render(p, owner, &plan)); + passive(&document, &before, &source); +} + +#[test] +fn nested_upward_anchor_reaches_457_without_moving_parent_or_followers() { + let (document, owner) = fixture(false); + let p = document.project(); + let before = p.clone(); + let source = document.bundle().clone(); + let baseline = Scene::sequence(p, owner, owner); + let nested = id(p, "nested"); + let fragment = id(p, "fragment"); + assert_eq!(row(&baseline, nested), 477.); + let candidate = drag(&Candidate::new(), &baseline, nested, -20.); + let plan = Plan::build(p, owner, &baseline, &candidate).unwrap(); + let moved = render(p, owner, &plan); + assert_eq!(row(&moved, nested), 457.); + assert_eq!(named_box(&moved, nested).rect.y, 437.); + assert_eq!(named_box(&moved, nested).rect.height, 70.); + assert_eq!( + named_box(&moved, fragment).rect, + named_box(&baseline, fragment).rect + ); + assert_eq!( + ( + named_box(&moved, fragment).rect.y, + named_box(&moved, fragment).rect.height + ), + (332., 240.) + ); + fixed_frame_metadata(&baseline, &moved, fragment); + assert_eq!( + moved.sequence_bounds[&id(p, "operand")], + baseline.sequence_bounds[&id(p, "operand")] + ); + others_fixed(&baseline, &moved, nested); + assert!(plan.diagnostics.is_empty()); + passive(&document, &before, &source); +} + +#[test] +fn nested_move_that_would_relocate_fixed_header_is_rejected_but_mere_overlap_is_not_banned() { + let (document, owner) = fixture(false); + let p = document.project(); + let before = p.clone(); + let source = document.bundle().clone(); + let baseline = Scene::sequence(p, owner, owner); + let nested = id(p, "nested"); + let fragment = id(p, "fragment"); + let requested = Candidate::from([(nested, 300.)]); + // Actual70px action box has row-to-top offset-20: proposed footprint280..350. + // The old envelope union would move frame/header top332 to260 while leaving + // FrameStart332 and before-slot290. Reject before any such Scene is exposed. + let original = named_box(&baseline, nested).rect; + assert_eq!( + (row(&baseline, nested), original.y, original.height), + (477., 457., 70.) + ); + let proposed_top = original.y + (300. - row(&baseline, nested)); + assert_eq!((proposed_top, proposed_top + original.height), (280., 350.)); + assert_eq!( + Plan::build(p, owner, &baseline, &requested).unwrap_err(), + PlanError::FixedFrameHeaderWouldMove { frame: fragment } + ); + fixed_frame_metadata(&baseline, &Scene::sequence(p, owner, owner), fragment); + // Overlap inside the existing frame does not itself move its top. This + // remains admitted mechanical geometry, not a claim of usable overlap UX. + let overlap = Plan::build(p, owner, &baseline, &Candidate::from([(nested, 380.)])).unwrap(); + let overlapping = render(p, owner, &overlap); + assert_eq!(named_box(&overlapping, nested).rect.y, 360.); + assert_eq!(overlapping.sequence_bounds[&id(p, "operand")], (338., 530.)); + fixed_frame_metadata(&baseline, &overlapping, fragment); + let original_lifetimes = actual_lifetime_projection(p, owner, &baseline); + let moved_lifetimes = actual_lifetime_projection(p, owner, &overlapping); + assert_eq!(original_lifetimes.len(), 2); + assert_eq!(moved_lifetimes.len(), 2); + assert!( + original_lifetimes + .iter() + .all(|lane| lane.uncertainties.is_empty()) + ); + assert!( + moved_lifetimes + .iter() + .all(|lane| lane.uncertainties.is_empty()), + "{moved_lifetimes:?}" + ); + let receiver = id(p, "right"); + for scene in [&baseline, &overlapping] { + assert_eq!( + scene + .lifecycle + .alive_spans(receiver, owner, 530., scene.size.height - 20.) + .iter() + .map(|span| (span.start, span.end)) + .collect::>(), + vec![(530., 845.)] + ); + } + let call = id(p, "self_call"); + let bars = |scene: &Scene| { + scene + .boxes + .iter() + .filter(|item| item.id == call && item.title.is_empty()) + .map(|item| (item.rect, item.marker)) + .collect::>() + }; + assert!(!bars(&baseline).is_empty()); + assert_eq!(bars(&overlapping), bars(&baseline)); + passive(&document, &before, &source); +} + +#[test] +fn nested353_scope_escape_is_named_and_rejected_before_missing_lifecycle_geometry_is_exposed() { + let (document, owner) = fixture(false); + let p = document.project(); + let before = p.clone(); + let source = document.bundle().clone(); + let baseline = Scene::sequence(p, owner, owner); + let nested = id(p, "nested"); + let operand = id(p, "operand"); + let fragment = id(p, "fragment"); + let original = named_box(&baseline, nested).rect; + assert_eq!( + (original.y, baseline.sequence_bounds[&operand].0), + (457., 435.) + ); + let proposed_top = original.y + (353. - row(&baseline, nested)); + let proposed_scope_start = proposed_top - (original.y - baseline.sequence_bounds[&operand].0); + assert_eq!( + (proposed_top, proposed_scope_start, row(&baseline, fragment)), + (333., 311., 332.) + ); + assert_eq!( + Plan::build(p, owner, &baseline, &Candidate::from([(nested, 353.)])).unwrap_err(), + PlanError::OperandScopeOutsideEnclosingInterval { operand, fragment } + ); + same_scene(&baseline, &Scene::sequence(p, owner, owner)); + passive(&document, &before, &source); +} + +#[test] +fn lifecycle_anchor_below_actual_root_render_scope_is_rejected_before_scene_construction() { + let (document, owner) = fixture(true); + let p = document.project(); + let before = p.clone(); + let source = document.bundle().clone(); + let baseline = Scene::sequence(p, owner, owner); + let create = id(p, "create_receiver"); + let provider = id(p, "provider"); + let requester = id(p, "requester"); + let root_life = baseline + .lifecycle + .alive_spans(requester, owner, 0., baseline.size.height); + assert_eq!( + root_life + .iter() + .map(|span| (span.start, span.end)) + .collect::>(), + vec![(85., 544.)] + ); + for requested in [50., 84.] { + assert_eq!( + Plan::build(p, owner, &baseline, &Candidate::from([(create, requested)])).unwrap_err(), + PlanError::LifecycleAnchorOutsideRenderScope { + occurrence: create, + scope: owner + } + ); + } + // At the renderer's actual85 boundary the marker/lifetime remains present. + // Source-order crossing is still diagnosed separately, not silently banned. + let boundary = Plan::build(p, owner, &baseline, &Candidate::from([(create, 85.)])).unwrap(); + let moved = render(p, owner, &boundary); + let x = moved.lanes[&provider]; + assert_eq!(row(&moved, create), 85.); + assert_eq!(moved.lifecycle.marker_at(Point::new(x, 85.)), Some(create)); + let alive = moved + .lifecycle + .alive_spans(provider, owner, 0., moved.size.height); + assert_eq!( + alive + .iter() + .map(|span| (span.start, span.end)) + .collect::>(), + vec![(85., 427.)] + ); + passive(&document, &before, &source); +} + +#[test] +fn earlier_death_reaches_380_and_actual_lifetime_and_activation_clip_there() { + let (document, owner) = fixture(true); + let p = document.project(); + let before = p.clone(); + let source = document.bundle().clone(); + let baseline = Scene::sequence(p, owner, owner); + let create = id(p, "create_receiver"); + let call = id(p, "service_call"); + let delete = id(p, "delete_receiver"); + let provider = id(p, "provider"); + assert_eq!( + ( + row(&baseline, create), + row(&baseline, call), + row(&baseline, delete) + ), + (243., 335., 427.) + ); + let candidate = drag(&Candidate::new(), &baseline, delete, -47.); + let plan = Plan::build(p, owner, &baseline, &candidate).unwrap(); + assert_eq!(plan.anchors[&delete].role, AnchorRole::MessageReceive); + assert_eq!( + plan.messages[&delete], + MessageAnchors { + send_y: 380., + receive_y: 380. + } + ); + let moved = render(p, owner, &plan); + assert_eq!(row(&moved, delete), 380.); + others_fixed(&baseline, &moved, delete); + let spans = moved + .lifecycle + .alive_spans(provider, owner, 85., moved.size.height - 20.); + assert_eq!( + spans.iter().map(|s| (s.start, s.end)).collect::>(), + vec![(243., 380.)] + ); + assert!( + moved + .lifecycle + .alive_spans(provider, owner, 380., moved.size.height - 20.) + .is_empty() + ); + let x = moved.lanes[&provider]; + assert_eq!(moved.lifecycle.marker_at(Point::new(x, 380.)), Some(delete)); + assert_eq!(moved.lifecycle.marker_at(Point::new(x, 427.)), None); + assert_eq!(moved.hit(Point::new(x, 380.)), Some(delete)); + let bars: Vec<_> = moved + .boxes + .iter() + .filter(|b| b.id == call && b.title.is_empty()) + .collect(); + assert_eq!(bars.len(), 1); + assert_eq!( + bars[0].rect, + Rectangle::new(Point::new(x - 4., 335.), Size::new(8., 45.)) + ); + assert_eq!(moved.open_calls, vec![Point::new(x + 12., 364.)]); + assert!(plan.diagnostics.is_empty()); + passive(&document, &before, &source); +} + +#[test] +fn self_call_has_explicit_send610_receive634_and_reply_stays728() { + let (document, owner) = fixture(false); + let p = document.project(); + let before = p.clone(); + let source = document.bundle().clone(); + let baseline = Scene::sequence(p, owner, owner); + let call = id(p, "self_call"); + let reply = id(p, "self_reply"); + let candidate = drag(&Candidate::new(), &baseline, call, -26.); + let plan = Plan::build(p, owner, &baseline, &candidate).unwrap(); + assert_eq!( + plan.anchors[&call], + Anchor { + y: 610., + role: AnchorRole::MessageSend + } + ); + assert_eq!( + plan.messages[&call], + MessageAnchors { + send_y: 610., + receive_y: 634. + } + ); + let moved = render(p, owner, &plan); + assert_eq!(row(&moved, call), 610.); + assert_eq!(row(&moved, reply), 728.); + assert_eq!( + line(&moved, call) + .points + .iter() + .map(|p| p.y) + .collect::>(), + vec![610., 610., 634., 634.] + ); + assert_eq!(line(&moved, reply).points, line(&baseline, reply).points); + others_fixed(&baseline, &moved, call); + // Peer #21 owns the activation start/column contract. This assertion covers + // actual message anchors, not the old row-based activation start defect. + passive(&document, &before, &source); +} + +#[test] +fn signed_zero_keeps_sparse_candidate_and_restoring_original_candidate_restores_actual_scene() { + let (document, owner) = fixture(false); + let p = document.project(); + let before = p.clone(); + let source = document.bundle().clone(); + let baseline = Scene::sequence(p, owner, owner); + let second = id(p, "second"); + let original = Candidate::new(); + for zero in [0., -0.] { + assert_eq!(drag(&original, &baseline, second, zero), original); + } + let changed = drag(&original, &baseline, second, -40.); + let plan = Plan::build(p, owner, &baseline, &changed).unwrap(); + let frame = render(p, owner, &plan); + for zero in [0., -0.] { + let untouched = drag(&changed, &frame, second, zero); + assert_eq!(untouched, changed); + same_scene( + &frame, + &render( + p, + owner, + &Plan::build(p, owner, &baseline, &untouched).unwrap(), + ), + ); + } + // This restores the exact sidecar candidate; it is not a production Undo + // command or evidence of a layout source/REST persistence implementation. + same_scene( + &baseline, + &render( + p, + owner, + &Plan::build(p, owner, &baseline, &original).unwrap(), + ), + ); + passive(&document, &before, &source); +} + +#[test] +fn crossing_is_rendered_exactly_and_diagnosed_with_extents_covering_the_earlier_far_event() { + let (document, owner) = fixture(false); + let p = document.project(); + let before = p.clone(); + let source = document.bundle().clone(); + let baseline = Scene::sequence(p, owner, owner); + let first = id(p, "first"); + let second = id(p, "second"); + let plan = Plan::build(p, owner, &baseline, &Candidate::from([(first, 1000.)])).unwrap(); + assert!(plan.diagnostics.contains(&Diagnostic::SourceOrderCrossing { + scope: owner, + before: first, + after: second + })); + let moved = render(p, owner, &plan); + assert_eq!((row(&moved, first), row(&moved, second)), (1000., 240.)); + assert_eq!(row(&moved, id(p, "self_reply")), 728.); + others_fixed(&baseline, &moved, first); + assert_eq!(moved.sequence_bounds[&owner].1, 1026.); + assert_eq!(moved.size.height, 1113.); + assert!( + line(&moved, first) + .points + .iter() + .all(|point| point.y < moved.size.height) + ); + assert_eq!(&p.elements[&owner].steps().unwrap()[..2], &[first, second]); + // This is a policy falsifier: mechanically exact coordinates do not prove + // that out-of-order vertical notation is understandable or acceptable. + passive(&document, &before, &source); +} + +#[test] +fn nested_outlier_expands_enclosing_geometry_without_using_final_traversal_row_as_extent() { + let (document, owner) = fixture(false); + let p = document.project(); + let baseline = Scene::sequence(p, owner, owner); + let nested = id(p, "nested"); + let fragment = id(p, "fragment"); + let following = id(p, "self_call"); + let plan = Plan::build(p, owner, &baseline, &Candidate::from([(nested, 1000.)])).unwrap(); + assert!( + plan.diagnostics + .contains(&Diagnostic::FrameExtendsPastFollowingAnchor { + frame: fragment, + following + }) + ); + let moved = render(p, owner, &plan); + assert_eq!(row(&moved, nested), 1000.); + assert_eq!(named_box(&moved, nested).rect.y, 980.); + let enclosing = named_box(&moved, fragment).rect; + assert_eq!(enclosing.y + enclosing.height, 1070.); + assert!(moved.sequence_bounds[&owner].1 >= 1070.); + assert!(moved.size.height > 1070.); + assert_eq!(row(&moved, following), 636.); + others_fixed(&baseline, &moved, nested); +} + +#[test] +fn unsupported_targets_and_lifetime_inversion_fail_explicitly_instead_of_silent_clamping() { + let (document, owner) = fixture(true); + let p = document.project(); + let baseline = Scene::sequence(p, owner, owner); + let create = id(p, "create_receiver"); + let delete = id(p, "delete_receiver"); + assert_eq!( + Plan::build(p, owner, &baseline, &Candidate::from([(delete, 200.)])).unwrap_err(), + PlanError::LifetimeInversionNotSupported { create, delete } + ); + assert_eq!( + Plan::build(p, owner, &baseline, &Candidate::from([(delete, f32::NAN)])).unwrap_err(), + PlanError::NonFinite(delete) + ); + let edge = id(p, "service"); + assert_eq!( + Plan::build(p, owner, &baseline, &Candidate::from([(edge, 200.)])).unwrap_err(), + PlanError::UnknownAnchor(edge) + ); + let (other, other_owner) = fixture(false); + let fragment = id(other.project(), "fragment"); + let other_scene = Scene::sequence(other.project(), other_owner, other_owner); + assert_eq!( + Plan::build( + other.project(), + other_owner, + &other_scene, + &Candidate::from([(fragment, 300.)]) + ) + .unwrap_err(), + PlanError::CompoundMoveNotImplemented(fragment) + ); +} + +#[test] +fn complete_plan_scope_is_inactive_in_other_threads_and_restored_after_deliberate_panic() { + let (document, owner) = fixture(false); + let p = document.project(); + let baseline = Scene::sequence(p, owner, owner); + let second = id(p, "second"); + let plan = Plan::build(p, owner, &baseline, &Candidate::from([(second, 200.)])).unwrap(); + let caught = std::panic::catch_unwind(|| { + let _scope = Scope::install(&plan); + assert_eq!(row(&Scene::sequence(p, owner, owner), second), 200.); + std::thread::spawn(|| { + let (document, owner) = fixture(false); + let p = document.project(); + assert_eq!( + row(&Scene::sequence(p, owner, owner), id(p, "second")), + 240. + ); + }) + .join() + .unwrap(); + panic!("deliberate complete-anchor scope unwind"); + }); + let payload = caught.expect_err("deliberate panic is required"); + assert_eq!( + payload.downcast_ref::<&str>().copied(), + Some("deliberate complete-anchor scope unwind") + ); + same_scene(&baseline, &Scene::sequence(p, owner, owner)); +} diff --git a/harness/experiments/behavior-anchor-plan-20260912/wiring.patch b/harness/experiments/behavior-anchor-plan-20260912/wiring.patch new file mode 100644 index 00000000..dc27b942 --- /dev/null +++ b/harness/experiments/behavior-anchor-plan-20260912/wiring.patch @@ -0,0 +1,51 @@ +diff --git a/crates/oa-desktop/src/behavior.rs b/crates/oa-desktop/src/behavior.rs +index 3f9fd97..f1ef5b7 100644 +--- a/crates/oa-desktop/src/behavior.rs ++++ b/crates/oa-desktop/src/behavior.rs +@@ -1121,6 +1121,10 @@ fn in_machine(p: &Project, id: Id, machine: Id) -> bool { + #[path = "../../../harness/experiments/behavior-layout-scene-20260912/mod.rs"] + mod behavior_layout_scene_experiment; + ++#[cfg(test)] ++#[path = "../../../harness/experiments/behavior-anchor-plan-20260912/mod.rs"] ++mod behavior_anchor_plan_experiment; ++ + struct BoxItem { + id: Id, + rect: Rectangle, +@@ -1332,6 +1336,8 @@ impl Scene { + let mut y = if scenario { 148. } else { 120. }; + scene.occurrences(p, owner, scenario, &lane_x, &mut y, 0); + scene.size.height = (y + 45.).max(210.); ++ #[cfg(test)] ++ behavior_anchor_plan_experiment::scene_extent(&mut scene); + if scenario { + scene.lifecycle = scenario_lifecycle::render::Rendering::new( + p, +@@ -1441,6 +1447,8 @@ impl Scene { + } + #[cfg(test)] + behavior_layout_scene_experiment::resolve_row_anchor(*id, y); ++ #[cfg(test)] ++ behavior_anchor_plan_experiment::row_anchor(*id, y); + if ordered { + let slot = scenario::Slot { + parent: owner, +@@ -1465,6 +1473,8 @@ impl Scene { + (*y - top).max(95.), + ), + ); ++ #[cfg(test)] ++ let rect = behavior_anchor_plan_experiment::frame_bounds(*id, rect); + self.boxes.insert( + 0, + BoxItem { +@@ -1649,6 +1659,8 @@ impl Scene { + } + if ordered { + self.sequence_bounds.insert(owner, (sequence_top, *y - 42.)); ++ #[cfg(test)] ++ behavior_anchor_plan_experiment::scope_end(owner, y, &mut self.sequence_bounds); + let slot = scenario::Slot { + parent: owner, + before: None, diff --git a/harness/experiments/behavior-anchor-plan-review-20260912/.gitattributes b/harness/experiments/behavior-anchor-plan-review-20260912/.gitattributes new file mode 100644 index 00000000..e9d44d97 --- /dev/null +++ b/harness/experiments/behavior-anchor-plan-review-20260912/.gitattributes @@ -0,0 +1,4 @@ +attempt1/** -text +attempt2/** -text +attempt1/harness/experiments/behavior-anchor-plan-20260912/wiring.patch -whitespace +attempt2/harness/experiments/behavior-anchor-plan-20260912/wiring.patch -whitespace diff --git a/harness/experiments/behavior-anchor-plan-review-20260912/README.md b/harness/experiments/behavior-anchor-plan-review-20260912/README.md new file mode 100644 index 00000000..e603ceaf --- /dev/null +++ b/harness/experiments/behavior-anchor-plan-review-20260912/README.md @@ -0,0 +1,14 @@ +# Complete-anchor plan: independent static findings + +The [original five-file candidate and hashes](attempt1/manifest.json), based on66a1f1690f778c8814bfefff7f46fe7f6bbe520e, are preserved exactly. It was not compiled or executed. These are source-verified counterexamples, not recorded runtime failures. Two reviewers independently examined the first; the independent reviewer also traced the second into actual lifecycle projection. + +1. In the original named scenario fixture, setting the nested action anchor to300 moves its box to280..350. Frame coverage expands the fragment from top332 to260 while its FrameStart anchor and Scene row remain332; title and header hit geometry follow260..298, and the before-slot remains290. The existing rows-only unchanged-anchor check cannot detect this split. A bounded repair must keep the frame header invariant or reject this unsupported operation explicitly until a compound/header policy is defined. +2. In the lifetime fixture, setting Create to50 passes the candidate admission checks, but the actual lifecycle renderer starts the root span at85. Its projection rejects the birth as missing geometry; the message and row can remain50 while the real birth marker disappears. Admission must account for the actual supported scope or expose an explicit unsupported result, rather than claiming preserved lifecycle geometry. + +Feasible upward movement inside the existing headers/lifetimes remains required by the experiment. Neither finding authorizes turning preferred spacing into a permanent user movement restriction. The corrected candidate, compiler results and production-policy decision remain separate. No native, persistence or Undo acceptance is claimed. + +## Additional scope-boundary finding before execution + +The [two-fix candidate](attempt2/manifest.json) remains uncompiled. Its author found that the admitted nested353 overlap case can expand an operand scope upward to311, before the enclosing fragment starts332. The existing lifecycle projector rejects that scope as missing geometry. Earlier review of the two guards did not establish this ancestor relationship. The next bounded bridge must reject incompatible scope intervals explicitly and test the actual lifecycle diagnostics; overlapping header geometry alone must not become a global spacing ban. These accumulating dependencies show that complete anchors require a coherent scope/lifecycle contract. The legacy renderer is not demonstrated to support arbitrary free placement by changing its cursor. + +The three exact wiring.patch artifacts retain CRLF and patch-context blank lines. Git whitespace checking is disabled only for those raw patch paths; their recorded bytes are preserved. All other changed source and report paths remain checked. diff --git a/harness/experiments/behavior-anchor-plan-review-20260912/attempt1/crates/oa-desktop/src/behavior.rs b/harness/experiments/behavior-anchor-plan-review-20260912/attempt1/crates/oa-desktop/src/behavior.rs new file mode 100644 index 00000000..f1ef5b70 --- /dev/null +++ b/harness/experiments/behavior-anchor-plan-review-20260912/attempt1/crates/oa-desktop/src/behavior.rs @@ -0,0 +1,3515 @@ +//! Readable behavioral projections. Selection edits the same typed records as text. +use crate::{ + app::{App, Choice, Message}, + style, + views::{action, heading, label, short}, +}; +use iced::{ + Color, Element, Fill, Point, Rectangle, Renderer, Size, Theme, mouse, + widget::{ + Action, button, + canvas::{self, Frame, Path, Stroke, Text}, + column, container, pick_list, row, scrollable, text, + }, +}; +use oa_core::*; +use std::collections::BTreeMap; +pub(crate) mod canvas_workspace; +#[cfg(test)] +mod contextual_source_tests; +pub(crate) mod creation; +mod form_session; +pub(crate) mod scenario; +mod scenario_lifecycle; +pub(crate) mod source_draft; +pub(crate) mod state_canvas; +pub(crate) mod state_edit; +pub(crate) mod state_menu; +pub(crate) mod state_view; +pub(crate) mod states; +pub(crate) mod topology; +// The standard on_scroll callback only reports active scrolling. This local +// wrapper reads the existing Scrollable's actual translation as well, including +// initial mount and passive size clamping. It forwards layout, input and drawing. +mod scenario_scroll { + use super::*; + use iced::advanced::{Clipboard, Layout, Shell, Widget, layout, renderer, widget}; + use iced::{Length, Vector, overlay}; + + pub(super) fn observe<'a, R: renderer::Renderer + 'a>( + context: scenario::Context, + area: scenario::ScrollArea, + process: bool, + restore: Option, + content: impl Into>, + ) -> Element<'a, Message, Theme, R> { + Element::new(Observer { + content: content.into(), + context, + area, + process, + restore, + }) + } + struct Observer<'a, R = Renderer> { + content: Element<'a, Message, Theme, R>, + context: scenario::Context, + area: scenario::ScrollArea, + process: bool, + restore: Option, + } + type Report = Option<(bool, Id, Id, Vector)>; + struct ObservedScroll { + identity: (Id, Id), + report: Report, + restored: Option, + } + #[derive(Default)] + struct Translation(Option); + impl widget::Operation for Translation { + fn traverse(&mut self, _: &mut dyn FnMut(&mut dyn widget::Operation)) {} + fn scrollable( + &mut self, + _: Option<&widget::Id>, + _: Rectangle, + _: Rectangle, + translation: Vector, + _: &mut dyn widget::operation::Scrollable, + ) { + self.0 = Some(translation); + } + } + struct RestoreTranslation { + offset: Vector, + applied: bool, + } + impl widget::Operation for RestoreTranslation { + // Restore only this wrapper's root Scrollable, never its nested canvas. + fn traverse(&mut self, _: &mut dyn FnMut(&mut dyn widget::Operation)) {} + fn scrollable( + &mut self, + _: Option<&widget::Id>, + _: Rectangle, + _: Rectangle, + _: Vector, + state: &mut dyn widget::operation::Scrollable, + ) { + state.scroll_to(widget::operation::scrollable::AbsoluteOffset { + x: Some(self.offset.x), + y: Some(self.offset.y), + }); + self.applied = true; + } + } + impl Observer<'_, R> { + fn report( + &mut self, + tree: &mut widget::Tree, + layout: Layout<'_>, + renderer: &R, + shell: &mut Shell<'_, Message>, + ) { + let restore = self.restore.filter(|request| { + !self.process + && (request.document, request.owner) + == (self.context.document, self.context.scenario) + && tree.state.downcast_ref::().restored != Some(request.token) + }); + let receipt = restore.and_then(|request| { + let mut operation = RestoreTranslation { + offset: request.offset, + applied: false, + }; + self.content.as_widget_mut().operate( + &mut tree.children[0], + layout, + renderer, + &mut operation, + ); + operation.applied.then_some(request.token) + }); + let mut offset = Translation::default(); + self.content.as_widget_mut().operate( + &mut tree.children[0], + layout, + renderer, + &mut offset, + ); + if let Some(offset) = offset.0 { + let next = Some(( + self.process, + self.context.document, + self.context.scenario, + offset, + )); + let state = tree.state.downcast_mut::(); + if let Some(token) = receipt { + state.restored = Some(token); + state.report = next; + shell.publish(Message::ScenarioCanvas(scenario::CanvasEvent::Restored( + self.context, + token, + self.area, + offset, + ))); + return; + } + let previous = &mut state.report; + if *previous != next { + *previous = next; + shell.publish(if self.process { + Message::TopologyCanvas(topology::CanvasEvent::Scrolled( + topology::Context { + document: self.context.document, + owner: self.context.scenario, + revision: self.context.revision, + epoch: self.context.epoch, + }, + self.area, + offset, + )) + } else { + Message::ScenarioCanvas(scenario::CanvasEvent::Scrolled( + self.context, + self.area, + offset, + )) + }); + } + } + } + } + impl Widget for Observer<'_, R> { + fn tag(&self) -> widget::tree::Tag { + widget::tree::Tag::of::() + } + fn state(&self) -> widget::tree::State { + widget::tree::State::new(ObservedScroll { + identity: (self.context.document, self.context.scenario), + report: None, + restored: None, + }) + } + fn children(&self) -> Vec { + vec![widget::Tree::new(self.content.as_widget())] + } + fn diff(&self, tree: &mut widget::Tree) { + if tree.state.downcast_ref::().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(); + tree.children = self.children(); + } else { + tree.diff_children(std::slice::from_ref(&self.content)); + } + } + fn size(&self) -> Size { + self.content.as_widget().size() + } + fn size_hint(&self) -> Size { + self.content.as_widget().size_hint() + } + fn layout( + &mut self, + tree: &mut widget::Tree, + renderer: &R, + limits: &layout::Limits, + ) -> layout::Node { + self.content + .as_widget_mut() + .layout(&mut tree.children[0], renderer, limits) + } + fn update( + &mut self, + tree: &mut widget::Tree, + event: &iced::Event, + layout: Layout<'_>, + cursor: mouse::Cursor, + renderer: &R, + clipboard: &mut dyn Clipboard, + shell: &mut Shell<'_, Message>, + viewport: &Rectangle, + ) { + // A passive clamp notification precedes any pointer result produced + // with the old context; App rejects that result after the epoch bump. + self.report(tree, layout, renderer, shell); + self.content.as_widget_mut().update( + &mut tree.children[0], + event, + layout, + cursor, + renderer, + clipboard, + shell, + viewport, + ); + self.report(tree, layout, renderer, shell); + } + fn draw( + &self, + tree: &widget::Tree, + renderer: &mut R, + theme: &Theme, + style: &renderer::Style, + layout: Layout<'_>, + cursor: mouse::Cursor, + viewport: &Rectangle, + ) { + self.content.as_widget().draw( + &tree.children[0], + renderer, + theme, + style, + layout, + cursor, + viewport, + ); + } + fn operate( + &mut self, + tree: &mut widget::Tree, + layout: Layout<'_>, + renderer: &R, + operation: &mut dyn widget::Operation, + ) { + self.content.as_widget_mut().operate( + &mut tree.children[0], + layout, + renderer, + operation, + ); + } + fn mouse_interaction( + &self, + tree: &widget::Tree, + layout: Layout<'_>, + cursor: mouse::Cursor, + viewport: &Rectangle, + renderer: &R, + ) -> mouse::Interaction { + self.content.as_widget().mouse_interaction( + &tree.children[0], + layout, + cursor, + viewport, + renderer, + ) + } + fn overlay<'b>( + &'b mut self, + tree: &'b mut widget::Tree, + layout: Layout<'b>, + renderer: &R, + viewport: &Rectangle, + translation: Vector, + ) -> Option> { + self.content.as_widget_mut().overlay( + &mut tree.children[0], + layout, + renderer, + viewport, + translation, + ) + } + } + // Iced exposes its null renderer only in debug builds. + #[cfg(all(test, debug_assertions))] + mod tests { + use super::*; + fn observer(height: f32, context: scenario::Context) -> Observer<'static, ()> { + Observer { + context, + area: scenario::ScrollArea::Page, + process: false, + restore: None, + content: scrollable(iced::widget::Space::new().width(200.).height(height)) + .id("observer-test") + .width(200.) + .height(200.) + .into(), + } + } + fn update( + observer: &mut Observer<'_, ()>, + tree: &mut widget::Tree, + event: iced::Event, + ) -> (Vec, bool) { + let node = observer.layout( + tree, + &(), + &layout::Limits::new(Size::ZERO, Size::new(800., 700.)), + ); + let mut messages = vec![]; + let mut shell = Shell::new(&mut messages); + observer.update( + tree, + &event, + Layout::new(&node), + mouse::Cursor::Available(Point::new(50., 50.)), + &(), + &mut iced::advanced::clipboard::Null, + &mut shell, + &Rectangle::with_size(Size::new(800., 700.)), + ); + let captured = shell.is_event_captured(); + (messages, captured) + } + fn offsets(messages: &[Message]) -> Vec { + messages + .iter() + .filter_map(|message| { + if let Message::ScenarioCanvas(scenario::CanvasEvent::Scrolled(_, _, offset)) = + message + { + Some(*offset) + } else { + None + } + }) + .collect() + } + fn actual_offset(view: &mut Observer<'_, ()>, tree: &mut widget::Tree) -> Vector { + let node = view.layout( + tree, + &(), + &layout::Limits::new(Size::ZERO, Size::new(800., 700.)), + ); + let mut operation = Translation::default(); + view.operate(tree, Layout::new(&node), &(), &mut operation); + operation.0.unwrap() + } + #[test] + fn direct_scenario_caller_restores_mounted_scroll_once_with_clamping_and_app_receipt() { + use scenario::{ + CanvasEvent, ScrollArea, + direct_tests::{navigation_event, navigation_fixture}, + }; + let redraw = || { + iced::Event::Window(iced::window::Event::RedrawRequested( + std::time::Instant::now(), + )) + }; + let area = ScrollArea::Page; + for returned_height in [600., 250.] { + let (project, [a, _, _], [use_id, _, _]) = navigation_fixture(); + let mut app = App::with_project(project.clone()); + let _ = app.update(Message::Select(a)); + let _ = app.update(Message::Page(crate::app::Page::Behavior)); + let source = app.document.bundle().clone(); + let mut view = observer(600., app.scenario_context().unwrap()); + view.area = area; + let mut tree = widget::Tree::new(&view as &dyn Widget); + for message in update(&mut view, &mut tree, redraw()).0 { + let _ = app.update(message); + } + for message in update( + &mut view, + &mut tree, + iced::Event::Mouse(mouse::Event::WheelScrolled { + delta: mouse::ScrollDelta::Pixels { x: 0., y: -180. }, + }), + ) + .0 + { + let _ = app.update(message); + } + assert_eq!(actual_offset(&mut view, &mut tree), Vector::new(0., 180.)); + navigation_event(&mut app, |ctx| CanvasEvent::OpenUse(ctx, use_id)); + view = observer(600., app.scenario_context().unwrap()); + view.area = area; + view.diff(&mut tree); + for message in update(&mut view, &mut tree, redraw()).0 { + let _ = app.update(message); + } + assert_eq!(actual_offset(&mut view, &mut tree), Vector::ZERO); + navigation_event(&mut app, CanvasEvent::Back); + assert_eq!(app.selected, use_id); + let context = app.scenario_context().unwrap(); + let request = app.behavior.scenario.scroll_restore(context, area).unwrap(); + view = observer(returned_height, context); + view.area = area; + view.restore = Some(request); + view.diff(&mut tree); + let expected = Vector::new(0., (returned_height - 200.).min(180.)); + let messages = update(&mut view, &mut tree, redraw()).0; + assert!( + matches!(messages.as_slice(), [Message::ScenarioCanvas(CanvasEvent::Restored(_, _, _, actual))] if *actual == expected) + ); + assert_eq!(actual_offset(&mut view, &mut tree), expected); + for message in messages { + let _ = app.update(message); + } + assert!( + app.behavior + .scenario + .scroll_restore(app.scenario_context().unwrap(), area) + .is_none() + ); + // A delayed receipt leaves the same request in the view; later input must not snap back. + for message in update( + &mut view, + &mut tree, + iced::Event::Mouse(mouse::Event::WheelScrolled { + delta: mouse::ScrollDelta::Pixels { x: 0., y: 20. }, + }), + ) + .0 + { + let _ = app.update(message); + } + let moved = expected - Vector::new(0., 20.); + assert_eq!(actual_offset(&mut view, &mut tree), moved); + view.context = app.scenario_context().unwrap(); + view.diff(&mut tree); + assert!(update(&mut view, &mut tree, redraw()).0.is_empty()); + assert_eq!(actual_offset(&mut view, &mut tree), moved); + navigation_event(&mut app, |ctx| { + CanvasEvent::Restored(ctx, request.token, area, Vector::new(0., 999.)) + }); + let late_context = app.scenario_context().unwrap(); + let _ = app.update(Message::Page(crate::app::Page::Diagram)); + let epoch = app.canvas_epoch; + let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Restored( + late_context, + request.token, + area, + Vector::ZERO, + ))); + assert_eq!(app.canvas_epoch, epoch); + assert_eq!(app.project(), &project); + assert_eq!(app.document.bundle(), &source); + assert!(!app.document.can_undo()); + } + } + #[test] + fn direct_scenario_restore_cannot_scroll_another_document_owner_or_process_view() { + 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, + token: Id::new_v4(), + offset: Vector::new(0., 180.), + }; + match mismatch { + 0 => request.document = Id::new_v4(), + 1 => request.owner = Id::new_v4(), + _ => {} + } + let mut view = observer(600., context); + view.process = mismatch == 2; + view.restore = Some(request); + let mut tree = widget::Tree::new(&view as &dyn Widget); + let messages = update( + &mut view, + &mut tree, + iced::Event::Window(iced::window::Event::RedrawRequested( + std::time::Instant::now(), + )), + ) + .0; + assert_eq!(actual_offset(&mut view, &mut tree), Vector::ZERO); + assert!(!messages.iter().any(|m| matches!( + m, + Message::ScenarioCanvas(scenario::CanvasEvent::Restored(..)) + ))); + } + } + #[test] + fn independent_process_owner_change_resets_actual_scroll_offsets() { + let context = scenario::direct_tests::context(Id::new_v4()); + let redraw = || { + iced::Event::Window(iced::window::Event::RedrawRequested( + std::time::Instant::now(), + )) + }; + let process_offsets = |messages: Vec| { + messages + .into_iter() + .filter_map(|message| { + if let Message::TopologyCanvas(topology::CanvasEvent::Scrolled( + _, + _, + offset, + )) = message + { + Some(offset) + } else { + None + } + }) + .collect::>() + }; + let mut view = observer(600., context); + view.process = true; + let mut tree = widget::Tree::new(&view as &dyn Widget); + assert_eq!( + process_offsets(update(&mut view, &mut tree, redraw()).0), + vec![Vector::ZERO] + ); + let (messages, captured) = update( + &mut view, + &mut tree, + iced::Event::Mouse(mouse::Event::WheelScrolled { + delta: mouse::ScrollDelta::Pixels { x: 0., y: -180. }, + }), + ); + assert!(captured); + assert_eq!(process_offsets(messages), vec![Vector::new(0., 180.)]); + let mut other = context; + other.scenario = Id::new_v4(); + let mut state = topology::State::default(); + state.enter(context.document, context.scenario); + state.scrolled(scenario::ScrollArea::Page, Vector::new(0., 180.)); + state.enter(other.document, other.scenario); + assert_eq!(state.details_offset(), Vector::ZERO); + view = observer(600., other); + view.process = true; + view.diff(&mut tree); + let reported = process_offsets(update(&mut view, &mut tree, redraw()).0); + assert_eq!( + reported, + vec![Vector::ZERO], + "The new process immediately reacquires its predecessor's scroll offset" + ); + } + + #[test] + fn direct_scroll_mounted_identity_resets_navigation_but_preserves_edits_before_first_report() + { + use widget::operation::scrollable::{AbsoluteOffset, scroll_to}; + for process in [false, true] { + let area = scenario::ScrollArea::Page; + for change in 0..4 { + let context = scenario::direct_tests::context(Id::new_v4()); + let mut view = observer(600., context); + view.process = process; + view.area = area; + let mut tree = widget::Tree::new(&view as &dyn Widget); + let node = view.layout( + &mut tree, + &(), + &layout::Limits::new(Size::ZERO, Size::new(800., 700.)), + ); + // Model a programmatic reveal before the observer has reported. + // Mounted identity must not depend on its last scroll notification. + let mut operation = scroll_to::<()>( + widget::Id::new("observer-test"), + AbsoluteOffset { + x: None, + y: Some(180.), + }, + ); + view.operate(&mut tree, Layout::new(&node), &(), &mut operation); + let mut next = context; + match change { + 0 => next.scenario = Id::new_v4(), + 1 => next.document = Id::new_v4(), + 2 => { + next.revision += 1; + next.epoch += 5; + } + _ => {} + } + view = observer(if change == 3 { 800. } else { 600. }, next); + view.process = process; + view.area = area; + view.diff(&mut tree); + let redraw = || { + iced::Event::Window(iced::window::Event::RedrawRequested( + std::time::Instant::now(), + )) + }; + let (messages, _) = update(&mut view, &mut tree, redraw()); + let actual = messages + .into_iter() + .filter_map(|message| match message { + Message::TopologyCanvas(topology::CanvasEvent::Scrolled( + c, + reported_area, + offset, + )) => { + assert_eq!( + (c.document, c.owner, c.revision, c.epoch), + (next.document, next.scenario, next.revision, next.epoch) + ); + assert_eq!( + std::mem::discriminant(&reported_area), + std::mem::discriminant(&area) + ); + Some(offset) + } + Message::ScenarioCanvas(scenario::CanvasEvent::Scrolled( + c, + reported_area, + offset, + )) => { + assert_eq!(c, next); + assert_eq!( + std::mem::discriminant(&reported_area), + std::mem::discriminant(&area) + ); + Some(offset) + } + _ => None, + }) + .collect::>(); + assert_eq!( + actual, + vec![if change < 2 { + Vector::ZERO + } else { + Vector::new(0., 180.) + }], + "process={process} area={area:?} change={change}" + ); + assert!(update(&mut view, &mut tree, redraw()).0.is_empty()); + } + } + } + + #[test] + fn direct_scroll_observer_reports_actual_initial_clamped_and_remounted_offsets_once() { + let context = scenario::direct_tests::context(Id::new_v4()); + let redraw = || { + iced::Event::Window(iced::window::Event::RedrawRequested( + std::time::Instant::now(), + )) + }; + let mut view = observer(600., context); + let mut tree = widget::Tree::new(&view as &dyn Widget); + assert_eq!( + offsets(&update(&mut view, &mut tree, redraw()).0), + vec![Vector::ZERO] + ); + assert!(update(&mut view, &mut tree, redraw()).0.is_empty()); + let (messages, captured) = update( + &mut view, + &mut tree, + iced::Event::Mouse(mouse::Event::WheelScrolled { + delta: mouse::ScrollDelta::Pixels { x: 0., y: -180. }, + }), + ); + assert!(captured, "the observer forwards normal Scrollable capture"); + assert_eq!(offsets(&messages), vec![Vector::new(0., 180.)]); + assert!(update(&mut view, &mut tree, redraw()).0.is_empty()); + view = observer(250., context); + view.diff(&mut tree); + assert_eq!( + offsets(&update(&mut view, &mut tree, redraw()).0), + vec![Vector::new(0., 50.)] + ); + assert!(update(&mut view, &mut tree, redraw()).0.is_empty()); + view = observer(600., context); + view.diff(&mut tree); + assert_eq!( + offsets(&update(&mut view, &mut tree, redraw()).0), + vec![Vector::new(0., 180.)] + ); + let mut remounted = widget::Tree::new(&view as &dyn Widget); + assert_eq!( + offsets(&update(&mut view, &mut remounted, redraw()).0), + vec![Vector::ZERO] + ); + assert!(update(&mut view, &mut remounted, redraw()).0.is_empty()); + } + } +} +#[derive(Debug, Clone)] +pub enum Event { + Creation(creation::Event), + Topology(topology::Event), + Scenario(scenario::Event), + States(states::Event), + Submit, +} +impl Event { + pub fn is_source_back(&self) -> bool { + matches!( + self, + Self::Creation(creation::Event::Source(source_draft::Event::Edit( + _, + source_draft::Edit::Back + ))) | Self::Scenario(scenario::Event::Source(source_draft::Event::Edit( + _, + source_draft::Edit::Back + ))) | Self::Topology(topology::Event::Source(source_draft::Event::Edit( + _, + source_draft::Edit::Back + ))) + ) + } + pub fn is_source(&self) -> bool { + matches!( + self, + Self::Creation(creation::Event::Source(_)) + | Self::Scenario(scenario::Event::Source(_)) + | Self::Topology(topology::Event::Source(_)) + ) + } + pub fn starts_form(&self) -> bool { + matches!( + self, + Self::Creation(creation::Event::Open(..)) + | Self::Topology(topology::Event::Start(..)) + | Self::Scenario(scenario::Event::Start(..)) + | Self::States(states::Event::Start(..) | states::Event::Edit(..)) + ) + } + pub fn is_submit(&self) -> bool { + matches!( + self, + Self::Submit + | Self::Creation(creation::Event::Submit) + | Self::Topology(topology::Event::Submit) + | Self::Scenario(scenario::Event::Submit) + | Self::States(states::Event::Submit) + ) + } +} +#[derive(Default)] +pub struct State { + pub(crate) creation: creation::State, + pub active_owner: Option, + pub diagram: state_view::State, + pub(crate) topology: topology::State, + pub(crate) scenario: scenario::State, + states: states::State, +} +const PENDING_DRAFT: &str = "Apply or cancel the current behavior form before starting another."; +#[derive(serde::Serialize, serde::Deserialize)] +#[serde(deny_unknown_fields)] +struct RecoveryDraft { + version: u32, + kind: RecoveryKind, + draft: serde_json::Value, +} +#[derive(serde::Serialize, serde::Deserialize)] +enum RecoveryKind { + Creation, + State, + Scenario, + Process, +} +impl State { + /// Capture only the pending authored form. Pointer gestures, cached choices, + /// viewport geometry and document/revision tokens never cross a restart. + pub fn recovery_snapshot(&self) -> Result, String> { + let drafts = [ + (RecoveryKind::Creation, self.creation.recovery_snapshot()?), + (RecoveryKind::State, self.states.recovery_snapshot()?), + (RecoveryKind::Scenario, self.scenario.recovery_snapshot()?), + (RecoveryKind::Process, self.topology.recovery_snapshot()?), + ]; + let mut drafts = drafts.into_iter().filter_map(|(kind, draft)| { + draft.map(|draft| RecoveryDraft { + version: 1, + kind, + draft, + }) + }); + let first = drafts.next(); + if drafts.next().is_some() { + return Err( + "Several behavior forms are pending; recovery cannot choose one to discard.".into(), + ); + } + first + .map(serde_json::to_value) + .transpose() + .map_err(|e| e.to_string()) + } + + /// Restore raw fields without planning/executing them. Rejected values and + /// incomplete endpoints must remain available for repair after a crash. + pub fn restore_recovery(&mut self, value: serde_json::Value) -> Result<(), String> { + let saved: RecoveryDraft = serde_json::from_value(value).map_err(|e| e.to_string())?; + if saved.version != 1 { + return Err(format!( + "Unsupported behavior recovery version {}", + saved.version + )); + } + let mut restored = Self::default(); + match saved.kind { + RecoveryKind::Creation => { + restored.creation = creation::State::from_recovery(saved.draft)? + } + RecoveryKind::State => restored.states = states::State::from_recovery(saved.draft)?, + RecoveryKind::Scenario => { + restored.scenario = scenario::State::from_recovery(saved.draft)? + } + RecoveryKind::Process => { + restored.topology = topology::State::from_recovery(saved.draft)? + } + } + restored.active_owner = restored.pending_owner(); + *self = restored; + Ok(()) + } + + pub fn has_pending(&self) -> bool { + self.creation.has_pending() || self.pending_owner().is_some() + } + + pub fn pending_owner(&self) -> Option { + self.topology + .pending_owner() + .or_else(|| self.scenario.pending_owner()) + .or_else(|| self.states.pending_owner()) + } + + /// Prepare the active form without discarding inputs. The caller must execute + /// this exact command and acknowledge only a successful document transaction. + pub fn prepare_pending(&self, p: &Project) -> Result, String> { + if self.creation.has_pending() { + self.creation.prepare_pending(p).map(Some) + } else if self.topology.pending_owner().is_some() { + self.topology.prepare_pending(p).map(Some) + } else if self.scenario.pending_owner().is_some() { + self.scenario.prepare_pending(p).map(Some) + } else if self.states.pending_owner().is_some() { + self.states.prepare_pending(p).map(Some) + } else { + Ok(None) + } + } + + /// Clear the active form after its prepared command commits successfully. + /// Rejected commands must leave the form available for repair and retry. + pub fn source_active(&self) -> bool { + self.creation.source_active() + || self.scenario.source_active() + || self.topology.source_active() + } + pub fn source_open(&self) -> bool { + self.creation.source_open() || self.scenario.source_open() || self.topology.source_open() + } + pub fn source_back(&mut self) -> bool { + self.creation.source_back() || self.scenario.source_back() || self.topology.source_back() + } + + pub fn acknowledge_commit(&mut self) { + self.creation.acknowledge_commit(); + self.topology.acknowledge_commit(); + self.scenario.acknowledge_commit(); + self.states.acknowledge_commit(); + } + + pub fn update(&mut self, p: &Project, event: Event) -> Result, String> { + if self.has_pending() + && matches!( + &event, + Event::Creation(creation::Event::Open(..)) + | Event::Topology(topology::Event::Start(..)) + | Event::Scenario(scenario::Event::Start(..)) + | Event::States(states::Event::Start(..) | states::Event::Edit(..)) + ) + { + return Err(PENDING_DRAFT.into()); + } + let cancels_pending = match &event { + Event::Topology(topology::Event::Cancel) => self.topology.pending_owner().is_some(), + Event::Scenario(scenario::Event::Cancel) => self.scenario.pending_owner().is_some(), + Event::States(states::Event::Cancel) => self.states.pending_owner().is_some(), + _ => false, + }; + if cancels_pending { + self.acknowledge_commit(); + return Ok(None); + } + match event { + Event::Creation(event) => self.creation.update(p, event), + Event::Topology(event) => self.topology.update(p, event), + Event::Scenario(event) => self.scenario.update(p, event), + Event::States(event) => self.states.update(p, event), + Event::Submit => self + .prepare_pending(p)? + .map(Some) + .ok_or_else(|| "Choose a behavior modeling tool.".into()), + } + } +} + +pub fn view(app: &App) -> Element<'_, Message> { + let p = app.project(); + if let Some(card) = app.behavior.creation.view(p) { + return card.map(|event| Message::Behavior(Event::Creation(event))); + } + if let Some(owner) = app.behavior.pending_owner() { + let kind = p.elements.get(&owner).map(oa_core::Element::kind); + let unavailable = p.layer_of(owner) != Some(app.layer) + || (app.behavior.scenario.pending_owner().is_some() && kind != Some(Kind::Scenario)) + || (app.behavior.topology.pending_owner().is_some() + && !matches!(kind, Some(Kind::Process | Kind::FunctionalChain))); + if unavailable { + let mut out=column![text("The pending behavior owner is unavailable").size(20),text("Your inputs are retained. Return from the source form or cancel this parent draft before choosing another behavior.").size(13)].spacing(12); + if let Some(name) = app + .behavior + .scenario + .pending_name() + .or_else(|| app.behavior.topology.pending_name()) + { + out = + out.push(text(format!("Pending consumer name: {}", short(name, 80))).size(14)); + } + if let Some(child) = app.behavior.scenario.source_view(p) { + out = out.push(child.map(|e| Message::Behavior(Event::Scenario(e)))); + } else if let Some(child) = app.behavior.topology.source_view(p) { + out = out.push(child.map(|e| Message::Behavior(Event::Topology(e)))); + } else { + let cancel = if app.behavior.scenario.pending_owner().is_some() { + Event::Scenario(scenario::Event::Cancel) + } else if app.behavior.topology.pending_owner().is_some() { + Event::Topology(topology::Event::Cancel) + } else { + Event::States(states::Event::Cancel) + }; + out = out.push(button("Cancel retained draft").on_press(Message::Behavior(cancel))); + } + return container(out).padding(16).width(Fill).height(Fill).into(); + } + } + let choices = p + .elements + .values() + .filter(|e| { + e.layer() == app.layer && (e.kind().is_sequence() || e.kind() == Kind::StateMachine) + }) + .map(|e| Choice { + id: e.id, + label: format!("{} · {}", e.kind(), e.name), + }) + .collect::>(); + let owner = selected_owner(app); + let selected = owner + .and_then(|id| choices.iter().find(|c| c.id == id)) + .or(choices.first()) + .cloned(); + let mut out = column![heading("Behavior & scenarios", "Follow exchanges and operating conditions. Select a message, state or transition to edit it.")].spacing(14); + let Some(selected) = selected else { + return out + .push(label( + "Start a named process, scenario or state machine, then model its behavior here.", + )) + .push(action( + "New", + Message::Behavior(Event::Creation(creation::Event::Open(app.layer))), + )) + .padding(24) + .into(); + }; + let e = &p.elements[&selected.id]; + let compact_canvas = matches!( + e.kind(), + Kind::Process | Kind::FunctionalChain | Kind::Scenario + ); + if e.kind() == Kind::StateMachine { + out = column![text("States & modes").size(20)].spacing(8); + } else if compact_canvas { + // The process/scenario canvas owns its compact remaining-height layout. + out = column![].spacing(6); + } + out = out.push( + row![ + pick_list(choices, Some(selected), Message::BehaviorOwner) + .width(Fill) + .text_size(13) + .padding(if compact_canvas { 6 } else { 10 }), + action( + "New", + Message::Behavior(Event::Creation(creation::Event::Open(app.layer))) + ) + ] + .spacing(8), + ); + if matches!(e.kind(), Kind::Process | Kind::FunctionalChain) { + return topology::view(app, e.id, out); + } + if e.kind() == Kind::Scenario { + return scenario::view(app, e.id, out); + } + state_view::view(app, e.id, out) +} + +pub fn selected_owner(app: &App) -> Option { + let valid = |id: &Id| { + app.project().elements.get(id).is_some_and(|e| { + e.layer() == app.layer && (e.kind().is_sequence() || e.kind() == Kind::StateMachine) + }) + }; + app.behavior + .pending_owner() + .or(app.behavior.active_owner.filter(valid)) + .or_else(|| selected_behavior(app.project(), app.selected).filter(valid)) + .or_else(|| app.project().elements.keys().find(|id| valid(id)).copied()) +} +/// Mirror the core's legacy-versus-explicit process interpretation. Existing +/// exchange-only lists still describe ordered paths until explicitly migrated. +fn uses_explicit_topology(p: &Project, owner: Id) -> bool { + let Some(e) = p.elements.get(&owner) else { + return false; + }; + if !matches!(e.kind(), Kind::Process | Kind::FunctionalChain) { + return false; + } + p.relations.values().any(|r| { + r.kind() == RelationKind::SequenceLink + && p.elements.get(&r.source()).and_then(|e| e.parent) == Some(owner) + && p.elements.get(&r.target()).and_then(|e| e.parent) == Some(owner) + }) || e.steps().into_iter().flatten().any(|id| { + matches!( + p.elements.get(id).and_then(|e| e.shared()), + Some(SharedBody::Occurrence { + body: OccurrenceBody::Action { .. } + | OccurrenceBody::Control { .. } + | OccurrenceBody::SequenceReference { .. }, + .. + }) + ) + }) +} +pub fn selected_behavior(p: &Project, selected: Id) -> Option { + if let Some(Relation { + body: RelationBody::ContextualLink { owner, .. }, + .. + }) = p.relations.get(&selected) + { + return p + .elements + .get(owner) + .filter(|e| matches!(e.kind(), Kind::Process | Kind::FunctionalChain)) + .map(|e| e.id); + } + let mut current = Some( + p.relations + .get(&selected) + .filter(|r| { + matches!( + r.kind(), + RelationKind::StateTransition + | RelationKind::SequenceLink + | RelationKind::StateAction + ) + }) + .map(|r| r.source()) + .unwrap_or(selected), + ); + while let Some(id) = current { + let e = p.elements.get(&id)?; + if e.kind().is_sequence() || e.kind() == Kind::StateMachine { + return Some(id); + } + current = e.parent; + } + None +} +fn in_machine(p: &Project, id: Id, machine: Id) -> bool { + let mut current = Some(id); + while let Some(id) = current { + if id == machine { + return true; + } + current = p.elements.get(&id).and_then(|e| e.parent); + } + false +} + +#[cfg(test)] +#[path = "../../../harness/experiments/behavior-layout-scene-20260912/mod.rs"] +mod behavior_layout_scene_experiment; + +#[cfg(test)] +#[path = "../../../harness/experiments/behavior-anchor-plan-20260912/mod.rs"] +mod behavior_anchor_plan_experiment; + +struct BoxItem { + id: Id, + rect: Rectangle, + title: String, + detail: String, + marker: Option, +} +fn box_text_clip( + pan: iced::Vector, + rect: Rectangle, + scale: f32, + viewport: Size, +) -> Option { + if !pan.x.is_finite() + || !pan.y.is_finite() + || !scale.is_finite() + || scale <= 0. + || rect.width <= 20. + || rect.height <= 16. + { + return None; + } + Rectangle::new( + Point::new( + pan.x + (rect.x + 10.) * scale, + pan.y + (rect.y + 8.) * scale, + ), + Size::new((rect.width - 20.) * scale, (rect.height - 16.) * scale), + ) + .intersection(&Rectangle::new(Point::ORIGIN, viewport)) +} + +struct Line { + id: Id, + points: Vec, + label: String, + label_at: Point, + dashed: bool, +} +impl Line { + fn label_bounds(&self) -> Rectangle { + Rectangle::new( + self.label_at, + Size::new( + (self.label.chars().count() as f32 * 6.8 + 8.).min(225.), + 25., + ), + ) + } +} +struct Scene { + boxes: Vec, + lines: Vec, + size: Size, + selected: Id, + scale: f32, + camera_pan: iced::Vector, + interaction: Option<(scenario::Context, scenario::CanvasTool, bool)>, + pending_placement: Option<(scenario::Slot, Option)>, + lanes: BTreeMap, + slots: Vec, + rows: BTreeMap, + open_calls: Vec, + sequence_bounds: BTreeMap, + lifecycle: scenario_lifecycle::render::Rendering, +} +struct SequenceSlot { + slot: scenario::Slot, + y: f32, + label: String, +} +#[derive(Default)] +struct SequenceInput { + context: Option, + tool: scenario::CanvasTool, + pointer: Option, + bounds: Option, + source: Option<(Id, scenario::Slot)>, + press: Option, +} +#[derive(Clone, Copy)] +enum SequencePress { + Occurrence { + tool: scenario::Tool, + slot: scenario::Slot, + lifeline: Option, + }, + Message { + source: Id, + slot: scenario::Slot, + start: Point, + first: bool, + moved: bool, + }, + Reorder { + occurrence: Id, + parent: Id, + start: Point, + moved: bool, + }, +} +impl SequenceInput { + fn cancel(&mut self) { + self.source = None; + self.press = None; + } +} +impl Scene { + fn sequence(p: &Project, owner: Id, selected: Id) -> Self { + let mut scene = Self { + scale: 1., + camera_pan: iced::Vector::ZERO, + boxes: vec![], + lines: vec![], + size: Size::new(700., 200.), + selected, + interaction: None, + pending_placement: None, + lanes: BTreeMap::new(), + slots: vec![], + rows: BTreeMap::new(), + open_calls: vec![], + sequence_bounds: BTreeMap::new(), + lifecycle: scenario_lifecycle::render::Rendering::default(), + }; + let mut lanes = p + .children(owner) + .filter(|e| e.kind() == Kind::Lifeline) + .map(|e| { + ( + e.id, + e.name.clone(), + match e.shared() { + Some(SharedBody::Lifeline { + represented: Some(id), + }) => p.named(*id).to_owned(), + _ => "Unassigned participant".into(), + }, + ) + }) + .collect::>(); + let scenario = p.elements[&owner].kind() == Kind::Scenario; + if !scenario { + fn endpoints(p: &Project, steps: &[Id], ids: &mut Vec) { + for id in steps { + if let Some(SharedBody::Occurrence { body, .. }) = + p.elements.get(id).and_then(|e| e.shared()) + { + match body { + OccurrenceBody::Exchange { + exchange: Some(id), .. + } => { + if let Some(r) = p.relations.get(id) { + for endpoint in [r.source(), r.target()] { + if !ids.contains(&endpoint) { + ids.push(endpoint); + } + } + } + } + OccurrenceBody::Fragment { branches, .. } => { + endpoints(p, branches, ids) + } + OccurrenceBody::Branch { occurrences, .. } => { + endpoints(p, occurrences, ids) + } + _ => {} + } + } + } + } + let mut ids = vec![]; + endpoints(p, p.elements[&owner].steps().unwrap_or(&vec![]), &mut ids); + lanes = ids + .into_iter() + .map(|id| { + ( + id, + p.named(id).to_owned(), + p.elements + .get(&id) + .map(|e| e.kind().label()) + .unwrap_or("Endpoint") + .into(), + ) + }) + .collect(); + } + let lane_x = lanes + .iter() + .enumerate() + .map(|(i, (id, ..))| (*id, 160. + i as f32 * 240.)) + .collect::>(); + scene.size.width = (lanes.len() as f32 * 240. + 80.).max(680.); + if scenario { + scene.lanes = lane_x + .iter() + .filter(|(id, _)| { + matches!( + p.elements.get(id).and_then(|e| e.shared()), + Some(SharedBody::Lifeline { + represented: Some(_) + }) + ) + }) + .map(|(id, x)| (*id, *x)) + .collect(); + } + let mut y = if scenario { 148. } else { 120. }; + scene.occurrences(p, owner, scenario, &lane_x, &mut y, 0); + scene.size.height = (y + 45.).max(210.); + #[cfg(test)] + behavior_anchor_plan_experiment::scene_extent(&mut scene); + if scenario { + scene.lifecycle = scenario_lifecycle::render::Rendering::new( + p, + owner, + &lane_x, + &scene.rows, + &scene.sequence_bounds, + scene.size.height, + ); + scene.lifecycle.align_messages(&mut scene.lines); + let calls: Vec<_> = scene + .rows + .keys() + .filter_map(|&id| { + if let Some(SharedBody::Occurrence { + body: + OccurrenceBody::Exchange { + message_kind: MessageKind::Call, + receiver: Some(receiver), + .. + }, + .. + }) = p.elements[&id].shared() + { + Some((id, *lane_x.get(receiver)?)) + } else { + None + } + }) + .collect(); + for (call, x) in calls { + scene.activation_sequence(p, p.elements[&call].parent.unwrap(), call, x, None); + } + } + for (id, name, detail) in lanes { + let x = lane_x[&id]; + if !scenario { + scene.lines.insert( + 0, + Line { + id, + points: vec![Point::new(x, 85.), Point::new(x, scene.size.height - 20.)], + label: String::new(), + label_at: Point::ORIGIN, + dashed: false, + }, + ); + } + scene.boxes.push(BoxItem { + id, + rect: Rectangle::new(Point::new(x - 102., 15.), Size::new(204., 70.)), + title: if scenario { + format!("Lane · {name}") + } else { + name + }, + detail, + marker: None, + }); + } + scene + } + fn occurrences( + &mut self, + p: &Project, + owner: Id, + scenario: bool, + lanes: &BTreeMap, + y: &mut f32, + depth: usize, + ) { + let steps = p + .elements + .get(&owner) + .and_then(|e| { + e.steps().map(Vec::as_slice).or_else(|| { + if let Some(SharedBody::Occurrence { + body: OccurrenceBody::Fragment { branches, .. }, + .. + }) = e.shared() + { + Some(branches.as_slice()) + } else { + None + } + }) + }) + .unwrap_or(&[]); + let ordered = p.elements.get(&owner).and_then(|e| e.steps()).is_some(); + let sequence_top = *y - 42.; + for (index, id) in steps.iter().enumerate() { + let Some(e) = p.elements.get(id) else { + continue; + }; + let Some(SharedBody::Occurrence { body, .. }) = e.shared() else { + continue; + }; + if scenario + && matches!(body, OccurrenceBody::Exchange { + sender: Some(sender), receiver: Some(receiver), + message_kind: MessageKind::Create | MessageKind::Delete, .. + } if sender == receiver) + { + // Self lifecycle elbows and their caption need clearance from + // the preceding action/state/reference box, before recording rows. + *y += 16.; + } + #[cfg(test)] + behavior_layout_scene_experiment::resolve_row_anchor(*id, y); + #[cfg(test)] + behavior_anchor_plan_experiment::row_anchor(*id, y); + if ordered { + let slot = scenario::Slot { + parent: owner, + before: Some(*id), + }; + self.slots.push(SequenceSlot { + slot, + y: *y - 42., + label: scenario::slot_label(p, slot), + }); + self.rows.insert(*id, (owner, *y)); + } + match body { + OccurrenceBody::Fragment { operator, .. } => { + let top = *y; + *y += 55.; + self.occurrences(p, *id, scenario, lanes, y, depth + 1); + let rect = Rectangle::new( + Point::new(18. + depth as f32 * 10., top), + Size::new( + self.size.width - 36. - depth as f32 * 20., + (*y - top).max(95.), + ), + ); + #[cfg(test)] + let rect = behavior_anchor_plan_experiment::frame_bounds(*id, rect); + self.boxes.insert( + 0, + BoxItem { + id: *id, + rect, + title: format!("{operator:?} · {}", e.name), + detail: String::new(), + marker: Some(Kind::Occurrence), + }, + ); + *y += if scenario { 64. } else { 20. }; + } + OccurrenceBody::Branch { guard, .. } => { + self.boxes.push(BoxItem { + id: *id, + rect: Rectangle::new( + Point::new(36. + depth as f32 * 10., *y), + Size::new(self.size.width - 85. - depth as f32 * 20., 43.), + ), + title: format!( + "[{}]", + if guard.is_empty() { + "guard not specified" + } else { + guard + } + ), + detail: String::new(), + marker: Some(Kind::Occurrence), + }); + *y += if scenario { 90. } else { 62. }; + self.occurrences(p, *id, scenario, lanes, y, depth + 1); + } + OccurrenceBody::Exchange { + exchange, + sender, + receiver, + message_kind, + .. + } => { + let r = exchange.and_then(|id| p.relations.get(&id)); + let source = if scenario { + *sender + } else { + r.map(|r| r.source()) + }; + let target = if scenario { + if matches!(message_kind, MessageKind::Timer { .. }) { + *sender + } else { + *receiver + } + } else { + r.map(|r| r.target()) + }; + if let (Some(a), Some(b)) = ( + source.and_then(|id| lanes.get(&id)), + target.and_then(|id| lanes.get(&id)), + ) { + let points = if a == b { + vec![ + Point::new(*a, *y), + Point::new(a + 75., *y), + Point::new(a + 75., *y + 24.), + Point::new(*a, *y + 24.), + ] + } else { + vec![Point::new(*a, *y), Point::new(*b, *y)] + }; + let kind = if !scenario { + "" + } else { + match message_kind { + MessageKind::Asynchronous => "signal · ", + MessageKind::Call => "call · ", + MessageKind::Reply => "reply · ", + MessageKind::Create => "create · ", + MessageKind::Delete => "delete · ", + MessageKind::Timer { .. } => "timer · ", + } + }; + let mut message_label = if !scenario { + r.map(|r| r.name.clone()).unwrap_or_else(|| e.name.clone()) + } else if let MessageKind::Timer { expression } = message_kind { + format!("{} · {expression}", e.name) + } else if let Some(exchange) = r.filter(|r| r.name != e.name) { + format!("{} · {}", e.name, exchange.name) + } else { + e.name.clone() + }; + if scenario + && let Some(operation) = crate::data_values::operation_label(p, e) + { + message_label.push_str(" · "); + message_label.push_str(&operation); + } + self.lines.push(Line { + id: *id, + points, + label: format!("{}. {kind}{}", index + 1, message_label), + label_at: Point::new(a.min(*b) + 12., *y - 26.), + dashed: matches!(message_kind, MessageKind::Reply), + }); + } else { + self.boxes.push(BoxItem { + id: *id, + rect: Rectangle::new( + Point::new(65., *y - 20.), + Size::new(self.size.width - 130., 65.), + ), + title: e.name.clone(), + detail: "Choose an exchange and its lifelines in Properties".into(), + marker: None, + }); + } + *y += 92.; + } + OccurrenceBody::Action { action, lifeline } + | OccurrenceBody::State { + state: action, + lifeline, + } => { + let x = lifeline + .and_then(|id| lanes.get(&id)) + .copied() + .unwrap_or(180.); + self.boxes.push(BoxItem { + id: *id, + rect: Rectangle::new(Point::new(x - 110., *y - 20.), Size::new(220., 70.)), + title: e.name.clone(), + detail: action + .map(|id| p.named(id).to_owned()) + .unwrap_or("Choose observed behavior".into()), + marker: None, + }); + *y += 95.; + } + OccurrenceBody::Control { kind } => { + self.boxes.push(BoxItem { + id: *id, + rect: Rectangle::new(Point::new(65., *y - 20.), Size::new(240., 70.)), + title: format!("{kind:?} · {}", e.name), + detail: "Contextual process control".into(), + marker: None, + }); + *y += 95.; + } + OccurrenceBody::SequenceReference { sequence } => { + self.boxes.push(BoxItem { + id: *id, + rect: Rectangle::new(Point::new(65., *y - 20.), Size::new(280., 70.)), + title: e.name.clone(), + detail: sequence + .map(|id| format!("Use {}", p.named(id))) + .unwrap_or("Choose a reusable process or chain".into()), + marker: None, + }); + *y += 95.; + } + OccurrenceBody::ScenarioUse { scenario, bindings } => { + self.boxes.push(BoxItem { + id: *id, + rect: Rectangle::new( + Point::new(65., *y - 20.), + Size::new(self.size.width - 130., 70.), + ), + title: format!("ref · {}", e.name), + detail: scenario + .map(|id| { + format!( + "{} · {} explicit lifeline bindings", + p.named(id), + bindings.len() + ) + }) + .unwrap_or("Choose a scenario".into()), + marker: Some(Kind::Occurrence), + }); + *y += 95.; + } + } + } + if ordered { + self.sequence_bounds.insert(owner, (sequence_top, *y - 42.)); + #[cfg(test)] + behavior_anchor_plan_experiment::scope_end(owner, y, &mut self.sequence_bounds); + let slot = scenario::Slot { + parent: owner, + before: None, + }; + self.slots.push(SequenceSlot { + slot, + y: *y - 42., + label: scenario::slot_label(p, slot), + }); + } + } + fn activation_segment( + &mut self, + call: Id, + scope: Id, + receiver: Id, + x: f32, + top: f32, + bottom: f32, + ) { + for span in self.lifecycle.alive_spans(receiver, scope, top, bottom) { + self.boxes.push(BoxItem { + id: call, + rect: Rectangle::new( + Point::new(x - 4., span.start), + Size::new(8., span.end - span.start), + ), + title: String::new(), + detail: String::new(), + marker: None, + }); + } + } + /// Follow a single correlation through the ordered tree. Branches are drawn + /// as independent segments, never as one chronological stretch through ALT + /// operands. The result says whether a path can leave this sequence open. + fn activation_sequence( + &mut self, + p: &Project, + owner: Id, + call: Id, + x: f32, + mut start: Option, + ) -> bool { + let Some(SharedBody::Occurrence { + body: + OccurrenceBody::Exchange { + receiver: Some(receiver), + .. + }, + .. + }) = p.elements[&call].shared() + else { + return false; + }; + let receiver = *receiver; + let entered_open = start.is_some(); + let mut split = false; + for id in p.elements[&owner].steps().into_iter().flatten() { + let Some(&(_, y)) = self.rows.get(id) else { + continue; + }; + if *id == call { + start = Some(y); + } + let Some(top) = start else { + continue; + }; + match p.elements[id].shared() { + Some(SharedBody::Occurrence { + body: + OccurrenceBody::Exchange { + message_kind: MessageKind::Delete, + receiver: Some(deleted), + .. + }, + .. + }) if *deleted == receiver => { + // This path cannot show activation beyond its local death. + // Keep the unresolved-call cue; a Delete is not a Reply. + self.activation_segment(call, owner, receiver, x, top, y); + self.open_calls.push(Point::new(x + 12., y - 16.)); + start = None; + } + Some(SharedBody::Occurrence { + body: + OccurrenceBody::Exchange { + message_kind: MessageKind::Reply, + reply_to: Some(correlation), + .. + }, + .. + }) if *correlation == call => { + self.activation_segment(call, owner, receiver, x, top, y); + for span in self.lifecycle.alive_spans(receiver, owner, y - 1., y + 1.) { + self.boxes.push(BoxItem { + id: call, + rect: Rectangle::new( + Point::new(x - 7., span.start), + Size::new(14., span.end - span.start), + ), + title: String::new(), + detail: String::new(), + marker: None, + }); + } + start = None; + } + Some(SharedBody::Occurrence { + body: OccurrenceBody::Fragment { operator, branches }, + .. + }) => { + split = true; + self.activation_segment(call, owner, receiver, x, top, y); + let bottom = self + .boxes + .iter() + .find(|b| b.id == *id && !b.title.is_empty()) + .map(|b| b.rect.y + b.rect.height) + .unwrap_or(y); + let outcomes: Vec<_> = branches + .iter() + .filter_map(|branch| { + let &(top, _) = self.sequence_bounds.get(branch)?; + Some(self.activation_sequence(p, *branch, call, x, Some(top))) + }) + .collect(); + let open = match operator { + FragmentOperator::Alternative => { + outcomes.is_empty() || outcomes.iter().any(|open| *open) + } + FragmentOperator::Parallel => outcomes.iter().all(|open| *open), + // Optional/repeated bodies do not guarantee execution. + FragmentOperator::Optional | FragmentOperator::Loop => true, + }; + start = open.then_some(bottom); + } + _ => {} + } + } + if let Some(top) = start { + let bottom = if entered_open || split { + self.sequence_bounds + .get(&owner) + .map_or(top + 26., |(_, bottom)| (*bottom).max(top + 26.)) + } else { + top + 26. + }; + self.activation_segment(call, owner, receiver, x, top, bottom); + self.open_calls.push(Point::new(x + 8., bottom - 7.)); + } + start.is_some() + } + fn slot_at(&self, point: Point, parent: Option) -> Option<&SequenceSlot> { + self.slots + .iter() + .min_by(|a, b| (a.y - point.y).abs().total_cmp(&(b.y - point.y).abs())) + .filter(|slot| parent.is_none_or(|id| id == slot.slot.parent)) + } + fn lane_at(&self, point: Point) -> Option { + if let Some(header) = self + .boxes + .iter() + .find(|b| self.lanes.contains_key(&b.id) && b.rect.contains(point)) + { + return Some(header.id); + } + self.lanes + .iter() + .filter(|(_, x)| (**x - point.x).abs() < 12. / self.scale) + .min_by(|a, b| (a.1 - point.x).abs().total_cmp(&(b.1 - point.x).abs())) + .map(|(id, _)| *id) + } + fn reorder_handle(&self) -> Option { + let (_, y) = self.rows.get(&self.selected)?; + Some(Rectangle::new( + Point::new(6., *y - 12.), + Size::new(22., 24.), + )) + } + fn hit(&self, point: Point) -> Option { + if !point.x.is_finite() || !point.y.is_finite() { + return None; + } + if let Some(id) = self.lifecycle.marker_at(point) { + return Some(id); + } + self.boxes + .iter() + .rev() + .find(|b| { + !b.title.is_empty() + && b.rect.contains(point) + && (b.marker != Some(Kind::Occurrence) || point.y < b.rect.y + 38.) + }) + .map(|b| b.id) + .or_else(|| { + self.lines + .iter() + .find(|line| !line.label.is_empty() && line.label_bounds().contains(point)) + .map(|line| line.id) + }) + .or_else(|| self.near_sequence_item(point)) + .or_else(|| { + self.boxes + .iter() + .rev() + .find(|b| b.rect.contains(point) && b.title.is_empty()) + .map(|b| b.id) + }) + .or_else(|| self.lifecycle.lane_at(point, self.scale)) + .or_else(|| self.lane_at(point)) + .or_else(|| { + self.lines + .iter() + .find(|line| { + line.label.is_empty() + && line.points.windows(2).any(|p| { + crate::diagram::distance_to_segment(point, p[0], p[1]) + < 8. / self.scale + }) + }) + .map(|line| line.id) + }) + } + + fn near_sequence_item(&self, point: Point) -> Option { + if !self.scale.is_finite() || self.scale <= 0. { + return None; + } + let messages = self + .lines + .iter() + .filter(|line| !line.label.is_empty()) + .map(|line| { + let distance = line + .points + .windows(2) + .map(|p| crate::diagram::distance_to_segment(point, p[0], p[1])) + .fold(f32::INFINITY, f32::min); + (line.id, distance) + }); + // One distance comparison for both kinds of screen-sized hit tolerance. + // Dense minimum-Fit rows must not give the first message or last marker + // a larger hit priority than the visibly nearer item. UUID breaks ties. + self.lifecycle + .nearest_marker(point) + .into_iter() + .chain(messages) + .filter(|(_, distance)| *distance <= 8. / self.scale) + .min_by(|a, b| a.1.total_cmp(&b.1).then_with(|| a.0.cmp(&b.0))) + .map(|(id, _)| id) + } +} +impl canvas::Program for Scene { + type State = SequenceInput; + fn update( + &self, + state: &mut SequenceInput, + event: &canvas::Event, + bounds: Rectangle, + cursor: mouse::Cursor, + ) -> Option> { + use scenario::{CanvasEvent, CanvasTool}; + let (context, tool, editable) = self + .interaction + .map_or((None, CanvasTool::Select, false), |(c, t, e)| { + (Some(c), t, e) + }); + if state.context != context || state.tool != tool { + state.cancel(); + state.pointer = None; + state.context = context; + state.tool = tool; + } + // Scrolling either enclosing Scrollable changes the canvas origin. A + // 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) { + state.cancel(); + state.pointer = None; + } + state.bounds = Some(bounds); + if !editable { + state.cancel(); + } + // The viewport masks input outside its visible canvas bounds. + let visible = cursor.position().is_some(); + if !visible + && matches!( + event, + canvas::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) + ) + { + state.cancel(); + return Some(Action::request_redraw()); + } + let cursor = match event { + canvas::Event::Mouse(mouse::Event::CursorMoved { position }) => { + state.pointer = Some(*position); + if let Some(press) = &mut state.press { + match press { + SequencePress::Message { start, moved, .. } + | SequencePress::Reorder { start, moved, .. } => { + *moved |= position.distance(*start) >= 3. + } + SequencePress::Occurrence { .. } => {} + } + } + mouse::Cursor::Available(*position) + } + canvas::Event::Mouse(mouse::Event::WheelScrolled { .. }) + | canvas::Event::Window(iced::window::Event::Resized(_)) + | canvas::Event::Mouse(mouse::Event::CursorLeft) + | canvas::Event::Window(iced::window::Event::Unfocused) => { + state.cancel(); + state.pointer = None; + return Some(Action::request_redraw()); + } + canvas::Event::Mouse( + mouse::Event::ButtonPressed(_) | mouse::Event::ButtonReleased(_), + ) => state.pointer.map_or(cursor, mouse::Cursor::Available), + canvas::Event::Window(iced::window::Event::RedrawRequested(_)) => { + state.pointer = cursor.position(); + cursor + } + canvas::Event::Keyboard(iced::keyboard::Event::KeyPressed { + key: iced::keyboard::Key::Named(iced::keyboard::key::Named::Escape), + .. + }) if state.source.is_some() || state.press.is_some() => { + state.cancel(); + return Some(Action::request_redraw().and_capture()); + } + _ => cursor, + }; + let world = cursor.position_in(bounds).map(|p| { + Point::new( + (p.x - self.camera_pan.x) / self.scale, + (p.y - self.camera_pan.y) / self.scale, + ) + }); + match event { + canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => { + if !visible { + return None; + } + let point = world?; + if let Some(context) = context { + if !editable { + return None; + } + if let CanvasTool::Message(_) = tool { + let first = state.source.is_none(); + let (source, slot) = state.source.or_else(|| { + Some((self.lane_at(point)?, self.slot_at(point, None)?.slot)) + })?; + state.source = Some((source, slot)); + state.press = Some(SequencePress::Message { + source, + slot, + start: cursor.position()?, + first, + moved: false, + }); + return Some(Action::request_redraw().and_capture()); + } + if let CanvasTool::Add(tool) = tool { + if !tool.canvas_occurrence() && tool != scenario::Tool::Lifeline { + return None; + } + state.press = Some(SequencePress::Occurrence { + tool, + slot: self.slot_at(point, None)?.slot, + lifeline: tool.uses_lifeline().then(|| self.lane_at(point)).flatten(), + }); + return Some(Action::request_redraw().and_capture()); + } + if let Some(occurrence) = self + .hit(point) + .filter(|id| self.rows.contains_key(id)) + .or_else(|| { + self.reorder_handle() + .filter(|r| r.contains(point)) + .map(|_| self.selected) + }) + { + state.press = Some(SequencePress::Reorder { + occurrence, + parent: self.rows[&occurrence].0, + start: cursor.position()?, + moved: false, + }); + return Some(Action::request_redraw().and_capture()); + } + return self.hit(point).map(|id| { + Action::publish(Message::ScenarioCanvas(CanvasEvent::Select(context, id))) + .and_capture() + }); + } + self.hit(point) + .map(|id| Action::publish(Message::Select(id)).and_capture()) + } + canvas::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => { + let press = state.press.take()?; + let context = context?; + match press { + SequencePress::Occurrence { + tool: captured, + slot, + lifeline, + } if tool == CanvasTool::Add(captured) && world.is_some() => { + return Some( + Action::publish(Message::ScenarioCanvas( + CanvasEvent::BeginOccurrence { + context, + tool: captured, + slot, + lifeline, + }, + )) + .and_capture(), + ); + } + SequencePress::Message { source, slot, .. } + if tool == CanvasTool::Message(scenario::MessageStyle::Timer) + && world.is_some() => + { + state.cancel(); + return Some( + Action::publish(Message::ScenarioCanvas(CanvasEvent::Begin { + context, + slot, + sender: source, + receiver: source, + style: scenario::MessageStyle::Timer, + })) + .and_capture(), + ); + } + SequencePress::Message { + source, + slot, + first, + moved, + .. + } if moved || !first => { + if let Some(receiver) = world.and_then(|p| self.lane_at(p)) { + let CanvasTool::Message(style) = tool else { + return None; + }; + state.cancel(); + return Some( + Action::publish(Message::ScenarioCanvas(CanvasEvent::Begin { + context, + slot, + sender: source, + receiver, + style, + })) + .and_capture(), + ); + } + } + SequencePress::Reorder { + occurrence, + moved: false, + .. + } => { + return Some( + Action::publish(Message::ScenarioCanvas(CanvasEvent::Select( + context, occurrence, + ))) + .and_capture(), + ); + } + SequencePress::Reorder { + occurrence, + parent, + moved: true, + .. + } => { + if let Some(slot) = world.and_then(|p| self.slot_at(p, Some(parent))) { + return Some( + Action::publish(Message::ScenarioCanvas(CanvasEvent::Reorder { + context, + occurrence, + slot: slot.slot, + })) + .and_capture(), + ); + } + } + _ => {} + } + Some(Action::request_redraw().and_capture()) + } + canvas::Event::Mouse(mouse::Event::CursorMoved { .. }) if context.is_some() => { + Some(Action::request_redraw()) + } + _ => None, + } + } + fn draw( + &self, + state: &SequenceInput, + renderer: &Renderer, + _: &Theme, + bounds: Rectangle, + cursor: mouse::Cursor, + ) -> Vec { + let mut f = Frame::new(renderer, bounds.size()); + f.fill_rectangle(Point::ORIGIN, bounds.size(), Color::WHITE); + f.translate(self.camera_pan); + f.scale(self.scale); + self.lifecycle.draw_lanes(&mut f, self.selected); + for b in &self.boxes { + let outline = matches!( + b.marker, + Some(Kind::Region | Kind::StateMachine | Kind::Occurrence) + ); + let color = if b.id == self.selected { + style::TEAL + } else { + style::LINE + }; + if outline { + f.stroke( + &Path::rectangle(b.rect.position(), b.rect.size()), + Stroke::default().with_color(color), + ); + } else { + let path = Path::rounded_rectangle(b.rect.position(), b.rect.size(), 8.into()); + f.fill(&path, Color::from_rgb8(243, 248, 248)); + f.stroke( + &path, + Stroke::default() + .with_color(color) + .with_width(if b.id == self.selected { 2.5 } else { 1. }), + ); + } + } + for l in &self.lines { + let color = if l.id == self.selected { + style::TEAL + } else { + style::MUTED + }; + f.stroke( + &Path::new(|p| { + p.move_to(l.points[0]); + for point in &l.points[1..] { + p.line_to(*point); + } + }), + Stroke { + line_dash: canvas::LineDash { + segments: if l.dashed { &[6., 4.] } else { &[] }, + offset: 0, + }, + ..Stroke::default() + .with_color(color) + .with_width(if l.id == self.selected { 2.5 } else { 1.2 }) + }, + ); + if !l.label.is_empty() { + let end = l.points[l.points.len() - 1]; + let prev = l.points[l.points.len() - 2]; + let delta = end - prev; + let len = (delta.x * delta.x + delta.y * delta.y).sqrt().max(1.); + let d = delta / len; + f.stroke( + &Path::new(|p| { + p.move_to(end - d * 9. + iced::Vector::new(-d.y, d.x) * 4.); + p.line_to(end); + p.line_to(end - d * 9. - iced::Vector::new(-d.y, d.x) * 4.); + }), + Stroke::default().with_color(color), + ); + f.fill_rectangle( + l.label_at, + Size::new((l.label.chars().count() as f32 * 6.8 + 8.).min(225.), 22.), + Color::WHITE, + ); + f.fill_text(Text { + content: short(&l.label, 32), + position: l.label_at, + color, + size: 12.into(), + ..Default::default() + }); + } + } + for b in &self.boxes { + if let Some(clip) = box_text_clip(self.camera_pan, b.rect, self.scale, f.size()) { + // Clipped child frames start with an identity transform. Match + // the card's pan and scale, as for lifecycle Create labels. + f.with_clip(clip, |label| { + label.translate(self.camera_pan); + label.scale(self.scale); + label.fill_text(Text { + content: short(&b.title, ((b.rect.width - 25.) / 7.) as usize), + position: Point::new(b.rect.x + 12., b.rect.y + 12.), + size: 13.into(), + color: if b.id == self.selected { + style::TEAL + } else { + style::INK + }, + ..Default::default() + }); + if !b.detail.is_empty() { + label.fill_text(Text { + content: short(&b.detail, ((b.rect.width - 25.) / 6.) as usize), + position: Point::new(b.rect.x + 12., b.rect.y + 39.), + size: 11.into(), + color: style::MUTED, + ..Default::default() + }); + } + }); + } + } + for point in &self.open_calls { + f.fill_text(Text { + content: "…".into(), + position: *point, + size: 11.into(), + color: style::MUTED, + ..Default::default() + }); + } + self.lifecycle + .draw_markers(&mut f, self.selected, self.scale, self.camera_pan); + if let Some((context, tool, editable)) = self.interaction { + 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, + (p.y - self.camera_pan.y) / self.scale, + ) + }); + let source = active.then_some(state.source).flatten(); + let reorder = active.then_some(state.press).flatten().and_then(|press| { + if let SequencePress::Reorder { parent, .. } = press { + Some(parent) + } else { + None + } + }); + if editable + && tool == scenario::CanvasTool::Select + && let Some(handle) = self.reorder_handle() + { + f.fill_rectangle(handle.position(), handle.size(), style::TEAL); + f.fill_text(Text { + content: "↕".into(), + position: handle.position() + iced::Vector::new(5., 2.), + size: 16.into(), + color: Color::WHITE, + ..Default::default() + }); + } + let placement = self.pending_placement.or_else(|| { + active.then_some(state.press).flatten().and_then(|press| { + if let SequencePress::Occurrence { slot, lifeline, .. } = press { + Some((slot, lifeline)) + } else { + None + } + }) + }); + let slot = if let Some((slot, _)) = placement { + self.slots.iter().find(|s| s.slot == slot) + } else if let Some((_, slot)) = source { + self.slots.iter().find(|s| s.slot == slot) + } else if editable + && (matches!( + tool, + scenario::CanvasTool::Message(_) | scenario::CanvasTool::Add(_) + ) || reorder.is_some()) + { + point.and_then(|p| self.slot_at(p, reorder)) + } else { + None + }; + if reorder.is_some() + && slot.is_none() + && let Some(point) = point + { + f.fill_text(Text { + content: "Other operand: move stays in the current sequence".into(), + position: Point::new(point.x + 8., point.y + 12.), + size: (11. / self.scale).into(), + color: Color::from_rgb(0.75, 0.2, 0.15), + ..Default::default() + }); + } + if let Some(slot) = slot { + f.stroke( + &Path::line( + Point::new(32., slot.y), + Point::new(self.size.width - 24., slot.y), + ), + Stroke::default().with_color(style::TEAL).with_width(1.5), + ); + f.fill_rectangle( + Point::new(34., slot.y - 20.), + Size::new((self.size.width - 70.).min(650.), 19.), + Color::WHITE, + ); + f.fill_text(Text { + content: short( + &format!( + "{}: {}", + if self.pending_placement.is_some() { + "Pending" + } else { + "Insert" + }, + slot.label + ), + 88, + ), + position: Point::new(38., slot.y - 19.), + size: 11.into(), + color: style::TEAL, + ..Default::default() + }); + if let Some(x) = placement + .and_then(|(_, lifeline)| lifeline) + .and_then(|id| self.lanes.get(&id)) + { + f.fill(&Path::circle(Point::new(*x, slot.y), 5.), style::TEAL); + } + if let Some((source, _)) = source + && let Some(x) = self.lanes.get(&source) + && let Some(point) = point + { + let to = self + .lane_at(point) + .and_then(|id| self.lanes.get(&id)) + .copied() + .unwrap_or(point.x); + f.stroke( + &Path::line(Point::new(*x, slot.y), Point::new(to, slot.y)), + Stroke::default().with_color(style::TEAL).with_width(3.), + ); + f.fill(&Path::circle(Point::new(to, slot.y), 5.), style::TEAL); + } + } + } + vec![f.into_geometry()] + } + fn mouse_interaction( + &self, + _: &SequenceInput, + bounds: Rectangle, + cursor: mouse::Cursor, + ) -> mouse::Interaction { + if cursor.is_over(bounds) { + mouse::Interaction::Pointer + } else { + mouse::Interaction::default() + } + } +} + +#[cfg(test)] +mod direct_canvas_tests { + use super::*; + use scenario::{CanvasEvent, CanvasTool, MessageStyle}; + fn scene() -> (scenario::direct_tests::Fixture, Scene) { + let f = scenario::direct_tests::fixture(); + let mut scene = Scene::sequence(&f.project, f.owner, f.call); + scene.interaction = Some(( + scenario::direct_tests::context(f.owner), + CanvasTool::Message(MessageStyle::CallReply), + true, + )); + (f, scene) + } + fn send( + scene: &Scene, + state: &mut SequenceInput, + event: mouse::Event, + final_cursor: Point, + bounds: Rectangle, + ) -> Option { + canvas::Program::update( + scene, + state, + &canvas::Event::Mouse(event), + bounds, + mouse::Cursor::Available(final_cursor), + ) + .and_then(|action| action.into_inner().0) + .and_then(|message| { + if let Message::ScenarioCanvas(event) = message { + Some(event) + } else { + None + } + }) + } + #[test] + fn reused_exchange_messages_display_their_distinct_occurrence_names() { + let mut f = scenario::direct_tests::fixture(); + f.project.elements.get_mut(&f.call).unwrap().name = "Route request".into(); + f.project.elements.get_mut(&f.reply).unwrap().name = "Route response".into(); + let scene = Scene::sequence(&f.project, f.owner, f.call); + let call = scene.lines.iter().find(|line| line.id == f.call).unwrap(); + let reply = scene.lines.iter().find(|line| line.id == f.reply).unwrap(); + assert!(call.label.starts_with("1. call · Route request")); + assert!(reply.label.starts_with("2. reply · Route response")); + assert!(call.label.ends_with(&f.project.relations[&f.exchange].name)); + assert!( + reply + .label + .ends_with(&f.project.relations[&f.exchange].name) + ); + } + #[test] + fn direct_drag_and_click_click_use_ordered_sources_and_publish_once() { + for drag in [false, true] { + let (f, scene) = scene(); + let mut state = SequenceInput::default(); + let bounds = Rectangle::new(Point::new(30., 50.), scene.size); + let a = Point::new( + bounds.x + scene.lanes[&f.sender], + bounds.y + scene.slots[0].y, + ); + let b = Point::new(bounds.x + scene.lanes[&f.receiver], a.y); + send( + &scene, + &mut state, + mouse::Event::CursorMoved { position: a }, + b, + bounds, + ); + assert!( + send( + &scene, + &mut state, + mouse::Event::ButtonPressed(mouse::Button::Left), + b, + bounds + ) + .is_none() + ); + if !drag { + assert!( + send( + &scene, + &mut state, + mouse::Event::ButtonReleased(mouse::Button::Left), + b, + bounds + ) + .is_none() + ); + assert_eq!(state.source.unwrap().0, f.sender); + } + send( + &scene, + &mut state, + mouse::Event::CursorMoved { position: b }, + b, + bounds, + ); + if !drag { + send( + &scene, + &mut state, + mouse::Event::ButtonPressed(mouse::Button::Left), + b, + bounds, + ); + } + let result = send( + &scene, + &mut state, + mouse::Event::ButtonReleased(mouse::Button::Left), + b, + bounds, + ); + assert!( + matches!(result, Some(CanvasEvent::Begin { sender, receiver, slot, .. }) if sender == f.sender && receiver == f.receiver && slot.before == Some(f.call)) + ); + assert!( + send( + &scene, + &mut state, + mouse::Event::ButtonReleased(mouse::Button::Left), + b, + bounds + ) + .is_none() + ); + assert!(state.source.is_none() && state.press.is_none()); + } + } + #[test] + fn direct_panned_canvas_maps_ordered_pointer_events_to_the_visible_slot() { + let (f, mut scene) = scene(); + scene.camera_pan = iced::Vector::new(-35., -140.); + let bounds = Rectangle::new(Point::new(20., 230.), scene.size); + let slot = scene.slots.last().unwrap().slot; + let y = scene.slots.last().unwrap().y; + let a = Point::new(bounds.x + scene.lanes[&f.sender], bounds.y + y) + scene.camera_pan; + let b = Point::new(bounds.x + scene.lanes[&f.receiver], bounds.y + y) + scene.camera_pan; + assert!(bounds.contains(a) && bounds.contains(b)); + let mut state = SequenceInput::default(); + // Batched pointer events use their own positions with the final cursor. + for event in [ + mouse::Event::CursorMoved { position: a }, + mouse::Event::ButtonPressed(mouse::Button::Left), + mouse::Event::CursorMoved { position: b }, + ] { + send(&scene, &mut state, event, b, bounds); + } + assert!( + matches!(send(&scene, &mut state, mouse::Event::ButtonReleased(mouse::Button::Left), b, bounds), Some(CanvasEvent::Begin { sender, receiver, slot: actual, .. }) if sender == f.sender && receiver == f.receiver && actual == slot) + ); + } + #[test] + fn direct_scroll_resize_and_bounds_changes_cancel_captured_slots() { + for change in 0..5 { + let (f, scene) = scene(); + let mut state = SequenceInput::default(); + let bounds = Rectangle::new(Point::new(20., 70.), scene.size); + let a = Point::new( + bounds.x + scene.lanes[&f.sender], + bounds.y + scene.slots[0].y, + ); + send( + &scene, + &mut state, + mouse::Event::CursorMoved { position: a }, + a, + bounds, + ); + send( + &scene, + &mut state, + mouse::Event::ButtonPressed(mouse::Button::Left), + a, + bounds, + ); + send( + &scene, + &mut state, + mouse::Event::ButtonReleased(mouse::Button::Left), + a, + bounds, + ); + assert!(state.source.is_some()); + let changed = match change { + 0 => Rectangle::new( + bounds.position() - iced::Vector::new(15., 32.), + bounds.size(), + ), + 1 => Rectangle::new(bounds.position(), bounds.size() + Size::new(20., 10.)), + _ => bounds, + }; + let event = match change { + 2 => canvas::Event::Mouse(mouse::Event::WheelScrolled { + delta: mouse::ScrollDelta::Lines { x: 0., y: -1. }, + }), + 3 => canvas::Event::Window(iced::window::Event::Resized(Size::new(1100., 700.))), + _ => canvas::Event::Window(iced::window::Event::RedrawRequested( + std::time::Instant::now(), + )), + }; + let cursor = if change == 4 { + mouse::Cursor::Unavailable + } else { + mouse::Cursor::Available(a) + }; + let event = if change == 4 { + canvas::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) + } else { + event + }; + canvas::Program::update(&scene, &mut state, &event, changed, cursor); + assert!( + state.source.is_none() && state.press.is_none(), + "change {change} must cancel the old slot" + ); + let b = Point::new( + changed.x + scene.lanes[&f.receiver], + changed.y + scene.slots.last().unwrap().y, + ); + send( + &scene, + &mut state, + mouse::Event::CursorMoved { position: b }, + b, + changed, + ); + send( + &scene, + &mut state, + mouse::Event::ButtonPressed(mouse::Button::Left), + b, + changed, + ); + assert!( + send( + &scene, + &mut state, + mouse::Event::ButtonReleased(mouse::Button::Left), + b, + changed + ) + .is_none(), + "a click after change {change} must start a fresh gesture" + ); + assert_eq!(state.source.unwrap().0, f.receiver); + assert_ne!(state.source.unwrap().1.before, Some(f.call)); + } + } + #[test] + fn direct_bounds_change_during_held_drag_never_commits_old_slot() { + let (f, scene) = scene(); + let mut state = SequenceInput::default(); + let bounds = Rectangle::new(Point::ORIGIN, scene.size); + let a = Point::new(scene.lanes[&f.sender], scene.slots[0].y); + let b = Point::new(scene.lanes[&f.receiver], a.y); + send( + &scene, + &mut state, + mouse::Event::CursorMoved { position: a }, + a, + bounds, + ); + send( + &scene, + &mut state, + mouse::Event::ButtonPressed(mouse::Button::Left), + a, + bounds, + ); + send( + &scene, + &mut state, + mouse::Event::CursorMoved { position: b }, + b, + bounds, + ); + let shifted = Rectangle::new(Point::new(0., -40.), scene.size); + assert!( + send( + &scene, + &mut state, + mouse::Event::ButtonReleased(mouse::Button::Left), + b, + shifted + ) + .is_none() + ); + assert!(state.source.is_none() && state.press.is_none()); + } + #[test] + fn direct_context_escape_unfocus_and_pending_form_cancel_gestures() { + for cancellation in 0..5 { + let (f, mut scene) = scene(); + let mut state = SequenceInput::default(); + let bounds = Rectangle::new(Point::ORIGIN, scene.size); + let a = Point::new(scene.lanes[&f.sender], scene.slots[0].y); + let b = Point::new(scene.lanes[&f.receiver], a.y); + send( + &scene, + &mut state, + mouse::Event::CursorMoved { position: a }, + a, + bounds, + ); + send( + &scene, + &mut state, + mouse::Event::ButtonPressed(mouse::Button::Left), + a, + bounds, + ); + send( + &scene, + &mut state, + mouse::Event::CursorMoved { position: b }, + b, + bounds, + ); + match cancellation { + 0 => { + scene.interaction.as_mut().unwrap().0.revision += 1; + } + 1 => { + scene.interaction.as_mut().unwrap().1 = CanvasTool::Select; + } + 2 => { + scene.interaction.as_mut().unwrap().2 = false; + } + 3 => { + canvas::Program::update( + &scene, + &mut state, + &canvas::Event::Window(iced::window::Event::Unfocused), + bounds, + mouse::Cursor::Unavailable, + ); + } + _ => { + canvas::Program::update( + &scene, + &mut state, + &canvas::Event::Keyboard(iced::keyboard::Event::KeyPressed { + key: iced::keyboard::Key::Named(iced::keyboard::key::Named::Escape), + modified_key: iced::keyboard::Key::Named( + iced::keyboard::key::Named::Escape, + ), + physical_key: iced::keyboard::key::Physical::Code( + iced::keyboard::key::Code::Escape, + ), + location: iced::keyboard::Location::Standard, + modifiers: iced::keyboard::Modifiers::empty(), + text: None, + repeat: false, + }), + bounds, + mouse::Cursor::Available(b), + ); + } + } + assert!( + send( + &scene, + &mut state, + mouse::Event::ButtonReleased(mouse::Button::Left), + b, + bounds + ) + .is_none() + ); + assert!(state.press.is_none() && state.source.is_none()); + } + } + #[test] + fn direct_clicked_occurrence_capture_cancels_on_view_and_context_changes() { + for tool in [ + scenario::Tool::Action, + scenario::Tool::Observation, + scenario::Tool::Reuse, + scenario::Tool::Fragment, + ] { + for cancellation in 0..9 { + let (f, mut scene) = scene(); + scene.interaction.as_mut().unwrap().1 = CanvasTool::Add(tool); + let mut state = SequenceInput::default(); + let mut bounds = Rectangle::new(Point::ORIGIN, scene.size); + let point = Point::new(scene.lanes[&f.sender], scene.slots[0].y); + send( + &scene, + &mut state, + mouse::Event::CursorMoved { position: point }, + point, + bounds, + ); + assert!( + send( + &scene, + &mut state, + mouse::Event::ButtonPressed(mouse::Button::Left), + point, + bounds + ) + .is_none() + ); + assert!(matches!( + state.press, + Some(SequencePress::Occurrence { .. }) + )); + let cancellation_event = match cancellation { + 0 => { + scene.interaction.as_mut().unwrap().0.epoch += 1; + None + } + 1 => { + scene.interaction.as_mut().unwrap().1 = CanvasTool::Select; + None + } + 2 => { + scene.interaction.as_mut().unwrap().2 = false; + None + } + 3 => Some(canvas::Event::Mouse(mouse::Event::WheelScrolled { + delta: mouse::ScrollDelta::Lines { x: 0., y: -1. }, + })), + 4 => Some(canvas::Event::Window(iced::window::Event::Resized( + Size::new(1100., 700.), + ))), + 5 => { + bounds.y -= 30.; + None + } + 6 => Some(canvas::Event::Window(iced::window::Event::Unfocused)), + 7 => Some(canvas::Event::Keyboard(iced::keyboard::Event::KeyPressed { + key: iced::keyboard::Key::Named(iced::keyboard::key::Named::Escape), + modified_key: iced::keyboard::Key::Named( + iced::keyboard::key::Named::Escape, + ), + physical_key: iced::keyboard::key::Physical::Code( + iced::keyboard::key::Code::Escape, + ), + location: iced::keyboard::Location::Standard, + modifiers: iced::keyboard::Modifiers::empty(), + text: None, + repeat: false, + })), + _ => None, + }; + if let Some(event) = cancellation_event { + let action = canvas::Program::update( + &scene, + &mut state, + &event, + bounds, + mouse::Cursor::Available(point), + ); + assert!(action.and_then(|action| action.into_inner().0).is_none()); + } + let cursor = if cancellation == 8 { + mouse::Cursor::Unavailable + } else { + mouse::Cursor::Available(point) + }; + let released = canvas::Program::update( + &scene, + &mut state, + &canvas::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)), + bounds, + cursor, + ); + assert!( + released.and_then(|action| action.into_inner().0).is_none(), + "{tool:?} cancellation {cancellation}" + ); + assert!(state.press.is_none()); + } + } + } + #[test] + fn direct_selected_handle_reorders_and_hits_keep_semantic_identity() { + let (f, mut scene) = scene(); + scene.interaction.as_mut().unwrap().1 = CanvasTool::Select; + let bounds = Rectangle::new(Point::ORIGIN, scene.size); + let header = scene + .boxes + .iter() + .find(|b| b.id == f.sender) + .unwrap() + .rect + .center(); + assert_eq!(scene.hit(header), Some(f.sender)); + assert_eq!( + scene.hit(Point::new(scene.lanes[&f.sender], scene.size.height - 28.)), + Some(f.sender) + ); + let line = scene.lines.iter().find(|line| line.id == f.reply).unwrap(); + assert_eq!( + scene.hit(line.label_at + iced::Vector::new(4., 4.)), + Some(f.reply) + ); + let mut state = SequenceInput::default(); + let start = scene.reorder_handle().unwrap().center(); + let end = Point::new(start.x, scene.slots.last().unwrap().y); + send( + &scene, + &mut state, + mouse::Event::CursorMoved { position: start }, + start, + bounds, + ); + send( + &scene, + &mut state, + mouse::Event::ButtonPressed(mouse::Button::Left), + start, + bounds, + ); + send( + &scene, + &mut state, + mouse::Event::CursorMoved { position: end }, + end, + bounds, + ); + assert!( + matches!(send(&scene, &mut state, mouse::Event::ButtonReleased(mouse::Button::Left), end, bounds), Some(CanvasEvent::Reorder { occurrence, slot, .. }) if occurrence == f.call && slot.parent == f.owner && slot.before.is_none()) + ); + } + #[test] + fn direct_alt_execution_stops_each_operand_at_its_reply_and_marks_open_operand() { + use super::topology::occurrence; + let f = scenario::direct_tests::fixture(); + let mut p = execute_batch(&f.project, &[Command::Delete(f.reply)]).unwrap(); + let fragment = occurrence( + &p, + f.owner, + "Alternatives", + OccurrenceBody::Fragment { + operator: FragmentOperator::Alternative, + branches: vec![], + }, + ); + let fragment_id = fragment.id; + let mut commands = vec![Command::CreateElement(fragment)]; + let mut branches = vec![]; + let mut replies = vec![]; + let mut actions = vec![]; + for index in 0..3 { + let mut branch = occurrence( + &p, + f.owner, + &format!("Operand {index}"), + OccurrenceBody::Branch { + guard: format!("path {index}"), + occurrences: vec![], + }, + ); + branch.parent = Some(fragment_id); + let branch_id = branch.id; + branches.push(branch_id); + commands.push(Command::CreateElement(branch)); + let mut steps = vec![]; + if index < 2 { + let mut reply = occurrence( + &p, + f.owner, + &format!("Reply {index}"), + OccurrenceBody::Exchange { + operation: None, + exchange: Some(f.exchange), + sender: Some(f.receiver), + receiver: Some(f.sender), + message_kind: MessageKind::Reply, + reply_to: Some(f.call), + values: vec![], + }, + ); + reply.parent = Some(branch_id); + replies.push(reply.id); + steps.push(reply.id); + commands.push(Command::CreateElement(reply)); + } + let mut action = occurrence( + &p, + f.owner, + &format!("Action {index}"), + OccurrenceBody::Action { + action: Some(p.relations[&f.exchange].target()), + lifeline: Some(f.receiver), + }, + ); + action.parent = Some(branch_id); + actions.push(action.id); + steps.push(action.id); + commands.push(Command::CreateElement(action)); + commands.push(Command::SetSteps(branch_id, steps)); + } + if let Command::CreateElement(element) = &mut commands[0] + && let Some(SharedBody::Occurrence { + body: + OccurrenceBody::Fragment { + branches: stored, .. + }, + .. + }) = element.shared_mut() + { + *stored = branches.clone(); + } + p = execute_batch(&p, &commands).unwrap(); + let scene = Scene::sequence(&p, f.owner, f.call); + let bars: Vec<_> = scene + .boxes + .iter() + .filter(|b| b.id == f.call && b.rect.width == 8.) + .map(|b| b.rect) + .collect(); + for reply in &replies { + let y = scene.rows[reply].1; + assert!( + bars.iter().any(|r| r.y <= y && r.y + r.height == y), + "every reply terminates its own operand segment" + ); + } + for action in &actions[..2] { + let y = scene.rows[action].1; + assert!( + !bars.iter().any(|r| r.y <= y && r.y + r.height > y), + "a returned operand must not remain active through its following action" + ); + } + let last_action = scene.rows[&actions[2]].1; + assert!( + scene.open_calls.iter().any(|p| p.y > last_action), + "the reply-free operand remains explicitly open" + ); + let header = scene + .boxes + .iter() + .find(|b| b.id == branches[1] && !b.title.is_empty()) + .unwrap() + .rect; + assert!( + !bars + .iter() + .any(|r| r.y < header.y + header.height && r.y + r.height > header.y), + "segments do not join mutually exclusive operand headers" + ); + } + #[test] + fn direct_execution_span_ends_at_its_correlated_reply_and_marks_an_open_call() { + let (f, scene) = scene(); + let bar = scene + .boxes + .iter() + .find(|b| b.id == f.call && b.rect.width == 8.) + .unwrap(); + assert_eq!(bar.rect.y + bar.rect.height, scene.rows[&f.reply].1); + assert!(scene.open_calls.is_empty()); + let p = execute_batch(&f.project, &[Command::Delete(f.reply)]).unwrap(); + let scene = Scene::sequence(&p, f.owner, f.call); + assert_eq!(scene.open_calls.len(), 1); + assert_eq!( + scene + .boxes + .iter() + .find(|b| b.id == f.call && b.rect.width == 8.) + .unwrap() + .rect + .height, + 26. + ); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn recovery_retains_each_incomplete_behavior_form_without_executing_it() { + let p = Project::demo(); + let before = p.clone(); + let owner = |kind| p.elements.values().find(|e| e.kind() == kind).unwrap().id; + let machine = owner(Kind::StateMachine); + let scenario = owner(Kind::Scenario); + let process = owner(Kind::Process); + let cases = [ + ( + machine, + vec![ + Event::States(states::Event::Start( + machine, + states::Tool::Transition, + machine, + )), + Event::States(states::Event::Name("Unfinished timeout".into())), + Event::States(states::Event::Timed(true)), + Event::States(states::Event::Expression("-".into())), + Event::States(states::Event::Guard("temperature >".into())), + ], + ), + ( + scenario, + vec![ + Event::Scenario(scenario::Event::Start(scenario, scenario::Tool::Message)), + Event::Scenario(scenario::Event::Name("Unfinished request".into())), + Event::Scenario(scenario::Event::Expression("-".into())), + ], + ), + ( + process, + vec![ + Event::Topology(topology::Event::Start( + process, + topology::Tool::Connect, + None, + )), + Event::Topology(topology::Event::Name("Unfinished path".into())), + Event::Topology(topology::Event::Guard("available &&".into())), + ], + ), + ]; + for (owner, events) in cases { + let mut original = State::default(); + for event in events { + original.update(&p, event).unwrap(); + } + assert!(original.prepare_pending(&p).is_err()); + let snapshot = original.recovery_snapshot().unwrap().unwrap(); + let bytes = serde_json::to_vec(&snapshot).unwrap(); + let mut recovered = State::default(); + recovered + .restore_recovery(serde_json::from_slice(&bytes).unwrap()) + .unwrap(); + assert_eq!(recovered.pending_owner(), Some(owner)); + assert_eq!(recovered.active_owner, Some(owner)); + assert!( + recovered.prepare_pending(&p).is_err(), + "An incomplete recovered form is not applied or silently repaired" + ); + assert_eq!(recovered.recovery_snapshot().unwrap(), Some(snapshot)); + assert_eq!(p, before); + recovered.acknowledge_commit(); + assert!(recovered.recovery_snapshot().unwrap().is_none()); + } + } + + #[test] + fn recovery_preserves_distinct_qualified_value_paths_and_rejects_invalid_payload_atomically() { + let p = Project::demo(); + let scenario = p + .elements + .values() + .find(|e| e.kind() == Kind::Scenario) + .unwrap() + .id; + let field = Id::new_v4(); + let paths = [Id::new_v4(), Id::new_v4()]; + let mut state = State::default(); + state + .update( + &p, + Event::Scenario(scenario::Event::Start(scenario, scenario::Tool::Message)), + ) + .unwrap(); + for (path, value) in paths.into_iter().zip(["-", "3.4e"]) { + state + .update( + &p, + Event::Scenario(scenario::Event::Value( + crate::data_values::FieldPathKey { + path: vec![path], + field, + }, + value.into(), + )), + ) + .unwrap(); + } + let original = state.recovery_snapshot().unwrap().unwrap(); + let mut recovered = State::default(); + recovered.restore_recovery(original.clone()).unwrap(); + assert_eq!( + recovered.recovery_snapshot().unwrap(), + Some(original.clone()) + ); + let values = original["draft"]["values"].as_array().unwrap(); + assert_eq!(values.len(), 2); + assert_ne!(values[0][0], values[1][0]); + assert_eq!(values[0][1], values[1][1]); + for bad in [ + { + let mut value = original.clone(); + value["version"] = 99.into(); + value + }, + { + let mut value = original.clone(); + value["draft"]["unknown"] = true.into(); + value + }, + { + let mut value = original.clone(); + value["draft"]["values"] + .as_array_mut() + .unwrap() + .push(values[0].clone()); + value + }, + ] { + assert!(recovered.restore_recovery(bad).is_err()); + assert_eq!( + recovered.recovery_snapshot().unwrap(), + Some(original.clone()), + "Malformed recovery cannot discard the currently retained form" + ); + } + } + use crate::document::{Document, DocumentError}; + use oa_core::source_project::{self, ProjectLimits}; + + #[test] + fn one_behavior_form_survives_start_edit_and_unrelated_cancel_until_commit() { + let p = Project::demo(); + let owner = |kind| p.elements.values().find(|e| e.kind() == kind).unwrap().id; + let process = owner(Kind::Process); + let scenario = owner(Kind::Scenario); + let machine = owner(Kind::StateMachine); + let transition = p + .relations + .values() + .find(|r| r.kind() == RelationKind::StateTransition) + .unwrap() + .id; + let starts = [ + Event::Topology(topology::Event::Start( + process, + topology::Tool::Control, + None, + )), + Event::Scenario(scenario::Event::Start(scenario, scenario::Tool::Fragment)), + Event::States(states::Event::Start(machine, states::Tool::Region, machine)), + ]; + let names = [ + Event::Topology(topology::Event::Name("Keep this draft".into())), + Event::Scenario(scenario::Event::Name("Keep this draft".into())), + Event::States(states::Event::Name("Keep this draft".into())), + ]; + let cancels = [ + Event::Topology(topology::Event::Cancel), + Event::Scenario(scenario::Event::Cancel), + Event::States(states::Event::Cancel), + ]; + for (index, expected_owner) in [process, scenario, machine].into_iter().enumerate() { + let mut state = State::default(); + assert!(!state.has_pending()); + assert!(state.prepare_pending(&p).unwrap().is_none()); + state.update(&p, starts[index].clone()).unwrap(); + state.update(&p, names[index].clone()).unwrap(); + if index == 1 { + state + .update( + &p, + Event::Scenario(scenario::Event::Expression("approved".into())), + ) + .unwrap(); + } + assert_eq!(state.pending_owner(), Some(expected_owner)); + for replacement in starts + .iter() + .cloned() + .chain([Event::States(states::Event::Edit(transition))]) + { + assert_eq!(state.update(&p, replacement).unwrap_err(), PENDING_DRAFT); + assert_eq!(state.pending_owner(), Some(expected_owner)); + } + state.update(&p, cancels[(index + 1) % 3].clone()).unwrap(); + assert!(state.has_pending(), "Another editor's cancel is stale"); + let command = state.update(&p, Event::Submit).unwrap().unwrap(); + assert!(state.has_pending(), "Preparation must retain the form"); + let expected = execute_batch(&p, std::slice::from_ref(&command)).unwrap(); + assert!(expected.elements.values().any(|e| { + !p.elements.contains_key(&e.id) + && e.name == "Keep this draft" + && e.parent == Some(expected_owner) + })); + let mut document = Document::from_project(p.clone(), ProjectLimits::default()).unwrap(); + document.execute(command).unwrap(); + assert_eq!(document.project(), &expected); + state.acknowledge_commit(); + assert!(!state.has_pending()); + assert!(state.prepare_pending(document.project()).unwrap().is_none()); + assert_eq!(document.revision(), 1); + assert!(document.undo()); + assert_eq!(document.project(), &p); + assert!(!document.can_undo()); + + state.update(&p, starts[index].clone()).unwrap(); + state.update(&p, cancels[index].clone()).unwrap(); + assert!(!state.has_pending(), "Explicit cancel discards this form"); + state.update(&p, starts[(index + 1) % 3].clone()).unwrap(); + } + } + + #[test] + fn failed_document_commit_retains_typed_transition_and_time_event_for_repair() { + let mut p = Project::empty(); + let owner = p + .add_element(Kind::System, Layer::Sa, "System", None) + .unwrap(); + let machine = p + .add_element(Kind::StateMachine, Layer::Sa, "Modes", Some(owner)) + .unwrap(); + let region = p + .add_element(Kind::Region, Layer::Sa, "Region", Some(machine)) + .unwrap(); + let source = p + .add_element(Kind::State, Layer::Sa, "Ready", Some(region)) + .unwrap(); + let target = p + .add_element(Kind::State, Layer::Sa, "Waiting", Some(region)) + .unwrap(); + let work = p + .add_element(Kind::Function, Layer::Sa, "Monitor", None) + .unwrap(); + let choice = |id| Choice { + id, + label: p.named(id).into(), + }; + let mut state = State::default(); + for event in [ + states::Event::Start(machine, states::Tool::Transition, source), + states::Event::Name("Transition".repeat(1024)), + states::Event::Trigger(choice(work)), + states::Event::Effect(choice(work)), + states::Event::TriggerNotes("On a monitor result".into()), + states::Event::Guard("connection unavailable".into()), + states::Event::EffectNotes("Record the timeout".into()), + states::Event::Timed(true), + states::Event::TimeKind("At".into()), + states::Event::Expression("12:30 UTC".into()), + ] { + state.update(&p, Event::States(event)).unwrap(); + } + assert!( + state.prepare_pending(&p).is_err(), + "A target is still missing" + ); + assert_eq!(state.pending_owner(), Some(machine)); + state + .update(&p, Event::States(states::Event::Target(choice(target)))) + .unwrap(); + let command = state.prepare_pending(&p).unwrap().unwrap(); + let bundle = source_project::format_bundle(&p); + let mut limits = ProjectLimits::default(); + limits.model.max_source_bytes = + bundle.files.values().map(String::len).sum::() + 2000; + let mut document = Document::from_bundle(bundle.clone(), limits).unwrap(); + assert!(matches!( + document.execute(command), + Err(DocumentError::Source(_)) + )); + assert_eq!(document.project(), &p); + assert_eq!(document.bundle(), &bundle); + assert_eq!(document.revision(), 0); + assert!(!document.can_undo()); + assert!(state.has_pending()); + + state + .update( + &p, + Event::States(states::Event::Name("Wait for recovery".into())), + ) + .unwrap(); + let command = state.prepare_pending(&p).unwrap().unwrap(); + let Command::Batch(commands) = &command else { + panic!("Expected one atomic transition batch") + }; + assert_eq!(commands.len(), 2); + let Command::CreateElement(event) = &commands[0] else { + panic!("Expected a typed time event") + }; + assert!( + matches!(event.shared(), Some(SharedBody::Event { body: EventBody::Time { kind: TimeEventKind::At, expression } }) if expression == "12:30 UTC") + ); + let Command::CreateRelation(relation) = &commands[1] else { + panic!("Expected the transition") + }; + let RelationBody::StateTransition { + triggers, + effects, + trigger, + guard, + effect, + .. + } = &relation.body + else { + panic!() + }; + assert_eq!(triggers, &[work, event.id]); + assert_eq!(effects, &[work]); + assert_eq!(trigger, "On a monitor result"); + assert_eq!(guard, "connection unavailable"); + assert_eq!(effect, "Record the timeout"); + let expected = execute_batch(&p, std::slice::from_ref(&command)).unwrap(); + document.execute(command).unwrap(); + assert_eq!( + document.project(), + &expected, + "Commit the exact prepared identities" + ); + state.acknowledge_commit(); + assert!(!state.has_pending()); + assert_eq!(document.revision(), 1); + assert!(document.undo()); + assert_eq!(document.bundle(), &bundle); + assert_eq!(document.project(), &p); + assert!(!document.can_undo()); + } + + #[test] + fn legacy_process_and_chain_keep_their_ordered_exchange_scene() { + let p = Project::demo(); + let owners: Vec<_> = p + .elements + .values() + .filter(|e| matches!(e.kind(), Kind::Process | Kind::FunctionalChain)) + .collect(); + assert!(owners.iter().any(|e| e.kind() == Kind::Process)); + assert!(owners.iter().any(|e| e.kind() == Kind::FunctionalChain)); + for owner in owners { + assert!(!uses_explicit_topology(&p, owner.id)); + let expected: Vec<_> = owner + .steps() + .unwrap() + .iter() + .enumerate() + .map(|(index, id)| { + let Some(SharedBody::Occurrence { + body: + OccurrenceBody::Exchange { + exchange: Some(exchange), + .. + }, + .. + }) = p.elements[id].shared() + else { + panic!("Demo legacy sequence should contain exchange occurrences"); + }; + format!("{}. {}", index + 1, p.relations[exchange].name) + }) + .collect(); + assert!(!expected.is_empty()); + let scene = Scene::sequence(&p, owner.id, owner.id); + let actual: Vec<_> = scene + .lines + .iter() + .filter(|line| !line.label.is_empty()) + .map(|line| line.label.clone()) + .collect(); + assert_eq!( + actual, expected, + "Legacy rendering must preserve the model's ordered exchange path" + ); + } + } + #[test] + fn selecting_state_transition_keeps_its_behavior_context() { + let p = Project::demo(); + for r in p + .relations + .values() + .filter(|r| r.kind() == RelationKind::StateTransition) + { + let machine = selected_behavior(&p, r.id).expect("Transition remains in its machine"); + assert_eq!(p.elements[&machine].kind(), Kind::StateMachine); + assert!(in_machine(&p, r.source(), machine)); + assert!(in_machine(&p, r.target(), machine)); + } + } +} diff --git a/harness/experiments/behavior-anchor-plan-review-20260912/attempt1/harness/experiments/behavior-anchor-plan-20260912/README.md b/harness/experiments/behavior-anchor-plan-review-20260912/attempt1/harness/experiments/behavior-anchor-plan-20260912/README.md new file mode 100644 index 00000000..30673cd5 --- /dev/null +++ b/harness/experiments/behavior-anchor-plan-review-20260912/attempt1/harness/experiments/behavior-anchor-plan-20260912/README.md @@ -0,0 +1,71 @@ +# Complete anchor plan into actual Scene — pending execution + +Prepared 2026-09-12 in isolated branch `codex/behavior-anchor-plan-ytr83r-20260912`, base `66a1f1690f778c8814bfefff7f46fe7f6bbe520e`. Parent owns compilation and publication. **Not compiled, executed, staged or committed.** Only test-only additions to `crates/oa-desktop/src/behavior.rs` and this new directory were authored. The existing floor experiment and its fixtures are unchanged. All new production-file statements are guarded by `cfg(test)`; normal production rendering is unchanged. + +## Bounded question + +Can a complete ephemeral anchor plan drive the actual Scene at exact requested y coordinates without the old running-cursor floor, preserve unspecified anchors and the entire Document, and maintain covered extents and real lifecycle derivation? This isolates the integration seam from the unresolved choice of a product movement policy. + +The old floor candidate imposed existing seed increments on every requested row. This candidate seeds missing anchors from the actual baseline Scene, then replaces explicitly requested coordinates exactly. It does not impose sequence-order constraints or overlap spacing. Source-order crossings are returned as diagnostic data, not silently clamped or converted into semantic Reorder. This is a mechanical projection experiment, not evidence that crossed vertical ordering is acceptable UX. + +`Candidate` is an external map keyed by occurrence UUID. `Plan` is a complete resolved input for the currently visible Scene, including operand headers missing from the ordinary rows map. It contains row roles, derived message send/receive y values, frame bounds, sequence extents and canvas height. It is not a model, parser or REST payload. + +## Actual code boundary and wiring + +`wiring.patch` records the exact production-file delta from the base commit. The five new cfg(test) integration points are: + +1. Include this module as a private child of `behavior`, next to the unchanged floor module. +2. Immediately after the old floor hook, consume the complete plan's exact occurrence anchor, before Scene records rows and constructs the occurrence's existing geometry. +3. Before inserting an existing fragment BoxItem, use the plan's frame bounds. Existing fragment paint and label code remain in place. +4. Immediately after recording a sequence's bounds, substitute planned bounds and update the existing final insertion axis. An earlier event placed lower than the last event cannot truncate the scope merely because traversal ends earlier. +5. Before current lifecycle and activation construction, substitute the covered canvas height. Current `Rendering::new`, message alignment, activation generation and drawing remain responsible for their actual geometry. + +The plan is scoped per thread, inactive by default, restored by Drop during unwinding, and has a deliberate-panic test requiring the exact sentinel payload. Other tests on other threads cannot consume its plan. The original floor hooks remain operational when their own scope is active; the new plan is otherwise absent. + +Footprints are measured from the actual baseline named boxes, message polylines and label bounds, translated by requested row deltas. This avoids cloning the renderer's line or lifecycle algorithms. Empty activation bars are excluded because the real renderer derives those later. Frames conservatively preserve seed extents and expand when covered geometry escapes; growth adds an explicitly experimental 20px presentation margin. Sequence bounds preserve seed extents and measured edge padding while expanding to cover outliers; canvas height uses the original scope-to-canvas margin. Neither these margins nor nonshrinking seed extents are adopted user requirements. This is not a general container-resize solver. + +## Independently expected checks + +Ten new test functions use the existing hand-authored fixtures through the actual parser and Document bundle loader. Numeric expectations below are independently stated rather than generated from the new planner: + +| Case | Expected actual Scene result | +| --- | --- | +| Empty complete plan, both fixtures | Same rows, named boxes and marker kinds, lines and labels, scopes, slots, lifecycle, open calls and size as the unmodified baseline Scene | +| Second delivery240→200 | First148; second200; every other occurrence anchor unchanged; both second-message endpoints200; repeated exchange references retain different occurrence IDs | +| Nested action477→457 | Box top437, height70; original fragment top332/height240 retained; operand bounds and all other anchors unchanged | +| Death427→380 | Create243 and call335 stay fixed; real alive span243..380; actual bar335..380; death marker/hit moves to380; unresolved-call cue remains, no Reply is invented | +| Self-call636→610 | Actual send610/receive634, using the current24 offset; reply728 unchanged; MessageSend role distinguished from the lifecycle MessageReceive role | +| Signed zero and candidate restoration | Both0 and-0 return the exact original sparse candidate; no first-edit materialization; restoring the original candidate restores Scene geometry | +| First event→1000, last remains728 | First1000/second240, SourceOrderCrossing diagnostic, source order unchanged; root scope end1026 and height1113 cover the earlier outlier rather than the traversal tail | +| Nested action→1000 | Box top980/bottom1050; enclosing frame bottom1070; actual root scope/canvas cover it; following self-call636 remains fixed and overlap is diagnosed | +| Invalid/unsupported input | Unknown anchor, nonfinite y, compound frame movement and same-scope inverted birth/death fail explicitly | +| Scoped execution | Another thread sees baseline geometry; a deliberate panic restores the baseline scope | + +The primary cases compare the entire Project and original SourceBundle and require the real Document to remain clean with no Undo/Redo entry. Canonical parser roundtrip is used, but it covers only the semantic Project: layout stays external. Candidate restoration is a reversible-sidecar check, **not** a production Undo implementation. No command has been added to Document. + +## Explicit limitations and falsifiers + +- Crossing source-ordered events is deliberately drawable with diagnostics. That proves removal of the mechanical floor, not adoption of unrestricted vertical notation. The crossing output remains an independent product-policy falsifier. +- An expanded frame can overlap the next event. Its diagnostic preserves that evidence instead of shifting the next event implicitly. The experiment does not establish a frame/header constraint policy or all feasible layouts. +- The same-scope creation-after-deletion case returns `LifetimeInversionNotSupported` before rendering. Current lifecycle intervals cannot truthfully depict that inversion without another representation policy. This is an experimental bridge limitation, not permission to reject semantically valid models or a user-approved global movement rule. Cross-operand lifecycle combinations remain unproven. +- Compound frame/operand movement, box resizing, horizontal layout, independent send/receive movement, route editing, drag transaction stamps, pointer capture, dirty-source recovery, persistence, REST and native UI are not implemented. +- Self-call send and receive roles are explicit, but the current activation engine still owns its existing row-based behavior. Peer #21's typed activation integration must consume the actual receive anchor and retain the existing lifetime clipping. These tests do not silently bless the old activation-start discrepancy. +- Scope/containment walking is deliberately straightforward and not a large-model performance implementation. There is no responsiveness claim. +- Some self-message fixture exchanges are unassigned because the current global relation model forbids self edges. The existing fixture's known incomplete-model warnings remain; tests reject semantic errors. + +No storage or parser design should be adopted from this adapter. Coordinate any eventual payload with prospective #7, whose required addressing must preserve diagram/owner/occurrence identity and explicit anchor roles. Coordinate inverse resize with #16 and typed activation/lifetime union with #21 before broad UI work. + +Parent reports [peer #21 comment716](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/21#issuecomment-716), posted2026-09-12 23:06UTC: Codex-956atf owns an isolated typed-activation/lifecycle source union. Its intended order is finalized rows/height, lifecycle construction and Create/Delete alignment, then typed Call/Reply attachment, with receiver/scope clipping, logical reply timing distinct from clipped geometry, no resurrection after Delete, and unanswered-call cues preserved. That WIP is not integrated here. Retain the explicit send/receive seam and review the future union against these fixtures; this paragraph records a coordination dependency, not verified acceptance of peer code. + +## Reproduction and handoff + +At the parent's chosen compiler checkpoint, use the configured Windows Rust/linker environment: + +```powershell +cargo test -p oa-desktop behavior_anchor_plan_experiment --locked --offline -- --nocapture +cargo test -p oa-desktop behavior_layout_scene_experiment --locked --offline -- --nocapture +``` + +The first filter must execute ten new tests; the second must retain the ten existing floor/lifetime tests. Preserve compile failures, parser failures, numeric mismatches and diagnostics as results. Do not claim success from a zero-test filtered run or from compiling only production targets. An intentional panic should be followed by its restoration test passing; another panic payload is a failure. + +Record exact compiled commit/uncommitted patch, commands and results before publication. Source hashes supplied with the handoff identify prepared bytes, not tested code. Root should publish the complete directory and exact behavior.rs delta so remote peers can reproduce it; workspace-local paths are not remote handoffs. diff --git a/harness/experiments/behavior-anchor-plan-review-20260912/attempt1/harness/experiments/behavior-anchor-plan-20260912/mod.rs b/harness/experiments/behavior-anchor-plan-review-20260912/attempt1/harness/experiments/behavior-anchor-plan-20260912/mod.rs new file mode 100644 index 00000000..351ad1de --- /dev/null +++ b/harness/experiments/behavior-anchor-plan-review-20260912/attempt1/harness/experiments/behavior-anchor-plan-20260912/mod.rs @@ -0,0 +1,270 @@ +//! Experimental complete anchor input for the actual Scene, cfg(test) only. +//! No persistence schema and no replacement line/lifecycle/activation renderer. +use super::*; +use std::cell::RefCell; + +mod tests; + +type Candidate = BTreeMap; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum AnchorRole { + MessageSend, + MessageReceive, + Observation, + FrameStart, + OperandStart, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +struct Anchor { + y: f32, + role: AnchorRole, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +struct MessageAnchors { + send_y: f32, + receive_y: f32, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +enum Diagnostic { + SourceOrderCrossing { scope: Id, before: Id, after: Id }, + FrameExtendsPastFollowingAnchor { frame: Id, following: Id }, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +enum PlanError { + UnknownAnchor(Id), + NonFinite(Id), + CompoundMoveNotImplemented(Id), + LifetimeInversionNotSupported { create: Id, delete: Id }, +} + +#[derive(Clone, Debug)] +struct Plan { + anchors: BTreeMap, + messages: BTreeMap, + frames: BTreeMap, + scopes: BTreeMap, + height: f32, + diagnostics: Vec, +} + +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 +} + +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, seed: &BTreeMap) -> BTreeMap { + 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 covered_bounds(p: &Project, owner: Id, footprints: &BTreeMap) -> Option { + footprints.iter().filter(|(id, _)| inside(p, **id, owner)) + .map(|(_, rect)| *rect).reduce(union) +} + +impl Plan { + fn build(p: &Project, owner: Id, baseline: &Scene, desired: &Candidate) -> Result { + 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(PlanError::UnknownAnchor(id))?; + if !y.is_finite() { return Err(PlanError::NonFinite(id)); } + if matches!(anchor.role, AnchorRole::FrameStart | AnchorRole::OperandStart) && y != anchor.y { + return Err(PlanError::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 delta = anchor.y - seed[&line.id].y; + messages.insert(line.id, MessageAnchors { send_y: first.y + delta, receive_y: last.y + delta }); + } + } + // The current lifecycle bridge needs positive birth/death intervals. + // This is an explicit unsupported crossing, not a new semantic rule. + for (&create, message) in &messages { + if let Some(OccurrenceBody::Exchange { message_kind: MessageKind::Create, receiver: Some(lane), .. }) = occurrence(p, create) { + for (&delete, finish) in &messages { + if p.elements[&create].parent == p.elements[&delete].parent + && matches!(occurrence(p, delete), Some(OccurrenceBody::Exchange { message_kind: MessageKind::Delete, receiver: Some(other), .. }) if other == lane) + && message.receive_y > finish.receive_y { + return Err(PlanError::LifetimeInversionNotSupported { create, delete }); + } + } + } + } + let old_footprints = footprints(baseline, &seed, &seed); + let mut drawn = footprints(baseline, &anchors, &seed); + 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) = covered_bounds(p, id, &drawn) { + // Keep seed extents where possible; 20px growth padding is an + // explicit experimental presentation preference, not semantics. + if covered.y < rect.y { rect = union(rect, Rectangle::new(Point::new(covered.x, covered.y - 20.), Size::new(covered.width, covered.height + 20.))); } + if covered.y + covered.height > rect.y + rect.height { + rect.height = covered.y + covered.height + 20. - rect.y; + } + } + frames.insert(id, rect); + drawn.insert(id, rect); + } + let mut scopes = baseline.sequence_bounds.clone(); + for (&scope, bounds) in &mut scopes { + if let (Some(old), Some(new)) = (covered_bounds(p, scope, &old_footprints), covered_bounds(p, scope, &drawn)) { + 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); + 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]) { + if frame.y + frame.height > b.y { diagnostics.push(Diagnostic::FrameExtendsPastFollowingAnchor { frame: pair[0], following: pair[1] }); } + } + } + } + } + } + Ok(Self { anchors, messages, frames, scopes, height, diagnostics }) + } +} + +thread_local! { + static PLAN: RefCell> = const { RefCell::new(None) }; +} + +struct Scope(Option); +impl Scope { + fn install(plan: &Plan) -> Self { Self(PLAN.with(|slot| slot.replace(Some(plan.clone())))) } +} +impl Drop for Scope { + fn drop(&mut self) { PLAN.with(|slot| { slot.replace(self.0.take()); }); } +} + +pub(super) fn row_anchor(id: Id, y: &mut f32) { + PLAN.with(|slot| { + if let Some(plan) = slot.borrow().as_ref() { + *y = plan.anchors.get(&id).expect("complete Scene anchor plan").y; + } + }); +} + +pub(super) fn frame_bounds(id: Id, fallback: Rectangle) -> Rectangle { + PLAN.with(|slot| slot.borrow().as_ref().map_or(fallback, |plan| plan.frames[&id])) +} + +pub(super) fn scope_end(owner: Id, y: &mut f32, bounds: &mut BTreeMap) { + PLAN.with(|slot| { + if let Some(plan) = slot.borrow().as_ref() { + bounds.insert(owner, plan.scopes[&owner]); + *y = plan.scopes[&owner].1 + 42.; // Existing insertion-axis offset. + } + }); +} + +pub(super) fn scene_extent(scene: &mut Scene) { + PLAN.with(|slot| { + if let Some(plan) = slot.borrow().as_ref() { scene.size.height = plan.height; } + }); +} + +fn render(p: &Project, owner: Id, plan: &Plan) -> Scene { + let _scope = Scope::install(plan); + Scene::sequence(p, owner, owner) +} + +fn drag(original: &Candidate, frame: &Scene, target: Id, delta_y: f32) -> Candidate { + assert!(delta_y.is_finite()); + if delta_y == 0. { return original.clone(); } + let mut result = original.clone(); + result.insert(target, frame.rows[&target].1 + delta_y); + result +} diff --git a/harness/experiments/behavior-anchor-plan-review-20260912/attempt1/harness/experiments/behavior-anchor-plan-20260912/tests.rs b/harness/experiments/behavior-anchor-plan-review-20260912/attempt1/harness/experiments/behavior-anchor-plan-20260912/tests.rs new file mode 100644 index 00000000..9d339ab0 --- /dev/null +++ b/harness/experiments/behavior-anchor-plan-review-20260912/attempt1/harness/experiments/behavior-anchor-plan-20260912/tests.rs @@ -0,0 +1,276 @@ +use super::*; +use crate::document::Document; +use oa_core::{language, source_project::{ProjectLimits, SourceBundle}, validation}; + +fn id(p: &Project, alias: &str) -> Id { + p.identities().into_iter().find(|(_, name)| *name == alias).unwrap().0 +} + +fn fixture(lifetime: bool) -> (Document, Id) { + let (name, input, owner) = if lifetime { + ("lifetime.arcadia", include_str!("../behavior-layout-scene-20260912/lifetime.arcadia"), "lifetime_conversation") + } else { + ("fixture.arcadia", include_str!("../behavior-layout-scene-20260912/fixture.arcadia"), "conversation") + }; + let p = language::parse(input).expect("existing real Arcadia fixture"); + let errors: Vec<_> = validation::validate(&p).into_iter() + .filter(|issue| issue.severity == validation::Severity::Error).collect(); + assert!(errors.is_empty(), "{errors:?}"); + let bundle = SourceBundle { entry: name.into(), files: BTreeMap::from([(name.into(), input.into())]) }; + let document = Document::from_bundle(bundle, ProjectLimits::default()).unwrap(); + assert_eq!(document.project(), &p); + assert_eq!(language::parse(&language::format(&p)).unwrap(), p); + let owner = id(&p, owner); + (document, owner) +} + +fn passive(document: &Document, before: &Project, source: &SourceBundle) { + assert_eq!(document.project(), before); + assert_eq!(document.bundle(), source); + assert!(!document.dirty()); + assert!(!document.can_undo() && !document.can_redo()); +} + +fn row(scene: &Scene, id: Id) -> f32 { scene.rows[&id].1 } +fn line(scene: &Scene, id: Id) -> &Line { + assert_eq!(scene.lines.iter().filter(|line| line.id == id).count(), 1); + scene.lines.iter().find(|line| line.id == id).unwrap() +} +fn named_box(scene: &Scene, id: Id) -> &BoxItem { + scene.boxes.iter().find(|item| item.id == id && !item.title.is_empty()).unwrap() +} + +fn same_scene(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.lanes, b.lanes); + assert_eq!(a.lifecycle, b.lifecycle); + assert_eq!(a.open_calls, b.open_calls); + let boxes = |s: &Scene| s.boxes.iter().map(|b| (b.id,b.rect,b.title.clone(),b.detail.clone(),b.marker)).collect::>(); + let lines = |s: &Scene| s.lines.iter().map(|l| (l.id,l.points.clone(),l.label_at,l.label.clone(),l.dashed)).collect::>(); + let slots = |s: &Scene| s.slots.iter().map(|slot| (slot.slot.parent,slot.slot.before,slot.y,slot.label.clone())).collect::>(); + assert_eq!(boxes(a),boxes(b)); + assert_eq!(lines(a),lines(b)); + assert_eq!(slots(a),slots(b)); +} + +fn others_fixed(before: &Scene, after: &Scene, changed: Id) { + assert_eq!(before.rows.len(),after.rows.len()); + for (&id, original) in &before.rows { + if id != changed { assert_eq!(&after.rows[&id],original,"unexpected movement of {id}"); } + } +} + +#[test] +fn complete_seed_plan_preserves_both_actual_scene_and_document_baselines() { + for lifetime in [false,true] { + let (document,owner)=fixture(lifetime); + let p=document.project(); let before=p.clone(); let source=document.bundle().clone(); + let baseline=Scene::sequence(p,owner,owner); + let plan=Plan::build(p,owner,&baseline,&Candidate::new()).unwrap(); + assert!(plan.diagnostics.is_empty()); + for (&id,(_,y)) in &baseline.rows { assert_eq!(plan.anchors[&id].y,*y); } + same_scene(&baseline,&render(p,owner,&plan)); + let reparsed=language::parse(&language::format(p)).unwrap(); + same_scene(&baseline,&render(&reparsed,owner,&plan)); + passive(&document,&before,&source); + } +} + +#[test] +fn upward_second_delivery_reaches_200_and_all_other_occurrence_anchors_stay_fixed() { + let (document,owner)=fixture(false); + let p=document.project(); let before=p.clone(); let source=document.bundle().clone(); + let baseline=Scene::sequence(p,owner,owner); + let first=id(p,"first"); let second=id(p,"second"); + assert_ne!(first,second); + for occurrence_id in [first,second] { + assert!(matches!(occurrence(p,occurrence_id),Some(OccurrenceBody::Exchange { exchange:Some(edge),.. }) if *edge==id(p,"channel"))); + } + assert_eq!((row(&baseline,first),row(&baseline,second)),(148.,240.)); + let candidate=drag(&Candidate::new(),&baseline,second,-40.); + assert_eq!(candidate,Candidate::from([(second,200.)])); + let plan=Plan::build(p,owner,&baseline,&candidate).unwrap(); + let moved=render(p,owner,&plan); + assert_eq!((row(&moved,first),row(&moved,second)),(148.,200.)); + others_fixed(&baseline,&moved,second); + assert_eq!(line(&moved,first).points,line(&baseline,first).points); + let left=moved.lanes[&id(p,"left")]; let right=moved.lanes[&id(p,"right")]; + assert_eq!(line(&moved,second).points,vec![Point::new(left,200.),Point::new(right,200.)]); + assert_eq!(plan.messages[&second],MessageAnchors { send_y:200.,receive_y:200. }); + assert!(plan.diagnostics.is_empty()); + same_scene(&moved,&render(p,owner,&plan)); + passive(&document,&before,&source); +} + +#[test] +fn nested_upward_anchor_reaches_457_without_moving_parent_or_followers() { + let (document,owner)=fixture(false); + let p=document.project(); let before=p.clone(); let source=document.bundle().clone(); + let baseline=Scene::sequence(p,owner,owner); + let nested=id(p,"nested"); let fragment=id(p,"fragment"); + assert_eq!(row(&baseline,nested),477.); + let candidate=drag(&Candidate::new(),&baseline,nested,-20.); + let plan=Plan::build(p,owner,&baseline,&candidate).unwrap(); + let moved=render(p,owner,&plan); + assert_eq!(row(&moved,nested),457.); + assert_eq!(named_box(&moved,nested).rect.y,437.); + assert_eq!(named_box(&moved,nested).rect.height,70.); + assert_eq!(named_box(&moved,fragment).rect,named_box(&baseline,fragment).rect); + assert_eq!((named_box(&moved,fragment).rect.y,named_box(&moved,fragment).rect.height),(332.,240.)); + assert_eq!(moved.sequence_bounds[&id(p,"operand")],baseline.sequence_bounds[&id(p,"operand")]); + others_fixed(&baseline,&moved,nested); + assert!(plan.diagnostics.is_empty()); + passive(&document,&before,&source); +} + +#[test] +fn earlier_death_reaches_380_and_actual_lifetime_and_activation_clip_there() { + let (document,owner)=fixture(true); + let p=document.project(); let before=p.clone(); let source=document.bundle().clone(); + let baseline=Scene::sequence(p,owner,owner); + let create=id(p,"create_receiver"); let call=id(p,"service_call"); let delete=id(p,"delete_receiver"); + let provider=id(p,"provider"); + assert_eq!((row(&baseline,create),row(&baseline,call),row(&baseline,delete)),(243.,335.,427.)); + let candidate=drag(&Candidate::new(),&baseline,delete,-47.); + let plan=Plan::build(p,owner,&baseline,&candidate).unwrap(); + assert_eq!(plan.anchors[&delete].role,AnchorRole::MessageReceive); + assert_eq!(plan.messages[&delete],MessageAnchors { send_y:380.,receive_y:380. }); + let moved=render(p,owner,&plan); + assert_eq!(row(&moved,delete),380.); + others_fixed(&baseline,&moved,delete); + let spans=moved.lifecycle.alive_spans(provider,owner,85.,moved.size.height-20.); + assert_eq!(spans.iter().map(|s|(s.start,s.end)).collect::>(),vec![(243.,380.)]); + assert!(moved.lifecycle.alive_spans(provider,owner,380.,moved.size.height-20.).is_empty()); + let x=moved.lanes[&provider]; + assert_eq!(moved.lifecycle.marker_at(Point::new(x,380.)),Some(delete)); + assert_eq!(moved.lifecycle.marker_at(Point::new(x,427.)),None); + assert_eq!(moved.hit(Point::new(x,380.)),Some(delete)); + let bars:Vec<_>=moved.boxes.iter().filter(|b|b.id==call && b.title.is_empty()).collect(); + assert_eq!(bars.len(),1); + assert_eq!(bars[0].rect,Rectangle::new(Point::new(x-4.,335.),Size::new(8.,45.))); + assert_eq!(moved.open_calls,vec![Point::new(x+12.,364.)]); + assert!(plan.diagnostics.is_empty()); + passive(&document,&before,&source); +} + +#[test] +fn self_call_has_explicit_send610_receive634_and_reply_stays728() { + let (document,owner)=fixture(false); + let p=document.project(); let before=p.clone(); let source=document.bundle().clone(); + let baseline=Scene::sequence(p,owner,owner); + let call=id(p,"self_call"); let reply=id(p,"self_reply"); + let candidate=drag(&Candidate::new(),&baseline,call,-26.); + let plan=Plan::build(p,owner,&baseline,&candidate).unwrap(); + assert_eq!(plan.anchors[&call],Anchor { y:610.,role:AnchorRole::MessageSend }); + assert_eq!(plan.messages[&call],MessageAnchors { send_y:610.,receive_y:634. }); + let moved=render(p,owner,&plan); + assert_eq!(row(&moved,call),610.); + assert_eq!(row(&moved,reply),728.); + assert_eq!(line(&moved,call).points.iter().map(|p|p.y).collect::>(),vec![610.,610.,634.,634.]); + assert_eq!(line(&moved,reply).points,line(&baseline,reply).points); + others_fixed(&baseline,&moved,call); + // Peer #21 owns the activation start/column contract. This assertion covers + // actual message anchors, not the old row-based activation start defect. + passive(&document,&before,&source); +} + +#[test] +fn signed_zero_keeps_sparse_candidate_and_restoring_original_candidate_restores_actual_scene() { + let (document,owner)=fixture(false); + let p=document.project(); let before=p.clone(); let source=document.bundle().clone(); + let baseline=Scene::sequence(p,owner,owner); + let second=id(p,"second"); + let original=Candidate::new(); + for zero in [0.,-0.] { assert_eq!(drag(&original,&baseline,second,zero),original); } + let changed=drag(&original,&baseline,second,-40.); + let plan=Plan::build(p,owner,&baseline,&changed).unwrap(); + let frame=render(p,owner,&plan); + for zero in [0.,-0.] { + let untouched=drag(&changed,&frame,second,zero); + assert_eq!(untouched,changed); + same_scene(&frame,&render(p,owner,&Plan::build(p,owner,&baseline,&untouched).unwrap())); + } + // This restores the exact sidecar candidate; it is not a production Undo + // command or evidence of a layout source/REST persistence implementation. + same_scene(&baseline,&render(p,owner,&Plan::build(p,owner,&baseline,&original).unwrap())); + passive(&document,&before,&source); +} + +#[test] +fn crossing_is_rendered_exactly_and_diagnosed_with_extents_covering_the_earlier_far_event() { + let (document,owner)=fixture(false); + let p=document.project(); let before=p.clone(); let source=document.bundle().clone(); + let baseline=Scene::sequence(p,owner,owner); + let first=id(p,"first"); let second=id(p,"second"); + let plan=Plan::build(p,owner,&baseline,&Candidate::from([(first,1000.)])).unwrap(); + assert!(plan.diagnostics.contains(&Diagnostic::SourceOrderCrossing { scope:owner,before:first,after:second })); + let moved=render(p,owner,&plan); + assert_eq!((row(&moved,first),row(&moved,second)),(1000.,240.)); + assert_eq!(row(&moved,id(p,"self_reply")),728.); + others_fixed(&baseline,&moved,first); + assert_eq!(moved.sequence_bounds[&owner].1,1026.); + assert_eq!(moved.size.height,1113.); + assert!(line(&moved,first).points.iter().all(|point| point.y=1070.); + assert!(moved.size.height>1070.); + assert_eq!(row(&moved,following),636.); + others_fixed(&baseline,&moved,nested); +} + +#[test] +fn unsupported_targets_and_lifetime_inversion_fail_explicitly_instead_of_silent_clamping() { + let (document,owner)=fixture(true); + let p=document.project(); let baseline=Scene::sequence(p,owner,owner); + let create=id(p,"create_receiver"); let delete=id(p,"delete_receiver"); + assert_eq!(Plan::build(p,owner,&baseline,&Candidate::from([(delete,200.)])).unwrap_err(), + PlanError::LifetimeInversionNotSupported { create,delete }); + assert_eq!(Plan::build(p,owner,&baseline,&Candidate::from([(delete,f32::NAN)])).unwrap_err(),PlanError::NonFinite(delete)); + let edge=id(p,"service"); + assert_eq!(Plan::build(p,owner,&baseline,&Candidate::from([(edge,200.)])).unwrap_err(),PlanError::UnknownAnchor(edge)); + let (other,other_owner)=fixture(false); + let fragment=id(other.project(),"fragment"); + let other_scene=Scene::sequence(other.project(),other_owner,other_owner); + assert_eq!(Plan::build(other.project(),other_owner,&other_scene,&Candidate::from([(fragment,300.)])).unwrap_err(), + PlanError::CompoundMoveNotImplemented(fragment)); +} + +#[test] +fn complete_plan_scope_is_inactive_in_other_threads_and_restored_after_deliberate_panic() { + let (document,owner)=fixture(false); + let p=document.project(); let baseline=Scene::sequence(p,owner,owner); + let second=id(p,"second"); + let plan=Plan::build(p,owner,&baseline,&Candidate::from([(second,200.)])).unwrap(); + let caught=std::panic::catch_unwind(|| { + let _scope=Scope::install(&plan); + assert_eq!(row(&Scene::sequence(p,owner,owner),second),200.); + std::thread::spawn(|| { + let (document,owner)=fixture(false); + let p=document.project(); + assert_eq!(row(&Scene::sequence(p,owner,owner),id(p,"second")),240.); + }).join().unwrap(); + panic!("deliberate complete-anchor scope unwind"); + }); + let payload=caught.expect_err("deliberate panic is required"); + assert_eq!(payload.downcast_ref::<&str>().copied(),Some("deliberate complete-anchor scope unwind")); + same_scene(&baseline,&Scene::sequence(p,owner,owner)); +} diff --git a/harness/experiments/behavior-anchor-plan-review-20260912/attempt1/harness/experiments/behavior-anchor-plan-20260912/wiring.patch b/harness/experiments/behavior-anchor-plan-review-20260912/attempt1/harness/experiments/behavior-anchor-plan-20260912/wiring.patch new file mode 100644 index 00000000..dc27b942 --- /dev/null +++ b/harness/experiments/behavior-anchor-plan-review-20260912/attempt1/harness/experiments/behavior-anchor-plan-20260912/wiring.patch @@ -0,0 +1,51 @@ +diff --git a/crates/oa-desktop/src/behavior.rs b/crates/oa-desktop/src/behavior.rs +index 3f9fd97..f1ef5b7 100644 +--- a/crates/oa-desktop/src/behavior.rs ++++ b/crates/oa-desktop/src/behavior.rs +@@ -1121,6 +1121,10 @@ fn in_machine(p: &Project, id: Id, machine: Id) -> bool { + #[path = "../../../harness/experiments/behavior-layout-scene-20260912/mod.rs"] + mod behavior_layout_scene_experiment; + ++#[cfg(test)] ++#[path = "../../../harness/experiments/behavior-anchor-plan-20260912/mod.rs"] ++mod behavior_anchor_plan_experiment; ++ + struct BoxItem { + id: Id, + rect: Rectangle, +@@ -1332,6 +1336,8 @@ impl Scene { + let mut y = if scenario { 148. } else { 120. }; + scene.occurrences(p, owner, scenario, &lane_x, &mut y, 0); + scene.size.height = (y + 45.).max(210.); ++ #[cfg(test)] ++ behavior_anchor_plan_experiment::scene_extent(&mut scene); + if scenario { + scene.lifecycle = scenario_lifecycle::render::Rendering::new( + p, +@@ -1441,6 +1447,8 @@ impl Scene { + } + #[cfg(test)] + behavior_layout_scene_experiment::resolve_row_anchor(*id, y); ++ #[cfg(test)] ++ behavior_anchor_plan_experiment::row_anchor(*id, y); + if ordered { + let slot = scenario::Slot { + parent: owner, +@@ -1465,6 +1473,8 @@ impl Scene { + (*y - top).max(95.), + ), + ); ++ #[cfg(test)] ++ let rect = behavior_anchor_plan_experiment::frame_bounds(*id, rect); + self.boxes.insert( + 0, + BoxItem { +@@ -1649,6 +1659,8 @@ impl Scene { + } + if ordered { + self.sequence_bounds.insert(owner, (sequence_top, *y - 42.)); ++ #[cfg(test)] ++ behavior_anchor_plan_experiment::scope_end(owner, y, &mut self.sequence_bounds); + let slot = scenario::Slot { + parent: owner, + before: None, diff --git a/harness/experiments/behavior-anchor-plan-review-20260912/attempt1/manifest.json b/harness/experiments/behavior-anchor-plan-review-20260912/attempt1/manifest.json new file mode 100644 index 00000000..a596a2c5 --- /dev/null +++ b/harness/experiments/behavior-anchor-plan-review-20260912/attempt1/manifest.json @@ -0,0 +1,31 @@ +{ + "base": "66a1f1690f778c8814bfefff7f46fe7f6bbe520e", + "status": "Static-review candidate; not compiled or executed", + "files": [ + { + "path": "crates/oa-desktop/src/behavior.rs", + "bytes": 137409, + "sha256": "5884BA35AD994EB5936CA29C071A5002EFC221DC42EF35E2265D50593BDF60EC" + }, + { + "path": "harness/experiments/behavior-anchor-plan-20260912/mod.rs", + "bytes": 10863, + "sha256": "CA7CFE5DD1FBE37D54E5E96F0C73825D0AA0B06733E19C62592BF2AAFD62EC2F" + }, + { + "path": "harness/experiments/behavior-anchor-plan-20260912/tests.rs", + "bytes": 14475, + "sha256": "477B6D271CCBC95CFFF415DDE3DAE5CF4B7F1DEE12D4D5FCE6B095A0DF35891C" + }, + { + "path": "harness/experiments/behavior-anchor-plan-20260912/README.md", + "bytes": 10076, + "sha256": "6B9AD8B34AFC1E62931865007F7AFE57D28DC54AB9C805F33D3D86D215588514" + }, + { + "path": "harness/experiments/behavior-anchor-plan-20260912/wiring.patch", + "bytes": 2131, + "sha256": "4CE4BC45FBC419EBA6A07B048B82EFB8D85525514E3D2E56421948E7733F40FD" + } + ] +} diff --git a/harness/experiments/behavior-anchor-plan-review-20260912/attempt2/crates/oa-desktop/src/behavior.rs b/harness/experiments/behavior-anchor-plan-review-20260912/attempt2/crates/oa-desktop/src/behavior.rs new file mode 100644 index 00000000..f1ef5b70 --- /dev/null +++ b/harness/experiments/behavior-anchor-plan-review-20260912/attempt2/crates/oa-desktop/src/behavior.rs @@ -0,0 +1,3515 @@ +//! Readable behavioral projections. Selection edits the same typed records as text. +use crate::{ + app::{App, Choice, Message}, + style, + views::{action, heading, label, short}, +}; +use iced::{ + Color, Element, Fill, Point, Rectangle, Renderer, Size, Theme, mouse, + widget::{ + Action, button, + canvas::{self, Frame, Path, Stroke, Text}, + column, container, pick_list, row, scrollable, text, + }, +}; +use oa_core::*; +use std::collections::BTreeMap; +pub(crate) mod canvas_workspace; +#[cfg(test)] +mod contextual_source_tests; +pub(crate) mod creation; +mod form_session; +pub(crate) mod scenario; +mod scenario_lifecycle; +pub(crate) mod source_draft; +pub(crate) mod state_canvas; +pub(crate) mod state_edit; +pub(crate) mod state_menu; +pub(crate) mod state_view; +pub(crate) mod states; +pub(crate) mod topology; +// The standard on_scroll callback only reports active scrolling. This local +// wrapper reads the existing Scrollable's actual translation as well, including +// initial mount and passive size clamping. It forwards layout, input and drawing. +mod scenario_scroll { + use super::*; + use iced::advanced::{Clipboard, Layout, Shell, Widget, layout, renderer, widget}; + use iced::{Length, Vector, overlay}; + + pub(super) fn observe<'a, R: renderer::Renderer + 'a>( + context: scenario::Context, + area: scenario::ScrollArea, + process: bool, + restore: Option, + content: impl Into>, + ) -> Element<'a, Message, Theme, R> { + Element::new(Observer { + content: content.into(), + context, + area, + process, + restore, + }) + } + struct Observer<'a, R = Renderer> { + content: Element<'a, Message, Theme, R>, + context: scenario::Context, + area: scenario::ScrollArea, + process: bool, + restore: Option, + } + type Report = Option<(bool, Id, Id, Vector)>; + struct ObservedScroll { + identity: (Id, Id), + report: Report, + restored: Option, + } + #[derive(Default)] + struct Translation(Option); + impl widget::Operation for Translation { + fn traverse(&mut self, _: &mut dyn FnMut(&mut dyn widget::Operation)) {} + fn scrollable( + &mut self, + _: Option<&widget::Id>, + _: Rectangle, + _: Rectangle, + translation: Vector, + _: &mut dyn widget::operation::Scrollable, + ) { + self.0 = Some(translation); + } + } + struct RestoreTranslation { + offset: Vector, + applied: bool, + } + impl widget::Operation for RestoreTranslation { + // Restore only this wrapper's root Scrollable, never its nested canvas. + fn traverse(&mut self, _: &mut dyn FnMut(&mut dyn widget::Operation)) {} + fn scrollable( + &mut self, + _: Option<&widget::Id>, + _: Rectangle, + _: Rectangle, + _: Vector, + state: &mut dyn widget::operation::Scrollable, + ) { + state.scroll_to(widget::operation::scrollable::AbsoluteOffset { + x: Some(self.offset.x), + y: Some(self.offset.y), + }); + self.applied = true; + } + } + impl Observer<'_, R> { + fn report( + &mut self, + tree: &mut widget::Tree, + layout: Layout<'_>, + renderer: &R, + shell: &mut Shell<'_, Message>, + ) { + let restore = self.restore.filter(|request| { + !self.process + && (request.document, request.owner) + == (self.context.document, self.context.scenario) + && tree.state.downcast_ref::().restored != Some(request.token) + }); + let receipt = restore.and_then(|request| { + let mut operation = RestoreTranslation { + offset: request.offset, + applied: false, + }; + self.content.as_widget_mut().operate( + &mut tree.children[0], + layout, + renderer, + &mut operation, + ); + operation.applied.then_some(request.token) + }); + let mut offset = Translation::default(); + self.content.as_widget_mut().operate( + &mut tree.children[0], + layout, + renderer, + &mut offset, + ); + if let Some(offset) = offset.0 { + let next = Some(( + self.process, + self.context.document, + self.context.scenario, + offset, + )); + let state = tree.state.downcast_mut::(); + if let Some(token) = receipt { + state.restored = Some(token); + state.report = next; + shell.publish(Message::ScenarioCanvas(scenario::CanvasEvent::Restored( + self.context, + token, + self.area, + offset, + ))); + return; + } + let previous = &mut state.report; + if *previous != next { + *previous = next; + shell.publish(if self.process { + Message::TopologyCanvas(topology::CanvasEvent::Scrolled( + topology::Context { + document: self.context.document, + owner: self.context.scenario, + revision: self.context.revision, + epoch: self.context.epoch, + }, + self.area, + offset, + )) + } else { + Message::ScenarioCanvas(scenario::CanvasEvent::Scrolled( + self.context, + self.area, + offset, + )) + }); + } + } + } + } + impl Widget for Observer<'_, R> { + fn tag(&self) -> widget::tree::Tag { + widget::tree::Tag::of::() + } + fn state(&self) -> widget::tree::State { + widget::tree::State::new(ObservedScroll { + identity: (self.context.document, self.context.scenario), + report: None, + restored: None, + }) + } + fn children(&self) -> Vec { + vec![widget::Tree::new(self.content.as_widget())] + } + fn diff(&self, tree: &mut widget::Tree) { + if tree.state.downcast_ref::().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(); + tree.children = self.children(); + } else { + tree.diff_children(std::slice::from_ref(&self.content)); + } + } + fn size(&self) -> Size { + self.content.as_widget().size() + } + fn size_hint(&self) -> Size { + self.content.as_widget().size_hint() + } + fn layout( + &mut self, + tree: &mut widget::Tree, + renderer: &R, + limits: &layout::Limits, + ) -> layout::Node { + self.content + .as_widget_mut() + .layout(&mut tree.children[0], renderer, limits) + } + fn update( + &mut self, + tree: &mut widget::Tree, + event: &iced::Event, + layout: Layout<'_>, + cursor: mouse::Cursor, + renderer: &R, + clipboard: &mut dyn Clipboard, + shell: &mut Shell<'_, Message>, + viewport: &Rectangle, + ) { + // A passive clamp notification precedes any pointer result produced + // with the old context; App rejects that result after the epoch bump. + self.report(tree, layout, renderer, shell); + self.content.as_widget_mut().update( + &mut tree.children[0], + event, + layout, + cursor, + renderer, + clipboard, + shell, + viewport, + ); + self.report(tree, layout, renderer, shell); + } + fn draw( + &self, + tree: &widget::Tree, + renderer: &mut R, + theme: &Theme, + style: &renderer::Style, + layout: Layout<'_>, + cursor: mouse::Cursor, + viewport: &Rectangle, + ) { + self.content.as_widget().draw( + &tree.children[0], + renderer, + theme, + style, + layout, + cursor, + viewport, + ); + } + fn operate( + &mut self, + tree: &mut widget::Tree, + layout: Layout<'_>, + renderer: &R, + operation: &mut dyn widget::Operation, + ) { + self.content.as_widget_mut().operate( + &mut tree.children[0], + layout, + renderer, + operation, + ); + } + fn mouse_interaction( + &self, + tree: &widget::Tree, + layout: Layout<'_>, + cursor: mouse::Cursor, + viewport: &Rectangle, + renderer: &R, + ) -> mouse::Interaction { + self.content.as_widget().mouse_interaction( + &tree.children[0], + layout, + cursor, + viewport, + renderer, + ) + } + fn overlay<'b>( + &'b mut self, + tree: &'b mut widget::Tree, + layout: Layout<'b>, + renderer: &R, + viewport: &Rectangle, + translation: Vector, + ) -> Option> { + self.content.as_widget_mut().overlay( + &mut tree.children[0], + layout, + renderer, + viewport, + translation, + ) + } + } + // Iced exposes its null renderer only in debug builds. + #[cfg(all(test, debug_assertions))] + mod tests { + use super::*; + fn observer(height: f32, context: scenario::Context) -> Observer<'static, ()> { + Observer { + context, + area: scenario::ScrollArea::Page, + process: false, + restore: None, + content: scrollable(iced::widget::Space::new().width(200.).height(height)) + .id("observer-test") + .width(200.) + .height(200.) + .into(), + } + } + fn update( + observer: &mut Observer<'_, ()>, + tree: &mut widget::Tree, + event: iced::Event, + ) -> (Vec, bool) { + let node = observer.layout( + tree, + &(), + &layout::Limits::new(Size::ZERO, Size::new(800., 700.)), + ); + let mut messages = vec![]; + let mut shell = Shell::new(&mut messages); + observer.update( + tree, + &event, + Layout::new(&node), + mouse::Cursor::Available(Point::new(50., 50.)), + &(), + &mut iced::advanced::clipboard::Null, + &mut shell, + &Rectangle::with_size(Size::new(800., 700.)), + ); + let captured = shell.is_event_captured(); + (messages, captured) + } + fn offsets(messages: &[Message]) -> Vec { + messages + .iter() + .filter_map(|message| { + if let Message::ScenarioCanvas(scenario::CanvasEvent::Scrolled(_, _, offset)) = + message + { + Some(*offset) + } else { + None + } + }) + .collect() + } + fn actual_offset(view: &mut Observer<'_, ()>, tree: &mut widget::Tree) -> Vector { + let node = view.layout( + tree, + &(), + &layout::Limits::new(Size::ZERO, Size::new(800., 700.)), + ); + let mut operation = Translation::default(); + view.operate(tree, Layout::new(&node), &(), &mut operation); + operation.0.unwrap() + } + #[test] + fn direct_scenario_caller_restores_mounted_scroll_once_with_clamping_and_app_receipt() { + use scenario::{ + CanvasEvent, ScrollArea, + direct_tests::{navigation_event, navigation_fixture}, + }; + let redraw = || { + iced::Event::Window(iced::window::Event::RedrawRequested( + std::time::Instant::now(), + )) + }; + let area = ScrollArea::Page; + for returned_height in [600., 250.] { + let (project, [a, _, _], [use_id, _, _]) = navigation_fixture(); + let mut app = App::with_project(project.clone()); + let _ = app.update(Message::Select(a)); + let _ = app.update(Message::Page(crate::app::Page::Behavior)); + let source = app.document.bundle().clone(); + let mut view = observer(600., app.scenario_context().unwrap()); + view.area = area; + let mut tree = widget::Tree::new(&view as &dyn Widget); + for message in update(&mut view, &mut tree, redraw()).0 { + let _ = app.update(message); + } + for message in update( + &mut view, + &mut tree, + iced::Event::Mouse(mouse::Event::WheelScrolled { + delta: mouse::ScrollDelta::Pixels { x: 0., y: -180. }, + }), + ) + .0 + { + let _ = app.update(message); + } + assert_eq!(actual_offset(&mut view, &mut tree), Vector::new(0., 180.)); + navigation_event(&mut app, |ctx| CanvasEvent::OpenUse(ctx, use_id)); + view = observer(600., app.scenario_context().unwrap()); + view.area = area; + view.diff(&mut tree); + for message in update(&mut view, &mut tree, redraw()).0 { + let _ = app.update(message); + } + assert_eq!(actual_offset(&mut view, &mut tree), Vector::ZERO); + navigation_event(&mut app, CanvasEvent::Back); + assert_eq!(app.selected, use_id); + let context = app.scenario_context().unwrap(); + let request = app.behavior.scenario.scroll_restore(context, area).unwrap(); + view = observer(returned_height, context); + view.area = area; + view.restore = Some(request); + view.diff(&mut tree); + let expected = Vector::new(0., (returned_height - 200.).min(180.)); + let messages = update(&mut view, &mut tree, redraw()).0; + assert!( + matches!(messages.as_slice(), [Message::ScenarioCanvas(CanvasEvent::Restored(_, _, _, actual))] if *actual == expected) + ); + assert_eq!(actual_offset(&mut view, &mut tree), expected); + for message in messages { + let _ = app.update(message); + } + assert!( + app.behavior + .scenario + .scroll_restore(app.scenario_context().unwrap(), area) + .is_none() + ); + // A delayed receipt leaves the same request in the view; later input must not snap back. + for message in update( + &mut view, + &mut tree, + iced::Event::Mouse(mouse::Event::WheelScrolled { + delta: mouse::ScrollDelta::Pixels { x: 0., y: 20. }, + }), + ) + .0 + { + let _ = app.update(message); + } + let moved = expected - Vector::new(0., 20.); + assert_eq!(actual_offset(&mut view, &mut tree), moved); + view.context = app.scenario_context().unwrap(); + view.diff(&mut tree); + assert!(update(&mut view, &mut tree, redraw()).0.is_empty()); + assert_eq!(actual_offset(&mut view, &mut tree), moved); + navigation_event(&mut app, |ctx| { + CanvasEvent::Restored(ctx, request.token, area, Vector::new(0., 999.)) + }); + let late_context = app.scenario_context().unwrap(); + let _ = app.update(Message::Page(crate::app::Page::Diagram)); + let epoch = app.canvas_epoch; + let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Restored( + late_context, + request.token, + area, + Vector::ZERO, + ))); + assert_eq!(app.canvas_epoch, epoch); + assert_eq!(app.project(), &project); + assert_eq!(app.document.bundle(), &source); + assert!(!app.document.can_undo()); + } + } + #[test] + fn direct_scenario_restore_cannot_scroll_another_document_owner_or_process_view() { + 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, + token: Id::new_v4(), + offset: Vector::new(0., 180.), + }; + match mismatch { + 0 => request.document = Id::new_v4(), + 1 => request.owner = Id::new_v4(), + _ => {} + } + let mut view = observer(600., context); + view.process = mismatch == 2; + view.restore = Some(request); + let mut tree = widget::Tree::new(&view as &dyn Widget); + let messages = update( + &mut view, + &mut tree, + iced::Event::Window(iced::window::Event::RedrawRequested( + std::time::Instant::now(), + )), + ) + .0; + assert_eq!(actual_offset(&mut view, &mut tree), Vector::ZERO); + assert!(!messages.iter().any(|m| matches!( + m, + Message::ScenarioCanvas(scenario::CanvasEvent::Restored(..)) + ))); + } + } + #[test] + fn independent_process_owner_change_resets_actual_scroll_offsets() { + let context = scenario::direct_tests::context(Id::new_v4()); + let redraw = || { + iced::Event::Window(iced::window::Event::RedrawRequested( + std::time::Instant::now(), + )) + }; + let process_offsets = |messages: Vec| { + messages + .into_iter() + .filter_map(|message| { + if let Message::TopologyCanvas(topology::CanvasEvent::Scrolled( + _, + _, + offset, + )) = message + { + Some(offset) + } else { + None + } + }) + .collect::>() + }; + let mut view = observer(600., context); + view.process = true; + let mut tree = widget::Tree::new(&view as &dyn Widget); + assert_eq!( + process_offsets(update(&mut view, &mut tree, redraw()).0), + vec![Vector::ZERO] + ); + let (messages, captured) = update( + &mut view, + &mut tree, + iced::Event::Mouse(mouse::Event::WheelScrolled { + delta: mouse::ScrollDelta::Pixels { x: 0., y: -180. }, + }), + ); + assert!(captured); + assert_eq!(process_offsets(messages), vec![Vector::new(0., 180.)]); + let mut other = context; + other.scenario = Id::new_v4(); + let mut state = topology::State::default(); + state.enter(context.document, context.scenario); + state.scrolled(scenario::ScrollArea::Page, Vector::new(0., 180.)); + state.enter(other.document, other.scenario); + assert_eq!(state.details_offset(), Vector::ZERO); + view = observer(600., other); + view.process = true; + view.diff(&mut tree); + let reported = process_offsets(update(&mut view, &mut tree, redraw()).0); + assert_eq!( + reported, + vec![Vector::ZERO], + "The new process immediately reacquires its predecessor's scroll offset" + ); + } + + #[test] + fn direct_scroll_mounted_identity_resets_navigation_but_preserves_edits_before_first_report() + { + use widget::operation::scrollable::{AbsoluteOffset, scroll_to}; + for process in [false, true] { + let area = scenario::ScrollArea::Page; + for change in 0..4 { + let context = scenario::direct_tests::context(Id::new_v4()); + let mut view = observer(600., context); + view.process = process; + view.area = area; + let mut tree = widget::Tree::new(&view as &dyn Widget); + let node = view.layout( + &mut tree, + &(), + &layout::Limits::new(Size::ZERO, Size::new(800., 700.)), + ); + // Model a programmatic reveal before the observer has reported. + // Mounted identity must not depend on its last scroll notification. + let mut operation = scroll_to::<()>( + widget::Id::new("observer-test"), + AbsoluteOffset { + x: None, + y: Some(180.), + }, + ); + view.operate(&mut tree, Layout::new(&node), &(), &mut operation); + let mut next = context; + match change { + 0 => next.scenario = Id::new_v4(), + 1 => next.document = Id::new_v4(), + 2 => { + next.revision += 1; + next.epoch += 5; + } + _ => {} + } + view = observer(if change == 3 { 800. } else { 600. }, next); + view.process = process; + view.area = area; + view.diff(&mut tree); + let redraw = || { + iced::Event::Window(iced::window::Event::RedrawRequested( + std::time::Instant::now(), + )) + }; + let (messages, _) = update(&mut view, &mut tree, redraw()); + let actual = messages + .into_iter() + .filter_map(|message| match message { + Message::TopologyCanvas(topology::CanvasEvent::Scrolled( + c, + reported_area, + offset, + )) => { + assert_eq!( + (c.document, c.owner, c.revision, c.epoch), + (next.document, next.scenario, next.revision, next.epoch) + ); + assert_eq!( + std::mem::discriminant(&reported_area), + std::mem::discriminant(&area) + ); + Some(offset) + } + Message::ScenarioCanvas(scenario::CanvasEvent::Scrolled( + c, + reported_area, + offset, + )) => { + assert_eq!(c, next); + assert_eq!( + std::mem::discriminant(&reported_area), + std::mem::discriminant(&area) + ); + Some(offset) + } + _ => None, + }) + .collect::>(); + assert_eq!( + actual, + vec![if change < 2 { + Vector::ZERO + } else { + Vector::new(0., 180.) + }], + "process={process} area={area:?} change={change}" + ); + assert!(update(&mut view, &mut tree, redraw()).0.is_empty()); + } + } + } + + #[test] + fn direct_scroll_observer_reports_actual_initial_clamped_and_remounted_offsets_once() { + let context = scenario::direct_tests::context(Id::new_v4()); + let redraw = || { + iced::Event::Window(iced::window::Event::RedrawRequested( + std::time::Instant::now(), + )) + }; + let mut view = observer(600., context); + let mut tree = widget::Tree::new(&view as &dyn Widget); + assert_eq!( + offsets(&update(&mut view, &mut tree, redraw()).0), + vec![Vector::ZERO] + ); + assert!(update(&mut view, &mut tree, redraw()).0.is_empty()); + let (messages, captured) = update( + &mut view, + &mut tree, + iced::Event::Mouse(mouse::Event::WheelScrolled { + delta: mouse::ScrollDelta::Pixels { x: 0., y: -180. }, + }), + ); + assert!(captured, "the observer forwards normal Scrollable capture"); + assert_eq!(offsets(&messages), vec![Vector::new(0., 180.)]); + assert!(update(&mut view, &mut tree, redraw()).0.is_empty()); + view = observer(250., context); + view.diff(&mut tree); + assert_eq!( + offsets(&update(&mut view, &mut tree, redraw()).0), + vec![Vector::new(0., 50.)] + ); + assert!(update(&mut view, &mut tree, redraw()).0.is_empty()); + view = observer(600., context); + view.diff(&mut tree); + assert_eq!( + offsets(&update(&mut view, &mut tree, redraw()).0), + vec![Vector::new(0., 180.)] + ); + let mut remounted = widget::Tree::new(&view as &dyn Widget); + assert_eq!( + offsets(&update(&mut view, &mut remounted, redraw()).0), + vec![Vector::ZERO] + ); + assert!(update(&mut view, &mut remounted, redraw()).0.is_empty()); + } + } +} +#[derive(Debug, Clone)] +pub enum Event { + Creation(creation::Event), + Topology(topology::Event), + Scenario(scenario::Event), + States(states::Event), + Submit, +} +impl Event { + pub fn is_source_back(&self) -> bool { + matches!( + self, + Self::Creation(creation::Event::Source(source_draft::Event::Edit( + _, + source_draft::Edit::Back + ))) | Self::Scenario(scenario::Event::Source(source_draft::Event::Edit( + _, + source_draft::Edit::Back + ))) | Self::Topology(topology::Event::Source(source_draft::Event::Edit( + _, + source_draft::Edit::Back + ))) + ) + } + pub fn is_source(&self) -> bool { + matches!( + self, + Self::Creation(creation::Event::Source(_)) + | Self::Scenario(scenario::Event::Source(_)) + | Self::Topology(topology::Event::Source(_)) + ) + } + pub fn starts_form(&self) -> bool { + matches!( + self, + Self::Creation(creation::Event::Open(..)) + | Self::Topology(topology::Event::Start(..)) + | Self::Scenario(scenario::Event::Start(..)) + | Self::States(states::Event::Start(..) | states::Event::Edit(..)) + ) + } + pub fn is_submit(&self) -> bool { + matches!( + self, + Self::Submit + | Self::Creation(creation::Event::Submit) + | Self::Topology(topology::Event::Submit) + | Self::Scenario(scenario::Event::Submit) + | Self::States(states::Event::Submit) + ) + } +} +#[derive(Default)] +pub struct State { + pub(crate) creation: creation::State, + pub active_owner: Option, + pub diagram: state_view::State, + pub(crate) topology: topology::State, + pub(crate) scenario: scenario::State, + states: states::State, +} +const PENDING_DRAFT: &str = "Apply or cancel the current behavior form before starting another."; +#[derive(serde::Serialize, serde::Deserialize)] +#[serde(deny_unknown_fields)] +struct RecoveryDraft { + version: u32, + kind: RecoveryKind, + draft: serde_json::Value, +} +#[derive(serde::Serialize, serde::Deserialize)] +enum RecoveryKind { + Creation, + State, + Scenario, + Process, +} +impl State { + /// Capture only the pending authored form. Pointer gestures, cached choices, + /// viewport geometry and document/revision tokens never cross a restart. + pub fn recovery_snapshot(&self) -> Result, String> { + let drafts = [ + (RecoveryKind::Creation, self.creation.recovery_snapshot()?), + (RecoveryKind::State, self.states.recovery_snapshot()?), + (RecoveryKind::Scenario, self.scenario.recovery_snapshot()?), + (RecoveryKind::Process, self.topology.recovery_snapshot()?), + ]; + let mut drafts = drafts.into_iter().filter_map(|(kind, draft)| { + draft.map(|draft| RecoveryDraft { + version: 1, + kind, + draft, + }) + }); + let first = drafts.next(); + if drafts.next().is_some() { + return Err( + "Several behavior forms are pending; recovery cannot choose one to discard.".into(), + ); + } + first + .map(serde_json::to_value) + .transpose() + .map_err(|e| e.to_string()) + } + + /// Restore raw fields without planning/executing them. Rejected values and + /// incomplete endpoints must remain available for repair after a crash. + pub fn restore_recovery(&mut self, value: serde_json::Value) -> Result<(), String> { + let saved: RecoveryDraft = serde_json::from_value(value).map_err(|e| e.to_string())?; + if saved.version != 1 { + return Err(format!( + "Unsupported behavior recovery version {}", + saved.version + )); + } + let mut restored = Self::default(); + match saved.kind { + RecoveryKind::Creation => { + restored.creation = creation::State::from_recovery(saved.draft)? + } + RecoveryKind::State => restored.states = states::State::from_recovery(saved.draft)?, + RecoveryKind::Scenario => { + restored.scenario = scenario::State::from_recovery(saved.draft)? + } + RecoveryKind::Process => { + restored.topology = topology::State::from_recovery(saved.draft)? + } + } + restored.active_owner = restored.pending_owner(); + *self = restored; + Ok(()) + } + + pub fn has_pending(&self) -> bool { + self.creation.has_pending() || self.pending_owner().is_some() + } + + pub fn pending_owner(&self) -> Option { + self.topology + .pending_owner() + .or_else(|| self.scenario.pending_owner()) + .or_else(|| self.states.pending_owner()) + } + + /// Prepare the active form without discarding inputs. The caller must execute + /// this exact command and acknowledge only a successful document transaction. + pub fn prepare_pending(&self, p: &Project) -> Result, String> { + if self.creation.has_pending() { + self.creation.prepare_pending(p).map(Some) + } else if self.topology.pending_owner().is_some() { + self.topology.prepare_pending(p).map(Some) + } else if self.scenario.pending_owner().is_some() { + self.scenario.prepare_pending(p).map(Some) + } else if self.states.pending_owner().is_some() { + self.states.prepare_pending(p).map(Some) + } else { + Ok(None) + } + } + + /// Clear the active form after its prepared command commits successfully. + /// Rejected commands must leave the form available for repair and retry. + pub fn source_active(&self) -> bool { + self.creation.source_active() + || self.scenario.source_active() + || self.topology.source_active() + } + pub fn source_open(&self) -> bool { + self.creation.source_open() || self.scenario.source_open() || self.topology.source_open() + } + pub fn source_back(&mut self) -> bool { + self.creation.source_back() || self.scenario.source_back() || self.topology.source_back() + } + + pub fn acknowledge_commit(&mut self) { + self.creation.acknowledge_commit(); + self.topology.acknowledge_commit(); + self.scenario.acknowledge_commit(); + self.states.acknowledge_commit(); + } + + pub fn update(&mut self, p: &Project, event: Event) -> Result, String> { + if self.has_pending() + && matches!( + &event, + Event::Creation(creation::Event::Open(..)) + | Event::Topology(topology::Event::Start(..)) + | Event::Scenario(scenario::Event::Start(..)) + | Event::States(states::Event::Start(..) | states::Event::Edit(..)) + ) + { + return Err(PENDING_DRAFT.into()); + } + let cancels_pending = match &event { + Event::Topology(topology::Event::Cancel) => self.topology.pending_owner().is_some(), + Event::Scenario(scenario::Event::Cancel) => self.scenario.pending_owner().is_some(), + Event::States(states::Event::Cancel) => self.states.pending_owner().is_some(), + _ => false, + }; + if cancels_pending { + self.acknowledge_commit(); + return Ok(None); + } + match event { + Event::Creation(event) => self.creation.update(p, event), + Event::Topology(event) => self.topology.update(p, event), + Event::Scenario(event) => self.scenario.update(p, event), + Event::States(event) => self.states.update(p, event), + Event::Submit => self + .prepare_pending(p)? + .map(Some) + .ok_or_else(|| "Choose a behavior modeling tool.".into()), + } + } +} + +pub fn view(app: &App) -> Element<'_, Message> { + let p = app.project(); + if let Some(card) = app.behavior.creation.view(p) { + return card.map(|event| Message::Behavior(Event::Creation(event))); + } + if let Some(owner) = app.behavior.pending_owner() { + let kind = p.elements.get(&owner).map(oa_core::Element::kind); + let unavailable = p.layer_of(owner) != Some(app.layer) + || (app.behavior.scenario.pending_owner().is_some() && kind != Some(Kind::Scenario)) + || (app.behavior.topology.pending_owner().is_some() + && !matches!(kind, Some(Kind::Process | Kind::FunctionalChain))); + if unavailable { + let mut out=column![text("The pending behavior owner is unavailable").size(20),text("Your inputs are retained. Return from the source form or cancel this parent draft before choosing another behavior.").size(13)].spacing(12); + if let Some(name) = app + .behavior + .scenario + .pending_name() + .or_else(|| app.behavior.topology.pending_name()) + { + out = + out.push(text(format!("Pending consumer name: {}", short(name, 80))).size(14)); + } + if let Some(child) = app.behavior.scenario.source_view(p) { + out = out.push(child.map(|e| Message::Behavior(Event::Scenario(e)))); + } else if let Some(child) = app.behavior.topology.source_view(p) { + out = out.push(child.map(|e| Message::Behavior(Event::Topology(e)))); + } else { + let cancel = if app.behavior.scenario.pending_owner().is_some() { + Event::Scenario(scenario::Event::Cancel) + } else if app.behavior.topology.pending_owner().is_some() { + Event::Topology(topology::Event::Cancel) + } else { + Event::States(states::Event::Cancel) + }; + out = out.push(button("Cancel retained draft").on_press(Message::Behavior(cancel))); + } + return container(out).padding(16).width(Fill).height(Fill).into(); + } + } + let choices = p + .elements + .values() + .filter(|e| { + e.layer() == app.layer && (e.kind().is_sequence() || e.kind() == Kind::StateMachine) + }) + .map(|e| Choice { + id: e.id, + label: format!("{} · {}", e.kind(), e.name), + }) + .collect::>(); + let owner = selected_owner(app); + let selected = owner + .and_then(|id| choices.iter().find(|c| c.id == id)) + .or(choices.first()) + .cloned(); + let mut out = column![heading("Behavior & scenarios", "Follow exchanges and operating conditions. Select a message, state or transition to edit it.")].spacing(14); + let Some(selected) = selected else { + return out + .push(label( + "Start a named process, scenario or state machine, then model its behavior here.", + )) + .push(action( + "New", + Message::Behavior(Event::Creation(creation::Event::Open(app.layer))), + )) + .padding(24) + .into(); + }; + let e = &p.elements[&selected.id]; + let compact_canvas = matches!( + e.kind(), + Kind::Process | Kind::FunctionalChain | Kind::Scenario + ); + if e.kind() == Kind::StateMachine { + out = column![text("States & modes").size(20)].spacing(8); + } else if compact_canvas { + // The process/scenario canvas owns its compact remaining-height layout. + out = column![].spacing(6); + } + out = out.push( + row![ + pick_list(choices, Some(selected), Message::BehaviorOwner) + .width(Fill) + .text_size(13) + .padding(if compact_canvas { 6 } else { 10 }), + action( + "New", + Message::Behavior(Event::Creation(creation::Event::Open(app.layer))) + ) + ] + .spacing(8), + ); + if matches!(e.kind(), Kind::Process | Kind::FunctionalChain) { + return topology::view(app, e.id, out); + } + if e.kind() == Kind::Scenario { + return scenario::view(app, e.id, out); + } + state_view::view(app, e.id, out) +} + +pub fn selected_owner(app: &App) -> Option { + let valid = |id: &Id| { + app.project().elements.get(id).is_some_and(|e| { + e.layer() == app.layer && (e.kind().is_sequence() || e.kind() == Kind::StateMachine) + }) + }; + app.behavior + .pending_owner() + .or(app.behavior.active_owner.filter(valid)) + .or_else(|| selected_behavior(app.project(), app.selected).filter(valid)) + .or_else(|| app.project().elements.keys().find(|id| valid(id)).copied()) +} +/// Mirror the core's legacy-versus-explicit process interpretation. Existing +/// exchange-only lists still describe ordered paths until explicitly migrated. +fn uses_explicit_topology(p: &Project, owner: Id) -> bool { + let Some(e) = p.elements.get(&owner) else { + return false; + }; + if !matches!(e.kind(), Kind::Process | Kind::FunctionalChain) { + return false; + } + p.relations.values().any(|r| { + r.kind() == RelationKind::SequenceLink + && p.elements.get(&r.source()).and_then(|e| e.parent) == Some(owner) + && p.elements.get(&r.target()).and_then(|e| e.parent) == Some(owner) + }) || e.steps().into_iter().flatten().any(|id| { + matches!( + p.elements.get(id).and_then(|e| e.shared()), + Some(SharedBody::Occurrence { + body: OccurrenceBody::Action { .. } + | OccurrenceBody::Control { .. } + | OccurrenceBody::SequenceReference { .. }, + .. + }) + ) + }) +} +pub fn selected_behavior(p: &Project, selected: Id) -> Option { + if let Some(Relation { + body: RelationBody::ContextualLink { owner, .. }, + .. + }) = p.relations.get(&selected) + { + return p + .elements + .get(owner) + .filter(|e| matches!(e.kind(), Kind::Process | Kind::FunctionalChain)) + .map(|e| e.id); + } + let mut current = Some( + p.relations + .get(&selected) + .filter(|r| { + matches!( + r.kind(), + RelationKind::StateTransition + | RelationKind::SequenceLink + | RelationKind::StateAction + ) + }) + .map(|r| r.source()) + .unwrap_or(selected), + ); + while let Some(id) = current { + let e = p.elements.get(&id)?; + if e.kind().is_sequence() || e.kind() == Kind::StateMachine { + return Some(id); + } + current = e.parent; + } + None +} +fn in_machine(p: &Project, id: Id, machine: Id) -> bool { + let mut current = Some(id); + while let Some(id) = current { + if id == machine { + return true; + } + current = p.elements.get(&id).and_then(|e| e.parent); + } + false +} + +#[cfg(test)] +#[path = "../../../harness/experiments/behavior-layout-scene-20260912/mod.rs"] +mod behavior_layout_scene_experiment; + +#[cfg(test)] +#[path = "../../../harness/experiments/behavior-anchor-plan-20260912/mod.rs"] +mod behavior_anchor_plan_experiment; + +struct BoxItem { + id: Id, + rect: Rectangle, + title: String, + detail: String, + marker: Option, +} +fn box_text_clip( + pan: iced::Vector, + rect: Rectangle, + scale: f32, + viewport: Size, +) -> Option { + if !pan.x.is_finite() + || !pan.y.is_finite() + || !scale.is_finite() + || scale <= 0. + || rect.width <= 20. + || rect.height <= 16. + { + return None; + } + Rectangle::new( + Point::new( + pan.x + (rect.x + 10.) * scale, + pan.y + (rect.y + 8.) * scale, + ), + Size::new((rect.width - 20.) * scale, (rect.height - 16.) * scale), + ) + .intersection(&Rectangle::new(Point::ORIGIN, viewport)) +} + +struct Line { + id: Id, + points: Vec, + label: String, + label_at: Point, + dashed: bool, +} +impl Line { + fn label_bounds(&self) -> Rectangle { + Rectangle::new( + self.label_at, + Size::new( + (self.label.chars().count() as f32 * 6.8 + 8.).min(225.), + 25., + ), + ) + } +} +struct Scene { + boxes: Vec, + lines: Vec, + size: Size, + selected: Id, + scale: f32, + camera_pan: iced::Vector, + interaction: Option<(scenario::Context, scenario::CanvasTool, bool)>, + pending_placement: Option<(scenario::Slot, Option)>, + lanes: BTreeMap, + slots: Vec, + rows: BTreeMap, + open_calls: Vec, + sequence_bounds: BTreeMap, + lifecycle: scenario_lifecycle::render::Rendering, +} +struct SequenceSlot { + slot: scenario::Slot, + y: f32, + label: String, +} +#[derive(Default)] +struct SequenceInput { + context: Option, + tool: scenario::CanvasTool, + pointer: Option, + bounds: Option, + source: Option<(Id, scenario::Slot)>, + press: Option, +} +#[derive(Clone, Copy)] +enum SequencePress { + Occurrence { + tool: scenario::Tool, + slot: scenario::Slot, + lifeline: Option, + }, + Message { + source: Id, + slot: scenario::Slot, + start: Point, + first: bool, + moved: bool, + }, + Reorder { + occurrence: Id, + parent: Id, + start: Point, + moved: bool, + }, +} +impl SequenceInput { + fn cancel(&mut self) { + self.source = None; + self.press = None; + } +} +impl Scene { + fn sequence(p: &Project, owner: Id, selected: Id) -> Self { + let mut scene = Self { + scale: 1., + camera_pan: iced::Vector::ZERO, + boxes: vec![], + lines: vec![], + size: Size::new(700., 200.), + selected, + interaction: None, + pending_placement: None, + lanes: BTreeMap::new(), + slots: vec![], + rows: BTreeMap::new(), + open_calls: vec![], + sequence_bounds: BTreeMap::new(), + lifecycle: scenario_lifecycle::render::Rendering::default(), + }; + let mut lanes = p + .children(owner) + .filter(|e| e.kind() == Kind::Lifeline) + .map(|e| { + ( + e.id, + e.name.clone(), + match e.shared() { + Some(SharedBody::Lifeline { + represented: Some(id), + }) => p.named(*id).to_owned(), + _ => "Unassigned participant".into(), + }, + ) + }) + .collect::>(); + let scenario = p.elements[&owner].kind() == Kind::Scenario; + if !scenario { + fn endpoints(p: &Project, steps: &[Id], ids: &mut Vec) { + for id in steps { + if let Some(SharedBody::Occurrence { body, .. }) = + p.elements.get(id).and_then(|e| e.shared()) + { + match body { + OccurrenceBody::Exchange { + exchange: Some(id), .. + } => { + if let Some(r) = p.relations.get(id) { + for endpoint in [r.source(), r.target()] { + if !ids.contains(&endpoint) { + ids.push(endpoint); + } + } + } + } + OccurrenceBody::Fragment { branches, .. } => { + endpoints(p, branches, ids) + } + OccurrenceBody::Branch { occurrences, .. } => { + endpoints(p, occurrences, ids) + } + _ => {} + } + } + } + } + let mut ids = vec![]; + endpoints(p, p.elements[&owner].steps().unwrap_or(&vec![]), &mut ids); + lanes = ids + .into_iter() + .map(|id| { + ( + id, + p.named(id).to_owned(), + p.elements + .get(&id) + .map(|e| e.kind().label()) + .unwrap_or("Endpoint") + .into(), + ) + }) + .collect(); + } + let lane_x = lanes + .iter() + .enumerate() + .map(|(i, (id, ..))| (*id, 160. + i as f32 * 240.)) + .collect::>(); + scene.size.width = (lanes.len() as f32 * 240. + 80.).max(680.); + if scenario { + scene.lanes = lane_x + .iter() + .filter(|(id, _)| { + matches!( + p.elements.get(id).and_then(|e| e.shared()), + Some(SharedBody::Lifeline { + represented: Some(_) + }) + ) + }) + .map(|(id, x)| (*id, *x)) + .collect(); + } + let mut y = if scenario { 148. } else { 120. }; + scene.occurrences(p, owner, scenario, &lane_x, &mut y, 0); + scene.size.height = (y + 45.).max(210.); + #[cfg(test)] + behavior_anchor_plan_experiment::scene_extent(&mut scene); + if scenario { + scene.lifecycle = scenario_lifecycle::render::Rendering::new( + p, + owner, + &lane_x, + &scene.rows, + &scene.sequence_bounds, + scene.size.height, + ); + scene.lifecycle.align_messages(&mut scene.lines); + let calls: Vec<_> = scene + .rows + .keys() + .filter_map(|&id| { + if let Some(SharedBody::Occurrence { + body: + OccurrenceBody::Exchange { + message_kind: MessageKind::Call, + receiver: Some(receiver), + .. + }, + .. + }) = p.elements[&id].shared() + { + Some((id, *lane_x.get(receiver)?)) + } else { + None + } + }) + .collect(); + for (call, x) in calls { + scene.activation_sequence(p, p.elements[&call].parent.unwrap(), call, x, None); + } + } + for (id, name, detail) in lanes { + let x = lane_x[&id]; + if !scenario { + scene.lines.insert( + 0, + Line { + id, + points: vec![Point::new(x, 85.), Point::new(x, scene.size.height - 20.)], + label: String::new(), + label_at: Point::ORIGIN, + dashed: false, + }, + ); + } + scene.boxes.push(BoxItem { + id, + rect: Rectangle::new(Point::new(x - 102., 15.), Size::new(204., 70.)), + title: if scenario { + format!("Lane · {name}") + } else { + name + }, + detail, + marker: None, + }); + } + scene + } + fn occurrences( + &mut self, + p: &Project, + owner: Id, + scenario: bool, + lanes: &BTreeMap, + y: &mut f32, + depth: usize, + ) { + let steps = p + .elements + .get(&owner) + .and_then(|e| { + e.steps().map(Vec::as_slice).or_else(|| { + if let Some(SharedBody::Occurrence { + body: OccurrenceBody::Fragment { branches, .. }, + .. + }) = e.shared() + { + Some(branches.as_slice()) + } else { + None + } + }) + }) + .unwrap_or(&[]); + let ordered = p.elements.get(&owner).and_then(|e| e.steps()).is_some(); + let sequence_top = *y - 42.; + for (index, id) in steps.iter().enumerate() { + let Some(e) = p.elements.get(id) else { + continue; + }; + let Some(SharedBody::Occurrence { body, .. }) = e.shared() else { + continue; + }; + if scenario + && matches!(body, OccurrenceBody::Exchange { + sender: Some(sender), receiver: Some(receiver), + message_kind: MessageKind::Create | MessageKind::Delete, .. + } if sender == receiver) + { + // Self lifecycle elbows and their caption need clearance from + // the preceding action/state/reference box, before recording rows. + *y += 16.; + } + #[cfg(test)] + behavior_layout_scene_experiment::resolve_row_anchor(*id, y); + #[cfg(test)] + behavior_anchor_plan_experiment::row_anchor(*id, y); + if ordered { + let slot = scenario::Slot { + parent: owner, + before: Some(*id), + }; + self.slots.push(SequenceSlot { + slot, + y: *y - 42., + label: scenario::slot_label(p, slot), + }); + self.rows.insert(*id, (owner, *y)); + } + match body { + OccurrenceBody::Fragment { operator, .. } => { + let top = *y; + *y += 55.; + self.occurrences(p, *id, scenario, lanes, y, depth + 1); + let rect = Rectangle::new( + Point::new(18. + depth as f32 * 10., top), + Size::new( + self.size.width - 36. - depth as f32 * 20., + (*y - top).max(95.), + ), + ); + #[cfg(test)] + let rect = behavior_anchor_plan_experiment::frame_bounds(*id, rect); + self.boxes.insert( + 0, + BoxItem { + id: *id, + rect, + title: format!("{operator:?} · {}", e.name), + detail: String::new(), + marker: Some(Kind::Occurrence), + }, + ); + *y += if scenario { 64. } else { 20. }; + } + OccurrenceBody::Branch { guard, .. } => { + self.boxes.push(BoxItem { + id: *id, + rect: Rectangle::new( + Point::new(36. + depth as f32 * 10., *y), + Size::new(self.size.width - 85. - depth as f32 * 20., 43.), + ), + title: format!( + "[{}]", + if guard.is_empty() { + "guard not specified" + } else { + guard + } + ), + detail: String::new(), + marker: Some(Kind::Occurrence), + }); + *y += if scenario { 90. } else { 62. }; + self.occurrences(p, *id, scenario, lanes, y, depth + 1); + } + OccurrenceBody::Exchange { + exchange, + sender, + receiver, + message_kind, + .. + } => { + let r = exchange.and_then(|id| p.relations.get(&id)); + let source = if scenario { + *sender + } else { + r.map(|r| r.source()) + }; + let target = if scenario { + if matches!(message_kind, MessageKind::Timer { .. }) { + *sender + } else { + *receiver + } + } else { + r.map(|r| r.target()) + }; + if let (Some(a), Some(b)) = ( + source.and_then(|id| lanes.get(&id)), + target.and_then(|id| lanes.get(&id)), + ) { + let points = if a == b { + vec![ + Point::new(*a, *y), + Point::new(a + 75., *y), + Point::new(a + 75., *y + 24.), + Point::new(*a, *y + 24.), + ] + } else { + vec![Point::new(*a, *y), Point::new(*b, *y)] + }; + let kind = if !scenario { + "" + } else { + match message_kind { + MessageKind::Asynchronous => "signal · ", + MessageKind::Call => "call · ", + MessageKind::Reply => "reply · ", + MessageKind::Create => "create · ", + MessageKind::Delete => "delete · ", + MessageKind::Timer { .. } => "timer · ", + } + }; + let mut message_label = if !scenario { + r.map(|r| r.name.clone()).unwrap_or_else(|| e.name.clone()) + } else if let MessageKind::Timer { expression } = message_kind { + format!("{} · {expression}", e.name) + } else if let Some(exchange) = r.filter(|r| r.name != e.name) { + format!("{} · {}", e.name, exchange.name) + } else { + e.name.clone() + }; + if scenario + && let Some(operation) = crate::data_values::operation_label(p, e) + { + message_label.push_str(" · "); + message_label.push_str(&operation); + } + self.lines.push(Line { + id: *id, + points, + label: format!("{}. {kind}{}", index + 1, message_label), + label_at: Point::new(a.min(*b) + 12., *y - 26.), + dashed: matches!(message_kind, MessageKind::Reply), + }); + } else { + self.boxes.push(BoxItem { + id: *id, + rect: Rectangle::new( + Point::new(65., *y - 20.), + Size::new(self.size.width - 130., 65.), + ), + title: e.name.clone(), + detail: "Choose an exchange and its lifelines in Properties".into(), + marker: None, + }); + } + *y += 92.; + } + OccurrenceBody::Action { action, lifeline } + | OccurrenceBody::State { + state: action, + lifeline, + } => { + let x = lifeline + .and_then(|id| lanes.get(&id)) + .copied() + .unwrap_or(180.); + self.boxes.push(BoxItem { + id: *id, + rect: Rectangle::new(Point::new(x - 110., *y - 20.), Size::new(220., 70.)), + title: e.name.clone(), + detail: action + .map(|id| p.named(id).to_owned()) + .unwrap_or("Choose observed behavior".into()), + marker: None, + }); + *y += 95.; + } + OccurrenceBody::Control { kind } => { + self.boxes.push(BoxItem { + id: *id, + rect: Rectangle::new(Point::new(65., *y - 20.), Size::new(240., 70.)), + title: format!("{kind:?} · {}", e.name), + detail: "Contextual process control".into(), + marker: None, + }); + *y += 95.; + } + OccurrenceBody::SequenceReference { sequence } => { + self.boxes.push(BoxItem { + id: *id, + rect: Rectangle::new(Point::new(65., *y - 20.), Size::new(280., 70.)), + title: e.name.clone(), + detail: sequence + .map(|id| format!("Use {}", p.named(id))) + .unwrap_or("Choose a reusable process or chain".into()), + marker: None, + }); + *y += 95.; + } + OccurrenceBody::ScenarioUse { scenario, bindings } => { + self.boxes.push(BoxItem { + id: *id, + rect: Rectangle::new( + Point::new(65., *y - 20.), + Size::new(self.size.width - 130., 70.), + ), + title: format!("ref · {}", e.name), + detail: scenario + .map(|id| { + format!( + "{} · {} explicit lifeline bindings", + p.named(id), + bindings.len() + ) + }) + .unwrap_or("Choose a scenario".into()), + marker: Some(Kind::Occurrence), + }); + *y += 95.; + } + } + } + if ordered { + self.sequence_bounds.insert(owner, (sequence_top, *y - 42.)); + #[cfg(test)] + behavior_anchor_plan_experiment::scope_end(owner, y, &mut self.sequence_bounds); + let slot = scenario::Slot { + parent: owner, + before: None, + }; + self.slots.push(SequenceSlot { + slot, + y: *y - 42., + label: scenario::slot_label(p, slot), + }); + } + } + fn activation_segment( + &mut self, + call: Id, + scope: Id, + receiver: Id, + x: f32, + top: f32, + bottom: f32, + ) { + for span in self.lifecycle.alive_spans(receiver, scope, top, bottom) { + self.boxes.push(BoxItem { + id: call, + rect: Rectangle::new( + Point::new(x - 4., span.start), + Size::new(8., span.end - span.start), + ), + title: String::new(), + detail: String::new(), + marker: None, + }); + } + } + /// Follow a single correlation through the ordered tree. Branches are drawn + /// as independent segments, never as one chronological stretch through ALT + /// operands. The result says whether a path can leave this sequence open. + fn activation_sequence( + &mut self, + p: &Project, + owner: Id, + call: Id, + x: f32, + mut start: Option, + ) -> bool { + let Some(SharedBody::Occurrence { + body: + OccurrenceBody::Exchange { + receiver: Some(receiver), + .. + }, + .. + }) = p.elements[&call].shared() + else { + return false; + }; + let receiver = *receiver; + let entered_open = start.is_some(); + let mut split = false; + for id in p.elements[&owner].steps().into_iter().flatten() { + let Some(&(_, y)) = self.rows.get(id) else { + continue; + }; + if *id == call { + start = Some(y); + } + let Some(top) = start else { + continue; + }; + match p.elements[id].shared() { + Some(SharedBody::Occurrence { + body: + OccurrenceBody::Exchange { + message_kind: MessageKind::Delete, + receiver: Some(deleted), + .. + }, + .. + }) if *deleted == receiver => { + // This path cannot show activation beyond its local death. + // Keep the unresolved-call cue; a Delete is not a Reply. + self.activation_segment(call, owner, receiver, x, top, y); + self.open_calls.push(Point::new(x + 12., y - 16.)); + start = None; + } + Some(SharedBody::Occurrence { + body: + OccurrenceBody::Exchange { + message_kind: MessageKind::Reply, + reply_to: Some(correlation), + .. + }, + .. + }) if *correlation == call => { + self.activation_segment(call, owner, receiver, x, top, y); + for span in self.lifecycle.alive_spans(receiver, owner, y - 1., y + 1.) { + self.boxes.push(BoxItem { + id: call, + rect: Rectangle::new( + Point::new(x - 7., span.start), + Size::new(14., span.end - span.start), + ), + title: String::new(), + detail: String::new(), + marker: None, + }); + } + start = None; + } + Some(SharedBody::Occurrence { + body: OccurrenceBody::Fragment { operator, branches }, + .. + }) => { + split = true; + self.activation_segment(call, owner, receiver, x, top, y); + let bottom = self + .boxes + .iter() + .find(|b| b.id == *id && !b.title.is_empty()) + .map(|b| b.rect.y + b.rect.height) + .unwrap_or(y); + let outcomes: Vec<_> = branches + .iter() + .filter_map(|branch| { + let &(top, _) = self.sequence_bounds.get(branch)?; + Some(self.activation_sequence(p, *branch, call, x, Some(top))) + }) + .collect(); + let open = match operator { + FragmentOperator::Alternative => { + outcomes.is_empty() || outcomes.iter().any(|open| *open) + } + FragmentOperator::Parallel => outcomes.iter().all(|open| *open), + // Optional/repeated bodies do not guarantee execution. + FragmentOperator::Optional | FragmentOperator::Loop => true, + }; + start = open.then_some(bottom); + } + _ => {} + } + } + if let Some(top) = start { + let bottom = if entered_open || split { + self.sequence_bounds + .get(&owner) + .map_or(top + 26., |(_, bottom)| (*bottom).max(top + 26.)) + } else { + top + 26. + }; + self.activation_segment(call, owner, receiver, x, top, bottom); + self.open_calls.push(Point::new(x + 8., bottom - 7.)); + } + start.is_some() + } + fn slot_at(&self, point: Point, parent: Option) -> Option<&SequenceSlot> { + self.slots + .iter() + .min_by(|a, b| (a.y - point.y).abs().total_cmp(&(b.y - point.y).abs())) + .filter(|slot| parent.is_none_or(|id| id == slot.slot.parent)) + } + fn lane_at(&self, point: Point) -> Option { + if let Some(header) = self + .boxes + .iter() + .find(|b| self.lanes.contains_key(&b.id) && b.rect.contains(point)) + { + return Some(header.id); + } + self.lanes + .iter() + .filter(|(_, x)| (**x - point.x).abs() < 12. / self.scale) + .min_by(|a, b| (a.1 - point.x).abs().total_cmp(&(b.1 - point.x).abs())) + .map(|(id, _)| *id) + } + fn reorder_handle(&self) -> Option { + let (_, y) = self.rows.get(&self.selected)?; + Some(Rectangle::new( + Point::new(6., *y - 12.), + Size::new(22., 24.), + )) + } + fn hit(&self, point: Point) -> Option { + if !point.x.is_finite() || !point.y.is_finite() { + return None; + } + if let Some(id) = self.lifecycle.marker_at(point) { + return Some(id); + } + self.boxes + .iter() + .rev() + .find(|b| { + !b.title.is_empty() + && b.rect.contains(point) + && (b.marker != Some(Kind::Occurrence) || point.y < b.rect.y + 38.) + }) + .map(|b| b.id) + .or_else(|| { + self.lines + .iter() + .find(|line| !line.label.is_empty() && line.label_bounds().contains(point)) + .map(|line| line.id) + }) + .or_else(|| self.near_sequence_item(point)) + .or_else(|| { + self.boxes + .iter() + .rev() + .find(|b| b.rect.contains(point) && b.title.is_empty()) + .map(|b| b.id) + }) + .or_else(|| self.lifecycle.lane_at(point, self.scale)) + .or_else(|| self.lane_at(point)) + .or_else(|| { + self.lines + .iter() + .find(|line| { + line.label.is_empty() + && line.points.windows(2).any(|p| { + crate::diagram::distance_to_segment(point, p[0], p[1]) + < 8. / self.scale + }) + }) + .map(|line| line.id) + }) + } + + fn near_sequence_item(&self, point: Point) -> Option { + if !self.scale.is_finite() || self.scale <= 0. { + return None; + } + let messages = self + .lines + .iter() + .filter(|line| !line.label.is_empty()) + .map(|line| { + let distance = line + .points + .windows(2) + .map(|p| crate::diagram::distance_to_segment(point, p[0], p[1])) + .fold(f32::INFINITY, f32::min); + (line.id, distance) + }); + // One distance comparison for both kinds of screen-sized hit tolerance. + // Dense minimum-Fit rows must not give the first message or last marker + // a larger hit priority than the visibly nearer item. UUID breaks ties. + self.lifecycle + .nearest_marker(point) + .into_iter() + .chain(messages) + .filter(|(_, distance)| *distance <= 8. / self.scale) + .min_by(|a, b| a.1.total_cmp(&b.1).then_with(|| a.0.cmp(&b.0))) + .map(|(id, _)| id) + } +} +impl canvas::Program for Scene { + type State = SequenceInput; + fn update( + &self, + state: &mut SequenceInput, + event: &canvas::Event, + bounds: Rectangle, + cursor: mouse::Cursor, + ) -> Option> { + use scenario::{CanvasEvent, CanvasTool}; + let (context, tool, editable) = self + .interaction + .map_or((None, CanvasTool::Select, false), |(c, t, e)| { + (Some(c), t, e) + }); + if state.context != context || state.tool != tool { + state.cancel(); + state.pointer = None; + state.context = context; + state.tool = tool; + } + // Scrolling either enclosing Scrollable changes the canvas origin. A + // 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) { + state.cancel(); + state.pointer = None; + } + state.bounds = Some(bounds); + if !editable { + state.cancel(); + } + // The viewport masks input outside its visible canvas bounds. + let visible = cursor.position().is_some(); + if !visible + && matches!( + event, + canvas::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) + ) + { + state.cancel(); + return Some(Action::request_redraw()); + } + let cursor = match event { + canvas::Event::Mouse(mouse::Event::CursorMoved { position }) => { + state.pointer = Some(*position); + if let Some(press) = &mut state.press { + match press { + SequencePress::Message { start, moved, .. } + | SequencePress::Reorder { start, moved, .. } => { + *moved |= position.distance(*start) >= 3. + } + SequencePress::Occurrence { .. } => {} + } + } + mouse::Cursor::Available(*position) + } + canvas::Event::Mouse(mouse::Event::WheelScrolled { .. }) + | canvas::Event::Window(iced::window::Event::Resized(_)) + | canvas::Event::Mouse(mouse::Event::CursorLeft) + | canvas::Event::Window(iced::window::Event::Unfocused) => { + state.cancel(); + state.pointer = None; + return Some(Action::request_redraw()); + } + canvas::Event::Mouse( + mouse::Event::ButtonPressed(_) | mouse::Event::ButtonReleased(_), + ) => state.pointer.map_or(cursor, mouse::Cursor::Available), + canvas::Event::Window(iced::window::Event::RedrawRequested(_)) => { + state.pointer = cursor.position(); + cursor + } + canvas::Event::Keyboard(iced::keyboard::Event::KeyPressed { + key: iced::keyboard::Key::Named(iced::keyboard::key::Named::Escape), + .. + }) if state.source.is_some() || state.press.is_some() => { + state.cancel(); + return Some(Action::request_redraw().and_capture()); + } + _ => cursor, + }; + let world = cursor.position_in(bounds).map(|p| { + Point::new( + (p.x - self.camera_pan.x) / self.scale, + (p.y - self.camera_pan.y) / self.scale, + ) + }); + match event { + canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => { + if !visible { + return None; + } + let point = world?; + if let Some(context) = context { + if !editable { + return None; + } + if let CanvasTool::Message(_) = tool { + let first = state.source.is_none(); + let (source, slot) = state.source.or_else(|| { + Some((self.lane_at(point)?, self.slot_at(point, None)?.slot)) + })?; + state.source = Some((source, slot)); + state.press = Some(SequencePress::Message { + source, + slot, + start: cursor.position()?, + first, + moved: false, + }); + return Some(Action::request_redraw().and_capture()); + } + if let CanvasTool::Add(tool) = tool { + if !tool.canvas_occurrence() && tool != scenario::Tool::Lifeline { + return None; + } + state.press = Some(SequencePress::Occurrence { + tool, + slot: self.slot_at(point, None)?.slot, + lifeline: tool.uses_lifeline().then(|| self.lane_at(point)).flatten(), + }); + return Some(Action::request_redraw().and_capture()); + } + if let Some(occurrence) = self + .hit(point) + .filter(|id| self.rows.contains_key(id)) + .or_else(|| { + self.reorder_handle() + .filter(|r| r.contains(point)) + .map(|_| self.selected) + }) + { + state.press = Some(SequencePress::Reorder { + occurrence, + parent: self.rows[&occurrence].0, + start: cursor.position()?, + moved: false, + }); + return Some(Action::request_redraw().and_capture()); + } + return self.hit(point).map(|id| { + Action::publish(Message::ScenarioCanvas(CanvasEvent::Select(context, id))) + .and_capture() + }); + } + self.hit(point) + .map(|id| Action::publish(Message::Select(id)).and_capture()) + } + canvas::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => { + let press = state.press.take()?; + let context = context?; + match press { + SequencePress::Occurrence { + tool: captured, + slot, + lifeline, + } if tool == CanvasTool::Add(captured) && world.is_some() => { + return Some( + Action::publish(Message::ScenarioCanvas( + CanvasEvent::BeginOccurrence { + context, + tool: captured, + slot, + lifeline, + }, + )) + .and_capture(), + ); + } + SequencePress::Message { source, slot, .. } + if tool == CanvasTool::Message(scenario::MessageStyle::Timer) + && world.is_some() => + { + state.cancel(); + return Some( + Action::publish(Message::ScenarioCanvas(CanvasEvent::Begin { + context, + slot, + sender: source, + receiver: source, + style: scenario::MessageStyle::Timer, + })) + .and_capture(), + ); + } + SequencePress::Message { + source, + slot, + first, + moved, + .. + } if moved || !first => { + if let Some(receiver) = world.and_then(|p| self.lane_at(p)) { + let CanvasTool::Message(style) = tool else { + return None; + }; + state.cancel(); + return Some( + Action::publish(Message::ScenarioCanvas(CanvasEvent::Begin { + context, + slot, + sender: source, + receiver, + style, + })) + .and_capture(), + ); + } + } + SequencePress::Reorder { + occurrence, + moved: false, + .. + } => { + return Some( + Action::publish(Message::ScenarioCanvas(CanvasEvent::Select( + context, occurrence, + ))) + .and_capture(), + ); + } + SequencePress::Reorder { + occurrence, + parent, + moved: true, + .. + } => { + if let Some(slot) = world.and_then(|p| self.slot_at(p, Some(parent))) { + return Some( + Action::publish(Message::ScenarioCanvas(CanvasEvent::Reorder { + context, + occurrence, + slot: slot.slot, + })) + .and_capture(), + ); + } + } + _ => {} + } + Some(Action::request_redraw().and_capture()) + } + canvas::Event::Mouse(mouse::Event::CursorMoved { .. }) if context.is_some() => { + Some(Action::request_redraw()) + } + _ => None, + } + } + fn draw( + &self, + state: &SequenceInput, + renderer: &Renderer, + _: &Theme, + bounds: Rectangle, + cursor: mouse::Cursor, + ) -> Vec { + let mut f = Frame::new(renderer, bounds.size()); + f.fill_rectangle(Point::ORIGIN, bounds.size(), Color::WHITE); + f.translate(self.camera_pan); + f.scale(self.scale); + self.lifecycle.draw_lanes(&mut f, self.selected); + for b in &self.boxes { + let outline = matches!( + b.marker, + Some(Kind::Region | Kind::StateMachine | Kind::Occurrence) + ); + let color = if b.id == self.selected { + style::TEAL + } else { + style::LINE + }; + if outline { + f.stroke( + &Path::rectangle(b.rect.position(), b.rect.size()), + Stroke::default().with_color(color), + ); + } else { + let path = Path::rounded_rectangle(b.rect.position(), b.rect.size(), 8.into()); + f.fill(&path, Color::from_rgb8(243, 248, 248)); + f.stroke( + &path, + Stroke::default() + .with_color(color) + .with_width(if b.id == self.selected { 2.5 } else { 1. }), + ); + } + } + for l in &self.lines { + let color = if l.id == self.selected { + style::TEAL + } else { + style::MUTED + }; + f.stroke( + &Path::new(|p| { + p.move_to(l.points[0]); + for point in &l.points[1..] { + p.line_to(*point); + } + }), + Stroke { + line_dash: canvas::LineDash { + segments: if l.dashed { &[6., 4.] } else { &[] }, + offset: 0, + }, + ..Stroke::default() + .with_color(color) + .with_width(if l.id == self.selected { 2.5 } else { 1.2 }) + }, + ); + if !l.label.is_empty() { + let end = l.points[l.points.len() - 1]; + let prev = l.points[l.points.len() - 2]; + let delta = end - prev; + let len = (delta.x * delta.x + delta.y * delta.y).sqrt().max(1.); + let d = delta / len; + f.stroke( + &Path::new(|p| { + p.move_to(end - d * 9. + iced::Vector::new(-d.y, d.x) * 4.); + p.line_to(end); + p.line_to(end - d * 9. - iced::Vector::new(-d.y, d.x) * 4.); + }), + Stroke::default().with_color(color), + ); + f.fill_rectangle( + l.label_at, + Size::new((l.label.chars().count() as f32 * 6.8 + 8.).min(225.), 22.), + Color::WHITE, + ); + f.fill_text(Text { + content: short(&l.label, 32), + position: l.label_at, + color, + size: 12.into(), + ..Default::default() + }); + } + } + for b in &self.boxes { + if let Some(clip) = box_text_clip(self.camera_pan, b.rect, self.scale, f.size()) { + // Clipped child frames start with an identity transform. Match + // the card's pan and scale, as for lifecycle Create labels. + f.with_clip(clip, |label| { + label.translate(self.camera_pan); + label.scale(self.scale); + label.fill_text(Text { + content: short(&b.title, ((b.rect.width - 25.) / 7.) as usize), + position: Point::new(b.rect.x + 12., b.rect.y + 12.), + size: 13.into(), + color: if b.id == self.selected { + style::TEAL + } else { + style::INK + }, + ..Default::default() + }); + if !b.detail.is_empty() { + label.fill_text(Text { + content: short(&b.detail, ((b.rect.width - 25.) / 6.) as usize), + position: Point::new(b.rect.x + 12., b.rect.y + 39.), + size: 11.into(), + color: style::MUTED, + ..Default::default() + }); + } + }); + } + } + for point in &self.open_calls { + f.fill_text(Text { + content: "…".into(), + position: *point, + size: 11.into(), + color: style::MUTED, + ..Default::default() + }); + } + self.lifecycle + .draw_markers(&mut f, self.selected, self.scale, self.camera_pan); + if let Some((context, tool, editable)) = self.interaction { + 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, + (p.y - self.camera_pan.y) / self.scale, + ) + }); + let source = active.then_some(state.source).flatten(); + let reorder = active.then_some(state.press).flatten().and_then(|press| { + if let SequencePress::Reorder { parent, .. } = press { + Some(parent) + } else { + None + } + }); + if editable + && tool == scenario::CanvasTool::Select + && let Some(handle) = self.reorder_handle() + { + f.fill_rectangle(handle.position(), handle.size(), style::TEAL); + f.fill_text(Text { + content: "↕".into(), + position: handle.position() + iced::Vector::new(5., 2.), + size: 16.into(), + color: Color::WHITE, + ..Default::default() + }); + } + let placement = self.pending_placement.or_else(|| { + active.then_some(state.press).flatten().and_then(|press| { + if let SequencePress::Occurrence { slot, lifeline, .. } = press { + Some((slot, lifeline)) + } else { + None + } + }) + }); + let slot = if let Some((slot, _)) = placement { + self.slots.iter().find(|s| s.slot == slot) + } else if let Some((_, slot)) = source { + self.slots.iter().find(|s| s.slot == slot) + } else if editable + && (matches!( + tool, + scenario::CanvasTool::Message(_) | scenario::CanvasTool::Add(_) + ) || reorder.is_some()) + { + point.and_then(|p| self.slot_at(p, reorder)) + } else { + None + }; + if reorder.is_some() + && slot.is_none() + && let Some(point) = point + { + f.fill_text(Text { + content: "Other operand: move stays in the current sequence".into(), + position: Point::new(point.x + 8., point.y + 12.), + size: (11. / self.scale).into(), + color: Color::from_rgb(0.75, 0.2, 0.15), + ..Default::default() + }); + } + if let Some(slot) = slot { + f.stroke( + &Path::line( + Point::new(32., slot.y), + Point::new(self.size.width - 24., slot.y), + ), + Stroke::default().with_color(style::TEAL).with_width(1.5), + ); + f.fill_rectangle( + Point::new(34., slot.y - 20.), + Size::new((self.size.width - 70.).min(650.), 19.), + Color::WHITE, + ); + f.fill_text(Text { + content: short( + &format!( + "{}: {}", + if self.pending_placement.is_some() { + "Pending" + } else { + "Insert" + }, + slot.label + ), + 88, + ), + position: Point::new(38., slot.y - 19.), + size: 11.into(), + color: style::TEAL, + ..Default::default() + }); + if let Some(x) = placement + .and_then(|(_, lifeline)| lifeline) + .and_then(|id| self.lanes.get(&id)) + { + f.fill(&Path::circle(Point::new(*x, slot.y), 5.), style::TEAL); + } + if let Some((source, _)) = source + && let Some(x) = self.lanes.get(&source) + && let Some(point) = point + { + let to = self + .lane_at(point) + .and_then(|id| self.lanes.get(&id)) + .copied() + .unwrap_or(point.x); + f.stroke( + &Path::line(Point::new(*x, slot.y), Point::new(to, slot.y)), + Stroke::default().with_color(style::TEAL).with_width(3.), + ); + f.fill(&Path::circle(Point::new(to, slot.y), 5.), style::TEAL); + } + } + } + vec![f.into_geometry()] + } + fn mouse_interaction( + &self, + _: &SequenceInput, + bounds: Rectangle, + cursor: mouse::Cursor, + ) -> mouse::Interaction { + if cursor.is_over(bounds) { + mouse::Interaction::Pointer + } else { + mouse::Interaction::default() + } + } +} + +#[cfg(test)] +mod direct_canvas_tests { + use super::*; + use scenario::{CanvasEvent, CanvasTool, MessageStyle}; + fn scene() -> (scenario::direct_tests::Fixture, Scene) { + let f = scenario::direct_tests::fixture(); + let mut scene = Scene::sequence(&f.project, f.owner, f.call); + scene.interaction = Some(( + scenario::direct_tests::context(f.owner), + CanvasTool::Message(MessageStyle::CallReply), + true, + )); + (f, scene) + } + fn send( + scene: &Scene, + state: &mut SequenceInput, + event: mouse::Event, + final_cursor: Point, + bounds: Rectangle, + ) -> Option { + canvas::Program::update( + scene, + state, + &canvas::Event::Mouse(event), + bounds, + mouse::Cursor::Available(final_cursor), + ) + .and_then(|action| action.into_inner().0) + .and_then(|message| { + if let Message::ScenarioCanvas(event) = message { + Some(event) + } else { + None + } + }) + } + #[test] + fn reused_exchange_messages_display_their_distinct_occurrence_names() { + let mut f = scenario::direct_tests::fixture(); + f.project.elements.get_mut(&f.call).unwrap().name = "Route request".into(); + f.project.elements.get_mut(&f.reply).unwrap().name = "Route response".into(); + let scene = Scene::sequence(&f.project, f.owner, f.call); + let call = scene.lines.iter().find(|line| line.id == f.call).unwrap(); + let reply = scene.lines.iter().find(|line| line.id == f.reply).unwrap(); + assert!(call.label.starts_with("1. call · Route request")); + assert!(reply.label.starts_with("2. reply · Route response")); + assert!(call.label.ends_with(&f.project.relations[&f.exchange].name)); + assert!( + reply + .label + .ends_with(&f.project.relations[&f.exchange].name) + ); + } + #[test] + fn direct_drag_and_click_click_use_ordered_sources_and_publish_once() { + for drag in [false, true] { + let (f, scene) = scene(); + let mut state = SequenceInput::default(); + let bounds = Rectangle::new(Point::new(30., 50.), scene.size); + let a = Point::new( + bounds.x + scene.lanes[&f.sender], + bounds.y + scene.slots[0].y, + ); + let b = Point::new(bounds.x + scene.lanes[&f.receiver], a.y); + send( + &scene, + &mut state, + mouse::Event::CursorMoved { position: a }, + b, + bounds, + ); + assert!( + send( + &scene, + &mut state, + mouse::Event::ButtonPressed(mouse::Button::Left), + b, + bounds + ) + .is_none() + ); + if !drag { + assert!( + send( + &scene, + &mut state, + mouse::Event::ButtonReleased(mouse::Button::Left), + b, + bounds + ) + .is_none() + ); + assert_eq!(state.source.unwrap().0, f.sender); + } + send( + &scene, + &mut state, + mouse::Event::CursorMoved { position: b }, + b, + bounds, + ); + if !drag { + send( + &scene, + &mut state, + mouse::Event::ButtonPressed(mouse::Button::Left), + b, + bounds, + ); + } + let result = send( + &scene, + &mut state, + mouse::Event::ButtonReleased(mouse::Button::Left), + b, + bounds, + ); + assert!( + matches!(result, Some(CanvasEvent::Begin { sender, receiver, slot, .. }) if sender == f.sender && receiver == f.receiver && slot.before == Some(f.call)) + ); + assert!( + send( + &scene, + &mut state, + mouse::Event::ButtonReleased(mouse::Button::Left), + b, + bounds + ) + .is_none() + ); + assert!(state.source.is_none() && state.press.is_none()); + } + } + #[test] + fn direct_panned_canvas_maps_ordered_pointer_events_to_the_visible_slot() { + let (f, mut scene) = scene(); + scene.camera_pan = iced::Vector::new(-35., -140.); + let bounds = Rectangle::new(Point::new(20., 230.), scene.size); + let slot = scene.slots.last().unwrap().slot; + let y = scene.slots.last().unwrap().y; + let a = Point::new(bounds.x + scene.lanes[&f.sender], bounds.y + y) + scene.camera_pan; + let b = Point::new(bounds.x + scene.lanes[&f.receiver], bounds.y + y) + scene.camera_pan; + assert!(bounds.contains(a) && bounds.contains(b)); + let mut state = SequenceInput::default(); + // Batched pointer events use their own positions with the final cursor. + for event in [ + mouse::Event::CursorMoved { position: a }, + mouse::Event::ButtonPressed(mouse::Button::Left), + mouse::Event::CursorMoved { position: b }, + ] { + send(&scene, &mut state, event, b, bounds); + } + assert!( + matches!(send(&scene, &mut state, mouse::Event::ButtonReleased(mouse::Button::Left), b, bounds), Some(CanvasEvent::Begin { sender, receiver, slot: actual, .. }) if sender == f.sender && receiver == f.receiver && actual == slot) + ); + } + #[test] + fn direct_scroll_resize_and_bounds_changes_cancel_captured_slots() { + for change in 0..5 { + let (f, scene) = scene(); + let mut state = SequenceInput::default(); + let bounds = Rectangle::new(Point::new(20., 70.), scene.size); + let a = Point::new( + bounds.x + scene.lanes[&f.sender], + bounds.y + scene.slots[0].y, + ); + send( + &scene, + &mut state, + mouse::Event::CursorMoved { position: a }, + a, + bounds, + ); + send( + &scene, + &mut state, + mouse::Event::ButtonPressed(mouse::Button::Left), + a, + bounds, + ); + send( + &scene, + &mut state, + mouse::Event::ButtonReleased(mouse::Button::Left), + a, + bounds, + ); + assert!(state.source.is_some()); + let changed = match change { + 0 => Rectangle::new( + bounds.position() - iced::Vector::new(15., 32.), + bounds.size(), + ), + 1 => Rectangle::new(bounds.position(), bounds.size() + Size::new(20., 10.)), + _ => bounds, + }; + let event = match change { + 2 => canvas::Event::Mouse(mouse::Event::WheelScrolled { + delta: mouse::ScrollDelta::Lines { x: 0., y: -1. }, + }), + 3 => canvas::Event::Window(iced::window::Event::Resized(Size::new(1100., 700.))), + _ => canvas::Event::Window(iced::window::Event::RedrawRequested( + std::time::Instant::now(), + )), + }; + let cursor = if change == 4 { + mouse::Cursor::Unavailable + } else { + mouse::Cursor::Available(a) + }; + let event = if change == 4 { + canvas::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) + } else { + event + }; + canvas::Program::update(&scene, &mut state, &event, changed, cursor); + assert!( + state.source.is_none() && state.press.is_none(), + "change {change} must cancel the old slot" + ); + let b = Point::new( + changed.x + scene.lanes[&f.receiver], + changed.y + scene.slots.last().unwrap().y, + ); + send( + &scene, + &mut state, + mouse::Event::CursorMoved { position: b }, + b, + changed, + ); + send( + &scene, + &mut state, + mouse::Event::ButtonPressed(mouse::Button::Left), + b, + changed, + ); + assert!( + send( + &scene, + &mut state, + mouse::Event::ButtonReleased(mouse::Button::Left), + b, + changed + ) + .is_none(), + "a click after change {change} must start a fresh gesture" + ); + assert_eq!(state.source.unwrap().0, f.receiver); + assert_ne!(state.source.unwrap().1.before, Some(f.call)); + } + } + #[test] + fn direct_bounds_change_during_held_drag_never_commits_old_slot() { + let (f, scene) = scene(); + let mut state = SequenceInput::default(); + let bounds = Rectangle::new(Point::ORIGIN, scene.size); + let a = Point::new(scene.lanes[&f.sender], scene.slots[0].y); + let b = Point::new(scene.lanes[&f.receiver], a.y); + send( + &scene, + &mut state, + mouse::Event::CursorMoved { position: a }, + a, + bounds, + ); + send( + &scene, + &mut state, + mouse::Event::ButtonPressed(mouse::Button::Left), + a, + bounds, + ); + send( + &scene, + &mut state, + mouse::Event::CursorMoved { position: b }, + b, + bounds, + ); + let shifted = Rectangle::new(Point::new(0., -40.), scene.size); + assert!( + send( + &scene, + &mut state, + mouse::Event::ButtonReleased(mouse::Button::Left), + b, + shifted + ) + .is_none() + ); + assert!(state.source.is_none() && state.press.is_none()); + } + #[test] + fn direct_context_escape_unfocus_and_pending_form_cancel_gestures() { + for cancellation in 0..5 { + let (f, mut scene) = scene(); + let mut state = SequenceInput::default(); + let bounds = Rectangle::new(Point::ORIGIN, scene.size); + let a = Point::new(scene.lanes[&f.sender], scene.slots[0].y); + let b = Point::new(scene.lanes[&f.receiver], a.y); + send( + &scene, + &mut state, + mouse::Event::CursorMoved { position: a }, + a, + bounds, + ); + send( + &scene, + &mut state, + mouse::Event::ButtonPressed(mouse::Button::Left), + a, + bounds, + ); + send( + &scene, + &mut state, + mouse::Event::CursorMoved { position: b }, + b, + bounds, + ); + match cancellation { + 0 => { + scene.interaction.as_mut().unwrap().0.revision += 1; + } + 1 => { + scene.interaction.as_mut().unwrap().1 = CanvasTool::Select; + } + 2 => { + scene.interaction.as_mut().unwrap().2 = false; + } + 3 => { + canvas::Program::update( + &scene, + &mut state, + &canvas::Event::Window(iced::window::Event::Unfocused), + bounds, + mouse::Cursor::Unavailable, + ); + } + _ => { + canvas::Program::update( + &scene, + &mut state, + &canvas::Event::Keyboard(iced::keyboard::Event::KeyPressed { + key: iced::keyboard::Key::Named(iced::keyboard::key::Named::Escape), + modified_key: iced::keyboard::Key::Named( + iced::keyboard::key::Named::Escape, + ), + physical_key: iced::keyboard::key::Physical::Code( + iced::keyboard::key::Code::Escape, + ), + location: iced::keyboard::Location::Standard, + modifiers: iced::keyboard::Modifiers::empty(), + text: None, + repeat: false, + }), + bounds, + mouse::Cursor::Available(b), + ); + } + } + assert!( + send( + &scene, + &mut state, + mouse::Event::ButtonReleased(mouse::Button::Left), + b, + bounds + ) + .is_none() + ); + assert!(state.press.is_none() && state.source.is_none()); + } + } + #[test] + fn direct_clicked_occurrence_capture_cancels_on_view_and_context_changes() { + for tool in [ + scenario::Tool::Action, + scenario::Tool::Observation, + scenario::Tool::Reuse, + scenario::Tool::Fragment, + ] { + for cancellation in 0..9 { + let (f, mut scene) = scene(); + scene.interaction.as_mut().unwrap().1 = CanvasTool::Add(tool); + let mut state = SequenceInput::default(); + let mut bounds = Rectangle::new(Point::ORIGIN, scene.size); + let point = Point::new(scene.lanes[&f.sender], scene.slots[0].y); + send( + &scene, + &mut state, + mouse::Event::CursorMoved { position: point }, + point, + bounds, + ); + assert!( + send( + &scene, + &mut state, + mouse::Event::ButtonPressed(mouse::Button::Left), + point, + bounds + ) + .is_none() + ); + assert!(matches!( + state.press, + Some(SequencePress::Occurrence { .. }) + )); + let cancellation_event = match cancellation { + 0 => { + scene.interaction.as_mut().unwrap().0.epoch += 1; + None + } + 1 => { + scene.interaction.as_mut().unwrap().1 = CanvasTool::Select; + None + } + 2 => { + scene.interaction.as_mut().unwrap().2 = false; + None + } + 3 => Some(canvas::Event::Mouse(mouse::Event::WheelScrolled { + delta: mouse::ScrollDelta::Lines { x: 0., y: -1. }, + })), + 4 => Some(canvas::Event::Window(iced::window::Event::Resized( + Size::new(1100., 700.), + ))), + 5 => { + bounds.y -= 30.; + None + } + 6 => Some(canvas::Event::Window(iced::window::Event::Unfocused)), + 7 => Some(canvas::Event::Keyboard(iced::keyboard::Event::KeyPressed { + key: iced::keyboard::Key::Named(iced::keyboard::key::Named::Escape), + modified_key: iced::keyboard::Key::Named( + iced::keyboard::key::Named::Escape, + ), + physical_key: iced::keyboard::key::Physical::Code( + iced::keyboard::key::Code::Escape, + ), + location: iced::keyboard::Location::Standard, + modifiers: iced::keyboard::Modifiers::empty(), + text: None, + repeat: false, + })), + _ => None, + }; + if let Some(event) = cancellation_event { + let action = canvas::Program::update( + &scene, + &mut state, + &event, + bounds, + mouse::Cursor::Available(point), + ); + assert!(action.and_then(|action| action.into_inner().0).is_none()); + } + let cursor = if cancellation == 8 { + mouse::Cursor::Unavailable + } else { + mouse::Cursor::Available(point) + }; + let released = canvas::Program::update( + &scene, + &mut state, + &canvas::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)), + bounds, + cursor, + ); + assert!( + released.and_then(|action| action.into_inner().0).is_none(), + "{tool:?} cancellation {cancellation}" + ); + assert!(state.press.is_none()); + } + } + } + #[test] + fn direct_selected_handle_reorders_and_hits_keep_semantic_identity() { + let (f, mut scene) = scene(); + scene.interaction.as_mut().unwrap().1 = CanvasTool::Select; + let bounds = Rectangle::new(Point::ORIGIN, scene.size); + let header = scene + .boxes + .iter() + .find(|b| b.id == f.sender) + .unwrap() + .rect + .center(); + assert_eq!(scene.hit(header), Some(f.sender)); + assert_eq!( + scene.hit(Point::new(scene.lanes[&f.sender], scene.size.height - 28.)), + Some(f.sender) + ); + let line = scene.lines.iter().find(|line| line.id == f.reply).unwrap(); + assert_eq!( + scene.hit(line.label_at + iced::Vector::new(4., 4.)), + Some(f.reply) + ); + let mut state = SequenceInput::default(); + let start = scene.reorder_handle().unwrap().center(); + let end = Point::new(start.x, scene.slots.last().unwrap().y); + send( + &scene, + &mut state, + mouse::Event::CursorMoved { position: start }, + start, + bounds, + ); + send( + &scene, + &mut state, + mouse::Event::ButtonPressed(mouse::Button::Left), + start, + bounds, + ); + send( + &scene, + &mut state, + mouse::Event::CursorMoved { position: end }, + end, + bounds, + ); + assert!( + matches!(send(&scene, &mut state, mouse::Event::ButtonReleased(mouse::Button::Left), end, bounds), Some(CanvasEvent::Reorder { occurrence, slot, .. }) if occurrence == f.call && slot.parent == f.owner && slot.before.is_none()) + ); + } + #[test] + fn direct_alt_execution_stops_each_operand_at_its_reply_and_marks_open_operand() { + use super::topology::occurrence; + let f = scenario::direct_tests::fixture(); + let mut p = execute_batch(&f.project, &[Command::Delete(f.reply)]).unwrap(); + let fragment = occurrence( + &p, + f.owner, + "Alternatives", + OccurrenceBody::Fragment { + operator: FragmentOperator::Alternative, + branches: vec![], + }, + ); + let fragment_id = fragment.id; + let mut commands = vec![Command::CreateElement(fragment)]; + let mut branches = vec![]; + let mut replies = vec![]; + let mut actions = vec![]; + for index in 0..3 { + let mut branch = occurrence( + &p, + f.owner, + &format!("Operand {index}"), + OccurrenceBody::Branch { + guard: format!("path {index}"), + occurrences: vec![], + }, + ); + branch.parent = Some(fragment_id); + let branch_id = branch.id; + branches.push(branch_id); + commands.push(Command::CreateElement(branch)); + let mut steps = vec![]; + if index < 2 { + let mut reply = occurrence( + &p, + f.owner, + &format!("Reply {index}"), + OccurrenceBody::Exchange { + operation: None, + exchange: Some(f.exchange), + sender: Some(f.receiver), + receiver: Some(f.sender), + message_kind: MessageKind::Reply, + reply_to: Some(f.call), + values: vec![], + }, + ); + reply.parent = Some(branch_id); + replies.push(reply.id); + steps.push(reply.id); + commands.push(Command::CreateElement(reply)); + } + let mut action = occurrence( + &p, + f.owner, + &format!("Action {index}"), + OccurrenceBody::Action { + action: Some(p.relations[&f.exchange].target()), + lifeline: Some(f.receiver), + }, + ); + action.parent = Some(branch_id); + actions.push(action.id); + steps.push(action.id); + commands.push(Command::CreateElement(action)); + commands.push(Command::SetSteps(branch_id, steps)); + } + if let Command::CreateElement(element) = &mut commands[0] + && let Some(SharedBody::Occurrence { + body: + OccurrenceBody::Fragment { + branches: stored, .. + }, + .. + }) = element.shared_mut() + { + *stored = branches.clone(); + } + p = execute_batch(&p, &commands).unwrap(); + let scene = Scene::sequence(&p, f.owner, f.call); + let bars: Vec<_> = scene + .boxes + .iter() + .filter(|b| b.id == f.call && b.rect.width == 8.) + .map(|b| b.rect) + .collect(); + for reply in &replies { + let y = scene.rows[reply].1; + assert!( + bars.iter().any(|r| r.y <= y && r.y + r.height == y), + "every reply terminates its own operand segment" + ); + } + for action in &actions[..2] { + let y = scene.rows[action].1; + assert!( + !bars.iter().any(|r| r.y <= y && r.y + r.height > y), + "a returned operand must not remain active through its following action" + ); + } + let last_action = scene.rows[&actions[2]].1; + assert!( + scene.open_calls.iter().any(|p| p.y > last_action), + "the reply-free operand remains explicitly open" + ); + let header = scene + .boxes + .iter() + .find(|b| b.id == branches[1] && !b.title.is_empty()) + .unwrap() + .rect; + assert!( + !bars + .iter() + .any(|r| r.y < header.y + header.height && r.y + r.height > header.y), + "segments do not join mutually exclusive operand headers" + ); + } + #[test] + fn direct_execution_span_ends_at_its_correlated_reply_and_marks_an_open_call() { + let (f, scene) = scene(); + let bar = scene + .boxes + .iter() + .find(|b| b.id == f.call && b.rect.width == 8.) + .unwrap(); + assert_eq!(bar.rect.y + bar.rect.height, scene.rows[&f.reply].1); + assert!(scene.open_calls.is_empty()); + let p = execute_batch(&f.project, &[Command::Delete(f.reply)]).unwrap(); + let scene = Scene::sequence(&p, f.owner, f.call); + assert_eq!(scene.open_calls.len(), 1); + assert_eq!( + scene + .boxes + .iter() + .find(|b| b.id == f.call && b.rect.width == 8.) + .unwrap() + .rect + .height, + 26. + ); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn recovery_retains_each_incomplete_behavior_form_without_executing_it() { + let p = Project::demo(); + let before = p.clone(); + let owner = |kind| p.elements.values().find(|e| e.kind() == kind).unwrap().id; + let machine = owner(Kind::StateMachine); + let scenario = owner(Kind::Scenario); + let process = owner(Kind::Process); + let cases = [ + ( + machine, + vec![ + Event::States(states::Event::Start( + machine, + states::Tool::Transition, + machine, + )), + Event::States(states::Event::Name("Unfinished timeout".into())), + Event::States(states::Event::Timed(true)), + Event::States(states::Event::Expression("-".into())), + Event::States(states::Event::Guard("temperature >".into())), + ], + ), + ( + scenario, + vec![ + Event::Scenario(scenario::Event::Start(scenario, scenario::Tool::Message)), + Event::Scenario(scenario::Event::Name("Unfinished request".into())), + Event::Scenario(scenario::Event::Expression("-".into())), + ], + ), + ( + process, + vec![ + Event::Topology(topology::Event::Start( + process, + topology::Tool::Connect, + None, + )), + Event::Topology(topology::Event::Name("Unfinished path".into())), + Event::Topology(topology::Event::Guard("available &&".into())), + ], + ), + ]; + for (owner, events) in cases { + let mut original = State::default(); + for event in events { + original.update(&p, event).unwrap(); + } + assert!(original.prepare_pending(&p).is_err()); + let snapshot = original.recovery_snapshot().unwrap().unwrap(); + let bytes = serde_json::to_vec(&snapshot).unwrap(); + let mut recovered = State::default(); + recovered + .restore_recovery(serde_json::from_slice(&bytes).unwrap()) + .unwrap(); + assert_eq!(recovered.pending_owner(), Some(owner)); + assert_eq!(recovered.active_owner, Some(owner)); + assert!( + recovered.prepare_pending(&p).is_err(), + "An incomplete recovered form is not applied or silently repaired" + ); + assert_eq!(recovered.recovery_snapshot().unwrap(), Some(snapshot)); + assert_eq!(p, before); + recovered.acknowledge_commit(); + assert!(recovered.recovery_snapshot().unwrap().is_none()); + } + } + + #[test] + fn recovery_preserves_distinct_qualified_value_paths_and_rejects_invalid_payload_atomically() { + let p = Project::demo(); + let scenario = p + .elements + .values() + .find(|e| e.kind() == Kind::Scenario) + .unwrap() + .id; + let field = Id::new_v4(); + let paths = [Id::new_v4(), Id::new_v4()]; + let mut state = State::default(); + state + .update( + &p, + Event::Scenario(scenario::Event::Start(scenario, scenario::Tool::Message)), + ) + .unwrap(); + for (path, value) in paths.into_iter().zip(["-", "3.4e"]) { + state + .update( + &p, + Event::Scenario(scenario::Event::Value( + crate::data_values::FieldPathKey { + path: vec![path], + field, + }, + value.into(), + )), + ) + .unwrap(); + } + let original = state.recovery_snapshot().unwrap().unwrap(); + let mut recovered = State::default(); + recovered.restore_recovery(original.clone()).unwrap(); + assert_eq!( + recovered.recovery_snapshot().unwrap(), + Some(original.clone()) + ); + let values = original["draft"]["values"].as_array().unwrap(); + assert_eq!(values.len(), 2); + assert_ne!(values[0][0], values[1][0]); + assert_eq!(values[0][1], values[1][1]); + for bad in [ + { + let mut value = original.clone(); + value["version"] = 99.into(); + value + }, + { + let mut value = original.clone(); + value["draft"]["unknown"] = true.into(); + value + }, + { + let mut value = original.clone(); + value["draft"]["values"] + .as_array_mut() + .unwrap() + .push(values[0].clone()); + value + }, + ] { + assert!(recovered.restore_recovery(bad).is_err()); + assert_eq!( + recovered.recovery_snapshot().unwrap(), + Some(original.clone()), + "Malformed recovery cannot discard the currently retained form" + ); + } + } + use crate::document::{Document, DocumentError}; + use oa_core::source_project::{self, ProjectLimits}; + + #[test] + fn one_behavior_form_survives_start_edit_and_unrelated_cancel_until_commit() { + let p = Project::demo(); + let owner = |kind| p.elements.values().find(|e| e.kind() == kind).unwrap().id; + let process = owner(Kind::Process); + let scenario = owner(Kind::Scenario); + let machine = owner(Kind::StateMachine); + let transition = p + .relations + .values() + .find(|r| r.kind() == RelationKind::StateTransition) + .unwrap() + .id; + let starts = [ + Event::Topology(topology::Event::Start( + process, + topology::Tool::Control, + None, + )), + Event::Scenario(scenario::Event::Start(scenario, scenario::Tool::Fragment)), + Event::States(states::Event::Start(machine, states::Tool::Region, machine)), + ]; + let names = [ + Event::Topology(topology::Event::Name("Keep this draft".into())), + Event::Scenario(scenario::Event::Name("Keep this draft".into())), + Event::States(states::Event::Name("Keep this draft".into())), + ]; + let cancels = [ + Event::Topology(topology::Event::Cancel), + Event::Scenario(scenario::Event::Cancel), + Event::States(states::Event::Cancel), + ]; + for (index, expected_owner) in [process, scenario, machine].into_iter().enumerate() { + let mut state = State::default(); + assert!(!state.has_pending()); + assert!(state.prepare_pending(&p).unwrap().is_none()); + state.update(&p, starts[index].clone()).unwrap(); + state.update(&p, names[index].clone()).unwrap(); + if index == 1 { + state + .update( + &p, + Event::Scenario(scenario::Event::Expression("approved".into())), + ) + .unwrap(); + } + assert_eq!(state.pending_owner(), Some(expected_owner)); + for replacement in starts + .iter() + .cloned() + .chain([Event::States(states::Event::Edit(transition))]) + { + assert_eq!(state.update(&p, replacement).unwrap_err(), PENDING_DRAFT); + assert_eq!(state.pending_owner(), Some(expected_owner)); + } + state.update(&p, cancels[(index + 1) % 3].clone()).unwrap(); + assert!(state.has_pending(), "Another editor's cancel is stale"); + let command = state.update(&p, Event::Submit).unwrap().unwrap(); + assert!(state.has_pending(), "Preparation must retain the form"); + let expected = execute_batch(&p, std::slice::from_ref(&command)).unwrap(); + assert!(expected.elements.values().any(|e| { + !p.elements.contains_key(&e.id) + && e.name == "Keep this draft" + && e.parent == Some(expected_owner) + })); + let mut document = Document::from_project(p.clone(), ProjectLimits::default()).unwrap(); + document.execute(command).unwrap(); + assert_eq!(document.project(), &expected); + state.acknowledge_commit(); + assert!(!state.has_pending()); + assert!(state.prepare_pending(document.project()).unwrap().is_none()); + assert_eq!(document.revision(), 1); + assert!(document.undo()); + assert_eq!(document.project(), &p); + assert!(!document.can_undo()); + + state.update(&p, starts[index].clone()).unwrap(); + state.update(&p, cancels[index].clone()).unwrap(); + assert!(!state.has_pending(), "Explicit cancel discards this form"); + state.update(&p, starts[(index + 1) % 3].clone()).unwrap(); + } + } + + #[test] + fn failed_document_commit_retains_typed_transition_and_time_event_for_repair() { + let mut p = Project::empty(); + let owner = p + .add_element(Kind::System, Layer::Sa, "System", None) + .unwrap(); + let machine = p + .add_element(Kind::StateMachine, Layer::Sa, "Modes", Some(owner)) + .unwrap(); + let region = p + .add_element(Kind::Region, Layer::Sa, "Region", Some(machine)) + .unwrap(); + let source = p + .add_element(Kind::State, Layer::Sa, "Ready", Some(region)) + .unwrap(); + let target = p + .add_element(Kind::State, Layer::Sa, "Waiting", Some(region)) + .unwrap(); + let work = p + .add_element(Kind::Function, Layer::Sa, "Monitor", None) + .unwrap(); + let choice = |id| Choice { + id, + label: p.named(id).into(), + }; + let mut state = State::default(); + for event in [ + states::Event::Start(machine, states::Tool::Transition, source), + states::Event::Name("Transition".repeat(1024)), + states::Event::Trigger(choice(work)), + states::Event::Effect(choice(work)), + states::Event::TriggerNotes("On a monitor result".into()), + states::Event::Guard("connection unavailable".into()), + states::Event::EffectNotes("Record the timeout".into()), + states::Event::Timed(true), + states::Event::TimeKind("At".into()), + states::Event::Expression("12:30 UTC".into()), + ] { + state.update(&p, Event::States(event)).unwrap(); + } + assert!( + state.prepare_pending(&p).is_err(), + "A target is still missing" + ); + assert_eq!(state.pending_owner(), Some(machine)); + state + .update(&p, Event::States(states::Event::Target(choice(target)))) + .unwrap(); + let command = state.prepare_pending(&p).unwrap().unwrap(); + let bundle = source_project::format_bundle(&p); + let mut limits = ProjectLimits::default(); + limits.model.max_source_bytes = + bundle.files.values().map(String::len).sum::() + 2000; + let mut document = Document::from_bundle(bundle.clone(), limits).unwrap(); + assert!(matches!( + document.execute(command), + Err(DocumentError::Source(_)) + )); + assert_eq!(document.project(), &p); + assert_eq!(document.bundle(), &bundle); + assert_eq!(document.revision(), 0); + assert!(!document.can_undo()); + assert!(state.has_pending()); + + state + .update( + &p, + Event::States(states::Event::Name("Wait for recovery".into())), + ) + .unwrap(); + let command = state.prepare_pending(&p).unwrap().unwrap(); + let Command::Batch(commands) = &command else { + panic!("Expected one atomic transition batch") + }; + assert_eq!(commands.len(), 2); + let Command::CreateElement(event) = &commands[0] else { + panic!("Expected a typed time event") + }; + assert!( + matches!(event.shared(), Some(SharedBody::Event { body: EventBody::Time { kind: TimeEventKind::At, expression } }) if expression == "12:30 UTC") + ); + let Command::CreateRelation(relation) = &commands[1] else { + panic!("Expected the transition") + }; + let RelationBody::StateTransition { + triggers, + effects, + trigger, + guard, + effect, + .. + } = &relation.body + else { + panic!() + }; + assert_eq!(triggers, &[work, event.id]); + assert_eq!(effects, &[work]); + assert_eq!(trigger, "On a monitor result"); + assert_eq!(guard, "connection unavailable"); + assert_eq!(effect, "Record the timeout"); + let expected = execute_batch(&p, std::slice::from_ref(&command)).unwrap(); + document.execute(command).unwrap(); + assert_eq!( + document.project(), + &expected, + "Commit the exact prepared identities" + ); + state.acknowledge_commit(); + assert!(!state.has_pending()); + assert_eq!(document.revision(), 1); + assert!(document.undo()); + assert_eq!(document.bundle(), &bundle); + assert_eq!(document.project(), &p); + assert!(!document.can_undo()); + } + + #[test] + fn legacy_process_and_chain_keep_their_ordered_exchange_scene() { + let p = Project::demo(); + let owners: Vec<_> = p + .elements + .values() + .filter(|e| matches!(e.kind(), Kind::Process | Kind::FunctionalChain)) + .collect(); + assert!(owners.iter().any(|e| e.kind() == Kind::Process)); + assert!(owners.iter().any(|e| e.kind() == Kind::FunctionalChain)); + for owner in owners { + assert!(!uses_explicit_topology(&p, owner.id)); + let expected: Vec<_> = owner + .steps() + .unwrap() + .iter() + .enumerate() + .map(|(index, id)| { + let Some(SharedBody::Occurrence { + body: + OccurrenceBody::Exchange { + exchange: Some(exchange), + .. + }, + .. + }) = p.elements[id].shared() + else { + panic!("Demo legacy sequence should contain exchange occurrences"); + }; + format!("{}. {}", index + 1, p.relations[exchange].name) + }) + .collect(); + assert!(!expected.is_empty()); + let scene = Scene::sequence(&p, owner.id, owner.id); + let actual: Vec<_> = scene + .lines + .iter() + .filter(|line| !line.label.is_empty()) + .map(|line| line.label.clone()) + .collect(); + assert_eq!( + actual, expected, + "Legacy rendering must preserve the model's ordered exchange path" + ); + } + } + #[test] + fn selecting_state_transition_keeps_its_behavior_context() { + let p = Project::demo(); + for r in p + .relations + .values() + .filter(|r| r.kind() == RelationKind::StateTransition) + { + let machine = selected_behavior(&p, r.id).expect("Transition remains in its machine"); + assert_eq!(p.elements[&machine].kind(), Kind::StateMachine); + assert!(in_machine(&p, r.source(), machine)); + assert!(in_machine(&p, r.target(), machine)); + } + } +} diff --git a/harness/experiments/behavior-anchor-plan-review-20260912/attempt2/harness/experiments/behavior-anchor-plan-20260912/README.md b/harness/experiments/behavior-anchor-plan-review-20260912/attempt2/harness/experiments/behavior-anchor-plan-20260912/README.md new file mode 100644 index 00000000..748d9086 --- /dev/null +++ b/harness/experiments/behavior-anchor-plan-review-20260912/attempt2/harness/experiments/behavior-anchor-plan-20260912/README.md @@ -0,0 +1,79 @@ +# Complete anchor plan into actual Scene — pending execution + +Prepared 2026-09-12 in isolated branch `codex/behavior-anchor-plan-ytr83r-20260912`, base `66a1f1690f778c8814bfefff7f46fe7f6bbe520e`. Parent owns compilation and publication. **Not compiled, executed, staged or committed.** Only test-only additions to `crates/oa-desktop/src/behavior.rs` and this new directory were authored. The existing floor experiment and its fixtures are unchanged. All new production-file statements are guarded by `cfg(test)`; normal production rendering is unchanged. + +## Bounded question + +Can a complete ephemeral anchor plan drive the actual Scene at exact requested y coordinates without the old running-cursor floor, preserve unspecified anchors and the entire Document, and maintain covered extents and real lifecycle derivation? This isolates the integration seam from the unresolved choice of a product movement policy. + +The old floor candidate imposed existing seed increments on every requested row. This candidate seeds missing anchors from the actual baseline Scene, then replaces explicitly requested coordinates exactly. It does not impose sequence-order constraints or overlap spacing. Source-order crossings are returned as diagnostic data, not silently clamped or converted into semantic Reorder. This is a mechanical projection experiment, not evidence that crossed vertical ordering is acceptable UX. + +`Candidate` is an external map keyed by occurrence UUID. `Plan` is a complete resolved input for the currently visible Scene, including operand headers missing from the ordinary rows map. It contains row roles, derived message send/receive y values, frame bounds, sequence extents and canvas height. It is not a model, parser or REST payload. + +## Actual code boundary and wiring + +`wiring.patch` records the exact production-file delta from the base commit. The five new cfg(test) integration points are: + +1. Include this module as a private child of `behavior`, next to the unchanged floor module. +2. Immediately after the old floor hook, consume the complete plan's exact occurrence anchor, before Scene records rows and constructs the occurrence's existing geometry. +3. Before inserting an existing fragment BoxItem, use the plan's frame bounds. Existing fragment paint and label code remain in place. +4. Immediately after recording a sequence's bounds, substitute planned bounds and update the existing final insertion axis. An earlier event placed lower than the last event cannot truncate the scope merely because traversal ends earlier. +5. Before current lifecycle and activation construction, substitute the covered canvas height. Current `Rendering::new`, message alignment, activation generation and drawing remain responsible for their actual geometry. + +The plan is scoped per thread, inactive by default, restored by Drop during unwinding, and has a deliberate-panic test requiring the exact sentinel payload. Other tests on other threads cannot consume its plan. The original floor hooks remain operational when their own scope is active; the new plan is otherwise absent. + +Footprints are measured from the actual baseline named boxes, message polylines and label bounds, translated by requested row deltas. This avoids cloning the renderer's line or lifecycle algorithms. Empty activation bars are excluded because the real renderer derives those later. Frames conservatively preserve seed extents and can grow downward; growth adds an explicitly experimental 20px presentation margin. A move requiring upward frame growth returns `FixedFrameHeaderWouldMove`, because the candidate cannot move an unspecified painted header while retaining its FrameStart and insertion slot. Mere overlap inside the existing frame is not categorically rejected. Sequence bounds preserve seed extents and measured edge padding while expanding to cover outliers; canvas height uses the original scope-to-canvas margin. Neither these margins nor nonshrinking seed extents are adopted user requirements. This is not a general container-resize solver. + +## Independently expected checks + +Twelve new test functions use the existing hand-authored fixtures through the actual parser and Document bundle loader. Numeric expectations below are independently stated rather than generated from the new planner: + +| Case | Expected actual Scene result | +| --- | --- | +| Empty complete plan, both fixtures | Same rows, named boxes and marker kinds, lines and labels, scopes, slots, lifecycle, open calls and size as the unmodified baseline Scene | +| Second delivery240→200 | First148; second200; every other occurrence anchor unchanged; both second-message endpoints200; repeated exchange references retain different occurrence IDs | +| Nested action477→457 | Box top437, height70; actual fragment metadata and active header332..370 retained, header hit still resolves the fragment, before-slot290 and label unchanged; operand bounds and all other anchors unchanged | +| Nested action→300 | Proposed footprint280..350 would relocate unspecified frame top332 to260; reject with `FixedFrameHeaderWouldMove` before rendering. Nested353 remains admitted despite overlap inside the fixed frame | +| Create243→50 or84 | Reject with `LifecycleAnchorOutsideRenderScope`; actual root lifecycle span starts85 even when insertion-scope planning expands higher. At85 the real birth marker and alive span85..427 remain present | +| Death427→380 | Create243 and call335 stay fixed; real alive span243..380; actual bar335..380; death marker/hit moves to380; unresolved-call cue remains, no Reply is invented | +| Self-call636→610 | Actual send610/receive634, using the current24 offset; reply728 unchanged; MessageSend role distinguished from the lifecycle MessageReceive role | +| Signed zero and candidate restoration | Both0 and-0 return the exact original sparse candidate; no first-edit materialization; restoring the original candidate restores Scene geometry | +| First event→1000, last remains728 | First1000/second240, SourceOrderCrossing diagnostic, source order unchanged; root scope end1026 and height1113 cover the earlier outlier rather than the traversal tail | +| Nested action→1000 | Box top980/bottom1050; enclosing frame bottom1070; actual root scope/canvas cover it; following self-call636 remains fixed and overlap is diagnosed | +| Invalid/unsupported input | Unknown anchor, nonfinite y, compound frame movement and same-scope inverted birth/death fail explicitly | +| Scoped execution | Another thread sees baseline geometry; a deliberate panic restores the baseline scope | + +The primary cases compare the entire Project and original SourceBundle and require the real Document to remain clean with no Undo/Redo entry. Canonical parser roundtrip is used, but it covers only the semantic Project: layout stays external. Candidate restoration is a reversible-sidecar check, **not** a production Undo implementation. No command has been added to Document. + +## Explicit limitations and falsifiers + +- Crossing source-ordered events is deliberately drawable with diagnostics. That proves removal of the mechanical floor, not adoption of unrestricted vertical notation. The crossing output remains an independent product-policy falsifier. +- An expanded frame can overlap the next event. Its diagnostic preserves that evidence instead of shifting the next event implicitly. The experiment does not establish a frame/header constraint policy or all feasible layouts. +- Upward growth that would relocate an unspecified frame header is explicitly unsupported. This does not ban arbitrary overlap gaps; the numeric353 example remains admitted. Full compound/header movement policy remains unresolved. +- Lifecycle receive anchors must be within the actual effective Rendering scope: root85..height-20, or its recorded non-root scope with the renderer's existing empty-scope allowance. Outside coordinates are rejected before exposing a Scene whose row exists but whose marker/lifetime is missing. This bounds the inspected bridge; it does not establish general ancestor/operand ordering or cross-operand lifetime support. +- The same-scope creation-after-deletion case returns `LifetimeInversionNotSupported` before rendering. Current lifecycle intervals cannot truthfully depict that inversion without another representation policy. This is an experimental bridge limitation, not permission to reject semantically valid models or a user-approved global movement rule. Cross-operand lifecycle combinations remain unproven. +- Compound frame/operand movement, box resizing, horizontal layout, independent send/receive movement, route editing, drag transaction stamps, pointer capture, dirty-source recovery, persistence, REST and native UI are not implemented. +- Self-call send and receive roles are explicit, but the current activation engine still owns its existing row-based behavior. Peer #21's typed activation integration must consume the actual receive anchor and retain the existing lifetime clipping. These tests do not silently bless the old activation-start discrepancy. +- Scope/containment walking is deliberately straightforward and not a large-model performance implementation. There is no responsiveness claim. +- Some self-message fixture exchanges are unassigned because the current global relation model forbids self edges. The existing fixture's known incomplete-model warnings remain; tests reject semantic errors. + +No storage or parser design should be adopted from this adapter. Coordinate any eventual payload with prospective #7, whose required addressing must preserve diagram/owner/occurrence identity and explicit anchor roles. Coordinate inverse resize with #16 and typed activation/lifetime union with #21 before broad UI work. + +Parent reports [peer #21 comment716](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/21#issuecomment-716), posted2026-09-12 23:06UTC: Codex-956atf owns an isolated typed-activation/lifecycle source union. Its intended order is finalized rows/height, lifecycle construction and Create/Delete alignment, then typed Call/Reply attachment, with receiver/scope clipping, logical reply timing distinct from clipped geometry, no resurrection after Delete, and unanswered-call cues preserved. That WIP is not integrated here. Retain the explicit send/receive seam and review the future union against these fixtures; this paragraph records a coordination dependency, not verified acceptance of peer code. + +## Reproduction and handoff + +At the parent's chosen compiler checkpoint, use the configured Windows Rust/linker environment: + +```powershell +cargo test -p oa-desktop behavior_anchor_plan_experiment --locked --offline -- --nocapture +cargo test -p oa-desktop behavior_layout_scene_experiment --locked --offline -- --nocapture +``` + +The first filter must execute twelve new tests; the second must retain the ten existing floor/lifetime tests. Preserve compile failures, parser failures, numeric mismatches and diagnostics as results. Do not claim success from a zero-test filtered run or from compiling only production targets. An intentional panic should be followed by its restoration test passing; another panic payload is a failure. + +## Static review correction history + +The frozen attempt1 candidate was preserved before these corrections. Parent's authoritative archive is `.local/behavior-anchor-plan-review-20260912/attempt1` with its five-file hash manifest; parent owns portable archival publication. The pre-review failures are static, unexecuted findings: nested300 silently moved the frame header to260 while row332/slot290 stayed fixed, and Create50 was omitted as MissingGeometry by the actual root lifetime span starting85. The corrected candidate adds named unsupported errors and independent numeric checks rather than recording either original behavior as acceptance. Production hooks and the original floor experiment remain unchanged. + +Record exact compiled commit/uncommitted patch, commands and results before publication. Source hashes supplied with the handoff identify prepared bytes, not tested code. Root should publish the complete directory and exact behavior.rs delta so remote peers can reproduce it; workspace-local paths are not remote handoffs. diff --git a/harness/experiments/behavior-anchor-plan-review-20260912/attempt2/harness/experiments/behavior-anchor-plan-20260912/mod.rs b/harness/experiments/behavior-anchor-plan-review-20260912/attempt2/harness/experiments/behavior-anchor-plan-20260912/mod.rs new file mode 100644 index 00000000..2283e92d --- /dev/null +++ b/harness/experiments/behavior-anchor-plan-review-20260912/attempt2/harness/experiments/behavior-anchor-plan-20260912/mod.rs @@ -0,0 +1,297 @@ +//! Experimental complete anchor input for the actual Scene, cfg(test) only. +//! No persistence schema and no replacement line/lifecycle/activation renderer. +use super::*; +use std::cell::RefCell; + +mod tests; + +type Candidate = BTreeMap; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum AnchorRole { + MessageSend, + MessageReceive, + Observation, + FrameStart, + OperandStart, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +struct Anchor { + y: f32, + role: AnchorRole, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +struct MessageAnchors { + send_y: f32, + receive_y: f32, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +enum Diagnostic { + SourceOrderCrossing { scope: Id, before: Id, after: Id }, + FrameExtendsPastFollowingAnchor { frame: Id, following: Id }, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +enum PlanError { + UnknownAnchor(Id), + NonFinite(Id), + CompoundMoveNotImplemented(Id), + FixedFrameHeaderWouldMove { frame: Id }, + LifecycleAnchorOutsideRenderScope { occurrence: Id, scope: Id }, + LifetimeInversionNotSupported { create: Id, delete: Id }, +} + +#[derive(Clone, Debug)] +struct Plan { + anchors: BTreeMap, + messages: BTreeMap, + frames: BTreeMap, + scopes: BTreeMap, + height: f32, + diagnostics: Vec, +} + +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 +} + +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, seed: &BTreeMap) -> BTreeMap { + 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 covered_bounds(p: &Project, owner: Id, footprints: &BTreeMap) -> Option { + footprints.iter().filter(|(id, _)| inside(p, **id, owner)) + .map(|(_, rect)| *rect).reduce(union) +} + +impl Plan { + fn build(p: &Project, owner: Id, baseline: &Scene, desired: &Candidate) -> Result { + 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(PlanError::UnknownAnchor(id))?; + if !y.is_finite() { return Err(PlanError::NonFinite(id)); } + if matches!(anchor.role, AnchorRole::FrameStart | AnchorRole::OperandStart) && y != anchor.y { + return Err(PlanError::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 delta = anchor.y - seed[&line.id].y; + messages.insert(line.id, MessageAnchors { send_y: first.y + delta, receive_y: last.y + delta }); + } + } + // The current lifecycle bridge needs positive birth/death intervals. + // This is an explicit unsupported crossing, not a new semantic rule. + for (&create, message) in &messages { + if let Some(OccurrenceBody::Exchange { message_kind: MessageKind::Create, receiver: Some(lane), .. }) = occurrence(p, create) { + for (&delete, finish) in &messages { + if p.elements[&create].parent == p.elements[&delete].parent + && matches!(occurrence(p, delete), Some(OccurrenceBody::Exchange { message_kind: MessageKind::Delete, receiver: Some(other), .. }) if other == lane) + && message.receive_y > finish.receive_y { + return Err(PlanError::LifetimeInversionNotSupported { create, delete }); + } + } + } + } + let old_footprints = footprints(baseline, &seed, &seed); + let mut drawn = footprints(baseline, &anchors, &seed); + 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) = covered_bounds(p, id, &drawn) { + // Keep seed extents where possible; 20px growth padding is an + // explicit experimental presentation preference, not semantics. + // 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(PlanError::FixedFrameHeaderWouldMove { frame: id }); + } + if covered.y + covered.height > rect.y + rect.height { + rect.height = covered.y + covered.height + 20. - rect.y; + } + } + frames.insert(id, rect); + drawn.insert(id, rect); + } + let mut scopes = baseline.sequence_bounds.clone(); + for (&scope, bounds) in &mut scopes { + if let (Some(old), Some(new)) = (covered_bounds(p, scope, &old_footprints), covered_bounds(p, scope, &drawn)) { + 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 (&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(PlanError::LifecycleAnchorOutsideRenderScope { occurrence: id, scope }); + }; + if message.receive_y < start || message.receive_y > end { + return Err(PlanError::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]) { + if frame.y + frame.height > b.y { diagnostics.push(Diagnostic::FrameExtendsPastFollowingAnchor { frame: pair[0], following: pair[1] }); } + } + } + } + } + } + Ok(Self { anchors, messages, frames, scopes, height, diagnostics }) + } +} + +thread_local! { + static PLAN: RefCell> = const { RefCell::new(None) }; +} + +struct Scope(Option); +impl Scope { + fn install(plan: &Plan) -> Self { Self(PLAN.with(|slot| slot.replace(Some(plan.clone())))) } +} +impl Drop for Scope { + fn drop(&mut self) { PLAN.with(|slot| { slot.replace(self.0.take()); }); } +} + +pub(super) fn row_anchor(id: Id, y: &mut f32) { + PLAN.with(|slot| { + if let Some(plan) = slot.borrow().as_ref() { + *y = plan.anchors.get(&id).expect("complete Scene anchor plan").y; + } + }); +} + +pub(super) fn frame_bounds(id: Id, fallback: Rectangle) -> Rectangle { + PLAN.with(|slot| slot.borrow().as_ref().map_or(fallback, |plan| plan.frames[&id])) +} + +pub(super) fn scope_end(owner: Id, y: &mut f32, bounds: &mut BTreeMap) { + PLAN.with(|slot| { + if let Some(plan) = slot.borrow().as_ref() { + bounds.insert(owner, plan.scopes[&owner]); + *y = plan.scopes[&owner].1 + 42.; // Existing insertion-axis offset. + } + }); +} + +pub(super) fn scene_extent(scene: &mut Scene) { + PLAN.with(|slot| { + if let Some(plan) = slot.borrow().as_ref() { scene.size.height = plan.height; } + }); +} + +fn render(p: &Project, owner: Id, plan: &Plan) -> Scene { + let _scope = Scope::install(plan); + Scene::sequence(p, owner, owner) +} + +fn drag(original: &Candidate, frame: &Scene, target: Id, delta_y: f32) -> Candidate { + assert!(delta_y.is_finite()); + if delta_y == 0. { return original.clone(); } + let mut result = original.clone(); + result.insert(target, frame.rows[&target].1 + delta_y); + result +} diff --git a/harness/experiments/behavior-anchor-plan-review-20260912/attempt2/harness/experiments/behavior-anchor-plan-20260912/tests.rs b/harness/experiments/behavior-anchor-plan-review-20260912/attempt2/harness/experiments/behavior-anchor-plan-20260912/tests.rs new file mode 100644 index 00000000..36aa1397 --- /dev/null +++ b/harness/experiments/behavior-anchor-plan-review-20260912/attempt2/harness/experiments/behavior-anchor-plan-20260912/tests.rs @@ -0,0 +1,348 @@ +use super::*; +use crate::document::Document; +use oa_core::{language, source_project::{ProjectLimits, SourceBundle}, validation}; + +fn id(p: &Project, alias: &str) -> Id { + p.identities().into_iter().find(|(_, name)| *name == alias).unwrap().0 +} + +fn fixture(lifetime: bool) -> (Document, Id) { + let (name, input, owner) = if lifetime { + ("lifetime.arcadia", include_str!("../behavior-layout-scene-20260912/lifetime.arcadia"), "lifetime_conversation") + } else { + ("fixture.arcadia", include_str!("../behavior-layout-scene-20260912/fixture.arcadia"), "conversation") + }; + let p = language::parse(input).expect("existing real Arcadia fixture"); + let errors: Vec<_> = validation::validate(&p).into_iter() + .filter(|issue| issue.severity == validation::Severity::Error).collect(); + assert!(errors.is_empty(), "{errors:?}"); + let bundle = SourceBundle { entry: name.into(), files: BTreeMap::from([(name.into(), input.into())]) }; + let document = Document::from_bundle(bundle, ProjectLimits::default()).unwrap(); + assert_eq!(document.project(), &p); + assert_eq!(language::parse(&language::format(&p)).unwrap(), p); + let owner = id(&p, owner); + (document, owner) +} + +fn passive(document: &Document, before: &Project, source: &SourceBundle) { + assert_eq!(document.project(), before); + assert_eq!(document.bundle(), source); + assert!(!document.dirty()); + assert!(!document.can_undo() && !document.can_redo()); +} + +fn row(scene: &Scene, id: Id) -> f32 { scene.rows[&id].1 } +fn line(scene: &Scene, id: Id) -> &Line { + assert_eq!(scene.lines.iter().filter(|line| line.id == id).count(), 1); + scene.lines.iter().find(|line| line.id == id).unwrap() +} +fn named_box(scene: &Scene, id: Id) -> &BoxItem { + scene.boxes.iter().find(|item| item.id == id && !item.title.is_empty()).unwrap() +} + +fn same_scene(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.lanes, b.lanes); + assert_eq!(a.lifecycle, b.lifecycle); + assert_eq!(a.open_calls, b.open_calls); + let boxes = |s: &Scene| s.boxes.iter().map(|b| (b.id,b.rect,b.title.clone(),b.detail.clone(),b.marker)).collect::>(); + let lines = |s: &Scene| s.lines.iter().map(|l| (l.id,l.points.clone(),l.label_at,l.label.clone(),l.dashed)).collect::>(); + let slots = |s: &Scene| s.slots.iter().map(|slot| (slot.slot.parent,slot.slot.before,slot.y,slot.label.clone())).collect::>(); + assert_eq!(boxes(a),boxes(b)); + assert_eq!(lines(a),lines(b)); + assert_eq!(slots(a),slots(b)); +} + +fn others_fixed(before: &Scene, after: &Scene, changed: Id) { + assert_eq!(before.rows.len(),after.rows.len()); + for (&id, original) in &before.rows { + if id != changed { assert_eq!(&after.rows[&id],original,"unexpected movement of {id}"); } + } +} + +fn fixed_frame_metadata(before: &Scene, after: &Scene, frame: Id) { + let original=named_box(before,frame); + let current=named_box(after,frame); + assert_eq!((current.id,current.rect,¤t.title,¤t.detail,current.marker), + (original.id,original.rect,&original.title,&original.detail,original.marker)); + assert_eq!(before.rows[&frame],after.rows[&frame]); + assert_eq!(row(after,frame),332.); + // Scene::hit uses the first38px as the frame's active painted header. + let header=Rectangle::new(Point::new(original.rect.x,original.rect.y),Size::new(original.rect.width,38.)); + assert_eq!((header.y,header.y+header.height),(332.,370.)); + let probe=Point::new(header.x+8.,header.y+8.); + assert_eq!(before.hit(probe),Some(frame)); + assert_eq!(after.hit(probe),Some(frame)); + let original_slot=before.slots.iter().find(|slot|slot.slot.before==Some(frame)).unwrap(); + let current_slot=after.slots.iter().find(|slot|slot.slot.before==Some(frame)).unwrap(); + assert_eq!((current_slot.slot.parent,current_slot.slot.before,current_slot.y,¤t_slot.label), + (original_slot.slot.parent,original_slot.slot.before,original_slot.y,&original_slot.label)); + assert_eq!(current_slot.y,290.); +} + +#[test] +fn complete_seed_plan_preserves_both_actual_scene_and_document_baselines() { + for lifetime in [false,true] { + let (document,owner)=fixture(lifetime); + let p=document.project(); let before=p.clone(); let source=document.bundle().clone(); + let baseline=Scene::sequence(p,owner,owner); + let plan=Plan::build(p,owner,&baseline,&Candidate::new()).unwrap(); + assert!(plan.diagnostics.is_empty()); + for (&id,(_,y)) in &baseline.rows { assert_eq!(plan.anchors[&id].y,*y); } + same_scene(&baseline,&render(p,owner,&plan)); + let reparsed=language::parse(&language::format(p)).unwrap(); + same_scene(&baseline,&render(&reparsed,owner,&plan)); + passive(&document,&before,&source); + } +} + +#[test] +fn upward_second_delivery_reaches_200_and_all_other_occurrence_anchors_stay_fixed() { + let (document,owner)=fixture(false); + let p=document.project(); let before=p.clone(); let source=document.bundle().clone(); + let baseline=Scene::sequence(p,owner,owner); + let first=id(p,"first"); let second=id(p,"second"); + assert_ne!(first,second); + for occurrence_id in [first,second] { + assert!(matches!(occurrence(p,occurrence_id),Some(OccurrenceBody::Exchange { exchange:Some(edge),.. }) if *edge==id(p,"channel"))); + } + assert_eq!((row(&baseline,first),row(&baseline,second)),(148.,240.)); + let candidate=drag(&Candidate::new(),&baseline,second,-40.); + assert_eq!(candidate,Candidate::from([(second,200.)])); + let plan=Plan::build(p,owner,&baseline,&candidate).unwrap(); + let moved=render(p,owner,&plan); + assert_eq!((row(&moved,first),row(&moved,second)),(148.,200.)); + others_fixed(&baseline,&moved,second); + assert_eq!(line(&moved,first).points,line(&baseline,first).points); + let left=moved.lanes[&id(p,"left")]; let right=moved.lanes[&id(p,"right")]; + assert_eq!(line(&moved,second).points,vec![Point::new(left,200.),Point::new(right,200.)]); + assert_eq!(plan.messages[&second],MessageAnchors { send_y:200.,receive_y:200. }); + assert!(plan.diagnostics.is_empty()); + same_scene(&moved,&render(p,owner,&plan)); + passive(&document,&before,&source); +} + +#[test] +fn nested_upward_anchor_reaches_457_without_moving_parent_or_followers() { + let (document,owner)=fixture(false); + let p=document.project(); let before=p.clone(); let source=document.bundle().clone(); + let baseline=Scene::sequence(p,owner,owner); + let nested=id(p,"nested"); let fragment=id(p,"fragment"); + assert_eq!(row(&baseline,nested),477.); + let candidate=drag(&Candidate::new(),&baseline,nested,-20.); + let plan=Plan::build(p,owner,&baseline,&candidate).unwrap(); + let moved=render(p,owner,&plan); + assert_eq!(row(&moved,nested),457.); + assert_eq!(named_box(&moved,nested).rect.y,437.); + assert_eq!(named_box(&moved,nested).rect.height,70.); + assert_eq!(named_box(&moved,fragment).rect,named_box(&baseline,fragment).rect); + assert_eq!((named_box(&moved,fragment).rect.y,named_box(&moved,fragment).rect.height),(332.,240.)); + fixed_frame_metadata(&baseline,&moved,fragment); + assert_eq!(moved.sequence_bounds[&id(p,"operand")],baseline.sequence_bounds[&id(p,"operand")]); + others_fixed(&baseline,&moved,nested); + assert!(plan.diagnostics.is_empty()); + passive(&document,&before,&source); +} + +#[test] +fn nested_move_that_would_relocate_fixed_header_is_rejected_but_mere_overlap_is_not_banned() { + let (document,owner)=fixture(false); + let p=document.project(); let before=p.clone(); let source=document.bundle().clone(); + let baseline=Scene::sequence(p,owner,owner); + let nested=id(p,"nested"); let fragment=id(p,"fragment"); + let requested=Candidate::from([(nested,300.)]); + // Actual70px action box has row-to-top offset-20: proposed footprint280..350. + // The old envelope union would move frame/header top332 to260 while leaving + // FrameStart332 and before-slot290. Reject before any such Scene is exposed. + let original=named_box(&baseline,nested).rect; + assert_eq!((row(&baseline,nested),original.y,original.height),(477.,457.,70.)); + let proposed_top=original.y+(300.-row(&baseline,nested)); + assert_eq!((proposed_top,proposed_top+original.height),(280.,350.)); + assert_eq!(Plan::build(p,owner,&baseline,&requested).unwrap_err(), + PlanError::FixedFrameHeaderWouldMove { frame:fragment }); + fixed_frame_metadata(&baseline,&Scene::sequence(p,owner,owner),fragment); + // Overlap inside the existing frame does not itself move its top. This + // remains admitted mechanical geometry, not a claim of usable overlap UX. + let overlap=Plan::build(p,owner,&baseline,&Candidate::from([(nested,353.)])).unwrap(); + let overlapping=render(p,owner,&overlap); + assert_eq!(named_box(&overlapping,nested).rect.y,333.); + fixed_frame_metadata(&baseline,&overlapping,fragment); + passive(&document,&before,&source); +} + +#[test] +fn lifecycle_anchor_below_actual_root_render_scope_is_rejected_before_scene_construction() { + let (document,owner)=fixture(true); + let p=document.project(); let before=p.clone(); let source=document.bundle().clone(); + let baseline=Scene::sequence(p,owner,owner); + let create=id(p,"create_receiver"); let provider=id(p,"provider"); + let requester=id(p,"requester"); + let root_life=baseline.lifecycle.alive_spans(requester,owner,0.,baseline.size.height); + assert_eq!(root_life.iter().map(|span|(span.start,span.end)).collect::>(),vec![(85.,544.)]); + for requested in [50.,84.] { + assert_eq!(Plan::build(p,owner,&baseline,&Candidate::from([(create,requested)])).unwrap_err(), + PlanError::LifecycleAnchorOutsideRenderScope { occurrence:create,scope:owner }); + } + // At the renderer's actual85 boundary the marker/lifetime remains present. + // Source-order crossing is still diagnosed separately, not silently banned. + let boundary=Plan::build(p,owner,&baseline,&Candidate::from([(create,85.)])).unwrap(); + let moved=render(p,owner,&boundary); + let x=moved.lanes[&provider]; + assert_eq!(row(&moved,create),85.); + assert_eq!(moved.lifecycle.marker_at(Point::new(x,85.)),Some(create)); + let alive=moved.lifecycle.alive_spans(provider,owner,0.,moved.size.height); + assert_eq!(alive.iter().map(|span|(span.start,span.end)).collect::>(),vec![(85.,427.)]); + passive(&document,&before,&source); +} + +#[test] +fn earlier_death_reaches_380_and_actual_lifetime_and_activation_clip_there() { + let (document,owner)=fixture(true); + let p=document.project(); let before=p.clone(); let source=document.bundle().clone(); + let baseline=Scene::sequence(p,owner,owner); + let create=id(p,"create_receiver"); let call=id(p,"service_call"); let delete=id(p,"delete_receiver"); + let provider=id(p,"provider"); + assert_eq!((row(&baseline,create),row(&baseline,call),row(&baseline,delete)),(243.,335.,427.)); + let candidate=drag(&Candidate::new(),&baseline,delete,-47.); + let plan=Plan::build(p,owner,&baseline,&candidate).unwrap(); + assert_eq!(plan.anchors[&delete].role,AnchorRole::MessageReceive); + assert_eq!(plan.messages[&delete],MessageAnchors { send_y:380.,receive_y:380. }); + let moved=render(p,owner,&plan); + assert_eq!(row(&moved,delete),380.); + others_fixed(&baseline,&moved,delete); + let spans=moved.lifecycle.alive_spans(provider,owner,85.,moved.size.height-20.); + assert_eq!(spans.iter().map(|s|(s.start,s.end)).collect::>(),vec![(243.,380.)]); + assert!(moved.lifecycle.alive_spans(provider,owner,380.,moved.size.height-20.).is_empty()); + let x=moved.lanes[&provider]; + assert_eq!(moved.lifecycle.marker_at(Point::new(x,380.)),Some(delete)); + assert_eq!(moved.lifecycle.marker_at(Point::new(x,427.)),None); + assert_eq!(moved.hit(Point::new(x,380.)),Some(delete)); + let bars:Vec<_>=moved.boxes.iter().filter(|b|b.id==call && b.title.is_empty()).collect(); + assert_eq!(bars.len(),1); + assert_eq!(bars[0].rect,Rectangle::new(Point::new(x-4.,335.),Size::new(8.,45.))); + assert_eq!(moved.open_calls,vec![Point::new(x+12.,364.)]); + assert!(plan.diagnostics.is_empty()); + passive(&document,&before,&source); +} + +#[test] +fn self_call_has_explicit_send610_receive634_and_reply_stays728() { + let (document,owner)=fixture(false); + let p=document.project(); let before=p.clone(); let source=document.bundle().clone(); + let baseline=Scene::sequence(p,owner,owner); + let call=id(p,"self_call"); let reply=id(p,"self_reply"); + let candidate=drag(&Candidate::new(),&baseline,call,-26.); + let plan=Plan::build(p,owner,&baseline,&candidate).unwrap(); + assert_eq!(plan.anchors[&call],Anchor { y:610.,role:AnchorRole::MessageSend }); + assert_eq!(plan.messages[&call],MessageAnchors { send_y:610.,receive_y:634. }); + let moved=render(p,owner,&plan); + assert_eq!(row(&moved,call),610.); + assert_eq!(row(&moved,reply),728.); + assert_eq!(line(&moved,call).points.iter().map(|p|p.y).collect::>(),vec![610.,610.,634.,634.]); + assert_eq!(line(&moved,reply).points,line(&baseline,reply).points); + others_fixed(&baseline,&moved,call); + // Peer #21 owns the activation start/column contract. This assertion covers + // actual message anchors, not the old row-based activation start defect. + passive(&document,&before,&source); +} + +#[test] +fn signed_zero_keeps_sparse_candidate_and_restoring_original_candidate_restores_actual_scene() { + let (document,owner)=fixture(false); + let p=document.project(); let before=p.clone(); let source=document.bundle().clone(); + let baseline=Scene::sequence(p,owner,owner); + let second=id(p,"second"); + let original=Candidate::new(); + for zero in [0.,-0.] { assert_eq!(drag(&original,&baseline,second,zero),original); } + let changed=drag(&original,&baseline,second,-40.); + let plan=Plan::build(p,owner,&baseline,&changed).unwrap(); + let frame=render(p,owner,&plan); + for zero in [0.,-0.] { + let untouched=drag(&changed,&frame,second,zero); + assert_eq!(untouched,changed); + same_scene(&frame,&render(p,owner,&Plan::build(p,owner,&baseline,&untouched).unwrap())); + } + // This restores the exact sidecar candidate; it is not a production Undo + // command or evidence of a layout source/REST persistence implementation. + same_scene(&baseline,&render(p,owner,&Plan::build(p,owner,&baseline,&original).unwrap())); + passive(&document,&before,&source); +} + +#[test] +fn crossing_is_rendered_exactly_and_diagnosed_with_extents_covering_the_earlier_far_event() { + let (document,owner)=fixture(false); + let p=document.project(); let before=p.clone(); let source=document.bundle().clone(); + let baseline=Scene::sequence(p,owner,owner); + let first=id(p,"first"); let second=id(p,"second"); + let plan=Plan::build(p,owner,&baseline,&Candidate::from([(first,1000.)])).unwrap(); + assert!(plan.diagnostics.contains(&Diagnostic::SourceOrderCrossing { scope:owner,before:first,after:second })); + let moved=render(p,owner,&plan); + assert_eq!((row(&moved,first),row(&moved,second)),(1000.,240.)); + assert_eq!(row(&moved,id(p,"self_reply")),728.); + others_fixed(&baseline,&moved,first); + assert_eq!(moved.sequence_bounds[&owner].1,1026.); + assert_eq!(moved.size.height,1113.); + assert!(line(&moved,first).points.iter().all(|point| point.y=1070.); + assert!(moved.size.height>1070.); + assert_eq!(row(&moved,following),636.); + others_fixed(&baseline,&moved,nested); +} + +#[test] +fn unsupported_targets_and_lifetime_inversion_fail_explicitly_instead_of_silent_clamping() { + let (document,owner)=fixture(true); + let p=document.project(); let baseline=Scene::sequence(p,owner,owner); + let create=id(p,"create_receiver"); let delete=id(p,"delete_receiver"); + assert_eq!(Plan::build(p,owner,&baseline,&Candidate::from([(delete,200.)])).unwrap_err(), + PlanError::LifetimeInversionNotSupported { create,delete }); + assert_eq!(Plan::build(p,owner,&baseline,&Candidate::from([(delete,f32::NAN)])).unwrap_err(),PlanError::NonFinite(delete)); + let edge=id(p,"service"); + assert_eq!(Plan::build(p,owner,&baseline,&Candidate::from([(edge,200.)])).unwrap_err(),PlanError::UnknownAnchor(edge)); + let (other,other_owner)=fixture(false); + let fragment=id(other.project(),"fragment"); + let other_scene=Scene::sequence(other.project(),other_owner,other_owner); + assert_eq!(Plan::build(other.project(),other_owner,&other_scene,&Candidate::from([(fragment,300.)])).unwrap_err(), + PlanError::CompoundMoveNotImplemented(fragment)); +} + +#[test] +fn complete_plan_scope_is_inactive_in_other_threads_and_restored_after_deliberate_panic() { + let (document,owner)=fixture(false); + let p=document.project(); let baseline=Scene::sequence(p,owner,owner); + let second=id(p,"second"); + let plan=Plan::build(p,owner,&baseline,&Candidate::from([(second,200.)])).unwrap(); + let caught=std::panic::catch_unwind(|| { + let _scope=Scope::install(&plan); + assert_eq!(row(&Scene::sequence(p,owner,owner),second),200.); + std::thread::spawn(|| { + let (document,owner)=fixture(false); + let p=document.project(); + assert_eq!(row(&Scene::sequence(p,owner,owner),id(p,"second")),240.); + }).join().unwrap(); + panic!("deliberate complete-anchor scope unwind"); + }); + let payload=caught.expect_err("deliberate panic is required"); + assert_eq!(payload.downcast_ref::<&str>().copied(),Some("deliberate complete-anchor scope unwind")); + same_scene(&baseline,&Scene::sequence(p,owner,owner)); +} diff --git a/harness/experiments/behavior-anchor-plan-review-20260912/attempt2/harness/experiments/behavior-anchor-plan-20260912/wiring.patch b/harness/experiments/behavior-anchor-plan-review-20260912/attempt2/harness/experiments/behavior-anchor-plan-20260912/wiring.patch new file mode 100644 index 00000000..dc27b942 --- /dev/null +++ b/harness/experiments/behavior-anchor-plan-review-20260912/attempt2/harness/experiments/behavior-anchor-plan-20260912/wiring.patch @@ -0,0 +1,51 @@ +diff --git a/crates/oa-desktop/src/behavior.rs b/crates/oa-desktop/src/behavior.rs +index 3f9fd97..f1ef5b7 100644 +--- a/crates/oa-desktop/src/behavior.rs ++++ b/crates/oa-desktop/src/behavior.rs +@@ -1121,6 +1121,10 @@ fn in_machine(p: &Project, id: Id, machine: Id) -> bool { + #[path = "../../../harness/experiments/behavior-layout-scene-20260912/mod.rs"] + mod behavior_layout_scene_experiment; + ++#[cfg(test)] ++#[path = "../../../harness/experiments/behavior-anchor-plan-20260912/mod.rs"] ++mod behavior_anchor_plan_experiment; ++ + struct BoxItem { + id: Id, + rect: Rectangle, +@@ -1332,6 +1336,8 @@ impl Scene { + let mut y = if scenario { 148. } else { 120. }; + scene.occurrences(p, owner, scenario, &lane_x, &mut y, 0); + scene.size.height = (y + 45.).max(210.); ++ #[cfg(test)] ++ behavior_anchor_plan_experiment::scene_extent(&mut scene); + if scenario { + scene.lifecycle = scenario_lifecycle::render::Rendering::new( + p, +@@ -1441,6 +1447,8 @@ impl Scene { + } + #[cfg(test)] + behavior_layout_scene_experiment::resolve_row_anchor(*id, y); ++ #[cfg(test)] ++ behavior_anchor_plan_experiment::row_anchor(*id, y); + if ordered { + let slot = scenario::Slot { + parent: owner, +@@ -1465,6 +1473,8 @@ impl Scene { + (*y - top).max(95.), + ), + ); ++ #[cfg(test)] ++ let rect = behavior_anchor_plan_experiment::frame_bounds(*id, rect); + self.boxes.insert( + 0, + BoxItem { +@@ -1649,6 +1659,8 @@ impl Scene { + } + if ordered { + self.sequence_bounds.insert(owner, (sequence_top, *y - 42.)); ++ #[cfg(test)] ++ behavior_anchor_plan_experiment::scope_end(owner, y, &mut self.sequence_bounds); + let slot = scenario::Slot { + parent: owner, + before: None, diff --git a/harness/experiments/behavior-anchor-plan-review-20260912/attempt2/manifest.json b/harness/experiments/behavior-anchor-plan-review-20260912/attempt2/manifest.json new file mode 100644 index 00000000..b508b891 --- /dev/null +++ b/harness/experiments/behavior-anchor-plan-review-20260912/attempt2/manifest.json @@ -0,0 +1,30 @@ +{ + "status": "Two-fix static candidate; uncompiled, with newly discovered operand-scope gap", + "files": [ + { + "path": "crates/oa-desktop/src/behavior.rs", + "bytes": 137409, + "sha256": "5884BA35AD994EB5936CA29C071A5002EFC221DC42EF35E2265D50593BDF60EC" + }, + { + "path": "harness/experiments/behavior-anchor-plan-20260912/mod.rs", + "bytes": 12330, + "sha256": "2F139B39D067E8ACB39E9239F06E6AE3B560BE0541F7478B3EADA81D758CEDA9" + }, + { + "path": "harness/experiments/behavior-anchor-plan-20260912/tests.rs", + "bytes": 18836, + "sha256": "12CBC8FFCF2CDA54694C265C31F18CA47FB795BE34E28C2F09129027274D8E22" + }, + { + "path": "harness/experiments/behavior-anchor-plan-20260912/README.md", + "bytes": 12234, + "sha256": "080C7027CBFF51FC5D0A51E550F13C9F66AD3E3453C105355E7B78AF32CA9BC6" + }, + { + "path": "harness/experiments/behavior-anchor-plan-20260912/wiring.patch", + "bytes": 2131, + "sha256": "4CE4BC45FBC419EBA6A07B048B82EFB8D85525514E3D2E56421948E7733F40FD" + } + ] +}