diff --git a/docs/README.md b/docs/README.md index 7dd2225a8..33f8804c2 100644 --- a/docs/README.md +++ b/docs/README.md @@ -35,6 +35,7 @@ - [design-plans/2026-08-25-compiler-unification.md](design-plans/2026-08-25-compiler-unification.md) -- Engine compiler de-duplication, landed: one fragment compiler (`lower_fragment`/`DepShape`), one `BuiltinFn::signature` table, one temp allocator and materialization pass, one axis matcher (`match_axes`), structured `DepRef`s, one `Variable`, one `Diagnostic`, a `(variable, project)`-keyed parse with AST-carried captures and implicit modules, per-variable lowered memos, Loops That Matter as a consumer of each; every pinned semantic divergence and a per-commit measured ledger - [design-plans/2026-09-04-link-scores-from-fragments.md](design-plans/2026-09-04-link-scores-from-fragments.md) -- LTM link scores synthesized from the target's compiled fragment instead of generated equation text: the rewrite, its per-link keying, the formula families as typed builders, one `LinkScore` opcode, a native post-pass; the roofline for "LTM always on" - [design-plans/2026-09-07-ltm-single-mode.md](design-plans/2026-09-07-ltm-single-mode.md) -- Single-mode LTM: score every causal edge, compute every loop score after the run from the recorded series (one pipeline for every surface), Johnson only for the structural preview and ids, pins injected into the pipeline, a reducer as a real loop node, static polarity and the shortest-path fallback deleted, sub-model pathway scores emitted only for instances that can lie on a loop; 8 phases + - [design-plans/2026-09-10-diagram-editing-core.md](design-plans/2026-09-10-diagram-editing-core.md) -- Diagram editing core: one flow-geometry owner (terminals, `route`/`routeEnd`, `offsetSegment`, `heal`), one pure gesture planner whose frame at pointer-up is the commit, one controller executor over committed + pending[] edits with rollback; the G1-G8/M1-M3/E1-E6 invariants the tests assert; 7 phases - [design-plans/2026-08-26-compiler-unification-phase7-investigation.md](design-plans/2026-08-26-compiler-unification-phase7-investigation.md) -- Companion to the compiler unification plan: every parse-time decision that reads model state, where each moves under a `(variable, project)` parse key, every text/name identity site of synthesized helpers, the salsa keys, the capture runlist contract - [design-plans/2026-08-17-pysimlin-widget.md](design-plans/2026-08-17-pysimlin-widget.md) -- pysimlin file-backed models + anywidget in-notebook editor (file on disk as sync authority) - [design-plans/2026-04-05-server-rewrite.md](design-plans/2026-04-05-server-rewrite.md) -- Local-first `simlin-serve` binary: filesystem-backed editor + in-process MCP diff --git a/docs/design-plans/2026-09-10-diagram-editing-core.md b/docs/design-plans/2026-09-10-diagram-editing-core.md new file mode 100644 index 000000000..289ddb352 --- /dev/null +++ b/docs/design-plans/2026-09-10-diagram-editing-core.md @@ -0,0 +1,564 @@ +# Diagram editing core: one geometry owner, one gesture planner, one engine executor + +## Why + +The interactive editor (`src/diagram`) had no single owner for flow geometry, +for what a gesture does, or for how an edit reaches the engine. A four-part +adversarial audit (seeded invariant fuzzing of the production routing +functions, a preview-vs-commit sweep of every Canvas gesture, a commit-path +audit against the real WASM engine, and a measurement of every flow in the +`test/` corpus) found about fifty distinct defects. They are symptoms of three +structural problems: + +1. **Flow geometry has no owner.** Straight-to-bend conversion had seven + implementations with four thresholds and three corner conventions; stock face + selection had at least six rules; valve placement at least nine. Callers of + `UpdateCloudAndFlow` fabricated an endpoint element parked at the old + position plus an inverted delta, each differently. Consequences included + endpoints committed at a stock's center, pipes running along a stock face, + routes through stock bodies, valves teleporting between segments, and a + detached cloud landing half a stock width from the pointer. +2. **Preview and commit were different code.** The Canvas previewed through one + layered pipeline and pointer-up dispatched to per-gesture Editor handlers + that recomputed through others. Every seam was a jump on release (#830 and its + variants). Clicks with no movement committed: a click on a stock-attached flow + end detached the flow from its stock, a click on a link arrowhead deleted the + link. Invalid (red) drop targets still committed. A selection change mid-drag + crashed the Canvas; a pointer-up exception left half a gesture live. +3. **The commit path could save a model that disagrees with its diagram.** + Handlers awaited the model patch before applying the view, built full + replacement payloads from state read before that await, committed the view + even when the patch failed, sent optimistic element lists with viewport-only + updates, and undo disposed the engine an in-flight patch was using. Attaching a + flow onto a stock that already listed it duplicated the inflow, which the engine + integrates twice with no error. Creating a variable under an existing name + silently replaced that variable, whatever its kind. + +## Facts the design rests on + +- XMILE 1.0 section 6.1.2 (`docs/reference/xmile-v1.0.html`): a flow's `pts` "MUST + form right angles". The spec says nothing about attachment faces, clouds, or + valve position. +- Corpus (490 imported models, 572 flows): 98.8% of flows have two points, none + has four or more; 578/579 segments are exactly axis-aligned; of stock endpoints + on a face, 593/595 have the adjacent segment perpendicular and leaving outward; + 72% of face endpoints are off-center; 8% of stock endpoints are off the face + (Vensim stocks larger than 45x35, up to 254px); 99% of valves are on the path; + 75% of cloud endpoints coincide exactly with the cloud center and 99.7% within + `CloudRadius`. XMILE imports can carry one flow listed as an outflow of two stocks. +- The engine's layout (`src/simlin-engine/src/layout/orthogonal.rs`) rebuilds pipes + "so each segment leaves its stock face perpendicular". +- Rendering is pinned byte-for-byte to the Rust renderer (`tests/svg-rendering.test.ts`): + stored points are the render input, the cloud endpoint is stored at the cloud + center (the renderer retracts it), the valve is drawn where stored, and the + arrowhead is drawn 7.5px back along the final segment. +- libsimlin applies a patch atomically: it stages a clone and restores it on any + error (`src/libsimlin/src/patch.rs`); a view-only patch takes a fast path with no + model validation. +- An engine round trip of a view is not value-exact: floats can move by one ULP, + `isStraight` is re-derived from `arc`, and `nextUid` is recomputed as max uid + 1. A + second round trip is a fixed point. +- `RenameVariable` renames equations, module references and group members, not view + elements; the editor pairs it with an `upsertView`. + +## Units + +Model coordinates (px at zoom 1): `CORNER_CLEARANCE` = 3, `MIN_SEGMENT` (shortest +routed stub/riser) = 10, `VALVE_CLAMP_MARGIN` = 10, `MIN_SINK_SEGMENT` = +`FlowArrowheadRadius` + 7.5, `PIPE_SPACING` (preferred distance between endpoints on +a face) = 10, `GEOMETRY_EPSILON` = 1e-6. Screen px: `ClickDragThresholdPx` = 5 +(divided by zoom before comparing to model deltas). + +## Invariants + +Every committed edit produces a view that holds these for every flow the edit +routed. Input views may violate them (imported data, legacy saves); the editor must +accept such input without asserting, render it unmodified while idle, and heal a +flow only when an edit routes it. + +- **G1 structure**: >= 2 points; first point attached to the source, last to the + sink (a stock, or a cloud owned by this flow); interior points unattached; finite + coordinates; no uid <= 0 in a committed view; source and sink are different + elements (strict only: imports can carry a self-loop, #720). +- **G2 orthogonal**: every segment axis-aligned (cloud-to-cloud included; spec 6.1.2). +- **G3 normalized**: no zero-length segment; no two consecutive collinear segments; + every routed stub/riser >= `MIN_SEGMENT` and the final segment >= `MIN_SINK_SEGMENT` + whenever the terminals leave room (otherwise the longest achievable). +- **G4 face attachment**: a stock endpoint lies on a face at least `CORNER_CLEARANCE` + from its corners. +- **G5 perpendicular exit**: the segment adjacent to a stock endpoint is perpendicular + to that face and leaves outward. +- **G6 no body crossing**: no segment passes through the interior of either terminal + stock, and no cloud center lies inside a stock -- whenever the two terminal + bodies, each inflated by `MIN_SEGMENT`, do not overlap. When they overlap, G1-G5 + still hold and G6 is best effort. +- **G7 cloud coincidence**: a cloud endpoint equals its cloud's center. +- **G8 valve on path**: the valve lies on the path, at least `VALVE_CLAMP_MARGIN` from + the path's ends when the path is long enough. +- **M1 kind agreement**: every stock/flow/aux/module element names an existing + variable of the same kind; a created element's variable exists after the commit. +- **M2 stock/flow agreement**: after an edit changes a flow end's attachment, the flow + is removed from the old stock's list and present exactly once in the new stock's; + no other list entry changes. +- **M3 referential integrity**: links, aliases and clouds reference existing elements; + a link's ends are distinct named elements or aliases (never a cloud); an alias refers + to a named element; every cloud is an endpoint of its owning flow exactly once. + +Readings the checkers (`tests/support/flow-invariants.ts`, `view-invariants.ts`) pin: +tolerant mode checks only the structural G1 arms (heal repairs every geometric arm, and +the Vensim importer emits unattached flows); in G3 the first segment is a stub, interior +segments are risers, and "room" means the source body inflated by `MIN_SEGMENT` and the +sink body inflated by `MIN_SINK_SEGMENT` do not overlap; G6's cloud clause means any stock +in the view; G8's "long enough" is a path at least 2 x `VALVE_CLAMP_MARGIN` long, the +margin measured by arc length from the path ends; G1's uid clause covers every element; +G5 is undefined for an off-face endpoint (G4 owns it); in M2 a created or deleted flow is +unattached on its missing side, renames are excluded, list order is not compared, and +stale imported entries on uninvolved stocks must stay untouched; M1 and M3 are +committed-view properties of the elements an edit routes or creates -- imported input +may violate them (an orphan cloud, lookup-only variables without a primary element) +and the editor must accept it. + +Routing preference (not an invariant): a flow that newly lands on a face takes a +slot at least `PIPE_SPACING` from existing endpoints on that face if one exists, +else the slot maximizing the minimum distance. Slides along a face are exempt. + +Gesture invariants: + +- **E1 click is not a drag**: movement below the click threshold commits nothing and + previews nothing -- except an armed creation tool, where a click creates the element. +- **E2 preview == commit**: pointer-up commits `planGesture` evaluated at the pointer-up + coordinates, which is exactly the frame the preview shows at those coordinates. +- **E3 continuity**: within a gesture, geometry changes continuously with the pointer + except at documented discrete transitions: crossing the click threshold (and the + slide/offset latch), snapping onto or off a target, a route changing shape because + feasibility changed, `offsetSegment`'s riser appearing at `MIN_SEGMENT` beyond a face + extent, and healing an imported flow on its first routed frame. +- **E4 locality**: elements the gesture does not route are unchanged in the planner's + next view (exactly), and within `GEOMETRY_EPSILON` after an engine round trip, + including other flows' attachment offsets. +- **E5 abort**: the gesture is dropped without committing and without throwing on + pointercancel, a second pointer, pinch, a change of the controller's state token + (below), or a change to the geometry or attachment fields of the elements the gesture + reads (the routed set and target candidates), compared with `GEOMETRY_EPSILON` and + ignoring derived fields (`isStraight`, `nextUid`, `var`). Republishes that do not + change those fields (sim results attached, error annotations, a round trip, the + gesture's own press-time selection change) do not abort. +- **E6 invalid drops abort**: a drop over a target rendered invalid commits nothing. + +## Scope + +In scope: the first stock-flow view of each model (the one the editor renders); +stocks, flows, clouds, auxes, modules, aliases, links (arc form), labels. +Out of scope, unchanged: groups (move only when explicitly selected; never +rubber-band selected), additional views, link `multiPoint` geometry, array +dimension compatibility of attachments. + +## Architecture + +``` +drawing/Canvas.tsx shell: pointer capture, coordinates, viewport physics, + one `activeGesture` value, renders planGesture(...).elements +gesture-planner.ts pure: classifyPress, planGesture +plan-delete.ts pure: planDelete +flow-geometry/ pure: geometry (units, boxes), terminal (face attachment), + validity (G2-G6), path (normalize, valve, slideValve), + route (route/routeEnd), offset-segment, heal; index re-exports +view-model-sync.ts pure: renames, per-flow-end stock ops, created/deleted variable ops +Editor.tsx one gesture commit handler + details/module/sim-spec edits, + all expressed as controller edits +project-controller.ts committed + pending[] per model, state token, one executor +``` + +### flow-geometry/ + +Positions are absolute model coordinates, never inverted deltas. Every operation +reads the gesture's base flow, never a previous frame, and returns the base geometry +unchanged when a terminal or coordinate is not finite. + +- **Terminal**: `{ kind: 'stock'; stock; face?; offset? }` or `{ kind: 'free'; point; + cloud? }` (a cloud or the pointer). One module owns face attachment -- the valid face + points within the face extent minus `CORNER_CLEARANCE`, the outward direction, and + the stub tip (`plane + sign * minimum`) -- and `route`'s candidates, `routeEnd`'s + pinned terminal, `offsetSegment`'s tail re-solve and `heal`'s re-pin all use it. + `face`/`offset` carry the BASE flow's attachment (stickiness is defined against the + gesture's base view, so it needs no previous frame). A corner endpoint belongs to + the face its adjacent segment leaves perpendicular to. `occupied` (other endpoints on + the terminal stocks, for the slot preference) is in the frame's coordinates: a + planner moving a stock moves the endpoints on it too. +- **heal(flow, terminals, { stocks })**: idempotent, identity on a valid flow. Order: + attach endpoints to their terminals (a cloud is moved onto the endpoint rather than + the pipe onto the cloud, and out of any stock in `stocks` along its segment; an + off-face stock endpoint is re-pinned to the nearest valid face point, a corner + endpoint clamped along the face its adjacent segment is perpendicular to), then snap + slightly-diagonal segments by dominant axis, then normalize, then re-route from the + moved terminals only if the result still violates G2-G6. +- **route(source, sink, ctx)**: the minimal orthogonal polyline between two terminals. + Candidate faces per stock terminal x shapes (straight, L, Z). Interior holds are + candidates owned by the port pair: the base corners clamped into the pair's band + between its stub tips, a minimum riser either side of a point port, the band's + midpoint, then stub tips and body clearances. Ordering: validity (G3-G6) -> not + crossing a terminal body (G6's best effort when the bodies overlap is a preference, + not no constraint) -> stickiness (base face kept if it has a candidate within one + bend of the best; stickiness is the only base-face preference, since a pure function + has no previous frame for hysteresis) -> bends -> axis change -> length (within + `GEOMETRY_EPSILON`, a tie). When nothing simpler is valid, a U detour and then 3-4 + bends are tried (clouds a pixel off each other's line, or a stock over its own cloud, + have no valid straight, L or Z). Total: if no candidate is valid, the least severe + fault wins (G6 before G3 before structure); a route is always returned. +- **routeEnd(flow, end, terminal, ctx)**: re-route one end with a preserved prefix. + Try preserving k interior corners counted from the FIXED end, k = K..1 (K = all but + the corner adjacent to the re-routed end), then k = 0 with the fixed terminal pinned + to its face and offset (no detours), and only then release to `route`. A candidate is + accepted only if it is valid, crosses no terminal body, and meets every G3 minimum + even where G3 excuses them. A preserved tail may not give the path more bends or + more U turns than the base had (preserving corners keeps a shape; a tail that grows + it is a detour that releasing replaces), nor run back over a preserved segment + within `MIN_SEGMENT`; tails may take up to 3 bends when those budgets allow. +- **offsetSegment(flow, segmentIndex, coordinate, terminals, { stocks })**: move one + segment perpendicular to itself. The coordinate is first resolved against the + adjacent segments jointly, as the nearest coordinate where every stock stub keeps its + minimum and every adjacent riser or cloud segment is at least its minimum or + collapsed to zero (so a short riser collapses within half a minimum and is pushed out + otherwise). If the path would still cross a terminal body or put a cloud inside a + stock, the nearest valid coordinate on either side of the obstacle is taken (the + segment follows the pointer to the obstacle and jumps across once the far side is + nearer). Adjacent corners follow. At a terminal end the tail is re-solved: a cloud + moves with the segment; a stock endpoint sits at `clamp(coordinate, face extent minus + CORNER_CLEARANCE)`. While the coordinate is within `MIN_SEGMENT` beyond that extent + the segment stays at the extent; beyond that a stub plus a riser connects the + endpoint to the segment, each at least `MIN_SEGMENT` (the final segment at the sink + end at least `MIN_SINK_SEGMENT`) (a documented E3 transition, so G3 holds). A tail of + the form endpoint -> stub (at most the end's minimum, perpendicular) -> riser is + recognized as re-solvable when the dragged segment follows the riser, so dragging a + bracket back collapses it to straight and stubs never accumulate. A straight flow + between aligned stocks slides within the faces first and becomes a bracket beyond + them. +- **translate(flow, delta)**: both terminals move by the same delta. +- **Valve policy**: the valve is an arc-length position measured from the fixed end + (from the source for operations with no fixed end). Routing preserves that distance, + clamped to the new path length; `slideValve` moves it by the pointer delta projected + along the path as straight-line pointer travel from the press (keeping the grab + offset, crossing corners, and lagging the pointer at a corner by design); + `offsetSegment` keeps the valve's coordinate along its own segment while that + segment survives (clamped into its new span), and moves it to the nearest point of + the new path when its segment is removed. `VALVE_CLAMP_MARGIN` is applied once, at + the end. +- **normalize**: remove zero-length and collinear interior points. +- **Documented transitions a drag shows**: dragging a cloud perpendicular off a + straight stock flow slides the endpoint along its face while nothing pinned is valid, + then returns the endpoint to its base offset when the pinned Z becomes valid (at + `MIN_SEGMENT`), then becomes an L (at `MIN_SINK_SEGMENT`); an endpoint that started + off-center steps at 5, 10 and 16px accordingly. The accepted pinned routes occupy + regions of pointer positions whose edges are G3 minima, so a region two minima bound + has a corner, and a pointer path clipping that corner flip-flops (A -> B -> A over a + few px). A cloud circling its stock does so in a band of radii just past each corner's + distance (for a top-face source, r in [44.2, 45.7) through the pinned Z's corner). A + pure function with no previous frame cannot avoid this; the sweeps pin those bands. + +### gesture-planner.ts + +```ts +type Gesture = + | { kind: 'moveSelection' } + | { kind: 'slideValve'; flow: UID } + | { kind: 'offsetSegment'; flow: UID; segmentIndex: number } + | { kind: 'flowEndpoint'; flow: UID; end: 'source' | 'sink' } + | { kind: 'linkEndpoint'; link: UID } + | { kind: 'linkArc'; link: UID } + | { kind: 'createFlow'; from: { stock: UID } | 'empty' } + | { kind: 'createLink'; from: UID } + | { kind: 'createElement'; type: 'aux' | 'stock' | 'module' } + | { kind: 'label'; uid: UID } + | { kind: 'rubberBand' } + | { kind: 'pan' }; + +planGesture(input: { + view; variables; selection; gesture; press: Point; current: Point; + zoom; pointerType; readOnly; names: NameAllocator; +}): { + elements; nextUid; // what the preview renders and the commit saves + target?: { uid: UID; valid: boolean }; + commit: 'none' | 'edit' | 'select'; + selection; handoff?: { editName: UID }; +}; +``` + +`classifyPress` maps (hit element and part, modifiers, armed tool, selection, pointer +type) to a gesture, a press-time selection change, and whether the tool is cleared. +Its table is derived from the current `Canvas.handleSetSelection`/`handlePointerDown` +arms and covers every arm, including: modifier-press on a selected element (toggle out, +`commit: 'none'` for any following drag); an armed tool on an element it does not apply +to (tool cleared, normal press semantics); link tool on empty canvas; a cloud in a +multi-element selection (moveSelection) vs a sole or unselected cloud (flowEndpoint); an +alias as a link source; label double-click (name edit), module double-click (drill-in); +the name-editor overlay press and tool change while editing (commit the name); a mouse +move with buttons = 0 (lost release: the gesture is cancelled, not committed) and a +second touch (pinch, E5). A pipe or valve press latches `offsetSegment` (segment under +the pointer) iff the first move beyond the click threshold is perpendicular-dominant, +else `slideValve`; the latch holds for the rest of the gesture. + +Semantics: + +- **moveSelection**: selected positioned elements translate; a flow with both ends + moving translates; a flow with one moving end is `routeEnd`-ed to its moved terminal; + a selected flow with no moving end slides its valve; links whose endpoint elements' + positions changed (valves moved by routing included) get their arc updated once, from + the final elements. +- **flowEndpoint**: the dragged end follows the pointer, keeping the grab offset + relative to the endpoint. Target hit-testing uses the pointer. Over a valid stock the + end routes to that stock; over empty space it becomes (or stays) a cloud at the + endpoint; over an invalid target nothing commits. Cloud-attached and stock-attached + ends are one gesture. +- **createFlow**: `route` from a source terminal whose face is chosen by the route (a + stock) or a cloud at the press point, to the pointer; then as flowEndpoint. +- **createElement**: a draft element staged by the Canvas; the drag positions it; a + click places it at the press point. The draft lives in Canvas state through name + editing (it survives republishes); cancel discards it; done plans the create against + the view rendered at done time and enqueues it. +- **createLink / linkEndpoint / linkArc / label**: existing semantics behind the same + interface; a click never deletes a link; a drop on its own source or on nothing + aborts. +- A target is valid exactly when committing onto it yields a view holding the + invariants and the semantic rules: a flow's source and sink are different stocks, + and the target's stock variable exists in the rendered model, which holds the + variable of every create still queued (the create lands before the gesture's + edit is built) and none for an orphan element. + +Resolutions made while implementing (the module is `src/diagram/gesture-planner/`): + +- The Canvas plans on the view it is rendering each frame. A live gesture aborts (E5) + when the state token moves or the view's geometry changes by value from the view + captured at press (`sameGeometry`); a pan reads no geometry and is exempt. The + comparison covers the whole view rather than the gesture's read set: a real change + to ANY element aborts, including one the gesture does not read. That is simpler + than tracking read sets and costs little, since geometry changes mid-gesture only + when another edit lands. Benign republishes keep the gesture: a pending edit + landing (floats one ULP away, `isStraight`, `var` and `nextUid` re-derived), sim + results attaching, error annotations updating. The commit carries its base view, + and the Editor drops a commit whose base no longer matches the view it would edit. +- A selection change mid-gesture does not abort: the gesture plans with the selection + captured at press. +- A flow end that is unattached (a Vensim fallback flow) gets a new cloud when the + gesture routes the flow, so every committed flow holds G1. +- A move whose routed flows break an invariant the plan does not excuse commits + nothing, like an invalid drop: a cloud dragged into another stock while the flow's + terminals are apart. Because G6 and the G3 minima are best effort when the terminal + bodies leave no room, a stock dragged onto or up to its flow's other terminal + still commits, holding G1-G5. +- Only the aux, stock and module tools place an element on a click; a flow or link tool + click creates nothing. A creation press clears the selection rather than selecting a + sentinel uid. +- A label drag that ends on the side the label already has commits nothing; the label + gesture's threshold belongs to `Label`, so the planner applies none to it. +- Module double-click (drill-in) is classified before `pressesDisabled`: navigation is + not an edit. +- A routed flow goes around every stock body in the view where it can, not only its + terminals: a pipe through a stock reads as attached to it (a real-browser repro + detached a sink from a stock and kept the old face's line straight through it). + G6 stays terminal-only; this is a preference. Routing takes the view's stocks as + obstacles: a candidate through one ranks as crossing, a preserved or pinned tail + through one is refused, the released search generates detours (U turns, more bends) + before settling for a route through a stock, and it never refuses its last resort. + `offsetSegment` treats every stock as an obstacle and takes the nearest clear + coordinate on either side. E3 gains a documented transition: a route stepping + around an obstacle when the pointer crosses the point where going around becomes + feasible or shorter. +- Escape during a live gesture cancels it: the preview returns to the published view + and the release commits nothing. +- A pointercancel or a lost release on a pan settles the viewport it reached. +- Flows render identically while moving and at rest (no retracted-arrow or hidden-grip + variants), so the last preview frame equals the committed frame. + +`planDelete(view, selection)` (in its own module, `plan-delete.ts`, because the delete +path is keyboard- and panel-driven rather than a pointer gesture) removes the selected +elements, links touching them, +aliases of them, and clouds of deleted flows; flow endpoints on deleted stocks become +clouds at the endpoint; a selected cloud whose flow survives is ignored. + +Names: default names for new elements are allocated against the committed model plus +pending creates. A typed name that collides keeps the name editor open with an error +and enqueues nothing. A collision discovered at dequeue (only reachable if the allocator +is wrong) fails the item like any other engine error. + +### view-model-sync.ts + +`buildEditOps(committedModel, baseView, nextView)`, evaluated at dequeue against the +committed model produced by the previous edit item of that model. Every ident it uses is +derived from an element's `name` (canonicalized), never from the element's `ident` +field, which a caller-built element need not keep in step; a view planned on a pending +rename then resolves against the committed model the rename produced. + +- **Renames**: a named element whose uid survives with a different `name` emits + `renameVariable` from the committed ident to the new name as typed. The rename edit's + next view is the rendered view with that element relabeled; nothing else carries the + rename, so a combined rename and reattach needs no special path. Stock list entries + echoed by the stock/flow ops are carried through the rename. +- **Stock/flow delta**, per flow element whose source (sink) attachment differs between + base and next: remove the flow from the old stock's outflows (inflows) and add it to + the new stock's, deduped. Only existing flow variables and existing stock variables + are touched, and stocks deleted by the same edit are excluded (deleting a stock needs + no list cleanup); no other entry of any list changes. One `updateStockFlows` per + touched stock, carrying both full lists from the committed model with the deltas + applied. "Existing" means present in the committed model or created by this same edit + (a drawn flow between two stocks is listed in both). The echoed lists omit entries + naming variables this edit deletes, because the patch applies `deleteVariable` first + and the engine strips a deleted flow from every list; echoing it would re-add it. A + stock whose lists come out unchanged (attaching onto a stock that already lists the + flow) gets no op. +- **Created variables**: upserts for named elements present in next and absent in base. +- **Deleted variables**: `deleteVariable` for named elements removed in next whose + variable exists in the committed model and has no remaining element. +- Op order in one patch: rename, variable upserts/deletes, stock/flow ops, `upsertView` + last. + +### Controller: committed + pending[] per model, a state token, one executor + +State: + +- `committed`: the engine's last acknowledged project (model + views). +- `token`: an integer bumped by every truncation, undo/redo, and any other change to + `committed` not produced by the pending chain (reopen, external reload). +- `pending[]`: queued edit items in order. Each carries its target `modelName`, the + `token` it was planned under, the view it planned on, its next view (every edit that + changes what a view shows has one -- including rename, whose next view is the rendered + view with the element relabeled), and `buildOps(committedModel)`. Model-only edits + (details panel equation/table edits, module wiring, sim specs) have no next view and + derive their full payload from the committed variable at dequeue, so `stockToJson`'s + echoed inflows/outflows are never stale. +- `viewport`: the live viewBox/zoom per model. + +Rendered view for model M = the next view of the last pending item targeting M (or +committed M), with `viewport` for M overlaid. `applyOptimisticView`, `preserveLiveView` +and `adoptPatchedViews` are replaced by this rule. Connector errors are computed on the +rendered view (the engine's per-variable incoming links plus the rendered connectors). +Every rendered element's `ident` is its name's (a create or rename sets it, and a rename +matches elements by name, so renaming an element whose rename or create is pending finds +it). While a pending view renames a committed variable, the rendered model names that +variable by its new ident, with its committed content, errors and connector dependencies, +so the canvas, the details panel and name allocation see the model the queued edits will +produce. A model-only edit resolves its variable at dequeue through the element: the uid +it was enqueued for, on the committed view. + +Executor: one serialized async loop; every engine call runs inside an item, and no +engine reference is held across an await outside one. Two item classes: + +- **Edit items** (and viewport persist items), FIFO. An edit item with a next view whose + `token` is stale is dropped as failed; model-only edits derive everything at dequeue and + are exempt (one that targets a variable a truncated item would have created fails + naturally). Otherwise it sends its ops plus `upsertView` (its next view with + the committed viewport; for a model-only edit none) as one patch, serializes, rebuilds + `committed`, and records one history entry. A viewport persist item sends + `upsertView(committed view with viewport)` -- never an optimistic element list -- + records no history, and is never truncated. +- **Maintenance items** (save, sim run, error refresh, connector-error attach): at most + one queued per kind, never truncated, and run when no edit item is queued -- or after 5 + consecutive edit items or 5 seconds of continuous edit work, whichever comes first, so a + sustained stream of slow edits cannot starve saving. A burst of edits costs one sim run + and one save. + +Failure of view edit item k (engine error or stale token): truncate every later edit with a +next view and every later undo/redo (each was planned on k's optimistic view), bump +`token` (which aborts live gestures, E5; a stale-token drop does not bump again), render +`committed`, report one error naming how many later edits were discarded. Model-only edits +survive truncation: they derive their payload from committed state at dequeue, so an +unrelated failure does not invalidate them, and discarding one would silently lose the +user's typed text. One that targets a variable a discarded edit would have created fails +naturally at dequeue and reports its own error. A failed model-only edit only reports: +no edit was planned on it, so nothing is truncated and `token` does not move; an undo +queued behind it for the draft it carried is discarded with it. A patch that applied but +could not be read back resyncs `committed` before its fate is decided (its next view keeps +rendering meanwhile): a successful re-read means it landed; a reopen of the snapshot at +the history cursor means it failed, and it fails after the swap, so edits planned on it +during the reopen are truncated too; a failed reopen latches engine-unavailable -- the +queue settles, every later request is refused quietly, and the host shows one persistent +notice offering a reload. +Undo/redo are edit-class items; the UI and the keyboard shortcut are disabled while +`pending` is non-empty or a gesture is live, the Canvas ignores new presses while an +undo/redo item is queued, a view edit enqueued while one is queued is refused quietly, and +landing bumps `token`. An Undo press with a details-panel draft submits the draft and +queues the undo behind its edit, so the undo takes the draft back; a Redo press queues +the redo first and submits the draft behind it, so the draft lands on the redone project. +A create or rename refused while an undo is queued returns a message, keeping the name +editor open. +Navigation need not wait for the queue; its viewport restore is a viewport item for the +target model. + +Details panel drafts: any canvas press first flushes an open panel draft (the panel +commits synchronously, enqueueing its edit ahead of the gesture), because a canvas press +does not blur the panel editor. Panels are keyed on the selected element and its variable's +committed content (plus the read-only flag and a counter that moves only when an undo/redo +lands), not on a global generation, so an unrelated edit item landing while the user types +does not remount the panel and discard the draft; and while the panel holds a draft its key +is held, so an edit landing on this variable (a rename rewriting its equation, the draft's +own flushed edit while more text is typed) does not either. A field holds a draft when its +text differs from its base -- what the panel last submitted for it, or its seeded text -- and +only drafts are submitted: an untouched field is never echoed, and a field changed back +while its edit is in flight holds the panel so that edit cannot land over it. A submission +that does not land stops being the base. While an undo/redo is queued the panels render +read-only. + +Engine defense in depth: stock inflow/outflow lists are deduped after canonicalization +for every op that sets them (`updateStockFlows`, `upsertStock`), so a duplicate can never +be integrated twice. Upsert keeps full-replacement semantics (MCP and pysimlin rely on +kind-changing upserts); name collisions are the editor's to prevent. + +## Testing + +- Committed invariant checkers (`tests/support/flow-invariants.ts`, + `tests/support/view-invariants.ts`) with strict and tolerant modes, and a seeded scene + and gesture generator (no new dependencies). Fuzz every gesture kind over generated + scenes and 8-step sequences; assert G*, M*, E1-E6; `route`/`routeEnd` return a route + for every input, including overlapping stocks. +- Table tests with rows derived from enumerations: terminal kind (4 faces, free) x path + shape x drag direction for `route`/`routeEnd`; segment position (first/interior/last) x + terminal kind x path length (2/3/4/6) x direction x reversal across gestures for + `offsetSegment`; every arm of `classifyPress` and `Gesture` (click, sub-threshold, + read-only, invalid target, abort); `buildEditOps` over delete stock x flow attached as + source/sink/both, and delete stock together with one of its flows. +- Old suites (`flow-routing`, `group-movement`, `flow-attach`, `canvas-gestures-*`, + `canvas-interaction`, `selection-logic`) are mined: each scenario is ported as a row or + dropped with a stated reason (including #53, #818, #819, #720, #832, touch-straight + links, wobble-is-a-click). Tests pinning the #820 commit-anyway policy are rewritten + to the rollback policy. +- Canvas harness: one DOM-diff test per gesture kind (last frame vs committed view); + the harness does not bump `version` on selection changes and fixtures pin endpoints to + faces as production does. +- Controller: tables against the fake engine for truncation, stale tokens (a gesture + planned before a failure, an edit planned before an undo lands), maintenance + coalescing, viewport items, per-model rendering during navigation, undo gating; Editor + + real WASM engine: model/view invariants after every gesture, including in-flight + races, undo during a pending patch, and typing in a details panel then dragging a stock. +- Corpus: every imported flow routes under each gesture without throwing, and routed + flows hold the strict invariants (run after the engine import fixes). +- Browser journey (notebook-widget e2e harness): drag a stock, detach and reattach a + flow, offset a flow into a bracket; screenshots for review. + +## Engine import and layout fixes + +Found by the corpus measurement, fixed in the same branch as separate commits: +XMILE `Stock::is_left`/`is_above` sign errors; XMILE takeoff fixup reading top-left +coordinates as centers; clouds created before 2-point straightening; MDL endpoints left +off-face for stocks larger than 45x35; MDL flows imported unattached (invisible) -- +attachments follow the model's stock lists; layout clamping endpoints onto corners and +orthogonalizer Ls missing the valve; incremental layout rewriting untouched flows; +duplicate flow elements when two stocks claim one outflow; duplicate stock +inflow/outflow entries. + +## Phases + +Each phase: one implementer, then an adversarial reviewer (mutation testing of new +tests, hashed snapshot), iterate, commit. + +1. Test support: invariant checkers, generator. +2. `flow-geometry.ts` with exhaustive and fuzz tests (not yet wired). +3. `gesture-planner.ts` with tests (not yet wired). +4. `view-model-sync.ts` and the controller executor: committed + pending[] per model, + token, item classes, undo gating; every Editor handler (including details panel, + modules, sim specs, rename) migrated to controller edits while the Canvas still uses + the old geometry. Independent of phases 2-3, so it runs in a parallel worktree. +5. Canvas and Editor gestures rewired onto the planner; old routing, + `group-movement.ts`, `flow-attach.ts` removed; harness fidelity; gesture and + Editor+engine tests. +6. Engine import/layout fixes (parallel worktree), merged before the corpus test. +7. Docs (`LAYOUT.md`, `src/diagram/CLAUDE.md` invariants), browser journey, removal of + audit scratch files and logs, PR. diff --git a/docs/tech-debt.md b/docs/tech-debt.md index 4d0237441..43304915c 100644 --- a/docs/tech-debt.md +++ b/docs/tech-debt.md @@ -623,9 +623,9 @@ Known debt items consolidated from CLAUDE.md files and codebase analysis. Each e ### 67. Connector-error recompute fires on viewport-only project rebuilds -- **Component**: diagram (`src/diagram/project-controller.ts` `attachConnectorErrors` / `updateVariableErrors`, called from `updateProject`) +- **Component**: diagram (`src/diagram/project-controller.ts`) - **Severity**: low -- **Description**: A sketch-connector/equation consistency check (`ProjectController.attachConnectorErrors`) runs from `updateVariableErrors`, which executes on EVERY project rebuild in `updateProject`. It fetches per-variable equation dependencies via the engine's `Model.getIncomingLinks(varName)` -- one async engine call per on-view aux/flow/stock variable (batched with `Promise.all`). Because viewport-only settles (pan/zoom) route through `queueViewUpdate` -> `updateProject` -> `updateVariableErrors`, this recompute also fires when connectors cannot possibly have changed. For large models (e.g. World3, hundreds of variables) on the Web Worker backend that is N `postMessage` round-trips per pan/zoom settle, adding avoidable latency to the settle's save. The feature is correct as-is; this is purely an efficiency refinement. +- **Description**: (**Resolved** by the controller's executor model: a viewport persist refreshes no annotations, connector dependencies are fetched by a coalesced `connectors` maintenance item requested only after landed edits, the open, undo/redo and navigation, and drift is computed at render time from the cached dependencies. Kept as a historical pointer.) A sketch-connector/equation consistency check (`ProjectController.attachConnectorErrors`) runs from `updateVariableErrors`, which executes on EVERY project rebuild in `updateProject`. It fetches per-variable equation dependencies via the engine's `Model.getIncomingLinks(varName)` -- one async engine call per on-view aux/flow/stock variable (batched with `Promise.all`). Because viewport-only settles (pan/zoom) route through `queueViewUpdate` -> `updateProject` -> `updateVariableErrors`, this recompute also fires when connectors cannot possibly have changed. For large models (e.g. World3, hundreds of variables) on the Web Worker backend that is N `postMessage` round-trips per pan/zoom settle, adding avoidable latency to the settle's save. The feature is correct as-is; this is purely an efficiency refinement. - **Suggested fix**: (a) Skip the connector recompute on non-content updates by distinguishing viewport-only rebuilds from content/structure edits, or (b) memoize per-variable incoming-links keyed on the serialized model content so pan/zoom settles reuse the prior result. - **Owner**: unassigned - **Discovered**: sketch-connector/equation consistency-check work on branch `editor-bug-burndown-2026-07` diff --git a/src/core/CLAUDE.md b/src/core/CLAUDE.md index 180646197..7196c36fe 100644 --- a/src/core/CLAUDE.md +++ b/src/core/CLAUDE.md @@ -19,6 +19,7 @@ For build/test/lint commands, see [docs/dev/commands.md](/docs/dev/commands.md). - Variables carry BOTH `ident` (canonical; the `Model.variables` Map key, and it must match engine-canonical idents in sim results, error details, and view-element lookups) and an optional `rawName` (the display spelling from the wire `name`, e.g. "Total Students"). `*FromJson` populates `rawName`; `*ToJson` emits `rawName ?? ident` as `name`. This is what keeps the editor's full-upsert paths from downgrading an imported model's display names one edit at a time (issue #906): the engine stores the payload's `name` verbatim and does all matching canonically (issue #890), so the payload's spelling is authoritative for presentation. - `Stock`, `Flow`, and `Aux` interfaces all carry `canBeModuleInput` and `isPublic` boolean fields. These are read from `compat` in JSON deserialization and written back to `compat` when true. The fields control which variables appear as module input/output ports in the diagram editor. - The full engine `compat` field set round-trips through `datamodel.ts`: `activeInitial`, `nonNegative`, `canBeModuleInput`, `isPublic`, `dataSource`, plus the conveyor/queue markers -- `conveyor`/`queue` on `Stock`, `leakage`/`spreadflow`/`overflow` on `Flow`. This is load-bearing: the editor re-serializes a variable as a FULL upsert on any edit (`Editor.tsx` via `stockToJson`/`flowToJson`/`auxToJson`), so any compat field the conversion drops is silently stripped from the model the moment an unrelated field is edited. `Aux` and `Module` deliberately omit the conveyor markers (the engine's uniform Compat accepts them there, but no importer or editor produces them on those kinds). +- Sim results attach to variables as `data` through `groupSeriesByIdent(data, previous?)` (series grouped per variable ident; a variable whose Series objects are unchanged keeps `previous`'s array) and `projectAttachSeries(project, grouped, modelName)` (attaches those arrays by identity). `projectAttachData` groups afresh on every call. A host that re-renders the project often must attach a stable grouping: the diagram's sparklines memoize on the array's identity. - `Model.macroSpec?: MacroSpec` (`parameters`/`primaryOutput`/`additionalOutputs`) is set exactly when the model is a callable macro template (imported `:MACRO:` / XMILE ``). `macroSpecFromJson`/`macroSpecToJson` round-trip it; `additionalOutputs` is omitted from JSON when empty. Consumers gate macro-marked models out of module-reference UI (`@simlin/diagram`'s `isMacroModel`). ## Tests diff --git a/src/core/datamodel.ts b/src/core/datamodel.ts index cc0d1daf3..e78a43d50 100644 --- a/src/core/datamodel.ts +++ b/src/core/datamodel.ts @@ -75,6 +75,16 @@ export interface SimError { readonly details: string | undefined; } +// An engine advisory about a variable that is not a unit error: a +// Warning-severity diagnostic (for example a stock whose flow list repeats a +// flow). The variable still simulates, so a warning never stands in for its +// results. `details` is the engine's reason; the code is often the wire +// `Generic`, which says nothing on its own. +export interface VariableWarning { + readonly code: ErrorCode; + readonly details: string | undefined; +} + // A sketch-hygiene issue: the sketch connectors for a variable have drifted out // of sync with its equation. `missingConnector` -- the equation references // `ident` but no connector is drawn from it; `staleConnector` -- a connector is @@ -592,6 +602,9 @@ export interface Stock { // Optional so the many Variable literals that predate this feature stay valid; // absent and undefined are equivalent ("no connector issues"). readonly connectorErrors?: readonly ConnectorError[] | undefined; + // Engine advisories that are not unit errors, attached by the diagram layer. + // Optional like connectorErrors; absent and undefined are equivalent. + readonly warnings?: readonly VariableWarning[] | undefined; readonly uid: number | undefined; } @@ -626,6 +639,9 @@ export interface Flow { // Optional so the many Variable literals that predate this feature stay valid; // absent and undefined are equivalent ("no connector issues"). readonly connectorErrors?: readonly ConnectorError[] | undefined; + // Engine advisories that are not unit errors, attached by the diagram layer. + // Optional like connectorErrors; absent and undefined are equivalent. + readonly warnings?: readonly VariableWarning[] | undefined; readonly uid: number | undefined; } @@ -652,6 +668,9 @@ export interface Aux { // Optional so the many Variable literals that predate this feature stay valid; // absent and undefined are equivalent ("no connector issues"). readonly connectorErrors?: readonly ConnectorError[] | undefined; + // Engine advisories that are not unit errors, attached by the diagram layer. + // Optional like connectorErrors; absent and undefined are equivalent. + readonly warnings?: readonly VariableWarning[] | undefined; readonly uid: number | undefined; } @@ -691,6 +710,9 @@ export interface Module { // Optional so the many Variable literals that predate this feature stay valid; // absent and undefined are equivalent ("no connector issues"). readonly connectorErrors?: readonly ConnectorError[] | undefined; + // Engine advisories that are not unit errors, attached by the diagram layer. + // Optional like connectorErrors; absent and undefined are equivalent. + readonly warnings?: readonly VariableWarning[] | undefined; readonly uid: number | undefined; } @@ -702,11 +724,13 @@ export function variableIsArrayed(v: Variable): boolean { } export function variableHasError(v: Variable): boolean { - // Includes non-fatal warnings (unit errors, sketch-connector drift), matching - // how the diagram surfaces every variable problem with the same indicator. - // Simulatability is decided separately (engine.isSimulatable), so a - // connector-only warning never blocks a run. - return v.errors !== undefined || v.unitErrors !== undefined || v.connectorErrors !== undefined; + // Includes non-fatal warnings (unit errors, sketch-connector drift, engine + // advisories), matching how the diagram surfaces every variable problem with + // the same indicator. Simulatability is decided separately + // (engine.isSimulatable), so a warning never blocks a run. + return ( + v.errors !== undefined || v.unitErrors !== undefined || v.connectorErrors !== undefined || v.warnings !== undefined + ); } export function variableGf(v: Variable): GraphicalFunction | undefined { @@ -1240,7 +1264,11 @@ export function auxViewElementFromJson(json: JsonAuxiliaryViewElement, auxVar?: var: auxVar?.type === 'aux' ? auxVar : undefined, x: json.x, y: json.y, - labelSide: (json.labelSide ?? 'right') as LabelSide, + // An absent side is Center, as the engine reads it (json.rs + // label_side_from_string) and as every other element kind here defaults: + // any other default rewrites the stored side the first time a view is + // upserted, and draws a label the Rust renderer places at the center. + labelSide: (json.labelSide ?? 'center') as LabelSide, isZeroRadius: false, }; } @@ -2086,29 +2114,61 @@ export function projectToJson(project: Project): JsonProject { } export function projectAttachData(project: Project, data: ReadonlyMap, modelName: string): Project { - const model = defined(project.models.get(modelName)); + return projectAttachSeries(project, groupSeriesByIdent(data), modelName); +} - // Group every result series by its base variable ident. A scalar variable's - // series is keyed by the bare canonical ident; an arrayed variable's - // per-element series are keyed `ident[]` for any - // dimensionality (1-D `x[a]`, multi-D `x[a,b]`). Grouping by the ident before - // the first `[` attaches every element series -- so multi-dimensional - // variables are plotted too -- and matches whatever the simulation emitted - // rather than reconstructing keys from a Dimension's (original-case) - // subscripts, which avoids the element-name canonicalization mismatch - // entirely. - const seriesByIdent = new Map(); +/** + * Simulation result series grouped by the variable they belong to. A scalar + * variable's series is keyed by the bare canonical ident; an arrayed variable's + * per-element series are keyed `ident[]` for any + * dimensionality (1-D `x[a]`, multi-D `x[a,b]`) and grouped, in result order, + * under the ident before the first `[`. That attaches every element series -- + * so multi-dimensional variables are plotted too -- and matches whatever the + * simulation emitted rather than reconstructing keys from a Dimension's + * (original-case) subscripts, which avoids the element-name canonicalization + * mismatch entirely. + * + * `previous` is the grouping of an earlier result: a variable whose series are + * the same objects in the same order keeps that grouping's array. The array + * becomes the variable's `data`, and the diagram's sparklines memoize on its + * identity, so a variable whose results did not change must keep its array. + */ +export function groupSeriesByIdent( + data: ReadonlyMap, + previous: ReadonlyMap = new Map(), +): ReadonlyMap { + const grouped = new Map(); for (const [key, s] of data) { const open = key.indexOf('['); const ident = open === -1 ? key : key.slice(0, open); - const existing = seriesByIdent.get(ident); + const existing = grouped.get(ident); if (existing) { existing.push(s); } else { - seriesByIdent.set(ident, [s]); + grouped.set(ident, [s]); } } + const seriesByIdent = new Map(); + for (const [ident, series] of grouped) { + const before = previous.get(ident); + const unchanged = + before !== undefined && before.length === series.length && before.every((s, i) => s === series[i]); + seriesByIdent.set(ident, unchanged ? before : series); + } + return seriesByIdent; +} +/** + * `modelName`'s variables with their grouped result series (see + * `groupSeriesByIdent`) attached as `data`, by identity. A variable with no + * series is left unchanged. + */ +export function projectAttachSeries( + project: Project, + seriesByIdent: ReadonlyMap, + modelName: string, +): Project { + const model = defined(project.models.get(modelName)); const variables = mapValues(model.variables, (v: Variable) => { const series = seriesByIdent.get(v.ident); if (!series || series.length === 0) { diff --git a/src/core/tests/datamodel.test.ts b/src/core/tests/datamodel.test.ts index 7e8cb74ca..a0d4c7ef9 100644 --- a/src/core/tests/datamodel.test.ts +++ b/src/core/tests/datamodel.test.ts @@ -39,6 +39,8 @@ import { projectFromJson, projectToJson, projectAttachData, + projectAttachSeries, + groupSeriesByIdent, variableHasError, ErrorCode, } from '../datamodel'; @@ -1944,6 +1946,117 @@ describe('projectAttachData', () => { expect(v?.data).toBeUndefined(); }); + + it('projectAttachSeries attaches the grouped arrays by identity', () => { + const project = projectWith( + [arrayedAux('population', ['region'])], + [{ name: 'region', subscripts: ['boston', 'nyc'] }], + ); + const grouped = groupSeriesByIdent( + new Map([ + ['population[boston]', series('population[boston]', [10, 11])], + ['population[nyc]', series('population[nyc]', [20, 21])], + ]), + ); + const first = projectAttachSeries(project, grouped, 'main'); + const second = projectAttachSeries(project, grouped, 'main'); + const data = (p: Project) => defined(p.models.get('main')).variables.get('population')?.data; + expect(data(first)).toBe(grouped.get('population')); + // Attaching the same grouping again (a re-render) hands out the same array. + expect(data(second)).toBe(data(first)); + }); +}); + +describe('groupSeriesByIdent', () => { + const series = (name: string, values: number[]): Series => ({ + name, + time: new Float64Array([0, 1]), + values: new Float64Array(values), + }); + const a = series('a', [1, 2]); + const x1 = series('x[one]', [3, 4]); + const x2 = series('x[two]', [5, 6]); + + it('groups scalar and per-element series under the variable ident, in result order', () => { + const grouped = groupSeriesByIdent( + new Map([ + ['x[two]', x2], + ['a', a], + ['x[one]', x1], + ]), + ); + expect([...grouped.keys()].sort()).toEqual(['a', 'x']); + expect(grouped.get('a')).toEqual([a]); + expect(grouped.get('x')).toEqual([x2, x1]); + }); + + // Every arm of the reuse decision: a variable keeps the previous array only + // when its series are the same objects, the same number, in the same order. + const previous = groupSeriesByIdent( + new Map([ + ['a', a], + ['x[one]', x1], + ['x[two]', x2], + ]), + ); + const rows: ReadonlyArray<{ name: string; data: Array<[string, Series]>; keepsX: boolean }> = [ + { + name: 'the same series objects', + data: [ + ['a', a], + ['x[one]', x1], + ['x[two]', x2], + ], + keepsX: true, + }, + { + name: 'an equal but new series object', + data: [ + ['a', a], + ['x[one]', series('x[one]', [3, 4])], + ['x[two]', x2], + ], + keepsX: false, + }, + { + name: 'a series added', + data: [ + ['a', a], + ['x[one]', x1], + ['x[two]', x2], + ['x[three]', a], + ], + keepsX: false, + }, + { + name: 'a series removed', + data: [ + ['a', a], + ['x[one]', x1], + ], + keepsX: false, + }, + { + name: 'the series reordered', + data: [ + ['a', a], + ['x[two]', x2], + ['x[one]', x1], + ], + keepsX: false, + }, + ]; + for (const row of rows) { + it(`${row.name}: ${row.keepsX ? 'keeps' : 'replaces'} the variable's array`, () => { + const next = groupSeriesByIdent(new Map(row.data), previous); + expect(next.get('a')).toBe(previous.get('a')); + if (row.keepsX) { + expect(next.get('x')).toBe(previous.get('x')); + } else { + expect(next.get('x')).not.toBe(previous.get('x')); + } + }); + } }); // These tests pin the wire fields that an editor upsert (a full variable diff --git a/src/diagram/CLAUDE.md b/src/diagram/CLAUDE.md index 9afd9b944..a7f244ae0 100644 --- a/src/diagram/CLAUDE.md +++ b/src/diagram/CLAUDE.md @@ -42,19 +42,21 @@ The Editor's shortcuts (Delete/Backspace, Escape, undo/redo) are handled by a do ### Editor and Core Logic -- `Editor.tsx` -- Main model editor: a thin **view binding** over `ProjectController` (see below), a `React.memo` function component following the same imperative-shell pattern as `Canvas.tsx`. It owns only UI/presentation state (selection, dial/drawer/tool/showDetails/flowStillBeingCreated/variableDetailsActiveTab, the snapshot-image URL, and the toast `modelErrors` list) in one `useState` object with a class-parity merge `setState` helper; all project/engine coordination lives in the controller. Instance-style state (the controller, its unsubscribe, `lastNavResetSeq`, the `errorKey` WeakMap/counter, `liveSnapshotUrl`) lives in a single mutable `refs` object, and a `latest` ref refreshed each render is what escaped callbacks (the controller subscription, the global keydown listener, the snapshot-image load handlers) read so they observe CURRENT props/state. The controller is constructed once in a `refs`-init guard (NOT the `useState` initializer, which StrictMode double-invokes and would orphan a second controller); the empty-deps mount effect subscribes to its immutable snapshots (mirrored into one `controllerSnapshot` state field, so `React.memo` re-renders on snapshot identity change), kicks off `openInitialProject()`, and its cleanup disposes the controller and clears `refs.controller` so a StrictMode mount -> unmount -> mount builds a *fresh* controller on the second mount. The op-building handlers build JSON ops and hand them to `controller.applyPatch(patch, label)` / `controller.updateView(view)` / `controller.queueViewUpdate(view)`; navigation handlers call the controller's `drillIntoModule` / `navigateBack` / `navigateToLevel` and apply only the UI consequences (selection/showDetails/selectedTool) from the returned `NavigationOutcome`. Handlers passed to memo'd children (`Canvas`, `Status`, `UndoRedoBar`, `ZoomBar`, `BreadcrumbBar`, the detail panels) are `useCallback`-stable reading through `latest`, so those memos hold on the pan path. Optional `onSelectionChanged?: (idents: string[]) => void` prop fires from a post-commit `useEffect` keyed on the committed `selection` whenever it changes (used by `simlin-serve`'s `EditorHost` to forward selection state to backend listeners; `HostedWebEditor` in `src/app` does not subscribe). A `prevSelection` ref compared with `setsEqual` reproduces the old `componentDidUpdate` semantics: it does not fire on initial mount, and an undo/redo that rebuilds a content-equal (but referentially new) Set fires nothing. The effect reads the already-committed `selection`, so no `setTimeout(0)` deferral is needed; it never runs after unmount, so no timer-cancellation is needed. This fires for *every* committed selection change -- not just clicks routed through `handleSelection`, but also selections cleared by a delete and resets on module drill-in/back. (A normal undo/redo preserves the selection and fires nothing; the selection only resets when the viewed model disappears from the restored project -- the controller bumps `navResetSeq`, which a second post-commit effect observes to clear selection/details/tool.) Optional `onDeleteProject?: () => Promise` prop: when set and not `readOnlyMode`, `getDrawer()` forwards it to `ModelPropertiesDrawer` as the drawer's destructive "Delete project" action (hosts backed by a non-deletable project -- `simlin-serve`, embeds -- leave it undefined). Optional `showHomeLink?: boolean` (default true) is forwarded to the drawer to hide its "Exit" link for hosts with no `/` route (see Hosting Requirements). Optional `portalContainer?: HTMLElement` (default `document.body`) is provided through `PortalContainerContext` to every portaled surface (see Portals). Optional `initialViewport?: Viewport` (`{ viewBox, zoom }`) opens the root model's first view at that viewport instead of the stored one -- forwarded to the controller config, which splices it into the first published project and round-trips it view-only (no undo entry, no save; see `ProjectController.openInitialProject`) -- (a carried viewport also turns the Canvas's mount-time offscreen re-center off, `recenterOffscreenOnMount={initialViewport === undefined}`: the user's own framing is never yanked back, while a viewport that came from data keeps the issue #52 safety net) and optional `onViewportChange?: (modelName, viewport) => void` fires from a post-commit effect keyed on the controller snapshot whenever the viewed model's committed viewBox/zoom changes by value (first render with a view, each settled gesture, an idle resize, module navigation), never per gesture frame and never for a content-equal republish. Together they let a host that remounts the Editor on new project bytes (the notebook widget on a kernel push) carry the user's live pan/zoom -- which a pan alone never persists -- across the remount; hosts that mount once per project leave both unset (`tests/editor-viewport.test.ts`). The root `
` carries `data-simlin-editor-root`, `tabIndex={-1}` and capture-phase pointerdown/focus handlers that mark this instance active for the keyboard-scoping contract (Hosting Requirements; `editor-key-scope.ts`); the document keydown handler consults `editorOwnsKeyEvent` before acting and the unmount cleanup releases the root. The Editor's `getProject()`/`getEngineProject()` accessors delegate to the controller (kept for tests and internal reads; no external consumer uses them). -- `project-controller.ts` -- `ProjectController`: the headless, framework-free coordination layer (zero React, zero DOM; `setTimeout` is allowed for deferred sim/save/undo dispatch). Owns the eight responsibilities relocated from the Editor: (1) engine lifecycle (open protobuf/json, reopen-for-undo, dispose, orphan disposal), (2) the apply-patch -> serialize -> rebuild pipeline including optimistic view updates and `preserveLiveView`, (3) the save queue, (4) undo/redo history (`MaxUndoSize`), (5) sim runs with the LTM fallback, (6) the cached-error derivation (`convertErrorDetails`, `updateVariableErrors`, `refreshCachedErrors`), (7) version/generation bookkeeping (the fractional `+0.01`/`+0.001` render-cache key, plus `serverVersion`, the last server-acknowledged integer the save path sends), and (8) the module-navigation stack. State is published as an immutable `ProjectSnapshot` replaced wholesale on each change; `subscribe()`/`getSnapshot()` drive the Editor. Synchronous multi-step mutations coalesce into one notification via an internal `batch` depth counter. The `disposed` flag latches once and every async continuation short-circuits on it, releasing any engine it opened -- this is the single StrictMode-safety mechanism (the Editor creates a fresh controller per mount; `dispose()` per unmount). Transient errors go to the host via the `onError` config callback; the controller never owns presentation state. The `EngineApi`/`EngineModelApi`/`EngineRunApi` interfaces capture exactly the engine surface used, and `openProtobuf`/`openJson`/`save` are injected via config so the controller is unit-testable against a fake engine. +- `Editor.tsx` -- Main model editor: a thin **view binding** over `ProjectController` (see below), a `React.memo` function component following the same imperative-shell pattern as `Canvas.tsx`. It owns only UI/presentation state (selection, dial/drawer/tool/showDetails/flowStillBeingCreated/variableDetailsActiveTab, the snapshot-image URL, and the toast `modelErrors` list) in one `useState` object with a class-parity merge `setState` helper; all project/engine coordination lives in the controller. Instance-style state (the controller, its unsubscribe, `lastNavResetSeq`, the `errorKey` WeakMap/counter, `liveSnapshotUrl`) lives in a single mutable `refs` object, and a `latest` ref refreshed each render is what escaped callbacks (the controller subscription, the global keydown listener, the snapshot-image load handlers) read so they observe CURRENT props/state. The controller is constructed once in a `refs`-init guard (NOT the `useState` initializer, which StrictMode double-invokes and would orphan a second controller); the empty-deps mount effect subscribes to its immutable snapshots (mirrored into one `controllerSnapshot` state field, so `React.memo` re-renders on snapshot identity change), kicks off `openInitialProject()`, and its cleanup disposes the controller and clears `refs.controller` so a StrictMode mount -> unmount -> mount builds a *fresh* controller on the second mount. Every mutation is a controller edit. A canvas gesture arrives as ONE `GestureCommit` from the Canvas (the planner's elements, `nextUid`, selection and undo label, plus the state token and the base view it was planned on); `handleCommitGesture` drops it quietly while view edits are refused or when the base view's geometry no longer matches the view it would edit (`sameGeometry`), and otherwise enqueues it as a view edit carrying its token -- the Editor never plans gesture geometry itself. Create, delete and rename compute their next view through `planDelete` and `relabelVariable` and call `controller.enqueueViewEdit`. Details-panel, module and sim-spec handlers call `controller.enqueueModelEdit` with a builder that reads the COMMITTED variable or specs at dequeue (`enqueueVariableEdit` skips a submission identical to the latest pending one for the same variable and field set). Viewport changes (settled pan/zoom, resize, centering, zoom bar) call `controller.setViewport`. LaTeX and XMILE export run through `controller.query`. Handlers plan on the controller's CURRENT snapshot (`currentSnapshot()`), never on the React-state mirror, which lags a render behind an enqueue: two handlers in one tick would otherwise both plan on the view before the first, and the second full-replacement view would drop the first edit. Navigation handlers call the controller's `drillIntoModule` / `navigateBack` / `navigateToLevel` and apply only the UI consequences (selection/showDetails/selectedTool) from the returned `NavigationOutcome`. Handlers passed to memo'd children (`Canvas`, `Status`, `UndoRedoBar`, `ZoomBar`, `BreadcrumbBar`, the detail panels) are `useCallback`-stable reading through `latest`, so those memos hold on the pan path. Optional `onSelectionChanged?: (idents: string[]) => void` prop fires from a post-commit `useEffect` keyed on the committed `selection` whenever it changes (used by `simlin-serve`'s `EditorHost` to forward selection state to backend listeners; `HostedWebEditor` in `src/app` does not subscribe). A `prevSelection` ref compared with `setsEqual` reproduces the old `componentDidUpdate` semantics: it does not fire on initial mount, and an undo/redo that rebuilds a content-equal (but referentially new) Set fires nothing. The effect reads the already-committed `selection`, so no `setTimeout(0)` deferral is needed; it never runs after unmount, so no timer-cancellation is needed. This fires for *every* committed selection change -- not just clicks routed through `handleSelection`, but also selections cleared by a delete and resets on module drill-in/back. (A normal undo/redo preserves the selection and fires nothing; the selection only resets when the viewed model disappears from the restored project -- the controller bumps `navResetSeq`, which a second post-commit effect observes to clear selection/details/tool.) Optional `onDeleteProject?: () => Promise` prop: when set and not `readOnlyMode`, `getDrawer()` forwards it to `ModelPropertiesDrawer` as the drawer's destructive "Delete project" action (hosts backed by a non-deletable project -- `simlin-serve`, embeds -- leave it undefined). Optional `showHomeLink?: boolean` (default true) is forwarded to the drawer to hide its "Exit" link for hosts with no `/` route (see Hosting Requirements). Optional `portalContainer?: HTMLElement` (default `document.body`) is provided through `PortalContainerContext` to every portaled surface (see Portals). Optional `initialViewport?: Viewport` (`{ viewBox, zoom }`) opens the root model's first view at that viewport instead of the stored one -- forwarded to the controller config, which splices it into the first published project and round-trips it view-only (no undo entry, no save; see `ProjectController.openInitialProject`) -- (a carried viewport also turns the Canvas's mount-time offscreen re-center off, `recenterOffscreenOnMount={initialViewport === undefined}`: the user's own framing is never yanked back, while a viewport that came from data keeps the issue #52 safety net) and optional `onViewportChange?: (modelName, viewport) => void` fires from a post-commit effect keyed on the controller snapshot whenever the viewed model's committed viewBox/zoom changes by value (first render with a view, each settled gesture, an idle resize, module navigation), never per gesture frame and never for a content-equal republish. Together they let a host that remounts the Editor on new project bytes (the notebook widget on a kernel push) carry the user's live pan/zoom -- which a pan alone never persists -- across the remount; hosts that mount once per project leave both unset (`tests/editor-viewport.test.ts`). The root `
` carries `data-simlin-editor-root`, `tabIndex={-1}` and capture-phase pointerdown/focus handlers that mark this instance active for the keyboard-scoping contract (Hosting Requirements; `editor-key-scope.ts`); the document keydown handler consults `editorOwnsKeyEvent` before acting and the unmount cleanup releases the root. The same capture-phase pointerdown sets `refs.gestureLive` (cleared by window-level capture `pointerup`/`pointercancel`/`contextmenu` listeners and a window `blur` listener, added in the mount effect and removed in its cleanup) and flushes an open details panel's draft when the press lands outside the details slot (`data-simlin-details-slot`) and the undo/redo bar (`data-simlin-undo-redo`). The Canvas gets `newVariableName` (the controller's allocator) and `pressesDisabled` (`snapshot.undoRedoQueued`); `handleCreateVariable`/`handleRename` return a refusal message instead of enqueueing -- for a taken name, while an undo/redo is queued, and once the engine is unavailable -- which keeps the Canvas's name editor open. Optional `onReload?: () => void` is the Reload action of the one persistent notice shown while `snapshot.engineUnavailable` (default: reload the page). +- `project-controller.ts` -- `ProjectController`: the headless, framework-free coordination layer between the Editor and the engine (zero React, zero DOM, no timers). State: `committed` (the engine's last acknowledged project), the edit-class `queue` (view edits, model-only edits, viewport persists, undo/redo, engine queries, the initial open; the running item stays at the head until it settles), the live `viewport` per model, the `token`, the undo history (`MaxUndoSize`), `serverVersion`, the module-navigation stack, and the derived-state inputs maintenance refreshes (the engine's raw error list and simulatability, the sim series, per-model equation dependencies). The published `ProjectSnapshot` is immutable and replaced wholesale; its `project` is the RENDERED project (see "Rendered view rule" below) with render-time annotations: active-model equation/unit errors and advisories (`convertErrorDetails`, by kind and severity: a unit error is a unit error, any other Warning is an advisory in `variable.warnings`, which never hides the variable's chart, and the rest are equation errors; empty-starter `hasNoEquations`), sim series on 'main', and connector drift on the rendered view. `render()` memoizes on its inputs, so a republish that changes none of them (a save acknowledgment) keeps the project identity and the Canvas its caches. API: `openInitialProject`, `enqueueViewEdit`, `enqueueModelEdit`, `setViewport`, `undoRedo`, `query`, `requestSave`, `whenIdle`, the navigation methods, and the naming helpers `usedIdents`/`newVariableName`/`nameError`. Synchronous multi-step mutations coalesce into one notification via a `batch` depth counter. Transient errors go to the host via `onError`; the controller never owns presentation state. The `EngineApi`/`EngineModelApi`/`EngineRunApi` interfaces capture exactly the engine surface used, and `openProtobuf`/`openJson`/`save`/`now` are injected via config so the controller is unit-testable against a fake engine. +- `view-model-sync.ts` -- Pure `buildEditOps(committedModel, baseView, nextView)`: the model ops a view edit implies, evaluated at dequeue against the committed model, ending with the `upsertView` of the next view. In patch order: `renameVariable` for a named element whose uid survives with a different name (`from` the committed ident, `to` the typed name raw); `deleteVariable` for removed named elements whose variable exists and names no remaining element; upserts for created named elements (a created name that already exists throws `EditConflictError`); one `updateStockFlows` per stock whose lists change, from per-flow-end attachment deltas touching only flows and stocks that exist after the edit (committed or created by it, not deleted), carrying both full committed lists with renames applied, deleted variables omitted, and duplicates never added. Every ident is derived from an element's `name` (what the engine stores and matches canonically), never from its `ident` field, which a caller-built element need not keep in step. +- `plan-delete.ts` -- Pure `planDelete(view, selection)`: the view a delete produces -- selected elements, the links touching anything removed, aliases of removed elements, clouds of removed flows removed; endpoints of surviving flows on removed stocks become new clouds; a selected cloud whose flow survives is ignored. Model ops come from `buildEditOps`. +- `variable-names.ts` -- Pure `allocateVariableName(base, used)` ("New Variable", "New Variable 1", ...) and `nameCollisionError(newName, currentIdent, used)`, matching canonically; the controller supplies `used`. - `ModelPropertiesDrawer.tsx` -- Hamburger-menu drawer: model name, sim-spec fields (start/stop/dt/time units), "Download model", the "Exit" link to `/` (omitted when `showHomeLink` is false), and -- when `onDelete` is supplied -- a `DeleteProjectButton`. - `DeleteProjectButton.tsx` -- Low-emphasis destructive button + modal confirmation (`Dialog`) that calls `onDelete`; a rejected `onDelete` keeps the dialog open with the error message, a resolved one means the host has navigated away. Kept separate so the confirmation state lives in one small, reusable place. - `VariableDetails.tsx` -- Variable properties/equation panel (stocks, flows, auxes) - `ModuleDetails.tsx` -- Module properties panel: model reference selector, input wiring table, output ports, units/docs editors - `BreadcrumbBar.tsx` -- Breadcrumb navigation: back arrow + breadcrumb trail when inside a module, hamburger menu at root - `ModuleIcon.tsx` -- Module tool icon for the SpeedDial toolbar -- `group-movement.ts` -- Group manipulation and movement logic -- `flow-attach.ts` -- Pure functional core for flow attach/reattach/create (`computeFlowAttachment`): the source/sink endpoint and `updateStockFlows` op builders extracted from `Editor.handleFlowAttach`. Returns the new elements, model ops, and selection; the Editor shell applies them. Also `growEndpointDrag` -- the shared live-preview routing (via `UpdateCloudAndFlow`, the same function the commit uses) that grows a flow whose cloud endpoint is being dragged: it drives BOTH flow creation and dragging an existing flow's cloud endpoint (source or sink), so the two feel identical (the flow line, arrowhead, and valve all track the cursor, not just the valve). `growInCreationFlow` is a thin wrapper over it for the creation call site. Canvas's `deriveRenderState` invokes it during a `movingEndpoint` drag and swaps the rerouted flow into `selectionUpdates`. -- `connector-sync.ts` -- Pure functional core (`computeConnectorErrors`) for the sketch-connector / equation consistency check: given a view's elements, the model variables, and each target's equation-derived dependency idents, it returns per-variable `ConnectorError`s (`missingConnector` -- equation references a drawable variable with no connector; `staleConnector` -- a connector whose source the equation does not use). The imperative shell is `ProjectController.attachConnectorErrors`, which sources the dependency idents from the engine's per-variable `getIncomingLinks` and attaches the results as `variable.connectorErrors` (surfaced by the same warning dot as `errors`/`unitErrors` via `variableHasError`, and listed in `VariableDetails`). It runs on the view that will actually be RENDERED -- in `updateProject` that means AFTER `preserveLiveView` swaps in the live optimistic view (which can be newer than the engine snapshot, e.g. a just-drawn connector), so it is deliberately NOT tail-called from `updateVariableErrors`; computing against the stale engine view would flag a phantom missing/stale connector. **Invariant**: the equation truth MUST come from `Model.getIncomingLinks` (per-variable), NOT `Model.getLinks` -- `getLinks`/`model_causal_edges` *adds* structural flow->stock integration edges (which have no connector) and *omits* a stock's initial-equation deps, so it would produce false positives/negatives; `getIncomingLinks` already excludes builtins/TIME, structural flow<->stock edges, and dotted module-output refs. Modules are excluded as check targets (input wiring lives in the references table, not always drawn) and connectors whose source is a module are exempt from the stale check (their `module.output` dep is the dotted ident `getIncomingLinks` drops). A target whose equation has a FATAL parse/compile error (`variable.errors` non-empty) is skipped: its AST did not parse so the engine reports no deps, which would spuriously flag every inbound connector as stale -- this relies on `updateVariableErrors` annotating `errors` before `attachConnectorErrors` runs on the same project (every caller orders them that way). Unit-only errors do NOT skip (the AST is fine, deps are authoritative). The brand-new ALL-empty starter model is a separate case: `updateVariableErrors` takes its `hasNoEquations` branch and skips annotating `errors`, so the per-variable skip cannot catch it -- `attachConnectorErrors` instead early-returns on `project.hasNoEquations`, suppressing the wall of stale warnings while a fresh sketch is drawn before any equations are written. -- `rename-ops.ts` -- Pure functional core for variable rename: `buildVariableRenameOps(view, oldName, newName)` builds the `renameVariable` + paired `upsertView` ops for `Editor.handleRename`. The rename `to` is the user's typed name RAW (newlines encoded, NOT canonicalized) -- the engine preserves display spellings verbatim and matches canonically (issues #890/#906), so canonicalizing `to` would downgrade the stored display name and a case-only rename would restamp a preserved spelling. `from` stays the canonical ident, matching TS-side keying everywhere else. -- `selection-logic.ts` -- Selection-set arithmetic (mouse-down/up selection decisions, deferred-single-select, reattachment override, pointer-state reset). The pure slice the Canvas interaction model composes. +- `flow-geometry/` -- The one owner of flow geometry (pure, never mutates its inputs): face attachment and terminals (`stockTerminal`, `freeTerminal`, `flowTerminals`), the G2-G6 classification (`flowFault`), path helpers (`translate`, `slideValve`), `route`/`routeEnd` (an orthogonal path between two terminals, or one end re-routed to a moved terminal; both take the view's stocks as obstacles: a candidate through a non-terminal stock is `obstructed` and ranks as crossing, a preserved or pinned tail through one is refused, and the released search generates detours before settling for one, but an obstruction is never a fault, so `flowFault` ignores it and a route always exists), `offsetSegment`/`segmentHold` (a segment dragged perpendicular, stepping to the nearest clear coordinate around any stock in its context) and `heal` (repairs an imported flow the first time an edit routes it). Every function reads the gesture's BASE flow rather than a previous frame, so any frame can be evaluated at any pointer position and preview cannot diverge from commit. Its constants (`CORNER_CLEARANCE`, `MIN_SEGMENT`, `MIN_SINK_SEGMENT`, `VALVE_CLAMP_MARGIN`, `PIPE_SPACING`, `GEOMETRY_EPSILON`) are the thresholds of the invariants G1-G8 stated in `docs/design-plans/2026-09-10-diagram-editing-core.md` and checked by `tests/support/flow-invariants.ts`. +- `gesture-planner/` -- Pure planner for every canvas gesture (zero React/DOM). `classifyPress` (`classify.ts`) maps a press -- the hit element and part (body, arrowhead, source grip, label, module double-click, name-editor overlay), the armed tool, modifiers, pointer type and count, read-only, `pressesDisabled`, whether a gesture is already live -- to one outcome: ignore, pinch, abort, commit the name, drill in, edit a name, select, or start a `Gesture` (moveSelection | slideValve | offsetSegment | flowEndpoint | linkEndpoint | linkArc | createFlow | createLink | createElement | label | rubberBand | pan) with its press-time selection and the selection a click settles to; a pipe press latches (`latchGesture`) into offsetSegment when the first move past the threshold is perpendicular-dominant, else slideValve, and keeps it. `planGesture(input)` (`plan.ts`, with `flow-ends.ts` and `links.ts`) returns one frame's `GesturePlan`: the elements the preview renders AND the commit saves (one value, so preview equals commit), `nextUid`, the drop `target` and its validity, `commit` ('none' | 'select' | 'edit'), the selection, a name-editor handoff, whether a click opens details, and the undo label. A frame within the click threshold (`beyondThreshold`, in screen pixels) changes nothing; flow geometry comes only from `flow-geometry/`; a drop target is valid exactly when the routed flow has no `flowFault` and the semantic rules hold (source and sink are different stocks, the stock's variable exists in the rendered model, which holds each pending create's variable); an invalid drop, or a move whose routed flows cannot hold the invariants, commits nothing. Loose imported flow ends get clouds when routed (`attachLooseEnds`), new names come from the injected allocator, and links whose endpoints moved are updated once from the final elements (`followLinks`). `sameGeometry(base, current)` (`base.ts`) is the by-value view comparison a live gesture aborts on. +- `connector-sync.ts` -- Pure functional core (`computeConnectorErrors`) for the sketch-connector / equation consistency check: given a view's elements, the model variables, and each target's equation-derived dependency idents, it returns per-variable `ConnectorError`s (`missingConnector` -- equation references a drawable variable with no connector; `staleConnector` -- a connector whose source the equation does not use). The imperative shell is `ProjectController`: a `connectors` maintenance item fetches the active model's per-variable `getIncomingLinks`, and `annotateConnectors` attaches the results at render time as `variable.connectorErrors` (surfaced by the same warning dot as `errors`/`unitErrors` via `variableHasError`, and listed in `VariableDetails`). It runs on the RENDERED view, so a connector drawn by a pending edit counts at once; computing against the committed view would flag a phantom missing/stale connector. **Invariant**: the equation truth MUST come from `Model.getIncomingLinks` (per-variable), NOT `Model.getLinks` -- `getLinks`/`model_causal_edges` *adds* structural flow->stock integration edges (which have no connector) and *omits* a stock's initial-equation deps, so it would produce false positives/negatives; `getIncomingLinks` already excludes builtins/TIME, structural flow<->stock edges, and dotted module-output refs. Modules are excluded as check targets (input wiring lives in the references table, not always drawn) and connectors whose source is a module are exempt from the stale check (their `module.output` dep is the dotted ident `getIncomingLinks` drops). A target whose equation has a FATAL parse/compile error (`variable.errors` non-empty) is skipped: its AST did not parse so the engine reports no deps, which would spuriously flag every inbound connector as stale -- this relies on `annotateErrors` annotating `errors` before `annotateConnectors` runs in the same render. Unit-only errors do NOT skip (the AST is fine, deps are authoritative). The brand-new ALL-empty starter model is a separate case: `annotateErrors` takes its `hasNoEquations` branch and skips annotating `errors`, so the per-variable skip cannot catch it -- `annotateConnectors` instead early-returns on `project.hasNoEquations`, suppressing the wall of stale warnings while a fresh sketch is drawn before any equations are written. +- `rename-ops.ts` -- Pure `relabelVariable(view, oldName, newName)`: a rename's next view. `RenameVariable` never renames view elements, so a rename is an edit with a next view and `buildEditOps` derives the `renameVariable` op from the relabeled element. The new name is the user's typed name RAW (newlines encoded, NOT canonicalized) -- the engine preserves display spellings verbatim and matches canonically (issues #890/#906), so canonicalizing it would downgrade the stored display name and a case-only rename would restamp a preserved spelling. Elements are matched by canonical NAME and take the new name's ident, so a rename of an element whose rename or create is still pending finds it under the name it renders with (a stale-ident match silently relabeled nothing). While the rename is pending, the controller's rendered model names the committed variable by the new ident (see "Rendered view rule"), which is what keeps the canvas and the details panel resolving it. - `arc-utils.ts` -- Arc geometry helpers (`radToDeg`, `degToRad`, arc math) - `keyboard-shortcuts.ts` -- Keyboard shortcut handling (`detectUndoRedo`, `isEditableElement`, `isNewlineChord`) - `editor-key-scope.ts` -- Pure `editorOwnsKeyEvent` (which Editor instance a document-level key event belongs to, over its `composedPath()`) plus the one page-global slot (`globalThis`, registry symbol) recording the most recently active Editor root, shared by every copy of this module on the page; see the keyboard scoping contract under Hosting Requirements @@ -70,18 +72,20 @@ The Editor's shortcuts (Delete/Backspace, Escape, undo/redo) are handled by a do - `module-details-utils.ts` -- Module detail utilities: `countModelInstances`, `wouldCreateCycle` (DFS cycle detection), `getAvailableModels` (excludes macro-marked models -- a macro is materialized by the engine, never a selectable module-reference target; macros.AC6.6), `getInputPorts`, `getPublicVariables` - `module-wiring.ts` -- Module reference array manipulation: `addReference`, `removeReference`, `updateReferenceSrc`, `updateReferenceDst`, `getAvailableSrcVariables` - `module-warning.ts` -- `anyModuleHasModelReference`: suppresses warning dots when no modules have references yet (new model sketching scenario) -- `merge-live-view.ts` -- `preserveLiveView(incoming, live, modelName)`: merges an engine-rebuilt Project with the live one, keeping the active model's latest optimistic view (round-tripped through JSON to re-link element/stock refs). Orchestrated by `ProjectController.updateProject` to avoid the diagram snapping back to a stale engine view during a pan. - `project-history.ts` -- `advanceProjectHistory(state, snapshot, maxSize)`: newest-first undo history; records a snapshot at the head and discards the redo branch when editing after an undo. Used by `ProjectController` (`MaxUndoSize` = 5). ### Test Helpers (`tests/`) -- `fake-engine.ts` -- `makeFakeEngine`/`makeControllerConfig`/`validProjectJson`/`fakeRun`: an in-memory fake of the `EngineApi`/`EngineModelApi`/`EngineRunApi` surface plus a wired `ProjectControllerConfig`, so `ProjectController` can be unit-tested (`project-controller.test.ts`) without WASM or jsdom. Scripts serialized outputs, errors, simulatability, sim runs, and patch failures; records applied patches, dispose calls, and saves. +- `fake-engine.ts` -- `makeFakeEngine`/`makeControllerConfig`/`makeGate`/`validProjectJson`/`fakeRun`: an in-memory fake of the `EngineApi`/`EngineModelApi`/`EngineRunApi` surface plus a wired `ProjectControllerConfig`, so `ProjectController` can be unit-tested (`project-controller.test.ts`) without WASM or jsdom. Scripts serialized outputs, errors, simulatability, sim runs, per-patch failures and latency (`applyPatchGate`, opened by hand through `makeGate`), and a hook that moves the scripted serialization to the patched state; records applied patches, the engine-call sequence, the most calls in flight at once, dispose calls, saves, and the bytes each reopen was given. +- `editor-engine-races.test.ts` -- The Editor (Canvas mocked, details panels real) against the real engine with every patch delayed, porting the commit-path audit's race scenarios; after each, the engine's serialized project must hold M1/M3, static stock/view agreement and "nothing invisible simulates", and the rendered view must equal the engine's. +- `editor-gestures-engine.test.ts` -- One row per gesture kind that commits a view edit: planned by the production planner on the Editor's rendered view, committed through `onCommitGesture`, and checked on the real engine's serialized project (M1-M3, strict flow invariants over the routed flows, rendered view equals the engine's). +- `tests/support/gesture-fixtures.ts` -- Planner scenes loaded through the production `modelFromJson` (a variable per named element, stock lists derived from the view's attachments, optional arrayed auxes), element builders, `planInput` with the Canvas's defaults, and `committedReport` (strict flow invariants over the flows a plan routed, plus referential integrity). +- `canvas-gesture-harness.tsx` -- Mounts the real Canvas in jsdom with pointer helpers; by default it applies each `onCommitGesture` commit to its view, model and selection as the Editor would (throwing if the commit's base view is not the rendered one), and never bumps anything on a selection. ### Drawing (`drawing/`) -- `Canvas.tsx` -- Main canvas and rendering engine (the imperative shell), a `React.memo` function component. Supports module creation tool (`selectedTool: 'module'`), double-click drill-in on modules (`onDrillIntoModule`), and module warning suppression. It owns the DOM/continuous concerns -- pointer capture, screen->canvas coordinate conversion, the raw multi-touch `activePointers` map, the momentum rAF loop and velocity tracking, the native wheel / Safari-gesture listeners, the Slate editing value, and all rendering -- and composes the pure interaction logic in `canvas-interaction.ts`. The former `CanvasState` fields are per-field `useState`; the former instance fields (`elements`/`derived` caches, `cachedVersion`, `activePointers`, momentum/velocity, `pointerId`, `mouseDownPoint`, `selectionCenterOffset`, `prevSelectedTool`, the `ResizeObserver`) live in a single `refs` ref object. A `latest` ref, refreshed synchronously every render with the current props + discrete/continuous state, is what escaped callbacks (native wheel/gesture listeners, the momentum rAF loop, the `ResizeObserver`, the deferred tool-change name-commit) read so they observe CURRENT values -- exactly as `this.props`/`this.state` always did; React-event handlers read it too for a uniform "event-time reads go through `latest`" rule. `buildSelectionMap` is a module-level exported function (formerly the static `Canvas.buildSelectionMap`). Mount/unmount (formerly `componentDidMount`/`componentWillUnmount`) is one empty-deps `useEffect` whose cleanup is symmetric so a StrictMode mount/unmount/mount cycle leaves no stuck listeners or running rAF. `React.memo` replaces the old `PureComponent` shallow-prop gate (state changes always re-render in both). Render-phase purity: `deriveRenderState()` is the single function that writes the render caches (`refs.elements`, `refs.cachedVersion`, `refs.derived`); every element-rendering helper only reads `refs.derived` (see invariant below). -- `canvas-interaction.ts` -- Pure, table-tested model of the Canvas DISCRETE interaction state machine (zero React/DOM). Exposes the tagged-union `InteractionState` (idle | panning | dragSelecting | movingSelection | movingEndpoint | movingLabel | editingName | pinching), the semantic `InteractionEvent`/`InteractionEffect` types, `reduceInteraction(state, event, ctx)`, and the pure geometry/decision helpers the shell composes: `decideMouseDownSelection`/`resolveDeferredSelection` (delegating to `selection-logic.ts`), `labelSideForPointer` (label-side quadrants), `computeDragSelection`/`isInDragSelectRect` (rubber-band membership), and `isDrag` (click-vs-drag threshold). `CanvasState` carries a single `interaction: InteractionState` field (the former bag of mutually-exclusive booleans and loose pinch/label/deferred fields was deleted). The reducer owns the mode transitions that are NOT geometry-dominated: the empty-canvas press (pan vs rubber-band drag-select), the creation tools, the flow tool, pinch enter/exit, and label-drag start; the shell raises the matching event and executes the returned effects. Element / arrowhead / source press resolution is intentionally NOT in the reducer -- it is geometry-dominated (hit element, cloud-vs-flow reattachment, link/flow-tool staging, the deferred-single-select dance) and lives in the shell's `handleSetSelection`, which composes the pure helpers (`decideMouseDownSelection`, `resolveSelectionForReattachment`) and constructs the union variant directly. Pointer-UP RESOLUTION likewise stays in the shell by design: `handlePointerCancel` reads `state.interaction`, composes the pure helpers, and constructs the next state directly. The `editingName` variant's `onPointerUp` flag separates "the inline editor is showing NOW" (the shell's `showingNameEditor` accessor, `onPointerUp === false`) from the "start editing once the creation drag ends" staging handoff (`onPointerUp === true`, set during an aux/stock/module creation drag while the element's own label still renders). Continuous pinch/pan/momentum physics, the Slate editing value, and the concrete in-creation element (`CanvasState.inCreation`, the single source of truth for movingEndpoint's creation companion) remain shell-internal companions outside the union. -- `Flow.tsx` -- Flow/arc visual rendering +- `Canvas.tsx` -- Main canvas and rendering engine: the imperative shell over `gesture-planner/`, a `React.memo` function component. Supports module creation tool (`selectedTool: 'module'`), double-click drill-in on modules (`onDrillIntoModule`), and module warning suppression. It owns the DOM/continuous concerns -- pointer capture, screen->canvas coordinate conversion, the raw multi-touch `activePointers` map and pinch, the momentum rAF loop and velocity tracking, the native wheel / Safari-gesture listeners, the Slate name editor, and all rendering -- and exactly ONE live gesture (`refs.gesture`, mirrored into state): the classified `Gesture`, its pointer id and type, the press and current points in model coordinates, and the base view, state token, selection and click selection captured at press. Every press goes through `classifyPress`, and presses are ignored while `pressesDisabled`. Each frame renders `planGesture`'s elements, draft, drop target and selection while the gesture is valid (a pan always is; otherwise the token and the view's geometry by value, `sameGeometry`, must match the press); pointer-up latches and plans at the release point and hands a commit 'edit' to the Editor as ONE `onCommitGesture(GestureCommit)`, a 'select' to `onSetSelection`, a handoff to the name editor and a details click to `onShowVariableDetails`, resetting the gesture in a `finally`. A gesture ends without committing on Escape, pointercancel, a second pointer (a touch pinch takes over), a lost release (a mouse move with no button down; the pointer is forgotten too, so a later touch is not counted as a second finger), a token change, a geometry change, or a subject missing from the view. A press captures the pointer on the `` root, never on the pressed node: a plan can remove the pressed element from the preview (a valid drop deletes the dragged cloud), which would release a capture it held and lose a release over chrome. The name editor (`refs.nameEdit`: its uid, the draft element of a create, whether a drawn flow awaits its name) is separate from the gesture and survives republishes; one naming an element the view no longer holds (a refused or rolled-back create) closes quietly without touching the selection. The callbacks handed to the memo'd element components (`handleSetSelection`, `handleLabelDrag`, `handleEditConnector`, `handleModuleDoubleClick`) are `useCallback`-stable and dispatch through a handlers ref to this render's implementations, so a drag frame re-renders only the elements whose props change (`tests/canvas-render-stability.test.tsx`). A `latest` ref, refreshed synchronously every render with the current props + state, is what escaped callbacks (native wheel/gesture listeners, the momentum rAF loop, the `ResizeObserver`, the deferred tool-change name-commit) read so they observe CURRENT values. Mount/unmount is one empty-deps `useEffect` whose cleanup is symmetric so a StrictMode mount/unmount/mount cycle leaves no stuck listeners or running rAF. Render-phase purity: `deriveRenderState()` is the single function that writes the render caches (see invariant below). +- `Flow.tsx` -- Flow rendering: the pipe, valve, arrowhead, source grip and label; a press on any part reports the element and the part to the Canvas, which classifies it. Also the pure render helpers `retractFinalPointIntoCloud`, `finalSegmentAngle` and `flowBounds`. A flow renders the same while it is dragged as at rest, so the last preview frame equals the committed frame. - `Connector.tsx` -- Connection/link rendering and arc geometry (`computeLinkCreationArc`) - `Stock.tsx` -- Stock visualization - `Auxiliary.tsx` -- Auxiliary variable rendering @@ -100,30 +104,34 @@ Notable semantics: `SpeedDial`'s open dial is a `role="group"` of directly-tabba ## Invariants -Most project/engine invariants now live in `ProjectController` (`project-controller.ts`); the Editor-side ones describe rendering and UI-state consequences. Both are load-bearing. - -- **Live viewport ownership** (Canvas, issue #707): during a viewport gesture -- drag-pan, momentum coast, wheel/trackpad pan+zoom, pinch -- the Canvas holds offset+zoom in local `liveViewport` state and renders from it, notifying the host (`onViewBoxChange`) exactly **once, on settle** (pan release with no momentum, the momentum coast's natural end, pinch exit, or the `DEFERRED_COMMIT_DELAY_MS` trailing-debounce for wheel). The former per-frame engine round-trip is gone. `getCanvasOffset()`/`getCanvasZoom()` resolve from `liveViewport` when set, else `props.view` -- and every read of the STORED zoom goes through `getViewZoom()`, which heals a value outside `viewport.ts`'s `[MIN_ZOOM, MAX_ZOOM]` (`isRenderableZoom`) to 1 before any transform, threshold, or commit consumes it. Gestures cannot produce such a value; it comes from data (an unset 0, or a file that recorded a percentage where a factor belongs -- `view.zoom` is a FACTOR, 1.0 = 100%, and the engine's XMILE reader converts the spec's percentage), so it is treated as "no usable zoom" and the mount-time fit persists the healed 1 rather than clamping to an edge that is equally unreadable. `tests/canvas-zoom-validity.test.tsx` pins this; the single settle commit goes through `commitLiveViewport()`, which sources viewBox width/height from the live `svgSize` so a mid-gesture resize settles with the current dimensions. The viewport math is the pure `drawing/viewport.ts`; the shell owns the rAF loop, the `refs.deferredCommitTimer` debounce, and screen->canvas mapping. A drag-pan anchors against `refs.panBaseOffset` (captured at press) so a pan that interrupts a coast does not snap back to the last committed viewBox. Interruptions (a new pointer-down / wheel) never commit and never clear `liveViewport`: the new gesture inherits it (via `panBaseOffset` / the pinch reference reads) and commits the combined result. The `refs.deferredCommitTimer` is the safety net for an **orphaned** live viewport -- one left by a wheel gesture (no end event) or by a coast a non-viewport press interrupted: its guarded callback commits only when no pan/pinch/coast is active, so a plain click between a scroll/coast and the timer still persists the viewport instead of stranding it, with no double commit. A mid-gesture resize updates only `svgSize` (the gesture keeps the offset; no re-centering shift to fight or be discarded); an idle resize re-centers and commits immediately. Embedded mode is viewport-inert (it draws to tight element bounds and ignores viewBox/zoom). These gestures still ultimately call the controller's `queueViewUpdate`, just once per gesture instead of per frame. -- **External view overrides a live gesture** (Canvas, issue #707): a `useEffect` keyed on `[props.view, liveViewport]` compares `props.view`'s offset/zoom VALUE against `refs.viewBaseline` (tracked while idle). While a gesture is live, `props.view` is expected to stay put (a gesture never commits mid-flight), so any value change seen with `liveViewport` still set is external (centerVariable, module navigation, undo) and drops the live viewport + cancels pending momentum/deferred commits -- the external view wins, with no stray commit. A self-commit clears `liveViewport` in the same React commit as its optimistic `props.view` update, so it is never misread as external; comparing by value (not snapshot identity) ignores a content-equal republished view. If a pointer-driven viewport gesture (drag-pan or pinch) is still physically in progress when this fires, it is also abandoned (interaction -> idle; `panBaseOffset`/`mouseDownPoint`/`pointerId`/`activePointers` cleared) -- otherwise a continued move would recreate `liveViewport` from the stale press-time anchor and the pointer-up could commit that abandoned gesture back over the external view. -- **Optimistic view updates** (controller): `updateView()`/`queueViewUpdate()` call `applyOptimisticView()` (synchronous snapshot replace of the active model's view + `projectVersion += 0.001`) *before* awaiting the engine round-trip. Any new view-modifying handler must go through these controller methods to avoid flicker. -- **updateProject preserves the live view** (controller): `ProjectController.updateProject()` rebuilds `project` from the engine's serialized JSON, then merges via `preserveLiveView()` so the active model's view comes from the live snapshot (the most recent optimistic view). Without this, a slow engine round-trip racing with a newer pan/move would snap the diagram back to the engine's older view. The live view is round-tripped through JSON to re-link element `var` refs and stock inflow/outflow UIDs against the incoming variables. -- **Viewport-only updates never record undo history; discrete element edits do** (controller): there are two view-mutating paths and they record differently. The `queueViewUpdate` path (pan/zoom/momentum/resize, and the internal navigation/replay viewport restores) calls `updateProject(serialized, { recordHistory: false, scheduleSave: false })` -- it refreshes `project` and bumps `projectVersion` but must NEVER touch `projectHistory`/`projectOffset`: viewBox/zoom are serialized into the protobuf, so recording them would let a single momentum flick evict every real edit from the `MaxUndoSize` (5) buffer. The `updateView` path is for DISCRETE element/structure edits (create, delete, element/group move, label move, flow/link attach); each such edit passes `{ recordHistory: true }` so it becomes individually undoable, producing exactly one entry per edit. For the delete/flow-attach/create handlers that apply a content patch via `applyPatchOrReportError` BEFORE calling `updateView`, the snapshot recorded in that final `updateView` captures the engine state after BOTH the content patch and the view update -- one entry, no double-recording. Bare `updateView()` (no opts) stays non-recording. All recorded edits go through `advanceProjectHistory` (project-history.ts), which discards the redo branch when editing after an undo. -- **Version vs generation bookkeeping** (controller): three counters with disjoint jobs. `projectVersion` is PURELY the fractional render-cache key (`+0.01` on content edits/undo, `+0.001` on view-only updates; Canvas keys render caches off it) -- it never resets and carries no server meaning. `serverVersion` is the last server-ACKNOWLEDGED integer version: seeded from `initialProjectVersion`, advanced only by a successful save's returned version, and the SOLE source of the `currVersion` a save sends; local edits, view updates, and undo/redo never move it (undo restores older content, but the server still holds the version it last acked). Deriving the save version from `projectVersion` was issue #958: ~100 unsaved edits drifted the fraction past the next integer, producing bogus 409s after session recovery and a silent-overwrite hazard on version collision. `projectGeneration` increments on every history-recording edit and on undo/redo -- that is, both model-content edits (equation/table/module/sim-specs/rename via `applyPatch`/`applyPatchAndRefresh`) AND discrete layout edits (element/label move, flow/link attach, create, delete) that call `updateView` with `recordHistory: true`. It never increments on the viewport-only stream (`queueViewUpdate`: pan/zoom/momentum/resize) or on save acknowledgments. -- **Details panels are keyed by `projectGeneration`, not `projectVersion`** (Editor render): VariableDetails/ModuleDetails seed their Slate editors from props in **lazy `useState` initializers** (one run per mount, the function-component equivalent of the old constructor seed), so key-driven remounts -- not prop-sync effects -- are what refreshes them. The Editor builds the panel key from `controllerSnapshot.projectGeneration`; keying on `projectVersion` would remount an open panel on every pan frame, discarding in-progress edits. Discrete layout edits (element/label move, flow/link attach) now bump `projectGeneration` and so remount an open panel; this is safe because a canvas-driven edit first blurs the side-panel editor, which commits its in-progress text (the same blur-commit the click-away flows already rely on) before the move records. -- **Save queue releases `inSave` in a `finally` block** (controller): a thrown save (e.g. host-side network failure) must not leave `inSave === true`, otherwise every subsequent edit silently queues forever. A save requested while one is in flight queues exactly one flush; each attempt (including the queued retry) reads the live `serverVersion` at flush time, so a save that errored leaves the still-valid version in place for the next attempt and a successful one hands its returned version to the flush. -- **Engine lifecycle / StrictMode safety** (controller + Editor): the controller's `disposed` flag latches once; every async continuation short-circuits on it and releases any engine it opened (collapsing the old `unmounted`/`openInitialProjectTimer`/`undoRedoTimer` machinery). The Editor constructs a controller once in its `refs`-init guard and its mount effect rebuilds one whenever a prior cleanup disposed and cleared it, so a React 18 StrictMode mount -> unmount -> mount cycle builds a *fresh* controller on the second mount. -- **Module navigation stack** (controller): the controller owns `modelName` and an immutable `modelStack` of `ModuleStackEntry` (each storing the child model name, module ident, and the parent's selection/viewBox/zoom). All navigation goes through `pushModule`/`popModule`/`navigateToLevel` pure functions; the controller's navigation methods restore the parent viewport internally via `queueViewUpdate` (its `modelName` updates synchronously first, so `getView()` resolves to the restored model with no deferral) and the model-scoped error cache via `refreshCachedErrors`. They return a `NavigationOutcome` with the selection the Editor should adopt. The undo-driven navigation reset (restored project lacks the viewed model -> reset to 'main') bumps `navResetSeq`; a post-commit `useEffect` in the Editor clears selection/details/tool exactly once per bump (an ordinary undo preserves them). +The project/engine invariants live in `ProjectController` (`project-controller.ts`); the Editor-side ones describe rendering and UI-state consequences. Both are load-bearing. + +- **Live viewport ownership** (Canvas, issue #707): during a viewport gesture -- drag-pan, momentum coast, wheel/trackpad pan+zoom, pinch -- the Canvas holds offset+zoom in local `liveViewport` state and renders from it, notifying the host (`onViewBoxChange`) exactly **once, on settle** (pan release with no momentum, the momentum coast's natural end, pinch exit, or the `DEFERRED_COMMIT_DELAY_MS` trailing-debounce for wheel). The former per-frame engine round-trip is gone. `getCanvasOffset()`/`getCanvasZoom()` resolve from `liveViewport` when set, else `props.view` -- and every read of the STORED zoom goes through `getViewZoom()`, which heals a value outside `viewport.ts`'s `[MIN_ZOOM, MAX_ZOOM]` (`isRenderableZoom`) to 1 before any transform, threshold, or commit consumes it. Gestures cannot produce such a value; it comes from data (an unset 0, or a file that recorded a percentage where a factor belongs -- `view.zoom` is a FACTOR, 1.0 = 100%, and the engine's XMILE reader converts the spec's percentage), so it is treated as "no usable zoom" and the mount-time fit persists the healed 1 rather than clamping to an edge that is equally unreadable. `tests/canvas-zoom-validity.test.tsx` pins this; the single settle commit goes through `commitLiveViewport()`, which sources viewBox width/height from the live `svgSize` so a mid-gesture resize settles with the current dimensions. The viewport math is the pure `drawing/viewport.ts`; the shell owns the rAF loop, the `refs.deferredCommitTimer` debounce, and screen->canvas mapping. A drag-pan anchors against `refs.panBaseOffset` (captured at press) so a pan that interrupts a coast does not snap back to the last committed viewBox. Interruptions (a new pointer-down / wheel) never commit and never clear `liveViewport`: the new gesture inherits it (via `panBaseOffset` / the pinch reference reads) and commits the combined result. The `refs.deferredCommitTimer` is the safety net for an **orphaned** live viewport -- one left by a wheel gesture (no end event) or by a coast a non-viewport press interrupted: its guarded callback commits only when no pan/pinch/coast is active, so a plain click between a scroll/coast and the timer still persists the viewport instead of stranding it, with no double commit. A mid-gesture resize updates only `svgSize` (the gesture keeps the offset; no re-centering shift to fight or be discarded); an idle resize re-centers and commits immediately. Embedded mode is viewport-inert (it draws to tight element bounds and ignores viewBox/zoom). The settle commit reaches the controller's `setViewport` once per gesture. +- **External view overrides a live gesture** (Canvas, issue #707): a `useEffect` keyed on `[props.view, liveViewport]` compares `props.view`'s offset/zoom VALUE against `refs.viewBaseline` (tracked while idle). While a gesture is live, `props.view` is expected to stay put (a gesture never commits mid-flight), so any value change seen with `liveViewport` still set is external (centerVariable, module navigation, undo) and drops the live viewport + cancels pending momentum/deferred commits -- the external view wins, with no stray commit. A self-commit clears `liveViewport` in the same React commit as its optimistic `props.view` update, so it is never misread as external; comparing by value (not snapshot identity) ignores a content-equal republished view. If a pointer-driven viewport gesture (drag-pan or pinch) is still physically in progress when this fires, it is also abandoned (the gesture and pinch end and the pan anchors are cleared) -- otherwise a continued move would recreate `liveViewport` from the stale press-time anchor and the pointer-up could commit that abandoned gesture back over the external view. +- **Rendered view rule** (controller): the rendered view of model M is the next view of the last pending edit targeting M, else committed M, with the live viewport for M overlaid. An enqueue publishes it synchronously, so nothing blinks between pointer-up and the patch landing, and a landing or a rollback republishes. There is no separate optimistic apply and no live-view merge: every view that renders goes through this rule. Views of other models render the same way, so drilling into a module while a parent edit is pending keeps the parent's pending view. Every rendered element's `ident` is its name's (creates and renames set it), and while a pending view renames a committed variable (a named element whose uid is on the committed view under another canonical name) the rendered MODEL names that variable by its new ident, with its committed content and annotations, and connector dependencies are mapped onto it. While a pending view creates a named element (a uid not on the committed view, under a name no variable keeps), the rendered model holds the variable its create op will produce (`createdVariable` in `view-model-sync.ts`): the create lands ahead of any edit planned on it, so a gesture onto a stock whose create is still queued attaches to it, while an orphan element an import left (on the committed view, with no variable) still resolves to nothing. So the canvas, the details panel and name allocation all see the model the queued edits will produce, and a failure renders the committed idents again. +- **One executor; an edit is one atomic patch** (controller): every engine call (patches, serialization, error and dependency queries, sim runs, the reopen for undo, LaTeX and XMILE queries) runs inside an executor item, and no engine reference is held across an await outside one. A view edit is ONE patch: `buildEditOps` against the committed model at dequeue (renames, deletes, creates, stock list deltas) plus the `upsertView` of its next view carrying the COMMITTED viewport (an edit never persists a viewport ahead of the viewport items). A model-only edit builds its full payload from the committed project at dequeue, so an echoed field (a stock's inflows, a module's references) is never stale. libsimlin applies a patch atomically, so the model and the view land together or not at all. +- **A failed edit rolls back** (controller): a failed VIEW edit (an engine rejection, an `EditConflictError`, the project not open, or a token that moved before dequeue) truncates every later view edit and undo/redo (each was planned on the failed edit's optimistic view), bumps the token (except for the moved-token drop, whose move already bumped it), renders committed, and reports ONE error naming how many later edits were discarded. Model-only edits, viewport items and query items survive: a model-only edit builds its payload from committed state at dequeue, so an unrelated failure does not invalidate it and discarding it would silently lose typed text; one targeting a variable a discarded edit would have created fails at dequeue with its own error. A failed MODEL-ONLY edit (a builder throw, an engine rejection) only reports: no edit was planned on it, so nothing is truncated and the token does not move; an undo queued behind it with `afterQueuedEdits` is discarded, since it was meant for that edit's change and would otherwise undo an older one. A patch that applied but could not be read back (serialization or `projectFromJson` failing) resyncs committed BEFORE its fate is decided -- the item keeps rendering its next view meanwhile, so handlers may plan on it: a re-read that succeeds means the edit landed (history records it; nothing is discarded or reported); a reopen of the snapshot at the history cursor means it failed, and it fails AFTER the swap, so every view edit planned on it, including those enqueued while the reopen ran, is discarded and live gestures abort; a failed reopen latches `engineUnavailable` (see "Engine lifecycle"). A viewport persist resyncs the same way without recording history and reports nothing: a reopen loses only viewports, which it persists again. A rejected viewport persist renders the committed viewport again unless a newer one was set meanwhile. Never commit a view whose patch failed: that saves a diagram whose elements name variables the model lacks. Enqueue refuses up front a next view with a non-finite coordinate (#818), an edit carrying a stale token (before it can render), and -- quietly, since it could only be dropped once the undo lands -- a view edit while an undo/redo is queued. +- **History and saves** (controller): each landed edit records exactly one history entry (none when its serialized bytes equal the head), through `advanceProjectHistory` (project-history.ts), which discards the redo branch. Viewport items and the open record nothing: viewBox/zoom are serialized into the protobuf, so recording them would let a single momentum flick evict every real edit from the `MaxUndoSize` (5) buffer. Landed edits and undo/redo request a save; viewport items do not. +- **Maintenance is coalesced and bounded** (controller): save serialization, error refresh, connector dependencies and sim runs are at most one pending run per kind, run when no edit-class item is queued, or after `MaintenanceEditBound` (5) consecutive items or `MaintenanceTimeBoundMs` (5 s) of continuous edit work, so a burst of edits costs one sim run and one save and a sustained stream of slow edits cannot starve saving. A landed edit whose patch changes nothing but views (every op an `upsertView`) requests only the save: a view feeds no simulation, diagnostic or equation dependency. A run publishes a new Series object only for a variable whose results changed (elementwise, NaN equal to NaN) and keeps the previous data map when none did, and the per-variable arrays the rendered project attaches (`groupSeriesByIdent`, `projectAttachSeries`) are derived once per such change and reuse unchanged variables' arrays: Stock/Aux/Flow and their sparklines memoize on those arrays, so a landed edit or an unchanged rerun rebuilds no sparkline, which on a model C-LEARN's size costs seconds of main-thread work. +- **Undo gating** (controller + Editor + Canvas): undo/redo are edit-class items. `canUndo`/`canRedo` are false while an edit or undo/redo is queued (`undoRedo` refuses then), `handleUndoRedo` refuses while a pointer press inside the editor is live (`refs.gestureLive`, cleared by window-level capture `pointerup`/`pointercancel`/`contextmenu` and window `blur`, so a press released over the host page, outside the browser, or into a context menu never leaves undo refused), and the Canvas ignores presses while `undoRedoQueued`; the Editor's view-edit handlers refuse then too (`viewEditsRefused`), with no side effects -- quietly, except a create or rename, whose refusal message keeps the name editor open. An open details panel's draft is the user's latest change. Undo submits it first and queues the undo behind its edit (`undoRedo('undo', { afterQueuedEdits: true })`): the undo takes the draft back and a redo restores it; if the draft's edit fails, the undo is discarded with it. Redo goes the other way: the redo is queued first and the draft submitted behind it, so the draft (built from committed state at dequeue) lands on the redone project and neither is lost -- submitting first would record the draft and discard the redo branch. The UndoRedoBar keeps focus on press (a blur would commit the draft and disable the button before the click) and is exempt from the pointerdown flush. Landing opens the restored snapshot in a new engine and disposes the old one only after the new one serializes, bumps the token, resets live viewports to the restored views, and resets navigation to 'main' when the viewed model is gone. +- **Version bookkeeping** (controller): two counters with disjoint jobs. `projectVersion` is PURELY the Canvas render-cache key: +1 whenever the rendered project identity changes; it never resets and carries no server meaning. `serverVersion` is the last server-ACKNOWLEDGED integer version: seeded from `initialProjectVersion`, advanced only by a successful save's returned version, and the SOLE source of the `currVersion` a save sends; local edits, view updates, and undo/redo never move it (undo restores older content, but the server still holds the version it last acked). Never derive the save version from the render key: issue #958 was ~100 unsaved edits drifting a render counter past the next integer, producing bogus 409s after session recovery and a silent-overwrite hazard on version collision. The `token` is neither: it counts truncations, undo/redo landings and reopens. +- **Details panels are keyed by the selected variable's committed content** (Editor render): VariableDetails/ModuleDetails seed their Slate editors from props in **lazy `useState` initializers** (one run per mount), so key-driven remounts -- not prop-sync effects -- are what refreshes them. `detailsPanelKey` is the model name and the selected element's uid plus its variable's user-editable committed content (equation, units, documentation, table; a module's model reference and references) plus the read-only flag plus the snapshot's `restoreSeq`, which moves only when an undo/redo lands: restored content can equal the content the panel was seeded from -- a draft's edit landed and was undone before a render -- and the panel must still drop that text. While the panel reports a draft (`onDraftStateChange`) the Editor HOLDS its key, so a landed edit -- to this variable, or one that rewrites it, as a rename of a name its equation references does -- never remounts the panel over text that is unsubmitted or submitted but not landed; the key advances once the panel reports no draft (its drafts landed, or were cancelled), and a different selected element, an undo/redo landing or a read-only flip remount at once. A field's draft is its text when that differs from its BASE -- what this panel last submitted for the field, or the seeded text if nothing was (`draftText`) -- and a submission carries only drafts; Save and Cancel are enabled exactly while a draft exists, and Cancel (or Escape) puts every field back to its base, so it releases the hold without turning a field whose submission is still pending into a draft of the committed text that submission replaces. So a field the user left alone is never echoed back, even when the held panel fell behind committed content (a rename rewrote its equation), while a field changed back after a submission (typed, flushed, cleared while the edit is in flight) is a draft that holds the panel, though it equals the seeded or pre-landing committed text; otherwise its in-flight edit would land over it with no notice. The panel handlers resolve whether a submission landed, and one that did not stops being the base (the field falls back to its committed text, so the text can be submitted again). The Editor keeps the latest pending submission per element (model, uid) and field (`refs.pendingPanelSubmissions`) until its edit settles, and a panel mounting meanwhile (reopened on the element, or remounted) seeds those fields from it and takes it as their base -- falling back the same way if it does not land -- so it never shows the text the submission is replacing. The key and the hold both include the model name, since a uid is unique only within its model. While an undo/redo is queued the panels render read-only, as the Canvas ignores presses: the undo's landing remounts them, which would discard text typed meanwhile. It deliberately excludes the variable's ident (a rename, pending or landed, changes nothing a panel seeds), errors (VariableDetails decorates the underline from props, and only while a field shows exactly the committed text), sim data, connector drift and every global counter: an unrelated edit landing while the user types must not remount the panel and discard the draft. A canvas press does not blur the panel (the Canvas prevents every press's default), so the Editor's capture-phase pointerdown calls the panel's registered flush (`registerDraftFlush`) before the press reaches the Canvas, enqueueing the draft's edit ahead of the gesture's; `enqueueVariableEdit` drops a submission identical to the latest pending one for that variable (the blur that may follow) and enqueues any other: a draft changed and changed back while its edits are pending must still land as its last text. A model-only edit resolves its variable at dequeue through the element: the uid it was enqueued for, looked up on the COMMITTED view, names the variable, so an edit enqueued while a rename was pending targets the renamed variable once the rename lands and the original one if it rolled back. +- **Names** (controller + Editor + Canvas): default names are allocated against `usedIdents` -- the rendered model's variables (committed, with pending renames applied, so a variable with no element still takes its name and a name a pending rename frees is free) plus every name on the rendered view, which carries each pending create and rename. A typed name that collides makes `onCreateVariable`/`onRenameVariable` return a refusal, the Canvas keeps the name editor open showing it, and nothing is enqueued. A collision discovered at dequeue (only reachable if allocation is wrong) throws `EditConflictError` and fails the item like any engine error, because the engine's upsert would silently replace the existing variable whatever its kind. +- **Save flush releases `inSave` in a `finally` block** (controller): the executor's save item only serializes; `flushSave` hands the bytes to the host outside the executor, because a network save must not hold edits back. A thrown save (e.g. host-side network failure) must not leave `inSave === true`, otherwise every subsequent save silently queues forever. A save requested while one is in flight queues exactly one flush of the LATEST bytes, which reads the live `serverVersion` when it runs, so a save that errored leaves the still-valid version in place for the next attempt and a successful one hands its returned version to the flush. +- **Engine lifecycle / StrictMode safety** (controller + Editor): the controller's `disposed` flag latches once. `dispose()` settles every queued item as not landed except the one whose engine calls are in flight (`runningItem`, tracked explicitly: an item enqueued in the same tick has not started and settles now), an item pushed afterwards settles at once, and the engine is released when the running item returns (`dispose()` awaits the executor) or at once when the executor is idle. The continuations that can install an engine (the open, an undo's reopen, a resync's reopen) check the flag and release that engine instead, so an open racing a dispose releases its own engine. When a resync cannot reopen the project the controller latches `engineUnavailable`: it releases the engine, settles everything queued as not landed, drops pending maintenance, and refuses every later edit, undo/redo and query quietly; the Editor shows ONE persistent notice (changes can no longer be saved; Reload) instead of a toast per refusal. The Editor constructs a controller once in its `refs`-init guard and its mount effect rebuilds one whenever a prior cleanup disposed and cleared it, so a React 18 StrictMode mount -> unmount -> mount cycle builds a *fresh* controller on the second mount. +- **Module navigation stack** (controller): the controller owns `modelName` and an immutable `modelStack` of `ModuleStackEntry` (each storing the child model name, module ident, and the parent's selection/viewBox/zoom). All navigation goes through `pushModule`/`popModule`/`navigateToLevel` pure functions. Navigation need not wait for the queue: back/level restore the parent viewport through `setViewport` (it renders at once and persists through a viewport item for the restored model), error annotations re-scope at render, and a connector-dependency refresh is requested for the newly active model. The methods return a `NavigationOutcome` with the selection the Editor should adopt. The undo-driven navigation reset (restored project lacks the viewed model -> reset to 'main') bumps `navResetSeq`; a post-commit `useEffect` in the Editor clears selection/details/tool exactly once per bump (an ordinary undo preserves them). - **Read-only capability gate** (Editor, issue #935): the Editor has ONE mutation predicate, `isReadOnly()` = `readOnlyMode || embedded || isStdlibModel(modelName())`, and a narrower `isModelLocked()` = `embedded || stdlib` for panel-OPEN affordances -- a read-only viewer (`readOnlyMode`, the app's "viewing someone else's public project" state where the host makes saves no-ops) keeps inspection: opening VariableDetails/ModuleDetails to READ works, but the panels receive `readOnly` and render non-editable (Slate `readOnly`, hidden Delete/Cancel/Save, disabled model-ref select, static wiring text, inert lookup). Every op-building handler ALSO early-returns on `isReadOnly()` as defense in depth, so no wiring mistake can mutate a read-only project; undo/redo and sim-specs are PROJECT-scoped and gate on `readOnlyMode` alone (undoing a parent edit while viewing a stdlib model stays legal). Preserved capabilities: selection, pan/zoom, drill-in navigation, details-for-inspection, sim runs, download/snapshot. The details-panel key includes the read-only flag, so a mid-session `readOnlyMode` flip REMOUNTS an open panel (re-seeding its Slate editors -- in-flight text is deterministically discarded); a flip to read-only also clears `selectedTool`/`dialOpen`/`flowStillBeingCreated` via a prev-value-guarded effect so flipping back never resurrects an armed tool. The indication is the persistent "View only" pill rendered in-flow inside the search bar from the CURRENT prop each render (the former mount-latched toast went stale when ownership resolved after mount). Canvas takes a `readOnly` prop for the one editing entry point it owns itself: the inline label editor on label double-click never opens read-only. - **Module patches target `modelName`** (Editor op-builders): module creation and editing patches use the controller's active `modelName` (not hardcoded 'main'), so operations work at any nesting depth. - **Module warning suppression**: When no module in a model has a model reference, warning indicators are suppressed on all modules. This prevents a wall of warnings during initial module layout. - **Post-commit selection callback runs in a `useEffect`, not `setTimeout(0)`** (Editor): `onSelectionChanged` fires from a post-commit effect keyed on the committed `selection` (covers clicks, deletes, navigation resets), guarded by a `prevSelection` ref + `setsEqual` so it does not fire on initial mount and ignores content-equal Sets. It reads the already-committed state and never fires after unmount, so no deferral or timer cancellation is needed. -- **Link drag arc ownership**: During any single-link arrowhead drag (creation or reattachment), Canvas.tsx's `connector()` has exclusive control over the arc. `applyGroupMovement` is intentionally given `arcPoint: undefined` during link drags so `processLinks` does not interfere. The arc is computed once per render in `deriveRenderState` (stored in `derived.draggedLinkArc`), rendered by `connector()`, and read by the pointer-up persist path for exact visual consistency (see "Canvas render purity" below). +- **Link arc ownership** (planner): a link's arc during a gesture comes from the planner alone. `createLink` and `linkEndpoint` curve through the pointer (`computeLinkCreationArc`; straight for touch), `linkArc` takes its angle from the pointer, and `followLinks` turns the arc of each link whose endpoints moved exactly once, from the final elements, never touching a link the gesture itself changed. The Canvas renders the plan's link element and commits that same element, so there is no render-side arc to disagree with the commit. - **Collinear defense in Connector geometry**: `takeoffθ()` and `arcCircle()` catch `circleFromPoints` throws for collinear points (cursor on the source-to-target line). Any code passing user cursor positions as arc points must handle this gracefully or go through these functions. -- **Canvas render purity**: the Canvas function component's render body does not mutate refs mid-render beyond a single derivation step. `deriveRenderState()` runs at the top of the render body (and once in the mount effect) and is the only code that writes the render caches (`refs.elements`, `refs.cachedVersion`, `refs.derived: RenderDerivation`). The element-rendering helpers (`connector()`, `aux()`, ...) only *read* `refs.derived`. `refs.elements` and `refs.derived` are intentionally NOT cleared at render end because event handlers read them after render returns (connector-end resolution, dragged-link-arc persistence). Writing these refs during render is the same write the old class did to `this.*` with identical semantics, and is kept idempotent (the version cache short-circuits) so a StrictMode double-render is safe. Element bounds are an explicit pure pass (`computeElementBounds`) over the displayed elements, not a side effect of rendering. After a click settles (`clearPointerState`), `focusCanvas` focuses the container div (`tabIndex={-1}`, no focus ring, `preventScroll`): the text field the user was typing in blurs and commits, and focus lands inside the editor -- never on `` -- so subsequent key events resolve to this Editor instance and land inside a host's shortcut-suppressing wrapper (see Hosting Requirements). The other render-phase write that remains is `refs.prevSelectedTool` -- a previous-props comparison that triggers the deferred editing-done when the tool changes; it does not feed rendered output or event-time caches. Separately, the `latest` ref is refreshed during render, but it only mirrors the values render already has (props + state) for escaped callbacks; it is not a render cache. The dragged-link arc is computed once in `deriveRenderState` (`derived.draggedLinkArc`) and read by both `connector()` and the pointer-up persist path, preserving "Link drag arc ownership" with explicit dataflow. +- **Canvas render purity**: the Canvas function component's render body does not mutate refs mid-render beyond a single derivation step. `deriveRenderState()` runs at the top of the render body (and once in the mount effect) and is the only code that writes the render caches (`refs.elements`, `refs.cachedElements`, `refs.derived`): it plans the live gesture on the current view when the gesture is still valid and picks the displayed elements (the plan's, else the view's, plus a creation draft). Planning is pure and reads only props and gesture state, so a StrictMode double render renders the same plan. The element-rendering helpers (`connector()`, `aux()`, ...) only *read* `refs.derived`. `refs.elements` and `refs.derived` are intentionally NOT cleared at render end because event handlers read them after render returns. Element bounds are an explicit pure pass (`computeElementBounds`) over the displayed elements, not a side effect of rendering. After a gesture settles, `focusCanvas` focuses the container div (`tabIndex={-1}`, no focus ring, `preventScroll`): the text field the user was typing in blurs and commits, and focus lands inside the editor -- never on `` -- so subsequent key events resolve to this Editor instance and land inside a host's shortcut-suppressing wrapper (see Hosting Requirements). The other render-phase writes are `refs.prevSelectedTool` -- a previous-props comparison that triggers the deferred editing-done when the tool changes -- and the handlers ref the stable element callbacks dispatch through, which mirrors this render's implementations; neither feeds rendered output or event-time caches. Separately, the `latest` ref is refreshed during render, but it only mirrors the values render already has (props + state) for escaped callbacks; it is not a render cache. ## Gotchas -- **buildSelectionMap async race**: When `inCreation` is undefined but selection still references `inCreationUid`, the entry is silently skipped. This handles the transient state between Canvas clearing `inCreation` and Editor's async handler updating selection. -- **Touch links are always straight**: When `dragPointerType === 'touch'`, link creation always produces `arc: undefined` (straight line) because touch interactions lack a stable cursor midpoint. +- **Touch links are always straight**: a link created or reattached with a touch pointer has `arc: undefined` (a straight line) because a touch has no stable pointer midpoint to curve through (`arcThrough` in `gesture-planner/links.ts`). +- **Sentinel uids never reach a selection or a commit**: `inCreationUid` (a draft element) and `fauxTargetUid` (a link's preview target) exist only in a plan's rendered elements; a creation press clears the selection, and every committed element carries an allocated uid. - **Module upsert is full replacement**: The engine does full variable replacement on `upsertModule`, not merge. All module handlers in Editor.tsx must send the complete module state (modelName, references, units, documentation) in every patch. - **projectOps ordering**: `AddModel` in `projectOps` is processed before model-level `ops`, allowing atomic create-and-reference in a single patch. `handleCreateModelForModule` relies on this ordering. diff --git a/src/diagram/Editor.module.css b/src/diagram/Editor.module.css index ffe39bb99..0824525b5 100644 --- a/src/diagram/Editor.module.css +++ b/src/diagram/Editor.module.css @@ -293,6 +293,47 @@ } } +/* The persistent notice for a lost engine: centred over the top of the editor + like the hosted editor's save-failure banner, above the editor chrome + (sharedModelBanner is 1) but below drawers/dialogs (1200+) and toasts + (1400). It is deliberately not dismissible: nothing can be saved until the + user reloads. */ +.engineUnavailableNotice { + position: absolute; + top: var(--shared-model-banner-top); + left: 50%; + transform: translateX(-50%); + z-index: 1150; + width: max-content; + max-width: min(520px, calc(100% - var(--spacing-2))); + box-sizing: border-box; + padding: var(--spacing-1-5) var(--spacing-2); + border: 1px solid var(--color-border); + border-radius: 4px; + background: var(--color-surface); + color: var(--color-text-primary); + box-shadow: var(--shadow-3); +} + +.engineUnavailableTitle { + margin: 0 0 4px; + font-size: 0.875rem; + font-weight: 500; + color: var(--color-error); +} + +.engineUnavailableBody { + margin: 0; + font-size: 0.8125rem; + line-height: 1.5; +} + +.engineUnavailableActions { + display: flex; + justify-content: flex-end; + margin-top: var(--spacing-1); +} + /* The root is the containing block for every piece of floating chrome (search bar, detail cards, banners, bottom-left controls, the toast viewport): all of it is position: absolute and anchors HERE, so an editor embedded as one box @@ -324,4 +365,3 @@ width: 100%; height: 100%; } - diff --git a/src/diagram/Editor.tsx b/src/diagram/Editor.tsx index 072706886..0429dd4d7 100644 --- a/src/diagram/Editor.tsx +++ b/src/diagram/Editor.tsx @@ -26,10 +26,6 @@ import { NamedViewElement, StockFlowView, GraphicalFunction, - LinkViewElement, - FlowViewElement, - CloudViewElement, - viewElementType, Rect, isNamedViewElement, stockToJson, @@ -53,15 +49,14 @@ import type { SimSpecField } from './sim-spec-draft'; import { renderSvgToString } from './render-common'; import { Status } from './Status'; import { StockIcon } from './StockIcon'; -import { UndoRedoBar } from './UndoRedoBar'; -import { VariableDetails } from './VariableDetails'; +import { UNDO_REDO_BAR_ATTRIBUTE, UndoRedoBar } from './UndoRedoBar'; +import { VariableDetails, type PendingSubmission } from './VariableDetails'; import { ModuleDetails } from './ModuleDetails'; import { ErrorDetails } from './ErrorDetails'; import { ZoomBar } from './ZoomBar'; -import { Canvas, inCreationUid } from './drawing/Canvas'; -import { Point, searchableName } from './drawing/common'; -import { computeFlowAttachment } from './flow-attach'; -import { applyGroupMovement } from './group-movement'; +import { Canvas, type GestureCommit } from './drawing/Canvas'; +import { encodeNameNewlines, searchableName } from './drawing/common'; +import { sameGeometry } from './gesture-planner'; import { detectUndoRedo, isEditableElement } from './keyboard-shortcuts'; import { EDITOR_ROOT_ATTRIBUTE, @@ -73,7 +68,8 @@ import { import { isStdlibModel } from './module-navigation'; import { countModelInstances } from './module-details-utils'; import { buildModuleReferencePayload } from './module-wiring'; -import { buildVariableRenameOps } from './rename-ops'; +import { relabelVariable } from './rename-ops'; +import { planDelete } from './plan-delete'; import { BreadcrumbBar } from './BreadcrumbBar'; import { ProjectController, type ProjectSnapshot, type EngineApi, type Viewport } from './project-controller'; @@ -82,6 +78,10 @@ export type { Viewport } from './project-controller'; import styles from './Editor.module.css'; // These must stay in sync with --panel-width-sm/-md/-lg in theme.css (and the // media-query breakpoints in Editor.module.css). +// Marks the details slot. A press inside it is the panel's own (it blurs and +// commits normally); a press anywhere else flushes the panel's draft first. +const DETAILS_SLOT_ATTRIBUTE = 'data-simlin-details-slot'; + const SearchbarWidthSm = 359; const SearchbarWidthMd = 420; const SearchbarWidthLg = 480; @@ -114,10 +114,7 @@ function panelWidth(): number { // layer on every Editor render. const noopRename = (_oldName: string, _newName: string): void => {}; const noopSetSelection = (_selected: ReadonlySet): void => {}; -const noopMoveSelection = (_position: Point): void => {}; -const noopMoveFlow = (_e: FlowViewElement, _t: number, _p: Point): void => {}; -const noopMoveLabel = (_u: UID, _s: 'top' | 'left' | 'bottom' | 'right'): void => {}; -const noopAttachLink = (_element: LinkViewElement, _to: string): void => {}; +const noopCommitGesture = (_commit: GestureCommit): void => {}; const noopCreateVariable = (_element: ViewElement): void => {}; const noop = (): void => {}; const noopViewBoxChange = (_viewBox: Rect, _zoom: number): void => {}; @@ -155,16 +152,61 @@ function getErrorDetails(error: unknown): ErrorDetailsLike { return {}; } -// Editor state is now split in two: the project/engine coordination state -// lives in the ProjectController and is mirrored here as a single immutable -// `controllerSnapshot` field (replaced wholesale on every controller change, -// so a new snapshot identity drives a re-render). The remaining fields are -// genuinely Editor-owned UI/presentation state. Held as one useState object -// (see the function component) with a class-like merging setState helper. +/** + * The React key of the details panel for `variable`. The panels seed their + * Slate editors once per mount, so the key is what re-seeds them: it changes + * exactly when the selected variable's committed, user-editable content changes + * (a landed edit to it, an undo), or the read-only flag flips. It deliberately + * excludes errors (the highlight is decorated from props), sim data, connector + * drift, and every global counter, so an unrelated edit landing while the user + * types does not remount the panel and discard the draft. + */ +export function detailsPanelKey( + modelName: string, + elementUid: UID, + variable: Variable, + restoreSeq: number, + readOnly: boolean, +): string { + // The element (a uid is unique only within its model), not the variable's + // ident: a rename changes the ident (at once, while it is pending) but none of + // the content a panel seeds. + const content = + variable.type === 'module' + ? [ + modelName, + elementUid, + variable.type, + variable.modelName, + variable.references, + variable.units, + variable.documentation, + ] + : [ + modelName, + elementUid, + variable.type, + variable.equation, + variable.units, + variable.documentation, + variable.type === 'stock' ? undefined : variable.gf, + ]; + // restoreSeq: restored content can equal the content the panel was seeded + // from (a draft's edit landed and was undone before a render), and the panel + // must still drop that text; see ProjectSnapshot.restoreSeq. + return `${JSON.stringify(content)}-r${restoreSeq}${readOnly ? '-ro' : ''}`; +} + +// The project/engine coordination state lives in the ProjectController and is +// mirrored here as a single immutable `controllerSnapshot` field (replaced +// wholesale on every controller change, so a new snapshot identity drives a +// re-render). The remaining fields are genuinely Editor-owned UI/presentation +// state. Held as one useState object (see the function component) with a +// class-like merging setState helper. interface EditorState { // The latest immutable snapshot published by the ProjectController. Holds - // project, projectVersion, serverVersion, projectGeneration, status, - // cachedErrors, data, modelName, modelStack, and the undo/redo predicates. + // the rendered project, projectVersion, serverVersion, status, cachedErrors, + // data, modelName, modelStack, the undo/redo predicates, and the token. controllerSnapshot: ProjectSnapshot; // Toast-style transient errors. These STAY in the Editor as UI state: the // controller surfaces errors via its onError config callback, which appends @@ -183,6 +225,9 @@ interface EditorState { // fresh URL per render; revoked when replaced, cleared, or on unmount. snapshotUrl: string | undefined; variableDetailsActiveTab: number; + // Whether the open details panel holds a draft (onDraftStateChange); while it + // does, the panel's key is held (see getDetails). + panelHasDraft: boolean; } // Enforces the Editor's selection invariant for the element-mutation paths @@ -318,6 +363,11 @@ interface EditorPropsBase { // the live viewport during a gesture and commits it once on settle. A // content-equal republished view fires nothing. onViewportChange?: (modelName: string, viewport: Viewport) => void; + // Called by the Reload action of the notice shown once the engine is lost + // and cannot be reopened (ProjectSnapshot.engineUnavailable). Default: reload + // the page. A host whose page reload would not reload the project (or would + // discard more than the Editor) supplies its own. + onReload?: () => void; } export type EditorProps = EditorPropsBase & ProjectInputProps; @@ -359,6 +409,28 @@ interface EditorRefs { // value and leak one URL; reading and revoking this field synchronously in // setSnapshotUrl is race-free. liveSnapshotUrl: string | undefined; + // True between a pointer press inside the editor and its release, wherever + // the release lands (see the mount effect's window listeners). Undo/redo is + // refused while it is set: a gesture planned on the current view could not + // commit once the undo replaced that view. + gestureLive: boolean; + // The open details panel's draft commit, registered by the panel (see + // registerDraftFlush). A canvas press calls it before the gesture starts; it + // returns true when it submitted a changed draft. + draftFlush: (() => boolean) | undefined; + // The latest pending model-only submission per target (model, label, + // variable), until it settles. A panel's flush on a canvas press and its blur + // afterwards submit the same draft; a submission identical to the LATEST + // pending one is not enqueued again. Any other is: a draft changed and changed + // back (A, B, A) must still land as A, which comparing against every pending + // submission would drop. + pendingModelEdits: Map }>; + // The latest pending details-panel submission per element (model, uid), by + // field, until each field's edit settles: a panel reopened meanwhile seeds + // from it rather than from committed text the submission is replacing. + pendingPanelSubmissions: Map; + // The details panel's last key and what it was held against (see getDetails). + heldPanelKey: { readonly base: string; readonly key: string } | undefined; } // The snapshot of props + state that escaped callbacks (the controller @@ -444,6 +516,11 @@ export const Editor = React.memo(function Editor(props: EditorProps): React.Reac nextErrorKey: 1, errorKeys: new WeakMap(), liveSnapshotUrl: undefined, + gestureLive: false, + draftFlush: undefined, + pendingModelEdits: new Map }>(), + pendingPanelSubmissions: new Map(), + heldPanelKey: undefined, }; makeController(props); } @@ -465,6 +542,7 @@ export const Editor = React.memo(function Editor(props: EditorProps): React.Reac drawerOpen: false, snapshotUrl: undefined, variableDetailsActiveTab: 0, + panelHasDraft: false, })); // Class-parity setState: merges a partial patch (or a functional updater that @@ -541,16 +619,24 @@ export const Editor = React.memo(function Editor(props: EditorProps): React.Reac // derived from the CURRENT prop on every render. document.addEventListener('keydown', handleKeyDown); + // A press inside the editor can be released anywhere -- over the host page, + // outside the browser window (the window loses focus) -- so the release is + // observed on the window, in the capture phase, where no handler can stop + // it first. + window.addEventListener('pointerup', handleGestureRelease, true); + window.addEventListener('pointercancel', handleGestureRelease, true); + // A context menu opened by the press can swallow its pointerup. + window.addEventListener('contextmenu', handleGestureRelease, true); + window.addEventListener('blur', handleGestureRelease); // Captured here (not read in the cleanup): React detaches refs before // passive-effect cleanups run, so rootRef.current is null by then. const root = rootRef.current; - // Open the engine, then schedule the first sim run. The controller guards - // its own dispose-races internally (see ProjectController.dispose), so no - // Editor-side timer or unmounted flag is needed here. - void controller.openInitialProject().then(() => { - r.controller?.scheduleSimRun(); - }); + // Open the engine. The open item requests the first sim run, error refresh + // and connector check itself, and the controller guards its own + // dispose-races (see ProjectController.dispose), so no Editor-side timer or + // unmounted flag is needed here. + void controller.openInitialProject(); return () => { // componentWillUnmount: remove the keydown listener, unsubscribe (before @@ -565,6 +651,10 @@ export const Editor = React.memo(function Editor(props: EditorProps): React.Reac // setup above so a StrictMode mount/unmount/mount cycle builds a fresh // controller on remount and leaves nothing stuck. document.removeEventListener('keydown', handleKeyDown); + window.removeEventListener('pointerup', handleGestureRelease, true); + window.removeEventListener('pointercancel', handleGestureRelease, true); + window.removeEventListener('contextmenu', handleGestureRelease, true); + window.removeEventListener('blur', handleGestureRelease); if (root) { // A key on must never resolve to an unmounted instance. releaseEditorRoot(root); @@ -635,7 +725,7 @@ export const Editor = React.memo(function Editor(props: EditorProps): React.Reac // Report the committed viewport of the viewed model whenever it changes by // VALUE (offset, size or zoom), including the first render that has a view. // Keyed on the controller snapshot: every viewport commit -- a settled - // gesture's queueViewUpdate, the mount-time fit, an idle resize, module + // gesture's setViewport, the mount-time fit, an idle resize, module // navigation's viewport restore -- publishes a new snapshot, and the // prev-value ref keeps content-equal republishes (a content edit, a save // acknowledgment) silent. The Canvas holds a gesture's live viewport locally @@ -815,6 +905,21 @@ export const Editor = React.memo(function Editor(props: EditorProps): React.Reac // nothing would drop the caret. const handlePointerDownCapture = React.useCallback((e: React.PointerEvent): void => { handleActivity(); + r.gestureLive = true; + // A press outside the details panel does not blur the panel's editors when + // its default is prevented (the Canvas prevents every press), so flush the + // draft now: its edit is enqueued ahead of whatever the press starts. + // Capture phase runs before the Canvas's own handler. The undo/redo controls + // are exempt: a draft flushed on their press would queue an edit, and undo + // refuses while one is queued, so the click would do nothing. handleUndoRedo + // flushes the draft itself and queues the undo behind it. + const target = e.target as Element | null; + const exempt = + typeof target?.closest === 'function' && + target.closest(`[${DETAILS_SLOT_ATTRIBUTE}], [${UNDO_REDO_BAR_ATTRIBUTE}]`) !== null; + if (!exempt) { + r.draftFlush?.(); + } const root = rootRef.current; if (!root) { return; @@ -837,40 +942,43 @@ export const Editor = React.memo(function Editor(props: EditorProps): React.Reac } }, []); - const isUndoEnabled = (): boolean => { - return latest.current.state.controllerSnapshot.canUndo; - }; + const handleGestureRelease = React.useCallback((): void => { + r.gestureLive = false; + }, []); - const isRedoEnabled = (): boolean => { - return latest.current.state.controllerSnapshot.canRedo; + // The panel registers its draft commit; the unregistration only clears a + // registration that is still its own, so a remounting panel's cleanup cannot + // drop the new mount's registration. + const registerDraftFlush = React.useCallback((flush: () => boolean): (() => void) => { + r.draftFlush = flush; + return () => { + if (r.draftFlush === flush) { + r.draftFlush = undefined; + } + }; + }, []); + + // The controller's CURRENT snapshot. Handlers plan edits on it rather than on + // the mirror in React state, which lags a render behind an enqueue: two + // handlers in one tick would otherwise both plan on the view before the + // first, and the second full-replacement view would drop the first edit. + const currentSnapshot = (): ProjectSnapshot => { + return r.controller?.getSnapshot() ?? latest.current.state.controllerSnapshot; }; - // Delegating accessor for the active data-model Project. Kept for the - // Editor's own render/op-building reads. No external consumer (HostedWebEditor, - // simlin-serve's EditorHost) uses it. Named getProject (the class method was - // project()) to avoid colliding with the many `const project = ...` locals. - const getProject = (): Project | undefined => { - return latest.current.state.controllerSnapshot.project; + const isUndoEnabled = (): boolean => { + return currentSnapshot().canUndo; }; - // Op-building helpers go through the controller's apply* / view methods, so - // they generally don't need the raw engine handle. Retained as a delegating - // accessor (returns undefined before the engine opens / after dispose). Named - // getEngineProject (the class method was engine()) to avoid colliding with the - // `const engine = ...` locals. - const getEngineProject = (): EngineProject | undefined => { - return r.controller?.getEngine() as EngineProject | undefined; + const isRedoEnabled = (): boolean => { + return currentSnapshot().canRedo; }; - // Convenience wrapper for the simple edit handlers: apply a patch and, on - // success, refresh from the engine. All engine/save/sim coordination lives - // in the controller now. Returns false (without refreshing) on patch failure. - const applyPatchAndRefresh = async (patch: JsonProjectPatch, label: string): Promise => { - const controller = r.controller; - if (!controller) { - return false; - } - return await controller.applyPatch(patch, label); + // The rendered data-model Project (committed content plus pending edits). + // Named getProject (the class method was project()) to avoid colliding with + // the many `const project = ...` locals. + const getProject = (): Project | undefined => { + return currentSnapshot().project; }; // Surface a transient error to the toast list. Op-building handlers that @@ -883,10 +991,10 @@ export const Editor = React.memo(function Editor(props: EditorProps): React.Reac })); }; - // The active model name lives in the controller snapshot now. Op-building - // patches target it so operations work at any module nesting depth. + // The active model name lives in the controller snapshot. Edits target it so + // operations work at any module nesting depth. const modelName = (): string => { - return latest.current.state.controllerSnapshot.modelName; + return currentSnapshot().modelName; }; // The active MODEL cannot be edited and its details panels must not open: @@ -916,34 +1024,113 @@ export const Editor = React.memo(function Editor(props: EditorProps): React.Reac return !!latest.current.props.readOnlyMode || isModelLocked(); }; - // Thin delegating wrappers so the Editor's op-building handlers can keep - // their shape. All engine/save/sim/history coordination lives in the - // controller. Each is a no-op when no controller is mounted. - const applyPatchOrReportError = async (patch: JsonProjectPatch, label: string): Promise => { - const controller = r.controller; - if (!controller) { - return false; + // The gate of every handler that enqueues a view edit: the mutation gate, plus + // a queued undo/redo. An edit planned now would be planned on the view the + // undo replaces, so the controller refuses it; the handler refuses first, so + // its UI side effects (a cleared selection, an un-suppressed panel) do not + // happen either. + const viewEditsRefused = (): boolean => { + return isReadOnly() || viewEditRefusal() !== undefined; + }; + + // Why a view edit cannot be made now, for the handlers whose refusal must be + // visible: a create or rename commits a typed name, and returning a message + // keeps the Canvas's name editor open, where returning nothing would close it + // and drop the name. + const viewEditRefusal = (): string | undefined => { + const snapshot = currentSnapshot(); + if (snapshot.engineUnavailable) { + return 'The project cannot be edited until it is reloaded'; } - return await controller.applyPatchOrReportError(patch, label); + if (snapshot.undoRedoQueued) { + return 'Wait for the undo or redo to finish'; + } + return undefined; }; - const refreshFromEngine = async (): Promise => { - await r.controller?.refreshFromEngine(); + // A diagram edit: `nextView` renders at once and the controller later applies + // the model ops implied by (rendered view -> nextView) plus the view, + // atomically; a failure rolls the diagram back. No-op without a controller. + const enqueueViewEdit = (label: string, nextView: StockFlowView): void => { + void r.controller?.enqueueViewEdit({ label, nextView }); }; - const scheduleSimRun = (): void => { - r.controller?.scheduleSimRun(); + // The uid of the rendered element naming `ident` in the active model. + const renderedElementUid = (ident: string): UID | undefined => { + return getView()?.elements.find((el) => isNamedViewElement(el) && el.ident === ident)?.uid; }; - // Discrete element/structure edits pass { recordHistory: true } so each one - // becomes individually undoable; the per-frame viewport stream goes through - // queueViewUpdate (never records). See the controller's updateView doc. - const updateView = async (view: StockFlowView, opts?: { recordHistory?: boolean }): Promise => { - await r.controller?.updateView(view, opts); + // The committed variable an edit enqueued for rendered element `uid` (named + // `ident` when enqueued) targets at dequeue: the variable the element names on + // the COMMITTED view. A rename may have landed since the edit was enqueued, or + // be pending then and rolled back since, so the ident as enqueued can be stale + // either way; the element's uid is not. An element absent from the committed + // view (its create was rolled back, or it was deleted) resolves to nothing. + // `ident` is the lookup only when no rendered element named it. + const committedVariable = ( + committed: Project, + mName: string, + uid: UID | undefined, + ident: string, + ): Variable | undefined => { + const model = committed.models.get(mName); + if (uid === undefined) { + return model?.variables.get(ident); + } + const element = model?.views[0]?.elements.find((el) => el.uid === uid); + return element !== undefined && isNamedViewElement(element) + ? model?.variables.get(canonicalize(element.name)) + : undefined; }; - const queueViewUpdate = async (view: StockFlowView): Promise => { - await r.controller?.queueViewUpdate(view); + // A model-only edit to variable `ident` of the active model. The payload is + // built at dequeue from the COMMITTED variable (see committedVariable), so + // echoed fields (a stock's inflows, a module's references) are never stale; a + // variable that no longer exists fails the item. An identical edit still + // pending is not enqueued twice (a panel's canvas-press flush and its later + // blur submit the same draft). + // Resolves whether the edit landed; a submission identical to the latest + // pending one resolves as that one does. + const enqueueVariableEdit = ( + label: string, + ident: string, + content: unknown, + build: (variable: Variable, committed: Project, mName: string) => JsonProjectPatch, + ): Promise => { + const controller = r.controller; + if (!controller) { + return Promise.resolve(false); + } + const mName = modelName(); + const target = JSON.stringify([mName, label, ident]); + const pending = r.pendingModelEdits.get(target); + const contentKey = JSON.stringify(content); + if (pending?.content === contentKey) { + return pending.landed; + } + const uid = renderedElementUid(ident); + const submission: { content: string; landed: Promise } = { + content: contentKey, + landed: Promise.resolve(false), + }; + submission.landed = controller + .enqueueModelEdit({ + label, + buildPatch: (committed) => { + const variable = committedVariable(committed, mName, uid, ident); + if (variable === undefined) { + throw new EditorError(`${label} failed: '${ident}' no longer exists`); + } + return build(variable, committed, mName); + }, + }) + .finally(() => { + if (r.pendingModelEdits.get(target) === submission) { + r.pendingModelEdits.delete(target); + } + }); + r.pendingModelEdits.set(target, submission); + return submission.landed; }; const handleDialClick = React.useCallback((_event: React.MouseEvent): void => { @@ -968,44 +1155,40 @@ export const Editor = React.memo(function Editor(props: EditorProps): React.Reac }); }, []); - const handleRename = React.useCallback(async (oldName: string, newName: string): Promise => { + const handleRename = React.useCallback((oldName: string, newName: string): string | undefined => { // Defense in depth (issue #935): the canvas wiring already substitutes // no-ops when read-only, but every op-building handler re-checks so a // stale-closure commit (e.g. a blur that lands after a flip to read-only) // can never mutate the project. Same guard on every mutation handler below. if (isReadOnly() || oldName === newName) { - return; + return undefined; } - - const eng = getEngineProject(); - if (!eng) { - return; + const pausedRefusal = viewEditRefusal(); + if (pausedRefusal !== undefined) { + return pausedRefusal; } - - const view = defined(getView()); - // buildVariableRenameOps sends the typed name RAW as the rename `to` (the - // engine preserves display spellings and matches canonically; issue #906) - // and keeps the sketch label in sync via the paired upsertView. - const { ops } = buildVariableRenameOps(view, oldName, newName); - - const patch: JsonProjectPatch = { - models: [{ name: modelName(), ops: [...ops] }], - }; - - if (!(await applyPatchOrReportError(patch, 'rename'))) { - // A failed rename leaves flowStillBeingCreated untouched. - return; + const controller = r.controller; + const view = getView(); + if (!controller || !view) { + return undefined; } - - // Clear the in-progress flow-creation flag synchronously after the - // patch succeeds and BEFORE the engine round-trip in refreshFromEngine. - // This matches the pre-refactor ordering: the details panel for a - // just-named flow must un-suppress immediately, not wait out the - // serialize/JSON/setState round-trip. + // Refuse a name another variable (or a pending create) already has: the + // inline editor stays open with the message and nothing is enqueued. + const refusal = controller.nameError(encodeNameNewlines(newName), canonicalize(encodeNameNewlines(oldName))); + if (refusal !== undefined) { + return refusal; + } + // RenameVariable never renames view elements, so a rename is an edit WITH a + // next view: the rendered view with the element relabeled. The controller + // derives renameVariable from the relabeled element (the typed name raw, + // issue #906). + enqueueViewEdit('rename', relabelVariable(view, oldName, newName)); + // The details panel for a just-named flow un-suppresses now, with the + // optimistic rename, not once the edit lands. setState({ flowStillBeingCreated: false, }); - await refreshFromEngine(); + return undefined; }, []); const handleSelection = React.useCallback((selection: ReadonlySet): void => { @@ -1036,342 +1219,117 @@ export const Editor = React.memo(function Editor(props: EditorProps): React.Reac }, []); const getLatexEquation = React.useCallback(async (ident: string): Promise => { - const eng = getEngineProject(); - if (!eng) return undefined; - try { - const model = await eng.getModel(modelName()); - return (await model.getLatexEquation(ident)) ?? undefined; - } catch { + const controller = r.controller; + if (!controller) { return undefined; } + const mName = modelName(); + // Through the executor, so the query never runs against an engine an undo is + // swapping out. + const latex = await controller.query(async (engine) => { + const model = (await engine.getModel(mName)) as unknown as { + getLatexEquation(ident: string): Promise; + }; + return (await model.getLatexEquation(ident)) ?? undefined; + }); + return latex ?? undefined; }, []); - const handleSelectionDelete = React.useCallback(async (): Promise => { - if (isReadOnly()) { + const handleSelectionDelete = React.useCallback((): void => { + if (viewEditsRefused()) { return; } const selection = latest.current.state.selection; - const mName = modelName(); - const view = defined(getView()); - - // this will remove the selected elements, clouds, and connectors - let elements = view.elements.filter((element: ViewElement) => { - const remove = - selection.has(element.uid) || - (element.type === 'cloud' && selection.has(element.flowUid)) || - (element.type === 'link' && (selection.has(element.toUid) || selection.has(element.fromUid))); - return !remove; - }); - - // next we have to potentially make new clouds if we've deleted a stock - let { nextUid } = view; - const clouds: CloudViewElement[] = []; - elements = elements.map((element: ViewElement) => { - if (element.type !== 'flow') { - return element; - } - const points = element.points.map((pt) => { - if (!pt.attachedToUid || !selection.has(pt.attachedToUid)) { - return pt; - } - - const cloud: CloudViewElement = { - type: 'cloud', - uid: nextUid++, - x: pt.x, - y: pt.y, - flowUid: element.uid, - isZeroRadius: false, - ident: undefined, - }; - - clouds.push(cloud); - - return { ...pt, attachedToUid: cloud.uid }; - }); - return { ...element, points }; - }); - elements = [...elements, ...clouds]; - - // Parity with the pre-refactor `if (!engine) return`: bail before clearing - // the selection or running the optimistic view update if the engine hasn't - // finished opening yet, so a delete attempted in that brief window cleanly - // no-ops instead of mutating UI state against a project that can't apply it. - if (!r.controller?.getEngine()) { + const view = getView(); + if (!r.controller || !view || selection.size === 0) { return; } - - const deleteOps: JsonModelOperation[] = getSelectionIdents().map((ident) => ({ - type: 'deleteVariable' as const, - payload: { ident }, - })); - - // Clear the selection now, in the same synchronous block (before any - // await) as the view update below, so React batches them into a single - // render: no consumer should ever observe a selection that references an - // element the view no longer contains. (Clearing it after - // `await updateView(...)` instead left a window where props.view had - // dropped the deleted element but props.selection still pointed at it -- - // Canvas's buildSelectionMap now tolerates that, but the state transition - // should still be atomic.) The deleteOps above were computed from the - // pre-clear selection. selectionStatePatch also closes the variable panel - // (but not an open errors panel) and resets the tab in this same block so - // the emptied selection can't strand a variable panel over nothing. + // planDelete removes the selection, the links and aliases touching it, and + // the clouds of deleted flows, and turns endpoints on deleted stocks into + // clouds; the controller derives deleteVariable and the stock list ops from + // the view difference. + const nextView = planDelete(view, selection); + // Clear the selection in the same synchronous block as the optimistic view, + // so React batches them into a single render: no consumer should ever + // observe a selection that references an element the view no longer + // contains. selectionStatePatch also closes the variable panel (but not an + // open errors panel) and resets the tab. setState(selectionStatePatch(new Set(), latest.current.state.showDetails)); - - if (deleteOps.length > 0) { - const patch: JsonProjectPatch = { - models: [{ name: mName, ops: deleteOps }], - }; - // The controller reports any failure via onError; we ignore the boolean - // here because the view update below must run regardless (matching the - // original, which committed the cloud/view changes even on a delete-op - // failure). - await applyPatchOrReportError(patch, 'delete'); - } - - await updateView({ ...view, elements, nextUid }, { recordHistory: true }); - scheduleSimRun(); + enqueueViewEdit('delete', nextView); }, []); - const handleMoveLabel = React.useCallback( - async (uid: UID, side: 'top' | 'left' | 'bottom' | 'right'): Promise => { - if (isReadOnly()) { - return; - } - const view = defined(getView()); - - const elements = view.elements.map((element: ViewElement) => { - if (element.uid !== uid || !isNamedViewElement(element)) { - return element; - } - return { ...element, labelSide: side }; - }); - - await updateView({ ...view, elements }, { recordHistory: true }); - }, - [], - ); - - const handleFlowAttach = React.useCallback( - async ( - flow: FlowViewElement, - targetUid: number, - cursorMoveDelta: Point, - fauxTargetCenter: Point | undefined, - inCreation: boolean, - isSourceAttach?: boolean, - ): Promise => { - if (isReadOnly()) { - return; - } - const view = defined(getView()); - const model = defined(getModel()); - - // Pure core: compute the new view (elements + nextUid), the model - // operations to apply, and the selection/creation state. See - // flow-attach.ts for the source/sink and op-builder deduplication. - const result = computeFlowAttachment(view, model.variables, { - flow, - targetUid, - cursorMoveDelta, - fauxTargetCenter, - inCreation, - isSourceAttach: !!isSourceAttach, - }); - - // The pure core only assigns a selection when creating a new flow; - // otherwise it returns undefined and the existing selection is preserved. - // This matches the original, which seeded `selection` from state and only - // reassigned it in the creation path. - const selection = result.selection ?? latest.current.state.selection; - - // Preserve the original's early return on a missing engine: it bailed - // before applying ops or updating the view (no setState, no sim run). - if (!r.controller?.getEngine()) { - return; - } - - if (result.ops.length > 0) { - const patch: JsonProjectPatch = { - models: [{ name: modelName(), ops: [...result.ops] }], - }; - // Apply the model-level ops but do NOT bail on failure: the drawn flow - // must still be committed to the view (issue #820). The controller - // reports any failure via onError (the toast); the view update below - // runs regardless, matching handleCreateVariable/handleSelectionDelete. - // Discarding it here previously both lost the user's work AND left the - // just-created-flow name edit selecting a flow that was never committed - // to the view -- the handleEditingNameDone getElementByUid crash. - await applyPatchOrReportError(patch, 'flow attach'); - } - - await updateView({ ...view, nextUid: result.nextUid, elements: [...result.elements] }, { recordHistory: true }); - // Creation assigns the new flow (non-empty) and arms flowStillBeingCreated - // (which suppresses the panel until the flow is named); a reattach - // preserves the prior selection. selectionStatePatch keeps the - // empty-selection invariant should either ever resolve to no selection. - setState({ - ...selectionStatePatch(selection, latest.current.state.showDetails), - flowStillBeingCreated: inCreation, - }); - scheduleSimRun(); - }, - [], - ); - - const handleLinkAttach = React.useCallback(async (link: LinkViewElement, newTarget: string): Promise => { - if (isReadOnly()) { + // A canvas gesture's commit (docs/design-plans/2026-09-10-diagram-editing-core.md, + // E2): the planner's elements are the next view exactly as the preview showed + // them, and the controller derives every model op from the view difference at + // dequeue. The token is the one the gesture was pressed under, so a truncation + // or undo that landed meanwhile drops the edit rather than applying it to a + // view it was not planned on. + const handleCommitGesture = React.useCallback((commit: GestureCommit): void => { + if (viewEditsRefused()) { return; } - let { selection } = latest.current.state; - let view = defined(getView()); - - const getName = (ident: string) => { - for (const e of view.elements) { - if (isNamedViewElement(e) && e.ident === ident) { - return e; - } - } - throw new Error(`unknown name ${ident}`); - }; - - let nextUid = view.nextUid; - let elements: ViewElement[]; - if (link.uid === inCreationUid) { - const to = getName(newTarget); - const newLink: LinkViewElement = { - ...link, - uid: nextUid++, - toUid: to.uid, - }; - elements = [...view.elements, newLink]; - selection = new Set([newLink.uid]); - } else { - // Reattachment: Canvas already computed the correct arc in - // link.arc, so we just update the target. - const to = getName(defined(newTarget)); - elements = view.elements.map((element: ViewElement) => { - if (element.uid !== link.uid || element.type !== 'link') { - return element; - } - return { ...element, arc: link.arc, toUid: to.uid }; - }); + const view = getView(); + if (!r.controller || !view || !sameGeometry(commit.baseView, view)) { + // A commit planned on a view another edit has since replaced is dropped + // quietly, as the Canvas drops a gesture whose view changed under it (E5). + return; } - view = { ...view, nextUid, elements }; - - await updateView(view, { recordHistory: true }); - setState(selectionStatePatch(selection, latest.current.state.showDetails)); + void r.controller.enqueueViewEdit({ + label: commit.label, + nextView: { ...view, nextUid: commit.nextUid, elements: [...commit.elements] }, + token: commit.token, + }); + // A drawn flow hands off to its name editor; flowStillBeingCreated keeps its + // details panel closed until it is named (handleRename clears it). If the + // edit fails, the rolled-back view no longer holds the selected flow, which + // the Canvas tolerates (the name editor resolves nothing and closes). + setState({ + ...selectionStatePatch(commit.selection, latest.current.state.showDetails), + flowStillBeingCreated: commit.editName !== undefined, + }); }, []); - const handleCreateVariable = React.useCallback(async (element: ViewElement): Promise => { + const handleCreateVariable = React.useCallback((element: ViewElement): string | undefined => { if (isReadOnly()) { - return; + return undefined; } - const view = defined(getView()); - // Parity with the pre-refactor `if (!engine) return`: bail before the - // optimistic view update if the engine hasn't finished opening yet, so a - // create attempted in that window cleanly no-ops. - if (!r.controller?.getEngine()) { - return; + const pausedRefusal = viewEditRefusal(); + if (pausedRefusal !== undefined) { + return pausedRefusal; } - - let nextUid = view.nextUid; - const elements = [...view.elements, { ...element, uid: nextUid++ }]; - const elementType = viewElementType(element); - const name = (element as NamedViewElement).name; - - let op: JsonModelOperation; - if (elementType === 'stock') { - op = { - type: 'upsertStock', - payload: { - stock: { - name, - inflows: [], - outflows: [], - initialEquation: '', - }, - }, - }; - } else if (elementType === 'flow') { - op = { - type: 'upsertFlow', - payload: { - flow: { - name, - equation: '', - }, - }, - }; - } else if (elementType === 'module') { - op = { - type: 'upsertModule', - payload: { - module: { - name, - modelName: '', - references: [], - }, - }, - }; - } else { - op = { - type: 'upsertAux', - payload: { - aux: { - name, - equation: '', - }, - }, - }; + const controller = r.controller; + const view = getView(); + if (!controller || !view || !isNamedViewElement(element)) { + return undefined; } - - // AC5.2: patch targets modelName() (not a hardcoded value), so module - // creation works at any nesting depth -- navigating into a child model - // updates modelName, and newly created modules land in that child. - const patch: JsonProjectPatch = { - models: [{ name: modelName(), ops: [op] }], - }; - - // The controller reports any failure via onError; the view update below - // runs regardless, matching the original (which committed the new element - // even when the upsert errored). - await applyPatchOrReportError(patch, 'variable creation'); - - await updateView({ ...view, nextUid, elements }, { recordHistory: true }); + // A typed name that another variable or a pending create already has keeps + // the name editor open with the message: the engine's upsert would silently + // replace that variable, whatever its kind. + const refusal = controller.nameError(element.name, undefined); + if (refusal !== undefined) { + return refusal; + } + // The created element names a variable that does not exist yet; the + // controller derives its upsert from the view difference. The edit targets + // the active model (AC5.2), so modules created while drilled in land there. + // The Canvas stages the element under its default name's ident; the element + // takes the typed name's, so every rendered element's ident is its name's. + enqueueViewEdit('variable creation', { + ...view, + nextUid: view.nextUid + 1, + elements: [...view.elements, { ...element, uid: view.nextUid, ident: canonicalize(element.name) }], + }); setState(selectionStatePatch(new Set(), latest.current.state.showDetails)); + return undefined; }, []); - const handleSelectionMove = React.useCallback( - async (delta: Point, arcPoint?: Point, segmentIndex?: number): Promise => { - if (isReadOnly()) { - return; - } - const view = defined(getView()); - const selection = latest.current.state.selection; - - const { updatedElements } = applyGroupMovement({ - elements: view.elements, - selection, - delta, - arcPoint, - segmentIndex, - }); - - const elements = view.elements.map((el) => updatedElements.get(el.uid) ?? el); - await updateView({ ...view, elements }, { recordHistory: true }); - }, - [], - ); - const handleDrawerToggle = React.useCallback((isOpen: boolean): void => { setState({ drawerOpen: isOpen, }); }, []); - const applySimSpecChange = async (updates: Partial): Promise => { + const applySimSpecChange = (updates: Partial): void => { // Sim specs are PROJECT content: read-only viewers cannot change them // (the drawer also renders its fields disabled). Gated on readOnlyMode, // not isReadOnly -- editing your own project's sim specs while viewing a @@ -1379,41 +1337,30 @@ export const Editor = React.memo(function Editor(props: EditorProps): React.Reac if (latest.current.props.readOnlyMode) { return; } - // The engine is re-checked inside applyPatchAndRefresh; here we only - // need the project to read the current sim specs. - const project = getProject(); - if (!project) { - return; - } - - const simSpec = project.simSpecs; - const dt = simSpec.dt.isReciprocal ? `1/${simSpec.dt.value}` : `${simSpec.dt.value}`; - - // Convert saveStep Dt to the actual numeric step size - let saveStep: number | undefined; - if (simSpec.saveStep) { - saveStep = simSpec.saveStep.isReciprocal ? 1 / simSpec.saveStep.value : simSpec.saveStep.value; - } - - const simSpecs: JsonSimSpecs = { - startTime: updates.startTime ?? simSpec.start, - endTime: updates.endTime ?? simSpec.stop, - dt: updates.dt ?? dt, - timeUnits: updates.timeUnits ?? simSpec.timeUnits, - saveStep: updates.saveStep ?? saveStep, - method: updates.method ?? simSpec.simMethod, - }; - - const patch: JsonProjectPatch = { - projectOps: [ - { - type: 'setSimSpecs', - payload: { simSpecs: simSpecs }, - }, - ], - }; - - await applyPatchAndRefresh(patch, 'sim specs'); + void r.controller?.enqueueModelEdit({ + label: 'sim specs', + // setSimSpecs replaces every field, so the untouched ones are echoed from + // the COMMITTED specs at dequeue, never from specs read before an earlier + // commit landed. + buildPatch: (committed) => { + const simSpec = committed.simSpecs; + const dt = simSpec.dt.isReciprocal ? `1/${simSpec.dt.value}` : `${simSpec.dt.value}`; + // Convert saveStep Dt to the actual numeric step size + let saveStep: number | undefined; + if (simSpec.saveStep) { + saveStep = simSpec.saveStep.isReciprocal ? 1 / simSpec.saveStep.value : simSpec.saveStep.value; + } + const simSpecs: JsonSimSpecs = { + startTime: updates.startTime ?? simSpec.start, + endTime: updates.endTime ?? simSpec.stop, + dt: updates.dt ?? dt, + timeUnits: updates.timeUnits ?? simSpec.timeUnits, + saveStep: updates.saveStep ?? saveStep, + method: updates.method ?? simSpec.simMethod, + }; + return { projectOps: [{ type: 'setSimSpecs', payload: { simSpecs } }] }; + }, + }); }; // The drawer holds a draft while a sim-specs field is focused and calls this @@ -1439,12 +1386,25 @@ export const Editor = React.memo(function Editor(props: EditorProps): React.Reac }, []); const handleDownloadXmile = React.useCallback(async (): Promise => { - const engine = getEngineProject(); - if (!engine) { + const controller = r.controller; + if (!controller) { + return; + } + const result = await controller.query(async (engine): Promise<{ xmile: string } | { error: unknown }> => { + try { + return { xmile: await (engine as unknown as EngineProject).toXmileString() }; + } catch (error: unknown) { + return { error }; + } + }); + if (result === undefined) { return; } try { - const xmile = await engine.toXmileString(); + if ('error' in result) { + throw result.error; + } + const xmile = result.xmile; const encoder = new TextEncoder(); const xmileBytes = encoder.encode(xmile); const blob = new Blob([xmileBytes], { @@ -1534,13 +1494,18 @@ export const Editor = React.memo(function Editor(props: EditorProps): React.Reac return model.views[0]; }; - const handleViewBoxChange = React.useCallback(async (viewBox: Rect, zoom: number): Promise => { - const view = defined(getView()); - await queueViewUpdate({ ...view, viewBox, zoom }); + // Viewport changes (a settled pan/zoom, a resize, the mount fit, centering) + // render at once and persist through a controller viewport item: no undo + // entry, no save. + const handleViewBoxChange = React.useCallback((viewBox: Rect, zoom: number): void => { + r.controller?.setViewport(modelName(), { viewBox, zoom }); }, []); - const centerVariable = async (element: ViewElement): Promise => { - const view = defined(getView()); + const centerVariable = (element: ViewElement): void => { + const view = getView(); + if (!view) { + return; + } const zoom = view.zoom; const cx = element.x; @@ -1555,9 +1520,13 @@ export const Editor = React.memo(function Editor(props: EditorProps): React.Reac y: viewCy - cy, }; - await queueViewUpdate({ ...view, viewBox }); + handleViewBoxChange(viewBox, zoom); }; + const handleNewVariableName = React.useCallback((base: string): string => { + return r.controller?.newVariableName(base) ?? base; + }, []); + const getCanvas = (): React.ReactElement | undefined => { const project = getProject(); if (!project) { @@ -1584,10 +1553,7 @@ export const Editor = React.memo(function Editor(props: EditorProps): React.Reac const readOnly = isReadOnly(); const onRenameVariable = !readOnly ? handleRename : noopRename; const onSetSelection = !embedded ? handleSelection : noopSetSelection; - const onMoveSelection = !readOnly ? handleSelectionMove : noopMoveSelection; - const onMoveFlow = !readOnly ? handleFlowAttach : noopMoveFlow; - const onMoveLabel = !readOnly ? handleMoveLabel : noopMoveLabel; - const onAttachLink = !readOnly ? handleLinkAttach : noopAttachLink; + const onCommitGesture = !readOnly ? handleCommitGesture : noopCommitGesture; const onCreateVariable = !readOnly ? handleCreateVariable : noopCreateVariable; const onClearSelectedTool = !readOnly ? handleClearSelectedTool : noop; const onDeleteSelection = !readOnly ? handleSelectionDelete : noop; @@ -1605,21 +1571,20 @@ export const Editor = React.memo(function Editor(props: EditorProps): React.Reac project={project} model={model} view={view} - version={latest.current.state.controllerSnapshot.projectVersion} + token={latest.current.state.controllerSnapshot.token} selectedTool={readOnly ? undefined : latest.current.state.selectedTool} selection={latest.current.state.selection} onRenameVariable={onRenameVariable} onSetSelection={onSetSelection} - onMoveSelection={onMoveSelection} - onMoveFlow={onMoveFlow} - onMoveLabel={onMoveLabel} - onAttachLink={onAttachLink} + onCommitGesture={onCommitGesture} onCreateVariable={onCreateVariable} onClearSelectedTool={onClearSelectedTool} onDeleteSelection={onDeleteSelection} onShowVariableDetails={onShowVariableDetails} onViewBoxChange={onViewBoxChange} onDrillIntoModule={onDrillIntoModule} + newVariableName={handleNewVariableName} + pressesDisabled={latest.current.state.controllerSnapshot.undoRedoQueued} /> ); }; @@ -1812,7 +1777,7 @@ export const Editor = React.memo(function Editor(props: EditorProps): React.Reac setState({ showDetails: isModelLocked() ? undefined : 'variable', }); - await centerVariable(element); + centerVariable(element); } }, [], @@ -1928,117 +1893,155 @@ export const Editor = React.memo(function Editor(props: EditorProps): React.Reac }; const handleEquationChange = React.useCallback( - async ( + ( ident: string, newEquation: string | undefined, newUnits: string | undefined, newDocs: string | undefined, - ): Promise => { + ): Promise => { if (isReadOnly()) { - return; - } - const model = getModel(); - if (!model) { - return; + return Promise.resolve(false); } + const landed = enqueueVariableEdit( + 'equation update', + ident, + [newEquation, newUnits, newDocs], + (variable, _committed, mName) => ({ + models: [{ name: mName, ops: [equationChangeOp(variable, newEquation, newUnits, newDocs)] }], + }), + ); + recordPanelSubmission(ident, { equation: newEquation, units: newUnits, docs: newDocs }, landed); + return landed; + }, + [], + ); - const variable = model.variables.get(ident); - if (!variable) { + // Record a details-panel submission as the element's pending one (see + // EditorRefs.pendingPanelSubmissions). Each field's entry is removed once its + // edit settles, unless a newer submission for that field replaced it. + const recordPanelSubmission = ( + ident: string, + fields: Partial>, + landed: Promise, + ): void => { + const uid = renderedElementUid(ident); + if (uid === undefined) { + return; + } + const key = JSON.stringify([modelName(), uid]); + const entries: PendingSubmission = { ...r.pendingPanelSubmissions.get(key) }; + const recorded: Array<'equation' | 'units' | 'docs'> = []; + for (const field of ['equation', 'units', 'docs'] as const) { + const text = fields[field]; + if (text !== undefined) { + entries[field] = { text, landed }; + recorded.push(field); + } + } + r.pendingPanelSubmissions.set(key, entries); + void landed.finally(() => { + const current = r.pendingPanelSubmissions.get(key); + if (current === undefined) { return; } + const next: PendingSubmission = { ...current }; + for (const field of recorded) { + if (next[field]?.landed === landed && next[field]?.text === fields[field]) { + delete next[field]; + } + } + if (Object.keys(next).length === 0) { + r.pendingPanelSubmissions.delete(key); + } else { + r.pendingPanelSubmissions.set(key, next); + } + }); + }; - // When newEquation is provided, use it as a scalar equation. - // Otherwise, preserve the existing equation structure (including arrayed equations). - const existingEqFields = getEquationFields(variable); + // The full upsert for an equation/units/docs change of `variable` (the + // committed variable, at dequeue). The *ToJson serializers preserve every + // field (compat flags included); the edited fields override. + const equationChangeOp = ( + variable: Variable, + newEquation: string | undefined, + newUnits: string | undefined, + newDocs: string | undefined, + ): JsonModelOperation => { + // When newEquation is provided, use it as a scalar equation. + // Otherwise, preserve the existing equation structure (including arrayed equations). + const existingEqFields = getEquationFields(variable); - let op: JsonModelOperation; - if (variable.type === 'stock') { - // Use stockToJson to preserve all fields (including compat flags - // like nonNegative, canBeModuleInput, isPublic), then override - // the fields being edited. - const base = stockToJson(variable); - op = { - type: 'upsertStock', - payload: { - stock: { - ...base, - initialEquation: newEquation ?? existingEqFields.equation, - arrayedEquation: newEquation !== undefined ? undefined : existingEqFields.arrayedEquation, - units: newUnits ?? variable.units ?? undefined, - documentation: newDocs ?? variable.documentation ?? undefined, - }, + let op: JsonModelOperation; + if (variable.type === 'stock') { + // Use stockToJson to preserve all fields (including compat flags + // like nonNegative, canBeModuleInput, isPublic), then override + // the fields being edited. + const base = stockToJson(variable); + op = { + type: 'upsertStock', + payload: { + stock: { + ...base, + initialEquation: newEquation ?? existingEqFields.equation, + arrayedEquation: newEquation !== undefined ? undefined : existingEqFields.arrayedEquation, + units: newUnits ?? variable.units ?? undefined, + documentation: newDocs ?? variable.documentation ?? undefined, }, - }; - } else if (variable.type === 'flow') { - const base = flowToJson(variable); - op = { - type: 'upsertFlow', - payload: { - flow: { - ...base, - equation: newEquation ?? existingEqFields.equation, - arrayedEquation: newEquation !== undefined ? undefined : existingEqFields.arrayedEquation, - units: newUnits ?? variable.units ?? undefined, - documentation: newDocs ?? variable.documentation ?? undefined, - }, + }, + }; + } else if (variable.type === 'flow') { + const base = flowToJson(variable); + op = { + type: 'upsertFlow', + payload: { + flow: { + ...base, + equation: newEquation ?? existingEqFields.equation, + arrayedEquation: newEquation !== undefined ? undefined : existingEqFields.arrayedEquation, + units: newUnits ?? variable.units ?? undefined, + documentation: newDocs ?? variable.documentation ?? undefined, }, - }; - } else if (variable.type === 'module') { - // Modules have no equations or graphical functions -- only units and docs. - // Use moduleToJson to preserve all fields (including compat flags - // canBeModuleInput, isPublic, dataSource), then override edited fields. - const base = moduleToJson(variable); - op = { - type: 'upsertModule', - payload: { - module: { - ...base, - units: newUnits ?? variable.units ?? undefined, - documentation: newDocs ?? variable.documentation ?? undefined, - }, + }, + }; + } else if (variable.type === 'module') { + // Modules have no equations or graphical functions -- only units and docs. + // Use moduleToJson to preserve all fields (including compat flags + // canBeModuleInput, isPublic, dataSource), then override edited fields. + const base = moduleToJson(variable); + op = { + type: 'upsertModule', + payload: { + module: { + ...base, + units: newUnits ?? variable.units ?? undefined, + documentation: newDocs ?? variable.documentation ?? undefined, }, - }; - } else { - const auxVar = variable as Aux; - const base = auxToJson(auxVar); - op = { - type: 'upsertAux', - payload: { - aux: { - ...base, - equation: newEquation ?? existingEqFields.equation, - arrayedEquation: newEquation !== undefined ? undefined : existingEqFields.arrayedEquation, - units: newUnits ?? auxVar.units ?? undefined, - documentation: newDocs ?? auxVar.documentation ?? undefined, - }, + }, + }; + } else { + const auxVar = variable as Aux; + const base = auxToJson(auxVar); + op = { + type: 'upsertAux', + payload: { + aux: { + ...base, + equation: newEquation ?? existingEqFields.equation, + arrayedEquation: newEquation !== undefined ? undefined : existingEqFields.arrayedEquation, + units: newUnits ?? auxVar.units ?? undefined, + documentation: newDocs ?? auxVar.documentation ?? undefined, }, - }; - } - - const patch: JsonProjectPatch = { - models: [{ name: modelName(), ops: [op] }], + }, }; + } + return op; + }; - await applyPatchAndRefresh(patch, 'equation update'); - }, - [], - ); - - const handleTableChange = React.useCallback( - async (ident: string, newTable: GraphicalFunction | null): Promise => { - if (isReadOnly()) { - return; - } - const model = getModel(); - if (!model) { - return; - } - - const variable = model.variables.get(ident); - if (!variable) { - return; - } - + const handleTableChange = React.useCallback((ident: string, newTable: GraphicalFunction | null): void => { + if (isReadOnly()) { + return; + } + enqueueVariableEdit('table update', ident, newTable, (variable, _committed, mName) => { const gf = newTable ? { yPoints: [...newTable.yPoints], @@ -2082,75 +2085,65 @@ export const Editor = React.memo(function Editor(props: EditorProps): React.Reac }, }; } + return { models: [{ name: mName, ops: [op] }] }; + }); + }, []); - const patch: JsonProjectPatch = { - models: [{ name: modelName(), ops: [op] }], - }; - - await applyPatchAndRefresh(patch, 'table update'); - }, - [], - ); + // The module variable an edit builder reads; a variable that changed kind + // since the panel opened fails the item. + const asModule = (variable: Variable, label: string) => { + if (variable.type !== 'module') { + throw new EditorError(`${label} failed: '${variable.ident}' is no longer a module`); + } + return variable; + }; // Updates the model reference for a module variable. - const handleModuleModelReferenceChange = React.useCallback( - async (ident: string, newModelName: string): Promise => { - if (isReadOnly()) { - return; - } - const model = getModel(); - if (!model) return; - const variable = model.variables.get(ident); - if (!variable || variable.type !== 'module') return; - - // Preserve all fields (including compat) via moduleToJson; override the model ref. - const op: JsonModelOperation = { - type: 'upsertModule', - payload: { - module: { - ...moduleToJson(variable), - modelName: newModelName, - }, + const handleModuleModelReferenceChange = React.useCallback((ident: string, newModelName: string): void => { + if (isReadOnly()) { + return; + } + enqueueVariableEdit('model reference update', ident, newModelName, (variable, _committed, mName) => ({ + models: [ + { + name: mName, + // Preserve all fields (including compat) via moduleToJson; override the model ref. + ops: [ + { + type: 'upsertModule', + payload: { + module: { ...moduleToJson(asModule(variable, 'model reference update')), modelName: newModelName }, + }, + }, + ], }, - }; - - const patch: JsonProjectPatch = { - models: [{ name: modelName(), ops: [op] }], - }; - - await applyPatchAndRefresh(patch, 'model reference update'); - }, - [], - ); + ], + })); + }, []); // Updates units and/or documentation for a module variable. const handleModuleUnitsDocsChange = React.useCallback( - async (ident: string, newUnits: string | undefined, newDocs: string | undefined): Promise => { + (ident: string, newUnits: string | undefined, newDocs: string | undefined): Promise => { if (isReadOnly()) { - return; + return Promise.resolve(false); } - const model = getModel(); - if (!model) return; - const variable = model.variables.get(ident); - if (!variable || variable.type !== 'module') return; - - // Preserve all fields (including compat) via moduleToJson; override units/docs. - const op: JsonModelOperation = { - type: 'upsertModule', - payload: { - module: { - ...moduleToJson(variable), - units: newUnits ?? variable.units ?? undefined, - documentation: newDocs ?? variable.documentation ?? undefined, + const landed = enqueueVariableEdit('module update', ident, [newUnits, newDocs], (variable, _committed, mName) => { + const module = asModule(variable, 'module update'); + // Preserve all fields (including compat) via moduleToJson; override units/docs. + const op: JsonModelOperation = { + type: 'upsertModule', + payload: { + module: { + ...moduleToJson(module), + units: newUnits ?? module.units ?? undefined, + documentation: newDocs ?? module.documentation ?? undefined, + }, }, - }, - }; - - const patch: JsonProjectPatch = { - models: [{ name: modelName(), ops: [op] }], - }; - - await applyPatchAndRefresh(patch, 'module update'); + }; + return { models: [{ name: mName, ops: [op] }] }; + }); + recordPanelSubmission(ident, { units: newUnits, docs: newDocs }, landed); + return landed; }, [], ); @@ -2159,31 +2152,25 @@ export const Editor = React.memo(function Editor(props: EditorProps): React.Reac // The engine does full variable replacement (not merge), so we send the // complete module with the new references array. const handleModuleReferencesChange = React.useCallback( - async (ident: string, newReferences: ReadonlyArray): Promise => { + (ident: string, newReferences: ReadonlyArray): void => { if (isReadOnly()) { return; } - const model = getModel(); - if (!model) return; - const variable = model.variables.get(ident); - if (!variable || variable.type !== 'module') return; - - // Preserve all fields (including compat) via moduleToJson; override references. - const op: JsonModelOperation = { - type: 'upsertModule', - payload: { - module: { - ...moduleToJson(variable), - references: newReferences.map((ref) => ({ src: ref.src, dst: ref.dst })), + const references = newReferences.map((ref) => ({ src: ref.src, dst: ref.dst })); + enqueueVariableEdit('references update', ident, references, (variable, _committed, mName) => ({ + models: [ + { + name: mName, + // Preserve all fields (including compat) via moduleToJson; override references. + ops: [ + { + type: 'upsertModule', + payload: { module: { ...moduleToJson(asModule(variable, 'references update')), references } }, + }, + ], }, - }, - }; - - const patch: JsonProjectPatch = { - models: [{ name: modelName(), ops: [op] }], - }; - - await applyPatchAndRefresh(patch, 'references update'); + ], + })); }, [], ); @@ -2191,117 +2178,129 @@ export const Editor = React.memo(function Editor(props: EditorProps): React.Reac // Creates a new empty model and sets it as the module's reference. // The engine processes projectOps before model ops (see patch.rs), // so AddModel creates the model before upsertModule references it. - const handleCreateModelForModule = React.useCallback(async (moduleIdent: string): Promise => { + const handleCreateModelForModule = React.useCallback((moduleIdent: string): void => { if (isReadOnly()) { return; } - const project = getProject(); - if (!project) return; + const mName = modelName(); + const uid = renderedElementUid(moduleIdent); + void r.controller?.enqueueModelEdit({ + label: 'model creation', + buildPatch: (committed) => { + // Generate a unique model name to avoid collisions when the module + // ident already matches an existing model name. + let newModelName = moduleIdent; + if (committed.models.has(newModelName)) { + newModelName = getUniqueDuplicateName(moduleIdent, committed); + } + // Look up the committed module to preserve metadata (including compat) + // through the model reference change; the shared helper carries every + // field forward and keeps this in lockstep with the duplicate-model path. + const existingModule = committedVariable(committed, mName, uid, moduleIdent); + const modulePayload = buildModuleReferencePayload( + existingModule, + existingModule?.ident ?? moduleIdent, + newModelName, + ); + return { + projectOps: [{ type: 'addModel', payload: { name: newModelName } }], + models: [ + // Seed a default empty view so getCanvas() works after drilling in + { + name: newModelName, + ops: [{ type: 'upsertView', payload: { index: 0, view: { elements: [] } } }], + }, + { + name: mName, + ops: [{ type: 'upsertModule', payload: { module: modulePayload } }], + }, + ], + }; + }, + }); + }, []); - // Generate a unique model name to avoid collisions when the module - // ident already matches an existing model name. - let newModelName = moduleIdent; - if (project.models.has(newModelName)) { - newModelName = getUniqueDuplicateName(moduleIdent, project); + // Duplicates the source model and sets the copy as the module's reference. + // Copies all variables and the primary view from the source model. + const handleDuplicateModelForModule = React.useCallback((moduleIdent: string, sourceModelName: string): void => { + if (isReadOnly()) { + return; } + const mName = modelName(); + const uid = renderedElementUid(moduleIdent); + void r.controller?.enqueueModelEdit({ + label: 'model duplication', + buildPatch: (committed) => { + const sourceModel = committed.models.get(sourceModelName); + if (!sourceModel) { + throw new EditorError(`model duplication failed: model '${sourceModelName}' no longer exists`); + } + const existingModule = committedVariable(committed, mName, uid, moduleIdent); + return duplicateModelPatch(committed, sourceModel, existingModule, existingModule?.ident ?? moduleIdent, mName); + }, + }); + }, []); - // Look up existing module to preserve metadata (including compat) through the - // model reference change; the shared helper carries every field forward - // (incl. compat) and keeps this in lockstep with the duplicate-model path. - const model = getModel(); - const existingModule = model?.variables.get(moduleIdent); - const modulePayload = buildModuleReferencePayload(existingModule, moduleIdent, newModelName); + const duplicateModelPatch = ( + project: Project, + sourceModel: Model, + existingModule: Variable | undefined, + moduleIdent: string, + mName: string, + ): JsonProjectPatch => { + const newModelName = getUniqueDuplicateName(sourceModel.name, project); + + // Build ops to copy all variables from source model + const variableOps: JsonModelOperation[] = []; + for (const variable of sourceModel.variables.values()) { + if (variable.type === 'stock') { + variableOps.push({ type: 'upsertStock', payload: { stock: stockToJson(variable) } }); + } else if (variable.type === 'flow') { + variableOps.push({ type: 'upsertFlow', payload: { flow: flowToJson(variable) } }); + } else if (variable.type === 'aux') { + variableOps.push({ type: 'upsertAux', payload: { aux: auxToJson(variable) } }); + } else if (variable.type === 'module') { + variableOps.push({ type: 'upsertModule', payload: { module: moduleToJson(variable) } }); + } + } + + // Copy the primary view, or seed an empty one so getCanvas() works + if (sourceModel.views.length > 0) { + variableOps.push({ + type: 'upsertView', + payload: { index: 0, view: stockFlowViewToJson(sourceModel.views[0]) }, + }); + } else { + variableOps.push({ + type: 'upsertView', + payload: { index: 0, view: { elements: [] } }, + }); + } + + // Preserve ALL existing module fields (incl. compat: canBeModuleInput / + // isPublic / dataSource) through the model reference change; the shared + // helper keeps this in lockstep with the create-model path, so a + // full-replacement upsert never drops a compat flag. + const dupModulePayload = buildModuleReferencePayload(existingModule, moduleIdent, newModelName); - const patch: JsonProjectPatch = { + // Combined patch: create model, copy contents, update module reference. + // Engine processes projectOps before model ops (patch.rs). + return { projectOps: [{ type: 'addModel', payload: { name: newModelName } }], models: [ - // Seed a default empty view so getCanvas() works after drilling in - { - name: newModelName, - ops: [{ type: 'upsertView', payload: { index: 0, view: { elements: [] } } }], - }, + { name: newModelName, ops: variableOps }, { - name: modelName(), - ops: [{ type: 'upsertModule', payload: { module: modulePayload } }], + name: mName, + ops: [ + { + type: 'upsertModule', + payload: { module: dupModulePayload }, + }, + ], }, ], }; - - await applyPatchAndRefresh(patch, 'model creation'); - }, []); - - // Duplicates the source model and sets the copy as the module's reference. - // Copies all variables and the primary view from the source model. - const handleDuplicateModelForModule = React.useCallback( - async (moduleIdent: string, sourceModelName: string): Promise => { - if (isReadOnly()) { - return; - } - const project = getProject(); - if (!project) return; - - const sourceModel = project.models.get(sourceModelName); - if (!sourceModel) return; - - const newModelName = getUniqueDuplicateName(sourceModelName, project); - - // Build ops to copy all variables from source model - const variableOps: JsonModelOperation[] = []; - for (const variable of sourceModel.variables.values()) { - if (variable.type === 'stock') { - variableOps.push({ type: 'upsertStock', payload: { stock: stockToJson(variable) } }); - } else if (variable.type === 'flow') { - variableOps.push({ type: 'upsertFlow', payload: { flow: flowToJson(variable) } }); - } else if (variable.type === 'aux') { - variableOps.push({ type: 'upsertAux', payload: { aux: auxToJson(variable) } }); - } else if (variable.type === 'module') { - variableOps.push({ type: 'upsertModule', payload: { module: moduleToJson(variable) } }); - } - } - - // Copy the primary view, or seed an empty one so getCanvas() works - if (sourceModel.views.length > 0) { - variableOps.push({ - type: 'upsertView', - payload: { index: 0, view: stockFlowViewToJson(sourceModel.views[0]) }, - }); - } else { - variableOps.push({ - type: 'upsertView', - payload: { index: 0, view: { elements: [] } }, - }); - } - - // Preserve ALL existing module fields (incl. compat: canBeModuleInput / - // isPublic / dataSource) through the model reference change. The hand-built - // payload here previously dropped compat -- the same full-replace data-loss - // trap fixed elsewhere; use the shared helper so it matches its sibling. - const currentModel = getModel(); - const existingModule = currentModel?.variables.get(moduleIdent); - const dupModulePayload = buildModuleReferencePayload(existingModule, moduleIdent, newModelName); - - // Combined patch: create model, copy contents, update module reference. - // Engine processes projectOps before model ops (patch.rs). - const patch: JsonProjectPatch = { - projectOps: [{ type: 'addModel', payload: { name: newModelName } }], - models: [ - { name: newModelName, ops: variableOps }, - { - name: modelName(), - ops: [ - { - type: 'upsertModule', - payload: { module: dupModulePayload }, - }, - ], - }, - ], - }; - - await applyPatchAndRefresh(patch, 'model duplication'); - }, - [], - ); + }; const getUniqueDuplicateName = (baseName: string, project: Project): string => { let name = `${baseName}_copy`; @@ -2319,13 +2318,14 @@ export const Editor = React.memo(function Editor(props: EditorProps): React.Reac const { cachedErrors } = latest.current.state.controllerSnapshot; return ( -
+
); @@ -2410,25 +2410,49 @@ export const Editor = React.memo(function Editor(props: EditorProps): React.Reac return; } - // The read-only flag is part of the panel key: the panels seed their Slate - // editors once per mount (see "Details panels are keyed by - // projectGeneration" in diagram/CLAUDE.md), so a mid-session readOnlyMode - // flip REMOUNTS an open panel rather than toggling it in place. That makes - // the flip deterministic: any in-flight typed text is discarded and the - // panel re-seeds from the committed model state. + // The panel key (detailsPanelKey) is the selected element plus its + // variable's committed editable content plus the read-only flag: the panels + // seed their Slate editors once per mount (see "Details panels are keyed by + // the selected variable's committed content" in diagram/CLAUDE.md), so a + // landed edit to this variable, or a mid-session readOnlyMode flip, REMOUNTS + // an open panel rather than toggling it in place. A flip is deterministic: + // any in-flight typed text is discarded and the panel re-seeds from the + // committed state. const readOnly = isReadOnly(); - const keySuffix = readOnly ? '-ro' : ''; + const restoreSeq = latest.current.state.controllerSnapshot.restoreSeq; + // While the panel holds a draft its key is held: a landed edit (to this + // variable, or one that rewrote it, as a rename of a name its equation + // references does) must not remount the panel over text the user typed. + // The key advances once the panel reports no draft -- its draft landed, or + // was cancelled. Selecting another element, an undo/redo landing + // (restoreSeq) and a read-only flip still remount at once. + const mName = modelName(); + const freshKey = detailsPanelKey(mName, namedElement.uid, variable, restoreSeq, readOnly); + const base = JSON.stringify([mName, namedElement.uid, restoreSeq, readOnly]); + const held = r.heldPanelKey; + const key = latest.current.state.panelHasDraft && held?.base === base ? held.key : freshKey; + // Idempotent for a given state, so a StrictMode double render is harmless. + r.heldPanelKey = { base, key }; + // While an undo/redo is queued the panel renders read-only, as the Canvas + // ignores presses then: the undo's landing remounts the panel (restoreSeq), + // which would discard text typed meanwhile. Not part of the key, so a draft + // already submitted keeps its panel until then. + const panelReadOnly = readOnly || latest.current.state.controllerSnapshot.undoRedoQueued === true; + const pendingSubmission = r.pendingPanelSubmissions.get(JSON.stringify([mName, namedElement.uid])); if (variable.type === 'module') { return ( -
+
+
({ selectedTool: prev.selectedTool === 'module' ? undefined : 'module' })); }, []); - // Undo/redo is fully owned by the controller: it moves the undo cursor, - // bumps version/generation synchronously (so the details panels remount), - // reopens the engine from the restored snapshot, and -- when the restored - // project no longer contains the viewed model -- resets navigation to 'main' - // and bumps navResetSeq, which the navReset effect observes to clear the - // Editor's selection/details/tool UI state. + // Undo/redo is owned by the controller: an edit-class item that reopens the + // engine from the restored snapshot when it runs, bumps the token, and -- + // when the restored project no longer contains the viewed model -- resets + // navigation to 'main' and bumps navResetSeq, which the navReset effect + // observes to clear the Editor's selection/details/tool UI state. The + // controller refuses it while an edit is pending (snapshot canUndo/canRedo). const handleUndoRedo = React.useCallback((kind: 'undo' | 'redo'): void => { // Undo/redo rewrite project content; a read-only viewer gets neither // (issue #935). This is the single choke point covering the UndoRedoBar // buttons and the keyboard shortcut alike. Project-scoped gate: see the - // keyboard handler's comment for why stdlib views keep undo. - if (latest.current.props.readOnlyMode) { + // keyboard handler's comment for why stdlib views keep undo. A live gesture + // blocks it too: the gesture's commit could not apply to the restored view. + if (latest.current.props.readOnlyMode || r.gestureLive) { return; } - r.controller?.undoRedo(kind); + // An open panel's draft is the user's latest change. Undo submits it first + // and queues the undo behind its edit, so the undo takes the draft back (and + // a redo restores it) rather than undoing an older edit under text the panel + // would then discard. Redo goes the other way: the redo is queued first and + // the draft submitted behind it -- the draft's payload derives from + // committed state at dequeue, so it applies on top of the redone project and + // neither is lost (submitting first would record the draft and discard the + // redo branch). + if (kind === 'undo') { + const submittedDraft = r.draftFlush?.() ?? false; + r.controller?.undoRedo(kind, { afterQueuedEdits: submittedDraft }); + } else { + r.controller?.undoRedo(kind); + r.draftFlush?.(); + } + }, []); + + const handleDraftStateChange = React.useCallback((hasDraft: boolean): void => { + setStateRaw((prev) => (prev.panelHasDraft === hasDraft ? prev : { ...prev, panelHasDraft: hasDraft })); }, []); - const handleZoomChange = React.useCallback(async (newZoom: number): Promise => { - const view = defined(getView()); + const handleReload = React.useCallback((): void => { + const onReload = latest.current.props.onReload; + if (onReload) { + onReload(); + } else { + window.location.reload(); + } + }, []); + + // The one persistent notice for a lost engine (ProjectSnapshot.engineUnavailable): + // every edit is refused quietly from then on, so this is the only report. + const getEngineUnavailableNotice = (): React.ReactElement | undefined => { + if (props.embedded || !latest.current.state.controllerSnapshot.engineUnavailable) { + return undefined; + } + return ( +
+

The model engine stopped working

+

+ Changes can no longer be saved, and changes since the last save may be lost. Reload to continue from the last + saved version. +

+
+ +
+
+ ); + }; + + const handleZoomChange = React.useCallback((newZoom: number): void => { + const view = getView(); + if (!view) { + return; + } const oldViewBox = view.viewBox; const widthAdjust = latest.current.state.showDetails ? panelWidth() : 0; @@ -2551,7 +2631,7 @@ export const Editor = React.memo(function Editor(props: EditorProps): React.Reac x: oldViewBox.x + diffX, y: oldViewBox.y + diffY, }; - await handleViewBoxChange(newViewBox, newZoom); + handleViewBoxChange(newViewBox, newZoom); }, []); // True once the unmount cleanup has cleared the controller. The snapshot @@ -2790,6 +2870,7 @@ export const Editor = React.memo(function Editor(props: EditorProps): React.Reac {getDetails(sharedModelBannerInfo.visible)} {getSearchBar()} {getSharedModelBanner(sharedModelBannerInfo)} + {getEngineUnavailableNotice()} {getCanvas()} {getSnackbar()} {getEditorControls()} diff --git a/src/diagram/ErrorDetails.tsx b/src/diagram/ErrorDetails.tsx index ebfa3a5f5..a226f083d 100644 --- a/src/diagram/ErrorDetails.tsx +++ b/src/diagram/ErrorDetails.tsx @@ -4,7 +4,7 @@ import * as React from 'react'; -import { SimError, ModelError, EquationError, ErrorCode, UnitError } from '@simlin/core/datamodel'; +import { SimError, ModelError, EquationError, ErrorCode, UnitError, VariableWarning } from '@simlin/core/datamodel'; import { errorCodeDescription } from '@simlin/engine'; import styles from './ErrorDetails.module.css'; @@ -14,6 +14,7 @@ interface ErrorDetailsProps { modelErrors: readonly ModelError[]; varErrors: ReadonlyMap; varUnitErrors: ReadonlyMap; + varWarnings: ReadonlyMap; status: 'ok' | 'error' | 'disabled'; } @@ -22,6 +23,7 @@ export function ErrorDetails({ modelErrors, varErrors, varUnitErrors, + varWarnings, }: ErrorDetailsProps): React.ReactElement { const errors = []; if ( @@ -72,6 +74,17 @@ export function ErrorDetails({ ); } } + for (const [ident, warnings] of varWarnings) { + warnings.forEach((warning, i) => { + // An advisory's code is often the wire Generic, so its details are the + // message; the code description is only the fallback. + errors.push( +
+ variable "{ident}" warning: {warning.details ?? errorCodeDescription(warning.code)} +
, + ); + }); + } return (
diff --git a/src/diagram/LAYOUT.md b/src/diagram/LAYOUT.md index 00a5bbfa9..b10835169 100644 --- a/src/diagram/LAYOUT.md +++ b/src/diagram/LAYOUT.md @@ -143,35 +143,37 @@ Point { 1. **Must have at least 2 points** (source and destination) 2. **First point** connects to source (stock or cloud) 3. **Last point** connects to sink (stock or cloud) -4. **Intermediate points** define the path shape. Multi-segment (L- and - Z-shaped) flows are fully supported: stocks moving off-axis produce a - corner point, endpoint drags with a dominant perpendicular component - convert a straight flow to an L, and interior segments of 3+ point flows - can be dragged perpendicular to their direction. `normalizeFlowPoints` - removes zero-length and colinear interior segments after every mutation so - segments strictly alternate horizontal/vertical. -5. **Flows are constrained** to horizontal or vertical segments when connected to stocks -6. **Stock connections**: - - Endpoints snap to stock edges (±width/2 or ±height/2) - - Must be at least 3px from corners - - Multiple flows attaching to the same stock side spread evenly along the - edge (n flows sit at i/(n+1) fractions; straight flows keep their - anchor-determined position but still reserve a slot) -7. **Cloud connections**: +4. **Intermediate points** define the path shape. Every segment is horizontal + or vertical (cloud-to-cloud flows included), and an edited path has no + zero-length segment and no two consecutive collinear segments, so segments + alternate horizontal/vertical. Straight, L-, Z- and bracket-shaped flows are + all ordinary. +5. **Stock connections**: + - The endpoint lies on a face of the stock (±StockWidth/2 or ±StockHeight/2 + from its center), at least 3px (`CORNER_CLEARANCE`) from the corners + - The segment touching the stock leaves the face perpendicularly, outward, + and no segment passes through a terminal stock; an edit also routes around + every other stock where a route around exists (a pipe through a stock reads + as attached to it), including one an end has just been detached from + - A flow newly landing on a face takes a slot at least 10px (`PIPE_SPACING`) + from the endpoints already on that face when one exists; sliding along a + face never re-spreads the other flows +6. **Cloud connections**: - The endpoint sits at the cloud center; the rendered path is retracted by CloudRadius (13.5px) so the arrowhead lands on the cloud's edge - - Clouds adjust position when dragged + - A cloud moves with its flow's end: dragging the cloud drags that end +7. **Valve**: lies on the path, at least 10px (`VALVE_CLAMP_MARGIN`) from its + ends when the path is long enough **Path Rendering**: - Path is drawn as SVG path using M (move) and L (line) commands - Final segment adjusted by 7.5px (finalAdjust) to accommodate arrowhead - Arrowhead angle snapped to cardinal directions (0°, 90°, 180°, 270°) -**Movement Constraints**: -- **Horizontal flows**: All points have same y-coordinate -- **Vertical flows**: All points have same x-coordinate -- **Valve movement**: Constrained within bounds between connected elements -- **Cloud-to-cloud flows**: Can be diagonal (no axis constraint) +**Imported geometry**: a saved or imported flow may break these rules (diagonal +segments, ends with no attachment from a Vensim import, a valve off the path). +It renders unmodified; the first edit that routes it repairs it (`heal` in +`flow-geometry/`), giving any unattached end a cloud. ### Auxiliary Variables @@ -255,11 +257,9 @@ CloudViewElement { **Special Properties**: - **No label**: Clouds are unnamed (no name or ident property) -- **Always attached**: Must be connected to exactly one flow -- **Position constraints**: - - When flow is horizontal: cloud can only move along x-axis - - When flow is vertical: cloud can only move along y-axis - - For diagonal flows (cloud-to-cloud): no movement constraints +- **Always attached**: Must be an endpoint of exactly one flow, its owner +- **Position**: always the owning flow's endpoint; moving the cloud re-routes + that end of the flow from its other end - **Scaling**: SVG path scaled using matrix transform to achieve target diameter - Scale factor = (2 × CloudRadius) / CloudWidth @@ -429,42 +429,42 @@ switch(side) { ### Flow Connections -Flows connect stocks and clouds with specific constraints: +Flows connect stocks and clouds. `flow-geometry/` owns every rule in +[Flow Elements](#flow-elements), and `tests/support/flow-invariants.ts` checks +them on every flow an edit routes: -1. **Stock-to-Stock flows**: - - **Axis constraint**: Must maintain horizontal OR vertical orientation - - **Endpoint attachment**: Snap to stock edge (±StockWidth/2 or ±StockHeight/2) - - **Valve position**: Constrained within connection bounds - - **Corner clearance**: Minimum 3px from stock corners - - **Movement**: When stock moves, flow endpoints adjust to maintain connection - -2. **Cloud-to-Stock flows**: - - **Cloud position**: Adjustable along flow axis only - - **Distance maintenance**: Cloud maintains CloudRadius distance from flow endpoint - - **Axis determination**: Based on relative positions at creation time - -3. **Cloud-to-Cloud flows**: - - **No axis constraint**: Can be diagonal - - **Free movement**: Both clouds and valve can move freely - - **Uniform translation**: All points move together when valve dragged +1. **Stock-to-Stock flows**: both ends on faces; the path is straight when the + faces line up, otherwise an L, Z or bracket. The source and sink are + different stocks. +2. **Cloud-to-Stock flows**: the stock end is on a face; the cloud end is the + cloud's center, wherever the cloud is. +3. **Cloud-to-Cloud flows**: orthogonal like any other flow; with both clouds + selected, the whole flow translates. ### Flow Movement Algorithm -**UpdateStockAndFlows**: When moving a stock with connected flows: -1. Classify flows by attachment side (left, right, top, bottom) -2. Calculate proposed new stock position -3. Constrain position to keep flows valid: - - Horizontal flows: constrain Y within flow valve ± StockHeight/2 - 3px - - Vertical flows: constrain X within flow valve ± StockWidth/2 - 3px -4. Adjust all flow endpoints to new stock edges - -**UpdateFlow**: When moving a flow valve: -1. Determine if flow is horizontal, vertical, or diagonal -2. For stock-connected flows: - - Maintain axis alignment - - Constrain valve position within valid range -3. Update cloud positions to follow flow movement -4. Keep minimum 10px clearance from flow endpoints (`VALVE_CLAMP_MARGIN`) +Every canvas edit is planned by `gesture-planner/` from the view as it was when +the gesture started (the base view), never from the previous frame, so the frame +previewed at a pointer position is exactly the frame committed there. Movement +below 5 screen pixels is a click and changes nothing. + +- **Moving a selection**: selected elements translate. A flow with both ends + moving translates; a flow with one moving end re-routes that end to its moved + terminal (`routeEnd`), keeping the face it was attached to where it can; a + selected flow with neither end moving slides its valve. Links whose endpoints + moved turn their arcs once, from the final positions. +- **Dragging a valve or a pipe**: the first move past the click threshold decides + once. Along the pressed segment slides the valve along the path (`slideValve`); + perpendicular to it offsets that segment (`offsetSegment`), bending a straight + flow into an L or, between two stocks, a bracket. +- **Dragging a flow end** (its arrowhead, its source grip, or its cloud): the end + follows the pointer. Over a stock that is a valid target it routes onto that + stock's face; over empty space it ends at a cloud; over an invalid target (the + flow's other stock, a stock with no variable) nothing commits. +- **Drawing a flow**: routes from the pressed stock's face, or a new cloud at the + press point, to the pointer, then behaves as a dragged sink. +- A move whose routed flows cannot hold the rules (a cloud dragged into a stock) + commits nothing. ### Connector Rules @@ -660,11 +660,13 @@ Each element type has specific hit testing: - **Arrowheads**: Separate selection target for reconnection ### Interaction Modes -- **Single click**: Select element +- **Single click**: Select element (a movement under 5 screen pixels is a click) - **Double click on label**: Enter text edit mode -- **Drag element**: Move with constraints -- **Drag arrowhead**: Reconnect to different target +- **Drag element**: Move the selection +- **Drag valve or pipe**: Slide the valve or offset the segment +- **Drag arrowhead, source grip or cloud**: Reconnect that end of the flow or link - **Drag label**: Reposition label side +- **Drag empty canvas**: Rubber-band select (with touch or Shift: pan) ## Best Practices for View Generation diff --git a/src/diagram/ModuleDetails.tsx b/src/diagram/ModuleDetails.tsx index 9f9bfb8cb..a2b320f57 100644 --- a/src/diagram/ModuleDetails.tsx +++ b/src/diagram/ModuleDetails.tsx @@ -25,6 +25,14 @@ import { updateReferenceSrc, } from './module-wiring'; import { plainDeserialize, plainSerialize } from './drawing/common'; +import { + basesAfterFailedSubmission, + draftText, + fallBackOnFailedPending, + pendingTexts, + type DraftFields, + type PendingSubmission, +} from './VariableDetails'; import type { CustomEditor } from './drawing/SlateEditor'; import type { Module, ModuleReference, Project, Variable, ViewElement } from '@simlin/core/datamodel'; @@ -38,7 +46,12 @@ interface ModuleDetailsProps { currentModelName: string; onDelete: (ident: string) => void; onModelReferenceChange: (ident: string, newModelName: string) => void; - onUnitsDocsChange: (ident: string, newUnits: string | undefined, newDocs: string | undefined) => void; + // May resolve whether the submission landed (see draftText in VariableDetails). + onUnitsDocsChange: ( + ident: string, + newUnits: string | undefined, + newDocs: string | undefined, + ) => Promise | void; onDrillIntoModule: (moduleIdent: string, targetModelName: string) => void; onCreateModel: (moduleName: string) => void; onDuplicateModel: (moduleIdent: string, sourceModelName: string) => void; @@ -49,6 +62,14 @@ interface ModuleDetailsProps { // without add/remove, the units/docs fields are non-editable, and the // module-delete affordance is hidden. readOnly?: boolean; + // Registers the units/docs draft commit with the host; see the same prop on + // VariableDetails. + registerDraftFlush?: (flush: () => boolean) => () => void; + // Reports whether units or docs hold a draft; see the same prop on VariableDetails. + onDraftStateChange?: (hasDraft: boolean) => void; + // The host's latest pending submission for this element; see the same prop on + // VariableDetails. + pendingSubmission?: PendingSubmission; } export function ModuleDetails(props: ModuleDetailsProps): React.ReactElement { @@ -68,21 +89,23 @@ export function ModuleDetails(props: ModuleDetailsProps): React.ReactElement { // Seed the Slate editors and their contents from props exactly once per mount // (lazy useState initializers), mirroring the old constructor. The Editor keys - // this panel on projectGeneration, so a content change remounts the panel and - // re-seeds it -- there is deliberately NO prop-sync effect here, which would - // fight that keyed-remount invariant (see diagram/CLAUDE.md "Details panels are - // keyed by projectGeneration"). + // this panel on the module's committed editable content, so a landed edit to + // it remounts the panel and re-seeds it -- there is deliberately NO prop-sync + // effect here, which would fight that keyed-remount invariant (see + // diagram/CLAUDE.md "Details panels are keyed by the selected variable's + // committed content"). const [unitsEditor] = React.useState( () => withHistory(withReact(createEditor())) as unknown as CustomEditor, ); + const pendingAtMount = React.useRef(props.pendingSubmission); const [unitsContents, setUnitsContents] = React.useState(() => - plainDeserialize('equation', variable.units), + plainDeserialize('equation', pendingAtMount.current?.units?.text ?? variable.units), ); const [notesEditor] = React.useState( () => withHistory(withReact(createEditor())) as unknown as CustomEditor, ); const [notesContents, setNotesContents] = React.useState(() => - plainDeserialize('equation', variable.documentation), + plainDeserialize('equation', pendingAtMount.current?.docs?.text ?? variable.documentation), ); const handleDelete = (): void => { @@ -112,18 +135,58 @@ export function ModuleDetails(props: ModuleDetailsProps): React.ReactElement { setNotesContents(value); }; - const handleUnitDocsSave = (): void => { - const newUnits = plainSerialize(unitsContents); - const newDocs = plainSerialize(notesContents); - - const unitsChanged = variable.units !== newUnits; - const docsChanged = variable.documentation !== newDocs; - - if (unitsChanged || docsChanged) { - onUnitsDocsChange(variable.ident, unitsChanged ? newUnits : undefined, docsChanged ? newDocs : undefined); + // The texts the fields were seeded with, and what this panel last submitted + // for each; a field's draft is as VariableDetails defines it (draftText). + const [seeded] = React.useState(() => ({ units: variable.units, docs: variable.documentation })); + const [submitted, setSubmitted] = React.useState, string>>>(() => + pendingTexts(pendingAtMount.current), + ); + const committedRef = React.useRef>({ equation: '', units: '', docs: '' }); + committedRef.current = { equation: '', units: variable.units, docs: variable.documentation }; + const alive = React.useRef(true); + React.useEffect(() => { + alive.current = true; + return () => { + alive.current = false; + }; + }, []); + React.useEffect(() => fallBackOnFailedPending(pendingAtMount.current, alive, committedRef, setSubmitted), []); + const unitsDraft = draftText(plainSerialize(unitsContents), submitted.units ?? seeded.units); + const docsDraft = draftText(plainSerialize(notesContents), submitted.docs ?? seeded.docs); + // Independent of readOnly (see VariableDetails). + const hasDraft = unitsDraft !== undefined || docsDraft !== undefined; + const onDraftStateChange = props.onDraftStateChange; + React.useEffect(() => { + onDraftStateChange?.(hasDraft); + }, [hasDraft, onDraftStateChange]); + React.useEffect(() => () => onDraftStateChange?.(false), [onDraftStateChange]); + + // True when a draft was submitted. + const handleUnitDocsSave = (): boolean => { + if (readOnly || !hasDraft) { + return false; } + const submission = { units: unitsDraft, docs: docsDraft }; + setSubmitted((prev) => ({ + ...prev, + ...(submission.units !== undefined ? { units: submission.units } : {}), + ...(submission.docs !== undefined ? { docs: submission.docs } : {}), + })); + const landed = onUnitsDocsChange(variable.ident, submission.units, submission.docs); + void landed?.then((ok) => { + if (!ok && alive.current) { + setSubmitted((prev) => basesAfterFailedSubmission(prev, submission, committedRef.current)); + } + }); + return true; }; + // The flush the host calls before a canvas press (see VariableDetails). + const saveRef = React.useRef(handleUnitDocsSave); + saveRef.current = handleUnitDocsSave; + const registerDraftFlush = props.registerDraftFlush; + React.useEffect(() => registerDraftFlush?.(() => saveRef.current()), [registerDraftFlush]); + const renderModelRefSelector = (): React.ReactNode => { const { projectModels, stdlibModels } = getAvailableModels(project, currentModelName); // Show duplicate for user-defined models, not for stdlib models (read-only). diff --git a/src/diagram/StaticDiagram.tsx b/src/diagram/StaticDiagram.tsx index 5b9367b52..44acf9b3c 100644 --- a/src/diagram/StaticDiagram.tsx +++ b/src/diagram/StaticDiagram.tsx @@ -13,8 +13,7 @@ import { at, getOrThrow } from '@simlin/core/collections'; import { UID, ViewElement, Project, projectFromJson, projectAttachData } from '@simlin/core/datamodel'; import { Project as EngineProject } from '@simlin/engine'; import type { JsonProject } from '@simlin/engine'; -import { Point } from './drawing/common'; -import { Canvas } from './drawing/Canvas'; +import { Canvas, type GestureCommit } from './drawing/Canvas'; interface DiagramProps { isDarkTheme?: boolean; @@ -126,10 +125,7 @@ export function StaticDiagram(props: DiagramProps): React.ReactElement | null { const renameVariable = (_oldName: string, _newName: string): void => {}; const onSelection = (_selected: ReadonlySet): void => {}; - const moveSelection = (_position: Point): void => {}; - const moveFlow = (_element: ViewElement, _target: number, _position: Point): void => {}; - const moveLabel = (_uid: UID, _side: 'top' | 'left' | 'bottom' | 'right'): void => {}; - const attachLink = (_element: ViewElement, _to: string): void => {}; + const commitGesture = (_commit: GestureCommit): void => {}; const createCb = (_element: ViewElement): void => {}; const nullCb = (): void => {}; @@ -139,15 +135,11 @@ export function StaticDiagram(props: DiagramProps): React.ReactElement | null { project={project} model={model} view={at(model.views, 0)} - version={1} selectedTool={undefined} selection={new Set()} onRenameVariable={renameVariable} onSetSelection={onSelection} - onMoveSelection={moveSelection} - onMoveFlow={moveFlow} - onMoveLabel={moveLabel} - onAttachLink={attachLink} + onCommitGesture={commitGesture} onCreateVariable={createCb} onClearSelectedTool={nullCb} onDeleteSelection={nullCb} diff --git a/src/diagram/UndoRedoBar.tsx b/src/diagram/UndoRedoBar.tsx index 8e49c3a4e..81374ab3e 100644 --- a/src/diagram/UndoRedoBar.tsx +++ b/src/diagram/UndoRedoBar.tsx @@ -9,6 +9,15 @@ import { RedoIcon, UndoIcon } from './components/icons'; import styles from './UndoRedoBar.module.css'; +// Marks the undo/redo controls. A press on them does not flush an open details +// panel's draft on pointerdown: the Editor flushes it in the undo/redo handler +// instead, ahead of the undo (see handleUndoRedo). +export const UNDO_REDO_BAR_ATTRIBUTE = 'data-simlin-undo-redo'; + +const keepFocus = (e: React.MouseEvent): void => { + e.preventDefault(); +}; + interface UndoRedoBarProps { undoEnabled: boolean; redoEnabled: boolean; @@ -33,8 +42,11 @@ export const UndoRedoBar = React.memo(function UndoRedoBar({ onUndoRedo('redo'); }; + // A press keeps focus where it is: moving focus onto a button would blur an + // open details panel, whose blur commits its draft and makes undo refuse + // (an edit is queued) before the click arrives. return ( -
+
diff --git a/src/diagram/VariableDetails.tsx b/src/diagram/VariableDetails.tsx index d89fa6815..d54cc73ea 100644 --- a/src/diagram/VariableDetails.tsx +++ b/src/diagram/VariableDetails.tsx @@ -5,7 +5,7 @@ import * as React from 'react'; import { LineChart, ChartSeries } from './LineChart'; -import { createEditor, Descendant, Editor, Transforms } from 'slate'; +import { createEditor, Descendant, Editor, NodeEntry, Range, Text, Transforms } from 'slate'; import { withHistory } from 'slate-history'; import { Editable, ReactEditor, RenderLeafProps, Slate, withReact } from 'slate-react'; import Button from './components/Button'; @@ -44,12 +44,14 @@ interface VariableDetailsProps { viewElement: ViewElement; getLatexEquation?: (ident: string) => Promise; onDelete: (ident: string) => void; + // May resolve whether the submission landed; a submission that did not land + // is no longer the field's base (see draftText). onEquationChange: ( ident: string, newEquation: string | undefined, newUnits: string | undefined, newDoc: string | undefined, - ) => void; + ) => Promise | void; onTableChange: (ident: string, newTable: GraphicalFunction | null) => void; activeTab: number; onActiveTabChange: (newActiveTab: number) => void; @@ -60,6 +62,109 @@ interface VariableDetailsProps { // lookup editing are hidden. The Editor keys this panel on the flag, so a // mid-session flip remounts (re-seeds) it rather than toggling in place. readOnly?: boolean; + // Registers this panel's draft commit with the host; returns the + // unregistration. A canvas press does not blur the panel (the canvas prevents + // the default focus change), so the host flushes the draft through this + // before the press starts a gesture, putting the draft's edit ahead of the + // gesture's. The flush returns true when it submitted a changed draft. + registerDraftFlush?: (flush: () => boolean) => () => void; + // Reports whether a field holds a draft (see draftText), and false on unmount. + // The host holds the panel's key while it does, so a landed edit does not + // remount the panel over text the user has not submitted, or has submitted + // but that has not landed yet. + onDraftStateChange?: (hasDraft: boolean) => void; + // The host's latest pending submission for this element, by field, read at + // mount. A panel reopened while its own submission is in flight seeds those + // fields from it (so it never shows the text the submission replaced) and + // takes it as their base, falling back as for its own submissions if it does + // not land. + pendingSubmission?: PendingSubmission; +} + +/** A submitted field's text and whether its edit landed. */ +export type PendingSubmission = Partial< + Record, { readonly text: string; readonly landed: Promise }> +>; + +// A field's DRAFT: its text when that differs from the field's BASE, else +// undefined. The base is what this panel last submitted for the field, or the +// text the field was seeded with when nothing was submitted. +// +// Measuring against the base, not against the seeded or committed text, is +// what keeps two things apart: +// - a field the user never touched equals its base, so it is never echoed -- +// even when the held panel fell behind committed content (a rename rewrote +// a name the equation references) and submitting it would revert that; +// - a field changed back after a submission (typed, flushed, then cleared +// while the edit is in flight) differs from its base, so it is a draft and +// holds the key, even though it equals the seeded or pre-landing committed +// text -- otherwise the in-flight edit would land over it with no notice. +// A submission that does not land stops being the base: the field's base +// becomes its committed text then, so the text is a draft again to retry. +export function draftText(text: string, baseText: string): string | undefined { + return text !== baseText ? text : undefined; +} + +/** The texts of a pending submission, as bases. */ +export function pendingTexts( + pending: PendingSubmission | undefined, +): Partial, string>> { + const texts: Partial, string>> = {}; + for (const field of ['equation', 'units', 'docs'] as const) { + const entry = pending?.[field]; + if (entry !== undefined) { + texts[field] = entry.text; + } + } + return texts; +} + +/** + * Subscribe a panel that seeded from a pending submission to that submission's + * outcome: each field whose edit does not land falls back as for the panel's + * own submissions (basesAfterFailedSubmission). A panel effect. + */ +export function fallBackOnFailedPending( + pending: PendingSubmission | undefined, + alive: { readonly current: boolean }, + committed: { readonly current: DraftFields }, + setBases: ( + update: ( + prev: Partial, string>>, + ) => Partial, string>>, + ) => void, +): void { + for (const field of ['equation', 'units', 'docs'] as const) { + const entry = pending?.[field]; + void entry?.landed.then((ok) => { + if (!ok && alive.current) { + setBases((prev) => basesAfterFailedSubmission(prev, { [field]: entry.text }, committed.current)); + } + }); + } +} + +/** The panel fields a draft can live in. */ +export type DraftFields = { readonly equation: T; readonly units: T; readonly docs: T }; + +/** + * The bases after a submission settles as not landed: each field the + * submission set, and whose base is still that submission's text, falls back to + * its committed text. + */ +export function basesAfterFailedSubmission( + bases: Partial, string>>, + submission: Partial, string>>, + committed: DraftFields, +): Partial, string>> { + const next = { ...bases }; + for (const field of ['equation', 'units', 'docs'] as const) { + const submitted = submission[field]; + if (submitted !== undefined && next[field] === submitted) { + next[field] = committed[field]; + } + } + return next; } function stringFromDescendants(children: Descendant[]): string { @@ -114,15 +219,54 @@ function rawEquationStart(displayed: string, isUnits: boolean): number { return !isUnits && displayed.startsWith(applyToAllPrefix) ? applyToAllPrefix.length : 0; } -function highlightErrors( - s: string, +// The error/warning underline of one field as a Slate `decorate` function over +// the field's live document. Decorated from props at render rather than +// seeded into the document as marks: the panel keeps its editors across edits +// that change only the variable's errors (the Editor's panel key excludes +// errors, so a landing edit that breaks this variable's equation does not +// discard a draft), and the highlight follows the props when they change. The +// engine's offsets describe the committed text, so the underline applies only +// while the field shows exactly that text; a dirty draft is not underlined. +function fieldDecorate( + committedText: string, + currentText: string, errors: readonly EquationError[] | undefined, unitErrors: readonly UnitError[] | undefined, isUnits: boolean, -): CustomElement[] { - const rawStart = rawEquationStart(s, isUnits); - const range = highlightRangeForField(s.slice(rawStart), errors, unitErrors, isUnits); - return highlightSpansForLines(s, rawStart, range).map((children): CustomElement => ({ type: 'equation', children })); +): (entry: NodeEntry) => Range[] { + if (currentText !== committedText) { + return () => []; + } + const rawStart = rawEquationStart(committedText, isUnits); + const range = highlightRangeForField(committedText.slice(rawStart), errors, unitErrors, isUnits); + if (range === undefined) { + return () => []; + } + // One Slate element per line, each holding one text node: path [line, 0]. + const lines = highlightSpansForLines(committedText, rawStart, range); + return ([node, path]) => { + if (!Text.isText(node) || path.length !== 2 || path[1] !== 0) { + return []; + } + const spans = lines[path[0]]; + if (spans === undefined) { + return []; + } + const out: Range[] = []; + let offset = 0; + for (const span of spans) { + const end = offset + span.text.length; + if (span.error || span.warning) { + out.push({ + anchor: { path, offset }, + focus: { path, offset: end }, + ...(span.error ? { error: true } : { warning: true }), + } as Range); + } + offset = end; + } + return out; + }; } // KaTeX needs `trust` enabled to honor `\htmlData`. Scope it to that one @@ -258,40 +402,94 @@ export function VariableDetails(props: VariableDetailsProps): React.ReactElement // embeds on one page can each show a details panel. const fieldIdPrefix = React.useId(); - // The original (props-derived) document for each field. These seed the editors - // on mount and are what the discard path (Cancel/Escape) restores, so the - // seeding and the revert stay in lockstep -- including the error/warning - // highlight the equation and units fields carry. - const initialEquationContents = (): CustomElement[] => - highlightErrors(scalarEquationFor(variable), variable.errors, variable.unitErrors, false); - const initialUnitsContents = (): CustomElement[] => - highlightErrors(variable.units, variable.errors, variable.unitErrors, true); - const initialNotesContents = (): CustomElement[] => descendantsFromString(variable.documentation); - // Seed the Slate editors and their contents from props exactly once per mount // (lazy useState initializers), mirroring the old constructor. The Editor keys - // this panel on projectGeneration, so a content change remounts the panel and - // re-seeds it -- there is deliberately NO prop-sync effect for these fields, - // which would fight that keyed-remount invariant (see diagram/CLAUDE.md - // "Details panels are keyed by projectGeneration"). The latex fields below ARE - // prop-driven (on viewElement.ident) because selecting a different variable - // without an intervening content edit does not remount the panel. + // this panel on the variable's committed editable content, so a landed edit to + // this variable remounts the panel and re-seeds it -- there is deliberately NO + // prop-sync effect for these fields, which would fight that keyed-remount + // invariant (see diagram/CLAUDE.md "Details panels are keyed by the selected + // variable's committed content"). The latex fields below ARE prop-driven (on + // viewElement.ident) because selecting a different variable without an + // intervening content edit does not remount the panel. const [equationEditor] = React.useState( () => withHistory(withReact(createEditor())) as unknown as CustomEditor, ); - const [equationContents, setEquationContents] = React.useState(initialEquationContents); + const pendingAtMount = React.useRef(props.pendingSubmission); + const [equationContents, setEquationContents] = React.useState(() => + descendantsFromString(pendingAtMount.current?.equation?.text ?? scalarEquationFor(variable)), + ); const [unitsEditor] = React.useState( () => withHistory(withReact(createEditor())) as unknown as CustomEditor, ); - const [unitsContents, setUnitsContents] = React.useState(initialUnitsContents); + const [unitsContents, setUnitsContents] = React.useState(() => + descendantsFromString(pendingAtMount.current?.units?.text ?? variable.units), + ); const [notesEditor] = React.useState( () => withHistory(withReact(createEditor())) as unknown as CustomEditor, ); - const [notesContents, setNotesContents] = React.useState(initialNotesContents); + const [notesContents, setNotesContents] = React.useState(() => + descendantsFromString(pendingAtMount.current?.docs?.text ?? variable.documentation), + ); const [editingEquation, setEditingEquation] = React.useState( () => !!(variable.errors && variable.errors.length > 0), ); const [latexEquation, setLatexEquation] = React.useState(undefined); + // The texts the fields were seeded with, and what this panel last submitted + // for each (see draftText). + const [seeded] = React.useState(() => ({ + equation: scalarEquationFor(variable), + units: variable.units, + docs: variable.documentation, + })); + const [submitted, setSubmitted] = React.useState, string>>>(() => + pendingTexts(pendingAtMount.current), + ); + const committedTexts: DraftFields = { + equation: scalarEquationFor(variable), + units: variable.units, + docs: variable.documentation, + }; + const committedRef = React.useRef(committedTexts); + committedRef.current = committedTexts; + const alive = React.useRef(true); + React.useEffect(() => { + alive.current = true; + return () => { + alive.current = false; + }; + }, []); + React.useEffect(() => fallBackOnFailedPending(pendingAtMount.current, alive, committedRef, setSubmitted), []); + const drafts = { + equation: draftText(stringFromDescendants(equationContents), submitted.equation ?? seeded.equation), + units: draftText(stringFromDescendants(unitsContents), submitted.units ?? seeded.units), + docs: draftText(stringFromDescendants(notesContents), submitted.docs ?? seeded.docs), + }; + // Independent of readOnly: a read-only panel's fields cannot change, and the + // host renders a panel read-only while an undo/redo is queued without that + // releasing a draft it holds. + const hasDraft = drafts.equation !== undefined || drafts.units !== undefined || drafts.docs !== undefined; + const onDraftStateChange = props.onDraftStateChange; + React.useEffect(() => { + onDraftStateChange?.(hasDraft); + }, [hasDraft, onDraftStateChange]); + React.useEffect(() => () => onDraftStateChange?.(false), [onDraftStateChange]); + + const equationDecorate = React.useMemo( + () => + fieldDecorate( + scalarEquationFor(variable), + stringFromDescendants(equationContents), + variable.errors, + variable.unitErrors, + false, + ), + [variable, equationContents], + ); + const unitsDecorate = React.useMemo( + () => + fieldDecorate(variable.units, stringFromDescendants(unitsContents), variable.errors, variable.unitErrors, true), + [variable, unitsContents], + ); // Monotonic request id and mounted flag for the loadLatex race guard, mirroring // the class's `_latexRequestId`/`_mounted` instance fields. Refs (not state) @@ -363,15 +561,20 @@ export function VariableDetails(props: VariableDetailsProps): React.ReactElement setNotesContents(equation); }; - // Discard every in-progress edit and restore the original documents. Both the - // React state (which drives the preview and the Save/Cancel enabled state) and - // the live Slate documents (uncontrolled after mount) must be reset, or the - // visible editors would keep showing the abandoned text. Shared by the Cancel - // button and the Escape key. + // Discard every draft: each field goes back to its base -- what this panel + // last submitted for it while that submission stands, else the text it was + // seeded with (see draftText) -- so no field holds a draft afterwards and the + // host's hold on the panel releases. Restoring the committed text instead + // would make a field whose submission has not landed yet differ from its + // base, and the next blur or canvas press would submit the committed text over + // the pending save. Both the React state (which drives the preview and the + // Save/Cancel enabled state) and the live Slate documents (uncontrolled after + // mount) must be reset, or the visible editors would keep showing the + // abandoned text. Shared by the Cancel button and the Escape key. const handleEquationCancel = (): void => { - const equation = initialEquationContents(); - const units = initialUnitsContents(); - const notes = initialNotesContents(); + const equation = descendantsFromString(submitted.equation ?? seeded.equation); + const units = descendantsFromString(submitted.units ?? seeded.units); + const notes = descendantsFromString(submitted.docs ?? seeded.docs); resetEditorDocument(equationEditor, equation); resetEditorDocument(unitsEditor, units); resetEditorDocument(notesEditor, notes); @@ -382,10 +585,10 @@ export function VariableDetails(props: VariableDetailsProps): React.ReactElement }; // Blur commits the in-progress edit only when focus actually leaves the panel - // (to the canvas, another variable, or nowhere). This is load-bearing: a - // canvas-driven edit first blurs the side-panel editor, and that blur must - // commit the pending text (see diagram/CLAUDE.md, "Details panels are keyed by - // projectGeneration"). But a blur toward the panel's own Cancel/Save buttons + // (to another control, another variable, or nowhere). A canvas press does not + // blur the panel at all -- the canvas prevents the default focus change -- so + // that path goes through registerDraftFlush instead. But a blur toward the + // panel's own Cancel/Save buttons // or another field must NOT commit -- otherwise clicking or tabbing to Cancel // would save the very edit the user is discarding, and the button's own // pointerdown-preventDefault (which keeps focus on the editor for mouse/touch) @@ -402,28 +605,38 @@ export function VariableDetails(props: VariableDetailsProps): React.ReactElement } }; - const handleEquationSave = (): void => { + // True when a changed draft was submitted. + const handleEquationSave = (): boolean => { // Backstop: with the fields non-editable the contents cannot diverge from // the initial values, but a save must still never fire in read-only mode // (tab switches and blurs route through here unconditionally). - if (readOnly) { - return; - } - const initialEquation = scalarEquationFor(variable); - const initialUnits = variable.units; - const initialDocs = variable.documentation; - - const newEquation = stringFromDescendants(equationContents); - const newUnits = stringFromDescendants(unitsContents); - const newDocs = stringFromDescendants(notesContents); - const equation = initialEquation !== newEquation ? newEquation : undefined; - const units = initialUnits !== newUnits ? newUnits : undefined; - const docs = initialDocs !== newDocs ? newDocs : undefined; - if (equation !== undefined || units !== undefined || docs != undefined) { - onEquationChange(defined(viewElement.ident), equation, units, docs); + if (readOnly || !hasDraft) { + return false; } + // Only the fields holding a draft: the rest echo committed content at dequeue. + const submission = drafts; + setSubmitted((prev) => ({ + ...prev, + ...(submission.equation !== undefined ? { equation: submission.equation } : {}), + ...(submission.units !== undefined ? { units: submission.units } : {}), + ...(submission.docs !== undefined ? { docs: submission.docs } : {}), + })); + const landed = onEquationChange(defined(viewElement.ident), submission.equation, submission.units, submission.docs); + void landed?.then((ok) => { + if (!ok && alive.current) { + setSubmitted((prev) => basesAfterFailedSubmission(prev, submission, committedRef.current)); + } + }); + return true; }; + // The flush the host calls before a canvas press. It reads the draft through a + // ref, so a registration made at mount commits the CURRENT text. + const saveRef = React.useRef(handleEquationSave); + saveRef.current = handleEquationSave; + const registerDraftFlush = props.registerDraftFlush; + React.useEffect(() => registerDraftFlush?.(() => saveRef.current()), [registerDraftFlush]); + const formatValue = (value: number): string => { return value.toFixed(3); }; @@ -468,10 +681,6 @@ export function VariableDetails(props: VariableDetailsProps): React.ReactElement }; const renderEquation = (): React.ReactElement => { - const initialEquation = scalarEquationFor(variable); - const initialUnits = variable.units; - const initialDocs = variable.documentation; - const data: Readonly> | undefined = variable.data; let yMin = 0; @@ -500,11 +709,8 @@ export function VariableDetails(props: VariableDetailsProps): React.ReactElement yMin = Math.floor(yMin); yMax = Math.ceil(yMax); - // enable saving and canceling if the equation has changed - const equationActionsEnabled = - initialEquation !== stringFromDescendants(equationContents) || - initialUnits !== stringFromDescendants(unitsContents) || - initialDocs !== stringFromDescendants(notesContents); + // Save and Cancel act on drafts, so they are enabled exactly while one exists. + const equationActionsEnabled = hasDraft; const detailsView = variableDetailsView(variable); // Unit errors are non-fatal warnings: the variable still simulates and has @@ -543,6 +749,14 @@ export function VariableDetails(props: VariableDetailsProps): React.ReactElement
)); + // Engine advisories are non-fatal too. Their code is often the wire + // Generic, so the details are the message. + const advisories = detailsView.warnings.map((warning, i) => ( +
+ warning: {warning.details ?? errorCodeDescription(warning.code)} +
+ )); + let chartOrErrors; if (!detailsView.showChart) { // Equation/compile errors mean the variable produced no valid data, so @@ -552,13 +766,14 @@ export function VariableDetails(props: VariableDetailsProps): React.ReactElement error: {errorCodeDescription(error.code)}
)); - chartOrErrors = [...errorList, ...unitWarnings, ...connectorWarnings]; + chartOrErrors = [...errorList, ...unitWarnings, ...connectorWarnings, ...advisories]; } else { chartOrErrors = ( <> {unitWarnings} {connectorWarnings} + {advisories} ); } @@ -606,6 +821,7 @@ export function VariableDetails(props: VariableDetailsProps): React.ReactElement , elementsByUid: ReadonlyMap, ): Array { const bounds: Array = []; - for (let element of displayElements) { - const updated = selectionUpdates.get(element.uid); - if (updated !== undefined) { - element = updated; - } + for (const element of displayElements) { switch (element.type) { case 'cloud': bounds.push(cloudBounds(element)); @@ -173,6 +133,10 @@ function computeElementBounds( const ZMax = 6; +// A client point with no pointer behind it, for presses classified from events +// that carry none (a module's double-click). +const NO_POINTER = { clientX: 0, clientY: 0, shiftKey: false, ctrlKey: false, metaKey: false, pointerType: 'mouse' }; + // Momentum physics, zoom limits, and the wheel/pinch math live in `viewport.ts` // (the pure functional core); this shell resolves screen->canvas points and the // rAF/timer lifecycle, then calls those pure transforms. @@ -199,38 +163,54 @@ interface VelocityTracker { } // The result of the single render-phase derivation step (deriveRenderState). -// Every cached/derived value the render path needs is produced here exactly -// once at the top of render(); the element-rendering helpers (connector(), -// aux(), ...) only *read* these, never recompute or mutate during render. This -// keeps render free of mid-render ref mutation beyond the single -// deriveRenderState writer. +// Every derived value the render path needs is produced there once, at the top +// of render; the element-rendering helpers only read it, and event handlers read +// it after render returns (connector ends, the name editor's element). interface RenderDerivation { - // The elements to draw (props.view.elements plus any in-creation element). + // What is drawn: the live gesture's plan while one is in flight, else the view + // (plus a draft element while its name is being edited). displayElements: readonly ViewElement[]; - // UID -> element lookup over displayElements plus the faux drag targets. - // Reused at event-time (getElementByUid, handlers) -- see elementsRef. + // UID -> element lookup over displayElements. elementsByUid: Map; - // Selected elements with live drag/label updates applied (group movement, - // label-side, single-link arc suppression). Keyed by UID. - selectionUpdates: Map; + // The live gesture's plan, while one is in flight and still valid (E5). + plan: GesturePlan | undefined; + // The selection drawn: a committing plan's, so the last preview frame draws + // what the release commits (E2); otherwise the host's. + selection: ReadonlySet; // AC1.6: whether any module in the model has a model reference, used to // suppress warning dots while a model is being sketched. hasAnyModuleReference: boolean; - // The arc last computed for a single-link arrowhead drag (creation or - // reattachment), or undefined when not dragging a link / straight line. - // connector() renders this exact value and pointer-up persists it, so the - // saved arc always matches the on-screen arc (see "Link drag arc ownership"). - draggedLinkArc: number | undefined; +} + +/** + * What a gesture's release commits: the next view's elements and uid counter, + * exactly as the last preview frame drew them, and the selection it applies. + */ +export interface GestureCommit { + readonly label: string; + readonly elements: readonly ViewElement[]; + readonly nextUid: number; + readonly selection: ReadonlySet; + /** The controller state token the gesture was pressed under. */ + readonly token: number | undefined; + /** + * The view the release planned on. The host refuses the commit when its + * rendered view no longer agrees with it (E5): the elements replace the whole + * view, so an edit that landed in between would otherwise be reverted. + */ + readonly baseView: StockFlowView; + /** An element the edit creates whose name editor opens next (a drawn flow). */ + readonly editName?: UID; } export interface CanvasProps { embedded: boolean; // The host says the displayed model must not be mutated (read-only viewer, - // stdlib model, embed). The Editor already hands a read-only Canvas no-op - // mutation callbacks and no selectedTool; this flag additionally suppresses - // the UI-only entry points Canvas owns itself -- today the inline label - // editor a label double-click opens -- which would otherwise LOOK editable - // while the eventual onRenameVariable commit silently no-ops (issue #935). + // stdlib model, embed). The Editor also hands a read-only Canvas a no-op + // commit callback and no selectedTool; with this flag the gesture planner + // previews and commits no edit, and the inline label editor a label + // double-click opens never opens -- it would LOOK editable while the eventual + // onRenameVariable commit silently no-ops (issue #935). readOnly?: boolean; // Whether the mount-time offscreen re-center (issue #52) may run for this // mount. Default true. A host that opened the view at a viewport it carried @@ -243,91 +223,95 @@ export interface CanvasProps { project: Project; model: Model; view: StockFlowView; - version: number; + // The host's state token (ProjectSnapshot.token): a live gesture pressed under + // another token aborts (E5), since the view it planned on was replaced. + token?: number; selectedTool: 'stock' | 'flow' | 'aux' | 'link' | 'module' | undefined; selection: ReadonlySet; - onRenameVariable: (oldName: string, newName: string) => void; + // Returns an error message when the host refuses the name (it names another + // variable or a pending create); the inline name editor then stays open and + // shows it, and nothing is committed. + onRenameVariable: (oldName: string, newName: string) => string | undefined | void; onSetSelection: (selected: ReadonlySet) => void; - onMoveSelection: (position: Point, arcPoint?: Point, segmentIndex?: number) => void; - onMoveFlow: ( - flow: FlowViewElement, - targetUid: number, - moveDelta: Point, - fauxTargetCenter: Point | undefined, - inCreation: boolean, - isSourceAttach?: boolean, - ) => void; - onMoveLabel: (uid: UID, side: 'top' | 'left' | 'bottom' | 'right') => void; - onAttachLink: (link: LinkViewElement, newTarget: string) => void; - onCreateVariable: (element: ViewElement) => void; + // A gesture released with an edit to commit (see GestureCommit). + onCommitGesture: (commit: GestureCommit) => void; + // Returns an error message when the host refuses the name, as onRenameVariable. + onCreateVariable: (element: ViewElement) => string | undefined | void; onClearSelectedTool: () => void; + // Deletes the selection; the Canvas calls it when a drawn flow's first name + // edit is cancelled (the flow is then the selection). onDeleteSelection: () => void; onShowVariableDetails: () => void; onViewBoxChange: (viewBox: ViewRect, zoom: number) => void; onDrillIntoModule: (moduleIdent: string, targetModelName: string) => void; + // Allocates the default name of a new element ("New Variable", "New + // Variable 1", ...). The host allocates against everything that exists once + // its pending edits land; `props.model` lacks pending creates, so two quick + // creates allocating from it both got the same name. Absent (static and test + // hosts), the Canvas allocates against `props.model`. + newVariableName?: (base: string) => string; + // Presses start no gesture: the host has an undo or redo queued, and a + // gesture planned on the view it is about to replace could not commit. + pressesDisabled?: boolean; } -// UID -> element lookup for resolving connector ends. Module-level pure function -// (formerly Canvas.buildSelectionMap, a static method). The skip rationale for -// inCreationUid / missing elements is preserved verbatim below. -export function buildSelectionMap( - props: CanvasProps, - elements: ReadonlyMap, - inCreation?: ViewElement, -): Map { - const selection = new Map(); - for (const uid of props.selection) { - if (uid === inCreationUid) { - if (inCreation) { - selection.set(uid, inCreation); - } - // When inCreation is undefined the async Editor update hasn't - // finished yet — skip this transient UID; the next render after - // Editor.setState will carry the real selection. - continue; - } - const e = elements.get(uid); - if (e === undefined) { - // The selection can transiently reference an element that has just - // been removed from the view (e.g. dropping a connector's arrowhead - // off-canvas deletes it): Editor updates the view and clears the - // selection in separate setState calls, so there is a render in - // between where props.view no longer has the element but - // props.selection still does. Skip it rather than crashing the whole - // canvas; the next render after the selection-clear lands is - // consistent. (Same rationale as the inCreationUid case above.) - continue; - } - selection.set(e.uid, e); - } - return selection; +// A gesture in flight: what the press started, where (model coordinates), the +// pointer now, and what it was pressed on. Every frame is planned afresh from +// these, never from a previous frame. +interface ActiveGesture { + readonly gesture: PressGesture; + readonly pointerId: number; + readonly pointerType: string; + readonly press: Point; + readonly current: Point; + // The view and token at press: a republish that changes either aborts (E5). + readonly baseView: StockFlowView; + readonly token: number | undefined; + // The selection in effect after the press, and the one a click settles on. + readonly selection: ReadonlySet; + readonly clickSelection: ReadonlySet | undefined; +} + +// An open inline name editor: the element being named, and for a creation +// tool's draft the element itself (it is not in the view until the name is +// done). A drawn flow's first name edit deletes the flow when cancelled. +interface NameEdit { + readonly uid: UID; + readonly draft: ViewElement | undefined; + readonly creatingFlow: boolean; +} + +// A two-finger pinch's fixed reference, captured when the second finger lands. +interface PinchState { + readonly initialDistance: number; + readonly initialZoom: number; + readonly modelPoint: Point; } -// The mutable instance state that, in the class component, lived as instance -// fields (this.*) and was read by event handlers / native listeners / the -// momentum rAF loop AFTER render returned. Collected here so the function -// component can keep them in a single ref and the event-time readers share one -// "current" view -- exactly as `this.*` always reflected the latest values. +// The mutable instance state read by event handlers, native listeners, the +// momentum rAF loop and the ResizeObserver after render returns, collected in +// one ref so every event-time reader shares one "current" view. interface CanvasRefs { svgObserver: ResizeObserver | undefined; - mouseDownPoint: Point | undefined; - selectionCenterOffset: Point | undefined; - pointerId: number | undefined; prevSelectedTool: CanvasProps['selectedTool']; - // Cache key for the elements-by-uid lookup map: when props.version is - // unchanged we reuse the existing map (and the displayElements array) rather - // than rebuilding it. Owned exclusively by deriveRenderState(). - cachedVersion: number; + // The live gesture, pinch and name editor. Handlers read these refs, so two + // events that arrive between renders see each other's writes; the gesture and + // name editor setters mirror into state to re-render (see setGesture). + gesture: ActiveGesture | undefined; + pinch: PinchState | undefined; + nameEdit: NameEdit | undefined; + + // A pan's press in canvas coordinates: the pan physics anchor. + mouseDownPoint: Point | undefined; - // UID -> element lookup, populated by deriveRenderState() and intentionally - // NOT cleared at the end of render: event handlers (getElementByUid and the - // pointer callbacks) read it after render returns. Mirrors derived.elementsByUid. + // The displayed elements the lookup map was built from; the map is rebuilt + // only when they change identity. Owned by deriveRenderState(). + cachedElements: readonly ViewElement[] | undefined; elements: Map; // The most recent render derivation. Written only by deriveRenderState(); - // read by the element-rendering helpers during render and by the pointer - // handlers at event time. + // read by the element-rendering helpers during render and by handlers. derived: RenderDerivation; // Multi-touch tracking for pinch gestures @@ -383,21 +367,15 @@ interface LiveViewport { zoom: number; } -// The snapshot of props + discrete/continuous state that event-time readers -// (native wheel/gesture listeners, the momentum rAF loop, the ResizeObserver, -// the deferred tool-change commit) must see CURRENT, not as captured by a stale -// render closure. Refreshed synchronously on every render so any escaped -// callback reads the same values `this.props` / `this.state` would have. +// The snapshot of props + continuous state that event-time readers (native +// wheel/gesture listeners, the momentum rAF loop, the ResizeObserver, the +// deferred tool-change commit) must see CURRENT, not as captured by a stale +// render closure. Refreshed synchronously on every render. interface LatestState { props: CanvasProps; - interaction: InteractionState; editingName: Array; - dragSelectionPoint: Point | undefined; - moveDelta: Point | undefined; liveViewport: LiveViewport | undefined; svgSize: Readonly<{ width: number; height: number }> | undefined; - inCreation: ViewElement | undefined; - inCreationCloud: CloudViewElement | undefined; } // Main canvas + rendering engine (the imperative shell). Converted from a @@ -432,38 +410,40 @@ export const Canvas = React.memo(function Canvas(props: CanvasProps): React.Reac [props.embedded, labelHaloId], ); - // ---- Discrete + continuous state (formerly CanvasState) ----------------- - const [interaction, setInteraction] = React.useState(idleState); + // ---- State --------------------------------------------------------------- + const [gesture, setGestureState] = React.useState(undefined); + const [nameEdit, setNameEditState] = React.useState(undefined); const [editingName, setEditingName] = React.useState>([]); - const [dragSelectionPoint, setDragSelectionPoint] = React.useState(undefined); - const [moveDelta, setMoveDelta] = React.useState(undefined); const [liveViewport, setLiveViewport] = React.useState(undefined); const [initialBounds, setInitialBounds] = React.useState(viewRectDefault); const [svgSize, setSvgSize] = React.useState | undefined>(undefined); - const [inCreation, setInCreation] = React.useState(undefined); - const [inCreationCloud, setInCreationCloud] = React.useState(undefined); + // The host's refusal of the name the inline editor tried to commit; shown in + // the editor, cleared by typing or by the editor closing. + const [nameError, setNameError] = React.useState(undefined); // initialBounds is written in the mount effect and only read there; keep the // setter referenced to avoid an unused-var lint while preserving the field. void initialBounds; - // ---- Instance fields (formerly this.*) as refs -------------------------- + // ---- Instance fields as refs --------------------------------------------- const refs = React.useRef(undefined as unknown as CanvasRefs); if (refs.current === undefined) { + const elements = new Map(); refs.current = { svgObserver: undefined, - mouseDownPoint: undefined, - selectionCenterOffset: undefined, - pointerId: undefined, prevSelectedTool: undefined, - cachedVersion: -Infinity, - elements: new Map(), + gesture: undefined, + pinch: undefined, + nameEdit: undefined, + mouseDownPoint: undefined, + cachedElements: undefined, + elements, derived: { displayElements: [], - elementsByUid: new Map(), - selectionUpdates: new Map(), + elementsByUid: elements, + plan: undefined, + selection: new Set(), hasAnyModuleReference: false, - draggedLinkArc: undefined, }, activePointers: new Map(), panBaseOffset: undefined, @@ -476,101 +456,25 @@ export const Canvas = React.memo(function Canvas(props: CanvasProps): React.Reac momentumStartOffset: undefined, offscreenChecked: false, }; - // Seed the empty derivation's elementsByUid to the same map instance, as the - // class constructor did (derived.elementsByUid === this.elements). - refs.current.derived.elementsByUid = refs.current.elements; } const r = refs.current; // ---- Latest props/state snapshot for escaped callbacks ------------------ // Updated synchronously below on every render. Event handlers, native // listeners, the momentum loop, and the ResizeObserver all read through this - // so they see CURRENT values (the class read this.props/this.state, which - // were always current). Writing during render is safe: it is the same data - // the JSX below renders, just exposed to non-render-scope callers. + // so they see CURRENT values. Writing during render is safe: it is the same + // data the JSX below renders, just exposed to non-render-scope callers. const latest = React.useRef(undefined as unknown as LatestState); - latest.current = { - props, - interaction, - editingName, - dragSelectionPoint, - moveDelta, - liveViewport, - svgSize, - inCreation, - inCreationCloud, - }; + latest.current = { props, editingName, liveViewport, svgSize }; - // ---- Discrete-interaction-mode accessors -------------------------------- - // The migration (#65) collapsed the former boolean CanvasState modes onto the - // tagged-union interaction state. These narrow helpers keep the call sites - // readable; they are the ONLY places that destructure the union mode, so the - // render/handler code stays mode-agnostic. They take the interaction value - // explicitly so render-time callers pass the render value and event-time - // callers pass latest.current.interaction. - - // Dragging a link/flow arrowhead (sink) endpoint. - const isDraggingArrowhead = (i: InteractionState): boolean => i.mode === 'movingEndpoint' && i.endpoint === 'arrow'; - - // Dragging a flow source endpoint. - const isDraggingSource = (i: InteractionState): boolean => i.mode === 'movingEndpoint' && i.endpoint === 'source'; - - // The inline name editor is showing NOW. This reproduces the OLD boolean - // `isEditingName` ("the inline editor is visible"), which was distinct from - // `editNameOnPointerUp` ("enter editing AFTER this creation drag ends"). Both - // map onto the `editingName` union variant, separated by `onPointerUp`: during - // an aux/stock/module creation drag the variant is `editingName {onPointerUp: - // true}` but the editor is NOT yet visible, so this MUST exclude that staging - // case. Readers that drive the EditableLabel overlay, the label-suppression - // props, the overlay's pointer-event capture, and the tool-change deferred - // commit all want this "showing now" semantics -- never the staged handoff. - // The pointer-up staging read uses `mode === 'editingName' && onPointerUp` - // directly (the old `editNameOnPointerUp`), not this helper. - const isShowingNameEditor = (i: InteractionState): boolean => i.mode === 'editingName' && !i.onPointerUp; - - // The pointer type captured at the start of an endpoint drag, or undefined - // when not dragging an endpoint. Drives the touch-is-always-straight link - // rule (touch links never get an arc). - const getDragPointerType = (i: InteractionState): string | undefined => - i.mode === 'movingEndpoint' ? i.pointerType : undefined; - - // The flow segment being dragged (undefined = valve / whole element). - const getDraggingSegmentIndex = (i: InteractionState): number | undefined => - i.mode === 'movingSelection' ? i.segmentIndex : undefined; - - // The active label-drag side, or undefined when not dragging a label. - const getLabelSide = (i: InteractionState): 'right' | 'bottom' | 'left' | 'top' | undefined => - i.mode === 'movingLabel' ? i.side : undefined; - - // The read-only environment the pure reducer needs from the shell. Reads the - // latest selection so a reducer call mid-handler sees current props. - const interactionContext = (): InteractionContext => ({ selection: latest.current.props.selection }); - - // Execute the discrete effects a reducer transition emitted, in order. The - // reducer only ever emits `capturePointer` today (selection/tool changes are - // done by the shell directly), so this is the lone arm. - const runEffects = (effects: readonly InteractionEffect[], target: Element | undefined, pointerId: number): void => { - for (const effect of effects) { - switch (effect.kind) { - case 'capturePointer': - target?.setPointerCapture(pointerId); - break; - } - } + const setGesture = (next: ActiveGesture | undefined): void => { + r.gesture = next; + setGestureState(next); }; - // Apply the PointerStateReset bag (formerly `setState(pointerStateReset())`) - // by calling the per-field setters. React batches them into one render. The - // former loose instance fields (deferredSingleSelectUid, dragPointerType) - // now live inside the interaction union, reset by - // pointerStateReset()'s `interaction: idle`. - const applyPointerStateReset = (): void => { - const reset = pointerStateReset(); - setInteraction(reset.interaction); - setMoveDelta(reset.moveDelta); - setDragSelectionPoint(reset.dragSelectionPoint); - setInCreation(reset.inCreation); - setInCreationCloud(reset.inCreationCloud); + const setNameEdit = (next: NameEdit | undefined): void => { + r.nameEdit = next; + setNameEditState(next); }; // Offset/zoom resolve from the live viewport while a gesture is in flight, @@ -634,8 +538,8 @@ export const Canvas = React.memo(function Canvas(props: CanvasProps): React.Reac // its own settle -- don't double-commit. Otherwise commit now, so a plain // click/selection that interrupted a wheel scroll or a coast still persists // the viewport rather than stranding it in local state. - const mode = latest.current.interaction.mode; - const viewportGestureActive = r.momentumAnimationId !== undefined || mode === 'panning' || mode === 'pinching'; + const viewportGestureActive = + r.momentumAnimationId !== undefined || r.gesture?.gesture.kind === 'pan' || r.pinch !== undefined; if (!viewportGestureActive) { commitLiveViewport(); } @@ -665,28 +569,11 @@ export const Canvas = React.memo(function Canvas(props: CanvasProps): React.Reac scheduleDeferredCommit(); } }; - - // Non-throwing element lookup. Returns undefined for the two creation - // sentinels when their backing element has already been cleared, and for any - // uid not present in the (version-cached) element map. Callers that can - // legitimately encounter a transiently-unresolvable uid (e.g. a selection - // that still references inCreationUid for one render after creation hands off - // to name-editing) use this and skip rather than crash; getElementByUid keeps - // the strict, throwing contract for everyone else. - const tryGetElementByUid = (uid: UID): ViewElement | undefined => { - if (uid === inCreationUid) { - return latest.current.inCreation; - } else if (uid === inCreationCloudUid) { - return latest.current.inCreationCloud; - } - return r.elements.get(uid); - }; - - const getElementByUid = (uid: UID): ViewElement => { - return defined(tryGetElementByUid(uid)); - }; - - const isSelected = (element: ViewElement): boolean => latest.current.props.selection.has(element.uid); + // Non-throwing element lookup over what is drawn (a live plan's elements, or + // the view). A uid can transiently resolve to nothing -- a name editor naming a + // flow whose create was refused or rolled back -- and callers skip it rather + // than crash. + const tryGetElementByUid = (uid: UID): ViewElement | undefined => r.elements.get(uid); const getCanvasPoint = (x: number, y: number): Point => { if (svgRef.current) { @@ -719,15 +606,18 @@ export const Canvas = React.memo(function Canvas(props: CanvasProps): React.Reac // subtree; focus left // on would instead route the key by the last-active instance. // preventScroll: a host page (notebook) may scroll; focusing must not jump it. - // No fallback for a missing container: both callers -- clearPointerState - // (pointer release / click settle) and the name-edit commit/cancel path, - // which is reached from the keyboard too -- run on a rendered canvas, where - // svgRef is always attached. + // No fallback for a missing container: every caller -- a gesture's release + // and the name editor closing, which is reached from the keyboard too -- runs + // on a rendered canvas, where svgRef is always attached. const focusCanvas = (): void => { svgRef.current?.focus({ preventScroll: true }); }; const getNewVariableName = (base: string): string => { + const allocate = latest.current.props.newVariableName; + if (allocate !== undefined) { + return allocate(base); + } const variables = latest.current.props.model.variables; if (!variables.has(canonicalize(base))) { return base; @@ -742,320 +632,73 @@ export const Canvas = React.memo(function Canvas(props: CanvasProps): React.Reac return base; }; - // ---- isValidTarget / arc / link-drag helpers ---------------------------- - // These run during render (called by the element-rendering helpers) and at - // event time (pointer-up resolution). They read the live moveDelta and - // selectionCenterOffset; during render those reflect the current render, at - // event time they reflect the latest committed values -- both via `latest`/`r`. - - const isValidTarget = (element: ViewElement): boolean | undefined => { - const draggingArrowhead = isDraggingArrowhead(latest.current.interaction); - const draggingSource = isDraggingSource(latest.current.interaction); - - if ((!draggingArrowhead && !draggingSource) || !r.selectionCenterOffset) { - return undefined; - } - - const arrowUid = only(latest.current.props.selection); - // Guard against a stale selection pointing at a now-missing element (e.g. an - // undo landing mid-drag) rather than throwing during render. - const arrow = tryGetElementByUid(arrowUid); - if (!arrow) { - return undefined; - } - - const off = r.selectionCenterOffset; - const delta = latest.current.moveDelta || { x: 0, y: 0 }; - const canvasOffset = getCanvasOffset(); - const pointer = { - x: off.x - delta.x - canvasOffset.x, - y: off.y - delta.y - canvasOffset.y, - }; - - let isTarget = false; - if (element.type === 'cloud') { - isTarget = cloudContains(element, pointer); - } else if (element.type === 'stock') { - isTarget = stockContains(element, pointer); - } else if (element.type === 'module') { - isTarget = moduleContains(element, pointer); - } else if (element.type === 'aux') { - isTarget = auxContains(element, pointer); - } else if (element.type === 'flow') { - isTarget = auxContains(element, pointer); - } - if (!isTarget) { - return undefined; - } - - // don't allow connectors from and to the same element - if (arrow.type === 'link' && arrow.fromUid === element.uid) { - return undefined; - } - - // dont allow duplicate links between the same two elements - if (arrow.type === 'link') { - const { view } = latest.current.props; - for (const e of view.elements) { - // skip if its not a connector, or if it is the currently selected connector - if (e.type !== 'link' || e.uid === arrow.uid) { - continue; - } - - if (e.fromUid === arrow.fromUid && e.toUid === element.uid) { - return false; - } - } - } + // ---- The live gesture's plan --------------------------------------------- - if (arrow.type === 'flow') { - if (element.type !== 'stock') { - return false; - } - - if (draggingSource) { - // For source movement: check if target stock is valid source - const lastPt = last(arrow.points); - // Don't allow connecting source and sink to the same stock - if (lastPt.attachedToUid === element.uid) { - return false; - } - // For multi-segment flows (3+ points), the source needs to align with - // the adjacent point (second), not the sink point. For 2-point flows, - // points[1] gives us the last point, which is correct. - const adjacentToSource = at(arrow.points, 1); - return ( - Math.abs(adjacentToSource.x - element.x) < StockWidth / 2 || - Math.abs(adjacentToSource.y - element.y) < StockHeight / 2 - ); - } else { - // For arrowhead movement: check if target stock is valid sink - const firstPt = first(arrow.points); - // make sure we don't point a flow back at its source - if (firstPt.attachedToUid === element.uid) { - return false; - } - // For multi-segment flows (3+ points), the arrowhead needs to align with - // the adjacent point (second-to-last), not the source point. For 2-point - // flows, points.length - 2 = 0 gives us the first point, which is correct. - const adjacentToArrowhead = at(arrow.points, arrow.points.length - 2); - return ( - Math.abs(adjacentToArrowhead.x - element.x) < StockWidth / 2 || - Math.abs(adjacentToArrowhead.y - element.y) < StockHeight / 2 - ); - } - } - - return element.type === 'flow' || element.type === 'aux' || element.type === 'module'; + // Model coordinates of a client point: the canvas point less the live offset. + // Gesture presses and pointers are kept in model coordinates, so a wheel that + // pans or zooms mid-drag does not move what the drag plans. + const modelPoint = (clientX: number, clientY: number): Point => { + const p = getCanvasPoint(clientX, clientY); + const offset = getCanvasOffset(); + return { x: p.x - offset.x, y: p.y - offset.y }; }; - const getArcPoint = (): FlowPoint | undefined => { - if (!r.selectionCenterOffset) { - return undefined; - } - const off = defined(r.selectionCenterOffset); - const delta = latest.current.moveDelta ?? { x: 0, y: 0 }; - const canvasOffset = getCanvasOffset(); - return { - x: off.x - delta.x - canvasOffset.x, - y: off.y - delta.y - canvasOffset.y, - attachedToUid: undefined, - }; - }; + // A live gesture survives a republish that changes nothing it reads (E5): the + // same controller token, and the same geometry as the view it was pressed on. + // A pan reads nothing of the view. + const gestureIsValid = (g: ActiveGesture, p: CanvasProps): boolean => + g.gesture.kind === 'pan' || (g.token === p.token && sameGeometry(g.baseView, p.view)); - // The element the dragged single link's arrowhead is currently snapped to (a - // valid aux/flow/module target under the cursor), or undefined for empty - // space. A pure read over the displayed elements; shared by connector() - // (visual `to` endpoint) and deriveDraggedLinkArc (arc computation) so both - // agree on the snap target within a render. - const findLinkDragTarget = (): ViewElement | undefined => { - return r.derived.displayElements.find((e: ViewElement) => { - if (e.type !== 'aux' && e.type !== 'flow' && e.type !== 'module') { - return false; - } - return isValidTarget(e) || false; + // The gesture's plan with the pointer at `current`. The preview renders it and + // a release commits it at the release point: preview and commit are one + // function evaluated at one point (E2). + const planAt = (g: ActiveGesture, current: Point): GesturePlan => { + const p = latest.current.props; + return planGesture({ + view: p.view, + variables: p.model.variables, + selection: g.selection, + gesture: g.gesture, + press: g.press, + current, + zoom: getCanvasZoom(), + pointerType: g.pointerType, + readOnly: !!p.readOnly, + names: getNewVariableName, + clickSelection: g.clickSelection, }); }; - // Compute the arc for a single-link arrowhead drag exactly as connector() - // renders it: an arc only when snapped to a valid target with a mouse - // pointer (touch links are always straight), undefined otherwise. Writes - // nothing; called once per render from deriveRenderState so connector() and - // the pointer-up persist path read the identical value. - const deriveDraggedLinkArc = (selectionUpdates: ReadonlyMap): number | undefined => { - if (!isDraggingArrowhead(latest.current.interaction) || !r.selectionCenterOffset) { - return undefined; - } - if (latest.current.props.selection.size !== 1) { - return undefined; - } - const linkUid = only(latest.current.props.selection); - let link = r.elements.get(linkUid); - const updated = selectionUpdates.get(linkUid); - if (updated !== undefined) { - link = updated; - } - if (link === undefined || link.type !== 'link') { - return undefined; - } - if (getDragPointerType(latest.current.interaction) === 'touch') { - return undefined; - } - const validTarget = findLinkDragTarget(); - if (!validTarget) { - return undefined; - } - const from = selectionUpdates.get(link.fromUid) || tryGetElementByUid(link.fromUid); - if (!from) { - return undefined; - } - const arcPt = getArcPoint(); - return arcPt ? computeLinkCreationArc(from, validTarget, arcPt) : undefined; - }; - // The single render-phase derivation step. Invoked once at the top of the // render body (and the mount effect); it is the ONLY code permitted to write - // the render caches (r.elements, r.cachedVersion, r.derived). Every - // element-rendering helper reads r.derived and never recomputes or mutates a - // cache mid-render. - const deriveRenderState = (): RenderDerivation => { + // the render caches (r.elements, r.cachedElements, r.derived). + const deriveRenderState = (g: ActiveGesture | undefined, edit: NameEdit | undefined): RenderDerivation => { const p = latest.current.props; - const inCreationNow = latest.current.inCreation; - const inCreationCloudNow = latest.current.inCreationCloud; - let displayElements: readonly ViewElement[] = p.view.elements; - if (inCreationNow) { - displayElements = [...displayElements, inCreationNow]; - } - if (inCreationCloudNow) { - displayElements = [...displayElements, inCreationCloudNow]; - } - - // Rebuild the uid lookup only when the project version changed. r.elements - // is held across renders because event handlers read it after render returns - // ("n.b. we don't want to clear r.elements"). The displayElements array - // identity must track the same key, so cache both together. - if (p.version !== r.cachedVersion) { - const elements = new Map(displayElements.map((el) => [el.uid, el])); - elements.set(fauxTarget.uid, fauxTarget); - elements.set(fauxCloudTarget.uid, fauxCloudTarget); - r.elements = elements; - r.cachedVersion = p.version; - } - - let selectionUpdates = buildSelectionMap(p, r.elements, inCreationNow); - const activeLabelSide = getLabelSide(latest.current.interaction); - if (activeLabelSide) { - selectionUpdates = mapValues(selectionUpdates, (el) => { - return { ...el, labelSide: activeLabelSide } as ViewElement; - }) as Map; - } - if (latest.current.moveDelta) { - const moveDeltaValue = defined(latest.current.moveDelta); - - // When dragging a single link arrow (creation or reattachment), - // suppress arcPoint so processLinks doesn't compute a rotation-based - // arc. connector() handles arc computation directly. - const isDraggingLink = isDraggingArrowhead(latest.current.interaction) && p.selection.size === 1; - const { updatedElements } = applyGroupMovement({ - elements: r.elements.values(), - selection: p.selection, - delta: moveDeltaValue, - arcPoint: isDraggingLink ? undefined : getArcPoint(), - segmentIndex: getDraggingSegmentIndex(latest.current.interaction), - }); - - selectionUpdates = new Map([...selectionUpdates, ...updatedElements]); - } - - // Grow the in-creation flow's live preview. The flow tool stages a degenerate - // flow (both points at the press point) and records the drag only as - // moveDelta; applyGroupMovement can't grow it (a cloud->cloud flow translates - // rigidly, so it stays zero-length and invisible). Route it here the way the - // commit (computeFlowAttachment) does: the sink follows the cursor, or snaps - // onto a hovered stock's edge, with the source fixed and the flow orthogonal, - // so the preview matches what releasing the drag will produce. - if (inCreationNow?.type === 'flow' && latest.current.moveDelta && isDraggingArrowhead(latest.current.interaction)) { - let previewTarget: StockViewElement | undefined; - for (const el of displayElements) { - if (el.type === 'stock' && isValidTarget(el)) { - previewTarget = el; - break; - } - } - const grown = growInCreationFlow(inCreationNow, defined(latest.current.moveDelta), previewTarget); - selectionUpdates = new Map(selectionUpdates); - selectionUpdates.set(inCreationUid, grown); - // applyGroupMovement's single-flow path runs UpdateFlow, whose cloud->cloud - // case rigidly translates BOTH clouds by moveDelta -- dragging the source - // cloud along to the cursor. Restore it to its staged position so the flow - // grows from a planted tail. (The faux sink cloud isn't rendered.) - if (inCreationCloudNow) { - selectionUpdates.set(inCreationCloudUid, inCreationCloudNow); - } - } - - // Live-route an EXISTING flow's cloud endpoint drag the same way flow - // creation does (growEndpointDrag -> UpdateCloudAndFlow, the routing the - // commit computeFlowAttachment also uses), so the flow line, arrowhead, and - // valve all track the dragged cloud. applyGroupMovement's single-flow path - // (UpdateFlow) instead treats an along-axis endpoint drag as a valve slide - // and leaves the path stale -- the reported "valve moves live but the flow - // doesn't". Only fires for a cloud endpoint; a stock-endpoint (detach) drag - // keeps its existing applyGroupMovement behavior. - const draggingEndpoint = - isDraggingArrowhead(latest.current.interaction) || isDraggingSource(latest.current.interaction); - if (!inCreationNow && latest.current.moveDelta && draggingEndpoint && p.selection.size === 1) { - const flowUid = only(p.selection); - const flowEl = r.elements.get(flowUid); - if (flowEl?.type === 'flow' && flowEl.points.length >= 2) { - const isSource = isDraggingSource(latest.current.interaction); - const endPt = isSource ? first(flowEl.points) : last(flowEl.points); - const endpointEl = endPt.attachedToUid !== undefined ? r.elements.get(endPt.attachedToUid) : undefined; - if (endpointEl?.type === 'cloud') { - let previewTarget: StockViewElement | undefined; - for (const el of displayElements) { - if (el.type === 'stock' && isValidTarget(el)) { - previewTarget = el; - break; - } - } - const grown = growEndpointDrag(flowEl, isSource, defined(latest.current.moveDelta), previewTarget); - selectionUpdates = new Map(selectionUpdates); - selectionUpdates.set(flowUid, grown); - // Keep the (hidden-during-drag) cloud coherent with the rerouted flow: - // over empty space its center coincides with the moved endpoint. When - // snapped to a stock the cloud is being replaced, so leave it be. - if (previewTarget === undefined) { - const newEndPt = isSource ? first(grown.points) : last(grown.points); - selectionUpdates.set(endpointEl.uid, { ...endpointEl, x: newEndPt.x, y: newEndPt.y }); - } - // Cloud-to-cloud flow: applyGroupMovement's single-flow UpdateFlow - // path translates BOTH clouds by the drag delta, but growEndpointDrag - // holds the opposite endpoint FIXED. Restore the non-dragged cloud to - // its original position (it isn't hidden during the drag, unlike the - // dragged one) so it stays attached to the flow's fixed endpoint, - // matching the commit. - const otherPt = isSource ? last(flowEl.points) : first(flowEl.points); - const otherEl = otherPt.attachedToUid !== undefined ? r.elements.get(otherPt.attachedToUid) : undefined; - if (otherEl?.type === 'cloud') { - selectionUpdates.set(otherEl.uid, otherEl); - } - } - } - } - + const plan = g !== undefined && g.gesture.kind !== 'pan' && gestureIsValid(g, p) ? planAt(g, g.current) : undefined; + let displayElements: readonly ViewElement[] = plan?.elements ?? p.view.elements; + if (plan === undefined && edit?.draft !== undefined) { + displayElements = [...displayElements, edit.draft]; + } + if (displayElements !== r.cachedElements) { + r.elements = new Map(displayElements.map((el) => [el.uid, el])); + r.cachedElements = displayElements; + } + // A committing plan draws the selection its release applies, and a rubber + // band past the click threshold draws its membership, so the last preview + // frame is the committed frame. + const drawsPlanSelection = + plan !== undefined && + g !== undefined && + (plan.commit === 'edit' || + (g.gesture.kind === 'rubberBand' && beyondThreshold(g.press, g.current, getCanvasZoom()))); const derived: RenderDerivation = { displayElements, elementsByUid: r.elements, - selectionUpdates, + plan, + selection: drawsPlanSelection ? plan.selection : p.selection, hasAnyModuleReference: anyModuleHasModelReference(p.model.variables), - draggedLinkArc: undefined, }; - // Publish before computing the dragged-link arc: deriveDraggedLinkArc reads - // r.derived.displayElements (via findLinkDragTarget) and selectionUpdates. r.derived = derived; - derived.draggedLinkArc = deriveDraggedLinkArc(selectionUpdates); - return derived; }; @@ -1176,8 +819,8 @@ export const Canvas = React.memo(function Canvas(props: CanvasProps): React.Reac // Handle pinch-to-zoom gesture movement const handlePinchMove = (): void => { - const interactionNow = latest.current.interaction; - if (interactionNow.mode !== 'pinching') { + const interactionNow = r.pinch; + if (interactionNow === undefined) { return; } @@ -1320,913 +963,512 @@ export const Canvas = React.memo(function Canvas(props: CanvasProps): React.Reac // ---- Pointer handlers --------------------------------------------------- - const clearPointerState = (clearSelection = true): void => { - r.pointerId = undefined; + const trackPointer = (e: { pointerId: number; clientX: number; clientY: number }): void => { + r.activePointers.set(e.pointerId, { + id: e.pointerId, + x: e.clientX, + y: e.clientY, + timestamp: window.performance.now(), + }); + }; + + // Drop the live gesture, and a pan's physics anchors with it. Its release (if + // one still comes) finds no gesture and commits nothing. + const endGesture = (): void => { r.mouseDownPoint = undefined; - r.selectionCenterOffset = undefined; r.panBaseOffset = undefined; + setGesture(undefined); + }; - applyPointerStateReset(); - - if (clearSelection) { - latest.current.props.onSetSelection(new Set()); + // A pan's release: start the momentum coast; if it does not start (a + // stationary release), commit the pan now. Exactly one commit either way. + const settlePan = (): void => { + if (latest.current.liveViewport && !startMomentumAnimation()) { + commitLiveViewport(); } - - focusCanvas(); }; - const handlePointerCancel = (e: React.PointerEvent): void => { - if (latest.current.props.embedded) { - return; + // A gesture ended with no release to commit -- a pointercancel or a lost + // release -- commits nothing (E5). A pan still settles the viewport it moved: + // a viewport is presentation, not an edit. + const cancelGesture = (): void => { + if (r.gesture?.gesture.kind === 'pan') { + settlePan(); } + endGesture(); + focusCanvas(); + }; - e.preventDefault(); - e.stopPropagation(); - - // Remove this pointer from tracking - r.activePointers.delete(e.pointerId); + // A second finger: whatever the first finger started is dropped (E5), and the + // pinch anchors against the live viewport (a prior pan's offset if one was in + // flight, else props.view), so a pinch that follows a pan keeps its place. + const startPinch = (): void => { + endGesture(); + r.velocityTracker.positions = []; + const center = getPinchCenter(); + const centerCanvas = getCanvasPoint(center.x, center.y); + const base = getCanvasOffset(); + // The MODEL point under the pinch center stays under the fingers throughout. + r.pinch = { + initialDistance: getPinchDistance(), + initialZoom: getCanvasZoom(), + modelPoint: { x: centerCanvas.x - base.x, y: centerCanvas.y - base.y }, + }; + }; - // Handle end of pinch gesture - if (latest.current.interaction.mode === 'pinching') { - // Commit the pinched viewport once, on exit (handlePinchMove kept it local - // throughout the gesture). - commitLiveViewport(); - // When exiting pinch mode, clear all gesture state for a clean restart. - // Continuing with a single finger after pinch leads to confusing UX. - const { state: nextInteraction } = reduceInteraction( - latest.current.interaction, - { kind: 'pinchEnd' }, - interactionContext(), - ); - setInteraction(nextInteraction); - r.activePointers.clear(); - r.pointerId = undefined; - r.mouseDownPoint = undefined; - return; - } + // Commit the pinched viewport once, on exit, and drop every pointer: + // continuing with a single finger after a pinch leads to confusing UX. + const endPinch = (): void => { + commitLiveViewport(); + r.pinch = undefined; + r.activePointers.clear(); + r.mouseDownPoint = undefined; + }; - if (r.pointerId === undefined || r.pointerId !== e.pointerId) { + const handleMovingCanvas = (e: React.PointerEvent): void => { + if (!r.mouseDownPoint) { return; } + // Anchor against the offset captured at pan start (see refs.panBaseOffset), + // not props.view.viewBox, so an interrupted-momentum -> pan does not jump. + const base = r.panBaseOffset ?? latest.current.props.view.viewBox; + const curr = getCanvasPoint(e.clientX, e.clientY); + const newOffset = { + x: base.x + (curr.x - r.mouseDownPoint.x), + y: base.y + (curr.y - r.mouseDownPoint.y), + }; + trackPosition(newOffset.x, newOffset.y); + // A pan does not change zoom, so the live viewport keeps the current zoom. + setLiveViewport({ x: newOffset.x, y: newOffset.y, zoom: getCanvasZoom() }); + }; - const showDetails = shouldShowVariableDetails( - r.selectionCenterOffset !== undefined, - latest.current.moveDelta, - getViewZoom(), - isDraggingArrowhead(latest.current.interaction), - isDraggingSource(latest.current.interaction), - latest.current.interaction.mode === 'movingLabel', - ); + const pressInput = ( + hit: PressHit, + e: { + clientX: number; + clientY: number; + shiftKey: boolean; + ctrlKey: boolean; + metaKey: boolean; + pointerType?: string; + }, + pointers: number, + ): PressInput => { + const p = latest.current.props; + return { + view: p.view, + selection: p.selection, + tool: p.selectedTool, + hit, + point: modelPoint(e.clientX, e.clientY), + shiftKey: e.shiftKey, + toggleKey: e.ctrlKey || e.metaKey, + pointerType: e.pointerType || 'mouse', + readOnly: !!p.readOnly, + pressesDisabled: !!p.pressesDisabled, + pointers, + gestureLive: r.gesture !== undefined, + }; + }; - r.pointerId = undefined; - - // Resolve deferred selection: if user clicked an already-selected element - // without modifier, we deferred the selection change to allow group drag. - // Now on mouseUp, if no drag occurred, collapse to the single element. The - // deferred fields now live in the movingSelection union variant. - const interactionNow = latest.current.interaction; - if (interactionNow.mode === 'movingSelection' && interactionNow.deferredSingleSelectUid !== undefined) { - const didDrag = isDrag(latest.current.moveDelta, getViewZoom()); - const newSel = resolveDeferredSelection(interactionNow.deferredSingleSelectUid, didDrag); - // Collapse the group selection to the pressed element on a no-drag - // pointer-up (Figma-style). Name editing is NOT entered from here: a - // double-click on a label is a terminal `dblclick` and enters editing - // synchronously in handleSetSelection, never via this deferred pointer-up. - if (newSel) { - latest.current.props.onSetSelection(newSel); - } - } + const beginNameEdit = ( + uid: UID, + draft: ViewElement | undefined, + creatingFlow: boolean, + named: ViewElement | undefined, + ): void => { + setNameEdit({ uid, draft, creatingFlow }); + const name = named !== undefined && isNamedViewElement(named) ? named.name : ''; + setEditingName(plainDeserialize('label', displayName(name))); + setNameError(undefined); + }; - if (interactionNow.mode === 'movingLabel') { - const selected = only(latest.current.props.selection); - latest.current.props.onMoveLabel(selected, interactionNow.side); - clearPointerState(false); - return; - } + // Close the name editor. Settling a name clears the selection, and focus lands + // on the canvas so the key events that follow belong to this editor. + const endNameEdit = (): void => { + setNameEdit(undefined); + setNameError(undefined); + latest.current.props.onSetSelection(new Set()); + focusCanvas(); + }; - if (r.selectionCenterOffset) { - if (latest.current.moveDelta) { - const arcPoint = getArcPoint(); - const delta = latest.current.moveDelta; - // The mode after committing the move: idle, unless we hand off into name - // editing (creation tool, or a just-created flow). Computed once because - // every boolean that used to be cleared piecemeal now lives in the union. - let nextInteraction: InteractionState = idleState; - - if (interactionNow.mode === 'editingName' && interactionNow.onPointerUp) { - let inCreationLocal = latest.current.inCreation; - if ( - inCreationLocal !== undefined && - (inCreationLocal.type === 'stock' || inCreationLocal.type === 'aux' || inCreationLocal.type === 'module') - ) { - inCreationLocal = { - ...inCreationLocal, - x: inCreationLocal.x - delta.x, - y: inCreationLocal.y - delta.y, - }; - } else { - throw new Error('invariant broken'); - } - - const nextEditingName = plainDeserialize( - 'label', - displayName(defined((inCreationLocal as NamedViewElement).name)), - ); - setInteraction({ mode: 'editingName', onPointerUp: false, creatingFlow: false }); - setEditingName(nextEditingName); - setInCreation(inCreationLocal); - setMoveDelta(undefined); - r.selectionCenterOffset = undefined; - // we do weird one off things in this codepath, so exit early - return; - } else if (!isDraggingArrowhead(interactionNow) && !isDraggingSource(interactionNow)) { - // A sub-threshold pointer wobble during a click is not a drag: don't - // nudge the element. shouldShowVariableDetails (which applies the - // same threshold) will open the details panel for it instead. - if (isDragMovement(delta, getViewZoom())) { - latest.current.props.onMoveSelection(delta, arcPoint, getDraggingSegmentIndex(interactionNow)); - } - } else { - const element = getElementByUid(only(latest.current.props.selection)); - let foundInvalidTarget = false; - const validTarget = r.derived.displayElements.find((el: ViewElement) => { - const isValid = isValidTarget(el); - foundInvalidTarget = foundInvalidTarget || isValid === false; - return isValid || false; - }); - if (element.type === 'link' && validTarget) { - // Use the arc that was last rendered — computed once per render in - // deriveRenderState (derived.draggedLinkArc) and drawn by connector() - // — so the saved link matches the visual exactly. Works for both - // new-link creation and existing-link reattachment. - const linkToAttach = { ...element, arc: r.derived.draggedLinkArc }; - latest.current.props.onAttachLink(linkToAttach, defined(validTarget.ident)); - } else if (element.type === 'flow') { - // don't create a flow stacked on top of 2 clouds due to a misclick - // (a click that wobbled a pixel is still a misclick, not a drag) - if (!isDragMovement(latest.current.moveDelta, getViewZoom()) && latest.current.inCreation) { - clearPointerState(); - return; - } - const inCreationFlag = !!latest.current.inCreation; - const isSourceAttach = isDraggingSource(interactionNow); - let fauxTargetCenter: Point | undefined; - if (element.points[1]?.attachedToUid === fauxCloudTargetUid) { - const canvasOffset = getCanvasOffset(); - fauxTargetCenter = { - x: r.selectionCenterOffset.x - canvasOffset.x, - y: r.selectionCenterOffset.y - canvasOffset.y, - }; - } - // For source movement when not snapped to a valid target, compute the faux source center - if (isSourceAttach && !validTarget) { - const canvasOffset = getCanvasOffset(); - fauxTargetCenter = { - x: r.selectionCenterOffset.x - canvasOffset.x, - y: r.selectionCenterOffset.y - canvasOffset.y, - }; - } - latest.current.props.onMoveFlow( - element, - validTarget ? validTarget.uid : 0, - delta, - fauxTargetCenter, - inCreationFlag, - isSourceAttach, - ); - if (inCreationFlag) { - // Hand off into editing the just-created flow's name. creatingFlow - // (formerly flowStillBeingCreated) makes a later name-cancel delete - // the flow. The editingName Slate value is carried alongside. - nextInteraction = { mode: 'editingName', onPointerUp: false, creatingFlow: true }; - setEditingName(plainDeserialize('label', displayName(defined(element.name)))); - } - } else if (!foundInvalidTarget || latest.current.inCreation) { - latest.current.props.onDeleteSelection(); - } + // Carry out what classifyPress decided a press does. + const applyPress = (outcome: PressOutcome, e: React.MouseEvent, capture: boolean): void => { + const p = latest.current.props; + switch (outcome.kind) { + case 'ignore': + case 'drill': + return; + case 'pinch': + startPinch(); + return; + case 'abort': + endGesture(); + return; + case 'commitName': + handleEditingNameDone(false); + return; + case 'select': + if (outcome.clearTool) { + p.onClearSelectedTool(); } - - // Single coalesced commit: the discrete mode (idle, or the editingName - // hand-off computed above) plus the continuous companions that travel - // with a move. Replaces the former piecemeal isMovingArrow / isMovingSource - // / draggingSegmentIndex clears -- those all collapse into `interaction`. - // React batches these setters into one render with the net state. - setInteraction(nextInteraction); - setMoveDelta(undefined); - setInCreation(undefined); - setInCreationCloud(undefined); - } else if (isDraggingArrowhead(interactionNow) || isDraggingSource(interactionNow)) { - // User clicked on flow arrowhead/source (or cloud) but didn't move. - // Clear the movement mode so the cloud reappears. - setInteraction(idleState); - } - r.selectionCenterOffset = undefined; - if (showDetails) { - latest.current.props.onShowVariableDetails(); - } - return; - } - - if (interactionNow.mode === 'panning' && latest.current.liveViewport) { - // Start the momentum coast first. If it starts, the live viewport stays set - // and the single commit is deferred to the coast's natural end; if it does - // not (a stationary release), commit the pan now. Exactly one commit either - // way. - const didStartMomentum = startMomentumAnimation(); - if (!didStartMomentum) { - commitLiveViewport(); + p.onSetSelection(outcome.selection); + return; + case 'editName': + if (outcome.clearTool) { + p.onClearSelectedTool(); + } + p.onSetSelection(outcome.selection); + beginNameEdit(outcome.uid, undefined, false, tryGetElementByUid(outcome.uid)); + return; + case 'start': { + const pe = e as React.PointerEvent; + if (outcome.clearTool) { + p.onClearSelectedTool(); + } + if (outcome.selection !== undefined) { + p.onSetSelection(outcome.selection); + } + if (outcome.gesture.kind === 'pan') { + r.mouseDownPoint = getCanvasPoint(pe.clientX, pe.clientY); + r.velocityTracker.positions = []; + const offset = getCanvasOffset(); + r.panBaseOffset = { x: offset.x, y: offset.y }; + trackPosition(offset.x, offset.y); + } else if (capture) { + // Capture on the svg root, never the pressed node: a plan can remove the + // pressed element from the preview (a valid drop deletes the dragged + // cloud), which releases a capture it held, and a release over chrome + // would then be lost. + svgRef.current?.querySelector('svg')?.setPointerCapture(pe.pointerId); + } + const at = modelPoint(pe.clientX, pe.clientY); + setGesture({ + gesture: outcome.gesture, + pointerId: pe.pointerId, + pointerType: pe.pointerType || 'mouse', + press: at, + current: at, + baseView: p.view, + token: p.token, + selection: outcome.selection ?? p.selection, + clickSelection: outcome.clickSelection, + }); + return; } } - - if (!r.mouseDownPoint) { - return; - } - - // Handle drag selection - if (interactionNow.mode === 'dragSelecting' && latest.current.dragSelectionPoint) { - const pointA = r.mouseDownPoint; - const pointB = latest.current.dragSelectionPoint; - const canvasOffset = getCanvasOffset(); - - // Calculate selection rectangle bounds - const left = Math.min(pointA.x, pointB.x) - canvasOffset.x; - const right = Math.max(pointA.x, pointB.x) - canvasOffset.x; - const top = Math.min(pointA.y, pointB.y) - canvasOffset.y; - const bottom = Math.max(pointA.y, pointB.y) - canvasOffset.y; - - // Find all elements within the selection rectangle. Each element type's - // containment rule lives in canvas-interaction.isInDragSelectRect; auxes - // additionally count when any rectangle corner falls inside the aux - // circle (a geometry test the shell owns via auxContains). - const rect = { left, right, top, bottom }; - const auxCornerHit = (element: ViewElement): boolean => - auxContains(element as AuxViewElement, { x: left, y: top }) || - auxContains(element as AuxViewElement, { x: right, y: top }) || - auxContains(element as AuxViewElement, { x: left, y: bottom }) || - auxContains(element as AuxViewElement, { x: right, y: bottom }); - const selectedElements = computeDragSelection(r.derived.displayElements, rect, auxCornerHit); - - // Update selection - latest.current.props.onSetSelection(selectedElements); - clearPointerState(false); - return; - } - - // A pan must not clear the selection; everything reaching here does. The - // panning branch above only cleared movingCanvasOffset, so the mode is still - // 'panning' here (mirrors the former `!this.state.isMovingCanvas`). - const clearSelection = interactionNow.mode !== 'panning'; - clearPointerState(clearSelection); }; - const handleSelectionMove = (e: React.PointerEvent): void => { - if (!r.selectionCenterOffset) { + // A pointer press on the empty canvas or an element. A press that starts + // anything interrupts an in-flight momentum coast; the live viewport is + // preserved, so a pan or pinch this press starts inherits it and commits the + // combined result, while any other press lets interruptCoast's deferred + // commit persist it. + const pressPointer = (hit: PressHit, e: React.PointerEvent): void => { + const pointers = r.activePointers.size + (r.activePointers.has(e.pointerId) ? 0 : 1); + const outcome = classifyPress(pressInput(hit, e, pointers)); + if (outcome.kind === 'ignore') { return; } + interruptCoast(); + trackPointer(e); + applyPress(outcome, e, true); + }; - const currPt = getCanvasPoint(e.clientX, e.clientY); - - const dx = r.selectionCenterOffset.x - currPt.x; - const dy = r.selectionCenterOffset.y - currPt.y; - - setMoveDelta({ - x: dx, - y: dy, + const moveGesture = (g: ActiveGesture, e: React.PointerEvent): void => { + const current = modelPoint(e.clientX, e.clientY); + const latched = latchGesture(g.gesture, { + view: latest.current.props.view, + press: g.press, + current, + zoom: getCanvasZoom(), }); + setGesture({ ...g, gesture: latched, current }); }; - const handleMovingCanvas = (e: React.PointerEvent): void => { - if (!r.mouseDownPoint) { + // Commit a released gesture: its plan at the release point, which is the frame + // the preview drew there (E2), unless a republish invalidated it (E5). + const finishGesture = (g: ActiveGesture, current: Point): void => { + const p = latest.current.props; + if (!gestureIsValid(g, p)) { + focusCanvas(); return; } - - // Anchor against the offset captured at pan start (see refs.panBaseOffset), - // not props.view.viewBox, so an interrupted-momentum -> pan does not jump. - const base = r.panBaseOffset ?? latest.current.props.view.viewBox; - const curr = getCanvasPoint(e.clientX, e.clientY); - - const newOffset = { - x: base.x + (curr.x - r.mouseDownPoint.x), - y: base.y + (curr.y - r.mouseDownPoint.y), + const released: ActiveGesture = { + ...g, + current, + gesture: latchGesture(g.gesture, { view: p.view, press: g.press, current, zoom: getCanvasZoom() }), }; - - // Track position for momentum calculation - trackPosition(newOffset.x, newOffset.y); - - // The panning mode was already entered on pointer-down; re-affirm it (it is - // the move-guard in handlePointerMove) alongside the continuous offset. A pan - // does not change zoom, so the live viewport keeps the current zoom. - setInteraction({ mode: 'panning' }); - setLiveViewport({ x: newOffset.x, y: newOffset.y, zoom: getCanvasZoom() }); + const plan = planAt(released, current); + if (plan.commit === 'edit') { + p.onCommitGesture({ + label: plan.label, + elements: plan.elements, + nextUid: plan.nextUid, + selection: plan.selection, + token: g.token, + baseView: p.view, + editName: plan.handoff?.editName, + }); + } else if (plan.commit === 'select') { + p.onSetSelection(plan.selection); + } + if (plan.handoff !== undefined) { + const uid = plan.handoff.editName; + const named = plan.draft ?? plan.elements.find((el) => el.uid === uid); + beginNameEdit(uid, plan.draft, released.gesture.kind === 'createFlow', named); + return; + } + if (plan.details) { + p.onShowVariableDetails(); + } + focusCanvas(); }; - const handleDragSelection = (e: React.PointerEvent): void => { - if (!r.mouseDownPoint) { + const handlePointerDown = (e: React.PointerEvent): void => { + if (latest.current.props.embedded) { return; } - - const nextDragSelectionPoint = getCanvasPoint(e.clientX, e.clientY); - - setInteraction({ mode: 'dragSelecting' }); - setDragSelectionPoint(nextDragSelectionPoint); + e.preventDefault(); + e.stopPropagation(); + pressPointer({ kind: 'canvas' }, e); }; const handlePointerMove = (e: React.PointerEvent): void => { if (latest.current.props.embedded) { return; } - - // Update tracked pointer position if (r.activePointers.has(e.pointerId)) { - r.activePointers.set(e.pointerId, { - id: e.pointerId, - x: e.clientX, - y: e.clientY, - timestamp: window.performance.now(), - }); + trackPointer(e); } - - // Handle pinch gesture - if (latest.current.interaction.mode === 'pinching' && r.activePointers.size >= 2) { - handlePinchMove(); + if (r.pinch !== undefined) { + if (r.activePointers.size >= 2) { + handlePinchMove(); + } return; } - - if (r.pointerId !== e.pointerId) { + const g = r.gesture; + if (g === undefined || g.pointerId !== e.pointerId) { return; - } else if (r.pointerId && e.pointerType === 'mouse' && e.buttons === 0) { - handlePointerCancel(e); } - - if (r.selectionCenterOffset) { - handleSelectionMove(e); - } else if (latest.current.interaction.mode === 'dragSelecting') { - handleDragSelection(e); - } else if (latest.current.interaction.mode === 'panning') { + if (isLostRelease(e.pointerType, e.buttons)) { + // The release never came, so forget the pointer too: a later press would + // count it, and a single touch would start a pinch. + r.activePointers.delete(e.pointerId); + cancelGesture(); + return; + } + if (g.gesture.kind === 'pan') { handleMovingCanvas(e); + return; } + moveGesture(g, e); }; - const handlePointerDown = (e: React.PointerEvent): void => { + const handlePointerUp = (e: React.PointerEvent): void => { if (latest.current.props.embedded) { return; } - e.preventDefault(); e.stopPropagation(); - - // A new press interrupts an in-flight momentum coast. The live viewport is - // preserved: a pan or pinch started by this press inherits it (via - // panBaseOffset / the pinch reference reads) and commits the combined result - // on its own settle, while a press that is NOT a viewport gesture (a - // click/selection) lets interruptCoast's deferred commit persist the coasted - // viewport. (A pending wheel commit is likewise left armed, not cancelled.) - interruptCoast(); - - // Track this pointer for multi-touch detection - r.activePointers.set(e.pointerId, { - id: e.pointerId, - x: e.clientX, - y: e.clientY, - timestamp: window.performance.now(), - }); - - // Check for pinch gesture (two touches) - if (r.activePointers.size === 2 && e.pointerType === 'touch') { - // Start pinch mode - clear all single-finger gesture state to prevent - // simultaneous pan+pinch or drag+pinch if user adds second finger mid-gesture - r.pointerId = undefined; - r.mouseDownPoint = undefined; - r.selectionCenterOffset = undefined; - // Reset velocity tracker since pinch doesn't use momentum - r.velocityTracker.positions = []; - - const distance = getPinchDistance(); - const center = getPinchCenter(); - const centerCanvas = getCanvasPoint(center.x, center.y); - // Anchor against the live viewport (= a prior pan's offset if one was in - // flight, else props.view), so a pinch that follows a pan keeps its place. - const base = getCanvasOffset(); - - // Calculate the MODEL point under the pinch center. This is the fixed - // point in model space that should remain under the user's fingers - // throughout the pinch gesture. - const pinchModelPoint = { - x: centerCanvas.x - base.x, - y: centerCanvas.y - base.y, - }; - - // Entering pinch mode supersedes any single-finger panning/dragSelecting - // mode; the reducer returns the pinching variant carrying the fixed - // reference. The live viewport is intentionally NOT cleared: handlePinchMove - // writes it each move and pinch exit commits it once. - const { state: nextInteraction, effects } = reduceInteraction( - latest.current.interaction, - { - kind: 'pinchStart', - initialDistance: distance, - initialZoom: getCanvasZoom(), - modelPoint: pinchModelPoint, - }, - interactionContext(), - ); - runEffects(effects, e.target as Element | undefined, e.pointerId); - setInteraction(nextInteraction); - return; - } - - // If already pinching and a third finger comes in, ignore it - if (latest.current.interaction.mode === 'pinching') { + r.activePointers.delete(e.pointerId); + if (r.pinch !== undefined) { + endPinch(); return; } - - // For non-primary touches when we already have a primary, track for potential pinch - if (!e.isPrimary && r.pointerId !== undefined) { + const g = r.gesture; + if (g === undefined || g.pointerId !== e.pointerId) { return; } - - const client = getCanvasPoint(e.clientX, e.clientY); - - const canvasOffset = getCanvasOffset(); - const { selectedTool } = latest.current.props; - if (selectedTool === 'aux' || selectedTool === 'stock' || selectedTool === 'module') { - let inCreationLocal: AuxViewElement | StockViewElement | ModuleViewElement; - if (selectedTool === 'aux') { - const name = getNewVariableName('New Variable'); - inCreationLocal = { - type: 'aux', - uid: inCreationUid, - var: undefined, - x: client.x - canvasOffset.x, - y: client.y - canvasOffset.y, - name, - ident: canonicalize(name), - labelSide: 'right', - isZeroRadius: false, - }; - } else if (selectedTool === 'stock') { - const name = getNewVariableName('New Stock'); - inCreationLocal = { - type: 'stock', - uid: inCreationUid, - var: undefined, - x: client.x - canvasOffset.x, - y: client.y - canvasOffset.y, - name, - ident: canonicalize(name), - labelSide: 'bottom', - isZeroRadius: false, - inflows: [], - outflows: [], - }; + // The gesture ends whatever its commit does: a host callback that throws + // must not leave it live for the next press to inherit. + try { + if (g.gesture.kind === 'pan') { + settlePan(); + focusCanvas(); } else { - const name = getNewVariableName('New Module'); - inCreationLocal = { - type: 'module', - uid: inCreationUid, - var: undefined, - x: client.x - canvasOffset.x, - y: client.y - canvasOffset.y, - name, - ident: canonicalize(name), - labelSide: 'bottom', - isZeroRadius: false, - }; + finishGesture(g, modelPoint(e.clientX, e.clientY)); } + } finally { + endGesture(); + } + }; - r.pointerId = e.pointerId; - r.selectionCenterOffset = client; - - // The creation-tool press enters the editing-on-pointer-up handoff and - // captures the pointer (the capturePointer effect runs setPointerCapture). - // The staged element + zero moveDelta are the continuous companions the - // shell owns. - const { state: nextInteraction, effects } = reduceInteraction( - latest.current.interaction, - { kind: 'createToolPointerDown', tool: selectedTool }, - interactionContext(), - ); - runEffects(effects, e.target as Element | undefined, e.pointerId); - setInteraction(nextInteraction); - setInCreation(inCreationLocal); - setMoveDelta({ x: 0, y: 0 }); - latest.current.props.onSetSelection(new Set([inCreationLocal.uid])); + const handlePointerCancel = (e: React.PointerEvent): void => { + if (latest.current.props.embedded) { return; } - r.pointerId = e.pointerId; - - if (selectedTool === 'flow') { - const canvasOffsetFlow = getCanvasOffset(); - const x = client.x - canvasOffsetFlow.x; - const y = client.y - canvasOffsetFlow.y; - - const inCreationCloudLocal: CloudViewElement = { - type: 'cloud', - uid: inCreationCloudUid, - flowUid: inCreationUid, - x, - y, - isZeroRadius: false, - ident: undefined, - }; - - const name = getNewVariableName('New Flow'); - const inCreationLocal: FlowViewElement = { - type: 'flow', - uid: inCreationUid, - var: undefined, - name, - ident: canonicalize(name), - x, - y, - labelSide: 'bottom', - points: [ - { x, y, attachedToUid: inCreationCloudLocal.uid }, - { x, y, attachedToUid: fauxCloudTarget.uid }, - ], - isZeroRadius: false, - }; - - r.selectionCenterOffset = client; - - // Flow tool on empty canvas: enter arrowhead-drag of the staged flow so the - // user drags the sink into place (no pointer capture in this branch, as - // before). The staged flow + source cloud are the continuous companions. - const { state: nextInteraction, effects } = reduceInteraction( - latest.current.interaction, - { kind: 'flowToolPointerDown', pointerType: e.pointerType }, - interactionContext(), - ); - runEffects(effects, e.target as Element | undefined, e.pointerId); - setInteraction(nextInteraction); - setInCreation(inCreationLocal); - setInCreationCloud(inCreationCloudLocal); - setMoveDelta({ x: 0, y: 0 }); - latest.current.props.onSetSelection(new Set([inCreationLocal.uid])); + e.preventDefault(); + e.stopPropagation(); + r.activePointers.delete(e.pointerId); + if (r.pinch !== undefined) { + endPinch(); return; } - - // onclick handlers are weird. If we mouse down on a circle, move - // off the circle, and mouse-up on the canvas, the canvas gets an - // onclick. Instead, capture where we mouse-down'd, and on mouse up - // check if its the same. - r.mouseDownPoint = getCanvasPoint(e.clientX, e.clientY); - - // Discrete decision: touch / shift-drag pans, everything else rubber-band - // drag-selects. Routed through the pure reducer so the pan-vs-select rule - // lives in canvas-interaction; the continuous pan offset + momentum stay in - // the shell. - const pan = e.pointerType === 'touch' || e.shiftKey; - const { state: nextInteraction, effects } = reduceInteraction( - idleState, - { kind: 'canvasPointerDown', pan }, - interactionContext(), - ); - runEffects(effects, e.target as Element | undefined, e.pointerId); - if (nextInteraction.mode === 'panning') { - // Initialize velocity tracking for momentum - r.velocityTracker.positions = []; - const canvasOffsetPan = getCanvasOffset(); - // Anchor the pan against the on-screen offset at press time (= the live - // viewport if a momentum coast was interrupted, else props.view.viewBox). - r.panBaseOffset = { x: canvasOffsetPan.x, y: canvasOffsetPan.y }; - trackPosition(canvasOffsetPan.x, canvasOffsetPan.y); - } - // The pan-vs-drag-select mode came from the reducer; the in-creation - // companions are cleared regardless (an empty-canvas press stages nothing). - setInteraction(nextInteraction); - setInCreation(undefined); - setInCreationCloud(undefined); - }; - - const handleModuleDoubleClick = (element: ModuleViewElement): void => { - const variable = latest.current.props.model.variables.get(element.ident); - if (variable?.type !== 'module' || !variable.modelName) { + if (r.gesture === undefined || r.gesture.pointerId !== e.pointerId) { return; } - latest.current.props.onDrillIntoModule(element.ident, variable.modelName); + cancelGesture(); }; - const handleLabelDrag = (uid: number, e: React.PointerEvent): void => { - r.pointerId = e.pointerId; - - const selectionSet = new Set([uid]); - if (!setsEqual(latest.current.props.selection, selectionSet)) { - latest.current.props.onSetSelection(selectionSet); + // A label dragged past the label component's own click threshold starts a + // label gesture on that first move. Its later moves and its release bubble to + // the svg's handlers, which update and finish the gesture like any other. + // The label holds its own pointer capture (Label.tsx), so a label gesture + // captures nothing more. + const labelDragImpl = (uid: number, e: React.PointerEvent): void => { + if (latest.current.props.embedded || r.gesture !== undefined) { + return; } - - const element = getElementByUid(uid); - const delta = getCanvasOffset(); - const client = getCanvasPoint(e.clientX, e.clientY); - const pointer = { - x: client.x - delta.x, - y: client.y - delta.y, - }; - - const side = labelSideForPointer({ x: element.x, y: element.y }, pointer); - - const { state: nextInteraction, effects } = reduceInteraction( - latest.current.interaction, - { kind: 'labelDragStart', side }, - interactionContext(), - ); - runEffects(effects, e.target as Element | undefined, e.pointerId); - setInteraction(nextInteraction); + applyPress(classifyPress(pressInput({ kind: 'labelDrag', uid }, e, 1)), e, false); }; const handleEditingEnd = (e: React.PointerEvent): void => { e.preventDefault(); e.stopPropagation(); - - handleEditingNameDone(false); + applyPress(classifyPress(pressInput({ kind: 'nameEditor' }, e, 1)), e, false); }; - const handleEditConnector = (element: ViewElement, e: React.PointerEvent, isArrowhead: boolean): void => { - handleSetSelection(element, e, false, isArrowhead); + const editConnectorImpl = (element: ViewElement, e: React.PointerEvent, isArrowhead: boolean): void => { + setSelectionImpl(element, e, false, isArrowhead); }; - // called from handleMouseDown in elements like Aux - const handleSetSelection = ( + // Called from the element components' press handlers: a body, arrowhead or + // source-grip press with a pointer, or a label's double-click (isText), which + // carries no pointer and starts no drag. + const setSelectionImpl = ( element: ViewElement, e: React.PointerEvent, isText?: boolean, isArrowhead?: boolean, - segmentIndex?: number, isSource?: boolean, ): void => { if (latest.current.props.embedded) { return; } - - // These locals track the discrete outcome the way the pre-migration code did - // (mutually-exclusive booleans); they are folded into a single interaction - // variant at the end. The shell owns the geometry/hit-testing here (cloud - // reattachment, staged tool elements, Slate name deserialize) and composes - // the pure selection decisions (decideMouseDownSelection, - // resolveSelectionForReattachment); the discrete *mode* it lands in is then - // expressed through the tagged union, not loose flags. - // A read-only canvas never opens the inline label editor: the rename it - // would eventually request is a no-op there, so offering the editing UI - // would be the "editable but unsavable" trap issue #935 closes. - let isEditingName = !!isText && !latest.current.props.readOnly; - let nextEditingName: Array = []; - let draggingArrowEndpoint = !!isArrowhead; - let draggingSourceEndpoint = !!isSource; - - r.pointerId = e.pointerId; - - // For multi-selection, use the click point as the offset - // This ensures smooth dragging from where the user clicked - r.selectionCenterOffset = getCanvasPoint(e.clientX, e.clientY); - - if (!isEditingName) { - (e.target as Element).setPointerCapture(e.pointerId); - } - - const { selectedTool } = latest.current.props; - let inCreationLocal: ViewElement | undefined; - - if (selectedTool === 'link' && isNamedViewElement(element)) { - isEditingName = false; - draggingArrowEndpoint = true; - inCreationLocal = { - type: 'link', - uid: inCreationUid, - fromUid: element.uid, - toUid: fauxTarget.uid, - arc: 0.0, - multiPoint: undefined, - isStraight: false, - polarity: undefined, - x: 0, - y: 0, - isZeroRadius: false, - ident: undefined, - }; - element = inCreationLocal; - } else if (selectedTool === 'flow' && element.type === 'stock') { - isEditingName = false; - draggingArrowEndpoint = true; - const startPoint: FlowPoint = { - x: element.x, - y: element.y, - attachedToUid: element.uid, - }; - const endPoint: FlowPoint = { - x: element.x, - y: element.y, - attachedToUid: fauxCloudTarget.uid, - }; - const name = getNewVariableName('New Flow'); - inCreationLocal = { - type: 'flow', - uid: inCreationUid, - var: undefined, - name: name, - ident: canonicalize(name), - x: element.x, - y: element.y, - labelSide: 'bottom', - points: [startPoint, endPoint], - isZeroRadius: false, - }; - element = inCreationLocal; - } else { - // Not a link/flow tool action -- compute selection and handle clouds - latest.current.props.onClearSelectedTool(); - - // A name-edit request (double-click on the label) opens the inline editor - // immediately, whether or not the element is already selected. It must NOT - // go through the deferred-single-select dance below: that path only - // resolves into editing on the pointer-UP that follows a modifier-less - // press on a selected element, but a name-edit request arrives as a - // terminal `dblclick` whose pointer-up already fired -- so deferring would - // silently drop the edit for any already-selected variable (the reported - // "doesn't reliably open" bug). Only named elements render a label, so - // `element` is nameable here; collapse the selection to it and edit now. - if (isEditingName && isNamedViewElement(element)) { - nextEditingName = plainDeserialize('label', displayName(defined(element.name))); - latest.current.props.onSetSelection(new Set([element.uid])); - setInteraction({ mode: 'editingName', onPointerUp: false, creatingFlow: false }); - setEditingName(nextEditingName); - setInCreation(undefined); - setMoveDelta(undefined); - return; - } - - const isMultiSelect = e.ctrlKey || e.metaKey || e.shiftKey; - const { newSelection, deferSingleSelect } = decideMouseDownSelection( - latest.current.props.selection, - element.uid, - isMultiSelect, - ); - - if (deferSingleSelect !== undefined) { - // Element is already in the selection and no modifier -- defer selection - // change to mouseUp so that group drag works without dissolving selection. - // The deferred fields ride inside the movingSelection variant now. - setInteraction({ - mode: 'movingSelection', - deferredSingleSelectUid: deferSingleSelect, - segmentIndex, - }); - setEditingName(nextEditingName); - setInCreation(inCreationLocal); - setMoveDelta({ x: 0, y: 0 }); - return; - } - - // Cloud re-attachment only when the cloud will be the sole selection - const willBeSoleSelection = newSelection !== undefined && newSelection.size === 1; - if (element.type === 'cloud' && element.flowUid !== undefined && willBeSoleSelection) { - let flow: FlowViewElement | undefined; - try { - const flowElement = getElementByUid(element.flowUid); - if (flowElement.type === 'flow') { - flow = flowElement; - } - } catch (err) { - console.warn(`Cloud ${element.uid} references invalid flow ${element.flowUid}:`, err); - } - if (flow) { - if (isCloudOnSourceSide(element, flow)) { - draggingSourceEndpoint = true; - element = flow; - } else if (isCloudOnSinkSide(element, flow)) { - draggingArrowEndpoint = true; - element = flow; - } - } - } - - // Only allow editing name if single selection of a named element - if (isEditingName && newSelection !== undefined && newSelection.size === 1) { - const uid = only(newSelection); - const editingElement = getElementByUid(uid) as NamedViewElement; - nextEditingName = plainDeserialize('label', displayName(defined(editingElement.name))); - } else { - isEditingName = false; - } - - if (newSelection !== undefined) { - const enteredReattachment = draggingSourceEndpoint || draggingArrowEndpoint; - latest.current.props.onSetSelection( - resolveSelectionForReattachment(newSelection, enteredReattachment, element.uid), - ); - } - } - - // Fold the mutually-exclusive outcome into one interaction variant: - // - an endpoint drag (arrowhead/source, link/flow tool, cloud reattach) - // - inline name editing (double-click on a single named element) - // - otherwise a (potential) selection move, carrying any flow segmentIndex. - // pointerType is recorded for every endpoint drag so the touch-is-always- - // straight link rule (connector()/deriveDraggedLinkArc) has the real value. - let nextInteraction: InteractionState; - if (draggingArrowEndpoint || draggingSourceEndpoint) { - nextInteraction = { - mode: 'movingEndpoint', - endpoint: draggingSourceEndpoint ? 'source' : 'arrow', - pointerType: e.pointerType, - }; - } else if (isEditingName) { - nextInteraction = { mode: 'editingName', onPointerUp: false, creatingFlow: false }; - } else { - nextInteraction = { - mode: 'movingSelection', - deferredSingleSelectUid: undefined, - segmentIndex, - }; - } - - setInteraction(nextInteraction); - setEditingName(nextEditingName); - setInCreation(inCreationLocal); - setMoveDelta({ x: 0, y: 0 }); - - if (selectedTool === 'link' || selectedTool === 'flow') { - latest.current.props.onSetSelection(new Set([element.uid])); + if (isText) { + applyPress(classifyPress(pressInput({ kind: 'labelDoubleClick', uid: element.uid }, e, 1)), e, false); + return; } + const part = isArrowhead ? 'arrowhead' : isSource ? 'source' : 'body'; + pressPointer({ kind: 'element', uid: element.uid, part }, e); }; const handleEditingNameChange = (value: Descendant[]): void => { setEditingName(value); + setNameError(undefined); }; const handleEditingNameDone = (isCancel: boolean): void => { - const interactionNow = latest.current.interaction; - // Old guard was `if (!this.state.isEditingName) return` -- the editor must be - // SHOWING NOW. The staging variant (`onPointerUp: true`, set during a - // creation drag before the editor mounts) must NOT run this, so exclude it - // here too (mirrors the isShowingNameEditor helper while narrowing the union). - if (interactionNow.mode !== 'editingName' || interactionNow.onPointerUp) { + const edit = r.nameEdit; + if (edit === undefined) { return; } - if (isCancel) { - // Cancelling the initial name edit of a just-created flow deletes the - // flow; creatingFlow (formerly flowStillBeingCreated) is reset by - // clearPointerState's `interaction: idle` below, so a later rename-cancel - // can't re-trigger this. - if (interactionNow.creatingFlow) { + // Cancelling a drawn flow's first name edit deletes the flow (its commit + // made it the selection). The latch lives in this edit only, so a later + // rename's cancel can never delete anything. + if (edit.creatingFlow) { latest.current.props.onDeleteSelection(); } - clearPointerState(); + endNameEdit(); return; } // A commit whose sanitized name is empty (all whitespace/blank lines) is a - // cancel, not a rename to "": for a just-created flow the recursive cancel - // path also deletes the flow, matching Escape. + // cancel, not a rename to "": for a drawn flow it deletes the flow, matching + // Escape. const newName = sanitizeLabelInput(plainSerialize(defined(latest.current.editingName))); if (newName === '') { handleEditingNameDone(true); return; } - // Resolve the element being named through the NON-throwing lookup, mirroring - // the render-time guard (see the editingElement resolution). A failed - // flow-attach can leave the selection referencing a flow that was never - // committed to the view (issue #820); dereferencing it with the throwing - // getElementByUid wedged the editor in a repeated-exception loop. When the - // selection is not a single resolvable element there is nothing to commit -- - // tear the editor down cleanly instead of crashing. - const selection = latest.current.props.selection; - const uid = selection.size === 1 ? only(selection) : undefined; - const element = uid !== undefined ? tryGetElementByUid(uid) : undefined; - if (uid === undefined || element === undefined) { - clearPointerState(); + // The element resolves through the non-throwing lookup: a refused or + // rolled-back create leaves the editor naming an element the view does not + // hold (issue #820), and there is nothing to commit then. + const element = edit.draft ?? tryGetElementByUid(edit.uid); + if (element === undefined || !isNamedViewElement(element)) { + endNameEdit(); return; } - const oldName = displayName(defined((element as NamedViewElement).name)); - if (uid === inCreationUid) { - // Names persist line breaks as literal backslash-n (see displayName); - // the rename path encodes in rename-ops.ts (buildVariableRenameOps), - // the create path here. - latest.current.props.onCreateVariable({ ...element, name: encodeNameNewlines(newName) } as ViewElement); - } else { - latest.current.props.onRenameVariable(oldName, newName); + // Names persist line breaks as literal backslash-n (see displayName); the + // rename path encodes in rename-ops.ts (relabelVariable), the create path + // here. A refused name keeps the editor open with the host's message, so + // the user can pick another name without losing the element. + const refusal = + edit.draft !== undefined + ? latest.current.props.onCreateVariable({ ...element, name: encodeNameNewlines(newName) } as ViewElement) + : latest.current.props.onRenameVariable(displayName(element.name), newName); + if (typeof refusal === 'string') { + setNameError(refusal); + return; } + endNameEdit(); + }; - clearPointerState(); + const moduleDoubleClickImpl = (element: ModuleViewElement): void => { + if (classifyPress(pressInput({ kind: 'moduleDoubleClick', uid: element.uid }, NO_POINTER, 1)).kind !== 'drill') { + return; + } + const variable = latest.current.props.model.variables.get(element.ident); + if (variable?.type !== 'module' || !variable.modelName) { + return; + } + latest.current.props.onDrillIntoModule(element.ident, variable.modelName); }; + // The element components are memo'd, so the callbacks handed to them keep one + // identity for the Canvas's life and dispatch to this render's implementation. + // Otherwise every drag frame would re-render every element on the canvas. + const impls = { setSelectionImpl, labelDragImpl, editConnectorImpl, moduleDoubleClickImpl }; + const handlers = React.useRef(impls); + handlers.current = impls; + const handleSetSelection = React.useCallback( + ( + element: ViewElement, + e: React.PointerEvent, + isText?: boolean, + isArrowhead?: boolean, + isSource?: boolean, + ): void => handlers.current.setSelectionImpl(element, e, isText, isArrowhead, isSource), + [], + ); + const handleLabelDrag = React.useCallback( + (uid: number, e: React.PointerEvent): void => handlers.current.labelDragImpl(uid, e), + [], + ); + const handleEditConnector = React.useCallback( + (element: ViewElement, e: React.PointerEvent, isArrowhead: boolean): void => + handlers.current.editConnectorImpl(element, e, isArrowhead), + [], + ); + const handleModuleDoubleClick = React.useCallback( + (element: ModuleViewElement): void => handlers.current.moduleDoubleClickImpl(element), + [], + ); + // ---- Element-rendering helpers (read r.derived; never mutate caches) ----- + // Drawn as selected: the derived selection, a creation tool's draft while it + // is dragged, and the element whose name is being edited. + const isSelected = (uid: UID): boolean => + r.derived.selection.has(uid) || r.derived.plan?.draft?.uid === uid || nameEdit?.uid === uid; + + // The drop target a live gesture's pointer is over: green when valid, red when + // not, undefined for every other element. + const targetState = (uid: UID): boolean | undefined => { + const target = r.derived.plan?.target; + return target !== undefined && target.uid === uid ? target.valid : undefined; + }; + const alias = (element: AliasViewElement): React.ReactElement => { const aliasOf = r.elements.get(element.aliasOfUid) as NamedViewElement | undefined; - let series; - let validTarget: boolean | undefined; - if (aliasOf) { - series = props.model.variables.get(defined(aliasOf.ident))?.data; - validTarget = isValidTarget(aliasOf); - } - const selected = isSelected(element); const aliasProps: AliasProps = { - isSelected: selected, - isValidTarget: validTarget, - series, + isSelected: isSelected(element.uid), + isValidTarget: aliasOf ? targetState(aliasOf.uid) : undefined, + series: aliasOf ? props.model.variables.get(defined(aliasOf.ident))?.data : undefined, onSelection: handleSetSelection, onLabelDrag: handleLabelDrag, element, @@ -2236,274 +1478,162 @@ export const Canvas = React.memo(function Canvas(props: CanvasProps): React.Reac }; const cloud = (element: CloudViewElement): React.ReactElement | undefined => { - const selected = isSelected(element); - - // TODO: fix this -- we apparently can get in the state where a flow doesn't exist but we haven't deleted the cloud - let flow: FlowViewElement; - try { - flow = getElementByUid(defined(element.flowUid)) as FlowViewElement; - } catch { - return; - } - - // When dragging a cloud to attach to a stock, we need to visually hide it - // but keep it in the DOM to maintain pointer capture. - let isHidden = false; - if (isSelected(flow)) { - try { - if (isDraggingArrowhead(interaction) && isCloudOnSinkSide(element, flow)) { - isHidden = true; - } else if (isDraggingSource(interaction) && isCloudOnSourceSide(element, flow)) { - isHidden = true; - } - } catch (e) { - console.error('Invalid flow state when checking cloud position:', e); - } + // A cloud whose flow is not drawn is corrupt or transient data: skip it. + if (tryGetElementByUid(element.flowUid) === undefined) { + return undefined; } - const cloudProps: CloudProps = { element, - isSelected: selected, - isHidden, + isSelected: isSelected(element.uid), onSelection: handleSetSelection, }; - return ; }; - const aux = (element: AuxViewElement): React.ReactElement => { + const aux = (element: AuxViewElement, editing: boolean): React.ReactElement => { const variable = props.model.variables.get(element.ident); - const hasWarning = variable ? variableHasError(variable) : false; - const selected = isSelected(element); - const series = variable?.data; + const selected = isSelected(element.uid); const auxProps: AuxProps = { element, - series, + series: variable?.data, isSelected: selected, - isEditingName: selected && isShowingNameEditor(interaction), - isValidTarget: isValidTarget(element), + isEditingName: selected && editing && nameEdit?.uid === element.uid, + isValidTarget: targetState(element.uid), onSelection: handleSetSelection, onLabelDrag: handleLabelDrag, - hasWarning, + hasWarning: variable ? variableHasError(variable) : false, }; - return ; }; - const stock = (element: StockViewElement): React.ReactElement => { + const stock = (element: StockViewElement, editing: boolean): React.ReactElement => { const variable = props.model.variables.get(element.ident); - const hasWarning = variable ? variableHasError(variable) : false; - const selected = isSelected(element); - const series = variable?.data; + const selected = isSelected(element.uid); const stockProps: StockProps = { element, - series, + series: variable?.data, isSelected: selected, - isEditingName: selected && isShowingNameEditor(interaction), - isValidTarget: isValidTarget(element), + isEditingName: selected && editing && nameEdit?.uid === element.uid, + isValidTarget: targetState(element.uid), onSelection: handleSetSelection, onLabelDrag: handleLabelDrag, - hasWarning, + hasWarning: variable ? variableHasError(variable) : false, }; - return ; }; - const module = (element: ModuleViewElement): React.ReactElement => { + const module = (element: ModuleViewElement, editing: boolean): React.ReactElement => { const variable = props.model.variables.get(element.ident); const hasEngineError = variable ? variableHasError(variable) : false; - // AC1.6: suppress warning when no module in the model has a model reference - // yet (new model scenario where user is rapidly sketching structure). - const hasWarning = hasEngineError && r.derived.hasAnyModuleReference; - const selected = isSelected(element); + const selected = isSelected(element.uid); const moduleProps: ModuleProps = { element, isSelected: selected, - isEditingName: selected && isShowingNameEditor(interaction), - isValidTarget: isValidTarget(element), + isEditingName: selected && editing && nameEdit?.uid === element.uid, + isValidTarget: targetState(element.uid), onSelection: handleSetSelection, onLabelDrag: handleLabelDrag, onDoubleClick: handleModuleDoubleClick, - hasWarning, + // AC1.6: suppress warning when no module in the model has a model reference + // yet (new model scenario where user is rapidly sketching structure). + hasWarning: hasEngineError && r.derived.hasAnyModuleReference, }; - return ; }; const group = (element: GroupViewElement): React.ReactElement => { - const selected = isSelected(element); - const groupProps: GroupProps = { - element, - isSelected: selected, - }; - + const groupProps: GroupProps = { element, isSelected: isSelected(element.uid) }; return ; }; const connector = (element: LinkViewElement): React.ReactElement | undefined => { - const draggingArrowhead = isDraggingArrowhead(interaction); - const selected = props.selection.has(element.uid); - - // Get the updated element from selectionUpdates if available (arc was already adjusted - // by applyGroupMovement for group selection cases) - const updatedElement = r.derived.selectionUpdates.get(element.uid); - if (updatedElement !== undefined && updatedElement.type === 'link') { - element = updatedElement; - } - - // A dangling from/to reference (uid not in the view) is corrupt data -- see - // flow() above. Skip rendering the broken link rather than throwing out of - // render (#812, #817). - const from = r.derived.selectionUpdates.get(element.fromUid) || tryGetElementByUid(element.fromUid); - let to = r.derived.selectionUpdates.get(element.toUid) || tryGetElementByUid(element.toUid); + // A dangling from/to reference (uid not in the view) is corrupt data: skip + // the broken link rather than throwing out of render (#812, #817). A link a + // gesture is dragging points at the plan's stand-in target at the pointer. + const from = tryGetElementByUid(element.fromUid); + const to = tryGetElementByUid(element.toUid); if (!from || !to) { - return; - } - let isSticky = false; - - // Dragging this link's arrowhead — covers both new-link creation and - // reattaching an existing link. Unified: straight line when not over - // a target, dynamic arc when snapped to a valid target. The arc itself is - // computed once in deriveRenderState (derived.draggedLinkArc); we only - // resolve the visual `to` endpoint here. Reading the derived arc (instead - // of recomputing-and-caching it during render) keeps render free of - // mid-render cache mutation while preserving the guarantee that the rendered - // arc equals the value persisted on pointer-up. - const isDraggingLink = draggingArrowhead && selected; - if (isDraggingLink && r.selectionCenterOffset) { - const validTarget = findLinkDragTarget(); - if (validTarget) { - isSticky = true; - to = validTarget; - } else { - const off = r.selectionCenterOffset; - const delta = moveDelta ?? { x: 0, y: 0 }; - const canvasOffset = getCanvasOffset(); - to = { - ...(to as AuxViewElement), - x: off.x - delta.x - canvasOffset.x, - y: off.y - delta.y - canvasOffset.y, - isZeroRadius: true, - }; - } - - const isTouch = getDragPointerType(interaction) === 'touch'; - if (isSticky && !isTouch) { - element = { ...element, arc: r.derived.draggedLinkArc }; - } else { - element = { ...element, arc: undefined }; - } + return undefined; } - const connectorProps: ConnectorProps = { element, from, to, - isSelected: selected, + isSelected: isSelected(element.uid), isDashed: to.type === 'stock', onSelection: handleEditConnector, }; - // When not dragging: pass arcPoint for existing arc-adjustment interactions - // (e.g. clicking the arc mid-line to curve it). During link dragging the arc - // is already computed on the element, so arcPoint would interfere. - if (selected && !isSticky && !isDraggingLink) { - connectorProps.arcPoint = getArcPoint(); - } return ; }; - const flow = (element: FlowViewElement): React.ReactElement | undefined => { + const flow = (element: FlowViewElement, editing: boolean): React.ReactElement | undefined => { const variable = props.model.variables.get(element.ident); - const hasWarning = variable ? variableHasError(variable) : false; - const draggingArrowhead = isDraggingArrowhead(interaction); - const selected = isSelected(element); - const series = variable?.data; + const selected = isSelected(element.uid); if (element.points.length < 2) { - return; - } - - const sourceId = first(element.points).attachedToUid; - if (!sourceId) { - return; + return undefined; } // A dangling endpoint reference (source/sink uid not in the view) is corrupt // data -- transient during an undo rebuild (#817) or persisted (#812). Skip // rendering the broken flow rather than throwing out of render and taking the // whole editor down via the ErrorBoundary. - const source = tryGetElementByUid(sourceId); + const sourceId = first(element.points).attachedToUid; + const source = sourceId === undefined ? undefined : tryGetElementByUid(sourceId); if (!source || (source.type !== 'stock' && source.type !== 'cloud')) { - return; + return undefined; } - const sinkId = last(element.points).attachedToUid; - if (!sinkId) { - return; - } - const sink = tryGetElementByUid(sinkId); + const sink = sinkId === undefined ? undefined : tryGetElementByUid(sinkId); if (!sink || (sink.type !== 'stock' && sink.type !== 'cloud')) { - return; + return undefined; } + // A drag draws the flow exactly as it will be committed -- its sink cloud at + // the endpoint -- so nothing is drawn differently while moving (E2). return ( ); }; - const buildLayers = (displayElements: readonly ViewElement[]): React.ReactElement[][] => { - const selectionUpdates = r.derived.selectionUpdates; - - // create different layers for each of the display types so that views compose together nicely + // One layer per z-order so the element kinds compose: groups behind + // everything, then links, flows, stocks/clouds/modules, and auxes/aliases. + const buildLayers = (displayElements: readonly ViewElement[], editing: boolean): React.ReactElement[][] => { const zLayers = new Array(ZMax) as React.ReactElement[][]; for (let i = 0; i < ZMax; i++) { zLayers[i] = []; } - for (let element of displayElements) { - if (selectionUpdates.has(element.uid)) { - element = getOrThrow(selectionUpdates, element.uid); + for (const element of displayElements) { + // A link preview's stand-in target at the pointer is never drawn. + if (element.uid === fauxTargetUid) { + continue; } - - // const ZOrder = Map<'flow' | 'module' | 'stock' | 'aux' | 'link' | 'style' | 'reference' | 'cloud' | 'alias', number>([ - // ['style', 0], - // ['module', 1], - // ['link', 2], - // ['flow', 3], - // ['cloud', 4], - // ['stock', 4], - // ['aux', 5], - // ['reference', 5], - // ['alias', 5], - // ]); - let zOrder = 0; let component: React.ReactElement | undefined; if (element.type === 'aux') { - component = aux(element); + component = aux(element, editing); zOrder = 5; } else if (element.type === 'link') { component = connector(element); zOrder = 2; } else if (element.type === 'stock') { - component = stock(element); + component = stock(element, editing); zOrder = 4; } else if (element.type === 'flow') { - component = flow(element); + component = flow(element, editing); zOrder = 3; } else if (element.type === 'cloud') { component = cloud(element); @@ -2512,11 +1642,11 @@ export const Canvas = React.memo(function Canvas(props: CanvasProps): React.Reac component = alias(element); zOrder = 5; } else if (element.type === 'module') { - component = module(element); + component = module(element, editing); zOrder = 4; } else if (element.type === 'group') { component = group(element); - zOrder = 0; // Groups render behind everything else + zOrder = 0; } if (!component) { @@ -2555,16 +1685,13 @@ export const Canvas = React.memo(function Canvas(props: CanvasProps): React.Reac // not enough: a continued pointer move would recreate it from the now // stale press-time anchor (panBaseOffset) / pinch reference and the // pointer-up could then commit that abandoned gesture back over the - // external view. Resetting the interaction to idle and dropping the - // pointer anchors makes handleMovingCanvas/handlePinchMove no-op and the + // external view. Dropping the pan gesture, the pinch and the pointer + // anchors makes handleMovingCanvas/handlePinchMove no-op and the // release a clean no-commit. (Non-viewport gestures don't touch // liveViewport, so they're left alone.) - const mode = latest.current.interaction.mode; - if (mode === 'panning' || mode === 'pinching') { - setInteraction(idleState); - r.mouseDownPoint = undefined; - r.panBaseOffset = undefined; - r.pointerId = undefined; + if (r.gesture?.gesture.kind === 'pan' || r.pinch !== undefined) { + endGesture(); + r.pinch = undefined; r.activePointers.clear(); } } @@ -2584,15 +1711,10 @@ export const Canvas = React.memo(function Canvas(props: CanvasProps): React.Reac // Runs once (empty deps); reads the latest props/state through `latest`. // Cleanup is symmetric so a StrictMode mount/unmount/mount cycle is safe. React.useEffect(() => { - const derived = deriveRenderState(); - - // Compute initial diagram bounds via the explicit pure pass (no longer a - // side effect of rendering each element). - const elementBounds = computeElementBounds( - derived.displayElements, - derived.selectionUpdates, - derived.elementsByUid, - ); + const derived = deriveRenderState(r.gesture, r.nameEdit); + + // Compute initial diagram bounds via the explicit pure pass. + const elementBounds = computeElementBounds(derived.displayElements, derived.elementsByUid); let computedInitialBounds: ViewRect | undefined; const bounds = calcViewBox(elementBounds); @@ -2630,6 +1752,15 @@ export const Canvas = React.memo(function Canvas(props: CanvasProps): React.Reac svg.addEventListener('gestureend', handleGestureEnd, { passive: false }); } + // Escape abandons a live gesture: the preview returns to the published view + // and the release, when it comes, finds nothing to commit. + const handleEscape = (e: KeyboardEvent): void => { + if (e.key === 'Escape' && r.gesture !== undefined) { + cancelGesture(); + } + }; + window.addEventListener('keydown', handleEscape); + const svgWidth = svgElement.clientWidth; const svgHeight = svgElement.clientHeight; @@ -2712,6 +1843,7 @@ export const Canvas = React.memo(function Canvas(props: CanvasProps): React.Reac r.svgObserver.disconnect(); r.svgObserver = undefined; } + window.removeEventListener('keydown', handleEscape); const teardownSvg = svgRef.current?.querySelector('svg'); if (teardownSvg) { teardownSvg.removeEventListener('wheel', handleNativeWheel); @@ -2729,10 +1861,9 @@ export const Canvas = React.memo(function Canvas(props: CanvasProps): React.Reac // Clear velocity tracking and pointer data r.velocityTracker.positions = []; r.activePointers.clear(); - // Clear single-pointer gesture state - r.pointerId = undefined; + // Clear the pan anchor and any pinch reference r.mouseDownPoint = undefined; - r.selectionCenterOffset = undefined; + r.pinch = undefined; }; // Intentionally empty deps: this effect mirrors componentDidMount/Unmount. // All props/state it reads go through `latest`, and the native listeners / @@ -2775,9 +1906,7 @@ export const Canvas = React.memo(function Canvas(props: CanvasProps): React.Reac // Bounds come from the derivation the just-committed render produced (same // pure pass the mount effect uses), so this reflects what is actually drawn. const derived = r.derived; - const bounds = calcViewBox( - computeElementBounds(derived.displayElements, derived.selectionUpdates, derived.elementsByUid), - ); + const bounds = calcViewBox(computeElementBounds(derived.displayElements, derived.elementsByUid)); if (!bounds) { // Empty model: nothing to center against. return; @@ -2803,14 +1932,39 @@ export const Canvas = React.memo(function Canvas(props: CanvasProps): React.Reac // effect a no-op after its single run, so extra runs are harmless. }, [svgSize, props.view, liveViewport]); + // ---- E5: a republish that invalidates the live gesture drops it --------- + // Planning already ignores an invalid gesture (deriveRenderState renders the + // published view), and a release re-checks; dropping it here also keeps a + // later move from planning on the replaced view. + React.useEffect(() => { + const g = r.gesture; + if (g !== undefined && !gestureIsValid(g, props)) { + endGesture(); + } + }, [props.view, props.token]); + + // ---- A name editor whose element is gone closes quietly ----------------- + // A refused or rolled-back flow create leaves the editor naming a uid the view + // no longer holds. It closes without settling a selection, so the overlay does + // not linger inert and a later tool change has nothing to commit. A draft (an + // element the view never held) is exempt. + React.useEffect(() => { + const edit = r.nameEdit; + if (edit !== undefined && edit.draft === undefined && !props.view.elements.some((el) => el.uid === edit.uid)) { + setNameEdit(undefined); + setNameError(undefined); + } + }, [props.view, nameEdit]); + // ---- Render ------------------------------------------------------------- const { selectedTool, embedded } = props; - let isEditingNameNow = isShowingNameEditor(interaction); + let isEditingNameNow = nameEdit !== undefined; if (isEditingNameNow && selectedTool !== r.prevSelectedTool) { - // The deferred editing-done fires after this render commits; route it - // through `latest` so it observes the freshest interaction/selection state. + // Changing the tool while editing commits the name. The deferred done fires + // after this render commits and reads the refs, so it observes the latest + // name edit. setTimeout(() => { handleEditingNameDone(false); }); @@ -2818,65 +1972,55 @@ export const Canvas = React.memo(function Canvas(props: CanvasProps): React.Reac } r.prevSelectedTool = selectedTool; - // phase 1: the single render derivation. Produces displayElements, the uid - // lookup, selection updates, module-warning flag, and the dragged-link arc. - // This is the only place render mutates the instance caches (r.elements, - // r.cachedVersion, r.derived) -- the same writes the class did to this.*, - // with identical semantics, kept idempotent so a StrictMode double-render is - // safe (the version cache short-circuits the second pass). - const derived = deriveRenderState(); + // phase 1: the single render derivation (displayed elements, lookup, the live + // plan and the drawn selection). The only place render writes the caches. + const derived = deriveRenderState(gesture, nameEdit); const displayElements = derived.displayElements; // phase 2: create React components and add them to the appropriate layer - const zLayers = buildLayers(displayElements); + const zLayers = buildLayers(displayElements, isEditingNameNow); let overlayClass = styles.overlay; let nameEditor; let dragRect; - if (interaction.mode === 'dragSelecting' && r.mouseDownPoint && dragSelectionPoint) { - const pointA = r.mouseDownPoint; - const pointB = dragSelectionPoint; - const offset = getCanvasOffset(); - - const x = Math.min(pointA.x, pointB.x) - offset.x; - const y = Math.min(pointA.y, pointB.y) - offset.y; - const w = Math.abs(pointA.x - pointB.x); - const h = Math.abs(pointA.y - pointB.y); - - dragRect = ; + if (gesture?.gesture.kind === 'rubberBand' && beyondThreshold(gesture.press, gesture.current, getCanvasZoom())) { + const { press, current } = gesture; + dragRect = ( + + ); } - // Resolve the element being named, if any. The selection can transiently - // reference inCreationUid for one render after a flow creation hands off to - // name-editing: the pointer-up enters editingName and clears the in-creation - // element in the same commit, but props.selection only updates to the real - // flow uid once the host's async attach lands. In that render the editing - // element is unresolvable -- skip the name editor (the next render, after the - // selection commits, shows it for the real element) rather than crashing. - // Same transient-skip rationale as buildSelectionMap. + // The element being named: a draft, or an element of what is drawn. It can be + // unresolvable for a moment -- a drawn flow whose commit the host refused -- + // and the editor is skipped then rather than crashing. const editingElement = - isEditingNameNow && props.selection.size === 1 - ? (tryGetElementByUid(only(props.selection)) as NamedViewElement | undefined) + isEditingNameNow && nameEdit !== undefined + ? ((nameEdit.draft ?? tryGetElementByUid(nameEdit.uid)) as NamedViewElement | undefined) : undefined; if (!editingElement) { overlayClass += ' ' + styles.noPointerEvents; } else { const zoom = getCanvasZoom(); - const editingUid = editingElement.uid; const { rw, rh } = labelRadii(editingElement.type); - const side = editingElement.labelSide; const offset = getCanvasOffset(); nameEditor = ( @@ -2888,7 +2032,7 @@ export const Canvas = React.memo(function Canvas(props: CanvasProps): React.Reac if (embedded) { // For embedded/export mode, always calculate tight bounds from elements. // The stored view.viewBox represents the editor viewport, not diagram bounds. - const bounds = calcViewBox(computeElementBounds(displayElements, derived.selectionUpdates, derived.elementsByUid)); + const bounds = calcViewBox(computeElementBounds(displayElements, derived.elementsByUid)); if (bounds) { const left = Math.floor(bounds.left) - 10; const top = Math.floor(bounds.top) - 10; @@ -2911,10 +2055,6 @@ export const Canvas = React.memo(function Canvas(props: CanvasProps): React.Reac
); - // n.b. r.elements (and r.derived) are intentionally NOT cleared here: - // event handlers read them after render returns (getElementByUid and the - // pointer callbacks resolve connector ends / persist the dragged-link arc). - // The label halo: each label's glyphs dilated and blurred into a soft // backing plate at 85% opacity, so a label stays legible where it crosses a // connector or a flow. Two variants of one filter, differing only in where @@ -2966,7 +2106,7 @@ export const Canvas = React.memo(function Canvas(props: CanvasProps): React.Reac onPointerDown={handlePointerDown} onPointerMove={handlePointerMove} onPointerCancel={handlePointerCancel} - onPointerUp={handlePointerCancel} + onPointerUp={handlePointerUp} > {labelHaloFilter} diff --git a/src/diagram/drawing/EditableLabel.module.css b/src/diagram/drawing/EditableLabel.module.css index aef7a6eac..9145fbcb2 100644 --- a/src/diagram/drawing/EditableLabel.module.css +++ b/src/diagram/drawing/EditableLabel.module.css @@ -1,7 +1,17 @@ .editableLabel { - font-family: "Roboto", "Open Sans", "Arial", sans-serif; + font-family: 'Roboto', 'Open Sans', 'Arial', sans-serif; font-weight: 300; text-anchor: middle; white-space: nowrap; vertical-align: middle; } + +.nameError { + color: var(--color-error); + font-size: 12px; + font-weight: 400; + line-height: 16px; + white-space: normal; + text-align: left; + padding: 2px 4px; +} diff --git a/src/diagram/drawing/EditableLabel.tsx b/src/diagram/drawing/EditableLabel.tsx index d0d71eaef..4dbb4b49a 100644 --- a/src/diagram/drawing/EditableLabel.tsx +++ b/src/diagram/drawing/EditableLabel.tsx @@ -18,6 +18,9 @@ interface EditingLabelProps extends CommonLabelProps { onChange: (value: Descendant[]) => void; onDone: (isCancel: boolean) => void; zoom: number; + // Why the last commit was refused (e.g. the name is taken). Rendered below + // the text as an alert; the editor stays open so the user can fix the name. + error?: string; } export const EditableLabel = React.memo(function EditableLabel(props: EditingLabelProps): React.ReactElement { @@ -115,9 +118,10 @@ export const EditableLabel = React.memo(function EditableLabel(props: EditingLab left = x; textY = y - (fontSize + (lines.length - 1) * 14 * zoom) / 2 - 3; break; - default: - // FIXME - console.log('unknown label case ' + side); + case 'center': + // Over the element, where Label draws a centered label. + left = textX - editorWidth / 2; + break; } textY = Math.round(textY); @@ -143,8 +147,18 @@ export const EditableLabel = React.memo(function EditableLabel(props: EditingLab onPointerUp={handlePointerUpDown} > - + + {props.error !== undefined && ( +
+ {props.error} +
+ )}
); }); diff --git a/src/diagram/drawing/Flow.tsx b/src/diagram/drawing/Flow.tsx index 66c711f70..fced0b119 100644 --- a/src/diagram/drawing/Flow.tsx +++ b/src/diagram/drawing/Flow.tsx @@ -15,1722 +15,17 @@ import { variableIsArrayed, } from '@simlin/core/datamodel'; import { arrayWith, defined, Series } from '@simlin/core/common'; -import { at, first, last } from '@simlin/core/collections'; +import { at } from '@simlin/core/collections'; import { Arrowhead } from './Arrowhead'; -import { displayName, Point as IPoint, Rect } from './common'; +import { displayName, Rect } from './common'; import { AuxRadius, CloudRadius, FlowArrowheadRadius } from './default'; import { Label, labelBounds, LabelProps } from './Label'; import { Sparkline } from './Sparkline'; -import { StockHeight, StockWidth } from './Stock'; import { jsFormatNumber as ff } from '../render-common'; import styles from './Flow.module.css'; -type Side = 'left' | 'right' | 'top' | 'bottom'; - -/** - * Classify a segment's working orientation for ROUTING decisions by its - * dominant axis. Exact coordinate equality stays the definition of - * "orthogonal" for rendering and normalization, but routing must also handle - * slightly-diagonal data -- pre-fix creation bugs persisted flows with a few - * pixels of cross-axis drift, and imported models can carry the same. Under - * exact-equality checks such a visually-horizontal flow classified as - * VERTICAL: stock drags routed a wrong-way L (valve and label stacked on the - * source cloud) and endpoint drags constrained/updated the wrong coordinate. - * Ties (a perfect 45-degree diagonal, only possible cloud-to-cloud) count as - * horizontal. - */ -function isDominantlyHorizontal(p1: IPoint, p2: IPoint): boolean { - return Math.abs(p2.y - p1.y) <= Math.abs(p2.x - p1.x); -} - -/** - * Returns a point on the edge of a stock. - * - * @param stockCx - X center of the stock - * @param stockCy - Y center of the stock - * @param side - Which side of the stock (left, right, top, bottom) - * @param offsetFraction - Position along the edge, 0.5 = center (default), 0 = one end, 1 = other end. - * For top/bottom: 0 = left, 1 = right. - * For left/right: 0 = top, 1 = bottom. - */ -function getStockEdgePoint(stockCx: number, stockCy: number, side: Side, offsetFraction: number = 0.5): IPoint { - switch (side) { - case 'left': - // Offset along Y axis: 0 = top, 1 = bottom - return { - x: stockCx - StockWidth / 2, - y: stockCy - StockHeight / 2 + StockHeight * offsetFraction, - }; - case 'right': - // Offset along Y axis: 0 = top, 1 = bottom - return { - x: stockCx + StockWidth / 2, - y: stockCy - StockHeight / 2 + StockHeight * offsetFraction, - }; - case 'top': - // Offset along X axis: 0 = left, 1 = right - return { - x: stockCx - StockWidth / 2 + StockWidth * offsetFraction, - y: stockCy - StockHeight / 2, - }; - case 'bottom': - // Offset along X axis: 0 = left, 1 = right - return { - x: stockCx - StockWidth / 2 + StockWidth * offsetFraction, - y: stockCy + StockHeight / 2, - }; - } -} - -/** - * Which stock edge a flow endpoint currently occupies relative to a given stock - * center, or undefined if the point is not on an edge (within tolerance). - * - * Used to preserve the attachment edge across a stock move when the - * stock-adjacent segment runs PARALLEL to that edge -- a Z-shaped flow's riser - * hugs a left/right edge with a vertical segment (or a top/bottom edge with a - * horizontal one). Classifying by segment orientation alone would misread that - * as the perpendicular edge and jump the endpoint on a mere nudge (#819 - * follow-up). Endpoints are pinned exactly onto edges, so a small tolerance is - * safe and avoids float noise. - */ -function classifyStockEdge(point: IPoint, center: IPoint): Side | undefined { - const EPS = 0.5; - const dx = point.x - center.x; - const dy = point.y - center.y; - const onLeftRightEdge = Math.abs(Math.abs(dx) - StockWidth / 2) <= EPS && Math.abs(dy) <= StockHeight / 2 + EPS; - const onTopBottomEdge = Math.abs(Math.abs(dy) - StockHeight / 2) <= EPS && Math.abs(dx) <= StockWidth / 2 + EPS; - if (onLeftRightEdge) { - return dx >= 0 ? 'right' : 'left'; - } - if (onTopBottomEdge) { - return dy >= 0 ? 'bottom' : 'top'; - } - return undefined; -} - -/** - * Determine which stock edge a multi-segment (3+ point) flow's stock endpoint - * attaches to after a stock move, plus whether the stock-adjacent segment is - * horizontal. Shared by computeFlowRoute (which re-pins the endpoint) and - * getFlowAttachmentInfo (which groups flows for spread spacing) so the two - * ALWAYS agree on a Z-shaped flow's occupied edge -- otherwise a Z and a - * straight flow on the same stock edge land in different spread groups and both - * center at 0.5, overlapping their endpoints. - * - * Normally the segment orientation implies the edge (a horizontal segment - * leaves a left/right edge). A Z-riser runs PARALLEL to its edge, so when we - * know the prior stock center and the endpoint sits on an edge whose axis is - * parallel to the segment, preserve that occupied edge instead. - */ -function resolveStockAdjacentSide( - currentStockPoint: IPoint, - adjacentPoint: IPoint, - newStockCx: number, - newStockCy: number, - prevStockCenter: IPoint | undefined, -): { side: Side; isHorizontalSegment: boolean } { - const isHorizontalSegment = isDominantlyHorizontal(currentStockPoint, adjacentPoint); - - if (prevStockCenter) { - const occupiedEdge = classifyStockEdge(currentStockPoint, prevStockCenter); - if (occupiedEdge) { - const edgeIsLeftRight = occupiedEdge === 'left' || occupiedEdge === 'right'; - if (edgeIsLeftRight !== isHorizontalSegment) { - return { side: occupiedEdge, isHorizontalSegment }; - } - } - } - - const side: Side = isHorizontalSegment - ? adjacentPoint.x > newStockCx - ? 'right' - : 'left' - : adjacentPoint.y > newStockCy - ? 'bottom' - : 'top'; - return { side, isHorizontalSegment }; -} - -interface FlowAttachmentInfo { - flow: FlowViewElement; - side: Side; - // The anchor point (cloud/other stock at the opposite end from this stock) - // Used for ordering flows on the same side - anchor position determines - // where the flow is "going to", which is stable across L-shape creation - anchor: IPoint; - // Whether the stock is at the start (first point) or end (last point) of the flow - stockIsFirst: boolean; - // Whether the flow will be straight (vs L-shaped) - straight flows don't need spreading - isStraight: boolean; -} - -/** - * Returns the point adjacent to the stock attachment (the point right next to where - * the flow attaches to the stock). - */ -function getStockAdjacentPoint(points: readonly Point[], stockIsFirst: boolean): Point { - const firstPoint = first(points); - const lastPoint = last(points); - if (stockIsFirst) { - return points.length >= 2 ? at(points, 1) : lastPoint; - } else { - return points.length >= 2 ? at(points, points.length - 2) : firstPoint; - } -} - -/** - * Returns the point adjacent to the anchor (used to determine the original flow direction). - */ -function getAnchorAdjacentPoint(points: readonly Point[], stockIsFirst: boolean): Point { - const firstPoint = first(points); - const lastPoint = last(points); - if (stockIsFirst) { - // anchor is last, so adjacent to anchor is second-to-last - return points.length >= 2 ? at(points, points.length - 2) : firstPoint; - } else { - // anchor is first, so adjacent to anchor is second - return points.length >= 2 ? at(points, 1) : lastPoint; - } -} - -/** - * Determines which side of the stock a flow will attach to after the stock moves, - * and returns the adjacent point for ordering purposes. - * - * This mirrors the logic in computeFlowRoute but extracts just the side determination. - */ -function getFlowAttachmentInfo( - flow: FlowViewElement, - stockUid: number, - newStockCx: number, - newStockCy: number, - prevStockCenter?: IPoint, -): FlowAttachmentInfo | undefined { - const points = flow.points; - if (points.length < 2) { - return undefined; - } - - const firstPoint = first(points); - const lastPoint = last(points); - - const stockIsFirst = firstPoint.attachedToUid === stockUid; - const stockIsLast = lastPoint.attachedToUid === stockUid; - - if (!stockIsFirst && !stockIsLast) { - return undefined; - } - - const anchor = stockIsFirst ? lastPoint : firstPoint; - const adjacentPoint = getStockAdjacentPoint(points, stockIsFirst); - const anchorAdjacentPoint = getAnchorAdjacentPoint(points, stockIsFirst); - const originalFlowIsHorizontal = isDominantlyHorizontal(anchorAdjacentPoint, anchor); - - let side: Side; - let isStraight = false; - - // For 4+ point flows, use the existing segment orientation (preserving a - // Z-riser's occupied edge -- see resolveStockAdjacentSide -- so this agrees - // with computeFlowRoute and Z flows spread alongside straight ones). - if (points.length >= 4) { - const currentStockPoint = stockIsFirst ? firstPoint : lastPoint; - ({ side } = resolveStockAdjacentSide(currentStockPoint, adjacentPoint, newStockCx, newStockCy, prevStockCenter)); - } else if (canFlowBeStraight(newStockCx, newStockCy, anchor.x, anchor.y, originalFlowIsHorizontal)) { - // Straight flow - these naturally separate based on anchor position - isStraight = true; - if (originalFlowIsHorizontal) { - side = anchor.x > newStockCx ? 'right' : 'left'; - } else { - side = anchor.y > newStockCy ? 'bottom' : 'top'; - } - } else { - // L-shaped flow: attach perpendicular to the original flow direction - if (originalFlowIsHorizontal) { - side = anchor.y < newStockCy ? 'top' : 'bottom'; - } else { - side = anchor.x < newStockCx ? 'left' : 'right'; - } - } - - return { - flow, - side, - anchor: { x: anchor.x, y: anchor.y }, - stockIsFirst, - isStraight, - }; -} - -/** - * Groups flows by their attachment side and computes offset fractions for each flow. - * - * All flows on the same side are included in the spacing calculation to avoid overlap. - * Flows are ordered by their anchor's position: - * - For top/bottom sides: ordered by anchor's X (left to right) - * - For left/right sides: ordered by anchor's Y (top to bottom) - * - * Straight flows get offset 0.5 (their position is determined by anchor, not offset), - * but they're still included in the count so L-shaped flows spread around them. - * - * Using anchor position (not the stock-adjacent corner) ensures stable ordering - * even for pre-existing L-shaped flows where corners may have the same coordinate. - * - * Returns a map from flow UID to its offset fraction (0 to 1). - */ -export function computeFlowOffsets( - flows: readonly FlowViewElement[], - stockUid: number, - newStockCx: number, - newStockCy: number, - prevStockCenter?: IPoint, -): Map { - // Get attachment info for all flows - const attachmentInfos: FlowAttachmentInfo[] = []; - for (const flow of flows) { - const info = getFlowAttachmentInfo(flow, stockUid, newStockCx, newStockCy, prevStockCenter); - if (info) { - attachmentInfos.push(info); - } - } - - // Group ALL flows by side (including straight flows for proper spacing) - const bySide: Map = new Map(); - for (const info of attachmentInfos) { - const existing = bySide.get(info.side) || []; - existing.push(info); - bySide.set(info.side, existing); - } - - // Compute offsets for each flow - const offsets: Map = new Map(); - - for (const [side, infos] of bySide) { - if (infos.length === 1) { - // Single flow on this side - center it (offset = 0.5) - offsets.set(infos[0].flow.uid, 0.5); - } else { - // Multiple flows - sort all by anchor position and spread evenly. - // Including straight flows in the count ensures L-shaped flows spread - // around them, avoiding overlap even when straight flows' anchor positions - // happen to coincide with spread positions. - if (side === 'top' || side === 'bottom') { - infos.sort((a, b) => a.anchor.x - b.anchor.x); - } else { - infos.sort((a, b) => a.anchor.y - b.anchor.y); - } - - // Spread flows evenly: for n flows, positions are at 1/(n+1), 2/(n+1), ..., n/(n+1) - const n = infos.length; - for (let i = 0; i < n; i++) { - const fraction = (i + 1) / (n + 1); - if (infos[i].isStraight) { - // Straight flows must use 0.5 - their position is determined by anchor, - // not by offset. But including them in the count reserves their "slot". - offsets.set(infos[i].flow.uid, 0.5); - } else { - offsets.set(infos[i].flow.uid, fraction); - } - } - } - } - - return offsets; -} - -function canFlowBeStraight( - stockCx: number, - stockCy: number, - anchorX: number, - anchorY: number, - originalFlowIsHorizontal: boolean, -): boolean { - if (originalFlowIsHorizontal) { - return Math.abs(stockCy - anchorY) <= StockHeight / 2; - } else { - return Math.abs(stockCx - anchorX) <= StockWidth / 2; - } -} - -// Exported for testing -export function computeFlowRoute( - flow: FlowViewElement, - stockEl: StockViewElement, - newStockCx: number, - newStockCy: number, - offsetFraction: number = 0.5, - prevStockCenter?: IPoint, -): FlowViewElement { - const points = flow.points; - if (points.length < 2) { - return flow; - } - - const firstPoint = first(points); - const lastPoint = last(points); - - const stockIsFirst = firstPoint.attachedToUid === stockEl.uid; - const stockIsLast = lastPoint.attachedToUid === stockEl.uid; - - if (!stockIsFirst && !stockIsLast) { - return flow; - } - - const anchor = stockIsFirst ? lastPoint : firstPoint; - - // Determine original flow direction by looking at the anchor-side segment. - // This works for both 2-point (straight) and 3+ point (L-shaped) flows. - // For L-shaped flows, the anchor-side segment preserves the original direction. - const anchorAdjacentPoint = getAnchorAdjacentPoint(points, stockIsFirst); - const originalFlowIsHorizontal = isDominantlyHorizontal(anchorAdjacentPoint, anchor); - - // For flows with 4+ points (imported or manually-edited multi-segment flows), - // update both the attached endpoint and the adjacent corner to preserve - // orthogonality. The endpoint stays on the stock edge, and the adjacent - // corner is adjusted to maintain axis alignment. - if (points.length >= 4) { - const adjacentPointIndex = stockIsFirst ? 1 : points.length - 2; - const adjacentPoint = at(points, adjacentPointIndex); - - // Determine the ORIGINAL first segment's orientation from the existing - // geometry (preserved to avoid diagonal segments between corner1 and corner2 - // when the stock moves far perpendicular to the segment) and the attachment - // side, which also preserves a Z-riser's occupied edge to avoid an - // endpoint jump on a nudge (see resolveStockAdjacentSide). - const currentStockPoint = stockIsFirst ? firstPoint : lastPoint; - const { side, isHorizontalSegment } = resolveStockAdjacentSide( - currentStockPoint, - adjacentPoint, - newStockCx, - newStockCy, - prevStockCenter, - ); - - // Keep the endpoint on the stock's actual edge - const stockEdge = getStockEdgePoint(newStockCx, newStockCy, side, offsetFraction); - - const newStockPoint: Point = { - x: stockEdge.x, - y: stockEdge.y, - attachedToUid: stockEl.uid, - }; - - // Adjust the adjacent corner to preserve orthogonality - let newAdjacentPoint: Point; - if (isHorizontalSegment) { - // For horizontal segment, corner's Y must match endpoint's Y - newAdjacentPoint = { - x: adjacentPoint.x, - y: stockEdge.y, - attachedToUid: adjacentPoint.attachedToUid, - }; - } else { - // For vertical segment, corner's X must match endpoint's X - newAdjacentPoint = { - x: stockEdge.x, - y: adjacentPoint.y, - attachedToUid: adjacentPoint.attachedToUid, - }; - } - - let newPoints: readonly Point[]; - if (stockIsFirst) { - newPoints = arrayWith(arrayWith(points, 0, newStockPoint), 1, newAdjacentPoint); - } else { - newPoints = arrayWith(arrayWith(points, points.length - 1, newStockPoint), points.length - 2, newAdjacentPoint); - } - - // Preserve valve's fractional position on the stock-adjacent segment. - // When a stock moves along the flow axis, the segment gets longer or shorter. - // If the valve is on that segment, we should preserve its fractional position - // (similar to how straight flows are handled) rather than just clamping its - // absolute position, which would cause it to slide or jump. - const currentValve: IPoint = { x: flow.x, y: flow.y }; - const oldSegments = getSegments(points); - const valveOldSegment = findClosestSegment(currentValve, oldSegments); - const stockAdjacentSegmentIndex = stockIsFirst ? 0 : oldSegments.length - 1; - const valveIsOnStockAdjacentSegment = valveOldSegment.index === stockAdjacentSegmentIndex; - - // Normalize to remove any colinear or zero-length segments that may have - // been created by adjusting the adjacent corner. - newPoints = normalizeFlowPoints(newPoints); - - const newSegments = getSegments(newPoints); - - let newValve: IPoint; - if (valveIsOnStockAdjacentSegment && newSegments.length > 0) { - // Valve is on the stock-adjacent segment - preserve fractional position. - const newStockAdjacentSegmentIndex = stockIsFirst ? 0 : newSegments.length - 1; - const newStockAdjacentSegment = newSegments[newStockAdjacentSegmentIndex]; - newValve = preserveValveFraction(currentValve, valveOldSegment, newStockAdjacentSegment); - } else { - // Valve is not on the stock-adjacent segment - clamp to closest segment. - const closestSegment = findClosestSegment(currentValve, newSegments); - newValve = clampToSegment(currentValve, closestSegment); - } - - return { - ...flow, - x: newValve.x, - y: newValve.y, - points: newPoints, - }; - } - - if (canFlowBeStraight(newStockCx, newStockCy, anchor.x, anchor.y, originalFlowIsHorizontal)) { - let stockEdge: IPoint; - if (originalFlowIsHorizontal) { - const side: Side = anchor.x > newStockCx ? 'right' : 'left'; - stockEdge = getStockEdgePoint(newStockCx, anchor.y, side, offsetFraction); - } else { - const side: Side = anchor.y > newStockCy ? 'bottom' : 'top'; - stockEdge = getStockEdgePoint(anchor.x, newStockCy, side, offsetFraction); - } - - const newStockPoint: Point = { - x: stockEdge.x, - y: stockEdge.y, - attachedToUid: stockEl.uid, - }; - - let newPoints: readonly Point[]; - if (stockIsFirst) { - newPoints = [newStockPoint, anchor]; - } else { - newPoints = [firstPoint, newStockPoint]; - } - - // Preserve valve's fractional position along the segment. - // This prevents the valve from jumping when the stock moves past its position. - // Find which segment the valve was actually on (for L-shaped flows, the valve - // might be on either segment, so we can't just assume it was on segment 0). - const currentValve: IPoint = { x: flow.x, y: flow.y }; - const oldSegments = getSegments(points); - const newSegments = getSegments(newPoints); - const valveSegment = findClosestSegment(currentValve, oldSegments); - const newValve = preserveValveFraction(currentValve, valveSegment, newSegments[0]); - - return { - ...flow, - x: newValve.x, - y: newValve.y, - points: newPoints, - }; - } - - // For L-shaped flow, attach perpendicular to the original flow direction - let attachmentSide: Side; - if (originalFlowIsHorizontal) { - // Original was horizontal, so the new segment from stock should be vertical - attachmentSide = anchor.y < newStockCy ? 'top' : 'bottom'; - } else { - // Original was vertical, so the new segment from stock should be horizontal - attachmentSide = anchor.x < newStockCx ? 'left' : 'right'; - } - const stockEdge = getStockEdgePoint(newStockCx, newStockCy, attachmentSide, offsetFraction); - - // Corner connects the stock's perpendicular segment to the original flow direction - let corner: IPoint; - if (originalFlowIsHorizontal) { - // Vertical segment from stock, then horizontal to anchor - corner = { x: stockEdge.x, y: anchor.y }; - } else { - // Horizontal segment from stock, then vertical to anchor - corner = { x: anchor.x, y: stockEdge.y }; - } - - const newStockPoint: Point = { - x: stockEdge.x, - y: stockEdge.y, - attachedToUid: stockEl.uid, - }; - const cornerPoint: Point = { - x: corner.x, - y: corner.y, - attachedToUid: undefined, - }; - - let newPoints: readonly Point[]; - if (stockIsFirst) { - newPoints = [newStockPoint, cornerPoint, anchor]; - } else { - newPoints = [firstPoint, cornerPoint, newStockPoint]; - } - - // Preserve valve position by clamping to the closest segment of the new L-shape - const currentValve: IPoint = { x: flow.x, y: flow.y }; - const clampedValve = clampValveToClosestSegment(currentValve, newPoints); - - return { - ...flow, - x: clampedValve.x, - y: clampedValve.y, - points: newPoints, - }; -} - -function adjustFlows( - origStock: StockViewElement | CloudViewElement, - stock: StockViewElement | CloudViewElement, - flows: readonly FlowViewElement[], -): readonly FlowViewElement[] { - return flows.map((flow: FlowViewElement) => { - let horizontal = isHorizontal(flow); - const vertical = isVertical(flow); - const inCreation = horizontal && vertical; - if (!inCreation && !horizontal && !vertical && flow.points.length === 2) { - // Slightly-diagonal legacy data: neither exact test matched, so classify - // by the dominant axis (see isDominantlyHorizontal). Without this, a - // visually-horizontal drifted flow snapped the dragged endpoint's y but - // never updated its x, detaching the flow from its own endpoint. - horizontal = isDominantlyHorizontal(first(flow.points), last(flow.points)); - } - - let otherEnd: IPoint | undefined; - const points = flow.points.map((point, i) => { - // if its not the start or end point, don't change it. - if (!(i === 0 || i === flow.points.length - 1)) { - return point; - } - - if (point.attachedToUid !== stock.uid) { - otherEnd = point; - return point; - } - - let compare: IPoint; - // we're inside a lambda passed to flow.points.map(), so while - // first and last can conceptually return undefined, we know - // that can't actually happen here. - if (i === 0) { - compare = flow.points[flow.points.length - 1] as IPoint; - } else { - compare = flow.points[0] as IPoint; - } - - const d = { - x: stock.x - compare.x, - y: stock.y - compare.y, - }; - - if (inCreation) { - // The flow is degenerate (all points coincide), so its own orientation - // can't pick the axis -- recover it from the larger-magnitude component - // of the source->target delta. A signed `d.x > d.y` here silently left - // leftward/upward creations degenerate: the perpendicular component is - // zeroed upstream, so the signed test collapsed to `d.x > 0` / `0 > d.y`, - // which is wrong for the negative direction on each axis. - horizontal = Math.abs(d.x) > Math.abs(d.y); - } - - const adjust = { - x: StockWidth / 2, - y: StockHeight / 2, - }; - if (stock.type === 'cloud' || stock.isZeroRadius) { - adjust.x = 0; - adjust.y = 0; - } - - if (horizontal && d.x < 0) { - // right - point = { ...point, x: stock.x + adjust.x }; - } else if (horizontal) { - // left - point = { ...point, x: stock.x - adjust.x }; - } else if (!horizontal && d.y < 0) { - // bottom - point = { ...point, y: stock.y + adjust.y }; - } else { - // top - point = { ...point, y: stock.y - adjust.y }; - } - - return point; - }); - - // Degenerate self-loop: both endpoints attach to the same stock, so no - // endpoint was recorded as the "other end" to anchor the valve fraction - // against. Such a flow can't be created in the editor but can arrive via an - // imported model or a programmatic patch (issue #720). Leave it unchanged - // rather than throwing on the canvas render/interaction path. - if (otherEnd === undefined) { - return flow; - } - - // For multi-segment flows (3+ points), use segment-based valve positioning. - // When moving an endpoint (like the arrowhead), the valve should only be - // affected if it's on the segment adjacent to that endpoint. If it's on - // an interior segment, it should stay put. - if (points.length >= 3) { - const currentValve: IPoint = { x: flow.x, y: flow.y }; - const oldSegments = getSegments(flow.points); - const newSegments = getSegments(points); - const valveOldSegment = findClosestSegment(currentValve, oldSegments); - - // Determine which endpoint is being moved (attached to stock) - const stockIsFirst = first(flow.points).attachedToUid === stock.uid; - const movedEndpointSegmentIndex = stockIsFirst ? 0 : oldSegments.length - 1; - - let newValve: IPoint; - if (valveOldSegment.index === movedEndpointSegmentIndex && newSegments.length > 0) { - // Valve is on the segment adjacent to the moved endpoint - preserve - // fractional position on that segment (similar to computeFlowRoute) - const newSegment = newSegments[stockIsFirst ? 0 : newSegments.length - 1]; - newValve = preserveValveFraction(currentValve, valveOldSegment, newSegment); - } else { - // Valve is on an interior segment that wasn't affected by the endpoint - // move - keep it in place - newValve = currentValve; - } - - return { - ...flow, - x: newValve.x, - y: newValve.y, - points, - }; - } - - // Re-place the valve by preserving its FRACTIONAL position along the segment, - // anchored to the fixed `otherEnd`. The earlier form -- base = min(otherEnd, - // moved end) plus the ABSOLUTE scaled offset -- happened to be idempotent only - // when otherEnd was the SMALLER coordinate (a sink cloud on the right). When - // the dragged end was the smaller coordinate (a SOURCE cloud on the left), - // `base` landed on the moved end and the offset-from-otherEnd was applied from - // the wrong reference, reflecting an off-center valve across the segment - // midpoint on the very first drag frame (#832). Anchoring to otherEnd with a - // SIGNED new length keeps the valve between the two ends even when the moved - // end crosses past otherEnd (the flip case the mirror form was chasing), and - // clamping the fraction to [0, 1] keeps it on the segment. This mirrors what - // `preserveValveFraction` already does for multi-segment flows. - // - // The `oldLen === 0` branch preserves the #818 divide-by-zero guard and has - // two sub-cases distinguished by the NEW length: - // - newLen === 0: the axis is perpendicular to the flow (x for a vertical - // flow, y for a horizontal one) -- origStock and otherEnd share a - // coordinate and still do, so there is no length to take a fraction of and - // the valve stays on otherEnd's coordinate (frac 0). - // - newLen !== 0: the OLD flow was DEGENERATE (a creation flow whose points - // all coincided at the press point) and this end has now expanded out; - // center the valve on the freshly-created segment (frac 0.5), matching the - // prior formula's degenerate fallback so creation keeps its mid-segment - // valve. - const preserveAxis = (valve: number, other: number, origMoved: number, newMoved: number): number => { - const oldLen = origMoved - other; - const newLen = newMoved - other; - const frac = oldLen === 0 ? (newLen === 0 ? 0 : 0.5) : Math.max(0, Math.min(1, (valve - other) / oldLen)); - return other + frac * newLen; - }; - flow = { - ...flow, - x: preserveAxis(flow.x, otherEnd.x, origStock.x, stock.x), - y: preserveAxis(flow.y, otherEnd.y, origStock.y, stock.y), - }; - - return { ...flow, points }; - }); -} - -/** - * Pin a flow's stock-attached SOURCE endpoint onto the stock edge facing the - * sink, preserving the flow's orientation (the perpendicular coordinate is - * untouched, so a straight flow stays straight) and re-clamping the valve to - * the resulting path. Used at flow-creation commit: the flow tool stages the - * source point at the press-time stock CENTER and only routes the sink, so - * without this the persisted source endpoint sits hidden under the stock body - * (violating the edge-attachment rule) until the next stock drag re-pins it. - */ -export function pinSourceToStockEdge(flow: FlowViewElement, stock: StockViewElement): FlowViewElement { - if (flow.points.length < 2) { - return flow; - } - const srcPt = first(flow.points); - const sinkPt = last(flow.points); - if (srcPt.attachedToUid !== stock.uid) { - return flow; - } - - const horizontal = isDominantlyHorizontal(srcPt, sinkPt); - const side: Side = horizontal ? (sinkPt.x >= stock.x ? 'right' : 'left') : sinkPt.y >= stock.y ? 'bottom' : 'top'; - const edge = getStockEdgePoint(stock.x, stock.y, side); - const newSrc: Point = horizontal ? { ...srcPt, x: edge.x } : { ...srcPt, y: edge.y }; - const points: readonly Point[] = [newSrc, ...flow.points.slice(1)]; - - const currentValve: IPoint = { x: flow.x, y: flow.y }; - const segments = getSegments(points); - const valve = clampToSegment(currentValve, findClosestSegment(currentValve, segments)); - return { ...flow, x: valve.x, y: valve.y, points }; -} - -export function UpdateStockAndFlows( - stockEl: StockViewElement, - flows: readonly FlowViewElement[], - moveDelta: IPoint, -): [StockViewElement, readonly FlowViewElement[]] { - const newStockCx = stockEl.x - moveDelta.x; - const newStockCy = stockEl.y - moveDelta.y; - - // The stock's center before this move, used to classify which edge each flow - // endpoint currently occupies so computeFlowRoute can preserve it (see the - // Z-riser edge-preservation there). - const prevStockCenter: IPoint = { x: stockEl.x, y: stockEl.y }; - - stockEl = { - ...stockEl, - x: newStockCx, - y: newStockCy, - }; - - // Compute offset fractions to spread multiple flows on the same side - const offsets = computeFlowOffsets(flows, stockEl.uid, newStockCx, newStockCy, prevStockCenter); - - flows = flows.map((flow) => { - const offset = offsets.get(flow.uid) ?? 0.5; - return computeFlowRoute(flow, stockEl, newStockCx, newStockCy, offset, prevStockCenter); - }); - - return [stockEl, flows]; -} - -function allEqual(extractor: (pt: Point) => T): (flow: FlowViewElement) => boolean { - return (flow: FlowViewElement) => { - if (flow.points.length === 0) { - return false; - } - - const firstVal = extractor(at(flow.points, 0)); - return flow.points.every((pt) => extractor(pt) === firstVal); - }; -} - -const isHorizontal = allEqual((pt) => pt.y); -const isVertical = allEqual((pt) => pt.x); - -export function UpdateCloudAndFlow( - cloud: StockViewElement | CloudViewElement, - flow: FlowViewElement, - moveDelta: IPoint, -): [StockViewElement | CloudViewElement, FlowViewElement] { - let points = flow.points; - const firstPoint = first(points); - const lastPoint = last(points); - - // Determine if cloud is at first or last position - const cloudIsFirst = firstPoint.attachedToUid === cloud.uid; - const cloudIsLast = lastPoint.attachedToUid === cloud.uid; - - if (!cloudIsFirst && !cloudIsLast) { - return [cloud, flow]; - } - - const segments = getSegments(points); - const currentValve: IPoint = { x: flow.x, y: flow.y }; - - // For 2-point (straight) flows, check for perpendicular offset - if (segments.length === 1) { - const seg = segments[0]; - - // Degenerate segment: both points at same position (e.g., during flow creation). - // In this case, seg.isHorizontal and seg.isVertical are both true. - // Use the drag direction to determine the intended axis. - const isDegenerate = seg.isHorizontal && seg.isVertical; - - // For degenerate segments, determine axis from drag direction. For - // non-degenerate segments, use the segment's DOMINANT orientation (see - // isDominantlyHorizontal): an exact check misread a parallel drag of a - // slightly-diagonal legacy flow as perpendicular and rerouted it into a - // bogus L. - const treatAsHorizontal = isDegenerate - ? Math.abs(moveDelta.x) > Math.abs(moveDelta.y) - : isDominantlyHorizontal(seg.p1, seg.p2); - - const perpDelta = treatAsHorizontal ? moveDelta.y : moveDelta.x; - const parDelta = treatAsHorizontal ? moveDelta.x : moveDelta.y; - - const PERP_THRESHOLD = 5; - const perpAbs = Math.abs(perpDelta); - const parAbs = Math.abs(parDelta); - // Don't reroute degenerate flows - they should stay straight - const shouldReroute = !isDegenerate && perpAbs >= PERP_THRESHOLD && perpAbs > parAbs; - - if (shouldReroute) { - // Bend the straight flow into an L by inserting a corner. The dragged cloud - // moves by the FULL accumulated delta -- BOTH the perpendicular component - // (which forms the bend) AND the parallel component. An earlier version - // applied only the perpendicular delta and kept the cloud's ORIGINAL - // parallel coordinate; because the live drag re-routes from the original - // 2-point flow each pointermove, a drag that had already traveled - // along-axis snapped the cloud back to its starting parallel position the - // instant it crossed the perpendicular threshold (a one-frame teleport). - const base = cloudIsFirst ? firstPoint : lastPoint; - const otherPoint = cloudIsFirst ? lastPoint : firstPoint; - const newCloudPoint: Point = { ...base, x: base.x - moveDelta.x, y: base.y - moveDelta.y }; - - // The corner keeps the two segments orthogonal: a riser on the FIXED end's - // coordinate, then a run out to the cloud. For a (dominantly) horizontal - // flow the riser is vertical at otherPoint.x; for a vertical flow it is - // horizontal at otherPoint.y. - const corner: Point = treatAsHorizontal - ? { x: otherPoint.x, y: newCloudPoint.y, attachedToUid: undefined } - : { x: newCloudPoint.x, y: otherPoint.y, attachedToUid: undefined }; - - points = cloudIsFirst ? [newCloudPoint, corner, otherPoint] : [otherPoint, corner, newCloudPoint]; - - // Update cloud position to the full dragged position. - cloud = { - ...cloud, - x: newCloudPoint.x, - y: newCloudPoint.y, - }; - - // Normalize before placing the valve: if the cloud was dragged onto the - // fixed end's axis the run collapses to zero length and the L degenerates - // to a straight flow -- normalizeFlowPoints drops the redundant corner so - // the valve lands on a real segment. - points = normalizeFlowPoints(points); - - // Land the valve on the segment adjacent to the dragged cloud -- the bent - // segment the drag is actively growing -- at an interior position. Picking - // the merely-CLOSEST segment here (clampValveToClosestSegment) let the - // valve's closest segment flip from this bent segment onto the - // perpendicular riser as the L deepened, teleporting the valve (and its - // label) to the riser's stock-adjacent end, flush against the stock body - // (#53). Anchoring to the cloud-adjacent segment tracks the cursor - // continuously, and clampToSegment keeps the valve off the segment ends. - const rerouteSegments = getSegments(points); - const cloudAdjacentSegment = cloudIsFirst ? rerouteSegments[0] : rerouteSegments[rerouteSegments.length - 1]; - const newValve = clampToSegment(currentValve, cloudAdjacentSegment); - - flow = { - ...flow, - x: newValve.x, - y: newValve.y, - points, - }; - - return [cloud, flow]; - } - - // No perpendicular offset: constrain to flow axis - let proposed: Point = { - x: cloud.x - moveDelta.x, - y: cloud.y - moveDelta.y, - attachedToUid: cloud.uid, - }; - - if (treatAsHorizontal) { - proposed = { ...proposed, y: firstPoint.y }; - } else { - proposed = { ...proposed, x: firstPoint.x }; - } - - const origCloud = cloud; - cloud = { - ...cloud, - x: proposed.x, - y: proposed.y, - }; - - flow = first(adjustFlows(origCloud, cloud, [flow])); - return [cloud, flow]; - } - - // For multi-segment flows: update adjacent corner to maintain orthogonality - const cloudPointIndex = cloudIsFirst ? 0 : points.length - 1; - const adjacentPointIndex = cloudIsFirst ? 1 : points.length - 2; - const cloudPoint = at(points, cloudPointIndex); - const adjacentPoint = at(points, adjacentPointIndex); - - // Determine segment orientation between cloud and adjacent point - const adjacentSegment = cloudIsFirst ? segments[0] : segments[segments.length - 1]; - - let newCloudX: number; - let newCloudY: number; - - // For stocks, we need to determine whether to: - // 1. Translate the endpoint (for normal movement where the edge stays correct), or - // 2. Recompute the edge (when reattaching to a stock on the opposite side of the corner) - // - // The key insight is: if translating the old endpoint would put it on the WRONG side - // of the stock center (opposite to where the corner is), we need to recompute the edge. - // - // IMPORTANT: Callers pass the stock with OLD coordinates and moveDelta = oldCenter - newCenter. - // So the NEW stock center is: cloud.x - moveDelta.x, cloud.y - moveDelta.y - // - // Skip this branch for isZeroRadius stocks - these are temporary drag placeholders that - // Canvas creates when detaching a flow from a stock. They should be treated as clouds. - if (cloud.type === 'stock' && !cloud.isZeroRadius) { - const isHorizontalSegment = adjacentSegment.isHorizontal; - - // Compute the NEW stock center (where it's being moved/reattached to) - const newStockCx = cloud.x - moveDelta.x; - const newStockCy = cloud.y - moveDelta.y; - - // Calculate what the translated endpoint position would be - const translatedX = cloudPoint.x - moveDelta.x; - const translatedY = cloudPoint.y - moveDelta.y; - - // Determine which side the adjacent point is on relative to the NEW stock center - // This tells us which edge the endpoint SHOULD be on - let expectedSide: Side; - if (isHorizontalSegment) { - expectedSide = adjacentPoint.x > newStockCx ? 'right' : 'left'; - } else { - expectedSide = adjacentPoint.y > newStockCy ? 'bottom' : 'top'; - } - - // Check if the translated endpoint is on the correct side of the NEW stock center - let translatedIsCorrectSide: boolean; - if (isHorizontalSegment) { - const shouldBeRight = expectedSide === 'right'; - const translatedIsRight = translatedX > newStockCx; - translatedIsCorrectSide = shouldBeRight === translatedIsRight; - } else { - const shouldBeBottom = expectedSide === 'bottom'; - const translatedIsBottom = translatedY > newStockCy; - translatedIsCorrectSide = shouldBeBottom === translatedIsBottom; - } - - if (translatedIsCorrectSide) { - // Translation keeps the endpoint on the correct side - use translated position - // This handles normal movement where the edge offset is preserved - newCloudX = translatedX; - newCloudY = translatedY; - } else { - // Translation would put endpoint on wrong side - recompute the edge - // This handles reattachment to a stock on the opposite side of the corner - const stockEdge = getStockEdgePoint(newStockCx, newStockCy, expectedSide); - newCloudX = stockEdge.x; - newCloudY = stockEdge.y; - } - } else { - // For clouds: calculate position from the existing endpoint position (which is on the - // stock edge for stock-attached endpoints), not from cloud.x/y (stock center). - // This ensures that dragging a stock-attached endpoint keeps it on the edge. - newCloudX = cloudPoint.x - moveDelta.x; - newCloudY = cloudPoint.y - moveDelta.y; - } - - // Update adjacent corner to maintain orthogonality - let newAdjacentX = adjacentPoint.x; - let newAdjacentY = adjacentPoint.y; - - if (adjacentSegment.isHorizontal) { - // Horizontal segment: cloud Y change affects corner Y - newAdjacentY = newCloudY; - } else if (adjacentSegment.isVertical) { - // Vertical segment: cloud X change affects corner X - newAdjacentX = newCloudX; - } - - const newCloudPoint: Point = { - ...cloudPoint, - x: newCloudX, - y: newCloudY, - }; - - const newAdjacentPoint: Point = { - x: newAdjacentX, - y: newAdjacentY, - attachedToUid: adjacentPoint.attachedToUid, - }; - - if (cloudIsFirst) { - points = arrayWith(arrayWith(points, 0, newCloudPoint), 1, newAdjacentPoint); - } else { - points = arrayWith(arrayWith(points, points.length - 1, newCloudPoint), points.length - 2, newAdjacentPoint); - } - - // Update cloud position - cloud = { - ...cloud, - x: newCloudX, - y: newCloudY, - }; - - // Preserve valve fractional position on the cloud-adjacent segment - const oldSegments = getSegments(flow.points); - const valveOldSegment = findClosestSegment(currentValve, oldSegments); - const cloudAdjacentSegmentIndex = cloudIsFirst ? 0 : oldSegments.length - 1; - const valveIsOnCloudAdjacentSegment = valveOldSegment.index === cloudAdjacentSegmentIndex; - - // Normalize to remove any colinear or zero-length segments - points = normalizeFlowPoints(points); - const newSegments = getSegments(points); - - let newValve: IPoint; - if (valveIsOnCloudAdjacentSegment && newSegments.length > 0) { - const newCloudAdjacentSegmentIndex = cloudIsFirst ? 0 : newSegments.length - 1; - const newCloudAdjacentSegment = newSegments[newCloudAdjacentSegmentIndex]; - newValve = preserveValveFraction(currentValve, valveOldSegment, newCloudAdjacentSegment); - } else { - const closestSegment = findClosestSegment(currentValve, newSegments); - newValve = clampToSegment(currentValve, closestSegment); - } - - flow = { - ...flow, - x: newValve.x, - y: newValve.y, - points, - }; - - return [cloud, flow]; -} - -export interface Segment { - index: number; - p1: IPoint; - p2: IPoint; - isHorizontal: boolean; - isVertical: boolean; - isDiagonal: boolean; -} - -// Exported for testing -export function getSegments(points: readonly Point[]): Segment[] { - const segments: Segment[] = []; - for (let i = 0; i < points.length - 1; i++) { - const p1 = at(points, i); - const p2 = at(points, i + 1); - const isHorizontal = p1.y === p2.y; - const isVertical = p1.x === p2.x; - const isDiagonal = !isHorizontal && !isVertical; - segments.push({ - index: i, - p1: { x: p1.x, y: p1.y }, - p2: { x: p2.x, y: p2.y }, - isHorizontal, - isVertical, - isDiagonal, - }); - } - return segments; -} - -/** - * Normalizes flow points to ensure valid geometry: - * - * 1. Segments must alternate between horizontal and vertical - no two adjacent - * segments should have the same orientation (colinear segments). - * 2. No zero-length segments - adjacent points at the same position are redundant. - * - * This function removes interior points that violate these rules. The first and - * last points are always preserved since they're attached to stocks/clouds. - * - * Examples of normalization: - * - [A, B, C] where A-B and B-C are both horizontal → [A, C] (B is redundant) - * - [A, B, C] where B is at the same position as A → [A, C] (B is redundant) - * - * This should be called after any operation that modifies flow points (stock moves, - * segment drags) to maintain clean geometry. - */ -export function normalizeFlowPoints(points: readonly Point[]): readonly Point[] { - // Need at least 2 points for a valid flow - if (points.length <= 2) { - return points; - } - - // First and last points are attached to stocks/clouds - always preserve them - const firstPoint = first(points); - const lastPoint = last(points); - - const result: Point[] = [firstPoint]; - - for (let i = 1; i < points.length - 1; i++) { - const prev = last(result); - const curr = at(points, i); - const next = at(points, i + 1); - - // Skip if this point creates a zero-length segment with prev - if (prev.x === curr.x && prev.y === curr.y) { - continue; - } - - // Skip if this point creates a zero-length segment with next - if (curr.x === next.x && curr.y === next.y) { - continue; - } - - // Check if prev-curr and curr-next are colinear (same orientation) - const prevToCurrIsHorizontal = prev.y === curr.y; - const prevToCurrIsVertical = prev.x === curr.x; - const currToNextIsHorizontal = curr.y === next.y; - const currToNextIsVertical = curr.x === next.x; - - // Skip if both segments are horizontal or both are vertical (colinear) - if ((prevToCurrIsHorizontal && currToNextIsHorizontal) || (prevToCurrIsVertical && currToNextIsVertical)) { - continue; - } - - result.push(curr); - } - - // Always add the last point - result.push(lastPoint); - - return result; -} - -// General point-to-line-segment distance using vector projection -function distanceToSegmentGeneral(point: IPoint, p1: IPoint, p2: IPoint): number { - const dx = p2.x - p1.x; - const dy = p2.y - p1.y; - const lenSq = dx * dx + dy * dy; - - // Degenerate segment (single point) - if (lenSq === 0) { - return Math.hypot(point.x - p1.x, point.y - p1.y); - } - - // Parameter t of the closest point on the infinite line - let t = ((point.x - p1.x) * dx + (point.y - p1.y) * dy) / lenSq; - // Clamp t to [0, 1] to stay on the segment - t = Math.max(0, Math.min(1, t)); - - // Closest point on segment - const closestX = p1.x + t * dx; - const closestY = p1.y + t * dy; - - return Math.hypot(point.x - closestX, point.y - closestY); -} - -function distanceToSegment(point: IPoint, seg: Segment): number { - const { p1, p2 } = seg; - - // For diagonal segments, use the general formula - if (seg.isDiagonal) { - return distanceToSegmentGeneral(point, p1, p2); - } - - if (seg.isHorizontal) { - const minX = Math.min(p1.x, p2.x); - const maxX = Math.max(p1.x, p2.x); - if (point.x >= minX && point.x <= maxX) { - return Math.abs(point.y - p1.y); - } - const distToP1 = Math.hypot(point.x - p1.x, point.y - p1.y); - const distToP2 = Math.hypot(point.x - p2.x, point.y - p2.y); - return Math.min(distToP1, distToP2); - } else { - // Vertical segment - const minY = Math.min(p1.y, p2.y); - const maxY = Math.max(p1.y, p2.y); - if (point.y >= minY && point.y <= maxY) { - return Math.abs(point.x - p1.x); - } - const distToP1 = Math.hypot(point.x - p1.x, point.y - p1.y); - const distToP2 = Math.hypot(point.x - p2.x, point.y - p2.y); - return Math.min(distToP1, distToP2); - } -} - -export function findClosestSegment(point: IPoint, segments: Segment[]): Segment { - if (segments.length === 0) { - throw new Error('findClosestSegment called with empty segments array'); - } - let closest = segments[0]; - let minDist = distanceToSegment(point, closest); - for (let i = 1; i < segments.length; i++) { - const dist = distanceToSegment(point, segments[i]); - if (dist < minDist) { - minDist = dist; - closest = segments[i]; - } - } - return closest; -} - -export function clampToSegment(point: IPoint, seg: Segment, margin: number = VALVE_CLAMP_MARGIN): IPoint { - const { p1, p2 } = seg; - - // For diagonal segments, project onto the line and apply margin - if (seg.isDiagonal) { - const dx = p2.x - p1.x; - const dy = p2.y - p1.y; - const len = Math.hypot(dx, dy); - - if (len === 0) { - return { x: p1.x, y: p1.y }; - } - - // If segment is shorter than 2 * margin, use the midpoint - if (len < 2 * margin) { - return { - x: (p1.x + p2.x) / 2, - y: (p1.y + p2.y) / 2, - }; - } - - // Parameter t of the closest point on the infinite line - let t = ((point.x - p1.x) * dx + (point.y - p1.y) * dy) / (len * len); - - // Clamp t to [margin/len, 1 - margin/len] to apply margin from endpoints - const marginT = margin / len; - t = Math.max(marginT, Math.min(1 - marginT, t)); - - return { - x: p1.x + t * dx, - y: p1.y + t * dy, - }; - } - - if (seg.isHorizontal) { - const segLen = Math.abs(p2.x - p1.x); - // If segment is shorter than 2 * margin, use the midpoint - if (segLen < 2 * margin) { - return { - x: (p1.x + p2.x) / 2, - y: p1.y, - }; - } - const minX = Math.min(p1.x, p2.x) + margin; - const maxX = Math.max(p1.x, p2.x) - margin; - return { - x: Math.max(minX, Math.min(maxX, point.x)), - y: p1.y, - }; - } else { - // Vertical segment - const segLen = Math.abs(p2.y - p1.y); - // If segment is shorter than 2 * margin, use the midpoint - if (segLen < 2 * margin) { - return { - x: p1.x, - y: (p1.y + p2.y) / 2, - }; - } - const minY = Math.min(p1.y, p2.y) + margin; - const maxY = Math.max(p1.y, p2.y) - margin; - return { - x: p1.x, - y: Math.max(minY, Math.min(maxY, point.y)), - }; - } -} - -const VALVE_RADIUS = 6; -const VALVE_HIT_TOLERANCE = 5; -// Margin from segment endpoints when clamping valve position -const VALVE_CLAMP_MARGIN = 10; - -/** - * Snap a valve onto the closest segment of a flow path -- the recurring - * getSegments -> findClosestSegment -> clampToSegment chain run wherever a - * flow's geometry changes (stock/cloud moves, segment drags, L-shape - * conversions). Assumes `points` has at least two points, which every caller - * guarantees, so `findClosestSegment` always has a segment to return. - */ -function clampValveToClosestSegment(valve: IPoint, points: readonly Point[]): IPoint { - const segments = getSegments(points); - return clampToSegment(valve, findClosestSegment(valve, segments)); -} - -/** - * Preserves the valve's fractional position when a segment changes. - * - * When a stock moves along the flow axis, the segment gets longer or shorter. - * Instead of just clamping the valve to the new segment bounds (which causes it - * to jump to an endpoint when the stock moves past the valve), we preserve - * the valve's proportional position along the segment. - * - * For example, if the valve was at 65% from the anchor toward the stock on the - * old segment, it will be placed at 65% from the anchor toward the stock on the - * new segment. - * - * @param valve The current valve position - * @param oldSeg The segment before the stock moved - * @param newSeg The segment after the stock moved - * @param margin Minimum distance from segment endpoints - * @returns The new valve position preserving fractional placement - */ -function preserveValveFraction(valve: IPoint, oldSeg: Segment, newSeg: Segment): IPoint { - if (oldSeg.isHorizontal && newSeg.isHorizontal) { - const oldLen = oldSeg.p2.x - oldSeg.p1.x; - const newLen = newSeg.p2.x - newSeg.p1.x; - - // Calculate valve's fraction along old segment (0 = at p1, 1 = at p2) - // Don't apply margin constraints - preserve the exact fractional position - // as the adjustFlows logic does. This prevents the valve from jumping - // when the stock moves past it. - let fraction = oldLen !== 0 ? (valve.x - oldSeg.p1.x) / oldLen : 0.5; - - // Only clamp to [0, 1] to ensure the valve stays on the segment - fraction = Math.max(0, Math.min(1, fraction)); - - return { - x: newSeg.p1.x + fraction * newLen, - y: newSeg.p1.y, - }; - } else if (oldSeg.isVertical && newSeg.isVertical) { - const oldLen = oldSeg.p2.y - oldSeg.p1.y; - const newLen = newSeg.p2.y - newSeg.p1.y; - - // Calculate valve's fraction along old segment - let fraction = oldLen !== 0 ? (valve.y - oldSeg.p1.y) / oldLen : 0.5; - - // Only clamp to [0, 1] to ensure the valve stays on the segment - fraction = Math.max(0, Math.min(1, fraction)); - - return { - x: newSeg.p1.x, - y: newSeg.p1.y + fraction * newLen, - }; - } - - // For mixed orientation or diagonal segments, fall back to clamping - return clampToSegment(valve, newSeg); -} - -// Check if a segment has an attached endpoint that would prevent dragging. -// Dragging a segment with an attached endpoint would create a diagonal segment, -// which breaks the axis-alignment assumptions used by hit-testing and valve clamping. -function segmentHasAttachedEndpoint(points: readonly Point[], segmentIndex: number): boolean { - const numSegments = points.length - 1; - if (segmentIndex < 0 || segmentIndex >= numSegments) { - return true; - } - - // Check both endpoints of the segment - const p1 = points[segmentIndex]; - const p2 = points[segmentIndex + 1]; - - return p1?.attachedToUid !== undefined || p2?.attachedToUid !== undefined; -} - -// Determine which segment was clicked, or undefined if clicking on the valve -export function findClickedSegment( - clickX: number, - clickY: number, - valveCx: number, - valveCy: number, - points: readonly Point[], -): number | undefined { - // If click is on/near the valve, return undefined (valve drag, not segment) - const distToValve = Math.hypot(clickX - valveCx, clickY - valveCy); - if (distToValve <= VALVE_RADIUS + VALVE_HIT_TOLERANCE) { - return undefined; - } - - const segments = getSegments(points); - if (segments.length === 0) { - return undefined; - } - - // For single-segment flows (straight lines), clicking anywhere drags the valve - if (segments.length === 1) { - return undefined; - } - - // For multi-segment flows, find closest segment - const clickPoint: IPoint = { x: clickX, y: clickY }; - const closest = findClosestSegment(clickPoint, segments); - - // Don't allow dragging diagonal segments - they shouldn't exist in valid - // flow geometry, but could appear in imported models. moveSegment assumes - // all segments are axis-aligned, so dragging a diagonal would be incorrect. - if (closest.isDiagonal) { - return undefined; - } - - // Don't allow dragging segments that have an attached endpoint - // (would create diagonal segments which break axis-alignment assumptions) - if (segmentHasAttachedEndpoint(points, closest.index)) { - return undefined; - } - - return closest.index; -} - -// Move a segment perpendicular to its direction, adjusting adjacent segments -export function moveSegment(points: readonly Point[], segmentIndex: number, delta: IPoint): readonly Point[] { - const segments = getSegments(points); - if (segmentIndex < 0 || segmentIndex >= segments.length) { - return points; - } - - const seg = segments[segmentIndex]; - const isFirst = segmentIndex === 0; - const isLast = segmentIndex === segments.length - 1; - - return points.map((p, i) => { - if (seg.isHorizontal) { - // Horizontal segment: move up/down (change Y) - // Both endpoints of this segment move - if (i === segmentIndex || i === segmentIndex + 1) { - // Don't move attached endpoints (first and last points) - if ((i === 0 && isFirst) || (i === points.length - 1 && isLast)) { - return p; - } - return { ...p, y: p.y - delta.y }; - } - } else { - // Vertical segment: move left/right (change X) - if (i === segmentIndex || i === segmentIndex + 1) { - if ((i === 0 && isFirst) || (i === points.length - 1 && isLast)) { - return p; - } - return { ...p, x: p.x - delta.x }; - } - } - return p; - }); -} - -export function UpdateFlow( - flowEl: FlowViewElement, - ends: readonly (StockViewElement | CloudViewElement)[], - moveDelta: IPoint, - segmentIndex?: number, -): [FlowViewElement, readonly CloudViewElement[]] { - const clouds = ends.filter((e): e is CloudViewElement => e.type === 'cloud'); - - let points = flowEl.points; - - const currentValve: IPoint = { x: flowEl.x, y: flowEl.y }; - const proposedValve: IPoint = { - x: currentValve.x - moveDelta.x, - y: currentValve.y - moveDelta.y, - }; - - // For cloud-to-cloud flows, move everything uniformly - const hasStock = ends.some((e) => e.type === 'stock'); - if (!hasStock) { - points = points.map((p) => ({ ...p, x: p.x - moveDelta.x, y: p.y - moveDelta.y })); - flowEl = { - ...flowEl, - x: proposedValve.x, - y: proposedValve.y, - points, - }; - - const updatedClouds: readonly CloudViewElement[] = clouds.map((cloud) => { - return { - ...cloud, - x: cloud.x - moveDelta.x, - y: cloud.y - moveDelta.y, - }; - }); - - return [flowEl, updatedClouds]; - } - - const segments = getSegments(points); - - // If a specific segment is being moved, move that segment. - // Note: We return an empty clouds list because segment movement only affects - // interior points (corners), not attached endpoints. Attached endpoints stay - // fixed at their stock/cloud positions. Only draggable segments are interior - // segments (between two corners), so no cloud positions need updating. - if (segmentIndex !== undefined) { - points = moveSegment(points, segmentIndex, moveDelta); - - // Normalize to remove any colinear or zero-length segments that may have - // been created by the segment movement. - points = normalizeFlowPoints(points); - - // Always re-clamp the valve to the closest segment after any segment drag. - // Dragging any segment can affect adjacent segments via shared corners, - // so the valve's segment may have changed shape even if it wasn't the - // segment being dragged. - const newValve = clampValveToClosestSegment(currentValve, points); - flowEl = { - ...flowEl, - x: newValve.x, - y: newValve.y, - points, - }; - - return [flowEl, []]; - } - - // For 2-point (straight) flows, allow a perpendicular valve drag to offset the - // route. A flow with a cloud endpoint bends into an L (the cloud moves); a flow - // pinned between two stocks -- where neither endpoint can move -- inserts two - // corners to form a Z whose middle segment runs parallel to the original axis, - // displaced by the perpendicular drag (#819). Once either shape exists, its - // middle/bent segment is interior, so the normal segment-drag machinery takes - // over and dragging it back onto the axis collapses (via normalizeFlowPoints) - // to the straight flow. - if (segments.length === 1) { - const seg = segments[0]; - // Classify by the dominant axis rather than exact equality so a - // slightly-diagonal legacy flow is offset along the axis it visually runs - // (audit finding 5); for exact orthogonal flows this matches seg.isHorizontal. - const treatAsHorizontal = isDominantlyHorizontal(seg.p1, seg.p2); - const perpDelta = treatAsHorizontal ? moveDelta.y : moveDelta.x; - const parDelta = treatAsHorizontal ? moveDelta.x : moveDelta.y; - - // Require a significant and dominant perpendicular component to trigger reroute. - // This prevents accidental reroute during normal valve dragging, since pointer - // movement often has small perpendicular noise. - const PERP_THRESHOLD = 5; - const perpAbs = Math.abs(perpDelta); - const parAbs = Math.abs(parDelta); - const shouldReroute = perpAbs >= PERP_THRESHOLD && perpAbs > parAbs; - - if (shouldReroute && clouds.length > 0) { - const firstPoint = first(points); - const lastPoint = last(points); - - // Find which endpoint is the cloud vs stock - const firstIsCloud = clouds.some((c) => c.uid === firstPoint.attachedToUid); - const lastIsCloud = clouds.some((c) => c.uid === lastPoint.attachedToUid); - - // Calculate new cloud position(s) - let newFirstPoint = firstPoint; - let newLastPoint = lastPoint; - - if (treatAsHorizontal) { - // Horizontal segment: perpendicular movement is vertical (Y changes) - if (firstIsCloud) { - newFirstPoint = { ...firstPoint, y: firstPoint.y - moveDelta.y }; - } - if (lastIsCloud) { - newLastPoint = { ...lastPoint, y: lastPoint.y - moveDelta.y }; - } - - // Create corner point to maintain orthogonality - // Corner connects the moved cloud to the fixed stock - let corner: Point; - if (firstIsCloud && !lastIsCloud) { - // Cloud is first, stock is last: corner at (stock.x, newCloud.y) - corner = { x: lastPoint.x, y: newFirstPoint.y, attachedToUid: undefined }; - points = [newFirstPoint, corner, lastPoint]; - } else if (!firstIsCloud && lastIsCloud) { - // Stock is first, cloud is last: corner at (stock.x, newCloud.y) - corner = { x: firstPoint.x, y: newLastPoint.y, attachedToUid: undefined }; - points = [firstPoint, corner, newLastPoint]; - } else { - // Both are clouds - move both endpoints uniformly - points = [newFirstPoint, newLastPoint]; - } - } else { - // Vertical segment: perpendicular movement is horizontal (X changes) - if (firstIsCloud) { - newFirstPoint = { ...firstPoint, x: firstPoint.x - moveDelta.x }; - } - if (lastIsCloud) { - newLastPoint = { ...lastPoint, x: lastPoint.x - moveDelta.x }; - } - - // Create corner point - let corner: Point; - if (firstIsCloud && !lastIsCloud) { - // Cloud is first, stock is last: corner at (newCloud.x, stock.y) - corner = { x: newFirstPoint.x, y: lastPoint.y, attachedToUid: undefined }; - points = [newFirstPoint, corner, lastPoint]; - } else if (!firstIsCloud && lastIsCloud) { - // Stock is first, cloud is last: corner at (newCloud.x, stock.y) - corner = { x: newLastPoint.x, y: firstPoint.y, attachedToUid: undefined }; - points = [firstPoint, corner, newLastPoint]; - } else { - // Both are clouds - move both endpoints uniformly - points = [newFirstPoint, newLastPoint]; - } - } - - // Update cloud positions - const updatedClouds: readonly CloudViewElement[] = clouds.map((cloud) => { - if (cloud.uid === firstPoint.attachedToUid && firstIsCloud) { - return { - ...cloud, - x: newFirstPoint.x, - y: newFirstPoint.y, - }; - } else if (cloud.uid === lastPoint.attachedToUid && lastIsCloud) { - return { - ...cloud, - x: newLastPoint.x, - y: newLastPoint.y, - }; - } - return cloud; - }); - - // Clamp valve to the closest segment of the new shape - const newValve = clampValveToClosestSegment(currentValve, points); - - flowEl = { - ...flowEl, - x: newValve.x, - y: newValve.y, - points, - }; - - return [flowEl, updatedClouds]; - } - - if (shouldReroute && clouds.length === 0) { - // Both endpoints are attached stocks, so neither can move to absorb the - // offset. Keep both endpoints on their current stock edges and insert two - // corners at the displaced perpendicular coordinate (the dragged valve's - // position), forming a Z: two perpendicular risers bracketing a middle - // segment parallel to the original axis. The endpoints are untouched, so - // corner clearance is preserved and dragging the middle back onto the axis - // collapses the risers to zero length (normalizeFlowPoints) -> straight. - const firstPoint = first(points); - const lastPoint = last(points); - - let corner1: Point; - let corner2: Point; - if (treatAsHorizontal) { - // Horizontal flow: risers are vertical (on each endpoint's X), the - // middle segment is horizontal at the dragged valve's Y. - const midY = proposedValve.y; - corner1 = { x: firstPoint.x, y: midY, attachedToUid: undefined }; - corner2 = { x: lastPoint.x, y: midY, attachedToUid: undefined }; - } else { - // Vertical flow: risers are horizontal (on each endpoint's Y), the - // middle segment is vertical at the dragged valve's X. - const midX = proposedValve.x; - corner1 = { x: midX, y: firstPoint.y, attachedToUid: undefined }; - corner2 = { x: midX, y: lastPoint.y, attachedToUid: undefined }; - } - - // Normalize like every other mutation path so the orthogonality invariant - // doesn't silently rely on the perpendicular threshold keeping the risers - // non-degenerate. - points = normalizeFlowPoints([firstPoint, corner1, corner2, lastPoint]); - - // Land the valve on the interior (middle) segment the drag created, not - // merely the closest segment: a perpendicular-dominant drag can still - // carry a large parallel component, which can leave a riser closer to the - // dragged position and strand the valve off the offset the user just made. - const zSegments = getSegments(points); - const middleSeg = zSegments.length === 3 ? zSegments[1] : findClosestSegment(proposedValve, zSegments); - const newValve = clampToSegment(proposedValve, middleSeg); - flowEl = { - ...flowEl, - x: newValve.x, - y: newValve.y, - points, - }; - - return [flowEl, []]; - } - } - - // Moving the valve along the flow path. - // Note: Valve movement doesn't change any endpoint positions, so no cloud - // positions need updating. We return an empty clouds list. - // Use proposedValve (not currentValve) to find the closest segment, so the - // valve can cross corners when dragged past them to another segment. - const closestSegment = findClosestSegment(proposedValve, segments); - const clampedValve = clampToSegment(proposedValve, closestSegment); - - flowEl = { - ...flowEl, - x: clampedValve.x, - y: clampedValve.y, - }; - - return [flowEl, []]; -} - /** * Pull a cloud-terminated flow's endpoint back by `radius` along the * direction of the final segment, so the arrowhead lands on the cloud's @@ -1827,8 +122,6 @@ export interface FlowProps { isSelected: boolean; isEditingName: boolean; isValidTarget?: boolean; - isMovingArrow: boolean; - isMovingSource: boolean; hasWarning?: boolean; embedded?: boolean; series: Readonly> | undefined; @@ -1837,7 +130,6 @@ export interface FlowProps { e: React.PointerEvent, isText?: boolean, isArrowhead?: boolean, - segmentIndex?: number, isSource?: boolean, ) => void; onLabelDrag: (uid: number, e: React.PointerEvent) => void; @@ -1847,7 +139,7 @@ export interface FlowProps { } export const Flow = React.memo(function Flow(props: FlowProps): React.ReactElement { - const { element, isEditingName, isMovingArrow, isMovingSource, isSelected, isValidTarget, series, sink } = props; + const { element, isEditingName, isSelected, isValidTarget, series, sink } = props; const { hasWarning, embedded, onSelection, onLabelDrag } = props; const handlePointerUp = (_e: React.PointerEvent): void => { @@ -1855,31 +147,12 @@ export const Flow = React.memo(function Flow(props: FlowProps): React.ReactEleme // e.stopPropagation(); }; + // A press on the pipe or the valve; the canvas decides from the pointer which + // segment it is and whether the drag slides the valve or offsets the segment. const handlePointerDown = (e: React.PointerEvent): void => { e.preventDefault(); e.stopPropagation(); - - // Convert screen coordinates to model coordinates using the element's CTM. - // We must use the clicked element's CTM (not the SVG root's CTM) because - // Canvas applies zoom/pan via a parent group. - // The element's CTM includes this transform, so inverting it correctly - // converts screen coordinates to model coordinates regardless of zoom/pan. - const target = e.currentTarget as SVGGraphicsElement; - const svg = target.ownerSVGElement; - let segmentIndex: number | undefined; - - if (svg) { - const pt = svg.createSVGPoint(); - pt.x = e.clientX; - pt.y = e.clientY; - const ctm = target.getScreenCTM(); - if (ctm) { - const modelPt = pt.matrixTransform(ctm.inverse()); - segmentIndex = findClickedSegment(modelPt.x, modelPt.y, element.x, element.y, element.points); - } - } - - onSelection(element, e, false, false, segmentIndex); + onSelection(element, e); }; // Memoized: passed to the memo'd Label/Arrowhead below, so a stable identity @@ -1905,7 +178,7 @@ export const Flow = React.memo(function Flow(props: FlowProps): React.ReactEleme const handlePointerDownSource = (e: React.PointerEvent): void => { e.preventDefault(); e.stopPropagation(); - onSelection(element, e, false, false, undefined, true); + onSelection(element, e, false, false, true); }; const isArrayed = element.var ? variableIsArrayed(element.var) : false; @@ -1916,7 +189,7 @@ export const Flow = React.memo(function Flow(props: FlowProps): React.ReactEleme throw new Error('expected at least two points on a flow'); } - if (sink.type === 'cloud' && !isMovingArrow) { + if (sink.type === 'cloud') { pts = retractFinalPointIntoCloud(pts, CloudRadius); } @@ -2031,19 +304,18 @@ export const Flow = React.memo(function Flow(props: FlowProps): React.ReactEleme sourceHitY = firstPt.y + segDy * offsetRatio; } - // Only show the source hit area when not in embedded/export mode and not already moving the source - const sourceHitArea = - !embedded && !isMovingSource ? ( - - ) : null; + // The source grip is interactive-only: an exported diagram has nothing to grab. + const sourceHitArea = !embedded ? ( + + ) : null; return ( diff --git a/src/diagram/drawing/Label.tsx b/src/diagram/drawing/Label.tsx index 7e4cfc774..9a72915b2 100644 --- a/src/diagram/drawing/Label.tsx +++ b/src/diagram/drawing/Label.tsx @@ -82,9 +82,10 @@ function labelLayout(props: LabelPropsFull): LabelLayout { className = 'start'; // right textY = y - (12 + (lines.length - 1) * 14) / 2 - 3; break; - default: - // FIXME - console.log('unknown label case ' + side); + case 'center': + // Centered on the element: the initial values, as the Rust renderer + // (diagram/label.rs label_layout) places it. + break; } return { @@ -135,9 +136,10 @@ export function labelBounds(props: LabelProps): Rect { left = x; textY = y - (12 + (lines.length - 1) * 14) / 2 - 3; break; - default: - // FIXME - console.log('unknown label case ' + side); + case 'center': + // As the Rust renderer's label_bounds: centered horizontally on the text. + left = textX - editorWidth / 2; + break; } textY = Math.round(textY); diff --git a/src/diagram/drawing/canvas-interaction.ts b/src/diagram/drawing/canvas-interaction.ts deleted file mode 100644 index f971f905d..000000000 --- a/src/diagram/drawing/canvas-interaction.ts +++ /dev/null @@ -1,358 +0,0 @@ -// Copyright 2026 The Simlin Authors. All rights reserved. -// Use of this source code is governed by the Apache License, -// Version 2.0, that can be found in the LICENSE file. - -/** - * Pure, table-testable model of the Canvas discrete-interaction state machine. - * - * This module owns the DISCRETE gesture state -- the mutually-exclusive modes a - * pointer interaction can be in (idle, panning, drag-selecting, moving a - * selection, dragging a link/flow endpoint, moving a label, editing a name, - * creating an element, pinching) -- as a tagged union, plus the pure - * transitions between them. It has ZERO React/DOM dependencies. - * - * The imperative shell (drawing/Canvas.tsx) keeps everything that is not pure - * discrete state: pointer capture, screen->canvas coordinate conversion, the - * raw multi-touch activePointers map, the momentum rAF loop and velocity - * tracking, the native wheel/Safari-gesture listeners, the Slate rich-text - * editing value, and all rendering. The shell hit-tests raw DOM events into the - * semantic InteractionEvents below, feeds them to reduceInteraction, then - * executes the returned InteractionEffects (each effect is a command the shell - * performs: pointer capture, prop callbacks, starting momentum, ...). - * - * Division of labor: the reducer owns every discrete *mode* transition and - * chooses *which* effects fire and *in what order*. The shell owns geometry and - * hit-testing: before raising an event it has already resolved the canvas-space - * point, the hit element, the drag target, the move delta, and the faux-target - * centers, and it passes those pre-resolved results into the event (so an effect - * the reducer emits carries a payload the shell merely executes). This keeps the - * reducer pure -- it never touches a ViewElement's geometry beyond the explicit - * pure helpers (labelSideForPointer, isInDragSelectRect, isDrag) -- while still - * driving all of the shell's discrete behavior through reduceInteraction. - * - * Continuous physics (pinch zoom math, pan offset math, momentum frames) stays - * in the shell and calls props.onViewBoxChange directly; this reducer models - * only the discrete pinch/pan *mode* transitions, not the per-frame geometry. - * - * Selection-set arithmetic lives in selection-logic.ts (already extracted and - * table-tested); this reducer imports and composes it rather than duplicating - * it. The point/coordinate math the transitions need (drag-vs-click threshold, - * label-side quadrants, drag-select rectangle membership) is pure and lives - * here so it is exercised by the same gesture-sequence tests. - */ - -import { UID, ViewElement } from '@simlin/core/datamodel'; - -import type { Point } from './common'; -import { ClickDragThresholdPx } from './pointer-utils'; -import { computeMouseDownSelection, computeMouseUpSelection, type MouseDownSelectionResult } from '../selection-logic'; - -export type LabelSideName = 'top' | 'left' | 'bottom' | 'right'; - -/** - * The discrete interaction mode. Each variant carries exactly the data that is - * valid while in that mode -- replacing the former bag of mutually-exclusive - * booleans (isMovingCanvas/isDragSelecting/isMovingArrow/...) and the loose - * instance fields (deferredSingleSelectUid, draggingSegmentIndex, labelSide, - * pinch fields, inCreation, ...) on the Canvas component. - */ -export type InteractionState = - | { readonly mode: 'idle' } - // Single-finger / shift-drag canvas pan. The per-frame offset is continuous - // physics owned by the shell; the mode only records that a pan is in progress. - | { readonly mode: 'panning' } - // Rubber-band selection rectangle from mouseDownPoint to the current pointer. - | { readonly mode: 'dragSelecting' } - // Moving the current selection. `deferredSingleSelectUid` is set when the - // user pressed an already-selected element without a modifier: on pointer-up - // with no drag, selection collapses to that element (Figma-style); with a - // drag, the group selection is preserved. `segmentIndex` is the flow segment - // being dragged (undefined = valve / whole element). Name editing is never - // deferred here -- a double-click on a label is a terminal `dblclick` and - // enters `editingName` synchronously in the shell's handleSetSelection. - | { - readonly mode: 'movingSelection'; - readonly deferredSingleSelectUid: UID | undefined; - readonly segmentIndex: number | undefined; - } - // Dragging a link or flow endpoint. `endpoint` distinguishes the arrowhead - // (sink) from the source. `pointerType` drives the touch-is-always-straight - // rule for links. Whether the dragged element is a not-yet-persisted creation - // (link/flow tool or flow-from-stock) is NOT recorded here: the shell already - // owns the concrete in-creation element (`CanvasState.inCreation`) as the - // single source of truth, and reads it directly on pointer-up. - | { - readonly mode: 'movingEndpoint'; - readonly endpoint: 'arrow' | 'source'; - readonly pointerType: string; - } - // Dragging an element's label to a new side. - | { readonly mode: 'movingLabel'; readonly side: LabelSideName } - // Editing a name (the Slate value itself lives in the shell). `onPointerUp` - // stages the "start editing once the creation drag finishes" handoff used by - // the aux/stock/module creation tools. `creatingFlow` marks the just-created - // flow whose name edit, if cancelled, deletes the flow. - | { readonly mode: 'editingName'; readonly onPointerUp: boolean; readonly creatingFlow: boolean } - // Two-finger pinch. The continuous zoom math is the shell's; the mode records - // the fixed reference captured at pinch start. - | { - readonly mode: 'pinching'; - readonly initialDistance: number; - readonly initialZoom: number; - readonly modelPoint: Point; - }; - -export const idleState: InteractionState = { mode: 'idle' }; - -/** - * Semantic, hit-tested inputs to the reducer. The shell produces these from raw - * DOM events: it has already resolved which element was hit, the canvas-space - * point, modifier keys, and pointer type. The reducer never sees a DOM event. - */ -export type InteractionEvent = - // Pressed on empty canvas. `pan` is true for touch / shift (pan), false - // otherwise (rubber-band drag-select). - | { readonly kind: 'canvasPointerDown'; readonly pan: boolean } - // Pressed with a creation tool active (aux/stock/module): stage an element. - | { readonly kind: 'createToolPointerDown'; readonly tool: 'aux' | 'stock' | 'module' } - // Pressed with the flow tool on empty canvas: stage a flow + its source cloud. - | { readonly kind: 'flowToolPointerDown'; readonly pointerType: string } - // A second touch point started while a single-finger gesture was active: the - // shell has already computed the fixed pinch reference geometry. - | { - readonly kind: 'pinchStart'; - readonly initialDistance: number; - readonly initialZoom: number; - readonly modelPoint: Point; - } - // The pinch gesture ended (a pinch pointer lifted/cancelled). Returns to idle - // -- continuing with a single finger after a pinch is intentionally dropped. - | { readonly kind: 'pinchEnd' } - // Began dragging an element's label; the shell resolved the quadrant side. - | { readonly kind: 'labelDragStart'; readonly side: LabelSideName }; - -/** - * Whether a pointer move is far enough to count as a drag rather than the - * incidental jitter of a click. Mirrors pointer-utils.isDragMovement, kept here - * as the reducer's own threshold check so gesture-sequence tests exercise it. - */ -export function isDrag(moveDelta: Point | undefined, zoom: number): boolean { - if (moveDelta === undefined) { - return false; - } - return Math.hypot(moveDelta.x, moveDelta.y) * zoom >= ClickDragThresholdPx; -} - -/** - * The side a label snaps to given the pointer position relative to the - * element's center. Pure quadrant math extracted from Canvas.handleLabelDrag. - * - * `angle` is atan2(cy - py, cx - px) in degrees: the direction from the pointer - * toward the element center. The quadrants are intentionally asymmetric to - * match the original (a pointer to the LEFT of the center -> angle ~0 -> the - * label sits on the 'left'). - */ -export function labelSideForPointer(center: Point, pointer: Point): LabelSideName { - const angle = (Math.atan2(center.y - pointer.y, center.x - pointer.x) * 180) / Math.PI; - if (-45 < angle && angle <= 45) { - return 'left'; - } else if (45 < angle && angle <= 135) { - return 'top'; - } else if (-135 < angle && angle <= -45) { - return 'bottom'; - } - return 'right'; -} - -/** - * Whether an element falls within a drag-selection rectangle. Each element type - * has its own containment rule, extracted verbatim from - * Canvas.handlePointerCancel's drag-select loop: - * - clouds, stocks, flows, modules, aliases: center-point containment - * - aux: center containment OR any rectangle corner inside the aux circle - * (passed in via `auxCornerHit` because the circle hit-test is geometry the - * shell already owns in Auxiliary.auxContains) - * Links and groups are never drag-selected. - */ -export function isInDragSelectRect( - element: ViewElement, - rect: { left: number; right: number; top: number; bottom: number }, - auxCornerHit: (element: ViewElement) => boolean, -): boolean { - const centerInside = - element.x >= rect.left && element.x <= rect.right && element.y >= rect.top && element.y <= rect.bottom; - switch (element.type) { - case 'cloud': - case 'stock': - case 'flow': - case 'module': - case 'alias': - return centerInside; - case 'aux': - return centerInside || auxCornerHit(element); - default: - return false; - } -} - -/** - * The set of element UIDs inside a drag-selection rectangle. Pure given the - * element list and the aux corner-hit predicate. - */ -export function computeDragSelection( - elements: Iterable, - rect: { left: number; right: number; top: number; bottom: number }, - auxCornerHit: (element: ViewElement) => boolean, -): Set { - const selected = new Set(); - for (const element of elements) { - if (isInDragSelectRect(element, rect, auxCornerHit)) { - selected.add(element.uid); - } - } - return selected; -} - -/** - * Mouse-down selection decision (Figma/Illustrator pattern). This is the - * existing, separately-tested selection-logic.computeMouseDownSelection, - * re-exported through the reducer's vocabulary so the shell and gesture tests - * have a single entry point. The selection-set arithmetic deliberately stays in - * selection-logic.ts (it predates this module and has its own table tests); - * the reducer composes it rather than duplicating it. - */ -export type MouseDownSelection = MouseDownSelectionResult; - -export function decideMouseDownSelection( - currentSelection: ReadonlySet, - clickedUid: UID, - isModifier: boolean, -): MouseDownSelection { - return computeMouseDownSelection(currentSelection, clickedUid, isModifier); -} - -/** - * Resolve a deferred single-select on pointer-up: collapse to the deferred - * element only when no drag occurred (otherwise the group is preserved). - * Delegates to selection-logic.computeMouseUpSelection. - */ -export function resolveDeferredSelection(deferredUid: UID | undefined, didDrag: boolean): ReadonlySet | undefined { - return computeMouseUpSelection(deferredUid, didDrag); -} - -/** - * Commands the shell executes after a transition. The reducer never performs - * side effects; it returns these for the imperative shell to carry out (call a - * prop callback, capture the pointer, start the momentum animation, ...). Order - * is significant and preserved as emitted. - */ -export type InteractionEffect = - // Capture the pointer on the pressed target so moves keep flowing during a - // drag even if the cursor leaves the element. - { readonly kind: 'capturePointer' }; - -/** Read-only environment a transition needs from the shell. */ -export interface InteractionContext { - /** The currently committed selection. */ - readonly selection: ReadonlySet; -} - -export interface InteractionResult { - readonly state: InteractionState; - readonly effects: readonly InteractionEffect[]; -} - -/** - * The discrete-interaction transition function. Pure: given the current mode, - * a semantic (hit-tested) event, and the read-only context, it returns the next - * mode and the effects the shell should perform. - * - * What the reducer owns: the empty-canvas press (pan vs rubber-band drag-select), - * the three creation tools, the flow tool, pinch enter/exit, and label-drag - * start. These are mode transitions whose decision is NOT geometry-dominated, so - * they live here and are table-tested. The shell (`Canvas.handlePointerDown`, - * `handleLabelDrag`, `handlePointerCancel`) raises the matching event and - * executes the returned effects. The reducer only ever emits `capturePointer` - * today: selection replacement and tool-clearing are done by the shell directly - * (it owns the geometry/hit-testing those depend on), so they are not modeled - * as effects. - * - * What the reducer deliberately does NOT own: element / arrowhead / source press - * resolution. That is geometry-dominated (which element was hit, cloud-vs-flow - * reattachment, link/flow-tool staging, the deferred-single-select dance) and is - * interleaved with shell-only concerns (building the staged ViewElement, - * deserializing the Slate name value, the reattachment selection override). It - * therefore lives entirely in the shell's `handleSetSelection`, which composes - * the pure helpers re-exported here (`decideMouseDownSelection`, - * `resolveSelectionForReattachment`) and constructs the next `InteractionState` - * variant directly. There is no `elementPointerDown` event: a single source of - * truth (the shell) avoids a parallel, easy-to-skew model of press handling. - * - * Pointer-UP RESOLUTION likewise stays in the shell by design: it is dominated - * by geometry (which element is under the cursor, the move delta, faux-target - * centers, the dragged-link arc) and interleaved branches (a deferred-select - * resolution falls through into the generic move-commit path). The shell reads - * `state.interaction`, composes the pure helpers exported here - * (`resolveDeferredSelection`, `isDrag`, `computeDragSelection`, - * `labelSideForPointer`), and constructs the next InteractionState directly. - * Continuous physics (per-frame pan offset, pinch zoom math, momentum frames) is - * likewise shell-internal; the reducer only marks the pan/pinch *mode*. - */ -export function reduceInteraction( - _state: InteractionState, - event: InteractionEvent, - _ctx: InteractionContext, -): InteractionResult { - switch (event.kind) { - case 'canvasPointerDown': { - // Empty-canvas press: pan (touch/shift) or rubber-band drag-select. - // Selection is not cleared here -- that happens on pointer-up so a press - // that turns into a pan does not flicker the selection away. - return { - state: event.pan ? { mode: 'panning' } : { mode: 'dragSelecting' }, - effects: [], - }; - } - - case 'createToolPointerDown': { - // Aux/stock/module creation tool: stage an element (the shell builds the - // concrete ViewElement) and enter the editing-on-pointer-up handoff. - return { - state: { mode: 'editingName', onPointerUp: true, creatingFlow: false }, - effects: [{ kind: 'capturePointer' }], - }; - } - - case 'flowToolPointerDown': { - // Flow tool on empty canvas: stage a flow + source cloud and immediately - // enter arrowhead-drag so the user drags the sink into place. - return { - state: { mode: 'movingEndpoint', endpoint: 'arrow', pointerType: event.pointerType }, - effects: [], - }; - } - - case 'pinchStart': { - // Two-finger pinch began: capture the fixed reference. The shell has - // already cleared its single-finger pointer state. - return { - state: { - mode: 'pinching', - initialDistance: event.initialDistance, - initialZoom: event.initialZoom, - modelPoint: event.modelPoint, - }, - effects: [], - }; - } - - case 'pinchEnd': { - // A pinch pointer lifted: return to idle for a clean restart. - return { state: idleState, effects: [] }; - } - - case 'labelDragStart': { - return { state: { mode: 'movingLabel', side: event.side }, effects: [] }; - } - } -} diff --git a/src/diagram/drawing/cloud-utils.ts b/src/diagram/drawing/cloud-utils.ts deleted file mode 100644 index 27dc54a55..000000000 --- a/src/diagram/drawing/cloud-utils.ts +++ /dev/null @@ -1,32 +0,0 @@ -// Copyright 2026 The Simlin Authors. All rights reserved. -// Use of this source code is governed by the Apache License, -// Version 2.0, that can be found in the LICENSE file. - -import { CloudViewElement, FlowViewElement } from '@simlin/core/datamodel'; -import { first, last } from '@simlin/core/collections'; - -/** - * Determines if a cloud is on the source side (first point) of a flow. - * Returns true if the flow's first point is attached to this cloud. - * Throws if the flow has fewer than 2 points (which would be invalid). - */ -export function isCloudOnSourceSide(cloud: CloudViewElement, flow: FlowViewElement): boolean { - if (flow.points.length < 2) { - throw new Error(`Flow ${flow.uid} has fewer than 2 points`); - } - const firstPoint = first(flow.points); - return firstPoint.attachedToUid === cloud.uid; -} - -/** - * Determines if a cloud is on the sink side (last point) of a flow. - * Returns true if the flow's last point is attached to this cloud. - * Throws if the flow has fewer than 2 points (which would be invalid). - */ -export function isCloudOnSinkSide(cloud: CloudViewElement, flow: FlowViewElement): boolean { - if (flow.points.length < 2) { - throw new Error(`Flow ${flow.uid} has fewer than 2 points`); - } - const lastPoint = last(flow.points); - return lastPoint.attachedToUid === cloud.uid; -} diff --git a/src/diagram/drawing/creation-sentinels.ts b/src/diagram/drawing/creation-sentinels.ts index 072b0f53a..10a832276 100644 --- a/src/diagram/drawing/creation-sentinels.ts +++ b/src/diagram/drawing/creation-sentinels.ts @@ -3,23 +3,14 @@ // Version 2.0, that can be found in the LICENSE file. /** - * Sentinel UIDs used while a flow is being created or dragged. + * Sentinel UIDs for elements a gesture draws that are not in the view. * - * During flow creation the Canvas stages placeholder elements that do not yet - * exist in the persisted view; these negative UIDs mark them so the creation - * logic (Canvas rendering, `flow-attach`'s `computeFlowAttachment`) can - * recognize and later replace them with real, positive UIDs on commit. - * - * This is the single source of truth. `drawing/Canvas.tsx` and `flow-attach.ts` - * both re-export these so existing import paths keep resolving, and so the - * functional-core `flow-attach` module stays free of React/DOM imports. + * A live gesture renders placeholders the persisted view never holds; these + * negative UIDs mark them so they can never collide with an allocated uid and + * are never committed. A committed gesture allocates real uids from `nextUid`. */ -/** The in-creation flow itself (replaced with a real uid on commit). */ +/** The element an aux/stock/module tool press places until its name is typed. */ export const inCreationUid = -2; -/** The faux drag target under the cursor while reattaching an existing flow endpoint. */ +/** The zero-radius point a link previews toward while it is over no valid target. */ export const fauxTargetUid = -3; -/** The source cloud staged when a new flow is drawn out of empty space. */ -export const inCreationCloudUid = -4; -/** The faux sink target a new flow points at until it snaps to a stock/cloud. */ -export const fauxCloudTargetUid = -5; diff --git a/src/diagram/drawing/pointer-utils.ts b/src/diagram/drawing/pointer-utils.ts index 60889cd66..4c5bfd281 100644 --- a/src/diagram/drawing/pointer-utils.ts +++ b/src/diagram/drawing/pointer-utils.ts @@ -2,8 +2,6 @@ // Use of this source code is governed by the Apache License, // Version 2.0, that can be found in the LICENSE file. -import type { Point } from './common'; - /** * A pointerdown→pointerup whose cursor wobbled less than this many *screen* * pixels is a click, not a drag. Physical trackpad/mouse clicks routinely @@ -12,43 +10,3 @@ import type { Point } from './common'; * (because dragging suppresses it) leaves the variable-details panel closed. */ export const ClickDragThresholdPx = 5; - -/** - * Whether a pointer move is far enough to count as a drag rather than the - * incidental jitter of a click. - * - * `moveDelta` is in model/canvas coordinates (screen pixels divided by the - * view zoom), so we multiply by `zoom` to compare against a fixed - * screen-pixel threshold: the user moves a finger/mouse in screen space, so - * the same model-coord delta should count as a drag when zoomed in and as - * jitter when zoomed far out. - */ -export function isDragMovement(moveDelta: Point | undefined, zoom: number): boolean { - if (moveDelta === undefined) { - return false; - } - return Math.hypot(moveDelta.x, moveDelta.y) * zoom >= ClickDragThresholdPx; -} - -/** - * Determines whether to show variable details panel after a pointer interaction. - * - * We only want to show details on a pure click on an element body - not when - * dragging elements, clicking arrowheads/sources (which are for repositioning), - * or clicking on empty canvas. A sub-threshold pointer wobble during a click - * still counts as a click (see `isDragMovement`). - */ -export function shouldShowVariableDetails( - hadSelection: boolean, - moveDelta: Point | undefined, - zoom: number, - isMovingArrow: boolean, - isMovingSource: boolean, - isMovingLabel: boolean, -): boolean { - if (!hadSelection) return false; - if (isDragMovement(moveDelta, zoom)) return false; - if (isMovingArrow || isMovingSource) return false; - if (isMovingLabel) return false; - return true; -} diff --git a/src/diagram/flow-attach.ts b/src/diagram/flow-attach.ts deleted file mode 100644 index d55302a33..000000000 --- a/src/diagram/flow-attach.ts +++ /dev/null @@ -1,552 +0,0 @@ -// Copyright 2026 The Simlin Authors. All rights reserved. -// Use of this source code is governed by the Apache License, -// Version 2.0, that can be found in the LICENSE file. - -/** - * Pure functional core for flow attachment, reattachment, and creation. - * - * Extracted verbatim (behavior-preserving) from `Editor.handleFlowAttach`, - * which is the hairiest interaction path in the editor: it covers flow - * creation, endpoint reattachment, and the full cloud lifecycle - * (create/update/delete). The imperative shell in Editor.tsx now reads the - * live view/model from state, calls `computeFlowAttachment`, applies the - * returned ops via the engine, and commits the returned view + selection. - * - * The two endpoint branches (source = first point, sink = last point) were - * near-duplicate ~70-line blocks in the original. They are unified here into a - * single `reattachEndpoint` parameterized by `end`. The branches differ only - * in: - * - which endpoint of the flow is operated on (first vs last point), and - * - which idents the bookkeeping records (source-side vs sink-side), which - * in turn drive outflows (source) vs inflows (sink) operations. - * Everything else -- cloud creation/update/deletion, the move-delta math, the - * UpdateCloudAndFlow call -- is byte-identical between the two. - * - * The four `updateStockFlows` op builders in the original (source attach/detach - * touching outflows, sink attach/detach touching inflows) are unified into one - * `stockFlowsOp` helper. See `computeFlowAttachment` for the one deliberate - * normalization (collapsing exact-duplicate ops). - */ - -import { first, last } from '@simlin/core/collections'; -import { defined } from '@simlin/core/common'; -import { - CloudViewElement, - FlowViewElement, - NamedViewElement, - StockFlowView, - StockViewElement, - UID, - Variable, - ViewElement, -} from '@simlin/core/datamodel'; -import type { JsonModelOperation } from '@simlin/engine'; - -import { pinSourceToStockEdge, UpdateCloudAndFlow } from './drawing/Flow'; -// The drawing-layer Point is a bare {x, y} (no attachedToUid). cursorMoveDelta -// and fauxTargetCenter are screen-space positions/deltas, not flow points, so -// they use this type -- matching what Canvas passes to onMoveFlow. -import type { Point as CanvasPoint } from './drawing/common'; - -// Sentinel UIDs used by Canvas during flow creation, defined once in -// drawing/creation-sentinels and re-exported here so this functional-core -// module stays free of React/DOM dependencies while callers that import them -// from `../flow-attach` keep resolving. -export { inCreationUid, inCreationCloudUid, fauxCloudTargetUid } from './drawing/creation-sentinels'; -import { inCreationUid, inCreationCloudUid, fauxCloudTargetUid } from './drawing/creation-sentinels'; - -/** - * Inputs to `computeFlowAttachment`, mirroring the arguments Canvas passes to - * Editor.handleFlowAttach. - */ -export interface FlowAttachParams { - /** The flow being attached/reattached/created (may carry a sentinel uid). */ - readonly flow: FlowViewElement; - /** UID of the snap target stock/cloud, or 0 when released over empty space. */ - readonly targetUid: number; - /** Pointer delta accumulated during the drag. */ - readonly cursorMoveDelta: CanvasPoint; - /** Center of the faux target, used when a created flow ends in empty space. */ - readonly fauxTargetCenter: CanvasPoint | undefined; - /** Whether the flow is still being created (drives flowStillBeingCreated). */ - readonly inCreation: boolean; - /** True when the dragged endpoint is the source (first point). */ - readonly isSourceAttach: boolean; -} - -/** - * Result of `computeFlowAttachment`: a new view (elements + nextUid), the model - * operations to apply, and the selection/creation state to commit. This is a - * pure description of the change; the caller performs all side effects. - */ -export interface FlowAttachResult { - readonly elements: readonly ViewElement[]; - readonly nextUid: number; - readonly ops: readonly JsonModelOperation[]; - readonly selection: ReadonlySet | undefined; - readonly isCreatingNew: boolean; -} - -/** Mutable bookkeeping threaded through the endpoint reattachment logic. */ -interface AttachState { - // Sink-side idents (drive inflows operations). - stockDetachingIdent: string | undefined; - stockAttachingIdent: string | undefined; - // Source-side idents (drive outflows operations). - sourceStockIdent: string | undefined; - sourceStockDetachingIdent: string | undefined; - sourceStockAttachingIdent: string | undefined; - // The cloud uid to remove (set when a flow detaches from a cloud). - uidToDelete: number | undefined; - // A cloud whose position changed (must be substituted back into elements). - updatedCloud: ViewElement | undefined; - // Newly created clouds to append to the element list. - newClouds: ViewElement[]; - // The next free uid; incremented as clouds/flows are materialized. - nextUid: number; -} - -/** - * Reattach one endpoint (source = first point, sink = last point) of an - * existing flow. Mutates `state` for the cloud/ident bookkeeping and returns - * the updated flow element. This preserves the original two-branch behavior - * exactly; the only structural change is parameterizing on `end`. - */ -function reattachEndpoint( - element: FlowViewElement, - end: 'source' | 'sink', - params: FlowAttachParams, - getUid: (uid: number) => ViewElement, - state: AttachState, -): FlowViewElement { - const { targetUid, cursorMoveDelta, flow } = params; - - // The endpoint being moved: first point for source, last point for sink. - const oldEnd = getUid(defined((end === 'source' ? first : last)(element.points).attachedToUid)); - - let newCloud = false; - let updateCloud = false; - let endpoint: StockViewElement | CloudViewElement; - - if (targetUid) { - if (oldEnd.type === 'cloud') { - state.uidToDelete = oldEnd.uid; - } - const newTarget = getUid(targetUid); - if (newTarget.type !== 'stock' && newTarget.type !== 'cloud') { - throw new Error(`new target isn't a stock or cloud (uid ${newTarget.uid})`); - } - endpoint = newTarget; - } else if (oldEnd.type === 'cloud') { - updateCloud = true; - endpoint = { - ...oldEnd, - x: oldEnd.x - cursorMoveDelta.x, - y: oldEnd.y - cursorMoveDelta.y, - }; - } else { - // Detaching from a stock - create a new cloud at the release position. - // oldEnd.x - cursorMoveDelta.x/y places the cloud where the user - // released, not where they started. - newCloud = true; - endpoint = { - type: 'cloud' as const, - uid: state.nextUid++, - x: oldEnd.x - cursorMoveDelta.x, - y: oldEnd.y - cursorMoveDelta.y, - flowUid: flow.uid, - isZeroRadius: false, - ident: undefined, - }; - } - - if (oldEnd.uid !== endpoint.uid) { - if (oldEnd.type === 'stock') { - if (end === 'source') { - state.sourceStockDetachingIdent = oldEnd.ident; - } else { - state.stockDetachingIdent = oldEnd.ident; - } - } - if (endpoint.type === 'stock') { - if (end === 'source') { - state.sourceStockAttachingIdent = endpoint.ident; - } else { - state.stockAttachingIdent = endpoint.ident; - } - } - } - - const moveDelta = { - x: oldEnd.x - endpoint.x, - y: oldEnd.y - endpoint.y, - }; - const points = element.points.map((point) => { - if (point.attachedToUid !== oldEnd.uid) { - return point; - } - return { ...point, attachedToUid: endpoint.uid }; - }); - endpoint = { - ...endpoint, - x: oldEnd.x, - y: oldEnd.y, - } as StockViewElement | CloudViewElement; - element = { ...element, points }; - - let updatedEndpoint: StockViewElement | CloudViewElement; - [updatedEndpoint, element] = UpdateCloudAndFlow(endpoint, element, moveDelta); - if (newCloud) { - state.newClouds.push(updatedEndpoint); - } else if (updateCloud) { - state.updatedCloud = updatedEndpoint; - } - - return element; -} - -/** - * Build a single `updateStockFlows` operation that adds or removes `flowIdent` - * from the named stock's inflows or outflows. Returns undefined when the - * variable is missing or not a stock (matching the original guard). - * - * `list`/`action` collapse the four near-identical op builders from the - * original (source attach/detach -> outflows add/remove; sink attach/detach - * -> inflows add/remove). Every op carries the *full* inflow and outflow lists - * because the engine does full replacement on updateStockFlows, so the other - * list must be echoed back unchanged. - */ -function stockFlowsOp( - variables: ReadonlyMap, - stockIdent: string, - list: 'inflows' | 'outflows', - action: 'add' | 'remove', - flowIdent: string, -): JsonModelOperation | undefined { - const stockVar = variables.get(stockIdent); - if (stockVar?.type !== 'stock') { - return undefined; - } - - const mutate = (current: readonly string[]): string[] => - action === 'add' ? [...current, flowIdent] : current.filter((f) => f !== flowIdent); - - return { - type: 'updateStockFlows', - payload: { - ident: stockVar.ident, - inflows: list === 'inflows' ? mutate(stockVar.inflows) : [...stockVar.inflows], - outflows: list === 'outflows' ? mutate(stockVar.outflows) : [...stockVar.outflows], - }, - }; -} - -/** - * Compute the full result of a flow attach/reattach/create interaction. - * - * This is a behavior-preserving extraction of the body of - * Editor.handleFlowAttach up to (but not including) the engine round-trip. It - * is pure: given the current view, the active model's variables, and the - * interaction params, it returns the new elements, the operations to apply, - * and the selection/creation state. The caller owns all side effects. - * - * Throws `unknown uid ` (preserved from the original `getUid`) when an - * attachment references a uid not present in the view. - */ -export function computeFlowAttachment( - view: StockFlowView, - variables: ReadonlyMap, - params: FlowAttachParams, -): FlowAttachResult { - const { targetUid, fauxTargetCenter, isSourceAttach, cursorMoveDelta } = params; - - let selection: ReadonlySet | undefined = undefined; - let isCreatingNew = false; - - const getUid = (uid: number): ViewElement => { - for (const e of view.elements) { - if (e.uid === uid) { - return e; - } - } - throw new Error(`unknown uid ${uid}`); - }; - - const state: AttachState = { - stockDetachingIdent: undefined, - stockAttachingIdent: undefined, - sourceStockIdent: undefined, - sourceStockDetachingIdent: undefined, - sourceStockAttachingIdent: undefined, - uidToDelete: undefined, - updatedCloud: undefined, - newClouds: [], - nextUid: view.nextUid, - }; - - let flow = params.flow; - - let elements: ViewElement[] = view.elements.map((element: ViewElement) => { - if (element.uid !== flow.uid) { - return element; - } - if (element.type !== 'flow') { - return element; - } - return reattachEndpoint(element, isSourceAttach ? 'source' : 'sink', params, getUid, state); - }); - - // we might have updated some clouds - elements = elements.map((element: ViewElement) => { - if (state.updatedCloud && state.updatedCloud.uid === element.uid) { - return state.updatedCloud; - } - return element; - }); - // if we have something to delete, do it here - elements = elements.filter((e) => e.uid !== state.uidToDelete); - - if (flow.uid === inCreationUid) { - flow = { - ...flow, - uid: state.nextUid++, - }; - const firstPt = first(flow.points); - const sourceUid = firstPt.attachedToUid; - if (sourceUid === inCreationCloudUid) { - const newCloud: CloudViewElement = { - type: 'cloud', - uid: state.nextUid++, - x: firstPt.x, - y: firstPt.y, - flowUid: flow.uid, - isZeroRadius: false, - ident: undefined, - }; - elements = [...elements, newCloud]; - flow = { - ...flow, - points: flow.points.map((pt) => { - if (pt.attachedToUid === inCreationCloudUid) { - return { ...pt, attachedToUid: newCloud.uid }; - } - return pt; - }), - }; - } else if (sourceUid) { - const sourceStock = getUid(sourceUid) as StockViewElement; - state.sourceStockIdent = defined(sourceStock.ident); - } - const lastPt = last(flow.points); - if (lastPt.attachedToUid === fauxCloudTargetUid) { - if (targetUid) { - // Attaching the new flow's sink to an existing stock. Route it exactly - // the way an existing flow's endpoint reattaches (see reattachEndpoint): - // pin the sink to the stock's EDGE and keep the flow orthogonal. The - // in-creation flow's points are degenerate -- both sit at the press - // point, since the drag offset is applied only at render time and never - // committed back to the element -- so we drive UpdateCloudAndFlow from - // the current sink point: place the target at the old sink position, then - // move it to the stock by the resulting delta. UpdateCloudAndFlow picks - // the axis from that delta, aligns the sink to the source's axis, and - // clips it to the stock face (a degenerate flow stays straight). This - // replaces an earlier snap-to-center that drew the arrowhead behind the - // stock; a prior zero-delta route had instead collapsed the sink onto the - // source column, which is why the center snap was tried. - const to = getUid(targetUid) as StockViewElement | CloudViewElement; - if (to.type === 'stock') { - state.stockAttachingIdent = defined(to.ident); - } - const oldSink = last(flow.points); - flow = { - ...flow, - points: flow.points.map((pt) => - pt.attachedToUid === fauxCloudTargetUid ? { ...pt, attachedToUid: to.uid } : pt, - ), - }; - const sinkDelta = { x: oldSink.x - to.x, y: oldSink.y - to.y }; - const targetAtOldSink = { ...to, x: oldSink.x, y: oldSink.y } as StockViewElement | CloudViewElement; - // A flow sink only ever attaches to a stock (isValidTarget gates the - // canvas to stock targets), and a stock keeps its position in the view -- - // so we discard the routed endpoint and keep only the re-routed flow. - [, flow] = UpdateCloudAndFlow(targetAtOldSink, flow, sinkDelta); - } else { - let to: StockViewElement | CloudViewElement = { - type: 'cloud' as const, - uid: state.nextUid++, - x: defined(fauxTargetCenter).x, - y: defined(fauxTargetCenter).y, - flowUid: flow.uid, - isZeroRadius: false, - ident: undefined, - }; - flow = { - ...flow, - points: flow.points.map((pt) => { - if (pt.attachedToUid === fauxCloudTargetUid) { - return { ...pt, attachedToUid: to.uid }; - } - return pt; - }), - }; - // The new sink cloud is materialized at the press point; the real drag - // delta moves it (and the flow's sink) out to the release position. - [to, flow] = UpdateCloudAndFlow(to, flow, cursorMoveDelta); - elements = [...elements, to]; - } - } - // A flow drawn OUT of a stock stages its source point at the stock's - // CENTER; now that the sink is routed, pin the source onto the facing - // edge so the persisted endpoint honors the edge-attachment rule (it - // otherwise hides under the stock body until the next stock drag). - if (sourceUid !== undefined && sourceUid !== inCreationCloudUid) { - const sourceEl = getUid(sourceUid); - if (sourceEl.type === 'stock') { - flow = pinSourceToStockEdge(flow, sourceEl); - } - } - elements = [...elements, flow]; - selection = new Set([flow.uid]); - isCreatingNew = true; - } - elements = [...elements, ...state.newClouds]; - - // Build the operations. Each updateStockFlows carries the full inflow and - // outflow lists because the engine replaces them wholesale. - const rawOps: (JsonModelOperation | undefined)[] = []; - - if (isCreatingNew) { - rawOps.push({ - type: 'upsertFlow', - payload: { - flow: { - name: (flow as NamedViewElement).name, - equation: '', - }, - }, - }); - } - - // Source side -> outflows. sourceStockIdent (creation) and - // sourceStockAttachingIdent (reattach) both ADD this flow to outflows and - // are computed from the same pre-patch stockVar, so when both are set they - // produce identical ops; the dedup pass below collapses the duplicate. - if (state.sourceStockIdent) { - rawOps.push(stockFlowsOp(variables, state.sourceStockIdent, 'outflows', 'add', flow.ident)); - } - if (state.sourceStockAttachingIdent) { - rawOps.push(stockFlowsOp(variables, state.sourceStockAttachingIdent, 'outflows', 'add', flow.ident)); - } - if (state.sourceStockDetachingIdent) { - rawOps.push(stockFlowsOp(variables, state.sourceStockDetachingIdent, 'outflows', 'remove', flow.ident)); - } - // Sink side -> inflows. - if (state.stockAttachingIdent) { - rawOps.push(stockFlowsOp(variables, state.stockAttachingIdent, 'inflows', 'add', flow.ident)); - } - if (state.stockDetachingIdent) { - rawOps.push(stockFlowsOp(variables, state.stockDetachingIdent, 'inflows', 'remove', flow.ident)); - } - - // Drop ops whose variable was missing/non-stock (stockFlowsOp returned - // undefined), then collapse exact-duplicate ops. The original emitted two - // identical updateStockFlows ops when both sourceStockIdent and - // sourceStockAttachingIdent were set; under the engine's full-replacement - // semantics the second op rewrites the same content, so collapsing exact - // duplicates is a no-op in effect. This is the ONE deliberate normalization - // in this extraction -- everything else is byte-identical in effect. - const ops: JsonModelOperation[] = []; - const seen = new Set(); - for (const op of rawOps) { - if (op === undefined) { - continue; - } - const key = JSON.stringify(op); - if (seen.has(key)) { - continue; - } - seen.add(key); - ops.push(op); - } - - return { - elements, - nextUid: state.nextUid, - ops, - selection, - isCreatingNew, - }; -} - -/** - * Compute the GROWN geometry of a flow whose cloud-terminated endpoint is being - * dragged, for the live drag preview. - * - * This is the shared routing behind two visually-identical interactions: - * - flow CREATION (the flow tool stages a degenerate flow and records the drag - * only as `moveDelta`; the sink follows the cursor), and - * - dragging an EXISTING flow's cloud endpoint (source or sink) to move or - * reattach it. - * In both cases the dragged endpoint follows the cursor over empty space, or - * pins to a hovered stock's edge, while the OTHER endpoint stays fixed and the - * flow stays orthogonal. Routing both previews through `UpdateCloudAndFlow` -- - * the same function the commit (`computeFlowAttachment`/`reattachEndpoint`) - * uses -- keeps the preview identical to the committed result and makes an - * existing-cloud drag feel the same as creation (the flow line, arrowhead, and - * valve all track the cursor, not just the valve). - * - * `isSource` selects which endpoint moves (source = first point, sink = last). - * `target` is the valid stock under the cursor (or undefined over empty space). - * `moveDelta` is the Canvas convention (= press - cursor). Pure: returns a new - * flow element; the caller swaps it into the render. - */ -export function growEndpointDrag( - flow: FlowViewElement, - isSource: boolean, - moveDelta: CanvasPoint, - target: StockViewElement | CloudViewElement | undefined, -): FlowViewElement { - const endIndex = isSource ? 0 : flow.points.length - 1; - const endPt = isSource ? first(flow.points) : last(flow.points); - if (target !== undefined) { - // Hovering a valid stock: pin the endpoint to its edge -- the same routing - // the commit (computeFlowAttachment) and reattachEndpoint use. Temporarily - // attach the endpoint to the target so UpdateCloudAndFlow can route and clip - // it; the real attachment is (re)computed on release. - const attached: FlowViewElement = { - ...flow, - points: flow.points.map((pt, i) => (i === endIndex ? { ...pt, attachedToUid: target.uid } : pt)), - }; - const endDelta = { x: endPt.x - target.x, y: endPt.y - target.y }; - const targetAtOldEnd = { ...target, x: endPt.x, y: endPt.y } as StockViewElement | CloudViewElement; - return UpdateCloudAndFlow(targetAtOldEnd, attached, endDelta)[1]; - } - // Over empty space: the endpoint follows the cursor. Move the cloud (the - // endpoint's current attachment, real or faux) from its current position by - // moveDelta; UpdateCloudAndFlow picks the axis from moveDelta and keeps the - // opposite endpoint fixed. - const endCloud: CloudViewElement = { - type: 'cloud', - uid: endPt.attachedToUid ?? fauxCloudTargetUid, - flowUid: flow.uid, - x: endPt.x, - y: endPt.y, - isZeroRadius: false, - ident: undefined, - }; - return UpdateCloudAndFlow(endCloud, flow, moveDelta)[1]; -} - -/** - * Live drag preview for flow CREATION: the sink follows the cursor (or snaps to - * a hovered stock's edge) with the source fixed. A thin wrapper over - * `growEndpointDrag` (the sink is the last point, so `isSource: false`) kept as - * a named entry point for the creation call site and its tests. - */ -export function growInCreationFlow( - flow: FlowViewElement, - moveDelta: CanvasPoint, - target: StockViewElement | CloudViewElement | undefined, -): FlowViewElement { - return growEndpointDrag(flow, false, moveDelta, target); -} diff --git a/src/diagram/flow-geometry/geometry.ts b/src/diagram/flow-geometry/geometry.ts new file mode 100644 index 000000000..bf5589e6e --- /dev/null +++ b/src/diagram/flow-geometry/geometry.ts @@ -0,0 +1,155 @@ +// Copyright 2026 The Simlin Authors. All rights reserved. +// Use of this source code is governed by the Apache License, +// Version 2.0, that can be found in the LICENSE file. + +/** + * Constants, units and small geometry shared by the flow-geometry modules. + * Positions are absolute model coordinates (px at zoom 1). + */ + +import { FlowArrowheadRadius, StockHeight, StockWidth } from '../drawing/default'; + +/** A stock endpoint stays this far from its face's corners (G4). */ +export const CORNER_CLEARANCE = 3; +/** The shortest routed stub or riser (G3). */ +export const MIN_SEGMENT = 10; +/** The valve keeps this arc-length distance from the path's ends when the path is long enough (G8). */ +export const VALVE_CLAMP_MARGIN = 10; +/** + * The shortest final segment (G3). The renderer pulls the path back 7.5px to + * seat the arrowhead (`finalAdjust` in drawing/Flow.tsx); a shorter final + * segment tucks the arrowhead into the preceding turn. + */ +export const MIN_SINK_SEGMENT = FlowArrowheadRadius + 7.5; +/** Preferred distance between two endpoints on one face (a routing preference, not an invariant). */ +export const PIPE_SPACING = 10; +/** + * Coordinates within this distance are equal. Face points are fractions of the + * stock size and valves are interpolated, so exact comparison would report + * float noise; every defect the invariants exist for is a pixel or more. + */ +export const GEOMETRY_EPSILON = 1e-6; + +export const HALF_WIDTH = StockWidth / 2; +export const HALF_HEIGHT = StockHeight / 2; + +export type Face = 'left' | 'right' | 'top' | 'bottom'; +export const FACES: readonly Face[] = ['left', 'right', 'top', 'bottom']; +export type Axis = 'x' | 'y'; +export type FlowEnd = 'source' | 'sink'; + +export interface XY { + readonly x: number; + readonly y: number; +} + +export function otherAxis(axis: Axis): Axis { + return axis === 'x' ? 'y' : 'x'; +} + +/** The point whose `axis` coordinate is `v` and whose other coordinate is `w`. */ +export function compose(axis: Axis, v: number, w: number): XY { + return axis === 'x' ? { x: v, y: w } : { x: w, y: v }; +} + +export function coord(p: XY, axis: Axis): number { + return axis === 'x' ? p.x : p.y; +} + +export function clamp(v: number, lo: number, hi: number): number { + return v < lo ? lo : v > hi ? hi : v; +} + +export function distance(a: XY, b: XY): number { + return Math.hypot(a.x - b.x, a.y - b.y); +} + +export function samePoint(a: XY, b: XY): boolean { + return Math.abs(a.x - b.x) <= GEOMETRY_EPSILON && Math.abs(a.y - b.y) <= GEOMETRY_EPSILON; +} + +export function isFiniteXY(p: XY): boolean { + return Number.isFinite(p.x) && Number.isFinite(p.y); +} + +/** + * A segment's working axis. Exactly axis-aligned segments are what routing + * produces; imported data can carry a few pixels of drift, which is classified + * by its dominant axis (ties count as horizontal). + */ +export function segmentAxisOf(a: XY, b: XY): Axis { + return Math.abs(b.y - a.y) <= Math.abs(b.x - a.x) ? 'x' : 'y'; +} + +export interface Box { + readonly minX: number; + readonly maxX: number; + readonly minY: number; + readonly maxY: number; +} + +export function stockBody(stock: XY): Box { + return { + minX: stock.x - HALF_WIDTH, + maxX: stock.x + HALF_WIDTH, + minY: stock.y - HALF_HEIGHT, + maxY: stock.y + HALF_HEIGHT, + }; +} + +export function pointBody(p: XY): Box { + return { minX: p.x, maxX: p.x, minY: p.y, maxY: p.y }; +} + +export function inflate(box: Box, by: number): Box { + return { minX: box.minX - by, maxX: box.maxX + by, minY: box.minY - by, maxY: box.maxY + by }; +} + +// Touching boxes do not overlap: two bodies exactly MIN_SEGMENT apart leave +// exactly enough room for a routed stub. +export function boxesOverlap(a: Box, b: Box): boolean { + return a.minX < b.maxX && b.minX < a.maxX && a.minY < b.maxY && b.minY < a.maxY; +} + +/** + * Does a segment pass through the open interior of `box` with positive length? + * A segment starting on a face or running along an edge line is not "through". + * The positive-length tolerance is GEOMETRY_EPSILON; on orthogonal paths a larger + * one would change nothing observable, because an axis-aligned segment can only + * overlap the interior shallowly by ending inside it, where the adjacent segment + * or the endpoint check reports the crossing anyway. + */ +export function segmentThroughBox(a: XY, b: XY, box: Box): boolean { + const e = GEOMETRY_EPSILON; + if (Math.abs(a.y - b.y) <= e) { + if (!(a.y > box.minY + e && a.y < box.maxY - e)) { + return false; + } + const lo = Math.max(Math.min(a.x, b.x), box.minX + e); + const hi = Math.min(Math.max(a.x, b.x), box.maxX - e); + return hi - lo > e; + } + if (Math.abs(a.x - b.x) <= e) { + if (!(a.x > box.minX + e && a.x < box.maxX - e)) { + return false; + } + const lo = Math.max(Math.min(a.y, b.y), box.minY + e); + const hi = Math.min(Math.max(a.y, b.y), box.maxY - e); + return hi - lo > e; + } + // A diagonal is structurally invalid on its own; routing never produces one. + // Sample it so an imported diagonal still reads as crossing when it does. + for (let t = 0; t <= 1; t += 0.05) { + const x = a.x + (b.x - a.x) * t; + const y = a.y + (b.y - a.y) * t; + if (x > box.minX + e && x < box.maxX - e && y > box.minY + e && y < box.maxY - e) { + return true; + } + } + return false; +} + +export function strictlyInside(p: XY, box: Box): boolean { + const e = GEOMETRY_EPSILON; + return p.x > box.minX + e && p.x < box.maxX - e && p.y > box.minY + e && p.y < box.maxY - e; +} diff --git a/src/diagram/flow-geometry/heal.ts b/src/diagram/flow-geometry/heal.ts new file mode 100644 index 000000000..d7e34cfab --- /dev/null +++ b/src/diagram/flow-geometry/heal.ts @@ -0,0 +1,177 @@ +// Copyright 2026 The Simlin Authors. All rights reserved. +// Use of this source code is governed by the Apache License, +// Version 2.0, that can be found in the LICENSE file. + +/** + * heal: repairing a flow an edit is about to route (imported or legacy data). + */ + +import type { FlowViewElement } from '@simlin/core/datamodel'; + +import { + compose, + coord, + isFiniteXY, + otherAxis, + segmentAxisOf, + stockBody, + strictlyInside, + VALVE_CLAMP_MARGIN, + GEOMETRY_EPSILON, + type XY, +} from './geometry'; +import { applyValveMargin, arcPosition, distanceToPath, normalize, pathLength, placeValve, pointAtArc } from './path'; +import { routeBetween, route } from './route'; +import { + attachPoints, + cloudUpdates, + faceAttachment, + faceOfEndpoint, + facePoint, + type FlowGeometry, + freeTerminal, + nearestFaceAttachment, + terminalIsFinite, + type Terminals, + terminalUid, + withGeometry, +} from './terminal'; +import { FAULT_NONE, pathQuality } from './validity'; + +export interface HealContext { + /** The view's stocks: a healed cloud must not sit inside one (G6's cloud clause). */ + readonly stocks?: readonly XY[]; +} + +/** + * Repair a flow an edit is about to route. Identity on a valid flow, and + * idempotent. In order: attach the endpoints to their terminals (a stock + * endpoint off its face, or inside the corner clearance, is re-pinned to the + * nearest valid point, along the face its adjacent segment is perpendicular to + * when there is one; a cloud is moved onto its endpoint rather than the pipe + * onto the cloud, and out of any stock it would sit inside), snap + * slightly-diagonal segments along their dominant axis, normalize, and only + * when the result still violates G2-G6, re-route. The valve is re-projected onto + * the healed path and the margin applied. A non-finite terminal returns the + * flow unchanged; non-finite points (or fewer than two) route afresh. + */ +export function heal(flow: FlowViewElement, terminals: Terminals, ctx: HealContext = {}): FlowGeometry { + if (!terminalIsFinite(terminals.source) || !terminalIsFinite(terminals.sink)) { + return { flow, clouds: [] }; + } + const pts = flow.points; + if (pts.length < 2 || !pts.every(isFiniteXY)) { + return route(terminals.source, terminals.sink, { flow: { ...flow, points: [] } }); + } + if (isHealthy(flow, terminals, ctx.stocks)) { + return { flow, clouds: cloudUpdates(pts, terminals) }; + } + const healed: XY[] = pts.map((p) => ({ x: p.x, y: p.y })); + const n = healed.length; + for (const [t, index, adjacent] of [ + [terminals.source, 0, 1], + [terminals.sink, n - 1, n - 2], + ] as const) { + if (t.kind !== 'stock') { + continue; + } + const p = healed[index]; + const face = faceOfEndpoint(t.stock, p, healed[adjacent]); + if (face === undefined) { + healed[index] = nearestFaceAttachment(t.stock, p, healed[adjacent]).point; + continue; + } + const att = faceAttachment(t.stock, face); + healed[index] = facePoint(att, coord(p, att.along)); + } + const movable = (index: number): boolean => + (index > 0 && index < n - 1) || + (index === 0 && terminals.source.kind === 'free') || + (index === n - 1 && terminals.sink.kind === 'free'); + for (let i = 0; i < n - 1; i++) { + const a = healed[i]; + const b = healed[i + 1]; + if (Math.abs(a.x - b.x) <= GEOMETRY_EPSILON || Math.abs(a.y - b.y) <= GEOMETRY_EPSILON) { + continue; + } + const H = otherAxis(segmentAxisOf(a, b)); + if (movable(i + 1)) { + healed[i + 1] = compose(H, coord(a, H), coord(b, otherAxis(H))); + } else if (movable(i)) { + healed[i] = compose(H, coord(b, H), coord(a, otherAxis(H))); + } + } + if (terminals.source.kind === 'free') { + healed[0] = outOfStocks(healed[0], healed[1], ctx.stocks); + } + if (terminals.sink.kind === 'free') { + healed[n - 1] = outOfStocks(healed[n - 1], healed[n - 2], ctx.stocks); + } + // A free terminal is wherever its endpoint was healed to (its cloud follows), + // so a re-route starts from there, not from the cloud's original center: that + // center may be exactly the stock interior the endpoint was just moved out of. + const moved: Terminals = { + source: terminals.source.kind === 'free' ? freeTerminal(healed[0], terminals.source.cloud) : terminals.source, + sink: terminals.sink.kind === 'free' ? freeTerminal(healed[n - 1], terminals.sink.cloud) : terminals.sink, + }; + let points: XY[] = normalize(attachPoints(healed, moved)); + if (pathQuality(points, moved, ctx.stocks).fault !== FAULT_NONE) { + points = routeBetween(moved, { source: false, sink: false }, points, []); + } + const length = pathLength(points); + const valve = isFiniteXY(flow) + ? pointAtArc(points, applyValveMargin(length, arcPosition(points, flow))) + : placeValve(points, 'source', undefined); + return withGeometry(flow, points, valve, moved); +} + +/** + * A free endpoint inside a stock moved along its adjacent segment's axis to the + * nearer edge of that stock, so the cloud lands on the boundary (not inside, + * G6) and the segment stays orthogonal. + */ +function outOfStocks(p: XY, adjacent: XY, stocks: readonly XY[] | undefined): XY { + const stock = stocks?.find((s) => strictlyInside(p, stockBody(s))); + if (stock === undefined) { + return p; + } + const axis = segmentAxisOf(p, adjacent); + const body = stockBody(stock); + const lo = axis === 'x' ? body.minX : body.minY; + const hi = axis === 'x' ? body.maxX : body.maxY; + const v = coord(p, axis); + return compose(axis, v - lo <= hi - v ? lo : hi, coord(p, otherAxis(axis))); +} + +/** + * A flow heal leaves alone: attached to its terminals, valid (G2-G6, clouds + * outside `stocks`), and its valve on the path within the margin. A free + * terminal always sits at its own endpoint here: `flowTerminals` reads a cloud + * terminal at the cloud's center, and a cloud off its endpoint is reported (and + * moved) through `cloudUpdates` either way. + */ +function isHealthy(flow: FlowViewElement, terminals: Terminals, stocks: readonly XY[] | undefined): boolean { + const pts = flow.points; + const n = pts.length; + if ( + pts[0].attachedToUid !== terminalUid(terminals.source) || + pts[n - 1].attachedToUid !== terminalUid(terminals.sink) + ) { + return false; + } + if (pts.some((p, i) => i > 0 && i < n - 1 && p.attachedToUid !== undefined)) { + return false; + } + if (pathQuality(pts, terminals, stocks).fault !== FAULT_NONE || !isFiniteXY(flow)) { + return false; + } + if (distanceToPath(pts, flow) > GEOMETRY_EPSILON) { + return false; + } + const length = pathLength(pts); + if (length < 2 * VALVE_CLAMP_MARGIN) { + return true; + } + const s = arcPosition(pts, flow); + return Math.min(s, length - s) >= VALVE_CLAMP_MARGIN - GEOMETRY_EPSILON; +} diff --git a/src/diagram/flow-geometry/index.ts b/src/diagram/flow-geometry/index.ts new file mode 100644 index 000000000..f4c890431 --- /dev/null +++ b/src/diagram/flow-geometry/index.ts @@ -0,0 +1,70 @@ +// Copyright 2026 The Simlin Authors. All rights reserved. +// Use of this source code is governed by the Apache License, +// Version 2.0, that can be found in the LICENSE file. + +/** + * The flow geometry core: one owner for how a flow's pipe attaches to stocks, + * how it is routed, how a segment is offset, and where its valve sits. Pure: no + * React, no DOM, never mutates its inputs. + * + * The invariants this module maintains are G2-G8 of + * docs/design-plans/2026-09-10-diagram-editing-core.md. Positions are absolute + * model coordinates. A terminal is either a stock (attached through one of its + * four faces) or a free point (a cloud, or the pointer during a drag); the + * stored cloud endpoint is the cloud's center, as the renderer expects. + * + * Every routing decision reads the gesture's BASE flow (the flow as it was when + * the gesture started) rather than a previous frame, so a planner can evaluate + * any frame at any pointer position and get the same answer: preview and commit + * cannot diverge, and continuity is a property of these functions, not of the + * order frames were computed in. + * + * Modules: `geometry` (constants, units, boxes), `terminal` (face attachment and + * terminals), `validity` (G2-G6 classification), `path` (normalize, arc length, + * the valve, translate, slideValve), `route` (route, routeEnd), `offset-segment` + * and `heal`. + */ + +export { + CORNER_CLEARANCE, + FACES, + GEOMETRY_EPSILON, + MIN_SEGMENT, + MIN_SINK_SEGMENT, + PIPE_SPACING, + VALVE_CLAMP_MARGIN, + type Axis, + type Face, + type FlowEnd, + type XY, +} from './geometry'; +export { + faceAttachment, + faceOfEndpoint, + facePoint, + flowTerminals, + freeTerminal, + nearestFaceAttachment, + stockTerminal, + stubTip, + type FaceAttachment, + type FlowGeometry, + type FreeTerminal, + type StockTerminal, + type Terminal, + type Terminals, +} from './terminal'; +export { flowFault, type RouteFault } from './validity'; +export { + arcPosition, + normalize, + pathLength, + placeValve, + pointAtArc, + slideValve, + translate, + valveDistance, +} from './path'; +export { route, routeEnd, type RouteContext, type RouteEndContext } from './route'; +export { offsetSegment, segmentHold, type OffsetContext } from './offset-segment'; +export { heal, type HealContext } from './heal'; diff --git a/src/diagram/flow-geometry/offset-segment.ts b/src/diagram/flow-geometry/offset-segment.ts new file mode 100644 index 000000000..6876d874b --- /dev/null +++ b/src/diagram/flow-geometry/offset-segment.ts @@ -0,0 +1,406 @@ +// Copyright 2026 The Simlin Authors. All rights reserved. +// Use of this source code is governed by the Apache License, +// Version 2.0, that can be found in the LICENSE file. + +/** + * offsetSegment: moving one segment of a flow perpendicular to itself. + */ + +import type { FlowViewElement, Point } from '@simlin/core/datamodel'; + +import { + type Axis, + clamp, + compose, + coord, + distance, + type FlowEnd, + GEOMETRY_EPSILON, + isFiniteXY, + MIN_SEGMENT, + MIN_SINK_SEGMENT, + otherAxis, + samePoint, + segmentAxisOf, + type XY, +} from './geometry'; +import { applyValveMargin, arcPosition, normalize, pathLength, placeValve, pointAtArc } from './path'; +import { + attachPoints, + type FaceAttachment, + faceAttachment, + faceOfEndpoint, + type FlowGeometry, + stubTip, + terminalIsFinite, + type Terminals, + withGeometry, +} from './terminal'; +import { FAULT_NONE, pathQuality } from './validity'; + +export interface OffsetContext { + /** + * The view's stocks. A cloud moved with its segment must not land inside one + * (G6), and the dragged path steps around every one that is not a terminal: a + * pipe through a stock reads as attached to it. + */ + readonly stocks?: readonly XY[]; +} + +/** The axis segment `segmentIndex` runs along, and the coordinate it holds constant (the one offsetSegment sets). */ +export function segmentHold( + points: readonly XY[], + segmentIndex: number, +): { readonly axis: Axis; readonly hold: number } { + const a = points[segmentIndex]; + const b = points[segmentIndex + 1]; + const axis = segmentAxisOf(a, b); + return { axis, hold: coord(a, otherAxis(axis)) }; +} + +// How far past the request offsetSegment looks for the far side of an obstacle +// (a stock the segment or its cloud would enter): more than any stock's extent. +const OBSTACLE_SCAN = 120; +const OBSTACLE_STEP = 2; + +/** + * Move segment `segmentIndex` perpendicular to itself so it holds `coordinate`. + * + * The coordinate is first resolved against the adjacent segments: a stock stub + * keeps at least MIN_SEGMENT (MIN_SINK_SEGMENT at the sink), and an adjacent + * riser or cloud segment is either at least its minimum or collapsed to zero + * (removing that corner). These constraints are solved jointly, as the feasible + * coordinate nearest the request, so a short riser collapses when the request is + * within half a minimum of collapsing and is pushed out to the minimum otherwise. + * When the resolved coordinate still puts the path through a terminal body, a + * cloud inside a stock, or the body of any other stock in `ctx.stocks`, the + * nearest valid coordinate on either side of that obstacle is taken: the segment + * follows the pointer up to the obstacle and jumps across it once the far side is + * nearer (a documented feasibility transition). + * + * At a terminal the tail is re-solved: a cloud moves with the segment; a stock + * endpoint sits at the coordinate clamped to the face extent (minus + * CORNER_CLEARANCE). While the coordinate is within MIN_SEGMENT beyond the + * extent the segment stays at the extent; beyond that a stub plus a riser join + * the endpoint to the segment. A tail that is already endpoint -> stub (at most + * the end's minimum long) -> riser, with the dragged segment following the + * riser, is re-solved the same way, so a bracket dragged back collapses to + * straight and stubs never accumulate. + * + * The valve stays where it is along its own segment while that segment + * survives (the dragged segment moves with the valve on it), clamped into the + * segment's new span; when its segment is removed (a collapsed riser) it moves to + * the nearest point of the new path, so it jumps no further than the path did. A + * non-finite coordinate or terminal returns the base flow unchanged. + */ +export function offsetSegment( + flow: FlowViewElement, + segmentIndex: number, + coordinate: number, + terminals: Terminals, + ctx: OffsetContext = {}, +): FlowGeometry { + const pts = flow.points; + if ( + segmentIndex < 0 || + segmentIndex >= pts.length - 1 || + !Number.isFinite(coordinate) || + !terminalIsFinite(terminals.source) || + !terminalIsFinite(terminals.sink) || + pts.length < 2 || + !pts.every(isFiniteXY) || + samePoint(pts[segmentIndex], pts[segmentIndex + 1]) + ) { + return { flow, clouds: [] }; + } + const tails = { + source: resolvableTail(pts, segmentIndex, 'source', terminals), + sink: resolvableTail(pts, segmentIndex, 'sink', terminals), + }; + const constraints = adjacentConstraints(pts, segmentIndex, terminals, tails); + const resolve = (c: number): number => resolveCoordinate(c, constraints); + // Validity is judged on the normalized path: a riser collapsed to zero length + // is a corner removed, not a zero-length segment. + const build = (c: number): Point[] => normalize(attachPoints(buildOffset(pts, segmentIndex, c, tails), terminals)); + const clear = (points: readonly XY[]): boolean => { + const quality = pathQuality(points, terminals, ctx.stocks, ctx.stocks); + return quality.fault === FAULT_NONE && !quality.obstructed; + }; + const valid = (c: number): boolean => clear(build(c)); + let c = resolve(coordinate); + if (!valid(c) && clear(pts)) { + c = nearestValid(segmentHold(pts, segmentIndex).hold, c, resolve, valid); + } + const points = build(c); + const valve = offsetValve(flow, segmentIndex, points, effectiveHold(c, tails)); + return withGeometry(flow, points, valve, terminals); +} + +/** + * The valid coordinate nearest `request`, looking back toward the (valid) base + * and past the obstacle, each side found by bisection on the resolved + * coordinate. + */ +function nearestValid( + baseHold: number, + request: number, + resolve: (c: number) => number, + valid: (c: number) => boolean, +): number { + const bisect = (good: number, bad: number): number => { + for (let iteration = 0; iteration < 32; iteration++) { + const mid = (good + bad) / 2; + if (valid(resolve(mid))) { + good = mid; + } else { + bad = mid; + } + } + return resolve(good); + }; + const back = bisect(baseHold, request); + const sign = Math.sign(request - baseHold) || 1; + for (let step = OBSTACLE_STEP; step <= OBSTACLE_SCAN; step += OBSTACLE_STEP) { + const probe = request + sign * step; + if (valid(resolve(probe))) { + const across = bisect(probe, request); + return Math.abs(across - request) < Math.abs(back - request) ? across : back; + } + } + return back; +} + +/** + * The face whose tail offsetSegment re-solves at `end`, or undefined when the + * tail is kept. A stock tail is re-solved when the dragged segment is the one + * leaving the face, or when it follows a stub (at most the end's minimum long) + * and a riser. + */ +function resolvableTail(pts: readonly XY[], i: number, end: FlowEnd, terminals: Terminals): FaceAttachment | undefined { + const t = end === 'source' ? terminals.source : terminals.sink; + if (t.kind !== 'stock') { + return undefined; + } + const n = pts.length; + const endpoint = end === 'source' ? pts[0] : pts[n - 1]; + const adjacent = end === 'source' ? pts[1] : pts[n - 2]; + const face = faceOfEndpoint(t.stock, endpoint, adjacent); + if (face === undefined) { + return undefined; + } + const att = faceAttachment(t.stock, face); + if (segmentAxisOf(pts[i], pts[i + 1]) !== att.normal) { + return undefined; + } + const fromEnd = end === 'source' ? i : n - 2 - i; + if (fromEnd === 0) { + return att; + } + const minimum = end === 'source' ? MIN_SEGMENT : MIN_SINK_SEGMENT; + if (fromEnd === 2 && distance(endpoint, adjacent) <= minimum + GEOMETRY_EPSILON) { + return att; + } + return undefined; +} + +/** + * The hold a re-solved stock tail gives the dragged segment: the coordinate + * itself within the face extent, the extent while within MIN_SEGMENT beyond it, + * and the coordinate again past that (where a stub and riser appear). + */ +function tailHold(att: FaceAttachment, c: number): number { + if (c > att.hi && c <= att.hi + MIN_SEGMENT) { + return att.hi; + } + if (c < att.lo && c >= att.lo - MIN_SEGMENT) { + return att.lo; + } + return c; +} + +interface Tails { + readonly source: FaceAttachment | undefined; + readonly sink: FaceAttachment | undefined; +} + +/** The hold the dragged segment actually takes for coordinate `c`, after each re-solved tail's extent band. */ +function effectiveHold(c: number, tails: Tails): number { + let hold = c; + if (tails.source !== undefined) { + hold = tailHold(tails.source, hold); + } + if (tails.sink !== undefined) { + hold = tailHold(tails.sink, hold); + } + return hold; +} + +interface Constraints { + /** The coordinate must be at least `from` in direction `sign` (a stock stub's minimum). */ + readonly halves: ReadonlyArray<{ readonly from: number; readonly sign: 1 | -1 }>; + /** The coordinate must be at `t` (collapsed) or at least `m` from it (a riser or cloud segment). */ + readonly aways: ReadonlyArray<{ readonly t: number; readonly m: number }>; +} + +function adjacentConstraints(pts: readonly XY[], i: number, terminals: Terminals, tails: Tails): Constraints { + const n = pts.length; + const last = n - 2; + const { axis } = segmentHold(pts, i); + const H = otherAxis(axis); + const halves: Array<{ from: number; sign: 1 | -1 }> = []; + const aways: Array<{ t: number; m: number }> = []; + const adjacent = (neighbor: number, fixedIndex: number, end: FlowEnd | undefined): void => { + const t = end === 'source' ? terminals.source : end === 'sink' ? terminals.sink : undefined; + const minimum = end === 'sink' ? MIN_SINK_SEGMENT : MIN_SEGMENT; + if (t?.kind === 'stock') { + const face = faceOfEndpoint(t.stock, pts[fixedIndex], pts[neighbor]); + if (face !== undefined) { + const att = faceAttachment(t.stock, face); + halves.push({ from: stubTip(att, minimum), sign: att.sign }); + return; + } + } + aways.push({ t: coord(pts[fixedIndex], H), m: minimum }); + }; + if (tails.source === undefined && i >= 1) { + adjacent(i, i - 1, i - 1 === 0 ? 'source' : undefined); + } + if (tails.sink === undefined && i <= last - 1) { + adjacent(i + 1, i + 2, i + 1 === last ? 'sink' : undefined); + } + return { halves, aways }; +} + +/** + * The coordinate nearest `c` satisfying every constraint. The candidates are + * `c` clamped by the half-lines and each away constraint's collapse point and + * its two minimum positions; when none satisfies everything (the constraints + * contradict), the clamped request is returned and validity rejects the path. + */ +function resolveCoordinate(c: number, constraints: Constraints): number { + const e = GEOMETRY_EPSILON; + const clampHalves = (v: number): number => { + for (const h of constraints.halves) { + v = h.sign > 0 ? Math.max(v, h.from) : Math.min(v, h.from); + } + return v; + }; + const satisfies = (v: number): boolean => + constraints.halves.every((h) => h.sign * (v - h.from) >= -e) && + constraints.aways.every((a) => Math.abs(v - a.t) <= e || Math.abs(v - a.t) >= a.m - e); + const clamped = clampHalves(c); + if (satisfies(clamped)) { + return clamped; + } + const candidates = constraints.aways.flatMap((a) => [a.t, a.t - a.m, a.t + a.m]).filter(satisfies); + if (candidates.length === 0) { + return clamped; + } + return candidates.reduce((best, v) => (Math.abs(v - c) < Math.abs(best - c) ? v : best)); +} + +/** + * The path with segment `i` at hold `c`. A free terminal's endpoint on the + * dragged segment moves with it (its cloud follows the endpoint); a re-solved + * stock tail is rebuilt from the face attachment. + */ +function buildOffset(pts: readonly XY[], i: number, c: number, tails: Tails): XY[] { + const n = pts.length; + const last = n - 2; + const { axis } = segmentHold(pts, i); + const hold = effectiveHold(c, tails); + const head: XY[] = []; + let u: XY; + if (tails.source !== undefined) { + const att = tails.source; + const along = clamp(hold, att.lo, att.hi); + const endpoint = compose(att.along, along, att.plane); + if (Math.abs(along - hold) <= GEOMETRY_EPSILON) { + u = endpoint; + } else { + const tip = stubTip(att, MIN_SEGMENT); + head.push(endpoint, compose(att.normal, tip, along)); + u = compose(att.normal, tip, hold); + } + } else if (i === 0) { + u = compose(axis, pts[0][axis], hold); + } else { + head.push(...pts.slice(0, i)); + u = compose(axis, coord(pts[i], axis), hold); + } + const tail: XY[] = []; + let v: XY; + if (tails.sink !== undefined) { + const att = tails.sink; + const along = clamp(hold, att.lo, att.hi); + const endpoint = compose(att.along, along, att.plane); + if (Math.abs(along - hold) <= GEOMETRY_EPSILON) { + v = endpoint; + } else { + const tip = stubTip(att, MIN_SINK_SEGMENT); + v = compose(att.normal, tip, hold); + tail.push(compose(att.normal, tip, along), endpoint); + } + } else if (i === last) { + v = compose(axis, pts[n - 1][axis], hold); + } else { + v = compose(axis, coord(pts[i + 1], axis), hold); + tail.push(...pts.slice(i + 2)); + } + return [...head, u, v, ...tail]; +} + +/** + * The valve after an offset. Its base segment j survives when the new path has + * a segment on the same axis holding the same coordinate (for j = i, the new + * hold `c`) whose span overlaps j's: the valve keeps its coordinate along that + * axis, clamped into the surviving segment's span. Otherwise it moves to the + * nearest point of the new path. + */ +function offsetValve(base: FlowViewElement, i: number, points: readonly XY[], c: number): XY { + const pts = base.points; + if (!isFiniteXY(base)) { + return placeValve(points, 'source', undefined); + } + const s0 = arcPosition(pts, base); + let start = 0; + let j = 0; + for (; j < pts.length - 2; j++) { + const length = distance(pts[j], pts[j + 1]); + if (s0 <= start + length) { + break; + } + start += length; + } + const { axis, hold } = segmentHold(pts, j); + const newHold = j === i ? c : hold; + const along = coord(base, axis); + const spanLo = Math.min(coord(pts[j], axis), coord(pts[j + 1], axis)); + const spanHi = Math.max(coord(pts[j], axis), coord(pts[j + 1], axis)); + let best: XY | undefined; + let bestGap = Infinity; + for (let k = 0; k < points.length - 1; k++) { + const a = points[k]; + const b = points[k + 1]; + if ( + samePoint(a, b) || + segmentAxisOf(a, b) !== axis || + Math.abs(coord(a, otherAxis(axis)) - newHold) > GEOMETRY_EPSILON + ) { + continue; + } + const lo = Math.min(coord(a, axis), coord(b, axis)); + const hi = Math.max(coord(a, axis), coord(b, axis)); + if (Math.min(hi, spanHi) - Math.max(lo, spanLo) < -GEOMETRY_EPSILON) { + continue; + } + const at = clamp(along, lo, hi); + const gap = Math.abs(at - along); + if (gap < bestGap) { + bestGap = gap; + best = compose(axis, at, newHold); + } + } + const at = best ?? { x: base.x, y: base.y }; + return pointAtArc(points, applyValveMargin(pathLength(points), arcPosition(points, at))); +} diff --git a/src/diagram/flow-geometry/path.ts b/src/diagram/flow-geometry/path.ts new file mode 100644 index 000000000..5b2bba5f2 --- /dev/null +++ b/src/diagram/flow-geometry/path.ts @@ -0,0 +1,266 @@ +// Copyright 2026 The Simlin Authors. All rights reserved. +// Use of this source code is governed by the Apache License, +// Version 2.0, that can be found in the LICENSE file. + +/** + * Paths and the valve: normalize, arc length, the valve policy (an arc-length + * position measured from an end, margin applied once), translate and + * slideValve. + */ + +import type { FlowViewElement, Point } from '@simlin/core/datamodel'; + +import { clamp, distance, type FlowEnd, GEOMETRY_EPSILON, isFiniteXY, VALVE_CLAMP_MARGIN, type XY } from './geometry'; + +/** + * Remove zero-length segments and collinear interior points (G3's structural + * arms). Endpoints are always kept: they carry the attachments. Removing one + * point can make its neighbors collinear, so this repeats to a fixed point. + * Inputs are orthogonal (heal snaps diagonals first), where a zero-length + * segment is always also collinear with its neighbor, so one test covers both. + */ +export function normalize(points: readonly Point[]): Point[] { + let current = [...points]; + for (;;) { + const next = normalizeOnce(current); + if (next.length === current.length) { + return next; + } + current = next; + } +} + +function normalizeOnce(points: readonly Point[]): Point[] { + if (points.length <= 2) { + return [...points]; + } + const e = GEOMETRY_EPSILON; + const result: Point[] = [points[0]]; + for (let i = 1; i < points.length - 1; i++) { + const prev = result[result.length - 1]; + const curr = points[i]; + const next = points[i + 1]; + const horizontal = Math.abs(prev.y - curr.y) <= e && Math.abs(curr.y - next.y) <= e; + const vertical = Math.abs(prev.x - curr.x) <= e && Math.abs(curr.x - next.x) <= e; + if (horizontal || vertical) { + continue; + } + result.push(curr); + } + result.push(points[points.length - 1]); + return result; +} + +export function pathLength(points: readonly XY[]): number { + let total = 0; + for (let i = 0; i < points.length - 1; i++) { + total += distance(points[i], points[i + 1]); + } + return total; +} + +/** The arc-length position of the point on the path nearest to `p` (the earliest one on a tie). */ +export function arcPosition(points: readonly XY[], p: XY): number { + let best = Infinity; + let position = 0; + let traversed = 0; + for (let i = 0; i < points.length - 1; i++) { + const a = points[i]; + const b = points[i + 1]; + const length = distance(a, b); + const t = + length === 0 ? 0 : clamp(((p.x - a.x) * (b.x - a.x) + (p.y - a.y) * (b.y - a.y)) / (length * length), 0, 1); + const d = Math.hypot(p.x - (a.x + t * (b.x - a.x)), p.y - (a.y + t * (b.y - a.y))); + if (d < best - GEOMETRY_EPSILON) { + best = d; + position = traversed + t * length; + } + traversed += length; + } + return position; +} + +/** The distance from `p` to the nearest point on the path. */ +export function distanceToPath(points: readonly XY[], p: XY): number { + let best = Infinity; + for (let i = 0; i < points.length - 1; i++) { + const a = points[i]; + const b = points[i + 1]; + const length = distance(a, b); + const t = + length === 0 ? 0 : clamp(((p.x - a.x) * (b.x - a.x) + (p.y - a.y) * (b.y - a.y)) / (length * length), 0, 1); + best = Math.min(best, Math.hypot(p.x - (a.x + t * (b.x - a.x)), p.y - (a.y + t * (b.y - a.y)))); + } + return best; +} + +/** The point at arc length `s` along the path, clamped to the path. */ +export function pointAtArc(points: readonly XY[], s: number): XY { + if (points.length === 1 || s <= 0) { + return { x: points[0].x, y: points[0].y }; + } + let remaining = s; + for (let i = 0; i < points.length - 1; i++) { + const a = points[i]; + const b = points[i + 1]; + const length = distance(a, b); + if (remaining <= length) { + const t = length === 0 ? 0 : remaining / length; + return { x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t }; + } + remaining -= length; + } + const last = points[points.length - 1]; + return { x: last.x, y: last.y }; +} + +/** + * The valve's arc-length distance from `from` on the base path, or undefined + * when the base path has no length (a creation draft), which places the valve + * at the midpoint of whatever path is routed. The margin is NOT applied here. + */ +export function valveDistance(points: readonly XY[], valve: XY, from: FlowEnd): number | undefined { + const length = pathLength(points); + if (length <= GEOMETRY_EPSILON || !isFiniteXY(valve)) { + return undefined; + } + const s = arcPosition(points, valve); + return from === 'source' ? s : length - s; +} + +/** + * Place the valve `distance` along the path from `from`, clamped to the path, + * then apply VALVE_CLAMP_MARGIN. The margin is applied once, here, at the end: + * applying it while measuring would move a valve that sat inside the margin on + * the base path further inward whenever the path grows at the far end. + */ +export function placeValve(points: readonly XY[], from: FlowEnd, distanceFromEnd: number | undefined): XY { + const length = pathLength(points); + let s: number; + if (distanceFromEnd === undefined) { + s = length / 2; + } else { + s = clamp(from === 'source' ? distanceFromEnd : length - distanceFromEnd, 0, length); + } + return pointAtArc(points, applyValveMargin(length, s)); +} + +/** + * G8's margin. At exactly two margins long the only valid position is the + * midpoint, which the clamp also produces, so the `<` boundary is a choice + * without consequence. + */ +export function applyValveMargin(length: number, s: number): number { + if (length < 2 * VALVE_CLAMP_MARGIN) { + return length / 2; + } + return clamp(s, VALVE_CLAMP_MARGIN, length - VALVE_CLAMP_MARGIN); +} + +/** + * Move a flow whose two terminals both move by `delta`: every point and the + * valve translate. The caller moves the terminal elements (selected clouds and + * stocks translate as positioned elements), so there are no clouds to report. + * A non-finite delta returns the flow unchanged. + */ +export function translate(flow: FlowViewElement, delta: XY): FlowViewElement { + if (!isFiniteXY(delta)) { + return flow; + } + return { + ...flow, + x: flow.x + delta.x, + y: flow.y + delta.y, + points: flow.points.map((p) => ({ ...p, x: p.x + delta.x, y: p.y + delta.y })), + }; +} + +/** + * Slide the valve along the path by the pointer delta projected onto the path. + * + * The delta is applied as if the pointer traveled straight from the press: on + * each segment the valve moves at the rate the delta projects onto that + * segment's direction, and when it reaches a corner it continues onto the next + * segment with the time that is left, if the delta projects forward along it + * (otherwise it rests at the corner). Carrying the remaining TIME rather than + * the remaining vector is what keeps this continuous: a delta component + * perpendicular to the valve's segment is never banked and released all at + * once when the corner is reached. The valve crosses corners instead of hopping + * to whichever segment is nearest; it lags the pointer at a corner by design, + * and `delta` is measured from the press, so the grab offset is kept. The path + * changes nothing a cloud sits on, so there are no clouds to report. + */ +export function slideValve(flow: FlowViewElement, delta: XY): FlowViewElement { + const pts = flow.points; + const segments: Array<{ start: number; length: number; tx: number; ty: number }> = []; + let traversed = 0; + for (let i = 0; i < pts.length - 1; i++) { + const length = distance(pts[i], pts[i + 1]); + if (length > GEOMETRY_EPSILON) { + segments.push({ + start: traversed, + length, + tx: (pts[i + 1].x - pts[i].x) / length, + ty: (pts[i + 1].y - pts[i].y) / length, + }); + } + traversed += length; + } + if (segments.length === 0 || !isFiniteXY(delta) || !pts.every(isFiniteXY)) { + return flow; + } + const total = traversed; + let pos = isFiniteXY(flow) ? arcPosition(pts, flow) : total / 2; + const rate = (j: number): number => delta.x * segments[j].tx + delta.y * segments[j].ty; + let j = segmentAtArc(segments, pos); + // A valve exactly on a corner belongs to whichever adjacent segment the delta moves it along. + if ( + Math.abs(rate(j)) <= GEOMETRY_EPSILON && + j + 1 < segments.length && + pos >= segments[j + 1].start - GEOMETRY_EPSILON + ) { + j++; + } + let time = 1; + while (time > 0) { + const seg = segments[j]; + const v = rate(j); + // Only a neighbor the delta still moves the valve along is entered, in either + // direction. The forward check is redundant with the other branches: a + // neighbor pointing back is entered with no time spent and stops the valve at + // the corner, since the backward branch's own guard sees the segment it came + // from moving forward; a perpendicular neighbor stops it in the final branch. + if (v > GEOMETRY_EPSILON) { + const need = (seg.start + seg.length - pos) / v; + if (need >= time || j + 1 >= segments.length || rate(j + 1) <= GEOMETRY_EPSILON) { + pos = Math.min(pos + v * time, seg.start + seg.length); + break; + } + pos = seg.start + seg.length; + time -= need; + j++; + } else if (v < -GEOMETRY_EPSILON) { + const need = (pos - seg.start) / -v; + if (need >= time || j === 0 || rate(j - 1) >= -GEOMETRY_EPSILON) { + pos = Math.max(pos + v * time, seg.start); + break; + } + pos = seg.start; + time -= need; + j--; + } else { + break; + } + } + const valve = pointAtArc(pts, applyValveMargin(total, pos)); + return { ...flow, x: valve.x, y: valve.y }; +} + +function segmentAtArc(segments: ReadonlyArray<{ start: number; length: number }>, s: number): number { + for (let i = 0; i < segments.length; i++) { + if (s <= segments[i].start + segments[i].length) { + return i; + } + } + return segments.length - 1; +} diff --git a/src/diagram/flow-geometry/route.ts b/src/diagram/flow-geometry/route.ts new file mode 100644 index 000000000..65811f047 --- /dev/null +++ b/src/diagram/flow-geometry/route.ts @@ -0,0 +1,898 @@ +// Copyright 2026 The Simlin Authors. All rights reserved. +// Use of this source code is governed by the Apache License, +// Version 2.0, that can be found in the LICENSE file. + +/** + * Route search: `route` and `routeEnd`. + * + * A route between two ports is an orthogonal polyline with k bends. Its + * segments alternate axes starting with A0, so it is fully described by the + * coordinate each segment HOLDS constant: h0 (the source endpoint's coordinate + * across A0), interior holds h1..h(k-1), and hk (the sink endpoint's across the + * last axis). For a face port h0/hk is the endpoint's position along the face, + * the only free coordinate an attached endpoint has. Interior holds come from + * candidates that are continuous functions of the terminals and belong to the + * port pair being generated (so a tie between two pairs never switches on a + * third pair's feasibility); face positions are solved from a preference. The + * winning route therefore changes discontinuously only when the ranking changes + * winner: when feasibility changes, which E3 documents. + */ + +import type { FlowViewElement } from '@simlin/core/datamodel'; + +import { + type Axis, + type Box, + boxesOverlap, + clamp, + compose, + CORNER_CLEARANCE, + coord, + FACES, + type FlowEnd, + GEOMETRY_EPSILON, + inflate, + isFiniteXY, + MIN_SEGMENT, + MIN_SINK_SEGMENT, + otherAxis, + PIPE_SPACING, + segmentAxisOf, + stockBody, + type XY, +} from './geometry'; +import { normalize, pathLength, placeValve, valveDistance } from './path'; +import { + type FaceAttachment, + faceAttachment, + facePoint, + type FlowGeometry, + stubTip, + type Terminal, + terminalIsFinite, + type Terminals, + withGeometry, +} from './terminal'; +import { FAULT_NONE, pathQuality } from './validity'; + +interface FacePort { + readonly kind: 'face'; + readonly att: FaceAttachment; + /** The preferred along-face position, within [lo, hi]. */ + readonly pref: number; + readonly pinned: boolean; + /** The terminal had a base face and this is not it. */ + readonly offBase: boolean; +} + +interface PointPort { + readonly kind: 'point'; + readonly point: XY; + /** The axis the adjacent segment must run along (a preserved corner), or undefined (any). */ + readonly axis: Axis | undefined; +} + +type Port = FacePort | PointPort; + +/** + * The ports a terminal offers. A pinned stock terminal offers only its base face + * at its base offset (routeEnd's fixed end). An unpinned stock offers all four + * faces: the base face prefers the base offset, any other face the slot the + * routing preference picks. + */ +function portsOf(t: Terminal, pinned: boolean, occupied: readonly XY[]): Port[] { + if (t.kind === 'free') { + return [{ kind: 'point', point: t.point, axis: undefined }]; + } + const ports: FacePort[] = []; + for (const face of FACES) { + if (pinned && t.face !== undefined && face !== t.face) { + continue; + } + const att = faceAttachment(t.stock, face); + const isBase = t.face === face; + const pref = + isBase && t.offset !== undefined ? clamp(att.center + t.offset, att.lo, att.hi) : slotPreference(att, occupied); + ports.push({ kind: 'face', att, pref, pinned: pinned && isBase, offBase: t.face !== undefined && !isBase }); + } + return ports; +} + +/** + * The routing preference for a flow newly landing on a face: the position + * nearest the face center at least PIPE_SPACING from every existing endpoint on + * the face, else the position maximizing the minimum distance to them. + */ +export function slotPreference(att: FaceAttachment, occupied: readonly XY[]): number { + const used = occupied + .filter( + (p) => + Math.abs(coord(p, att.normal) - att.plane) <= GEOMETRY_EPSILON && + coord(p, att.along) >= att.lo - CORNER_CLEARANCE - GEOMETRY_EPSILON && + coord(p, att.along) <= att.hi + CORNER_CLEARANCE + GEOMETRY_EPSILON, + ) + .map((p) => coord(p, att.along)); + if (used.length === 0) { + return att.center; + } + const minDistance = (v: number): number => Math.min(...used.map((u) => Math.abs(u - v))); + const inRange = (v: number): boolean => v >= att.lo - GEOMETRY_EPSILON && v <= att.hi + GEOMETRY_EPSILON; + const nearestCenter = (vs: readonly number[]): number => + vs.reduce((best, v) => (Math.abs(v - att.center) < Math.abs(best - att.center) ? v : best)); + const candidates = [att.center, att.lo, att.hi, ...used.flatMap((u) => [u - PIPE_SPACING, u + PIPE_SPACING])]; + const spaced = candidates.filter((v) => inRange(v) && minDistance(v) >= PIPE_SPACING - GEOMETRY_EPSILON); + if (spaced.length > 0) { + return nearestCenter(spaced); + } + const sorted = [...used].sort((a, b) => a - b); + const gaps = [att.lo, att.hi, ...sorted.slice(1).map((u, i) => (u + sorted[i]) / 2)].filter(inRange); + const best = Math.max(...gaps.map(minDistance)); + return nearestCenter(gaps.filter((v) => minDistance(v) >= best - GEOMETRY_EPSILON)); +} + +type AlongConstraint = + | { readonly kind: 'half'; readonly from: number; readonly sign: 1 | -1 } + | { readonly kind: 'away'; readonly t: number; readonly m: number }; + +/** + * The along-face position nearest `pref` that satisfies `c`: a half-line (the + * position must be at least `from` in direction `sign`) or a minimum distance + * from `t`. Infeasible constraints return the clamped preference, which the + * validity check then rejects. + */ +function solveAlong(port: FacePort, pref: number, c: AlongConstraint): number { + if (port.pinned) { + return port.pref; + } + const { lo, hi } = port.att; + const v = clamp(pref, lo, hi); + if (c.kind === 'half') { + const a = c.sign > 0 ? Math.max(lo, c.from) : lo; + const b = c.sign < 0 ? Math.min(hi, c.from) : hi; + return a <= b ? clamp(v, a, b) : v; + } + if (Math.abs(v - c.t) >= c.m - GEOMETRY_EPSILON) { + return v; + } + const options = [c.t - c.m, c.t + c.m].filter((o) => o >= lo - GEOMETRY_EPSILON && o <= hi + GEOMETRY_EPSILON); + if (options.length === 0) { + return v; + } + return options.reduce((best, o) => (Math.abs(o - v) < Math.abs(best - v) ? o : best)); +} + +interface Candidate { + readonly points: XY[]; + readonly bends: number; + readonly sticky: number; + readonly axisChange: number; + readonly length: number; + readonly fault: number; + /** A best effort G6 miss on a valid candidate (see PathQuality), honored as a preference. */ + readonly crossing: boolean; + /** Passes through a non-terminal stock (see PathQuality); worth generating detours to avoid. */ + readonly obstructed: boolean; + readonly index: number; +} + +interface Search { + readonly sources: readonly Port[]; + readonly sinks: readonly Port[]; + /** Minimum length of the segment adjacent to each port. */ + readonly minSource: number; + readonly minSink: number; + readonly terminals: Terminals; + /** Stocks besides the terminals a candidate should not pass through; one that does ranks as crossing (see pathQuality). */ + readonly obstacles: readonly XY[]; + /** Turns a generated path into the flow's full path (routeEnd prepends or appends a preserved prefix). */ + readonly assemble: (path: XY[]) => XY[]; + /** Candidates a caller refuses outright (a tail folding back over its preserved prefix). */ + readonly refuse: (points: readonly XY[]) => boolean; + readonly baseFirstAxis: Axis | undefined; + readonly baseLastAxis: Axis | undefined; + /** + * The most bends a candidate is ranked with: 2 for a route between two + * terminals (straight, L, Z), 3 for a tail a preserved corner forces into a + * turn. Past that only `detours` generates more. + */ + readonly maxBends: number; + /** When nothing valid exists, also try U turns and up to four bends (never for routeEnd's pinned attempt). */ + readonly detours: boolean; + /** Base corner coordinates per axis: the holds that keep an existing shape. */ + readonly baseHolds: { readonly x: readonly number[]; readonly y: readonly number[] }; + /** A minimum riser either side of each point port, per axis: ahead of the pair midpoint. */ + readonly pointPools: { readonly x: readonly number[]; readonly y: readonly number[] }; + /** Pair-independent hold candidates per axis after the midpoint (point coordinates, stub tips, body clearances). */ + readonly pools: { readonly x: readonly number[]; readonly y: readonly number[] }; +} + +function portRef(port: Port): XY { + return port.kind === 'point' ? port.point : facePoint(port.att, port.pref); +} + +/** The port's stub tip on `axis` (a face whose normal is `axis`), else its reference coordinate. */ +function tipOf(port: Port, axis: Axis, min: number): number { + return port.kind === 'face' && port.att.normal === axis ? stubTip(port.att, min) : coord(portRef(port), axis); +} + +function dedupe(values: readonly number[]): number[] { + const out: number[] = []; + for (const v of values) { + if (Number.isFinite(v) && !out.some((o) => Math.abs(o - v) <= GEOMETRY_EPSILON)) { + out.push(v); + } + } + return out; +} + +/** + * The box a route between the search's ports can be expected to pass through: + * the terminal bodies and port references, inflated by two minimum segments. + * Only obstacles meeting it contribute clearance holds, so a view's distant + * stocks do not multiply the candidates of every search. + */ +function reachBox(search: Omit): Box { + const box = { minX: Infinity, maxX: -Infinity, minY: Infinity, maxY: -Infinity }; + const add = (b: Box): void => { + box.minX = Math.min(box.minX, b.minX); + box.maxX = Math.max(box.maxX, b.maxX); + box.minY = Math.min(box.minY, b.minY); + box.maxY = Math.max(box.maxY, b.maxY); + }; + const pointBox = (p: XY): Box => ({ minX: p.x, maxX: p.x, minY: p.y, maxY: p.y }); + for (const t of [search.terminals.source, search.terminals.sink]) { + add(t.kind === 'stock' ? stockBody(t.stock) : pointBox(t.point)); + } + for (const port of [...search.sources, ...search.sinks]) { + add(pointBox(portRef(port))); + } + return inflate(box, 2 * MIN_SEGMENT); +} + +/** + * Pair-independent hold candidates per axis. `pointPools` is the minimum + * distance either side of a point port: a Z whose riser hugs a cloud turns into + * the L the cloud reaches with the least change, and a tail leaves a preserved + * corner by exactly a minimum riser, so these rank ahead of a pair's midpoint. + * `pools` follows the midpoint: point coordinates, stub tips, and clearances + * around each terminal body and each obstacle within reach (a route can only go + * around a body whose clearance is a hold it can take). + */ +function buildPools(search: Omit): Pick { + const reach = reachBox(search); + const nearObstacles = search.obstacles.filter((center) => boxesOverlap(stockBody(center), reach)); + const pool = (axis: Axis): { point: number[]; rest: number[] } => { + const point: number[] = []; + const rest: number[] = []; + for (const [ports, min] of [ + [search.sources, search.minSource], + [search.sinks, search.minSink], + ] as const) { + for (const port of ports) { + if (port.kind === 'point') { + point.push(coord(port.point, axis) - min, coord(port.point, axis) + min); + rest.push(coord(port.point, axis)); + } else if (port.att.normal === axis) { + rest.push(stubTip(port.att, min)); + } + } + } + const bodies: readonly XY[] = [ + ...[search.terminals.source, search.terminals.sink].flatMap((t): XY[] => (t.kind === 'stock' ? [t.stock] : [])), + ...nearObstacles, + ]; + for (const center of bodies) { + const body = stockBody(center); + rest.push(axis === 'x' ? body.minX - MIN_SEGMENT : body.minY - MIN_SEGMENT); + rest.push(axis === 'x' ? body.maxX + MIN_SEGMENT : body.maxY + MIN_SEGMENT); + } + return { point: dedupe(point), rest: dedupe(rest) }; + }; + const x = pool('x'); + const y = pool('y'); + return { pointPools: { x: x.point, y: y.point }, pools: { x: x.rest, y: y.rest } }; +} + +/** + * The hold candidates for a port pair on `axis`, in priority order: each base + * corner clamped into the pair's feasible band (between the two ports' stub + * tips), the point-port clearances, the band's midpoint, then the shared pools. + * Clamping keeps an existing run where it is while the band covers it and + * follows the band's edge when it does not, so the hold is a continuous function + * of the terminals; the midpoint is the pair's own fallback, never another + * pair's. + */ +function holdCandidates(search: Search, P: Port, Q: Port, axis: Axis): number[] { + const a = tipOf(P, axis, search.minSource); + const b = tipOf(Q, axis, search.minSink); + const lo = Math.min(a, b); + const hi = Math.max(a, b); + return dedupe([ + ...search.baseHolds[axis].map((v) => clamp(v, lo, hi)), + ...search.pointPools[axis], + (a + b) / 2, + ...search.pools[axis], + ]); +} + +/** + * Which two-bend shapes to emit. A plain route's two-bend shape is a Z (it + * leaves and arrives travelling the same way); a U (arriving travelling back) + * is a detour, generated only when nothing else is valid. A tail ending at a + * preserved corner may need either, so both are emitted with the other shapes. + */ +type ShapeTier = 'shapes' | 'uTurns'; + +function generate(search: Search, k: number, out: Candidate[], tier: ShapeTier = 'shapes'): void { + for (const P of search.sources) { + for (const Q of search.sinks) { + for (const A0 of ['x', 'y'] as const) { + generateShape(search, P, Q, k, A0, out, tier); + } + } + } +} + +function generateShape(search: Search, P: Port, Q: Port, k: number, A0: Axis, out: Candidate[], tier: ShapeTier): void { + const preserved = (P.kind === 'point' && P.axis !== undefined) || (Q.kind === 'point' && Q.axis !== undefined); + if (tier === 'uTurns' && (k !== 2 || preserved)) { + return; + } + const A1 = otherAxis(A0); + const Ak = k % 2 === 0 ? A0 : A1; + if (P.kind === 'face' ? P.att.normal !== A0 : P.axis !== undefined && P.axis !== A0) { + return; + } + if (Q.kind === 'face' ? Q.att.normal !== Ak : Q.axis !== undefined && Q.axis !== Ak) { + return; + } + const e = GEOMETRY_EPSILON; + const start = P.kind === 'face' ? P.att.plane : coord(P.point, A0); + const end = Q.kind === 'face' ? Q.att.plane : coord(Q.point, Ak); + + if (k === 0) { + let v: number; + if (P.kind === 'face' && Q.kind === 'face') { + const lo = Math.max(P.att.lo, Q.att.lo); + const hi = Math.min(P.att.hi, Q.att.hi); + if (lo > hi + e) { + return; + } + // Unpinned, a straight between two faces splits the difference between + // the two preferences: each end gives way equally. + v = P.pinned ? P.pref : Q.pinned ? Q.pref : clamp((P.pref + Q.pref) / 2, lo, hi); + } else if (P.kind === 'face' && Q.kind === 'point') { + v = coord(Q.point, A1); + if (!alongAccepts(P, v)) { + return; + } + } else if (P.kind === 'point' && Q.kind === 'face') { + v = coord(P.point, A1); + if (!alongAccepts(Q, v)) { + return; + } + } else if (P.kind === 'point' && Q.kind === 'point') { + v = coord(P.point, A1); + if (Math.abs(coord(Q.point, A1) - v) > e) { + return; + } + } else { + return; + } + emit(search, P, Q, [compose(A0, start, v), compose(A0, end, v)], 0, out); + return; + } + + const outwardOk = (port: Port, portCoord: number, v: number): boolean => + port.kind === 'face' ? port.att.sign * (v - port.att.plane) > e : Math.abs(v - portCoord) > e; + const holds: number[] = new Array(k + 1).fill(0); + const axisOfHold = (i: number): Axis => (i % 2 === 1 ? A0 : A1); + const candidates = { x: holdCandidates(search, P, Q, 'x'), y: holdCandidates(search, P, Q, 'y') }; + + const finish = (): void => { + const fixedP = P.kind === 'point' ? coord(P.point, A1) : undefined; + const fixedQ = Q.kind === 'point' ? coord(Q.point, otherAxis(Ak)) : undefined; + const solveP = (pref: number, neighbor: number): number => { + if (fixedP !== undefined) return fixedP; + const port = P as FacePort; + if (k === 1) { + return Q.kind === 'face' + ? solveAlong(port, pref, { kind: 'half', from: stubTip(Q.att, search.minSink), sign: Q.att.sign }) + : solveAlong(port, pref, { kind: 'away', t: end, m: search.minSink }); + } + return solveAlong(port, pref, { kind: 'away', t: neighbor, m: MIN_SEGMENT }); + }; + const solveQ = (pref: number, neighbor: number): number => { + if (fixedQ !== undefined) return fixedQ; + const port = Q as FacePort; + if (k === 1) { + return P.kind === 'face' + ? solveAlong(port, pref, { kind: 'half', from: stubTip(P.att, search.minSource), sign: P.att.sign }) + : solveAlong(port, pref, { kind: 'away', t: start, m: search.minSource }); + } + return solveAlong(port, pref, { kind: 'away', t: neighbor, m: MIN_SEGMENT }); + }; + const prefP = P.kind === 'face' ? P.pref : fixedP!; + const prefQ = Q.kind === 'face' ? Q.pref : fixedQ!; + const variants: Array<[number, number]> = []; + if (k === 2) { + // The riser joins the two endpoints' along positions, so they are solved + // against each other, once in each order. + const a1 = solveP(prefP, prefQ); + variants.push([a1, solveQ(prefQ, a1)]); + const b2 = solveQ(prefQ, prefP); + variants.push([solveP(prefP, b2), b2]); + } else { + variants.push([solveP(prefP, holds[2]), solveQ(prefQ, holds[k - 2])]); + } + const seen: Array<[number, number]> = []; + for (const [a, b] of variants) { + if (seen.some(([sa, sb]) => Math.abs(sa - a) <= e && Math.abs(sb - b) <= e)) { + continue; + } + seen.push([a, b]); + holds[0] = a; + holds[k] = b; + const points: XY[] = [compose(A0, start, holds[0])]; + for (let i = 1; i <= k; i++) { + const Ai = i % 2 === 0 ? A0 : A1; + points.push(compose(Ai, holds[i - 1], holds[i])); + } + points.push(compose(Ak, end, holds[k])); + if (k === 2 && !preserved) { + const leaving = Math.sign(coord(points[1], A0) - coord(points[0], A0)); + const arriving = Math.sign(coord(points[3], A0) - coord(points[2], A0)); + if ((leaving !== arriving) !== (tier === 'uTurns')) { + continue; + } + } + emit(search, P, Q, points, k, out); + } + }; + + const fill = (i: number): void => { + if (i === k) { + finish(); + return; + } + const axis = axisOfHold(i); + // A hold a minimum riser away from the parallel hold two segments back (or + // from a point port's fixed coordinate there) keeps a tail from detouring to + // a far candidate while a near feasible position exists. + const neighbors: number[] = []; + if (i >= 3) { + neighbors.push(holds[i - 2] - MIN_SEGMENT, holds[i - 2] + MIN_SEGMENT); + } else if (i === 2 && P.kind === 'point') { + neighbors.push(coord(P.point, A1) - MIN_SEGMENT, coord(P.point, A1) + MIN_SEGMENT); + } + if (i === k - 2 && Q.kind === 'point') { + neighbors.push(coord(Q.point, otherAxis(Ak)) - MIN_SEGMENT, coord(Q.point, otherAxis(Ak)) + MIN_SEGMENT); + } + for (const v of neighbors.length === 0 ? candidates[axis] : [...candidates[axis], ...neighbors]) { + if (i === 1 && !outwardOk(P, start, v)) continue; + if (i === k - 1 && !outwardOk(Q, end, v)) continue; + // An interior segment between holds i-2 and i must have length. + if (i >= 3 && Math.abs(v - holds[i - 2]) <= e) continue; + holds[i] = v; + fill(i + 1); + } + }; + if (k === 1) { + finish(); + } else { + fill(1); + } +} + +function alongAccepts(port: FacePort, v: number): boolean { + const e = GEOMETRY_EPSILON; + if (port.pinned) { + return Math.abs(v - port.pref) <= e; + } + return v >= port.att.lo - e && v <= port.att.hi + e; +} + +function emit(search: Search, P: Port, Q: Port, path: XY[], bends: number, out: Candidate[]): void { + const points = search.assemble(path); + if (search.refuse(points)) { + return; + } + const sticky = (P.kind === 'face' && P.offBase ? 1 : 0) + (Q.kind === 'face' && Q.offBase ? 1 : 0); + const n = points.length; + let axisChange = 0; + if (search.baseFirstAxis !== undefined && segmentAxisOf(points[0], points[1]) !== search.baseFirstAxis) { + axisChange++; + } + if (search.baseLastAxis !== undefined && segmentAxisOf(points[n - 2], points[n - 1]) !== search.baseLastAxis) { + axisChange++; + } + const quality = pathQuality(points, search.terminals, undefined, search.obstacles); + out.push({ + points, + bends, + sticky, + axisChange, + length: pathLength(points), + fault: quality.fault, + crossing: quality.crossing, + obstructed: quality.obstructed, + index: out.length, + }); +} + +function compareCandidates(a: Candidate, b: Candidate): number { + // Lengths within GEOMETRY_EPSILON tie: Z variants whose riser sits at different + // holds have the same length up to float noise, and letting that noise rank + // them flips the riser between frames. + const byLength = Math.abs(a.length - b.length) > GEOMETRY_EPSILON ? a.length - b.length : 0; + return a.bends - b.bends || a.axisChange - b.axisChange || byLength || a.index - b.index; +} + +/** + * Pick the route: validity; then, among valid candidates, not crossing a + * terminal body (G6's best effort when the bodies overlap and crossing is no + * fault); then stickiness (keep the base faces when they have a candidate + * within one bend of the best); then bends, axis change and length. Shapes past + * straight, L and Z are generated only when needed: tails up to `maxBends` when + * nothing valid exists yet or the stickiness window reaches past what was + * generated, and, when `detours` is set and nothing is valid -- or every valid + * candidate crosses something and one of them passes through a non-terminal + * stock -- U turns and then more bends. A detour around a stock therefore beats + * a route through it, while a crossing G6 excuses (overlapping terminal bodies) + * generates no detours. With nothing valid at all, the least severe fault wins, + * so a route always exists. + */ +function search(s: Search): XY[] { + const candidates: Candidate[] = []; + let generated = 2; + for (let k = 0; k <= generated; k++) { + generate(s, k, candidates); + } + let uTurns = false; + for (;;) { + const valid = candidates.filter((c) => c.fault === FAULT_NONE); + if (valid.length === 0) { + if (s.detours && !uTurns) { + uTurns = true; + generate(s, 2, candidates, 'uTurns'); + continue; + } + if (generated < 4 && (generated < s.maxBends || s.detours || candidates.length === 0)) { + generate(s, ++generated, candidates); + continue; + } + if (candidates.length === 0) { + return []; + } + // The totality rule: G6 relaxed before G3 before structure. Searches without + // detours (routeEnd's preserved and pinned attempts) reach this branch + // often, but routeEnd discards a winner that is not valid. With detours it + // is a backstop no generated scene or table row reaches, so the fault + // ordering is observable through no test. + const fallback = [...candidates].sort( + (a, b) => + a.fault - b.fault || + Number(a.crossing) - Number(b.crossing) || + a.sticky - b.sticky || + compareCandidates(a, b), + ); + return fallback[0].points; + } + const clear = valid.filter((c) => !c.crossing); + if (clear.length === 0 && s.detours && valid.some((c) => c.obstructed)) { + if (!uTurns) { + uTurns = true; + generate(s, 2, candidates, 'uTurns'); + continue; + } + if (generated < 4) { + generate(s, ++generated, candidates); + continue; + } + } + const pool = clear.length > 0 ? clear : valid; + const best = Math.min(...pool.map((c) => c.bends)); + const window = pool.filter((c) => c.bends <= best + 1); + const minSticky = Math.min(...window.map((c) => c.sticky)); + if (minSticky > 0 && best + 1 > generated && generated < s.maxBends) { + generate(s, ++generated, candidates); + continue; + } + return window.filter((c) => c.sticky === minSticky).sort(compareCandidates)[0].points; + } +} + +function distinctPath(points: readonly XY[]): boolean { + return points.length >= 2 && points.every(isFiniteXY) && pathLength(points) > GEOMETRY_EPSILON; +} + +function endAxes(points: readonly XY[]): { baseFirstAxis: Axis | undefined; baseLastAxis: Axis | undefined } { + if (!distinctPath(points)) { + return { baseFirstAxis: undefined, baseLastAxis: undefined }; + } + const n = points.length; + return { + baseFirstAxis: segmentAxisOf(points[0], points[1]), + baseLastAxis: segmentAxisOf(points[n - 2], points[n - 1]), + }; +} + +function baseHoldsOf(base: readonly XY[]): Search['baseHolds'] { + const interior = distinctPath(base) ? base.slice(1, -1).filter(isFiniteXY) : []; + return { x: dedupe(interior.map((p) => p.x)), y: dedupe(interior.map((p) => p.y)) }; +} + +/** Route between two terminals with no preserved prefix; a missing route (no candidates at all) is undefined. */ +export function routeBetween( + terminals: Terminals, + pinned: { readonly source: boolean; readonly sink: boolean }, + base: readonly XY[], + occupied: readonly XY[], + detours = true, + obstacles: readonly XY[] = [], +): XY[] { + const partial = { + sources: portsOf(terminals.source, pinned.source, occupied), + sinks: portsOf(terminals.sink, pinned.sink, occupied), + minSource: MIN_SEGMENT, + minSink: MIN_SINK_SEGMENT, + terminals, + obstacles, + assemble: (path: XY[]) => path, + refuse: () => false, + ...endAxes(base), + maxBends: 2, + detours, + baseHolds: baseHoldsOf(base), + }; + return search({ ...partial, ...buildPools(partial) }); +} + +export interface RouteContext { + /** + * The flow being routed, as it was when the gesture started: its identity is + * kept, and its path supplies the valve's arc position and the shape + * stickiness. A creation draft with no length routes fresh. + */ + readonly flow: FlowViewElement; + /** The end the valve's arc position is measured from (the fixed end); defaults to the source. */ + readonly valveFrom?: FlowEnd; + /** + * Other flows' endpoints on the terminal stocks, for the slot preference, in + * the coordinates of THIS frame: a planner moving a stock moves the other + * endpoints on it too before passing them. + */ + readonly occupied?: readonly XY[]; + /** + * The view's stocks, in this frame's coordinates. A candidate through one + * that is not a terminal ranks as crossing, so the route goes around it + * wherever it can: a pipe through a stock reads as attached to it. + */ + readonly obstacles?: readonly XY[]; +} + +/** + * The minimal orthogonal route between two terminals: a straight, L or Z over + * every face pair, ranked as `search` describes. When none is valid a U turn, + * then more bends, are tried (two clouds a pixel off each other's line, or a + * stock over its own cloud, have no valid straight, L or Z). Total: when nothing + * is valid (the terminal bodies overlap), G6 is relaxed first and a route is + * still returned. The valve keeps its arc distance from `ctx.valveFrom`. A + * non-finite terminal returns the base flow unchanged. + */ +export function route(source: Terminal, sink: Terminal, ctx: RouteContext): FlowGeometry { + if (!terminalIsFinite(source) || !terminalIsFinite(sink)) { + return { flow: ctx.flow, clouds: [] }; + } + const terminals = { source, sink }; + const from = ctx.valveFrom ?? 'source'; + const points = routeBetween( + terminals, + { source: false, sink: false }, + ctx.flow.points, + ctx.occupied ?? [], + true, + ctx.obstacles ?? [], + ); + const valve = placeValve(points, from, valveDistance(ctx.flow.points, ctx.flow, from)); + return withGeometry(ctx.flow, points, valve, terminals); +} + +export interface RouteEndContext { + /** The terminal at the end that does not move. */ + readonly fixed: Terminal; + /** Other flows' endpoints on the terminal stocks, in this frame's coordinates (see RouteContext). */ + readonly occupied?: readonly XY[]; + /** + * The view's stocks, in this frame's coordinates (see RouteContext), including + * one the moving end has just left, which a preserved corner on its old face's + * line would otherwise run straight through. + */ + readonly obstacles?: readonly XY[]; +} + +/** + * Re-route one end of `flow` (the base flow) to `terminal`, keeping as much of + * the path near the fixed end as stays valid. Preserve k interior corners + * counted from the fixed end, for k = K..1 with K all but the corner adjacent + * to the re-routed end; the first k whose tail yields a valid path that crosses + * no terminal body and meets every G3 minimum (even where the terminals leave no + * room and G3 would excuse it) wins. A tail may not fold back over its preserved prefix, nor + * give the path more bends than the base had: preserving corners keeps a shape, + * and a tail that grows it is a detour, which releasing to fewer preserved + * corners replaces. Then k = 0 with the fixed terminal pinned to its base face + * and offset, and only if that is still invalid or crossing is the flow + * released to `route`. A path through `ctx.obstacles` ranks as crossing in + * every attempt: a preserved or pinned tail through one is refused, and the + * released search prefers a route around it but never refuses its last resort. + * + * The valve keeps its arc-length distance from the fixed end. A non-finite + * terminal returns the base flow unchanged. + */ +export function routeEnd(flow: FlowViewElement, end: FlowEnd, terminal: Terminal, ctx: RouteEndContext): FlowGeometry { + if (!terminalIsFinite(terminal) || !terminalIsFinite(ctx.fixed)) { + return { flow, clouds: [] }; + } + const terminals: Terminals = + end === 'source' ? { source: terminal, sink: ctx.fixed } : { source: ctx.fixed, sink: terminal }; + const fixedEnd: FlowEnd = end === 'source' ? 'sink' : 'source'; + const occupied = ctx.occupied ?? []; + const obstacles = ctx.obstacles ?? []; + const base = distinctPath(flow.points) ? normalize(flow.points) : flow.points; + const acceptable = (points: readonly XY[]): boolean => { + const quality = pathQuality(points, terminals, undefined, obstacles); + return quality.fault === FAULT_NONE && !quality.crossing && !quality.short; + }; + let points: XY[] | undefined; + if (distinctPath(base)) { + for (let k = Math.max(0, base.length - 3); k >= 1 && points === undefined; k--) { + const tail = preservedTail(base, end, k, terminals, occupied, obstacles); + points = tail !== undefined && acceptable(tail) ? tail : undefined; + } + } + if (points === undefined) { + // No detours while pinned: a U turn that keeps the fixed endpoint put is + // worse than releasing it to slide along its face into a straight route. + const pinned = routeBetween( + terminals, + { source: fixedEnd === 'source', sink: fixedEnd === 'sink' }, + base, + occupied, + false, + obstacles, + ); + // Of `acceptable`'s clauses, a terminal crossing never fires here: a pinned + // search generates no detours and at most two bends, so a valid winner is a + // straight, L or same-direction Z, monotone in both axes. It leaves the fixed + // face outward and enters the moving terminal's face inward, so it stays + // outside both bodies. The clause acts on preserved tails ("a tail crossing + // its stock is refused even where G6 excuses the crossing" in + // flow-geometry-route.test.ts) and on an obstacle a monotone path can still + // pass through, which releases the flow to the full search below. + if (pinned.length > 0 && acceptable(pinned)) { + points = pinned; + } + } + if (points === undefined) { + points = routeBetween(terminals, { source: false, sink: false }, base, occupied, true, obstacles); + } + const valve = placeValve(points, fixedEnd, valveDistance(flow.points, flow, fixedEnd)); + return withGeometry(flow, points, valve, terminals); +} + +function preservedTail( + base: readonly XY[], + end: FlowEnd, + k: number, + terminals: Terminals, + occupied: readonly XY[], + obstacles: readonly XY[], +): XY[] | undefined { + const n = base.length; + const moving = end === 'source' ? terminals.source : terminals.sink; + const movingPorts = portsOf(moving, false, occupied); + const baseBends = n - 2; + const baseUTurns = uTurnCount(base); + const refuse = (points: readonly XY[]): boolean => + points.length - 2 > baseBends || uTurnCount(points) > baseUTurns || foldsBack(points, prefixSegments); + let partial: Omit; + let prefixSegments: Array; + if (end === 'sink') { + const prefix = base.slice(0, k + 1); + prefixSegments = segmentsOf(prefix); + const corner = prefix[k]; + const port: PointPort = { kind: 'point', point: corner, axis: otherAxis(segmentAxisOf(prefix[k - 1], corner)) }; + partial = { + sources: [port], + sinks: movingPorts, + minSource: MIN_SEGMENT, + minSink: MIN_SINK_SEGMENT, + terminals, + obstacles, + assemble: (path) => [...prefix.slice(0, k), ...path], + refuse, + ...endAxes(base), + maxBends: 3, + detours: false, + baseHolds: baseHoldsOf(base), + }; + } else { + const prefix = base.slice(n - 1 - k); + prefixSegments = segmentsOf(prefix); + const corner = prefix[0]; + const port: PointPort = { kind: 'point', point: corner, axis: otherAxis(segmentAxisOf(corner, prefix[1])) }; + partial = { + sources: movingPorts, + sinks: [port], + minSource: MIN_SEGMENT, + minSink: MIN_SEGMENT, + terminals, + obstacles, + assemble: (path) => [...path, ...prefix.slice(1)], + refuse, + ...endAxes(base), + maxBends: 3, + detours: false, + baseHolds: baseHoldsOf(base), + }; + } + const points = search({ ...partial, ...buildPools(partial) }); + return points.length > 0 && pathQuality(points, terminals, undefined, obstacles).fault === FAULT_NONE + ? points + : undefined; +} + +/** + * The number of U turns in a path: two consecutive turns in the same rotational + * direction (a Z turns one way and back; a U turns the same way twice and heads + * back past where it came from). + */ +function uTurnCount(points: readonly XY[]): number { + let count = 0; + let previous = 0; + for (let i = 1; i < points.length - 1; i++) { + const ax = points[i].x - points[i - 1].x; + const ay = points[i].y - points[i - 1].y; + const bx = points[i + 1].x - points[i].x; + const by = points[i + 1].y - points[i].y; + const turn = Math.sign(ax * by - ay * bx); + if (turn !== 0 && turn === previous) { + count++; + } + previous = turn; + } + return count; +} + +function segmentsOf(points: readonly XY[]): Array { + return points.slice(1).map((p, i) => [points[i], p] as const); +} + +/** + * Whether some segment of `points` runs back over a preserved segment: the two + * are parallel, travel in opposite directions, hold coordinates less than + * MIN_SEGMENT apart, and overlap in span. The pipe would draw over itself. + */ +function foldsBack(points: readonly XY[], preserved: ReadonlyArray): boolean { + const e = GEOMETRY_EPSILON; + const segments = segmentsOf(points); + for (const [a, b] of segments) { + const axis = segmentAxisOf(a, b); + const hold = coord(a, otherAxis(axis)); + const direction = Math.sign(coord(b, axis) - coord(a, axis)); + for (const [c, d] of preserved) { + if (segmentAxisOf(c, d) !== axis || Math.sign(coord(d, axis) - coord(c, axis)) !== -direction) { + continue; + } + if (Math.abs(coord(c, otherAxis(axis)) - hold) >= MIN_SEGMENT - e) { + continue; + } + const lo = Math.max(Math.min(coord(a, axis), coord(b, axis)), Math.min(coord(c, axis), coord(d, axis))); + const hi = Math.min(Math.max(coord(a, axis), coord(b, axis)), Math.max(coord(c, axis), coord(d, axis))); + if (hi - lo > e) { + return true; + } + } + } + return false; +} diff --git a/src/diagram/flow-geometry/terminal.ts b/src/diagram/flow-geometry/terminal.ts new file mode 100644 index 000000000..e488ed115 --- /dev/null +++ b/src/diagram/flow-geometry/terminal.ts @@ -0,0 +1,286 @@ +// Copyright 2026 The Simlin Authors. All rights reserved. +// Use of this source code is governed by the Apache License, +// Version 2.0, that can be found in the LICENSE file. + +/** + * Face attachment and terminals. This module is the single owner of how a pipe + * attaches to a stock: route's candidates, routeEnd's pinned terminal, + * offsetSegment's tail re-solve and heal's re-pin all derive endpoints, outward + * directions and stub tips from `faceAttachment` and `stubTip`. + */ + +import type { + CloudViewElement, + FlowViewElement, + Point, + StockViewElement, + UID, + ViewElement, +} from '@simlin/core/datamodel'; + +import { + type Axis, + type Box, + clamp, + compose, + CORNER_CLEARANCE, + coord, + distance, + type Face, + FACES, + GEOMETRY_EPSILON, + HALF_HEIGHT, + HALF_WIDTH, + isFiniteXY, + pointBody, + segmentAxisOf, + stockBody, + type XY, +} from './geometry'; + +/** Where a pipe may attach to one face of a stock. */ +export interface FaceAttachment { + readonly face: Face; + /** The axis a segment leaving this face runs along. */ + readonly normal: Axis; + /** The axis the face itself runs along. */ + readonly along: Axis; + /** +1 when leaving the face increases the normal coordinate. */ + readonly sign: 1 | -1; + /** The face's coordinate on the normal axis. */ + readonly plane: number; + /** The stock center's coordinate on the along axis. */ + readonly center: number; + /** Valid endpoint positions on the along axis: the face extent minus CORNER_CLEARANCE. */ + readonly lo: number; + readonly hi: number; +} + +export function faceAttachment(stock: XY, face: Face): FaceAttachment { + switch (face) { + case 'left': + case 'right': { + const extent = HALF_HEIGHT - CORNER_CLEARANCE; + return { + face, + normal: 'x', + along: 'y', + sign: face === 'right' ? 1 : -1, + plane: stock.x + (face === 'right' ? HALF_WIDTH : -HALF_WIDTH), + center: stock.y, + lo: stock.y - extent, + hi: stock.y + extent, + }; + } + case 'top': + case 'bottom': { + const extent = HALF_WIDTH - CORNER_CLEARANCE; + return { + face, + normal: 'y', + along: 'x', + sign: face === 'bottom' ? 1 : -1, + plane: stock.y + (face === 'bottom' ? HALF_HEIGHT : -HALF_HEIGHT), + center: stock.x, + lo: stock.x - extent, + hi: stock.x + extent, + }; + } + } +} + +/** The normal-axis coordinate of a stub `min` long leaving the face. */ +export function stubTip(att: FaceAttachment, min: number): number { + return att.plane + att.sign * min; +} + +/** The endpoint at `along` on the face, clamped into the valid range. */ +export function facePoint(att: FaceAttachment, along: number): XY { + return compose(att.along, clamp(along, att.lo, att.hi), att.plane); +} + +/** + * The face an endpoint lies on (within GEOMETRY_EPSILON), or undefined when it + * is on none. A corner point is on two faces; the one whose adjacent segment + * leaves perpendicular is preferred when `adjacent` is given. + */ +export function faceOfEndpoint(stock: XY, p: XY, adjacent?: XY): Face | undefined { + const dx = p.x - stock.x; + const dy = p.y - stock.y; + const e = GEOMETRY_EPSILON; + const faces: Face[] = []; + if (Math.abs(Math.abs(dx) - HALF_WIDTH) <= e && Math.abs(dy) <= HALF_HEIGHT + e) { + faces.push(dx > 0 ? 'right' : 'left'); + } + if (Math.abs(Math.abs(dy) - HALF_HEIGHT) <= e && Math.abs(dx) <= HALF_WIDTH + e) { + faces.push(dy > 0 ? 'bottom' : 'top'); + } + if (faces.length > 1 && adjacent !== undefined) { + const axis = segmentAxisOf(p, adjacent); + const perpendicular = faces.find((f) => faceAttachment(stock, f).normal === axis); + if (perpendicular !== undefined) { + return perpendicular; + } + } + return faces[0]; +} + +/** + * The nearest valid face point to `p`, over all four faces. When `adjacent` is + * given, a tie (a corner point, equidistant from two faces) goes to the face the + * adjacent segment leaves perpendicular to, so a stub keeps its direction. + */ +export function nearestFaceAttachment(stock: XY, p: XY, adjacent?: XY): { readonly face: Face; readonly point: XY } { + const preferred = adjacent === undefined ? undefined : segmentAxisOf(p, adjacent); + let best: { face: Face; point: XY } | undefined; + let bestDistance = Infinity; + for (const face of FACES) { + const att = faceAttachment(stock, face); + const point = facePoint(att, coord(p, att.along)); + const d = distance(point, p); + const tie = Math.abs(d - bestDistance) <= GEOMETRY_EPSILON; + if (d < bestDistance - GEOMETRY_EPSILON || (tie && preferred !== undefined && att.normal === preferred)) { + bestDistance = Math.min(d, bestDistance); + best = { face, point }; + } + } + return best!; +} + +/** + * A stock terminal. `face` and `offset` describe the BASE flow's attachment + * (offset is the endpoint's along-face distance from the face center), so + * stickiness needs no previous frame; both are undefined for a stock the flow + * was not attached to before (a new target). + */ +export interface StockTerminal { + readonly kind: 'stock'; + readonly stock: StockViewElement; + readonly face?: Face; + readonly offset?: number; +} + +/** + * A free terminal: a cloud, or a point with no element (the pointer before a + * cloud exists). The endpoint is attached to `cloud` when one is given, and the + * cloud is moved onto the endpoint when an operation moves the endpoint. + */ +export interface FreeTerminal { + readonly kind: 'free'; + readonly point: XY; + readonly cloud?: CloudViewElement; +} + +export type Terminal = StockTerminal | FreeTerminal; + +export interface Terminals { + readonly source: Terminal; + readonly sink: Terminal; +} + +/** + * A stock terminal whose base attachment is read from `endpoint` relative to + * `from` (the stock's base position; defaults to `stock`). A planner moving a + * stock passes the moved stock and the base stock, so the base face and offset + * travel with the stock. An endpoint on no face attaches to the nearest face. + */ +export function stockTerminal(stock: StockViewElement, endpoint?: XY, adjacent?: XY, from: XY = stock): StockTerminal { + if (endpoint === undefined || !isFiniteXY(endpoint)) { + return { kind: 'stock', stock }; + } + const face = faceOfEndpoint(from, endpoint, adjacent) ?? nearestFaceAttachment(from, endpoint, adjacent).face; + const att = faceAttachment(from, face); + return { kind: 'stock', stock, face, offset: coord(endpoint, att.along) - att.center }; +} + +export function freeTerminal(point: XY, cloud?: CloudViewElement): FreeTerminal { + const at = { x: point.x, y: point.y }; + return cloud === undefined ? { kind: 'free', point: at } : { kind: 'free', point: at, cloud }; +} + +/** + * A flow's terminals as the view supplies them: a stock endpoint's base face + * and offset are read from the endpoint (and its adjacent point, which picks + * the perpendicular face at a corner); a cloud endpoint's terminal is its + * cloud, at the cloud's center; an unattached or dangling endpoint is a free + * point with no cloud, which no operation attaches. + */ +export function flowTerminals(flow: FlowViewElement, byUid: ReadonlyMap): Terminals { + const pts = flow.points; + const n = pts.length; + const terminalAt = (index: number, adjacentIndex: number): Terminal => { + const p = pts[index]; + if (p === undefined) { + return freeTerminal({ x: flow.x, y: flow.y }); + } + const el = p.attachedToUid === undefined ? undefined : byUid.get(p.attachedToUid); + if (el?.type === 'stock') { + return stockTerminal(el, p, pts[adjacentIndex]); + } + if (el?.type === 'cloud') { + return freeTerminal(el, el); + } + return freeTerminal(p); + }; + return { source: terminalAt(0, 1), sink: terminalAt(n - 1, n - 2) }; +} + +export function terminalUid(t: Terminal): UID | undefined { + return t.kind === 'stock' ? t.stock.uid : t.cloud?.uid; +} + +export function terminalBody(t: Terminal): Box { + return t.kind === 'stock' ? stockBody(t.stock) : pointBody(t.point); +} + +/** + * The geometry an operation produced: the new flow, plus every cloud whose + * position changed because its endpoint moved (a cloud endpoint always equals + * its cloud's center, G7). + */ +export interface FlowGeometry { + readonly flow: FlowViewElement; + readonly clouds: readonly CloudViewElement[]; +} + +export function cloudUpdates(points: readonly XY[], terminals: Terminals): CloudViewElement[] { + const out: CloudViewElement[] = []; + const ends: Array<[Terminal, XY]> = [ + [terminals.source, points[0]], + [terminals.sink, points[points.length - 1]], + ]; + for (const [t, p] of ends) { + if (t.kind === 'free' && t.cloud !== undefined && (t.cloud.x !== p.x || t.cloud.y !== p.y)) { + out.push({ ...t.cloud, x: p.x, y: p.y }); + } + } + return out; +} + +export function attachPoints(points: readonly XY[], terminals: Terminals): Point[] { + const last = points.length - 1; + return points.map((p, i) => ({ + x: p.x, + y: p.y, + attachedToUid: i === 0 ? terminalUid(terminals.source) : i === last ? terminalUid(terminals.sink) : undefined, + })); +} + +export function withGeometry( + flow: FlowViewElement, + points: readonly XY[], + valve: XY, + terminals: Terminals, +): FlowGeometry { + return { + flow: { ...flow, x: valve.x, y: valve.y, points: attachPoints(points, terminals) }, + clouds: cloudUpdates(points, terminals), + }; +} + +/** Whether every coordinate a terminal carries is finite (a NaN pointer is a caller bug, never geometry). */ +export function terminalIsFinite(t: Terminal): boolean { + return t.kind === 'stock' + ? isFiniteXY(t.stock) && (t.offset === undefined || Number.isFinite(t.offset)) + : isFiniteXY(t.point); +} diff --git a/src/diagram/flow-geometry/validity.ts b/src/diagram/flow-geometry/validity.ts new file mode 100644 index 000000000..3f8b99082 --- /dev/null +++ b/src/diagram/flow-geometry/validity.ts @@ -0,0 +1,232 @@ +// Copyright 2026 The Simlin Authors. All rights reserved. +// Use of this source code is governed by the Apache License, +// Version 2.0, that can be found in the LICENSE file. + +/** + * Classifying a path against its terminals (G2-G6). + */ + +import type { FlowViewElement } from '@simlin/core/datamodel'; + +import { + type Axis, + boxesOverlap, + coord, + distance, + GEOMETRY_EPSILON, + inflate, + isFiniteXY, + MIN_SEGMENT, + MIN_SINK_SEGMENT, + segmentThroughBox, + stockBody, + strictlyInside, + type XY, +} from './geometry'; +import { faceAttachment, faceOfEndpoint, terminalBody, type Terminals } from './terminal'; + +/** + * How a path violates G2-G6, worst last. When nothing is valid the fallback + * keeps the least severe fault, so G6 (crossing) is given up before G3 minima + * (short), and those before G2/G4/G5 (structure). + */ +export type RouteFault = 'none' | 'crossing' | 'short' | 'structure'; + +const FAULT_NAMES: readonly RouteFault[] = ['none', 'crossing', 'short', 'structure']; +export const FAULT_NONE = 0; +const FAULT_CROSSING = 1; +const FAULT_SHORT = 2; +const FAULT_STRUCTURE = 3; + +/** + * G3's "whenever the terminals leave room": the source body inflated by + * MIN_SEGMENT and the sink body inflated by MIN_SINK_SEGMENT are disjoint. + */ +export function terminalsLeaveRoom(terminals: Terminals): boolean { + return !boxesOverlap( + inflate(terminalBody(terminals.source), MIN_SEGMENT), + inflate(terminalBody(terminals.sink), MIN_SINK_SEGMENT), + ); +} + +/** G6's precondition: the two terminal bodies, each inflated by MIN_SEGMENT, do not overlap. */ +export function bodiesApart(terminals: Terminals): boolean { + return !boxesOverlap( + inflate(terminalBody(terminals.source), MIN_SEGMENT), + inflate(terminalBody(terminals.sink), MIN_SEGMENT), + ); +} + +export interface PathQuality { + /** A FAULT_* rank: 0 when the path holds G2-G6. */ + readonly fault: number; + /** + * Whether the path crosses a terminal body, puts a free endpoint inside a + * stock, or is `obstructed`, computed whether or not G6's precondition holds. + * With the bodies apart the first two are a fault; with them overlapping they + * are G6's best effort, and an obstruction is never a fault. The ranking + * honors every one as a preference rather than no constraint at all. + */ + readonly crossing: boolean; + /** Whether some segment passes through an obstacle stock that is not a terminal (see pathQuality). */ + readonly obstructed: boolean; + /** + * Whether some segment is under its G3 minimum, computed whether or not the + * terminals leave room. With room it is a fault; without, the minima are best + * effort ("the longest achievable") and a candidate that meets them anyway is + * preferred, so a drag across the room boundary does not flip between a route + * that respects the minima and one that merely stopped being required to. + */ + readonly short: boolean; +} + +/** + * Classify a path against its terminals. `stocks` are the view's stocks a free + * endpoint must not sit inside (G6's cloud clause); the terminal stocks are + * always checked. `obstacles` are stocks the path should not pass through + * although they are not its terminals (a pipe through a stock reads as attached + * to it): a path through one is `obstructed` and ranks as crossing, but is never + * a fault, so a route still exists where nothing else does. An obstacle at a + * terminal stock's center is that terminal, whose crossing G6 governs. + */ +export function pathQuality( + points: readonly XY[], + terminals: Terminals, + stocks?: readonly XY[], + obstacles?: readonly XY[], +): PathQuality { + const n = points.length; + const structure = { fault: FAULT_STRUCTURE, crossing: false, obstructed: false, short: false }; + if (n < 2 || !points.every(isFiniteXY)) { + return structure; + } + const e = GEOMETRY_EPSILON; + let previousAxis: Axis | undefined; + for (let i = 0; i < n - 1; i++) { + const a = points[i]; + const b = points[i + 1]; + const flatX = Math.abs(a.x - b.x) <= e; + const flatY = Math.abs(a.y - b.y) <= e; + if (flatX === flatY) { + return structure; + } + const axis: Axis = flatY ? 'x' : 'y'; + if (axis === previousAxis) { + return structure; + } + previousAxis = axis; + } + for (const [t, index, adjacent] of [ + [terminals.source, 0, 1], + [terminals.sink, n - 1, n - 2], + ] as const) { + if (t.kind !== 'stock') { + continue; + } + const p = points[index]; + // With the adjacent point, faceOfEndpoint picks the face the (orthogonal) + // adjacent segment is perpendicular to whenever one exists, so only the + // outward direction remains to check. + const face = faceOfEndpoint(t.stock, p, points[adjacent]); + if (face === undefined) { + return structure; + } + const att = faceAttachment(t.stock, face); + const along = coord(p, att.along); + const q = points[adjacent]; + if (along < att.lo - e || along > att.hi + e || att.sign * (coord(q, att.normal) - att.plane) <= e) { + return structure; + } + } + const terminalCrossing = crossesBodies(points, terminals, stocks); + const obstructed = obstacles !== undefined && throughObstacles(points, terminals, obstacles); + const crossing = terminalCrossing || obstructed; + let short = false; + for (let i = 0; i < n - 1 && !short; i++) { + const minimum = i === n - 2 ? MIN_SINK_SEGMENT : MIN_SEGMENT; + short = distance(points[i], points[i + 1]) < minimum - e; + } + if (short && terminalsLeaveRoom(terminals)) { + return { fault: FAULT_SHORT, crossing, obstructed, short }; + } + const fault = terminalCrossing && bodiesApart(terminals) ? FAULT_CROSSING : FAULT_NONE; + return { fault, crossing, obstructed, short }; +} + +/** + * Whether a segment passes through the body of an obstacle other than a + * terminal. The path's bounding box prefilters the obstacles, so a stock that + * cannot be hit costs one comparison however many a view holds. + */ +function throughObstacles(points: readonly XY[], terminals: Terminals, obstacles: readonly XY[]): boolean { + const e = GEOMETRY_EPSILON; + const n = points.length; + let minX = Infinity; + let maxX = -Infinity; + let minY = Infinity; + let maxY = -Infinity; + for (const p of points) { + minX = Math.min(minX, p.x); + maxX = Math.max(maxX, p.x); + minY = Math.min(minY, p.y); + maxY = Math.max(maxY, p.y); + } + const terminalStocks = [terminals.source, terminals.sink].flatMap((t): XY[] => (t.kind === 'stock' ? [t.stock] : [])); + for (const center of obstacles) { + const body = stockBody(center); + if (body.maxX <= minX || body.minX >= maxX || body.maxY <= minY || body.minY >= maxY) { + continue; + } + if (terminalStocks.some((t) => Math.abs(t.x - center.x) <= e && Math.abs(t.y - center.y) <= e)) { + continue; + } + for (let i = 0; i < n - 1; i++) { + if (segmentThroughBox(points[i], points[i + 1], body)) { + return true; + } + } + } + return false; +} + +function crossesBodies(points: readonly XY[], terminals: Terminals, stocks: readonly XY[] | undefined): boolean { + const n = points.length; + for (const t of [terminals.source, terminals.sink]) { + if (t.kind !== 'stock') { + continue; + } + const body = stockBody(t.stock); + for (let i = 0; i < n - 1; i++) { + if (segmentThroughBox(points[i], points[i + 1], body)) { + return true; + } + } + } + for (const [t, index] of [ + [terminals.source, 0], + [terminals.sink, n - 1], + ] as const) { + if (t.kind !== 'free') { + continue; + } + const p = points[index]; + const inTerminal = [terminals.source, terminals.sink].some( + (other) => other.kind === 'stock' && strictlyInside(p, stockBody(other.stock)), + ); + if (inTerminal || stocks?.some((s) => strictlyInside(p, stockBody(s)))) { + return true; + } + } + return false; +} + +/** + * Classify a flow's path against its terminals (G2-G6). The planner uses this to + * decide whether committing onto a target yields a view that holds the + * invariants; `stocks` extends G6's cloud clause to the rest of the view. A path + * through a non-terminal stock is a routing preference, not a fault, so it has + * no say here. + */ +export function flowFault(flow: FlowViewElement, terminals: Terminals, stocks?: readonly XY[]): RouteFault { + return FAULT_NAMES[pathQuality(flow.points, terminals, stocks).fault]; +} diff --git a/src/diagram/gesture-planner/base.ts b/src/diagram/gesture-planner/base.ts new file mode 100644 index 000000000..f7376929c --- /dev/null +++ b/src/diagram/gesture-planner/base.ts @@ -0,0 +1,89 @@ +// Copyright 2026 The Simlin Authors. All rights reserved. +// Use of this source code is governed by the Apache License, +// Version 2.0, that can be found in the LICENSE file. + +/** + * E5: when a republished view invalidates a live gesture. + */ + +import type { StockFlowView, UID, ViewElement } from '@simlin/core/datamodel'; + +import { GEOMETRY_EPSILON } from '../flow-geometry'; + +function near(a: number, b: number): boolean { + return Math.abs(a - b) <= GEOMETRY_EPSILON || (Number.isNaN(a) && Number.isNaN(b)); +} + +function nearOptional(a: number | undefined, b: number | undefined): boolean { + return a === undefined || b === undefined ? a === b : near(a, b); +} + +/** + * Whether two versions of an element agree on every field a gesture reads: + * position, attachments and label side, compared within GEOMETRY_EPSILON. + * Derived fields (`isStraight`, `var`, `ident`) are ignored, since an engine + * round trip re-derives them, and so are names: a gesture copies names from the + * view it renders, and a rename reaches the canvas as its own republish. + */ +function sameReadFields(a: ViewElement, b: ViewElement): boolean { + if (a.type !== b.type) { + return false; + } + // A link has no position of its own: the datamodel reads its x/y as NaN, and + // nothing draws or routes through them, so only its ends and arc are read. + if (a.type !== 'link' && (!near(a.x, b.x) || !near(a.y, b.y))) { + return false; + } + switch (a.type) { + case 'flow': { + const bp = (b as typeof a).points; + return ( + a.labelSide === (b as typeof a).labelSide && + a.points.length === bp.length && + a.points.every((p, i) => near(p.x, bp[i].x) && near(p.y, bp[i].y) && p.attachedToUid === bp[i].attachedToUid) + ); + } + case 'stock': + case 'aux': + case 'module': + return a.labelSide === (b as typeof a).labelSide; + case 'alias': + return a.aliasOfUid === (b as typeof a).aliasOfUid && a.labelSide === (b as typeof a).labelSide; + case 'cloud': + return a.flowUid === (b as typeof a).flowUid; + case 'link': { + const l = b as typeof a; + return a.fromUid === l.fromUid && a.toUid === l.toUid && nearOptional(a.arc, l.arc); + } + case 'group': { + const g = b as typeof a; + return near(a.width, g.width) && near(a.height, g.height); + } + } +} + +/** + * Whether `current` still agrees with the view a gesture captured at press on + * every element's read fields (see `sameReadFields`), with the same set of + * elements. The comparison covers the whole view: a flow endpoint's targets are + * every stock, a link's every named element, and a move routes through any + * attachment, so every element is read by some gesture that can be live. + * Republishes that change nothing geometric (sim results, error annotations, a + * round trip one ULP away, the press's own selection change) keep the gesture. + */ +export function sameGeometry(base: StockFlowView, current: StockFlowView): boolean { + if (base === current) { + return true; + } + if (base.elements.length !== current.elements.length) { + return false; + } + const byUid = new Map(current.elements.map((el) => [el.uid, el])); + if (byUid.size !== current.elements.length) { + return false; + } + return base.elements.every((el) => { + const other = byUid.get(el.uid); + return other !== undefined && sameReadFields(el, other); + }); +} diff --git a/src/diagram/gesture-planner/classify.ts b/src/diagram/gesture-planner/classify.ts new file mode 100644 index 000000000..cf48d07e4 --- /dev/null +++ b/src/diagram/gesture-planner/classify.ts @@ -0,0 +1,253 @@ +// Copyright 2026 The Simlin Authors. All rights reserved. +// Use of this source code is governed by the Apache License, +// Version 2.0, that can be found in the LICENSE file. + +/** + * classifyPress: what a press starts. The Canvas hit-tests the DOM event into a + * `PressHit` and tracks its pointers; everything that decides which gesture + * follows, which selection the press applies at once and which a click settles + * on, and whether an armed tool is cleared, lives here. + */ + +import type { StockFlowView, UID } from '@simlin/core/datamodel'; + +import { segmentHold, type XY } from '../flow-geometry'; +import { beyondThreshold, delta, isLinkSource, segmentNearest } from './common'; +import type { PressGesture, Tool } from './types'; + +export type PressHit = + /** The empty canvas. */ + | { readonly kind: 'canvas' } + /** An element's body, a flow or link arrowhead, or a flow's source grip. */ + | { readonly kind: 'element'; readonly uid: UID; readonly part: 'body' | 'arrowhead' | 'source' } + | { readonly kind: 'labelDoubleClick'; readonly uid: UID } + /** A label dragged past its own click threshold (the label component owns that threshold). */ + | { readonly kind: 'labelDrag'; readonly uid: UID } + | { readonly kind: 'moduleDoubleClick'; readonly uid: UID } + /** The overlay behind an open name editor. */ + | { readonly kind: 'nameEditor' }; + +export interface PressInput { + readonly view: StockFlowView; + readonly selection: ReadonlySet; + readonly tool: Tool | undefined; + readonly hit: PressHit; + /** The press in model coordinates. */ + readonly point: XY; + readonly shiftKey: boolean; + /** Ctrl or Meta. */ + readonly toggleKey: boolean; + readonly pointerType: string; + readonly readOnly: boolean; + /** The host has an undo or redo queued: a gesture planned on the view it replaces could not commit. */ + readonly pressesDisabled: boolean; + /** Pointers down, this press included. */ + readonly pointers: number; + /** Another pointer owns a live gesture. */ + readonly gestureLive: boolean; +} + +export type PressOutcome = + | { readonly kind: 'ignore' } + /** A second touch: abort any live gesture (E5) and start a pinch. */ + | { readonly kind: 'pinch' } + /** A second pointer: abort the live gesture without committing (E5). */ + | { readonly kind: 'abort' } + /** Commit the open name editor. */ + | { readonly kind: 'commitName' } + | { readonly kind: 'drill'; readonly uid: UID } + | { + readonly kind: 'editName'; + readonly uid: UID; + readonly selection: ReadonlySet; + readonly clearTool: boolean; + } + /** A selection change and nothing else: no drag follows. */ + | { readonly kind: 'select'; readonly selection: ReadonlySet; readonly clearTool: boolean } + | { + readonly kind: 'start'; + readonly gesture: PressGesture; + /** The selection applied at press, or undefined to leave it (a deferred single select). */ + readonly selection: ReadonlySet | undefined; + /** The selection a release within the click threshold settles on; undefined keeps the press's. */ + readonly clickSelection: ReadonlySet | undefined; + readonly clearTool: boolean; + }; + +const EMPTY: ReadonlySet = new Set(); + +function start( + gesture: PressGesture, + selection: ReadonlySet | undefined, + clickSelection: ReadonlySet | undefined, + clearTool: boolean, +): PressOutcome { + return { kind: 'start', gesture, selection, clickSelection, clearTool }; +} + +/** + * The press table. In order: + * + * - the name editor's overlay commits the name (before anything else, so a + * typed name is never lost to a disabled or multi-touch press); + * - presses are ignored while the host has an undo or redo queued; + * - a second touch starts a pinch (a third is ignored), and any other second + * pointer aborts the live gesture; + * - a label double-click opens the name editor (read-only: selects only); + * - a label drag moves the label; + * - on the empty canvas: an aux/stock/module tool stages a draft element, the + * flow tool draws a flow out of empty space, touch or Shift pans, and anything + * else rubber-bands (a click clears the selection). The link tool on the empty + * canvas starts nothing of its own and stays armed; + * - on an element: the link tool on a named element or an alias draws a link, + * the flow tool on a stock draws a flow; any other armed tool is cleared and + * the press behaves as with no tool; + * - a flow's arrowhead or source grip drags that end, a link's arrowhead drags + * the link's end, each selecting just the flow or link; + * - a modifier press toggles the element: toggling it out starts nothing, + * toggling it in moves the new selection; + * - a cloud that is unselected or the sole selection drags its flow's end; a + * cloud in a multi-element selection moves with the selection; + * - a press on a selected element defers collapsing the selection to it until a + * click, so a drag moves the whole selection; an unselected element is + * selected at once; + * - when the element ends up the sole selection, a link's body adjusts its arc + * and a flow's pipe or valve waits for the first move to latch slide or offset. + */ +export function classifyPress(input: PressInput): PressOutcome { + const { hit, tool } = input; + if (hit.kind === 'nameEditor') { + return { kind: 'commitName' }; + } + if (hit.kind === 'moduleDoubleClick') { + return { kind: 'drill', uid: hit.uid }; + } + if (input.pressesDisabled) { + return { kind: 'ignore' }; + } + if (input.pointers > 2) { + return { kind: 'ignore' }; + } + if (input.pointers === 2) { + return input.pointerType === 'touch' ? { kind: 'pinch' } : { kind: 'abort' }; + } + if (input.gestureLive) { + return { kind: 'abort' }; + } + const clearTool = tool !== undefined; + switch (hit.kind) { + case 'labelDoubleClick': { + const only = new Set([hit.uid]); + return input.readOnly + ? { kind: 'select', selection: only, clearTool } + : { kind: 'editName', uid: hit.uid, selection: only, clearTool }; + } + case 'labelDrag': + return start({ kind: 'label', uid: hit.uid }, new Set([hit.uid]), undefined, clearTool); + case 'canvas': + if (tool === 'aux' || tool === 'stock' || tool === 'module') { + return start({ kind: 'createElement', type: tool }, EMPTY, undefined, false); + } + if (tool === 'flow') { + return start({ kind: 'createFlow', from: 'empty' }, undefined, undefined, false); + } + if (input.pointerType === 'touch' || input.shiftKey) { + return start({ kind: 'pan' }, undefined, undefined, false); + } + return start({ kind: 'rubberBand' }, undefined, EMPTY, false); + case 'element': + return classifyElementPress(input, hit.uid, hit.part); + } +} + +function classifyElementPress(input: PressInput, uid: UID, part: 'body' | 'arrowhead' | 'source'): PressOutcome { + const { view, tool, selection } = input; + const el = view.elements.find((e) => e.uid === uid); + if (el === undefined) { + return { kind: 'ignore' }; + } + if (part === 'body' && tool === 'link' && isLinkSource(el)) { + return start({ kind: 'createLink', from: uid }, undefined, undefined, false); + } + if (part === 'body' && tool === 'flow' && el.type === 'stock') { + return start({ kind: 'createFlow', from: { stock: uid } }, undefined, undefined, false); + } + const clearTool = tool !== undefined; + const only = new Set([uid]); + if (el.type === 'flow' && part !== 'body') { + return start( + { kind: 'flowEndpoint', flow: uid, end: part === 'source' ? 'source' : 'sink' }, + only, + undefined, + clearTool, + ); + } + if (el.type === 'link' && part === 'arrowhead') { + return start({ kind: 'linkEndpoint', link: uid }, only, undefined, clearTool); + } + const selected = selection.has(uid); + if (input.shiftKey || input.toggleKey) { + if (selected) { + return { kind: 'select', selection: new Set([...selection].filter((u) => u !== uid)), clearTool }; + } + const added = new Set([...selection, uid]); + return start({ kind: 'moveSelection' }, added, undefined, clearTool); + } + if (el.type === 'cloud' && !(selected && selection.size > 1)) { + const flow = view.elements.find((e) => e.uid === el.flowUid); + if (flow?.type === 'flow' && flow.points.length >= 2) { + const end = + flow.points[0].attachedToUid === uid + ? 'source' + : flow.points[flow.points.length - 1].attachedToUid === uid + ? 'sink' + : undefined; + if (end !== undefined) { + return start({ kind: 'flowEndpoint', flow: flow.uid, end }, new Set([flow.uid]), undefined, clearTool); + } + } + } + const effective = selected ? selection : only; + let gesture: PressGesture = { kind: 'moveSelection' }; + if (effective.size === 1 && el.type === 'link') { + gesture = { kind: 'linkArc', link: uid }; + } else if (effective.size === 1 && el.type === 'flow' && el.points.length >= 2) { + gesture = { kind: 'pipe', flow: uid, segmentIndex: segmentNearest(el.points, input.point) }; + } + return start(gesture, selected ? undefined : only, only, clearTool); +} + +/** + * Latch a pipe press once the pointer passes the click threshold: a + * perpendicular-dominant first move offsets the pressed segment, anything else + * slides the valve. Every other gesture is returned unchanged, as is a pipe + * press still within the threshold. + */ +export function latchGesture( + gesture: PressGesture, + input: { readonly view: StockFlowView; readonly press: XY; readonly current: XY; readonly zoom: number }, +): PressGesture { + if (gesture.kind !== 'pipe' || !beyondThreshold(input.press, input.current, input.zoom)) { + return gesture; + } + const flow = input.view.elements.find((e) => e.uid === gesture.flow); + if (flow?.type !== 'flow' || gesture.segmentIndex < 0 || gesture.segmentIndex >= flow.points.length - 1) { + return { kind: 'slideValve', flow: gesture.flow }; + } + const { axis } = segmentHold(flow.points, gesture.segmentIndex); + const d = delta(input.press, input.current); + const along = Math.abs(axis === 'x' ? d.x : d.y); + const across = Math.abs(axis === 'x' ? d.y : d.x); + return across > along + ? { kind: 'offsetSegment', flow: gesture.flow, segmentIndex: gesture.segmentIndex } + : { kind: 'slideValve', flow: gesture.flow }; +} + +/** + * A mouse move that reports no button held belongs to a release the canvas + * never saw (it landed outside the window): the gesture is cancelled, not + * committed. + */ +export function isLostRelease(pointerType: string, buttons: number): boolean { + return pointerType === 'mouse' && buttons === 0; +} diff --git a/src/diagram/gesture-planner/common.ts b/src/diagram/gesture-planner/common.ts new file mode 100644 index 000000000..500993d3a --- /dev/null +++ b/src/diagram/gesture-planner/common.ts @@ -0,0 +1,302 @@ +// Copyright 2026 The Simlin Authors. All rights reserved. +// Use of this source code is governed by the Apache License, +// Version 2.0, that can be found in the LICENSE file. + +/** + * Small pure helpers every gesture plan shares: the click threshold, element + * lookup and merging, hit tests, the other endpoints on a stock, and link arcs + * that follow their moved endpoints. + */ + +import { + isNamedViewElement, + type CloudViewElement, + type FlowViewElement, + type LinkViewElement, + type StockFlowView, + type UID, + type ViewElement, +} from '@simlin/core/datamodel'; + +import { radToDeg, updateArcAngle } from '../arc-utils'; +import { getVisualCenter } from '../drawing/Connector'; +import { AuxRadius, ModuleHeight, ModuleWidth, StockHeight, StockWidth } from '../drawing/default'; +import { ClickDragThresholdPx } from '../drawing/pointer-utils'; +import { GEOMETRY_EPSILON, type XY } from '../flow-geometry'; +import type { GesturePlan, PlanInput } from './types'; + +/** + * Whether the pointer has moved past the click threshold. The threshold is in + * screen pixels (a finger or mouse wobbles in screen space), so the model-space + * distance is scaled by the zoom before comparing. + */ +export function beyondThreshold(press: XY, current: XY, zoom: number): boolean { + return Math.hypot(current.x - press.x, current.y - press.y) * zoom >= ClickDragThresholdPx; +} + +export function delta(from: XY, to: XY): XY { + return { x: to.x - from.x, y: to.y - from.y }; +} + +/** A flow's endpoint at `end`, read off its own points (a routed flow can have more points than its base). */ +export function endpointOf(points: readonly XY[], end: 'source' | 'sink'): XY { + return end === 'source' ? points[0] : points[points.length - 1]; +} + +/** + * A flow about to be routed, with each end attached the way strict G1 requires: + * an end with no attachment, a dangling one, or one attached to anything but a + * stock or this flow's own cloud (imported data the editor accepts, e.g. Vensim + * fallback flows) gets a new cloud at its endpoint, taking uids from `nextUid`. + * The geometry core leaves such an end free and unattached, so the planner owns + * attaching it. Identity (and no clouds) when both ends are attached. + */ +export function attachLooseEnds( + flow: FlowViewElement, + byUid: ReadonlyMap, + nextUid: number, +): { readonly flow: FlowViewElement; readonly clouds: readonly CloudViewElement[]; readonly nextUid: number } { + const last = flow.points.length - 1; + const clouds: CloudViewElement[] = []; + let uid = nextUid; + const points = flow.points.map((p, i) => { + if (i !== 0 && i !== last) { + return p; + } + const el = p.attachedToUid === undefined ? undefined : byUid.get(p.attachedToUid); + if (el?.type === 'stock' || (el?.type === 'cloud' && el.flowUid === flow.uid)) { + return p; + } + const cloud: CloudViewElement = { + type: 'cloud', + uid: uid++, + flowUid: flow.uid, + x: p.x, + y: p.y, + isZeroRadius: false, + ident: undefined, + }; + clouds.push(cloud); + return { ...p, attachedToUid: cloud.uid }; + }); + return clouds.length === 0 ? { flow, clouds, nextUid } : { flow: { ...flow, points }, clouds, nextUid: uid }; +} + +export function byUidOf(elements: readonly ViewElement[]): Map { + return new Map(elements.map((el) => [el.uid, el])); +} + +/** + * The view's elements with `changed` substituted by uid (in place, so element + * order and z-order are kept), `removed` dropped, and `added` appended. An + * element in `changed` whose uid the view lacks is appended too. + */ +export function mergeElements( + elements: readonly ViewElement[], + changed: ReadonlyMap, + removed: ReadonlySet = new Set(), +): ViewElement[] { + const out: ViewElement[] = []; + const seen = new Set(); + for (const el of elements) { + seen.add(el.uid); + if (removed.has(el.uid)) { + continue; + } + out.push(changed.get(el.uid) ?? el); + } + for (const [uid, el] of changed) { + if (!seen.has(uid) && !removed.has(uid)) { + out.push(el); + } + } + return out; +} + +/** The plan that changes nothing: what a sub-threshold frame, a read-only drag and an aborted subject all render. */ +export function idlePlan(input: PlanInput): GesturePlan { + return { + elements: input.view.elements, + nextUid: input.view.nextUid, + commit: 'none', + selection: input.selection, + label: '', + }; +} + +/** + * The plan of a release within the click threshold: nothing moves, and the + * selection settles on the press's click selection when that differs. + */ +export function clickPlan(input: PlanInput, details: boolean): GesturePlan { + const selection = input.clickSelection ?? input.selection; + const changed = !sameSet(selection, input.selection); + return { ...idlePlan(input), selection, commit: changed ? 'select' : 'none', details }; +} + +export function sameSet(a: ReadonlySet, b: ReadonlySet): boolean { + return a.size === b.size && [...a].every((v) => b.has(v)); +} + +export function stockContains(stock: XY, p: XY): boolean { + return Math.abs(p.x - stock.x) <= StockWidth / 2 && Math.abs(p.y - stock.y) <= StockHeight / 2; +} + +function circleContains(center: XY, p: XY, radius: number): boolean { + return Math.hypot(p.x - center.x, p.y - center.y) <= radius; +} + +/** The first stock of the view whose body contains `p`. */ +export function stockUnder(view: StockFlowView, p: XY) { + for (const el of view.elements) { + if (el.type === 'stock' && stockContains(el, p)) { + return el; + } + } + return undefined; +} + +/** + * The first element a link can point at under `p`: an aux (its circle), a flow + * (its valve) or a module (its body). Stocks and aliases are not link targets. + */ +export function linkTargetUnder(view: StockFlowView, p: XY): ViewElement | undefined { + for (const el of view.elements) { + if ((el.type === 'aux' || el.type === 'flow') && circleContains(el, p, AuxRadius)) { + return el; + } + if (el.type === 'module' && Math.abs(p.x - el.x) <= ModuleWidth / 2 && Math.abs(p.y - el.y) <= ModuleHeight / 2) { + return el; + } + } + return undefined; +} + +export function stockPositions(elements: readonly ViewElement[]): XY[] { + return elements.filter((el) => el.type === 'stock').map((el) => ({ x: el.x, y: el.y })); +} + +/** + * The endpoints of flows other than `flowUid` attached to any of `stockUids`, + * for the slot preference. `shift` moves the endpoints on a moving stock with + * it, since the preference reads the frame's coordinates. + */ +export function occupiedOn( + elements: readonly ViewElement[], + flowUid: UID, + stockUids: ReadonlySet, + shift: (stockUid: UID) => XY = () => ({ x: 0, y: 0 }), +): XY[] { + const out: XY[] = []; + for (const el of elements) { + if (el.type !== 'flow' || el.uid === flowUid || el.points.length < 2) { + continue; + } + for (const p of [el.points[0], el.points[el.points.length - 1]]) { + if (p.attachedToUid !== undefined && stockUids.has(p.attachedToUid)) { + const d = shift(p.attachedToUid); + out.push({ x: p.x + d.x, y: p.y + d.y }); + } + } + } + return out; +} + +/** The index of the segment of `points` nearest to `p` (the earliest on a tie). */ +export function segmentNearest(points: readonly XY[], p: XY): number { + let best = 0; + let bestDistance = Infinity; + for (let i = 0; i < points.length - 1; i++) { + const a = points[i]; + const b = points[i + 1]; + const dx = b.x - a.x; + const dy = b.y - a.y; + const l2 = dx * dx + dy * dy; + const t = l2 === 0 ? 0 : Math.max(0, Math.min(1, ((p.x - a.x) * dx + (p.y - a.y) * dy) / l2)); + const d = Math.hypot(p.x - (a.x + t * dx), p.y - (a.y + t * dy)); + if (d < bestDistance - GEOMETRY_EPSILON) { + bestDistance = d; + best = i; + } + } + return best; +} + +export type LabelSideName = 'top' | 'left' | 'bottom' | 'right'; + +/** + * The side a label snaps to for a pointer at `pointer` around an element at + * `center`: the quadrant of the direction from the pointer toward the center + * (a pointer left of the center puts the label on the left). + */ +export function labelSideForPointer(center: XY, pointer: XY): LabelSideName { + const angle = (Math.atan2(center.y - pointer.y, center.x - pointer.x) * 180) / Math.PI; + if (-45 < angle && angle <= 45) { + return 'left'; + } else if (45 < angle && angle <= 135) { + return 'top'; + } else if (-135 < angle && angle <= -45) { + return 'bottom'; + } + return 'right'; +} + +// Endpoints moving by the same amount within this tolerance translate a link +// rather than bend it: float noise between two routed positions is not rotation. +const MOVEMENT_EQUALITY_EPSILON = 0.1; + +/** + * Links whose endpoint elements moved, updated once from the final elements: + * both ends moved alike keeps the arc (and translates a multi-point path); + * otherwise the arc turns with the line between the endpoints' visual centers, + * so the curve keeps its shape relative to that line. A straight link (no arc) + * stays straight. Returns the changed links by uid; `next` holds only the + * elements the gesture changed. + */ +export function followLinks( + base: ReadonlyMap, + next: ReadonlyMap, +): Map { + const out = new Map(); + for (const link of base.values()) { + if (link.type !== 'link' || next.has(link.uid)) { + continue; + } + const oldFrom = base.get(link.fromUid); + const oldTo = base.get(link.toUid); + if (oldFrom === undefined || oldTo === undefined) { + continue; + } + const newFrom = next.get(link.fromUid) ?? oldFrom; + const newTo = next.get(link.toUid) ?? oldTo; + const fromDelta = delta(newFrom, oldFrom); + const toDelta = delta(newTo, oldTo); + const didMove = fromDelta.x !== 0 || fromDelta.y !== 0 || toDelta.x !== 0 || toDelta.y !== 0; + if (!didMove) { + continue; + } + const sameMovement = + Math.abs(fromDelta.x - toDelta.x) < MOVEMENT_EQUALITY_EPSILON && + Math.abs(fromDelta.y - toDelta.y) < MOVEMENT_EQUALITY_EPSILON; + if (sameMovement) { + if (link.multiPoint !== undefined) { + const multiPoint = link.multiPoint.map((p) => ({ ...p, x: p.x - fromDelta.x, y: p.y - fromDelta.y })); + out.set(link.uid, { ...link, multiPoint }); + } + continue; + } + const oldFromVisual = getVisualCenter(oldFrom); + const oldToVisual = getVisualCenter(oldTo); + const newFromVisual = getVisualCenter(newFrom); + const newToVisual = getVisualCenter(newTo); + const oldθ = Math.atan2(oldToVisual.cy - oldFromVisual.cy, oldToVisual.cx - oldFromVisual.cx); + const newθ = Math.atan2(newToVisual.cy - newFromVisual.cy, newToVisual.cx - newFromVisual.cx); + out.set(link.uid, { ...link, arc: updateArcAngle(link.arc, radToDeg(oldθ - newθ)) }); + } + return out; +} + +/** Whether `el` can be the source of a link: a named element or an alias of one (M3). */ +export function isLinkSource(el: ViewElement): boolean { + return isNamedViewElement(el) || el.type === 'alias'; +} diff --git a/src/diagram/gesture-planner/flow-ends.ts b/src/diagram/gesture-planner/flow-ends.ts new file mode 100644 index 000000000..e7c23dc17 --- /dev/null +++ b/src/diagram/gesture-planner/flow-ends.ts @@ -0,0 +1,264 @@ +// Copyright 2026 The Simlin Authors. All rights reserved. +// Use of this source code is governed by the Apache License, +// Version 2.0, that can be found in the LICENSE file. + +/** + * Gestures that move a flow's end onto a stock or into empty space: dragging an + * existing end (`flowEndpoint`) and drawing a new flow (`createFlow`). + */ + +import { canonicalize } from '@simlin/core/canonicalize'; +import type { CloudViewElement, FlowViewElement, StockViewElement, UID, ViewElement } from '@simlin/core/datamodel'; + +import { + flowFault, + flowTerminals, + freeTerminal, + heal, + route, + routeEnd, + stockTerminal, + type FlowEnd, + type Terminal, + type Terminals, + type XY, +} from '../flow-geometry'; +import { + attachLooseEnds, + byUidOf, + delta, + endpointOf, + followLinks, + idlePlan, + mergeElements, + occupiedOn, + stockPositions, + stockUnder, +} from './common'; +import type { GesturePlan, PlanInput } from './types'; + +function stockVariableExists(input: PlanInput, stock: StockViewElement): boolean { + return input.variables.get(canonicalize(stock.name))?.type === 'stock'; +} + +function stockUidsOf(...terminals: readonly Terminal[]): Set { + const out = new Set(); + for (const t of terminals) { + if (t.kind === 'stock') { + out.add(t.stock.uid); + } + } + return out; +} + +function newCloud(uid: UID, flowUid: UID, at: XY): CloudViewElement { + return { type: 'cloud', uid, flowUid, x: at.x, y: at.y, isZeroRadius: false, ident: undefined }; +} + +/** + * Drag one end of a flow. The end follows the pointer, keeping the grab offset + * (it moves by the pointer's travel from the press). The pointer, not the end, + * hit-tests targets: + * + * - over a stock, the end routes onto it, and the drop is valid exactly when + * the stock's variable exists, the stock is not the flow's other end, and the + * routed flow holds G2-G6; a valid drop deletes the end's old cloud; + * - over an invalid stock the preview shows the end free at the pointer and the + * drop commits nothing (E6); + * - over empty space the end becomes, or stays, a cloud at the end, committing + * when the routed flow holds G2-G6. + * + * Cloud-attached and stock-attached ends are one gesture. The flow is healed + * first, since the drag routes it. + */ +export function planFlowEndpoint(input: PlanInput, flowUid: UID, end: FlowEnd): GesturePlan { + const { view } = input; + const base = byUidOf(view.elements); + const el = base.get(flowUid); + if (el?.type !== 'flow' || el.points.length < 2) { + return idlePlan(input); + } + const stocks = stockPositions(view.elements); + const loose = attachLooseEnds(el, base, view.nextUid); + const byUid = new Map(base); + for (const cloud of loose.clouds) { + byUid.set(cloud.uid, cloud); + } + const h = heal(loose.flow, flowTerminals(loose.flow, byUid), { stocks }); + for (const cloud of h.clouds) { + byUid.set(cloud.uid, cloud); + } + const flow = h.flow; + const n = flow.points.length; + const endIndex = end === 'source' ? 0 : n - 1; + const adjacentIndex = end === 'source' ? 1 : n - 2; + const terminals = flowTerminals(flow, byUid); + const fixed = end === 'source' ? terminals.sink : terminals.source; + const attachedUid = flow.points[endIndex].attachedToUid; + const endEl = attachedUid === undefined ? undefined : byUid.get(attachedUid); + // Every stock is in the way, the one this end leaves included: a route keeping + // the old face's line would run straight through it, and a pipe through any + // stock reads as attached to it. + const obstacles = stocks; + const withEnd = (t: Terminal): Terminals => + end === 'source' ? { source: t, sink: fixed } : { source: fixed, sink: t }; + const changed = new Map([...loose.clouds, ...h.clouds].map((c) => [c.uid, c])); + const finish = ( + commit: GesturePlan['commit'], + nextUid: number, + target: GesturePlan['target'], + removed: ReadonlySet, + ) => { + for (const [uid, link] of followLinks(base, changed)) { + changed.set(uid, link); + } + return { + elements: mergeElements(view.elements, changed, removed), + nextUid, + target, + commit, + selection: input.selection, + label: commit === 'edit' ? 'flow attach' : '', + }; + }; + + const target = stockUnder(view, input.current); + let mark: GesturePlan['target']; + if (target !== undefined) { + const terminal = + endEl?.uid === target.uid + ? stockTerminal(target, flow.points[endIndex], flow.points[adjacentIndex]) + : stockTerminal(target); + const g = routeEnd(flow, end, terminal, { + fixed, + occupied: occupiedOn(view.elements, flowUid, stockUidsOf(terminal, fixed)), + obstacles, + }); + const distinct = !(fixed.kind === 'stock' && fixed.stock.uid === target.uid); + const valid = + distinct && stockVariableExists(input, target) && flowFault(g.flow, withEnd(terminal), stocks) === 'none'; + if (valid) { + const removed = new Set(); + if (endEl?.type === 'cloud') { + removed.add(endEl.uid); + changed.delete(endEl.uid); + } + changed.set(flowUid, g.flow); + return finish('edit', loose.nextUid, { uid: target.uid, valid: true }, removed); + } + mark = { uid: target.uid, valid: false }; + } + + const d = delta(input.press, input.current); + const at = { x: flow.points[endIndex].x + d.x, y: flow.points[endIndex].y + d.y }; + let nextUid = loose.nextUid; + const cloud = endEl?.type === 'cloud' ? endEl : newCloud(nextUid++, flowUid, at); + const terminal = freeTerminal(at, cloud); + const g = routeEnd(flow, end, terminal, { + fixed, + occupied: occupiedOn(view.elements, flowUid, stockUidsOf(fixed)), + obstacles, + }); + const p = endpointOf(g.flow.points, end); + changed.set(flowUid, g.flow); + changed.set(cloud.uid, { ...cloud, x: p.x, y: p.y }); + const valid = mark === undefined && flowFault(g.flow, withEnd(terminal), stocks) === 'none'; + return finish(valid ? 'edit' : 'none', nextUid, mark, new Set()); +} + +const NEW_FLOW_NAME = 'New Flow'; + +/** + * Draw a new flow out of a stock (the route picks its face) or out of empty + * space (a cloud at the press point), with its sink at the pointer: onto a + * stock under the pointer when valid (a different stock from the source, whose + * variable exists, with a route holding G2-G6), else a cloud at the pointer. A + * valid drop selects the flow and hands off to its name editor; an invalid + * target commits nothing (E6). + */ +export function planCreateFlow(input: PlanInput, from: { readonly stock: UID } | 'empty'): GesturePlan { + const { view } = input; + const base = byUidOf(view.elements); + let nextUid = view.nextUid; + const flowUid = nextUid++; + const name = input.names(NEW_FLOW_NAME); + const draft: FlowViewElement = { + type: 'flow', + uid: flowUid, + var: undefined, + name, + ident: canonicalize(name), + x: input.press.x, + y: input.press.y, + labelSide: 'bottom', + points: [], + isZeroRadius: false, + }; + let source: Terminal; + let sourceCloud: CloudViewElement | undefined; + if (from === 'empty') { + sourceCloud = newCloud(nextUid++, flowUid, input.press); + source = freeTerminal(input.press, sourceCloud); + } else { + const stock = base.get(from.stock); + if (stock?.type !== 'stock') { + return idlePlan(input); + } + source = stockTerminal(stock); + } + const stocks = stockPositions(view.elements); + const selection = new Set([flowUid]); + const plan = ( + flow: FlowViewElement, + sinkCloud: CloudViewElement | undefined, + commit: 'edit' | 'none', + target: GesturePlan['target'], + ): GesturePlan => { + const added = new Map([[flowUid, flow]]); + const last = flow.points[flow.points.length - 1]; + if (sourceCloud !== undefined) { + added.set(sourceCloud.uid, { ...sourceCloud, x: flow.points[0].x, y: flow.points[0].y }); + } + if (sinkCloud !== undefined) { + added.set(sinkCloud.uid, { ...sinkCloud, x: last.x, y: last.y }); + } + return { + elements: mergeElements(view.elements, added), + nextUid, + target, + commit, + selection, + handoff: commit === 'edit' ? { editName: flowUid } : undefined, + label: commit === 'edit' ? 'flow creation' : '', + }; + }; + + const target = stockUnder(view, input.current); + let mark: GesturePlan['target']; + if (target !== undefined) { + const terminal = stockTerminal(target); + const g = route(source, terminal, { + flow: draft, + occupied: occupiedOn(view.elements, flowUid, stockUidsOf(source, terminal)), + obstacles: stocks, + }); + const distinct = !(source.kind === 'stock' && source.stock.uid === target.uid); + const valid = + distinct && + stockVariableExists(input, target) && + flowFault(g.flow, { source, sink: terminal }, stocks) === 'none'; + if (valid) { + return plan(g.flow, undefined, 'edit', { uid: target.uid, valid: true }); + } + mark = { uid: target.uid, valid: false }; + } + const sinkCloud = newCloud(nextUid++, flowUid, input.current); + const sink = freeTerminal(input.current, sinkCloud); + const g = route(source, sink, { + flow: draft, + occupied: occupiedOn(view.elements, flowUid, stockUidsOf(source)), + obstacles: stocks, + }); + const valid = mark === undefined && flowFault(g.flow, { source, sink }, stocks) === 'none'; + return plan(g.flow, sinkCloud, valid ? 'edit' : 'none', mark); +} diff --git a/src/diagram/gesture-planner/index.ts b/src/diagram/gesture-planner/index.ts new file mode 100644 index 000000000..bc775be27 --- /dev/null +++ b/src/diagram/gesture-planner/index.ts @@ -0,0 +1,37 @@ +// Copyright 2026 The Simlin Authors. All rights reserved. +// Use of this source code is governed by the Apache License, +// Version 2.0, that can be found in the LICENSE file. + +/** + * The gesture planner: every canvas gesture as a pure function of the press, + * the pointer and the rendered view. Pure: no React, no DOM, never mutates its + * inputs. + * + * `classifyPress` decides what a press starts; `latchGesture` settles a pipe + * press into a valve slide or a segment offset on its first move past the click + * threshold; `planGesture` plans one frame, and its `elements` are both what + * the preview renders and what a release commits (E2 by construction); + * `sameGeometry` is the E5 test for whether a republished view invalidates a + * live gesture. Geometry comes from flow-geometry/, names through the host's + * allocator, and the model ops a committed view implies from + * view-model-sync.ts. + * + * Modules: `types`, `classify` (classifyPress, latchGesture), `plan` + * (planGesture and the move, valve, segment, label, rubber-band and draft + * gestures), `flow-ends` (flow endpoints and flow creation), `links`, `common` + * (threshold, hit tests, merging, links following their endpoints) and `base` + * (E5). + */ + +export { sameGeometry } from './base'; +export { + classifyPress, + isLostRelease, + latchGesture, + type PressHit, + type PressInput, + type PressOutcome, +} from './classify'; +export { beyondThreshold, labelSideForPointer, type LabelSideName } from './common'; +export { planGesture } from './plan'; +export type { Gesture, GesturePlan, NameAllocator, PipePress, PlanInput, PressGesture, Tool } from './types'; diff --git a/src/diagram/gesture-planner/links.ts b/src/diagram/gesture-planner/links.ts new file mode 100644 index 000000000..77274146d --- /dev/null +++ b/src/diagram/gesture-planner/links.ts @@ -0,0 +1,170 @@ +// Copyright 2026 The Simlin Authors. All rights reserved. +// Use of this source code is governed by the Apache License, +// Version 2.0, that can be found in the LICENSE file. + +/** + * Link gestures: drawing a link, dragging a link's arrowhead onto another + * element, and curving a link by dragging its body. + */ + +import type { AuxViewElement, LinkViewElement, UID, ViewElement } from '@simlin/core/datamodel'; + +import { radToDeg } from '../arc-utils'; +import { computeLinkCreationArc, takeoffθ } from '../drawing/Connector'; +import { fauxTargetUid } from '../drawing/creation-sentinels'; +import type { XY } from '../flow-geometry'; +import { byUidOf, idlePlan, isLinkSource, linkTargetUnder, mergeElements } from './common'; +import type { GesturePlan, PlanInput } from './types'; + +/** + * The element a dragged link's arrowhead is over, and whether the link may end + * there: never on its own source (that is no target at all, so the drop + * aborts), and never duplicating another link between the same two elements + * (a red target). + */ +function linkTarget( + input: PlanInput, + fromUid: UID, + linkUid: UID | undefined, +): { readonly el: ViewElement; readonly valid: boolean } | undefined { + const el = linkTargetUnder(input.view, input.current); + if (el === undefined || el.uid === fromUid) { + return undefined; + } + const duplicate = input.view.elements.some( + (e) => e.type === 'link' && e.uid !== linkUid && e.fromUid === fromUid && e.toUid === el.uid, + ); + return { el, valid: !duplicate }; +} + +/** + * The zero-radius stand-in a link's preview points at while its arrowhead is + * over no valid target. It exists only in preview frames, which never commit. + */ +function fauxTarget(at: XY): AuxViewElement { + return { + type: 'aux', + uid: fauxTargetUid, + var: undefined, + name: '', + ident: '', + x: at.x, + y: at.y, + labelSide: 'right', + isZeroRadius: true, + }; +} + +/** A link created or reattached with a mouse curves through the pointer; touch links are always straight. */ +function arcThrough(input: PlanInput, from: ViewElement, to: ViewElement): number | undefined { + return input.pointerType === 'touch' ? undefined : computeLinkCreationArc(from, to, input.current); +} + +/** + * Draw a link from `fromUid`. Over a valid target it commits a link curving + * through the pointer and selects it; anywhere else the preview draws a straight + * link to the pointer and the drop commits nothing. + */ +export function planCreateLink(input: PlanInput, fromUid: UID): GesturePlan { + const { view } = input; + const from = view.elements.find((e) => e.uid === fromUid); + if (from === undefined || !isLinkSource(from)) { + return idlePlan(input); + } + const linkUid = view.nextUid; + const t = linkTarget(input, fromUid, undefined); + const link: LinkViewElement = { + type: 'link', + uid: linkUid, + fromUid, + toUid: t?.valid ? t.el.uid : fauxTargetUid, + arc: t?.valid ? arcThrough(input, from, t.el) : undefined, + multiPoint: undefined, + isStraight: false, + polarity: undefined, + // A link has no position; NaN is what the datamodel reads it back as, so the + // pending view and the landed one agree. + x: NaN, + y: NaN, + isZeroRadius: false, + ident: undefined, + }; + const selection = new Set([linkUid]); + if (t?.valid) { + return { + elements: [...view.elements, link], + nextUid: linkUid + 1, + target: { uid: t.el.uid, valid: true }, + commit: 'edit', + selection, + label: 'link creation', + }; + } + return { + elements: [...view.elements, fauxTarget(input.current), link], + nextUid: view.nextUid, + target: t === undefined ? undefined : { uid: t.el.uid, valid: false }, + commit: 'none', + selection, + label: '', + }; +} + +/** + * Drag an existing link's arrowhead. Over a valid target the link ends there, + * curving through the pointer. Over its own source, an invalid target or empty + * space the preview draws it straight to the pointer and the drop commits + * nothing: dropping a link never deletes it. + */ +export function planLinkEndpoint(input: PlanInput, linkUid: UID): GesturePlan { + const { view } = input; + const base = byUidOf(view.elements); + const link = base.get(linkUid); + const from = link?.type === 'link' ? base.get(link.fromUid) : undefined; + if (link?.type !== 'link' || from === undefined) { + return idlePlan(input); + } + const t = linkTarget(input, link.fromUid, linkUid); + if (t?.valid) { + const next = { ...link, toUid: t.el.uid, arc: arcThrough(input, from, t.el) }; + return { + elements: mergeElements(view.elements, new Map([[linkUid, next]])), + nextUid: view.nextUid, + target: { uid: t.el.uid, valid: true }, + commit: 'edit', + selection: input.selection, + label: 'link attach', + }; + } + const preview = { ...link, toUid: fauxTargetUid, arc: undefined }; + return { + elements: [...mergeElements(view.elements, new Map([[linkUid, preview]])), fauxTarget(input.current)], + nextUid: view.nextUid, + target: t === undefined ? undefined : { uid: t.el.uid, valid: false }, + commit: 'none', + selection: input.selection, + label: '', + }; +} + +/** Curve a sole selected link through the pointer by dragging its body. */ +export function planLinkArc(input: PlanInput, linkUid: UID): GesturePlan { + const base = byUidOf(input.view.elements); + const link = base.get(linkUid); + const from = link?.type === 'link' ? base.get(link.fromUid) : undefined; + const to = link?.type === 'link' ? base.get(link.toUid) : undefined; + if (link?.type !== 'link' || from === undefined || to === undefined) { + return idlePlan(input); + } + const arc = radToDeg(takeoffθ({ element: link, from, to, arcPoint: input.current })); + if (!Number.isFinite(arc)) { + return idlePlan(input); + } + return { + elements: mergeElements(input.view.elements, new Map([[linkUid, { ...link, arc }]])), + nextUid: input.view.nextUid, + commit: 'edit', + selection: input.selection, + label: 'link arc', + }; +} diff --git a/src/diagram/gesture-planner/plan.ts b/src/diagram/gesture-planner/plan.ts new file mode 100644 index 000000000..22ec0433d --- /dev/null +++ b/src/diagram/gesture-planner/plan.ts @@ -0,0 +1,424 @@ +// Copyright 2026 The Simlin Authors. All rights reserved. +// Use of this source code is governed by the Apache License, +// Version 2.0, that can be found in the LICENSE file. + +/** + * planGesture: one frame of a gesture, evaluated from the press and the pointer + * now against the rendered view. The preview renders its `elements`, and a + * release commits the plan evaluated at the release point, which is exactly the + * frame the preview showed there (E2). + */ + +import { canonicalize } from '@simlin/core/canonicalize'; +import { + isNamedViewElement, + type CloudViewElement, + type FlowViewElement, + type StockViewElement, + type UID, + type ViewElement, +} from '@simlin/core/datamodel'; + +import { inCreationUid } from '../drawing/creation-sentinels'; +import { AuxRadius } from '../drawing/default'; +import { + flowFault, + flowTerminals, + freeTerminal, + heal, + offsetSegment, + routeEnd, + segmentHold, + slideValve, + stockTerminal, + translate, + type FlowEnd, + type Terminal, + type XY, +} from '../flow-geometry'; +import { latchGesture } from './classify'; +import { + attachLooseEnds, + beyondThreshold, + byUidOf, + clickPlan, + delta, + endpointOf, + followLinks, + idlePlan, + labelSideForPointer, + mergeElements, + occupiedOn, + segmentNearest, + stockPositions, +} from './common'; +import { planCreateFlow, planFlowEndpoint } from './flow-ends'; +import { planCreateLink, planLinkArc, planLinkEndpoint } from './links'; +import type { GesturePlan, PlanInput } from './types'; + +/** + * Plan one frame. Common to every gesture that edits the view: a frame within + * the click threshold changes nothing (E1) and settles the click's selection; a + * read-only frame changes nothing; a missing subject changes nothing. The + * exceptions: an armed creation tool places its draft on a click, a label drag + * was already past the label's own threshold when it started, and a rubber band + * previews only its selection. A pipe press is latched here from the pointer + * when the caller has not latched it (the Canvas latches on the first move and + * keeps it). + */ +export function planGesture(input: PlanInput): GesturePlan { + const gesture = latchGesture(input.gesture, input); + const moved = beyondThreshold(input.press, input.current, input.zoom); + switch (gesture.kind) { + case 'pan': + return idlePlan(input); + case 'rubberBand': + return planRubberBand(input, moved); + case 'createElement': + return input.readOnly ? idlePlan(input) : planCreateElement(input, gesture.type, moved); + case 'label': + return input.readOnly ? idlePlan(input) : planLabel(input, gesture.uid); + case 'pipe': + return clickPlan(input, true); + } + if (!moved) { + const bodyClick = gesture.kind === 'moveSelection' || gesture.kind === 'linkArc'; + return clickPlan(input, bodyClick || gesture.kind === 'slideValve' || gesture.kind === 'offsetSegment'); + } + if (input.readOnly) { + return idlePlan(input); + } + switch (gesture.kind) { + case 'moveSelection': + return planMoveSelection(input); + case 'slideValve': + return planSlideValve(input, gesture.flow); + case 'offsetSegment': + return planOffsetSegment(input, gesture.flow, gesture.segmentIndex); + case 'flowEndpoint': + return planFlowEndpoint(input, gesture.flow, gesture.end); + case 'createFlow': + return planCreateFlow(input, gesture.from); + case 'createLink': + return planCreateLink(input, gesture.from); + case 'linkEndpoint': + return planLinkEndpoint(input, gesture.link); + case 'linkArc': + return planLinkArc(input, gesture.link); + } +} + +function editPlan( + input: PlanInput, + changed: Map, + label: string, + nextUid = input.view.nextUid, +): GesturePlan { + if (changed.size === 0) { + return idlePlan(input); + } + const base = byUidOf(input.view.elements); + for (const [uid, link] of followLinks(base, changed)) { + changed.set(uid, link); + } + return { + elements: mergeElements(input.view.elements, changed), + nextUid, + commit: 'edit', + selection: input.selection, + label, + }; +} + +/** + * The view's flow with its loose ends attached to new clouds (uids from `uids`) + * and healed against its terminals, identity on a valid attached flow; the + * lookup map carries every cloud created or moved onto its endpoint. + */ +function healed( + flow: FlowViewElement, + base: ReadonlyMap, + stocks: readonly XY[], + uids: { next: number }, +): { + readonly flow: FlowViewElement; + readonly byUid: Map; + readonly clouds: readonly CloudViewElement[]; +} { + const loose = attachLooseEnds(flow, base, uids.next); + uids.next = loose.nextUid; + const byUid = new Map(base); + const clouds = new Map(); + for (const cloud of loose.clouds) { + byUid.set(cloud.uid, cloud); + clouds.set(cloud.uid, cloud); + } + const g = heal(loose.flow, flowTerminals(loose.flow, byUid), { stocks }); + for (const cloud of g.clouds) { + byUid.set(cloud.uid, cloud); + clouds.set(cloud.uid, cloud); + } + return { flow: g.flow, byUid, clouds: [...clouds.values()] }; +} + +function flowOf(input: PlanInput, uid: UID): FlowViewElement | undefined { + const el = input.view.elements.find((e) => e.uid === uid); + return el?.type === 'flow' && el.points.length >= 2 ? el : undefined; +} + +function isPositioned(el: ViewElement): boolean { + return ( + el.type === 'stock' || + el.type === 'aux' || + el.type === 'module' || + el.type === 'alias' || + el.type === 'cloud' || + el.type === 'group' + ); +} + +/** + * Move the selection: selected positioned elements translate; a flow whose two + * terminals both move translates; a flow with one moving terminal is healed and + * `routeEnd`-ed to its moved terminal (a stock carries the base face and offset + * along; a cloud is re-centered on the routed endpoint); a selected flow with no + * moving terminal slides its valve. Links follow their moved endpoints. + */ +function planMoveSelection(input: PlanInput): GesturePlan { + const { view, selection } = input; + const d = delta(input.press, input.current); + const base = byUidOf(view.elements); + const changed = new Map(); + const uids = { next: view.nextUid }; + for (const uid of selection) { + const el = base.get(uid); + if (el !== undefined && isPositioned(el)) { + changed.set(uid, { ...el, x: el.x + d.x, y: el.y + d.y } as ViewElement); + } + } + const moving = new Set(changed.keys()); + const stocks = stockPositions(view.elements); + const frameStocks = view.elements + .filter((e) => e.type === 'stock') + .map((e) => (moving.has(e.uid) ? { x: e.x + d.x, y: e.y + d.y } : { x: e.x, y: e.y })); + let valid = true; + const terminalMoves = (uid: UID | undefined): boolean => { + const el = uid === undefined ? undefined : base.get(uid); + return uid !== undefined && moving.has(uid) && (el?.type === 'stock' || el?.type === 'cloud'); + }; + for (const el of view.elements) { + if (el.type !== 'flow' || el.points.length < 2) { + continue; + } + const sourceMoves = terminalMoves(el.points[0].attachedToUid); + const sinkMoves = terminalMoves(el.points[el.points.length - 1].attachedToUid); + if (sourceMoves && sinkMoves) { + changed.set(el.uid, translate(el, d)); + } else if (sourceMoves || sinkMoves) { + const end = sourceMoves ? 'source' : 'sink'; + valid = routeMovedEnd(input, el, end, d, changed, base, stocks, moving, uids, frameStocks) && valid; + } else if (selection.has(el.uid)) { + const h = healed(el, base, stocks, uids); + for (const cloud of h.clouds) { + changed.set(cloud.uid, cloud); + } + changed.set(el.uid, slideValve(h.flow, d)); + } + } + // A routed flow whose geometry cannot hold G2-G6 in this frame (a dragged + // cloud inside another stock, say) makes the move an invalid drop: it previews + // and commits nothing, like an invalid target (E6). + const plan = editPlan(input, changed, 'move', uids.next); + return valid ? plan : { ...plan, commit: 'none', label: '' }; +} + +function routeMovedEnd( + input: PlanInput, + el: FlowViewElement, + end: FlowEnd, + d: XY, + changed: Map, + base: ReadonlyMap, + stocks: readonly XY[], + moving: ReadonlySet, + uids: { next: number }, + frameStocks: readonly XY[], +): boolean { + const h = healed(el, base, stocks, uids); + const flow = h.flow; + const n = flow.points.length; + const endIndex = end === 'source' ? 0 : n - 1; + const adjacentIndex = end === 'source' ? 1 : n - 2; + const endUid = flow.points[endIndex].attachedToUid!; + const terminalEl = h.byUid.get(endUid)!; + const terminals = flowTerminals(flow, h.byUid); + const fixed = end === 'source' ? terminals.sink : terminals.source; + let terminal: Terminal; + if (terminalEl.type === 'stock') { + const moved = changed.get(endUid) as StockViewElement; + terminal = stockTerminal(moved, flow.points[endIndex], flow.points[adjacentIndex], terminalEl); + } else { + const cloud = terminalEl as CloudViewElement; + const at = { x: cloud.x + d.x, y: cloud.y + d.y }; + terminal = freeTerminal(at, { ...cloud, ...at }); + } + const stockUids = new Set(); + for (const t of [terminal, fixed]) { + if (t.kind === 'stock') { + stockUids.add(t.stock.uid); + } + } + const occupied = occupiedOn(input.view.elements, el.uid, stockUids, (uid) => (moving.has(uid) ? d : { x: 0, y: 0 })); + const g = routeEnd(flow, end, terminal, { fixed, occupied, obstacles: frameStocks }); + for (const cloud of h.clouds) { + changed.set(cloud.uid, cloud); + } + for (const cloud of g.clouds) { + changed.set(cloud.uid, cloud); + } + if (terminalEl.type === 'cloud') { + const p = endpointOf(g.flow.points, end); + changed.set(endUid, { ...terminalEl, x: p.x, y: p.y }); + } + changed.set(el.uid, g.flow); + const routedTerminals = end === 'source' ? { source: terminal, sink: fixed } : { source: fixed, sink: terminal }; + return flowFault(g.flow, routedTerminals, frameStocks) === 'none'; +} + +/** Slide a sole selected flow's valve along its (healed) path by the pointer travel. */ +function planSlideValve(input: PlanInput, flowUid: UID): GesturePlan { + const el = flowOf(input, flowUid); + if (el === undefined) { + return idlePlan(input); + } + const uids = { next: input.view.nextUid }; + const h = healed(el, byUidOf(input.view.elements), stockPositions(input.view.elements), uids); + const changed = new Map(h.clouds.map((c) => [c.uid, c])); + changed.set(flowUid, slideValve(h.flow, delta(input.press, input.current))); + return editPlan(input, changed, 'valve move', uids.next); +} + +/** + * Offset the pressed segment of a sole selected flow perpendicular to itself by + * the pointer travel. When healing an imported flow changed its points, the + * segment is the healed path's nearest to the press. + */ +function planOffsetSegment(input: PlanInput, flowUid: UID, segmentIndex: number): GesturePlan { + const el = flowOf(input, flowUid); + if (el === undefined) { + return idlePlan(input); + } + const stocks = stockPositions(input.view.elements); + const uids = { next: input.view.nextUid }; + const h = healed(el, byUidOf(input.view.elements), stocks, uids); + const index = h.flow === el ? segmentIndex : segmentNearest(h.flow.points, input.press); + if (index < 0 || index >= h.flow.points.length - 1) { + return idlePlan(input); + } + const d = delta(input.press, input.current); + const { axis, hold } = segmentHold(h.flow.points, index); + const coordinate = hold + (axis === 'x' ? d.y : d.x); + const g = offsetSegment(h.flow, index, coordinate, flowTerminals(h.flow, h.byUid), { stocks }); + const changed = new Map(h.clouds.map((c) => [c.uid, c])); + for (const cloud of g.clouds) { + changed.set(cloud.uid, cloud); + } + changed.set(flowUid, g.flow); + return editPlan(input, changed, 'pipe move', uids.next); +} + +/** + * Move a label to the side the pointer points it at. No click threshold here: + * the label component starts the gesture only once its own threshold is + * crossed, and a side that does not change commits nothing. + */ +function planLabel(input: PlanInput, uid: UID): GesturePlan { + const el = input.view.elements.find((e) => e.uid === uid); + if (el === undefined || !(isNamedViewElement(el) || el.type === 'alias')) { + return idlePlan(input); + } + const side = labelSideForPointer(el, input.current); + if (side === el.labelSide) { + return idlePlan(input); + } + return editPlan(input, new Map([[uid, { ...el, labelSide: side }]]), 'label move'); +} + +/** + * The rubber band's selection: stocks, clouds, flows (by valve), modules and + * aliases whose center lies in the rectangle, and auxes whose center lies in it + * or whose circle holds one of its corners. Links and groups are never + * rubber-band selected. A click selects nothing. + */ +function planRubberBand(input: PlanInput, moved: boolean): GesturePlan { + if (!moved) { + return { ...idlePlan(input), commit: 'select', selection: input.clickSelection ?? new Set() }; + } + const left = Math.min(input.press.x, input.current.x); + const right = Math.max(input.press.x, input.current.x); + const top = Math.min(input.press.y, input.current.y); + const bottom = Math.max(input.press.y, input.current.y); + const inside = (p: XY): boolean => p.x >= left && p.x <= right && p.y >= top && p.y <= bottom; + const corners: XY[] = [ + { x: left, y: top }, + { x: right, y: top }, + { x: left, y: bottom }, + { x: right, y: bottom }, + ]; + const selection = new Set(); + for (const el of input.view.elements) { + switch (el.type) { + case 'cloud': + case 'stock': + case 'flow': + case 'module': + case 'alias': + if (inside(el)) { + selection.add(el.uid); + } + break; + case 'aux': + if (inside(el) || corners.some((c) => Math.hypot(c.x - el.x, c.y - el.y) <= AuxRadius)) { + selection.add(el.uid); + } + break; + default: + break; + } + } + return { ...idlePlan(input), commit: 'select', selection }; +} + +const DRAFT_NAMES = { aux: 'New Variable', stock: 'New Stock', module: 'New Module' } as const; + +/** + * An armed aux, stock or module tool stages a draft under its default name at + * the pointer (at the press for a click), then hands off to the name editor. + * Nothing commits here: the draft lives in the Canvas through name editing and + * the create is planned against the view rendered when the name is done. + */ +function planCreateElement(input: PlanInput, type: 'aux' | 'stock' | 'module', moved: boolean): GesturePlan { + const at = moved ? input.current : input.press; + const name = input.names(DRAFT_NAMES[type]); + const common = { + uid: inCreationUid, + var: undefined, + x: at.x, + y: at.y, + name, + ident: canonicalize(name), + isZeroRadius: false, + } as const; + const draft: ViewElement = + type === 'aux' + ? { ...common, type: 'aux', labelSide: 'right' } + : type === 'stock' + ? { ...common, type: 'stock', labelSide: 'bottom', inflows: [], outflows: [] } + : { ...common, type: 'module', labelSide: 'bottom' }; + return { + ...idlePlan(input), + elements: [...input.view.elements, draft], + draft, + handoff: { editName: inCreationUid }, + }; +} diff --git a/src/diagram/gesture-planner/types.ts b/src/diagram/gesture-planner/types.ts new file mode 100644 index 000000000..0e03bf188 --- /dev/null +++ b/src/diagram/gesture-planner/types.ts @@ -0,0 +1,104 @@ +// Copyright 2026 The Simlin Authors. All rights reserved. +// Use of this source code is governed by the Apache License, +// Version 2.0, that can be found in the LICENSE file. + +/** + * The vocabulary of the gesture planner: which gesture a press starts, what a + * frame of it plans, and what a press outcome asks the Canvas to do. See + * "gesture-planner.ts" in docs/design-plans/2026-09-10-diagram-editing-core.md. + */ + +import type { StockFlowView, UID, Variable, ViewElement } from '@simlin/core/datamodel'; + +import type { FlowEnd, XY } from '../flow-geometry'; + +export type Tool = 'stock' | 'flow' | 'aux' | 'link' | 'module'; + +export type Gesture = + | { readonly kind: 'moveSelection' } + | { readonly kind: 'slideValve'; readonly flow: UID } + | { readonly kind: 'offsetSegment'; readonly flow: UID; readonly segmentIndex: number } + | { readonly kind: 'flowEndpoint'; readonly flow: UID; readonly end: FlowEnd } + | { readonly kind: 'linkEndpoint'; readonly link: UID } + | { readonly kind: 'linkArc'; readonly link: UID } + | { readonly kind: 'createFlow'; readonly from: { readonly stock: UID } | 'empty' } + | { readonly kind: 'createLink'; readonly from: UID } + | { readonly kind: 'createElement'; readonly type: 'aux' | 'stock' | 'module' } + | { readonly kind: 'label'; readonly uid: UID } + | { readonly kind: 'rubberBand' } + | { readonly kind: 'pan' }; + +// A Record over the union's kinds, so adding a kind without listing it here +// fails to compile, and tables that must cover every gesture derive their rows +// from GESTURE_KINDS. +const GESTURE_KIND_TABLE: Record = { + moveSelection: true, + slideValve: true, + offsetSegment: true, + flowEndpoint: true, + linkEndpoint: true, + linkArc: true, + createFlow: true, + createLink: true, + createElement: true, + label: true, + rubberBand: true, + pan: true, +}; + +export const GESTURE_KINDS = Object.keys(GESTURE_KIND_TABLE) as ReadonlyArray; + +/** + * A press on a sole selected flow's pipe or valve, before the first move past + * the click threshold decides between `slideValve` and `offsetSegment` + * (`latchGesture`). The decision is state the Canvas holds for the rest of the + * gesture: a pure function of the current pointer could not keep it once the + * pointer comes back. + */ +export interface PipePress { + readonly kind: 'pipe'; + readonly flow: UID; + readonly segmentIndex: number; +} + +export type PressGesture = Gesture | PipePress; + +/** The default name of a new element: "New Variable", "New Variable 1", ... against everything that exists. */ +export type NameAllocator = (base: string) => string; + +export interface PlanInput { + /** The rendered view this frame plans on (the Canvas aborts when it changes geometry, E5). */ + readonly view: StockFlowView; + readonly variables: ReadonlyMap; + /** The selection in effect after the press. */ + readonly selection: ReadonlySet; + readonly gesture: PressGesture; + /** Model coordinates of the press and of the pointer now. */ + readonly press: XY; + readonly current: XY; + readonly zoom: number; + readonly pointerType: string; + readonly readOnly: boolean; + readonly names: NameAllocator; + /** The selection a click (a release within the click threshold) settles on; defaults to `selection`. */ + readonly clickSelection?: ReadonlySet; +} + +export interface GesturePlan { + /** What the preview renders and, for `commit: 'edit'`, what the commit saves. */ + readonly elements: readonly ViewElement[]; + readonly nextUid: number; + /** The drop target under the pointer, rendered green when valid and red when not. */ + readonly target?: { readonly uid: UID; readonly valid: boolean }; + readonly commit: 'none' | 'edit' | 'select'; + /** The selection the preview renders and the release applies. */ + readonly selection: ReadonlySet; + /** An element whose name editor opens once the release lands. */ + readonly handoff?: { readonly editName: UID }; + /** The element a creation tool staged (never part of `elements` committed by an edit). */ + readonly draft?: ViewElement; + /** A click on an element's body, which opens its details. */ + readonly details?: boolean; + /** The edit's name in errors and history; empty unless `commit` is 'edit'. */ + readonly label: string; +} diff --git a/src/diagram/group-movement.ts b/src/diagram/group-movement.ts deleted file mode 100644 index 8302eba06..000000000 --- a/src/diagram/group-movement.ts +++ /dev/null @@ -1,914 +0,0 @@ -// Copyright 2026 The Simlin Authors. All rights reserved. -// Use of this source code is governed by the Apache License, -// Version 2.0, that can be found in the LICENSE file. - -/** - * Shared logic for group selection movement. - * - * This module provides functions for computing how diagram elements should - * move when a group selection is dragged. The same logic is used by both - * Editor.tsx (for persisting changes) and Canvas.tsx (for live preview). - */ - -import { first, last } from '@simlin/core/collections'; -import { arrayWith } from '@simlin/core/common'; -import { - ViewElement, - FlowViewElement, - StockViewElement, - CloudViewElement, - LinkViewElement, - UID, - Point, -} from '@simlin/core/datamodel'; -import { updateArcAngle, radToDeg } from './arc-utils'; -import { getVisualCenter, takeoffθ } from './drawing/Connector'; -import { - clampToSegment, - computeFlowOffsets, - computeFlowRoute, - findClosestSegment, - getSegments, - UpdateCloudAndFlow, - UpdateFlow, -} from './drawing/Flow'; - -// Tolerance for floating-point comparison when checking if two movements are equal -const MOVEMENT_EQUALITY_EPSILON = 0.1; - -// Minimum distance in pixels before requiring L-shape routing to avoid diagonal flows -const MIN_DIAGONAL_DISTANCE = 1; - -/** - * Represents a 2D movement delta (change in position). - * Distinct from Point which represents an absolute position with optional attachment. - */ -export interface MovementDelta { - x: number; - y: number; -} - -/** - * Move a selected flow's valve by the drag delta and clamp it back onto the - * flow's (already re-routed) path. - * - * A dragged flow's valve should track the pointer -- flow.x/y shifted by the - * drag delta -- and then snap to the closest segment of the new geometry. The - * routing helpers (computeFlowRoute, the pre-processed pass) instead preserve - * the valve's OLD fractional position, which lags behind the drag; this - * re-clamps it. Returns undefined when the path has no segments (fewer than two - * points) so each caller keeps its own fallback for that unreachable case. - */ -function clampDraggedValve( - flow: FlowViewElement, - delta: MovementDelta, - points: readonly Point[], -): { x: number; y: number } | undefined { - const proposedValve = { x: flow.x - delta.x, y: flow.y - delta.y }; - const segments = getSegments(points); - if (segments.length === 0) { - return undefined; - } - return clampToSegment(proposedValve, findClosestSegment(proposedValve, segments)); -} - -/** - * Result of routing a flow attached to a cloud endpoint. - */ -interface CloudFlowRouteResult { - updatedFlow: FlowViewElement; - movedCloud: CloudViewElement; -} - -/** - * Route a flow attached to a cloud endpoint during group movement. - * - * This handles: - * - Moving the cloud by the full delta - * - Creating an L-shaped flow if the movement would create a diagonal - * - Re-clamping the valve to the new flow path - * - * @param cloud The cloud endpoint being moved - * @param flow The flow attached to the cloud - * @param delta The movement delta - * @param isSource True if the cloud is the source (first point), false if sink (last point) - * @returns The updated flow and moved cloud - */ -function routeCloudEndpointFlow( - cloud: CloudViewElement, - flow: FlowViewElement, - delta: MovementDelta, - isSource: boolean, -): CloudFlowRouteResult { - const [, routedFlow] = UpdateCloudAndFlow(cloud, flow, delta); - - const newCloudX = cloud.x - delta.x; - const newCloudY = cloud.y - delta.y; - const movedCloud: CloudViewElement = { ...cloud, x: newCloudX, y: newCloudY }; - - const cloudPointIndex = isSource ? 0 : routedFlow.points.length - 1; - const otherPointIndex = isSource ? routedFlow.points.length - 1 : 0; - const cloudPoint = routedFlow.points[cloudPointIndex]; - const otherPoint = routedFlow.points[otherPointIndex]; - - let updatedFlow = routedFlow; - if (cloudPoint && otherPoint) { - // Check if the flow is 2-point straight and movement would create a diagonal - const needsLShape = - routedFlow.points.length === 2 && - Math.abs(newCloudX - otherPoint.x) > MIN_DIAGONAL_DISTANCE && - Math.abs(newCloudY - otherPoint.y) > MIN_DIAGONAL_DISTANCE; - - if (needsLShape) { - // Add intermediate point to create L-shape (horizontal then vertical) - const intermediatePoint: Point = { x: newCloudX, y: otherPoint.y, attachedToUid: undefined }; - const newCloudPoint: Point = { ...cloudPoint, x: newCloudX, y: newCloudY }; - // Order depends on whether cloud is source or sink - const newPoints: readonly Point[] = isSource - ? [newCloudPoint, intermediatePoint, otherPoint] - : [otherPoint, intermediatePoint, newCloudPoint]; - updatedFlow = { ...routedFlow, points: newPoints }; - - // Re-clamp valve to the new path - const segments = getSegments(newPoints); - if (segments.length > 0) { - const closestSegment = findClosestSegment({ x: updatedFlow.x, y: updatedFlow.y }, segments); - const clampedValve = clampToSegment({ x: updatedFlow.x, y: updatedFlow.y }, closestSegment); - updatedFlow = { ...updatedFlow, x: clampedValve.x, y: clampedValve.y }; - } - } else { - // Just update the cloud point position - const updatedPoints = arrayWith(routedFlow.points, cloudPointIndex, { - ...cloudPoint, - x: newCloudX, - y: newCloudY, - }); - updatedFlow = { ...routedFlow, points: updatedPoints }; - } - } - - return { updatedFlow, movedCloud }; -} - -/** - * Pre-compute flow offsets for all flows attached to moved stocks. - * This ensures both selected and unselected flows maintain proper spacing. - * - * @param elements All view elements - * @param selectedStockUids UIDs of stocks in the selection - * @param delta The movement delta - * @param isInSelection Function to check if a UID is in the selection - * @returns Map from flow UID to its offset fraction - */ -export function computePreRoutedOffsets( - elements: Iterable, - selectedStockUids: Set, - delta: MovementDelta, - isInSelection: (uid: UID | undefined) => boolean, -): Map { - const preComputedOffsets = new Map(); - - // Materialize the iterable into an array so we can iterate multiple times. - // The caller may pass a single-use iterator (e.g., Map.values()), and we need - // to iterate once for stocks (outer loop) and again for flows (inner loop). - const allElements = Array.from(elements); - - for (const element of allElements) { - if (element.type !== 'stock') continue; - if (!selectedStockUids.has(element.uid)) continue; - - // Collect ALL flows attached to this stock for proper offset computation. - // For flows where both endpoints are selected, we translate their points by delta - // so their anchor position is correct relative to the stock's new position. - // This ensures translated flows reserve their slots and don't overlap with routed flows. - const allFlows: FlowViewElement[] = []; - for (const el of allElements) { - if (el.type !== 'flow') continue; - const pts = el.points; - if (pts.length < 2) continue; - const sourceUid = first(pts).attachedToUid; - const sinkUid = last(pts).attachedToUid; - const attachedToThisStock = sourceUid === element.uid || sinkUid === element.uid; - if (!attachedToThisStock) continue; - - const otherEndpointUid = sourceUid === element.uid ? sinkUid : sourceUid; - const bothEndpointsSelected = isInSelection(otherEndpointUid); - - if (bothEndpointsSelected) { - // Translate points by delta so anchor is correct relative to new stock position - const translatedPoints = pts.map((p) => ({ - ...p, - x: p.x - delta.x, - y: p.y - delta.y, - })); - allFlows.push({ ...el, points: translatedPoints }); - } else { - allFlows.push(el); - } - } - - // Compute offsets at the new stock position. `element` still holds the - // pre-move center, which computeFlowOffsets needs to classify a Z-riser's - // occupied edge (so a Z and a straight flow on the same edge spread instead - // of overlapping). - const newStockCx = element.x - delta.x; - const newStockCy = element.y - delta.y; - const offsets = computeFlowOffsets(allFlows, element.uid, newStockCx, newStockCy, { - x: element.x, - y: element.y, - }); - - // Store offsets for flows - for (const [flowUid, offset] of offsets) { - preComputedOffsets.set(flowUid, offset); - } - } - - return preComputedOffsets; -} - -/** - * Pre-process selected flows with one endpoint selected (stock endpoint only). - * Returns a map from flow UID to the pre-routed flow. - * - * @param elements All view elements - * @param selectedFlowUids UIDs of flows in the selection - * @param preComputedOffsets Pre-computed offsets from computePreRoutedOffsets - * @param delta The movement delta - * @param isInSelection Function to check if a UID is in the selection - * @param getElementByUid Function to get an element by UID - * @returns Map from flow UID to pre-routed flow - */ -export function preProcessSelectedFlows( - elements: Iterable, - selectedFlowUids: Set, - preComputedOffsets: Map, - delta: MovementDelta, - isInSelection: (uid: UID | undefined) => boolean, - getElementByUid: (uid: UID) => ViewElement | undefined, -): Map { - const preProcessedFlows = new Map(); - - for (const element of elements) { - if (element.type !== 'flow') continue; - if (!selectedFlowUids.has(element.uid)) continue; - - const pts = element.points; - if (pts.length < 2) continue; - - const sourceUid = first(pts).attachedToUid; - const sinkUid = last(pts).attachedToUid; - const sourceInSel = isInSelection(sourceUid); - const sinkInSel = isInSelection(sinkUid); - - // Process flows where exactly one endpoint is in selection (and is a stock) - if (sourceInSel && !sinkInSel && sourceUid !== undefined) { - const endpoint = getElementByUid(sourceUid); - if (endpoint && endpoint.type === 'stock') { - const newStockCx = endpoint.x - delta.x; - const newStockCy = endpoint.y - delta.y; - const offset = preComputedOffsets.get(element.uid) ?? 0.5; - const updatedFlow = computeFlowRoute(element, endpoint, newStockCx, newStockCy, offset, { - x: endpoint.x, - y: endpoint.y, - }); - preProcessedFlows.set(element.uid, updatedFlow); - } - } else if (!sourceInSel && sinkInSel && sinkUid !== undefined) { - const endpoint = getElementByUid(sinkUid); - if (endpoint && endpoint.type === 'stock') { - const newStockCx = endpoint.x - delta.x; - const newStockCy = endpoint.y - delta.y; - const offset = preComputedOffsets.get(element.uid) ?? 0.5; - const updatedFlow = computeFlowRoute(element, endpoint, newStockCx, newStockCy, offset, { - x: endpoint.x, - y: endpoint.y, - }); - preProcessedFlows.set(element.uid, updatedFlow); - } - } - } - - return preProcessedFlows; -} - -/** - * Process a selected flow element during group movement. - * - * @param flow The flow element to process - * @param delta The movement delta - * @param isInSelection Function to check if a UID is in the selection - * @param preProcessedFlows Pre-processed flows from preProcessSelectedFlows - * @param getElementByUid Function to get an element by UID - * @returns Tuple of [updatedFlow, sideEffectElements] - */ -export function processSelectedFlow( - flow: FlowViewElement, - delta: MovementDelta, - isInSelection: (uid: UID | undefined) => boolean, - preProcessedFlows: Map, - getElementByUid: (uid: UID) => ViewElement | undefined, -): [FlowViewElement, ViewElement[]] { - const sideEffects: ViewElement[] = []; - const pts = flow.points; - - if (pts.length < 2) { - return [flow, sideEffects]; - } - - const sourceUid = first(pts).attachedToUid; - const sinkUid = last(pts).attachedToUid; - const sourceInSelection = isInSelection(sourceUid); - const sinkInSelection = isInSelection(sinkUid); - - if (sourceInSelection && sinkInSelection) { - // Both endpoints are selected: translate entire flow uniformly - const newPoints = pts.map((p) => ({ - ...p, - x: p.x - delta.x, - y: p.y - delta.y, - })); - return [ - { - ...flow, - x: flow.x - delta.x, - y: flow.y - delta.y, - points: newPoints, - }, - sideEffects, - ]; - } else if (sourceInSelection || sinkInSelection) { - // One endpoint is selected: that endpoint moves, flow re-routes to fixed endpoint - // Check if this flow was pre-processed (for multi-flow spacing preservation) - const preProcessed = preProcessedFlows.get(flow.uid); - if (preProcessed) { - // The pre-processed flow preserved the valve's fractional position, which - // lags the drag when the flow itself is selected; re-clamp it to the path. - const clampedValve = clampDraggedValve(flow, delta, preProcessed.points); - return [clampedValve ? { ...preProcessed, x: clampedValve.x, y: clampedValve.y } : preProcessed, sideEffects]; - } - - // Handle different endpoint types: stocks need computeFlowRoute, clouds need UpdateCloudAndFlow - if (sourceInSelection && sourceUid !== undefined) { - const sourceEndpoint = getElementByUid(sourceUid); - if (sourceEndpoint && sourceEndpoint.type === 'stock') { - // Route flow from moved stock to fixed sink endpoint - const newStockCx = sourceEndpoint.x - delta.x; - const newStockCy = sourceEndpoint.y - delta.y; - // Use default offset of 0.5 (center) for selected flows - let updatedFlow = computeFlowRoute(flow, sourceEndpoint, newStockCx, newStockCy, 0.5, { - x: sourceEndpoint.x, - y: sourceEndpoint.y, - }); - - // computeFlowRoute preserves the valve's fractional position, which lags - // the drag when the flow is selected; re-clamp it to the new path. - const clampedValve = clampDraggedValve(flow, delta, updatedFlow.points); - if (clampedValve) { - updatedFlow = { ...updatedFlow, x: clampedValve.x, y: clampedValve.y }; - } - return [updatedFlow, sideEffects]; - } else if (sourceEndpoint && sourceEndpoint.type === 'cloud') { - const { updatedFlow, movedCloud } = routeCloudEndpointFlow(sourceEndpoint, flow, delta, true); - sideEffects.push(movedCloud); - return [updatedFlow, sideEffects]; - } - } else if (sinkInSelection && sinkUid !== undefined) { - const sinkEndpoint = getElementByUid(sinkUid); - if (sinkEndpoint && sinkEndpoint.type === 'stock') { - // Route flow from fixed source to moved stock - const newStockCx = sinkEndpoint.x - delta.x; - const newStockCy = sinkEndpoint.y - delta.y; - // Use default offset of 0.5 (center) for selected flows - let updatedFlow = computeFlowRoute(flow, sinkEndpoint, newStockCx, newStockCy, 0.5, { - x: sinkEndpoint.x, - y: sinkEndpoint.y, - }); - - // computeFlowRoute preserves the valve's fractional position, which lags - // the drag when the flow is selected; re-clamp it to the new path. - const clampedValve = clampDraggedValve(flow, delta, updatedFlow.points); - if (clampedValve) { - updatedFlow = { ...updatedFlow, x: clampedValve.x, y: clampedValve.y }; - } - return [updatedFlow, sideEffects]; - } else if (sinkEndpoint && sinkEndpoint.type === 'cloud') { - const { updatedFlow, movedCloud } = routeCloudEndpointFlow(sinkEndpoint, flow, delta, false); - sideEffects.push(movedCloud); - return [updatedFlow, sideEffects]; - } - } - - // Fallback: just move valve if we couldn't find the endpoint - return [ - { - ...flow, - x: flow.x - delta.x, - y: flow.y - delta.y, - }, - sideEffects, - ]; - } else { - // Neither endpoint is selected. For cloud-cloud flows, move the entire flow - // and both clouds together. For flows with cloud endpoints, use UpdateFlow - // to allow perpendicular drag rerouting. For stock-to-stock flows, just move - // the valve. - const sourceEl = sourceUid !== undefined ? getElementByUid(sourceUid) : undefined; - const sinkEl = sinkUid !== undefined ? getElementByUid(sinkUid) : undefined; - const sourceIsCloud = sourceEl !== undefined && sourceEl.type === 'cloud'; - const sinkIsCloud = sinkEl !== undefined && sinkEl.type === 'cloud'; - const sourceIsStock = sourceEl !== undefined && sourceEl.type === 'stock'; - const sinkIsStock = sinkEl !== undefined && sinkEl.type === 'stock'; - const hasCloud = sourceIsCloud || sinkIsCloud; - - // Cloud-to-cloud flows: translate everything uniformly (matches UpdateFlow behavior) - if (sourceIsCloud && sinkIsCloud) { - const newPoints = pts.map((p) => ({ - ...p, - x: p.x - delta.x, - y: p.y - delta.y, - })); - const updatedFlow: FlowViewElement = { - ...flow, - x: flow.x - delta.x, - y: flow.y - delta.y, - points: newPoints, - }; - // Update both clouds as side effects - const movedSourceCloud: CloudViewElement = { - ...(sourceEl as CloudViewElement), - x: sourceEl.x - delta.x, - y: sourceEl.y - delta.y, - }; - const movedSinkCloud: CloudViewElement = { - ...(sinkEl as CloudViewElement), - x: sinkEl.x - delta.x, - y: sinkEl.y - delta.y, - }; - sideEffects.push(movedSourceCloud, movedSinkCloud); - return [updatedFlow, sideEffects]; - } - - // Cloud-stock flows: delegate to UpdateFlow for perpendicular drag L-shape behavior - if (hasCloud && sourceEl && sinkEl && (sourceIsStock || sourceIsCloud) && (sinkIsStock || sinkIsCloud)) { - const ends: readonly (StockViewElement | CloudViewElement)[] = [ - sourceEl as StockViewElement | CloudViewElement, - sinkEl as StockViewElement | CloudViewElement, - ]; - const [newFlow, newClouds] = UpdateFlow(flow, ends, delta, undefined); - for (const cloud of newClouds) { - sideEffects.push(cloud); - } - return [newFlow, sideEffects]; - } - - // Stock-to-stock flows: move valve but clamp to flow path - const valve = clampDraggedValve(flow, delta, pts) ?? { x: flow.x - delta.x, y: flow.y - delta.y }; - return [{ ...flow, x: valve.x, y: valve.y }, sideEffects]; - } -} - -/** - * Route unselected flows attached to selected endpoints. - * - * Called after the selected elements have moved (pass 1). Each unselected flow - * whose source and/or sink endpoint is in the selection is re-routed to follow - * the moved endpoint(s): a flow with BOTH ends selected translates uniformly; - * otherwise the flow routes to its single moved endpoint -- stocks via - * `computeFlowRoute` (honoring the pre-computed spacing offset) and clouds via - * `routeCloudEndpointFlow`. - * - * @param elements All view elements at their ORIGINAL (pre-movement) positions. - * The routing helpers derive the endpoints' new positions from `delta`, so - * they need the originals rather than the already-moved copies. - * @param selection Selected UIDs - * @param preComputedOffsets Pre-computed offsets - * @param delta The movement delta - * @returns List of updated flow elements - */ -export function routeUnselectedFlows( - elements: Iterable, - selection: ReadonlySet, - preComputedOffsets: Map, - delta: MovementDelta, -): ViewElement[] { - const updatedFlows: ViewElement[] = []; - - const elementsMap = new Map(); - for (const el of elements) { - elementsMap.set(el.uid, el); - } - - // Collect flows grouped by whichever attached endpoint is selected. - const flowsBySourceEndpoint = new Map(); - const flowsBySinkEndpoint = new Map(); - const bothEndsSelectedFlows: FlowViewElement[] = []; - - for (const element of elementsMap.values()) { - if (element.type !== 'flow') continue; - if (selection.has(element.uid)) continue; // Already processed - - const pts = element.points; - if (pts.length < 2) continue; - - const sourceUid = first(pts).attachedToUid; - const sinkUid = last(pts).attachedToUid; - const sourceEndpointSelected = sourceUid !== undefined && selection.has(sourceUid); - const sinkEndpointSelected = sinkUid !== undefined && selection.has(sinkUid); - - if (sourceEndpointSelected && sinkEndpointSelected) { - bothEndsSelectedFlows.push(element); - } else if (sourceEndpointSelected && sourceUid !== undefined) { - const existing = flowsBySourceEndpoint.get(sourceUid) || []; - existing.push(element); - flowsBySourceEndpoint.set(sourceUid, existing); - } else if (sinkEndpointSelected && sinkUid !== undefined) { - const existing = flowsBySinkEndpoint.get(sinkUid) || []; - existing.push(element); - flowsBySinkEndpoint.set(sinkUid, existing); - } - } - - // Flows with both ends selected translate uniformly. - for (const element of bothEndsSelectedFlows) { - const pts = element.points; - const newPoints = pts.map((p) => ({ - ...p, - x: p.x - delta.x, - y: p.y - delta.y, - })); - updatedFlows.push({ - ...element, - x: element.x - delta.x, - y: element.y - delta.y, - points: newPoints, - }); - } - - // Route flows anchored to a single moved endpoint. `isSource` records which - // end of the flow the grouped endpoint is (source = first point, sink = last). - // Because the maps are keyed only by endpoints that ARE selected, the cloud - // path always honors the full delta -- the former per-endpoint `cloudIsSelected` - // guard was true by construction and has been removed. - const routeForEndpoint = (flowsByEndpoint: Map, isSource: boolean): void => { - for (const [endpointUid, flows] of flowsByEndpoint) { - const endpoint = elementsMap.get(endpointUid); - if (endpoint?.type === 'stock') { - const newStockCx = endpoint.x - delta.x; - const newStockCy = endpoint.y - delta.y; - for (const flow of flows) { - const offset = preComputedOffsets.get(flow.uid) ?? 0.5; - updatedFlows.push( - computeFlowRoute(flow, endpoint, newStockCx, newStockCy, offset, { x: endpoint.x, y: endpoint.y }), - ); - } - } else if (endpoint?.type === 'cloud') { - for (const flow of flows) { - updatedFlows.push(routeCloudEndpointFlow(endpoint, flow, delta, isSource).updatedFlow); - } - } - } - }; - - routeForEndpoint(flowsBySourceEndpoint, true); - routeForEndpoint(flowsBySinkEndpoint, false); - - return updatedFlows; -} - -/** - * Input for the unified applyGroupMovement function. - */ -export interface GroupMovementInput { - /** - * All view elements in the diagram. This can be any Iterable (array, Map.values(), etc.). - * - * IMPORTANT: The iterator will be consumed exactly once when building an internal lookup Map. - * This allows callers to pass any Iterable without pre-materializing it, while the function - * handles the materialization internally for efficient repeated access. - */ - elements: Iterable; - /** UIDs of elements in the selection that should move */ - selection: ReadonlySet; - /** Movement delta to apply (subtracted from positions, so negative = move right/down) */ - delta: MovementDelta; - /** For single-link arc drag: the current drag position */ - arcPoint?: MovementDelta; - /** For single-flow segment drag: which segment is being dragged */ - segmentIndex?: number; -} - -/** - * Output from the unified applyGroupMovement function. - */ -export interface GroupMovementOutput { - updatedElements: Map; -} - -/** - * Process links during group movement. - * - * Links are processed LAST because flows may re-route during group movement, - * so we need to use the actual final positions of endpoints (not assume they - * moved by exactly `delta`). - * - * @param links Links to process - * @param originalElements Map of original elements (before movement) - * @param updatedElements Map of updated elements (after movement) - * @param selection Set of selected UIDs - * @param delta Movement delta - * @param arcPoint Optional arc point for single-link drag - * @returns Map of updated link elements - */ -export function processLinks( - links: Iterable, - originalElements: Map, - updatedElements: Map, - selection: ReadonlySet, - delta: MovementDelta, - arcPoint?: MovementDelta, -): Map { - const result = new Map(); - - for (const link of links) { - // Get original and updated endpoint positions - const oldFrom = originalElements.get(link.fromUid); - const oldTo = originalElements.get(link.toUid); - if (!oldFrom || !oldTo) { - continue; - } - - const newFrom = updatedElements.get(link.fromUid) ?? oldFrom; - const newTo = updatedElements.get(link.toUid) ?? oldTo; - - // Check if both endpoints moved by the same amount (pure translation) - const fromDelta = { x: oldFrom.x - newFrom.x, y: oldFrom.y - newFrom.y }; - const toDelta = { x: oldTo.x - newTo.x, y: oldTo.y - newTo.y }; - const sameMovement = - Math.abs(fromDelta.x - toDelta.x) < MOVEMENT_EQUALITY_EPSILON && - Math.abs(fromDelta.y - toDelta.y) < MOVEMENT_EQUALITY_EPSILON; - const didMove = fromDelta.x !== 0 || fromDelta.y !== 0 || toDelta.x !== 0 || toDelta.y !== 0; - - // Single link selection with arcPoint: adjust arc based on drag position - if (selection.size === 1 && selection.has(link.uid) && arcPoint) { - const newTakeoff = takeoffθ({ - element: link, - from: oldFrom, - to: oldTo, - arcPoint: { x: arcPoint.x, y: arcPoint.y }, - }); - result.set(link.uid, { ...link, arc: radToDeg(newTakeoff) }); - } else if (sameMovement && didMove) { - // Both endpoints moved together - translate multiPoint if present, keep arc - if (link.multiPoint) { - const translatedMultiPoint = link.multiPoint.map((p) => ({ - ...p, - x: p.x - fromDelta.x, - y: p.y - fromDelta.y, - })); - result.set(link.uid, { ...link, multiPoint: translatedMultiPoint }); - } - // arc is preserved (no change needed) - } else if (didMove) { - // Endpoints moved differently - adjust arc based on rotation of the - // line between endpoints' visual centers - const oldFromVisual = getVisualCenter(oldFrom); - const oldToVisual = getVisualCenter(oldTo); - const newFromVisual = getVisualCenter(newFrom); - const newToVisual = getVisualCenter(newTo); - - const oldθ = Math.atan2(oldToVisual.cy - oldFromVisual.cy, oldToVisual.cx - oldFromVisual.cx); - const newθ = Math.atan2(newToVisual.cy - newFromVisual.cy, newToVisual.cx - newFromVisual.cx); - const diffθ = oldθ - newθ; - - result.set(link.uid, { ...link, arc: updateArcAngle(link.arc, radToDeg(diffθ)) }); - } - } - - return result; -} - -/** - * Unified function to apply group movement to all element types. - * - * This function handles: - * 1. Single-element movement (delegates to existing helpers for special cases) - * 2. Multi-element group movement with proper flow routing and link arc adjustment - * - * Processing order: - * 1. Move stocks and auxes by delta - * 2. Pre-compute flow offsets for multi-flow spacing - * 3. Process selected flows (route or translate based on endpoint selection) - * 4. Move selected clouds by delta - * 5. Route unselected flows attached to selected endpoints - * 6. Process links LAST (using actual updated positions) - * - * @param input Movement input parameters - * @returns Map of element UID to updated element - */ -export function applyGroupMovement(input: GroupMovementInput): GroupMovementOutput { - const { elements, selection, delta, arcPoint, segmentIndex } = input; - - // Build maps of elements for efficient lookup - const originalElements = new Map(); - for (const el of elements) { - originalElements.set(el.uid, el); - } - - // Helper to check if a UID is in selection - const isInSelection = (uid: UID | undefined): boolean => { - return uid !== undefined && selection.has(uid); - }; - - // Classify elements by type - const selectedStockUids = new Set(); - const selectedFlowUids = new Set(); - for (const uid of selection) { - const el = originalElements.get(uid); - if (!el) continue; - if (el.type === 'stock') { - selectedStockUids.add(uid); - } else if (el.type === 'flow') { - selectedFlowUids.add(uid); - } - } - - const updatedElements = new Map(originalElements); - - // First pass: Move positioned elements (stocks, auxes, clouds, modules, aliases, groups) by delta - for (const uid of selection) { - const el = originalElements.get(uid); - if (!el) continue; - - if (el.type === 'stock') { - updatedElements.set(uid, { - ...el, - x: el.x - delta.x, - y: el.y - delta.y, - }); - } else if (el.type === 'aux') { - updatedElements.set(uid, { - ...el, - x: el.x - delta.x, - y: el.y - delta.y, - }); - } else if (el.type === 'cloud') { - updatedElements.set(uid, { - ...el, - x: el.x - delta.x, - y: el.y - delta.y, - }); - } else if (el.type === 'module') { - updatedElements.set(uid, { - ...el, - x: el.x - delta.x, - y: el.y - delta.y, - }); - } else if (el.type === 'alias') { - updatedElements.set(uid, { - ...el, - x: el.x - delta.x, - y: el.y - delta.y, - }); - } else if (el.type === 'group') { - updatedElements.set(uid, { - ...el, - x: el.x - delta.x, - y: el.y - delta.y, - }); - } - } - - // Pre-compute flow offsets for all flows attached to moved stocks. - // This applies even for single-stock selection to preserve multi-flow spacing. - // Note: We use originalElements.values() since the input `elements` iterator was already consumed. - const preComputedOffsets = - selectedStockUids.size > 0 - ? computePreRoutedOffsets(originalElements.values(), selectedStockUids, delta, isInSelection) - : new Map(); - - // Pre-process selected flows with one endpoint selected (stock endpoint only) - const preProcessedFlows = - selection.size > 1 - ? preProcessSelectedFlows( - originalElements.values(), - selectedFlowUids, - preComputedOffsets, - delta, - isInSelection, - (uid) => originalElements.get(uid), - ) - : new Map(); - - // Process selected flows - for (const uid of selection) { - const el = originalElements.get(uid); - if (!el || el.type !== 'flow') continue; - - // Single-flow selection with segmentIndex: move segment - if (selection.size === 1 && segmentIndex !== undefined) { - const pts = el.points; - if (pts.length >= 2) { - const sourceId = first(pts).attachedToUid; - const sinkId = last(pts).attachedToUid; - const source = sourceId !== undefined ? originalElements.get(sourceId) : undefined; - const sink = sinkId !== undefined ? originalElements.get(sinkId) : undefined; - - if ( - source && - sink && - (source.type === 'stock' || source.type === 'cloud') && - (sink.type === 'stock' || sink.type === 'cloud') - ) { - const ends: readonly (StockViewElement | CloudViewElement)[] = [source, sink]; - const [newFlow, newClouds] = UpdateFlow(el, ends, delta, segmentIndex); - updatedElements.set(uid, newFlow); - for (const cloud of newClouds) { - updatedElements.set(cloud.uid, cloud); - } - } - } - continue; - } - - // Single-flow selection without segmentIndex: delegate to UpdateFlow so a - // perpendicular valve drag can reroute. Cloud-ended flows bend into an L; - // stock-to-stock flows offset into a Z (#819). UpdateFlow also handles the - // plain along-axis valve slide, so this subsumes the old stock-to-stock - // clamp path for single-flow selections. - if (selection.size === 1) { - const pts = el.points; - if (pts.length >= 2) { - const sourceId = first(pts).attachedToUid; - const sinkId = last(pts).attachedToUid; - const source = sourceId !== undefined ? originalElements.get(sourceId) : undefined; - const sink = sinkId !== undefined ? originalElements.get(sinkId) : undefined; - - if ( - source && - sink && - (source.type === 'stock' || source.type === 'cloud') && - (sink.type === 'stock' || sink.type === 'cloud') - ) { - const ends: readonly (StockViewElement | CloudViewElement)[] = [source, sink]; - const [newFlow, newClouds] = UpdateFlow(el, ends, delta, undefined); - updatedElements.set(uid, newFlow); - for (const cloud of newClouds) { - updatedElements.set(cloud.uid, cloud); - } - continue; - } - } - } - - const [newFlow, sideEffects] = processSelectedFlow(el, delta, isInSelection, preProcessedFlows, (flowUid) => - originalElements.get(flowUid), - ); - updatedElements.set(uid, newFlow); - for (const sideEffect of sideEffects) { - updatedElements.set(sideEffect.uid, sideEffect); - } - } - - // Route unselected flows attached to selected endpoints. - // This applies even for single-element selection (e.g., moving a single stock - // should route its attached flows). routeUnselectedFlows needs the ORIGINAL - // endpoint positions (it derives the new ones from `delta`), so we pass - // originalElements.values() -- the input `elements` iterator was already - // consumed when building originalElements. - if (selection.size > 0) { - const routedFlows = routeUnselectedFlows(originalElements.values(), selection, preComputedOffsets, delta); - for (const flow of routedFlows) { - updatedElements.set(flow.uid, flow); - } - } - - // Process links LAST using actual updated positions - const links: LinkViewElement[] = []; - for (const el of originalElements.values()) { - if (el.type === 'link') { - // Include link if it's selected OR if either endpoint was updated - const fromUpdated = updatedElements.get(el.fromUid) !== originalElements.get(el.fromUid); - const toUpdated = updatedElements.get(el.toUid) !== originalElements.get(el.toUid); - if (selection.has(el.uid) || fromUpdated || toUpdated) { - links.push(el); - } - } - } - - const updatedLinks = processLinks(links, originalElements, updatedElements, selection, delta, arcPoint); - for (const [uid, link] of updatedLinks) { - updatedElements.set(uid, link); - } - - // Filter to only return elements that actually changed - const result = new Map(); - for (const [uid, newEl] of updatedElements) { - const oldEl = originalElements.get(uid); - if (newEl !== oldEl) { - result.set(uid, newEl); - } - } - - return { updatedElements: result }; -} diff --git a/src/diagram/merge-live-view.ts b/src/diagram/merge-live-view.ts deleted file mode 100644 index ce2cf423b..000000000 --- a/src/diagram/merge-live-view.ts +++ /dev/null @@ -1,52 +0,0 @@ -// Copyright 2026 The Simlin Authors. All rights reserved. -// Use of this source code is governed by the Apache License, -// Version 2.0, that can be found in the LICENSE file. - -import { Model, Project, stockFlowViewFromJson, stockFlowViewToJson } from '@simlin/core/datamodel'; -import { mapSet } from '@simlin/core/common'; - -/** - * Merge an incoming Project (built from the engine's serialized state) with - * the current live Project (from React state), preserving the live model's - * view for the active model. - * - * Why: Editor.updateProject() awaits the async engine round-trip - * (applyPatch + serialize). During that round-trip the user can keep - * panning/moving via setView() optimistic updates. When updateProject - * finally commits, the engine view it serialized may be older than the - * latest setView, so simply overwriting activeProject snaps the diagram - * back to the engine's older view. Preserving the live view keeps those - * optimistic updates intact. - * - * The live view is round-tripped through JSON so that ViewElement.var - * references and Stock inflow/outflow UIDs are re-linked against the - * incoming model's variables -- the live view may carry stale or - * undefined refs from setView calls that ran before variable changes - * propagated, but its positions, viewBox, and zoom are the latest user - * intent. - */ -export function preserveLiveView(incoming: Project, live: Project | undefined, modelName: string): Project { - if (!live) { - return incoming; - } - const liveModel = live.models.get(modelName); - const newModel = incoming.models.get(modelName); - if (!liveModel || !newModel) { - return incoming; - } - if (liveModel.views.length === 0 || newModel.views.length === 0) { - return incoming; - } - - const liveViewJson = stockFlowViewToJson(liveModel.views[0]); - const preservedView = stockFlowViewFromJson(liveViewJson, newModel.variables); - - const updatedModel: Model = { - ...newModel, - views: [preservedView, ...newModel.views.slice(1)], - }; - return { - ...incoming, - models: mapSet(incoming.models, modelName, updatedModel), - }; -} diff --git a/src/diagram/plan-delete.ts b/src/diagram/plan-delete.ts new file mode 100644 index 000000000..b198d5e29 --- /dev/null +++ b/src/diagram/plan-delete.ts @@ -0,0 +1,82 @@ +// Copyright 2026 The Simlin Authors. All rights reserved. +// Use of this source code is governed by the Apache License, +// Version 2.0, that can be found in the LICENSE file. + +// pattern: Functional Core +// +// The view a delete produces. Model ops are not built here: the controller +// derives them from (base view, next view) through buildEditOps, so deleting a +// variable, detaching flows from a deleted stock, and removing aliases all +// reach the engine through the same diff every other edit uses. + +import type { CloudViewElement, StockFlowView, UID, ViewElement } from '@simlin/core/datamodel'; + +/** + * Remove `selection` from `view`: + * + * - every selected element except a cloud whose flow is not also removed (a + * cloud is a flow endpoint, not a variable; deleting it alone would leave the + * flow's end dangling, so the request is ignored); + * - the clouds of removed flows; + * - the aliases of removed elements; + * - every link touching a removed element (an alias or cloud included); + * - and every endpoint of a surviving flow that was attached to a removed stock + * becomes a new cloud at that endpoint. + * + * Uids that survive are unchanged; new clouds take uids from `nextUid`. + */ +export function planDelete(view: StockFlowView, selection: ReadonlySet): StockFlowView { + const removed = new Set(); + for (const el of view.elements) { + if (selection.has(el.uid) && el.type !== 'cloud') { + removed.add(el.uid); + } + } + for (const el of view.elements) { + if (el.type === 'cloud' && removed.has(el.flowUid)) { + removed.add(el.uid); + } + } + for (const el of view.elements) { + if (el.type === 'alias' && removed.has(el.aliasOfUid)) { + removed.add(el.uid); + } + } + for (const el of view.elements) { + if (el.type === 'link' && (removed.has(el.fromUid) || removed.has(el.toUid))) { + removed.add(el.uid); + } + } + + let nextUid = view.nextUid; + const clouds: CloudViewElement[] = []; + const elements: ViewElement[] = []; + for (const el of view.elements) { + if (removed.has(el.uid)) { + continue; + } + if (el.type !== 'flow') { + elements.push(el); + continue; + } + const points = el.points.map((pt) => { + if (pt.attachedToUid === undefined || !removed.has(pt.attachedToUid)) { + return pt; + } + const cloud: CloudViewElement = { + type: 'cloud', + uid: nextUid++, + x: pt.x, + y: pt.y, + flowUid: el.uid, + isZeroRadius: false, + ident: undefined, + }; + clouds.push(cloud); + return { ...pt, attachedToUid: cloud.uid }; + }); + elements.push({ ...el, points }); + } + + return { ...view, elements: [...elements, ...clouds], nextUid }; +} diff --git a/src/diagram/project-controller.ts b/src/diagram/project-controller.ts index 66a3cfc23..90666ae20 100644 --- a/src/diagram/project-controller.ts +++ b/src/diagram/project-controller.ts @@ -4,27 +4,49 @@ // pattern: Imperative Shell // -// ProjectController is the headless coordination layer extracted from -// Editor.tsx. It owns the WASM engine lifecycle, the apply-patch -> -// serialize -> rebuild pipeline, the save queue, undo/redo history, sim -// runs, the cached-error derivation, version/generation bookkeeping (the -// fractional render-cache key plus the server-acknowledged save version), and -// the module-navigation stack. It has ZERO React and ZERO DOM dependencies -// (no document/window; setTimeout is allowed for deferred dispatch) so the -// async coordination can be unit-tested against a fake engine without jsdom. +// ProjectController is the headless coordination layer between the Editor and +// the WASM engine. It has ZERO React and ZERO DOM dependencies (setTimeout-free +// as well: every engine call runs through one serialized executor), so the +// async coordination is unit-tested against a fake engine without jsdom. // -// The Editor is a thin view binding: it subscribes to the controller's -// snapshot, mirrors it into one state field, and builds JSON ops that it -// hands to controller.applyPatch()/updateView()/queueViewUpdate(). +// The model (docs/design-plans/2026-09-10-diagram-editing-core.md, "Controller"): // -// The controller never owns presentation state. Toast-style transient errors -// (the Editor's `modelErrors`) are surfaced via the `onError` config callback; -// the Editor decides how to present them. +// - `committed` is the engine's last acknowledged project. +// - `queue` holds edit-class items in FIFO order: view edits and model-only +// edits (the pending edits), viewport persists, undo/redo, engine queries, +// and the initial open. An item stays at the head of the queue while it runs. +// - The RENDERED view of a model is the next view of the last pending edit +// targeting it, else its committed view, with the live viewport for that +// model overlaid. The snapshot's `project` is committed plus those views plus +// the derived annotations (errors, sim series, connector drift). +// - `token` is bumped by every truncation, undo/redo landing, and reopen. An +// edit with a next view planned under an older token is dropped as failed. +// - One executor runs every engine call. No engine reference is held across an +// await outside an item. +// - Maintenance (save serialization, error refresh, connector dependencies, sim +// runs) is coalesced to one pending run per kind and runs when no edit-class +// item is queued, or after MaintenanceEditBound consecutive edit-class items +// or MaintenanceTimeBoundMs of continuous edit work. +// - A failed view edit truncates every later view edit and undo/redo (each was +// planned on its optimistic view), bumps the token, renders committed, and +// reports one error naming how many later edits were discarded. Model-only +// edits survive: they derive their payload from committed state at dequeue. +// A failed model-only edit only reports: nothing was planned on it. A patch +// that applied but could not be read back resyncs committed from the engine +// before the item's fate is decided: a successful re-read means it landed; a +// reopen of the last recorded snapshot means it failed; a failed reopen +// latches engine-unavailable. The model and the diagram never disagree. +// - While a pending edit renames a variable, the rendered model names that +// variable by its new ident, and while one creates a variable, the rendered +// model holds the variable its create op will produce, so every rendered +// element resolves to the variable it will name. +// +// The controller never owns presentation state: transient errors go to the +// host through `onError`. import { Project, Model, - Variable, EquationError, UnitError, UnitErrorKind, @@ -34,18 +56,21 @@ import { StockFlowView, UID, Rect, + Variable, + VariableWarning, projectFromJson, - projectAttachData, + projectAttachSeries, + groupSeriesByIdent, findNonFiniteViewCoord, - stockFlowViewFromJson, + isNamedViewElement, stockFlowViewToJson, } from '@simlin/core/datamodel'; -import { defined, mapSet, setsEqual, uint8ArraysEqual, type Series } from '@simlin/core/common'; -import { first, getOrThrow } from '@simlin/core/collections'; -import type { JsonProjectPatch, JsonModelOperation, ErrorDetail, JsonProject } from '@simlin/engine'; -import { SimlinErrorKind, SimlinUnitErrorKind } from '@simlin/engine'; +import { canonicalize } from '@simlin/core/canonicalize'; +import { mapSet, setsEqual, uint8ArraysEqual, type Series } from '@simlin/core/common'; +import { first } from '@simlin/core/collections'; +import type { JsonProjectPatch, ErrorDetail, JsonProject } from '@simlin/engine'; +import { SimlinErrorKind, SimlinErrorSeverity, SimlinUnitErrorKind } from '@simlin/engine'; -import { preserveLiveView } from './merge-live-view'; import { advanceProjectHistory } from './project-history'; import { type ModuleStackEntry, @@ -57,6 +82,8 @@ import { isMacroModel, } from './module-navigation'; import { computeConnectorErrors } from './connector-sync'; +import { buildEditOps, createdVariable } from './view-model-sync'; +import { allocateVariableName, nameCollisionError } from './variable-names'; /** * The maximum number of undo snapshots kept. A small buffer is intentional: @@ -66,13 +93,21 @@ import { computeConnectorErrors } from './connector-sync'; export const MaxUndoSize = 5; /** - * Cached, model-scoped error derivation. Recomputed from `engine.getErrors()` - * whenever the project content or the active model changes. The Editor reads - * this from the snapshot to render the error panel and warning dots. + * Maintenance waits while edit-class items are queued, but never for more than + * this many consecutive items or this much continuous edit work, so a sustained + * stream of slow edits cannot starve saving. + */ +export const MaintenanceEditBound = 5; +export const MaintenanceTimeBoundMs = 5000; + +/** + * Cached, model-scoped error derivation for the active model. The Editor reads + * this from the snapshot to render the error panel. */ export interface CachedErrorDetails { readonly varErrors: ReadonlyMap; readonly unitErrors: ReadonlyMap; + readonly varWarnings: ReadonlyMap; readonly simError: SimError | undefined; readonly modelErrors: readonly ModelError[]; } @@ -83,38 +118,48 @@ export interface CachedErrorDetails { * updates; prior snapshots are never mutated. */ export interface ProjectSnapshot { + // The RENDERED project: committed content with each model's rendered view + // (pending next views, live viewports) and the derived annotations. readonly project: Project | undefined; - // PURELY the render-cache key: the fractional scheme (+0.01 for content - // edits, +0.001 for view-only updates) that Canvas invalidates caches off. - // It never resets and carries no server meaning -- the integer version the - // server holds is `serverVersion` below. (Deriving the save version from - // this value was issue #958: ~100 unsaved edits drifted the fraction past - // the next integer, corrupting the optimistic-concurrency check.) + // PURELY the render-cache key the Canvas invalidates its element lookup off. + // It advances whenever `project` is replaced and carries no server meaning -- + // the integer version the server holds is `serverVersion`. (Deriving the + // save version from a render counter was issue #958: unsaved edits drifted it + // past the next integer, corrupting the optimistic-concurrency check.) readonly projectVersion: number; - // The last server-ACKNOWLEDGED integer version: seeded from the initial - // load and advanced only by a successful save's returned version. This is - // the sole source of the `currVersion` a save sends. Local edits, view - // updates, and undo/redo never move it -- the server's version doesn't - // change when the user edits locally. + // The last server-ACKNOWLEDGED integer version: seeded from the initial load + // and advanced only by a successful save's returned version. This is the + // sole source of the `currVersion` a save sends. readonly serverVersion: number; - // Increments exactly when project *content* changes (real edits and - // undo/redo) -- not on view-only updates or save-version bookkeeping. - // The Editor keys the details panels on this so a pan frame or autosave - // does not remount an open panel and discard in-progress edits. - readonly projectGeneration: number; readonly status: 'ok' | 'error' | 'disabled'; readonly cachedErrors: CachedErrorDetails; readonly data: ReadonlyMap; readonly modelName: string; readonly modelStack: readonly ModuleStackEntry[]; + // Undo/redo availability: history exists in that direction AND no edit or + // undo/redo item is queued (an undo landing under a pending edit would drop + // it as stale). readonly canUndo: boolean; readonly canRedo: boolean; + // True while an undo/redo item is queued: the Canvas ignores presses, since a + // gesture planned on the pre-undo view could not commit. + readonly undoRedoQueued: boolean; + // Bumped by every truncation, undo/redo landing and reopen (see the module + // header). A gesture captures it at press time and aborts when it moves. + readonly token: number; + // Bumped by every undo/redo landing. Restored content can equal content a + // panel was seeded from (a draft's edit landed and was undone before the host + // rendered), so a panel keyed on content alone would keep text that is no + // longer a draft. + readonly restoreSeq: number; + // True once the engine was lost and could not be reopened (see resync). It + // latches: every later edit, undo/redo and query is refused quietly, nothing + // more can be saved, and the host shows one persistent notice offering a + // reload instead of a toast per refused edit. + readonly engineUnavailable: boolean; // Monotonic counter bumped only when undo/redo resets navigation to 'main' // because the restored project no longer contains the viewed model. The - // Editor watches this to clear its own selection/details/tool UI state for - // that specific case (ordinary undo preserves them). Drill-in / back / level - // are driven by the Editor's own handlers (via the NavigationOutcome return), - // so they do NOT bump this. + // Editor watches this to clear its own selection/details/tool UI state. readonly navResetSeq: number; } @@ -171,9 +216,7 @@ export function isUsableViewport(viewport: Viewport): boolean { /** * Configuration injected by the host (the Editor). The two `open*` factories * isolate the controller from the concrete `EngineProject` static methods so - * it can be unit-tested against a fake engine. `onError` surfaces transient - * errors to the host's toast UI; `onChange` notifies subscribers (the Editor - * subscribes through `subscribe()`, which wraps this). + * it can be unit-tested against a fake engine. */ export interface ProjectControllerConfig { readonly initialProjectVersion: number; @@ -181,16 +224,14 @@ export interface ProjectControllerConfig { | { readonly format: 'protobuf'; readonly data: Readonly } | { readonly format: 'json'; readonly data: string }; // When set, the root model's first view opens with THIS viewport in place of - // the one stored in the project. The override is applied before the first - // snapshot is published (the canvas never renders the stored viewport) and - // round-tripped to the engine as a view-only update -- no undo entry, no - // save -- so it is on the same footing as a pan the user just made: the next - // saved edit persists it, until then it is presentation. A host that + // the one stored in the project. It is the live viewport from the first + // published snapshot on (the canvas never renders or fits the stored one) and + // is persisted to the engine by a viewport item -- no undo entry, no save -- + // so it is on the same footing as a pan the user just made. A host that // remounts the Editor on new project bytes (the notebook widget on a kernel // push) uses this to keep the user's live pan/zoom, which a pan alone never - // persists (`queueViewUpdate` does not save) and a remount on the stored - // bytes would otherwise reset. Ignored when the view is absent or the - // viewport is unusable (a non-finite coordinate, a non-positive zoom). + // saves. Ignored when the view is absent or the viewport is unusable (a + // non-finite coordinate, a non-positive zoom). readonly initialViewport?: Viewport; readonly openProtobuf: (data: Uint8Array) => Promise; readonly openJson: (data: string) => Promise; @@ -199,6 +240,9 @@ export interface ProjectControllerConfig { currVersion: number, ) => Promise; readonly onError: (err: Error) => void; + // The clock the maintenance time bound reads. Tests supply a controllable + // one; production uses Date.now. + readonly now?: () => number; } interface ErrorDetailsLike { @@ -237,8 +281,12 @@ function convertUnitErrorKind(kind: SimlinUnitErrorKind): UnitErrorKind { } /** - * Convert the engine's flat error list into the model-scoped equation/unit - * error maps the Editor renders. Errors for other models are filtered out. + * Convert the engine's flat error list into the model-scoped per-variable maps + * the Editor renders, by kind and severity: a unit error (either severity) is a + * unit error; any other Warning is an advisory (`varWarnings`), which leaves the + * variable's results standing; everything else is an equation error, which + * means the variable produced no valid data. Errors for other models are + * filtered out. */ export function convertErrorDetails( errors: readonly ErrorDetail[], @@ -246,19 +294,25 @@ export function convertErrorDetails( ): { varErrors: ReadonlyMap; unitErrors: ReadonlyMap; + varWarnings: ReadonlyMap; } { const varErrors = new Map(); const unitErrors = new Map(); + const varWarnings = new Map(); for (const err of errors) { if (err.modelName !== modelName) { continue; } - const ident = err.variableName; - if (!ident) { + if (!err.variableName) { continue; } + // Keyed like Model.variables. A raising site may name the variable by its + // canonical ident or by its source spelling (the stock-list advisory names + // 'Level', not 'level'); canonicalizing an already-canonical ident changes + // nothing. + const ident = canonicalize(err.variableName); const isUnitError = err.kind === SimlinErrorKind.Units; @@ -281,6 +335,19 @@ export function convertErrorDetails( unitErrors.set(ident, existing); } existing.push(unitError); + } else if (err.severity === SimlinErrorSeverity.Warning) { + const warning: VariableWarning = { + code: err.code as unknown as ErrorCode, + // Prefer the bare reason over the terminal-formatted message, as for + // model errors (cachedErrorsFor). + details: err.details ?? err.message ?? undefined, + }; + let existing = varWarnings.get(ident); + if (!existing) { + existing = []; + varWarnings.set(ident, existing); + } + existing.push(warning); } else { const eqError: EquationError = { start: err.startOffset ?? 0, @@ -296,44 +363,279 @@ export function convertErrorDetails( } } - return { varErrors, unitErrors }; + return { varErrors, unitErrors, varWarnings }; +} + +function cachedErrorsFor(errors: readonly ErrorDetail[], modelName: string): CachedErrorDetails { + const { varErrors, unitErrors, varWarnings } = convertErrorDetails(errors, modelName); + let simError: SimError | undefined; + const modelErrors: ModelError[] = []; + for (const err of errors) { + if (err.modelName && err.modelName !== modelName) { + continue; + } + if (err.kind === SimlinErrorKind.Simulation) { + simError = { + code: err.code as unknown as ErrorCode, + details: err.message ?? undefined, + }; + } else if (!err.variableName) { + modelErrors.push({ + code: err.code as unknown as ErrorCode, + // Prefer the bare reason over the terminal-formatted message (the + // unit-inference umbrella carries a plain-language sentence there); + // most model errors have no details and keep the message. + details: err.details ?? err.message ?? undefined, + }); + } + } + return { varErrors, unitErrors, varWarnings, simError, modelErrors }; +} + +/** + * Annotate `modelName`'s variables with their equation/unit errors, or flag the + * project `hasNoEquations` when every variable's only error is an empty + * equation: a brand-new sketch should not scream "error" at the user. + */ +function annotateErrors(project: Project, cached: CachedErrorDetails, modelName: string): Project { + const model = project.models.get(modelName); + if (!model) { + return project; + } + const { varErrors, unitErrors, varWarnings } = cached; + if ( + varErrors.size > 0 && + varErrors.size === model.variables.size && + setsEqual(new Set(varErrors.keys()), new Set(model.variables.keys())) && + [...varErrors.values()].every((errs) => errs.length === 1 && first(errs).code === ErrorCode.EmptyEquation) + ) { + return { ...project, hasNoEquations: true }; + } + if (varErrors.size === 0 && unitErrors.size === 0 && varWarnings.size === 0) { + return project; + } + const variables = new Map(model.variables); + for (const [ident, errs] of varErrors) { + const variable = variables.get(ident); + if (variable) { + variables.set(ident, { ...variable, errors: errs }); + } + } + for (const [ident, errs] of unitErrors) { + const variable = variables.get(ident); + if (variable) { + variables.set(ident, { ...variable, unitErrors: errs }); + } + } + for (const [ident, warnings] of varWarnings) { + const variable = variables.get(ident); + if (variable) { + variables.set(ident, { ...variable, warnings }); + } + } + return { ...project, models: mapSet(project.models, modelName, { ...model, variables }) }; +} + +/** + * Annotate the active model's aux/flow/stock variables with sketch-connector + * drift on the RENDERED view (see connector-sync.ts). Dependencies are the + * engine's per-variable `getIncomingLinks` for the variables the last + * connector refresh fetched: authoritative (they exclude builtins/TIME, + * structural flow<->stock edges and dotted module-output refs), where + * `getLinks` would add structural edges and omit initial-equation deps. + * + * Targets with a fatal equation error are skipped: their AST did not parse, so + * the engine reports no dependencies and every inbound connector would read as + * stale. That relies on `annotateErrors` having run first. The all-empty + * starter model sets `hasNoEquations` without annotating `errors`, so it + * returns early instead; stdlib and macro models are not user sketches. + */ +function annotateConnectors( + project: Project, + modelName: string, + committedDependencies: ReadonlyMap | undefined, + renames: ReadonlyMap, +): Project { + if (committedDependencies === undefined || isStdlibModel(modelName) || project.hasNoEquations) { + return project; + } + const model = project.models.get(modelName); + const view = model?.views[0]; + if (!model || !view || isMacroModel(model)) { + return project; + } + // The engine reports dependencies under committed idents; the rendered model + // already names each pending rename's variable by its new ident. + const renamed = (ident: string): string => renames.get(ident)?.ident ?? ident; + const dependencies = + renames.size === 0 + ? committedDependencies + : new Map([...committedDependencies].map(([ident, deps]) => [renamed(ident), deps.map(renamed)])); + const checked = new Map(); + for (const el of view.elements) { + if (el.type !== 'aux' && el.type !== 'stock' && el.type !== 'flow') { + continue; + } + const variable = model.variables.get(el.ident); + const deps = dependencies.get(el.ident); + if (!variable || variable.type === 'module' || deps === undefined) { + continue; + } + if (variable.errors && variable.errors.length > 0) { + continue; + } + checked.set(el.ident, deps); + } + if (checked.size === 0) { + return project; + } + const issuesByIdent = computeConnectorErrors({ + elements: view.elements, + variables: model.variables, + dependencies: checked, + }); + if (issuesByIdent.size === 0) { + return project; + } + const variables = new Map(model.variables); + for (const [ident, issues] of issuesByIdent) { + const variable = variables.get(ident); + if (variable) { + variables.set(ident, { ...variable, connectorErrors: issues }); + } + } + return { ...project, models: mapSet(project.models, modelName, { ...model, variables }) }; +} + +/** A committed variable a pending view renames: its ident and display name after the rename. */ +interface PendingRename { + readonly ident: string; + readonly name: string; +} + +/** + * The renames `pendingView` implies against the committed model, keyed by + * committed ident: a named element whose uid is on the committed view under a + * different canonical name, naming a committed variable. The same derivation as + * buildEditOps' renames, so the rendered model shows exactly the renames the + * queued edits will send. + */ +function pendingRenames( + variables: ReadonlyMap, + committedView: StockFlowView, + pendingView: StockFlowView, +): ReadonlyMap { + const committedIdents = new Map(); + for (const el of committedView.elements) { + if (isNamedViewElement(el)) { + committedIdents.set(el.uid, canonicalize(el.name)); + } + } + const renames = new Map(); + for (const el of pendingView.elements) { + if (!isNamedViewElement(el)) { + continue; + } + const from = committedIdents.get(el.uid); + const to = canonicalize(el.name); + if (from === undefined || from === to || !variables.has(from) || renames.has(from)) { + continue; + } + renames.set(from, { ident: to, name: el.name }); + } + return renames; +} + +/** + * The committed variables with each pending rename applied: the variable moves + * to its new ident, carrying its committed content and annotations. Every + * element of a rendered view names the variable it will name once its edit + * lands, so the canvas and the details panel resolve a renamed element to the + * committed variable it renames. A rename onto a name another committed variable + * keeps is not applied: buildEditOps refuses it at dequeue, and that variable + * keeps rendering. + */ +function withRenamedVariables( + variables: ReadonlyMap, + renames: ReadonlyMap, +): ReadonlyMap { + const applicable = [...renames].filter(([, to]) => !variables.has(to.ident) || renames.has(to.ident)); + if (applicable.length === 0) { + return variables; + } + const out = new Map(variables); + for (const [from] of applicable) { + out.delete(from); + } + for (const [from, to] of applicable) { + out.set(to.ident, { ...variables.get(from)!, ident: to.ident, rawName: to.name }); + } + return out; +} + +/** + * `variables` plus the variable each pending create will produce: a named + * element on `pendingView` whose uid is not on the committed view, and whose + * name names no variable, renders with `createdVariable`, the variable its + * create op makes. So a gesture onto a stock whose create is still queued finds + * its variable, as it will once the create lands ahead of the gesture's edit, + * while an element no pending edit creates and no variable names (an orphan an + * import left) still finds none. A create onto a name a variable keeps is + * refused at dequeue (buildEditOps), and that variable keeps rendering. + */ +function withCreatedVariables( + variables: ReadonlyMap, + committedView: StockFlowView, + pendingView: StockFlowView, +): ReadonlyMap { + const committedUids = new Set(committedView.elements.map((el) => el.uid)); + let out: Map | undefined; + for (const el of pendingView.elements) { + if (!isNamedViewElement(el) || committedUids.has(el.uid)) { + continue; + } + const ident = canonicalize(el.name); + if ((out ?? variables).has(ident)) { + continue; + } + out ??= new Map(variables); + out.set(ident, createdVariable(el)); + } + return out ?? variables; } const EMPTY_CACHED_ERRORS: CachedErrorDetails = { varErrors: new Map(), unitErrors: new Map(), + varWarnings: new Map(), simError: undefined, modelErrors: [], }; -/** - * Replace `modelName`'s first view's viewport with `viewport` (when given and - * usable). Returns the (possibly unchanged) project and the replaced view, or - * `view: undefined` when nothing was applied -- the model or its view is absent, - * no override was given, or the override is unusable. - */ -function withInitialViewport( - project: Project, - modelName: string, - viewport: Viewport | undefined, -): { project: Project; view: StockFlowView | undefined } { - if (viewport === undefined || !isUsableViewport(viewport)) { - return { project, view: undefined }; +function viewportOf(view: StockFlowView): Viewport { + return { viewBox: view.viewBox, zoom: view.zoom }; +} + +function viewportsEqual(a: Viewport, b: Viewport): boolean { + return ( + a.zoom === b.zoom && + a.viewBox.x === b.viewBox.x && + a.viewBox.y === b.viewBox.y && + a.viewBox.width === b.viewBox.width && + a.viewBox.height === b.viewBox.height + ); +} + +function withViewport(view: StockFlowView, viewport: Viewport | undefined): StockFlowView { + if (viewport === undefined || viewportsEqual(viewportOf(view), viewport)) { + return view; } - const model = project.models.get(modelName); - const stored = model?.views[0]; - if (model === undefined || stored === undefined) { - return { project, view: undefined }; - } - const view: StockFlowView = { ...stored, viewBox: { ...viewport.viewBox }, zoom: viewport.zoom }; - const views = [...model.views]; - views[0] = view; - return { project: { ...project, models: mapSet(project.models, modelName, { ...model, views }) }, view }; + return { ...view, viewBox: viewport.viewBox, zoom: viewport.zoom }; } /** The result of a navigation method, describing the UI consequences the * Editor must apply (selection restoration, panel/tool resets). Viewport - * restoration is handled internally by the controller via queueViewUpdate. */ + * restoration is handled internally by the controller. */ export interface NavigationOutcome { // The selection to restore (drill-in clears it; back/level restore the // parent's). Undefined means "navigation did not happen" (e.g. drill-in @@ -341,81 +643,188 @@ export interface NavigationOutcome { readonly restoredSelection: ReadonlySet | undefined; } +/** + * Whether a patch changes anything but views: a project op, or a model op other + * than `upsertView`. A patch with no ops changes nothing. + */ +function patchChangesModel(patch: JsonProjectPatch): boolean { + return ( + (patch.projectOps ?? []).length > 0 || + (patch.models ?? []).some((model) => model.ops.some((op) => op.type !== 'upsertView')) + ); +} + +function sameFloats(a: Readonly, b: Readonly): boolean { + if (a.length !== b.length) { + return false; + } + for (let i = 0; i < a.length; i++) { + if (!Object.is(a[i], b[i])) { + return false; + } + } + return true; +} + +/** + * A run's results as series, reusing `previous`'s Series object for every + * variable whose time axis and values are elementwise identical (`Object.is`, so + * NaN matches NaN), and returning `previous` itself when every variable is + * unchanged. A new Series object makes each sparkline that draws it rebuild its + * path, which over a large model's results costs seconds; comparing the arrays + * is cheap by comparison. + */ +function seriesReusingUnchanged(previous: ReadonlyMap, run: EngineRunApi): ReadonlyMap { + const time = run.getSeries('time') ?? new Float64Array(0); + const any = previous.values().next(); + const previousTime = any.done ? undefined : any.value.time; + const sameTime = previousTime !== undefined && sameFloats(previousTime, time); + const next = new Map(); + let changed = !sameTime || previous.size !== run.varNames.length; + for (const ident of run.varNames) { + const values = run.getSeries(ident) ?? new Float64Array(0); + const before = previous.get(ident); + if (sameTime && before !== undefined && sameFloats(before.values, values)) { + next.set(ident, before); + } else { + next.set(ident, { name: ident, time: sameTime ? previousTime : time, values }); + changed = true; + } + } + return changed ? next : previous; +} + +type MaintenanceKind = 'save' | 'errors' | 'connectors' | 'sim'; +// The order pending maintenance runs in: user data first, then the annotations +// the diagram shows, then the (potentially slow) simulation. +const MAINTENANCE_ORDER: readonly MaintenanceKind[] = ['save', 'errors', 'connectors', 'sim']; + +interface ItemBase { + readonly settle: (landed: boolean) => void; +} + +// An edit with a next view (a diagram edit, rename included) or without one (a +// model-only edit whose payload is derived from the committed project at +// dequeue, so an echoed field is never stale). +interface EditItem extends ItemBase { + readonly kind: 'edit'; + readonly label: string; + readonly modelName: string; + readonly token: number; + readonly baseView: StockFlowView | undefined; + readonly nextView: StockFlowView | undefined; + readonly buildPatch: ((committed: Project) => JsonProjectPatch) | undefined; +} + +interface ViewportItem extends ItemBase { + readonly kind: 'viewport'; + readonly modelName: string; +} + +interface UndoRedoItem extends ItemBase { + readonly kind: 'undoRedo'; + readonly direction: 'undo' | 'redo'; +} + +interface QueryItem extends ItemBase { + readonly kind: 'query'; + readonly run: (engine: EngineApi) => Promise; +} + +interface OpenItem extends ItemBase { + readonly kind: 'open'; +} + +type QueueItem = EditItem | ViewportItem | UndoRedoItem | QueryItem | OpenItem; + /** * Headless coordination for a single open project. Create one per mounted * Editor; call `dispose()` exactly once when the Editor unmounts. * - * StrictMode safety: the Editor creates the controller in componentDidMount - * and disposes it in componentWillUnmount. A mount -> unmount -> mount cycle - * on the same Editor instance (React 18 StrictMode) therefore creates a - * *fresh* controller on the second mount -- the first one was disposed. The - * controller itself need not be re-armable after dispose; `disposed` latches - * true and every async continuation short-circuits on it. + * StrictMode safety: the Editor disposes the controller when its mount effect + * cleans up and builds a fresh one on the next mount, so the controller itself + * need not be re-armable: `disposed` latches true, queued items settle as not + * landed, and the executor releases the engine once its running item returns. */ export class ProjectController { private readonly config: ProjectControllerConfig; + private readonly now: () => number; - // The live engine handle. Undefined before openInitialProject() resolves - // and after dispose(). + // The live engine handle. Only the executor touches it. private engine: EngineApi | undefined = undefined; - // --- snapshot-backing state --- - private project: Project | undefined = undefined; + // --- committed state + private committed: Project | undefined = undefined; private projectHistory: readonly Readonly[]; private projectOffset = 0; - private projectVersion: number; - // Last server-acknowledged integer version (see ProjectSnapshot.serverVersion). private serverVersion: number; - private projectGeneration = 0; - private status: 'ok' | 'error' | 'disabled' = 'disabled'; - private cachedErrors: CachedErrorDetails = EMPTY_CACHED_ERRORS; + private token = 0; + + // --- derived-state inputs, refreshed by maintenance + private errorDetails: readonly ErrorDetail[] = []; + private simulatable: boolean | undefined = undefined; private data: ReadonlyMap = new Map(); + // `data` grouped per variable, derived once per run that changes `data` + // (groupSeriesByIdent, reusing unchanged variables' arrays). Every render + // attaches these same arrays: the diagram's sparklines memoize on array + // identity, and a landed edit re-renders the project without new results. + private seriesByIdent: ReadonlyMap = new Map(); + // model name -> (variable ident -> equation dependencies), from the engine. + private incomingLinks = new Map>(); + + // --- live state + private readonly viewport = new Map(); private modelName = 'main'; private modelStack: readonly ModuleStackEntry[] = []; private navResetSeq = 0; - - // The currently-published immutable snapshot. Replaced wholesale whenever - // any backing field changes and a notify is flushed. - private snapshot: ProjectSnapshot; - - // --- save queue --- + private restoreSeq = 0; + private engineUnavailable = false; + + // --- the executor + private queue: QueueItem[] = []; + // The item whose engine calls are in flight, if any. It stays at the head of + // the queue; dispose settles every other item. + private runningItem: QueueItem | undefined = undefined; + private readonly maintenance = new Set(); + private running = false; + private loop: Promise | undefined = undefined; + private editStreak = 0; + private editStreakStart = 0; + private idleWaiters: Array<() => void> = []; + + // --- save flush (outside the executor: a host save is a network call, not + // an engine call, and must not hold edits back) private inSave = false; - private saveQueued = false; - - // --- new-engine view race --- - // There exists a race where we need to center/update the viewBox when - // displaying a newly imported model, but the async wasm round-trip hasn't - // completed before we want to save the viewBox change. We stash the queued - // view and replay it once the new engine is installed. - private newEngineShouldPullView = false; - private newEngineQueuedView: StockFlowView | undefined = undefined; - - // --- lifecycle --- - // Latches true on dispose(). Every async continuation checks it before - // touching state, opening an engine, or notifying subscribers, so work that - // was already in flight at dispose time cannot resurrect a dead controller. + private queuedSave: { format: 'protobuf'; data: Uint8Array } | { format: 'json'; data: string } | undefined = + undefined; + + // --- lifecycle private disposed = false; - // --- notification coalescing --- + // --- publication + private snapshot: ProjectSnapshot; + private projectVersion: number; private readonly listeners = new Set<() => void>(); - // Depth counter so a synchronous multi-step mutation (the old code's single - // setState batch) flushes exactly one notify. notify() increments published - // state but defers the listener fan-out until the outermost batch closes. private batchDepth = 0; private snapshotDirty = false; + private renderMemo: + | { + readonly inputs: readonly unknown[]; + readonly project: Project | undefined; + readonly cachedErrors: CachedErrorDetails; + } + | undefined = undefined; constructor(config: ProjectControllerConfig) { this.config = config; - // Both versions seed from the load: projectVersion then drifts fractionally - // as a cache key while serverVersion stays integer, tracking only what the - // server has acknowledged. + this.now = config.now ?? Date.now; this.projectVersion = config.initialProjectVersion; this.serverVersion = config.initialProjectVersion; this.projectHistory = config.input.format === 'protobuf' ? [config.input.data] : []; this.snapshot = this.buildSnapshot(); } - // --- subscription API --- + // --- subscription API /** Subscribe to snapshot changes. Returns an unsubscribe function. */ subscribe(listener: () => void): () => void { @@ -431,27 +840,119 @@ export class ProjectController { } private buildSnapshot(): ProjectSnapshot { + const { project, cachedErrors } = this.render(); + if (project !== this.snapshot?.project) { + this.projectVersion += 1; + } return { - project: this.project, + project, projectVersion: this.projectVersion, serverVersion: this.serverVersion, - projectGeneration: this.projectGeneration, - status: this.status, - cachedErrors: this.cachedErrors, + status: this.status(project), + cachedErrors, data: this.data, modelName: this.modelName, modelStack: this.modelStack, canUndo: this.canUndo(), canRedo: this.canRedo(), + undoRedoQueued: this.undoRedoQueued(), + token: this.token, + restoreSeq: this.restoreSeq, + engineUnavailable: this.engineUnavailable, navResetSeq: this.navResetSeq, }; } + private status(project: Project | undefined): 'ok' | 'error' | 'disabled' { + if (!this.engine || !project || project.hasNoEquations || this.simulatable === undefined) { + return 'disabled'; + } + return this.simulatable ? 'ok' : 'error'; + } + + /** + * The rendered project and the active model's error cache, memoized on their + * inputs so an unrelated republish (a save acknowledgment) keeps the project + * identity and the Canvas keeps its render caches. + */ + private render(): { project: Project | undefined; cachedErrors: CachedErrorDetails } { + // Only pending NEXT VIEWS feed the rendered project: queuing a viewport, + // query or model-only item must not replace the project identity. + const pendingViews = this.queue.flatMap((item) => + item.kind === 'edit' && item.nextView !== undefined ? [item.modelName, item.nextView] : [], + ); + const inputs: readonly unknown[] = [ + this.committed, + this.errorDetails, + this.data, + this.incomingLinks, + this.modelName, + ...pendingViews, + ...[...this.viewport.entries()].flat(), + ]; + const memo = this.renderMemo; + if (memo && memo.inputs.length === inputs.length && memo.inputs.every((input, i) => input === inputs[i])) { + return memo; + } + const cachedErrors = + memo && memo.inputs[1] === this.errorDetails && memo.inputs[4] === this.modelName + ? memo.cachedErrors + : this.errorDetails.length === 0 + ? EMPTY_CACHED_ERRORS + : cachedErrorsFor(this.errorDetails, this.modelName); + let project = this.committed; + if (project !== undefined) { + if (this.seriesByIdent.size > 0 && project.models.has('main')) { + // Sim data comes from the root model, so series attach to 'main' even + // while a child model is viewed. + project = projectAttachSeries(project, this.seriesByIdent, 'main'); + } + project = annotateErrors(project, cachedErrors, this.modelName); + let models = project.models; + let activeRenames: ReadonlyMap = new Map(); + for (const [name, model] of project.models) { + const committedView = model.views[0]; + if (committedView === undefined) { + continue; + } + const pendingView = this.pendingViewOf(name); + const renames = + pendingView === undefined ? new Map() : pendingRenames(model.variables, committedView, pendingView); + if (name === this.modelName) { + activeRenames = renames; + } + const variables = + pendingView === undefined + ? model.variables + : withCreatedVariables(withRenamedVariables(model.variables, renames), committedView, pendingView); + const rendered = withViewport(pendingView ?? committedView, this.viewport.get(name)); + if (rendered !== committedView || variables !== model.variables) { + models = mapSet(models, name, { ...model, variables, views: [rendered, ...model.views.slice(1)] }); + } + } + if (models !== project.models) { + project = { ...project, models }; + } + project = annotateConnectors(project, this.modelName, this.incomingLinks.get(this.modelName), activeRenames); + } + this.renderMemo = { inputs, project, cachedErrors }; + return this.renderMemo; + } + + /** The next view of the last pending edit targeting `modelName`. */ + private pendingViewOf(modelName: string): StockFlowView | undefined { + for (let i = this.queue.length - 1; i >= 0; i--) { + const item = this.queue[i]; + if (item.kind === 'edit' && item.modelName === modelName && item.nextView !== undefined) { + return item.nextView; + } + } + return undefined; + } + /** * Mark the snapshot stale and (when not inside a batch) flush a single - * notification. Subscribers run after the new snapshot is published, so a - * listener calling getSnapshot() sees the latest state. Disposed controllers - * never notify. + * notification. Disposed controllers never notify. */ private notify(): void { this.snapshotDirty = true; @@ -475,11 +976,7 @@ export class ProjectController { } } - /** - * Coalesce all snapshot changes made inside `fn` into a single notify. This - * mirrors the old code's batching of multiple synchronous setState-equivalent - * changes into one render. Re-entrant: only the outermost batch flushes. - */ + /** Coalesce every snapshot change made inside `fn` into one notification. */ private batch(fn: () => T): T { this.batchDepth++; try { @@ -492,1014 +989,979 @@ export class ProjectController { } } - // --- undo/redo predicates --- + // --- undo/redo predicates - canUndo(): boolean { + private editsQueued(): boolean { + return this.queue.some((item) => item.kind === 'edit' || item.kind === 'undoRedo'); + } + + private undoRedoQueued(): boolean { + return this.queue.some((item) => item.kind === 'undoRedo'); + } + + private hasUndoHistory(): boolean { return this.projectHistory.length > 1 && this.projectOffset < this.projectHistory.length - 1; } - canRedo(): boolean { + private hasRedoHistory(): boolean { return this.projectOffset > 0; } - // --- engine lifecycle --- + canUndo(): boolean { + return this.hasUndoHistory() && !this.editsQueued() && !this.engineUnavailable; + } + + canRedo(): boolean { + return this.hasRedoHistory() && !this.editsQueued() && !this.engineUnavailable; + } + + // --- enqueueing + + /** + * Open the initial project in the engine. Resolves when the open item has + * run (successfully or not). + */ + openInitialProject(): Promise { + return new Promise((resolve) => { + this.push({ kind: 'open', settle: () => resolve() }); + }); + } /** - * Open the initial project in the engine and rebuild `project`. Idempotent - * against dispose: if dispose() races in before/after the open completes, - * the freshly-opened engine is released here rather than stranded. + * Enqueue a diagram edit: `nextView` becomes the rendered view of `modelName` + * immediately, and the executor later applies the model ops implied by + * (`baseView` -> `nextView`) plus the view, atomically. Resolves true when the + * edit lands, false when it is refused, dropped, or rolled back. * - * The try/catch deliberately extends past the engine open: the post-open - * steps (serializeProtobuf, serializeJson, projectFromJson) can still throw - * (a WASM panic, or projectFromJson rejecting an unknown view element type). - * Catching here surfaces a contextual message and disposes the orphaned - * engine, which is strictly better than leaving the user on a blank canvas. + * `baseView` defaults to the model's rendered view at enqueue time, which is + * what a handler reading `getView()` in the same tick planned on. `token` + * defaults to the current one; a gesture passes the token it captured at + * press so a truncation or undo landing in between drops it. + * + * Refused quietly while an undo/redo is queued: the edit was planned on the + * view the undo is about to replace, so it could only be dropped later, and + * reporting that would name a failure the user did not cause. */ - async openInitialProject(): Promise { - let engine: EngineApi; - try { - engine = - this.config.input.format === 'json' - ? await this.config.openJson(this.config.input.data) - : await this.config.openProtobuf(this.config.input.data as Uint8Array); - } catch (e: unknown) { - const err = getErrorDetails(e); - this.reportError(`opening the project in the engine failed: ${err.message ?? 'Unknown error'}`); - return; - } + enqueueViewEdit(edit: { + readonly label: string; + readonly nextView: StockFlowView; + readonly modelName?: string; + readonly baseView?: StockFlowView; + readonly token?: number; + }): Promise { + const modelName = edit.modelName ?? this.modelName; + const baseView = edit.baseView ?? this.getRenderedView(modelName); + if (this.disposed || this.engineUnavailable || baseView === undefined || this.undoRedoQueued()) { + return Promise.resolve(false); + } + // Refused here rather than dropped at dequeue, so the stale view never + // renders. + if (edit.token !== undefined && edit.token !== this.token) { + this.reportError(`${edit.label} discarded: the project changed while it was being made`); + return Promise.resolve(false); + } + // A non-finite coordinate serializes to JSON null, which the engine's patch + // parser rejects; it always means an upstream geometry bug, so the whole + // edit is refused before anything renders (issue #818). + const bad = findNonFiniteViewCoord(edit.nextView); + if (bad !== undefined) { + this.reportError(`internal error: refusing a view update with a non-finite coordinate (${bad})`); + return Promise.resolve(false); + } + return new Promise((resolve) => { + this.push({ + kind: 'edit', + label: edit.label, + modelName, + token: edit.token ?? this.token, + baseView, + nextView: edit.nextView, + buildPatch: undefined, + settle: resolve, + }); + }); + } + + /** + * Enqueue a model-only edit (equation, table, module wiring, sim specs): + * `buildPatch` runs at dequeue against the committed project, so the payload + * echoes the committed variable rather than one read before earlier edits + * landed. A builder that throws (the variable no longer exists) fails the + * item like an engine error. Resolves as `enqueueViewEdit` does. + */ + enqueueModelEdit(edit: { + readonly label: string; + readonly buildPatch: (committed: Project) => JsonProjectPatch; + }): Promise { + if (this.disposed || this.engineUnavailable || this.committed === undefined) { + return Promise.resolve(false); + } + return new Promise((resolve) => { + this.push({ + kind: 'edit', + label: edit.label, + modelName: this.modelName, + token: this.token, + baseView: undefined, + nextView: undefined, + buildPatch: edit.buildPatch, + settle: resolve, + }); + }); + } + /** + * Set the live viewport of `modelName` (a settled pan/zoom, a resize, a + * centering, a navigation restore). It renders immediately and a viewport + * item persists it to the engine later: no history, no save. The item reads + * the LATEST viewport of its model when it runs and patches nothing when that + * equals the committed one, so a burst of settles while an edit runs persists + * once. + */ + setViewport(modelName: string, viewport: Viewport): void { if (this.disposed) { - // dispose() ran during the engine open. Release the orphan: dispose() - // could not reach an engine that didn't exist yet. - await this.disposeOrphanedEngine(engine); return; } - - // The view the host's viewport override was spliced into, to be - // round-tripped to the engine once the project is published. - let overriddenView: StockFlowView | undefined; - try { - this.engine = engine; - - const serializedProject = await engine.serializeProtobuf(); - const json = JSON.parse(await engine.serializeJson(undefined, true)) as JsonProject; - // No live view exists on first open, so the engine-serialized view IS the - // rendered view -- compute connector annotations directly against it. - // The host's viewport override is spliced in first, so the FIRST published - // snapshot already carries it and the canvas never renders (or fits) the - // stored viewport for a frame. - const opened = withInitialViewport(projectFromJson(json), this.modelName, this.config.initialViewport); - const project = await this.attachConnectorErrors(await this.updateVariableErrors(opened.project)); - - if (this.disposed) { - this.engine = undefined; - await this.disposeOrphanedEngine(engine); - return; - } - - this.batch(() => { - this.projectHistory = [serializedProject]; - this.project = project; - this.notify(); - }); - overriddenView = opened.view; - } catch (e: unknown) { - this.engine = undefined; - await this.disposeOrphanedEngine(engine); - const err = getErrorDetails(e); - this.reportError(`opening the project failed: ${err.message ?? 'Unknown error'}`); + if (!isUsableViewport(viewport)) { + this.reportError('internal error: refusing a viewport with a non-finite coordinate or non-positive zoom'); return; } + this.viewport.set(modelName, { viewBox: { ...viewport.viewBox }, zoom: viewport.zoom }); + if (this.engineUnavailable) { + // Panning stays live; there is no engine to persist it to. + this.notify(); + return; + } + this.push({ kind: 'viewport', modelName, settle: () => {} }); + } - if (overriddenView !== undefined) { - // Bring the engine's copy of the view in line with the override -- the - // same view-only round-trip a settled pan makes (no history, no save), so - // a later content edit's snapshot carries this viewport. Outside the - // try above on purpose: the project is already published, and a failure - // here is a view-update failure (reported by queueViewUpdate itself), not - // a failed open that should tear the engine down. - await this.queueViewUpdate(overriddenView); + /** + * Enqueue an undo or redo. Refused (a no-op) while an edit or another + * undo/redo is queued, and when there is no history in that direction. + * + * `afterQueuedEdits` lifts only the queued-edit refusal: the undo/redo is + * queued behind the edits and applies to the history they leave. It is for a + * caller that has just submitted the user's latest change (a details-panel + * draft committed by the Undo press itself), so the undo applies to that + * change. View edits enqueued after it are refused as usual. + */ + undoRedo(direction: 'undo' | 'redo', options: { readonly afterQueuedEdits?: boolean } = {}): void { + if (this.disposed || this.engineUnavailable || this.undoRedoQueued()) { + return; } + const available = options.afterQueuedEdits + ? direction === 'undo' + ? this.hasUndoHistory() + : this.hasRedoHistory() + : direction === 'undo' + ? this.canUndo() + : this.canRedo(); + if (!available) { + return; + } + this.push({ kind: 'undoRedo', direction, settle: () => {} }); } /** - * Reopen the engine from a serialized snapshot (the undo/redo path). Disposes - * the previous engine first. Returns the new engine on success, undefined on - * failure. See openInitialProject for why the post-open steps are guarded. + * Run a read-only engine query (LaTeX rendering, XMILE export) through the + * executor, so it never runs concurrently with a patch or an engine swap. + * Resolves undefined when the controller has no engine or the query throws. */ - private async openEngineProject(serializedProject: Readonly): Promise { - await this.engine?.dispose(); - this.engine = undefined; + query(fn: (engine: EngineApi) => Promise): Promise { + if (this.disposed || this.engineUnavailable) { + return Promise.resolve(undefined); + } + return new Promise((resolve) => { + let result: T | undefined; + this.push({ + kind: 'query', + run: async (engine) => { + try { + result = await fn(engine); + } catch { + result = undefined; + } + }, + settle: () => resolve(result), + }); + }); + } - let engine: EngineApi; - try { - engine = await this.config.openProtobuf(serializedProject as Uint8Array); - } catch (e: unknown) { - const err = getErrorDetails(e); - this.reportError(`opening the project in the engine failed: ${err.message ?? 'Unknown error'}`); - return undefined; - } + /** Request a save of the committed state. */ + requestSave(): void { + this.requestMaintenance('save'); + } - if (this.disposed) { - await this.disposeOrphanedEngine(engine); - return undefined; + /** + * Resolves once nothing is queued, no maintenance is pending, the executor is + * idle and no host save is in flight. + */ + whenIdle(): Promise { + if (this.isIdle()) { + return Promise.resolve(); } + return new Promise((resolve) => { + this.idleWaiters.push(resolve); + }); + } - try { - this.engine = engine; + private isIdle(): boolean { + return !this.running && this.queue.length === 0 && this.maintenance.size === 0 && !this.inSave; + } - const json = JSON.parse(await engine.serializeJson(undefined, true)) as JsonProject; - let project = projectFromJson(json); - - if (this.newEngineShouldPullView) { - const queuedView = defined(this.newEngineQueuedView); - this.newEngineShouldPullView = false; - this.newEngineQueuedView = undefined; - const model = defined(project.models.get(this.modelName)); - const views = [...model.views]; - views[0] = queuedView; - const updatedModel = { ...model, views }; - project = { ...project, models: mapSet(project.models, this.modelName, updatedModel) }; - // queueViewUpdate is async; it will round-trip the queued view to the - // freshly-installed engine. We intentionally do not await it here. - void this.queueViewUpdate(queuedView); + private maybeResolveIdle(): void { + if (this.isIdle() && this.idleWaiters.length > 0) { + const waiters = this.idleWaiters; + this.idleWaiters = []; + for (const waiter of waiters) { + waiter(); } - - // The rendered view here is `project`'s view -- either the engine snapshot - // or the queued view spliced in above -- so annotate directly against it. - const withErrors = await this.attachConnectorErrors(await this.updateVariableErrors(project)); - - if (this.disposed) { - this.engine = undefined; - await this.disposeOrphanedEngine(engine); - return undefined; - } - - this.batch(() => { - this.project = withErrors; - // A reopen restores different project content (the undo/redo path). Bump - // the version (Canvas render-cache key) and the generation (detail-panel - // remount key) in the SAME notification as the content swap, so the - // version-keyed element cache rebuilds from the restored view and the - // panels re-seed from restored content. undoRedo deliberately does NOT - // bump these synchronously -- see the comment there (#817). - this.projectVersion = this.projectVersion + 0.01; - this.projectGeneration += 1; - this.notify(); - }); - - return engine; - } catch (e: unknown) { - this.engine = undefined; - await this.disposeOrphanedEngine(engine); - const err = getErrorDetails(e); - this.reportError(`opening the project failed: ${err.message ?? 'Unknown error'}`); - return undefined; } } - /** - * Release the WASM engine handle and latch the controller disposed. Safe to - * call before openInitialProject() resolves: a still-in-flight open detects - * the disposed flag and releases its own engine. Best-effort: a throwing - * dispose must not crash the host. - */ - async dispose(): Promise { + private push(item: QueueItem): void { if (this.disposed) { + item.settle(false); return; } - this.disposed = true; - this.listeners.clear(); - const engine = this.engine; - this.engine = undefined; - if (engine) { - await this.disposeOrphanedEngine(engine); - } + this.queue.push(item); + this.notify(); + this.kick(); } - /** - * Release an engine handle we opened but never wired into a live snapshot, - * so the WASM allocation doesn't leak. dispose() is best-effort: a throwing - * dispose must not mask the original error we're surfacing. - */ - private async disposeOrphanedEngine(engine: EngineApi): Promise { - try { - await engine.dispose(); - } catch { - // ignored: the engine is being abandoned regardless + private requestMaintenance(...kinds: MaintenanceKind[]): void { + if (this.disposed) { + return; + } + for (const kind of kinds) { + this.maintenance.add(kind); } + this.kick(); } - // --- the update pipeline --- + // --- the executor - /** - * Apply a content patch and, on success, rebuild `project` from the engine - * and schedule a re-simulation. Returns false (without rebuilding or - * scheduling) when the patch throws. - * - * `label` identifies the operation in the user-facing fallback message when - * the engine reports no message. - */ - async applyPatch(patch: JsonProjectPatch, label: string): Promise { - if (!(await this.applyPatchOrReportError(patch, label))) { - return false; + private kick(): void { + if (this.running || this.disposed) { + return; } - await this.refreshFromEngine(); - return true; + this.running = true; + this.loop = this.runLoop(); } - /** - * Apply a patch (allowing errors so partially-invalid models can be edited), - * reporting any failure. Returns false on failure so callers can bail. This - * is split from refreshFromEngine() so a caller can interleave its own state - * updates between the patch and the (async, serialize-heavy) round-trip. - */ - async applyPatchOrReportError(patch: JsonProjectPatch, label: string): Promise { - const engine = this.engine; - if (!engine) { - return false; - } + private async runLoop(): Promise { + // Start on a microtask so an enqueue returns (and its optimistic render + // publishes) before any engine call begins. + await Promise.resolve(); try { - await engine.applyPatch(patch, { allowErrors: true }); - } catch (e: unknown) { - const err = getErrorDetails(e); - console.error(`applyPatch error (${label}):`, err.code, err.message, err.details); - this.reportError(err.message ?? `Unknown error during ${label}`); - return false; + for (;;) { + if (this.disposed) { + break; + } + const step = this.nextStep(); + if (step === undefined) { + break; + } + await step(); + } + } finally { + this.running = false; + this.loop = undefined; + if (this.disposed) { + await this.releaseEngine(); + } + this.maybeResolveIdle(); } - this.adoptPatchedViews(patch); - return true; } - /** - * Mirror any primary-view upsert carried by a just-applied patch into the - * live project, exactly as updateView's optimistic step does. - * - * Why: preserveLiveView (see updateProject) always keeps the ACTIVE model's - * live view on refresh, protecting newer optimistic pans/moves from older - * engine snapshots. But a view arriving in an explicit upsertView op (e.g. - * a rename patching the variable and its view together) is newer user intent - * than the live view by construction -- without this mirror, the stale live - * view clobbers the patched one on refresh, the edit looks like a silent - * no-op, and the next geometry edit round-trips the stale view back into the - * engine, persisting a model/view divergence. - * - * Elements are re-linked against the CURRENT (pre-patch) variables; refs to - * variables the patch introduced resolve as undefined until the follow-up - * refreshFromEngine re-links them (same transient the optimistic paths - * already tolerate). - */ - private adoptPatchedViews(patch: JsonProjectPatch): void { - const variables = this.project?.models.get(this.modelName)?.variables; - if (!variables) { - return; - } - for (const model of patch.models ?? []) { - if (model.name !== this.modelName) { - continue; + private nextStep(): (() => Promise) | undefined { + if (this.queue.length > 0) { + const streakExpired = + this.editStreak >= MaintenanceEditBound || this.now() - this.editStreakStart >= MaintenanceTimeBoundMs; + if (this.maintenance.size > 0 && this.editStreak > 0 && streakExpired) { + const kinds = MAINTENANCE_ORDER.filter((kind) => this.maintenance.has(kind)); + this.editStreak = 0; + return async () => { + for (const kind of kinds) { + await this.runMaintenance(kind); + } + }; } - for (const op of model.ops ?? []) { - if (op.type === 'upsertView' && op.payload.index === 0) { - this.applyOptimisticView(stockFlowViewFromJson(op.payload.view, variables)); - } + if (this.editStreak === 0) { + this.editStreakStart = this.now(); } + this.editStreak++; + const item = this.queue[0]; + return () => this.runItem(item); } - } - - /** Round-trip the engine's serialized state back into `project` and schedule - * a re-simulation. Called after a successful patch. */ - async refreshFromEngine(): Promise { - const engine = this.engine; - if (!engine) { - return; + this.editStreak = 0; + const kind = MAINTENANCE_ORDER.find((k) => this.maintenance.has(k)); + if (kind === undefined) { + return undefined; } - await this.updateProject(await engine.serializeProtobuf()); - this.scheduleSimRun(); + return () => this.runMaintenance(kind); } - /** - * Rebuild `project` from a serialized protobuf snapshot. Records undo history - * and schedules a save unless told otherwise. - * - * Preserving the live view: this call may have raced with a newer optimistic - * setView (the user kept panning while the round-trip was in flight), so the - * engine snapshot is potentially behind. preserveLiveView keeps the active - * model's view from the live `project` to avoid the diagram snapping back. - * - * View-only updates (recordHistory: false) refresh the rendered project and - * bump projectVersion but must not touch projectHistory/projectOffset: - * viewBox/zoom are serialized into the protobuf, so recording them would let - * a single momentum flick evict every real edit from the small undo buffer. - */ - async updateProject( - serializedProject: Readonly, - opts: { scheduleSave?: boolean; recordHistory?: boolean } = {}, - ): Promise { - const { scheduleSave = true, recordHistory = true } = opts; - if (this.projectHistory.length > 0) { - const current = this.projectHistory[this.projectOffset]; - if (uint8ArraysEqual(serializedProject, current)) { - return; + private async runItem(item: QueueItem): Promise { + let landed = false; + this.runningItem = item; + try { + switch (item.kind) { + case 'open': + await this.runOpen(); + landed = true; + break; + case 'edit': + landed = await this.runEdit(item); + break; + case 'viewport': + landed = await this.runViewport(item); + break; + case 'undoRedo': + landed = await this.runUndoRedo(item); + break; + case 'query': + if (this.engine) { + await item.run(this.engine); + } + landed = true; + break; } + } finally { + this.runningItem = undefined; + const index = this.queue.indexOf(item); + if (index !== -1) { + this.queue.splice(index, 1); + } + this.notify(); + item.settle(landed); } + } - const engine = this.engine; - if (!engine) { + private async runOpen(): Promise { + let engine: EngineApi; + try { + engine = + this.config.input.format === 'json' + ? await this.config.openJson(this.config.input.data) + : await this.config.openProtobuf(this.config.input.data as Uint8Array); + } catch (e: unknown) { + this.reportError(`opening the project in the engine failed: ${getErrorDetails(e).message ?? 'Unknown error'}`); return; } - // Include stdlib model definitions so the editor can display and navigate - // into stdlib modules. The save path does NOT pass includeStdlib, so - // stdlib models are never persisted. - const json = JSON.parse(await engine.serializeJson(undefined, true)) as JsonProject; - let activeProject = await this.updateVariableErrors(projectFromJson(json)); - if (this.data) { - activeProject = projectAttachData(activeProject, this.data, 'main'); - } - activeProject = preserveLiveView(activeProject, this.project, this.modelName); - // Connector annotations must reflect the RENDERED view, so compute them only - // after preserveLiveView has swapped in the (possibly newer) live view -- - // see attachConnectorErrors. Runs after projectAttachData too, so it - // preserves the attached series when it rewrites the active model's vars. - activeProject = await this.attachConnectorErrors(activeProject); - if (this.disposed) { + await disposeQuietly(engine); + return; + } + let serialized: Uint8Array; + let project: Project; + try { + serialized = await engine.serializeProtobuf(); + project = projectFromJson(JSON.parse(await engine.serializeJson(undefined, true)) as JsonProject); + } catch (e: unknown) { + await disposeQuietly(engine); + this.reportError(`opening the project failed: ${getErrorDetails(e).message ?? 'Unknown error'}`); + return; + } + if (this.disposed) { + await disposeQuietly(engine); return; } - - // Fractionally increase the render-cache key so the Canvas invalidates - // with a simple version check. This is display bookkeeping only: the - // integer version the save path sends lives in `serverVersion` (#958). - const projectVersion = this.projectVersion + 0.01; - this.batch(() => { - if (recordHistory) { - const nextHistory = advanceProjectHistory( - { projectHistory: this.projectHistory, projectOffset: this.projectOffset }, - serializedProject, - MaxUndoSize, - ); - this.projectHistory = nextHistory.projectHistory; - this.projectOffset = nextHistory.projectOffset; - this.projectGeneration += 1; + this.engine = engine; + this.committed = project; + this.projectHistory = [serialized]; + this.projectOffset = 0; + const initial = this.config.initialViewport; + if (initial !== undefined && isUsableViewport(initial) && project.models.get(this.modelName)?.views[0]) { + this.setViewport(this.modelName, initial); } - this.project = activeProject; - this.projectVersion = projectVersion; this.notify(); }); - - if (scheduleSave) { - this.scheduleSave(); - } + this.requestMaintenance('errors', 'connectors', 'sim'); } - /** - * Optimistic view update for a DISCRETE element/structure edit (create, - * delete, element/group move, label move, flow/link attach): reflect the new - * view in the snapshot immediately (so the UI never flashes stale positions), - * then round-trip through the engine. - * - * `recordHistory` controls whether this edit advances the undo buffer; it - * defaults to false so the bare call stays a non-recording view refresh. Each - * discrete user edit passes `recordHistory: true` so it becomes individually - * undoable. For the handlers that apply a content patch (via - * applyPatchOrReportError) BEFORE calling this, the snapshot serialized here - * captures the engine state AFTER both the content patch and the view update, - * so a single recorded entry covers the whole edit -- no double-recording. - * - * The per-frame viewport stream (pan/zoom/momentum/resize) does NOT come - * through here -- it uses queueViewUpdate, which never records, so a momentum - * flick cannot evict real edits from the small undo buffer. - */ - /** - * Guard against a view carrying a non-finite (NaN/Infinity) coordinate. Such a - * coordinate serializes to JSON `null`, which the engine's patch parser rejects - * with "invalid type: null, expected f64" -- historically bricking the model - * (every later edit failed and the element rendered displaced). A non-finite - * coordinate always means an upstream geometry bug, so we refuse the update - * entirely (no optimistic apply, no patch) and surface a descriptive error - * rather than corrupting the model. Returns true when the view is safe to - * apply. (issue #818) - */ - private viewCoordsAreFinite(view: StockFlowView): boolean { - const bad = findNonFiniteViewCoord(view); - if (bad === undefined) { - return true; - } - this.reportError(`internal error: refusing a view update with a non-finite coordinate (${bad})`); - return false; - } - - async updateView(view: StockFlowView, opts: { recordHistory?: boolean } = {}): Promise { - if (!this.viewCoordsAreFinite(view)) { - return; - } - const { recordHistory = false } = opts; - this.applyOptimisticView(view); - + private async runEdit(item: EditItem): Promise { const engine = this.engine; - if (!engine) { - return; + const committed = this.committed; + if (engine === undefined || committed === undefined) { + this.fail(item, `${item.label} failed: the project is not open`); + return false; } - const patch = this.viewPatch(view); + if (item.nextView !== undefined && item.token !== this.token) { + // Whatever moved the token already bumped it; bumping again would abort + // gestures planned under the current token for no reason. + this.failFrom(item, `${item.label} discarded: the project changed while it was being made`, { + bumpToken: false, + }); + return false; + } + let patch: JsonProjectPatch; try { + patch = this.patchFor(item, committed); await engine.applyPatch(patch, { allowErrors: true }); } catch (e: unknown) { const err = getErrorDetails(e); - console.error('applyPatch error (view update):', err.code, err.message, err.details); - this.reportError(err.message ?? 'Unknown error during view update'); - return; + console.error(`applyPatch error (${item.label}):`, err.code, err.message, err.details); + this.fail(item, err.message ?? `Unknown error during ${item.label}`); + return false; } - await this.updateProject(await engine.serializeProtobuf(), { scheduleSave: true, recordHistory }); + if (this.disposed) { + return false; + } + try { + await this.rebuildCommitted(engine, true); + } catch (e: unknown) { + // The patch applied, but committed no longer matches the engine. The item + // stays at the head of the queue (its next view still renders) while the + // resync runs, so handlers keep planning on that view; its fate is decided + // only once committed agrees with the engine again. + const message = `reading the project back after ${item.label} failed: ${getErrorDetails(e).message ?? 'Unknown error'}`; + const outcome = await this.resync(engine, true); + if (outcome !== 'reread') { + // Reopened: the patch is lost, so the edit failed, and so did every view + // edit planned on its next view -- including those enqueued while the + // reopen ran, which is why this fails AFTER the swap. Released or + // disposed: the queue was already settled. + if (outcome === 'reopened') { + this.fail(item, message); + } + return false; + } + } + if (patchChangesModel(patch)) { + this.requestMaintenance('save', 'errors', 'connectors', 'sim'); + } else { + // Only views changed. A view feeds no simulation, no diagnostic and no + // equation dependency (connector targets are named elements, and creating, + // deleting or renaming one is a model op), so a geometry-only edit leaves + // everything those refreshes derive as it was. They are not free on a large + // model: a C-LEARN-sized run and its follow-up render take seconds. + this.requestMaintenance('save'); + } + return true; } - /** - * Like updateView but for viewBox/zoom-only changes (pan/zoom/momentum, - * panel resizes): optimistic immediate snapshot, async engine round-trip - * that neither records history nor schedules a save. When no engine is yet - * installed (a newly imported model still loading), stash the view to replay - * once the engine arrives. - */ - async queueViewUpdate(view: StockFlowView): Promise { - if (!this.viewCoordsAreFinite(view)) { - return; - } - this.applyOptimisticView(view); + private patchFor(item: EditItem, committed: Project): JsonProjectPatch { + if (item.nextView === undefined || item.baseView === undefined) { + return item.buildPatch!(committed); + } + const model = committed.models.get(item.modelName); + if (model === undefined) { + throw new Error(`model '${item.modelName}' does not exist`); + } + // An edit never persists a viewport ahead of the viewport items: the view it + // upserts carries the committed viewport. + const committedView = model.views[0]; + const nextView = + committedView === undefined + ? item.nextView + : { ...item.nextView, viewBox: committedView.viewBox, zoom: committedView.zoom }; + return { models: [{ name: item.modelName, ops: buildEditOps(model, item.baseView, nextView) }] }; + } + private async runViewport(item: ViewportItem): Promise { const engine = this.engine; - if (!engine) { - this.newEngineShouldPullView = true; - this.newEngineQueuedView = view; - return; + const view = this.committed?.models.get(item.modelName)?.views[0]; + const viewport = this.viewport.get(item.modelName); + if (engine === undefined || view === undefined || viewport === undefined) { + return false; } - const patch = this.viewPatch(view); + if (viewportsEqual(viewportOf(view), viewport)) { + return true; + } + const patch: JsonProjectPatch = { + models: [ + { + name: item.modelName, + ops: [ + { + type: 'upsertView', + payload: { + index: 0, + view: stockFlowViewToJson({ ...view, viewBox: viewport.viewBox, zoom: viewport.zoom }), + }, + }, + ], + }, + ], + }; try { await engine.applyPatch(patch, { allowErrors: true }); } catch (e: unknown) { const err = getErrorDetails(e); - console.error('applyPatch error (queue view update):', err.code, err.message, err.details); + console.error('applyPatch error (viewport):', err.code, err.message, err.details); + // The engine kept the committed viewport, so the rendered one goes back + // to it -- unless a newer viewport was set meanwhile, whose own item + // persists it. + if (this.viewport.get(item.modelName) === viewport) { + this.batch(() => { + this.viewport.delete(item.modelName); + this.notify(); + }); + } this.reportError(err.message ?? 'Unknown error during view update'); - return; + return false; + } + if (this.disposed) { + return false; + } + try { + await this.rebuildCommitted(engine, false); + } catch { + // Nothing is reported: the re-read keeps the viewport, and a reopen loses + // nothing but viewports (committed differs from the last recorded snapshot + // only by persisted viewports), which it persists again. A release shows + // the engine-unavailable notice. + return (await this.resync(engine, false)) === 'reread'; } - await this.updateProject(await engine.serializeProtobuf(), { scheduleSave: false, recordHistory: false }); + return true; } - /** - * Synchronously replace the active model's primary view in `project` and bump - * the render version by a small fraction (cache-key only; no history, no - * generation bump). This is the optimistic step shared by updateView and - * queueViewUpdate. No-op (other than version bump skipped) when no project is - * loaded yet. - */ - private applyOptimisticView(view: StockFlowView): void { - const project = this.project; - if (!project) { - return; + private async runUndoRedo(item: UndoRedoItem): Promise { + const delta = item.direction === 'undo' ? 1 : -1; + const offset = Math.max(0, Math.min(this.projectOffset + delta, this.projectHistory.length - 1)); + if (offset === this.projectOffset) { + return false; + } + let engine: EngineApi; + try { + engine = await this.config.openProtobuf(this.projectHistory[offset] as Uint8Array); + } catch (e: unknown) { + this.reportError(`opening the project in the engine failed: ${getErrorDetails(e).message ?? 'Unknown error'}`); + return false; + } + let project: Project; + try { + project = projectFromJson(JSON.parse(await engine.serializeJson(undefined, true)) as JsonProject); + } catch (e: unknown) { + await disposeQuietly(engine); + this.reportError(`opening the project failed: ${getErrorDetails(e).message ?? 'Unknown error'}`); + return false; + } + if (this.disposed) { + await disposeQuietly(engine); + return false; + } + const previous = this.engine; + this.engine = engine; + if (previous !== undefined) { + await disposeQuietly(previous); } - const model = defined(project.models.get(this.modelName)); - const views = [...model.views]; - views[0] = view; - const updatedModel = { ...model, views }; - const activeProject = { ...project, models: mapSet(project.models, this.modelName, updatedModel) }; - this.batch(() => { - this.project = activeProject; - this.projectVersion = this.projectVersion + 0.001; + this.committed = project; + this.projectOffset = offset; + this.token += 1; + this.restoreSeq += 1; + // The restored project carries its own viewports (they are part of each + // snapshot), exactly as the engine will save them. + this.viewport.clear(); + if (this.modelStack.length > 0 && !project.models.has(this.modelName)) { + this.modelStack = []; + this.modelName = 'main'; + this.navResetSeq += 1; + } this.notify(); }); + this.requestMaintenance('save', 'errors', 'connectors', 'sim'); + return true; } - private viewPatch(view: StockFlowView): JsonProjectPatch { - const ops: JsonModelOperation[] = [ - { - type: 'upsertView', - payload: { index: 0, view: stockFlowViewToJson(view) }, - }, - ]; - return { models: [{ name: this.modelName, ops }] }; - } - - // --- save queue --- - /** - * Schedule a save. Deferred via setTimeout so a burst of edits coalesces. - * The continuation short-circuits if the controller was disposed before it - * fired. The version to send is NOT captured here: save() reads the live - * `serverVersion` at flush time, so a save acknowledged between scheduling - * and flushing is reflected. + * Fail edit item `item`. A view edit fails from itself (see failFrom). A + * model-only edit has no next view, so no queued edit was planned on it: its + * failure is reported, later edits stay queued and the token does not move. + * The one thing that can wait behind a model-only edit and depends on it is + * an undo/redo queued with `afterQueuedEdits` (undo/redo is otherwise refused + * while an edit is queued): it was meant to apply to the change this edit + * carried, so it is discarded rather than applied to an older edit. */ - scheduleSave(): void { - setTimeout(() => { - if (this.disposed) { - return; + private fail(item: EditItem, message: string): void { + if (item.nextView === undefined) { + const index = this.queue.indexOf(item); + const dependent = new Set( + index === -1 ? [] : this.queue.slice(index + 1).filter((i) => i.kind === 'undoRedo'), + ); + if (dependent.size > 0) { + this.batch(() => { + this.queue = this.queue.filter((i) => !dependent.has(i)); + this.notify(); + }); + for (const d of dependent) { + d.settle(false); + } } - void this.save(); - }); - } - - /** - * Serialize and hand off to the host's save callback, sending the last - * server-ACKNOWLEDGED version (`serverVersion`) as the optimistic-concurrency - * check -- never a value derived from the fractional `projectVersion` cache - * key, whose drift used to cross integer boundaries after ~100 unsaved edits - * and corrupt the check (issue #958). A returned version advances - * serverVersion; a failed save (rejection or resolved-undefined) leaves it - * untouched so the next attempt re-sends the same still-valid version. - * - * A save already in flight queues exactly one flush. inSave is released in a - * finally block: a thrown save (e.g. host-side network failure) must not - * leave inSave stuck true, otherwise every subsequent edit silently queues - * forever. The queued retry re-reads serverVersion, picking up whatever this - * save's outcome left there. - */ - async save(): Promise { - if (this.inSave) { - this.saveQueued = true; + this.reportError(message); return; } - - this.inSave = true; - - try { - const engine = defined(this.engine); - const currVersion = this.serverVersion; - let version: number | undefined; - if (this.config.input.format === 'json') { - version = await this.config.save({ format: 'json', data: await engine.serializeJson() }, currVersion); - } else { - version = await this.config.save({ format: 'protobuf', data: await engine.serializeProtobuf() }, currVersion); - } - if (version) { - this.serverVersion = version; - this.notify(); - } - } catch (err) { - this.reportError(err instanceof Error ? err : new Error(String(err))); - } finally { - this.inSave = false; - if (this.saveQueued) { - this.saveQueued = false; - await this.save(); - } - } + this.failFrom(item, message); } - // --- undo/redo --- - /** - * Move the undo cursor and reopen the engine from the restored snapshot. - * Only the cursor (`projectOffset`) moves synchronously; the version and - * generation bump (and the notify that drives re-render / details-panel - * remount) are deferred to the engine reopen so they land together with the - * restored content -- see the inline comment below and openEngineProject - * (#817). One consequence: the UndoRedoBar's enabled state, read from the - * snapshot, updates a macrotask later (after the reopen notifies) rather than - * on click; canUndo()/canRedo() the live methods still reflect the cursor - * immediately. After the reopen, if the restored project no longer contains - * the viewed model (e.g. undo after creating and drilling into a new - * submodel), navigation resets to 'main' and `navResetSeq` bumps so the - * Editor clears its selection/details/tool state. + * Fail view edit `item`: every later view edit and undo/redo was planned on + * its optimistic view (or would drop as stale), so they are discarded with it. + * Model-only edits stay queued: they build their whole payload from the + * committed project at dequeue, so an unrelated failure does not invalidate + * them, and discarding one would silently lose the user's typed text. One + * that targets a variable a discarded edit would have created fails on its own + * at dequeue and reports its own error. Viewport and query items are not edits + * and stay queued too. Bumps the token (unless the caller is dropping an item + * whose token already moved) and reports one error. */ - undoRedo(kind: 'undo' | 'redo'): void { - const delta = kind === 'undo' ? 1 : -1; - let projectOffset = this.projectOffset + delta; - projectOffset = Math.min(projectOffset, this.projectHistory.length - 1); - projectOffset = Math.max(projectOffset, 0); - const serializedProject = defined(this.projectHistory[projectOffset]); - - // Move the undo cursor synchronously so canUndo/canRedo (live methods) and a - // rapid second click compute the right next offset. But do NOT bump - // projectVersion/projectGeneration or notify yet: this.project is still the - // pre-undo content and the rebuild is async. Bumping the version now would - // make the Canvas cache its uid lookup from the stale (pre-undo) view, then - // the async reopen would swap in the restored view WITHOUT re-bumping the - // version -- leaving the version-keyed element cache stale relative to - // props.view, the transient inconsistency behind the dangling-ref undo crash - // (#817). The bump + notify happens once, inside openEngineProject, in the - // same batch as the content swap. - this.projectOffset = projectOffset; - - setTimeout(() => { - if (this.disposed) { - return; + private failFrom(item: EditItem, message: string, options: { readonly bumpToken?: boolean } = {}): void { + const index = this.queue.indexOf(item); + const later = index === -1 ? [] : this.queue.slice(index + 1); + const discarded = new Set( + later.filter( + (i) => (i.kind === 'edit' && i.nextView !== undefined) || i.kind === 'undoRedo' || i.kind === 'open', + ), + ); + this.queue = [...this.queue.slice(0, index + 1), ...later.filter((i) => !discarded.has(i))]; + this.batch(() => { + if (options.bumpToken ?? true) { + this.token += 1; } - void this.reopenForUndoRedo(serializedProject); + this.notify(); }); - } - - private async reopenForUndoRedo(serializedProject: Readonly): Promise { - const engine = await this.openEngineProject(serializedProject); - if (this.disposed) { - // The reopen finished against a disposed controller -- release the - // engine it installed so the WASM allocation isn't stranded. - this.engine = undefined; - if (engine) { - await this.disposeOrphanedEngine(engine); - } - return; - } - // After undo/redo, the restored project may not contain the model we were - // viewing. Reset navigation if the current model is gone. - const project = this.project; - if (project && this.modelStack.length > 0 && !project.models.has(this.modelName)) { - this.batch(() => { - this.modelStack = []; - this.modelName = 'main'; - this.navResetSeq += 1; - this.notify(); - }); + for (const d of discarded) { + d.settle(false); } - this.scheduleSimRun(); - this.scheduleSave(); - } - - // --- sim runs --- - - /** Schedule a deferred simulation run. The continuation short-circuits on - * dispose or a missing engine. */ - scheduleSimRun(): void { - setTimeout(() => { - if (this.disposed) { - return; - } - if (!this.engine) { - return; - } - void this.loadSim(); - }); + const suffix = + discarded.size === 0 ? '' : ` (${discarded.size} later edit${discarded.size === 1 ? '' : 's'} discarded)`; + this.reportError(`${message}${suffix}`); } /** - * Recalculate status, then run the main model and attach the resulting series - * to the root model. Sparklines don't need Loops-That-Matter analysis, and - * LTM compilation can blow up WASM memory on dense causal graphs (World3: - * ~1.8M elementary circuits -> RuntimeError: unreachable). We request a plain - * simulation first; on any failure we retry with LTM explicitly disabled so a - * future default flip cannot starve the UI of sparkline data. The first - * failure is surfaced as a warning-style error entry. + * Bring `committed` back in line with the engine after a patch applied but + * reading the project back failed, before the next item runs, and say how: + * + * - 'reread': reading the engine again succeeded. The patch is kept, and + * history records it when `recordHistory` (the caller's own setting: a + * viewport persist records nothing). + * - 'reopened': the last recorded snapshot (the one at the history cursor) was + * opened in a new engine and installed. The patch is lost, as a rolled-back + * edit's is; the live viewports, which the snapshot may not carry, are + * persisted again. + * - 'released': the reopen failed too. The engine is released and the + * controller latches engine-unavailable (see becomeUnavailable). + * - 'disposed': the controller was disposed meanwhile; an engine the reopen + * produced is released, and the executor releases the installed one. */ - async loadSim(): Promise { - await this.recalculateStatus(); - - const engine = this.engine; - if (!engine) { - return; + private async resync( + engine: EngineApi, + recordHistory: boolean, + ): Promise<'reread' | 'reopened' | 'released' | 'disposed'> { + try { + await this.rebuildCommitted(engine, recordHistory); + return this.disposed ? 'disposed' : 'reread'; + } catch { + // fall through to the reopen } - - if (!(await engine.isSimulatable())) { - return; + if (this.disposed) { + return 'disposed'; } - const model = await engine.mainModel(); - let run: EngineRunApi; + let reopened: EngineApi; + let project: Project; try { - run = await model.run(); - } catch (e) { - this.reportError(e instanceof Error ? e : new Error(String(e))); + reopened = await this.config.openProtobuf(this.projectHistory[this.projectOffset] as Uint8Array); try { - run = await model.run({}, { analyzeLtm: false }); - } catch (e2) { - this.reportError(e2 instanceof Error ? e2 : new Error(String(e2))); - await this.refreshCachedErrors(); - return; + project = projectFromJson(JSON.parse(await reopened.serializeJson(undefined, true)) as JsonProject); + } catch (e: unknown) { + await disposeQuietly(reopened); + throw e; } + } catch (e: unknown) { + if (this.disposed) { + return 'disposed'; + } + await this.becomeUnavailable(engine, e); + return 'released'; } - if (this.disposed) { - return; + await disposeQuietly(reopened); + return 'disposed'; } - - const idents = run.varNames; - const time = run.getSeries('time') ?? new Float64Array(0); - const data = new Map( - idents.map((ident) => { - const values = run.getSeries(ident) ?? new Float64Array(0); - return [ident, { name: ident, time, values }]; - }), - ); - const project = defined(this.project); - // Simulation data comes from mainModel(), so variable idents are - // root-model-scoped. Always attach data to 'main' so root sparklines stay - // populated even when a sim runs while viewing a child model. + this.engine = reopened; + await disposeQuietly(engine); this.batch(() => { - this.project = projectAttachData(project, data, 'main'); - this.data = data; + this.committed = project; this.notify(); }); - // Refresh cached errors after simulation so the error panel reflects any - // new simulation errors (e.g. runtime divide-by-zero). - await this.refreshCachedErrors(); + for (const modelName of this.viewport.keys()) { + this.push({ kind: 'viewport', modelName, settle: () => {} }); + } + this.requestMaintenance('errors', 'connectors', 'sim'); + return 'reopened'; } - /** Derive simulatability status from the engine and project. */ - async recalculateStatus(): Promise { - const project = this.project; - const engine = this.engine; - - let status: 'ok' | 'error' | 'disabled'; - if (!engine || !project || project.hasNoEquations) { - status = 'disabled'; - } else if (!(await engine.isSimulatable())) { - status = 'error'; - } else { - status = 'ok'; + /** + * Latch engine-unavailable after a resync could not reopen the project: + * release the engine, settle every queued item but the running one as not + * landed, drop pending maintenance, and refuse everything from here on + * quietly. One persistent state replaces a toast per refused edit: the host + * tells the user once that changes can no longer be saved and offers a reload. + */ + private async becomeUnavailable(engine: EngineApi, cause: unknown): Promise { + console.error('the project could not be reloaded:', getErrorDetails(cause).message ?? 'Unknown error'); + const discarded = this.queue.filter((item) => item !== this.runningItem); + this.batch(() => { + this.engine = undefined; + this.engineUnavailable = true; + this.queue = this.queue.filter((item) => item === this.runningItem); + this.maintenance.clear(); + this.notify(); + }); + for (const item of discarded) { + item.settle(false); } + await disposeQuietly(engine); + } + /** Replace `committed` with the engine's serialized state, recording history. */ + private async rebuildCommitted(engine: EngineApi, recordHistory: boolean): Promise { + const serialized = await engine.serializeProtobuf(); + // Include stdlib model definitions so the editor can display and navigate + // into stdlib modules. The save path does NOT include them, so stdlib models + // are never persisted. + const project = projectFromJson(JSON.parse(await engine.serializeJson(undefined, true)) as JsonProject); if (this.disposed) { return; } - if (status !== this.status) { - this.status = status; + this.batch(() => { + this.committed = project; + const head = this.projectHistory[this.projectOffset]; + // viewBox/zoom are serialized into the protobuf, so a viewport persist + // never records: one momentum flick would evict every real edit from the + // small undo buffer. + if (recordHistory && (head === undefined || !uint8ArraysEqual(serialized, head))) { + const next = advanceProjectHistory( + { projectHistory: this.projectHistory, projectOffset: this.projectOffset }, + serialized, + MaxUndoSize, + ); + this.projectHistory = next.projectHistory; + this.projectOffset = next.projectOffset; + } this.notify(); - } + }); } - // --- error cache --- + // --- maintenance - /** - * Re-derive the model-scoped cached errors from the engine. Returns the new - * cache (or undefined when no engine is installed). - */ - async refreshCachedErrors(): Promise { + private async runMaintenance(kind: MaintenanceKind): Promise { + this.maintenance.delete(kind); const engine = this.engine; - if (!engine) { - return undefined; + if (engine === undefined) { + return; } - - const modelName = this.modelName; - const errors = await engine.getErrors(); - const { varErrors, unitErrors } = convertErrorDetails(errors, modelName); - - let simError: SimError | undefined; - const modelErrors: ModelError[] = []; - for (const err of errors) { - if (err.modelName && err.modelName !== modelName) { - continue; - } - if (err.kind === SimlinErrorKind.Simulation) { - simError = { - code: err.code as unknown as ErrorCode, - details: err.message ?? undefined, - }; - } else if (!err.variableName) { - modelErrors.push({ - code: err.code as unknown as ErrorCode, - // Prefer the bare reason over the terminal-formatted message (the - // unit-inference umbrella carries a plain-language sentence there); - // most model errors have no details and keep the message. - details: err.details ?? err.message ?? undefined, - }); + try { + switch (kind) { + case 'save': + await this.serializeForSave(engine); + break; + case 'errors': + await this.refreshErrors(engine); + break; + case 'connectors': + await this.refreshConnectors(engine); + break; + case 'sim': + await this.runSim(engine); + break; } + } catch (e: unknown) { + this.reportError(e instanceof Error ? e : new Error(String(e))); } - const cachedErrors: CachedErrorDetails = { varErrors, unitErrors, simError, modelErrors }; - if (this.disposed) { - return cachedErrors; - } - this.cachedErrors = cachedErrors; - this.notify(); - return cachedErrors; + } + + private async serializeForSave(engine: EngineApi): Promise { + const project = + this.config.input.format === 'json' + ? { format: 'json' as const, data: await engine.serializeJson() } + : { format: 'protobuf' as const, data: await engine.serializeProtobuf() }; + void this.flushSave(project); } /** - * Annotate the project's active-model variables with their equation/unit - * errors. Refreshes the cached errors as a side effect. Returns a new Project; - * does not mutate `this.project`. + * Hand serialized bytes to the host, sending the last server-ACKNOWLEDGED + * version (`serverVersion`) as the optimistic-concurrency check. A returned + * version advances serverVersion; a failed save (rejection or + * resolved-undefined) leaves it untouched so the next attempt re-sends the + * same still-valid version. + * + * A save requested while one is in flight queues exactly one flush of the + * LATEST bytes, which re-reads serverVersion when it runs. inSave is released + * in a finally block: a thrown host save must not leave it stuck true, or + * every later save would queue forever. */ - async updateVariableErrors(project: Project): Promise { - const cached = await this.refreshCachedErrors(); - if (!cached) { - return project; + private async flushSave( + project: { format: 'protobuf'; data: Uint8Array } | { format: 'json'; data: string }, + ): Promise { + if (this.inSave) { + this.queuedSave = project; + return; } - - const modelName = this.modelName; - const { varErrors, unitErrors } = cached; - - if (varErrors.size > 0) { - const model = getOrThrow(project.models, modelName); - - // If all the errors are 'just' that we have no equations, don't scream - // "error" at the user -- they are starting from scratch on a new model - // and don't expect it to be running yet. - if ( - varErrors.size === model.variables.size && - setsEqual(new Set(varErrors.keys()), new Set(model.variables.keys())) - ) { - let foundOtherError = false; - for (const [, errs] of varErrors) { - if (errs.length !== 1 || first(errs).code !== ErrorCode.EmptyEquation) { - foundOtherError = true; - break; - } - } - if (!foundOtherError) { - return { ...project, hasNoEquations: true }; - } + this.inSave = true; + try { + const version = await this.config.save(project, this.serverVersion); + if (version) { + this.serverVersion = version; + this.notify(); } - - const mutableVars = new Map(model.variables); - for (const [ident, errs] of varErrors) { - const variable = mutableVars.get(ident); - if (variable) { - mutableVars.set(ident, { ...variable, errors: errs }); - } + } catch (err) { + this.reportError(err instanceof Error ? err : new Error(String(err))); + } finally { + this.inSave = false; + const queued = this.queuedSave; + this.queuedSave = undefined; + if (queued !== undefined && !this.disposed) { + await this.flushSave(queued); } - const updatedModel = { ...model, variables: mutableVars as ReadonlyMap }; - project = { ...project, models: mapSet(project.models, modelName, updatedModel) }; + this.maybeResolveIdle(); } + } - if (unitErrors.size > 0) { - const model = getOrThrow(project.models, modelName); - const mutableVars = new Map(model.variables); - for (const [ident, errs] of unitErrors) { - const variable = mutableVars.get(ident); - if (variable) { - mutableVars.set(ident, { ...variable, unitErrors: errs }); - } - } - const updatedModel = { ...model, variables: mutableVars as ReadonlyMap }; - project = { ...project, models: mapSet(project.models, modelName, updatedModel) }; + private async refreshErrors(engine: EngineApi): Promise { + const errors = await engine.getErrors(); + const simulatable = await engine.isSimulatable(); + if (this.disposed) { + return; } - - return project; + this.errorDetails = errors; + this.simulatable = simulatable; + this.notify(); } /** - * Annotate the active model's aux/flow/stock variables with sketch-connector - * drift (connectors out of sync with equations; see diagram/connector-sync.ts). - * Equation dependencies come from the engine's per-variable `getIncomingLinks` - * (authoritative: excludes builtins/TIME, structural flow<->stock edges, and - * dotted module-output refs), so the check resolves arrayed/apply-to-all - * dependencies correctly. Returns a new Project; does not mutate `this.project`. - * - * ORDERING: this MUST run on the view that will actually be rendered, so it is - * called by each rebuild path AFTER that path has settled on its final view -- - * NOT tail-called from updateVariableErrors. In updateProject the rendered view - * is the live optimistic view that `preserveLiveView` swaps in, which can be - * NEWER than the engine-serialized snapshot (e.g. a connector the user just - * drew but that has not round-tripped yet); computing against the stale engine - * view would flag a "missing" connector that is actually present on screen (or - * miss a stale one). The open/undo-reopen paths have no newer live view -- the - * engine (or queued) view IS the rendered view -- so they call this directly on - * that project. - * - * Best-effort and non-fatal: any engine failure (a raced rename, a missing - * model) leaves the project without connector annotations rather than aborting - * the rebuild. Skipped for stdlib/macro models (not user-edited sketches). + * Fetch the active model's equation dependencies for the aux/flow/stock + * variables on its committed view, under their committed idents (the engine + * knows no pending rename; annotateConnectors maps them onto the rendered + * model). Best-effort: a failing model lookup leaves the previous + * dependencies in place, and a per-variable failure drops only that variable + * from the check. */ - async attachConnectorErrors(project: Project): Promise { - const engine = this.engine; + private async refreshConnectors(engine: EngineApi): Promise { const modelName = this.modelName; - // hasNoEquations is the brand-new starter model: every variable is empty, so - // updateVariableErrors sets the flag and DELIBERATELY skips annotating - // `variable.errors` (blank-sketch suppression). Without this guard the - // per-variable errors skip below never triggers, and getIncomingLinks would - // report no deps for every (empty-equation) variable -- surfacing every - // connector drawn while sketching as stale, the wall of warnings the editor - // suppresses during initial layout (cf. module-warning.ts). - if (!engine || isStdlibModel(modelName) || project.hasNoEquations) { - return project; - } - const model = project.models.get(modelName); + const model = this.committed?.models.get(modelName); const view = model?.views[0]; - if (!model || !view || isMacroModel(model)) { - return project; + if (model === undefined || view === undefined || isStdlibModel(modelName) || isMacroModel(model)) { + return; } - - // Only aux/flow/stock with a primary node on this view are checkable targets. - const targetIdents: string[] = []; - const seen = new Set(); + const targets = new Set(); for (const el of view.elements) { - if (el.type !== 'aux' && el.type !== 'stock' && el.type !== 'flow') { - continue; + const ident = el.type === 'aux' || el.type === 'stock' || el.type === 'flow' ? canonicalize(el.name) : undefined; + if (ident !== undefined && model.variables.has(ident)) { + targets.add(ident); } - const variable = model.variables.get(el.ident); - if (!variable || variable.type === 'module' || seen.has(el.ident)) { - continue; - } - // Skip targets with fatal equation/compile errors: their AST did not parse, - // so the engine reports no dependencies and every inbound connector would be - // surfaced as stale (and every real dep as missing) -- bogus noise while the - // user is already seeing the real equation error. This relies on - // updateVariableErrors having annotated `errors` first, which every caller - // guarantees by running it before attachConnectorErrors on the same project. - // In a MIXED model a single not-yet-written variable carries an EmptyEquation - // error and is skipped here, so an inbound connector to it reads as a - // legitimate forward declaration rather than stale. This skip does NOT cover - // the ALL-empty starter model: there updateVariableErrors takes its - // hasNoEquations branch and skips annotating `errors` entirely, so `errors` - // is empty on every variable -- that case is handled by the hasNoEquations - // early return at the top of this method. Unit errors do NOT gate: the AST - // is fine there, so dependencies stay authoritative. - if (variable.errors && variable.errors.length > 0) { - continue; - } - seen.add(el.ident); - targetIdents.push(el.ident); - } - if (targetIdents.length === 0) { - return project; } - let engineModel: EngineModelApi; try { engineModel = await engine.getModel(modelName); } catch { - return project; - } - if (this.disposed) { - return project; + return; } - - // Fetch each target's equation dependencies. A per-variable failure (e.g. a - // transient rename mismatch) drops only that variable from the check. const dependencies = new Map(); - const fetched = await Promise.all( - targetIdents.map(async (ident): Promise => { - try { - return await engineModel.getIncomingLinks(ident); - } catch { - return undefined; - } - }), - ); - if (this.disposed) { - return project; - } - for (let i = 0; i < targetIdents.length; i++) { - const deps = fetched[i]; - if (deps) { - dependencies.set(targetIdents[i], deps); + for (const ident of targets) { + try { + dependencies.set(ident, await engineModel.getIncomingLinks(ident)); + } catch { + // dropped from the check } } - if (dependencies.size === 0) { - return project; + if (this.disposed) { + return; } + this.incomingLinks = new Map(this.incomingLinks).set(modelName, dependencies); + this.notify(); + } - const issuesByIdent = computeConnectorErrors({ - elements: view.elements, - variables: model.variables, - dependencies, - }); - if (issuesByIdent.size === 0) { - return project; + /** + * Run the main model and attach the series. Sparklines don't need + * Loops-That-Matter analysis, and LTM compilation can blow up WASM memory on + * dense causal graphs (World3: ~1.8M elementary circuits -> RuntimeError: + * unreachable). A plain run is requested first; on any failure it retries + * with LTM explicitly disabled so a future default flip cannot starve the UI + * of sparkline data. The first failure is reported. + */ + private async runSim(engine: EngineApi): Promise { + if (!(await engine.isSimulatable())) { + return; } - - const mutableVars = new Map(model.variables); - for (const [ident, issues] of issuesByIdent) { - const variable = mutableVars.get(ident); - if (variable) { - mutableVars.set(ident, { ...variable, connectorErrors: issues }); + const model = await engine.mainModel(); + let run: EngineRunApi; + try { + run = await model.run(); + } catch (e) { + this.reportError(e instanceof Error ? e : new Error(String(e))); + try { + run = await model.run({}, { analyzeLtm: false }); + } catch (e2) { + this.reportError(e2 instanceof Error ? e2 : new Error(String(e2))); + this.requestMaintenance('errors'); + return; } } - const updatedModel = { ...model, variables: mutableVars as ReadonlyMap }; - return { ...project, models: mapSet(project.models, modelName, updatedModel) }; + if (this.disposed) { + return; + } + const data = seriesReusingUnchanged(this.data, run); + if (data !== this.data) { + this.data = data; + this.seriesByIdent = groupSeriesByIdent(data, this.seriesByIdent); + this.notify(); + } + // A run can raise simulation errors (e.g. a runtime divide-by-zero). + this.requestMaintenance('errors'); } + // --- engine lifecycle + /** - * Re-annotate the ACTIVE model's variables with equation/unit errors AND - * connector drift after a model switch that did NOT rebuild the project. - * - * `updateVariableErrors` and `attachConnectorErrors` are model-scoped and are - * otherwise reached only from the rebuild/open paths (which run `projectFromJson` - * and reset every model's annotations). Drilling into a module (drillIntoModule) - * only flips `modelName` -- no rebuild -- so without this the newly-active child - * model's variables would show neither error dots nor connector warnings on - * first navigation, even though the error PANEL (refreshCachedErrors) re-scopes. - * This subsumes the bare refreshCachedErrors those paths used to call, since - * updateVariableErrors refreshes the panel cache too. - * - * Commit guard: the two engine round-trips (getErrors, getIncomingLinks) can - * race a rebuild or a further navigation that lands first; committing our stale - * result would clobber the fresher (or differently model-scoped) project. So we - * commit ONLY when neither `this.project` nor `this.modelName` moved while we - * were in flight. The panel cache was still refreshed above (it is model-scoped - * and self-correcting), so skipping the commit loses nothing. + * Latch the controller disposed and release the engine once the running item + * (if any) returns. Every other queued item settles as not landed now, + * including one the executor had not started yet. Best-effort: a throwing + * engine dispose must not crash the host. */ - async refreshActiveModelAnnotations(): Promise { - const project = this.project; - const modelName = this.modelName; - if (!this.engine || !project) { + async dispose(): Promise { + if (this.disposed) { return; } - let annotated = await this.updateVariableErrors(project); - annotated = await this.attachConnectorErrors(annotated); - if (this.disposed || this.project !== project || this.modelName !== modelName) { - return; + this.disposed = true; + this.listeners.clear(); + this.maintenance.clear(); + this.settleQueued(); + if (this.loop !== undefined) { + await this.loop; + } else { + await this.releaseEngine(); } - this.project = annotated; - this.notify(); + this.maybeResolveIdle(); } - // --- active-model navigation --- + /** Settle every queued item but the running one as not landed. */ + private settleQueued(): void { + const settled = this.queue.filter((item) => item !== this.runningItem); + this.queue = this.queue.filter((item) => item === this.runningItem); + for (const item of settled) { + item.settle(false); + } + } + + private async releaseEngine(): Promise { + const engine = this.engine; + this.engine = undefined; + if (engine !== undefined) { + await disposeQuietly(engine); + } + } + + // --- navigation /** * Drill into a module's child model. Pushes a stack entry capturing the - * current (parent) selection/viewport, switches the active model, and clears - * the rendered model's optimistic view to the child's. Returns the selection - * the Editor should adopt (empty) or undefined when the target model is not - * present (a guard against pushing a nonexistent model). Viewport restoration - * is not needed on drill-in (the child keeps its own stored view). - * - * @param currentSelection the Editor's live selection to capture for restore - * @param currentViewBox/currentZoom the active view's viewport to capture + * parent's selection/viewport and switches the active model. Returns the + * selection the Editor should adopt (empty), or undefined when the target + * model is not present. */ drillIntoModule( moduleIdent: string, @@ -1508,8 +1970,7 @@ export class ProjectController { currentViewBox: Rect, currentZoom: number, ): NavigationOutcome { - const project = this.project; - if (!project || !project.models.has(targetModelName)) { + if (!this.committed?.models.has(targetModelName)) { return { restoredSelection: undefined }; } const newStack = pushModule( @@ -1520,27 +1981,16 @@ export class ProjectController { currentViewBox, currentZoom, ); - const newModelName = currentModelName(newStack); this.batch(() => { this.modelStack = newStack; - this.modelName = newModelName; + this.modelName = currentModelName(newStack); this.notify(); }); - // Drill-in does NOT rebuild the project (only modelName flips), so annotate - // the newly-active child model's variables directly -- error dots AND - // connector warnings, not just the error panel. Fire-and-forget: the - // snapshot updates when it resolves. (refreshActiveModelAnnotations refreshes - // the panel cache too, subsuming the old bare refreshCachedErrors call.) - void this.refreshActiveModelAnnotations(); + this.requestMaintenance('connectors'); return { restoredSelection: new Set() }; } - /** - * Navigate back one level. Restores the parent's selection (returned to the - * Editor) and viewport (applied internally via queueViewUpdate, which now - * resolves getView() to the just-restored model because modelName is updated - * synchronously first). Returns undefined selection when the stack is empty. - */ + /** Navigate back one level, restoring the parent's selection and viewport. */ navigateBack(): NavigationOutcome { if (this.modelStack.length === 0) { return { restoredSelection: undefined }; @@ -1548,10 +1998,7 @@ export class ProjectController { return this.applyNavigation(popModule(this.modelStack)); } - /** - * Navigate to a breadcrumb level. Same restoration contract as navigateBack. - * Returns undefined selection when targetLevel is out of range. - */ + /** Navigate to a breadcrumb level. Same restoration contract as navigateBack. */ navigateToLevel(targetLevel: number): NavigationOutcome { if (targetLevel >= this.modelStack.length) { return { restoredSelection: undefined }; @@ -1569,63 +2016,69 @@ export class ProjectController { this.batch(() => { this.modelStack = result.newStack; this.modelName = result.restoredModelName; + // Navigation need not wait for the queue: the viewport restore renders now + // and persists through a viewport item for the restored model. + if (this.committed?.models.get(result.restoredModelName)?.views[0] !== undefined) { + this.setViewport(result.restoredModelName, { viewBox: result.restoredViewBox, zoom: result.restoredZoom }); + } this.notify(); }); - // Restore the parent model's viewport. modelName was updated synchronously - // above, so getView() (via this.project) resolves to the restored model -- - // no setState-callback deferral is needed. Fire-and-forget round-trip. - const view = this.getView(); - if (view) { - // queueViewUpdate round-trips through updateProject, which re-annotates the - // restored active model's variables (error dots + connector warnings) as a - // side effect -- so back-navigation is covered without a separate pass. - void this.queueViewUpdate({ ...view, viewBox: result.restoredViewBox, zoom: result.restoredZoom }); - } else { - // No stored view means no rebuild will run, so annotate the restored - // model's variables directly (mirrors drillIntoModule). - void this.refreshActiveModelAnnotations(); - } - // Refresh the model-scoped error PANEL promptly. The annotation paths above - // also refresh it (via updateVariableErrors), but this keeps the panel snappy - // and covers a queueViewUpdate that bails on a non-finite restored viewport. - void this.refreshCachedErrors(); + this.requestMaintenance('connectors'); return { restoredSelection: result.restoredSelection }; } - // --- read accessors used by the Editor's op builders --- - - getEngine(): EngineApi | undefined { - return this.engine; - } + // --- read accessors getProject(): Project | undefined { - return this.project; + return this.snapshot.project; } getModel(): Model | undefined { - const project = this.project; - if (!project) { - return undefined; - } - return project.models.get(this.modelName); + return this.snapshot.project?.models.get(this.modelName); } getView(): StockFlowView | undefined { - const model = this.getModel(); - if (!model) { - return undefined; - } - return model.views[0]; + return this.getModel()?.views[0]; } getModelName(): string { return this.modelName; } - // --- error surfacing --- + private getRenderedView(modelName: string): StockFlowView | undefined { + return this.snapshot.project?.models.get(modelName)?.views[0]; + } + + /** + * The idents a new or renamed element may not take in `modelName`: the + * rendered model's variables (the committed variables, with each pending + * rename applied, so a name a pending rename frees is free) plus every name + * on the rendered view, which carries each pending create and rename. + */ + usedIdents(modelName: string = this.modelName): ReadonlySet { + const model = this.snapshot.project?.models.get(modelName); + const used = new Set(model?.variables.keys() ?? []); + for (const el of model?.views[0]?.elements ?? []) { + if (isNamedViewElement(el)) { + used.add(canonicalize(el.name)); + } + } + return used; + } + + /** A default name for a new element of `modelName` that no variable or pending create uses. */ + newVariableName(base: string, modelName: string = this.modelName): string { + return allocateVariableName(base, this.usedIdents(modelName)); + } - /** Forward a transient error to the host's toast UI (never presentation - * state the controller owns). Accepts a message string or an Error. */ + /** The error to show when `newName` cannot name an element (see nameCollisionError). */ + nameError(newName: string, currentIdent: string | undefined, modelName: string = this.modelName): string | undefined { + return nameCollisionError(newName, currentIdent, this.usedIdents(modelName)); + } + + // --- error surfacing + + /** Forward a transient error to the host's toast UI. */ private reportError(err: string | Error): void { if (this.disposed) { return; @@ -1633,3 +2086,11 @@ export class ProjectController { this.config.onError(err instanceof Error ? err : new Error(err)); } } + +async function disposeQuietly(engine: EngineApi): Promise { + try { + await engine.dispose(); + } catch { + // ignored: the engine is being abandoned regardless + } +} diff --git a/src/diagram/rename-ops.ts b/src/diagram/rename-ops.ts index 0489e1c76..a085b8eaf 100644 --- a/src/diagram/rename-ops.ts +++ b/src/diagram/rename-ops.ts @@ -2,61 +2,44 @@ // Use of this source code is governed by the Apache License, // Version 2.0, that can be found in the LICENSE file. -// pattern: Functional Core -- pure construction of the variable-rename patch ops +// pattern: Functional Core -- the next view of a variable rename import { canonicalize } from '@simlin/core/canonicalize'; -import { isNamedViewElement, type StockFlowView, stockFlowViewToJson, type ViewElement } from '@simlin/core/datamodel'; -import type { JsonModelOperation } from '@simlin/engine'; +import { isNamedViewElement, type StockFlowView, type ViewElement } from '@simlin/core/datamodel'; import { encodeNameNewlines } from './drawing/common'; -export interface RenameOps { - readonly updatedView: StockFlowView; - readonly ops: readonly JsonModelOperation[]; -} - /** - * Builds the patch ops for renaming a variable: a renameVariable op plus the - * upsertView that keeps the sketch label in sync. + * The view a rename produces: every named element whose name is `oldName` + * (compared canonically) relabeled `newName`. The engine's RenameVariable + * renames equations, module references and group members but never view + * elements, so a rename is an edit WITH a next view, and buildEditOps derives + * the renameVariable op from the relabeled element (`from` its committed ident, + * `to` its new name). * - * The rename `to` is the user's typed name RAW (newlines encoded to the stored - * backslash-n form, but NOT canonicalized): the engine stores display - * spellings verbatim and does all matching canonically (issue #890), so - * sending `canonicalize(newName)` would downgrade the stored display name -- - * and a case-only rename ("students" -> "Students") would actively restamp a - * preserved spelling with its canonical form (issue #906). `from` stays - * canonical, matching how idents are keyed and compared everywhere else in - * the TS layer. + * The new name is the user's typed name RAW, with line breaks encoded to the + * stored backslash-n form (a raw newline would canonicalize into a malformed + * ident) but NOT canonicalized: the engine stores display spellings verbatim + * and does all matching canonically (issue #890), so canonicalizing would + * downgrade the stored display name, and a case-only rename ("students" -> + * "Students") would restamp a preserved spelling (issue #906). * - * The view element keeps its stale `ident` here on purpose (only `name` is - * updated), preserving the pre-extraction behavior: the engine round-trip - * that follows the patch rebuilds every element with a fresh ident. + * The element takes the new name's ident too. Elements are matched by name, + * not by `ident`, so a second rename while the first is pending (or a rename of + * a pending create) finds the element under the name it renders with. While the + * rename is pending, the controller's rendered model names the committed + * variable by the new ident, so the canvas and the details panel still resolve + * the element to it. */ -export function buildVariableRenameOps(view: StockFlowView, oldName: string, newName: string): RenameOps { - const oldIdent = canonicalize(oldName); - // Encode ALL line breaks to the stored backslash-n form -- a raw newline in - // a multi-line name would canonicalize into a malformed ident. +export function relabelVariable(view: StockFlowView, oldName: string, newName: string): StockFlowView { + const oldIdent = canonicalize(encodeNameNewlines(oldName)); const encodedName = encodeNameNewlines(newName); - + const newIdent = canonicalize(encodedName); const elements = view.elements.map((element: ViewElement) => { - if (!isNamedViewElement(element) || element.ident !== oldIdent) { + if (!isNamedViewElement(element) || canonicalize(element.name) !== oldIdent) { return element; } - return { ...element, name: encodedName }; + return { ...element, name: encodedName, ident: newIdent }; }); - - const updatedView: StockFlowView = { ...view, elements }; - - const ops: readonly JsonModelOperation[] = [ - { - type: 'renameVariable', - payload: { from: oldIdent, to: encodedName }, - }, - { - type: 'upsertView', - payload: { index: 0, view: stockFlowViewToJson(updatedView) }, - }, - ]; - - return { updatedView, ops }; + return { ...view, elements }; } diff --git a/src/diagram/render-common.tsx b/src/diagram/render-common.tsx index 02bc4cf6a..21b399fb9 100644 --- a/src/diagram/render-common.tsx +++ b/src/diagram/render-common.tsx @@ -11,8 +11,8 @@ import { renderToString } from 'react-dom/server'; import { UID, ViewElement, Project } from '@simlin/core/datamodel'; import { at, getOrThrow } from '@simlin/core/collections'; -import { Canvas } from './drawing/Canvas'; -import { Box, Point } from './drawing/common'; +import { Canvas, type GestureCommit } from './drawing/Canvas'; +import { Box } from './drawing/common'; import { renderStyles } from './drawing/render-styles'; /** @@ -58,10 +58,7 @@ export function renderSvgToString(project: Project, modelName: string): [string, const renameVariable = (_oldName: string, _newName: string): void => {}; const onSelection = (_selected: ReadonlySet): void => {}; - const moveSelection = (_position: Point): void => {}; - const moveFlow = (_element: ViewElement, _target: number, _position: Point): void => {}; - const moveLabel = (_uid: UID, _side: 'top' | 'left' | 'bottom' | 'right'): void => {}; - const attachLink = (_element: ViewElement, _to: string): void => {}; + const commitGesture = (_commit: GestureCommit): void => {}; const createCb = (_element: ViewElement): void => {}; const nullCb = (): void => {}; @@ -71,15 +68,11 @@ export function renderSvgToString(project: Project, modelName: string): [string, project={project} model={model} view={at(model.views, 0)} - version={1} selectedTool={undefined} selection={new Set()} onRenameVariable={renameVariable} onSetSelection={onSelection} - onMoveSelection={moveSelection} - onMoveFlow={moveFlow} - onMoveLabel={moveLabel} - onAttachLink={attachLink} + onCommitGesture={commitGesture} onCreateVariable={createCb} onClearSelectedTool={nullCb} onDeleteSelection={nullCb} diff --git a/src/diagram/rstest.config.mts b/src/diagram/rstest.config.mts index 2a69d9549..ab0eb872b 100644 --- a/src/diagram/rstest.config.mts +++ b/src/diagram/rstest.config.mts @@ -19,7 +19,6 @@ const NODE_ENV_TESTS = [ 'tests/hosted-web-editor-delete.test.ts', 'tests/hosted-web-editor-load-errors.test.ts', 'tests/hosted-web-editor-save.test.ts', - 'tests/merge-live-view.test.ts', 'tests/module-creation.test.ts', 'tests/module-details-utils.test.ts', 'tests/module-navigation.test.ts', diff --git a/src/diagram/selection-logic.ts b/src/diagram/selection-logic.ts deleted file mode 100644 index e7b8c8443..000000000 --- a/src/diagram/selection-logic.ts +++ /dev/null @@ -1,113 +0,0 @@ -// Copyright 2026 The Simlin Authors. All rights reserved. -// Use of this source code is governed by the Apache License, -// Version 2.0, that can be found in the LICENSE file. - -import { setAdd, setDelete } from '@simlin/core/common'; - -import { UID } from '@simlin/core/datamodel'; - -import type { InteractionState } from './drawing/canvas-interaction'; - -export interface MouseDownSelectionResult { - newSelection: ReadonlySet | undefined; - deferSingleSelect: UID | undefined; -} - -/** - * Determines selection state on mouseDown. This implements the standard - * selection pattern used by Figma, Illustrator, etc: - * - * - Modifier key: toggle element in/out of selection immediately - * - No modifier, element already in selection: defer -- don't change - * selection yet, so group drag can proceed without dissolving - * - No modifier, element NOT in selection: replace selection - */ -export function computeMouseDownSelection( - currentSelection: ReadonlySet, - clickedUid: UID, - isMultiSelect: boolean, -): MouseDownSelectionResult { - if (isMultiSelect) { - if (currentSelection.has(clickedUid)) { - return { newSelection: setDelete(currentSelection, clickedUid), deferSingleSelect: undefined }; - } else { - return { newSelection: setAdd(currentSelection, clickedUid), deferSingleSelect: undefined }; - } - } - - // No modifier key - if (currentSelection.has(clickedUid)) { - // Element is already selected -- defer to mouseUp so group drag works - return { newSelection: undefined, deferSingleSelect: clickedUid }; - } - - // Element not in current selection -- select it immediately - return { newSelection: new Set([clickedUid]), deferSingleSelect: undefined }; -} - -/** - * When cloud re-attachment is activated (clicking a cloud triggers flow - * source/sink drag mode), the selection must contain the flow UID -- not - * the cloud UID. Downstream mouseUp handlers read `only(selection)` and - * expect a FlowViewElement for attachment handling. - */ -export function resolveSelectionForReattachment( - newSelection: ReadonlySet, - enteredReattachmentMode: boolean, - reattachFlowUid: UID, -): ReadonlySet { - if (enteredReattachmentMode) { - return new Set([reattachFlowUid]); - } - return newSelection; -} - -/** - * State fields that must be cleared when pointer interactions end. Used by - * clearPointerState and the deferred-click early-return for non-named - * elements (clouds) to ensure no stale pointer state leaks into subsequent - * renders or interactions. - * - * Post tagged-union migration (#65): the eight former boolean modes plus the - * loose labelSide / flowStillBeingCreated / draggingSegmentIndex fields all - * collapse into a single `interaction: idle`. (Notably, `editingName.creatingFlow` - * -- formerly `flowStillBeingCreated` -- is reset here so that once name editing - * ends, a later Escape-cancel of an unrelated rename can't see a stale `true` - * and delete that variable via the cancel-of-newly-created-flow path.) Only the - * continuous companions that travel alongside the discrete mode are also - * cleared; the Slate `editingName` value and `movingCanvasOffset` are - * intentionally left untouched, matching the pre-migration reset. - */ -export interface PointerStateReset { - interaction: InteractionState; - moveDelta: undefined; - dragSelectionPoint: undefined; - inCreation: undefined; - inCreationCloud: undefined; -} - -export function pointerStateReset(): PointerStateReset { - return { - interaction: { mode: 'idle' }, - moveDelta: undefined, - dragSelectionPoint: undefined, - inCreation: undefined, - inCreationCloud: undefined, - }; -} - -/** - * Resolves deferred selection on mouseUp. - * - * If a deferred UID was set on mouseDown and no drag occurred, - * collapse the selection to just that element. - */ -export function computeMouseUpSelection(deferredUid: UID | undefined, didDrag: boolean): ReadonlySet | undefined { - if (deferredUid === undefined) { - return undefined; - } - if (didDrag) { - return undefined; - } - return new Set([deferredUid]); -} diff --git a/src/diagram/tests/build-selection-map.test.ts b/src/diagram/tests/build-selection-map.test.ts deleted file mode 100644 index 4e36cf0f0..000000000 --- a/src/diagram/tests/build-selection-map.test.ts +++ /dev/null @@ -1,82 +0,0 @@ -// Copyright 2026 The Simlin Authors. All rights reserved. -// Use of this source code is governed by the Apache License, -// Version 2.0, that can be found in the LICENSE file. - -import { describe, it, expect } from '@rstest/core'; - -import { buildSelectionMap, inCreationUid } from '../drawing/Canvas'; -import { AuxViewElement, ViewElement } from '@simlin/core/datamodel'; -import { UID } from '@simlin/core/common'; -import { CanvasProps } from '../drawing/Canvas'; - -const makeAux = (uid: number, name: string): AuxViewElement => ({ - type: 'aux', - uid, - var: undefined, - x: 100, - y: 100, - name, - ident: name.toLowerCase().replace(/ /g, '_'), - labelSide: 'right', - isZeroRadius: false, -}); - -describe('buildSelectionMap', () => { - const aux1 = makeAux(1, 'a'); - const aux2 = makeAux(2, 'b'); - const elements: ReadonlyMap = new Map([ - [aux1.uid, aux1], - [aux2.uid, aux2], - ]); - - it('maps selected UIDs to their elements', () => { - const props = { selection: new Set([1, 2]) } as CanvasProps; - const result = buildSelectionMap(props, elements); - expect(result.size).toBe(2); - expect(result.get(1)).toBe(aux1); - expect(result.get(2)).toBe(aux2); - }); - - it('uses inCreation element when selection contains inCreationUid', () => { - const inCreation = makeAux(inCreationUid, 'New Variable'); - const props = { selection: new Set([inCreationUid]) } as CanvasProps; - const result = buildSelectionMap(props, elements, inCreation); - expect(result.size).toBe(1); - expect(result.get(inCreationUid)).toBe(inCreation); - }); - - it('skips inCreationUid when inCreation is undefined (async race)', () => { - // This reproduces the crash: selection contains inCreationUid (-2) - // but inCreation has been cleared (undefined) because Canvas.setState - // runs synchronously while Editor.handleFlowAttach is still awaiting. - const props = { selection: new Set([inCreationUid]) } as CanvasProps; - const result = buildSelectionMap(props, elements, undefined); - expect(result.size).toBe(0); - }); - - it('skips inCreationUid but keeps other selected elements', () => { - const props = { selection: new Set([1, inCreationUid]) } as CanvasProps; - const result = buildSelectionMap(props, elements, undefined); - expect(result.size).toBe(1); - expect(result.get(1)).toBe(aux1); - }); - - it('skips a selected UID that is no longer present in elements (async race after delete)', () => { - // Reproduces the white-screen crash: when a selected connector is deleted, - // Editor updates the view (removing the connector) before clearing the - // selection, so there is a render where props.selection still references - // the now-missing element. buildSelectionMap must not throw on it. - const props = { selection: new Set([1, 13, 2]) } as CanvasProps; - const result = buildSelectionMap(props, elements, undefined); - expect(result.size).toBe(2); - expect(result.get(1)).toBe(aux1); - expect(result.get(2)).toBe(aux2); - expect(result.has(13)).toBe(false); - }); - - it('returns an empty map when every selected UID is missing from elements', () => { - const props = { selection: new Set([13, 14]) } as CanvasProps; - const result = buildSelectionMap(props, elements, undefined); - expect(result.size).toBe(0); - }); -}); diff --git a/src/diagram/tests/canvas-gesture-harness.tsx b/src/diagram/tests/canvas-gesture-harness.tsx index 59b775e09..fb23ef6eb 100644 --- a/src/diagram/tests/canvas-gesture-harness.tsx +++ b/src/diagram/tests/canvas-gesture-harness.tsx @@ -43,6 +43,7 @@ import { FlowViewElement, LinkViewElement, Model, + modelFromJson, ModuleViewElement, Point as DataPoint, Project, @@ -54,8 +55,9 @@ import { ViewElement, } from '@simlin/core/datamodel'; import { canonicalize } from '@simlin/core/canonicalize'; +import type { JsonModel } from '@simlin/engine'; -import { Canvas, CanvasProps } from '../drawing/Canvas'; +import { Canvas, CanvasProps, type GestureCommit } from '../drawing/Canvas'; import type { Point } from '../drawing/common'; // --------------------------------------------------------------------------- @@ -374,10 +376,27 @@ function makeView(elements: readonly ViewElement[], zoom = 1): StockFlowView { }; } +/** + * A variable for every named element, derived through the production + * `modelFromJson`: the editor's model always has one, and the gesture planner + * refuses a drop onto a stock whose variable does not exist. + */ +function variablesOf(elements: readonly ViewElement[]): ReadonlyMap { + const names = (type: string): string[] => + elements.filter((el) => el.type === type).map((el) => (el as { name: string }).name); + return modelFromJson({ + name: 'main', + stocks: names('stock').map((name) => ({ name, initialEquation: '1', inflows: [], outflows: [] })), + flows: names('flow').map((name) => ({ name, equation: '1' })), + auxiliaries: names('aux').map((name) => ({ name, equation: '1' })), + modules: names('module').map((name) => ({ name, modelName: 'sub' })), + } as JsonModel).variables; +} + function makeModel(view: StockFlowView, variables?: ReadonlyMap): Model { return { name: 'main', - variables: variables ?? new Map(), + variables: variables ?? variablesOf(view.elements), views: [view], loopMetadata: [], groups: [], @@ -407,10 +426,7 @@ function makeProject(model: Model): Project { export interface CanvasCallbacks { onRenameVariable: Mock; onSetSelection: Mock; - onMoveSelection: Mock; - onMoveFlow: Mock; - onMoveLabel: Mock; - onAttachLink: Mock; + onCommitGesture: Mock; onCreateVariable: Mock; onClearSelectedTool: Mock; onDeleteSelection: Mock; @@ -423,10 +439,7 @@ function makeCallbacks(): CanvasCallbacks { return { onRenameVariable: rs.fn(), onSetSelection: rs.fn(), - onMoveSelection: rs.fn(), - onMoveFlow: rs.fn(), - onMoveLabel: rs.fn(), - onAttachLink: rs.fn(), + onCommitGesture: rs.fn(), onCreateVariable: rs.fn(), onClearSelectedTool: rs.fn(), onDeleteSelection: rs.fn(), @@ -462,23 +475,47 @@ export interface HarnessOptions { mountSize?: { width: number; height: number }; /** * When true (the default), `onSetSelection` commits the new selection back - * into `props.selection` and re-renders -- modeling the real host (Editor), - * which sets its selection state in the same React event so the resulting - * re-render sees both the new selection prop AND the new internal interaction - * state. Several Canvas render paths (e.g. `isValidTarget` doing - * `only(props.selection)` during an arrowhead drag) assume that batching and - * would throw if selection lagged behind. Set false only to test a host that - * deliberately ignores a selection request. + * into `props.selection` and re-renders, modeling the real host (Editor), + * which sets its selection state in the same React event. A selection change + * is UI state: it republishes nothing, so the view and every other prop stay + * as they were. Set false only to test a host that ignores a selection request. */ autoCommitSelection?: boolean; + /** + * When true (the default), `onCommitGesture` applies the commit the way the + * controller's synchronous publish does: the committed elements become + * `props.view` and the commit's selection `props.selection`. Set false to test + * a host that refuses or loses the edit. + */ + autoCommitEdits?: boolean; + /** The controller state token the Canvas reads (default 0); `setProps({ token })` moves it. */ + token?: number; + /** Forwarded to the Canvas prop of the same name (default: unset). */ + pressesDisabled?: boolean; + /** Forwarded to the Canvas prop of the same name (default: unset, the Canvas allocates). */ + newVariableName?: (base: string) => string; } export interface CanvasHarness { readonly callbacks: CanvasCallbacks; readonly container: HTMLElement; readonly svg: SVGSVGElement; - /** Re-render with updated props (e.g. after the host commits a selection). */ - setProps: (next: Partial>) => void; + /** + * Re-render with updated props (e.g. after the host commits a selection). A new + * `view` re-derives the model's variables from its elements; a `model` passed + * with it replaces them (a republish carrying sim series or error annotations). + */ + setProps: ( + next: Partial< + Pick + >, + ) => void; + /** The view the Canvas currently renders from (after any applied commits). */ + view: () => StockFlowView; + /** The model the Canvas currently renders from. */ + model: () => Model; + /** The selection the Canvas currently holds (after any applied selection requests). */ + selection: () => ReadonlySet; rerender: RenderResult['rerender']; unmount: RenderResult['unmount']; /** Find a rendered element node by the CSS class the element component emits. */ @@ -524,18 +561,23 @@ export function renderCanvas(opts: HarnessOptions): CanvasHarness { let selection: ReadonlySet = opts.selection ?? new Set(); let selectedTool: CanvasProps['selectedTool'] = opts.selectedTool; let currentView = view; - let version = 1; + let currentModel = model; + let token = opts.token ?? 0; + let readOnly = opts.readOnly; + let pressesDisabled = opts.pressesDisabled; const buildProps = (): CanvasProps => ({ embedded: opts.embedded ?? false, - readOnly: opts.readOnly, + readOnly, recenterOffscreenOnMount: opts.recenterOffscreenOnMount, project, - model, + model: currentModel, view: currentView, - version, + token, selectedTool, selection, + pressesDisabled, + newVariableName: opts.newVariableName, ...callbacks, }); @@ -547,7 +589,17 @@ export function renderCanvas(opts: HarnessOptions): CanvasHarness { // already inside the event's act()), so the resulting render sees both. callbacks.onSetSelection.mockImplementation((next: ReadonlySet) => { selection = next; - version += 1; + result.rerender(); + }); + } + if (opts.autoCommitEdits ?? true) { + callbacks.onCommitGesture.mockImplementation((commit: GestureCommit) => { + if (commit.baseView !== currentView) { + throw new Error('harness: a commit planned on a view other than the rendered one'); + } + currentView = { ...currentView, elements: commit.elements, nextUid: commit.nextUid }; + currentModel = { ...currentModel, views: [currentView], variables: variablesOf(commit.elements) }; + selection = commit.selection; result.rerender(); }); } @@ -569,7 +621,14 @@ export function renderCanvas(opts: HarnessOptions): CanvasHarness { const svg = result.container.querySelector('svg') as SVGSVGElement; - const setProps = (next: Partial>): void => { + const setProps = ( + next: Partial< + Pick + >, + ): void => { + if ('pressesDisabled' in next) { + pressesDisabled = next.pressesDisabled; + } if ('selection' in next && next.selection !== undefined) { selection = next.selection; } @@ -578,8 +637,17 @@ export function renderCanvas(opts: HarnessOptions): CanvasHarness { } if ('view' in next && next.view !== undefined) { currentView = next.view; + currentModel = { ...currentModel, views: [currentView], variables: variablesOf(currentView.elements) }; + } + if ('model' in next && next.model !== undefined) { + currentModel = { ...next.model, views: [currentView] }; + } + if ('token' in next && next.token !== undefined) { + token = next.token; + } + if ('readOnly' in next) { + readOnly = next.readOnly; } - version += 1; act(() => { result.rerender(); }); @@ -590,6 +658,9 @@ export function renderCanvas(opts: HarnessOptions): CanvasHarness { container: result.container, svg, setProps, + view: () => currentView, + model: () => currentModel, + selection: () => selection, rerender: result.rerender, unmount: result.unmount, query: (selector: string) => result.container.querySelector(selector), diff --git a/src/diagram/tests/canvas-gestures-elements.test.tsx b/src/diagram/tests/canvas-gestures-elements.test.tsx index 0632a5144..0ed7ae1ec 100644 --- a/src/diagram/tests/canvas-gestures-elements.test.tsx +++ b/src/diagram/tests/canvas-gestures-elements.test.tsx @@ -2,19 +2,26 @@ // Use of this source code is governed by the Apache License, // Version 2.0, that can be found in the LICENSE file. -// Reconciler-level gesture tests for element-edge interactions of the React -// `Canvas` (Piece 1a; see -// docs/design-plans/2026-06-07-canvas-interaction-migration.md): flow-segment -// drag, label drag, link/flow endpoint drag, creation tools, and name editing. -// Assertions are on prop-callback payloads and rendered DOM only. +// Reconciler-level gesture tests for element interactions of the React +// `Canvas`: flow pipe and valve drags, label drags, link and flow endpoint +// drags, creation tools, and name editing, driven with real pointer events. The +// harness applies each commit the way the controller publishes it, so a test +// sees what renders after the release. Assertions are on prop-callback payloads +// and rendered DOM only. +// +// What this establishes: the Canvas hit-tests presses into the planner, +// renders its plan while dragging, and commits exactly one GestureCommit (or +// nothing) on release. The geometry of each plan is gesture-planner*.test.ts; +// preview == commit frame by frame is canvas-gestures-preview-commit.test.tsx. import { describe, it, expect, rs } from '@rstest/core'; import type { Mock } from '@rstest/core'; import { fireEvent, act } from '@testing-library/react'; -import type { StockFlowView } from '@simlin/core/datamodel'; +import type { FlowViewElement, LinkViewElement, ViewElement } from '@simlin/core/datamodel'; +import type { GestureCommit } from '../drawing/Canvas'; import { makeAux, makeCloud, @@ -35,15 +42,21 @@ function lastSelection(fn: Mock): number[] { return last ? [...(last[0] as Set)].sort((a, b) => a - b) : []; } -// Parse the rendered flow path's polyline points from its `d` attribute -// (e.g. "M100,200L180,200" -> [[100,200],[180,200]]). The inner flow path is -// drawn straight from the flow element's points, so this reflects the live -// geometry the user sees. NOTE: the line's final point is pulled back by a fixed -// glyph inset (finalAdjust) to leave room for the arrowhead, so use this for -// growth/orientation -- not for the exact endpoint (use arrowheadPoint for that). +function onlyCommit(h: CanvasHarness): GestureCommit { + expect(h.callbacks.onCommitGesture).toHaveBeenCalledTimes(1); + return h.callbacks.onCommitGesture.mock.calls[0][0] as GestureCommit; +} + +function committed(commit: GestureCommit, uid: number): T { + const el = commit.elements.find((e) => e.uid === uid); + expect(el).toBeDefined(); + return el as T; +} + +// The inner flow path's polyline points from its `d` attribute. The final point +// is pulled back by the arrowhead inset, so use it for growth and orientation. function flowPoints(h: CanvasHarness): Array<[number, number]> { - const inner = h.query('.simlin-flow .simlin-inner'); - const d = inner?.getAttribute('d') ?? ''; + const d = h.query('.simlin-flow .simlin-inner')?.getAttribute('d') ?? ''; const nums = (d.match(/-?\d+(?:\.\d+)?/g) ?? []).map(Number); const pts: Array<[number, number]> = []; for (let i = 0; i + 1 < nums.length; i += 2) { @@ -52,15 +65,6 @@ function flowPoints(h: CanvasHarness): Array<[number, number]> { return pts; } -// The flow arrowhead is drawn at the TRUE sink endpoint -- where the arrow -// actually points -- via transform="rotate(angle, x, y)". Extract (x, y). -function arrowheadPoint(h: CanvasHarness): [number, number] { - const head = h.query('.simlin-arrowhead-bg'); - const t = head?.getAttribute('transform') ?? ''; - const m = t.match(/rotate\([^,]+,\s*(-?\d+(?:\.\d+)?),\s*(-?\d+(?:\.\d+)?)\)/); - return m ? [Number(m[1]), Number(m[2])] : [NaN, NaN]; -} - // Clouds render via transform="matrix(sx,0,0,sy, x-radius, y-radius)"; recover // each cloud's center as (translateX + radius, translateY + radius). function cloudCenters(h: CanvasHarness): Array<[number, number]> { @@ -71,7 +75,10 @@ function cloudCenters(h: CanvasHarness): Array<[number, number]> { }); } -// A horizontal flow stock -> cloud, with the stock wired to the flow. +const hasCloudAt = (h: CanvasHarness, x: number, y: number): boolean => + cloudCenters(h).some(([cx, cy]) => Math.abs(cx - x) < 0.5 && Math.abs(cy - y) < 0.5); + +// Stock (100,100) -> cloud (300,100), the source pinned to the stock's right face. function stockToCloudFlow(): CanvasHarness { const stock = makeStock(1, 'stock', 100, 100); const cloud = makeCloud(2, 3, 300, 100); @@ -79,73 +86,97 @@ function stockToCloudFlow(): CanvasHarness { 3, 'flow', [ - { x: 100, y: 100, attachedToUid: 1 }, + { x: 122.5, y: 100, attachedToUid: 1 }, { x: 300, y: 100, attachedToUid: 2 }, ], { x: 200, y: 100 }, ); - const stockWithFlow = { ...stock, outflows: [3] }; - return renderCanvas({ elements: [stockWithFlow, cloud, flow] }); + return renderCanvas({ elements: [stock, cloud, flow] }); } -describe('Canvas gestures: flow segment drag (checklist 8)', () => { - it('dragging an interior flow segment plumbs the segmentIndex through onMoveSelection', () => { - // L-shaped flow: stock(100,100) -> (300,100) -> (300,300) -> cloud(500,300). - // The interior vertical segment is index 1. +describe('Canvas gestures: flow pipe and valve drags', () => { + it('a perpendicular drag on a sole flow`s pipe offsets the pressed segment', () => { + // stock(100,100) -> (300,100) -> (300,300) -> cloud(500,300); segment 1 is vertical. const stock = makeStock(1, 'stock', 100, 100); const cloud = makeCloud(2, 3, 500, 300); const flow = makeFlow( 3, 'flow', [ - { x: 100, y: 100, attachedToUid: 1 }, + { x: 122.5, y: 100, attachedToUid: 1 }, { x: 300, y: 100, attachedToUid: undefined }, { x: 300, y: 300, attachedToUid: undefined }, { x: 500, y: 300, attachedToUid: 2 }, ], { x: 300, y: 200 }, ); - const stockWithFlow = { ...stock, outflows: [3] }; - const h = renderCanvas({ elements: [stockWithFlow, cloud, flow] }); + const h = renderCanvas({ elements: [stock, cloud, flow] }); h.clearMountCalls(); - const outer = h.query('.simlin-outer')!; - // Press the vertical interior segment at (300,260) -- away from the valve - // (300,200) so findClickedSegment returns the segment, not the valve. - pointerDown(outer, 300, 260); + pointerDown(h.query('.simlin-outer')!, 300, 260); expect(lastSelection(h.callbacks.onSetSelection)).toEqual([3]); + pointerMove(h.svg, 340, 262, { buttons: 1 }); + pointerUp(h.svg, 340, 262); - pointerMove(h.svg, 340, 260, { buttons: 1 }); - pointerUp(h.svg, 340, 260); + const commit = onlyCommit(h); + expect(commit.label).toBe('pipe move'); + const f = committed(commit, 3); + expect(f.points.map((p) => [p.x, p.y])).toEqual([ + [122.5, 100], + [340, 100], + [340, 300], + [500, 300], + ]); + }); + + it('an along-axis drag on the valve slides it', () => { + const h = stockToCloudFlow(); + h.clearMountCalls(); + + pointerDown(h.query('.simlin-flow circle')!, 200, 100); + pointerMove(h.svg, 240, 101, { buttons: 1 }); + pointerUp(h.svg, 240, 101); - expect(h.callbacks.onMoveSelection).toHaveBeenCalledTimes(1); - const [delta, , segmentIndex] = h.callbacks.onMoveSelection.mock.calls[0]; - expect(delta).toEqual({ x: -40, y: 0 }); - expect(segmentIndex).toBe(1); + const f = committed(onlyCommit(h), 3); + expect([f.x, f.y]).toEqual([240, 100]); + expect(f.points.map((p) => [p.x, p.y])).toEqual([ + [122.5, 100], + [300, 100], + ]); }); }); -describe('Canvas gestures: label drag (checklist 9)', () => { - // labelSideForPointer maps the pointer position relative to the element center - // to a quadrant. Each direction is dragged from the label text node. +describe('Canvas gestures: label drag', () => { + // The quadrant rule itself is tabled in gesture-planner-classify.test.ts; each + // direction here is dragged from the label text node. The aux's label starts + // on the right, so the right-hand drag commits nothing. it.each([ ['left', 60, 100, 'left'], - ['right', 140, 100, 'right'], ['top', 100, 60, 'top'], ['bottom', 100, 140, 'bottom'], - ] as const)('dragging the label toward the %s fires onMoveLabel with that side', (_name, toX, toY, side) => { + ] as const)('dragging the label toward the %s commits that side', (_name, toX, toY, side) => { const h = renderCanvas({ elements: [makeAux(10, 'foo', 100, 100)] }); h.clearMountCalls(); const text = h.query('.simlin-aux text')!; pointerDown(text, 130, 100); pointerMove(text, toX, toY); - // The label-drag selects the element (handleLabelDrag), which the host - // commits so the pointer-up's only(selection) resolves. expect(lastSelection(h.callbacks.onSetSelection)).toEqual([10]); - pointerUp(h.svg, toX, toY); - expect(h.callbacks.onMoveLabel).toHaveBeenCalledWith(10, side); + + const commit = onlyCommit(h); + expect(commit.label).toBe('label move'); + expect(committed(commit, 10)).toMatchObject({ labelSide: side }); + }); + + it('dragging the label to the side it already has commits nothing', () => { + const h = renderCanvas({ elements: [makeAux(10, 'foo', 100, 100)] }); + h.clearMountCalls(); + const text = h.query('.simlin-aux text')!; + pointerDown(text, 130, 100); + pointerMove(text, 140, 100); + pointerUp(h.svg, 140, 100); + expect(h.callbacks.onCommitGesture).not.toHaveBeenCalled(); }); it('shows the label-side preview during the drag (text-anchor flips with the side)', () => { @@ -153,197 +184,131 @@ describe('Canvas gestures: label drag (checklist 9)', () => { h.clearMountCalls(); const text = h.query('.simlin-aux text')!; - // Default labelSide is 'right' -> text-anchor 'start'. expect((h.query('.simlin-aux text') as SVGTextElement).style.textAnchor).toBe('start'); pointerDown(text, 130, 100); - pointerMove(text, 60, 100); // drag to the left - // During the drag the selected element's labelSide is overridden to 'left' - // (deriveRenderState applies state.labelSide to selectionUpdates), so the - // rendered label re-anchors to 'end'. + pointerMove(text, 60, 100); expect((h.query('.simlin-aux text') as SVGTextElement).style.textAnchor).toBe('end'); }); }); -describe('Canvas gestures: link arrowhead drag (checklist 10)', () => { - it('releasing over a valid target fires onAttachLink with that target ident', () => { +describe('Canvas gestures: link arrowhead drag', () => { + function linkScene(): CanvasHarness { const from = makeAux(1, 'from', 100, 100); const to = makeAux(2, 'to', 300, 100); const other = makeAux(3, 'other', 300, 300); - const link = makeLink(4, 1, 2); - const h = renderCanvas({ elements: [from, to, other, link] }); + return renderCanvas({ elements: [from, to, other, makeLink(4, 1, 2)] }); + } + + it('releasing over a valid target commits the link ending there', () => { + const h = linkScene(); h.clearMountCalls(); - const arrowhead = h.query('.simlin-arrowhead-link')!; - pointerDown(arrowhead, 290, 100); - // Pressing the arrowhead enters reattachment and selects the link. + pointerDown(h.query('.simlin-arrowhead-link')!, 290, 100); expect(lastSelection(h.callbacks.onSetSelection)).toEqual([4]); - - pointerMove(h.svg, 300, 300, { buttons: 1 }); // over 'other' + pointerMove(h.svg, 300, 300, { buttons: 1 }); pointerUp(h.svg, 300, 300); - expect(h.callbacks.onAttachLink).toHaveBeenCalledTimes(1); - const [linkArg, target] = h.callbacks.onAttachLink.mock.calls[0]; - expect(linkArg.uid).toBe(4); - expect(target).toBe('other'); + const l = committed(onlyCommit(h), 4); + expect(l.toUid).toBe(3); expect(h.callbacks.onDeleteSelection).not.toHaveBeenCalled(); }); - it('releasing over empty space with no invalid target deletes the link', () => { - const from = makeAux(1, 'from', 100, 100); - const to = makeAux(2, 'to', 300, 100); - const link = makeLink(4, 1, 2); - const h = renderCanvas({ elements: [from, to, link] }); + it('releasing over empty space aborts: nothing commits and the link is kept', () => { + const h = linkScene(); h.clearMountCalls(); - const arrowhead = h.query('.simlin-arrowhead-link')!; - pointerDown(arrowhead, 290, 100); + pointerDown(h.query('.simlin-arrowhead-link')!, 290, 100); pointerMove(h.svg, 600, 600, { buttons: 1 }); pointerUp(h.svg, 600, 600); - expect(h.callbacks.onAttachLink).not.toHaveBeenCalled(); - expect(h.callbacks.onDeleteSelection).toHaveBeenCalledTimes(1); + expect(h.callbacks.onCommitGesture).not.toHaveBeenCalled(); + expect(h.callbacks.onDeleteSelection).not.toHaveBeenCalled(); + expect(h.query('.simlin-arrowhead-link')).not.toBeNull(); }); - it('pressing a flow sink cloud swaps the selection to the flow (reattachment override)', () => { - const h = stockToCloudFlow(); + it('H1: a click on the arrowhead deletes nothing and commits nothing', () => { + const h = linkScene(); h.clearMountCalls(); - - const cloudNode = h.query('.simlin-cloud')!; - pointerDown(cloudNode, 300, 100); - // resolveSelectionForReattachment replaces the cloud uid with the flow uid. - expect(lastSelection(h.callbacks.onSetSelection)).toEqual([3]); + pointerDown(h.query('.simlin-arrowhead-link')!, 286, 100); + pointerUp(h.svg, 286, 100); + expect(h.callbacks.onCommitGesture).not.toHaveBeenCalled(); + expect(h.callbacks.onDeleteSelection).not.toHaveBeenCalled(); }); -}); -describe('Canvas gestures: flow endpoint drag (checklist 11)', () => { - it('dragging the flow arrowhead (sink) fires onMoveFlow with isSourceAttach false', () => { + it('pressing a flow sink cloud selects the flow and drags its end', () => { const h = stockToCloudFlow(); h.clearMountCalls(); - - const arrowhead = h.query('.simlin-arrowhead-flow')!; - pointerDown(arrowhead, 300, 100); + pointerDown(h.query('.simlin-cloud')!, 300, 100); expect(lastSelection(h.callbacks.onSetSelection)).toEqual([3]); - - pointerMove(h.svg, 350, 150, { buttons: 1 }); - pointerUp(h.svg, 350, 150); - - expect(h.callbacks.onMoveFlow).toHaveBeenCalledTimes(1); - const [flowArg, targetUid, delta, , inCreation, isSourceAttach] = h.callbacks.onMoveFlow.mock.calls[0]; - expect(flowArg.uid).toBe(3); - expect(targetUid).toBe(0); // no valid stock under the cursor - expect(delta).toEqual({ x: -50, y: -50 }); - expect(inCreation).toBe(false); - expect(isSourceAttach).toBe(false); }); +}); - it('dragging the flow source fires onMoveFlow with isSourceAttach true and a faux-target center', () => { +describe('Canvas gestures: flow endpoint drag', () => { + it('dragging the arrowhead moves the sink cloud, keeping the grab offset', () => { const h = stockToCloudFlow(); h.clearMountCalls(); - const sourceHit = h.query('.simlin-flow rect[fill="transparent"]')!; - pointerDown(sourceHit, 110, 100); + pointerDown(h.query('.simlin-arrowhead-flow')!, 290, 100); expect(lastSelection(h.callbacks.onSetSelection)).toEqual([3]); + pointerMove(h.svg, 340, 150, { buttons: 1 }); + pointerUp(h.svg, 340, 150); - pointerMove(h.svg, 150, 200, { buttons: 1 }); - pointerUp(h.svg, 150, 200); - - expect(h.callbacks.onMoveFlow).toHaveBeenCalledTimes(1); - const [flowArg, targetUid, delta, fauxTargetCenter, , isSourceAttach] = h.callbacks.onMoveFlow.mock.calls[0]; - expect(flowArg.uid).toBe(3); - expect(targetUid).toBe(0); - expect(delta).toEqual({ x: -40, y: -100 }); - // Unattached source -> faux-target center = selectionCenterOffset - offset. - expect(fauxTargetCenter).toEqual({ x: 110, y: 100 }); - expect(isSourceAttach).toBe(true); + const commit = onlyCommit(h); + const f = committed(commit, 3); + expect(f.points[f.points.length - 1]).toMatchObject({ x: 350, y: 150, attachedToUid: 2 }); + expect(committed(commit, 2)).toMatchObject({ x: 350, y: 150 }); }); -}); -describe('Canvas gestures: existing cloud endpoint drag live preview', () => { - // Regression: dragging an existing flow's cloud endpoint used to move only the - // valve (UpdateFlow's valve-slide fallback), leaving the flow line/arrowhead - // stale until pointer-up. It must now track the cursor DURING the drag, the - // same as flow creation (both route through growEndpointDrag -> - // UpdateCloudAndFlow). Assertions are made BEFORE pointer-up. - - it('sink cloud, along-axis drag: the flow line + arrowhead track the cursor mid-drag', () => { - const h = stockToCloudFlow(); // stock(100,100) -> cloud(300,100), valve(200,100) + it('dragging the source grip detaches it from the stock into a new cloud at the end', () => { + const h = stockToCloudFlow(); h.clearMountCalls(); - const arrowhead = h.query('.simlin-arrowhead-flow')!; - pointerDown(arrowhead, 300, 100); + pointerDown(h.query('.simlin-flow rect[fill="transparent"]')!, 132, 100); expect(lastSelection(h.callbacks.onSetSelection)).toEqual([3]); + pointerMove(h.svg, 172, 200, { buttons: 1 }); + pointerUp(h.svg, 172, 200); - // Drag 100px further right along the axis; do NOT release. - pointerMove(h.svg, 400, 100, { buttons: 1 }); - - const head = arrowheadPoint(h); - // Pre-fix the arrowhead stayed at the original cloud (300); now it follows - // the cursor to 400 (the arrowhead sits at the true endpoint). - expect(head[0]).toBeGreaterThan(360); - expect(head[0]).toBeCloseTo(400); - expect(head[1]).toBeCloseTo(100); // stayed horizontal - // The rendered flow line grew to the moved endpoint (pulled back by the - // finalAdjust arrowhead inset to ~392.5), not stuck at 300. - const pts = flowPoints(h); - expect(pts[pts.length - 1][0]).toBeGreaterThan(360); + const commit = onlyCommit(h); + const f = committed(commit, 3); + const source = f.points[0]; + expect(source).toMatchObject({ x: 162.5, y: 200 }); + expect(committed(commit, source.attachedToUid!)).toMatchObject({ type: 'cloud', x: 162.5, y: 200, flowUid: 3 }); }); - it('sink cloud, vertical flow, along-axis drag: the arrowhead tracks down mid-drag', () => { - const stock = makeStock(1, 'stock', 100, 100); - const cloud = makeCloud(2, 3, 100, 300); - const flow = makeFlow( - 3, - 'flow', - [ - { x: 100, y: 100, attachedToUid: 1 }, - { x: 100, y: 300, attachedToUid: 2 }, - ], - { x: 100, y: 200 }, - ); - const h = renderCanvas({ elements: [{ ...stock, outflows: [3] }, cloud, flow] }); + it('C0: a click on the source grip detaches nothing', () => { + const h = stockToCloudFlow(); h.clearMountCalls(); - - const arrowhead = h.query('.simlin-arrowhead-flow')!; - pointerDown(arrowhead, 100, 300); - pointerMove(h.svg, 100, 400, { buttons: 1 }); - - const head = arrowheadPoint(h); - expect(head[1]).toBeGreaterThan(360); // tracked downward - expect(head[1]).toBeCloseTo(400); - expect(head[0]).toBeCloseTo(100); // stayed vertical + pointerDown(h.query('.simlin-flow rect[fill="transparent"]')!, 132, 100); + pointerUp(h.svg, 132, 100); + expect(h.callbacks.onCommitGesture).not.toHaveBeenCalled(); }); - it('source cloud, along-axis drag: the flow source tracks the cursor mid-drag', () => { - const cloud = makeCloud(2, 3, 100, 100); // source - const stock = makeStock(1, 'stock', 300, 100); // sink + it('the dragged end tracks the pointer before release, the cloud-to-cloud source staying put', () => { + const source = makeCloud(1, 3, 100, 100); + const sink = makeCloud(2, 3, 300, 100); const flow = makeFlow( 3, 'flow', [ - { x: 100, y: 100, attachedToUid: 2 }, - { x: 300, y: 100, attachedToUid: 1 }, + { x: 100, y: 100, attachedToUid: 1 }, + { x: 300, y: 100, attachedToUid: 2 }, ], { x: 200, y: 100 }, ); - const h = renderCanvas({ elements: [{ ...stock, inflows: [3] }, cloud, flow] }); + const h = renderCanvas({ elements: [source, sink, flow] }); h.clearMountCalls(); - const sourceHit = h.query('.simlin-flow rect[fill="transparent"]')!; - pointerDown(sourceHit, 110, 100); - expect(lastSelection(h.callbacks.onSetSelection)).toEqual([3]); + pointerDown(h.query('.simlin-arrowhead-flow')!, 290, 100); + pointerMove(h.svg, 390, 100, { buttons: 1 }); - // Drag the source 60px to the left; do NOT release. - pointerMove(h.svg, 50, 100, { buttons: 1 }); - - const pts = flowPoints(h); - // Pre-fix the source point stayed at x=100; now it follows the cursor to 40. - expect(pts[0][0]).toBeLessThan(80); - expect(pts[0][0]).toBeCloseTo(40); - expect(pts[0][1]).toBeCloseTo(100); // stayed horizontal + expect(hasCloudAt(h, 400, 100)).toBe(true); + expect(hasCloudAt(h, 100, 100)).toBe(true); + expect(flowPoints(h)[0]).toEqual([100, 100]); + expect(h.callbacks.onCommitGesture).not.toHaveBeenCalled(); }); - it('sink cloud dragged over a stock: the flow line snaps to the stock EDGE mid-drag', () => { + it('a sink cloud dragged over a stock snaps onto its face, and releasing there attaches the flow', () => { const stock = makeStock(1, 'stock', 100, 100); const cloud = makeCloud(2, 3, 300, 100); const target = makeStock(4, 'target', 400, 100); @@ -351,93 +316,48 @@ describe('Canvas gestures: existing cloud endpoint drag live preview', () => { 3, 'flow', [ - { x: 100, y: 100, attachedToUid: 1 }, + { x: 122.5, y: 100, attachedToUid: 1 }, { x: 300, y: 100, attachedToUid: 2 }, ], { x: 200, y: 100 }, ); - const h = renderCanvas({ elements: [{ ...stock, outflows: [3] }, cloud, target, flow] }); + const h = renderCanvas({ elements: [stock, cloud, target, flow] }); h.clearMountCalls(); - const arrowhead = h.query('.simlin-arrowhead-flow')!; - pointerDown(arrowhead, 300, 100); - // Move onto the target stock's center; do NOT release. + pointerDown(h.query('.simlin-cloud')!, 300, 100); pointerMove(h.svg, 400, 100, { buttons: 1 }); + // The target stock renders as a valid target, and the dragged cloud is gone. + expect(h.queryAll('.simlin-stock')[1].getAttribute('class')).toContain('targetGood'); + expect(h.query('.simlin-cloud')).toBeNull(); + pointerUp(h.svg, 400, 100); - const head = arrowheadPoint(h); - // Pinned to the target's LEFT edge (400 - StockWidth/2 = 377.5), pulled back - // by the arrowhead inset -- not the stock center (400) and not stuck at 300. - expect(head[0]).toBeGreaterThan(355); - expect(head[0]).toBeLessThan(378); - expect(head[1]).toBeCloseTo(100); - }); - - it('cloud-to-cloud flow, sink drag: the non-dragged source cloud stays put mid-drag', () => { - // Both endpoints are clouds. applyGroupMovement's UpdateFlow cloud-to-cloud - // path would translate BOTH clouds by the delta; the fix holds the source - // fixed and restores its cloud, so it stays attached to the fixed endpoint. - const source = makeCloud(1, 3, 100, 100); - const sink = makeCloud(2, 3, 300, 100); - const flow = makeFlow( - 3, - 'flow', - [ - { x: 100, y: 100, attachedToUid: 1 }, - { x: 300, y: 100, attachedToUid: 2 }, - ], - { x: 200, y: 100 }, - ); - const h = renderCanvas({ elements: [source, sink, flow] }); - h.clearMountCalls(); - - const arrowhead = h.query('.simlin-arrowhead-flow')!; - pointerDown(arrowhead, 300, 100); - pointerMove(h.svg, 350, 100, { buttons: 1 }); // delta = {x:-50}; do NOT release - - // The flow's source endpoint stays fixed at (100,100)... - const pts = flowPoints(h); - expect(pts[0][0]).toBeCloseTo(100); - expect(pts[0][1]).toBeCloseTo(100); - // ...and the source cloud stays there too (pre-fix it drifted to x=150). - const centers = cloudCenters(h); - expect(centers.some(([cx, cy]) => Math.abs(cx - 100) < 0.5 && Math.abs(cy - 100) < 0.5)).toBe(true); - expect(centers.some(([cx]) => Math.abs(cx - 150) < 0.5)).toBe(false); + const f = committed(onlyCommit(h), 3); + expect(f.points[f.points.length - 1]).toMatchObject({ x: 400 - StockWidth / 2, y: 100, attachedToUid: 4 }); }); }); -describe('Canvas gestures: creation tools (checklist 12)', () => { +describe('Canvas gestures: creation tools', () => { it.each([ ['aux', 'aux', 'New Variable'], ['stock', 'stock', 'New Stock'], ['module', 'module', 'New Module'], ] as const)( - '%s tool: press stages the element, release opens name editing, Enter commits via onCreateVariable', + '%s tool: the draft follows the drag, release opens name editing, Enter commits via onCreateVariable', (tool, type, expectedName) => { const h = renderCanvas({ elements: [], selectedTool: tool }); h.clearMountCalls(); pointerDown(h.svg, 200, 200); - // The in-creation element is staged and selected (uid inCreationUid = -2). - expect(lastSelection(h.callbacks.onSetSelection)).toEqual([-2]); + // A draft is not in the view: the press clears the selection and stages it. + expect(lastSelection(h.callbacks.onSetSelection)).toEqual([]); expect(h.query(`.simlin-${type}`)).not.toBeNull(); - pointerMove(h.svg, 210, 210, { buttons: 1 }); - - // DURING the creation drag (after move, before pointer-up) the name editor - // must NOT be active: the union is `editingName {onPointerUp: true}` (the - // "start editing once the drag ends" staging handoff), which is distinct - // from the editor being visible NOW. Asserting the old two-field semantics: - // (a) no inline contenteditable overlay is mounted yet, and - // (b) the staged element still renders its own text label (it is only - // suppressed once the editor actually shows on pointer-up). + pointerMove(h.svg, 230, 240, { buttons: 1 }); + // During the drag the draft still renders its own label and no editor is open. expect(h.query('[contenteditable]')).toBeNull(); - const stagedLabel = h.query(`.simlin-${type} text`); - expect(stagedLabel).not.toBeNull(); - expect(stagedLabel!.textContent).toBe(expectedName); + expect(h.query(`.simlin-${type} text`)?.textContent).toBe(expectedName); - pointerUp(h.svg, 210, 210); - - // The creation drag releases into name editing (EditableLabel overlay). + pointerUp(h.svg, 230, 240); const editable = h.query('[contenteditable]'); expect(editable).not.toBeNull(); @@ -447,72 +367,41 @@ describe('Canvas gestures: creation tools (checklist 12)', () => { }); expect(h.callbacks.onCreateVariable).toHaveBeenCalledTimes(1); const created = h.callbacks.onCreateVariable.mock.calls[0][0]; - expect(created.type).toBe(type); - expect(created.name).toBe(expectedName); + expect(created).toMatchObject({ type, name: expectedName, x: 230, y: 240 }); + expect(h.callbacks.onCommitGesture).not.toHaveBeenCalled(); }, ); - it('flow tool: cancelling the just-created flow name edit deletes it (flowStillBeingCreated)', () => { + it('flow tool: cancelling the just-created flow`s name edit deletes it', () => { const h = renderCanvas({ elements: [], selectedTool: 'flow' }); h.clearMountCalls(); - // Model the host: onMoveFlow materializes a concrete flow + clouds and - // selects the real flow (uid 50) -- mirroring Editor.handleFlowAttach. - h.callbacks.onMoveFlow.mockImplementation(() => { - const source = makeCloud(51, 50, 200, 200); - const sink = makeCloud(52, 50, 300, 200); - const flow = makeFlow( - 50, - 'New Flow', - [ - { x: 200, y: 200, attachedToUid: 51 }, - { x: 300, y: 200, attachedToUid: 52 }, - ], - { x: 250, y: 200 }, - ); - const view: StockFlowView = { - nextUid: 53, - elements: [source, sink, flow], - viewBox: { x: 0, y: 0, width: 1000, height: 1000 }, - zoom: 1, - useLetteredPolarity: false, - }; - h.setProps({ view, selection: new Set([50]) }); - }); - pointerDown(h.svg, 200, 200); pointerMove(h.svg, 300, 200, { buttons: 1 }); pointerUp(h.svg, 300, 200); + const commit = onlyCommit(h); + expect(commit.label).toBe('flow creation'); + expect(commit.editName).toBe(commit.elements.find((e) => e.type === 'flow')!.uid); const editable = h.query('[contenteditable]'); expect(editable).not.toBeNull(); act(() => { fireEvent.keyUp(editable!, { code: 'Escape' }); }); - // Cancelling the initial flow name deletes the just-created flow. expect(h.callbacks.onDeleteSelection).toHaveBeenCalledTimes(1); }); - it('flow tool: releasing does not crash before the host commits the new selection (async attach)', () => { - // Regression: Editor.handleFlowAttach commits the new flow's selection - // asynchronously (after the engine round-trip). The pointer-up that creates - // the flow enters name-editing AND clears the in-creation element in the - // same commit, but props.selection still holds inCreationUid (-2) until that - // async commit lands. The name-editor render must not dereference the - // now-cleared in-creation element. Unlike the test above, onMoveFlow here - // deliberately does NOT commit a selection, modeling that gap -- which is - // exactly what the real (async) host does for one render. + it('flow tool: releasing does not crash when the host refuses the commit', () => { const stock = makeStock(1, 'pop', 300, 200); - const h = renderCanvas({ elements: [stock], selectedTool: 'flow' }); + const h = renderCanvas({ elements: [stock], selectedTool: 'flow', autoCommitEdits: false }); h.clearMountCalls(); - pointerDown(h.svg, 100, 200); // empty space -> source cloud materializes + pointerDown(h.svg, 100, 200); pointerMove(h.svg, 200, 200, { buttons: 1 }); - pointerMove(h.svg, 295, 200, { buttons: 1 }); // drag the sink toward the stock + pointerMove(h.svg, 295, 200, { buttons: 1 }); expect(() => pointerUp(h.svg, 300, 200)).not.toThrow(); - - expect(h.callbacks.onMoveFlow).toHaveBeenCalledTimes(1); + expect(h.callbacks.onCommitGesture).toHaveBeenCalledTimes(1); }); it('flow tool: releasing the sink on a stock attaches the flow to that stock', () => { @@ -520,174 +409,109 @@ describe('Canvas gestures: creation tools (checklist 12)', () => { const h = renderCanvas({ elements: [stock], selectedTool: 'flow' }); h.clearMountCalls(); - pointerDown(h.svg, 100, 200); // empty space, aligned in y with the stock + pointerDown(h.svg, 100, 200); pointerMove(h.svg, 200, 200, { buttons: 1 }); - pointerMove(h.svg, 300, 200, { buttons: 1 }); // cursor over the stock center + pointerMove(h.svg, 300, 200, { buttons: 1 }); pointerUp(h.svg, 300, 200); - expect(h.callbacks.onMoveFlow).toHaveBeenCalledTimes(1); - const [, targetUid] = h.callbacks.onMoveFlow.mock.calls[0]; - expect(targetUid).toBe(1); // attached to the stock (uid 1), not 0 (empty space) + const commit = onlyCommit(h); + const f = commit.elements.find((e): e is FlowViewElement => e.type === 'flow')!; + expect(f.points[f.points.length - 1]).toMatchObject({ x: 300 - StockWidth / 2, y: 200, attachedToUid: 1 }); }); }); -describe('Canvas gestures: flow tool live preview (a)', () => { - // As the user drags the flow tool, the in-creation flow must GROW toward the - // cursor as an orthogonal segment (it previously rendered as a zero-length, - // invisible path), and snap to the stock's edge when the cursor is over a - // valid stock. We assert on the rendered flow path geometry, not just presence. - - it('the in-creation flow grows orthogonally toward the cursor (not degenerate)', () => { - const stock = makeStock(1, 'pop', 300, 200); - const h = renderCanvas({ elements: [stock], selectedTool: 'flow' }); - h.clearMountCalls(); - - pointerDown(h.svg, 100, 200); // press empty space, level with the stock, to its left - pointerMove(h.svg, 180, 200, { buttons: 1 }); // drag right, NOT yet over the stock - - const line = flowPoints(h); - expect(line.length).toBeGreaterThanOrEqual(2); - const lineStart = line[0]; - const lineEnd = line[line.length - 1]; - // grew a visible length (was a zero-length, invisible path before the fix) - expect(Math.hypot(lineEnd[0] - lineStart[0], lineEnd[1] - lineStart[1])).toBeGreaterThan(50); - // horizontal (orthogonal): the line stays on the source's y - expect(lineEnd[1]).toBeCloseTo(lineStart[1]); - // the arrowhead (true endpoint) points right at the cursor - const head = arrowheadPoint(h); - expect(head[0]).toBeCloseTo(180); - expect(head[1]).toBeCloseTo(200); - }); - - it('the in-creation flow snaps to the stock edge when the cursor is over a stock', () => { - const stock = makeStock(1, 'pop', 300, 200); - const h = renderCanvas({ elements: [stock], selectedTool: 'flow' }); - h.clearMountCalls(); - - pointerDown(h.svg, 100, 200); // press level with the stock - pointerMove(h.svg, 180, 200, { buttons: 1 }); - pointerMove(h.svg, 295, 200, { buttons: 1 }); // cursor now over the stock (center 300) - - const head = arrowheadPoint(h); - // pinned to the stock's LEFT edge (300 - StockWidth/2 = 277.5), NOT the cursor - // (295) and NOT the stock center (300, which drew the arrowhead behind it) - expect(head[0]).toBeCloseTo(300 - StockWidth / 2); - expect(head[0]).not.toBeCloseTo(300); - expect(head[0]).toBeLessThan(295); // snapped back to the edge, left of the cursor - expect(head[1]).toBeCloseTo(200); // still horizontal - }); - - it('a dominant vertical drag grows a vertical in-creation flow', () => { - const h = renderCanvas({ elements: [], selectedTool: 'flow' }); - h.clearMountCalls(); - - pointerDown(h.svg, 200, 100); // press empty space - pointerMove(h.svg, 200, 220, { buttons: 1 }); // drag straight down - - const line = flowPoints(h); - const lineStart = line[0]; - const lineEnd = line[line.length - 1]; - expect(Math.abs(lineEnd[1] - lineStart[1])).toBeGreaterThan(50); // grew downward - // the arrowhead points straight down at the cursor (x unchanged, vertical) - const head = arrowheadPoint(h); - expect(head[0]).toBeCloseTo(200); - expect(head[1]).toBeCloseTo(220); - }); - - it('grows LEFTWARD toward the cursor (not degenerate)', () => { +describe('Canvas gestures: flow tool live preview', () => { + // As the user drags the flow tool, the drawn flow grows toward the pointer as + // an orthogonal segment, its sink cloud at the pointer and its source cloud + // planted at the press, and snaps onto a stock's face over a stock. + it.each([ + ['right', { x: 100, y: 200 }, { x: 180, y: 200 }], + ['down', { x: 200, y: 100 }, { x: 200, y: 220 }], + ['left', { x: 300, y: 200 }, { x: 220, y: 200 }], + ['up', { x: 200, y: 300 }, { x: 200, y: 220 }], + ] as const)('grows %s toward the pointer with the sink cloud at the pointer', (_name, press, at) => { const h = renderCanvas({ elements: [], selectedTool: 'flow' }); h.clearMountCalls(); - pointerDown(h.svg, 300, 200); // press empty space - pointerMove(h.svg, 220, 200, { buttons: 1 }); // drag LEFT + pointerDown(h.svg, press.x, press.y); + pointerMove(h.svg, at.x, at.y, { buttons: 1 }); const line = flowPoints(h); - expect(Math.abs(line[line.length - 1][0] - line[0][0])).toBeGreaterThan(50); // grew - const head = arrowheadPoint(h); - expect(head[0]).toBeCloseTo(220); // arrowhead at the cursor, to the left - expect(head[1]).toBeCloseTo(200); + expect(line.length).toBe(2); + expect(line[0][0] === line[1][0] || line[0][1] === line[1][1]).toBe(true); + expect(hasCloudAt(h, press.x, press.y)).toBe(true); + expect(hasCloudAt(h, at.x, at.y)).toBe(true); }); - it('grows UPWARD toward the cursor (not degenerate)', () => { + it('a press within the click threshold previews nothing (E1)', () => { const h = renderCanvas({ elements: [], selectedTool: 'flow' }); h.clearMountCalls(); - - pointerDown(h.svg, 200, 300); // press empty space - pointerMove(h.svg, 200, 220, { buttons: 1 }); // drag UP - - const head = arrowheadPoint(h); - expect(head[0]).toBeCloseTo(200); // vertical - expect(head[1]).toBeCloseTo(220); // arrowhead at the cursor, above + pointerDown(h.svg, 100, 200); + pointerMove(h.svg, 102, 201, { buttons: 1 }); + expect(h.query('.simlin-flow')).toBeNull(); }); - it('plants the source cloud at the tail, not on the arrowhead at the cursor', () => { - const h = renderCanvas({ elements: [], selectedTool: 'flow' }); + it('snaps onto the stock face when the pointer is over a stock', () => { + const stock = makeStock(1, 'pop', 300, 200); + const h = renderCanvas({ elements: [stock], selectedTool: 'flow' }); h.clearMountCalls(); - pointerDown(h.svg, 100, 200); // press empty space -> source cloud planted here - pointerMove(h.svg, 180, 200, { buttons: 1 }); // drag right + pointerDown(h.svg, 100, 200); + pointerMove(h.svg, 295, 200, { buttons: 1 }); - const centers = cloudCenters(h); - // the source cloud stays at the tail (the press point)... - expect(centers.some(([x, y]) => Math.abs(x - 100) < 1 && Math.abs(y - 200) < 1)).toBe(true); - // ...and does NOT ride along to the cursor/arrowhead at x=180 - expect(centers.some(([x]) => Math.abs(x - 180) < 1)).toBe(false); + const line = flowPoints(h); + // The path ends at the face (277.5) less the arrowhead inset, not at the pointer. + expect(line[line.length - 1][0]).toBeCloseTo(300 - StockWidth / 2 - 7.5); + expect(h.queryAll('.simlin-cloud')).toHaveLength(1); }); }); -describe('Canvas gestures: link/flow tool from a named element (checklist 13)', () => { - it('link tool pressing a named element starts an inCreation link drag that attaches on release', () => { - const a = makeAux(1, 'a', 100, 100); - const b = makeAux(2, 'b', 300, 100); - const h = renderCanvas({ elements: [a, b], selectedTool: 'link' }); +describe('Canvas gestures: link and flow tools on an element', () => { + it('link tool pressing a named element draws a link that commits onto the release target', () => { + const h = renderCanvas({ elements: [makeAux(1, 'a', 100, 100), makeAux(2, 'b', 300, 100)], selectedTool: 'link' }); h.clearMountCalls(); - const nodes = h.queryAll('.simlin-aux'); - pointerDown(nodes[0], 100, 100); - expect(lastSelection(h.callbacks.onSetSelection)).toEqual([-2]); // inCreation link + pointerDown(h.queryAll('.simlin-aux')[0], 100, 100); + expect(h.callbacks.onSetSelection).not.toHaveBeenCalled(); + pointerMove(h.svg, 200, 130, { buttons: 1 }); expect(h.query('.simlin-connector')).not.toBeNull(); - - pointerMove(h.svg, 300, 100, { buttons: 1 }); // onto b + pointerMove(h.svg, 300, 100, { buttons: 1 }); pointerUp(h.svg, 300, 100); - expect(h.callbacks.onAttachLink).toHaveBeenCalledTimes(1); - const [linkArg, target] = h.callbacks.onAttachLink.mock.calls[0]; - expect(linkArg.fromUid).toBe(1); - expect(target).toBe('b'); + const commit = onlyCommit(h); + const l = commit.elements.find((e): e is LinkViewElement => e.type === 'link')!; + expect({ fromUid: l.fromUid, toUid: l.toUid }).toEqual({ fromUid: 1, toUid: 2 }); + expect([...commit.selection]).toEqual([l.uid]); }); - it('flow tool pressing a stock starts an inCreation flow drag', () => { - const s = makeStock(1, 'stock', 100, 100); - const h = renderCanvas({ elements: [s], selectedTool: 'flow' }); + it('flow tool pressing a stock draws a flow once the pointer moves', () => { + const h = renderCanvas({ elements: [makeStock(1, 'stock', 100, 100)], selectedTool: 'flow' }); h.clearMountCalls(); - const node = h.query('.simlin-stock')!; - pointerDown(node, 100, 100); - - expect(lastSelection(h.callbacks.onSetSelection)).toEqual([-2]); // inCreation flow - // The in-creation flow renders (a second .simlin-flow exists beyond none). + pointerDown(h.query('.simlin-stock')!, 100, 100); + expect(h.query('.simlin-flow')).toBeNull(); + pointerMove(h.svg, 220, 100, { buttons: 1 }); expect(h.query('.simlin-flow')).not.toBeNull(); }); }); -describe('Canvas gestures: name editing (checklist 15)', () => { +describe('Canvas gestures: name editing', () => { function enterEditing(h: CanvasHarness): Element { const text = h.query('.simlin-aux text')!; act(() => { fireEvent.doubleClick(text, { clientX: 130, clientY: 100 }); }); - // Host commits the selection the double-click requested. - h.setProps({ selection: new Set([10]) }); return h.query('[contenteditable]')!; } - it('double-clicking a single named element enters editing (EditableLabel overlay appears)', () => { + it('double-clicking a named element`s label enters editing (EditableLabel overlay appears)', () => { const h = renderCanvas({ elements: [makeAux(10, 'foo', 100, 100)] }); h.clearMountCalls(); expect(h.query('.editableLabel')).toBeNull(); enterEditing(h); expect(h.query('.editableLabel')).not.toBeNull(); + expect(lastSelection(h.callbacks.onSetSelection)).toEqual([10]); }); it('Enter commits the rename via onRenameVariable', () => { @@ -696,13 +520,11 @@ describe('Canvas gestures: name editing (checklist 15)', () => { const editable = enterEditing(h); act(() => { - // EditableLabel commits on Enter held with a modifier. fireEvent.keyDown(editable, { code: 'Enter' }); fireEvent.keyUp(editable, { code: 'Enter' }); }); expect(h.callbacks.onRenameVariable).toHaveBeenCalledTimes(1); - // No text was typed, so the name round-trips unchanged. expect(h.callbacks.onRenameVariable.mock.calls[0]).toEqual(['foo', 'foo']); expect(h.callbacks.onDeleteSelection).not.toHaveBeenCalled(); }); @@ -717,7 +539,6 @@ describe('Canvas gestures: name editing (checklist 15)', () => { }); expect(h.callbacks.onRenameVariable).not.toHaveBeenCalled(); - // clearPointerState() on cancel clears the selection. expect(lastSelection(h.callbacks.onSetSelection)).toEqual([]); expect(h.query('[contenteditable]')).toBeNull(); }); @@ -729,7 +550,6 @@ describe('Canvas gestures: name editing (checklist 15)', () => { enterEditing(h); expect(h.query('[contenteditable]')).not.toBeNull(); - // render() schedules handleEditingNameDone(false) when selectedTool changes. h.setProps({ selectedTool: 'aux' }); await act(async () => { await Promise.resolve(); @@ -738,38 +558,32 @@ describe('Canvas gestures: name editing (checklist 15)', () => { expect(h.callbacks.onRenameVariable).toHaveBeenCalledTimes(1); }); + + it('a press on the overlay behind the editor commits the name', () => { + const h = renderCanvas({ elements: [makeAux(10, 'foo', 100, 100)] }); + h.clearMountCalls(); + enterEditing(h); + const overlay = h.query('[contenteditable]')!.closest('.overlay')!; + pointerDown(overlay, 500, 500); + expect(h.callbacks.onRenameVariable).toHaveBeenCalledTimes(1); + }); }); // Regression coverage for "double-clicking a var name doesn't reliably open the -// name editor". Two independent unreliability sources are pinned here: -// -// 1. An ALREADY-SELECTED element's name-edit request was routed through the -// deferred-single-select dance (computeMouseDownSelection returns -// deferSingleSelect when a modifier-less press lands on a selected element). -// That path only opens the editor on the *pointer-up* that resolves the -// defer -- but a double-click's terminal event is `dblclick`, whose -// pointer-up already fired, so the editor never appeared. It worked when the -// element was unselected (that path selects + edits synchronously), so the -// bug surfaced as "sometimes works, sometimes doesn't". -// -// 2. The name label started a label-drag on ANY pointer movement (no -// click-vs-drag threshold), so the incidental 1-2px wobble of a physical -// double-click was treated as a drag -- selecting the element and moving its -// label instead of editing. +// name editor": a double-click on an already-selected element's label must open +// the editor at once (a label double-click is its own press arm, never a +// deferred single select resolved on a pointer-up that already fired), and the +// label's own click threshold keeps a physical double-click's 1-2px wobble from +// starting a label drag. describe('Canvas gestures: double-click name-edit reliability', () => { it('opens the editor when double-clicking the name of an ALREADY-SELECTED variable', () => { const h = renderCanvas({ elements: [makeAux(10, 'foo', 100, 100)], selection: new Set([10]) }); h.clearMountCalls(); expect(h.query('[contenteditable]')).toBeNull(); - - const text = h.query('.simlin-aux text')!; act(() => { - fireEvent.doubleClick(text, { clientX: 130, clientY: 100 }); + fireEvent.doubleClick(h.query('.simlin-aux text')!, { clientX: 130, clientY: 100 }); }); - // Mirror the host committing the selection the double-click requested. - h.setProps({ selection: new Set([10]) }); - expect(h.query('[contenteditable]')).not.toBeNull(); expect(h.callbacks.onRenameVariable).not.toHaveBeenCalled(); }); @@ -777,13 +591,9 @@ describe('Canvas gestures: double-click name-edit reliability', () => { it('opens the editor when double-clicking the name of an UNSELECTED variable', () => { const h = renderCanvas({ elements: [makeAux(10, 'foo', 100, 100)] }); h.clearMountCalls(); - - const text = h.query('.simlin-aux text')!; act(() => { - fireEvent.doubleClick(text, { clientX: 130, clientY: 100 }); + fireEvent.doubleClick(h.query('.simlin-aux text')!, { clientX: 130, clientY: 100 }); }); - h.setProps({ selection: new Set([10]) }); - expect(h.query('[contenteditable]')).not.toBeNull(); }); @@ -793,11 +603,10 @@ describe('Canvas gestures: double-click name-edit reliability', () => { const text = h.query('.simlin-aux text')!; pointerDown(text, 130, 100); - pointerMove(text, 132, 101); // ~2px, below the 5px click/drag threshold + pointerMove(text, 132, 101); pointerUp(text, 132, 101); - // No drag => no label move and no drag-driven selection. - expect(h.callbacks.onMoveLabel).not.toHaveBeenCalled(); + expect(h.callbacks.onCommitGesture).not.toHaveBeenCalled(); expect(h.callbacks.onSetSelection).not.toHaveBeenCalled(); }); @@ -807,11 +616,10 @@ describe('Canvas gestures: double-click name-edit reliability', () => { const text = h.query('.simlin-aux text')!; pointerDown(text, 130, 100); - pointerMove(text, 60, 100); // 70px to the left, well past the threshold + pointerMove(text, 60, 100); expect(lastSelection(h.callbacks.onSetSelection)).toEqual([10]); - pointerUp(h.svg, 60, 100); - expect(h.callbacks.onMoveLabel).toHaveBeenCalledWith(10, 'left'); + expect(committed(onlyCommit(h), 10)).toMatchObject({ labelSide: 'left' }); }); it('captures the pointer on press (so an edge grip that leaves the label sub-threshold can still drag)', () => { @@ -819,23 +627,14 @@ describe('Canvas gestures: double-click name-edit reliability', () => { h.clearMountCalls(); const text = h.query('.simlin-aux text') as SVGElement; - // Spy on the text node's setPointerCapture (the harness polyfills it as a - // no-op on Element.prototype); the component captures on `e.currentTarget`, - // which is this node. const captureSpy = rs.spyOn(text, 'setPointerCapture'); - // Capture must happen at press time, not once the drag starts: a short - // label's tiny hit box means an edge grip can leave the bbox while still - // sub-threshold, and only an already-captured pointer keeps delivering moves. pointerDown(text, 130, 100, { pointerId: 7 }); expect(captureSpy).toHaveBeenCalledWith(7); - // The drag is still gated on the threshold: a sub-threshold move starts no - // drag (no selection churn between the two clicks of a double-click)... pointerMove(text, 132, 101, { pointerId: 7 }); expect(h.callbacks.onSetSelection).not.toHaveBeenCalled(); - // ...and crossing the threshold begins the drag and selects the element. pointerMove(text, 150, 130, { pointerId: 7 }); expect(lastSelection(h.callbacks.onSetSelection)).toEqual([10]); @@ -843,17 +642,12 @@ describe('Canvas gestures: double-click name-edit reliability', () => { }); }); -// A flow or link whose endpoint UID points at an element that is not present in -// the view is a corrupt/dangling reference. It can arise transiently (an undo -// rebuild that renders before the project swaps, issue #817) or be persisted in -// model data (issue #812, which left the whole model permanently uneditable). -// The element renderers must degrade gracefully -- skip the broken element -- -// rather than throw out of render and take the entire editor down via the -// ErrorBoundary. +// A flow or link whose endpoint uid points at an element not present in the view +// is corrupt data -- transient (an undo rebuild, #817) or persisted (#812). The +// renderers skip the broken element rather than throw out of render. describe('Canvas rendering: dangling element references (#812, #817)', () => { it('does not crash when a flow references a missing source/sink, and still renders healthy elements', () => { const goodAux = makeAux(10, 'healthy', 100, 100); - // sink point attaches to uid 999, which does not exist in the view. const danglingFlow = makeFlow( 3, 'broken flow', @@ -869,14 +663,12 @@ describe('Canvas rendering: dangling element references (#812, #817)', () => { h = renderCanvas({ elements: [goodAux, danglingFlow] }); }).not.toThrow(); - // The broken flow is skipped; the healthy aux still renders. expect(h.query('.simlin-flow')).toBeNull(); expect(h.query('.simlin-aux')).not.toBeNull(); }); it('does not crash when a link references a missing from/to endpoint', () => { const goodAux = makeAux(10, 'healthy', 100, 100); - // from/to reference uids that do not exist. const danglingLink = makeLink(20, 901, 902); let h!: CanvasHarness; diff --git a/src/diagram/tests/canvas-gestures-flow-attach-failure.test.tsx b/src/diagram/tests/canvas-gestures-flow-attach-failure.test.tsx index 81f9bc8d1..cf03f07fe 100644 --- a/src/diagram/tests/canvas-gestures-flow-attach-failure.test.tsx +++ b/src/diagram/tests/canvas-gestures-flow-attach-failure.test.tsx @@ -5,10 +5,10 @@ // Regression tests for the create-flow name-edit crash (issue #820 / the // getElementByUid crash surfaced during the #819 review). // -// When a just-drawn flow's attach patch fails, the host (Editor) does not -// commit the flow into the view, yet the Canvas has already handed off to the -// just-created-flow name edit. Its `props.selection` then references a flow -// that is not in the view. The render already tolerates that (it resolves the +// When a just-drawn flow's attach patch fails, the host (Editor) rolls the +// optimistic view back to the committed one, which has no such flow, yet the +// Canvas has already handed off to the just-created-flow name edit. Its +// `props.selection` then references a flow that is not in the view. The render already tolerates that (it resolves the // editing element through the NON-throwing tryGetElementByUid and skips the // editor), but `handleEditingNameDone` used the THROWING getElementByUid, so // any path that fired it against the phantom selection -- notably the deferred @@ -31,9 +31,7 @@ import { describe, it, expect } from '@rstest/core'; import { act, fireEvent } from '@testing-library/react'; -import type { StockFlowView } from '@simlin/core/datamodel'; - -import { makeCloud, makeFlow, pointerDown, pointerMove, pointerUp, renderCanvas } from './canvas-gesture-harness'; +import { pointerDown, pointerMove, pointerUp, renderCanvas } from './canvas-gesture-harness'; // Drain one macrotask so the render-scheduled deferred editing-done runs. async function flushDeferred(): Promise { @@ -55,11 +53,10 @@ function captureWindowErrors(): { errors: unknown[]; stop: () => void } { describe('Canvas name-edit teardown for a just-created flow', () => { it('the deferred editing-done does not crash on a phantom selection (commit path)', async () => { - const h = renderCanvas({ elements: [], selectedTool: 'flow' }); // Model the failed-attach path: the host neither commits the drawn flow - // into the view nor selects a committed element, so the Canvas's selection - // is left pointing at the (now-cleared) in-creation flow. - h.callbacks.onMoveFlow.mockImplementation(() => {}); + // into the view nor selects it, so the name editor names a flow the view + // never holds. + const h = renderCanvas({ elements: [], selectedTool: 'flow', autoCommitEdits: false }); h.clearMountCalls(); // Draw the flow. Pointer-up hands off into the just-created-flow name edit @@ -92,31 +89,10 @@ describe('Canvas name-edit teardown for a just-created flow', () => { }); it('cancelling a just-created flow name edit fires exactly one delete and never throws (cancel path)', () => { + // The harness applies the commit, so the name editor renders for a real + // element. A cancel can only be issued against a rendered editor, so this is + // the only way to exercise the genuine cancel/delete-on-cancel path. const h = renderCanvas({ elements: [], selectedTool: 'flow' }); - // Success materializer: commit the drawn flow so the name editor renders for - // a real element. A cancel can only be issued against a rendered editor, so - // this is the only way to exercise the genuine cancel/delete-on-cancel path. - h.callbacks.onMoveFlow.mockImplementation(() => { - const source = makeCloud(51, 50, 200, 200); - const sink = makeCloud(52, 50, 300, 200); - const flow = makeFlow( - 50, - 'New Flow', - [ - { x: 200, y: 200, attachedToUid: 51 }, - { x: 300, y: 200, attachedToUid: 52 }, - ], - { x: 250, y: 200 }, - ); - const view: StockFlowView = { - nextUid: 60, - elements: [source, sink, flow], - viewBox: { x: 0, y: 0, width: 1000, height: 1000 }, - zoom: 1, - useLetteredPolarity: false, - }; - h.setProps({ view, selection: new Set([50]) }); - }); h.clearMountCalls(); pointerDown(h.svg, 200, 200); diff --git a/src/diagram/tests/canvas-gestures-lifecycle.test.tsx b/src/diagram/tests/canvas-gestures-lifecycle.test.tsx new file mode 100644 index 000000000..4231083db --- /dev/null +++ b/src/diagram/tests/canvas-gestures-lifecycle.test.tsx @@ -0,0 +1,539 @@ +// Copyright 2026 The Simlin Authors. All rights reserved. +// Use of this source code is governed by the Apache License, +// Version 2.0, that can be found in the LICENSE file. + +// A live gesture's lifecycle, driven through real pointer events: what aborts it +// (E5: pointercancel, a lost release, a second pointer, a pinch, a token change, +// a geometry change under it), what does not (a republish that changes nothing +// it reads, a selection change), and that no path throws or leaves gesture state +// behind for the next press. Rows port the audit's lifecycle probes (L1-L8) and +// press defects (H3, H4, M3, M8, L-a, L-b, L-c, P-1, P-3, P-4, P-5). +// +// What this does not establish: the geometry a gesture commits +// (gesture-planner*.test.ts) or the controller refusing a stale token +// (project-controller.test.ts). +// +// Coverage note, two paths with no row. The release's own validity re-check +// cannot be killed in jsdom: act() flushes the E5 effect that drops an +// invalidated gesture before the next dispatched event, so no release here ever +// finds one still live. It guards the browser case, where a controller +// republish can leave that effect pending when pointerup arrives. The no-op +// commit handler the Editor gives a read-only Canvas is equivalent to the real +// one, which already refuses while read-only (viewEditsRefused includes +// isReadOnly). + +import { describe, it, expect, rs } from '@rstest/core'; + +import { act } from '@testing-library/react'; + +import type { Model, StockFlowView, Variable, ViewElement } from '@simlin/core/datamodel'; + +import { + dispatchWheel, + makeAux, + makeCloud, + makeFlow, + makeLink, + makeStock, + pointerCancel, + pointerDown, + pointerMove, + pointerUp, + renderCanvas, + type CanvasHarness, +} from './canvas-gesture-harness'; + +const B1 = { buttons: 1 }; + +function stockCloud(): ViewElement[] { + return [ + makeStock(1, 'stock', 100, 100), + makeCloud(2, 3, 300, 100), + makeFlow( + 3, + 'flow', + [ + { x: 122.5, y: 100, attachedToUid: 1 }, + { x: 300, y: 100, attachedToUid: 2 }, + ], + { x: 200, y: 100 }, + ), + ]; +} + +function linkScene(): ViewElement[] { + return [makeAux(1, 'a', 100, 100), makeAux(2, 'b', 300, 100), makeAux(3, 'c', 300, 300), makeLink(4, 1, 2)]; +} + +function viewOf(h: CanvasHarness, elements: readonly ViewElement[]): StockFlowView { + return { ...h.view(), elements }; +} + +const auxX = (h: CanvasHarness, i = 0): string | null => h.queryAll('g.simlin-aux circle')[i].getAttribute('cx'); + +// The live viewport the content group is drawn with: offset and zoom. +function translate(transform: string | null): { x: number; y: number; zoom: number } { + const m = /matrix\(([^)]+)\)/.exec(transform ?? ''); + if (!m) { + throw new Error(`no matrix in transform: ${transform}`); + } + const [a, , , , e, f] = m[1].split(/[\s,]+/).map(Number); + return { x: e / a, y: f / a, zoom: a }; +} + +function captureWindowErrors(): { errors: unknown[]; stop: () => void } { + const errors: unknown[] = []; + const onError = (e: ErrorEvent): void => { + errors.push(e.error ?? e.message); + e.preventDefault(); + }; + window.addEventListener('error', onError); + return { errors, stop: () => window.removeEventListener('error', onError) }; +} + +describe('Canvas gesture lifecycle: aborts (E5)', () => { + it('M3/L3: a pointercancel mid link reattach commits and deletes nothing', () => { + const h = renderCanvas({ elements: linkScene() }); + h.clearMountCalls(); + pointerDown(h.query('path.simlin-arrowhead-link')!, 289, 100); + pointerMove(h.svg, 300, 300, B1); + pointerCancel(h.svg, 300, 300); + expect(h.callbacks.onCommitGesture).not.toHaveBeenCalled(); + expect(h.callbacks.onDeleteSelection).not.toHaveBeenCalled(); + expect(h.query('path.simlin-arrowhead-link')).not.toBeNull(); + }); + + it('L4: a pointercancel mid flow creation leaves no flow, and mid aux creation opens no editor', () => { + const h = renderCanvas({ elements: [], selectedTool: 'flow' }); + h.clearMountCalls(); + pointerDown(h.svg, 200, 200); + pointerMove(h.svg, 300, 200, B1); + expect(h.query('g.simlin-flow')).not.toBeNull(); + pointerCancel(h.svg, 300, 200); + expect(h.callbacks.onCommitGesture).not.toHaveBeenCalled(); + expect(h.query('g.simlin-flow')).toBeNull(); + + const h2 = renderCanvas({ elements: [], selectedTool: 'aux' }); + h2.clearMountCalls(); + pointerDown(h2.svg, 200, 200); + pointerMove(h2.svg, 260, 240, B1); + pointerCancel(h2.svg, 260, 240); + expect(h2.query('[contenteditable]')).toBeNull(); + expect(h2.query('g.simlin-aux')).toBeNull(); + }); + + it.each([1, 0])( + 'M8/L5: a mouse move with no button held (a lost release) cancels, for pointerId %d alike', + (pointerId) => { + const h = renderCanvas({ elements: [makeAux(10, 'a', 100, 100)] }); + h.clearMountCalls(); + pointerDown(h.query('g.simlin-aux')!, 100, 100, { pointerId }); + pointerMove(h.svg, 150, 150, { pointerId, buttons: 1 }); + pointerMove(h.svg, 160, 160, { pointerId, buttons: 0 }); + expect(h.callbacks.onCommitGesture).not.toHaveBeenCalled(); + expect(auxX(h)).toBe('100'); + // Later hover moves move nothing. + pointerMove(h.svg, 250, 250, { pointerId, buttons: 0 }); + pointerUp(h.svg, 250, 250, { pointerId }); + expect(h.callbacks.onCommitGesture).not.toHaveBeenCalled(); + expect(auxX(h)).toBe('100'); + }, + ); + + it('P-1: a second pointer while dragging aborts the drag', () => { + const h = renderCanvas({ elements: [makeAux(10, 'a', 100, 100)] }); + h.clearMountCalls(); + pointerDown(h.query('g.simlin-aux')!, 100, 100, { pointerId: 1 }); + pointerMove(h.svg, 160, 160, { pointerId: 1, buttons: 1 }); + pointerDown(h.svg, 500, 500, { pointerId: 2, pointerType: 'pen', isPrimary: false }); + expect(auxX(h)).toBe('100'); + pointerUp(h.svg, 160, 160, { pointerId: 1 }); + pointerUp(h.svg, 500, 500, { pointerId: 2, pointerType: 'pen', isPrimary: false }); + expect(h.callbacks.onCommitGesture).not.toHaveBeenCalled(); + }); + + it('L-a/L7: a pinch during flow creation leaves no phantom flow after both fingers lift', () => { + const h = renderCanvas({ elements: [], selectedTool: 'flow' }); + h.clearMountCalls(); + const t1 = { pointerId: 1, pointerType: 'touch' }; + const t2 = { pointerId: 2, pointerType: 'touch', isPrimary: false }; + pointerDown(h.svg, 200, 200, t1); + pointerMove(h.svg, 300, 200, { ...t1, buttons: 1 }); + expect(h.queryAll('g.simlin-flow')).toHaveLength(1); + pointerDown(h.svg, 500, 500, t2); + pointerMove(h.svg, 550, 550, { ...t2, buttons: 1 }); + pointerUp(h.svg, 550, 550, t2); + pointerUp(h.svg, 300, 200, t1); + expect(h.queryAll('g.simlin-flow')).toHaveLength(0); + expect(h.callbacks.onCommitGesture).not.toHaveBeenCalled(); + }); + + it('P-3: an undo landing mid drag (the token moves) aborts it', () => { + const h = renderCanvas({ elements: [makeAux(10, 'a', 100, 100)] }); + h.clearMountCalls(); + pointerDown(h.query('g.simlin-aux')!, 100, 100); + pointerMove(h.svg, 160, 160, B1); + expect(auxX(h)).toBe('160'); + h.setProps({ token: 1 }); + expect(auxX(h)).toBe('100'); + pointerMove(h.svg, 180, 180, B1); + pointerUp(h.svg, 180, 180); + expect(h.callbacks.onCommitGesture).not.toHaveBeenCalled(); + expect(auxX(h)).toBe('100'); + }); + + it('L2: deleting the dragged flow mid sink drag neither throws nor commits', () => { + const h = renderCanvas({ elements: stockCloud() }); + h.clearMountCalls(); + pointerDown(h.query('path.simlin-arrowhead-flow')!, 290, 100); + pointerMove(h.svg, 350, 150, B1); + const cap = captureWindowErrors(); + try { + h.setProps({ selection: new Set(), view: viewOf(h, [makeStock(1, 'stock', 100, 100)]) }); + pointerMove(h.svg, 360, 160, B1); + pointerUp(h.svg, 360, 160); + } finally { + cap.stop(); + } + expect(cap.errors).toEqual([]); + expect(h.callbacks.onCommitGesture).not.toHaveBeenCalled(); + expect(h.query('g.simlin-flow')).toBeNull(); + }); +}); + +describe('Canvas gesture lifecycle: what keeps a gesture live', () => { + it('H3/L1: clearing the selection mid link-arrowhead drag does not crash, and the drag still commits', () => { + const h = renderCanvas({ elements: linkScene() }); + h.clearMountCalls(); + pointerDown(h.query('path.simlin-arrowhead-link')!, 289, 100); + pointerMove(h.svg, 300, 200, B1); + const cap = captureWindowErrors(); + try { + h.setProps({ selection: new Set() }); + pointerMove(h.svg, 300, 300, B1); + pointerUp(h.svg, 300, 300); + } finally { + cap.stop(); + } + expect(cap.errors).toEqual([]); + expect(h.callbacks.onCommitGesture).toHaveBeenCalledTimes(1); + }); + + it('E5: a republish that changes nothing the gesture reads keeps it', () => { + const h = renderCanvas({ elements: [makeAux(10, 'a', 100, 100)] }); + h.clearMountCalls(); + pointerDown(h.query('g.simlin-aux')!, 100, 100); + pointerMove(h.svg, 160, 160, B1); + // A fresh view object whose elements carry annotations and float noise only. + const republished = viewOf( + h, + h.view().elements.map((el) => ({ ...el, x: el.x + 1e-9, var: undefined }) as ViewElement), + ); + h.setProps({ view: { ...republished, nextUid: 999 } }); + expect(Number(auxX(h))).toBeCloseTo(160, 6); + pointerUp(h.svg, 160, 160); + expect(h.callbacks.onCommitGesture).toHaveBeenCalledTimes(1); + }); + + // The controller republishes a new project for each of these: a new view object + // with fresh element objects, and a new model. None moves any geometry by more + // than GEOMETRY_EPSILON, so the drag continues and commits. + const ulp = (v: number): number => v + Math.max(Number.MIN_VALUE, Math.abs(v) * Number.EPSILON); + const copies = (view: StockFlowView): StockFlowView => ({ + ...view, + elements: view.elements.map((el) => ({ ...el }) as ViewElement), + }); + // Every variable of `model` with `fields` attached, as the controller's render + // annotations attach sim series and errors. + const annotated = (model: Model, fields: object): Model => ({ + ...model, + variables: new Map([...model.variables].map(([ident, v]) => [ident, { ...v, ...fields } as Variable])), + }); + const BENIGN: ReadonlyArray<{ name: string; annotates: boolean; republish: (h: CanvasHarness) => void }> = [ + { + name: 'a pending edit landing (one ULP of drift; element objects and nextUid re-derived)', + annotates: false, + republish: (h) => + h.setProps({ + view: { + ...h.view(), + nextUid: h.view().nextUid + 7, + elements: h.view().elements.map((el) => ({ ...el, x: ulp(el.x), y: ulp(el.y) }) as ViewElement), + }, + }), + }, + { + name: 'sim results attaching', + annotates: true, + republish: (h) => + h.setProps({ + view: copies(h.view()), + model: annotated(h.model(), { + data: [{ name: 'b', time: new Float64Array([0, 1, 2]), values: new Float64Array([1, 3, 2]) }], + }), + }), + }, + { + name: 'error annotations updating', + annotates: true, + republish: (h) => + h.setProps({ + view: copies(h.view()), + model: annotated(h.model(), { errors: [{ start: 0, end: 1, code: 'unknown_dependency' }] }), + }), + }, + ]; + for (const row of BENIGN) { + it(`E5: ${row.name} keeps a live drag, which then commits`, () => { + const h = renderCanvas({ elements: [makeAux(10, 'a', 100, 100), makeAux(11, 'b', 400, 400)] }); + h.clearMountCalls(); + pointerDown(h.query('g.simlin-aux')!, 100, 100); + pointerMove(h.svg, 160, 160, B1); + const bBefore = h.queryAll('g.simlin-aux')[1].outerHTML; + row.republish(h); + if (row.annotates) { + // The annotation reached the render (a sparkline or a warning dot on b). + expect(h.queryAll('g.simlin-aux')[1].outerHTML).not.toBe(bBefore); + } + expect(Number(auxX(h))).toBeCloseTo(160, 6); + pointerMove(h.svg, 170, 170, B1); + pointerUp(h.svg, 170, 170); + expect(h.callbacks.onCommitGesture).toHaveBeenCalledTimes(1); + }); + } + + it('E5: a real change to an element the drag does not read still aborts it (the comparison is whole-view)', () => { + const h = renderCanvas({ elements: [makeAux(10, 'a', 100, 100), makeAux(11, 'b', 400, 400)] }); + h.clearMountCalls(); + pointerDown(h.query('g.simlin-aux')!, 100, 100); + pointerMove(h.svg, 160, 160, B1); + h.setProps({ + view: viewOf( + h, + h.view().elements.map((el) => (el.uid === 11 ? ({ ...el, x: el.x + 20 } as ViewElement) : el)), + ), + }); + expect(Number(auxX(h))).toBeCloseTo(100, 6); + pointerMove(h.svg, 170, 170, B1); + pointerUp(h.svg, 170, 170); + expect(h.callbacks.onCommitGesture).not.toHaveBeenCalled(); + }); + + it('E5: a gesture a republish invalidated stays dropped when the view changes back', () => { + const h = renderCanvas({ elements: [makeAux(10, 'a', 100, 100), makeAux(11, 'b', 400, 400)] }); + h.clearMountCalls(); + const original = h.view(); + pointerDown(h.query('g.simlin-aux')!, 100, 100); + pointerMove(h.svg, 160, 160, B1); + h.setProps({ + view: viewOf( + h, + h.view().elements.map((el) => (el.uid === 11 ? ({ ...el, x: el.x + 20 } as ViewElement) : el)), + ), + }); + // The same geometry and token the press saw: validity alone would revive it. + h.setProps({ view: original }); + pointerMove(h.svg, 170, 170, B1); + expect(auxX(h)).toBe('100'); + pointerUp(h.svg, 170, 170); + expect(h.callbacks.onCommitGesture).not.toHaveBeenCalled(); + }); + + it('Escape cancels a live drag: the preview returns to the view, the release commits nothing, the next press starts fresh', () => { + const h = renderCanvas({ elements: [makeAux(10, 'a', 100, 100), makeAux(11, 'b', 400, 400)] }); + h.clearMountCalls(); + pointerDown(h.query('g.simlin-aux')!, 100, 100); + pointerMove(h.svg, 160, 160, B1); + expect(Number(auxX(h))).toBeCloseTo(160, 6); + act(() => { + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); + }); + expect(auxX(h)).toBe('100'); + pointerMove(h.svg, 180, 180, B1); + expect(auxX(h)).toBe('100'); + pointerUp(h.svg, 180, 180); + expect(h.callbacks.onCommitGesture).not.toHaveBeenCalled(); + // Nothing is left live for the next press to abort against: a band selects b. + pointerDown(h.svg, 380, 380); + pointerMove(h.svg, 420, 420, B1); + pointerUp(h.svg, 420, 420); + expect([...h.selection()]).toEqual([11]); + }); + + it('M-1: a link a->b landing while c is dragged keeps the drag, which commits', () => { + const h = renderCanvas({ + elements: [makeAux(1, 'a', 100, 100), makeAux(2, 'b', 300, 100), makeAux(3, 'c', 300, 300)], + selectedTool: 'link', + }); + h.clearMountCalls(); + pointerDown(h.queryAll('g.simlin-aux')[0], 100, 100); + pointerMove(h.svg, 300, 100, B1); + pointerUp(h.svg, 300, 100); + expect(h.callbacks.onCommitGesture).toHaveBeenCalledTimes(1); + h.setProps({ selectedTool: undefined }); + h.callbacks.onCommitGesture.mockClear(); + + pointerDown(h.queryAll('g.simlin-aux')[2], 300, 300); + pointerMove(h.svg, 340, 340, B1); + // The link's patch lands: the datamodel reads a link's position back as NaN + // and re-derives isStraight. + h.setProps({ + view: viewOf( + h, + h + .view() + .elements.map((el) => + el.type === 'link' ? ({ ...el, x: NaN, y: NaN, isStraight: !el.isStraight } as ViewElement) : el, + ), + ), + }); + pointerMove(h.svg, 350, 350, B1); + expect(Number(auxX(h, 2))).toBeCloseTo(350, 6); + pointerUp(h.svg, 350, 350); + expect(h.callbacks.onCommitGesture).toHaveBeenCalledTimes(1); + }); + + it('S-1: a lost release forgets its pointer, so a later single touch pans instead of pinching', () => { + const h = renderCanvas({ elements: [makeAux(10, 'a', 100, 100)] }); + h.clearMountCalls(); + pointerDown(h.query('g.simlin-aux')!, 100, 100, { pointerId: 1 }); + pointerMove(h.svg, 150, 150, { pointerId: 1, buttons: 0 }); + const before = translate(h.getTransform()); + pointerDown(h.svg, 500, 500, { pointerId: 2, pointerType: 'touch', isPrimary: true }); + pointerMove(h.svg, 540, 560, { pointerId: 2, pointerType: 'touch', isPrimary: true, buttons: 1 }); + const after = translate(h.getTransform()); + expect(after.zoom).toBe(before.zoom); + expect({ x: after.x - before.x, y: after.y - before.y }).toEqual({ x: 40, y: 60 }); + pointerUp(h.svg, 540, 560, { pointerId: 2, pointerType: 'touch', isPrimary: true }); + }); + + it('S-7: a press captures the pointer on the svg root, not on the pressed element', () => { + const h = renderCanvas({ elements: [makeAux(10, 'a', 100, 100)] }); + h.clearMountCalls(); + const rootCapture = rs.fn(); + const elementCapture = rs.fn(); + const aux = h.query('g.simlin-aux')!; + h.svg.setPointerCapture = rootCapture; + aux.setPointerCapture = elementCapture; + pointerDown(aux, 100, 100, { pointerId: 7 }); + expect(rootCapture).toHaveBeenCalledWith(7); + expect(elementCapture).not.toHaveBeenCalled(); + pointerUp(h.svg, 100, 100, { pointerId: 7 }); + }); + + it('P-4: a wheel pan during an element drag keeps the element under the pointer and commits once', () => { + const h = renderCanvas({ elements: [makeAux(10, 'a', 100, 100)] }); + h.clearMountCalls(); + pointerDown(h.query('g.simlin-aux')!, 100, 100); + pointerMove(h.svg, 160, 160, B1); + dispatchWheel(h.svg, { deltaY: 40, clientX: 160, clientY: 160 }); + pointerMove(h.svg, 160, 160, B1); + pointerUp(h.svg, 160, 160); + expect(h.callbacks.onCommitGesture).toHaveBeenCalledTimes(1); + }); +}); + +describe('Canvas gesture lifecycle: nothing is left behind', () => { + it('H4/L6: a commit that throws ends the gesture, so the next press starts a rubber band', () => { + const h = renderCanvas({ elements: [makeAux(10, 'a', 100, 100), makeAux(11, 'b', 400, 400)] }); + h.clearMountCalls(); + h.callbacks.onCommitGesture.mockImplementationOnce(() => { + throw new Error('host failure'); + }); + pointerDown(h.query('g.simlin-aux')!, 100, 100); + pointerMove(h.svg, 150, 150, B1); + // React reports an exception thrown from an event handler on window rather + // than out of the dispatch. + const cap = captureWindowErrors(); + try { + pointerUp(h.svg, 150, 150); + } finally { + cap.stop(); + } + expect(cap.errors.map(String)).toEqual(['Error: host failure']); + + h.setProps({ selection: new Set([11]) }); + h.callbacks.onCommitGesture.mockClear(); + // A gesture left live would make this press abort instead: the band around a + // (where the failed commit left it) must select it. + pointerDown(h.svg, 80, 80); + pointerMove(h.svg, 120, 120, B1); + pointerUp(h.svg, 120, 120); + expect([...h.selection()]).toEqual([10]); + expect(h.callbacks.onCommitGesture).not.toHaveBeenCalled(); + expect(auxX(h, 1)).toBe('400'); + }); + + it('S-2: a refused flow create closes its name editor quietly, so a later tool change touches no selection', async () => { + const h = renderCanvas({ elements: [makeAux(10, 'a', 100, 100)], selectedTool: 'flow', autoCommitEdits: false }); + h.clearMountCalls(); + pointerDown(h.svg, 300, 300); + pointerMove(h.svg, 420, 300, B1); + pointerUp(h.svg, 420, 300); + expect(h.callbacks.onCommitGesture).toHaveBeenCalledTimes(1); + expect(h.query('[contenteditable]')).toBeNull(); + h.callbacks.onSetSelection.mockClear(); + h.setProps({ selectedTool: 'aux' }); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + expect(h.callbacks.onSetSelection).not.toHaveBeenCalled(); + }); + + it('L-b/L8: clearing the selection mid label drag does not throw, and no label side sticks to the next selection', () => { + const h = renderCanvas({ elements: [makeAux(10, 'a', 100, 100), makeAux(11, 'b', 400, 100)] }); + h.clearMountCalls(); + const text = h.queryAll('g.simlin-aux text')[0]; + pointerDown(text, 130, 100); + pointerMove(text, 60, 100); + h.setProps({ selection: new Set() }); + const cap = captureWindowErrors(); + try { + pointerUp(text, 60, 100); + } finally { + cap.stop(); + } + expect(cap.errors).toEqual([]); + h.setProps({ selection: new Set([11]) }); + expect((h.queryAll('g.simlin-aux text')[1] as SVGElement).style.textAnchor).toBe('start'); + }); + + it('L-c: a sub-threshold wobble previews no nudge', () => { + const h = renderCanvas({ elements: [makeAux(10, 'a', 100, 100)] }); + h.clearMountCalls(); + pointerDown(h.query('g.simlin-aux')!, 100, 100); + pointerMove(h.svg, 102, 102, B1); + expect(auxX(h)).toBe('100'); + pointerUp(h.svg, 102, 102); + expect(h.callbacks.onCommitGesture).not.toHaveBeenCalled(); + }); + + it('P-5: a shift-press on a selected cloud deselects it and starts no drag', () => { + const h = renderCanvas({ elements: [...stockCloud(), makeAux(10, 'a', 600, 600)], selection: new Set([2, 10]) }); + h.clearMountCalls(); + pointerDown(h.query('path.simlin-cloud')!, 300, 100, { shiftKey: true }); + expect([...h.selection()]).toEqual([10]); + pointerMove(h.svg, 360, 160, { shiftKey: true, buttons: 1 }); + pointerUp(h.svg, 360, 160, { shiftKey: true }); + expect(h.callbacks.onCommitGesture).not.toHaveBeenCalled(); + }); + + it('presses are ignored while presses are disabled, and a gesture pressed before still ends cleanly', () => { + const h = renderCanvas({ elements: [makeAux(10, 'a', 100, 100)], pressesDisabled: true }); + h.clearMountCalls(); + pointerDown(h.query('g.simlin-aux')!, 100, 100); + pointerMove(h.svg, 150, 150, B1); + expect(auxX(h)).toBe('100'); + pointerUp(h.svg, 150, 150); + expect(h.callbacks.onCommitGesture).not.toHaveBeenCalled(); + expect(h.callbacks.onSetSelection).not.toHaveBeenCalled(); + + h.setProps({ pressesDisabled: false }); + pointerDown(h.query('g.simlin-aux')!, 100, 100); + pointerMove(h.svg, 150, 150, B1); + h.setProps({ pressesDisabled: true }); + pointerUp(h.svg, 150, 150); + expect(h.callbacks.onCommitGesture).toHaveBeenCalledTimes(1); + expect(auxX(h)).toBe('150'); + }); +}); diff --git a/src/diagram/tests/canvas-gestures-name-edit-lifecycle.test.tsx b/src/diagram/tests/canvas-gestures-name-edit-lifecycle.test.tsx index b35d2f5dd..a16dc2092 100644 --- a/src/diagram/tests/canvas-gestures-name-edit-lifecycle.test.tsx +++ b/src/diagram/tests/canvas-gestures-name-edit-lifecycle.test.tsx @@ -24,12 +24,8 @@ import { describe, it, expect } from '@rstest/core'; import { act, fireEvent } from '@testing-library/react'; -import type { StockFlowView, ViewElement } from '@simlin/core/datamodel'; - import { makeAux, - makeCloud, - makeFlow, pointerDown, pointerMove, pointerUp, @@ -37,34 +33,9 @@ import { type CanvasHarness, } from './canvas-gesture-harness'; -// Materialize a concrete flow + source/sink clouds when the flow-tool drag -// releases, mirroring Editor.handleFlowAttach: it commits the real flow (uid 50) -// into the view and selects it. `extraElements` lets a test keep an unrelated -// pre-existing variable in the view alongside the new flow. -function installFlowMaterializer(h: CanvasHarness, extraElements: readonly ViewElement[] = []): void { - h.callbacks.onMoveFlow.mockImplementation(() => { - const source = makeCloud(51, 50, 200, 200); - const sink = makeCloud(52, 50, 300, 200); - const flow = makeFlow( - 50, - 'New Flow', - [ - { x: 200, y: 200, attachedToUid: 51 }, - { x: 300, y: 200, attachedToUid: 52 }, - ], - { x: 250, y: 200 }, - ); - const elements = [...extraElements, source, sink, flow]; - const view: StockFlowView = { - nextUid: 60, - elements, - viewBox: { x: 0, y: 0, width: 1000, height: 1000 }, - zoom: 1, - useLetteredPolarity: false, - }; - h.setProps({ view, selection: new Set([50]) }); - }); -} +// The harness applies a gesture's commit the way the controller publishes it: +// the drawn flow and its clouds join the view and the flow becomes the +// selection, which is what the name editor for the just-created flow opens on. // Drive the flow tool from empty canvas through to the on-screen name editor. // Returns the live contenteditable node for the just-created flow's name edit. @@ -81,7 +52,6 @@ function createFlowAndEnterNameEdit(h: CanvasHarness): Element { describe('Canvas name-edit lifecycle: just-created flow', () => { it('cancelling the initial name edit of a just-created flow deletes it (checklist 12)', () => { const h = renderCanvas({ elements: [], selectedTool: 'flow' }); - installFlowMaterializer(h); h.clearMountCalls(); const editable = createFlowAndEnterNameEdit(h); @@ -98,7 +68,6 @@ describe('Canvas name-edit lifecycle: just-created flow', () => { it('committing the initial flow name does NOT delete it and clears the latch', () => { const h = renderCanvas({ elements: [], selectedTool: 'flow' }); - installFlowMaterializer(h); h.clearMountCalls(); const editable = createFlowAndEnterNameEdit(h); @@ -110,8 +79,8 @@ describe('Canvas name-edit lifecycle: just-created flow', () => { fireEvent.keyUp(editable, { code: 'Enter' }); }); - // Committing renames the flow (uid 50 != inCreationUid, so it is an existing - // element by now) and must NOT delete it. + // Committing renames the flow (its create already landed as the gesture's + // commit) and must NOT delete it. expect(h.callbacks.onDeleteSelection).not.toHaveBeenCalled(); expect(h.callbacks.onRenameVariable).toHaveBeenCalledTimes(1); // The editor closes on commit. @@ -122,7 +91,6 @@ describe('Canvas name-edit lifecycle: just-created flow', () => { // An unrelated variable that survives the flow creation and is renamed later. const existing = makeAux(9, 'Existing Variable', 600, 600); const h = renderCanvas({ elements: [existing], selectedTool: 'flow' }); - installFlowMaterializer(h, [existing]); h.clearMountCalls(); // 1. Create a flow and COMMIT its name. This clears the creatingFlow latch. @@ -159,7 +127,6 @@ describe('Canvas name-edit lifecycle: just-created flow', () => { it('shift+Enter does NOT commit (it inserts a line break instead)', () => { const h = renderCanvas({ elements: [], selectedTool: 'flow' }); - installFlowMaterializer(h); h.clearMountCalls(); const editable = createFlowAndEnterNameEdit(h); diff --git a/src/diagram/tests/canvas-gestures-preview-commit.test.tsx b/src/diagram/tests/canvas-gestures-preview-commit.test.tsx new file mode 100644 index 000000000..3a09bf2af --- /dev/null +++ b/src/diagram/tests/canvas-gestures-preview-commit.test.tsx @@ -0,0 +1,458 @@ +// Copyright 2026 The Simlin Authors. All rights reserved. +// Use of this source code is governed by the Apache License, +// Version 2.0, that can be found in the LICENSE file. + +// E2 in the DOM: for every gesture that commits an edit, the last preview frame +// the Canvas renders is the frame it renders once the commit lands. The harness +// applies each commit the way the controller publishes it (synchronously, the +// committed elements becoming props.view), does not republish on selection +// changes, and its fixtures pin stock endpoints to faces as production data +// does. Rows port the audit's preview-vs-commit probes (P1-P27); the #830 family +// (P14-P17: stock-attached endpoint drags), M4 (P20: a flow drawn from an +// off-center press) and M5 (P27: a link into a flow whose end is dragged) were +// the diverging ones. +// +// The comparison is the content group's markup with the drop-target highlight +// removed (a preview-only cue) and, for a gesture that hands off to a name +// editor, the labels removed (the editor replaces the new element's label). +// +// What this establishes: preview == commit for the edit-committing gesture kinds +// (moveSelection, slideValve, offsetSegment, flowEndpoint, linkEndpoint, +// linkArc, createFlow, createLink, label). Not covered here: createElement +// (its draft becomes a name editor; canvas-gestures-elements.test.tsx checks the +// created element lands at the release point), rubberBand (it commits a +// selection, not a view), and pan (canvas-gestures-pan-zoom.test.tsx). + +import { describe, it, expect } from '@rstest/core'; + +import type { ViewElement } from '@simlin/core/datamodel'; + +import { GESTURE_KINDS, type Gesture } from '../gesture-planner/types'; +import { + makeAux, + makeCloud, + makeFlow, + makeLink, + makeStock, + pointerDown, + pointerMove, + pointerUp, + renderCanvas, + type CanvasHarness, + type HarnessOptions, +} from './canvas-gesture-harness'; + +type Pt = readonly [number, number]; + +interface Row { + readonly name: string; + readonly kind: Gesture['kind']; + readonly elements: () => readonly ViewElement[]; + readonly selection?: readonly number[]; + readonly selectedTool?: HarnessOptions['selectedTool']; + /** The element the press lands on, or the svg itself. */ + readonly target: (h: CanvasHarness) => Element; + readonly press: Pt; + readonly moves: readonly Pt[]; + /** The gesture hands off to a name editor, whose overlay replaces the new element's label. */ + readonly handoff?: boolean; +} + +// Stock (100,100) -> cloud (300,100), the source on the stock's right face. +function stockCloud(): ViewElement[] { + return [ + makeStock(1, 'stock', 100, 100), + makeCloud(2, 3, 300, 100), + makeFlow( + 3, + 'flow', + [ + { x: 122.5, y: 100, attachedToUid: 1 }, + { x: 300, y: 100, attachedToUid: 2 }, + ], + { x: 200, y: 100 }, + ), + ]; +} + +// Stock src (100,100) -> stock dst (400,100), both endpoints on faces. +function stockStock(): ViewElement[] { + return [ + makeStock(1, 'src', 100, 100), + makeStock(2, 'dst', 400, 100), + makeFlow( + 3, + 'flow', + [ + { x: 122.5, y: 100, attachedToUid: 1 }, + { x: 377.5, y: 100, attachedToUid: 2 }, + ], + { x: 250, y: 100 }, + ), + ]; +} + +const svg = (h: CanvasHarness): Element => h.svg; +const first = (selector: string) => (h: CanvasHarness) => h.query(selector)!; +const nth = (selector: string, i: number) => (h: CanvasHarness) => h.queryAll(selector)[i]; +const valve = (h: CanvasHarness): Element => h.query('g.simlin-flow circle')!.parentElement!; +const sourceGrip = first('g.simlin-flow rect[fill="transparent"]'); + +const ROWS: readonly Row[] = [ + { + name: 'P1 a single aux moved', + kind: 'moveSelection', + elements: () => [makeAux(10, 'a', 100, 100)], + target: first('g.simlin-aux'), + press: [100, 100], + moves: [ + [130, 120], + [160, 140], + ], + }, + { + name: 'P2 a group with a stock and its attached flow, pressed on the selected aux', + kind: 'moveSelection', + elements: () => [...stockCloud(), makeAux(10, 'a', 200, 250)], + selection: [1, 10], + target: first('g.simlin-aux'), + press: [200, 250], + moves: [ + [230, 270], + [260, 290], + ], + }, + { + name: 'P3 a stock moved across its flow routes the flow', + kind: 'moveSelection', + elements: stockCloud, + target: first('g.simlin-stock'), + press: [100, 100], + moves: [ + [100, 130], + [100, 160], + ], + }, + { + name: 'P4 a valve slid along its pipe', + kind: 'slideValve', + elements: stockCloud, + target: valve, + press: [200, 100], + moves: [ + [220, 101], + [240, 101], + ], + }, + { + name: 'P5 a stock -> cloud pipe offset perpendicular', + kind: 'offsetSegment', + elements: stockCloud, + target: valve, + press: [200, 100], + moves: [ + [200, 130], + [202, 160], + ], + }, + { + name: 'P6 a stock -> stock pipe offset into a bracket (#819)', + kind: 'offsetSegment', + elements: stockStock, + target: valve, + press: [250, 100], + moves: [ + [250, 130], + [251, 160], + ], + }, + { + name: 'P7 an interior segment offset', + kind: 'offsetSegment', + elements: () => [ + makeStock(1, 'stock', 100, 100), + makeCloud(2, 3, 500, 300), + makeFlow( + 3, + 'flow', + [ + { x: 122.5, y: 100, attachedToUid: 1 }, + { x: 300, y: 100, attachedToUid: undefined }, + { x: 300, y: 300, attachedToUid: undefined }, + { x: 500, y: 300, attachedToUid: 2 }, + ], + { x: 300, y: 200 }, + ), + ], + target: first('path.simlin-outer'), + press: [300, 260], + moves: [ + [320, 261], + [340, 261], + ], + }, + { + name: 'P9 a sink cloud dragged along its axis', + kind: 'flowEndpoint', + elements: stockCloud, + target: first('path.simlin-cloud'), + press: [300, 100], + moves: [ + [350, 100], + [400, 100], + ], + }, + { + name: 'P10 a sink cloud dragged perpendicular', + kind: 'flowEndpoint', + elements: stockCloud, + target: first('path.simlin-cloud'), + press: [300, 100], + moves: [ + [300, 150], + [300, 200], + ], + }, + { + name: 'P11 a source cloud dragged along its axis', + kind: 'flowEndpoint', + elements: () => [ + makeStock(1, 'stock', 300, 100), + makeCloud(2, 3, 100, 100), + makeFlow( + 3, + 'flow', + [ + { x: 100, y: 100, attachedToUid: 2 }, + { x: 277.5, y: 100, attachedToUid: 1 }, + ], + { x: 188.75, y: 100 }, + ), + ], + target: sourceGrip, + press: [110, 100], + moves: [ + [80, 100], + [50, 100], + ], + }, + { + name: 'P12 a sink cloud dropped on an aligned stock', + kind: 'flowEndpoint', + elements: () => [...stockCloud(), makeStock(4, 'target', 450, 100)], + target: first('path.simlin-cloud'), + press: [300, 100], + moves: [ + [400, 100], + [450, 100], + ], + }, + { + name: 'P13 a sink cloud dropped on a stock below the pipe (H5: it lands on that stock`s face)', + kind: 'flowEndpoint', + elements: () => [...stockCloud(), makeStock(4, 'target', 130, 250)], + target: first('path.simlin-cloud'), + press: [300, 100], + moves: [ + [200, 200], + [130, 250], + ], + }, + { + name: 'P14 #830: a stock-attached sink dragged along its axis into empty space', + kind: 'flowEndpoint', + elements: stockStock, + target: first('path.simlin-arrowhead-flow'), + press: [372, 100], + moves: [ + [340, 100], + [300, 100], + ], + }, + { + name: 'P15 #830: a stock-attached sink dragged perpendicular', + kind: 'flowEndpoint', + elements: stockStock, + target: first('path.simlin-arrowhead-flow'), + press: [372, 100], + moves: [ + [372, 150], + [372, 200], + ], + }, + { + name: 'P16 #830: a stock-attached sink dropped on another stock', + kind: 'flowEndpoint', + elements: () => [...stockStock(), makeStock(4, 'other', 600, 100)], + target: first('path.simlin-arrowhead-flow'), + press: [372, 100], + moves: [ + [500, 100], + [600, 100], + ], + }, + { + name: 'P17 #830: a stock-attached source dragged perpendicular', + kind: 'flowEndpoint', + elements: stockCloud, + target: sourceGrip, + press: [132, 100], + moves: [ + [132, 150], + [132, 200], + ], + }, + { + name: 'P27 M5: a link into a flow whose sink cloud is dragged', + kind: 'flowEndpoint', + elements: () => [...stockCloud(), makeAux(20, 'x', 200, 20), makeLink(21, 20, 3, 30)], + target: first('path.simlin-cloud'), + press: [300, 100], + moves: [ + [300, 160], + [360, 200], + ], + }, + { + name: 'P18 the flow tool from empty space into empty space', + kind: 'createFlow', + elements: () => [makeAux(9, 'unrelated', 700, 700)], + selectedTool: 'flow', + target: svg, + press: [200, 200], + moves: [ + [260, 200], + [320, 200], + ], + handoff: true, + }, + { + name: 'P19 the flow tool from empty space onto a stock', + kind: 'createFlow', + elements: () => [makeStock(4, 'target', 400, 200)], + selectedTool: 'flow', + target: svg, + press: [200, 200], + moves: [ + [300, 200], + [400, 200], + ], + handoff: true, + }, + { + name: 'P20 M4: the flow tool from an off-center press on a stock into empty space', + kind: 'createFlow', + elements: () => [makeStock(1, 'src', 100, 100)], + selectedTool: 'flow', + target: first('g.simlin-stock'), + press: [115, 95], + moves: [ + [200, 95], + [300, 95], + ], + handoff: true, + }, + { + name: 'P21 the flow tool from a stock onto another stock', + kind: 'createFlow', + elements: () => [makeStock(1, 'src', 100, 100), makeStock(2, 'dst', 400, 100)], + selectedTool: 'flow', + target: nth('g.simlin-stock', 0), + press: [115, 95], + moves: [ + [250, 95], + [400, 95], + ], + handoff: true, + }, + { + name: 'P22 the link tool from one aux to another, curving through the release', + kind: 'createLink', + elements: () => [makeAux(1, 'a', 100, 100), makeAux(2, 'b', 300, 100)], + selectedTool: 'link', + target: nth('g.simlin-aux', 0), + press: [100, 100], + moves: [ + [200, 130], + [300, 106], + ], + }, + { + name: 'P23 a link arrowhead reattached to another aux', + kind: 'linkEndpoint', + elements: () => [ + makeAux(1, 'a', 100, 100), + makeAux(2, 'b', 300, 100), + makeAux(3, 'c', 300, 300), + makeLink(4, 1, 2), + ], + target: first('path.simlin-arrowhead-link'), + press: [289, 100], + moves: [ + [300, 200], + [303, 296], + ], + }, + { + name: 'P24 a link curved by its body', + kind: 'linkArc', + elements: () => [makeAux(1, 'a', 100, 100), makeAux(2, 'b', 300, 100), makeLink(4, 1, 2)], + target: first('path.simlin-connector'), + press: [200, 100], + moves: [ + [200, 120], + [200, 140], + ], + }, + { + name: 'P25 a label dragged to the bottom', + kind: 'label', + elements: () => [makeAux(10, 'a', 100, 100)], + target: first('g.simlin-aux text'), + press: [130, 100], + moves: [ + [120, 130], + [100, 160], + ], + }, +]; + +function markup(h: CanvasHarness, stripLabels: boolean): string { + let html = h.query('svg g[transform]')?.innerHTML ?? ''; + html = html.replace(/ ?targetGood| ?targetBad/g, ''); + if (stripLabels) { + html = html.replace(/<\/g>/g, ''); + } + return html; +} + +describe('Canvas: the last preview frame is the committed frame (E2)', () => { + it('covers every edit-committing gesture kind', () => { + const notCommitting = new Set(['createElement', 'rubberBand', 'pan']); + const covered = new Set(ROWS.map((r) => r.kind)); + expect(GESTURE_KINDS.filter((k) => !notCommitting.has(k) && !covered.has(k))).toEqual([]); + }); + + for (const row of ROWS) { + it(`${row.kind}: ${row.name}`, () => { + const h = renderCanvas({ + elements: row.elements(), + selection: new Set(row.selection ?? []), + selectedTool: row.selectedTool, + }); + h.clearMountCalls(); + + const target = row.target(h); + pointerDown(target, row.press[0], row.press[1]); + for (const [x, y] of row.moves) { + pointerMove(row.kind === 'label' ? target : h.svg, x, y, { buttons: 1 }); + } + const [x, y] = row.moves[row.moves.length - 1]; + const preview = markup(h, !!row.handoff); + pointerUp(h.svg, x, y); + + expect(h.callbacks.onCommitGesture).toHaveBeenCalledTimes(1); + expect(markup(h, !!row.handoff)).toBe(preview); + }); + } +}); diff --git a/src/diagram/tests/canvas-gestures-readonly.test.tsx b/src/diagram/tests/canvas-gestures-readonly.test.tsx index efd6c923c..925c38e5b 100644 --- a/src/diagram/tests/canvas-gestures-readonly.test.tsx +++ b/src/diagram/tests/canvas-gestures-readonly.test.tsx @@ -4,14 +4,13 @@ // Reconciler-level gesture tests for the Canvas `readOnly` prop (issue #935). // -// The mutation gating itself lives in the Editor (it hands a read-only Canvas -// no-op callbacks and no selectedTool); what Canvas owns is the one editing -// entry point it opens ITSELF -- the inline label editor on a label -// double-click. Without the prop that editor still opened while the eventual -// onRenameVariable commit silently no-op'd: the exact "editable but unsavable" -// deception the issue is about. These tests pin that the editor never opens -// read-only, while selection (a read capability) still works and gestures -// still raise their callbacks (the host decides what they do). +// The Editor hands a read-only Canvas a no-op commit callback and no +// selectedTool. The Canvas owns two things itself: the inline label editor a +// label double-click opens, which must never open read-only (otherwise it LOOKS +// editable while the eventual onRenameVariable commit silently no-ops, the +// deception the issue is about), and the drag preview, which must not show a +// move the release could never commit. Selection, a read capability, still +// works. import { describe, it, expect } from '@rstest/core'; @@ -61,18 +60,19 @@ describe('Canvas gestures: readOnly', () => { expect([...lastCall]).toEqual([10]); }); - it('a drag still raises onMoveSelection -- the host decides it is a no-op', () => { - // Deliberate layering: Canvas raises the gesture callback; the Editor - // substitutes a no-op when read-only. Pinning this keeps the gate's - // location honest (Editor-side, not silently duplicated in Canvas). + it('a drag previews no move and commits nothing (audit M9)', () => { + // The Editor also hands a read-only Canvas a no-op commit callback; the + // planner's own read-only arm keeps the preview from showing a move the + // release could never commit. const h = renderCanvas({ elements: [makeAux(10, 'foo', 100, 100)], selection: new Set([10]), readOnly: true }); h.clearMountCalls(); const aux = h.query('.simlin-aux')!; pointerDown(aux, 100, 100); pointerMove(aux, 200, 200, { buttons: 1 }); + expect(h.query('.simlin-aux circle')?.getAttribute('cx')).toBe('100'); pointerUp(h.svg, 200, 200); - expect(h.callbacks.onMoveSelection).toHaveBeenCalled(); + expect(h.callbacks.onCommitGesture).not.toHaveBeenCalled(); }); }); diff --git a/src/diagram/tests/canvas-gestures-selection.test.tsx b/src/diagram/tests/canvas-gestures-selection.test.tsx index a5170c4e6..495eb3ef2 100644 --- a/src/diagram/tests/canvas-gestures-selection.test.tsx +++ b/src/diagram/tests/canvas-gestures-selection.test.tsx @@ -2,19 +2,17 @@ // Use of this source code is governed by the Apache License, // Version 2.0, that can be found in the LICENSE file. -// Reconciler-level gesture tests for selection behavior of the React `Canvas` -// (Piece 1a of the canvas-interaction migration; see -// docs/design-plans/2026-06-07-canvas-interaction-migration.md). These pin the -// CURRENT behavior of click-select, drag-select, modifier toggle, -// deferred-single-select collapse, group drag, and pointercancel reset, and are -// the gate for the subsequent class->tagged-union and class->hooks migrations. -// They assert only on prop-callback payloads and rendered DOM -- never on Canvas -// instance internals -- so they must survive Canvas becoming a function -// component unchanged. +// Reconciler-level gesture tests for selection behavior of the React `Canvas`: +// click-select, drag-select, modifier toggle, deferred-single-select collapse, +// group drag, and pointercancel. They assert only on prop-callback payloads and +// rendered DOM, never on Canvas internals. The press table itself is +// gesture-planner-classify.test.ts; these establish that real pointer events +// reach it and that the Canvas carries out its outcomes. import { describe, it, expect } from '@rstest/core'; import type { Mock } from '@rstest/core'; +import type { GestureCommit } from '../drawing/Canvas'; import { makeAux, makeStock, @@ -50,24 +48,29 @@ describe('Canvas gestures: empty-canvas click (checklist 1)', () => { expect(lastSelection(h.callbacks.onSetSelection)).toEqual([]); }); - // Pinned surprise: the empty-canvas press has NO sub-threshold guard. ANY - // pointermove (even a 2px wobble) calls handleDragSelection, which sets - // isDragSelecting+dragSelectionPoint unconditionally (Canvas.handleDragSelection - // ~line 1674), so a drag rect renders and pointer-up routes through the - // drag-select path -- unlike the element-press path, which DOES threshold (see - // checklist 4). This is current behavior, not a harness artifact. - it('a sub-threshold wobble on empty canvas still renders a drag rect (no threshold here)', () => { + // E1: a wobble within the click threshold is a click, on the empty canvas as + // on an element: no rubber band previews, and the release clears the selection. + it('a sub-threshold wobble on empty canvas previews no drag rect and settles as a click', () => { const h = renderCanvas({ elements: [makeAux(10, 'foo', 100, 100)], selection: new Set([10]) }); h.clearMountCalls(); pointerDown(h.svg, 500, 500); pointerMove(h.svg, 502, 502, { buttons: 1 }); - expect(h.query(DRAG_RECT)).not.toBeNull(); + expect(h.query(DRAG_RECT)).toBeNull(); pointerUp(h.svg, 502, 502); - // Empty rubber-band selects nothing, replacing the selection. expect(lastSelection(h.callbacks.onSetSelection)).toEqual([]); }); + + it('a rubber band previews its membership while it is dragged', () => { + const h = renderCanvas({ elements: [makeAux(1, 'a', 100, 100)] }); + h.clearMountCalls(); + + pointerDown(h.svg, 50, 50); + pointerMove(h.svg, 200, 200, { buttons: 1 }); + expect(h.query('.simlin-aux')?.getAttribute('class')).toContain('simlin-selected'); + expect(h.callbacks.onSetSelection).not.toHaveBeenCalled(); + }); }); describe('Canvas gestures: drag-select (checklist 2)', () => { @@ -131,10 +134,9 @@ describe('Canvas gestures: element click (checklist 4)', () => { // Immediate selection replace with the clicked uid. expect(h.callbacks.onSetSelection).toHaveBeenCalledWith(new Set([1])); - // Sub-threshold wobble does not nudge the element (isDragMovement gate - // ~line 1109) ... - expect(h.callbacks.onMoveSelection).not.toHaveBeenCalled(); - // ... and opens the variable-details panel instead (shouldShowVariableDetails). + // A sub-threshold wobble does not nudge the element (E1) ... + expect(h.callbacks.onCommitGesture).not.toHaveBeenCalled(); + // ... and opens the variable-details panel instead. expect(h.callbacks.onShowVariableDetails).toHaveBeenCalledTimes(1); }); }); @@ -199,9 +201,14 @@ describe('Canvas gestures: group drag (checklist 7)', () => { // The deferred single-select is abandoned because a drag occurred, so the // group selection is preserved (no onSetSelection from this gesture). expect(h.callbacks.onSetSelection).not.toHaveBeenCalled(); - // The move is committed with the canvas-space delta (mouseDown - pointerUp). - expect(h.callbacks.onMoveSelection).toHaveBeenCalledTimes(1); - expect(h.callbacks.onMoveSelection.mock.calls[0][0]).toEqual({ x: -60, y: -60 }); + // One commit moves both auxes by the pointer travel. + expect(h.callbacks.onCommitGesture).toHaveBeenCalledTimes(1); + const commit = h.callbacks.onCommitGesture.mock.calls[0][0] as GestureCommit; + expect(commit.elements.map((el) => [el.uid, el.x, el.y])).toEqual([ + [1, 160, 160], + [2, 260, 260], + ]); + expect([...commit.selection].sort()).toEqual([1, 2]); }); }); @@ -225,22 +232,20 @@ describe('Canvas gestures: pointercancel mid-gesture (checklist 16)', () => { expect(lastSelection(h.callbacks.onSetSelection)).toEqual([]); }); - // Pinned surprise: pointercancel and pointerup are the SAME handler on the svg - // (onPointerCancel and onPointerUp both -> handlePointerCancel, ~lines - // 2515-2516), so a cancel mid element-drag COMMITS the move just like a - // release rather than discarding it. Documenting current behavior; the - // post-migration code must preserve it. - it('cancelling an element drag commits the in-progress move (cancel == up today)', () => { + // E5 (audit M3): a pointercancel is not a release. The drag is dropped + // without committing, and the element renders where it was. + it('cancelling an element drag commits nothing and restores the element', () => { const h = renderCanvas({ elements: [makeAux(10, 'foo', 100, 100)] }); h.clearMountCalls(); const node = h.query('.simlin-aux')!; pointerDown(node, 100, 100); pointerMove(h.svg, 160, 160, { buttons: 1 }); + expect(h.query('.simlin-aux circle')?.getAttribute('cx')).toBe('160'); pointerCancel(h.svg, 160, 160); - expect(h.callbacks.onMoveSelection).toHaveBeenCalledTimes(1); - expect(h.callbacks.onMoveSelection.mock.calls[0][0]).toEqual({ x: -60, y: -60 }); + expect(h.callbacks.onCommitGesture).not.toHaveBeenCalled(); + expect(h.query('.simlin-aux circle')?.getAttribute('cx')).toBe('100'); }); }); diff --git a/src/diagram/tests/canvas-host-affordances.test.tsx b/src/diagram/tests/canvas-host-affordances.test.tsx new file mode 100644 index 000000000..27d099252 --- /dev/null +++ b/src/diagram/tests/canvas-host-affordances.test.tsx @@ -0,0 +1,123 @@ +// Copyright 2026 The Simlin Authors. All rights reserved. +// Use of this source code is governed by the Apache License, +// Version 2.0, that can be found in the LICENSE file. + +// The three Canvas affordances the host's edit queue relies on, driven through +// real pointer and key events on a rendered Canvas: +// +// - `pressesDisabled`: while the host has an undo or redo queued, a press on +// empty canvas, on an element, or on a label starts nothing -- a gesture +// planned on the view the undo replaces could not commit; +// - `newVariableName`: a creation tool takes its default name from the host, +// which allocates against pending creates; +// - a refused name: `onCreateVariable`/`onRenameVariable` returning a message +// keeps the inline name editor open showing it, and an accepted retry closes +// it. + +import { describe, it, expect } from '@rstest/core'; + +import { act, fireEvent } from '@testing-library/react'; + +import { + makeAux, + pointerDown, + pointerMove, + pointerUp, + renderCanvas, + type CanvasHarness, +} from './canvas-gesture-harness'; + +function commitName(h: CanvasHarness): void { + const editable = h.query('[contenteditable]'); + expect(editable).not.toBeNull(); + act(() => { + fireEvent.keyDown(editable!, { code: 'Enter' }); + fireEvent.keyUp(editable!, { code: 'Enter' }); + }); +} + +describe('Canvas pressesDisabled', () => { + it('a press on empty canvas with a creation tool armed stages nothing', () => { + const h = renderCanvas({ elements: [makeAux(1, 'a', 100, 100)], selectedTool: 'aux', pressesDisabled: true }); + h.clearMountCalls(); + pointerDown(h.svg, 300, 300); + pointerMove(h.svg, 320, 320, { buttons: 1 }); + pointerUp(h.svg, 320, 320); + expect(h.callbacks.onSetSelection).not.toHaveBeenCalled(); + expect(h.callbacks.onCreateVariable).not.toHaveBeenCalled(); + expect(h.query('[contenteditable]')).toBeNull(); + }); + + it('a press and drag on an element neither selects nor moves it', () => { + const h = renderCanvas({ elements: [makeAux(1, 'a', 100, 100)], pressesDisabled: true }); + h.clearMountCalls(); + const aux = h.query('.simlin-aux circle')!; + pointerDown(aux, 100, 100); + pointerMove(h.svg, 160, 160, { buttons: 1 }); + pointerUp(h.svg, 160, 160); + expect(h.callbacks.onSetSelection).not.toHaveBeenCalled(); + expect(h.callbacks.onCommitGesture).not.toHaveBeenCalled(); + }); + + it('control: the same press and drag moves the element when presses are enabled', () => { + const h = renderCanvas({ elements: [makeAux(1, 'a', 100, 100)] }); + h.clearMountCalls(); + const aux = h.query('.simlin-aux circle')!; + pointerDown(aux, 100, 100); + pointerMove(h.svg, 160, 160, { buttons: 1 }); + pointerUp(h.svg, 160, 160); + expect(h.callbacks.onCommitGesture).toHaveBeenCalledTimes(1); + }); +}); + +describe('Canvas newVariableName', () => { + it("a creation tool takes the host's default name", () => { + const h = renderCanvas({ elements: [], selectedTool: 'aux', newVariableName: (base) => `${base} 7` }); + h.clearMountCalls(); + pointerDown(h.svg, 200, 200); + pointerUp(h.svg, 200, 200); + commitName(h); + expect(h.callbacks.onCreateVariable).toHaveBeenCalledTimes(1); + expect(h.callbacks.onCreateVariable.mock.calls[0][0].name).toBe('New Variable 7'); + }); +}); + +describe('Canvas name refusal', () => { + it('a refused create keeps the name editor open with the message; an accepted retry closes it', () => { + const h = renderCanvas({ elements: [], selectedTool: 'aux' }); + h.clearMountCalls(); + h.callbacks.onCreateVariable.mockReturnValueOnce("A variable named 'New Variable' already exists"); + pointerDown(h.svg, 200, 200); + pointerUp(h.svg, 200, 200); + + commitName(h); + expect(h.callbacks.onCreateVariable).toHaveBeenCalledTimes(1); + expect(h.query('[contenteditable]')).not.toBeNull(); + expect(h.query('[role="alert"]')?.textContent).toBe("A variable named 'New Variable' already exists"); + + commitName(h); + expect(h.callbacks.onCreateVariable).toHaveBeenCalledTimes(2); + expect(h.query('[contenteditable]')).toBeNull(); + expect(h.query('[role="alert"]')).toBeNull(); + }); + + it('a refused rename keeps the name editor open with the message', () => { + const h = renderCanvas({ elements: [makeAux(9, 'Existing Variable', 600, 600)] }); + h.clearMountCalls(); + h.callbacks.onRenameVariable.mockReturnValue("A variable named 'a' already exists"); + const text = h.query('.simlin-aux text')!; + act(() => { + fireEvent.doubleClick(text, { clientX: 630, clientY: 600 }); + }); + h.setProps({ selection: new Set([9]) }); + const editable = h.query('[contenteditable]'); + expect(editable).not.toBeNull(); + // The Canvas hands every commit to the host (the host decides that an + // unchanged name is a no-op), so committing the seeded name is enough to + // exercise the refusal path. + commitName(h); + expect(h.callbacks.onRenameVariable).toHaveBeenCalledTimes(1); + expect(h.query('[contenteditable]')).not.toBeNull(); + expect(h.query('[role="alert"]')?.textContent).toBe("A variable named 'a' already exists"); + }); +}); diff --git a/src/diagram/tests/canvas-interaction.test.ts b/src/diagram/tests/canvas-interaction.test.ts deleted file mode 100644 index 26732b9cd..000000000 --- a/src/diagram/tests/canvas-interaction.test.ts +++ /dev/null @@ -1,327 +0,0 @@ -// Copyright 2026 The Simlin Authors. All rights reserved. -// Use of this source code is governed by the Apache License, -// Version 2.0, that can be found in the LICENSE file. - -// Table-driven tests for the pure Canvas interaction model -// (drawing/canvas-interaction.ts). These exercise the discrete-gesture -// transitions and the pure geometry helpers the shell composes, asserting both -// resulting state and emitted effects. - -import { describe, it, expect } from '@rstest/core'; - -import { - AuxViewElement, - CloudViewElement, - FlowViewElement, - LinkViewElement, - ModuleViewElement, - StockViewElement, - UID, - ViewElement, -} from '@simlin/core/datamodel'; - -import { - computeDragSelection, - decideMouseDownSelection, - idleState, - InteractionContext, - InteractionEffect, - InteractionState, - isDrag, - isInDragSelectRect, - labelSideForPointer, - reduceInteraction, - resolveDeferredSelection, -} from '../drawing/canvas-interaction'; - -function makeAux(uid: number, x = 100, y = 100): AuxViewElement { - return { - type: 'aux', - uid, - var: undefined, - x, - y, - name: `aux${uid}`, - ident: `aux${uid}`, - labelSide: 'right', - isZeroRadius: false, - }; -} - -function makeStock(uid: number, x = 0, y = 0): StockViewElement { - return { - type: 'stock', - uid, - var: undefined, - x, - y, - name: `stock${uid}`, - ident: `stock${uid}`, - labelSide: 'bottom', - isZeroRadius: false, - inflows: [], - outflows: [], - }; -} - -function makeCloud(uid: number, x = 0, y = 0): CloudViewElement { - return { type: 'cloud', uid, flowUid: -1, x, y, isZeroRadius: false, ident: undefined }; -} - -function makeModule(uid: number, x = 0, y = 0): ModuleViewElement { - return { - type: 'module', - uid, - var: undefined, - x, - y, - name: `m${uid}`, - ident: `m${uid}`, - labelSide: 'bottom', - isZeroRadius: false, - }; -} - -function makeFlow(uid: number, x = 0, y = 0): FlowViewElement { - return { - type: 'flow', - uid, - var: undefined, - x, - y, - name: `flow${uid}`, - ident: `flow${uid}`, - labelSide: 'bottom', - points: [ - { x: x - 10, y, attachedToUid: undefined }, - { x: x + 10, y, attachedToUid: undefined }, - ], - isZeroRadius: false, - }; -} - -function makeLink(uid: number, fromUid: number, toUid: number): LinkViewElement { - return { - type: 'link', - uid, - fromUid, - toUid, - arc: 0, - multiPoint: undefined, - isStraight: false, - polarity: undefined, - x: 0, - y: 0, - isZeroRadius: false, - ident: undefined, - }; -} - -const ctx = (selection: Iterable): InteractionContext => ({ - selection: new Set(selection), -}); - -// Convenience accessor for asserting on emitted effects. -const effectKinds = (effects: readonly InteractionEffect[]): string[] => effects.map((e) => e.kind); - -describe('decideMouseDownSelection', () => { - it('replaces selection when clicking an unselected element without modifier', () => { - const r = decideMouseDownSelection(new Set([1, 2]), 5, false); - expect(r.newSelection).toEqual(new Set([5])); - expect(r.deferSingleSelect).toBeUndefined(); - }); - - it('toggles in with modifier when not selected', () => { - const r = decideMouseDownSelection(new Set([1]), 2, true); - expect(r.newSelection).toEqual(new Set([1, 2])); - }); - - it('toggles out with modifier when already selected', () => { - const r = decideMouseDownSelection(new Set([1, 2]), 2, true); - expect(r.newSelection).toEqual(new Set([1])); - }); - - it('defers when clicking an already-selected element without modifier', () => { - const r = decideMouseDownSelection(new Set([1, 2]), 2, false); - expect(r.newSelection).toBeUndefined(); - expect(r.deferSingleSelect).toBe(2); - }); -}); - -describe('resolveDeferredSelection', () => { - it('collapses to the deferred element when no drag occurred', () => { - expect(resolveDeferredSelection(7, false)).toEqual(new Set([7])); - }); - it('preserves the group (returns undefined) when a drag occurred', () => { - expect(resolveDeferredSelection(7, true)).toBeUndefined(); - }); - it('returns undefined when nothing was deferred', () => { - expect(resolveDeferredSelection(undefined, false)).toBeUndefined(); - }); -}); - -describe('isDrag threshold', () => { - it('sub-threshold wobble is a click, not a drag', () => { - // 4px screen movement at zoom 1 is below the 5px threshold - expect(isDrag({ x: 4, y: 0 }, 1)).toBe(false); - }); - it('over-threshold movement is a drag', () => { - expect(isDrag({ x: 6, y: 0 }, 1)).toBe(true); - }); - it('scales with zoom: small model delta is a drag when zoomed in', () => { - expect(isDrag({ x: 3, y: 0 }, 1)).toBe(false); - expect(isDrag({ x: 3, y: 0 }, 2)).toBe(true); - }); - it('undefined delta is never a drag', () => { - expect(isDrag(undefined, 10)).toBe(false); - }); -}); - -describe('labelSideForPointer quadrants', () => { - const center = { x: 100, y: 100 }; - it('pointer to the left -> label on the left', () => { - expect(labelSideForPointer(center, { x: 0, y: 100 })).toBe('left'); - }); - it('pointer to the right -> label on the right', () => { - expect(labelSideForPointer(center, { x: 200, y: 100 })).toBe('right'); - }); - it('pointer above -> label on top', () => { - expect(labelSideForPointer(center, { x: 100, y: 0 })).toBe('top'); - }); - it('pointer below -> label on the bottom', () => { - expect(labelSideForPointer(center, { x: 100, y: 200 })).toBe('bottom'); - }); - - // Pin the inclusive/exclusive boundary semantics, where the `<` vs `<=` - // distinctions live. The predicates are `(-45 < a <= 45) -> left`, - // `(45 < a <= 135) -> top`, `(-135 < a <= -45) -> bottom`, else `right`, - // with a = atan2(cy - py, cx - px) in degrees. Each boundary belongs to the - // quadrant whose comparison is `<=`. - it('angle exactly 45deg belongs to left (a <= 45)', () => { - // (cy-py)=100, (cx-px)=100 -> atan2(100, 100) = 45 - expect(labelSideForPointer(center, { x: 0, y: 0 })).toBe('left'); - }); - it('angle exactly 135deg belongs to top (a <= 135)', () => { - // (cy-py)=100, (cx-px)=-100 -> atan2(100, -100) = 135 - expect(labelSideForPointer(center, { x: 200, y: 0 })).toBe('top'); - }); - it('angle exactly -45deg belongs to bottom (a <= -45)', () => { - // (cy-py)=-100, (cx-px)=100 -> atan2(-100, 100) = -45 - expect(labelSideForPointer(center, { x: 0, y: 200 })).toBe('bottom'); - }); - it('angle exactly -135deg belongs to right (else branch: -135 is not > -135)', () => { - // (cy-py)=-100, (cx-px)=-100 -> atan2(-100, -100) = -135 - expect(labelSideForPointer(center, { x: 200, y: 200 })).toBe('right'); - }); -}); - -describe('drag-select rectangle membership', () => { - const rect = { left: 0, right: 100, top: 0, bottom: 100 }; - const auxHitNever = () => false; - - it('selects a stock whose center is inside', () => { - expect(isInDragSelectRect(makeStock(1, 50, 50), rect, auxHitNever)).toBe(true); - }); - it('rejects a stock whose center is outside', () => { - expect(isInDragSelectRect(makeStock(1, 200, 50), rect, auxHitNever)).toBe(false); - }); - it('selects a cloud / flow / module / alias by center containment', () => { - expect(isInDragSelectRect(makeCloud(1, 50, 50), rect, auxHitNever)).toBe(true); - expect(isInDragSelectRect(makeFlow(2, 50, 50), rect, auxHitNever)).toBe(true); - expect(isInDragSelectRect(makeModule(3, 50, 50), rect, auxHitNever)).toBe(true); - }); - it('aux is selected when a rectangle corner hits its circle even if center is outside', () => { - const aux = makeAux(1, 200, 200); - expect(isInDragSelectRect(aux, rect, auxHitNever)).toBe(false); - expect(isInDragSelectRect(aux, rect, () => true)).toBe(true); - }); - it('never selects links', () => { - expect(isInDragSelectRect(makeLink(9, 1, 2), rect, () => true)).toBe(false); - }); - - it('computeDragSelection collects every contained element', () => { - const elements: ViewElement[] = [ - makeStock(1, 50, 50), - makeStock(2, 500, 500), - makeAux(3, 10, 10), - makeLink(9, 1, 3), - ]; - const result = computeDragSelection(elements, rect, auxHitNever); - expect(result).toEqual(new Set([1, 3])); - }); -}); - -// Every branch the reducer still owns is shell-driven: the shell raises the -// matching event and executes the returned effects. Element / arrowhead / source -// press resolution is intentionally NOT in the reducer (it is geometry-dominated -// and lives in Canvas.handleSetSelection), so there is no elementPointerDown -// event to test here. -describe('reduceInteraction: canvas press (shell-driven)', () => { - it('touch/shift press enters panning, no selection change', () => { - const r = reduceInteraction(idleState, { kind: 'canvasPointerDown', pan: true }, ctx([1])); - expect(r.state).toEqual({ mode: 'panning' }); - expect(r.effects).toEqual([]); - }); - it('plain press enters drag-selecting', () => { - const r = reduceInteraction(idleState, { kind: 'canvasPointerDown', pan: false }, ctx([])); - expect(r.state).toEqual({ mode: 'dragSelecting' }); - }); -}); - -describe('reduceInteraction: creation tools (shell-driven)', () => { - it('aux/stock/module tool stages editing-on-pointer-up and captures pointer', () => { - const r = reduceInteraction(idleState, { kind: 'createToolPointerDown', tool: 'aux' }, ctx([])); - expect(r.state).toEqual({ mode: 'editingName', onPointerUp: true, creatingFlow: false }); - expect(effectKinds(r.effects)).toEqual(['capturePointer']); - }); - - it('flow tool enters arrowhead drag of an in-creation flow', () => { - // The mode carries only endpoint + pointerType: the in-creation companion is - // the concrete element in shell state (CanvasState.inCreation), not a union - // field. - const r = reduceInteraction(idleState, { kind: 'flowToolPointerDown', pointerType: 'mouse' }, ctx([])); - expect(r.state).toEqual({ - mode: 'movingEndpoint', - endpoint: 'arrow', - pointerType: 'mouse', - }); - }); -}); - -describe('reduceInteraction: pinch enter/exit (shell-driven)', () => { - it('pinchStart captures the fixed pinch reference', () => { - const r = reduceInteraction( - idleState, - { kind: 'pinchStart', initialDistance: 120, initialZoom: 1.5, modelPoint: { x: 10, y: 20 } }, - ctx([]), - ); - expect(r.state).toEqual({ mode: 'pinching', initialDistance: 120, initialZoom: 1.5, modelPoint: { x: 10, y: 20 } }); - expect(r.effects).toEqual([]); - }); - - it('pinchEnd returns to idle for a clean restart', () => { - const pinching: InteractionState = { - mode: 'pinching', - initialDistance: 120, - initialZoom: 1.5, - modelPoint: { x: 10, y: 20 }, - }; - const r = reduceInteraction(pinching, { kind: 'pinchEnd' }, ctx([])); - expect(r.state).toEqual({ mode: 'idle' }); - expect(r.effects).toEqual([]); - }); -}); - -describe('reduceInteraction: label drag start (shell-driven)', () => { - it('enters movingLabel with the resolved side', () => { - const r = reduceInteraction(idleState, { kind: 'labelDragStart', side: 'left' }, ctx([5])); - expect(r.state).toEqual({ mode: 'movingLabel', side: 'left' }); - expect(r.effects).toEqual([]); - }); -}); - -// NOTE: element / arrowhead / source press resolution is intentionally absent -// from the reducer (no elementPointerDown event). It is geometry-dominated and -// lives in Canvas.handleSetSelection (composing decideMouseDownSelection / -// resolveSelectionForReattachment and building the union variant directly); the -// reconciler-level gesture suite (canvas-gestures-elements.test.tsx) covers it. diff --git a/src/diagram/tests/canvas-render-stability.test.tsx b/src/diagram/tests/canvas-render-stability.test.tsx new file mode 100644 index 000000000..0498bbb05 --- /dev/null +++ b/src/diagram/tests/canvas-render-stability.test.tsx @@ -0,0 +1,89 @@ +// Copyright 2026 The Simlin Authors. All rights reserved. +// Use of this source code is governed by the Apache License, +// Version 2.0, that can be found in the LICENSE file. + +// A drag frame re-renders only the elements whose props change. The element +// components are memo'd, which holds only while every callback the Canvas hands +// them keeps its identity across renders. Each drawing component's inner render +// function is swapped for a counting wrapper before the Canvas mounts; the memo +// wrapper and its default shallow comparison stay the production ones, so a +// render the memo skips is a render the counter does not record. +// +// What this does not establish: how long a frame takes on a large model (the +// C-LEARN timing is measured outside the suite), or a module's double-click +// handler, which takes the same stable-callback path but is not rendered here. + +import { describe, it, expect, afterAll } from '@rstest/core'; + +import * as React from 'react'; + +import { Aux } from '../drawing/Auxiliary'; +import { Connector } from '../drawing/Connector'; +import { Flow } from '../drawing/Flow'; +import { Stock } from '../drawing/Stock'; +import { + makeAux, + makeCloud, + makeFlow, + makeLink, + makeStock, + pointerDown, + pointerMove, + pointerUp, + renderCanvas, +} from './canvas-gesture-harness'; + +const renders: number[] = []; + +type MemoObject = { type: (props: { element: { uid: number } }) => React.ReactNode }; +const restores: Array<() => void> = []; +for (const component of [Aux, Stock, Flow, Connector] as unknown as MemoObject[]) { + const inner = component.type; + component.type = function Counted(props) { + renders.push(props.element.uid); + return inner(props); + }; + restores.push(() => { + component.type = inner; + }); +} + +afterAll(() => { + for (const restore of restores) { + restore(); + } +}); + +describe('Canvas drag frames re-render only what they change', () => { + it('a drag frame re-renders the dragged aux and no unchanged aux, stock, flow or link', () => { + const h = renderCanvas({ + elements: [ + makeStock(1, 'stock', 100, 100), + makeCloud(2, 3, 300, 100), + makeFlow( + 3, + 'flow', + [ + { x: 122.5, y: 100, attachedToUid: 1 }, + { x: 300, y: 100, attachedToUid: 2 }, + ], + { x: 200, y: 100 }, + ), + makeAux(10, 'a', 100, 400), + makeAux(11, 'b', 400, 400), + makeLink(20, 11, 1), + ], + }); + h.clearMountCalls(); + // Every counted component rendered at mount, so the counters are live. + expect(new Set(renders)).toEqual(new Set([1, 3, 10, 11, 20])); + + pointerDown(h.queryAll('g.simlin-aux')[0], 100, 400); + pointerMove(h.svg, 150, 420, { buttons: 1 }); + renders.length = 0; + pointerMove(h.svg, 170, 430, { buttons: 1 }); + expect(renders).toContain(10); + expect(renders.filter((uid) => uid !== 10)).toEqual([]); + pointerUp(h.svg, 170, 430); + }); +}); diff --git a/src/diagram/tests/cloud-utils.test.ts b/src/diagram/tests/cloud-utils.test.ts deleted file mode 100644 index 34ca60129..000000000 --- a/src/diagram/tests/cloud-utils.test.ts +++ /dev/null @@ -1,166 +0,0 @@ -// Copyright 2026 The Simlin Authors. All rights reserved. -// Use of this source code is governed by the Apache License, -// Version 2.0, that can be found in the LICENSE file. - -import { describe, it, expect } from '@rstest/core'; - -import { FlowViewElement, CloudViewElement } from '@simlin/core/datamodel'; - -import { isCloudOnSourceSide, isCloudOnSinkSide } from '../drawing/cloud-utils'; - -function makeFlow( - uid: number, - x: number, - y: number, - points: Array<{ x: number; y: number; attachedToUid?: number }>, -): FlowViewElement { - return { - type: 'flow', - uid, - name: 'TestFlow', - ident: 'test_flow', - var: undefined, - x, - y, - labelSide: 'center', - points: points.map((p) => ({ x: p.x, y: p.y, attachedToUid: p.attachedToUid })), - isZeroRadius: false, - }; -} - -function makeCloud(uid: number, flowUid: number, x: number, y: number): CloudViewElement { - return { - type: 'cloud', - uid, - flowUid, - x, - y, - isZeroRadius: false, - ident: undefined, - }; -} - -describe('Cloud to stock attachment', () => { - const stockUid = 1; - const flowUid = 2; - const sourceCloudUid = 3; - const sinkCloudUid = 4; - - describe('isCloudOnSourceSide', () => { - it('should return true when cloud is attached to first point of flow', () => { - const cloud = makeCloud(sourceCloudUid, flowUid, 100, 100); - const flow = makeFlow(flowUid, 150, 100, [ - { x: 100, y: 100, attachedToUid: sourceCloudUid }, - { x: 200, y: 100, attachedToUid: stockUid }, - ]); - - expect(isCloudOnSourceSide(cloud, flow)).toBe(true); - }); - - it('should return false when cloud is attached to last point of flow', () => { - const cloud = makeCloud(sinkCloudUid, flowUid, 200, 100); - const flow = makeFlow(flowUid, 150, 100, [ - { x: 100, y: 100, attachedToUid: stockUid }, - { x: 200, y: 100, attachedToUid: sinkCloudUid }, - ]); - - expect(isCloudOnSourceSide(cloud, flow)).toBe(false); - }); - - it('should return false when cloud is not attached to the flow', () => { - const cloud = makeCloud(99, flowUid, 300, 100); - const flow = makeFlow(flowUid, 150, 100, [ - { x: 100, y: 100, attachedToUid: stockUid }, - { x: 200, y: 100, attachedToUid: sinkCloudUid }, - ]); - - expect(isCloudOnSourceSide(cloud, flow)).toBe(false); - }); - }); - - describe('isCloudOnSinkSide', () => { - it('should return true when cloud is attached to last point of flow', () => { - const cloud = makeCloud(sinkCloudUid, flowUid, 200, 100); - const flow = makeFlow(flowUid, 150, 100, [ - { x: 100, y: 100, attachedToUid: stockUid }, - { x: 200, y: 100, attachedToUid: sinkCloudUid }, - ]); - - expect(isCloudOnSinkSide(cloud, flow)).toBe(true); - }); - - it('should return false when cloud is attached to first point of flow', () => { - const cloud = makeCloud(sourceCloudUid, flowUid, 100, 100); - const flow = makeFlow(flowUid, 150, 100, [ - { x: 100, y: 100, attachedToUid: sourceCloudUid }, - { x: 200, y: 100, attachedToUid: stockUid }, - ]); - - expect(isCloudOnSinkSide(cloud, flow)).toBe(false); - }); - - it('should return false when cloud is not attached to the flow', () => { - const cloud = makeCloud(99, flowUid, 300, 100); - const flow = makeFlow(flowUid, 150, 100, [ - { x: 100, y: 100, attachedToUid: stockUid }, - { x: 200, y: 100, attachedToUid: sinkCloudUid }, - ]); - - expect(isCloudOnSinkSide(cloud, flow)).toBe(false); - }); - }); - - describe('invalid flow handling', () => { - it('should throw if flow has fewer than 2 points', () => { - const cloud = makeCloud(sourceCloudUid, flowUid, 100, 100); - const invalidFlow = makeFlow(flowUid, 100, 100, [{ x: 100, y: 100, attachedToUid: sourceCloudUid }]); - - expect(() => isCloudOnSourceSide(cloud, invalidFlow)).toThrow('has fewer than 2 points'); - expect(() => isCloudOnSinkSide(cloud, invalidFlow)).toThrow('has fewer than 2 points'); - }); - }); - - describe('cloud attached to middle point', () => { - it('should return false for both source and sink when cloud is attached to middle point', () => { - const middleCloudUid = 5; - const cloud = makeCloud(middleCloudUid, flowUid, 150, 100); - // 3-point flow with cloud attached to the middle point (not at source or sink) - const flow = makeFlow(flowUid, 150, 100, [ - { x: 100, y: 100, attachedToUid: stockUid }, - { x: 150, y: 100, attachedToUid: middleCloudUid }, - { x: 200, y: 100, attachedToUid: sinkCloudUid }, - ]); - - expect(isCloudOnSourceSide(cloud, flow)).toBe(false); - expect(isCloudOnSinkSide(cloud, flow)).toBe(false); - }); - }); - - describe('L-shaped flow cloud positioning', () => { - it('should correctly identify source cloud on L-shaped flow', () => { - const cloud = makeCloud(sourceCloudUid, flowUid, 100, 50); - // L-shaped flow: cloud at top-left, corner in middle, stock at right - const flow = makeFlow(flowUid, 150, 100, [ - { x: 100, y: 50, attachedToUid: sourceCloudUid }, - { x: 100, y: 100 }, // corner - { x: 200, y: 100, attachedToUid: stockUid }, - ]); - - expect(isCloudOnSourceSide(cloud, flow)).toBe(true); - expect(isCloudOnSinkSide(cloud, flow)).toBe(false); - }); - - it('should correctly identify sink cloud on L-shaped flow', () => { - const cloud = makeCloud(sinkCloudUid, flowUid, 200, 150); - // L-shaped flow: stock at left, corner in middle, cloud at bottom-right - const flow = makeFlow(flowUid, 150, 100, [ - { x: 100, y: 100, attachedToUid: stockUid }, - { x: 200, y: 100 }, // corner - { x: 200, y: 150, attachedToUid: sinkCloudUid }, - ]); - - expect(isCloudOnSourceSide(cloud, flow)).toBe(false); - expect(isCloudOnSinkSide(cloud, flow)).toBe(true); - }); - }); -}); diff --git a/src/diagram/tests/editable-label.test.tsx b/src/diagram/tests/editable-label.test.tsx index 1c471f9c1..0abcfa643 100644 --- a/src/diagram/tests/editable-label.test.tsx +++ b/src/diagram/tests/editable-label.test.tsx @@ -59,6 +59,30 @@ function renderLabel(): { editable: Element; onDone: Mock; onChange: Mock } { return { editable: editable as Element, onDone, onChange }; } +describe('EditableLabel placement', () => { + // A centered label (an absent stored side) is drawn over the element by Label, + // so the inline editor sits there too: centered horizontally on the element. + // 'some name' is 9 characters: width (9 * 6 + 10) * zoom = 64, so the left + // edge is cx - 32. + it('centers the editor on the element for a center side', () => { + const { container } = render( + , + ); + expect((container.firstElementChild as HTMLElement).style.left).toBe('68px'); + }); +}); + describe('EditableLabel key handling', () => { it('commits on plain Enter and prevents the default line-break insertion', () => { const { editable, onDone } = renderLabel(); diff --git a/src/diagram/tests/editor-controller-wiring.test.ts b/src/diagram/tests/editor-controller-wiring.test.ts index 08577c374..9786ec0e5 100644 --- a/src/diagram/tests/editor-controller-wiring.test.ts +++ b/src/diagram/tests/editor-controller-wiring.test.ts @@ -16,8 +16,8 @@ // during the lazy state init), capture the config the Editor passed to the // ProjectController constructor by spying on it, then exercise the config's // save() directly and drive onError() and assert the resulting toast appears in -// the rendered DOM. openInitialProject/dispose/scheduleSimRun are stubbed so the -// test stays off WASM. +// the rendered DOM. openInitialProject and dispose are stubbed so the test stays +// off WASM. import { describe, it, expect, afterEach, rs } from '@rstest/core'; @@ -46,7 +46,6 @@ function makeProps(overrides: Partial = {}): EditorProps { function renderAndCaptureConfig(props: EditorProps): { config: ControllerConfig; result: RenderResult } { rs.spyOn(ProjectControllerModule.ProjectController.prototype, 'openInitialProject').mockResolvedValue(undefined); rs.spyOn(ProjectControllerModule.ProjectController.prototype, 'dispose').mockResolvedValue(undefined); - rs.spyOn(ProjectControllerModule.ProjectController.prototype, 'scheduleSimRun').mockImplementation(() => {}); let captured: ControllerConfig | undefined; const real = ProjectControllerModule.ProjectController; @@ -117,7 +116,6 @@ describe('Editor controller config wiring', () => { // render. Render under StrictMode and assert exactly one pill and no toast. rs.spyOn(ProjectControllerModule.ProjectController.prototype, 'openInitialProject').mockResolvedValue(undefined); rs.spyOn(ProjectControllerModule.ProjectController.prototype, 'dispose').mockResolvedValue(undefined); - rs.spyOn(ProjectControllerModule.ProjectController.prototype, 'scheduleSimRun').mockImplementation(() => {}); act(() => { render( diff --git a/src/diagram/tests/editor-details-draft.test.tsx b/src/diagram/tests/editor-details-draft.test.tsx new file mode 100644 index 000000000..e31364161 --- /dev/null +++ b/src/diagram/tests/editor-details-draft.test.tsx @@ -0,0 +1,310 @@ +// Copyright 2026 The Simlin Authors. All rights reserved. +// Use of this source code is governed by the Apache License, +// Version 2.0, that can be found in the LICENSE file. + +// Details-panel drafts across the controller's edit queue, with the REAL +// VariableDetails (Canvas mocked, controller snapshot stubbed): +// +// - a press outside the panel (a canvas press, which does not blur the panel +// because the Canvas prevents the default focus change) flushes the draft +// first: the panel's model edit is enqueued before the press is handled; +// - the same draft submitted again while its edit is still pending (the blur +// that follows) is not enqueued twice; +// - a landed change to the selected variable's errors does not remount the +// panel, so the draft text survives; a landed change to its content does. +// +// What this does not establish: that the flushed edit lands against the real +// engine before a following gesture's edit (editor-engine-races.test.ts). + +import { describe, it, expect, beforeAll, beforeEach, afterEach, rs } from '@rstest/core'; + +beforeAll(() => { + // jsdom lacks isContentEditable and Range geometry, which slate-react and the + // equation preview read. + Object.defineProperty(HTMLElement.prototype, 'isContentEditable', { + configurable: true, + get(this: HTMLElement): boolean { + return this.getAttribute('contenteditable') === 'true'; + }, + }); + if (!('getBoundingClientRect' in Range.prototype)) { + const zero = () => + ({ x: 0, y: 0, width: 0, height: 0, top: 0, left: 0, right: 0, bottom: 0, toJSON() {} }) as DOMRect; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (Range.prototype as any).getBoundingClientRect = zero; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (Range.prototype as any).getClientRects = () => + ({ length: 0, item: () => null, [Symbol.iterator]: function* () {} }) as unknown as DOMRectList; + } +}); + +import * as React from 'react'; +import { act, render, fireEvent, type RenderResult } from '@testing-library/react'; +import { Editor as SlateEditor, Transforms } from 'slate'; +import { ELEMENT_TO_NODE } from 'slate-dom'; + +import * as react from 'react' with { rstest: 'importActual' }; + +import { projectFromJson, type JsonProject, type Project, type Variable } from '@simlin/core/datamodel'; +import { mapSet } from '@simlin/core/common'; +import type { JsonProjectPatch } from '@simlin/engine'; + +import { ProjectController, type ProjectSnapshot } from '../project-controller'; +import type { CanvasProps } from '../drawing/Canvas'; + +let canvasProps: CanvasProps | undefined; +rs.mock('../drawing/Canvas', () => ({ + __esModule: true, + Canvas: (p: CanvasProps) => { + canvasProps = p; + return react.createElement('div', { 'data-testid': 'canvas' }); + }, + inCreationUid: -2, +})); + +import { Editor, type EditorProps } from '../Editor'; + +const projectJson = JSON.stringify({ + name: 'test', + simSpecs: { startTime: 0, endTime: 10, dt: '1' }, + models: [ + { + name: 'main', + stocks: [], + flows: [], + auxiliaries: [{ name: 'x', equation: '1', units: 'people' }], + views: [{ elements: [{ type: 'aux', uid: 1, name: 'x', x: 0, y: 0 }] }], + }, + ], +}); + +function baseProject(): Project { + return projectFromJson(JSON.parse(projectJson) as JsonProject); +} + +function withX(project: Project, patch: Partial): Project { + const model = project.models.get('main')!; + const variables = new Map(model.variables); + variables.set('x', { ...variables.get('x')!, ...patch } as Variable); + return { ...project, models: mapSet(project.models, 'main', { ...model, variables }) }; +} + +function makeSnapshot(project: Project, projectVersion: number): ProjectSnapshot { + return { + project, + projectVersion, + serverVersion: 1, + status: 'ok', + cachedErrors: { + simError: undefined, + modelErrors: [], + varErrors: new Map(), + unitErrors: new Map(), + varWarnings: new Map(), + }, + data: new Map(), + modelName: 'main', + modelStack: [], + canUndo: false, + canRedo: false, + undoRedoQueued: false, + token: 0, + navResetSeq: 0, + } as unknown as ProjectSnapshot; +} + +function makeProps(): EditorProps { + return { + inputFormat: 'json', + initialProjectJson: projectJson, + initialProjectVersion: 1, + name: 'test', + onSave: async () => 1, + } as EditorProps; +} + +function unitsEditor(container: HTMLElement): SlateEditor { + const el = container.querySelector('.unitsEditor'); + expect(el).not.toBeNull(); + return ELEMENT_TO_NODE.get(el as HTMLElement) as unknown as SlateEditor; +} + +function unitsText(container: HTMLElement): string { + return container.querySelector('.unitsEditor')?.textContent ?? ''; +} + +async function appendUnits(container: HTMLElement, text: string): Promise { + const editor = unitsEditor(container); + await act(async () => { + Transforms.insertText(editor, text, { at: SlateEditor.end(editor, []) }); + editor.onChange(); + await Promise.resolve(); + }); +} + +describe('Editor details-panel drafts', () => { + let snapshot: ProjectSnapshot; + let listener: (() => void) | undefined; + let modelEdits: Array<{ label: string; buildPatch: (committed: Project) => JsonProjectPatch }>; + + beforeEach(() => { + canvasProps = undefined; + listener = undefined; + modelEdits = []; + snapshot = makeSnapshot(baseProject(), 1); + rs.spyOn(ProjectController.prototype, 'getSnapshot').mockImplementation(() => snapshot); + rs.spyOn(ProjectController.prototype, 'subscribe').mockImplementation((l: () => void) => { + listener = l; + return () => { + listener = undefined; + }; + }); + rs.spyOn(ProjectController.prototype, 'openInitialProject').mockResolvedValue(undefined); + rs.spyOn(ProjectController.prototype, 'dispose').mockResolvedValue(undefined); + rs.spyOn(ProjectController.prototype, 'query').mockResolvedValue(undefined); + // Edits stay pending: they never settle, as while a slow patch is in flight. + rs.spyOn(ProjectController.prototype, 'enqueueModelEdit').mockImplementation((edit) => { + modelEdits.push(edit); + return new Promise(() => {}); + }); + }); + + afterEach(() => { + rs.restoreAllMocks(); + }); + + function publish(next: ProjectSnapshot): void { + snapshot = next; + act(() => { + listener?.(); + }); + } + + function openPanel(): RenderResult { + let result!: RenderResult; + act(() => { + result = render(React.createElement(Editor, makeProps())); + }); + act(() => { + canvasProps!.onSetSelection(new Set([1])); + canvasProps!.onShowVariableDetails(); + }); + expect(result.container.querySelector('.unitsEditor')).not.toBeNull(); + return result; + } + + it('a press outside the panel flushes the draft as one model edit, and a repeat submission is not enqueued twice', async () => { + const { container, getByTestId } = openPanel(); + await appendUnits(container, ' per year'); + expect(modelEdits).toHaveLength(0); + + act(() => { + fireEvent.pointerDown(getByTestId('canvas')); + fireEvent.pointerUp(getByTestId('canvas')); + }); + expect(modelEdits).toHaveLength(1); + const patch = modelEdits[0].buildPatch(baseProject()); + const op = patch.models![0].ops[0] as { type: string; payload: { aux: { units?: string } } }; + expect(op.type).toBe('upsertAux'); + expect(op.payload.aux.units).toBe('people per year'); + + // A second press (or the blur that follows) submits the same pending draft. + act(() => { + fireEvent.pointerDown(getByTestId('canvas')); + }); + expect(modelEdits).toHaveLength(1); + }); + + it('a draft that differs from the latest pending submission is enqueued, even one changed back to an earlier pending draft', async () => { + const { container, getByTestId } = openPanel(); + const press = () => + act(() => { + fireEvent.pointerDown(getByTestId('canvas')); + fireEvent.pointerUp(getByTestId('canvas')); + }); + const unitsOf = (i: number) => + (modelEdits[i].buildPatch(baseProject()).models![0].ops[0] as { payload: { aux: { units?: string } } }).payload + .aux.units; + await appendUnits(container, ' per year'); + press(); + await appendUnits(container, '!'); + press(); + // Changed back to the first draft while both earlier edits are still pending. + const editor = unitsEditor(container); + await act(async () => { + Transforms.delete(editor, { at: SlateEditor.end(editor, []), distance: 1, unit: 'character', reverse: true }); + editor.onChange(); + await Promise.resolve(); + }); + press(); + expect(modelEdits.map((_, i) => unitsOf(i))).toEqual(['people per year', 'people per year!', 'people per year']); + }); + + it('the same variable ident in another model is its own submission target, and its own panel', async () => { + const { container, getByTestId } = openPanel(); + const press = () => + act(() => { + fireEvent.pointerDown(getByTestId('canvas')); + fireEvent.pointerUp(getByTestId('canvas')); + }); + await appendUnits(container, ' per year'); + press(); + expect(modelEdits).toHaveLength(1); + // Unsubmitted text past the submission holds main's panel key. + await appendUnits(container, '!'); + // Drilled into a child model holding a variable with the same ident, + // element uid and content, while main's edit is still pending. + const project = baseProject(); + const main = project.models.get('main')!; + const twoModels = { ...project, models: new Map([...project.models, ['child', { ...main, name: 'child' }]]) }; + publish({ ...makeSnapshot(twoModels, 2), modelName: 'child' } as ProjectSnapshot); + expect(unitsText(container)).toBe('people'); + await appendUnits(container, ' per year'); + press(); + expect(modelEdits).toHaveLength(2); + }); + + it('a press inside the panel does not flush', async () => { + const { container } = openPanel(); + await appendUnits(container, '!'); + act(() => { + fireEvent.pointerDown(container.querySelector('.unitsEditor')!); + }); + expect(modelEdits).toHaveLength(0); + }); + + it("a landed change to the variable's errors keeps the draft; its submitted edit landing re-seeds it", async () => { + const { container, getByTestId } = openPanel(); + const editorBefore = unitsEditor(container); + await appendUnits(container, ' per year'); + expect(unitsText(container)).toBe('people per year'); + + // An unrelated edit landed and broke x (e.g. a variable x reads was deleted). + publish( + makeSnapshot( + withX(baseProject(), { + errors: [{ start: 0, end: 1, code: 1 }], + unitErrors: [{ start: 0, end: 6, code: 1, kind: 'definition' }], + } as Partial), + 2, + ), + ); + expect(unitsEditor(container)).toBe(editorBefore); + expect(unitsText(container)).toBe('people per year'); + + // Committed content that merely equals an unsubmitted draft does not release + // it: the text differs from what the panel last submitted (nothing). + publish(makeSnapshot(withX(baseProject(), { units: 'people per year' }), 3)); + expect(unitsEditor(container)).toBe(editorBefore); + + // The draft is submitted and its edit lands: the panel re-seeds. + act(() => { + fireEvent.pointerDown(getByTestId('canvas')); + fireEvent.pointerUp(getByTestId('canvas')); + }); + expect(modelEdits).toHaveLength(1); + publish(makeSnapshot(withX(baseProject(), { units: 'people per year' }), 4)); + expect(unitsEditor(container)).not.toBe(editorBefore); + expect(unitsText(container)).toBe('people per year'); + }); +}); diff --git a/src/diagram/tests/editor-details-remount.test.ts b/src/diagram/tests/editor-details-remount.test.ts index 9be27c33f..fee0c3817 100644 --- a/src/diagram/tests/editor-details-remount.test.ts +++ b/src/diagram/tests/editor-details-remount.test.ts @@ -2,36 +2,34 @@ // Use of this source code is governed by the Apache License, // Version 2.0, that can be found in the LICENSE file. -// Regression test for the details-panel remount key. +// The details-panel remount key. The variable/module details panels seed their +// Slate editors once per mount, so a React remount (key change) is what +// refreshes them. The Editor keys a panel on the SELECTED variable's committed, +// user-editable content (plus the read-only flag, pinned in +// editor-readonly-gating.test.ts), so: // -// The variable/module details panels seed their Slate editors from props in -// their constructors, so React remounts (key changes) are the mechanism that -// refreshes them after the underlying variable changes. The Editor builds the -// panel key from `controllerSnapshot.projectGeneration` (not projectVersion): -// projectGeneration increments exactly when project *content* changes (real -// edits, undo/redo) and stays put for view-only pan/zoom frames and -// save-version bookkeeping, so an open panel is NOT remounted -- discarding -// in-progress edits -- on a pan or an autosave. +// - a landed edit to that variable's equation/units/docs/table remounts it; +// - a pan, a save acknowledgment or a sim run (render-key-only changes), an +// edit to ANOTHER variable, and a change to this variable's errors do not -- +// each of those can land while the user types, and a remount would discard +// the draft. // -// The *semantics* of projectGeneration (when it does/doesn't increment) live in -// and are tested against the ProjectController (project-controller.test.ts). -// What remains Editor-specific is that the rendered panel REMOUNTS on a -// projectGeneration change and NOT on a projectVersion-only change. The Editor -// is now a function component, so rather than reading the literal `key` string -// off an internal render-helper, this asserts that observable remount behavior: -// a stub VariableDetails records each mount; a generation bump remounts it, a -// version-only bump does not. +// The rows are the variable fields that can change between snapshots: the +// editable content (equation, units, documentation, gf) and the non-content +// annotations (errors, unitErrors, connectorErrors, data), plus another +// variable's content and the render key. A stub VariableDetails records each +// mount; snapshots are published through the controller subscription, as the +// real controller does. import { describe, it, expect, beforeEach, afterEach, rs } from '@rstest/core'; import * as React from 'react'; import { act, render } from '@testing-library/react'; -import { projectFromJson, type JsonProject } from '@simlin/core/datamodel'; +import { projectFromJson, type JsonProject, type Project, type Variable } from '@simlin/core/datamodel'; +import { mapSet } from '@simlin/core/common'; import { ProjectController, type ProjectSnapshot } from '../project-controller'; -// A project with a single aux 'x' and a view element selecting it, so the -// Editor's details flow resolves to a VariableDetails panel. const projectJson = JSON.stringify({ name: 'test', simSpecs: { startTime: 0, endTime: 10, dt: '1' }, @@ -40,15 +38,22 @@ const projectJson = JSON.stringify({ name: 'main', stocks: [], flows: [], - auxiliaries: [{ name: 'x', equation: '1' }], - views: [{ elements: [{ type: 'aux', uid: 1, name: 'x', x: 0, y: 0 }] }], + auxiliaries: [ + { name: 'x', equation: '1' }, + { name: 'y', equation: '2' }, + ], + views: [ + { + elements: [ + { type: 'aux', uid: 1, name: 'x', x: 0, y: 0 }, + { type: 'aux', uid: 2, name: 'y', x: 50, y: 0 }, + ], + }, + ], }, ], }); -// Record a mount each time the stub VariableDetails is (re)mounted. A key change -// (generation bump) unmounts the old instance and mounts a new one, so the -// mount count distinguishes a remount from an in-place prop update. let variableDetailsMounts = 0; rs.mock('../VariableDetails', () => ({ __esModule: true, @@ -60,9 +65,6 @@ rs.mock('../VariableDetails', () => ({ }, })); -// Capture the props the Editor hands the Canvas so we can drive the real -// selection/show-details handlers (the documented Canvas -> Editor contract) -// without WASM, jsdom SVG geometry, or a ResizeObserver. interface CapturedCanvasProps { onSetSelection: (sel: ReadonlySet) => void; onShowVariableDetails: () => void; @@ -77,22 +79,40 @@ rs.mock('../drawing/Canvas', () => ({ inCreationUid: -2, })); -import { Editor, type EditorProps } from '../Editor'; +import { Editor, detailsPanelKey, type EditorProps } from '../Editor'; -function makeSnapshot(projectGeneration: number, projectVersion: number): ProjectSnapshot { - const project = projectFromJson(JSON.parse(projectJson) as JsonProject); +function baseProject(): Project { + return projectFromJson(JSON.parse(projectJson) as JsonProject); +} + +function withVariable(project: Project, ident: string, patch: Partial): Project { + const model = project.models.get('main')!; + const variables = new Map(model.variables); + variables.set(ident, { ...variables.get(ident)!, ...patch } as Variable); + return { ...project, models: mapSet(project.models, 'main', { ...model, variables }) }; +} + +function makeSnapshot(project: Project, projectVersion: number, restoreSeq = 0, token = 0): ProjectSnapshot { return { project, projectVersion, serverVersion: 1, - projectGeneration, status: 'ok', - cachedErrors: { simError: undefined, modelErrors: [], varErrors: new Map(), unitErrors: new Map() }, + cachedErrors: { + simError: undefined, + modelErrors: [], + varErrors: new Map(), + unitErrors: new Map(), + varWarnings: new Map(), + }, data: new Map(), modelName: 'main', modelStack: [], canUndo: false, canRedo: false, + undoRedoQueued: false, + token, + restoreSeq, navResetSeq: 0, } as unknown as ProjectSnapshot; } @@ -107,7 +127,59 @@ function makeProps(): EditorProps { } as EditorProps; } -describe('Editor details-panel remount on projectGeneration change', () => { +const gf = { + kind: 'continuous', + xScale: { min: 0, max: 1 }, + yScale: { min: 0, max: 1 }, + xPoints: undefined, + yPoints: [0, 1], +}; + +// Each row publishes one change to the selected variable x (or to y, or only to +// the render key) and states whether the open panel must remount. +const ROWS: ReadonlyArray<{ name: string; change: (p: Project) => Project; remounts: boolean }> = [ + { + name: "x's equation", + change: (p) => withVariable(p, 'x', { equation: { type: 'scalar', equation: '5' } }), + remounts: true, + }, + { name: "x's units", change: (p) => withVariable(p, 'x', { units: 'widgets' }), remounts: true }, + { name: "x's documentation", change: (p) => withVariable(p, 'x', { documentation: 'docs' }), remounts: true }, + { name: "x's lookup table", change: (p) => withVariable(p, 'x', { gf } as Partial), remounts: true }, + { + name: "x's equation errors", + change: (p) => withVariable(p, 'x', { errors: [{ start: 0, end: 1, code: 1 }] } as Partial), + remounts: false, + }, + { + name: "x's unit errors", + change: (p) => + withVariable(p, 'x', { unitErrors: [{ start: 0, end: 1, code: 1, kind: 'definition' }] } as Partial), + remounts: false, + }, + { + name: "x's connector drift", + change: (p) => + withVariable(p, 'x', { + connectorErrors: [{ kind: 'missingConnector', ident: 'y', name: 'y' }], + } as Partial), + remounts: false, + }, + { + name: "x's sim series", + change: (p) => + withVariable(p, 'x', { data: [{ name: 'x', time: new Float64Array([0]), values: new Float64Array([1]) }] }), + remounts: false, + }, + { + name: "another variable's equation", + change: (p) => withVariable(p, 'y', { equation: { type: 'scalar', equation: '9' } }), + remounts: false, + }, + { name: 'only the render key (a pan, a save ack)', change: (p) => p, remounts: false }, +]; + +describe('Editor details-panel key', () => { let snapshot: ProjectSnapshot; let listener: (() => void) | undefined; @@ -115,7 +187,7 @@ describe('Editor details-panel remount on projectGeneration change', () => { variableDetailsMounts = 0; capturedCanvasProps = undefined; listener = undefined; - snapshot = makeSnapshot(0, 1); + snapshot = makeSnapshot(baseProject(), 1); rs.spyOn(ProjectController.prototype, 'getSnapshot').mockImplementation(() => snapshot); rs.spyOn(ProjectController.prototype, 'subscribe').mockImplementation((l: () => void) => { listener = l; @@ -125,15 +197,12 @@ describe('Editor details-panel remount on projectGeneration change', () => { }); rs.spyOn(ProjectController.prototype, 'openInitialProject').mockResolvedValue(undefined); rs.spyOn(ProjectController.prototype, 'dispose').mockResolvedValue(undefined); - rs.spyOn(ProjectController.prototype, 'scheduleSimRun').mockImplementation(() => {}); }); afterEach(() => { rs.restoreAllMocks(); }); - // Publish a new snapshot through the controller subscription, the way the real - // controller notifies the Editor after an edit/undo/pan. function publish(next: ProjectSnapshot): void { snapshot = next; act(() => { @@ -141,36 +210,82 @@ describe('Editor details-panel remount on projectGeneration change', () => { }); } - function openVariableDetails(): void { - // Drive the real Editor handlers through the Canvas -> Editor contract. - act(() => { - capturedCanvasProps!.onSetSelection(new Set([1])); - capturedCanvasProps!.onShowVariableDetails(); + for (const row of ROWS) { + it(`${row.remounts ? 'remounts' : 'does not remount'} the open panel when ${row.name} changes`, () => { + act(() => { + render(React.createElement(Editor, makeProps())); + }); + act(() => { + capturedCanvasProps!.onSetSelection(new Set([1])); + capturedCanvasProps!.onShowVariableDetails(); + }); + expect(variableDetailsMounts).toBe(1); + + publish(makeSnapshot(row.change(baseProject()), 2)); + expect(variableDetailsMounts).toBe(row.remounts ? 2 : 1); }); } - it('remounts the VariableDetails panel when projectGeneration changes', () => { + // The key names the model for both panel kinds: a uid is unique only within + // its model, so a module or variable with the same uid and content in another + // model must get its own panel. + it('keys a variable panel and a module panel by model name', () => { + const variable = baseProject().models.get('main')!.variables.get('x')!; + const module: Variable = { + type: 'module', + ident: 'm', + modelName: 'child', + documentation: '', + units: '', + references: [], + canBeModuleInput: false, + isPublic: false, + dataSource: undefined, + data: undefined, + errors: undefined, + unitErrors: undefined, + uid: undefined, + }; + for (const v of [variable, module]) { + expect(detailsPanelKey('main', 1, v, 0, false)).not.toBe(detailsPanelKey('child', 1, v, 0, false)); + expect(detailsPanelKey('main', 1, v, 0, false)).toBe(detailsPanelKey('main', 1, v, 0, false)); + } + }); + + it('remounts the open panel when another element is selected, even one whose variable has the same content', () => { + // y gets x's content, so only the selected element tells the two panels apart. + snapshot = makeSnapshot(withVariable(baseProject(), 'y', { equation: { type: 'scalar', equation: '1' } }), 1); act(() => { render(React.createElement(Editor, makeProps())); }); - openVariableDetails(); + act(() => { + capturedCanvasProps!.onSetSelection(new Set([1])); + capturedCanvasProps!.onShowVariableDetails(); + }); expect(variableDetailsMounts).toBe(1); - - // A content edit bumps generation -> key changes -> remount. - publish(makeSnapshot(1, 2)); + act(() => { + capturedCanvasProps!.onSetSelection(new Set([2])); + capturedCanvasProps!.onShowVariableDetails(); + }); expect(variableDetailsMounts).toBe(2); }); - it('does NOT remount the panel on a projectVersion-only change (pan/autosave)', () => { - act(() => { - render(React.createElement(Editor, makeProps())); + // The two counters that move without x's content changing: an undo/redo + // landing (restoreSeq) remounts, since the restored content can equal the + // content the panel was seeded from while the panel holds a draft whose edit + // was undone; the token (which a failure elsewhere also moves) does not. + for (const counter of ['restoreSeq', 'token'] as const) { + it(`${counter === 'restoreSeq' ? 'remounts' : 'does not remount'} the open panel when only the ${counter} moves`, () => { + act(() => { + render(React.createElement(Editor, makeProps())); + }); + act(() => { + capturedCanvasProps!.onSetSelection(new Set([1])); + capturedCanvasProps!.onShowVariableDetails(); + }); + expect(variableDetailsMounts).toBe(1); + publish(counter === 'restoreSeq' ? makeSnapshot(baseProject(), 2, 1, 0) : makeSnapshot(baseProject(), 2, 0, 1)); + expect(variableDetailsMounts).toBe(counter === 'restoreSeq' ? 2 : 1); }); - openVariableDetails(); - expect(variableDetailsMounts).toBe(1); - - // A view-only update bumps projectVersion but NOT projectGeneration -> the - // key is unchanged -> the open panel must not remount. - publish(makeSnapshot(0, 1.001)); - expect(variableDetailsMounts).toBe(1); - }); + } }); diff --git a/src/diagram/tests/editor-drawer-delete.test.ts b/src/diagram/tests/editor-drawer-delete.test.ts index e693cc49e..37a18bc0f 100644 --- a/src/diagram/tests/editor-drawer-delete.test.ts +++ b/src/diagram/tests/editor-drawer-delete.test.ts @@ -70,9 +70,14 @@ function makeSnapshot(): ProjectSnapshot { modelName: 'main', projectVersion: 1, serverVersion: 1, - projectGeneration: 0, status: 'ok', - cachedErrors: { simError: undefined, modelErrors: [], varErrors: new Map(), unitErrors: new Map() }, + cachedErrors: { + simError: undefined, + modelErrors: [], + varErrors: new Map(), + unitErrors: new Map(), + varWarnings: new Map(), + }, data: new Map(), modelStack: [], canUndo: false, @@ -108,7 +113,6 @@ describe('Editor.getDrawer() delete wiring', () => { rs.spyOn(ProjectController.prototype, 'getSnapshot').mockReturnValue(makeSnapshot()); rs.spyOn(ProjectController.prototype, 'openInitialProject').mockResolvedValue(undefined); rs.spyOn(ProjectController.prototype, 'dispose').mockResolvedValue(undefined); - rs.spyOn(ProjectController.prototype, 'scheduleSimRun').mockImplementation(() => {}); rs.spyOn(ProjectController.prototype, 'subscribe').mockReturnValue(() => {}); }); diff --git a/src/diagram/tests/editor-engine-races.test.ts b/src/diagram/tests/editor-engine-races.test.ts new file mode 100644 index 000000000..0bc9e3cb0 --- /dev/null +++ b/src/diagram/tests/editor-engine-races.test.ts @@ -0,0 +1,1541 @@ +// Copyright 2026 The Simlin Authors. All rights reserved. +// Use of this source code is governed by the Apache License, +// Version 2.0, that can be found in the LICENSE file. + +// The Editor's commit path against the REAL WASM engine, including edits that +// race an in-flight patch. The Editor mounts with the Canvas mocked (its props +// are captured, so tests call the gesture callbacks the Canvas would), the +// details panels real, and every engine patch delayed 30ms to emulate the +// worker round trip. After each scenario the engine's own serialized project +// must hold M1 (kind agreement), M3 (referential integrity), static stock/view +// agreement (every stock's lists name exactly the flows attached to it on the +// view, once), and "nothing invisible simulates" (every non-module variable has +// a primary element); the rendered view must equal the engine's view. +// +// The scenarios port the commit-path audit's races: two attaches onto one stock +// (T11), a create raced by a delete (T11b), an attach raced by a label move +// (T11c), an attach raced by a delete (T11e), undo pressed while an attach is in +// flight (T24), an attach onto a stock already listing the flow (T12), creating +// under a taken name (T6), two quick creates, deleting a stock with an alias +// (T23), deleting a lone cloud (T1), a non-finite geometry commit (T22), and +// typing in a details panel then dragging a stock. The Phase 4 review's repros +// follow: a second rename while the first is pending (R1), renaming a pending +// create (R3), typing in the panel of a variable whose rename is pending (R4), a +// patch that applied but could not be read back (R9), Delete right after Ctrl+Z +// (R10), two handlers in one tick, and Undo pressed with a draft in the panel. +// The delta review's follow: a patch whose read-back fails then re-reads (N4) or +// reopens while the user keeps editing (N1), an engine that cannot be reopened +// (N10), typed names committed while an undo is queued (N2), text typed after a +// flush (N5), a draft in the panel of a variable a rename rewrites, an unrelated +// landing while typing, Undo with a draft whose edit fails (N7), Redo with a +// draft (N8), and a swap of two names through three pending renames (O3). +// +// Gesture commits are planned by the production planner on the rendered view. +// What this does not establish: the Canvas turning pointer events into those +// gestures (canvas-gestures-*.test.tsx), or the strict geometry of the committed +// flows (editor-gestures-engine.test.ts). + +import { it, expect, beforeAll, afterEach, rs } from '@rstest/core'; + +beforeAll(() => { + Object.defineProperty(HTMLElement.prototype, 'isContentEditable', { + configurable: true, + get(this: HTMLElement): boolean { + return this.getAttribute('contenteditable') === 'true'; + }, + }); + if (!('getBoundingClientRect' in Range.prototype)) { + const zero = () => + ({ x: 0, y: 0, width: 0, height: 0, top: 0, left: 0, right: 0, bottom: 0, toJSON() {} }) as DOMRect; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (Range.prototype as any).getBoundingClientRect = zero; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (Range.prototype as any).getClientRects = () => + ({ length: 0, item: () => null, [Symbol.iterator]: function* () {} }) as unknown as DOMRectList; + } +}); + +import * as React from 'react'; +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { Editor as SlateEditor, Transforms } from 'slate'; +import { ELEMENT_TO_NODE } from 'slate-dom'; + +import * as react from 'react' with { rstest: 'importActual' }; + +import { canonicalize } from '@simlin/core/canonicalize'; +import { + isNamedViewElement, + projectFromJson, + type FlowViewElement, + type Model, + type StockFlowView, + type ViewElement, +} from '@simlin/core/datamodel'; +import { Project as EngineProject, type JsonProject, type JsonProjectPatch } from '@simlin/engine'; + +import { ProjectController } from '../project-controller'; +import type { CanvasProps } from '../drawing/Canvas'; +import { planGesture, sameGeometry, type Gesture } from '../gesture-planner'; +import { describeWithEngine, loadEngine, mainModel, type EngineModule } from './support/engine'; +import { + checkKindAgreement, + checkReferentialIntegrity, + checkStockFlowAgreement, + checkStockListDuplicates, + formatViewViolations, +} from './support/view-invariants'; + +let canvasProps: CanvasProps | undefined; +rs.mock('../drawing/Canvas', () => ({ + __esModule: true, + Canvas: (p: CanvasProps) => { + canvasProps = p; + return react.createElement('div', { 'data-testid': 'canvas' }); + }, + inCreationUid: -2, +})); + +import { Editor, type EditorProps } from '../Editor'; + +// --------------------------------------------------------------------------- +// Fixture (the audit's): A -> B via f, g from a cloud into A, h from B to a +// cloud, k between two clouds, x reads A and feeds f, an alias of A feeding k. + +function baseModel(): Record & { stocks: Array<{ name: string; inflows: string[] }> } { + return { + name: 'main', + stocks: [ + { name: 'A', initialEquation: '10', inflows: ['g'], outflows: ['f'] }, + { name: 'B', initialEquation: '0', inflows: ['f'], outflows: ['h'] }, + ], + flows: [ + { name: 'g', equation: '1' }, + { name: 'f', equation: 'x' }, + { name: 'h', equation: 'B * 0.1' }, + { name: 'k', equation: '0' }, + ], + auxiliaries: [{ name: 'x', equation: 'A * 0.1' }], + views: [ + { + elements: [ + { type: 'cloud', uid: 20, flowUid: 3, x: 0, y: 100 }, + { + type: 'flow', + uid: 3, + name: 'g', + x: 40, + y: 100, + points: [ + { x: 0, y: 100, attachedToUid: 20 }, + { x: 77.5, y: 100, attachedToUid: 1 }, + ], + }, + { type: 'stock', uid: 1, name: 'A', x: 100, y: 100 }, + { + type: 'flow', + uid: 4, + name: 'f', + x: 200, + y: 100, + points: [ + { x: 122.5, y: 100, attachedToUid: 1 }, + { x: 277.5, y: 100, attachedToUid: 2 }, + ], + }, + { type: 'stock', uid: 2, name: 'B', x: 300, y: 100 }, + { + type: 'flow', + uid: 5, + name: 'h', + x: 400, + y: 100, + points: [ + { x: 322.5, y: 100, attachedToUid: 2 }, + { x: 500, y: 100, attachedToUid: 21 }, + ], + }, + { type: 'cloud', uid: 21, flowUid: 5, x: 500, y: 100 }, + { type: 'cloud', uid: 22, flowUid: 6, x: 0, y: 300 }, + { + type: 'flow', + uid: 6, + name: 'k', + x: 100, + y: 300, + points: [ + { x: 0, y: 300, attachedToUid: 22 }, + { x: 200, y: 300, attachedToUid: 23 }, + ], + }, + { type: 'cloud', uid: 23, flowUid: 6, x: 200, y: 300 }, + { type: 'aux', uid: 7, name: 'x', x: 200, y: 20 }, + { type: 'link', uid: 8, fromUid: 7, toUid: 4 }, + { type: 'link', uid: 9, fromUid: 1, toUid: 7 }, + { type: 'alias', uid: 10, aliasOfUid: 1, x: 100, y: 250 }, + { type: 'link', uid: 11, fromUid: 10, toUid: 6 }, + ], + }, + ], + }; +} + +function projectJson(model: Record): string { + return JSON.stringify({ name: 'races', simSpecs: { startTime: 0, endTime: 3, dt: '1' }, models: [model] }); +} + +// --------------------------------------------------------------------------- +// Mounting + +interface Mounted { + readonly controller: ProjectController; + readonly patches: JsonProjectPatch[]; + view(): StockFlowView; + element(uid: number): ViewElement; + settle(): Promise; + engineModel(): Promise; + // The engine's next `count` project reads (with stdlib, as a read-back does) + // throw, as a worker fault would. + armReadFailure(count?: number): void; + // The engine's next applyPatch rejects. + armPatchFailure(): void; +} + +const PATCH_LATENCY_MS = 30; + +async function mount(json: string, extraProps: Partial = {}): Promise { + const controllers: ProjectController[] = []; + const patches: JsonProjectPatch[] = []; + const originalOpen = ProjectController.prototype.openInitialProject; + rs.spyOn(ProjectController.prototype, 'openInitialProject').mockImplementation(function (this: ProjectController) { + controllers.push(this); + return originalOpen.call(this); + }); + rs.spyOn(console, 'error').mockImplementation(() => {}); + + const props = { + inputFormat: 'json', + initialProjectJson: json, + initialProjectVersion: 1, + name: 'races', + onSave: async () => 1, + ...extraProps, + } as EditorProps; + act(() => { + render(React.createElement(Editor, props)); + }); + const controller = controllers[0]; + await act(async () => { + await controller.whenIdle(); + }); + + // Every patch from here on waits out a worker round trip first. The engine + // is reached through a query so the wrapper is installed inside the executor. + let readFailures = 0; + let patchFailureArmed = false; + await act(async () => { + await controller.query(async (engine) => { + const apply = engine.applyPatch.bind(engine); + engine.applyPatch = async (patch, options) => { + await new Promise((resolve) => setTimeout(resolve, PATCH_LATENCY_MS)); + if (patchFailureArmed) { + patchFailureArmed = false; + throw new Error('injected patch failure'); + } + patches.push(patch); + return apply(patch, options); + }; + const serializeJson = engine.serializeJson.bind(engine); + engine.serializeJson = async (format, includeStdlib) => { + if (includeStdlib && readFailures > 0) { + readFailures -= 1; + throw new Error('injected read failure'); + } + return serializeJson(format, includeStdlib); + }; + }); + }); + + const view = (): StockFlowView => { + if (canvasProps === undefined) { + throw new Error('Canvas never rendered'); + } + return canvasProps.view; + }; + return { + controller, + patches, + view, + element: (uid) => { + const el = view().elements.find((e) => e.uid === uid); + if (el === undefined) { + throw new Error(`no uid ${uid} on the rendered view`); + } + return el; + }, + settle: async () => { + for (let i = 0; i < 3; i++) { + await act(async () => { + await controller.whenIdle(); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + } + }, + engineModel: async () => { + const serialized = await controller.query((engine) => engine.serializeJson(undefined, true)); + return mainModel(projectFromJson(JSON.parse(serialized!) as JsonProject).models); + }, + armReadFailure: (count = 1) => { + readFailures = count; + }, + armPatchFailure: () => { + patchFailureArmed = true; + }, + }; +} + +// The toasts the Editor shows. +function alerts(): string[] { + return Array.from(document.querySelectorAll('[id="client-snackbar"]')).map((el) => el.textContent ?? ''); +} + +function nameOf(el: ViewElement): string | undefined { + return isNamedViewElement(el) ? el.name : undefined; +} + +function labelSideOf(model: Model, uid: number): string | undefined { + return (model.views[0].elements.find((el) => el.uid === uid) as { labelSide?: string } | undefined)?.labelSide; +} + +async function typeUnits(text: string): Promise { + const unitsEl = document.querySelector('.unitsEditor') as HTMLElement; + expect(unitsEl).not.toBeNull(); + const editor = ELEMENT_TO_NODE.get(unitsEl) as unknown as SlateEditor; + await act(async () => { + Transforms.insertText(editor, text, { at: SlateEditor.end(editor, []) }); + editor.onChange(); + await Promise.resolve(); + }); + return editor; +} + +// The text in the open panel's units editor. +function unitsText(): string { + return SlateEditor.string( + ELEMENT_TO_NODE.get(document.querySelector('.unitsEditor') as HTMLElement) as unknown as SlateEditor, + [], + ); +} + +// A canvas press and release: the press flushes an open panel's draft. +function pressCanvas(): void { + const canvas = screen.getByTestId('canvas'); + act(() => { + fireEvent.pointerDown(canvas); + fireEvent.pointerUp(canvas); + }); +} + +// A press on an Undo/Redo button as a browser delivers it: React commits what +// the pointerdown changed before the click is dispatched. +function pressButton(label: string): void { + const button = screen.getByLabelText(label) as HTMLButtonElement; + expect(button.disabled).toBe(false); + act(() => { + fireEvent.pointerDown(button); + fireEvent.mouseDown(button); + }); + act(() => { + fireEvent.pointerUp(button); + fireEvent.mouseUp(button); + fireEvent.click(button); + }); +} + +// Replace the text of the panel field matching `selector`, as selecting it all +// and typing does. +async function setField(selector: string, text: string): Promise { + const editor = ELEMENT_TO_NODE.get(document.querySelector(selector) as HTMLElement) as unknown as SlateEditor; + await act(async () => { + Transforms.delete(editor, { at: { anchor: SlateEditor.start(editor, []), focus: SlateEditor.end(editor, []) } }); + if (text !== '') { + Transforms.insertText(editor, text, { at: SlateEditor.end(editor, []) }); + } + editor.onChange(); + await Promise.resolve(); + }); +} + +function fieldText(selector: string): string { + return SlateEditor.string( + ELEMENT_TO_NODE.get(document.querySelector(selector) as HTMLElement) as unknown as SlateEditor, + [], + ); +} + +async function waitFor(predicate: () => boolean): Promise { + for (let i = 0; i < 400 && !predicate(); i++) { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 5)); + }); + } + expect(predicate()).toBe(true); +} + +// --------------------------------------------------------------------------- +// Invariants over the engine's serialized project + +function expectModelViewAgreement(model: Model): void { + const view = model.views[0]; + const violations = [ + ...checkKindAgreement(view, model.variables), + ...checkReferentialIntegrity(view), + ...checkStockListDuplicates(model.variables), + ...checkStockFlowAgreement(view, model.variables), + ]; + expect(formatViewViolations(violations)).toBe(''); + const primaries = new Set(view.elements.filter(isNamedViewElement).map((el) => canonicalize(el.name))); + for (const variable of model.variables.values()) { + if (variable.type !== 'module') { + expect(primaries.has(variable.ident)).toBe(true); + } + if (variable.type === 'stock') { + for (const entry of [...variable.inflows, ...variable.outflows]) { + expect(model.variables.get(canonicalize(entry))?.type).toBe('flow'); + } + } + } +} + +// The rendered view (what the user sees) agrees with the engine's view: same +// elements, same flow attachments. +function expectRenderedMatchesEngine(rendered: StockFlowView, engine: StockFlowView): void { + const shape = (view: StockFlowView) => + view.elements + .map((el) => + el.type === 'flow' + ? `${el.uid}:flow:${canonicalize(el.name)}:${el.points[0].attachedToUid}->${el.points[el.points.length - 1].attachedToUid}` + : `${el.uid}:${el.type}`, + ) + .sort(); + expect(shape(rendered)).toEqual(shape(engine)); +} + +async function expectConsistent(m: Mounted): Promise { + const model = await m.engineModel(); + expectModelViewAgreement(model); + expectRenderedMatchesEngine(m.view(), model.views[0]); + return model; +} + +function stockLists(model: Model, ident: string): { inflows: string[]; outflows: string[] } { + const stock = model.variables.get(ident); + if (stock?.type !== 'stock') { + throw new Error(`no stock ${ident}`); + } + return { inflows: stock.inflows.map(canonicalize).sort(), outflows: stock.outflows.map(canonicalize).sort() }; +} + +// A gesture released at `current`: planned by the production planner on the +// rendered view with the Canvas's inputs, and its commit handed to the Editor +// exactly as the Canvas's pointer-up hands it. +function release(gesture: Gesture, press: { x: number; y: number }, current: { x: number; y: number }): void { + const p = canvasProps!; + const plan = planGesture({ + view: p.view, + variables: p.model.variables, + selection: p.selection, + gesture, + press, + current, + zoom: 1, + pointerType: 'mouse', + readOnly: !!p.readOnly, + names: p.newVariableName!, + }); + expect(plan.commit).toBe('edit'); + p.onCommitGesture({ + label: plan.label, + elements: plan.elements, + nextUid: plan.nextUid, + selection: plan.selection, + token: p.token, + baseView: p.view, + editName: plan.handoff?.editName, + }); +} + +// Reattach `flowUid`'s sink onto the stock `targetUid`: its arrowhead dragged +// onto the stock's center. +function attachSink(m: Mounted, flowUid: number, targetUid: number): void { + const flow = m.element(flowUid) as FlowViewElement; + const target = m.element(targetUid); + act(() => { + release({ kind: 'flowEndpoint', flow: flowUid, end: 'sink' }, flow.points[flow.points.length - 1], target); + }); +} + +// Drag element `uid`'s label 40px toward `side`. +function moveLabel(uid: number, side: 'top' | 'bottom' | 'left' | 'right'): void { + const el = canvasProps!.view.elements.find((e) => e.uid === uid)!; + const at = { + top: { x: el.x, y: el.y - 40 }, + bottom: { x: el.x, y: el.y + 40 }, + left: { x: el.x - 40, y: el.y }, + right: { x: el.x + 40, y: el.y }, + }[side]; + release({ kind: 'label', uid }, at, at); +} + +function select(uids: number[]): void { + act(() => { + canvasProps!.onSetSelection(new Set(uids)); + }); +} + +function pressDelete(): void { + const root = document.querySelector('[data-simlin-editor-root]') as HTMLElement; + act(() => { + fireEvent.pointerDown(root); + fireEvent.pointerUp(root); + fireEvent.keyDown(root, { key: 'Delete' }); + }); +} + +function newStock(name: string, x: number, y: number): ViewElement { + return { + type: 'stock', + uid: -2, + name, + ident: canonicalize(name), + var: undefined, + x, + y, + labelSide: 'bottom', + isZeroRadius: false, + inflows: [], + outflows: [], + }; +} + +function newAux(name: string, x: number, y: number): ViewElement { + return { + type: 'aux', + uid: -2, + name, + ident: canonicalize(name), + var: undefined, + x, + y, + labelSide: 'right', + isZeroRadius: false, + }; +} + +// --------------------------------------------------------------------------- + +describeWithEngine('Editor + real engine: edits racing in-flight patches', () => { + let engine: EngineModule; + + beforeAll(async () => { + engine = await loadEngine(); + }); + + afterEach(async () => { + cleanup(); + rs.restoreAllMocks(); + canvasProps = undefined; + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + it('T11: two attaches onto one stock while the first patch is in flight both land', async () => { + const m = await mount(projectJson(baseModel())); + select([5]); + attachSink(m, 5, 1); + select([6]); + attachSink(m, 6, 1); + await m.settle(); + const model = await expectConsistent(m); + expect(stockLists(model, 'a').inflows).toEqual(['g', 'h', 'k']); + // Only h's sink moved; its source stays on B. + expect(stockLists(model, 'b').outflows).toEqual(['h']); + }); + + it('T11b: a create raced by a delete: the created variable is visible and the deleted flow is gone', async () => { + const m = await mount(projectJson(baseModel())); + let refusal: string | undefined | void; + act(() => { + refusal = canvasProps!.onCreateVariable(newAux('brand new', 600, 300)); + }); + expect(refusal).toBeUndefined(); + select([6]); + pressDelete(); + await m.settle(); + const model = await expectConsistent(m); + expect(model.variables.has('brand_new')).toBe(true); + expect(model.variables.has('k')).toBe(false); + }); + + it('T11c: an attach raced by a label move: both land', async () => { + const m = await mount(projectJson(baseModel())); + select([5]); + attachSink(m, 5, 1); + act(() => { + moveLabel(7, 'top'); + }); + await m.settle(); + const model = await expectConsistent(m); + expect(stockLists(model, 'a').inflows).toEqual(['g', 'h']); + expect((model.views[0].elements.find((el) => el.uid === 7) as { labelSide: string }).labelSide).toBe('top'); + }); + + it('T11e: an attach raced by deleting another flow: the saved bytes hold the invariants', async () => { + const m = await mount(projectJson(baseModel())); + select([5]); + attachSink(m, 5, 1); + select([6]); + pressDelete(); + await m.settle(); + const model = await expectConsistent(m); + expect(stockLists(model, 'a').inflows).toEqual(['g', 'h']); + expect(model.variables.has('k')).toBe(false); + + const bytes = await m.controller.query((e) => e.serializeProtobuf()); + const reopened = await engine.Project.openProtobuf(bytes!); + try { + const saved = mainModel(projectFromJson(JSON.parse(await reopened.serializeJson()) as JsonProject).models); + expectModelViewAgreement(saved); + } finally { + await reopened.dispose(); + } + }); + + it('T24: undo is unavailable while an attach is in flight, and undoes it once the attach lands', async () => { + const m = await mount(projectJson(baseModel())); + act(() => { + moveLabel(7, 'top'); + }); + await m.settle(); + expect((screen.getByLabelText('Undo') as HTMLButtonElement).disabled).toBe(false); + + select([5]); + attachSink(m, 5, 1); + expect((screen.getByLabelText('Undo') as HTMLButtonElement).disabled).toBe(true); + act(() => { + fireEvent.click(screen.getByLabelText('Undo')); + }); + await m.settle(); + let model = await expectConsistent(m); + expect(stockLists(model, 'a').inflows).toEqual(['g', 'h']); + + act(() => { + fireEvent.click(screen.getByLabelText('Undo')); + }); + await m.settle(); + model = await expectConsistent(m); + expect(stockLists(model, 'a').inflows).toEqual(['g']); + expect(stockLists(model, 'b').outflows).toEqual(['h']); + }); + + it('T12: attaching onto a stock that already lists the flow does not list it twice', async () => { + const model = baseModel(); + model.stocks[0].inflows = ['g', 'k']; + const m = await mount(projectJson(model)); + select([6]); + attachSink(m, 6, 1); + await m.settle(); + const after = await expectConsistent(m); + expect(stockLists(after, 'a').inflows).toEqual(['g', 'k']); + }); + + it('T6: creating a variable under a taken name is refused and enqueues nothing', async () => { + const m = await mount(projectJson(baseModel())); + const patchesBefore = m.patches.length; + let refusal: string | undefined | void; + act(() => { + refusal = canvasProps!.onCreateVariable(newAux('A', 600, 300)); + }); + expect(typeof refusal).toBe('string'); + await m.settle(); + expect(m.patches.length).toBe(patchesBefore); + const model = await expectConsistent(m); + expect(model.variables.get('a')?.type).toBe('stock'); + }); + + it('T20: renaming a variable onto a taken name is refused and enqueues nothing', async () => { + const m = await mount(projectJson(baseModel())); + const patchesBefore = m.patches.length; + select([7]); + let refusal: string | undefined | void; + act(() => { + refusal = canvasProps!.onRenameVariable('x', 'A'); + }); + expect(typeof refusal).toBe('string'); + await m.settle(); + expect(m.patches.length).toBe(patchesBefore); + const model = await expectConsistent(m); + expect(model.variables.get('x')?.type).toBe('aux'); + expect(model.variables.get('a')?.type).toBe('stock'); + }); + + it('two quick creates get distinct default names, and both variables exist', async () => { + const m = await mount(projectJson(baseModel())); + const first = canvasProps!.newVariableName!('New Variable'); + act(() => { + canvasProps!.onCreateVariable(newAux(first, 600, 300)); + }); + const second = canvasProps!.newVariableName!('New Variable'); + expect(second).not.toBe(first); + act(() => { + canvasProps!.onCreateVariable(newAux(second, 700, 300)); + }); + await m.settle(); + const model = await expectConsistent(m); + expect(model.variables.get(canonicalize(first))?.type).toBe('aux'); + expect(model.variables.get(canonicalize(second))?.type).toBe('aux'); + }); + + it('a flow drawn onto a stock whose create is still in flight attaches, and the stock lists it', async () => { + const m = await mount(projectJson(baseModel())); + act(() => { + canvasProps!.onCreateVariable(newStock('Reservoir', 600, 300)); + }); + const reservoir = m.view().elements.find((el) => nameOf(el) === 'Reservoir')!; + // release() fails the row unless the planner commits the drop. + act(() => { + release({ kind: 'createFlow', from: { stock: 1 } }, m.element(1), reservoir); + }); + await m.settle(); + const model = await expectConsistent(m); + const drawn = model.views[0].elements.find( + (el): el is FlowViewElement => + el.type === 'flow' && el.points[el.points.length - 1].attachedToUid === reservoir.uid, + ); + expect(drawn).toBeDefined(); + expect(stockLists(model, 'reservoir').inflows).toEqual([canonicalize(drawn!.name)]); + expect(stockLists(model, 'a').outflows).toEqual(['f', canonicalize(drawn!.name)].sort()); + }); + + it('T23: deleting a stock removes its alias and every link touching either', async () => { + const m = await mount(projectJson(baseModel())); + select([1]); + pressDelete(); + await m.settle(); + const model = await expectConsistent(m); + expect(model.variables.has('a')).toBe(false); + expect(model.views[0].elements.some((el) => el.type === 'alias')).toBe(false); + }); + + it('T1: deleting a lone cloud whose flow survives changes nothing', async () => { + const m = await mount(projectJson(baseModel())); + const before = await m.engineModel(); + select([21]); + pressDelete(); + await m.settle(); + const after = await expectConsistent(m); + expect(after.views[0].elements.map((el) => el.uid).sort()).toEqual( + before.views[0].elements.map((el) => el.uid).sort(), + ); + }); + + it('T22: an attach whose geometry is non-finite is refused whole: no stock ops, no view', async () => { + const m = await mount(projectJson(baseModel())); + const patchesBefore = m.patches.length; + select([4]); + // The planner never produces a non-finite coordinate (its fuzz asserts + // finiteness); this hand-builds one to exercise the executor's refusal, the + // defense in depth behind it (#818): f's sink detached to a NaN cloud. + const view = canvasProps!.view; + const f = m.element(4) as FlowViewElement; + const detached: FlowViewElement = { + ...f, + points: [f.points[0], { x: NaN, y: 100, attachedToUid: view.nextUid }], + }; + act(() => { + canvasProps!.onCommitGesture({ + label: 'flow attach', + elements: [ + ...view.elements.map((el) => (el.uid === 4 ? detached : el)), + { type: 'cloud', uid: view.nextUid, flowUid: 4, x: NaN, y: 100, isZeroRadius: false, ident: undefined }, + ], + nextUid: view.nextUid + 1, + selection: new Set([4]), + token: canvasProps!.token, + baseView: view, + }); + }); + await m.settle(); + expect(m.patches.length).toBe(patchesBefore); + const model = await expectConsistent(m); + expect(stockLists(model, 'b').inflows).toEqual(['f']); + }); + + it('typing in a details panel then dragging a stock: the draft lands first and the text survives', async () => { + const m = await mount(projectJson(baseModel())); + select([1]); + act(() => { + canvasProps!.onShowVariableDetails(); + }); + const unitsEl = document.querySelector('.unitsEditor') as HTMLElement; + expect(unitsEl).not.toBeNull(); + const editor = ELEMENT_TO_NODE.get(unitsEl) as unknown as SlateEditor; + await act(async () => { + Transforms.insertText(editor, 'widgets', { at: SlateEditor.end(editor, []) }); + editor.onChange(); + await Promise.resolve(); + }); + + // The press that starts the drag (the Canvas prevents its default, so the + // panel never blurs), then the drag's commit. + const canvas = screen.getByTestId('canvas'); + act(() => { + fireEvent.pointerDown(canvas); + }); + act(() => { + release({ kind: 'moveSelection' }, { x: 100, y: 100 }, { x: 150, y: 140 }); + }); + act(() => { + fireEvent.pointerUp(canvas); + }); + await m.settle(); + + const model = await expectConsistent(m); + expect(model.variables.get('a')?.units).toBe('widgets'); + const stock = model.views[0].elements.find((el) => el.uid === 1)!; + expect({ x: stock.x, y: stock.y }).toEqual({ x: 150, y: 140 }); + // The draft's edit reached the engine before the move. + const opTypes = m.patches.map((p) => p.models![0].ops[0].type); + expect(opTypes.indexOf('upsertStock')).toBeLessThan(opTypes.lastIndexOf('upsertView')); + expect(document.querySelector('.unitsEditor')?.textContent).toBe('widgets'); + }); + + it('R1: renaming an element again while its first rename is pending takes the second name', async () => { + const m = await mount(projectJson(baseModel())); + select([7]); + let first: string | undefined | void; + let second: string | undefined | void; + act(() => { + first = canvasProps!.onRenameVariable('x', 'Y'); + }); + expect(first).toBeUndefined(); + // The Canvas commits the next rename with the name the element renders. + const renderedName = nameOf(m.element(7))!; + expect(renderedName).toBe('Y'); + act(() => { + second = canvasProps!.onRenameVariable(renderedName, 'Z'); + }); + expect(second).toBeUndefined(); + expect(nameOf(m.element(7))).toBe('Z'); + await m.settle(); + const model = await expectConsistent(m); + expect([...model.variables.keys()].filter((k) => ['x', 'y', 'z'].includes(k))).toEqual(['z']); + expect(model.variables.get('f')?.type === 'flow' && model.variables.get('f')!.equation).toMatchObject({ + equation: 'z', + }); + expect(alerts()).toEqual([]); + }); + + it('R3: renaming a just-created element while its create is pending takes the new name', async () => { + const m = await mount(projectJson(baseModel())); + // As the Canvas commits a create: staged under the default name's ident, + // with the typed name. + act(() => { + canvasProps!.onCreateVariable({ ...newAux('Births', 600, 300), ident: canonicalize('New Variable') }); + }); + const created = m.view().elements.find((el) => nameOf(el) === 'Births')!; + // Every rendered element's ident is its name's, a pending create's too. + expect(created).toMatchObject({ ident: 'births' }); + select([created.uid]); + act(() => { + canvasProps!.onRenameVariable('Births', 'Deaths'); + }); + expect(m.element(created.uid)).toMatchObject({ name: 'Deaths', ident: 'deaths' }); + await m.settle(); + const model = await expectConsistent(m); + expect(model.variables.has('deaths')).toBe(true); + expect(model.variables.has('births')).toBe(false); + expect(alerts()).toEqual([]); + }); + + it('R4: typing in the panel of a variable whose rename is pending keeps the text, and a later edit survives', async () => { + const m = await mount(projectJson(baseModel())); + select([7]); + act(() => { + canvasProps!.onShowVariableDetails(); + }); + act(() => { + canvasProps!.onRenameVariable('x', 'Y'); + }); + // The panel stays open on the renamed element while the rename is pending. + await typeUnits('widgets'); + // A canvas press flushes the draft; then an unrelated diagram edit. + const canvas = screen.getByTestId('canvas'); + act(() => { + fireEvent.pointerDown(canvas); + fireEvent.pointerUp(canvas); + }); + act(() => { + moveLabel(1, 'top'); + }); + await m.settle(); + const model = await expectConsistent(m); + expect({ alerts: alerts(), units: model.variables.get('y')?.units, labelSide: labelSideOf(model, 1) }).toEqual({ + alerts: [], + units: 'widgets', + labelSide: 'top', + }); + }); + + it('a draft typed while a rename is pending lands on the variable even when the rename rolls back', async () => { + const m = await mount(projectJson(baseModel())); + select([7]); + act(() => { + canvasProps!.onShowVariableDetails(); + }); + m.armPatchFailure(); + act(() => { + canvasProps!.onRenameVariable('x', 'Y'); + }); + await typeUnits('widgets'); + // Flushed under the pending name; the edit resolves its variable through + // the element on the committed view when it runs. + const canvas = screen.getByTestId('canvas'); + act(() => { + fireEvent.pointerDown(canvas); + fireEvent.pointerUp(canvas); + }); + await m.settle(); + const model = await expectConsistent(m); + expect(model.variables.has('y')).toBe(false); + expect(model.variables.get('x')?.units).toBe('widgets'); + expect(nameOf(m.element(7))).toBe('x'); + expect(alerts()).toEqual(['injected patch failure']); + }); + + it('R9/N4: a patch whose read-back fails once lands on the re-read: an edit planned on it lands too, and nothing is reported', async () => { + const m = await mount(projectJson(baseModel())); + m.armReadFailure(); + act(() => { + canvasProps!.onCreateVariable(newAux('brand new', 600, 300)); + }); + act(() => { + moveLabel(1, 'top'); + }); + await m.settle(); + const model = await expectConsistent(m); + expect(model.variables.has('brand_new')).toBe(true); + expect(labelSideOf(model, 1)).toBe('top'); + expect(alerts()).toEqual([]); + }); + + it('N1: an edit made while a failed read-back reopens the project is discarded with the failed edit', async () => { + const m = await mount(projectJson(baseModel())); + let openGate!: () => void; + const gate = new Promise((resolve) => { + openGate = resolve; + }); + let reopenStarted = false; + const openProtobuf = EngineProject.openProtobuf.bind(EngineProject); + rs.spyOn(EngineProject, 'openProtobuf').mockImplementation(async (...args) => { + reopenStarted = true; + await gate; + return openProtobuf(...args); + }); + // The create's read-back fails, and so does the re-read. + m.armReadFailure(2); + act(() => { + canvasProps!.onCreateVariable(newAux('brand new', 600, 300)); + }); + await waitFor(() => reopenStarted); + // While the new engine opens the failed create still renders, and a label + // move is planned on it. + expect(m.view().elements.some((el) => nameOf(el) === 'brand new')).toBe(true); + act(() => { + moveLabel(1, 'top'); + }); + openGate(); + await m.settle(); + const model = await expectConsistent(m); + expect(model.variables.has('brand_new')).toBe(false); + expect(labelSideOf(model, 1)).not.toBe('top'); + expect(alerts()).toEqual([ + 'reading the project back after variable creation failed: injected read failure (1 later edit discarded)', + ]); + }); + + it('N10: an engine that cannot be reopened shows one persistent notice offering a reload, and edits are refused without a toast each', async () => { + const onReload = rs.fn(); + const m = await mount(projectJson(baseModel()), { onReload }); + // History to undo, so undo refusal below is the unavailable state's doing. + act(() => { + moveLabel(7, 'top'); + }); + await m.settle(); + expect((screen.getByLabelText('Undo') as HTMLButtonElement).disabled).toBe(false); + rs.spyOn(EngineProject, 'openProtobuf').mockImplementation(async () => { + throw new Error('reopen failed'); + }); + m.armReadFailure(2); + act(() => { + canvasProps!.onCreateVariable(newAux('brand new', 600, 300)); + }); + await m.settle(); + expect(screen.getAllByText('The model engine stopped working')).toHaveLength(1); + + let refusal: string | undefined | void; + act(() => { + refusal = canvasProps!.onCreateVariable(newAux('another', 600, 400)); + moveLabel(1, 'top'); + }); + await m.settle(); + expect(refusal).toBe('The project cannot be edited until it is reloaded'); + expect(alerts()).toEqual([]); + expect(screen.getAllByText('The model engine stopped working')).toHaveLength(1); + act(() => { + fireEvent.click(screen.getByText('Reload')); + }); + expect(onReload).toHaveBeenCalledTimes(1); + // Undo and redo are unavailable too. + expect((screen.getByLabelText('Undo') as HTMLButtonElement).disabled).toBe(true); + m.controller.undoRedo('undo'); + expect(m.controller.getSnapshot().undoRedoQueued).toBe(false); + }); + + it('R10/N2: while an undo is queued, Delete does nothing and reports nothing, and a typed name is refused visibly', async () => { + const m = await mount(projectJson(baseModel())); + act(() => { + moveLabel(7, 'top'); + }); + await m.settle(); + select([6]); + const root = document.querySelector('[data-simlin-editor-root]') as HTMLElement; + let createRefusal: string | undefined | void; + let renameRefusal: string | undefined | void; + act(() => { + fireEvent.keyDown(root, { key: 'z', ctrlKey: true }); + fireEvent.keyDown(root, { key: 'Delete' }); + // A name commit returning a message keeps the Canvas's name editor open. + createRefusal = canvasProps!.onCreateVariable(newAux('Births', 600, 300)); + renameRefusal = canvasProps!.onRenameVariable('x', 'Renamed'); + }); + expect([createRefusal, renameRefusal]).toEqual([ + 'Wait for the undo or redo to finish', + 'Wait for the undo or redo to finish', + ]); + // The selection was not cleared for a delete that could not happen. + expect(canvasProps!.selection).toEqual(new Set([6])); + await m.settle(); + const model = await expectConsistent(m); + expect(model.variables.has('k')).toBe(true); + expect(model.variables.has('births')).toBe(false); + expect(model.variables.has('renamed')).toBe(false); + expect(labelSideOf(model, 7)).not.toBe('top'); + expect(alerts()).toEqual([]); + }); + + it('two edits made in one tick both land: each handler plans on the view the previous one produced', async () => { + const m = await mount(projectJson(baseModel())); + act(() => { + canvasProps!.onCreateVariable(newAux('brand new', 600, 300)); + pressDelete(); + }); + await m.settle(); + const model = await expectConsistent(m); + expect(model.variables.has('brand_new')).toBe(true); + }); + + it('a gesture planned on a view another edit replaced in the same tick is dropped quietly (E5), the first landing', async () => { + const m = await mount(projectJson(baseModel())); + act(() => { + moveLabel(1, 'top'); + // Planned on the same rendered view the first gesture planned on, which + // the first edit has replaced: committing it would revert that edit. + moveLabel(2, 'bottom'); + }); + await m.settle(); + const model = await expectConsistent(m); + expect(labelSideOf(model, 1)).toBe('top'); + expect(labelSideOf(model, 2)).not.toBe('bottom'); + expect(alerts()).toEqual([]); + }); + + it('Undo pressed with a draft in the details panel takes the draft back; Redo restores it', async () => { + const m = await mount(projectJson(baseModel())); + act(() => { + moveLabel(7, 'top'); + }); + await m.settle(); + select([1]); + act(() => { + canvasProps!.onShowVariableDetails(); + }); + await typeUnits('widgets'); + + // The press on Undo, as a browser delivers it: React commits what the + // pointerdown changed before the click is dispatched, so a draft flushed on + // the press would disable the button before its click. + const undo = screen.getByLabelText('Undo') as HTMLButtonElement; + expect(undo.disabled).toBe(false); + act(() => { + fireEvent.pointerDown(undo); + fireEvent.mouseDown(undo); + }); + act(() => { + fireEvent.pointerUp(undo); + fireEvent.mouseUp(undo); + fireEvent.click(undo); + }); + await m.settle(); + let model = await expectConsistent(m); + expect(model.variables.get('a')?.units).toBe(''); + // Only the draft was taken back. + expect(labelSideOf(model, 7)).toBe('top'); + expect( + SlateEditor.string( + ELEMENT_TO_NODE.get(document.querySelector('.unitsEditor') as HTMLElement) as unknown as SlateEditor, + [], + ), + ).toBe(''); + + act(() => { + fireEvent.click(screen.getByLabelText('Redo')); + }); + await m.settle(); + model = await expectConsistent(m); + expect(model.variables.get('a')?.units).toBe('widgets'); + expect(alerts()).toEqual([]); + }); + + it('N5: text typed after a flush survives the flushed edit landing, and lands once submitted', async () => { + const m = await mount(projectJson(baseModel())); + select([1]); + act(() => { + canvasProps!.onShowVariableDetails(); + }); + await typeUnits('widgets'); + pressCanvas(); + // The flushed edit is in flight; the user keeps typing. + await typeUnits(' more'); + await m.settle(); + let model = await m.engineModel(); + expect({ units: model.variables.get('a')?.units, panel: unitsText() }).toEqual({ + units: 'widgets', + panel: 'widgets more', + }); + pressCanvas(); + await m.settle(); + model = await expectConsistent(m); + expect({ units: model.variables.get('a')?.units, panel: unitsText() }).toEqual({ + units: 'widgets more', + panel: 'widgets more', + }); + }); + + it('a draft in the panel of a variable whose equation a rename rewrites survives the rename, and submitting it keeps the rewrite', async () => { + const m = await mount(projectJson(baseModel())); + select([4]); + act(() => { + canvasProps!.onShowVariableDetails(); + }); + await typeUnits('widgets'); + // f's equation reads x, so renaming x rewrites it when the rename lands. + act(() => { + canvasProps!.onRenameVariable('x', 'Y'); + }); + await m.settle(); + expect(unitsText()).toBe('widgets'); + pressCanvas(); + await m.settle(); + const model = await expectConsistent(m); + const f = model.variables.get('f'); + expect({ units: f?.units, equation: f?.type === 'flow' ? f.equation : undefined, alerts: alerts() }).toEqual({ + units: 'widgets', + equation: expect.objectContaining({ equation: 'y' }), + alerts: [], + }); + }); + + it('an unrelated edit landing while the user types keeps the panel and its draft', async () => { + const m = await mount(projectJson(baseModel())); + select([1]); + act(() => { + canvasProps!.onShowVariableDetails(); + }); + const editor = await typeUnits('widgets'); + act(() => { + moveLabel(7, 'top'); + }); + await m.settle(); + expect(labelSideOf(await m.engineModel(), 7)).toBe('top'); + expect(ELEMENT_TO_NODE.get(document.querySelector('.unitsEditor') as HTMLElement)).toBe(editor); + expect(unitsText()).toBe('widgets'); + }); + + it('N7: Undo pressed with a draft whose edit fails keeps the draft and undoes nothing', async () => { + const m = await mount(projectJson(baseModel())); + act(() => { + moveLabel(7, 'top'); + }); + await m.settle(); + select([1]); + act(() => { + canvasProps!.onShowVariableDetails(); + }); + await typeUnits('widgets'); + m.armPatchFailure(); + pressButton('Undo'); + await m.settle(); + const model = await expectConsistent(m); + expect({ + label7: labelSideOf(model, 7), + units: model.variables.get('a')?.units, + panel: unitsText(), + alerts: alerts(), + }).toEqual({ label7: 'top', units: '', panel: 'widgets', alerts: ['injected patch failure'] }); + }); + + it('N8: Redo pressed with a draft in the panel redoes, then lands the draft on the redone project', async () => { + const m = await mount(projectJson(baseModel())); + act(() => { + moveLabel(7, 'top'); + }); + await m.settle(); + act(() => { + fireEvent.click(screen.getByLabelText('Undo')); + }); + await m.settle(); + expect(labelSideOf(await m.engineModel(), 7)).not.toBe('top'); + select([1]); + act(() => { + canvasProps!.onShowVariableDetails(); + }); + await typeUnits('widgets'); + pressButton('Redo'); + await m.settle(); + const model = await expectConsistent(m); + expect({ + label7: labelSideOf(model, 7), + units: model.variables.get('a')?.units, + panel: unitsText(), + alerts: alerts(), + }).toEqual({ label7: 'top', units: 'widgets', panel: 'widgets', alerts: [] }); + }); + + it('O3: two names swapped through three pending renames resolve every rendered element mid-flight and land as a swap', async () => { + const m = await mount(projectJson(baseModel())); + const refusals: Array = []; + act(() => { + refusals.push(canvasProps!.onRenameVariable('x', 'tmp')); + }); + act(() => { + refusals.push(canvasProps!.onRenameVariable('A', 'x')); + }); + act(() => { + refusals.push(canvasProps!.onRenameVariable('tmp', 'A')); + }); + const rendered = m.controller.getModel()!; + const unresolved = m + .view() + .elements.filter((el) => isNamedViewElement(el) && !rendered.variables.has(el.ident)) + .map((el) => el.uid); + expect({ + refusals, + unresolved, + a: rendered.variables.get('a')?.type, + x: rendered.variables.get('x')?.type, + }).toEqual({ refusals: [undefined, undefined, undefined], unresolved: [], a: 'aux', x: 'stock' }); + await m.settle(); + const model = await expectConsistent(m); + expect({ a: model.variables.get('a')?.type, x: model.variables.get('x')?.type, alerts: alerts() }).toEqual({ + a: 'aux', + x: 'stock', + alerts: [], + }); + }); + + it('N6: a draft whose edit is lost to a reopen stays in the panel, to submit again', async () => { + const m = await mount(projectJson(baseModel())); + select([1]); + act(() => { + canvasProps!.onShowVariableDetails(); + }); + await typeUnits('widgets'); + // The flushed edit applies, and neither its read-back nor the re-read works, + // so the last recorded snapshot is reopened without it. + m.armReadFailure(2); + pressCanvas(); + await m.settle(); + const model = await expectConsistent(m); + expect({ units: model.variables.get('a')?.units, panel: unitsText(), alerts: alerts() }).toEqual({ + units: '', + panel: 'widgets', + alerts: ['reading the project back after equation update failed: injected read failure'], + }); + // It is still a draft: the next press submits it again, and it lands. + pressCanvas(); + await m.settle(); + expect((await m.engineModel()).variables.get('a')?.units).toBe('widgets'); + }); + + it('F1/P1: a field changed back while its flushed edit is in flight holds its text, and the next press lands it', async () => { + const m = await mount(projectJson(baseModel())); + select([1]); + act(() => { + canvasProps!.onShowVariableDetails(); + }); + await typeUnits('widgets'); + pressCanvas(); + // The flushed edit is in flight; the user changes their mind. + await setField('.unitsEditor', ''); + await m.settle(); + // The edit landed underneath, without remounting the panel over the cleared field. + expect((await m.engineModel()).variables.get('a')?.units).toBe('widgets'); + expect(fieldText('.unitsEditor')).toBe(''); + pressCanvas(); + await m.settle(); + const model = await expectConsistent(m); + expect({ units: model.variables.get('a')?.units, panel: fieldText('.unitsEditor'), alerts: alerts() }).toEqual({ + units: '', + panel: '', + alerts: [], + }); + }); + + it('F1/P1b: with another field holding the panel, a field changed back after its edit landed is submitted', async () => { + const m = await mount(projectJson(baseModel())); + select([1]); + act(() => { + canvasProps!.onShowVariableDetails(); + }); + await typeUnits('widgets'); + pressCanvas(); + // While the units edit is in flight, the user writes documentation. + await setField('.notesEditor', 'hello'); + await m.settle(); + expect((await m.engineModel()).variables.get('a')?.units).toBe('widgets'); + // Documentation holds the panel; units goes back to what it was. + await setField('.unitsEditor', ''); + pressCanvas(); + await m.settle(); + const model = await expectConsistent(m); + expect({ + units: model.variables.get('a')?.units, + docs: model.variables.get('a')?.documentation, + panel: fieldText('.unitsEditor'), + alerts: alerts(), + }).toEqual({ units: '', docs: 'hello', panel: '', alerts: [] }); + }); + + it("HK4: selecting another element while the panel holds a draft opens that element's own panel", async () => { + const m = await mount(projectJson(baseModel())); + select([1]); + act(() => { + canvasProps!.onShowVariableDetails(); + }); + await typeUnits('widgets'); + pressCanvas(); + // A draft past the submitted text holds the panel's key. + await typeUnits(' more'); + select([2]); + act(() => { + canvasProps!.onShowVariableDetails(); + }); + expect(unitsText()).toBe(''); + pressCanvas(); + await m.settle(); + const model = await expectConsistent(m); + expect({ a: model.variables.get('a')?.units, b: model.variables.get('b')?.units }).toEqual({ a: 'widgets', b: '' }); + }); + + it('M1: while an undo is queued the details panel is read-only, and it is editable again once the undo lands', async () => { + const m = await mount(projectJson(baseModel())); + act(() => { + moveLabel(7, 'top'); + }); + await m.settle(); + select([1]); + act(() => { + canvasProps!.onShowVariableDetails(); + }); + let openGate!: () => void; + const gate = new Promise((resolve) => { + openGate = resolve; + }); + const openProtobuf = EngineProject.openProtobuf.bind(EngineProject); + rs.spyOn(EngineProject, 'openProtobuf').mockImplementation(async (...args) => { + await gate; + return openProtobuf(...args); + }); + pressButton('Undo'); + expect(m.controller.getSnapshot().undoRedoQueued).toBe(true); + expect(document.querySelector('.unitsEditor')!.getAttribute('contenteditable')).toBe('false'); + openGate(); + await m.settle(); + expect(labelSideOf(await m.engineModel(), 7)).not.toBe('top'); + expect(document.querySelector('.unitsEditor')!.getAttribute('contenteditable')).toBe('true'); + }); + + it('F1/P1 docs: a documentation field changed back while its flushed edit is in flight holds its text, and the next press lands it', async () => { + const m = await mount(projectJson(baseModel())); + select([1]); + act(() => { + canvasProps!.onShowVariableDetails(); + }); + await setField('.notesEditor', 'hello'); + pressCanvas(); + await setField('.notesEditor', ''); + await m.settle(); + expect((await m.engineModel()).variables.get('a')?.documentation).toBe('hello'); + expect(fieldText('.notesEditor')).toBe(''); + pressCanvas(); + await m.settle(); + const model = await expectConsistent(m); + expect({ docs: model.variables.get('a')?.documentation, panel: fieldText('.notesEditor') }).toEqual({ + docs: '', + panel: '', + }); + }); + + it('P14: a panel reopened while its own submission is in flight shows the submitted text, not the text it replaces', async () => { + const m = await mount(projectJson(baseModel())); + select([1]); + act(() => { + canvasProps!.onShowVariableDetails(); + }); + await typeUnits('widgets'); + pressCanvas(); + select([2]); + select([1]); + act(() => { + canvasProps!.onShowVariableDetails(); + }); + const reopened = unitsText(); + await m.settle(); + const model = await expectConsistent(m); + expect({ reopened, afterLanding: unitsText(), units: model.variables.get('a')?.units, alerts: alerts() }).toEqual({ + reopened: 'widgets', + afterLanding: 'widgets', + units: 'widgets', + alerts: [], + }); + // The reopened panel took the submission as its base: the text is not a + // draft, so the next press submits nothing. + const patches = m.patches.length; + pressCanvas(); + await m.settle(); + expect(m.patches).toHaveLength(patches); + }); + + it('P14 failure: a panel reopened on a pending submission that then fails keeps the text as a draft to submit again', async () => { + const m = await mount(projectJson(baseModel())); + select([1]); + act(() => { + canvasProps!.onShowVariableDetails(); + }); + await typeUnits('widgets'); + m.armPatchFailure(); + pressCanvas(); + select([2]); + select([1]); + act(() => { + canvasProps!.onShowVariableDetails(); + }); + expect(unitsText()).toBe('widgets'); + await m.settle(); + expect({ + units: (await m.engineModel()).variables.get('a')?.units, + panel: unitsText(), + alerts: alerts(), + }).toEqual({ units: '', panel: 'widgets', alerts: ['injected patch failure'] }); + pressCanvas(); + await m.settle(); + expect((await expectConsistent(m)).variables.get('a')?.units).toBe('widgets'); + }); + + it("M4: an element with no stored label side renders centered, and a pan's persist or an edit's view keeps it absent", async () => { + const m = await mount(projectJson(baseModel())); + // x (uid 7) has no labelSide in the fixture. + expect((m.element(7) as { labelSide: string }).labelSide).toBe('center'); + const rawElement = async (uid: number) => { + const raw = JSON.parse((await m.controller.query((engine) => engine.serializeJson()))!) as JsonProject; + const main = raw.models.find((model) => model.name === 'main')!; + const view = main.views![0] as unknown as { zoom?: number; elements: Array<{ uid: number; labelSide?: string }> }; + return { zoom: view.zoom, element: view.elements.find((el) => el.uid === uid)! }; + }; + expect((await rawElement(7)).element.labelSide).toBeUndefined(); + act(() => { + canvasProps!.onViewBoxChange({ x: 40, y: 40, width: 800, height: 600 }, 1.5); + }); + await m.settle(); + let persisted = await rawElement(7); + expect({ zoom: persisted.zoom, labelSide: persisted.element.labelSide }).toEqual({ + zoom: 1.5, + labelSide: undefined, + }); + act(() => { + moveLabel(1, 'top'); + }); + await m.settle(); + persisted = await rawElement(7); + expect(persisted.element.labelSide).toBeUndefined(); + expect((await rawElement(1)).element.labelSide).toBe('top'); + }); + + it('ED4: a drawn flow keeps the details panel closed until it is named, landed or not', async () => { + const m = await mount(projectJson(baseModel())); + select([1]); + act(() => { + canvasProps!.onShowVariableDetails(); + }); + expect(document.querySelector('.unitsEditor')).not.toBeNull(); + act(() => { + release({ kind: 'createFlow', from: 'empty' }, { x: 600, y: 500 }, { x: 720, y: 500 }); + }); + expect(canvasProps!.selection.size).toBe(1); + expect(document.querySelector('.unitsEditor')).toBeNull(); + await m.settle(); + expect((await m.engineModel()).variables.get('new_flow')?.type).toBe('flow'); + expect(document.querySelector('.unitsEditor')).toBeNull(); + }); + + it('ED2: a commit carries its press token, so once another edit moves the token it is refused', async () => { + const m = await mount(projectJson(baseModel())); + const pressToken = canvasProps!.token; + const pressView = canvasProps!.view; + // A failing edit rolls back, moving the token; the rendered geometry returns + // to what the press saw, so only the token tells the commit is stale. + m.armPatchFailure(); + act(() => { + moveLabel(7, 'top'); + }); + await m.settle(); + expect(canvasProps!.token).not.toBe(pressToken); + expect(sameGeometry(pressView, canvasProps!.view)).toBe(true); + + const p = canvasProps!; + const plan = planGesture({ + view: pressView, + variables: p.model.variables, + selection: new Set([1]), + gesture: { kind: 'label', uid: 1 }, + press: { x: 100, y: 60 }, + current: { x: 100, y: 60 }, + zoom: 1, + pointerType: 'mouse', + readOnly: false, + names: p.newVariableName!, + }); + expect(plan.commit).toBe('edit'); + act(() => { + p.onCommitGesture({ + label: plan.label, + elements: plan.elements, + nextUid: plan.nextUid, + selection: plan.selection, + token: pressToken, + baseView: p.view, + }); + }); + await m.settle(); + expect(labelSideOf(await m.engineModel(), 1)).not.toBe('top'); + }); +}); diff --git a/src/diagram/tests/editor-flow-attach-failure.test.ts b/src/diagram/tests/editor-flow-attach-failure.test.ts index c9977dbd4..4b34a9ed3 100644 --- a/src/diagram/tests/editor-flow-attach-failure.test.ts +++ b/src/diagram/tests/editor-flow-attach-failure.test.ts @@ -2,21 +2,19 @@ // Use of this source code is governed by the Apache License, // Version 2.0, that can be found in the LICENSE file. -// Issue #820: a failed flow-attach patch must not silently discard the drawn -// flow. handleFlowAttach used to early-return when the model-level patch failed, -// leaving the drawn flow uncommitted -- so the flow the user just drew vanished -// (the only feedback a transient toast) AND the just-created-flow name edit was -// left selecting a flow that was not in the view (the getElementByUid crash). +// A drawn flow renders from release until its patch settles, and a failed +// patch ROLLS BACK the drawn flow: the model and the diagram never disagree. +// The drawn flow renders at once (the rendered view rule), and when the patch is +// rejected the rendered view returns to the committed one, which has no flow; +// the failure is reported once. (Committing the view anyway saved a diagram +// whose flow names no variable.) // -// The fix commits the optimistic view regardless of patch success, matching the -// sibling handlers (handleCreateVariable / handleSelectionDelete): the drawn -// flow stays on the canvas (and stays a real, selectable element), while the -// engine error still surfaces via the toast. -// -// This drives the real Editor + a real ProjectController wired to a fake engine -// scripted to reject every applyPatch (the corrupt-project failure mode). Canvas -// is mocked to a null renderer that captures the props (notably onMoveFlow = -// handleFlowAttach) so the flow-attach can be invoked directly. +// This drives the real Editor and a real ProjectController wired to a fake +// engine that holds each patch behind a gate (the worker round trip). Canvas is +// mocked to a null renderer that captures its props; the commit handed to +// onCommitGesture is planned by the production planner from those props, as the +// Canvas does at release. What the Canvas does with a selection naming the +// rolled-back flow is pinned in canvas-gestures-flow-attach-failure.test.tsx. import { describe, it, expect, afterEach, rs } from '@rstest/core'; @@ -25,20 +23,22 @@ import { act, render, screen } from '@testing-library/react'; import type { FlowViewElement, StockFlowView, ViewElement } from '@simlin/core/datamodel'; import { Project as EngineProject } from '@simlin/engine'; -import { inCreationCloudUid, fauxCloudTargetUid } from '../drawing/creation-sentinels'; -import { makeFakeEngine, validProjectJson } from './fake-engine'; +import type { CanvasProps } from '../drawing/Canvas'; +import { planGesture } from '../gesture-planner'; +import { makeFakeEngine, makeGate, validProjectJson } from './fake-engine'; -// Capture the props the Editor hands to Canvas so a test can invoke onMoveFlow -// (handleFlowAttach) directly and read back the committed view/selection. -let capturedCanvasProps: Record | undefined; +let capturedCanvasProps: CanvasProps | undefined; +// Every view the Canvas was rendered with, in order, so a frame published and +// replaced inside one flush is still seen. +const renderedViews: StockFlowView[] = []; rs.mock('../drawing/Canvas', () => ({ __esModule: true, - Canvas: (props: Record) => { + Canvas: (props: CanvasProps) => { capturedCanvasProps = props; + renderedViews.push(props.view); return null; }, - inCreationUid: -2, })); import { Editor, type EditorProps } from '../Editor'; @@ -54,89 +54,113 @@ function makeProps(overrides: Partial = {}): EditorProps { } as EditorProps; } -async function flushTimers(): Promise { - for (let i = 0; i < 5; i++) { +async function flushUntil(condition: () => boolean): Promise { + for (let i = 0; i < 200 && !condition(); i++) { await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); }); } } -// An in-creation flow drawn out of empty space toward empty space: source -// staged on the in-creation source cloud, sink on the faux sink target. This is -// the element Canvas passes to onMoveFlow at pointer-up, with targetUid 0 (no -// snap target) and a faux target center for the released sink position. -function inCreationFlow(): FlowViewElement { - return { - type: 'flow', - uid: -2, // inCreationUid - var: undefined, - name: 'New Flow', - ident: 'new_flow', - x: 200, - y: 200, - labelSide: 'bottom', - isZeroRadius: false, - points: [ - { x: 200, y: 200, attachedToUid: inCreationCloudUid }, - { x: 200, y: 200, attachedToUid: fauxCloudTargetUid }, - ], - }; +// The flow tool dragged from empty space into empty space, released: the +// commit the Canvas hands the Editor at pointer-up. +function drawFlow(): void { + const p = capturedCanvasProps!; + const plan = planGesture({ + view: p.view, + variables: p.model.variables, + selection: p.selection, + gesture: { kind: 'createFlow', from: 'empty' }, + press: { x: 200, y: 200 }, + current: { x: 300, y: 200 }, + zoom: 1, + pointerType: 'mouse', + readOnly: false, + names: p.newVariableName!, + }); + expect(plan.commit).toBe('edit'); + p.onCommitGesture({ + label: plan.label, + elements: plan.elements, + nextUid: plan.nextUid, + selection: plan.selection, + token: p.token, + baseView: p.view, + editName: plan.handoff?.editName, + }); +} + +function renderedView(): StockFlowView { + return capturedCanvasProps!.view; +} + +function flows(view: StockFlowView): FlowViewElement[] { + return view.elements.filter((e: ViewElement): e is FlowViewElement => e.type === 'flow'); +} + +async function mount(applyPatchThrows: boolean) { + const gate = makeGate(); + const engine = makeFakeEngine({ + applyPatchThrows, + applyPatchGate: () => gate.wait(), + json: validProjectJson(), + }); + rs.spyOn(EngineProject, 'openJson').mockResolvedValue(engine as unknown as EngineProject); + rs.spyOn(console, 'error').mockImplementation(() => {}); + + act(() => { + render(React.createElement(Editor, makeProps())); + }); + await flushUntil(() => capturedCanvasProps?.view !== undefined); + return { gate, engine }; } -describe('Editor flow-attach patch failure (issue #820)', () => { +describe('Editor flow-creation patch lifecycle', () => { afterEach(() => { rs.restoreAllMocks(); capturedCanvasProps = undefined; + renderedViews.length = 0; + }); + + // This stops at the engine accepting the patch: the fake's serialization is + // static, so its read-back cannot carry the flow. The landed state against a + // real engine is editor-engine-races.test.ts's job. + it('F1: the drawn flow renders in every frame from release until the engine accepts the patch', async () => { + const { gate, engine } = await mount(false); + const releasedAt = renderedViews.length; + + act(() => drawFlow()); + const drawn = flows(renderedView()); + expect(drawn).toHaveLength(1); + expect(engine.appliedPatches).toHaveLength(0); + + gate.open(); + await flushUntil(() => engine.appliedPatches.length === 1); + expect(engine.appliedPatches).toHaveLength(1); + const frames = renderedViews.slice(releasedAt); + expect(frames.length).toBeGreaterThan(0); + for (const view of frames) { + expect(flows(view).map((f) => f.uid)).toEqual([drawn[0].uid]); + } }); - it('preserves the drawn flow (and selects a real element) when the attach patch fails', async () => { - // A fake engine that rejects every applyPatch models the corrupt-project - // failure mode the issue was observed against. - const engine = makeFakeEngine({ applyPatchThrows: true, json: validProjectJson() }); - rs.spyOn(EngineProject, 'openJson').mockResolvedValue(engine as unknown as EngineProject); + it('renders the drawn flow at once, then rolls it back and reports once when the patch fails', async () => { + const { gate, engine } = await mount(true); - act(() => { - render(React.createElement(Editor, makeProps())); - }); - await flushTimers(); + act(() => drawFlow()); + // Rendered before the patch lands. + expect(flows(renderedView())).toHaveLength(1); + const drawn = flows(renderedView())[0]; + expect(capturedCanvasProps!.selection.has(drawn.uid)).toBe(true); - const props = capturedCanvasProps; - if (!props) { - throw new Error('Editor never rendered Canvas'); - } - const onMoveFlow = props.onMoveFlow as ( - flow: FlowViewElement, - targetUid: number, - delta: { x: number; y: number }, - fauxTargetCenter: { x: number; y: number } | undefined, - inCreation: boolean, - isSourceAttach?: boolean, - ) => Promise; + gate.open(); + await flushUntil(() => flows(renderedView()).length === 0); - await act(async () => { - await onMoveFlow(inCreationFlow(), 0, { x: -100, y: 0 }, { x: 300, y: 200 }, true, false); - }); - await flushTimers(); - - // The committed view must contain the drawn flow -- it was NOT discarded. - const committedView = capturedCanvasProps!.view as StockFlowView; - const flows = committedView.elements.filter((e: ViewElement): e is FlowViewElement => e.type === 'flow'); - expect(flows).toHaveLength(1); - const flow = flows[0]; - - // The flow carries a real (committed, non-sentinel) uid. - expect(flow.uid).toBeGreaterThan(0); - - // The selection references that real, in-view element -- no phantom that a - // later name-edit commit would dereference and crash on. - const selection = capturedCanvasProps!.selection as ReadonlySet; - expect(selection.has(flow.uid)).toBe(true); - expect(committedView.elements.some((e: ViewElement) => e.uid === flow.uid)).toBe(true); - - // The failure is NOT swallowed: the controller's onError surfaces the engine - // error as a toast (the non-silent feedback the preserve-the-flow UX relies - // on). The fake engine rejects with message 'patch rejected'. - expect(screen.getAllByText('patch rejected').length).toBeGreaterThan(0); + // Rolled back: the rendered view is the committed one -- no flow, no clouds. + expect(flows(renderedView())).toHaveLength(0); + expect(renderedView().elements.filter((e) => e.type === 'cloud')).toHaveLength(0); + expect(engine.appliedPatches).toHaveLength(0); + // Reported once, naming no discarded edits (there were none). + expect(screen.getAllByText('patch rejected')).toHaveLength(1); }); }); diff --git a/src/diagram/tests/editor-gestures-engine.test.ts b/src/diagram/tests/editor-gestures-engine.test.ts new file mode 100644 index 000000000..acd6e1f72 --- /dev/null +++ b/src/diagram/tests/editor-gestures-engine.test.ts @@ -0,0 +1,370 @@ +// Copyright 2026 The Simlin Authors. All rights reserved. +// Use of this source code is governed by the Apache License, +// Version 2.0, that can be found in the LICENSE file. + +// Every editing gesture, committed through the Editor into the REAL WASM +// engine. The Editor mounts with the Canvas mocked (its props are captured); +// each row plans a gesture with the production planner on the rendered view and +// hands the commit to onCommitGesture exactly as the Canvas's pointer-up does. +// Once the patch lands, the engine's own serialized project must hold M1 (kind +// agreement), M2 (every stock's lists name exactly the flows attached to it, once), +// M3 (referential integrity) and the strict flow invariants over the flows the +// gesture routed, and the rendered view must equal the engine's. +// +// Rows are keyed by GESTURE_KINDS minus the kinds that commit no view edit +// through onCommitGesture: createElement (placed by the name editor through +// onCreateVariable; editor-engine-races.test.ts T6/T11b), rubberBand (a +// selection) and pan (a viewport). +// +// What this establishes: a committed plan is a patch the engine accepts, and the +// model the engine derives from it holds M1-M3 and G1-G8. What it does not: the +// Canvas producing these gestures from pointer events (canvas-gestures-*.test.tsx) +// or edits racing an in-flight patch (editor-engine-races.test.ts). + +import { it, expect, beforeAll, afterEach, rs } from '@rstest/core'; + +import * as React from 'react'; +import { act, cleanup, render } from '@testing-library/react'; + +import * as react from 'react' with { rstest: 'importActual' }; + +import { canonicalize } from '@simlin/core/canonicalize'; +import { projectFromJson, type LinkViewElement, type Model, type StockFlowView } from '@simlin/core/datamodel'; +import type { JsonProject } from '@simlin/engine'; + +import { ProjectController } from '../project-controller'; +import type { CanvasProps } from '../drawing/Canvas'; +import { planGesture, type Gesture, type GesturePlan } from '../gesture-planner'; +import { GESTURE_KINDS } from '../gesture-planner/types'; +import { describeWithEngine, loadEngine, mainModel } from './support/engine'; +import { checkFlowInvariants, formatFlowViolations } from './support/flow-invariants'; +import { routedFlows, type Pt } from './support/gesture-fixtures'; +import { + checkKindAgreement, + checkReferentialIntegrity, + checkStockFlowAgreement, + checkStockListDuplicates, + formatViewViolations, +} from './support/view-invariants'; + +let canvasProps: CanvasProps | undefined; +rs.mock('../drawing/Canvas', () => ({ + __esModule: true, + Canvas: (p: CanvasProps) => { + canvasProps = p; + return react.createElement('div', { 'data-testid': 'canvas' }); + }, +})); + +import { Editor, type EditorProps } from '../Editor'; + +// A (uid 1) -> B (uid 2) through f (uid 4); a cloud (uid 21) into C (uid 3) +// through h (uid 5); aux x (uid 7) links into f (link uid 8); aux y (uid 9). +function projectJson(): string { + const model = { + name: 'main', + stocks: [ + { name: 'A', initialEquation: '10', inflows: [], outflows: ['f'] }, + { name: 'B', initialEquation: '0', inflows: ['f'], outflows: [] }, + { name: 'C', initialEquation: '0', inflows: ['h'], outflows: [] }, + ], + flows: [ + { name: 'f', equation: 'x' }, + { name: 'h', equation: '1' }, + ], + auxiliaries: [ + { name: 'x', equation: '0.1' }, + { name: 'y', equation: '2' }, + ], + views: [ + { + elements: [ + { type: 'stock', uid: 1, name: 'A', x: 100, y: 100 }, + { type: 'stock', uid: 2, name: 'B', x: 400, y: 100 }, + { type: 'stock', uid: 3, name: 'C', x: 400, y: 300 }, + { + type: 'flow', + uid: 4, + name: 'f', + x: 250, + y: 100, + points: [ + { x: 122.5, y: 100, attachedToUid: 1 }, + { x: 377.5, y: 100, attachedToUid: 2 }, + ], + }, + { + type: 'flow', + uid: 5, + name: 'h', + x: 310, + y: 300, + points: [ + { x: 250, y: 300, attachedToUid: 21 }, + { x: 377.5, y: 300, attachedToUid: 3 }, + ], + }, + { type: 'cloud', uid: 21, flowUid: 5, x: 250, y: 300 }, + { type: 'aux', uid: 7, name: 'x', x: 250, y: 20 }, + { type: 'link', uid: 8, fromUid: 7, toUid: 4 }, + { type: 'aux', uid: 9, name: 'y', x: 100, y: 400 }, + ], + }, + ], + }; + return JSON.stringify({ name: 'gestures', simSpecs: { startTime: 0, endTime: 3, dt: '1' }, models: [model] }); +} + +interface Mounted { + readonly controller: ProjectController; + settle(): Promise; + engineModel(): Promise; +} + +async function mount(): Promise { + const controllers: ProjectController[] = []; + const originalOpen = ProjectController.prototype.openInitialProject; + rs.spyOn(ProjectController.prototype, 'openInitialProject').mockImplementation(function (this: ProjectController) { + controllers.push(this); + return originalOpen.call(this); + }); + rs.spyOn(console, 'error').mockImplementation(() => {}); + act(() => { + render( + React.createElement(Editor, { + inputFormat: 'json', + initialProjectJson: projectJson(), + initialProjectVersion: 1, + name: 'gestures', + onSave: async () => 1, + } as EditorProps), + ); + }); + const controller = controllers[0]; + const settle = async () => { + for (let i = 0; i < 3; i++) { + await act(async () => { + await controller.whenIdle(); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + } + }; + await settle(); + return { + controller, + settle, + engineModel: async () => { + const serialized = await controller.query((engine) => engine.serializeJson(undefined, true)); + return mainModel(projectFromJson(JSON.parse(serialized!) as JsonProject).models); + }, + }; +} + +// The gesture released at `current`, planned on the rendered view with the +// Canvas's inputs and committed as the Canvas's pointer-up commits it. +function release(gesture: Gesture, selection: ReadonlySet, press: Pt, current: Pt): GesturePlan { + const p = canvasProps!; + const plan = planGesture({ + view: p.view, + variables: p.model.variables, + selection, + gesture, + press, + current, + zoom: 1, + pointerType: 'mouse', + readOnly: false, + names: p.newVariableName!, + }); + expect(plan.commit).toBe('edit'); + act(() => { + p.onCommitGesture({ + label: plan.label, + elements: plan.elements, + nextUid: plan.nextUid, + selection: plan.selection, + token: p.token, + baseView: p.view, + editName: plan.handoff?.editName, + }); + }); + return plan; +} + +function lists(model: Model, ident: string): { inflows: string[]; outflows: string[] } { + const stock = model.variables.get(ident); + if (stock?.type !== 'stock') { + throw new Error(`no stock ${ident}`); + } + return { inflows: stock.inflows.map(canonicalize).sort(), outflows: stock.outflows.map(canonicalize).sort() }; +} + +function engineElement(model: Model, uid: number) { + const el = model.views[0].elements.find((e) => e.uid === uid); + if (el === undefined) { + throw new Error(`no uid ${uid} on the engine's view`); + } + return el; +} + +function shape(view: StockFlowView): string[] { + return view.elements + .map((el) => + el.type === 'flow' + ? `${el.uid}:flow:${canonicalize(el.name)}:${el.points[0].attachedToUid}->${el.points[el.points.length - 1].attachedToUid}` + : `${el.uid}:${el.type}`, + ) + .sort(); +} + +interface Row { + readonly name: string; + readonly gesture: Gesture; + readonly selection: ReadonlySet; + readonly press: Pt; + readonly current: Pt; + readonly check: (model: Model) => void; +} + +const ROWS: Readonly, Row>> = { + moveSelection: { + name: 'stock A dragged down: f routes to its moved face', + gesture: { kind: 'moveSelection' }, + selection: new Set([1]), + press: { x: 100, y: 100 }, + current: { x: 100, y: 160 }, + check: (model) => { + expect(engineElement(model, 1)).toMatchObject({ x: 100, y: 160 }); + expect(lists(model, 'a').outflows).toEqual(['f']); + }, + }, + slideValve: { + name: 'f`s valve slid along its pipe', + gesture: { kind: 'slideValve', flow: 4 }, + selection: new Set([4]), + press: { x: 250, y: 100 }, + current: { x: 300, y: 100 }, + check: (model) => { + expect((engineElement(model, 4) as { x: number }).x).toBeCloseTo(300); + }, + }, + offsetSegment: { + name: 'f offset perpendicular into a bracket between A and B', + gesture: { kind: 'offsetSegment', flow: 4, segmentIndex: 0 }, + selection: new Set([4]), + press: { x: 250, y: 100 }, + current: { x: 250, y: 170 }, + check: (model) => { + const f = engineElement(model, 4); + expect(f.type === 'flow' && f.points.length > 2).toBe(true); + }, + }, + flowEndpoint: { + name: 'h`s sink moved from C onto B (M2: C loses h, B gains it)', + gesture: { kind: 'flowEndpoint', flow: 5, end: 'sink' }, + selection: new Set([5]), + press: { x: 377.5, y: 300 }, + current: { x: 400, y: 100 }, + check: (model) => { + expect(lists(model, 'c').inflows).toEqual([]); + expect(lists(model, 'b').inflows).toEqual(['f', 'h']); + }, + }, + createFlow: { + name: 'a flow drawn from C onto A (M1/M2: a new flow variable listed by both)', + gesture: { kind: 'createFlow', from: { stock: 3 } }, + selection: new Set(), + press: { x: 400, y: 300 }, + current: { x: 100, y: 100 }, + check: (model) => { + expect(model.variables.get('new_flow')?.type).toBe('flow'); + expect(lists(model, 'c').outflows).toEqual(['new_flow']); + expect(lists(model, 'a').inflows).toEqual(['new_flow']); + }, + }, + createLink: { + name: 'a link drawn from y to x', + gesture: { kind: 'createLink', from: 9 }, + selection: new Set(), + press: { x: 100, y: 400 }, + current: { x: 250, y: 20 }, + check: (model) => { + const links = model.views[0].elements.filter((e): e is LinkViewElement => e.type === 'link'); + expect(links.some((l) => l.fromUid === 9 && l.toUid === 7)).toBe(true); + }, + }, + linkEndpoint: { + name: 'x`s link arrowhead moved from f onto y', + gesture: { kind: 'linkEndpoint', link: 8 }, + selection: new Set([8]), + press: { x: 250, y: 100 }, + current: { x: 100, y: 400 }, + check: (model) => { + expect(engineElement(model, 8)).toMatchObject({ fromUid: 7, toUid: 9 }); + }, + }, + linkArc: { + name: 'x`s link curved by its body', + gesture: { kind: 'linkArc', link: 8 }, + selection: new Set([8]), + press: { x: 250, y: 60 }, + current: { x: 290, y: 60 }, + check: (model) => { + const arc = (engineElement(model, 8) as LinkViewElement).arc; + expect(arc !== undefined && Math.abs(arc) > 1).toBe(true); + }, + }, + label: { + name: 'x`s label dragged to the top', + gesture: { kind: 'label', uid: 7 }, + selection: new Set([7]), + press: { x: 250, y: -20 }, + current: { x: 250, y: -20 }, + check: (model) => { + expect(engineElement(model, 7)).toMatchObject({ labelSide: 'top' }); + }, + }, +}; + +describeWithEngine('Editor + real engine: every editing gesture lands holding M1-M3 and G1-G8', () => { + beforeAll(async () => { + await loadEngine(); + }); + + afterEach(async () => { + cleanup(); + rs.restoreAllMocks(); + canvasProps = undefined; + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + it('has a row for every kind that commits a view edit', () => { + const covered = new Set([...Object.keys(ROWS), 'createElement', 'rubberBand', 'pan']); + expect([...GESTURE_KINDS].filter((k) => !covered.has(k))).toEqual([]); + }); + + for (const [kind, row] of Object.entries(ROWS)) { + it(`${kind}: ${row.name}`, async () => { + const m = await mount(); + const base = { model: canvasProps!.model, view: canvasProps!.view }; + const plan = release(row.gesture, row.selection, row.press, row.current); + await m.settle(); + + const model = await m.engineModel(); + const view = model.views[0]; + expect( + formatViewViolations([ + ...checkKindAgreement(view, model.variables), + ...checkReferentialIntegrity(view), + ...checkStockListDuplicates(model.variables), + ...checkStockFlowAgreement(view, model.variables), + ]), + ).toBe(''); + expect(formatFlowViolations(checkFlowInvariants(view, { mode: 'strict', routed: routedFlows(base, plan) }))).toBe( + '', + ); + expect(shape(canvasProps!.view)).toEqual(shape(view)); + row.check(model); + }); + } +}); diff --git a/src/diagram/tests/editor-home-link.test.ts b/src/diagram/tests/editor-home-link.test.ts index 5e90bb0f1..a139098df 100644 --- a/src/diagram/tests/editor-home-link.test.ts +++ b/src/diagram/tests/editor-home-link.test.ts @@ -42,9 +42,14 @@ function makeSnapshot(): ProjectSnapshot { modelName: 'main', projectVersion: 1, serverVersion: 1, - projectGeneration: 0, status: 'ok', - cachedErrors: { simError: undefined, modelErrors: [], varErrors: new Map(), unitErrors: new Map() }, + cachedErrors: { + simError: undefined, + modelErrors: [], + varErrors: new Map(), + unitErrors: new Map(), + varWarnings: new Map(), + }, data: new Map(), modelStack: [], canUndo: false, @@ -71,7 +76,6 @@ describe('Editor showHomeLink', () => { rs.spyOn(ProjectController.prototype, 'getSnapshot').mockReturnValue(makeSnapshot()); rs.spyOn(ProjectController.prototype, 'openInitialProject').mockResolvedValue(undefined); rs.spyOn(ProjectController.prototype, 'dispose').mockResolvedValue(undefined); - rs.spyOn(ProjectController.prototype, 'scheduleSimRun').mockImplementation(() => {}); rs.spyOn(ProjectController.prototype, 'subscribe').mockReturnValue(() => {}); }); diff --git a/src/diagram/tests/editor-keyboard-scoping.test.ts b/src/diagram/tests/editor-keyboard-scoping.test.ts index 1d0300461..4850e1a0e 100644 --- a/src/diagram/tests/editor-keyboard-scoping.test.ts +++ b/src/diagram/tests/editor-keyboard-scoping.test.ts @@ -126,9 +126,14 @@ function makeSnapshot(): ProjectSnapshot { modelName: 'main', projectVersion: 1, serverVersion: 1, - projectGeneration: 0, status: 'ok', - cachedErrors: { simError: undefined, modelErrors: [], varErrors: new Map(), unitErrors: new Map() }, + cachedErrors: { + simError: undefined, + modelErrors: [], + varErrors: new Map(), + unitErrors: new Map(), + varWarnings: new Map(), + }, data: new Map(), modelStack: [], canUndo: true, @@ -166,27 +171,25 @@ describe('Editor keyboard scoping across instances', () => { // prototype-level spy (undoRedo) was invoked on. let controllers: ProjectController[]; let undoRedoCalls: Array<{ controller: ProjectController; kind: string }>; - let updateViewCalls: Array<{ controller: ProjectController; view: StockFlowView }>; + let viewEditCalls: Array<{ controller: ProjectController; view: StockFlowView }>; beforeEach(() => { controllers = []; undoRedoCalls = []; - updateViewCalls = []; + viewEditCalls = []; rs.spyOn(ProjectController.prototype, 'getSnapshot').mockReturnValue(makeSnapshot()); rs.spyOn(ProjectController.prototype, 'openInitialProject').mockResolvedValue(undefined); rs.spyOn(ProjectController.prototype, 'dispose').mockResolvedValue(undefined); - rs.spyOn(ProjectController.prototype, 'scheduleSimRun').mockImplementation(() => {}); rs.spyOn(ProjectController.prototype, 'subscribe').mockImplementation(function (this: ProjectController) { controllers.push(this); return () => {}; }); - rs.spyOn(ProjectController.prototype, 'getEngine').mockReturnValue({} as never); - rs.spyOn(ProjectController.prototype, 'applyPatchOrReportError').mockResolvedValue(true); - rs.spyOn(ProjectController.prototype, 'updateView').mockImplementation(async function ( + rs.spyOn(ProjectController.prototype, 'enqueueViewEdit').mockImplementation(async function ( this: ProjectController, - view: StockFlowView, + edit: { nextView: StockFlowView }, ) { - updateViewCalls.push({ controller: this, view }); + viewEditCalls.push({ controller: this, view: edit.nextView }); + return true; }); rs.spyOn(ProjectController.prototype, 'undoRedo').mockImplementation(function ( this: ProjectController, @@ -240,6 +243,7 @@ describe('Editor keyboard scoping across instances', () => { function pressInside(inst: Instance): void { act(() => { fireEvent.pointerDown(inst.root); + fireEvent.pointerUp(inst.root); }); } @@ -259,7 +263,7 @@ describe('Editor keyboard scoping across instances', () => { await pressKey(document.body, { key: 'Delete' }); - expect(updateViewCalls.map((c) => c.controller)).toEqual([b.controller]); + expect(viewEditCalls.map((c) => c.controller)).toEqual([b.controller]); expect(b.canvas().selection.size).toBe(0); expect(a.canvas().selection.has(9)).toBe(true); }); @@ -290,7 +294,7 @@ describe('Editor keyboard scoping across instances', () => { // document -> window, so it names B and never A. await pressKey(b.root, { key: 'Delete' }); - expect(updateViewCalls.map((c) => c.controller)).toEqual([b.controller]); + expect(viewEditCalls.map((c) => c.controller)).toEqual([b.controller]); expect(b.canvas().selection.size).toBe(0); expect(a.canvas().selection.has(9)).toBe(true); }); @@ -326,7 +330,7 @@ describe('Editor keyboard scoping across instances', () => { await pressKey(document.body, { key: 'Delete' }); await pressKey(document, { key: 'z', ctrlKey: true }); - expect(updateViewCalls).toEqual([]); + expect(viewEditCalls).toEqual([]); expect(undoRedoCalls).toEqual([]); expect(a.canvas().selection.has(9)).toBe(true); expect(b.canvas().selection.has(9)).toBe(true); @@ -345,7 +349,7 @@ describe('Editor keyboard scoping across instances', () => { await pressKey(outside, { key: 'z', ctrlKey: true }); outside.remove(); - expect(updateViewCalls).toEqual([]); + expect(viewEditCalls).toEqual([]); expect(undoRedoCalls).toEqual([]); expect(a.canvas().selection.has(9)).toBe(true); }); @@ -361,7 +365,7 @@ describe('Editor keyboard scoping across instances', () => { await pressKey(input, { key: 'z', ctrlKey: true }); input.remove(); - expect(updateViewCalls).toEqual([]); + expect(viewEditCalls).toEqual([]); expect(undoRedoCalls).toEqual([]); }); @@ -467,4 +471,79 @@ describe('Editor keyboard scoping across instances', () => { await pressKey(document.body, { key: 'Escape' }); expect(a.canvas().selection.size).toBe(0); }); + + it('undo is refused while a press inside the editor is held (a live gesture), and allowed after release', async () => { + const a = mountEditor(); + act(() => { + fireEvent.pointerDown(a.root); + }); + await pressKey(document.body, { key: 'z', ctrlKey: true }); + expect(undoRedoCalls).toEqual([]); + + act(() => { + fireEvent.pointerUp(a.root); + }); + await pressKey(document.body, { key: 'z', ctrlKey: true }); + expect(undoRedoCalls).toEqual([{ controller: a.controller, kind: 'undo' }]); + + // A cancelled press releases too. + act(() => { + fireEvent.pointerDown(a.root); + fireEvent.pointerCancel(a.root); + }); + await pressKey(document.body, { key: 'z', ctrlKey: true }); + expect(undoRedoCalls).toHaveLength(2); + + // So does a press released over the host page, outside the editor... + act(() => { + fireEvent.pointerDown(a.root); + fireEvent.pointerUp(document.body); + }); + await pressKey(document.body, { key: 'z', ctrlKey: true }); + expect(undoRedoCalls).toHaveLength(3); + + // ...and one released outside the browser window, which loses focus... + act(() => { + fireEvent.pointerDown(a.root); + window.dispatchEvent(new Event('blur')); + }); + await pressKey(document.body, { key: 'z', ctrlKey: true }); + expect(undoRedoCalls).toHaveLength(4); + + // ...and one whose context menu swallowed its pointerup. + act(() => { + fireEvent.pointerDown(a.root); + fireEvent.contextMenu(a.root); + }); + await pressKey(document.body, { key: 'z', ctrlKey: true }); + expect(undoRedoCalls).toHaveLength(5); + }); + + it('unmounting removes every window listener the Editor added to release a press', () => { + const added: Array<[string, unknown, unknown]> = []; + const removed: Array<[string, unknown, unknown]> = []; + const realAdd = window.addEventListener.bind(window); + const realRemove = window.removeEventListener.bind(window); + rs.spyOn(window, 'addEventListener').mockImplementation( + (type: string, fn: EventListenerOrEventListenerObject, opts?: boolean | AddEventListenerOptions) => { + added.push([type, fn, opts]); + realAdd(type, fn, opts); + }, + ); + rs.spyOn(window, 'removeEventListener').mockImplementation( + (type: string, fn: EventListenerOrEventListenerObject, opts?: boolean | EventListenerOptions) => { + removed.push([type, fn, opts]); + realRemove(type, fn, opts); + }, + ); + const a = mountEditor(); + const releases = added.filter(([type]) => ['pointerup', 'pointercancel', 'contextmenu', 'blur'].includes(type)); + expect(releases.map(([type]) => type).sort()).toEqual(['blur', 'contextmenu', 'pointercancel', 'pointerup']); + act(() => { + a.result.unmount(); + }); + for (const [type, fn, opts] of releases) { + expect(removed.some(([t, f, o]) => t === type && f === fn && o === opts)).toBe(true); + } + }); }); diff --git a/src/diagram/tests/editor-keyboard.test.ts b/src/diagram/tests/editor-keyboard.test.ts index 2ac81865a..922281721 100644 --- a/src/diagram/tests/editor-keyboard.test.ts +++ b/src/diagram/tests/editor-keyboard.test.ts @@ -14,6 +14,10 @@ // corrupted/divergent project) must not crash the editor when the details // panel opens; it degrades to no panel so the element can still be // selected and keyboard-deleted (the repair path). +// +// A delete is a controller view edit (the rendered view with the selection +// removed); which model ops it implies is view-model-sync's, tested there and +// through the real engine in editor-engine-races.test.ts. import { describe, it, expect, beforeEach, afterEach, rs } from '@rstest/core'; @@ -150,13 +154,20 @@ function makeSnapshot(): ProjectSnapshot { modelName: 'main', projectVersion: 1, serverVersion: 1, - projectGeneration: 0, status: 'ok', - cachedErrors: { simError: undefined, modelErrors: [], varErrors: new Map(), unitErrors: new Map() }, + cachedErrors: { + simError: undefined, + modelErrors: [], + varErrors: new Map(), + unitErrors: new Map(), + varWarnings: new Map(), + }, data: new Map(), modelStack: [], canUndo: false, canRedo: false, + undoRedoQueued: false, + token: 0, navResetSeq: 0, } as unknown as ProjectSnapshot; } @@ -184,31 +195,25 @@ function renderEditor(props: EditorProps = makeProps()): void { container = render(React.createElement(Editor, props)).container; }); fireEvent.pointerDown(container.firstElementChild as HTMLElement); + fireEvent.pointerUp(container.firstElementChild as HTMLElement); } const toolSelected = (title: string): boolean => screen.getByLabelText(title).getAttribute('data-selected') === 'true'; describe('Editor keyboard shortcuts', () => { - let applyPatchCalls: unknown[]; - let updateViewCalls: StockFlowView[]; + let viewEdits: Array<{ label: string; nextView: StockFlowView }>; beforeEach(() => { canvasProps = undefined; - applyPatchCalls = []; - updateViewCalls = []; + viewEdits = []; rs.spyOn(ProjectController.prototype, 'getSnapshot').mockReturnValue(makeSnapshot()); rs.spyOn(ProjectController.prototype, 'openInitialProject').mockResolvedValue(undefined); rs.spyOn(ProjectController.prototype, 'dispose').mockResolvedValue(undefined); - rs.spyOn(ProjectController.prototype, 'scheduleSimRun').mockImplementation(() => {}); rs.spyOn(ProjectController.prototype, 'subscribe').mockReturnValue(() => {}); - rs.spyOn(ProjectController.prototype, 'getEngine').mockReturnValue({} as never); - rs.spyOn(ProjectController.prototype, 'applyPatchOrReportError').mockImplementation(async (patch) => { - applyPatchCalls.push(patch); + rs.spyOn(ProjectController.prototype, 'enqueueViewEdit').mockImplementation(async (edit) => { + viewEdits.push({ label: edit.label, nextView: edit.nextView }); return true; }); - rs.spyOn(ProjectController.prototype, 'updateView').mockImplementation(async (view) => { - updateViewCalls.push(view); - }); }); afterEach(() => { @@ -221,7 +226,7 @@ describe('Editor keyboard shortcuts', () => { }); } - it('Delete removes the selected element (deleteVariable op + view update)', async () => { + it('Delete enqueues a delete edit whose next view lacks the selected element, and clears the selection', async () => { renderEditor(); selectUid(9); @@ -229,12 +234,10 @@ describe('Editor keyboard shortcuts', () => { fireEvent.keyDown(document, { key: 'Delete' }); }); - expect(applyPatchCalls).toHaveLength(1); - expect(JSON.stringify(applyPatchCalls[0])).toContain('"deleteVariable"'); - expect(JSON.stringify(applyPatchCalls[0])).toContain('some_var'); - expect(updateViewCalls).toHaveLength(1); - expect(updateViewCalls[0].elements.some((el) => el.uid === 9)).toBe(false); - // The selection was cleared alongside. + expect(viewEdits).toHaveLength(1); + expect(viewEdits[0].label).toBe('delete'); + expect(viewEdits[0].nextView.elements.some((el) => el.uid === 9)).toBe(false); + expect(viewEdits[0].nextView.elements.some((el) => el.uid === 10)).toBe(true); expect(canvasProps?.selection.size).toBe(0); }); @@ -246,7 +249,7 @@ describe('Editor keyboard shortcuts', () => { fireEvent.keyDown(document, { key: 'Backspace' }); }); - expect(updateViewCalls).toHaveLength(1); + expect(viewEdits).toHaveLength(1); }); it('Delete with no selection is a no-op', async () => { @@ -256,8 +259,7 @@ describe('Editor keyboard shortcuts', () => { fireEvent.keyDown(document, { key: 'Delete' }); }); - expect(applyPatchCalls).toHaveLength(0); - expect(updateViewCalls).toHaveLength(0); + expect(viewEdits).toHaveLength(0); }); it('Delete in readOnlyMode is a no-op', async () => { @@ -268,8 +270,7 @@ describe('Editor keyboard shortcuts', () => { fireEvent.keyDown(document, { key: 'Delete' }); }); - expect(applyPatchCalls).toHaveLength(0); - expect(updateViewCalls).toHaveLength(0); + expect(viewEdits).toHaveLength(0); }); it('Delete typed in an editable field does not delete the selection', async () => { @@ -282,7 +283,7 @@ describe('Editor keyboard shortcuts', () => { fireEvent.keyDown(input, { key: 'Delete' }); }); - expect(updateViewCalls).toHaveLength(0); + expect(viewEdits).toHaveLength(0); input.remove(); }); @@ -307,10 +308,10 @@ describe('Editor keyboard shortcuts', () => { }); it('the ghost element (variable missing from the model) can be selected and keyboard-deleted', async () => { - // Before the hardening, selecting the ghost with the details panel open - // crashed the whole editor in render (getOrThrow on the missing variable), - // which ALSO made the element undeletable (the panel is the only other - // delete affordance). + // Selecting the ghost with the details panel open must not crash the whole + // editor in render (getOrThrow on the missing variable), which would ALSO + // make the element undeletable (the panel is the only other delete + // affordance). renderEditor(); selectUid(10); @@ -324,13 +325,13 @@ describe('Editor keyboard shortcuts', () => { expect(canvasProps).toBeDefined(); expect(screen.queryByTestId('variable-details')).toBeNull(); - // The repair path: keyboard-delete the ghost. Its variable is missing, so - // no deleteVariable op is emitted -- but the view element must go. + // The repair path: keyboard-delete the ghost. The view element must go + // (the controller derives no deleteVariable, since no variable exists). await act(async () => { fireEvent.keyDown(document, { key: 'Delete' }); }); - expect(updateViewCalls).toHaveLength(1); - expect(updateViewCalls[0].elements.some((el) => el.uid === 10)).toBe(false); + expect(viewEdits).toHaveLength(1); + expect(viewEdits[0].nextView.elements.some((el) => el.uid === 10)).toBe(false); }); it('the details panel still renders for a healthy variable', () => { @@ -341,4 +342,16 @@ describe('Editor keyboard shortcuts', () => { }); expect(screen.queryByTestId('variable-details')).not.toBeNull(); }); + + it('tells the Canvas to ignore presses exactly while an undo or redo is queued', () => { + renderEditor(); + expect(canvasProps?.pressesDisabled).toBe(false); + + rs.spyOn(ProjectController.prototype, 'getSnapshot').mockReturnValue({ + ...makeSnapshot(), + undoRedoQueued: true, + } as ProjectSnapshot); + renderEditor(); + expect(canvasProps?.pressesDisabled).toBe(true); + }); }); diff --git a/src/diagram/tests/editor-module-creation-gate.test.ts b/src/diagram/tests/editor-module-creation-gate.test.ts index 43a15546c..a6db3d5ad 100644 --- a/src/diagram/tests/editor-module-creation-gate.test.ts +++ b/src/diagram/tests/editor-module-creation-gate.test.ts @@ -72,9 +72,14 @@ function makeSnapshot(): ProjectSnapshot { modelName: 'main', projectVersion: 1, serverVersion: 1, - projectGeneration: 0, status: 'ok', - cachedErrors: { simError: undefined, modelErrors: [], varErrors: new Map(), unitErrors: new Map() }, + cachedErrors: { + simError: undefined, + modelErrors: [], + varErrors: new Map(), + unitErrors: new Map(), + varWarnings: new Map(), + }, data: new Map(), modelStack: [], canUndo: false, @@ -107,7 +112,6 @@ describe('Editor module-creation tool gating', () => { rs.spyOn(ProjectController.prototype, 'getSnapshot').mockReturnValue(makeSnapshot()); rs.spyOn(ProjectController.prototype, 'openInitialProject').mockResolvedValue(undefined); rs.spyOn(ProjectController.prototype, 'dispose').mockResolvedValue(undefined); - rs.spyOn(ProjectController.prototype, 'scheduleSimRun').mockImplementation(() => {}); rs.spyOn(ProjectController.prototype, 'subscribe').mockReturnValue(() => {}); }); diff --git a/src/diagram/tests/editor-portal-container.test.tsx b/src/diagram/tests/editor-portal-container.test.tsx index cd70dc00f..dfab80f8e 100644 --- a/src/diagram/tests/editor-portal-container.test.tsx +++ b/src/diagram/tests/editor-portal-container.test.tsx @@ -69,9 +69,14 @@ function makeSnapshot(): ProjectSnapshot { modelName: 'main', projectVersion: 1, serverVersion: 1, - projectGeneration: 0, status: 'ok', - cachedErrors: { simError: undefined, modelErrors: [], varErrors: new Map(), unitErrors: new Map() }, + cachedErrors: { + simError: undefined, + modelErrors: [], + varErrors: new Map(), + unitErrors: new Map(), + varWarnings: new Map(), + }, data: new Map(), modelStack: [], canUndo: false, @@ -105,7 +110,6 @@ describe('Editor portalContainer', () => { rs.spyOn(ProjectController.prototype, 'getSnapshot').mockReturnValue(makeSnapshot()); rs.spyOn(ProjectController.prototype, 'openInitialProject').mockResolvedValue(undefined); rs.spyOn(ProjectController.prototype, 'dispose').mockResolvedValue(undefined); - rs.spyOn(ProjectController.prototype, 'scheduleSimRun').mockImplementation(() => {}); rs.spyOn(ProjectController.prototype, 'subscribe').mockReturnValue(() => {}); }); diff --git a/src/diagram/tests/editor-readonly-gating.test.ts b/src/diagram/tests/editor-readonly-gating.test.ts index 7c128beb4..4eaf4242f 100644 --- a/src/diagram/tests/editor-readonly-gating.test.ts +++ b/src/diagram/tests/editor-readonly-gating.test.ts @@ -31,16 +31,26 @@ import { act, fireEvent, render, screen, type RenderResult } from '@testing-libr // the binding resolves to the real module and is hoisted alongside the factory. import * as react from 'react' with { rstest: 'importActual' }; -import type { - FlowViewElement, - GraphicalFunction, - LinkViewElement, - StockFlowView, - Variable, -} from '@simlin/core/datamodel'; +import type { GraphicalFunction, StockFlowView, Variable } from '@simlin/core/datamodel'; import { ProjectController, type ProjectSnapshot } from '../project-controller'; -import type { CanvasProps } from '../drawing/Canvas'; +import type { CanvasProps, GestureCommit } from '../drawing/Canvas'; + +// Every view-editing gesture reaches the Editor as one commit; this one moves +// the first element of the rendered view, planned on that view, so only the +// read-only gate can stop it. +function movedCommit(cp: CanvasProps): GestureCommit { + const [first, ...rest] = cp.view.elements; + const elements = first === undefined ? [] : [{ ...first, x: first.x + 10, y: first.y + 10 }, ...rest]; + return { + label: 'move', + elements, + nextUid: cp.view.nextUid, + selection: new Set(), + token: cp.token, + baseView: cp.view, + }; +} import type { VariableDetails as VariableDetailsType } from '../VariableDetails'; import type { ModuleDetails as ModuleDetailsType } from '../ModuleDetails'; @@ -219,9 +229,14 @@ function makeSnapshot(): ProjectSnapshot { modelName: 'main', projectVersion: 1, serverVersion: 1, - projectGeneration: 0, status: 'ok', - cachedErrors: { simError: undefined, modelErrors: [], varErrors: new Map(), unitErrors: new Map() }, + cachedErrors: { + simError: undefined, + modelErrors: [], + varErrors: new Map(), + unitErrors: new Map(), + varWarnings: new Map(), + }, data: new Map(), modelStack: [], // History exists, so undo/redo gating is observable (not vacuously off). @@ -247,13 +262,14 @@ function makeProps(overrides: Partial = {}): EditorProps { const viewOnlyLabel = 'View only'; describe('Editor readOnlyMode capability gate', () => { + // Every way the Editor can change project content goes through one of these + // controller methods: diagram edits, model-only edits, and undo/redo. let mutationSpies: { - applyPatch: ReturnType; - applyPatchOrReportError: ReturnType; - updateView: ReturnType; + enqueueViewEdit: ReturnType; + enqueueModelEdit: ReturnType; undoRedo: ReturnType; }; - let queueViewUpdateSpy: ReturnType; + let setViewportSpy: ReturnType; beforeEach(() => { canvasProps = undefined; @@ -264,16 +280,13 @@ describe('Editor readOnlyMode capability gate', () => { rs.spyOn(ProjectController.prototype, 'getSnapshot').mockReturnValue(makeSnapshot()); rs.spyOn(ProjectController.prototype, 'openInitialProject').mockResolvedValue(undefined); rs.spyOn(ProjectController.prototype, 'dispose').mockResolvedValue(undefined); - rs.spyOn(ProjectController.prototype, 'scheduleSimRun').mockImplementation(() => {}); rs.spyOn(ProjectController.prototype, 'subscribe').mockReturnValue(() => {}); - rs.spyOn(ProjectController.prototype, 'getEngine').mockReturnValue({} as never); mutationSpies = { - applyPatch: rs.spyOn(ProjectController.prototype, 'applyPatch').mockResolvedValue(true), - applyPatchOrReportError: rs.spyOn(ProjectController.prototype, 'applyPatchOrReportError').mockResolvedValue(true), - updateView: rs.spyOn(ProjectController.prototype, 'updateView').mockResolvedValue(undefined), + enqueueViewEdit: rs.spyOn(ProjectController.prototype, 'enqueueViewEdit').mockResolvedValue(true), + enqueueModelEdit: rs.spyOn(ProjectController.prototype, 'enqueueModelEdit').mockResolvedValue(true), undoRedo: rs.spyOn(ProjectController.prototype, 'undoRedo').mockImplementation(() => {}), }; - queueViewUpdateSpy = rs.spyOn(ProjectController.prototype, 'queueViewUpdate').mockResolvedValue(undefined); + setViewportSpy = rs.spyOn(ProjectController.prototype, 'setViewport').mockImplementation(() => {}); }); afterEach(() => { @@ -290,13 +303,13 @@ describe('Editor readOnlyMode capability gate', () => { result = render(React.createElement(Editor, props)); }); fireEvent.pointerDown(result.container.firstElementChild as HTMLElement); + fireEvent.pointerUp(result.container.firstElementChild as HTMLElement); return result; } function expectNoMutations(): void { - expect(mutationSpies.applyPatch).not.toHaveBeenCalled(); - expect(mutationSpies.applyPatchOrReportError).not.toHaveBeenCalled(); - expect(mutationSpies.updateView).not.toHaveBeenCalled(); + expect(mutationSpies.enqueueViewEdit).not.toHaveBeenCalled(); + expect(mutationSpies.enqueueModelEdit).not.toHaveBeenCalled(); expect(mutationSpies.undoRedo).not.toHaveBeenCalled(); } @@ -306,8 +319,7 @@ describe('Editor readOnlyMode capability gate', () => { const cp = canvasProps!; await act(async () => { cp.onRenameVariable('some var', 'renamed'); - cp.onMoveSelection({ x: 10, y: 10 }); - cp.onMoveLabel(9, 'left'); + cp.onCommitGesture(movedCommit(cp)); cp.onCreateVariable({ type: 'aux', uid: -2, @@ -319,14 +331,6 @@ describe('Editor readOnlyMode capability gate', () => { labelSide: 'right', isZeroRadius: false, }); - cp.onMoveFlow( - { type: 'flow', uid: 12, points: [] } as unknown as FlowViewElement, - 9, - { x: 0, y: 0 }, - undefined, - true, - ); - cp.onAttachLink({ type: 'link', uid: -2, fromUid: 9 } as unknown as LinkViewElement, 'some_var'); void cp.onDeleteSelection(); }); await act(async () => { @@ -352,15 +356,51 @@ describe('Editor readOnlyMode capability gate', () => { isZeroRadius: false, }); }); - expect(mutationSpies.applyPatchOrReportError).toHaveBeenCalled(); - expect(mutationSpies.updateView).toHaveBeenCalled(); + expect(mutationSpies.enqueueViewEdit).toHaveBeenCalled(); + + // Model-only edits, probed through the drawer. + await act(async () => { + drawerProps!.onSimSpecCommit('startTime', 1900); + }); + expect(mutationSpies.enqueueModelEdit).toHaveBeenCalled(); await act(async () => { fireEvent.keyDown(document, { key: 'z', ctrlKey: true }); }); - expect(mutationSpies.undoRedo).toHaveBeenCalledWith('undo'); + // No panel is open, so there is no draft to queue the undo behind. + expect(mutationSpies.undoRedo).toHaveBeenCalledWith('undo', { afterQueuedEdits: false }); }); + // The panel handlers resolve whether the edit landed (a panel falls back to + // committed text on false). A submission identical to the latest pending one + // is not enqueued again, and must resolve as that one does, whichever way + // it goes. + for (const outcome of [false, true]) { + it(`an identical submission while one is pending resolves as the pending one does (${outcome})`, async () => { + let settle!: (landed: boolean) => void; + mutationSpies.enqueueModelEdit.mockImplementation( + () => + new Promise((resolve) => { + settle = resolve; + }), + ); + renderEditor(); + act(() => { + canvasProps!.onSetSelection(new Set([9])); + canvasProps!.onShowVariableDetails(); + }); + const first = variableDetailsProps!.onEquationChange('some_var', undefined, 'widgets', undefined); + const second = variableDetailsProps!.onEquationChange('some_var', undefined, 'widgets', undefined); + expect(mutationSpies.enqueueModelEdit).toHaveBeenCalledTimes(1); + await act(async () => { + settle(outcome); + await Promise.resolve(); + }); + expect(await first).toBe(outcome); + expect(await second).toBe(outcome); + }); + } + it('a barrage of attempted mutations never reaches the controller in readOnlyMode', async () => { renderEditor(makeProps({ readOnlyMode: true })); // Give the barrage a selection to chew on: selection itself must stay live. @@ -436,8 +476,7 @@ describe('Editor readOnlyMode capability gate', () => { await act(async () => { staleHandlers.onRenameVariable('some var', 'renamed'); - staleHandlers.onMoveSelection({ x: 10, y: 10 }); - staleHandlers.onMoveLabel(9, 'left'); + staleHandlers.onCommitGesture(movedCommit(staleHandlers)); staleHandlers.onCreateVariable({ type: 'aux', uid: -2, @@ -449,14 +488,6 @@ describe('Editor readOnlyMode capability gate', () => { labelSide: 'right', isZeroRadius: false, }); - staleHandlers.onMoveFlow( - { type: 'flow', uid: 12, points: [] } as unknown as FlowViewElement, - 9, - { x: 0, y: 0 }, - undefined, - true, - ); - staleHandlers.onAttachLink({ type: 'link', uid: -2, fromUid: 9 } as unknown as LinkViewElement, 'some_var'); void staleHandlers.onDeleteSelection(); }); expectNoMutations(); @@ -480,7 +511,7 @@ describe('Editor readOnlyMode capability gate', () => { }); // Viewport updates are view-only (never recorded in undo history, and the // host's save is a no-op), so panning a read-only project is allowed. - expect(queueViewUpdateSpy).toHaveBeenCalled(); + expect(setViewportSpy).toHaveBeenCalled(); expectNoMutations(); }); @@ -542,14 +573,11 @@ describe('Editor readOnlyMode flips (both directions)', () => { rs.spyOn(ProjectController.prototype, 'getSnapshot').mockReturnValue(makeSnapshot()); rs.spyOn(ProjectController.prototype, 'openInitialProject').mockResolvedValue(undefined); rs.spyOn(ProjectController.prototype, 'dispose').mockResolvedValue(undefined); - rs.spyOn(ProjectController.prototype, 'scheduleSimRun').mockImplementation(() => {}); rs.spyOn(ProjectController.prototype, 'subscribe').mockReturnValue(() => {}); - rs.spyOn(ProjectController.prototype, 'getEngine').mockReturnValue({} as never); - rs.spyOn(ProjectController.prototype, 'applyPatch').mockResolvedValue(true); - rs.spyOn(ProjectController.prototype, 'applyPatchOrReportError').mockResolvedValue(true); - rs.spyOn(ProjectController.prototype, 'updateView').mockResolvedValue(undefined); + rs.spyOn(ProjectController.prototype, 'enqueueViewEdit').mockResolvedValue(true); + rs.spyOn(ProjectController.prototype, 'enqueueModelEdit').mockResolvedValue(true); rs.spyOn(ProjectController.prototype, 'undoRedo').mockImplementation(() => {}); - rs.spyOn(ProjectController.prototype, 'queueViewUpdate').mockResolvedValue(undefined); + rs.spyOn(ProjectController.prototype, 'setViewport').mockImplementation(() => {}); }); afterEach(() => { @@ -564,6 +592,7 @@ describe('Editor readOnlyMode flips (both directions)', () => { result = render(React.createElement(Editor, props)); }); fireEvent.pointerDown(result.container.firstElementChild as HTMLElement); + fireEvent.pointerUp(result.container.firstElementChild as HTMLElement); return result; } @@ -599,11 +628,11 @@ describe('Editor readOnlyMode flips (both directions)', () => { expect(canvasProps!.readOnly).toBe(true); expect(canvasProps!.selectedTool).toBeUndefined(); - const updateView = ProjectController.prototype.updateView as unknown as ReturnType; + const enqueueViewEdit = ProjectController.prototype.enqueueViewEdit as unknown as ReturnType; await act(async () => { - canvasProps!.onMoveSelection({ x: 10, y: 10 }); + canvasProps!.onCommitGesture(movedCommit(canvasProps!)); }); - expect(updateView).not.toHaveBeenCalled(); + expect(enqueueViewEdit).not.toHaveBeenCalled(); }); it('an armed creation tool is disarmed by the flip and does not re-arm on flipping back', () => { @@ -652,16 +681,16 @@ describe('Editor readOnlyMode flips (both directions)', () => { act(() => { canvasProps!.onSetSelection(new Set([9])); }); - const updateView = ProjectController.prototype.updateView as unknown as ReturnType; + const enqueueViewEdit = ProjectController.prototype.enqueueViewEdit as unknown as ReturnType; await act(async () => { fireEvent.keyDown(document, { key: 'Delete' }); }); - expect(updateView).not.toHaveBeenCalled(); + expect(enqueueViewEdit).not.toHaveBeenCalled(); setReadOnly(result, false); await act(async () => { fireEvent.keyDown(document, { key: 'Delete' }); }); - expect(updateView).toHaveBeenCalled(); + expect(enqueueViewEdit).toHaveBeenCalled(); }); }); diff --git a/src/diagram/tests/editor-selection-changed.test.ts b/src/diagram/tests/editor-selection-changed.test.ts index d5eeb8c0d..7351b8835 100644 --- a/src/diagram/tests/editor-selection-changed.test.ts +++ b/src/diagram/tests/editor-selection-changed.test.ts @@ -77,9 +77,14 @@ function makeSnapshot(navResetSeq = 0): ProjectSnapshot { project, projectVersion: 1, serverVersion: 1, - projectGeneration: 0, status: 'ok', - cachedErrors: { simError: undefined, modelErrors: [], varErrors: new Map(), unitErrors: new Map() }, + cachedErrors: { + simError: undefined, + modelErrors: [], + varErrors: new Map(), + unitErrors: new Map(), + varWarnings: new Map(), + }, data: new Map(), modelName: 'main', modelStack: [], @@ -117,7 +122,6 @@ describe('Editor onSelectionChanged (post-commit effect)', () => { }); rs.spyOn(ProjectController.prototype, 'openInitialProject').mockResolvedValue(undefined); rs.spyOn(ProjectController.prototype, 'dispose').mockResolvedValue(undefined); - rs.spyOn(ProjectController.prototype, 'scheduleSimRun').mockImplementation(() => {}); }); afterEach(() => { diff --git a/src/diagram/tests/editor-selection-invariant.test.ts b/src/diagram/tests/editor-selection-invariant.test.ts index 76605fe1c..d2b3cacde 100644 --- a/src/diagram/tests/editor-selection-invariant.test.ts +++ b/src/diagram/tests/editor-selection-invariant.test.ts @@ -92,7 +92,6 @@ function makeSnapshot(): ProjectSnapshot { project, projectVersion: 1, serverVersion: 1, - projectGeneration: 0, status: 'ok', cachedErrors: { simError: undefined, modelErrors: [], varErrors: new Map(), unitErrors: new Map() }, data: new Map(), @@ -163,13 +162,9 @@ describe('Editor empty-selection invariant (issue #529)', () => { rs.spyOn(ProjectController.prototype, 'subscribe').mockImplementation(() => () => {}); rs.spyOn(ProjectController.prototype, 'openInitialProject').mockResolvedValue(undefined); rs.spyOn(ProjectController.prototype, 'dispose').mockResolvedValue(undefined); - rs.spyOn(ProjectController.prototype, 'scheduleSimRun').mockImplementation(() => {}); - // The delete/create handlers bail if the engine hasn't opened; the mocked - // openInitialProject never opens one, so stub getEngine truthy and stub the - // patch/view methods the handlers await so they proceed to their setState. - rs.spyOn(ProjectController.prototype, 'getEngine').mockReturnValue({} as never); - rs.spyOn(ProjectController.prototype, 'applyPatchOrReportError').mockResolvedValue(true); - rs.spyOn(ProjectController.prototype, 'updateView').mockResolvedValue(undefined); + // The delete/create handlers enqueue a view edit; the mocked + // openInitialProject never opens an engine, so stub the enqueue. + rs.spyOn(ProjectController.prototype, 'enqueueViewEdit').mockResolvedValue(true); }); afterEach(() => { diff --git a/src/diagram/tests/editor-sim-spec-commit.test.ts b/src/diagram/tests/editor-sim-spec-commit.test.ts index 960b03b5f..aeaa02363 100644 --- a/src/diagram/tests/editor-sim-spec-commit.test.ts +++ b/src/diagram/tests/editor-sim-spec-commit.test.ts @@ -3,12 +3,13 @@ // Version 2.0, that can be found in the LICENSE file. // The Editor wires the model-properties drawer's onSimSpecCommit to a single -// engine patch per settled field edit (issue #55): before the fix each typed -// character fired applyPatch -- an undo-history entry and a scheduled save -- -// so typing "1900" recorded four entries and evicted real edits from the -// 5-deep undo buffer. The drawer now debounces to one commit per settle; this -// test asserts the Editor side turns each commit into exactly one applyPatch -// (hence one undo entry) targeting the right sim-spec field. +// model-only edit per settled field edit (issue #55): each typed character +// used to fire a patch -- an undo-history entry and a scheduled save -- so +// typing "1900" recorded four entries and evicted real edits from the 5-deep +// undo buffer. The drawer debounces to one commit per settle; this test asserts +// the Editor turns each commit into exactly one controller model edit (hence +// one undo entry) whose patch, built against the committed project at dequeue, +// sets the right field and echoes the others from the COMMITTED specs. // // Mirrors editor-drawer-delete.test.ts: the drawer is mocked to a prop-recording // stub, Canvas is stubbed out, and the controller is stubbed so a seeded @@ -20,6 +21,9 @@ import type { MockInstance } from '@rstest/core'; import * as React from 'react'; import { act, render } from '@testing-library/react'; +import type { Project } from '@simlin/core/datamodel'; +import type { JsonProjectPatch } from '@simlin/engine'; + import type { ModelPropertiesDrawer as ModelPropertiesDrawerType } from '../ModelPropertiesDrawer'; import { ProjectController, type ProjectSnapshot } from '../project-controller'; @@ -42,7 +46,7 @@ rs.mock('../drawing/Canvas', () => ({ import { Editor, type EditorProps } from '../Editor'; -function makeSnapshot(): ProjectSnapshot { +function makeSnapshot(stop = 100): ProjectSnapshot { const view = { nextUid: 1, elements: [], @@ -56,7 +60,7 @@ function makeSnapshot(): ProjectSnapshot { models: new Map([['main', { name: 'main', variables: new Map(), views: [view], loopMetadata: [], groups: [] }]]), simSpecs: { start: 0, - stop: 100, + stop, dt: { isReciprocal: false, value: 1 }, timeUnits: 'years', }, @@ -64,13 +68,14 @@ function makeSnapshot(): ProjectSnapshot { modelName: 'main', projectVersion: 1, serverVersion: 1, - projectGeneration: 0, status: 'ok', cachedErrors: { simError: undefined, modelErrors: [], varErrors: new Map(), unitErrors: new Map() }, data: new Map(), modelStack: [], canUndo: false, canRedo: false, + undoRedoQueued: false, + token: 0, navResetSeq: 0, } as unknown as ProjectSnapshot; } @@ -89,16 +94,15 @@ function makeProps(overrides: Partial = {}): EditorProps { } describe('Editor sim-spec commit wiring (issue #55)', () => { - let applyPatch: MockInstance; + let enqueueModelEdit: MockInstance; beforeEach(() => { capturedDrawerProps = undefined; rs.spyOn(ProjectController.prototype, 'getSnapshot').mockReturnValue(makeSnapshot()); rs.spyOn(ProjectController.prototype, 'openInitialProject').mockResolvedValue(undefined); rs.spyOn(ProjectController.prototype, 'dispose').mockResolvedValue(undefined); - rs.spyOn(ProjectController.prototype, 'scheduleSimRun').mockImplementation(() => {}); rs.spyOn(ProjectController.prototype, 'subscribe').mockReturnValue(() => {}); - applyPatch = rs.spyOn(ProjectController.prototype, 'applyPatch').mockResolvedValue(true); + enqueueModelEdit = rs.spyOn(ProjectController.prototype, 'enqueueModelEdit').mockResolvedValue(true); }); afterEach(() => { @@ -111,19 +115,40 @@ describe('Editor sim-spec commit wiring (issue #55)', () => { }); } - test('a start-time commit fires exactly one applyPatch with setSimSpecs', async () => { + // The patch queued edit `index` builds when it dequeues against `committed`. + function patchOf(index: number, committed: Project = makeSnapshot().project!): JsonProjectPatch { + const [edit] = enqueueModelEdit.mock.calls[index] as [{ buildPatch: (p: Project) => JsonProjectPatch }]; + return edit.buildPatch(committed); + } + + test('a start-time commit enqueues exactly one model edit that sets startTime', async () => { render_(); expect(capturedDrawerProps).toBeDefined(); await act(async () => { capturedDrawerProps!.onSimSpecCommit('startTime', 1900); }); - expect(applyPatch).toHaveBeenCalledTimes(1); - const [patch] = applyPatch.mock.calls[0]; + expect(enqueueModelEdit).toHaveBeenCalledTimes(1); + const patch = patchOf(0); expect(patch.projectOps).toHaveLength(1); - expect(patch.projectOps[0].type).toBe('setSimSpecs'); - expect(patch.projectOps[0].payload.simSpecs.startTime).toBe(1900); - // Fields the user did not touch keep the model values. - expect(patch.projectOps[0].payload.simSpecs.endTime).toBe(100); + expect(patch.projectOps![0].type).toBe('setSimSpecs'); + const simSpecs = (patch.projectOps![0].payload as { simSpecs: Record }).simSpecs; + expect(simSpecs.startTime).toBe(1900); + // Fields the user did not touch keep the committed values. + expect(simSpecs.endTime).toBe(100); + }); + + test('untouched fields are echoed from the committed specs at dequeue, not from specs read at commit time', async () => { + render_(); + await act(async () => { + capturedDrawerProps!.onSimSpecCommit('startTime', 1900); + }); + // An earlier edit landed stopTime 250 between the commit and the dequeue. + const simSpecs = ( + patchOf(0, makeSnapshot(250).project!).projectOps![0].payload as { + simSpecs: Record; + } + ).simSpecs; + expect(simSpecs.endTime).toBe(250); }); test('a dt commit routes to the dt field as a string', async () => { @@ -131,9 +156,8 @@ describe('Editor sim-spec commit wiring (issue #55)', () => { await act(async () => { capturedDrawerProps!.onSimSpecCommit('dt', 0.5); }); - expect(applyPatch).toHaveBeenCalledTimes(1); - const [patch] = applyPatch.mock.calls[0]; - expect(patch.projectOps[0].payload.simSpecs.dt).toBe('0.5'); + expect(enqueueModelEdit).toHaveBeenCalledTimes(1); + expect((patchOf(0).projectOps![0].payload as { simSpecs: Record }).simSpecs.dt).toBe('0.5'); }); test('a time-units commit routes the free string through', async () => { @@ -141,12 +165,13 @@ describe('Editor sim-spec commit wiring (issue #55)', () => { await act(async () => { capturedDrawerProps!.onSimSpecCommit('timeUnits', 'months'); }); - expect(applyPatch).toHaveBeenCalledTimes(1); - const [patch] = applyPatch.mock.calls[0]; - expect(patch.projectOps[0].payload.simSpecs.timeUnits).toBe('months'); + expect(enqueueModelEdit).toHaveBeenCalledTimes(1); + expect((patchOf(0).projectOps![0].payload as { simSpecs: Record }).simSpecs.timeUnits).toBe( + 'months', + ); }); - test('three separate commits fire three applyPatches (one undo entry each)', async () => { + test('three separate commits enqueue three model edits (one undo entry each)', async () => { render_(); await act(async () => { capturedDrawerProps!.onSimSpecCommit('startTime', 10); @@ -157,6 +182,6 @@ describe('Editor sim-spec commit wiring (issue #55)', () => { await act(async () => { capturedDrawerProps!.onSimSpecCommit('dt', 2); }); - expect(applyPatch).toHaveBeenCalledTimes(3); + expect(enqueueModelEdit).toHaveBeenCalledTimes(3); }); }); diff --git a/src/diagram/tests/editor-tool-deselect.test.ts b/src/diagram/tests/editor-tool-deselect.test.ts index 40548132c..8fb2c9de0 100644 --- a/src/diagram/tests/editor-tool-deselect.test.ts +++ b/src/diagram/tests/editor-tool-deselect.test.ts @@ -80,7 +80,6 @@ function makeSnapshot(): ProjectSnapshot { modelName: 'main', projectVersion: 1, serverVersion: 1, - projectGeneration: 0, status: 'ok', cachedErrors: { simError: undefined, modelErrors: [], varErrors: new Map(), unitErrors: new Map() }, data: new Map(), @@ -117,7 +116,6 @@ describe('Editor tool deselection', () => { rs.spyOn(ProjectController.prototype, 'getSnapshot').mockReturnValue(makeSnapshot()); rs.spyOn(ProjectController.prototype, 'openInitialProject').mockResolvedValue(undefined); rs.spyOn(ProjectController.prototype, 'dispose').mockResolvedValue(undefined); - rs.spyOn(ProjectController.prototype, 'scheduleSimRun').mockImplementation(() => {}); rs.spyOn(ProjectController.prototype, 'subscribe').mockReturnValue(() => {}); }); diff --git a/src/diagram/tests/editor-unmount.test.ts b/src/diagram/tests/editor-unmount.test.ts index 606c5b4f2..b02710666 100644 --- a/src/diagram/tests/editor-unmount.test.ts +++ b/src/diagram/tests/editor-unmount.test.ts @@ -56,7 +56,6 @@ describe('Editor controller lifecycle', () => { // tests stay off WASM. dispose is the contract we assert on unmount. openSpy = rs.spyOn(ProjectController.prototype, 'openInitialProject').mockResolvedValue(undefined); disposeSpy = rs.spyOn(ProjectController.prototype, 'dispose').mockResolvedValue(undefined); - rs.spyOn(ProjectController.prototype, 'scheduleSimRun').mockImplementation(() => {}); }); afterEach(() => { diff --git a/src/diagram/tests/editor-viewport.test.ts b/src/diagram/tests/editor-viewport.test.ts index 4caba8d22..3382dc383 100644 --- a/src/diagram/tests/editor-viewport.test.ts +++ b/src/diagram/tests/editor-viewport.test.ts @@ -83,7 +83,6 @@ function makeSnapshot(project: Project | undefined, overrides: Partial { }); rs.spyOn(ProjectController.prototype, 'openInitialProject').mockResolvedValue(undefined); rs.spyOn(ProjectController.prototype, 'dispose').mockResolvedValue(undefined); - rs.spyOn(ProjectController.prototype, 'scheduleSimRun').mockImplementation(() => {}); }); afterEach(() => { @@ -164,7 +162,7 @@ describe('Editor onViewportChange (post-commit effect)', () => { // A content edit / save ack republishes a NEW project object whose view has // the same viewport: no notification. - publish(makeSnapshot(baseProject(), { projectVersion: 1.01, projectGeneration: 1 })); + publish(makeSnapshot(baseProject(), { projectVersion: 1.01 })); publish(makeSnapshot(baseProject(), { projectVersion: 1.02, serverVersion: 2 })); expect(onViewportChange).not.toHaveBeenCalled(); @@ -241,7 +239,6 @@ describe('Editor initialViewport (controller-config wiring)', () => { function captureConfigs(propsList: EditorProps[]): ControllerConfig[] { rs.spyOn(ProjectControllerModule.ProjectController.prototype, 'openInitialProject').mockResolvedValue(undefined); rs.spyOn(ProjectControllerModule.ProjectController.prototype, 'dispose').mockResolvedValue(undefined); - rs.spyOn(ProjectControllerModule.ProjectController.prototype, 'scheduleSimRun').mockImplementation(() => {}); const captured: ControllerConfig[] = []; const real = ProjectControllerModule.ProjectController; rs.spyOn(ProjectControllerModule, 'ProjectController').mockImplementation((config: ControllerConfig) => { @@ -270,7 +267,6 @@ describe('Editor initialViewport (controller-config wiring)', () => { rs.spyOn(ProjectController.prototype, 'getSnapshot').mockImplementation(() => makeSnapshot(baseProject())); rs.spyOn(ProjectController.prototype, 'openInitialProject').mockResolvedValue(undefined); rs.spyOn(ProjectController.prototype, 'dispose').mockResolvedValue(undefined); - rs.spyOn(ProjectController.prototype, 'scheduleSimRun').mockImplementation(() => {}); rs.spyOn(ProjectController.prototype, 'subscribe').mockReturnValue(() => {}); capturedCanvasProps = undefined; diff --git a/src/diagram/tests/editor-without-reset-css.test.ts b/src/diagram/tests/editor-without-reset-css.test.ts index 436668275..6091a1c0b 100644 --- a/src/diagram/tests/editor-without-reset-css.test.ts +++ b/src/diagram/tests/editor-without-reset-css.test.ts @@ -76,7 +76,6 @@ function makeSnapshot(): ProjectSnapshot { modelName: 'main', projectVersion: 1, serverVersion: 1, - projectGeneration: 0, status: 'ok', cachedErrors: { simError: undefined, modelErrors: [], varErrors: new Map(), unitErrors: new Map() }, data: new Map(), @@ -124,7 +123,6 @@ describe('Editor mounted without reset.css', () => { rs.spyOn(ProjectController.prototype, 'getSnapshot').mockReturnValue(makeSnapshot()); rs.spyOn(ProjectController.prototype, 'openInitialProject').mockResolvedValue(undefined); rs.spyOn(ProjectController.prototype, 'dispose').mockResolvedValue(undefined); - rs.spyOn(ProjectController.prototype, 'scheduleSimRun').mockImplementation(() => {}); rs.spyOn(ProjectController.prototype, 'subscribe').mockReturnValue(() => {}); }); diff --git a/src/diagram/tests/error-details.test.tsx b/src/diagram/tests/error-details.test.tsx index 87ba537c6..fe1d3fcf5 100644 --- a/src/diagram/tests/error-details.test.tsx +++ b/src/diagram/tests/error-details.test.tsx @@ -7,7 +7,7 @@ import { describe, test, expect } from '@rstest/core'; import * as React from 'react'; import { render, screen } from '@testing-library/react'; -import { SimError, ModelError, EquationError, ErrorCode, UnitError } from '@simlin/core/datamodel'; +import { SimError, ModelError, EquationError, ErrorCode, UnitError, VariableWarning } from '@simlin/core/datamodel'; import { ErrorDetails } from '../ErrorDetails'; @@ -16,6 +16,7 @@ const noErrors = { modelErrors: [] as readonly ModelError[], varErrors: new Map(), varUnitErrors: new Map(), + varWarnings: new Map(), status: 'ok' as const, }; @@ -92,4 +93,18 @@ describe('ErrorDetails', () => { ); expect(screen.getByText(/unit error: unit mismatch/i)).not.toBeNull(); }); + + test('renders per-variable advisories as warnings with their details, not as errors', () => { + render( + , + ); + expect(screen.getByText(/variable "level" warning: stock 'level': its inflow list repeats 'f'/)).not.toBeNull(); + expect(screen.queryByText(/variable "level" error:/)).toBeNull(); + expect(screen.queryByText(/generic/i)).toBeNull(); + }); }); diff --git a/src/diagram/tests/fake-engine.ts b/src/diagram/tests/fake-engine.ts index ecddda8b6..d286c6a89 100644 --- a/src/diagram/tests/fake-engine.ts +++ b/src/diagram/tests/fake-engine.ts @@ -4,9 +4,10 @@ // A reusable in-memory fake of the engine `Project`/`Model`/`Run` surface the // ProjectController depends on (the `EngineApi` interface). It records applied -// patches and dispose calls and lets tests script serialized outputs, errors, -// simulatability, and sim-run results -- so the controller's async -// coordination can be exercised without spinning up WASM or jsdom. +// patches, dispose calls and the method-call sequence, and lets tests script +// serialized outputs, errors, simulatability, sim-run results, patch failures, +// and patch latency (a gate each applyPatch awaits) -- so the controller's +// async coordination can be exercised without spinning up WASM or jsdom. import type { JsonProjectPatch, ErrorDetail } from '@simlin/engine'; import type { EngineApi, EngineModelApi, EngineRunApi, ProjectControllerConfig } from '../project-controller'; @@ -29,6 +30,7 @@ export function validProjectJson( name?: string; extraModels?: ReadonlyArray>; mainViewElements?: ReadonlyArray>; + auxiliaries?: ReadonlyArray>; includeStdlib?: boolean; } = {}, ): string { @@ -38,7 +40,7 @@ export function validProjectJson( name: 'main', stocks: [], flows: [], - auxiliaries: [], + auxiliaries: overrides.auxiliaries ?? [], views: [{ elements: overrides.mainViewElements ?? [] }], }, ...(overrides.extraModels ?? []), @@ -67,15 +69,23 @@ export interface FakeEngineOptions { // stdlib model on the display path and omits it on the save path. json?: string | ((includeStdlib: boolean) => string); // The protobuf returned by serializeProtobuf(). Defaults to a 1-byte marker - // that increments on each call so updateProject() always sees a new snapshot. + // that increments on each call so every committed rebuild sees a new snapshot. protobuf?: Uint8Array | (() => Uint8Array); errors?: ErrorDetail[] | (() => ErrorDetail[]); simulatable?: boolean | (() => boolean); - // Scripts the sim run. When it throws, loadSim's LTM-fallback retries; supply - // a function that throws on the first call to exercise that path. + // Scripts the sim run. When it throws, the LTM fallback retries; supply a + // function that throws on the first call to exercise that path. run?: (overrides: Record, options: { analyzeLtm?: boolean }) => EngineRunApi; - // Forces applyPatch to reject (the patch-failure path). - applyPatchThrows?: boolean | Error; + // Forces applyPatch to reject: always (true / an Error), or per patch (a + // function of the patch and its 0-based call index returning the error to + // throw, or undefined to accept). + applyPatchThrows?: boolean | Error | ((patch: JsonProjectPatch, index: number) => Error | undefined); + // Awaited by every applyPatch before it applies or throws: the latency a + // worker round trip adds, controllable per call. + applyPatchGate?: (patch: JsonProjectPatch, index: number) => Promise; + // Called when a patch is accepted, after the gate: lets a test move the + // scripted serialization to the patched state. + onApplyPatch?: (patch: JsonProjectPatch) => void; // Scripts getModel().getIncomingLinks(varName) for the connector-sync check: // a per-ident dependency map, or a function. Unlisted idents yield []. When a // function throws, that variable is dropped from the connector check. @@ -88,6 +98,11 @@ export interface FakeEngine extends EngineApi { readonly appliedPatches: ReadonlyArray; readonly serializeProtobufCalls: number; readonly runCalls: ReadonlyArray<{ overrides: Record; analyzeLtm: boolean | undefined }>; + // Every engine method entry, in order ('applyPatch', 'serializeJson:stdlib', + // 'serializeJson:save', 'serializeProtobuf', 'getErrors', ...). + readonly calls: ReadonlyArray; + // The most engine calls observed in flight at once. + readonly maxConcurrentCalls: number; disposeCount: number; } @@ -115,8 +130,27 @@ export function fakeRun(seriesByName: Record): EngineRunApi { export function makeFakeEngine(options: FakeEngineOptions = {}): FakeEngine { const appliedPatches: JsonProjectPatch[] = []; const runCalls: Array<{ overrides: Record; analyzeLtm: boolean | undefined }> = []; + const calls: string[] = []; let serializeProtobufCalls = 0; let protobufCounter = 100; + let patchIndex = 0; + let inFlight = 0; + let maxConcurrentCalls = 0; + + // Every method counts itself in flight across its own awaits, so a test can + // assert the controller never overlaps two engine calls. + const tracked = async (name: string, body: () => Promise): Promise => { + calls.push(name); + inFlight++; + maxConcurrentCalls = Math.max(maxConcurrentCalls, inFlight); + try { + // One macrotask of latency, so overlapping callers would actually overlap. + await new Promise((resolve) => setTimeout(resolve, 0)); + return await body(); + } finally { + inFlight--; + } + }; const resolveJson = (includeStdlib: boolean): string => { if (typeof options.json === 'function') { @@ -146,60 +180,82 @@ export function makeFakeEngine(options: FakeEngineOptions = {}): FakeEngine { }; const model: EngineModelApi = { - async run( - overrides: Record = {}, - runOptions: { analyzeLtm?: boolean } = {}, - ): Promise { - runCalls.push({ overrides, analyzeLtm: runOptions.analyzeLtm }); - if (options.run) { - return options.run(overrides, runOptions); - } - return fakeRun({ time: [0, 1, 2], output: [1, 2, 3] }); + run(overrides: Record = {}, runOptions: { analyzeLtm?: boolean } = {}): Promise { + return tracked('run', async () => { + runCalls.push({ overrides, analyzeLtm: runOptions.analyzeLtm }); + if (options.run) { + return options.run(overrides, runOptions); + } + return fakeRun({ time: [0, 1, 2], output: [1, 2, 3] }); + }); }, - async getIncomingLinks(varName: string): Promise { - return resolveIncomingLinks(varName); + getIncomingLinks(varName: string): Promise { + return tracked('getIncomingLinks', async () => resolveIncomingLinks(varName)); }, }; const engine: FakeEngine = { appliedPatches, runCalls, + calls, disposeCount: 0, get serializeProtobufCalls() { return serializeProtobufCalls; }, - async applyPatch(patch: JsonProjectPatch): Promise { - if (options.applyPatchThrows) { - throw options.applyPatchThrows instanceof Error - ? options.applyPatchThrows - : Object.assign(new Error('patch rejected'), { code: 1, details: [] }); - } - appliedPatches.push(patch); - return []; + get maxConcurrentCalls() { + return maxConcurrentCalls; + }, + applyPatch(patch: JsonProjectPatch): Promise { + return tracked('applyPatch', async () => { + const index = patchIndex++; + await options.applyPatchGate?.(patch, index); + const throws = options.applyPatchThrows; + const error = + typeof throws === 'function' + ? throws(patch, index) + : throws + ? throws instanceof Error + ? throws + : Object.assign(new Error('patch rejected'), { code: 1, details: [] }) + : undefined; + if (error !== undefined) { + throw error; + } + appliedPatches.push(patch); + options.onApplyPatch?.(patch); + return []; + }); }, - async serializeProtobuf(): Promise { - serializeProtobufCalls++; - return resolveProtobuf(); + serializeProtobuf(): Promise { + return tracked('serializeProtobuf', async () => { + serializeProtobufCalls++; + return resolveProtobuf(); + }); }, - async serializeJson(_format?: unknown, includeStdlib?: boolean): Promise { - return resolveJson(!!includeStdlib); + serializeJson(_format?: unknown, includeStdlib?: boolean): Promise { + return tracked(includeStdlib ? 'serializeJson:stdlib' : 'serializeJson:save', async () => + resolveJson(!!includeStdlib), + ); }, - async getErrors(): Promise { - return resolveErrors(); + getErrors(): Promise { + return tracked('getErrors', async () => resolveErrors()); }, - async isSimulatable(): Promise { - return resolveSimulatable(); + isSimulatable(): Promise { + return tracked('isSimulatable', async () => resolveSimulatable()); }, - async mainModel(): Promise { - return model; + mainModel(): Promise { + return tracked('mainModel', async () => model); }, - async getModel(_modelName: string | null): Promise { - if (options.getModelThrows) { - throw options.getModelThrows instanceof Error ? options.getModelThrows : new Error('getModel failed'); - } - return model; + getModel(_modelName: string | null): Promise { + return tracked('getModel', async () => { + if (options.getModelThrows) { + throw options.getModelThrows instanceof Error ? options.getModelThrows : new Error('getModel failed'); + } + return model; + }); }, async dispose(): Promise { + calls.push('dispose'); engine.disposeCount++; }, }; @@ -207,6 +263,15 @@ export function makeFakeEngine(options: FakeEngineOptions = {}): FakeEngine { return engine; } +/** A gate a test opens by hand: `gate.wait` is what applyPatchGate awaits. */ +export function makeGate(): { wait: () => Promise; open: () => void } { + let resolve: () => void = () => {}; + const promise = new Promise((r) => { + resolve = r; + }); + return { wait: () => promise, open: () => resolve() }; +} + /** * Build a ProjectControllerConfig wired to fake engines. `openProtobuf` / * `openJson` resolve to the engines yielded by `nextEngine` (a queue of @@ -221,6 +286,7 @@ export function makeControllerConfig(opts: { engine?: FakeEngine; engines?: FakeEngine[]; openThrows?: boolean | Error; + now?: () => number; save?: ( project: { format: 'protobuf'; data: Uint8Array } | { format: 'json'; data: string }, currVersion: number, @@ -230,11 +296,13 @@ export function makeControllerConfig(opts: { errors: Error[]; saves: Array<{ project: { format: string; data: unknown }; currVersion: number }>; openedEngines: FakeEngine[]; + openedWith: Uint8Array[]; } { const format = opts.format ?? 'protobuf'; const errors: Error[] = []; const saves: Array<{ project: { format: string; data: unknown }; currVersion: number }> = []; const openedEngines: FakeEngine[] = []; + const openedWith: Uint8Array[] = []; const queue: FakeEngine[] = opts.engines ? [...opts.engines] : []; const singleEngine = opts.engine; @@ -254,7 +322,10 @@ export function makeControllerConfig(opts: { format === 'protobuf' ? { format: 'protobuf', data: (opts.initialData as Uint8Array | undefined) ?? new Uint8Array([1]) } : { format: 'json', data: (opts.initialData as string | undefined) ?? validProjectJson() }, - openProtobuf: () => nextEngine(), + openProtobuf: (data) => { + openedWith.push(data); + return nextEngine(); + }, openJson: () => nextEngine(), save: opts.save ?? @@ -265,9 +336,10 @@ export function makeControllerConfig(opts: { onError: (err) => { errors.push(err); }, + now: opts.now, }; - return { config, errors, saves, openedEngines }; + return { config, errors, saves, openedEngines, openedWith }; } function defined(value: T | undefined): T { diff --git a/src/diagram/tests/flow-attach.test.ts b/src/diagram/tests/flow-attach.test.ts deleted file mode 100644 index 0a4240490..000000000 --- a/src/diagram/tests/flow-attach.test.ts +++ /dev/null @@ -1,1281 +0,0 @@ -// Copyright 2026 The Simlin Authors. All rights reserved. -// Use of this source code is governed by the Apache License, -// Version 2.0, that can be found in the LICENSE file. - -import { describe, it, expect } from '@rstest/core'; - -import { - type CloudViewElement, - type Equation, - type FlowViewElement, - type Stock, - type StockFlowView, - type StockViewElement, - type Variable, - type ViewElement, -} from '@simlin/core/datamodel'; -import type { JsonModelOperation } from '@simlin/engine'; - -import { - computeFlowAttachment, - fauxCloudTargetUid, - growEndpointDrag, - growInCreationFlow, - inCreationCloudUid, - inCreationUid, - type FlowAttachParams, -} from '../flow-attach'; -import { - fauxCloudTargetUid as canvasFauxCloudTargetUid, - inCreationCloudUid as canvasInCreationCloudUid, - inCreationUid as canvasInCreationUid, -} from '../drawing/Canvas'; -import { - fauxCloudTargetUid as sharedFauxCloudTargetUid, - inCreationCloudUid as sharedInCreationCloudUid, - inCreationUid as sharedInCreationUid, -} from '../drawing/creation-sentinels'; -import { StockWidth, StockHeight } from '../drawing/default'; -import { getSegments, findClosestSegment } from '../drawing/Flow'; - -// ----- fixture helpers (mirroring flow-routing.test.ts patterns) ----- - -function makeStockEl( - uid: number, - ident: string, - x: number, - y: number, - inflows: number[] = [], - outflows: number[] = [], -): StockViewElement { - return { - type: 'stock', - uid, - name: ident, - ident, - var: undefined, - x, - y, - labelSide: 'center', - isZeroRadius: false, - inflows, - outflows, - }; -} - -function makeFlowEl( - uid: number, - ident: string, - x: number, - y: number, - points: Array<{ x: number; y: number; attachedToUid?: number }>, -): FlowViewElement { - return { - type: 'flow', - uid, - name: ident, - ident, - var: undefined, - x, - y, - labelSide: 'center', - points: points.map((p) => ({ x: p.x, y: p.y, attachedToUid: p.attachedToUid })), - isZeroRadius: false, - }; -} - -function makeCloudEl(uid: number, flowUid: number, x: number, y: number): CloudViewElement { - return { - type: 'cloud', - uid, - flowUid, - x, - y, - isZeroRadius: false, - ident: undefined, - }; -} - -function makeView(elements: ViewElement[], nextUid: number): StockFlowView { - return { - nextUid, - elements, - viewBox: { x: 0, y: 0, width: 1000, height: 1000 }, - zoom: 1, - useLetteredPolarity: false, - }; -} - -const emptyEquation: Equation = { type: 'scalar', equation: '' }; - -function makeStockVar(ident: string, inflows: string[] = [], outflows: string[] = []): Stock { - return { - type: 'stock', - ident, - equation: emptyEquation, - documentation: '', - units: '', - inflows, - outflows, - nonNegative: false, - canBeModuleInput: false, - isPublic: false, - activeInitial: undefined, - dataSource: undefined, - data: undefined, - errors: undefined, - unitErrors: undefined, - uid: undefined, - }; -} - -function varsOf(...stocks: Stock[]): ReadonlyMap { - return new Map(stocks.map((s) => [s.ident, s])); -} - -const NO_DELTA = { x: 0, y: 0 }; - -function params(overrides: Partial & { flow: FlowViewElement }): FlowAttachParams { - return { - targetUid: 0, - cursorMoveDelta: NO_DELTA, - fauxTargetCenter: undefined, - inCreation: false, - isSourceAttach: false, - ...overrides, - }; -} - -// Convenience: assert exactly one updateStockFlows op for `ident` and return it. -function stockFlowsOpFor(ops: readonly JsonModelOperation[], ident: string): JsonModelOperation { - const matches = ops.filter( - (op) => op.type === 'updateStockFlows' && (op as { payload: { ident: string } }).payload.ident === ident, - ); - expect(matches.length).toBe(1); - return matches[0]; -} - -function payloadOf(op: JsonModelOperation): { ident: string; inflows: string[]; outflows: string[] } { - return (op as unknown as { payload: { ident: string; inflows: string[]; outflows: string[] } }).payload; -} - -describe('computeFlowAttachment', () => { - // The creation sentinels are defined once in drawing/creation-sentinels and - // re-exported by both flow-attach and Canvas. This guards that every import - // path stays wired to that single source (a trivial identity now, but it - // keeps the compatibility of both re-export paths under test). - it('keeps creation sentinel constants in sync across import paths', () => { - expect(inCreationUid).toBe(sharedInCreationUid); - expect(inCreationCloudUid).toBe(sharedInCreationCloudUid); - expect(fauxCloudTargetUid).toBe(sharedFauxCloudTargetUid); - expect(canvasInCreationUid).toBe(sharedInCreationUid); - expect(canvasInCreationCloudUid).toBe(sharedInCreationCloudUid); - expect(canvasFauxCloudTargetUid).toBe(sharedFauxCloudTargetUid); - }); - - describe('sink reattach', () => { - it('stock -> stock: detaches old inflow, attaches new inflow', () => { - const srcStock = makeStockEl(1, 'src', 0, 100); - const oldSink = makeStockEl(2, 'old_sink', 200, 100, [3]); - const newSink = makeStockEl(4, 'new_sink', 200, 300); - const flow = makeFlowEl(3, 'f', 100, 100, [ - { x: 20, y: 100, attachedToUid: 1 }, - { x: 180, y: 100, attachedToUid: 2 }, - ]); - const view = makeView([srcStock, oldSink, newSink, flow], 5); - const variables = varsOf(makeStockVar('old_sink', ['f']), makeStockVar('new_sink')); - - const result = computeFlowAttachment(view, variables, params({ flow, targetUid: 4 })); - - // attach new inflow on new_sink, detach from old_sink - const attach = payloadOf(stockFlowsOpFor(result.ops, 'new_sink')); - expect(attach.inflows).toEqual(['f']); - const detach = payloadOf(stockFlowsOpFor(result.ops, 'old_sink')); - expect(detach.inflows).toEqual([]); - // no clouds created or deleted; element count unchanged - expect(result.elements.length).toBe(4); - expect(result.isCreatingNew).toBe(false); - expect(result.selection).toBeUndefined(); - // flow's last point now references the new sink - const outFlow = result.elements.find((e) => e.uid === 3) as FlowViewElement; - expect(outFlow.points[outFlow.points.length - 1].attachedToUid).toBe(4); - }); - - it('stock -> empty space: creates a cloud at release, detaches inflow', () => { - const srcStock = makeStockEl(1, 'src', 0, 100); - const oldSink = makeStockEl(2, 'old_sink', 200, 100, [3]); - const flow = makeFlowEl(3, 'f', 100, 100, [ - { x: 20, y: 100, attachedToUid: 1 }, - { x: 180, y: 100, attachedToUid: 2 }, - ]); - const view = makeView([srcStock, oldSink, flow], 5); - const variables = varsOf(makeStockVar('old_sink', ['f'])); - - const result = computeFlowAttachment( - view, - variables, - params({ flow, targetUid: 0, cursorMoveDelta: { x: -40, y: 0 } }), - ); - - // a new cloud was created (uid 5) - const clouds = result.elements.filter((e) => e.type === 'cloud'); - expect(clouds.length).toBe(1); - expect(result.nextUid).toBe(6); - // detach op only - const detach = payloadOf(stockFlowsOpFor(result.ops, 'old_sink')); - expect(detach.inflows).toEqual([]); - // no attach op (no stock target) - expect(result.ops.filter((o) => o.type === 'updateStockFlows').length).toBe(1); - }); - - it('drops the op for a stock missing from the variables map', () => { - // The detaching stock's view element exists, but its Variable is absent - // from the model map (e.g. mid-edit). stockFlowsOp returns undefined and - // the op is dropped -- matching the original `if (stockVar?.type === - // 'stock')` guard. The attach op for the present stock still fires. - const srcStock = makeStockEl(1, 'src', 0, 100); - const oldSink = makeStockEl(2, 'old_sink', 200, 100, [3]); - const newSink = makeStockEl(4, 'new_sink', 200, 300); - const flow = makeFlowEl(3, 'f', 100, 100, [ - { x: 20, y: 100, attachedToUid: 1 }, - { x: 180, y: 100, attachedToUid: 2 }, - ]); - const view = makeView([srcStock, oldSink, newSink, flow], 5); - // old_sink intentionally omitted from the variables map. - const variables = varsOf(makeStockVar('new_sink')); - - const result = computeFlowAttachment(view, variables, params({ flow, targetUid: 4 })); - - // attach op present, detach op dropped (no var for old_sink) - expect(payloadOf(stockFlowsOpFor(result.ops, 'new_sink')).inflows).toEqual(['f']); - expect(result.ops.filter((o) => o.type === 'updateStockFlows').length).toBe(1); - }); - - it('cloud -> stock: deletes old cloud, attaches inflow', () => { - const srcStock = makeStockEl(1, 'src', 0, 100); - const oldCloud = makeCloudEl(2, 3, 200, 100); - const newSink = makeStockEl(4, 'new_sink', 200, 300); - const flow = makeFlowEl(3, 'f', 100, 100, [ - { x: 20, y: 100, attachedToUid: 1 }, - { x: 200, y: 100, attachedToUid: 2 }, - ]); - const view = makeView([srcStock, oldCloud, newSink, flow], 5); - const variables = varsOf(makeStockVar('new_sink')); - - const result = computeFlowAttachment(view, variables, params({ flow, targetUid: 4 })); - - // old cloud (uid 2) deleted - expect(result.elements.find((e) => e.uid === 2)).toBeUndefined(); - // attach inflow on new_sink, no detach (old end was a cloud) - const attach = payloadOf(stockFlowsOpFor(result.ops, 'new_sink')); - expect(attach.inflows).toEqual(['f']); - expect(result.ops.filter((o) => o.type === 'updateStockFlows').length).toBe(1); - }); - - it('cloud -> empty space: moves the cloud, emits no ops', () => { - const srcStock = makeStockEl(1, 'src', 0, 100); - const oldCloud = makeCloudEl(2, 3, 200, 100); - const flow = makeFlowEl(3, 'f', 100, 100, [ - { x: 20, y: 100, attachedToUid: 1 }, - { x: 200, y: 100, attachedToUid: 2 }, - ]); - const view = makeView([srcStock, oldCloud, flow], 5); - const variables = varsOf(); - - const result = computeFlowAttachment( - view, - variables, - params({ flow, targetUid: 0, cursorMoveDelta: { x: -30, y: 0 } }), - ); - - // cloud preserved (updated), no new cloud, no ops - expect(result.elements.filter((e) => e.type === 'cloud').length).toBe(1); - expect(result.elements.find((e) => e.uid === 2)).toBeDefined(); - expect(result.ops.length).toBe(0); - expect(result.nextUid).toBe(5); - }); - }); - - describe('source reattach (mirror cases, outflows)', () => { - it('stock -> stock: detaches old outflow, attaches new outflow', () => { - const oldSrc = makeStockEl(1, 'old_src', 0, 100, [], [3]); - const newSrc = makeStockEl(4, 'new_src', 0, 300); - const sinkStock = makeStockEl(2, 'sink', 200, 100); - const flow = makeFlowEl(3, 'f', 100, 100, [ - { x: 20, y: 100, attachedToUid: 1 }, - { x: 180, y: 100, attachedToUid: 2 }, - ]); - const view = makeView([oldSrc, newSrc, sinkStock, flow], 5); - const variables = varsOf(makeStockVar('old_src', [], ['f']), makeStockVar('new_src')); - - const result = computeFlowAttachment(view, variables, params({ flow, targetUid: 4, isSourceAttach: true })); - - const attach = payloadOf(stockFlowsOpFor(result.ops, 'new_src')); - expect(attach.outflows).toEqual(['f']); - const detach = payloadOf(stockFlowsOpFor(result.ops, 'old_src')); - expect(detach.outflows).toEqual([]); - const outFlow = result.elements.find((e) => e.uid === 3) as FlowViewElement; - expect(outFlow.points[0].attachedToUid).toBe(4); - }); - - it('stock -> empty space: creates a cloud at release, detaches outflow', () => { - const oldSrc = makeStockEl(1, 'old_src', 0, 100, [], [3]); - const sinkStock = makeStockEl(2, 'sink', 200, 100); - const flow = makeFlowEl(3, 'f', 100, 100, [ - { x: 20, y: 100, attachedToUid: 1 }, - { x: 180, y: 100, attachedToUid: 2 }, - ]); - const view = makeView([oldSrc, sinkStock, flow], 5); - const variables = varsOf(makeStockVar('old_src', [], ['f'])); - - const result = computeFlowAttachment( - view, - variables, - params({ flow, targetUid: 0, cursorMoveDelta: { x: 40, y: 0 }, isSourceAttach: true }), - ); - - expect(result.elements.filter((e) => e.type === 'cloud').length).toBe(1); - const detach = payloadOf(stockFlowsOpFor(result.ops, 'old_src')); - expect(detach.outflows).toEqual([]); - expect(result.ops.filter((o) => o.type === 'updateStockFlows').length).toBe(1); - }); - - it('cloud -> stock: deletes old cloud, attaches outflow', () => { - const oldCloud = makeCloudEl(1, 3, 0, 100); - const newSrc = makeStockEl(4, 'new_src', 0, 300); - const sinkStock = makeStockEl(2, 'sink', 200, 100); - const flow = makeFlowEl(3, 'f', 100, 100, [ - { x: 0, y: 100, attachedToUid: 1 }, - { x: 180, y: 100, attachedToUid: 2 }, - ]); - const view = makeView([oldCloud, newSrc, sinkStock, flow], 5); - const variables = varsOf(makeStockVar('new_src')); - - const result = computeFlowAttachment(view, variables, params({ flow, targetUid: 4, isSourceAttach: true })); - - expect(result.elements.find((e) => e.uid === 1)).toBeUndefined(); - const attach = payloadOf(stockFlowsOpFor(result.ops, 'new_src')); - expect(attach.outflows).toEqual(['f']); - expect(result.ops.filter((o) => o.type === 'updateStockFlows').length).toBe(1); - }); - - it('cloud -> empty space: moves the cloud, emits no ops', () => { - const oldCloud = makeCloudEl(1, 3, 0, 100); - const sinkStock = makeStockEl(2, 'sink', 200, 100); - const flow = makeFlowEl(3, 'f', 100, 100, [ - { x: 0, y: 100, attachedToUid: 1 }, - { x: 180, y: 100, attachedToUid: 2 }, - ]); - const view = makeView([oldCloud, sinkStock, flow], 5); - const variables = varsOf(); - - const result = computeFlowAttachment( - view, - variables, - params({ flow, targetUid: 0, cursorMoveDelta: { x: 30, y: 0 }, isSourceAttach: true }), - ); - - expect(result.elements.find((e) => e.uid === 1)).toBeDefined(); - expect(result.ops.length).toBe(0); - expect(result.nextUid).toBe(5); - }); - }); - - describe('creation', () => { - it('cloud source to empty space: two new clouds, upsertFlow, selects new flow', () => { - // in-creation flow: source attached to inCreationCloudUid, sink to faux cloud target - const flow = makeFlowEl(inCreationUid, 'new_flow', 100, 100, [ - { x: 50, y: 100, attachedToUid: inCreationCloudUid }, - { x: 150, y: 100, attachedToUid: fauxCloudTargetUid }, - ]); - // The in-creation flow is a transient Canvas element, never part of the - // persisted view, so it is NOT in view.elements. - const view = makeView([], 5); - const variables = varsOf(); - - const result = computeFlowAttachment( - view, - variables, - params({ flow, targetUid: 0, fauxTargetCenter: { x: 150, y: 100 }, inCreation: true }), - ); - - expect(result.isCreatingNew).toBe(true); - // upsertFlow present - const upsert = result.ops.find((o) => o.type === 'upsertFlow'); - expect(upsert).toBeDefined(); - expect((upsert as { payload: { flow: { name: string } } }).payload.flow.name).toBe('new_flow'); - // two clouds materialized (source + sink) - expect(result.elements.filter((e) => e.type === 'cloud').length).toBe(2); - // selection is the realized flow uid (sentinel replaced by a real uid) - const realFlow = result.elements.find((e) => e.type === 'flow') as FlowViewElement; - expect(realFlow.uid).not.toBe(inCreationUid); - expect(result.selection).toEqual(new Set([realFlow.uid])); - // no stock ops (no stocks involved) - expect(result.ops.filter((o) => o.type === 'updateStockFlows').length).toBe(0); - }); - - it('stock source to target stock: upsertFlow + source outflow + sink inflow', () => { - const srcStock = makeStockEl(1, 'src', 0, 100); - const sinkStock = makeStockEl(2, 'snk', 300, 100); - const flow = makeFlowEl(inCreationUid, 'new_flow', 150, 100, [ - { x: 20, y: 100, attachedToUid: 1 }, - { x: 280, y: 100, attachedToUid: fauxCloudTargetUid }, - ]); - const view = makeView([srcStock, sinkStock], 5); - const variables = varsOf(makeStockVar('src'), makeStockVar('snk')); - - const result = computeFlowAttachment(view, variables, params({ flow, targetUid: 2, inCreation: true })); - - expect(result.isCreatingNew).toBe(true); - expect(result.ops.find((o) => o.type === 'upsertFlow')).toBeDefined(); - const realFlow = result.elements.find((e) => e.type === 'flow') as FlowViewElement; - const flowIdent = realFlow.ident; - // source stock gets an outflow - const srcOp = payloadOf(stockFlowsOpFor(result.ops, 'src')); - expect(srcOp.outflows).toEqual([flowIdent]); - // sink stock gets an inflow - const snkOp = payloadOf(stockFlowsOpFor(result.ops, 'snk')); - expect(snkOp.inflows).toEqual([flowIdent]); - }); - - it('cloud source to target stock: sink pins to the stock EDGE, flow horizontal, valve on the segment', () => { - // The exact UI scenario, faithful to runtime: the flow tool stages a - // DEGENERATE in-creation flow (both points AND the valve at the press - // point, since the drag offset is applied only at render time and never - // committed back to the element); the user drags the sink onto a stock and - // releases (targetUid = the stock). The sink must land on the stock's EDGE - // (not its center -- the center put the arrowhead behind the stock, the - // 71752f3b regression), the flow must be orthogonal, and the valve must sit - // ON the source->sink segment (not at/behind the stock center). - const sinkStock = makeStockEl(1, 'snk', 300, 100); - const flow = makeFlowEl(inCreationUid, 'new_flow', 50, 100, [ - { x: 50, y: 100, attachedToUid: inCreationCloudUid }, - { x: 50, y: 100, attachedToUid: fauxCloudTargetUid }, - ]); - const view = makeView([sinkStock], 5); - const variables = varsOf(makeStockVar('snk')); - - const result = computeFlowAttachment(view, variables, params({ flow, targetUid: 1, inCreation: true })); - - const realFlow = result.elements.find((e) => e.type === 'flow') as FlowViewElement; - const sourcePt = realFlow.points[0]; - const sinkPt = realFlow.points[realFlow.points.length - 1]; - // the sink point attaches to the stock (uid 1), not a freshly-created cloud - expect(sinkPt.attachedToUid).toBe(1); - // ...landing on the stock's LEFT edge (center.x - StockWidth/2), NOT its - // center: the source is to the left, so the flow enters the left face. - expect(sinkPt.x).toBeCloseTo(300 - StockWidth / 2); - expect(sinkPt.x).not.toBe(300); - // ...and the flow is horizontal: the sink shares the source's y. - expect(sinkPt.y).toBe(sourcePt.y); - // a straight (2-point) orthogonal segment - expect(realFlow.points.length).toBe(2); - // the valve sits strictly between the source and the (edge-clipped) sink, - // i.e. on the visible segment -- never at/behind the stock center. - expect(realFlow.x).toBeGreaterThan(sourcePt.x); - expect(realFlow.x).toBeLessThan(sinkPt.x); - expect(realFlow.y).toBe(sourcePt.y); - // only the source cloud is materialized; no sink cloud - expect(result.elements.filter((e) => e.type === 'cloud').length).toBe(1); - // the stock gains the flow as an inflow - const snkOp = payloadOf(stockFlowsOpFor(result.ops, 'snk')); - expect(snkOp.inflows).toEqual([realFlow.ident]); - }); - - it('cloud source above a target stock: sink pins to the TOP edge and the flow is vertical', () => { - // Press above the stock, drag straight down onto it: the flow must route - // vertically and pin to the stock's top edge (center.y - StockHeight/2). - const sinkStock = makeStockEl(1, 'snk', 300, 200); - const flow = makeFlowEl(inCreationUid, 'new_flow', 300, 120, [ - { x: 300, y: 50, attachedToUid: inCreationCloudUid }, - { x: 300, y: 180, attachedToUid: fauxCloudTargetUid }, - ]); - const view = makeView([sinkStock], 5); - const variables = varsOf(makeStockVar('snk')); - - const result = computeFlowAttachment(view, variables, params({ flow, targetUid: 1, inCreation: true })); - - const realFlow = result.elements.find((e) => e.type === 'flow') as FlowViewElement; - const sourcePt = realFlow.points[0]; - const sinkPt = realFlow.points[realFlow.points.length - 1]; - expect(sinkPt.attachedToUid).toBe(1); - expect(sinkPt.y).toBeCloseTo(200 - StockHeight / 2); // top edge, not center - expect(sinkPt.y).not.toBe(200); - expect(sinkPt.x).toBe(sourcePt.x); // vertical - }); - - it('degenerate creation onto a stock to the LEFT: edge-pinned, not degenerate', () => { - // Faithful to runtime: the in-creation flow is degenerate (both points at - // the press point). Releasing on a stock to the LEFT of the press must pin - // the sink to the stock's RIGHT edge -- a signed axis comparison in - // adjustFlows previously left this (and upward) degenerate on the commit - // path, not just the preview. - const sinkStock = makeStockEl(1, 'snk', 40, 200); // stock to the left of the press - const flow = makeFlowEl(inCreationUid, 'new_flow', 100, 200, [ - { x: 100, y: 200, attachedToUid: inCreationCloudUid }, - { x: 100, y: 200, attachedToUid: fauxCloudTargetUid }, - ]); - const view = makeView([sinkStock], 5); - const variables = varsOf(makeStockVar('snk')); - - const result = computeFlowAttachment(view, variables, params({ flow, targetUid: 1, inCreation: true })); - - const realFlow = result.elements.find((e) => e.type === 'flow') as FlowViewElement; - const sourcePt = realFlow.points[0]; - const sinkPt = realFlow.points[realFlow.points.length - 1]; - expect(sinkPt.attachedToUid).toBe(1); - expect(sinkPt.x).toBeCloseTo(40 + StockWidth / 2); // 62.5: right edge - expect(sinkPt.x).not.toBe(100); // not degenerate / stuck at the press point - expect(sinkPt.y).toBe(sourcePt.y); // horizontal - }); - - it('stock source: the source endpoint pins to the stock EDGE facing the sink, not its center', () => { - // Faithful to runtime: pressing the flow tool ON a stock stages the - // source point at the stock's CENTER, and only the sink is routed at - // commit -- so the persisted source endpoint hid under the stock body - // (violating the edge-attachment rule) until the next stock drag - // re-pinned it. The commit must pin the source to the facing edge. - const srcStock = makeStockEl(1, 'src', 100, 100); - const sinkStock = makeStockEl(2, 'snk', 400, 100); - const flow = makeFlowEl(inCreationUid, 'new_flow', 100, 100, [ - { x: 100, y: 100, attachedToUid: 1 }, - { x: 100, y: 100, attachedToUid: fauxCloudTargetUid }, - ]); - const view = makeView([srcStock, sinkStock], 5); - const variables = varsOf(makeStockVar('src'), makeStockVar('snk')); - - const result = computeFlowAttachment(view, variables, params({ flow, targetUid: 2, inCreation: true })); - - const realFlow = result.elements.find((e) => e.type === 'flow') as FlowViewElement; - const sourcePt = realFlow.points[0]; - const sinkPt = realFlow.points[realFlow.points.length - 1]; - // Source on the RIGHT edge of the source stock (the sink is to the right). - expect(sourcePt.attachedToUid).toBe(1); - expect(sourcePt.x).toBeCloseTo(100 + StockWidth / 2); - expect(sourcePt.x).not.toBe(100); - // Sink on the LEFT edge of the target stock; flow horizontal and straight. - expect(sinkPt.x).toBeCloseTo(400 - StockWidth / 2); - expect(sinkPt.y).toBe(sourcePt.y); - expect(realFlow.points.length).toBe(2); - // The valve sits on the visible segment between the two edges. - expect(realFlow.x).toBeGreaterThan(sourcePt.x); - expect(realFlow.x).toBeLessThan(sinkPt.x); - }); - - it('stock source dragged UP to empty space: the source pins to the TOP edge', () => { - const srcStock = makeStockEl(1, 'src', 100, 300); - const flow = makeFlowEl(inCreationUid, 'new_flow', 100, 300, [ - { x: 100, y: 300, attachedToUid: 1 }, - { x: 100, y: 300, attachedToUid: fauxCloudTargetUid }, - ]); - const view = makeView([srcStock], 5); - const variables = varsOf(makeStockVar('src')); - - // Release 150px above the press: the sink cloud materializes there. - const result = computeFlowAttachment( - view, - variables, - params({ flow, inCreation: true, fauxTargetCenter: { x: 100, y: 150 }, cursorMoveDelta: { x: 0, y: 150 } }), - ); - - const realFlow = result.elements.find((e) => e.type === 'flow') as FlowViewElement; - const sourcePt = realFlow.points[0]; - const sinkPt = realFlow.points[realFlow.points.length - 1]; - expect(sourcePt.attachedToUid).toBe(1); - expect(sourcePt.y).toBeCloseTo(300 - StockHeight / 2); // top edge, not center - expect(sourcePt.x).toBe(sinkPt.x); // vertical - }); - - it('stock source to empty space: faux target becomes a new cloud at fauxTargetCenter', () => { - const srcStock = makeStockEl(1, 'src', 0, 100); - const flow = makeFlowEl(inCreationUid, 'new_flow', 150, 100, [ - { x: 20, y: 100, attachedToUid: 1 }, - { x: 280, y: 100, attachedToUid: fauxCloudTargetUid }, - ]); - const view = makeView([srcStock], 5); - const variables = varsOf(makeStockVar('src')); - - const result = computeFlowAttachment( - view, - variables, - params({ flow, targetUid: 0, fauxTargetCenter: { x: 280, y: 100 }, inCreation: true }), - ); - - // a cloud was created for the faux target - const clouds = result.elements.filter((e) => e.type === 'cloud') as CloudViewElement[]; - expect(clouds.length).toBe(1); - // source stock gets an outflow only (no sink stock) - const realFlow = result.elements.find((e) => e.type === 'flow') as FlowViewElement; - const srcOp = payloadOf(stockFlowsOpFor(result.ops, 'src')); - expect(srcOp.outflows).toEqual([realFlow.ident]); - expect(result.ops.filter((o) => o.type === 'updateStockFlows').length).toBe(1); - }); - }); - - describe('nextUid monotonicity and element validity', () => { - it('nextUid never decreases and all element uids are valid integers', () => { - const srcStock = makeStockEl(1, 'src', 0, 100); - const oldSink = makeStockEl(2, 'old_sink', 200, 100, [3]); - const flow = makeFlowEl(3, 'f', 100, 100, [ - { x: 20, y: 100, attachedToUid: 1 }, - { x: 180, y: 100, attachedToUid: 2 }, - ]); - const view = makeView([srcStock, oldSink, flow], 5); - const variables = varsOf(makeStockVar('old_sink', ['f'])); - - const result = computeFlowAttachment( - view, - variables, - params({ flow, targetUid: 0, cursorMoveDelta: { x: -40, y: 0 } }), - ); - - expect(result.nextUid).toBeGreaterThanOrEqual(view.nextUid); - for (const el of result.elements) { - expect(Number.isInteger(el.uid)).toBe(true); - expect(el.uid).toBeGreaterThanOrEqual(0); - expect(el.uid).toBeLessThan(result.nextUid); - } - }); - }); - - describe('op deduplication', () => { - // The original emitted two byte-identical outflow-add ops only if - // sourceStockIdent (creation path) AND sourceStockAttachingIdent (reattach - // path) were both set for the same stock. Those two flags are mutually - // exclusive in practice -- reattachEndpoint never runs during creation - // (the in-creation flow isn't yet a view element), so sourceStockAttaching - // is never set when sourceStockIdent is. The collapse is therefore a - // defensive normalization. These tests pin the two observable guarantees: - // (1) genuinely-distinct ops are NOT collapsed, and (2) the normal paths - // never emit duplicates. - it('does not collapse ops that differ only by list (outflow add vs inflow add)', () => { - // Creation from a source stock to a distinct sink stock yields an outflow - // add on src and an inflow add on snk. They share neither ident nor list, - // so dedup must keep both -- a guard that the collapse keys on full op - // content, not just on ident. - const srcStock = makeStockEl(1, 'src', 0, 100); - const sinkStock = makeStockEl(2, 'snk', 300, 100); - const flow = makeFlowEl(inCreationUid, 'new_flow', 150, 100, [ - { x: 20, y: 100, attachedToUid: 1 }, - { x: 280, y: 100, attachedToUid: fauxCloudTargetUid }, - ]); - const view = makeView([srcStock, sinkStock], 5); - const variables = varsOf(makeStockVar('src'), makeStockVar('snk')); - - const result = computeFlowAttachment(view, variables, params({ flow, targetUid: 2, inCreation: true })); - const stockOps = result.ops.filter((o) => o.type === 'updateStockFlows'); - expect(stockOps.length).toBe(2); - const realFlow = result.elements.find((e) => e.type === 'flow') as FlowViewElement; - expect(payloadOf(stockFlowsOpFor(result.ops, 'src')).outflows).toEqual([realFlow.ident]); - expect(payloadOf(stockFlowsOpFor(result.ops, 'snk')).inflows).toEqual([realFlow.ident]); - }); - - it('emits no duplicate ops for a plain reattach', () => { - const srcStock = makeStockEl(1, 'src', 0, 100); - const oldSink = makeStockEl(2, 'old_sink', 200, 100, [3]); - const newSink = makeStockEl(4, 'new_sink', 200, 300); - const flow = makeFlowEl(3, 'f', 100, 100, [ - { x: 20, y: 100, attachedToUid: 1 }, - { x: 180, y: 100, attachedToUid: 2 }, - ]); - const view = makeView([srcStock, oldSink, newSink, flow], 5); - const variables = varsOf(makeStockVar('old_sink', ['f']), makeStockVar('new_sink')); - - const result = computeFlowAttachment(view, variables, params({ flow, targetUid: 4 })); - const serialized = result.ops.map((o) => JSON.stringify(o)); - expect(new Set(serialized).size).toBe(serialized.length); - }); - }); - - describe('error handling', () => { - it('throws on unknown targetUid', () => { - const srcStock = makeStockEl(1, 'src', 0, 100); - const oldSink = makeStockEl(2, 'old_sink', 200, 100, [3]); - const flow = makeFlowEl(3, 'f', 100, 100, [ - { x: 20, y: 100, attachedToUid: 1 }, - { x: 180, y: 100, attachedToUid: 2 }, - ]); - const view = makeView([srcStock, oldSink, flow], 5); - const variables = varsOf(makeStockVar('old_sink', ['f'])); - - expect(() => computeFlowAttachment(view, variables, params({ flow, targetUid: 999 }))).toThrow('unknown uid 999'); - }); - - it("throws when the snap target isn't a stock or cloud", () => { - const srcStock = makeStockEl(1, 'src', 0, 100); - const oldSink = makeStockEl(2, 'old_sink', 200, 100, [3]); - // an aux is not a valid flow endpoint target - const aux: ViewElement = { - type: 'aux', - uid: 7, - name: 'a', - ident: 'a', - var: undefined, - x: 200, - y: 300, - labelSide: 'center', - isZeroRadius: false, - }; - const flow = makeFlowEl(3, 'f', 100, 100, [ - { x: 20, y: 100, attachedToUid: 1 }, - { x: 180, y: 100, attachedToUid: 2 }, - ]); - const view = makeView([srcStock, oldSink, aux, flow], 8); - const variables = varsOf(makeStockVar('old_sink', ['f'])); - - expect(() => computeFlowAttachment(view, variables, params({ flow, targetUid: 7 }))).toThrow( - "new target isn't a stock or cloud", - ); - }); - }); - - // These tests assert the exact x/y of cloud placement and flow endpoints so - // a future edit to the cloud-placement / UpdateCloudAndFlow math can't pass - // silently. They use non-zero deltas where a sign flip would change the - // result. - describe('coordinate math', () => { - it('(a) detach sink to empty space places the cloud at oldEnd - cursorMoveDelta', () => { - // Sink stock at (200,100); release delta {-40,0} -> cloud at x = 200 - (-40) = 240. - // A sign flip (200 + -40 = 160) would fail this assertion. - const srcStock = makeStockEl(1, 'src', 0, 100); - const oldSink = makeStockEl(2, 'old_sink', 200, 100, [3]); - const flow = makeFlowEl(3, 'f', 100, 100, [ - { x: 20, y: 100, attachedToUid: 1 }, - { x: 180, y: 100, attachedToUid: 2 }, - ]); - const view = makeView([srcStock, oldSink, flow], 5); - const variables = varsOf(makeStockVar('old_sink', ['f'])); - - const result = computeFlowAttachment( - view, - variables, - params({ flow, targetUid: 0, cursorMoveDelta: { x: -40, y: 0 } }), - ); - - const cloud = result.elements.find((e) => e.type === 'cloud') as CloudViewElement; - expect(cloud.x).toBe(240); - expect(cloud.y).toBe(100); - // The flow's last point now attaches to that cloud at the same position. - const outFlow = result.elements.find((e) => e.uid === 3) as FlowViewElement; - const lastPt = outFlow.points[outFlow.points.length - 1]; - expect(lastPt.attachedToUid).toBe(cloud.uid); - expect(lastPt.x).toBe(240); - expect(lastPt.y).toBe(100); - }); - - it('(b) creation to empty space places the cloud at fauxTargetCenter', () => { - // Source stock at (0,100); faux target center at (280,100), on the same - // horizontal axis as the source point so the flow stays straight. With a - // zero cursor delta the realized sink cloud lands exactly at the faux - // center, and the flow's last point attaches to it there. A regression - // that ignored fauxTargetCenter (e.g. placing the cloud at the source) - // would put x at 20 instead of 280. - const srcStock = makeStockEl(1, 'src', 0, 100); - const flow = makeFlowEl(inCreationUid, 'new_flow', 150, 100, [ - { x: 20, y: 100, attachedToUid: 1 }, - { x: 280, y: 100, attachedToUid: fauxCloudTargetUid }, - ]); - const view = makeView([srcStock], 5); - const variables = varsOf(makeStockVar('src')); - - const result = computeFlowAttachment( - view, - variables, - params({ flow, targetUid: 0, fauxTargetCenter: { x: 280, y: 100 }, inCreation: true }), - ); - - const cloud = result.elements.find((e) => e.type === 'cloud') as CloudViewElement; - expect(cloud.x).toBe(280); - expect(cloud.y).toBe(100); - // The realized flow's last point attaches to that cloud, at the cloud. - const realFlow = result.elements.find((e) => e.type === 'flow') as FlowViewElement; - const lastPt = realFlow.points[realFlow.points.length - 1]; - expect(lastPt.attachedToUid).toBe(cloud.uid); - expect(lastPt.x).toBe(280); - expect(lastPt.y).toBe(100); - }); - - it('(c) cloud -> stock reattach reroutes the moved endpoint via UpdateCloudAndFlow', () => { - // Flow source attached to a cloud at (0,100); reattach to a stock whose - // center is (0,300). moveDelta = oldCloud - stock = (0-0, 100-300) = - // (0,-200). The 200px perpendicular move converts the straight flow into - // an L-shape: the source endpoint lands on the stock center (0,300), a - // corner is introduced at (180,300), and the fixed sink stays at - // (180,100). A sign error in the moveDelta math would move the endpoint - // the wrong direction and break the asserted geometry. - const oldCloud = makeCloudEl(1, 3, 0, 100); - const newSrc = makeStockEl(4, 'new_src', 0, 300); - const sinkStock = makeStockEl(2, 'sink', 200, 100); - const flow = makeFlowEl(3, 'f', 100, 100, [ - { x: 0, y: 100, attachedToUid: 1 }, - { x: 180, y: 100, attachedToUid: 2 }, - ]); - const view = makeView([oldCloud, newSrc, sinkStock, flow], 5); - const variables = varsOf(makeStockVar('new_src')); - - const result = computeFlowAttachment(view, variables, params({ flow, targetUid: 4, isSourceAttach: true })); - - const outFlow = result.elements.find((e) => e.uid === 3) as FlowViewElement; - expect(outFlow.points.map((p) => ({ x: p.x, y: p.y, attachedToUid: p.attachedToUid }))).toEqual([ - { x: 0, y: 300, attachedToUid: 4 }, - { x: 180, y: 300, attachedToUid: undefined }, - { x: 180, y: 100, attachedToUid: 2 }, - ]); - }); - - it('(d) L-shaped (3-point) flow reattach reroutes via UpdateCloudAndFlow', () => { - // 3-point L-shaped flow: source cloud at (0,100) -> corner (0,300) -> - // sink stock at (200,300). Reattach the SOURCE cloud endpoint to a stock - // at (0,500), exercising UpdateCloudAndFlow's multi-segment reroute path. - // The cloud-adjacent vertical segment keeps x=0, so the source endpoint - // slides down to the new stock center (0,500) while the corner (0,300) - // and the fixed sink (180,300) are preserved. - const oldCloud = makeCloudEl(1, 3, 0, 100); - const newSrc = makeStockEl(4, 'new_src', 0, 500); - const sinkStock = makeStockEl(2, 'sink', 200, 300); - const flow = makeFlowEl(3, 'f', 0, 200, [ - { x: 0, y: 100, attachedToUid: 1 }, - { x: 0, y: 300, attachedToUid: undefined }, - { x: 180, y: 300, attachedToUid: 2 }, - ]); - const view = makeView([oldCloud, newSrc, sinkStock, flow], 5); - const variables = varsOf(makeStockVar('new_src')); - - const result = computeFlowAttachment(view, variables, params({ flow, targetUid: 4, isSourceAttach: true })); - - const outFlow = result.elements.find((e) => e.uid === 3) as FlowViewElement; - // Source endpoint now attaches to the new stock; the old cloud is gone. - expect(result.elements.find((e) => e.uid === 1)).toBeUndefined(); - expect(outFlow.points.map((p) => ({ x: p.x, y: p.y, attachedToUid: p.attachedToUid }))).toEqual([ - { x: 0, y: 500, attachedToUid: 4 }, - { x: 0, y: 300, attachedToUid: undefined }, - { x: 180, y: 300, attachedToUid: 2 }, - ]); - }); - }); -}); - -describe('growInCreationFlow (live drag preview)', () => { - // The in-creation flow is degenerate while dragging: both points sit at the - // press point, and the drag is recorded only as moveDelta (= press - cursor). - function degenerateFlow(px: number, py: number): FlowViewElement { - return makeFlowEl(inCreationUid, 'new_flow', px, py, [ - { x: px, y: py, attachedToUid: inCreationCloudUid }, - { x: px, y: py, attachedToUid: fauxCloudTargetUid }, - ]); - } - - it('over empty space: the sink follows the cursor along a horizontal segment', () => { - const flow = degenerateFlow(100, 200); - // cursor 80px to the right of the press point -> moveDelta = press - cursor. - const grown = growInCreationFlow(flow, { x: -80, y: 0 }, undefined); - - expect(grown.points.length).toBe(2); - const src = grown.points[0]; - const sink = grown.points[grown.points.length - 1]; - // source stays at the press point - expect(src.x).toBe(100); - expect(src.y).toBe(200); - // sink reaches the cursor; flow is horizontal (sink shares source y) - expect(sink.x).toBeCloseTo(180); - expect(sink.y).toBe(200); - // the sink still references the faux cursor target during the preview - expect(sink.attachedToUid).toBe(fauxCloudTargetUid); - }); - - it('over empty space: a dominant vertical drag grows a vertical segment', () => { - const flow = degenerateFlow(100, 200); - // cursor 80px below -> dominant y component picks the vertical axis. - const grown = growInCreationFlow(flow, { x: 0, y: -80 }, undefined); - - const src = grown.points[0]; - const sink = grown.points[grown.points.length - 1]; - expect(sink.x).toBe(src.x); // vertical - expect(sink.y).toBeCloseTo(280); - }); - - it('over a stock: the sink snaps to the stock EDGE (not the cursor or center)', () => { - const flow = degenerateFlow(100, 200); - const stock = makeStockEl(1, 'snk', 300, 200); - // moveDelta is irrelevant once a target is hovered; the sink pins to the edge. - const grown = growInCreationFlow(flow, { x: -200, y: 0 }, stock); - - const src = grown.points[0]; - const sink = grown.points[grown.points.length - 1]; - expect(sink.attachedToUid).toBe(1); - expect(sink.x).toBeCloseTo(300 - StockWidth / 2); // left edge, not center (300) - expect(sink.x).not.toBe(300); - expect(sink.y).toBe(src.y); // horizontal - expect(src.x).toBe(100); // source unchanged - }); - - // The degenerate axis-recovery in adjustFlows must work in BOTH directions on - // each axis; a signed comparison there silently left LEFT/UP drags degenerate. - it('over empty space: the sink follows a LEFTWARD cursor (not degenerate)', () => { - const flow = degenerateFlow(100, 200); - // cursor 80px to the LEFT -> moveDelta = press - cursor = +80. - const grown = growInCreationFlow(flow, { x: 80, y: 0 }, undefined); - - const src = grown.points[0]; - const sink = grown.points[grown.points.length - 1]; - expect(sink.x).toBeCloseTo(20); // reached the cursor to the left - expect(sink.x).not.toBe(100); // not stuck at the press point - expect(sink.y).toBe(src.y); // horizontal - }); - - it('over empty space: the sink follows an UPWARD cursor (not degenerate)', () => { - const flow = degenerateFlow(100, 200); - // cursor 80px ABOVE -> moveDelta = press - cursor = +80 in y. - const grown = growInCreationFlow(flow, { x: 0, y: 80 }, undefined); - - const src = grown.points[0]; - const sink = grown.points[grown.points.length - 1]; - expect(sink.x).toBe(src.x); // vertical - expect(sink.y).toBeCloseTo(120); // reached the cursor above - expect(sink.y).not.toBe(200); // not stuck at the press point - }); - - it('over a stock to the LEFT: the sink snaps to the stock RIGHT edge', () => { - const flow = degenerateFlow(100, 200); - const stock = makeStockEl(1, 'snk', 40, 200); // stock to the left of the press - const grown = growInCreationFlow(flow, { x: 0, y: 0 }, stock); - - const sink = grown.points[grown.points.length - 1]; - expect(sink.attachedToUid).toBe(1); - expect(sink.x).toBeCloseTo(40 + StockWidth / 2); // 62.5: right edge, not the center - expect(sink.x).not.toBe(100); // not stuck at the press point - expect(sink.y).toBe(200); - }); - - it('over a stock ABOVE: the sink snaps to the stock BOTTOM edge', () => { - const flow = degenerateFlow(100, 200); - const stock = makeStockEl(1, 'snk', 100, 40); // stock above the press - const grown = growInCreationFlow(flow, { x: 0, y: 0 }, stock); - - const sink = grown.points[grown.points.length - 1]; - expect(sink.attachedToUid).toBe(1); - expect(sink.y).toBeCloseTo(40 + StockHeight / 2); // bottom edge, not the center - expect(sink.y).not.toBe(200); // not stuck at the press point - expect(sink.x).toBe(100); - }); -}); - -describe('growEndpointDrag (existing cloud endpoint drag)', () => { - // A straight, already-attached flow: stock (uid 1) on the left, cloud (uid 2) - // on the right, valve in the middle. This is the geometry the reported bug is - // about -- dragging the cloud along the flow axis previously moved only the - // valve, leaving the path/arrowhead stale. - function stockToCloudFlow(): FlowViewElement { - return makeFlowEl(10, 'f', 200, 200, [ - { x: 122.5, y: 200, attachedToUid: 1 }, // stock right edge (100 + StockWidth/2) - { x: 300, y: 200, attachedToUid: 2 }, // cloud center - ]); - } - // The mirror: cloud (uid 2) on the left is the SOURCE, stock (uid 1) the sink. - function cloudToStockFlow(): FlowViewElement { - return makeFlowEl(10, 'f', 200, 200, [ - { x: 100, y: 200, attachedToUid: 2 }, // cloud center (source) - { x: 277.5, y: 200, attachedToUid: 1 }, // stock left edge (300 - StockWidth/2) - ]); - } - - it('sink cloud, along-axis drag: the sink endpoint follows the cursor (not just the valve)', () => { - const flow = stockToCloudFlow(); - // cursor 50px further right -> moveDelta = press - cursor = -50 in x. - const grown = growEndpointDrag(flow, false, { x: -50, y: 0 }, undefined); - - expect(grown.points.length).toBe(2); - const src = grown.points[0]; - const sink = grown.points[1]; - // sink tracked the cursor; flow stayed horizontal; source fixed on its edge - expect(sink.x).toBeCloseTo(350); - expect(sink.y).toBe(200); - expect(sink.attachedToUid).toBe(2); - expect(src.x).toBeCloseTo(122.5); - expect(src.y).toBe(200); - // the valve stayed between the two ends (no longer stuck lagging behind) - expect(grown.x).toBeGreaterThan(src.x); - expect(grown.x).toBeLessThan(sink.x); - }); - - it('sink cloud, along-axis drag TOWARD the source: the sink still tracks the cursor', () => { - const flow = stockToCloudFlow(); - // cursor 120px to the LEFT -> moveDelta = press - cursor = +120 in x. - const grown = growEndpointDrag(flow, false, { x: 120, y: 0 }, undefined); - - const sink = grown.points[grown.points.length - 1]; - expect(sink.x).toBeCloseTo(180); - expect(sink.x).not.toBeCloseTo(300); // not stuck at the original cloud position - expect(sink.y).toBe(200); - }); - - it('source cloud, along-axis drag: the source endpoint follows the cursor', () => { - const flow = cloudToStockFlow(); - // cursor 50px to the LEFT -> moveDelta = press - cursor = +50 in x. - const grown = growEndpointDrag(flow, true, { x: 50, y: 0 }, undefined); - - const src = grown.points[0]; - const sink = grown.points[grown.points.length - 1]; - expect(src.x).toBeCloseTo(50); // source cloud followed the cursor left - expect(src.x).not.toBeCloseTo(100); // not stuck at the original position - expect(src.y).toBe(200); - expect(sink.x).toBeCloseTo(277.5); // stock endpoint fixed on its edge - expect(sink.attachedToUid).toBe(1); - }); - - it('vertical flow, sink cloud along-axis drag: the sink tracks the cursor vertically', () => { - const flow = makeFlowEl(10, 'f', 200, 200, [ - { x: 200, y: 122.5, attachedToUid: 1 }, // stock bottom edge - { x: 200, y: 300, attachedToUid: 2 }, // cloud below - ]); - // cursor 40px further down -> moveDelta = press - cursor = -40 in y. - const grown = growEndpointDrag(flow, false, { x: 0, y: -40 }, undefined); - - const sink = grown.points[grown.points.length - 1]; - expect(sink.y).toBeCloseTo(340); - expect(sink.x).toBe(200); // stayed vertical - }); - - it('sink cloud, perpendicular drag: bends into an L and the cloud moves perpendicular', () => { - const flow = stockToCloudFlow(); - // cursor 60px DOWN -> moveDelta = press - cursor = -60 in y (perpendicular). - const grown = growEndpointDrag(flow, false, { x: 0, y: -60 }, undefined); - - expect(grown.points.length).toBe(3); - const sink = grown.points[grown.points.length - 1]; - expect(sink.y).toBeCloseTo(260); // cloud descended with the drag - expect(sink.attachedToUid).toBe(2); - }); - - it('sink cloud dragged onto a stock: the sink pins to the stock EDGE (matches the commit)', () => { - const flow = stockToCloudFlow(); - const target = makeStockEl(3, 'snk', 500, 200); - const grown = growEndpointDrag(flow, false, { x: -200, y: 0 }, target); - - const sink = grown.points[grown.points.length - 1]; - expect(sink.attachedToUid).toBe(3); - expect(sink.x).toBeCloseTo(500 - StockWidth / 2); // left edge, not the center - expect(sink.x).not.toBe(500); - expect(sink.y).toBe(200); // horizontal - }); - - it('source cloud dragged onto a stock: the source pins to the stock EDGE', () => { - const flow = cloudToStockFlow(); - const target = makeStockEl(3, 'src', 0, 200); // stock to the left - const grown = growEndpointDrag(flow, true, { x: 100, y: 0 }, target); - - const src = grown.points[0]; - expect(src.attachedToUid).toBe(3); - expect(src.x).toBeCloseTo(0 + StockWidth / 2); // right edge of the left-hand stock - expect(src.x).not.toBe(0); - expect(src.y).toBe(200); - }); - - it('grabbing a source cloud (zero delta) leaves an OFF-CENTER valve unmoved (issue #832)', () => { - // Source cloud (uid 2) on the left, stock sink edge on the right, valve - // deliberately OFF-CENTER (x=160, nearer the source). The instant the source - // cloud is grabbed -- before any real movement -- adjustFlows must not reflect - // the valve across the segment midpoint. Regression for the first-frame jump. - const flow = makeFlowEl(10, 'f', 160, 200, [ - { x: 100, y: 200, attachedToUid: 2 }, // source cloud (min coord) - { x: 277.5, y: 200, attachedToUid: 1 }, // stock sink edge (max coord) - ]); - const grown = growEndpointDrag(flow, true, { x: 0, y: 0 }, undefined); - - expect(grown.points.length).toBe(2); - expect(grown.x).toBeCloseTo(160, 5); // unchanged, not reflected to ~217.5 - expect(grown.y).toBe(200); - }); - - // Regression for issue #53: "when moving a cloud, the flow label jumps around". - // The live drag re-routes from the ORIGINAL flow each pointermove with an - // accumulating moveDelta (that is what Canvas does -- r.elements holds the - // committed flow and moveDelta = press - cursor). As a perpendicular drag - // deepens the L, the valve (rendered at flow.x/y, the label anchors to it) - // must move CONTINUOUSLY with the cursor and never teleport onto the vertical - // riser flush against the stock. Sweeping the drag as small steps and - // measuring per-step valve displacement catches the segment flip-flop. - describe('perpendicular cloud drag keeps the valve continuous (issue #53)', () => { - // Index of the segment the valve currently sits on (closest to flow.x/y). - function valveSegmentIndex(flow: FlowViewElement): number { - const segs = getSegments(flow.points); - return findClosestSegment({ x: flow.x, y: flow.y }, segs).index; - } - - // Assert per-step valve continuity across a swept drag. Consecutive frames - // with the SAME point count (same route topology) must move the valve only a - // little -- the teleport bug jumped it ~100px between two L frames. The ONE - // step where the point count changes (straight <-> L) is the "single - // unavoidable relocation" the requirements exempt; it must still be a - // sensible interior hop, not a jump to the stock corner. The measured - // topology hop is ~8px for both sink and source clouds (default bound leaves - // margin); `topologyBound` stays overridable for any future looser case. - function expectContinuous(frames: FlowViewElement[], topologyBound = 15): void { - let sawTopologyChange = false; - for (let i = 1; i < frames.length; i++) { - const prev = frames[i - 1]; - const cur = frames[i]; - const disp = Math.hypot(cur.x - prev.x, cur.y - prev.y); - if (cur.points.length !== prev.points.length) { - expect(sawTopologyChange).toBe(false); // exactly one transition - sawTopologyChange = true; - expect(disp).toBeLessThan(topologyBound); - } else { - expect(disp).toBeLessThan(12); - } - } - } - - // Sweep a perpendicular drag in small cursor steps, re-routing from the - // ORIGINAL flow each step (the live-drag contract), and return the sequence - // of grown flows. `fromTotal` defaults to 0 (start straight and deepen); - // pass a nonzero `fromTotal` with `total` = 0 to REVERSE the drag -- start - // from a formed L and collapse it back to straight. - function sweep( - original: FlowViewElement, - isSource: boolean, - perAxis: 'x' | 'y', - total: number, - step: number, - fromTotal = 0, - ): FlowViewElement[] { - const out: FlowViewElement[] = []; - const sign = Math.sign(total - fromTotal) || 1; - for (let d = fromTotal; sign > 0 ? d <= total : d >= total; d += sign * step) { - const moveDelta = perAxis === 'y' ? { x: 0, y: d } : { x: d, y: 0 }; - out.push(growEndpointDrag(original, isSource, moveDelta, undefined)); - } - return out; - } - - it('sink cloud dragged straight down: valve tracks the cursor, no teleport to the stock corner', () => { - // stock edge at x=122.5, cloud sink at x=300, valve mid at x=200, y=200. - const flow = stockToCloudFlow(); - // moveDelta.y negative drags the cloud DOWN. Sweep to a deep offset so we - // pass the point where the buggy "closest segment" flips onto the riser. - const frames = sweep(flow, false, 'y', -200, 4); - - // Continuity: every same-topology step moves the valve only a little. The - // bug jumped the valve ~100px (onto the riser near the stock) at one step. - expectContinuous(frames); - - // No segment flip-flop: after the L forms the valve stays on one segment. - const lFrames = frames.filter((f) => f.points.length === 3); - const firstL = valveSegmentIndex(lFrames[0]); - for (const f of lFrames) { - expect(valveSegmentIndex(f)).toBe(firstL); - } - - // Final valve is interior to the cloud-adjacent (bottom, horizontal) - // segment -- tracking the cursor -- not pinned at the stock corner. - const finalFlow = frames[frames.length - 1]; - const finalSink = finalFlow.points[finalFlow.points.length - 1]; - expect(finalFlow.points.length).toBe(3); - // valve sits on the horizontal bottom segment (same Y as the descended sink) - expect(finalFlow.y).toBeCloseTo(finalSink.y, 5); - // ...and well away from the stock edge (x=122.5), roughly under its old x. - expect(finalFlow.x).toBeGreaterThan(150); - expect(finalFlow.x).toBeLessThan(finalSink.x); - }); - - it('source cloud dragged straight down: same continuity, valve never pins to the stock', () => { - // cloud source at x=100, stock sink edge at x=277.5, valve mid at x=200. - const flow = cloudToStockFlow(); - const frames = sweep(flow, true, 'y', -200, 4); - - // Default (15px) topology-hop bound: with the source-cloud valve reflection - // fixed (#832), the straight->L hop is ~8px here, same as the sink case. - expectContinuous(frames); - - const finalFlow = frames[frames.length - 1]; - const finalSource = finalFlow.points[0]; - expect(finalFlow.points.length).toBe(3); - // valve on the cloud-adjacent (bottom, horizontal) segment, tracking the cloud - expect(finalFlow.y).toBeCloseTo(finalSource.y, 5); - expect(finalFlow.x).toBeGreaterThan(finalSource.x); - expect(finalFlow.x).toBeLessThan(250); - }); - - it('vertical flow, sink cloud dragged sideways: valve stays interior on the bent segment', () => { - const flow = makeFlowEl(10, 'f', 200, 200, [ - { x: 200, y: 122.5, attachedToUid: 1 }, // stock bottom edge - { x: 200, y: 300, attachedToUid: 2 }, // cloud below - ]); - // Drag cloud to the RIGHT (perpendicular to the vertical flow). - const frames = sweep(flow, false, 'x', -200, 4); - - expectContinuous(frames); - - const finalFlow = frames[frames.length - 1]; - const finalSink = finalFlow.points[finalFlow.points.length - 1]; - expect(finalFlow.points.length).toBe(3); - // valve on the cloud-adjacent (bent, vertical) segment: same X as the sink, - // interior between the corner and the cloud (not flush at the corner). - expect(finalFlow.x).toBeCloseTo(finalSink.x, 5); - expect(finalFlow.y).toBeGreaterThan(150); - expect(finalFlow.y).toBeLessThan(finalSink.y); - }); - - it('reverse drag: an L collapsed back to straight keeps the valve continuous', () => { - // The user drags a sink cloud DOWN to form the L, then drags back UP so the - // L collapses to straight. Because the live drag re-routes from the ORIGINAL - // flow each frame, this is moveDelta.y sweeping from -200 back to 0. The - // valve must stay continuous the whole way and end interior on the restored - // straight segment -- no teleport as the riser shrinks to zero. - const flow = stockToCloudFlow(); - const frames = sweep(flow, false, 'y', 0, 4, -200); - - // Same continuity guarantees as the forward drag: tight within a topology, - // one bounded straight<->L hop (~8px for a sink cloud). - expectContinuous(frames); - - // While the L exists the valve stays on a single segment (no flip-flop). - const lFrames = frames.filter((f) => f.points.length === 3); - const firstL = valveSegmentIndex(lFrames[0]); - for (const f of lFrames) { - expect(valveSegmentIndex(f)).toBe(firstL); - } - - // The drag ended back at zero offset: the flow is straight again and the - // valve sits interior between the stock edge and the cloud, not pinned. - const finalFlow = frames[frames.length - 1]; - expect(finalFlow.points.length).toBe(2); - const src = finalFlow.points[0]; - const sink = finalFlow.points[1]; - expect(finalFlow.x).toBeGreaterThan(src.x); - expect(finalFlow.x).toBeLessThan(sink.x); - expect(finalFlow.y).toBeCloseTo(200, 5); - }); - }); - - it('diagonal drag: along-axis travel is preserved when the flow bends into an L (no cloud snap-back)', () => { - // The cloud has already been dragged 100px ALONG the axis; now the drag pushes - // perpendicular past the point where it dominates and the flow bends. The - // parallel (x) coordinate of the cloud must stay put across the bend -- the bug - // rebuilt the L from the ORIGINAL flow and discarded the 100px of along-axis - // travel, snapping the cloud back ~100px in one frame. (The perpendicular - // coordinate legitimately appears only at the bend: a straight flow is pinned - // to its axis until it reroutes, so only the PARALLEL axis is asserted here.) - const flow = stockToCloudFlow(); // stock edge x=122.5, cloud at (300,200) - const PARALLEL = -100; // cursor 100px right -> cloud x target 400 - const frames: FlowViewElement[] = []; - for (let dy = 0; dy >= -200; dy -= 8) { - frames.push(growEndpointDrag(flow, false, { x: PARALLEL, y: dy }, undefined)); - } - - // Parallel-axis (x) continuity of the cloud endpoint across the bend: the - // buggy reroute jumped it 100px in a single frame. - for (let i = 1; i < frames.length; i++) { - const prevCloud = frames[i - 1].points[frames[i - 1].points.length - 1]; - const curCloud = frames[i].points[frames[i].points.length - 1]; - expect(Math.abs(curCloud.x - prevCloud.x)).toBeLessThan(20); - } - - // End state: bent into an L with the cloud at the FULL dragged position. - const finalFlow = frames[frames.length - 1]; - expect(finalFlow.points.length).toBe(3); - const finalCloud = finalFlow.points[finalFlow.points.length - 1]; - expect(finalCloud.x).toBeCloseTo(400, 5); // 300 + 100 along-axis, not snapped to 300 - expect(finalCloud.y).toBeCloseTo(400, 5); // 200 + 200 perpendicular - }); -}); diff --git a/src/diagram/tests/flow-geometry-fuzz.test.ts b/src/diagram/tests/flow-geometry-fuzz.test.ts new file mode 100644 index 000000000..0bfb2822d --- /dev/null +++ b/src/diagram/tests/flow-geometry-fuzz.test.ts @@ -0,0 +1,482 @@ +// Copyright 2026 The Simlin Authors. All rights reserved. +// Use of this source code is governed by the Apache License, +// Version 2.0, that can be found in the LICENSE file. + +// Seeded property tests of flow-geometry.ts over generated scenes. +// +// Each seed builds a strict scene (genScene) and drives every operation a +// planner will call through simulated drags: successive pointer positions +// from the press, each frame evaluated against the gesture's base view. +// Operations and their terminals are derived the way production supplies them +// (flowTerminals over the view; a moved stock keeps its base attachment through +// stockTerminal): +// moveStock routeEnd on every flow attached to a dragged stock +// dragCloud routeEnd with a cloud endpoint following the pointer +// detach routeEnd with a stock endpoint dragged into empty space +// reattach routeEnd onto every other stock in the view +// create route from a stock to the pointer +// offset offsetSegment on a random segment, swept perpendicular +// slide slideValve, swept by pointer delta +// translate both terminals moved together +// Imported scenes (genImportedScene) are healed first, then dragged. +// +// Asserted on every frame: the strict invariants on the routed flow (frames +// with the pointer inside a stock are the planner's target hover and are +// skipped); E4 locality (the result names only this flow and its own clouds, +// and changes no field but the geometry). Asserted across frames: E3 +// continuity. slideValve and translate never jump. For the routing operations +// a jump (a frame-to-frame change more than three pointer steps plus 1px) must +// coincide with a change of route shape (segment directions or a stock face), +// which is where the plan's documented transitions happen, except for a small +// residue of same-shape switches where a preserved tail's interior corner moves +// to another candidate because the one it held became infeasible; that residue +// is bounded and reported, not zero. +// +// What this does not establish: behavior after an engine round trip (E4's +// epsilon arm), or anything about the planner's target hit testing. + +import { describe, it, expect } from '@rstest/core'; + +import { performance } from 'node:perf_hooks'; + +import type { + CloudViewElement, + FlowViewElement, + StockFlowView, + StockViewElement, + UID, + ViewElement, +} from '@simlin/core/datamodel'; + +import { StockHeight, StockWidth } from '../drawing/default'; +import { + faceOfEndpoint, + flowTerminals, + freeTerminal, + heal, + offsetSegment, + route, + routeEnd, + segmentHold, + slideValve, + stockTerminal, + translate, + type FlowGeometry, + type XY, +} from '../flow-geometry'; +import { checkFlowInvariants, formatFlowViolations } from './support/flow-invariants'; +import { byUidOf, directions, hausdorff, patchView } from './support/flow-geometry-fixtures'; +import { genImportedScene, genScene, Rng } from './support/scene-generator'; + +const SEEDS = 120; +const FRAMES = 24; + +const OPS = [ + 'moveStock', + 'dragCloud', + 'detach', + 'reattach', + 'create', + 'offset', + 'slide', + 'translate', + 'imported', +] as const; +type Op = (typeof OPS)[number]; + +interface Tally { + frames: number; + violations: string[]; + locality: string[]; + jumps: number; + shapeJumps: number; + sameShapeJumps: string[]; + maxJump: number; +} + +function newTally(): Tally { + return { frames: 0, violations: [], locality: [], jumps: 0, shapeJumps: 0, sameShapeJumps: [], maxJump: 0 }; +} + +function insideAnyStock(p: XY, view: StockFlowView): boolean { + return view.elements.some( + (e) => e.type === 'stock' && Math.abs(p.x - e.x) < StockWidth / 2 && Math.abs(p.y - e.y) < StockHeight / 2, + ); +} + +/** The route's shape: segment directions and the faces its stock endpoints use. */ +function shapeOf(f: FlowViewElement, view: StockFlowView): string { + const byUid = byUidOf(view); + const n = f.points.length; + const face = (i: number, j: number): string => { + const el = byUid.get(f.points[i].attachedToUid ?? -1); + return el?.type === 'stock' ? (faceOfEndpoint(el, f.points[i], f.points[j]) ?? '?') : '-'; + }; + return `${face(0, 1)} ${directions(f.points)} ${face(n - 1, n - 2)}`; +} + +function checkFrame(tally: Tally, view: StockFlowView, routed: readonly UID[], context: string): void { + tally.frames++; + const violations = checkFlowInvariants(view, { mode: 'strict', routed: new Set(routed) }).filter((v) => + routed.includes(v.uid), + ); + if (violations.length > 0 && tally.violations.length < 5) { + tally.violations.push(`${context}\n${formatFlowViolations(violations)}`); + } +} + +function checkLocality( + tally: Tally, + base: FlowViewElement, + g: FlowGeometry, + allowedClouds: readonly UID[], + context: string, +): void { + const problems: string[] = []; + const identity = (f: FlowViewElement): string => JSON.stringify({ ...f, x: 0, y: 0, points: [] }); + if (identity(g.flow) !== identity(base)) problems.push('non-geometry flow fields changed'); + for (const c of g.clouds) { + if (!allowedClouds.includes(c.uid) || c.flowUid !== base.uid) + problems.push(`cloud ${c.uid} is not this flow's terminal`); + } + if (problems.length > 0 && tally.locality.length < 5) { + tally.locality.push(`${context}: ${problems.join('; ')}`); + } +} + +function continuity( + tally: Tally, + prev: { flow: FlowViewElement; shape: string } | undefined, + cur: FlowViewElement, + shape: string, + step: number, + context: string, +): void { + if (prev === undefined) return; + const d = Math.max(hausdorff(prev.flow.points, cur.points, 2), Math.hypot(prev.flow.x - cur.x, prev.flow.y - cur.y)); + if (d <= 3 * step + 1) return; + tally.jumps++; + tally.maxJump = Math.max(tally.maxJump, d); + if (prev.shape !== shape) { + tally.shapeJumps++; + } else if (tally.sameShapeJumps.length < 1000) { + tally.sameShapeJumps.push(`${context} d=${d.toFixed(2)} ${shape}`); + } +} + +function attachedFlows(view: StockFlowView, stockUid: UID): FlowViewElement[] { + return view.elements.filter( + (e): e is FlowViewElement => + e.type === 'flow' && + e.points.length >= 2 && + (e.points[0].attachedToUid === stockUid || e.points[e.points.length - 1].attachedToUid === stockUid), + ); +} + +function randomDelta(rng: Rng): XY { + const angle = rng.float(0, 2 * Math.PI); + const magnitude = rng.float(5, 250); + return { x: magnitude * Math.cos(angle), y: magnitude * Math.sin(angle) }; +} + +describe('flow-geometry over generated scenes', () => { + const tallies = new Map(OPS.map((op) => [op, newTally()])); + let routeEndCalls = 0; + let routeEndMs = 0; + let routeCalls = 0; + let routeMs = 0; + + const moveStock = (view: StockFlowView, stock: StockViewElement, delta: XY, tally: Tally, context: string): void => { + const byUid = byUidOf(view); + const flows = attachedFlows(view, stock.uid); + const prev = new Map(); + const step = Math.hypot(delta.x, delta.y) / FRAMES; + for (let k = 0; k <= FRAMES; k++) { + const moved = { ...stock, x: stock.x + (delta.x * k) / FRAMES, y: stock.y + (delta.y * k) / FRAMES }; + const changed: ViewElement[] = [moved]; + const routed: UID[] = []; + for (const f of flows) { + const n = f.points.length; + const t = flowTerminals(f, byUid); + const sourceOn = f.points[0].attachedToUid === stock.uid; + const sinkOn = f.points[n - 1].attachedToUid === stock.uid; + let g: FlowGeometry; + if (sourceOn && sinkOn) { + g = { flow: translate(f, { x: moved.x - stock.x, y: moved.y - stock.y }), clouds: [] }; + } else { + const end = sourceOn ? 'source' : 'sink'; + const endpoint = sourceOn ? f.points[0] : f.points[n - 1]; + const adjacent = sourceOn ? f.points[1] : f.points[n - 2]; + const started = performance.now(); + g = routeEnd(f, end, stockTerminal(moved, endpoint, adjacent, stock), { + fixed: sourceOn ? t.sink : t.source, + }); + routeEndMs += performance.now() - started; + routeEndCalls++; + } + checkLocality(tally, f, g, [], `${context} k ${k} flow ${f.uid}`); + changed.push(g.flow, ...g.clouds); + routed.push(f.uid); + } + const view2 = patchView(view, changed); + checkFrame(tally, view2, routed, `${context} k ${k}`); + for (const uid of routed) { + const f = view2.elements.find((e) => e.uid === uid) as FlowViewElement; + const shape = shapeOf(f, view2); + continuity(tally, prev.get(uid), f, shape, step, `${context} flow ${uid} k ${k}`); + prev.set(uid, { flow: f, shape }); + } + } + }; + + for (let seed = 1; seed <= SEEDS; seed++) { + const scene = genScene(new Rng(seed)); + const rng = new Rng(seed * 7919 + 1); + const view = scene.view; + const byUid = byUidOf(view); + const stocks = view.elements.filter((e): e is StockViewElement => e.type === 'stock'); + const flows = view.elements.filter((e): e is FlowViewElement => e.type === 'flow'); + + // moveStock + moveStock(view, rng.pick(stocks), randomDelta(rng), tallies.get('moveStock')!, `seed ${seed}`); + + // dragCloud / detach: an endpoint follows the pointer + for (const op of ['dragCloud', 'detach'] as const) { + const tally = tallies.get(op)!; + const candidates: Array<{ f: FlowViewElement; end: 'source' | 'sink' }> = []; + for (const f of flows) { + const t = flowTerminals(f, byUid); + const want = op === 'dragCloud' ? 'free' : 'stock'; + if (t.source.kind === want) candidates.push({ f, end: 'source' }); + if (t.sink.kind === want) candidates.push({ f, end: 'sink' }); + } + if (candidates.length === 0) continue; + const { f, end } = rng.pick(candidates); + const t = flowTerminals(f, byUid); + const endpoint = end === 'source' ? f.points[0] : f.points[f.points.length - 1]; + const own = end === 'source' ? t.source : t.sink; + const cloud: CloudViewElement = + own.kind === 'free' && own.cloud !== undefined + ? own.cloud + : { + type: 'cloud', + uid: view.nextUid, + flowUid: f.uid, + x: endpoint.x, + y: endpoint.y, + isZeroRadius: false, + ident: undefined, + }; + const delta = randomDelta(rng); + const step = Math.hypot(delta.x, delta.y) / FRAMES; + let prev: { flow: FlowViewElement; shape: string } | undefined; + for (let k = 1; k <= FRAMES; k++) { + const p = { x: endpoint.x + (delta.x * k) / FRAMES, y: endpoint.y + (delta.y * k) / FRAMES }; + if (insideAnyStock(p, view)) { + prev = undefined; + continue; + } + const g = routeEnd(f, end, freeTerminal(p, cloud), { fixed: end === 'source' ? t.sink : t.source }); + checkLocality(tally, f, g, [cloud.uid], `seed ${seed} k ${k}`); + // A detached end's new cloud is added by the planner; the core reports it moved onto the endpoint. + const withCloud = patchView(view, [{ ...cloud, x: p.x, y: p.y }, g.flow, ...g.clouds]); + checkFrame(tally, withCloud, [f.uid], `seed ${seed} ${op} flow ${f.uid} k ${k}`); + const shape = shapeOf(g.flow, withCloud); + continuity(tally, prev, g.flow, shape, step, `seed ${seed} flow ${f.uid} k ${k}`); + prev = { flow: g.flow, shape }; + } + } + + // reattach: a cloud end onto every other stock + { + const tally = tallies.get('reattach')!; + for (const f of flows) { + const t = flowTerminals(f, byUid); + for (const end of ['source', 'sink'] as const) { + const own = end === 'source' ? t.source : t.sink; + const fixed = end === 'source' ? t.sink : t.source; + if (own.kind !== 'free' || own.cloud === undefined) continue; + for (const s of stocks) { + if (fixed.kind === 'stock' && fixed.stock.uid === s.uid) continue; + const g = routeEnd(f, end, stockTerminal(s), { fixed }); + checkLocality(tally, f, g, [], `seed ${seed} flow ${f.uid} ${end} -> S${s.uid}`); + checkFrame( + tally, + patchView(view, [g.flow], [own.cloud.uid]), + [f.uid], + `seed ${seed} flow ${f.uid} ${end} -> S${s.uid}`, + ); + } + } + } + } + + // create: from a stock to the pointer + { + const tally = tallies.get('create')!; + const s = rng.pick(stocks); + const delta = randomDelta(rng); + const draft: FlowViewElement = { ...flows[0], uid: view.nextUid + 1, points: [], x: s.x, y: s.y }; + const sinkCloud: CloudViewElement = { + type: 'cloud', + uid: view.nextUid + 2, + flowUid: draft.uid, + x: s.x, + y: s.y, + isZeroRadius: false, + ident: undefined, + }; + const step = Math.hypot(delta.x, delta.y) / FRAMES; + let prev: { flow: FlowViewElement; shape: string } | undefined; + for (let k = 1; k <= FRAMES; k++) { + const p = { x: s.x + (delta.x * k) / FRAMES, y: s.y + (delta.y * k) / FRAMES }; + if (insideAnyStock(p, view)) { + prev = undefined; + continue; + } + const started = performance.now(); + const g = route(stockTerminal(s), freeTerminal(p, sinkCloud), { flow: draft }); + routeMs += performance.now() - started; + routeCalls++; + const view2 = patchView(view, [{ ...sinkCloud, x: p.x, y: p.y }, g.flow, ...g.clouds]); + checkFrame(tally, view2, [draft.uid], `seed ${seed} create k ${k}`); + const shape = shapeOf(g.flow, view2); + continuity(tally, prev, g.flow, shape, step, `seed ${seed} create k ${k}`); + prev = { flow: g.flow, shape }; + } + } + + // offset + { + const tally = tallies.get('offset')!; + const f = rng.pick(flows); + const i = rng.int(0, f.points.length - 2); + const { hold } = segmentHold(f.points, i); + const amount = rng.float(-150, 150); + const t = flowTerminals(f, byUid); + const allowed = [t.source, t.sink].flatMap((term) => + term.kind === 'free' && term.cloud !== undefined ? [term.cloud.uid] : [], + ); + const step = Math.abs(amount) / FRAMES; + let prev: { flow: FlowViewElement; shape: string } | undefined; + for (let k = 0; k <= FRAMES; k++) { + const g = offsetSegment(f, i, hold + (amount * k) / FRAMES, t, { stocks }); + checkLocality(tally, f, g, allowed, `seed ${seed} seg ${i} k ${k}`); + const view2 = patchView(view, [g.flow, ...g.clouds]); + checkFrame(tally, view2, [f.uid], `seed ${seed} offset flow ${f.uid} seg ${i} k ${k}`); + const shape = shapeOf(g.flow, view2); + continuity(tally, prev, g.flow, shape, step, `seed ${seed} flow ${f.uid} seg ${i} k ${k}`); + prev = { flow: g.flow, shape }; + } + } + + // slide + { + const tally = tallies.get('slide')!; + const f = rng.pick(flows); + const delta = randomDelta(rng); + const step = Math.hypot(delta.x, delta.y) / FRAMES; + let prev: { flow: FlowViewElement; shape: string } | undefined; + for (let k = 0; k <= FRAMES; k++) { + const nf = slideValve(f, { x: (delta.x * k) / FRAMES, y: (delta.y * k) / FRAMES }); + const view2 = patchView(view, [nf]); + checkFrame(tally, view2, [f.uid], `seed ${seed} slide flow ${f.uid} k ${k}`); + continuity(tally, prev, nf, 'same', step, `seed ${seed} flow ${f.uid} k ${k}`); + prev = { flow: nf, shape: 'same' }; + } + } + + // translate: a flow with both terminals selected (stocks and clouds moved together) + { + const tally = tallies.get('translate')!; + const f = rng.pick(flows); + const delta = randomDelta(rng); + const t = flowTerminals(f, byUid); + const step = Math.hypot(delta.x, delta.y) / FRAMES; + let prev: { flow: FlowViewElement; shape: string } | undefined; + for (let k = 0; k <= FRAMES; k++) { + const d = { x: (delta.x * k) / FRAMES, y: (delta.y * k) / FRAMES }; + const moved: ViewElement[] = []; + let cloudOnStock = false; + for (const term of [t.source, t.sink]) { + if (term.kind === 'stock') { + moved.push({ ...term.stock, x: term.stock.x + d.x, y: term.stock.y + d.y }); + } else if (term.cloud !== undefined) { + const c = { ...term.cloud, x: term.cloud.x + d.x, y: term.cloud.y + d.y }; + cloudOnStock ||= insideAnyStock(c, view); + moved.push(c); + } + } + const nf = translate(f, d); + // Dropping a selection's cloud onto another stock is the user's move (the + // planner hit-tests it); translate itself changes no shape, so those frames + // are continuity frames only. + if (!cloudOnStock) { + checkFrame(tally, patchView(view, [...moved, nf]), [f.uid], `seed ${seed} translate flow ${f.uid} k ${k}`); + } + continuity(tally, prev, nf, 'same', step, `seed ${seed} flow ${f.uid} k ${k}`); + prev = { flow: nf, shape: 'same' }; + } + } + + // imported: heal every flow, then drag a stock on the healed view + { + const tally = tallies.get('imported')!; + const imported = genImportedScene(new Rng(seed)); + let healedView = imported.view; + const changed: ViewElement[] = []; + const routed: UID[] = []; + for (const el of imported.view.elements) { + if (el.type !== 'flow') continue; + const g = heal(el, flowTerminals(el, byUidOf(imported.view))); + changed.push(g.flow, ...g.clouds); + if (!imported.mutations.some((m) => m.flowUid === el.uid && m.shape === 'unattachedFlow')) { + routed.push(el.uid); + } + } + healedView = patchView(imported.view, changed); + checkFrame(tally, healedView, routed, `seed ${seed} healed`); + const istocks = healedView.elements.filter((e): e is StockViewElement => e.type === 'stock'); + moveStock(healedView, rng.pick(istocks), randomDelta(rng), tally, `seed ${seed} imported`); + } + } + + for (const op of OPS) { + it(`${op}: strict invariants on every routed frame, and E4 locality`, () => { + const tally = tallies.get(op)!; + expect(tally.frames).toBeGreaterThan(SEEDS); + expect(tally.violations.join('\n\n')).toBe(''); + expect(tally.locality.join('\n')).toBe(''); + }); + } + + for (const op of ['slide', 'translate'] as const) { + it(`${op}: E3 continuity with no discrete transitions at all`, () => { + const tally = tallies.get(op)!; + expect(`${tally.jumps} jumps; ${tally.sameShapeJumps.slice(0, 5).join('\n')}`).toBe(`0 jumps; `); + }); + } + + // The same-shape residue, pinned per op at the measured count. It is where a + // feasibility change keeps the route's shape but moves a hold: an offset riser + // pushed out at MIN_SEGMENT, a tail whose held corner stopped being feasible. + // The continuity constraints with real budgets are the 1px sweeps + // (flow-geometry-sweeps.test.ts); this guards the seeded scenes from new classes. + const SAME_SHAPE_BUDGET = { moveStock: 0, dragCloud: 0, detach: 1, create: 0, offset: 2, imported: 2 } as const; + for (const op of ['moveStock', 'dragCloud', 'detach', 'create', 'offset', 'imported'] as const) { + it(`${op}: E3 continuity: jumps are shape transitions, with at most ${SAME_SHAPE_BUDGET[op]} same-shape jumps`, () => { + const tally = tallies.get(op)!; + const report = `${op}: ${tally.sameShapeJumps.length} same-shape jumps over ${tally.frames} frames\n${tally.sameShapeJumps.slice(0, 5).join('\n')}`; + expect(tally.sameShapeJumps.length <= SAME_SHAPE_BUDGET[op] ? 'within budget' : report).toBe('within budget'); + }); + } + + it('route and routeEnd stay well under a millisecond per call (measured over every frame above)', () => { + // A generous bound on the mean so a loaded machine does not flake; typical + // calls take tens of microseconds. + expect(routeEndCalls).toBeGreaterThan(1000); + expect(routeCalls).toBeGreaterThan(1000); + expect(`routeEnd ${(routeEndMs / routeEndCalls).toFixed(4)}ms`).toMatch(/^routeEnd 0\./); + expect(`route ${(routeMs / routeCalls).toFixed(4)}ms`).toMatch(/^route 0\./); + }); +}); diff --git a/src/diagram/tests/flow-geometry-heal.test.ts b/src/diagram/tests/flow-geometry-heal.test.ts new file mode 100644 index 000000000..3f525bf56 --- /dev/null +++ b/src/diagram/tests/flow-geometry-heal.test.ts @@ -0,0 +1,446 @@ +// Copyright 2026 The Simlin Authors. All rights reserved. +// Use of this source code is governed by the Apache License, +// Version 2.0, that can be found in the LICENSE file. + +// Tests of `heal` (flow-geometry/heal.ts). +// +// Rows are derived from the generator's IMPORT_SHAPES: every corpus shape is +// found across seeds and healed through the production terminals; each shape +// states what heal repairs and what it cannot (an unattached flow has nothing to +// attach to; a flow listed in two stocks is a model-list shape with no geometry). +// Every shape is also healed twice (idempotence), and every flow of strict scenes +// is healed to itself (identity on a valid flow). Separate rows pin the corner +// tie-break, the stocks context (G6's cloud clause), degenerate input, and a +// seeded sweep of junk flows. + +import { describe, it, expect } from '@rstest/core'; + +import type { FlowViewElement, Point, StockFlowView } from '@simlin/core/datamodel'; + +import { + faceOfEndpoint, + flowFault, + flowTerminals, + freeTerminal, + heal, + offsetSegment, + route, + routeEnd, + slideValve, + stockTerminal, +} from '../flow-geometry'; +import { checkFlowInvariants } from './support/flow-invariants'; +import { + applyGeometry, + byUidOf, + cloudJson, + cloudOf, + flowJson, + flowOf, + fmtFlow, + loadView, + stockJson, + stockOf, + strictReport, +} from './support/flow-geometry-fixtures'; +import { genImportedScene, genScene, IMPORT_SHAPES, Rng, type ImportShape } from './support/scene-generator'; + +const P = (x: number, y: number, attachedToUid?: number): Point => ({ x, y, attachedToUid }); +const CORNER_CLEARANCE = 3; + +interface ShapeExpectation { + /** Strict arms still reported on the healed flow, one per occurrence. */ + readonly remaining: readonly string[]; + /** Whether heal returns the input unchanged. */ + readonly identity: boolean; + readonly why: string; +} + +const EXPECTATIONS: Readonly> = { + offFaceAxis: { remaining: [], identity: false, why: 'the endpoint is re-pinned to the nearest valid face point' }, + cornerEndpoint: { + remaining: [], + identity: false, + why: 'the endpoint moves inside the corner clearance along its face', + }, + cloudOffset: { remaining: [], identity: false, why: 'the cloud moves onto its endpoint' }, + valveOffPath: { remaining: [], identity: false, why: 'the valve is projected onto the path' }, + slightlyDiagonal: { remaining: [], identity: false, why: 'the segment is snapped along its dominant axis' }, + unattachedFlow: { + remaining: ['G1.unattachedEndpoint', 'G1.unattachedEndpoint'], + identity: true, + why: 'no cloud exists to attach to; creating one is the planner`s job', + }, + listedInTwoStocks: { remaining: [], identity: true, why: 'a model-list shape: the geometry is valid' }, +}; + +const SEEDS = 200; + +function stocksOf(view: StockFlowView): Array<{ x: number; y: number }> { + return view.elements.filter((e) => e.type === 'stock'); +} + +describe('heal over every import shape', () => { + for (const shape of IMPORT_SHAPES) { + const want = EXPECTATIONS[shape]; + it(`${shape}: ${want.why}`, () => { + let seen = 0; + for (let seed = 1; seed <= SEEDS; seed++) { + const scene = genImportedScene(new Rng(seed)); + for (const m of scene.mutations.filter((mm) => mm.shape === shape)) { + seen++; + const view = scene.view; + const base = flowOf(view, m.flowUid); + const t = flowTerminals(base, byUidOf(view)); + const g = heal(base, t, { stocks: stocksOf(view) }); + const healed = applyGeometry(view, g); + const context = `seed ${seed} flow ${m.flowUid}\nbase ${fmtFlow(base)}\nhealed ${fmtFlow(g.flow)}`; + const arms = checkFlowInvariants(healed, { mode: 'strict', routed: new Set([m.flowUid]) }) + .filter((v) => v.uid === m.flowUid) + .map((v) => v.arm) + .sort(); + expect(`${context}\n${arms.join(' ')}`).toBe(`${context}\n${[...want.remaining].sort().join(' ')}`); + expect(`${context}\nidentity ${g.flow === base && g.clouds.length === 0}`).toBe( + `${context}\nidentity ${want.identity}`, + ); + const again = heal(g.flow, flowTerminals(g.flow, byUidOf(healed)), { stocks: stocksOf(healed) }); + expect(`${context}\n${again.flow === g.flow} ${again.clouds.length}`).toBe(`${context}\ntrue 0`); + assertShapeRepair(shape, base, g, view, context); + } + } + expect(`${shape} applied ${seen > 0}`).toBe(`${shape} applied true`); + }); + } + + it('is the identity on every flow of strict scenes', () => { + for (let seed = 1; seed <= 60; seed++) { + const { view } = genScene(new Rng(seed)); + for (const el of view.elements) { + if (el.type !== 'flow') continue; + const g = heal(el, flowTerminals(el, byUidOf(view)), { stocks: stocksOf(view) }); + expect(`seed ${seed} flow ${el.uid}: ${g.flow === el} ${g.clouds.length}`).toBe( + `seed ${seed} flow ${el.uid}: true 0`, + ); + } + } + }); +}); + +function assertShapeRepair( + shape: ImportShape, + base: FlowViewElement, + g: { readonly flow: FlowViewElement; readonly clouds: readonly { uid: number; x: number; y: number }[] }, + view: StockFlowView, + context: string, +): void { + const healed = g.flow; + const n = base.points.length; + const byUid = byUidOf(view); + const stockEnd = [0, n - 1].find((i) => byUid.get(base.points[i].attachedToUid ?? -1)?.type === 'stock'); + switch (shape) { + case 'offFaceAxis': { + // The endpoint moved back along its stub onto the face: the along-face coordinate is kept. + const i = [0, n - 1].find((index) => { + const el = byUid.get(base.points[index].attachedToUid ?? -1); + return el?.type === 'stock' && faceOfEndpoint(el, base.points[index]) === undefined; + })!; + const moved = healed.points[i === 0 ? 0 : healed.points.length - 1]; + const before = base.points[i]; + expect(`${context}\n${Math.abs(moved.x - before.x) <= 1e-9 || Math.abs(moved.y - before.y) <= 1e-9}`).toBe( + `${context}\ntrue`, + ); + break; + } + case 'cornerEndpoint': { + // The endpoint slides CORNER_CLEARANCE along the face its stub leaves + // perpendicular to; the straight pipe and its cloud follow, so it stays straight. + const i = stockEnd!; + const stock = byUid.get(base.points[i].attachedToUid!)!; + const adjacent = base.points[i === 0 ? 1 : n - 2]; + const face = faceOfEndpoint(stock, base.points[i], adjacent); + const moved = healed.points[i === 0 ? 0 : healed.points.length - 1]; + const shift = Math.hypot(moved.x - base.points[i].x, moved.y - base.points[i].y); + expect( + `${context}\n${healed.points.length} ${shift.toFixed(9)} ${faceOfEndpoint(stock, moved, healed.points[i === 0 ? 1 : healed.points.length - 2])}`, + ).toBe(`${context}\n2 ${CORNER_CLEARANCE.toFixed(9)} ${face}`); + expect(g.clouds.length).toBe(1); + break; + } + case 'cloudOffset': { + // The pipe keeps its geometry; the cloud comes to the endpoint. + expect(`${context}\n${JSON.stringify(healed.points)}`).toBe(`${context}\n${JSON.stringify(base.points)}`); + expect(g.clouds.length).toBe(1); + const cloud = g.clouds[0]; + const end = healed.points.find((p) => p.attachedToUid === cloud.uid)!; + expect([cloud.x, cloud.y]).toEqual([end.x, end.y]); + break; + } + case 'valveOffPath': + expect(`${context}\n${JSON.stringify(healed.points)}`).toBe(`${context}\n${JSON.stringify(base.points)}`); + break; + case 'slightlyDiagonal': + // The source endpoint is kept; the sink (a cloud) snaps onto the source's axis. + expect([healed.points[0].x, healed.points[0].y]).toEqual([base.points[0].x, base.points[0].y]); + expect(healed.points.length).toBe(2); + break; + case 'unattachedFlow': + case 'listedInTwoStocks': + break; + } +} + +describe('heal: a stock endpoint on a corner', () => { + // Stock S at the origin: corners (+-22.5, +-17.5), valid range x in [-19.5, 19.5] and y in [-14.5, 14.5]. + const ROWS: ReadonlyArray<{ + readonly name: string; + readonly endpoint: [number, number]; + readonly cloud: [number, number]; + readonly want: number[][]; + }> = [ + { + name: 'bottom-right corner, stub down: along the bottom face', + endpoint: [22.5, 17.5], + cloud: [22.5, 80], + want: [ + [19.5, 17.5], + [19.5, 80], + ], + }, + { + name: 'bottom-right corner, stub right: along the right face', + endpoint: [22.5, 17.5], + cloud: [100, 17.5], + want: [ + [22.5, 14.5], + [100, 14.5], + ], + }, + { + name: 'top-left corner, stub up: along the top face', + endpoint: [-22.5, -17.5], + cloud: [-22.5, -80], + want: [ + [-19.5, -17.5], + [-19.5, -80], + ], + }, + { + name: 'top-left corner, stub left: along the left face', + endpoint: [-22.5, -17.5], + cloud: [-100, -17.5], + want: [ + [-22.5, -14.5], + [-100, -14.5], + ], + }, + ]; + for (const row of ROWS) { + it(row.name, () => { + const view = loadView([ + stockJson(1, 0, 0), + cloudJson(3, 10, row.cloud[0], row.cloud[1]), + flowJson( + 10, + { x: (row.endpoint[0] + row.cloud[0]) / 2, y: (row.endpoint[1] + row.cloud[1]) / 2 }, + [ + { x: row.endpoint[0], y: row.endpoint[1] }, + { x: row.cloud[0], y: row.cloud[1] }, + ], + { source: 1, sink: 3 }, + ), + ]); + const f = flowOf(view, 10); + const g = heal(f, flowTerminals(f, byUidOf(view))); + expect(g.flow.points.map((p) => [p.x, p.y])).toEqual(row.want); + expect(g.clouds.map((c) => [c.x, c.y])).toEqual([row.want[1]]); + expect(strictReport(applyGeometry(view, g), [10])).toBe(''); + }); + } +}); + +describe('heal: the stocks context (G6`s cloud clause)', () => { + // A valid stock -> cloud flow whose cloud sits inside a second, unrelated stock. + const view = loadView([ + stockJson(1, 0, 0), + stockJson(2, 310, 0), + cloudJson(3, 10, 300, 0), + flowJson( + 10, + { x: 160, y: 0 }, + [ + { x: 22.5, y: 0 }, + { x: 300, y: 0 }, + ], + { source: 1, sink: 3 }, + ), + ]); + const f = flowOf(view, 10); + const t = flowTerminals(f, byUidOf(view)); + + it('without stocks the flow is healthy and left alone', () => { + const g = heal(f, t); + expect([g.flow === f, g.clouds.length]).toEqual([true, 0]); + }); + + it('with stocks the cloud moves along its segment to the nearer edge of the stock it sits in', () => { + const stocks = [stockOf(view, 1), stockOf(view, 2)]; + expect(flowFault(f, t, stocks)).toBe('crossing'); + const g = heal(f, t, { stocks }); + expect(g.flow.points.map((p) => [p.x, p.y])).toEqual([ + [22.5, 0], + [287.5, 0], + ]); + expect(g.clouds).toEqual([{ ...cloudOf(view, 3), x: 287.5, y: 0 }]); + const healed = applyGeometry(view, g); + expect(flowFault(g.flow, flowTerminals(g.flow, byUidOf(healed)), stocks)).toBe('none'); + expect(strictReport(healed, [10])).toBe(''); + }); + + it('a source cloud inside a stock moves along its segment too', () => { + // The mirror image: cloud -> stock, the cloud inside a stock left of the flow. + const mirror = loadView([ + stockJson(1, 0, 0), + stockJson(2, -310, 0), + cloudJson(3, 10, -300, 0), + flowJson( + 10, + { x: -160, y: 0 }, + [ + { x: -300, y: 0 }, + { x: -22.5, y: 0 }, + ], + { source: 3, sink: 1 }, + ), + ]); + const mf = flowOf(mirror, 10); + const mt = flowTerminals(mf, byUidOf(mirror)); + const stocks = [stockOf(mirror, 1), stockOf(mirror, 2)]; + expect(flowFault(mf, mt, stocks)).toBe('crossing'); + const g = heal(mf, mt, { stocks }); + expect(g.flow.points.map((p) => [p.x, p.y])).toEqual([ + [-287.5, 0], + [-22.5, 0], + ]); + expect(g.clouds).toEqual([{ ...cloudOf(mirror, 3), x: -287.5, y: 0 }]); + const healed = applyGeometry(mirror, g); + expect(flowFault(g.flow, flowTerminals(g.flow, byUidOf(healed)), stocks)).toBe('none'); + expect(strictReport(healed, [10])).toBe(''); + }); + + it('over seeded junk flows, heal with stocks leaves every flow flowFault(stocks) accepts, and is idempotent', () => { + const rng = new Rng(20260911); + let checked = 0; + for (let i = 0; i < 400; i++) { + const sx = rng.float(-40, 40); + const other = { x: rng.float(-250, 250), y: rng.float(-250, 250) }; + const count = rng.int(2, 5); + const pts: Array<{ x: number; y: number }> = [{ x: 22.5, y: rng.float(-25, 25) }]; + for (let k = 1; k < count; k++) pts.push({ x: rng.float(-200, 200), y: rng.float(-200, 200) }); + const cloud = pts[count - 1]; + const junk = loadView([ + stockJson(1, sx, 0), + stockJson(2, other.x, other.y), + cloudJson(3, 10, cloud.x + rng.float(-5, 5), cloud.y + rng.float(-5, 5)), + flowJson(10, { x: rng.float(-100, 100), y: rng.float(-100, 100) }, pts, { source: 1, sink: 3 }), + ]); + const jf = flowOf(junk, 10); + const stocks = stocksOf(junk); + const g = heal(jf, flowTerminals(jf, byUidOf(junk)), { stocks }); + const healed = applyGeometry(junk, g); + const ht = flowTerminals(g.flow, byUidOf(healed)); + const again = heal(g.flow, ht, { stocks }); + expect(`junk ${i} ${fmtFlow(g.flow)}: ${again.flow === g.flow} ${again.clouds.length}`).toBe( + `junk ${i} ${fmtFlow(g.flow)}: true 0`, + ); + if (flowFault(g.flow, ht, stocks) === 'none') checked++; + } + // Most junk heals to a valid flow; the rest are overlapping-body scenes where G6 is best effort. + expect(checked).toBeGreaterThan(300); + }); +}); + +describe('heal on degenerate input', () => { + const view = loadView([ + stockJson(1, 100, 100), + cloudJson(3, 10, 300, 100), + flowJson( + 10, + { x: 200, y: 100 }, + [ + { x: 122.5, y: 100 }, + { x: 300, y: 100 }, + ], + { source: 1, sink: 3 }, + ), + ]); + const good = flowOf(view, 10); + const t = flowTerminals(good, byUidOf(view)); + + // #818: a non-finite coordinate only exists in memory (the loader repairs + // NaN), so these rows mutate a loaded flow. + const DEGENERATE: ReadonlyArray<{ readonly name: string; readonly flow: FlowViewElement }> = [ + { name: 'NaN valve (#818)', flow: { ...good, x: NaN } }, + { name: 'NaN interior point (#818)', flow: { ...good, points: [good.points[0], P(NaN, 5), good.points[1]] } }, + { name: 'Infinity endpoint (#818)', flow: { ...good, points: [P(Infinity, 100, 1), good.points[1]] } }, + { name: 'a single point', flow: { ...good, points: [good.points[0]] } }, + { name: 'no points', flow: { ...good, points: [] } }, + ]; + for (const row of DEGENERATE) { + it(`${row.name}: routes afresh to finite, valid geometry`, () => { + const g = heal(row.flow, t); + const all = [g.flow.x, g.flow.y, ...g.flow.points.flatMap((p) => [p.x, p.y])]; + expect(all.every(Number.isFinite)).toBe(true); + expect(strictReport(applyGeometry(view, g), [10])).toBe(''); + }); + } + + it('a non-finite terminal (a NaN pointer) returns the flow unchanged from every operation', () => { + const nan = freeTerminal({ x: NaN, y: 0 }, cloudOf(view, 3)); + const moved = { ...stockOf(view, 1), x: Infinity }; + for (const g of [ + heal(good, { source: t.source, sink: nan }), + routeEnd(good, 'sink', nan, { fixed: t.source }), + routeEnd(good, 'source', stockTerminal(moved, good.points[0], good.points[1], stockOf(view, 1)), { + fixed: t.sink, + }), + route(t.source, nan, { flow: good }), + offsetSegment(good, 0, 120, { source: t.source, sink: nan }), + offsetSegment(good, 0, NaN, t), + ]) { + expect([g.flow === good, g.clouds.length]).toEqual([true, 0]); + } + }); + + it('does not throw on a self-loop flow, and returns finite geometry (#720)', () => { + const loop = loadView([ + stockJson(1, 100, 100), + flowJson( + 10, + { x: 150, y: 100 }, + [ + { x: 122.5, y: 100 }, + { x: 200, y: 100 }, + { x: 200, y: 82.5 }, + { x: 100, y: 82.5 }, + ], + { source: 1, sink: 1 }, + ), + ]); + const f = flowOf(loop, 10); + const lt = flowTerminals(f, byUidOf(loop)); + const results = [ + heal(f, lt).flow, + routeEnd(f, 'sink', lt.sink, { fixed: lt.source }).flow, + route(lt.source, lt.sink, { flow: f }).flow, + offsetSegment(f, 1, 250, lt).flow, + slideValve(f, { x: 30, y: 0 }), + ]; + for (const r of results) { + expect(r.points.length).toBeGreaterThanOrEqual(2); + expect([r.x, r.y, ...r.points.flatMap((p) => [p.x, p.y])].every(Number.isFinite)).toBe(true); + // Tolerant mode accepts the input and every result (#720 is strict-only). + const v = { ...loop, elements: loop.elements.map((e) => (e.uid === 10 ? r : e)) }; + expect(checkFlowInvariants(v, { mode: 'tolerant' })).toEqual([]); + } + }); +}); diff --git a/src/diagram/tests/flow-geometry-offset.test.ts b/src/diagram/tests/flow-geometry-offset.test.ts new file mode 100644 index 000000000..ccdf83afd --- /dev/null +++ b/src/diagram/tests/flow-geometry-offset.test.ts @@ -0,0 +1,643 @@ +// Copyright 2026 The Simlin Authors. All rights reserved. +// Use of this source code is governed by the Apache License, +// Version 2.0, that can be found in the LICENSE file. + +// Tests of `offsetSegment` (flow-geometry/offset-segment.ts). +// +// The main table is the cross product of: path length (2, 3, 4, 6 points), the +// dragged segment's position (first, every interior index, last), the source +// and sink terminal kinds (stock or cloud each), the face the path leaves +// through (all four, so both hold axes are exercised), and the drag direction. +// A 2-point path has one segment, which is both first and last; a 3-point path +// has no interior segment. Every row drags out 40px, then back to the base +// coordinate on the committed result, three times over, and asserts: strict +// invariants after each gesture; the dragged segment holds the coordinate the +// plan determines (the request, or the documented clamp or collapse); stock +// endpoints stay on their base faces and clouds follow their endpoints; the +// valve keeps its coordinate along its own segment while that segment survives +// (an independent oracle below); and, unless the out gesture collapsed a corner, +// dragging back restores the base exactly, so stubs never accumulate. +// +// Separate rows pin the face-extent rule on a straight flow between aligned +// stocks (slide within the faces, hold at the extent within MIN_SEGMENT beyond +// it, a bracket past that), the joint resolution of two adjacent risers, the +// nearest valid side of an obstacle, and the valve when its segment is removed. + +import { describe, it, expect } from '@rstest/core'; + +import type { JsonViewElement } from '@simlin/engine'; +import type { FlowViewElement, Point, StockFlowView } from '@simlin/core/datamodel'; + +import { StockHeight, StockWidth } from '../drawing/default'; +import { faceOfEndpoint, FACES, flowTerminals, offsetSegment, segmentHold, type Face } from '../flow-geometry'; +import { + applyGeometry, + arcOf, + byUidOf, + cloudJson, + directions, + flowJson, + flowOf, + fmtFlow, + loadView, + pathLength, + stockJson, + hausdorff, + stockOf, + strictReport, + type Pt, +} from './support/flow-geometry-fixtures'; + +const MIN_SEGMENT = 10; +const MIN_SINK_SEGMENT = 15.5; +const VALVE_CLAMP_MARGIN = 10; +const EPS = 1e-6; + +const FLOW = 10; +const SOURCE_STOCK = 1; +const SINK_STOCK = 2; +const SOURCE_CLOUD = 11; +const SINK_CLOUD = 12; +const ORIGIN: Pt = { x: 400, y: 400 }; + +// Canonical paths leave the source rightward from (0,0). Stubs and risers are +// longer than the minima, so no base tail is a re-solvable stub + riser. +const PATHS: Readonly> = { + 2: [ + { x: 0, y: 0 }, + { x: 200, y: 0 }, + ], + 3: [ + { x: 0, y: 0 }, + { x: 100, y: 0 }, + { x: 100, y: 120 }, + ], + 4: [ + { x: 0, y: 0 }, + { x: 80, y: 0 }, + { x: 80, y: 70 }, + { x: 200, y: 70 }, + ], + 6: [ + { x: 0, y: 0 }, + { x: 40, y: 0 }, + { x: 40, y: -60 }, + { x: 160, y: -60 }, + { x: 160, y: 0 }, + { x: 200, y: 0 }, + ], +}; +const LENGTHS = [2, 3, 4, 6] as const; +const KINDS = ['stock', 'cloud'] as const; +type Kind = (typeof KINDS)[number]; +const DIRECTIONS = [1, -1] as const; +const OUT = 40; + +function rotate(p: Pt, face: Face): Pt { + switch (face) { + case 'right': + return { x: p.x, y: p.y }; + case 'bottom': + return { x: -p.y, y: p.x }; + case 'left': + return { x: -p.x, y: -p.y }; + case 'top': + return { x: p.y, y: -p.x }; + } +} + +function build(length: number, face: Face, sourceKind: Kind, sinkKind: Kind): StockFlowView { + const pts = PATHS[length].map((p) => { + const r = rotate(p, face); + return { x: ORIGIN.x + r.x, y: ORIGIN.y + r.y }; + }); + const n = pts.length; + const elements: JsonViewElement[] = []; + const out0 = rotate({ x: 1, y: 0 }, face); + if (sourceKind === 'stock') { + elements.push(stockJson(SOURCE_STOCK, pts[0].x - out0.x * (StockWidth / 2), pts[0].y - out0.y * (StockHeight / 2))); + } else { + elements.push(cloudJson(SOURCE_CLOUD, FLOW, pts[0].x, pts[0].y)); + } + const dx = Math.sign(pts[n - 1].x - pts[n - 2].x); + const dy = Math.sign(pts[n - 1].y - pts[n - 2].y); + if (sinkKind === 'stock') { + elements.push(stockJson(SINK_STOCK, pts[n - 1].x + dx * (StockWidth / 2), pts[n - 1].y + dy * (StockHeight / 2))); + } else { + elements.push(cloudJson(SINK_CLOUD, FLOW, pts[n - 1].x, pts[n - 1].y)); + } + const valve = pointAt(pts, pathLength(pts) * 0.3); + elements.push( + flowJson(FLOW, valve, pts, { + source: sourceKind === 'stock' ? SOURCE_STOCK : SOURCE_CLOUD, + sink: sinkKind === 'stock' ? SINK_STOCK : SINK_CLOUD, + }), + ); + return loadView(elements); +} + +function pointAt(pts: readonly Pt[], s: number): Pt { + let remaining = s; + for (let i = 0; i < pts.length - 1; i++) { + const length = Math.hypot(pts[i + 1].x - pts[i].x, pts[i + 1].y - pts[i].y); + if (remaining <= length) { + const t = remaining / length; + return { x: pts[i].x + (pts[i + 1].x - pts[i].x) * t, y: pts[i].y + (pts[i + 1].y - pts[i].y) * t }; + } + remaining -= length; + } + return pts[pts.length - 1]; +} + +const axisOf = (a: Pt, b: Pt): 'x' | 'y' => (Math.abs(a.y - b.y) <= EPS ? 'x' : 'y'); +const holdOf = (a: Pt, b: Pt): number => (axisOf(a, b) === 'x' ? a.y : a.x); + +/** The segment of `pts` on `axis` holding `hold` whose span covers `along`. */ +function findSegment(pts: readonly Pt[], axis: 'x' | 'y', hold: number, along: number): number { + for (let i = 0; i < pts.length - 1; i++) { + const a = pts[i]; + const b = pts[i + 1]; + if (axisOf(a, b) !== axis || Math.abs(holdOf(a, b) - hold) > EPS) continue; + const lo = Math.min(axis === 'x' ? a.x : a.y, axis === 'x' ? b.x : b.y); + const hi = Math.max(axis === 'x' ? a.x : a.y, axis === 'x' ? b.x : b.y); + if (along >= lo - EPS && along <= hi + EPS) return i; + } + return -1; +} + +interface Row { + readonly name: string; + readonly length: number; + readonly index: number; + readonly position: 'first' | 'interior' | 'last'; + readonly face: Face; + readonly sourceKind: Kind; + readonly sinkKind: Kind; + readonly direction: 1 | -1; +} + +const ROWS: Row[] = LENGTHS.flatMap((length) => { + const segments = length - 1; + const indexes: Array<[number, Row['position']]> = [[0, 'first']]; + for (let i = 1; i < segments - 1; i++) indexes.push([i, 'interior']); + if (segments > 1) indexes.push([segments - 1, 'last']); + return indexes.flatMap(([index, position]) => + FACES.flatMap((face) => + KINDS.flatMap((sourceKind) => + KINDS.flatMap((sinkKind) => + DIRECTIONS.map((direction) => ({ + name: `${length} points, ${position} segment ${index}, leaving ${face}, ${sourceKind} -> ${sinkKind}, ${direction > 0 ? '+' : '-'}`, + length, + index, + position, + face, + sourceKind, + sinkKind, + direction, + })), + ), + ), + ), + ); +}); + +/** + * The hold the plan determines for the out gesture, in canonical coordinates + * (before rotation): the request, unless an adjacent stock stub would shrink + * under its minimum (clamped there) or an adjacent cloud segment would shrink + * to zero (the corner collapses at the cloud's coordinate). + */ +function expectedOutHold(row: Row): { hold: number; collapses: boolean } { + const canon = PATHS[row.length]; + const i = row.index; + const baseHold = holdOf(canon[i], canon[i + 1]); + const requested = baseHold + row.direction * OUT; + const last = canon.length - 2; + if (i === 1 && last > 1) { + // The adjacent first segment leaves the source at canonical x = 0. + if (row.sourceKind === 'stock') { + return { hold: Math.max(requested, MIN_SEGMENT), collapses: false }; + } + return { hold: requested, collapses: Math.abs(requested) <= EPS }; + } + if (i === last - 1 && i > 0) { + const sinkCoordinate = canon[canon.length - 1].x; + const sign = Math.sign(canon[canon.length - 1].x - canon[canon.length - 2].x); + if (row.sinkKind === 'stock') { + const bound = sinkCoordinate - sign * MIN_SINK_SEGMENT; + return { hold: sign > 0 ? Math.min(requested, bound) : Math.max(requested, bound), collapses: false }; + } + return { hold: requested, collapses: Math.abs(requested - sinkCoordinate) <= EPS }; + } + return { hold: requested, collapses: false }; +} + +/** + * The valve's expected arc position after an offset (an independent oracle for + * the plan's rule): the valve keeps its coordinate along its own segment while a + * segment on the same axis holding the same coordinate (the dragged segment's new + * hold, when the valve rode it) overlaps that segment's span, clamped into the new + * span; with its segment gone it goes to the nearest point of the new path. The + * margin is applied last. + */ +function valveExpectation(base: FlowViewElement, i: number, result: FlowViewElement, newHold: number): number { + const pts = base.points; + const s0 = arcOf(pts, base); + let start = 0; + let j = 0; + for (; j < pts.length - 2; j++) { + const length = Math.hypot(pts[j + 1].x - pts[j].x, pts[j + 1].y - pts[j].y); + if (s0 <= start + length) break; + start += length; + } + const axis = axisOf(pts[j], pts[j + 1]); + const hold = j === i ? newHold : holdOf(pts[j], pts[j + 1]); + const along = axis === 'x' ? base.x : base.y; + const at = (p: Pt): number => (axis === 'x' ? p.x : p.y); + const spanLo = Math.min(at(pts[j]), at(pts[j + 1])); + const spanHi = Math.max(at(pts[j]), at(pts[j + 1])); + const out = result.points; + let target: Pt = { x: base.x, y: base.y }; + let bestGap = Infinity; + for (let k = 0; k < out.length - 1; k++) { + if (axisOf(out[k], out[k + 1]) !== axis || Math.abs(holdOf(out[k], out[k + 1]) - hold) > EPS) continue; + const lo = Math.min(at(out[k]), at(out[k + 1])); + const hi = Math.max(at(out[k]), at(out[k + 1])); + if (Math.min(hi, spanHi) - Math.max(lo, spanLo) < -EPS) continue; + const v = Math.max(lo, Math.min(hi, along)); + if (Math.abs(v - along) < bestGap) { + bestGap = Math.abs(v - along); + target = axis === 'x' ? { x: v, y: hold } : { x: hold, y: v }; + } + } + const length = pathLength(out); + const s = arcOf(out, target); + return length < 2 * VALVE_CLAMP_MARGIN + ? length / 2 + : Math.max(VALVE_CLAMP_MARGIN, Math.min(length - VALVE_CLAMP_MARGIN, s)); +} + +describe('offsetSegment over length x position x face x terminal kinds x direction, dragged out and back', () => { + it('covers the full cross product', () => { + // Positions: 1 (2 points) + 2 (3 points) + 3 (4 points) + 5 (6 points). + expect(ROWS.length).toBe(11 * FACES.length * 4 * 2); + }); + + for (const row of ROWS) { + it(row.name, () => { + const view0 = build(row.length, row.face, row.sourceKind, row.sinkKind); + const base = flowOf(view0, FLOW); + const { axis, hold: baseHold } = segmentHold(base.points, row.index); + + let view = view0; + let flow = base; + let index = row.index; + const baseAlong = + axis === 'x' + ? (base.points[row.index].x + base.points[row.index + 1].x) / 2 + : (base.points[row.index].y + base.points[row.index + 1].y) / 2; + const expected = expectedOutHold(row); + const maxPoints = base.points.length + 4; + + for (let cycle = 0; cycle < 3; cycle++) { + // Out: the hold moves `direction * OUT` in canonical terms, rotated with the path. + const canonicalHold = + holdOf(PATHS[row.length][row.index], PATHS[row.length][row.index + 1]) + row.direction * OUT; + const outHold = realHold(axis, row.face, canonicalHold); + const terminals = flowTerminals(flow, byUidOf(view)); + const out = offsetSegment(flow, index, outHold, terminals, {}); + view = applyGeometry(view, out); + const context = `${row.name} cycle ${cycle} out\nbase ${fmtFlow(flow)}\nresult ${fmtFlow(out.flow)}`; + expect(`${context}\n${strictReport(view, [FLOW])}`).toBe(`${context}\n`); + expect(out.flow.points.length).toBeLessThanOrEqual(maxPoints); + + const wantHold = realHold(axis, row.face, expected.hold); + const newIndex = findSegment(out.flow.points, axis, wantHold, baseAlong); + expect(`${context}\nsegment holding ${wantHold}: ${newIndex >= 0}`).toBe( + `${context}\nsegment holding ${wantHold}: true`, + ); + + assertTerminals(view0, out.flow, row, context); + if (cycle === 0) { + const got = arcOf(out.flow.points, out.flow); + const want = valveExpectation(flow, index, out.flow, wantHold); + expect(`${context}\nvalve arc ${got.toFixed(6)}`).toBe(`${context}\nvalve arc ${want.toFixed(6)}`); + } + + // Back to the base coordinate. + const back = offsetSegment(out.flow, newIndex, baseHold, flowTerminals(out.flow, byUidOf(view)), {}); + view = applyGeometry(view, back); + const backContext = `${row.name} cycle ${cycle} back\nfrom ${fmtFlow(out.flow)}\nresult ${fmtFlow(back.flow)}`; + expect(`${backContext}\n${strictReport(view, [FLOW])}`).toBe(`${backContext}\n`); + assertTerminals(view0, back.flow, row, backContext); + if (!expected.collapses) { + expect(`${backContext}\n${JSON.stringify(back.flow.points.map((p) => [p.x, p.y]))}`).toBe( + `${backContext}\n${JSON.stringify(base.points.map((p) => [p.x, p.y]))}`, + ); + flow = back.flow; + index = findSegment(back.flow.points, axis, baseHold, baseAlong); + } else { + // The out gesture removed a corner, so there is nothing to restore; + // repeated cycles still must not grow the path. + flow = back.flow; + index = findSegment(back.flow.points, axis, baseHold, baseAlong); + if (index < 0) break; + } + } + }); + } +}); + +/** + * The real hold coordinate for a canonical hold. A canonical segment is + * horizontal (holding y) exactly when its real axis matches the face's normal + * axis after rotation, so the canonical hold is placed on that canonical axis + * and rotated with the path. + */ +function realHold(axis: 'x' | 'y', face: Face, canonical: number): number { + const faceNormalIsX = face === 'right' || face === 'left'; + const canonicalHorizontal = (axis === 'x') === faceNormalIsX; + const p = rotate(canonicalHorizontal ? { x: 0, y: canonical } : { x: canonical, y: 0 }, face); + return axis === 'x' ? ORIGIN.y + p.y : ORIGIN.x + p.x; +} + +function assertTerminals(view0: StockFlowView, f: FlowViewElement, row: Row, context: string): void { + const n = f.points.length; + const byUid = byUidOf(view0); + if (row.sourceKind === 'stock') { + const stock = byUid.get(SOURCE_STOCK)!; + expect(`${context}\nsource face ${faceOfEndpoint(stock as never, f.points[0], f.points[1])}`).toBe( + `${context}\nsource face ${row.face}`, + ); + } + if (row.sinkKind === 'stock') { + const stock = byUid.get(SINK_STOCK)!; + const baseFlow = flowOf(view0, FLOW); + const bn = baseFlow.points.length; + const baseFace = faceOfEndpoint(stock as never, baseFlow.points[bn - 1], baseFlow.points[bn - 2]); + expect(`${context}\nsink face ${faceOfEndpoint(stock as never, f.points[n - 1], f.points[n - 2])}`).toBe( + `${context}\nsink face ${baseFace}`, + ); + } + expect(f.points[0].attachedToUid).toBe(row.sourceKind === 'stock' ? SOURCE_STOCK : SOURCE_CLOUD); + expect(f.points[n - 1].attachedToUid).toBe(row.sinkKind === 'stock' ? SINK_STOCK : SINK_CLOUD); +} + +describe('the face-extent rule on a straight flow between aligned stocks', () => { + // Stocks at (0,0) and (200,0): the right face of A is x = 22.5, the left face + // of B x = 177.5, and both valid ranges are y in [-14.5, 14.5]. + const view = loadView([ + stockJson(1, 0, 0), + stockJson(2, 200, 0), + flowJson( + FLOW, + { x: 100, y: 0 }, + [ + { x: 22.5, y: 0 }, + { x: 177.5, y: 0 }, + ], + { source: 1, sink: 2 }, + ), + ]); + const base = flowOf(view, FLOW); + const terminals = flowTerminals(base, byUidOf(view)); + + const SWEEP: ReadonlyArray<{ readonly c: number; readonly want: readonly Pt[]; readonly why: string }> = [ + { c: 0, want: base.points, why: 'identity' }, + { + c: 10, + want: [ + { x: 22.5, y: 10 }, + { x: 177.5, y: 10 }, + ], + why: 'slides within both faces', + }, + { + c: 14.5, + want: [ + { x: 22.5, y: 14.5 }, + { x: 177.5, y: 14.5 }, + ], + why: 'at the extent', + }, + { + c: 20, + want: [ + { x: 22.5, y: 14.5 }, + { x: 177.5, y: 14.5 }, + ], + why: 'within MIN_SEGMENT beyond: held at the extent', + }, + { + c: 24.5, + want: [ + { x: 22.5, y: 14.5 }, + { x: 177.5, y: 14.5 }, + ], + why: 'exactly MIN_SEGMENT beyond: still held', + }, + { + c: 30, + want: [ + { x: 22.5, y: 14.5 }, + { x: 22.5 + MIN_SEGMENT, y: 14.5 }, + { x: 22.5 + MIN_SEGMENT, y: 30 }, + { x: 177.5 - MIN_SINK_SEGMENT, y: 30 }, + { x: 177.5 - MIN_SINK_SEGMENT, y: 14.5 }, + { x: 177.5, y: 14.5 }, + ], + why: 'past it: a bracket, stubs at the minima, risers from the extent', + }, + { + c: -30, + want: [ + { x: 22.5, y: -14.5 }, + { x: 22.5 + MIN_SEGMENT, y: -14.5 }, + { x: 22.5 + MIN_SEGMENT, y: -30 }, + { x: 177.5 - MIN_SINK_SEGMENT, y: -30 }, + { x: 177.5 - MIN_SINK_SEGMENT, y: -14.5 }, + { x: 177.5, y: -14.5 }, + ], + why: 'the other side', + }, + ]; + for (const row of SWEEP) { + it(`c = ${row.c}: ${row.why}`, () => { + const g = offsetSegment(base, 0, row.c, terminals); + expect(g.flow.points.map((p: Point) => [p.x, p.y])).toEqual(row.want.map((p) => [p.x, p.y])); + expect(strictReport(applyGeometry(view, g), [FLOW])).toBe(''); + }); + } + + it('dragging the bracket back collapses it, whatever the bracket was built from', () => { + const bracket = offsetSegment(base, 0, 60, terminals); + const run = findSegment(bracket.flow.points, 'x', 60, 100); + for (const c of [0, 5, -14.5]) { + const back = offsetSegment( + bracket.flow, + run, + c, + flowTerminals(bracket.flow, byUidOf(applyGeometry(view, bracket))), + ); + expect(`${c}: ${directions(back.flow.points)}`).toBe(`${c}: R`); + expect(back.flow.points[0].y).toBe(c); + } + }); + + it('a dragged segment next to a stock stub is clamped so the stub keeps MIN_SEGMENT', () => { + // A Z out of A's right face: dragging the riser left into the stub stops at 32.5. + const z = loadView([ + stockJson(1, 0, 0), + stockJson(2, 200, 60), + flowJson( + FLOW, + { x: 100, y: 30 }, + [ + { x: 22.5, y: 0 }, + { x: 100, y: 0 }, + { x: 100, y: 60 }, + { x: 177.5, y: 60 }, + ], + { source: 1, sink: 2 }, + ), + ]); + const f = flowOf(z, FLOW); + const t = flowTerminals(f, byUidOf(z)); + expect(offsetSegment(f, 1, 0, t).flow.points[1].x).toBe(22.5 + MIN_SEGMENT); + expect(offsetSegment(f, 1, 1000, t).flow.points[1].x).toBe(177.5 - MIN_SINK_SEGMENT); + }); + + it('an interior riser shorter than MIN_SEGMENT snaps to zero (collapse) or out to the minimum, whichever is nearer', () => { + // Cloud source at (0,0); an L-then-Z path whose second riser can be dragged onto the first's column. + const v = loadView([ + cloudJson(11, FLOW, 0, 0), + cloudJson(12, FLOW, 300, 100), + flowJson( + FLOW, + { x: 150, y: 50 }, + [ + { x: 0, y: 0 }, + { x: 100, y: 0 }, + { x: 100, y: 50 }, + { x: 200, y: 50 }, + { x: 200, y: 100 }, + { x: 300, y: 100 }, + ], + { source: 11, sink: 12 }, + ), + ]); + const f = flowOf(v, FLOW); + const t = flowTerminals(f, byUidOf(v)); + // Segment 2 holds y = 50 between a riser from y = 0 and one to y = 100. A + // riser under MIN_SEGMENT / 2 collapses (its corner goes, 4 points); one + // between that and MIN_SEGMENT is pushed out to MIN_SEGMENT (6 points). + const ROWS: ReadonlyArray = [ + [4, 0, 4], + [6, 10, 6], + [94, 90, 6], + [96, 100, 4], + ]; + for (const [c, hold, pointCount] of ROWS) { + const g = offsetSegment(f, 2, c, t); + expect(`c ${c}: ${g.flow.points.length}`).toBe(`c ${c}: ${pointCount}`); + expect(strictReport(applyGeometry(v, g), [FLOW])).toBe(''); + const along = findSegment(g.flow.points, 'x', hold, 150); + expect(`c ${c}: holds ${hold} ${along >= 0}`).toBe(`c ${c}: holds ${hold} true`); + } + }); +}); + +describe('offsetSegment: joint constraints, obstacles, and a removed valve segment', () => { + it('two adjacent risers are resolved jointly: the nearest coordinate satisfying both', () => { + // Segment 2 holds y = 50 between a riser from y = 0 and one to y = 15. Asked + // for y = 6: pushing out from the first riser (to 10) would put the second + // at 5, under its minimum; the nearest coordinate satisfying both collapses + // the first riser (y = 0), which also leaves the second 15 long. + const v = loadView([ + cloudJson(11, FLOW, 0, 0), + cloudJson(12, FLOW, 160, 15), + flowJson( + FLOW, + { x: 75, y: 50 }, + [ + { x: 0, y: 0 }, + { x: 50, y: 0 }, + { x: 50, y: 50 }, + { x: 100, y: 50 }, + { x: 100, y: 15 }, + { x: 160, y: 15 }, + ], + { source: 11, sink: 12 }, + ), + ]); + const f = flowOf(v, FLOW); + const g = offsetSegment(f, 2, 6, flowTerminals(f, byUidOf(v))); + expect(g.flow.points.map((p) => [p.x, p.y])).toEqual([ + [0, 0], + [100, 0], + [100, 15], + [160, 15], + ]); + expect(strictReport(applyGeometry(v, g), [FLOW])).toBe(''); + }); + + for (const [request, want, why] of [ + [50, 42.5, 'the near side is nearer: the segment rests at the stock`s top edge'], + [70, 77.5, 'the far side is nearer: the segment jumps across to the bottom edge'], + ] as const) { + it(`a cloud dragged into another stock takes the nearest valid side (${why})`, () => { + // A cloud -> cloud flow whose source cloud sits above a stock (x 77.5..122.5, + // y 42.5..77.5); dragging the segment down moves both clouds with it. + const v = loadView([ + stockJson(1, 100, 60), + cloudJson(11, FLOW, 100, 0), + cloudJson(12, FLOW, 300, 0), + flowJson( + FLOW, + { x: 200, y: 0 }, + [ + { x: 100, y: 0 }, + { x: 300, y: 0 }, + ], + { source: 11, sink: 12 }, + ), + ]); + const f = flowOf(v, FLOW); + const g = offsetSegment(f, 0, request, flowTerminals(f, byUidOf(v)), { stocks: [stockOf(v, 1)] }); + // The valid boundary is GEOMETRY_EPSILON outside the stock edge (a cloud on the edge is not inside). + expect(g.flow.points[0].y).toBeCloseTo(want, 5); + expect(g.clouds.map((c) => c.uid).sort()).toEqual([11, 12]); + expect(strictReport(applyGeometry(v, g), [FLOW])).toBe(''); + }); + } + + it('a bracket dragged back straight moves the valve no further than the path moved', () => { + // The valve rides the bracket's far riser (x = 180). Dragging the run back to + // the stub's hold collapses both risers: the valve's segment is gone, so it + // goes to the nearest point of the straight path instead of keeping an arc + // distance measured past the removed risers. + const v = loadView([ + stockJson(1, 0, 0), + cloudJson(12, FLOW, 260, 0), + flowJson( + FLOW, + { x: 180, y: -20 }, + [ + { x: 22.5, y: 0 }, + { x: 32.5, y: 0 }, + { x: 32.5, y: -40 }, + { x: 180, y: -40 }, + { x: 180, y: 0 }, + { x: 260, y: 0 }, + ], + { source: 1, sink: 12 }, + ), + ]); + const f = flowOf(v, FLOW); + const g = offsetSegment(f, 2, 0, flowTerminals(f, byUidOf(v))); + expect(directions(g.flow.points)).toBe('R'); + const valveJump = Math.hypot(g.flow.x - f.x, g.flow.y - f.y); + expect(valveJump).toBeLessThanOrEqual(hausdorff(f.points, g.flow.points) + 1e-9); + expect([g.flow.x, g.flow.y]).toEqual([180, 0]); + }); +}); diff --git a/src/diagram/tests/flow-geometry-preferences.test.ts b/src/diagram/tests/flow-geometry-preferences.test.ts new file mode 100644 index 000000000..4ba7f41c0 --- /dev/null +++ b/src/diagram/tests/flow-geometry-preferences.test.ts @@ -0,0 +1,177 @@ +// Copyright 2026 The Simlin Authors. All rights reserved. +// Use of this source code is governed by the Apache License, +// Version 2.0, that can be found in the LICENSE file. + +// The routing preferences and tie rules of flow-geometry.ts that no invariant +// forces: the constants (pinned against literals and against the checker's own +// literals, so drift in any of the three is caught), the PIPE_SPACING slot a +// flow newly landing on a face takes, and length ties within GEOMETRY_EPSILON. + +import { describe, it, expect } from '@rstest/core'; + +import type { FlowViewElement } from '@simlin/core/datamodel'; + +import { FlowArrowheadRadius } from '../drawing/default'; +import { + CORNER_CLEARANCE, + flowTerminals, + freeTerminal, + GEOMETRY_EPSILON, + MIN_SEGMENT, + MIN_SINK_SEGMENT, + PIPE_SPACING, + route, + stockTerminal, + VALVE_CLAMP_MARGIN, + type XY, +} from '../flow-geometry'; +import * as checker from './support/flow-invariants'; +import { + applyGeometry, + byUidOf, + flowJson, + flowOf, + fmtFlow, + loadView, + stockJson, + stockOf, + strictReport, +} from './support/flow-geometry-fixtures'; + +describe('constants', () => { + it('match the plan`s units and the checker`s independently pinned literals', () => { + const core = { + CORNER_CLEARANCE, + MIN_SEGMENT, + VALVE_CLAMP_MARGIN, + MIN_SINK_SEGMENT, + PIPE_SPACING, + GEOMETRY_EPSILON, + }; + expect(core).toEqual({ + CORNER_CLEARANCE: 3, + MIN_SEGMENT: 10, + VALVE_CLAMP_MARGIN: 10, + MIN_SINK_SEGMENT: FlowArrowheadRadius + 7.5, + PIPE_SPACING: 10, + GEOMETRY_EPSILON: 1e-6, + }); + expect(core).toEqual({ + CORNER_CLEARANCE: checker.CORNER_CLEARANCE, + MIN_SEGMENT: checker.MIN_SEGMENT, + VALVE_CLAMP_MARGIN: checker.VALVE_CLAMP_MARGIN, + MIN_SINK_SEGMENT: checker.MIN_SINK_SEGMENT, + PIPE_SPACING: checker.PIPE_SPACING, + GEOMETRY_EPSILON: checker.GEOMETRY_EPSILON, + }); + }); +}); + +describe('the PIPE_SPACING slot of a flow newly landing on a face', () => { + // Stock S at the origin; the pointer up and to the right, where the best + // route is an L out of S's right face (valid range y in [-14.5, 14.5]) and the + // endpoint's position along the face is the slot preference. + const view = loadView([stockJson(1, 0, 0)]); + const draft = flowOf(loadView([flowJson(10, { x: 0, y: 0 }, [], {})]), 10); + const S = stockOf(view, 1); + const on = (y: number): XY => ({ x: 22.5, y }); + + const ROWS: ReadonlyArray<{ readonly name: string; readonly occupied: XY[]; readonly want: number }> = [ + { name: 'an empty face: the center', occupied: [], want: 0 }, + { name: 'the center taken: the nearest spaced slot', occupied: [on(0)], want: -10 }, + { name: 'two taken: still the nearest spaced slot', occupied: [on(0), on(10)], want: -10 }, + { + name: 'no spaced slot left: the position farthest from its nearest neighbor', + occupied: [on(-14.5), on(-5), on(5), on(14.5)], + want: 0, + }, + { + name: 'endpoints on other faces do not count', + occupied: [ + { x: 0, y: -17.5 }, + { x: -22.5, y: 0 }, + ], + want: 0, + }, + ]; + for (const row of ROWS) { + it(row.name, () => { + const g = route(stockTerminal(S), freeTerminal({ x: 60, y: -100 }), { flow: draft, occupied: row.occupied }); + expect(`${fmtFlow(g.flow)}`).toBe( + fmtFlow({ + ...g.flow, + points: [ + { x: 22.5, y: row.want, attachedToUid: 1 }, + { x: 60, y: row.want, attachedToUid: undefined }, + { x: 60, y: -100, attachedToUid: undefined }, + ], + }), + ); + }); + } + + it('no spaced slot left, asymmetric: the widest gap, not the center', () => { + // The widest gap's midpoint (nearest neighbor 5.5 away) is not the center + // (nearest neighbor 2 away). The pointer is down and to the right, where the + // right face's L stays the shortest route with the endpoint below the center. + const g = route(stockTerminal(S), freeTerminal({ x: 60, y: 100 }), { + flow: draft, + occupied: [on(-14), on(-6), on(2), on(13)], + }); + expect(g.flow.points.map((p) => [p.x, p.y])).toEqual([ + [22.5, 7.5], + [60, 7.5], + [60, 100], + ]); + }); + + it('a straight route is exempt: its endpoint aligns with the pointer whatever is occupied', () => { + const g = route(stockTerminal(S), freeTerminal({ x: 100, y: 3 }), { flow: draft, occupied: [on(3)] }); + expect(g.flow.points.map((p) => [p.x, p.y])).toEqual([ + [22.5, 3], + [100, 3], + ]); + }); +}); + +describe('length ties within GEOMETRY_EPSILON', () => { + it('a Z riser holds its base corner frame after frame instead of flipping on float noise', () => { + // A Z from stock A's right face into stock B's left face, rerouted with + // route() while B is dragged in non-integer steps. Stickiness keeps both base + // faces (an off-base L is only one bend better), and every Z joining them has + // the same length whatever its riser holds, up to float noise in the summed + // segments; the base corner's hold (x = 100) is the first candidate and must + // win every frame. + const view = loadView([ + stockJson(1, 0, 0), + stockJson(2, 200, 60), + flowJson( + 10, + { x: 60, y: 0 }, + [ + { x: 22.5, y: 0 }, + { x: 100, y: 0 }, + { x: 100, y: 60 }, + { x: 177.5, y: 60 }, + ], + { source: 1, sink: 2 }, + ), + ]); + const f: FlowViewElement = flowOf(view, 10); + const t = flowTerminals(f, byUidOf(view)); + const B = stockOf(view, 2); + const holds: string[] = []; + let zFrames = 0; + for (let k = 1; k <= 96; k++) { + const moved = { ...B, x: 200 + (6.4234917 * k) / 96, y: 60 + (44.8912337 * k) / 96 }; + const g = route(t.source, stockTerminal(moved, f.points[3], f.points[2], B), { flow: f }); + expect(strictReport(applyGeometry(view, g, [moved]), [10])).toBe(''); + if (g.flow.points.length === 4) { + zFrames++; + holds.push(`k ${k}: ${g.flow.points[1].x}`); + } + } + expect(zFrames).toBeGreaterThan(20); + expect(holds.filter((h) => !h.endsWith(': 100'))).toEqual([]); + }); +}); diff --git a/src/diagram/tests/flow-geometry-repros.test.ts b/src/diagram/tests/flow-geometry-repros.test.ts new file mode 100644 index 000000000..eee74c601 --- /dev/null +++ b/src/diagram/tests/flow-geometry-repros.test.ts @@ -0,0 +1,703 @@ +// Copyright 2026 The Simlin Authors. All rights reserved. +// Use of this source code is governed by the Apache License, +// Version 2.0, that can be found in the LICENSE file. + +// The flow-geometry defects the editing audit found, each as a row through the +// core operation the planner will call for that gesture. Scenes and pointer +// positions are the audit's minimal repros; each row asserts the strict +// invariants on the routed flow and the geometry the plan determines. +// +// Repros that are the planner's to fix, not the core's, and so are not rows +// here: C0 (a click commits nothing: E1), R12 and R14 (a drop on the flow's own +// terminal is an invalid target: E6), R13b's preview/commit disagreement (E2, +// one function by construction), and the zero-delta commit identities T8 (E1). +// The core half of R13b (the new sink lands exactly at the pointer) is a row. + +import { describe, it, expect } from '@rstest/core'; + +import type { CloudViewElement, FlowViewElement, StockFlowView } from '@simlin/core/datamodel'; + +import { + faceOfEndpoint, + flowTerminals, + freeTerminal, + offsetSegment, + route, + routeEnd, + slideValve, + stockTerminal, +} from '../flow-geometry'; +import { + applyGeometry, + arcOf, + byUidOf, + cloudJson, + cloudOf, + directions, + flowJson, + flowOf, + fmtFlow, + hausdorff, + loadView, + stockJson, + stockOf, + strictReport, + type Pt, +} from './support/flow-geometry-fixtures'; + +const pts = (g: { flow: FlowViewElement }): number[][] => g.flow.points.map((p) => [p.x, p.y]); + +function clean(view: StockFlowView, flowUid: number, g: { flow: FlowViewElement }): void { + expect(`${fmtFlow(g.flow)}\n${strictReport(view, [flowUid])}`).toBe(`${fmtFlow(g.flow)}\n`); +} + +// S1 at (100,100): right face x = 122.5. +const straightToCloud = (cloudAt: Pt): StockFlowView => + loadView([ + stockJson(1, 100, 100), + cloudJson(3, 10, cloudAt.x, cloudAt.y), + flowJson(10, { x: 200, y: 100 }, [{ x: 122.5, y: 100 }, cloudAt], { source: 1, sink: 3 }), + ]); + +describe('audit lead A / A2: a sink reattached to a column-aligned stock lands on a face, not at its center', () => { + const ROWS: ReadonlyArray<{ + readonly name: string; + readonly cloud: Pt; + readonly target: Pt; + readonly want: number[][]; + }> = [ + { + name: 'A (perpendicular-dominant drag)', + cloud: { x: 300, y: 100 }, + target: { x: 130, y: 300 }, + want: [ + [122.5, 100], + [132.5, 100], + [132.5, 282.5], + ], + }, + { + name: 'A2 (parallel-dominant drag)', + cloud: { x: 400, y: 100 }, + target: { x: 130, y: 250 }, + want: [ + [122.5, 100], + [132.5, 100], + [132.5, 232.5], + ], + }, + ]; + for (const row of ROWS) { + it(row.name, () => { + const view = loadView([ + ...straightToCloud(row.cloud).elements.map(toJson), + stockJson(2, row.target.x, row.target.y), + ]); + const f = flowOf(view, 10); + const t = flowTerminals(f, byUidOf(view)); + const g = routeEnd(f, 'sink', stockTerminal(stockOf(view, 2)), { fixed: t.source }); + const out = applyGeometry(view, g, [], [3]); + clean(out, 10, g); + expect(pts(g)).toEqual(row.want); + expect(g.flow.points[2].attachedToUid).toBe(2); + expect(faceOfEndpoint(stockOf(view, 2), g.flow.points[2], g.flow.points[1])).toBe('top'); + }); + } +}); + +function toJson(el: StockFlowView['elements'][number]): Parameters[0][number] { + switch (el.type) { + case 'stock': + return stockJson(el.uid, el.x, el.y); + case 'cloud': + return cloudJson(el.uid, el.flowUid, el.x, el.y); + case 'flow': + return flowJson(el.uid, el, el.points, { + source: el.points[0].attachedToUid, + sink: el.points[el.points.length - 1].attachedToUid, + }); + default: + throw new Error(`unexpected ${el.type}`); + } +} + +describe('audit lead B: a three-point flow reattached onto a stock takes the face its last segment reaches', () => { + const ROWS: ReadonlyArray<{ + readonly name: string; + readonly points: Pt[]; + readonly stock: Pt; + readonly want: number[][]; + readonly face: string; + }> = [ + { + name: 'B: stock to the right of the corner: the left face', + points: [ + { x: 0, y: 0 }, + { x: 0, y: 100 }, + { x: 200, y: 100 }, + ], + stock: { x: 300, y: 100 }, + want: [ + [0, 0], + [0, 100], + [277.5, 100], + ], + face: 'left', + }, + { + name: 'B-mirror: stock below the corner: the top face', + points: [ + { x: 0, y: 0 }, + { x: 100, y: 0 }, + { x: 100, y: 200 }, + ], + stock: { x: 100, y: 300 }, + want: [ + [0, 0], + [100, 0], + [100, 282.5], + ], + face: 'top', + }, + { + name: 'B-contrast: stock to the left of the corner: the right face', + points: [ + { x: 0, y: 0 }, + { x: 0, y: 100 }, + { x: 200, y: 100 }, + ], + stock: { x: -100, y: 100 }, + want: [ + [0, 0], + [0, 100], + [-77.5, 100], + ], + face: 'right', + }, + ]; + for (const row of ROWS) { + it(row.name, () => { + const last = row.points[2]; + const view = loadView([ + stockJson(2, row.stock.x, row.stock.y), + cloudJson(3, 10, 0, 0), + cloudJson(4, 10, last.x, last.y), + flowJson(10, row.points[1], row.points, { source: 3, sink: 4 }), + ]); + const f = flowOf(view, 10); + const t = flowTerminals(f, byUidOf(view)); + const g = routeEnd(f, 'sink', stockTerminal(stockOf(view, 2)), { fixed: t.source }); + clean(applyGeometry(view, g, [], [4]), 10, g); + expect(pts(g)).toEqual(row.want); + expect(faceOfEndpoint(stockOf(view, 2), g.flow.points[2], g.flow.points[1])).toBe(row.face); + }); + } +}); + +describe('audit lead C: a source detached to empty space puts the cloud under the pointer', () => { + it('press at x = 132 (in the source hit area), release at x = 60: the endpoint moves 72px, the cloud is at the endpoint', () => { + const view = straightToCloud({ x: 300, y: 100 }); + const f = flowOf(view, 10); + const t = flowTerminals(f, byUidOf(view)); + const newCloud: CloudViewElement = { + type: 'cloud', + uid: 20, + flowUid: 10, + x: 0, + y: 0, + isZeroRadius: false, + ident: undefined, + }; + // The endpoint keeps its grab offset: base endpoint + (release - press). + const pointer = { x: 122.5 + (60 - 132), y: 100 }; + const g = routeEnd(f, 'source', freeTerminal(pointer, newCloud), { fixed: t.sink }); + clean(applyGeometry(view, g), 10, g); + expect(pts(g)).toEqual([ + [50.5, 100], + [300, 100], + ]); + expect(g.clouds).toEqual([{ ...newCloud, x: 50.5, y: 100 }]); + }); +}); + +describe('audit lead D and #819: offsetting a flow between two stocks forms a bracket with stubs, not risers along the faces', () => { + const view = loadView([ + stockJson(1, 100, 100), + stockJson(2, 300, 100), + flowJson( + 10, + { x: 200, y: 100 }, + [ + { x: 122.5, y: 100 }, + { x: 277.5, y: 100 }, + ], + { source: 1, sink: 2 }, + ), + ]); + const f = flowOf(view, 10); + const t = flowTerminals(f, byUidOf(view)); + + it('D: the valve dragged down 40', () => { + const g = offsetSegment(f, 0, 140, t); + clean(applyGeometry(view, g), 10, g); + expect(pts(g)).toEqual([ + [122.5, 114.5], + [132.5, 114.5], + [132.5, 140], + [262, 140], + [262, 114.5], + [277.5, 114.5], + ]); + }); + + it('#819 follow-up: nudging a stock keeps the bracket and the endpoint on its face', () => { + const bracket = offsetSegment(f, 0, 140, t); + const bview = applyGeometry(view, bracket); + const bf = bracket.flow; + const A = stockOf(bview, 1); + const moved = { ...A, x: 80 }; + const g = routeEnd(bf, 'source', stockTerminal(moved, bf.points[0], bf.points[1], A), { + fixed: flowTerminals(bf, byUidOf(bview)).sink, + }); + clean(applyGeometry(bview, g, [moved]), 10, g); + expect(directions(g.flow.points)).toBe(directions(bf.points)); + expect(faceOfEndpoint(moved, g.flow.points[0], g.flow.points[1])).toBe('right'); + expect(g.flow.points.slice(2)).toEqual(bf.points.slice(2)); + }); +}); + +describe('audit L1-L5: a stock -> cloud flow with its valve or cloud dragged off axis', () => { + const view = straightToCloud({ x: 300, y: 100 }); + const f = flowOf(view, 10); + const t = flowTerminals(f, byUidOf(view)); + + it('L1: the valve dragged down 30 offsets the pipe: a stub and a riser, never a riser along the face', () => { + const g = offsetSegment(f, 0, 130, t); + clean(applyGeometry(view, g), 10, g); + expect(pts(g)).toEqual([ + [122.5, 114.5], + [132.5, 114.5], + [132.5, 130], + [300, 130], + ]); + expect(g.clouds.map((c) => [c.uid, c.x, c.y])).toEqual([[3, 300, 130]]); + }); + + const DRAGS: ReadonlyArray<{ readonly name: string; readonly to: Pt; readonly want: number[][] }> = [ + { + name: 'L2/L3: the cloud dragged down 30: an L turning at the cloud', + to: { x: 300, y: 130 }, + want: [ + [122.5, 100], + [300, 100], + [300, 130], + ], + }, + { + name: 'L4: the cloud moved (+5,+1): the endpoint slides, no diagonal', + to: { x: 305, y: 101 }, + want: [ + [122.5, 101], + [305, 101], + ], + }, + { + name: 'L5: the cloud moved (+50,+3): no 3px riser', + to: { x: 350, y: 103 }, + want: [ + [122.5, 103], + [350, 103], + ], + }, + ]; + for (const row of DRAGS) { + it(row.name, () => { + const g = routeEnd(f, 'sink', freeTerminal(row.to, cloudOf(view, 3)), { fixed: t.source }); + clean(applyGeometry(view, g), 10, g); + expect(pts(g)).toEqual(row.want); + }); + } +}); + +describe('audit R8: moving a stock up keeps the valve continuous on an L', () => { + it('the valve moves at most one frame step per 1px of stock travel', () => { + const view = loadView([ + stockJson(1, 100, 100), + cloudJson(3, 10, 40, 300), + flowJson( + 10, + { x: 60, y: 100 }, + [ + { x: 77.5, y: 100 }, + { x: 40, y: 100 }, + { x: 40, y: 300 }, + ], + { source: 1, sink: 3 }, + ), + ]); + const f = flowOf(view, 10); + const t = flowTerminals(f, byUidOf(view)); + const A = stockOf(view, 1); + let prev: FlowViewElement | undefined; + let worst = 0; + for (let d = 0; d <= 30; d++) { + const moved = { ...A, y: 100 - d }; + const g = routeEnd(f, 'source', stockTerminal(moved, f.points[0], f.points[1], A), { fixed: t.sink }); + clean(applyGeometry(view, g, [moved]), 10, g); + if (prev !== undefined) { + worst = Math.max(worst, Math.hypot(g.flow.x - prev.x, g.flow.y - prev.y)); + } + prev = g.flow; + } + // One px of travel moves the corner one px; a valve on the run moves with it (sqrt 2). + expect(worst).toBeLessThanOrEqual(Math.SQRT2 + 1e-9); + }); +}); + +describe('audit R9 and R16: a route never crosses the fixed end`s stock', () => { + it('R9: B moved left under A', () => { + const view = loadView([ + stockJson(1, 100, 100), + stockJson(2, 300, 200), + flowJson( + 10, + { x: 200, y: 100 }, + [ + { x: 300, y: 182.5 }, + { x: 300, y: 100 }, + { x: 122.5, y: 100 }, + ], + { source: 2, sink: 1 }, + ), + ]); + const f = flowOf(view, 10); + const t = flowTerminals(f, byUidOf(view)); + const B = stockOf(view, 2); + for (let x = 300; x >= 110; x -= 5) { + const moved = { ...B, x }; + const g = routeEnd(f, 'source', stockTerminal(moved, f.points[0], f.points[1], B), { fixed: t.sink }); + clean(applyGeometry(view, g, [moved]), 10, g); + } + }); + + it('R16: an L flow`s source cloud dragged up 40', () => { + const view = loadView([ + stockJson(1, 100, 100), + cloudJson(3, 10, 250, 150), + flowJson( + 10, + { x: 170, y: 150 }, + [ + { x: 250, y: 150 }, + { x: 90, y: 150 }, + { x: 90, y: 117.5 }, + ], + { source: 3, sink: 1 }, + ), + ]); + const f = flowOf(view, 10); + const t = flowTerminals(f, byUidOf(view)); + const g = routeEnd(f, 'source', freeTerminal({ x: 250, y: 110 }, cloudOf(view, 3)), { fixed: t.sink }); + clean(applyGeometry(view, g), 10, g); + // Pinned to the bottom face it would need a U turn; released, it reaches the right face straight. + expect(pts(g)).toEqual([ + [250, 110], + [122.5, 110], + ]); + }); +}); + +describe('audit R10 and lead probe F: an unmoved terminal changes nothing (no re-spread, no snap to center)', () => { + it('R10: an off-center L endpoint', () => { + const view = loadView([ + stockJson(1, 100, 100), + cloudJson(3, 10, 200, 300), + flowJson( + 10, + { x: 200, y: 200 }, + [ + { x: 122.5, y: 95 }, + { x: 200, y: 95 }, + { x: 200, y: 300 }, + ], + { source: 1, sink: 3 }, + ), + ]); + const f = flowOf(view, 10); + const t = flowTerminals(f, byUidOf(view)); + const A = stockOf(view, 1); + const g = routeEnd(f, 'source', stockTerminal(A, f.points[0], f.points[1], A), { fixed: t.sink }); + expect(pts(g)).toEqual(f.points.map((p) => [p.x, p.y])); + expect(g.flow.x).toBeCloseTo(f.x, 9); + expect(g.flow.y).toBeCloseTo(f.y, 9); + // R10b: a real drag keeps the offset instead of snapping the endpoint to the face center. + const moved = { ...A, x: 106 }; + const nudged = routeEnd(f, 'source', stockTerminal(moved, f.points[0], f.points[1], A), { fixed: t.sink }); + expect(nudged.flow.points[0]).toEqual({ x: 128.5, y: 95, attachedToUid: 1 }); + }); + + it('probe F: two endpoints 5px apart on one face are not re-spread', () => { + const view = loadView([ + stockJson(1, 100, 100), + cloudJson(3, 10, 300, 90), + cloudJson(4, 11, 300, 95), + flowJson( + 10, + { x: 200, y: 90 }, + [ + { x: 122.5, y: 90 }, + { x: 300, y: 90 }, + ], + { source: 1, sink: 3 }, + ), + flowJson( + 11, + { x: 200, y: 95 }, + [ + { x: 122.5, y: 95 }, + { x: 300, y: 95 }, + ], + { source: 1, sink: 4 }, + ), + ]); + const A = stockOf(view, 1); + for (const uid of [10, 11]) { + const f = flowOf(view, uid); + const t = flowTerminals(f, byUidOf(view)); + const g = routeEnd(f, 'source', stockTerminal(A, f.points[0], f.points[1], A), { fixed: t.sink }); + expect(pts(g)).toEqual(f.points.map((p) => [p.x, p.y])); + } + }); +}); + +describe('audit R11: a stock nudged past a straight flow`s column leaves no sub-pixel stub', () => { + it('stock nudged right 5 under a vertical flow into its top face', () => { + const view = loadView([ + stockJson(1, 100, 100), + cloudJson(3, 10, 82, 0), + flowJson( + 10, + { x: 82, y: 40 }, + [ + { x: 82, y: 0 }, + { x: 82, y: 82.5 }, + ], + { source: 3, sink: 1 }, + ), + ]); + const f = flowOf(view, 10); + const t = flowTerminals(f, byUidOf(view)); + const A = stockOf(view, 1); + for (const dx of [1, 2, 3, 4, 5, 6, 10]) { + const moved = { ...A, x: 100 + dx }; + const g = routeEnd(f, 'sink', stockTerminal(moved, f.points[1], f.points[0], A), { fixed: t.source }); + clean(applyGeometry(view, g, [moved]), 10, g); + } + }); +}); + +describe('audit R13: flows created from a stock', () => { + it('R13: the pointer still inside the source stock: a route exists and holds G1-G5', () => { + const view = loadView([stockJson(1, 445, 479)]); + const draft = { ...flowOf(loadView([flowJson(10, { x: 445, y: 479 }, [], {})]), 10) }; + const sink: CloudViewElement = { + type: 'cloud', + uid: 3, + flowUid: 10, + x: 445, + y: 484, + isZeroRadius: false, + ident: undefined, + }; + const g = route(stockTerminal(stockOf(view, 1)), freeTerminal({ x: 445, y: 484 }, sink), { flow: draft }); + clean(applyGeometry(view, g, [sink]), 10, g); + }); + + it('R13b (core half): the sink lands exactly at the pointer', () => { + const view = loadView([stockJson(1, 200, 200)]); + const draft = flowOf(loadView([flowJson(10, { x: 200, y: 200 }, [], {})]), 10); + const sink: CloudViewElement = { + type: 'cloud', + uid: 3, + flowUid: 10, + x: 315, + y: 210, + isZeroRadius: false, + ident: undefined, + }; + const g = route(stockTerminal(stockOf(view, 1)), freeTerminal({ x: 315, y: 210 }, sink), { flow: draft }); + clean(applyGeometry(view, g, [sink]), 10, g); + expect(pts(g)).toEqual([ + [222.5, 210], + [315, 210], + ]); + }); +}); + +describe('audit R17: dragging a Z middle past the stock face stops at the stub minimum', () => { + it('the pipe drag is clamped so the stub keeps MIN_SEGMENT', () => { + const view = loadView([ + stockJson(1, 100, 100), + cloudJson(3, 10, 250, 300), + flowJson( + 10, + { x: 175, y: 200 }, + [ + { x: 100, y: 117.5 }, + { x: 100, y: 200 }, + { x: 250, y: 200 }, + { x: 250, y: 300 }, + ], + { + source: 1, + sink: 3, + }, + ), + ]); + const f = flowOf(view, 10); + const g = offsetSegment(f, 1, 105, flowTerminals(f, byUidOf(view))); + clean(applyGeometry(view, g), 10, g); + expect(pts(g)).toEqual([ + [100, 117.5], + [100, 127.5], + [250, 127.5], + [250, 300], + ]); + }); +}); + +describe('audit R18: a valve dragged diagonally off an L slides continuously, never hopping segments', () => { + it('sweeping the pointer diagonally moves the valve at most 1px of arc per 1px step', () => { + const view = loadView([ + stockJson(1, 100, 100), + cloudJson(3, 10, 300, 300), + flowJson( + 10, + { x: 200, y: 100 }, + [ + { x: 122.5, y: 100 }, + { x: 300, y: 100 }, + { x: 300, y: 300 }, + ], + { source: 1, sink: 3 }, + ), + ]); + const f = flowOf(view, 10); + let prev = arcOf(f.points, f); + for (let k = 1; k <= 60; k++) { + const g = slideValve(f, { x: k, y: k }); + const arc = arcOf(f.points, g); + expect(Math.abs(arc - prev)).toBeLessThanOrEqual(1 + 1e-9); + prev = arc; + } + expect([slideValve(f, { x: 60, y: 60 }).x, slideValve(f, { x: 60, y: 60 }).y]).toEqual([260, 100]); + }); +}); + +describe('audit R19: stocks touching or overlapping still route (totality)', () => { + for (const [name, x] of [ + ['R19a: faces meeting', 155], + ['R19b: A moved into B', 170], + ] as const) { + it(name, () => { + const view = loadView([ + stockJson(1, 100, 100), + stockJson(2, 200, 100), + flowJson( + 10, + { x: 150, y: 100 }, + [ + { x: 122.5, y: 100 }, + { x: 177.5, y: 100 }, + ], + { source: 1, sink: 2 }, + ), + ]); + const f = flowOf(view, 10); + const t = flowTerminals(f, byUidOf(view)); + const A = stockOf(view, 1); + const moved = { ...A, x }; + const g = routeEnd(f, 'source', stockTerminal(moved, f.points[0], f.points[1], A), { fixed: t.sink }); + clean(applyGeometry(view, g, [moved]), 10, g); + }); + } +}); + +describe('#53: a cloud dragged perpendicular keeps the valve continuous except at the documented transitions', () => { + it('sweeping the sink cloud down 200px in 1px frames', () => { + const view = loadView([ + stockJson(1, 100, 200), + cloudJson(3, 10, 300, 200), + flowJson( + 10, + { x: 200, y: 200 }, + [ + { x: 122.5, y: 200 }, + { x: 300, y: 200 }, + ], + { source: 1, sink: 3 }, + ), + ]); + const f = flowOf(view, 10); + const t = flowTerminals(f, byUidOf(view)); + const valveJumps: number[] = []; + const pathJumps: number[] = []; + let prev: FlowViewElement | undefined; + for (let dy = 0; dy <= 200; dy++) { + const g = routeEnd(f, 'sink', freeTerminal({ x: 300, y: 200 + dy }, cloudOf(view, 3)), { fixed: t.source }); + clean(applyGeometry(view, g), 10, g); + if (prev !== undefined) { + if (Math.hypot(g.flow.x - prev.x, g.flow.y - prev.y) > 1 + 1e-9) valveJumps.push(dy); + if (hausdorff(prev.points, g.flow.points) > 1.5) pathJumps.push(dy); + } + prev = g.flow; + } + // Under MIN_SEGMENT the endpoint slides (valve follows); at 10 the pinned Z + // becomes valid and the endpoint returns to its base offset (the valve jumps + // back 9px); at 16 (MIN_SINK_SEGMENT past) the Z's riser moves onto the + // cloud as the L becomes valid. Nothing else jumps. + expect(valveJumps).toEqual([10]); + expect(pathJumps).toEqual([10, 16]); + }); +}); + +describe('#818: a non-finite valve on the base flow routes to finite geometry', () => { + it('routeEnd and route place the valve at the midpoint', () => { + const view = straightToCloud({ x: 300, y: 100 }); + const f = { ...flowOf(view, 10), x: NaN }; + const t = flowTerminals(f, byUidOf(view)); + const moved = { ...stockOf(view, 1), y: 90 }; + const a = routeEnd(f, 'source', stockTerminal(moved, f.points[0], f.points[1], stockOf(view, 1)), { + fixed: t.sink, + }); + const b = route(t.source, t.sink, { flow: f }); + for (const g of [a, b]) { + expect([g.flow.x, g.flow.y].every(Number.isFinite)).toBe(true); + } + }); +}); + +describe('#832: grabbing a source cloud without moving leaves an off-center valve where it is', () => { + it('zero-delta source cloud grab', () => { + const view = loadView([ + stockJson(1, 300, 200), + cloudJson(2, 10, 100, 200), + flowJson( + 10, + { x: 160, y: 200 }, + [ + { x: 100, y: 200 }, + { x: 277.5, y: 200 }, + ], + { source: 2, sink: 1 }, + ), + ]); + const f = flowOf(view, 10); + const t = flowTerminals(f, byUidOf(view)); + const g = routeEnd(f, 'source', freeTerminal({ x: 100, y: 200 }, cloudOf(view, 2)), { fixed: t.sink }); + expect(pts(g)).toEqual(f.points.map((p) => [p.x, p.y])); + expect(g.flow.x).toBeCloseTo(160, 9); + expect(g.flow.y).toBe(200); + expect(g.clouds).toEqual([]); + }); +}); diff --git a/src/diagram/tests/flow-geometry-route.test.ts b/src/diagram/tests/flow-geometry-route.test.ts new file mode 100644 index 000000000..5f8999609 --- /dev/null +++ b/src/diagram/tests/flow-geometry-route.test.ts @@ -0,0 +1,855 @@ +// Copyright 2026 The Simlin Authors. All rights reserved. +// Use of this source code is governed by the Apache License, +// Version 2.0, that can be found in the LICENSE file. + +// Tests of `route` and `routeEnd` (flow-geometry/route.ts). +// +// The main table is the cross product of enumerations: the moving terminal's +// kind (each of the four stock faces, and a free cloud), the base path's shape +// (straight, L, Z, bracket, and a five-segment path), the drag quadrant, which +// end moves, the fixed terminal's kind, and the drag distance (a nudge every +// preserved prefix survives, and a far drag that releases most of them). Every +// row asserts the strict invariants on the routed flow, the attachments, and the +// valve policy (arc-length distance from the fixed end, clamped, margin once). +// Nudge rows additionally pin what the plan determines: the preserved prefix from +// the fixed end is kept exactly, and a moving stock keeps its base face. +// +// Separate tables pin each ranking arm (validity of off-base faces with no +// hysteresis, stickiness within one bend, axis change, length), the pair-owned +// holds (the straight's split preference, a Z's tip midpoint), U detours, which +// preserved tails routeEnd accepts (not crossing, no added bends or U turns), the +// pinned-then-released k = 0 rule, and totality with overlapping stocks. +// +// Not covered here: offsetSegment (flow-geometry-offset.test.ts), heal +// (flow-geometry-heal.test.ts), the primitives (-terminal, -valve, -validity), +// the audit repros (-repros), continuity sweeps (-sweeps), and seeded scenes +// (-fuzz). + +import { describe, it, expect } from '@rstest/core'; + +import type { JsonViewElement } from '@simlin/engine'; +import type { FlowViewElement, StockFlowView, StockViewElement } from '@simlin/core/datamodel'; + +import { StockHeight, StockWidth } from '../drawing/default'; +import { + FACES, + faceOfEndpoint, + flowTerminals, + freeTerminal, + route, + routeEnd, + stockTerminal, + type Face, + type FlowEnd, + type Terminal, +} from '../flow-geometry'; +import { + applyGeometry, + arcOf, + byUidOf, + cloudJson, + cloudOf, + directions, + flowJson, + flowOf, + fmtFlow, + loadView, + pathLength, + stockJson, + stockOf, + strictReport, + type Pt, +} from './support/flow-geometry-fixtures'; + +// Pinned literal, independent of the core's export (drift in either is caught). +const VALVE_CLAMP_MARGIN = 10; + +const MOVING = 1; +const FIXED = 2; +const FLOW = 10; +const MOVING_CLOUD = 11; +const FIXED_CLOUD = 12; +const CENTER: Pt = { x: 400, y: 400 }; + +// Canonical templates: the moving end is the source, at a stock centered at the +// origin whose right face is x = 22.5, leaving rightward. Stubs are at least 20 +// so a nudge keeps every preserved prefix feasible. +const SHAPES = { + straight: [ + { x: 22.5, y: 0 }, + { x: 200, y: 0 }, + ], + L: [ + { x: 22.5, y: 0 }, + { x: 150, y: 0 }, + { x: 150, y: 150 }, + ], + Z: [ + { x: 22.5, y: 0 }, + { x: 100, y: 0 }, + { x: 100, y: 80 }, + { x: 250, y: 80 }, + ], + bracket: [ + { x: 22.5, y: 0 }, + { x: 52.5, y: 0 }, + { x: 52.5, y: -70 }, + { x: 180, y: -70 }, + { x: 180, y: 0 }, + { x: 260, y: 0 }, + ], + prefix3: [ + { x: 22.5, y: 0 }, + { x: 70, y: 0 }, + { x: 70, y: 90 }, + { x: 170, y: 90 }, + { x: 170, y: -60 }, + { x: 280, y: -60 }, + ], +} as const; +type ShapeName = keyof typeof SHAPES; +const SHAPE_NAMES = Object.keys(SHAPES) as ShapeName[]; + +const MOVING_KINDS = [...FACES, 'free'] as const; +type MovingKind = (typeof MOVING_KINDS)[number]; +const QUADRANTS: ReadonlyArray = [ + [1, 1], + [1, -1], + [-1, 1], + [-1, -1], +]; +const ENDS: readonly FlowEnd[] = ['source', 'sink']; +const FIXED_KINDS = ['cloud', 'stock'] as const; +const DISTANCES = { nudge: 3, far: 140 } as const; +type DistanceName = keyof typeof DISTANCES; + +/** Rotate the canonical +x exit onto `face`'s outward direction. */ +function rotate(p: Pt, face: Face): Pt { + switch (face) { + case 'right': + return { x: p.x, y: p.y }; + case 'bottom': + return { x: -p.y, y: p.x }; + case 'left': + return { x: -p.x, y: -p.y }; + case 'top': + return { x: p.y, y: -p.x }; + } +} + +interface Fixture { + readonly view: StockFlowView; + readonly flow: FlowViewElement; + readonly movingEnd: FlowEnd; + readonly movingKind: MovingKind; +} + +function buildFixture(kind: MovingKind, shape: ShapeName, movingEnd: FlowEnd, fixedKind: 'cloud' | 'stock'): Fixture { + const face: Face = kind === 'free' ? 'right' : kind; + const canonical = SHAPES[shape].map((p) => rotate(p, face)); + const pts = canonical.map((p) => ({ x: CENTER.x + p.x, y: CENTER.y + p.y })); + const elements: JsonViewElement[] = []; + if (kind === 'free') { + elements.push(cloudJson(MOVING_CLOUD, FLOW, pts[0].x, pts[0].y)); + } else { + elements.push(stockJson(MOVING, CENTER.x, CENTER.y)); + } + const last = pts[pts.length - 1]; + const before = pts[pts.length - 2]; + if (fixedKind === 'cloud') { + elements.push(cloudJson(FIXED_CLOUD, FLOW, last.x, last.y)); + } else { + const dx = Math.sign(last.x - before.x); + const dy = Math.sign(last.y - before.y); + elements.push(stockJson(FIXED, last.x + dx * (StockWidth / 2), last.y + dy * (StockHeight / 2))); + } + const movingUid = kind === 'free' ? MOVING_CLOUD : MOVING; + const fixedUid = fixedKind === 'cloud' ? FIXED_CLOUD : FIXED; + const ordered = movingEnd === 'source' ? pts : [...pts].reverse(); + const valve = pointAt(ordered, pathLength(ordered) * 0.4); + elements.push( + flowJson( + FLOW, + valve, + ordered, + movingEnd === 'source' ? { source: movingUid, sink: fixedUid } : { source: fixedUid, sink: movingUid }, + ), + ); + const view = loadView(elements); + return { view, flow: flowOf(view, FLOW), movingEnd, movingKind: kind }; +} + +function pointAt(pts: readonly Pt[], s: number): Pt { + let remaining = s; + for (let i = 0; i < pts.length - 1; i++) { + const length = Math.hypot(pts[i + 1].x - pts[i].x, pts[i + 1].y - pts[i].y); + if (remaining <= length) { + const t = remaining / length; + return { x: pts[i].x + (pts[i + 1].x - pts[i].x) * t, y: pts[i].y + (pts[i + 1].y - pts[i].y) * t }; + } + remaining -= length; + } + return pts[pts.length - 1]; +} + +/** Move the moving terminal by `delta` the way a planner does: the moved stock keeps the base face and offset. */ +function movingTerminal(fx: Fixture, delta: Pt): { terminal: Terminal; moved: StockViewElement | undefined } { + const f = fx.flow; + const n = f.points.length; + const endpoint = fx.movingEnd === 'source' ? f.points[0] : f.points[n - 1]; + const adjacent = fx.movingEnd === 'source' ? f.points[1] : f.points[n - 2]; + if (fx.movingKind === 'free') { + const cloud = cloudOf(fx.view, MOVING_CLOUD); + const point = { x: cloud.x + delta.x, y: cloud.y + delta.y }; + return { terminal: freeTerminal(point, cloud), moved: undefined }; + } + const base = stockOf(fx.view, MOVING); + const stock = { ...base, x: base.x + delta.x, y: base.y + delta.y }; + return { terminal: stockTerminal(stock, endpoint, adjacent, base), moved: stock }; +} + +interface Row { + readonly name: string; + readonly kind: MovingKind; + readonly shape: ShapeName; + readonly quadrant: readonly [number, number]; + readonly end: FlowEnd; + readonly fixedKind: 'cloud' | 'stock'; + readonly distance: DistanceName; +} + +const ROWS: Row[] = MOVING_KINDS.flatMap((kind) => + SHAPE_NAMES.flatMap((shape) => + QUADRANTS.flatMap((quadrant) => + ENDS.flatMap((end) => + FIXED_KINDS.flatMap((fixedKind) => + (Object.keys(DISTANCES) as DistanceName[]).map((distance) => ({ + name: `${kind} ${shape} q(${quadrant.join(',')}) ${end} moves, fixed ${fixedKind}, ${distance}`, + kind, + shape, + quadrant, + end, + fixedKind, + distance, + })), + ), + ), + ), + ), +); + +describe('routeEnd over terminal kind x shape x quadrant x end x fixed kind x distance', () => { + it('covers the full cross product', () => { + expect(ROWS.length).toBe(MOVING_KINDS.length * SHAPE_NAMES.length * 4 * 2 * 2 * 2); + }); + + for (const row of ROWS) { + it(row.name, () => { + const fx = buildFixture(row.kind, row.shape, row.end, row.fixedKind); + const base = fx.flow; + const d = DISTANCES[row.distance]; + const delta = { x: row.quadrant[0] * d, y: row.quadrant[1] * d }; + const terminals = flowTerminals(base, byUidOf(fx.view)); + const fixedEnd: FlowEnd = row.end === 'source' ? 'sink' : 'source'; + const fixed = fixedEnd === 'source' ? terminals.source : terminals.sink; + const { terminal, moved } = movingTerminal(fx, delta); + + const g = routeEnd(base, row.end, terminal, { fixed }); + const view = applyGeometry(fx.view, g, moved === undefined ? [] : [moved]); + const f = g.flow; + const n = f.points.length; + const context = `${row.name}\nbase ${fmtFlow(base)}\nresult ${fmtFlow(f)}`; + + expect(`${context}\n${strictReport(view, [FLOW])}`).toBe(`${context}\n`); + + // Attachments: the moving end to the moving terminal, the fixed end to the fixed one. + const movingUid = row.kind === 'free' ? MOVING_CLOUD : MOVING; + const fixedUid = row.fixedKind === 'cloud' ? FIXED_CLOUD : FIXED; + const movingPoint = row.end === 'source' ? f.points[0] : f.points[n - 1]; + const fixedPoint = row.end === 'source' ? f.points[n - 1] : f.points[0]; + expect(`${context}\n${movingPoint.attachedToUid} ${fixedPoint.attachedToUid}`).toBe( + `${context}\n${movingUid} ${fixedUid}`, + ); + // The only cloud a routeEnd moves is the moving terminal's (E4). + expect(g.clouds.every((c) => c.uid === MOVING_CLOUD)).toBe(true); + if (row.kind === 'free') { + expect(movingPoint.x).toBeCloseTo(cloudOf(fx.view, MOVING_CLOUD).x + delta.x, 9); + expect(movingPoint.y).toBeCloseTo(cloudOf(fx.view, MOVING_CLOUD).y + delta.y, 9); + } + + // Valve: arc distance from the fixed end preserved, clamped, margin once. + const baseLength = pathLength(base.points); + const baseFromFixed = fixedEnd === 'source' ? arcOf(base.points, base) : baseLength - arcOf(base.points, base); + const length = pathLength(f.points); + const wantFromFixed = + length < 2 * VALVE_CLAMP_MARGIN + ? length / 2 + : Math.max(VALVE_CLAMP_MARGIN, Math.min(length - VALVE_CLAMP_MARGIN, Math.min(baseFromFixed, length))); + const gotFromFixed = fixedEnd === 'source' ? arcOf(f.points, f) : length - arcOf(f.points, f); + expect(`${context}\nvalve ${gotFromFixed.toFixed(6)}`).toBe(`${context}\nvalve ${wantFromFixed.toFixed(6)}`); + + if (row.distance !== 'nudge') { + return; + } + // A nudge keeps the preserved prefix from the fixed end exactly: K all but + // the corner adjacent to the moving end, and at K = 0 the fixed endpoint. + const K = Math.max(0, base.points.length - 3); + const fromFixed = (pts: readonly Pt[]): Pt[] => (fixedEnd === 'source' ? [...pts] : [...pts].reverse()); + const keptBase = fromFixed(base.points).slice(0, K + 1); + const keptResult = fromFixed(f.points).slice(0, K + 1); + if (!(row.kind === 'free' && row.shape === 'straight')) { + expect(`${context}\n${JSON.stringify(keptResult.map((p) => [p.x, p.y]))}`).toBe( + `${context}\n${JSON.stringify(keptBase.map((p) => [p.x, p.y]))}`, + ); + } + if (row.kind !== 'free') { + // Stickiness: a nudged stock keeps its base face and the path its shape. + const stock = moved!; + const movingAdjacent = row.end === 'source' ? f.points[1] : f.points[n - 2]; + expect(`${context}\n${faceOfEndpoint(stock, movingPoint, movingAdjacent)}`).toBe(`${context}\n${row.kind}`); + expect(`${context}\n${directions(f.points)}`).toBe(`${context}\n${directions(base.points)}`); + } else if (row.shape === 'straight') { + // A free end nudged off a straight flow's axis: pinned, nothing valid is + // straight, L or Z (the offset is under MIN_SEGMENT), so the flow is + // released. A fixed stock slides its endpoint along its face and stays + // straight; a fixed cloud cannot slide, and the detour is a U turn. + if (row.fixedKind === 'stock') { + expect(`${context}\n${n}`).toBe(`${context}\n2`); + } else { + expect(`${context}\n${n}`).toBe(`${context}\n4`); + } + } + }); + } +}); + +// --------------------------------------------------------------------------- +// Ranking arms + +function scene(elements: JsonViewElement[]): StockFlowView { + return loadView(elements); +} + +function faces(view: StockFlowView, f: FlowViewElement): string { + const byUid = byUidOf(view); + const n = f.points.length; + const at = (i: number, j: number): string => { + const el = byUid.get(f.points[i].attachedToUid ?? -1); + return el?.type === 'stock' ? (faceOfEndpoint(el, f.points[i], f.points[j]) ?? 'offFace') : 'free'; + }; + return `${at(0, 1)}->${at(n - 1, n - 2)}`; +} + +describe('route ranking', () => { + // Stock A at (0,0) with a base straight out of its right face. Only the base's + // face, offset and end axes matter to route; the cloud is where the drag put it. + const A = stockJson(1, 0, 0); + const baseStraight = (): JsonViewElement => + flowJson( + FLOW, + { x: 60, y: 0 }, + [ + { x: 22.5, y: 0 }, + { x: 120, y: 0 }, + ], + { source: 1, sink: 12 }, + ); + + interface RankRow { + readonly name: string; + readonly cloud: Pt; + readonly want: string; + readonly why: string; + } + // No hysteresis: a pure function has no previous frame to be sticky against, + // and stickiness already holds the base face. With the base face (right) + // invalid, the cloud up and to the left has two off-base one-bend candidates + // that change one end axis each, and they compete on validity and length only. + const OFF_BASE: RankRow[] = [ + { + name: 'an off-base face whose stub clears MIN_SEGMENT wins on length', + cloud: { x: -40, y: -60 }, + want: 'left->free', + why: 'left stub 17.5 >= 10; left L is 77.5 long, top L 82.5', + }, + { + name: 'an off-base face whose stub would be under MIN_SEGMENT is invalid', + cloud: { x: -30, y: -60 }, + want: 'top->free', + why: 'left stub 7.5 < 10', + }, + ]; + for (const row of OFF_BASE) { + it(row.name, () => { + const view = scene([A, cloudJson(12, FLOW, row.cloud.x, row.cloud.y), baseStraight()]); + const base = flowOf(view, FLOW); + const t = flowTerminals(base, byUidOf(view)); + const g = route(t.source, freeTerminal(row.cloud, cloudOf(view, 12)), { flow: base }); + const out = applyGeometry(view, g); + expect(`${row.why}: ${faces(out, g.flow)} ${fmtFlow(g.flow)}`).toBe(`${row.why}: ${row.want} ${fmtFlow(g.flow)}`); + expect(strictReport(out, [FLOW])).toBe(''); + }); + } + + it('keeps both base faces when they have a valid candidate within one bend of the best (stickiness)', () => { + // A right -> B left is a Z (two bends); A bottom -> B left is an L (one + // bend). The base faces are within one bend of the best, so the Z is kept. + const view = scene([ + A, + stockJson(2, 100, 60), + flowJson( + FLOW, + { x: 50, y: 30 }, + [ + { x: 22.5, y: 0 }, + { x: 50, y: 0 }, + { x: 50, y: 60 }, + { x: 77.5, y: 60 }, + ], + { source: 1, sink: 2 }, + ), + ]); + const base = flowOf(view, FLOW); + const t = flowTerminals(base, byUidOf(view)); + const g = route(t.source, t.sink, { flow: base }); + const out = applyGeometry(view, g); + expect(`${faces(out, g.flow)} ${directions(g.flow.points)}`).toBe('right->left RDR'); + expect(strictReport(out, [FLOW])).toBe(''); + // Without base faces (a fresh route between the same stocks) an L wins on + // bends. Two Ls tie on bends, axis change and length here; which one is the + // generation-order tie break, deliberately not pinned. + const fresh = route(stockTerminal(stockOf(view, 1)), stockTerminal(stockOf(view, 2)), { + flow: { ...base, points: [] }, + }); + expect(fresh.flow.points.length).toBe(3); + expect(strictReport(applyGeometry(view, fresh), [FLOW])).toBe(''); + }); + + it('prefers the candidate keeping the base end axes (axis change)', () => { + // Cloud to cloud, base an L leaving horizontally and arriving vertically. + // The sink moves: a horizontal-first and a vertical-first L tie on bends and + // length; only the horizontal-first one keeps both end axes. + const view = scene([ + cloudJson(11, FLOW, 0, 0), + cloudJson(12, FLOW, 100, 100), + flowJson( + FLOW, + { x: 50, y: 0 }, + [ + { x: 0, y: 0 }, + { x: 100, y: 0 }, + { x: 100, y: 100 }, + ], + { source: 11, sink: 12 }, + ), + ]); + const base = flowOf(view, FLOW); + const t = flowTerminals(base, byUidOf(view)); + const g = routeEnd(base, 'sink', freeTerminal({ x: 160, y: 140 }, cloudOf(view, 12)), { fixed: t.source }); + expect(directions(g.flow.points)).toBe('RD'); + expect(g.flow.points[1]).toEqual({ x: 160, y: 0, attachedToUid: undefined }); + }); + + it('prefers a route crossing neither body over sticky faces and axes, even where G6 excuses the crossing', () => { + // A straight from A's right face into B's left face; B is dragged over A's + // left side, so the inflated bodies overlap and a crossing is no fault. Out of + // A's left face into B's left face keeps both base end axes and B's face, but + // runs through B; over the top of both, out of A's top face into B's, crosses + // neither and wins. + const view = loadView([ + stockJson(1, 0, 0), + stockJson(2, 222.5, 0), + flowJson( + FLOW, + { x: 200, y: 0 }, + [ + { x: 22.5, y: 0 }, + { x: 200, y: 0 }, + ], + { source: 1, sink: 2 }, + ), + ]); + const base = flowOf(view, FLOW); + const t = flowTerminals(base, byUidOf(view)); + const B = stockOf(view, 2); + const moved = { ...B, x: -40, y: 8 }; + const g = routeEnd(base, 'sink', stockTerminal(moved, base.points[1], base.points[0], B), { fixed: t.source }); + expect(fmtFlow(g.flow)).toBe('valve(-40,-19.5) (0,-17.5)@1 (0,-19.5) (-40,-19.5) (-40,-9.5)@2'); + expect(strictReport(applyGeometry(view, g, [moved]), [FLOW])).toBe(''); + }); + + it('breaks a tie on bends and axis change by length (the shorter U detour)', () => { + // Clouds 4px off each other's line: nothing straight, L or Z is valid, so a + // U detour; the one whose riser turns toward the sink's side is shorter. + const view = scene([ + cloudJson(11, FLOW, 0, 0), + cloudJson(12, FLOW, 100, 4), + flowJson( + FLOW, + { x: 50, y: 0 }, + [ + { x: 0, y: 0 }, + { x: 100, y: 0 }, + ], + { source: 11, sink: 12 }, + ), + ]); + const base = flowOf(view, FLOW); + const t = flowTerminals(base, byUidOf(view)); + const g = route(t.source, t.sink, { flow: base }); + const out = applyGeometry(view, g); + expect(strictReport(out, [FLOW])).toBe(''); + // Up, across, down into the sink: the run holds 4 - 15.5 = -11.5 (an 11.5px + // stub, 127 long) rather than 4 + 15.5 = 19.5 below (a 19.5px stub, 135 long). + expect(directions(g.flow.points)).toBe('URD'); + expect(g.flow.points[1].y).toBeCloseTo(-11.5, 9); + }); +}); + +describe('route: straights, Zs and U detours', () => { + const draft = (): FlowViewElement => flowOf(loadView([flowJson(FLOW, { x: 0, y: 0 }, [], {})]), FLOW); + + it('a straight between two faces splits the difference between their preferences', () => { + // Fresh prefs are the face centers, y = 0 and y = 10; both ranges hold y = 5. + const view = loadView([stockJson(1, 0, 0), stockJson(2, 200, 10)]); + const g = route(stockTerminal(stockOf(view, 1)), stockTerminal(stockOf(view, 2)), { flow: draft() }); + expect(g.flow.points.map((p) => [p.x, p.y])).toEqual([ + [22.5, 5], + [177.5, 5], + ]); + }); + + it('a Z with no base corners holds its riser midway between the two stub tips, not the two faces', () => { + // A straight base from A's right face into B's left face; B moves to (100, 40). + // No straight fits and the L into B's top face is off-base, so stickiness + // keeps both faces as a Z. The base has no corner to keep, so the riser sits + // between A's tip 22.5 + 10 and B's tip 77.5 - 15.5, not between the faces. + const view = loadView([ + stockJson(1, 0, 0), + stockJson(2, 200, 0), + flowJson( + FLOW, + { x: 100, y: 0 }, + [ + { x: 22.5, y: 0 }, + { x: 177.5, y: 0 }, + ], + { source: 1, sink: 2 }, + ), + ]); + const base = flowOf(view, FLOW); + const t = flowTerminals(base, byUidOf(view)); + const B = stockOf(view, 2); + const moved = { ...B, x: 100, y: 40 }; + const g = routeEnd(base, 'sink', stockTerminal(moved, base.points[1], base.points[0], B), { fixed: t.source }); + expect(directions(g.flow.points)).toBe('RDR'); + expect(g.flow.points[1].x).toBeCloseTo((32.5 + 62) / 2, 9); + expect(strictReport(applyGeometry(view, g, [moved]), [FLOW])).toBe(''); + }); + + for (const [dy, want, runY] of [ + [4, 'URD', -11.5], + [-4, 'DRU', 11.5], + ] as const) { + it(`clouds ${dy}px off each other's line: the shorter U detour (${want}), whichever side it is on`, () => { + // Nothing straight, L or Z is valid; the U whose run passes on the sink's + // side needs the shorter riser into the sink. + const view = loadView([ + cloudJson(11, FLOW, 0, 0), + cloudJson(12, FLOW, 100, dy), + flowJson( + FLOW, + { x: 50, y: 0 }, + [ + { x: 0, y: 0 }, + { x: 100, y: 0 }, + ], + { source: 11, sink: 12 }, + ), + ]); + const base = flowOf(view, FLOW); + const t = flowTerminals(base, byUidOf(view)); + const g = route(t.source, t.sink, { flow: base }); + expect(directions(g.flow.points)).toBe(want); + expect(g.flow.points[1].y).toBeCloseTo(runY, 9); + expect(strictReport(applyGeometry(view, g), [FLOW])).toBe(''); + }); + } +}); + +describe('routeEnd: which preserved tails are accepted', () => { + it('a tail that would run alongside its stock is not accepted; the flow is released instead', () => { + // Base: out of the bottom face, down, left, down into a cloud. The cloud is + // dragged above the stock, just right of its right face (401.5): preserving + // the first corner would give down, right, up past the body, hugging it. + const view = loadView([ + stockJson(1, 379, 350), + cloudJson(3, FLOW, 359, 431.5), + flowJson( + FLOW, + { x: 379, y: 400.5 }, + [ + { x: 379, y: 367.5 }, + { x: 379, y: 410.5 }, + { x: 359, y: 410.5 }, + { x: 359, y: 431.5 }, + ], + { source: 1, sink: 3 }, + ), + ]); + const base = flowOf(view, FLOW); + const t = flowTerminals(base, byUidOf(view)); + const g = routeEnd(base, 'sink', freeTerminal({ x: 401.9, y: 223.4 }, cloudOf(view, 3)), { fixed: t.source }); + expect(fmtFlow(g.flow)).toBe( + fmtFlow({ + ...g.flow, + points: [ + { x: 379, y: 332.5, attachedToUid: 1 }, + { x: 379, y: 223.4, attachedToUid: undefined }, + { x: 401.9, y: 223.4, attachedToUid: 3 }, + ], + }), + ); + expect(strictReport(applyGeometry(view, g), [FLOW])).toBe(''); + }); + + it('a tail that adds a U turn the base did not have is refused', () => { + // Base: cloud, down, right, down into a stock's top face (no U turn). The sink + // detaches to a point above the run: preserving two corners would give down, + // right, UP to the point, a U turn; the released route is right, down. + const view = loadView([ + stockJson(1, 395.5, 503), + cloudJson(3, FLOW, 344.5, 361.5), + flowJson( + FLOW, + { x: 344.5, y: 445.3 }, + [ + { x: 344.5, y: 361.5 }, + { x: 344.5, y: 461.5 }, + { x: 395.5, y: 461.5 }, + { x: 395.5, y: 485.5 }, + ], + { source: 3, sink: 1 }, + ), + ]); + const base = flowOf(view, FLOW); + const t = flowTerminals(base, byUidOf(view)); + const newCloud = { + type: 'cloud' as const, + uid: 21, + flowUid: FLOW, + x: 395.5, + y: 485.5, + isZeroRadius: false, + ident: undefined, + }; + const g = routeEnd(base, 'sink', freeTerminal({ x: 393, y: 444 }, newCloud), { fixed: t.source }); + expect(directions(g.flow.points)).toBe('RD'); + }); + + it('a tail that gives the path more bends than the base is refused', () => { + // Base: a Z from stock 1's right face into stock 5's left face (2 bends). + // Stock 5 moved down-left under the run: keeping the base's left face would + // take right, down, left, down, right (4 bends); the released route is an L. + const view = loadView([ + stockJson(1, 503, 382), + stockJson(5, 659, 451.5), + flowJson( + FLOW, + { x: 551.5, y: 447 }, + [ + { x: 525.5, y: 383.5 }, + { x: 551.5, y: 383.5 }, + { x: 551.5, y: 451.5 }, + { x: 636.5, y: 451.5 }, + ], + { source: 1, sink: 5 }, + ), + ]); + const base = flowOf(view, FLOW); + const t = flowTerminals(base, byUidOf(view)); + const S5 = stockOf(view, 5); + for (let k = 0; k <= 24; k++) { + const moved = { ...S5, x: 659 - (184.3 * k) / 24, y: 451.5 + (106.2 * k) / 24 }; + const g = routeEnd(base, 'sink', stockTerminal(moved, base.points[3], base.points[2], S5), { fixed: t.source }); + expect(`k ${k} ${directions(g.flow.points)}`).toMatch(/^k \d+ (RDR|RD|D)$/); + expect(strictReport(applyGeometry(view, g, [moved]), [FLOW])).toBe(''); + } + }); + + it('a tail crossing its stock is refused even where G6 excuses the crossing', () => { + // Base: a U out of the right face, down, back left under the stock into a + // cloud. The cloud is dragged within MIN_SEGMENT of the stock's inflated left + // side, so G6 excuses crossings: preserving the first corner would run the + // tail back through the stock at y = 10, a valid path routeEnd still refuses. + // Released, the endpoint takes the left face straight to the cloud. + const view = loadView([ + stockJson(1, 0, 0), + cloudJson(3, FLOW, -60, 80), + flowJson( + FLOW, + { x: 80, y: 0 }, + [ + { x: 22.5, y: 0 }, + { x: 80, y: 0 }, + { x: 80, y: 80 }, + { x: -60, y: 80 }, + ], + { source: 1, sink: 3 }, + ), + ]); + const base = flowOf(view, FLOW); + const t = flowTerminals(base, byUidOf(view)); + const g = routeEnd(base, 'sink', freeTerminal({ x: -40, y: 10 }, cloudOf(view, 3)), { fixed: t.source }); + expect(fmtFlow(g.flow)).toBe('valve(-31.25,10) (-22.5,10)@1 (-40,10)@3'); + expect(strictReport(applyGeometry(view, g), [FLOW])).toBe(''); + }); + + it('a tail may take three bends when the bend budget allows', () => { + // Base: the bracket (four bends) into a cloud, dragged 2.5px left of the first + // riser. Preserving the first corner, no one- or two-bend tail meets the + // minima; up, right, up, left (each at its minimum) does, and keeps four bends. + const view = loadView([ + stockJson(1, 0, 0), + cloudJson(3, FLOW, 260, 0), + flowJson(FLOW, { x: 52.5, y: 0 }, [...SHAPES.bracket], { source: 1, sink: 3 }), + ]); + const base = flowOf(view, FLOW); + const t = flowTerminals(base, byUidOf(view)); + const g = routeEnd(base, 'sink', freeTerminal({ x: 50, y: -70 }, cloudOf(view, 3)), { fixed: t.source }); + expect(fmtFlow(g.flow)).toBe('valve(52.5,0) (22.5,0)@1 (52.5,0) (52.5,-10) (65.5,-10) (65.5,-70) (50,-70)@3'); + expect(strictReport(applyGeometry(view, g), [FLOW])).toBe(''); + }); + + it('a moving stock keeps its base face through a three-bend tail within one bend of an off-base face', () => { + // Base: a hook (four bends) from stock 1's right face into stock 2's left face. + // Stock 2 is dragged under the first corner: preserving it, the top face takes + // a two-bend tail, and the base left face a three-bend tail. Stickiness reaches + // one bend past the best, so the three-bend tier is generated and the left face + // kept. + const view = hook(); + const base = flowOf(view, FLOW); + const t = flowTerminals(base, byUidOf(view)); + const B = stockOf(view, 2); + const moved = { ...B, x: 80, y: 20 }; + const g = routeEnd(base, 'sink', stockTerminal(moved, base.points[5], base.points[4], B), { fixed: t.source }); + expect(fmtFlow(g.flow)).toBe('valve(60,0) (22.5,0)@1 (60,0) (60,-10) (42,-10) (42,20) (57.5,20)@2'); + expect(strictReport(applyGeometry(view, g, [moved]), [FLOW])).toBe(''); + }); + + it('a tail running back alongside its preserved prefix within MIN_SEGMENT is refused', () => { + // Base: the hook; stock 2 is dragged beside the first riser (x = 60). The + // terminals leave no room, so G3 excuses short segments. Preserving two + // corners, the shortest tail steps out 4.5px and runs back up beside the + // riser: valid, but under the minima, so routeEnd would not accept it and + // would release to an L. Refusing it inside the search lets the tail that + // steps out a full MIN_SEGMENT win instead, and that one is accepted. + const view = hook(); + const base = flowOf(view, FLOW); + const t = flowTerminals(base, byUidOf(view)); + const B = stockOf(view, 2); + const moved = { ...B, x: 32, y: 44 }; + const g = routeEnd(base, 'sink', stockTerminal(moved, base.points[5], base.points[4], B), { fixed: t.source }); + expect(fmtFlow(g.flow)).toBe('valve(60,0) (22.5,0)@1 (60,0) (60,60) (70,60) (70,44) (54.5,44)@2'); + expect(strictReport(applyGeometry(view, g, [moved]), [FLOW])).toBe(''); + }); +}); + +/** A hook (four bends) from stock 1's right face into stock 2's left face. */ +function hook(): StockFlowView { + const points = [ + { x: 22.5, y: 0 }, + { x: 60, y: 0 }, + { x: 60, y: 60 }, + { x: -60, y: 60 }, + { x: -60, y: -60 }, + { x: 100, y: -60 }, + ]; + return loadView([ + stockJson(1, 0, 0), + stockJson(2, 122.5, -60), + flowJson(FLOW, points[1], points, { source: 1, sink: 2 }), + ]); +} + +describe('routeEnd k = 0: pinned, then released', () => { + const A = stockJson(1, 0, 0); + const view = loadView([ + A, + cloudJson(12, FLOW, 200, 0), + flowJson( + FLOW, + { x: 100, y: 0 }, + [ + { x: 22.5, y: 0 }, + { x: 200, y: 0 }, + ], + { source: 1, sink: 12 }, + ), + ]); + const base = flowOf(view, FLOW); + const t = flowTerminals(base, byUidOf(view)); + + const ROWS: ReadonlyArray<{ readonly dy: number; readonly want: string; readonly why: string }> = [ + { dy: 0, want: 'R @0', why: 'identity' }, + { dy: 5, want: 'R @5', why: 'pinned has nothing valid (riser under MIN_SEGMENT); released, the endpoint slides' }, + { dy: 12, want: 'RDR @0', why: 'pinned Z valid: the fixed endpoint stays put, the riser hugs the cloud' }, + { dy: 40, want: 'RD @0', why: 'pinned L valid' }, + ]; + for (const row of ROWS) { + it(`cloud dragged down ${row.dy}: ${row.why}`, () => { + const cloud = cloudOf(view, 12); + const g = routeEnd(base, 'sink', freeTerminal({ x: 200, y: row.dy }, cloud), { fixed: t.source }); + const out = applyGeometry(view, g); + expect(strictReport(out, [FLOW])).toBe(''); + expect(`${directions(g.flow.points)} @${g.flow.points[0].y}`).toBe(row.want); + }); + } + + it('the pinned Z riser sits MIN_SINK_SEGMENT short of the cloud', () => { + const g = routeEnd(base, 'sink', freeTerminal({ x: 200, y: 12 }, cloudOf(view, 12)), { fixed: t.source }); + expect(g.flow.points[1].x).toBeCloseTo(200 - 15.5, 9); + }); + + it('a pinned route under minima that G3 excuses is not accepted; released, the endpoint slides', () => { + // The cloud 7.5px beyond the face and 10px below the base line: the terminals + // leave no room, so every minimum is excused and the pinned L (7.5 across, 10 + // down) is valid. Accepting it would flip to the pinned L at the room + // boundary; released, the least-bend route is the straight. + const g = routeEnd(base, 'sink', freeTerminal({ x: 30, y: 10 }, cloudOf(view, 12)), { fixed: t.source }); + expect(fmtFlow(g.flow)).toBe('valve(26.25,10) (22.5,10)@1 (30,10)@12'); + expect(strictReport(applyGeometry(view, g), [FLOW])).toBe(''); + }); +}); + +describe('totality with overlapping stocks', () => { + const OFFSETS = [0, 5, 20, 45, 54.9] as const; + for (const kind of FACES) { + for (const dx of OFFSETS) { + it(`moving stock (base ${kind}) ${dx}px from the fixed stock returns a route holding G1-G5`, () => { + const face = kind; + const exit = rotate({ x: 22.5, y: 0 }, face); + const far = rotate({ x: 200, y: 0 }, face); + const view = loadView([ + stockJson(1, 0, 0), + stockJson(2, far.x + rotate({ x: 22.5, y: 0 }, face).x, far.y + rotate({ x: 22.5, y: 0 }, face).y), + flowJson(FLOW, { x: (exit.x + far.x) / 2, y: (exit.y + far.y) / 2 }, [exit, far], { source: 1, sink: 2 }), + ]); + const base = flowOf(view, FLOW); + const t = flowTerminals(base, byUidOf(view)); + const B = stockOf(view, 2); + const A = stockOf(view, 1); + const moved = { ...A, x: B.x + dx, y: B.y }; + const g = routeEnd(base, 'source', stockTerminal(moved, base.points[0], base.points[1], A), { + fixed: t.sink, + }); + const out = applyGeometry(view, g, [moved]); + const all = [g.flow.x, g.flow.y, ...g.flow.points.flatMap((p) => [p.x, p.y])]; + expect(all.every(Number.isFinite)).toBe(true); + // The bodies, each inflated by MIN_SEGMENT, overlap: G6 and the G3 + // minima are exempt, and everything else must still hold. + expect(`${fmtFlow(g.flow)}\n${strictReport(out, [FLOW])}`).toBe(`${fmtFlow(g.flow)}\n`); + }); + } + } +}); diff --git a/src/diagram/tests/flow-geometry-sweeps.test.ts b/src/diagram/tests/flow-geometry-sweeps.test.ts new file mode 100644 index 000000000..f19bf25ca --- /dev/null +++ b/src/diagram/tests/flow-geometry-sweeps.test.ts @@ -0,0 +1,482 @@ +// Copyright 2026 The Simlin Authors. All rights reserved. +// Use of this source code is governed by the Apache License, +// Version 2.0, that can be found in the LICENSE file. + +// E3 continuity over the most common real gestures, in 1px frames. +// +// Rows come from a gesture enumeration: +// - a stock dragged in a circle around its straight flow's cloud (radius x the +// flow's end at the stock); +// - a stock dragged in a line across its flow's axis at several distances; +// - a stock dragged in a circle around the other stock of a stock-stock flow; +// - a stock carrying three flows on one face, dragged in a circle and in a line; +// - a cloud dragged in a circle around its stock (face x the flow's end at the +// stock x radius), and along and across the flow's axis; +// - a stock end detached and dragged around the stock it left (end x radius); +// - a flow created from a stock and dragged around it (radius). +// Every frame routes as the planner will: routeEnd against the base view for +// the flows on a dragged stock and for a dragged or detached end, route for a +// creation draft. +// +// Each row pins, per flow: +// - the number of route shape transitions (a shape is the faces at both ends +// plus the segment directions), at the implementation's measured count; +// - that no frame violates the strict invariants; +// - the shape flip-flops, A -> B -> A within FLIP_WINDOW px of pointer travel. +// A row expects no flip-flops unless it lists them. The budgets are measured, +// not derived: a circle visits each face transition twice, so 12 per revolution +// is what the geometry needs, and a count above a row's budget is flicker: a +// shape change no feasibility change forces. +// +// The listed flip-flops are of one kind, which the last describe pins over a +// band of radii. The route partitions pointer positions into regions whose edges +// are G3 minima, and a region two minima bound has a corner. A circle whose +// radius is just past that corner's distance clips it: it passes a few px +// through the region and returns to the region it came from. A pure function +// with no previous frame (so no hysteresis) cannot avoid that on such a path. + +import { describe, it, expect } from '@rstest/core'; + +import type { CloudViewElement, FlowViewElement, StockFlowView, UID } from '@simlin/core/datamodel'; + +import { + faceOfEndpoint, + FACES, + flowTerminals, + freeTerminal, + route, + routeEnd, + stockTerminal, + type Face, + type FlowEnd, +} from '../flow-geometry'; +import { + applyGeometry, + byUidOf, + cloudJson, + directions, + flowJson, + flowOf, + loadView, + stockJson, + stockOf, + strictReport, + type Pt, +} from './support/flow-geometry-fixtures'; + +const FLIP_WINDOW = 20; + +interface SweepResult { + readonly transitions: Map; + readonly violations: string[]; + readonly flips: string[]; +} + +/** One frame of a gesture: the planned view and the flows it routed. */ +interface Frame { + readonly view: StockFlowView; + readonly routed: readonly UID[]; +} + +function shapeOf(f: FlowViewElement, view: StockFlowView): string { + const byUid = byUidOf(view); + const n = f.points.length; + const face = (i: number, j: number): string => { + const el = byUid.get(f.points[i].attachedToUid ?? -1); + return el?.type === 'stock' ? (faceOfEndpoint(el, f.points[i], f.points[j]) ?? '?') : '-'; + }; + return `${face(0, 1)} ${directions(f.points)} ${face(n - 1, n - 2)}`; +} + +function sweep(positions: readonly Pt[], frame: (p: Pt) => Frame, check = true): SweepResult { + const transitions = new Map(); + const history = new Map>(); + const violations: string[] = []; + const flips: string[] = []; + let travel = 0; + for (let k = 0; k < positions.length; k++) { + const p = positions[k]; + if (k > 0) travel += Math.hypot(p.x - positions[k - 1].x, p.y - positions[k - 1].y); + const { view, routed } = frame(p); + for (const uid of routed) { + const f = view.elements.find((e) => e.uid === uid) as FlowViewElement; + const report = check ? strictReport(view, [uid]) : ''; + if (report !== '' && violations.length < 3) violations.push(`frame ${k}: ${report}`); + const shape = shapeOf(f, view); + const h = history.get(uid) ?? []; + if (h.length === 0 || h[h.length - 1].shape !== shape) { + if (h.length > 0) transitions.set(uid, (transitions.get(uid) ?? 0) + 1); + // A -> B -> A: the shape two changes back, re-entered within the window. + if (h.length >= 2 && h[h.length - 2].shape === shape && travel - h[h.length - 1].travel <= FLIP_WINDOW) { + flips.push(`flow ${uid} frame ${k}: ${h[h.length - 2].shape} -> ${h[h.length - 1].shape} -> ${shape}`); + } + h.push({ shape, travel }); + } + history.set(uid, h); + } + } + return { transitions, violations, flips }; +} + +/** Drag a stock: every flow on it is re-routed with routeEnd against the base view. */ +function stockSweep(view0: StockFlowView, stockUid: UID, positions: readonly Pt[]): SweepResult { + const base = stockOf(view0, stockUid); + const byUid = byUidOf(view0); + const flows = view0.elements.filter( + (e): e is FlowViewElement => + e.type === 'flow' && + (e.points[0].attachedToUid === stockUid || e.points[e.points.length - 1].attachedToUid === stockUid), + ); + return sweep(positions, (p) => { + const moved = { ...base, x: p.x, y: p.y }; + const routed = flows.map((f) => { + const n = f.points.length; + const t = flowTerminals(f, byUid); + const sourceOn = f.points[0].attachedToUid === stockUid; + const endpoint = sourceOn ? f.points[0] : f.points[n - 1]; + const adjacent = sourceOn ? f.points[1] : f.points[n - 2]; + return routeEnd(f, sourceOn ? 'source' : 'sink', stockTerminal(moved, endpoint, adjacent, base), { + fixed: sourceOn ? t.sink : t.source, + }).flow; + }); + const view = applyGeometry(view0, { flow: routed[0], clouds: [] }, [moved, ...routed.slice(1)]); + return { view, routed: flows.map((f) => f.uid) }; + }); +} + +/** Drag a flow's cloud end: routeEnd against the base view with the other end fixed. */ +function cloudSweep(view0: StockFlowView, flowUid: UID, positions: readonly Pt[], check = true): SweepResult { + const f = flowOf(view0, flowUid); + const t = flowTerminals(f, byUidOf(view0)); + const end: FlowEnd = t.sink.kind === 'free' ? 'sink' : 'source'; + const own = end === 'sink' ? t.sink : t.source; + const cloud = own.kind === 'free' ? own.cloud : undefined; + const fixed = end === 'sink' ? t.source : t.sink; + return sweep( + positions, + (p) => { + const g = routeEnd(f, end, freeTerminal(p, cloud), { fixed }); + return { view: applyGeometry(view0, g), routed: [flowUid] }; + }, + check, + ); +} + +function circle(center: Pt, r: number, startAngle: number): Pt[] { + const count = Math.ceil(2 * Math.PI * r); + return Array.from({ length: count + 1 }, (_, i) => { + const th = startAngle + (2 * Math.PI * i) / count; + return { x: center.x + r * Math.cos(th), y: center.y + r * Math.sin(th) }; + }); +} + +function line(from: Pt, to: Pt): Pt[] { + const count = Math.ceil(Math.hypot(to.x - from.x, to.y - from.y)); + return Array.from({ length: count + 1 }, (_, i) => ({ + x: from.x + ((to.x - from.x) * i) / count, + y: from.y + ((to.y - from.y) * i) / count, + })); +} + +/** Detach a flow's stock end: routeEnd to a new cloud at the pointer, the other end fixed. */ +function detachSweep(view0: StockFlowView, flowUid: UID, end: FlowEnd, positions: readonly Pt[]): SweepResult { + const f = flowOf(view0, flowUid); + const t = flowTerminals(f, byUidOf(view0)); + const detached: CloudViewElement = { + type: 'cloud', + uid: 20, + flowUid, + x: 0, + y: 0, + isZeroRadius: false, + ident: undefined, + }; + const fixed = end === 'source' ? t.sink : t.source; + return sweep(positions, (p) => { + const g = routeEnd(f, end, freeTerminal(p, detached), { fixed }); + const added = g.clouds.some((c) => c.uid === detached.uid) ? [] : [{ ...detached, x: p.x, y: p.y }]; + return { view: applyGeometry(view0, g, added), routed: [flowUid] }; + }); +} + +/** Create a flow from stock 1 of `view0` to a new cloud at the pointer. */ +function createSweep(view0: StockFlowView, positions: readonly Pt[]): SweepResult { + const draft = flowOf(loadView([flowJson(10, { x: 0, y: 0 }, [], {})]), 10); + const sink: CloudViewElement = { + type: 'cloud', + uid: 20, + flowUid: 10, + x: 0, + y: 0, + isZeroRadius: false, + ident: undefined, + }; + const source = stockTerminal(stockOf(view0, 1)); + return sweep(positions, (p) => ({ + view: applyGeometry(view0, route(source, freeTerminal(p, sink), { flow: draft })), + routed: [10], + })); +} + +/** A stock at `stock` with one straight flow out of (source) or into (sink) its right face from a cloud at `cloud`. */ +function straightScene(stock: Pt, cloud: Pt, end: FlowEnd): StockFlowView { + const face = { x: stock.x + 22.5, y: stock.y }; + const points = end === 'source' ? [face, cloud] : [cloud, face]; + return loadView([ + stockJson(1, stock.x, stock.y), + cloudJson(3, 10, cloud.x, cloud.y), + flowJson( + 10, + { x: (face.x + cloud.x) / 2, y: cloud.y }, + points, + end === 'source' ? { source: 1, sink: 3 } : { source: 3, sink: 1 }, + ), + ]); +} + +const STOCK = { x: 500, y: 500 }; +const OUTWARD: Record = { + left: { x: -1, y: 0 }, + right: { x: 1, y: 0 }, + top: { x: 0, y: -1 }, + bottom: { x: 0, y: 1 }, +}; + +/** Stock 1 at STOCK with one straight flow out of (source) or into (sink) the middle of `face`, from a cloud r from its center. */ +function faceScene(face: Face, end: FlowEnd, r: number): StockFlowView { + const o = OUTWARD[face]; + const endpoint = { x: STOCK.x + o.x * 22.5, y: STOCK.y + o.y * 17.5 }; + const cloud = { x: STOCK.x + o.x * r, y: STOCK.y + o.y * r }; + return loadView([ + stockJson(1, STOCK.x, STOCK.y), + cloudJson(3, 10, cloud.x, cloud.y), + flowJson( + 10, + { x: (endpoint.x + cloud.x) / 2, y: (endpoint.y + cloud.y) / 2 }, + end === 'source' ? [endpoint, cloud] : [cloud, endpoint], + end === 'source' ? { source: 1, sink: 3 } : { source: 3, sink: 1 }, + ), + ]); +} + +function cloudCircle(face: Face, end: FlowEnd, r: number, check = true): SweepResult { + const o = OUTWARD[face]; + return cloudSweep(faceScene(face, end, r), 10, circle(STOCK, r, Math.atan2(o.y, o.x)), check); +} + +/** + * The measured count for a cloud circling its stock. With the flow as source + * (the cloud is its sink) the pinned Z between the sliding endpoint and the L + * (routeEnd k = 0 in -route) adds two transitions per revolution once the + * circle reaches the Z's region; at r = 45 the top and bottom faces clip its + * corner instead, which is two more (the listed flip-flops). + */ +function cloudCircleBudget(face: Face, end: FlowEnd, r: number): number { + if (end === 'sink') return 12; + if (r > 45) return 14; + return face === 'top' || face === 'bottom' ? 16 : 12; +} + +/** A cloud circling its stock's flip-flops where the circle clips a corner (see the last describe). */ +const CLIPPED_CORNERS: Readonly> = { + 'top source 45': [ + 'flow 10 frame 14: top U - -> top URU - -> top U -', + 'flow 10 frame 273: top U - -> top ULU - -> top U -', + ], + 'bottom source 45': [ + 'flow 10 frame 14: bottom D - -> bottom DLD - -> bottom D -', + 'flow 10 frame 273: bottom D - -> bottom DRD - -> bottom D -', + ], +}; + +interface Row { + readonly name: string; + readonly run: () => SweepResult; + /** Transitions per flow uid, pinned at the measured count: more is flicker, fewer an improvement to re-pin. */ + readonly budget: Readonly>; + /** The flip-flops the row shows, each a clipped corner (see the last describe). */ + readonly flips?: readonly string[]; +} + +const CLOUD = { x: 500, y: 500 }; + +const ROWS: Row[] = [ + ...[45, 100, 160, 250].flatMap((r) => + (['source', 'sink'] as const).map((end) => ({ + name: `a stock circling its cloud, r = ${r}, flow as ${end}`, + run: () => stockSweep(straightScene({ x: CLOUD.x - r, y: CLOUD.y }, CLOUD, end), 1, circle(CLOUD, r, Math.PI)), + budget: { 10: 12 }, + })), + ), + ...[40, 160].map((dx) => ({ + name: `a stock dragged down across its flow's axis, ${dx}px from the cloud`, + run: () => { + const s = { x: CLOUD.x - dx, y: CLOUD.y }; + return stockSweep(straightScene(s, CLOUD, 'source'), 1, line(s, { x: s.x, y: s.y + 200 })); + }, + budget: { 10: 1 }, + })), + ...[200, 100].map((r) => ({ + name: `a stock circling the other stock of a stock-stock flow, r = ${r}`, + run: () => + stockSweep( + loadView([ + stockJson(1, 500 - r, 400), + stockJson(2, 500, 400), + flowJson( + 10, + { x: 500 - r / 2, y: 400 }, + [ + { x: 522.5 - r, y: 400 }, + { x: 477.5, y: 400 }, + ], + { source: 1, sink: 2 }, + ), + ]), + 1, + circle({ x: 500, y: 400 }, r, Math.PI), + ), + budget: { 10: 14 }, + })), + ...(['circle', 'line'] as const).map((kind) => ({ + name: `three flows on one face, the stock dragged in a ${kind}`, + run: () => + stockSweep( + loadView([ + stockJson(1, 400, 400), + cloudJson(3, 10, 560, 390), + cloudJson(4, 11, 520, 300), + cloudJson(5, 12, 540, 480), + flowJson( + 10, + { x: 480, y: 390 }, + [ + { x: 422.5, y: 390 }, + { x: 560, y: 390 }, + ], + { source: 1, sink: 3 }, + ), + flowJson( + 11, + { x: 470, y: 400 }, + [ + { x: 422.5, y: 400 }, + { x: 520, y: 400 }, + { x: 520, y: 300 }, + ], + { source: 1, sink: 4 }, + ), + flowJson( + 12, + { x: 470, y: 410 }, + [ + { x: 422.5, y: 410 }, + { x: 540, y: 410 }, + { x: 540, y: 480 }, + ], + { source: 1, sink: 5 }, + ), + ]), + 1, + kind === 'circle' ? circle({ x: 500, y: 400 }, 100, Math.PI) : line({ x: 400, y: 250 }, { x: 400, y: 550 }), + ), + budget: kind === 'circle' ? { 10: 12, 11: 8, 12: 11 } : { 10: 2, 11: 2, 12: 2 }, + })), + ...FACES.flatMap((face) => + (['source', 'sink'] as const).flatMap((end) => + [45, 100, 160, 250].map((r) => ({ + name: `a cloud circling its stock, ${face} face, flow as ${end}, r = ${r}`, + run: () => cloudCircle(face, end, r), + budget: { 10: cloudCircleBudget(face, end, r) }, + flips: CLIPPED_CORNERS[`${face} ${end} ${r}`], + })), + ), + ), + ...( + [ + ['along its axis, out and back in', line({ x: 400, y: 0 }, { x: 40, y: 0 }), 0], + ['across its axis at the cloud, 200px from the stock', line({ x: 200, y: -150 }, { x: 200, y: 150 }), 4], + ['across its axis 60px from the stock', line({ x: 60, y: -150 }, { x: 60, y: 150 }), 4], + ['across its axis behind the stock', line({ x: -80, y: -150 }, { x: -80, y: 150 }), 2], + ] as const + ).map(([what, positions, budget]) => ({ + name: `a straight flow's cloud dragged ${what}`, + run: () => cloudSweep(straightScene({ x: 0, y: 0 }, { x: 200, y: 0 }, 'source'), 10, positions), + budget: { 10: budget }, + })), + ...(['source', 'sink'] as const).flatMap((end) => + [45, 100, 160].map((r) => ({ + name: `a stock end detached and dragged around its stock, flow as ${end}, r = ${r}`, + run: () => + detachSweep(straightScene({ x: 0, y: 0 }, { x: 200, y: 0 }, end), 10, end, circle({ x: 0, y: 0 }, r, 0)), + budget: { 10: r === 160 ? 12 : 11 }, + })), + ), + ...[45, 100, 160, 250].map((r) => ({ + name: `a flow created from a stock and dragged around it, r = ${r}`, + run: () => createSweep(loadView([stockJson(1, 0, 0)]), circle({ x: 0, y: 0 }, r, 0)), + budget: { 10: 12 }, + })), +]; + +describe('E3 continuity sweeps in 1px frames', () => { + for (const row of ROWS) { + it(row.name, () => { + const result = row.run(); + expect(result.violations.join('\n')).toBe(''); + expect(result.flips.join('\n')).toBe((row.flips ?? []).join('\n')); + for (const [uid, budget] of Object.entries(row.budget)) { + expect(`flow ${uid}: ${result.transitions.get(Number(uid)) ?? 0} transitions`).toBe( + `flow ${uid}: ${budget} transitions`, + ); + } + }); + } +}); + +describe('flip-flops only where a circle clips the corner of a region two G3 minima bound', () => { + // Pinned literals, independent of the core: MIN_SEGMENT, MIN_SINK_SEGMENT, + // CORNER_CLEARANCE and the stock's half extents (bottom and right mirror top + // and left). + // + // In face coordinates (along the face from the endpoint, out from the face), + // the pinned routes routeEnd accepts occupy regions bounded by minima. With the + // flow as source (the cloud is its sink) the L needs along >= MIN_SINK_SEGMENT + // and out >= MIN_SEGMENT, and the Z along >= MIN_SEGMENT and out >= both + // minima. With the flow as sink the L needs along >= MIN_SEGMENT and out >= + // MIN_SINK_SEGMENT, and no Z shows, since the L is valid wherever it would be. + // A circle flip-flops when its radius is at least a corner's distance from the + // stock center and it leaves the region through the out edge while the + // released straight is still there: along <= the face's slide range (and, for + // the Z, before the L's edge). + const m = 10; + const s = 15.5; + const clearance = 3; + function bands(face: 'top' | 'left', end: FlowEnd): Array { + const toFace = face === 'top' ? 17.5 : 22.5; + const slide = (face === 'top' ? 22.5 : 17.5) - clearance; + const band = (along: number, out: number, until: number): readonly [number, number] => [ + Math.hypot(toFace + out, along), + Math.hypot(toFace + out, until), + ]; + const all = end === 'source' ? [band(s, m, slide), band(m, m + s, Math.min(s, slide))] : [band(m, s, slide)]; + return all.filter(([lo, hi]) => hi > lo); + } + for (const face of ['top', 'left'] as const) { + for (const end of ['source', 'sink'] as const) { + it(`a cloud circling its stock, ${face} face, flow as ${end}, r in [28, 52]`, () => { + const flipping: number[] = []; + for (let r = 28; r <= 52; r += 0.25) { + if (cloudCircle(face, end, r, false).flips.length > 0) flipping.push(r); + } + const expected = bands(face, end); + const outside = flipping.filter((r) => !expected.some(([lo, hi]) => r >= lo && r < hi)); + const empty = expected.filter(([lo, hi]) => !flipping.some((r) => r >= lo && r < hi)); + const fmt = ([lo, hi]: readonly [number, number]): string => `[${lo.toFixed(2)}, ${hi.toFixed(2)})`; + expect( + `flipping outside the bands: ${outside.join(' ')}; bands with no flip: ${empty.map(fmt).join(' ')}`, + ).toBe('flipping outside the bands: ; bands with no flip: '); + }); + } + } +}); diff --git a/src/diagram/tests/flow-geometry-terminal.test.ts b/src/diagram/tests/flow-geometry-terminal.test.ts new file mode 100644 index 000000000..3cd091f8d --- /dev/null +++ b/src/diagram/tests/flow-geometry-terminal.test.ts @@ -0,0 +1,247 @@ +// Copyright 2026 The Simlin Authors. All rights reserved. +// Use of this source code is governed by the Apache License, +// Version 2.0, that can be found in the LICENSE file. + +// Tests of face attachment and terminals (flow-geometry/terminal.ts): one row +// per face of faceAttachment, each arm of faceOfEndpoint and +// nearestFaceAttachment (including the corner tie-break toward the face the +// adjacent segment is perpendicular to), and each arm of flowTerminals and +// stockTerminal. Views go through the production loader. + +import { describe, it, expect } from '@rstest/core'; + +import type { StockFlowView } from '@simlin/core/datamodel'; + +import { StockHeight, StockWidth } from '../drawing/default'; +import { + FACES, + faceAttachment, + faceOfEndpoint, + facePoint, + flowTerminals, + freeTerminal, + nearestFaceAttachment, + stockTerminal, + stubTip, + type Face, +} from '../flow-geometry'; +import { + byUidOf, + cloudJson, + cloudOf, + flowJson, + flowOf, + loadView, + stockJson, + stockOf, +} from './support/flow-geometry-fixtures'; + +const CLEARANCE = 3; + +describe('faceAttachment', () => { + const stock = { x: 100, y: 100 }; + const lr = { lo: 100 - (StockHeight / 2 - CLEARANCE), hi: 100 + (StockHeight / 2 - CLEARANCE) }; + const tb = { lo: 100 - (StockWidth / 2 - CLEARANCE), hi: 100 + (StockWidth / 2 - CLEARANCE) }; + const ROWS: Readonly> = { + left: ['x', -1, 100 - StockWidth / 2, lr.lo, lr.hi], + right: ['x', 1, 100 + StockWidth / 2, lr.lo, lr.hi], + top: ['y', -1, 100 - StockHeight / 2, tb.lo, tb.hi], + bottom: ['y', 1, 100 + StockHeight / 2, tb.lo, tb.hi], + }; + for (const face of FACES) { + it(`${face}: normal, sign, plane, the valid range minus CORNER_CLEARANCE, and its stub tips`, () => { + const att = faceAttachment(stock, face); + expect([att.normal, att.sign, att.plane, att.lo, att.hi]).toEqual(ROWS[face]); + expect(stubTip(att, 10)).toBe(ROWS[face][2] + ROWS[face][1] * 10); + // facePoint clamps into [lo, hi] along the face. + const far = facePoint(att, 1000); + expect(att.normal === 'x' ? [far.x, far.y] : [far.y, far.x]).toEqual([ROWS[face][2], ROWS[face][4]]); + }); + } +}); + +describe('faceOfEndpoint', () => { + const s = { x: 0, y: 0 }; + const ROWS: ReadonlyArray<{ + readonly name: string; + readonly p: [number, number]; + readonly adjacent?: [number, number]; + readonly want: Face | undefined; + }> = [ + { name: 'right face', p: [22.5, 5], want: 'right' }, + { name: 'left face', p: [-22.5, 5], want: 'left' }, + { name: 'top face', p: [5, -17.5], want: 'top' }, + { name: 'bottom face', p: [5, 17.5], want: 'bottom' }, + { name: 'a corner with a vertical stub: the bottom face', p: [22.5, 17.5], adjacent: [22.5, 40], want: 'bottom' }, + { name: 'a corner with a horizontal stub: the right face', p: [22.5, 17.5], adjacent: [40, 17.5], want: 'right' }, + { name: 'a corner with no adjacent point: the left/right face first', p: [22.5, 17.5], want: 'right' }, + { name: 'off every face (outside)', p: [30, 0], want: undefined }, + { name: 'past a corner along the face line', p: [22.5, 18], want: undefined }, + { name: 'inside the body', p: [0, 0], want: undefined }, + ]; + for (const row of ROWS) { + it(row.name, () => { + const adjacent = row.adjacent === undefined ? undefined : { x: row.adjacent[0], y: row.adjacent[1] }; + expect(faceOfEndpoint(s, { x: row.p[0], y: row.p[1] }, adjacent)).toBe(row.want); + }); + } +}); + +describe('nearestFaceAttachment', () => { + const s = { x: 0, y: 0 }; + const ROWS: ReadonlyArray<{ + readonly name: string; + readonly p: [number, number]; + readonly adjacent?: [number, number]; + readonly want: [Face, number, number]; + }> = [ + { name: 'outside the right face: its nearest valid point', p: [40, 0], want: ['right', 22.5, 0] }, + { name: 'above the top face', p: [5, -40], want: ['top', 5, -17.5] }, + { + name: 'a corner, no adjacent: equidistant faces keep the first (right)', + p: [22.5, 17.5], + want: ['right', 22.5, 14.5], + }, + { + name: 'a corner, vertical adjacent: the tie goes to the bottom face', + p: [22.5, 17.5], + adjacent: [22.5, 80], + want: ['bottom', 19.5, 17.5], + }, + { + name: 'a corner, horizontal adjacent: the tie goes to the right face', + p: [22.5, 17.5], + adjacent: [80, 17.5], + want: ['right', 22.5, 14.5], + }, + { + name: 'a clear winner is not overridden by the adjacent axis', + p: [40, 2], + adjacent: [40, 80], + want: ['right', 22.5, 2], + }, + ]; + for (const row of ROWS) { + it(row.name, () => { + const adjacent = row.adjacent === undefined ? undefined : { x: row.adjacent[0], y: row.adjacent[1] }; + const got = nearestFaceAttachment(s, { x: row.p[0], y: row.p[1] }, adjacent); + expect([got.face, got.point.x, got.point.y]).toEqual(row.want); + }); + } +}); + +describe('flowTerminals and stockTerminal', () => { + const view: StockFlowView = loadView([ + stockJson(1, 100, 100), + cloudJson(3, 10, 300, 100), + { type: 'aux', uid: 4, name: 'a4', x: 0, y: 0 }, + flowJson( + 10, + { x: 200, y: 95 }, + [ + { x: 122.5, y: 95 }, + { x: 300, y: 95 }, + ], + { source: 1, sink: 3 }, + ), + flowJson( + 20, + { x: 50, y: 50 }, + [ + { x: 0, y: 50 }, + { x: 100, y: 50 }, + ], + { source: 99, sink: 4 }, + ), + flowJson( + 30, + { x: 50, y: 70 }, + [ + { x: 0, y: 70 }, + { x: 100, y: 70 }, + ], + {}, + ), + // A corner endpoint (bottom-right of stock 1) whose stub runs down: the bottom face. + flowJson( + 40, + { x: 122.5, y: 160 }, + [ + { x: 122.5, y: 117.5 }, + { x: 122.5, y: 200 }, + ], + { source: 1 }, + ), + // The same corner with a stub running right: the right face. + flowJson( + 50, + { x: 160, y: 117.5 }, + [ + { x: 122.5, y: 117.5 }, + { x: 200, y: 117.5 }, + ], + { source: 1 }, + ), + ]); + const byUid = byUidOf(view); + + it('a stock endpoint: the base face and the offset from the face center', () => { + expect(flowTerminals(flowOf(view, 10), byUid).source).toEqual({ + kind: 'stock', + stock: stockOf(view, 1), + face: 'right', + offset: -5, + }); + }); + it('a stock endpoint at a corner: the face its adjacent segment leaves perpendicular to', () => { + expect(flowTerminals(flowOf(view, 40), byUid).source).toEqual({ + kind: 'stock', + stock: stockOf(view, 1), + face: 'bottom', + offset: 22.5, + }); + expect(flowTerminals(flowOf(view, 50), byUid).source).toEqual({ + kind: 'stock', + stock: stockOf(view, 1), + face: 'right', + offset: 17.5, + }); + }); + it('a cloud endpoint: the cloud, at its center', () => { + expect(flowTerminals(flowOf(view, 10), byUid).sink).toEqual({ + kind: 'free', + point: { x: 300, y: 100 }, + cloud: cloudOf(view, 3), + }); + }); + it('a dangling or non-stock/cloud attachment: a free point with no cloud', () => { + const t = flowTerminals(flowOf(view, 20), byUid); + expect([t.source, t.sink]).toEqual([ + { kind: 'free', point: { x: 0, y: 50 } }, + { kind: 'free', point: { x: 100, y: 50 } }, + ]); + }); + it('an unattached endpoint: a free point with no cloud', () => { + expect(flowTerminals(flowOf(view, 30), byUid).source).toEqual({ kind: 'free', point: { x: 0, y: 70 } }); + }); + it('stockTerminal reads the base attachment against the base position when a stock moves', () => { + const stock = stockOf(view, 1); + const moved = { ...stock, x: 400, y: 400 }; + expect(stockTerminal(moved, { x: 122.5, y: 95 }, { x: 300, y: 95 }, stock)).toEqual({ + kind: 'stock', + stock: moved, + face: 'right', + offset: -5, + }); + expect(stockTerminal(stock)).toEqual({ kind: 'stock', stock }); + expect(stockTerminal(stock, { x: NaN, y: 0 })).toEqual({ kind: 'stock', stock }); + // An off-face endpoint attaches to the nearest face. + expect(stockTerminal(stock, { x: 140, y: 101 })).toEqual({ kind: 'stock', stock, face: 'right', offset: 1 }); + }); + it('freeTerminal copies only the coordinates', () => { + expect(freeTerminal({ x: 1, y: 2, extra: 3 } as { x: number; y: number })).toEqual({ + kind: 'free', + point: { x: 1, y: 2 }, + }); + }); +}); diff --git a/src/diagram/tests/flow-geometry-validity.test.ts b/src/diagram/tests/flow-geometry-validity.test.ts new file mode 100644 index 000000000..1f35567ae --- /dev/null +++ b/src/diagram/tests/flow-geometry-validity.test.ts @@ -0,0 +1,174 @@ +// Copyright 2026 The Simlin Authors. All rights reserved. +// Use of this source code is governed by the Apache License, +// Version 2.0, that can be found in the LICENSE file. + +// Tests of path classification (flow-geometry/validity.ts, through flowFault): +// one row per RouteFault arm, and rows at each precondition boundary: G3's +// "terminals leave room" at the exact touching distance and just inside it, and +// G6's "bodies apart" with a crossing path when the bodies overlap. + +import { describe, it, expect } from '@rstest/core'; + +import type { JsonViewElement } from '@simlin/engine'; + +import { flowFault, flowTerminals, type RouteFault } from '../flow-geometry'; +import { byUidOf, cloudJson, flowJson, flowOf, loadView, stockJson, type Pt } from './support/flow-geometry-fixtures'; + +interface Row { + readonly name: string; + readonly elements: JsonViewElement[]; + readonly points: Pt[]; + readonly attach: { readonly source?: number; readonly sink?: number }; + readonly stocks?: Pt[]; + readonly want: RouteFault; +} + +const S1 = stockJson(1, 100, 100); +const cloudAt = (p: Pt): JsonViewElement => cloudJson(3, 10, p.x, p.y); + +// Stock A at the origin and B to its right at `bx`: A's right face is x = 22.5 +// and B's left face x = bx - 22.5. A inflated by MIN_SEGMENT (maxX 32.5) and B +// inflated by MIN_SINK_SEGMENT (minX bx - 38) touch exactly at bx = 70.5. +function zBetween(bx: number): Pick { + return { + elements: [stockJson(1, 0, 0), stockJson(2, bx, 5)], + points: [ + { x: 22.5, y: 0 }, + { x: 40, y: 0 }, + { x: 40, y: 5 }, + { x: bx - 22.5, y: 5 }, + ], + attach: { source: 1, sink: 2 }, + }; +} + +const ROWS: readonly Row[] = [ + { + name: 'none: a straight stock -> cloud flow', + elements: [S1, cloudAt({ x: 300, y: 100 })], + points: [ + { x: 122.5, y: 100 }, + { x: 300, y: 100 }, + ], + attach: { source: 1, sink: 3 }, + want: 'none', + }, + { + name: 'crossing: the cloud sits inside another stock of the view', + elements: [S1, cloudAt({ x: 300, y: 100 })], + points: [ + { x: 122.5, y: 100 }, + { x: 300, y: 100 }, + ], + attach: { source: 1, sink: 3 }, + stocks: [{ x: 305, y: 100 }], + want: 'crossing', + }, + { + name: 'crossing: a path through its own terminal stock, bodies apart', + elements: [S1, cloudAt({ x: 40, y: 160 })], + points: [ + { x: 122.5, y: 100 }, + { x: 140, y: 100 }, + { x: 140, y: 110 }, + { x: 40, y: 110 }, + { x: 40, y: 160 }, + ], + attach: { source: 1, sink: 3 }, + want: 'crossing', + }, + { + name: 'short: a final segment under MIN_SINK_SEGMENT', + elements: [S1, cloudAt({ x: 200, y: 110 })], + points: [ + { x: 122.5, y: 100 }, + { x: 200, y: 100 }, + { x: 200, y: 110 }, + ], + attach: { source: 1, sink: 3 }, + want: 'short', + }, + { + name: 'structure: a diagonal segment', + elements: [S1, cloudAt({ x: 300, y: 110 })], + points: [ + { x: 122.5, y: 100 }, + { x: 300, y: 110 }, + ], + attach: { source: 1, sink: 3 }, + want: 'structure', + }, + { + name: 'structure: two consecutive collinear segments', + elements: [S1, cloudAt({ x: 300, y: 100 })], + points: [ + { x: 122.5, y: 100 }, + { x: 200, y: 100 }, + { x: 300, y: 100 }, + ], + attach: { source: 1, sink: 3 }, + want: 'structure', + }, + { + name: 'structure: leaving the face inward', + elements: [S1, cloudAt({ x: 60, y: 100 })], + points: [ + { x: 122.5, y: 100 }, + { x: 60, y: 100 }, + ], + attach: { source: 1, sink: 3 }, + want: 'structure', + }, + { + name: 'structure: an endpoint inside the corner clearance', + elements: [S1, cloudAt({ x: 300, y: 116 })], + points: [ + { x: 122.5, y: 116 }, + { x: 300, y: 116 }, + ], + attach: { source: 1, sink: 3 }, + want: 'structure', + }, + { + name: 'structure: a stock endpoint on no face', + elements: [S1, cloudAt({ x: 300, y: 100 })], + points: [ + { x: 130, y: 100 }, + { x: 300, y: 100 }, + ], + attach: { source: 1, sink: 3 }, + want: 'structure', + }, + { name: 'G3 room at exactly touching bodies: the minima apply (short)', ...zBetween(70.5), want: 'short' }, + { name: 'G3 room just inside touching: the minima are excused (none)', ...zBetween(70.49), want: 'none' }, + { + name: 'G6 with overlapping bodies: a crossing path is best effort, not a fault (none)', + // A cloud inside its own flow's stock: the bodies overlap, so neither the + // crossing nor the G3 minima are faults. + elements: [S1, cloudAt({ x: 105, y: 100 })], + points: [ + { x: 122.5, y: 100 }, + { x: 140, y: 100 }, + { x: 140, y: 120 }, + { x: 105, y: 120 }, + { x: 105, y: 100 }, + ], + attach: { source: 1, sink: 3 }, + want: 'none', + }, +]; + +describe('flowFault', () => { + for (const row of ROWS) { + it(row.name, () => { + const valve = row.points[0]; + const view = loadView([...row.elements, flowJson(10, valve, row.points, row.attach)]); + const f = flowOf(view, 10); + expect(flowFault(f, flowTerminals(f, byUidOf(view)), row.stocks)).toBe(row.want); + }); + } + + it('covers every RouteFault', () => { + expect([...new Set(ROWS.map((r) => r.want))].sort()).toEqual(['crossing', 'none', 'short', 'structure']); + }); +}); diff --git a/src/diagram/tests/flow-geometry-valve.test.ts b/src/diagram/tests/flow-geometry-valve.test.ts new file mode 100644 index 000000000..15a4043e7 --- /dev/null +++ b/src/diagram/tests/flow-geometry-valve.test.ts @@ -0,0 +1,303 @@ +// Copyright 2026 The Simlin Authors. All rights reserved. +// Use of this source code is governed by the Apache License, +// Version 2.0, that can be found in the LICENSE file. + +// Tests of paths and the valve (flow-geometry/path.ts): normalize, the valve +// policy (arc position from an end, clamped, margin applied once), slideValve +// and translate. + +import { describe, it, expect } from '@rstest/core'; + +import type { FlowViewElement, Point } from '@simlin/core/datamodel'; + +import { + flowTerminals, + normalize, + placeValve, + routeEnd, + slideValve, + stockTerminal, + translate, + valveDistance, +} from '../flow-geometry'; +import { + arcOf, + byUidOf, + cloudJson, + flowJson, + flowOf, + loadView, + pathLength, + stockJson, + stockOf, + type Pt, +} from './support/flow-geometry-fixtures'; + +const P = (x: number, y: number, attachedToUid?: number): Point => ({ x, y, attachedToUid }); + +describe('normalize', () => { + const ROWS: ReadonlyArray<{ readonly name: string; readonly input: Point[]; readonly want: Pt[] }> = [ + { + name: 'two points untouched', + input: [P(0, 0, 1), P(0, 0, 2)], + want: [ + { x: 0, y: 0 }, + { x: 0, y: 0 }, + ], + }, + { + name: 'valid L untouched', + input: [P(0, 0, 1), P(10, 0), P(10, 10, 2)], + want: [ + { x: 0, y: 0 }, + { x: 10, y: 0 }, + { x: 10, y: 10 }, + ], + }, + { + name: 'collinear interior removed', + input: [P(0, 0, 1), P(5, 0), P(10, 0, 2)], + want: [ + { x: 0, y: 0 }, + { x: 10, y: 0 }, + ], + }, + { + name: 'zero-length interior removed', + input: [P(0, 0, 1), P(0, 0), P(10, 0), P(10, 10, 2)], + want: [ + { x: 0, y: 0 }, + { x: 10, y: 0 }, + { x: 10, y: 10 }, + ], + }, + { + name: 'zero-length at the end removed', + input: [P(0, 0, 1), P(10, 0), P(10, 0, 2)], + want: [ + { x: 0, y: 0 }, + { x: 10, y: 0 }, + ], + }, + { + name: 'removal that exposes collinearity repeats to a fixed point', + input: [P(0, 0, 1), P(10, 0), P(10, 0), P(10, 10), P(10, 10), P(20, 10), P(30, 10, 2)], + want: [ + { x: 0, y: 0 }, + { x: 10, y: 0 }, + { x: 10, y: 10 }, + { x: 30, y: 10 }, + ], + }, + { + name: 'a collapsed riser merges its neighbors', + input: [P(0, 0, 1), P(10, 0), P(10, 0), P(20, 0, 2)], + want: [ + { x: 0, y: 0 }, + { x: 20, y: 0 }, + ], + }, + { + name: 'within GEOMETRY_EPSILON counts as equal', + input: [P(0, 0, 1), P(10, 1e-7), P(20, 0, 2)], + want: [ + { x: 0, y: 0 }, + { x: 20, y: 0 }, + ], + }, + ]; + for (const row of ROWS) { + it(row.name, () => { + const out = normalize(row.input); + expect(out.map((p) => ({ x: p.x, y: p.y }))).toEqual(row.want); + expect(out[0].attachedToUid).toBe(row.input[0].attachedToUid); + expect(out[out.length - 1].attachedToUid).toBe(row.input[row.input.length - 1].attachedToUid); + expect(normalize(out)).toEqual(out); + }); + } +}); + +describe('placeValve and valveDistance', () => { + const L: Pt[] = [ + { x: 0, y: 0 }, + { x: 100, y: 0 }, + { x: 100, y: 50 }, + ]; + const ROWS: ReadonlyArray<{ + readonly name: string; + readonly points: Pt[]; + readonly from: 'source' | 'sink'; + readonly d: number | undefined; + readonly want: Pt; + }> = [ + { name: 'from the source', points: L, from: 'source', d: 40, want: { x: 40, y: 0 } }, + { name: 'from the sink, crossing the corner', points: L, from: 'sink', d: 70, want: { x: 80, y: 0 } }, + { name: 'clamped to the path, then the margin', points: L, from: 'source', d: 1000, want: { x: 100, y: 40 } }, + { name: 'inside the margin at the start', points: L, from: 'source', d: 3, want: { x: 10, y: 0 } }, + { name: 'no base distance: the midpoint', points: L, from: 'source', d: undefined, want: { x: 75, y: 0 } }, + { + name: 'a path shorter than two margins: the midpoint', + points: [ + { x: 0, y: 0 }, + { x: 18, y: 0 }, + ], + from: 'source', + d: 2, + want: { x: 9, y: 0 }, + }, + { + name: 'exactly two margins long: the single valid position', + points: [ + { x: 0, y: 0 }, + { x: 20, y: 0 }, + ], + from: 'sink', + d: 0, + want: { x: 10, y: 0 }, + }, + ]; + for (const row of ROWS) { + it(row.name, () => { + const got = placeValve(row.points, row.from, row.d); + expect(got.x).toBeCloseTo(row.want.x, 9); + expect(got.y).toBeCloseTo(row.want.y, 9); + }); + } + + it('valveDistance measures from either end, never applies the margin, and is undefined with no length', () => { + expect(valveDistance(L, { x: 3, y: 1 }, 'source')).toBeCloseTo(3, 9); + expect(valveDistance(L, { x: 40, y: 3 }, 'sink')).toBeCloseTo(110, 9); + expect( + valveDistance( + [ + { x: 5, y: 5 }, + { x: 5, y: 5 }, + ], + { x: 5, y: 5 }, + 'source', + ), + ).toBeUndefined(); + expect(valveDistance(L, { x: NaN, y: 0 }, 'source')).toBeUndefined(); + }); + + it('the margin is applied once: a base valve inside the margin is measured raw, so growing the far end does not push it inward', () => { + // An imported valve 3px from the source endpoint; the source stock moves 5px + // away, the sink is fixed. From the sink the valve is 174.5 along; on the new + // 182.5 path that is 8 from the source, clamped to the margin: 10. Measuring + // with the margin would place it 15 from the source. + const view = loadView([ + stockJson(1, 0, 0), + cloudJson(3, 10, 200, 0), + flowJson( + 10, + { x: 25.5, y: 0 }, + [ + { x: 22.5, y: 0 }, + { x: 200, y: 0 }, + ], + { source: 1, sink: 3 }, + ), + ]); + const f = flowOf(view, 10); + const t = flowTerminals(f, byUidOf(view)); + const A = stockOf(view, 1); + const moved = { ...A, x: -5 }; + const g = routeEnd(f, 'source', stockTerminal(moved, f.points[0], f.points[1], A), { fixed: t.sink }); + expect(g.flow.points.map((p) => [p.x, p.y])).toEqual([ + [17.5, 0], + [200, 0], + ]); + expect(g.flow.x).toBeCloseTo(27.5, 9); + }); +}); + +describe('slideValve', () => { + const base: FlowViewElement = flowOf( + loadView([ + cloudJson(11, 10, 0, 0), + cloudJson(12, 10, 100, 100), + flowJson( + 10, + { x: 50, y: 0 }, + [ + { x: 0, y: 0 }, + { x: 100, y: 0 }, + { x: 100, y: 100 }, + ], + { source: 11, sink: 12 }, + ), + ]), + 10, + ); + const ROWS: ReadonlyArray<{ readonly name: string; readonly delta: Pt; readonly want: Pt }> = [ + { name: 'along its segment', delta: { x: 20, y: 0 }, want: { x: 70, y: 0 } }, + { name: 'backwards along its segment', delta: { x: -20, y: 0 }, want: { x: 30, y: 0 } }, + { name: 'perpendicular to its segment: no movement', delta: { x: 0, y: 30 }, want: { x: 50, y: 0 } }, + { name: 'past the corner, continuing down the next segment', delta: { x: 80, y: 80 }, want: { x: 100, y: 30 } }, + { + name: 'into the corner with the next segment pointing back: rests at the corner', + delta: { x: 80, y: -80 }, + want: { x: 100, y: 0 }, + }, + { name: 'clamped to the margin at the end of the path', delta: { x: 500, y: 500 }, want: { x: 100, y: 90 } }, + { name: 'clamped to the margin at the start of the path', delta: { x: -500, y: 0 }, want: { x: 10, y: 0 } }, + { name: 'a non-finite delta: unchanged', delta: { x: NaN, y: 0 }, want: { x: 50, y: 0 } }, + ]; + for (const row of ROWS) { + it(row.name, () => { + const got = slideValve(base, row.delta); + expect(got.points).toBe(base.points); + expect(got.x).toBeCloseTo(row.want.x, 9); + expect(got.y).toBeCloseTo(row.want.y, 9); + }); + } + + it('starting on a corner, it takes the segment the delta moves it along', () => { + const onCorner = { ...base, x: 100, y: 0 }; + expect([slideValve(onCorner, { x: 0, y: 25 }).x, slideValve(onCorner, { x: 0, y: 25 }).y]).toEqual([100, 25]); + expect([slideValve(onCorner, { x: -25, y: 0 }).x, slideValve(onCorner, { x: -25, y: 0 }).y]).toEqual([75, 0]); + }); + + it('is continuous in the delta (the time left, not the vector left, carries past a corner)', () => { + let worst = 0; + for (let dx = -120; dx <= 120; dx += 1) { + let prev: FlowViewElement | undefined; + for (let dy = -120; dy <= 120; dy += 1) { + const got = slideValve(base, { x: dx, y: dy }); + if (prev !== undefined) { + worst = Math.max(worst, Math.abs(arcOf(base.points, got) - arcOf(base.points, prev))); + } + prev = got; + } + } + expect(worst).toBeLessThanOrEqual(1 + 1e-9); + }); +}); + +describe('translate', () => { + const f = flowOf( + loadView([ + cloudJson(11, 10, 0, 0), + cloudJson(12, 10, 100, 0), + flowJson( + 10, + { x: 50, y: 0 }, + [ + { x: 0, y: 0 }, + { x: 100, y: 0 }, + ], + { source: 11, sink: 12 }, + ), + ]), + 10, + ); + it('moves every point and the valve, keeping attachments', () => { + const g = translate(f, { x: 7, y: -3 }); + expect([g.x, g.y]).toEqual([57, -3]); + expect(g.points).toEqual([P(7, -3, 11), P(107, -3, 12)]); + expect(pathLength(g.points)).toBe(pathLength(f.points)); + }); + it('a non-finite delta returns the flow unchanged', () => { + expect(translate(f, { x: Infinity, y: 0 })).toBe(f); + }); +}); diff --git a/src/diagram/tests/flow-invariants-corpus.test.ts b/src/diagram/tests/flow-invariants-corpus.test.ts new file mode 100644 index 000000000..366c6b0c0 --- /dev/null +++ b/src/diagram/tests/flow-invariants-corpus.test.ts @@ -0,0 +1,136 @@ +// Copyright 2026 The Simlin Authors. All rights reserved. +// Use of this source code is governed by the Apache License, +// Version 2.0, that can be found in the LICENSE file. + +// The flow checker over real imported models, through the real WASM engine. +// +// What this establishes: for a handful of small corpus models covering the +// producers the corpus measurement grouped (Stella, Vensim via xmutil, native +// MDL, Simlin-authored XMILE), the first stock-flow view as the editor loads it +// (engine import -> serializeJson -> projectFromJson) passes tolerant mode and +// holds M1/M3, and the engine's import normalization leaves every flow holding +// strict geometry. Each file's raw geometry carries a violation shape the corpus +// measurement recorded, so a row fails if the importer stops normalizing it +// (M1/M3 are committed-view checks for the elements an edit routes or creates; a +// few other imports carry violations the editor still accepts, e.g. an orphan +// cloud in Query_file.mdl, lookup-only variables in test_lookups*.xmile). +// +// What it does not establish: anything about the other ~480 corpus models, that +// strict mode detects violations at all (flow-invariants.test.ts mutates a valid +// fixture once per arm), or that routing these flows keeps strict geometry +// (gesture-planner-corpus.test.ts). + +import { describe, it, expect, beforeAll } from '@rstest/core'; + +import * as fs from 'fs'; +import * as path from 'path'; + +import type { Model } from '@simlin/core/datamodel'; + +import { + describeWithEngine, + editorModel, + engineSuiteMode, + loadEngine, + WASM_PATH, + type EngineModule, +} from './support/engine'; +import { checkFlowInvariants, formatFlowViolations } from './support/flow-invariants'; +import { checkKindAgreement, checkReferentialIntegrity, formatViewViolations } from './support/view-invariants'; + +const repoRoot = path.join(__dirname, '..', '..', '..'); + +interface CorpusRow { + readonly file: string; + readonly producer: string; + /** The violation shape the file's raw geometry carries, which the import must normalize. */ + readonly raw: string; +} + +const CORPUS: readonly CorpusRow[] = [ + { file: 'test/test-models/samples/teacup/teacup.stmx', producer: 'Stella', raw: 'none (the control)' }, + { + file: 'test/land_model/land_model.stmx', + producer: 'Stella', + raw: 'endpoints on stock corners and off the 45x35 face', + }, + { + file: 'test/test-models/tests/abs/test_abs.xmile', + producer: 'Vensim via xmutil', + raw: 'a cloud a few px off its endpoint', + }, + { + file: 'test/test-models/samples/Roessler_Chaos/roessler_chaos.mdl', + producer: 'Vensim MDL', + raw: 'clouds a few px off their endpoints', + }, + { + file: 'test/test-models/tests/subscript_mapping_simple/test_subscript_mapping_simple.mdl', + producer: 'Vensim MDL', + raw: 'a fallback flow with unattached ends and its valve off the path', + }, + { file: 'test/cross_element_ltm/cross_element.stmx', producer: 'Simlin XMILE', raw: 'an endpoint off its face' }, +]; + +describeWithEngine('flow invariants over imported corpus models', () => { + let engine: EngineModule; + + beforeAll(async () => { + engine = await loadEngine(); + }); + + async function loadMainModel(file: string): Promise { + const bytes = new Uint8Array(fs.readFileSync(path.join(repoRoot, file))); + const project = file.endsWith('.mdl') ? await engine.Project.openVensim(bytes) : await engine.Project.open(bytes); + try { + return await editorModel(project); + } finally { + await project.dispose(); + } + } + + for (const row of CORPUS) { + it(`${row.producer}: ${row.file} (raw: ${row.raw})`, async () => { + const model = await loadMainModel(row.file); + const view = model.views[0]; + expect(view.elements.some((e) => e.type === 'flow')).toBe(true); + + const tolerant = checkFlowInvariants(view, { mode: 'tolerant' }); + const structural = [...checkKindAgreement(view, model.variables), ...checkReferentialIntegrity(view)]; + expect(formatFlowViolations(tolerant) + formatViewViolations(structural)).toBe(''); + expect(formatFlowViolations(checkFlowInvariants(view, { mode: 'strict' }))).toBe(''); + }); + } +}); + +describe('corpus fixtures', () => { + it('every corpus model exists', () => { + for (const row of CORPUS) { + expect(`${row.file}: ${fs.existsSync(path.join(repoRoot, row.file))}`).toBe(`${row.file}: true`); + } + }); +}); + +describe('engine-backed suites', () => { + // Every arm of the run-or-skip decision: a built engine always runs, and CI + // (GitHub Actions sets CI=true) runs even without one, so a missing build fails. + const CASES: ReadonlyArray<{ built: boolean; ci: string | undefined; mode: 'run' | 'skip' }> = [ + { built: true, ci: undefined, mode: 'run' }, + { built: true, ci: 'true', mode: 'run' }, + { built: false, ci: 'true', mode: 'run' }, + { built: false, ci: '1', mode: 'run' }, + { built: false, ci: undefined, mode: 'skip' }, + { built: false, ci: '', mode: 'skip' }, + { built: false, ci: 'false', mode: 'skip' }, + ]; + for (const c of CASES) { + it(`built=${c.built} CI=${JSON.stringify(c.ci)}: ${c.mode}`, () => { + expect(engineSuiteMode(c.built, c.ci)).toBe(c.mode); + }); + } + + it('this process uses that decision', () => { + const mode = engineSuiteMode(fs.existsSync(WASM_PATH), process.env.CI); + expect(describeWithEngine).toBe(mode === 'run' ? describe : describe.skip); + }); +}); diff --git a/src/diagram/tests/flow-invariants.test.ts b/src/diagram/tests/flow-invariants.test.ts new file mode 100644 index 000000000..31dee335f --- /dev/null +++ b/src/diagram/tests/flow-invariants.test.ts @@ -0,0 +1,1282 @@ +// Copyright 2026 The Simlin Authors. All rights reserved. +// Use of this source code is governed by the Apache License, +// Version 2.0, that can be found in the LICENSE file. + +// Tests of the committed flow-invariant checker (tests/support/flow-invariants.ts). +// +// Rows are derived from FLOW_ARMS: every arm has one row with a minimal valid +// fixture that passes strict mode and a minimal mutation strict mode reports. +// Each mutation's EXPECTED arm list is exact (a multiset), so a row pins what +// its arm reports AND that no other arm fires on it; where a mutation cannot +// violate one arm without another (an inward stub necessarily crosses the +// body), the row lists both and says why. Each row also states, literally, what +// tolerant mode reports on the same mutation. +// +// The boundary table then pins each threshold at the value the plan names: +// equality on both sides of every `<` / `<=` choice, sub-pixel defects against +// the epsilon, the room and precondition inflations at the exact touching +// distance, and one row per face or terminal wherever an arm has a per-face or +// per-terminal branch. +// +// Fixtures go through the production loader (`modelFromJson`), with stock +// endpoints pinned on faces and clouds at their endpoints as the engine stores +// them. The one exception is the non-finite row: the loader repairs NaN on load +// (issue #818), so a NaN can only exist in memory, where a planner computing it +// would put it; that row mutates the loaded view. + +import { describe, it, expect } from '@rstest/core'; + +import type { JsonModel, JsonViewElement } from '@simlin/engine'; +import { modelFromJson, type FlowViewElement, type StockFlowView, type UID } from '@simlin/core/datamodel'; + +import { + ALL_FLOW_ARMS, + checkFlowInvariants, + CORNER_CLEARANCE, + formatFlowViolations, + GEOMETRY_EPSILON, + MIN_SEGMENT, + MIN_SINK_SEGMENT, + PIPE_SPACING, + TOLERANT_FLOW_ARMS, + VALVE_CLAMP_MARGIN, + type FlowArm, + type FlowViolation, +} from './support/flow-invariants'; + +type P = [number, number] | [number, number, number]; + +function loadView(elements: JsonViewElement[]): StockFlowView { + return modelFromJson({ name: 'main', views: [{ elements }] } as JsonModel).views[0]; +} + +const stock = (uid: UID, x: number, y: number): JsonViewElement => ({ type: 'stock', uid, name: `s${uid}`, x, y }); +const aux = (uid: UID, x: number, y: number): JsonViewElement => ({ type: 'aux', uid, name: `a${uid}`, x, y }); +const cloud = (uid: UID, flowUid: UID, x: number, y: number): JsonViewElement => ({ + type: 'cloud', + uid, + flowUid, + x, + y, +}); +function flow(uid: UID, valve: [number, number], points: P[]): JsonViewElement { + return { + type: 'flow', + uid, + name: `f${uid}`, + x: valve[0], + y: valve[1], + points: points.map(([x, y, attachedToUid]) => (attachedToUid === undefined ? { x, y } : { x, y, attachedToUid })), + }; +} + +// Stock S1 at (100,100): faces x = 77.5 / 122.5, y = 82.5 / 117.5. +const S1 = stock(1, 100, 100); +// Straight flow out of S1's right face into a cloud, valve mid-path. +const STRAIGHT = [ + S1, + flow( + 2, + [200, 100], + [ + [122.5, 100, 1], + [300, 100, 3], + ], + ), + cloud(3, 2, 300, 100), +]; +// L out of S1's right face turning down into a cloud. +const L_DOWN = [ + S1, + flow( + 2, + [200, 175], + [ + [122.5, 100, 1], + [200, 100], + [200, 250, 3], + ], + ), + cloud(3, 2, 200, 250), +]; +// Cloud-to-cloud straight flow. +const CLOUD_STRAIGHT = [ + flow( + 10, + [50, 0], + [ + [0, 0, 11], + [100, 0, 12], + ], + ), + cloud(11, 10, 0, 0), + cloud(12, 10, 100, 0), +]; + +function sorted(arms: readonly string[]): string[] { + return [...arms].sort(); +} + +function arms(violations: readonly FlowViolation[]): string[] { + return sorted(violations.map((v) => v.arm)); +} + +interface ArmRow { + readonly arm: FlowArm; + readonly valid: JsonViewElement[]; + readonly broken: () => StockFlowView; + /** Every arm strict mode reports on `broken`, one entry per occurrence. */ + readonly expected: readonly FlowArm[]; + /** Every arm tolerant mode reports on `broken`, one entry per occurrence. */ + readonly tolerant: readonly FlowArm[]; + readonly uid: UID; + readonly numbers?: Readonly>; + readonly why?: string; +} + +const ROWS: readonly ArmRow[] = [ + { + arm: 'G1.minPoints', + valid: STRAIGHT, + broken: () => loadView([S1, flow(2, [122.5, 100], [[122.5, 100, 1]]), cloud(3, 2, 300, 100)]), + expected: ['G1.minPoints'], + tolerant: ['G1.minPoints'], + uid: 2, + numbers: { points: 1 }, + }, + { + arm: 'G1.nonFinite', + valid: STRAIGHT, + broken: () => mapFlow(loadView(STRAIGHT), 2, (f) => ({ ...f, x: NaN })), + expected: ['G1.nonFinite'], + tolerant: ['G1.nonFinite'], + uid: 2, + numbers: { count: 1 }, + }, + { + arm: 'G1.unattachedEndpoint', + valid: STRAIGHT, + broken: () => + loadView([ + S1, + flow( + 2, + [200, 100], + [ + [122.5, 100, 1], + [300, 100], + ], + ), + cloud(3, 2, 300, 100), + ]), + expected: ['G1.unattachedEndpoint'], + tolerant: [], + uid: 2, + numbers: { endIndex: 1 }, + }, + { + arm: 'G1.danglingAttachment', + valid: STRAIGHT, + broken: () => + loadView([ + S1, + flow( + 2, + [200, 100], + [ + [122.5, 100, 1], + [300, 100, 99], + ], + ), + cloud(3, 2, 300, 100), + ]), + expected: ['G1.danglingAttachment'], + tolerant: ['G1.danglingAttachment'], + uid: 2, + numbers: { endIndex: 1, attachedToUid: 99 }, + }, + { + arm: 'G1.attachmentKind', + valid: [...STRAIGHT, aux(4, 400, 400)], + broken: () => + loadView([ + S1, + flow( + 2, + [200, 100], + [ + [122.5, 100, 1], + [300, 100, 4], + ], + ), + cloud(3, 2, 300, 100), + aux(4, 300, 100), + ]), + expected: ['G1.attachmentKind'], + tolerant: ['G1.attachmentKind'], + uid: 2, + numbers: { attachedToUid: 4 }, + }, + { + arm: 'G1.foreignCloud', + valid: [...STRAIGHT, ...CLOUD_STRAIGHT], + broken: () => + loadView([ + S1, + flow( + 2, + [200, 100], + [ + [122.5, 100, 1], + [300, 100, 3], + ], + ), + cloud(3, 10, 300, 100), + ...CLOUD_STRAIGHT, + ]), + expected: ['G1.foreignCloud'], + tolerant: ['G1.foreignCloud'], + uid: 2, + numbers: { cloudUid: 3, cloudFlowUid: 10 }, + }, + { + arm: 'G1.interiorAttached', + valid: L_DOWN, + broken: () => + loadView([ + S1, + flow( + 2, + [200, 175], + [ + [122.5, 100, 1], + [200, 100, 1], + [200, 250, 3], + ], + ), + cloud(3, 2, 200, 250), + ]), + expected: ['G1.interiorAttached'], + tolerant: ['G1.interiorAttached'], + uid: 2, + numbers: { index: 1, attachedToUid: 1 }, + }, + { + arm: 'G1.nonPositiveUid', + valid: [...STRAIGHT, aux(4, 400, 400)], + // -3 is the Canvas's faux-target sentinel, the kind of uid that must never + // reach a committed view. + broken: () => loadView([...STRAIGHT, aux(-3, 400, 400)]), + expected: ['G1.nonPositiveUid'], + tolerant: [], + uid: -3, + numbers: { uid: -3 }, + }, + { + arm: 'G1.sourceIsSink', + valid: [ + S1, + stock(4, 300, 100), + flow( + 2, + [200, 100], + [ + [122.5, 100, 1], + [277.5, 100, 4], + ], + ), + ], + // Out of S1's right face, around, and back into its top face. The terminals + // overlap themselves, so the G3 minima and G6 are exempt; only G1 reports. + broken: () => + loadView([ + S1, + flow( + 2, + [160, 70], + [ + [122.5, 100, 1], + [160, 100], + [160, 40], + [100, 40], + [100, 82.5, 1], + ], + ), + ]), + expected: ['G1.sourceIsSink'], + tolerant: [], + uid: 2, + numbers: { attachedToUid: 1 }, + }, + { + arm: 'G2.diagonal', + valid: CLOUD_STRAIGHT, + broken: () => + loadView([ + flow( + 10, + [50, 1.5], + [ + [0, 0, 11], + [100, 3, 12], + ], + ), + cloud(11, 10, 0, 0), + cloud(12, 10, 100, 3), + ]), + expected: ['G2.diagonal'], + tolerant: [], + uid: 10, + numbers: { segment: 0, dx: 100, dy: 3 }, + }, + { + arm: 'G3.zeroLength', + valid: L_DOWN, + broken: () => + loadView([ + S1, + flow( + 2, + [200, 175], + [ + [122.5, 100, 1], + [200, 100], + [200, 100], + [200, 250, 3], + ], + ), + cloud(3, 2, 200, 250), + ]), + expected: ['G3.zeroLength'], + tolerant: [], + uid: 2, + numbers: { segment: 1 }, + }, + { + arm: 'G3.collinear', + valid: STRAIGHT, + broken: () => + loadView([ + S1, + flow( + 2, + [200, 100], + [ + [122.5, 100, 1], + [200, 100], + [300, 100, 3], + ], + ), + cloud(3, 2, 300, 100), + ]), + expected: ['G3.collinear'], + tolerant: [], + uid: 2, + numbers: { segment: 1 }, + }, + { + arm: 'G3.shortStub', + valid: [ + flow( + 10, + [50, 175], + [ + [0, 100, 11], + [50, 100], + [50, 250, 12], + ], + ), + cloud(11, 10, 0, 100), + cloud(12, 10, 50, 250), + ], + broken: () => + loadView([ + flow( + 10, + [5, 175], + [ + [0, 100, 11], + [5, 100], + [5, 250, 12], + ], + ), + cloud(11, 10, 0, 100), + cloud(12, 10, 5, 250), + ]), + expected: ['G3.shortStub'], + tolerant: [], + uid: 10, + numbers: { segment: 0, length: 5, minimum: 10 }, + }, + { + arm: 'G3.shortRiser', + valid: [ + flow( + 10, + [50, 100], + [ + [0, 100, 11], + [100, 100], + [100, 150], + [200, 150, 12], + ], + ), + cloud(11, 10, 0, 100), + cloud(12, 10, 200, 150), + ], + broken: () => + loadView([ + flow( + 10, + [50, 100], + [ + [0, 100, 11], + [100, 100], + [100, 104], + [200, 104, 12], + ], + ), + cloud(11, 10, 0, 100), + cloud(12, 10, 200, 104), + ]), + expected: ['G3.shortRiser'], + tolerant: [], + uid: 10, + numbers: { segment: 1, length: 4, minimum: 10 }, + }, + { + arm: 'G3.shortSink', + valid: [ + S1, + flow( + 2, + [160, 100], + [ + [122.5, 100, 1], + [200, 100], + [200, 250, 3], + ], + ), + cloud(3, 2, 200, 250), + ], + broken: () => + loadView([ + S1, + flow( + 2, + [160, 100], + [ + [122.5, 100, 1], + [200, 100], + [200, 110, 3], + ], + ), + cloud(3, 2, 200, 110), + ]), + expected: ['G3.shortSink'], + tolerant: [], + uid: 2, + numbers: { segment: 1, length: 10, minimum: 15.5 }, + }, + { + arm: 'G4.offFace', + valid: STRAIGHT, + broken: () => + loadView([ + S1, + flow( + 2, + [200, 100], + [ + [130, 100, 1], + [300, 100, 3], + ], + ), + cloud(3, 2, 300, 100), + ]), + expected: ['G4.offFace'], + tolerant: [], + uid: 2, + numbers: { endIndex: 0, dx: 30, dy: 0 }, + }, + { + arm: 'G4.cornerClearance', + valid: STRAIGHT, + broken: () => + loadView([ + S1, + flow( + 2, + [200, 83.5], + [ + [122.5, 83.5, 1], + [300, 83.5, 3], + ], + ), + cloud(3, 2, 300, 83.5), + ]), + expected: ['G4.cornerClearance'], + tolerant: [], + uid: 2, + numbers: { endIndex: 0, clearance: 1, minimum: 3 }, + }, + { + arm: 'G5.notPerpendicular', + valid: [ + S1, + flow( + 2, + [160, 100], + [ + [122.5, 100, 1], + [200, 100], + [200, 40, 3], + ], + ), + cloud(3, 2, 200, 40), + ], + broken: () => + loadView([ + S1, + flow( + 2, + [200, 40], + [ + [122.5, 100, 1], + [122.5, 40], + [300, 40, 3], + ], + ), + cloud(3, 2, 300, 40), + ]), + expected: ['G5.notPerpendicular'], + tolerant: [], + uid: 2, + numbers: { endIndex: 0, dx: 0, dy: -60 }, + }, + { + arm: 'G5.inward', + valid: STRAIGHT, + broken: () => + loadView([ + S1, + flow( + 2, + [110, 200], + [ + [122.5, 100, 1], + [110, 100], + [110, 300, 3], + ], + ), + cloud(3, 2, 110, 300), + ]), + expected: ['G5.inward', 'G6.segmentThroughTerminal', 'G6.segmentThroughTerminal'], + tolerant: [], + uid: 2, + numbers: { endIndex: 0, dx: -12.5, dy: 0 }, + why: 'a stub pointing into the stock necessarily crosses its body (segments 0 and 1), so G6 fires too', + }, + { + arm: 'G6.segmentThroughTerminal', + valid: [ + S1, + flow( + 2, + [60, 200], + [ + [122.5, 110, 1], + [140, 110], + [140, 60], + [60, 60], + [60, 300, 3], + ], + ), + cloud(3, 2, 60, 300), + ], + broken: () => + loadView([ + S1, + flow( + 2, + [60, 200], + [ + [122.5, 110, 1], + [140, 110], + [140, 95], + [60, 95], + [60, 300, 3], + ], + ), + cloud(3, 2, 60, 300), + ]), + expected: ['G6.segmentThroughTerminal'], + tolerant: [], + uid: 2, + numbers: { segment: 2, stockUid: 1 }, + }, + { + arm: 'G6.cloudInsideStock', + valid: [ + S1, + flow( + 10, + [300, 100], + [ + [200, 100, 11], + [400, 100, 12], + ], + ), + cloud(11, 10, 200, 100), + cloud(12, 10, 400, 100), + ], + broken: () => + loadView([ + S1, + flow( + 10, + [200, 100], + [ + [100, 100, 11], + [300, 100, 12], + ], + ), + cloud(11, 10, 100, 100), + cloud(12, 10, 300, 100), + ]), + expected: ['G6.cloudInsideStock'], + tolerant: [], + uid: 10, + numbers: { cloudUid: 11, stockUid: 1 }, + }, + { + arm: 'G7.cloudOffEndpoint', + valid: STRAIGHT, + broken: () => + loadView([ + S1, + flow( + 2, + [200, 100], + [ + [122.5, 100, 1], + [300, 100, 3], + ], + ), + cloud(3, 2, 305, 100), + ]), + expected: ['G7.cloudOffEndpoint'], + tolerant: [], + uid: 2, + numbers: { endIndex: 1, distance: 5 }, + }, + { + arm: 'G8.valveOffPath', + valid: STRAIGHT, + broken: () => + loadView([ + S1, + flow( + 2, + [200, 105], + [ + [122.5, 100, 1], + [300, 100, 3], + ], + ), + cloud(3, 2, 300, 100), + ]), + expected: ['G8.valveOffPath'], + tolerant: [], + uid: 2, + numbers: { distance: 5 }, + }, + { + arm: 'G8.valveMargin', + valid: STRAIGHT, + broken: () => + loadView([ + S1, + flow( + 2, + [295, 100], + [ + [122.5, 100, 1], + [300, 100, 3], + ], + ), + cloud(3, 2, 300, 100), + ]), + expected: ['G8.valveMargin'], + tolerant: [], + uid: 2, + numbers: { arcPosition: 172.5, pathLength: 177.5, margin: 10 }, + }, +]; + +function mapFlow(view: StockFlowView, uid: UID, fn: (f: FlowViewElement) => FlowViewElement): StockFlowView { + return { ...view, elements: view.elements.map((el) => (el.type === 'flow' && el.uid === uid ? fn(el) : el)) }; +} + +describe('units', () => { + // The checker's constants are imported by the generator and the rows, so a + // changed constant would move every consumer together; this pins the plan's + // literals independently of all of them. + it('match the plan', () => { + expect({ + CORNER_CLEARANCE, + MIN_SEGMENT, + VALVE_CLAMP_MARGIN, + MIN_SINK_SEGMENT, + PIPE_SPACING, + GEOMETRY_EPSILON, + }).toEqual({ + CORNER_CLEARANCE: 3, + MIN_SEGMENT: 10, + VALVE_CLAMP_MARGIN: 10, + MIN_SINK_SEGMENT: 15.5, + PIPE_SPACING: 10, + GEOMETRY_EPSILON: 1e-6, + }); + }); +}); + +describe('checkFlowInvariants arm table', () => { + it('has exactly one row per enumerated arm', () => { + expect(sorted(ROWS.map((r) => r.arm))).toEqual(sorted(ALL_FLOW_ARMS)); + }); + + for (const row of ROWS) { + describe(row.arm, () => { + it('valid fixture passes strict mode', () => { + const violations = checkFlowInvariants(loadView(row.valid), { mode: 'strict' }); + expect(formatFlowViolations(violations)).toBe(''); + }); + + it(`mutation reports exactly ${row.expected.join(', ')}${row.why ? ` (${row.why})` : ''}`, () => { + const violations = checkFlowInvariants(row.broken(), { mode: 'strict' }); + expect(arms(violations)).toEqual(sorted(row.expected)); + const target = violations.find((v) => v.arm === row.arm); + expect(target?.uid).toBe(row.uid); + if (row.numbers !== undefined) { + for (const [key, value] of Object.entries(row.numbers)) { + expect(target?.numbers[key]).toBeCloseTo(value, 9); + } + } + }); + + it(`tolerant mode reports exactly [${row.tolerant.join(', ')}]`, () => { + const violations = checkFlowInvariants(row.broken(), { mode: 'tolerant' }); + expect(arms(violations)).toEqual(sorted(row.tolerant)); + }); + }); + } +}); + +type Face = 'left' | 'right' | 'top' | 'bottom'; +const FACES: readonly Face[] = ['left', 'right', 'top', 'bottom']; + +// A diagonal first segment that still moves away from the face: the outward +// test must demand an axis-aligned segment, not only the outward sign. +const DIAGONAL_EXIT: Record = { + left: { start: [77.5, 100], end: [37.5, 80] }, + right: { start: [122.5, 100], end: [162.5, 80] }, + top: { start: [100, 82.5], end: [80, 42.5] }, + bottom: { start: [100, 117.5], end: [80, 157.5] }, +}; + +// G6's precondition inflates both terminals by MIN_SEGMENT. S1's inflated body +// starts at x = 67.5 and a sink cloud at cx inflated by MIN_SEGMENT ends at +// cx + 10, so cx = 57.5 touches (the precondition holds) and cx = 57.6 overlaps. +// The cloud crowds S1 for G3's room either way, so only G6 can report. +function g6Precondition(cx: number): JsonViewElement[] { + return [ + S1, + flow( + 2, + [100, 95], + [ + [122.5, 110, 1], + [140, 110], + [140, 95], + [cx, 95], + [cx, 100, 3], + ], + ), + cloud(3, 2, cx, 100), + ]; +} + +// G3's room inflates the source by MIN_SEGMENT (S1 to x = 132.5) and the sink +// by MIN_SINK_SEGMENT (a cloud at cx from cx - 15.5), so cx = 148 touches (room: +// the 10px final segment reports) and cx = 147.9 overlaps (exempt). +function g3Room(cx: number): JsonViewElement[] { + return [ + S1, + flow( + 2, + [135, 100], + [ + [122.5, 100, 1], + [cx, 100], + [cx, 110, 3], + ], + ), + cloud(3, 2, cx, 110), + ]; +} + +interface BoundaryRow { + readonly name: string; + readonly elements: JsonViewElement[]; + readonly expected: readonly FlowArm[]; +} + +const BOUNDARY_ROWS: readonly BoundaryRow[] = [ + { + name: 'G1: uid 0 is non-positive', + elements: [...STRAIGHT, aux(0, 400, 400)], + expected: ['G1.nonPositiveUid'], + }, + { + name: 'G2: a 1e-3px drift across the axis is diagonal', + elements: [ + flow( + 10, + [50, 0.0005], + [ + [0, 0, 11], + [100, 0.001, 12], + ], + ), + cloud(11, 10, 0, 0), + cloud(12, 10, 100, 0.001), + ], + expected: ['G2.diagonal'], + }, + { + name: 'G3: two consecutive vertical segments are collinear', + elements: [ + flow( + 10, + [0, 25], + [ + [0, 0, 11], + [0, 50], + [0, 100, 12], + ], + ), + cloud(11, 10, 0, 0), + cloud(12, 10, 0, 100), + ], + expected: ['G3.collinear'], + }, + { name: 'G3: room when the inflated terminals exactly touch', elements: g3Room(148), expected: ['G3.shortSink'] }, + { name: 'G3: no room when they overlap by 0.1px', elements: g3Room(147.9), expected: [] }, + { + name: 'G3: a missing terminal cannot crowd, so the minima still apply', + elements: [ + S1, + flow( + 2, + [160, 100], + [ + [122.5, 100, 1], + [200, 100], + [200, 110], + ], + ), + ], + expected: ['G1.unattachedEndpoint', 'G3.shortSink'], + }, + { + name: 'G3: a final segment of exactly MIN_SINK_SEGMENT is long enough', + elements: [ + S1, + flow( + 2, + [160, 100], + [ + [122.5, 100, 1], + [200, 100], + [200, 115.5, 3], + ], + ), + cloud(3, 2, 200, 115.5), + ], + expected: [], + }, + { + name: 'G3: a riser of exactly MIN_SEGMENT is long enough', + elements: [ + flow( + 10, + [50, 100], + [ + [0, 100, 11], + [100, 100], + [100, 110], + [200, 110, 12], + ], + ), + cloud(11, 10, 0, 100), + cloud(12, 10, 200, 110), + ], + expected: [], + }, + { + name: 'G4: an endpoint on the face line 2.5px past the corner is off the face', + elements: [ + S1, + flow( + 2, + [200, 120], + [ + [122.5, 120, 1], + [300, 120, 3], + ], + ), + cloud(3, 2, 300, 120), + ], + expected: ['G4.offFace'], + }, + ...FACES.map((face) => ({ + name: `G5: a diagonal segment moving away from the ${face} face is not an outward exit`, + elements: [ + S1, + flow( + 2, + [ + (DIAGONAL_EXIT[face].start[0] + DIAGONAL_EXIT[face].end[0]) / 2, + (DIAGONAL_EXIT[face].start[1] + DIAGONAL_EXIT[face].end[1]) / 2, + ], + [ + [...DIAGONAL_EXIT[face].start, 1], + [...DIAGONAL_EXIT[face].end, 3], + ], + ), + cloud(3, 2, ...DIAGONAL_EXIT[face].end), + ], + expected: ['G2.diagonal', 'G5.notPerpendicular'] as const, + })), + { + name: 'G6: a segment through the SINK terminal reports', + elements: [ + S1, + flow( + 2, + [60, 200], + [ + [60, 300, 3], + [60, 95], + [140, 95], + [140, 110], + [122.5, 110, 1], + ], + ), + cloud(3, 2, 60, 300), + ], + expected: ['G6.segmentThroughTerminal'], + }, + { + name: 'G6: the precondition holds when the bodies inflated by MIN_SEGMENT exactly touch', + elements: g6Precondition(57.5), + expected: ['G6.segmentThroughTerminal'], + }, + { + name: 'G6: overlapping by 0.1px exempts the crossing', + elements: g6Precondition(57.6), + expected: [], + }, + { + name: 'G6: a segment 1.5px inside the body crosses it', + elements: [ + S1, + flow( + 2, + [60, 200], + [ + [122.5, 110, 1], + [140, 110], + [140, 84], + [60, 84], + [60, 300, 3], + ], + ), + cloud(3, 2, 60, 300), + ], + expected: ['G6.segmentThroughTerminal'], + }, + { + name: 'G6: a cloud exactly on a stock edge is not inside it', + elements: [ + S1, + flow( + 10, + [200, 100], + [ + [122.5, 100, 11], + [300, 100, 12], + ], + ), + cloud(11, 10, 122.5, 100), + cloud(12, 10, 300, 100), + ], + expected: [], + }, + { + name: 'G7: a cloud 1e-3px off its endpoint is off', + elements: [...STRAIGHT.slice(0, 2), cloud(3, 2, 300.001, 100)], + expected: ['G7.cloudOffEndpoint'], + }, + { + name: 'G8: a valve 1e-3px off the path is off', + elements: [ + S1, + flow( + 2, + [200, 100.001], + [ + [122.5, 100, 1], + [300, 100, 3], + ], + ), + cloud(3, 2, 300, 100), + ], + expected: ['G8.valveOffPath'], + }, + { + name: 'G8: a valve exactly VALVE_CLAMP_MARGIN from an end is far enough', + elements: [ + flow( + 10, + [10, 0], + [ + [0, 0, 11], + [100, 0, 12], + ], + ), + cloud(11, 10, 0, 0), + cloud(12, 10, 100, 0), + ], + expected: [], + }, +]; + +describe('checkFlowInvariants boundaries', () => { + for (const row of BOUNDARY_ROWS) { + it(`${row.name}: reports [${row.expected.join(', ')}]`, () => { + expect(arms(checkFlowInvariants(loadView(row.elements), { mode: 'strict' }))).toEqual(sorted(row.expected)); + }); + } +}); + +// The preconditions and definitional choices each arm relies on. These are the +// places the plan's wording needed a reading; each row states it. +describe('checkFlowInvariants definitions', () => { + it('G3: crowded terminals exempt the segment minima (12.5px straight flow between stocks)', () => { + const view = loadView([ + stock(1, 100, 100), + stock(2, 157.5, 100), + flow( + 4, + [128.75, 100], + [ + [122.5, 100, 1], + [135, 100, 2], + ], + ), + ]); + expect(formatFlowViolations(checkFlowInvariants(view, { mode: 'strict' }))).toBe(''); + }); + + it('G6: overlapping inflated terminal bodies exempt a body crossing', () => { + // The G6 row's crossing route, with the sink cloud moved to within MIN_SEGMENT of the stock. + const view = loadView([ + S1, + flow( + 2, + [60, 110], + [ + [122.5, 110, 1], + [140, 110], + [140, 95], + [60, 95], + [60, 125, 3], + ], + ), + cloud(3, 2, 60, 125), + ]); + expect(formatFlowViolations(checkFlowInvariants(view, { mode: 'strict' }))).toBe(''); + }); + + it('G5 is undefined for an off-face endpoint: only G4.offFace reports', () => { + const view = loadView([ + S1, + flow( + 2, + [130, 180], + [ + [130, 60, 1], + [130, 300, 3], + ], + ), + cloud(3, 2, 130, 300), + ]); + expect(arms(checkFlowInvariants(view, { mode: 'strict' }))).toEqual(['G4.offFace']); + }); + + it('G5 reads the first segment of positive length past a coincident point', () => { + const view = loadView([ + S1, + flow( + 2, + [200, 40], + [ + [122.5, 100, 1], + [122.5, 100], + [122.5, 40], + [300, 40, 3], + ], + ), + cloud(3, 2, 300, 40), + ]); + expect(arms(checkFlowInvariants(view, { mode: 'strict' }))).toEqual(['G3.zeroLength', 'G5.notPerpendicular']); + }); + + it('G4/G5: an endpoint exactly on a corner reports the clearance but not the exit', () => { + const view = loadView([ + S1, + flow( + 2, + [200, 82.5], + [ + [122.5, 82.5, 1], + [300, 82.5, 3], + ], + ), + cloud(3, 2, 300, 82.5), + ]); + const violations = checkFlowInvariants(view, { mode: 'strict' }); + expect(arms(violations)).toEqual(['G4.cornerClearance']); + expect(violations[0].numbers.clearance).toBe(0); + }); + + it('G8: a path shorter than two margins has no margin requirement', () => { + const view = loadView([ + flow( + 10, + [2, 0], + [ + [0, 0, 11], + [15, 0, 12], + ], + ), + cloud(11, 10, 0, 0), + cloud(12, 10, 15, 0), + ]); + expect(formatFlowViolations(checkFlowInvariants(view, { mode: 'strict' }))).toBe(''); + }); + + it('G8: a path of exactly two margins already has the requirement', () => { + // 20px cloud-to-cloud path (the G3 minima are exempt: the clouds crowd each + // other), valve 5px from the source end. + const view = loadView([ + flow( + 10, + [5, 0], + [ + [0, 0, 11], + [20, 0, 12], + ], + ), + cloud(11, 10, 0, 0), + cloud(12, 10, 20, 0), + ]); + expect(arms(checkFlowInvariants(view, { mode: 'strict' }))).toEqual(['G8.valveMargin']); + }); + + it('G8: the margin is arc length from the path ends, not from segment ends', () => { + // Valve 4px past the L's corner: 4px from its segment's start, 81.5px along the path. + const view = loadView([ + S1, + flow( + 2, + [200, 104], + [ + [122.5, 100, 1], + [200, 100], + [200, 250, 3], + ], + ), + cloud(3, 2, 200, 250), + ]); + expect(formatFlowViolations(checkFlowInvariants(view, { mode: 'strict' }))).toBe(''); + }); + + it('routed: strict arms apply only to routed flows; unrouted flows keep the tolerant arms', () => { + const view = loadView([ + // Flow 2 (routed): cloud 5px off its endpoint. + S1, + flow( + 2, + [200, 100], + [ + [122.5, 100, 1], + [300, 100, 3], + ], + ), + cloud(3, 2, 305, 100), + // Flow 10 (not routed): the same geometric defect plus a dangling sink. + flow( + 10, + [50, 300], + [ + [0, 300, 11], + [100, 300, 99], + ], + ), + cloud(11, 10, 3, 300), + ]); + const violations = checkFlowInvariants(view, { mode: 'strict', routed: new Set([2]) }); + expect(violations.map((v) => `${v.uid}:${v.arm}`).sort()).toEqual([ + '10:G1.danglingAttachment', + '2:G7.cloudOffEndpoint', + ]); + }); + + it('routed: an empty routed set still demands positive uids in a committed view', () => { + const view = loadView([...STRAIGHT, aux(-3, 400, 400)]); + expect(arms(checkFlowInvariants(view, { mode: 'strict', routed: new Set() }))).toEqual(['G1.nonPositiveUid']); + expect(arms(checkFlowInvariants(view, { mode: 'tolerant' }))).toEqual([]); + }); + + it('tolerant mode accepts an unattached flow (Vensim fallback flows import this way)', () => { + const view = loadView([ + flow( + 10, + [50, 0], + [ + [0, 0], + [100, 0], + ], + ), + ]); + expect(arms(checkFlowInvariants(view, { mode: 'tolerant' }))).toEqual([]); + expect(arms(checkFlowInvariants(view, { mode: 'strict' }))).toEqual([ + 'G1.unattachedEndpoint', + 'G1.unattachedEndpoint', + ]); + }); + + it('the tolerant arm set is exactly the structural arms heal cannot repair and imports never carry', () => { + expect(sorted([...TOLERANT_FLOW_ARMS])).toEqual([ + 'G1.attachmentKind', + 'G1.danglingAttachment', + 'G1.foreignCloud', + 'G1.interiorAttached', + 'G1.minPoints', + 'G1.nonFinite', + ]); + }); +}); diff --git a/src/diagram/tests/flow-routing.test.ts b/src/diagram/tests/flow-routing.test.ts deleted file mode 100644 index 907865011..000000000 --- a/src/diagram/tests/flow-routing.test.ts +++ /dev/null @@ -1,3321 +0,0 @@ -// Copyright 2026 The Simlin Authors. All rights reserved. -// Use of this source code is governed by the Apache License, -// Version 2.0, that can be found in the LICENSE file. - -import { describe, it, expect } from '@rstest/core'; - -import { type Point, type FlowViewElement, type StockViewElement, type CloudViewElement } from '@simlin/core/datamodel'; - -import { - computeFlowRoute, - computeFlowOffsets, - UpdateStockAndFlows, - UpdateCloudAndFlow, - UpdateFlow, - moveSegment, - findClickedSegment, - getSegments, -} from '../drawing/Flow'; -import { StockWidth, StockHeight } from '../drawing/Stock'; - -function makeStock( - uid: number, - x: number, - y: number, - inflows: number[] = [], - outflows: number[] = [], -): StockViewElement { - return { - type: 'stock', - uid, - name: 'TestStock', - ident: 'test_stock', - var: undefined, - x, - y, - labelSide: 'center', - isZeroRadius: false, - inflows, - outflows, - }; -} - -function makeFlow( - uid: number, - x: number, - y: number, - points: Array<{ x: number; y: number; attachedToUid?: number }>, -): FlowViewElement { - return { - type: 'flow', - uid, - name: 'TestFlow', - ident: 'test_flow', - var: undefined, - x, - y, - labelSide: 'center', - points: points.map((p) => ({ x: p.x, y: p.y, attachedToUid: p.attachedToUid })), - isZeroRadius: false, - }; -} - -function makeCloud(uid: number, flowUid: number, x: number, y: number): CloudViewElement { - return { - type: 'cloud', - uid, - flowUid, - x, - y, - isZeroRadius: false, - ident: undefined, - }; -} - -describe('Flow routing', () => { - const stockUid = 1; - const flowUid = 2; - const cloudUid = 3; - - describe('computeFlowRoute', () => { - describe('straight horizontal flows', () => { - it('should keep flow straight when stock moves within vertical bounds', () => { - // Stock at (100, 100), anchor/cloud at (200, 100) - horizontal flow - const stock = makeStock(stockUid, 100, 100); - const flow = makeFlow(flowUid, 150, 100, [ - { x: 100 + StockWidth / 2, y: 100, attachedToUid: stockUid }, - { x: 200, y: 100, attachedToUid: cloudUid }, - ]); - - // Move stock slightly up (within StockHeight/2 = 17.5) - const newStockY = 100 - 10; - const result = computeFlowRoute(flow, stock, 100, newStockY); - - // Should still be 2 points (straight) - expect(result.points.length).toBe(2); - // Stock point should be at stock's right edge, at anchor's Y - const stockPoint = result.points[0]; - expect(stockPoint.x).toBe(100 + StockWidth / 2); - expect(stockPoint.y).toBe(100); // anchor Y - }); - - it('should create L-shape when stock moves vertically beyond bounds', () => { - // Stock at (100, 100), anchor/cloud at (200, 100) - horizontal flow - const stock = makeStock(stockUid, 100, 100); - const flow = makeFlow(flowUid, 150, 100, [ - { x: 100 + StockWidth / 2, y: 100, attachedToUid: stockUid }, - { x: 200, y: 100, attachedToUid: cloudUid }, - ]); - - // Move stock down significantly (beyond StockHeight/2 = 17.5) - const newStockY = 100 + 50; - const result = computeFlowRoute(flow, stock, 100, newStockY); - - // Should be 3 points (L-shape) - expect(result.points.length).toBe(3); - - const stockPoint = result.points[0]; - const corner = result.points[1]; - const anchor = result.points[2]; - - // Stock should attach at TOP (since anchor is above) - expect(stockPoint.x).toBe(100); - expect(stockPoint.y).toBe(newStockY - StockHeight / 2); - - // Corner should create vertical-then-horizontal L - expect(corner.x).toBe(stockPoint.x); // same X as stock (vertical segment) - expect(corner.y).toBe(anchor.y); // same Y as anchor (horizontal segment) - - // Anchor unchanged - expect(anchor.x).toBe(200); - expect(anchor.y).toBe(100); - }); - - it('should attach to bottom when stock moves up (anchor below)', () => { - // Stock at (100, 100), anchor/cloud at (200, 100) - horizontal flow - const stock = makeStock(stockUid, 100, 100); - const flow = makeFlow(flowUid, 150, 100, [ - { x: 100 + StockWidth / 2, y: 100, attachedToUid: stockUid }, - { x: 200, y: 100, attachedToUid: cloudUid }, - ]); - - // Move stock up significantly - const newStockY = 100 - 50; - const result = computeFlowRoute(flow, stock, 100, newStockY); - - expect(result.points.length).toBe(3); - - const stockPoint = result.points[0]; - // Stock should attach at BOTTOM (since anchor is below) - expect(stockPoint.y).toBe(newStockY + StockHeight / 2); - }); - - it('should preserve off-center valve position when stock moves on straight flow', () => { - // Horizontal flow with valve positioned off-center (closer to anchor) - const stock = makeStock(stockUid, 100, 100); - // Valve at x=180 (near anchor at x=200), not at midpoint x=161.25 - const flow = makeFlow(flowUid, 180, 100, [ - { x: 100 + StockWidth / 2, y: 100, attachedToUid: stockUid }, // stock right edge at x=122.5 - { x: 200, y: 100, attachedToUid: cloudUid }, - ]); - - // Move stock slightly (within bounds to keep flow straight) - const newStockY = 100 - 10; - const result = computeFlowRoute(flow, stock, 100, newStockY); - - // Flow should still be straight - expect(result.points.length).toBe(2); - - // Valve should preserve its x position (clamped to segment bounds) - // The valve was at x=180, which is still valid on the new segment - expect(result.x).toBe(180); - expect(result.y).toBe(100); - }); - - it('should preserve valve fractional position when stock moves along flow axis', () => { - // Horizontal flow: stock at x=100, cloud at x=200 - // Valve starts at x=150 (roughly at the midpoint of the segment) - const stock = makeStock(stockUid, 100, 100); - const stockEdgeX = 100 + StockWidth / 2; // 122.5 - const anchorX = 200; - const valveX = 150; - - const flow = makeFlow(flowUid, valveX, 100, [ - { x: stockEdgeX, y: 100, attachedToUid: stockUid }, - { x: anchorX, y: 100, attachedToUid: cloudUid }, - ]); - - // Calculate the valve's fractional position on the original segment - // Fraction = (valve - anchor) / (stockEdge - anchor) = (150 - 200) / (122.5 - 200) = 0.645 - const originalFraction = (valveX - anchorX) / (stockEdgeX - anchorX); - - // Move stock right along the flow axis (toward anchor) - // This makes the segment shorter - const newStockX = 160; - const newStockEdgeX = newStockX + StockWidth / 2; // 182.5 - const result = computeFlowRoute(flow, stock, newStockX, 100); - - // Flow should still be straight (2 points) - expect(result.points.length).toBe(2); - - // Verify the new segment bounds - expect(result.points[0].x).toBe(newStockEdgeX); - expect(result.points[1].x).toBe(anchorX); - - // The valve should preserve its fractional position along the segment. - // New valve x = anchor + fraction * (newStockEdge - anchor) - // = 200 + 0.645 * (182.5 - 200) = 200 + 0.645 * (-17.5) = 200 - 11.29 ≈ 188.7 - const expectedValveX = anchorX + originalFraction * (newStockEdgeX - anchorX); - expect(result.x).toBeCloseTo(expectedValveX, 1); - expect(result.y).toBe(100); - }); - - it('should preserve valve fractional position when stock moves past valve position', () => { - // This is a more extreme case where the old valve position is outside the new segment. - // Horizontal flow: stock at x=100, cloud at x=200 - // Valve starts at x=150 (roughly at the midpoint of the segment) - const stock = makeStock(stockUid, 100, 100); - const stockEdgeX = 100 + StockWidth / 2; // 122.5 - const anchorX = 200; - const valveX = 150; - - const flow = makeFlow(flowUid, valveX, 100, [ - { x: stockEdgeX, y: 100, attachedToUid: stockUid }, - { x: anchorX, y: 100, attachedToUid: cloudUid }, - ]); - - // Calculate the valve's fractional position on the original segment - const originalFraction = (valveX - anchorX) / (stockEdgeX - anchorX); - - // Move stock PAST the old valve position (stock center at 170 means edge at 192.5) - const newStockX = 170; - const newStockEdgeX = newStockX + StockWidth / 2; // 192.5 - const result = computeFlowRoute(flow, stock, newStockX, 100); - - // Flow should still be straight (2 points) - expect(result.points.length).toBe(2); - - // The valve should preserve its fractional position. - // Even though the old valve position (150) is now outside the new segment [192.5, 200], - // the fractional position places it correctly within the new segment. - // New valve x = 200 + 0.645 * (192.5 - 200) = 200 - 4.84 ≈ 195.16 - const expectedValveX = anchorX + originalFraction * (newStockEdgeX - anchorX); - expect(result.x).toBeCloseTo(expectedValveX, 1); - expect(result.y).toBe(100); - }); - }); - - describe('straight vertical flows', () => { - it('should keep flow straight when stock moves within horizontal bounds', () => { - // Stock at (100, 100), anchor/cloud at (100, 200) - vertical flow - const stock = makeStock(stockUid, 100, 100); - const flow = makeFlow(flowUid, 100, 150, [ - { x: 100, y: 100 + StockHeight / 2, attachedToUid: stockUid }, - { x: 100, y: 200, attachedToUid: cloudUid }, - ]); - - // Move stock slightly right (within StockWidth/2 = 22.5) - const newStockX = 100 + 10; - const result = computeFlowRoute(flow, stock, newStockX, 100); - - // Should still be 2 points (straight) - expect(result.points.length).toBe(2); - }); - - it('should create L-shape when stock moves horizontally beyond bounds', () => { - // Stock at (100, 100), anchor/cloud at (100, 200) - vertical flow - const stock = makeStock(stockUid, 100, 100); - const flow = makeFlow(flowUid, 100, 150, [ - { x: 100, y: 100 + StockHeight / 2, attachedToUid: stockUid }, - { x: 100, y: 200, attachedToUid: cloudUid }, - ]); - - // Move stock right significantly (beyond StockWidth/2 = 22.5) - const newStockX = 100 + 50; - const result = computeFlowRoute(flow, stock, newStockX, 100); - - // Should be 3 points (L-shape) - expect(result.points.length).toBe(3); - - const stockPoint = result.points[0]; - const corner = result.points[1]; - const anchor = result.points[2]; - - // Stock should attach at LEFT (since anchor is to the left) - expect(stockPoint.x).toBe(newStockX - StockWidth / 2); - expect(stockPoint.y).toBe(100); - - // Corner should create horizontal-then-vertical L - expect(corner.x).toBe(anchor.x); // same X as anchor (vertical segment) - expect(corner.y).toBe(stockPoint.y); // same Y as stock (horizontal segment) - }); - }); - - describe('L-shaped flows maintain direction', () => { - it('should preserve horizontal anchor segment direction for existing L-shape', () => { - // Existing L-shaped flow: stock at top, corner in middle, anchor at right - // This represents a flow that was originally horizontal and bent - const stock = makeStock(stockUid, 100, 50); - const flow = makeFlow(flowUid, 150, 100, [ - { x: 100, y: 50 + StockHeight / 2, attachedToUid: stockUid }, // stock bottom - { x: 100, y: 100 }, // corner - { x: 200, y: 100, attachedToUid: cloudUid }, // anchor - ]); - - // Move stock further up - anchor segment (corner-anchor) is horizontal - const newStockY = 30; - const result = computeFlowRoute(flow, stock, 100, newStockY); - - expect(result.points.length).toBe(3); - - // The anchor-side segment should remain horizontal - const corner = result.points[1]; - const anchor = result.points[2]; - expect(corner.y).toBe(anchor.y); // horizontal segment preserved - }); - - it('should preserve vertical anchor segment direction for existing L-shape', () => { - // Existing L-shaped flow: stock at right, corner in middle, anchor at bottom - // This represents a flow that was originally vertical and bent - const stock = makeStock(stockUid, 150, 100); - const flow = makeFlow(flowUid, 125, 150, [ - { x: 150 - StockWidth / 2, y: 100, attachedToUid: stockUid }, // stock left - { x: 100, y: 100 }, // corner - { x: 100, y: 200, attachedToUid: cloudUid }, // anchor - ]); - - // Move stock further right - anchor segment (corner-anchor) is vertical - const newStockX = 180; - const result = computeFlowRoute(flow, stock, newStockX, 100); - - expect(result.points.length).toBe(3); - - // The anchor-side segment should remain vertical - const corner = result.points[1]; - const anchor = result.points[2]; - expect(corner.x).toBe(anchor.x); // vertical segment preserved - }); - - it('should revert L-shape to straight when stock returns to valid position', () => { - // Existing L-shaped flow from horizontal original - const stock = makeStock(stockUid, 100, 150); - const flow = makeFlow(flowUid, 150, 100, [ - { x: 100, y: 150 - StockHeight / 2, attachedToUid: stockUid }, // stock top - { x: 100, y: 100 }, // corner - { x: 200, y: 100, attachedToUid: cloudUid }, // anchor - ]); - - // Move stock back to anchor's Y level (within bounds) - const newStockY = 100; - const result = computeFlowRoute(flow, stock, 100, newStockY); - - // Should revert to 2 points (straight) - expect(result.points.length).toBe(2); - - const stockPoint = result.points[0]; - const anchor = result.points[1]; - - // Should be a straight horizontal flow again - expect(stockPoint.y).toBe(anchor.y); - }); - - it('should preserve valve fraction from second segment when L-shape straightens', () => { - // L-shaped flow where the valve is on segment 1 (horizontal, near anchor), - // not segment 0 (vertical, near stock). - // When the L-shape straightens, the valve fraction should be computed from - // the segment the valve was actually on, not always from segment 0. - const stock = makeStock(stockUid, 100, 150); - // L-shape: stock bottom -> corner -> anchor - // Segment 0: vertical from (100, 132.5) to (100, 100) - stock to corner - // Segment 1: horizontal from (100, 100) to (200, 100) - corner to anchor - // Valve at (180, 100) is on segment 1, at fraction 0.8 along that segment - const flow = makeFlow(flowUid, 180, 100, [ - { x: 100, y: 150 - StockHeight / 2, attachedToUid: stockUid }, // stock top at y=132.5 - { x: 100, y: 100 }, // corner - { x: 200, y: 100, attachedToUid: cloudUid }, // anchor - ]); - - // Calculate valve's fractional position on OLD segment 1 (horizontal) - // Segment 1 goes from corner (100, 100) to anchor (200, 100) - const oldCornerX = 100; - const anchorX = 200; - const valveX = 180; - const oldFraction = (valveX - oldCornerX) / (anchorX - oldCornerX); // = 0.8 - - // Move stock back to anchor's Y level to straighten the L-shape - const newStockY = 100; - const result = computeFlowRoute(flow, stock, 100, newStockY); - - // Should revert to 2 points (straight) - expect(result.points.length).toBe(2); - - // New segment goes from stock edge (122.5, 100) to anchor (200, 100) - const newStockEdgeX = 100 + StockWidth / 2; // 122.5 - // The valve should preserve its fraction (0.8) along the new segment - // Expected X = newStockEdgeX + 0.8 * (anchorX - newStockEdgeX) - // = 122.5 + 0.8 * 77.5 = 122.5 + 62 = 184.5 - const expectedValveX = newStockEdgeX + oldFraction * (anchorX - newStockEdgeX); - expect(result.x).toBeCloseTo(expectedValveX, 1); - expect(result.y).toBe(100); - }); - - it('should preserve off-center valve position when stock moves on L-shaped flow', () => { - // L-shaped flow with valve positioned near the anchor (not at midpoint) - // Flow: stock at top -> corner -> anchor at right (horizontal anchor segment) - const stock = makeStock(stockUid, 100, 50); - // Valve at (180, 100) is on the horizontal segment near the anchor - const flow = makeFlow(flowUid, 180, 100, [ - { x: 100, y: 50 + StockHeight / 2, attachedToUid: stockUid }, // stock bottom - { x: 100, y: 100 }, // corner - { x: 200, y: 100, attachedToUid: cloudUid }, // anchor - ]); - - // Move stock further up - this changes the vertical segment length - const newStockY = 30; - const result = computeFlowRoute(flow, stock, 100, newStockY); - - // Flow should still be L-shaped - expect(result.points.length).toBe(3); - - // Valve should preserve its position on the horizontal segment - // It was at (180, 100) which is still valid on the anchor segment - expect(result.x).toBe(180); - expect(result.y).toBe(100); - }); - - it('should clamp valve to nearest segment when straight flow becomes L-shaped', () => { - // Horizontal flow with valve near the center - const stock = makeStock(stockUid, 100, 100); - // Valve at (160, 100) on the horizontal segment - const flow = makeFlow(flowUid, 160, 100, [ - { x: 100 + StockWidth / 2, y: 100, attachedToUid: stockUid }, // stock right edge at x=122.5 - { x: 200, y: 100, attachedToUid: cloudUid }, - ]); - - // Move stock down significantly to create L-shape - const newStockY = 100 + 50; - const result = computeFlowRoute(flow, stock, 100, newStockY); - - // Flow should become L-shaped - expect(result.points.length).toBe(3); - - // Valve was at (160, 100). The new L-shape has: - // - Vertical segment from stock at (100, 132.5) to corner at (100, 100) - // - Horizontal segment from corner at (100, 100) to anchor at (200, 100) - // The valve (160, 100) is on the horizontal segment, so it should stay there - expect(result.x).toBe(160); - expect(result.y).toBe(100); - }); - }); - - describe('stock as sink (last point)', () => { - it('should handle stock as sink correctly for horizontal flow', () => { - // Flow from cloud to stock: cloud at left, stock at right - const stock = makeStock(stockUid, 200, 100); - const flow = makeFlow(flowUid, 150, 100, [ - { x: 100, y: 100, attachedToUid: cloudUid }, // anchor (cloud) - { x: 200 - StockWidth / 2, y: 100, attachedToUid: stockUid }, // stock left edge - ]); - - // Move stock down - const newStockY = 100 + 50; - const result = computeFlowRoute(flow, stock, 200, newStockY); - - expect(result.points.length).toBe(3); - - const anchor = result.points[0]; - const corner = result.points[1]; - const stockPoint = result.points[2]; - - // Anchor unchanged - expect(anchor.x).toBe(100); - expect(anchor.y).toBe(100); - - // Stock attaches at top - expect(stockPoint.y).toBe(newStockY - StockHeight / 2); - - // Corner creates proper L - expect(corner.y).toBe(anchor.y); // horizontal from anchor - expect(corner.x).toBe(stockPoint.x); // vertical to stock - }); - }); - - describe('edge cases', () => { - it('should return unchanged flow if stock not attached', () => { - const stock = makeStock(stockUid, 100, 100); - const flow = makeFlow(flowUid, 150, 100, [ - { x: 50, y: 100, attachedToUid: 99 }, // different uid - { x: 200, y: 100, attachedToUid: cloudUid }, - ]); - - const result = computeFlowRoute(flow, stock, 100, 150); - - // Should be unchanged - expect(result.points).toEqual(flow.points); - }); - - it('should return unchanged flow if fewer than 2 points', () => { - const stock = makeStock(stockUid, 100, 100); - const flow = makeFlow(flowUid, 100, 100, [{ x: 100, y: 100, attachedToUid: stockUid }]); - - const result = computeFlowRoute(flow, stock, 100, 150); - - expect(result.points.length).toBe(1); - }); - }); - - describe('multi-point flow preservation', () => { - it('should preserve non-adjacent points and adjust adjacent corner on 4+ point flow', () => { - // 4-point flow: stock -> corner1 -> corner2 -> cloud - const stock = makeStock(stockUid, 100, 100); - const flow = makeFlow(flowUid, 150, 150, [ - { x: 100 + StockWidth / 2, y: 100, attachedToUid: stockUid }, // stock right edge - { x: 150, y: 100 }, // corner1 (adjacent to stock - will be adjusted) - { x: 150, y: 200 }, // corner2 (not adjacent - preserved) - { x: 200, y: 200, attachedToUid: cloudUid }, // cloud - ]); - - // Move stock up - const newStockY = 80; - const result = computeFlowRoute(flow, stock, 100, newStockY); - - // Should preserve all 4 points - expect(result.points.length).toBe(4); - - // Stock endpoint should be on stock's actual edge - const stockPoint = result.points[0]; - expect(stockPoint.attachedToUid).toBe(stockUid); - expect(stockPoint.y).toBe(newStockY); - - // Corner1 is adjacent to stock - its Y is adjusted to maintain horizontal segment - const corner1 = result.points[1]; - expect(corner1.x).toBe(150); - expect(corner1.y).toBe(newStockY); // Adjusted to match stock edge - - // Corner2 is not adjacent to stock - fully preserved - const corner2 = result.points[2]; - expect(corner2.x).toBe(150); - expect(corner2.y).toBe(200); - - // Anchor should be unchanged - const anchor = result.points[3]; - expect(anchor.x).toBe(200); - expect(anchor.y).toBe(200); - expect(anchor.attachedToUid).toBe(cloudUid); - }); - - it('should preserve intermediate points when stock is at end of 4+ point flow', () => { - // 4-point flow: cloud -> corner1 -> corner2 -> stock - const stock = makeStock(stockUid, 200, 200); - const flow = makeFlow(flowUid, 150, 150, [ - { x: 100, y: 100, attachedToUid: cloudUid }, // cloud - { x: 150, y: 100 }, // corner1 - { x: 150, y: 200 }, // corner2 - { x: 200 - StockWidth / 2, y: 200, attachedToUid: stockUid }, // stock left edge - ]); - - // Move stock right - const newStockX = 250; - const result = computeFlowRoute(flow, stock, newStockX, 200); - - // Should preserve all 4 points - expect(result.points.length).toBe(4); - - // Anchor should be unchanged - const anchor = result.points[0]; - expect(anchor.x).toBe(100); - expect(anchor.attachedToUid).toBe(cloudUid); - - // Intermediate points should be preserved - const corner1 = result.points[1]; - const corner2 = result.points[2]; - expect(corner1.x).toBe(150); - expect(corner1.y).toBe(100); - expect(corner2.x).toBe(150); - expect(corner2.y).toBe(200); - - // Stock endpoint should be updated - const stockPoint = result.points[3]; - expect(stockPoint.attachedToUid).toBe(stockUid); - }); - - it('should update valve position when moving stock on 4+ point flow', () => { - // 4-point flow with valve on middle segment - // Segments: [stock-corner1], [corner1-corner2], [corner2-cloud] - const stock = makeStock(stockUid, 100, 100); - // Valve at (150, 150) is on segment 1 (corner1-corner2) - const flow = makeFlow(flowUid, 150, 150, [ - { x: 100 + StockWidth / 2, y: 100, attachedToUid: stockUid }, // stock right edge - { x: 150, y: 100 }, // corner1 - { x: 150, y: 200 }, // corner2 - { x: 200, y: 200, attachedToUid: cloudUid }, // cloud - ]); - - // Move stock - this shouldn't affect the valve since it's on segment 1 - const result = computeFlowRoute(flow, stock, 100, 80); - - // Valve should still be clamped to a valid segment - // The segments are still the same, so valve should be on segment 1 - expect(result.x).toBe(150); - expect(result.y).toBe(150); - }); - - it('should clamp valve to nearest segment when stock moves significantly', () => { - // 4-point flow with valve on the middle vertical segment - const stock = makeStock(stockUid, 100, 100); - // Valve at (150, 150) is on segment 1 (corner1-corner2, vertical at x=150) - const flow = makeFlow(flowUid, 150, 150, [ - { x: 100 + StockWidth / 2, y: 100, attachedToUid: stockUid }, // stock right edge (122.5, 100) - { x: 150, y: 100 }, // corner1 - { x: 150, y: 200 }, // corner2 - { x: 200, y: 200, attachedToUid: cloudUid }, // cloud - ]); - - // Move stock right - this changes the first segment but not the middle one - const newStockX = 130; - const result = computeFlowRoute(flow, stock, newStockX, 100); - - // Stock endpoint should be updated to new right edge - const stockPoint = result.points[0]; - expect(stockPoint.x).toBe(newStockX + StockWidth / 2); // 152.5 - expect(stockPoint.y).toBe(100); - expect(stockPoint.attachedToUid).toBe(stockUid); - - // Valve was at (150, 150) on segment 1 (vertical from corner1 to corner2) - // Segment 1 is still vertical at x=150 from y=100 to y=200 - // The valve should still be at (150, 150) since it's on an unaffected segment - expect(result.x).toBe(150); - expect(result.y).toBe(150); - }); - - it('should preserve horizontal orientation when stock moves beyond 45 degree threshold', () => { - // 4-point flow with horizontal first segment: stock -> corner1 (horizontal) - // This tests that the segment orientation is determined from the existing geometry, - // not from the direction to the adjacent point (which would flip at 45 degrees) - const stock = makeStock(stockUid, 100, 100); - const flow = makeFlow(flowUid, 150, 150, [ - { x: 100 + StockWidth / 2, y: 100, attachedToUid: stockUid }, // stock right edge at (122.5, 100) - { x: 150, y: 100 }, // corner1 at y=100 (horizontal segment) - { x: 150, y: 200 }, // corner2 - { x: 200, y: 200, attachedToUid: cloudUid }, // cloud - ]); - - // Move stock down but NOT to y=200 (which would make corner1 colinear with corner2). - // At y=180, dy (80) > dx (27.5), which would flip to vertical if we used - // the naive Math.abs(dx) > Math.abs(dy) heuristic. - const newStockY = 180; - const result = computeFlowRoute(flow, stock, 100, newStockY); - - // Should still be 4 points (no colinear segments to remove) - expect(result.points.length).toBe(4); - - // First segment should STILL be horizontal (Y values match) - const stockPoint = result.points[0]; - const corner1 = result.points[1]; - expect(stockPoint.y).toBe(corner1.y); - expect(stockPoint.y).toBe(newStockY); - - // Corner2 should be unchanged - const corner2 = result.points[2]; - expect(corner2.x).toBe(150); - expect(corner2.y).toBe(200); - - // The first segment is horizontal, so corner1's X is preserved, Y is adjusted - expect(corner1.x).toBe(150); - expect(corner1.y).toBe(newStockY); - - // Second segment (corner1 to corner2) should be vertical - expect(corner1.x).toBe(corner2.x); - }); - - it('should normalize to remove colinear segments when stock aligns with corner (horizontal)', () => { - // When stock moves to the same Y as corner2, corner1 becomes colinear - // and should be removed by normalization. - const stock = makeStock(stockUid, 100, 100); - const flow = makeFlow(flowUid, 150, 150, [ - { x: 100 + StockWidth / 2, y: 100, attachedToUid: stockUid }, - { x: 150, y: 100 }, // corner1 - { x: 150, y: 200 }, // corner2 - { x: 200, y: 200, attachedToUid: cloudUid }, - ]); - - // Move stock to y=200 - same as corner2 and cloud - const newStockY = 200; - const result = computeFlowRoute(flow, stock, 100, newStockY); - - // After normalization, the flow should be straight (2 points) - // because all segments become colinear (all at y=200) - expect(result.points.length).toBe(2); - - const stockPoint = result.points[0]; - const anchor = result.points[1]; - expect(stockPoint.y).toBe(200); - expect(anchor.y).toBe(200); - }); - - it('should normalize to remove colinear segments when stock aligns with corner (vertical)', () => { - // When stock moves to the same X as corner2, corner1 becomes colinear - // and should be removed by normalization. - const stock = makeStock(stockUid, 100, 100); - const flow = makeFlow(flowUid, 150, 150, [ - { x: 100, y: 100 + StockHeight / 2, attachedToUid: stockUid }, // stock bottom edge - { x: 100, y: 150 }, // corner1 at x=100 (vertical) - { x: 200, y: 150 }, // corner2 - { x: 200, y: 200, attachedToUid: cloudUid }, - ]); - - // Move stock to x=200 - same as corner2 and cloud - const newStockX = 200; - const result = computeFlowRoute(flow, stock, newStockX, 100); - - // After normalization, corner1 becomes (200, 150) same as corner2, - // so it's removed. The flow becomes 3 points: stock -> corner2 -> cloud - // Then since stock is also at x=200, we get a vertical line plus horizontal. - // Actually, let's trace through: - // - stockPoint at (200, 117.5) - bottom edge of stock - // - corner1 adjusted to (200, 150) to keep vertical - but that's same as corner2! - // - corner2 at (200, 150) - // - cloud at (200, 200) - // Normalization removes corner1 (zero-length segment with corner2) - // Then we have: stock(200,117.5) -> corner2(200,150) -> cloud(200,200) - // All at x=200, so corner2 is also removed (colinear) - // Final: stock(200,117.5) -> cloud(200,200) = 2 points (straight vertical) - expect(result.points.length).toBe(2); - - const stockPoint = result.points[0]; - const anchor = result.points[1]; - expect(stockPoint.x).toBe(200); - expect(anchor.x).toBe(200); - }); - - it('should keep first segment horizontal when stock moves vertically on 4+ point flow', () => { - // 4-point flow with horizontal first segment: stock -> corner1 (horizontal) - const stock = makeStock(stockUid, 100, 100); - const flow = makeFlow(flowUid, 150, 150, [ - { x: 100 + StockWidth / 2, y: 100, attachedToUid: stockUid }, // stock right edge at y=100 - { x: 150, y: 100 }, // corner1 at y=100 (horizontal segment) - { x: 150, y: 200 }, // corner2 - { x: 200, y: 200, attachedToUid: cloudUid }, // cloud - ]); - - // Move stock vertically - endpoint stays on stock edge, corner1 adjusts to maintain horizontal - const newStockY = 120; - const result = computeFlowRoute(flow, stock, 100, newStockY); - - // First segment (stock to corner1) should remain horizontal - const stockPoint = result.points[0]; - const corner1 = result.points[1]; - - // Endpoint stays on stock's actual edge (y = newStockY) - expect(stockPoint.y).toBe(newStockY); - // Corner1's Y is adjusted to match, preserving horizontal segment - expect(corner1.y).toBe(newStockY); - // Same Y values = horizontal segment - expect(stockPoint.y).toBe(corner1.y); - }); - - it('should preserve valve fraction when stock moves along first segment on 4+ point flow', () => { - // 4-point flow with horizontal first segment - // Valve is on the first segment with a specific fractional position - const stock = makeStock(stockUid, 100, 100); - const stockEdgeX = 100 + StockWidth / 2; // 122.5 - const corner1X = 150; - // Valve at x=140 is at fraction (140-122.5)/(150-122.5) = 17.5/27.5 = 0.636 along segment - const valveX = 140; - const flow = makeFlow(flowUid, valveX, 100, [ - { x: stockEdgeX, y: 100, attachedToUid: stockUid }, // stock right edge - { x: corner1X, y: 100 }, // corner1 (horizontal segment) - { x: corner1X, y: 200 }, // corner2 - { x: 200, y: 200, attachedToUid: cloudUid }, // cloud - ]); - - // Calculate original valve fraction along first segment - const originalFraction = (valveX - stockEdgeX) / (corner1X - stockEdgeX); - - // Move stock left - this makes the first segment longer - const newStockX = 70; - const newStockEdgeX = newStockX + StockWidth / 2; // 92.5 - const result = computeFlowRoute(flow, stock, newStockX, 100); - - // New segment goes from 92.5 to 150 (longer than before) - // Expected valve X = newStockEdgeX + fraction * (corner1X - newStockEdgeX) - // = 92.5 + 0.636 * (150 - 92.5) = 92.5 + 36.6 = 129.1 - const expectedValveX = newStockEdgeX + originalFraction * (corner1X - newStockEdgeX); - expect(result.x).toBeCloseTo(expectedValveX, 1); - expect(result.y).toBe(100); - }); - - it('should keep first segment vertical when stock moves horizontally on 4+ point flow', () => { - // 4-point flow with vertical first segment: stock -> corner1 (vertical) - const stock = makeStock(stockUid, 100, 100); - const flow = makeFlow(flowUid, 150, 150, [ - { x: 100, y: 100 + StockHeight / 2, attachedToUid: stockUid }, // stock bottom edge at x=100 - { x: 100, y: 150 }, // corner1 at x=100 (vertical segment) - { x: 200, y: 150 }, // corner2 - { x: 200, y: 200, attachedToUid: cloudUid }, // cloud - ]); - - // Move stock horizontally - endpoint stays on stock edge, corner1 adjusts to maintain vertical - const newStockX = 120; - const result = computeFlowRoute(flow, stock, newStockX, 100); - - // First segment (stock to corner1) should remain vertical - const stockPoint = result.points[0]; - const corner1 = result.points[1]; - - // Endpoint stays on stock's actual edge (x = newStockX) - expect(stockPoint.x).toBe(newStockX); - // Corner1's X is adjusted to match, preserving vertical segment - expect(corner1.x).toBe(newStockX); - // Same X values = vertical segment - expect(stockPoint.x).toBe(corner1.x); - }); - - it('should preserve vertical orientation when stock moves beyond 45 degree threshold', () => { - // 4-point flow with vertical first segment: stock -> corner1 (vertical) - // This tests that the segment orientation is determined from the existing geometry, - // not from the direction to the adjacent point (which would flip at 45 degrees) - const stock = makeStock(stockUid, 100, 100); - const flow = makeFlow(flowUid, 150, 150, [ - { x: 100, y: 100 + StockHeight / 2, attachedToUid: stockUid }, // stock bottom edge at (100, 117.5) - { x: 100, y: 150 }, // corner1 at x=100 (vertical segment) - { x: 200, y: 150 }, // corner2 - { x: 200, y: 200, attachedToUid: cloudUid }, // cloud - ]); - - // Move stock right but NOT to x=200 (which would make corner1 colinear with corner2). - // dx (80) > dy (32.5), which would flip to horizontal if we used the naive - // Math.abs(dx) > Math.abs(dy) heuristic. - const newStockX = 180; - const result = computeFlowRoute(flow, stock, newStockX, 100); - - // First segment should STILL be vertical (X values match) - const stockPoint = result.points[0]; - const corner1 = result.points[1]; - expect(stockPoint.x).toBe(corner1.x); - - // The first segment is vertical, so corner1's Y is preserved, X is adjusted - expect(corner1.y).toBe(150); - expect(corner1.x).toBe(newStockX); // Adjusted to match stock - - // Should still have 4 points (no colinear segments created) - expect(result.points.length).toBe(4); - - // Corner2 should be unchanged (no diagonal created) - const corner2 = result.points[2]; - expect(corner2.x).toBe(200); - expect(corner2.y).toBe(150); - - // Second segment (corner1 to corner2) should be horizontal - expect(corner1.y).toBe(corner2.y); - }); - }); - }); - - describe('UpdateStockAndFlows', () => { - it('should update stock position and re-route all connected flows', () => { - const stock = makeStock(stockUid, 100, 100, [flowUid], []); - const flow = makeFlow(flowUid, 150, 100, [ - { x: 100 + StockWidth / 2, y: 100, attachedToUid: stockUid }, - { x: 200, y: 100, attachedToUid: cloudUid }, - ]); - - // Move stock down by 50 (moveDelta is inverted: negative delta = positive movement) - const [newStock, newFlows] = UpdateStockAndFlows(stock, [flow], { x: 0, y: -50 }); - - // Stock should have moved - expect(newStock.x).toBe(100); - expect(newStock.y).toBe(150); - - // Flow should be L-shaped - expect(newFlows.length).toBe(1); - expect(newFlows[0].points.length).toBe(3); - }); - - it('should handle multiple flows attached to one stock', () => { - const inflowUid = 2; - const outflowUid = 3; - const stock = makeStock(stockUid, 100, 100, [inflowUid], [outflowUid]); - - // Inflow from left - const inflow = makeFlow(inflowUid, 50, 100, [ - { x: 0, y: 100, attachedToUid: 4 }, // cloud - { x: 100 - StockWidth / 2, y: 100, attachedToUid: stockUid }, - ]); - - // Outflow to right - const outflow = makeFlow(outflowUid, 150, 100, [ - { x: 100 + StockWidth / 2, y: 100, attachedToUid: stockUid }, - { x: 200, y: 100, attachedToUid: 5 }, // cloud - ]); - - const [newStock, newFlows] = UpdateStockAndFlows(stock, [inflow, outflow], { x: 0, y: -50 }); - - expect(newStock.y).toBe(150); - expect(newFlows.length).toBe(2); - - // Both flows should be L-shaped - expect(newFlows[0].points.length).toBe(3); - expect(newFlows[1].points.length).toBe(3); - }); - - describe('flow spreading - multiple flows on same side', () => { - it('should spread two flows on the bottom side at 1/3 and 2/3 positions', () => { - const inflowUid = 2; - const outflowUid = 3; - const stock = makeStock(stockUid, 100, 100, [inflowUid], [outflowUid]); - - // Stock is at (100, 100), will move up to (100, 50) - // Inflow from left (cloud at x=0) - will attach to bottom - const inflow = makeFlow(inflowUid, 50, 100, [ - { x: 0, y: 100, attachedToUid: 4 }, // cloud on left - { x: 100 - StockWidth / 2, y: 100, attachedToUid: stockUid }, - ]); - - // Outflow to right (cloud at x=200) - will also attach to bottom - const outflow = makeFlow(outflowUid, 150, 100, [ - { x: 100 + StockWidth / 2, y: 100, attachedToUid: stockUid }, - { x: 200, y: 100, attachedToUid: 5 }, // cloud on right - ]); - - // Move stock up by 50 so both flows become L-shaped and attach to bottom - const [newStock, newFlows] = UpdateStockAndFlows(stock, [inflow, outflow], { x: 0, y: 50 }); - - expect(newStock.y).toBe(50); - - // Both flows should be L-shaped and attach to bottom - const newInflow = newFlows[0]; - const newOutflow = newFlows[1]; - - expect(newInflow.points.length).toBe(3); - expect(newOutflow.points.length).toBe(3); - - // Get the stock attachment points - const inflowStockPt = newInflow.points[newInflow.points.length - 1]; - const outflowStockPt = newOutflow.points[0]; - - // Both should be at the bottom of the stock (y = 50 + StockHeight/2) - const bottomY = 50 + StockHeight / 2; - expect(inflowStockPt.y).toBe(bottomY); - expect(outflowStockPt.y).toBe(bottomY); - - // The inflow (adjacent point at x=0) should be on the left (1/3) - // The outflow (adjacent point at x=200) should be on the right (2/3) - // Stock X range: 100 - StockWidth/2 to 100 + StockWidth/2 = 77.5 to 122.5 - const leftEdge = 100 - StockWidth / 2; - const oneThird = leftEdge + StockWidth / 3; - const twoThirds = leftEdge + (2 * StockWidth) / 3; - - expect(inflowStockPt.x).toBeCloseTo(oneThird, 1); - expect(outflowStockPt.x).toBeCloseTo(twoThirds, 1); - }); - - it('should order flows on bottom side by adjacent point X coordinate', () => { - // Three flows that will all attach to the bottom - const flow1Uid = 2; - const flow2Uid = 3; - const flow3Uid = 4; - const stock = makeStock(stockUid, 100, 100, [], [flow1Uid, flow2Uid, flow3Uid]); - - // Flow 1: adjacent point at x=150 (middle) - const flow1 = makeFlow(flow1Uid, 125, 100, [ - { x: 100 + StockWidth / 2, y: 100, attachedToUid: stockUid }, - { x: 150, y: 100, attachedToUid: 5 }, - ]); - - // Flow 2: adjacent point at x=50 (leftmost) - const flow2 = makeFlow(flow2Uid, 75, 100, [ - { x: 100 - StockWidth / 2, y: 100, attachedToUid: stockUid }, - { x: 50, y: 100, attachedToUid: 6 }, - ]); - - // Flow 3: adjacent point at x=250 (rightmost) - const flow3 = makeFlow(flow3Uid, 175, 100, [ - { x: 100 + StockWidth / 2, y: 100, attachedToUid: stockUid }, - { x: 250, y: 100, attachedToUid: 7 }, - ]); - - // Move stock up so all flows become L-shaped - const [newStock, newFlows] = UpdateStockAndFlows(stock, [flow1, flow2, flow3], { x: 0, y: 50 }); - - expect(newStock.y).toBe(50); - - // Get the stock attachment X coordinates for each flow - const getStockX = (flow: FlowViewElement) => { - const stockIsFirst = flow.points[0].attachedToUid === stockUid; - return stockIsFirst ? flow.points[0].x : flow.points[flow.points.length - 1].x; - }; - - const flow1StockX = getStockX(newFlows[0]); - const flow2StockX = getStockX(newFlows[1]); - const flow3StockX = getStockX(newFlows[2]); - - // Order should be: flow2 (x=50) < flow1 (x=150) < flow3 (x=250) - // So: flow2 at 1/4, flow1 at 2/4 (1/2), flow3 at 3/4 - const leftEdge = 100 - StockWidth / 2; - const quarter1 = leftEdge + StockWidth / 4; - const quarter2 = leftEdge + StockWidth / 2; - const quarter3 = leftEdge + (3 * StockWidth) / 4; - - expect(flow2StockX).toBeCloseTo(quarter1, 1); - expect(flow1StockX).toBeCloseTo(quarter2, 1); - expect(flow3StockX).toBeCloseTo(quarter3, 1); - }); - - it('should spread two flows on the top side at 1/3 and 2/3 positions', () => { - const inflowUid = 2; - const outflowUid = 3; - const stock = makeStock(stockUid, 100, 100, [inflowUid], [outflowUid]); - - // Stock at (100, 100), will move down to (100, 150) - // Inflow from left cloud - will attach to top - const inflow = makeFlow(inflowUid, 50, 100, [ - { x: 0, y: 100, attachedToUid: 4 }, - { x: 100 - StockWidth / 2, y: 100, attachedToUid: stockUid }, - ]); - - // Outflow to right cloud - will also attach to top - const outflow = makeFlow(outflowUid, 150, 100, [ - { x: 100 + StockWidth / 2, y: 100, attachedToUid: stockUid }, - { x: 200, y: 100, attachedToUid: 5 }, - ]); - - // Move stock down - const [newStock, newFlows] = UpdateStockAndFlows(stock, [inflow, outflow], { x: 0, y: -50 }); - - expect(newStock.y).toBe(150); - - const newInflow = newFlows[0]; - const newOutflow = newFlows[1]; - - // Get the stock attachment points - const inflowStockPt = newInflow.points[newInflow.points.length - 1]; - const outflowStockPt = newOutflow.points[0]; - - // Both should be at the top of the stock - const topY = 150 - StockHeight / 2; - expect(inflowStockPt.y).toBe(topY); - expect(outflowStockPt.y).toBe(topY); - - // Left flow should be at 1/3, right flow at 2/3 - const leftEdge = 100 - StockWidth / 2; - const oneThird = leftEdge + StockWidth / 3; - const twoThirds = leftEdge + (2 * StockWidth) / 3; - - expect(inflowStockPt.x).toBeCloseTo(oneThird, 1); - expect(outflowStockPt.x).toBeCloseTo(twoThirds, 1); - }); - - it('should spread two flows on the left side by Y coordinate', () => { - const flow1Uid = 2; - const flow2Uid = 3; - const stock = makeStock(stockUid, 100, 100, [flow1Uid], [flow2Uid]); - - // Stock at (100, 100), will move right to (150, 100) - // Flow 1: vertical flow from above (cloud at y=50) - will attach to left, upper position - const flow1 = makeFlow(flow1Uid, 100, 75, [ - { x: 100, y: 50, attachedToUid: 4 }, - { x: 100, y: 100 - StockHeight / 2, attachedToUid: stockUid }, - ]); - - // Flow 2: vertical flow from below (cloud at y=150) - will attach to left, lower position - const flow2 = makeFlow(flow2Uid, 100, 125, [ - { x: 100, y: 100 + StockHeight / 2, attachedToUid: stockUid }, - { x: 100, y: 150, attachedToUid: 5 }, - ]); - - // Move stock right so both flows become L-shaped and attach to left - const [newStock, newFlows] = UpdateStockAndFlows(stock, [flow1, flow2], { x: -50, y: 0 }); - - expect(newStock.x).toBe(150); - - const newFlow1 = newFlows[0]; - const newFlow2 = newFlows[1]; - - // Get the stock attachment points - const flow1StockPt = newFlow1.points[newFlow1.points.length - 1]; - const flow2StockPt = newFlow2.points[0]; - - // Both should be at the left of the stock - const leftX = 150 - StockWidth / 2; - expect(flow1StockPt.x).toBe(leftX); - expect(flow2StockPt.x).toBe(leftX); - - // Flow 1 (adjacent at y=50) should be at 1/3 (upper) - // Flow 2 (adjacent at y=150) should be at 2/3 (lower) - const topEdge = 100 - StockHeight / 2; - const oneThird = topEdge + StockHeight / 3; - const twoThirds = topEdge + (2 * StockHeight) / 3; - - expect(flow1StockPt.y).toBeCloseTo(oneThird, 1); - expect(flow2StockPt.y).toBeCloseTo(twoThirds, 1); - }); - - it('should keep single flow centered when only one flow on a side', () => { - const inflowUid = 2; - const outflowUid = 3; - const stock = makeStock(stockUid, 100, 100, [inflowUid], [outflowUid]); - - // Inflow from left - will attach to bottom - const inflow = makeFlow(inflowUid, 50, 100, [ - { x: 0, y: 100, attachedToUid: 4 }, - { x: 100 - StockWidth / 2, y: 100, attachedToUid: stockUid }, - ]); - - // Outflow to above - will attach to top (different side) - const outflow = makeFlow(outflowUid, 100, 75, [ - { x: 100, y: 100 - StockHeight / 2, attachedToUid: stockUid }, - { x: 100, y: 50, attachedToUid: 5 }, - ]); - - // Move stock up so inflow becomes L-shaped (attaches to bottom) - // Outflow remains vertical (attaches to top) - const [newStock, newFlows] = UpdateStockAndFlows(stock, [inflow, outflow], { x: 0, y: 50 }); - - expect(newStock.y).toBe(50); - - const newInflow = newFlows[0]; - const newOutflow = newFlows[1]; - - // Inflow: should be centered on bottom (only one flow on bottom) - const inflowStockPt = newInflow.points[newInflow.points.length - 1]; - expect(inflowStockPt.x).toBe(100); // centered - expect(inflowStockPt.y).toBe(50 + StockHeight / 2); // bottom - - // Outflow: should be centered on top (only one flow on top) - const outflowStockPt = newOutflow.points[0]; - expect(outflowStockPt.x).toBe(100); // centered - expect(outflowStockPt.y).toBe(50 - StockHeight / 2); // top - }); - - it('should not apply spreading to straight flows - they separate by anchor position', () => { - // Two horizontal flows that both REMAIN STRAIGHT after stock moves - // Both go to the left side, but at different Y coordinates (based on their anchors) - const flow1Uid = 2; - const flow2Uid = 3; - const stock = makeStock(stockUid, 100, 100, [], [flow1Uid, flow2Uid]); - - // Flow 1: horizontal flow to cloud at y=95 (within stock's vertical extent) - const flow1 = makeFlow(flow1Uid, 60, 95, [ - { x: 100 - StockWidth / 2, y: 95, attachedToUid: stockUid }, - { x: 20, y: 95, attachedToUid: 4 }, - ]); - - // Flow 2: horizontal flow to cloud at y=105 (also within stock's vertical extent) - const flow2 = makeFlow(flow2Uid, 60, 105, [ - { x: 100 - StockWidth / 2, y: 105, attachedToUid: stockUid }, - { x: 20, y: 105, attachedToUid: 5 }, - ]); - - // Move stock slightly - both flows should remain straight - const [newStock, newFlows] = UpdateStockAndFlows(stock, [flow1, flow2], { x: -10, y: 0 }); - - expect(newStock.x).toBe(110); - - const newFlow1 = newFlows[0]; - const newFlow2 = newFlows[1]; - - // Both flows should remain 2-point (straight) - expect(newFlow1.points.length).toBe(2); - expect(newFlow2.points.length).toBe(2); - - // Stock attachment points should maintain their anchor's Y coordinate - // (not shifted by spreading offset) - const flow1StockPt = newFlow1.points[0]; - const flow2StockPt = newFlow2.points[0]; - - // Y coordinates should match the anchors' Y (not shifted) - expect(flow1StockPt.y).toBe(95); - expect(flow2StockPt.y).toBe(105); - - // X should be at the left edge of the new stock position - expect(flow1StockPt.x).toBe(110 - StockWidth / 2); - expect(flow2StockPt.x).toBe(110 - StockWidth / 2); - }); - - it('should order pre-existing L-shaped flows by anchor position, not corner', () => { - // Two pre-existing L-shaped flows that both attach to the bottom. - // Their corners have the same X coordinate (at stock center), so ordering - // by corner would give undefined results. Ordering by anchor avoids this. - const flow1Uid = 2; - const flow2Uid = 3; - const stock = makeStock(stockUid, 100, 100, [], [flow1Uid, flow2Uid]); - - // Flow 1: L-shaped, corner at (100, 150), anchor at x=200 (right side) - // Stock at bottom (100, 100 + StockHeight/2) -> corner (100, 150) -> anchor (200, 150) - const flow1 = makeFlow(flow1Uid, 100, 125, [ - { x: 100, y: 100 + StockHeight / 2, attachedToUid: stockUid }, - { x: 100, y: 150 }, // corner - { x: 200, y: 150, attachedToUid: 4 }, // anchor on RIGHT - ]); - - // Flow 2: L-shaped, corner at (100, 160), anchor at x=0 (left side) - // Stock at bottom -> corner (100, 160) -> anchor (0, 160) - const flow2 = makeFlow(flow2Uid, 100, 130, [ - { x: 100, y: 100 + StockHeight / 2, attachedToUid: stockUid }, - { x: 100, y: 160 }, // corner - note both corners have x=100 - { x: 0, y: 160, attachedToUid: 5 }, // anchor on LEFT - ]); - - // Move stock slightly - both L-shaped flows stay L-shaped - const [newStock, newFlows] = UpdateStockAndFlows(stock, [flow1, flow2], { x: 0, y: -10 }); - - expect(newStock.y).toBe(110); - - const newFlow1 = newFlows[0]; - const newFlow2 = newFlows[1]; - - // Get stock attachment X coordinates - const flow1StockPt = newFlow1.points[0]; - const flow2StockPt = newFlow2.points[0]; - - // Flow 2 (anchor at x=0, left) should attach to the LEFT of the bottom edge - // Flow 1 (anchor at x=200, right) should attach to the RIGHT of the bottom edge - // So flow2StockPt.x < flow1StockPt.x - expect(flow2StockPt.x).toBeLessThan(flow1StockPt.x); - - // More specifically, with 2 flows: 1/3 and 2/3 positions - const leftEdge = 100 - StockWidth / 2; - const oneThird = leftEdge + StockWidth / 3; - const twoThirds = leftEdge + (2 * StockWidth) / 3; - - expect(flow2StockPt.x).toBeCloseTo(oneThird, 1); // left anchor -> left position - expect(flow1StockPt.x).toBeCloseTo(twoThirds, 1); // right anchor -> right position - }); - - it('should include straight flows in spacing to avoid overlap with L-shaped', () => { - // Scenario: 1 straight flow + 2 L-shaped flows on the left side - // Without including straight flow in count: L-shaped get 1/3 and 2/3 - // With including straight flow: 3 flows total → slots at 1/4, 2/4, 3/4 - // L-shaped flows get 1/4 and 3/4, avoiding the middle where straight might be - const straightUid = 2; - const lshape1Uid = 3; - const lshape2Uid = 4; - const stock = makeStock(stockUid, 100, 100, [], [straightUid, lshape1Uid, lshape2Uid]); - - // Straight vertical flow: anchor at y=100 (within stock's extent, so stays straight) - // Will attach to left side at y = anchor.y = 100 (center of stock) - const straightFlow = makeFlow(straightUid, 75, 100, [ - { x: 100 - StockWidth / 2, y: 100, attachedToUid: stockUid }, - { x: 50, y: 100, attachedToUid: 5 }, - ]); - - // L-shaped flow 1: anchor at y=50 (above stock, so becomes L-shaped) - const lshape1 = makeFlow(lshape1Uid, 75, 75, [ - { x: 100 - StockWidth / 2, y: 100, attachedToUid: stockUid }, - { x: 50, y: 50, attachedToUid: 6 }, - ]); - - // L-shaped flow 2: anchor at y=150 (below stock, so becomes L-shaped) - const lshape2 = makeFlow(lshape2Uid, 75, 125, [ - { x: 100 - StockWidth / 2, y: 100, attachedToUid: stockUid }, - { x: 50, y: 150, attachedToUid: 7 }, - ]); - - // Move stock right so all flows attach to left side - const [newStock, newFlows] = UpdateStockAndFlows(stock, [straightFlow, lshape1, lshape2], { - x: -50, - y: 0, - }); - - expect(newStock.x).toBe(150); - - const newStraight = newFlows[0]; - const newLshape1 = newFlows[1]; - const newLshape2 = newFlows[2]; - - // Straight flow should remain 2-point - expect(newStraight.points.length).toBe(2); - // L-shaped flows should be 3-point - expect(newLshape1.points.length).toBe(3); - expect(newLshape2.points.length).toBe(3); - - // Get the Y coordinates of stock attachment points - const straightY = newStraight.points[0].y; - const lshape1Y = newLshape1.points[0].y; - const lshape2Y = newLshape2.points[0].y; - - // Straight flow stays at its anchor Y = 100 - expect(straightY).toBe(100); - - // L-shaped flows should be spread to 1/4 and 3/4, NOT 1/3 and 2/3 - // (because straight flow is included in the count) - const topEdge = 100 - StockHeight / 2; - const quarter1 = topEdge + StockHeight / 4; - const quarter3 = topEdge + (3 * StockHeight) / 4; - - // lshape1 (anchor y=50, topmost) should be at 1/4 - // lshape2 (anchor y=150, bottommost) should be at 3/4 - expect(lshape1Y).toBeCloseTo(quarter1, 1); - expect(lshape2Y).toBeCloseTo(quarter3, 1); - - // Verify no overlap: all three Y coordinates should be distinct - expect(straightY).not.toBeCloseTo(lshape1Y, 0); - expect(straightY).not.toBeCloseTo(lshape2Y, 0); - expect(lshape1Y).not.toBeCloseTo(lshape2Y, 0); - }); - }); - }); - - describe('UpdateFlow - valve movement', () => { - it('should move valve along horizontal segment', () => { - // Horizontal flow from cloud to stock - const flow = makeFlow(flowUid, 150, 100, [ - { x: 100, y: 100, attachedToUid: cloudUid }, - { x: 200, y: 100, attachedToUid: stockUid }, - ]); - const stock = makeStock(stockUid, 200, 100); - - // Move valve to the right along the segment - const [newFlow] = UpdateFlow(flow, [stock], { x: -20, y: 0 }); - - // Valve should move along the horizontal segment (Y stays same) - expect(newFlow.y).toBe(100); - expect(newFlow.x).toBe(170); - }); - - it('should move valve along vertical segment', () => { - // Vertical flow from cloud to stock - const flow = makeFlow(flowUid, 100, 150, [ - { x: 100, y: 100, attachedToUid: cloudUid }, - { x: 100, y: 200, attachedToUid: stockUid }, - ]); - const stock = makeStock(stockUid, 100, 200); - - // Move valve down along the segment - const [newFlow] = UpdateFlow(flow, [stock], { x: 0, y: -20 }); - - // Valve should move along the vertical segment (X stays same) - expect(newFlow.x).toBe(100); - expect(newFlow.y).toBe(170); - }); - - it('should constrain valve to segment bounds', () => { - // Short horizontal flow - const flow = makeFlow(flowUid, 150, 100, [ - { x: 100, y: 100, attachedToUid: cloudUid }, - { x: 200, y: 100, attachedToUid: stockUid }, - ]); - const stock = makeStock(stockUid, 200, 100); - - // Try to move valve way past the segment end - const [newFlow] = UpdateFlow(flow, [stock], { x: -500, y: 0 }); - - // Valve should be clamped to segment bounds (with margin) - expect(newFlow.y).toBe(100); - expect(newFlow.x).toBeLessThanOrEqual(190); // margin from end - expect(newFlow.x).toBeGreaterThanOrEqual(110); // margin from start - }); - - it('should clamp valve to midpoint on very short horizontal segment', () => { - // Very short horizontal flow (15px, less than 2 * margin of 20px) - const flow = makeFlow(flowUid, 107.5, 100, [ - { x: 100, y: 100, attachedToUid: cloudUid }, - { x: 115, y: 100, attachedToUid: stockUid }, - ]); - const stock = makeStock(stockUid, 115, 100); - - // Try to move valve - should clamp to midpoint since segment is too short - const [newFlow] = UpdateFlow(flow, [stock], { x: -50, y: 0 }); - - // Valve should be at segment midpoint - expect(newFlow.y).toBe(100); - expect(newFlow.x).toBe(107.5); // midpoint of 100 to 115 - }); - - it('should clamp valve to midpoint on very short vertical segment', () => { - // Very short vertical flow (15px, less than 2 * margin of 20px) - const flow = makeFlow(flowUid, 100, 107.5, [ - { x: 100, y: 100, attachedToUid: cloudUid }, - { x: 100, y: 115, attachedToUid: stockUid }, - ]); - const stock = makeStock(stockUid, 100, 115); - - // Try to move valve - should clamp to midpoint since segment is too short - const [newFlow] = UpdateFlow(flow, [stock], { x: 0, y: -50 }); - - // Valve should be at segment midpoint - expect(newFlow.x).toBe(100); - expect(newFlow.y).toBe(107.5); // midpoint of 100 to 115 - }); - - it('should move valve on L-shaped flow along closest segment', () => { - // L-shaped flow: horizontal then vertical - const flow = makeFlow(flowUid, 150, 100, [ - { x: 100, y: 100, attachedToUid: cloudUid }, // anchor - { x: 200, y: 100 }, // corner - { x: 200, y: 200, attachedToUid: stockUid }, // stock - ]); - const stock = makeStock(stockUid, 200, 200); - - // Valve at (150, 100) is on the horizontal segment - // Move it along that segment - const [newFlow] = UpdateFlow(flow, [stock], { x: -30, y: 0 }); - - // Should stay on horizontal segment - expect(newFlow.y).toBe(100); - expect(newFlow.x).toBe(180); - }); - - it('should allow valve to cross corners when dragged past them on L-shaped flow', () => { - // L-shaped flow: horizontal segment then vertical segment - // Valve starts at (150, 100) on the horizontal segment - const flow = makeFlow(flowUid, 150, 100, [ - { x: 100, y: 100, attachedToUid: cloudUid }, // anchor - { x: 200, y: 100 }, // corner - { x: 200, y: 200, attachedToUid: stockUid }, // stock - ]); - const stock = makeStock(stockUid, 200, 200); - const cloud = makeCloud(cloudUid, flowUid, 100, 100); - - // Drag valve past the corner to (200, 150) on the vertical segment. - // In UpdateFlow, proposedValve = currentValve - moveDelta, so: - // moveDelta = { x: 150 - 200, y: 100 - 150 } = { x: -50, y: -50 } - const [newFlow] = UpdateFlow(flow, [cloud, stock], { x: -50, y: -50 }); - - // Valve should have crossed to the vertical segment at (200, 150) - expect(newFlow.x).toBe(200); - expect(newFlow.y).toBe(150); - }); - - it('should allow perpendicular offset on straight horizontal flow with cloud', () => { - // Straight horizontal flow: cloud to stock - // This tests the ability to offset a straight flow to avoid overlap - const flow = makeFlow(flowUid, 150, 100, [ - { x: 100, y: 100, attachedToUid: cloudUid }, // cloud - { x: 200, y: 100, attachedToUid: stockUid }, // stock - ]); - const stock = makeStock(stockUid, 200, 100); - const cloud = makeCloud(cloudUid, flowUid, 100, 100); - - // Drag perpendicular (up) - this should convert to L-shape - // moveDelta.y = 30 means dragging up (toward lower Y) - const [newFlow, updatedClouds] = UpdateFlow(flow, [cloud, stock], { x: 0, y: 30 }); - - // The flow should now be L-shaped (3 points) to accommodate the offset - expect(newFlow.points.length).toBe(3); - - // Stock endpoint should stay fixed - const stockPoint = newFlow.points[newFlow.points.length - 1]; - expect(stockPoint.x).toBe(200); - expect(stockPoint.y).toBe(100); - - // Cloud endpoint should have moved up - const cloudPoint = newFlow.points[0]; - expect(cloudPoint.y).toBe(70); // moved up by 30 - - // There should be a corner connecting them - const corner = newFlow.points[1]; - expect(corner.y).toBe(70); // same Y as cloud (horizontal segment) - expect(corner.x).toBe(200); // same X as stock (vertical segment) - - // Cloud position should be updated - expect(updatedClouds.length).toBe(1); - expect(updatedClouds[0].y).toBe(70); - }); - - it('should allow perpendicular offset on straight vertical flow with cloud', () => { - // Straight vertical flow: cloud to stock - const flow = makeFlow(flowUid, 100, 150, [ - { x: 100, y: 100, attachedToUid: cloudUid }, // cloud - { x: 100, y: 200, attachedToUid: stockUid }, // stock - ]); - const stock = makeStock(stockUid, 100, 200); - const cloud = makeCloud(cloudUid, flowUid, 100, 100); - - // Drag perpendicular (right) - this should convert to L-shape - // moveDelta.x = -30 means dragging right (toward higher X) - const [newFlow, updatedClouds] = UpdateFlow(flow, [cloud, stock], { x: -30, y: 0 }); - - // The flow should now be L-shaped (3 points) - expect(newFlow.points.length).toBe(3); - - // Stock endpoint should stay fixed - const stockPoint = newFlow.points[newFlow.points.length - 1]; - expect(stockPoint.x).toBe(100); - expect(stockPoint.y).toBe(200); - - // Cloud endpoint should have moved right - const cloudPoint = newFlow.points[0]; - expect(cloudPoint.x).toBe(130); // moved right by 30 - - // Cloud position should be updated - expect(updatedClouds.length).toBe(1); - expect(updatedClouds[0].x).toBe(130); - }); - - it('should keep valve on flow when converting straight to L-shape', () => { - // Straight horizontal flow with valve at midpoint - const flow = makeFlow(flowUid, 150, 100, [ - { x: 100, y: 100, attachedToUid: cloudUid }, - { x: 200, y: 100, attachedToUid: stockUid }, - ]); - const stock = makeStock(stockUid, 200, 100); - const cloud = makeCloud(cloudUid, flowUid, 100, 100); - - // Drag perpendicular - converts to L-shape - const [newFlow] = UpdateFlow(flow, [cloud, stock], { x: 0, y: 30 }); - - // Valve should be clamped to the closest segment of the new L-shape - const segments = getSegments(newFlow.points); - expect(segments.length).toBe(2); - - // Valve should be on one of the segments (either the horizontal or vertical part) - const valveOnHorizontal = newFlow.y === 70; // on the horizontal segment at y=70 - const valveOnVertical = newFlow.x === 200; // on the vertical segment at x=200 - expect(valveOnHorizontal || valveOnVertical).toBe(true); - }); - - it('should not reroute on small perpendicular movement (threshold check)', () => { - // Straight horizontal flow: cloud to stock - const flow = makeFlow(flowUid, 150, 100, [ - { x: 100, y: 100, attachedToUid: cloudUid }, - { x: 200, y: 100, attachedToUid: stockUid }, - ]); - const stock = makeStock(stockUid, 200, 100); - const cloud = makeCloud(cloudUid, flowUid, 100, 100); - - // Small perpendicular movement (below threshold of 5px) should not reroute - const [newFlow, updatedClouds] = UpdateFlow(flow, [cloud, stock], { x: -20, y: 3 }); - - // Flow should remain straight (2 points) - not converted to L-shape - expect(newFlow.points.length).toBe(2); - // Cloud should not be updated - expect(updatedClouds.length).toBe(0); - // Valve should move along the segment - expect(newFlow.y).toBe(100); - expect(newFlow.x).toBe(170); // moved along segment by x delta - }); - - it('should not reroute when parallel movement is dominant', () => { - // Straight horizontal flow: cloud to stock - const flow = makeFlow(flowUid, 150, 100, [ - { x: 100, y: 100, attachedToUid: cloudUid }, - { x: 200, y: 100, attachedToUid: stockUid }, - ]); - const stock = makeStock(stockUid, 200, 100); - const cloud = makeCloud(cloudUid, flowUid, 100, 100); - - // Even with significant perpendicular movement, if parallel is larger, don't reroute - const [newFlow, updatedClouds] = UpdateFlow(flow, [cloud, stock], { x: -30, y: 20 }); - - // Flow should remain straight - parallel movement (30) > perpendicular (20) - expect(newFlow.points.length).toBe(2); - expect(updatedClouds.length).toBe(0); - }); - }); - - describe('UpdateFlow - Z-shape offset for stock-to-stock flows', () => { - // Both endpoints are stocks, so neither can move to absorb an offset. A - // perpendicular valve drag must insert two corners to form a Z whose middle - // segment runs parallel to the original axis, displaced by the drag. - const stockAUid = 1; - const stockBUid = 4; - - // A horizontal straight flow between two stocks: A(100,100) -> B(250,100). - function makeHorizontalStockFlow(): { - flow: FlowViewElement; - stockA: StockViewElement; - stockB: StockViewElement; - } { - const stockA = makeStock(stockAUid, 100, 100, [], [flowUid]); - const stockB = makeStock(stockBUid, 250, 100, [flowUid], []); - const flow = makeFlow(flowUid, 175, 100, [ - { x: 100 + StockWidth / 2, y: 100, attachedToUid: stockAUid }, // A right edge = 122.5 - { x: 250 - StockWidth / 2, y: 100, attachedToUid: stockBUid }, // B left edge = 227.5 - ]); - return { flow, stockA, stockB }; - } - - // A vertical straight flow between two stocks: A(100,100) -> B(100,250). - function makeVerticalStockFlow(): { - flow: FlowViewElement; - stockA: StockViewElement; - stockB: StockViewElement; - } { - const stockA = makeStock(stockAUid, 100, 100, [], [flowUid]); - const stockB = makeStock(stockBUid, 100, 250, [flowUid], []); - const flow = makeFlow(flowUid, 100, 175, [ - { x: 100, y: 100 + StockHeight / 2, attachedToUid: stockAUid }, // A bottom edge = 117.5 - { x: 100, y: 250 - StockHeight / 2, attachedToUid: stockBUid }, // B top edge = 232.5 - ]); - return { flow, stockA, stockB }; - } - - it('should form a Z when dragging a horizontal flow perpendicular (up)', () => { - const { flow, stockA, stockB } = makeHorizontalStockFlow(); - - // Drag valve up by 30 (moveDelta.y = 30 => middle segment at y = 100 - 30 = 70) - const [newFlow, updatedClouds] = UpdateFlow(flow, [stockA, stockB], { x: 0, y: 30 }); - - // Two corners inserted -> four points - expect(newFlow.points.length).toBe(4); - // No clouds involved - expect(updatedClouds.length).toBe(0); - - // Endpoints stay pinned on their stock edges, still attached - expect(newFlow.points[0].x).toBe(122.5); - expect(newFlow.points[0].y).toBe(100); - expect(newFlow.points[0].attachedToUid).toBe(stockAUid); - expect(newFlow.points[3].x).toBe(227.5); - expect(newFlow.points[3].y).toBe(100); - expect(newFlow.points[3].attachedToUid).toBe(stockBUid); - - // Corners are unattached and sit at the displaced Y, forming a horizontal - // middle segment parallel to the original axis. - expect(newFlow.points[1].attachedToUid).toBeUndefined(); - expect(newFlow.points[2].attachedToUid).toBeUndefined(); - expect(newFlow.points[1].x).toBe(122.5); // riser stays on A's edge X - expect(newFlow.points[1].y).toBe(70); - expect(newFlow.points[2].x).toBe(227.5); // riser stays on B's edge X - expect(newFlow.points[2].y).toBe(70); - - // Valve lands on the displaced middle segment - expect(newFlow.y).toBe(70); - }); - - it('should form a Z when dragging a horizontal flow perpendicular (down)', () => { - const { flow, stockA, stockB } = makeHorizontalStockFlow(); - - // Drag valve down by 40 (moveDelta.y = -40 => middle segment at y = 140) - const [newFlow] = UpdateFlow(flow, [stockA, stockB], { x: 0, y: -40 }); - - expect(newFlow.points.length).toBe(4); - expect(newFlow.points[1].y).toBe(140); - expect(newFlow.points[2].y).toBe(140); - // Middle segment is horizontal (parallel to the original horizontal axis) - expect(newFlow.points[1].y).toBe(newFlow.points[2].y); - expect(newFlow.y).toBe(140); - }); - - it('should form a Z when dragging a vertical flow perpendicular (right)', () => { - const { flow, stockA, stockB } = makeVerticalStockFlow(); - - // Drag valve right by 30 (moveDelta.x = -30 => middle segment at x = 130) - const [newFlow, updatedClouds] = UpdateFlow(flow, [stockA, stockB], { x: -30, y: 0 }); - - expect(newFlow.points.length).toBe(4); - expect(updatedClouds.length).toBe(0); - - // Endpoints pinned to their stock edges (top/bottom), still attached - expect(newFlow.points[0].x).toBe(100); - expect(newFlow.points[0].y).toBe(117.5); - expect(newFlow.points[0].attachedToUid).toBe(stockAUid); - expect(newFlow.points[3].x).toBe(100); - expect(newFlow.points[3].y).toBe(232.5); - expect(newFlow.points[3].attachedToUid).toBe(stockBUid); - - // Corners at displaced X, forming a vertical middle segment - expect(newFlow.points[1].x).toBe(130); - expect(newFlow.points[1].y).toBe(117.5); - expect(newFlow.points[2].x).toBe(130); - expect(newFlow.points[2].y).toBe(232.5); - expect(newFlow.points[1].x).toBe(newFlow.points[2].x); // middle is vertical - - // Valve lands on the displaced middle segment - expect(newFlow.x).toBe(130); - }); - - it('should not form a Z on a parallel (along-axis) drag', () => { - const { flow, stockA, stockB } = makeHorizontalStockFlow(); - - // Purely parallel drag: valve slides along the flow, no reroute - const [newFlow, updatedClouds] = UpdateFlow(flow, [stockA, stockB], { x: -20, y: 0 }); - - expect(newFlow.points.length).toBe(2); - expect(updatedClouds.length).toBe(0); - expect(newFlow.y).toBe(100); - // Valve slid along the segment (proposed 195, within [132.5, 217.5]) - expect(newFlow.x).toBe(195); - }); - - it('should not form a Z when perpendicular movement is below threshold', () => { - const { flow, stockA, stockB } = makeHorizontalStockFlow(); - - // Perpendicular component (3) is below the 5px threshold - const [newFlow] = UpdateFlow(flow, [stockA, stockB], { x: -20, y: 3 }); - - expect(newFlow.points.length).toBe(2); - expect(newFlow.y).toBe(100); - }); - - it('should not form a Z when parallel movement dominates', () => { - const { flow, stockA, stockB } = makeHorizontalStockFlow(); - - // Perpendicular (20) present but parallel (30) is larger - const [newFlow] = UpdateFlow(flow, [stockA, stockB], { x: -30, y: 20 }); - - expect(newFlow.points.length).toBe(2); - }); - - it('should form a horizontal-middle Z for a slightly-diagonal (near-horizontal) flow', () => { - // Near-horizontal legacy flow: endpoints drift 5px in Y but the run is - // dominantly horizontal, so the offset must be along Y with a horizontal - // middle segment (audit finding 5 dominant-axis classification). - const stockA = makeStock(stockAUid, 100, 100, [], [flowUid]); - const stockB = makeStock(stockBUid, 250, 105, [flowUid], []); - const flow = makeFlow(flowUid, 175, 102, [ - { x: 122.5, y: 100, attachedToUid: stockAUid }, - { x: 227.5, y: 105, attachedToUid: stockBUid }, - ]); - - // Drag up by 30 (proposedValve.y = 102 - 30 = 72) - const [newFlow] = UpdateFlow(flow, [stockA, stockB], { x: 0, y: 30 }); - - expect(newFlow.points.length).toBe(4); - // Middle segment is horizontal at the displaced Y - expect(newFlow.points[1].y).toBe(72); - expect(newFlow.points[2].y).toBe(72); - // Risers stay on the endpoints' X, so each is vertical despite the drift - expect(newFlow.points[1].x).toBe(122.5); - expect(newFlow.points[2].x).toBe(227.5); - // Endpoints keep their (drifted) positions - expect(newFlow.points[0].y).toBe(100); - expect(newFlow.points[3].y).toBe(105); - }); - - it('should collapse a Z back to a straight flow when the middle drags onto the axis', () => { - // Freshly-formed Z: middle segment offset to y=70. - const zFlow = makeFlow(flowUid, 175, 70, [ - { x: 122.5, y: 100, attachedToUid: stockAUid }, - { x: 122.5, y: 70 }, - { x: 227.5, y: 70 }, - { x: 227.5, y: 100, attachedToUid: stockBUid }, - ]); - const stockA = makeStock(stockAUid, 100, 100, [], [flowUid]); - const stockB = makeStock(stockBUid, 250, 100, [flowUid], []); - - // Drag the middle segment (index 1) back down onto the original axis (y=100). - // moveDelta.y = -30 moves the corners down by 30 -> y=100, collapsing. - const [newFlow] = UpdateFlow(zFlow, [stockA, stockB], { x: 0, y: -30 }, 1); - - // The colinear/zero-length cleanup in normalizeFlowPoints restores a straight flow - expect(newFlow.points.length).toBe(2); - expect(newFlow.points[0].x).toBe(122.5); - expect(newFlow.points[0].y).toBe(100); - expect(newFlow.points[1].x).toBe(227.5); - expect(newFlow.points[1].y).toBe(100); - }); - - it('should keep a Z valid when an attached stock is moved (UpdateStockAndFlows)', () => { - // Z flow with middle segment at y=70, between A(100,100) and B(250,100). - const zFlow = makeFlow(flowUid, 175, 70, [ - { x: 122.5, y: 100, attachedToUid: stockAUid }, - { x: 122.5, y: 70 }, - { x: 227.5, y: 70 }, - { x: 227.5, y: 100, attachedToUid: stockBUid }, - ]); - const stockA = makeStock(stockAUid, 100, 100, [], [flowUid]); - - // Move stock A left by 20 (moveDelta inverted: +20 => new center x = 80) - const [newStock, newFlows] = UpdateStockAndFlows(stockA, [zFlow], { x: 20, y: 0 }); - - expect(newStock.x).toBe(80); - const routed = newFlows[0]; - // Still a valid Z (four points), not corrupted into a diagonal or a stub - expect(routed.points.length).toBe(4); - // The middle segment is still horizontal at its displaced Y - expect(routed.points[1].y).toBe(70); - expect(routed.points[2].y).toBe(70); - // Endpoints remain attached to their stocks - expect(routed.points[0].attachedToUid).toBe(stockAUid); - expect(routed.points[3].attachedToUid).toBe(stockBUid); - // The moved endpoint stays on stock A's RIGHT edge (it must not jump to - // the top/bottom edge just because the riser is vertical): new center - // x=80 => right edge x=102.5, y unchanged. - expect(routed.points[0].x).toBe(80 + StockWidth / 2); - expect(routed.points[0].y).toBe(100); - // The riser stays aligned with the moved endpoint (vertical at x=102.5) - expect(routed.points[1].x).toBe(80 + StockWidth / 2); - // Segments strictly alternate orientation (orthogonal path preserved) - const segs = getSegments(routed.points); - expect(segs.length).toBe(3); - for (let i = 0; i < segs.length - 1; i++) { - expect(segs[i].isHorizontal).not.toBe(segs[i + 1].isHorizontal); - } - }); - - it('should not jump the endpoint edge across a repeated stock-nudge sequence', () => { - // Repeatedly nudging a stock on a Z must keep the endpoint on the SAME - // edge each time (no oscillation/jump between right and top). - let flow: FlowViewElement = makeFlow(flowUid, 175, 70, [ - { x: 122.5, y: 100, attachedToUid: stockAUid }, - { x: 122.5, y: 70 }, - { x: 227.5, y: 70 }, - { x: 227.5, y: 100, attachedToUid: stockBUid }, - ]); - let cx = 100; - for (let i = 0; i < 4; i++) { - const stockA = makeStock(stockAUid, cx, 100, [], [flowUid]); - // Nudge stock A left by 10 each iteration (moveDelta +10 => cx - 10) - const [newStock, newFlows] = UpdateStockAndFlows(stockA, [flow], { x: 10, y: 0 }); - cx = newStock.x; - flow = newFlows[0]; - // Endpoint stays pinned to the RIGHT edge of the (moved) stock, never - // jumping to the top edge; middle segment preserved at y=70. - expect(flow.points.length).toBe(4); - expect(flow.points[0].attachedToUid).toBe(stockAUid); - expect(flow.points[0].x).toBe(cx + StockWidth / 2); - expect(flow.points[0].y).toBe(100); - expect(flow.points[1].y).toBe(70); - expect(flow.points[2].y).toBe(70); - } - expect(cx).toBe(60); // 100 - 4*10 - }); - - it('should spread a Z and a straight flow that share a stock edge (no endpoint overlap)', () => { - // Probe: stock A has two right-edge outflows -- one straight (A->B) and one - // Z (A->C, vertical riser off the right edge). getFlowAttachmentInfo must - // classify the Z as 'right' (its occupied edge), matching computeFlowRoute, - // so the two land in the same spread group and their endpoints don't - // collide at the edge center. - const stockCUid = 6; - const straight = makeFlow(2, 175, 100, [ - { x: 122.5, y: 100, attachedToUid: stockAUid }, - { x: 227.5, y: 100, attachedToUid: stockBUid }, - ]); - const zFlow = makeFlow(5, 200, 60, [ - { x: 122.5, y: 100, attachedToUid: stockAUid }, - { x: 122.5, y: 60 }, - { x: 300, y: 60 }, - { x: 300, y: 100, attachedToUid: stockCUid }, - ]); - const stockA = makeStock(stockAUid, 100, 100, [], [2, 5]); - - // Offsets: both flows are on the 'right' side, so they spread. The straight - // flow is pinned to 0.5; the Z takes a non-center slot. - const offsets = computeFlowOffsets([straight, zFlow], stockAUid, 95, 100, { x: 100, y: 100 }); - expect(offsets.get(2)).toBe(0.5); - expect(offsets.get(5)).not.toBe(0.5); - expect(offsets.get(5)).toBeCloseTo(2 / 3, 5); - - // End to end: nudge A right by 5 (delta +5 => new center x=95). Both - // endpoints stay on the right edge (x=117.5) but at DIFFERENT y -- before - // the fix both landed at (117.5, 100). - const [newStock, newFlows] = UpdateStockAndFlows(stockA, [straight, zFlow], { x: 5, y: 0 }); - expect(newStock.x).toBe(95); - const straightEnd = newFlows[0].points[0]; - const zEnd = newFlows[1].points[0]; - expect(straightEnd.x).toBe(95 + StockWidth / 2); - expect(zEnd.x).toBe(95 + StockWidth / 2); - expect(straightEnd.y).toBe(100); - // The Z endpoint must not coincide with the straight endpoint. - expect(zEnd.y).not.toBe(straightEnd.y); - // The Z is still a valid 4-point flow with its middle preserved at y=60. - expect(newFlows[1].points.length).toBe(4); - expect(newFlows[1].points[1].y).toBe(60); - expect(newFlows[1].points[2].y).toBe(60); - }); - - it('should clamp the valve to the middle segment on a perpendicular-dominant drag with large parallel component', () => { - // Probe from review: perpendicular dominates (82 > 80) so a Z forms, but - // the large parallel component would put the CLOSEST segment on a riser. - // The valve must land on the offset middle segment, not a riser. - const stockA = makeStock(stockAUid, 100, 100, [], [flowUid]); - const stockB = makeStock(stockBUid, 250, 100, [flowUid], []); - const flow = makeFlow(flowUid, 175, 100, [ - { x: 122.5, y: 100, attachedToUid: stockAUid }, - { x: 227.5, y: 100, attachedToUid: stockBUid }, - ]); - - const [newFlow] = UpdateFlow(flow, [stockA, stockB], { x: 80, y: 82 }); - - // Z formed at the displaced Y (100 - 82 = 18) - expect(newFlow.points.length).toBe(4); - expect(newFlow.points[1].y).toBe(18); - expect(newFlow.points[2].y).toBe(18); - // Valve is on the horizontal MIDDLE segment (y=18), not stranded on a - // vertical riser at x=122.5 / x=227.5. - expect(newFlow.y).toBe(18); - expect(newFlow.x).toBeGreaterThan(122.5); - expect(newFlow.x).toBeLessThan(227.5); - }); - }); - - describe('moveSegment', () => { - it('should move horizontal segment up/down', () => { - // L-shaped flow with horizontal middle concept: - // Actually for a simple test, let's use a 3-point L - const points: readonly Point[] = [ - { x: 100, y: 200, attachedToUid: cloudUid }, - { x: 100, y: 100, attachedToUid: undefined }, // corner - { x: 200, y: 100, attachedToUid: stockUid }, - ]; - - // Move segment 1 (horizontal: corner to stock) up by 20 - const newPoints = moveSegment(points, 1, { x: 0, y: 20 }); - - // The corner should move up (it's not an endpoint) - expect(newPoints[1].y).toBe(80); - // The stock endpoint should NOT move (it's attached) - expect(newPoints[2].y).toBe(100); - // The cloud endpoint should stay - expect(newPoints[0].y).toBe(200); - }); - - it('should move vertical segment left/right', () => { - const points: readonly Point[] = [ - { x: 100, y: 100, attachedToUid: cloudUid }, - { x: 100, y: 200, attachedToUid: undefined }, // corner - { x: 200, y: 200, attachedToUid: stockUid }, - ]; - - // Move segment 0 (vertical: cloud to corner) right by 20 - const newPoints = moveSegment(points, 0, { x: -20, y: 0 }); - - // The corner should move right (it's not an endpoint) - expect(newPoints[1].x).toBe(120); - // The cloud endpoint should NOT move (it's attached) - expect(newPoints[0].x).toBe(100); - // The stock endpoint should stay - expect(newPoints[2].x).toBe(200); - }); - - it('should not move attached endpoints', () => { - // Simple 2-point horizontal flow - const points: readonly Point[] = [ - { x: 100, y: 100, attachedToUid: cloudUid }, - { x: 200, y: 100, attachedToUid: stockUid }, - ]; - - // Try to move the only segment - const newPoints = moveSegment(points, 0, { x: 0, y: -50 }); - - // Both endpoints are attached, so neither should move - expect(newPoints[0].y).toBe(100); - expect(newPoints[1].y).toBe(100); - }); - }); - - describe('UpdateFlow - segment movement', () => { - it('should move a segment when segmentIndex is provided', () => { - // L-shaped flow - const flow = makeFlow(flowUid, 150, 100, [ - { x: 100, y: 200, attachedToUid: cloudUid }, - { x: 100, y: 100 }, // corner - { x: 200, y: 100, attachedToUid: stockUid }, - ]); - const stock = makeStock(stockUid, 200, 100); - - // Move segment 1 (horizontal) up - const [newFlow] = UpdateFlow(flow, [stock], { x: 0, y: 20 }, 1); - - // Corner should have moved up - expect(newFlow.points[1].y).toBe(80); - // Endpoints should not have moved - expect(newFlow.points[0].y).toBe(200); - expect(newFlow.points[2].y).toBe(100); - }); - - it('should re-clamp valve when dragging adjacent segment that shares a corner', () => { - // 5-point flow with valve on segment 1 - // Segments: [cloud-corner1], [corner1-corner2], [corner2-corner3], [corner3-stock] - // Valve is on segment 1 (corner1-corner2), segment 2 is dragged - const flow = makeFlow(flowUid, 100, 150, [ - { x: 100, y: 100, attachedToUid: cloudUid }, // cloud - { x: 100, y: 200 }, // corner1 - { x: 150, y: 200 }, // corner2 (shared by segments 1 and 2) - { x: 150, y: 300 }, // corner3 - { x: 200, y: 300, attachedToUid: stockUid }, // stock - ]); - const stock = makeStock(stockUid, 200, 300); - - // Valve at (100, 150) is on segment 0 (vertical from cloud to corner1) - // Drag segment 1 (horizontal corner1-corner2) down - this moves corner1 and corner2 - const [newFlow] = UpdateFlow(flow, [stock], { x: 0, y: -20 }, 1); - - // Segment 1 moved: corner1 and corner2 moved down by 20 - expect(newFlow.points[1].y).toBe(220); - expect(newFlow.points[2].y).toBe(220); - - // The valve was on segment 0 (cloud at y=100 to corner1 at y=200, vertical at x=100) - // Now segment 0 goes from y=100 to y=220 (longer), still vertical at x=100 - // The valve should still be clamped to segment 0 since it's closest - expect(newFlow.x).toBe(100); - // Valve y should still be within the segment (100+margin to 220-margin) - expect(newFlow.y).toBeGreaterThanOrEqual(100); - expect(newFlow.y).toBeLessThanOrEqual(220); - }); - }); - - describe('findClickedSegment', () => { - it('should return undefined when clicking on the valve', () => { - // 4-point flow with valve at (150, 100) - const points: readonly Point[] = [ - { x: 100, y: 200, attachedToUid: cloudUid }, - { x: 100, y: 100, attachedToUid: undefined }, - { x: 200, y: 100, attachedToUid: undefined }, - { x: 200, y: 50, attachedToUid: stockUid }, - ]; - const valveCx = 150; - const valveCy = 100; - - // Click exactly on the valve - const result = findClickedSegment(150, 100, valveCx, valveCy, points); - expect(result).toBeUndefined(); - - // Click near the valve (within tolerance) - const result2 = findClickedSegment(155, 103, valveCx, valveCy, points); - expect(result2).toBeUndefined(); - }); - - it('should return undefined for single-segment (straight) flows', () => { - // Straight horizontal flow - only 2 points - const points: readonly Point[] = [ - { x: 100, y: 100, attachedToUid: cloudUid }, - { x: 200, y: 100, attachedToUid: stockUid }, - ]; - const valveCx = 150; - const valveCy = 100; - - // Click away from the valve on the segment - const result = findClickedSegment(120, 100, valveCx, valveCy, points); - expect(result).toBeUndefined(); - }); - - it('should return undefined for L-shaped flow segments with attached endpoints', () => { - // L-shaped flow: both segments have one attached endpoint - // Segment 0 has attached first point, segment 1 has attached last point - const points: readonly Point[] = [ - { x: 100, y: 200, attachedToUid: cloudUid }, - { x: 100, y: 100, attachedToUid: undefined }, // corner - { x: 200, y: 100, attachedToUid: stockUid }, - ]; - const valveCx = 150; - const valveCy = 100; - - // Click on the vertical segment (segment 0) - has attached first point - const result = findClickedSegment(100, 150, valveCx, valveCy, points); - expect(result).toBeUndefined(); - - // Click on the horizontal segment (segment 1) - has attached last point - const result2 = findClickedSegment(180, 100, valveCx, valveCy, points); - expect(result2).toBeUndefined(); - }); - - it('should return segment index for middle segment of 4-point flow', () => { - // 4-point flow: 3 segments, middle segment has no attached endpoints - // Segment 0: attached -> corner1 (has attached endpoint) - // Segment 1: corner1 -> corner2 (no attached endpoints - CAN drag) - // Segment 2: corner2 -> attached (has attached endpoint) - const points: readonly Point[] = [ - { x: 100, y: 200, attachedToUid: cloudUid }, - { x: 100, y: 100, attachedToUid: undefined }, // corner1 - { x: 200, y: 100, attachedToUid: undefined }, // corner2 - { x: 200, y: 50, attachedToUid: stockUid }, - ]; - const valveCx = 150; - const valveCy = 100; - - // Click on the middle horizontal segment (segment 1) - const result = findClickedSegment(150, 100 + 20, valveCx, valveCy, points); - expect(result).toBe(1); - - // Click on segment 0 (has attached endpoint) - should return undefined - const result2 = findClickedSegment(100, 150, valveCx, valveCy, points); - expect(result2).toBeUndefined(); - - // Click on segment 2 (has attached endpoint) - should return undefined - const result3 = findClickedSegment(200, 75, valveCx, valveCy, points); - expect(result3).toBeUndefined(); - }); - - it('should return undefined for empty points list', () => { - const points: readonly Point[] = []; - const result = findClickedSegment(100, 100, 100, 100, points); - expect(result).toBeUndefined(); - }); - - it('should return undefined for diagonal segments (from imported models)', () => { - // 4-point flow with a diagonal middle segment (shouldn't exist in valid geometry, - // but could appear in imported models). Diagonal segments can't be dragged - // because moveSegment assumes axis-aligned segments. - const points: readonly Point[] = [ - { x: 100, y: 200, attachedToUid: cloudUid }, - { x: 100, y: 100, attachedToUid: undefined }, // corner1 - { x: 200, y: 150, attachedToUid: undefined }, // corner2 - diagonal from corner1! - { x: 200, y: 50, attachedToUid: stockUid }, - ]; - const valveCx = 150; - const valveCy = 125; - - // Click on the diagonal middle segment (segment 1) - should return undefined - const result = findClickedSegment(150, 125, valveCx, valveCy, points); - expect(result).toBeUndefined(); - }); - }); - - describe('getSegments', () => { - it('should identify horizontal segments', () => { - const points: readonly Point[] = [ - { x: 100, y: 100, attachedToUid: undefined }, - { x: 200, y: 100, attachedToUid: undefined }, - ]; - const segments = getSegments(points); - - expect(segments.length).toBe(1); - expect(segments[0].isHorizontal).toBe(true); - expect(segments[0].isVertical).toBe(false); - expect(segments[0].isDiagonal).toBe(false); - }); - - it('should identify vertical segments', () => { - const points: readonly Point[] = [ - { x: 100, y: 100, attachedToUid: undefined }, - { x: 100, y: 200, attachedToUid: undefined }, - ]; - const segments = getSegments(points); - - expect(segments.length).toBe(1); - expect(segments[0].isHorizontal).toBe(false); - expect(segments[0].isVertical).toBe(true); - expect(segments[0].isDiagonal).toBe(false); - }); - - it('should identify diagonal segments', () => { - const points: readonly Point[] = [ - { x: 100, y: 100, attachedToUid: undefined }, - { x: 200, y: 200, attachedToUid: undefined }, - ]; - const segments = getSegments(points); - - expect(segments.length).toBe(1); - expect(segments[0].isHorizontal).toBe(false); - expect(segments[0].isVertical).toBe(false); - expect(segments[0].isDiagonal).toBe(true); - }); - - it('should handle mixed segment types', () => { - // Path: horizontal -> diagonal -> vertical - const points: readonly Point[] = [ - { x: 100, y: 100, attachedToUid: undefined }, - { x: 200, y: 100, attachedToUid: undefined }, - { x: 250, y: 150, attachedToUid: undefined }, - { x: 250, y: 250, attachedToUid: undefined }, - ]; - const segments = getSegments(points); - - expect(segments.length).toBe(3); - expect(segments[0].isHorizontal).toBe(true); - expect(segments[0].isDiagonal).toBe(false); - expect(segments[1].isHorizontal).toBe(false); - expect(segments[1].isDiagonal).toBe(true); - expect(segments[2].isVertical).toBe(true); - expect(segments[2].isDiagonal).toBe(false); - }); - - it('should return empty array for single point', () => { - const points: readonly Point[] = [{ x: 100, y: 100, attachedToUid: undefined }]; - const segments = getSegments(points); - expect(segments.length).toBe(0); - }); - - it('should return empty array for empty points list', () => { - const points: readonly Point[] = []; - const segments = getSegments(points); - expect(segments.length).toBe(0); - }); - }); - - describe('UpdateCloudAndFlow - multi-segment flows', () => { - it('should not move valve on interior segment when arrowhead moves vertically', () => { - // 3-point L-shaped flow: source -> corner -> arrowhead (cloud) - // Segment 0 is horizontal (source to corner), valve is on segment 0 - // Segment 1 is vertical (corner to arrowhead) - // Moving the arrowhead vertically should NOT move the valve, since it's - // on segment 0 (the horizontal segment) which doesn't change. - const sourceUid = 1; - const cloudUid = 3; - const sourceX = 100; - const sourceEdgeX = sourceX + StockWidth / 2; - const cornerX = 200; - const cornerY = 100; - const arrowheadX = cornerX; - const arrowheadY = 200; - - // Valve at (150, 100) on the horizontal segment - const valveX = 150; - const valveY = cornerY; - - const flow = makeFlow(flowUid, valveX, valveY, [ - { x: sourceEdgeX, y: cornerY, attachedToUid: sourceUid }, // source edge - { x: cornerX, y: cornerY }, // corner - { x: arrowheadX, y: arrowheadY, attachedToUid: cloudUid }, // arrowhead - ]); - - // Cloud at arrowhead position - const cloud = makeCloud(cloudUid, flowUid, arrowheadX, arrowheadY); - - // Move arrowhead down by 50 (moveDelta is inverted, so delta.y = 50 moves down) - // The original cloud position is (200, 200), new position will be (200, 250) - const moveDelta = { x: 0, y: -50 }; - const [newCloud, newFlow] = UpdateCloudAndFlow(cloud, flow, moveDelta); - - // Valve should NOT have moved vertically since it's on a horizontal segment - // that wasn't affected by the vertical arrowhead movement - expect(newFlow.x).toBe(valveX); - expect(newFlow.y).toBe(valveY); - - // Cloud should have moved - expect(newCloud.y).toBe(arrowheadY + 50); - }); - - it('should preserve valve position on segment 0 when arrowhead segment changes', () => { - // Same setup as above, but testing that valve fraction is preserved if - // the arrowhead movement affects segment 0 - const sourceUid = 1; - const cloudUid = 3; - const sourceX = 100; - const sourceEdgeX = sourceX + StockWidth / 2; - const cornerX = 200; - const cornerY = 100; - const arrowheadX = cornerX; - const arrowheadY = 200; - - // Valve at corner on the horizontal segment - const valveX = cornerX; - const valveY = cornerY; - - const flow = makeFlow(flowUid, valveX, valveY, [ - { x: sourceEdgeX, y: cornerY, attachedToUid: sourceUid }, - { x: cornerX, y: cornerY }, - { x: arrowheadX, y: arrowheadY, attachedToUid: cloudUid }, - ]); - - const cloud = makeCloud(cloudUid, flowUid, arrowheadX, arrowheadY); - - // Move arrowhead vertically - const moveDelta = { x: 0, y: -50 }; - const [, newFlow] = UpdateCloudAndFlow(cloud, flow, moveDelta); - - // Valve on segment 0 should stay put - expect(newFlow.y).toBe(valveY); - }); - - it('should update valve when it is on the segment adjacent to the moving arrowhead', () => { - // 3-point L-shaped flow with valve on segment 1 (adjacent to arrowhead) - const sourceUid = 1; - const cloudUid = 3; - const sourceX = 100; - const sourceEdgeX = sourceX + StockWidth / 2; - const cornerX = 200; - const cornerY = 100; - const arrowheadX = cornerX; - const arrowheadY = 200; - - // Valve at (200, 150) on the vertical segment (segment 1) - const valveX = cornerX; - const valveY = 150; - - const flow = makeFlow(flowUid, valveX, valveY, [ - { x: sourceEdgeX, y: cornerY, attachedToUid: sourceUid }, - { x: cornerX, y: cornerY }, - { x: arrowheadX, y: arrowheadY, attachedToUid: cloudUid }, - ]); - - const cloud = makeCloud(cloudUid, flowUid, arrowheadX, arrowheadY); - - // Move arrowhead down by 50 - const moveDelta = { x: 0, y: -50 }; - const [newCloud, newFlow] = UpdateCloudAndFlow(cloud, flow, moveDelta); - - // Valve is on segment 1 (the segment adjacent to arrowhead), so it should - // preserve its fractional position. Original segment: corner(200,100) to - // arrowhead(200,200), length=100. Valve at (200,150) is 50% along. - // New segment: corner(200,100) to arrowhead(200,250), length=150. - // New valve should be at 50% = (200, 100 + 0.5*150) = (200, 175) - expect(newFlow.x).toBe(valveX); - expect(newFlow.y).toBeCloseTo(175, 0); - - // Cloud should have moved down - expect(newCloud.y).toBe(250); - }); - }); - - describe('UpdateCloudAndFlow - perpendicular offset', () => { - it('should create L-shape when cloud dragged perpendicular to horizontal 2-point flow', () => { - // 2-point horizontal flow: stock -> cloud - // Drag cloud upward (perpendicular) -> should create 3-point L-shape - const stockUid = 1; - const cloudUid = 3; - const stockX = 100; - const stockEdgeX = stockX + StockWidth / 2; - const cloudX = 200; - const flowY = 100; - - const flow = makeFlow(flowUid, 150, flowY, [ - { x: stockEdgeX, y: flowY, attachedToUid: stockUid }, - { x: cloudX, y: flowY, attachedToUid: cloudUid }, - ]); - - const cloud = makeCloud(cloudUid, flowUid, cloudX, flowY); - - // Drag cloud up by 30 (perpendicular to horizontal flow) - // moveDelta is inverted, so positive y means moving up (to lower Y) - const moveDelta = { x: 0, y: 30 }; - const [newCloud, newFlow] = UpdateCloudAndFlow(cloud, flow, moveDelta); - - // Flow should now be L-shaped (3 points) - expect(newFlow.points.length).toBe(3); - - // Stock endpoint should stay fixed - const stockPoint = newFlow.points[0]; - expect(stockPoint.x).toBe(stockEdgeX); - expect(stockPoint.y).toBe(flowY); - expect(stockPoint.attachedToUid).toBe(stockUid); - - // Cloud endpoint should have moved up - const cloudPoint = newFlow.points[2]; - expect(cloudPoint.y).toBe(flowY - 30); - expect(cloudPoint.attachedToUid).toBe(cloudUid); - - // Corner should connect them orthogonally - const corner = newFlow.points[1]; - expect(corner.y).toBe(cloudPoint.y); // same Y as cloud (horizontal to cloud) - expect(corner.x).toBe(stockEdgeX); // same X as stock (vertical from stock) - - // Cloud position should be updated - expect(newCloud.y).toBe(flowY - 30); - }); - - it('should create L-shape when cloud dragged perpendicular to vertical 2-point flow', () => { - // 2-point vertical flow: stock -> cloud - // Drag cloud leftward (perpendicular) -> should create 3-point L-shape - const stockUid = 1; - const cloudUid = 3; - const stockY = 100; - const stockEdgeY = stockY + StockHeight / 2; - const cloudY = 200; - const flowX = 100; - - const flow = makeFlow(flowUid, flowX, 150, [ - { x: flowX, y: stockEdgeY, attachedToUid: stockUid }, - { x: flowX, y: cloudY, attachedToUid: cloudUid }, - ]); - - const cloud = makeCloud(cloudUid, flowUid, flowX, cloudY); - - // Drag cloud right by 30 (perpendicular to vertical flow) - // moveDelta is inverted, so negative x means moving right (to higher X) - const moveDelta = { x: -30, y: 0 }; - const [newCloud, newFlow] = UpdateCloudAndFlow(cloud, flow, moveDelta); - - // Flow should now be L-shaped (3 points) - expect(newFlow.points.length).toBe(3); - - // Stock endpoint should stay fixed - const stockPoint = newFlow.points[0]; - expect(stockPoint.x).toBe(flowX); - expect(stockPoint.y).toBe(stockEdgeY); - expect(stockPoint.attachedToUid).toBe(stockUid); - - // Cloud endpoint should have moved right - const cloudPoint = newFlow.points[2]; - expect(cloudPoint.x).toBe(flowX + 30); - expect(cloudPoint.attachedToUid).toBe(cloudUid); - - // Corner should connect them orthogonally - const corner = newFlow.points[1]; - expect(corner.x).toBe(cloudPoint.x); // same X as cloud (vertical to cloud) - expect(corner.y).toBe(stockEdgeY); // same Y as stock (horizontal from stock) - - // Cloud position should be updated - expect(newCloud.x).toBe(flowX + 30); - }); - - it('should not create L-shape for small perpendicular movements (threshold)', () => { - // 2-point horizontal flow: stock -> cloud - // Small perpendicular movement < 5px should NOT trigger L-shape - const stockUid = 1; - const cloudUid = 3; - const stockX = 100; - const stockEdgeX = stockX + StockWidth / 2; - const cloudX = 200; - const flowY = 100; - - const flow = makeFlow(flowUid, 150, flowY, [ - { x: stockEdgeX, y: flowY, attachedToUid: stockUid }, - { x: cloudX, y: flowY, attachedToUid: cloudUid }, - ]); - - const cloud = makeCloud(cloudUid, flowUid, cloudX, flowY); - - // Small perpendicular movement (3px, below threshold of 5px) - const moveDelta = { x: 0, y: 3 }; - const [newCloud, newFlow] = UpdateCloudAndFlow(cloud, flow, moveDelta); - - // Flow should remain straight (2 points) - expect(newFlow.points.length).toBe(2); - - // Cloud should still be at the same Y (constrained to flow axis) - expect(newCloud.y).toBe(flowY); - }); - - it('should not create L-shape when parallel movement dominates', () => { - // 2-point horizontal flow: stock -> cloud - // If parallel movement > perpendicular, don't reroute - const stockUid = 1; - const cloudUid = 3; - const stockX = 100; - const stockEdgeX = stockX + StockWidth / 2; - const cloudX = 200; - const flowY = 100; - - const flow = makeFlow(flowUid, 150, flowY, [ - { x: stockEdgeX, y: flowY, attachedToUid: stockUid }, - { x: cloudX, y: flowY, attachedToUid: cloudUid }, - ]); - - const cloud = makeCloud(cloudUid, flowUid, cloudX, flowY); - - // Parallel movement (30px) dominates perpendicular (10px) - const moveDelta = { x: -30, y: 10 }; - const [newCloud, newFlow] = UpdateCloudAndFlow(cloud, flow, moveDelta); - - // Flow should remain straight (2 points) since parallel dominates - expect(newFlow.points.length).toBe(2); - - // Cloud should have moved along the flow axis - expect(newCloud.x).toBe(cloudX + 30); - expect(newCloud.y).toBe(flowY); // constrained to horizontal - }); - - it('should handle source cloud perpendicular offset (cloud at first point)', () => { - // 2-point horizontal flow: cloud -> stock - // Drag cloud perpendicular -> should create L-shape with corner near stock - const stockUid = 1; - const cloudUid = 3; - const cloudX = 100; - const stockX = 200; - const stockEdgeX = stockX - StockWidth / 2; - const flowY = 100; - - const flow = makeFlow(flowUid, 150, flowY, [ - { x: cloudX, y: flowY, attachedToUid: cloudUid }, - { x: stockEdgeX, y: flowY, attachedToUid: stockUid }, - ]); - - const cloud = makeCloud(cloudUid, flowUid, cloudX, flowY); - - // Drag cloud up by 30 (perpendicular to horizontal flow) - const moveDelta = { x: 0, y: 30 }; - const [newCloud, newFlow] = UpdateCloudAndFlow(cloud, flow, moveDelta); - - // Flow should now be L-shaped (3 points) - expect(newFlow.points.length).toBe(3); - - // Cloud endpoint should have moved up - const cloudPoint = newFlow.points[0]; - expect(cloudPoint.y).toBe(flowY - 30); - expect(cloudPoint.attachedToUid).toBe(cloudUid); - - // Stock endpoint should stay fixed - const stockPoint = newFlow.points[2]; - expect(stockPoint.x).toBe(stockEdgeX); - expect(stockPoint.y).toBe(flowY); - expect(stockPoint.attachedToUid).toBe(stockUid); - - // Corner should connect them orthogonally - const corner = newFlow.points[1]; - expect(corner.y).toBe(cloudPoint.y); // same Y as cloud (horizontal from cloud) - expect(corner.x).toBe(stockEdgeX); // same X as stock (vertical to stock) - - // Cloud position should be updated - expect(newCloud.y).toBe(flowY - 30); - }); - - it('should update adjacent corner when dragging cloud on multi-segment flow', () => { - // 3-point L-shaped flow: stock -> corner -> cloud - // Dragging cloud should update the adjacent corner while preserving orthogonality - const stockUid = 1; - const cloudUid = 3; - const stockX = 100; - const stockEdgeX = stockX + StockWidth / 2; - const cornerX = 200; - const cornerY = 100; - const cloudX = cornerX; - const cloudY = 200; - - const flow = makeFlow(flowUid, 150, cornerY, [ - { x: stockEdgeX, y: cornerY, attachedToUid: stockUid }, - { x: cornerX, y: cornerY }, - { x: cloudX, y: cloudY, attachedToUid: cloudUid }, - ]); - - const cloud = makeCloud(cloudUid, flowUid, cloudX, cloudY); - - // Drag cloud horizontally (perpendicular to the vertical segment) - const moveDelta = { x: -30, y: 0 }; - const [newCloud, newFlow] = UpdateCloudAndFlow(cloud, flow, moveDelta); - - // Flow should still be L-shaped (3 points) - expect(newFlow.points.length).toBe(3); - - // Stock endpoint should stay fixed - const stockPoint = newFlow.points[0]; - expect(stockPoint.x).toBe(stockEdgeX); - expect(stockPoint.y).toBe(cornerY); - - // Cloud should have moved horizontally - const cloudPoint = newFlow.points[2]; - expect(cloudPoint.x).toBe(cloudX + 30); - expect(cloudPoint.y).toBe(cloudY); - - // Corner should be updated to maintain orthogonality - const corner = newFlow.points[1]; - expect(corner.x).toBe(cloudX + 30); // X updated to match cloud - expect(corner.y).toBe(cornerY); // Y preserved to maintain horizontal first segment - - // Cloud position should be updated - expect(newCloud.x).toBe(cloudX + 30); - }); - - it('should preserve valve fractional position on multi-segment flow when cloud moves', () => { - // 3-point L-shaped flow with valve on the vertical segment (segment 1) - // When cloud moves, valve's fractional position along its segment should be preserved - const stockUid = 1; - const cloudUid = 3; - const stockX = 100; - const stockEdgeX = stockX + StockWidth / 2; - const cornerX = 200; - const cornerY = 100; - const cloudX = cornerX; - const cloudY = 200; - - // Valve at (200, 150) - 50% along the vertical segment from corner (200,100) to cloud (200,200) - const valveX = cornerX; - const valveY = 150; - - const flow = makeFlow(flowUid, valveX, valveY, [ - { x: stockEdgeX, y: cornerY, attachedToUid: stockUid }, - { x: cornerX, y: cornerY }, - { x: cloudX, y: cloudY, attachedToUid: cloudUid }, - ]); - - const cloud = makeCloud(cloudUid, flowUid, cloudX, cloudY); - - // Move cloud down by 50 (extending the vertical segment) - const moveDelta = { x: 0, y: -50 }; - const [, newFlow] = UpdateCloudAndFlow(cloud, flow, moveDelta); - - // Valve was at 50% of segment 1 (corner to cloud) - // Original segment: (200, 100) to (200, 200), length 100, valve at y=150 (50%) - // New segment: (200, 100) to (200, 250), length 150, valve should be at 50% = y=175 - expect(newFlow.x).toBe(valveX); - expect(newFlow.y).toBeCloseTo(175, 0); - }); - - it('should keep multi-segment endpoints on stock edge when dragging stock-attached source', () => { - // 3-point L-shaped flow: stock -> corner -> cloud - // When dragging the source (attached to stock), the endpoint should stay on - // the stock's edge, not shift to the stock's center. - const stockUid = 1; - const cloudUid = 3; - const stockX = 100; - const stockY = 100; - const stockEdgeX = stockX + StockWidth / 2; // Right edge of stock - const cornerX = 200; - const cornerY = stockY; // Horizontal segment from stock edge to corner - const cloudX = cornerX; - const cloudY = 200; - - // Flow starts at stock's right edge, goes horizontal to corner, then vertical to cloud - const flow = makeFlow(flowUid, 150, cornerY, [ - { x: stockEdgeX, y: cornerY, attachedToUid: stockUid }, - { x: cornerX, y: cornerY }, - { x: cloudX, y: cloudY, attachedToUid: cloudUid }, - ]); - - // Create a stock (not a cloud) to simulate dragging the source end - const stock = makeStock(stockUid, stockX, stockY); - - // Apply zero movement - this simulates "dropping back on the same stock" - // The endpoint should stay on the stock edge, not shift to stock center - const moveDelta = { x: 0, y: 0 }; - const [, newFlow] = UpdateCloudAndFlow(stock, flow, moveDelta); - - // The source endpoint should still be on the stock's right edge - const sourcePoint = newFlow.points[0]; - expect(sourcePoint.x).toBe(stockEdgeX); // Should be on edge, not stockX (center) - expect(sourcePoint.y).toBe(cornerY); - - // Corner should be unchanged - const corner = newFlow.points[1]; - expect(corner.x).toBe(cornerX); - expect(corner.y).toBe(cornerY); - }); - - it('should keep multi-segment endpoints on stock edge when dragging stock-attached sink', () => { - // 3-point L-shaped flow: cloud -> corner -> stock - // When dragging the sink (attached to stock), the endpoint should stay on - // the stock's edge, not shift to the stock's center. - const stockUid = 1; - const cloudUid = 3; - const cloudX = 100; - const cloudY = 100; - const cornerX = 200; - const cornerY = cloudY; // Horizontal segment from cloud to corner - const stockX = 200; - const stockY = 200; - const stockEdgeY = stockY - StockHeight / 2; // Top edge of stock - - // Flow starts at cloud, goes horizontal to corner, then vertical to stock's top edge - const flow = makeFlow(flowUid, 150, cornerY, [ - { x: cloudX, y: cloudY, attachedToUid: cloudUid }, - { x: cornerX, y: cornerY }, - { x: cornerX, y: stockEdgeY, attachedToUid: stockUid }, - ]); - - // Create a stock (not a cloud) to simulate dragging the sink end - const stock = makeStock(stockUid, stockX, stockY); - - // Apply zero movement - this simulates "dropping back on the same stock" - const moveDelta = { x: 0, y: 0 }; - const [, newFlow] = UpdateCloudAndFlow(stock, flow, moveDelta); - - // The sink endpoint should still be on the stock's top edge - const sinkPoint = newFlow.points[2]; - expect(sinkPoint.x).toBe(cornerX); - expect(sinkPoint.y).toBe(stockEdgeY); // Should be on edge, not stockY (center) - - // Corner should be unchanged - const corner = newFlow.points[1]; - expect(corner.x).toBe(cornerX); - expect(corner.y).toBe(cornerY); - }); - - it('should apply movement delta to existing endpoint position, not stock center', () => { - // When dragging a stock-attached endpoint by some delta, the new position - // should be computed from the current endpoint position (on the edge), - // not from the stock's center. - const stockUid = 1; - const cloudUid = 3; - const stockX = 100; - const stockY = 100; - const stockEdgeX = stockX + StockWidth / 2; - const cornerX = 200; - const cornerY = stockY; - const cloudX = cornerX; - const cloudY = 200; - - const flow = makeFlow(flowUid, 150, cornerY, [ - { x: stockEdgeX, y: cornerY, attachedToUid: stockUid }, - { x: cornerX, y: cornerY }, - { x: cloudX, y: cloudY, attachedToUid: cloudUid }, - ]); - - const stock = makeStock(stockUid, stockX, stockY); - - // Drag the source down by 20 pixels - const moveDelta = { x: 0, y: -20 }; - const [, newFlow] = UpdateCloudAndFlow(stock, flow, moveDelta); - - // The source endpoint should move from the edge position, not the center - // Original position: (stockEdgeX, cornerY) = (130, 100) - // With moveDelta.y = -20 (inverted, so +20 to Y): new Y should be 120 - const sourcePoint = newFlow.points[0]; - expect(sourcePoint.x).toBe(stockEdgeX); // X unchanged for vertical movement - expect(sourcePoint.y).toBe(cornerY + 20); // Y moved from edge position - - // Corner Y should also update to maintain orthogonality (horizontal segment) - const corner = newFlow.points[1]; - expect(corner.y).toBe(cornerY + 20); - }); - }); - - describe('UpdateCloudAndFlow - valve fraction on parallel cloud drag (adjustFlows valve formula)', () => { - // A parallel drag of a straight cloud flow constrains the cloud to the flow - // axis and re-places the valve via adjustFlows, which preserves the valve's - // fractional position between the fixed other end and the moved cloud using - // base=min(otherEnd, cloud) + abs(fraction*d). These pin that formula's - // output. - // Fixture: source point at x=100 (uid 1), cloud sink at x=200 (uid 3), flow - // horizontal at y=100; drag the cloud right by 20 (cloud -> x=220). - const makeParallelCase = (valveX: number) => { - const cloud = makeCloud(3, 30, 200, 100); - const flow = makeFlow(30, valveX, 100, [ - { x: 100, y: 100, attachedToUid: 1 }, - { x: 200, y: 100, attachedToUid: 3 }, - ]); - return UpdateCloudAndFlow(cloud, flow, { x: -20, y: 0 }); - }; - - it('scales an off-center valve toward the moved cloud', () => { - // fraction.x = (220-100)/(200-100) = 1.2; d.x = 175-100 = 75; - // valve.x = min(100,220) + |1.2*75| = 100 + 90 = 190. - const [newCloud, newFlow] = makeParallelCase(175); - expect(newFlow.points.length).toBe(2); - expect(newCloud.x).toBe(220); - expect(newFlow.x).toBe(190); - expect(newFlow.y).toBe(100); - }); - - it('scales a centered valve toward the moved cloud', () => { - // fraction.x = 1.2; d.x = 150-100 = 50; valve.x = 100 + |1.2*50| = 160. - const [, newFlow] = makeParallelCase(150); - expect(newFlow.x).toBe(160); - expect(newFlow.y).toBe(100); - }); - - it('leaves the valve in place for a zero-delta drag (identity)', () => { - // fraction.x = (200-100)/(200-100) = 1; d.x = 175-100 = 75; - // valve.x = 100 + |1*75| = 175 (unchanged). - const cloud = makeCloud(3, 30, 200, 100); - const flow = makeFlow(30, 175, 100, [ - { x: 100, y: 100, attachedToUid: 1 }, - { x: 200, y: 100, attachedToUid: 3 }, - ]); - const [newCloud, newFlow] = UpdateCloudAndFlow(cloud, flow, { x: 0, y: 0 }); - expect(newCloud.x).toBe(200); - expect(newFlow.x).toBe(175); - expect(newFlow.y).toBe(100); - }); - }); - - describe('UpdateCloudAndFlow - source cloud valve is not reflected (issue #832)', () => { - // The mirror above happens to be idempotent only when the fixed otherEnd is - // the SMALLER coordinate (a sink cloud on the right). When the DRAGGED cloud - // is the smaller-coordinate end (a SOURCE cloud on the left), the old formula - // `base = min(otherEnd, cloud) + abs(fraction*d)` measured the valve's offset - // from the wrong reference and reflected an off-center valve across the - // segment midpoint on the very first drag frame (#832). The fix preserves the - // valve's fractional position anchored to otherEnd, so a zero/sub-threshold - // move leaves the valve put and a real drag scales it correctly. - // - // Fixture: SOURCE cloud (uid 3) at x=100 (first point), fixed other end (uid - // 1) at x=200, flow horizontal at y=100, valve off-center at x=175 (fraction - // 0.25 from otherEnd toward the cloud). - const makeSourceCase = (valveX: number, delta: { x: number; y: number }) => { - const cloud = makeCloud(3, 30, 100, 100); - const flow = makeFlow(30, valveX, 100, [ - { x: 100, y: 100, attachedToUid: 3 }, - { x: 200, y: 100, attachedToUid: 1 }, - ]); - return UpdateCloudAndFlow(cloud, flow, delta); - }; - - it('leaves an off-center valve unmoved on a zero-delta source-cloud grab', () => { - // The reported bug: grabbing the source cloud (no movement yet) reflected - // the valve 175 -> 125. It must stay at 175. - const [newCloud, newFlow] = makeSourceCase(175, { x: 0, y: 0 }); - expect(newCloud.x).toBe(100); - expect(newFlow.x).toBe(175); - expect(newFlow.y).toBe(100); - }); - - it('leaves an off-center valve unmoved on a sub-threshold perpendicular grab', () => { - // A 3px perpendicular twitch (below the 5px reroute threshold) still runs - // the axis-constrain path; the valve must not reflect. - const [, newFlow] = makeSourceCase(175, { x: 0, y: 3 }); - expect(newFlow.points.length).toBe(2); - expect(newFlow.x).toBe(175); - }); - - it('preserves the valve fraction when the source cloud is dragged along-axis', () => { - // Drag the source cloud LEFT by 20 (cloud 100 -> 80). The valve was at - // fraction 0.25 from otherEnd(200): new x = 200 + 0.25*(80-200) = 170. - // The old mirror teleported it to 110. - const [newCloud, newFlow] = makeSourceCase(175, { x: 20, y: 0 }); - expect(newCloud.x).toBe(80); - expect(newFlow.x).toBeCloseTo(170, 5); - expect(newFlow.y).toBe(100); - }); - - it('keeps the valve between the ends when the source cloud crosses past otherEnd', () => { - // Drag the source cloud RIGHT past the fixed end: cloud 100 -> 250, so the - // segment flips (cloud now to the right of otherEnd=200). The valve stays - // between them: 200 + 0.25*(250-200) = 212.5. - const [newCloud, newFlow] = makeSourceCase(175, { x: -150, y: 0 }); - expect(newCloud.x).toBe(250); - expect(newFlow.x).toBeCloseTo(212.5, 5); - expect(newFlow.x).toBeGreaterThan(200); - expect(newFlow.x).toBeLessThan(250); - }); - - it('leaves an off-center valve unmoved on a zero-delta vertical (top) source grab', () => { - // Vertical flow: source cloud (uid 3) at the TOP (y=100, min), fixed end at - // y=200. Off-center valve at y=175. A zero-delta grab must not reflect it. - const cloud = makeCloud(3, 30, 100, 100); - const flow = makeFlow(30, 100, 175, [ - { x: 100, y: 100, attachedToUid: 3 }, - { x: 100, y: 200, attachedToUid: 1 }, - ]); - const [, newFlow] = UpdateCloudAndFlow(cloud, flow, { x: 0, y: 0 }); - expect(newFlow.x).toBe(100); - expect(newFlow.y).toBe(175); - }); - }); - - describe('UpdateCloudAndFlow - reroute preserves along-axis cloud travel (diagonal teleport)', () => { - // When a cloud drag has traveled ALONG the flow axis and then crosses the - // perpendicular threshold, the reroute rebuilds the L from the ORIGINAL - // 2-point flow (the live drag re-routes from the committed flow every frame). - // The reroute must apply BOTH the perpendicular AND the parallel component of - // the accumulated moveDelta to the cloud endpoint; applying only the - // perpendicular one snapped the cloud back to its original along-axis - // position -- a one-frame teleport. The reroute condition (perp > par) still - // holds while the parallel travel is large in absolute terms. - - it('applies both deltas to a horizontal sink-cloud reroute (no along-axis snap-back)', () => { - const stockUid = 1; - const cloudUid = 3; - const stockEdgeX = 100 + StockWidth / 2; // 122.5 - const flow = makeFlow(flowUid, 200, 200, [ - { x: stockEdgeX, y: 200, attachedToUid: stockUid }, - { x: 300, y: 200, attachedToUid: cloudUid }, - ]); - const cloud = makeCloud(cloudUid, flowUid, 300, 200); - - // Cloud dragged RIGHT by 150 (parallel) and DOWN by 160 (perpendicular, - // dominant so it reroutes). moveDelta is inverted (press - cursor). - const [newCloud, newFlow] = UpdateCloudAndFlow(cloud, flow, { x: -150, y: -160 }); - - expect(newFlow.points.length).toBe(3); - const sink = newFlow.points[2]; - // sink lands at the FULL dragged position, not snapped back to x=300 - expect(sink.x).toBe(450); - expect(sink.y).toBe(360); - expect(newCloud.x).toBe(450); - expect(newCloud.y).toBe(360); - // corner keeps the L orthogonal: vertical riser at the stock's x, then - // horizontal out to the cloud's new x. - const corner = newFlow.points[1]; - expect(corner.x).toBe(stockEdgeX); - expect(corner.y).toBe(360); - // valve stays interior on the cloud-adjacent (horizontal) segment - expect(newFlow.y).toBe(360); - expect(newFlow.x).toBeGreaterThan(stockEdgeX); - expect(newFlow.x).toBeLessThan(450); - }); - - it('applies both deltas to a vertical source-cloud reroute (no along-axis snap-back)', () => { - const stockUid = 1; - const cloudUid = 3; - const stockEdgeY = 200 - StockHeight / 2; // 182.5 (top edge, stock below) - const flow = makeFlow(flowUid, 100, 150, [ - { x: 100, y: 100, attachedToUid: cloudUid }, // source cloud at top - { x: 100, y: stockEdgeY, attachedToUid: stockUid }, - ]); - const cloud = makeCloud(cloudUid, flowUid, 100, 100); - - // Vertical flow: parallel axis is Y, perpendicular is X. Drag the source - // cloud DOWN by 150 (parallel) and RIGHT by 160 (perpendicular, dominant). - const [newCloud, newFlow] = UpdateCloudAndFlow(cloud, flow, { x: -160, y: -150 }); - - expect(newFlow.points.length).toBe(3); - const source = newFlow.points[0]; - // source lands at the FULL dragged position, not snapped back to y=100 - expect(source.x).toBe(260); - expect(source.y).toBe(250); - expect(newCloud.x).toBe(260); - expect(newCloud.y).toBe(250); - // corner: vertical run from the cloud down to the fixed end's y, then - // horizontal across to the stock. - const corner = newFlow.points[1]; - expect(corner.x).toBe(260); - expect(corner.y).toBe(stockEdgeY); - }); - }); - - describe('UpdateCloudAndFlow - degenerate flow creation', () => { - // When a flow is first created, both endpoints are at the same position. - // The segment is both horizontal AND vertical (zero length). - // The drag direction should determine the flow axis. - - it('should create vertical flow when dragging mostly downward from degenerate start', () => { - const stockUid = 1; - const cloudUid = 3; - const startX = 100; - const startY = 100; - - // Degenerate flow: both points at same position - const flow = makeFlow(flowUid, startX, startY, [ - { x: startX, y: startY, attachedToUid: stockUid }, - { x: startX, y: startY, attachedToUid: cloudUid }, - ]); - - const cloud = makeCloud(cloudUid, flowUid, startX, startY); - - // Drag mostly downward (negative moveDelta.y means moving down in screen coords) - // moveDelta is inverted: negative y means moving to higher Y - const moveDelta = { x: -5, y: -50 }; - const [newCloud, newFlow] = UpdateCloudAndFlow(cloud, flow, moveDelta); - - // Flow should remain straight (2 points) and be vertical - expect(newFlow.points.length).toBe(2); - - // Both points should have the same X (vertical flow) - const firstPt = newFlow.points[0]; - const lastPt = newFlow.points[1]; - expect(firstPt.x).toBe(lastPt.x); - - // Cloud should have moved down (Y increased) - expect(newCloud.y).toBe(startY + 50); - }); - - it('should create vertical flow when dragging mostly upward from degenerate start', () => { - const stockUid = 1; - const cloudUid = 3; - const startX = 100; - const startY = 100; - - const flow = makeFlow(flowUid, startX, startY, [ - { x: startX, y: startY, attachedToUid: stockUid }, - { x: startX, y: startY, attachedToUid: cloudUid }, - ]); - - const cloud = makeCloud(cloudUid, flowUid, startX, startY); - - // Drag mostly upward (positive moveDelta.y means moving up) - const moveDelta = { x: 5, y: 50 }; - const [newCloud, newFlow] = UpdateCloudAndFlow(cloud, flow, moveDelta); - - // Flow should remain straight and be vertical - expect(newFlow.points.length).toBe(2); - - const firstPt = newFlow.points[0]; - const lastPt = newFlow.points[1]; - expect(firstPt.x).toBe(lastPt.x); - - // Cloud should have moved up (Y decreased) - expect(newCloud.y).toBe(startY - 50); - }); - - it('should create horizontal flow when dragging mostly rightward from degenerate start', () => { - const stockUid = 1; - const cloudUid = 3; - const startX = 100; - const startY = 100; - - const flow = makeFlow(flowUid, startX, startY, [ - { x: startX, y: startY, attachedToUid: stockUid }, - { x: startX, y: startY, attachedToUid: cloudUid }, - ]); - - const cloud = makeCloud(cloudUid, flowUid, startX, startY); - - // Drag mostly rightward (negative moveDelta.x means moving right) - const moveDelta = { x: -50, y: -5 }; - const [newCloud, newFlow] = UpdateCloudAndFlow(cloud, flow, moveDelta); - - // Flow should remain straight and be horizontal - expect(newFlow.points.length).toBe(2); - - const firstPt = newFlow.points[0]; - const lastPt = newFlow.points[1]; - expect(firstPt.y).toBe(lastPt.y); - - // Cloud should have moved right (X increased) - expect(newCloud.x).toBe(startX + 50); - }); - - it('should create horizontal flow when dragging mostly leftward from degenerate start', () => { - const stockUid = 1; - const cloudUid = 3; - const startX = 100; - const startY = 100; - - const flow = makeFlow(flowUid, startX, startY, [ - { x: startX, y: startY, attachedToUid: stockUid }, - { x: startX, y: startY, attachedToUid: cloudUid }, - ]); - - const cloud = makeCloud(cloudUid, flowUid, startX, startY); - - // Drag mostly leftward (positive moveDelta.x means moving left) - const moveDelta = { x: 50, y: 5 }; - const [newCloud, newFlow] = UpdateCloudAndFlow(cloud, flow, moveDelta); - - // Flow should remain straight and be horizontal - expect(newFlow.points.length).toBe(2); - - const firstPt = newFlow.points[0]; - const lastPt = newFlow.points[1]; - expect(firstPt.y).toBe(lastPt.y); - - // Cloud should have moved left (X decreased) - expect(newCloud.x).toBe(startX - 50); - }); - }); - - describe('UpdateCloudAndFlow - stock edge recomputation', () => { - it('should recompute stock edge when reattaching to stock on opposite side of corner', () => { - // Scenario: L-shaped flow with source on left, corner in middle, sink on right - // Original source stock is LEFT of corner (endpoint on stock's right edge) - // New source stock is RIGHT of corner (endpoint should be on stock's LEFT edge) - // - // Before: [Stock1] ---> corner - // | - // v - // sink - // - // After: corner <--- [Stock2] - // | - // v - // sink - // - // The endpoint should be on the LEFT edge of Stock2, not preserve the - // "right edge" offset from Stock1. - - const oldStockUid = 1; - const sinkUid = 3; - - // Old stock at (100, 100), endpoint on right edge at (100 + StockWidth/2, 100) - const oldStockX = 100; - const oldStockY = 100; - const oldStockRightEdge = oldStockX + StockWidth / 2; - - // Corner at (200, 100) - to the right of old stock - const cornerX = 200; - const cornerY = oldStockY; - - // Sink at (200, 200) - below corner - const sinkX = cornerX; - const sinkY = 200; - - // New stock at (300, 100) - to the RIGHT of the corner - // The flow should exit from its LEFT edge (toward the corner) - const newStockX = 300; - const newStockY = 100; - const newStockLeftEdge = newStockX - StockWidth / 2; - - // Create the flow: source -> corner -> sink - const flow = makeFlow(flowUid, 150, 100, [ - { x: oldStockRightEdge, y: oldStockY, attachedToUid: oldStockUid }, - { x: cornerX, y: cornerY, attachedToUid: undefined }, - { x: sinkX, y: sinkY, attachedToUid: sinkUid }, - ]); - - // Create the stock at OLD coordinates, as Editor.tsx does when calling UpdateCloudAndFlow. - // Editor.tsx resets the stock back to old coordinates before calling the function. - const stock: StockViewElement = { - type: 'stock', - uid: oldStockUid, // Same UID since we're simulating reattachment - name: 'Stock', - ident: 'stock', - var: undefined, - x: oldStockX, // Passed with OLD coordinates - y: oldStockY, - labelSide: 'center', - isZeroRadius: false, - inflows: [], - outflows: [flowUid], - }; - - // moveDelta = oldCenter - newCenter (as computed in Editor.tsx) - // old: (100, 100), new: (300, 100) -> moveDelta = (100 - 300, 100 - 100) = (-200, 0) - // So newCenter = cloud.x - moveDelta.x = 100 - (-200) = 300 - const moveDelta = { x: oldStockX - newStockX, y: oldStockY - newStockY }; - - const [, newFlow] = UpdateCloudAndFlow(stock, flow, moveDelta); - - // The endpoint should be on the LEFT edge of the new stock (facing the corner) - const firstPt = newFlow.points[0]; - expect(firstPt.x).toBe(newStockLeftEdge); - expect(firstPt.y).toBe(newStockY); - - // The corner should maintain orthogonality with the new endpoint - const secondPt = newFlow.points[1]; - expect(secondPt.y).toBe(firstPt.y); // Same Y for horizontal segment - }); - - it('should recompute stock edge for vertical segments when reattaching', () => { - // Similar test but with a vertical first segment - // Source stock above corner, new stock below corner - - const oldStockUid = 1; - const sinkUid = 3; - - // Old stock at (100, 50), endpoint on bottom edge - const oldStockX = 100; - const oldStockY = 50; - const oldStockBottomEdge = oldStockY + StockHeight / 2; - - // Corner at (100, 150) - below old stock - const cornerX = oldStockX; - const cornerY = 150; - - // Sink at (200, 150) - to the right of corner - const sinkX = 200; - const sinkY = cornerY; - - // New stock at (100, 250) - BELOW the corner - // The flow should exit from its TOP edge (toward the corner) - const newStockX = 100; - const newStockY = 250; - const newStockTopEdge = newStockY - StockHeight / 2; - - // Create the flow: source -> corner -> sink - const flow = makeFlow(flowUid, 100, 100, [ - { x: oldStockX, y: oldStockBottomEdge, attachedToUid: oldStockUid }, - { x: cornerX, y: cornerY, attachedToUid: undefined }, - { x: sinkX, y: sinkY, attachedToUid: sinkUid }, - ]); - - // Create the stock at OLD coordinates, as Editor.tsx does when calling UpdateCloudAndFlow. - const stock: StockViewElement = { - type: 'stock', - uid: oldStockUid, - name: 'Stock', - ident: 'stock', - var: undefined, - x: oldStockX, // Passed with OLD coordinates - y: oldStockY, - labelSide: 'center', - isZeroRadius: false, - inflows: [], - outflows: [flowUid], - }; - - // moveDelta = oldCenter - newCenter (as computed in Editor.tsx) - // old: (100, 50), new: (100, 250) -> moveDelta = (0, -200) - // So newCenterY = cloud.y - moveDelta.y = 50 - (-200) = 250 - const moveDelta = { x: oldStockX - newStockX, y: oldStockY - newStockY }; - - const [, newFlow] = UpdateCloudAndFlow(stock, flow, moveDelta); - - // The endpoint should be on the TOP edge of the new stock (facing the corner) - const firstPt = newFlow.points[0]; - expect(firstPt.x).toBe(newStockX); - expect(firstPt.y).toBe(newStockTopEdge); - - // The corner should maintain orthogonality with the new endpoint - const secondPt = newFlow.points[1]; - expect(secondPt.x).toBe(firstPt.x); // Same X for vertical segment - }); - - it('should treat isZeroRadius stocks as clouds (simple translation)', () => { - // When detaching a flow from a stock, Canvas creates a temporary placeholder - // with isZeroRadius: true. This should be treated as a cloud, not go through - // stock-edge logic, so the endpoint tracks the drag position directly. - - const stockUid = 1; - const sinkUid = 3; - - // Stock at (100, 100), endpoint on right edge - const stockX = 100; - const stockY = 100; - const stockRightEdge = stockX + StockWidth / 2; - - // Corner at (200, 100) - const cornerX = 200; - const cornerY = stockY; - - // Sink at (200, 200) - const sinkX = cornerX; - const sinkY = 200; - - // Create the flow: source -> corner -> sink - const flow = makeFlow(flowUid, 150, 100, [ - { x: stockRightEdge, y: stockY, attachedToUid: stockUid }, - { x: cornerX, y: cornerY, attachedToUid: undefined }, - { x: sinkX, y: sinkY, attachedToUid: sinkUid }, - ]); - - // Create a zero-radius placeholder (simulating drag detachment) - // Position at (150, 80) - somewhere the user is dragging to - const dragX = 150; - const dragY = 80; - const zeroRadiusPlaceholder: StockViewElement = { - type: 'stock', - uid: stockUid, - name: 'DragPlaceholder', - ident: 'drag_placeholder', - var: undefined, - x: stockX, // OLD position (as passed by Editor.tsx) - y: stockY, - labelSide: 'center', - isZeroRadius: true, // Key: this makes it a drag placeholder - inflows: [], - outflows: [flowUid], - }; - - // moveDelta = oldCenter - newCenter - // Dragging from (100, 100) to (150, 80) -> moveDelta = (100-150, 100-80) = (-50, 20) - const moveDelta = { x: stockX - dragX, y: stockY - dragY }; - - const [, newFlow] = UpdateCloudAndFlow(zeroRadiusPlaceholder, flow, moveDelta); - - // For isZeroRadius, endpoint should simply translate (like a cloud) - // newX = stockRightEdge - moveDelta.x = 122.5 - (-50) = 172.5 - // newY = stockY - moveDelta.y = 100 - 20 = 80 - const firstPt = newFlow.points[0]; - expect(firstPt.x).toBe(stockRightEdge - moveDelta.x); - expect(firstPt.y).toBe(stockY - moveDelta.y); - }); - }); - - describe('degenerate self-loop flow (both endpoints on the same stock)', () => { - // A flow whose first and last points are both attached to the same stock - // (issue #720). It cannot be produced through normal editor interactions, - // but can arrive via imported models or programmatic patches. adjustFlows - // (reached here through UpdateCloudAndFlow's straight-flow axis-constrain - // path) used to call defined(otherEnd) and throw because no endpoint was - // attached to a *different* element -- crashing the canvas render/interaction - // path. It must degrade gracefully instead. - it('does not throw and leaves the flow unchanged when dragged', () => { - const stockX = 100; - const stockY = 100; - // 2-point horizontal flow with BOTH endpoints attached to the stock. - const flow = makeFlow(flowUid, 150, stockY, [ - { x: stockX, y: stockY, attachedToUid: stockUid }, - { x: 200, y: stockY, attachedToUid: stockUid }, - ]); - const stock = makeStock(stockUid, stockX, stockY); - - // A small drag so we stay on the straight-flow axis-constrain path - // (below PERP_THRESHOLD) that reaches adjustFlows. - const moveDelta = { x: 2, y: 1 }; - - let result: ReturnType | undefined; - expect(() => { - result = UpdateCloudAndFlow(stock, flow, moveDelta); - }).not.toThrow(); - - const [, newFlow] = result!; - // Graceful degradation: the degenerate flow's points are returned intact. - expect(newFlow.points).toEqual(flow.points); - }); - }); - - describe('non-finite coordinate guard in adjustFlows (#818)', () => { - // adjustFlows computes the valve's fractional position by dividing by - // (origStock.x - otherEnd.x) / (origStock.y - otherEnd.y). For a flow whose - // attached endpoint and other end share an axis (a vertical or horizontal - // flow) that denominator is zero. Before the `|| 1` guard, dragging a cloud - // on such a flow produced a NaN/Infinity valve coordinate, which serialized to - // JSON null and bricked the model. Moving must always yield finite coordinates. - it('keeps the valve finite for a vertical cloud flow with an off-axis valve', () => { - // Vertical flow: cloud (uid 1) and the other end (uid 2) share x = 100. - // The valve sits off that axis (x = 150), as can happen with the degenerate - // geometry older flow-creation bugs left behind. - const cloud = makeCloud(1, 3, 100, 100); - const flow = makeFlow(3, 150, 200, [ - { x: 100, y: 100, attachedToUid: 1 }, - { x: 100, y: 300, attachedToUid: 2 }, - ]); - - const [, newFlow] = UpdateCloudAndFlow(cloud, flow, { x: 5, y: 20 }); - - expect(Number.isFinite(newFlow.x)).toBe(true); - expect(Number.isFinite(newFlow.y)).toBe(true); - for (const p of newFlow.points) { - expect(Number.isFinite(p.x)).toBe(true); - expect(Number.isFinite(p.y)).toBe(true); - } - }); - - it('keeps the valve finite for a horizontal cloud flow with an off-axis valve', () => { - // Horizontal flow: cloud and other end share y = 100; valve off-axis (y=150). - const cloud = makeCloud(1, 3, 100, 100); - const flow = makeFlow(3, 200, 150, [ - { x: 100, y: 100, attachedToUid: 1 }, - { x: 300, y: 100, attachedToUid: 2 }, - ]); - - const [, newFlow] = UpdateCloudAndFlow(cloud, flow, { x: 20, y: 5 }); - - expect(Number.isFinite(newFlow.x)).toBe(true); - expect(Number.isFinite(newFlow.y)).toBe(true); - for (const p of newFlow.points) { - expect(Number.isFinite(p.x)).toBe(true); - expect(Number.isFinite(p.y)).toBe(true); - } - }); - }); - - describe('slightly-diagonal legacy flows classify by their dominant axis', () => { - // Real specimen: pre-fix flow creation persisted visually-horizontal flows - // whose endpoint y's drift a few pixels (5.5px in the wild case); imported - // models can carry the same. Exact-equality orientation checks classified - // such a flow as VERTICAL, so routing produced a wrong-way L (down from - // the cloud, into the stock's left edge, valve and label stacked on the - // cloud) and endpoint drags constrained/updated the wrong coordinate. - - it('computeFlowRoute treats a near-horizontal flow as horizontal (L via the top edge)', () => { - // Cloud anchor on the left, stock on the right, 5.5px of y-drift. - const stock = makeStock(stockUid, 565.6, 353); - const flow = makeFlow(flowUid, 426.5, 355.7, [ - { x: 310, y: 358.5, attachedToUid: cloudUid }, - { x: 543.1, y: 353, attachedToUid: stockUid }, - ]); - - // Drag the stock far downward: a horizontal flow must attach via a - // perpendicular (vertical) segment into the stock's TOP edge, with the - // corner carrying the anchor's y. - const newStockCy = 483; - const result = computeFlowRoute(flow, stock, 565.6, newStockCy); - - expect(result.points.length).toBe(3); - const corner = result.points[1]; - const stockPoint = result.points[2]; - expect(stockPoint.y).toBeCloseTo(newStockCy - StockHeight / 2); - expect(corner.x).toBeCloseTo(stockPoint.x); - expect(corner.y).toBeCloseTo(358.5); - }); - - it('UpdateCloudAndFlow slides a near-horizontal cloud drag along x (no bogus L, endpoint follows)', () => { - // Same drift, cloud end being dragged 50px leftward along the VISUAL - // axis. As "vertical", this parallel drag read as perpendicular: it - // rerouted into an L and never updated the endpoint's x (the cloud - // detached visually from its own flow). - const cloud = makeCloud(cloudUid, flowUid, 310, 358.5); - const flow = makeFlow(flowUid, 426.5, 355.7, [ - { x: 310, y: 358.5, attachedToUid: cloudUid }, - { x: 543.1, y: 353, attachedToUid: 99 }, - ]); - - const [movedCloud, newFlow] = UpdateCloudAndFlow(cloud, flow, { x: 50, y: 0 }); - - // Still straight, constrained to the flow's (horizontal) axis. - expect(newFlow.points.length).toBe(2); - expect(movedCloud.x).toBeCloseTo(260); - expect(movedCloud.y).toBeCloseTo(358.5); - // The dragged endpoint follows the cloud on x. - expect(newFlow.points[0].x).toBeCloseTo(260); - // The far endpoint is untouched. - expect(newFlow.points[1].x).toBeCloseTo(543.1); - expect(newFlow.points[1].y).toBeCloseTo(353); - }); - }); -}); diff --git a/src/diagram/tests/gesture-planner-base.test.ts b/src/diagram/tests/gesture-planner-base.test.ts new file mode 100644 index 000000000..f082965d1 --- /dev/null +++ b/src/diagram/tests/gesture-planner-base.test.ts @@ -0,0 +1,232 @@ +// Copyright 2026 The Simlin Authors. All rights reserved. +// Use of this source code is governed by the Apache License, +// Version 2.0, that can be found in the LICENSE file. + +// sameGeometry, the E5 test of whether a republished view invalidates a live +// gesture. Rows are derived from the element kinds of a scene holding one of +// each and, per kind, the read fields a gesture depends on: each must abort +// when it moves by more than GEOMETRY_EPSILON, and none of the derived or +// cosmetic fields may. +// +// The comparison is whole-view by design: a real change to any element aborts, +// including one the live gesture does not read. The benign republishes -- a +// pending edit landing (one ULP of drift, re-derived fields), sim results +// attaching, error annotations updating -- are rows of their own. +// +// What this does not establish: that an engine round trip drifts by no more +// than GEOMETRY_EPSILON (a measured fact of the design plan), or the Canvas +// acting on the answer (canvas-gestures-lifecycle.test.tsx). + +import { describe, it, expect } from '@rstest/core'; + +import { isNamedViewElement, type StockFlowView, type ViewElement } from '@simlin/core/datamodel'; + +import { planGesture, sameGeometry } from '../gesture-planner'; +import { aux, cloud, flow, link, linkedAuxes, planInput, scene, stock } from './support/gesture-fixtures'; + +function everyKind(): StockFlowView { + return scene([ + stock(1, 'S', 100, 100), + cloud(2, 3, 300, 100), + flow(3, 'F', { x: 200, y: 100 }, [ + [122.5, 100, 1], + [300, 100, 2], + ]), + aux(10, 'a', 100, 300), + { type: 'module', uid: 20, name: 'm', x: 600, y: 600 } as never, + { type: 'alias', uid: 15, aliasOfUid: 10, x: 100, y: 450 } as never, + link(13, 10, 3, 20), + { type: 'group', uid: 30, name: 'g', x: 500, y: 100, width: 100, height: 80 } as never, + ]).view; +} + +function edit(view: StockFlowView, uid: number, change: (el: ViewElement) => ViewElement): StockFlowView { + return { ...view, elements: view.elements.map((el) => (el.uid === uid ? change(el) : el)) }; +} + +const BIG = 0.5; +const TINY = 1e-9; + +// Every read field by element kind; the kinds are the scene's own. +const READ_FIELDS: Record ViewElement]>> = { + stock: [ + ['x', (el: ViewElement, by) => ({ ...el, x: el.x + by }) as ViewElement], + ['labelSide', (el: ViewElement) => ({ ...el, labelSide: 'top' }) as ViewElement], + ], + cloud: [ + ['y', (el: ViewElement, by) => ({ ...el, y: el.y + by }) as ViewElement], + ['flowUid', (el: ViewElement) => ({ ...el, flowUid: 99 }) as ViewElement], + ], + flow: [ + ['valve x', (el: ViewElement, by) => ({ ...el, x: el.x + by }) as ViewElement], + [ + 'point y', + (el: never, by) => + ({ + ...(el as { points: { y: number }[] }), + points: (el as { points: { y: number }[] }).points.map((p, i) => (i === 1 ? { ...p, y: p.y + by } : p)), + }) as never, + ], + [ + 'attachment', + (el: never) => + ({ + ...(el as object), + points: (el as { points: object[] }).points.map((p, i) => (i === 1 ? { ...p, attachedToUid: 1 } : p)), + }) as never, + ], + [ + 'point count', + (el: never) => + ({ ...(el as object), points: [...(el as { points: object[] }).points, { x: 400, y: 100 }] }) as never, + ], + ['labelSide', (el: ViewElement) => ({ ...el, labelSide: 'left' }) as ViewElement], + ], + aux: [ + ['y', (el: ViewElement, by) => ({ ...el, y: el.y + by }) as ViewElement], + ['labelSide', (el: ViewElement) => ({ ...el, labelSide: 'left' }) as ViewElement], + ], + module: [ + ['x', (el: ViewElement, by) => ({ ...el, x: el.x + by }) as ViewElement], + ['labelSide', (el: ViewElement) => ({ ...el, labelSide: 'top' }) as ViewElement], + ], + alias: [ + ['x', (el: ViewElement, by) => ({ ...el, x: el.x + by }) as ViewElement], + ['aliasOfUid', (el: ViewElement) => ({ ...el, aliasOfUid: 1 }) as ViewElement], + ['labelSide', (el: ViewElement) => ({ ...el, labelSide: 'top' }) as ViewElement], + ], + link: [ + ['arc', (el: ViewElement, by) => ({ ...el, arc: (el as { arc: number }).arc + by }) as ViewElement], + ['arc to straight', (el: ViewElement) => ({ ...el, arc: undefined }) as ViewElement], + ['toUid', (el: ViewElement) => ({ ...el, toUid: 1 }) as ViewElement], + ['fromUid', (el: ViewElement) => ({ ...el, fromUid: 1 }) as ViewElement], + ], + group: [ + ['width', (el: ViewElement, by) => ({ ...el, width: (el as { width: number }).width + by }) as ViewElement], + ['y', (el: ViewElement, by) => ({ ...el, y: el.y + by }) as ViewElement], + ], +}; + +describe('sameGeometry', () => { + const base = everyKind(); + + it('the scene holds every element kind the table lists', () => { + expect([...new Set(base.elements.map((el) => el.type))].sort()).toEqual(Object.keys(READ_FIELDS).sort()); + }); + + for (const el of base.elements) { + for (const [field, change] of READ_FIELDS[el.type]) { + it(`${el.type} ${field}: a change aborts, float noise does not`, () => { + expect( + sameGeometry( + base, + edit(base, el.uid, (e) => change(e as never, BIG)), + ), + ).toBe(false); + if (change.length === 2 && field !== 'labelSide') { + expect( + sameGeometry( + base, + edit(base, el.uid, (e) => change(e as never, TINY)), + ), + ).toBe(true); + } + }); + } + } + + it('derived and cosmetic fields do not abort: isStraight, var, ident, name, nextUid, viewport', () => { + let next = edit(base, 13, (e) => ({ ...e, isStraight: true }) as ViewElement); + next = edit( + next, + 1, + (e) => ({ ...e, var: { type: 'stock' } as never, name: 'Renamed', ident: 'renamed' }) as ViewElement, + ); + next = { ...next, nextUid: 999, zoom: 3, viewBox: { x: 50, y: 50, width: 10, height: 10 } }; + expect(sameGeometry(base, next)).toBe(true); + }); + + it('elements added, removed or reordered: added and removed abort, reordering does not', () => { + expect(sameGeometry(base, { ...base, elements: base.elements.slice(1) })).toBe(false); + expect(sameGeometry(base, { ...base, elements: [...base.elements, { ...base.elements[0], uid: 77 }] })).toBe(false); + expect(sameGeometry(base, { ...base, elements: [...base.elements].reverse() })).toBe(true); + }); + + it('a duplicated uid replacing another element aborts', () => { + const dup = [...base.elements.slice(0, -1), { ...base.elements[0] }]; + expect(sameGeometry(base, { ...base, elements: dup })).toBe(false); + }); +}); + +describe('sameGeometry: benign republishes keep a live gesture', () => { + const base = everyKind(); + // One ULP: the drift an engine round trip can leave on a coordinate. + const ulp = (v: unknown): unknown => + typeof v === 'number' ? v + Math.max(Number.MIN_VALUE, Math.abs(v) * Number.EPSILON) : v; + const annotateVars = (view: StockFlowView, fields: object): StockFlowView => ({ + ...view, + elements: view.elements.map((el) => + isNamedViewElement(el) ? ({ ...el, var: { ...(el.var ?? {}), ...fields } } as ViewElement) : el, + ), + }); + + it('a pending edit landing: every coordinate one ULP away, isStraight, var and nextUid re-derived', () => { + const elements = base.elements.map((el) => { + const next: Record = { ...el, x: ulp(el.x), y: ulp(el.y) }; + if (el.type === 'flow') { + next.points = el.points.map((p) => ({ ...p, x: ulp(p.x), y: ulp(p.y) })); + } else if (el.type === 'link') { + next.arc = ulp(el.arc); + next.isStraight = !el.isStraight; + } else if (el.type === 'group') { + next.width = ulp(el.width); + next.height = ulp(el.height); + } + if (isNamedViewElement(el)) { + next.var = el.var === undefined ? undefined : { ...el.var }; + } + return next as unknown as ViewElement; + }); + const landed = { ...base, elements, nextUid: Math.max(...base.elements.map((el) => el.uid)) + 1 }; + expect(landed.elements.filter((el, i) => el.x !== base.elements[i].x).length).toBeGreaterThan(0); + expect(sameGeometry(base, landed)).toBe(true); + }); + + it('sim results attaching to the variables', () => { + const series = [{ name: 'S', time: new Float64Array([0, 1]), values: new Float64Array([1, 2]) }]; + expect(sameGeometry(base, annotateVars(base, { data: series }))).toBe(true); + }); + + it('error annotations updating', () => { + const errors = [{ start: 0, end: 1, code: 'unknown_dependency' }]; + expect(sameGeometry(base, annotateVars(base, { errors, unitErrors: errors }))).toBe(true); + }); + + it('M-1: a link the planner created, landing as the datamodel reads it (x/y NaN, isStraight re-derived)', () => { + const s = linkedAuxes(); + const p = planGesture(planInput(s, { kind: 'createLink', from: 12 }, { x: 300, y: 450 }, { x: 100, y: 300 })); + expect(p.commit).toBe('edit'); + const pending = { ...s.view, elements: p.elements, nextUid: p.nextUid }; + const landed = { + ...pending, + elements: pending.elements.map((el) => + el.type === 'link' && el.uid === p.nextUid - 1 + ? ({ ...el, x: NaN, y: NaN, isStraight: true } as ViewElement) + : el, + ), + }; + expect(sameGeometry(pending, landed)).toBe(true); + // Whatever a producer stores as a link's position, nothing reads it. + const zeroed = edit(landed, p.nextUid - 1, (e) => ({ ...e, x: 0, y: 0 }) as ViewElement); + expect(sameGeometry(landed, zeroed)).toBe(true); + }); + + it('a real change to any element aborts, including one a gesture on another element never reads', () => { + expect( + sameGeometry( + base, + edit(base, 20, (e) => ({ ...e, x: e.x + 5 }) as ViewElement), + ), + ).toBe(false); + }); +}); diff --git a/src/diagram/tests/gesture-planner-classify.test.ts b/src/diagram/tests/gesture-planner-classify.test.ts new file mode 100644 index 000000000..ac23705df --- /dev/null +++ b/src/diagram/tests/gesture-planner-classify.test.ts @@ -0,0 +1,566 @@ +// Copyright 2026 The Simlin Authors. All rights reserved. +// Use of this source code is governed by the Apache License, +// Version 2.0, that can be found in the LICENSE file. + +// classifyPress, latchGesture and isLostRelease as tables. +// +// The arm list is the Canvas's press handling as it stood before the planner +// (handlePointerDown for the empty canvas and the lifecycle, handleSetSelection +// for elements, handleEditConnector, the label components, the module +// double-click), plus the arms docs/design-plans/2026-09-10-diagram-editing-core.md +// adds. Every arm has at least one row, and the rows' outcome kinds cover every +// PressOutcome kind. The audit's press defects are rows too (M7, P-6, P-7). +// +// What this does not establish: that the Canvas hit-tests DOM events into these +// inputs (the canvas-gestures-*.test.tsx suites drive real pointer events). + +import { describe, it, expect } from '@rstest/core'; + +import type { UID } from '@simlin/core/datamodel'; + +import { classifyPress, isLostRelease, latchGesture, type PressInput, type PressOutcome } from '../gesture-planner'; +import { + linkedAuxes, + scene, + stock, + cloud, + flow, + aux, + link, + stockToCloud, + type Scene, +} from './support/gesture-fixtures'; + +const ARMS = [ + 'nameEditor overlay commits the name', + 'presses disabled', + 'second touch pinches', + 'third pointer ignored', + 'second non-touch pointer aborts', + 'press while another pointer gesture is live aborts', + 'module double-click drills in', + 'label double-click edits the name', + 'label double-click read-only selects', + 'label drag', + 'creation tool on canvas stages a draft', + 'flow tool on canvas draws from empty', + 'touch on canvas pans', + 'shift on canvas pans', + 'plain press on canvas rubber-bands', + 'link tool on canvas rubber-bands and stays armed', + 'link tool on a named element draws a link', + 'link tool on an alias draws a link', + 'flow tool on a stock draws a flow', + 'tool on an inapplicable element is cleared', + 'flow arrowhead drags the sink', + 'flow source grip drags the source', + 'link arrowhead drags the link end', + 'modifier press toggles out without a gesture', + 'modifier press toggles in and moves', + 'unselected cloud drags its flow end', + 'sole selected cloud drags its flow end', + 'cloud in a multi-selection moves with it', + 'cloud whose flow is missing moves as an element', + 'selected element defers the single select', + 'unselected element selects and moves', + 'sole link body adjusts the arc', + 'link body in a multi-selection moves', + 'sole flow pipe waits to latch', + 'flow pipe in a multi-selection moves', + 'element missing from the view is ignored', +] as const; + +type Arm = (typeof ARMS)[number]; + +const OUTCOME_KINDS: ReadonlyArray = [ + 'ignore', + 'pinch', + 'abort', + 'commitName', + 'drill', + 'editName', + 'select', + 'start', +]; + +function withAlias(): Scene { + return scene([ + aux(10, 'a', 100, 300), + aux(11, 'b', 300, 300), + { type: 'alias', uid: 15, aliasOfUid: 10, x: 100, y: 450 } as never, + link(13, 10, 11), + stock(1, 'S', 100, 100), + cloud(2, 3, 300, 100), + flow(3, 'F', { x: 200, y: 100 }, [ + [122.5, 100, 1], + [300, 100, 2], + ]), + { type: 'module', uid: 20, name: 'm', x: 600, y: 600 } as never, + ]); +} + +function press(overrides: Partial): PressInput { + return { + view: withAlias().view, + selection: new Set(), + tool: undefined, + hit: { kind: 'canvas' }, + point: { x: 500, y: 500 }, + shiftKey: false, + toggleKey: false, + pointerType: 'mouse', + readOnly: false, + pressesDisabled: false, + pointers: 1, + gestureLive: false, + ...overrides, + }; +} + +const set = (...uids: UID[]): ReadonlySet => new Set(uids); + +interface Row { + readonly arm: Arm; + readonly input: PressInput; + readonly outcome: PressOutcome; +} + +const ROWS: readonly Row[] = [ + { + arm: 'nameEditor overlay commits the name', + input: press({ hit: { kind: 'nameEditor' }, pressesDisabled: true }), + outcome: { kind: 'commitName' }, + }, + { arm: 'presses disabled', input: press({ pressesDisabled: true }), outcome: { kind: 'ignore' } }, + { + arm: 'presses disabled', + input: press({ pressesDisabled: true, hit: { kind: 'element', uid: 10, part: 'body' } }), + outcome: { kind: 'ignore' }, + }, + { arm: 'second touch pinches', input: press({ pointerType: 'touch', pointers: 2 }), outcome: { kind: 'pinch' } }, + { arm: 'third pointer ignored', input: press({ pointerType: 'touch', pointers: 3 }), outcome: { kind: 'ignore' } }, + { + arm: 'second non-touch pointer aborts', + input: press({ pointerType: 'pen', pointers: 2 }), + outcome: { kind: 'abort' }, + }, + { + arm: 'press while another pointer gesture is live aborts', + input: press({ gestureLive: true }), + outcome: { kind: 'abort' }, + }, + { + // Navigation is not an edit: it need not wait for a queued undo. + arm: 'module double-click drills in', + input: press({ hit: { kind: 'moduleDoubleClick', uid: 20 }, pressesDisabled: true }), + outcome: { kind: 'drill', uid: 20 }, + }, + { + arm: 'label double-click edits the name', + input: press({ hit: { kind: 'labelDoubleClick', uid: 10 }, tool: 'link', selection: set(10) }), + outcome: { kind: 'editName', uid: 10, selection: set(10), clearTool: true }, + }, + { + arm: 'label double-click read-only selects', + input: press({ hit: { kind: 'labelDoubleClick', uid: 10 }, readOnly: true }), + outcome: { kind: 'select', selection: set(10), clearTool: false }, + }, + { + arm: 'label drag', + input: press({ hit: { kind: 'labelDrag', uid: 11 }, selection: set(10) }), + outcome: { + kind: 'start', + gesture: { kind: 'label', uid: 11 }, + selection: set(11), + clickSelection: undefined, + clearTool: false, + }, + }, + ...(['aux', 'stock', 'module'] as const).map( + (tool): Row => ({ + arm: 'creation tool on canvas stages a draft', + input: press({ tool, selection: set(10) }), + outcome: { + kind: 'start', + gesture: { kind: 'createElement', type: tool }, + selection: set(), + clickSelection: undefined, + clearTool: false, + }, + }), + ), + { + arm: 'flow tool on canvas draws from empty', + input: press({ tool: 'flow' }), + outcome: { + kind: 'start', + gesture: { kind: 'createFlow', from: 'empty' }, + selection: undefined, + clickSelection: undefined, + clearTool: false, + }, + }, + { + arm: 'touch on canvas pans', + input: press({ pointerType: 'touch' }), + outcome: { + kind: 'start', + gesture: { kind: 'pan' }, + selection: undefined, + clickSelection: undefined, + clearTool: false, + }, + }, + { + arm: 'shift on canvas pans', + input: press({ shiftKey: true }), + outcome: { + kind: 'start', + gesture: { kind: 'pan' }, + selection: undefined, + clickSelection: undefined, + clearTool: false, + }, + }, + { + arm: 'plain press on canvas rubber-bands', + input: press({ selection: set(10) }), + outcome: { + kind: 'start', + gesture: { kind: 'rubberBand' }, + selection: undefined, + clickSelection: set(), + clearTool: false, + }, + }, + { + arm: 'link tool on canvas rubber-bands and stays armed', + input: press({ tool: 'link' }), + outcome: { + kind: 'start', + gesture: { kind: 'rubberBand' }, + selection: undefined, + clickSelection: set(), + clearTool: false, + }, + }, + { + arm: 'link tool on a named element draws a link', + input: press({ tool: 'link', hit: { kind: 'element', uid: 3, part: 'body' } }), + outcome: { + kind: 'start', + gesture: { kind: 'createLink', from: 3 }, + selection: undefined, + clickSelection: undefined, + clearTool: false, + }, + }, + { + arm: 'link tool on an alias draws a link', + input: press({ tool: 'link', hit: { kind: 'element', uid: 15, part: 'body' } }), + outcome: { + kind: 'start', + gesture: { kind: 'createLink', from: 15 }, + selection: undefined, + clickSelection: undefined, + clearTool: false, + }, + }, + { + arm: 'flow tool on a stock draws a flow', + input: press({ tool: 'flow', hit: { kind: 'element', uid: 1, part: 'body' } }), + outcome: { + kind: 'start', + gesture: { kind: 'createFlow', from: { stock: 1 } }, + selection: undefined, + clickSelection: undefined, + clearTool: false, + }, + }, + { + arm: 'tool on an inapplicable element is cleared', + input: press({ tool: 'flow', hit: { kind: 'element', uid: 10, part: 'body' } }), + outcome: { + kind: 'start', + gesture: { kind: 'moveSelection' }, + selection: set(10), + clickSelection: set(10), + clearTool: true, + }, + }, + { + // P-6: the link tool on a cloud clears the tool and drags the flow's end. + arm: 'tool on an inapplicable element is cleared', + input: press({ tool: 'link', hit: { kind: 'element', uid: 2, part: 'body' } }), + outcome: { + kind: 'start', + gesture: { kind: 'flowEndpoint', flow: 3, end: 'sink' }, + selection: set(3), + clickSelection: undefined, + clearTool: true, + }, + }, + { + arm: 'flow arrowhead drags the sink', + input: press({ hit: { kind: 'element', uid: 3, part: 'arrowhead' }, selection: set(10, 3), shiftKey: true }), + outcome: { + kind: 'start', + gesture: { kind: 'flowEndpoint', flow: 3, end: 'sink' }, + selection: set(3), + clickSelection: undefined, + clearTool: false, + }, + }, + { + arm: 'flow source grip drags the source', + input: press({ hit: { kind: 'element', uid: 3, part: 'source' } }), + outcome: { + kind: 'start', + gesture: { kind: 'flowEndpoint', flow: 3, end: 'source' }, + selection: set(3), + clickSelection: undefined, + clearTool: false, + }, + }, + { + arm: 'link arrowhead drags the link end', + input: press({ hit: { kind: 'element', uid: 13, part: 'arrowhead' } }), + outcome: { + kind: 'start', + gesture: { kind: 'linkEndpoint', link: 13 }, + selection: set(13), + clickSelection: undefined, + clearTool: false, + }, + }, + { + // P-7: a modifier press on a selected element toggles it out and starts no drag. + arm: 'modifier press toggles out without a gesture', + input: press({ hit: { kind: 'element', uid: 10, part: 'body' }, selection: set(10, 11), toggleKey: true }), + outcome: { kind: 'select', selection: set(11), clearTool: false }, + }, + { + arm: 'modifier press toggles in and moves', + input: press({ hit: { kind: 'element', uid: 11, part: 'body' }, selection: set(10), shiftKey: true }), + outcome: { + kind: 'start', + gesture: { kind: 'moveSelection' }, + selection: set(10, 11), + clickSelection: undefined, + clearTool: false, + }, + }, + { + arm: 'unselected cloud drags its flow end', + input: press({ hit: { kind: 'element', uid: 2, part: 'body' }, selection: set(10) }), + outcome: { + kind: 'start', + gesture: { kind: 'flowEndpoint', flow: 3, end: 'sink' }, + selection: set(3), + clickSelection: undefined, + clearTool: false, + }, + }, + { + // M7: a cloud's press does not depend on whether it was already the sole selection. + arm: 'sole selected cloud drags its flow end', + input: press({ hit: { kind: 'element', uid: 2, part: 'body' }, selection: set(2) }), + outcome: { + kind: 'start', + gesture: { kind: 'flowEndpoint', flow: 3, end: 'sink' }, + selection: set(3), + clickSelection: undefined, + clearTool: false, + }, + }, + { + arm: 'cloud in a multi-selection moves with it', + input: press({ hit: { kind: 'element', uid: 2, part: 'body' }, selection: set(2, 1) }), + outcome: { + kind: 'start', + gesture: { kind: 'moveSelection' }, + selection: undefined, + clickSelection: set(2), + clearTool: false, + }, + }, + { + arm: 'cloud whose flow is missing moves as an element', + input: press({ + view: scene([stock(1, 'S', 100, 100), cloud(2, 99, 300, 100)]).view, + hit: { kind: 'element', uid: 2, part: 'body' }, + }), + outcome: { + kind: 'start', + gesture: { kind: 'moveSelection' }, + selection: set(2), + clickSelection: set(2), + clearTool: false, + }, + }, + { + arm: 'selected element defers the single select', + input: press({ hit: { kind: 'element', uid: 10, part: 'body' }, selection: set(10, 11) }), + outcome: { + kind: 'start', + gesture: { kind: 'moveSelection' }, + selection: undefined, + clickSelection: set(10), + clearTool: false, + }, + }, + { + arm: 'unselected element selects and moves', + input: press({ hit: { kind: 'element', uid: 1, part: 'body' }, selection: set(10) }), + outcome: { + kind: 'start', + gesture: { kind: 'moveSelection' }, + selection: set(1), + clickSelection: set(1), + clearTool: false, + }, + }, + { + arm: 'sole link body adjusts the arc', + input: press({ hit: { kind: 'element', uid: 13, part: 'body' } }), + outcome: { + kind: 'start', + gesture: { kind: 'linkArc', link: 13 }, + selection: set(13), + clickSelection: set(13), + clearTool: false, + }, + }, + { + arm: 'link body in a multi-selection moves', + input: press({ hit: { kind: 'element', uid: 13, part: 'body' }, selection: set(13, 10) }), + outcome: { + kind: 'start', + gesture: { kind: 'moveSelection' }, + selection: undefined, + clickSelection: set(13), + clearTool: false, + }, + }, + { + arm: 'sole flow pipe waits to latch', + input: press({ hit: { kind: 'element', uid: 3, part: 'body' }, point: { x: 160, y: 101 } }), + outcome: { + kind: 'start', + gesture: { kind: 'pipe', flow: 3, segmentIndex: 0 }, + selection: set(3), + clickSelection: set(3), + clearTool: false, + }, + }, + { + arm: 'flow pipe in a multi-selection moves', + input: press({ hit: { kind: 'element', uid: 3, part: 'body' }, selection: set(3, 1) }), + outcome: { + kind: 'start', + gesture: { kind: 'moveSelection' }, + selection: undefined, + clickSelection: set(3), + clearTool: false, + }, + }, + { + arm: 'element missing from the view is ignored', + input: press({ hit: { kind: 'element', uid: 999, part: 'body' } }), + outcome: { kind: 'ignore' }, + }, +]; + +describe('classifyPress', () => { + it('has a row for every arm and every outcome kind', () => { + expect(ARMS.filter((arm) => !ROWS.some((r) => r.arm === arm))).toEqual([]); + expect(OUTCOME_KINDS.filter((kind) => !ROWS.some((r) => r.outcome.kind === kind))).toEqual([]); + }); + + for (const row of ROWS) { + it(row.arm, () => { + expect(classifyPress(row.input)).toEqual(row.outcome); + }); + } + + it('a pipe press picks the segment under the pointer', () => { + const s = scene([ + stock(1, 'S', 100, 100), + cloud(2, 3, 300, 300), + flow(3, 'F', { x: 200, y: 100 }, [ + [122.5, 100, 1], + [300, 100], + [300, 300, 2], + ]), + ]); + const at = (point: { x: number; y: number }) => + classifyPress(press({ view: s.view, hit: { kind: 'element', uid: 3, part: 'body' }, point })); + expect(at({ x: 200, y: 102 })).toMatchObject({ gesture: { kind: 'pipe', segmentIndex: 0 } }); + expect(at({ x: 297, y: 250 })).toMatchObject({ gesture: { kind: 'pipe', segmentIndex: 1 } }); + }); +}); + +describe('latchGesture', () => { + const s = stockToCloud(); + const pipe = { kind: 'pipe', flow: 3, segmentIndex: 0 } as const; + const latch = (current: { x: number; y: number }, zoom = 1, gesture = pipe as never) => + latchGesture(gesture, { view: s.view, press: { x: 200, y: 100 }, current, zoom }); + + it.each([ + ['within the threshold: still a pipe press', { x: 203, y: 102 }, pipe], + [ + 'perpendicular-dominant: offset the pressed segment', + { x: 204, y: 130 }, + { kind: 'offsetSegment', flow: 3, segmentIndex: 0 }, + ], + ['along-dominant: slide the valve', { x: 240, y: 110 }, { kind: 'slideValve', flow: 3 }], + ['a diagonal tie slides the valve', { x: 220, y: 120 }, { kind: 'slideValve', flow: 3 }], + ])('%s', (_name, current, expected) => { + expect(latch(current)).toEqual(expected); + }); + + it('measures the threshold in screen pixels', () => { + expect(latch({ x: 200, y: 102 }, 1)).toEqual(pipe); + expect(latch({ x: 200, y: 102 }, 4)).toEqual({ kind: 'offsetSegment', flow: 3, segmentIndex: 0 }); + }); + + it('a flow gone from the view or a stale segment index slides (the planner then finds no subject)', () => { + expect(latch({ x: 200, y: 140 }, 1, { kind: 'pipe', flow: 999, segmentIndex: 0 } as never)).toEqual({ + kind: 'slideValve', + flow: 999, + }); + expect(latch({ x: 200, y: 140 }, 1, { kind: 'pipe', flow: 3, segmentIndex: 4 } as never)).toEqual({ + kind: 'slideValve', + flow: 3, + }); + }); + + it('every other gesture passes through unchanged', () => { + const g = { kind: 'moveSelection' } as const; + expect(latch({ x: 400, y: 400 }, 1, g as never)).toBe(g); + }); +}); + +describe('isLostRelease', () => { + it.each([ + ['mouse', 0, true], + ['mouse', 1, false], + ['touch', 0, false], + ['pen', 0, false], + ] as const)('%s with buttons %d: %s', (pointerType, buttons, expected) => { + expect(isLostRelease(pointerType, buttons)).toBe(expected); + }); +}); + +describe('link sources', () => { + it('a link cannot start at a cloud or a link, so the tool is cleared', () => { + const s = linkedAuxes(); + expect( + classifyPress(press({ view: s.view, tool: 'link', hit: { kind: 'element', uid: 13, part: 'body' } })), + ).toMatchObject({ + kind: 'start', + gesture: { kind: 'linkArc' }, + clearTool: true, + }); + }); +}); diff --git a/src/diagram/tests/gesture-planner-corpus.test.ts b/src/diagram/tests/gesture-planner-corpus.test.ts new file mode 100644 index 000000000..7447e39e9 --- /dev/null +++ b/src/diagram/tests/gesture-planner-corpus.test.ts @@ -0,0 +1,182 @@ +// Copyright 2026 The Simlin Authors. All rights reserved. +// Use of this source code is governed by the Apache License, +// Version 2.0, that can be found in the LICENSE file. + +// Every flow of a handful of imported corpus models, dragged through each flow +// gesture, through the real WASM engine's import (engine import -> serializeJson +// -> projectFromJson, the editor's load path). +// +// What this establishes: for these models (Stella, Vensim via xmutil, native +// MDL including unattached fallback flows, Simlin-authored XMILE), planGesture +// never throws on an imported flow, every committed plan's routed flows hold the +// strict invariants (the imported violations are healed by routing), and each +// gesture commits on some flow of each model. What it does not establish: the +// other ~480 corpus models, or anything about the engine applying these edits. + +import { describe, it, expect, beforeAll } from '@rstest/core'; + +import * as fs from 'fs'; +import * as path from 'path'; + +import type { FlowViewElement, Model, UID } from '@simlin/core/datamodel'; + +import { planGesture, type Gesture, type GesturePlan } from '../gesture-planner'; +import { describeWithEngine, editorModel, loadEngine, type EngineModule } from './support/engine'; +import { checkFlowInvariants, formatFlowViolations } from './support/flow-invariants'; +import { namesOf, planned, planInput, routedFlows, type Pt, type Scene } from './support/gesture-fixtures'; +import { checkReferentialIntegrity, formatViewViolations } from './support/view-invariants'; + +const repoRoot = path.join(__dirname, '..', '..', '..'); + +const CORPUS: readonly string[] = [ + 'test/test-models/samples/teacup/teacup.stmx', + 'test/land_model/land_model.stmx', + 'test/test-models/tests/abs/test_abs.xmile', + 'test/test-models/samples/Roessler_Chaos/roessler_chaos.mdl', + 'test/test-models/tests/subscript_mapping_simple/test_subscript_mapping_simple.mdl', + 'test/cross_element_ltm/cross_element.stmx', +]; + +type FlowGestureKind = 'flowEndpoint' | 'slideValve' | 'offsetSegment' | 'moveTerminal'; +const FLOW_GESTURES: readonly FlowGestureKind[] = ['flowEndpoint', 'slideValve', 'offsetSegment', 'moveTerminal']; + +function gesturesFor( + f: FlowViewElement, +): Array<{ kind: FlowGestureKind; gesture: Gesture; selection: UID[]; press: Pt; current: Pt }> { + const src = f.points[0]; + const sink = f.points[f.points.length - 1]; + const a = f.points[0]; + const b = f.points[1]; + const horizontal = Math.abs(b.y - a.y) <= Math.abs(b.x - a.x); + const mid = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 }; + const out: Array<{ kind: FlowGestureKind; gesture: Gesture; selection: UID[]; press: Pt; current: Pt }> = [ + { + kind: 'flowEndpoint', + gesture: { kind: 'flowEndpoint', flow: f.uid, end: 'sink' }, + selection: [f.uid], + press: sink, + current: { x: sink.x + 40, y: sink.y + 60 }, + }, + { + kind: 'flowEndpoint', + gesture: { kind: 'flowEndpoint', flow: f.uid, end: 'source' }, + selection: [f.uid], + press: src, + current: { x: src.x - 50, y: src.y + 20 }, + }, + { + kind: 'slideValve', + gesture: { kind: 'slideValve', flow: f.uid }, + selection: [f.uid], + press: f, + current: { x: f.x + 12, y: f.y + 6 }, + }, + { + kind: 'offsetSegment', + gesture: { kind: 'offsetSegment', flow: f.uid, segmentIndex: 0 }, + selection: [f.uid], + press: mid, + current: horizontal ? { x: mid.x, y: mid.y + 25 } : { x: mid.x + 25, y: mid.y }, + }, + ]; + if (src.attachedToUid !== undefined) { + out.push({ + kind: 'moveTerminal', + gesture: { kind: 'moveSelection' }, + selection: [src.attachedToUid], + press: src, + current: { x: src.x + 30, y: src.y + 35 }, + }); + } + return out; +} + +describeWithEngine('gestures over imported corpus models', () => { + let engine: EngineModule; + + beforeAll(async () => { + engine = await loadEngine(); + }); + + async function load(file: string): Promise { + const bytes = new Uint8Array(fs.readFileSync(path.join(repoRoot, file))); + const project = file.endsWith('.mdl') ? await engine.Project.openVensim(bytes) : await engine.Project.open(bytes); + try { + return await editorModel(project); + } finally { + await project.dispose(); + } + } + + for (const file of CORPUS) { + it(file, async () => { + const model = await load(file); + const s: Scene = { model, view: model.views[0] }; + const names = namesOf(s); + const commits = new Map(); + const failures: string[] = []; + const flows = s.view.elements.filter((e): e is FlowViewElement => e.type === 'flow' && e.points.length >= 2); + expect(flows.length).toBeGreaterThan(0); + for (const f of flows) { + for (const g of gesturesFor(f)) { + let plan: GesturePlan; + try { + plan = planGesture(planInput(s, g.gesture, g.press, g.current, { selection: new Set(g.selection), names })); + } catch (err) { + failures.push(`flow ${f.uid} ${g.kind}: threw ${String(err)}`); + continue; + } + if (plan.commit !== 'edit') { + continue; + } + commits.set(g.kind, (commits.get(g.kind) ?? 0) + 1); + const view = planned(s, plan); + const violations = checkFlowInvariants(view, { mode: 'strict', routed: routedOnly(s, plan, g) }); + const refs = checkReferentialIntegrity(view).filter((v) => !preexisting(s, v.arm, v.uid)); + if (violations.length > 0 || refs.length > 0) { + failures.push( + `flow ${f.uid} ${JSON.stringify(g)}:\n${formatFlowViolations(violations)}${formatViewViolations(refs)}`, + ); + } + } + } + expect(failures.slice(0, 3).join('\n\n')).toBe(''); + expect(FLOW_GESTURES.filter((k) => (k === 'moveTerminal' ? false : (commits.get(k) ?? 0) === 0))).toEqual([]); + }); + } +}); + +// A flow translated with both terminals is not routed (support: a moved stock +// can carry both ends of an imported flow). +function routedOnly(s: Scene, plan: GesturePlan, g: { current: Pt; press: Pt }): Set { + const d = { x: g.current.x - g.press.x, y: g.current.y - g.press.y }; + const base = new Map(s.view.elements.map((el) => [el.uid, el])); + const out = new Set(); + for (const uid of routedFlows(s, plan)) { + const b = base.get(uid); + const n = plan.elements.find((el) => el.uid === uid); + const translated = + b?.type === 'flow' && + n?.type === 'flow' && + b.points.length === n.points.length && + b.points.every((p, i) => p.x + d.x === n.points[i].x && p.y + d.y === n.points[i].y); + if (!translated) { + out.add(uid); + } + } + return out; +} + +// M3 violations an imported view already carries (an orphan cloud, say) are the +// import's, not the edit's. +function preexisting(s: Scene, arm: string, uid: UID | undefined): boolean { + return checkReferentialIntegrity(s.view).some((v) => v.arm === arm && v.uid === uid); +} + +describe('corpus gesture fixtures', () => { + it('every corpus model exists', () => { + for (const file of CORPUS) { + expect(`${file}: ${fs.existsSync(path.join(repoRoot, file))}`).toBe(`${file}: true`); + } + }); +}); diff --git a/src/diagram/tests/gesture-planner-fuzz.test.ts b/src/diagram/tests/gesture-planner-fuzz.test.ts new file mode 100644 index 000000000..42a0be843 --- /dev/null +++ b/src/diagram/tests/gesture-planner-fuzz.test.ts @@ -0,0 +1,322 @@ +// Copyright 2026 The Simlin Authors. All rights reserved. +// Use of this source code is governed by the Apache License, +// Version 2.0, that can be found in the LICENSE file. + +// Seeded property tests of planGesture over generated scenes. +// +// Every seed builds a strict scene (genScene) or an imported one +// (genImportedScene) and derives gestures from its elements the way the Canvas +// supplies them: a press on an element's center, endpoint, valve, segment or +// label, the selection that press produces, and a pointer walk of eight frames +// that starts within the click threshold and sometimes ends over a stock or a +// named element (a drop target). Every GESTURE_KINDS kind is derived for every +// scene that has a subject for it. +// +// Asserted on every frame: planGesture never throws and plans finite +// coordinates; E1 (within the threshold nothing previews or commits, except a +// creation tool's draft and a label drag, which starts past the label's own +// threshold); E6 (an invalid target never commits); E4 locality (a plan changes +// only its subject, the flows its moving terminals route, their clouds, and the +// links whose endpoints changed; it removes only its own flow's cloud); and on +// committed frames the strict flow invariants over the flows the plan routed +// (a flow both of whose terminals moved is translated, not routed), M3, and a +// buildEditOps that accepts the planned view (no name collision). Across the +// run, every gesture kind that can edit commits at least once. Per scene: E5's +// no-abort half, a JSON round trip of the view keeps sameGeometry. +// +// What this does not establish: M1/M2 after the engine applies the edit +// (editor-gestures-engine.test.ts), continuity between frames (the geometry +// core's sweeps), or the Canvas using these plans for preview and commit. + +import { describe, it, expect } from '@rstest/core'; + +import { + isNamedViewElement, + stockFlowViewFromJson, + stockFlowViewToJson, + type FlowViewElement, + type UID, +} from '@simlin/core/datamodel'; + +import { beyondThreshold, planGesture, sameGeometry, type Gesture, type GesturePlan } from '../gesture-planner'; +import { GESTURE_KINDS } from '../gesture-planner/types'; +import { buildEditOps } from '../view-model-sync'; +import { checkFlowInvariants, formatFlowViolations } from './support/flow-invariants'; +import { namesOf, planned, planInput, type Pt, type Scene } from './support/gesture-fixtures'; +import { genImportedScene, genScene, Rng } from './support/scene-generator'; +import { checkReferentialIntegrity, formatViewViolations } from './support/view-invariants'; + +const STRICT_SEEDS = 30; +const IMPORTED_SEEDS = 30; +const FRAMES = 8; + +interface Case { + readonly gesture: Gesture; + readonly selection: ReadonlySet; + readonly press: Pt; + /** Drop targets a walk may end on. */ + readonly targets: readonly Pt[]; +} + +function cases(rng: Rng, s: Scene): Case[] { + const els = s.view.elements; + const stocks = els.filter((e) => e.type === 'stock'); + const flows = els.filter((e): e is FlowViewElement => e.type === 'flow' && e.points.length >= 2); + const links = els.filter((e) => e.type === 'link'); + const named = els.filter((e) => isNamedViewElement(e) || e.type === 'alias'); + const positioned = els.filter((e) => ['stock', 'aux', 'module', 'alias', 'cloud'].includes(e.type)); + const linkTargets = els.filter((e) => e.type === 'aux' || e.type === 'flow' || e.type === 'module'); + const stockTargets = stocks.map((e) => ({ x: e.x, y: e.y })); + const anywhere = (): Pt => ({ x: rng.int(0, 900), y: rng.int(0, 900) }); + const out: Case[] = []; + const add = (gesture: Gesture, selection: readonly UID[], press: Pt, targets: readonly Pt[] = []): void => { + out.push({ gesture, selection: new Set(selection), press: { x: press.x, y: press.y }, targets }); + }; + if (stocks.length > 0) { + const st = rng.pick(stocks); + add({ kind: 'moveSelection' }, [st.uid], st, stockTargets); + const group = positioned.filter(() => rng.bool(0.4)); + if (group.length > 0) { + add( + { kind: 'moveSelection' }, + group.map((e) => e.uid), + group[0], + ); + } + } + for (const f of flows) { + add({ kind: 'slideValve', flow: f.uid }, [f.uid], f); + const i = rng.int(0, f.points.length - 2); + const a = f.points[i]; + const b = f.points[i + 1]; + add({ kind: 'offsetSegment', flow: f.uid, segmentIndex: i }, [f.uid], { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 }); + add({ kind: 'flowEndpoint', flow: f.uid, end: 'source' }, [f.uid], f.points[0], stockTargets); + add({ kind: 'flowEndpoint', flow: f.uid, end: 'sink' }, [f.uid], f.points[f.points.length - 1], stockTargets); + } + if (stocks.length > 0) { + const st = rng.pick(stocks); + add({ kind: 'createFlow', from: { stock: st.uid } }, [], st, stockTargets); + } + add({ kind: 'createFlow', from: 'empty' }, [], anywhere(), stockTargets); + const linkTargetPts = linkTargets.map((e) => ({ x: e.x, y: e.y })); + if (named.length > 0) { + const from = rng.pick(named); + add({ kind: 'createLink', from: from.uid }, [], from, linkTargetPts); + const labelled = rng.pick(named); + add({ kind: 'label', uid: labelled.uid }, [labelled.uid], { x: labelled.x + 30, y: labelled.y }); + } + for (const l of links) { + const to = els.find((e) => e.uid === l.toUid); + const from = els.find((e) => e.uid === l.fromUid); + if (to !== undefined && from !== undefined) { + add({ kind: 'linkEndpoint', link: l.uid }, [l.uid], to, linkTargetPts); + add({ kind: 'linkArc', link: l.uid }, [l.uid], { x: (from.x + to.x) / 2, y: (from.y + to.y) / 2 }); + } + } + add({ kind: 'createElement', type: rng.pick(['aux', 'stock', 'module'] as const) }, [], anywhere()); + add({ kind: 'rubberBand' }, [], anywhere()); + add({ kind: 'pan' }, [], anywhere()); + return out; +} + +function walk(rng: Rng, c: Case): Pt[] { + const frames: Pt[] = [{ x: c.press.x + rng.float(-2, 2), y: c.press.y + rng.float(-2, 2) }]; + let at = frames[0]; + for (let i = 1; i < FRAMES; i++) { + at = { x: at.x + rng.float(-45, 45), y: at.y + rng.float(-45, 45) }; + frames.push(at); + } + if (c.targets.length > 0 && rng.bool(0.6)) { + const t = rng.pick(c.targets); + frames[FRAMES - 1] = { x: t.x + rng.float(-3, 3), y: t.y + rng.float(-3, 3) }; + } + return frames; +} + +// Links carry no position of their own (their x/y are unset in the JSON), so +// only positioned elements and flow paths are checked. +function finite(plan: GesturePlan): boolean { + return plan.elements.every( + (el) => + el.type === 'link' || + (Number.isFinite(el.x) && + Number.isFinite(el.y) && + (el.type !== 'flow' || el.points.every((p) => Number.isFinite(p.x) && Number.isFinite(p.y)))), + ); +} + +function subjects(c: Case): Set { + const out = new Set(c.selection); + const g = c.gesture; + switch (g.kind) { + case 'slideValve': + case 'offsetSegment': + case 'flowEndpoint': + out.add(g.flow); + break; + case 'linkEndpoint': + case 'linkArc': + out.add(g.link); + break; + case 'label': + out.add(g.uid); + break; + default: + break; + } + return out; +} + +/** + * E4, derived from the gesture rather than from the planner: the elements a plan + * may change, and remove. + */ +function localityViolations(s: Scene, c: Case, plan: GesturePlan): string[] { + const base = new Map(s.view.elements.map((el) => [el.uid, el])); + const next = new Map(plan.elements.map((el) => [el.uid, el])); + const changed = new Set(); + for (const el of plan.elements) { + if (base.get(el.uid) !== el) { + changed.add(el.uid); + } + } + const subject = subjects(c); + const moving = c.gesture.kind === 'moveSelection' ? c.selection : new Set(); + const out: string[] = []; + for (const uid of changed) { + const el = next.get(uid)!; + const created = !base.has(uid); + if (created || subject.has(uid)) { + continue; + } + if (el.type === 'flow') { + const ends = [el.points[0]?.attachedToUid, el.points[el.points.length - 1]?.attachedToUid]; + if (ends.some((u) => u !== undefined && moving.has(u))) { + continue; + } + } + if (el.type === 'cloud' && (changed.has(el.flowUid) || subject.has(el.flowUid))) { + continue; + } + if (el.type === 'link' && (changed.has(el.fromUid) || changed.has(el.toUid))) { + continue; + } + out.push(`changed ${el.type} ${uid}`); + } + for (const [uid, el] of base) { + if (!next.has(uid) && !(el.type === 'cloud' && subject.has(el.flowUid))) { + out.push(`removed ${el.type} ${uid}`); + } + } + return out; +} + +/** The flows a committed plan routed: changed flows that are not a plain translation of their base. */ +function routed(s: Scene, plan: GesturePlan, d: Pt): Set { + const base = new Map(s.view.elements.map((el) => [el.uid, el])); + const out = new Set(); + for (const el of plan.elements) { + const b = base.get(el.uid); + if (el.type !== 'flow' || b === el) { + continue; + } + const shifted = (by: Pt): boolean => + b?.type === 'flow' && + b.points.length === el.points.length && + b.points.every((p, i) => p.x + by.x === el.points[i].x && p.y + by.y === el.points[i].y); + // Translated with both terminals, or only relabeled: not routed. + const relabeled = shifted({ x: 0, y: 0 }) && b?.x === el.x && b?.y === el.y; + if (!shifted(d) && !relabeled) { + out.add(el.uid); + } + } + return out; +} + +interface Tally { + readonly commits: Map; + readonly failures: string[]; +} + +function run(s: Scene, rng: Rng, label: string, tally: Tally): void { + const names = namesOf(s); + for (const c of cases(rng, s)) { + for (const current of walk(rng, c)) { + const where = `${label} ${JSON.stringify(c.gesture)} sel=${JSON.stringify([...c.selection])} press=${JSON.stringify(c.press)} current=${JSON.stringify(current)}`; + let plan: GesturePlan; + try { + plan = planGesture(planInput(s, c.gesture, c.press, current, { selection: c.selection, names })); + } catch (err) { + tally.failures.push(`${where}: threw ${String(err)}`); + continue; + } + if (!finite(plan)) { + tally.failures.push(`${where}: non-finite coordinate`); + } + const exempt = c.gesture.kind === 'createElement' || c.gesture.kind === 'label'; + if ( + !beyondThreshold(c.press, current, 1) && + !exempt && + (plan.elements !== s.view.elements || plan.commit === 'edit') + ) { + tally.failures.push(`${where}: E1 a click changed the view`); + } + if (plan.target?.valid === false && plan.commit !== 'none') { + tally.failures.push(`${where}: E6 an invalid target committed`); + } + if (plan.commit !== 'edit') { + continue; + } + tally.commits.set(c.gesture.kind, (tally.commits.get(c.gesture.kind) ?? 0) + 1); + const view = planned(s, plan); + const d = { x: current.x - c.press.x, y: current.y - c.press.y }; + const flows = checkFlowInvariants(view, { mode: 'strict', routed: routed(s, plan, d) }); + const refs = checkReferentialIntegrity(view); + if (flows.length > 0 || refs.length > 0) { + tally.failures.push(`${where}:\n${formatFlowViolations(flows)}${formatViewViolations(refs)}`); + } + const locality = localityViolations(s, c, plan); + if (locality.length > 0) { + tally.failures.push(`${where}: E4 ${locality.join(', ')}`); + } + try { + buildEditOps(s.model, s.view, view); + } catch (err) { + tally.failures.push(`${where}: buildEditOps refused the planned view: ${String(err)}`); + } + } + } +} + +describe('planGesture over generated scenes', () => { + const tally: Tally = { commits: new Map(), failures: [] }; + + it(`strict scenes (${STRICT_SEEDS} seeds): totality, E1, E4, E6 and committed invariants`, () => { + for (let seed = 1; seed <= STRICT_SEEDS; seed++) { + const rng = new Rng(seed); + run(genScene(rng), rng, `strict seed ${seed}`, tally); + } + expect(tally.failures.slice(0, 5).join('\n\n')).toBe(''); + }); + + it(`imported scenes (${IMPORTED_SEEDS} seeds): totality, E1, E4, E6 and committed invariants`, () => { + for (let seed = 1; seed <= IMPORTED_SEEDS; seed++) { + const rng = new Rng(1000 + seed); + run(genImportedScene(rng), rng, `imported seed ${seed}`, tally); + } + expect(tally.failures.slice(0, 5).join('\n\n')).toBe(''); + }); + + it('every gesture kind that edits committed at least once', () => { + const editing = GESTURE_KINDS.filter((k) => k !== 'createElement' && k !== 'rubberBand' && k !== 'pan'); + expect(editing.filter((k) => (tally.commits.get(k) ?? 0) === 0)).toEqual([]); + }); + + it('E5: a JSON round trip of a generated view keeps a live gesture', () => { + for (let seed = 1; seed <= 10; seed++) { + const s = genImportedScene(new Rng(seed)); + const json = JSON.parse(JSON.stringify(stockFlowViewToJson(s.view))); + expect(sameGeometry(s.view, stockFlowViewFromJson(json, s.model.variables))).toBe(true); + } + }); +}); diff --git a/src/diagram/tests/gesture-planner-ported.test.ts b/src/diagram/tests/gesture-planner-ported.test.ts new file mode 100644 index 000000000..d43fd466f --- /dev/null +++ b/src/diagram/tests/gesture-planner-ported.test.ts @@ -0,0 +1,592 @@ +// Copyright 2026 The Simlin Authors. All rights reserved. +// Use of this source code is governed by the Apache License, +// Version 2.0, that can be found in the LICENSE file. + +// Scenarios the movement, attachment and pointer-interaction behaviors are +// specified by, as planner rows: the click threshold, label sides, rubber-band +// membership, which clicks open details, how a selection moves, how a flow end +// reattaches and a flow is drawn (with the stock lists buildEditOps derives, +// M2), a pipe drag's latch, and how links follow their moved endpoints. How a +// route bends, where a valve lands and how an offset forms belong to +// flow-geometry/ and are pinned by the flow-geometry-*.test.ts tables and +// repros, so they are not restated here. +// +// Rows are derived from enumerations where one exists: every element type for +// rubber-band membership and single-element moves, both flow ends x {stock, +// cloud} x {another stock, empty space} for reattachment, both creation sources x +// both drop kinds for drawing a flow, and every gesture kind for whether a click +// opens details. Tests are not type-checked, so each table also checks its keys +// against the enumeration at run time. +// +// What this establishes: the planner's decisions for these scenarios, and that +// the committed geometry holds the strict flow invariants and M3. What it does +// not: the Canvas rendering or dispatching them (canvas-gestures-*.test.tsx), or +// the engine applying the edits (editor-gestures-engine.test.ts). + +import { describe, it, expect } from '@rstest/core'; + +import { canonicalize } from '@simlin/core/canonicalize'; +import { variableIsArrayed, type FlowViewElement, type LinkViewElement, type UID } from '@simlin/core/datamodel'; +import type { JsonViewElement } from '@simlin/engine'; + +import { beyondThreshold, labelSideForPointer, planGesture, type Gesture, type GesturePlan } from '../gesture-planner'; +import { GESTURE_KINDS, type PressGesture } from '../gesture-planner/types'; +import { ClickDragThresholdPx } from '../drawing/pointer-utils'; +import { buildEditOps } from '../view-model-sync'; +import { + aux, + cloud, + committedReport, + elementOf, + flow, + link, + planInput, + planned, + routedFlows, + scene, + stock, + stockToStock, + type Pt, + type Scene, +} from './support/gesture-fixtures'; + +const add = (p: Pt, d: Pt): Pt => ({ x: p.x + d.x, y: p.y + d.y }); + +function centerOf(s: Scene, uid: UID): Pt { + const el = s.view.elements.find((e) => e.uid === uid) as { x: number; y: number } | undefined; + if (el === undefined) { + throw new Error(`no uid ${uid}`); + } + return { x: el.x, y: el.y }; +} + +function plan(s: Scene, gesture: PressGesture, press: Pt, current: Pt, selection: readonly UID[] = []) { + return planGesture(planInput(s, gesture, press, current, { selection: new Set(selection) })); +} + +/** One element of every view element type. */ +function everyKind(): Scene { + return scene([ + stock(1, 'S', 100, 100), + cloud(2, 3, 300, 100), + flow(3, 'F', { x: 200, y: 100 }, [ + [122.5, 100, 1], + [300, 100, 2], + ]), + aux(10, 'a', 100, 300), + { type: 'module', uid: 20, name: 'm', x: 300, y: 300 } as JsonViewElement, + { type: 'alias', uid: 30, aliasOfUid: 10, x: 500, y: 300 } as JsonViewElement, + link(40, 10, 3), + { type: 'group', uid: 50, name: 'g', x: 500, y: 100, width: 100, height: 80 } as JsonViewElement, + ]); +} + +const ELEMENT_TYPES = ['stock', 'cloud', 'flow', 'aux', 'module', 'alias', 'link', 'group'] as const; + +describe('beyondThreshold: a click wobbles, a drag moves', () => { + const T = ClickDragThresholdPx; + const rows: ReadonlyArray<{ name: string; delta: Pt; zoom: number; want: boolean }> = [ + { name: 'no movement is a click', delta: { x: 0, y: 0 }, zoom: 1, want: false }, + { name: 'sub-threshold jitter is a click', delta: { x: 1, y: 1 }, zoom: 1, want: false }, + { name: 'just under the threshold is a click', delta: { x: T - 0.5, y: 0 }, zoom: 1, want: false }, + { name: 'exactly the threshold is a drag', delta: { x: T, y: 0 }, zoom: 1, want: true }, + { name: 'well past the threshold is a drag', delta: { x: 50, y: 0 }, zoom: 1, want: true }, + { name: 'screen pixels: 3 model px at zoom 4 is a drag', delta: { x: 3, y: 0 }, zoom: 4, want: true }, + { name: 'screen pixels: 3 model px at zoom 0.5 is a click', delta: { x: 3, y: 0 }, zoom: 0.5, want: false }, + { + name: 'Euclidean: under the threshold on each axis, past it diagonally', + delta: { x: T / Math.SQRT2 + 0.01, y: T / Math.SQRT2 + 0.01 }, + zoom: 1, + want: true, + }, + ]; + for (const row of rows) { + it(row.name, () => { + const press = { x: 100, y: 100 }; + expect(beyondThreshold(press, add(press, row.delta), row.zoom)).toBe(row.want); + }); + } +}); + +describe('labelSideForPointer: the label goes to the side the pointer is on', () => { + const rows: ReadonlyArray<{ name: string; pointer: Pt; want: string }> = [ + { name: 'pointer to the left', pointer: { x: -10, y: 0 }, want: 'left' }, + { name: 'pointer to the right', pointer: { x: 10, y: 0 }, want: 'right' }, + { name: 'pointer above', pointer: { x: 0, y: -10 }, want: 'top' }, + { name: 'pointer below', pointer: { x: 0, y: 10 }, want: 'bottom' }, + { name: 'the upper-left diagonal belongs to left', pointer: { x: -10, y: -10 }, want: 'left' }, + { name: 'the upper-right diagonal belongs to top', pointer: { x: 10, y: -10 }, want: 'top' }, + { name: 'the lower-left diagonal belongs to bottom', pointer: { x: -10, y: 10 }, want: 'bottom' }, + { name: 'the lower-right diagonal belongs to right', pointer: { x: 10, y: 10 }, want: 'right' }, + ]; + for (const row of rows) { + it(row.name, () => { + expect(labelSideForPointer({ x: 0, y: 0 }, row.pointer)).toBe(row.want); + }); + } +}); + +describe('rubberBand membership, by element type', () => { + type Rule = 'center' | 'centerOrCorner' | 'never'; + const MEMBERSHIP: Readonly> = { + stock: { uid: 1, rule: 'center' }, + cloud: { uid: 2, rule: 'center' }, + flow: { uid: 3, rule: 'center' }, + aux: { uid: 10, rule: 'centerOrCorner' }, + module: { uid: 20, rule: 'center' }, + alias: { uid: 30, rule: 'center' }, + link: { uid: 40, rule: 'never' }, + group: { uid: 50, rule: 'never' }, + }; + + function band(s: Scene, press: Pt, current: Pt): ReadonlySet { + const p = planGesture(planInput(s, { kind: 'rubberBand' }, press, current, { clickSelection: new Set() })); + expect(p.commit).toBe('select'); + return p.selection; + } + + it('the table and the scene cover every element type', () => { + expect(Object.keys(MEMBERSHIP).sort()).toEqual([...ELEMENT_TYPES].sort()); + expect([...new Set(everyKind().view.elements.map((el) => el.type))].sort()).toEqual([...ELEMENT_TYPES].sort()); + }); + + for (const [type, { uid, rule }] of Object.entries(MEMBERSHIP)) { + if (rule === 'never') { + it(`${type}: never selected, even by a band covering everything`, () => { + expect(band(everyKind(), { x: -1000, y: -1000 }, { x: 2000, y: 2000 }).has(uid)).toBe(false); + }); + continue; + } + it(`${type}: selected by a band around its center`, () => { + const s = everyKind(); + const c = centerOf(s, uid); + expect(band(s, add(c, { x: -15, y: -15 }), add(c, { x: 15, y: 15 })).has(uid)).toBe(true); + }); + if (rule === 'center') { + it(`${type}: a band overlapping it but not its center does not select it`, () => { + const s = everyKind(); + const c = centerOf(s, uid); + expect(band(s, add(c, { x: 3, y: -15 }), add(c, { x: 40, y: 15 })).has(uid)).toBe(false); + }); + } else { + it(`${type}: a band whose corner lies within the circle selects it, one just outside does not`, () => { + const s = everyKind(); + const c = centerOf(s, uid); + expect(band(s, add(c, { x: 5, y: 5 }), add(c, { x: 60, y: 60 })).has(uid)).toBe(true); + expect(band(s, add(c, { x: 8, y: 8 }), add(c, { x: 60, y: 60 })).has(uid)).toBe(false); + }); + } + } + + it('a click (a band within the threshold) selects nothing', () => { + expect([...band(everyKind(), { x: 100, y: 100 }, { x: 101, y: 101 })]).toEqual([]); + }); +}); + +describe('which clicks open details, by gesture kind', () => { + const detailsScene = (): Scene => + scene([ + stock(1, 'S', 100, 100), + cloud(2, 3, 300, 100), + flow(3, 'F', { x: 200, y: 100 }, [ + [122.5, 100, 1], + [300, 100, 2], + ]), + aux(10, 'a', 100, 300), + aux(11, 'b', 300, 300), + link(13, 10, 11, 20), + ]); + // A click on an element's body (or its valve, pipe or link body) opens details; + // a click on an end grip, a label, a tool press or empty canvas does not. + const DETAILS: Readonly< + Record + > = { + moveSelection: { gesture: { kind: 'moveSelection' }, selection: [1], press: { x: 100, y: 100 }, details: true }, + slideValve: { gesture: { kind: 'slideValve', flow: 3 }, selection: [3], press: { x: 200, y: 100 }, details: true }, + offsetSegment: { + gesture: { kind: 'offsetSegment', flow: 3, segmentIndex: 0 }, + selection: [3], + press: { x: 250, y: 100 }, + details: true, + }, + linkArc: { gesture: { kind: 'linkArc', link: 13 }, selection: [13], press: { x: 200, y: 280 }, details: true }, + flowEndpoint: { + gesture: { kind: 'flowEndpoint', flow: 3, end: 'sink' }, + selection: [3], + press: { x: 300, y: 100 }, + details: false, + }, + linkEndpoint: { + gesture: { kind: 'linkEndpoint', link: 13 }, + selection: [13], + press: { x: 291, y: 300 }, + details: false, + }, + createFlow: { + gesture: { kind: 'createFlow', from: 'empty' }, + selection: [], + press: { x: 500, y: 500 }, + details: false, + }, + createLink: { gesture: { kind: 'createLink', from: 10 }, selection: [], press: { x: 100, y: 300 }, details: false }, + createElement: { + gesture: { kind: 'createElement', type: 'aux' }, + selection: [], + press: { x: 500, y: 500 }, + details: false, + }, + label: { gesture: { kind: 'label', uid: 10 }, selection: [10], press: { x: 100, y: 300 }, details: false }, + rubberBand: { gesture: { kind: 'rubberBand' }, selection: [], press: { x: 500, y: 500 }, details: false }, + pan: { gesture: { kind: 'pan' }, selection: [], press: { x: 500, y: 500 }, details: false }, + }; + + it('has a row for every gesture kind', () => { + expect(Object.keys(DETAILS).sort()).toEqual([...GESTURE_KINDS].sort()); + }); + + for (const [kind, row] of Object.entries(DETAILS)) { + it(`${kind}: a click ${row.details ? 'opens' : 'does not open'} details`, () => { + const p = plan(detailsScene(), row.gesture, row.press, add(row.press, { x: 1, y: 1 }), row.selection); + expect(!!p.details).toBe(row.details); + }); + if (row.details) { + it(`${kind}: a drag never opens details`, () => { + const p = plan(detailsScene(), row.gesture, row.press, add(row.press, { x: 40, y: 25 }), row.selection); + expect(!!p.details).toBe(false); + }); + } + } + + it('an unlatched pipe press: a click opens details', () => { + const p = plan( + detailsScene(), + { kind: 'pipe', flow: 3, segmentIndex: 0 }, + { x: 250, y: 100 }, + { x: 251, y: 101 }, + [3], + ); + expect(!!p.details).toBe(true); + }); +}); + +describe('moveSelection', () => { + // A lone flow slides its valve, a lone cloud drags its flow's end and a link + // curves; those are rows below, not translations. + const POSITIONED: Readonly> = { + stock: 1, + aux: 10, + module: 20, + alias: 30, + group: 50, + }; + + for (const [type, uid] of Object.entries(POSITIONED)) { + it(`a selected ${type} translates by exactly the pointer delta`, () => { + const s = everyKind(); + const c = centerOf(s, uid); + const p = plan(s, { kind: 'moveSelection' }, c, add(c, { x: 40, y: 30 }), [uid]); + expect(p.commit).toBe('edit'); + expect(elementOf(p, uid)).toMatchObject(add(c, { x: 40, y: 30 })); + expect(committedReport(s, p)).toBe(''); + }); + } + + it('a chain whose every element is selected translates rigidly, flow points and valve included', () => { + const s = stockToStock(); + const d = { x: 30, y: 50 }; + const p = plan(s, { kind: 'moveSelection' }, { x: 100, y: 100 }, add({ x: 100, y: 100 }, d), [1, 2, 3]); + const before = s.view.elements.find((e) => e.uid === 3) as FlowViewElement; + const after = elementOf(p, 3) as FlowViewElement; + expect(after.points.map((pt) => ({ x: pt.x, y: pt.y }))).toEqual(before.points.map((pt) => add(pt, d))); + expect({ x: after.x, y: after.y }).toEqual(add(before, d)); + expect(elementOf(p, 1)).toMatchObject({ x: 130, y: 150 }); + expect(elementOf(p, 2)).toMatchObject({ x: 430, y: 150 }); + }); + + it('a stock and its flow`s cloud selected together translate the flow rigidly', () => { + const s = everyKind(); + const d = { x: 20, y: 60 }; + const p = plan(s, { kind: 'moveSelection' }, { x: 100, y: 100 }, add({ x: 100, y: 100 }, d), [1, 2]); + const before = s.view.elements.find((e) => e.uid === 3) as FlowViewElement; + const after = elementOf(p, 3) as FlowViewElement; + expect(after.points.map((pt) => ({ x: pt.x, y: pt.y }))).toEqual(before.points.map((pt) => add(pt, d))); + expect(committedReport(s, p)).toBe(''); + }); + + it('a selected flow whose ends do not move keeps its path and slides only its valve', () => { + const s = everyKind(); + const p = plan(s, { kind: 'moveSelection' }, { x: 200, y: 100 }, { x: 240, y: 100 }, [3, 10]); + const before = s.view.elements.find((e) => e.uid === 3) as FlowViewElement; + const after = elementOf(p, 3) as FlowViewElement; + expect(after.points).toEqual(before.points); + expect(after.x).toBeCloseTo(240); + expect(elementOf(p, 10)).toMatchObject({ x: 140, y: 300 }); + }); + + it('a stock moved without its flows routes every attached flow onto its moved faces', () => { + const s = scene([ + stock(1, 'S', 200, 200), + cloud(11, 21, 400, 200), + cloud(12, 22, 200, 400), + cloud(13, 23, 200, 0), + flow(21, 'F1', { x: 300, y: 200 }, [ + [222.5, 200, 1], + [400, 200, 11], + ]), + flow(22, 'F2', { x: 200, y: 300 }, [ + [200, 217.5, 1], + [200, 400, 12], + ]), + flow(23, 'F3', { x: 200, y: 100 }, [ + [200, 182.5, 1], + [200, 0, 13], + ]), + ]); + const p = plan(s, { kind: 'moveSelection' }, { x: 200, y: 200 }, { x: 230, y: 230 }, [1]); + expect([...routedFlows(s, p)].sort()).toEqual([21, 22, 23]); + for (const uid of [21, 22, 23]) { + expect((elementOf(p, uid) as FlowViewElement).points[0].attachedToUid).toBe(1); + } + expect(committedReport(s, p)).toBe(''); + }); + + it('a cloud moved without its flow drags that end of the flow, and the cloud stays at the endpoint', () => { + const s = everyKind(); + const p = plan(s, { kind: 'moveSelection' }, { x: 300, y: 100 }, { x: 300, y: 160 }, [2]); + expect(p.commit).toBe('edit'); + expect(routedFlows(s, p).has(3)).toBe(true); + expect(committedReport(s, p)).toBe(''); + }); +}); + +describe('a pipe drag latches once: perpendicular offsets, along the pipe slides', () => { + it('perpendicular: the straight flow bends and holds the invariants', () => { + const s = everyKind(); + const p = plan(s, { kind: 'pipe', flow: 3, segmentIndex: 0 }, { x: 250, y: 100 }, { x: 250, y: 140 }, [3]); + expect(p.commit).toBe('edit'); + expect((elementOf(p, 3) as FlowViewElement).points.length).toBeGreaterThan(2); + expect(committedReport(s, p)).toBe(''); + }); + + it('along the pipe: the path stays and the valve slides', () => { + const s = everyKind(); + const before = s.view.elements.find((e) => e.uid === 3) as FlowViewElement; + const p = plan(s, { kind: 'pipe', flow: 3, segmentIndex: 0 }, { x: 200, y: 100 }, { x: 240, y: 100 }, [3]); + const after = elementOf(p, 3) as FlowViewElement; + expect(after.points).toEqual(before.points); + expect(after.x).toBeCloseTo(240); + }); + + it('a perpendicular wobble within the threshold does not bend the flow', () => { + const s = everyKind(); + const p = plan(s, { kind: 'pipe', flow: 3, segmentIndex: 0 }, { x: 250, y: 100 }, { x: 250, y: 103 }, [3]); + expect(p.commit).not.toBe('edit'); + expect(p.elements).toBe(s.view.elements); + }); +}); + +type StockLists = { inflows: string[]; outflows: string[] }; + +/** The stock lists the plan's edit rewrites, by stock ident: exactly the updateStockFlows ops buildEditOps derives. */ +function stockListOps(s: Scene, p: GesturePlan): Map { + const out = new Map(); + for (const op of buildEditOps(s.model, s.view, planned(s, p))) { + if (op.type === 'updateStockFlows') { + out.set(canonicalize(op.payload.ident), { + inflows: op.payload.inflows.map(canonicalize).sort(), + outflows: op.payload.outflows.map(canonicalize).sort(), + }); + } + } + return out; +} + +/** A (1) -> B (2) through f (3); cloud c6 (6) -> cloud c7 (7) through g (5); target stock T (4) below. */ +function attachScene(): Scene { + return scene([ + stock(1, 'A', 100, 100), + stock(2, 'B', 400, 100), + stock(4, 'T', 250, 300), + flow(3, 'f', { x: 250, y: 100 }, [ + [122.5, 100, 1], + [377.5, 100, 2], + ]), + cloud(6, 5, 100, 450), + cloud(7, 5, 400, 450), + flow(5, 'g', { x: 250, y: 450 }, [ + [100, 450, 6], + [400, 450, 7], + ]), + ]); +} + +describe('flowEndpoint: every end x attachment x drop kind, with the stock lists it implies (M2)', () => { + const ENDS = ['source', 'sink'] as const; + const FROM = ['stock', 'cloud'] as const; + const TO = ['stock', 'empty'] as const; + + for (const end of ENDS) { + for (const from of FROM) { + for (const to of TO) { + it(`${end} on a ${from}, dropped on ${to === 'stock' ? 'another stock' : 'empty space'}`, () => { + const s = attachScene(); + const flowUid = from === 'stock' ? 3 : 5; + const name = from === 'stock' ? 'f' : 'g'; + const base = s.view.elements.find((e) => e.uid === flowUid) as FlowViewElement; + const baseEnd = end === 'source' ? base.points[0] : base.points[base.points.length - 1]; + const press = { x: baseEnd.x, y: baseEnd.y }; + const current = to === 'stock' ? { x: 250, y: 300 } : add(press, { x: 0, y: 70 }); + + const p = plan(s, { kind: 'flowEndpoint', flow: flowUid, end }, press, current, [flowUid]); + expect(p.commit).toBe('edit'); + expect(committedReport(s, p)).toBe(''); + const routed = elementOf(p, flowUid) as FlowViewElement; + const routedEnd = end === 'source' ? routed.points[0] : routed.points[routed.points.length - 1]; + + const want = new Map(); + const listed = end === 'source' ? 'outflows' : 'inflows'; + if (from === 'stock') { + // The old stock's list loses the flow; the stock at the other end is untouched. + want.set(end === 'source' ? 'a' : 'b', { inflows: [], outflows: [] }); + } + if (to === 'stock') { + expect(routedEnd.attachedToUid).toBe(4); + want.set('t', { inflows: [], outflows: [], [listed]: [name] } as StockLists); + } else { + const endCloud = p.elements.find((e) => e.uid === routedEnd.attachedToUid); + expect(endCloud?.type === 'cloud' && endCloud.flowUid === flowUid).toBe(true); + if (from === 'cloud') { + // The same cloud moves with the end; no new cloud is made. + expect(routedEnd.attachedToUid).toBe(baseEnd.attachedToUid); + } + } + if (from === 'cloud' && to === 'stock') { + // The detached cloud is removed with the drop. + expect(p.elements.some((e) => e.uid === baseEnd.attachedToUid)).toBe(false); + } + expect(stockListOps(s, p)).toEqual(want); + }); + } + } + } +}); + +describe('createFlow: every source kind x drop kind, with the model it implies (M1/M2)', () => { + const FROM = ['empty', 'stock'] as const; + const TO = ['empty', 'stock'] as const; + + for (const from of FROM) { + for (const to of TO) { + it(`from ${from === 'stock' ? 'a stock' : 'empty space'} to ${to === 'stock' ? 'a stock' : 'empty space'}`, () => { + const s = attachScene(); + const gesture: Gesture = { kind: 'createFlow', from: from === 'stock' ? { stock: 1 } : 'empty' }; + const press = from === 'stock' ? { x: 100, y: 100 } : { x: 600, y: 300 }; + const current = to === 'stock' ? { x: 250, y: 300 } : add(press, { x: 0, y: 120 }); + + const p = plan(s, gesture, press, current); + expect(p.commit).toBe('edit'); + expect(committedReport(s, p)).toBe(''); + const created = p.elements.find((e): e is FlowViewElement => e.type === 'flow' && e.uid === s.view.nextUid); + expect(created).toBeDefined(); + expect([...p.selection]).toEqual([created!.uid]); + expect(p.handoff?.editName).toBe(created!.uid); + expect(p.nextUid).toBeGreaterThan(created!.uid); + + const source = created!.points[0]; + const sink = created!.points[created!.points.length - 1]; + const cloudOf = (uid: UID | undefined) => p.elements.find((e) => e.uid === uid); + expect(from === 'stock' ? source.attachedToUid === 1 : cloudOf(source.attachedToUid)?.type === 'cloud').toBe( + true, + ); + expect(to === 'stock' ? sink.attachedToUid === 4 : cloudOf(sink.attachedToUid)?.type === 'cloud').toBe(true); + + const ops = buildEditOps(s.model, s.view, planned(s, p)); + expect(ops.some((op) => op.type === 'upsertFlow')).toBe(true); + const want = new Map(); + if (from === 'stock') { + want.set('a', { inflows: [], outflows: ['f', 'new_flow'] }); + } + if (to === 'stock') { + want.set('t', { inflows: ['new_flow'], outflows: [] }); + } + expect(stockListOps(s, p)).toEqual(want); + }); + } + } + + // A drawn flow ends exactly at the pointer (its sink cloud is there), so a drag + // off an axis is an L whose long leg -- along the dominant axis -- runs into + // the sink, where the arrowhead needs room; a drag along an axis is straight. + const DIRECTIONS = [ + { name: 'mostly down', delta: { x: 10, y: 80 }, sinkAxis: 'vertical', points: 3 }, + { name: 'mostly up', delta: { x: 10, y: -80 }, sinkAxis: 'vertical', points: 3 }, + { name: 'mostly right', delta: { x: 80, y: 10 }, sinkAxis: 'horizontal', points: 3 }, + { name: 'mostly left', delta: { x: -80, y: 10 }, sinkAxis: 'horizontal', points: 3 }, + { name: 'straight down', delta: { x: 0, y: 80 }, sinkAxis: 'vertical', points: 2 }, + { name: 'straight right', delta: { x: 80, y: 0 }, sinkAxis: 'horizontal', points: 2 }, + ] as const; + for (const row of DIRECTIONS) { + it(`drawn from empty space ${row.name}: the sink cloud is at the pointer and the flow enters it ${row.sinkAxis}ly`, () => { + const s = attachScene(); + const press = { x: 700, y: 600 }; + const current = add(press, row.delta); + const p = plan(s, { kind: 'createFlow', from: 'empty' }, press, current); + expect(committedReport(s, p)).toBe(''); + const created = p.elements.find((e): e is FlowViewElement => e.type === 'flow' && e.uid === s.view.nextUid)!; + expect(created.points).toHaveLength(row.points); + const sink = created.points[created.points.length - 1]; + const beforeSink = created.points[created.points.length - 2]; + expect(p.elements.find((e) => e.uid === sink.attachedToUid)).toMatchObject({ type: 'cloud', ...current }); + expect(row.sinkAxis === 'vertical' ? beforeSink.x === sink.x : beforeSink.y === sink.y).toBe(true); + }); + } +}); + +describe('links follow their moved endpoints once, from the final positions', () => { + function linked(arc: number, arrayed: readonly string[] = []): Scene { + return scene([aux(10, 'a', 100, 100), aux(11, 'b', 200, 100), link(13, 10, 11, arc)], [], arrayed); + } + const arcOf = (p: ReturnType): number => (elementOf(p, 13) as LinkViewElement).arc!; + + it('both endpoints moved alike keep the arc', () => { + const p = plan(linked(30), { kind: 'moveSelection' }, { x: 100, y: 100 }, { x: 150, y: 125 }, [10, 11]); + expect(arcOf(p)).toBe(30); + }); + + it('one endpoint moved along the link`s line keeps the arc', () => { + const p = plan(linked(30), { kind: 'moveSelection' }, { x: 100, y: 100 }, { x: 150, y: 100 }, [10]); + expect(arcOf(p)).toBeCloseTo(30, 5); + }); + + it('one endpoint rotating the line turns the arc by the rotation', () => { + const p = plan(linked(0), { kind: 'moveSelection' }, { x: 100, y: 100 }, { x: 100, y: 200 }, [10]); + expect(arcOf(p)).toBeCloseTo(-45, 0); + }); + + it('a link selected along with one endpoint is turned once, not twice', () => { + const p = plan(linked(0), { kind: 'moveSelection' }, { x: 100, y: 100 }, { x: 100, y: 200 }, [10, 13]); + expect(arcOf(p)).toBeCloseTo(-45, 0); + }); + + // An arrayed element's link anchors at its front shape (offset by 3px), both + // before and after the move, so the turn matches the visual rotation. + const ARRAYED = [ + { name: 'the moved source is arrayed', arrayed: ['a'], moved: 10, want: -45 }, + { name: 'both ends are arrayed', arrayed: ['a', 'b'], moved: 10, want: -45 }, + // Old visual line (100,100)->(197,97), new (100,100)->(197,197): the line turns + // by 45 + atan(3/97) degrees. + { name: 'the moved target is arrayed', arrayed: ['b'], moved: 11, want: 45 + (Math.atan2(3, 97) * 180) / Math.PI }, + ] as const; + for (const row of ARRAYED) { + it(`${row.name}: the arc turns by the visual rotation`, () => { + const s = linked(0, row.arrayed); + for (const name of row.arrayed) { + const el = s.view.elements.find((e) => 'name' in e && e.name === name) as { var?: never }; + expect(el.var !== undefined && variableIsArrayed(el.var)).toBe(true); + } + const c = centerOf(s, row.moved); + const p = plan(s, { kind: 'moveSelection' }, c, add(c, { x: 0, y: 100 }), [row.moved]); + expect(Math.abs(arcOf(p) - row.want)).toBeLessThan(0.5); + }); + } +}); diff --git a/src/diagram/tests/gesture-planner.test.ts b/src/diagram/tests/gesture-planner.test.ts new file mode 100644 index 000000000..b6113471f --- /dev/null +++ b/src/diagram/tests/gesture-planner.test.ts @@ -0,0 +1,1016 @@ +// Copyright 2026 The Simlin Authors. All rights reserved. +// Use of this source code is governed by the Apache License, +// Version 2.0, that can be found in the LICENSE file. + +// planGesture, gesture by gesture. The per-gesture rows are keyed by +// GESTURE_KINDS (a compile-checked Record over the union), and every kind runs +// the same arms: a click within the threshold, a drag past it, the drag +// read-only, and the gesture naming a subject the view lacks. Kinds with drop +// targets add the invalid-target arm. Scenes load through the production +// modelFromJson (support/gesture-fixtures), and committed plans are checked with +// the strict flow checker over the flows the plan routed plus M3. +// +// What this establishes: each gesture's plan at one pointer position, E1 (a +// click previews and commits nothing), E6 (an invalid drop commits nothing), and +// that committed geometry holds G1-G8/M3 for these scenes. What it does not: +// the same properties over generated scenes (gesture-planner-fuzz.test.ts), +// continuity between frames (the geometry core's sweeps; nothing here or in the +// fuzz suite compares consecutive frames), the Canvas rendering the plan and +// committing it (canvas-gestures-*.test.tsx), or M1/M2 after the engine applies +// the edit (editor-gestures-engine.test.ts). + +import { describe, it, expect } from '@rstest/core'; + +import type { FlowViewElement, LinkViewElement, UID } from '@simlin/core/datamodel'; + +import { planGesture, type Gesture, type GesturePlan } from '../gesture-planner'; +import { GESTURE_KINDS, type PressGesture } from '../gesture-planner/types'; +import { StockHeight, StockWidth } from '../drawing/default'; +import { PIPE_SPACING } from '../flow-geometry'; +import { buildEditOps } from '../view-model-sync'; +import { + aux, + cloud, + committedReport, + elementOf, + flow, + link, + linkedAuxes, + planInput, + planned, + routedFlows, + scene, + stock, + stockToCloud, + stockToStock, + type Pt, + type Scene, +} from './support/gesture-fixtures'; + +interface KindRow { + readonly scene: () => Scene; + readonly gesture: Gesture; + readonly selection?: ReadonlySet; + readonly press: Pt; + /** A pointer past the click threshold. */ + readonly moved: Pt; + /** A pointer within the threshold; defaults to press + (2, 1). */ + readonly click?: Pt; + readonly commit: GesturePlan['commit']; + /** The same gesture naming a subject the view lacks; undefined for gestures with no subject. */ + readonly subjectless?: Gesture; + readonly check?: (s: Scene, plan: GesturePlan) => void; +} + +const ROWS: Record = { + moveSelection: { + scene: stockToCloud, + gesture: { kind: 'moveSelection' }, + selection: new Set([1]), + press: { x: 100, y: 100 }, + moved: { x: 100, y: 150 }, + commit: 'edit', + check: (s, plan) => { + expect(elementOf(plan, 1)).toMatchObject({ x: 100, y: 150 }); + expect([...routedFlows(s, plan)]).toEqual([3]); + expect(elementOf(plan, 2)).toBe(s.view.elements.find((e) => e.uid === 2)); + }, + }, + slideValve: { + scene: stockToCloud, + gesture: { kind: 'slideValve', flow: 3 }, + selection: new Set([3]), + press: { x: 200, y: 100 }, + moved: { x: 240, y: 100 }, + commit: 'edit', + subjectless: { kind: 'slideValve', flow: 999 }, + check: (_s, plan) => { + expect(elementOf(plan, 3)).toMatchObject({ x: 240, y: 100 }); + }, + }, + offsetSegment: { + scene: stockToStock, + gesture: { kind: 'offsetSegment', flow: 3, segmentIndex: 0 }, + selection: new Set([3]), + press: { x: 250, y: 100 }, + moved: { x: 250, y: 150 }, + commit: 'edit', + subjectless: { kind: 'offsetSegment', flow: 999, segmentIndex: 0 }, + check: (_s, plan) => { + // #819 as a bracket: beyond the face extent (100 + 17.5 - 3) plus + // MIN_SEGMENT, each endpoint keeps its face at the extent and a stub and a + // riser reach the run at the pointer's y. + const f = elementOf(plan, 3) as FlowViewElement; + expect(f.points.length).toBe(6); + expect(f.points[0]).toMatchObject({ x: 122.5, y: 114.5 }); + expect(f.points[5]).toMatchObject({ x: 377.5, y: 114.5 }); + expect(f.points.some((p, i) => i > 0 && p.y === 150 && f.points[i - 1].y === 150)).toBe(true); + }, + }, + flowEndpoint: { + scene: stockToCloud, + gesture: { kind: 'flowEndpoint', flow: 3, end: 'sink' }, + selection: new Set([3]), + press: { x: 300, y: 100 }, + moved: { x: 360, y: 160 }, + commit: 'edit', + subjectless: { kind: 'flowEndpoint', flow: 999, end: 'sink' }, + check: (_s, plan) => { + const f = elementOf(plan, 3) as FlowViewElement; + expect(f.points[f.points.length - 1]).toMatchObject({ x: 360, y: 160, attachedToUid: 2 }); + expect(elementOf(plan, 2)).toMatchObject({ x: 360, y: 160 }); + }, + }, + linkEndpoint: { + scene: linkedAuxes, + gesture: { kind: 'linkEndpoint', link: 13 }, + selection: new Set([13]), + press: { x: 291, y: 300 }, + moved: { x: 303, y: 447 }, + commit: 'edit', + subjectless: { kind: 'linkEndpoint', link: 999 }, + check: (_s, plan) => { + const l = elementOf(plan, 13) as LinkViewElement; + expect(l.toUid).toBe(12); + expect(Number.isFinite(l.arc)).toBe(true); + expect(plan.target).toEqual({ uid: 12, valid: true }); + }, + }, + linkArc: { + scene: linkedAuxes, + gesture: { kind: 'linkArc', link: 13 }, + selection: new Set([13]), + press: { x: 200, y: 300 }, + moved: { x: 200, y: 340 }, + commit: 'edit', + subjectless: { kind: 'linkArc', link: 999 }, + check: (_s, plan) => { + const l = elementOf(plan, 13) as LinkViewElement; + expect(Number.isFinite(l.arc)).toBe(true); + expect(l.arc).not.toBe(20); + }, + }, + createFlow: { + scene: linkedAuxes, + gesture: { kind: 'createFlow', from: 'empty' }, + press: { x: 500, y: 600 }, + moved: { x: 620, y: 600 }, + commit: 'edit', + subjectless: { kind: 'createFlow', from: { stock: 999 } }, + check: (s, plan) => { + const uid = s.view.nextUid; + const f = elementOf(plan, uid) as FlowViewElement; + expect(f.name).toBe('New Flow'); + expect(f.points.map((p) => [p.x, p.y])).toEqual([ + [500, 600], + [620, 600], + ]); + expect(plan.nextUid).toBe(uid + 3); + expect(plan.selection).toEqual(new Set([uid])); + expect(plan.handoff).toEqual({ editName: uid }); + // M1 for the created element: the committed edit creates its variable. + const ops = buildEditOps(s.model, s.view, planned(s, plan)); + expect(ops.some((op) => op.type === 'upsertFlow')).toBe(true); + }, + }, + createLink: { + scene: linkedAuxes, + gesture: { kind: 'createLink', from: 10 }, + press: { x: 100, y: 300 }, + moved: { x: 303, y: 447 }, + commit: 'edit', + subjectless: { kind: 'createLink', from: 999 }, + check: (s, plan) => { + const l = elementOf(plan, s.view.nextUid) as LinkViewElement; + expect({ fromUid: l.fromUid, toUid: l.toUid }).toEqual({ fromUid: 10, toUid: 12 }); + expect(plan.selection).toEqual(new Set([s.view.nextUid])); + }, + }, + createElement: { + scene: stockToCloud, + gesture: { kind: 'createElement', type: 'aux' }, + press: { x: 500, y: 500 }, + moved: { x: 560, y: 540 }, + commit: 'none', + check: (_s, plan) => { + expect(plan.draft).toMatchObject({ type: 'aux', x: 560, y: 540, name: 'New Variable' }); + expect(plan.handoff).toEqual({ editName: plan.draft!.uid }); + }, + }, + label: { + scene: () => scene([{ ...aux(10, 'a', 100, 300), labelSide: 'right' } as never, aux(11, 'b', 300, 300)]), + gesture: { kind: 'label', uid: 10 }, + selection: new Set([10]), + press: { x: 130, y: 300 }, + moved: { x: 100, y: 260 }, + // The label component owns the threshold: a frame whose side is the label's + // own changes nothing. + click: { x: 132, y: 301 }, + commit: 'edit', + subjectless: { kind: 'label', uid: 999 }, + check: (_s, plan) => { + expect(elementOf(plan, 10)).toMatchObject({ labelSide: 'top' }); + }, + }, + rubberBand: { + scene: linkedAuxes, + gesture: { kind: 'rubberBand' }, + press: { x: 50, y: 250 }, + moved: { x: 350, y: 350 }, + commit: 'select', + check: (_s, plan) => { + expect(plan.selection).toEqual(new Set([10, 11])); + }, + }, + pan: { + scene: stockToCloud, + gesture: { kind: 'pan' }, + press: { x: 500, y: 500 }, + moved: { x: 600, y: 600 }, + commit: 'none', + }, +}; + +describe('planGesture per gesture kind', () => { + it('has a row for every gesture kind', () => { + expect(Object.keys(ROWS).sort()).toEqual([...GESTURE_KINDS].sort()); + }); + + for (const kind of GESTURE_KINDS) { + const row = ROWS[kind]; + const plan = (s: Scene, current: Pt, overrides = {}): GesturePlan => + planGesture( + planInput(s, row.gesture, row.press, current, { selection: row.selection ?? new Set(), ...overrides }), + ); + + it(`${kind}: a drag past the threshold commits '${row.commit}' holding the invariants`, () => { + const s = row.scene(); + const p = plan(s, row.moved); + expect(p.commit).toBe(row.commit); + if (p.commit === 'edit') { + expect(committedReport(s, p)).toBe(''); + } + row.check?.(s, p); + }); + + it(`${kind}: a click within the threshold previews and commits nothing (E1)`, () => { + const s = row.scene(); + const p = plan(s, row.click ?? { x: row.press.x + 2, y: row.press.y + 1 }); + expect(p.commit).not.toBe('edit'); + if (kind === 'createElement') { + // The exception: an armed creation tool places its draft at the press. + expect(p.elements.slice(0, -1)).toEqual(s.view.elements); + expect(p.draft).toMatchObject({ x: row.press.x, y: row.press.y }); + } else { + expect(p.elements).toBe(s.view.elements); + } + }); + + it(`${kind}: read-only, the drag previews and commits no edit`, () => { + const s = row.scene(); + const p = plan(s, row.moved, { readOnly: true }); + expect(p.commit).not.toBe('edit'); + expect(p.elements).toBe(s.view.elements); + expect(p.draft).toBeUndefined(); + }); + + it(`${kind}: a subject the view lacks changes nothing`, () => { + const s = row.scene(); + if (row.subjectless === undefined) { + // moveSelection's subject is its selection; the rest have none. + if (kind === 'moveSelection') { + const p = plan(s, row.moved, { selection: new Set([999]) }); + expect(p.commit).toBe('none'); + expect(p.elements).toBe(s.view.elements); + } + return; + } + const p = planGesture(planInput(s, row.subjectless, row.press, row.moved, { selection: row.selection })); + expect(p.commit).toBe('none'); + expect(p.elements).toBe(s.view.elements); + }); + } +}); + +interface TargetRow { + readonly name: string; + readonly scene: () => Scene; + readonly gesture: Gesture; + readonly press: Pt; + readonly current: Pt; + readonly target: GesturePlan['target']; +} + +// The kinds with drop targets, each with the invalid arms the plan names. +const INVALID_TARGETS: readonly TargetRow[] = [ + { + name: 'flowEndpoint R12: a sink cloud over the flow`s own source stock', + scene: stockToCloud, + gesture: { kind: 'flowEndpoint', flow: 3, end: 'sink' }, + press: { x: 300, y: 100 }, + current: { x: 100, y: 100 }, + target: { uid: 1, valid: false }, + }, + { + name: 'flowEndpoint R14: a source dragged over the flow`s own sink stock', + scene: stockToStock, + gesture: { kind: 'flowEndpoint', flow: 3, end: 'source' }, + press: { x: 132, y: 100 }, + current: { x: 400, y: 105 }, + target: { uid: 2, valid: false }, + }, + { + name: 'flowEndpoint M6: a stock whose variable does not exist', + scene: () => + scene( + [ + stock(1, 'S', 100, 100), + stock(4, 'T', 400, 100), + cloud(2, 3, 300, 100), + flow(3, 'F', { x: 200, y: 100 }, [ + [122.5, 100, 1], + [300, 100, 2], + ]), + ], + ['T'], + ), + gesture: { kind: 'flowEndpoint', flow: 3, end: 'sink' }, + press: { x: 300, y: 100 }, + current: { x: 400, y: 100 }, + target: { uid: 4, valid: false }, + }, + { + name: 'createFlow R13/P36: released inside its own source stock', + scene: stockToCloud, + gesture: { kind: 'createFlow', from: { stock: 1 } }, + press: { x: 100, y: 100 }, + current: { x: 105, y: 108 }, + target: { uid: 1, valid: false }, + }, + { + name: 'createLink: a second link between the same two elements', + scene: linkedAuxes, + gesture: { kind: 'createLink', from: 10 }, + press: { x: 100, y: 300 }, + current: { x: 302, y: 302 }, + target: { uid: 11, valid: false }, + }, + { + name: 'linkEndpoint: onto an element the source already links to', + scene: () => + scene([ + aux(10, 'a', 100, 300), + aux(11, 'b', 300, 300), + aux(12, 'c', 300, 450), + link(13, 10, 11), + link(14, 10, 12), + ]), + gesture: { kind: 'linkEndpoint', link: 13 }, + press: { x: 291, y: 300 }, + current: { x: 300, y: 450 }, + target: { uid: 12, valid: false }, + }, +]; + +describe('planGesture invalid drop targets (E6)', () => { + for (const row of INVALID_TARGETS) { + it(row.name, () => { + const s = row.scene(); + const p = planGesture(planInput(s, row.gesture, row.press, row.current)); + expect(p.target).toEqual(row.target); + expect(p.commit).toBe('none'); + expect(p.handoff).toBeUndefined(); + }); + } +}); + +describe('planGesture audit repros and pinned behaviors', () => { + it('H1/P28: a click on a link arrowhead never deletes the link', () => { + const s = linkedAuxes(); + const p = planGesture(planInput(s, { kind: 'linkEndpoint', link: 13 }, { x: 286, y: 300 }, { x: 286, y: 300 })); + expect(p.commit).toBe('none'); + expect(p.elements).toBe(s.view.elements); + }); + + it('L-e/P35: a link arrowhead dropped on its own source aborts (no target, no commit)', () => { + const s = linkedAuxes(); + const p = planGesture(planInput(s, { kind: 'linkEndpoint', link: 13 }, { x: 291, y: 300 }, { x: 100, y: 300 })); + expect(p.target).toBeUndefined(); + expect(p.commit).toBe('none'); + }); + + it('a link arrowhead dropped on empty space aborts instead of deleting the link', () => { + const s = linkedAuxes(); + const p = planGesture(planInput(s, { kind: 'linkEndpoint', link: 13 }, { x: 291, y: 300 }, { x: 700, y: 700 })); + expect(p.commit).toBe('none'); + expect(p.elements.some((e) => e.uid === 13)).toBe(true); + }); + + it.each([ + ['C0: the source grip', 'source', { x: 132, y: 100 }], + ['T8: the arrowhead', 'sink', { x: 296, y: 100 }], + ] as const)('%s of a flow: a click commits nothing and detaches nothing', (_name, end, at) => { + const s = stockToCloud(); + const p = planGesture(planInput(s, { kind: 'flowEndpoint', flow: 3, end }, at, { x: at.x + 1, y: at.y + 2 })); + expect(p.commit).toBe('none'); + expect(p.elements).toBe(s.view.elements); + }); + + it('E6 for moves: a routed flow that cannot hold G2-G6 (its cloud dragged into another stock) commits nothing', () => { + const s = scene([ + stock(1, 'S', 100, 100), + cloud(2, 3, 300, 100), + flow(3, 'F', { x: 200, y: 100 }, [ + [122.5, 100, 1], + [300, 100, 2], + ]), + aux(10, 'a', 600, 600), + stock(4, 'T', 300, 300), + ]); + const p = planGesture( + planInput(s, { kind: 'moveSelection' }, { x: 300, y: 100 }, { x: 300, y: 300 }, { selection: new Set([2, 10]) }), + ); + expect(elementOf(p, 2)).toMatchObject({ x: 300, y: 300 }); + expect(p.commit).toBe('none'); + const clear = planGesture( + planInput(s, { kind: 'moveSelection' }, { x: 300, y: 100 }, { x: 300, y: 200 }, { selection: new Set([2, 10]) }), + ); + expect(clear.commit).toBe('edit'); + }); + + // G6 is best effort when the terminal bodies, each inflated by MIN_SEGMENT, + // overlap, so a stock dragged onto or up to its flow's other terminal still + // commits, holding G1-G5. Only a violation the plan does not excuse refuses a + // move: a cloud center inside a stock while the flow's terminals are apart. + describe('a move commits unless a routed flow breaks an invariant the plan does not excuse', () => { + const cloudBesideStock = (): Scene => + scene([ + stock(1, 'S', 100, 100), + cloud(2, 3, 300, 100), + flow(3, 'F', { x: 200, y: 100 }, [ + [122.5, 100, 1], + [300, 100, 2], + ]), + stock(4, 'T', 300, 300), + ]); + const ROWS: ReadonlyArray<{ + name: string; + scene: () => Scene; + selection: number[]; + press: Pt; + current: Pt; + commit: GesturePlan['commit']; + }> = [ + { + name: 'a stock dragged onto its flow`s other stock (bodies overlap) commits', + scene: stockToStock, + selection: [1], + press: { x: 100, y: 100 }, + current: { x: 380, y: 110 }, + commit: 'edit', + }, + { + name: 'a stock dragged 5px short of its flow`s other stock (inflated bodies overlap) commits', + scene: stockToStock, + selection: [1], + press: { x: 100, y: 100 }, + current: { x: 350, y: 100 }, + commit: 'edit', + }, + { + name: 'a stock dragged onto its own flow`s sink cloud (bodies overlap) commits', + scene: stockToCloud, + selection: [1], + press: { x: 100, y: 100 }, + current: { x: 300, y: 100 }, + commit: 'edit', + }, + { + name: 'a cloud dragged into another stock while the flow`s terminals are apart refuses', + scene: cloudBesideStock, + selection: [2], + press: { x: 300, y: 100 }, + current: { x: 300, y: 300 }, + commit: 'none', + }, + ]; + for (const row of ROWS) { + it(row.name, () => { + const s = row.scene(); + const p = planGesture( + planInput(s, { kind: 'moveSelection' }, row.press, row.current, { selection: new Set(row.selection) }), + ); + expect(p.commit).toBe(row.commit); + if (row.commit === 'edit') { + // The strict checker excuses exactly what the plan excuses (G6 and the G3 + // minima without room), so this is G1-G5 plus G7/G8 and M3. + expect(committedReport(s, p)).toBe(''); + } + }); + } + }); + + // A routed flow goes around every stock body where it can: a pipe through a + // stock reads as attached to it. Rows cover the stocks a flow can meet -- one + // an end has just left (class A), and any other (class C) -- through every + // gesture that routes: detach, reattach (either end), a stock move, drawing a + // flow, and offsetting a segment. births is the real-browser repro: its sink + // detached from Population once kept the corner on the old face's line and ran + // straight through Population's body. The land_model rows mirror the imported + // shapes the reviewer's sweep found crossing a third stock. + describe('a routed flow avoids every stock body', () => { + const births = (): Scene => + scene([ + stock(1, 'Population', 200, 230), + cloud(2, 3, 60, 150), + flow(3, 'births', { x: 120, y: 230 }, [ + [60, 150, 2], + [60, 230], + [177.5, 230, 1], + ]), + stock(4, 'Other', 420, 230), + ]); + // births after its sink was detached: Population is now no terminal of it. + const birthsDetached = (): Scene => + scene([ + stock(1, 'Population', 200, 230), + cloud(2, 3, 60, 150), + cloud(5, 3, 330, 320), + flow(3, 'births', { x: 195, y: 320 }, [ + [60, 150, 2], + [60, 320], + [330, 320, 5], + ]), + ]); + const straight = (u?: Pt): Scene => + scene([ + stock(1, 'S', 100, 100), + stock(2, 'T', 400, 100), + flow(3, 'F', { x: 250, y: 100 }, [ + [122.5, 100, 1], + [377.5, 100, 2], + ]), + ...(u === undefined ? [] : [stock(4, 'U', u.x, u.y)]), + ]); + // Imported land_model shapes: a cloud into stock 2, with stock 4 beyond it. + const landModel = (): Scene => + scene([ + cloud(284, 3, 282, 312), + stock(2, 'Land2', 480, 312), + stock(4, 'Land4', 691, 316), + flow(3, 'F3', { x: 370, y: 312 }, [ + [282, 312, 284], + [457.5, 312, 2], + ]), + ]); + // Whether a segment passes through the open interior of the stock at `c`. + const through = (points: readonly Pt[], c: Pt): boolean => { + const box = { + minX: c.x - StockWidth / 2, + maxX: c.x + StockWidth / 2, + minY: c.y - StockHeight / 2, + maxY: c.y + StockHeight / 2, + }; + const e = 1e-6; + return points.slice(1).some((b, i) => { + const a = points[i]; + if (Math.abs(a.y - b.y) <= e) { + const span = Math.min(Math.max(a.x, b.x), box.maxX) - Math.max(Math.min(a.x, b.x), box.minX); + return a.y > box.minY + e && a.y < box.maxY - e && span > e; + } + const span = Math.min(Math.max(a.y, b.y), box.maxY) - Math.max(Math.min(a.y, b.y), box.minY); + return a.x > box.minX + e && a.x < box.maxX - e && span > e; + }); + }; + const POPULATION = { x: 200, y: 230 }; + const ROWS: ReadonlyArray<{ + name: string; + scene: () => Scene; + gesture: PressGesture; + selection: number[]; + press: Pt; + current: Pt; + /** The stocks the routed flow must stay out of, where they sit in the planned frame. */ + avoid: Pt[]; + /** An end's expected attachment after the gesture. */ + attached?: { end: 'source' | 'sink'; uid: number }; + }> = [ + { + name: 'A detach: births` sink dragged from Population into empty space beyond it', + scene: births, + gesture: { kind: 'flowEndpoint', flow: 3, end: 'sink' }, + selection: [3], + press: { x: 177.5, y: 230 }, + current: { x: 363, y: 230 }, + avoid: [POPULATION], + }, + { + name: 'A detach: a straight flow`s sink dragged up past its old stock', + scene: () => straight(), + gesture: { kind: 'flowEndpoint', flow: 3, end: 'sink' }, + selection: [3], + press: { x: 377.5, y: 100 }, + current: { x: 380, y: -150 }, + avoid: [{ x: 400, y: 100 }], + }, + { + name: 'A reattach: births` sink dropped onto another stock beyond Population', + scene: births, + gesture: { kind: 'flowEndpoint', flow: 3, end: 'sink' }, + selection: [3], + press: { x: 177.5, y: 230 }, + current: { x: 420, y: 230 }, + avoid: [POPULATION], + attached: { end: 'sink', uid: 4 }, + }, + { + name: 'A reattach: a straight flow`s sink dropped onto a stock above its old stock', + scene: () => straight({ x: 400, y: -150 }), + gesture: { kind: 'flowEndpoint', flow: 3, end: 'sink' }, + selection: [3], + press: { x: 377.5, y: 100 }, + current: { x: 400, y: -150 }, + avoid: [{ x: 400, y: 100 }], + attached: { end: 'sink', uid: 4 }, + }, + { + name: 'A reattach, the other end: a straight flow`s source dropped onto a stock beyond its old stock', + scene: () => straight({ x: -150, y: -150 }), + gesture: { kind: 'flowEndpoint', flow: 3, end: 'source' }, + selection: [3], + press: { x: 122.5, y: 100 }, + current: { x: -150, y: -150 }, + avoid: [{ x: 100, y: 100 }], + attached: { end: 'source', uid: 4 }, + }, + { + name: 'A reattach, imported: land_model`s sink dropped onto the stock beyond its old one', + scene: landModel, + gesture: { kind: 'flowEndpoint', flow: 3, end: 'sink' }, + selection: [3], + press: { x: 457.5, y: 312 }, + current: { x: 691, y: 316 }, + avoid: [{ x: 480, y: 312 }], + attached: { end: 'sink', uid: 4 }, + }, + { + name: 'A and C detach, imported: land_model`s sink dragged below the stock beyond its old one', + scene: landModel, + gesture: { kind: 'flowEndpoint', flow: 3, end: 'sink' }, + selection: [3], + press: { x: 457.5, y: 312 }, + current: { x: 679, y: 404 }, + avoid: [ + { x: 480, y: 312 }, + { x: 691, y: 316 }, + ], + }, + { + name: 'C stock move, imported: land_model`s sink stock moved past a neighbor', + scene: landModel, + gesture: { kind: 'moveSelection' }, + selection: [2], + press: { x: 480, y: 312 }, + current: { x: 780, y: 312 }, + avoid: [{ x: 691, y: 316 }], + attached: { end: 'sink', uid: 2 }, + }, + { + name: 'terminal stock move: Population moved past births` source cloud', + scene: births, + gesture: { kind: 'moveSelection' }, + selection: [1], + press: { x: 200, y: 230 }, + current: { x: -40, y: 230 }, + avoid: [{ x: -40, y: 230 }], + attached: { end: 'sink', uid: 1 }, + }, + { + name: 'create: a flow drawn from Population back past its own far side', + scene: births, + gesture: { kind: 'createFlow', from: { stock: 1 } }, + selection: [], + press: { x: 215, y: 230 }, + current: { x: 0, y: 300 }, + avoid: [POPULATION], + }, + { + name: 'offset: births` last segment dragged down within Population`s height', + scene: births, + gesture: { kind: 'offsetSegment', flow: 3, segmentIndex: 1 }, + selection: [3], + press: { x: 120, y: 230 }, + current: { x: 120, y: 242 }, + avoid: [POPULATION], + attached: { end: 'sink', uid: 1 }, + }, + { + name: 'C offset across a former terminal: detached births` run dragged onto Population', + scene: birthsDetached, + gesture: { kind: 'offsetSegment', flow: 3, segmentIndex: 1 }, + selection: [3], + press: { x: 195, y: 320 }, + current: { x: 195, y: 215 }, + avoid: [POPULATION], + }, + { + name: 'C offset across a third stock: a straight flow`s bracket dragged onto a stock below', + scene: () => straight({ x: 250, y: 300 }), + gesture: { kind: 'offsetSegment', flow: 3, segmentIndex: 0 }, + selection: [3], + press: { x: 250, y: 100 }, + current: { x: 250, y: 285 }, + avoid: [{ x: 250, y: 300 }], + }, + ]; + for (const row of ROWS) { + it(row.name, () => { + const s = row.scene(); + const p = planGesture(planInput(s, row.gesture, row.press, row.current, { selection: new Set(row.selection) })); + expect(p.commit).toBe('edit'); + expect(committedReport(s, p)).toBe(''); + const uid = row.gesture.kind === 'createFlow' ? s.view.nextUid : 'flow' in row.gesture ? row.gesture.flow : 3; + const f = elementOf(p, uid) as FlowViewElement; + for (const at of row.avoid) { + expect(through(f.points, at)).toBe(false); + } + if (row.attached !== undefined) { + const point = row.attached.end === 'source' ? f.points[0] : f.points[f.points.length - 1]; + expect(point.attachedToUid).toBe(row.attached.uid); + } + }); + } + }); + + // The slot preference reaches production: a flow newly landing on a stock face + // that already holds an endpoint takes a slot at least PIPE_SPACING from it. + // Each row asserts the two endpoints really share the face, so the spacing + // clause is exercised rather than satisfied by a different face. + describe('a flow landing on an occupied face keeps its spacing', () => { + // f: A (1) -> B (2) into B's left face center; g: cloud (5) -> cloud (6) left + // of B and below f. Routed onto B, g enters the left face; with no occupied + // slots it would land on f's endpoint. + const occupied = (): Scene => + scene([ + stock(1, 'A', 100, 100), + stock(2, 'B', 400, 100), + flow(3, 'f', { x: 250, y: 100 }, [ + [122.5, 100, 1], + [377.5, 100, 2], + ]), + cloud(5, 4, 300, 130), + cloud(6, 4, 340, 130), + flow(4, 'g', { x: 320, y: 130 }, [ + [300, 130, 5], + [340, 130, 6], + ]), + ]); + const sinkOf = (p: GesturePlan, uid: number): Pt => { + const f = elementOf(p, uid) as FlowViewElement; + return f.points[f.points.length - 1]; + }; + it('OCC2: a sink dropped onto the stock', () => { + const s = occupied(); + const p = planGesture( + planInput( + s, + { kind: 'flowEndpoint', flow: 4, end: 'sink' }, + { x: 340, y: 130 }, + { x: 400, y: 100 }, + { selection: new Set([4]) }, + ), + ); + expect(p.commit).toBe('edit'); + const g = sinkOf(p, 4); + expect(g.x).toBe(377.5); + expect(Math.abs(g.y - 100)).toBeGreaterThanOrEqual(PIPE_SPACING); + expect(committedReport(s, p)).toBe(''); + }); + it('OCC3: a flow drawn onto the stock', () => { + const s = occupied(); + const p = planGesture( + planInput(s, { kind: 'createFlow', from: 'empty' }, { x: 300, y: 130 }, { x: 400, y: 100 }), + ); + expect(p.commit).toBe('edit'); + const g = sinkOf(p, s.view.nextUid); + expect(g.x).toBe(377.5); + expect(Math.abs(g.y - 100)).toBeGreaterThanOrEqual(PIPE_SPACING); + expect(committedReport(s, p)).toBe(''); + }); + it('OCC1: a moved stock whose two flows come to share a face', () => { + // S leaves g1 from its bottom face and g2 from its right face; moved far up + // and right, g2 re-routes onto the bottom face g1 already holds. + const s = scene([ + stock(1, 'S', 100, 100), + cloud(11, 21, 100, 300), + cloud(12, 22, 300, 100), + flow(21, 'g1', { x: 100, y: 200 }, [ + [100, 117.5, 1], + [100, 300, 11], + ]), + flow(22, 'g2', { x: 200, y: 100 }, [ + [122.5, 100, 1], + [300, 100, 12], + ]), + ]); + const p = planGesture( + planInput(s, { kind: 'moveSelection' }, { x: 100, y: 100 }, { x: 420, y: -120 }, { selection: new Set([1]) }), + ); + expect(p.commit).toBe('edit'); + const ends = [21, 22].map((uid) => (elementOf(p, uid) as FlowViewElement).points[0]); + const sameFace = Math.abs(ends[0].y - ends[1].y) < 1e-6 || Math.abs(ends[0].x - ends[1].x) < 1e-6; + expect(sameFace).toBe(true); + expect(Math.hypot(ends[0].x - ends[1].x, ends[0].y - ends[1].y)).toBeGreaterThanOrEqual(PIPE_SPACING); + expect(committedReport(s, p)).toBe(''); + }); + }); + + it('Vensim fallback flows: routing a flow with unattached ends attaches each to a new cloud', () => { + const s = scene([ + aux(10, 'a', 600, 600), + flow(3, 'F', { x: 200, y: 100 }, [ + [100, 100], + [300, 100], + ]), + ]); + const p = planGesture( + planInput( + s, + { kind: 'slideValve', flow: 3 }, + { x: 200, y: 100 }, + { x: 240, y: 100 }, + { selection: new Set([3]) }, + ), + ); + expect(p.commit).toBe('edit'); + const f = elementOf(p, 3) as FlowViewElement; + expect([f.points[0].attachedToUid, f.points[1].attachedToUid]).toEqual([s.view.nextUid, s.view.nextUid + 1]); + expect(p.nextUid).toBe(s.view.nextUid + 2); + expect(committedReport(s, p)).toBe(''); + }); + + it('#832: grabbing a source cloud without moving leaves an off-center valve where it is', () => { + const s = scene([ + cloud(2, 3, 100, 100), + stock(1, 'S', 300, 100), + flow(3, 'F', { x: 130, y: 100 }, [ + [100, 100, 2], + [277.5, 100, 1], + ]), + ]); + const p = planGesture( + planInput(s, { kind: 'flowEndpoint', flow: 3, end: 'source' }, { x: 100, y: 100 }, { x: 100, y: 100 }), + ); + expect(p.elements).toBe(s.view.elements); + }); + + it('H5/audit lead A: a sink cloud dropped on a column-aligned stock lands on that stock`s face', () => { + const s = scene([ + stock(1, 'S', 100, 100), + cloud(2, 3, 300, 100), + stock(4, 'T', 130, 250), + flow(3, 'F', { x: 200, y: 100 }, [ + [122.5, 100, 1], + [300, 100, 2], + ]), + ]); + const p = planGesture( + planInput(s, { kind: 'flowEndpoint', flow: 3, end: 'sink' }, { x: 300, y: 100 }, { x: 130, y: 250 }), + ); + expect(p.commit).toBe('edit'); + expect(p.target).toEqual({ uid: 4, valid: true }); + const f = elementOf(p, 3) as FlowViewElement; + const end = f.points[f.points.length - 1]; + expect(end.attachedToUid).toBe(4); + expect(Math.abs(end.x - 130) <= StockWidth / 2 + 1e-6 && Math.abs(end.y - 250) <= StockHeight / 2 + 1e-6).toBe( + true, + ); + expect(p.elements.some((e) => e.uid === 2)).toBe(false); + expect(committedReport(s, p)).toBe(''); + }); + + it('R13b/M4: a flow drawn from an off-center press on a stock ends exactly at the pointer', () => { + const s = scene([stock(1, 'S', 200, 200)]); + const current = { x: 315, y: 210 }; + const p = planGesture(planInput(s, { kind: 'createFlow', from: { stock: 1 } }, { x: 215, y: 210 }, current)); + expect(p.commit).toBe('edit'); + const f = elementOf(p, s.view.nextUid) as FlowViewElement; + expect(f.points[f.points.length - 1]).toMatchObject(current); + expect(f.points[0]).toMatchObject({ x: 222.5, attachedToUid: 1 }); + expect(committedReport(s, p)).toBe(''); + }); + + it('M5/P27: a link into a flow whose valve moves follows it in the planned elements', () => { + const s = scene([ + stock(1, 'S', 100, 100), + cloud(2, 3, 300, 100), + flow(3, 'F', { x: 200, y: 100 }, [ + [122.5, 100, 1], + [300, 100, 2], + ]), + aux(20, 'x', 200, 20), + link(21, 20, 3, 30), + ]); + const p = planGesture(planInput(s, { kind: 'slideValve', flow: 3 }, { x: 200, y: 100 }, { x: 260, y: 100 })); + const l = elementOf(p, 21) as LinkViewElement; + expect(l.arc).not.toBe(30); + expect(Number.isFinite(l.arc)).toBe(true); + }); + + it('#818: a flow whose stored valve is not finite routes to finite geometry', () => { + const s = stockToCloud(); + const f = s.view.elements.find((e) => e.uid === 3) as FlowViewElement; + const broken: Scene = { + ...s, + view: { ...s.view, elements: s.view.elements.map((e) => (e === f ? { ...f, x: NaN } : e)) }, + }; + const p = planGesture( + planInput(broken, { kind: 'flowEndpoint', flow: 3, end: 'sink' }, { x: 300, y: 100 }, { x: 400, y: 100 }), + ); + const routed = elementOf(p, 3) as FlowViewElement; + expect([routed.x, routed.y, ...routed.points.flatMap((pt) => [pt.x, pt.y])].every(Number.isFinite)).toBe(true); + }); + + it('#720: a flow whose source and sink are one stock neither throws nor breaks the move', () => { + const s = scene([ + stock(1, 'S', 100, 100), + flow(3, 'F', { x: 100, y: 60 }, [ + [100, 82.5, 1], + [100, 60], + [140, 60], + [140, 100], + [122.5, 100, 1], + ]), + ]); + const move = planGesture( + planInput(s, { kind: 'moveSelection' }, { x: 100, y: 100 }, { x: 140, y: 140 }, { selection: new Set([1]) }), + ); + expect(elementOf(move, 3)).toMatchObject({ x: 140, y: 100 }); + expect(() => + planGesture(planInput(s, { kind: 'flowEndpoint', flow: 3, end: 'sink' }, { x: 122, y: 100 }, { x: 300, y: 300 })), + ).not.toThrow(); + }); + + it('touch-straight links: a touch link is always straight, a mouse link curves through the pointer', () => { + const s = linkedAuxes(); + const at = (pointerType: string) => + elementOf( + planGesture( + planInput(s, { kind: 'createLink', from: 10 }, { x: 100, y: 300 }, { x: 300, y: 448 }, { pointerType }), + ), + s.view.nextUid, + ) as LinkViewElement; + expect(at('touch').arc).toBeUndefined(); + expect(Number.isFinite(at('mouse').arc)).toBe(true); + }); + + it('wobble-is-a-click: a sub-threshold wobble on a selected element settles its click selection and opens details', () => { + const s = linkedAuxes(); + const p = planGesture( + planInput( + s, + { kind: 'moveSelection' }, + { x: 100, y: 300 }, + { x: 102, y: 302 }, + { + selection: new Set([10, 11]), + clickSelection: new Set([10]), + }, + ), + ); + expect(p).toMatchObject({ commit: 'select', details: true }); + expect(p.selection).toEqual(new Set([10])); + }); + + it('the click threshold is in screen pixels: 2 model px at zoom 4 is a drag', () => { + const s = stockToCloud(); + const input = planInput( + s, + { kind: 'moveSelection' }, + { x: 100, y: 100 }, + { x: 100, y: 102 }, + { selection: new Set([1]) }, + ); + expect(planGesture(input).commit).toBe('none'); + expect(planGesture({ ...input, zoom: 4 }).commit).toBe('edit'); + }); + + it('E4: elements the gesture does not route are the very same objects', () => { + const s = scene([ + stock(1, 'S', 100, 100), + cloud(2, 3, 300, 100), + flow(3, 'F', { x: 200, y: 100 }, [ + [122.5, 100, 1], + [300, 100, 2], + ]), + aux(10, 'a', 600, 600), + stock(11, 'U', 600, 300), + ]); + const p = planGesture( + planInput(s, { kind: 'moveSelection' }, { x: 100, y: 100 }, { x: 100, y: 160 }, { selection: new Set([1]) }), + ); + for (const uid of [2, 10, 11]) { + expect(elementOf(p, uid)).toBe(s.view.elements.find((e) => e.uid === uid)); + } + }); +}); diff --git a/src/diagram/tests/group-movement.test.ts b/src/diagram/tests/group-movement.test.ts deleted file mode 100644 index 153d2366c..000000000 --- a/src/diagram/tests/group-movement.test.ts +++ /dev/null @@ -1,1927 +0,0 @@ -// Copyright 2026 The Simlin Authors. All rights reserved. -// Use of this source code is governed by the Apache License, -// Version 2.0, that can be found in the LICENSE file. - -import { describe, it, expect } from '@rstest/core'; - -import { - FlowViewElement, - StockViewElement, - CloudViewElement, - AuxViewElement, - LinkViewElement, - ModuleViewElement, - AliasViewElement, - GroupViewElement, - ViewElement, - UID, -} from '@simlin/core/datamodel'; - -import { StockHeight, StockWidth } from '../drawing/Stock'; -import { applyGroupMovement } from '../group-movement'; - -// Helper functions to create test elements -function makeStock( - uid: number, - x: number, - y: number, - inflows: number[] = [], - outflows: number[] = [], -): StockViewElement { - return { - type: 'stock', - uid, - name: `Stock${uid}`, - ident: `stock_${uid}`, - var: undefined, - x, - y, - labelSide: 'center', - isZeroRadius: false, - inflows, - outflows, - }; -} - -function makeFlow( - uid: number, - x: number, - y: number, - points: Array<{ x: number; y: number; attachedToUid?: number }>, -): FlowViewElement { - return { - type: 'flow', - uid, - name: `Flow${uid}`, - ident: `flow_${uid}`, - var: undefined, - x, - y, - labelSide: 'center', - points: points.map((p) => ({ x: p.x, y: p.y, attachedToUid: p.attachedToUid })), - isZeroRadius: false, - }; -} - -function makeCloud(uid: number, flowUid: number, x: number, y: number): CloudViewElement { - return { - type: 'cloud', - uid, - flowUid, - x, - y, - isZeroRadius: false, - ident: undefined, - }; -} - -function makeAux(uid: number, x: number, y: number, isArrayed = false): AuxViewElement { - const auxVar = isArrayed - ? { - type: 'aux' as const, - ident: `aux_${uid}`, - equation: { - type: 'applyToAll' as const, - dimensionNames: ['dim1'], - equation: '1', - }, - documentation: '', - units: '', - gf: undefined, - data: undefined, - errors: undefined, - unitErrors: undefined, - uid: undefined, - } - : undefined; - return { - type: 'aux', - uid, - name: `Aux${uid}`, - ident: `aux_${uid}`, - var: auxVar, - x, - y, - labelSide: 'center', - isZeroRadius: false, - }; -} - -function makeLink(uid: number, fromUid: number, toUid: number, arc: number = 0): LinkViewElement { - return { - type: 'link', - uid, - fromUid, - toUid, - arc, - isStraight: false, - multiPoint: undefined, - polarity: undefined, - x: 0, - y: 0, - isZeroRadius: false, - ident: undefined, - }; -} - -function makeModule(uid: number, x: number, y: number): ModuleViewElement { - return { - type: 'module', - uid, - name: `Module${uid}`, - ident: `module_${uid}`, - var: undefined, - x, - y, - labelSide: 'center', - isZeroRadius: false, - }; -} - -function makeAlias(uid: number, aliasOfUid: number, x: number, y: number): AliasViewElement { - return { - type: 'alias', - uid, - aliasOfUid, - x, - y, - labelSide: 'center', - isZeroRadius: false, - ident: undefined, - }; -} - -function makeGroup(uid: number, x: number, y: number, width: number = 100, height: number = 80): GroupViewElement { - return { - type: 'group', - uid, - name: `Group${uid}`, - x, - y, - width, - height, - isZeroRadius: false, - ident: undefined, - }; -} - -interface Point2D { - x: number; - y: number; -} - -/** - * Test helper that wraps the actual applyGroupMovement function. - * Takes elements as a Map (for convenience in tests) and returns a Map - * with all elements (original + updated). - */ -function testApplyGroupMovement( - elements: Map, - selection: Set, - delta: Point2D, - arcPoint?: Point2D, -): Map { - const { updatedElements } = applyGroupMovement({ - elements: elements.values(), - selection, - delta, - arcPoint, - }); - - // Merge updates back into the original elements map - const result = new Map(elements); - for (const [uid, el] of updatedElements) { - result.set(uid, el); - } - return result; -} - -describe('Group Movement', () => { - describe('Chain of stocks and flows all in selection', () => { - it('should translate entire chain uniformly when all elements are selected', () => { - // Setup: Stock A -> Flow 1 -> Stock B - const stockA = makeStock(1, 100, 100, [], [2]); - const stockB = makeStock(3, 200, 100, [2], []); - const flow = makeFlow(2, 150, 100, [ - { x: 100 + StockWidth / 2, y: 100, attachedToUid: 1 }, - { x: 200 - StockWidth / 2, y: 100, attachedToUid: 3 }, - ]); - - const elements = new Map([ - [1, stockA], - [2, flow], - [3, stockB], - ]); - - const selection = new Set([1, 2, 3]); - const delta = { x: -50, y: -30 }; // Move right 50, down 30 - - const result = testApplyGroupMovement(elements, selection, delta); - - // Stock A should move - const newStockA = result.get(1) as StockViewElement; - expect(newStockA.x).toBe(150); // 100 + 50 - expect(newStockA.y).toBe(130); // 100 + 30 - - // Stock B should move - const newStockB = result.get(3) as StockViewElement; - expect(newStockB.x).toBe(250); // 200 + 50 - expect(newStockB.y).toBe(130); // 100 + 30 - - // Flow valve should move - const newFlow = result.get(2) as FlowViewElement; - expect(newFlow.x).toBe(200); // 150 + 50 - expect(newFlow.y).toBe(130); // 100 + 30 - - // Flow endpoints should move too - const newPoints = newFlow.points; - expect(newPoints[0].x).toBe(100 + StockWidth / 2 + 50); - expect(newPoints[0].y).toBe(130); - expect(newPoints[newPoints.length - 1].x).toBe(200 - StockWidth / 2 + 50); - expect(newPoints[newPoints.length - 1].y).toBe(130); - }); - - it('should preserve relative positions in a longer chain', () => { - // Setup: Stock A -> Flow 1 -> Stock B -> Flow 2 -> Stock C - const stockA = makeStock(1, 100, 100, [], [2]); - const stockB = makeStock(3, 200, 100, [2], [4]); - const stockC = makeStock(5, 300, 100, [4], []); - const flow1 = makeFlow(2, 150, 100, [ - { x: 100 + StockWidth / 2, y: 100, attachedToUid: 1 }, - { x: 200 - StockWidth / 2, y: 100, attachedToUid: 3 }, - ]); - const flow2 = makeFlow(4, 250, 100, [ - { x: 200 + StockWidth / 2, y: 100, attachedToUid: 3 }, - { x: 300 - StockWidth / 2, y: 100, attachedToUid: 5 }, - ]); - - const elements = new Map([ - [1, stockA], - [2, flow1], - [3, stockB], - [4, flow2], - [5, stockC], - ]); - - const selection = new Set([1, 2, 3, 4, 5]); - const delta = { x: -100, y: 0 }; // Move right 100 - - const result = testApplyGroupMovement(elements, selection, delta); - - // All stocks should move by same amount - expect((result.get(1) as StockViewElement).x).toBe(200); - expect((result.get(3) as StockViewElement).x).toBe(300); - expect((result.get(5) as StockViewElement).x).toBe(400); - - // All flows should move by same amount - expect((result.get(2) as FlowViewElement).x).toBe(250); - expect((result.get(4) as FlowViewElement).x).toBe(350); - }); - }); - - describe('Stock in selection, attached flow not in selection', () => { - it('should adjust flow when stock moves but flow is not selected', () => { - // Setup: Stock A -> Flow 1 (not selected) -> Cloud - const stockA = makeStock(1, 100, 100, [], [2]); - const cloud = makeCloud(3, 2, 200, 100); - const flow = makeFlow(2, 150, 100, [ - { x: 100 + StockWidth / 2, y: 100, attachedToUid: 1 }, - { x: 200, y: 100, attachedToUid: 3 }, - ]); - - const elements = new Map([ - [1, stockA], - [2, flow], - [3, cloud], - ]); - - // Only select the stock, not the flow - const selection = new Set([1]); - const delta = { x: -50, y: 0 }; // Move stock right 50 - - const result = testApplyGroupMovement(elements, selection, delta); - - // Stock should move - const newStock = result.get(1) as StockViewElement; - expect(newStock.x).toBe(150); - - // Flow should be adjusted (routed from new stock position to fixed cloud) - const newFlow = result.get(2) as FlowViewElement; - // The source point should be updated to connect to new stock position - expect(newFlow.points[0].attachedToUid).toBe(1); - // IMPORTANT: Verify the source point x-coordinate is at the NEW stock edge - // (not double-moved). Stock moved from 100 to 150, so source point should - // be at 150 + StockWidth/2. - expect(newFlow.points[0].x).toBe(150 + StockWidth / 2); - // The sink point should still be at cloud - expect(newFlow.points[newFlow.points.length - 1].attachedToUid).toBe(3); - expect(newFlow.points[newFlow.points.length - 1].x).toBe(200); - }); - - it('should not jump a Z flow endpoint to a different edge when its stock is nudged', () => { - // Z-shaped stock-to-stock flow: endpoint on A's RIGHT edge with a vertical - // riser. Nudging A must keep the endpoint on the right edge (translate), - // not jump it to the top edge (#819 follow-up). This exercises the real - // interactive path (applyGroupMovement -> computeFlowRoute), not - // UpdateStockAndFlows. - const stockA = makeStock(1, 100, 100, [], [2]); - const stockB = makeStock(3, 250, 100, [2], []); - const zFlow = makeFlow(2, 175, 70, [ - { x: 122.5, y: 100, attachedToUid: 1 }, - { x: 122.5, y: 70 }, - { x: 227.5, y: 70 }, - { x: 227.5, y: 100, attachedToUid: 3 }, - ]); - - const elements = new Map([ - [1, stockA], - [2, zFlow], - [3, stockB], - ]); - - // Select only stock A and nudge it left by 20 (delta +20 => new center 80) - const result = testApplyGroupMovement(elements, new Set([1]), { x: 20, y: 0 }); - - const newStock = result.get(1) as StockViewElement; - expect(newStock.x).toBe(80); - - const newFlow = result.get(2) as FlowViewElement; - // Endpoint stays on A's RIGHT edge (80 + 22.5), y unchanged -- no jump to - // the top edge (which would be y=82.5). - expect(newFlow.points[0].attachedToUid).toBe(1); - expect(newFlow.points[0].x).toBe(80 + StockWidth / 2); - expect(newFlow.points[0].y).toBe(100); - // Riser stays aligned; middle segment preserved at y=70; still a Z. - expect(newFlow.points.length).toBe(4); - expect(newFlow.points[1].x).toBe(80 + StockWidth / 2); - expect(newFlow.points[1].y).toBe(70); - expect(newFlow.points[2].y).toBe(70); - }); - - it('should spread a Z and a straight flow sharing a stock edge through the interactive path', () => { - // Same probe as the flow-routing spread test, but via applyGroupMovement's - // computePreRoutedOffsets path (the interactive stock drag). A straight and - // a Z outflow both occupy A's right edge; nudging A must spread them, not - // stack both endpoints at the edge center. - const stockA = makeStock(1, 100, 100, [], [2, 5]); - const stockB = makeStock(3, 250, 100, [2], []); - const stockC = makeStock(6, 300, 250, [5], []); - const straight = makeFlow(2, 175, 100, [ - { x: 122.5, y: 100, attachedToUid: 1 }, - { x: 227.5, y: 100, attachedToUid: 3 }, - ]); - const zFlow = makeFlow(5, 200, 60, [ - { x: 122.5, y: 100, attachedToUid: 1 }, - { x: 122.5, y: 60 }, - { x: 300, y: 60 }, - { x: 300, y: 100, attachedToUid: 6 }, - ]); - - const elements = new Map([ - [1, stockA], - [2, straight], - [3, stockB], - [5, zFlow], - [6, stockC], - ]); - - const result = testApplyGroupMovement(elements, new Set([1]), { x: 5, y: 0 }); - - const straightEnd = (result.get(2) as FlowViewElement).points[0]; - const zEnd = (result.get(5) as FlowViewElement).points[0]; - // Both on A's right edge (x = 95 + 22.5), but NOT at the same point. - expect(straightEnd.x).toBe(95 + StockWidth / 2); - expect(zEnd.x).toBe(95 + StockWidth / 2); - expect(zEnd.y).not.toBe(straightEnd.y); - // Z stays a valid 4-point flow with its middle preserved. - expect((result.get(5) as FlowViewElement).points.length).toBe(4); - expect((result.get(5) as FlowViewElement).points[1].y).toBe(60); - }); - }); - - describe('Offset preservation with translated and routed flows', () => { - it('should compute offsets for all stocks regardless of iteration order', () => { - // This test verifies that computePreRoutedOffsets correctly handles its - // `elements` iterable even when it's a single-use iterator (like Map.values()). - // The bug: elements was iterated twice (outer loop for stocks, inner loop for flows). - // When applyGroupMovement calls computePreRoutedOffsets with originalElements.values(), - // the inner loop continues from the iterator's current position instead of restarting, - // causing flows appearing earlier in iteration order to be skipped for later stocks. - // - // Setup: Two stocks (A and B), each with TWO outflows to clouds. - // Stock A has flows 1 and 2. Stock B has flows 3 and 4. - // When both stocks move, ALL flows should get proper offset computation. - // Without the fix, Stock B's flows might have incorrect/missing offsets because - // the flows for Stock A were already consumed by the iterator. - - const stockA = makeStock(1, 100, 100, [], [10, 11]); - const stockB = makeStock(2, 300, 100, [], [20, 21]); - - // Stock A's two outflows - const cloudA1 = makeCloud(30, 10, 200, 80); - const cloudA2 = makeCloud(31, 11, 200, 120); - const flowA1 = makeFlow(10, 150, 90, [ - { x: 100 + StockWidth / 2, y: 90, attachedToUid: 1 }, - { x: 200, y: 80, attachedToUid: 30 }, - ]); - const flowA2 = makeFlow(11, 150, 110, [ - { x: 100 + StockWidth / 2, y: 110, attachedToUid: 1 }, - { x: 200, y: 120, attachedToUid: 31 }, - ]); - - // Stock B's two outflows - const cloudB1 = makeCloud(40, 20, 400, 80); - const cloudB2 = makeCloud(41, 21, 400, 120); - const flowB1 = makeFlow(20, 350, 90, [ - { x: 300 + StockWidth / 2, y: 90, attachedToUid: 2 }, - { x: 400, y: 80, attachedToUid: 40 }, - ]); - const flowB2 = makeFlow(21, 350, 110, [ - { x: 300 + StockWidth / 2, y: 110, attachedToUid: 2 }, - { x: 400, y: 120, attachedToUid: 41 }, - ]); - - const elements = new Map([ - [1, stockA], - [2, stockB], - [10, flowA1], - [11, flowA2], - [20, flowB1], - [21, flowB2], - [30, cloudA1], - [31, cloudA2], - [40, cloudB1], - [41, cloudB2], - ]); - - // Select both stocks (not the flows or clouds) - const selection = new Set([1, 2]); - const delta = { x: -50, y: 0 }; // Move stocks right by 50 - - const result = testApplyGroupMovement(elements, selection, delta); - - // Both stocks should move - expect((result.get(1) as StockViewElement).x).toBe(150); - expect((result.get(2) as StockViewElement).x).toBe(350); - - // All four flows should be routed and have DIFFERENT y-coordinates on their stock edges. - // This verifies that offsets were computed correctly for both stocks. - - // Stock A's flows should have different y-coordinates at the stock edge - const newFlowA1 = result.get(10) as FlowViewElement; - const newFlowA2 = result.get(11) as FlowViewElement; - const flowA1SourceY = newFlowA1.points[0].y; - const flowA2SourceY = newFlowA2.points[0].y; - expect(flowA1SourceY).not.toBe(flowA2SourceY); - - // Stock B's flows should have different y-coordinates at the stock edge - const newFlowB1 = result.get(20) as FlowViewElement; - const newFlowB2 = result.get(21) as FlowViewElement; - const flowB1SourceY = newFlowB1.points[0].y; - const flowB2SourceY = newFlowB2.points[0].y; - // This assertion will FAIL if the iterator bug exists, because Stock B's - // flows won't have proper offsets computed (inner loop consumed iterator) - expect(flowB1SourceY).not.toBe(flowB2SourceY); - }); - - it('should preserve flow spacing when stock has both translated and routed flows', () => { - // Setup: Stock A with two outflows - one to another selected stock (translated), - // one to a non-selected cloud (routed). Both flows should maintain proper spacing. - // - // Stock A (selected) -> Flow 1 (both endpoints selected) -> Stock B (selected) - // Stock A (selected) -> Flow 2 (one endpoint selected) -> Cloud (not selected) - // - // When Stock A moves, Flow 1 translates and Flow 2 is routed. Both should - // maintain their relative positions on Stock A's right edge. - - const stockA = makeStock(1, 100, 100, [], [2, 3]); - const stockB = makeStock(4, 200, 50, [2], []); - const cloud = makeCloud(5, 3, 200, 150); - - // Flow 1: Stock A -> Stock B (horizontal, from right side of A) - const flow1 = makeFlow(2, 150, 75, [ - { x: 100 + StockWidth / 2, y: 100, attachedToUid: 1 }, - { x: 200 - StockWidth / 2, y: 50, attachedToUid: 4 }, - ]); - - // Flow 2: Stock A -> Cloud (horizontal, from right side of A) - const flow2 = makeFlow(3, 150, 125, [ - { x: 100 + StockWidth / 2, y: 100, attachedToUid: 1 }, - { x: 200, y: 150, attachedToUid: 5 }, - ]); - - const elements = new Map([ - [1, stockA], - [2, flow1], - [3, flow2], - [4, stockB], - [5, cloud], - ]); - - // Select Stock A and Stock B (so Flow 1 has both endpoints selected) - // Don't select the cloud (so Flow 2 has only one endpoint selected) - const selection = new Set([1, 4]); - const delta = { x: -50, y: 0 }; // Move stocks right 50 - - const result = testApplyGroupMovement(elements, selection, delta); - - // Both stocks should move - const newStockA = result.get(1) as StockViewElement; - const newStockB = result.get(4) as StockViewElement; - expect(newStockA.x).toBe(150); - expect(newStockB.x).toBe(250); - - // Flow 1 should translate uniformly (both endpoints moved) - const newFlow1 = result.get(2) as FlowViewElement; - expect(newFlow1.points[0].x).toBe(150 + StockWidth / 2); - expect(newFlow1.points[newFlow1.points.length - 1].x).toBe(250 - StockWidth / 2); - - // Flow 2 should be routed (one endpoint moved, one fixed). It runs - // 77.5px right and 50px down, so it classifies as dominantly HORIZONTAL - // and its L attaches perpendicular to that: via the stock's bottom edge. - // (Before dominant-axis classification the exact-equality check misread - // this diagonal as vertical and attached via the right edge.) - const newFlow2 = result.get(3) as FlowViewElement; - expect(newFlow2.points[0].x).toBe(150); // bottom-edge attach at new stock x - expect(newFlow2.points[0].y).toBe(100 + StockHeight / 2); - expect(newFlow2.points[newFlow2.points.length - 1].x).toBe(200); // cloud unchanged - - // Both flows should have different y-coordinates on Stock A's edge - // (i.e., they should not overlap) - const flow1SourceY = newFlow1.points[0].y; - const flow2SourceY = newFlow2.points[0].y; - expect(flow1SourceY).not.toBe(flow2SourceY); - }); - }); - - describe('Cloud-to-cloud flow movement', () => { - it('should move entire flow and both clouds when cloud-cloud flow selected alone', () => { - // Setup: Cloud A -> Flow -> Cloud B (both clouds, no stocks) - // This tests the regression where cloud-cloud flows only moved the valve - // instead of translating the entire flow + both clouds together. - const cloudA = makeCloud(1, 2, 100, 100); - const cloudB = makeCloud(3, 2, 200, 100); - const flow = makeFlow(2, 150, 100, [ - { x: 100, y: 100, attachedToUid: 1 }, - { x: 200, y: 100, attachedToUid: 3 }, - ]); - - const elements = new Map([ - [1, cloudA], - [2, flow], - [3, cloudB], - ]); - - // Only select the flow (not the clouds) - const selection = new Set([2]); - const delta = { x: -50, y: -30 }; // Move right 50, down 30 - - const result = testApplyGroupMovement(elements, selection, delta); - - // Both clouds should move along with the flow - const newCloudA = result.get(1) as CloudViewElement; - expect(newCloudA.x).toBe(150); // 100 + 50 - expect(newCloudA.y).toBe(130); // 100 + 30 - - const newCloudB = result.get(3) as CloudViewElement; - expect(newCloudB.x).toBe(250); // 200 + 50 - expect(newCloudB.y).toBe(130); // 100 + 30 - - // Flow valve should move - const newFlow = result.get(2) as FlowViewElement; - expect(newFlow.x).toBe(200); // 150 + 50 - expect(newFlow.y).toBe(130); // 100 + 30 - - // Flow endpoints should move too - expect(newFlow.points[0].x).toBe(150); - expect(newFlow.points[0].y).toBe(130); - expect(newFlow.points[newFlow.points.length - 1].x).toBe(250); - expect(newFlow.points[newFlow.points.length - 1].y).toBe(130); - }); - - it('should move L-shaped cloud-cloud flow uniformly', () => { - // Setup: Cloud A -> L-shaped Flow (3 points) -> Cloud B - const cloudA = makeCloud(1, 2, 100, 100); - const cloudB = makeCloud(3, 2, 200, 200); - // L-shaped flow with corner at (100, 200) - const flow = makeFlow(2, 100, 150, [ - { x: 100, y: 100, attachedToUid: 1 }, - { x: 100, y: 200 }, // corner point - { x: 200, y: 200, attachedToUid: 3 }, - ]); - - const elements = new Map([ - [1, cloudA], - [2, flow], - [3, cloudB], - ]); - - // Only select the flow - const selection = new Set([2]); - const delta = { x: -25, y: -25 }; - - const result = testApplyGroupMovement(elements, selection, delta); - - // Both clouds should move - expect((result.get(1) as CloudViewElement).x).toBe(125); - expect((result.get(1) as CloudViewElement).y).toBe(125); - expect((result.get(3) as CloudViewElement).x).toBe(225); - expect((result.get(3) as CloudViewElement).y).toBe(225); - - // Flow valve and all points should move - const newFlow = result.get(2) as FlowViewElement; - expect(newFlow.x).toBe(125); - expect(newFlow.y).toBe(175); - expect(newFlow.points[0].x).toBe(125); - expect(newFlow.points[0].y).toBe(125); - expect(newFlow.points[1].x).toBe(125); - expect(newFlow.points[1].y).toBe(225); - expect(newFlow.points[2].x).toBe(225); - expect(newFlow.points[2].y).toBe(225); - }); - }); - - describe('Cloud-stock flow perpendicular drag behavior', () => { - it('should create L-shape when cloud-stock flow in group selection dragged perpendicular', () => { - // Setup: Cloud -> Flow (selected) -> Stock, horizontal flow - // This tests the bug where selecting a flow with a cloud endpoint as part - // of a group (e.g., flow + aux) prevents perpendicular drag rerouting. - // The cloud should move and the flow should become L-shaped, same as single-flow selection. - const cloud = makeCloud(1, 2, 100, 100); - const stock = makeStock(3, 200, 100, [2], []); - const flow = makeFlow(2, 150, 100, [ - { x: 100, y: 100, attachedToUid: 1 }, - { x: 200 - StockWidth / 2, y: 100, attachedToUid: 3 }, - ]); - // Add an aux that's also selected (making this a group selection) - const aux = makeAux(4, 300, 200); - - const elements = new Map([ - [1, cloud], - [2, flow], - [3, stock], - [4, aux], - ]); - - // Select the flow AND the aux (group selection, but neither flow endpoint is selected) - const selection = new Set([2, 4]); - // Drag DOWN 30 (perpendicular to horizontal flow, > 5px threshold) - // delta is subtracted, so y: -30 moves down - const delta = { x: 0, y: -30 }; - - const result = testApplyGroupMovement(elements, selection, delta); - - // Stock should NOT move (not selected) - expect((result.get(3) as StockViewElement).x).toBe(200); - expect((result.get(3) as StockViewElement).y).toBe(100); - - // Aux should move with the group - expect((result.get(4) as AuxViewElement).x).toBe(300); - expect((result.get(4) as AuxViewElement).y).toBe(230); - - // Cloud should move down (perpendicular to flow direction) - const newCloud = result.get(1) as CloudViewElement; - expect(newCloud.x).toBe(100); // x unchanged - expect(newCloud.y).toBe(130); // moved down 30 - - // Flow should become L-shaped (3 points) - const newFlow = result.get(2) as FlowViewElement; - expect(newFlow.points.length).toBe(3); - - // First point: at cloud's new position - expect(newFlow.points[0].x).toBe(100); - expect(newFlow.points[0].y).toBe(130); - - // Middle point: corner (at stock's x, cloud's new y) - expect(newFlow.points[1].x).toBe(200 - StockWidth / 2); - expect(newFlow.points[1].y).toBe(130); - - // Last point: at stock (unchanged) - expect(newFlow.points[newFlow.points.length - 1].x).toBe(200 - StockWidth / 2); - expect(newFlow.points[newFlow.points.length - 1].y).toBe(100); - }); - - it('should create L-shape when single cloud-stock flow dragged perpendicular', () => { - // Setup: Cloud -> Flow (selected) -> Stock, horizontal flow - // This tests the behavior from UpdateFlow where perpendicular drag - // converts a 2-point flow to an L-shape and moves the cloud endpoint. - const cloud = makeCloud(1, 2, 100, 100); - const stock = makeStock(3, 200, 100, [2], []); - const flow = makeFlow(2, 150, 100, [ - { x: 100, y: 100, attachedToUid: 1 }, - { x: 200 - StockWidth / 2, y: 100, attachedToUid: 3 }, - ]); - - const elements = new Map([ - [1, cloud], - [2, flow], - [3, stock], - ]); - - // Only select the flow (single-flow selection, not group movement) - const selection = new Set([2]); - // Drag DOWN 30 (perpendicular to horizontal flow, > 5px threshold) - // delta is subtracted, so y: -30 moves down - const delta = { x: 0, y: -30 }; - - const result = testApplyGroupMovement(elements, selection, delta); - - // Stock should NOT move - expect((result.get(3) as StockViewElement).x).toBe(200); - expect((result.get(3) as StockViewElement).y).toBe(100); - - // Cloud should move down (perpendicular to flow direction) - const newCloud = result.get(1) as CloudViewElement; - expect(newCloud.x).toBe(100); // x unchanged - expect(newCloud.y).toBe(130); // moved down 30 - - // Flow should become L-shaped (3 points) - const newFlow = result.get(2) as FlowViewElement; - expect(newFlow.points.length).toBe(3); - - // First point: at cloud's new position - expect(newFlow.points[0].x).toBe(100); - expect(newFlow.points[0].y).toBe(130); - - // Middle point: corner (at stock's x, cloud's new y) - expect(newFlow.points[1].x).toBe(200 - StockWidth / 2); - expect(newFlow.points[1].y).toBe(130); - - // Last point: at stock (unchanged) - expect(newFlow.points[newFlow.points.length - 1].x).toBe(200 - StockWidth / 2); - expect(newFlow.points[newFlow.points.length - 1].y).toBe(100); - }); - - it('should not create L-shape when perpendicular drag is too small', () => { - // When perpendicular movement is < 5px, the flow should remain 2-point - // and just clamp the valve (no L-shape conversion) - const cloud = makeCloud(1, 2, 100, 100); - const stock = makeStock(3, 200, 100, [2], []); - const flow = makeFlow(2, 150, 100, [ - { x: 100, y: 100, attachedToUid: 1 }, - { x: 200 - StockWidth / 2, y: 100, attachedToUid: 3 }, - ]); - - const elements = new Map([ - [1, cloud], - [2, flow], - [3, stock], - ]); - - // Small perpendicular drag (< 5px threshold) - const selection = new Set([2]); - const delta = { x: 0, y: -3 }; - - const result = testApplyGroupMovement(elements, selection, delta); - - // Flow should remain 2-point (no L-shape) - const newFlow = result.get(2) as FlowViewElement; - expect(newFlow.points.length).toBe(2); - - // Cloud should NOT move - expect((result.get(1) as CloudViewElement).y).toBe(100); - }); - - it('should slide valve along path when single cloud-stock flow dragged parallel', () => { - // When drag is parallel to the flow (not perpendicular), valve should - // slide along the flow path, not convert to L-shape - const cloud = makeCloud(1, 2, 100, 100); - const stock = makeStock(3, 200, 100, [2], []); - const flow = makeFlow(2, 140, 100, [ - { x: 100, y: 100, attachedToUid: 1 }, - { x: 200 - StockWidth / 2, y: 100, attachedToUid: 3 }, - ]); - - const elements = new Map([ - [1, cloud], - [2, flow], - [3, stock], - ]); - - // Parallel drag (along the flow) - const selection = new Set([2]); - const delta = { x: -20, y: 0 }; // Move right 20 - - const result = testApplyGroupMovement(elements, selection, delta); - - // Flow should remain 2-point - const newFlow = result.get(2) as FlowViewElement; - expect(newFlow.points.length).toBe(2); - - // Valve should move right (clamped to flow path) - // Original: 140, proposed: 160 - // Flow spans from 100 to 177.5 (stock edge minus 10px margin = 167.5) - expect(newFlow.x).toBe(160); - expect(newFlow.y).toBe(100); - - // Cloud should NOT move (parallel drag) - expect((result.get(1) as CloudViewElement).x).toBe(100); - }); - }); - - describe('Flow in selection, endpoint stocks not in selection', () => { - it('should only move valve when flow selected but attached stocks are not', () => { - // Setup: Stock A (not selected) -> Flow 1 (selected) -> Stock B (not selected) - const stockA = makeStock(1, 100, 100, [], [2]); - const stockB = makeStock(3, 200, 100, [2], []); - const flow = makeFlow(2, 150, 100, [ - { x: 100 + StockWidth / 2, y: 100, attachedToUid: 1 }, - { x: 200 - StockWidth / 2, y: 100, attachedToUid: 3 }, - ]); - - const elements = new Map([ - [1, stockA], - [2, flow], - [3, stockB], - ]); - - // Only select the flow - const selection = new Set([2]); - const delta = { x: -20, y: 0 }; // Move right 20 - - const result = testApplyGroupMovement(elements, selection, delta); - - // Stocks should NOT move - expect((result.get(1) as StockViewElement).x).toBe(100); - expect((result.get(3) as StockViewElement).x).toBe(200); - - // Flow valve should move but be clamped to stay on the flow path - // Flow goes from x=122.5 to x=177.5 (with 10px margin, max is 167.5) - // Proposed position is 170, so it gets clamped to 167.5 - const newFlow = result.get(2) as FlowViewElement; - expect(newFlow.x).toBe(167.5); // Clamped to stay within flow bounds - - // Flow endpoints should stay fixed (attached to stocks) - expect(newFlow.points[0].x).toBe(100 + StockWidth / 2); - expect(newFlow.points[newFlow.points.length - 1].x).toBe(200 - StockWidth / 2); - }); - - it('should offset a stock-to-stock flow into a Z when dragged perpendicular', () => { - // Setup: Stock A -> horizontal Flow -> Stock B - // Selecting only the flow and dragging it perpendicular offsets the route - // into a Z (two corners), since neither stock endpoint can move (#819). - const stockA = makeStock(1, 100, 100, [], [2]); - const stockB = makeStock(3, 200, 100, [2], []); - const flow = makeFlow(2, 150, 100, [ - { x: 100 + StockWidth / 2, y: 100, attachedToUid: 1 }, - { x: 200 - StockWidth / 2, y: 100, attachedToUid: 3 }, - ]); - - const elements = new Map([ - [1, stockA], - [2, flow], - [3, stockB], - ]); - - // Only select the flow, move UP (perpendicular to the horizontal flow) - const selection = new Set([2]); - const delta = { x: 0, y: 50 }; // Move up 50 => middle segment at y=50 - - const result = testApplyGroupMovement(elements, selection, delta); - - // Stocks should NOT move - expect((result.get(1) as StockViewElement).y).toBe(100); - expect((result.get(3) as StockViewElement).y).toBe(100); - - // Flow becomes a Z (four points); endpoints stay pinned on the stock edges - const newFlow = result.get(2) as FlowViewElement; - expect(newFlow.points.length).toBe(4); - expect(newFlow.points[0].y).toBe(100); - expect(newFlow.points[newFlow.points.length - 1].y).toBe(100); - // Middle segment (the two corners) sits at the displaced Y - expect(newFlow.points[1].y).toBe(50); - expect(newFlow.points[2].y).toBe(50); - // Valve rides the offset middle segment - expect(newFlow.y).toBe(50); - }); - }); - - describe('Partial chain selection', () => { - it('should move selected elements and adjust connections to unselected elements', () => { - // Setup: Stock A (selected) -> Flow 1 (selected) -> Stock B (not selected) - const stockA = makeStock(1, 100, 100, [], [2]); - const stockB = makeStock(3, 200, 100, [2], []); - const flow = makeFlow(2, 150, 100, [ - { x: 100 + StockWidth / 2, y: 100, attachedToUid: 1 }, - { x: 200 - StockWidth / 2, y: 100, attachedToUid: 3 }, - ]); - - const elements = new Map([ - [1, stockA], - [2, flow], - [3, stockB], - ]); - - // Select Stock A and Flow 1, but NOT Stock B - const selection = new Set([1, 2]); - const delta = { x: -50, y: 0 }; // Move right 50 - - const result = testApplyGroupMovement(elements, selection, delta); - - // Stock A should move - expect((result.get(1) as StockViewElement).x).toBe(150); - - // Stock B should NOT move - expect((result.get(3) as StockViewElement).x).toBe(200); - - // Flow source should move with Stock A - const newFlow = result.get(2) as FlowViewElement; - expect(newFlow.points[0].x).toBe(100 + StockWidth / 2 + 50); - - // Flow sink should stay at Stock B's position - expect(newFlow.points[newFlow.points.length - 1].x).toBe(200 - StockWidth / 2); - }); - - it('should move flow valve with the group when flow and one endpoint are selected', () => { - // This tests the bug where valve "lags behind" because computeFlowRoute - // preserves the valve based on old position without applying drag delta. - // - // Setup: Stock A (selected) -> Flow (selected, valve at 140) -> Stock B (not selected) - // When we drag Stock A + Flow, the valve should move with the drag delta, - // then be clamped to the new flow path. - const stockA = makeStock(1, 100, 100, [], [2]); - const stockB = makeStock(3, 300, 100, [2], []); - // Valve is at x=140, which is closer to Stock A - // Flow spans from 122.5 (Stock A edge) to 277.5 (Stock B edge) - const flow = makeFlow(2, 140, 100, [ - { x: 100 + StockWidth / 2, y: 100, attachedToUid: 1 }, // x = 122.5 - { x: 300 - StockWidth / 2, y: 100, attachedToUid: 3 }, // x = 277.5 - ]); - - const elements = new Map([ - [1, stockA], - [2, flow], - [3, stockB], - ]); - - // Select Stock A and Flow (not Stock B) - const selection = new Set([1, 2]); - const delta = { x: -50, y: 0 }; // Move right 50 - - const result = testApplyGroupMovement(elements, selection, delta); - - // Stock A should move from 100 to 150 - expect((result.get(1) as StockViewElement).x).toBe(150); - - // Flow should be re-routed: - // - Source moves from 122.5 to 172.5 (at new Stock A edge) - // - Sink stays at 277.5 (at Stock B edge) - const newFlow = result.get(2) as FlowViewElement; - expect(newFlow.points[0].x).toBe(150 + StockWidth / 2); // 172.5 - expect(newFlow.points[newFlow.points.length - 1].x).toBe(300 - StockWidth / 2); // 277.5 - - // The valve should have moved with the drag: - // Original valve at 140, drag delta of 50 right -> proposed position 190 - // Flow now spans 172.5 to 277.5, so 190 is within bounds - // Valve should be at or near 190 (clamped to flow path) - expect(newFlow.x).toBeCloseTo(190, 0); - expect(newFlow.y).toBe(100); - }); - - it('should preserve orthogonal flow geometry when moving perpendicular to flow direction', () => { - // Setup: Stock A -> horizontal Flow -> Stock B - // When we move Stock A + Flow UP (perpendicular to the flow), the flow - // should maintain orthogonal segments rather than becoming diagonal - const stockA = makeStock(1, 100, 100, [], [2]); - const stockB = makeStock(3, 200, 100, [2], []); - const flow = makeFlow(2, 150, 100, [ - { x: 100 + StockWidth / 2, y: 100, attachedToUid: 1 }, - { x: 200 - StockWidth / 2, y: 100, attachedToUid: 3 }, - ]); - - const elements = new Map([ - [1, stockA], - [2, flow], - [3, stockB], - ]); - - // Select Stock A and Flow, but NOT Stock B - // Move UP by 50 (perpendicular to original horizontal flow) - const selection = new Set([1, 2]); - const delta = { x: 0, y: 50 }; // Move up 50 - - const result = testApplyGroupMovement(elements, selection, delta); - - // Stock A should move up - expect((result.get(1) as StockViewElement).y).toBe(50); - - // Stock B should stay in place - expect((result.get(3) as StockViewElement).y).toBe(100); - - // Flow should be re-routed properly (L-shaped or straight to fixed stock) - // The key assertion: the flow should NOT have a diagonal segment - // The sink point should still connect to Stock B at its edge - const newFlow = result.get(2) as FlowViewElement; - expect(newFlow.points[newFlow.points.length - 1].attachedToUid).toBe(3); - expect(newFlow.points[newFlow.points.length - 1].x).toBe(200 - StockWidth / 2); - expect(newFlow.points[newFlow.points.length - 1].y).toBe(100); - }); - }); - - describe('Aux movement in group', () => { - it('should move auxes along with other selected elements', () => { - const aux1 = makeAux(1, 100, 100); - const aux2 = makeAux(2, 150, 150); - const stock = makeStock(3, 200, 100); - - const elements = new Map([ - [1, aux1], - [2, aux2], - [3, stock], - ]); - - const selection = new Set([1, 2, 3]); - const delta = { x: -30, y: -20 }; - - const result = testApplyGroupMovement(elements, selection, delta); - - expect((result.get(1) as AuxViewElement).x).toBe(130); - expect((result.get(1) as AuxViewElement).y).toBe(120); - expect((result.get(2) as AuxViewElement).x).toBe(180); - expect((result.get(2) as AuxViewElement).y).toBe(170); - expect((result.get(3) as StockViewElement).x).toBe(230); - expect((result.get(3) as StockViewElement).y).toBe(120); - }); - }); - - describe('Module movement in group', () => { - it('should move modules when selected', () => { - const module1 = makeModule(1, 100, 100); - const stock = makeStock(2, 200, 100); - - const elements = new Map([ - [1, module1], - [2, stock], - ]); - - const selection = new Set([1, 2]); - const delta = { x: -50, y: -25 }; - - const result = testApplyGroupMovement(elements, selection, delta); - - const newModule = result.get(1) as ModuleViewElement; - expect(newModule.x).toBe(150); - expect(newModule.y).toBe(125); - expect((result.get(2) as StockViewElement).x).toBe(250); - }); - - it('should move a single selected module', () => { - const module1 = makeModule(1, 100, 100); - - const elements = new Map([[1, module1]]); - - const selection = new Set([1]); - const delta = { x: -30, y: -20 }; - - const result = testApplyGroupMovement(elements, selection, delta); - - const newModule = result.get(1) as ModuleViewElement; - expect(newModule.x).toBe(130); - expect(newModule.y).toBe(120); - }); - }); - - describe('Alias movement in group', () => { - it('should move aliases when selected', () => { - const aux = makeAux(1, 100, 100); - const alias = makeAlias(2, 1, 200, 150); - - const elements = new Map([ - [1, aux], - [2, alias], - ]); - - const selection = new Set([1, 2]); - const delta = { x: -40, y: -30 }; - - const result = testApplyGroupMovement(elements, selection, delta); - - expect((result.get(1) as AuxViewElement).x).toBe(140); - expect((result.get(1) as AuxViewElement).y).toBe(130); - const newAlias = result.get(2) as AliasViewElement; - expect(newAlias.x).toBe(240); - expect(newAlias.y).toBe(180); - }); - - it('should move a single selected alias', () => { - const aux = makeAux(1, 100, 100); - const alias = makeAlias(2, 1, 200, 150); - - const elements = new Map([ - [1, aux], - [2, alias], - ]); - - // Only select the alias - const selection = new Set([2]); - const delta = { x: -25, y: -15 }; - - const result = testApplyGroupMovement(elements, selection, delta); - - // Aux should NOT move - expect((result.get(1) as AuxViewElement).x).toBe(100); - // Alias should move - const newAlias = result.get(2) as AliasViewElement; - expect(newAlias.x).toBe(225); - expect(newAlias.y).toBe(165); - }); - }); - - describe('Group element movement', () => { - it('should move group elements when selected', () => { - const group = makeGroup(1, 150, 140, 100, 80); - const stock = makeStock(2, 150, 140); - - const elements = new Map([ - [1, group], - [2, stock], - ]); - - const selection = new Set([1, 2]); - const delta = { x: -50, y: -30 }; - - const result = testApplyGroupMovement(elements, selection, delta); - - const newGroup = result.get(1) as GroupViewElement; - expect(newGroup.x).toBe(200); - expect(newGroup.y).toBe(170); - // Width and height should be preserved - expect(newGroup.width).toBe(100); - expect(newGroup.height).toBe(80); - }); - - it('should move a single selected group element', () => { - const group = makeGroup(1, 150, 140, 120, 90); - - const elements = new Map([[1, group]]); - - const selection = new Set([1]); - const delta = { x: -20, y: -10 }; - - const result = testApplyGroupMovement(elements, selection, delta); - - const newGroup = result.get(1) as GroupViewElement; - expect(newGroup.x).toBe(170); - expect(newGroup.y).toBe(150); - expect(newGroup.width).toBe(120); - expect(newGroup.height).toBe(90); - }); - }); - - describe('Cloud with flow movement', () => { - it('should translate cloud-to-stock flow uniformly when cloud and flow are both selected', () => { - // Setup: Cloud -> Flow -> Stock - const cloud = makeCloud(1, 2, 100, 100); - const stock = makeStock(3, 200, 100, [2], []); - const flow = makeFlow(2, 150, 100, [ - { x: 100, y: 100, attachedToUid: 1 }, - { x: 200 - StockWidth / 2, y: 100, attachedToUid: 3 }, - ]); - - const elements = new Map([ - [1, cloud], - [2, flow], - [3, stock], - ]); - - // Select cloud, flow, and stock - const selection = new Set([1, 2, 3]); - const delta = { x: -50, y: -30 }; - - const result = testApplyGroupMovement(elements, selection, delta); - - // Cloud should move - expect((result.get(1) as CloudViewElement).x).toBe(150); - expect((result.get(1) as CloudViewElement).y).toBe(130); - - // Stock should move - expect((result.get(3) as StockViewElement).x).toBe(250); - expect((result.get(3) as StockViewElement).y).toBe(130); - - // Flow should translate uniformly - const newFlow = result.get(2) as FlowViewElement; - expect(newFlow.x).toBe(200); - expect(newFlow.y).toBe(130); - expect(newFlow.points[0].x).toBe(150); - expect(newFlow.points[0].y).toBe(130); - }); - }); - - describe('Inline chain: cloud-flow-stock-flow-cloud, all selected', () => { - it('should translate entire inline chain uniformly', () => { - const cloudA = makeCloud(10, 20, 50, 100); - const stock = makeStock(1, 150, 100, [20], [21]); - const cloudB = makeCloud(11, 21, 250, 100); - const flowIn = makeFlow(20, 100, 100, [ - { x: 50, y: 100, attachedToUid: 10 }, - { x: 150 - StockWidth / 2, y: 100, attachedToUid: 1 }, - ]); - const flowOut = makeFlow(21, 200, 100, [ - { x: 150 + StockWidth / 2, y: 100, attachedToUid: 1 }, - { x: 250, y: 100, attachedToUid: 11 }, - ]); - - const elements = new Map([ - [10, cloudA], - [20, flowIn], - [1, stock], - [21, flowOut], - [11, cloudB], - ]); - - const selection = new Set([10, 20, 1, 21, 11]); - // delta is subtracted from viewBox coords, so negative = elements move in positive direction - const delta = { x: -60, y: -40 }; - - const result = testApplyGroupMovement(elements, selection, delta); - - // All elements should shift by (60, 40) - expect((result.get(10) as CloudViewElement).x).toBe(110); - expect((result.get(10) as CloudViewElement).y).toBe(140); - expect((result.get(11) as CloudViewElement).x).toBe(310); - expect((result.get(11) as CloudViewElement).y).toBe(140); - expect((result.get(1) as StockViewElement).x).toBe(210); - expect((result.get(1) as StockViewElement).y).toBe(140); - - const newFlowIn = result.get(20) as FlowViewElement; - expect(newFlowIn.x).toBe(160); - expect(newFlowIn.y).toBe(140); - expect(newFlowIn.points[0].x).toBe(110); - expect(newFlowIn.points[0].y).toBe(140); - expect(newFlowIn.points[newFlowIn.points.length - 1].x).toBe(150 - StockWidth / 2 + 60); - expect(newFlowIn.points[newFlowIn.points.length - 1].y).toBe(140); - expect(newFlowIn.points.length).toBe(2); - - const newFlowOut = result.get(21) as FlowViewElement; - expect(newFlowOut.x).toBe(260); - expect(newFlowOut.y).toBe(140); - expect(newFlowOut.points[0].x).toBe(150 + StockWidth / 2 + 60); - expect(newFlowOut.points[0].y).toBe(140); - expect(newFlowOut.points[newFlowOut.points.length - 1].x).toBe(310); - expect(newFlowOut.points[newFlowOut.points.length - 1].y).toBe(140); - expect(newFlowOut.points.length).toBe(2); - }); - - it('should re-route flows when only clouds and stock are selected', () => { - const cloudA = makeCloud(10, 20, 50, 100); - const stock = makeStock(1, 150, 100, [20], [21]); - const cloudB = makeCloud(11, 21, 250, 100); - const flowIn = makeFlow(20, 100, 100, [ - { x: 50, y: 100, attachedToUid: 10 }, - { x: 150 - StockWidth / 2, y: 100, attachedToUid: 1 }, - ]); - const flowOut = makeFlow(21, 200, 100, [ - { x: 150 + StockWidth / 2, y: 100, attachedToUid: 1 }, - { x: 250, y: 100, attachedToUid: 11 }, - ]); - - const elements = new Map([ - [10, cloudA], - [20, flowIn], - [1, stock], - [21, flowOut], - [11, cloudB], - ]); - - // Select only clouds + stock, not flows - const selection = new Set([10, 1, 11]); - const delta = { x: -60, y: 0 }; - - const result = testApplyGroupMovement(elements, selection, delta); - - // Clouds and stock move - expect((result.get(10) as CloudViewElement).x).toBe(110); - expect((result.get(1) as StockViewElement).x).toBe(210); - expect((result.get(11) as CloudViewElement).x).toBe(310); - - // Flows are re-routed between new positions - const newFlowIn = result.get(20) as FlowViewElement; - expect(newFlowIn.points[0].x).toBe(110); - expect(newFlowIn.points[newFlowIn.points.length - 1].x).toBe(210 - StockWidth / 2); - - const newFlowOut = result.get(21) as FlowViewElement; - expect(newFlowOut.points[0].x).toBe(210 + StockWidth / 2); - expect(newFlowOut.points[newFlowOut.points.length - 1].x).toBe(310); - }); - }); - - describe('Cloud in selection, attached flow not in selection', () => { - it('should adjust flow when cloud moves parallel to flow direction', () => { - // Setup: Cloud -> Flow (not selected) -> Stock, horizontal flow - const cloud = makeCloud(1, 2, 100, 100); - const stock = makeStock(3, 200, 100, [2], []); - const flow = makeFlow(2, 150, 100, [ - { x: 100, y: 100, attachedToUid: 1 }, - { x: 200 - StockWidth / 2, y: 100, attachedToUid: 3 }, - ]); - - const elements = new Map([ - [1, cloud], - [2, flow], - [3, stock], - ]); - - // Only select the cloud, not the flow - const selection = new Set([1]); - const delta = { x: -50, y: 0 }; // Move cloud right 50 (parallel to flow) - - const result = testApplyGroupMovement(elements, selection, delta); - - // Cloud should move - const newCloud = result.get(1) as CloudViewElement; - expect(newCloud.x).toBe(150); - - // Flow should be adjusted (routed from new cloud position to fixed stock) - const newFlow = result.get(2) as FlowViewElement; - // Flow remains 2 points (straight horizontal line) - expect(newFlow.points.length).toBe(2); - // The source point should be updated to connect to new cloud position - expect(newFlow.points[0].attachedToUid).toBe(1); - expect(newFlow.points[0].x).toBe(150); - expect(newFlow.points[0].y).toBe(100); - // The sink point should still be at stock - expect(newFlow.points[newFlow.points.length - 1].attachedToUid).toBe(3); - expect(newFlow.points[newFlow.points.length - 1].x).toBe(200 - StockWidth / 2); - }); - - it('should create L-shaped flow when cloud moves perpendicular to flow direction', () => { - // Setup: Cloud -> Flow (not selected) -> Stock, horizontal flow - const cloud = makeCloud(1, 2, 100, 100); - const stock = makeStock(3, 200, 100, [2], []); - const flow = makeFlow(2, 150, 100, [ - { x: 100, y: 100, attachedToUid: 1 }, - { x: 200 - StockWidth / 2, y: 100, attachedToUid: 3 }, - ]); - - const elements = new Map([ - [1, cloud], - [2, flow], - [3, stock], - ]); - - // Only select the cloud, not the flow - const selection = new Set([1]); - // Move cloud DOWN 30 (perpendicular to horizontal flow) - // delta is subtracted, so y: -30 moves from y=100 to y=130 - const delta = { x: 0, y: -30 }; - - const result = testApplyGroupMovement(elements, selection, delta); - - // Cloud should move down - const newCloud = result.get(1) as CloudViewElement; - expect(newCloud.x).toBe(100); // x unchanged - expect(newCloud.y).toBe(130); // moved down 30 - - // Flow should be re-routed as L-shaped (3 points) - const newFlow = result.get(2) as FlowViewElement; - expect(newFlow.points.length).toBe(3); - - // First point: at cloud's new position - const firstPt = newFlow.points[0]; - expect(firstPt.attachedToUid).toBe(1); - expect(firstPt.x).toBe(100); - expect(firstPt.y).toBe(130); - - // Middle point: corner creating the L-shape (at stock's x, cloud's new y) - const middlePt = newFlow.points[1]; - expect(middlePt.attachedToUid).toBeUndefined(); // corner point, not attached - expect(middlePt.x).toBe(200 - StockWidth / 2); // at stock's x - expect(middlePt.y).toBe(130); // at cloud's new y - - // Last point: at stock (unchanged) - const lastPt = newFlow.points[newFlow.points.length - 1]; - expect(lastPt.attachedToUid).toBe(3); - expect(lastPt.x).toBe(200 - StockWidth / 2); - expect(lastPt.y).toBe(100); - - // Verify flow maintains orthogonal segments (horizontal + vertical) - // Segment 1: (100, 130) -> (175, 130) is horizontal - expect(firstPt.y).toBe(middlePt.y); - // Segment 2: (175, 130) -> (175, 100) is vertical - expect(middlePt.x).toBe(lastPt.x); - }); - }); - - describe('Sink cloud endpoint selected, attached flow not in selection', () => { - // Characterization of routeUnselectedFlows' sink-endpoint loop: the mirror - // of the "Cloud in selection" source-endpoint cases above, exercising a flow - // whose LAST point is the selected cloud. Pins the sink loop's L-shape and - // straight-through behavior before the two loops are unified. - it('should adjust flow when sink cloud moves parallel to flow direction', () => { - const stock = makeStock(1, 100, 100, [], [2]); - const cloud = makeCloud(3, 2, 200, 100); - const flow = makeFlow(2, 150, 100, [ - { x: 100 + StockWidth / 2, y: 100, attachedToUid: 1 }, - { x: 200, y: 100, attachedToUid: 3 }, - ]); - - const elements = new Map([ - [1, stock], - [2, flow], - [3, cloud], - ]); - - // Only select the sink cloud, move it right 50 (parallel to flow) - const selection = new Set([3]); - const delta = { x: -50, y: 0 }; - - const result = testApplyGroupMovement(elements, selection, delta); - - // Cloud should move right - expect((result.get(3) as CloudViewElement).x).toBe(250); - - // Flow stays 2-point straight, sink follows the cloud - const newFlow = result.get(2) as FlowViewElement; - expect(newFlow.points.length).toBe(2); - expect(newFlow.points[0].x).toBe(100 + StockWidth / 2); - expect(newFlow.points[newFlow.points.length - 1].attachedToUid).toBe(3); - expect(newFlow.points[newFlow.points.length - 1].x).toBe(250); - expect(newFlow.points[newFlow.points.length - 1].y).toBe(100); - }); - - it('should create L-shaped flow when sink cloud moves perpendicular to flow direction', () => { - const stock = makeStock(1, 100, 100, [], [2]); - const cloud = makeCloud(3, 2, 200, 100); - const flow = makeFlow(2, 150, 100, [ - { x: 100 + StockWidth / 2, y: 100, attachedToUid: 1 }, - { x: 200, y: 100, attachedToUid: 3 }, - ]); - - const elements = new Map([ - [1, stock], - [2, flow], - [3, cloud], - ]); - - // Only select the sink cloud, move it down 30 (perpendicular) - const selection = new Set([3]); - const delta = { x: 0, y: -30 }; - - const result = testApplyGroupMovement(elements, selection, delta); - - // Cloud should move down - const newCloud = result.get(3) as CloudViewElement; - expect(newCloud.x).toBe(200); - expect(newCloud.y).toBe(130); - - // Flow becomes L-shaped (3 points), staying orthogonal - const newFlow = result.get(2) as FlowViewElement; - expect(newFlow.points.length).toBe(3); - - // First point stays at the stock edge - expect(newFlow.points[0].attachedToUid).toBe(1); - expect(newFlow.points[0].x).toBe(100 + StockWidth / 2); - expect(newFlow.points[0].y).toBe(100); - - // Corner turns the L (at stock edge x, cloud's new y) - const corner = newFlow.points[1]; - expect(corner.attachedToUid).toBeUndefined(); - expect(corner.x).toBe(100 + StockWidth / 2); - expect(corner.y).toBe(130); - - // Last point at the cloud's new position - const lastPt = newFlow.points[2]; - expect(lastPt.attachedToUid).toBe(3); - expect(lastPt.x).toBe(200); - expect(lastPt.y).toBe(130); - - // Orthogonal segments: vertical then horizontal - expect(newFlow.points[0].x).toBe(corner.x); - expect(corner.y).toBe(lastPt.y); - }); - }); -}); - -describe('Link arc adjustment during group movement', () => { - it('should preserve arc when both link endpoints move together', () => { - // Setup: Aux A -> Link (with arc) -> Aux B, both selected - const auxA = makeAux(1, 100, 100); - const auxB = makeAux(2, 200, 100); - const link = makeLink(3, 1, 2, 30); // Arc of 30 degrees - - const elements = new Map([ - [1, auxA], - [2, auxB], - [3, link], - ]); - - // Select both auxes and the link - const selection = new Set([1, 2, 3]); - const delta = { x: -50, y: -25 }; // Move everything right 50, down 25 - - const result = testApplyGroupMovement(elements, selection, delta); - - // Auxes should move - expect((result.get(1) as AuxViewElement).x).toBe(150); - expect((result.get(1) as AuxViewElement).y).toBe(125); - expect((result.get(2) as AuxViewElement).x).toBe(250); - expect((result.get(2) as AuxViewElement).y).toBe(125); - - // Link arc should be preserved since both endpoints moved together - const newLink = result.get(3) as LinkViewElement; - expect(newLink.arc).toBe(30); - }); - - it('should adjust arc angle when only one endpoint moves', () => { - // Setup: Aux A (selected) -> Link (selected) -> Aux B (not selected) - // Moving Aux A will change the link direction, so arc should be adjusted - // to preserve the curve shape - const auxA = makeAux(1, 100, 100); - const auxB = makeAux(2, 200, 100); - const link = makeLink(3, 1, 2, 30); - - const elements = new Map([ - [1, auxA], - [2, auxB], - [3, link], - ]); - - // Select only Aux A and the link (not Aux B) - const selection = new Set([1, 3]); - const delta = { x: -50, y: 0 }; // Move Aux A right 50, keeping horizontal - - const result = testApplyGroupMovement(elements, selection, delta); - - // Aux A should move - expect((result.get(1) as AuxViewElement).x).toBe(150); - expect((result.get(1) as AuxViewElement).y).toBe(100); - - // Aux B should stay - expect((result.get(2) as AuxViewElement).x).toBe(200); - - // Link arc should be adjusted to preserve curve shape. - // Original line: (100, 100) -> (200, 100), angle = 0 - // New line: (150, 100) -> (200, 100), angle = 0 - // Angle difference is 0, so arc should stay the same in this case - const newLink = result.get(3) as LinkViewElement; - expect(newLink.arc).toBeCloseTo(30, 5); - }); - - it('should adjust arc angle for rotational movement of one endpoint', () => { - // Setup: Aux A (selected) -> Link (selected) -> Aux B (not selected) - // Move Aux A perpendicular to the original link direction, causing rotation - const auxA = makeAux(1, 100, 100); - const auxB = makeAux(2, 200, 100); - const link = makeLink(3, 1, 2, 0); // No initial arc - - const elements = new Map([ - [1, auxA], - [2, auxB], - [3, link], - ]); - - // Select only Aux A and the link - const selection = new Set([1, 3]); - // Move Aux A down, causing rotation - const delta = { x: 0, y: -100 }; - - const result = testApplyGroupMovement(elements, selection, delta); - - // Aux A should move down - expect((result.get(1) as AuxViewElement).x).toBe(100); - expect((result.get(1) as AuxViewElement).y).toBe(200); - - // Link arc should be adjusted for the rotation - // Original line: (100, 100) -> (200, 100), angle = 0 - // New line: (100, 200) -> (200, 100), angle = atan2(100-200, 200-100) = atan2(-100, 100) = -45 degrees - // Angle difference = 0 - (-45) = 45 degrees - // newArc = originalArc - angleDiff = 0 - 45 = -45 degrees - const newLink = result.get(3) as LinkViewElement; - // Arc should have been adjusted to preserve curve shape - expect(Math.abs(newLink.arc - -45)).toBeLessThan(1); - }); - - it('should not double-adjust arc when link is selected with one endpoint', () => { - // This test verifies that when a link is selected along with one of its endpoints, - // the arc is only adjusted once (not twice from two separate passes). - // The expected arc adjustment is -45 degrees, not -90 degrees (which would be double). - const auxA = makeAux(1, 100, 100); - const auxB = makeAux(2, 200, 100); - const link = makeLink(3, 1, 2, 0); - - const elements = new Map([ - [1, auxA], - [2, auxB], - [3, link], - ]); - - // Select Aux A and the link (but not Aux B) - const selection = new Set([1, 3]); - const delta = { x: 0, y: -100 }; // Move down - - const result = testApplyGroupMovement(elements, selection, delta); - - const newLink = result.get(3) as LinkViewElement; - // Arc should be adjusted once (-45 degrees), not twice (-90 degrees) - // If double-adjusted, arc would be around -90 instead of -45 - expect(Math.abs(newLink.arc - -45)).toBeLessThan(1); - expect(Math.abs(newLink.arc - -90)).toBeGreaterThan(40); // Verify it's not -90 - }); - - it('should adjust arc based on drag position when only link is selected', () => { - // When a link is the only selected element, dragging it should change its - // curvature based on the arcPoint (drag position), not just preserve it. - const auxA = makeAux(1, 100, 100); - const auxB = makeAux(2, 200, 100); - const link = makeLink(3, 1, 2, 0); // Initially straight (arc = 0) - - const elements = new Map([ - [1, auxA], - [2, auxB], - [3, link], - ]); - - // Select only the link (no endpoints) - const selection = new Set([3]); - const delta = { x: 0, y: 0 }; // No actual movement - // Drag to a point above the link line to create an arc - const arcPoint = { x: 150, y: 50 }; - - const result = testApplyGroupMovement(elements, selection, delta, arcPoint); - - const newLink = result.get(3) as LinkViewElement; - // Arc should have changed from 0 to some non-zero value - // The exact value depends on takeoffθ calculation, but it should be non-zero - expect(newLink.arc).not.toBe(0); - // Dragging above the line should create a positive arc - expect(newLink.arc).toBeGreaterThan(0); - }); - - describe('arrayed elements', () => { - it('should correctly adjust arc when arrayed source moves with fixed endpoint', () => { - // This tests the bug where arrayed elements use visual centers (with ArrayedOffset) - // for old positions but raw cx/cy for new positions, causing arc drift. - // - // Setup: Arrayed Aux A -> Link -> Non-arrayed Aux B - // Move Aux A down (perpendicular movement causes rotation). - // The arc adjustment should be the same as for non-arrayed elements. - const auxA = makeAux(1, 100, 100, true); // Arrayed - const auxB = makeAux(2, 200, 100, false); // Not arrayed - const link = makeLink(3, 1, 2, 0); // Initially straight - - const elements = new Map([ - [1, auxA], - [2, auxB], - [3, link], - ]); - - // Select Aux A and link (not Aux B) - const selection = new Set([1, 3]); - // Move Aux A down by 100 - this causes a 45-degree rotation - const delta = { x: 0, y: -100 }; - - const result = testApplyGroupMovement(elements, selection, delta); - - // Aux A should move down - expect((result.get(1) as AuxViewElement).y).toBe(200); - - // Link arc should be adjusted by -45 degrees (same as non-arrayed case) - // This verifies the bug is fixed: without the fix, the arc would drift - // due to the ArrayedOffset (3px) mismatch between old and new position calculations. - const newLink = result.get(3) as LinkViewElement; - expect(Math.abs(newLink.arc - -45)).toBeLessThan(1); - }); - - it('should correctly adjust arc when arrayed target moves with fixed source', () => { - // Setup: Non-arrayed Aux A -> Link -> Arrayed Aux B - // Move Aux B down (perpendicular movement causes rotation). - // The arc adjustment should match the geometric rotation based on visual centers. - const auxA = makeAux(1, 100, 100, false); // Not arrayed - const auxB = makeAux(2, 200, 100, true); // Arrayed (visual center at 197, 97 due to ArrayedOffset) - const link = makeLink(3, 1, 2, 0); // Initially straight - - const elements = new Map([ - [1, auxA], - [2, auxB], - [3, link], - ]); - - // Select Aux B and link (not Aux A) - const selection = new Set([2, 3]); - // Move Aux B down by 100 (causes rotation) - const delta = { x: 0, y: -100 }; - - const result = testApplyGroupMovement(elements, selection, delta); - - // Aux B should move down (y increases by 100) - expect((result.get(2) as AuxViewElement).y).toBe(200); - - // Calculate expected arc adjustment based on visual centers: - // Old visual line: (100, 100) -> (197, 97), old angle ≈ atan2(-3, 97) - // New visual line: (100, 100) -> (197, 197), new angle = atan2(97, 97) = 45 degrees - // The arc should be adjusted to preserve the curve shape - const newLink = result.get(3) as LinkViewElement; - // Without the fix, the arc would be wrong because old angle was computed from visual - // centers but new angle was computed from raw positions. - // With the fix, both angles are computed from visual centers, so the geometry is consistent. - // We just verify the arc changed significantly (rotation occurred) - expect(Math.abs(newLink.arc)).toBeGreaterThan(30); - }); - - it('should correctly adjust arc when both endpoints are arrayed', () => { - // Setup: Arrayed Aux A -> Link -> Arrayed Aux B - // Move Aux A diagonally while Aux B stays fixed. - const auxA = makeAux(1, 100, 100, true); // Arrayed - const auxB = makeAux(2, 200, 100, true); // Arrayed - const link = makeLink(3, 1, 2, 0); // Initially straight - - const elements = new Map([ - [1, auxA], - [2, auxB], - [3, link], - ]); - - // Select Aux A and link (not Aux B) - const selection = new Set([1, 3]); - // Move Aux A down by 100 (causes 45-degree rotation) - const delta = { x: 0, y: -100 }; - - const result = testApplyGroupMovement(elements, selection, delta); - - // Link arc should be adjusted by -45 degrees (same as non-arrayed case) - const newLink = result.get(3) as LinkViewElement; - expect(Math.abs(newLink.arc - -45)).toBeLessThan(1); - }); - }); - - describe('multiple flow accumulation', () => { - it('should return all routed flows when multiple flows attach to a moved stock', () => { - // Stock with 3 outflows to 3 separate clouds: moving the stock should - // re-route all flows and return all of them (regression: quadratic spread - // in routeUnselectedFlows could silently lose elements). - const stock = makeStock(1, 200, 200, [], [10, 20, 30]); - const cloud1 = makeCloud(2, 10, 400, 200); - const cloud2 = makeCloud(3, 20, 200, 400); - const cloud3 = makeCloud(4, 30, 200, 50); - const flow1 = makeFlow(10, 300, 200, [ - { x: 200, y: 200, attachedToUid: 1 }, - { x: 400, y: 200, attachedToUid: 2 }, - ]); - const flow2 = makeFlow(20, 200, 300, [ - { x: 200, y: 200, attachedToUid: 1 }, - { x: 200, y: 400, attachedToUid: 3 }, - ]); - const flow3 = makeFlow(30, 200, 125, [ - { x: 200, y: 200, attachedToUid: 1 }, - { x: 200, y: 50, attachedToUid: 4 }, - ]); - - const elements = new Map([ - [1, stock], - [2, cloud1], - [3, cloud2], - [4, cloud3], - [10, flow1], - [20, flow2], - [30, flow3], - ]); - - // Select only the stock - const selection = new Set([1]); - const delta = { x: -50, y: -30 }; - - const result = testApplyGroupMovement(elements, selection, delta); - - // All 3 flows should be present in the result with updated routing - expect(result.has(10)).toBe(true); - expect(result.has(20)).toBe(true); - expect(result.has(30)).toBe(true); - - // Verify stock moved - const movedStock = result.get(1) as StockViewElement; - expect(movedStock.x).toBe(250); - expect(movedStock.y).toBe(230); - - // Each flow's source point should be attached to the stock - for (const flowUid of [10, 20, 30]) { - const flow = result.get(flowUid) as FlowViewElement; - expect(flow).toBeDefined(); - expect(flow.points.length).toBeGreaterThanOrEqual(2); - const sourcePoint = flow.points[0]; - expect(sourcePoint.attachedToUid).toBe(1); - } - }); - - it('should return all routed flows when both-ends-selected flows are accumulated', () => { - // Multiple flows where both source and sink are in the selection - const stock1 = makeStock(1, 100, 100, [], [10, 20]); - const stock2 = makeStock(2, 300, 100, [10], []); - const stock3 = makeStock(3, 100, 300, [20], []); - const flow1 = makeFlow(10, 200, 100, [ - { x: 100, y: 100, attachedToUid: 1 }, - { x: 300, y: 100, attachedToUid: 2 }, - ]); - const flow2 = makeFlow(20, 100, 200, [ - { x: 100, y: 100, attachedToUid: 1 }, - { x: 100, y: 300, attachedToUid: 3 }, - ]); - - const elements = new Map([ - [1, stock1], - [2, stock2], - [3, stock3], - [10, flow1], - [20, flow2], - ]); - - // Select all stocks and flows - const selection = new Set([1, 2, 3, 10, 20]); - const delta = { x: -10, y: -10 }; - - const result = testApplyGroupMovement(elements, selection, delta); - - // Both flows should be in the result - expect(result.has(10)).toBe(true); - expect(result.has(20)).toBe(true); - - // Verify uniform translation for both-ends-selected flows - const movedFlow1 = result.get(10) as FlowViewElement; - const movedFlow2 = result.get(20) as FlowViewElement; - expect(movedFlow1.x).toBe(210); - expect(movedFlow1.y).toBe(110); - expect(movedFlow2.x).toBe(110); - expect(movedFlow2.y).toBe(210); - }); - }); - - describe('View reconstruction pattern (handleSelectionMove)', () => { - // This tests the exact pattern used by Editor.handleSelectionMove to - // reconstruct the view element array from applyGroupMovement results. - // The pattern is: view.elements.map(el => updatedElements.get(el.uid) ?? el) - // This must produce an array where selected elements have new positions - // and unselected elements retain their original positions. - - it('should produce a complete element array with moved positions for selected elements', () => { - const aux1 = makeAux(1, 100, 100); - const aux2 = makeAux(2, 200, 200); - const aux3 = makeAux(3, 300, 300); - const viewElements: readonly ViewElement[] = [aux1, aux2, aux3]; - - const selection = new Set([1, 3]); - const delta = { x: -10, y: -20 }; - - const { updatedElements } = applyGroupMovement({ - elements: viewElements, - selection, - delta, - }); - - // Reconstruct the view array using the same pattern as handleSelectionMove - const elements = viewElements.map((el) => updatedElements.get(el.uid) ?? el); - - expect(elements).toHaveLength(3); - - // Selected elements should have moved positions - const moved1 = elements[0] as AuxViewElement; - expect(moved1.uid).toBe(1); - expect(moved1.x).toBe(110); - expect(moved1.y).toBe(120); - - const moved3 = elements[2] as AuxViewElement; - expect(moved3.uid).toBe(3); - expect(moved3.x).toBe(310); - expect(moved3.y).toBe(320); - - // Unselected element should retain original position - const unmoved2 = elements[1] as AuxViewElement; - expect(unmoved2.uid).toBe(2); - expect(unmoved2.x).toBe(200); - expect(unmoved2.y).toBe(200); - }); - - it('should produce correct positions for stock with attached flows', () => { - const stock = makeStock(1, 100, 100, [2], []); - const cloud = makeCloud(3, 2, 50, 100); - const flow = makeFlow(2, 75, 100, [ - { x: 50, y: 100, attachedToUid: 3 }, - { x: 100 - StockWidth / 2, y: 100, attachedToUid: 1 }, - ]); - const aux = makeAux(4, 300, 300); - const viewElements: readonly ViewElement[] = [stock, flow, cloud, aux]; - - // Select only the stock -- flow endpoints attached to it should adjust - const selection = new Set([1]); - const delta = { x: -20, y: -10 }; - - const { updatedElements } = applyGroupMovement({ - elements: viewElements, - selection, - delta, - }); - - const elements = viewElements.map((el) => updatedElements.get(el.uid) ?? el); - - expect(elements).toHaveLength(4); - - // Stock should have moved - const movedStock = elements[0] as StockViewElement; - expect(movedStock.x).toBe(120); - expect(movedStock.y).toBe(110); - - // Unselected aux should not have moved - const unmovedAux = elements[3] as AuxViewElement; - expect(unmovedAux.x).toBe(300); - expect(unmovedAux.y).toBe(300); - }); - }); -}); diff --git a/src/diagram/tests/hosted-web-editor-save-surface.test.tsx b/src/diagram/tests/hosted-web-editor-save-surface.test.tsx index d669665a3..de4079e8d 100644 --- a/src/diagram/tests/hosted-web-editor-save-surface.test.tsx +++ b/src/diagram/tests/hosted-web-editor-save-surface.test.tsx @@ -264,12 +264,12 @@ describe('HostedWebEditor save-failure surface', () => { // The end-to-end #958 scenario, driving a REAL ProjectController against // the shell's onSave (wired exactly as Editor.tsx's makeController does). // The session expires at server version 5, the user keeps editing (>100 - // content edits -- enough that the fractional render-cache key crosses the - // next integer), then re-authenticates. The retry save must carry 5 -- the - // version the server actually holds -- not a locally-drifted 6, which - // would bogus-409 into the dead-end conflict banner (defeating the #928 - // session-expiry rescue) or, if another session had committed 6, silently - // overwrite that session's save. + // unsaved content edits, each advancing the render-cache key), then + // re-authenticates. The retry save must carry 5 -- the version the server + // actually holds -- not a locally-drifted value, which would bogus-409 + // into the dead-end conflict banner (defeating the #928 session-expiry + // rescue) or, on a version collision, silently overwrite another + // session's save. let signedIn = false; let serverVersion = 5; const postVersions: number[] = []; @@ -306,22 +306,25 @@ describe('HostedWebEditor save-failure surface', () => { await controller.openInitialProject(); }); + // A content edit: the fake engine serializes fresh bytes after each patch, + // so every landed edit records history and requests a save. + const edit = (): Promise => + controller.enqueueModelEdit({ label: 'edit', buildPatch: () => ({ models: [] }) }); + // The signed-out stretch: every autosave 401s while the edits pile up. await act(async () => { for (let i = 0; i < 110; i++) { - await controller.updateProject(new Uint8Array([1, i])); + await edit(); } - await new Promise((resolve) => setTimeout(resolve, 0)); - await new Promise((resolve) => setTimeout(resolve, 0)); + await controller.whenIdle(); }); expect(screen.getByRole('alert').textContent).toMatch(/session expired/i); // Re-auth, then one more edit triggers the retry save. signedIn = true; await act(async () => { - await controller.updateProject(new Uint8Array([2, 0])); - await new Promise((resolve) => setTimeout(resolve, 0)); - await new Promise((resolve) => setTimeout(resolve, 0)); + await edit(); + await controller.whenIdle(); }); expect(postVersions[postVersions.length - 1]).toBe(5); diff --git a/src/diagram/tests/merge-live-view.test.ts b/src/diagram/tests/merge-live-view.test.ts deleted file mode 100644 index 2ac7dcf0a..000000000 --- a/src/diagram/tests/merge-live-view.test.ts +++ /dev/null @@ -1,178 +0,0 @@ -// Copyright 2026 The Simlin Authors. All rights reserved. -// Use of this source code is governed by the Apache License, -// Version 2.0, that can be found in the LICENSE file. - -import { describe, it, expect, beforeAll } from '@rstest/core'; - -import * as fs from 'fs'; -import * as path from 'path'; - -import { Project as EngineProject, configureWasm, ready, resetWasm } from '@simlin/engine'; -import { Project, projectFromJson, type StockFlowView } from '@simlin/core/datamodel'; -import { mapSet } from '@simlin/core/common'; - -import { preserveLiveView } from '../merge-live-view'; - -async function loadWasm(): Promise { - const wasmPath = path.join(__dirname, '..', '..', 'engine', 'core', 'libsimlin.wasm'); - const wasmBuffer = fs.readFileSync(wasmPath); - await resetWasm(); - configureWasm({ source: wasmBuffer }); - await ready(); -} - -function loadTeacup(): Uint8Array { - const xmilePath = path.join(__dirname, '..', '..', 'pysimlin', 'tests', 'fixtures', 'teacup.stmx'); - if (!fs.existsSync(xmilePath)) { - throw new Error('Required test XMILE model not found: ' + xmilePath); - } - return fs.readFileSync(xmilePath); -} - -async function loadTeacupProject(): Promise { - const project = await EngineProject.open(loadTeacup()); - const json = JSON.parse(await project.serializeJson(undefined, true)); - await project.dispose(); - return projectFromJson(json); -} - -function withView(project: Project, modelName: string, view: StockFlowView): Project { - const model = project.models.get(modelName); - if (!model) { - throw new Error(`model ${modelName} not in project`); - } - const updatedModel = { ...model, views: [view, ...model.views.slice(1)] }; - return { ...project, models: mapSet(project.models, modelName, updatedModel) }; -} - -describe('preserveLiveView', () => { - beforeAll(loadWasm); - - it('returns incoming unchanged when live is undefined', async () => { - const incoming = await loadTeacupProject(); - const result = preserveLiveView(incoming, undefined, 'main'); - expect(result).toBe(incoming); - }); - - it('returns incoming unchanged when modelName is missing in live', async () => { - const incoming = await loadTeacupProject(); - const live = { ...incoming, models: new Map() }; - const result = preserveLiveView(incoming, live, 'main'); - expect(result).toBe(incoming); - }); - - it('returns incoming unchanged when modelName is missing in incoming', async () => { - const live = await loadTeacupProject(); - const incoming = { ...live, models: new Map() }; - const result = preserveLiveView(incoming, live, 'main'); - expect(result).toBe(incoming); - }); - - // The core race: a stale incoming view (from a serialize call that the - // engine completed before the user's most recent pan applied) would otherwise - // overwrite the live view -- snap-back -- when updateProject commits. - it('preserves the live viewBox and zoom over an incoming engine view', async () => { - const baseline = await loadTeacupProject(); - const baselineView = baseline.models.get('main')!.views[0]; - - // Engine snapshot: still at the original viewBox. - const incoming = baseline; - - // Live (state.activeProject): user has panned and zoomed past the engine. - const optimisticView: StockFlowView = { - ...baselineView, - viewBox: { x: -123, y: -456, width: 800, height: 600 }, - zoom: 1.75, - }; - const live = withView(baseline, 'main', optimisticView); - - const merged = preserveLiveView(incoming, live, 'main'); - - const mergedView = merged.models.get('main')!.views[0]; - expect(mergedView.viewBox).toEqual({ x: -123, y: -456, width: 800, height: 600 }); - expect(mergedView.zoom).toBe(1.75); - }); - - // Element drags use the same code path as pan -- the live view's element - // positions must win over the engine's snapshot positions. - it('preserves live element positions over an incoming engine view', async () => { - const baseline = await loadTeacupProject(); - const baselineView = baseline.models.get('main')!.views[0]; - - // Link elements carry x/y as NaN by design; skip those when shifting. - const hasFiniteXY = (el: { x?: unknown; y?: unknown }): boolean => - typeof el.x === 'number' && Number.isFinite(el.x) && typeof el.y === 'number' && Number.isFinite(el.y); - const movedElements = baselineView.elements.map((el) => - hasFiniteXY(el as { x?: unknown; y?: unknown }) - ? { ...el, x: (el as { x: number }).x + 200, y: (el as { y: number }).y - 75 } - : el, - ); - const optimisticView: StockFlowView = { ...baselineView, elements: movedElements }; - const live = withView(baseline, 'main', optimisticView); - - const merged = preserveLiveView(baseline, live, 'main'); - - const mergedView = merged.models.get('main')!.views[0]; - let anyChecked = false; - for (const el of mergedView.elements) { - const original = baselineView.elements.find((b) => b.uid === el.uid); - if (original && hasFiniteXY(original) && hasFiniteXY(el)) { - expect((el as { x: number }).x).toBeCloseTo((original as { x: number }).x + 200); - expect((el as { y: number }).y).toBeCloseTo((original as { y: number }).y - 75); - anyChecked = true; - } - } - expect(anyChecked).toBe(true); - }); - - // A live view built via setView during/after a structural change can carry - // var: undefined or stale Variable refs. Re-linking against the incoming - // (latest) variables keeps the diagram consistent without losing positions. - it('relinks element var refs against the incoming model variables', async () => { - const baseline = await loadTeacupProject(); - const baselineView = baseline.models.get('main')!.views[0]; - - const elementsWithoutVarRefs = baselineView.elements.map((el) => { - if (el.type === 'stock' || el.type === 'flow' || el.type === 'aux' || el.type === 'module') { - return { ...el, var: undefined }; - } - return el; - }); - const optimisticView: StockFlowView = { ...baselineView, elements: elementsWithoutVarRefs }; - const live = withView(baseline, 'main', optimisticView); - - const merged = preserveLiveView(baseline, live, 'main'); - - const incomingVars = baseline.models.get('main')!.variables; - const mergedView = merged.models.get('main')!.views[0]; - let anyRelinked = false; - for (const el of mergedView.elements) { - if (el.type === 'stock' || el.type === 'flow' || el.type === 'aux') { - if (incomingVars.has(el.ident)) { - expect(el.var).toBeDefined(); - anyRelinked = true; - } - } - } - expect(anyRelinked).toBe(true); - }); - - // The whole point of the merge is that the engine's structural changes - // (new variables, edited equations) survive while the live view wins. - it('keeps incoming variables -- only the live view is preserved', async () => { - const baseline = await loadTeacupProject(); - const baselineModel = baseline.models.get('main')!; - - // Construct a "live" project missing some variables, as could happen if - // an optimistic setView propagated before a variable-creation patch did. - const reducedVars = new Map(baselineModel.variables); - const removedKey = [...reducedVars.keys()][0]; - reducedVars.delete(removedKey); - const liveModel = { ...baselineModel, variables: reducedVars }; - const live: Project = { ...baseline, models: mapSet(baseline.models, 'main', liveModel) }; - - const merged = preserveLiveView(baseline, live, 'main'); - - expect(merged.models.get('main')!.variables.has(removedKey)).toBe(true); - }); -}); diff --git a/src/diagram/tests/module-details.test.tsx b/src/diagram/tests/module-details.test.tsx index f43bf4eeb..b75e7da33 100644 --- a/src/diagram/tests/module-details.test.tsx +++ b/src/diagram/tests/module-details.test.tsx @@ -2,10 +2,12 @@ // Use of this source code is governed by the Apache License, // Version 2.0, that can be found in the LICENSE file. -import { describe, test, expect, rs } from '@rstest/core'; +import { describe, test, expect, beforeAll, rs } from '@rstest/core'; import * as React from 'react'; -import { render, fireEvent, screen } from '@testing-library/react'; +import { act, render, fireEvent, screen } from '@testing-library/react'; +import { Editor as SlateEditor, Transforms } from 'slate'; +import { ELEMENT_TO_NODE } from 'slate-dom'; import { ModuleDetails } from '../ModuleDetails'; import type { Module, Aux, Stock, Model, Project, ViewElement } from '@simlin/core/datamodel'; @@ -135,6 +137,198 @@ function defaultCallbacks() { // -- Tests -- +describe('ModuleDetails drafts', () => { + beforeAll(() => { + // jsdom lacks isContentEditable and Range geometry, which slate-react reads. + Object.defineProperty(HTMLElement.prototype, 'isContentEditable', { + configurable: true, + get(this: HTMLElement): boolean { + return this.getAttribute('contenteditable') === 'true'; + }, + }); + if (!('getBoundingClientRect' in Range.prototype)) { + const zero = () => + ({ x: 0, y: 0, width: 0, height: 0, top: 0, left: 0, right: 0, bottom: 0, toJSON() {} }) as DOMRect; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (Range.prototype as any).getBoundingClientRect = zero; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (Range.prototype as any).getClientRects = () => + ({ length: 0, item: () => null, [Symbol.iterator]: function* () {} }) as unknown as DOMRectList; + } + }); + + // The units field, then the documentation field. + function slateEditors(container: HTMLElement): SlateEditor[] { + return Array.from(container.querySelectorAll('[data-slate-editor="true"]')).map( + (el) => ELEMENT_TO_NODE.get(el as HTMLElement) as unknown as SlateEditor, + ); + } + + async function append(editor: SlateEditor, text: string): Promise { + await act(async () => { + Transforms.insertText(editor, text, { at: SlateEditor.end(editor, []) }); + editor.onChange(); + await Promise.resolve(); + }); + } + + test('reports a draft, and a flush submits only the field holding it; a submission that does not land can be submitted again', async () => { + const variable = makeModule('hares_mod', 'hares', { units: 'people', documentation: 'docs' }); + const project = makeProject([makeModel('main', [variable]), makeModel('hares', [makeAux('population')])]); + const callbacks = defaultCallbacks(); + let landed = false; + callbacks.onUnitsDocsChange.mockImplementation(async () => landed); + const draftStates: boolean[] = []; + let flush: (() => boolean) | undefined; + const { container } = render( + { + flush = f; + return () => {}; + }} + onDraftStateChange={(hasDraft) => draftStates.push(hasDraft)} + {...callbacks} + />, + ); + const [units] = slateEditors(container); + await append(units, ' per year'); + expect(draftStates[draftStates.length - 1]).toBe(true); + + let submitted = false; + await act(async () => { + submitted = flush!(); + await Promise.resolve(); + }); + expect(submitted).toBe(true); + // The documentation field was never touched, so it is not echoed. + expect(callbacks.onUnitsDocsChange).toHaveBeenLastCalledWith('hares_mod', 'people per year', undefined); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + // The submission settled as not landed, so the text is a draft again. + expect(draftStates[draftStates.length - 1]).toBe(true); + landed = true; + await act(async () => { + submitted = flush!(); + await Promise.resolve(); + }); + expect(submitted).toBe(true); + expect(callbacks.onUnitsDocsChange).toHaveBeenCalledTimes(2); + }); + + function deferred(): { promise: Promise; resolve: (ok: boolean) => void } { + let resolve!: (ok: boolean) => void; + const promise = new Promise((r) => { + resolve = r; + }); + return { promise, resolve }; + } + + async function removeLast(editor: SlateEditor, count: number): Promise { + await act(async () => { + Transforms.delete(editor, { at: SlateEditor.end(editor, []), distance: count, unit: 'character', reverse: true }); + editor.onChange(); + await Promise.resolve(); + }); + } + + function mountPanel(variable: Module, onUnitsDocsChange: (...args: unknown[]) => Promise) { + const project = makeProject([makeModel('main', [variable]), makeModel('hares', [makeAux('population')])]); + const callbacks = { ...defaultCallbacks(), onUnitsDocsChange: rs.fn(onUnitsDocsChange) }; + const draftStates: boolean[] = []; + let flush: (() => boolean) | undefined; + const props = { + viewElement: makeViewElement('hares_mod'), + project, + currentModelName: 'main', + registerDraftFlush: (f: () => boolean) => { + flush = f; + return () => {}; + }, + onDraftStateChange: (hasDraft: boolean) => draftStates.push(hasDraft), + ...callbacks, + }; + const result = render(); + return { + result, + callbacks, + draftStates, + hasDraft: () => draftStates[draftStates.length - 1], + flush: async () => { + let submitted = false; + await act(async () => { + submitted = flush!(); + await Promise.resolve(); + }); + return submitted; + }, + rerender: (next: Module) => result.rerender(), + }; + } + + test("a field changed back to its seeded text after a submission is a draft (the submission is the field's base)", async () => { + const pending = deferred(); + const panel = mountPanel(makeModule('hares_mod', 'hares', { units: 'people' }), () => pending.promise); + const [units] = slateEditors(panel.result.container); + await append(units, ' per year'); + expect(await panel.flush()).toBe(true); + expect(panel.hasDraft()).toBe(false); + // While the edit is in flight, back to what the field was seeded with. + await removeLast(units, ' per year'.length); + expect(panel.hasDraft()).toBe(true); + expect(await panel.flush()).toBe(true); + expect(panel.callbacks.onUnitsDocsChange).toHaveBeenLastCalledWith('hares_mod', 'people', undefined); + }); + + test('a failed submission falls back to the committed text, not the seeded one', async () => { + // Seeded '', then 'X' landed; the user clears the field and that fails. + let landed = true; + const panel = mountPanel(makeModule('hares_mod', 'hares', { units: '' }), async () => landed); + const [units] = slateEditors(panel.result.container); + await append(units, 'X'); + expect(await panel.flush()).toBe(true); + await act(async () => { + panel.rerender(makeModule('hares_mod', 'hares', { units: 'X' })); + await Promise.resolve(); + }); + expect(panel.hasDraft()).toBe(false); + await removeLast(units, 1); + landed = false; + expect(await panel.flush()).toBe(true); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + // Committed is still 'X', so the cleared field stays a draft to retry. + expect(panel.hasDraft()).toBe(true); + }); + + test("a submission failing after a newer one for the same field leaves the newer one as the field's base", async () => { + const first = deferred(); + const second = deferred(); + const outcomes = [first.promise, second.promise]; + const panel = mountPanel(makeModule('hares_mod', 'hares', { units: 'people' }), () => outcomes.shift()!); + const [units] = slateEditors(panel.result.container); + await append(units, ' per year'); + expect(await panel.flush()).toBe(true); + await append(units, '!'); + expect(await panel.flush()).toBe(true); + expect(panel.hasDraft()).toBe(false); + await act(async () => { + first.resolve(false); + await Promise.resolve(); + await Promise.resolve(); + }); + // The newer submission is still pending and is still the base: no draft. + expect(panel.hasDraft()).toBe(false); + }); +}); + describe('ModuleDetails', () => { // AC2.1: Selecting a module shows ModuleDetails panel describe('rendering', () => { diff --git a/src/diagram/tests/plan-delete.test.ts b/src/diagram/tests/plan-delete.test.ts new file mode 100644 index 000000000..724290360 --- /dev/null +++ b/src/diagram/tests/plan-delete.test.ts @@ -0,0 +1,172 @@ +// Copyright 2026 The Simlin Authors. All rights reserved. +// Use of this source code is governed by the Apache License, +// Version 2.0, that can be found in the LICENSE file. + +// planDelete, one row per element kind a selection can hold (stock, flow, aux, +// module, link, alias, cloud, group), plus the cascades: clouds of removed +// flows, aliases of removed elements, links touching anything removed, and +// endpoints of surviving flows on removed stocks. The view is loaded through +// the production loader (projectFromJson), so element shapes are production's. + +import { describe, it, expect } from '@rstest/core'; + +import { projectFromJson, type StockFlowView, type UID, type ViewElement } from '@simlin/core/datamodel'; +import type { JsonProject } from '@simlin/engine'; + +import { planDelete } from '../plan-delete'; +import { checkReferentialIntegrity, formatViewViolations } from './support/view-invariants'; + +function view(): StockFlowView { + const json = { + name: 'plan-delete', + simSpecs: { startTime: 0, endTime: 1, dt: '1' }, + models: [ + { + name: 'main', + stocks: [ + { name: 'A', initialEquation: '1', inflows: [], outflows: ['f'] }, + { name: 'B', initialEquation: '1', inflows: ['f', 'loop'], outflows: ['loop'] }, + ], + flows: [ + { name: 'f', equation: '1' }, + { name: 'loop', equation: '1' }, + { name: 'k', equation: '1' }, + ], + auxiliaries: [{ name: 'x', equation: '1' }], + modules: [{ name: 'm', modelName: 'main' }], + views: [ + { + elements: [ + { type: 'stock', uid: 1, name: 'A', x: 100, y: 100 }, + { type: 'stock', uid: 2, name: 'B', x: 300, y: 100 }, + { + type: 'flow', + uid: 3, + name: 'f', + x: 200, + y: 100, + points: [ + { x: 122.5, y: 100, attachedToUid: 1 }, + { x: 277.5, y: 100, attachedToUid: 2 }, + ], + }, + { + type: 'flow', + uid: 4, + name: 'loop', + x: 300, + y: 50, + points: [ + { x: 300, y: 82.5, attachedToUid: 2 }, + { x: 300, y: 40, attachedToUid: 2 }, + ], + }, + { + type: 'flow', + uid: 5, + name: 'k', + x: 100, + y: 300, + points: [ + { x: 0, y: 300, attachedToUid: 6 }, + { x: 200, y: 300, attachedToUid: 7 }, + ], + }, + { type: 'cloud', uid: 6, flowUid: 5, x: 0, y: 300 }, + { type: 'cloud', uid: 7, flowUid: 5, x: 200, y: 300 }, + { type: 'aux', uid: 8, name: 'x', x: 200, y: 20 }, + { type: 'module', uid: 9, name: 'm', x: 500, y: 20 }, + { type: 'link', uid: 10, fromUid: 1, toUid: 8 }, + { type: 'alias', uid: 11, aliasOfUid: 1, x: 100, y: 250 }, + { type: 'link', uid: 12, fromUid: 11, toUid: 5 }, + { type: 'link', uid: 13, fromUid: 8, toUid: 9 }, + { type: 'group', uid: 14, name: 'g', x: 0, y: 0, width: 50, height: 50 }, + ], + }, + ], + }, + ], + } as unknown as JsonProject; + return projectFromJson(json).models.get('main')!.views[0]; +} + +function uids(v: StockFlowView): UID[] { + return v.elements.map((el) => el.uid).sort((a, b) => a - b); +} + +function byUid(v: StockFlowView, uid: UID): ViewElement | undefined { + return v.elements.find((el) => el.uid === uid); +} + +function endpoints(v: StockFlowView, flowUid: UID): Array { + const flow = byUid(v, flowUid); + if (flow?.type !== 'flow') { + throw new Error(`no flow ${flowUid}`); + } + return [flow.points[0].attachedToUid, flow.points[flow.points.length - 1].attachedToUid]; +} + +describe('planDelete', () => { + it('stock: removes it, its alias, links touching either, and clouds the endpoints on it', () => { + const next = planDelete(view(), new Set([1])); + // 1 stock, 10 link from it, 11 its alias, 12 link from the alias. + expect(uids(next)).toEqual([2, 3, 4, 5, 6, 7, 8, 9, 13, 14, 15]); + // f's source was on A: a new cloud at the endpoint, owned by f. + expect(endpoints(next, 3)).toEqual([15, 2]); + expect(byUid(next, 15)).toMatchObject({ type: 'cloud', flowUid: 3, x: 122.5, y: 100 }); + expect(next.nextUid).toBe(16); + expect(formatViewViolations(checkReferentialIntegrity(next))).toBe(''); + }); + + it('stock a flow both starts and ends on: two clouds, one per endpoint', () => { + const next = planDelete(view(), new Set([2])); + expect(endpoints(next, 4)).toEqual([16, 17]); + expect(endpoints(next, 3)).toEqual([1, 15]); + expect(formatViewViolations(checkReferentialIntegrity(next))).toBe(''); + }); + + it('flow: removes it and its clouds, and the links touching it', () => { + const next = planDelete(view(), new Set([5])); + expect(uids(next)).toEqual([1, 2, 3, 4, 8, 9, 10, 11, 13, 14]); + expect(next.nextUid).toBe(15); + expect(formatViewViolations(checkReferentialIntegrity(next))).toBe(''); + }); + + it('aux and module: removes them and the links touching them', () => { + expect(uids(planDelete(view(), new Set([8])))).toEqual([1, 2, 3, 4, 5, 6, 7, 9, 11, 12, 14]); + expect(uids(planDelete(view(), new Set([9])))).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 10, 11, 12, 14]); + }); + + it('link: removes only the link', () => { + expect(uids(planDelete(view(), new Set([10])))).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 11, 12, 13, 14]); + }); + + it('alias: removes the alias and links touching it, never the aliased element', () => { + expect(uids(planDelete(view(), new Set([11])))).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 13, 14]); + }); + + it('cloud whose flow survives: ignored, nothing changes', () => { + const before = view(); + const next = planDelete(before, new Set([6])); + expect(uids(next)).toEqual(uids(before)); + expect(next.nextUid).toBe(before.nextUid); + }); + + it('cloud together with its flow: removed with the flow', () => { + expect(uids(planDelete(view(), new Set([5, 6])))).toEqual([1, 2, 3, 4, 8, 9, 10, 11, 13, 14]); + }); + + it('group: removes only the group', () => { + expect(uids(planDelete(view(), new Set([14])))).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13]); + }); + + it('a selected uid the view does not contain is ignored', () => { + const before = view(); + expect(uids(planDelete(before, new Set([999])))).toEqual(uids(before)); + }); + + it('empty selection returns an equal view', () => { + const before = view(); + expect(planDelete(before, new Set())).toEqual(before); + }); +}); diff --git a/src/diagram/tests/pointer-utils.test.ts b/src/diagram/tests/pointer-utils.test.ts deleted file mode 100644 index c53e4d565..000000000 --- a/src/diagram/tests/pointer-utils.test.ts +++ /dev/null @@ -1,92 +0,0 @@ -// Copyright 2026 The Simlin Authors. All rights reserved. -// Use of this source code is governed by the Apache License, -// Version 2.0, that can be found in the LICENSE file. - -import { describe, it, expect } from '@rstest/core'; - -import { ClickDragThresholdPx, isDragMovement, shouldShowVariableDetails } from '../drawing/pointer-utils'; - -describe('isDragMovement', () => { - it('is false for an undefined delta (no pointer move at all)', () => { - expect(isDragMovement(undefined, 1)).toBe(false); - }); - - it('is false for a zero delta', () => { - expect(isDragMovement({ x: 0, y: 0 }, 1)).toBe(false); - }); - - it('is false for sub-threshold jitter at zoom 1', () => { - // a click commonly wobbles a pixel or two -- not a drag - expect(isDragMovement({ x: 1, y: 1 }, 1)).toBe(false); - expect(isDragMovement({ x: ClickDragThresholdPx - 0.5, y: 0 }, 1)).toBe(false); - }); - - it('is true once the move reaches the threshold at zoom 1', () => { - expect(isDragMovement({ x: ClickDragThresholdPx, y: 0 }, 1)).toBe(true); - expect(isDragMovement({ x: 50, y: 0 }, 1)).toBe(true); - }); - - it('measures the threshold in screen pixels, not model units', () => { - // moveDelta is in model coords (screen px / zoom). At 4x zoom a 2-unit - // model delta is 8 screen px -- a real drag. The same 2-unit delta at - // 0.5x zoom is only 1 screen px -- jitter. - const delta = { x: 2, y: 0 }; - expect(isDragMovement(delta, 4)).toBe(true); - expect(isDragMovement(delta, 0.5)).toBe(false); - }); - - it('uses Euclidean distance, not per-axis', () => { - // each axis is below threshold but the combined move is not - const half = ClickDragThresholdPx / Math.SQRT2 + 0.1; - expect(isDragMovement({ x: half, y: half }, 1)).toBe(true); - }); -}); - -describe('shouldShowVariableDetails', () => { - it('returns true for a click on element body (no pointer move)', () => { - expect(shouldShowVariableDetails(true, undefined, 1, false, false, false)).toBe(true); - }); - - it('returns true when moveDelta is zero', () => { - expect(shouldShowVariableDetails(true, { x: 0, y: 0 }, 1, false, false, false)).toBe(true); - }); - - it('returns true when the move is only incidental click jitter', () => { - // regression: a stock click that wobbled a pixel used to leave the - // details panel closed (the deselect/reselect-fixes-it bug) - expect(shouldShowVariableDetails(true, { x: 1, y: 1 }, 1, false, false, false)).toBe(true); - expect(shouldShowVariableDetails(true, { x: 2, y: 0 }, 1, false, false, false)).toBe(true); - }); - - it('returns false when actually dragging an element', () => { - expect(shouldShowVariableDetails(true, { x: 50, y: 50 }, 1, false, false, false)).toBe(false); - }); - - it('returns false when a small model-coord move is a real drag at high zoom', () => { - expect(shouldShowVariableDetails(true, { x: 3, y: 0 }, 4, false, false, false)).toBe(false); - }); - - it('returns false when dragging an arrowhead', () => { - expect(shouldShowVariableDetails(true, { x: 40, y: 20 }, 1, true, false, false)).toBe(false); - }); - - it('returns false when dragging a source', () => { - expect(shouldShowVariableDetails(true, { x: 20, y: 40 }, 1, false, true, false)).toBe(false); - }); - - it('returns false when dragging a label', () => { - expect(shouldShowVariableDetails(true, undefined, 1, false, false, true)).toBe(false); - }); - - it('returns false when clicking on empty canvas', () => { - expect(shouldShowVariableDetails(false, undefined, 1, false, false, false)).toBe(false); - }); - - it('returns false when clicking on arrowhead without movement', () => { - expect(shouldShowVariableDetails(true, undefined, 1, true, false, false)).toBe(false); - }); - - it('returns false when clicking on source without movement', () => { - expect(shouldShowVariableDetails(true, undefined, 1, false, true, false)).toBe(false); - }); -}); diff --git a/src/diagram/tests/project-controller-connector-sync.test.ts b/src/diagram/tests/project-controller-connector-sync.test.ts index 60271d000..80f7362f7 100644 --- a/src/diagram/tests/project-controller-connector-sync.test.ts +++ b/src/diagram/tests/project-controller-connector-sync.test.ts @@ -2,33 +2,24 @@ // Use of this source code is governed by the Apache License, // Version 2.0, that can be found in the LICENSE file. -// Verifies ProjectController.attachConnectorErrors wiring: after a rebuild, the -// active model's variables carry connectorErrors derived from the engine's -// getIncomingLinks and the sketch connectors, and engine failures degrade -// gracefully. +// Connector drift on the rendered project: the controller fetches each +// target's equation dependencies from the engine (`getIncomingLinks`) in a +// maintenance item and computes `connectorErrors` at render time against the +// RENDERED view, so a connector drawn by a pending edit counts immediately. +// Engine failures degrade to no annotations. import { describe, it, expect } from '@rstest/core'; import type { LinkViewElement, StockFlowView, Variable } from '@simlin/core/datamodel'; import { ErrorCode } from '@simlin/core/datamodel'; -import { defined } from '@simlin/core/common'; import type { ErrorDetail } from '@simlin/engine'; import { SimlinErrorKind } from '@simlin/engine'; import { ProjectController } from '../project-controller'; -import { makeFakeEngine, makeControllerConfig } from './fake-engine'; +import { makeFakeEngine, makeControllerConfig, makeGate, type FakeEngineOptions } from './fake-engine'; -// Drain microtasks + macrotasks so fire-and-forget navigation refreshes settle. -async function flushTimers(): Promise { - for (let i = 0; i < 8; i++) { - await new Promise((resolve) => setTimeout(resolve, 0)); - } -} - -// A project with a main model that references child model 'child' via module -// 'm', and a child model with auxes ca (constant) and cb (= ca) laid out on its -// view WITHOUT a connector -- so cb has a missing-connector issue. Module uid=1; -// child aux uids ca=10, cb=11. +// A main model referencing child model 'child' via module 'm'; the child has +// auxes ca (constant) and cb (= ca) WITHOUT a connector, so cb misses one. function moduleProjectJson(): string { return JSON.stringify({ name: 'test', @@ -63,15 +54,14 @@ function moduleProjectJson(): string { }); } -function childVar(controller: ProjectController, ident: string): Variable | undefined { - return controller.getSnapshot().project?.models.get('child')?.variables.get(ident); -} - -const rect = { x: 0, y: 0, width: 1, height: 1 }; - -// A project with two auxes (a, b) and a view holding both plus optionally a -// connector a -> b. Auxiliary uids: a=1, b=2; link uid=3. -function projectJson(withConnector: boolean): string { +// Two auxes (a, b = a) and, optionally, a connector a -> b. uids a=1, b=2, link=3. +function projectJson( + withConnector: boolean, + auxiliaries = [ + { name: 'a', equation: '1' }, + { name: 'b', equation: 'a' }, + ], +): string { const elements: Array> = [ { type: 'aux', uid: 1, name: 'a', x: 0, y: 0 }, { type: 'aux', uid: 2, name: 'b', x: 100, y: 0 }, @@ -82,94 +72,53 @@ function projectJson(withConnector: boolean): string { return JSON.stringify({ name: 'test', simSpecs: { startTime: 0, endTime: 10, dt: '1' }, - models: [ - { - name: 'main', - stocks: [], - flows: [], - auxiliaries: [ - { name: 'a', equation: '1' }, - { name: 'b', equation: 'a' }, - ], - views: [{ elements }], - }, - ], + models: [{ name: 'main', stocks: [], flows: [], auxiliaries, views: [{ elements }] }], }); } -function mainVar(controller: ProjectController, ident: string): Variable | undefined { - return controller.getSnapshot().project?.models.get('main')?.variables.get(ident); +async function open(json: string, options: FakeEngineOptions): Promise { + const engine = makeFakeEngine({ json: () => json, ...options }); + const { config } = makeControllerConfig({ engine, format: 'json' }); + const controller = new ProjectController(config); + await controller.openInitialProject(); + await controller.whenIdle(); + return controller; } -function bConnectorErrors(controller: ProjectController) { - const project = controller.getSnapshot().project; - return project?.models.get('main')?.variables.get('b')?.connectorErrors; +function variable(controller: ProjectController, ident: string, modelName = 'main'): Variable | undefined { + return controller.getSnapshot().project?.models.get(modelName)?.variables.get(ident); } -describe('ProjectController connector-sync', () => { - it('attaches a missing-connector error when an equation dep has no connector', async () => { - const engine = makeFakeEngine({ - json: () => projectJson(false), - incomingLinks: { b: ['a'], a: [] }, - }); - const { config } = makeControllerConfig({ engine }); - const controller = new ProjectController(config); - - await controller.openInitialProject(); +const rect = { x: 0, y: 0, width: 1, height: 1 }; - expect(bConnectorErrors(controller)).toEqual([{ kind: 'missingConnector', ident: 'a', name: 'a' }]); +describe('ProjectController connector drift', () => { + it('flags a missing connector when an equation dependency has none drawn', async () => { + const controller = await open(projectJson(false), { incomingLinks: { b: ['a'], a: [] } }); + expect(variable(controller, 'b')?.connectorErrors).toEqual([{ kind: 'missingConnector', ident: 'a', name: 'a' }]); await controller.dispose(); }); - it('attaches no connector error when the connector matches the dependency', async () => { - const engine = makeFakeEngine({ - json: () => projectJson(true), - incomingLinks: { b: ['a'], a: [] }, - }); - const { config } = makeControllerConfig({ engine }); - const controller = new ProjectController(config); - - await controller.openInitialProject(); - - expect(bConnectorErrors(controller)).toBeUndefined(); + it('flags nothing when the connector matches the dependency', async () => { + const controller = await open(projectJson(true), { incomingLinks: { b: ['a'], a: [] } }); + expect(variable(controller, 'b')?.connectorErrors).toBeUndefined(); await controller.dispose(); }); - it('attaches a stale-connector error when a drawn connector is unused', async () => { - const engine = makeFakeEngine({ - json: () => projectJson(true), - // b's equation no longer references a, but the connector remains. - incomingLinks: { b: [], a: [] }, - }); - const { config } = makeControllerConfig({ engine }); - const controller = new ProjectController(config); - - await controller.openInitialProject(); - - expect(bConnectorErrors(controller)).toEqual([{ kind: 'staleConnector', ident: 'a', name: 'a' }]); + it('flags a stale connector the equation does not use', async () => { + const controller = await open(projectJson(true), { incomingLinks: { b: [], a: [] } }); + expect(variable(controller, 'b')?.connectorErrors).toEqual([{ kind: 'staleConnector', ident: 'a', name: 'a' }]); await controller.dispose(); }); - it('degrades gracefully (no connector errors) when getModel throws', async () => { - const engine = makeFakeEngine({ - json: () => projectJson(false), - incomingLinks: { b: ['a'] }, - getModelThrows: true, - }); - const { config } = makeControllerConfig({ engine }); - const controller = new ProjectController(config); - - await controller.openInitialProject(); - - expect(bConnectorErrors(controller)).toBeUndefined(); - // The project still opened successfully despite the getModel failure. + it('degrades to no annotations when getModel throws', async () => { + const controller = await open(projectJson(false), { incomingLinks: { b: ['a'] }, getModelThrows: true }); + expect(variable(controller, 'b')?.connectorErrors).toBeUndefined(); expect(controller.getSnapshot().project).toBeDefined(); await controller.dispose(); }); it('drops only the variable whose getIncomingLinks throws', async () => { - const engine = makeFakeEngine({ - json: () => projectJson(false), + const controller = await open(projectJson(false), { incomingLinks: (name: string) => { if (name === 'b') { throw new Error('transient'); @@ -177,35 +126,18 @@ describe('ProjectController connector-sync', () => { return []; }, }); - const { config } = makeControllerConfig({ engine }); - const controller = new ProjectController(config); - - await controller.openInitialProject(); - - // b's deps could not be fetched, so it is not checked -- no error attached. - expect(bConnectorErrors(controller)).toBeUndefined(); + expect(variable(controller, 'b')?.connectorErrors).toBeUndefined(); await controller.dispose(); }); - it('computes warnings against the rendered live view, not the stale engine snapshot', async () => { - // The fake engine always serializes the connector-less view, so after an - // optimistic view update that ADDS the a -> b connector the live view is - // newer than what the engine returns. attachConnectorErrors must run on the - // preserved live view, so b's dependency on a is satisfied and NOT flagged. - // Pre-fix (annotations computed inside updateVariableErrors on the engine - // snapshot) this asserted the missing warning was present -> this fails. - const engine = makeFakeEngine({ - json: () => projectJson(false), + it('a connector drawn by a pending edit satisfies the dependency at once (the rendered view, not committed)', async () => { + const gate = makeGate(); + const controller = await open(projectJson(false), { incomingLinks: { b: ['a'], a: [] }, + applyPatchGate: () => gate.wait(), }); - const { config } = makeControllerConfig({ engine }); - const controller = new ProjectController(config); - - await controller.openInitialProject(); - // Sanity: with no connector drawn yet, b is flagged missing. - expect(bConnectorErrors(controller)).toEqual([{ kind: 'missingConnector', ident: 'a', name: 'a' }]); - - const view = defined(controller.getView()); + expect(variable(controller, 'b')?.connectorErrors).toEqual([{ kind: 'missingConnector', ident: 'a', name: 'a' }]); + const view = controller.getView() as StockFlowView; const connector: LinkViewElement = { type: 'link', uid: view.nextUid, @@ -220,49 +152,31 @@ describe('ProjectController connector-sync', () => { isZeroRadius: false, ident: undefined, }; - const liveView: StockFlowView = { - ...view, - elements: [...view.elements, connector], - nextUid: view.nextUid + 1, - }; - await controller.updateView(liveView, { recordHistory: true }); - - expect(bConnectorErrors(controller)).toBeUndefined(); + void controller.enqueueViewEdit({ + label: 'link', + nextView: { ...view, elements: [...view.elements, connector], nextUid: view.nextUid + 1 }, + }); + expect(variable(controller, 'b')?.connectorErrors).toBeUndefined(); + gate.open(); + await controller.whenIdle(); await controller.dispose(); }); }); -describe('ProjectController connector-sync on module drill-in', () => { - it('annotates the newly-active child model on drill-in (missing connector)', async () => { - // Drill-in switches modelName without a rebuild, so before the fix the child - // model's variables never received connectorErrors on first navigation. - const engine = makeFakeEngine({ - json: () => moduleProjectJson(), - incomingLinks: { cb: ['ca'], ca: [] }, - }); - const { config } = makeControllerConfig({ engine }); - const controller = new ProjectController(config); - - await controller.openInitialProject(); - // Not drilled in yet: the child model carries no annotations. - expect(childVar(controller, 'cb')?.connectorErrors).toBeUndefined(); - +describe('ProjectController connector drift on module drill-in', () => { + it('annotates the newly active child model once its dependencies are fetched', async () => { + const controller = await open(moduleProjectJson(), { incomingLinks: { cb: ['ca'], ca: [] } }); + expect(variable(controller, 'cb', 'child')?.connectorErrors).toBeUndefined(); controller.drillIntoModule('m', 'child', new Set(), rect, 1); - await flushTimers(); - - expect(childVar(controller, 'cb')?.connectorErrors).toEqual([ + await controller.whenIdle(); + expect(variable(controller, 'cb', 'child')?.connectorErrors).toEqual([ { kind: 'missingConnector', ident: 'ca', name: 'ca' }, ]); await controller.dispose(); }); - it('also annotates equation-error dots on the child model on drill-in (deeper gap)', async () => { - // The same drill-in gap affected equation/unit error dots, not just connector - // warnings: updateVariableErrors is model-scoped and only ran on rebuild - // paths, so the child model's error dots were missing on first navigation - // even though the error PANEL re-scoped. - const engine = makeFakeEngine({ - json: () => moduleProjectJson(), + it('annotates equation-error dots on the child model on drill-in, synchronously', async () => { + const controller = await open(moduleProjectJson(), { incomingLinks: { cb: [], ca: [] }, errors: [ { @@ -275,53 +189,26 @@ describe('ProjectController connector-sync on module drill-in', () => { } as unknown as ErrorDetail, ], }); - const { config } = makeControllerConfig({ engine }); - const controller = new ProjectController(config); - - await controller.openInitialProject(); - expect(childVar(controller, 'cb')?.errors).toBeUndefined(); - + expect(variable(controller, 'cb', 'child')?.errors).toBeUndefined(); controller.drillIntoModule('m', 'child', new Set(), rect, 1); - await flushTimers(); - - expect(childVar(controller, 'cb')?.errors).toEqual([{ start: 0, end: 1, code: 1 }]); + expect(variable(controller, 'cb', 'child')?.errors).toEqual([{ start: 0, end: 1, code: 1 }]); await controller.dispose(); }); - it('does not clobber a superseding navigation (mid-flight guard)', async () => { - const engine = makeFakeEngine({ - json: () => moduleProjectJson(), - incomingLinks: { cb: ['ca'], ca: [] }, - }); - const { config } = makeControllerConfig({ engine }); - const controller = new ProjectController(config); - - await controller.openInitialProject(); + it("only the active model carries connector annotations: navigating back clears the child's", async () => { + const controller = await open(moduleProjectJson(), { incomingLinks: { cb: ['ca'], ca: [] } }); controller.drillIntoModule('m', 'child', new Set(), rect, 1); - await flushTimers(); - expect(childVar(controller, 'cb')?.connectorErrors).toEqual([ - { kind: 'missingConnector', ident: 'ca', name: 'ca' }, - ]); - - // Start a fresh annotation pass (captures modelName='child'); it suspends at - // its first engine await. navigateBack then runs synchronously, flipping - // modelName to 'main' and rebuilding (which resets the child's annotations). - // When the stale pass resumes its guard sees modelName/project moved and must - // NOT commit its child-scoped result over the post-navigation project. - const pending = controller.refreshActiveModelAnnotations(); + await controller.whenIdle(); + expect(variable(controller, 'cb', 'child')?.connectorErrors).toBeDefined(); controller.navigateBack(); - await flushTimers(); + await controller.whenIdle(); expect(controller.getModelName()).toBe('main'); - - await pending; - await flushTimers(); - - expect(childVar(controller, 'cb')?.connectorErrors).toBeUndefined(); + expect(variable(controller, 'cb', 'child')?.connectorErrors).toBeUndefined(); await controller.dispose(); }); }); -describe('ProjectController connector-sync skips errored-equation targets', () => { +describe('ProjectController connector drift skips errored-equation targets', () => { const eqnError = (variableName: string): ErrorDetail => ({ modelName: 'main', @@ -331,7 +218,6 @@ describe('ProjectController connector-sync skips errored-equation targets', () = startOffset: 0, endOffset: 1, }) as unknown as ErrorDetail; - const unitError = (variableName: string): ErrorDetail => ({ modelName: 'main', @@ -343,44 +229,19 @@ describe('ProjectController connector-sync skips errored-equation targets', () = }) as unknown as ErrorDetail; it('does not flag a stale connector on a variable with a fatal equation error', async () => { - // b has a parse error (engine reports no deps) and an inbound connector a->b. - // Without the skip that connector reads as stale; with it, only the real - // equation error is shown. - const engine = makeFakeEngine({ - json: () => projectJson(true), - incomingLinks: { a: [], b: [] }, - errors: [eqnError('b')], - }); - const { config } = makeControllerConfig({ engine }); - const controller = new ProjectController(config); - - await controller.openInitialProject(); - - expect(bConnectorErrors(controller)).toBeUndefined(); - expect(mainVar(controller, 'b')?.errors).toEqual([{ start: 0, end: 1, code: 1 }]); + const controller = await open(projectJson(true), { incomingLinks: { a: [], b: [] }, errors: [eqnError('b')] }); + expect(variable(controller, 'b')?.connectorErrors).toBeUndefined(); + expect(variable(controller, 'b')?.errors).toEqual([{ start: 0, end: 1, code: 1 }]); await controller.dispose(); }); it('does not flag a missing connector on a variable with a fatal equation error', async () => { - // b's (broken) equation would reference a, but the target is skipped, so no - // missing-connector warning is produced. - const engine = makeFakeEngine({ - json: () => projectJson(false), - incomingLinks: { a: [], b: ['a'] }, - errors: [eqnError('b')], - }); - const { config } = makeControllerConfig({ engine }); - const controller = new ProjectController(config); - - await controller.openInitialProject(); - - expect(bConnectorErrors(controller)).toBeUndefined(); + const controller = await open(projectJson(false), { incomingLinks: { a: [], b: ['a'] }, errors: [eqnError('b')] }); + expect(variable(controller, 'b')?.connectorErrors).toBeUndefined(); await controller.dispose(); }); it('still checks a healthy sibling variable in the same view', async () => { - // Model: a (const), b (errored, inbound connector a->b), c (healthy, uses a - // with no connector). b is skipped; c must still get its missing warning. const json = JSON.stringify({ name: 'test', simSpecs: { startTime: 0, endTime: 10, dt: '1' }, @@ -407,28 +268,13 @@ describe('ProjectController connector-sync skips errored-equation targets', () = }, ], }); - const engine = makeFakeEngine({ - json: () => json, - incomingLinks: { a: [], b: [], c: ['a'] }, - errors: [eqnError('b')], - }); - const { config } = makeControllerConfig({ engine }); - const controller = new ProjectController(config); - - await controller.openInitialProject(); - - expect(mainVar(controller, 'b')?.connectorErrors).toBeUndefined(); - expect(mainVar(controller, 'c')?.connectorErrors).toEqual([{ kind: 'missingConnector', ident: 'a', name: 'a' }]); + const controller = await open(json, { incomingLinks: { a: [], b: [], c: ['a'] }, errors: [eqnError('b')] }); + expect(variable(controller, 'b')?.connectorErrors).toBeUndefined(); + expect(variable(controller, 'c')?.connectorErrors).toEqual([{ kind: 'missingConnector', ident: 'a', name: 'a' }]); await controller.dispose(); }); it('suppresses all connector warnings in an all-empty starter model (hasNoEquations)', async () => { - // Brand-new sketch: two auxes with empty equations and a drawn connector - // a -> b. Every variable reports EmptyEquation, so updateVariableErrors takes - // its hasNoEquations branch and does NOT annotate variable.errors -- meaning - // the per-variable errors skip cannot catch this. Without the hasNoEquations - // early return, getIncomingLinks reports no deps and the connector reads as - // stale. Fails before the guard was added. const emptyErr = (variableName: string): ErrorDetail => ({ modelName: 'main', @@ -438,58 +284,19 @@ describe('ProjectController connector-sync skips errored-equation targets', () = startOffset: 0, endOffset: 0, }) as unknown as ErrorDetail; - const json = JSON.stringify({ - name: 'test', - simSpecs: { startTime: 0, endTime: 10, dt: '1' }, - models: [ - { - name: 'main', - stocks: [], - flows: [], - auxiliaries: [{ name: 'a' }, { name: 'b' }], - views: [ - { - elements: [ - { type: 'aux', uid: 1, name: 'a', x: 0, y: 0 }, - { type: 'aux', uid: 2, name: 'b', x: 100, y: 0 }, - { type: 'link', uid: 3, fromUid: 1, toUid: 2 }, - ], - }, - ], - }, - ], - }); - const engine = makeFakeEngine({ - json: () => json, + const controller = await open(projectJson(true, [{ name: 'a' }, { name: 'b' }] as never), { incomingLinks: { a: [], b: [] }, errors: [emptyErr('a'), emptyErr('b')], }); - const { config } = makeControllerConfig({ engine }); - const controller = new ProjectController(config); - - await controller.openInitialProject(); - - // Sanity: the starter-model flag is set, and nothing is flagged. expect(controller.getSnapshot().project?.hasNoEquations).toBe(true); - expect(mainVar(controller, 'a')?.connectorErrors).toBeUndefined(); - expect(mainVar(controller, 'b')?.connectorErrors).toBeUndefined(); + expect(variable(controller, 'a')?.connectorErrors).toBeUndefined(); + expect(variable(controller, 'b')?.connectorErrors).toBeUndefined(); await controller.dispose(); }); - it('still checks a variable that has only unit errors (AST is valid)', async () => { - // b has a unit error but a valid equation, so its deps are authoritative -- - // the unused inbound connector a->b is correctly flagged stale. - const engine = makeFakeEngine({ - json: () => projectJson(true), - incomingLinks: { a: [], b: [] }, - errors: [unitError('b')], - }); - const { config } = makeControllerConfig({ engine }); - const controller = new ProjectController(config); - - await controller.openInitialProject(); - - expect(bConnectorErrors(controller)).toEqual([{ kind: 'staleConnector', ident: 'a', name: 'a' }]); + it('still checks a variable that has only unit errors (its AST is valid)', async () => { + const controller = await open(projectJson(true), { incomingLinks: { a: [], b: [] }, errors: [unitError('b')] }); + expect(variable(controller, 'b')?.connectorErrors).toEqual([{ kind: 'staleConnector', ident: 'a', name: 'a' }]); await controller.dispose(); }); }); diff --git a/src/diagram/tests/project-controller.test.ts b/src/diagram/tests/project-controller.test.ts index 678b89c37..52340b388 100644 --- a/src/diagram/tests/project-controller.test.ts +++ b/src/diagram/tests/project-controller.test.ts @@ -2,84 +2,211 @@ // Use of this source code is governed by the Apache License, // Version 2.0, that can be found in the LICENSE file. -// Unit tests for ProjectController -- the headless coordination layer extracted -// from Editor.tsx. These exercise the engine lifecycle, the apply-patch -// pipeline, the optimistic-view race, undo/redo, the save queue, sim runs, the -// error cache, navigation, and snapshot immutability/coalescing -- all against -// the FakeEngineProject helper, with no jsdom or real WASM. +// ProjectController against the fake engine (no WASM, no jsdom): the open +// lifecycle, edit items, truncation, stale tokens, maintenance coalescing and +// its starvation bound, viewport items, per-model rendering during navigation, +// undo gating, history, saves and server-version bookkeeping, sim runs, error +// derivation, and the one-executor guarantee. +// +// What this establishes: the controller's queueing, rendering and failure +// rules on scripted engine behavior. What it does not: that the real engine +// accepts the patches the controller builds (view-model-sync.test.ts and +// editor-engine-races.test.ts run those through libsimlin), or any Editor or +// Canvas wiring. +// +// The fake engine's serialization follows applied patches through +// `statefulProject`, which implements only the patch ops these tests send (view +// upserts, aux upserts and deletes). It is a test double for "the engine +// serializes what it was patched to", not a second implementation of patch +// semantics; the real semantics are exercised through libsimlin elsewhere. import { describe, it, expect, rs } from '@rstest/core'; +import { canonicalize } from '@simlin/core/canonicalize'; import { + ErrorCode, projectFromJson, - stockFlowViewToJson, + type AuxViewElement, type StockFlowView, type StockViewElement, + type ViewElement, } from '@simlin/core/datamodel'; -import type { JsonProject, ErrorDetail } from '@simlin/engine'; -import { SimlinErrorKind } from '@simlin/engine'; +import type { JsonProject, JsonProjectPatch, ErrorDetail } from '@simlin/engine'; +import { SimlinErrorKind, SimlinErrorSeverity, SimlinUnitErrorKind } from '@simlin/engine'; +import { planGesture } from '../gesture-planner'; +import { createdVariable } from '../view-model-sync'; +import { describeWithEngine, loadEngine } from './support/engine'; -import { SimlinUnitErrorKind } from '@simlin/engine'; - -import { ProjectController, MaxUndoSize, convertErrorDetails, type ProjectSnapshot } from '../project-controller'; +import { + ProjectController, + MaintenanceEditBound, + MaxUndoSize, + convertErrorDetails, + type ProjectSnapshot, +} from '../project-controller'; import { makeFakeEngine, makeControllerConfig, + makeGate, validProjectJson, fakeRun, FAKE_STDLIB_MODEL_NAME, type FakeEngine, + type FakeEngineOptions, } from './fake-engine'; import { isStdlibModel } from '../module-navigation'; -// Drain the microtask + macrotask queue so deferred setTimeout(0) callbacks -// (scheduleSave, scheduleSimRun, undoRedo reopen) and their promise chains run. -async function flushTimers(): Promise { - // A handful of cycles is enough for the chained setTimeout -> promise -> - // setTimeout sequences the controller produces. - for (let i = 0; i < 8; i++) { - await new Promise((resolve) => setTimeout(resolve, 0)); +// --------------------------------------------------------------------------- +// Helpers + +type JsonModelState = { + name: string; + auxiliaries: Array<{ name: string; equation?: string }>; + stocks: Array<{ name: string }>; + flows: Array<{ name: string }>; + views: Array>; +}; + +function statefulProject(json: string): { + json: (includeStdlib: boolean) => string; + apply: (patch: JsonProjectPatch) => void; +} { + const state = JSON.parse(json) as { models: JsonModelState[] }; + return { + json: () => JSON.stringify(state), + apply: (patch) => { + for (const modelPatch of patch.models ?? []) { + const model = state.models.find((m) => m.name === modelPatch.name); + if (model === undefined) { + continue; + } + for (const op of modelPatch.ops) { + if (op.type === 'upsertView') { + model.views[op.payload.index] = op.payload.view as unknown as Record; + } else if (op.type === 'upsertAux') { + const ident = canonicalize(op.payload.aux.name); + model.auxiliaries = [ + ...model.auxiliaries.filter((a) => canonicalize(a.name) !== ident), + op.payload.aux as { name: string }, + ]; + } else if (op.type === 'deleteVariable') { + const ident = canonicalize(op.payload.ident); + model.auxiliaries = model.auxiliaries.filter((a) => canonicalize(a.name) !== ident); + } + } + } + }, + }; +} + +// An aux element as the Canvas stages one and the Editor commits it (a real uid, +// no var ref yet). +function aux(uid: number, name: string, x = 10, y = 10): AuxViewElement { + return { + type: 'aux', + uid, + name, + ident: canonicalize(name), + var: undefined, + x, + y, + labelSide: 'right', + isZeroRadius: false, + }; +} + +function withElements(view: StockFlowView, ...elements: ViewElement[]): StockFlowView { + const nextUid = Math.max(view.nextUid, ...elements.map((el) => el.uid + 1)); + return { ...view, elements: [...view.elements, ...elements], nextUid }; +} + +function moved(view: StockFlowView, uid: number, dx: number): StockFlowView { + return { + ...view, + elements: view.elements.map((el) => (el.uid === uid && el.type === 'aux' ? { ...el, x: el.x + dx } : el)), + }; +} + +function uids(view: StockFlowView | undefined): number[] { + return (view?.elements ?? []).map((el) => el.uid).sort((a, b) => a - b); +} + +function viewOps(patch: JsonProjectPatch, modelName = 'main') { + return (patch.models ?? []) + .filter((m) => m.name === modelName) + .flatMap((m) => m.ops) + .filter((op) => op.type === 'upsertView') as Array<{ + type: 'upsertView'; + payload: { view: { elements: Array<{ uid: number }>; viewBox?: { x: number }; zoom?: number } }; + }>; +} + +interface Opened { + controller: ProjectController; + engine: FakeEngine; + errors: Error[]; + saves: Array<{ project: { format: string; data: unknown }; currVersion: number }>; + openedWith: Uint8Array[]; +} + +async function openController( + engineOptions: FakeEngineOptions = {}, + configOptions: Partial[0]> = {}, + projectJson: string = validProjectJson({ + auxiliaries: [{ name: 'a', equation: '1' }], + mainViewElements: [{ type: 'aux', uid: 1, name: 'a', x: 0, y: 0 }], + }), +): Promise { + const project = statefulProject(projectJson); + const engine = makeFakeEngine({ json: project.json, onApplyPatch: project.apply, ...engineOptions }); + const { config, errors, saves, openedWith } = makeControllerConfig({ engine, format: 'json', ...configOptions }); + const controller = new ProjectController(config); + await controller.openInitialProject(); + await controller.whenIdle(); + return { controller, engine, errors, saves, openedWith }; +} + +function view(controller: ProjectController, modelName = 'main'): StockFlowView { + const v = controller.getSnapshot().project?.models.get(modelName)?.views[0]; + if (v === undefined) { + throw new Error(`no rendered view for ${modelName}`); } + return v; } function snap(n: number): Uint8Array { return new Uint8Array([n]); } +// --------------------------------------------------------------------------- + describe('ProjectController open lifecycle', () => { - it('opens the initial protobuf project and publishes a snapshot', async () => { - const engine = makeFakeEngine({ protobuf: snap(7) }); + it('opens the initial project and publishes a snapshot', async () => { + const engine = makeFakeEngine(); const { config } = makeControllerConfig({ engine, initialData: snap(1), initialVersion: 3 }); const controller = new ProjectController(config); - let notifies = 0; controller.subscribe(() => { notifies++; }); - await controller.openInitialProject(); - const s = controller.getSnapshot(); - expect(s.project).toBeDefined(); expect(s.project?.name).toBe('test'); - expect(s.projectVersion).toBe(3); + expect(s.serverVersion).toBe(3); expect(notifies).toBeGreaterThan(0); await controller.dispose(); }); - it('surfaces an error (and no snapshot project) when the engine open fails', async () => { + it('surfaces an error (and no project) when the engine open fails', async () => { const { config, errors } = makeControllerConfig({ openThrows: new Error('bad bytes') }); const controller = new ProjectController(config); - await controller.openInitialProject(); - expect(controller.getSnapshot().project).toBeUndefined(); - expect(errors.some((e) => e.message.includes('bad bytes'))).toBe(true); - expect(errors.some((e) => e.message.includes('opening the project in the engine failed'))).toBe(true); + expect(errors.map((e) => e.message)).toEqual(['opening the project in the engine failed: bad bytes']); await controller.dispose(); }); - it('disposes the orphan engine and surfaces an error when serializeJson fails after open', async () => { + it('disposes the opened engine and surfaces an error when serialization fails after open', async () => { const engine = makeFakeEngine({ json: () => { throw new Error('engine panic in serializeJson'); @@ -87,41 +214,34 @@ describe('ProjectController open lifecycle', () => { }); const { config, errors } = makeControllerConfig({ engine }); const controller = new ProjectController(config); - await controller.openInitialProject(); - expect(controller.getSnapshot().project).toBeUndefined(); - expect(errors.some((e) => e.message.includes('engine panic in serializeJson'))).toBe(true); - expect(errors.some((e) => e.message.includes('opening the project failed'))).toBe(true); - // The opened-but-unwired engine must be released. + expect(errors.map((e) => e.message)).toEqual(['opening the project failed: engine panic in serializeJson']); expect(engine.disposeCount).toBe(1); - expect(controller.getEngine()).toBeUndefined(); await controller.dispose(); + expect(engine.disposeCount).toBe(1); }); - it('disposes the engine opened by an in-flight open when dispose races in first', async () => { - // Make the open slow so dispose() can land while it is in flight. + it('releases the engine an in-flight open produces when dispose races in first', async () => { let resolveOpen: (e: FakeEngine) => void = () => {}; const engine = makeFakeEngine(); const openPromise = new Promise((resolve) => { resolveOpen = resolve; }); - const config = { + const controller = new ProjectController({ initialProjectVersion: 1, - input: { format: 'protobuf' as const, data: snap(1) }, + input: { format: 'protobuf', data: snap(1) }, openProtobuf: () => openPromise, openJson: () => openPromise, save: async () => 1, onError: () => {}, - }; - const controller = new ProjectController(config); - + }); const opening = controller.openInitialProject(); - await controller.dispose(); + await new Promise((resolve) => setTimeout(resolve, 0)); + const disposing = controller.dispose(); resolveOpen(engine); await opening; - - // dispose() ran before the engine existed, so the open path releases it. + await disposing; expect(engine.disposeCount).toBe(1); expect(controller.getSnapshot().project).toBeUndefined(); }); @@ -130,42 +250,25 @@ describe('ProjectController open lifecycle', () => { describe('ProjectController initialViewport (a host-carried viewport for the opened view)', () => { const carried = { viewBox: { x: -120, y: 35.5, width: 800, height: 450 }, zoom: 1.75 }; - it('opens the root view at the carried viewport: in the FIRST published snapshot, then round-tripped view-only', async () => { + it('renders from the first published snapshot and persists through one view-only patch', async () => { const engine = makeFakeEngine(); const { config, saves } = makeControllerConfig({ engine, format: 'json' }); const controller = new ProjectController({ ...config, initialViewport: carried }); - - // The very first snapshot that carries a project already shows the carried - // viewport (the canvas must never render or fit the stored one). Earlier - // notifications (the error-cache refresh) carry no project yet. const seen: Array<{ viewBox: unknown; zoom: number }> = []; controller.subscribe(() => { - const view = controller.getView(); - if (view) { - seen.push({ viewBox: view.viewBox, zoom: view.zoom }); + const v = controller.getView(); + if (v) { + seen.push({ viewBox: v.viewBox, zoom: v.zoom }); } }); await controller.openInitialProject(); - expect(seen.length).toBeGreaterThan(0); + await controller.whenIdle(); expect(seen[0]).toEqual({ viewBox: carried.viewBox, zoom: carried.zoom }); - expect(controller.getView()).toMatchObject({ viewBox: carried.viewBox, zoom: carried.zoom }); - - // The engine's copy of the view was brought in line through the same - // upsertView the pan path uses... - const viewPatches = engine.appliedPatches.filter((p) => - p.models?.some((m) => m.ops.some((op) => op.type === 'upsertView')), - ); - expect(viewPatches).toHaveLength(1); - const op = viewPatches[0].models![0].ops[0] as { - type: 'upsertView'; - payload: { view: { viewBox: unknown; zoom: number } }; - }; + expect(engine.appliedPatches).toHaveLength(1); + const op = viewOps(engine.appliedPatches[0])[0]; expect(op.payload.view.viewBox).toEqual(carried.viewBox); expect(op.payload.view.zoom).toBe(carried.zoom); - // ...as a VIEW-ONLY update: no undo entry, no generation bump, no save. - await flushTimers(); expect(controller.canUndo()).toBe(false); - expect(controller.getSnapshot().projectGeneration).toBe(0); expect(saves).toHaveLength(0); await controller.dispose(); }); @@ -175,6 +278,7 @@ describe('ProjectController initialViewport (a host-carried viewport for the ope const { config } = makeControllerConfig({ engine, format: 'json' }); const controller = new ProjectController(config); await controller.openInitialProject(); + await controller.whenIdle(); expect(controller.getView()).toMatchObject({ viewBox: { x: 0, y: 0, width: 0, height: 0 }, zoom: 1 }); expect(engine.appliedPatches).toHaveLength(0); await controller.dispose(); @@ -192,6 +296,7 @@ describe('ProjectController initialViewport (a host-carried viewport for the ope const { config, errors } = makeControllerConfig({ engine, format: 'json' }); const controller = new ProjectController({ ...config, initialViewport: bad }); await controller.openInitialProject(); + await controller.whenIdle(); expect(controller.getView()).toMatchObject({ viewBox: { x: 0, y: 0, width: 0, height: 0 }, zoom: 1 }); expect(engine.appliedPatches).toHaveLength(0); expect(errors).toEqual([]); @@ -209,6 +314,7 @@ describe('ProjectController initialViewport (a host-carried viewport for the ope const { config, errors } = makeControllerConfig({ engine, format: 'json', initialData: json }); const controller = new ProjectController({ ...config, initialViewport: carried }); await controller.openInitialProject(); + await controller.whenIdle(); expect(controller.getSnapshot().project).toBeDefined(); expect(controller.getView()).toBeUndefined(); expect(engine.appliedPatches).toHaveLength(0); @@ -217,620 +323,1521 @@ describe('ProjectController initialViewport (a host-carried viewport for the ope }); }); -describe('ProjectController applyPatch pipeline', () => { - async function openController(engineOpts = {}): Promise<{ - controller: ProjectController; - engine: FakeEngine; - errors: Error[]; - }> { - const engine = makeFakeEngine(engineOpts); - const { config, errors } = makeControllerConfig({ engine }); - const controller = new ProjectController(config); - await controller.openInitialProject(); - return { controller, engine, errors }; - } - - it('applies a patch, rebuilds the project, and bumps version + generation', async () => { - const { controller, engine } = await openController(); - const before = controller.getSnapshot(); +describe('ProjectController edit items', () => { + it('a view edit renders at once, lands as one patch, and records one history entry', async () => { + const gate = makeGate(); + const { controller, engine } = await openController({ applyPatchGate: () => gate.wait() }); + const next = withElements(view(controller), aux(2, 'b')); + const landed = controller.enqueueViewEdit({ label: 'create', nextView: next }); + // Optimistic: rendered before any engine call. + expect(uids(view(controller))).toEqual([1, 2]); + expect(controller.getSnapshot().canUndo).toBe(false); + gate.open(); + expect(await landed).toBe(true); + await controller.whenIdle(); + expect(engine.appliedPatches).toHaveLength(1); + expect(engine.appliedPatches[0].models![0].ops.map((op) => op.type)).toEqual(['upsertAux', 'upsertView']); + expect(uids(view(controller))).toEqual([1, 2]); + expect(controller.getSnapshot().canUndo).toBe(true); + await controller.dispose(); + }); - const ok = await controller.applyPatch({ models: [{ name: 'main', ops: [] }] }, 'edit'); + it('each landed edit records exactly one history entry, up to MaxUndoSize', async () => { + const { controller, engines } = await (async () => { + const opened = await openController(); + return { controller: opened.controller, engines: [opened.engine] }; + })(); + void engines; + for (let i = 0; i < 3; i++) { + await controller.enqueueViewEdit({ label: 'move', nextView: moved(view(controller), 1, 10) }); + } + await controller.whenIdle(); + // Three edits on top of the open: three undos available, not four. + let undos = 0; + // Count by walking the predicate without landing an undo (which reopens). + const history = (controller as unknown as { projectHistory: unknown[] }).projectHistory; + undos = history.length - 1; + expect(undos).toBe(3); + for (let i = 0; i < MaxUndoSize + 2; i++) { + await controller.enqueueViewEdit({ label: 'move', nextView: moved(view(controller), 1, 10) }); + } + await controller.whenIdle(); + expect((controller as unknown as { projectHistory: unknown[] }).projectHistory).toHaveLength(MaxUndoSize); + await controller.dispose(); + }); - expect(ok).toBe(true); - expect(engine.appliedPatches).toHaveLength(1); - const after = controller.getSnapshot(); - expect(after.projectVersion).toBeGreaterThan(before.projectVersion); - expect(after.projectGeneration).toBe(before.projectGeneration + 1); + it('a model-only edit builds its payload from the committed project at dequeue, after earlier edits landed', async () => { + const gate = makeGate(); + const { controller, engine } = await openController({ + applyPatchGate: (_p, i) => (i === 0 ? gate.wait() : Promise.resolve()), + }); + void controller.enqueueViewEdit({ label: 'create', nextView: withElements(view(controller), aux(2, 'b')) }); + const seen: string[][] = []; + const landed = controller.enqueueModelEdit({ + label: 'equation', + buildPatch: (committed) => { + seen.push([...committed.models.get('main')!.variables.keys()].sort()); + return { + models: [{ name: 'main', ops: [{ type: 'upsertAux', payload: { aux: { name: 'b', equation: '2' } } }] }], + }; + }, + }); + expect(seen).toEqual([]); + gate.open(); + expect(await landed).toBe(true); + expect(seen).toEqual([['a', 'b']]); + expect(engine.appliedPatches).toHaveLength(2); await controller.dispose(); }); - it('reports the error and leaves the snapshot unchanged when applyPatch throws', async () => { - const { controller, engine, errors } = await openController({ applyPatchThrows: true }); - const before = controller.getSnapshot(); + it('a failing model-only edit only reports: a later view edit lands and the token does not move', async () => { + // Nothing is planned on a model-only edit (it has no next view), so its + // failure -- a builder throw or an engine rejection -- discards nothing. + for (const failure of ['builder', 'engine'] as const) { + const { controller, errors, engine } = await openController({ + applyPatchThrows: (_p, i) => (failure === 'engine' && i === 0 ? new Error('rejected') : undefined), + }); + const tokenBefore = controller.getSnapshot().token; + const failing = controller.enqueueModelEdit({ + label: 'equation', + buildPatch: () => { + if (failure === 'builder') { + throw new Error("variable 'gone' does not exist"); + } + return { models: [] }; + }, + }); + const move = controller.enqueueViewEdit({ label: 'move', nextView: moved(view(controller), 1, 10) }); + expect(await failing).toBe(false); + expect(await move).toBe(true); + await controller.whenIdle(); + expect(errors.map((e) => e.message)).toEqual([ + failure === 'builder' ? "variable 'gone' does not exist" : 'rejected', + ]); + expect(controller.getSnapshot().token).toBe(tokenBefore); + expect(engine.appliedPatches.map((p) => p.models![0].ops[0].type)).toEqual(['upsertView']); + await controller.dispose(); + } + }); - const ok = await controller.applyPatch({ models: [{ name: 'main', ops: [] }] }, 'bad edit'); + it('a model-only builder reads the committed project, never the rendered one', async () => { + const gate = makeGate(); + const { controller } = await openController({ + applyPatchGate: (_p, i) => (i === 0 ? gate.wait() : Promise.resolve()), + }); + // A create queued behind a model-only edit renders before the builder runs. + const seen: number[][] = []; + void controller.enqueueModelEdit({ + label: 'equation', + buildPatch: (committed) => { + seen.push(uids(committed.models.get('main')!.views[0])); + return { models: [] }; + }, + }); + void controller.enqueueViewEdit({ label: 'create', nextView: withElements(view(controller), aux(2, 'b')) }); + expect(uids(view(controller))).toEqual([1, 2]); + gate.open(); + await controller.whenIdle(); + expect(seen).toEqual([[1]]); + await controller.dispose(); + }); - expect(ok).toBe(false); - expect(errors.length).toBeGreaterThan(0); - // Snapshot identity is unchanged: a failed patch makes no state change. - expect(controller.getSnapshot()).toBe(before); - expect(engine.appliedPatches).toHaveLength(0); + it('an edit whose serialized project equals the history head records no history entry', async () => { + const { controller, engine } = await openController({ protobuf: new Uint8Array([7]) }); + expect(await controller.enqueueViewEdit({ label: 'move', nextView: moved(view(controller), 1, 10) })).toBe(true); + await controller.whenIdle(); + expect(engine.appliedPatches).toHaveLength(1); + expect(controller.getSnapshot().canUndo).toBe(false); await controller.dispose(); }); -}); -describe('ProjectController optimistic view updates', () => { - it('reflects the view immediately and keeps the newer view when the engine is behind', async () => { - // The engine serializes an OLD view (zoom 1) while the user has panned to a - // newer one (zoom 5) via the optimistic path. preserveLiveView must keep the - // newer live view after the round-trip rebuild. - const engine = makeFakeEngine({ - json: () => validProjectJson({ mainViewElements: [] }), + it("an edit's upsertView carries the committed viewport, never the live one", async () => { + const gate = makeGate(); + const { controller, engine } = await openController({ + applyPatchGate: (_p, i) => (i === 0 ? gate.wait() : Promise.resolve()), }); - const { config } = makeControllerConfig({ engine }); - const controller = new ProjectController(config); - await controller.openInitialProject(); + void controller.enqueueViewEdit({ label: 'move', nextView: moved(view(controller), 1, 5) }); + void controller.enqueueViewEdit({ label: 'move', nextView: moved(view(controller), 1, 5) }); + controller.setViewport('main', { viewBox: { x: 9, y: 9, width: 800, height: 600 }, zoom: 2 }); + // The second edit was planned before the pan, the viewport item queued after it. + gate.open(); + await controller.whenIdle(); + const [first, second, viewportPatch] = engine.appliedPatches; + expect(viewOps(first)[0].payload.view.zoom).toBe(1); + expect(viewOps(second)[0].payload.view.zoom).toBe(1); + expect(viewOps(viewportPatch)[0].payload.view.zoom).toBe(2); + await controller.dispose(); + }); - const view = controller.getView() as StockFlowView; - expect(view).toBeDefined(); + it('a view with a non-finite coordinate is refused before anything renders or patches (#818)', async () => { + const { controller, engine, errors } = await openController(); + const before = controller.getSnapshot(); + const badStock: StockViewElement = { + type: 'stock', + uid: 999, + name: 'bad', + ident: 'bad', + var: undefined, + x: NaN, + y: 0, + labelSide: 'top', + isZeroRadius: false, + inflows: [], + outflows: [], + }; + expect( + await controller.enqueueViewEdit({ label: 'move', nextView: withElements(view(controller), badStock) }), + ).toBe(false); + expect(controller.getSnapshot()).toBe(before); + expect(engine.appliedPatches).toHaveLength(0); + expect(errors).toHaveLength(1); + expect(errors[0].message).toContain('uid=999'); - await controller.updateView({ ...view, zoom: 5 }); + controller.setViewport('main', { viewBox: { x: NaN, y: 0, width: 1, height: 1 }, zoom: 1 }); + await controller.whenIdle(); + expect(engine.appliedPatches).toHaveLength(0); + expect(errors).toHaveLength(2); + await controller.dispose(); + }); - const liveView = controller.getView() as StockFlowView; - // Even though the engine's serialized JSON carries the default zoom, the - // live optimistic zoom survives the rebuild. - expect(liveView.zoom).toBe(5); + it('newVariableName allocates past pending creates; nameError reports a pending create', async () => { + const gate = makeGate(); + const { controller } = await openController({ applyPatchGate: () => gate.wait() }); + expect(controller.newVariableName('New Variable')).toBe('New Variable'); + void controller.enqueueViewEdit({ + label: 'create', + nextView: withElements(view(controller), aux(2, 'New Variable')), + }); + expect(controller.newVariableName('New Variable')).toBe('New Variable 1'); + expect(controller.nameError('new variable', undefined)).toBeDefined(); + expect(controller.nameError('a', 'a')).toBeUndefined(); + gate.open(); + await controller.whenIdle(); await controller.dispose(); }); - it('view-only updates never consume undo slots', async () => { - const engine = makeFakeEngine(); - const { config } = makeControllerConfig({ engine, format: 'protobuf', initialData: snap(1) }); + it('a committed variable with no element on the view still takes its name', async () => { + const engine = makeFakeEngine({ + json: validProjectJson({ + auxiliaries: [{ name: 'a', equation: '1' }, { name: 'Hidden' }], + mainViewElements: [{ type: 'aux', uid: 1, name: 'a', x: 0, y: 0 }], + }), + }); + const { config } = makeControllerConfig({ engine, format: 'json' }); const controller = new ProjectController(config); await controller.openInitialProject(); - - const beforeGen = controller.getSnapshot().projectGeneration; - const view = controller.getView() as StockFlowView; - await controller.queueViewUpdate({ ...view, zoom: 3 }); - - // No history recorded, no generation bump (details panels must not remount). - expect(controller.getSnapshot().projectGeneration).toBe(beforeGen); - expect(controller.canUndo()).toBe(false); + expect(controller.nameError('HIDDEN', undefined)).toBeDefined(); + expect(controller.newVariableName('hidden')).toBe('hidden 1'); await controller.dispose(); }); +}); - it('adopts a view carried by a content patch instead of preserving the stale live view', async () => { - // Mirrors Editor.handleRename: one patch both renames the variable AND - // upserts the renamed view. preserveLiveView exists to protect newer - // OPTIMISTIC views from older engine snapshots, but a view arriving in an - // explicit upsertView op is newer user intent than the live view by - // construction. Before the fix, the live (pre-rename) view clobbered the - // patched one on refresh: the rename looked like a silent no-op, and the - // next geometry edit round-tripped the stale view back into the engine, - // persisting a model/view divergence (a view element whose variable no - // longer exists -- observed live as an editor-crashing corrupted project). - const auxEl = { type: 'aux', name: 'x', uid: 1, x: 10, y: 20, labelSide: 'right' }; - let currentJson = validProjectJson({ mainViewElements: [auxEl] }); - const engine = makeFakeEngine({ json: () => currentJson }); - const { config } = makeControllerConfig({ engine }); - const controller = new ProjectController(config); - await controller.openInitialProject(); +describe('ProjectController a patch that applied but could not be read back', () => { + // The resync runs BEFORE the item's fate is decided, and the arm it takes + // decides it: + // - re-read: the patch is kept, so the edit landed -- nothing is discarded, + // nothing reported; + // - reopen: the patch is lost, so the edit failed, and so did every view edit + // planned on its next view, including one enqueued while the reopen ran; + // - release: the engine is gone -- everything queued settles quietly, every + // later request is refused quietly, and the snapshot says engineUnavailable + // for the host's one notice; + // - disposed meanwhile: both engines are released. + // A viewport persist runs the same resync without recording history, and + // reports nothing (the last two rows). + const originalJson = () => + validProjectJson({ + auxiliaries: [{ name: 'a', equation: '1' }], + mainViewElements: [{ type: 'aux', uid: 1, name: 'a', x: 0, y: 0 }], + }); - const view = controller.getView() as StockFlowView; - const renamedView: StockFlowView = { - ...view, - elements: view.elements.map((el) => (el.uid === 1 && el.type === 'aux' ? { ...el, name: 'y' } : el)), - }; + // `failures.remaining` project reads (with stdlib, as a read-back does) throw, + // on whichever engine is current. A reopen succeeds with a fresh engine that + // starts from the original project and follows its own patches, fails, or + // waits on a gate first. + function armed(reopen: 'succeeds' | 'fails' | { readonly gate: Promise }) { + const failures = { remaining: 0 }; + const readJson = + (source: (includeStdlib: boolean) => string) => + (includeStdlib: boolean): string => { + if (includeStdlib && failures.remaining > 0) { + failures.remaining -= 1; + throw new Error('injected read failure'); + } + return source(includeStdlib); + }; + const project = statefulProject(originalJson()); + const engine = makeFakeEngine({ json: readJson(project.json), onApplyPatch: project.apply }); + const reopened: FakeEngine[] = []; + const opens: Uint8Array[] = []; + const { config, errors } = makeControllerConfig({ engine, format: 'json' }); + const controller = new ProjectController({ + ...config, + openProtobuf: async (data) => { + opens.push(data); + if (reopen === 'fails') { + throw new Error('reopen failed'); + } + if (reopen !== 'succeeds') { + await reopen.gate; + } + const reopenedProject = statefulProject(originalJson()); + const next = makeFakeEngine({ json: readJson(reopenedProject.json), onApplyPatch: reopenedProject.apply }); + reopened.push(next); + return next; + }, + }); + return { controller, engine, reopened, failures, opens, errors }; + } - const patch = { - models: [ - { - name: 'main', - ops: [ - { type: 'renameVariable' as const, payload: { from: 'x', to: 'y' } }, - { type: 'upsertView' as const, payload: { index: 0, view: stockFlowViewToJson(renamedView) } }, - ], - }, - ], - }; - // After the patch, the engine's serialized state carries the rename. - currentJson = validProjectJson({ mainViewElements: [{ ...auxEl, name: 'y' }] }); + function history(controller: ProjectController): { projectHistory: Uint8Array[]; projectOffset: number } { + return controller as unknown as { projectHistory: Uint8Array[]; projectOffset: number }; + } - expect(await controller.applyPatchOrReportError(patch, 'rename')).toBe(true); + async function until(predicate: () => boolean): Promise { + for (let i = 0; i < 100 && !predicate(); i++) { + await new Promise((resolve) => setTimeout(resolve, 1)); + } + expect(predicate()).toBe(true); + } - // The optimistic mirror lands synchronously, before any refresh: the UI - // must show the rename immediately. - const optimistic = controller.getView() as StockFlowView; - const optimisticNames = optimistic.elements.map((el) => ('name' in el ? el.name : undefined)); - expect(optimisticNames).toContain('y'); - expect(optimisticNames).not.toContain('x'); + it('re-read: the edit landed -- it stays, an edit planned on it lands, and nothing is reported', async () => { + const { controller, failures, opens, errors } = armed('succeeds'); + await controller.openInitialProject(); + await controller.whenIdle(); + const tokenBefore = controller.getSnapshot().token; - await controller.refreshFromEngine(); + failures.remaining = 1; + const create = controller.enqueueViewEdit({ + label: 'create', + nextView: withElements(view(controller), aux(2, 'b')), + }); + const later = controller.enqueueViewEdit({ label: 'move', nextView: moved(view(controller), 2, 30) }); + expect(await create).toBe(true); + expect(await later).toBe(true); + await controller.whenIdle(); - const after = controller.getView() as StockFlowView; - const names = after.elements.map((el) => ('name' in el ? el.name : undefined)); - expect(names).toContain('y'); - expect(names).not.toContain('x'); + expect(errors).toEqual([]); + expect(controller.getSnapshot().token).toBe(tokenBefore); + expect(uids(view(controller))).toEqual([1, 2]); + // The open, the create (recorded by the re-read) and the move. + expect(history(controller).projectHistory).toHaveLength(3); + expect(opens).toEqual([]); await controller.dispose(); }); - it('updateView records undo history only when recordHistory is set', async () => { - // Discrete element/structure edits (create/delete/move/flow-attach/etc.) - // funnel their final engine state through updateView and must each produce - // exactly one undo entry. A plain updateView (the legacy default) and the - // viewport-only queueViewUpdate path must record nothing. - const engine = makeFakeEngine(); - const { config } = makeControllerConfig({ engine, format: 'protobuf', initialData: snap(1) }); - const controller = new ProjectController(config); + it('reopen: the edit failed -- the last recorded snapshot is installed, the edit planned on it is discarded, the live viewport persists again', async () => { + const { controller, engine, reopened, failures, opens, errors } = armed('succeeds'); await controller.openInitialProject(); + await controller.whenIdle(); + controller.setViewport('main', { viewBox: { x: 3, y: 0, width: 800, height: 600 }, zoom: 2 }); + await controller.whenIdle(); + const head = history(controller).projectHistory[0]; + const tokenBefore = controller.getSnapshot().token; + + failures.remaining = 2; + const create = controller.enqueueViewEdit({ + label: 'create', + nextView: withElements(view(controller), aux(2, 'b')), + }); + const later = controller.enqueueViewEdit({ label: 'move', nextView: moved(view(controller), 2, 30) }); + expect(await create).toBe(false); + expect(await later).toBe(false); + await controller.whenIdle(); + + expect(errors.map((e) => e.message)).toEqual([ + 'reading the project back after create failed: injected read failure (1 later edit discarded)', + ]); + expect(controller.getSnapshot().token).toBe(tokenBefore + 1); + expect(opens).toEqual([head]); + expect(engine.disposeCount).toBe(1); + expect(uids(view(controller))).toEqual([1]); + // The live viewport, which the snapshot does not carry, is persisted again. + expect(viewOps(reopened[0].appliedPatches[0])[0].payload.view.zoom).toBe(2); + await controller.dispose(); + }); - const view = controller.getView() as StockFlowView; + it('reopen after a geometry-only edit refreshes errors, connectors and the sim on the reopened engine', async () => { + // The reopened snapshot is not the project the edit planned on, so whatever + // the failed patch carried, everything derived from the model is refreshed. + const { controller, reopened, failures } = armed('succeeds'); + await controller.openInitialProject(); + await controller.whenIdle(); + failures.remaining = 2; + expect(await controller.enqueueViewEdit({ label: 'move', nextView: moved(view(controller), 1, 10) })).toBe(false); + await controller.whenIdle(); + expect(reopened).toHaveLength(1); + for (const marker of ['getErrors', 'getModel', 'run']) { + expect(reopened[0].calls).toContain(marker); + } + await controller.dispose(); + }); - // A plain updateView (no opts) refreshes the project but records nothing. - const genBefore = controller.getSnapshot().projectGeneration; - await controller.updateView({ ...view, zoom: 2 }); - expect(controller.canUndo()).toBe(false); - expect(controller.getSnapshot().projectGeneration).toBe(genBefore); + it('reopen: a view edit enqueued while the reopen runs was planned on the failed edit, and is discarded with it after the swap', async () => { + let openGate!: () => void; + const gate = new Promise((resolve) => { + openGate = resolve; + }); + const { controller, failures, opens, errors } = armed({ gate }); + await controller.openInitialProject(); + await controller.whenIdle(); + const tokenBefore = controller.getSnapshot().token; - // queueViewUpdate (pan/zoom/momentum) likewise records nothing. - await controller.queueViewUpdate({ ...view, zoom: 3 }); - expect(controller.canUndo()).toBe(false); - expect(controller.getSnapshot().projectGeneration).toBe(genBefore); + failures.remaining = 2; + const create = controller.enqueueViewEdit({ + label: 'create', + nextView: withElements(view(controller), aux(2, 'b')), + }); + await until(() => opens.length === 1); + // Nothing is decided yet: the failed create still renders, and the token + // has not moved, so this is what a handler plans on. + expect(uids(view(controller))).toEqual([1, 2]); + expect(controller.getSnapshot().token).toBe(tokenBefore); + const planned = controller.enqueueViewEdit({ label: 'move', nextView: moved(view(controller), 2, 30) }); + openGate(); + expect(await create).toBe(false); + expect(await planned).toBe(false); + await controller.whenIdle(); + + expect(controller.getSnapshot().token).toBe(tokenBefore + 1); + expect(uids(view(controller))).toEqual([1]); + expect(errors.map((e) => e.message)).toEqual([ + 'reading the project back after create failed: injected read failure (1 later edit discarded)', + ]); + await controller.dispose(); + }); - // A discrete edit (recordHistory: true) advances history exactly once. - await controller.updateView({ ...view, zoom: 4 }, { recordHistory: true }); - expect(controller.canUndo()).toBe(true); - expect(controller.getSnapshot().projectGeneration).toBe(genBefore + 1); + it('release: the engine is lost -- everything queued settles quietly, every later request is refused quietly, and the snapshot says so', async () => { + const { controller, engine, failures, errors } = armed('fails'); + await controller.openInitialProject(); + await controller.whenIdle(); + // History to undo, so the refusal below is the unavailable state's doing. + await controller.enqueueViewEdit({ label: 'move', nextView: moved(view(controller), 1, 10) }); + await controller.whenIdle(); + expect(controller.getSnapshot().canUndo).toBe(true); + const tokenBefore = controller.getSnapshot().token; + + failures.remaining = 2; + const create = controller.enqueueViewEdit({ + label: 'create', + nextView: withElements(view(controller), aux(2, 'b')), + }); + const later = controller.enqueueViewEdit({ label: 'move', nextView: moved(view(controller), 2, 30) }); + const query = controller.query(async () => 'answer'); + expect(await create).toBe(false); + expect(await later).toBe(false); + expect(await query).toBeUndefined(); + await controller.whenIdle(); - // undo then redo round-trips the cursor. + const s = controller.getSnapshot(); + expect(s.engineUnavailable).toBe(true); + expect(s.status).toBe('disabled'); + expect(s.token).toBe(tokenBefore); + expect(engine.disposeCount).toBe(1); + expect(await controller.enqueueModelEdit({ label: 'equation', buildPatch: () => ({ models: [] }) })).toBe(false); + expect(await controller.enqueueViewEdit({ label: 'move', nextView: moved(view(controller), 1, 5) })).toBe(false); + expect(await controller.query(async () => 1)).toBeUndefined(); + expect(controller.getSnapshot().canUndo).toBe(false); controller.undoRedo('undo'); - await flushTimers(); - expect(controller.canRedo()).toBe(true); - controller.undoRedo('redo'); - await flushTimers(); - expect(controller.canRedo()).toBe(false); + controller.undoRedo('undo', { afterQueuedEdits: true }); + expect(controller.getSnapshot().undoRedoQueued).toBe(false); + await controller.whenIdle(); + expect(errors).toEqual([]); await controller.dispose(); }); - it('undo after a recordHistory updateView reopens the engine from the pre-edit snapshot', async () => { - // Prove restoration, not just the cursor move: the undo reopen must pull the - // serialized snapshot captured BEFORE the edit back into the engine. - const openedWith: Uint8Array[] = []; - let counter = 100; - const engine = makeFakeEngine({ protobuf: () => new Uint8Array([counter++]) }); - const config = { - initialProjectVersion: 1, - input: { format: 'protobuf' as const, data: new Uint8Array([1]) }, - openProtobuf: async (data: Uint8Array) => { - openedWith.push(data); - return engine; - }, - openJson: async () => engine, - save: async () => 1, - onError: () => {}, - }; - const controller = new ProjectController(config); + it('a dispose while the reopen runs releases both engines and reports nothing', async () => { + let openGate!: () => void; + const gate = new Promise((resolve) => { + openGate = resolve; + }); + const { controller, engine, reopened, failures, opens, errors } = armed({ gate }); await controller.openInitialProject(); - // History head is the post-open snapshot ([100]). - const view = controller.getView() as StockFlowView; - await controller.updateView({ ...view, zoom: 9 }, { recordHistory: true }); - // The edit recorded a fresh head, so there is a distinct pre-edit snapshot - // to restore (without this the undo would merely clamp to the only entry). - expect(controller.canUndo()).toBe(true); + await controller.whenIdle(); - controller.undoRedo('undo'); - await flushTimers(); + failures.remaining = 2; + const create = controller.enqueueViewEdit({ + label: 'create', + nextView: withElements(view(controller), aux(2, 'b')), + }); + await until(() => opens.length === 1); + const disposing = controller.dispose(); + openGate(); + expect(await create).toBe(false); + await disposing; + expect(engine.disposeCount).toBe(1); + expect(reopened).toHaveLength(1); + expect(reopened[0].disposeCount).toBe(1); + expect(errors).toEqual([]); + }); - // The reopen restored the pre-edit snapshot ([100]) into the engine. - expect(openedWith[openedWith.length - 1]).toEqual(new Uint8Array([100])); + it('a viewport persist whose read-back fails re-reads without recording history: the redo branch survives, committed catches up, nothing is reported', async () => { + const { controller, reopened, failures, opens, errors } = armed('succeeds'); + await controller.openInitialProject(); + await controller.whenIdle(); + await controller.enqueueViewEdit({ label: 'move', nextView: moved(view(controller), 1, 10) }); + controller.undoRedo('undo'); + await controller.whenIdle(); + expect(controller.getSnapshot().canRedo).toBe(true); + const current = reopened[0]; + const historyLength = history(controller).projectHistory.length; + + failures.remaining = 1; + const viewport = { viewBox: { x: 40, y: 40, width: 800, height: 600 }, zoom: 1.5 }; + controller.setViewport('main', viewport); + await controller.whenIdle(); + expect(controller.getSnapshot().canRedo).toBe(true); + expect(history(controller).projectHistory).toHaveLength(historyLength); + expect(opens).toHaveLength(1); + expect(errors).toEqual([]); + // Committed caught up with the engine, so the same viewport patches nothing. + const patches = current.appliedPatches.length; + controller.setViewport('main', viewport); + await controller.whenIdle(); + expect(current.appliedPatches).toHaveLength(patches); await controller.dispose(); }); - it('stashes the queued view when no engine is installed yet and replays it on reopen', async () => { - // Drive queueViewUpdate before any engine exists (controller just - // constructed), then confirm the next engine pulls the queued view. - const engine = makeFakeEngine(); - const { config } = makeControllerConfig({ engine }); - const controller = new ProjectController(config); - - // No project yet -> applyOptimisticView is a no-op, but the queued-view - // flag is set for the eventual engine install path. We assert via the - // reopen-for-undo path below instead; here just verify it does not throw. - const dummyView = { - elements: [], - nextUid: 1, - viewBox: { x: 0, y: 0, width: 0, height: 0 }, - zoom: 1, - } as StockFlowView; - await controller.queueViewUpdate(dummyView); - expect(controller.getSnapshot().project).toBeUndefined(); + it('a viewport persist whose re-read fails too reopens the snapshot at the history cursor, and persists the viewport again', async () => { + const { controller, reopened, failures, opens, errors } = armed('succeeds'); + await controller.openInitialProject(); + await controller.whenIdle(); + await controller.enqueueViewEdit({ label: 'move', nextView: moved(view(controller), 1, 10) }); + controller.undoRedo('undo'); + await controller.whenIdle(); + const { projectHistory, projectOffset } = history(controller); + expect(projectOffset).toBe(1); + + failures.remaining = 2; + controller.setViewport('main', { viewBox: { x: 40, y: 40, width: 800, height: 600 }, zoom: 1.5 }); + await controller.whenIdle(); + // The cursor's snapshot (the undone state), not the head (the redo branch). + expect(opens).toHaveLength(2); + expect(opens[1]).toEqual(projectHistory[1]); + expect(opens[1]).not.toEqual(projectHistory[0]); + expect(controller.getSnapshot().canRedo).toBe(true); + expect(errors).toEqual([]); + expect(viewOps(reopened[1].appliedPatches[0])[0].payload.view.zoom).toBe(1.5); await controller.dispose(); }); }); -describe('ProjectController undo/redo', () => { - it('editing after an undo discards the redo branch and caps at MaxUndoSize', async () => { - const engine = makeFakeEngine(); - const { config } = makeControllerConfig({ engine, format: 'protobuf', initialData: snap(1) }); - const controller = new ProjectController(config); - await controller.openInitialProject(); +describe('ProjectController truncation when an edit fails', () => { + // Every kind of item that can wait behind a failing edit. A view edit was + // planned on the failing edit's optimistic view and is discarded. A model-only + // edit builds its payload from committed state at dequeue, so it survives and + // lands. Viewport and query items are not edits and survive. Undo is not a row: + // it cannot be queued while an edit is pending (see undo gating). + const LATER = ['viewEdit', 'modelEdit', 'viewport', 'query'] as const; + + for (const later of LATER) { + const discarded = later === 'viewEdit'; + it(`a failing edit ${discarded ? 'discards' : 'keeps'} a later ${later}`, async () => { + const gate = makeGate(); + const { controller, engine, errors } = await openController({ + applyPatchGate: (_p, i) => (i === 0 ? gate.wait() : Promise.resolve()), + applyPatchThrows: (_p, i) => (i === 0 ? new Error('boom') : undefined), + }); + const tokenBefore = controller.getSnapshot().token; + const failing = controller.enqueueViewEdit({ + label: 'create', + nextView: withElements(view(controller), aux(2, 'b')), + }); + let laterResult: Promise = Promise.resolve(); + switch (later) { + case 'viewEdit': + laterResult = controller.enqueueViewEdit({ label: 'move', nextView: moved(view(controller), 2, 30) }); + break; + case 'modelEdit': + laterResult = controller.enqueueModelEdit({ label: 'eq', buildPatch: () => ({ models: [] }) }); + break; + case 'viewport': + controller.setViewport('main', { viewBox: { x: 4, y: 4, width: 640, height: 480 }, zoom: 1.5 }); + break; + case 'query': + laterResult = controller.query(async () => 'answer'); + break; + } + gate.open(); + expect(await failing).toBe(false); + const laterValue = await laterResult; + await controller.whenIdle(); + + // Model and diagram agree: the rendered view is committed. + expect(uids(view(controller))).toEqual([1]); + expect(controller.getSnapshot().token).toBe(tokenBefore + 1); + expect(errors.map((e) => e.message)).toEqual([discarded ? 'boom (1 later edit discarded)' : 'boom']); + if (discarded) { + expect(laterValue).toBe(false); + expect(engine.appliedPatches).toHaveLength(0); + } else if (later === 'modelEdit') { + // The model-only edit queued behind the failed view edit lands. + expect(laterValue).toBe(true); + expect(engine.appliedPatches).toHaveLength(1); + } else if (later === 'viewport') { + expect(engine.appliedPatches).toHaveLength(1); + // The surviving viewport item persists committed elements only. + expect(viewOps(engine.appliedPatches[0])[0].payload.view.elements.map((el) => el.uid)).toEqual([1]); + } else { + expect(laterValue).toBe('answer'); + } + await controller.dispose(); + }); + } - // Record a long history by feeding distinct protobufs through updateProject. - for (let i = 2; i <= 7; i++) { - await controller.updateProject(snap(i)); - } - // History is capped at MaxUndoSize, newest-first. - let s = controller.getSnapshot(); - expect(controller.canUndo()).toBe(true); - const historyLen = MaxUndoSize; + it('a model-only edit to a variable the failed edit would have created survives truncation and fails with its own error', async () => { + const gate = makeGate(); + const { controller, engine, errors } = await openController({ + applyPatchGate: (_p, i) => (i === 0 ? gate.wait() : Promise.resolve()), + applyPatchThrows: (_p, i) => (i === 0 ? new Error('boom') : undefined), + }); + const created = controller.enqueueViewEdit({ + label: 'create', + nextView: withElements(view(controller), aux(2, 'b')), + }); + // The user types an equation for b while its create is still in flight. The + // builder reads the committed variable at dequeue, as the Editor's does. + const equation = controller.enqueueModelEdit({ + label: 'equation update', + buildPatch: (committed) => { + if (!committed.models.get('main')!.variables.has('b')) { + throw new Error("equation update failed: 'b' no longer exists"); + } + return { + models: [{ name: 'main', ops: [{ type: 'upsertAux', payload: { aux: { name: 'b', equation: '5' } } }] }], + }; + }, + }); + gate.open(); + expect(await created).toBe(false); + expect(await equation).toBe(false); + await controller.whenIdle(); + expect(engine.appliedPatches).toHaveLength(0); + expect(uids(view(controller))).toEqual([1]); + expect(errors.map((e) => e.message)).toEqual(['boom', "equation update failed: 'b' no longer exists"]); + await controller.dispose(); + }); - // Undo twice, then edit: the redo branch must be discarded. - controller.undoRedo('undo'); - await flushTimers(); - controller.undoRedo('undo'); - await flushTimers(); - await controller.updateProject(snap(99)); + it('an edit planned on a failed edit is discarded with it (a move of the element it created)', async () => { + const gate = makeGate(); + const { controller, engine } = await openController({ + applyPatchGate: (_p, i) => (i === 0 ? gate.wait() : Promise.resolve()), + applyPatchThrows: (_p, i) => (i === 0 ? new Error('boom') : undefined), + }); + const created = controller.enqueueViewEdit({ + label: 'create', + nextView: withElements(view(controller), aux(2, 'b')), + }); + // Planned on the optimistic view: element 2 exists only there. + expect(uids(view(controller))).toEqual([1, 2]); + const moveIt = controller.enqueueViewEdit({ label: 'move', nextView: moved(view(controller), 2, 30) }); + gate.open(); + expect(await created).toBe(false); + expect(await moveIt).toBe(false); + expect(engine.appliedPatches).toHaveLength(0); + expect(uids(view(controller))).toEqual([1]); + await controller.dispose(); + }); - s = controller.getSnapshot(); - expect(s.canRedo).toBe(false); // editing discarded the redo branch - expect(historyLen).toBe(MaxUndoSize); + it('a failure in the middle keeps the edits before it and discards those after', async () => { + const { controller, engine, errors } = await openController({ + applyPatchThrows: (_p, i) => (i === 1 ? new Error('second failed') : undefined), + }); + const first = controller.enqueueViewEdit({ + label: 'create', + nextView: withElements(view(controller), aux(2, 'b')), + }); + const second = controller.enqueueViewEdit({ + label: 'create', + nextView: withElements(view(controller), aux(3, 'c')), + }); + const third = controller.enqueueViewEdit({ + label: 'create', + nextView: withElements(view(controller), aux(4, 'd')), + }); + const fourth = controller.enqueueViewEdit({ + label: 'create', + nextView: withElements(view(controller), aux(5, 'e')), + }); + expect(await Promise.all([first, second, third, fourth])).toEqual([true, false, false, false]); + await controller.whenIdle(); + expect(engine.appliedPatches).toHaveLength(1); + expect(uids(view(controller))).toEqual([1, 2]); + expect(errors.map((e) => e.message)).toEqual(['second failed (2 later edits discarded)']); await controller.dispose(); }); +}); - it('defers the version/generation bump until the restored project is installed (#817)', async () => { - // undoRedo must not bump projectVersion/projectGeneration synchronously: at - // click time this.project is still the pre-undo content and the rebuild is - // async. Bumping the version then would make the Canvas cache its uid lookup - // from the stale view, and the async rebuild swaps in the restored view - // WITHOUT re-bumping the version -- leaving the version-keyed element cache - // stale relative to props.view, the transient inconsistency behind the - // dangling-ref undo crash (#817). The bump must land in the same - // notification as the content swap. - const engine = makeFakeEngine(); - const { config } = makeControllerConfig({ engine, format: 'protobuf', initialData: snap(1) }); - const controller = new ProjectController(config); - await controller.openInitialProject(); +describe('ProjectController stale tokens', () => { + it('a gesture planned before a failure is refused at enqueue', async () => { + const { controller, engine, errors } = await openController({ + applyPatchThrows: (_p, i) => (i === 0 ? new Error('boom') : undefined), + }); + const plannedUnder = controller.getSnapshot().token; + const plannedOn = view(controller); + await controller.enqueueViewEdit({ label: 'create', nextView: withElements(view(controller), aux(2, 'b')) }); + expect(controller.getSnapshot().token).toBe(plannedUnder + 1); + const renderedX: number[] = []; + controller.subscribe(() => { + renderedX.push(view(controller).elements.find((el) => el.uid === 1)!.x); + }); + const landed = await controller.enqueueViewEdit({ + label: 'move', + nextView: moved(plannedOn, 1, 20), + token: plannedUnder, + }); + expect(landed).toBe(false); + await controller.whenIdle(); + // Refused before it could render: the stale view never flashes. + expect(renderedX).not.toContain(plannedOn.elements.find((el) => el.uid === 1)!.x + 20); + expect(engine.appliedPatches).toHaveLength(0); + expect(errors.map((e) => e.message)).toEqual([ + 'boom', + 'move discarded: the project changed while it was being made', + ]); + await controller.dispose(); + }); - const view = controller.getView() as StockFlowView; - await controller.updateView({ ...view, zoom: 4 }, { recordHistory: true }); - const before = controller.getSnapshot(); + it('an edit with a next view planned while an undo is queued is refused quietly', async () => { + const { controller, engine, errors } = await openController(); + await controller.enqueueViewEdit({ label: 'move', nextView: moved(view(controller), 1, 10) }); + await controller.whenIdle(); + expect(controller.getSnapshot().canUndo).toBe(true); controller.undoRedo('undo'); - // Synchronously: the cursor moved (canRedo via the live method), but the - // published snapshot's version/generation are untouched -- no stale-content - // render is forced. - expect(controller.canRedo()).toBe(true); - const sync = controller.getSnapshot(); - expect(sync.projectVersion).toBe(before.projectVersion); - expect(sync.projectGeneration).toBe(before.projectGeneration); - - // After the async reopen installs the restored project, the bump lands. - await flushTimers(); - const after = controller.getSnapshot(); - expect(after.projectVersion).toBeGreaterThan(before.projectVersion); - expect(after.projectGeneration).toBe(before.projectGeneration + 1); + const seen: ProjectSnapshot[] = []; + controller.subscribe(() => { + seen.push(controller.getSnapshot()); + }); + // Planned now, on the pre-undo view: it could only be dropped once the undo + // lands, and that is no failure of the user's to report. + const stale = controller.enqueueViewEdit({ label: 'move', nextView: moved(view(controller), 1, 10) }); + expect(seen).toHaveLength(0); + expect(await stale).toBe(false); + await controller.whenIdle(); + expect(errors).toEqual([]); + // The undo reopened the same fake engine; only the first move reached it. + expect(engine.appliedPatches).toHaveLength(1); await controller.dispose(); }); - it('undo restoring a project lacking the viewed model resets navigation and bumps navResetSeq', async () => { - // Open a project that, on undo (reopen), serializes JSON WITHOUT the - // drilled-into child model. We simulate by: drill into 'child', then make - // the engine's reopen JSON lack 'child'. - let includeChild = true; - const engine = makeFakeEngine({ - json: () => - includeChild - ? validProjectJson({ - extraModels: [{ name: 'child', stocks: [], flows: [], auxiliaries: [], views: [{ elements: [] }] }], - }) - : validProjectJson(), + it('a view edit whose token moved before dequeue is dropped with the edits planned on it, and the token moves only once', async () => { + // No production path moves the token while a view edit stays queued (a + // truncation discards it, and an undo cannot be queued ahead of one), so the + // move is made by hand; this pins the dequeue check behind the enqueue check. + const gate = makeGate(); + const { controller, engine, errors } = await openController({ + applyPatchGate: (_p, i) => (i === 0 ? gate.wait() : Promise.resolve()), }); - const { config } = makeControllerConfig({ engine, format: 'protobuf', initialData: snap(1) }); - const controller = new ProjectController(config); - await controller.openInitialProject(); - - // Grow history so undo has somewhere to go. - await controller.updateProject(snap(2)); + void controller.enqueueModelEdit({ label: 'equation', buildPatch: () => ({ models: [] }) }); + const stale = controller.enqueueViewEdit({ label: 'move', nextView: moved(view(controller), 1, 10) }); + const plannedOnIt = controller.enqueueViewEdit({ label: 'move', nextView: moved(view(controller), 1, 10) }); + const tokenBefore = controller.getSnapshot().token; + (controller as unknown as { token: number }).token += 1; + gate.open(); + expect(await stale).toBe(false); + expect(await plannedOnIt).toBe(false); + await controller.whenIdle(); + expect(controller.getSnapshot().token).toBe(tokenBefore + 1); + expect(errors.map((e) => e.message)).toEqual([ + 'move discarded: the project changed while it was being made (1 later edit discarded)', + ]); + expect(engine.appliedPatches).toHaveLength(1); + await controller.dispose(); + }); - // Drill into the child model (present in the current project). - const outcome = controller.drillIntoModule( - 'child_module', - 'child', - new Set([1]), - { x: 0, y: 0, width: 100, height: 100 }, - 1, - ); - expect(outcome.restoredSelection).toEqual(new Set()); - expect(controller.getModelName()).toBe('child'); + it('a model-only edit planned before an undo lands is exempt: it derives its payload at dequeue and lands', async () => { + const { controller, engine, errors } = await openController(); + await controller.enqueueViewEdit({ label: 'move', nextView: moved(view(controller), 1, 10) }); + await controller.whenIdle(); + const tokenBefore = controller.getSnapshot().token; - const navSeqBefore = controller.getSnapshot().navResetSeq; - // Now undo reopens the engine, whose JSON no longer has 'child'. - includeChild = false; controller.undoRedo('undo'); - await flushTimers(); - - const s = controller.getSnapshot(); - expect(s.modelName).toBe('main'); - expect(s.modelStack).toHaveLength(0); - expect(s.navResetSeq).toBe(navSeqBefore + 1); + const modelOnly = controller.enqueueModelEdit({ + label: 'equation', + buildPatch: () => ({ + models: [{ name: 'main', ops: [{ type: 'upsertAux', payload: { aux: { name: 'a', equation: '5' } } }] }], + }), + }); + expect(await modelOnly).toBe(true); + await controller.whenIdle(); + expect(controller.getSnapshot().token).toBe(tokenBefore + 1); + expect(errors).toEqual([]); + expect(engine.appliedPatches.map((p) => p.models![0].ops[0].type)).toEqual(['upsertView', 'upsertAux']); await controller.dispose(); }); }); -describe('ProjectController save queue', () => { - it('queues exactly one flush when a save is requested during an in-flight save', async () => { - let resolveFirst: (v: number) => void = () => {}; - let callCount = 0; - const engine = makeFakeEngine(); - const save = rs.fn(async () => { - callCount++; - if (callCount === 1) { - return await new Promise((resolve) => { - resolveFirst = resolve; - }); - } - return callCount + 1; +describe('ProjectController undo/redo gating', () => { + it('undo is unavailable (snapshot and method) and refused while an edit is pending', async () => { + const gate = makeGate(); + const { controller, openedWith } = await openController({ + applyPatchGate: (_p, i) => (i === 1 ? gate.wait() : Promise.resolve()), }); - const { config } = makeControllerConfig({ engine, format: 'json', save }); - const controller = new ProjectController(config); - await controller.openInitialProject(); + await controller.enqueueViewEdit({ label: 'move', nextView: moved(view(controller), 1, 10) }); + expect(controller.getSnapshot().canUndo).toBe(true); - const first = controller.save(); - // Two concurrent saves while the first is in flight -> only one queued. - void controller.save(); - void controller.save(); - // Let the first save reach its awaited config.save() before resolving it. - await flushTimers(); - resolveFirst(2); - await first; - await flushTimers(); - - // First save + exactly one queued flush == 2 invocations, and the flush - // reads serverVersion at flush time: it carries the version the first - // save's success just acknowledged, not the one it was requested under. - expect(save).toHaveBeenCalledTimes(2); - expect(save).toHaveBeenNthCalledWith(1, expect.anything(), 1); - expect(save).toHaveBeenNthCalledWith(2, expect.anything(), 2); + const pending = controller.enqueueViewEdit({ label: 'move', nextView: moved(view(controller), 1, 10) }); + expect(controller.getSnapshot().canUndo).toBe(false); + expect(controller.canUndo()).toBe(false); + controller.undoRedo('undo'); + expect(controller.getSnapshot().undoRedoQueued).toBe(false); + gate.open(); + await pending; + await controller.whenIdle(); + expect(openedWith).toHaveLength(0); + expect(controller.getSnapshot().canUndo).toBe(true); await controller.dispose(); }); - it('releases inSave and still flushes the queued save when onSave throws', async () => { - let callCount = 0; - const engine = makeFakeEngine(); - const save = rs.fn(async () => { - callCount++; - if (callCount === 1) { - await Promise.resolve(); - throw new Error('network failure'); - } - return 5; + it('a queued undo blocks further undo and flags undoRedoQueued; landing restores, bumps the token, and resets viewports', async () => { + const original = validProjectJson({ + auxiliaries: [{ name: 'a', equation: '1' }], + mainViewElements: [{ type: 'aux', uid: 1, name: 'a', x: 0, y: 0 }], }); - const { config, errors } = makeControllerConfig({ engine, format: 'json', save }); + const project = statefulProject(original); + const engine = makeFakeEngine({ json: project.json, onApplyPatch: project.apply }); + // The reopened engine serializes the pre-edit project, as libsimlin does + // when it opens the history snapshot. + const reopened = makeFakeEngine({ json: () => original }); + const { config, openedWith } = makeControllerConfig({ engines: [engine, reopened], format: 'json' }); const controller = new ProjectController(config); await controller.openInitialProject(); + await controller.whenIdle(); + const openBytes = (controller as unknown as { projectHistory: Uint8Array[] }).projectHistory[0]; + await controller.enqueueViewEdit({ label: 'move', nextView: moved(view(controller), 1, 10) }); + controller.setViewport('main', { viewBox: { x: 77, y: 0, width: 800, height: 600 }, zoom: 3 }); + await controller.whenIdle(); + const tokenBefore = controller.getSnapshot().token; - const first = controller.save(); - void controller.save(); // queue one - await first; - await flushTimers(); + controller.undoRedo('undo'); + expect(controller.getSnapshot().undoRedoQueued).toBe(true); + expect(controller.getSnapshot().canUndo).toBe(false); + expect(controller.getSnapshot().canRedo).toBe(false); + await controller.whenIdle(); - expect(callCount).toBe(2); // thrown save did not strand the queue - expect(errors.some((e) => e.message.includes('network failure'))).toBe(true); + expect(openedWith[openedWith.length - 1]).toEqual(openBytes); + const s = controller.getSnapshot(); + expect(s.undoRedoQueued).toBe(false); + expect(s.token).toBe(tokenBefore + 1); + expect(s.canRedo).toBe(true); + // The live viewport map was reset: the rendered view is the restored one. + expect(view(controller).zoom).toBe(1); + // The reopen installs the new engine and releases the old one. + expect(engine.disposeCount).toBe(1); await controller.dispose(); }); - it('saves without stdlib models while the display project includes them', async () => { - // Invariant: the display/rebuild path serializes with includeStdlib=true so - // the editor can navigate into stdlib modules, but the SAVE path serializes - // with includeStdlib=false so stdlib definitions are never persisted. Record - // the includeStdlib flag the controller passes on each serializeJson call. - const calls: boolean[] = []; - const engine = makeFakeEngine({ - json: (includeStdlib) => { - calls.push(includeStdlib); - return validProjectJson({ includeStdlib }); - }, + it('an undo submitted together with a draft queues behind the draft edit and undoes it', async () => { + const gate = makeGate(); + const { controller, errors, openedWith } = await openController({ + applyPatchGate: (_p, i) => (i === 1 ? gate.wait() : Promise.resolve()), }); - const saved: string[] = []; - const { config } = makeControllerConfig({ - engine, - format: 'json', - save: async (project) => { - saved.push(project.data as string); - return 2; - }, + await controller.enqueueViewEdit({ label: 'move', nextView: moved(view(controller), 1, 10) }); + const afterMove = (controller as unknown as { projectHistory: Uint8Array[] }).projectHistory[0]; + const draft = controller.enqueueModelEdit({ + label: 'units', + buildPatch: () => ({ + models: [{ name: 'main', ops: [{ type: 'upsertAux', payload: { aux: { name: 'a', equation: '7' } } }] }], + }), }); - const controller = new ProjectController(config); - await controller.openInitialProject(); + // A plain undo refuses while the draft's edit is queued... + controller.undoRedo('undo'); + expect(controller.getSnapshot().undoRedoQueued).toBe(false); + // ...one submitted with it queues behind it. + controller.undoRedo('undo', { afterQueuedEdits: true }); + expect(controller.getSnapshot().undoRedoQueued).toBe(true); + gate.open(); + expect(await draft).toBe(true); + await controller.whenIdle(); + // The draft landed and was undone: the restored snapshot is the one before it. + expect(openedWith).toEqual([afterMove]); + expect(controller.getSnapshot().canRedo).toBe(true); + expect(errors).toEqual([]); + await controller.dispose(); + }); - // The display project (rebuilt via the includeStdlib=true path) carries the - // stdlib model so the editor can drill into it. - const displayProject = controller.getSnapshot().project; - expect(displayProject?.models.has(FAKE_STDLIB_MODEL_NAME)).toBe(true); - expect([...(displayProject?.models.keys() ?? [])].some(isStdlibModel)).toBe(true); + it('a failing draft edit discards the undo queued behind it, so no older edit is undone', async () => { + const { controller, errors, openedWith } = await openController({ + applyPatchThrows: (_p, i) => (i === 1 ? new Error('rejected') : undefined), + }); + await controller.enqueueViewEdit({ label: 'move', nextView: moved(view(controller), 1, 10) }); + const draft = controller.enqueueModelEdit({ label: 'units', buildPatch: () => ({ models: [] }) }); + controller.undoRedo('undo', { afterQueuedEdits: true }); + expect(controller.getSnapshot().undoRedoQueued).toBe(true); + expect(await draft).toBe(false); + await controller.whenIdle(); + expect(openedWith).toHaveLength(0); + expect(errors.map((e) => e.message)).toEqual(['rejected']); + expect(controller.getSnapshot().canUndo).toBe(true); + await controller.dispose(); + }); - await controller.save(); + it('afterQueuedEdits still requires history in its own direction', async () => { + const { controller } = await openController(); + // No history at all. + controller.undoRedo('undo', { afterQueuedEdits: true }); + expect(controller.getSnapshot().undoRedoQueued).toBe(false); + await controller.enqueueViewEdit({ label: 'move', nextView: moved(view(controller), 1, 10) }); + await controller.whenIdle(); + // History to undo, none to redo. + controller.undoRedo('redo', { afterQueuedEdits: true }); + expect(controller.getSnapshot().undoRedoQueued).toBe(false); + controller.undoRedo('undo', { afterQueuedEdits: true }); + expect(controller.getSnapshot().undoRedoQueued).toBe(true); + await controller.whenIdle(); + await controller.dispose(); + }); - // The save path serialized with includeStdlib=false, so its payload omits - // the stdlib model -- it is never persisted. - expect(saved).toHaveLength(1); - const savedModels = (JSON.parse(saved[0]) as { models: Array<{ name: string }> }).models; - expect(savedModels.some((m) => isStdlibModel(m.name))).toBe(false); - // And the controller did make at least one display-path (true) call and the - // save-path (false) call. - expect(calls).toContain(true); - expect(calls).toContain(false); + it('a second undo is refused while one is queued, with or without afterQueuedEdits', async () => { + const { controller, openedWith } = await openController(); + await controller.enqueueViewEdit({ label: 'move', nextView: moved(view(controller), 1, 10) }); + await controller.enqueueViewEdit({ label: 'move', nextView: moved(view(controller), 1, 10) }); + await controller.whenIdle(); + controller.undoRedo('undo'); + controller.undoRedo('undo'); + controller.undoRedo('undo', { afterQueuedEdits: true }); + await controller.whenIdle(); + expect(openedWith).toHaveLength(1); + expect((controller as unknown as { projectOffset: number }).projectOffset).toBe(1); await controller.dispose(); }); -}); -describe('ProjectController server-version bookkeeping (#958)', () => { - // A serialized snapshot distinct per i (two bytes so i can exceed 255's - // single-byte space and never collide with the fake engine's own counter). - function editSnap(i: number): Uint8Array { - return new Uint8Array([1, i]); - } + it('an edit landing after an undo discards the redo branch', async () => { + const { controller, openedWith } = await openController(); + await controller.enqueueViewEdit({ label: 'move', nextView: moved(view(controller), 1, 10) }); + await controller.enqueueViewEdit({ label: 'move', nextView: moved(view(controller), 1, 10) }); + await controller.whenIdle(); + controller.undoRedo('undo'); + await controller.whenIdle(); + expect(controller.getSnapshot().canRedo).toBe(true); + await controller.enqueueViewEdit({ label: 'move', nextView: moved(view(controller), 1, 30) }); + await controller.whenIdle(); + expect(controller.getSnapshot().canRedo).toBe(false); + const opens = openedWith.length; + controller.undoRedo('redo'); + await controller.whenIdle(); + expect(openedWith).toHaveLength(opens); + // The history is the open, the first move and the new move. + expect((controller as unknown as { projectHistory: unknown[] }).projectHistory).toHaveLength(3); + await controller.dispose(); + }); - it('keeps sending the last server-acknowledged version after 100+ unsaved content edits', async () => { - // The drift bug: projectVersion is ALSO the fractional render-cache key - // (+0.01 per content edit), and the save path derived its integer version - // from it. After ~100 edits with no successful save (offline stretch, - // expired session), toInt(projectVersion) crossed to the NEXT integer and - // the save carried a version the server never issued -- a bogus 409 at - // best, a silent overwrite of another session's committed save at worst. + it('a failed reopen keeps the current engine and history cursor', async () => { const engine = makeFakeEngine(); - const sentVersions: number[] = []; - const { config } = makeControllerConfig({ - engine, - format: 'protobuf', - initialData: snap(1), - initialVersion: 5, - save: async (_project, currVersion) => { - sentVersions.push(currVersion); - // Resolved-undefined is the host's "save failed, retry with the next - // edit" signal (see hosted-web-editor-core saveProject). - return undefined; + let opens = 0; + const { config, errors } = makeControllerConfig({ engine, format: 'json' }); + const controller = new ProjectController({ + ...config, + openProtobuf: async () => { + opens++; + throw new Error('reopen failed'); }, }); + await controller.openInitialProject(); + await controller.enqueueViewEdit({ label: 'move', nextView: moved(controller.getView()!, 1, 10) }); + await controller.whenIdle(); + controller.undoRedo('undo'); + await controller.whenIdle(); + expect(opens).toBe(1); + expect(errors.map((e) => e.message)).toEqual(['opening the project in the engine failed: reopen failed']); + expect(controller.getSnapshot().canUndo).toBe(true); + expect(engine.disposeCount).toBe(0); + await controller.dispose(); + }); + + it('undo restoring a project lacking the viewed model resets navigation and bumps navResetSeq', async () => { + let includeChild = true; + const engine = makeFakeEngine({ + json: () => + includeChild + ? validProjectJson({ + extraModels: [{ name: 'child', stocks: [], flows: [], auxiliaries: [], views: [{ elements: [] }] }], + }) + : validProjectJson(), + }); + const { config } = makeControllerConfig({ engine, format: 'json' }); const controller = new ProjectController(config); await controller.openInitialProject(); + await controller.enqueueModelEdit({ label: 'edit', buildPatch: () => ({ models: [] }) }); + await controller.whenIdle(); + expect( + controller.drillIntoModule('m', 'child', new Set([1]), { x: 0, y: 0, width: 1, height: 1 }, 1).restoredSelection, + ).toEqual(new Set()); + const navSeqBefore = controller.getSnapshot().navResetSeq; + includeChild = false; + controller.undoRedo('undo'); + await controller.whenIdle(); + const s = controller.getSnapshot(); + expect(s.modelName).toBe('main'); + expect(s.modelStack).toHaveLength(0); + expect(s.navResetSeq).toBe(navSeqBefore + 1); + await controller.dispose(); + }); +}); + +describe('ProjectController maintenance', () => { + function countBefore(calls: readonly string[], call: string, marker: string): number { + const end = calls.indexOf(marker); + return (end === -1 ? calls : calls.slice(0, end)).filter((c) => c === call).length; + } + + it('a burst of edits costs one save and one sim run', async () => { + const gate = makeGate(); + const { controller, engine, saves } = await openController({ + applyPatchGate: (_p, i) => (i === 0 ? gate.wait() : Promise.resolve()), + }); + const runsBefore = engine.runCalls.length; + // Creates, since a geometry-only edit requests no sim run at all. + for (let i = 0; i < 3; i++) { + void controller.enqueueViewEdit({ + label: 'create', + nextView: withElements(view(controller), aux(2 + i, `n${i}`)), + }); + } + gate.open(); + await controller.whenIdle(); + expect(saves).toHaveLength(1); + expect(engine.runCalls.length - runsBefore).toBe(1); + // Nothing ran between the queued edits. + expect( + countBefore(engine.calls.slice(engine.calls.indexOf('applyPatch')), 'applyPatch', 'serializeJson:save'), + ).toBe(3); + await controller.dispose(); + }); - for (let i = 0; i < 110; i++) { - await controller.updateProject(editSnap(i)); + it(`maintenance runs after ${MaintenanceEditBound} consecutive edit items even while more are queued`, async () => { + const { controller, engine, saves } = await openController(); + const start = engine.calls.length; + // Creates, so every maintenance kind is pending at the bound (a geometry-only + // edit requests only the save). + for (let i = 0; i < MaintenanceEditBound + 2; i++) { + void controller.enqueueViewEdit({ + label: 'create', + nextView: withElements(view(controller), aux(2 + i, `n${i}`)), + }); } - await flushTimers(); + await controller.whenIdle(); + const calls = engine.calls.slice(start); + expect(countBefore(calls, 'applyPatch', 'serializeJson:save')).toBe(MaintenanceEditBound); + // One save at the bound, one after the burst drains. + expect(saves).toHaveLength(2); + // The bound runs every pending kind, not just the first. + const patchAt = calls.flatMap((c, i) => (c === 'applyPatch' ? [i] : [])); + const atBound = calls.slice(patchAt[MaintenanceEditBound - 1] + 1, patchAt[MaintenanceEditBound]); + for (const call of ['serializeJson:save', 'getErrors', 'getIncomingLinks', 'run']) { + expect(atBound).toContain(call); + } + await controller.dispose(); + }); - expect(sentVersions.length).toBeGreaterThan(0); - // Every attempt carries the version the server actually holds. - expect(sentVersions.every((v) => v === 5)).toBe(true); - expect(controller.getSnapshot().serverVersion).toBe(5); + it('a landed edit refreshes errors and connector dependencies', async () => { + let errorList: ErrorDetail[] = []; + const { controller, engine } = await openController({ + errors: () => errorList, + onApplyPatch: () => { + errorList = [ + { modelName: 'main', variableName: 'a', kind: SimlinErrorKind.Variable, code: 1 } as unknown as ErrorDetail, + ]; + }, + }); + expect(controller.getSnapshot().cachedErrors.varErrors.has('a')).toBe(false); + await controller.enqueueModelEdit({ + label: 'equation', + buildPatch: () => ({ + models: [{ name: 'main', ops: [{ type: 'upsertAux', payload: { aux: { name: 'a', equation: '2' } } }] }], + }), + }); + await controller.whenIdle(); + expect(controller.getSnapshot().cachedErrors.varErrors.has('a')).toBe(true); + expect(engine.calls.lastIndexOf('getIncomingLinks')).toBeGreaterThan(engine.calls.lastIndexOf('applyPatch')); await controller.dispose(); }); - it('a successful save updates serverVersion and later saves send the returned value', async () => { - const engine = makeFakeEngine(); - const sentVersions: number[] = []; - let nextResponse: number | undefined = 9; - const { config } = makeControllerConfig({ - engine, - format: 'protobuf', - initialData: snap(1), - initialVersion: 5, - save: async (_project, currVersion) => { - sentVersions.push(currVersion); - const response = nextResponse; - nextResponse = undefined; - return response; + it('maintenance runs once continuous edit work passes the time bound', async () => { + let clock = 0; + const { controller, engine } = await openController( + { + applyPatchGate: async (_p, i) => { + if (i === 0) { + clock += 6000; + } + }, }, + { now: () => clock }, + ); + const start = engine.calls.length; + for (let i = 0; i < 3; i++) { + void controller.enqueueViewEdit({ label: 'move', nextView: moved(view(controller), 1, 10) }); + } + await controller.whenIdle(); + expect(countBefore(engine.calls.slice(start), 'applyPatch', 'serializeJson:save')).toBe(1); + await controller.dispose(); + }); + + it('a save serializes the committed state: never while an edit is in flight', async () => { + const gate = makeGate(); + const { controller, engine, saves } = await openController({ applyPatchGate: () => gate.wait() }); + const landed = controller.enqueueViewEdit({ + label: 'create', + nextView: withElements(view(controller), aux(2, 'b')), + }); + controller.requestSave(); + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(engine.calls.filter((c) => c === 'serializeJson:save')).toHaveLength(0); + gate.open(); + await landed; + await controller.whenIdle(); + expect(saves).toHaveLength(1); + const saved = JSON.parse(saves[0].project.data as string) as JsonProject; + const savedUids = (saved.models[0].views![0] as { elements: Array<{ uid: number }> }).elements.map((el) => el.uid); + expect(savedUids.sort()).toEqual([1, 2]); + await controller.dispose(); + }); + + it('every engine call runs through one executor: no two ever overlap', async () => { + const { controller, engine } = await openController(); + for (let i = 0; i < 4; i++) { + void controller.enqueueViewEdit({ label: 'move', nextView: moved(view(controller), 1, 10) }); + controller.setViewport('main', { viewBox: { x: i, y: 0, width: 800, height: 600 }, zoom: 1 }); + void controller.query((e) => e.getErrors()); + controller.requestSave(); + } + controller.drillIntoModule('m', 'main', new Set(), { x: 0, y: 0, width: 1, height: 1 }, 1); + await controller.whenIdle(); + expect(engine.maxConcurrentCalls).toBe(1); + await controller.dispose(); + }); +}); + +describe('ProjectController viewport items', () => { + it('render at once, persist once with no history and no save', async () => { + const { controller, engine, saves } = await openController(); + controller.setViewport('main', { viewBox: { x: 5, y: 6, width: 800, height: 600 }, zoom: 2 }); + expect(view(controller).zoom).toBe(2); + await controller.whenIdle(); + expect(engine.appliedPatches).toHaveLength(1); + expect(controller.getSnapshot().canUndo).toBe(false); + expect(saves).toHaveLength(0); + await controller.dispose(); + }); + + it('coalesce: two settles while an item runs persist once, with the latest viewport', async () => { + const gate = makeGate(); + const { controller, engine } = await openController({ + applyPatchGate: (_p, i) => (i === 0 ? gate.wait() : Promise.resolve()), + }); + void controller.enqueueViewEdit({ label: 'move', nextView: moved(view(controller), 1, 10) }); + controller.setViewport('main', { viewBox: { x: 1, y: 0, width: 800, height: 600 }, zoom: 1.5 }); + controller.setViewport('main', { viewBox: { x: 2, y: 0, width: 800, height: 600 }, zoom: 2.5 }); + gate.open(); + await controller.whenIdle(); + expect(engine.appliedPatches).toHaveLength(2); + expect(viewOps(engine.appliedPatches[1])[0].payload.view.zoom).toBe(2.5); + await controller.dispose(); + }); + + it('a rejected viewport persist renders the committed viewport again, and an edit planned on it upserts the committed one', async () => { + const { controller, engine, errors } = await openController({ + applyPatchThrows: (_p, i) => (i === 0 ? new Error('view rejected') : undefined), }); + controller.setViewport('main', { viewBox: { x: 9, y: 9, width: 800, height: 600 }, zoom: 2 }); + expect(view(controller).zoom).toBe(2); + // Planned while the live viewport rendered. + const move = controller.enqueueViewEdit({ label: 'move', nextView: moved(view(controller), 1, 10) }); + expect(await move).toBe(true); + await controller.whenIdle(); + expect(errors.map((e) => e.message)).toEqual(['view rejected']); + expect(view(controller).zoom).toBe(1); + expect(engine.appliedPatches).toHaveLength(1); + expect(viewOps(engine.appliedPatches[0])[0].payload.view.zoom).toBe(1); + await controller.dispose(); + }); + + it('a rejected viewport persist keeps a newer viewport set while it ran, and that one persists', async () => { + const gate = makeGate(); + const { controller, engine } = await openController({ + applyPatchGate: (_p, i) => (i === 0 ? gate.wait() : Promise.resolve()), + applyPatchThrows: (_p, i) => (i === 0 ? new Error('view rejected') : undefined), + }); + controller.setViewport('main', { viewBox: { x: 1, y: 0, width: 800, height: 600 }, zoom: 2 }); + await new Promise((resolve) => setTimeout(resolve, 10)); + controller.setViewport('main', { viewBox: { x: 2, y: 0, width: 800, height: 600 }, zoom: 3 }); + gate.open(); + await controller.whenIdle(); + expect(view(controller).zoom).toBe(3); + expect(engine.appliedPatches).toHaveLength(1); + expect(viewOps(engine.appliedPatches[0])[0].payload.view.zoom).toBe(3); + await controller.dispose(); + }); + + it('never carry optimistic elements of an edit queued behind them', async () => { + const gate = makeGate(); + const { controller, engine } = await openController({ + applyPatchGate: (_p, i) => (i === 0 ? gate.wait() : Promise.resolve()), + }); + void controller.enqueueViewEdit({ label: 'move', nextView: moved(view(controller), 1, 10) }); + controller.setViewport('main', { viewBox: { x: 3, y: 0, width: 800, height: 600 }, zoom: 1.25 }); + void controller.enqueueViewEdit({ label: 'create', nextView: withElements(view(controller), aux(2, 'b')) }); + expect(uids(view(controller))).toEqual([1, 2]); + gate.open(); + await controller.whenIdle(); + const [, viewportPatch, createPatch] = engine.appliedPatches; + expect(viewOps(viewportPatch)[0].payload.view.elements.map((el) => el.uid)).toEqual([1]); + expect( + viewOps(createPatch)[0] + .payload.view.elements.map((el) => el.uid) + .sort(), + ).toEqual([1, 2]); + await controller.dispose(); + }); +}); + +describe('ProjectController pending renames', () => { + const json = validProjectJson({ + auxiliaries: [ + { name: 'a', equation: '1' }, + { name: 'b', equation: 'a' }, + ], + mainViewElements: [ + { type: 'aux', uid: 1, name: 'a', x: 0, y: 0 }, + { type: 'aux', uid: 2, name: 'b', x: 50, y: 0 }, + { type: 'link', uid: 3, fromUid: 1, toUid: 2 }, + ], + extraModels: [{ name: 'child', stocks: [], flows: [], auxiliaries: [], views: [{ elements: [] }] }], + }); + + function renamed(v: StockFlowView, uid: number, name: string): StockFlowView { + return { + ...v, + elements: v.elements.map((el) => + el.uid === uid && el.type === 'aux' ? { ...el, name, ident: canonicalize(name) } : el, + ), + }; + } + + it("the rendered model names a pending rename's variable by its new ident, with its committed content, errors and connectors", async () => { + const gate = makeGate(); + const engine = makeFakeEngine({ + json, + errors: [ + { + modelName: 'main', + variableName: 'a', + kind: SimlinErrorKind.Units, + code: 1, + unitErrorKind: 1, + } as unknown as ErrorDetail, + ], + incomingLinks: { a: [], b: ['a'] }, + applyPatchGate: () => gate.wait(), + applyPatchThrows: true, + }); + const { config } = makeControllerConfig({ engine, format: 'json' }); + const controller = new ProjectController(config); + await controller.openInitialProject(); + await controller.whenIdle(); + const committedA = controller.getModel()!.variables.get('a')!; + expect(committedA.unitErrors).toBeDefined(); + + const renaming = controller.enqueueViewEdit({ label: 'rename', nextView: renamed(view(controller), 1, 'Alpha') }); + const model = controller.getModel()!; + expect(model.variables.has('a')).toBe(false); + const alpha = model.variables.get('alpha')!; + expect(alpha).toMatchObject({ ident: 'alpha', rawName: 'Alpha', equation: committedA.equation }); + expect(alpha.unitErrors).toEqual(committedA.unitErrors); + // b's dependency on a is the link from the renamed element: no drift. + expect(model.variables.get('b')!.connectorErrors).toBeUndefined(); + // Renaming back is free while the rename is pending; the new name is taken. + expect(controller.nameError('A', 'alpha')).toBeUndefined(); + expect(controller.nameError('alpha', undefined)).toBeDefined(); + // Every model renders its pending renames, not just the one being viewed. + controller.drillIntoModule('m', 'child', new Set(), { x: 0, y: 0, width: 1, height: 1 }, 1); + expect(controller.getSnapshot().project!.models.get('main')!.variables.has('alpha')).toBe(true); + controller.navigateBack(); + + // The rename fails: the committed ident renders again. + gate.open(); + expect(await renaming).toBe(false); + await controller.whenIdle(); + expect(controller.getModel()!.variables.has('a')).toBe(true); + expect(controller.getModel()!.variables.has('alpha')).toBe(false); + await controller.dispose(); + }); +}); + +describe('ProjectController per-model rendering during navigation', () => { + const childJson = validProjectJson({ + auxiliaries: [{ name: 'a', equation: '1' }], + mainViewElements: [{ type: 'aux', uid: 1, name: 'a', x: 0, y: 0 }], + extraModels: [ + { + name: 'child', + stocks: [], + flows: [], + auxiliaries: [{ name: 'c', equation: '1' }], + views: [ + { + elements: [{ type: 'aux', uid: 1, name: 'c', x: 0, y: 0 }], + viewBox: { x: 5, y: 6, width: 300, height: 200 }, + zoom: 2, + }, + ], + }, + ], + }); + + async function openWithChild(engineOptions: FakeEngineOptions = {}) { + const project = statefulProject(childJson); + const engine = makeFakeEngine({ json: project.json, onApplyPatch: project.apply, ...engineOptions }); + const { config } = makeControllerConfig({ engine, format: 'json' }); const controller = new ProjectController(config); await controller.openInitialProject(); + await controller.whenIdle(); + return { controller, engine }; + } + + it('a pending main edit keeps rendering main while the child is viewed; each edit targets its own model', async () => { + const gate = makeGate(); + const { controller, engine } = await openWithChild({ + applyPatchGate: (_p, i) => (i === 0 ? gate.wait() : Promise.resolve()), + }); + void controller.enqueueViewEdit({ label: 'create', nextView: withElements(view(controller), aux(2, 'b')) }); + const main = view(controller); + controller.drillIntoModule('m', 'child', new Set(), main.viewBox, main.zoom); + expect(controller.getSnapshot().modelName).toBe('child'); + expect(uids(view(controller, 'main'))).toEqual([1, 2]); + expect(uids(view(controller, 'child'))).toEqual([1]); + + void controller.enqueueViewEdit({ + label: 'create', + nextView: withElements(view(controller, 'child'), aux(3, 'd')), + }); + expect(uids(view(controller, 'child'))).toEqual([1, 3]); + expect(uids(view(controller, 'main'))).toEqual([1, 2]); + gate.open(); + await controller.whenIdle(); + expect(engine.appliedPatches.map((p) => p.models![0].name)).toEqual(['main', 'child']); + expect( + viewOps(engine.appliedPatches[1], 'child')[0] + .payload.view.elements.map((el) => el.uid) + .sort(), + ).toEqual([1, 3]); + await controller.dispose(); + }); - await controller.save(); - expect(sentVersions).toEqual([5]); + it("navigating back restores the parent's selection and viewport through a viewport item", async () => { + const { controller, engine } = await openWithChild(); + const parentSelection = new Set([42]); + controller.drillIntoModule('m', 'child', parentSelection, { x: 11, y: 12, width: 640, height: 480 }, 1.5); + const back = controller.navigateBack(); + expect(back.restoredSelection).toEqual(parentSelection); + expect(view(controller, 'main')).toMatchObject({ viewBox: { x: 11, y: 12, width: 640, height: 480 }, zoom: 1.5 }); + await controller.whenIdle(); + expect(engine.appliedPatches).toHaveLength(1); + expect(engine.appliedPatches[0].models![0].name).toBe('main'); + expect(viewOps(engine.appliedPatches[0])[0].payload.view.zoom).toBe(1.5); + await controller.dispose(); + }); + + it('refuses to drill into a missing model', async () => { + const { controller } = await openWithChild(); + expect( + controller.drillIntoModule('m', 'nope', new Set(), { x: 0, y: 0, width: 1, height: 1 }, 1).restoredSelection, + ).toBeUndefined(); + expect(controller.getModelName()).toBe('main'); + expect(controller.navigateBack().restoredSelection).toBeUndefined(); + expect(controller.navigateToLevel(0).restoredSelection).toBeUndefined(); + await controller.dispose(); + }); +}); + +describe('ProjectController saves and server-version bookkeeping (#958)', () => { + it('keeps sending the last server-acknowledged version across many edits whose saves fail', async () => { + const sent: number[] = []; + const { controller } = await openController( + {}, + { + initialVersion: 5, + save: async (_project, currVersion) => { + sent.push(currVersion); + return undefined; + }, + }, + ); + for (let i = 0; i < 12; i++) { + await controller.enqueueViewEdit({ label: 'move', nextView: moved(view(controller), 1, 1) }); + await controller.whenIdle(); + } + expect(sent.length).toBeGreaterThan(0); + expect(sent.every((v) => v === 5)).toBe(true); + expect(controller.getSnapshot().serverVersion).toBe(5); + await controller.dispose(); + }); + + it('a successful save advances serverVersion and later saves send it', async () => { + const sent: number[] = []; + const responses = [9, undefined]; + const { controller } = await openController( + {}, + { + initialVersion: 5, + save: async (_project, currVersion) => { + sent.push(currVersion); + return responses.shift(); + }, + }, + ); + controller.requestSave(); + await controller.whenIdle(); expect(controller.getSnapshot().serverVersion).toBe(9); + controller.requestSave(); + await controller.whenIdle(); + expect(sent).toEqual([5, 9]); + await controller.dispose(); + }); - await controller.save(); - expect(sentVersions).toEqual([5, 9]); + it('a save requested while one is in flight queues exactly one flush of the latest state', async () => { + const hostGate = makeGate(); + const saved: Array<{ version: number; uids: number[] }> = []; + let first = true; + const { controller } = await openController( + {}, + { + save: async (project, currVersion) => { + const data = JSON.parse(project.data as string) as JsonProject; + saved.push({ + version: currVersion, + uids: (data.models[0].views![0] as { elements: Array<{ uid: number }> }).elements + .map((el) => el.uid) + .sort(), + }); + if (first) { + first = false; + await hostGate.wait(); + } + return currVersion + 1; + }, + }, + ); + controller.requestSave(); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(saved).toHaveLength(1); + // Two more edits land while the first save is in flight: each requests a + // save; only the latest serialization is flushed, once. + await controller.enqueueViewEdit({ label: 'create', nextView: withElements(view(controller), aux(2, 'b')) }); + await new Promise((resolve) => setTimeout(resolve, 20)); + await controller.enqueueViewEdit({ label: 'create', nextView: withElements(view(controller), aux(3, 'c')) }); + await new Promise((resolve) => setTimeout(resolve, 20)); + hostGate.open(); + await controller.whenIdle(); + expect(saved).toEqual([ + { version: 1, uids: [1] }, + { version: 2, uids: [1, 2, 3] }, + ]); await controller.dispose(); }); - it('view-only updates bump the render key but never the server version', async () => { - const engine = makeFakeEngine(); - const sentVersions: number[] = []; + it('a thrown host save releases the latch and the queued flush still goes out', async () => { + let calls = 0; + const { controller, errors } = await openController( + {}, + { + save: async () => { + calls++; + if (calls === 1) { + await new Promise((resolve) => setTimeout(resolve, 10)); + throw new Error('network failure'); + } + return 5; + }, + }, + ); + controller.requestSave(); + await new Promise((resolve) => setTimeout(resolve, 5)); + await controller.enqueueViewEdit({ label: 'move', nextView: moved(view(controller), 1, 1) }); + await controller.whenIdle(); + expect(calls).toBe(2); + expect(errors.map((e) => e.message)).toEqual(['network failure']); + await controller.dispose(); + }); + + it('saves without stdlib models while the rendered project includes them', async () => { + const engine = makeFakeEngine({ json: (includeStdlib) => validProjectJson({ includeStdlib }) }); + const saved: string[] = []; const { config } = makeControllerConfig({ engine, - format: 'protobuf', - initialData: snap(1), - initialVersion: 5, - save: async (_project, currVersion) => { - sentVersions.push(currVersion); - return undefined; + format: 'json', + save: async (project) => { + saved.push(project.data as string); + return 2; }, }); const controller = new ProjectController(config); await controller.openInitialProject(); + expect([...(controller.getSnapshot().project?.models.keys() ?? [])].some(isStdlibModel)).toBe(true); + expect(controller.getSnapshot().project?.models.has(FAKE_STDLIB_MODEL_NAME)).toBe(true); + controller.requestSave(); + await controller.whenIdle(); + expect(saved).toHaveLength(1); + expect( + (JSON.parse(saved[0]) as { models: Array<{ name: string }> }).models.some((m) => isStdlibModel(m.name)), + ).toBe(false); + await controller.dispose(); + }); - const view = controller.getView() as StockFlowView; + it('the viewport stream advances the render key but never saves or moves the server version', async () => { + const { controller, saves } = await openController({}, { initialVersion: 5 }); const before = controller.getSnapshot(); - for (let i = 0; i < 20; i++) { - await controller.queueViewUpdate({ ...view, zoom: 1 + i }); + for (let i = 0; i < 5; i++) { + controller.setViewport('main', { viewBox: { x: i, y: 0, width: 800, height: 600 }, zoom: 1 + i }); + await controller.whenIdle(); } - await flushTimers(); - const after = controller.getSnapshot(); - // The render-cache key advanced (Canvas invalidation) ... expect(after.projectVersion).toBeGreaterThan(before.projectVersion); - // ... but the viewport stream neither saved nor moved the server version. - expect(sentVersions).toHaveLength(0); + expect(saves).toHaveLength(0); expect(after.serverVersion).toBe(5); await controller.dispose(); }); - it('a successful save no longer resets the fractional render-cache key', async () => { - // projectVersion is now PURELY the render-cache key; the server ack lands - // in serverVersion only. (Canvas's single-slot equality cache would even - // tolerate a save ack rewriting the key -- the justification is purity of - // purpose: one counter, one meaning, so drift bugs like #958 have nowhere - // to start.) - const engine = makeFakeEngine(); - const { config } = makeControllerConfig({ - engine, - format: 'protobuf', - initialData: snap(1), - initialVersion: 5, - save: async () => 9, - }); - const controller = new ProjectController(config); - await controller.openInitialProject(); - await controller.updateProject(editSnap(0), { scheduleSave: false }); - - const before = controller.getSnapshot().projectVersion; - await controller.save(); - - expect(controller.getSnapshot().serverVersion).toBe(9); - expect(controller.getSnapshot().projectVersion).toBe(before); + it('a save acknowledgment republishes without replacing the rendered project (no Canvas re-cache)', async () => { + const { controller } = await openController({}, { initialVersion: 5, save: async () => 9 }); + const before = controller.getSnapshot(); + controller.requestSave(); + await controller.whenIdle(); + const after = controller.getSnapshot(); + expect(after.serverVersion).toBe(9); + expect(after.project).toBe(before.project); + expect(after.projectVersion).toBe(before.projectVersion); await controller.dispose(); }); it('undo does not rewind the server-acknowledged version', async () => { - // Undo restores older CONTENT, but the server still holds the version it - // last acknowledged -- the post-undo save must carry that version, not a - // value rewound (or re-derived) from restored state. - const engine = makeFakeEngine(); - const sentVersions: number[] = []; + const sent: number[] = []; let acked = false; - const { config } = makeControllerConfig({ - engine, - format: 'protobuf', - initialData: snap(1), - initialVersion: 5, - save: async (_project, currVersion) => { - sentVersions.push(currVersion); - if (!acked) { - acked = true; - return 6; - } - return undefined; + const { controller } = await openController( + {}, + { + initialVersion: 5, + save: async (_project, currVersion) => { + sent.push(currVersion); + if (!acked) { + acked = true; + return 6; + } + return undefined; + }, }, - }); - const controller = new ProjectController(config); - await controller.openInitialProject(); - - // First edit's autosave succeeds: the server moves to 6. - await controller.updateProject(editSnap(0)); - await flushTimers(); + ); + await controller.enqueueViewEdit({ label: 'move', nextView: moved(view(controller), 1, 1) }); + await controller.whenIdle(); expect(controller.getSnapshot().serverVersion).toBe(6); - - // A second edit (its save fails), then undo it. The undo-scheduled save - // must still send 6. - await controller.updateProject(editSnap(1)); - await flushTimers(); + await controller.enqueueViewEdit({ label: 'move', nextView: moved(view(controller), 1, 1) }); + await controller.whenIdle(); controller.undoRedo('undo'); - await flushTimers(); - + await controller.whenIdle(); expect(controller.getSnapshot().serverVersion).toBe(6); - expect(sentVersions[sentVersions.length - 1]).toBe(6); + expect(sent[sent.length - 1]).toBe(6); await controller.dispose(); }); }); @@ -838,44 +1845,330 @@ describe('ProjectController server-version bookkeeping (#958)', () => { describe('ProjectController sim runs', () => { it('attaches sim data to main and falls back to non-LTM on first-run failure', async () => { let firstRun = true; - const engine = makeFakeEngine({ - simulatable: true, + const { controller, engine, errors } = await openController({ run: () => { if (firstRun) { firstRun = false; throw new Error('LTM blew up'); } - return fakeRun({ time: [0, 1], output: [10, 20] }); + return fakeRun({ time: [0, 1], a: [10, 20] }); }, }); - const { config, errors } = makeControllerConfig({ engine }); - const controller = new ProjectController(config); - await controller.openInitialProject(); - - await controller.loadSim(); - - expect(errors.some((e) => e.message.includes('LTM blew up'))).toBe(true); - // Two run attempts: the failing first and the LTM-disabled retry. + expect(errors.map((e) => e.message)).toEqual(['LTM blew up']); expect(engine.runCalls).toHaveLength(2); expect(engine.runCalls[1].analyzeLtm).toBe(false); - expect(controller.getSnapshot().data.has('output')).toBe(true); + expect(controller.getSnapshot().data.has('a')).toBe(true); + expect(controller.getSnapshot().project?.models.get('main')?.variables.get('a')?.data).toBeDefined(); await controller.dispose(); }); - it('does nothing destructive when the project is not simulatable', async () => { - const engine = makeFakeEngine({ simulatable: false }); - const { config } = makeControllerConfig({ engine }); - const controller = new ProjectController(config); - await controller.openInitialProject(); - - await controller.loadSim(); - + it('does not run an unsimulatable project and reports status error', async () => { + const { controller, engine } = await openController({ simulatable: false }); expect(engine.runCalls).toHaveLength(0); expect(controller.getSnapshot().status).toBe('error'); await controller.dispose(); }); }); +describe('ProjectController model refresh after a landed edit', () => { + // A stock, a flow from a cloud to a cloud, and an aux. + const stockFlowJson = JSON.stringify({ + name: 'test', + simSpecs: { startTime: 0, endTime: 10, dt: '1' }, + models: [ + { + name: 'main', + stocks: [{ name: 's', initialEquation: '1', inflows: [], outflows: [] }], + flows: [{ name: 'f', equation: '1' }], + auxiliaries: [{ name: 'a', equation: '1' }], + views: [ + { + elements: [ + { type: 'stock', uid: 1, name: 's', x: 100, y: 100 }, + { + type: 'flow', + uid: 2, + name: 'f', + x: 40, + y: 100, + points: [ + { x: 0, y: 100, attachedToUid: 3 }, + { x: 77.5, y: 100, attachedToUid: 4 }, + ], + }, + { type: 'cloud', uid: 3, flowUid: 2, x: 0, y: 100 }, + { type: 'cloud', uid: 4, flowUid: 2, x: 77.5, y: 100 }, + { type: 'aux', uid: 5, name: 'a', x: 0, y: 0 }, + ], + }, + ], + }, + ], + }); + + // Each maintenance kind's mark in the fake engine's call log: the save + // serializes for the host, the error refresh reads getErrors, the connector + // refresh fetches the model, the sim runs it. + const SAVE = 'serializeJson:save'; + const MODEL_REFRESH = ['getErrors', 'getModel', 'run']; + + async function callsDuring(opened: Opened, act: () => Promise): Promise { + const start = opened.engine.calls.length; + await act(); + await opened.controller.whenIdle(); + return opened.engine.calls.slice(start); + } + + const opTypes = (patch: JsonProjectPatch | undefined): string[] => + (patch?.models ?? []).flatMap((m) => m.ops.map((op) => op.type)); + + const named = (type: 'stock' | 'flow' | 'aux' | 'module', uid: number, name: string): ViewElement => + ({ + type, + uid, + name, + ident: canonicalize(name), + var: undefined, + x: 200, + y: 200, + labelSide: 'bottom', + isZeroRadius: false, + ...(type === 'stock' ? { inflows: [], outflows: [] } : {}), + ...(type === 'flow' ? { points: [] } : {}), + }) as unknown as ViewElement; + + // One row per model op buildEditOps emits for a view edit (view-model-sync.ts: + // renames, deletes, a create per element kind, stock list changes); each row + // checks its edit applied that op, so the row exercises the arm it names. + // Undo/redo, a model-only edit and a patch with no ops follow; the reopen arm + // is in 'a patch that applied but could not be read back', and the open's own + // refresh in the open lifecycle suite. + const viewEditRows: ReadonlyArray<{ op: string; next: (v: StockFlowView) => StockFlowView }> = [ + { + op: 'renameVariable', + next: (v) => ({ + ...v, + elements: v.elements.map((el) => + el.uid === 5 && el.type === 'aux' ? { ...el, name: 'renamed', ident: 'renamed' } : el, + ), + }), + }, + { op: 'deleteVariable', next: (v) => ({ ...v, elements: v.elements.filter((el) => el.uid !== 5) }) }, + { op: 'upsertStock', next: (v) => withElements(v, named('stock', 10, 'new stock')) }, + { op: 'upsertFlow', next: (v) => withElements(v, named('flow', 10, 'new flow')) }, + { op: 'upsertAux', next: (v) => withElements(v, named('aux', 10, 'new aux')) }, + { op: 'upsertModule', next: (v) => withElements(v, named('module', 10, 'new module')) }, + { + op: 'updateStockFlows', + next: (v) => ({ + ...v, + elements: v.elements + .filter((el) => el.uid !== 4) + .map((el) => + el.type === 'flow' && el.uid === 2 + ? { ...el, points: [el.points[0], { ...el.points[1], attachedToUid: 1 }] } + : el, + ), + }), + }, + ]; + + it('a geometry-only view edit (only upsertView) saves and refreshes nothing derived from the model', async () => { + const opened = await openController({}, {}, stockFlowJson); + const savesBefore = opened.saves.length; + const calls = await callsDuring(opened, () => + opened.controller.enqueueViewEdit({ label: 'move', nextView: moved(view(opened.controller), 5, 10) }), + ); + expect(opTypes(opened.engine.appliedPatches.at(-1))).toEqual(['upsertView']); + expect(calls).toContain(SAVE); + expect(opened.saves.length).toBe(savesBefore + 1); + for (const marker of ['getErrors', 'isSimulatable', 'getModel', 'getIncomingLinks', 'mainModel', 'run']) { + expect(calls).not.toContain(marker); + } + await opened.controller.dispose(); + }); + + for (const row of viewEditRows) { + it(`a view edit applying ${row.op} saves and refreshes errors, connectors and the sim`, async () => { + const opened = await openController({}, {}, stockFlowJson); + const calls = await callsDuring(opened, () => + opened.controller.enqueueViewEdit({ label: row.op, nextView: row.next(view(opened.controller)) }), + ); + expect(opTypes(opened.engine.appliedPatches.at(-1))).toContain(row.op); + for (const marker of [SAVE, ...MODEL_REFRESH]) { + expect(calls).toContain(marker); + } + await opened.controller.dispose(); + }); + } + + it('a model-only edit saves and refreshes errors, connectors and the sim', async () => { + const opened = await openController({}, {}, stockFlowJson); + const calls = await callsDuring(opened, () => + opened.controller.enqueueModelEdit({ + label: 'equation', + buildPatch: () => ({ + models: [{ name: 'main', ops: [{ type: 'upsertAux', payload: { aux: { name: 'a', equation: '2' } } }] }], + }), + }), + ); + for (const marker of [SAVE, ...MODEL_REFRESH]) { + expect(calls).toContain(marker); + } + await opened.controller.dispose(); + }); + + for (const direction of ['undo', 'redo'] as const) { + it(`${direction} saves and refreshes errors, connectors and the sim, even of a geometry-only edit`, async () => { + const opened = await openController({}, {}, stockFlowJson); + await opened.controller.enqueueViewEdit({ label: 'move', nextView: moved(view(opened.controller), 5, 10) }); + await opened.controller.whenIdle(); + if (direction === 'redo') { + opened.controller.undoRedo('undo'); + await opened.controller.whenIdle(); + } + const calls = await callsDuring(opened, async () => { + opened.controller.undoRedo(direction); + }); + // Undo and redo reopen a recorded snapshot in a new engine; the fake + // config reuses this one engine for every open. + expect(calls).toContain('serializeJson:stdlib'); + for (const marker of [SAVE, ...MODEL_REFRESH]) { + expect(calls).toContain(marker); + } + await opened.controller.dispose(); + }); + } + + it('a patch whose only ops are project ops saves and refreshes errors, connectors and the sim', async () => { + const opened = await openController({}, {}, stockFlowJson); + const calls = await callsDuring(opened, () => + opened.controller.enqueueModelEdit({ + label: 'add model', + buildPatch: () => ({ projectOps: [{ type: 'addModel', payload: { name: 'child' } }], models: [] }), + }), + ); + for (const marker of [SAVE, ...MODEL_REFRESH]) { + expect(calls).toContain(marker); + } + await opened.controller.dispose(); + }); + + it('a patch with no ops changes no model: it saves and refreshes nothing else', async () => { + const opened = await openController({}, {}, stockFlowJson); + const calls = await callsDuring(opened, () => + opened.controller.enqueueModelEdit({ label: 'nothing', buildPatch: () => ({ models: [] }) }), + ); + expect(calls).toContain(SAVE); + for (const marker of MODEL_REFRESH) { + expect(calls).not.toContain(marker); + } + await opened.controller.dispose(); + }); +}); + +describe('ProjectController sim series identity', () => { + // Stock/Aux/Flow and their sparklines memoize on a variable's attached data + // array, so a variable whose results did not change must keep its Series and + // its array; formatting a large model's results is seconds of main-thread work. + const twoAuxJson = validProjectJson({ + auxiliaries: [ + { name: 'a', equation: '1' }, + { name: 'b', equation: '2' }, + ], + mainViewElements: [ + { type: 'aux', uid: 1, name: 'a', x: 0, y: 0 }, + { type: 'aux', uid: 2, name: 'b', x: 40, y: 0 }, + ], + }); + const first = { time: [0, 1, 2], a: [1, NaN, 3], b: [4, 5, 6] }; + const equationEdit = { + label: 'equation', + buildPatch: () => ({ + models: [{ name: 'main', ops: [{ type: 'upsertAux' as const, payload: { aux: { name: 'a', equation: '1' } } }] }], + }), + }; + const attached = (controller: ProjectController, ident: string) => + controller.getSnapshot().project?.models.get('main')?.variables.get(ident)?.data; + + // Every arm of the reuse decision, as a second run against the first. + const rows: ReadonlyArray<{ + name: string; + second: Record; + keepsMap: boolean; + keepsA: boolean; + keepsB: boolean; + }> = [ + { name: 'identical results (NaN included)', second: first, keepsMap: true, keepsA: true, keepsB: true }, + { + name: 'one variable changed', + second: { ...first, b: [4, 5, 7] }, + keepsMap: false, + keepsA: true, + keepsB: false, + }, + { + name: 'the time axis changed', + second: { ...first, time: [0, 2, 4] }, + keepsMap: false, + keepsA: false, + keepsB: false, + }, + { name: 'a variable added', second: { ...first, c: [7, 8, 9] }, keepsMap: false, keepsA: true, keepsB: true }, + { + name: 'a variable removed', + second: { time: first.time, a: first.a }, + keepsMap: false, + keepsA: true, + keepsB: false, + }, + ]; + + for (const row of rows) { + it(`a rerun with ${row.name}`, async () => { + const results = [first, row.second]; + let call = 0; + const opened = await openController( + { run: () => fakeRun(results[Math.min(call++, results.length - 1)]) }, + {}, + twoAuxJson, + ); + const before = opened.controller.getSnapshot(); + const arrayA = attached(opened.controller, 'a'); + const arrayB = attached(opened.controller, 'b'); + expect(arrayA).toBeDefined(); + expect(arrayB).toBeDefined(); + + await opened.controller.enqueueModelEdit(equationEdit); + await opened.controller.whenIdle(); + + expect(opened.engine.runCalls).toHaveLength(2); + const after = opened.controller.getSnapshot(); + expect(after.data === before.data).toBe(row.keepsMap); + expect(after.data.get('a') === before.data.get('a')).toBe(row.keepsA); + expect(after.data.get('b') === before.data.get('b')).toBe(row.keepsB); + expect(attached(opened.controller, 'a') === arrayA).toBe(row.keepsA); + if (after.data.has('b')) { + expect(attached(opened.controller, 'b') === arrayB).toBe(row.keepsB); + } + await opened.controller.dispose(); + }); + } + + it('a landed geometry-only edit re-renders the project with the same attached arrays and no rerun', async () => { + const opened = await openController({ run: () => fakeRun(first) }, {}, twoAuxJson); + const before = opened.controller.getSnapshot(); + const arrayA = attached(opened.controller, 'a'); + expect(arrayA).toBeDefined(); + await opened.controller.enqueueViewEdit({ label: 'move', nextView: moved(view(opened.controller), 1, 10) }); + await opened.controller.whenIdle(); + expect(opened.engine.runCalls).toHaveLength(1); + expect(opened.controller.getSnapshot().project).not.toBe(before.project); + expect(attached(opened.controller, 'a')).toBe(arrayA); + await opened.controller.dispose(); + }); +}); + describe('convertErrorDetails', () => { it('maps a unit error to its bare details, not the formatted message', () => { const errors: ErrorDetail[] = [ @@ -884,17 +2177,13 @@ describe('convertErrorDetails', () => { variableName: 'inflow', kind: SimlinErrorKind.Units, unitErrorKind: SimlinUnitErrorKind.Consistency, - code: 33, // SimlinErrorCode.UnitMismatch on the wire + code: 33, startOffset: 0, endOffset: 6, - // The message is terminal-formatted (snippet + squiggles + summary) - // and must NOT leak into the UnitError the UI renders. A consistency - // mismatch is Warning severity, so the engine words it as a warning. message: " 2*aux1\n ~~~~~~\nunits warning in model 'main' variable 'inflow': unit_mismatch", details: "computed units 'blerz' don't match specified units", } as unknown as ErrorDetail, ]; - const { unitErrors } = convertErrorDetails(errors, 'main'); const errs = unitErrors.get('inflow'); expect(errs).toHaveLength(1); @@ -917,7 +2206,6 @@ describe('convertErrorDetails', () => { message: null, details: null, }) as unknown as ErrorDetail; - const { unitErrors } = convertErrorDetails( [ errAt(SimlinUnitErrorKind.Definition, 'a'), @@ -943,180 +2231,351 @@ describe('convertErrorDetails', () => { details: null, } as unknown as ErrorDetail, ]; - const { unitErrors } = convertErrorDetails(errors, 'main'); expect(unitErrors.get('x')![0].details).toBeUndefined(); expect(unitErrors.get('x')![0].kind).toBe('definition'); }); -}); -describe('ProjectController error cache + navigation', () => { - it('refreshes the cached errors scoped to the active model', async () => { - const errorList: ErrorDetail[] = [ - { + it('sorts per-variable errors by kind and severity', () => { + // Every arm of the classification: a unit error is a unit error whatever its + // severity; any other Warning is an advisory; everything else is an error. + const at = (variableName: string, kind: SimlinErrorKind, severity: SimlinErrorSeverity): ErrorDetail => + ({ modelName: 'main', - variableName: 'x', - kind: SimlinErrorKind.Variable, - code: 1, + variableName, + kind, + severity, + unitErrorKind: SimlinUnitErrorKind.Consistency, + code: 32, startOffset: 0, - endOffset: 1, - } as unknown as ErrorDetail, - { - modelName: 'child', - variableName: 'y', - kind: SimlinErrorKind.Variable, - code: 1, - } as unknown as ErrorDetail, - ]; - const engine = makeFakeEngine({ - errors: errorList, - json: () => - validProjectJson({ - extraModels: [{ name: 'child', stocks: [], flows: [], auxiliaries: [], views: [{ elements: [] }] }], - }), + endOffset: 0, + message: `formatted message for ${variableName}`, + details: variableName === 'bare' ? null : `reason for ${variableName}`, + }) as unknown as ErrorDetail; + const { varErrors, unitErrors, varWarnings } = convertErrorDetails( + [ + at('unit_error', SimlinErrorKind.Units, SimlinErrorSeverity.Error), + at('unit_warning', SimlinErrorKind.Units, SimlinErrorSeverity.Warning), + at('equation_error', SimlinErrorKind.Variable, SimlinErrorSeverity.Error), + at('model_error', SimlinErrorKind.Model, SimlinErrorSeverity.Error), + at('advisory', SimlinErrorKind.Model, SimlinErrorSeverity.Warning), + at('variable_advisory', SimlinErrorKind.Variable, SimlinErrorSeverity.Warning), + at('bare', SimlinErrorKind.Model, SimlinErrorSeverity.Warning), + // Some raising sites name a variable by its source spelling. + at('Source Spelling', SimlinErrorKind.Model, SimlinErrorSeverity.Warning), + ], + 'main', + ); + expect([...unitErrors.keys()].sort()).toEqual(['unit_error', 'unit_warning']); + expect([...varErrors.keys()].sort()).toEqual(['equation_error', 'model_error']); + expect([...varWarnings.keys()].sort()).toEqual(['advisory', 'bare', 'source_spelling', 'variable_advisory']); + expect(varWarnings.get('advisory')).toEqual([{ code: 32, details: 'reason for advisory' }]); + // Without a bare reason the message stands in. + expect(varWarnings.get('bare')).toEqual([{ code: 32, details: 'formatted message for bare' }]); + }); +}); + +describeWithEngine('convertErrorDetails over the real engine', () => { + it("a stock whose inflow list repeats a flow carries the engine's advisory as a warning, not an error", async () => { + const engine = await loadEngine(); + const project = await engine.Project.openJson( + JSON.stringify({ + name: 'repeats', + simSpecs: { startTime: 0, endTime: 3, dt: '1' }, + models: [ + { + name: 'main', + stocks: [{ name: 'Level', initialEquation: '1', inflows: ['f', 'f'], outflows: [] }], + flows: [{ name: 'f', equation: '1' }], + auxiliaries: [], + views: [{ elements: [] }], + }, + ], + }), + ); + try { + const { varErrors, varWarnings } = convertErrorDetails(await project.getErrors(), 'main'); + expect(varErrors.has('level')).toBe(false); + const warnings = varWarnings.get('level') ?? []; + expect(warnings).toHaveLength(1); + expect(warnings[0].details).toContain("repeats 'f'"); + expect(await project.isSimulatable()).toBe(true); + } finally { + await project.dispose(); + } + }); +}); + +describe('ProjectController advisories', () => { + it('annotates a Warning as the variable’s warnings, leaving its errors and the status alone', async () => { + const opened = await openController({ + errors: [ + { + modelName: 'main', + variableName: 'a', + kind: SimlinErrorKind.Model, + severity: SimlinErrorSeverity.Warning, + code: 32, + details: 'an advisory', + } as unknown as ErrorDetail, + ], }); - const { config } = makeControllerConfig({ engine }); - const controller = new ProjectController(config); - await controller.openInitialProject(); + const a = opened.controller.getModel()!.variables.get('a')!; + expect(a.errors).toBeUndefined(); + expect(a.warnings).toEqual([{ code: 32, details: 'an advisory' }]); + expect(opened.controller.getSnapshot().cachedErrors.varWarnings.get('a')).toEqual(a.warnings); + expect(opened.controller.getSnapshot().cachedErrors.varErrors.size).toBe(0); + await opened.controller.dispose(); + }); +}); - const cached = await controller.refreshCachedErrors(); - // Only 'main'-scoped variable errors appear while viewing main. - expect(cached?.varErrors.has('x')).toBe(true); - expect(cached?.varErrors.has('y')).toBe(false); +describe('ProjectController pending creations', () => { + // Stock A on the committed view with its variable; Ghost, an orphan stock + // element no variable names (an import can leave one). + const json = (() => { + const project = JSON.parse( + validProjectJson({ + mainViewElements: [ + { type: 'stock', uid: 1, name: 'A', x: 100, y: 100 }, + { type: 'stock', uid: 2, name: 'Ghost', x: 100, y: 300 }, + ], + }), + ) as { models: Array<{ stocks: unknown[] }> }; + project.models[0].stocks = [{ name: 'A', initialEquation: '1', inflows: [], outflows: [] }]; + return JSON.stringify(project); + })(); + + function stock(uid: number, name: string, x: number, y: number): StockViewElement { + return { + type: 'stock', + uid, + name, + ident: canonicalize(name), + var: undefined, + x, + y, + labelSide: 'bottom', + isZeroRadius: false, + inflows: [], + outflows: [], + }; + } - // Drilling into child re-scopes the cache (fire-and-forget refresh). - controller.drillIntoModule('m', 'child', new Set(), { x: 0, y: 0, width: 1, height: 1 }, 1); - await flushTimers(); - expect(controller.getSnapshot().cachedErrors.varErrors.has('y')).toBe(true); - expect(controller.getSnapshot().cachedErrors.varErrors.has('x')).toBe(false); + function drawFlow(controller: ProjectController, fromUid: number, toUid: number) { + const rendered = view(controller); + const at = (uid: number) => rendered.elements.find((el) => el.uid === uid)!; + return planGesture({ + view: rendered, + variables: controller.getModel()!.variables, + selection: new Set(), + gesture: { kind: 'createFlow', from: { stock: fromUid } }, + press: at(fromUid), + current: at(toUid), + zoom: 1, + pointerType: 'mouse', + readOnly: false, + names: (base) => controller.newVariableName(base), + }); + } + + it('the rendered model holds the variable a queued create will produce, so a flow can be drawn onto it', async () => { + const gate = makeGate(); + const engine = makeFakeEngine({ json, applyPatchGate: () => gate.wait(), applyPatchThrows: true }); + const { config } = makeControllerConfig({ engine, format: 'json' }); + const controller = new ProjectController(config); + await controller.openInitialProject(); + await controller.whenIdle(); + + const b = stock(3, 'B', 400, 100); + const creating = controller.enqueueViewEdit({ label: 'create', nextView: withElements(view(controller), b) }); + expect(controller.getModel()!.variables.get('b')).toEqual(createdVariable(b)); + expect(drawFlow(controller, 1, 3).commit).toBe('edit'); + // The orphan resolves to nothing, pending creates or not. + expect(controller.getModel()!.variables.has('ghost')).toBe(false); + expect(drawFlow(controller, 1, 2).commit).toBe('none'); + + // The create fails: the rendered model no longer holds its variable. + gate.open(); + expect(await creating).toBe(false); + await controller.whenIdle(); + expect(controller.getModel()!.variables.has('b')).toBe(false); await controller.dispose(); }); +}); - it('prefers the bare details over the terminal-formatted message for model errors', async () => { - // The unit-inference umbrella diagnostic carries a plain-language - // sentence in `details`; the `message` twin is terminal-formatted and - // must not reach the errors panel when details is present. - const errorList: ErrorDetail[] = [ - { - modelName: 'main', - variableName: null, - kind: SimlinErrorKind.Model, - code: 33, - message: - "warning in model 'main': unit_mismatch -- unit checking failed; inconsistent constraints:\n 1 == x", - details: "the units of 'a' and 'b' are inconsistent with each other", - } as unknown as ErrorDetail, - { - modelName: 'main', - variableName: null, - kind: SimlinErrorKind.Model, - code: 1, - message: 'error in model main: something broke', - details: null, - } as unknown as ErrorDetail, - ]; - const engine = makeFakeEngine({ errors: errorList }); - const { config } = makeControllerConfig({ engine }); +describe('ProjectController error derivation', () => { + const errorList: ErrorDetail[] = [ + { + modelName: 'main', + variableName: 'a', + kind: SimlinErrorKind.Variable, + code: 1, + startOffset: 0, + endOffset: 1, + } as unknown as ErrorDetail, + { modelName: 'child', variableName: 'c', kind: SimlinErrorKind.Variable, code: 1 } as unknown as ErrorDetail, + { + modelName: 'main', + variableName: null, + kind: SimlinErrorKind.Model, + code: 33, + message: "warning in model 'main': unit_mismatch -- unit checking failed", + details: "the units of 'a' and 'b' are inconsistent with each other", + } as unknown as ErrorDetail, + { + modelName: 'main', + variableName: null, + kind: SimlinErrorKind.Model, + code: 1, + message: 'error in model main: something broke', + details: null, + } as unknown as ErrorDetail, + ]; + + it('scopes the error panel and variable annotations to the active model, and re-scopes on drill-in with no engine call', async () => { + const project = statefulProject( + validProjectJson({ + auxiliaries: [{ name: 'a', equation: '1' }], + mainViewElements: [{ type: 'aux', uid: 1, name: 'a', x: 0, y: 0 }], + extraModels: [ + { + name: 'child', + stocks: [], + flows: [], + auxiliaries: [{ name: 'c', equation: '1' }], + views: [{ elements: [] }], + }, + ], + }), + ); + const engine = makeFakeEngine({ json: project.json, errors: errorList }); + const { config } = makeControllerConfig({ engine, format: 'json' }); const controller = new ProjectController(config); await controller.openInitialProject(); - - const cached = await controller.refreshCachedErrors(); - expect(cached?.modelErrors).toHaveLength(2); - expect(cached?.modelErrors[0].details).toBe("the units of 'a' and 'b' are inconsistent with each other"); - // A model error without details still falls back to the message. - expect(cached?.modelErrors[1].details).toBe('error in model main: something broke'); + await controller.whenIdle(); + const s = controller.getSnapshot(); + expect(s.cachedErrors.varErrors.has('a')).toBe(true); + expect(s.cachedErrors.varErrors.has('c')).toBe(false); + expect(s.project?.models.get('main')?.variables.get('a')?.errors).toEqual([{ start: 0, end: 1, code: 1 }]); + expect(s.cachedErrors.modelErrors.map((e) => e.details)).toEqual([ + "the units of 'a' and 'b' are inconsistent with each other", + 'error in model main: something broke', + ]); + + const callsBefore = engine.calls.filter((c) => c === 'getErrors').length; + controller.drillIntoModule('m', 'child', new Set(), { x: 0, y: 0, width: 1, height: 1 }, 1); + const child = controller.getSnapshot(); + expect(child.cachedErrors.varErrors.has('c')).toBe(true); + expect(child.cachedErrors.varErrors.has('a')).toBe(false); + expect(child.project?.models.get('child')?.variables.get('c')?.errors).toBeDefined(); + expect(engine.calls.filter((c) => c === 'getErrors').length).toBe(callsBefore); await controller.dispose(); }); - it('restores selection on navigateBack and refuses to drill into a missing model', async () => { - const engine = makeFakeEngine({ - json: () => - validProjectJson({ - extraModels: [{ name: 'child', stocks: [], flows: [], auxiliaries: [], views: [{ elements: [] }] }], - }), - }); - const { config } = makeControllerConfig({ engine }); + it('flags an all-empty starter model hasNoEquations without annotating errors', async () => { + const json = validProjectJson({ auxiliaries: [{ name: 'a' }, { name: 'b' }] }); + const empty = (variableName: string): ErrorDetail => + ({ + modelName: 'main', + variableName, + kind: SimlinErrorKind.Variable, + code: ErrorCode.EmptyEquation, + startOffset: 0, + endOffset: 0, + }) as unknown as ErrorDetail; + const engine = makeFakeEngine({ json, errors: [empty('a'), empty('b')] }); + const { config } = makeControllerConfig({ engine, format: 'json' }); const controller = new ProjectController(config); await controller.openInitialProject(); - - // Drilling into a nonexistent model is a no-op. - const missing = controller.drillIntoModule('m', 'nope', new Set(), { x: 0, y: 0, width: 1, height: 1 }, 1); - expect(missing.restoredSelection).toBeUndefined(); - expect(controller.getModelName()).toBe('main'); - - // Drill in capturing a parent selection, then navigate back restores it. - const parentSelection = new Set([42]); - controller.drillIntoModule('m', 'child', parentSelection, { x: 0, y: 0, width: 1, height: 1 }, 1); - expect(controller.getModelName()).toBe('child'); - - const back = controller.navigateBack(); - expect(back.restoredSelection).toEqual(parentSelection); - expect(controller.getModelName()).toBe('main'); + await controller.whenIdle(); + const s = controller.getSnapshot(); + expect(s.project?.hasNoEquations).toBe(true); + expect(s.project?.models.get('main')?.variables.get('a')?.errors).toBeUndefined(); + expect(s.status).toBe('disabled'); await controller.dispose(); }); }); -describe('ProjectController snapshot immutability + coalescing', () => { +describe('ProjectController snapshots and disposal', () => { it('produces a fresh snapshot object on each change and never mutates prior ones', async () => { const engine = makeFakeEngine(); const { config } = makeControllerConfig({ engine }); const controller = new ProjectController(config); - const s0 = controller.getSnapshot(); await controller.openInitialProject(); const s1 = controller.getSnapshot(); expect(s1).not.toBe(s0); - // The prior snapshot is untouched. expect(s0.project).toBeUndefined(); - - await controller.applyPatch({ models: [{ name: 'main', ops: [] }] }, 'edit'); - const s2 = controller.getSnapshot(); - expect(s2).not.toBe(s1); - expect(s1.projectVersion).not.toBe(s2.projectVersion); await controller.dispose(); }); - it('coalesces a synchronous multi-step navigation into a single notification', async () => { - const engine = makeFakeEngine({ - json: () => - validProjectJson({ - extraModels: [{ name: 'child', stocks: [], flows: [], auxiliaries: [], views: [{ elements: [] }] }], - }), - }); - const { config } = makeControllerConfig({ engine }); + it('coalesces a synchronous navigation into a single notification', async () => { + const project = statefulProject( + validProjectJson({ + extraModels: [{ name: 'child', stocks: [], flows: [], auxiliaries: [], views: [{ elements: [] }] }], + }), + ); + const engine = makeFakeEngine({ json: project.json }); + const { config } = makeControllerConfig({ engine, format: 'json' }); const controller = new ProjectController(config); await controller.openInitialProject(); - + await controller.whenIdle(); const seen: ProjectSnapshot[] = []; controller.subscribe(() => { seen.push(controller.getSnapshot()); }); - - // drillIntoModule mutates modelStack + modelName together -> exactly one - // synchronous notify (the fire-and-forget error refresh notifies later). controller.drillIntoModule('m', 'child', new Set(), { x: 0, y: 0, width: 1, height: 1 }, 1); expect(seen).toHaveLength(1); expect(seen[0].modelName).toBe('child'); await controller.dispose(); }); - it('does not notify after dispose', async () => { - const engine = makeFakeEngine(); - const { config } = makeControllerConfig({ engine }); - const controller = new ProjectController(config); - await controller.openInitialProject(); - + it('dispose settles queued items as not landed, releases the engine after the running item, and stops notifying', async () => { + const gate = makeGate(); + const { controller, engine } = await openController({ applyPatchGate: () => gate.wait() }); let notifies = 0; controller.subscribe(() => { notifies++; }); - await controller.dispose(); + const running = controller.enqueueViewEdit({ label: 'move', nextView: moved(view(controller), 1, 10) }); + const queued = controller.enqueueViewEdit({ label: 'move', nextView: moved(view(controller), 1, 10) }); + await new Promise((resolve) => setTimeout(resolve, 10)); notifies = 0; - - // Post-dispose calls must not fan out to listeners. - await controller.refreshCachedErrors(); - await controller.recalculateStatus(); + const disposing = controller.dispose(); + expect(await queued).toBe(false); + expect(engine.disposeCount).toBe(0); + gate.open(); + await running; + await disposing; + expect(engine.disposeCount).toBe(1); expect(notifies).toBe(0); + // Post-dispose calls are inert. + expect(await controller.enqueueModelEdit({ label: 'x', buildPatch: () => ({ models: [] }) })).toBe(false); + controller.setViewport('main', { viewBox: { x: 1, y: 1, width: 1, height: 1 }, zoom: 1 }); + controller.undoRedo('undo'); + expect(await controller.query(async () => 1)).toBeUndefined(); + rs.restoreAllMocks(); + }); + + it('dispose of an idle controller releases the engine', async () => { + const { controller, engine } = await openController(); + expect(engine.disposeCount).toBe(0); + await controller.dispose(); + expect(engine.disposeCount).toBe(1); + }); + + it('dispose in the same tick as an enqueue settles the item the executor has not started, and whenIdle resolves', async () => { + const engine = makeFakeEngine(); + const { config, openedEngines } = makeControllerConfig({ engine, format: 'json' }); + const controller = new ProjectController(config); + const opening = controller.openInitialProject(); + await controller.dispose(); + const hung = (ms: number) => new Promise((resolve) => setTimeout(() => resolve('hung'), ms)); + expect(await Promise.race([opening.then(() => 'settled'), hung(100)])).toBe('settled'); + expect(await Promise.race([controller.whenIdle().then(() => 'idle'), hung(100)])).toBe('idle'); + expect(openedEngines).toHaveLength(0); + // Anything pushed from here on settles at once, too. + expect(await Promise.race([controller.openInitialProject().then(() => 'settled'), hung(100)])).toBe('settled'); }); }); @@ -1129,62 +2588,3 @@ describe('fake-engine fixture', () => { expect(project.models.has('main')).toBe(true); }); }); - -describe('ProjectController non-finite coordinate guard (#818)', () => { - const badStock = (): StockViewElement => ({ - type: 'stock', - uid: 999, - name: 'bad', - ident: 'bad', - var: undefined, - x: NaN, - y: 0, - labelSide: 'top', - isZeroRadius: false, - inflows: [], - outflows: [], - }); - - it('updateView refuses a view with a non-finite coordinate (no patch, no optimistic bump)', async () => { - const engine = makeFakeEngine(); - const { config, errors } = makeControllerConfig({ engine, format: 'protobuf', initialData: snap(1) }); - const controller = new ProjectController(config); - await controller.openInitialProject(); - - const view = controller.getView() as StockFlowView; - const patchesBefore = engine.appliedPatches.length; - const versionBefore = controller.getSnapshot().projectVersion; - - // A move/geometry bug that produced NaN would serialize to JSON null and the - // engine would reject the patch, historically leaving the model uneditable. - const badView: StockFlowView = { ...view, elements: [...view.elements, badStock()] }; - await controller.updateView(badView, { recordHistory: true }); - - // No patch reached the engine, and the optimistic view (version bump) never - // applied -- the canvas stays at the last good state instead of bricking. - expect(engine.appliedPatches.length).toBe(patchesBefore); - expect(controller.getSnapshot().projectVersion).toBe(versionBefore); - expect(controller.canUndo()).toBe(false); - // The host is told why, with a descriptive (debuggable) message. - expect(errors.length).toBe(1); - expect(errors[0].message).toContain('uid=999'); - await controller.dispose(); - }); - - it('queueViewUpdate refuses a view with a non-finite coordinate', async () => { - const engine = makeFakeEngine(); - const { config, errors } = makeControllerConfig({ engine, format: 'protobuf', initialData: snap(1) }); - const controller = new ProjectController(config); - await controller.openInitialProject(); - - const view = controller.getView() as StockFlowView; - const patchesBefore = engine.appliedPatches.length; - - const badView: StockFlowView = { ...view, elements: [...view.elements, badStock()] }; - await controller.queueViewUpdate(badView); - - expect(engine.appliedPatches.length).toBe(patchesBefore); - expect(errors.length).toBe(1); - await controller.dispose(); - }); -}); diff --git a/src/diagram/tests/rename-ops.test.ts b/src/diagram/tests/rename-ops.test.ts index 47078a1f6..9a26e8eea 100644 --- a/src/diagram/tests/rename-ops.test.ts +++ b/src/diagram/tests/rename-ops.test.ts @@ -2,108 +2,110 @@ // Use of this source code is governed by the Apache License, // Version 2.0, that can be found in the LICENSE file. +// A rename's next view (relabelVariable) and the renameVariable op the +// controller derives from it (buildEditOps). The committed model is loaded +// through the production loader. + import { describe, it, expect } from '@rstest/core'; -import type { AuxViewElement, LinkViewElement, StockFlowView } from '@simlin/core/datamodel'; -import type { JsonViewElement } from '@simlin/engine'; +import { projectFromJson, type AuxViewElement, type Model } from '@simlin/core/datamodel'; +import type { JsonProject } from '@simlin/engine'; -import { buildVariableRenameOps } from '../rename-ops'; +import { relabelVariable } from '../rename-ops'; +import { buildEditOps } from '../view-model-sync'; -function makeAuxElement(overrides: Partial = {}): AuxViewElement { - return { - type: 'aux', - uid: 1, - name: 'Total Students', - ident: 'total_students', - var: undefined, - x: 10, - y: 20, - labelSide: 'right', - isZeroRadius: false, - ...overrides, - }; +function committed(auxName: string): Model { + const json = { + name: 'rename', + simSpecs: { startTime: 0, endTime: 1, dt: '1' }, + models: [ + { + name: 'main', + stocks: [], + flows: [], + auxiliaries: [ + { name: auxName, equation: '1' }, + { name: 'Other Var', equation: '2' }, + ], + views: [ + { + elements: [ + { type: 'aux', uid: 1, name: auxName, x: 10, y: 20 }, + { type: 'aux', uid: 2, name: 'Other Var', x: 50, y: 20 }, + { type: 'link', uid: 3, fromUid: 1, toUid: 2 }, + ], + }, + ], + }, + ], + } as unknown as JsonProject; + return projectFromJson(json).models.get('main')!; } -function makeView(elements: StockFlowView['elements']): StockFlowView { - return { - nextUid: 10, - elements, - viewBox: { x: 0, y: 0, width: 100, height: 100 }, - zoom: 1, - useLetteredPolarity: false, - }; +function renameOps(auxName: string, oldName: string, newName: string) { + const model = committed(auxName); + const base = model.views[0]; + return buildEditOps(model, base, relabelVariable(base, oldName, newName)); } -describe('buildVariableRenameOps', () => { +describe('relabelVariable + buildEditOps', () => { it('sends the typed name RAW as the rename `to` and the canonical ident as `from`', () => { // The engine preserves display spellings verbatim and matches canonically - // (issue #890); canonicalizing `to` here downgraded the stored spelling + // (issue #890); canonicalizing `to` downgraded the stored spelling // ("New Students" -> `new_students`) on every rename (issue #906). - const view = makeView([makeAuxElement()]); - const { ops } = buildVariableRenameOps(view, 'Total Students', 'New Students'); - - expect(ops[0]).toEqual({ + expect(renameOps('Total Students', 'Total Students', 'New Students')[0]).toEqual({ type: 'renameVariable', payload: { from: 'total_students', to: 'New Students' }, }); }); it('preserves the display spelling on a case-only rename', () => { - // A case-only rename is exactly the "restamp a preserved spelling" bug: - // from and to canonicalize identically, so a canonicalized `to` would be a - // no-op rename that still destroyed the display name. - const view = makeView([makeAuxElement({ name: 'students', ident: 'students' })]); - const { ops } = buildVariableRenameOps(view, 'students', 'Students'); - - expect(ops[0]).toEqual({ + expect(renameOps('students', 'students', 'Students')[0]).toEqual({ type: 'renameVariable', payload: { from: 'students', to: 'Students' }, }); }); it('encodes line breaks in the new name (stored backslash-n form)', () => { - const view = makeView([makeAuxElement()]); - const { ops, updatedView } = buildVariableRenameOps(view, 'Total Students', 'testing\nassymptomatic'); - - expect(ops[0]).toEqual({ + const model = committed('Total Students'); + const next = relabelVariable(model.views[0], 'Total Students', 'testing\nassymptomatic'); + expect((next.elements[0] as AuxViewElement).name).toBe('testing\\nassymptomatic'); + expect(buildEditOps(model, model.views[0], next)[0]).toEqual({ type: 'renameVariable', payload: { from: 'total_students', to: 'testing\\nassymptomatic' }, }); - expect((updatedView.elements[0] as AuxViewElement).name).toBe('testing\\nassymptomatic'); }); - it('updates only the matching named view element and pairs an upsertView op', () => { - const other = makeAuxElement({ uid: 2, name: 'Other Var', ident: 'other_var' }); - const link: LinkViewElement = { - type: 'link', - uid: 3, - fromUid: 1, - toUid: 2, - arc: undefined, - isStraight: true, - multiPoint: undefined, - polarity: undefined, - x: NaN, - y: NaN, - isZeroRadius: false, - ident: undefined, - }; - const view = makeView([makeAuxElement(), other, link]); - - const { ops, updatedView } = buildVariableRenameOps(view, 'Total Students', 'Enrolled Students'); - - expect((updatedView.elements[0] as AuxViewElement).name).toBe('Enrolled Students'); - // Unmatched elements pass through by reference. - expect(updatedView.elements[1]).toBe(other); - expect(updatedView.elements[2]).toBe(link); + it("relabels only the matching named element, gives it the new name's ident, and passes the rest through by reference", () => { + const model = committed('Total Students'); + const base = model.views[0]; + const next = relabelVariable(base, 'Total Students', 'Enrolled Students'); + expect(next.elements[0]).toMatchObject({ name: 'Enrolled Students', ident: 'enrolled_students' }); + expect(next.elements[1]).toBe(base.elements[1]); + expect(next.elements[2]).toBe(base.elements[2]); + const ops = buildEditOps(model, base, next); + expect(ops.map((op) => op.type)).toEqual(['renameVariable', 'upsertView']); + }); - expect(ops).toHaveLength(2); - expect(ops[1].type).toBe('upsertView'); - if (ops[1].type !== 'upsertView') { - throw new Error('expected upsertView'); - } - const serialized = ops[1].payload.view.elements as JsonViewElement[]; - const renamed = serialized.find((e) => e.uid === 1); - expect(renamed && 'name' in renamed ? renamed.name : undefined).toBe('Enrolled Students'); + it('matches the element by its name, so a second rename of a pending rename (or of a pending create) finds it', () => { + const model = committed('Total Students'); + const base = model.views[0]; + const once = relabelVariable(base, 'Total Students', 'Enrolled')!; + // The Canvas commits the second rename with the name the element renders. + const twice = relabelVariable(once, 'Enrolled', 'Graduated'); + expect(twice.elements[0]).toMatchObject({ name: 'Graduated', ident: 'graduated' }); + // Planned on the first rename's view, against the model that rename produced. + const renamedModel = committed('Enrolled'); + expect(buildEditOps(renamedModel, once, twice)[0]).toEqual({ + type: 'renameVariable', + payload: { from: 'enrolled', to: 'Graduated' }, + }); + // A staged element whose ident does not follow its name (a create staged + // under a default name) is still found by name. + const staged = { ...base, elements: [{ ...base.elements[0], ident: 'new_variable' }, ...base.elements.slice(1)] }; + expect(relabelVariable(staged, 'Total Students', 'Births').elements[0]).toMatchObject({ + name: 'Births', + ident: 'births', + }); }); }); diff --git a/src/diagram/tests/scene-generator.test.ts b/src/diagram/tests/scene-generator.test.ts new file mode 100644 index 000000000..9bb2c1f5d --- /dev/null +++ b/src/diagram/tests/scene-generator.test.ts @@ -0,0 +1,311 @@ +// Copyright 2026 The Simlin Authors. All rights reserved. +// Use of this source code is governed by the Apache License, +// Version 2.0, that can be found in the LICENSE file. + +// Tests of the seeded scene generator (tests/support/scene-generator.ts). +// +// What these establish: generated strict scenes hold every strict flow arm, M1, +// M3, duplicate-free stock lists and list agreement over many seeds; the view +// realizes every route the generator records (the route's shape classified from +// the points' directions, its hub end, and the face its far stock is entered +// through), and the recorded routes cover every shape x start x end combination, +// so "passes over many seeds" is not vacuous; imported scenes pass tolerant mode +// while strict mode reports exactly the arms each applied corpus shape records; +// a seed determines its scene; and a sample of both kinds of scene keeps its +// strict arms through an engine round trip. +// +// What they do not establish: that the generated distribution resembles real +// models beyond the enumerated shapes, or anything about gestures (the gesture +// generator arrives with the planner API). + +import { describe, it, expect, beforeAll } from '@rstest/core'; + +import type { JsonProject } from '@simlin/engine'; +import { + modelToJson, + type FlowViewElement, + type Point, + type StockFlowView, + type StockViewElement, + type UID, + type ViewElement, +} from '@simlin/core/datamodel'; + +import { StockHeight, StockWidth } from '../drawing/default'; +import { describeWithEngine, editorModel, loadEngine, type EngineModule } from './support/engine'; +import { checkFlowInvariants, formatFlowViolations, PIPE_SPACING } from './support/flow-invariants'; +import { + FACES, + FLOW_SHAPES, + genImportedScene, + genScene, + IMPORT_SHAPES, + Rng, + ROUTE_STARTS, + type Face, + type RouteRecord, + type Scene, +} from './support/scene-generator'; +import { + checkKindAgreement, + checkReferentialIntegrity, + checkStockFlowAgreement, + checkStockListDuplicates, + formatViewViolations, +} from './support/view-invariants'; + +const SEEDS = 300; + +function sceneFingerprint(scene: Scene): string { + return JSON.stringify({ view: scene.view, variables: [...scene.model.variables.entries()] }); +} + +/** + * The route shape as the points' segment directions show it: straight is one + * segment; an L turns once; a Z turns and turns back to its first direction; a + * bracket is stub, riser, run in the first direction, the riser reversed, and a + * stub in the first direction. Reversing a route preserves its class. + */ +function classifyRoute(points: readonly Point[]): string { + const dirs = points.slice(1).map((p, i) => ({ x: Math.sign(p.x - points[i].x), y: Math.sign(p.y - points[i].y) })); + const same = (a: Point, b: Point): boolean => a.x === b.x && a.y === b.y; + const reversed = (a: Point, b: Point): boolean => a.x === -b.x && a.y === -b.y; + const perpendicular = (a: Point, b: Point): boolean => a.x * b.x + a.y * b.y === 0; + if (dirs.length === 1) return 'straight'; + if (dirs.length === 2 && perpendicular(dirs[0], dirs[1])) return 'L'; + if (dirs.length === 3 && perpendicular(dirs[0], dirs[1]) && same(dirs[0], dirs[2])) return 'Z'; + if ( + dirs.length === 5 && + perpendicular(dirs[0], dirs[1]) && + same(dirs[0], dirs[2]) && + reversed(dirs[1], dirs[3]) && + same(dirs[0], dirs[4]) + ) { + return 'bracket'; + } + return `unclassified(${dirs.map((d) => `${d.x},${d.y}`).join(' ')})`; +} + +function faceOf(p: Point, stock: StockViewElement): Face | 'none' { + const eq = (a: number, b: number): boolean => Math.abs(a - b) < 1e-9; + if (eq(p.x, stock.x - StockWidth / 2)) return 'left'; + if (eq(p.x, stock.x + StockWidth / 2)) return 'right'; + if (eq(p.y, stock.y - StockHeight / 2)) return 'top'; + if (eq(p.y, stock.y + StockHeight / 2)) return 'bottom'; + return 'none'; +} + +function recordedRoute(route: RouteRecord): string { + return `${route.shape} ${route.start} ${route.far === undefined ? 'cloud' : `stock ${route.far.stockUid}@${route.far.face}`}`; +} + +/** The same description, read from the view alone. */ +function observedRoute(view: StockFlowView, hubUid: UID, f: FlowViewElement): string { + const byUid = new Map(view.elements.map((e) => [e.uid, e])); + const first = f.points[0]; + const last = f.points[f.points.length - 1]; + const kindAt = (p: Point): string => byUid.get(p.attachedToUid ?? -1)?.type ?? 'none'; + const start = + first.attachedToUid === hubUid + ? 'hubSource' + : last.attachedToUid === hubUid + ? 'hubSink' + : kindAt(first) === 'cloud' + ? 'freeCloud' + : 'unknown'; + const farPoint = start === 'hubSink' ? first : last; + const far = byUid.get(farPoint.attachedToUid ?? -1); + const end = + far?.type === 'stock' ? `stock ${far.uid}@${faceOf(farPoint, far)}` : far?.type === 'cloud' ? 'cloud' : 'none'; + return `${classifyRoute(f.points)} ${start} ${end}`; +} + +describe('genScene', () => { + it('is deterministic per seed and varies across seeds', () => { + expect(sceneFingerprint(genScene(new Rng(42)))).toBe(sceneFingerprint(genScene(new Rng(42)))); + expect(sceneFingerprint(genScene(new Rng(42)))).not.toBe(sceneFingerprint(genScene(new Rng(43)))); + }); + + it(`holds strict G1-G8, M1, M3, duplicate-free lists and list agreement over ${SEEDS} seeds`, () => { + for (let seed = 1; seed <= SEEDS; seed++) { + const { view, model } = genScene(new Rng(seed)); + const flowViolations = checkFlowInvariants(view, { mode: 'strict' }); + const viewViolations = [ + ...checkKindAgreement(view, model.variables), + ...checkReferentialIntegrity(view), + ...checkStockListDuplicates(model.variables), + ...checkStockFlowAgreement(view, model.variables), + ]; + expect(`seed ${seed}\n${formatFlowViolations(flowViolations)}${formatViewViolations(viewViolations)}`).toBe( + `seed ${seed}\n`, + ); + } + }); + + it('realizes every recorded route and covers every shape x start x end', () => { + const combos = new Set(); + for (let seed = 1; seed <= SEEDS; seed++) { + const { view, hubUid, routes } = genScene(new Rng(seed)); + const flows = view.elements.filter((e): e is FlowViewElement => e.type === 'flow'); + expect(`seed ${seed}: ${routes.map((r) => r.flowUid).sort((a, b) => a - b)}`).toBe( + `seed ${seed}: ${flows.map((f) => f.uid).sort((a, b) => a - b)}`, + ); + for (const route of routes) { + const f = flows.find((el) => el.uid === route.flowUid)!; + expect(`seed ${seed} flow ${f.uid}: ${observedRoute(view, hubUid, f)}`).toBe( + `seed ${seed} flow ${f.uid}: ${recordedRoute(route)}`, + ); + combos.add(`${route.shape} ${route.start} ${route.far?.face ?? 'cloud'}`); + } + } + const expected = FLOW_SHAPES.flatMap((shape) => + ROUTE_STARTS.flatMap((start) => [...FACES, 'cloud'].map((end) => `${shape} ${start} ${end}`)), + ); + expect([...combos].sort()).toEqual(expected.sort()); + }); + + it('shares stock faces at PIPE_SPACING, and produces links, aliases, modules and auxes', () => { + let sharedFaces = 0; + let links = 0; + let aliases = 0; + let modules = 0; + let auxes = 0; + for (let seed = 1; seed <= SEEDS; seed++) { + const { view } = genScene(new Rng(seed)); + const flows = view.elements.filter((e): e is FlowViewElement => e.type === 'flow'); + sharedFaces += countSharedFaces(view.elements, flows); + const count = (type: ViewElement['type']): number => view.elements.filter((e) => e.type === type).length; + links += count('link'); + aliases += count('alias'); + modules += count('module'); + auxes += count('aux'); + } + expect(sharedFaces).toBeGreaterThan(20); + expect(links).toBeGreaterThan(SEEDS); + expect(aliases).toBeGreaterThan(SEEDS / 3); + expect(modules).toBeGreaterThan(SEEDS / 10); + expect(auxes).toBeGreaterThan(SEEDS); + }); +}); + +/** + * Faces carrying two or more endpoints, with the nearest pair asserted at least + * PIPE_SPACING apart (the generator's slot rule, which the routing preference + * shares). + */ +function countSharedFaces(elements: readonly ViewElement[], flows: readonly FlowViewElement[]): number { + let shared = 0; + for (const s of elements) { + if (s.type !== 'stock') continue; + const byFace = new Map(); + for (const f of flows) { + for (const p of [f.points[0], f.points[f.points.length - 1]]) { + if (p.attachedToUid !== s.uid) continue; + const face = faceOf(p, s); + const along = face === 'left' || face === 'right' ? p.y : p.x; + byFace.set(face, [...(byFace.get(face) ?? []), along]); + } + } + for (const positions of byFace.values()) { + if (positions.length < 2) continue; + shared++; + positions.sort((a, b) => a - b); + for (let i = 1; i < positions.length; i++) { + expect(positions[i] - positions[i - 1]).toBeGreaterThanOrEqual(PIPE_SPACING); + } + } + } + return shared; +} + +function strictArms(view: StockFlowView): string[] { + return checkFlowInvariants(view, { mode: 'strict' }) + .map((v) => `${v.uid}:${v.arm}`) + .sort(); +} + +describe('genImportedScene', () => { + it('is deterministic per seed', () => { + const a = genImportedScene(new Rng(7)); + const b = genImportedScene(new Rng(7)); + expect(sceneFingerprint(a)).toBe(sceneFingerprint(b)); + expect(a.mutations).toEqual(b.mutations); + }); + + it(`passes tolerant mode, M1 and M3, while strict mode reports exactly the recorded arms, over ${SEEDS} seeds`, () => { + for (let seed = 1; seed <= SEEDS; seed++) { + const { view, model, mutations } = genImportedScene(new Rng(seed)); + const tolerant = checkFlowInvariants(view, { mode: 'tolerant' }); + const structural = [ + ...checkKindAgreement(view, model.variables), + ...checkReferentialIntegrity(view), + ...checkStockListDuplicates(model.variables), + ]; + expect(`seed ${seed}\n${formatFlowViolations(tolerant)}${formatViewViolations(structural)}`).toBe( + `seed ${seed}\n`, + ); + + const expectedStrict = mutations.flatMap((m) => m.flowArms.map((arm) => `${m.flowUid}:${arm}`)).sort(); + expect(`seed ${seed} ${strictArms(view).join(' ')}`).toBe(`seed ${seed} ${expectedStrict.join(' ')}`); + + const agreement = checkStockFlowAgreement(view, model.variables) + .map((v) => v.arm) + .sort(); + const expectedAgreement = mutations.flatMap((m) => m.viewArms).sort(); + expect(`seed ${seed} ${agreement.join(' ')}`).toBe(`seed ${seed} ${expectedAgreement.join(' ')}`); + } + }); + + it('applies every corpus shape across seeds', () => { + const seen = new Map(); + for (let seed = 1; seed <= SEEDS; seed++) { + for (const m of genImportedScene(new Rng(seed)).mutations) { + seen.set(m.shape, (seen.get(m.shape) ?? 0) + 1); + } + } + for (const shape of IMPORT_SHAPES) { + expect(`${shape}: ${seen.get(shape) ?? 0}`).not.toBe(`${shape}: 0`); + } + }); +}); + +// A committed edit reaches the editor through an engine round trip, which is not +// value-exact (one-ULP float drift, re-derived fields); these scenes must keep +// their strict arms through it or the checker would misjudge committed views. +const ROUND_TRIP_SEEDS = 30; + +describeWithEngine('generated scenes through an engine round trip', () => { + let engine: EngineModule; + + beforeAll(async () => { + engine = await loadEngine(); + }); + + async function roundTrip(scene: Scene): Promise { + const projectJson: JsonProject = { + name: 'scene', + simSpecs: { startTime: 0, endTime: 1, dt: '1' }, + models: [modelToJson(scene.model)], + }; + const project = await engine.Project.openJson(JSON.stringify(projectJson)); + try { + return (await editorModel(project)).views[0]; + } finally { + await project.dispose(); + } + } + + it(`keeps the strict arm multiset of ${ROUND_TRIP_SEEDS} strict and ${ROUND_TRIP_SEEDS} imported scenes`, async () => { + for (let seed = 1; seed <= ROUND_TRIP_SEEDS; seed++) { + for (const [kind, scene] of [ + ['strict', genScene(new Rng(seed))], + ['imported', genImportedScene(new Rng(seed))], + ] as const) { + const view = await roundTrip(scene); + expect(`${kind} seed ${seed} ${strictArms(view).join(' ')}`).toBe( + `${kind} seed ${seed} ${strictArms(scene.view).join(' ')}`, + ); + } + } + }); +}); diff --git a/src/diagram/tests/selection-logic.test.ts b/src/diagram/tests/selection-logic.test.ts deleted file mode 100644 index 6c922c4eb..000000000 --- a/src/diagram/tests/selection-logic.test.ts +++ /dev/null @@ -1,122 +0,0 @@ -// Copyright 2026 The Simlin Authors. All rights reserved. -// Use of this source code is governed by the Apache License, -// Version 2.0, that can be found in the LICENSE file. - -import { describe, it, expect } from '@rstest/core'; - -import { - computeMouseDownSelection, - computeMouseUpSelection, - pointerStateReset, - resolveSelectionForReattachment, -} from '../selection-logic'; - -describe('computeMouseDownSelection', () => { - it('click unselected element without modifier replaces selection', () => { - const result = computeMouseDownSelection(new Set([1, 2, 3]), 5, false); - expect(result.newSelection).toEqual(new Set([5])); - expect(result.deferSingleSelect).toBeUndefined(); - }); - - it('click unselected element with modifier adds to selection', () => { - const result = computeMouseDownSelection(new Set([1, 2]), 3, true); - expect(result.newSelection).toEqual(new Set([1, 2, 3])); - expect(result.deferSingleSelect).toBeUndefined(); - }); - - it('click selected element with modifier removes from selection', () => { - const result = computeMouseDownSelection(new Set([1, 2, 3]), 2, true); - expect(result.newSelection).toEqual(new Set([1, 3])); - expect(result.deferSingleSelect).toBeUndefined(); - }); - - it('click element already in multi-selection without modifier defers', () => { - const result = computeMouseDownSelection(new Set([1, 2, 3]), 2, false); - expect(result.newSelection).toBeUndefined(); - expect(result.deferSingleSelect).toBe(2); - }); - - it('click sole selected element without modifier defers', () => { - const result = computeMouseDownSelection(new Set([5]), 5, false); - expect(result.newSelection).toBeUndefined(); - expect(result.deferSingleSelect).toBe(5); - }); - - it('click with empty selection without modifier selects it', () => { - const result = computeMouseDownSelection(new Set(), 1, false); - expect(result.newSelection).toEqual(new Set([1])); - expect(result.deferSingleSelect).toBeUndefined(); - }); - - it('modifier click on only element in selection removes it', () => { - const result = computeMouseDownSelection(new Set([1]), 1, true); - expect(result.newSelection).toEqual(new Set()); - expect(result.deferSingleSelect).toBeUndefined(); - }); -}); - -describe('resolveSelectionForReattachment', () => { - it('overrides selection with flow UID when re-attachment is activated', () => { - // When clicking a cloud triggers flow re-attachment, the selection must - // contain the flow UID -- mouseUp reads only(selection) and expects a - // FlowViewElement for attachment handling. - const cloudUid = 10; - const flowUid = 20; - const result = resolveSelectionForReattachment(new Set([cloudUid]), true, flowUid); - expect(result).toEqual(new Set([flowUid])); - }); - - it('preserves original selection when re-attachment is not activated', () => { - const result = resolveSelectionForReattachment(new Set([10]), false, 20); - expect(result).toEqual(new Set([10])); - }); - - it('preserves multi-element selection when re-attachment is not activated', () => { - const result = resolveSelectionForReattachment(new Set([1, 2, 3]), false, 20); - expect(result).toEqual(new Set([1, 2, 3])); - }); -}); - -describe('computeMouseUpSelection', () => { - it('deferred + no drag collapses to single element', () => { - const result = computeMouseUpSelection(2, false); - expect(result).toEqual(new Set([2])); - }); - - it('deferred + drag occurred returns undefined', () => { - const result = computeMouseUpSelection(2, true); - expect(result).toBeUndefined(); - }); - - it('no deferred UID + no drag returns undefined', () => { - const result = computeMouseUpSelection(undefined, false); - expect(result).toBeUndefined(); - }); - - it('no deferred UID + drag returns undefined', () => { - const result = computeMouseUpSelection(undefined, true); - expect(result).toBeUndefined(); - }); -}); - -describe('pointerStateReset', () => { - it('clears moveDelta', () => { - const reset = pointerStateReset(); - expect(reset.moveDelta).toBeUndefined(); - }); - - it('resets the discrete interaction mode to idle', () => { - // Post tagged-union migration (#65): the eight former boolean modes plus the - // loose labelSide / flowStillBeingCreated / draggingSegmentIndex fields all - // collapse into `interaction: idle`. - const reset = pointerStateReset(); - expect(reset.interaction).toEqual({ mode: 'idle' }); - }); - - it('clears the continuous pointer companions', () => { - const reset = pointerStateReset(); - expect(reset.dragSelectionPoint).toBeUndefined(); - expect(reset.inCreation).toBeUndefined(); - expect(reset.inCreationCloud).toBeUndefined(); - }); -}); diff --git a/src/diagram/tests/support/engine.ts b/src/diagram/tests/support/engine.ts new file mode 100644 index 000000000..6496115fa --- /dev/null +++ b/src/diagram/tests/support/engine.ts @@ -0,0 +1,68 @@ +// Copyright 2026 The Simlin Authors. All rights reserved. +// Use of this source code is governed by the Apache License, +// Version 2.0, that can be found in the LICENSE file. + +/** + * The real WASM engine for the invariant tests that derive their inputs through + * it (corpus imports, the rename patch path, scene round trips). + * + * On a clean checkout without `libsimlin.wasm` those suites skip so the package + * stays testable standalone. Under CI they never skip: the workflow builds the + * engine first (`DISABLE_WASM_OPT=1 pnpm build`), so a missing module there is a + * broken build, and the suite runs and fails on it instead of reporting green. + */ + +import { describe } from '@rstest/core'; + +import * as fs from 'fs'; +import * as path from 'path'; + +import { projectFromJson, type Model } from '@simlin/core/datamodel'; +import type { JsonProject } from '@simlin/engine'; + +export type EngineModule = typeof import('@simlin/engine'); +export type EngineProject = InstanceType; + +export const WASM_PATH = path.join(__dirname, '..', '..', '..', 'engine', 'core', 'libsimlin.wasm'); + +export function engineSuiteMode(wasmBuilt: boolean, ci: string | undefined): 'run' | 'skip' { + const underCi = ci !== undefined && ci !== '' && ci !== 'false'; + return wasmBuilt || underCi ? 'run' : 'skip'; +} + +const mode = engineSuiteMode(fs.existsSync(WASM_PATH), process.env.CI); + +export const describeWithEngine: typeof describe = mode === 'run' ? describe : describe.skip; + +if (mode === 'skip') { + console.warn(`[invariant tests] skipping engine-backed checks: ${WASM_PATH} not found; run \`pnpm build\`.`); +} + +export async function loadEngine(): Promise { + // A dynamic import, so a checkout without the built engine package still + // resolves the modules of the skipped suites. + const engine = await import('@simlin/engine'); + await engine.resetWasm(); + // Under jsdom a node Buffer is not an instance of this realm's Uint8Array, + // which the engine checks; copy the bytes into a buffer allocated here. + const bytes = fs.readFileSync(WASM_PATH); + const buffer = new ArrayBuffer(bytes.length); + new Uint8Array(buffer).set(bytes); + engine.configureWasm({ source: new Uint8Array(buffer) }); + await engine.ready(); + return engine; +} + +/** The editor's load path: the engine's JSON serialization through the production deserializer. */ +export async function editorModel(project: EngineProject, name = 'main'): Promise { + const json = JSON.parse(await project.serializeJson()) as JsonProject; + return mainModel(projectFromJson(json).models, name); +} + +export function mainModel(models: ReadonlyMap, name = 'main'): Model { + const model = models.get(name) ?? [...models.values()][0]; + if (model === undefined) { + throw new Error('project has no models'); + } + return model; +} diff --git a/src/diagram/tests/support/flow-geometry-fixtures.ts b/src/diagram/tests/support/flow-geometry-fixtures.ts new file mode 100644 index 000000000..9b10bc1fc --- /dev/null +++ b/src/diagram/tests/support/flow-geometry-fixtures.ts @@ -0,0 +1,217 @@ +// Copyright 2026 The Simlin Authors. All rights reserved. +// Use of this source code is governed by the Apache License, +// Version 2.0, that can be found in the LICENSE file. + +/** + * Fixtures for the flow-geometry tests. Views are built as engine JSON and + * loaded through the production `modelFromJson`, so element fields (idents, + * attachment uids, stock flow lists) are what the editor sees; terminals are + * derived through the production `flowTerminals`. The helpers here only + * assemble inputs and read outputs; every geometric expectation a test states + * is computed with the independent arc helpers below, never with the core's. + */ + +import type { JsonModel, JsonViewElement } from '@simlin/engine'; +import { + modelFromJson, + type CloudViewElement, + type FlowViewElement, + type StockFlowView, + type StockViewElement, + type UID, + type ViewElement, +} from '@simlin/core/datamodel'; + +import type { FlowGeometry } from '../../flow-geometry'; +import { checkFlowInvariants, formatFlowViolations } from './flow-invariants'; + +export type Pt = { readonly x: number; readonly y: number }; + +export function loadView(elements: readonly JsonViewElement[]): StockFlowView { + return modelFromJson({ name: 'main', views: [{ elements: [...elements] }] } as JsonModel).views[0]; +} + +export const stockJson = (uid: UID, x: number, y: number): JsonViewElement => ({ + type: 'stock', + uid, + name: `s${uid}`, + x, + y, +}); + +export const cloudJson = (uid: UID, flowUid: UID, x: number, y: number): JsonViewElement => ({ + type: 'cloud', + uid, + flowUid, + x, + y, +}); + +export function flowJson( + uid: UID, + valve: Pt, + points: readonly Pt[], + attachments: { readonly source?: UID; readonly sink?: UID }, +): JsonViewElement { + return { + type: 'flow', + uid, + name: `f${uid}`, + x: valve.x, + y: valve.y, + points: points.map((p, i) => { + const attached = i === 0 ? attachments.source : i === points.length - 1 ? attachments.sink : undefined; + return attached === undefined ? { x: p.x, y: p.y } : { x: p.x, y: p.y, attachedToUid: attached }; + }), + }; +} + +export function byUidOf(view: StockFlowView): Map { + return new Map(view.elements.map((e) => [e.uid, e])); +} + +export function flowOf(view: StockFlowView, uid: UID): FlowViewElement { + const el = view.elements.find((e) => e.uid === uid); + if (el?.type !== 'flow') { + throw new Error(`no flow ${uid}`); + } + return el; +} + +export function stockOf(view: StockFlowView, uid: UID): StockViewElement { + const el = view.elements.find((e) => e.uid === uid); + if (el?.type !== 'stock') { + throw new Error(`no stock ${uid}`); + } + return el; +} + +export function cloudOf(view: StockFlowView, uid: UID): CloudViewElement { + const el = view.elements.find((e) => e.uid === uid); + if (el?.type !== 'cloud') { + throw new Error(`no cloud ${uid}`); + } + return el; +} + +/** The view with `changed` elements replaced (or added) and `removed` uids dropped. */ +export function patchView( + view: StockFlowView, + changed: readonly ViewElement[], + removed: readonly UID[] = [], +): StockFlowView { + const m = byUidOf(view); + for (const uid of removed) { + m.delete(uid); + } + for (const el of changed) { + m.set(el.uid, el); + } + return { ...view, elements: [...m.values()] }; +} + +/** The view a planner would render after applying `g` (plus any moved terminals). */ +export function applyGeometry( + view: StockFlowView, + g: FlowGeometry, + also: readonly ViewElement[] = [], + removed: readonly UID[] = [], +): StockFlowView { + return patchView(view, [...also, g.flow, ...g.clouds], removed); +} + +/** Strict violations of the routed flows, formatted so a failure shows every arm and its numbers. */ +export function strictReport(view: StockFlowView, routed: readonly UID[]): string { + return formatFlowViolations(checkFlowInvariants(view, { mode: 'strict', routed: new Set(routed) })); +} + +// --------------------------------------------------------------------------- +// Independent path geometry (never the core's) + +export function pathLength(pts: readonly Pt[]): number { + let total = 0; + for (let i = 0; i < pts.length - 1; i++) { + total += Math.hypot(pts[i + 1].x - pts[i].x, pts[i + 1].y - pts[i].y); + } + return total; +} + +/** Arc-length position of the nearest point on the path. */ +export function arcOf(pts: readonly Pt[], p: Pt): number { + let best = Infinity; + let position = 0; + let traversed = 0; + for (let i = 0; i < pts.length - 1; i++) { + const a = pts[i]; + const b = pts[i + 1]; + const dx = b.x - a.x; + const dy = b.y - a.y; + const l2 = dx * dx + dy * dy; + const t = l2 === 0 ? 0 : Math.max(0, Math.min(1, ((p.x - a.x) * dx + (p.y - a.y) * dy) / l2)); + const d = Math.hypot(p.x - (a.x + t * dx), p.y - (a.y + t * dy)); + if (d < best - 1e-9) { + best = d; + position = traversed + t * Math.sqrt(l2); + } + traversed += Math.sqrt(l2); + } + return position; +} + +export function distanceToPath(p: Pt, pts: readonly Pt[]): number { + let best = Infinity; + for (let i = 0; i < pts.length - 1; i++) { + const a = pts[i]; + const b = pts[i + 1]; + const dx = b.x - a.x; + const dy = b.y - a.y; + const l2 = dx * dx + dy * dy; + const t = l2 === 0 ? 0 : Math.max(0, Math.min(1, ((p.x - a.x) * dx + (p.y - a.y) * dy) / l2)); + best = Math.min(best, Math.hypot(p.x - (a.x + t * dx), p.y - (a.y + t * dy))); + } + return best; +} + +function sample(pts: readonly Pt[], step: number): Pt[] { + const out: Pt[] = [pts[0]]; + for (let i = 0; i < pts.length - 1; i++) { + const a = pts[i]; + const b = pts[i + 1]; + const n = Math.max(1, Math.ceil(Math.hypot(b.x - a.x, b.y - a.y) / step)); + for (let k = 1; k <= n; k++) { + out.push({ x: a.x + ((b.x - a.x) * k) / n, y: a.y + ((b.y - a.y) * k) / n }); + } + } + return out; +} + +/** Symmetric Hausdorff distance between two paths, sampled every `step` px. */ +export function hausdorff(a: readonly Pt[], b: readonly Pt[], step = 1): number { + let m = 0; + for (const p of sample(a, step)) { + m = Math.max(m, distanceToPath(p, b)); + } + for (const p of sample(b, step)) { + m = Math.max(m, distanceToPath(p, a)); + } + return m; +} + +/** The segment directions of a path as letters (R, L, D, U): its shape, independent of lengths. */ +export function directions(pts: readonly Pt[]): string { + return pts + .slice(1) + .map((p, i) => { + const q = pts[i]; + if (Math.abs(p.y - q.y) <= 1e-6) { + return p.x > q.x ? 'R' : 'L'; + } + return p.y > q.y ? 'D' : 'U'; + }) + .join(''); +} + +export function fmtFlow(f: FlowViewElement): string { + const r = (n: number): string => String(Math.round(n * 1000) / 1000); + return `valve(${r(f.x)},${r(f.y)}) ${f.points.map((p) => `(${r(p.x)},${r(p.y)})${p.attachedToUid === undefined ? '' : `@${p.attachedToUid}`}`).join(' ')}`; +} diff --git a/src/diagram/tests/support/flow-invariants.ts b/src/diagram/tests/support/flow-invariants.ts new file mode 100644 index 000000000..32a98b54c --- /dev/null +++ b/src/diagram/tests/support/flow-invariants.ts @@ -0,0 +1,665 @@ +// Copyright 2026 The Simlin Authors. All rights reserved. +// Use of this source code is governed by the Apache License, +// Version 2.0, that can be found in the LICENSE file. + +/** + * The flow geometry invariants G1-G8 of + * docs/design-plans/2026-09-10-diagram-editing-core.md, as a checker over a + * StockFlowView. + * + * Every check arm has its own name in FLOW_ARMS and its own small function, so + * a change to one definition in the plan stays local, and the tests derive + * their fixture rows from that enumeration. + * + * Modes. `strict` is what a committed edit must produce for every flow it + * routed. `tolerant` is what an INPUT view must satisfy for the editor to + * accept it at all: the plan requires the editor to render imported and legacy + * views unmodified and heal a flow only when an edit routes it, and `heal` is + * specified to repair every geometric arm (off-face endpoints, clouds off their + * endpoint, diagonal segments, unnormalized points), so tolerant mode keeps only + * the structural G1 arms whose violation leaves nothing to heal from. + */ + +import type { + CloudViewElement, + FlowViewElement, + Point, + StockFlowView, + StockViewElement, + UID, + ViewElement, +} from '@simlin/core/datamodel'; + +import { FlowArrowheadRadius, StockHeight, StockWidth } from '../../drawing/default'; + +export const CORNER_CLEARANCE = 3; +export const MIN_SEGMENT = 10; +export const VALVE_CLAMP_MARGIN = 10; +// The renderer pulls the path back 7.5px along the final segment to seat the +// arrowhead (`finalAdjust` in drawing/Flow.tsx); a shorter final segment tucks +// the arrowhead's back into the preceding turn. +export const MIN_SINK_SEGMENT = FlowArrowheadRadius + 7.5; +export const PIPE_SPACING = 10; + +// Face points are fractions of the stock size and valves are interpolated, so +// exact float comparison would report noise; every defect these checks exist +// for is a pixel or more. +export const GEOMETRY_EPSILON = 1e-6; + +const HALF_WIDTH = StockWidth / 2; +const HALF_HEIGHT = StockHeight / 2; + +export const FLOW_ARMS = { + G1: [ + 'minPoints', + 'nonFinite', + 'unattachedEndpoint', + 'danglingAttachment', + 'attachmentKind', + 'foreignCloud', + 'interiorAttached', + 'nonPositiveUid', + 'sourceIsSink', + ], + G2: ['diagonal'], + G3: ['zeroLength', 'collinear', 'shortStub', 'shortRiser', 'shortSink'], + G4: ['offFace', 'cornerClearance'], + G5: ['notPerpendicular', 'inward'], + G6: ['segmentThroughTerminal', 'cloudInsideStock'], + G7: ['cloudOffEndpoint'], + G8: ['valveOffPath', 'valveMargin'], +} as const; + +export type FlowInvariant = keyof typeof FLOW_ARMS; +export type FlowArm = { [K in FlowInvariant]: `${K}.${(typeof FLOW_ARMS)[K][number]}` }[FlowInvariant]; + +export const ALL_FLOW_ARMS: readonly FlowArm[] = (Object.keys(FLOW_ARMS) as FlowInvariant[]).flatMap((invariant) => + FLOW_ARMS[invariant].map((arm) => `${invariant}.${arm}` as FlowArm), +); + +/** + * The arms tolerant mode still reports. Two structural arms are deliberately + * absent: an unattached endpoint, because the Vensim importer emits flows with no + * attachment and the editor must accept them; and a flow whose source and sink are + * the same element, which no corpus import produces (#720 only speculates that one + * could) but which tolerating is the permissive direction -- the editor must never + * throw on it, and routing such a flow is heal's job, not the input gate's. + */ +export const TOLERANT_FLOW_ARMS: ReadonlySet = new Set([ + 'G1.minPoints', + 'G1.nonFinite', + 'G1.danglingAttachment', + 'G1.attachmentKind', + 'G1.foreignCloud', + 'G1.interiorAttached', +]); + +export type FlowInvariantMode = 'strict' | 'tolerant'; + +export interface FlowInvariantOptions { + readonly mode: FlowInvariantMode; + /** + * The flows an edit routed. In strict mode the full arm set applies only to + * these (the rest of the view may carry imported violations) and every other + * flow gets the tolerant arms. Undefined means every flow is routed. + */ + readonly routed?: ReadonlySet; +} + +export interface FlowViolation { + readonly arm: FlowArm; + /** The flow's uid; for G1.nonPositiveUid, the offending element's. */ + readonly uid: UID; + readonly numbers: Readonly>; + readonly message: string; +} + +export function formatFlowViolations(violations: readonly FlowViolation[]): string { + return violations.map((v) => `${v.arm} uid=${v.uid} ${JSON.stringify(v.numbers)} ${v.message}`).join('\n'); +} + +type Terminal = + | { readonly kind: 'stock'; readonly stock: StockViewElement } + | { readonly kind: 'cloud'; readonly cloud: CloudViewElement }; + +interface FlowContext { + readonly flow: FlowViewElement; + readonly source: Terminal | undefined; + readonly sink: Terminal | undefined; +} + +type Report = (arm: FlowArm, numbers: Record, message: string) => void; + +export function checkFlowInvariants(view: StockFlowView, opts: FlowInvariantOptions): FlowViolation[] { + const out: FlowViolation[] = []; + const byUid = new Map(); + for (const el of view.elements) { + byUid.set(el.uid, el); + } + + if (opts.mode === 'strict') { + checkNonPositiveUids(view, out); + } + + for (const el of view.elements) { + if (el.type !== 'flow') { + continue; + } + const strict = opts.mode === 'strict' && (opts.routed === undefined || opts.routed.has(el.uid)); + const report: Report = (arm, numbers, message) => { + if (strict || TOLERANT_FLOW_ARMS.has(arm)) { + out.push({ arm, uid: el.uid, numbers, message }); + } + }; + const ctx = checkStructure(el, byUid, report); + if (ctx === undefined) { + continue; + } + checkOrthogonal(ctx, report); + checkNormalized(ctx, report); + checkFaceAttachment(ctx, report); + checkPerpendicularExit(ctx, report); + checkNoBodyCrossing(ctx, view, report); + checkCloudCoincidence(ctx, report); + checkValveOnPath(ctx, report); + } + return out; +} + +// --------------------------------------------------------------------------- +// G1 structure + +// "No uid <= 0 in a committed view": the Canvas stages sentinel uids (-2..-5) +// for in-creation elements, so this is a property of committed views only and +// applies to every element, routed or not. +function checkNonPositiveUids(view: StockFlowView, out: FlowViolation[]): void { + for (const el of view.elements) { + if (el.uid <= 0) { + out.push({ + arm: 'G1.nonPositiveUid', + uid: el.uid, + numbers: { uid: el.uid }, + message: `${el.type} element has uid ${el.uid}`, + }); + } + } +} + +/** + * Returns the resolved terminals, or undefined when the flow is too broken for + * any geometric arm to mean anything (fewer than two points, or a non-finite + * coordinate every later computation would only echo). + */ +function checkStructure( + flow: FlowViewElement, + byUid: ReadonlyMap, + report: Report, +): FlowContext | undefined { + const pts = flow.points; + const coordinates = [flow.x, flow.y, ...pts.flatMap((p) => [p.x, p.y])]; + const nonFinite = coordinates.filter((v) => !Number.isFinite(v)).length; + if (nonFinite > 0) { + report('G1.nonFinite', { count: nonFinite }, `${nonFinite} non-finite coordinate(s)`); + } + if (pts.length < 2) { + report('G1.minPoints', { points: pts.length }, `flow has ${pts.length} point(s)`); + return undefined; + } + for (let i = 1; i < pts.length - 1; i++) { + const attached = pts[i].attachedToUid; + if (attached !== undefined) { + report('G1.interiorAttached', { index: i, attachedToUid: attached }, `interior point ${i} is attached`); + } + } + const source = resolveTerminal(flow, 0, byUid, report); + const sink = resolveTerminal(flow, pts.length - 1, byUid, report); + const sourceUid = pts[0].attachedToUid; + // A cloud at both ends is also M3.cloudEndpointCount (a cloud is an endpoint of + // its flow exactly once); this arm owns the stock self-loop the planner refuses. + if (source !== undefined && sink !== undefined && sourceUid === pts[pts.length - 1].attachedToUid) { + report('G1.sourceIsSink', { attachedToUid: sourceUid! }, `source and sink are both element ${sourceUid}`); + } + if (nonFinite > 0) { + return undefined; + } + return { flow, source, sink }; +} + +function resolveTerminal( + flow: FlowViewElement, + index: number, + byUid: ReadonlyMap, + report: Report, +): Terminal | undefined { + const end = index === 0 ? 'source' : 'sink'; + const attached = flow.points[index].attachedToUid; + if (attached === undefined) { + report('G1.unattachedEndpoint', { endIndex: index }, `${end} endpoint is unattached`); + return undefined; + } + const el = byUid.get(attached); + if (el === undefined) { + report( + 'G1.danglingAttachment', + { endIndex: index, attachedToUid: attached }, + `${end} references missing uid ${attached}`, + ); + return undefined; + } + if (el.type === 'stock') { + return { kind: 'stock', stock: el }; + } + if (el.type === 'cloud') { + if (el.flowUid !== flow.uid) { + report( + 'G1.foreignCloud', + { endIndex: index, cloudUid: el.uid, cloudFlowUid: el.flowUid }, + `${end} cloud ${el.uid} belongs to flow ${el.flowUid}`, + ); + return undefined; + } + return { kind: 'cloud', cloud: el }; + } + report('G1.attachmentKind', { endIndex: index, attachedToUid: attached }, `${end} is attached to a ${el.type}`); + return undefined; +} + +// --------------------------------------------------------------------------- +// G2 orthogonal + +function checkOrthogonal(ctx: FlowContext, report: Report): void { + const pts = ctx.flow.points; + for (let i = 0; i < pts.length - 1; i++) { + if (orientation(pts[i], pts[i + 1]) === 'diagonal') { + report( + 'G2.diagonal', + { segment: i, dx: pts[i + 1].x - pts[i].x, dy: pts[i + 1].y - pts[i].y }, + `segment ${i} is not axis-aligned`, + ); + } + } +} + +// --------------------------------------------------------------------------- +// G3 normalized + +function checkNormalized(ctx: FlowContext, report: Report): void { + const pts = ctx.flow.points; + const segmentCount = pts.length - 1; + for (let i = 0; i < segmentCount; i++) { + if (orientation(pts[i], pts[i + 1]) === 'zero') { + report('G3.zeroLength', { segment: i }, `segment ${i} has zero length`); + } + } + for (let i = 0; i + 1 < segmentCount; i++) { + const a = orientation(pts[i], pts[i + 1]); + const b = orientation(pts[i + 1], pts[i + 2]); + if ((a === 'horizontal' || a === 'vertical') && a === b) { + report('G3.collinear', { segment: i + 1 }, `segments ${i} and ${i + 1} are collinear`); + } + } + if (!terminalsLeaveRoom(ctx)) { + return; + } + for (let i = 0; i < segmentCount; i++) { + const length = distance(pts[i], pts[i + 1]); + if (length <= GEOMETRY_EPSILON) { + continue; + } + if (i === segmentCount - 1) { + if (length < MIN_SINK_SEGMENT - GEOMETRY_EPSILON) { + report('G3.shortSink', { segment: i, length, minimum: MIN_SINK_SEGMENT }, `final segment is ${length}px`); + } + } else if (i === 0) { + if (length < MIN_SEGMENT - GEOMETRY_EPSILON) { + report('G3.shortStub', { segment: i, length, minimum: MIN_SEGMENT }, `first segment is ${length}px`); + } + } else if (length < MIN_SEGMENT - GEOMETRY_EPSILON) { + report('G3.shortRiser', { segment: i, length, minimum: MIN_SEGMENT }, `interior segment ${i} is ${length}px`); + } + } +} + +/** + * G3's "whenever the terminals leave room": a route needs at least a + * MIN_SEGMENT stub out of the source and a MIN_SINK_SEGMENT segment into the + * sink, so the minima are demanded only when the source body inflated by + * MIN_SEGMENT and the sink body inflated by MIN_SINK_SEGMENT are disjoint. A + * missing terminal (an unattached endpoint, tolerated input) cannot crowd. + */ +function terminalsLeaveRoom(ctx: FlowContext): boolean { + if (ctx.source === undefined || ctx.sink === undefined) { + return true; + } + return !boxesOverlap( + inflate(terminalBody(ctx.source), MIN_SEGMENT), + inflate(terminalBody(ctx.sink), MIN_SINK_SEGMENT), + ); +} + +// --------------------------------------------------------------------------- +// G4 face attachment + +function checkFaceAttachment(ctx: FlowContext, report: Report): void { + for (const [index, terminal] of endTerminals(ctx)) { + if (terminal?.kind !== 'stock') { + continue; + } + const p = ctx.flow.points[index]; + const stock = terminal.stock; + const faces = facesOf(p, stock); + if (faces.length === 0) { + report( + 'G4.offFace', + { endIndex: index, dx: p.x - stock.x, dy: p.y - stock.y }, + `endpoint is not on a face of stock ${stock.uid}`, + ); + continue; + } + const clearance = Math.min(...faces.map((face) => faceClearance(p, stock, face))); + if (clearance < CORNER_CLEARANCE - GEOMETRY_EPSILON) { + report( + 'G4.cornerClearance', + { endIndex: index, clearance, minimum: CORNER_CLEARANCE }, + `endpoint is ${clearance}px from a corner of stock ${stock.uid}`, + ); + } + } +} + +// --------------------------------------------------------------------------- +// G5 perpendicular exit + +function checkPerpendicularExit(ctx: FlowContext, report: Report): void { + const pts = ctx.flow.points; + for (const [index, terminal] of endTerminals(ctx)) { + if (terminal?.kind !== 'stock') { + continue; + } + const p = pts[index]; + const faces = facesOf(p, terminal.stock); + // "That face" is undefined for an off-face endpoint; G4.offFace owns it. + if (faces.length === 0) { + continue; + } + // The adjacent segment is the first one of positive length, so a coincident + // point (G3.zeroLength) does not hide the direction the pipe actually takes. + const neighbor = firstDistinctNeighbor(pts, index); + if (neighbor === undefined) { + continue; + } + const dx = neighbor.x - p.x; + const dy = neighbor.y - p.y; + if (faces.some((face) => leavesOutward(face, dx, dy))) { + continue; + } + const perpendicular = faces.some((face) => + face === 'left' || face === 'right' ? Math.abs(dy) <= GEOMETRY_EPSILON : Math.abs(dx) <= GEOMETRY_EPSILON, + ); + report( + perpendicular ? 'G5.inward' : 'G5.notPerpendicular', + { endIndex: index, dx, dy }, + `segment at the ${faces.join('/')} face ${perpendicular ? 'points into the stock' : 'is not perpendicular to it'}`, + ); + } +} + +function leavesOutward(face: Face, dx: number, dy: number): boolean { + const horizontal = Math.abs(dy) <= GEOMETRY_EPSILON; + const vertical = Math.abs(dx) <= GEOMETRY_EPSILON; + switch (face) { + case 'left': + return horizontal && dx < 0; + case 'right': + return horizontal && dx > 0; + case 'top': + return vertical && dy < 0; + case 'bottom': + return vertical && dy > 0; + } +} + +// --------------------------------------------------------------------------- +// G6 no body crossing + +/** + * "No cloud center lies inside a stock" is read as ANY stock of the view. Read + * as this flow's other terminal the clause could never fire: a cloud inside + * that stock makes the inflated terminal bodies overlap, which is exactly the + * precondition that exempts G6. + */ +function checkNoBodyCrossing(ctx: FlowContext, view: StockFlowView, report: Report): void { + // With one terminal missing (tolerated input) there is no pair to overlap, + // so the precondition holds. + if ( + ctx.source !== undefined && + ctx.sink !== undefined && + boxesOverlap(inflate(terminalBody(ctx.source), MIN_SEGMENT), inflate(terminalBody(ctx.sink), MIN_SEGMENT)) + ) { + return; + } + const pts = ctx.flow.points; + const stocks = uniqueTerminalStocks(ctx); + for (const stock of stocks) { + for (let i = 0; i < pts.length - 1; i++) { + if (segmentEntersInterior(pts[i], pts[i + 1], stock)) { + report( + 'G6.segmentThroughTerminal', + { segment: i, stockUid: stock.uid }, + `segment ${i} crosses stock ${stock.uid}`, + ); + } + } + } + for (const [, terminal] of endTerminals(ctx)) { + if (terminal?.kind !== 'cloud') { + continue; + } + for (const el of view.elements) { + if (el.type === 'stock' && strictlyInside(terminal.cloud, el)) { + report( + 'G6.cloudInsideStock', + { cloudUid: terminal.cloud.uid, stockUid: el.uid }, + `cloud ${terminal.cloud.uid} lies inside stock ${el.uid}`, + ); + } + } + } +} + +function uniqueTerminalStocks(ctx: FlowContext): StockViewElement[] { + const stocks: StockViewElement[] = []; + for (const [, terminal] of endTerminals(ctx)) { + if (terminal?.kind === 'stock' && !stocks.some((s) => s.uid === terminal.stock.uid)) { + stocks.push(terminal.stock); + } + } + return stocks; +} + +// --------------------------------------------------------------------------- +// G7 cloud coincidence + +function checkCloudCoincidence(ctx: FlowContext, report: Report): void { + for (const [index, terminal] of endTerminals(ctx)) { + if (terminal?.kind !== 'cloud') { + continue; + } + const d = distance(ctx.flow.points[index], terminal.cloud); + if (d > GEOMETRY_EPSILON) { + report( + 'G7.cloudOffEndpoint', + { endIndex: index, distance: d }, + `cloud ${terminal.cloud.uid} is ${d}px from its endpoint`, + ); + } + } +} + +// --------------------------------------------------------------------------- +// G8 valve on path + +/** + * "When the path is long enough" is read as: long enough for a position at + * least VALVE_CLAMP_MARGIN from both ends to exist, i.e. >= 2 margins. The + * margin is measured in arc length from the path's ends, not per segment. + */ +function checkValveOnPath(ctx: FlowContext, report: Report): void { + const pts = ctx.flow.points; + const valve = { x: ctx.flow.x, y: ctx.flow.y }; + let best = Infinity; + let arcPosition = 0; + let traversed = 0; + for (let i = 0; i < pts.length - 1; i++) { + const length = distance(pts[i], pts[i + 1]); + const { d, t } = distanceToSegment(valve, pts[i], pts[i + 1]); + if (d < best) { + best = d; + arcPosition = traversed + t * length; + } + traversed += length; + } + if (best > GEOMETRY_EPSILON) { + report('G8.valveOffPath', { distance: best }, `valve is ${best}px off the path`); + return; + } + const pathLength = traversed; + if (pathLength < 2 * VALVE_CLAMP_MARGIN) { + return; + } + const fromEnd = Math.min(arcPosition, pathLength - arcPosition); + if (fromEnd < VALVE_CLAMP_MARGIN - GEOMETRY_EPSILON) { + report( + 'G8.valveMargin', + { arcPosition, pathLength, margin: VALVE_CLAMP_MARGIN }, + `valve is ${fromEnd}px from an end of the path`, + ); + } +} + +// --------------------------------------------------------------------------- +// Geometry + +type Face = 'left' | 'right' | 'top' | 'bottom'; + +interface Box { + readonly minX: number; + readonly maxX: number; + readonly minY: number; + readonly maxY: number; +} + +function endTerminals(ctx: FlowContext): Array<[number, Terminal | undefined]> { + return [ + [0, ctx.source], + [ctx.flow.points.length - 1, ctx.sink], + ]; +} + +function terminalBody(terminal: Terminal): Box { + if (terminal.kind === 'stock') { + const s = terminal.stock; + return { minX: s.x - HALF_WIDTH, maxX: s.x + HALF_WIDTH, minY: s.y - HALF_HEIGHT, maxY: s.y + HALF_HEIGHT }; + } + const c = terminal.cloud; + return { minX: c.x, maxX: c.x, minY: c.y, maxY: c.y }; +} + +function inflate(box: Box, by: number): Box { + return { minX: box.minX - by, maxX: box.maxX + by, minY: box.minY - by, maxY: box.maxY + by }; +} + +// Touching boxes do not overlap: the precondition exempts crowding, and two +// bodies exactly MIN_SEGMENT apart leave exactly enough room. +function boxesOverlap(a: Box, b: Box): boolean { + return a.minX < b.maxX && b.minX < a.maxX && a.minY < b.maxY && b.minY < a.maxY; +} + +type Orientation = 'zero' | 'horizontal' | 'vertical' | 'diagonal'; + +function orientation(a: Point, b: Point): Orientation { + const flatX = Math.abs(b.x - a.x) <= GEOMETRY_EPSILON; + const flatY = Math.abs(b.y - a.y) <= GEOMETRY_EPSILON; + if (flatX && flatY) return 'zero'; + if (flatY) return 'horizontal'; + if (flatX) return 'vertical'; + return 'diagonal'; +} + +function distance(a: { x: number; y: number }, b: { x: number; y: number }): number { + return Math.hypot(a.x - b.x, a.y - b.y); +} + +function distanceToSegment(p: { x: number; y: number }, a: Point, b: Point): { d: number; t: number } { + const dx = b.x - a.x; + const dy = b.y - a.y; + const lengthSquared = dx * dx + dy * dy; + const t = lengthSquared === 0 ? 0 : Math.max(0, Math.min(1, ((p.x - a.x) * dx + (p.y - a.y) * dy) / lengthSquared)); + return { d: Math.hypot(p.x - (a.x + t * dx), p.y - (a.y + t * dy)), t }; +} + +function firstDistinctNeighbor(pts: readonly Point[], index: number): Point | undefined { + const step = index === 0 ? 1 : -1; + for (let i = index + step; i >= 0 && i < pts.length; i += step) { + if (orientation(pts[index], pts[i]) !== 'zero') { + return pts[i]; + } + } + return undefined; +} + +function facesOf(p: Point, stock: StockViewElement): Face[] { + const dx = p.x - stock.x; + const dy = p.y - stock.y; + const faces: Face[] = []; + if (Math.abs(Math.abs(dx) - HALF_WIDTH) <= GEOMETRY_EPSILON && Math.abs(dy) <= HALF_HEIGHT + GEOMETRY_EPSILON) { + faces.push(dx > 0 ? 'right' : 'left'); + } + if (Math.abs(Math.abs(dy) - HALF_HEIGHT) <= GEOMETRY_EPSILON && Math.abs(dx) <= HALF_WIDTH + GEOMETRY_EPSILON) { + faces.push(dy > 0 ? 'bottom' : 'top'); + } + return faces; +} + +function faceClearance(p: Point, stock: StockViewElement, face: Face): number { + return face === 'left' || face === 'right' + ? HALF_HEIGHT - Math.abs(p.y - stock.y) + : HALF_WIDTH - Math.abs(p.x - stock.x); +} + +function strictlyInside(p: { x: number; y: number }, stock: StockViewElement): boolean { + return ( + Math.abs(p.x - stock.x) < HALF_WIDTH - GEOMETRY_EPSILON && Math.abs(p.y - stock.y) < HALF_HEIGHT - GEOMETRY_EPSILON + ); +} + +// Liang-Barsky clip against the stock rectangle inset by GEOMETRY_EPSILON, so a +// segment that starts on a face or runs along an edge line is not "through". +function segmentEntersInterior(a: Point, b: Point, stock: StockViewElement): boolean { + const minX = stock.x - HALF_WIDTH + GEOMETRY_EPSILON; + const maxX = stock.x + HALF_WIDTH - GEOMETRY_EPSILON; + const minY = stock.y - HALF_HEIGHT + GEOMETRY_EPSILON; + const maxY = stock.y + HALF_HEIGHT - GEOMETRY_EPSILON; + const dx = b.x - a.x; + const dy = b.y - a.y; + let t0 = 0; + let t1 = 1; + const clip = (p: number, q: number): boolean => { + if (p === 0) { + return q > 0; + } + const r = q / p; + if (p < 0) { + if (r > t1) return false; + if (r > t0) t0 = r; + } else { + if (r < t0) return false; + if (r < t1) t1 = r; + } + return true; + }; + if (!clip(-dx, a.x - minX) || !clip(dx, maxX - a.x) || !clip(-dy, a.y - minY) || !clip(dy, maxY - a.y)) { + return false; + } + return (t1 - t0) * Math.hypot(dx, dy) > GEOMETRY_EPSILON; +} diff --git a/src/diagram/tests/support/gesture-fixtures.ts b/src/diagram/tests/support/gesture-fixtures.ts new file mode 100644 index 000000000..6b1f1ab0a --- /dev/null +++ b/src/diagram/tests/support/gesture-fixtures.ts @@ -0,0 +1,235 @@ +// Copyright 2026 The Simlin Authors. All rights reserved. +// Use of this source code is governed by the Apache License, +// Version 2.0, that can be found in the LICENSE file. + +/** + * Fixtures for the gesture planner tests. Scenes are engine JSON loaded + * through the production `modelFromJson`, with a variable for every named + * element and stock inflow/outflow lists derived from the view's attachments, + * which is what a well-formed saved model gives the editor. Inputs to + * `planGesture` are assembled with the Canvas's defaults; routed flows are read + * off the plan (every flow element the plan replaced). + */ + +import type { JsonModel, JsonViewElement } from '@simlin/engine'; +import { canonicalize } from '@simlin/core/canonicalize'; +import { modelFromJson, type Model, type StockFlowView, type UID } from '@simlin/core/datamodel'; + +import type { GesturePlan, PlanInput, PressGesture } from '../../gesture-planner'; +import { allocateVariableName } from '../../variable-names'; +import { checkFlowInvariants, formatFlowViolations } from './flow-invariants'; +import { checkReferentialIntegrity, formatViewViolations } from './view-invariants'; + +export type Pt = { readonly x: number; readonly y: number }; + +export interface Scene { + readonly model: Model; + readonly view: StockFlowView; +} + +type JsonFlowElement = Extract; + +/** + * A scene from view elements. Every named element gets a variable of its kind; + * a stock lists the flows attached to it; `omit` leaves out the variables of the + * named elements it lists (an element naming no variable, as an import can + * carry); the auxes `arrayed` lists apply one equation over a dimension, so + * their elements render (and anchor links) as stacked, arrayed shapes. + */ +export function scene( + elements: readonly JsonViewElement[], + omit: readonly string[] = [], + arrayed: readonly string[] = [], +): Scene { + const named = (type: string): string[] => + elements + .filter((el) => el.type === type && !omit.includes((el as { name: string }).name)) + .map((el) => (el as { name: string }).name); + const flows = elements.filter((el): el is JsonFlowElement => el.type === 'flow'); + const listed = (stockUid: UID, end: 'source' | 'sink'): string[] => + flows + .filter((f) => { + const p = end === 'source' ? f.points![0] : f.points![f.points!.length - 1]; + return p?.attachedToUid === stockUid; + }) + .map((f) => f.name); + const stocks = elements + .filter((el) => el.type === 'stock' && !omit.includes((el as { name: string }).name)) + .map((el) => ({ + name: (el as { name: string }).name, + initialEquation: '1', + inflows: listed(el.uid!, 'sink'), + outflows: listed(el.uid!, 'source'), + })); + const json = { + name: 'main', + stocks, + flows: named('flow').map((name) => ({ name, equation: '1' })), + auxiliaries: named('aux').map((name) => + arrayed.includes(name) + ? { name, arrayedEquation: { dimensions: ['D'], equation: '1' } } + : { name, equation: '1' }, + ), + modules: named('module').map((name) => ({ name, modelName: 'sub' })), + views: [{ elements: [...elements], viewBox: { x: 0, y: 0, width: 1000, height: 1000 }, zoom: 1 }], + } as JsonModel; + const model = modelFromJson(json); + return { model, view: model.views[0] }; +} + +export const stock = (uid: UID, name: string, x: number, y: number): JsonViewElement => ({ + type: 'stock', + uid, + name, + x, + y, +}); + +export const aux = (uid: UID, name: string, x: number, y: number): JsonViewElement => ({ + type: 'aux', + uid, + name, + x, + y, +}); + +export const cloud = (uid: UID, flowUid: UID, x: number, y: number): JsonViewElement => ({ + type: 'cloud', + uid, + flowUid, + x, + y, +}); + +export const link = (uid: UID, fromUid: UID, toUid: UID, arc?: number): JsonViewElement => + arc === undefined ? { type: 'link', uid, fromUid, toUid } : { type: 'link', uid, fromUid, toUid, arc }; + +export function flow( + uid: UID, + name: string, + valve: Pt, + points: ReadonlyArray, +): JsonViewElement { + return { + type: 'flow', + uid, + name, + x: valve.x, + y: valve.y, + points: points.map(([x, y, attachedToUid]) => (attachedToUid === undefined ? { x, y } : { x, y, attachedToUid })), + }; +} + +/** Stock S (uid 1) at (100,100) -> cloud (uid 2) at (300,100) through flow F (uid 3), its source on S's right face. */ +export function stockToCloud(): Scene { + return scene([ + stock(1, 'S', 100, 100), + cloud(2, 3, 300, 100), + flow(3, 'F', { x: 200, y: 100 }, [ + [122.5, 100, 1], + [300, 100, 2], + ]), + ]); +} + +/** Stock A (uid 1) at (100,100) -> stock B (uid 2) at (400,100) through flow F (uid 3), on facing faces. */ +export function stockToStock(): Scene { + return scene([ + stock(1, 'A', 100, 100), + stock(2, 'B', 400, 100), + flow(3, 'F', { x: 250, y: 100 }, [ + [122.5, 100, 1], + [377.5, 100, 2], + ]), + ]); +} + +/** Auxes a (uid 10) at (100,300), b (uid 11) at (300,300), c (uid 12) at (300,450), and a link a -> b (uid 13). */ +export function linkedAuxes(): Scene { + return scene([aux(10, 'a', 100, 300), aux(11, 'b', 300, 300), aux(12, 'c', 300, 450), link(13, 10, 11, 20)]); +} + +/** The editor's allocator over a scene's variables and view names (see ProjectController.usedIdents). */ +export function namesOf(s: Scene): (base: string) => string { + const used = new Set(s.model.variables.keys()); + for (const el of s.view.elements) { + if ('name' in el && typeof el.name === 'string') { + used.add(canonicalize(el.name)); + } + } + return (base) => allocateVariableName(base, used); +} + +export function planInput( + s: Scene, + gesture: PressGesture, + press: Pt, + current: Pt, + overrides: Partial = {}, +): PlanInput { + return { + view: s.view, + variables: s.model.variables, + selection: new Set(), + gesture, + press, + current, + zoom: 1, + pointerType: 'mouse', + readOnly: false, + names: namesOf(s), + ...overrides, + }; +} + +/** The view a plan renders and commits. */ +export function planned(s: Scene, plan: GesturePlan): StockFlowView { + return { ...s.view, elements: plan.elements, nextUid: plan.nextUid }; +} + +/** + * Every flow the plan created or whose path or valve it changed: the flows its + * edit routed. A flow whose label alone changed was not routed. + */ +export function routedFlows(s: Scene, plan: GesturePlan): Set { + const before = new Map(s.view.elements.map((el) => [el.uid, el])); + const out = new Set(); + for (const el of plan.elements) { + const b = before.get(el.uid); + if (el.type !== 'flow' || b === el) { + continue; + } + const same = + b?.type === 'flow' && + b.x === el.x && + b.y === el.y && + b.points.length === el.points.length && + b.points.every( + (p, i) => p.x === el.points[i].x && p.y === el.points[i].y && p.attachedToUid === el.points[i].attachedToUid, + ); + if (!same) { + out.add(el.uid); + } + } + return out; +} + +/** + * The strict flow violations of the routed flows and the M3 violations of the + * planned view, formatted so a failure shows every arm and its numbers; empty + * when the plan's view holds them. + */ +export function committedReport(s: Scene, plan: GesturePlan): string { + const view = planned(s, plan); + const flows = checkFlowInvariants(view, { mode: 'strict', routed: routedFlows(s, plan) }); + const refs = checkReferentialIntegrity(view); + return [formatFlowViolations(flows), formatViewViolations(refs)].filter((t) => t !== '').join('\n'); +} + +export function elementOf(plan: GesturePlan, uid: UID) { + const el = plan.elements.find((e) => e.uid === uid); + if (el === undefined) { + throw new Error(`no element ${uid}`); + } + return el; +} diff --git a/src/diagram/tests/support/scene-generator.ts b/src/diagram/tests/support/scene-generator.ts new file mode 100644 index 000000000..ff2b9cc17 --- /dev/null +++ b/src/diagram/tests/support/scene-generator.ts @@ -0,0 +1,936 @@ +// Copyright 2026 The Simlin Authors. All rights reserved. +// Use of this source code is governed by the Apache License, +// Version 2.0, that can be found in the LICENSE file. + +/** + * Seeded scene generator for the diagram editing invariant tests. + * + * `genScene` produces views that hold the strict flow invariants (G1-G8), M1, + * M3, and stock-list agreement. `genImportedScene` starts from such a scene and + * applies the tolerant shapes the corpus measurement found in imported models, + * recording for each the arms strict mode must report. + * + * Scenes are built as the engine's JSON model and deserialized with the + * production `modelFromJson`, so element fields (idents, stock inflow/outflow + * uids, labelSide defaults) are exactly what the editor sees on load. + * + * The generator's validity rules are its own geometry, never the checker's: + * a test that generated with the checker and then checked would agree with + * itself by construction. + */ + +import type { JsonModel, JsonViewElement } from '@simlin/engine'; +import { modelFromJson, type Model, type StockFlowView, type UID } from '@simlin/core/datamodel'; + +import { CloudRadius, StockHeight, StockWidth } from '../../drawing/default'; +import { + CORNER_CLEARANCE, + MIN_SEGMENT, + MIN_SINK_SEGMENT, + PIPE_SPACING, + VALVE_CLAMP_MARGIN, + type FlowArm, +} from './flow-invariants'; +import type { ViewArm } from './view-invariants'; + +// --------------------------------------------------------------------------- +// PRNG + +function mulberry32(seed: number): () => number { + let a = seed >>> 0; + return () => { + a = (a + 0x6d2b79f5) | 0; + let t = Math.imul(a ^ (a >>> 15), 1 | a); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +export class Rng { + private readonly next: () => number; + + constructor(seed: number) { + this.next = mulberry32(seed); + } + + /** Uniform in [a, b). */ + float(a: number, b: number): number { + return a + (b - a) * this.next(); + } + + /** Uniform integer in [a, b]. */ + int(a: number, b: number): number { + return Math.floor(this.float(a, b + 1)); + } + + bool(p = 0.5): boolean { + return this.next() < p; + } + + pick(items: readonly T[]): T { + return items[Math.floor(this.next() * items.length)]; + } +} + +// --------------------------------------------------------------------------- +// Scene records + +type Pt = { readonly x: number; readonly y: number }; +export type Face = 'left' | 'right' | 'top' | 'bottom'; +export const FACES: readonly Face[] = ['left', 'right', 'top', 'bottom']; + +export const FLOW_SHAPES = ['straight', 'L', 'Z', 'bracket'] as const; +export type FlowShape = (typeof FLOW_SHAPES)[number]; + +export const ROUTE_STARTS = ['hubSource', 'hubSink', 'freeCloud'] as const; +export type RouteStart = (typeof ROUTE_STARTS)[number]; + +/** + * What the generator built for one flow, so a test can check the view realizes + * it (and so coverage is counted over what was built, not over what a lenient + * classification would accept). + */ +export interface RouteRecord { + readonly flowUid: UID; + readonly shape: FlowShape; + /** The walk's start: the hub stock at the flow's source or at its sink, or a free cloud (always the source). */ + readonly start: RouteStart; + /** The new stock the walk ended at and the face it entered through; undefined when the walk ended at a cloud. */ + readonly far: { readonly stockUid: UID; readonly face: Face } | undefined; +} + +const HALF_WIDTH = StockWidth / 2; +const HALF_HEIGHT = StockHeight / 2; + +// Clouds and other stocks keep this far from stock bodies so that no later +// mutation (a cloud moved by up to CloudRadius) can land inside one. +const CLOUD_STOCK_GAP = CloudRadius + 6; +const STOCK_STOCK_GAP = 20; + +interface StockRecord { + readonly uid: UID; + readonly name: string; + readonly center: Pt; + /** Positions along each face (from the face's top or left corner) already used by endpoints. */ + readonly slots: Map; +} + +interface CloudRecord { + readonly uid: UID; + readonly flowUid: UID; + center: Pt; +} + +type EndRecord = + | { readonly kind: 'stock'; readonly stock: StockRecord; readonly face: Face } + | { readonly kind: 'cloud'; readonly cloud: CloudRecord }; + +interface FlowRecord { + readonly uid: UID; + readonly name: string; + readonly shape: FlowShape; + points: Pt[]; + readonly source: EndRecord; + readonly sink: EndRecord; + valve: Pt; + /** Import shape: both endpoints carry no attachment and the flow's clouds are gone. */ + detached: boolean; +} + +interface NamedRecord { + readonly uid: UID; + readonly name: string; + readonly center: Pt; +} + +interface Builder { + readonly rng: Rng; + nextUid: number; + hubUid: UID; + readonly routes: RouteRecord[]; + readonly stocks: StockRecord[]; + readonly clouds: CloudRecord[]; + readonly flows: FlowRecord[]; + readonly auxes: NamedRecord[]; + readonly modules: NamedRecord[]; + readonly aliases: Array<{ readonly uid: UID; readonly aliasOfUid: UID; readonly center: Pt }>; + readonly links: Array<{ + readonly uid: UID; + readonly fromUid: UID; + readonly toUid: UID; + readonly arc: number | undefined; + }>; + /** Import shape: extra outflow entries by stock uid. */ + readonly extraOutflows: Map; +} + +export interface Scene { + readonly model: Model; + readonly view: StockFlowView; + readonly hubUid: UID; + /** One record per flow, describing the route as generated (before any import shape). */ + readonly routes: readonly RouteRecord[]; +} + +// --------------------------------------------------------------------------- +// Strict scenes + +/** + * A valid scene: a hub stock with several flows on random faces (endpoints on a + * shared face at least PIPE_SPACING apart), flows starting at free clouds, + * each flow a straight, L, Z or bracket route ending at a cloud or at a new + * stock entered through a perpendicular face, plus auxes, sometimes a module, + * an alias, and links between named elements. + */ +export function genScene(rng: Rng): Scene { + return toScene(buildScene(rng)); +} + +function buildScene(rng: Rng): Builder { + for (;;) { + const b = newBuilder(rng); + const hub = addStock(b, { x: rng.int(350, 550), y: rng.int(350, 550) }); + b.hubUid = hub.uid; + const hubFlows = rng.int(1, 5); + for (let i = 0; i < hubFlows; i++) { + addFlow(b, { kind: 'stock', stock: hub }); + } + const freeFlows = rng.int(0, 3); + for (let i = 0; i < freeFlows; i++) { + addFlow(b, { kind: 'free' }); + } + if (b.flows.length === 0) { + continue; + } + addAuxesModulesAliasesLinks(b); + return b; + } +} + +function newBuilder(rng: Rng): Builder { + return { + rng, + nextUid: 1, + hubUid: 0, + routes: [], + stocks: [], + clouds: [], + flows: [], + auxes: [], + modules: [], + aliases: [], + links: [], + extraOutflows: new Map(), + }; +} + +function addStock(b: Builder, center: Pt): StockRecord { + const uid = b.nextUid++; + const stock: StockRecord = { uid, name: `Stock ${uid}`, center, slots: new Map() }; + b.stocks.push(stock); + return stock; +} + +type Start = { readonly kind: 'stock'; readonly stock: StockRecord } | { readonly kind: 'free' }; + +function outward(face: Face): Pt { + switch (face) { + case 'left': + return { x: -1, y: 0 }; + case 'right': + return { x: 1, y: 0 }; + case 'top': + return { x: 0, y: -1 }; + case 'bottom': + return { x: 0, y: 1 }; + } +} + +function faceLength(face: Face): number { + return face === 'left' || face === 'right' ? StockHeight : StockWidth; +} + +/** The point `along` px from the face's top (left/right faces) or left (top/bottom faces) corner. */ +function facePoint(center: Pt, face: Face, along: number): Pt { + switch (face) { + case 'left': + return { x: center.x - HALF_WIDTH, y: center.y - HALF_HEIGHT + along }; + case 'right': + return { x: center.x + HALF_WIDTH, y: center.y - HALF_HEIGHT + along }; + case 'top': + return { x: center.x - HALF_WIDTH + along, y: center.y - HALF_HEIGHT }; + case 'bottom': + return { x: center.x - HALF_WIDTH + along, y: center.y + HALF_HEIGHT }; + } +} + +function turn(d: Pt, left: boolean): Pt { + return left ? { x: d.y || 0, y: -d.x || 0 } : { x: -d.y || 0, y: d.x || 0 }; +} + +function opposite(d: Pt): Face { + if (d.x > 0) return 'left'; + if (d.x < 0) return 'right'; + if (d.y > 0) return 'top'; + return 'bottom'; +} + +/** + * Segment directions and lengths for a shape, in walk order. `sinkIndex` is + * the segment that becomes the flow's final segment (the walk is reversed when + * the start stock is the sink), so it gets MIN_SINK_SEGMENT; every other + * segment gets MIN_SEGMENT. A bracket is stub, riser, run, riser back, stub. + */ +function shapeWalk(rng: Rng, shape: FlowShape, d0: Pt, reversed: boolean): Array<{ d: Pt; length: number }> { + const sinkMin = Math.ceil(MIN_SINK_SEGMENT); + const min = (index: number, count: number): number => ((reversed ? 0 : count - 1) === index ? sinkMin : MIN_SEGMENT); + const side = rng.bool(); + switch (shape) { + case 'straight': + return [{ d: d0, length: rng.int(Math.max(30, min(0, 1)), 200) }]; + case 'L': + return [ + { d: d0, length: rng.int(Math.max(20, min(0, 2)), 150) }, + { d: turn(d0, side), length: rng.int(Math.max(20, min(1, 2)), 150) }, + ]; + case 'Z': + return [ + { d: d0, length: rng.int(Math.max(15, min(0, 3)), 120) }, + { d: turn(d0, side), length: rng.int(20, 100) }, + { d: d0, length: rng.int(Math.max(20, min(2, 3)), 120) }, + ]; + case 'bracket': { + const riser = rng.int(20, 60); + return [ + { d: d0, length: rng.int(min(0, 5), min(0, 5) + 8) }, + { d: turn(d0, side), length: riser }, + { d: d0, length: rng.int(30, 150) }, + { d: turn(d0, !side), length: riser }, + { d: d0, length: rng.int(min(4, 5), min(4, 5) + 8) }, + ]; + } + } +} + +function addFlow(b: Builder, start: Start): boolean { + const rng = b.rng; + for (let attempt = 0; attempt < 40; attempt++) { + const shape = rng.pick(FLOW_SHAPES); + // When the walk starts at a stock that is the flow's SINK, the walk is + // reversed into points; choose that before sizing segments. + const reversed = start.kind === 'stock' && rng.bool(); + let p0: Pt; + let d0: Pt; + let startFace: Face | undefined; + let startAlong = 0; + if (start.kind === 'stock') { + startFace = rng.pick(FACES); + const along = pickSlot(rng, start.stock, startFace); + if (along === undefined) { + continue; + } + startAlong = along; + p0 = facePoint(start.stock.center, startFace, along); + d0 = outward(startFace); + } else { + p0 = { x: rng.int(0, 900), y: rng.int(0, 900) }; + d0 = outward(rng.pick(FACES)); + } + const walk = shapeWalk(rng, shape, d0, reversed); + const pts: Pt[] = [p0]; + for (const step of walk) { + const last = pts[pts.length - 1]; + pts.push({ x: last.x + step.d.x * step.length, y: last.y + step.d.y * step.length }); + } + const end = pts[pts.length - 1]; + const finalDirection = walk[walk.length - 1].d; + + let farStock: { center: Pt; face: Face; along: number } | undefined; + if (rng.bool(0.35)) { + const face = opposite(finalDirection); + const along = rng.int(CORNER_CLEARANCE, faceLength(face) - CORNER_CLEARANCE); + // The final walk point is at `along` on `face` of the new stock. + const probe = facePoint({ x: 0, y: 0 }, face, along); + farStock = { center: { x: end.x - probe.x, y: end.y - probe.y }, face, along }; + } + + const terminalStocks = [start.kind === 'stock' ? start.stock.center : undefined, farStock?.center].filter( + (c): c is Pt => c !== undefined, + ); + if (!pathIsClear(b, pts, terminalStocks)) { + continue; + } + if (farStock !== undefined) { + const center = farStock.center; + if (b.stocks.some((s) => boxGap(stockBox(s.center), stockBox(center)) < STOCK_STOCK_GAP)) continue; + if (b.clouds.some((c) => distanceToBox(c.center, stockBox(center)) < CLOUD_STOCK_GAP)) continue; + if ( + b.flows.some((f) => + f.points + .slice(1) + .some((_, i) => segmentHitsBox(f.points[i], f.points[i + 1], inflateBox(stockBox(center), 2))), + ) + ) + continue; + } + if (start.kind === 'free' && b.stocks.some((s) => distanceToBox(p0, stockBox(s.center)) < CLOUD_STOCK_GAP)) { + continue; + } + if (farStock === undefined && b.stocks.some((s) => distanceToBox(end, stockBox(s.center)) < CLOUD_STOCK_GAP)) { + continue; + } + + const flowUid = b.nextUid++; + let walkStart: EndRecord; + if (start.kind === 'stock') { + recordSlot(start.stock, startFace!, startAlong); + walkStart = { kind: 'stock', stock: start.stock, face: startFace! }; + } else { + const cloud: CloudRecord = { uid: b.nextUid++, flowUid, center: p0 }; + b.clouds.push(cloud); + walkStart = { kind: 'cloud', cloud }; + } + let walkEnd: EndRecord; + let far: RouteRecord['far']; + if (farStock !== undefined) { + const stock = addStock(b, farStock.center); + recordSlot(stock, farStock.face, farStock.along); + walkEnd = { kind: 'stock', stock, face: farStock.face }; + far = { stockUid: stock.uid, face: farStock.face }; + } else { + const cloud: CloudRecord = { uid: b.nextUid++, flowUid, center: end }; + b.clouds.push(cloud); + walkEnd = { kind: 'cloud', cloud }; + } + const points = reversed ? [...pts].reverse() : pts; + const flow: FlowRecord = { + uid: flowUid, + name: `Flow ${flowUid}`, + shape, + points, + source: reversed ? walkEnd : walkStart, + sink: reversed ? walkStart : walkEnd, + valve: placeValve(rng, points), + detached: false, + }; + b.flows.push(flow); + b.routes.push({ + flowUid, + shape, + start: start.kind === 'free' ? 'freeCloud' : reversed ? 'hubSink' : 'hubSource', + far, + }); + return true; + } + return false; +} + +function pickSlot(rng: Rng, stock: StockRecord, face: Face): number | undefined { + const used = stock.slots.get(face) ?? []; + for (let attempt = 0; attempt < 12; attempt++) { + const along = rng.bool(0.4) + ? rng.pick([faceLength(face) / 2, faceLength(face) / 4, (3 * faceLength(face)) / 4]) + : rng.int(CORNER_CLEARANCE, faceLength(face) - CORNER_CLEARANCE); + if (used.every((u) => Math.abs(u - along) >= PIPE_SPACING)) { + return along; + } + } + return undefined; +} + +function recordSlot(stock: StockRecord, face: Face, along: number): void { + const used = stock.slots.get(face) ?? []; + used.push(along); + stock.slots.set(face, used); +} + +/** + * A new path must not pass through any stock other than its terminals + * (inflated by 2px so it never grazes one), must not enter its terminal stocks' + * interiors, and must not touch another flow's endpoint cloud. + */ +function pathIsClear(b: Builder, pts: readonly Pt[], terminalCenters: readonly Pt[]): boolean { + const isTerminal = (center: Pt): boolean => terminalCenters.some((c) => c.x === center.x && c.y === center.y); + for (let i = 0; i < pts.length - 1; i++) { + for (const s of b.stocks) { + const box = isTerminal(s.center) ? inflateBox(stockBox(s.center), -0.01) : inflateBox(stockBox(s.center), 2); + if (segmentHitsBox(pts[i], pts[i + 1], box)) { + return false; + } + } + for (const c of terminalCenters) { + if (!b.stocks.some((s) => s.center === c) && segmentHitsBox(pts[i], pts[i + 1], inflateBox(stockBox(c), -0.01))) { + return false; + } + } + for (const c of b.clouds) { + if (distanceToSegment(c.center, pts[i], pts[i + 1]) < CloudRadius) { + return false; + } + } + } + return true; +} + +function placeValve(rng: Rng, pts: readonly Pt[]): Pt { + const length = pathLength(pts); + const s = rng.bool(0.3) ? length / 2 : rng.float(VALVE_CLAMP_MARGIN + 1, length - VALVE_CLAMP_MARGIN - 1); + return pointAtArc(pts, s); +} + +function addAuxesModulesAliasesLinks(b: Builder): void { + const rng = b.rng; + const clearSpot = (): Pt | undefined => { + for (let attempt = 0; attempt < 30; attempt++) { + const p = { x: rng.int(0, 900), y: rng.int(0, 900) }; + const nearStock = b.stocks.some((s) => distanceToBox(p, stockBox(s.center)) < 20); + const nearPath = b.flows.some((f) => + f.points.slice(1).some((_, i) => distanceToSegment(p, f.points[i], f.points[i + 1]) < 15), + ); + const nearCloud = b.clouds.some((c) => Math.hypot(c.center.x - p.x, c.center.y - p.y) < 25); + if (!nearStock && !nearPath && !nearCloud) { + return p; + } + } + return undefined; + }; + const auxCount = rng.int(1, 3); + for (let i = 0; i < auxCount; i++) { + const p = clearSpot(); + if (p !== undefined) { + const uid = b.nextUid++; + b.auxes.push({ uid, name: `Aux ${uid}`, center: p }); + } + } + if (rng.bool(0.3)) { + const p = clearSpot(); + if (p !== undefined) { + const uid = b.nextUid++; + b.modules.push({ uid, name: `Module ${uid}`, center: p }); + } + } + const aliasable = [...b.stocks, ...b.auxes]; + if (rng.bool(0.7) && aliasable.length > 0) { + const p = clearSpot(); + if (p !== undefined) { + b.aliases.push({ uid: b.nextUid++, aliasOfUid: rng.pick(aliasable).uid, center: p }); + } + } + const linkable: UID[] = [ + ...b.stocks.map((s) => s.uid), + ...b.flows.map((f) => f.uid), + ...b.auxes.map((a) => a.uid), + ...b.modules.map((m) => m.uid), + ...b.aliases.map((a) => a.uid), + ]; + const linkCount = rng.int(1, 4); + for (let i = 0; i < linkCount && linkable.length > 1; i++) { + const fromUid = rng.pick(linkable); + const toUid = rng.pick(linkable.filter((u) => u !== fromUid)); + b.links.push({ uid: b.nextUid++, fromUid, toUid, arc: rng.bool() ? undefined : rng.int(-60, 60) }); + } +} + +// --------------------------------------------------------------------------- +// Imported scenes + +export const IMPORT_SHAPES = [ + 'offFaceAxis', + 'cornerEndpoint', + 'cloudOffset', + 'valveOffPath', + 'slightlyDiagonal', + 'unattachedFlow', + 'listedInTwoStocks', +] as const; +export type ImportShape = (typeof IMPORT_SHAPES)[number]; + +export interface ImportMutation { + readonly shape: ImportShape; + readonly flowUid: UID; + /** Arms strict mode must report for this flow, one entry per occurrence. */ + readonly flowArms: readonly FlowArm[]; + /** Stock-list arms this mutation must produce. */ + readonly viewArms: readonly ViewArm[]; +} + +export interface ImportedScene extends Scene { + readonly mutations: readonly ImportMutation[]; +} + +/** + * A strict scene with one to three corpus shapes applied, each to a different + * flow: + * - offFaceAxis: a stock endpoint moved 5-60px outward along its stub (Vensim + * stocks larger than 45x35 put endpoints on their real faces); + * - cornerEndpoint: a straight stock-cloud flow shifted so its stock endpoint + * sits exactly on a corner; + * - cloudOffset: a cloud moved up to CloudRadius off its endpoint; + * - valveOffPath: the valve moved 1-20px perpendicular off its segment; + * - slightlyDiagonal: a straight cloud-cloud flow's sink moved 0.5-2px across; + * - unattachedFlow: a cloud-cloud flow with both attachments and its clouds + * removed (Vensim fallback flows); + * - listedInTwoStocks: a stock-sourced flow also listed in another stock's + * outflows (XMILE imports). + * Every precondition is chosen so the mutation produces exactly its recorded + * arms and nothing else. + */ +export function genImportedScene(rng: Rng): ImportedScene { + for (;;) { + const b = buildScene(rng); + const want = rng.int(1, 3); + const order = shuffle(rng, [...IMPORT_SHAPES]); + const used = new Set(); + const mutations: ImportMutation[] = []; + for (const shape of order) { + if (mutations.length >= want) { + break; + } + const m = applyImportShape(b, shape, used); + if (m !== undefined) { + used.add(m.flowUid); + mutations.push(m); + } + } + if (mutations.length > 0) { + return { ...toScene(b), mutations }; + } + } +} + +function shuffle(rng: Rng, items: T[]): T[] { + for (let i = items.length - 1; i > 0; i--) { + const j = rng.int(0, i); + [items[i], items[j]] = [items[j], items[i]]; + } + return items; +} + +function applyImportShape(b: Builder, shape: ImportShape, used: ReadonlySet): ImportMutation | undefined { + const rng = b.rng; + const candidates = b.flows.filter((f) => !used.has(f.uid)); + switch (shape) { + case 'offFaceAxis': { + for (const f of shuffle(rng, [...candidates])) { + const ends = [0, f.points.length - 1].filter((i) => (i === 0 ? f.source : f.sink).kind === 'stock'); + for (const index of ends) { + const adjacent = index === 0 ? f.points[1] : f.points[f.points.length - 2]; + const endpoint = f.points[index]; + const stubLength = Math.hypot(adjacent.x - endpoint.x, adjacent.y - endpoint.y); + const valveArc = arcPosition(f.points, f.valve); + const valveFromEnd = index === 0 ? valveArc : pathLength(f.points) - valveArc; + const d = rng.int(5, 60); + if (stubLength < d + MIN_SINK_SEGMENT + 1 || valveFromEnd < d + VALVE_CLAMP_MARGIN + 1) { + continue; + } + const ux = (adjacent.x - endpoint.x) / stubLength; + const uy = (adjacent.y - endpoint.y) / stubLength; + f.points[index] = { x: endpoint.x + ux * d, y: endpoint.y + uy * d }; + return { shape, flowUid: f.uid, flowArms: ['G4.offFace'], viewArms: [] }; + } + } + return undefined; + } + case 'cornerEndpoint': { + for (const f of shuffle(rng, [...candidates])) { + if (f.points.length !== 2) continue; + const stockEnd = f.source.kind === 'stock' ? f.source : f.sink.kind === 'stock' ? f.sink : undefined; + const cloudEnd = f.source.kind === 'cloud' ? f.source : f.sink.kind === 'cloud' ? f.sink : undefined; + if (stockEnd === undefined || cloudEnd === undefined) continue; + const index = f.source.kind === 'stock' ? 0 : 1; + const endpoint = f.points[index]; + const corner = facePoint(stockEnd.stock.center, stockEnd.face, rng.bool() ? 0 : faceLength(stockEnd.face)); + const shift = { x: corner.x - endpoint.x, y: corner.y - endpoint.y }; + const moved = f.points.map((p) => ({ x: p.x + shift.x, y: p.y + shift.y })); + const cloudCenter = { x: cloudEnd.cloud.center.x + shift.x, y: cloudEnd.cloud.center.y + shift.y }; + const others = b.stocks.filter((s) => s.uid !== stockEnd.stock.uid); + if (others.some((s) => segmentHitsBox(moved[0], moved[1], inflateBox(stockBox(s.center), 2)))) continue; + // A shifted cloud near a stock could land inside it (G6.cloudInsideStock) + // or crowd G3/G6 exemptions, adding arms this mutation does not record. + // The strict scene's own gaps keep every test seed clear of this guard, so + // no test observes it; it holds the exactness contract for seeds that do. + if (b.stocks.some((s) => distanceToBox(cloudCenter, stockBox(s.center)) < CLOUD_STOCK_GAP)) continue; + f.points = moved; + f.valve = { x: f.valve.x + shift.x, y: f.valve.y + shift.y }; + cloudEnd.cloud.center = cloudCenter; + return { shape, flowUid: f.uid, flowArms: ['G4.cornerClearance'], viewArms: [] }; + } + return undefined; + } + case 'cloudOffset': { + for (const f of shuffle(rng, [...candidates])) { + const cloudEnd = f.source.kind === 'cloud' ? f.source : f.sink.kind === 'cloud' ? f.sink : undefined; + if (cloudEnd === undefined) continue; + const r = rng.float(0.5, CloudRadius); + const angle = rng.float(0, 2 * Math.PI); + const center = { + x: cloudEnd.cloud.center.x + r * Math.cos(angle), + y: cloudEnd.cloud.center.y + r * Math.sin(angle), + }; + if (b.stocks.some((s) => distanceToBox(center, stockBox(s.center)) < 1)) continue; + cloudEnd.cloud.center = center; + return { shape, flowUid: f.uid, flowArms: ['G7.cloudOffEndpoint'], viewArms: [] }; + } + return undefined; + } + case 'valveOffPath': { + for (const f of shuffle(rng, [...candidates])) { + const segment = segmentAtArc(f.points, arcPosition(f.points, f.valve)); + const a = f.points[segment]; + const c = f.points[segment + 1]; + const length = Math.hypot(c.x - a.x, c.y - a.y); + const offset = rng.float(1, 20) * (rng.bool() ? 1 : -1); + const valve = { + x: f.valve.x - ((c.y - a.y) / length) * offset, + y: f.valve.y + ((c.x - a.x) / length) * offset, + }; + // Near a corner, moving the valve off its own segment can put it on the + // adjacent one, and then G8.valveOffPath would not fire. No test seed reaches + // this guard, so no test observes it; it holds the exactness contract for + // seeds that do. + if (distanceToPath(valve, f.points) < 0.5) continue; + f.valve = valve; + return { shape, flowUid: f.uid, flowArms: ['G8.valveOffPath'], viewArms: [] }; + } + return undefined; + } + case 'slightlyDiagonal': { + for (const f of shuffle(rng, [...candidates])) { + if (f.points.length !== 2 || f.source.kind !== 'cloud' || f.sink.kind !== 'cloud') continue; + const [a, c] = f.points; + const length = Math.hypot(c.x - a.x, c.y - a.y); + const t = arcPosition(f.points, f.valve) / length; + const across = rng.float(0.5, 2) * (rng.bool() ? 1 : -1); + const sink = { x: c.x - ((c.y - a.y) / length) * across, y: c.y + ((c.x - a.x) / length) * across }; + const newLength = Math.hypot(sink.x - a.x, sink.y - a.y); + if (t * newLength < VALVE_CLAMP_MARGIN + 0.5 || (1 - t) * newLength < VALVE_CLAMP_MARGIN + 0.5) continue; + f.points = [a, sink]; + f.sink.cloud.center = sink; + f.valve = { x: a.x + (sink.x - a.x) * t, y: a.y + (sink.y - a.y) * t }; + return { shape, flowUid: f.uid, flowArms: ['G2.diagonal'], viewArms: [] }; + } + return undefined; + } + case 'unattachedFlow': { + for (const f of shuffle(rng, [...candidates])) { + if (f.source.kind !== 'cloud' || f.sink.kind !== 'cloud') continue; + f.detached = true; + return { shape, flowUid: f.uid, flowArms: ['G1.unattachedEndpoint', 'G1.unattachedEndpoint'], viewArms: [] }; + } + return undefined; + } + case 'listedInTwoStocks': { + for (const f of shuffle(rng, [...candidates])) { + if (f.source.kind !== 'stock' || f.detached) continue; + const sourceUid = f.source.stock.uid; + const others = b.stocks.filter((s) => s.uid !== sourceUid); + if (others.length === 0) continue; + const other = rng.pick(others); + const extra = b.extraOutflows.get(other.uid) ?? []; + extra.push(f.name); + b.extraOutflows.set(other.uid, extra); + return { shape, flowUid: f.uid, flowArms: [], viewArms: ['stockLists.listedNotAttached'] }; + } + return undefined; + } + } +} + +// --------------------------------------------------------------------------- +// JSON model assembly + +function toScene(b: Builder): Scene { + const model = modelFromJson(toJsonModel(b)); + return { model, view: model.views[0], hubUid: b.hubUid, routes: b.routes }; +} + +function toJsonModel(b: Builder): JsonModel { + const elements: JsonViewElement[] = []; + for (const s of b.stocks) { + elements.push({ type: 'stock', uid: s.uid, name: s.name, x: s.center.x, y: s.center.y }); + } + const detached = new Set(b.flows.filter((f) => f.detached).map((f) => f.uid)); + for (const c of b.clouds) { + if (!detached.has(c.flowUid)) { + elements.push({ type: 'cloud', uid: c.uid, flowUid: c.flowUid, x: c.center.x, y: c.center.y }); + } + } + const endUid = (end: EndRecord): UID => (end.kind === 'stock' ? end.stock.uid : end.cloud.uid); + for (const f of b.flows) { + elements.push({ + type: 'flow', + uid: f.uid, + name: f.name, + x: f.valve.x, + y: f.valve.y, + points: f.points.map((p, i) => { + const attached = f.detached + ? undefined + : i === 0 + ? endUid(f.source) + : i === f.points.length - 1 + ? endUid(f.sink) + : undefined; + return attached === undefined ? { x: p.x, y: p.y } : { x: p.x, y: p.y, attachedToUid: attached }; + }), + }); + } + for (const a of b.auxes) { + elements.push({ type: 'aux', uid: a.uid, name: a.name, x: a.center.x, y: a.center.y }); + } + for (const m of b.modules) { + elements.push({ type: 'module', uid: m.uid, name: m.name, x: m.center.x, y: m.center.y }); + } + for (const a of b.aliases) { + elements.push({ type: 'alias', uid: a.uid, aliasOfUid: a.aliasOfUid, x: a.center.x, y: a.center.y }); + } + for (const l of b.links) { + elements.push( + l.arc === undefined + ? { type: 'link', uid: l.uid, fromUid: l.fromUid, toUid: l.toUid } + : { type: 'link', uid: l.uid, fromUid: l.fromUid, toUid: l.toUid, arc: l.arc }, + ); + } + + const listed = (stock: StockRecord, end: 'source' | 'sink'): string[] => + b.flows + .filter((f) => !f.detached) + .filter((f) => { + const e = end === 'source' ? f.source : f.sink; + return e.kind === 'stock' && e.stock.uid === stock.uid; + }) + .map((f) => f.name); + + return { + name: 'main', + stocks: b.stocks.map((s) => ({ + name: s.name, + initialEquation: '1', + inflows: listed(s, 'sink'), + outflows: [...listed(s, 'source'), ...(b.extraOutflows.get(s.uid) ?? [])], + })), + flows: b.flows.map((f) => ({ name: f.name, equation: '1' })), + auxiliaries: b.auxes.map((a) => ({ name: a.name, equation: '1' })), + modules: b.modules.map((m) => ({ name: m.name, modelName: 'sub' })), + views: [{ elements, viewBox: { x: 0, y: 0, width: 1000, height: 1000 }, zoom: 1 }], + } as JsonModel; +} + +// --------------------------------------------------------------------------- +// Generator geometry (independent of the checker) + +interface Box { + readonly minX: number; + readonly maxX: number; + readonly minY: number; + readonly maxY: number; +} + +function stockBox(center: Pt): Box { + return { + minX: center.x - HALF_WIDTH, + maxX: center.x + HALF_WIDTH, + minY: center.y - HALF_HEIGHT, + maxY: center.y + HALF_HEIGHT, + }; +} + +function inflateBox(box: Box, by: number): Box { + return { minX: box.minX - by, maxX: box.maxX + by, minY: box.minY - by, maxY: box.maxY + by }; +} + +function boxGap(a: Box, b: Box): number { + const gx = Math.max(a.minX - b.maxX, b.minX - a.maxX, 0); + const gy = Math.max(a.minY - b.maxY, b.minY - a.maxY, 0); + return Math.max(gx, gy); +} + +function distanceToBox(p: Pt, box: Box): number { + const dx = Math.max(box.minX - p.x, 0, p.x - box.maxX); + const dy = Math.max(box.minY - p.y, 0, p.y - box.maxY); + return Math.hypot(dx, dy); +} + +// Axis-aligned segments only (the generator never produces a diagonal before a +// mutation): does the segment overlap the open box with positive length? +function segmentHitsBox(a: Pt, b: Pt, box: Box): boolean { + if (a.y === b.y) { + const lo = Math.min(a.x, b.x); + const hi = Math.max(a.x, b.x); + return a.y > box.minY && a.y < box.maxY && Math.min(hi, box.maxX) - Math.max(lo, box.minX) > 0; + } + const lo = Math.min(a.y, b.y); + const hi = Math.max(a.y, b.y); + return a.x > box.minX && a.x < box.maxX && Math.min(hi, box.maxY) - Math.max(lo, box.minY) > 0; +} + +function distanceToSegment(p: Pt, a: Pt, b: Pt): number { + const dx = b.x - a.x; + const dy = b.y - a.y; + const l2 = dx * dx + dy * dy; + const t = l2 === 0 ? 0 : Math.max(0, Math.min(1, ((p.x - a.x) * dx + (p.y - a.y) * dy) / l2)); + return Math.hypot(p.x - (a.x + t * dx), p.y - (a.y + t * dy)); +} + +function distanceToPath(p: Pt, pts: readonly Pt[]): number { + let best = Infinity; + for (let i = 0; i < pts.length - 1; i++) { + best = Math.min(best, distanceToSegment(p, pts[i], pts[i + 1])); + } + return best; +} + +function pathLength(pts: readonly Pt[]): number { + let total = 0; + for (let i = 0; i < pts.length - 1; i++) { + total += Math.hypot(pts[i + 1].x - pts[i].x, pts[i + 1].y - pts[i].y); + } + return total; +} + +function pointAtArc(pts: readonly Pt[], s: number): Pt { + let remaining = s; + for (let i = 0; i < pts.length - 1; i++) { + const length = Math.hypot(pts[i + 1].x - pts[i].x, pts[i + 1].y - pts[i].y); + if (remaining <= length || i === pts.length - 2) { + const t = length === 0 ? 0 : remaining / length; + return { x: pts[i].x + (pts[i + 1].x - pts[i].x) * t, y: pts[i].y + (pts[i + 1].y - pts[i].y) * t }; + } + remaining -= length; + } + return pts[pts.length - 1]; +} + +function arcPosition(pts: readonly Pt[], p: Pt): number { + let best = Infinity; + let position = 0; + let traversed = 0; + for (let i = 0; i < pts.length - 1; i++) { + const a = pts[i]; + const c = pts[i + 1]; + const length = Math.hypot(c.x - a.x, c.y - a.y); + const l2 = length * length; + const t = l2 === 0 ? 0 : Math.max(0, Math.min(1, ((p.x - a.x) * (c.x - a.x) + (p.y - a.y) * (c.y - a.y)) / l2)); + const d = Math.hypot(p.x - (a.x + t * (c.x - a.x)), p.y - (a.y + t * (c.y - a.y))); + if (d < best) { + best = d; + position = traversed + t * length; + } + traversed += length; + } + return position; +} + +function segmentAtArc(pts: readonly Pt[], s: number): number { + let traversed = 0; + for (let i = 0; i < pts.length - 1; i++) { + const length = Math.hypot(pts[i + 1].x - pts[i].x, pts[i + 1].y - pts[i].y); + // A valve exactly on a corner belongs to the segment it would slide along + // perpendicular-off from; either works, so take the earlier one. + if (s <= traversed + length) { + return i; + } + traversed += length; + } + return pts.length - 2; +} diff --git a/src/diagram/tests/support/view-invariants.ts b/src/diagram/tests/support/view-invariants.ts new file mode 100644 index 000000000..9c19359dc --- /dev/null +++ b/src/diagram/tests/support/view-invariants.ts @@ -0,0 +1,546 @@ +// Copyright 2026 The Simlin Authors. All rights reserved. +// Use of this source code is governed by the Apache License, +// Version 2.0, that can be found in the LICENSE file. + +/** + * The model/view invariants M1-M3 of + * docs/design-plans/2026-09-10-diagram-editing-core.md, plus two stock-list + * checks the plan's defense in depth relies on: no duplicate inflow/outflow + * entry (the engine integrates a duplicate twice with no error) and static + * agreement between a stock's lists and the flows attached to it in the view + * (what a generated scene, and a well-formed saved model, must hold). + * + * Arms are enumerated in VIEW_ARMS so tests derive their rows from it. Idents + * are compared after canonicalization because the engine matches names + * canonically while stock lists carry display spellings. + */ + +import { canonicalize } from '@simlin/core/canonicalize'; +import { + isNamedViewElement, + type FlowViewElement, + type Stock, + type StockFlowView, + type UID, + type Variable, + type ViewElement, +} from '@simlin/core/datamodel'; + +export const VIEW_ARMS = { + M1: ['missingVariable', 'kindMismatch', 'createdVariableMissing'], + M2: ['notRemoved', 'newListCount', 'otherEntryChanged'], + M3: [ + 'linkFromMissing', + 'linkToMissing', + 'linkFromKind', + 'linkToKind', + 'linkSelf', + 'aliasOfMissing', + 'aliasOfKind', + 'cloudFlowMissing', + 'cloudFlowNotFlow', + 'cloudEndpointCount', + 'duplicateUid', + ], + stockLists: ['duplicateEntry', 'listedNotAttached', 'attachedNotListed'], +} as const; + +export type ViewInvariant = keyof typeof VIEW_ARMS; +export type ViewArm = { [K in ViewInvariant]: `${K}.${(typeof VIEW_ARMS)[K][number]}` }[ViewInvariant]; + +export const ALL_VIEW_ARMS: readonly ViewArm[] = (Object.keys(VIEW_ARMS) as ViewInvariant[]).flatMap((invariant) => + VIEW_ARMS[invariant].map((arm) => `${invariant}.${arm}` as ViewArm), +); + +export interface ViewViolation { + readonly arm: ViewArm; + /** The element the violation is about, when it is about one. */ + readonly uid: UID | undefined; + /** The variable the violation is about, when it is about one. */ + readonly ident: string | undefined; + readonly numbers: Readonly>; + readonly message: string; +} + +export interface ViewAndVariables { + readonly view: StockFlowView; + readonly variables: ReadonlyMap; +} + +export function formatViewViolations(violations: readonly ViewViolation[]): string { + return violations + .map((v) => `${v.arm} uid=${v.uid} ident=${v.ident} ${JSON.stringify(v.numbers)} ${v.message}`) + .join('\n'); +} + +type End = 'source' | 'sink'; +const ENDS: readonly End[] = ['source', 'sink']; + +function violation( + arm: ViewArm, + fields: { uid?: UID; ident?: string; numbers?: Record }, + message: string, +): ViewViolation { + return { arm, uid: fields.uid, ident: fields.ident, numbers: fields.numbers ?? {}, message }; +} + +// --------------------------------------------------------------------------- +// M1 kind agreement + +/** + * Every stock/flow/aux/module element names an existing variable of its own + * kind. The lookup key is the element's `ident`, the same key the Canvas and + * Editor use against `Model.variables`. + */ +export function checkKindAgreement(view: StockFlowView, variables: ReadonlyMap): ViewViolation[] { + const out: ViewViolation[] = []; + for (const el of view.elements) { + if (el.type !== 'stock' && el.type !== 'flow' && el.type !== 'aux' && el.type !== 'module') { + continue; + } + const variable = variables.get(el.ident); + if (variable === undefined) { + out.push( + violation('M1.missingVariable', { uid: el.uid, ident: el.ident }, `${el.type} element names no variable`), + ); + } else if (variable.type !== el.type) { + out.push( + violation('M1.kindMismatch', { uid: el.uid, ident: el.ident }, `${el.type} element names a ${variable.type}`), + ); + } + } + return out; +} + +/** + * "A created element's variable exists after the commit": named elements in + * `next` whose uid is absent from `base` must have a variable in the model the + * commit produced. Scoped to created elements so a base view carrying imported + * elements without variables does not mask or pollute the edit's own result. + */ +export function checkCreatedVariables(base: StockFlowView, next: ViewAndVariables): ViewViolation[] { + const baseUids = new Set(base.elements.map((el) => el.uid)); + const out: ViewViolation[] = []; + for (const el of next.view.elements) { + if (el.type !== 'stock' && el.type !== 'flow' && el.type !== 'aux' && el.type !== 'module') { + continue; + } + if (!baseUids.has(el.uid) && !next.variables.has(el.ident)) { + out.push( + violation('M1.createdVariableMissing', { uid: el.uid, ident: el.ident }, `created ${el.type} has no variable`), + ); + } + } + return out; +} + +// --------------------------------------------------------------------------- +// M2 stock/flow agreement (delta) + +interface AttachmentChange { + readonly flowUid: UID; + readonly flowIdent: string; + readonly end: End; + readonly from: string | undefined; + readonly to: string | undefined; +} + +/** + * After an edit changes a flow end's attachment, the flow is removed from the + * old stock's list and present exactly once in the new stock's, and no other + * list entry changes. + * + * A flow present only in `next` (created) or only in `base` (deleted) counts as + * unattached on the side it is absent from. A stock present only in `next` had + * empty lists before. Renames are excluded: a named element whose uid survives + * with a new ident is the same variable, and the engine's RenameVariable + * rewrites every list entry naming a renamed flow (`update_stock_flow_references` + * in src/simlin-engine/src/patch.rs), so base idents are carried through the + * rename before anything is compared. The new-list requirement applies only when + * both the stock and the flow variable exist in the next model, matching the + * plan's "only existing flow variables and existing stock variables are touched". + * Entry order is not compared: reordering changes no entry, and the same engine + * rename sorts both lists. + */ +export function checkStockFlowDelta(base: ViewAndVariables, next: ViewAndVariables): ViewViolation[] { + const baseById = new Map(base.view.elements.map((el) => [el.uid, el])); + const nextById = new Map(next.view.elements.map((el) => [el.uid, el])); + const renamed = renamedIdents(baseById, nextById); + const toNext = (ident: string): string => renamed.get(ident) ?? ident; + const flowUids = new Set(); + for (const el of [...base.view.elements, ...next.view.elements]) { + if (el.type === 'flow') { + flowUids.add(el.uid); + } + } + + const changes: AttachmentChange[] = []; + for (const uid of flowUids) { + const b = asFlow(baseById.get(uid)); + const n = asFlow(nextById.get(uid)); + const flowIdent = n?.ident ?? b!.ident; + for (const end of ENDS) { + const baseStock = attachedStockIdent(baseById, b, end); + const from = baseStock === undefined ? undefined : toNext(baseStock); + const to = attachedStockIdent(nextById, n, end); + if (from !== to) { + changes.push({ flowUid: uid, flowIdent, end, from, to }); + } + } + } + + const out: ViewViolation[] = []; + const touched = new Map>>(); + const touch = (stockIdent: string, end: End, flowIdent: string): void => { + const byEnd = touched.get(stockIdent) ?? new Map>(); + const set = byEnd.get(end) ?? new Set(); + set.add(flowIdent); + byEnd.set(end, set); + touched.set(stockIdent, byEnd); + }; + + for (const c of changes) { + const listName = c.end === 'source' ? 'outflows' : 'inflows'; + if (c.from !== undefined) { + touch(c.from, c.end, c.flowIdent); + const stock = stockVariable(next.variables, c.from); + if (stock !== undefined) { + const count = countEntries(listOf(stock, c.end), c.flowIdent); + if (count > 0) { + out.push( + violation( + 'M2.notRemoved', + { uid: c.flowUid, ident: c.flowIdent, numbers: { count } }, + `${c.flowIdent} still listed in ${c.from}.${listName} after its ${c.end} left that stock`, + ), + ); + } + } + } + if (c.to !== undefined) { + touch(c.to, c.end, c.flowIdent); + const stock = stockVariable(next.variables, c.to); + if (stock !== undefined && next.variables.get(c.flowIdent)?.type === 'flow') { + const count = countEntries(listOf(stock, c.end), c.flowIdent); + if (count !== 1) { + out.push( + violation( + 'M2.newListCount', + { uid: c.flowUid, ident: c.flowIdent, numbers: { count } }, + `${c.flowIdent} appears ${count} time(s) in ${c.to}.${listName} after its ${c.end} attached there`, + ), + ); + } + } + } + } + + const baseStocks = new Map(); + for (const variable of base.variables.values()) { + if (variable.type === 'stock') { + baseStocks.set(toNext(variable.ident), variable); + } + } + // A stock the edit deleted takes its lists with it; that changes no entry of + // a list that still exists. + for (const [ident, nextVariable] of next.variables) { + if (nextVariable.type !== 'stock') { + continue; + } + const variable = baseStocks.get(ident); + for (const end of ENDS) { + const exempt = touched.get(ident)?.get(end) ?? new Set(); + const before = variable === undefined ? [] : otherEntries(listOf(variable, end), exempt, toNext); + const after = otherEntries(listOf(nextVariable, end), exempt, (entry) => entry); + if (before.length !== after.length || before.some((entry, i) => entry !== after[i])) { + out.push( + violation( + 'M2.otherEntryChanged', + { ident, numbers: { before: before.length, after: after.length } }, + `${ident}.${end === 'source' ? 'outflows' : 'inflows'} changed other entries: ${JSON.stringify(before)} -> ${JSON.stringify(after)}`, + ), + ); + } + } + } + return out; +} + +function asFlow(el: ViewElement | undefined): FlowViewElement | undefined { + return el?.type === 'flow' ? el : undefined; +} + +/** Base ident -> next ident for every named element whose uid survives the edit under a new ident. */ +function renamedIdents( + baseById: ReadonlyMap, + nextById: ReadonlyMap, +): Map { + const renamed = new Map(); + for (const [uid, b] of baseById) { + const n = nextById.get(uid); + if (isNamedViewElement(b) && n !== undefined && isNamedViewElement(n) && b.ident !== n.ident) { + renamed.set(b.ident, n.ident); + } + } + return renamed; +} + +function attachedStockIdent( + byUid: ReadonlyMap, + flow: FlowViewElement | undefined, + end: End, +): string | undefined { + if (flow === undefined || flow.points.length === 0) { + return undefined; + } + const p = end === 'source' ? flow.points[0] : flow.points[flow.points.length - 1]; + const el = p.attachedToUid !== undefined ? byUid.get(p.attachedToUid) : undefined; + return el?.type === 'stock' ? el.ident : undefined; +} + +function stockVariable(variables: ReadonlyMap, ident: string): Stock | undefined { + const v = variables.get(ident); + return v?.type === 'stock' ? v : undefined; +} + +function listOf(stock: Stock, end: End): readonly string[] { + return end === 'source' ? stock.outflows : stock.inflows; +} + +function countEntries(list: readonly string[], flowIdent: string): number { + return list.filter((entry) => canonicalize(entry) === flowIdent).length; +} + +function otherEntries( + list: readonly string[], + exempt: ReadonlySet, + rename: (ident: string) => string, +): string[] { + return list + .map((entry) => rename(canonicalize(entry))) + .filter((entry) => !exempt.has(entry)) + .sort(); +} + +// --------------------------------------------------------------------------- +// M3 referential integrity + +/** + * Links, aliases and clouds reference existing elements; a cloud's reference is + * to its owning flow, so a cloud pointing at a non-flow is broken too. A + * duplicate uid is reported under M3 because a reference to it does not name + * one existing element. + * + * Beyond existence: a link joins two distinct elements, each a named element + * (stock, flow, aux, module) or an alias of one, never a cloud, link or group; + * an alias stands for a named element; and a cloud is exactly one endpoint of + * its owning flow (a cloud no endpoint reaches renders detached, one at both + * ends makes a flow its own source and sink). + */ +export function checkReferentialIntegrity(view: StockFlowView): ViewViolation[] { + const out: ViewViolation[] = []; + const byUid = new Map(); + const counts = new Map(); + for (const el of view.elements) { + byUid.set(el.uid, el); + counts.set(el.uid, (counts.get(el.uid) ?? 0) + 1); + } + for (const [uid, count] of counts) { + if (count > 1) { + out.push(violation('M3.duplicateUid', { uid, numbers: { count } }, `uid ${uid} is used by ${count} elements`)); + } + } + for (const el of view.elements) { + if (el.type === 'link') { + const from = byUid.get(el.fromUid); + if (from === undefined) { + out.push( + violation( + 'M3.linkFromMissing', + { uid: el.uid, numbers: { fromUid: el.fromUid } }, + `link from missing uid ${el.fromUid}`, + ), + ); + } else if (!isNamedViewElement(from) && from.type !== 'alias') { + out.push( + violation( + 'M3.linkFromKind', + { uid: el.uid, numbers: { fromUid: el.fromUid } }, + `link from a ${from.type} (uid ${el.fromUid})`, + ), + ); + } + const to = byUid.get(el.toUid); + if (to === undefined) { + out.push( + violation( + 'M3.linkToMissing', + { uid: el.uid, numbers: { toUid: el.toUid } }, + `link to missing uid ${el.toUid}`, + ), + ); + } else if (!isNamedViewElement(to) && to.type !== 'alias') { + out.push( + violation( + 'M3.linkToKind', + { uid: el.uid, numbers: { toUid: el.toUid } }, + `link to a ${to.type} (uid ${el.toUid})`, + ), + ); + } + if (el.fromUid === el.toUid) { + out.push( + violation( + 'M3.linkSelf', + { uid: el.uid, numbers: { fromUid: el.fromUid } }, + `link from uid ${el.fromUid} to itself`, + ), + ); + } + } else if (el.type === 'alias') { + const target = byUid.get(el.aliasOfUid); + if (target === undefined) { + out.push( + violation( + 'M3.aliasOfMissing', + { uid: el.uid, numbers: { aliasOfUid: el.aliasOfUid } }, + `alias of missing uid ${el.aliasOfUid}`, + ), + ); + } else if (!isNamedViewElement(target)) { + out.push( + violation( + 'M3.aliasOfKind', + { uid: el.uid, numbers: { aliasOfUid: el.aliasOfUid } }, + `alias of a ${target.type} (uid ${el.aliasOfUid})`, + ), + ); + } + } else if (el.type === 'cloud') { + const owner = byUid.get(el.flowUid); + if (owner === undefined) { + out.push( + violation( + 'M3.cloudFlowMissing', + { uid: el.uid, numbers: { flowUid: el.flowUid } }, + `cloud of missing uid ${el.flowUid}`, + ), + ); + } else if (owner.type !== 'flow') { + out.push( + violation( + 'M3.cloudFlowNotFlow', + { uid: el.uid, numbers: { flowUid: el.flowUid } }, + `cloud owner ${el.flowUid} is a ${owner.type}`, + ), + ); + } else { + const endIndexes = owner.points.length === 0 ? [] : [...new Set([0, owner.points.length - 1])]; + const count = endIndexes.filter((i) => owner.points[i].attachedToUid === el.uid).length; + if (count !== 1) { + out.push( + violation( + 'M3.cloudEndpointCount', + { uid: el.uid, numbers: { flowUid: el.flowUid, count } }, + `cloud is ${count} endpoint(s) of its flow ${el.flowUid}`, + ), + ); + } + } + } + } + return out; +} + +// --------------------------------------------------------------------------- +// Stock lists + +export function checkStockListDuplicates(variables: ReadonlyMap): ViewViolation[] { + const out: ViewViolation[] = []; + for (const variable of variables.values()) { + if (variable.type !== 'stock') { + continue; + } + for (const end of ENDS) { + const counts = new Map(); + for (const entry of listOf(variable, end)) { + const id = canonicalize(entry); + counts.set(id, (counts.get(id) ?? 0) + 1); + } + for (const [flowIdent, count] of counts) { + if (count > 1) { + out.push( + violation( + 'stockLists.duplicateEntry', + { ident: variable.ident, numbers: { count } }, + `${variable.ident}.${end === 'source' ? 'outflows' : 'inflows'} lists ${flowIdent} ${count} times`, + ), + ); + } + } + } + } + return out; +} + +/** + * A stock's outflows (inflows) name exactly the flows whose source (sink) is + * attached to that stock's element. Entries naming flows with no element on + * this view are ignored: another view, or no view, may carry them. + */ +export function checkStockFlowAgreement( + view: StockFlowView, + variables: ReadonlyMap, +): ViewViolation[] { + const out: ViewViolation[] = []; + const flows = view.elements.filter((el): el is FlowViewElement => el.type === 'flow'); + const onView = new Set(flows.map((f) => f.ident)); + for (const el of view.elements) { + if (el.type !== 'stock') { + continue; + } + const stock = stockVariable(variables, el.ident); + if (stock === undefined) { + continue; + } + for (const end of ENDS) { + const attached = new Set( + flows + .filter((f) => { + const p = end === 'source' ? f.points[0] : f.points[f.points.length - 1]; + return p?.attachedToUid === el.uid; + }) + .map((f) => f.ident), + ); + const listed = new Set( + listOf(stock, end) + .map((entry) => canonicalize(entry)) + .filter((id) => onView.has(id)), + ); + const listName = end === 'source' ? 'outflows' : 'inflows'; + for (const id of listed) { + if (!attached.has(id)) { + out.push( + violation( + 'stockLists.listedNotAttached', + { uid: el.uid, ident: id }, + `${el.ident}.${listName} lists unattached ${id}`, + ), + ); + } + } + for (const id of attached) { + if (!listed.has(id)) { + out.push( + violation( + 'stockLists.attachedNotListed', + { uid: el.uid, ident: id }, + `${id} attaches to ${el.ident} but is not in ${listName}`, + ), + ); + } + } + } + } + return out; +} diff --git a/src/diagram/tests/svg-rendering.test.ts b/src/diagram/tests/svg-rendering.test.ts index f2befaf67..d6e543d62 100644 --- a/src/diagram/tests/svg-rendering.test.ts +++ b/src/diagram/tests/svg-rendering.test.ts @@ -42,36 +42,38 @@ describe('SVG rendering cross-language comparison', () => { 'test/arrays1/arrays.stmx', ]; - for (const modelFile of testModels) { - it(`produces identical SVG for ${path.basename(modelFile)}`, () => { - const xmileData = loadXmile(modelFile); + function expectIdenticalSvg(xmileData: Uint8Array): void { + // Rust rendering via WASM + const projectPtr = simlin_project_open_xmile(xmileData); - // Rust rendering via WASM - const projectPtr = simlin_project_open_xmile(xmileData); + let rustSvg: string; + try { + const svgBytes = simlin_project_render_svg(projectPtr, 'main'); + rustSvg = new TextDecoder().decode(svgBytes); + } finally { + simlin_project_unref(projectPtr); + } - let rustSvg: string; - try { - const svgBytes = simlin_project_render_svg(projectPtr, 'main'); - rustSvg = new TextDecoder().decode(svgBytes); - } finally { - simlin_project_unref(projectPtr); - } + // TypeScript rendering via React + const projectPtr2 = simlin_project_open_xmile(xmileData); + let tsSvg: string; + try { + const jsonBytes = simlin_project_serialize_json(projectPtr2, SimlinJsonFormat.Native); + const jsonStr = new TextDecoder().decode(jsonBytes); + const jsonProject = JSON.parse(jsonStr); + const tsProject = projectFromJson(jsonProject); + const [svg] = renderSvgToString(tsProject, 'main'); + tsSvg = svg; + } finally { + simlin_project_unref(projectPtr2); + } - // TypeScript rendering via React - const projectPtr2 = simlin_project_open_xmile(xmileData); - let tsSvg: string; - try { - const jsonBytes = simlin_project_serialize_json(projectPtr2, SimlinJsonFormat.Native); - const jsonStr = new TextDecoder().decode(jsonBytes); - const jsonProject = JSON.parse(jsonStr); - const tsProject = projectFromJson(jsonProject); - const [svg] = renderSvgToString(tsProject, 'main'); - tsSvg = svg; - } finally { - simlin_project_unref(projectPtr2); - } + expect(rustSvg).toBe(tsSvg); + } - expect(rustSvg).toBe(tsSvg); + for (const modelFile of testModels) { + it(`produces identical SVG for ${path.basename(modelFile)}`, () => { + expectIdenticalSvg(loadXmile(modelFile)); }); } @@ -102,4 +104,28 @@ describe('SVG rendering cross-language comparison', () => { expect(left + width).toBeGreaterThanOrEqual(alias.x + AuxRadius); expect(top + height).toBeGreaterThanOrEqual(alias.y + AuxRadius); }); + + // A center side is stored as an absent labelSide in the native JSON (the + // engine's serialization of Center), so this is also the absent-side case. + // None of the corpus models above uses one. + it('produces identical SVG for elements whose label side is center', () => { + const xmile = ` + +
center labelssimlinsimlin
+ 01
1
+ + + 1 + 1 + + + + + + + + +
`; + expectIdenticalSvg(new TextEncoder().encode(xmile)); + }); }); diff --git a/src/diagram/tests/variable-details-cancel.test.tsx b/src/diagram/tests/variable-details-cancel.test.tsx index c690e28c0..59ceafe0f 100644 --- a/src/diagram/tests/variable-details-cancel.test.tsx +++ b/src/diagram/tests/variable-details-cancel.test.tsx @@ -40,7 +40,7 @@ import { render, act, fireEvent, waitFor } from '@testing-library/react'; import { Editor, Transforms } from 'slate'; import { HistoryEditor } from 'slate-history'; import { ELEMENT_TO_NODE } from 'slate-dom'; -import { VariableDetails } from '../VariableDetails'; +import { VariableDetails, type PendingSubmission } from '../VariableDetails'; import { Aux, AuxViewElement, EquationError, ErrorCode } from '@simlin/core/datamodel'; function makeAux(ident: string, equation: string, overrides: Partial = {}): Aux { @@ -85,8 +85,16 @@ interface Harness { onEquationChange: Mock; } -function renderDetails(variable: Aux): Harness { - const onEquationChange = rs.fn(); +interface DetailsOptions { + // What each submission's edit resolves to; undefined models a host that + // reports nothing. + readonly landed?: () => Promise; + readonly pendingSubmission?: PendingSubmission; + readonly onDraftStateChange?: (hasDraft: boolean) => void; +} + +function renderDetails(variable: Aux, options: DetailsOptions = {}): Harness { + const onEquationChange = rs.fn(() => options.landed?.()); const { container } = render( , ); return { container, onEquationChange }; } +const neverLands = (): Promise => new Promise(() => {}); + // The Slate editor instance backing a rendered Editable, reachable through // slate-dom's element->node map. Driving edits through it (rather than fake // DOM input events, which jsdom does not support for Slate) is the highest @@ -396,3 +408,88 @@ describe('VariableDetails discard (Cancel / Escape)', () => { expect(onEquationChange).toHaveBeenCalledWith('x', 'a + bZ', undefined, undefined); }); }); + +// Cancel puts each field back to its base (see draftText). The rows derive from +// the base's arms: the seeded text (the rows above: nothing was submitted), this +// panel's own submission while it is pending, a pending submission the panel +// mounted with, and a submission that did not land (the base falls back to the +// committed text). +describe('VariableDetails Cancel restores the base, not the committed text', () => { + async function blurAway(container: HTMLElement): Promise { + await act(async () => { + fireEvent.blur(container.querySelector('.eqnEditor') as Element, { relatedTarget: null }); + await Promise.resolve(); + }); + } + + async function pressCancel(container: HTMLElement): Promise { + const cancel = buttonByText(container, 'Cancel'); + await act(async () => { + fireEvent.blur(container.querySelector('.eqnEditor') as Element, { relatedTarget: cancel }); + fireEvent.click(cancel); + await Promise.resolve(); + }); + } + + function eqnText(container: HTMLElement): string { + return (container.querySelector('.eqnEditor') as HTMLElement).textContent ?? ''; + } + + it("after this panel's own save is submitted but not landed, Cancel shows the saved text and nothing re-submits", async () => { + const draftStates: boolean[] = []; + const { container, onEquationChange } = renderDetails(makeAux('x', 'a + b', { errors: forceEditorOpen }), { + landed: neverLands, + onDraftStateChange: (hasDraft) => draftStates.push(hasDraft), + }); + await appendText(container, editorFor(container, '.eqnEditor'), 'Z'); + await blurAway(container); + expect(onEquationChange).toHaveBeenCalledTimes(1); + expect(onEquationChange).toHaveBeenLastCalledWith('x', 'a + bZ', undefined, undefined); + + await appendText(container, editorFor(container, '.eqnEditor'), 'Q'); + await pressCancel(container); + + expect(eqnText(container)).toBe('a + bZ'); + expect(buttonByText(container, 'Cancel').disabled).toBe(true); + expect(draftStates[draftStates.length - 1]).toBe(false); + // A later blur or canvas flush finds no draft, so the committed 'a + b' is + // never submitted over the pending save. + await blurAway(container); + expect(onEquationChange).toHaveBeenCalledTimes(1); + }); + + it('a panel mounted with a pending submission cancels back to that submission', async () => { + const { container, onEquationChange } = renderDetails(makeAux('x', 'a + b', { errors: forceEditorOpen }), { + pendingSubmission: { equation: { text: 'a + bZ', landed: neverLands() } }, + }); + expect(eqnText(container)).toBe('a + bZ'); + await appendText(container, editorFor(container, '.eqnEditor'), 'Q'); + await pressCancel(container); + + expect(eqnText(container)).toBe('a + bZ'); + expect(buttonByText(container, 'Cancel').disabled).toBe(true); + await blurAway(container); + expect(onEquationChange).not.toHaveBeenCalled(); + }); + + it('once a submission settles as not landed, Cancel shows the committed text again', async () => { + const { container, onEquationChange } = renderDetails(makeAux('x', 'a + b', { errors: forceEditorOpen }), { + landed: () => Promise.resolve(false), + }); + await appendText(container, editorFor(container, '.eqnEditor'), 'Z'); + await blurAway(container); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + expect(onEquationChange).toHaveBeenCalledTimes(1); + + await appendText(container, editorFor(container, '.eqnEditor'), 'Q'); + await pressCancel(container); + + expect(eqnText(container)).toBe('a + b'); + expect(buttonByText(container, 'Cancel').disabled).toBe(true); + await blurAway(container); + expect(onEquationChange).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/diagram/tests/variable-details-display.test.ts b/src/diagram/tests/variable-details-display.test.ts index 887e178ed..032a61ffa 100644 --- a/src/diagram/tests/variable-details-display.test.ts +++ b/src/diagram/tests/variable-details-display.test.ts @@ -5,7 +5,7 @@ import { describe, it, expect } from '@rstest/core'; import { ErrorCode } from '@simlin/core/datamodel'; -import type { Aux, EquationError, UnitError, Variable } from '@simlin/core/datamodel'; +import type { Aux, EquationError, UnitError, Variable, VariableWarning } from '@simlin/core/datamodel'; import { variableDetailsView } from '../variable-details-display'; @@ -45,9 +45,21 @@ describe('variableDetailsView', () => { equationErrors: [], unitWarnings: [], connectorWarnings: [], + warnings: [], }); }); + it('keeps the chart and surfaces engine advisories as warnings (not fatal)', () => { + const advisory: VariableWarning = { + code: ErrorCode.Generic, + details: "stock 'level': its inflow list repeats 'f'", + }; + const view = variableDetailsView(aux({ warnings: [advisory] })); + expect(view.showChart).toBe(true); + expect(view.warnings).toEqual([advisory]); + expect(view.equationErrors).toEqual([]); + }); + it('surfaces connector-sync drift as non-fatal warnings (chart stays)', () => { const view = variableDetailsView(aux({ connectorErrors: [{ kind: 'missingConnector', ident: 'a', name: 'a' }] })); expect(view.showChart).toBe(true); diff --git a/src/diagram/tests/variable-details-highlight.test.tsx b/src/diagram/tests/variable-details-highlight.test.tsx new file mode 100644 index 000000000..df5547afd --- /dev/null +++ b/src/diagram/tests/variable-details-highlight.test.tsx @@ -0,0 +1,125 @@ +// Copyright 2026 The Simlin Authors. All rights reserved. +// Use of this source code is governed by the Apache License, +// Version 2.0, that can be found in the LICENSE file. + +// The error underline in VariableDetails is decorated from props, not seeded +// into the Slate document: the Editor does not remount the panel when only the +// variable's errors change (so a draft survives an unrelated edit landing), and +// the underline must still follow the props. The engine's offsets describe the +// committed text, so a dirty draft is not underlined. Driven on the units +// field, which is always an editor (the equation field collapses to a preview +// when it has no errors). + +import { describe, it, expect, beforeAll, rs } from '@rstest/core'; + +beforeAll(() => { + Object.defineProperty(HTMLElement.prototype, 'isContentEditable', { + configurable: true, + get(this: HTMLElement): boolean { + return this.getAttribute('contenteditable') === 'true'; + }, + }); +}); + +import * as React from 'react'; +import { render, act, type RenderResult } from '@testing-library/react'; +import { Editor, Transforms } from 'slate'; +import { ELEMENT_TO_NODE } from 'slate-dom'; + +import type { Aux, AuxViewElement, ErrorCode, UnitError } from '@simlin/core/datamodel'; + +import { VariableDetails } from '../VariableDetails'; + +function makeAux(units: string, unitErrors: UnitError[] | undefined): Aux { + return { + type: 'aux', + ident: 'x', + equation: { type: 'scalar', equation: '1' }, + documentation: '', + units, + gf: undefined, + data: undefined, + errors: undefined, + unitErrors, + uid: undefined, + } as unknown as Aux; +} + +const viewElement: AuxViewElement = { + type: 'aux', + uid: 1, + name: 'x', + ident: 'x', + var: undefined, + x: 0, + y: 0, + labelSide: 'right', + isZeroRadius: false, +}; + +const definitionError: UnitError[] = [{ start: 0, end: 3, code: 0 as unknown as ErrorCode, kind: 'definition' }]; + +function panel(variable: Aux): React.ReactElement { + return ( + + ); +} + +function underlined(container: HTMLElement): string[] { + return Array.from(container.querySelectorAll('.unitsEditor .eqnError')).map((el) => el.textContent ?? ''); +} + +function unitsEditor(container: HTMLElement): Editor { + return ELEMENT_TO_NODE.get(container.querySelector('.unitsEditor') as HTMLElement) as unknown as Editor; +} + +describe('VariableDetails error underline', () => { + it('underlines the committed text from props and follows prop changes without re-seeding the editor', () => { + let result!: RenderResult; + act(() => { + result = render(panel(makeAux('bad(units)', definitionError))); + }); + expect(underlined(result.container)).toEqual(['bad']); + const editor = unitsEditor(result.container); + + act(() => { + result.rerender(panel(makeAux('bad(units)', undefined))); + }); + expect(underlined(result.container)).toEqual([]); + expect(unitsEditor(result.container)).toBe(editor); + + act(() => { + result.rerender(panel(makeAux('bad(units)', [{ ...definitionError[0], start: 4, end: 9 }]))); + }); + expect(underlined(result.container)).toEqual(['units']); + expect(unitsEditor(result.container)).toBe(editor); + }); + + it('does not underline a dirty draft, and a prop change keeps the draft text', async () => { + let result!: RenderResult; + act(() => { + result = render(panel(makeAux('bad(units)', definitionError))); + }); + const editor = unitsEditor(result.container); + await act(async () => { + Transforms.insertText(editor, '!', { at: Editor.end(editor, []) }); + editor.onChange(); + await Promise.resolve(); + }); + expect(underlined(result.container)).toEqual([]); + + act(() => { + result.rerender(panel(makeAux('bad(units)', [{ ...definitionError[0] }]))); + }); + expect(result.container.querySelector('.unitsEditor')?.textContent).toBe('bad(units)!'); + expect(underlined(result.container)).toEqual([]); + }); +}); diff --git a/src/diagram/tests/variable-names.test.ts b/src/diagram/tests/variable-names.test.ts new file mode 100644 index 000000000..f0fda6adb --- /dev/null +++ b/src/diagram/tests/variable-names.test.ts @@ -0,0 +1,49 @@ +// Copyright 2026 The Simlin Authors. All rights reserved. +// Use of this source code is governed by the Apache License, +// Version 2.0, that can be found in the LICENSE file. + +import { describe, it, expect } from '@rstest/core'; + +import { allocateVariableName, nameCollisionError } from '../variable-names'; + +describe('allocateVariableName', () => { + it('returns the base name when it is free', () => { + expect(allocateVariableName('New Variable', new Set(['stock_a']))).toBe('New Variable'); + }); + + it('suffixes past every used candidate, matching canonically', () => { + expect(allocateVariableName('New Variable', new Set(['new_variable', 'new_variable_1']))).toBe('New Variable 2'); + }); + + it('gives up at the cap and returns the base name (the commit then reports the collision)', () => { + const used = new Set(['new_flow']); + for (let i = 1; i < 1024; i++) { + used.add(`new_flow_${i}`); + } + expect(allocateVariableName('New Flow', used)).toBe('New Flow'); + }); +}); + +describe('nameCollisionError', () => { + const used = new Set(['stock_a', 'aux_x']); + + it('a free name is not a collision', () => { + expect(nameCollisionError('Brand New', undefined, used)).toBeUndefined(); + }); + + it('a used name is a collision for a create (no current ident)', () => { + expect(nameCollisionError('Stock A', undefined, used)).toContain('Stock A'); + }); + + it('a canonical match is a collision', () => { + expect(nameCollisionError('STOCK a', undefined, used)).toBeDefined(); + }); + + it('a rename onto another used name is a collision', () => { + expect(nameCollisionError('Stock A', 'aux_x', used)).toBeDefined(); + }); + + it("a rename to the element's own ident (a case-only rename) is not a collision", () => { + expect(nameCollisionError('AUX X', 'aux_x', used)).toBeUndefined(); + }); +}); diff --git a/src/diagram/tests/view-invariants.test.ts b/src/diagram/tests/view-invariants.test.ts new file mode 100644 index 000000000..833cf2e6c --- /dev/null +++ b/src/diagram/tests/view-invariants.test.ts @@ -0,0 +1,755 @@ +// Copyright 2026 The Simlin Authors. All rights reserved. +// Use of this source code is governed by the Apache License, +// Version 2.0, that can be found in the LICENSE file. + +// Tests of the committed model/view invariant checkers +// (tests/support/view-invariants.ts). Rows are derived from VIEW_ARMS; the +// first test fails when an arm has no row, and arms with a per-kind or per-end +// branch get one row per kind or end. Every fixture is an engine-shaped JSON +// model deserialized with the production `modelFromJson`, so element idents and +// the stock lists' display spellings are what the editor holds. Renames are the +// exception that needs the engine itself: the list rewrite a rename produces is +// taken from the real RenameVariable patch path, not written by hand. + +import { describe, it, expect, beforeAll } from '@rstest/core'; + +import type { JsonModel, JsonProject, JsonStock, JsonViewElement } from '@simlin/engine'; +import { modelFromJson, projectFromJson, type Model, type UID } from '@simlin/core/datamodel'; + +import { relabelVariable } from '../rename-ops'; +import { buildEditOps } from '../view-model-sync'; +import { describeWithEngine, editorModel, loadEngine, mainModel, type EngineModule } from './support/engine'; +import { + ALL_VIEW_ARMS, + checkCreatedVariables, + checkKindAgreement, + checkReferentialIntegrity, + checkStockFlowAgreement, + checkStockFlowDelta, + checkStockListDuplicates, + formatViewViolations, + type ViewAndVariables, + type ViewArm, + type ViewViolation, +} from './support/view-invariants'; + +// Stocks A(1) -> B(2) through Flow F (4); Flow H (5) out of A's bottom into a +// cloud; Inflow G (7) from a cloud into A; Stock C (3) unattached; an aux, a +// module, an alias of A, two links, and a group. +function baseJson(): JsonModel { + return { + name: 'main', + stocks: [ + { name: 'Stock A', initialEquation: '1', inflows: ['Inflow G'], outflows: ['Flow F', 'Flow H'] }, + { name: 'Stock B', initialEquation: '1', inflows: ['Flow F'], outflows: [] }, + { name: 'Stock C', initialEquation: '1', inflows: [], outflows: [] }, + ], + flows: [ + { name: 'Flow F', equation: '1' }, + { name: 'Flow H', equation: '1' }, + { name: 'Inflow G', equation: '1' }, + ], + auxiliaries: [{ name: 'Aux X', equation: '1' }], + modules: [{ name: 'Module M', modelName: 'sub' }], + views: [ + { + elements: [ + { type: 'stock', uid: 1, name: 'Stock A', x: 100, y: 100 }, + { type: 'stock', uid: 2, name: 'Stock B', x: 300, y: 100 }, + { type: 'stock', uid: 3, name: 'Stock C', x: 300, y: 300 }, + { + type: 'flow', + uid: 4, + name: 'Flow F', + x: 200, + y: 100, + points: [ + { x: 122.5, y: 100, attachedToUid: 1 }, + { x: 277.5, y: 100, attachedToUid: 2 }, + ], + }, + { + type: 'flow', + uid: 5, + name: 'Flow H', + x: 100, + y: 180, + points: [ + { x: 100, y: 117.5, attachedToUid: 1 }, + { x: 100, y: 250, attachedToUid: 6 }, + ], + }, + { type: 'cloud', uid: 6, flowUid: 5, x: 100, y: 250 }, + { + type: 'flow', + uid: 7, + name: 'Inflow G', + x: 40, + y: 100, + points: [ + { x: 0, y: 100, attachedToUid: 8 }, + { x: 77.5, y: 100, attachedToUid: 1 }, + ], + }, + { type: 'cloud', uid: 8, flowUid: 7, x: 0, y: 100 }, + { type: 'aux', uid: 9, name: 'Aux X', x: 200, y: 20 }, + { type: 'module', uid: 10, name: 'Module M', x: 400, y: 20 }, + { type: 'alias', uid: 11, aliasOfUid: 1, x: 100, y: 300 }, + { type: 'link', uid: 12, fromUid: 9, toUid: 4 }, + { type: 'link', uid: 13, fromUid: 11, toUid: 7 }, + { type: 'group', uid: 14, name: 'Group Q', x: 500, y: 300, width: 100, height: 80 }, + ], + }, + ], + } as JsonModel; +} + +function load(json: JsonModel): ViewAndVariables { + return fromModel(modelFromJson(json)); +} + +function fromModel(model: Model): ViewAndVariables { + return { view: model.views[0], variables: model.variables }; +} + +function edit(fn: (json: JsonModel) => void): JsonModel { + const json = baseJson(); + fn(json); + return json; +} + +function elements(json: JsonModel): JsonViewElement[] { + return json.views![0].elements!; +} + +function element(json: JsonModel, uid: UID): Record { + return elements(json).find((e) => e.uid === uid) as unknown as Record; +} + +function stockJson(json: JsonModel, name: string): JsonStock { + return json.stocks!.find((s) => s.name === name)!; +} + +function setPoints( + json: JsonModel, + uid: number, + points: Array<{ x: number; y: number; attachedToUid?: number }>, +): void { + element(json, uid).points = points; +} + +function arms(violations: readonly ViewViolation[]): string[] { + return violations.map((v) => v.arm).sort(); +} + +interface Row { + readonly arm: ViewArm; + readonly name: string; + readonly check: () => ViewViolation[]; + readonly expected: readonly ViewArm[]; +} + +const KINDS = ['stock', 'flow', 'aux', 'module'] as const; +type Kind = (typeof KINDS)[number]; +// The element of each kind a row removes or re-kinds, and a variable of a +// different kind to put under its name. +const KIND_TARGETS: Record = { + stock: { name: 'Stock C', uid: 3, other: 'auxiliaries' }, + flow: { name: 'Flow H', uid: 5, other: 'auxiliaries' }, + aux: { name: 'Aux X', uid: 9, other: 'flows' }, + module: { name: 'Module M', uid: 10, other: 'auxiliaries' }, +}; + +function removeVariable(json: JsonModel, name: string): void { + json.stocks = json.stocks!.filter((v) => v.name !== name); + json.flows = json.flows!.filter((v) => v.name !== name); + json.auxiliaries = json.auxiliaries!.filter((v) => v.name !== name); + json.modules = json.modules!.filter((v) => v.name !== name); +} + +const M1_ROWS: Row[] = KINDS.flatMap((kind) => { + const target = KIND_TARGETS[kind]; + return [ + { + arm: 'M1.missingVariable' as const, + name: `${kind} element with no variable`, + check: () => { + const { view, variables } = load(edit((json) => removeVariable(json, target.name))); + return checkKindAgreement(view, variables); + }, + expected: ['M1.missingVariable'] as const, + }, + { + arm: 'M1.kindMismatch' as const, + name: `${kind} element naming a variable of another kind`, + check: () => { + const { view, variables } = load( + edit((json) => { + removeVariable(json, target.name); + if (target.other === 'auxiliaries') { + json.auxiliaries!.push({ name: target.name, equation: '1' }); + } else { + json.flows!.push({ name: target.name, equation: '1' }); + } + }), + ); + return checkKindAgreement(view, variables); + }, + expected: ['M1.kindMismatch'] as const, + }, + ]; +}); + +// A new element of each kind (uid 20), with or without its variable. +function withNewElement(json: JsonModel, kind: Kind, withVariable: boolean): void { + const name = `New ${kind}`; + const at = { x: 500, y: 500 }; + switch (kind) { + case 'stock': + elements(json).push({ type: 'stock', uid: 20, name, ...at }); + if (withVariable) json.stocks!.push({ name, initialEquation: '1', inflows: [], outflows: [] }); + break; + case 'flow': + elements(json).push({ type: 'flow', uid: 20, name, ...at, points: [] }); + if (withVariable) json.flows!.push({ name, equation: '1' }); + break; + case 'aux': + elements(json).push({ type: 'aux', uid: 20, name, ...at }); + if (withVariable) json.auxiliaries!.push({ name, equation: '1' }); + break; + case 'module': + elements(json).push({ type: 'module', uid: 20, name, ...at }); + if (withVariable) json.modules!.push({ name, modelName: 'sub' }); + break; + } +} + +const CREATED_ROWS: Row[] = KINDS.map((kind) => ({ + arm: 'M1.createdVariableMissing' as const, + name: `created ${kind} whose variable is absent after the commit`, + check: () => checkCreatedVariables(load(baseJson()).view, load(edit((json) => withNewElement(json, kind, false)))), + expected: ['M1.createdVariableMissing'] as const, +})); + +// Move Flow F's end (source or sink) from its stock to Stock C, and let the +// caller shape the next model's lists. +type End = 'source' | 'sink'; +const ENDS: readonly End[] = ['source', 'sink']; +const LIST: Record = { source: 'outflows', sink: 'inflows' }; +const OTHER_LIST: Record = { source: 'inflows', sink: 'outflows' }; +const OLD_STOCK: Record = { source: 'Stock A', sink: 'Stock B' }; + +function moveEnd(end: End, lists: (json: JsonModel) => void): Row['check'] { + return () => { + const next = edit((json) => { + if (end === 'source') { + setPoints(json, 4, [ + { x: 300, y: 282.5, attachedToUid: 3 }, + { x: 277.5, y: 100, attachedToUid: 2 }, + ]); + } else { + setPoints(json, 4, [ + { x: 122.5, y: 100, attachedToUid: 1 }, + { x: 300, y: 282.5, attachedToUid: 3 }, + ]); + } + lists(json); + }); + return checkStockFlowDelta(load(baseJson()), load(next)); + }; +} + +const removeFromOld = (json: JsonModel, end: End): void => { + const old = stockJson(json, OLD_STOCK[end]); + old[LIST[end]] = old[LIST[end]]!.filter((f) => f !== 'Flow F'); +}; + +// Both lists updated correctly for the move. +const moved = (json: JsonModel, end: End): void => { + removeFromOld(json, end); + stockJson(json, 'Stock C')[LIST[end]] = ['Flow F']; +}; + +const M2_ROWS: Row[] = ENDS.flatMap((end) => [ + { + arm: 'M2.notRemoved' as const, + name: `${end} moved; the old stock still lists the flow`, + check: moveEnd(end, (json) => { + stockJson(json, 'Stock C')[LIST[end]] = ['Flow F']; + }), + expected: ['M2.notRemoved'] as const, + }, + { + arm: 'M2.newListCount' as const, + name: `${end} moved; the new stock does not list the flow`, + check: moveEnd(end, (json) => removeFromOld(json, end)), + expected: ['M2.newListCount'] as const, + }, + { + arm: 'M2.newListCount' as const, + name: `${end} moved; the new stock lists the flow twice`, + check: moveEnd(end, (json) => { + removeFromOld(json, end); + stockJson(json, 'Stock C')[LIST[end]] = ['Flow F', 'Flow F']; + }), + expected: ['M2.newListCount'] as const, + }, + { + arm: 'M2.otherEntryChanged' as const, + name: `${end} moved; an unrelated entry of a stock list was removed`, + check: moveEnd(end, (json) => { + moved(json, end); + // Inflow G is A's inflow and has nothing to do with this edit. + stockJson(json, 'Stock A').inflows = []; + }), + expected: ['M2.otherEntryChanged'] as const, + }, + { + arm: 'M2.otherEntryChanged' as const, + name: `${end} moved; an unrelated entry was replaced by another, keeping the count`, + check: moveEnd(end, (json) => { + moved(json, end); + stockJson(json, 'Stock A').inflows = ['Flow H']; + }), + expected: ['M2.otherEntryChanged'] as const, + }, + { + arm: 'M2.otherEntryChanged' as const, + name: `${end} moved; the flow also appeared in the old stock's ${OTHER_LIST[end]}`, + // The move exempts the flow from its old stock's ${LIST[end]} only. + check: moveEnd(end, (json) => { + moved(json, end); + const old = stockJson(json, OLD_STOCK[end]); + old[OTHER_LIST[end]] = [...old[OTHER_LIST[end]]!, 'Flow F']; + }), + expected: ['M2.otherEntryChanged'] as const, + }, + { + arm: 'M2.otherEntryChanged' as const, + name: `${end} moved; a stock the edit created lists a flow nothing attached to it`, + check: moveEnd(end, (json) => { + moved(json, end); + elements(json).push({ type: 'stock', uid: 16, name: 'Stock D', x: 100, y: 400 }); + json.stocks!.push({ name: 'Stock D', initialEquation: '1', inflows: [], outflows: [], [LIST[end]]: ['Flow H'] }); + }), + expected: ['M2.otherEntryChanged'] as const, + }, +]); + +// Element kinds a link end may not be, and an element of each kind in the base +// fixture. An alias may not be an alias target either. +const NOT_LINK_ENDS: Record<'cloud' | 'link' | 'group', UID> = { cloud: 6, link: 13, group: 14 }; +const NOT_ALIAS_TARGETS: Record<'cloud' | 'link' | 'group' | 'alias', UID> = { ...NOT_LINK_ENDS, alias: 11 }; + +const referential = (fn: (json: JsonModel) => void): ViewViolation[] => checkReferentialIntegrity(load(edit(fn)).view); + +const M3_ROWS: Row[] = [ + { + arm: 'M3.linkFromMissing', + name: 'link from a missing uid', + check: () => referential((json) => (element(json, 12).fromUid = 99)), + expected: ['M3.linkFromMissing'], + }, + { + arm: 'M3.linkToMissing', + name: 'link to a missing uid', + check: () => referential((json) => (element(json, 12).toUid = 99)), + expected: ['M3.linkToMissing'], + }, + ...Object.entries(NOT_LINK_ENDS).flatMap(([kind, uid]) => [ + { + arm: 'M3.linkFromKind' as const, + name: `link from a ${kind}`, + check: () => referential((json) => (element(json, 12).fromUid = uid)), + expected: ['M3.linkFromKind'] as const, + }, + { + arm: 'M3.linkToKind' as const, + name: `link to a ${kind}`, + check: () => referential((json) => (element(json, 12).toUid = uid)), + expected: ['M3.linkToKind'] as const, + }, + ]), + { + arm: 'M3.linkSelf', + name: 'link from an aux to itself', + check: () => referential((json) => (element(json, 12).toUid = 9)), + expected: ['M3.linkSelf'], + }, + { + arm: 'M3.aliasOfMissing', + name: 'alias of a missing uid', + check: () => referential((json) => (element(json, 11).aliasOfUid = 99)), + expected: ['M3.aliasOfMissing'], + }, + ...Object.entries(NOT_ALIAS_TARGETS).map(([kind, uid]) => ({ + arm: 'M3.aliasOfKind' as const, + name: `alias of a ${kind}`, + check: () => referential((json) => (element(json, 11).aliasOfUid = uid)), + expected: ['M3.aliasOfKind'] as const, + })), + { + arm: 'M3.cloudFlowMissing', + name: 'cloud of a missing uid', + check: () => referential((json) => (element(json, 6).flowUid = 99)), + expected: ['M3.cloudFlowMissing'], + }, + { + arm: 'M3.cloudFlowNotFlow', + name: 'cloud owned by an aux', + check: () => referential((json) => (element(json, 6).flowUid = 9)), + expected: ['M3.cloudFlowNotFlow'], + }, + { + arm: 'M3.cloudEndpointCount', + name: 'cloud its flow no longer reaches', + check: () => + referential((json) => + setPoints(json, 5, [ + { x: 100, y: 117.5, attachedToUid: 1 }, + { x: 100, y: 250 }, + ]), + ), + expected: ['M3.cloudEndpointCount'], + }, + { + arm: 'M3.cloudEndpointCount', + name: 'cloud at both ends of its flow', + check: () => + referential((json) => + setPoints(json, 5, [ + { x: 100, y: 250, attachedToUid: 6 }, + { x: 100, y: 250, attachedToUid: 6 }, + ]), + ), + expected: ['M3.cloudEndpointCount'], + }, + { + arm: 'M3.duplicateUid', + name: 'two elements share a uid', + check: () => referential((json) => elements(json).push({ type: 'aux', uid: 10, name: 'Aux X', x: 600, y: 20 })), + expected: ['M3.duplicateUid'], + }, +]; + +const LIST_ROWS: Row[] = [ + ...ENDS.map((end) => ({ + arm: 'stockLists.duplicateEntry' as const, + name: `duplicate ${LIST[end]} entry`, + check: () => { + const json = edit((j) => { + const s = stockJson(j, OLD_STOCK[end]); + s[LIST[end]] = [...s[LIST[end]]!, 'Flow F']; + }); + return checkStockListDuplicates(load(json).variables); + }, + expected: ['stockLists.duplicateEntry'] as const, + })), + { + arm: 'stockLists.duplicateEntry', + name: 'duplicate spelled differently (canonical match)', + check: () => { + const json = edit((j) => { + stockJson(j, 'Stock B').inflows = ['Flow F', 'flow_f']; + }); + return checkStockListDuplicates(load(json).variables); + }, + expected: ['stockLists.duplicateEntry'], + }, + { + arm: 'stockLists.listedNotAttached', + name: 'a stock lists a flow attached elsewhere (XMILE imports list one flow in two stocks)', + check: () => { + const { view, variables } = load(edit((j) => (stockJson(j, 'Stock C').outflows = ['Flow F']))); + return checkStockFlowAgreement(view, variables); + }, + expected: ['stockLists.listedNotAttached'], + }, + { + arm: 'stockLists.attachedNotListed', + name: 'a flow attached to a stock that does not list it', + check: () => { + const { view, variables } = load(edit((j) => (stockJson(j, 'Stock A').outflows = ['Flow F']))); + return checkStockFlowAgreement(view, variables); + }, + expected: ['stockLists.attachedNotListed'], + }, +]; + +const ROWS: readonly Row[] = [...M1_ROWS, ...CREATED_ROWS, ...M2_ROWS, ...M3_ROWS, ...LIST_ROWS]; + +describe('view invariant arm table', () => { + it('covers every enumerated arm', () => { + expect([...new Set(ROWS.map((r) => r.arm))].sort()).toEqual([...ALL_VIEW_ARMS].sort()); + }); + + it('the base fixture passes every check', () => { + const base = load(baseJson()); + const all = [ + ...checkKindAgreement(base.view, base.variables), + ...checkCreatedVariables(base.view, base), + ...checkStockFlowDelta(base, base), + ...checkReferentialIntegrity(base.view), + ...checkStockListDuplicates(base.variables), + ...checkStockFlowAgreement(base.view, base.variables), + ]; + expect(formatViewViolations(all)).toBe(''); + }); + + for (const row of ROWS) { + it(`${row.arm}: ${row.name}`, () => { + expect(arms(row.check())).toEqual([...row.expected].sort()); + }); + } +}); + +describe('M1 created-element scoping', () => { + for (const kind of KINDS) { + it(`a created ${kind} with its variable passes`, () => { + expect( + checkCreatedVariables(load(baseJson()).view, load(edit((json) => withNewElement(json, kind, true)))), + ).toEqual([]); + }); + } + + it('an imported element without a variable in the base view is not a created-element violation', () => { + const ghost = (json: JsonModel): void => { + elements(json).push({ type: 'aux', uid: 21, name: 'Ghost', x: 700, y: 700 }); + }; + const base = load(edit(ghost)); + const next = load(edit(ghost)); + expect(checkCreatedVariables(base.view, next)).toEqual([]); + // The static arm still sees it, which is why created elements have their own arm. + expect(arms(checkKindAgreement(next.view, next.variables))).toEqual(['M1.missingVariable']); + }); +}); + +describe('M2 delta semantics', () => { + for (const end of ENDS) { + it(`${end} moved with both lists updated passes`, () => { + expect(moveEnd(end, (json) => moved(json, end))()).toEqual([]); + }); + } + + it('a created stock-to-stock flow must be listed once at each end', () => { + const base = edit((json) => { + json.views![0].elements = elements(json).filter((e) => e.uid !== 4); + stockJson(json, 'Stock A').outflows = ['Flow H']; + stockJson(json, 'Stock B').inflows = []; + }); + const unlisted = edit((json) => { + stockJson(json, 'Stock A').outflows = ['Flow H']; + stockJson(json, 'Stock B').inflows = []; + }); + expect(checkStockFlowDelta(load(base), load(baseJson()))).toEqual([]); + expect(arms(checkStockFlowDelta(load(base), load(unlisted)))).toEqual(['M2.newListCount', 'M2.newListCount']); + }); + + it('a deleted flow element must be removed from both lists', () => { + const deleted = (json: JsonModel): void => { + json.views![0].elements = elements(json).filter((e) => e.uid !== 4); + }; + const cleaned = edit((json) => { + deleted(json); + stockJson(json, 'Stock A').outflows = ['Flow H']; + stockJson(json, 'Stock B').inflows = []; + }); + expect(checkStockFlowDelta(load(baseJson()), load(cleaned))).toEqual([]); + expect(arms(checkStockFlowDelta(load(baseJson()), load(edit(deleted))))).toEqual([ + 'M2.notRemoved', + 'M2.notRemoved', + ]); + }); + + it('a flow moved onto a stock the edit created passes when that stock lists it', () => { + const next = edit((json) => { + elements(json).push({ type: 'stock', uid: 16, name: 'Stock D', x: 100, y: 400 }); + json.stocks!.push({ name: 'Stock D', initialEquation: '1', inflows: [], outflows: ['Flow F'] }); + setPoints(json, 4, [ + { x: 100, y: 382.5, attachedToUid: 16 }, + { x: 277.5, y: 100, attachedToUid: 2 }, + ]); + stockJson(json, 'Stock A').outflows = ['Flow H']; + }); + expect(checkStockFlowDelta(load(baseJson()), load(next))).toEqual([]); + }); + + it('a stale imported entry on an uninvolved stock must be left alone', () => { + // Imported shape: Flow F is also listed in Stock C's outflows while attached to A. + const stale = (json: JsonModel): void => { + stockJson(json, 'Stock C').outflows = ['Flow F']; + elements(json).push({ type: 'stock', uid: 16, name: 'Stock D', x: 100, y: 400 }); + json.stocks!.push({ name: 'Stock D', initialEquation: '1', inflows: [], outflows: [] }); + }; + const movedToD = (json: JsonModel): void => { + stale(json); + setPoints(json, 4, [ + { x: 100, y: 382.5, attachedToUid: 16 }, + { x: 277.5, y: 100, attachedToUid: 2 }, + ]); + stockJson(json, 'Stock A').outflows = ['Flow H']; + stockJson(json, 'Stock D').outflows = ['Flow F']; + }; + expect(checkStockFlowDelta(load(edit(stale)), load(edit(movedToD)))).toEqual([]); + const cleanedUp = edit((json) => { + movedToD(json); + stockJson(json, 'Stock C').outflows = []; + }); + expect(arms(checkStockFlowDelta(load(edit(stale)), load(cleanedUp)))).toEqual(['M2.otherEntryChanged']); + }); + + it('a flow leaving a renamed stock must still leave its list', () => { + // A stock rename leaves the stock's lists untouched (the engine rewrites + // list entries only for renamed flows); the move is judged under the new name. + const renamedAndMoved = (outflows: string[]): JsonModel => + edit((json) => { + stockJson(json, 'Stock A').name = 'Stock Renamed'; + stockJson(json, 'Stock Renamed').outflows = outflows; + element(json, 1).name = 'Stock Renamed'; + setPoints(json, 4, [ + { x: 300, y: 282.5, attachedToUid: 3 }, + { x: 277.5, y: 100, attachedToUid: 2 }, + ]); + stockJson(json, 'Stock C').outflows = ['Flow F']; + }); + expect(checkStockFlowDelta(load(baseJson()), load(renamedAndMoved(['Flow H'])))).toEqual([]); + expect(arms(checkStockFlowDelta(load(baseJson()), load(renamedAndMoved(['Flow F', 'Flow H']))))).toEqual([ + 'M2.notRemoved', + ]); + }); + + it('a flow renamed and moved in one commit is judged under its new name', () => { + // The rename half has the shape the engine rename path produces (entries naming + // the flow rewritten to the new name; see the engine-backed tests below); the + // move takes Flow F's source from Stock A to Stock C. + const renamedAndMoved = (aOutflows: string[]): JsonModel => + edit((json) => { + json.flows!.find((f) => f.name === 'Flow F')!.name = 'Flow Renamed'; + element(json, 4).name = 'Flow Renamed'; + setPoints(json, 4, [ + { x: 300, y: 282.5, attachedToUid: 3 }, + { x: 277.5, y: 100, attachedToUid: 2 }, + ]); + stockJson(json, 'Stock A').outflows = aOutflows; + stockJson(json, 'Stock B').inflows = ['flow_renamed']; + stockJson(json, 'Stock C').outflows = ['flow_renamed']; + }); + expect(checkStockFlowDelta(load(baseJson()), load(renamedAndMoved(['Flow H'])))).toEqual([]); + expect(arms(checkStockFlowDelta(load(baseJson()), load(renamedAndMoved(['Flow H', 'flow_renamed']))))).toEqual([ + 'M2.notRemoved', + ]); + }); + + it('reordering a list is not a change', () => { + const reordered = edit((json) => { + stockJson(json, 'Stock A').outflows = ['Flow H', 'Flow F']; + }); + expect(checkStockFlowDelta(load(baseJson()), load(reordered))).toEqual([]); + }); + + it('a canonical spelling of the new entry counts as the entry', () => { + expect( + moveEnd('source', (json) => { + removeFromOld(json, 'source'); + stockJson(json, 'Stock C').outflows = ['flow_f']; + })(), + ).toEqual([]); + }); + + it('no list requirement at the new stock when the flow has no variable', () => { + const next = edit((json) => { + json.flows = json.flows!.filter((f) => f.name !== 'Flow F'); + setPoints(json, 4, [ + { x: 300, y: 282.5, attachedToUid: 3 }, + { x: 277.5, y: 100, attachedToUid: 2 }, + ]); + removeFromOld(json, 'source'); + }); + expect(checkStockFlowDelta(load(baseJson()), load(next))).toEqual([]); + }); +}); + +// Renames through the path the Editor takes (`relabelVariable` -> the +// controller's `buildEditOps` -> `applyPatch` with the controller's options -> +// serializeJson -> projectFromJson), so the list rewrite being excluded is the +// one the engine produces. +describeWithEngine('M2 renames through the engine rename path', () => { + let engine: EngineModule; + + beforeAll(async () => { + engine = await loadEngine(); + }); + + async function rename( + oldName: string, + newName: string, + adjust: (json: JsonModel) => void = () => {}, + ): Promise<{ base: ViewAndVariables; next: ViewAndVariables }> { + const projectJson: JsonProject = { + name: 'rename', + simSpecs: { startTime: 0, endTime: 1, dt: '1' }, + models: [baseJson()], + }; + const project = await engine.Project.openJson(JSON.stringify(projectJson)); + try { + const base = await editorModel(project); + const ops = buildEditOps(base, base.views[0], relabelVariable(base.views[0], oldName, newName)); + await project.applyPatch({ models: [{ name: 'main', ops }] }, { allowErrors: true }); + const serialized = JSON.parse(await project.serializeJson()) as JsonProject; + adjust(serialized.models.find((m) => m.name === 'main')!); + return { base: fromModel(base), next: fromModel(mainModel(projectFromJson(serialized).models)) }; + } finally { + await project.dispose(); + } + } + + it('a renamed flow: the engine rewrites and re-sorts its list entries, which is not a change', async () => { + const { base, next } = await rename('Flow F', 'Flow Renamed'); + const lists = (vv: ViewAndVariables, ident: string): unknown => { + const s = vv.variables.get(ident); + return s?.type === 'stock' ? { inflows: s.inflows, outflows: s.outflows } : undefined; + }; + // What the rename actually produced, so the exclusion below is exercised on it. + expect(lists(next, 'stock_a')).toEqual({ inflows: ['Inflow G'], outflows: ['Flow H', 'flow_renamed'] }); + expect(lists(next, 'stock_b')).toEqual({ inflows: ['flow_renamed'], outflows: [] }); + expect(checkStockFlowDelta(base, next)).toEqual([]); + }); + + it('a renamed stock keeps its lists, which is not a change', async () => { + const { base, next } = await rename('Stock A', 'Stock Renamed'); + expect(next.variables.has('stock_renamed')).toBe(true); + expect(checkStockFlowDelta(base, next)).toEqual([]); + }); + + it('an entry left under the old name after a flow rename is a change', async () => { + const { base, next } = await rename('Flow F', 'Flow Renamed', (json) => { + json.stocks!.find((s) => s.name === 'Stock B')!.inflows = ['Flow F']; + }); + expect(arms(checkStockFlowDelta(base, next))).toEqual(['M2.otherEntryChanged']); + }); + + it('an unrelated entry changed alongside a rename still reports', async () => { + const { base, next } = await rename('Flow F', 'Flow Renamed', (json) => { + json.stocks!.find((s) => s.name === 'Stock A')!.inflows = []; + }); + expect(arms(checkStockFlowDelta(base, next))).toEqual(['M2.otherEntryChanged']); + }); +}); + +describe('M3 cloud endpoints', () => { + it("a one-point flow's lone point is one endpoint, not two", () => { + const view = load(edit((json) => setPoints(json, 5, [{ x: 100, y: 250, attachedToUid: 6 }]))).view; + expect(checkReferentialIntegrity(view)).toEqual([]); + }); +}); + +describe('stock-list agreement scoping', () => { + it('ignores entries naming flows with no element on the view', () => { + const json = edit((j) => { + j.flows!.push({ name: 'Offscreen', equation: '1' }); + stockJson(j, 'Stock A').outflows = ['Flow F', 'Flow H', 'Offscreen']; + }); + const { view, variables } = load(json); + expect(checkStockFlowAgreement(view, variables)).toEqual([]); + }); +}); diff --git a/src/diagram/tests/view-model-sync.test.ts b/src/diagram/tests/view-model-sync.test.ts new file mode 100644 index 000000000..97e4387e7 --- /dev/null +++ b/src/diagram/tests/view-model-sync.test.ts @@ -0,0 +1,801 @@ +// Copyright 2026 The Simlin Authors. All rights reserved. +// Use of this source code is governed by the Apache License, +// Version 2.0, that can be found in the LICENSE file. + +// buildEditOps over the enumerations the plan names: flow end (source, sink) x +// the attachment the end has before the edit (cloud, existing stock, stock +// element with no variable, stock element naming a non-stock) x the one it has +// after (cloud, a different existing stock, a stock created by the same edit, +// the two degenerate stock elements); deleting a stock with a flow attached as +// source, sink, or both; deleting a stock together with one of its flows; a +// flow listed in two stocks' lists; renames with and without a move; creates +// and deletes of each variable kind; op order. +// +// How the inputs are built, and why that is production's input: the base view +// and committed model come from the engine's own serialization through the +// production loader (projectFromJson), exactly as the controller rebuilds +// `committed`. A next view is that serialized view JSON with attachments +// rewritten and loaded through stockFlowViewFromJson. buildEditOps reads only +// element uids, names, types and flow endpoint attachments, so a next view that +// differs from base in those fields is the input production's gestures supply +// (Phase 4's gestures still compute geometry with the old routing; the geometry +// fields they change are not read here). Deletes are planned by the production +// planDelete. +// +// Every row is checked twice: the op payloads against the row's expectation +// (derived from the row's axes, not hand-listed per row), and -- through the +// real engine -- the committed checkers M1/M2/M3 on the model the patch +// produces. The engine rows skip on a checkout without libsimlin.wasm and run +// under CI (support/engine.ts). + +import { describe, it, expect, beforeAll } from '@rstest/core'; + +import { canonicalize } from '@simlin/core/canonicalize'; +import { projectFromJson, stockFlowViewFromJson, type Model, type StockFlowView } from '@simlin/core/datamodel'; +import type { JsonModelOperation, JsonProject } from '@simlin/engine'; + +import { buildEditOps, EditConflictError } from '../view-model-sync'; +import { planDelete } from '../plan-delete'; +import { describeWithEngine, editorModel, loadEngine, type EngineModule } from './support/engine'; +import { + checkCreatedVariables, + checkKindAgreement, + checkReferentialIntegrity, + checkStockFlowDelta, + checkStockListDuplicates, + formatViewViolations, +} from './support/view-invariants'; + +// --------------------------------------------------------------------------- +// Fixture + +// JSON view elements are plain objects on the wire. +type JsonElement = Record & { type: string; uid: number }; +interface JsonModelLike { + name: string; + stocks: Array<{ name: string; initialEquation: string; inflows: string[]; outflows: string[] }>; + flows: Array<{ name: string; equation: string }>; + auxiliaries: Array<{ name: string; equation: string }>; + modules?: Array>; + views: Array<{ elements: JsonElement[] }>; +} + +const UID = { + stockA: 1, + stockB: 2, + stockC: 3, + flowF: 4, + flowG: 5, + flowH: 6, + flowK: 7, + auxX: 8, + linkAtoX: 9, + aliasA: 10, + linkAliasToK: 11, + ghostStock: 12, + wrongKindStock: 13, + cloudG: 20, + cloudH: 21, + cloudKSource: 22, + cloudKSink: 23, + // Uids the edits allocate. + createdStock: 30, + createdFlow: 31, + createdAux: 32, + createdModule: 33, + newCloud: 40, +} as const; + +function baseModelJson(): JsonModelLike { + return { + name: 'main', + stocks: [ + { name: 'Stock A', initialEquation: '10', inflows: ['Flow G'], outflows: ['Flow F'] }, + { name: 'Stock B', initialEquation: '0', inflows: ['Flow F'], outflows: ['Flow H'] }, + { name: 'Stock C', initialEquation: '0', inflows: [], outflows: [] }, + ], + flows: [ + { name: 'Flow F', equation: '1' }, + { name: 'Flow G', equation: '1' }, + { name: 'Flow H', equation: '1' }, + { name: 'Flow K', equation: '1' }, + ], + auxiliaries: [ + { name: 'Aux X', equation: 'Stock A * 0.1' }, + { name: 'Aux W', equation: '2' }, + ], + views: [ + { + elements: [ + { type: 'stock', uid: UID.stockA, name: 'Stock A', x: 100, y: 100 }, + { type: 'stock', uid: UID.stockB, name: 'Stock B', x: 300, y: 100 }, + { type: 'stock', uid: UID.stockC, name: 'Stock C', x: 300, y: 300 }, + { + type: 'flow', + uid: UID.flowF, + name: 'Flow F', + x: 200, + y: 100, + points: [ + { x: 122.5, y: 100, attachedToUid: UID.stockA }, + { x: 277.5, y: 100, attachedToUid: UID.stockB }, + ], + }, + { type: 'cloud', uid: UID.cloudG, flowUid: UID.flowG, x: 0, y: 100 }, + { + type: 'flow', + uid: UID.flowG, + name: 'Flow G', + x: 40, + y: 100, + points: [ + { x: 0, y: 100, attachedToUid: UID.cloudG }, + { x: 77.5, y: 100, attachedToUid: UID.stockA }, + ], + }, + { + type: 'flow', + uid: UID.flowH, + name: 'Flow H', + x: 400, + y: 100, + points: [ + { x: 322.5, y: 100, attachedToUid: UID.stockB }, + { x: 500, y: 100, attachedToUid: UID.cloudH }, + ], + }, + { type: 'cloud', uid: UID.cloudH, flowUid: UID.flowH, x: 500, y: 100 }, + { type: 'cloud', uid: UID.cloudKSource, flowUid: UID.flowK, x: 0, y: 500 }, + { + type: 'flow', + uid: UID.flowK, + name: 'Flow K', + x: 100, + y: 500, + points: [ + { x: 0, y: 500, attachedToUid: UID.cloudKSource }, + { x: 200, y: 500, attachedToUid: UID.cloudKSink }, + ], + }, + { type: 'cloud', uid: UID.cloudKSink, flowUid: UID.flowK, x: 200, y: 500 }, + { type: 'aux', uid: UID.auxX, name: 'Aux X', x: 200, y: 20 }, + { type: 'link', uid: UID.linkAtoX, fromUid: UID.stockA, toUid: UID.auxX }, + { type: 'alias', uid: UID.aliasA, aliasOfUid: UID.stockA, x: 100, y: 250 }, + { type: 'link', uid: UID.linkAliasToK, fromUid: UID.aliasA, toUid: UID.flowK }, + // A stock element whose variable does not exist, and one naming an aux: + // imported shapes the editor must accept without touching any list. + { type: 'stock', uid: UID.ghostStock, name: 'Ghost', x: 500, y: 300 }, + { type: 'stock', uid: UID.wrongKindStock, name: 'Aux W', x: 600, y: 300 }, + ], + }, + ], + }; +} + +function projectJsonOf(model: JsonModelLike): JsonProject { + return { + name: 'view-model-sync', + simSpecs: { startTime: 0, endTime: 1, dt: '1' }, + models: [model], + } as unknown as JsonProject; +} + +function clone(value: T): T { + return JSON.parse(JSON.stringify(value)) as T; +} + +function elementByUid(model: JsonModelLike, uid: number): JsonElement { + const el = model.views[0].elements.find((e) => e.uid === uid); + if (el === undefined) { + throw new Error(`fixture has no uid ${uid}`); + } + return el; +} + +type End = 'source' | 'sink'; + +// Point `flowUid`'s `end` at `target` (a stock uid) or at a fresh cloud, +// removing the cloud it was attached to, if any. +function setEnd(model: JsonModelLike, flowUid: number, end: End, target: number | { cloud: number }): void { + const flow = elementByUid(model, flowUid) as JsonElement & { + points: Array<{ x: number; y: number; attachedToUid?: number }>; + }; + const point = end === 'source' ? flow.points[0] : flow.points[flow.points.length - 1]; + const old = model.views[0].elements.find((e) => e.uid === point.attachedToUid); + if (old?.type === 'cloud') { + model.views[0].elements = model.views[0].elements.filter((e) => e.uid !== old.uid); + } + if (typeof target === 'number') { + point.attachedToUid = target; + } else { + model.views[0].elements.push({ type: 'cloud', uid: target.cloud, flowUid, x: point.x, y: point.y }); + point.attachedToUid = target.cloud; + } +} + +function listOf(model: JsonModelLike, stockName: string, end: End): string[] { + const stock = model.stocks.find((s) => s.name === stockName); + if (stock === undefined) { + throw new Error(`fixture has no stock ${stockName}`); + } + return end === 'source' ? stock.outflows : stock.inflows; +} + +function addStockElement(model: JsonModelLike, uid: number, name: string): void { + model.views[0].elements.push({ type: 'stock', uid, name, x: 700, y: 500 }); +} + +// --------------------------------------------------------------------------- +// Running an edit + +interface Edit { + /** Adjust the authored model before it is opened (the committed state). */ + readonly setup?: (model: JsonModelLike) => void; + /** Rewrite the committed view JSON into the next view. */ + readonly next: (view: JsonModelLike) => void; + /** Build the next view through planDelete instead (a delete edit). */ + readonly deleteSelection?: readonly number[]; +} + +interface Planned { + readonly committed: Model; + readonly base: StockFlowView; + readonly next: StockFlowView; +} + +// Rewrites the serialized committed model's view, never the authored fixture: +// the base is what the engine (or loader) actually returned. +function planFrom(committedJson: JsonProject, edit: Edit): Planned { + const committed = projectFromJson(committedJson).models.get('main')!; + const base = committed.views[0]; + if (edit.deleteSelection !== undefined) { + return { committed, base, next: planDelete(base, new Set(edit.deleteSelection)) }; + } + const modelJson = clone(committedJson.models.find((m) => m.name === 'main')) as unknown as JsonModelLike; + edit.next(modelJson); + const next = stockFlowViewFromJson(modelJson.views[0] as never, committed.variables); + return { committed, base, next }; +} + +function authoredProject(edit: Edit): JsonProject { + const model = baseModelJson(); + edit.setup?.(model); + return projectJsonOf(model); +} + +type StockLists = { inflows: string[]; outflows: string[] }; + +function stockOpsOf(ops: readonly JsonModelOperation[]): Map { + const out = new Map(); + for (const op of ops) { + if (op.type === 'updateStockFlows') { + out.set(op.payload.ident, { + inflows: op.payload.inflows.map(canonicalize).sort(), + outflows: op.payload.outflows.map(canonicalize).sort(), + }); + } + } + return out; +} + +function opTypes(ops: readonly JsonModelOperation[]): string[] { + return ops.map((op) => op.type); +} + +// --------------------------------------------------------------------------- +// Attachment rows + +type FromKind = 'cloud' | 'existing' | 'ghost' | 'wrongKind'; +type ToKind = 'cloud' | 'existing' | 'created' | 'ghost' | 'wrongKind'; + +const FROM_KINDS: readonly FromKind[] = ['cloud', 'existing', 'ghost', 'wrongKind']; +const TO_KINDS: readonly ToKind[] = ['cloud', 'existing', 'created', 'ghost', 'wrongKind']; + +interface AttachRow { + readonly end: End; + readonly from: FromKind; + readonly to: ToKind; +} + +// Every (end, from, to) except the pairs that are not an attachment change: +// cloud -> cloud (the end stays on its own cloud), and a degenerate element to +// the same element. "existing -> existing" moves between two different stocks +// (Stock A -> Stock C); "created" is only a target, since a base view cannot +// contain an element the edit creates. +const ATTACH_ROWS: readonly AttachRow[] = (['source', 'sink'] as const).flatMap((end) => + FROM_KINDS.flatMap((from) => + TO_KINDS.filter((to) => !(from === to && from !== 'existing')).map((to) => ({ end, from, to })), + ), +); + +const FROM_STOCK = { existing: 'Stock A', ghost: 'Ghost', wrongKind: 'Aux W' } as const; +const FROM_UID = { existing: UID.stockA, ghost: UID.ghostStock, wrongKind: UID.wrongKindStock } as const; +const TO_UID = { + existing: UID.stockC, + created: UID.createdStock, + ghost: UID.ghostStock, + wrongKind: UID.wrongKindStock, +} as const; +const TO_IDENT = { existing: 'stock_c', created: 'stock_new' } as const; + +// Flow K starts cloud -> cloud; the row moves one of its ends. +function attachEdit(row: AttachRow): Edit { + return { + setup: (model) => { + if (row.from === 'cloud') { + return; + } + setEnd(model, UID.flowK, row.end, FROM_UID[row.from]); + if (row.from === 'existing') { + // A well-formed committed model lists what the view attaches. + listOf(model, FROM_STOCK.existing, row.end).push('Flow K'); + } + }, + next: (model) => { + if (row.to === 'cloud') { + setEnd(model, UID.flowK, row.end, { cloud: UID.newCloud }); + return; + } + if (row.to === 'created') { + addStockElement(model, UID.createdStock, 'Stock New'); + } + setEnd(model, UID.flowK, row.end, TO_UID[row.to]); + }, + }; +} + +// The expected stock ops for a row, from its axes alone: the flow leaves an +// existing old stock and joins an existing or created new one; degenerate +// elements and clouds get no op. +function expectedAttachStockOps(row: AttachRow): Map { + const baseLists: Record = { + stock_a: { inflows: ['flow_g'], outflows: ['flow_f'] }, + stock_c: { inflows: [], outflows: [] }, + stock_new: { inflows: [], outflows: [] }, + }; + const listName = row.end === 'source' ? 'outflows' : 'inflows'; + const out = new Map(); + if (row.from === 'existing') { + // The base listed Flow K there; the op removes it and echoes the rest. + out.set('stock_a', { ...baseLists.stock_a }); + } + if (row.to === 'existing' || row.to === 'created') { + const ident = TO_IDENT[row.to]; + const lists = { inflows: [...baseLists[ident].inflows], outflows: [...baseLists[ident].outflows] }; + lists[listName] = [...lists[listName], 'flow_k'].sort(); + out.set(ident, lists); + } + return out; +} + +function attachRowName(row: AttachRow): string { + return `${row.end}: ${row.from} -> ${row.to}`; +} + +// --------------------------------------------------------------------------- +// Other rows: each carries its own edit and expected ops. + +interface ScenarioRow { + readonly name: string; + readonly edit: Edit; + readonly expectOps: (ops: readonly JsonModelOperation[]) => void; + /** + * Uids whose M1 violations the committed model carries by construction + * (the degenerate stock elements), excluded from the kind check. + */ + readonly degenerateUids?: readonly number[]; + /** Why M2 is not checked, for the one row whose engine semantics the checker predates. */ + readonly skipM2?: string; +} + +const DEGENERATE = [UID.ghostStock, UID.wrongKindStock]; + +const SCENARIO_ROWS: readonly ScenarioRow[] = [ + { + // Delete stock x flow attached as sink: Stock B with only Flow F (sink) on it. + name: 'delete a stock a flow ends on (sink)', + edit: { + setup: (model) => { + setEnd(model, UID.flowH, 'source', { cloud: 41 }); + model.stocks.find((s) => s.name === 'Stock B')!.outflows = []; + }, + next: () => {}, + deleteSelection: [UID.stockB], + }, + expectOps: (ops) => { + expect(opTypes(ops)).toEqual(['deleteVariable', 'upsertView']); + expect(ops[0]).toEqual({ type: 'deleteVariable', payload: { ident: 'stock_b' } }); + }, + }, + { + name: 'delete a stock a flow starts on (source)', + edit: { + setup: (model) => { + setEnd(model, UID.flowF, 'sink', { cloud: 41 }); + model.stocks.find((s) => s.name === 'Stock B')!.inflows = []; + }, + next: () => {}, + deleteSelection: [UID.stockB], + }, + expectOps: (ops) => { + expect(opTypes(ops)).toEqual(['deleteVariable', 'upsertView']); + }, + }, + { + name: 'delete a stock a flow both starts and ends on', + edit: { + setup: (model) => { + setEnd(model, UID.flowK, 'source', UID.stockC); + setEnd(model, UID.flowK, 'sink', UID.stockC); + listOf(model, 'Stock C', 'source').push('Flow K'); + listOf(model, 'Stock C', 'sink').push('Flow K'); + }, + next: () => {}, + deleteSelection: [UID.stockC], + }, + expectOps: (ops) => { + expect(opTypes(ops)).toEqual(['deleteVariable', 'upsertView']); + }, + }, + { + // Flow F runs A -> B; deleting B with F takes F out of A's outflows through + // the engine's deleteVariable, so no updateStockFlows is emitted at all. + name: 'delete a stock together with one of its flows', + edit: { next: () => {}, deleteSelection: [UID.stockB, UID.flowF] }, + expectOps: (ops) => { + expect(opTypes(ops)).toEqual(['deleteVariable', 'deleteVariable', 'upsertView']); + expect(ops.slice(0, 2).map((op) => (op.payload as { ident: string }).ident)).toEqual(['stock_b', 'flow_f']); + }, + }, + { + // Deleting Stock A removes its alias and the links touching A or the alias. + name: 'delete a stock with an alias and links', + edit: { next: () => {}, deleteSelection: [UID.stockA] }, + expectOps: (ops) => { + expect(opTypes(ops)).toEqual(['deleteVariable', 'upsertView']); + }, + }, + { + // An XMILE import can list one flow as an outflow of two stocks. Moving an + // unrelated flow onto one of them echoes the stale entry untouched. + name: 'a flow listed in two stocks stays untouched when another flow attaches there', + edit: { + setup: (model) => { + listOf(model, 'Stock C', 'source').push('Flow F'); + }, + next: (model) => setEnd(model, UID.flowK, 'sink', UID.stockC), + }, + expectOps: (ops) => { + expect(stockOpsOf(ops)).toEqual(new Map([['stock_c', { inflows: ['flow_k'], outflows: ['flow_f'] }]])); + }, + }, + { + name: 'a flow listed in two stocks: moving its attached end leaves the stale list alone', + edit: { + setup: (model) => { + listOf(model, 'Stock C', 'source').push('Flow F'); + }, + next: (model) => setEnd(model, UID.flowF, 'source', UID.stockB), + }, + expectOps: (ops) => { + // Flow F leaves A's outflows and joins B's; C's stale entry gets no op. + expect(stockOpsOf(ops)).toEqual( + new Map([ + ['stock_a', { inflows: ['flow_g'], outflows: [] }], + ['stock_b', { inflows: ['flow_f'], outflows: ['flow_f', 'flow_h'] }], + ]), + ); + }, + }, + { + name: 'rename a flow', + edit: { + next: (model) => { + elementByUid(model, UID.flowF).name = 'Flow Renamed'; + }, + }, + expectOps: (ops) => { + expect(ops[0]).toEqual({ type: 'renameVariable', payload: { from: 'flow_f', to: 'Flow Renamed' } }); + expect(opTypes(ops)).toEqual(['renameVariable', 'upsertView']); + }, + }, + { + name: 'rename a flow and move its sink', + edit: { + next: (model) => { + elementByUid(model, UID.flowF).name = 'Flow Renamed'; + setEnd(model, UID.flowF, 'sink', UID.stockC); + }, + }, + expectOps: (ops) => { + expect(opTypes(ops)).toEqual(['renameVariable', 'updateStockFlows', 'updateStockFlows', 'upsertView']); + // B's inflow is carried through the rename, then removed; C gains the new ident. + expect(stockOpsOf(ops)).toEqual( + new Map([ + ['stock_b', { inflows: [], outflows: ['flow_h'] }], + ['stock_c', { inflows: ['flow_renamed'], outflows: [] }], + ]), + ); + }, + }, + { + // A rename of an attached stock's element changes no list entry. + name: 'rename a stock', + edit: { + next: (model) => { + elementByUid(model, UID.stockA).name = 'Stock Renamed'; + }, + }, + expectOps: (ops) => { + expect(opTypes(ops)).toEqual(['renameVariable', 'upsertView']); + }, + }, + { + name: 'create a flow between two existing stocks', + edit: { + next: (model) => { + model.views[0].elements.push({ + type: 'flow', + uid: UID.createdFlow, + name: 'Flow New', + x: 200, + y: 200, + points: [ + { x: 100, y: 122.5, attachedToUid: UID.stockA }, + { x: 300, y: 277.5, attachedToUid: UID.stockC }, + ], + }); + }, + }, + expectOps: (ops) => { + expect(opTypes(ops)).toEqual(['upsertFlow', 'updateStockFlows', 'updateStockFlows', 'upsertView']); + expect(stockOpsOf(ops)).toEqual( + new Map([ + ['stock_a', { inflows: ['flow_g'], outflows: ['flow_f', 'flow_new'] }], + ['stock_c', { inflows: ['flow_new'], outflows: [] }], + ]), + ); + }, + }, + { + name: 'create an aux and a module', + edit: { + next: (model) => { + model.views[0].elements.push({ type: 'aux', uid: UID.createdAux, name: 'Aux New', x: 5, y: 5 }); + model.views[0].elements.push({ type: 'module', uid: UID.createdModule, name: 'Module New', x: 9, y: 9 }); + }, + }, + expectOps: (ops) => { + expect(ops.slice(0, 2)).toEqual([ + { type: 'upsertAux', payload: { aux: { name: 'Aux New', equation: '' } } }, + { type: 'upsertModule', payload: { module: { name: 'Module New', modelName: '', references: [] } } }, + ]); + }, + }, + { + // The model already lists Flow K as an inflow of Stock C while the view + // shows K between clouds (a divergent import); attaching K there must not + // list it twice, and needs no op. + name: 'attach onto a stock that already lists the flow', + edit: { + setup: (model) => { + listOf(model, 'Stock C', 'sink').push('Flow K'); + }, + next: (model) => setEnd(model, UID.flowK, 'sink', UID.stockC), + }, + expectOps: (ops) => { + expect(opTypes(ops)).toEqual(['upsertView']); + }, + }, + { + // Stock C lists Flow K (not attached). The edit deletes K and attaches Flow + // G's source onto C: the echoed outflows must not re-add K, which the + // engine's deleteVariable already removed. + name: 'a touched stock does not echo an entry for a flow the edit deletes', + edit: { + setup: (model) => { + listOf(model, 'Stock C', 'source').push('Flow K'); + }, + next: (model) => { + model.views[0].elements = model.views[0].elements.filter( + (e) => + e.uid !== UID.flowK && e.uid !== UID.cloudKSource && e.uid !== UID.cloudKSink && e.uid !== UID.linkAliasToK, + ); + setEnd(model, UID.flowG, 'source', UID.stockC); + }, + }, + expectOps: (ops) => { + expect(opTypes(ops)).toEqual(['deleteVariable', 'updateStockFlows', 'upsertView']); + expect(stockOpsOf(ops)).toEqual(new Map([['stock_c', { inflows: [], outflows: ['flow_g'] }]])); + }, + skipM2: + "the engine's deleteVariable strips the deleted flow from every stock list, which checkStockFlowDelta " + + 'reads as a changed other entry on Stock C; the row pins the payload and the no-duplicate/no-dangling result instead', + }, + { + // Removing an alias, a link, or a degenerate stock element deletes no variable. + name: 'removing an alias, a link and a stock element with no variable deletes nothing', + edit: { next: () => {}, deleteSelection: [UID.aliasA, UID.linkAtoX, UID.ghostStock] }, + expectOps: (ops) => { + expect(opTypes(ops)).toEqual(['upsertView']); + }, + }, + { + name: 'a second primary element keeps its variable alive', + edit: { + setup: (model) => { + model.views[0].elements.push({ type: 'aux', uid: 15, name: 'Aux X', x: 900, y: 20 }); + }, + next: () => {}, + deleteSelection: [UID.auxX], + }, + expectOps: (ops) => { + expect(opTypes(ops)).toEqual(['upsertView']); + }, + }, + { + // One edit exercising every op class, to pin the order one patch applies them. + name: 'op order: rename, deletes, creates, stock ops, view', + edit: { + next: (model) => { + elementByUid(model, UID.auxX).name = 'Aux Renamed'; + model.views[0].elements = model.views[0].elements.filter((e) => e.uid !== UID.flowH && e.uid !== UID.cloudH); + model.views[0].elements.push({ type: 'aux', uid: UID.createdAux, name: 'Aux New', x: 5, y: 5 }); + setEnd(model, UID.flowK, 'sink', UID.stockC); + }, + }, + expectOps: (ops) => { + expect(opTypes(ops)).toEqual(['renameVariable', 'deleteVariable', 'upsertAux', 'updateStockFlows', 'upsertView']); + }, + }, +]; + +// --------------------------------------------------------------------------- + +describe('buildEditOps (op payloads)', () => { + function opsFor(edit: Edit): readonly JsonModelOperation[] { + const { committed, base, next } = planFrom(authoredProject(edit), edit); + return buildEditOps(committed, base, next); + } + + describe('flow end attachment', () => { + for (const row of ATTACH_ROWS) { + it(attachRowName(row), () => { + const ops = opsFor(attachEdit(row)); + expect(stockOpsOf(ops)).toEqual(expectedAttachStockOps(row)); + const creates = ops.filter((op) => op.type === 'upsertStock'); + expect(creates).toHaveLength(row.to === 'created' ? 1 : 0); + expect(ops[ops.length - 1].type).toBe('upsertView'); + }); + } + }); + + for (const row of SCENARIO_ROWS) { + it(row.name, () => { + row.expectOps(opsFor(row.edit)); + }); + } + + it('the upsertView carries the next view as given', () => { + const edit = attachEdit({ end: 'sink', from: 'cloud', to: 'existing' }); + const { committed, base, next } = planFrom(authoredProject(edit), edit); + const ops = buildEditOps(committed, base, next); + const view = ops[ops.length - 1]; + expect(view.type).toBe('upsertView'); + expect((view.payload as { index: number }).index).toBe(0); + const k = (view.payload as { view: { elements: JsonElement[] } }).view.elements.find((e) => e.uid === UID.flowK); + expect((k as unknown as { points: Array<{ attachedToUid: number }> }).points[1].attachedToUid).toBe(UID.stockC); + }); + + it('a created element naming an existing variable is a conflict, whatever its kind', () => { + for (const [type, name] of [ + ['aux', 'Stock A'], + ['stock', 'Flow F'], + ['flow', 'aux_x'], + ] as const) { + const edit: Edit = { + next: (model) => { + model.views[0].elements.push({ type, uid: UID.createdAux, name, x: 1, y: 1, points: [] }); + }, + }; + expect(() => opsFor(edit)).toThrow(EditConflictError); + } + }); + + it('a rename onto an existing variable is a conflict', () => { + const edit: Edit = { + next: (model) => { + elementByUid(model, UID.auxX).name = 'Stock A'; + }, + }; + expect(() => opsFor(edit)).toThrow(EditConflictError); + }); + + it('a case-only rename is a rename (the engine restamps the display spelling)', () => { + const ops = opsFor({ + next: (model) => { + elementByUid(model, UID.auxX).name = 'AUX X'; + }, + }); + expect(ops[0]).toEqual({ type: 'renameVariable', payload: { from: 'aux_x', to: 'AUX X' } }); + }); + + it('a view planned on a pending rename resolves idents from element names, not stale idents', () => { + // The rename handler relabels an element but leaves its ident stale; an + // edit planned on that view, dequeued after the rename landed, deletes the + // RENAMED variable. + const committedJson = authoredProject({ + setup: (model) => { + model.auxiliaries.find((a) => a.name === 'Aux X')!.name = 'Aux Renamed'; + elementByUid(model, UID.auxX).name = 'Aux Renamed'; + }, + next: () => {}, + }); + const committed = projectFromJson(committedJson).models.get('main')!; + const committedView = committed.views[0]; + const plannedOn: StockFlowView = { + ...committedView, + elements: committedView.elements.map((el) => + el.uid === UID.auxX && el.type === 'aux' ? { ...el, ident: 'aux_x' } : el, + ), + }; + const next = planDelete(plannedOn, new Set([UID.auxX])); + const ops = buildEditOps(committed, plannedOn, next); + expect(ops[0]).toEqual({ type: 'deleteVariable', payload: { ident: 'aux_renamed' } }); + }); +}); + +describeWithEngine('buildEditOps through the engine: M1/M2/M3 on the committed result', () => { + let engine: EngineModule; + + beforeAll(async () => { + engine = await loadEngine(); + }); + + async function runThroughEngine(edit: Edit, degenerateUids: readonly number[], skipM2?: string): Promise { + const project = await engine.Project.openJson(JSON.stringify(authoredProject(edit))); + try { + const committedJson = JSON.parse(await project.serializeJson(undefined, true)) as JsonProject; + const { committed, base, next } = planFrom(committedJson, edit); + const ops = buildEditOps(committed, base, next); + await project.applyPatch({ models: [{ name: 'main', ops }] }, { allowErrors: true }); + const after = await editorModel(project); + const baseVV = { view: base, variables: committed.variables }; + const nextVV = { view: after.views[0], variables: after.variables }; + const violations = [ + ...(skipM2 === undefined ? checkStockFlowDelta(baseVV, nextVV) : []), + ...checkKindAgreement(after.views[0], after.variables).filter( + (v) => v.uid === undefined || !degenerateUids.includes(v.uid), + ), + ...checkCreatedVariables(base, nextVV), + ...checkReferentialIntegrity(after.views[0]), + ...checkStockListDuplicates(after.variables), + ]; + expect(formatViewViolations(violations)).toBe(''); + // Every list entry names an existing flow: nothing dangles. + for (const variable of after.variables.values()) { + if (variable.type === 'stock') { + for (const entry of [...variable.inflows, ...variable.outflows]) { + expect(after.variables.get(canonicalize(entry))?.type).toBe('flow'); + } + } + } + } finally { + await project.dispose(); + } + } + + describe('flow end attachment', () => { + for (const row of ATTACH_ROWS) { + it(attachRowName(row), async () => { + await runThroughEngine(attachEdit(row), DEGENERATE); + }); + } + }); + + for (const row of SCENARIO_ROWS) { + it(row.name, async () => { + await runThroughEngine(row.edit, row.degenerateUids ?? DEGENERATE, row.skipM2); + }); + } +}); diff --git a/src/diagram/variable-details-display.ts b/src/diagram/variable-details-display.ts index ec7f7da0b..78112fc19 100644 --- a/src/diagram/variable-details-display.ts +++ b/src/diagram/variable-details-display.ts @@ -2,7 +2,7 @@ // Use of this source code is governed by the Apache License, // Version 2.0, that can be found in the LICENSE file. -import type { ConnectorError, EquationError, UnitError, Variable } from '@simlin/core/datamodel'; +import type { ConnectorError, EquationError, UnitError, Variable, VariableWarning } from '@simlin/core/datamodel'; export interface VariableDetailsView { /** @@ -20,22 +20,27 @@ export interface VariableDetailsView { * equation); surfaced as warnings beside the chart, like unit warnings. */ readonly connectorWarnings: readonly ConnectorError[]; + /** Engine advisories that are not unit errors; surfaced beside the chart too. */ + readonly warnings: readonly VariableWarning[]; } /** - * Decide what the variable-details panel shows for a variable. Unit errors are - * non-fatal -- the variable still simulates and has data -- so they no longer - * hide the chart; only genuine equation/compile errors (which leave the - * variable with no valid data) replace it. + * Decide what the variable-details panel shows for a variable. Unit errors, + * connector drift and engine advisories are non-fatal -- the variable still + * simulates and has data -- so they never hide the chart; only genuine + * equation/compile errors (which leave the variable with no valid data) + * replace it. */ export function variableDetailsView(variable: Variable): VariableDetailsView { const equationErrors = variable.errors ?? []; const unitWarnings = variable.unitErrors ?? []; const connectorWarnings = variable.connectorErrors ?? []; + const warnings = variable.warnings ?? []; return { showChart: equationErrors.length === 0, equationErrors, unitWarnings, connectorWarnings, + warnings, }; } diff --git a/src/diagram/variable-names.ts b/src/diagram/variable-names.ts new file mode 100644 index 000000000..5e138507c --- /dev/null +++ b/src/diagram/variable-names.ts @@ -0,0 +1,54 @@ +// Copyright 2026 The Simlin Authors. All rights reserved. +// Use of this source code is governed by the Apache License, +// Version 2.0, that can be found in the LICENSE file. + +// pattern: Functional Core +// +// Naming rules for elements the editor creates or renames. The set of used +// idents is supplied by the controller: the committed model's variables plus +// the idents named on the rendered view, which carries every pending create and +// rename. Allocating against the committed model alone reused a default name +// while an earlier create was still in flight, and the engine's upsert then +// replaced that variable whatever its kind. + +import { canonicalize } from '@simlin/core/canonicalize'; + +// The engine matches names canonically, so a collision is a canonical match. +// Allocation gives up after this many suffixes and returns the base name; the +// name commit then reports the collision instead of silently replacing. +const MaxNameSuffix = 1024; + +/** + * The first of `base`, `base 1`, `base 2`, ... whose canonical ident is not + * in `used`; `base` itself when every candidate up to the cap is taken. + */ +export function allocateVariableName(base: string, used: ReadonlySet): string { + if (!used.has(canonicalize(base))) { + return base; + } + for (let i = 1; i < MaxNameSuffix; i++) { + const candidate = `${base} ${i}`; + if (!used.has(canonicalize(candidate))) { + return candidate; + } + } + return base; +} + +/** + * The error to show when `newName` cannot name an element, or undefined when it + * can. `currentIdent` is the ident the element already has (a rename), which + * never collides with itself: a case-only rename changes the display spelling + * of the same variable. + */ +export function nameCollisionError( + newName: string, + currentIdent: string | undefined, + used: ReadonlySet, +): string | undefined { + const ident = canonicalize(newName); + if (ident === currentIdent || !used.has(ident)) { + return undefined; + } + return `A variable named '${newName}' already exists`; +} diff --git a/src/diagram/view-model-sync.ts b/src/diagram/view-model-sync.ts new file mode 100644 index 000000000..c8f944ccb --- /dev/null +++ b/src/diagram/view-model-sync.ts @@ -0,0 +1,354 @@ +// Copyright 2026 The Simlin Authors. All rights reserved. +// Use of this source code is governed by the Apache License, +// Version 2.0, that can be found in the LICENSE file. + +// pattern: Functional Core +// +// The model operations a view edit implies. A view edit is described only by +// the view it was planned on (`baseView`) and the view it produces +// (`nextView`); this module derives every model op from the difference, +// evaluated against the COMMITTED model at dequeue, so a payload is never built +// from state read before an earlier edit landed. See "view-model-sync.ts" in +// docs/design-plans/2026-09-10-diagram-editing-core.md. + +import { canonicalize } from '@simlin/core/canonicalize'; +import { + auxFromJson, + flowFromJson, + isNamedViewElement, + moduleFromJson, + stockFromJson, + stockFlowViewToJson, + type FlowViewElement, + type Model, + type NamedViewElement, + type StockFlowView, + type UID, + type Variable, + type ViewElement, +} from '@simlin/core/datamodel'; +import type { JsonModelOperation } from '@simlin/engine'; + +/** + * Thrown when an edit cannot be expressed against the committed model: a + * created element names a variable that already exists, or a rename targets + * one. Only reachable when name allocation is wrong or the committed model + * moved underneath the edit; the controller fails the item like any engine + * error. + */ +export class EditConflictError extends Error { + constructor(message: string) { + super(message); + this.name = 'EditConflictError'; + } +} + +type End = 'source' | 'sink'; +type VariableKind = Variable['type']; + +// Idents are derived from element NAMES, never from `ViewElement.ident`: the +// name is what the engine stores and matches canonically, while `ident` is a +// derived field a caller-built element (a staged create, a hand-built view) need +// not keep in step with it. +function identOf(element: NamedViewElement): string { + return canonicalize(element.name); +} + +function namedByUid(view: StockFlowView): Map { + const out = new Map(); + for (const el of view.elements) { + if (isNamedViewElement(el)) { + out.set(el.uid, el); + } + } + return out; +} + +function endPoint(flow: FlowViewElement, end: End) { + return end === 'source' ? flow.points[0] : flow.points[flow.points.length - 1]; +} + +/** The ident of the stock element `flow`'s `end` is attached to, if any. */ +function attachedStockIdent(byUid: ReadonlyMap, flow: FlowViewElement | undefined, end: End) { + if (flow === undefined || flow.points.length === 0) { + return undefined; + } + const uid = endPoint(flow, end).attachedToUid; + const el = uid === undefined ? undefined : byUid.get(uid); + return el?.type === 'stock' ? identOf(el) : undefined; +} + +function createOp(element: NamedViewElement): JsonModelOperation { + switch (element.type) { + case 'stock': + return { + type: 'upsertStock', + payload: { stock: { name: element.name, inflows: [], outflows: [], initialEquation: '' } }, + }; + case 'flow': + return { type: 'upsertFlow', payload: { flow: { name: element.name, equation: '' } } }; + case 'module': + return { type: 'upsertModule', payload: { module: { name: element.name, modelName: '', references: [] } } }; + case 'aux': + return { type: 'upsertAux', payload: { aux: { name: element.name, equation: '' } } }; + } +} + +/** + * The variable the create op for `element` makes, read as the editor's + * datamodel reads the engine's: what the rendered model holds for an element + * whose create is still queued (see ProjectController's render). + */ +export function createdVariable(element: NamedViewElement): Variable { + const op = createOp(element); + switch (op.type) { + case 'upsertStock': + return stockFromJson(op.payload.stock); + case 'upsertFlow': + return flowFromJson(op.payload.flow); + case 'upsertModule': + return moduleFromJson(op.payload.module); + case 'upsertAux': + return auxFromJson(op.payload.aux); + default: + throw new Error('createOp returned an operation that creates no variable'); + } +} + +function sameList(a: readonly string[], b: readonly string[]): boolean { + return a.length === b.length && a.every((entry, i) => entry === b[i]); +} + +/** + * The model operations implied by replacing `baseView` with `nextView`, + * followed by the `upsertView` of `nextView`, in the order one patch applies + * them: + * + * 1. `renameVariable` for every named element whose uid survives with a + * different name (`from` is the committed ident, `to` the new name as typed, + * which the engine stores verbatim). + * 2. `deleteVariable` for every named element removed from the view whose + * variable exists in the committed model and names no remaining element; + * then an upsert for every named element created by the edit (new uid). A + * created element naming an existing variable throws EditConflictError. + * 3. One `updateStockFlows` per stock whose lists the edit changes: per flow + * end whose attached stock differs between base and next, the flow leaves + * the old stock's list and joins the new stock's (deduped). Only flows and + * stocks that exist after the edit (committed, not deleted, or created by + * it) are touched; each op carries both full lists from the committed model + * with renames applied, deleted variables omitted, and the deltas applied. + * 4. `upsertView` of `nextView` (index 0), exactly as given: the caller decides + * which viewport it carries. + */ +export function buildEditOps( + committedModel: Model, + baseView: StockFlowView, + nextView: StockFlowView, +): JsonModelOperation[] { + const variables = committedModel.variables; + const baseNamed = namedByUid(baseView); + const nextNamed = namedByUid(nextView); + + // --- renames + const renameOps: JsonModelOperation[] = []; + // committed ident -> ident after the edit + const renamed = new Map(); + for (const [uid, b] of baseNamed) { + const n = nextNamed.get(uid); + if (n === undefined || n.name === b.name) { + continue; + } + const from = identOf(b); + const to = identOf(n); + if (!variables.has(from) || renamed.has(from)) { + continue; + } + if (to !== from && variables.has(to)) { + throw new EditConflictError(`cannot rename '${b.name}' to '${n.name}': a variable with that name exists`); + } + renamed.set(from, to); + renameOps.push({ type: 'renameVariable', payload: { from, to: n.name } }); + } + const afterRename = (ident: string): string => renamed.get(ident) ?? ident; + const beforeRename = new Map(); + for (const [from, to] of renamed) { + beforeRename.set(to, from); + } + + // --- deletes + const remainingIdents = new Set(); + for (const n of nextNamed.values()) { + remainingIdents.add(identOf(n)); + } + const deleteOps: JsonModelOperation[] = []; + const deleted = new Set(); + for (const [uid, b] of baseNamed) { + if (nextNamed.has(uid)) { + continue; + } + const ident = identOf(b); + const variable = variables.get(ident); + if (variable === undefined || remainingIdents.has(afterRename(ident)) || deleted.has(ident)) { + continue; + } + deleted.add(ident); + deleteOps.push({ type: 'deleteVariable', payload: { ident: variable.ident } }); + } + + // --- creates + const createOps: JsonModelOperation[] = []; + const created = new Map(); + for (const [uid, n] of nextNamed) { + if (baseNamed.has(uid)) { + continue; + } + const ident = identOf(n); + if (created.has(ident)) { + continue; + } + const existing = afterRenameVariable(variables, beforeRename, ident); + if (existing !== undefined && !deleted.has(existing.ident)) { + throw new EditConflictError(`cannot create '${n.name}': a variable with that name exists`); + } + created.set(ident, n.type); + createOps.push(createOp(n)); + } + + // The kind of `ident` (an after-edit ident) once the patch's renames, + // deletes and creates have applied, or undefined when no variable exists. + const kindAfterEdit = (ident: string): VariableKind | undefined => { + const createdKind = created.get(ident); + if (createdKind !== undefined) { + return createdKind; + } + const committedIdent = beforeRename.get(ident) ?? ident; + if (deleted.has(committedIdent)) { + return undefined; + } + if (beforeRename.get(ident) === undefined && renamed.has(ident)) { + // renamed away: the old ident no longer names a variable + return undefined; + } + return variables.get(committedIdent)?.type; + }; + + // --- stock/flow deltas + const baseByUid = new Map(baseView.elements.map((el) => [el.uid, el])); + const nextByUid = new Map(nextView.elements.map((el) => [el.uid, el])); + const flowUids = new Set(); + for (const el of [...baseView.elements, ...nextView.elements]) { + if (el.type === 'flow') { + flowUids.add(el.uid); + } + } + + interface ListDelta { + readonly add: Set; + readonly remove: Set; + } + const deltas = new Map(); + const deltaFor = (stockIdent: string, end: End): ListDelta => { + let entry = deltas.get(stockIdent); + if (entry === undefined) { + entry = { + inflows: { add: new Set(), remove: new Set() }, + outflows: { add: new Set(), remove: new Set() }, + }; + deltas.set(stockIdent, entry); + } + return end === 'source' ? entry.outflows : entry.inflows; + }; + + for (const uid of flowUids) { + const b = baseByUid.get(uid); + const n = nextByUid.get(uid); + const baseFlow = b?.type === 'flow' ? b : undefined; + const nextFlow = n?.type === 'flow' ? n : undefined; + // A next element's name is already the after-edit spelling; only a flow the + // edit removed is named through the base view and carried through renames. + const flowIdent = nextFlow !== undefined ? identOf(nextFlow) : afterRename(identOf(baseFlow!)); + if (kindAfterEdit(flowIdent) !== 'flow') { + continue; + } + for (const end of ['source', 'sink'] as const) { + const fromBase = attachedStockIdent(baseByUid, baseFlow, end); + const from = fromBase === undefined ? undefined : afterRename(fromBase); + const to = attachedStockIdent(nextByUid, nextFlow, end); + if (from === to) { + continue; + } + if (from !== undefined && kindAfterEdit(from) === 'stock') { + deltaFor(from, end).remove.add(flowIdent); + } + if (to !== undefined && kindAfterEdit(to) === 'stock') { + deltaFor(to, end).add.add(flowIdent); + } + } + } + + const stockOps: JsonModelOperation[] = []; + for (const stockIdent of [...deltas.keys()].sort()) { + const delta = deltas.get(stockIdent)!; + const committedIdent = beforeRename.get(stockIdent) ?? stockIdent; + const committedStock = created.has(stockIdent) ? undefined : variables.get(committedIdent); + const committedLists = + committedStock?.type === 'stock' + ? { inflows: committedStock.inflows, outflows: committedStock.outflows } + : { inflows: [], outflows: [] }; + const echo = (list: readonly string[]): string[] => + list + .map((entry) => { + const ident = canonicalize(entry); + return renamed.has(ident) ? renamed.get(ident)! : entry; + }) + .filter((entry) => !deleted.has(canonicalize(entry))); + const apply = (list: readonly string[], listDelta: ListDelta): string[] => { + const kept = list.filter((entry) => !listDelta.remove.has(canonicalize(entry))); + const present = new Set(kept.map((entry) => canonicalize(entry))); + for (const flowIdent of listDelta.add) { + if (!present.has(flowIdent)) { + kept.push(flowIdent); + present.add(flowIdent); + } + } + return kept; + }; + const inflowsBefore = echo(committedLists.inflows); + const outflowsBefore = echo(committedLists.outflows); + const inflows = apply(inflowsBefore, delta.inflows); + const outflows = apply(outflowsBefore, delta.outflows); + if (sameList(inflows, inflowsBefore) && sameList(outflows, outflowsBefore)) { + continue; + } + stockOps.push({ type: 'updateStockFlows', payload: { ident: stockIdent, inflows, outflows } }); + } + + return [ + ...renameOps, + ...deleteOps, + ...createOps, + ...stockOps, + { type: 'upsertView', payload: { index: 0, view: stockFlowViewToJson(nextView) } }, + ]; +} + +// The committed variable an after-edit ident names, looking through renames: +// a created element whose name is a rename TARGET collides with the renamed +// variable, and one whose name is a rename SOURCE does not (that name is free +// once the rename applies). +function afterRenameVariable( + variables: ReadonlyMap, + beforeRename: ReadonlyMap, + ident: string, +): Variable | undefined { + const renamedFrom = beforeRename.get(ident); + if (renamedFrom !== undefined) { + return variables.get(renamedFrom); + } + for (const from of beforeRename.values()) { + if (from === ident) { + return undefined; + } + } + return variables.get(ident); +} diff --git a/src/engine/src/index.ts b/src/engine/src/index.ts index 6c3781c06..d8a06adba 100644 --- a/src/engine/src/index.ts +++ b/src/engine/src/index.ts @@ -48,7 +48,7 @@ export * from './json-types'; // Internal types needed for error handling export type { ErrorDetail } from './internal/types'; -export { SimlinErrorKind, SimlinUnitErrorKind } from './internal/types'; +export { SimlinErrorKind, SimlinErrorSeverity, SimlinUnitErrorKind } from './internal/types'; // Backend interface and handle types export type { EngineBackend, ProjectHandle, ModelHandle, SimHandle } from './backend'; diff --git a/src/libsimlin/src/lib.rs b/src/libsimlin/src/lib.rs index 7dcb615ab..3afc11ace 100644 --- a/src/libsimlin/src/lib.rs +++ b/src/libsimlin/src/lib.rs @@ -325,6 +325,7 @@ impl From for SimlinErrorCode { engine::ErrorCode::QueueDrivenFlowRead => SimlinErrorCode::Generic, engine::ErrorCode::QueueOverflowNotOnQueue => SimlinErrorCode::Generic, engine::ErrorCode::QueueLtmDegraded => SimlinErrorCode::Generic, + engine::ErrorCode::RepeatedStockFlow => SimlinErrorCode::Generic, engine::ErrorCode::ConveyorInSubmodelUnsupported => SimlinErrorCode::Generic, engine::ErrorCode::QueueInSubmodelUnsupported => SimlinErrorCode::Generic, engine::ErrorCode::QueueSecondaryOutflowToConveyor => SimlinErrorCode::Generic, diff --git a/src/notebook-widget/CLAUDE.md b/src/notebook-widget/CLAUDE.md index e148315db..6f632aaa5 100644 --- a/src/notebook-widget/CLAUDE.md +++ b/src/notebook-widget/CLAUDE.md @@ -46,7 +46,7 @@ Why snapshots ride in a custom message and not a trait: ipywidgets allows ONE in - Reject handling is defense in depth under either order: when the kernel's state moved to something OTHER than the sent bytes (a foreign change raced the snapshot), its trait push already remounted the Editor and the following `rejected` re-checks the (revision, json) pair and finds it already seeded (no second remount); when it did not move (a reject at an unchanged revision: the kernel raised before applying anything), the pair equals the seed and nothing remounts -- the Editor keeps its local edits at the same version and the next save goes out against the same base. A kernel that applied the snapshot and then said `rejected` is handled (the push classifies `own-ack`, the seed is not adopted, so the reject remounts on the kernel's pair at `base + 1`) but not for free: a `rejected` there costs the view a remount (undo history and any local edits since are lost) for a change that is real -- which is why the kernel side treats apply-then-fail as an accept (obligation 4). Remounts are idempotent on the pair (`remountFrom`); `generation` makes the key change even when the revision number does not. - **Never sends a snapshot above `max_snapshot_bytes`** (`checkSnapshotSize` on the wire size -- `JSON.stringify` then UTF-8 bytes via `TextEncoder`, mirroring pysimlin's `snapshot_wire_size` -- read live from the trait so a raised cap is honoured without a remount): the server would drop it (tornado closes the socket with 1009 on an oversize frame; JupyterLab reconnects the kernel silently) and the save would never resolve. Instead `onSave` sends `{type:'oversize', bytes}`, shows its own warn toast ("Edit not saved: the model is too large for the notebook connection (N > M limit); edit it from Python instead." with sizes from `formatSize`, KiB below 1 MiB else MiB to one decimal, round-half-to-even -- byte-identical to pysimlin's `format_size` on the fixture list pinned in both suites, so the kernel's notice and the toast collapse into one), and resolves `undefined`: the controller keeps its version and the Editor its local edit, and the next edit is refused the same way until the model shrinks or is edited from Python. Exactly at the cap is sent. - The widget never writes `project_json`; only `selection` (set + `save_changes()` at most every 150 ms). A kernel-originated remount publishes an EMPTY selection through the same debounce (superseding any pending one): the new Editor starts with nothing selected and, like every Editor, does not report its initial selection, so without this `selection`/`Model.selection` would keep naming what the replaced Editor had selected -- possibly variables the push removed. -- **The user's viewport survives a kernel-originated remount.** A pan or zoom is never saved by itself (`ProjectController.queueViewUpdate` does not save; only the next edit's snapshot carries the viewBox/zoom), so a remount on the kernel's bytes -- a Python `edit()`, a disk reload, a reject re-seed -- would reset the framing to the stored one, and a project whose stored viewBox is still the unset 0/0/0/0 (a converted model never edited in the browser) would re-centre on the grown content every time the kernel adds a variable. `WidgetApp` keeps the live Editor's last committed viewport (`onViewportChange`, once per settled gesture/fit, never per frame) and `remountFrom` hands it to the new mount as `initialViewport` when `viewportToCarry` (widget-core) says the kernel change did not itself move the viewport: the incoming project's stored viewBox/zoom equal the OUTGOING seed's (the text the live Editor was mounted from) or the incoming one is unset; a kernel that moved the stored viewport wins (nothing carried), and a live viewport of a drilled-into module is never carried onto the root the remount opens. The Editor applies it before its first render, round-trips it view-only, and skips its mount-time offscreen re-center for it, so nothing moves and nothing is saved by the carry. Inherent to using the stored viewBox as the signal: with two views of one model (see the known limitation below), an edit saved from one view persists THAT view's viewport, the other view sees a kernel push whose stored viewport moved, and it adopts it -- both views converge onto the editing view's framing. `src/pysimlin/e2e/notebook-editor.spec.ts` asserts it in a real JupyterLab (shift-pan, Python `edit()`, the stock is where the pan left it to the pixel; the laid-out-on-display model does not shift). +- **The user's viewport survives a kernel-originated remount.** A pan or zoom is never saved by itself (`ProjectController.setViewport` persists it to the engine through a viewport item that does not save; only the next edit's snapshot carries the viewBox/zoom), so a remount on the kernel's bytes -- a Python `edit()`, a disk reload, a reject re-seed -- would reset the framing to the stored one, and a project whose stored viewBox is still the unset 0/0/0/0 (a converted model never edited in the browser) would re-centre on the grown content every time the kernel adds a variable. `WidgetApp` keeps the live Editor's last committed viewport (`onViewportChange`, once per settled gesture/fit, never per frame) and `remountFrom` hands it to the new mount as `initialViewport` when `viewportToCarry` (widget-core) says the kernel change did not itself move the viewport: the incoming project's stored viewBox/zoom equal the OUTGOING seed's (the text the live Editor was mounted from) or the incoming one is unset; a kernel that moved the stored viewport wins (nothing carried), and a live viewport of a drilled-into module is never carried onto the root the remount opens. The Editor applies it before its first render, round-trips it view-only, and skips its mount-time offscreen re-center for it, so nothing moves and nothing is saved by the carry. Inherent to using the stored viewBox as the signal: with two views of one model (see the known limitation below), an edit saved from one view persists THAT view's viewport, the other view sees a kernel push whose stored viewport moved, and it adopts it -- both views converge onto the editing view's framing. `src/pysimlin/e2e/notebook-editor.spec.ts` asserts it in a real JupyterLab (shift-pan, Python `edit()`, the stock is where the pan left it to the pixel; the laid-out-on-display model does not shift). - Notices auto-hide after 5 s; a repeat restarts the timer. The toast sits bottom-centre at 80px, above the Editor's own snackbar viewport (`Snackbar.module.css`, bottom 8px) and above the tool dial FAB (top edge at 72px, so a wrapped two-line notice in a narrow cell still clears it), away from the search bar -- the top edge is Editor chrome ("Find in Model", the details drawer) and a toast there covers it. `theme: 'auto'` follows JupyterLab's `body[data-jp-theme-light]` (a `MutationObserver`) else `prefers-color-scheme` (a `matchMedia` listener), live, both unsubscribed on unmount. Correctness boundary: the file is only ever written from a snapshot whose `base` equalled the kernel's revision at handling time, so a snapshot edited from stale state can never overwrite a change the widget has not seen; what a reject costs is the local edits since the last accept, which the remount discards (the notice tells the user). @@ -69,19 +69,23 @@ anywidget runs `render` once per VIEW; two views of the same model (`display(w)` ## Files - `src/index.tsx` -- AFM entry, `export default { initialize, render }`. Imports `@simlin/diagram/theme.css` and KaTeX's `katex.min.css` explicitly and deep-imports `@simlin/diagram/Editor` -- the package root would also pull in `reset.css`, a global page reset that must never land in someone else's notebook page. Mounts React 19 into a div appended to `el`; placeholder while the engine loads; failure text in the cell. -- `src/WidgetApp.tsx` -- The React shell: wrapper `
` (the Editor's chrome is absolutely positioned against the wrapper; the wrapper paints the editor's page-background token itself -- the Editor root and canvas are transparent, so a forced `theme="dark"` in a light JupyterLab would otherwise show dark chrome over a white canvas; the token lives on this very element via the scoped theme.css and flips with `data-theme`, and the loading/error placeholder in `index.tsx` resolves the same theme so a dark notebook never gets a light box while the engine loads; `data-lm-suppress-shortcuts` keeps notebook shortcuts out of the widget -- but see the keyboard paragraph below: Lumino needs it on the FOCUSED element, so the wrapper's `onFocusCapture` stamps it onto every element that receives focus inside the widget), the notice toast, and `` keyed by revision + generation, rendered once the wrapper element exists and given it as `portalContainer` -- so the drawer, dialogs, menus and the autocomplete listbox render INSIDE the widget box (where the scoped tokens, `data-theme` and the shortcut-suppression attribute live; portaled to `document.body` they sit outside the root class and every `var(--*)` is undefined) and position against it, not the viewport (JupyterLab's windowed notebook translates its viewport with a `transform`, which would displace fixed boxes) -- and `showHomeLink={false}` (a notebook page has no `/` to exit to; the link would pushState the notebook away). `widget.module.css` gives the wrapper `overflow: hidden` (the surfaces are clipped to the box by design) and `isolation: isolate` (their z-index band stays local to the widget). Owns the one in-flight snapshot slot and the seed pair; subscribes to the trait change events, `msg:custom` (save replies, notices), and the host theme signals; `onSave` / `onSelectionChanged` wiring. +- `src/WidgetApp.tsx` -- The React shell: wrapper `
` (the Editor's chrome is absolutely positioned against the wrapper; the wrapper paints the editor's page-background token itself -- the Editor root and canvas are transparent, so a forced `theme="dark"` in a light JupyterLab would otherwise show dark chrome over a white canvas; the token lives on this very element via the scoped theme.css and flips with `data-theme`, and the loading/error placeholder in `index.tsx` resolves the same theme so a dark notebook never gets a light box while the engine loads; `data-lm-suppress-shortcuts` keeps notebook shortcuts out of the widget -- but see the keyboard paragraph below: Lumino needs it on the FOCUSED element, so the wrapper's `onFocusCapture` stamps it onto every element that receives focus inside the widget), the notice toast, and `` keyed by revision + generation, rendered once the wrapper element exists and given it as `portalContainer` -- so the drawer, dialogs, menus and the autocomplete listbox render INSIDE the widget box (where the scoped tokens, `data-theme` and the shortcut-suppression attribute live; portaled to `document.body` they sit outside the root class and every `var(--*)` is undefined) and position against it, not the viewport (JupyterLab's windowed notebook translates its viewport with a `transform`, which would displace fixed boxes) -- and `showHomeLink={false}` (a notebook page has no `/` to exit to; the link would pushState the notebook away), and `onReload` (the Reload of the Editor's engine-unavailable notice): the default would reload the whole JupyterLab page, so the widget remounts the Editor on its seed -- the last state the kernel acknowledged -- by bumping the generation itself (`remountFrom` is idempotent on the unchanged pair), carrying the live viewport, freeing an in-flight snapshot slot and publishing an empty selection, as a kernel remount does. `widget.module.css` gives the wrapper `overflow: hidden` (the surfaces are clipped to the box by design) and `isolation: isolate` (their z-index band stays local to the widget). Owns the one in-flight snapshot slot and the seed pair; subscribes to the trait change events, `msg:custom` (save replies, notices), and the host theme signals; `onSave` / `onSelectionChanged` wiring. - `src/widget-root-class.ts` -- `WIDGET_ROOT_CLASS`, shared by the shell and the build (the CSS scoping selector). - `src/engine-bootstrap.ts` -- Page-global compiled-module cache (keyed by the built-against wasm sha256, `WASM_IDENTITY` / `wasmCacheKey`) + per-instance `ready()`; the inline-wasm global (`takeInlineWasm`, read at module evaluation) and the wasm request/reply over the comm with timeout. - `src/widget-core.ts` -- Functional core: the viewport carry (`storedViewport`: the root view's viewBox/zoom in a project text with the reader's defaults -- omitted viewBox is 0/0/0/0, omitted zoom is 1; `viewportToCarry(live, outgoingJson, incomingJson)`: the decision above), trait coercion (`readTraits`, which also runs `withEditorView`: a seed or push whose root model `main` has no view gets an empty stock-flow view, because the Editor renders `views[0]` and is a blank canvas without it -- the kernel lays viewless models out before seeding, this is the defence in depth; text that already has a view comes back byte-identical so the `classifyPush` pair comparisons hold), `resolveTheme`, `wrapperStyle`, `parseWasmReply`, `parseNoticeMessage`, the snapshot protocol (`requestId`, `snapshotMessage`, `parseSaveReply`, `inFlightFor`, `replyIsFor`, `classifyPush`, `versionAfterReply`), the snapshot size guard (`MAX_SNAPSHOT_BYTES`, `snapshotWireSize`, `checkSnapshotSize`, `oversizeMessage`, `oversizeNotice`, `formatSize` -- the last two mirror pysimlin's `snapshot_wire_size`/`format_size` byte for byte). No DOM, no React, no model. - `build/scope-css.ts` -- The PostCSS plugin that confines global stylesheets to the widget root (see Hosting model). - `src/anywidget-model.ts` -- The slice of anywidget's `AnyModel` this package uses (verified against anywidget 0.11.0), so no `@anywidget/types` dependency. - `rsbuild.config.ts` -- The single-file ESM build (see its header comment for every choice). `SIMLIN_WIDGET_BACKEND=worker` builds the measurement-only worker variant. -- `e2e/` -- Playwright feasibility journey (`pnpm test:e2e`): serves `dist/widget.js`, the harness page, and the wasm from disk via `page.route()`, imports the bundle through a blob URL per mount exactly as anywidget does, plays the kernel's side of the wasm handshake with a fake `AnyModel` (`e2e/harness/fake-anywidget-model.js`), adds a variable through the real Editor UI, asserts the snapshot lands on the model, that the compiled module is cached under the key carrying the served wasm's sha256, that a second module instance reuses it (no second request), that a kernel push remounts, that nothing outside the harness is fetched, and -- loading the module with the `inline` mode's base64 global prepended exactly as `inline_esm` does -- that the engine boots with no wasm request and the global is consumed. It also carries two layout-engine claims jsdom cannot make (`src/diagram/CLAUDE.md`, Hosting Requirements and Portals): in a 400px cell the details card scrolls inside the box, stays edge-aligned with the search bar, and its Delete button is reachable; and the hamburger drawer renders inside the widget box with an opaque, token-styled sheet (computed background and font), contained to the box (absolute, sheet from the box's left edge, backdrop covering the box, no Exit link), leaving the 48px backdrop strip in a 320px-wide cell; and the theming claim only a style engine can make: with `theme=dark` the label fill, the halo's flood colour and the wrapper background compute to their dark token values (light keeps the exact black/white), and a dark and a light widget on one page each reference their own canvas's halo filter (a per-root id would collide across the two React roots). Requires a prior `pnpm build` here and in `src/engine`. On a host without Playwright's system libraries, point `PLAYWRIGHT_BROWSERS_PATH` at a matching browser tree (nixpkgs `playwright-driver.browsers` matches the pinned Playwright revision). +- `e2e/support.ts` -- The static harness the journeys share (`serveHarness`, `mountWidget`, `widgetState`, the `HarnessWindow` shape of `e2e/harness/`). +- `e2e/diagram-editing.spec.ts` -- The diagram-editing journey (`docs/design-plans/2026-09-10-diagram-editing-core.md`): the real Editor and engine over a small hand-built project, driven with real pointer and key events, asserting on the rendered SVG (path `d`, valve circles, cloud transforms, stock rects) and on the snapshots the fake kernel receives -- a stock drag bends its cloud-ended flow orthogonally with a perpendicular face exit; a click on a stock-attached flow arrowhead or a link arrowhead changes and saves nothing; a flow end dragged into empty space previews a cloud at the pointer and commits exactly the last preview frame, and the saved stock drops the flow; reattaching lands on the new stock's face and the saved stock lists it; a valve dragged perpendicular forms a bracket whose middle follows the pointer and drags back to straight; Escape cancels a live drag; a drawn flow ends in a cloud at the pointer and takes its typed name; undo and redo restore and reapply the geometry; no routed pipe crosses the body of a stock it was attached to. A separate test drags with Escape pressed mid-drag: the drag is cancelled. Screenshots and `journey.json` go to `e2e/.output/diagram-editing/` or `$SIMLIN_JOURNEY_OUTPUT`. +- `e2e/diagram-editing-perf.spec.ts` -- Frame timing on C-LEARN (converted from its `.mdl` in Node; ~4000 view elements), framed with a viewBox equal to the measured canvas so nothing refits: a stock with the most attached flow ends, then a ~50-element rubber-band selection, each dragged for about two seconds of unpaced pointer moves. Reports rAF frame deltas and long animation frames while the pointer moves and, from each release, the in-page time until the widget sends the saved snapshot and the longest frame meanwhile, plus the browser and WebGL renderer (`perf.json`). Asserts the edits land, not a frame budget. Tracing is off for the file (`test.use` at the top level; Playwright snapshots the page per action and cannot switch it per describe). +- `e2e/canvas-probe.ts` -- The in-page SVG probe both diagram journeys read the canvas through (flow paths, valves, arrowheads, clouds, stock rects, links, model-to-client mapping, hit tests, the settle point), plus `shot` and the output directory. +- `e2e/widget.spec.ts` -- Playwright feasibility journey (`pnpm test:e2e`): serves `dist/widget.js`, the harness page, and the wasm from disk via `page.route()`, imports the bundle through a blob URL per mount exactly as anywidget does, plays the kernel's side of the wasm handshake with a fake `AnyModel` (`e2e/harness/fake-anywidget-model.js`), adds a variable through the real Editor UI, asserts the snapshot lands on the model, that the compiled module is cached under the key carrying the served wasm's sha256, that a second module instance reuses it (no second request), that a kernel push remounts, that nothing outside the harness is fetched, and -- loading the module with the `inline` mode's base64 global prepended exactly as `inline_esm` does -- that the engine boots with no wasm request and the global is consumed. It also carries two layout-engine claims jsdom cannot make (`src/diagram/CLAUDE.md`, Hosting Requirements and Portals): in a 400px cell the details card scrolls inside the box, stays edge-aligned with the search bar, and its Delete button is reachable; and the hamburger drawer renders inside the widget box with an opaque, token-styled sheet (computed background and font), contained to the box (absolute, sheet from the box's left edge, backdrop covering the box, no Exit link), leaving the 48px backdrop strip in a 320px-wide cell; and the theming claim only a style engine can make: with `theme=dark` the label fill, the halo's flood colour and the wrapper background compute to their dark token values (light keeps the exact black/white), and a dark and a light widget on one page each reference their own canvas's halo filter (a per-root id would collide across the two React roots). Requires a prior `pnpm build` here and in `src/engine`. On a host without Playwright's system libraries, point `PLAYWRIGHT_BROWSERS_PATH` at a matching browser tree (nixpkgs `playwright-driver.browsers` matches the pinned Playwright revision). - `src/test-utils/` -- `fake-model.ts` (a TRANSPORT-FAITHFUL AnyModel: one in-flight trait sync with merge-on-buffer semantics, ordered custom messages, a `busyKernel` toggle that queues everything until `releaseKernel()` -- or one message at a time with `releaseOne()` -- and the kernel half of the protocol -- accept/`saved`, reject/`rejected`, both echoing the request `id`, `oversize`/warn notice, `kernelChange`); `editor-mock.tsx` (reproduces `ProjectController.save()`'s serialisation: one in flight, one queued flush of the latest state re-reading the acknowledged version; its "pan"/"pan-child" buttons report a committed viewport through `onViewportChange` for the root / a module, and it records `initialViewport`); `engine-mock.ts`; wired by `rstest.config.mts` aliases so the shell tests never touch the wasm. ## Tests -`pnpm test` (rstest, jsdom): `widget-core.test.ts` covers every coercion/decision arm of the core (every `classifyPush` arm, reply parsing, every `viewportToCarry` arm, `storedViewport`'s defaults and its no-root-view cases); `engine-bootstrap.test.ts` the handshake, timeout (and its clearing), page-global cache (identity-keyed: another build's module is not reused), retry semantics, and the inline global (consumed and cleared; compiled with no comm request; unusable payload falls back to the request); `index.test.tsx` the AFM lifecycle and the protocol against the transport-faithful model (the wrapper is the Editor's `portalContainer` and `showHomeLink` is false; one snapshot per edit with the acknowledged base, its own request id, and no trait write; a burst of 3 edits during a busy kernel -> exactly one follow-up snapshot carrying the latest state, accepted; a disk change racing an in-flight snapshot -> remount from authoritative, the old save resolves undefined at once and the slot is freed so the new Editor's FIRST edit is sent with the new base and accepted while the stale `rejected` (the freed snapshot's id) is ignored without a second remount, and a stale `saved` arriving after a later remount does not regress the seed; replies naming another request, or none, leave the in-flight save waiting for its own; two views of one model each resolve only their own replies, in the distinct-bytes interleaving (B remounted by A's accept, edits again before its own late reject, which is ignored) and the byte-identical one (the loser classifies A's push `own-ack`, is remounted by its own reject rather than wedged, and chains its next edit from the new base); a reject at an unchanged revision (the kernel failed before applying) -> no remount, no retry loop; kernel pushes remount once and idempotently; unmount resolves an in-flight save; a second concurrent onSave is refused; an edit above `max_snapshot_bytes` -> `oversize` report + toast, resolved unsaved, no in-flight slot left behind, accepted once the kernel raises the cap; exactly at the cap is sent; notices; height/theme/read_only; live theme switching with observer disconnect asserted; selection debounce, and a remount publishing an empty selection (superseding a pending one; own-ack/idempotent pushes publish nothing); the wrapper's background style; a kernel push that left the stored viewport unchanged (or unset) remounts on the live viewport while one that moved it remounts on the kernel's, a module's live viewport is not carried, and a reject re-seed carries too; cleanup removes every listener); `build/scope-css.test.ts` the CSS scoping over the real stylesheets, its zero-specificity `:where()` prefixing, and fail-closed on unknown at-rules. `pnpm test:e2e` is the bundle-level journey above (its fake kernel accepts snapshots and replies `saved`); it is not part of `pnpm test`. +`pnpm test` (rstest, jsdom): `widget-core.test.ts` covers every coercion/decision arm of the core (every `classifyPush` arm, reply parsing, every `viewportToCarry` arm, `storedViewport`'s defaults and its no-root-view cases); `engine-bootstrap.test.ts` the handshake, timeout (and its clearing), page-global cache (identity-keyed: another build's module is not reused), retry semantics, and the inline global (consumed and cleared; compiled with no comm request; unusable payload falls back to the request); `index.test.tsx` the AFM lifecycle and the protocol against the transport-faithful model (the wrapper is the Editor's `portalContainer` and `showHomeLink` is false; one snapshot per edit with the acknowledged base, its own request id, and no trait write; a burst of 3 edits during a busy kernel -> exactly one follow-up snapshot carrying the latest state, accepted; a disk change racing an in-flight snapshot -> remount from authoritative, the old save resolves undefined at once and the slot is freed so the new Editor's FIRST edit is sent with the new base and accepted while the stale `rejected` (the freed snapshot's id) is ignored without a second remount, and a stale `saved` arriving after a later remount does not regress the seed; replies naming another request, or none, leave the in-flight save waiting for its own; two views of one model each resolve only their own replies, in the distinct-bytes interleaving (B remounted by A's accept, edits again before its own late reject, which is ignored) and the byte-identical one (the loser classifies A's push `own-ack`, is remounted by its own reject rather than wedged, and chains its next edit from the new base); a reject at an unchanged revision (the kernel failed before applying) -> no remount, no retry loop; kernel pushes remount once and idempotently; the Editor's Reload remounts on the acknowledged seed with nothing sent, repeatably; unmount resolves an in-flight save; a second concurrent onSave is refused; an edit above `max_snapshot_bytes` -> `oversize` report + toast, resolved unsaved, no in-flight slot left behind, accepted once the kernel raises the cap; exactly at the cap is sent; notices; height/theme/read_only; live theme switching with observer disconnect asserted; selection debounce, and a remount publishing an empty selection (superseding a pending one; own-ack/idempotent pushes publish nothing); the wrapper's background style; a kernel push that left the stored viewport unchanged (or unset) remounts on the live viewport while one that moved it remounts on the kernel's, a module's live viewport is not carried, and a reject re-seed carries too; cleanup removes every listener); `build/scope-css.test.ts` the CSS scoping over the real stylesheets, its zero-specificity `:where()` prefixing, and fail-closed on unknown at-rules. `pnpm test:e2e` is the bundle-level journey above (its fake kernel accepts snapshots and replies `saved`); it is not part of `pnpm test`. ## Size diff --git a/src/notebook-widget/e2e/canvas-probe.ts b/src/notebook-widget/e2e/canvas-probe.ts new file mode 100644 index 000000000..73ab04b84 --- /dev/null +++ b/src/notebook-widget/e2e/canvas-probe.ts @@ -0,0 +1,207 @@ +// Copyright 2026 The Simlin Authors. All rights reserved. +// Use of this source code is governed by the Apache License, +// Version 2.0, that can be found in the LICENSE file. + +/** + * The in-page DOM probe the diagram-editing journeys read the canvas through: + * flow paths, valves, arrowheads, clouds, stock rects and links of the Editor + * mounted in `#cell1`, model-to-client coordinate mapping, hit testing, and a + * settle point. Everything reads rendered SVG, never Editor internals. + */ + +import * as path from 'node:path'; + +import type { Page } from '@playwright/test'; + +import type { HarnessWindow } from './support'; + +const here = import.meta.dirname; + +// Screenshots, step measurements and perf numbers land here (gitignored by +// default); point SIMLIN_JOURNEY_OUTPUT elsewhere to collect them for a PR. +export const outDir = process.env.SIMLIN_JOURNEY_OUTPUT ?? path.join(here, '.output', 'diagram-editing'); + +export interface XY { + x: number; + y: number; +} + +export interface Rect { + x: number; + y: number; + width: number; + height: number; +} + +export interface FlowProbe { + d: string; + /** The rendered path's points (the sink end is retracted for the arrowhead). */ + points: XY[]; + valve: XY; + /** The arrowhead tip: the stored sink endpoint when the sink is a stock. */ + tip: XY; +} + +export interface CloudProbe { + transform: string; + center: XY; +} + +/** + * Installed with `page.addInitScript(installProbe, CloudWidth)` before the + * harness loads. Self-contained (serialized into the page), so it may not close + * over anything. + */ +export function installProbe(cloudWidth: number): void { + const canvas = (): SVGSVGElement => { + const svg = document.querySelector('#cell1 svg.simlin-canvas'); + if (!(svg instanceof SVGSVGElement)) { + throw new Error('no canvas in #cell1'); + } + return svg; + }; + // The transformed group every element is drawn in: its CTM maps model + // coordinates to client pixels (zoom and pan included). + const layer = (): SVGGraphicsElement => { + const g = canvas().querySelector(':scope > g'); + if (!(g instanceof SVGGraphicsElement)) { + throw new Error('no canvas layer'); + } + return g; + }; + const labelOf = (g: Element): string => + Array.from(g.querySelectorAll('text')) + .map((t) => t.textContent ?? '') + .join(''); + const num = (el: Element, attr: string): number => Number(el.getAttribute(attr)); + const parsePath = (d: string): Array<{ x: number; y: number }> => + Array.from(d.matchAll(/[ML](-?[\d.e+-]+),(-?[\d.e+-]+)/g)).map((m) => ({ x: Number(m[1]), y: Number(m[2]) })); + const flowProbe = (g: Element) => { + const inner = g.querySelector('path.simlin-inner'); + const valve = g.querySelector(':scope > g > circle'); + const arrow = g.querySelector('path.simlin-arrowhead-flow'); + if (!inner || !valve || !arrow) { + throw new Error('flow group without pipe, valve or arrowhead'); + } + const d = inner.getAttribute('d') ?? ''; + return { + d, + points: parsePath(d), + valve: { x: num(valve, 'cx'), y: num(valve, 'cy') }, + tip: parsePath(arrow.getAttribute('d') ?? '')[0], + }; + }; + const probe = { + toClient(p: { x: number; y: number }) { + const m = layer().getScreenCTM(); + if (m === null) { + throw new Error('canvas layer has no CTM'); + } + return { x: m.a * p.x + m.c * p.y + m.e, y: m.b * p.x + m.d * p.y + m.f }; + }, + flows() { + const out: Record> = {}; + for (const g of Array.from(canvas().querySelectorAll('g.simlin-flow'))) { + out[labelOf(g)] = flowProbe(g); + } + return out; + }, + flow(name: string) { + const g = Array.from(canvas().querySelectorAll('g.simlin-flow')).find((el) => labelOf(el) === name); + return g === undefined ? null : flowProbe(g); + }, + stocks() { + const out: Record = {}; + for (const g of Array.from(canvas().querySelectorAll('g.simlin-stock'))) { + const rect = g.querySelector('rect')!; + out[labelOf(g)] = { + x: num(rect, 'x'), + y: num(rect, 'y'), + width: num(rect, 'width'), + height: num(rect, 'height'), + }; + } + return out; + }, + clouds() { + return Array.from(canvas().querySelectorAll('path.simlin-cloud')).map((el) => { + const transform = el.getAttribute('transform') ?? ''; + const m = /matrix\(([^,]+), 0, 0, ([^,]+), ([^,]+), ([^)]+)\)/.exec(transform); + if (m === null) { + throw new Error(`unexpected cloud transform ${transform}`); + } + const radius = (Number(m[1]) * cloudWidth) / 2; + return { transform, center: { x: Number(m[3]) + radius, y: Number(m[4]) + radius } }; + }); + }, + links() { + return Array.from(canvas().querySelectorAll('path.simlin-connector')).map((el) => el.getAttribute('d') ?? ''); + }, + linkArrowheadCenter() { + const heads = canvas().querySelectorAll('path.simlin-arrowhead-link'); + if (heads.length !== 1) { + throw new Error(`expected one link arrowhead, found ${heads.length}`); + } + const b = heads[0].getBoundingClientRect(); + return { x: b.left + b.width / 2, y: b.top + b.height / 2 }; + }, + /** What a press at a client point lands on: the hit element's class and its element group's class. */ + hit(x: number, y: number) { + const el = document.elementFromPoint(x, y); + return { + tag: el?.tagName ?? '', + cls: el?.getAttribute('class') ?? '', + group: el?.closest('g[class*="simlin-"]')?.getAttribute('class') ?? '', + }; + }, + settle(): Promise { + return new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(() => setTimeout(() => resolve(), 0))), + ); + }, + }; + (window as unknown as { __journey: typeof probe }).__journey = probe; +} + +export type Probe = { + toClient(p: XY): XY; + flows(): Record; + flow(name: string): FlowProbe | null; + stocks(): Record; + clouds(): CloudProbe[]; + links(): string[]; + linkArrowheadCenter(): XY; + hit(x: number, y: number): { tag: string; cls: string; group: string }; + settle(): Promise; +}; + +export type ProbeWindow = { __journey: Probe } & HarnessWindow; + +export const probeFlow = (page: Page, name: string): Promise => + page.evaluate((n) => (window as unknown as ProbeWindow).__journey.flow(n), name); +export const probeStocks = (page: Page): Promise> => + page.evaluate(() => (window as unknown as ProbeWindow).__journey.stocks()); +export const probeClouds = (page: Page): Promise => + page.evaluate(() => (window as unknown as ProbeWindow).__journey.clouds()); +export const toClient = (page: Page, p: XY): Promise => + page.evaluate((pt) => (window as unknown as ProbeWindow).__journey.toClient(pt), p); +export const hitAt = (page: Page, p: XY): Promise<{ tag: string; cls: string; group: string }> => + page.evaluate((pt) => (window as unknown as ProbeWindow).__journey.hit(pt.x, pt.y), p); + +/** + * Two frames and a macrotask, `rounds` times. The controller is timer-free (its + * executor runs on promise continuations), so an edit a release enqueued has + * rendered, landed and handed its snapshot to the kernel by then; negative + * checks ("no snapshot") rest on this. + */ +export async function settle(page: Page, rounds = 3): Promise { + for (let i = 0; i < rounds; i++) { + await page.evaluate(() => (window as unknown as ProbeWindow).__journey.settle()); + } +} + +export async function shot(page: Page, name: string): Promise { + const file = path.join(outDir, `${name}.png`); + await page.locator('#cell1').screenshot({ path: file }); + return file; +} diff --git a/src/notebook-widget/e2e/diagram-editing-perf.spec.ts b/src/notebook-widget/e2e/diagram-editing-perf.spec.ts new file mode 100644 index 000000000..3654b95bd --- /dev/null +++ b/src/notebook-widget/e2e/diagram-editing-perf.spec.ts @@ -0,0 +1,480 @@ +// Copyright 2026 The Simlin Authors. All rights reserved. +// Use of this source code is governed by the Apache License, +// Version 2.0, that can be found in the LICENSE file. + +/** + * Frame timing of canvas drags on the largest model in the repo: C-LEARN v77 + * (~4000 view elements), converted from its `.mdl` by the engine in Node and + * mounted through the static harness (support.ts), real Editor over real wasm. + * It drags the stock the most flow ends attach to, then a ~50-element + * rubber-band selection, each for about two seconds of pointer moves sent as + * fast as the page accepts them, and writes `perf.json` (canvas-probe.ts + * `outDir`): requestAnimationFrame deltas and long animation frames while the + * pointer moves, and from each release the time until the widget sends the + * saved project and the longest frame meanwhile. + * + * What it asserts: the edits land (the stock where the pointer left it, a + * snapshot per release). What it reports but does NOT assert: any frame + * budget. The numbers come from headless Chromium on the machine running the + * test with synthetic CDP input, so they compare runs on one machine; they do + * not predict user hardware, touch input, or the app host (which runs the + * engine in a worker, where the notebook widget runs it on the main thread). + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +import { test, expect, type Page } from '@playwright/test'; +import { CloudWidth, StockHeight, StockWidth } from '@simlin/diagram/drawing/default'; +import { Project } from '@simlin/engine'; + +import { + hitAt, + installProbe, + outDir, + settle, + shot, + toClient, + type ProbeWindow, + type Rect, + type XY, +} from './canvas-probe'; +import { mountWidget, repoRoot, serveHarness, widgetState } from './support'; + +// Playwright's trace recording snapshots the page on every action; over a +// ~4000-element view that slows the very input pipeline this file measures. +test.use({ trace: 'off' }); + +interface ClearnElement { + type: string; + uid: number; + x?: number; + y?: number; + points?: Array; +} + +interface ClearnView { + elements: ClearnElement[]; + viewBox?: Rect; + zoom?: number; +} + +interface ClearnProject { + models: Array<{ views: ClearnView[] }>; +} + +// In-page recorder state. Times are performance.now() milliseconds. +interface FrameRecorder { + /** requestAnimationFrame timestamps. */ + frames: number[]; + running: boolean; + longFrames: Array<{ start: number; duration: number }>; + observers: PerformanceObserver[]; + /** The release's event timestamp. */ + pointerUpAt: number | undefined; + /** When the widget handed each snapshot to the kernel (the model's `send`). */ + snapshotAt: number[]; + restore: () => void; +} + +const deltasOf = (times: readonly number[]): number[] => times.slice(1).map((t, i) => t - times[i]); + +function frameStats(xs: readonly number[]): { n: number; mean: number; p95: number; max: number } { + if (xs.length === 0) { + return { n: 0, mean: 0, p95: 0, max: 0 }; + } + const sorted = [...xs].sort((a, b) => a - b); + const r = (v: number): number => Math.round(v * 10) / 10; + return { + n: xs.length, + mean: r(xs.reduce((a, b) => a + b, 0) / xs.length), + p95: r(sorted[Math.ceil(0.95 * sorted.length) - 1]), + max: r(sorted[sorted.length - 1]), + }; +} + +/** + * Press at `from`, move along a 1.75-turn arc (radius 120 px) for `durationMs` + * with each move sent as soon as the previous one was accepted (Chromium acks a + * dispatched mouse event once the page has handled it), finish on the arc's + * end point, and release. Frames are split at the release: `drag` covers the + * moves; `release` runs from the pointerup event to the first frame after the + * widget hands the saved project to the kernel, with a long animation frame + * that spans the release counted there. Both are stamped in the page, so the + * test's own polling adds nothing. Chromium's Event Timing does not report + * pointermove, so frame times are the per-move measure. + */ +async function measureDrag( + page: Page, + from: XY, + durationMs: number, + modelIndex: number, +): Promise<{ + moves: number; + dragMs: number; + drag: { frames: ReturnType; longAnimationFrames: ReturnType }; + release: { toSnapshotMs: number; longestFrameMs: number; longAnimationFrames: ReturnType }; + delta: XY; +}> { + const radius = 120; + const along = (fraction: number): XY => { + const theta = 1.75 * Math.PI * fraction; + return { x: from.x + radius * Math.sin(theta), y: from.y + (radius * (1 - Math.cos(theta))) / 2 }; + }; + await page.mouse.move(from.x, from.y); + await page.mouse.down(); + await page.evaluate((index) => { + const w = window as unknown as ProbeWindow & { __rec: FrameRecorder }; + const model = w.harness.models[index]; + const send = model.send; + const rec: FrameRecorder = { + frames: [], + running: true, + longFrames: [], + observers: [], + pointerUpAt: undefined, + snapshotAt: [], + restore: () => { + model.send = send; + }, + }; + const tick = (t: number): void => { + if (rec.running) { + rec.frames.push(t); + requestAnimationFrame(tick); + } + }; + requestAnimationFrame(tick); + try { + const o = new PerformanceObserver((list) => { + for (const e of list.getEntries()) { + rec.longFrames.push({ start: e.startTime, duration: e.duration }); + } + }); + o.observe({ type: 'long-animation-frame' }); + rec.observers.push(o); + } catch { + // Long Animation Frames are Chromium-only; the rAF timestamps still stand. + } + window.addEventListener( + 'pointerup', + (e) => { + rec.pointerUpAt = e.timeStamp; + }, + { capture: true, once: true }, + ); + model.send = (content, callbacks, buffers) => { + if ((content as { type?: unknown } | null)?.type === 'snapshot') { + rec.snapshotAt.push(performance.now()); + } + send.call(model, content, callbacks, buffers); + }; + w.__rec = rec; + }, modelIndex); + const start = Date.now(); + let moves = 0; + for (let elapsed = 0; elapsed < durationMs; elapsed = Date.now() - start) { + const p = along(elapsed / durationMs); + await page.mouse.move(p.x, p.y); + moves++; + } + const end = along(1); + await page.mouse.move(end.x, end.y); + moves++; + const dragMs = Date.now() - start; + await page.mouse.up(); + await expect + .poll(() => page.evaluate(() => (window as unknown as { __rec: FrameRecorder }).__rec.snapshotAt.length), { + timeout: 120_000, + }) + .toBeGreaterThan(0); + await settle(page, 1); + const recorded = await page.evaluate(() => { + const rec = (window as unknown as { __rec: FrameRecorder }).__rec; + rec.running = false; + rec.restore(); + for (const o of rec.observers) { + for (const e of o.takeRecords()) { + rec.longFrames.push({ start: e.startTime, duration: e.duration }); + } + o.disconnect(); + } + return { frames: rec.frames, longFrames: rec.longFrames, pointerUpAt: rec.pointerUpAt, snapshotAt: rec.snapshotAt }; + }); + expect(recorded.pointerUpAt, 'the release was observed').toBeDefined(); + const up = recorded.pointerUpAt!; + const saved = recorded.snapshotAt[0]; + const dragFrames = recorded.frames.filter((t) => t <= up); + // The last frame before the release through the first frame after the + // snapshot, so a frame the commit blocks is counted whole. + const firstAfterSave = recorded.frames.findIndex((t) => t > saved); + const releaseFrames = recorded.frames.slice( + Math.max(0, dragFrames.length - 1), + firstAfterSave < 0 ? recorded.frames.length : firstAfterSave + 1, + ); + const endsBeforeRelease = (f: { start: number; duration: number }): boolean => f.start + f.duration <= up; + return { + moves, + dragMs, + drag: { + frames: frameStats(deltasOf(dragFrames)), + longAnimationFrames: frameStats(recorded.longFrames.filter(endsBeforeRelease).map((f) => f.duration)), + }, + release: { + toSnapshotMs: Math.round((saved - up) * 10) / 10, + longestFrameMs: frameStats(deltasOf(releaseFrames)).max, + longAnimationFrames: frameStats(recorded.longFrames.filter((f) => !endsBeforeRelease(f)).map((f) => f.duration)), + }, + delta: { x: end.x - from.x, y: end.y - from.y }, + }; +} + +/** + * A rubber band in the visible part of the view holding about `want` (within + * -20%/+30%) of the elements the planner selects (stocks, clouds, flows by + * valve, modules and aliases by center; auxes by center or by a corner inside + * the circle), clear of the editor chrome, starting on empty canvas. Bands + * keep the safe area's aspect ratio, since a view is wider than it is tall, + * and grow from an empty start point in any of the four directions: a dense + * view (links and labels everywhere) leaves few empty points, and the band + * that holds `want` elements can span most of the visible view. + * Zoom is 1, so model and client pixels agree up to the canvas offset. + */ +async function findRubberBand( + page: Page, + view: ClearnView, + size: { width: number; height: number }, + canvasBox: { x: number; y: number }, + want: number, +): Promise<{ from: XY; to: XY; members: number; pressable: XY[] }> { + const origin = await toClient(page, { x: 0, y: 0 }); + const toModel = (c: XY): XY => ({ x: c.x - origin.x, y: c.y - origin.y }); + // Clear of the search bar and tool FAB along the top and bottom edges. + const top = toModel({ x: canvasBox.x + 24, y: canvasBox.y + 96 }); + const bottom = toModel({ x: canvasBox.x + size.width - 24, y: canvasBox.y + size.height - 96 }); + // Only elements in (or an aux radius around) the safe area can be members. + const selectable = view.elements.filter( + (e) => + ['stock', 'cloud', 'flow', 'module', 'alias', 'aux'].includes(e.type) && + e.x! >= top.x - 9 && + e.x! <= bottom.x + 9 && + e.y! >= top.y - 9 && + e.y! <= bottom.y + 9, + ) as Array; + const membersOf = (l: number, t: number, r: number, b: number): Array => + selectable.filter((e) => { + if (e.x >= l && e.x <= r && e.y >= t && e.y <= b) { + return true; + } + return ( + e.type === 'aux' && + [ + { x: l, y: t }, + { x: r, y: t }, + { x: l, y: b }, + { x: r, y: b }, + ].some((c) => Math.hypot(c.x - e.x, c.y - e.y) <= 9) + ); + }); + const aspect = (bottom.x - top.x) / (bottom.y - top.y); + const starts: XY[] = []; + for (let y = top.y; y <= bottom.y; y += 24) { + for (let x = top.x; x <= bottom.x; x += 24) { + starts.push({ x, y }); + } + } + // A press reaches the canvas (and starts a rubber band) on the svg itself or + // on a group's box, which has no press handler of its own. + const empty = await page.evaluate( + ({ points, offset }) => + points.map((p) => { + const el = document.elementFromPoint(p.x + offset.x, p.y + offset.y); + return el !== null && (el.tagName === 'svg' || el.closest('g.simlin-group') !== null); + }), + { points: starts, offset: origin }, + ); + let best = 0; + for (const [i, start] of starts.entries()) { + if (!empty[i]) { + continue; + } + for (const [sx, sy] of [ + [1, 1], + [-1, 1], + [1, -1], + [-1, -1], + ]) { + for (let span = 60; ; span += 12) { + const end = { x: start.x + sx * span, y: start.y + (sy * span) / aspect }; + if (end.x < top.x || end.x > bottom.x || end.y < top.y || end.y > bottom.y) { + break; + } + const members = membersOf( + Math.min(start.x, end.x), + Math.min(start.y, end.y), + Math.max(start.x, end.x), + Math.max(start.y, end.y), + ); + best = Math.max(best, members.length); + if (members.length < want * 0.8) { + continue; + } + if (members.length > want * 1.3) { + break; + } + return { + from: { x: start.x + origin.x, y: start.y + origin.y }, + to: { x: end.x + origin.x, y: end.y + origin.y }, + members: members.length, + pressable: members.filter((e) => e.type !== 'flow' && e.type !== 'cloud'), + }; + } + } + } + throw new Error( + `no rubber band of ~${want} elements: ${empty.filter(Boolean).length} empty start points, best band held ${best}`, + ); +} + +test('performance: a stock drag and a ~50-element selection drag on C-LEARN', async ({ page }) => { + test.setTimeout(300_000); + const errors: string[] = []; + fs.mkdirSync(outDir, { recursive: true }); + page.on('pageerror', (err) => errors.push(`pageerror: ${err.message}`)); + await page.addInitScript(installProbe, CloudWidth); + await serveHarness(page); + + // The canvas a PERF_HEIGHT cell gives, measured on a small mount in the + // other cell (same width): a view seeded with a viewBox of exactly this size + // opens at that viewBox with no fit on mount, and one inside the diagram's + // bounds is not re-centered, so the drag target can be framed without a wheel + // pan whose deferred viewport commit would land in the middle of a + // measurement. + const PERF_HEIGHT = 700; + const small = fs.readFileSync(path.join(repoRoot, 'test', 'logistic-growth.sd.json'), 'utf8'); + await mountWidget(page, 'cell2', widgetState(small, { height: PERF_HEIGHT })); + await expect(page.locator('#cell2 svg.simlin-canvas')).toBeVisible({ timeout: 60_000 }); + const size = await page.evaluate(() => { + const el = document.querySelector('#cell2 div.simlin-canvas') as HTMLElement; + return { width: el.clientWidth, height: el.clientHeight }; + }); + await page.evaluate(() => (window as unknown as ProbeWindow).harness.models[0].cleanup?.()); + + const mdl = fs.readFileSync(path.join(repoRoot, 'test', 'xmutil_test_models', 'C-LEARN v77 for Vensim.mdl')); + const project = await Project.openVensim(mdl); + const clearn = JSON.parse(await project.serializeJson()) as ClearnProject; + await project.dispose(); + const view = clearn.models[0].views[0]; + const byUid = new Map(view.elements.map((e) => [e.uid, e])); + // The stock the most flow ends attach to: a drag routes every one of them. + const ends = new Map(); + for (const el of view.elements) { + if (el.type !== 'flow' || el.points === undefined) { + continue; + } + for (const p of [el.points[0], el.points[el.points.length - 1]]) { + const t = p.attachedToUid === undefined ? undefined : byUid.get(p.attachedToUid); + if (t?.type === 'stock') { + ends.set(t.uid, (ends.get(t.uid) ?? 0) + 1); + } + } + } + const [targetUid, targetEnds] = [...ends.entries()].sort((a, b) => b[1] - a[1])[0]; + const target = byUid.get(targetUid)!; + view.viewBox = { + x: size.width / 2 - target.x!, + y: size.height / 2 - target.y!, + width: size.width, + height: size.height, + }; + view.zoom = 1; + + const openStart = Date.now(); + await mountWidget(page, 'cell1', widgetState(JSON.stringify(clearn), { height: PERF_HEIGHT })); + await expect(page.locator('#cell1 svg.simlin-canvas')).toBeVisible({ timeout: 120_000 }); + const targetClient = await toClient(page, { x: target.x!, y: target.y! }); + const canvasBox = (await page.locator('#cell1 div.simlin-canvas').boundingBox())!; + expect(targetClient.x - canvasBox.x, 'the view opened at the seeded viewBox').toBeCloseTo(size.width / 2, 0); + expect(targetClient.y - canvasBox.y).toBeCloseTo(size.height / 2, 0); + // The first simulation runs on the main thread after open; wait for its + // results (the stock sparklines) so it does not land inside a measurement. + await expect + .poll(async () => page.locator('#cell1 g.simlin-stock > g[transform^="translate"]').count(), { timeout: 120_000 }) + .toBeGreaterThan(0); + await settle(page); + const openMs = Date.now() - openStart; + await shot(page, 'perf-clearn-open'); + + // ---- stock drag ----------------------------------------------------------- + expect((await hitAt(page, targetClient)).group).toContain('simlin-stock'); + const stockDrag = await measureDrag(page, targetClient, 2000, 1); + // Where the pointer left the stock, within a pixel: the arc's points are + // fractional client pixels, which the input pipeline need not preserve. + const expectedRect = { + x: target.x! - StockWidth / 2 + stockDrag.delta.x, + y: target.y! - StockHeight / 2 + stockDrag.delta.y, + }; + const nearestRect = await page.evaluate( + ({ x, y }) => + Math.min( + ...Array.from(document.querySelectorAll('#cell1 g.simlin-stock rect')).map((r) => + Math.hypot(Number(r.getAttribute('x')) - x, Number(r.getAttribute('y')) - y), + ), + ), + expectedRect, + ); + expect(nearestRect, 'the dragged stock landed where the pointer left it').toBeLessThan(1); + await shot(page, 'perf-clearn-stock-drag-after'); + + // ---- rubber band ~50 elements, then drag them ------------------------------ + const committed = JSON.parse( + await page.evaluate(() => { + const m = (window as unknown as ProbeWindow).harness.models[1]; + return m.snapshots[m.snapshots.length - 1].json; + }), + ) as ClearnProject; + const band = await findRubberBand(page, committed.models[0].views[0], size, canvasBox, 50); + await page.mouse.move(band.from.x, band.from.y); + await page.mouse.down(); + await page.mouse.move(band.to.x, band.to.y, { steps: 20 }); + await page.mouse.up(); + await settle(page); + const selectedCount = await page.locator('#cell1 svg.simlin-canvas .simlin-selected').count(); + await shot(page, 'perf-clearn-rubber-band'); + expect(selectedCount, 'the rubber band selected its members').toBeGreaterThan(band.members * 0.5); + let grab: XY | undefined; + for (const p of band.pressable) { + const c = await toClient(page, p); + if ((await hitAt(page, c)).group.includes('simlin-selected')) { + grab = c; + break; + } + } + expect(grab, 'a selected element to grab').toBeDefined(); + const selectionDrag = await measureDrag(page, grab!, 2000, 1); + await shot(page, 'perf-clearn-selection-drag-after'); + + // What rendered the numbers: headless or not, and which GL backend (a + // software renderer where no GPU is available). + const browser = await page.evaluate(() => { + const gl = document.createElement('canvas').getContext('webgl'); + const info = gl?.getExtension('WEBGL_debug_renderer_info'); + return { + userAgent: navigator.userAgent, + webglRenderer: gl && info ? String(gl.getParameter(info.UNMASKED_RENDERER_WEBGL)) : 'no webgl', + devicePixelRatio: window.devicePixelRatio, + }; + }); + const perf = { + model: 'C-LEARN v77', + viewElements: view.elements.length, + browser, + canvas: size, + openMs, + stockDrag: { stockEnds: targetEnds, ...stockDrag }, + selectionDrag: { rubberBandMembers: band.members, selectedDrawn: selectedCount, ...selectionDrag }, + }; + fs.writeFileSync(path.join(outDir, 'perf.json'), JSON.stringify(perf, null, 2)); + console.log(JSON.stringify(perf, null, 2)); + expect(errors).toEqual([]); +}); diff --git a/src/notebook-widget/e2e/diagram-editing.spec.ts b/src/notebook-widget/e2e/diagram-editing.spec.ts new file mode 100644 index 000000000..9e45d5ec6 --- /dev/null +++ b/src/notebook-widget/e2e/diagram-editing.spec.ts @@ -0,0 +1,763 @@ +// Copyright 2026 The Simlin Authors. All rights reserved. +// Use of this source code is governed by the Apache License, +// Version 2.0, that can be found in the LICENSE file. + +/** + * Diagram-editing journey in a real browser: the built widget bundle mounts + * the real Editor over the real engine wasm (the static harness, see + * support.ts), and Playwright drives the canvas with real pointer and key + * events through the gestures the diagram-editing core owns + * (docs/design-plans/2026-09-10-diagram-editing-core.md). Every step reads the + * rendered SVG -- flow path `d`, valve circles, cloud transforms, stock rects + * -- and what the fake kernel received (snapshots carrying the whole project). + * + * What this establishes: in Chromium, through pointer capture, React's event + * scheduling and the controller's executor, a stock drag bends its flows + * orthogonally with perpendicular face exits; a click on a flow arrowhead or a + * link arrowhead changes nothing and saves nothing; a flow end detached into + * empty space previews a cloud at the pointer and commits exactly the last + * preview frame; reattaching lands on the new stock's face and moves the flow + * between stock lists in the saved model; a valve dragged perpendicular forms + * a bracket that follows the pointer and collapses back to straight; Escape + * cancels a live drag; a drawn flow ends in a cloud at the pointer and takes + * its typed name; undo and redo restore and reapply geometry; and no routed + * pipe runs through the body of a stock it was attached to. Frame timing on a + * large model is diagram-editing-perf.spec.ts. + * + * What it does NOT establish: touch, pen or pinch input; Firefox or Safari; + * zoom levels other than 1; hosts other than this harness (the JupyterLab + * journey in src/pysimlin/e2e covers a real notebook); and exhaustive geometry + * (the planner and flow-geometry suites fuzz that). + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +import { test, expect, type Page } from '@playwright/test'; +import { CloudWidth, StockWidth } from '@simlin/diagram/drawing/default'; +import { CORNER_CLEARANCE } from '@simlin/diagram/flow-geometry/geometry'; + +import { + hitAt, + installProbe, + outDir, + probeClouds, + probeFlow, + probeStocks, + settle, + shot, + toClient, + type FlowProbe, + type ProbeWindow, + type Rect, + type XY, +} from './canvas-probe'; +import { mountWidget, serveHarness, widgetState } from './support'; + +interface Geometry { + flows: Record; + clouds: string[]; + stocks: Record; + links: string[]; +} + +async function geometry(page: Page): Promise { + return page.evaluate(() => { + const j = (window as unknown as ProbeWindow).__journey; + const flows: Record = {}; + for (const [name, f] of Object.entries(j.flows())) { + flows[name] = f.d; + } + return { + flows, + clouds: j + .clouds() + .map((c) => c.transform) + .sort(), + stocks: j.stocks(), + links: j.links(), + }; + }); +} + +async function snapshotState(page: Page): Promise<{ count: number; json: string | undefined; kernelJson: string }> { + return page.evaluate(() => { + const m = (window as unknown as ProbeWindow).harness.models[0]; + return { + count: m.snapshots.length, + json: m.snapshots.length > 0 ? m.snapshots[m.snapshots.length - 1].json : undefined, + kernelJson: m.kernel.projectJson, + }; + }); +} +interface JsonView { + elements: Array<{ + type: string; + uid: number; + name?: string; + x?: number; + y?: number; + points?: Array; + }>; +} + +interface JsonProject { + models: Array<{ + stocks: Array<{ name: string; inflows?: string[]; outflows?: string[] }>; + flows: Array<{ name: string }>; + views: JsonView[]; + }>; +} + +// The engine matches identifiers canonically; lists may carry either spelling. +const canon = (name: string): string => + name + .trim() + .toLowerCase() + .replace(/[\s_]+/g, '_'); + +function stockLists(project: JsonProject, stock: string): { inflows: string[]; outflows: string[] } { + const s = project.models[0].stocks.find((x) => canon(x.name) === canon(stock)); + if (s === undefined) { + throw new Error(`no stock ${stock} in snapshot`); + } + return { inflows: (s.inflows ?? []).map(canon), outflows: (s.outflows ?? []).map(canon) }; +} + +function viewElement(project: JsonProject, name: string): JsonView['elements'][number] { + const el = project.models[0].views[0].elements.find((e) => e.name !== undefined && canon(e.name) === canon(name)); + if (el === undefined) { + throw new Error(`no view element named ${name} in snapshot`); + } + return el; +} + +async function waitForSnapshot(page: Page, what: string, predicate: (p: JsonProject) => boolean): Promise { + let found: JsonProject | undefined; + await expect + .poll( + async () => { + const { json } = await snapshotState(page); + if (json === undefined) { + return false; + } + const p = JSON.parse(json) as JsonProject; + if (predicate(p)) { + found = p; + return true; + } + return false; + }, + { message: what, timeout: 30_000 }, + ) + .toBe(true); + return found!; +} + +const journeyLog: Array> = []; + +function note(step: string, data: Record): void { + journeyLog.push({ step, ...data }); + fs.writeFileSync(path.join(outDir, 'journey.json'), JSON.stringify(journeyLog, null, 2)); +} + +function unit(from: XY, to: XY): XY { + const len = Math.hypot(to.x - from.x, to.y - from.y); + return { x: (to.x - from.x) / len, y: (to.y - from.y) / len }; +} + +type Face = 'left' | 'right' | 'top' | 'bottom'; + +const OUTWARD: Record = { + left: { x: -1, y: 0 }, + right: { x: 1, y: 0 }, + top: { x: 0, y: -1 }, + bottom: { x: 0, y: 1 }, +}; + +// G4: a stock endpoint lies on a face, at least CORNER_CLEARANCE from its corners. +function faceOf(stock: Rect, p: XY): Face | undefined { + const eps = 1e-4; + const alongY = p.y >= stock.y + CORNER_CLEARANCE - eps && p.y <= stock.y + stock.height - CORNER_CLEARANCE + eps; + const alongX = p.x >= stock.x + CORNER_CLEARANCE - eps && p.x <= stock.x + stock.width - CORNER_CLEARANCE + eps; + if (Math.abs(p.x - stock.x) <= eps && alongY) { + return 'left'; + } + if (Math.abs(p.x - (stock.x + stock.width)) <= eps && alongY) { + return 'right'; + } + if (Math.abs(p.y - stock.y) <= eps && alongX) { + return 'top'; + } + if (Math.abs(p.y - (stock.y + stock.height)) <= eps && alongX) { + return 'bottom'; + } + return undefined; +} + +// G2 over the rendered path: every segment axis-aligned and non-degenerate. +function expectOrthogonal(flow: FlowProbe, what: string): void { + expect(flow.points.length, `${what}: at least two points`).toBeGreaterThanOrEqual(2); + for (let i = 0; i + 1 < flow.points.length; i++) { + const a = flow.points[i]; + const b = flow.points[i + 1]; + expect(Math.min(Math.abs(a.x - b.x), Math.abs(a.y - b.y)), `${what}: segment ${i} is axis-aligned`).toBeLessThan( + 1e-5, + ); + expect(Math.hypot(a.x - b.x, a.y - b.y), `${what}: segment ${i} has length`).toBeGreaterThan(1e-5); + } +} + +// G4 + G5 at the sink: the arrowhead tip (the stored endpoint for a stock sink) +// is on a face, and the final segment enters perpendicular to it. +function expectSinkOnFace(flow: FlowProbe, stock: Rect, what: string): Face { + const face = faceOf(stock, flow.tip); + expect( + face, + `${what}: sink endpoint ${JSON.stringify(flow.tip)} on a face of ${JSON.stringify(stock)}`, + ).toBeDefined(); + const n = flow.points.length; + const u = unit(flow.points[n - 2], flow.points[n - 1]); + expect(u.x, `${what}: final segment enters the ${face} face perpendicular`).toBeCloseTo(-OUTWARD[face!].x, 6); + expect(u.y, `${what}: final segment enters the ${face} face perpendicular`).toBeCloseTo(-OUTWARD[face!].y, 6); + return face!; +} + +// G4 + G5 at the source: the first point is on a face and the first segment leaves outward. +function expectSourceOnFace(flow: FlowProbe, stock: Rect, what: string): Face { + const face = faceOf(stock, flow.points[0]); + expect( + face, + `${what}: source endpoint ${JSON.stringify(flow.points[0])} on a face of ${JSON.stringify(stock)}`, + ).toBeDefined(); + const u = unit(flow.points[0], flow.points[1]); + expect(u.x, `${what}: first segment leaves the ${face} face perpendicular`).toBeCloseTo(OUTWARD[face!].x, 6); + expect(u.y, `${what}: first segment leaves the ${face} face perpendicular`).toBeCloseTo(OUTWARD[face!].y, 6); + return face!; +} + +function near(a: XY, b: XY, tolerance: number): boolean { + return Math.abs(a.x - b.x) <= tolerance && Math.abs(a.y - b.y) <= tolerance; +} + +/** + * The indices of a rendered flow's segments that pass through the interior of + * `stock` (a segment running along a face, or leaving it perpendicular, does + * not). Segments are axis-aligned (G2), so this is an interval overlap test. + */ +function segmentsThrough(flow: FlowProbe, stock: Rect): number[] { + const eps = 1e-4; + const crossing: number[] = []; + for (let i = 0; i + 1 < flow.points.length; i++) { + const a = flow.points[i]; + const b = flow.points[i + 1]; + const overlapX = Math.min(Math.max(a.x, b.x), stock.x + stock.width) - Math.max(Math.min(a.x, b.x), stock.x); + const overlapY = Math.min(Math.max(a.y, b.y), stock.y + stock.height) - Math.max(Math.min(a.y, b.y), stock.y); + const horizontal = Math.abs(a.y - b.y) < eps; + const inside = horizontal + ? a.y > stock.y + eps && a.y < stock.y + stock.height - eps && overlapX > eps + : a.x > stock.x + eps && a.x < stock.x + stock.width - eps && overlapY > eps; + if (inside) { + crossing.push(i); + } + } + return crossing; +} + +const stockCenter = (r: Rect): XY => ({ x: r.x + r.width / 2, y: r.y + r.height / 2 }); + +// The journey's project, in the engine's native JSON, loaded through the real +// engine exactly as a kernel seed is. Endpoints are pinned to stock faces the +// way the planner pins them (a face point, perpendicular exit), clouds sit at +// their endpoints (G7), and every stock's lists name the flows attached to it: +// the same shape an Editor-drawn diagram saves. +function journeyProject(): string { + const halfW = StockWidth / 2; + const population = { x: 200, y: 150 }; + const inventory = { x: 200, y: 400 }; + const warehouse = { x: 460, y: 400 }; + const reservoir = { x: 640, y: 150 }; + const birthsCloud = { x: 60, y: 150 }; + const stock = (uid: number, name: string, at: XY, labelSide: string) => ({ + type: 'stock', + uid, + name, + x: at.x, + y: at.y, + labelSide, + }); + return JSON.stringify({ + name: 'diagram-editing-journey', + simSpecs: { startTime: 0, endTime: 10, dt: '1', method: 'euler' }, + dimensions: [], + units: [], + models: [ + { + name: 'main', + stocks: [ + { name: 'Population', initialEquation: '100', inflows: ['births'], outflows: [] }, + { name: 'Inventory', initialEquation: '100', inflows: [], outflows: ['shipments'] }, + { name: 'Warehouse', initialEquation: '0', inflows: ['shipments'], outflows: [] }, + { name: 'Reservoir', initialEquation: '0', inflows: [], outflows: [] }, + ], + flows: [ + { name: 'births', equation: 'rate' }, + { name: 'shipments', equation: 'rate' }, + ], + auxiliaries: [{ name: 'rate', equation: '5' }], + views: [ + { + kind: 'stock_flow', + elements: [ + stock(1, 'Population', population, 'top'), + { + type: 'flow', + uid: 2, + name: 'births', + x: (birthsCloud.x + population.x - halfW) / 2, + y: population.y, + labelSide: 'bottom', + points: [ + { x: birthsCloud.x, y: birthsCloud.y, attachedToUid: 3 }, + { x: population.x - halfW, y: population.y, attachedToUid: 1 }, + ], + }, + { type: 'cloud', uid: 3, flowUid: 2, x: birthsCloud.x, y: birthsCloud.y }, + stock(4, 'Inventory', inventory, 'bottom'), + stock(5, 'Warehouse', warehouse, 'bottom'), + { + type: 'flow', + uid: 6, + name: 'shipments', + x: (inventory.x + warehouse.x) / 2, + y: inventory.y, + labelSide: 'bottom', + points: [ + { x: inventory.x + halfW, y: inventory.y, attachedToUid: 4 }, + { x: warehouse.x - halfW, y: warehouse.y, attachedToUid: 5 }, + ], + }, + stock(7, 'Reservoir', reservoir, 'top'), + { type: 'aux', uid: 8, name: 'rate', x: (inventory.x + warehouse.x) / 2, y: 300, labelSide: 'top' }, + { type: 'link', uid: 9, fromUid: 8, toUid: 6 }, + ], + viewBox: { x: 0, y: 0, width: 0, height: 0 }, + zoom: 1, + }, + ], + }, + ], + }); +} + +async function openJourney(page: Page, errors: string[]): Promise { + fs.mkdirSync(outDir, { recursive: true }); + page.on('pageerror', (err) => errors.push(`pageerror: ${err.message}`)); + page.on('console', (msg) => { + if (msg.type() === 'error') { + errors.push(`console.error: ${msg.text()}`); + } + }); + await page.addInitScript(installProbe, CloudWidth); + await serveHarness(page); + await mountWidget(page, 'cell1', widgetState(journeyProject(), { height: 640 })); + await expect(page.locator('#cell1 svg.simlin-canvas')).toBeVisible({ timeout: 60_000 }); + await expect + .poll(async () => Object.keys(await probeStocks(page)).sort()) + .toEqual(['Inventory', 'Population', 'Reservoir', 'Warehouse']); + await settle(page); +} + +test.describe('diagram editing in a real browser', () => { + test('stock drag, arrowhead clicks, detach and reattach, bracket, drawn flow, undo and redo', async ({ page }) => { + const errors: string[] = []; + await openJourney(page, errors); + const cell = page.locator('#cell1'); + expect((await snapshotState(page)).count, 'opening the project saves nothing').toBe(0); + + // ---- 1. Drag a stock perpendicular to its cloud-ended flow ---------------- + const stocks0 = await probeStocks(page); + const births0 = (await probeFlow(page, 'births'))!; + expect(births0.points.length, 'births starts straight').toBe(2); + await shot(page, '01-stock-drag-before'); + const pop0 = stockCenter(stocks0.Population); + const popTarget = { x: pop0.x, y: pop0.y + 80 }; + const popPress = await toClient(page, pop0); + const popRelease = await toClient(page, popTarget); + expect((await hitAt(page, popPress)).group).toContain('simlin-stock'); + await page.mouse.move(popPress.x, popPress.y); + await page.mouse.down(); + await page.mouse.move(popRelease.x, popRelease.y, { steps: 12 }); + await expect.poll(async () => (await probeStocks(page)).Population.y).toBeCloseTo(stocks0.Population.y + 80, 6); + const birthsPreview1 = (await probeFlow(page, 'births'))!; + await shot(page, '01-stock-drag-preview'); + await page.mouse.up(); + const snap1 = await waitForSnapshot(page, 'the stock move is saved', (p) => + near(viewElement(p, 'Population') as XY, popTarget, 1e-6), + ); + const stocks1 = await probeStocks(page); + const births1 = (await probeFlow(page, 'births'))!; + await shot(page, '01-stock-drag-after'); + note('1-stock-drag', { + before: births0, + preview: birthsPreview1, + committed: births1, + population: stocks1.Population, + }); + expect(births1.d, 'the committed flow is the last preview frame (E2)').toBe(birthsPreview1.d); + expect(births1.points.length, 'births bends (an L or a Z)').toBeGreaterThanOrEqual(3); + expectOrthogonal(births1, 'births after the stock drag'); + expectSinkOnFace(births1, stocks1.Population, 'births after the stock drag'); + expect(segmentsThrough(births1, stocks1.Population), 'no segment runs through Population (G6)').toEqual([]); + const sourceCloud1 = (await probeClouds(page))[0]; + expect(near(births1.points[0], sourceCloud1.center, 1e-5), 'births still starts at its cloud (G7)').toBe(true); + expect(stockLists(snap1, 'Population').inflows).toEqual(['births']); + + // ---- 2. Click (no movement) the flow's arrowhead on the stock -------------- + const geometry2 = await geometry(page); + const count2 = (await snapshotState(page)).count; + { + const n = births1.points.length; + const u = unit(births1.points[n - 2], births1.points[n - 1]); + // The middle of the arrowhead: back from the tip, off the stock's body. + const onArrowhead = await toClient(page, { x: births1.tip.x - 4 * u.x, y: births1.tip.y - 4 * u.y }); + const hit = await hitAt(page, onArrowhead); + expect(hit.cls, 'the press lands on the flow arrowhead').toContain('simlin-arrowhead'); + await shot(page, '02-arrowhead-click-before'); + await page.mouse.click(onArrowhead.x, onArrowhead.y); + await settle(page); + await shot(page, '02-arrowhead-click-after'); + const after = await geometry(page); + note('2-arrowhead-click', { hit, snapshotsBefore: count2, snapshotsAfter: (await snapshotState(page)).count }); + expect(after, 'a click on an attached arrowhead changes nothing').toEqual(geometry2); + expect((await snapshotState(page)).count, 'and saves nothing').toBe(count2); + + // ---- 3. Drag that end off the stock into empty space ----------------------- + const empty = { x: 380, y: 230 }; + const pressModel = { x: births1.tip.x - 4 * u.x, y: births1.tip.y - 4 * u.y }; + const expectedCloud = { + x: births1.tip.x + (empty.x - pressModel.x), + y: births1.tip.y + (empty.y - pressModel.y), + }; + const emptyClient = await toClient(page, empty); + expect((await hitAt(page, emptyClient)).tag, 'the drop point is empty canvas').toBe('svg'); + await page.mouse.move(onArrowhead.x, onArrowhead.y); + await page.mouse.down(); + await page.mouse.move(emptyClient.x, emptyClient.y, { steps: 15 }); + await expect + .poll(async () => (await probeClouds(page)).some((c) => near(c.center, expectedCloud, 1e-5)), { + message: 'the preview shows a cloud at the pointer, keeping the grab offset', + }) + .toBe(true); + const preview3 = { births: (await probeFlow(page, 'births'))!, clouds: await probeClouds(page) }; + const cloudClient = await toClient(page, expectedCloud); + await shot(page, '03-detach-preview'); + // The grab offset is the 4px from the arrowhead tip to where it was pressed. + expect(Math.hypot(cloudClient.x - emptyClient.x, cloudClient.y - emptyClient.y)).toBeCloseTo(4, 4); + await page.mouse.up(); + await settle(page, 1); + const released3 = { births: (await probeFlow(page, 'births'))!, clouds: await probeClouds(page) }; + expect(released3, 'the release renders exactly the last preview frame (E2)').toEqual(preview3); + const snap3 = await waitForSnapshot( + page, + 'the detach is saved', + (p) => !stockLists(p, 'Population').inflows.includes('births'), + ); + await settle(page); + const committed3 = { births: (await probeFlow(page, 'births'))!, clouds: await probeClouds(page) }; + await shot(page, '03-detach-after'); + note('3-detach', { pointer: emptyClient, expectedCloud, preview: preview3, committed: committed3 }); + expect(committed3, 'after the engine round trip the geometry is still the last preview frame').toEqual(preview3); + expect(committed3.clouds.length, 'a source cloud and the new sink cloud').toBe(2); + expectOrthogonal(committed3.births, 'births detached'); + // A pipe through the body of the stock the end just left reads as still + // attached to it. Soft: the committed state is otherwise valid, so the + // steps that follow still run and report. + expect + .soft( + segmentsThrough(committed3.births, stocks1.Population), + 'no segment runs through Population, the stock the end detached from', + ) + .toEqual([]); + const savedBirths = viewElement(snap3, 'births'); + const sinkUid = savedBirths.points![savedBirths.points!.length - 1].attachedToUid; + const savedCloud = snap3.models[0].views[0].elements.find((e) => e.uid === sinkUid); + expect(savedCloud?.type, 'the saved sink is a cloud').toBe('cloud'); + expect(near(savedCloud as XY, expectedCloud, 1e-6), 'the saved cloud is where the preview drew it').toBe(true); + expect(stockLists(snap3, 'Population').inflows, 'the saved stock no longer lists the flow (M2)').toEqual([]); + } + + // ---- 4. Drag the end back, onto another stock ------------------------------ + { + const births = (await probeFlow(page, 'births'))!; + const clouds = await probeClouds(page); + const sink = clouds.find((c) => !near(c.center, births.points[0], 1e-5))!; + const stocks = await probeStocks(page); + const pressClient = await toClient(page, sink.center); + expect((await hitAt(page, pressClient)).cls, 'the press lands on the sink cloud').toContain('simlin-cloud'); + const reservoirClient = await toClient(page, stockCenter(stocks.Reservoir)); + await shot(page, '04-reattach-before'); + await page.mouse.move(pressClient.x, pressClient.y); + await page.mouse.down(); + await page.mouse.move(reservoirClient.x, reservoirClient.y, { steps: 20 }); + await expect + .poll(async () => faceOf(stocks.Reservoir, (await probeFlow(page, 'births'))!.tip), { + message: 'the preview attaches births to a face of Reservoir', + }) + .toBeDefined(); + const preview4 = { births: (await probeFlow(page, 'births'))!, clouds: await probeClouds(page) }; + await shot(page, '04-reattach-preview'); + await page.mouse.up(); + const snap4 = await waitForSnapshot(page, 'the reattach is saved', (p) => + stockLists(p, 'Reservoir').inflows.includes('births'), + ); + await settle(page); + const committed4 = { births: (await probeFlow(page, 'births'))!, clouds: await probeClouds(page) }; + await shot(page, '04-reattach-after'); + note('4-reattach', { preview: preview4, committed: committed4, reservoir: stocks.Reservoir }); + expect(committed4, 'the committed geometry is the last preview frame').toEqual(preview4); + expect(committed4.clouds.length, 'the sink cloud is gone').toBe(1); + expectOrthogonal(committed4.births, 'births reattached'); + expectSinkOnFace(committed4.births, stocks.Reservoir, 'births reattached'); + for (const name of ['Reservoir', 'Population']) { + expect( + segmentsThrough(committed4.births, stocks[name]), + `births reattached: no segment through ${name}`, + ).toEqual([]); + } + expect(stockLists(snap4, 'Reservoir').inflows, 'the saved model lists the flow in the new stock (M2)').toEqual([ + 'births', + ]); + expect(stockLists(snap4, 'Population').inflows).toEqual([]); + } + + // ---- 5. Drag a straight flow's valve perpendicular past the faces ---------- + { + const stocks = await probeStocks(page); + const shipments = (await probeFlow(page, 'shipments'))!; + expect(shipments.points.length, 'shipments starts straight').toBe(2); + const valveClient = await toClient(page, shipments.valve); + expect((await hitAt(page, valveClient)).group, 'the press lands on the valve').toContain('simlin-flow'); + const bracketY = shipments.valve.y + 80; + const bracketClient = await toClient(page, { x: shipments.valve.x, y: bracketY }); + await shot(page, '05-bracket-before'); + await page.mouse.move(valveClient.x, valveClient.y); + await page.mouse.down(); + // The first move past the click threshold is perpendicular, which latches + // the press into a segment offset rather than a valve slide. + await page.mouse.move(bracketClient.x, bracketClient.y, { steps: 16 }); + await expect.poll(async () => (await probeFlow(page, 'shipments'))!.valve.y).toBeCloseTo(bracketY, 6); + const preview5 = (await probeFlow(page, 'shipments'))!; + await shot(page, '05-bracket-preview'); + await page.mouse.up(); + await waitForSnapshot(page, 'the bracket is saved', (p) => viewElement(p, 'shipments').points!.length >= 4); + await settle(page); + const bracket = (await probeFlow(page, 'shipments'))!; + await shot(page, '05-bracket-after'); + note('5-bracket', { before: shipments, preview: preview5, committed: bracket, pointer: bracketClient }); + expect(bracket, 'the committed bracket is the last preview frame').toEqual(preview5); + expect(bracket.points.length, 'a bracket: stub, riser, middle, riser, stub').toBeGreaterThanOrEqual(4); + expectOrthogonal(bracket, 'shipments bracket'); + expectSourceOnFace(bracket, stocks.Inventory, 'shipments bracket'); + expectSinkOnFace(bracket, stocks.Warehouse, 'shipments bracket'); + for (const name of ['Inventory', 'Warehouse']) { + expect(segmentsThrough(bracket, stocks[name]), `shipments bracket: no segment through ${name}`).toEqual([]); + } + // The middle segment follows the pointer: it carries the valve, at the + // pointer's height. + const middle = bracket.points.findIndex( + (p, i) => + i + 1 < bracket.points.length && + Math.abs(p.y - bracketY) < 1e-6 && + Math.abs(bracket.points[i + 1].y - bracketY) < 1e-6, + ); + expect(middle, 'a segment runs at the pointer height').toBeGreaterThan(0); + expect((await toClient(page, bracket.valve)).y).toBeCloseTo(bracketClient.y, 4); + + // Drag it back: straight again. + const backClient = await toClient(page, shipments.valve); + const bracketValveClient = await toClient(page, bracket.valve); + await page.mouse.move(bracketValveClient.x, bracketValveClient.y); + await page.mouse.down(); + await page.mouse.move(backClient.x, backClient.y, { steps: 16 }); + await expect.poll(async () => (await probeFlow(page, 'shipments'))!.points.length).toBe(2); + const previewBack = (await probeFlow(page, 'shipments'))!; + await shot(page, '05-straight-preview'); + await page.mouse.up(); + await waitForSnapshot( + page, + 'the straightened flow is saved', + (p) => viewElement(p, 'shipments').points!.length === 2, + ); + await settle(page); + const straight = (await probeFlow(page, 'shipments'))!; + await shot(page, '05-straight-after'); + note('5-straight', { preview: previewBack, committed: straight }); + expect(straight, 'the committed straight flow is the last preview frame').toEqual(previewBack); + expect(straight.d, 'dragging the bracket back restores the original straight pipe').toBe(shipments.d); + expectSourceOnFace(straight, stocks.Inventory, 'shipments straightened'); + expectSinkOnFace(straight, stocks.Warehouse, 'shipments straightened'); + } + + // ---- 6. Click a link's arrowhead --------------------------------------------- + { + const geometry6 = await geometry(page); + const count6 = (await snapshotState(page)).count; + expect(geometry6.links.length).toBe(1); + const head = await page.evaluate(() => (window as unknown as ProbeWindow).__journey.linkArrowheadCenter()); + const hit = await hitAt(page, head); + expect(hit.cls, 'the press lands on the link arrowhead').toContain('simlin-arrowhead'); + await shot(page, '06-link-arrowhead-before'); + await page.mouse.click(head.x, head.y); + await settle(page); + await shot(page, '06-link-arrowhead-after'); + note('6-link-arrowhead-click', { + hit, + snapshotsBefore: count6, + snapshotsAfter: (await snapshotState(page)).count, + }); + expect(await geometry(page), 'the link still exists, unchanged').toEqual(geometry6); + expect((await snapshotState(page)).count, 'and nothing is saved').toBe(count6); + } + + // ---- 8. Draw a new flow from a stock into empty space, then name it ------- + const geometry8Before = await geometry(page); + { + const stocks = await probeStocks(page); + await cell.getByRole('button', { name: 'hide or show editor tools' }).click(); + await cell.getByRole('button', { name: 'Flow', exact: true }).click(); + const pressClient = await toClient(page, stockCenter(stocks.Reservoir)); + expect((await hitAt(page, pressClient)).group).toContain('simlin-stock'); + const target = { x: stockCenter(stocks.Reservoir).x + 120, y: stockCenter(stocks.Reservoir).y + 150 }; + const targetClient = await toClient(page, target); + expect((await hitAt(page, targetClient)).tag, 'the drop point is empty canvas').toBe('svg'); + await shot(page, '08-draw-flow-before'); + await page.mouse.move(pressClient.x, pressClient.y); + await page.mouse.down(); + await page.mouse.move(targetClient.x, targetClient.y, { steps: 20 }); + await expect + .poll(async () => (await probeClouds(page)).some((c) => near(c.center, target, 1e-5)), { + message: 'the drawn flow previews a cloud at the pointer', + }) + .toBe(true); + // While drawn, the flow carries its default name. + const previewDrain = await probeFlow(page, 'New Flow'); + const previewClouds = await probeClouds(page); + await shot(page, '08-draw-flow-preview'); + await page.mouse.up(); + const nameEditor = cell.locator('[contenteditable="true"]'); + await expect(nameEditor, 'the drawn flow opens its name editor').toBeVisible(); + await shot(page, '08-draw-flow-naming'); + // The editor opens with its default name selected: typing replaces it. + await page.keyboard.type('drain'); + await page.keyboard.press('Enter'); + const snap8 = await waitForSnapshot(page, 'the named flow is saved', (p) => + p.models[0].flows.some((f) => canon(f.name) === 'drain'), + ); + await settle(page); + await expect.poll(async () => (await probeFlow(page, 'drain')) !== null).toBe(true); + const drain = (await probeFlow(page, 'drain'))!; + await shot(page, '08-draw-flow-after'); + const clouds8 = await probeClouds(page); + note('8-draw-flow', { target, targetClient, preview: previewDrain, committed: drain, clouds: clouds8 }); + expect(previewDrain, 'the preview drew the new flow').not.toBeNull(); + expect(drain.d, 'the named flow is the last preview frame').toBe(previewDrain!.d); + expect(clouds8, 'and so are the clouds').toEqual(previewClouds); + expectOrthogonal(drain, 'drain'); + expectSourceOnFace(drain, stocks.Reservoir, 'drain'); + const savedDrain = viewElement(snap8, 'drain'); + const sinkUid = savedDrain.points![savedDrain.points!.length - 1].attachedToUid; + const savedCloud = snap8.models[0].views[0].elements.find((e) => e.uid === sinkUid); + expect(savedCloud?.type, 'the drawn flow ends in a cloud').toBe('cloud'); + expect(near(savedCloud as XY, target, 1e-6), 'at the pointer').toBe(true); + expect(stockLists(snap8, 'Reservoir').outflows, 'the saved stock lists the drawn flow').toEqual(['drain']); + } + const geometry8After = await geometry(page); + + // ---- 9. Undo then redo the drawn flow ---------------------------------------- + { + const undo = cell.getByRole('button', { name: 'Undo' }); + const redo = cell.getByRole('button', { name: 'Redo' }); + // Drawing the flow and naming it are two edits: undo takes the name back, + // then the flow. + await expect(undo).toBeEnabled(); + await undo.click(); + await expect + .poll(async () => (await probeFlow(page, 'New Flow')) !== null, { + message: 'the first undo takes the name back', + }) + .toBe(true); + await expect(undo).toBeEnabled(); + await undo.click(); + await expect + .poll(async () => geometry(page), { message: 'the second undo restores the geometry before the flow' }) + .toEqual(geometry8Before); + await waitForSnapshot( + page, + 'the undo is saved', + (p) => !p.models[0].flows.some((f) => canon(f.name) === 'drain' || canon(f.name) === 'new_flow'), + ); + await shot(page, '09-undo-after'); + await expect(redo).toBeEnabled(); + await redo.click(); + await expect(redo).toBeEnabled(); + await redo.click(); + await expect + .poll(async () => geometry(page), { message: 'redo reapplies the drawn and named flow' }) + .toEqual(geometry8After); + const snap9 = await waitForSnapshot(page, 'the redo is saved', (p) => + p.models[0].flows.some((f) => canon(f.name) === 'drain'), + ); + await shot(page, '09-redo-after'); + note('9-undo-redo', { before: geometry8Before, after: geometry8After }); + expect(stockLists(snap9, 'Reservoir').outflows).toEqual(['drain']); + } + + const final = await snapshotState(page); + expect(final.kernelJson, 'the kernel accepted the last snapshot').toBe(final.json); + expect(errors).toEqual([]); + }); + + test('Escape during a stock drag cancels it: pre-drag geometry, nothing committed', async ({ page }) => { + const errors: string[] = []; + await openJourney(page, errors); + const cell = page.locator('#cell1'); + const before = await geometry(page); + const warehouse = stockCenter(before.stocks.Warehouse); + const press = await toClient(page, warehouse); + const moved = await toClient(page, { x: warehouse.x + 40, y: warehouse.y + 70 }); + expect((await hitAt(page, press)).group).toContain('simlin-stock'); + await shot(page, '07-escape-before'); + await page.mouse.move(press.x, press.y); + await page.mouse.down(); + await page.mouse.move(moved.x, moved.y, { steps: 12 }); + await expect.poll(async () => (await probeStocks(page)).Warehouse.y).toBeCloseTo(before.stocks.Warehouse.y + 70, 6); + const preview = await geometry(page); + await shot(page, '07-escape-preview'); + await page.keyboard.press('Escape'); + await settle(page); + const afterEscape = await geometry(page); + await shot(page, '07-escape-after-escape'); + // Keep moving and release: a cancelled gesture must not come back to life. + await page.mouse.move(moved.x + 10, moved.y + 10, { steps: 4 }); + await page.mouse.up(); + await settle(page); + const afterRelease = await geometry(page); + const snapshots = (await snapshotState(page)).count; + await shot(page, '07-escape-after-release'); + note('7-escape', { + before: before.stocks.Warehouse, + preview: preview.stocks.Warehouse, + afterEscape: afterEscape.stocks.Warehouse, + afterRelease: afterRelease.stocks.Warehouse, + snapshots, + errors, + }); + await expect(cell.locator('[data-simlin-editor-root]'), 'the editor is still rendered').toHaveCount(1); + await expect(cell.getByText('Something went wrong'), 'no error boundary').toHaveCount(0); + expect(errors).toEqual([]); + expect(afterEscape, 'Escape restores the pre-drag geometry').toEqual(before); + expect(afterRelease, 'the release after Escape commits nothing').toEqual(before); + expect(snapshots, 'nothing is saved').toBe(0); + }); +}); diff --git a/src/notebook-widget/e2e/support.ts b/src/notebook-widget/e2e/support.ts new file mode 100644 index 000000000..3483ab582 --- /dev/null +++ b/src/notebook-widget/e2e/support.ts @@ -0,0 +1,106 @@ +// Copyright 2026 The Simlin Authors. All rights reserved. +// Use of this source code is governed by the Apache License, +// Version 2.0, that can be found in the LICENSE file. + +/** + * The static-page harness the Playwright journeys share: `dist/widget.js`, + * the harness page and the engine wasm are served from disk through + * `page.route()` (no web server), and a widget is mounted the way anywidget + * mounts one (a fresh blob: URL per instance, a fake `AnyModel` playing the + * kernel). See `src/notebook-widget/CLAUDE.md`, Files -> `e2e/`. + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +import type { Page } from '@playwright/test'; + +const here = import.meta.dirname; +export const packageRoot = path.resolve(here, '..'); +export const repoRoot = path.resolve(packageRoot, '..', '..'); + +export const ORIGIN = 'https://simlin-widget.test'; + +export const harnessFiles: Record = { + '/': { path: path.join(here, 'harness', 'index.html'), type: 'text/html' }, + '/harness/fake-anywidget-model.js': { + path: path.join(here, 'harness', 'fake-anywidget-model.js'), + type: 'text/javascript', + }, + '/widget.js': { path: path.join(packageRoot, 'dist', 'widget.js'), type: 'text/javascript' }, + '/libsimlin-browser.wasm': { + path: path.join(repoRoot, 'src', 'engine', 'core', 'libsimlin-browser.wasm'), + type: 'application/wasm', + }, +}; + +export async function serveHarness(page: Page): Promise { + for (const file of Object.values(harnessFiles)) { + if (!fs.existsSync(file.path)) { + throw new Error(`missing ${file.path}; run \`pnpm build\` in src/engine and src/notebook-widget first`); + } + } + await page.route(`${ORIGIN}/**`, async (route) => { + const url = new URL(route.request().url()); + const file = harnessFiles[url.pathname]; + if (file === undefined) { + await route.fulfill({ status: 404, body: `no such harness file: ${url.pathname}` }); + return; + } + await route.fulfill({ status: 200, contentType: file.type, body: fs.readFileSync(file.path) }); + }); + await page.goto(`${ORIGIN}/`); + await page.waitForFunction(() => (window as unknown as { harness?: unknown }).harness !== undefined); +} + +export interface HarnessModel { + state: Record; + sets: Array<{ key: string; value: unknown }>; + saveChangesCount: number; + wasmRequests: number; + sent: unknown[]; + snapshots: Array<{ id: unknown; base: number; json: string }>; + kernel: { revision: number; projectJson: string }; + send(content: unknown, callbacks?: unknown, buffers?: unknown): void; + kernelPush(patch: Record): void; + kernelSend(content: unknown): void; + kernelChange(projectJson: string, notice?: string): void; + cleanup?: () => void; +} + +export interface HarnessWindow { + harness: { + loadWidgetModule(inlineWasmBase64?: string): Promise; + mount(mod: unknown, el: HTMLElement, state: Record): Promise; + models: HarnessModel[]; + }; +} + +export function widgetState(projectJson: string, overrides: Record = {}): Record { + return { + project_json: projectJson, + revision: 0, + selection: [], + height: 520, + theme: 'light', + read_only: false, + ...overrides, + }; +} + +// Every mount imports the bundle again through a fresh blob: URL -- a +// separate module instance with its own module-level state -- because that is +// exactly what anywidget does per widget instance (load.ts `loadEsm` creates a +// new object URL for each Runtime). Sharing across instances therefore has to +// go through globalThis. +export async function mountWidget(page: Page, cellId: string, state: Record): Promise { + return page.evaluate( + async ({ cellId, state }) => { + const w = window as unknown as HarnessWindow; + const mod = await w.harness.loadWidgetModule(); + const el = document.getElementById(cellId)!; + return w.harness.mount(mod, el, state); + }, + { cellId, state }, + ); +} diff --git a/src/notebook-widget/e2e/widget.spec.ts b/src/notebook-widget/e2e/widget.spec.ts index 3fbc75a88..c819f3d2d 100644 --- a/src/notebook-widget/e2e/widget.spec.ts +++ b/src/notebook-widget/e2e/widget.spec.ts @@ -23,94 +23,22 @@ import * as path from 'node:path'; import { test, expect, type Page } from '@playwright/test'; +import { + harnessFiles as files, + mountWidget, + ORIGIN, + repoRoot, + serveHarness, + widgetState, + type HarnessWindow, +} from './support'; + const here = import.meta.dirname; -const packageRoot = path.resolve(here, '..'); -const repoRoot = path.resolve(packageRoot, '..', '..'); - -const ORIGIN = 'https://simlin-widget.test'; - -const files: Record = { - '/': { path: path.join(here, 'harness', 'index.html'), type: 'text/html' }, - '/harness/fake-anywidget-model.js': { - path: path.join(here, 'harness', 'fake-anywidget-model.js'), - type: 'text/javascript', - }, - '/widget.js': { path: path.join(packageRoot, 'dist', 'widget.js'), type: 'text/javascript' }, - '/libsimlin-browser.wasm': { - path: path.join(repoRoot, 'src', 'engine', 'core', 'libsimlin-browser.wasm'), - type: 'application/wasm', - }, -}; const projectJson = fs.readFileSync(path.join(repoRoot, 'test', 'logistic-growth.sd.json'), 'utf8'); -async function serveHarness(page: Page): Promise { - for (const [route, file] of Object.entries(files)) { - if (!fs.existsSync(file.path)) { - throw new Error(`missing ${file.path}; run \`pnpm build\` in src/engine and src/notebook-widget first`); - } - void route; - } - await page.route(`${ORIGIN}/**`, async (route) => { - const url = new URL(route.request().url()); - const file = files[url.pathname]; - if (file === undefined) { - await route.fulfill({ status: 404, body: `no such harness file: ${url.pathname}` }); - return; - } - await route.fulfill({ status: 200, contentType: file.type, body: fs.readFileSync(file.path) }); - }); - await page.goto(`${ORIGIN}/`); - await page.waitForFunction(() => (window as unknown as { harness?: unknown }).harness !== undefined); -} - -interface HarnessWindow { - harness: { - loadWidgetModule(inlineWasmBase64?: string): Promise; - mount(mod: unknown, el: HTMLElement, state: Record): Promise; - models: Array<{ - state: Record; - sets: Array<{ key: string; value: unknown }>; - saveChangesCount: number; - wasmRequests: number; - sent: unknown[]; - snapshots: Array<{ id: unknown; base: number; json: string }>; - kernel: { revision: number; projectJson: string }; - kernelPush(patch: Record): void; - kernelSend(content: unknown): void; - kernelChange(projectJson: string, notice?: string): void; - cleanup?: () => void; - }>; - }; -} - function initialState(overrides: Record = {}): Record { - return { - project_json: projectJson, - revision: 0, - selection: [], - height: 520, - theme: 'light', - read_only: false, - ...overrides, - }; -} - -// Every mount imports the bundle again through a fresh blob: URL -- a -// separate module instance with its own module-level state -- because that is -// exactly what anywidget does per widget instance (load.ts `loadEsm` creates a -// new object URL for each Runtime). Sharing across instances therefore has to -// go through globalThis, which is what the second-cell assertions below check. -async function mountWidget(page: Page, cellId: string, state: Record): Promise { - return page.evaluate( - async ({ cellId, state }) => { - const w = window as unknown as HarnessWindow; - const mod = await w.harness.loadWidgetModule(); - const el = document.getElementById(cellId)!; - return w.harness.mount(mod, el, state); - }, - { cellId, state }, - ); + return widgetState(projectJson, overrides); } // The drawer sheet slides in with a transform transition; wait until its @@ -202,6 +130,14 @@ test.describe('notebook widget bundle', () => { // The engine keeps the display spelling verbatim (canonical matching // happens on lookup), so the snapshot carries the name as typed. .toContain('"New Variable"'); + // The fake kernel records a snapshot synchronously in `send` but accepts it + // on a later task, as a real comm round trip would, so observing the + // snapshot says nothing yet about the accept: wait for the kernel to act. + await expect + .poll(() => page.evaluate(() => (window as unknown as HarnessWindow).harness.models[0].kernel.revision), { + timeout: 30_000, + }) + .toBeGreaterThan(0); const saved = await page.evaluate(() => { const m = (window as unknown as HarnessWindow).harness.models[0]; diff --git a/src/notebook-widget/eslint.config.cjs b/src/notebook-widget/eslint.config.cjs index 4be731561..02e72a4fe 100644 --- a/src/notebook-widget/eslint.config.cjs +++ b/src/notebook-widget/eslint.config.cjs @@ -27,6 +27,13 @@ if (baseConfig) { MutationCallback: 'readonly', MutationRecord: 'readonly', Node: 'readonly', + // The Playwright journeys' in-page probes and frame recorder. + performance: 'readonly', + PerformanceObserver: 'readonly', + PerformanceObserverInit: 'readonly', + SVGGraphicsElement: 'readonly', + SVGSVGElement: 'readonly', + Window: 'readonly', Response: 'readonly', TextEncoder: 'readonly', atob: 'readonly', diff --git a/src/notebook-widget/src/WidgetApp.tsx b/src/notebook-widget/src/WidgetApp.tsx index faf228a06..4157594ea 100644 --- a/src/notebook-widget/src/WidgetApp.tsx +++ b/src/notebook-widget/src/WidgetApp.tsx @@ -272,6 +272,34 @@ export function WidgetApp({ model, name }: { model: AnyModel; name: string }): R [publishSelection], ); + // The Editor's Reload, offered once its engine is lost and cannot be reopened. + // Reloading the page would reload the whole notebook front-end, so the widget + // remounts the Editor instead, on the last state the kernel acknowledged (the + // seed) with the live viewport carried. remountFrom is idempotent on the pair, + // and the pair has not changed, so this bumps the generation itself. A + // snapshot still in flight belongs to the Editor being replaced: its save + // resolves undefined and the slot is freed, as on a kernel remount. + const handleReload = React.useCallback((): void => { + const r = refs.current; + if (r.disposed) { + return; + } + if (r.inFlight !== null) { + const flight = r.inFlight; + r.inFlight = null; + flight.resolve(undefined); + } + const seedPair = r.seed; + const initialViewport = viewportToCarry(r.liveViewport, seedPair.projectJson, seedPair.projectJson); + setSeed((prev) => ({ + revision: seedPair.revision, + projectJson: seedPair.projectJson, + generation: prev.generation + 1, + initialViewport, + })); + publishSelection([]); + }, [publishSelection]); + // Kernel pushes. Only the kernel writes `project_json` and `revision`, so // every change event on them is a kernel push. The two travel in ONE // hold_sync but surface as up to two change events (Backbone fires per @@ -491,6 +519,7 @@ export function WidgetApp({ model, name }: { model: AnyModel; name: string }): R onSelectionChanged={publishSelection} onViewportChange={handleViewportChange} initialViewport={seed.initialViewport} + onReload={handleReload} portalContainer={wrapper} // The notebook page has no "/" to go home to; the drawer's Exit // link would pushState the notebook page away. diff --git a/src/notebook-widget/src/index.test.tsx b/src/notebook-widget/src/index.test.tsx index e8b27baaf..1bfd09b2b 100644 --- a/src/notebook-widget/src/index.test.tsx +++ b/src/notebook-widget/src/index.test.tsx @@ -605,6 +605,34 @@ describe('WidgetApp <-> model protocol', () => { expect(mounts[1].props.initialProjectJson).toBe(withEditorView('{"name":"p","models":[{"name":"main"}]}')); }); + it("the Editor's Reload (its engine lost) remounts it on the last acknowledged state: no page reload, nothing sent", async () => { + const model = new FakeModel(defaultState({ revision: 3 })); + await mount(model); + fireEvent.click(screen.getByText('edit')); + await waitFor(() => expect(mounts[0].saveResults).toEqual([4])); + const sentBefore = model.sent.length; + expect(typeof mounts[0].props.onReload).toBe('function'); + act(() => { + mounts[0].props.onReload!(); + }); + expect(mounts).toHaveLength(2); + // The kernel acknowledged our first edit, so that is what the new mount opens. + expect(mounts[1].props.initialProjectJson).toBe(localJson(SEED, 1)); + expect(mounts[1].props.initialProjectVersion).toBe(4); + expect(model.sent).toHaveLength(sentBefore); + // The new Editor starts with nothing selected, so the trait is told. + act(() => { + rs.advanceTimersByTime(SELECTION_DEBOUNCE_MS); + }); + expect(model.sets.filter((s) => s.key === 'selection')).toHaveLength(1); + expect(model.lastSet('selection')).toEqual([]); + // A second Reload remounts again: the pair is unchanged, the generation moves. + act(() => { + mounts[1].props.onReload!(); + }); + expect(mounts).toHaveLength(3); + }); + it('a revision that goes backwards with the same bytes still remounts (generation bump)', async () => { const model = new FakeModel(defaultState({ revision: 3, project_json: 'X' })); await mount(model); @@ -1008,6 +1036,26 @@ describe('viewport carried across a kernel-originated remount', () => { ); }); + it("the Editor's Reload carries the live viewport and frees an in-flight snapshot slot for the new Editor", async () => { + const model = new FakeModel(defaultState({ project_json: projectWith(box), revision: 3 })); + model.busyKernel = true; + await mount(model); + fireEvent.click(screen.getByText('pan')); + fireEvent.click(screen.getByText('edit')); + const snapshots = () => model.sent.filter((m) => (m as { type: string }).type === 'snapshot'); + expect(snapshots()).toHaveLength(1); + act(() => { + mounts[0].props.onReload!(); + }); + expect(mounts).toHaveLength(2); + expect(mounts[1].props.initialViewport).toEqual(firstPan); + // The replaced Editor's save resolves unsaved, and the new Editor's first + // edit is sent rather than refused as "one already in flight". + await waitFor(() => expect(mounts[0].saveResults).toEqual([undefined])); + fireEvent.click(screen.getByText('edit')); + expect(snapshots()).toHaveLength(2); + }); + it("a kernel push that moved the stored viewport remounts on the kernel's (nothing carried)", async () => { const model = new FakeModel(defaultState({ project_json: projectWith(box), revision: 3 })); await mount(model); diff --git a/src/notebook-widget/src/test-utils/editor-mock.tsx b/src/notebook-widget/src/test-utils/editor-mock.tsx index 3fcbf7a58..d833416c0 100644 --- a/src/notebook-widget/src/test-utils/editor-mock.tsx +++ b/src/notebook-widget/src/test-utils/editor-mock.tsx @@ -36,6 +36,8 @@ export interface EditorMockProps { // viewport and hands it back to the next mount. initialViewport?: Viewport; onViewportChange?: (modelName: string, viewport: Viewport) => void; + // The Reload action of the real Editor's engine-unavailable notice. + onReload?: () => void; } interface MountRecord { diff --git a/src/simlin-engine/CLAUDE.md b/src/simlin-engine/CLAUDE.md index d18b4c3f1..c66c32fc0 100644 --- a/src/simlin-engine/CLAUDE.md +++ b/src/simlin-engine/CLAUDE.md @@ -160,6 +160,7 @@ A conveyor or queue stock is not compiled directly. `conveyor_compile::expand_co ## Data model and identifiers - `datamodel.rs` -- the canonical serializable model: `Project`, `Model` (with optional `MacroSpec`), `Variable`, `Equation` (`Scalar`, `ApplyToAll`, `Arrayed` with an EXCEPT default), `Dimension` with `mappings`, views. + - A stock's `inflows`/`outflows` hold what the producer wrote, a repeat included; `distinct_stock_flows` is the one statement of how they are read (each flow once, at its first occurrence -- the engine's rule, unverified against Stella and Vensim). Every reader that decides what the model does takes the set through it before reading: the salsa sync (which records the repeats for the `RepeatedStockFlow` advisory), `queue_compile::build_compiled` before either expansion, the layout metadata, the MDL writer's `INTEG`, and the patch ops, which store the set. - `variable.rs` -- the compiler's variable: `Variable { ident, units, eqn, diagnostics, kind }` over `VarKind::{ Stock { init_ast, inflows, outflows, non_negative }, Aux { ast, init_ast, tables, non_negative, is_flow, is_table_only }, Module { model_name, inputs } }`, generic over the module-input type (`ModuleReference` before lowering, `ModuleInput` after) and the expression tier (`Expr0` before, `Expr2` after). `Aux` covers auxiliaries and flows alike -- they lower identically, one slot per element holding one equation's value, and `is_flow` only says where a stock's integration reads it. - `diagnostics` is a LIVE channel, not diagnostics-in-waiting: the context-free `DiagnosticError`s the parse and the lowering raised, in order (a `Unit` entry is a malformed `` string and is compiled past; everything else stops the variable, `Variable::fatal_diagnostics`), which the fragment constructors turn into `Diagnostic`s. `model::lower_variable` is a map over `kind`, so a transformation that only rewrites equations restates no field. - `common.rs` -- `ErrorCode`, `Error`, `EquationError`, `Result`; the identifier types. An `EquationError` is `{start, end, code, details}`: the code names the CLASS of failure, the span points at the offending text (which `errors.rs` renders as a source snippet), and `details` carries the reason when it is not visible in the span -- the name that did not resolve, the arity a call missed, the identifier a lowering could not shape. A parse error therefore writes no `details`: the snippet is its reason, and every Rust surface renders that snippet. @@ -188,11 +189,11 @@ Opt-in: a model that declares units on no variable gets no unit diagnostics. `un - `analysis.rs::analyze_model` bundles compilation, LTM discovery, and dominant-period selection into `ModelAnalysis`; a model that cannot compile returns `Ok` with `analysis_error` set so "could not analyze" is distinct from "no loops". - `layout/` generates and incrementally updates diagram layouts (force-directed placement, crossing reduction, a calibrated quality metric; deterministic per seed). The metric and the eval harness that measures layouts against it are described in [layout quality](/docs/design/layout-quality.md). The incremental path lives in `layout/incremental.rs`: chains an edit adds whole are laid out as chains beside the diagram, a new stock hung off a drawn chain continues its row, and what the edit added is decluttered around the fixed diagram (`declutter::declutter_part`). Incremental layout never rewrites an element the patch did not touch: position and `label_side` come back byte for byte (`layout_label_tests.rs` enumerates the arms), a label side is chosen only for elements created in that pass, and a new connector that runs through an existing label is accepted rather than re-optimizing its neighbours -- hand placement wins, and re-optimizing is exactly the churn that snaps a notebook user's dragged label somewhere else on the next edit. A label the layout wraps carries the stored two-character `\n` escape (`text::LABEL_LINE_BREAK`, the form the TypeScript editor's `encodeNameNewlines` produces), never a raw newline. +- The flow arm of that rule (`layout_flow_tests.rs` enumerates it): incremental layout rebuilds a flow only when the patch creates it or changes the flow's own attachment -- moves it to another stock, drops it from a stock's list (that end becomes a cloud), lists it on a stock at its cloud end (that end becomes the stock), deletes an attached stock, or changes an attached stock's kind -- because its stored endpoints then name the wrong element. A flow the patch names keeps its geometry (a rename changes only its name, a delete removes it with its clouds), and every other flow, a sibling of a flow added to or removed from the same stock included, comes back byte for byte even where a fresh layout would draw it differently. A created side flow takes a face its stock's drawn side flows leave free where there is one. The endpoint snap (`resnap_flow_endpoints`), the slot placement (`face_slots`: a created flow's stock end keeps the design plan's `PIPE_SPACING` from the ends already on its face where the face has room, else the farthest slot, always within the corner clearance; a two-point flow between parallel faces takes one line only where that line keeps the spacing on both faces; a created cloud end keeps off other clouds) and the finishing pass (`finish_flow_geometry`) run only on the flows the pass creates. The one repair made to an untouched flow is wiring an endpoint the view left unattached to the flow's own cloud (`diff_clouds`), which moves nothing. - `diagram/` renders a model's first stock-and-flow view as SVG (`render_svg`, byte-identical to the TypeScript static renderer; `src/diagram/tests/svg-rendering.test.ts` is that parity test), as PNG (`render_png`, behind `png_render`), and as a scene display list (`build_scene`, whose contract is [the diagram scene](/docs/design/diagram-scene.md)), and exposes the exact geometry the layout metric scores. Every drawing decision has one owner, and both serializers read it: - `resolve::resolve_view` decides which elements are drawn (a link or flow whose endpoints are missing from the view is not), their layer and draw order, whether each is arrayed, an alias's target, and the fit-to-content bounds. - Each element's geometry is one function: `aux_geometry`, `stock_geometry`, `module_geometry`, `alias_geometry`, `group_geometry`, `cloud_transform` (over the shared `CLOUD_PATH`), `flow_geometry`, `connector_geometry`, `arrowhead_geometry`, and `label::label_lines` for per-line label anchors. `render_*` prints from it and `scene.rs` reads it. Never compute a drawn number a second time in either serializer: a hand-maintained twin drifts exactly where the geometry is non-trivial; extend the geometry function instead. - The scene changes representation only: `path::PathBuilder::svg_arc_to` converts every SVG arc to cubic Béziers (SVG 1.1 F.6.5 and F.6.6, at most 30 degrees per cubic, so a cubic stays within 1e-6 of its radius), and transforms are applied to the points. `scene_tests.rs` checks each scene number against the number the SVG prints for the same project. ->>>>>>> Stashed changes ## Tests diff --git a/src/simlin-engine/src/common.rs b/src/simlin-engine/src/common.rs index 3ac1b3e55..f273b6196 100644 --- a/src/simlin-engine/src/common.rs +++ b/src/simlin-engine/src/common.rs @@ -495,6 +495,12 @@ pub enum ErrorCode { /// Warning naming the queue, mirroring `ConveyorLtmDegraded` /// (docs/design/queues.md §10.5). QueueLtmDegraded, + /// A stock's inflow or outflow list names one flow more than once. The + /// engine integrates each flow once (`datamodel::distinct_stock_flows`, + /// the engine's rule, unverified against Stella and Vensim), so the + /// simulated model is not a naive sum over the list as written. Emitted + /// as a Warning naming the stock and the repeated flows. + RepeatedStockFlow, /// A conveyor stock is defined in a model that is NOT the main model -- a /// module-referenced sub-model, or a model defined but never instantiated. /// Conveyor expansion (`conveyor_compile::expand_conveyors`) rewrites only the @@ -716,6 +722,7 @@ impl fmt::Display for ErrorCode { QueueDrivenFlowRead => "queue_driven_flow_read", QueueOverflowNotOnQueue => "queue_overflow_not_on_queue", QueueLtmDegraded => "queue_ltm_degraded", + RepeatedStockFlow => "repeated_stock_flow", ConveyorInSubmodelUnsupported => "conveyor_in_submodel_unsupported", QueueInSubmodelUnsupported => "queue_in_submodel_unsupported", QueueSecondaryOutflowToConveyor => "queue_secondary_outflow_to_conveyor", diff --git a/src/simlin-engine/src/datamodel.rs b/src/simlin-engine/src/datamodel.rs index d5c9c5020..0bc614be7 100644 --- a/src/simlin-engine/src/datamodel.rs +++ b/src/simlin-engine/src/datamodel.rs @@ -417,13 +417,93 @@ pub struct Stock { pub equation: Equation, pub documentation: String, pub units: Option, + /// The inflows as the producer wrote them. A list can repeat a flow (a + /// file with a repeated `` is stored as written); every reader + /// that decides what the model does reads the set through + /// [`distinct_stock_flows`]. pub inflows: Vec, + /// The outflows as the producer wrote them; see `inflows`. pub outflows: Vec, pub ai_state: Option, pub uid: Option, pub compat: Compat, } +/// A stock's inflow or outflow list as the set the engine integrates, and the +/// flows the list repeats. +#[cfg_attr(feature = "debug-derive", derive(Debug))] +#[derive(Clone, PartialEq, Default)] +pub struct DistinctFlows { + /// Each flow's first occurrence, judged after canonicalization, in list + /// order, spelled as written. + pub flows: Vec, + /// The canonical name of every flow the list names more than once, each + /// once, in the order their first repeat appears. + pub repeated: Vec, +} + +/// The one statement of how the engine reads a stock's inflow or outflow list: +/// each flow counts once, at its first occurrence. The readers that decide +/// what the model does -- the salsa sync (`db::sync`), the special-stock +/// build before expansion (`queue_compile::build_compiled`), the layout's +/// stock-flow metadata and the MDL writer's `INTEG` -- read the set through +/// this, and the sync keeps `repeated` so a model whose list repeats a flow is +/// warned about (`ErrorCode::RepeatedStockFlow`), since taking the set changes +/// what a naive sum over the list would integrate. +/// +/// This is the engine's rule, unverified against Stella or Vensim. XMILE 1.0 +/// section 4.2 (`docs/reference/xmile-v1.0.html`, "Stocks") never addresses a +/// repeated tag; its whole statement on the list is "The set of inflows +/// and/or outflows is NOT REQUIRED. If there are multiple inflows, they appear +/// with multiple tags in inflow-priority order (if the order of inflow to the +/// stock is important)." Calling the list a set, with each flow holding one +/// priority, is what the rule leans on. +pub fn distinct_stock_flows(flows: &[String]) -> DistinctFlows { + let mut seen: std::collections::HashSet = std::collections::HashSet::new(); + let mut result = DistinctFlows::default(); + for flow in flows { + let canonical = canonicalize(flow).into_owned(); + if seen.contains(&canonical) { + if !result.repeated.contains(&canonical) { + result.repeated.push(canonical); + } + } else { + seen.insert(canonical); + result.flows.push(flow.clone()); + } + } + result +} + +impl Project { + /// The project with every stock's inflow and outflow lists replaced by + /// their sets ([`distinct_stock_flows`]), borrowed when no list repeats a + /// flow. For a reader that walks the datamodel's lists wholesale, taken + /// before any of it reads them. + pub fn with_distinct_stock_flows(&self) -> std::borrow::Cow<'_, Project> { + let repeats = |flows: &[String]| !distinct_stock_flows(flows).repeated.is_empty(); + let any_repeat = self.models.iter().any(|m| { + m.variables.iter().any(|v| match v { + Variable::Stock(s) => repeats(&s.inflows) || repeats(&s.outflows), + _ => false, + }) + }); + if !any_repeat { + return std::borrow::Cow::Borrowed(self); + } + let mut project = self.clone(); + for model in &mut project.models { + for var in &mut model.variables { + if let Variable::Stock(s) = var { + s.inflows = distinct_stock_flows(&s.inflows).flows; + s.outflows = distinct_stock_flows(&s.outflows).flows; + } + } + } + std::borrow::Cow::Owned(project) + } +} + #[cfg_attr(feature = "debug-derive", derive(Debug))] #[derive(Clone, PartialEq)] pub struct Flow { diff --git a/src/simlin-engine/src/db/diagnostic.rs b/src/simlin-engine/src/db/diagnostic.rs index f69323168..07c4b1d17 100644 --- a/src/simlin-engine/src/db/diagnostic.rs +++ b/src/simlin-engine/src/db/diagnostic.rs @@ -153,6 +153,13 @@ pub fn model_all_diagnostics( // failure like a duplicate macro name, not a partial-result advisory. emit_duplicate_variable_diagnostics(db, model); + // A stock whose inflow or outflow list names a flow more than once: the + // sync took the set (`datamodel::distinct_stock_flows`), so the model + // simulated is not a naive sum over the list the file wrote. An advisory, + // not an error -- the set is the engine's reading -- emitted from this + // per-model owner, which `collect_all_diagnostics` drains once per model. + emit_repeated_stock_flow_warnings(db, model); + let source_vars = model.variables(db); // Trigger compile_var_fragment for each variable. This is a superset @@ -397,6 +404,53 @@ fn emit_duplicate_variable_diagnostics(db: &dyn Db, model: SourceModel) { } } +/// One `RepeatedStockFlow` `Warning` per stock in `model` whose inflow or +/// outflow list names a flow more than once, naming the repeated flows. +/// Stocks are visited in sorted-name order so the rows accumulate +/// deterministically. Carried as a `Model` error with the specific code, so +/// the advisory never makes the project look non-simulatable. +fn emit_repeated_stock_flow_warnings(db: &dyn Db, model: SourceModel) { + use crate::common::{ErrorCode, ErrorKind}; + + let mut stocks: Vec<&SourceVariable> = model + .variables(db) + .values() + .filter(|sv| !sv.repeated_inflows(db).is_empty() || !sv.repeated_outflows(db).is_empty()) + .collect(); + stocks.sort_unstable_by_key(|sv| sv.ident(db)); + + let model_name = model.name(db); + for sv in stocks { + let name = sv.ident(db); + let mut lists = Vec::new(); + for (noun, repeated) in [ + ("inflow", sv.repeated_inflows(db)), + ("outflow", sv.repeated_outflows(db)), + ] { + if !repeated.is_empty() { + let flows: Vec = repeated.iter().map(|f| format!("'{f}'")).collect(); + lists.push(format!("{noun} list repeats {}", flows.join(", "))); + } + } + let msg = format!( + "stock '{name}': its {}; each flow is integrated once", + lists.join(" and its ") + ); + Diagnostic { + model: model_name.clone(), + variable: Some(name.clone()), + owner: None, + severity: DiagnosticSeverity::Warning, + error: DiagnosticError::Model(Error::new( + ErrorKind::Model, + ErrorCode::RepeatedStockFlow, + Some(msg), + )), + } + .accumulate(db); + } +} + /// Shared emitter behind the conveyor/queue LTM-degraded advisories: one /// `Warning` per stock in `model` whose `Compat` carries the owner's marker /// (`has_marker`). diff --git a/src/simlin-engine/src/db/diagnostic_payload_tests.rs b/src/simlin-engine/src/db/diagnostic_payload_tests.rs index 38935d6bb..2200f207f 100644 --- a/src/simlin-engine/src/db/diagnostic_payload_tests.rs +++ b/src/simlin-engine/src/db/diagnostic_payload_tests.rs @@ -988,6 +988,22 @@ fn every_warning_family_is_emitted_once_across_revisions() { guards: no_guards, matches: |d| d.is(DiagnosticCategory::Model, ErrorCode::UnfilledEquation), }, + Family { + name: "advisory: a stock list that repeats a flow", + child: || { + as_child( + TestProject::new("repeat") + .flow("f", "1", None) + .stock("s", "0", &["f", "f"], &[], None) + .build_datamodel(), + ) + }, + probe: "s", + wiring: &[], + discovery: false, + guards: no_guards, + matches: |d| d.is(DiagnosticCategory::Model, ErrorCode::RepeatedStockFlow), + }, ]; /// How many models reach the child, and how. diff --git a/src/simlin-engine/src/db/input.rs b/src/simlin-engine/src/db/input.rs index aa6063ccb..58718adae 100644 --- a/src/simlin-engine/src/db/input.rs +++ b/src/simlin-engine/src/db/input.rs @@ -231,6 +231,14 @@ pub struct SourceVariable { pub inflows: Vec, #[returns(ref)] pub outflows: Vec, + /// The canonical names of the flows the datamodel's inflow list repeats + /// (`inflows` holds the set, `datamodel::distinct_stock_flows`), read only + /// by the `RepeatedStockFlow` advisory. + #[returns(ref)] + pub repeated_inflows: Vec, + /// As `repeated_inflows`, for the outflow list. + #[returns(ref)] + pub repeated_outflows: Vec, #[returns(ref)] pub module_refs: Vec, /// A `Module` variable's referenced target model; empty for every other diff --git a/src/simlin-engine/src/db/sync.rs b/src/simlin-engine/src/db/sync.rs index f537fdac2..821accd0f 100644 --- a/src/simlin-engine/src/db/sync.rs +++ b/src/simlin-engine/src/db/sync.rs @@ -401,6 +401,8 @@ struct SourceVariableFields { gf: Option, inflows: Vec, outflows: Vec, + repeated_inflows: Vec, + repeated_outflows: Vec, module_refs: Vec, /// A `Module` variable's referenced target model; empty for every other /// kind (NOT the owning model, which is `owner_model`). @@ -414,9 +416,15 @@ struct SourceVariableFields { impl SourceVariableFields { fn from_datamodel(var: &datamodel::Variable, owner_model: &str) -> Self { + // The sets the compiler integrates, and what the lists repeat, for the + // `RepeatedStockFlow` advisory. Every salsa reader of a stock's flows + // reads these inputs, so this is where the set is taken for them. let (inflows, outflows) = match var { - datamodel::Variable::Stock(s) => (s.inflows.clone(), s.outflows.clone()), - _ => (Vec::new(), Vec::new()), + datamodel::Variable::Stock(s) => ( + datamodel::distinct_stock_flows(&s.inflows), + datamodel::distinct_stock_flows(&s.outflows), + ), + _ => Default::default(), }; let (module_refs, referenced_model_name) = match var { datamodel::Variable::Module(m) => (m.references.clone(), m.model_name.clone()), @@ -435,8 +443,10 @@ impl SourceVariableFields { datamodel::Variable::Aux(a) => a.gf.clone(), _ => None, }, - inflows, - outflows, + inflows: inflows.flows, + outflows: outflows.flows, + repeated_inflows: inflows.repeated, + repeated_outflows: outflows.repeated, module_refs, referenced_model_name, owner_model: owner_model.to_string(), @@ -472,6 +482,8 @@ fn source_variable_from_datamodel( f.gf, f.inflows, f.outflows, + f.repeated_inflows, + f.repeated_outflows, f.module_refs, f.referenced_model_name, f.owner_model, @@ -481,6 +493,10 @@ fn source_variable_from_datamodel( ) } +#[cfg(test)] +#[path = "sync_flow_list_tests.rs"] +mod flow_list_tests; + // ── Incremental sync ─────────────────────────────────────────────────── /// Update a single `SourceVariable`'s fields via salsa setters, only @@ -521,6 +537,12 @@ fn update_source_variable( if *source_var.outflows(&*db) != f.outflows { source_var.set_outflows(db).to(f.outflows); } + if *source_var.repeated_inflows(&*db) != f.repeated_inflows { + source_var.set_repeated_inflows(db).to(f.repeated_inflows); + } + if *source_var.repeated_outflows(&*db) != f.repeated_outflows { + source_var.set_repeated_outflows(db).to(f.repeated_outflows); + } if *source_var.module_refs(&*db) != f.module_refs { source_var.set_module_refs(db).to(f.module_refs); } diff --git a/src/simlin-engine/src/db/sync_flow_list_tests.rs b/src/simlin-engine/src/db/sync_flow_list_tests.rs new file mode 100644 index 000000000..a0a0a6660 --- /dev/null +++ b/src/simlin-engine/src/db/sync_flow_list_tests.rs @@ -0,0 +1,246 @@ +// Copyright 2026 The Simlin Authors. All rights reserved. +// Use of this source code is governed by the Apache License, +// Version 2.0, that can be found in the LICENSE file. + +//! A stock's inflow and outflow lists are read as sets +//! (`datamodel::distinct_stock_flows`): each flow counts once, at its first +//! occurrence after canonicalization. This is the engine's rule, unverified +//! against Stella or Vensim; XMILE 1.0 section 4.2 never addresses a repeated +//! tag (the owner's rustdoc quotes what it does say). +//! +//! A file can repeat a flow, and the readers store what it says, so every +//! reader that decides what the model does reads the set. Rows, one per such +//! reader: +//! +//! - the salsa sync, for an ordinary stock (`SourceVariableFields::from_datamodel`) +//! - the special-stock build, for a queue and a conveyor, each list +//! (`queue_compile::build_compiled`, before either expansion) +//! - the layout's stock-flow metadata (`layout::compute_metadata`) +//! - the MDL writer's `INTEG` (`mdl::writer::write_stock_variable`) +//! - the patch ops, which store the set (`patch::tests`) +//! +//! and the advisory the sync's record of the repeat raises, once per stock +//! (`RepeatedStockFlow`; exactly once across reaches and revisions in +//! `db::diagnostic_payload_tests::every_warning_family_is_emitted_once_across_revisions`). + +use std::io::BufReader; + +use crate::common::{ErrorCode, Ident}; +use crate::datamodel::{self, SimSpecs}; +use crate::diagnostic::{DiagnosticCategory, DiagnosticSeverity}; +use crate::test_common::TestProject; + +fn three_steps() -> SimSpecs { + SimSpecs { + start: 0.0, + stop: 3.0, + dt: datamodel::Dt::Dt(1.0), + save_step: Some(datamodel::Dt::Dt(1.0)), + sim_method: datamodel::SimMethod::Euler, + time_units: Some("Month".to_string()), + } +} + +fn parse(xml: &str) -> datamodel::Project { + crate::compat::open_xmile(&mut BufReader::new(xml.as_bytes())).expect("imports") +} + +#[test] +fn distinct_stock_flows_keeps_first_occurrences_and_names_repeats() { + let flows: Vec = ["b", "Flow A", "b", "flow_a", "c", "FLOW_A"] + .iter() + .map(|s| s.to_string()) + .collect(); + let set = datamodel::distinct_stock_flows(&flows); + assert_eq!(set.flows, vec!["b", "Flow A", "c"]); + assert_eq!(set.repeated, vec!["b", "flow_a"]); + + let no_repeat = datamodel::distinct_stock_flows(&["x".to_string(), "y".to_string()]); + assert_eq!(no_repeat.flows, vec!["x", "y"]); + assert!(no_repeat.repeated.is_empty()); +} + +#[test] +fn a_duplicated_stock_flow_integrates_once() { + TestProject::new_with_specs("dup", three_steps()) + .flow("f", "1", None) + .flow("g", "0.5", None) + .stock("s", "0", &["f", "f"], &["g", "g"], None) + .assert_vm_result("s", &[0.0, 0.5, 1.0, 1.5]); +} + +#[test] +fn spellings_of_one_flow_are_one_member() { + TestProject::new_with_specs("dup", three_steps()) + .flow("flow_a", "1", None) + .stock("s", "0", &["flow_a", "Flow A", "FLOW_A"], &[], None) + .assert_vm_result("s", &[0.0, 1.0, 2.0, 3.0]); +} + +/// A file carrying a repeated `` imports with the repeat in the +/// datamodel (the reader stores what the file says), and simulates with the +/// flow integrated once. +#[test] +fn a_file_with_a_repeated_inflow_integrates_it_once() { + let project = parse( + r#" + +
dupxp
+ 03
1
+ + + 0ff + 1 + + +
"#, + ); + let stock = project.models[0] + .variables + .iter() + .find_map(|v| match v { + datamodel::Variable::Stock(s) => Some(s), + _ => None, + }) + .expect("stock s"); + assert_eq!( + stock.inflows, + vec!["f".to_string(), "f".to_string()], + "fixture: the datamodel carries the file's repeat" + ); + TestProject::from_datamodel(project).assert_vm_result("s", &[0.0, 1.0, 2.0, 3.0]); +} + +/// Each named series of `project` through `queue_compile::build_sim`, the +/// dispatch every VM-backed production caller funnels through, or the refusal. +fn special_stock_series( + project: &datamodel::Project, + names: &[&str], +) -> Result>, String> { + let mut db = crate::db::SimlinDb::default(); + let source_project = db.sync(project); + let main = project.models[0].name.clone(); + let mut vm = crate::queue_compile::build_sim( + &mut db, + source_project, + project, + &main, + crate::db::LtmOverlay::Off, + ) + .map_err(|e| format!("{e:?}"))?; + vm.run_to_end().map_err(|e| format!("{e:?}"))?; + names + .iter() + .map(|n| { + vm.get_series(&Ident::new(n)) + .ok_or_else(|| format!("no series {n}")) + }) + .collect() +} + +/// Rows: a queue's inflow, a queue's secondary outflow, a conveyor's inflow, +/// a conveyor's outflow. Each file with the tag repeated simulates exactly as +/// the file without the repeat. Without the set `build_compiled` takes, the +/// queue inflow and both conveyor rows fail (the conveyor outflow is refused +/// as a second primary outflow); the queue's secondary outflow is served off +/// the synced inputs and holds either way, so it pins the rule, not the build. +#[test] +fn a_special_stock_whose_file_repeats_a_flow_integrates_it_once() { + const QUEUE: &str = include_str!("../../../../test/queues/minimal_queue.xmile"); + const CONVEYOR: &str = include_str!("../../../../test/conveyors/minimal_conveyor.xmile"); + let rows: [(&str, &str, &str, &[&str]); 4] = [ + ( + "queue inflow", + QUEUE, + "arrivals", + &["waiting", "served"], + ), + ( + "queue secondary outflow", + QUEUE, + "balk", + &["waiting", "served"], + ), + ( + "conveyor inflow", + CONVEYOR, + "matriculating", + &["students", "alumni"], + ), + ( + "conveyor outflow", + CONVEYOR, + "graduating", + &["students", "alumni"], + ), + ]; + let mut differ = Vec::new(); + for (label, file, tag, names) in rows { + assert_eq!( + file.matches(tag).count(), + 1, + "{label}: fixture names the tag once" + ); + let repeated = file.replacen(tag, &format!("{tag}{tag}"), 1); + if special_stock_series(&parse(&repeated), names) + != special_stock_series(&parse(file), names) + { + differ.push(label); + } + } + assert_eq!( + differ, + Vec::<&str>::new(), + "a repeated tag must simulate as the file without it" + ); +} + +#[test] +fn a_stock_list_that_repeats_a_flow_warns_once_naming_the_repeats() { + let project = TestProject::new("repeat") + .flow("f", "1", None) + .flow("g", "1", None) + .stock("s", "0", &["f", "F", "f"], &["g", "g"], None) + .stock("t", "0", &["f"], &[], None) + .build_datamodel(); + let mut db = crate::db::SimlinDb::default(); + let source_project = db.sync(&project); + let diags = crate::db::collect_all_diagnostics(&db, source_project, crate::db::LtmOverlay::Off); + let warnings: Vec<_> = diags + .iter() + .filter(|d| d.is(DiagnosticCategory::Model, ErrorCode::RepeatedStockFlow)) + .collect(); + assert_eq!(warnings.len(), 1, "one warning, for s only: {diags:?}"); + let w = warnings[0]; + assert_eq!(w.severity, DiagnosticSeverity::Warning); + assert_eq!(w.variable.as_deref(), Some("s")); + let reason = w.reason().unwrap_or_default(); + assert!( + reason.contains("inflow list repeats 'f'") && reason.contains("outflow list repeats 'g'"), + "names both lists' repeats: {reason}" + ); +} + +#[test] +fn layout_metadata_reads_a_repeated_flow_once() { + let project = TestProject::new("repeat") + .flow("f", "1", None) + .stock("s", "0", &["f", "f"], &[], None) + .build_datamodel(); + let name = project.models[0].name.clone(); + let metadata = crate::layout::compute_metadata(&project, &name, None).expect("metadata"); + assert_eq!(metadata.stock_to_inflows["s"], vec!["f".to_string()]); +} + +#[test] +fn mdl_export_writes_a_repeated_flow_once() { + let project = TestProject::new("repeat") + .flow("f", "1", None) + .stock("s", "0", &["f", "f"], &[], None) + .build_datamodel(); + let mdl = crate::compat::to_mdl(&project).expect("exports"); + assert!( + mdl.contains("INTEG") && !mdl.contains("f+f") && !mdl.contains("f + f"), + "the INTEG names f once:\n{mdl}" + ); +} diff --git a/src/simlin-engine/src/diagram/flow_geometry.rs b/src/simlin-engine/src/diagram/flow_geometry.rs new file mode 100644 index 000000000..55933fb9b --- /dev/null +++ b/src/simlin-engine/src/diagram/flow_geometry.rs @@ -0,0 +1,1379 @@ +// Copyright 2026 The Simlin Authors. All rights reserved. +// Use of this source code is governed by the Apache License, +// Version 2.0, that can be found in the LICENSE file. + +//! Where a flow pipe meets the elements it connects. +//! +//! Every renderer -- this crate's `diagram`, the TypeScript editor -- draws a +//! stock as a `STOCK_WIDTH` x `STOCK_HEIGHT` box at its center, whatever size +//! the producing tool drew it, and a cloud as a glyph centered on its point. +//! The geometry of a flow is judged against those shapes, at the units of the +//! editor's design plan (`docs/design-plans/2026-09-10-diagram-editing-core.md`, +//! "Units" and "Invariants"). The standing invariants of a flow's points: +//! +//! - every segment is axis-aligned (XMILE 1.0 section 6.1.2, `pts`: "Flows can +//! have any arbitrary number of points, but those points MUST form right +//! angles", `docs/reference/xmile-v1.0.html`); +//! - no segment has zero length and no two consecutive segments are collinear; +//! whenever the terminals leave room (`Minima`), the first segment is at +//! least `MIN_SEGMENT`, every interior segment at least `MIN_SEGMENT`, and the +//! final segment at least `MIN_SINK_SEGMENT`; +//! - the first and last points are attached (to a stock, or to a cloud owned by +//! the flow) and interior points are not; +//! - a stock endpoint lies on a face with at least `CORNER_CLEARANCE` from the +//! corners, and the adjacent segment is perpendicular to that face and leaves +//! outward, so no segment runs through the stock's body; +//! - a cloud endpoint equals the cloud's center; +//! - the valve (the flow's `x`, `y`) lies on the pipe, at least +//! `VALVE_CLAMP_MARGIN` from the path's ends by arc length when the path is at +//! least twice that long. +//! +//! `normalize_flow_geometry` is the one pass that establishes them, run by the +//! importers and as the layout's finishing pass, and `clamp_to_face_span` is +//! the one statement of the corner clearance the layout's endpoint placement +//! also reads. + +use std::collections::{HashMap, HashSet}; + +use crate::datamodel::ViewElement; +use crate::datamodel::view_element::FlowPoint; + +use super::constants::{FLOW_ARROWHEAD_RADIUS, STOCK_HEIGHT, STOCK_WIDTH}; + +/// The minimum distance between a stock endpoint and the nearest corner of +/// the face it sits on. A pipe drawn into a corner reads as attached to two +/// faces at once, and its arrowhead overlaps the stock's outline. +pub(crate) const CORNER_CLEARANCE: f64 = 3.0; + +/// The shortest first segment (a stub out of the source) or interior segment +/// (a riser) a pipe is held to when its terminals leave room. +pub(crate) const MIN_SEGMENT: f64 = 10.0; + +/// The shortest final segment a pipe is held to when its terminals leave room: +/// the arrowhead's radius plus 7.5, so the arrowhead does not swallow it. +pub(crate) const MIN_SINK_SEGMENT: f64 = FLOW_ARROWHEAD_RADIUS + 7.5; + +/// How far the valve is kept from the path's ends, by arc length, when the +/// path is at least twice this long. +pub(crate) const VALVE_CLAMP_MARGIN: f64 = 10.0; + +/// The distance a flow that newly lands on a face keeps from the ends already +/// there, where the face has room: the design plan's routing preference ("a +/// flow that newly lands on a face takes a slot at least `PIPE_SPACING` from +/// existing endpoints on that face if one exists, else the slot maximizing +/// the minimum distance"). A preference of the layout's placement, not an +/// invariant this module establishes. +pub(crate) const PIPE_SPACING: f64 = 10.0; + +/// The segment minimum when the terminals crowd each other. G3 demands no +/// minimum then, so no segment is short for the checker; this is only the +/// shortest segment an attach arm builds, so that no arm leaves a segment of +/// no length, and the length under which `collapse_short_segment` still +/// removes a segment that keeps a stock end off its face. +const CROWDED_MIN_SEGMENT: f64 = 1.0; + +/// How many passes a flow gets to reach a fixed point. A pass can leave what +/// the next one removes -- a slide that shortens a riser the collapse then +/// merges -- so passes repeat until one changes nothing; the bound keeps a +/// pathological input from looping. +const MAX_PASSES: usize = 8; + +/// Tolerance for "same coordinate" and "on the face" comparisons. +const EPS: f64 = 1e-6; + +const HALF_W: f64 = STOCK_WIDTH / 2.0; +const HALF_H: f64 = STOCK_HEIGHT / 2.0; + +/// `v` clamped to the span of a stock face, keeping `CORNER_CLEARANCE` from +/// both corners. `center` is the stock center's coordinate along the face and +/// `half_extent` the stock's half-size in that direction (`STOCK_WIDTH / 2` +/// for the top and bottom faces, `STOCK_HEIGHT / 2` for the left and right). +pub(crate) fn clamp_to_face_span(v: f64, center: f64, half_extent: f64) -> f64 { + let reach = half_extent - CORNER_CLEARANCE; + v.clamp(center - reach, center + reach) +} + +/// The segment minima one flow is held to (G3): `MIN_SEGMENT` for the stub +/// and every riser and `MIN_SINK_SEGMENT` for the final segment, when the +/// terminals leave room -- the source body inflated by `MIN_SEGMENT` and the +/// sink body inflated by `MIN_SINK_SEGMENT` do not overlap, a cloud counting +/// as its point and an unattached end as no terminal at all -- and +/// `CROWDED_MIN_SEGMENT` otherwise. `room` records which: only with room does +/// the checker hold a segment to a minimum. +#[cfg_attr(feature = "debug-derive", derive(Debug))] +#[derive(Clone, Copy)] +struct Minima { + stub: f64, + riser: f64, + sink: f64, + room: bool, +} + +impl Minima { + fn of(points: &[FlowPoint], stocks: &HashMap) -> Minima { + if terminals_leave_room(points, stocks) { + Minima { + stub: MIN_SEGMENT, + riser: MIN_SEGMENT, + sink: MIN_SINK_SEGMENT, + room: true, + } + } else { + Minima { + stub: CROWDED_MIN_SEGMENT, + riser: CROWDED_MIN_SEGMENT, + sink: CROWDED_MIN_SEGMENT, + room: false, + } + } + } + + /// The minimum of segment `i` of an `n`-segment pipe: the final segment is + /// the sink's (a one-segment pipe's included), the first the stub, any + /// other a riser. + fn segment(&self, i: usize, n: usize) -> f64 { + if i + 1 == n { + self.sink + } else if i == 0 { + self.stub + } else { + self.riser + } + } +} + +fn terminals_leave_room(points: &[FlowPoint], stocks: &HashMap) -> bool { + terminals_clear(points, stocks, MIN_SEGMENT, MIN_SINK_SEGMENT) +} + +/// Whether the terminal bodies of `points` -- the source's inflated by +/// `source_inflate`, the sink's by `sink_inflate` -- do not overlap, a cloud +/// counting as its point and an unattached end as no terminal at all. +fn terminals_clear( + points: &[FlowPoint], + stocks: &HashMap, + source_inflate: f64, + sink_inflate: f64, +) -> bool { + let (Some(first), Some(last)) = (points.first(), points.last()) else { + return true; + }; + if first.attached_to_uid.is_none() || last.attached_to_uid.is_none() { + return true; + } + let body = |p: &FlowPoint, inflate: f64| -> (f64, f64, f64, f64) { + let (x, y, hw, hh) = match p.attached_to_uid.and_then(|uid| stocks.get(&uid)) { + Some(&(sx, sy)) => (sx, sy, HALF_W, HALF_H), + None => (p.x, p.y, 0.0, 0.0), + }; + ( + x - hw - inflate, + y - hh - inflate, + x + hw + inflate, + y + hh + inflate, + ) + }; + let a = body(first, source_inflate); + let b = body(last, sink_inflate); + !(a.0 < b.2 && b.0 < a.2 && a.1 < b.3 && b.1 < a.3) +} + +/// The orientation of an axis-aligned segment. +#[cfg_attr(feature = "debug-derive", derive(Debug))] +#[derive(Clone, Copy, PartialEq, Eq)] +enum Axis { + Horizontal, + Vertical, +} + +impl Axis { + fn of_segment(a: &FlowPoint, b: &FlowPoint) -> Option { + let same_y = (a.y - b.y).abs() <= EPS; + let same_x = (a.x - b.x).abs() <= EPS; + match (same_x, same_y) { + (false, true) => Some(Axis::Horizontal), + (true, false) => Some(Axis::Vertical), + _ => None, + } + } + + /// The coordinate that varies along a segment of this orientation. + fn along(self, x: f64, y: f64) -> f64 { + match self { + Axis::Horizontal => x, + Axis::Vertical => y, + } + } + + /// The coordinate that is fixed along a segment of this orientation. + fn cross(self, x: f64, y: f64) -> f64 { + match self { + Axis::Horizontal => y, + Axis::Vertical => x, + } + } + + fn point(self, along: f64, cross: f64, attached_to_uid: Option) -> FlowPoint { + let (x, y) = match self { + Axis::Horizontal => (along, cross), + Axis::Vertical => (cross, along), + }; + FlowPoint { + x, + y, + attached_to_uid, + } + } + + fn set_cross(self, p: &mut FlowPoint, cross: f64) { + match self { + Axis::Horizontal => p.y = cross, + Axis::Vertical => p.x = cross, + } + } + + fn set_along(self, p: &mut FlowPoint, along: f64) { + match self { + Axis::Horizontal => p.x = along, + Axis::Vertical => p.y = along, + } + } + + /// The stock's half-size along a segment of this orientation: the + /// distance from the center to the face such a segment meets + /// perpendicularly. + fn half_along(self) -> f64 { + match self { + Axis::Horizontal => HALF_W, + Axis::Vertical => HALF_H, + } + } + + fn half_cross(self) -> f64 { + match self { + Axis::Horizontal => HALF_H, + Axis::Vertical => HALF_W, + } + } +} + +/// Whether a stock endpoint `p` with adjacent point `q` already satisfies the +/// invariants: on a face with corner clearance, and the segment to `q` +/// perpendicular to that face and leaving outward. +fn stock_endpoint_is_valid(p: &FlowPoint, q: &FlowPoint, stock: (f64, f64)) -> bool { + let dx = p.x - stock.0; + let dy = p.y - stock.1; + let on_side = (dx.abs() - HALF_W).abs() <= EPS && dy.abs() <= HALF_H - CORNER_CLEARANCE + EPS; + let on_cap = (dy.abs() - HALF_H).abs() <= EPS && dx.abs() <= HALF_W - CORNER_CLEARANCE + EPS; + if on_side { + (q.y - p.y).abs() <= EPS && dx.signum() * (q.x - p.x) > EPS + } else if on_cap { + (q.x - p.x).abs() <= EPS && dy.signum() * (q.y - p.y) > EPS + } else { + false + } +} + +/// Whether every stock endpoint of `points` is valid. +fn stock_ends_valid(points: &[FlowPoint], stocks: &HashMap) -> bool { + let n = points.len(); + if n < 2 { + return false; + } + [(0, 1), (n - 1, n - 2)].iter().all(|&(idx, adj)| { + match points[idx].attached_to_uid.and_then(|uid| stocks.get(&uid)) { + Some(&stock) => stock_endpoint_is_valid(&points[idx], &points[adj], stock), + None => true, + } + }) +} + +/// How an end segment's stock endpoint is brought onto the stock. +#[cfg_attr(feature = "debug-derive", derive(Debug))] +#[derive(Clone, Copy, PartialEq)] +enum EndFix { + /// Already valid: the endpoint pins the segment's line where it is. Only + /// when neither a shared line nor a jog can bring the segment's other end + /// in does it give ground, sliding the least it can within its own + /// clearance span, where it stays valid. + Keep, + /// The line passes within the end's minimum segment of the face it + /// approaches, so the pipe enters that face: the line slides into the + /// face's clearance span (never further than that margin) and the endpoint + /// moves along the line onto the face. + Face, + /// The line misses the face by more than that: the line stays, the + /// endpoint becomes a bend above the stock, and a perpendicular leg runs + /// into the face the line runs past. A leg the shared line leaves shorter + /// than the end's minimum segment is removed on the next pass by + /// `collapse_short_segment`. + Leg, + /// A `Face` end whose clearance span excludes the line the segment's + /// other end needs (a valid slot pins it, or the two spans are disjoint): + /// the pipe steps from the shared line to this end's own line (the value) + /// the end's minimum segment short of the face, then enters the face. + Jog(f64), +} + +/// The lines every `Keep` and `Face` end of a segment can live with, except +/// the end at `skip`. A `Keep` end pins the line where it is, or, with +/// `relax_valid`, accepts any line in its own clearance span (it stays valid +/// there). `Leg` ends constrain nothing here: a leg is built from whatever +/// line the others settle on. +fn shared_line_span( + ends: &[StockEnd], + axis: Axis, + line: f64, + skip: Option, + relax_valid: bool, +) -> (f64, f64) { + let reach = axis.half_cross() - CORNER_CLEARANCE; + let (mut lo, mut hi) = (f64::NEG_INFINITY, f64::INFINITY); + for (i, end) in ends.iter().enumerate() { + if Some(i) == skip { + continue; + } + let s_cross = axis.cross(end.stock.0, end.stock.1); + match end.fix { + EndFix::Keep if !relax_valid => { + lo = lo.max(line); + hi = hi.min(line); + } + EndFix::Keep | EndFix::Face => { + lo = lo.max(s_cross - reach); + hi = hi.min(s_cross + reach); + } + EndFix::Leg | EndFix::Jog(_) => {} + } + } + (lo, hi) +} + +#[cfg_attr(feature = "debug-derive", derive(Debug))] +struct StockEnd { + idx: usize, + adj: usize, + stock: (f64, f64), + fix: EndFix, + /// The minimum length of the segment this end's fix creates into the face: + /// the stub's for the source, the sink's for the sink. + min: f64, +} + +fn point_segment_distance(p: (f64, f64), a: &FlowPoint, b: &FlowPoint) -> f64 { + let (dx, dy) = (b.x - a.x, b.y - a.y); + let len2 = dx * dx + dy * dy; + if len2 == 0.0 { + return (p.0 - a.x).hypot(p.1 - a.y); + } + let t = (((p.0 - a.x) * dx + (p.1 - a.y) * dy) / len2).clamp(0.0, 1.0); + (p.0 - (a.x + t * dx)).hypot(p.1 - (a.y + t * dy)) +} + +/// Bring the stock endpoints of one end segment of a pipe onto their stocks. +/// +/// The segment is `points[0..=1]` when `source`, `points[n-2..=n-1]` when +/// `sink`, and the whole pipe when both (a two-point flow, whose two ends share +/// one line). Works on a copy, and commits the result unless it runs more +/// segments through the terminal stocks' bodies than the pipe did, where G6 +/// holds (`adds_a_body_crossing`); an end segment +/// that is diagonal, or whose ends ask for incompatible lines, is left as it +/// was. The valve is not read: `project_valve_onto_pipe`, which runs after +/// the passes, owns where it ends up. +fn attach_end_segment( + points: &mut Vec, + stocks: &HashMap, + minima: &Minima, + source: bool, + sink: bool, +) { + let n = points.len(); + let (a, b) = if source { (0, 1) } else { (n - 2, n - 1) }; + let Some(axis) = Axis::of_segment(&points[a], &points[b]) else { + return; + }; + let line = axis.cross(points[a].x, points[a].y); + let half_cross = axis.half_cross(); + + let mut ends: Vec = Vec::new(); + for (enabled, idx, adj, min) in [ + (source, 0, 1, minima.stub), + (sink, n - 1, n - 2, minima.sink), + ] { + if !enabled { + continue; + } + let Some(&stock) = points[idx].attached_to_uid.and_then(|uid| stocks.get(&uid)) else { + continue; + }; + let fix = if stock_endpoint_is_valid(&points[idx], &points[adj], stock) { + EndFix::Keep + } else if (line - axis.cross(stock.0, stock.1)).abs() < half_cross + min { + EndFix::Face + } else { + EndFix::Leg + }; + ends.push(StockEnd { + idx, + adj, + stock, + fix, + min, + }); + } + if ends.iter().all(|e| e.fix == EndFix::Keep) { + return; + } + + // In order of preference: one line every end lives with (valid slots + // pinned); a jog for one `Face` end, keeping the others' line; and, when + // no jog fits, the least slide of a valid slot within its own clearance + // span. The last moves authored geometry, so it comes last. + let (lo, hi) = shared_line_span(&ends, axis, line, None, false); + let new_line = if lo <= hi + EPS { + line.clamp(lo, hi.max(lo)) + } else { + let reach = half_cross - CORNER_CLEARANCE; + let mut jog = None; + for (i, end) in ends.iter().enumerate() { + if end.fix != EndFix::Face { + continue; + } + let (olo, ohi) = shared_line_span(&ends, axis, line, Some(i), false); + if olo > ohi + EPS { + continue; + } + let shared = line.clamp(olo, ohi.max(olo)); + let s_cross = axis.cross(end.stock.0, end.stock.1); + let own = shared.clamp(s_cross - reach, s_cross + reach); + if (shared - own).abs() >= minima.riser - EPS { + jog = Some((i, shared, own)); + break; + } + } + if let Some((i, shared, own)) = jog { + ends[i].fix = EndFix::Jog(own); + shared + } else { + let (rlo, rhi) = shared_line_span(&ends, axis, line, None, true); + if rlo > rhi + EPS { + return; + } + line.clamp(rlo, rhi.max(rlo)) + } + }; + + let mut pts = points.clone(); + if (new_line - line).abs() > EPS { + // The slide moves the segment perpendicular to itself, so each + // interior neighbour lengthens or shortens along its own axis. Nothing + // is refused for what that does to a neighbour: one shortened under a + // riser is merged by the collapse, and one folded back over its far + // point is a spur the next pass's `simplify` removes. No neighbour is + // collinear with the segment or of no length, which a slide would pull + // off its axis: a pass starts from the simplified pipe. A diagonal + // neighbour stays diagonal, the producer's to resolve. + axis.set_cross(&mut pts[a], new_line); + axis.set_cross(&mut pts[b], new_line); + } + + // Highest index first, so the sink's insertion cannot shift the source. + ends.sort_by_key(|end| std::cmp::Reverse(end.idx)); + for end in &ends { + let s_along = axis.along(end.stock.0, end.stock.1); + let s_cross = axis.cross(end.stock.0, end.stock.1); + let half_along = axis.half_along(); + match end.fix { + EndFix::Keep => {} + EndFix::Face => { + let q_along = axis.along(pts[end.adj].x, pts[end.adj].y); + let side = if q_along > s_along + EPS { + 1.0 + } else if q_along < s_along - EPS { + -1.0 + } else { + return; + }; + let face = s_along + side * half_along; + // The segment the end leaves is held to the end's own minimum. + // A two-point pipe's one segment is the sink's, but while the + // pipe stays two points room already puts its ends further + // apart than both minima, and where the sink's arm has inserted + // points first (ends are fixed highest index first) the source's + // segment is a stub. + if side * (q_along - face) < end.min - EPS { + return; + } + axis.set_along(&mut pts[end.idx], face); + } + EndFix::Leg => { + let p = pts[end.idx].clone(); + let q_along = axis.along(pts[end.adj].x, pts[end.adj].y); + let bend_along = clamp_to_face_span(axis.along(p.x, p.y), s_along, half_along); + if (q_along - bend_along).abs() < minima.riser - EPS { + return; + } + let side = (new_line - s_cross).signum(); + let bend = axis.point(bend_along, new_line, None); + let on_face = + axis.point(bend_along, s_cross + side * half_cross, p.attached_to_uid); + if end.idx == 0 { + pts[0] = bend; + pts.insert(0, on_face); + } else { + let last = pts.len() - 1; + pts[last] = bend; + pts.push(on_face); + } + } + EndFix::Jog(own) => { + let q_along = axis.along(pts[end.adj].x, pts[end.adj].y); + let side = if q_along > s_along + EPS { + 1.0 + } else if q_along < s_along - EPS { + -1.0 + } else { + return; + }; + let face = s_along + side * half_along; + // The step sits the end's minimum segment from the face, and is + // refused only where it would pass the adjacent point and double + // back: no invariant names that route (crowded stocks give such + // inputs), so this guard is what keeps one out. A step that + // leaves the rest of the shared line under a riser is built: + // with room the collapse merges that run on a later pass, and + // with crowded terminals the checker demands no minimum of it. + // The valve is not a point the step must stay behind; it is + // projected onto the settled pipe. + if side * (q_along - face) < end.min - EPS { + return; + } + let step = face + side * end.min; + let attached = pts[end.idx].attached_to_uid; + let on_line = axis.point(step, new_line, None); + let on_own = axis.point(step, own, None); + let on_face = axis.point(face, own, attached); + if end.idx == 0 { + pts[0] = on_line; + pts.insert(0, on_own); + pts.insert(0, on_face); + } else { + let last = pts.len() - 1; + pts[last] = on_line; + pts.push(on_own); + pts.push(on_face); + } + } + } + } + + // The commit guard: where G6 holds, no step may add a segment through a + // terminal stock's body. A leg can drop into one stock's face across the + // other's body, and such a result is not committed. An end an arm leaves + // invalid is committed: the next pass tries that end again from the new + // geometry. + if adds_a_body_crossing(&pts, points, stocks) { + return; + } + *points = pts; +} + +/// Bring both end segments of a pipe onto their stocks: the whole pipe at once +/// for a two-point flow, whose ends share one line. +fn attach_ends(points: &mut Vec, stocks: &HashMap, minima: &Minima) { + if points.len() < 2 { + return; + } + if points.len() == 2 { + attach_end_segment(points, stocks, minima, true, true); + } else { + attach_end_segment(points, stocks, minima, true, false); + attach_end_segment(points, stocks, minima, false, true); + } +} + +/// Drop the points that draw no route: a repeated point (a repeated endpoint +/// keeps its attachment), and an unattached interior point whose two segments +/// lie on one line -- continuing onward, or turning back along the line, where +/// the point is the tip of a spur drawn over itself and the route without the +/// spur is the same route. A removal can leave the new last three points on +/// one line or two points repeated, so the tail is examined again until +/// nothing more drops. +fn simplify(points: &mut Vec) { + let mut out: Vec = Vec::with_capacity(points.len()); + for p in points.drain(..) { + out.push(p); + loop { + let n = out.len(); + if n >= 2 + && (out[n - 2].x - out[n - 1].x).abs() <= EPS + && (out[n - 2].y - out[n - 1].y).abs() <= EPS + { + if let Some(repeat) = out.pop() + && repeat.attached_to_uid.is_some() + { + out[n - 2].attached_to_uid = repeat.attached_to_uid; + } + continue; + } + if n >= 3 && out[n - 2].attached_to_uid.is_none() { + let axis = Axis::of_segment(&out[n - 3], &out[n - 2]); + if axis.is_some() && axis == Axis::of_segment(&out[n - 2], &out[n - 1]) { + out.remove(n - 2); + continue; + } + } + break; + } + } + *points = out; +} + +fn segment_length(a: &FlowPoint, b: &FlowPoint) -> f64 { + (b.x - a.x).hypot(b.y - a.y) +} + +/// Remove the first segment shorter than the minima allow, where the pipe can +/// give it up; returns whether the pipe changed. +/// +/// A segment is short where the checker holds it to a minimum, which is only +/// when the terminals leave room (`Minima::room`). With crowded terminals a +/// segment under `CROWDED_MIN_SEGMENT` is removed only while a stock endpoint +/// is invalid, where the segment is what keeps the attach arms from bringing +/// that end onto its face; a pipe whose ends are valid is never moved for a +/// segment the checker accepts. +/// +/// - A short end segment: the end moves to the bend, so the next segment is +/// the end segment, and the attach arms bring a stock end onto its face. +/// - A short riser between two parallel segments: the later run moves onto +/// the earlier run's line, or the earlier onto the later's, and the attach +/// arms settle the ends the move reaches. A candidate that leaves every +/// valid stock slot where it was is preferred, so the free side of the pipe +/// gives way before an authored slot does. +/// +/// A candidate is not committed when it runs more segments through the bodies +/// of the stocks it ends on than the pipe did, where G6 holds +/// (`adds_a_body_crossing`). It need not leave every +/// stock end valid: the next pass's attach step brings such an end onto its +/// face, and requiring it would refuse routes the passes finish. A pipe of one +/// segment is never short with room to spare: room means its terminals are +/// further apart than `MIN_SEGMENT + MIN_SINK_SEGMENT`. +fn collapse_short_segment( + points: &mut Vec, + stocks: &HashMap, + minima: &Minima, +) -> bool { + let n = points.len(); + if n < 3 || (!minima.room && stock_ends_valid(points, stocks)) { + return false; + } + let segments = n - 1; + let Some(i) = (0..segments).find(|&i| { + let len = segment_length(&points[i], &points[i + 1]); + len > EPS && len < minima.segment(i, segments) - EPS + }) else { + return false; + }; + + let mut candidates: Vec> = Vec::new(); + if i == 0 || i + 1 == segments { + let (end, bend) = if i == 0 { (0, 1) } else { (n - 1, n - 2) }; + let mut pts = points.clone(); + pts[bend].attached_to_uid = pts[end].attached_to_uid; + pts.remove(end); + candidates.push(pts); + } else { + let earlier = Axis::of_segment(&points[i - 1], &points[i]); + let later = Axis::of_segment(&points[i + 1], &points[i + 2]); + if earlier.is_none() || earlier != later { + return false; + } + let axis = earlier.unwrap_or(Axis::Horizontal); + let earlier_line = axis.cross(points[i].x, points[i].y); + let later_line = axis.cross(points[i + 1].x, points[i + 1].y); + let mut onto_earlier = points.clone(); + axis.set_cross(&mut onto_earlier[i + 1], earlier_line); + axis.set_cross(&mut onto_earlier[i + 2], earlier_line); + candidates.push(onto_earlier); + let mut onto_later = points.clone(); + axis.set_cross(&mut onto_later[i - 1], later_line); + axis.set_cross(&mut onto_later[i], later_line); + candidates.push(onto_later); + } + + // The valid stock slots the pipe had, by what they attach to. + let slots: Vec<(Option, (f64, f64))> = [(0, 1), (n - 1, n - 2)] + .iter() + .filter_map(|&(idx, adj)| { + let &stock = points[idx] + .attached_to_uid + .and_then(|uid| stocks.get(&uid))?; + stock_endpoint_is_valid(&points[idx], &points[adj], stock) + .then_some((points[idx].attached_to_uid, (points[idx].x, points[idx].y))) + }) + .collect(); + let mut accepted: Option> = None; + for mut pts in candidates { + simplify(&mut pts); + if pts.len() < 2 { + continue; + } + attach_ends(&mut pts, stocks, minima); + if pts == *points || adds_a_body_crossing(&pts, points, stocks) { + continue; + } + let last = pts.len() - 1; + let keeps_slots = slots.iter().all(|(uid, at)| { + [&pts[0], &pts[last]] + .iter() + .any(|p| p.attached_to_uid == *uid && (p.x, p.y) == *at) + }); + if keeps_slots { + accepted = Some(pts); + break; + } + if accepted.is_none() { + accepted = Some(pts); + } + } + let Some(pts) = accepted else { + return false; + }; + *points = pts; + true +} + +/// Whether `candidate` runs more segments through the bodies of the stocks it +/// ends on than `original` does, where G6 demands no crossing at all: the two +/// terminal bodies, each inflated by `MIN_SEGMENT`, do not overlap (the design +/// plan's G6). Where they overlap, G6 is best effort while G1-G5 must still +/// hold, so a step that brings the ends onto their faces is not refused for +/// running through the other body. A step may keep the crossings a pipe has, +/// but never adds to them. +fn adds_a_body_crossing( + candidate: &[FlowPoint], + original: &[FlowPoint], + stocks: &HashMap, +) -> bool { + terminals_clear(original, stocks, MIN_SEGMENT, MIN_SEGMENT) + && body_crossings(candidate, stocks) > body_crossings(original, stocks) +} + +/// How many (segment, terminal stock) pairs of `points` have the segment +/// entering the stock's body; a stock both ends attach to counts once. +fn body_crossings(points: &[FlowPoint], stocks: &HashMap) -> usize { + let (Some(first), Some(last)) = (points.first(), points.last()) else { + return 0; + }; + let mut terminals: Vec = [first.attached_to_uid, last.attached_to_uid] + .into_iter() + .flatten() + .filter(|uid| stocks.contains_key(uid)) + .collect(); + terminals.dedup(); + terminals + .iter() + .map(|uid| { + let stock = stocks[uid]; + points + .windows(2) + .filter(|w| segment_enters_body(&w[0], &w[1], stock)) + .count() + }) + .sum() +} + +/// Straighten a two-point pipe whose producer wrote it slightly off axis +/// (xmutil's Vensim conversions routinely do), onto the average of the two +/// cross coordinates of its dominant axis; the valve joins that line. Returns +/// whether it moved anything. +fn straighten_two_point_pipe(points: &mut [FlowPoint], valve: &mut (f64, f64)) -> bool { + if points.len() != 2 { + return false; + } + let (dx, dy) = ( + (points[1].x - points[0].x).abs(), + (points[1].y - points[0].y).abs(), + ); + if dx <= EPS || dy <= EPS { + return false; + } + if dx > dy { + let y = (points[0].y + points[1].y) / 2.0; + points[0].y = y; + points[1].y = y; + valve.1 = y; + } else { + let x = (points[0].x + points[1].x) / 2.0; + points[0].x = x; + points[1].x = x; + valve.0 = x; + } + true +} + +/// The point `arc` along `points`, by arc length from the first point. +fn point_at_arc(points: &[FlowPoint], arc: f64) -> (f64, f64) { + let mut remaining = arc; + let segments = points.len().saturating_sub(1); + for (i, w) in points.windows(2).enumerate() { + let len = segment_length(&w[0], &w[1]); + if remaining <= len + EPS || i + 1 == segments { + if len == 0.0 { + return (w[0].x, w[0].y); + } + let t = (remaining / len).clamp(0.0, 1.0); + return ( + w[0].x + (w[1].x - w[0].x) * t, + w[0].y + (w[1].y - w[0].y) * t, + ); + } + remaining -= len; + } + points.last().map_or((0.0, 0.0), |p| (p.x, p.y)) +} + +/// Move the valve onto the pipe: to its nearest point when it is off the pipe, +/// and, on a path at least `2 * VALVE_CLAMP_MARGIN` long, to within the path +/// at least `VALVE_CLAMP_MARGIN` from either end by arc length. A valve that +/// already satisfies both is not moved. +pub(crate) fn project_valve_onto_pipe(points: &[FlowPoint], valve: &mut (f64, f64)) { + if points.len() < 2 { + return; + } + let mut best: Option<(f64, f64)> = None; // (distance, arc position) + let mut traversed = 0.0; + for w in points.windows(2) { + let (a, b) = (&w[0], &w[1]); + let len = segment_length(a, b); + let t = if len == 0.0 { + 0.0 + } else { + (((valve.0 - a.x) * (b.x - a.x) + (valve.1 - a.y) * (b.y - a.y)) / (len * len)) + .clamp(0.0, 1.0) + }; + let d = point_segment_distance(*valve, a, b); + if best.is_none_or(|(bd, _)| d < bd) { + best = Some((d, traversed + t * len)); + } + traversed += len; + } + let Some((d, mut arc)) = best else { + return; + }; + let total = traversed; + let long_enough = total >= 2.0 * VALVE_CLAMP_MARGIN; + let within_margin = !long_enough + || (arc >= VALVE_CLAMP_MARGIN - EPS && arc <= total - VALVE_CLAMP_MARGIN + EPS); + if d <= EPS && within_margin { + return; + } + if long_enough { + arc = arc.clamp(VALVE_CLAMP_MARGIN, total - VALVE_CLAMP_MARGIN); + } + *valve = point_at_arc(points, arc); +} + +/// Keep a valve the layout placed where the editor would leave it and clear of +/// the other flows' pipes: at least `VALVE_CLAMP_MARGIN` from the ends of the +/// segment it sits on (the margin `clampToSegment` in +/// `src/diagram/drawing/Flow.tsx` clamps a dragged valve to along its closest +/// segment), and at least `VALVE_CLAMP_MARGIN` from every pipe in +/// `other_pipes`. A valve that already satisfies both is not moved. Otherwise +/// it moves to the nearest position of the pipe that does (the earlier segment +/// on a tie). Where no segment has one -- every long-enough segment runs within +/// the margin of another pipe -- it keeps the segment margin alone: clamped +/// along its own segment when that one is at least twice the margin long, else +/// at the middle of the longest segment (the first, on a tie) when that one +/// is, and left where it is when every segment is too short. Only for valves +/// the layout owns: an imported valve is held to the arc-length margin alone +/// (`project_valve_onto_pipe`), which this never undoes, since every placement +/// here is at least the margin from the path's ends. +pub(crate) fn settle_laid_out_valve( + points: &[FlowPoint], + valve: &mut (f64, f64), + other_pipes: &[&[FlowPoint]], +) { + let Some((i, _)) = points + .windows(2) + .enumerate() + .map(|(i, w)| (i, point_segment_distance(*valve, &w[0], &w[1]))) + .min_by(|a, b| a.1.total_cmp(&b.1)) + else { + return; + }; + let clear_of_others = |p: (f64, f64)| { + other_pipes.iter().all(|pipe| { + pipe.windows(2) + .all(|w| point_segment_distance(p, &w[0], &w[1]) >= VALVE_CLAMP_MARGIN - EPS) + }) + }; + { + let (a, b) = (&points[i], &points[i + 1]); + if point_segment_distance(*valve, a, b) <= EPS + && segment_length(a, b) >= 2.0 * VALVE_CLAMP_MARGIN + && (valve.0 - a.x).hypot(valve.1 - a.y) >= VALVE_CLAMP_MARGIN - EPS + && (valve.0 - b.x).hypot(valve.1 - b.y) >= VALVE_CLAMP_MARGIN - EPS + && clear_of_others(*valve) + { + return; + } + } + + // The positions of each long-enough segment keeping both margins, as + // intervals of arc length along it; the one nearest the valve wins. + let mut best: Option<((f64, f64), f64)> = None; + for w in points.windows(2) { + let (a, b) = (&w[0], &w[1]); + let len = segment_length(a, b); + if len < 2.0 * VALVE_CLAMP_MARGIN { + continue; + } + let u = ((b.x - a.x) / len, (b.y - a.y) / len); + let mut intervals = vec![(VALVE_CLAMP_MARGIN, len - VALVE_CLAMP_MARGIN)]; + for pipe in other_pipes { + for o in pipe.windows(2) { + let Some((lo, hi)) = + capsule_crossing((a.x, a.y), u, &o[0], &o[1], VALVE_CLAMP_MARGIN) + else { + continue; + }; + intervals = intervals + .into_iter() + .flat_map(|(p, q)| [(p, q.min(lo)), (p.max(hi), q)]) + .filter(|&(p, q)| q >= p - EPS) + .collect(); + } + } + let t_valve = ((valve.0 - a.x) * u.0 + (valve.1 - a.y) * u.1).clamp(0.0, len); + for (p, q) in intervals { + let t = t_valve.clamp(p, q.max(p)); + let at = (a.x + u.0 * t, a.y + u.1 * t); + let d = (at.0 - valve.0).hypot(at.1 - valve.1); + if best.is_none_or(|(_, bd)| d < bd - EPS) { + best = Some((at, d)); + } + } + } + if let Some((at, _)) = best { + *valve = at; + return; + } + + let (a, b) = (&points[i], &points[i + 1]); + let span = |p: f64, q: f64| (p.min(q) + VALVE_CLAMP_MARGIN, p.max(q) - VALVE_CLAMP_MARGIN); + if segment_length(a, b) >= 2.0 * VALVE_CLAMP_MARGIN { + let clamped = match Axis::of_segment(a, b) { + Some(Axis::Horizontal) => { + let (lo, hi) = span(a.x, b.x); + (valve.0.clamp(lo, hi), a.y) + } + Some(Axis::Vertical) => { + let (lo, hi) = span(a.y, b.y); + (a.x, valve.1.clamp(lo, hi)) + } + None => return, + }; + if (clamped.0 - valve.0).abs() > EPS || (clamped.1 - valve.1).abs() > EPS { + *valve = clamped; + } + return; + } + let mut longest: Option<(&FlowPoint, &FlowPoint)> = None; + for w in points.windows(2) { + if longest.is_none_or(|(p, q)| segment_length(&w[0], &w[1]) > segment_length(p, q) + EPS) { + longest = Some((&w[0], &w[1])); + } + } + if let Some((p, q)) = longest + && segment_length(p, q) >= 2.0 * VALVE_CLAMP_MARGIN + { + *valve = ((p.x + q.x) / 2.0, (p.y + q.y) / 2.0); + } +} + +/// The open interval of arc length `t` for which the point `origin + t * u` +/// (`u` a unit vector) lies within `radius` of the segment `q`-`r`, or `None` +/// when the line never comes that close. The points within `radius` of a +/// segment form a convex capsule -- two disks joined by a rectangle -- so the +/// line meets it in one interval, spanning its meetings with the two disks and +/// the rectangle. +fn capsule_crossing( + origin: (f64, f64), + u: (f64, f64), + q: &FlowPoint, + r: &FlowPoint, + radius: f64, +) -> Option<(f64, f64)> { + let mut lo = f64::INFINITY; + let mut hi = f64::NEG_INFINITY; + let mut take = |p: f64, s: f64| { + if s > p { + lo = lo.min(p); + hi = hi.max(s); + } + }; + for c in [(q.x, q.y), (r.x, r.y)] { + // |origin + t u - c|^2 < radius^2 + let (dx, dy) = (origin.0 - c.0, origin.1 - c.1); + let b = dx * u.0 + dy * u.1; + let disc = b * b - (dx * dx + dy * dy - radius * radius); + if disc > 0.0 { + take(-b - disc.sqrt(), -b + disc.sqrt()); + } + } + let len = (r.x - q.x).hypot(r.y - q.y); + if len > EPS { + let v = ((r.x - q.x) / len, (r.y - q.y) / len); + let n = (-v.1, v.0); + let (dx, dy) = (origin.0 - q.x, origin.1 - q.y); + // `lo_bound < c0 + t c1 < hi_bound`, an interval of `t`. + let band = |c0: f64, c1: f64, lo_bound: f64, hi_bound: f64| -> Option<(f64, f64)> { + if c1.abs() <= EPS { + (c0 > lo_bound && c0 < hi_bound).then_some((f64::NEG_INFINITY, f64::INFINITY)) + } else { + let (t0, t1) = ((lo_bound - c0) / c1, (hi_bound - c0) / c1); + Some((t0.min(t1), t0.max(t1))) + } + }; + let across = band(n.0 * dx + n.1 * dy, n.0 * u.0 + n.1 * u.1, -radius, radius); + let along = band(v.0 * dx + v.1 * dy, v.0 * u.0 + v.1 * u.1, 0.0, len); + if let (Some((p0, s0)), Some((p1, s1))) = (across, along) { + take(p0.max(p1), s0.min(s1)); + } + } + (hi > lo).then_some((lo, hi)) +} + +/// Bring every flow in an imported view to the invariants in the module docs. +/// +/// Geometry that already satisfies them -- including an off-center slot a +/// modeler chose on a face -- is not moved. Otherwise, per flow, until a pass +/// changes nothing: repeated and collinear points are dropped (`simplify`); a +/// two-point pipe written off axis is straightened (and the straightening +/// undone when the attach step cannot then bring its ends onto their stocks); +/// each stock endpoint is brought onto its stock as `EndFix` describes; and a +/// segment shorter than the minima allow is collapsed where the pipe can give +/// it up (`collapse_short_segment`). Then an off-pipe valve, +/// or one within the margin of an end, is projected onto the pipe, and each +/// cloud an endpoint is attached to is recentered on that endpoint (clouds are +/// created from a producer's raw endpoints, before any of this, so they are +/// the thing that moves). +/// +/// Unattached endpoints and diagonal multi-point segments are left alone: +/// the importer that produced them owns resolving them. +pub(crate) fn normalize_flow_geometry(elements: &mut [ViewElement]) { + normalize_flow_geometry_where(elements, |_| true); +} + +/// `normalize_flow_geometry` over only the flows `include` selects (by uid). +/// Stocks and clouds are read from the whole view, but only the selected +/// flows and the clouds their ends are attached to move: incremental layout +/// normalizes the flows it creates and leaves every preserved flow as it was. +pub(crate) fn normalize_flow_geometry_where( + elements: &mut [ViewElement], + include: impl Fn(i32) -> bool, +) { + let stocks: HashMap = elements + .iter() + .filter_map(|e| match e { + ViewElement::Stock(s) => Some((s.uid, (s.x, s.y))), + _ => None, + }) + .collect(); + let clouds: HashSet = elements + .iter() + .filter_map(|e| match e { + ViewElement::Cloud(c) => Some(c.uid), + _ => None, + }) + .collect(); + + let mut cloud_centers: HashMap = HashMap::new(); + for elem in elements.iter_mut() { + let ViewElement::Flow(f) = elem else { continue }; + if !include(f.uid) || f.points.len() < 2 { + continue; + } + let mut valve = (f.x, f.y); + for _ in 0..MAX_PASSES { + let before = (f.points.clone(), valve); + let mut simplified = f.points.clone(); + simplify(&mut simplified); + if simplified.len() >= 2 { + f.points = simplified; + } + let minima = Minima::of(&f.points, &stocks); + if straighten_two_point_pipe(&mut f.points, &mut valve) { + attach_ends(&mut f.points, &stocks, &minima); + if !stock_ends_valid(&f.points, &stocks) { + (f.points, valve) = before.clone(); + } + } else { + attach_ends(&mut f.points, &stocks, &minima); + } + collapse_short_segment(&mut f.points, &stocks, &minima); + if (f.points.clone(), valve) == before { + break; + } + } + project_valve_onto_pipe(&f.points, &mut valve); + (f.x, f.y) = valve; + + let last = f.points.len() - 1; + for end in [0, last] { + let p = &f.points[end]; + if let Some(uid) = p.attached_to_uid + && clouds.contains(&uid) + { + cloud_centers.insert(uid, (p.x, p.y)); + } + } + } + for elem in elements.iter_mut() { + if let ViewElement::Cloud(c) = elem + && let Some(&(x, y)) = cloud_centers.get(&c.uid) + { + c.x = x; + c.y = y; + } + } +} + +/// The invariants above as a checker, one message per violation, for tests of +/// every producer of flow geometry (the importers and the layout). It is the +/// test oracle, stated independently of the code that establishes the +/// invariants, and follows the editor's checker for the same invariants +/// (`docs/design-plans/2026-09-10-diagram-editing-core.md`, "Invariants"). +#[cfg(test)] +pub(crate) fn flow_invariant_violations(elements: &[ViewElement]) -> Vec { + let by_uid: HashMap = elements.iter().map(|e| (e.get_uid(), e)).collect(); + let mut out = Vec::new(); + for elem in elements { + let ViewElement::Flow(f) = elem else { continue }; + let name = &f.name; + let pts = &f.points; + if pts.len() < 2 { + out.push(format!("{name}: fewer than two points")); + continue; + } + for (i, w) in pts.windows(2).enumerate() { + let (dx, dy) = ((w[0].x - w[1].x).abs(), (w[0].y - w[1].y).abs()); + if dx <= EPS && dy <= EPS { + out.push(format!("{name}: segment {i} has zero length")); + } else if dx > EPS && dy > EPS { + out.push(format!("{name}: segment {i} is diagonal")); + } + } + for i in 0..pts.len().saturating_sub(2) { + let horizontal = + |a: &FlowPoint, b: &FlowPoint| (a.y - b.y).abs() <= EPS && (a.x - b.x).abs() > EPS; + let vertical = + |a: &FlowPoint, b: &FlowPoint| (a.x - b.x).abs() <= EPS && (a.y - b.y).abs() > EPS; + let (a, b, c) = (&pts[i], &pts[i + 1], &pts[i + 2]); + if (horizontal(a, b) && horizontal(b, c)) || (vertical(a, b) && vertical(b, c)) { + out.push(format!("{name}: segments {i} and {} are collinear", i + 1)); + } + } + for (i, p) in pts.iter().enumerate().take(pts.len() - 1).skip(1) { + if p.attached_to_uid.is_some() { + out.push(format!("{name}: interior point {i} is attached")); + } + } + let last = pts.len() - 1; + let mut end_stocks: Vec<(f64, f64)> = Vec::new(); + let mut bodies: Vec> = Vec::new(); + for (end, adj) in [(0, 1), (last, last - 1)] { + let p = &pts[end]; + let q = &pts[adj]; + let Some(uid) = p.attached_to_uid else { + out.push(format!("{name}: endpoint {end} is unattached")); + bodies.push(None); + continue; + }; + match by_uid.get(&uid) { + Some(ViewElement::Stock(s)) => { + end_stocks.push((s.x, s.y)); + bodies.push(Some(( + s.x - HALF_W, + s.y - HALF_H, + s.x + HALF_W, + s.y + HALF_H, + ))); + if let Some(problem) = stock_endpoint_problem(p, q, (s.x, s.y)) { + out.push(format!( + "{name}: endpoint {end} on stock {}: {problem}", + s.name + )); + } + } + Some(ViewElement::Cloud(c)) => { + bodies.push(Some((c.x, c.y, c.x, c.y))); + if (c.x - p.x).abs() > EPS || (c.y - p.y).abs() > EPS { + out.push(format!( + "{name}: endpoint {end} at ({}, {}) but its cloud is at ({}, {})", + p.x, p.y, c.x, c.y + )); + } + if c.flow_uid != f.uid { + out.push(format!( + "{name}: endpoint {end}'s cloud belongs to another flow" + )); + } + } + Some(_) => { + bodies.push(None); + out.push(format!("{name}: endpoint {end} attached to a non-stock")); + } + None => { + bodies.push(None); + out.push(format!("{name}: endpoint {end} attached to a missing uid")); + } + } + } + for s in &end_stocks { + for (i, w) in pts.windows(2).enumerate() { + if segment_enters_body(&w[0], &w[1], *s) { + out.push(format!( + "{name}: segment {i} runs through an endpoint stock" + )); + } + } + } + // G3's minima, demanded when the source body inflated by MIN_SEGMENT + // and the sink body inflated by MIN_SINK_SEGMENT do not overlap. + let room = match (bodies[0], bodies[1]) { + (Some(a), Some(b)) => { + let a = ( + a.0 - MIN_SEGMENT, + a.1 - MIN_SEGMENT, + a.2 + MIN_SEGMENT, + a.3 + MIN_SEGMENT, + ); + let b = ( + b.0 - MIN_SINK_SEGMENT, + b.1 - MIN_SINK_SEGMENT, + b.2 + MIN_SINK_SEGMENT, + b.3 + MIN_SINK_SEGMENT, + ); + !(a.0 < b.2 && b.0 < a.2 && a.1 < b.3 && b.1 < a.3) + } + _ => true, + }; + if room { + let segments = pts.len() - 1; + for (i, w) in pts.windows(2).enumerate() { + let len = (w[1].x - w[0].x).hypot(w[1].y - w[0].y); + if len <= EPS { + continue; + } + let (kind, min) = if i + 1 == segments { + ("final segment", MIN_SINK_SEGMENT) + } else if i == 0 { + ("first segment", MIN_SEGMENT) + } else { + ("interior segment", MIN_SEGMENT) + }; + if len < min - EPS { + out.push(format!("{name}: {kind} {i} is {len:.2}, under {min}")); + } + } + } + let mut best: Option<(f64, f64)> = None; + let mut traversed = 0.0; + for w in pts.windows(2) { + let len = (w[1].x - w[0].x).hypot(w[1].y - w[0].y); + let d = point_segment_distance((f.x, f.y), &w[0], &w[1]); + let along = (f.x - w[0].x).hypot(f.y - w[0].y).min(len); + if best.is_none_or(|(bd, _)| d < bd) { + best = Some((d, traversed + along)); + } + traversed += len; + } + match best { + Some((d, _)) if d > EPS => { + out.push(format!("{name}: valve ({}, {}) is off the pipe", f.x, f.y)); + } + Some((_, arc)) if traversed >= 2.0 * VALVE_CLAMP_MARGIN => { + let from_end = arc.min(traversed - arc); + if from_end < VALVE_CLAMP_MARGIN - EPS { + out.push(format!( + "{name}: valve {from_end:.2} from an end of the path, under {VALVE_CLAMP_MARGIN}" + )); + } + } + _ => {} + } + } + // A cloud drawn inside a stock's box reads as part of the stock. + for elem in elements { + let ViewElement::Cloud(c) = elem else { + continue; + }; + for other in elements { + if let ViewElement::Stock(s) = other + && (c.x - s.x).abs() < HALF_W - EPS + && (c.y - s.y).abs() < HALF_H - EPS + { + out.push(format!( + "cloud {} at ({}, {}) is inside stock {}", + c.uid, c.x, c.y, s.name + )); + } + } + } + out +} + +#[cfg(test)] +fn stock_endpoint_problem(p: &FlowPoint, q: &FlowPoint, stock: (f64, f64)) -> Option { + let dx = p.x - stock.0; + let dy = p.y - stock.1; + let on_side = (dx.abs() - HALF_W).abs() <= EPS && dy.abs() <= HALF_H + EPS; + let on_cap = (dy.abs() - HALF_H).abs() <= EPS && dx.abs() <= HALF_W + EPS; + if on_side && !on_cap { + if HALF_H - dy.abs() < CORNER_CLEARANCE - EPS { + return Some(format!("corner clearance {:.2}", HALF_H - dy.abs())); + } + let outward = dx.signum() * (q.x - p.x); + if (q.y - p.y).abs() > EPS || outward <= EPS { + return Some("adjacent segment is not perpendicular and outward".to_string()); + } + None + } else if on_cap && !on_side { + if HALF_W - dx.abs() < CORNER_CLEARANCE - EPS { + return Some(format!("corner clearance {:.2}", HALF_W - dx.abs())); + } + let outward = dy.signum() * (q.y - p.y); + if (q.x - p.x).abs() > EPS || outward <= EPS { + return Some("adjacent segment is not perpendicular and outward".to_string()); + } + None + } else if on_side && on_cap { + Some("at a corner".to_string()) + } else { + Some(format!("off the faces, offset ({dx:.2}, {dy:.2})")) + } +} + +/// Whether the axis-aligned segment `a`-`b` enters the open body of the stock +/// centered at `stock`: its fixed coordinate strictly inside the body's span +/// and its extent overlapping the other span. A diagonal segment is not +/// judged here (the checker reports it separately, and no step builds one). +fn segment_enters_body(a: &FlowPoint, b: &FlowPoint, stock: (f64, f64)) -> bool { + let (xmin, xmax) = (stock.0 - HALF_W + EPS, stock.0 + HALF_W - EPS); + let (ymin, ymax) = (stock.1 - HALF_H + EPS, stock.1 + HALF_H - EPS); + if (a.y - b.y).abs() <= EPS { + let (lo, hi) = (a.x.min(b.x), a.x.max(b.x)); + a.y > ymin && a.y < ymax && hi > xmin && lo < xmax + } else if (a.x - b.x).abs() <= EPS { + let (lo, hi) = (a.y.min(b.y), a.y.max(b.y)); + a.x > xmin && a.x < xmax && hi > ymin && lo < ymax + } else { + false + } +} + +#[cfg(test)] +#[path = "flow_geometry_tests.rs"] +mod tests; diff --git a/src/simlin-engine/src/diagram/flow_geometry_tests.rs b/src/simlin-engine/src/diagram/flow_geometry_tests.rs new file mode 100644 index 000000000..f114e646f --- /dev/null +++ b/src/simlin-engine/src/diagram/flow_geometry_tests.rs @@ -0,0 +1,1540 @@ +// Copyright 2026 The Simlin Authors. All rights reserved. +// Use of this source code is governed by the Apache License, +// Version 2.0, that can be found in the LICENSE file. + +//! Every arm of `normalize_flow_geometry`: `EndFix::{Keep, Face, Leg, Jog}` on +//! both axes and both ends, the line constraints that make a segment +//! unsolvable, a slide's interior neighbours, `simplify` and the simplified +//! pipe each pass starts from, the collapse of a segment under the minima +//! (with room, and while crowded ends are invalid), the rule that no step adds +//! a segment through a terminal stock's body, straightening and its undo, +//! valve projection by arc length, cloud recentering, idempotence and identity +//! on a view the checker accepts; and the checker's segment minima and valve +//! margin. +//! +//! These build datamodel views by hand. That is the pass's contract: it takes +//! any datamodel view, whatever produced it. What the importers actually hand +//! it is pinned separately, through the production readers on corpus files +//! (`xmile::views::flow_geometry_tests`, `mdl::view::convert::flow_geometry_tests`). + +use super::*; +use crate::datamodel::view_element::{Cloud, Flow, LabelSide, Stock}; + +fn stock(uid: i32, x: f64, y: f64) -> ViewElement { + ViewElement::Stock(Stock { + name: format!("s{uid}"), + uid, + x, + y, + label_side: LabelSide::Top, + compat: None, + }) +} + +fn cloud(uid: i32, flow_uid: i32, x: f64, y: f64) -> ViewElement { + ViewElement::Cloud(Cloud { + uid, + flow_uid, + x, + y, + compat: None, + }) +} + +fn pt(x: f64, y: f64, attached: Option) -> FlowPoint { + FlowPoint { + x, + y, + attached_to_uid: attached, + } +} + +fn flow(uid: i32, valve: (f64, f64), points: Vec) -> ViewElement { + ViewElement::Flow(Flow { + name: format!("f{uid}"), + uid, + x: valve.0, + y: valve.1, + label_side: LabelSide::Bottom, + points, + compat: None, + label_compat: None, + }) +} + +fn the_flow(elements: &[ViewElement]) -> &Flow { + elements + .iter() + .find_map(|e| match e { + ViewElement::Flow(f) => Some(f), + _ => None, + }) + .unwrap() +} + +fn coords(f: &Flow) -> Vec<(f64, f64)> { + f.points.iter().map(|p| (p.x, p.y)).collect() +} + +fn normalized(mut elements: Vec) -> Vec { + normalize_flow_geometry(&mut elements); + assert_eq!( + flow_invariant_violations(&elements), + Vec::::new(), + "normalization must establish the invariants" + ); + elements +} + +#[test] +fn keep_leaves_valid_endpoints_on_every_face_untouched() { + // Off-center slots on all four faces of a stock at (100, 100). + let cases = [ + vec![pt(122.5, 90.0, Some(1)), pt(200.0, 90.0, Some(3))], + vec![pt(20.0, 108.0, Some(3)), pt(77.5, 108.0, Some(1))], + vec![pt(88.0, 82.5, Some(1)), pt(88.0, 20.0, Some(3))], + vec![pt(110.0, 200.0, Some(3)), pt(110.0, 117.5, Some(1))], + ]; + for points in cases { + let (a, b) = (&points[0], &points[1]); + let valve = ((a.x + b.x) / 2.0, (a.y + b.y) / 2.0); + let cloud_end = if a.attached_to_uid == Some(3) { a } else { b }; + let elements = normalized(vec![ + stock(1, 100.0, 100.0), + cloud(3, 2, cloud_end.x, cloud_end.y), + flow(2, valve, points.clone()), + ]); + let f = the_flow(&elements); + assert_eq!( + coords(f), + points.iter().map(|p| (p.x, p.y)).collect::>() + ); + assert_eq!((f.x, f.y), valve); + } +} + +#[test] +fn face_snaps_an_endpoint_inside_the_clearance_span_along_its_line() { + // Horizontal: an endpoint at the stock's center column (a producer that + // anchors endpoints to centers) moves out to the face the pipe approaches. + let elements = normalized(vec![ + stock(1, 100.0, 100.0), + cloud(3, 2, 250.0, 108.0), + flow( + 2, + (180.0, 108.0), + vec![pt(100.0, 108.0, Some(1)), pt(250.0, 108.0, Some(3))], + ), + ]); + assert_eq!( + coords(the_flow(&elements)), + vec![(122.5, 108.0), (250.0, 108.0)] + ); + + // Vertical, pipe leaving upward from beyond the face. + let elements = normalized(vec![ + stock(1, 100.0, 100.0), + cloud(3, 2, 95.0, 10.0), + flow( + 2, + (95.0, 50.0), + vec![pt(95.0, 10.0, Some(3)), pt(95.0, 60.0, Some(1))], + ), + ]); + assert_eq!( + coords(the_flow(&elements)), + vec![(95.0, 10.0), (95.0, 82.5)] + ); +} + +#[test] +fn face_slides_a_line_in_the_corner_zone_and_carries_valve_and_cloud() { + // Line 16 below center: past the 14.5 clearance span but within a stub + // (MIN_SEGMENT) of the 17.5 face line, so the pipe enters the side face: + // it slides up by 1.5, and the valve and the cloud move with it. + let elements = normalized(vec![ + stock(1, 100.0, 100.0), + cloud(3, 2, 250.0, 116.0), + flow( + 2, + (180.0, 116.0), + vec![pt(122.5, 116.0, Some(1)), pt(250.0, 116.0, Some(3))], + ), + ]); + let f = the_flow(&elements); + assert_eq!(coords(f), vec![(122.5, 114.5), (250.0, 114.5)]); + assert_eq!((f.x, f.y), (180.0, 114.5)); + + // The vertical axis: a pipe on the bottom face 22 right of center (0.5 + // from the corner) slides left to 19.5. + let elements = normalized(vec![ + stock(1, 100.0, 100.0), + cloud(3, 2, 122.0, 250.0), + flow( + 2, + (122.0, 200.0), + vec![pt(122.0, 117.5, Some(1)), pt(122.0, 250.0, Some(3))], + ), + ]); + assert_eq!( + coords(the_flow(&elements)), + vec![(119.5, 117.5), (119.5, 250.0)] + ); +} + +#[test] +fn leg_keeps_a_far_line_and_adds_a_perpendicular_leg_into_the_passed_face() { + // Horizontal line 55 above the center, endpoint anchored at the center + // column: the endpoint becomes a bend and a leg drops into the top face. + let elements = normalized(vec![ + stock(1, 100.0, 100.0), + cloud(3, 2, 300.0, 45.0), + flow( + 2, + (200.0, 45.0), + vec![pt(100.0, 45.0, Some(1)), pt(300.0, 45.0, Some(3))], + ), + ]); + let f = the_flow(&elements); + assert_eq!(coords(f), vec![(100.0, 82.5), (100.0, 45.0), (300.0, 45.0)]); + assert_eq!(f.points[0].attached_to_uid, Some(1)); + assert_eq!(f.points[1].attached_to_uid, None); + + // Vertical line far left of the stock (a sketch whose pipe corner sits + // off to the side): the bend row is clamped into the face span and a + // horizontal leg runs into the left face. + let elements = normalized(vec![ + stock(1, 600.0, 400.0), + cloud(3, 2, 30.0, 100.0), + flow( + 2, + (30.0, 200.0), + vec![pt(30.0, 100.0, Some(3)), pt(30.0, 440.0, Some(1))], + ), + ]); + assert_eq!( + coords(the_flow(&elements)), + vec![(30.0, 100.0), (30.0, 414.5), (577.5, 414.5)] + ); +} + +#[test] +fn leg_at_both_ends_routes_over_two_stocks() { + let elements = normalized(vec![ + stock(1, 100.0, 100.0), + stock(3, 400.0, 100.0), + flow( + 2, + (250.0, 45.0), + vec![pt(100.0, 45.0, Some(1)), pt(400.0, 45.0, Some(3))], + ), + ]); + assert_eq!( + coords(the_flow(&elements)), + vec![(100.0, 82.5), (100.0, 45.0), (400.0, 45.0), (400.0, 82.5)] + ); +} + +#[test] +fn the_face_rule_takes_the_line_within_a_stub_of_the_face() { + // 27 below center: less than 17.5 + MIN_SEGMENT, so Face (slide to 14.5), + // not a leg shorter than a stub. + let elements = normalized(vec![ + stock(1, 100.0, 100.0), + cloud(3, 2, 250.0, 127.0), + flow( + 2, + (180.0, 127.0), + vec![pt(100.0, 127.0, Some(1)), pt(250.0, 127.0, Some(3))], + ), + ]); + assert_eq!( + coords(the_flow(&elements)), + vec![(122.5, 114.5), (250.0, 114.5)] + ); + + // 27.5 below center: exactly 17.5 + MIN_SEGMENT, so a stub-long leg. + let elements = normalized(vec![ + stock(1, 100.0, 100.0), + cloud(3, 2, 250.0, 127.5), + flow( + 2, + (180.0, 127.5), + vec![pt(100.0, 127.5, Some(1)), pt(250.0, 127.5, Some(3))], + ), + ]); + assert_eq!( + coords(the_flow(&elements)), + vec![(100.0, 117.5), (100.0, 127.5), (250.0, 127.5)] + ); +} + +#[test] +fn two_stock_ends_share_one_line() { + // Both ends Face (each anchored at its stock's center column) with + // overlapping spans, [85.5, 114.5] and [95.5, 124.5]: the line is clamped + // into both. + let elements = normalized(vec![ + stock(1, 100.0, 100.0), + stock(3, 400.0, 110.0), + flow( + 2, + (250.0, 118.0), + vec![pt(100.0, 118.0, Some(1)), pt(400.0, 118.0, Some(3))], + ), + ]); + assert_eq!( + coords(the_flow(&elements)), + vec![(122.5, 114.5), (377.5, 114.5)] + ); +} + +/// A valid end pins the line; a Face end whose span excludes that line jogs +/// to its own line, stepping the end's minimum segment short of its face (a +/// sink's for the sink, a stub's for the source), rather than moving the +/// modeler's valid slot. The riser is at least `MIN_SEGMENT`. +#[test] +fn a_face_end_jogs_to_its_own_line_when_a_valid_slot_pins_the_other() { + let elements = normalized(vec![ + stock(1, 100.0, 100.0), + stock(3, 400.0, 140.0), + flow( + 2, + (250.0, 110.0), + vec![pt(122.5, 110.0, Some(1)), pt(377.5, 110.0, Some(3))], + ), + ]); + let f = the_flow(&elements); + assert_eq!( + coords(f), + vec![ + (122.5, 110.0), + (362.0, 110.0), + (362.0, 125.5), + (377.5, 125.5) + ] + ); + assert_eq!((f.x, f.y), (250.0, 110.0)); + + // Two Face ends with disjoint spans ([85.5, 114.5] and [130.5, 159.5]): + // the ends are tried source first, so the line takes the sink's span + // (116 -> 130.5, valve carried along) and the source jogs a stub from its + // face. + let elements = normalized(vec![ + stock(1, 100.0, 100.0), + stock(3, 400.0, 145.0), + flow( + 2, + (250.0, 116.0), + vec![pt(100.0, 116.0, Some(1)), pt(400.0, 116.0, Some(3))], + ), + ]); + let f = the_flow(&elements); + assert_eq!( + coords(f), + vec![ + (122.5, 114.5), + (132.5, 114.5), + (132.5, 130.5), + (377.5, 130.5) + ] + ); + assert_eq!((f.x, f.y), (250.0, 130.5)); +} + +/// A valid end pins the line, and the Face end's own line is within a riser +/// (MIN_SEGMENT) of it, so no jog is possible. As the last resort the +/// valid slot slides within its own clearance span by the least amount that +/// lets the other end in, and stays valid. +#[test] +fn a_valid_slot_gives_up_the_least_it_can_when_no_jog_fits() { + // Source valid 4.5 from its right corner (dx = 15); the sink sits 2.5 + // from a corner and needs x >= 305.5 on a segment pinned at 305. + let elements = normalized(vec![ + stock(1, 320.0, 945.0), + stock(3, 325.0, 595.0), + flow( + 2, + (305.0, 755.0), + vec![pt(305.0, 927.5, Some(1)), pt(305.0, 612.5, Some(3))], + ), + ]); + let f = the_flow(&elements); + assert_eq!(coords(f), vec![(305.5, 927.5), (305.5, 612.5)]); + assert_eq!((f.x, f.y), (305.5, 755.0)); +} + +/// Two Face ends whose clearance spans are disjoint by less than +/// a riser, MIN_SEGMENT ([85.5, 114.5] and [116, 145]): no shared line, no jog +/// long enough, and no valid slot to relax, so nothing moves. +#[test] +fn an_unsolvable_segment_is_left_unchanged() { + let original = vec![pt(100.0, 115.0, Some(1)), pt(400.0, 115.0, Some(3))]; + let mut elements = vec![ + stock(1, 100.0, 100.0), + stock(3, 400.0, 130.5), + flow(2, (250.0, 115.0), original.clone()), + ]; + normalize_flow_geometry(&mut elements); + assert_eq!( + coords(the_flow(&elements)), + original.iter().map(|p| (p.x, p.y)).collect::>() + ); +} + +#[test] +fn a_slide_moves_an_interior_neighbour_only_along_its_perpendicular_segment() { + // Source on the bottom face 0.5 from the corner: the first segment slides + // 2.5 left into the clearance span; its neighbour's horizontal segment + // lengthens and the rest of the pipe is untouched. + let elements = normalized(vec![ + stock(1, 100.0, 100.0), + cloud(3, 2, 300.0, 160.0), + flow( + 2, + (200.0, 160.0), + vec![ + pt(122.0, 117.5, Some(1)), + pt(122.0, 160.0, None), + pt(300.0, 160.0, Some(3)), + ], + ), + ]); + assert_eq!( + coords(the_flow(&elements)), + vec![(119.5, 117.5), (119.5, 160.0), (300.0, 160.0)] + ); + + // A collinear interior point is dropped before the slide (a pass starts + // from the simplified pipe), so the neighbour the slide moves is the + // merged segment's far point and nothing is pulled off its axis. + let elements = normalized(vec![ + stock(1, 100.0, 100.0), + cloud(3, 2, 300.0, 200.0), + flow( + 2, + (200.0, 200.0), + vec![ + pt(122.0, 117.5, Some(1)), + pt(122.0, 160.0, None), + pt(122.0, 200.0, None), + pt(300.0, 200.0, Some(3)), + ], + ), + ]); + assert_eq!( + coords(the_flow(&elements)), + vec![(119.5, 117.5), (119.5, 200.0), (300.0, 200.0)] + ); +} + +#[test] +fn straightening_averages_the_dominant_axis_and_carries_the_valve() { + let elements = normalized(vec![ + cloud(3, 2, 10.0, 100.0), + cloud(4, 2, 110.0, 106.0), + flow( + 2, + (60.0, 101.0), + vec![pt(10.0, 100.0, Some(3)), pt(110.0, 106.0, Some(4))], + ), + ]); + let f = the_flow(&elements); + assert_eq!(coords(f), vec![(10.0, 103.0), (110.0, 103.0)]); + assert_eq!((f.x, f.y), (60.0, 103.0)); + + let elements = normalized(vec![ + cloud(3, 2, 50.0, 10.0), + cloud(4, 2, 54.0, 110.0), + flow( + 2, + (51.0, 60.0), + vec![pt(50.0, 10.0, Some(3)), pt(54.0, 110.0, Some(4))], + ), + ]); + let f = the_flow(&elements); + assert_eq!(coords(f), vec![(52.0, 10.0), (52.0, 110.0)]); + assert_eq!((f.x, f.y), (52.0, 60.0)); +} + +/// Rows: off the pipe beyond an end; on the pipe within the margin of an end; +/// on the pipe outside the margin; across a bend (the margin is arc length +/// from the path's ends, not from a segment's); a path too short for the +/// margin. +#[test] +fn an_off_pipe_valve_is_projected_with_a_margin() { + let straight = vec![pt(0.0, 100.0, Some(3)), pt(100.0, 100.0, Some(4))]; + let bent = vec![ + pt(0.0, 0.0, Some(3)), + pt(0.0, 100.0, None), + pt(30.0, 100.0, Some(4)), + ]; + let short = vec![pt(0.0, 100.0, Some(3)), pt(15.0, 100.0, Some(4))]; + /// (label, path, valve before, valve after). + type Row<'a> = (&'a str, &'a [FlowPoint], (f64, f64), (f64, f64)); + let rows: [Row; 6] = [ + ( + "off the pipe beyond an end", + &straight, + (130.0, 80.0), + (90.0, 100.0), + ), + ( + "on the pipe within the margin", + &straight, + (3.0, 100.0), + (10.0, 100.0), + ), + ( + "on the pipe outside the margin", + &straight, + (30.0, 100.0), + (30.0, 100.0), + ), + ( + "near a bend, far from the ends", + &bent, + (0.0, 97.0), + (0.0, 97.0), + ), + ( + "near the far end of a bent path", + &bent, + (27.0, 100.0), + (20.0, 100.0), + ), + ( + "a path shorter than two margins", + &short, + (2.0, 100.0), + (2.0, 100.0), + ), + ]; + for (label, points, valve, expected) in rows { + let mut v = valve; + project_valve_onto_pipe(points, &mut v); + assert_eq!(v, expected, "{label}"); + } +} + +#[test] +fn clouds_move_to_their_endpoints_and_nothing_else_moves_them() { + let elements = normalized(vec![ + stock(1, 100.0, 100.0), + cloud(3, 2, 240.0, 93.0), + flow( + 2, + (180.0, 100.0), + vec![pt(122.5, 100.0, Some(1)), pt(250.0, 100.0, Some(3))], + ), + ]); + let c = elements + .iter() + .find_map(|e| match e { + ViewElement::Cloud(c) => Some(c), + _ => None, + }) + .unwrap(); + assert_eq!((c.x, c.y), (250.0, 100.0)); +} + +/// Rows, one per arm of `collapse_short_segment`: a short stub at a cloud end +/// (the end moves to the bend); a short stub at a stock end (the end moves to +/// the bend and the Face arm brings it onto the face); a short riser whose +/// later run can move onto the earlier run's line; the same riser with the +/// pipe reversed, where moving the later run would move a valid stock slot and +/// the earlier (free) run moves instead. +#[test] +fn a_short_segment_collapses_where_the_pipe_can_give_it_up() { + let elements = normalized(vec![ + stock(1, 300.0, 116.0), + cloud(3, 2, 0.0, 100.0), + flow( + 2, + (150.0, 102.0), + vec![ + pt(0.0, 100.0, Some(3)), + pt(0.0, 102.0, None), + pt(277.5, 102.0, Some(1)), + ], + ), + ]); + let f = the_flow(&elements); + assert_eq!(coords(f), vec![(0.0, 102.0), (277.5, 102.0)], "cloud stub"); + assert_eq!((f.x, f.y), (150.0, 102.0), "cloud stub"); + + let elements = normalized(vec![ + stock(1, 100.0, 100.0), + cloud(3, 2, 300.0, 120.0), + flow( + 2, + (200.0, 120.0), + vec![ + pt(100.0, 117.5, Some(1)), + pt(100.0, 120.0, None), + pt(300.0, 120.0, Some(3)), + ], + ), + ]); + let f = the_flow(&elements); + assert_eq!( + coords(f), + vec![(122.5, 114.5), (300.0, 114.5)], + "stock stub" + ); + assert_eq!((f.x, f.y), (200.0, 114.5), "stock stub"); + + let elements = normalized(vec![ + stock(1, 100.0, 100.0), + stock(3, 400.0, 100.0), + flow( + 2, + (250.0, 101.0), + vec![ + pt(122.5, 100.0, Some(1)), + pt(250.0, 100.0, None), + pt(250.0, 102.0, None), + pt(377.5, 102.0, Some(3)), + ], + ), + ]); + let f = the_flow(&elements); + assert_eq!( + coords(f), + vec![(122.5, 100.0), (377.5, 100.0)], + "riser, later run moves" + ); + assert_eq!((f.x, f.y), (250.0, 100.0), "riser, later run moves"); + + let elements = normalized(vec![ + stock(1, 100.0, 100.0), + cloud(3, 2, 400.0, 102.0), + flow( + 2, + (300.0, 102.0), + vec![ + pt(400.0, 102.0, Some(3)), + pt(250.0, 102.0, None), + pt(250.0, 100.0, None), + pt(122.5, 100.0, Some(1)), + ], + ), + ]); + assert_eq!( + coords(the_flow(&elements)), + vec![(400.0, 100.0), (122.5, 100.0)], + "riser, earlier run moves to keep the valid slot" + ); +} + +/// Stocks at (100, 100) and (300, 130): the straightened line (115) is outside +/// both clearance spans by 0.5, no jog is a riser long, and no valid slot can +/// give way, so the attach step cannot bring the ends on. The straightening is +/// not committed either. +#[test] +fn a_straightening_the_attach_step_rejects_is_undone() { + let original = vec![pt(122.5, 108.0, Some(1)), pt(277.5, 122.0, Some(3))]; + let mut elements = vec![ + stock(1, 100.0, 100.0), + stock(3, 300.0, 130.0), + flow(2, (200.0, 115.0), original.clone()), + ]; + normalize_flow_geometry(&mut elements); + let f = the_flow(&elements); + assert_eq!( + coords(f), + original.iter().map(|p| (p.x, p.y)).collect::>() + ); + assert_eq!((f.x, f.y), (200.0, 115.0)); +} + +/// Rows, one per rule the checker adds beyond attachment: a zero-length +/// segment, collinear segments, a short first, interior and final segment +/// (with room), no minimum when the terminals crowd, and the valve's arc +/// margin. +#[test] +fn the_checker_reports_segment_minima_and_the_valve_margin() { + let clouds_at = + |a: (f64, f64), b: (f64, f64)| vec![cloud(3, 2, a.0, a.1), cloud(4, 2, b.0, b.1)]; + /// (label, points, valve, the message the checker must report). + type Row = (&'static str, Vec, (f64, f64), &'static str); + let rows: Vec = vec![ + ( + "zero length", + vec![ + pt(0.0, 0.0, Some(3)), + pt(0.0, 0.0, None), + pt(100.0, 0.0, Some(4)), + ], + (50.0, 0.0), + "segment 0 has zero length", + ), + ( + "collinear", + vec![ + pt(0.0, 0.0, Some(3)), + pt(50.0, 0.0, None), + pt(100.0, 0.0, Some(4)), + ], + (25.0, 0.0), + "segments 0 and 1 are collinear", + ), + ( + "short first segment", + vec![ + pt(0.0, 0.0, Some(3)), + pt(0.0, 5.0, None), + pt(100.0, 5.0, Some(4)), + ], + (50.0, 5.0), + "first segment 0 is 5.00", + ), + ( + "short interior segment", + vec![ + pt(0.0, 0.0, Some(3)), + pt(50.0, 0.0, None), + pt(50.0, 5.0, None), + pt(100.0, 5.0, Some(4)), + ], + (25.0, 0.0), + "interior segment 1 is 5.00", + ), + ( + "short final segment", + vec![ + pt(0.0, 0.0, Some(3)), + pt(100.0, 0.0, None), + pt(100.0, 12.0, Some(4)), + ], + (50.0, 0.0), + "final segment 1 is 12.00", + ), + ( + "valve margin", + vec![pt(0.0, 0.0, Some(3)), pt(100.0, 0.0, Some(4))], + (4.0, 0.0), + "valve 4.00 from an end of the path", + ), + ]; + for (label, points, valve, message) in rows { + let last = points.last().map(|p| (p.x, p.y)).unwrap(); + let mut elements = clouds_at((points[0].x, points[0].y), last); + elements.push(flow(2, valve, points)); + let violations = flow_invariant_violations(&elements); + assert!( + violations.iter().any(|v| v.contains(message)), + "{label}: expected {message:?} in {violations:?}" + ); + } + + // Crowded terminals: two clouds 20 apart leave no room, so a 20px pipe + // with a 5px stub is not held to the minima. + let mut elements = clouds_at((0.0, 0.0), (15.0, 5.0)); + elements.push(flow( + 2, + (10.0, 5.0), + vec![ + pt(0.0, 0.0, Some(3)), + pt(0.0, 5.0, None), + pt(15.0, 5.0, Some(4)), + ], + )); + assert!( + !flow_invariant_violations(&elements) + .iter() + .any(|v| v.contains("segment") && v.contains("under")), + "crowded terminals are not held to the minima" + ); +} + +/// `normalize_flow_geometry` is idempotent -- a second pass changes nothing -- +/// and it leaves a view the checker accepts exactly as it is. Rows: a Z +/// between two stocks whose 3px riser collapses and whose merged line then +/// needs a slide into both clearance spans, so the geometry settles only over +/// several passes; and a deterministic sweep over views of one or two stocks +/// and one flow of each shape the producers hand the pass -- two-point +/// stock-to-cloud and stock-to-stock pipes, an L into a cloud, a Z between +/// stocks -- with endpoints on, near, inside and far from their stocks. +#[test] +fn normalization_is_idempotent() { + fn twice_equals_once(elements: Vec, label: &str) { + let valid = flow_invariant_violations(&elements).is_empty(); + let mut once = elements.clone(); + normalize_flow_geometry(&mut once); + assert!( + !valid || once == elements, + "{label}: a view the checker accepts moved from {elements:?} to {once:?}" + ); + let mut twice = once.clone(); + normalize_flow_geometry(&mut twice); + assert!( + once == twice, + "{label}: a second pass moved {once:?} to {twice:?}" + ); + } + + twice_equals_once( + vec![ + stock(1, 100.0, 100.0), + stock(3, 300.0, 96.0), + flow( + 2, + (160.0, 116.0), + vec![ + pt(122.5, 116.0, Some(1)), + pt(200.0, 116.0, None), + pt(200.0, 113.0, None), + pt(277.5, 113.0, Some(3)), + ], + ), + ], + "a Z settled over several passes", + ); + + let mut state: u64 = 0x9E37_79B9_7F4A_7C15; + let mut next = move || { + state ^= state << 13; + state ^= state >> 7; + state ^= state << 17; + state + }; + let half = |lo: f64, hi: f64, next: &mut dyn FnMut() -> u64| { + let unit = (next() % 1_000_000) as f64 / 1_000_000.0; + ((lo + unit * (hi - lo)) * 2.0).round() / 2.0 + }; + for case in 0..3000 { + let s1 = (100.0, 100.0); + let s2 = if next() % 4 == 0 { + (half(90.0, 140.0, &mut next), half(80.0, 130.0, &mut next)) + } else { + ( + half(-300.0, 500.0, &mut next), + half(-300.0, 500.0, &mut next), + ) + }; + let near = |s: (f64, f64), next: &mut dyn FnMut() -> u64| -> (f64, f64) { + let pick = next() % 4; + let mut h = |lo: f64, hi: f64| { + let unit = (next() % 1_000_000) as f64 / 1_000_000.0; + ((lo + unit * (hi - lo)) * 2.0).round() / 2.0 + }; + match pick { + 0 => (s.0 + 22.5, h(s.1 - 17.5, s.1 + 17.5)), + 1 => (h(s.0 - 22.5, s.0 + 22.5), s.1 + 17.5), + 2 => (h(s.0 - 45.0, s.0 + 45.0), h(s.1 - 35.0, s.1 + 35.0)), + _ => (h(s.0 - 150.0, s.0 + 150.0), h(s.1 - 150.0, s.1 + 150.0)), + } + }; + let p = near(s1, &mut next); + let (points, uses_s2) = match next() % 4 { + 0 => { + let d = half(-250.0, 250.0, &mut next); + ( + vec![pt(p.0, p.1, Some(1)), pt(p.0 + d, p.1, Some(11))], + false, + ) + } + 1 => { + let q = near(s2, &mut next); + (vec![pt(p.0, p.1, Some(1)), pt(q.0, p.1, Some(3))], true) + } + 2 => { + let bx = half(-300.0, 500.0, &mut next); + let cy = half(-300.0, 500.0, &mut next); + ( + vec![ + pt(p.0, p.1, Some(1)), + pt(bx, p.1, None), + pt(bx, cy, Some(11)), + ], + false, + ) + } + _ => { + let q = near(s2, &mut next); + let mx = half(-300.0, 500.0, &mut next); + ( + vec![ + pt(p.0, p.1, Some(1)), + pt(mx, p.1, None), + pt(mx, q.1, None), + pt(q.0, q.1, Some(3)), + ], + true, + ) + } + }; + let valve = ( + half(-300.0, 500.0, &mut next), + half(-300.0, 500.0, &mut next), + ); + let mut elements = vec![stock(1, s1.0, s1.1)]; + if uses_s2 { + elements.push(stock(3, s2.0, s2.1)); + } else { + let end = points.last().unwrap(); + elements.push(cloud(11, 2, end.x, end.y)); + } + elements.push(flow(2, valve, points)); + twice_equals_once(elements, &format!("case {case}")); + } +} + +/// A stock-to-cloud L whose first segment runs along the stock's bottom face: +/// the Face arm needs the line 3px up, onto the cloud end's own row, where the +/// slide leaves the L's riser of no length. The next pass starts from the +/// simplified pipe, which drops the repeated point, and the straight pipe +/// comes onto the right face. +#[test] +fn a_slide_that_empties_a_neighbour_settles_on_the_next_pass() { + let elements = normalized(vec![ + stock(1, 100.0, 100.0), + cloud(11, 2, 197.0, 114.5), + flow( + 2, + (-4.0, 417.5), + vec![ + pt(118.0, 117.5, Some(1)), + pt(197.0, 117.5, None), + pt(197.0, 114.5, Some(11)), + ], + ), + ]); + assert_eq!( + coords(the_flow(&elements)), + vec![(122.5, 114.5), (197.0, 114.5)] + ); +} + +/// Where the terminal bodies, each inflated by `MIN_SEGMENT`, overlap, the +/// design plan leaves G6 best effort but still demands G1-G5, so no step is +/// refused for running a segment through the other stock. Rows: a diagonal +/// pipe between s1 and a stock 30px above it, both ends off their faces. +/// Each settles with every end on its face; only crossings may remain. +#[test] +fn between_overlapping_stocks_the_ends_attach_whatever_they_cross() { + /// (label, the second stock, the pipe, the valve). + type Row = (&'static str, (f64, f64), Vec, (f64, f64)); + let rows: Vec = vec![( + "a diagonal pipe", + (101.5, 69.5), + vec![pt(77.5, 82.5, Some(1)), pt(-41.5, 190.5, Some(3))], + (14.75, 139.5), + )]; + for (label, s3, points, valve) in rows { + let mut elements = vec![ + stock(1, 100.0, 100.0), + stock(3, s3.0, s3.1), + flow(2, valve, points), + ]; + normalize_flow_geometry(&mut elements); + let violations = flow_invariant_violations(&elements); + assert!( + violations + .iter() + .all(|v| v.contains("runs through an endpoint stock")), + "{label}: G1-G5 must hold: {violations:?} at {:?}", + coords(the_flow(&elements)) + ); + } +} + +/// Rows, one per thing `simplify` drops or keeps: a repeated interior point; +/// a repeated endpoint, whose attachment survives; an interior point that +/// continues its segment onward; the tip of a spur, where the pipe turns back +/// along its own line; a removal that exposes the next removal (a spur whose +/// retraction leaves a repeated point, then a collinear one); and an attached +/// interior point, which is not the pass's to drop. +#[test] +fn simplify_drops_what_draws_no_route() { + /// (label, points, the simplified points). + type Row = (&'static str, Vec, Vec<(f64, f64, Option)>); + let rows: Vec = vec![ + ( + "repeated interior point", + vec![ + pt(0.0, 0.0, Some(1)), + pt(0.0, 50.0, None), + pt(0.0, 50.0, None), + pt(40.0, 50.0, Some(2)), + ], + vec![ + (0.0, 0.0, Some(1)), + (0.0, 50.0, None), + (40.0, 50.0, Some(2)), + ], + ), + ( + "repeated endpoint keeps its attachment", + vec![ + pt(0.0, 0.0, Some(1)), + pt(0.0, 50.0, None), + pt(40.0, 50.0, None), + pt(40.0, 50.0, Some(2)), + ], + vec![ + (0.0, 0.0, Some(1)), + (0.0, 50.0, None), + (40.0, 50.0, Some(2)), + ], + ), + ( + "onward collinear point", + vec![ + pt(0.0, 0.0, Some(1)), + pt(0.0, 20.0, None), + pt(0.0, 50.0, None), + pt(40.0, 50.0, Some(2)), + ], + vec![ + (0.0, 0.0, Some(1)), + (0.0, 50.0, None), + (40.0, 50.0, Some(2)), + ], + ), + ( + "a spur's tip", + vec![ + pt(0.0, 0.0, Some(1)), + pt(0.0, 50.0, None), + pt(40.0, 50.0, None), + pt(20.0, 50.0, Some(2)), + ], + vec![ + (0.0, 0.0, Some(1)), + (0.0, 50.0, None), + (20.0, 50.0, Some(2)), + ], + ), + ( + "a removal exposes the next", + vec![ + pt(0.0, 0.0, Some(1)), + pt(0.0, 50.0, None), + pt(40.0, 50.0, None), + pt(40.0, 60.0, None), + pt(40.0, 50.0, None), + pt(80.0, 50.0, Some(2)), + ], + vec![ + (0.0, 0.0, Some(1)), + (0.0, 50.0, None), + (80.0, 50.0, Some(2)), + ], + ), + ( + "an attached interior point stays", + vec![ + pt(0.0, 0.0, Some(1)), + pt(0.0, 20.0, Some(9)), + pt(0.0, 50.0, Some(2)), + ], + vec![ + (0.0, 0.0, Some(1)), + (0.0, 20.0, Some(9)), + (0.0, 50.0, Some(2)), + ], + ), + ]; + for (label, mut points, expected) in rows { + simplify(&mut points); + let got: Vec<(f64, f64, Option)> = points + .iter() + .map(|p| (p.x, p.y, p.attached_to_uid)) + .collect(); + assert_eq!(got, expected, "{label}"); + } +} + +/// A U-turn whose 2px riser is under the minimum: collapsing the riser folds +/// the pipe back along its own line, and the fold's tip is a spur drawn over +/// itself, so the pipe settles without it -- a straight run from the source +/// into an L to the sink -- rather than keeping two collinear segments. +#[test] +fn a_pipe_turning_back_along_its_own_line_settles_without_the_spur() { + let elements = normalized(vec![ + stock(1, 100.0, 100.0), + stock(3, 413.5, 450.0), + flow( + 2, + (14.0, 178.5), + vec![ + pt(122.5, 105.0, Some(1)), + pt(122.5, 404.0, None), + pt(-81.0, 404.0, None), + pt(-81.0, 406.0, None), + pt(413.5, 406.0, Some(3)), + ], + ), + ]); + assert_eq!( + coords(the_flow(&elements)), + vec![ + (119.5, 117.5), + (119.5, 404.0), + (413.5, 404.0), + (413.5, 432.5) + ] + ); +} + +/// A pass starts from the simplified pipe. A repeated interior point on a +/// pipe that runs back over itself reads, unsimplified, as a zero-length +/// segment between two collinear runs, and no arm can bring the ends on; +/// simplified, it is a straight pipe whose ends enter the two facing faces. +#[test] +fn a_pass_starts_from_the_pipe_without_repeated_or_collinear_points() { + let elements = normalized(vec![ + stock(1, 100.0, 100.0), + stock(3, 489.0, 112.0), + flow( + 2, + (33.5, 100.0), + vec![ + pt(52.5, 100.0, Some(1)), + pt(-121.0, 100.0, None), + pt(-121.0, 100.0, None), + pt(463.5, 100.0, Some(3)), + ], + ), + ]); + let f = the_flow(&elements); + assert_eq!(coords(f), vec![(122.5, 100.0), (466.5, 100.0)]); + assert_eq!((f.x, f.y), (132.5, 100.0)); +} + +/// Neither step commits a segment through the body of a stock the pipe ends +/// on where the pipe had none. Rows: a collapse whose candidate would run the +/// merged line through the sink's body, where the other candidate settles +/// the pipe; an attach whose leg into the source would cross the sink, where +/// the pipe settles through a longer route; a Z whose leg into its sink +/// would drop through the source's body, which comes back with no crossing +/// and no more violations than it had; a pipe the producer drew up through its +/// own source, whose steps keep that crossing on the way to a valid route, so +/// the rule refuses only crossings a step adds; and a pipe already crossing its +/// source's body, whose collapse would run two more segments through bodies, +/// so a pipe that crosses may still not gain crossings. +#[test] +fn a_collapse_or_attach_never_adds_a_body_crossing() { + let elements = normalized(vec![ + stock(1, 100.0, 100.0), + stock(3, 179.5, -264.5), + flow( + 2, + (-114.0, 268.5), + vec![ + pt(111.5, 100.0, Some(1)), + pt(-12.5, 100.0, None), + pt(-12.5, 112.5, None), + pt(24.5, 112.5, None), + pt(24.5, 118.0, None), + pt(179.5, 118.0, Some(3)), + ], + ), + ]); + assert_eq!( + coords(the_flow(&elements)), + vec![ + (77.5, 100.0), + (-12.5, 100.0), + (-12.5, 118.0), + (179.5, 118.0), + (179.5, -247.0) + ], + "collapse" + ); + + let elements = normalized(vec![ + stock(1, 100.0, 100.0), + stock(3, 141.0, -22.5), + flow( + 2, + (196.0, -56.25), + vec![ + pt(118.5, -29.0, Some(3)), + pt(424.0, -29.0, None), + pt(424.0, -67.5, None), + pt(196.0, -67.5, None), + pt(196.0, -47.5, None), + pt(202.5, -47.5, Some(1)), + ], + ), + ]); + assert_eq!( + coords(the_flow(&elements)), + vec![ + (163.5, -29.0), + (424.0, -29.0), + (424.0, -67.5), + (196.0, -67.5), + (196.0, 85.5), + (122.5, 85.5) + ], + "attach" + ); + + let mut elements = vec![ + stock(1, 100.0, 100.0), + stock(3, 16.0, 99.5), + flow( + 2, + (79.0, 165.5), + vec![ + pt(85.5, 117.5, Some(1)), + pt(85.5, 144.0, None), + pt(79.0, 144.0, None), + pt(79.0, 194.0, Some(3)), + ], + ), + ]; + let before = flow_invariant_violations(&elements); + normalize_flow_geometry(&mut elements); + let after = flow_invariant_violations(&elements); + assert!( + !after + .iter() + .any(|v| v.contains("runs through an endpoint stock")), + "a Z into its sink: {after:?}" + ); + assert!( + after.len() <= before.len(), + "a Z into its sink: {before:?} -> {after:?}" + ); + + let elements = normalized(vec![ + stock(1, 100.0, 100.0), + stock(3, -291.0, -131.5), + flow( + 2, + (-241.5, -115.0), + vec![ + pt(121.5, 100.0, Some(1)), + pt(121.5, -115.0, None), + pt(-331.0, -115.0, Some(3)), + ], + ), + ]); + assert_eq!( + coords(the_flow(&elements)), + vec![(119.5, 82.5), (119.5, -117.0), (-268.5, -117.0)], + "a pipe drawn through its own source" + ); + + let elements = normalized(vec![ + stock(1, 100.0, 100.0), + stock(3, 244.5, 109.5), + flow( + 2, + (108.5, 111.75), + vec![ + pt(122.5, 106.0, Some(1)), + pt(119.0, 106.0, None), + pt(119.0, 101.5, None), + pt(108.5, 101.5, None), + pt(108.5, 112.5, Some(3)), + ], + ), + ]); + assert_eq!( + coords(the_flow(&elements)), + vec![(122.5, 101.5), (222.0, 101.5)], + "a pipe already crossing its source" + ); +} + +/// A collapse candidate need not leave every stock end valid. The pipe climbs +/// from its source, doubles back over the sink's body, and drops 9.5px, under +/// a riser, to a sink end at the stock's center. The candidate that merges +/// that riser still leaves the sink end at the center; the next passes bring +/// it onto the bottom face and settle the pipe into an L. Requiring every end +/// valid would refuse the candidate and keep the riser and the segments +/// through the sink's body. +#[test] +fn a_collapse_may_leave_an_end_for_the_next_pass_to_attach() { + let elements = normalized(vec![ + stock(1, 100.0, 100.0), + stock(3, 200.0, 179.0), + flow( + 2, + (71.5, 100.0), + vec![ + pt(84.5, 179.0, Some(3)), + pt(84.5, 90.5, None), + pt(41.0, 90.5, None), + pt(41.0, 100.0, None), + pt(100.0, 100.0, Some(1)), + ], + ), + ]); + assert_eq!( + coords(the_flow(&elements)), + vec![(177.5, 179.0), (84.5, 179.0), (84.5, 117.5)] + ); +} + +/// When the terminals crowd each other the checker demands no segment +/// minimum, so a pipe it accepts is not moved for a short segment. Rows: an L +/// from a stock's right face into a cloud 0.5 above the pipe, and a cloud +/// stub 4.5 long into a 0.5px final segment on a stock's face. +#[test] +fn a_crowded_pipe_the_checker_accepts_is_left_as_it_is() { + /// (label, the view). + type Row = (&'static str, Vec); + let rows: Vec = vec![ + ( + "an L into a cloud", + vec![ + stock(1, 100.0, 100.0), + stock(4, 148.5, 133.0), + cloud(3, 2, 136.0, 94.0), + flow( + 2, + (130.0, 94.5), + vec![ + pt(122.5, 94.5, Some(1)), + pt(136.0, 94.5, None), + pt(136.0, 94.0, Some(3)), + ], + ), + ], + ), + ( + "a final segment of half a pixel", + vec![ + stock(1, 100.0, 100.0), + stock(4, 62.0, 134.5), + cloud(3, 2, 123.0, 103.5), + flow( + 2, + (123.0, 101.0), + vec![ + pt(123.0, 103.5, Some(3)), + pt(123.0, 99.0, None), + pt(122.5, 99.0, Some(1)), + ], + ), + ], + ), + ]; + for (label, original) in rows { + assert_eq!( + flow_invariant_violations(&original), + Vec::::new(), + "{label}: the checker accepts the input" + ); + let mut elements = original.clone(); + normalize_flow_geometry(&mut elements); + assert!(elements == original, "{label}: moved to {elements:?}"); + } +} + +/// With crowded terminals a segment shorter than `CROWDED_MIN_SEGMENT` is +/// still collapsed while it keeps a stock end off its face. A 0.5px riser +/// sits between the sink's run and a stub that ends at the stock's center; +/// merging it lets the attach step bring that end onto the bottom face. +#[test] +fn a_crowded_segment_is_collapsed_to_bring_an_end_onto_its_face() { + let elements = normalized(vec![ + stock(1, 100.0, 100.0), + stock(3, 87.0, 135.0), + flow( + 2, + (112.25, 115.0), + vec![ + pt(112.5, 135.0, Some(3)), + pt(112.5, 115.0, None), + pt(112.0, 115.0, None), + pt(112.0, 100.0, Some(1)), + ], + ), + ]); + assert_eq!( + coords(the_flow(&elements)), + vec![(109.5, 135.0), (112.5, 135.0), (112.5, 117.5)] + ); +} + +/// The Face arm holds the segment an end leaves to that end's own minimum. A +/// two-point pipe runs along s1's top face and past the top of s3, far below: +/// s3's end drops a leg into its top face, and because the sink's arm runs +/// first, the segment s1's end leaves as it enters its right face is a stub, +/// 11.5px, held to `MIN_SEGMENT` rather than to the sink segment's +/// `MIN_SINK_SEGMENT`. +#[test] +fn a_face_end_needs_only_the_minimum_of_the_segment_it_leaves() { + let elements = normalized(vec![ + stock(1, 100.0, 100.0), + stock(3, 124.0, 282.0), + flow( + 2, + (116.5, 5.5), + vec![pt(100.5, 82.5, Some(1)), pt(134.0, 82.5, Some(3))], + ), + ]); + assert_eq!( + coords(the_flow(&elements)), + vec![(122.5, 85.5), (134.0, 85.5), (134.0, 264.5)] + ); +} + +/// A jog is not refused for where the valve sits. The diagonal pipe +/// straightens onto a line inside s3's clearance span but under s1's, and s3's +/// end is a valid slot that pins it, so s1's end jogs to its own line. The +/// valve lies on the shared line behind s1's face; the jog's step is placed +/// all the same, and the valve is projected onto the settled pipe. +#[test] +fn a_jog_does_not_step_around_the_valve() { + let elements = normalized(vec![ + stock(1, 100.0, 100.0), + stock(3, 156.5, 94.0), + flow( + 2, + (93.75, 82.5), + vec![pt(56.0, 71.0, Some(1)), pt(134.0, 94.5, Some(3))], + ), + ]); + assert_eq!( + coords(the_flow(&elements)), + vec![(122.5, 85.5), (123.5, 85.5), (123.5, 82.75), (134.0, 82.75)] + ); +} + +/// A jog whose step would pass the adjacent point is refused. Two overlapping +/// stocks and a vertical pipe through both: s1's end takes the line inside +/// s1's clearance span, and s2's end, whose span excludes that line, would jog +/// to its own line, stepping a stub short of s2's top face. That step lies +/// beyond s1's end, so the route would pass it and double back over itself. +/// No invariant names such a route, so the pipe is left as the producer drew +/// it. +#[test] +fn a_jog_whose_step_would_double_back_is_refused() { + let original = vec![pt(122.5, 131.0, Some(2)), pt(122.5, 88.5, Some(1))]; + let mut elements = vec![ + stock(1, 100.0, 100.0), + stock(2, 140.0, 118.5), + flow(10, (-132.0, -140.0), original.clone()), + ]; + normalize_flow_geometry(&mut elements); + assert_eq!( + coords(the_flow(&elements)), + original.iter().map(|p| (p.x, p.y)).collect::>() + ); +} + +/// Rows, one per arm of `settle_laid_out_valve`: a valve already +/// `VALVE_CLAMP_MARGIN` inside its segment is not moved; a valve within the +/// margin of its segment's end is clamped along that segment, as the editor +/// clamps a dragged valve; a valve on a segment shorter than twice the margin +/// moves to the nearest position of a longer segment; a pipe whose every +/// segment is that short leaves the valve where it is; a valve on a sibling's +/// pipe moves to the nearest position clear of both the sibling and its +/// segment's ends (the earlier segment on a tie); a valve clear of the sibling +/// is not moved; and where a sibling runs within the margin of every position, +/// the valve keeps the segment margin alone -- clamped along its own segment, +/// or at the longest segment's middle when its own is short. +#[test] +fn a_laid_out_valve_keeps_the_margin_from_its_segments_ends() { + let z = vec![ + pt(277.5, 115.25, Some(3)), + pt(200.0, 115.25, None), + pt(200.0, 95.0, None), + pt(122.5, 95.0, Some(1)), + ]; + let z_short_riser = vec![ + pt(277.5, 110.0, Some(3)), + pt(200.0, 110.0, None), + pt(200.0, 95.0, None), + pt(122.5, 95.0, Some(1)), + ]; + let tiny = vec![ + pt(0.0, 0.0, Some(3)), + pt(0.0, 8.0, None), + pt(6.0, 8.0, Some(4)), + ]; + // A Z between offset stocks crossing a sibling straight between them at + // y = 110, and a long pipe with a sibling running 5px beside it end to end. + let crossing_z = vec![ + pt(277.5, 122.25, Some(3)), + pt(200.0, 122.25, None), + pt(200.0, 97.75, None), + pt(122.5, 97.75, Some(1)), + ]; + let sibling: &[FlowPoint] = &[pt(122.5, 110.0, Some(1)), pt(277.5, 110.0, Some(3))]; + let straight = vec![pt(0.0, 0.0, Some(3)), pt(0.0, 100.0, Some(4))]; + let beside: &[FlowPoint] = &[pt(5.0, -50.0, Some(5)), pt(5.0, 150.0, Some(6))]; + let between_runs: &[FlowPoint] = &[pt(100.0, 103.0, Some(5)), pt(300.0, 103.0, Some(6))]; + /// (label, path, other pipes, valve before, valve after). + type Row<'a> = ( + &'a str, + &'a [FlowPoint], + Vec<&'a [FlowPoint]>, + (f64, f64), + (f64, f64), + ); + let rows: [Row; 8] = [ + ( + "inside the margin", + &z, + vec![], + (240.0, 115.25), + (240.0, 115.25), + ), + ( + "within the margin of a bend", + &z, + vec![], + (200.0, 105.5), + (200.0, 105.25), + ), + ( + "on a riser shorter than two margins: the nearest position of a longer segment", + &z_short_riser, + vec![], + (200.0, 102.0), + (190.0, 95.0), + ), + ( + "on a short riser, every longer segment beside a sibling: the longest one's middle", + &z_short_riser, + vec![between_runs], + (200.0, 102.0), + (238.75, 110.0), + ), + ("every segment short", &tiny, vec![], (0.0, 4.0), (0.0, 4.0)), + ( + "on a sibling's pipe: the nearest position clear of it", + &crossing_z, + vec![sibling], + (200.0, 110.0), + (210.0, 122.25), + ), + ( + "clear of the sibling and the segment's ends", + &crossing_z, + vec![sibling], + (240.0, 122.25), + (240.0, 122.25), + ), + ( + "no position clear of the sibling: the segment margin alone", + &straight, + vec![beside], + (0.0, 3.0), + (0.0, 10.0), + ), + ]; + for (label, points, others, valve, expected) in rows { + let mut v = valve; + settle_laid_out_valve(points, &mut v, &others); + assert_eq!(v, expected, "{label}"); + } +} + +#[test] +fn clamp_to_face_span_keeps_corner_clearance() { + assert_eq!(clamp_to_face_span(100.0, 100.0, 22.5), 100.0); + assert_eq!(clamp_to_face_span(130.0, 100.0, 22.5), 119.5); + assert_eq!(clamp_to_face_span(70.0, 100.0, 22.5), 80.5); + assert_eq!(clamp_to_face_span(117.5, 100.0, 17.5), 114.5); +} diff --git a/src/simlin-engine/src/diagram/mod.rs b/src/simlin-engine/src/diagram/mod.rs index 5663b8b8b..490a2ada7 100644 --- a/src/simlin-engine/src/diagram/mod.rs +++ b/src/simlin-engine/src/diagram/mod.rs @@ -8,6 +8,7 @@ pub(crate) mod connector; pub mod constants; pub(crate) mod elements; pub(crate) mod flow; +pub(crate) mod flow_geometry; pub(crate) mod label; mod path; mod render; diff --git a/src/simlin-engine/src/layout/declutter.rs b/src/simlin-engine/src/layout/declutter.rs index a83a5bf51..59b78ea1e 100644 --- a/src/simlin-engine/src/layout/declutter.rs +++ b/src/simlin-engine/src/layout/declutter.rs @@ -573,7 +573,9 @@ pub(crate) fn resnap_flow_endpoints_to_stocks(elements: &mut [ViewElement]) { let dy = valve.1 - sy; if half_h * dx.abs() >= half_w * dy.abs() { // Horizontal approach: snap to the left or right edge, - // preserving the (clamped) y position. + // preserving the (clamped) y position. Clamped to the face's + // span only: the layout's finishing pass + // (`finish_flow_geometry`) owns corner clearance. pt.x = sx + dx.signum() * half_w; pt.y = pt.y.clamp(sy - half_h, sy + half_h); } else { diff --git a/src/simlin-engine/src/layout/face_slots.rs b/src/simlin-engine/src/layout/face_slots.rs new file mode 100644 index 000000000..a4f5ef013 --- /dev/null +++ b/src/simlin-engine/src/layout/face_slots.rs @@ -0,0 +1,860 @@ +// Copyright 2026 The Simlin Authors. All rights reserved. +// Use of this source code is governed by the Apache License, +// Version 2.0, that can be found in the LICENSE file. + +//! Where a flow that incremental layout creates meets a stock face. +//! +//! Incremental layout never moves a flow the patch did not touch, so a flow it +//! creates fits around the ends already on a face instead of re-spacing them. +//! Its stock end follows the routing preference of the editor's design plan +//! (`docs/design-plans/2026-09-10-diagram-editing-core.md`, "Invariants"): a +//! slot at least `PIPE_SPACING` from every end already on the face if the face +//! has one, else the slot farthest from them, and always within the face's +//! corner clearance. A created flow whose far end is a cloud first looks for a +//! slot `CLOUD_RADIUS` clear, so a neighbouring pipe that runs past its cloud +//! does not cross it, and its cloud is kept from overlapping another cloud. A +//! created flow between two stocks takes one line for both ends when that +//! line keeps the spacing on both faces, so its pipe runs straight; otherwise +//! each end takes its own face's slot and the finishing pass routes the pipe +//! between them. + +use std::collections::{HashMap, HashSet}; + +use crate::datamodel::ViewElement; +use crate::datamodel::view_element::FlowPoint; +use crate::diagram::constants::{CLOUD_RADIUS, STOCK_HEIGHT, STOCK_WIDTH}; +use crate::diagram::flow_geometry::{CORNER_CLEARANCE, PIPE_SPACING}; + +const EPS: f64 = 1e-6; + +/// How many times a created flow's cloud end is pushed out along its pipe to +/// clear the clouds already in the view, at most. +const MAX_CLOUD_PUSHES: usize = 8; + +/// A face of a stock. +#[cfg_attr(feature = "debug-derive", derive(Debug))] +#[derive(Clone, Copy, PartialEq, Eq)] +enum Side { + Left, + Right, + Top, + Bottom, +} + +impl Side { + /// The face an endpoint attached to a stock centered at `stock` sits on, + /// by the aspect-normalized dominant offset -- the rule + /// `resnap_flow_endpoints` snaps by, so a snapped end classifies as the + /// face it was snapped to. + fn of(p: &FlowPoint, stock: (f64, f64)) -> Side { + let dx = p.x - stock.0; + let dy = p.y - stock.1; + if (STOCK_HEIGHT / 2.0) * dx.abs() >= (STOCK_WIDTH / 2.0) * dy.abs() { + if dx >= 0.0 { Side::Right } else { Side::Left } + } else if dy >= 0.0 { + Side::Bottom + } else { + Side::Top + } + } + + /// Whether a point moves along this face by changing its x. + fn runs_along_x(self) -> bool { + matches!(self, Side::Top | Side::Bottom) + } + + fn along(self, p: &FlowPoint) -> f64 { + if self.runs_along_x() { p.x } else { p.y } + } + + fn set_along(self, p: &mut FlowPoint, v: f64) { + if self.runs_along_x() { + p.x = v; + } else { + p.y = v; + } + } + + /// The span of positions along the face that keep `CORNER_CLEARANCE` + /// from both corners. + fn clearance_span(self, stock: (f64, f64)) -> (f64, f64) { + let (center, half) = if self.runs_along_x() { + (stock.0, STOCK_WIDTH / 2.0) + } else { + (stock.1, STOCK_HEIGHT / 2.0) + }; + let reach = half - CORNER_CLEARANCE; + (center - reach, center + reach) + } +} + +/// The gaps `occupied` leaves on `span`, in order. Occupied positions outside +/// the span count at its nearest end. +fn free_gaps(span: (f64, f64), occupied: &[f64]) -> Vec<(f64, f64)> { + let mut bounds: Vec = occupied.iter().map(|v| v.clamp(span.0, span.1)).collect(); + bounds.push(span.0); + bounds.push(span.1); + bounds.sort_by(f64::total_cmp); + bounds.windows(2).map(|w| (w[0], w[1])).collect() +} + +/// The center of the longest interval in `intervals`. Among intervals of equal +/// length, the one whose center is nearest `preferred` wins, then the lower +/// one, so the choice is deterministic. +fn longest_center(intervals: &[(f64, f64)], preferred: f64) -> Option { + let mut best: Option<(f64, f64)> = None; + for &(lo, hi) in intervals { + let len = hi - lo; + let center = (lo + hi) / 2.0; + let better = match best { + None => true, + Some((best_len, best_center)) => { + len > best_len + EPS + || ((len - best_len).abs() <= EPS + && (center - preferred).abs() < (best_center - preferred).abs() - EPS) + } + }; + if better { + best = Some((len, center)); + } + } + best.map(|(_, center)| center) +} + +/// The positions of `span` at least `spacing` from every position in +/// `occupied`, as closed intervals in order: a position exactly `spacing` +/// away is clear. +fn clear_intervals(span: (f64, f64), occupied: &[f64], spacing: f64) -> Vec<(f64, f64)> { + let mut intervals = vec![span]; + for &occupant in occupied { + let (lo, hi) = (occupant - spacing, occupant + spacing); + intervals = intervals + .into_iter() + .flat_map(|(a, b)| { + let left = (a, b.min(lo)); + let right = (a.max(hi), b); + [left, right] + }) + .filter(|&(a, b)| b >= a - EPS) + .map(|(a, b)| (a, b.max(a))) + .collect(); + } + intervals +} + +/// The position of `intervals` nearest `target`; on a tie, the lower one. +fn nearest_in(intervals: &[(f64, f64)], target: f64) -> Option { + let mut best: Option = None; + for &(a, b) in intervals { + let v = target.clamp(a, b); + if best.is_none_or(|best| (v - target).abs() < (best - target).abs() - EPS) { + best = Some(v); + } + } + best +} + +/// The position of `span` whose least distance to `occupied` is largest: a +/// span end, or the midpoint between two neighbouring occupants. On a tie, +/// the lower one. +fn farthest_from(span: (f64, f64), occupied: &[f64]) -> f64 { + let mut inside: Vec = occupied.iter().map(|v| v.clamp(span.0, span.1)).collect(); + inside.sort_by(f64::total_cmp); + let mut candidates = vec![span.0, span.1]; + candidates.extend(inside.windows(2).map(|w| (w[0] + w[1]) / 2.0)); + candidates.sort_by(f64::total_cmp); + let least = |v: f64| { + occupied + .iter() + .map(|o| (o - v).abs()) + .fold(f64::INFINITY, f64::min) + }; + let mut best = (span.0, least(span.0)); + for v in candidates { + let d = least(v); + if d > best.1 + EPS { + best = (v, d); + } + } + best.0 +} + +/// The slot a created end takes on a face whose clearance span is `span` and +/// whose other ends sit at `occupied`. The first spacing in `spacings` that +/// leaves the span a position at least that far from every occupant decides: +/// the such position nearest the center of the largest free gap (nearest +/// `preferred` on a tie of gaps), so an empty face gives its center. When no +/// spacing does, the position farthest from the occupants. +fn slot_on_face(span: (f64, f64), occupied: &[f64], preferred: f64, spacings: &[f64]) -> f64 { + let target = + longest_center(&free_gaps(span, occupied), preferred).unwrap_or((span.0 + span.1) / 2.0); + spacings + .iter() + .find_map(|&spacing| nearest_in(&clear_intervals(span, occupied, spacing), target)) + .unwrap_or_else(|| farthest_from(span, occupied)) +} + +/// A stock end of a created flow, with its face. +#[cfg_attr(feature = "debug-derive", derive(Debug))] +struct FaceEnd { + end: usize, + stock_uid: i32, + stock: (f64, f64), + side: Side, +} + +/// The positions along `e`'s face of every other flow end on that face: the +/// ends of flows outside `created`, and the created ends already `placed`. +fn occupied_on_face( + elements: &[ViewElement], + e: &FaceEnd, + uid: i32, + created: &HashSet, + placed: &HashSet<(i32, usize)>, +) -> Vec { + elements + .iter() + .filter_map(|el| match el { + ViewElement::Flow(g) if g.points.len() >= 2 => Some(g), + _ => None, + }) + .flat_map(|g| { + let g_last = g.points.len() - 1; + [0, g_last].into_iter().map(move |end| (g, end)) + }) + .filter(|&(g, end)| { + g.uid != uid && (!created.contains(&g.uid) || placed.contains(&(g.uid, end))) + }) + .filter_map(|(g, end)| { + let q = &g.points[end]; + (q.attached_to_uid == Some(e.stock_uid) && Side::of(q, e.stock) == e.side) + .then(|| e.side.along(q)) + }) + .collect() +} + +/// Move the stock ends of each flow in `created` into slots on their faces, +/// and each created flow's cloud end clear of the other clouds. +/// +/// The ends a slot is measured against are those of every flow not in +/// `created`, plus the created ends already placed (flows are processed in uid +/// order, so the result is deterministic). A two-point flow between two stocks +/// whose ends sit on faces running along the same coordinate takes one line +/// for both ends and the valve, so the pipe stays straight, when a line in +/// both clearance spans keeps `PIPE_SPACING` from the ends on both faces: the +/// such line nearest the center of the longest overlap of the two faces' free +/// gaps. Otherwise, and for every other flow, each stock end takes +/// `slot_on_face` on its own face, trying `CLOUD_RADIUS` before `PIPE_SPACING` +/// when the flow's far end is free (a cloud or nothing). An end whose far end +/// is free moves with its whole pipe and valve, so the pipe keeps its shape; +/// an end whose far end is on a stock moves alone, and the finishing pass +/// reroutes the pipe. Then a created flow's free end is pushed out along its +/// pipe, `2 * CLOUD_RADIUS` at a time, while its cloud would overlap another; +/// clouds themselves are left for the finishing pass's normalization, which +/// recenters them on their ends. +pub(crate) fn place_created_flow_ends(elements: &mut [ViewElement], created: &HashSet) { + let stocks: HashMap = elements + .iter() + .filter_map(|e| match e { + ViewElement::Stock(s) => Some((s.uid, (s.x, s.y))), + _ => None, + }) + .collect(); + let mut order: Vec<(i32, usize)> = elements + .iter() + .enumerate() + .filter_map(|(i, e)| match e { + ViewElement::Flow(f) if created.contains(&f.uid) && f.points.len() >= 2 => { + Some((f.uid, i)) + } + _ => None, + }) + .collect(); + order.sort_unstable(); + + let mut placed: HashSet<(i32, usize)> = HashSet::new(); + for &(uid, idx) in &order { + let ViewElement::Flow(f) = &elements[idx] else { + unreachable!() + }; + let last = f.points.len() - 1; + let face_end = |end: usize| -> Option { + let p = &f.points[end]; + let stock_uid = p.attached_to_uid.filter(|u| stocks.contains_key(u))?; + let stock = stocks[&stock_uid]; + Some(FaceEnd { + end, + stock_uid, + stock, + side: Side::of(p, stock), + }) + }; + let ends: Vec = [0, last].into_iter().filter_map(face_end).collect(); + + let joint = if last == 1 + && ends.len() == 2 + && ends[0].side.runs_along_x() == ends[1].side.runs_along_x() + { + let (span_a, span_b) = ( + ends[0].side.clearance_span(ends[0].stock), + ends[1].side.clearance_span(ends[1].stock), + ); + let occupied_a = occupied_on_face(elements, &ends[0], uid, created, &placed); + let occupied_b = occupied_on_face(elements, &ends[1], uid, created, &placed); + let gaps_a = free_gaps(span_a, &occupied_a); + let gaps_b = free_gaps(span_b, &occupied_b); + let overlaps: Vec<(f64, f64)> = gaps_a + .iter() + .flat_map(|a| gaps_b.iter().map(move |b| (a.0.max(b.0), a.1.min(b.1)))) + .filter(|(lo, hi)| hi - lo > EPS) + .collect(); + let span = (span_a.0.max(span_b.0), span_a.1.min(span_b.1)); + let current = ends[0].side.along(&f.points[0]); + let target = longest_center(&overlaps, current).unwrap_or((span.0 + span.1) / 2.0); + let occupied: Vec = occupied_a.iter().chain(&occupied_b).copied().collect(); + if span.0 <= span.1 + EPS { + nearest_in(&clear_intervals(span, &occupied, PIPE_SPACING), target) + } else { + None + } + } else { + None + }; + + let ViewElement::Flow(f) = &mut elements[idx] else { + unreachable!() + }; + if let Some(line) = joint { + let side = ends[0].side; + for e in &ends { + side.set_along(&mut f.points[e.end], line); + placed.insert((uid, e.end)); + } + if side.runs_along_x() { + f.x = line; + } else { + f.y = line; + } + continue; + } + + for e in &ends { + let occupied = occupied_on_face(elements, e, uid, created, &placed); + let ViewElement::Flow(f) = &mut elements[idx] else { + unreachable!() + }; + let current = e.side.along(&f.points[e.end]); + let far = if e.end == 0 { last } else { 0 }; + let far_on_stock = f.points[far] + .attached_to_uid + .is_some_and(|u| stocks.contains_key(&u)); + let spacings: &[f64] = if far_on_stock { + &[PIPE_SPACING] + } else { + &[CLOUD_RADIUS, PIPE_SPACING] + }; + let target = slot_on_face(e.side.clearance_span(e.stock), &occupied, current, spacings); + let delta = target - current; + if delta.abs() > EPS { + if far_on_stock { + e.side.set_along(&mut f.points[e.end], target); + } else { + for pt in &mut f.points { + let v = e.side.along(pt) + delta; + e.side.set_along(pt, v); + } + let mut valve = FlowPoint { + x: f.x, + y: f.y, + attached_to_uid: None, + }; + let v = e.side.along(&valve) + delta; + e.side.set_along(&mut valve, v); + (f.x, f.y) = (valve.x, valve.y); + } + } + placed.insert((uid, e.end)); + } + } + + separate_created_clouds(elements, created, &stocks, &order); +} +/// Push each created flow's free end out along its end segment until the cloud +/// on it overlaps no other cloud: every other flow's free end, a created one +/// once it is settled. +fn separate_created_clouds( + elements: &mut [ViewElement], + created: &HashSet, + stocks: &HashMap, + order: &[(i32, usize)], +) { + let is_free = |p: &FlowPoint| !p.attached_to_uid.is_some_and(|u| stocks.contains_key(&u)); + let mut settled: HashSet = HashSet::new(); + for &(uid, idx) in order { + let ViewElement::Flow(f) = &elements[idx] else { + unreachable!() + }; + let last = f.points.len() - 1; + let free_ends: Vec<(usize, usize)> = [(0, 1), (last, last - 1)] + .into_iter() + .filter(|&(end, _)| is_free(&f.points[end])) + .collect(); + for (end, adj) in free_ends { + let others: Vec<(f64, f64)> = elements + .iter() + .filter_map(|el| match el { + ViewElement::Flow(g) + if g.uid != uid + && g.points.len() >= 2 + && (!created.contains(&g.uid) || settled.contains(&g.uid)) => + { + Some(g) + } + _ => None, + }) + .flat_map(|g| { + let g_last = g.points.len() - 1; + [0, g_last] + .into_iter() + .map(move |i| &g.points[i]) + .filter(|q| is_free(q)) + .map(|q| (q.x, q.y)) + }) + .collect(); + let ViewElement::Flow(f) = &mut elements[idx] else { + unreachable!() + }; + let (dx, dy) = ( + f.points[end].x - f.points[adj].x, + f.points[end].y - f.points[adj].y, + ); + let len = dx.hypot(dy); + if len <= EPS { + continue; + } + let step = 2.0 * CLOUD_RADIUS; + for _ in 0..MAX_CLOUD_PUSHES { + let p = &f.points[end]; + let overlaps = others + .iter() + .any(|&(x, y)| (x - p.x).hypot(y - p.y) < step - EPS); + if !overlaps { + break; + } + f.points[end].x += dx / len * step; + f.points[end].y += dy / len * step; + } + } + settled.insert(uid); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::datamodel::view_element::{self, LabelSide}; + + /// Rows, one per arm of `slot_on_face`: an empty face gives its center; + /// one occupant, with the target on each side of it, gives the nearest + /// position a spacing away; a first spacing the face has room for wins + /// over a smaller one; a position exactly a spacing away is clear, even + /// where it is all that is clear (a span end, which the farthest-position + /// fallback would not choose over the lower end); the target of a tie of + /// gaps is the one nearest the preferred position, then the lower; a target + /// equidistant from two clear positions takes the lower; no clear position + /// at all gives the position farthest from the occupants, at a span end or + /// at the midpoint between two occupants; an occupant beyond the span + /// constrains nothing inside it. + #[test] + fn slot_on_face_rows() { + let face = (-19.5, 19.5); + /// (label, span, occupied, preferred, spacings, expected). + type Row = ( + &'static str, + (f64, f64), + &'static [f64], + f64, + &'static [f64], + f64, + ); + let rows: [Row; 10] = [ + ("empty face", face, &[], 5.0, &[PIPE_SPACING], 0.0), + ( + "one occupant, target above", + face, + &[0.0], + 7.5, + &[PIPE_SPACING], + 10.0, + ), + ( + "one occupant, target below", + face, + &[0.0], + -7.5, + &[PIPE_SPACING], + -10.0, + ), + ( + "the first spacing with room wins", + face, + &[0.0], + 7.5, + &[CLOUD_RADIUS, PIPE_SPACING], + CLOUD_RADIUS, + ), + ( + "exactly a spacing away is clear, at the upper span end", + (-10.0, 10.0), + &[0.0], + 7.0, + &[PIPE_SPACING], + 10.0, + ), + ( + "a tie of gaps takes the lower target", + face, + &[-5.0, 5.0], + 0.0, + &[PIPE_SPACING], + -15.0, + ), + ( + "a target equidistant from two clear positions takes the lower", + (-12.0, 12.0), + &[-5.0, 5.0], + 0.0, + &[6.0], + -11.0, + ), + ( + "no clear position: farthest from the occupants", + face, + &[-12.0, 0.0, 12.0], + 0.0, + &[PIPE_SPACING], + -19.5, + ), + ( + "no clear position: the midpoint between two occupants", + face, + &[-15.0, 15.0], + 0.0, + &[30.0], + 0.0, + ), + ( + "an occupant beyond the span", + face, + &[40.0], + 19.0, + &[PIPE_SPACING], + 0.0, + ), + ]; + for (label, span, occupied, preferred, spacings, expected) in rows { + let got = slot_on_face(span, occupied, preferred, spacings); + assert!( + (got - expected).abs() < 1e-9, + "{label}: got {got}, expected {expected}" + ); + } + } + + fn stock(uid: i32, x: f64, y: f64) -> ViewElement { + ViewElement::Stock(view_element::Stock { + name: format!("s{uid}"), + uid, + x, + y, + label_side: LabelSide::Bottom, + compat: None, + }) + } + + fn flow(uid: i32, valve: (f64, f64), points: &[(f64, f64, Option)]) -> ViewElement { + ViewElement::Flow(view_element::Flow { + name: format!("f{uid}"), + uid, + x: valve.0, + y: valve.1, + label_side: LabelSide::Bottom, + points: points + .iter() + .map(|&(x, y, attached_to_uid)| FlowPoint { + x, + y, + attached_to_uid, + }) + .collect(), + compat: None, + label_compat: None, + }) + } + + fn flow_of(elements: &[ViewElement], uid: i32) -> &view_element::Flow { + elements + .iter() + .find_map(|e| match e { + ViewElement::Flow(f) if f.uid == uid => Some(f), + _ => None, + }) + .unwrap() + } + + fn coords(f: &view_element::Flow) -> Vec<(f64, f64)> { + f.points.iter().map(|p| (p.x, p.y)).collect() + } + + /// Rows, one per way an end moves: a created cloud flow on a face a + /// preserved flow occupies moves with its pipe and valve to a slot a + /// cloud's radius clear; a created stock-to-stock flow whose faces cannot + /// share a line (a bottom face and a left face) moves only the end on the + /// occupied face, a pipe spacing clear, and a second row whose x-span and + /// y-span overlap numerically shows the faces are still not treated as + /// sharing a line; a created flow alone on its face takes the center; two + /// created flows on one face keep a cloud's radius apart; a created + /// stock-to-stock flow between two parallel faces takes one line a pipe + /// spacing off both occupants; the same flow between offset faces, where + /// every shared line sits within a spacing of the occupant, takes each + /// face's own slot; and a created cloud end within a cloud's width of + /// other clouds is pushed out along its pipe, a cloud's width at a time, + /// until it is clear. The preserved flow never moves. + /// + /// These views are built by hand in the shape incremental layout hands the + /// function: created stock ends already snapped onto a face by + /// `resnap_flow_endpoints` (on the face, the pipe leaving perpendicular), + /// free ends attached to cloud uids, and `created` holding the uids of the + /// flows the pass built. That composition through production is pinned by + /// `layout::tests::flow_tests` (a flow added on its face, a chain flow added + /// on a face it occupies, SIR's relapse, mark2's two outflows, a reverse + /// flow between offset stocks). + #[test] + fn place_created_flow_ends_rows() { + // Stock 1 at (100, 100): bottom face y = 117.5, span x in [80.5, 119.5]. + let preserved = flow( + 10, + (100.0, 160.0), + &[(100.0, 117.5, Some(1)), (100.0, 200.0, Some(98))], + ); + + let mut elements = vec![ + stock(1, 100.0, 100.0), + preserved.clone(), + flow( + 11, + (107.5, 160.0), + &[(107.5, 117.5, Some(1)), (107.5, 240.0, Some(99))], + ), + ]; + place_created_flow_ends(&mut elements, &HashSet::from([11])); + let f = flow_of(&elements, 11); + assert_eq!( + (f.x, f.y), + (100.0 + CLOUD_RADIUS, 160.0), + "cloud flow: valve moves with the pipe" + ); + assert_eq!( + coords(f), + vec![(100.0 + CLOUD_RADIUS, 117.5), (100.0 + CLOUD_RADIUS, 240.0)], + "cloud flow: the pipe keeps its shape, a cloud's radius clear" + ); + assert!( + elements.contains(&preserved), + "the preserved flow never moves" + ); + + // Stock 2 at (300, 300): its left face is x = 277.5, span y in [285.5, 314.5]. + let mut elements = vec![ + stock(1, 100.0, 100.0), + stock(2, 300.0, 300.0), + preserved.clone(), + flow( + 12, + (100.0, 300.0), + &[(100.0, 117.5, Some(1)), (277.5, 300.0, Some(2))], + ), + ]; + place_created_flow_ends(&mut elements, &HashSet::from([12])); + let f = flow_of(&elements, 12); + assert_eq!( + (f.x, f.y), + (100.0, 300.0), + "perpendicular faces: the valve stays" + ); + assert_eq!( + coords(f), + vec![(100.0 - PIPE_SPACING, 117.5), (277.5, 300.0)], + "perpendicular faces: only the end on the occupied face moves" + ); + + // Stock 2 at (300, 100): its left face's y-span [85.5, 114.5] overlaps + // stock 1's bottom x-span [80.5, 119.5] as numbers. + let mut elements = vec![ + stock(1, 100.0, 100.0), + stock(2, 300.0, 100.0), + preserved.clone(), + flow( + 12, + (200.0, 100.0), + &[(100.0, 117.5, Some(1)), (277.5, 100.0, Some(2))], + ), + ]; + place_created_flow_ends(&mut elements, &HashSet::from([12])); + assert_eq!( + coords(flow_of(&elements, 12)), + vec![(100.0 - PIPE_SPACING, 117.5), (277.5, 100.0)], + "perpendicular faces with numerically overlapping spans share no line" + ); + + let mut elements = vec![ + stock(1, 100.0, 100.0), + flow( + 13, + (90.0, 160.0), + &[(90.0, 117.5, Some(1)), (90.0, 200.0, Some(99))], + ), + ]; + place_created_flow_ends(&mut elements, &HashSet::from([13])); + assert_eq!( + flow_of(&elements, 13).points[0].x, + 100.0, + "alone on its face: center" + ); + + let mut elements = vec![ + stock(1, 100.0, 100.0), + flow( + 14, + (100.0, 160.0), + &[(100.0, 117.5, Some(1)), (100.0, 200.0, Some(98))], + ), + flow( + 15, + (100.0, 160.0), + &[(100.0, 117.5, Some(1)), (100.0, 200.0, Some(99))], + ), + ]; + place_created_flow_ends(&mut elements, &HashSet::from([14, 15])); + assert_eq!( + ( + flow_of(&elements, 14).points[0].x, + flow_of(&elements, 15).points[0].x + ), + (100.0, 100.0 - CLOUD_RADIUS), + "two created cloud flows on one face: the first at the center, the second a cloud's radius off" + ); + + // Stocks 1 at (100, 100) and 3 at (300, 100): a preserved flow runs + // between their facing left/right faces at y = 100 (spans [85.5, 114.5]). + let mut elements = vec![ + stock(1, 100.0, 100.0), + stock(3, 300.0, 100.0), + flow( + 16, + (200.0, 100.0), + &[(122.5, 100.0, Some(1)), (277.5, 100.0, Some(3))], + ), + flow( + 17, + (200.0, 100.0), + &[(122.5, 100.0, Some(3)), (277.5, 100.0, Some(1))], + ), + ]; + place_created_flow_ends(&mut elements, &HashSet::from([17])); + let f = flow_of(&elements, 17); + assert_eq!( + coords(f), + vec![(122.5, 90.0), (277.5, 90.0)], + "parallel faces: one line a pipe spacing off both occupants" + ); + assert_eq!( + (f.x, f.y), + (200.0, 90.0), + "parallel faces: the valve on the line" + ); + + // Stocks 1 at (100, 100) and 3 at (300, 128): the clearance spans share + // [113.5, 114.5], all within a spacing of the preserved flow at y = 114. + let mut elements = vec![ + stock(1, 100.0, 100.0), + stock(3, 300.0, 128.0), + flow( + 16, + (200.0, 114.0), + &[(122.5, 114.0, Some(1)), (277.5, 114.0, Some(3))], + ), + flow( + 17, + (200.0, 114.0), + &[(277.5, 114.0, Some(3)), (122.5, 114.0, Some(1))], + ), + ]; + place_created_flow_ends(&mut elements, &HashSet::from([17])); + let f = flow_of(&elements, 17); + assert_eq!( + coords(f), + vec![(277.5, 128.25), (122.5, 99.75)], + "offset faces: each end takes its own face's slot" + ); + assert_eq!((f.x, f.y), (200.0, 114.0), "offset faces: the valve stays"); + + // A preserved cloud flow leaves stock 1's right face at y = 100 into a + // cloud at (150, 100), and another flow's cloud sits at + // (177, 100 + CLOUD_RADIUS); a created flow leaves 7.25 below the first. + let mut elements = vec![ + stock(1, 100.0, 100.0), + flow( + 18, + (136.0, 100.0), + &[(122.5, 100.0, Some(1)), (150.0, 100.0, Some(98))], + ), + flow( + 20, + (177.0, 250.0), + &[ + (177.0, 100.0 + CLOUD_RADIUS, Some(97)), + (177.0, 300.0, Some(96)), + ], + ), + flow( + 19, + (136.0, 107.25), + &[(122.5, 107.25, Some(1)), (150.0, 107.25, Some(99))], + ), + ]; + place_created_flow_ends(&mut elements, &HashSet::from([19])); + assert_eq!( + coords(flow_of(&elements, 19)), + vec![ + (122.5, 100.0 + CLOUD_RADIUS), + (150.0 + 4.0 * CLOUD_RADIUS, 100.0 + CLOUD_RADIUS) + ], + "a cloud end is pushed a cloud's width at a time until no cloud overlaps it" + ); + + // Stocks 1 at (100, 100) and 3 at (300, 100): only stock 3's left face + // carries an end (a cloud flow entering it at y = 100) and stock 1's + // right face is empty, so only the second face constrains the line. + let mut elements = vec![ + stock(1, 100.0, 100.0), + stock(3, 300.0, 100.0), + flow( + 21, + (240.0, 100.0), + &[(200.0, 100.0, Some(95)), (277.5, 100.0, Some(3))], + ), + flow( + 22, + (200.0, 100.0), + &[(122.5, 100.0, Some(1)), (277.5, 100.0, Some(3))], + ), + ]; + place_created_flow_ends(&mut elements, &HashSet::from([22])); + assert_eq!( + coords(flow_of(&elements, 22)), + vec![(122.5, 90.0), (277.5, 90.0)], + "a joint line keeps the spacing from the second face's occupant" + ); + } +} diff --git a/src/simlin-engine/src/layout/incremental.rs b/src/simlin-engine/src/layout/incremental.rs index 8b6c370a6..fb6589b69 100644 --- a/src/simlin-engine/src/layout/incremental.rs +++ b/src/simlin-engine/src/layout/incremental.rs @@ -638,14 +638,20 @@ pub fn settle_new_elements( Ok(()) } -/// Re-snap stock-attached flow endpoints to stock edges after SFDP settlement. +/// Re-snap the stock-attached endpoints of every flow `include` selects (by +/// uid) to stock edges after SFDP settlement. /// /// SFDP may move flow valves while stocks stay pinned, causing the /// proportional point translation to detach endpoints from their stocks. /// This function restores each attached endpoint to the correct stock /// edge, using the flow valve position to determine which face of the -/// stock rectangle the flow approaches from. -pub fn resnap_flow_endpoints(state: &mut LayoutState, config: &LayoutConfig) { +/// stock rectangle the flow approaches from. Incremental layout selects only +/// the flows it creates: a preserved flow comes back byte for byte. +pub fn resnap_flow_endpoints( + state: &mut LayoutState, + config: &LayoutConfig, + include: impl Fn(i32) -> bool, +) { let stock_positions: HashMap = state .elements .iter() @@ -659,7 +665,9 @@ pub fn resnap_flow_endpoints(state: &mut LayoutState, config: &LayoutConfig) { let half_h = config.stock_height / 2.0; for elem in &mut state.elements { - if let ViewElement::Flow(f) = elem { + if let ViewElement::Flow(f) = elem + && include(f.uid) + { let valve = Position::new(f.x, f.y); for pt in &mut f.points { if let Some(attached_uid) = pt.attached_to_uid @@ -673,14 +681,16 @@ pub fn resnap_flow_endpoints(state: &mut LayoutState, config: &LayoutConfig) { if half_h * dx.abs() >= half_w * dy.abs() { // Horizontal approach: snap to left or right edge. // Preserve the y position (may be off-center for - // multi-flow sides), clamped to stock bounds. + // multi-flow sides), clamped into the face's span + // with corner clearance. pt.x = stock_pos.x + dx.signum() * half_w; - pt.y = pt.y.clamp(stock_pos.y - half_h, stock_pos.y + half_h); + pt.y = clamp_to_face_span(pt.y, stock_pos.y, half_h); } else { // Vertical approach: snap to top or bottom edge. // Preserve the x position (may be off-center for - // multi-flow sides), clamped to stock bounds. - pt.x = pt.x.clamp(stock_pos.x - half_w, stock_pos.x + half_w); + // multi-flow sides), clamped into the face's span + // with corner clearance. + pt.x = clamp_to_face_span(pt.x, stock_pos.x, half_w); pt.y = stock_pos.y + dy.signum() * half_h; } } @@ -1142,93 +1152,6 @@ fn existing_side_flow_faces( faces } -/// Re-sort flows on each affected stock's sides by their existing -/// attachment position rather than alphabetical ident. This preserves -/// the visual left-to-right (or top-to-bottom) ordering of imported or -/// manually-edited flows when a sibling is added or removed. -/// -/// Only affects flows that already have view elements in `state`; -/// new flows without positions are placed last (sorted by ident among -/// themselves). -fn reorder_attachments_by_position( - attachments: &mut HashMap, - state: &LayoutState, - affected_stocks: &HashSet, - metadata: &ComputedMetadata, -) { - for stock_ident in affected_stocks { - let stock_uid = match state.uid_manager.get_uid(stock_ident) { - Some(uid) => uid, - None => continue, - }; - - // Group flows on this stock by side, recording each flow's - // existing attachment position (x for Top/Bottom, y for Left/Right). - let mut by_side: HashMap> = HashMap::new(); - - for (flow_ident, att) in attachments.iter() { - let (from, to) = metadata.connected_stocks(flow_ident); - // Skip stock-to-stock flows: their attachment side depends on - // which stock classified them last, so including them would - // count them on the wrong side of one stock. - if from.is_some() && to.is_some() { - continue; - } - let connected = - from.is_some_and(|s| s == stock_ident) || to.is_some_and(|s| s == stock_ident); - if !connected { - continue; - } - - let pos_key = state - .uid_manager - .get_uid(flow_ident) - .and_then(|uid| { - state.elements.iter().find_map(|e| match e { - ViewElement::Flow(f) if f.uid == uid => f - .points - .iter() - .find(|pt| pt.attached_to_uid == Some(stock_uid)) - .map(|pt| match att.side { - StockAttachSide::Bottom | StockAttachSide::Top => pt.x, - StockAttachSide::Left | StockAttachSide::Right => pt.y, - }), - _ => None, - }) - }) - .unwrap_or(f64::MAX); // new flows sort last - - by_side - .entry(att.side) - .or_default() - .push((flow_ident.clone(), pos_key)); - } - - // Re-sort each side group by position and reassign offsets - for flows in by_side.values_mut() { - if flows.len() <= 1 { - continue; - } - flows.sort_by(|a, b| { - a.1.partial_cmp(&b.1) - .unwrap_or(std::cmp::Ordering::Equal) - .then_with(|| a.0.cmp(&b.0)) - }); - let n = flows.len(); - for (i, (flow_ident, _)) in flows.iter().enumerate() { - let offset = if n == 1 { - 0.5 - } else { - (i as f64 + 1.0) / (n as f64 + 1.0) - }; - if let Some(att) = attachments.get_mut(flow_ident) { - att.offset = offset; - } - } - } - } -} - /// Compute the valve position for a flow based on its attachment info and /// connected stock position. Returns `None` if the flow has no attachment /// or the stock position is unknown, in which case the caller should fall @@ -1248,25 +1171,6 @@ fn attachment_based_flow_position( Some(side_flow_valve_position(*stock_pos, *attachment, config)) } -/// Write `sides` back onto the named elements that carry those UIDs. Used by -/// incremental layout after a flow is rebuilt with unchanged orientation -/// (`create_flow_view_element` picks a default side) to reinstate the side -/// the element had before the rebuild. -fn restore_label_sides(state: &mut LayoutState, sides: &HashMap) { - for elem in &mut state.elements { - let Some(&side) = sides.get(&elem.get_uid()) else { - continue; - }; - match elem { - ViewElement::Stock(s) => s.label_side = side, - ViewElement::Flow(f) => f.label_side = side, - ViewElement::Aux(a) => a.label_side = side, - ViewElement::Module(m) => m.label_side = side, - _ => {} - } - } -} - /// Assemble a [`datamodel::StockFlow`] from finalized layout state, copying /// metadata (name, view box, zoom, font, sketch_compat) from `template`. /// @@ -1581,22 +1485,44 @@ fn translate_view_element(elem: &mut ViewElement, dx: f64, dy: f64) { /// /// Contract for elements the patch did not touch: position AND /// `label_side` are returned byte-for-byte. A label side is chosen only -/// for elements created in this pass -- new variables, kind-changed or -/// endpoint-changed rebuilds, and flows whose pipe orientation flipped. -/// A flow rebuilt merely to slide along the same stock face keeps its -/// side. The optimizer never revisits an existing side, even when a -/// connector added by this patch now crosses the label: hand placement -/// wins, and the human (or a full relayout) can move it. +/// for elements created in this pass -- new variables, kind-changed +/// rebuilds, and flows rebuilt because their attachment changed. The +/// optimizer never revisits an existing side, even when a connector added +/// by this patch now crosses the label: hand placement wins, and the human +/// (or a full relayout) can move it. +/// +/// Flows: a flow is rebuilt only when the patch creates it or changes the +/// flow's own attachment -- moves it to another stock, drops it from a +/// stock's list (that end becomes a cloud), lists it on a stock at its cloud +/// end (that end becomes the stock), deletes an attached stock, or changes +/// an attached stock's kind -- because its stored endpoints then name the +/// wrong element. A flow the patch names keeps its geometry (a rename changes +/// only its name), and every other flow, a sibling of a flow added to or +/// removed from the same stock included, comes back byte for byte -- points, +/// valve, label side and clouds -- even where a fresh layout would draw it +/// differently: re-spacing a face moves pipes a human placed. A created side +/// flow takes a face its stock's drawn side flows leave free where there is +/// one (`classify_flow_sides`). The endpoint snap, the free-slot placement +/// (`face_slots`) and the finishing pass (`finish_flow_geometry`) run only on +/// the flows this pass creates, so a created flow holds the flow invariants, +/// its stock end takes the largest free gap on its face (a flow between two +/// parallel faces takes one line where their free gaps overlap), and its +/// cloud is kept off the other clouds. The one repair made to an untouched +/// flow is wiring an endpoint the view left unattached to the flow's own +/// cloud (`diff_clouds`), which moves nothing. `layout_flow_tests.rs` +/// enumerates the arms. /// /// Composition: /// 1. Compute metadata for the post-patch model /// 2. Seed LayoutState from old view -/// 3. Process deletions and renames from the patch +/// 3. Process deletions and renames from the patch, and remove the +/// elements whose kind or attachment changed so they are rebuilt /// 4. Identify new elements, compute initial positions /// 5. Create view elements and settle via pinned SFDP -/// 6. Diff connectors/clouds, place labels for this pass's elements, -/// apply loop curvature -/// 7. Build StockFlow from final state +/// 6. Settle the created flows' geometry (snap, free slot, finishing pass) +/// 7. Diff connectors/clouds +/// 8. Polish and declutter what this pass added, apply loop curvature +/// 9. Build StockFlow from final state pub fn incremental_layout( old_view: &datamodel::StockFlow, project: &datamodel::Project, @@ -1791,276 +1717,49 @@ pub fn incremental_layout( // Step 4: Identify new elements and compute initial positions let new_elements = state.identify_new_elements(model); - // Compute flow attachments for flows on stocks that are affected by - // flow additions, deletions, or connection changes. This ensures - // preserved flows get reclassified when a sibling chain flow is - // added or removed. + // The face each new flow attaches on, from its stocks' current flow lists + // and the faces their drawn side flows already take. Only new flows read + // it: a preserved sibling keeps its face and slot, whatever the + // classification would give it now. let mut incr_flow_attachments: HashMap = HashMap::new(); - let mut affected_stocks: HashSet = HashSet::new(); - for flow_ident in &new_elements.new_flows { let (from_stock, to_stock) = metadata.connected_stocks(flow_ident); - if let Some(stock) = from_stock { - affected_stocks.insert(stock.to_string()); - } - if let Some(stock) = to_stock { - affected_stocks.insert(stock.to_string()); - } - } - - // Also mark stocks whose flow connections changed via the patch - // (e.g. when a chain flow is deleted, the stock loses a flow and - // remaining cloud flows may need reclassification from Bottom/Top - // back to Right/Left). - for op in &patch.ops { - if let crate::patch::ModelOperation::UpdateStockFlows { ident, .. } = op { - let canonical = canonicalize(ident).into_owned(); - affected_stocks.insert(canonical); - } - } - - // For deleted flows, find which stocks they were connected to in the - // old view. This handles patches that only emit DeleteVariable without - // UpdateStockFlows -- the remaining sibling flows still need to be - // reclassified. - // Build UID-to-ident map from the model's stock variables rather than - // from view element labels, since labels go through - // format_label_with_line_breaks and may not round-trip through - // canonicalize for quoted names like "a.b". - let stock_uid_to_ident: HashMap = model - .variables - .iter() - .filter_map(|v| { - if !matches!(v, datamodel::Variable::Stock(_)) { - return None; - } - let canonical = canonicalize(v.get_ident()).into_owned(); - state - .uid_manager - .get_uid(&canonical) - .map(|uid| (uid, canonical)) - }) - .collect(); - for op in &patch.ops { - if let crate::patch::ModelOperation::DeleteVariable { ident } = op { - let canonical = canonicalize(ident).into_owned(); - // Match by UID rather than display name: labels go through - // format_label_with_line_breaks which strips quoting, so - // canonicalizing the label back can produce a different ident - // for names like "a.b". - let deleted_uid = match state.uid_manager.get_uid(&canonical) { - Some(uid) => uid, - None => continue, - }; - for elem in &old_view.elements { - if let ViewElement::Flow(f) = elem - && f.uid == deleted_uid - { - for pt in &f.points { - if let Some(uid) = pt.attached_to_uid - && let Some(stock_ident) = stock_uid_to_ident.get(&uid) - { - affected_stocks.insert(stock_ident.clone()); - } - } - } - } - } - } - - // The faces the affected stocks' side flows are drawn on before the patch. - let mut existing_faces: HashMap = HashMap::new(); - for stock in &affected_stocks { - let existing = existing_side_flow_faces(&state, &config, &metadata, stock); - let sides = classify_flow_sides(stock, &metadata, &existing); - incr_flow_attachments.extend(sides); - existing_faces.extend(existing); - } - - // Re-sort flows within each side group by existing position rather - // than alphabetical ident, so imported or manually-edited ordering - // is preserved when a sibling is added or removed. - reorder_attachments_by_position( - &mut incr_flow_attachments, - &state, - &affected_stocks, - &metadata, - ); - - // Check if any existing (preserved) flows need to change sides. - // If classify_flow_sides assigns Bottom/Top to a flow that is - // currently horizontal (or Right/Left to one that is vertical), - // delete and rebuild it so its geometry matches. - let mut flows_to_rebuild: Vec = Vec::new(); - // Label sides of flows rebuilt only to move along the same stock face: - // their pipe keeps its orientation, so the existing (possibly hand-placed) - // side stays valid and is restored after the rebuild. Flows whose - // orientation flips are rebuilt with a freshly chosen side instead. - let mut offset_rebuilt_label_sides: HashMap = HashMap::new(); - for (flow_ident, attachment) in &incr_flow_attachments { - // Skip flows that are new (they'll be created below) - if new_elements.new_flows.contains(flow_ident) { - continue; - } - // Skip stock-to-stock (chain) flows entirely: their pipe geometry - // is determined by both stock positions and ignores the attachment - // offset. Rebuilding them via attachment_based_flow_position (which - // only knows one stock) would place the valve beside one stock - // instead of between the pair. - let (from_stock, to_stock) = metadata.connected_stocks(flow_ident); - if from_stock.is_some() && to_stock.is_some() { - continue; - } - // Check if this flow exists and has mismatched orientation or offset - if let Some(uid) = state.uid_manager.get_uid(flow_ident) { - let existing = state.elements.iter().find(|e| { - if let ViewElement::Flow(f) = e { - f.uid == uid - } else { - false - } - }); - if let Some(ViewElement::Flow(f)) = existing { - let orientation = compute_flow_orientation(&f.points); - let needs_vertical = matches!( - attachment.side, - StockAttachSide::Bottom | StockAttachSide::Top - ); - let is_vertical = matches!(orientation, FlowOrientation::Vertical); - if needs_vertical != is_vertical { - flows_to_rebuild.push(flow_ident.clone()); - } else if existing_faces - .get(flow_ident) - .is_some_and(|&side| side != attachment.side) - { - // Moved to the opposite face (top <-> bottom, left <-> - // right): the pipe keeps its orientation, so the label - // side stays valid. - flows_to_rebuild.push(flow_ident.clone()); - offset_rebuilt_label_sides.insert(flow_ident.clone(), f.label_side); - } else { - // Orientation matches but the offset may have changed - // (e.g. a sibling was added/removed on the same face). - let stock_name = from_stock.or(to_stock); - if let Some(sn) = stock_name - && let Some(stock_uid) = state.uid_manager.get_uid(sn) - && let Some(&stock_pos) = state.positions.get(&stock_uid) - { - let (expected, current) = if needs_vertical { - let exp = stock_pos.x - config.stock_width / 2.0 - + config.stock_width * attachment.offset; - let cur = f - .points - .iter() - .find(|pt| pt.attached_to_uid == Some(stock_uid)) - .map(|pt| pt.x); - (exp, cur) - } else { - let exp = stock_pos.y - config.stock_height / 2.0 - + config.stock_height * attachment.offset; - let cur = f - .points - .iter() - .find(|pt| pt.attached_to_uid == Some(stock_uid)) - .map(|pt| pt.y); - (exp, cur) - }; - if let Some(c) = current - && (c - expected).abs() > 0.5 - { - flows_to_rebuild.push(flow_ident.clone()); - offset_rebuilt_label_sides.insert(flow_ident.clone(), f.label_side); - } - } - } + for stock in [from_stock, to_stock].into_iter().flatten() { + let existing = existing_side_flow_faces(&state, &config, &metadata, stock); + if let Some(attachment) = + classify_flow_sides(stock, &metadata, &existing).remove(flow_ident) + { + incr_flow_attachments.insert(flow_ident.clone(), attachment); } } } - // Save old positions before deletion so we have a fallback if - // attachment_based_flow_position can't resolve the stock UID - // (e.g. imported views with quoted identifiers). - let old_flow_positions: HashMap = flows_to_rebuild - .iter() - .filter_map(|ident| { - let uid = state.uid_manager.get_uid(ident)?; - state.positions.get(&uid).map(|&pos| (ident.clone(), pos)) - }) - .collect(); - - // Delete and rebuild flows that need to change orientation or offset - for flow_ident in &flows_to_rebuild { - let saved_display = state - .display_names - .get(&canonicalize(flow_ident).into_owned()) - .cloned(); - state.apply_deletion(flow_ident); - if let Some(display) = saved_display { - state - .display_names - .insert(canonicalize(flow_ident).into_owned(), display); - } - } - // Every element still standing at this point survived the patch untouched // (or was merely renamed): it keeps its position, and a named one its label // side, for the rest of the pass. Whatever gets created from here on -- new - // variables, kind-changed or endpoint-changed rebuilds, orientation-flipped - // flows -- is absent from this snapshot, so `declutter_part` below chooses - // its side and may move it. Offset-only rebuilt flows are added back to the - // pinned sides explicitly because they were just deleted but keep their - // orientation. + // variables, kind-changed rebuilds, flows rebuilt because their attachment + // changed -- is absent from this snapshot, so `declutter_part` below chooses + // its side and may move it. let standing_uids: HashSet = state.elements.iter().map(ViewElement::get_uid).collect(); - let mut pinned_label_sides: HashMap = state + let pinned_labels: HashSet = state .elements .iter() - .filter_map(|elem| match elem { - ViewElement::Stock(s) => Some((s.uid, s.label_side)), - ViewElement::Flow(f) => Some((f.uid, f.label_side)), - ViewElement::Aux(a) => Some((a.uid, a.label_side)), - ViewElement::Module(m) => Some((m.uid, m.label_side)), - _ => None, + .filter(|elem| { + matches!( + elem, + ViewElement::Stock(_) + | ViewElement::Flow(_) + | ViewElement::Aux(_) + | ViewElement::Module(_) + ) }) + .map(ViewElement::get_uid) .collect(); - for (flow_ident, side) in &offset_rebuilt_label_sides { - if let Some(uid) = state.uid_manager.get_uid(flow_ident) { - pinned_label_sides.insert(uid, *side); - } - } - - // Compute positions for rebuilt flows based on their attachment info, - // falling back to the old position if the stock UID lookup fails. - for flow_ident in &flows_to_rebuild { - let pos = attachment_based_flow_position( - &state, - &config, - &metadata, - flow_ident, - &incr_flow_attachments, - ) - .or_else(|| old_flow_positions.get(flow_ident).copied()); - if let Some(pos) = pos { - let uid = state.get_or_alloc_uid(flow_ident); - create_flow_view_element( - &mut state, - &config, - &metadata, - flow_ident, - uid, - pos, - &incr_flow_attachments, - )?; - } - } - - restore_label_sides(&mut state, &pinned_label_sides); - let needs_label_placement = |uid: i32| !pinned_label_sides.contains_key(&uid); + let needs_label_placement = |uid: i32| !pinned_labels.contains(&uid); if new_elements.is_empty() { - // No new elements and no settlement step, so rebuilt flows - // already have correct geometry from create_flow_view_element. - // Skip resnap entirely to avoid rewriting unrelated manual or - // imported flow endpoints elsewhere in the diagram. + // No new element, so no flow is created or rebuilt: every flow in the + // view is untouched, and none of the flow geometry passes runs. diff_connectors(&mut state, &metadata); diff_clouds(&mut state, &metadata); declutter::declutter_part(&mut state.elements, needs_label_placement, |_| false); @@ -2069,6 +1768,10 @@ pub fn incremental_layout( return Ok(build_stock_flow_from_state(state, old_view)); } + // Every flow this pass builds, whichever placement below lays it out: the + // flow geometry passes of step 6b run on these and on nothing else. + let created_flow_idents = new_elements.new_flows.clone(); + // Step 4b: chains the patch added whole are laid out as chains and set // down beside the diagram; the rest of what is new is placed generically. let mut placed_chain_vars = place_new_chains(&mut state, &config, &metadata, &new_elements)?; @@ -2238,7 +1941,21 @@ pub fn incremental_layout( } } - resnap_flow_endpoints(&mut state, &config); + // Step 6b: Settle the geometry of the flows this pass created, and only + // those: an untouched flow comes back byte for byte, even where a fresh + // layout would draw it differently. Each created flow's stock ends snap + // onto the face its valve approaches and take a slot `PIPE_SPACING` clear + // of the ends already on that face where the face has room + // (`face_slots`); the pipe is then orthogonalized and brought to the flow + // invariants. + let created_flows: HashSet = created_flow_idents + .iter() + .filter_map(|ident| state.uid_manager.get_uid(ident)) + .collect(); + let is_created = |uid: i32| created_flows.contains(&uid); + resnap_flow_endpoints(&mut state, &config, is_created); + face_slots::place_created_flow_ends(&mut state.elements, &created_flows); + finish_flow_geometry(&mut state.elements, is_created); // Step 7: Diff connectors and clouds diff_connectors(&mut state, &metadata); @@ -2266,10 +1983,6 @@ pub fn incremental_layout( } } apply_loop_curvature(&mut state, &config, model, &metadata); - // Guarantee flows stay orthogonal after re-snapping endpoints to moved - // stocks (only rewrites pipes that actually went diagonal; hand-routed - // orthogonal flows are left untouched). - orthogonal::orthogonalize_flow_pipes(&mut state.elements); validate_view_completeness(&state, model)?; diff --git a/src/simlin-engine/src/layout/layout_flow_geometry_tests.rs b/src/simlin-engine/src/layout/layout_flow_geometry_tests.rs new file mode 100644 index 000000000..d9eb48a08 --- /dev/null +++ b/src/simlin-engine/src/layout/layout_flow_geometry_tests.rs @@ -0,0 +1,223 @@ +// Copyright 2026 The Simlin Authors. All rights reserved. +// Use of this source code is governed by the Apache License, +// Version 2.0, that can be found in the LICENSE file. + +//! The flow invariants (`diagram::flow_geometry`) on the geometry the layout +//! produces: where endpoints land on stock faces, what the finishing pass +//! settles, and what a fresh layout hands back. Mounted from `layout_tests.rs`, +//! whose helpers it reuses. + +use super::*; +use crate::diagram::flow_geometry::flow_invariant_violations; + +/// `resnap_flow_endpoints` keeps an endpoint's position along the face it +/// snaps to, but never within `CORNER_CLEARANCE` of a corner, on either +/// approach: a pipe drawn into a corner reads as attached to two faces. +#[test] +fn test_resnap_keeps_corner_clearance_on_both_approaches() { + let config = LayoutConfig::default(); + let half_w = config.stock_width / 2.0; + let half_h = config.stock_height / 2.0; + let clearance = crate::diagram::flow_geometry::CORNER_CLEARANCE; + // (valve, endpoint before resnap, expected endpoint): the preserved + // coordinate lies beyond the stock's span on each row. + let rows = [ + ( + "horizontal approach", + (400.0, 100.0), + (222.5, 140.0), + (200.0 + half_w, 100.0 + half_h - clearance), + ), + ( + "vertical approach", + (200.0, 400.0), + (160.0, 117.5), + (200.0 - half_w + clearance, 100.0 + half_h), + ), + ]; + for (label, valve, before, expected) in rows { + let model = simple_model(); + let mut state = LayoutState::new(&model); + state.elements.push(ViewElement::Stock(view_element::Stock { + name: "stock_a".into(), + uid: 1, + x: 200.0, + y: 100.0, + label_side: LabelSide::Bottom, + compat: None, + })); + state.elements.push(ViewElement::Flow(view_element::Flow { + name: "my_flow".into(), + uid: 2, + x: valve.0, + y: valve.1, + label_side: LabelSide::Bottom, + points: vec![ + FlowPoint { + x: before.0, + y: before.1, + attached_to_uid: Some(1), + }, + FlowPoint { + x: valve.0, + y: valve.1, + attached_to_uid: None, + }, + ], + compat: None, + label_compat: None, + })); + resnap_flow_endpoints(&mut state, &config, |_| true); + let ViewElement::Flow(f) = &state.elements[1] else { + unreachable!() + }; + assert!( + (f.points[0].x - expected.0).abs() < 1e-9 && (f.points[0].y - expected.1).abs() < 1e-9, + "{label}: endpoint ({}, {}) expected {expected:?}", + f.points[0].x, + f.points[0].y + ); + } +} + +/// `finish_flow_geometry` on a pipe the orthogonalizer alone leaves invalid: +/// the valve and the cloud sit on a row beyond the stock's face span, so the +/// endpoint was clamped into the span and the pipe leaves the stock +/// diagonally. The orthogonalizer routes an `L` between the two attached ends +/// that passes through neither the valve nor its row; the valve has to be +/// brought onto the route. +/// +/// The input is `generate_layout`'s own geometry for `dr` in +/// `test/metasd/scientific-revolution/scirev7.mdl` as it reaches the +/// finishing pass, translated so the stock sits at (100, 100) and rounded to +/// the half pixel. That model's placement alone takes over three seconds on a +/// debug build, past the per-test budget, so the capture stands in for it; the +/// corpus measurement covers the whole model. +#[test] +fn test_finish_flow_geometry_brings_a_valve_off_the_route_onto_it() { + let mut elements = vec![ + ViewElement::Stock(view_element::Stock { + name: "p".into(), + uid: 1, + x: 100.0, + y: 100.0, + label_side: LabelSide::Bottom, + compat: None, + }), + ViewElement::Flow(view_element::Flow { + name: "dr".into(), + uid: 2, + x: 450.0, + y: 128.0, + label_side: LabelSide::Bottom, + points: vec![ + FlowPoint { + x: 122.5, + y: 114.5, + attached_to_uid: Some(1), + }, + FlowPoint { + x: 691.5, + y: 128.0, + attached_to_uid: Some(3), + }, + ], + compat: None, + label_compat: None, + }), + ViewElement::Cloud(view_element::Cloud { + uid: 3, + flow_uid: 2, + x: 691.5, + y: 128.0, + compat: None, + }), + ]; + finish_flow_geometry(&mut elements, |_| true); + assert_eq!(flow_invariant_violations(&elements), Vec::::new()); +} + +/// `finish_flow_geometry` on a pipe whose orthogonal route leaves a stub +/// under `MIN_SEGMENT`: `aeei install_rate` in +/// `test/metasd/FREE/FREE6/FREE6-original/free 6.mdl`, as `generate_best_layout` +/// hands it to the finishing pass (seed 456, the seed that wins), captured +/// unrounded. The cloud sits on a row 2.16px above the sink's clamped face +/// slot, so the orthogonalizer's L rises 2.16px at the cloud before running +/// into the stock; the stub collapses and the pipe runs straight. That model's +/// placement takes tens of seconds on a debug build, so the capture stands in +/// for it; the corpus measurement covers the whole model. +#[test] +fn test_finish_flow_geometry_collapses_a_stub_under_the_minimum() { + let (cloud_x, cloud_y) = (1208.8755575407895, 1768.5582441059819); + let sink = (1536.2478075407898, 1770.7188274393143); + let mut elements = vec![ + ViewElement::Stock(view_element::Stock { + name: "embodied\\naeei".into(), + uid: 15, + x: 1558.7478075407898, + y: 1785.2188274393143, + label_side: LabelSide::Bottom, + compat: None, + }), + ViewElement::Flow(view_element::Flow { + name: "aeei\\ninstall_rate".into(), + uid: 16, + x: 1351.6805575407893, + y: cloud_y, + label_side: LabelSide::Top, + points: vec![ + FlowPoint { + x: cloud_x, + y: cloud_y, + attached_to_uid: Some(17), + }, + FlowPoint { + x: sink.0, + y: sink.1, + attached_to_uid: Some(15), + }, + ], + compat: None, + label_compat: None, + }), + ViewElement::Cloud(view_element::Cloud { + uid: 17, + flow_uid: 16, + x: cloud_x, + y: cloud_y, + compat: None, + }), + ]; + finish_flow_geometry(&mut elements, |_| true); + assert_eq!(flow_invariant_violations(&elements), Vec::::new()); + let ViewElement::Flow(f) = &elements[1] else { + unreachable!() + }; + assert_eq!( + f.points.iter().map(|p| (p.x, p.y)).collect::>(), + vec![(cloud_x, sink.1), sink] + ); +} + +/// Engine auto-layout holds the flow invariants end to end on a model whose +/// layout puts stocks off each other's rows and columns: `leaky_conveyor.xmile`, +/// whose leak flows ended on stock corners. This row pins the corner arm of +/// endpoint placement. The finishing pass's valve arm is pinned by +/// `test_finish_flow_geometry_brings_a_valve_off_the_route_onto_it`, the +/// resnap clearance by `test_resnap_keeps_corner_clearance_on_both_approaches`, +/// and the normalization's own arms by `diagram::flow_geometry_tests`; the +/// larger models that reach them through a whole layout (scirev7, thyroid, +/// C-LEARN) are measured corpus-wide rather than run here, to keep this test +/// within the time budget. +#[test] +fn test_fresh_layout_holds_the_flow_invariants() { + const LEAKY: &str = include_str!("../../../../test/conveyors/leaky_conveyor.xmile"); + let project = crate::compat::open_xmile(&mut std::io::BufReader::new(LEAKY.as_bytes())) + .expect("leaky_conveyor imports"); + let model_name = project.models[0].name.clone(); + let view = generate_layout(&project, &model_name, None).expect("layout"); + assert_eq!( + flow_invariant_violations(&view.elements), + Vec::::new() + ); +} diff --git a/src/simlin-engine/src/layout/layout_flow_tests.rs b/src/simlin-engine/src/layout/layout_flow_tests.rs new file mode 100644 index 000000000..c2f881e97 --- /dev/null +++ b/src/simlin-engine/src/layout/layout_flow_tests.rs @@ -0,0 +1,1010 @@ +// Copyright 2026 The Simlin Authors. All rights reserved. +// Use of this source code is governed by the Apache License, +// Version 2.0, that can be found in the LICENSE file. + +//! Incremental layout's flow contract. +//! +//! A flow is rebuilt only when the patch creates it or changes the flow's own +//! attachment: it moves to another stock, it is dropped from a stock's list or +//! listed on a stock at its cloud end, an attached stock is deleted, or an +//! attached stock changes kind. Every other flow comes back byte for byte -- +//! points, valve, label side and clouds -- even where its stored geometry is +//! not what a fresh layout would draw. A flow the pass builds holds the flow +//! invariants (`diagram::flow_geometry`), and its stock end keeps +//! `PIPE_SPACING` from the ends already on its face where the face has room +//! (the design plan's routing preference). One test per way a patch relates to +//! a flow: +//! +//! - names the flow: an upsert keeps it, a rename keeps all but the name, a +//! delete removes it with its clouds +//! - changes its attachment -- moves it to another stock, drops it from a +//! stock's list (that end becomes a cloud), lists it on a stock at its cloud +//! end (the cloud end becomes the stock), deletes an attached stock, changes +//! an attached stock's kind: rebuilt +//! - touches only a sibling -- a flow added on its face, a chain flow added on +//! a face it occupies, its stock's chain removed: preserved, and a created +//! sibling keeps the spacing +//! - creates flows on an imported view (`SIR.xmile`, `mark2.mdl`, and a +//! reverse flow between offset stocks imported from XMILE): they keep the +//! spacing from the ends already there, their clouds stay off other clouds +//! and pipes, and nothing else moves +//! - touches an unrelated element, with a new element (the settle path) and +//! without one (the early-return path): preserved, a diagonal pipe, an +//! off-face endpoint and an off-pipe valve included +//! +//! Label sides are enumerated in `layout_label_tests.rs`. + +use super::*; +use crate::datamodel::{self, view_element::Cloud}; +use crate::diagram::constants::CLOUD_RADIUS; +use crate::diagram::flow_geometry::{ + CORNER_CLEARANCE, PIPE_SPACING, VALVE_CLAMP_MARGIN, flow_invariant_violations, +}; +use crate::patch::{ModelOperation, ModelPatch}; + +fn stock(ident: &str, inflows: &[&str], outflows: &[&str]) -> datamodel::Variable { + datamodel::Variable::Stock(datamodel::Stock { + ident: ident.to_string(), + equation: datamodel::Equation::Scalar("100".to_string()), + documentation: String::new(), + units: None, + inflows: inflows.iter().map(|s| s.to_string()).collect(), + outflows: outflows.iter().map(|s| s.to_string()).collect(), + compat: datamodel::Compat::default(), + ai_state: None, + uid: None, + }) +} + +fn flow(ident: &str, equation: &str) -> datamodel::Flow { + datamodel::Flow { + ident: ident.to_string(), + equation: datamodel::Equation::Scalar(equation.to_string()), + documentation: String::new(), + units: None, + gf: None, + compat: datamodel::Compat::default(), + ai_state: None, + uid: None, + } +} + +fn aux(ident: &str, equation: &str) -> datamodel::Aux { + datamodel::Aux { + ident: ident.to_string(), + equation: datamodel::Equation::Scalar(equation.to_string()), + documentation: String::new(), + units: None, + gf: None, + compat: datamodel::Compat::default(), + ai_state: None, + uid: None, + } +} + +fn project_of(variables: Vec) -> datamodel::Project { + test_project(datamodel::Model { + name: TEST_MODEL.to_string(), + sim_specs: None, + variables, + views: Vec::new(), + loop_metadata: Vec::new(), + groups: Vec::new(), + macro_spec: None, + }) +} + +/// stock_a -> chain_flow -> stock_b, plus stock_a -> waste_a -> cloud, whose +/// rate reads the aux leak_rate. +fn chain_and_waste() -> datamodel::Project { + project_of(vec![ + stock("stock_a", &[], &["chain_flow", "waste_a"]), + stock("stock_b", &["chain_flow"], &[]), + datamodel::Variable::Flow(flow("chain_flow", "10")), + datamodel::Variable::Flow(flow("waste_a", "stock_a * leak_rate")), + datamodel::Variable::Aux(aux("leak_rate", "0.1")), + ]) +} + +/// A flow element by canonical name, with the clouds it owns in uid order. +fn flow_and_clouds(view: &datamodel::StockFlow, ident: &str) -> (view_element::Flow, Vec) { + let f = find_flow(view, ident).unwrap_or_else(|| panic!("flow {ident} not in view")); + let mut clouds: Vec = view + .elements + .iter() + .filter_map(|e| match e { + ViewElement::Cloud(c) if c.flow_uid == f.uid => Some(c.clone()), + _ => None, + }) + .collect(); + clouds.sort_by_key(|c| c.uid); + (f, clouds) +} + +fn find_flow(view: &datamodel::StockFlow, ident: &str) -> Option { + view.elements.iter().find_map(|e| match e { + ViewElement::Flow(f) if canonicalize(&f.name) == ident => Some(f.clone()), + _ => None, + }) +} + +fn stock_named(view: &datamodel::StockFlow, ident: &str) -> view_element::Stock { + view.elements + .iter() + .find_map(|e| match e { + ViewElement::Stock(s) if canonicalize(&s.name) == ident => Some(s.clone()), + _ => None, + }) + .unwrap_or_else(|| panic!("stock {ident} not in view")) +} + +/// The flow invariant violations that concern one flow: its own, and a cloud +/// of its drawn inside a stock. +fn violations_of(view: &datamodel::StockFlow, ident: &str) -> Vec { + let (f, clouds) = flow_and_clouds(view, ident); + let own = format!("{}:", f.name); + let cloud_prefixes: Vec = clouds.iter().map(|c| format!("cloud {} ", c.uid)).collect(); + flow_invariant_violations(&view.elements) + .into_iter() + .filter(|v| v.starts_with(&own) || cloud_prefixes.iter().any(|p| v.starts_with(p))) + .collect() +} + +fn set_stock_flows(model: &mut datamodel::Model, ident: &str, inflows: &[&str], outflows: &[&str]) { + for var in &mut model.variables { + if let datamodel::Variable::Stock(s) = var + && s.ident == ident + { + s.inflows = inflows.iter().map(|f| f.to_string()).collect(); + s.outflows = outflows.iter().map(|f| f.to_string()).collect(); + } + } +} + +/// Apply `ops` to a copy of `base` edited by `edit` (the post-patch model, as +/// `apply_patch` leaves it) and lay the view out incrementally. +fn incremental( + base: &datamodel::Project, + old_view: &datamodel::StockFlow, + edit: impl FnOnce(&mut datamodel::Model), + ops: Vec, +) -> datamodel::StockFlow { + let mut patched = base.clone(); + edit(patched.get_model_mut(TEST_MODEL).unwrap()); + let patch = ModelPatch { + name: TEST_MODEL.to_string(), + ops, + }; + incremental_layout(old_view, &patched, TEST_MODEL, &patch, None).expect("incremental layout") +} + +fn assert_preserved( + old: &datamodel::StockFlow, + new: &datamodel::StockFlow, + idents: &[&str], + row: &str, +) { + for ident in idents { + assert_eq!( + flow_and_clouds(new, ident), + flow_and_clouds(old, ident), + "{row}: {ident} must come back byte for byte" + ); + } +} + +/// Each stock end of `ident` keeps the design plan's routing preference on its +/// face, stated from the geometry independently of `face_slots`: at least +/// `PIPE_SPACING` from every other end on that face when the face's clearance +/// span has such a position, and otherwise as far from them as the span +/// allows (sampled at a quarter pixel). +fn assert_slot_keeps_spacing(view: &datamodel::StockFlow, ident: &str, row: &str) { + let config = LayoutConfig::default(); + let (half_w, half_h) = (config.stock_width / 2.0, config.stock_height / 2.0); + let f = find_flow(view, ident).unwrap(); + let stocks: HashMap = view + .elements + .iter() + .filter_map(|e| match e { + ViewElement::Stock(s) => Some((s.uid, (s.x, s.y))), + _ => None, + }) + .collect(); + // (along-coordinate, which face) of a point on a stock face, or None. + let face_of = |p: &FlowPoint, s: (f64, f64)| -> Option<(f64, i8)> { + let (dx, dy) = (p.x - s.0, p.y - s.1); + if (dx.abs() - half_w).abs() < 1e-6 && dy.abs() <= half_h { + Some((p.y, if dx > 0.0 { 0 } else { 1 })) + } else if (dy.abs() - half_h).abs() < 1e-6 && dx.abs() <= half_w { + Some((p.x, if dy > 0.0 { 2 } else { 3 })) + } else { + None + } + }; + let last = f.points.len() - 1; + for end in [0, last] { + let p = &f.points[end]; + let Some(&s) = p.attached_to_uid.and_then(|u| stocks.get(&u)) else { + continue; + }; + let (at, face) = + face_of(p, s).unwrap_or_else(|| panic!("{row}: {ident} end off its stock")); + let (center, half) = if face >= 2 { + (s.0, half_w) + } else { + (s.1, half_h) + }; + let reach = half - CORNER_CLEARANCE; + let mut others: Vec = Vec::new(); + for e in &view.elements { + let ViewElement::Flow(g) = e else { continue }; + let g_last = g.points.len() - 1; + for (i, q) in [(0, &g.points[0]), (g_last, &g.points[g_last])] { + if (g.uid, i) == (f.uid, end) || q.attached_to_uid != p.attached_to_uid { + continue; + } + if let Some((along, g_face)) = face_of(q, s) + && g_face == face + { + others.push(along); + } + } + } + let least = |v: f64| { + others + .iter() + .map(|o| (o - v).abs()) + .fold(f64::INFINITY, f64::min) + }; + let samples = (8.0 * reach) as usize; + let best = (0..=samples) + .map(|i| center - reach + 2.0 * reach * i as f64 / samples as f64) + .map(least) + .fold(0.0, f64::max); + let got = least(at); + if best >= PIPE_SPACING { + assert!( + got >= PIPE_SPACING - 1e-6, + "{row}: {ident}'s end at {at} is {got} from another end on its face, \ + under PIPE_SPACING though the face has room" + ); + } else { + assert!( + got >= best - 0.25, + "{row}: {ident}'s end at {at} is {got} from another end on its face, \ + though the face offers {best}" + ); + } + } +} + +#[test] +fn a_flow_the_patch_names_keeps_its_geometry() { + let base = chain_and_waste(); + let old = generate_layout(&base, TEST_MODEL, None).expect("initial layout"); + + let new = incremental( + &base, + &old, + |m| { + for var in &mut m.variables { + if let datamodel::Variable::Flow(f) = var + && f.ident == "waste_a" + { + f.equation = datamodel::Equation::Scalar("stock_a * leak_rate * 2".to_string()); + } + } + }, + vec![ModelOperation::UpsertFlow(flow( + "waste_a", + "stock_a * leak_rate * 2", + ))], + ); + assert_preserved(&old, &new, &["waste_a", "chain_flow"], "upsert"); + + let new = incremental( + &base, + &old, + |m| { + for var in &mut m.variables { + if let datamodel::Variable::Flow(f) = var + && f.ident == "waste_a" + { + f.ident = "spill".to_string(); + } + } + set_stock_flows(m, "stock_a", &[], &["chain_flow", "spill"]); + }, + vec![ModelOperation::RenameVariable { + from: "waste_a".to_string(), + to: "spill".to_string(), + }], + ); + let (mut renamed, clouds) = flow_and_clouds(&old, "waste_a"); + let (spill, spill_clouds) = flow_and_clouds(&new, "spill"); + renamed.name = spill.name.clone(); + assert_eq!( + (spill, spill_clouds), + (renamed, clouds), + "rename: all but the name" + ); + assert_preserved(&old, &new, &["chain_flow"], "rename"); + + let waste_uid = find_flow(&old, "waste_a").unwrap().uid; + let new = incremental( + &base, + &old, + |m| { + m.variables.retain(|v| v.get_ident() != "waste_a"); + set_stock_flows(m, "stock_a", &[], &["chain_flow"]); + }, + vec![ModelOperation::DeleteVariable { + ident: "waste_a".to_string(), + }], + ); + assert!( + find_flow(&new, "waste_a").is_none(), + "delete: the flow is gone" + ); + assert!( + !new.elements + .iter() + .any(|e| matches!(e, ViewElement::Cloud(c) if c.flow_uid == waste_uid)), + "delete: its clouds are gone" + ); + assert_preserved(&old, &new, &["chain_flow"], "delete"); +} + +/// One way a patch changes a flow's attachment: the patch, the flow it +/// rebuilds, the stock each end must then attach to (`None`: a cloud of the +/// flow's own), and the flows it must leave alone. +struct Reattachment { + label: &'static str, + edit: Box, + ops: Vec, + ident: &'static str, + source: Option<&'static str>, + sink: Option<&'static str>, + preserved: &'static [&'static str], +} + +#[test] +fn a_flow_whose_attachment_changes_is_rebuilt() { + let base = chain_and_waste(); + let old = generate_layout(&base, TEST_MODEL, None).expect("initial layout"); + + let rows = vec![ + Reattachment { + label: "moved to another stock", + edit: Box::new(|m| { + set_stock_flows(m, "stock_a", &[], &["chain_flow"]); + set_stock_flows(m, "stock_b", &["chain_flow"], &["waste_a"]); + }), + ops: vec![ + ModelOperation::UpdateStockFlows { + ident: "stock_a".to_string(), + inflows: vec![], + outflows: vec!["chain_flow".to_string()], + }, + ModelOperation::UpdateStockFlows { + ident: "stock_b".to_string(), + inflows: vec!["chain_flow".to_string()], + outflows: vec!["waste_a".to_string()], + }, + ], + ident: "waste_a", + source: Some("stock_b"), + sink: None, + preserved: &["chain_flow"], + }, + Reattachment { + label: "a stock drops the flow from its list (that end becomes a cloud)", + edit: Box::new(|m| set_stock_flows(m, "stock_a", &[], &["chain_flow"])), + ops: vec![ModelOperation::UpdateStockFlows { + ident: "stock_a".to_string(), + inflows: vec![], + outflows: vec!["chain_flow".to_string()], + }], + ident: "waste_a", + source: None, + sink: None, + preserved: &["chain_flow"], + }, + Reattachment { + label: "a stock lists the flow at its cloud end (the cloud end becomes the stock)", + edit: Box::new(|m| set_stock_flows(m, "stock_b", &["chain_flow", "waste_a"], &[])), + ops: vec![ModelOperation::UpdateStockFlows { + ident: "stock_b".to_string(), + inflows: vec!["chain_flow".to_string(), "waste_a".to_string()], + outflows: vec![], + }], + ident: "waste_a", + source: Some("stock_a"), + sink: Some("stock_b"), + preserved: &["chain_flow"], + }, + Reattachment { + label: "an attached stock deleted", + edit: Box::new(|m| m.variables.retain(|v| v.get_ident() != "stock_b")), + ops: vec![ModelOperation::DeleteVariable { + ident: "stock_b".to_string(), + }], + ident: "chain_flow", + source: Some("stock_a"), + sink: None, + preserved: &["waste_a"], + }, + Reattachment { + label: "an attached stock turned into an aux", + edit: Box::new(|m| { + m.variables.retain(|v| v.get_ident() != "stock_b"); + m.variables + .push(datamodel::Variable::Aux(aux("stock_b", "0"))); + }), + ops: vec![ModelOperation::UpsertAux(aux("stock_b", "0"))], + ident: "chain_flow", + source: Some("stock_a"), + sink: None, + preserved: &["waste_a"], + }, + ]; + for Reattachment { + label, + edit, + ops, + ident, + source, + sink, + preserved, + } in rows + { + let new = incremental(&base, &old, edit, ops); + let f = find_flow(&new, ident).unwrap(); + let last = f.points.len() - 1; + for (end, expected) in [(0, source), (last, sink)] { + let attached = f.points[end].attached_to_uid; + match expected { + Some(s) => assert_eq!( + attached, + Some(stock_named(&new, s).uid), + "{label}: {ident} end {end} attaches to {s}" + ), + None => assert!( + new.elements.iter().any( + |e| matches!(e, ViewElement::Cloud(c) if Some(c.uid) == attached && c.flow_uid == f.uid) + ), + "{label}: {ident} end {end} attaches to a cloud of its own" + ), + } + } + assert_eq!(violations_of(&new, ident), Vec::::new(), "{label}"); + assert_preserved(&old, &new, preserved, label); + } +} + +#[test] +fn a_flow_whose_sibling_changes_is_preserved() { + let base = chain_and_waste(); + let old = generate_layout(&base, TEST_MODEL, None).expect("initial layout"); + + let new = incremental( + &base, + &old, + |m| { + set_stock_flows(m, "stock_a", &[], &["chain_flow", "waste_a", "waste_b"]); + m.variables + .push(datamodel::Variable::Flow(flow("waste_b", "1"))); + }, + vec![ + ModelOperation::UpsertFlow(flow("waste_b", "1")), + ModelOperation::UpdateStockFlows { + ident: "stock_a".to_string(), + inflows: vec![], + outflows: vec![ + "chain_flow".to_string(), + "waste_a".to_string(), + "waste_b".to_string(), + ], + }, + ], + ); + let row = "a flow added on its face"; + assert_preserved(&old, &new, &["waste_a", "chain_flow"], row); + assert_eq!( + violations_of(&new, "waste_b"), + Vec::::new(), + "{row}" + ); + assert_slot_keeps_spacing(&new, "waste_b", row); + + // Classification now puts waste_a on the right face (stock_a has no chain + // outflow left); the preserved flow stays on the bottom. + let new = incremental( + &base, + &old, + |m| { + m.variables + .retain(|v| v.get_ident() != "chain_flow" && v.get_ident() != "stock_b"); + set_stock_flows(m, "stock_a", &[], &["waste_a"]); + }, + vec![ + ModelOperation::DeleteVariable { + ident: "chain_flow".to_string(), + }, + ModelOperation::DeleteVariable { + ident: "stock_b".to_string(), + }, + ModelOperation::UpdateStockFlows { + ident: "stock_a".to_string(), + inflows: vec![], + outflows: vec!["waste_a".to_string()], + }, + ], + ); + assert_preserved(&old, &new, &["waste_a"], "its stock's chain removed"); + + let waste_only = project_of(vec![ + stock("stock_a", &[], &["waste_flow"]), + datamodel::Variable::Flow(flow("waste_flow", "5")), + ]); + let old = generate_layout(&waste_only, TEST_MODEL, None).expect("initial layout"); + let new = incremental( + &waste_only, + &old, + |m| { + set_stock_flows(m, "stock_a", &[], &["waste_flow", "chain_flow"]); + m.variables.push(stock("stock_b", &["chain_flow"], &[])); + m.variables + .push(datamodel::Variable::Flow(flow("chain_flow", "10"))); + }, + vec![ + ModelOperation::UpsertStock(match stock("stock_b", &["chain_flow"], &[]) { + datamodel::Variable::Stock(s) => s, + _ => unreachable!(), + }), + ModelOperation::UpsertFlow(flow("chain_flow", "10")), + ModelOperation::UpdateStockFlows { + ident: "stock_a".to_string(), + inflows: vec![], + outflows: vec!["waste_flow".to_string(), "chain_flow".to_string()], + }, + ], + ); + let row = "a chain flow added on a face it occupies"; + assert_preserved(&old, &new, &["waste_flow"], row); + assert_eq!( + violations_of(&new, "chain_flow"), + Vec::::new(), + "{row}" + ); + assert_slot_keeps_spacing(&new, "chain_flow", row); +} + +/// Created flows on production views, through the importers: the flows a +/// patch creates hold the invariants and land clear of the flows already +/// there, and every pre-existing flow comes back byte for byte. Rows: +/// +/// - `SIR.xmile`: `relapse` from `infectious` back to `susceptible`, whose +/// facing faces already carry `succumbing`'s ends. The pipe runs straight +/// on one line a pipe spacing off those ends (the joint slot, pinned on its +/// own by `face_slots::tests::place_created_flow_ends_rows`). +/// - `mark2.mdl`: two cloud outflows added at once to `risk taking behavior`, +/// whose face a pre-existing flow occupies. Their ends keep the spacing, +/// and their clouds overlap no other cloud and lie across no other pipe. +#[test] +fn a_flow_created_on_an_imported_view_lands_clear_of_its_neighbours() { + fn pre_existing_flows(view: &datamodel::StockFlow) -> Vec<(view_element::Flow, Vec)> { + view.elements + .iter() + .filter_map(|e| match e { + ViewElement::Flow(f) => Some(flow_and_clouds(view, &canonicalize(&f.name))), + _ => None, + }) + .collect() + } + fn stock_lists(project: &datamodel::Project, ident: &str) -> (Vec, Vec) { + project.models[0] + .variables + .iter() + .find_map(|v| match v { + datamodel::Variable::Stock(s) if canonicalize(&s.ident) == ident => { + Some((s.inflows.clone(), s.outflows.clone())) + } + _ => None, + }) + .unwrap_or_else(|| panic!("stock {ident}")) + } + fn incremental_on_import( + project: &datamodel::Project, + new_flows: &[&str], + lists: &[(&str, Vec, Vec)], + ) -> (datamodel::StockFlow, datamodel::StockFlow) { + let model_name = project.models[0].name.clone(); + let datamodel::View::StockFlow(old) = &project.models[0].views[0]; + let mut patched = project.clone(); + let model = &mut patched.models[0]; + let mut ops = Vec::new(); + for name in new_flows { + model + .variables + .push(datamodel::Variable::Flow(flow(name, "1"))); + ops.push(ModelOperation::UpsertFlow(flow(name, "1"))); + } + for (ident, inflows, outflows) in lists { + for var in &mut model.variables { + if let datamodel::Variable::Stock(s) = var + && canonicalize(&s.ident) == *ident + { + s.inflows = inflows.clone(); + s.outflows = outflows.clone(); + } + } + ops.push(ModelOperation::UpdateStockFlows { + ident: ident.to_string(), + inflows: inflows.clone(), + outflows: outflows.clone(), + }); + } + let patch = ModelPatch { + name: model_name.clone(), + ops, + }; + let new = incremental_layout(old, &patched, &model_name, &patch, None) + .expect("incremental layout"); + (old.clone(), new) + } + fn assert_pre_existing_preserved( + old: &datamodel::StockFlow, + new: &datamodel::StockFlow, + row: &str, + ) { + for (f, clouds) in pre_existing_flows(old) { + assert_eq!( + flow_and_clouds(new, &canonicalize(&f.name)), + (f.clone(), clouds), + "{row}: {} must come back byte for byte", + f.name + ); + } + } + + const SIR: &str = include_str!("../../../../test/test-models/samples/SIR/SIR.xmile"); + let project = crate::compat::open_xmile(&mut std::io::BufReader::new(SIR.as_bytes())) + .expect("SIR imports"); + let (infectious_in, mut infectious_out) = stock_lists(&project, "infectious"); + let (mut susceptible_in, susceptible_out) = stock_lists(&project, "susceptible"); + infectious_out.push("relapse".to_string()); + susceptible_in.push("relapse".to_string()); + let (old, new) = incremental_on_import( + &project, + &["relapse"], + &[ + ("infectious", infectious_in, infectious_out), + ("susceptible", susceptible_in, susceptible_out), + ], + ); + let row = "SIR relapse"; + assert_pre_existing_preserved(&old, &new, row); + assert_eq!( + violations_of(&new, "relapse"), + Vec::::new(), + "{row}" + ); + let relapse = find_flow(&new, "relapse").unwrap(); + assert_eq!(relapse.points.len(), 2, "{row}: a straight pipe"); + assert_slot_keeps_spacing(&new, "relapse", row); + + const MARK2: &str = include_str!("../../../../test/bobby/vdf/econ/mark2.mdl"); + let project = crate::compat::open_vensim(MARK2).expect("mark2 imports"); + let (inflows, mut outflows) = stock_lists(&project, "risk_taking_behavior"); + outflows.push("probe_a".to_string()); + outflows.push("probe_b".to_string()); + let (old, new) = incremental_on_import( + &project, + &["probe_a", "probe_b"], + &[("risk_taking_behavior", inflows, outflows)], + ); + let row = "mark2 two outflows"; + assert_pre_existing_preserved(&old, &new, row); + let clouds: Vec<(i32, f64, f64)> = new + .elements + .iter() + .filter_map(|e| match e { + ViewElement::Cloud(c) => Some((c.uid, c.x, c.y)), + _ => None, + }) + .collect(); + for probe in ["probe_a", "probe_b"] { + assert_eq!( + violations_of(&new, probe), + Vec::::new(), + "{row}: {probe}" + ); + assert_slot_keeps_spacing(&new, probe, row); + let (probe_flow, probe_clouds) = flow_and_clouds(&new, probe); + for c in &probe_clouds { + for &(other, x, y) in &clouds { + if other == c.uid { + continue; + } + assert!( + (c.x - x).hypot(c.y - y) >= 2.0 * CLOUD_RADIUS - 1e-6, + "{row}: {probe}'s cloud at ({}, {}) overlaps cloud {other} at ({x}, {y})", + c.x, + c.y + ); + } + for e in &new.elements { + let ViewElement::Flow(g) = e else { continue }; + if g.uid == probe_flow.uid { + continue; + } + for w in g.points.windows(2) { + let (dx, dy) = (w[1].x - w[0].x, w[1].y - w[0].y); + let len2 = dx * dx + dy * dy; + let t = if len2 == 0.0 { + 0.0 + } else { + (((c.x - w[0].x) * dx + (c.y - w[0].y) * dy) / len2).clamp(0.0, 1.0) + }; + let d = (c.x - (w[0].x + t * dx)).hypot(c.y - (w[0].y + t * dy)); + assert!( + d >= CLOUD_RADIUS - 1e-6, + "{row}: {probe}'s cloud at ({}, {}) lies across {}'s pipe ({d} away)", + c.x, + c.y, + g.name + ); + } + } + } + } +} + +/// A reverse flow added between two stocks offset along their facing faces, +/// through the XMILE importer and incremental layout: the existing flow `f1` +/// comes back byte for byte, the created flow holds the flow invariants, and +/// each of its stock ends keeps `PIPE_SPACING` from `f1`'s end on the same +/// face, and its valve sits `VALVE_CLAMP_MARGIN` from the ends of its segment +/// and from `f1`'s pipe. Rows: stock B's offset below A across the range where +/// the two faces' clearance spans overlap (0 to 29), with `f1`'s line at the +/// ends and the middle of that overlap; offsets where the overlap is a sliver +/// within a spacing of `f1`'s ends, so no joint line keeps the spacing and each +/// end takes its own slot (B 28 below with `f1` at 114; 26 and 27 at 113; 27 at +/// 114; 24 and 25 at 112); and Z routes crossing `f1` whose valve the finishing +/// pass would otherwise leave on the riser near a bend or on `f1` (B 11 below +/// at 105, 15 at 110, 19 at 114). +#[test] +fn a_flow_created_between_offset_stocks_keeps_its_spacing() { + const XML: &str = r#" + +
jointsimlinsimlin
+ 03
1
+ + + 10f1 + 10f1 + 1 + + + + + + + + + + + + + + +
"#; + let mut rows: Vec<(i32, i32)> = vec![ + (28, 114), + (26, 113), + (27, 113), + (27, 114), + (24, 112), + (25, 112), + (11, 105), + (15, 110), + (19, 114), + ]; + for offset in [0, 1, 2, 10, 14, 20, 29] { + let lo = (86 + offset).max(86); + let hi = (114 + offset).min(114); + rows.extend([(offset, lo), (offset, (lo + hi) / 2), (offset, hi)]); + } + for (offset, line) in rows { + let row = format!("B {offset} below A, f1 at {line}"); + let text = XML + .replace("STOCK_B_Y", &(100 + offset).to_string()) + .replace("F1_Y", &line.to_string()); + let project = crate::compat::open_xmile(&mut std::io::BufReader::new(text.as_bytes())) + .expect("the view imports"); + let model_name = project.models[0].name.clone(); + let datamodel::View::StockFlow(old) = &project.models[0].views[0]; + let mut patched = project.clone(); + let model = &mut patched.models[0]; + model + .variables + .push(datamodel::Variable::Flow(flow("back", "1"))); + for var in &mut model.variables { + if let datamodel::Variable::Stock(s) = var { + let (inflows, outflows): (&[&str], &[&str]) = match canonicalize(&s.ident).as_ref() + { + "a" => (&["back"], &["f1"]), + _ => (&["f1"], &["back"]), + }; + s.inflows = inflows.iter().map(|f| f.to_string()).collect(); + s.outflows = outflows.iter().map(|f| f.to_string()).collect(); + } + } + let patch = ModelPatch { + name: model_name.clone(), + ops: vec![ + ModelOperation::UpsertFlow(flow("back", "1")), + ModelOperation::UpdateStockFlows { + ident: "a".to_string(), + inflows: vec!["back".to_string()], + outflows: vec!["f1".to_string()], + }, + ModelOperation::UpdateStockFlows { + ident: "b".to_string(), + inflows: vec!["f1".to_string()], + outflows: vec!["back".to_string()], + }, + ], + }; + let new = incremental_layout(old, &patched, &model_name, &patch, None) + .expect("incremental layout"); + assert_preserved(old, &new, &["f1"], &row); + assert_eq!(violations_of(&new, "back"), Vec::::new(), "{row}"); + assert_slot_keeps_spacing(&new, "back", &row); + let back = find_flow(&new, "back").unwrap(); + let on = back + .points + .windows(2) + .find(|w| { + let (a, b) = (&w[0], &w[1]); + let (lo_x, hi_x) = (a.x.min(b.x), a.x.max(b.x)); + let (lo_y, hi_y) = (a.y.min(b.y), a.y.max(b.y)); + back.x >= lo_x - 1e-6 + && back.x <= hi_x + 1e-6 + && back.y >= lo_y - 1e-6 + && back.y <= hi_y + 1e-6 + }) + .unwrap_or_else(|| panic!("{row}: back's valve is off its pipe")); + let len = (on[1].x - on[0].x).hypot(on[1].y - on[0].y); + let from_ends = (back.x - on[0].x) + .hypot(back.y - on[0].y) + .min((back.x - on[1].x).hypot(back.y - on[1].y)); + assert!( + len < 2.0 * VALVE_CLAMP_MARGIN || from_ends >= VALVE_CLAMP_MARGIN - 1e-6, + "{row}: back's valve ({}, {}) is {from_ends} from an end of its {len}px segment", + back.x, + back.y + ); + let f1 = find_flow(&new, "f1").unwrap(); + let (a, b) = (&f1.points[0], &f1.points[f1.points.len() - 1]); + let (lo_x, hi_x) = (a.x.min(b.x), a.x.max(b.x)); + let off_f1 = (back.x - back.x.clamp(lo_x, hi_x)).hypot(back.y - a.y); + assert!( + off_f1 >= VALVE_CLAMP_MARGIN - 1e-6, + "{row}: back's valve ({}, {}) is {off_f1} from f1's pipe", + back.x, + back.y + ); + } +} + +/// The old view breaks the invariants on every flow the patch does not touch, +/// in each way a pass could "repair": waste_a's valve is off its pipe and its +/// stock end is off stock_a's faces (the endpoint snap), chain_flow's pipe is +/// diagonal (the orthogonalizer), and waste_b's valve sits on its pipe 4px from +/// an end (the laid-out valve settle). Stored geometry like that -- a hand edit, +/// or a view saved before the invariants held -- is exactly what no pass may +/// move on a flow the patch did not touch, with a new element (the settle +/// path, which runs the snap and the finishing pass) and without one (the +/// early-return path). +#[test] +fn a_flow_the_patch_does_not_touch_is_preserved() { + let base = project_of(vec![ + stock("stock_a", &[], &["chain_flow", "waste_a"]), + stock("stock_b", &["chain_flow"], &["waste_b"]), + datamodel::Variable::Flow(flow("chain_flow", "10")), + datamodel::Variable::Flow(flow("waste_a", "stock_a * leak_rate")), + datamodel::Variable::Flow(flow("waste_b", "1")), + datamodel::Variable::Aux(aux("leak_rate", "0.1")), + ]); + let mut old = generate_layout(&base, TEST_MODEL, None).expect("initial layout"); + let stock_a_uid = stock_named(&old, "stock_a").uid; + for e in &mut old.elements { + if let ViewElement::Flow(f) = e + && canonicalize(&f.name) == "waste_b" + { + let (a, b) = (&f.points[0], &f.points[1]); + let len = (b.x - a.x).hypot(b.y - a.y); + (f.x, f.y) = (a.x + (b.x - a.x) * 4.0 / len, a.y + (b.y - a.y) * 4.0 / len); + } + if let ViewElement::Flow(f) = e + && canonicalize(&f.name) == "waste_a" + { + f.x += 30.0; + for p in &mut f.points { + if p.attached_to_uid == Some(stock_a_uid) { + p.x += 4.0; + p.y -= 5.0; + } + } + } + if let ViewElement::Flow(f) = e + && canonicalize(&f.name) == "chain_flow" + { + let last = f.points.len() - 1; + f.points[last].y += 6.0; + } + } + let waste_problems = violations_of(&old, "waste_a"); + assert!( + waste_problems.iter().any(|v| v.contains("off the pipe")) + && waste_problems.iter().any(|v| v.contains("off the faces")), + "fixture: waste_a's valve is off its pipe and its stock end off the faces: {waste_problems:?}" + ); + assert!( + violations_of(&old, "chain_flow") + .iter() + .any(|v| v.contains("diagonal")), + "fixture: chain_flow's pipe is diagonal" + ); + assert!( + violations_of(&old, "waste_b") + .iter() + .any(|v| v.contains("from an end of the path")), + "fixture: waste_b's valve is on its pipe within the margin of an end" + ); + + let new = incremental( + &base, + &old, + |m| { + m.variables + .push(datamodel::Variable::Aux(aux("unrelated", "1"))) + }, + vec![ModelOperation::UpsertAux(aux("unrelated", "1"))], + ); + assert!( + new.elements + .iter() + .any(|e| matches!(e, ViewElement::Aux(a) if canonicalize(&a.name) == "unrelated")), + "fixture: the patch creates an element, so the settle path runs" + ); + assert_preserved( + &old, + &new, + &["waste_a", "chain_flow", "waste_b"], + "a new unrelated element", + ); + + let new = incremental( + &base, + &old, + |m| { + for var in &mut m.variables { + if let datamodel::Variable::Aux(a) = var + && a.ident == "leak_rate" + { + a.equation = datamodel::Equation::Scalar("0.2".to_string()); + } + } + }, + vec![ModelOperation::UpsertAux(aux("leak_rate", "0.2"))], + ); + assert_preserved( + &old, + &new, + &["waste_a", "chain_flow", "waste_b"], + "an unrelated edit, no new element", + ); +} diff --git a/src/simlin-engine/src/layout/layout_label_tests.rs b/src/simlin-engine/src/layout/layout_label_tests.rs index e679ffd0c..18f8981cd 100644 --- a/src/simlin-engine/src/layout/layout_label_tests.rs +++ b/src/simlin-engine/src/layout/layout_label_tests.rs @@ -14,8 +14,9 @@ //! patch adds an element (settle path) and when it adds only a connector or //! only deletes (the no-new-elements early-return path) //! - renamed element: preserved (a rename keeps the element's geometry) -//! - flow rebuilt for an attach-offset change (orientation unchanged): preserved -//! - flow rebuilt for an orientation flip: re-chosen +//! - flow whose sibling on the same stock was added or removed: preserved +//! - flow rebuilt because its attachment changed (an attached stock deleted): +//! re-chosen //! - element rebuilt after a kind change (aux -> stock): re-chosen //! - element that is new in this pass: chosen //! @@ -440,9 +441,8 @@ fn incremental_layout_preserves_label_side_across_rename() { /// Fixture: stock_a -> chain_flow -> stock_b plus side outflows waste_a (the /// bottom face) and waste_b (the top face). Adding waste_c, with no free face -/// left, puts it beside waste_a on the bottom face and moves waste_a along -/// that face (offset 0.5 -> 1/3), which rebuilds waste_a's geometry without -/// changing its orientation. +/// left, puts a second flow on waste_a's face, which leaves waste_a a sibling +/// the patch does not touch. fn side_flow_project() -> datamodel::Project { test_project(model_with(vec![ scalar_stock("stock_a", &[], &["chain_flow", "waste_a", "waste_b"]), @@ -488,50 +488,30 @@ fn add_waste_c(project: &datamodel::Project) -> (datamodel::Project, crate::patc } #[test] -fn rebuilt_flow_with_unchanged_orientation_keeps_label_side() { +fn flow_whose_sibling_is_added_keeps_label_side() { let project = side_flow_project(); let base_view = generate_layout(&project, TEST_MODEL, None).expect("initial layout"); let (patched, patch) = add_waste_c(&project); - let old_waste_a = find_flow(&base_view, "waste_a"); - assert!( - matches!( - compute_flow_orientation(&old_waste_a.points), - FlowOrientation::Vertical - ), - "fixture: waste_a must start vertical (a side flow below stock_a)" - ); - let old_attach_x = old_waste_a.points[0].x; - - for side in [LabelSide::Left, LabelSide::Right] { + for side in ALL_SIDES { let mut old_view = base_view.clone(); set_label_side(&mut old_view, "waste_a", side); let new_view = incremental_layout(&old_view, &patched, TEST_MODEL, &patch, None) .expect("incremental layout"); - let new_waste_a = find_flow(&new_view, "waste_a"); - assert!( - (new_waste_a.points[0].x - old_attach_x).abs() > 1.0, - "fixture: waste_a must actually be rebuilt (attach x moved from {old_attach_x} to {})", - new_waste_a.points[0].x - ); - assert!( - matches!( - compute_flow_orientation(&new_waste_a.points), - FlowOrientation::Vertical - ), - "fixture: waste_a must stay vertical" - ); + // fixture: the sibling really was added (find_flow panics otherwise) + find_flow(&new_view, "waste_b"); assert_eq!( - new_waste_a.label_side, side, - "a flow rebuilt only for an attach-offset change keeps its label side" + find_flow(&new_view, "waste_a"), + find_flow(&old_view, "waste_a"), + "a flow whose sibling was added comes back byte for byte, label side {side:?} included" ); } } /// Fixture: stock_a -> chain_flow -> stock_b plus stock_a -> waste_flow -> cloud. -/// Deleting the chain reclassifies waste_flow from the bottom face to the -/// right face, i.e. vertical -> horizontal. -fn flip_flow_project() -> datamodel::Project { +/// Removing the chain leaves waste_flow a sibling the patch does not touch; +/// deleting only stock_b detaches chain_flow's sink, which rebuilds chain_flow. +fn chain_and_waste_project() -> datamodel::Project { test_project(model_with(vec![ scalar_stock("stock_a", &[], &["chain_flow", "waste_flow"]), scalar_stock("stock_b", &["chain_flow"], &[]), @@ -540,6 +520,21 @@ fn flip_flow_project() -> datamodel::Project { ])) } +fn delete_sink_stock( + project: &datamodel::Project, +) -> (datamodel::Project, crate::patch::ModelPatch) { + let mut patched = project.clone(); + let model = patched.get_model_mut(TEST_MODEL).unwrap(); + model.variables.retain(|v| v.get_ident() != "stock_b"); + let patch = crate::patch::ModelPatch { + name: TEST_MODEL.to_string(), + ops: vec![crate::patch::ModelOperation::DeleteVariable { + ident: "stock_b".to_string(), + }], + }; + (patched, patch) +} + fn remove_chain(project: &datamodel::Project) -> (datamodel::Project, crate::patch::ModelPatch) { let mut patched = project.clone(); let model = patched.get_model_mut(TEST_MODEL).unwrap(); @@ -573,42 +568,50 @@ fn remove_chain(project: &datamodel::Project) -> (datamodel::Project, crate::pat } #[test] -fn rebuilt_flow_with_flipped_orientation_rechooses_label_side() { - let project = flip_flow_project(); +fn flow_whose_sibling_chain_is_removed_keeps_label_side() { + let project = chain_and_waste_project(); let base_view = generate_layout(&project, TEST_MODEL, None).expect("initial layout"); let (patched, patch) = remove_chain(&project); - assert!( - matches!( - compute_flow_orientation(&find_flow(&base_view, "waste_flow").points), - FlowOrientation::Vertical - ), - "fixture: waste_flow must start vertical" - ); - let mut chosen = Vec::new(); for side in ALL_SIDES { let mut old_view = base_view.clone(); set_label_side(&mut old_view, "waste_flow", side); let new_view = incremental_layout(&old_view, &patched, TEST_MODEL, &patch, None) .expect("incremental layout"); - let waste = find_flow(&new_view, "waste_flow"); - assert!( - matches!( - compute_flow_orientation(&waste.points), - FlowOrientation::Horizontal - ), - "fixture: waste_flow must flip to horizontal" + assert_eq!( + find_flow(&new_view, "waste_flow"), + find_flow(&old_view, "waste_flow"), + "a flow whose sibling chain was removed comes back byte for byte, \ + label side {side:?} included" ); + } +} + +#[test] +fn reattached_flow_rechooses_label_side() { + let project = chain_and_waste_project(); + let base_view = generate_layout(&project, TEST_MODEL, None).expect("initial layout"); + let (patched, patch) = delete_sink_stock(&project); + + let mut chosen = Vec::new(); + for side in ALL_SIDES { + let mut old_view = base_view.clone(); + set_label_side(&mut old_view, "chain_flow", side); + let new_view = incremental_layout(&old_view, &patched, TEST_MODEL, &patch, None) + .expect("incremental layout"); + let chain = find_flow(&new_view, "chain_flow"); + let sink = chain.points.last().and_then(|p| p.attached_to_uid); assert!( - matches!(waste.label_side, LabelSide::Top | LabelSide::Bottom), - "a horizontal flow's label must sit above or below the pipe, got {:?}", - waste.label_side + new_view.elements.iter().any( + |e| matches!(e, ViewElement::Cloud(c) if Some(c.uid) == sink && c.flow_uid == chain.uid) + ), + "fixture: chain_flow must be rebuilt with a sink cloud" ); - chosen.push(waste.label_side); + chosen.push(chain.label_side); } assert!( chosen.iter().all(|s| *s == chosen[0]), - "a flipped flow's label side must not depend on the stale side: {chosen:?}" + "a re-attached flow's label side must not depend on the stale side: {chosen:?}" ); } diff --git a/src/simlin-engine/src/layout/layout_review_tests.rs b/src/simlin-engine/src/layout/layout_review_tests.rs index d0ffd4d14..9202b735c 100644 --- a/src/simlin-engine/src/layout/layout_review_tests.rs +++ b/src/simlin-engine/src/layout/layout_review_tests.rs @@ -1926,7 +1926,7 @@ fn test_incremental_new_side_flow_valve_on_pipe() { } // --------------------------------------------------------------------------- -// P2: Preserved side flows must be rebuilt when their offset changes +// A preserved side flow keeps its slot when a sibling joins its face // --------------------------------------------------------------------------- #[test] @@ -2067,47 +2067,29 @@ fn test_incremental_add_second_side_flow_takes_its_own_face() { let new_view = incremental_layout(&old_view, &patched_project, TEST_MODEL, &patch, None) .expect("incremental layout"); - let new_stock_a = new_view - .elements - .iter() - .find_map(|e| match e { - ViewElement::Stock(s) if canonicalize(&s.name).as_ref() == "stock_a" => Some(s), - _ => None, - }) - .expect("stock_a in new view"); - - let new_waste_a = new_view - .elements - .iter() - .find_map(|e| match e { - ViewElement::Flow(f) if canonicalize(&f.name).as_ref() == "waste_a" => Some(f), - _ => None, - }) - .expect("waste_a in new view"); - - let new_waste_b = new_view - .elements - .iter() - .find_map(|e| match e { - ViewElement::Flow(f) if canonicalize(&f.name).as_ref() == "waste_b" => Some(f), - _ => None, - }) - .expect("waste_b in new view"); + let flow_named = |view: &datamodel::StockFlow, name: &str| { + view.elements + .iter() + .find_map(|e| match e { + ViewElement::Flow(f) if canonicalize(&f.name).as_ref() == name => Some(f.clone()), + _ => None, + }) + .unwrap_or_else(|| panic!("{name} in view")) + }; - // waste_a is untouched: same pipe, same valve. - assert_eq!( - new_waste_a.points, old_waste_a.points, - "waste_a must keep its pipe when a sibling is added" - ); - assert!((new_waste_a.x - old_waste_a.x).abs() < 1e-9); - assert!((new_waste_a.y - old_waste_a.y).abs() < 1e-9); + // waste_a is a sibling the patch did not touch: it comes back byte for + // byte, pipe, valve and label side. + let new_waste_a = flow_named(&new_view, "waste_a"); + assert_eq!(new_waste_a, flow_named(&old_view, "waste_a")); - // waste_b leaves from the top face, on the opposite side of the stock. + // waste_b leaves from the top face, on the opposite side of the stock + // (incremental layout never moves a stock the patch did not touch). + let new_waste_b = flow_named(&new_view, "waste_b"); assert!( - new_waste_b.y < new_stock_a.y - 5.0, + new_waste_b.y < old_stock_a.y - 5.0, "waste_b valve y ({}) should be above stock_a y ({})", new_waste_b.y, - new_stock_a.y, + old_stock_a.y, ); let valve_gap = ((new_waste_a.x - new_waste_b.x).powi(2) + (new_waste_a.y - new_waste_b.y).powi(2)).sqrt(); @@ -2118,14 +2100,14 @@ fn test_incremental_add_second_side_flow_takes_its_own_face() { } // --------------------------------------------------------------------------- -// P2: Cloud flows must be reclassified when chain flows are removed +// P2: A preserved cloud flow keeps its face when its stock's chain is removed // --------------------------------------------------------------------------- #[test] -fn test_incremental_remove_chain_reclassifies_cloud_flow() { +fn test_incremental_remove_chain_keeps_cloud_flow_in_place() { // Start with: stock_a -> chain_flow -> stock_b, stock_a -> waste_flow -> cloud - // waste_flow should be on Bottom because chain_flow exists. - // Then remove chain_flow and stock_b. waste_flow should move back to Right. + // waste_flow is on Bottom because chain_flow exists. + // Then remove chain_flow and stock_b. waste_flow stays where it is. let initial_model = datamodel::Model { name: TEST_MODEL.to_string(), sim_specs: None, @@ -2237,42 +2219,21 @@ fn test_incremental_remove_chain_reclassifies_cloud_flow() { let new_view = incremental_layout(&old_view, &patched_project, TEST_MODEL, &patch, None) .expect("incremental layout"); - let new_stock = new_view - .elements - .iter() - .find_map(|e| match e { - ViewElement::Stock(s) if canonicalize(&s.name).as_ref() == "stock_a" => Some(s), - _ => None, - }) - .expect("stock_a in new view"); - - let new_waste = new_view - .elements - .iter() - .find_map(|e| match e { - ViewElement::Flow(f) if canonicalize(&f.name).as_ref() == "waste_flow" => Some(f), - _ => None, - }) - .expect("waste_flow in new view"); - - // waste_flow should now be horizontal (same y as stock) since there's no - // chain flow anymore -- it should be the sole outflow going right. - assert!( - (new_waste.y - new_stock.y).abs() < 5.0, - "waste_flow y ({}) should be near stock_a y ({}) after chain removed (now horizontal)", - new_waste.y, - new_stock.y, - ); - - // Flow points should be horizontal (similar y values) - let first_pt = &new_waste.points[0]; - let last_pt = &new_waste.points[new_waste.points.len() - 1]; - assert!( - (first_pt.y - last_pt.y).abs() < 5.0, - "waste_flow points should be horizontal after chain removal: first.y={}, last.y={}", - first_pt.y, - last_pt.y, - ); + // waste_flow is a sibling the patch did not touch. With the chain gone a + // fresh layout would put it on the right face, but it keeps the bottom + // one: it comes back byte for byte. + let waste_flow = |view: &datamodel::StockFlow| { + view.elements + .iter() + .find_map(|e| match e { + ViewElement::Flow(f) if canonicalize(&f.name).as_ref() == "waste_flow" => { + Some(f.clone()) + } + _ => None, + }) + .expect("waste_flow in view") + }; + assert_eq!(waste_flow(&new_view), waste_flow(&old_view)); } // --------------------------------------------------------------------------- @@ -2374,7 +2335,7 @@ fn test_resnap_preserves_vertical_flow_offset() { compat: None, })); - resnap_flow_endpoints(&mut state, &config); + resnap_flow_endpoints(&mut state, &config, |_| true); let flow = state .elements @@ -2547,15 +2508,15 @@ fn test_incremental_chain_flow_seeded_between_stocks() { } // --------------------------------------------------------------------------- -// P2: Redistributing flows must preserve existing positional order +// P2: Adding a sibling keeps a hand-reordered face exactly as it was // --------------------------------------------------------------------------- #[test] -fn test_incremental_redistribute_preserves_visual_order() { +fn test_incremental_add_side_flow_keeps_reordered_siblings_in_place() { // Three side outflows beside a chain flow: waste_a and waste_c share the // bottom face (waste_b holds the top). Construct a view where waste_c is - // visually LEFT of waste_a (non-alphabetical order). Adding waste_d, which - // reclassifies every flow on the stock, must not swap waste_a and waste_c. + // visually LEFT of waste_a (non-alphabetical order). Adding waste_d must + // leave every sibling exactly as drawn, the swapped order included. // First, build an initial model with chain + waste_a + waste_b + waste_c let initial_model = datamodel::Model { @@ -2762,45 +2723,51 @@ fn test_incremental_redistribute_preserves_visual_order() { let new_view = incremental_layout(&swapped_view, &patched_project, TEST_MODEL, &patch, None) .expect("incremental layout"); - // After redistribution, waste_c should still be to the left of waste_a - let new_wa_x = new_view - .elements - .iter() - .find_map(|e| match e { - ViewElement::Flow(f) if canonicalize(&f.name).as_ref() == "waste_a" => { - f.points.first().map(|p| p.x) - } - _ => None, - }) - .expect("waste_a in new view"); - let new_wc_x = new_view - .elements - .iter() - .find_map(|e| match e { - ViewElement::Flow(f) if canonicalize(&f.name).as_ref() == "waste_c" => { - f.points.first().map(|p| p.x) - } - _ => None, - }) - .expect("waste_c in new view"); - + // waste_a, waste_b and waste_c are siblings the patch did not touch: they + // come back byte for byte, the swapped order included, and waste_d's stock + // end lands on none of theirs. + let flow_named = |view: &datamodel::StockFlow, name: &str| { + view.elements + .iter() + .find_map(|e| match e { + ViewElement::Flow(f) if canonicalize(&f.name).as_ref() == name => Some(f.clone()), + _ => None, + }) + .unwrap_or_else(|| panic!("{name} in view")) + }; + for name in ["waste_a", "waste_b", "waste_c"] { + assert!( + flow_named(&new_view, name) == flow_named(&swapped_view, name), + "{name} must come back byte for byte" + ); + } + let wd = flow_named(&new_view, "waste_d").points[0].clone(); + for name in ["waste_a", "waste_b", "waste_c"] { + let end = flow_named(&swapped_view, name).points[0].clone(); + assert!( + (wd.x - end.x).hypot(wd.y - end.y) > 1.0, + "waste_d's end ({}, {}) must not land on {name}'s ({}, {})", + wd.x, + wd.y, + end.x, + end.y, + ); + } assert!( - new_wc_x < new_wa_x, - "waste_c ({}) should remain left of waste_a ({}) after adding waste_d", - new_wc_x, - new_wa_x, + swapped_wc_x < swapped_wa_x, + "fixture: waste_c stays left of waste_a" ); } // --------------------------------------------------------------------------- -// P1: DeleteVariable without UpdateStockFlows must still reclassify siblings +// P1: DeleteVariable without UpdateStockFlows leaves siblings in place // --------------------------------------------------------------------------- #[test] fn test_incremental_delete_flow_without_update_stock_flows() { // Start with: stock_a -> chain_flow -> stock_b, stock_a -> waste_flow -> cloud // Delete chain_flow and stock_b using only DeleteVariable (no UpdateStockFlows). - // waste_flow should move back to horizontal. + // waste_flow is a sibling the patch did not touch and stays where it is. let initial_model = datamodel::Model { name: TEST_MODEL.to_string(), sim_specs: None, @@ -2886,31 +2853,20 @@ fn test_incremental_delete_flow_without_update_stock_flows() { let new_view = incremental_layout(&old_view, &patched_project, TEST_MODEL, &patch, None) .expect("incremental layout"); - let new_stock = new_view - .elements - .iter() - .find_map(|e| match e { - ViewElement::Stock(s) if canonicalize(&s.name).as_ref() == "stock_a" => Some(s), - _ => None, - }) - .expect("stock_a"); - - let new_waste = new_view - .elements - .iter() - .find_map(|e| match e { - ViewElement::Flow(f) if canonicalize(&f.name).as_ref() == "waste_flow" => Some(f), - _ => None, - }) - .expect("waste_flow"); - - // waste_flow should now be horizontal (same y as stock) -- no more chain - assert!( - (new_waste.y - new_stock.y).abs() < 5.0, - "waste_flow y ({}) should be near stock_a y ({}) after chain deleted via DeleteVariable only", - new_waste.y, - new_stock.y, - ); + // waste_flow is a sibling the patch did not touch: deleting the chain + // through DeleteVariable alone leaves it where it was. + let waste_flow = |view: &datamodel::StockFlow| { + view.elements + .iter() + .find_map(|e| match e { + ViewElement::Flow(f) if canonicalize(&f.name).as_ref() == "waste_flow" => { + Some(f.clone()) + } + _ => None, + }) + .expect("waste_flow in view") + }; + assert_eq!(waste_flow(&new_view), waste_flow(&old_view)); } // --------------------------------------------------------------------------- diff --git a/src/simlin-engine/src/layout/layout_tests.rs b/src/simlin-engine/src/layout/layout_tests.rs index 780256161..7ef541148 100644 --- a/src/simlin-engine/src/layout/layout_tests.rs +++ b/src/simlin-engine/src/layout/layout_tests.rs @@ -1398,6 +1398,72 @@ fn test_isolated_flow_importance_is_5() { assert!((chains[0].importance - 5.0).abs() < f64::EPSILON); } +/// A flow two stocks list on the same side (degenerate input) has one source +/// in the metadata (`flow_to_stocks`), so it belongs to that stock's chain +/// only; claiming it in both chains lays it out twice. +#[test] +fn test_detect_chains_claims_a_flow_only_for_its_metadata_stock() { + let mut stock_to_inflows: HashMap> = HashMap::new(); + let mut stock_to_outflows: HashMap> = HashMap::new(); + let mut flow_to_stocks: HashMap, Option)> = HashMap::new(); + let mut all_flows: BTreeSet = BTreeSet::new(); + + for stock in ["a", "b"] { + stock_to_outflows.insert(stock.into(), vec!["f".into()]); + stock_to_inflows.insert(stock.into(), vec![]); + } + for stock in ["c", "d"] { + stock_to_inflows.insert(stock.into(), vec!["g".into()]); + stock_to_outflows.insert(stock.into(), vec![]); + } + flow_to_stocks.insert("f".into(), (Some("b".into()), None)); + flow_to_stocks.insert("g".into(), (None, Some("d".into()))); + all_flows.extend(["f".into(), "g".into()]); + + let chains = detect_chains( + &stock_to_inflows, + &stock_to_outflows, + &flow_to_stocks, + &all_flows, + ); + for (flow, stock) in [("f", "b"), ("g", "d")] { + let claiming: Vec<&StockFlowChain> = chains + .iter() + .filter(|c| c.flows.iter().any(|x| x == flow)) + .collect(); + assert_eq!(claiming.len(), 1, "{flow} must be in exactly one chain"); + assert!( + claiming[0].stocks.iter().any(|s| s == stock), + "{flow} belongs to the chain of its metadata stock {stock}" + ); + } +} + +/// sdCloud's `test_non_negative_all1.xmile` lists `OutFlow` as an outflow of +/// both `TestStock0` and `TestStock1`, and `if_else` of two others. The layout +/// emits each flow once, attached, and never a second detached copy. +#[test] +fn test_layout_emits_a_flow_listed_by_two_stocks_once() { + const NON_NEGATIVE: &str = include_str!( + "../../../../test/test-models/tests/non_negative_all/test_non_negative_all1.xmile" + ); + let project = crate::compat::open_xmile(&mut std::io::BufReader::new(NON_NEGATIVE.as_bytes())) + .expect("fixture imports"); + let view = generate_layout(&project, "default", None).expect("layout"); + for name in ["outflow", "if_else"] { + let count = view + .elements + .iter() + .filter(|e| matches!(e, ViewElement::Flow(f) if canonicalize(&f.name) == name)) + .count(); + assert_eq!(count, 1, "flow {name} must be laid out once"); + } + assert_eq!( + crate::diagram::flow_geometry::flow_invariant_violations(&view.elements), + Vec::::new() + ); +} + #[test] fn test_ast_deps_exclude_builtins() { // A variable referencing TIME (a builtin) should not produce a connector @@ -4051,7 +4117,7 @@ fn test_resnap_preserves_stock_edge_position() { })); state.positions.insert(2, Position::new(300.0, 100.0)); - resnap_flow_endpoints(&mut state, &config); + resnap_flow_endpoints(&mut state, &config, |_| true); let flow = state .elements @@ -4133,7 +4199,7 @@ fn test_resnap_snaps_to_correct_face() { })); state.positions.insert(2, Position::new(200.0, 100.0)); - resnap_flow_endpoints(&mut state, &config); + resnap_flow_endpoints(&mut state, &config, |_| true); let flow = state .elements @@ -4214,7 +4280,7 @@ fn test_resnap_vertical_flow_snaps_to_bottom_edge() { })); state.positions.insert(2, Position::new(200.0, 250.0)); - resnap_flow_endpoints(&mut state, &config); + resnap_flow_endpoints(&mut state, &config, |_| true); let flow = state .elements @@ -5632,3 +5698,9 @@ mod label_tests; #[path = "layout_flow_side_tests.rs"] mod flow_side_tests; + +#[path = "layout_flow_geometry_tests.rs"] +mod flow_geometry_tests; + +#[path = "layout_flow_tests.rs"] +mod flow_tests; diff --git a/src/simlin-engine/src/layout/mod.rs b/src/simlin-engine/src/layout/mod.rs index a83bf78e7..44198aecb 100644 --- a/src/simlin-engine/src/layout/mod.rs +++ b/src/simlin-engine/src/layout/mod.rs @@ -12,6 +12,7 @@ pub mod declutter; mod detect_ltm_loops; #[cfg(any(test, feature = "layout_eval"))] pub mod eval_stats; +mod face_slots; pub mod graph; mod incremental; pub mod metadata; @@ -65,6 +66,7 @@ use crate::common::canonicalize; use crate::datamodel; use crate::datamodel::view_element::{self, FlowPoint, LabelSide, LinkShape}; use crate::datamodel::{Rect, ViewElement}; +use crate::diagram::flow_geometry::clamp_to_face_span; use crate::ltm_dominance::{FeedbackLoop, LoopPolarity, PartitionSurface}; /// A queued element during chain layout BFS traversal. @@ -791,6 +793,8 @@ fn create_flow_view_element( if half_h * dx.abs() >= half_w * dy.abs() { FlowPoint { x: stock_pos.x + dx.signum() * half_w, + // The face's span only: the finishing pass + // (`finish_flow_geometry`) owns corner clearance. y: pos.y.clamp(stock_pos.y - half_h, stock_pos.y + half_h), attached_to_uid: Some(stock_uid), } @@ -2886,12 +2890,10 @@ pub fn fresh_layout( // Phase 5: Normalize coordinates normalize_coordinates(&mut state.elements, DIAGRAM_ORIGIN_MARGIN); - // Phase 5b: Orthogonalize flow pipes. Placement positions stocks freely, so - // a flow between two stocks offset in both axes would render as a diagonal; - // SD convention draws flows with horizontal/vertical segments only. This - // runs last (after declutter/normalize) so nothing re-diagonalizes it, and - // before scoring so the metric sees the real pipe geometry. - orthogonal::orthogonalize_flow_pipes(&mut state.elements); + // Phase 5b: Settle flow geometry. This runs last (after declutter/normalize) + // so nothing moves a stock, valve or cloud after it, and before scoring so + // the metric sees the real pipe geometry. + finish_flow_geometry(&mut state.elements, |_| true); // Phase 6: Apply feedback loop curvature apply_loop_curvature(&mut state, config, model, metadata); @@ -2927,6 +2929,49 @@ pub fn fresh_layout( }) } +/// The last word on flow geometry in a layout pass: every diagonal pipe is +/// rewritten into axis-aligned segments (placement positions stocks freely, so +/// a flow between two stocks offset in both axes would render as a diagonal), +/// then the flow invariants (`diagram::flow_geometry`) are established on the +/// result. The orthogonalizer only routes between a pipe's two attached ends, +/// leaving the valve and the clouds where placement put them, and placement +/// positions those independently of the pipe; the normalization owns where +/// they end up, as it does for imported views. The layout owns the valves of +/// the flows it settles, so each is then kept off its segment's ends, where the +/// editor would clamp a dragged valve, and off every other flow's pipe +/// (`settle_laid_out_valve`). The route itself is not chosen around siblings: +/// a created flow between two stocks whose slots `face_slots` put on opposite +/// sides of a sibling's line is routed as a Z that crosses the sibling, as +/// every route between the two faces that stays between the stocks must, and +/// only its valve is kept off the sibling. `include` selects the flows (by +/// uid) the pass may change: every flow in a fresh layout, and only the flows +/// it creates in an incremental one. +fn finish_flow_geometry(elements: &mut [ViewElement], include: impl Fn(i32) -> bool + Copy) { + orthogonal::orthogonalize_flow_pipes(elements, include); + crate::diagram::flow_geometry::normalize_flow_geometry_where(elements, include); + let pipes: Vec<(i32, Vec)> = elements + .iter() + .filter_map(|e| match e { + ViewElement::Flow(f) => Some((f.uid, f.points.clone())), + _ => None, + }) + .collect(); + for element in elements.iter_mut() { + if let ViewElement::Flow(f) = element + && include(f.uid) + { + let others: Vec<&[FlowPoint]> = pipes + .iter() + .filter(|(uid, _)| *uid != f.uid) + .map(|(_, points)| points.as_slice()) + .collect(); + let mut valve = (f.x, f.y); + crate::diagram::flow_geometry::settle_laid_out_valve(&f.points, &mut valve, &others); + (f.x, f.y) = valve; + } + } +} + /// Copy free nodes' element coordinates back into `state.positions` after a /// pass that moved the elements directly. fn sync_free_node_positions(state: &mut LayoutState) { @@ -3244,13 +3289,15 @@ pub fn compute_metadata( uid_to_ident.insert(uid, stock_ident.clone()); } - let inflows: Vec = stock - .inflows + // The sets the model integrates: a repeated entry is one flow + // with one pipe (`datamodel::distinct_stock_flows`). + let inflows: Vec = datamodel::distinct_stock_flows(&stock.inflows) + .flows .iter() .map(|f| canonicalize(f).into_owned()) .collect(); - let outflows: Vec = stock - .outflows + let outflows: Vec = datamodel::distinct_stock_flows(&stock.outflows) + .flows .iter() .map(|f| canonicalize(f).into_owned()) .collect(); @@ -3531,28 +3578,44 @@ fn detect_chains( } chain_stocks.push(stock.clone()); - // Follow inflows to connected stocks + // A stock claims a flow for its chain only when the metadata names + // it as that flow's sink (for an inflow) or source (for an + // outflow). A flow has one element and one attachment per side, so + // when two stocks list the same flow on one side (degenerate input) + // only the stock `flow_to_stocks` chose lays it out; claiming it in + // both chains would emit a second, detached copy. if let Some(inflows) = stock_to_inflows.get(&stock) { for flow in inflows { + let Some((from_stock, to_stock)) = flow_to_stocks.get(flow) else { + continue; + }; + if to_stock.as_deref() != Some(stock.as_str()) { + continue; + } if seen_flows.insert(flow.clone()) { chain_flows.push(flow.clone()); flows_in_chains.insert(flow.clone()); } - if let Some((Some(from_stock), _)) = flow_to_stocks.get(flow) + if let Some(from_stock) = from_stock && !visited.contains(from_stock) { queue.push_back(from_stock.clone()); } } } - // Follow outflows to connected stocks if let Some(outflows) = stock_to_outflows.get(&stock) { for flow in outflows { + let Some((from_stock, to_stock)) = flow_to_stocks.get(flow) else { + continue; + }; + if from_stock.as_deref() != Some(stock.as_str()) { + continue; + } if seen_flows.insert(flow.clone()) { chain_flows.push(flow.clone()); flows_in_chains.insert(flow.clone()); } - if let Some((_, Some(to_stock))) = flow_to_stocks.get(flow) + if let Some(to_stock) = to_stock && !visited.contains(to_stock) { queue.push_back(to_stock.clone()); diff --git a/src/simlin-engine/src/layout/orthogonal.rs b/src/simlin-engine/src/layout/orthogonal.rs index 3764634b1..2e426bea3 100644 --- a/src/simlin-engine/src/layout/orthogonal.rs +++ b/src/simlin-engine/src/layout/orthogonal.rs @@ -15,9 +15,11 @@ //! //! The pass is deliberately conservative and idempotent: //! -//! * a pipe whose every segment is already axis-aligned is left untouched (so a -//! hand-routed flow preserved by the incremental path is never clobbered, and -//! re-running the pass is a no-op); +//! * a pipe whose every segment is already axis-aligned is left untouched, so +//! re-running the pass is a no-op; +//! * the caller chooses which flows the pass may rewrite (incremental layout +//! passes only the flows it creates, so a preserved hand-routed flow is never +//! clobbered, diagonal or not); //! * only pipes that actually contain a diagonal segment are rebuilt, and they //! are rebuilt from their two *attached* endpoints (the faces the placement / //! resnap passes already chose), inserting bends so each segment leaves its @@ -25,8 +27,10 @@ //! //! The valve (the flow's `(x, y)`) is left where the layout put it. For the //! common stock-to-stock case (both endpoints on left/right faces) the rebuilt -//! `Z` route's middle segment passes through the valve column, so the valve -//! still sits on the rendered pipe. +//! `Z` route's middle segment passes through the valve column; any other route +//! can miss the valve, and the normalization that follows this pass +//! (`diagram::flow_geometry::normalize_flow_geometry`, the layout's finishing +//! pass) owns bringing it onto the pipe. use std::collections::HashMap; @@ -167,11 +171,17 @@ fn dedup_collinear(points: Vec) -> Vec { out } -/// Rewrite every diagonal flow pipe in `elements` into axis-aligned segments. +/// Rewrite the diagonal pipe of every flow `include` selects (by uid) into +/// axis-aligned segments. /// /// Reads stock centers from `elements` (so it must run after positions are -/// final). Flows whose pipes are already orthogonal are left untouched. -pub(crate) fn orthogonalize_flow_pipes(elements: &mut [ViewElement]) { +/// final). Flows whose pipes are already orthogonal are left untouched, and so +/// is every flow `include` rejects: incremental layout passes only the flows +/// it creates, because a preserved flow comes back byte for byte. +pub(crate) fn orthogonalize_flow_pipes( + elements: &mut [ViewElement], + include: impl Fn(i32) -> bool, +) { let stocks: HashMap = elements .iter() .filter_map(|e| match e { @@ -182,7 +192,7 @@ pub(crate) fn orthogonalize_flow_pipes(elements: &mut [ViewElement]) { for elem in elements.iter_mut() { let ViewElement::Flow(f) = elem else { continue }; - if f.points.len() < 2 || pipe_is_orthogonal(&f.points) { + if !include(f.uid) || f.points.len() < 2 || pipe_is_orthogonal(&f.points) { continue; } @@ -298,7 +308,7 @@ mod tests { ), ]; let before = flow_points(&elements[2]).to_vec(); - orthogonalize_flow_pipes(&mut elements); + orthogonalize_flow_pipes(&mut elements, |_| true); assert_eq!(flow_points(&elements[2]), before.as_slice()); } @@ -321,7 +331,7 @@ mod tests { ], ), ]; - orthogonalize_flow_pipes(&mut elements); + orthogonalize_flow_pipes(&mut elements, |_| true); let pts = flow_points(&elements[2]); assert_orthogonal(pts); assert!(pts.len() >= 3, "expected a bend, got {} points", pts.len()); @@ -352,7 +362,7 @@ mod tests { ], ), ]; - orthogonalize_flow_pipes(&mut elements); + orthogonalize_flow_pipes(&mut elements, |_| true); let pts = flow_points(&elements[2]); assert_orthogonal(pts); assert_eq!(pts.len(), 3, "an L should have exactly one bend"); @@ -376,9 +386,9 @@ mod tests { ], ), ]; - orthogonalize_flow_pipes(&mut elements); + orthogonalize_flow_pipes(&mut elements, |_| true); let once = flow_points(&elements[2]).to_vec(); - orthogonalize_flow_pipes(&mut elements); + orthogonalize_flow_pipes(&mut elements, |_| true); let twice = flow_points(&elements[2]).to_vec(); assert_eq!(once, twice); } @@ -398,7 +408,7 @@ mod tests { vec![fp(100.0 + half_w, 100.0, Some(1)), fp(220.0, 180.0, None)], ), ]; - orthogonalize_flow_pipes(&mut elements); + orthogonalize_flow_pipes(&mut elements, |_| true); let pts = flow_points(&elements[1]); assert_orthogonal(pts); } diff --git a/src/simlin-engine/src/mdl/view/convert.rs b/src/simlin-engine/src/mdl/view/convert.rs index df4d87693..ff257f526 100644 --- a/src/simlin-engine/src/mdl/view/convert.rs +++ b/src/simlin-engine/src/mdl/view/convert.rs @@ -11,15 +11,24 @@ use crate::datamodel::{self, View, ViewElement, view_element}; use std::collections::HashSet; use super::processing::{ - EffectiveGhosts, PrimaryMap, angle_from_points, associate_variables, - build_attached_valve_flow_maps, compose_views, is_cloud_endpoint, resolve_flow_uid_for_valve, - xmile_angle_to_canvas, + EffectiveGhosts, FlowEnd, PipeTarget, PrimaryMap, angle_from_points, associate_variables, + build_attached_valve_flow_maps, compose_views, flow_valve, resolve_flow_ends, + resolve_flow_uid_for_valve, xmile_angle_to_canvas, }; +use super::routes::{PendingFlowRoute, RouteEnd, route_pending_flows}; use super::types::{VensimComment, VensimElement, VensimVariable, VensimView}; use crate::mdl::builtins::to_lower_space; use crate::mdl::convert::VariableType; +#[cfg(test)] +#[path = "convert_flow_geometry_tests.rs"] +mod flow_geometry_tests; + +#[cfg(test)] +#[path = "convert_flow_resolution_tests.rs"] +mod flow_resolution_tests; + /// Build datamodel Views from parsed Vensim views. /// /// This function: @@ -60,7 +69,7 @@ pub fn build_views( let _offsets = compose_views(&mut views); // Track primary variable definitions and effective ghosts - let (primary_map, effective_ghosts) = associate_variables(&views); + let (primary_map, effective_ghosts) = associate_variables(&views, symbols); // Collect view UID offsets for cross-view alias resolution let view_offsets: Vec = views.iter().map(|v| v.uid_offset).collect(); @@ -69,6 +78,7 @@ pub fn build_views( // Track start positions for group geometry (matches compose_views logic) let is_multi_view = views.len() > 1; let mut result = Vec::with_capacity(views.len()); + let mut pending_routes: Vec = Vec::new(); let start_x = 100; let mut start_y = 100; @@ -89,6 +99,7 @@ pub fn build_views( start_x, start_y, use_lettered_polarity, + &mut pending_routes, ) { result.push(dm_view); } @@ -104,15 +115,16 @@ pub fn build_views( result }; - // Post-processing: adjust flow points to stock edges and reassign sequential UIDs. - // This matches the XMILE path's normalize() sequence: - // 1. assign_uids() — sequential UIDs - // 2. fixup_clouds() — sets pt.uid on flow points - // 3. fixup_flow_takeoffs() — adjusts flow point coords to stock edges - // We do steps 3 then 1 (flow points already have attached_to_uid from compute_flow_points). + // Post-processing over the merged view, where every stock's center is + // known. The flow ends the sketch does not place are placed first + // (`routes`). The sketch anchors pipe endpoints to element centers and + // draws stocks at the modeler's size, so the pipes are then brought onto + // the 45x35 boxes every renderer draws (the shared rule the XMILE importer + // also runs), and UIDs are reassigned sequentially. for view in &mut result { let View::StockFlow(sf) = view; - fixup_flow_takeoffs(&mut sf.elements); + route_pending_flows(&mut sf.elements, &pending_routes); + crate::diagram::flow_geometry::normalize_flow_geometry(&mut sf.elements); let uid_map = reassign_uids_sequential(&mut sf.elements); if let Some(sketch_compat) = sf.sketch_compat.as_mut() { remap_sketch_compat_uids(sketch_compat, &uid_map); @@ -122,95 +134,6 @@ pub fn build_views( result } -// Stock dimensions matching the XMILE constants in xmile.rs -const STOCK_WIDTH: f64 = 45.0; -const STOCK_HEIGHT: f64 = 35.0; - -/// Adjust flow point coordinates from stock centers to stock edges. -/// -/// Matches the XMILE path's `fixup_flow_takeoffs()` in xmile.rs. -/// When a flow point is attached to a stock, the coordinate is snapped -/// to the nearest edge of the stock rectangle rather than its center. -fn fixup_flow_takeoffs(elements: &mut [ViewElement]) { - // Collect stock positions by UID - let stocks: HashMap = elements - .iter() - .filter_map(|e| { - if let ViewElement::Stock(s) = e { - Some((s.uid, (s.x, s.y))) - } else { - None - } - }) - .collect(); - - for elem in elements.iter_mut() { - if let ViewElement::Flow(flow) = elem { - if flow.points.len() < 2 { - continue; - } - let source = flow - .points - .first() - .cloned() - .unwrap_or(view_element::FlowPoint { - x: 0.0, - y: 0.0, - attached_to_uid: None, - }); - let sink = flow - .points - .last() - .cloned() - .unwrap_or(view_element::FlowPoint { - x: 0.0, - y: 0.0, - attached_to_uid: None, - }); - - // Adjust source point if attached to a stock - if let Some(stock_uid) = source.attached_to_uid - && let Some(&(sx, sy)) = stocks.get(&stock_uid) - { - adjust_takeoff_point(&mut flow.points[0], sx, sy, &sink); - } - - // Adjust sink point if attached to a stock - if let Some(stock_uid) = sink.attached_to_uid - && let Some(&(sx, sy)) = stocks.get(&stock_uid) - { - let last_idx = flow.points.len() - 1; - adjust_takeoff_point(&mut flow.points[last_idx], sx, sy, &source); - } - } - } -} - -/// Snap a flow point to the nearest edge of its attached stock. -/// -/// `sx, sy` is the stock center. `other` is the flow point at the other end. -/// The point is moved to the stock edge facing the other endpoint. -fn adjust_takeoff_point( - pt: &mut view_element::FlowPoint, - sx: f64, - sy: f64, - other: &view_element::FlowPoint, -) { - if other.x > sx + STOCK_WIDTH / 2.0 && (other.y - sy).abs() < STOCK_HEIGHT / 2.0 { - // Other point is to the right - pt.x = sx + STOCK_WIDTH / 2.0; - } else if other.x < sx - STOCK_WIDTH / 2.0 && (other.y - sy).abs() < STOCK_HEIGHT / 2.0 { - // Other point is to the left - pt.x = sx - STOCK_WIDTH / 2.0; - } else if other.y < sy - STOCK_HEIGHT / 2.0 && (other.x - sx).abs() < STOCK_WIDTH / 2.0 { - // Other point is above - pt.y = sy - STOCK_HEIGHT / 2.0; - } else if other.y > sy + STOCK_HEIGHT / 2.0 && (other.x - sx).abs() < STOCK_WIDTH / 2.0 { - // Other point is below - pt.y = sy + STOCK_HEIGHT / 2.0; - } -} - /// Reassign UIDs sequentially starting from 1 and update all cross-references. /// /// Matches the XMILE path's `assign_uids()` in xmile.rs, which assigns @@ -327,33 +250,26 @@ fn convert_view( start_x: i32, start_y: i32, use_lettered_polarity: bool, + pending_routes: &mut Vec, ) -> Option { let mut elements = Vec::new(); let mut link_sketch_compat = Vec::new(); let uid_offset = view.uid_offset; let (valve_to_flow, flow_to_valve) = build_attached_valve_flow_maps(view); - // Track which comments are clouds (flow endpoints) - let mut cloud_comments: HashMap = HashMap::new(); // comment_uid -> flow_uid - for (uid, _elem) in view.iter_with_uids() { - if let Some(flow_uid) = is_cloud_endpoint(uid, view, &valve_to_flow) { - cloud_comments.insert(uid, flow_uid); - } - } - // If multi-view, add a group element for this view if is_multi_view { let group = create_sector_group(view, original_title, uid_offset, start_x, start_y); elements.push(group); } - // Two-phase conversion to avoid dangling cloud references: - // Phase 1: Convert variables, track emitted flow UIDs - // Phase 2: Create clouds only for flows that were actually emitted - let mut emitted_flow_uids: HashSet = HashSet::new(); - - // Deferred clouds: (local_uid, uid, comment, flow_uid_with_offset) - let mut deferred_clouds: Vec<(&VensimComment, i32, i32)> = Vec::new(); + // A comment is a cloud exactly when an emitted flow's end attaches to it, + // so comments are converted after the variables have claimed theirs: the + // local uid of each claimed comment -> the owning flow's uid. A comment a + // pipe touches but no end uses (a ghost copy's pipe, or a pipe end the + // model's stock lists overrule) is not a cloud of anything. + let mut cloud_owners: HashMap = HashMap::new(); + let mut deferred_comments: Vec<(&VensimComment, i32, i32)> = Vec::new(); for (local_uid, elem) in view.iter_with_uids() { let uid = uid_offset + local_uid; @@ -371,10 +287,9 @@ fn convert_view( uid_offset, view_offsets, &flow_to_valve, + &mut cloud_owners, + pending_routes, ) { - if matches!(&view_elem, ViewElement::Flow(_)) { - emitted_flow_uids.insert(uid); - } elements.push(view_elem); } } @@ -382,11 +297,7 @@ fn convert_view( // Valves are handled as part of flow conversion } VensimElement::Comment(comment) => { - if let Some(&flow_uid) = cloud_comments.get(&local_uid) { - let flow_uid_with_offset = flow_uid + uid_offset; - deferred_clouds.push((comment, uid, flow_uid_with_offset)); - } - // Non-cloud comments are ignored + deferred_comments.push((comment, local_uid, uid)); } VensimElement::Connector(conn) => { if let Some((link, link_compat)) = @@ -399,10 +310,9 @@ fn convert_view( } } - // Phase 2: Emit clouds only for flows that were actually emitted - for (comment, uid, flow_uid_with_offset) in deferred_clouds { - if emitted_flow_uids.contains(&flow_uid_with_offset) { - elements.push(convert_comment_as_cloud(comment, uid, flow_uid_with_offset)); + for (comment, local_uid, uid) in deferred_comments { + if let Some(&flow_uid) = cloud_owners.get(&local_uid) { + elements.push(convert_comment_as_cloud(comment, uid, flow_uid)); } } @@ -489,6 +399,8 @@ fn convert_variable( uid_offset: i32, view_offsets: &[i32], flow_to_valve: &HashMap, + cloud_owners: &mut HashMap, + pending_routes: &mut Vec, ) -> Option { let canonical = to_lower_space(&var.name); @@ -557,28 +469,30 @@ fn convert_variable( )), })), VariableType::Flow => { - // For flows, find the associated valve and compute flow points - let (flow_x, flow_y, points) = - compute_flow_data(var, view, uid_offset, symbols, flow_to_valve); + let (flow_x, flow_y, points) = compute_flow_data( + var, + uid, + view, + uid_offset, + symbols, + primary_map, + view_offsets, + flow_to_valve, + cloud_owners, + pending_routes, + ); // compat holds the valve's dimensions; label_compat holds the label variable's - let valve_uid = flow_to_valve.get(&var.uid).copied().unwrap_or(var.uid - 1); - let valve_compat = if var.attached { - if let Some(VensimElement::Valve(valve)) = view.get(valve_uid) { - Some(make_compat( - valve.width, - valve.height, - valve.shape, - valve.bits, - Some(valve.name.clone()), - &valve.tail, - )) - } else { - None - } - } else { - None - }; + let valve_compat = flow_valve(var, view, flow_to_valve).map(|valve| { + make_compat( + valve.width, + valve.height, + valve.shape, + valve.bits, + Some(valve.name.clone()), + &valve.tail, + ) + }); Some(ViewElement::Flow(view_element::Flow { name: xmile_name, @@ -648,81 +562,115 @@ fn flow_label_side( } } -/// Compute flow data including position and flow points. -/// -/// Returns (flow_x, flow_y, flow_points) where: -/// - flow_x, flow_y: Position of the flow (from valve if attached, else from variable) -/// - flow_points: Start and end points for the flow pipe with attached UIDs +/// A flow's valve position and points. /// -/// Flow point computation searches for connectors from the valve to connected -/// stocks/clouds and determines directionality by checking if this flow appears -/// in the connected stock's inflows or outflows list. +/// The valve is the flow's valve when it has one, else the label's position. +/// The ends come from `processing::resolve_flow_ends`: when the sketch places +/// both, the points are the pipe's, with the interior bends Vensim encodes as +/// self-connectors on the valve; otherwise the points are left empty and the +/// ends are recorded in `pending_routes`, to be placed once the views are +/// merged (`routes::route_pending_flows`). A comment an end attaches to is +/// claimed in `cloud_owners`. +#[allow(clippy::too_many_arguments)] fn compute_flow_data( var: &VensimVariable, + flow_uid: i32, view: &VensimView, uid_offset: i32, symbols: &HashMap>, + primary_map: &PrimaryMap, + view_offsets: &[i32], flow_to_valve: &HashMap, + cloud_owners: &mut HashMap, + pending_routes: &mut Vec, ) -> (i32, i32, Vec) { - // Look for valve at uid - 1 (typical Vensim layout) - // xmutil requires BOTH conditions: - // 1. Flow variable has attached=true (vele->Attached()) - // 2. Preceding element is a valve (elements[local_uid - 1]->Type() == VALVE) - let valve_uid = flow_to_valve.get(&var.uid).copied().unwrap_or(var.uid - 1); - let (flow_x, flow_y) = if var.attached // Flow must be attached - && let Some(VensimElement::Valve(valve)) = view.get(valve_uid) - { - // Use valve coordinates for flow element position - (valve.x, valve.y) - } else { - // Use flow variable coordinates - (var.x, var.y) - }; - - // Get the flow's canonical name for endpoint detection + let valve = flow_valve(var, view, flow_to_valve); + let (flow_x, flow_y) = valve.map_or((var.x, var.y), |v| (v.x, v.y)); let canonical = to_lower_space(&var.name); - - // Compute flow points using the processing module's algorithm - // Pass the flow variable's coordinates for fallback (not valve's) per xmutil behavior - let endpoints = super::processing::compute_flow_points( - valve_uid, var.x, var.y, view, &canonical, symbols, uid_offset, - ); - - let mut points = vec![view_element::FlowPoint { - x: endpoints.from_x as f64, - y: endpoints.from_y as f64, - attached_to_uid: endpoints.from_uid, - }]; - - // Interior pipe bend points are encoded in MDL as self-connectors from the - // valve back to itself; emit them between the endpoints in connector order. - let mut bend_points: Vec<(i32, (i32, i32))> = view - .iter() - .filter_map(|elem| match elem { - VensimElement::Connector(conn) - if conn.from_uid == valve_uid && conn.to_uid == valve_uid => - { - Some((conn.uid, conn.control_point)) + let ends = resolve_flow_ends(valve, view, &canonical, symbols); + + // A stock end attaches to the stock's primary element, wherever the pipe + // was drawn (into a ghost of the stock included). + let stock_uid = |name: &str| -> Option { + primary_map + .get(name) + .map(|(view_idx, local)| view_offsets.get(*view_idx).copied().unwrap_or(0) + local) + }; + let mut to_route = |end: FlowEnd| -> RouteEnd { + match end { + FlowEnd::Pipe { + x, + y, + target: PipeTarget::Stock(name), + } => stock_uid(&name).map_or(RouteEnd::Free, |uid| { + RouteEnd::Point(view_element::FlowPoint { + x: x as f64, + y: y as f64, + attached_to_uid: Some(uid), + }) + }), + FlowEnd::Pipe { + x, + y, + target: PipeTarget::Cloud(local_uid), + } => { + cloud_owners.insert(local_uid, flow_uid); + RouteEnd::Point(view_element::FlowPoint { + x: x as f64, + y: y as f64, + attached_to_uid: Some(uid_offset + local_uid), + }) } - _ => None, - }) - .collect(); - bend_points.sort_by_key(|(uid, _)| *uid); - for (_, (bend_x, bend_y)) in bend_points { - points.push(view_element::FlowPoint { - x: bend_x as f64, - y: bend_y as f64, - attached_to_uid: None, - }); + FlowEnd::PipeAtUnlinkedStock { x, y, stock } => RouteEnd::CloudNearStock { + end: (x as f64, y as f64), + stock: (stock.0 as f64, stock.1 as f64), + }, + FlowEnd::Stock(name) => stock_uid(&name).map_or(RouteEnd::Free, RouteEnd::Stock), + FlowEnd::Free => RouteEnd::Free, + } + }; + let source = to_route(ends.source); + let sink = to_route(ends.sink); + + match (source, sink) { + (RouteEnd::Point(first), RouteEnd::Point(last)) => { + let mut points = vec![first]; + // Interior pipe bend points are encoded in MDL as self-connectors + // from the valve back to itself; emit them between the endpoints + // in connector order. + if let Some(valve) = valve { + let mut bend_points: Vec<(i32, (i32, i32))> = view + .iter() + .filter_map(|elem| match elem { + VensimElement::Connector(conn) + if conn.from_uid == valve.uid && conn.to_uid == valve.uid => + { + Some((conn.uid, conn.control_point)) + } + _ => None, + }) + .collect(); + bend_points.sort_by_key(|(uid, _)| *uid); + for (_, (bend_x, bend_y)) in bend_points { + points.push(view_element::FlowPoint { + x: bend_x as f64, + y: bend_y as f64, + attached_to_uid: None, + }); + } + } + points.push(last); + (flow_x, flow_y, points) + } + (source, sink) => { + pending_routes.push(PendingFlowRoute { + flow_uid, + source, + sink, + }); + (flow_x, flow_y, Vec::new()) + } } - - points.push(view_element::FlowPoint { - x: endpoints.to_x as f64, - y: endpoints.to_y as f64, - attached_to_uid: endpoints.to_uid, - }); - - (flow_x, flow_y, points) } /// Convert a comment element that serves as a cloud (flow endpoint). diff --git a/src/simlin-engine/src/mdl/view/convert_flow_geometry_tests.rs b/src/simlin-engine/src/mdl/view/convert_flow_geometry_tests.rs new file mode 100644 index 000000000..cb632353e --- /dev/null +++ b/src/simlin-engine/src/mdl/view/convert_flow_geometry_tests.rs @@ -0,0 +1,173 @@ +// Copyright 2026 The Simlin Authors. All rights reserved. +// Use of this source code is governed by the Apache License, +// Version 2.0, that can be found in the LICENSE file. + +//! The MDL importer's flow geometry, read through `open_vensim` on corpus +//! files. A Vensim sketch anchors a pipe's endpoints to the centers of the +//! elements it connects and draws stocks at whatever size the modeler chose, +//! while every Simlin renderer draws a stock as a 45x35 box; the imported +//! geometry must satisfy `diagram::flow_geometry`'s invariants on that box. + +use crate::compat::open_vensim; +use crate::datamodel::{self, ViewElement, view_element}; +use crate::diagram::flow_geometry::flow_invariant_violations; + +fn main_view(project: &datamodel::Project) -> &datamodel::StockFlow { + let datamodel::View::StockFlow(sf) = &project.models[0].views[0]; + sf +} + +fn flow<'a>(view: &'a datamodel::StockFlow, name: &str) -> &'a view_element::Flow { + view.elements + .iter() + .find_map(|e| match e { + ViewElement::Flow(f) if f.name == name => Some(f), + _ => None, + }) + .unwrap_or_else(|| panic!("flow {name:?} not in view")) +} + +fn coords(f: &view_element::Flow) -> Vec<(f64, f64)> { + f.points.iter().map(|p| (p.x, p.y)).collect() +} + +fn violations_for(view: &datamodel::StockFlow, names: &[&str]) -> Vec { + flow_invariant_violations(&view.elements) + .into_iter() + .filter(|v| names.iter().any(|n| v.starts_with(&format!("{n}:")))) + .collect() +} + +fn cloud_at(view: &datamodel::StockFlow, uid: i32) -> (f64, f64) { + view.elements + .iter() + .find_map(|e| match e { + ViewElement::Cloud(c) if c.uid == uid => Some((c.x, c.y)), + _ => None, + }) + .unwrap_or_else(|| panic!("cloud {uid} not in view")) +} + +/// `mark2.mdl`: Vensim's `risk taking behavior` box is 90x65, and the pipe +/// into it runs 19 below its center -- past the 45x35 box's bottom face but +/// within reach of the side face it approaches. The pipe slides up into the +/// right face's clearance span, carrying its valve and its cloud. +#[test] +fn a_pipe_just_past_a_big_stocks_face_slides_onto_the_45x35_face() { + const MARK2: &str = include_str!("../../../../../test/bobby/vdf/econ/mark2.mdl"); + let project = open_vensim(MARK2).expect("mark2 imports"); + let view = main_view(&project); + let f = flow(view, "change_in_risk_taking_behavior"); + assert_eq!(coords(f), vec![(1087.0, 1478.5), (887.5, 1478.5)]); + assert_eq!((f.x, f.y), (1012.0, 1478.5)); + assert_eq!( + cloud_at(view, f.points[0].attached_to_uid.unwrap()), + (1087.0, 1478.5) + ); + assert_eq!( + flow_invariant_violations(&view.elements), + Vec::::new() + ); +} + +/// `scirev7.mdl`: a pipe whose line passes far from its stock keeps its line +/// and gains the perpendicular leg the 45x35 box needs. `PSR`'s pipe drops +/// from `PUA` and ends 571 left of `SP`'s center on `SP`'s center row, so a +/// horizontal leg runs into `SP`'s left face; `ARESR`'s pipe passes 210 above +/// `SP`'s center, so a vertical leg drops into its top face. +#[test] +fn a_pipe_far_from_its_stock_gains_a_leg_into_a_face() { + const SCIREV7: &str = + include_str!("../../../../../test/metasd/scientific-revolution/scirev7.mdl"); + let project = open_vensim(SCIREV7).expect("scirev7 imports"); + let view = main_view(&project); + assert_eq!( + coords(flow(view, "PSR")), + vec![(621.0, 227.5), (621.0, 420.0), (1169.5, 420.0)] + ); + assert_eq!( + coords(flow(view, "ARESR")), + vec![(989.5, 210.0), (1192.0, 210.0), (1192.0, 402.5)] + ); + assert_eq!( + violations_for(view, &["PSR", "ARESR"]), + Vec::::new() + ); +} + +/// `Covid19US v8.mdl`: `Fatalities` leaves `Infected sympto` 134 above its +/// center (a leg into the top face) and enters `Dead from COVID` on a valid +/// off-center slot, which stays exactly where it is. +#[test] +fn a_leg_at_one_end_leaves_a_valid_slot_at_the_other_untouched() { + const COVID: &str = + include_str!("../../../../../test/metasd/covid19-us-homer/homer v8/Covid19US v8.mdl"); + let project = open_vensim(COVID).expect("Covid19US v8 imports"); + let view = main_view(&project); + assert_eq!( + coords(flow(view, "Fatalities")), + vec![(949.0, 481.5), (949.0, 365.0), (1155.5, 365.0)] + ); + assert_eq!(violations_for(view, &["Fatalities"]), Vec::::new()); +} + +/// Endpoints on a top or bottom face within 3px of a corner slide into the +/// clearance span: `flow7` (0.5 from the corner, the other end a cloud) and +/// `Flow_k63` (2.5 from the corner, the other end a valid slot 4.5 from its +/// own corner, which gives up the least it can -- 0.5 -- and stays valid). +#[test] +fn corner_zone_endpoints_slide_into_the_clearance_span() { + const ZEROLED: &str = include_str!( + "../../../../../test/test-models/tests/zeroled_decimals/test_zeroled_decimals.mdl" + ); + let project = open_vensim(ZEROLED).expect("zeroled_decimals imports"); + let view = main_view(&project); + let f = flow(view, "flow7"); + assert_eq!(coords(f), vec![(800.5, 308.5), (800.5, 405.0)]); + assert_eq!((f.x, f.y), (800.5, 354.0)); + assert_eq!( + flow_invariant_violations(&view.elements), + Vec::::new() + ); + + const THYROID: &str = + include_str!("../../../../../test/metasd/thyroid-dynamics/thyroid-2008-d.mdl"); + let project = open_vensim(THYROID).expect("thyroid imports"); + let view = main_view(&project); + assert_eq!( + coords(flow(view, "Flow_k63")), + vec![(305.5, 612.5), (305.5, 927.5)] + ); + // Flow_k31 and its siblings run 50-66 above or below both stocks' + // centers: legs at both ends. + assert_eq!( + coords(flow(view, "Flow_k31")), + vec![ + (610.0, 577.5), + (610.0, 540.0), + (325.0, 540.0), + (325.0, 577.5) + ] + ); + assert_eq!( + flow_invariant_violations(&view.elements), + Vec::::new() + ); +} + +/// `integration3.mdl`: a Vensim cloud comment sits a few pixels off the +/// pipe's end; the cloud moves onto the endpoint. +#[test] +fn a_cloud_comment_off_the_pipe_end_is_centered_on_it() { + const INTEGRATION3: &str = + include_str!("../../../../../test/metasd/bathtub-statistics/integration3.mdl"); + let project = open_vensim(INTEGRATION3).expect("integration3 imports"); + let view = main_view(&project); + let f = flow(view, "Noise"); + let source = &f.points[0]; + assert_eq!( + cloud_at(view, source.attached_to_uid.unwrap()), + (source.x, source.y) + ); + assert_eq!(violations_for(view, &["Noise"]), Vec::::new()); +} diff --git a/src/simlin-engine/src/mdl/view/convert_flow_resolution_tests.rs b/src/simlin-engine/src/mdl/view/convert_flow_resolution_tests.rs new file mode 100644 index 000000000..6792f18ce --- /dev/null +++ b/src/simlin-engine/src/mdl/view/convert_flow_resolution_tests.rs @@ -0,0 +1,275 @@ +// Copyright 2026 The Simlin Authors. All rights reserved. +// Use of this source code is governed by the Apache License, +// Version 2.0, that can be found in the LICENSE file. + +//! Which elements an imported MDL flow's ends attach to, read through +//! `open_vensim` on corpus files. The attachments follow the model's stock +//! lists: a side the model links to a stock drawn in the view attaches to that +//! stock, and every other side ends in a cloud owned by the flow. Every +//! imported flow renders. +//! +//! One test per way the sketch can fail to supply an end the model has: +//! - A: the flow's primary sketch record is a label with no valve, and the pipe +//! is drawn on another copy; +//! - B: a pipe end targets a stock that does not list the flow (the importer +//! gave that stock a synthesized net flow), so that side has no stock; +//! - C: the flow is drawn as a label with no pipe at all; +//! - B': a pipe exists, but the stock the model links is not one of its ends. + +use crate::common::canonicalize; +use crate::compat::open_vensim; +use crate::datamodel::{self, ViewElement, view_element}; +use crate::diagram::flow_geometry::flow_invariant_violations; + +fn main_view(project: &datamodel::Project) -> &datamodel::StockFlow { + let datamodel::View::StockFlow(sf) = &project.models[0].views[0]; + sf +} + +fn flow<'a>(view: &'a datamodel::StockFlow, name: &str) -> &'a view_element::Flow { + view.elements + .iter() + .find_map(|e| match e { + ViewElement::Flow(f) if f.name == name => Some(f), + _ => None, + }) + .unwrap_or_else(|| panic!("flow {name:?} not in view")) +} + +/// What an endpoint is attached to: `Some(stock ident)` for a stock, `None` +/// for a cloud owned by the flow. Panics for anything else, which is itself a +/// violation the importer must not produce. +fn end_attachment( + view: &datamodel::StockFlow, + f: &view_element::Flow, + point: &view_element::FlowPoint, +) -> Option { + let uid = point + .attached_to_uid + .unwrap_or_else(|| panic!("{}: an endpoint is unattached", f.name)); + match view.elements.iter().find(|e| e.get_uid() == uid) { + Some(ViewElement::Stock(s)) => Some(canonicalize(&s.name).into_owned()), + Some(ViewElement::Cloud(c)) => { + assert_eq!(c.flow_uid, f.uid, "{}: cloud owned by another flow", f.name); + None + } + other => panic!( + "{}: endpoint attached to {:?}", + f.name, + other.map(|e| e.get_uid()) + ), + } +} + +/// The model's source and sink stock for a flow, restricted to stocks drawn in +/// the view. +fn model_sides( + project: &datamodel::Project, + view: &datamodel::StockFlow, + flow_ident: &str, +) -> (Option, Option) { + let drawn: Vec = view + .elements + .iter() + .filter_map(|e| match e { + ViewElement::Stock(s) => Some(canonicalize(&s.name).into_owned()), + _ => None, + }) + .collect(); + let mut source = None; + let mut sink = None; + for var in &project.models[0].variables { + let datamodel::Variable::Stock(s) = var else { + continue; + }; + let ident = canonicalize(&s.ident).into_owned(); + if !drawn.contains(&ident) { + continue; + } + if s.outflows.iter().any(|o| canonicalize(o) == flow_ident) { + source = Some(ident.clone()); + } + if s.inflows.iter().any(|i| canonicalize(i) == flow_ident) { + sink = Some(ident); + } + } + (source, sink) +} + +/// Every flow in the view: the invariants hold, and each end attaches where +/// the model says. +fn assert_every_flow_resolved(project: &datamodel::Project, label: &str) { + let view = main_view(project); + assert_eq!( + flow_invariant_violations(&view.elements), + Vec::::new(), + "{label}: flow invariants" + ); + for elem in &view.elements { + let ViewElement::Flow(f) = elem else { continue }; + let ident = canonicalize(&f.name).into_owned(); + let (source, sink) = model_sides(project, view, &ident); + let first = end_attachment(view, f, &f.points[0]); + let last = end_attachment(view, f, f.points.last().unwrap()); + assert_eq!(first, source, "{label}: {} source attachment", f.name); + assert_eq!(last, sink, "{label}: {} sink attachment", f.name); + } +} + +/// Class A. `free 6.mdl` draws `Total Carbon Emissions` twice: its primary +/// record is a label with no valve, and its pipe into `CO2 in Atmosphere` is on +/// a copy the sketch marks as a ghost. The pipe-carrying copy presents the +/// flow; the label is an alias of it. +#[test] +fn a_flow_whose_pipe_is_on_a_ghost_copy_is_presented_by_that_copy() { + const FREE6: &str = + include_str!("../../../../../test/metasd/FREE/FREE6/FREE6-original/free 6.mdl"); + let project = open_vensim(FREE6).expect("free 6 imports"); + let view = main_view(&project); + let f = flow(view, "Total_Carbon_Emissions"); + assert_eq!( + end_attachment(view, f, f.points.last().unwrap()), + Some("co2_in_atmosphere".to_string()) + ); + assert_eq!(end_attachment(view, f, &f.points[0]), None); + assert!( + view.elements + .iter() + .any(|e| matches!(e, ViewElement::Alias(a) if a.alias_of_uid == f.uid)), + "the label copy becomes an alias of the flow" + ); + assert_every_flow_resolved(&project, "free 6"); +} + +/// Class B. `IDch15d.mdl`'s `SRR` pipe runs from `MTR` into `IAR`, but `IAR`'s +/// rate (`SRR - SSR`, with `SSR` also draining `UOR`) does not decompose, so the +/// model gives `IAR` a synthesized net flow and `SRR` no sink stock. The sink is +/// a cloud at the pipe's end, outside `IAR`'s box, and the valve stays where +/// the sketch drew it. +#[test] +fn a_pipe_into_a_stock_that_does_not_list_the_flow_ends_in_a_cloud() { + const IDCH15D: &str = + include_str!("../../../../../test/metasd/industrial-dynamics/IDch15/IDch15d.mdl"); + let project = open_vensim(IDCH15D).expect("IDch15d imports"); + let view = main_view(&project); + let f = flow(view, "SRR"); + assert_eq!( + end_attachment(view, f, &f.points[0]), + Some("mtr".to_string()) + ); + assert_eq!(end_attachment(view, f, f.points.last().unwrap()), None); + assert_eq!((f.x, f.y), (159.0, 602.0)); + assert_every_flow_resolved(&project, "IDch15d"); + + const MAPPING: &str = include_str!( + "../../../../../test/test-models/tests/subscript_mapping_simple/test_subscript_mapping_simple.mdl" + ); + let project = open_vensim(MAPPING).expect("subscript_mapping_simple imports"); + assert_every_flow_resolved(&project, "subscript_mapping_simple"); + + const BEER: &str = include_str!("../../../../../test/metasd/beer-game/RealBeer4-Sterman13.mdl"); + let project = open_vensim(BEER).expect("RealBeer4 imports"); + let view = main_view(&project); + let f = flow(view, "Receiving"); + assert_eq!((f.x, f.y), (552.0, 769.0), "the sketch's valve position"); + assert_every_flow_resolved(&project, "RealBeer4"); +} + +/// Class C. `sample.mdl` draws `rate` as a label with a plain arrow into `G` +/// and no pipe; `G = INTEG(rate, ...)`. The flow attaches to `G` through a +/// pipe routed through the valve at the label's position, from a cloud. +/// `IDch15d.mdl`'s `SSD` has two label copies and no pipe; the copy with an +/// arrow into its stock `MTR` presents the flow. +#[test] +fn a_flow_drawn_without_a_pipe_is_routed_into_its_stock() { + const SAMPLE: &str = include_str!("../../../../../test/sdeverywhere/models/sample/sample.mdl"); + let project = open_vensim(SAMPLE).expect("sample imports"); + let view = main_view(&project); + let f = flow(view, "rate"); + assert_eq!((f.x, f.y), (101.0, 327.0), "the valve stays at the label"); + assert_every_flow_resolved(&project, "sample"); + + const IDCH15D: &str = + include_str!("../../../../../test/metasd/industrial-dynamics/IDch15/IDch15d.mdl"); + let project = open_vensim(IDCH15D).expect("IDch15d imports"); + let view = main_view(&project); + let f = flow(view, "SSD"); + assert_eq!( + (f.x, f.y), + (154.0, 350.0), + "the copy with an arrow into MTR presents SSD" + ); +} + +/// Class B'. `Query_file.mdl`'s `Expenses` pipe runs from `BalanceFunds` (a +/// net-flow stock that does not list it) to a cloud, while the model's only +/// link is `CumExpense = INTEG(Expenses, 0)`. The sink attaches to `CumExpense` +/// through a route from the valve; the source is a cloud at the pipe's end on +/// the valve's other side, outside `BalanceFunds`. The label-only cost flows +/// (class C) route into their `Cum...` stocks. +#[test] +fn a_pipe_whose_model_stock_is_elsewhere_is_routed_to_it() { + const QUERY: &str = + include_str!("../../../../../test/test-models/samples/Query_file/Query_file.mdl"); + let project = open_vensim(QUERY).expect("Query_file imports"); + let view = main_view(&project); + let f = flow(view, "Expenses"); + assert_eq!( + (f.x, f.y), + (1323.0, 805.0), + "the valve stays at the sketch valve" + ); + assert_eq!( + end_attachment(view, f, f.points.last().unwrap()), + Some("cumexpense".to_string()) + ); + assert_eq!(end_attachment(view, f, &f.points[0]), None); + assert_every_flow_resolved(&project, "Query_file"); +} + +/// Two pipe-carrying copies tie on rank. `thyroid-2008-d.mdl` draws +/// `T3 absorption` with a valve twice: in `Gut/Dosage` its pipe drains +/// `Gut T3 dissolved` into nothing, in `THR D&E` it fills `4 Plasma T3` from a +/// cloud. Each copy reaches one of the two stocks the model links and leaves +/// the other to a route from its valve; the `THR D&E` copy's valve is the +/// nearer to the stock it leaves unreached, so it presents the flow and the +/// other copy becomes an alias. +#[test] +fn of_two_pipe_carrying_copies_the_one_nearest_its_unreached_stock_presents_the_flow() { + const THYROID: &str = + include_str!("../../../../../test/metasd/thyroid-dynamics/thyroid-2008-d.mdl"); + let project = open_vensim(THYROID).expect("thyroid imports"); + let view = main_view(&project); + let f = flow(view, "T3_absorption"); + assert_eq!( + end_attachment(view, f, &f.points[0]), + Some("gut_t3_dissolved".to_string()) + ); + assert_eq!( + end_attachment(view, f, f.points.last().unwrap()), + Some("4_plasma_t3".to_string()) + ); + let stock_at = |ident: &str| { + view.elements + .iter() + .find_map(|e| match e { + ViewElement::Stock(s) if canonicalize(&s.name) == ident => Some((s.x, s.y)), + _ => None, + }) + .unwrap_or_else(|| panic!("stock {ident} in view")) + }; + let from_valve = |p: (f64, f64)| (p.0 - f.x).hypot(p.1 - f.y); + assert!( + from_valve(stock_at("4_plasma_t3")) < from_valve(stock_at("gut_t3_dissolved")), + "the THR D&E copy, whose pipe fills 4 Plasma T3, presents the flow: valve ({}, {})", + f.x, + f.y + ); + assert!( + view.elements + .iter() + .any(|e| matches!(e, ViewElement::Alias(a) if a.alias_of_uid == f.uid)), + "the Gut/Dosage copy becomes an alias of the flow" + ); + assert_every_flow_resolved(&project, "thyroid-2008-d"); +} diff --git a/src/simlin-engine/src/mdl/view/mod.rs b/src/simlin-engine/src/mdl/view/mod.rs index b0b2966f0..4ed7f9405 100644 --- a/src/simlin-engine/src/mdl/view/mod.rs +++ b/src/simlin-engine/src/mdl/view/mod.rs @@ -10,6 +10,7 @@ pub mod convert; pub mod elements; pub mod processing; +mod routes; pub mod types; pub use convert::build_views; diff --git a/src/simlin-engine/src/mdl/view/processing.rs b/src/simlin-engine/src/mdl/view/processing.rs index 83afd4519..e57f49ce4 100644 --- a/src/simlin-engine/src/mdl/view/processing.rs +++ b/src/simlin-engine/src/mdl/view/processing.rs @@ -10,7 +10,7 @@ use std::collections::HashMap; use std::f64::consts::PI; -use super::types::{VensimElement, VensimView}; +use super::types::{VensimElement, VensimValve, VensimVariable, VensimView}; /// Calculate angle from three points (AngleFromPoints from xmutil). /// @@ -259,21 +259,6 @@ pub fn compose_views(views: &mut [VensimView]) -> Vec { offsets } -/// Result of computing flow endpoints. -#[cfg_attr(feature = "debug-derive", derive(Debug))] -pub struct FlowEndpoints { - /// "From" endpoint coordinates - pub from_x: i32, - pub from_y: i32, - /// UID of the stock/cloud at the "from" endpoint (if any) - pub from_uid: Option, - /// "To" endpoint coordinates - pub to_x: i32, - pub to_y: i32, - /// UID of the stock/cloud at the "to" endpoint (if any) - pub to_uid: Option, -} - /// Build lookup tables between attached valves and attached flow variables. /// /// Legacy MDL commonly uses `flow_uid = valve_uid + 1`, but writer output may @@ -363,185 +348,347 @@ pub fn resolve_flow_uid_for_valve( }) } -/// Compute flow points for a flow variable. +/// The valve a flow record is drawn with: an attached flow record's paired +/// attached valve (`flow_to_valve`), falling back to the legacy `uid - 1` +/// adjacency. `None` for a flow drawn as a bare label. +pub fn flow_valve<'a>( + var: &VensimVariable, + view: &'a VensimView, + flow_to_valve: &HashMap, +) -> Option<&'a VensimValve> { + if !var.attached { + return None; + } + let valve_uid = flow_to_valve.get(&var.uid).copied().unwrap_or(var.uid - 1); + match view.get(valve_uid) { + Some(VensimElement::Valve(valve)) => Some(valve), + _ => None, + } +} + +/// What a flow's pipe end is drawn into. +#[cfg_attr(feature = "debug-derive", derive(Debug))] +#[derive(Clone, PartialEq, Eq)] +pub enum PipeTarget { + /// A stock, by canonical (`to_lower_space`) name. + Stock(String), + /// A comment drawn as a cloud, by its local uid in the view. + Cloud(i32), +} + +/// One end of an imported flow as its sketch pipe and the model's stock lists +/// resolve it. /// -/// This implements the XMILEGenerator.cpp:987-1072 algorithm for determining -/// flow pipe endpoints based on connected stocks and clouds. +/// The attachments follow the model: a side the model links to a stock ends +/// on that stock, and every other side ends in a cloud. The sketch supplies +/// where an end is drawn when it can; `convert` places the ends it cannot. +#[cfg_attr(feature = "debug-derive", derive(Debug))] +#[derive(Clone, PartialEq, Eq)] +pub enum FlowEnd { + /// The pipe ends at what this side attaches to: the stock the model links + /// on this side, or a cloud. `x`/`y` is the pipe end. + Pipe { x: i32, y: i32, target: PipeTarget }, + /// The pipe ends at a stock that does not list the flow on this side (the + /// importer gave that stock a synthesized net flow), so this side has no + /// stock and ends in a cloud clear of that stock, which the sketch drew at + /// `stock`. + PipeAtUnlinkedStock { x: i32, y: i32, stock: (i32, i32) }, + /// The model links this side to the stock with this canonical name, and + /// the pipe does not reach it (or there is no pipe). + Stock(String), + /// No stock on this side and no pipe end for it. + Free, +} + +/// Both ends of an imported flow. +#[cfg_attr(feature = "debug-derive", derive(Debug))] +pub struct FlowEnds { + pub source: FlowEnd, + pub sink: FlowEnd, +} + +/// The stock the model links a flow to on one side: the stock listing it as +/// an inflow (`sink`) or an outflow. The MDL importer lets a flow fill at most +/// one stock and drain at most one (a rate that would break that becomes a +/// synthesized net flow), so there is at most one; the sort only makes a +/// degenerate symbol table deterministic. +fn model_stock( + symbols: &HashMap>, + flow_name: &str, + sink: bool, +) -> Option { + use crate::mdl::convert::VariableType; + + let mut names: Vec<&String> = symbols + .iter() + .filter(|(_, info)| { + let list = if sink { &info.inflows } else { &info.outflows }; + info.var_type == VariableType::Stock && list.iter().any(|f| f == flow_name) + }) + .map(|(name, _)| name) + .collect(); + names.sort(); + names.first().map(|name| (*name).clone()) +} + +/// Resolve a flow's two ends from its sketch pipe and the model's stock lists. /// -/// Key behaviors matching xmutil: -/// 1. Use connector control points for endpoint positions, NOT element centers -/// 2. Apply anchor snapping: if vertical (xpt[0] == xpt[1]), snap Y to anchors; -/// otherwise snap X to anchors -/// 3. Output points in order: from endpoint first, then to endpoint +/// The pipe is the connectors from the flow's valve to stocks and comments, +/// in sketch order (a connector from the valve to any other variable is a +/// causal link drawn from the flow, not a pipe end). An end keeps xmutil's +/// anchor snapping (XMILEGenerator.cpp:1052-1061): a pipe whose two control +/// points share an x is vertical and its ends take their targets' y, otherwise +/// they take their targets' x; the other coordinate is the control point's. /// -/// `flow_name` is the canonical name of the flow variable. -/// The function looks up connected stocks and checks if this flow appears -/// in their inflows (making that stock the "to" endpoint) or outflows -/// (making that stock the "from" endpoint). -#[allow(clippy::too_many_arguments)] -pub fn compute_flow_points( - valve_uid: i32, - flow_x: i32, - flow_y: i32, +/// A pipe end at the stock the model links on a side is that side's end. The +/// remaining pipe ends -- clouds, and stocks that do not list the flow -- serve +/// the sides the model gives no stock. When exactly one side is open and the +/// other side's stock is not a pipe end, the route to that stock continues +/// through the valve, so the open side takes the pipe end on the valve's far +/// side from the stock. When both sides are open (a flow that touches no +/// stock), sketch order decides the source: unverified against Vensim, and it +/// decides only which end of a stockless flow carries the arrowhead. +pub fn resolve_flow_ends( + valve: Option<&VensimValve>, view: &VensimView, flow_name: &str, symbols: &HashMap>, - uid_offset: i32, -) -> FlowEndpoints { +) -> FlowEnds { use crate::mdl::builtins::to_lower_space; + use crate::mdl::convert::VariableType; - // Collect endpoint information (xmutil: xpt, ypt, xanchor, yanchor) - struct EndpointInfo { - uid: i32, - // Control point from connector (xpt, ypt in xmutil) - ctrl_x: i32, - ctrl_y: i32, - // Target element center (xanchor, yanchor in xmutil) - anchor_x: i32, - anchor_y: i32, + struct RawEnd { + control: (i32, i32), + anchor: (i32, i32), + target: PipeTarget, } - let mut endpoints: Vec = Vec::new(); - let mut to_index: Option = None; - - // Find connectors from valve to stocks/clouds - for elem in view.iter() { - if let VensimElement::Connector(conn) = elem - && conn.from_uid == valve_uid - && let Some(target) = view.get(conn.to_uid) - { - // Determine if this is a valid endpoint and whether it's an inflow/outflow - // xmutil only sets to_index for Variable (stock) endpoints, not comments - let (is_valid, is_inflow) = match target { - VensimElement::Variable(v) => { - let target_canonical = to_lower_space(&v.name); - // Look up the stock's SymbolInfo - if let Some(stock_info) = symbols.get(&target_canonical) { - // Check if this flow is in the stock's inflows or outflows - let is_inflow = stock_info.inflows.contains(&flow_name.to_string()); - let is_outflow = stock_info.outflows.contains(&flow_name.to_string()); - if is_inflow || is_outflow { - (true, Some(is_inflow)) - } else { - (false, None) - } - } else { - (false, None) - } - } - VensimElement::Comment(_) => { - // Clouds are valid endpoints but DON'T affect to_index (xmutil behavior) - (true, None) - } - _ => (false, None), + let mut raw: Vec = Vec::new(); + if let Some(valve) = valve { + for elem in view.iter() { + let VensimElement::Connector(conn) = elem else { + continue; }; - - if is_valid { - // xmutil uses connector control point for xpt/ypt - // and target element center for xanchor/yanchor - let count = endpoints.len(); - - // Only set to_index for Variable (stock) endpoints, not comments - // xmutil: inflows set toind = count, outflows set toind = count ? 0 : 1 - // Inflows take precedence over outflows - if let Some(is_inflow_endpoint) = is_inflow { - if is_inflow_endpoint { - // Inflow: this endpoint is the "to" - inflows always override - to_index = Some(count); - } else if to_index.is_none() { - // Outflow and no inflow found yet: the OTHER endpoint is the "to" - to_index = Some(if count > 0 { 0 } else { 1 }); + if conn.from_uid != valve.uid || conn.to_uid == valve.uid { + continue; + } + let (target, anchor) = match view.get(conn.to_uid) { + Some(VensimElement::Variable(v)) => { + let name = to_lower_space(&v.name); + let is_stock = symbols + .get(&name) + .is_some_and(|info| info.var_type == VariableType::Stock); + if !is_stock { + continue; } + (PipeTarget::Stock(name), (v.x, v.y)) } + Some(VensimElement::Comment(c)) => (PipeTarget::Cloud(conn.to_uid), (c.x, c.y)), + _ => continue, + }; + raw.push(RawEnd { + control: conn.control_point, + anchor, + target, + }); + if raw.len() == 2 { + break; + } + } + } - endpoints.push(EndpointInfo { - uid: conn.to_uid, - ctrl_x: conn.control_point.0, - ctrl_y: conn.control_point.1, - anchor_x: target.x(), - anchor_y: target.y(), - }); + let vertical = match raw.as_slice() { + [a, b] => a.control.0 == b.control.0, + [a] => valve.is_some_and(|v| a.control.0 == v.x), + _ => false, + }; + let point_of = |end: &RawEnd| -> (i32, i32) { + if vertical { + (end.control.0, end.anchor.1) + } else { + (end.anchor.0, end.control.1) + } + }; + let pipe_end = |end: &RawEnd| -> FlowEnd { + let (x, y) = point_of(end); + FlowEnd::Pipe { + x, + y, + target: end.target.clone(), + } + }; + let spare_end = |end: &RawEnd| -> FlowEnd { + let (x, y) = point_of(end); + match &end.target { + PipeTarget::Cloud(_) => pipe_end(end), + PipeTarget::Stock(_) => FlowEnd::PipeAtUnlinkedStock { + x, + y, + stock: end.anchor, + }, + } + }; - if endpoints.len() >= 2 { - break; - } + let source_stock = model_stock(symbols, flow_name, false); + let sink_stock = model_stock(symbols, flow_name, true); + let mut source: Option = None; + let mut sink: Option = None; + let mut spare: Vec<&RawEnd> = Vec::new(); + for end in &raw { + match &end.target { + PipeTarget::Stock(name) if sink.is_none() && sink_stock.as_ref() == Some(name) => { + sink = Some(pipe_end(end)); + } + PipeTarget::Stock(name) if source.is_none() && source_stock.as_ref() == Some(name) => { + source = Some(pipe_end(end)); } + _ => spare.push(end), } } - // Fall back to default if not enough endpoints found (xmutil: count < 2 || toind < 0) - let to_idx = match to_index { - Some(idx) if endpoints.len() >= 2 => idx, - _ => { - // xmutil uses flow element coordinates for defaults - return FlowEndpoints { - from_x: flow_x - 150, - from_y: flow_y, - from_uid: None, - to_x: flow_x + 25, - to_y: flow_y, - to_uid: None, - }; + let source_open = source.is_none() && source_stock.is_none(); + let sink_open = sink.is_none() && sink_stock.is_none(); + if source_open && sink_open { + let mut spares = spare.iter(); + source = spares.next().map(|end| spare_end(end)); + sink = spares.next().map(|end| spare_end(end)); + } else if source_open || sink_open { + // The other side's stock, when the pipe does not reach it. + let unreached = if source_open { + sink.is_none().then_some(sink_stock.as_deref()).flatten() + } else { + source + .is_none() + .then_some(source_stock.as_deref()) + .flatten() + }; + let stock_along = unreached.and_then(|name| { + view.iter().find_map(|e| match e { + VensimElement::Variable(v) if to_lower_space(&v.name) == name => { + Some(if vertical { v.y } else { v.x }) + } + _ => None, + }) + }); + let valve_along = valve.map(|v| if vertical { v.y } else { v.x }); + let far_side = match (stock_along, valve_along) { + (Some(s), Some(v)) => spare.iter().copied().find(|end| { + let (x, y) = point_of(end); + let e = if vertical { y } else { x }; + (e - v).signum() != (s - v).signum() + }), + _ => None, + }; + let chosen = far_side.or_else(|| spare.first().copied()).map(spare_end); + if source_open { + source = chosen; + } else { + sink = chosen; } - }; + } + + FlowEnds { + source: source.unwrap_or_else(|| source_stock.map_or(FlowEnd::Free, FlowEnd::Stock)), + sink: sink.unwrap_or_else(|| sink_stock.map_or(FlowEnd::Free, FlowEnd::Stock)), + } +} - // Apply anchor snapping (xmutil XMILEGenerator.cpp:1052-1061) - let mut xpt = [endpoints[0].ctrl_x, endpoints[1].ctrl_x]; - let mut ypt = [endpoints[0].ctrl_y, endpoints[1].ctrl_y]; - let xanchor = [endpoints[0].anchor_x, endpoints[1].anchor_x]; - let yanchor = [endpoints[0].anchor_y, endpoints[1].anchor_y]; +/// How well a sketch record of a flow can present the flow, higher first: 2 +/// for a record carrying the flow's pipe (an attached record with a valve), 1 +/// for a label with a connector into a stock that lists the flow, 0 otherwise. +fn flow_copy_rank( + view: &VensimView, + var: &VensimVariable, + flow_to_valve: &HashMap, + flow_name: &str, + symbols: &HashMap>, +) -> u8 { + use crate::mdl::builtins::to_lower_space; - if xpt[0] == xpt[1] { - // Vertical flow - snap Y coordinates to anchors - ypt[0] = yanchor[0]; - ypt[1] = yanchor[1]; - } else { - // Horizontal flow - snap X coordinates to anchors - xpt[0] = xanchor[0]; - xpt[1] = xanchor[1]; + if flow_valve(var, view, flow_to_valve).is_some() { + return 2; } + let links_its_stock = view.iter().any(|elem| match elem { + VensimElement::Connector(conn) if conn.from_uid == var.uid => match view.get(conn.to_uid) { + Some(VensimElement::Variable(target)) => symbols + .get(&to_lower_space(&target.name)) + .is_some_and(|info| { + info.inflows.iter().any(|f| f == flow_name) + || info.outflows.iter().any(|f| f == flow_name) + }), + _ => false, + }, + _ => false, + }); + u8::from(links_its_stock) +} + +/// How the third pass of [`associate_variables`] compares the copies of a +/// flow, best first: the higher `flow_copy_rank`; then the fewer sides whose +/// model-linked stock the copy's pipe does not reach; then the least total +/// distance from the copy's valve (a bare label's own position) to the +/// primary records of those unreached stocks. An unreached side is routed from +/// the valve to its stock after the views merge (`routes`), so the nearest +/// copy is the one with the shortest route. `thyroid-2008-d.mdl` draws +/// `T3 absorption` with a valve twice, each copy's pipe reaching one of the +/// two stocks the model links: rank alone ties them. +#[cfg_attr(feature = "debug-derive", derive(Debug))] +#[derive(Clone, Copy)] +struct CopyScore { + rank: u8, + unreached: usize, + distance: f64, +} - // Determine indices for from/to - // xmutil outputs [1-toind] first (from), then [toind] (to) - let from_idx = 1 - to_idx; - - FlowEndpoints { - from_x: xpt[from_idx], - from_y: ypt[from_idx], - from_uid: Some(uid_offset + endpoints[from_idx].uid), - to_x: xpt[to_idx], - to_y: ypt[to_idx], - to_uid: Some(uid_offset + endpoints[to_idx].uid), +impl CopyScore { + fn better_than(&self, other: &CopyScore) -> bool { + if self.rank != other.rank { + return self.rank > other.rank; + } + if self.unreached != other.unreached { + return self.unreached < other.unreached; + } + self.distance < other.distance - 1e-9 } } -/// Determine if a comment element is used as a cloud (flow endpoint). -/// -/// Returns the flow_uid if this comment is a flow endpoint, None otherwise. -pub fn is_cloud_endpoint( - comment_uid: i32, +fn copy_score( + views: &[VensimView], view: &VensimView, - valve_to_flow: &HashMap, -) -> Option { - // Look for connectors that connect to this comment - for elem in view.iter() { - if let VensimElement::Connector(conn) = elem - && conn.to_uid == comment_uid - { - // Check if the source is a valve - if let Some(VensimElement::Valve(v)) = view.get(conn.from_uid) - && v.attached - { - // Prefer robust valve->flow mapping and keep legacy +1 as fallback. - if let Some(flow_uid) = - resolve_flow_uid_for_valve(conn.from_uid, view, valve_to_flow) - { - return Some(flow_uid); - } + var: &VensimVariable, + flow_to_valve: &HashMap, + flow_name: &str, + symbols: &HashMap>, + primary_map: &PrimaryMap, +) -> CopyScore { + let rank = flow_copy_rank(view, var, flow_to_valve, flow_name, symbols); + let valve = flow_valve(var, view, flow_to_valve); + let at = valve.map_or((var.x, var.y), |v| (v.x, v.y)); + let ends = resolve_flow_ends(valve, view, flow_name, symbols); + let mut unreached = 0; + let mut distance = 0.0; + for end in [&ends.source, &ends.sink] { + let FlowEnd::Stock(name) = end else { + continue; + }; + unreached += 1; + // Coordinates are comparable across views: `compose_views` has already + // laid every view out in the merged diagram's space. + let stock_at = primary_map.get(name).and_then(|&(view_idx, uid)| { + match views.get(view_idx)?.get(uid) { + Some(VensimElement::Variable(v)) => Some((v.x, v.y)), + _ => None, } + }); + if let Some((x, y)) = stock_at { + distance += f64::from(x - at.0).hypot(f64::from(y - at.1)); } } - None + CopyScore { + rank, + unreached, + distance, + } } /// Track which view contains the primary definition of each variable. @@ -557,11 +704,25 @@ pub type EffectiveGhosts = std::collections::HashSet<(usize, i32)>; /// - A set of (view_index, uid) pairs that are "effective ghosts" (duplicates even if /// not marked as ghost in the MDL file) /// -/// This implements xmutil's two-pass algorithm: +/// The first two passes are xmutil's algorithm: /// 1. First pass: Find primaries, mark duplicates as effective ghosts /// 2. Second pass: Promote first occurrence to primary if variable has no primary -pub fn associate_variables(views: &[VensimView]) -> (PrimaryMap, EffectiveGhosts) { +/// +/// The third pass decides which copy of a FLOW presents it. The datamodel has +/// one Flow element per flow and it must carry the flow's pipe, but the +/// sketch's primary bit does not follow the pipe: a flow's primary record can +/// be a bare label while its valve and pipe are drawn on a ghost copy in +/// another view (`free 6.mdl`, `C-LEARN v77 for Vensim.mdl`). The best-ranked +/// copy (`CopyScore`: rank, then the pipe reaching more of the stocks the model +/// links, then the valve nearest the stocks it does not reach) presents the +/// flow, the displaced primary becomes an effective ghost, and an exact tie +/// keeps the xmutil primary. +pub fn associate_variables( + views: &[VensimView], + symbols: &HashMap>, +) -> (PrimaryMap, EffectiveGhosts) { use crate::mdl::builtins::to_lower_space; + use crate::mdl::convert::VariableType; let mut primary_map = HashMap::new(); let mut effective_ghosts: EffectiveGhosts = std::collections::HashSet::new(); @@ -607,6 +768,56 @@ pub fn associate_variables(views: &[VensimView]) -> (PrimaryMap, EffectiveGhosts } } + // Third pass: the copy of each flow that presents it. + let mut scores: HashMap<(usize, i32), CopyScore> = HashMap::new(); + let mut best: HashMap = HashMap::new(); + for (view_idx, view) in views.iter().enumerate() { + let (_, flow_to_valve) = build_attached_valve_flow_maps(view); + for (uid, elem) in view.iter_with_uids() { + let VensimElement::Variable(var) = elem else { + continue; + }; + let canonical = to_lower_space(&var.name); + if symbols + .get(&canonical) + .is_none_or(|info| info.var_type != VariableType::Flow) + { + continue; + } + let score = copy_score( + views, + view, + var, + &flow_to_valve, + &canonical, + symbols, + &primary_map, + ); + scores.insert((view_idx, uid), score); + if best + .get(&canonical) + .is_none_or(|(b, _, _)| score.better_than(b)) + { + best.insert(canonical, (score, view_idx, uid)); + } + } + } + for (canonical, (score, view_idx, uid)) in best { + let Some(&primary) = primary_map.get(&canonical) else { + continue; + }; + if primary == (view_idx, uid) + || scores + .get(&primary) + .is_some_and(|primary_score| !score.better_than(primary_score)) + { + continue; + } + effective_ghosts.insert(primary); + effective_ghosts.remove(&(view_idx, uid)); + primary_map.insert(canonical, (view_idx, uid)); + } + (primary_map, effective_ghosts) } @@ -749,7 +960,7 @@ mod tests { }), ); - let (primary_map, effective_ghosts) = associate_variables(&[view]); + let (primary_map, effective_ghosts) = associate_variables(&[view], &HashMap::new()); // to_lower_space canonicalizes to "test var" (underscores to spaces, lowercase) assert_eq!(primary_map.get("test var"), Some(&(0, 1))); @@ -774,720 +985,441 @@ mod tests { } } - #[test] - fn test_compute_flow_points_no_connectors() { - use super::super::types::{VensimValve, VensimVariable, ViewHeader, ViewVersion}; - - let header = ViewHeader { - version: ViewVersion::V300, - title: "Test".to_string(), - font: None, - }; - let mut view = VensimView::new(header); - - // Valve at uid 1 - view.insert( - 1, - VensimElement::Valve(VensimValve { - uid: 1, - name: "444".to_string(), - x: 100, - y: 100, - width: 6, - height: 8, - attached: true, - bits: 3, - shape: 0, - tail: String::new(), - }), - ); - - // Flow at uid 2 - view.insert( - 2, - VensimElement::Variable(VensimVariable { - uid: 2, - name: "Flow Rate".to_string(), - x: 100, - y: 120, - width: 40, - height: 20, - attached: true, - is_ghost: false, - bits: 3, - shape: 0, - tail: String::new(), - }), - ); - - let symbols = std::collections::HashMap::new(); - let endpoints = compute_flow_points(1, 100, 100, &view, "flow rate", &symbols, 0); - - // Fallback: no connectors found, so use default points - assert_eq!(endpoints.from_x, 100 - 150); - assert_eq!(endpoints.from_y, 100); - assert!(endpoints.from_uid.is_none()); - assert_eq!(endpoints.to_x, 100 + 25); - assert_eq!(endpoints.to_y, 100); - assert!(endpoints.to_uid.is_none()); - } - - #[test] - fn test_compute_flow_points_single_endpoint_uses_defaults() { - // xmutil requires 2 endpoints with valid to_index to use collected endpoints. - // With only 1 endpoint, it falls back to defaults (XMILEGenerator.cpp:1047-1051) - use super::super::types::{ - VensimConnector, VensimValve, VensimVariable, ViewHeader, ViewVersion, - }; - use crate::mdl::convert::VariableType; - - let header = ViewHeader { - version: ViewVersion::V300, - title: "Test".to_string(), - font: None, + // Sketch fixtures for `resolve_flow_ends` and the flow copy ranking. They + // build `VensimView`s directly because the functions under test take the + // parsed sketch; what the importer produces end to end is pinned through + // `open_vensim` on corpus files (`convert::flow_resolution_tests`). + mod flow_ends { + use super::super::super::types::{ + VensimComment, VensimConnector, VensimValve, VensimVariable, ViewHeader, ViewVersion, }; - let mut view = VensimView::new(header); - - // Stock at uid 1 - view.insert( - 1, + use super::*; + use crate::mdl::convert::{SymbolInfo, VariableType}; + + fn variable( + uid: i32, + name: &str, + x: i32, + y: i32, + attached: bool, + ghost: bool, + ) -> VensimElement { VensimElement::Variable(VensimVariable { - uid: 1, - name: "Stock A".to_string(), - x: 200, - y: 100, + uid, + name: name.to_string(), + x, + y, width: 40, height: 20, - attached: false, - is_ghost: false, - bits: 3, - shape: 0, + attached, + is_ghost: ghost, + bits: if ghost { 2 } else { 3 }, + shape: if attached { 32 } else { 0 }, tail: String::new(), - }), - ); + }) + } - // Valve at uid 2 - view.insert( - 2, + fn valve(uid: i32, x: i32, y: i32) -> VensimElement { VensimElement::Valve(VensimValve { - uid: 2, + uid, name: "444".to_string(), - x: 100, - y: 100, + x, + y, width: 6, height: 8, attached: true, bits: 3, - shape: 0, + shape: 34, tail: String::new(), - }), - ); + }) + } - // Flow at uid 3 - view.insert( - 3, - VensimElement::Variable(VensimVariable { - uid: 3, - name: "Flow Rate".to_string(), - x: 100, - y: 120, - width: 40, - height: 20, - attached: true, - is_ghost: false, + fn cloud(uid: i32, x: i32, y: i32) -> VensimElement { + VensimElement::Comment(VensimComment { + uid, + text: "48".to_string(), + x, + y, + width: 10, + height: 8, + scratch_name: false, bits: 3, shape: 0, tail: String::new(), - }), - ); + }) + } - // Connector from valve (2) to stock (1) - view.insert( - 4, + fn connector(uid: i32, from: i32, to: i32, control: (i32, i32)) -> VensimElement { VensimElement::Connector(VensimConnector { - uid: 4, - from_uid: 2, - to_uid: 1, + uid, + from_uid: from, + to_uid: to, + field4: 0, polarity: None, letter_polarity: false, - control_point: (150, 100), - field4: 0, + control_point: control, field10: 0, - }), - ); + }) + } - // Stock A has "flow rate" as an inflow - let mut symbols = std::collections::HashMap::new(); - symbols.insert( - "stock a".to_string(), - make_symbol_info(VariableType::Stock, vec!["flow rate".to_string()], vec![]), - ); + fn view_of(elements: Vec) -> VensimView { + let mut view = VensimView::new(ViewHeader { + version: ViewVersion::V300, + title: "t".to_string(), + font: None, + }); + for e in elements { + view.insert(e.uid(), e); + } + view + } - let endpoints = compute_flow_points(2, 100, 100, &view, "flow rate", &symbols, 0); + fn stock(inflows: &[&str], outflows: &[&str]) -> SymbolInfo<'static> { + make_symbol_info( + VariableType::Stock, + inflows.iter().map(|s| s.to_string()).collect(), + outflows.iter().map(|s| s.to_string()).collect(), + ) + } - // Single endpoint: xmutil uses defaults for BOTH endpoints when count < 2 - assert_eq!(endpoints.from_x, 100 - 150); // flow_x - 150 - assert_eq!(endpoints.from_y, 100); // flow_y - assert!(endpoints.from_uid.is_none()); - assert_eq!(endpoints.to_x, 100 + 25); // flow_x + 25 - assert_eq!(endpoints.to_y, 100); // flow_y - assert!(endpoints.to_uid.is_none()); - } + fn symbols( + entries: Vec<(&str, SymbolInfo<'static>)>, + ) -> HashMap> { + entries + .into_iter() + .map(|(n, s)| (n.to_string(), s)) + .collect() + } - // Note: test_compute_flow_points_single_outflow_endpoint removed - xmutil behavior - // requires 2 endpoints; single endpoint case covered by test_compute_flow_points_single_endpoint_uses_defaults + fn ends_of(view: &VensimView, symbols: &HashMap>) -> FlowEnds { + let (_, flow_to_valve) = build_attached_valve_flow_maps(view); + let VensimElement::Variable(flow) = view.get(4).unwrap() else { + unreachable!() + }; + resolve_flow_ends( + flow_valve(flow, view, &flow_to_valve), + view, + "flow rate", + symbols, + ) + } - #[test] - fn test_compute_flow_points_two_endpoints_horizontal() { - // Test horizontal flow with anchor snapping - // xmutil: if xpt[0] != xpt[1] (different x), snap x coords to anchors - use super::super::types::{ - VensimConnector, VensimValve, VensimVariable, ViewHeader, ViewVersion, - }; - use crate::mdl::convert::VariableType; + /// A pipe between two stocks that list the flow: each end attaches to + /// its stock, with xmutil's anchor snapping on both axes. + #[test] + fn pipe_ends_at_linked_stocks_attach_to_them() { + let symbols = symbols(vec![ + ("stock a", stock(&[], &["flow rate"])), + ("stock b", stock(&["flow rate"], &[])), + ]); + // Horizontal: control points differ in x, so x takes the anchors. + let view = view_of(vec![ + variable(1, "Stock A", 50, 100, false, false), + variable(2, "Stock B", 250, 100, false, false), + valve(3, 150, 100), + variable(4, "Flow Rate", 150, 120, true, false), + connector(5, 3, 1, (100, 100)), + connector(6, 3, 2, (200, 100)), + ]); + let ends = ends_of(&view, &symbols); + assert_eq!( + ends.source, + FlowEnd::Pipe { + x: 50, + y: 100, + target: PipeTarget::Stock("stock a".to_string()) + } + ); + assert_eq!( + ends.sink, + FlowEnd::Pipe { + x: 250, + y: 100, + target: PipeTarget::Stock("stock b".to_string()) + } + ); + + // Vertical: control points share an x, so y takes the anchors. + let view = view_of(vec![ + variable(1, "Stock A", 100, 50, false, false), + variable(2, "Stock B", 100, 250, false, false), + valve(3, 100, 150), + variable(4, "Flow Rate", 120, 150, true, false), + connector(5, 3, 1, (100, 100)), + connector(6, 3, 2, (100, 200)), + ]); + let ends = ends_of(&view, &symbols); + assert_eq!( + ends.source, + FlowEnd::Pipe { + x: 100, + y: 50, + target: PipeTarget::Stock("stock a".to_string()) + } + ); + assert_eq!( + ends.sink, + FlowEnd::Pipe { + x: 100, + y: 250, + target: PipeTarget::Stock("stock b".to_string()) + } + ); + } - let header = ViewHeader { - version: ViewVersion::V300, - title: "Test".to_string(), - font: None, - }; - let mut view = VensimView::new(header); + /// A pipe with one end: its axis is read off the valve, so a control + /// point sharing the valve's x makes it vertical and the end takes its + /// stock's y (the other side has no stock and no pipe end, so it is + /// free). + #[test] + fn a_single_pipe_end_drawn_vertical_takes_its_stocks_y() { + let symbols = symbols(vec![("stock b", stock(&["flow rate"], &[]))]); + let view = view_of(vec![ + variable(2, "Stock B", 100, 250, false, false), + valve(3, 100, 150), + variable(4, "Flow Rate", 120, 150, true, false), + connector(5, 3, 2, (100, 200)), + ]); + let ends = ends_of(&view, &symbols); + assert_eq!( + ends.sink, + FlowEnd::Pipe { + x: 100, + y: 250, + target: PipeTarget::Stock("stock b".to_string()) + } + ); + assert_eq!(ends.source, FlowEnd::Free); + } - // Stock A (source) at uid 1, x=50 - view.insert( - 1, - VensimElement::Variable(VensimVariable { - uid: 1, - name: "Stock A".to_string(), - x: 50, - y: 100, - width: 40, - height: 20, - attached: false, - is_ghost: false, - bits: 3, - shape: 0, - tail: String::new(), - }), - ); + /// A cloud serves the side the model gives no stock; a pipe end at a + /// stock that does not list the flow does too, as a cloud clear of that + /// stock. + #[test] + fn clouds_and_unlinked_stocks_serve_the_open_side() { + let view = view_of(vec![ + cloud(1, 50, 100), + variable(2, "Stock B", 250, 100, false, false), + valve(3, 150, 100), + variable(4, "Flow Rate", 150, 120, true, false), + connector(5, 3, 1, (100, 100)), + connector(6, 3, 2, (200, 100)), + ]); + let linked = symbols(vec![("stock b", stock(&["flow rate"], &[]))]); + let ends = ends_of(&view, &linked); + assert_eq!( + ends.source, + FlowEnd::Pipe { + x: 50, + y: 100, + target: PipeTarget::Cloud(1) + } + ); + assert_eq!( + ends.sink, + FlowEnd::Pipe { + x: 250, + y: 100, + target: PipeTarget::Stock("stock b".to_string()) + } + ); + + let view = view_of(vec![ + variable(1, "Stock A", 50, 100, false, false), + variable(2, "Stock B", 250, 100, false, false), + valve(3, 150, 100), + variable(4, "Flow Rate", 150, 120, true, false), + connector(5, 3, 1, (100, 100)), + connector(6, 3, 2, (200, 100)), + ]); + let unlinked_sink = symbols(vec![ + ("stock a", stock(&[], &["flow rate"])), + ("stock b", stock(&["stock b net flow"], &[])), + ]); + let ends = ends_of(&view, &unlinked_sink); + assert_eq!( + ends.source, + FlowEnd::Pipe { + x: 50, + y: 100, + target: PipeTarget::Stock("stock a".to_string()) + } + ); + assert_eq!( + ends.sink, + FlowEnd::PipeAtUnlinkedStock { + x: 250, + y: 100, + stock: (250, 100) + } + ); + } - // Stock B (destination) at uid 2, x=250 - view.insert( - 2, - VensimElement::Variable(VensimVariable { - uid: 2, - name: "Stock B".to_string(), - x: 250, - y: 100, - width: 40, - height: 20, - attached: false, - is_ghost: false, - bits: 3, - shape: 0, - tail: String::new(), - }), - ); + /// A flow drawn as a bare label has no pipe: the linked side names its + /// stock and the other side is free. A connector from a valve to a + /// variable that is not a stock is a causal link, not a pipe end. + #[test] + fn a_side_without_a_pipe_end_names_its_stock_or_is_free() { + let symbols = symbols(vec![ + ("stock a", stock(&["flow rate"], &[])), + ( + "helper", + make_symbol_info(VariableType::Aux, vec![], vec![]), + ), + ]); + let label_only = view_of(vec![ + variable(1, "Stock A", 250, 100, false, false), + variable(4, "Flow Rate", 150, 120, false, false), + connector(5, 4, 1, (0, 0)), + ]); + let ends = ends_of(&label_only, &symbols); + assert_eq!(ends.source, FlowEnd::Free); + assert_eq!(ends.sink, FlowEnd::Stock("stock a".to_string())); + + let causal_link_only = view_of(vec![ + variable(1, "Stock A", 250, 100, false, false), + variable(2, "Helper", 150, 60, false, false), + valve(3, 150, 100), + variable(4, "Flow Rate", 150, 120, true, false), + connector(5, 3, 2, (0, 0)), + ]); + let ends = ends_of(&causal_link_only, &symbols); + assert_eq!(ends.source, FlowEnd::Free); + assert_eq!(ends.sink, FlowEnd::Stock("stock a".to_string())); + } - // Valve at uid 3, x=150 (between the stocks) - view.insert( - 3, - VensimElement::Valve(VensimValve { - uid: 3, - name: "444".to_string(), - x: 150, - y: 100, - width: 6, - height: 8, - attached: true, - bits: 3, - shape: 0, - tail: String::new(), - }), - ); - - // Flow at uid 4 - view.insert( - 4, - VensimElement::Variable(VensimVariable { - uid: 4, - name: "Flow Rate".to_string(), - x: 150, - y: 120, - width: 40, - height: 20, - attached: true, - is_ghost: false, - bits: 3, - shape: 0, - tail: String::new(), - }), - ); - - // Connector from valve to Stock A - control point between valve and stock - view.insert( - 5, - VensimElement::Connector(VensimConnector { - uid: 5, - from_uid: 3, - to_uid: 1, - polarity: None, - letter_polarity: false, - control_point: (100, 100), // x=100, different from connector to Stock B - field4: 0, - field10: 0, - }), - ); - - // Connector from valve to Stock B - control point between valve and stock - view.insert( - 6, - VensimElement::Connector(VensimConnector { - uid: 6, - from_uid: 3, - to_uid: 2, - polarity: None, - letter_polarity: false, - control_point: (200, 100), // x=200, different from connector to Stock A - field4: 0, - field10: 0, - }), - ); - - // Stock A has "flow rate" as outflow, Stock B has it as inflow - let mut symbols = std::collections::HashMap::new(); - symbols.insert( - "stock a".to_string(), - make_symbol_info(VariableType::Stock, vec![], vec!["flow rate".to_string()]), - ); - symbols.insert( - "stock b".to_string(), - make_symbol_info(VariableType::Stock, vec!["flow rate".to_string()], vec![]), - ); - - let endpoints = compute_flow_points(3, 150, 100, &view, "flow rate", &symbols, 0); - - // xpt[0]=100, xpt[1]=200 → different x → horizontal → snap x to anchors - // xanchor[0]=50 (Stock A), xanchor[1]=250 (Stock B) - // Result: x snapped to anchors, y stays from control points - // Stock A is from (outflow), Stock B is to (inflow) - assert_eq!(endpoints.from_x, 50); // snapped to Stock A's x - assert_eq!(endpoints.from_y, 100); // control point y - assert_eq!(endpoints.from_uid, Some(1)); // Stock A's uid - assert_eq!(endpoints.to_x, 250); // snapped to Stock B's x - assert_eq!(endpoints.to_y, 100); // control point y - assert_eq!(endpoints.to_uid, Some(2)); // Stock B's uid - } - - #[test] - fn test_compute_flow_points_two_endpoints_vertical() { - // Test vertical flow with anchor snapping - // xmutil: if xpt[0] == xpt[1] (same x), snap y coords to anchors - use super::super::types::{ - VensimConnector, VensimValve, VensimVariable, ViewHeader, ViewVersion, - }; - use crate::mdl::convert::VariableType; - - let header = ViewHeader { - version: ViewVersion::V300, - title: "Test".to_string(), - font: None, - }; - let mut view = VensimView::new(header); - - // Stock A (source) at uid 1, y=50 - view.insert( - 1, - VensimElement::Variable(VensimVariable { - uid: 1, - name: "Stock A".to_string(), - x: 100, - y: 50, - width: 40, - height: 20, - attached: false, - is_ghost: false, - bits: 3, - shape: 0, - tail: String::new(), - }), - ); - - // Stock B (destination) at uid 2, y=250 - view.insert( - 2, - VensimElement::Variable(VensimVariable { - uid: 2, - name: "Stock B".to_string(), - x: 100, - y: 250, - width: 40, - height: 20, - attached: false, - is_ghost: false, - bits: 3, - shape: 0, - tail: String::new(), - }), - ); - - // Valve at uid 3, y=150 (between the stocks) - view.insert( - 3, - VensimElement::Valve(VensimValve { - uid: 3, - name: "444".to_string(), - x: 100, - y: 150, - width: 6, - height: 8, - attached: true, - bits: 3, - shape: 0, - tail: String::new(), - }), - ); - - // Flow at uid 4 - view.insert( - 4, - VensimElement::Variable(VensimVariable { - uid: 4, - name: "Flow Rate".to_string(), - x: 120, - y: 150, - width: 40, - height: 20, - attached: true, - is_ghost: false, - bits: 3, - shape: 0, - tail: String::new(), - }), - ); - - // Connector from valve to Stock A - same x as other connector (vertical) - view.insert( - 5, - VensimElement::Connector(VensimConnector { - uid: 5, - from_uid: 3, - to_uid: 1, - polarity: None, - letter_polarity: false, - control_point: (100, 100), // same x=100 as connector to Stock B - field4: 0, - field10: 0, - }), - ); - - // Connector from valve to Stock B - same x as other connector (vertical) - view.insert( - 6, - VensimElement::Connector(VensimConnector { - uid: 6, - from_uid: 3, - to_uid: 2, - polarity: None, - letter_polarity: false, - control_point: (100, 200), // same x=100 as connector to Stock A - field4: 0, - field10: 0, - }), - ); - - // Stock A has "flow rate" as outflow, Stock B has it as inflow - let mut symbols = std::collections::HashMap::new(); - symbols.insert( - "stock a".to_string(), - make_symbol_info(VariableType::Stock, vec![], vec!["flow rate".to_string()]), - ); - symbols.insert( - "stock b".to_string(), - make_symbol_info(VariableType::Stock, vec!["flow rate".to_string()], vec![]), - ); - - let endpoints = compute_flow_points(3, 100, 150, &view, "flow rate", &symbols, 0); - - // xpt[0]=100, xpt[1]=100 → same x → vertical → snap y to anchors - // yanchor[0]=50 (Stock A), yanchor[1]=250 (Stock B) - // Result: x stays from control points, y snapped to anchors - // Stock A is from (outflow), Stock B is to (inflow) - assert_eq!(endpoints.from_x, 100); // control point x - assert_eq!(endpoints.from_y, 50); // snapped to Stock A's y - assert_eq!(endpoints.from_uid, Some(1)); // Stock A's uid - assert_eq!(endpoints.to_x, 100); // control point x - assert_eq!(endpoints.to_y, 250); // snapped to Stock B's y - assert_eq!(endpoints.to_uid, Some(2)); // Stock B's uid - } - - #[test] - fn test_compute_flow_points_with_cloud() { - // Test flow between cloud and stock with anchor snapping - use super::super::types::{ - VensimComment, VensimConnector, VensimValve, VensimVariable, ViewHeader, ViewVersion, - }; - use crate::mdl::convert::VariableType; - - let header = ViewHeader { - version: ViewVersion::V300, - title: "Test".to_string(), - font: None, - }; - let mut view = VensimView::new(header); - - // Cloud (comment) at uid 1, x=50 - view.insert( - 1, - VensimElement::Comment(VensimComment { - uid: 1, - text: "".to_string(), - x: 50, - y: 100, - width: 15, - height: 15, - scratch_name: false, - bits: 0, - shape: 0, - tail: String::new(), - }), - ); - - // Stock B at uid 2, x=250 - view.insert( - 2, - VensimElement::Variable(VensimVariable { - uid: 2, - name: "Stock B".to_string(), - x: 250, - y: 100, - width: 40, - height: 20, - attached: false, - is_ghost: false, - bits: 3, - shape: 0, - tail: String::new(), - }), - ); - - // Valve at uid 3, x=150 (between cloud and stock) - view.insert( - 3, - VensimElement::Valve(VensimValve { - uid: 3, - name: "444".to_string(), - x: 150, - y: 100, - width: 6, - height: 8, - attached: true, - bits: 3, - shape: 0, - tail: String::new(), - }), - ); - - // Flow at uid 4 - view.insert( - 4, - VensimElement::Variable(VensimVariable { - uid: 4, - name: "Flow Rate".to_string(), - x: 150, - y: 120, - width: 40, - height: 20, - attached: true, - is_ghost: false, - bits: 3, - shape: 0, - tail: String::new(), - }), - ); - - // Connector from valve to cloud - control point between valve and cloud - view.insert( - 5, - VensimElement::Connector(VensimConnector { - uid: 5, - from_uid: 3, - to_uid: 1, - polarity: None, - letter_polarity: false, - control_point: (100, 100), // x=100, different from other connector - field4: 0, - field10: 0, - }), - ); - - // Connector from valve to Stock B - control point between valve and stock - view.insert( - 6, - VensimElement::Connector(VensimConnector { - uid: 6, - from_uid: 3, - to_uid: 2, - polarity: None, - letter_polarity: false, - control_point: (200, 100), // x=200, different from other connector - field4: 0, - field10: 0, - }), - ); - - // Stock B has "flow rate" as inflow - let mut symbols = std::collections::HashMap::new(); - symbols.insert( - "stock b".to_string(), - make_symbol_info(VariableType::Stock, vec!["flow rate".to_string()], vec![]), - ); - - let endpoints = compute_flow_points(3, 150, 100, &view, "flow rate", &symbols, 0); - - // xpt[0]=100, xpt[1]=200 → different x → horizontal → snap x to anchors - // Cloud at uid 1 should be from (since Stock B is to), Stock B is to - assert_eq!(endpoints.from_x, 50); // snapped to cloud's x - assert_eq!(endpoints.from_y, 100); // control point y - assert_eq!(endpoints.from_uid, Some(1)); // Cloud's uid - assert_eq!(endpoints.to_x, 250); // snapped to Stock B's x - assert_eq!(endpoints.to_y, 100); // control point y - assert_eq!(endpoints.to_uid, Some(2)); // Stock B's uid - } - - #[test] - fn test_compute_flow_points_with_uid_offset() { - // Test that UID offset is properly applied to returned UIDs - use super::super::types::{ - VensimConnector, VensimValve, VensimVariable, ViewHeader, ViewVersion, - }; - use crate::mdl::convert::VariableType; - - let header = ViewHeader { - version: ViewVersion::V300, - title: "Test".to_string(), - font: None, - }; - let mut view = VensimView::new(header); - - // Stock A (source) at uid 1, x=50 - view.insert( - 1, - VensimElement::Variable(VensimVariable { - uid: 1, - name: "Stock A".to_string(), - x: 50, - y: 100, - width: 40, - height: 20, - attached: false, - is_ghost: false, - bits: 3, - shape: 0, - tail: String::new(), - }), - ); - - // Stock B (destination) at uid 2, x=250 - view.insert( - 2, - VensimElement::Variable(VensimVariable { - uid: 2, - name: "Stock B".to_string(), - x: 250, - y: 100, - width: 40, - height: 20, - attached: false, - is_ghost: false, - bits: 3, - shape: 0, - tail: String::new(), - }), - ); - - // Valve at uid 3 - view.insert( - 3, - VensimElement::Valve(VensimValve { - uid: 3, - name: "444".to_string(), - x: 150, - y: 100, - width: 6, - height: 8, - attached: true, - bits: 3, - shape: 0, - tail: String::new(), - }), - ); - - // Flow at uid 4 - view.insert( - 4, - VensimElement::Variable(VensimVariable { - uid: 4, - name: "Flow Rate".to_string(), - x: 150, - y: 120, - width: 40, - height: 20, - attached: true, - is_ghost: false, - bits: 3, - shape: 0, - tail: String::new(), - }), - ); - - // Connector from valve (3) to Stock A (1) - view.insert( - 5, - VensimElement::Connector(VensimConnector { - uid: 5, - from_uid: 3, - to_uid: 1, - polarity: None, - letter_polarity: false, - control_point: (100, 100), - field4: 0, - field10: 0, - }), - ); - - // Connector from valve (3) to Stock B (2) - view.insert( - 6, - VensimElement::Connector(VensimConnector { - uid: 6, - from_uid: 3, - to_uid: 2, - polarity: None, - letter_polarity: false, - control_point: (200, 100), - field4: 0, - field10: 0, - }), - ); + /// One open side while the other side's stock is not a pipe end: the + /// open side takes the pipe end on the valve's far side from that + /// stock (whatever the sketch order), because the route to the stock + /// continues through the valve. + #[test] + fn the_open_side_takes_the_pipe_end_away_from_an_unreached_stock() { + let symbols = symbols(vec![ + ("stock b", stock(&["stock b net flow"], &[])), + ("stock c", stock(&["flow rate"], &[])), + ]); + for (cloud_x, stock_b_x) in [(200, 100), (100, 200)] { + let view = view_of(vec![ + cloud(1, cloud_x, 100), + variable(2, "Stock B", stock_b_x, 100, false, false), + valve(3, 150, 100), + variable(4, "Flow Rate", 150, 120, true, false), + connector(5, 3, 1, (cloud_x, 100)), + connector(6, 3, 2, (stock_b_x, 100)), + variable(7, "Stock C", 400, 100, false, false), + ]); + let ends = ends_of(&view, &symbols); + // Stock C is right of the valve, so the source is the pipe end + // on the left. + let expected = if cloud_x < 150 { + FlowEnd::Pipe { + x: 100, + y: 100, + target: PipeTarget::Cloud(1), + } + } else { + FlowEnd::PipeAtUnlinkedStock { + x: 100, + y: 100, + stock: (100, 100), + } + }; + assert_eq!(ends.source, expected, "cloud at x={cloud_x}"); + assert_eq!(ends.sink, FlowEnd::Stock("stock c".to_string())); + } + } - // Stock A has "flow rate" as outflow, Stock B has it as inflow - let mut symbols = std::collections::HashMap::new(); - symbols.insert( - "stock a".to_string(), - make_symbol_info(VariableType::Stock, vec![], vec!["flow rate".to_string()]), - ); - symbols.insert( - "stock b".to_string(), - make_symbol_info(VariableType::Stock, vec!["flow rate".to_string()], vec![]), - ); + /// The copy of a flow that carries its pipe presents the flow, even + /// when the sketch marks it a ghost and a label copy primary; with no + /// pipe anywhere, a label with an arrow into the flow's stock wins over + /// one without. + #[test] + fn the_pipe_carrying_copy_presents_a_flow() { + let symbols = symbols(vec![ + ("stock a", stock(&["flow rate"], &[])), + ( + "flow rate", + make_symbol_info(VariableType::Flow, vec![], vec![]), + ), + ]); + let view = view_of(vec![ + variable(1, "Flow Rate", 20, 20, false, false), + valve(2, 150, 100), + variable(3, "Flow Rate", 150, 120, true, true), + variable(4, "Stock A", 250, 100, false, false), + connector(5, 2, 4, (200, 100)), + ]); + let (primary, ghosts) = associate_variables(&[view], &symbols); + assert_eq!(primary.get("flow rate"), Some(&(0, 3))); + assert!(ghosts.contains(&(0, 1))); + assert!(!ghosts.contains(&(0, 3))); + + let view = view_of(vec![ + variable(1, "Flow Rate", 20, 20, false, false), + variable(3, "Flow Rate", 250, 60, false, true), + variable(4, "Stock A", 250, 100, false, false), + connector(5, 3, 4, (0, 0)), + ]); + let (primary, ghosts) = associate_variables(&[view], &symbols); + assert_eq!(primary.get("flow rate"), Some(&(0, 3))); + assert!(ghosts.contains(&(0, 1))); + } - // Use uid_offset of 100 - let endpoints = compute_flow_points(3, 150, 100, &view, "flow rate", &symbols, 100); + /// Of two copies that each carry a valve (a rank tie), the one whose + /// pipe reaches more of the stocks the model links presents the flow, + /// even where the other copy's valve is nearer the stocks it leaves + /// unreached: every unreached side is a route the import must add. + /// Copy 3 pipes into `stock a` and leaves `stock b` about 447 from its + /// valve; copy 7 reaches neither, but its valve sits 150 from each, + /// 300 in all. + #[test] + fn a_copy_reaching_more_of_its_stocks_presents_the_flow_over_a_nearer_one() { + let symbols = symbols(vec![ + ("stock a", stock(&[], &["flow rate"])), + ("stock b", stock(&["flow rate"], &[])), + ( + "flow rate", + make_symbol_info(VariableType::Flow, vec![], vec![]), + ), + ]); + let view = view_of(vec![ + valve(2, 0, 300), + variable(3, "Flow Rate", 0, 320, true, false), + valve(6, 250, 100), + variable(7, "Flow Rate", 250, 120, true, true), + variable(10, "Stock A", 100, 100, false, false), + variable(11, "Stock B", 400, 100, false, false), + connector(12, 2, 10, (50, 200)), + ]); + let (primary, ghosts) = associate_variables(&[view], &symbols); + assert_eq!(primary.get("flow rate"), Some(&(0, 3))); + assert!(!ghosts.contains(&(0, 3))); + } - // The returned UIDs should include the offset - assert_eq!(endpoints.from_uid, Some(101)); // Stock A's uid (1) + offset (100) - assert_eq!(endpoints.to_uid, Some(102)); // Stock B's uid (2) + offset (100) + /// Copies whose scores tie exactly leave the sketch's primary copy in + /// place, whichever the third pass meets first: copy 3, a ghost met + /// first, and copy 7, the primary, each carry a valve with no pipe, + /// 100 from the one stock the model links. + #[test] + fn of_copies_that_tie_exactly_the_sketchs_primary_presents_the_flow() { + let symbols = symbols(vec![ + ("stock a", stock(&[], &["flow rate"])), + ( + "flow rate", + make_symbol_info(VariableType::Flow, vec![], vec![]), + ), + ]); + let view = view_of(vec![ + valve(2, 100, 0), + variable(3, "Flow Rate", 100, 20, true, true), + valve(6, 100, 200), + variable(7, "Flow Rate", 100, 220, true, false), + variable(10, "Stock A", 100, 100, false, false), + ]); + let (primary, ghosts) = associate_variables(&[view], &symbols); + assert_eq!(primary.get("flow rate"), Some(&(0, 7))); + assert!(!ghosts.contains(&(0, 7))); + } } #[test] @@ -1540,7 +1472,7 @@ mod tests { }), ); - let (primary_map, effective_ghosts) = associate_variables(&[view]); + let (primary_map, effective_ghosts) = associate_variables(&[view], &HashMap::new()); // Both should be promoted to primaries since they have no non-ghost appearances assert!( diff --git a/src/simlin-engine/src/mdl/view/routes.rs b/src/simlin-engine/src/mdl/view/routes.rs new file mode 100644 index 000000000..da1ae5906 --- /dev/null +++ b/src/simlin-engine/src/mdl/view/routes.rs @@ -0,0 +1,420 @@ +// Copyright 2026 The Simlin Authors. All rights reserved. +// Use of this source code is governed by the Apache License, +// Version 2.0, that can be found in the LICENSE file. + +//! Placing the ends of imported flows that the sketch does not place. +//! +//! `processing::resolve_flow_ends` decides what each end of a flow attaches +//! to, from the model's stock lists. Where the sketch draws a pipe end, the end +//! is placed there during conversion. This module places the rest, over the +//! merged datamodel view where every stock's center is known, before +//! `diagram::flow_geometry::normalize_flow_geometry` brings the pipes onto the +//! stock faces: +//! +//! - a pipe end at a stock that does not list the flow becomes a cloud just +//! outside that stock, along the pipe; +//! - a side the model links to a stock the sketch draws no pipe into gets a +//! pipe from the valve to that stock, continuing through the valve, with a +//! bend when the stock is not ahead of it; +//! - a side with neither gets a cloud `CLOUD_DISTANCE` from the valve, on the +//! valve's other side from the flow's other end. +//! +//! The valve stays where the sketch drew it (for a flow drawn as a bare label, +//! the label's position) and the pipe runs through it. + +use std::collections::HashMap; + +use crate::datamodel::ViewElement; +use crate::datamodel::view_element::{Cloud, FlowPoint}; +use crate::diagram::constants::{CLOUD_RADIUS, STOCK_HEIGHT, STOCK_WIDTH}; + +/// How far from the valve a synthesized cloud sits: the pipe half-length the +/// engine's own layout gives a new cloud-ended flow +/// (`layout::create_flow_view_element`). +const CLOUD_DISTANCE: f64 = 50.0; + +/// The editor's valve margin: the valve is kept at least this far from the +/// ends of the segment it sits on. +const VALVE_MARGIN: f64 = 10.0; + +/// How far past the valve a route turns toward a stock that is not ahead of +/// the valve, so the valve keeps its margin from the bend. +const BEND_STEP: f64 = 2.0 * VALVE_MARGIN; + +/// One end of a flow whose route is placed after the views are merged. +#[cfg_attr(feature = "debug-derive", derive(Debug))] +pub(super) enum RouteEnd { + /// Placed by the sketch: the pipe end, attached to its stock or cloud. + Point(FlowPoint), + /// A cloud near the sketch's pipe end `end`, kept clear of the stock the + /// sketch drew at `stock`. + CloudNearStock { end: (f64, f64), stock: (f64, f64) }, + /// Attach to the stock element with this uid. + Stock(i32), + /// A cloud on the valve's far side from the other end. + Free, +} + +/// A flow whose ends are not all placed by the sketch. +#[cfg_attr(feature = "debug-derive", derive(Debug))] +pub(super) struct PendingFlowRoute { + pub(super) flow_uid: i32, + pub(super) source: RouteEnd, + pub(super) sink: RouteEnd, +} + +#[cfg_attr(feature = "debug-derive", derive(Debug))] +#[derive(Clone, Copy)] +enum Axis { + Horizontal, + Vertical, +} + +impl Axis { + /// The axis a pipe from `from` to `to` runs along. + fn dominant(from: (f64, f64), to: (f64, f64)) -> Axis { + if (to.0 - from.0).abs() >= (to.1 - from.1).abs() { + Axis::Horizontal + } else { + Axis::Vertical + } + } + + fn along(self, p: (f64, f64)) -> f64 { + match self { + Axis::Horizontal => p.0, + Axis::Vertical => p.1, + } + } + + fn cross(self, p: (f64, f64)) -> f64 { + match self { + Axis::Horizontal => p.1, + Axis::Vertical => p.0, + } + } + + fn point(self, along: f64, cross: f64) -> (f64, f64) { + match self { + Axis::Horizontal => (along, cross), + Axis::Vertical => (cross, along), + } + } + + fn half_along(self) -> f64 { + match self { + Axis::Horizontal => STOCK_WIDTH / 2.0, + Axis::Vertical => STOCK_HEIGHT / 2.0, + } + } +} + +/// The sign of `v`, with zero taken as positive so a degenerate route still +/// has a direction. +fn direction(v: f64) -> f64 { + if v < 0.0 { -1.0 } else { 1.0 } +} + +fn attached(p: (f64, f64), uid: i32) -> FlowPoint { + FlowPoint { + x: p.0, + y: p.1, + attached_to_uid: Some(uid), + } +} + +/// Place every pending flow's points, creating the clouds its routes end in. +pub(super) fn route_pending_flows(elements: &mut Vec, pending: &[PendingFlowRoute]) { + if pending.is_empty() { + return; + } + let stocks: HashMap = elements + .iter() + .filter_map(|e| match e { + ViewElement::Stock(s) => Some((s.uid, (s.x, s.y))), + _ => None, + }) + .collect(); + let mut next_uid = elements.iter().map(|e| e.get_uid()).max().unwrap_or(0) + 1; + let mut clouds: Vec = Vec::new(); + for route in pending { + let Some(ViewElement::Flow(flow)) = elements + .iter_mut() + .find(|e| matches!(e, ViewElement::Flow(f) if f.uid == route.flow_uid)) + else { + continue; + }; + let mut new_cloud = |p: (f64, f64)| -> FlowPoint { + let uid = next_uid; + next_uid += 1; + clouds.push(ViewElement::Cloud(Cloud { + uid, + flow_uid: route.flow_uid, + x: p.0, + y: p.1, + compat: None, + })); + attached(p, uid) + }; + let source = resolve_stock(&route.source, &stocks); + let sink = resolve_stock(&route.sink, &stocks); + flow.points = plan_route((flow.x, flow.y), source, sink, &mut new_cloud); + } + elements.extend(clouds); +} + +/// A route end with its stock's position resolved. A stock uid with no stock +/// element (not produced by the importer, but not assumed away) is an end with +/// no stock. +#[cfg_attr(feature = "debug-derive", derive(Debug))] +enum End<'a> { + Point(&'a FlowPoint), + CloudNearStock { end: (f64, f64), stock: (f64, f64) }, + Stock { uid: i32, at: (f64, f64) }, + Free, +} + +fn resolve_stock<'a>(end: &'a RouteEnd, stocks: &HashMap) -> End<'a> { + match end { + RouteEnd::Point(p) => End::Point(p), + RouteEnd::CloudNearStock { end, stock } => End::CloudNearStock { + end: *end, + stock: *stock, + }, + RouteEnd::Stock(uid) => match stocks.get(uid) { + Some(&at) => End::Stock { uid: *uid, at }, + None => End::Free, + }, + RouteEnd::Free => End::Free, + } +} + +/// The points of one flow, source first. +fn plan_route( + valve: (f64, f64), + source: End<'_>, + sink: End<'_>, + new_cloud: &mut dyn FnMut((f64, f64)) -> FlowPoint, +) -> Vec { + let sketch_end = |end: &End<'_>| -> Option<(f64, f64)> { + match end { + End::Point(p) => Some((p.x, p.y)), + End::CloudNearStock { end, .. } => Some(*end), + End::Stock { .. } | End::Free => None, + } + }; + match (sketch_end(&source), sketch_end(&sink)) { + (Some(_), Some(_)) => { + let first = place_sketch_end(&source, valve, new_cloud); + let last = place_sketch_end(&sink, valve, new_cloud); + vec![first, last] + } + (Some(_), None) => { + let first = place_sketch_end(&source, valve, new_cloud); + route_through_valve(first, valve, &sink, new_cloud) + } + (None, Some(_)) => { + let last = place_sketch_end(&sink, valve, new_cloud); + let mut points = route_through_valve(last, valve, &source, new_cloud); + points.reverse(); + points + } + (None, None) => route_without_sketch(valve, &source, &sink, new_cloud), + } +} + +/// A sketch-placed end: the pipe end itself, or a cloud pulled back along the +/// pipe from the unlinked stock the pipe was drawn into, to just outside the +/// 45x35 box (never past the valve's margin). +fn place_sketch_end( + end: &End<'_>, + valve: (f64, f64), + new_cloud: &mut dyn FnMut((f64, f64)) -> FlowPoint, +) -> FlowPoint { + match end { + End::Point(p) => (*p).clone(), + End::CloudNearStock { end, stock } => { + let axis = Axis::dominant(valve, *end); + let toward = direction(axis.along(*stock) - axis.along(valve)); + let mut along = axis.along(*stock) - toward * (axis.half_along() + CLOUD_RADIUS); + if toward * (along - axis.along(valve)) < VALVE_MARGIN { + along = axis.along(valve) + toward * VALVE_MARGIN; + } + new_cloud(axis.point(along, axis.cross(*end))) + } + End::Stock { .. } | End::Free => unreachable!("only sketch-placed ends are placed here"), + } +} + +/// `[first, ..., other end]`: the pipe runs from the placed end `first` +/// through the valve and on to the other end, which is a stock (straight on +/// when the stock is ahead of the valve, turning `BEND_STEP` past the valve +/// otherwise) or a cloud `CLOUD_DISTANCE` past the valve. +fn route_through_valve( + first: FlowPoint, + valve: (f64, f64), + other: &End<'_>, + new_cloud: &mut dyn FnMut((f64, f64)) -> FlowPoint, +) -> Vec { + let placed = (first.x, first.y); + let axis = Axis::dominant(placed, valve); + let ahead = direction(axis.along(valve) - axis.along(placed)); + // The pipe's line is the placed end's, so the pipe stays straight; the + // valve is on it wherever the sketch drew the pipe through the valve. + let line = axis.cross(placed); + match other { + End::Stock { uid, at } => { + if ahead * (axis.along(*at) - axis.along(valve)) >= VALVE_MARGIN { + vec![first, attached(axis.point(axis.along(*at), line), *uid)] + } else { + let bend_along = axis.along(valve) + ahead * BEND_STEP; + let bend = axis.point(bend_along, line); + vec![ + first, + FlowPoint { + x: bend.0, + y: bend.1, + attached_to_uid: None, + }, + attached(axis.point(bend_along, axis.cross(*at)), *uid), + ] + } + } + End::Free | End::Point(_) | End::CloudNearStock { .. } => { + let cloud = new_cloud(axis.point(axis.along(valve) + ahead * CLOUD_DISTANCE, line)); + vec![first, cloud] + } + } +} + +/// A flow with no sketch pipe end at all: the pipe runs through the valve +/// along the axis toward its stock (or between its two stocks), with a cloud +/// on each side that has no stock. +fn route_without_sketch( + valve: (f64, f64), + source: &End<'_>, + sink: &End<'_>, + new_cloud: &mut dyn FnMut((f64, f64)) -> FlowPoint, +) -> Vec { + match (source, sink) { + (End::Stock { uid: a, at: pa }, End::Stock { uid: b, at: pb }) => { + let axis = Axis::dominant(*pa, *pb); + let line = axis.cross(valve); + vec![ + attached(axis.point(axis.along(*pa), line), *a), + attached(axis.point(axis.along(*pb), line), *b), + ] + } + (End::Free, End::Stock { uid, at }) => { + let axis = Axis::dominant(valve, *at); + let toward = direction(axis.along(*at) - axis.along(valve)); + let line = axis.cross(valve); + let cloud = new_cloud(axis.point(axis.along(valve) - toward * CLOUD_DISTANCE, line)); + vec![cloud, attached(axis.point(axis.along(*at), line), *uid)] + } + (End::Stock { uid, at }, End::Free) => { + let axis = Axis::dominant(valve, *at); + let toward = direction(axis.along(*at) - axis.along(valve)); + let line = axis.cross(valve); + let stock_end = attached(axis.point(axis.along(*at), line), *uid); + let cloud = new_cloud(axis.point(axis.along(valve) - toward * CLOUD_DISTANCE, line)); + vec![stock_end, cloud] + } + _ => { + let source_cloud = new_cloud((valve.0 - CLOUD_DISTANCE, valve.1)); + let sink_cloud = new_cloud((valve.0 + CLOUD_DISTANCE, valve.1)); + vec![source_cloud, sink_cloud] + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn cloud_maker(next_uid: &mut i32) -> impl FnMut((f64, f64)) -> FlowPoint + '_ { + move |p| { + let uid = *next_uid; + *next_uid += 1; + attached(p, uid) + } + } + + fn coords(points: &[FlowPoint]) -> Vec<(f64, f64, Option)> { + points + .iter() + .map(|p| (p.x, p.y, p.attached_to_uid)) + .collect() + } + + /// A route from a placed end through the valve to a stock the model links: + /// straight on when the stock is ahead of the valve by at least the + /// margin, and otherwise turning `BEND_STEP` past the valve toward the + /// stock's line, so the valve keeps its margin from the bend. + #[test] + fn a_route_to_its_stock_turns_past_the_valve_when_the_stock_is_behind() { + let mut uid = 100; + let placed = attached((0.0, 100.0), 1); + + let ahead = End::Stock { + uid: 7, + at: (300.0, 180.0), + }; + let points = route_through_valve( + placed.clone(), + (50.0, 100.0), + &ahead, + &mut cloud_maker(&mut uid), + ); + assert_eq!( + coords(&points), + vec![(0.0, 100.0, Some(1)), (300.0, 100.0, Some(7))], + "stock ahead: straight on" + ); + + let behind = End::Stock { + uid: 7, + at: (40.0, 300.0), + }; + let points = + route_through_valve(placed, (50.0, 100.0), &behind, &mut cloud_maker(&mut uid)); + assert_eq!( + coords(&points), + vec![ + (0.0, 100.0, Some(1)), + (50.0 + BEND_STEP, 100.0, None), + (50.0 + BEND_STEP, 300.0, Some(7)) + ], + "stock behind the valve: a bend BEND_STEP past it" + ); + } + + /// A cloud for a pipe end drawn into a stock that does not list the flow + /// sits just outside that stock along the pipe, but never within the + /// valve's margin: rows for a stock far from the valve and one close to it. + #[test] + fn a_cloud_near_an_unlinked_stock_stays_off_the_valve() { + let mut uid = 100; + let far = End::CloudNearStock { + end: (300.0, 100.0), + stock: (300.0, 100.0), + }; + let cloud = place_sketch_end(&far, (100.0, 100.0), &mut cloud_maker(&mut uid)); + assert_eq!( + (cloud.x, cloud.y), + (300.0 - (STOCK_WIDTH / 2.0 + CLOUD_RADIUS), 100.0), + "just outside the stock, along the pipe" + ); + + let near = End::CloudNearStock { + end: (140.0, 100.0), + stock: (130.0, 100.0), + }; + let cloud = place_sketch_end(&near, (100.0, 100.0), &mut cloud_maker(&mut uid)); + assert_eq!( + (cloud.x, cloud.y), + (100.0 + VALVE_MARGIN, 100.0), + "a stock close to the valve: kept the margin off the valve" + ); + } +} diff --git a/src/simlin-engine/src/mdl/writer.rs b/src/simlin-engine/src/mdl/writer.rs index f5601a07f..85de8bbc7 100644 --- a/src/simlin-engine/src/mdl/writer.rs +++ b/src/simlin-engine/src/mdl/writer.rs @@ -1967,14 +1967,18 @@ fn write_stock_variable( ctx: &WriterContext, warnings: &mut Vec, ) { + // The sets the engine integrates (`datamodel::distinct_stock_flows`): a + // repeat written into the INTEG would make Vensim integrate the flow twice. + let inflows = datamodel::distinct_stock_flows(&stock.inflows).flows; + let outflows = datamodel::distinct_stock_flows(&stock.outflows).flows; let mut net_flow = String::new(); - for (i, inflow) in stock.inflows.iter().enumerate() { + for (i, inflow) in inflows.iter().enumerate() { if i > 0 { net_flow.push('+'); } net_flow.push_str(&format_mdl_ident(inflow)); } - for outflow in &stock.outflows { + for outflow in &outflows { net_flow.push('-'); net_flow.push_str(&format_mdl_ident(outflow)); } diff --git a/src/simlin-engine/src/patch.rs b/src/simlin-engine/src/patch.rs index b9dc15d52..3c3a3b8f1 100644 --- a/src/simlin-engine/src/patch.rs +++ b/src/simlin-engine/src/patch.rs @@ -174,14 +174,23 @@ fn canonicalize_ident(ident: &mut String) { // those fields. fn canonicalize_stock_references(stock: &mut datamodel::Stock) { - for inflow in stock.inflows.iter_mut() { - canonicalize_ident(inflow); - } - stock.inflows.sort_unstable(); - for outflow in stock.outflows.iter_mut() { - canonicalize_ident(outflow); - } - stock.outflows.sort_unstable(); + stock.inflows = canonical_flow_list(&stock.inflows); + stock.outflows = canonical_flow_list(&stock.outflows); +} + +/// The stored form of a stock's inflow or outflow list, for every op that sets +/// one: the set the engine integrates (`datamodel::distinct_stock_flows`, so +/// `"Flow A"` and `"flow_a"` are one member), as canonical idents, sorted. +/// Storing the set keeps a patch from writing a repeat the sync would then +/// have to warn about. +fn canonical_flow_list(flows: &[String]) -> Vec { + let mut list: Vec = datamodel::distinct_stock_flows(flows) + .flows + .iter() + .map(|flow| canonicalize(flow).into_owned()) + .collect(); + list.sort_unstable(); + list } fn canonicalize_module_references(module: &mut datamodel::Module) { @@ -329,16 +338,8 @@ fn apply_update_stock_flows( ) })?; - stock.inflows = inflows - .iter() - .map(|s| canonicalize(s).into_owned()) - .collect(); - stock.outflows = outflows - .iter() - .map(|s| canonicalize(s).into_owned()) - .collect(); - stock.inflows.sort_unstable(); - stock.outflows.sort_unstable(); + stock.inflows = canonical_flow_list(inflows); + stock.outflows = canonical_flow_list(outflows); Ok(()) } @@ -1959,6 +1960,105 @@ mod tests { assert_eq!(err.code, ErrorCode::DoesNotExist); } + /// Both ops that set a stock's flow lists store each flow once, judged + /// after canonicalization (`"Flow A"` and `"flow_a"` name one flow). The + /// compiler sums a stock's lists, so a stored duplicate would integrate the + /// flow twice. + #[test] + fn every_op_that_sets_stock_flow_lists_stores_each_flow_once() { + let base = TestProject::new("test") + .flow("flow_a", "1", None) + .flow("drain", "1", None) + .stock("population", "0", &[], &[], None) + .build_datamodel(); + + let update = ModelOperation::UpdateStockFlows { + ident: "population".to_string(), + inflows: vec![ + "Flow A".to_string(), + "flow_a".to_string(), + "flow_a".to_string(), + ], + outflows: vec!["drain".to_string(), "Drain".to_string()], + }; + let upsert = ModelOperation::UpsertStock(datamodel::Stock { + ident: "population".to_string(), + equation: Equation::Scalar("0".to_string()), + documentation: String::new(), + units: None, + inflows: vec!["flow_a".to_string(), "Flow_A".to_string()], + outflows: vec!["Drain".to_string(), "drain".to_string()], + ai_state: None, + uid: None, + compat: datamodel::Compat::default(), + }); + + for (label, op) in [("updateStockFlows", update), ("upsertStock", upsert)] { + let mut project = base.clone(); + let patch = ProjectPatch { + project_ops: vec![], + models: vec![ModelPatch { + name: "main".to_string(), + ops: vec![op], + }], + }; + apply_patch(&mut project, patch).unwrap(); + match project + .get_model("main") + .unwrap() + .get_variable("population") + .unwrap() + { + Variable::Stock(stock) => { + assert_eq!(stock.inflows, vec!["flow_a".to_string()], "{label}"); + assert_eq!(stock.outflows, vec!["drain".to_string()], "{label}"); + } + _ => panic!("{label}: expected stock"), + } + } + } + + /// Deduplication does not turn an upsert into a merge: a later upsert's + /// lists replace the earlier ones outright. + #[test] + fn upsert_stock_still_replaces_flow_lists_outright() { + let mut project = TestProject::new("test") + .flow("flow_a", "1", None) + .flow("flow_b", "1", None) + .stock("population", "0", &["flow_a", "flow_a"], &[], None) + .build_datamodel(); + let patch = ProjectPatch { + project_ops: vec![], + models: vec![ModelPatch { + name: "main".to_string(), + ops: vec![ModelOperation::UpsertStock(datamodel::Stock { + ident: "population".to_string(), + equation: Equation::Scalar("0".to_string()), + documentation: String::new(), + units: None, + inflows: vec!["flow_b".to_string(), "flow_b".to_string()], + outflows: vec![], + ai_state: None, + uid: None, + compat: datamodel::Compat::default(), + })], + }], + }; + apply_patch(&mut project, patch).unwrap(); + match project + .get_model("main") + .unwrap() + .get_variable("population") + .unwrap() + { + Variable::Stock(stock) => { + assert_eq!(stock.inflows, vec!["flow_b".to_string()]); + assert!(stock.outflows.is_empty()); + } + _ => panic!("expected stock"), + } + } + #[test] fn rename_updates_group_members() { let mut project = TestProject::new("test") diff --git a/src/simlin-engine/src/queue_compile.rs b/src/simlin-engine/src/queue_compile.rs index 2c4f193ba..aba996040 100644 --- a/src/simlin-engine/src/queue_compile.rs +++ b/src/simlin-engine/src/queue_compile.rs @@ -947,6 +947,13 @@ pub fn build_compiled( )> { use crate::common::{Error, ErrorKind}; + // Every stock's lists as the sets the engine integrates + // (`datamodel::distinct_stock_flows`), taken before anything below reads + // them: the expansions build their plans straight from these lists, and a + // repeat that reached them would drive a flow twice. + let project = project.with_distinct_stock_flows(); + let project = project.as_ref(); + // Duplicate canonical variable idents are rejected BEFORE any expansion // (GH #885): `expand_conveyors`/`expand_queues` walk the raw datamodel // variable list where both twins are still visible, and while each pass diff --git a/src/simlin-engine/src/variable.rs b/src/simlin-engine/src/variable.rs index 3340262c0..c164e2cba 100644 --- a/src/simlin-engine/src/variable.rs +++ b/src/simlin-engine/src/variable.rs @@ -1035,6 +1035,10 @@ impl VariableSource<'_> { } } +/// A borrowed view over a datamodel variable, for the test builders' parses. +/// It carries a stock's lists as written; production parses read the salsa +/// inputs (`db::variable_source`), which carry the sets +/// (`datamodel::distinct_stock_flows`). impl<'a> From<&'a datamodel::Variable> for VariableSource<'a> { fn from(v: &'a datamodel::Variable) -> Self { const NO_NAMES: &[String] = &[]; diff --git a/src/simlin-engine/src/xmile/mod.rs b/src/simlin-engine/src/xmile/mod.rs index e716c176e..4dd1e3340 100644 --- a/src/simlin-engine/src/xmile/mod.rs +++ b/src/simlin-engine/src/xmile/mod.rs @@ -33,9 +33,6 @@ pub(crate) trait ToXml { pub(crate) type XmlWriter = Cursor>; -pub(crate) const STOCK_WIDTH: f64 = 45.0; -pub(crate) const STOCK_HEIGHT: f64 = 35.0; - macro_rules! import_err( ($code:tt, $str:expr) => {{ use crate::common::{Error, ErrorCode, ErrorKind}; diff --git a/src/simlin-engine/src/xmile/model.rs b/src/simlin-engine/src/xmile/model.rs index be63a0d94..bc56cb6ba 100644 --- a/src/simlin-engine/src/xmile/model.rs +++ b/src/simlin-engine/src/xmile/model.rs @@ -511,7 +511,10 @@ impl From for datamodel::Model { .map(|v| { let mut v = v; v.normalize(&model); - datamodel::View::from(v) + let mut view = datamodel::View::from(v); + let datamodel::View::StockFlow(sf) = &mut view; + crate::diagram::flow_geometry::normalize_flow_geometry(&mut sf.elements); + view }) .collect(); let groups: Vec = model diff --git a/src/simlin-engine/src/xmile/views.rs b/src/simlin-engine/src/xmile/views.rs index ce38ece56..bab889b80 100644 --- a/src/simlin-engine/src/xmile/views.rs +++ b/src/simlin-engine/src/xmile/views.rs @@ -13,9 +13,7 @@ use crate::datamodel::{Rect, ViewElement}; use crate::xmile::model::Model; use crate::xmile::variables::Var; use crate::xmile::view_element::LinkEnd; -use crate::xmile::{ - STOCK_HEIGHT, STOCK_WIDTH, ToXml, XmlWriter, write_tag_end, write_tag_start_with_attrs, -}; +use crate::xmile::{ToXml, XmlWriter, write_tag_end, write_tag_start_with_attrs}; #[cfg_attr(feature = "debug-derive", derive(Debug))] #[derive(Copy, Clone, PartialEq, Eq, Deserialize, Serialize)] @@ -45,8 +43,8 @@ pub mod view_element { use crate::datamodel::StockFlow; use crate::datamodel::view_element::LinkShape; use crate::xmile::{ - STOCK_HEIGHT, STOCK_WIDTH, ToXml, XmlWriter, write_tag, write_tag_end, write_tag_start, - write_tag_start_with_attrs, write_tag_text, write_tag_with_attrs, + ToXml, XmlWriter, write_tag, write_tag_end, write_tag_start, write_tag_start_with_attrs, + write_tag_text, write_tag_with_attrs, }; use quick_xml::Writer; use serde::{Deserialize, Deserializer, Serialize}; @@ -347,21 +345,6 @@ pub mod view_element { } } - impl Stock { - pub fn is_right(&self, pt: &Point) -> bool { - pt.x > self.x + STOCK_WIDTH / 2.0 && (pt.y - self.y).abs() < STOCK_HEIGHT / 2.0 - } - pub fn is_left(&self, pt: &Point) -> bool { - pt.x < self.x + STOCK_WIDTH / 2.0 && (pt.y - self.y).abs() < STOCK_HEIGHT / 2.0 - } - pub fn is_above(&self, pt: &Point) -> bool { - pt.y < self.y + STOCK_HEIGHT / 2.0 && (pt.x - self.x).abs() < STOCK_WIDTH / 2.0 - } - pub fn is_below(&self, pt: &Point) -> bool { - pt.y > self.y + STOCK_HEIGHT / 2.0 && (pt.x - self.x).abs() < STOCK_WIDTH / 2.0 - } - } - impl From for datamodel::view_element::Stock { fn from(v: Stock) -> Self { let x = match v.width { @@ -512,49 +495,24 @@ pub mod view_element { } } - fn is_horizontal(points: &[datamodel::view_element::FlowPoint]) -> bool { - if points.len() > 2 { - return false; - } - let start = &points[0]; - let end = &points[1]; - let dx = (end.x - start.x).abs(); - let dy = (end.y - start.y).abs(); - - dx > dy - } - + /// A plain field conversion: the pipe's geometry is brought to the + /// flow invariants over the whole converted view + /// (`diagram::flow_geometry::normalize_flow_geometry`), where the stocks' + /// centers and the clouds are known. impl From for datamodel::view_element::Flow { fn from(v: Flow) -> Self { - // position of the flow valve - let mut cx = v.x; - let mut cy = v.y; - let mut points: Vec<_> = v + let points: Vec<_> = v .points .unwrap_or_default() .points .into_iter() .map(datamodel::view_element::FlowPoint::from) .collect(); - // Vensim imports don't actually enforce horizontal or vertical lines are straight - if points.len() == 2 { - if is_horizontal(&points) { - let new_y = (points[0].y + points[1].y) / 2.0; - points[0].y = new_y; - points[1].y = new_y; - cy = new_y; - } else { - let new_x = (points[0].x + points[1].x) / 2.0; - points[0].x = new_x; - points[1].x = new_x; - cx = new_x; - } - } datamodel::view_element::Flow { name: v.name, uid: v.uid.unwrap_or(-1), - x: cx, - y: cy, + x: v.x, + y: v.y, label_side: datamodel::view_element::LabelSide::from( v.label_side.unwrap_or(LabelSide::Bottom), ), @@ -611,96 +569,6 @@ pub mod view_element { let actual = datamodel::view_element::Flow::from(Flow::from(expected.clone())); assert_eq!(expected, actual); } - - let input_v = datamodel::view_element::Flow { - name: "from_vensim_v".to_string(), - uid: 76, - x: 2.0, - y: 5.0, - label_side: datamodel::view_element::LabelSide::Bottom, - points: vec![ - datamodel::view_element::FlowPoint { - x: 1.0, - y: 1.0, - attached_to_uid: None, - }, - datamodel::view_element::FlowPoint { - x: 3.0, - y: 9.0, - attached_to_uid: None, - }, - ], - compat: None, - label_compat: None, - }; - let expected_v = datamodel::view_element::Flow { - name: "from_vensim_v".to_string(), - uid: 76, - x: 2.0, - y: 5.0, - label_side: datamodel::view_element::LabelSide::Bottom, - points: vec![ - datamodel::view_element::FlowPoint { - x: 2.0, - y: 1.0, - attached_to_uid: None, - }, - datamodel::view_element::FlowPoint { - x: 2.0, - y: 9.0, - attached_to_uid: None, - }, - ], - compat: None, - label_compat: None, - }; - let actual_v = datamodel::view_element::Flow::from(Flow::from(input_v)); - assert_eq!(expected_v, actual_v); - - let input_h = datamodel::view_element::Flow { - name: "from_vensim_h".to_string(), - uid: 76, - x: 5.0, - y: 2.0, - label_side: datamodel::view_element::LabelSide::Bottom, - points: vec![ - datamodel::view_element::FlowPoint { - x: 1.0, - y: 1.0, - attached_to_uid: None, - }, - datamodel::view_element::FlowPoint { - x: 9.0, - y: 3.0, - attached_to_uid: None, - }, - ], - compat: None, - label_compat: None, - }; - let expected_h = datamodel::view_element::Flow { - name: "from_vensim_h".to_string(), - uid: 76, - x: 5.0, - y: 2.0, - label_side: datamodel::view_element::LabelSide::Bottom, - points: vec![ - datamodel::view_element::FlowPoint { - x: 1.0, - y: 2.0, - attached_to_uid: None, - }, - datamodel::view_element::FlowPoint { - x: 9.0, - y: 2.0, - attached_to_uid: None, - }, - ], - compat: None, - label_compat: None, - }; - let actual_h = datamodel::view_element::Flow::from(Flow::from(input_h)); - assert_eq!(expected_h, actual_h); } #[cfg_attr(feature = "debug-derive", derive(Debug))] @@ -2058,63 +1926,20 @@ impl View { self.objects.append(&mut clouds); } - fn fixup_flow_takeoffs(&mut self) { - let stocks: HashMap<_, _> = self - .objects - .iter() - .filter(|vo| matches!(vo, ViewObject::Stock(_))) - .cloned() - .map(|vo| (vo.uid().unwrap(), vo)) - .collect(); - let maybe_fixup_takeoff = |pt1: &mut view_element::Point, pt2: &view_element::Point| { - if let Some(source_uid) = pt1.uid - && let Some(ViewObject::Stock(stock)) = stocks.get(&source_uid) - { - if stock.is_right(pt2) { - pt1.x = stock.x + STOCK_WIDTH / 2.0; - } else if stock.is_left(pt2) { - pt1.x = stock.x - STOCK_WIDTH / 2.0; - } else if stock.is_above(pt2) { - pt1.y = stock.y - STOCK_HEIGHT / 2.0; - } else if stock.is_below(pt2) { - pt1.y = stock.y + STOCK_HEIGHT / 2.0; - } - } - }; - - for view_object in self.objects.iter_mut() { - if let ViewObject::Flow(flow) = view_object { - if flow.points.is_none() || flow.points.as_ref().unwrap().points.len() != 2 { - continue; - } - let source_point = flow - .points - .as_ref() - .unwrap() - .points - .first() - .unwrap() - .clone(); - let sink_point = flow.points.as_ref().unwrap().points.last().unwrap().clone(); - maybe_fixup_takeoff( - flow.points.as_mut().unwrap().points.first_mut().unwrap(), - &sink_point, - ); - maybe_fixup_takeoff( - flow.points.as_mut().unwrap().points.last_mut().unwrap(), - &source_point, - ); - } - } - } - + /// Assign uids and attach every flow endpoint to its stock (from the + /// model's inflow/outflow lists) or to a newly created cloud. + /// + /// This is the view's topology only. Geometry is fixed after conversion + /// to the datamodel (`diagram::flow_geometry::normalize_flow_geometry`): + /// a stock written with `width`/`height` carries its top-left corner here + /// and only becomes a center in that conversion, so no face can be judged + /// on these objects. pub(crate) fn normalize(&mut self, model: &Model) { if self.kind.unwrap_or(ViewType::StockFlow) != ViewType::StockFlow { return; } let uid_map = self.assign_uids(); self.fixup_clouds(model, &uid_map); - self.fixup_flow_takeoffs(); } } @@ -2297,6 +2122,10 @@ fn test_view_roundtrip() { } } +#[cfg(test)] +#[path = "views_flow_geometry_tests.rs"] +mod flow_geometry_tests; + /// The `` unit contract. XMILE spec section 5.1 /// (`docs/reference/xmile-v1.0.html`, "Views"): "Views may also have an /// OPTIONAL zoom specified as a double where 100 is default, 200 is 2x bigger diff --git a/src/simlin-engine/src/xmile/views_flow_geometry_tests.rs b/src/simlin-engine/src/xmile/views_flow_geometry_tests.rs new file mode 100644 index 000000000..3b8e6c59b --- /dev/null +++ b/src/simlin-engine/src/xmile/views_flow_geometry_tests.rs @@ -0,0 +1,278 @@ +// Copyright 2026 The Simlin Authors. All rights reserved. +// Use of this source code is governed by the Apache License, +// Version 2.0, that can be found in the LICENSE file. + +//! The XMILE importer's flow geometry, read through the production reader +//! (`project_from_reader`) on corpus files and on small documents shaped the +//! way producers write them. The invariants are `diagram::flow_geometry`'s. + +use std::io::BufReader; + +use crate::datamodel::{self, ViewElement, view_element}; +use crate::diagram::flow_geometry::flow_invariant_violations; +use crate::xmile::project_from_reader; + +fn import(xml: &str) -> datamodel::Project { + project_from_reader(&mut BufReader::new(xml.as_bytes())).expect("must parse") +} + +fn main_view(project: &datamodel::Project) -> &datamodel::StockFlow { + let model = project + .models + .iter() + .find(|m| m.name == "main") + .expect("main model"); + let datamodel::View::StockFlow(sf) = &model.views[0]; + sf +} + +fn flow<'a>(view: &'a datamodel::StockFlow, name: &str) -> &'a view_element::Flow { + view.elements + .iter() + .find_map(|e| match e { + ViewElement::Flow(f) if f.name == name => Some(f), + _ => None, + }) + .unwrap_or_else(|| panic!("flow {name:?} not in view")) +} + +fn stock<'a>(view: &'a datamodel::StockFlow, name: &str) -> &'a view_element::Stock { + view.elements + .iter() + .find_map(|e| match e { + ViewElement::Stock(s) if s.name == name => Some(s), + _ => None, + }) + .unwrap_or_else(|| panic!("stock {name:?} not in view")) +} + +fn cloud(view: &datamodel::StockFlow, uid: i32) -> &view_element::Cloud { + view.elements + .iter() + .find_map(|e| match e { + ViewElement::Cloud(c) if c.uid == uid => Some(c), + _ => None, + }) + .unwrap_or_else(|| panic!("cloud {uid} not in view")) +} + +/// Violations restricted to the named flows, so a corpus file's unrelated +/// flows do not decide a test about specific ones. +fn violations_for(view: &datamodel::StockFlow, names: &[&str]) -> Vec { + flow_invariant_violations(&view.elements) + .into_iter() + .filter(|v| names.iter().any(|n| v.starts_with(&format!("{n}:")))) + .collect() +} + +fn document(view_body: &str, variables: &str) -> String { + format!( + r#" + +
tisee systems, inc.Stella
+ 01
1
+ + {variables} + {view_body} + +
"# + ) +} + +/// A stock written with `width`/`height` carries its TOP-LEFT corner in +/// `x`/`y` (the importer converts it to a center). A flow drawn into that +/// stock's left edge must import onto the 45x35 box's left face at the +/// stock's CENTER, not relative to the top-left corner. +#[test] +fn sized_stock_endpoint_lands_on_the_centered_face() { + // presumed_infected: top-left (450, 1011.07), 45x35 -> center (472.5, 1028.57). + const COVID: &str = include_str!("../../../../test/conveyors/covid19_severity.stmx"); + let project = import(COVID); + let view = main_view(&project); + + let infected = stock(view, "presumed\\ninfected"); + let presumed = flow(view, "presumed\\nnew infections"); + let sink = presumed.points.last().unwrap(); + assert_eq!(sink.attached_to_uid, Some(infected.uid)); + assert!( + (sink.x - (infected.x - 22.5)).abs() < 1e-9, + "sink x {} must be on the left face {}", + sink.x, + infected.x - 22.5 + ); + + // Uninfected at risk: 93.75x65 in Stella, drawn 45x35 here; the pipe + // leaves its right edge, which is on the right face of the drawn box. + let at_risk = stock(view, "Uninfected\\nat risk"); + let by_severity = flow(view, "new infections\\nby severity"); + let source = &by_severity.points[0]; + assert_eq!(source.attached_to_uid, Some(at_risk.uid)); + assert!( + (source.x - (at_risk.x + 22.5)).abs() < 1e-9, + "source x {} must be on the right face {}", + source.x, + at_risk.x + 22.5 + ); + + assert_eq!( + violations_for( + view, + &["presumed\\nnew infections", "new infections\\nby severity"] + ), + Vec::::new() + ); +} + +/// Both takeoff directions and both axes for a sized stock (top-left `x`/`y`): +/// a flow leaving each of the four faces imports onto that face of the +/// centered box. Pins every arm of the face choice, not just the covid one. +#[test] +fn sized_stock_endpoints_on_every_face() { + // Stock top-left (100, 100), 45x35 -> center (122.5, 117.5). + let view_body = r#" + + + + + + "#; + let variables = r#" + 1left_intop_inright_outbottom_out + 1 + 1 + 1 + 1 + "#; + let project = import(&document(view_body, variables)); + let view = main_view(&project); + let s = stock(view, "s"); + assert_eq!((s.x, s.y), (122.5, 117.5)); + + let expect = [ + ("right_out", 0, (145.0, 117.5)), + ("left_in", 1, (100.0, 117.5)), + ("top_in", 1, (122.5, 100.0)), + ("bottom_out", 0, (122.5, 135.0)), + ]; + for (name, end, (x, y)) in expect { + let p = &flow(view, name).points[end]; + assert_eq!(p.attached_to_uid, Some(s.uid), "{name}"); + assert!( + (p.x - x).abs() < 1e-9 && (p.y - y).abs() < 1e-9, + "{name}: endpoint ({}, {}) expected ({x}, {y})", + p.x, + p.y + ); + } + assert_eq!( + flow_invariant_violations(&view.elements), + Vec::::new() + ); +} + +/// A flow already satisfying every invariant -- including a deliberately +/// off-center slot on a face -- imports byte for byte. +#[test] +fn valid_geometry_including_off_center_slots_is_not_moved() { + let view_body = r#" + + + + + "#; + let variables = r#" + 1abdrain + 1ab + 1 + 1 + "#; + let project = import(&document(view_body, variables)); + let view = main_view(&project); + let ab: Vec<(f64, f64)> = flow(view, "ab").points.iter().map(|p| (p.x, p.y)).collect(); + assert_eq!(ab, vec![(122.5, 110.0), (277.5, 110.0)]); + let drain = flow(view, "drain"); + let pts: Vec<(f64, f64)> = drain.points.iter().map(|p| (p.x, p.y)).collect(); + assert_eq!(pts, vec![(90.0, 117.5), (90.0, 160.0), (40.0, 160.0)]); + assert_eq!( + (drain.x, drain.y), + (65.0, 160.0), + "valve already on the pipe" + ); + let ab = flow(view, "ab"); + assert_eq!((ab.x, ab.y), (200.0, 110.0), "valve already on the pipe"); + assert_eq!( + flow_invariant_violations(&view.elements), + Vec::::new() + ); +} + +/// `land_model.stmx` (Stella): corner and off-stock endpoints are brought +/// onto faces. `forest to agriculture`'s corner sink needs a line 3px inside +/// its clearance span from the source's valid off-center slot: a jog that +/// short would be a riser under `MIN_SEGMENT`, so the valid slot gives the +/// least it can -- the 3px, staying valid -- and the pipe runs straight. +#[test] +fn land_model_corner_endpoints_are_fixed_without_moving_valid_slots() { + const LAND: &str = include_str!("../../../../test/land_model/land_model.stmx"); + let project = import(LAND); + let view = main_view(&project); + assert_eq!( + flow_invariant_violations(&view.elements), + Vec::::new() + ); + + let f = flow(view, "forest to agriculture"); + let pts: Vec<(f64, f64)> = f.points.iter().map(|p| (p.x, p.y)).collect(); + assert_eq!(pts, vec![(618.5, 2052.5), (827.5, 2052.5)]); + assert_eq!((f.x, f.y), (723.0, 2052.5)); +} + +/// A cloud is created at a 2-point flow's raw endpoint, and the importer then +/// straightens the pipe (xmutil writes pipes a pixel or two off axis). The +/// cloud must end up centered on the STRAIGHTENED endpoint, for both the +/// source and the sink cloud, horizontal and vertical. +#[test] +fn clouds_are_centered_on_straightened_endpoints() { + let view_body = r#" + + + + "#; + let variables = r#" + 1h_inv_out + 1 + 1 + "#; + let project = import(&document(view_body, variables)); + let view = main_view(&project); + for (name, cloud_end) in [("h_in", 0usize), ("v_out", 1usize)] { + let f = flow(view, name); + let p = &f.points[cloud_end]; + let c = cloud(view, p.attached_to_uid.expect("cloud end attached")); + assert_eq!(c.flow_uid, f.uid, "{name}"); + assert_eq!((c.x, c.y), (p.x, p.y), "{name}: cloud off its endpoint"); + } + assert_eq!( + flow_invariant_violations(&view.elements), + Vec::::new() + ); +} + +/// The corpus flows the cloud defect was measured on: xmutil-converted +/// models whose raw pipes are slightly diagonal. +#[test] +fn xmutil_corpus_clouds_sit_on_their_endpoints() { + const ABS: &str = include_str!("../../../../test/test-models/tests/abs/test_abs.xmile"); + const CHAINED: &str = include_str!( + "../../../../test/test-models/tests/chained_initialization/test_chained_initialization.xmile" + ); + for (label, text) in [("test_abs", ABS), ("chained_initialization", CHAINED)] { + let project = import(text); + let view = main_view(&project); + let problems: Vec = flow_invariant_violations(&view.elements) + .into_iter() + .filter(|v| v.contains("cloud")) + .collect(); + assert_eq!(problems, Vec::::new(), "{label}"); + } +} diff --git a/src/simlin-engine/tests/integration/layout.rs b/src/simlin-engine/tests/integration/layout.rs index 53c375060..737a09ae5 100644 --- a/src/simlin-engine/tests/integration/layout.rs +++ b/src/simlin-engine/tests/integration/layout.rs @@ -2061,10 +2061,11 @@ fn test_incremental_add_waste_flow_goes_below() { } /// P2: When a chain flow is incrementally added to a stock that already has -/// a cloud outflow on the right, the existing cloud flow should be rebuilt -/// to exit from the bottom. +/// a cloud outflow on the right, the existing cloud flow is a sibling the +/// patch did not touch: it stays where it is, and the chain flow's end takes +/// a free slot on the face instead of landing on it. #[test] -fn test_incremental_add_chain_rebuilds_existing_cloud_flow() { +fn test_incremental_add_chain_keeps_existing_cloud_flow() { use simlin_engine::datamodel; use simlin_engine::layout::incremental_layout; use simlin_engine::{ModelOperation, ModelPatch}; @@ -2219,63 +2220,45 @@ fn test_incremental_add_chain_rebuilds_existing_cloud_flow() { let new_view = incremental_layout(&old_view, &patched_project, MAIN_MODEL, &patch, None) .expect("incremental layout should succeed"); - let new_stock_pos = new_view - .elements - .iter() - .find_map(|e| { - if let ViewElement::Stock(s) = e - && normalize(&s.name) == "stock_a" - { - Some((s.x, s.y)) - } else { - None - } - }) - .expect("stock_a in new view"); + let flow_named = |view: &datamodel::StockFlow, name: &str| { + view.elements + .iter() + .find_map(|e| match e { + ViewElement::Flow(f) if normalize(&f.name) == name => Some(f.clone()), + _ => None, + }) + .unwrap_or_else(|| panic!("{name} in view")) + }; - let new_waste_pos = new_view - .elements - .iter() - .find_map(|e| { - if let ViewElement::Flow(f) = e - && normalize(&f.name) == "waste_flow" - { - Some((f.x, f.y)) - } else { - None - } - }) - .expect("waste_flow in new view"); + // waste_flow is a sibling the patch did not touch: it comes back byte for + // byte, still on the right face. + let new_waste = flow_named(&new_view, "waste_flow"); + assert!( + new_waste == flow_named(&old_view, "waste_flow"), + "waste_flow must come back byte for byte when a chain flow is added beside it" + ); - let new_chain_pos = new_view + // The chain flow's end on stock_a takes a free slot, never waste_flow's. + let stock_a_uid = new_view .elements .iter() - .find_map(|e| { - if let ViewElement::Flow(f) = e - && normalize(&f.name) == "chain_flow" - { - Some((f.x, f.y)) - } else { - None - } + .find_map(|e| match e { + ViewElement::Stock(s) if normalize(&s.name) == "stock_a" => Some(s.uid), + _ => None, }) - .expect("chain_flow in new view"); - - // Now waste_flow should have moved to the bottom (different y from stock) - assert!( - new_waste_pos.1 > new_stock_pos.1 + 5.0, - "waste_flow y ({}) should now be below stock_a y ({}) after chain added", - new_waste_pos.1, - new_stock_pos.1, - ); - - // Chain and waste should not overlap - let dist = ((new_chain_pos.0 - new_waste_pos.0).powi(2) - + (new_chain_pos.1 - new_waste_pos.1).powi(2)) - .sqrt(); + .expect("stock_a in new view"); + let end_on_stock_a = |f: &datamodel::view_element::Flow| { + f.points + .iter() + .find(|p| p.attached_to_uid == Some(stock_a_uid)) + .map(|p| (p.x, p.y)) + .expect("an end on stock_a") + }; + let waste_end = end_on_stock_a(&new_waste); + let chain_end = end_on_stock_a(&flow_named(&new_view, "chain_flow")); assert!( - dist > 5.0, - "chain_flow and waste_flow should not overlap after incremental add (dist={dist})" + (waste_end.0 - chain_end.0).abs() > 1.0 || (waste_end.1 - chain_end.1).abs() > 1.0, + "chain_flow's end on stock_a {chain_end:?} must not land on waste_flow's {waste_end:?}" ); } diff --git a/src/simlin-engine/tests/integration/mdl_roundtrip.rs b/src/simlin-engine/tests/integration/mdl_roundtrip.rs index 638e4df9b..e4f70420a 100644 --- a/src/simlin-engine/tests/integration/mdl_roundtrip.rs +++ b/src/simlin-engine/tests/integration/mdl_roundtrip.rs @@ -1319,6 +1319,40 @@ fn normalize_influence_connectors(view: &SketchView) -> Result, Stri Ok(connectors) } +/// mark2 flows whose Vensim geometry does not satisfy the flow invariants on +/// the 45x35 stock box every Simlin renderer draws, so the importer moves +/// their valve or cloud (`diagram::flow_geometry::normalize_flow_geometry`) +/// and the written records cannot carry the source coordinates. AC1.3 +/// compares these records with coordinates removed, and requires the full +/// comparison to still differ, so an entry that stops being needed fails. +const AC1_3_NORMALIZED_FLOWS: &[(&str, &str)] = &[ + // The cloud comment sits 3px off the pipe's line; the cloud is centered + // on the pipe's end. + ("1 housing", "homes sold"), + // The pipe enters Vensim's 90x65 `risk taking behavior` box 19 below its + // center, past the 45x35 box's bottom face; it slides 4.5 up onto the + // right face, carrying the valve and the cloud. + ("2 investments", "change in risk taking behavior"), +]; + +/// A normalized flow-block record with the element's `x`/`y` blanked, for the +/// flows in `AC1_3_NORMALIZED_FLOWS`. +fn without_coordinates(record: &str) -> String { + let Some((kind, rest)) = record.split_once(':') else { + return record.to_owned(); + }; + if kind != "valve" && kind != "cloud" { + return record.to_owned(); + } + let mut fields: Vec<&str> = rest.split(',').collect(); + for idx in [2, 3] { + if let Some(field) = fields.get_mut(idx) { + *field = "_"; + } + } + format!("{kind}:{}", fields.join(",")) +} + /// Verify mark2.mdl format roundtrip: parse, write, and compare the /// output against the original at the per-view-element level. /// @@ -1421,7 +1455,28 @@ fn mdl_format_roundtrip() { let actual = actual_blocks .get(flow_name) .expect("actual flow block by name"); - if let Some(diff) = diff_multiset(expected, actual) { + let normalized = AC1_3_NORMALIZED_FLOWS + .iter() + .any(|(view, flow)| orig.name == *view && flow_name == flow); + if normalized { + if diff_multiset(expected, actual).is_none() { + failures.push(format!( + "AC1.3: view[{i}] ({:?}) flow block {:?} now round-trips \ + exactly -- remove it from AC1_3_NORMALIZED_FLOWS", + orig.name, flow_name + )); + } + let strip = |records: &[String]| -> Vec { + records.iter().map(|r| without_coordinates(r)).collect() + }; + if let Some(diff) = diff_multiset(&strip(expected), &strip(actual)) { + failures.push(format!( + "AC1.3: view[{i}] ({:?}) normalized flow block {:?} \ + differs beyond coordinates: {diff}", + orig.name, flow_name + )); + } + } else if let Some(diff) = diff_multiset(expected, actual) { failures.push(format!( "AC1.3: view[{i}] ({:?}) flow block {:?} differs: {diff}", orig.name, flow_name @@ -1685,7 +1740,6 @@ const EXPECTED_NON_IDEMPOTENT: &[&str] = &[ "test/test-models/tests/subscripted_round/test_subscripted_round.mdl", "test/test-models/tests/subscripted_xidz/test_subscripted_xidz.mdl", "test/test-models/tests/xidz_zidz/xidz_zidz.mdl", - "test/test-models/tests/zeroled_decimals/test_zeroled_decimals.mdl", // CRLF free-text accumulation (GH #849). The free-text sanitization choke // point (`mdl::writer::sanitize_free_text`) normalizes embedded carriage // returns, so the fixtures whose ONLY non-idempotence was CR accumulation