Compare commits

...
Author SHA1 Message Date
Codex-ytr83r b10b28cefd Keep the legacy sequence helper in test builds only 2026-09-13 06:27:57 -07:00
Codex-ytr83r 5d4a6a47ce Follow inspected Saved scenario through the actual arrangement picker 2026-09-13 06:18:34 -07:00
Codex-ytr83r 8bf7607a3f Allow Details properties for the exact displayed Saved scenario view 2026-09-13 04:02:31 -07:00
Codex-ytr83r ef1c1be704 Restore Scenario inspector from focused Details with stamped admission 2026-09-13 03:42:37 -07:00
Codex-ytr83r 0409a481c7 Correct saved gesture fixtures and preserve explicit cancellation assertions 2026-09-13 02:55:57 -07:00
Codex-ytr83r 916ec5c78b Test exact and fractional scenario spacing input separately 2026-09-13 02:54:23 -07:00
sorokin.oleg.edu c4a2cf63f1 Sync agent instructions from main 2026-09-13 11:40:30 +03:00
Codex-ytr83r 07e611fb94 feat: preview and commit Saved Scenario spacing gestures 2026-09-13 01:32:42 -07:00
Codex-ytr83r 46ff127373 test: enter Scenario fixtures through live navigation 2026-09-13 01:20:02 -07:00
Codex-ytr83r 86a187ab4c Correct mounted scenario camera and palette assertions 2026-09-13 00:38:39 -07:00
Codex-ytr83r 28b7dc5b30 Complete canvas scope wiring and use Tokio for mounted Scenario test 2026-09-12 23:48:07 -07:00
Codex-ytr83r 457a2c28db Prototype pure saved Scenario spacing command previews 2026-09-12 23:46:57 -07:00
Codex-ytr83r 2c9cd2b3e6 Simplify Scenario geometry admission conditions for strict lint 2026-09-12 23:09:38 -07:00
Codex-ytr83r 46e106c04e Wire saved Scenario arrangements through workspace navigation 2026-09-12 23:00:52 -07:00
Codex-ytr83r aa62f42b81 Grow Scenario frames with preserved descendant padding 2026-09-12 22:12:40 -07:00
Codex-ytr83r 7f192f5dc4 Prepare complete saved Scenario display fallback adapter 2026-09-12 22:11:36 -07:00
Codex-ytr83r 68a81c3f50 Resolve saved Scenario geometry through explicit complete Scene plans 2026-09-12 21:26:33 -07:00
Codex-ytr83r 45a7fde2c1 Record spacing gesture candidate and explicit reorder usability gate 2026-09-12 21:11:32 -07:00
Codex-ytr83r bfb4b39744 Exercise saved Scenario positions through HTTP transactions and restart 2026-09-12 21:06:35 -07:00
Codex-ytr83r 20acd3238e Assert complete typed activations in authoring and offset regressions 2026-09-12 21:05:11 -07:00
Codex-ytr83r 695685a2b1 Integrate typed Scenario activations before saved geometry resolution 2026-09-12 20:52:51 -07:00
Codex-ytr83r e4e87951d7 test: read decoded model from restored source snapshot 2026-09-12 20:52:05 -07:00
Codex-ytr83r aed07176ab test: use participant viewpoints for Scenario layout fixtures 2026-09-12 19:49:18 -07:00
Codex-ytr83r 9245f8d23e fix: preserve migration context and reject unsupported SVG exports 2026-09-12 19:41:48 -07:00
Codex-ytr83r a4d1efc5f7 experiment: reserve label ink space inside the fixed corridor 2026-09-12 19:41:29 -07:00
Codex-ytr83r ecc49e0417 feat(core): prepare typed Scenario layout and source lifecycle 2026-09-12 19:27:49 -07:00
Codex-ytr83r e409107a63 experiment: prepare real WGPU Scenario label evaluator 2026-09-12 19:16:43 -07:00
Codex-ytr83r 2f90cf25d1 Verify conservative corridor rounding and stale font rejection 2026-09-12 18:49:11 -07:00
Codex-ytr83r 345f7b3d5b Keep reconstructed label corridors finite and inside blockers 2026-09-12 18:46:13 -07:00
Codex-ytr83r 6a8650669f Record passing measured label and corridor experiments 2026-09-12 18:42:10 -07:00
Codex-ytr83r 903d85dda4 Resolve label tests explicitly for isolated evaluator 2026-09-12 18:36:53 -07:00
Codex-ytr83r 9b56a337f8 Prepare measured scenario labels and clear corridor evaluators 2026-09-12 18:35:52 -07:00
Codex-ytr83r e811879857 Specify candidate saved scenario layout and source lifecycle 2026-09-12 18:29:24 -07:00
Codex-ytr83r 75cb09dade Define scenario layout identity and persistence direction 2026-09-12 18:02:38 -07:00
Codex-ytr83r 208268c8aa Combine Text alias rename with guarded diagram navigation 2026-09-12 17:27:08 -07:00
Codex-ytr83r 4db1ef543c Preserve original Windows test evidence bytes 2026-09-12 16:09:18 -07:00
Codex-ytr83r 0eca66f669 Explain raw test log whitespace preservation 2026-09-12 15:58:25 -07:00
Codex-ytr83r 2488ee5955 Record passing Windows diagram navigation tests 2026-09-12 15:50:15 -07:00
Codex-ytr83r 46b4f3c0d0 Preserve exact diagram usage identity and separate visual focus 2026-09-12 15:10:33 -07:00
Codex-ytr83r f23615ffeb Record layout policy experiment and reproduced history falsifier 2026-09-12 07:27:06 -07:00
Codex-ytr83r fdf756e6a4 Publish in-progress navigation source and independent canvas reviews 2026-09-12 06:58:08 -07:00
Codex-ytr83r 7aa007b077 Publish verified canvas and repair Windows prerelease checkpoint 2026-09-12 05:06:18 -07:00
Codex-ytr83r 0c53c21030 Record verified Windows canvas and repair package acceptance 2026-09-12 04:59:48 -07:00
Codex-ytr83r 4dd0f60c75 Record complete combined Windows gates and bounded native camera checks 2026-09-12 04:26:03 -07:00
Codex-ytr83r 60f714853f Merge commit '51846f5a2b44ae31f8e8f1127fc803f2f430fbe7' into codex/canvas-union-ytr83r-20260911 2026-09-12 04:06:16 -07:00
Codex-ytr83r 5511755a5e Preserve closed native and Unicode verification with repair integration review 2026-09-12 04:02:45 -07:00
Codex-ytr83r 33e30c1cd7 Keep repaired destinations unsaved until their accepted source is written 2026-09-12 03:55:42 -07:00
sorokin.oleg.edu 51846f5a2b Sync agent instructions from main 2026-09-12 13:43:20 +03:00
sorokin.oleg.edu 436ea4b29a Sync agent instructions from main 2026-09-12 13:40:03 +03:00
sorokin.oleg.edu 46c8783c72 Sync agent instructions from main 2026-09-12 13:15:35 +03:00
Codex-ytr83r 0d39d58ebd Record reviewed grapheme fix and closed packaged GUI evidence 2026-09-11 14:09:38 -07:00
Codex-ytr83r cb91525cfd Preserve grapheme clusters when shortening desktop labels 2026-09-11 13:48:21 -07:00
Codex-ytr83r 108b1dd208 Record combined Windows gates and native child-work evidence 2026-09-11 13:34:47 -07:00
Codex-ytr83r a57c334b30 Integrate persistent canvas controls with scenario lifetime rendering 2026-09-11 12:44:06 -07:00
Codex-ytr83r 32334c6631 Record Windows gates and partial native child-work checks 2026-09-11 12:13:33 -07:00
Codex-ytr83r 09da0d90aa Add reproducible native child-work verification fixtures 2026-09-11 11:32:15 -07:00
Codex-ytr83r 865483b4c6 Preserve responsibility assignments when creating child work 2026-09-11 11:04:12 -07:00
Codex-ytr83r f796a27277 Record integrated Windows tests and HTTP persistence checks 2026-09-11 11:04:09 -07:00
Codex-ytr83r 321897fd55 Integrate contextual source creation with lifecycle rendering 2026-09-11 10:38:05 -07:00
Codex-ytr83r 4da92ab481 Clip behavior card labels to their bounds 2026-09-11 10:23:36 -07:00
Codex-ytr83r 7831abeed5 Record native lifecycle checks and centralize live planning 2026-09-11 10:22:50 -07:00
sorokin.oleg.edu fe8364e7eb Sync agent instructions from main 2026-09-11 19:52:27 +03:00
Codex-ytr83r 3ade397008 Record Windows desktop validation and user-paused Linux setup 2026-09-11 08:40:05 -07:00
Codex-ytr83r 29084050a7 Prepare Linux delivery and correct lifecycle rendering build types 2026-09-11 07:53:42 -07:00
Codex-ytr83r cb611a71df Bound Create labels within lifeline capsules and preserve Unicode test inputs 2026-09-11 06:56:17 -07:00
Codex-ytr83r fb77b857d2 Save reboot checkpoint with interrupted build and restart instructions 2026-09-11 06:38:54 -07:00
Codex-ytr83r a9a5768144 Record bounded Linux trial and contextual source integration review 2026-09-11 06:35:30 -07:00
Codex-ytr83r d041212d17 Merge owner instruction sync while preserving exact supplied goal 2026-09-11 06:33:51 -07:00
Codex-ytr83r b96286ea37 Record current core and lifecycle evidence and refresh project entrypoints 2026-09-11 06:30:59 -07:00
sorokin.oleg.edu dd42d0ab01 Sync agent instructions from main 2026-09-11 15:55:52 +03:00
sorokin.oleg.edu 023e3a8f09 Add source-first CLI with guarded writes and reviewed evidence 2026-09-11 05:45:32 -07:00
Codex-ytr83r 52f6e176cd Render explicit scenario lifetimes and preserve marker selection 2026-09-11 05:25:04 -07:00
Codex-ytr83r b853a1eca3 Import corrected creation oracle and verified external evidence 2026-09-11 05:01:57 -07:00
sorokin.oleg.edu e207b04114 Add named behavior creation and verified textual authoring examples 2026-09-11 04:37:07 -07:00
Codex-ytr83r c81e285573 Integrate published behavior work and preserve current release goal 2026-09-11 04:27:09 -07:00
Codex c6d296a7e2 Preserve native authoring evidence and compared semantic contracts 2026-09-11 02:04:38 -07:00
Codex 80cf197be6 Remeasure scenario Fit viewport after owner or document changes 2026-09-11 01:27:48 -07:00
sorokin.oleg.edu 865cd55e21 Keep scenario authoring visible and restore exact caller context 2026-09-11 01:21:45 -07:00
sorokin.oleg.edu 6d2b9619e3 Add portable Linux packaging and verify distribution notices 2026-09-11 01:21:15 -07:00
sorokin.oleg.edu 65a1fef17c Restore inline naming focus after recovery 2026-09-10 12:12:16 -07:00
Codex ef1a4478c5 Validate scenario oracle and record first local behavior test results 2026-09-10 12:10:59 -07:00
Codex 632c8feb36 Consolidate active release ledgers and preserve exact historical evidence 2026-09-10 12:02:47 -07:00
Codex aa40345281 Record semantic release gaps and remove unused palette import 2026-09-10 11:57:29 -07:00
Codex 8547b85825 Record revised release requirements and native scenario oracle 2026-09-10 11:44:35 -07:00
Codex 4ecad78433 Seed optional history values from explicit constructors 2026-09-10 11:38:44 -07:00
sorokin.oleg.edu ff615d0ee0 Update the project goal
Rust checks / check (windows-latest) (push) Canceled after 0s
Rust checks / check (ubuntu-latest) (push) Canceled after 0s
2026-09-10 21:32:22 +03:00
Codex eb5161b207 Prepare scoped lifecycle projection and align authoring guide 2026-09-10 11:24:18 -07:00
sorokin.oleg.edu aed40d7464 Fix inline architecture naming and captured Escape cancellation 2026-09-10 11:14:20 -07:00
2021 changed files with 250428 additions and 3556 deletions
+63
View File
@@ -15,9 +15,72 @@
# Exact source/recovery oracles and archived ledgers retain their measured bytes.
# Keep ordinary text diffs; only disable checkout/check-in newline conversion.
PROJECT_GOAL.md -text whitespace=cr-at-eol
harness/fixtures/release1-native/** -text
harness/fixtures/interface-operation-probe/** -text
harness/history/2026-09-06-ledgers-before-consolidation/** -text
harness/history/2026-09-10-ledgers-before-continuation-refresh/** -text
harness/history/2026-09-10-ledgers-before-second-consolidation/** -text whitespace=blank-at-eol,blank-at-eof,space-before-tab,cr-at-eol
harness/experiments/textual-authoring-20260911-local/** -text whitespace=blank-at-eol,blank-at-eof,space-before-tab,cr-at-eol
harness/history/2026-09-11-ytr83r-integration/** -text whitespace=cr-at-eol
crates/oa-desktop/src/recovery/fixtures/*.json -text
harness/fixtures/source7-native/** -text
harness/fixtures/basic-editing/** -text
harness/fixtures/scenario-authoring/base/** -text whitespace=cr-at-eol
harness/experiments/textual-authoring-20260911/** -text whitespace=cr-at-eol
harness/experiments/textual-authoring-20260911/observed/split-entry.arcadia whitespace=-blank-at-eol,cr-at-eol
harness/experiments/analysis-context-20260911/** -text whitespace=cr-at-eol
harness/experiments/constructor-defaults-20260911/** -text whitespace=blank-at-eol,blank-at-eof,space-before-tab,cr-at-eol
harness/experiments/constructor-defaults-20260911/results/*.arcadia whitespace=-blank-at-eol,cr-at-eol
harness/experiments/behavior-creation-native-20260911/** -text whitespace=blank-at-eol,blank-at-eof,space-before-tab,cr-at-eol
harness/behavior-creation-contract.rs -text whitespace=cr-at-eol
harness/experiments/source-cli-independent-20260911/** -text whitespace=cr-at-eol
harness/fixtures/scenario-lifecycle-native/**/*.arcadia -text whitespace=cr-at-eol
harness/experiments/lifecycle-native-ytr83r-20260911/** -text whitespace=cr-at-eol
harness/experiments/lifecycle-native-ytr83r-20260911/*.log -text -whitespace
harness/experiments/lifecycle-native-ui-oa-20260911/** -text -whitespace
harness/experiments/lifecycle-native-ui-sa-20260911/** -text -whitespace
harness/experiments/wsl-postboot-ytr83r-20260911/** -text -whitespace
# Preserve checksum-bound font notices verbatim through Git round trips.
scripts/windows-notices/GNOME-Cantarell-61362600e104-COPYING -text
scripts/windows-notices/Fira-OFL.txt -text
scripts/windows-notices/FiraSans-Regular-NOTICE.txt -text
scripts/windows-notices/llvm-6dfe1677ab8d-libunwind-LICENSE.TXT -text
harness/experiments/lifecycle-header-overflow-before-20260911/** -text -whitespace
harness/history/2026-09-11-ytr83r-record-migration/STATE-before.md -text -whitespace
harness/fixtures/staged-source-authoring/** -text whitespace=cr-at-eol
harness/staged-source-authoring-contract.rs -text whitespace=cr-at-eol
harness/experiments/contextual-lifecycle-integration-ytr83r-20260911/** -text -whitespace
harness/experiments/windows-runtime-321897f-20260911/** -text -whitespace
harness/child-work-native-contract.rs -text whitespace=cr-at-eol
harness/fixtures/child-work-native/** -text whitespace=cr-at-eol
harness/experiments/windows-gates-865483b-20260911/** -text -whitespace
harness/experiments/native-child-oa-865483b-20260911/** -text -whitespace
harness/experiments/packaged-server-865483b-20260911/** -text -whitespace
harness/experiments/canvas-union-a57c334-20260911/** -text -whitespace
harness/experiments/windows-gates-a57c334-20260911/** -text -whitespace
harness/experiments/native-child-continuation-865483b-20260911/** -text -whitespace
harness/experiments/lifecycle-header-correction-native-865483b-20260911/** -text -whitespace
harness/release-native-child-continuation-review-20260911.md -text -whitespace
harness/experiments/packaged-gui-close-865483b-20260911/** -text -whitespace
harness/release-grapheme-shortening-review-20260911.md -text -whitespace
harness/experiments/staged-source-native-a57c334-20260911/** -text -whitespace
harness/experiments/grapheme-windows-cb91525-20260911/** -text -whitespace
harness/experiments/canvas-native-close-a57c334-20260911/** -text -whitespace
harness/release-staged-native-a57-independent-review-20260911.md -text -whitespace
harness/release-saved-destination-independent-review-20260912.md -text -whitespace
harness/experiments/repair-union-windows-33e30c1-20260912/** -text -whitespace
harness/experiments/canvas-camera-a57c334-20260912/** -text -whitespace
harness/release-camera-a57-independent-review-20260912.md -text -whitespace
harness/experiments/canvas-repair-package-33e30c1-20260912/** -text -whitespace
harness/experiments/canvas-repair-native-33e30c1-20260912/** -text -whitespace
harness/experiments/canvas-repair-server-33e30c1-20260912/** -text -whitespace
harness/experiments/canvas-repair-publication-20260912/** -text -whitespace
+1
View File
@@ -11,3 +11,4 @@
/model-store/
/.local/
.arcadia-save-*/
__pycache__/
+70 -10
View File
@@ -1,13 +1,73 @@
## Cooperation through Gitea MCP
# Agent working agreement
Use Gitea MCP to coordinate with other agents working on this project.
## Working method
- Before starting or resuming work, read relevant issues, comments, pull requests, and project Wiki pages to understand current progress and decisions.
- Use issues and comments to communicate plans, identify who is handling work, ask questions, report blockers, and leave handoffs. Check existing work before creating duplicates.
- Identify yourself consistently in messages. Do not assume another agent has read a message or is currently running.
- Respect work already in progress. Coordinate overlapping changes and preserve other agents' edits.
- Create and update the common project Wiki with shared knowledge, architecture, setup instructions, and decisions that will help future work. Keep it accurate as the project changes.
- Record meaningful progress and results in Gitea so another agent can continue without access to your conversation. Include relevant commits, validation results, and next steps.
- Keep coordination concise and useful. Use the available MCP tools, and report access failures honestly.
- Follow `PROJECT_GOAL.md` and the user's current instructions. Work autonomously within scope. Resolve discoverable questions yourself; record, test, and revise reversible assumptions. Ask only when a consequential decision needs information or authorization not already available. Continue independent useful work while blocked elsewhere.
- Map unfamiliar domains through primary sources, comparable systems, and real user workflows. Identify underlying needs, valuable concepts, and avoidable friction; evaluate improvements rather than merely reproducing features. Synthesize findings into decisions or experiments, and stop research when another action offers greater value.
- Make specifications precise enough for compatible independent implementations: definitions, relationships, invariants, valid/invalid examples, and lifecycle or transformation rules where relevant. Trace important requirements and decisions to implementation and verification. Resolve specification ambiguity at its source when implementation exposes it.
- For consequential uncertain choices, compare materially different candidates against explicit user-derived criteria. Ask independent critics to challenge assumptions and attempt to falsify proposals; refine or replace candidates as evidence warrants. Record the choice, evidence, tradeoffs, rejected approaches, and reasons to revisit it. Use proportionate effort for reversible local choices.
- Plan milestones that resolve uncertainty or deliver useful capability, with explicit dependencies. Test foundational assumptions before substantial downstream work. At checkpoints, assess progress toward the actual objective and replan when evidence invalidates the strategy or progress stalls.
- Define observable success and suitable evaluators before repeated improvement: invariants, examples, round trips, transformations, performance checks, realistic user tasks, or explicit review rubrics. Ensure the evaluator measures the intended outcome. For experiments, record the question, expectations, method, result, and implications; isolate exploratory prototypes from production architecture.
- Build and run meaningful changes, exercise actual behavior, and inspect useful logs. Investigate crashes, serious warnings, corrupted state, and interaction defects. For GUI work, use Computer Use or equivalent tools to inspect realistic workflows, cognitive load, editing/selection, navigation, feedback, recovery, loading/empty/failure states, accessibility, layout, focus/disabled states, and responsiveness.
- Match independent reviews to the artifact and risks. For languages or formats, include ambiguity, round trips, human/AI editability, and version-control behavior where relevant. Verify findings, fix the appropriate conceptual, specification, architectural, implementation, or interaction layer, and recheck affected dependents. Reuse valid evidence; repeat checks for concrete risks, changes, findings, or required gates. Compilation, screenshots, and worker reports alone do not establish correctness or usability.
- Favor the simplest maintainable design satisfying known requirements and justified extensibility. Avoid speculative abstraction, unrelated refactoring, and repeating approaches already invalidated by evidence.
Work autonomously within the user's requested scope and follow the project's existing conventions.
## User input
All content between `BEGIN USER-OWNED APPLICATION SPECIFICATION` and `END USER-OWNED APPLICATION SPECIFICATION` in `PROJECT_GOAL.md` belongs to the user. Its headings and structure are unrestricted. Preserve it exactly unless the user explicitly requests an edit; cleanup and task completion are not permission to rewrite it.
Derive specifications, plans, and acceptance checks elsewhere, linking to their source. Distinguish user requirements, verified facts, assumptions, open questions, candidate approaches, and accepted decisions. Interpretations must not silently replace the user's intent. When input changes, reconcile affected work. Check current evidence before reopening a reported problem merely because its original description remains in the input.
## Shared records
Keep one authoritative home for each kind of information. Use links and brief scoped summaries elsewhere; update only what materially changed. Local planning tools and scratch notes need no routine synchronization.
| Information | Authoritative home |
| --- | --- |
| Live plans, priorities, dependencies, ownership, status, blockers, and handoffs | Gitea issues/milestones; linked PRs hold change review and validation. |
| Specifications, architecture, interfaces, and build/run documentation | Established version-controlled project documents. |
| Durable decisions and reusable evidence | `harness/DECISIONS.md` and relevant topic notes, or existing equivalents. |
| Navigation and integrated-state checkpoint | `harness/STATE.md`: links, verified code commit/release, capabilities, and material limitations. No live task ledger. |
| Shared entrypoint | Gitea Wiki: overview and links, without duplicating versioned documentation. |
| Published deliverables | Gitea releases, tied to source commits and verification. |
Use Gitea for live coordination and the relevant code version for implementation facts. Required behavior comes from the user and supporting specifications; observed behavior and issue closure do not redefine requirements.
## Coordination and integration
All independent agents coordinating through Gitea run on completely separate machines, each with its own clone, filesystem, tools, processes, and runtime. Gitea and pushed Git history are the shared communication and delivery channels. Local files (including `harness`), unpushed commits, worktrees, test results, and localhost services are not accessible to peers. Matching branch names or paths do not imply synchronized contents.
1. On starting or resuming, including after compaction, inspect the working branch, local changes, applicable instructions, relevant shared records, and existing implementation. Recover context from authoritative records. Check for newer shared instructions on the actual remote default branch without overwriting local work. Read what is relevant; avoid repeated full-history scans and constant polling.
2. Search before creating tasks. Claim a meaningful outcome in its Gitea issue with a stable session identity, scope, remote repository/branch, and acceptance criteria. Keep small steps within that task. Recheck after claiming; comments are not atomic locks. Respect existing ownership and resolve overlapping claims before overlapping work.
3. Independent sessions are peers. Each owns its contribution and delegated work on its own machine. Record one integration/release owner when a milestone needs shared coordination. Use distinct remote work branches, partition files/interfaces, and coordinate changes to shared specifications and documents. Worktrees provide local isolation only; they do not share work between machines.
4. At meaningful checkpoints or handoffs, record changed results, commit/PR links, verification commands and environment, blockers, and next actions. Identify the tested code version and label uncommitted or unpublished work as local-only. Before asking peers to consume results, push the required code and versioned documentation, including relevant `harness` updates; publish other required artifacts in Gitea and link them. Use repository-relative paths with commit references, reproducible commands, and necessary environment details; local paths and localhost URLs alone are not handoffs. Do not assume another agent is running or has read a message; silence alone does not establish abandonment. Record evidence and preserve existing work when reassigning a task.
5. Follow project review and merge conventions. Fetch the relevant remote branches and inspect the identified commits before reviewing or integrating another machine's work. Resolve conflicts while preserving others' edits, and verify the combined result locally; report material environment differences. Local instruction-file edits reach peers only after publication and retrieval. Do not overwrite others' work or force-push shared history to resolve coordination problems.
6. Close work when its acceptance criteria are met and the result is integrated or delivered as appropriate. Mark duplicates and superseded tasks explicitly with replacement links. Then choose further useful unowned work while the requested objective remains incomplete.
Use Gitea MCP for shared coordination. If unavailable, report the failure, buffer necessary handoff information locally, and continue already-owned or clearly non-overlapping work. Reconcile with current Gitea state when access returns; do not claim an ownership update, review, or publication succeeded without confirmation.
## Delegation and reasoning
Use GPT-6 Astra exclusively. Gitea peers are independent remote sessions, not subagents controlled by this session. Spawned subagents use the execution environment actually provided by the agent tools; do not assume they share files, and publish their results before Gitea peers need them. Delegate bounded independent research, design, implementation, or review when it improves progress, judgment, or preserves the parent's context. Give subagents the relevant context, ownership boundaries, result format, and evidence requirements. Use fresh critics when practical. The parent owns synthesis and integration and normally consolidates results into Gitea. Choose concurrency according to useful independent work and available resources, including other sessions; there is no agent-count quota.
Optimize reasoning effort for verified outcomes per cost, including review, retries, and repair:
- Main agent: default to high for planning, synthesis, integration, and acceptance decisions; medium for dispatching a settled plan and routine coordination.
- Subagents: default to medium for bounded implementation and targeted research; low for straightforward retrieval, mechanical edits, and running established checks; high for complex debugging, correctness review, adversarial analysis, or ambiguous design.
- Use extra-high selectively for difficult foundational semantic, methodological, formal, or cross-domain decisions. Reserve max for exceptional unresolved problems where additional reasoning is likely to justify its cost. Retain higher settings when evidence supports their value.
Allocate by task difficulty and consequences, not role or length alone. Start higher when a clearly difficult decision has expensive downstream consequences. Escalate for concrete reasoning difficulties; resolve missing evidence or unclear requirements at their source. Set subagent effort explicitly through supported controls to avoid unintended inheritance. Do not claim an unapplied change or alter global configuration merely to enforce these preferences.
## Memory and maintenance
Preserve concise records of decisions and rationale, useful experiments, failed approaches, validation evidence, and remaining work. Link substantial evidence rather than copying logs into indexes. Update the integrated checkpoint after meaningful integration; mark branch-specific evidence as such.
Consolidate duplicates, repair stale guidance, and remove obsolete temporary files alongside useful project work. Before removing material, check references, active ownership, and ongoing use; preserve unique knowledge and update affected links. Prefer closing, superseding, or archiving history over deletion. User input is excluded from autonomous cleanup.
If legacy local task ledgers or plans duplicate Gitea, migrate their live content once into existing or appropriately scoped issues, preserve useful decisions/evidence, and mark the old records superseded. Record the migration so later sessions do not repeat it. Do not create empty documents or maintain records for ceremony.
## Delivery
Publish versioned Gitea releases at meaningful working milestones; mark unfinished versions as prereleases. Check existing releases/tags and coordinate one owner and version before publishing. Build from an identified integrated commit and verify the packaged result and launch steps.
Include runnable builds or appropriate artifacts, concise release notes, build/run/deployment instructions as applicable, known limitations, tested platforms, and verification evidence. Confirm publication and artifact availability. Use existing authorized API access or project tooling when MCP lacks a needed release/upload operation. Do not silently replace published versions with different contents.
Generated
+10
View File
@@ -2139,6 +2139,15 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "oa-cli"
version = "0.2.0"
dependencies = [
"oa-core",
"serde",
"serde_json",
]
[[package]]
name = "oa-core"
version = "0.2.0"
@@ -2167,6 +2176,7 @@ dependencies = [
"serde_json",
"tempfile",
"tokio",
"unicode-segmentation",
]
[[package]]
+1 -1
View File
@@ -1,5 +1,5 @@
[workspace]
members = ["crates/oa-core", "crates/oa-desktop", "crates/oa-server"]
members = ["crates/oa-core", "crates/oa-desktop", "crates/oa-server", "crates/oa-cli"]
resolver = "2"
[workspace.package]
+57 -569
View File
@@ -1,589 +1,50 @@
You are the principal investigator, chief systems engineer, product architect, and engineering orchestrator responsible for taking this project from its current state to a coherent, validated, working release.
# Project goal
Your responsibility is not merely to write software. Your responsibility is to solve the underlying problem end-to-end.
## Objective
The project may involve research, domain understanding, methodology design, semantics, formal specification, product design, UX, architecture, experimentation, implementation, testing, documentation, and integration. Treat all of these as first-class work when they contribute to the objective.
Take the existing project to the complete, coherent, usable, and verified outcome defined by the user input below. Solve the underlying problem end-to-end, preserving useful work already completed.
Software is one means of realizing and testing the solution, not automatically the primary unit of progress.
Treat domain understanding, research, methodology, semantics, specification, product design, UX, architecture, experimentation, implementation, and validation as first-class work. Choose what most advances the objective rather than following a predetermined development sequence.
## Primary objective
Deliver the product described by the repository, specification, requirements, references, current implementation, and my instructions.
Persist until the intended outcome is genuinely achieved.
Do not stop at:
- research,
- planning,
- a plausible conceptual design,
- scaffolding,
- a compiling implementation,
- a happy-path prototype,
- or an apparently polished UI.
The final result must be conceptually coherent, technically sound, usable, implemented, and verified end-to-end.
Bias toward action and completion.
Resolve discoverable questions yourself through:
- repository exploration,
- web and literature research,
- primary documentation,
- inspection of comparable products,
- experiments and prototypes,
- implementation,
- runtime observation,
- Computer Use,
- automated evaluation,
- and independent agent review.
Ask me only when a consequential decision depends on information that cannot reasonably be discovered, inferred, experimented with, or treated as a reversible assumption.
If an unresolved question does not block independent work, continue working.
For reversible choices, make a reasonable assumption, record it, test its consequences, and revise it if evidence contradicts it.
## Operating model
Use an adaptive orchestrator-worker workflow.
Do not force the project through a predetermined software-development sequence.
At any given time determine what type of work most reduces uncertainty or advances the final objective:
- research,
- problem decomposition,
- requirements analysis,
- domain modeling,
- hypothesis generation,
- alternative design exploration,
- formalization,
- UX investigation,
- experimentation,
- prototyping,
- architecture,
- implementation,
- testing,
- runtime observation,
- or review.
Choose the next action based on the state of the whole project rather than simply taking the next coding task.
## Task Ledger
Maintain a concise durable Task Ledger in `harness/TASK_LEDGER.md`, unless an equivalent artifact already exists.
It should distinguish:
### Goal and success criteria
What outcome must ultimately exist.
### Verified facts
Facts established from authoritative sources, repository evidence, experiments, or direct observation.
### Given facts
Requirements or constraints explicitly supplied by me.
### Assumptions
Things currently treated as true but not yet verified.
### Open questions
Important unknowns.
### Candidate hypotheses / designs
Competing explanations or approaches that remain plausible.
### Decisions
Choices that have been made, together with their rationale and supporting evidence.
### Current strategy
The current high-level route toward the goal.
Do not silently convert an assumption into a fact.
Do not silently convert the first plausible idea into a decision.
Update the Task Ledger when important knowledge changes.
## Progress Ledger
Maintain `harness/STATE.md` as the Progress Ledger.
At meaningful checkpoints record:
- current objective,
- completed work,
- evidence obtained,
- unresolved issues,
- active experiments or implementations,
- what remains,
- whether meaningful progress is being made,
- and the highest-value next actions.
Periodically ask:
1. Are we measurably closer to the actual end goal?
2. Are we solving the right problem?
3. Are we repeating an approach that is not working?
4. Has new evidence invalidated part of the plan?
5. Are important unknowns being hidden underneath implementation work?
6. Would research, experimentation, redesign, or implementation produce the highest information gain now?
If progress has stalled or the plan rests on invalid assumptions, replan instead of repeatedly patching the current approach.
## Durable decisions and evidence
Maintain `harness/DECISIONS.md` for important long-lived decisions.
For consequential decisions capture:
- the problem,
- viable alternatives,
- relevant evidence,
- important tradeoffs,
- selected approach,
- why it was selected,
- and what evidence would justify revisiting it.
Maintain research / evidence notes when substantial external investigation is performed. Prefer primary and authoritative sources.
Do not create documentation merely for ceremony. These artifacts exist to preserve reasoning across long runs and fresh agent contexts.
## Multi-agent orchestration
Explicitly delegate work whenever independent parallel effort can reduce elapsed time, increase breadth, produce independent criticism, or protect the root agent's context.
Normally use approximately 3–5 concurrent subagents for substantial parallel work.
Do not spawn agents merely to satisfy a role list.
Create roles dynamically according to the problem.
Possible roles include:
- domain researcher,
- literature / standards researcher,
- comparable-product investigator,
- user-workflow analyst,
- requirements analyst,
- systems / semantics architect,
- formalization or language-design specialist,
- UX / interaction designer,
- alternative-solution generator,
- adversarial critic,
- experimental investigator,
- software architect,
- implementation worker,
- runtime / Computer Use evaluator,
- correctness reviewer,
- integration reviewer.
Give every subagent:
- one bounded question or responsibility,
- relevant context,
- clear boundaries,
- evidence expectations,
- and an explicit output format.
Independent agents should explore genuinely different questions or approaches rather than duplicating one another.
Use fresh agents for independent criticism when practical.
Do not let several agents concurrently edit the same files. Partition implementation ownership or use isolated worktrees.
The root orchestrator owns synthesis, decisions, integration, and final responsibility.
## Research loop: start broad, then narrow
For important areas where the solution is not yet known:
1. Map the problem space before committing to a solution.
2. Delegate independent research tracks in parallel.
3. Prefer authoritative and primary sources.
4. Study relevant existing systems, standards, methods, research, and user experience.
5. Separate observations from interpretations.
6. Synthesize findings and identify contradictions, gaps, and unanswered questions.
7. Decide whether another focused research wave is justified.
8. Stop researching when additional information has low expected value relative to designing or testing a solution.
Research should lead to decisions, hypotheses, requirements, experiments, or implementation—not accumulate indefinitely.
## Candidate-generation and idea-tournament loop
For consequential, uncertain, difficult-to-reverse design problems, do not accept the first reasonable solution.
Use an idea-tournament process.
### Generate
Produce several materially different candidate solutions or hypotheses.
Aim for diversity rather than cosmetic variations of one idea.
### Explore
Where useful, assign independent agents to develop promising candidates in greater depth.
### Reflect
Have independent critics examine candidates for:
- correctness,
- hidden assumptions,
- internal consistency,
- usability,
- conceptual simplicity,
- extensibility,
- failure modes,
- edge cases,
- compatibility with project goals,
- and evidence.
Critics should actively attempt to falsify proposals rather than merely improve them.
### Rank
Compare candidates against explicit criteria derived from the actual project goals.
Prefer pairwise comparison or an explicit decision matrix when the decision is sufficiently important.
Do not rank based on eloquence.
### Evolve
Improve the strongest candidates.
Combine complementary ideas when a hybrid is genuinely better.
Introduce a new candidate if criticism reveals that the existing candidate set shares the same flawed assumption.
### Decide
Select an approach only when enough evidence exists to justify convergence.
Record the decision and why alternatives lost.
Do not run a heavyweight idea tournament for trivial, local, or easily reversible decisions.
## Experiment-first behavior
When an important assumption can be tested cheaply, test it instead of debating it indefinitely.
Create focused experiments, prototypes, mockups, benchmarks, sample models, formal examples, interaction trials, or throwaway implementations to reduce uncertainty.
An experiment should state:
- question,
- competing expectations,
- method,
- observable result,
- conclusion,
- and implications.
Do not confuse a prototype used to answer a question with production architecture.
Delete or isolate experimental code when it should not become part of the final product.
## Evaluator-driven improvement
Whenever practical, convert subjective progress into explicit evaluation.
Before optimizing a solution, determine how quality can be measured.
Possible evaluators include:
- formal invariants,
- consistency checks,
- parsers / validators,
- reference examples,
- round-trip tests,
- transformation tests,
- compatibility tests,
- usability scenarios,
- task-completion tests,
- latency / performance measurements,
- automated tests,
- visual comparison,
- LLM judges with explicit rubrics,
- and independent human-like review using Computer Use.
For important artifacts define evaluation criteria before repeatedly modifying them.
Generate or modify candidates, evaluate them, diagnose deficiencies, and retain improvements.
Do not optimize against an evaluator that is a poor proxy for the real objective.
## Requirements and specification
Treat requirements and specifications as living engineering artifacts, not immutable text and not merely documentation written after implementation.
When the project involves new semantics, methods, languages, protocols, information models, or user workflows, these are primary product artifacts.
Make them precise enough that independent implementations could reach compatible behavior.
Important concepts should have:
- unambiguous definitions,
- scope,
- relationships,
- invariants,
- valid and invalid examples,
- lifecycle / transformation rules where relevant,
- edge cases,
- and externally observable behavior.
Maintain traceability from important requirements and semantic decisions to their realization and verification where practical.
When implementation exposes ambiguity or contradiction in the specification, resolve the underlying specification problem rather than merely encoding arbitrary behavior.
## Product and user-centered reasoning
Continuously reason from the target user's actual work rather than from implementation convenience.
Identify important user goals, workflows, decisions, information needs, failure modes, and repetitive tasks.
When studying an existing product:
Do not merely clone features.
For each important capability determine:
1. What underlying user need does it satisfy?
2. What underlying domain concept does it represent?
3. What is powerful about the existing solution?
4. What causes friction or confusion?
5. Which limitations are accidental implementation / history artifacts?
6. What alternative interaction or semantic model could satisfy the need better?
7. How can the improvement be evaluated?
Preserve valuable domain knowledge without preserving legacy UX unnecessarily.
## Planning
Maintain a living execution plan in `harness/PLAN.md` unless an equivalent already exists.
The plan may contain research, specification, experimentation, design, implementation, integration, and evaluation milestones.
Prefer milestones that resolve important uncertainty or create observable useful capability.
Do not structure the entire plan around software modules unless that genuinely matches the problem.
Identify dependencies between conceptual decisions and implementation work.
Do not implement large amounts of downstream functionality on top of uncertain foundational assumptions without first testing those assumptions.
Revise the plan when evidence demands it.
## Engineering execution
When software implementation is the appropriate next action, act as a strong end-to-end engineering team.
Prefer the simplest architecture that cleanly satisfies known requirements.
Design for long-term maintainability and extensibility where those qualities are genuinely required, while avoiding speculative abstraction.
Reuse appropriate existing conventions.
Keep responsibilities and interfaces explicit.
Fix root causes rather than accumulating workarounds.
Avoid unrelated refactoring.
Delete obsolete scaffolding and abandoned approaches.
Treat warnings, crashes, serious runtime errors, corrupted state, and obvious interaction defects as evidence requiring investigation.
For an implementation milestone:
1. Define expected observable behavior.
2. Inspect relevant architecture and constraints.
3. Implement the smallest coherent solution.
4. Build it.
5. Run it.
6. Exercise real behavior.
7. Collect evidence.
8. Diagnose failures.
9. Fix root causes.
10. Run proportionate automated verification.
11. Perform real runtime / UI verification when user-facing behavior changed.
12. Update durable state.
13. Continue.
Do not declare functionality complete from code inspection alone.
## GUI and Computer Use
For GUI applications, visual behavior and interaction quality are part of correctness.
Use Computer Use and other available debugging capabilities against the actual running application.
After meaningful UI changes:
- launch the real application,
- exercise realistic workflows,
- enter representative data,
- inspect resulting states,
- resize or navigate where relevant,
- capture and inspect visual results,
- inspect logs / console / network information when useful,
- identify problems,
- fix them,
- relaunch,
- and verify the fix.
Actively examine:
- information hierarchy,
- workflow discoverability,
- cognitive load,
- unnecessary mode switching,
- consistency,
- navigation,
- interaction feedback,
- selection behavior,
- error recovery,
- loading / empty / failure states,
- accessibility,
- clipping,
- overlap,
- alignment,
- spacing,
- typography,
- sizing,
- responsiveness,
- focus states,
- disabled states,
- and unfinished-looking UI.
Do not equate technical functionality with usability.
## Independent review
Use independent reviewers throughout the project, but select reviewers according to the artifact being evaluated.
A conceptual or semantic design may need:
- domain review,
- consistency review,
- adversarial / falsification review,
- simplicity review,
- and user-workflow review.
A language may need:
- ambiguity review,
- grammar / round-trip review,
- human-editability review,
- AI-editability review,
- and version-control review.
An implementation may need:
- correctness review,
- architecture review,
- requirements review,
- runtime QA,
- and UI / UX review.
A final release should receive a cross-disciplinary review rather than only a code review.
Require concrete findings and evidence.
For material findings:
1. verify the finding,
2. determine whether the problem is conceptual, specification-level, architectural, implementation-level, or interaction-level,
3. fix it at the correct layer,
4. reevaluate affected downstream artifacts,
5. verify again.
## End-state evaluation
Evaluate outcomes rather than demanding one predetermined path.
At meaningful checkpoints ask whether the resulting state satisfies the actual objective.
Before final completion, run a fresh independent end-state review covering at least:
1. Requirements completeness.
2. Domain / semantic coherence.
3. Internal consistency and edge cases.
4. Target-user workflows and usability.
5. Architecture and long-term maintainability.
6. Implementation correctness and regression risk.
7. Actual runtime behavior.
8. Documentation / specification adequacy.
9. Remaining assumptions, unknowns, and risks.
Resolve blocking and high-impact findings before declaring completion.
Follow `AGENTS.md` for execution, coordination, record ownership, and delivery. Continue until the completion criteria are met; plans, prototypes, successful builds, and individual milestones are intermediate results. Do not silently weaken requirements or completion standards to finish sooner.
## Completion criteria
Do not weaken completion criteria merely because substantial time or context has been consumed.
Apply these criteria to the scope requested by the user:
The project is complete only when all applicable conditions are satisfied:
- Required capabilities address the intended problem and support realistic user workflows.
- Important concepts, semantics, and interfaces are precise and consistent; specifications agree with implemented behavior.
- Consequential decisions have evidence and rationale. Major assumptions have been validated, explicitly accepted, or isolated without compromising required behavior.
- The result builds, runs, and operates on the required platforms. Relevant tests and evaluators pass; integrations, edge cases, and failure states have been exercised against the actual result.
- User-facing behavior and usability have been exercised directly, using representative data and workflows.
- A fresh independent review covers requirements, domain/semantic consistency, usability, architecture, correctness, regressions, runtime behavior, documentation, and remaining risks. Valid material findings are addressed, blocking and high-impact issues resolved, and affected behavior reverified; no known blocker or high-severity defect remains.
- Deliverables are maintainable, appropriately extensible, documented, and published as required by `AGENTS.md`. Obsolete scaffolding is removed; documentation and shared state accurately describe the result. Another engineer or agent can reproduce the verification and continue the work.
- The intended user problem is coherently addressed.
- Important domain concepts and semantics are explicit and internally consistent.
- Important design decisions are supported by evidence rather than accidental first choices.
- Major unresolved assumptions have either been validated, explicitly accepted, or isolated.
- Required specifications accurately describe implemented behavior.
- Primary user workflows are implemented.
- The application builds and launches reliably.
- Real workflows have been exercised on the running application.
- Important edge / failure / empty / loading states behave sensibly.
- Relevant automated evaluators and tests pass.
- UI/interaction quality has been inspected on the actual application.
- No known blocker or high-severity defects remain.
- Independent cross-disciplinary review has been completed.
- Valid material findings have been addressed and reverified.
- Architecture is understandable and appropriately extensible.
- Temporary and obsolete scaffolding has been removed.
- Durable project state reflects reality.
- Another competent engineer or agent could understand, build, run, evaluate, and continue the project from the repository.
If context becomes large, reload the durable ledgers and authoritative project artifacts rather than reconstructing history from conversational memory.
If required work or verification is blocked, record the specific gap and continue independent useful work without claiming completion.
## Final handoff
Only when the completion criteria are satisfied, provide a concise handoff covering:
Link the delivered result and summarize capabilities, discoveries, architecture and decisions, relevant rejected approaches, verification and independent review, remaining limitations, and exact build/run/deployment/verification instructions. Refer to authoritative records instead of repeating the project history.
- what now exists,
- major discoveries,
- important conceptual / semantic decisions,
- important rejected alternatives,
- architecture,
- major implementation work,
- experiments and evaluations performed,
- real workflows exercised,
- independent reviews performed,
- remaining non-blocking limitations or uncertainties,
- and exact build / run / verification instructions.
## USER-OWNED APPLICATION SPECIFICATION
Do not provide routine narration merely for the sake of narration.
Spend effort on solving, testing, evaluating, integrating, and improving the project.
## Model and reasoning allocation
The root orchestrator should use the highest available reasoning effort because its primary responsibility is high-leverage synthesis, planning, conceptual decisions, integration, and deciding what work should happen next.
Do not automatically use maximum reasoning for every delegated task.
Allocate reasoning effort according to the task:
* Use maximum reasoning for foundational semantic, methodological, formalization, metamodel, language-design, cross-domain synthesis, and other consequential hard-to-reverse decisions.
* Use extra-high reasoning for difficult architecture, UX / workflow design, alternative-solution evaluation, adversarial conceptual review, and unusually difficult implementation problems.
* Use high reasoning for normal implementation, correctness review, targeted research, Computer Use evaluation, debugging, and complex but bounded analysis.
* Use medium reasoning for code mapping, repository exploration, mechanical implementation, builds, routine tests, log inspection, and other well-defined work.
* Use lower effort only for genuinely trivial mechanical tasks.
Prefer spending additional reasoning on upstream decisions whose consequences propagate through many downstream artifacts.
Do not spend maximum reasoning merely because a task is long. Distinguish computational volume from conceptual difficulty.
When spawning a subagent, explicitly choose an appropriate reasoning effort when the interface permits it rather than blindly inheriting the root agent's effort.
Use GPT-6 Astra model exclusively.
The block below accepts all project-specific requirements, context, priorities, and feedback, with any headings or organization the user chooses.
<!-- BEGIN USER-OWNED APPLICATION SPECIFICATION -->
## Application specification
Create a modern clone of ARCADIA Capella using Rust Iced framework. It should be cross platform (supporting Windows and Linux at least). Use Rust exclusively (wherever possible & practical). Focus on making the application modern, extensible, intuitive and easy to use. Try to address all known problems / downsides of the original ARCADIA Capella software. When writing code and making software architecture decisions focus on long term maintainability, extensibility while making the project friendly for active development
### General description
Create a modern clone of ARCADIA Capella using Rust Iced framework. It should be cross platform (supporting Windows and Linux at least). Use Rust exclusively (wherever possible & practical).
Focus on making the application modern, extensible, intuitive and easy to use. When writing code and making software architecture decisions focus on long term maintainability, extensibility while making the project friendly for active development
Try to address all known problems / downsides of the original ARCADIA Capella software.
Version 1 MVP in the form of working Operational Analysis layer is done.
Version 2 MVP in the form of working Operation Analysis + System Analysis is done.
You should make the 1-st release version of the project with fully feature complete Operation Analysis + System Architecture, according to this application specification.
And the remaining ARCADIA layers will be added in future releases.
You should make the 1st release version of the project with fully feature complete Operation Analysis + System Architecture, according to this application specification.
Remaining ARCADIA layers will be added in future releases.
Transitioning of model elements between layers (in both directions) should be easy and intuitive.
@@ -596,24 +57,50 @@ You should implement most of the ARCADIA semantics, while adressing original ARC
The original ARCADIA Capella UI is very bloated, clunky, unintuitive and hard to use. In addition, it lacks a number of quality of life features. You should at all cost avoid making the same mistakes as the developers of the original ARCADIA Capella
You should explore the functionality & UI of the original ARCADIA Capella to take the most useful and powerful ideas from it. While making them modern, easy to use and understand
Original ARCADIA Capella uses "nested" elements to signify various relationships, such as "Containment relationship" or "Deployment relationship". Instead of using visually noisy arrows / graph edges. I pretty much like this approach and want to see something similar to it in this application
Original ARCADIA Capella uses "nested" elements (visually nested) to signify various relationships, such as "Containment relationship" or "Deployment relationship". Instead of using visually noisy arrows / graph edges. I pretty much like this approach and want to see something similar to it in this application
To explore the original ARCADIA Capella UI & semantics you may use web resources / web search / official and community docs. Or open the original ARCADIA Capella installed on this desktop machine for exploration
Your second, parallel goal is to create a textual representation for ARCADIA, similar to SysML v2. So a user can either edit a system model using UI (graphical representation) or text. Both graphical and textual representations should be fully equivalent and have full parity. You may take inspiration from SysML v2. Both graphical and textual representations should have one overarching, concrete, unambiguous spec in reST format, similar to SysML v2 strictness
Your second, parallel, goal is to create a textual representation for ARCADIA, similar to SysML v2. So a user can either edit a system model using UI (graphical representation) or text. Both graphical and textual representations should be fully equivalent and have full parity. You may take inspiration from SysML v2. Both graphical and textual representations should have one overarching, concrete, unambiguous spec in reST format, similar to SysML v2 strictness
The application should support both forms of editing
The textual representation should be easily editable and understandble for both humans and AI
The textual representation should be easily handled by version control tools such as Git
The underlying semantics of the textual / graphical representation should support traceability of various development artifacts (for example - code, CAD files, electrical schematics, Docx files and anything in between)
The textual representation editor should have syntax highlighting and linting
The project should also have a backend server / service that you can deploy. The backend server purpose is to give programmatic access to the stored system models. So, there also should be a clear REST API for querying, editing and working with models. The purpose of the REST API is to allow automation, scripting and AI-driven workflows
The project should also have a backend server / service that you can deploy. The backend server purpose is to give programmatic access to stored system models. So, there also should be a clear REST API for querying, editing and working with models. The purpose of the REST API is to allow automation, scripting and AI-driven workflows
An example could be - writing custom plugins for various domain specific software (version control software, CAD software etc...) to establish traceability between development artifacts and a system model
The desktop application should contain built-in guide / help / tutorial with clear and concise info on modeling concepts, underlying semantics, common modeling scenarios and application features / how to use the app. It should be easy to navigate and explore
@Computer
### Semantic model / metamodel
Requirements:
- Textual notation.
- Graphical notation.
- Constraints and calculations.
- Quantities and units.
- State and behavior modeling.
- Reusable model libraries.
- Strong, standardized specification.
- Strong, standardized API.
#### Textual notation
Requirements:
- Should not have visual noise and clutter.
- Should have means to reduce cognitive load when developing system models.
- Should be easily editable by humans. And AI agents.
- System engineers should be able to easily develop system models (both big and small) using textual notation only.
### UI
The focus should be on reducing system engineer's cognitive load when creating / working with a system model. Regardless of the model size (big or small).
Long, homogeneous lists in the UI that require user to scroll far or search for a desired option for too long - is a bad design.
## Active issues / current guidance
Right now, the behavior & scenarios UI is hard to use. It contains static / uneditable diagrams, which is highly dubious. In addition, user workflow with the behavior & scenarios UI differ significantly from the other UIs - you can't edit diagrams and instead suppose to click on menus for model development
@@ -621,3 +108,4 @@ Right now, the behavior & scenarios UI is hard to use. It contains static / uned
---
The current project state is nowhere near the usability of the original ARCADIA Capella (despite its downfalls) or its functionality / semantics. You should focus on that by studying original ARCADIA Capella very closely and implementing its features in a modern, ergonomic and easy to use way
<!-- END USER-OWNED APPLICATION SPECIFICATION -->
+23 -2
View File
@@ -18,12 +18,14 @@ From the repository root:
cargo run --locked -p oa-desktop --bin arcadia-studio
```
Build both desktop and server release executables:
Build the desktop, server and source CLI release executables:
```sh
cargo build --release --workspace --locked
```
Stage the complete teaching examples beside these executables so **Home → Open water-pressure walkthrough** works in an optimized source build. From the repository root, use `Copy-Item -Recurse -Force examples target/release/` in PowerShell or `cp -R examples target/release/` on Linux. If you set a different Cargo target directory or pass `--target`, use that build's executable directory instead of `target/release`. The packaging scripts include these examples automatically.
Run `target/release/arcadia-studio.exe` on Windows or `./target/release/arcadia-studio` on Linux. No task-specific cache directory is required. A graphical desktop and compatible graphics driver are required; the application uses Iced 0.14 with WGPU and X11/Wayland support. It does not require Java, Eclipse, a browser, an account or a running server.
An optional Windows path uses the `stable-x86_64-pc-windows-gnullvm` Rust toolchain with LLVM-MinGW. Put LLVM-MinGW's `bin` directory on `PATH`, including `clang.exe` and `llvm-windres.exe`, then run:
@@ -49,17 +51,34 @@ The earlier V2 package record describes `dist/ArcadiaStudio-Windows.zip` and `di
The current [Windows packaging instructions](docs/windows-packaging.md) stage both executables, verify their runtime DLL dependencies, and produce an archive with checksums and provenance. Native acceptance fixtures and independent expected results are retained in [the release fixture directory](harness/fixtures/release1-native/README.md).
## Work with source files from the command line
The `arcadia` CLI checks an explicit `.arcadia` entry file and its includes without
starting the desktop or server:
```sh
cargo run --locked -p oa-cli --bin arcadia -- check examples/text-first/project.arcadia
```
`stabilize ROOT` previews the identities needed for persistent editing;
`rename-alias ROOT OLD NEW` previews an alias change and its references. Add
`--write` to save that invocation's candidate, or `--json` for structured
diagnostics and changed-file text/diffs. Preview leaves files untouched.
See [source CLI commands](docs/source-cli.md) for examples and exit codes.
## Model operational needs and system responsibilities
The desktop starts at **Home**, where you can create a model, open a local project, continue recent files or recover retained work. Missing recent files stay visible with **Locate**; **Remove** only forgets their history entry. Choose **Explore rescue example** for a model combining people, an organization, a System of Interest, information and physical exchange items, normal/degraded behavior and code/CAD evidence.
The example opens in an architecture view with work nested inside its performers. Each layer also has dedicated functional dataflow and mission/capability views. Regenerate both example formats with matching identities using `cargo run --locked -p oa-core --example generate_demo`; the command verifies both saved models. Historical migration fixtures remain separate.
The architecture diagram has a compact side palette; expand it to see tool names. Choose an element tool, click its position, enter its name in place and press **Enter**. **Escape** cancels the draft. Select a card and press **F2** to rename it. Right-click a participant and choose **Add activity** or **Add function** to create work with its performer allocation in one undoable edit. Ordinary palette creation leaves work unallocated. **Add existing** places a compatible model element in the current view; **Include already shown** offers **Locate** for existing placements.
1. In **Operational Analysis**, create entities, actors, activities and capabilities. Use **In selected** for semantic decomposition and **Allocated** for new leaf work performed by a participant. Activities can decompose independently of their performer; roles capture shared operational responsibility. **Communication means** connects operational Entities or Actors directly, with direction and carried items; **Exchange allocation** associates the Interactions it carries. Operational communication requires no system ports.
2. Switch to **System Analysis**. Define one System of Interest and external Actors, then functions, missions, capabilities and functional chains. The system boundary has no internal LA/PA components in this version.
3. Select a System or System Actor and choose **Add port** in the browser or Properties. Choose Standard or Flow, then **Create port**. Use **Boundary exchange** in the diagram palette, or **Draw boundary exchange** on a selected visible source port, to connect compatible component ports directly. Create the other participant's matching port first if none exists. Functions expose separate input/output port actions; the **Functional exchange** palette tool connects them. **Actions** exposes other engineering relationships and off-diagram configuration; **Data & interfaces** holds reusable payloads and contracts.
4. Create focused views. **Containment** nests semantic children; **Architecture · allocated work** nests work within performers; **Functional dataflow** shows work decomposition and exchanges; **Missions & capabilities** shows objectives and participation. Dashed, italic parent work denotes an allocation summary inferred from its leaves. Ports appear on their owner's boundary. **View properties** stages saved line-kind, category, behavior-coverage, context, label and individual-hide choices. Right-click empty diagram space for **Duplicate view**, retaining the same semantic records in an independent presentation. Placement **Hide** removes a placement; line hiding retains its relationship; **Delete from model** removes semantics and dependent references.
5. In **Behavior & scenarios**, author processes/chains, scenario lifelines and ordered occurrences, including repeated uses of the same exchange and explicit fragments/branches. Select a State machine and choose **Create diagram** to place its existing regions and states/modes in a saved view. State machines also support availability and state-action links. Conditions are descriptive, not executable simulation code. Behavior and Data authoring preserve your other diagram layouts.
5. In **Behavior & scenarios**, choose **New**, select Operational process (OA), Functional chain (SA), Scenario or State machine, then enter its name. For a scenario, choose what its lifelines represent; **Add optional context…** links a context without changing its layer-root ownership. For a state machine, search for its existing participant owner; optionally select **Create the first state view**. Choose **Create and open** to start authoring work contributions, lifelines, ordered occurrences or states. **Cancel · Esc** discards the creation draft; if another dialog is open, Escape dismisses that dialog first. Creation and its optional first view form one Undo operation. A new state machine starts without regions or states; **Create diagram** can later place its existing contents in a saved view. Behavior supports repeated exchanges, fragments/branches, availability and state-action links. Conditions are descriptive, not executable simulation code. Behavior and Data authoring preserve your other diagram layouts.
6. Select an OA or SA element and choose **Create / update counterpart**. Review dependency scope and counterpart choices, then apply one undoable transaction. For an existing trace, **Review semantics…** compares typed content and prior observations, with explicit keep, take or final-value decisions for supported structural, scalar, constructor, individual-row and ordered-collection changes. Open additional trace rows and choose **Include this row** to combine reviewed source contributions into one validated Apply/Undo transaction; conflicting decisions require resolution. Review does not automatically synchronize the perspectives. **Attachments** reviews an existing child’s parent or a relationship endpoint. **Artifact links** reviews metadata, owner moves, explicit source-absence deletion and creation of a reserved counterpart after all introduced metadata is reviewed. These controls have automated coverage; their native acceptance remains pending. Manual traces link other engineering correspondences. **Traceability** also edits artifact URI, revision and fragment metadata.
7. Review **Model checks**, then save `.arcadia` source or `.json`. Warnings allow incomplete authoring; structural errors preserve the committed model and rejected draft.
@@ -83,6 +102,8 @@ In the state view, **Select / move** drags vertices and containers without repar
## Edit the same model as text
For a short text-only start, open [the distress-response model](examples/text-first/project.arcadia) and follow [its three-step walkthrough](examples/text-first/README.md). Four declarations and three explicit relationships describe participants, work, responsibility and an exchange; no diagram or handwritten UUID is needed.
**Textual model** has a source-file folder tree, per-file drafts, syntax highlighting, live linting and explicit **Apply all**. Collapse/Locate navigate the hierarchy; collapsed folders retain draft and diagnostic indicators. Right-click a file for contextual actions. Projects can use relative includes to organize source into files. For example:
```text
+15
View File
@@ -0,0 +1,15 @@
[package]
name = "oa-cli"
version.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true
[[bin]]
name = "arcadia"
path = "src/main.rs"
[dependencies]
oa-core = { path = "../oa-core" }
serde.workspace = true
serde_json.workspace = true
+451
View File
@@ -0,0 +1,451 @@
//! Source-only authoring. Preparation captures bytes; only core owns disk writes.
use oa_core::{
Command, language,
persistence::MigrationReport,
prepare_command,
source_files::{self, DiskProject, SourceFileError},
source_project::{self, CompiledSourceProject, ProjectLimits, SourceDiagnostic},
validation::Severity,
};
use serde::Serialize;
use std::path::{Path, PathBuf};
pub const LIMITS: ProjectLimits = ProjectLimits {
max_files: 256,
max_include_depth: 32,
max_include_edges: 1024,
model: language::ParseLimits {
max_source_bytes: 64 * 1024 * 1024,
max_graph_objects: 100_000,
max_records: 250_000,
max_step_references: 500_000,
},
};
#[derive(Debug, Clone)]
pub enum Operation {
Check,
Stabilize,
RenameAlias { old: String, new: String },
}
#[derive(Debug, Clone, Serialize)]
pub struct Location {
pub path: String,
/// Half-open UTF-8 byte offsets. Line/column are one-based Unicode scalars.
pub start: usize,
pub end: usize,
pub line: Option<usize>,
pub column: Option<usize>,
}
#[derive(Debug, Clone, Serialize)]
pub struct Diagnostic {
pub severity: String,
pub code: String,
pub message: String,
pub coordinate_space: String,
pub location: Option<Location>,
pub related: Vec<Location>,
pub object_id: Option<String>,
pub field: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct Change {
pub path: String,
/// Exact captured and proposed UTF-8 text, including line endings.
pub before: String,
pub after: String,
pub diff: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct Report {
pub format_version: u32,
pub entry: String,
pub operation: String,
pub status: String,
pub exit_code: u8,
/// True only after a successful changed save. False on failure does not
/// promise rollback: consult the specific error/recovery outcome.
pub written: bool,
pub diagnostics: Vec<Diagnostic>,
pub changes: Vec<Change>,
pub identities_to_stabilize: usize,
pub migration: Option<MigrationReport>,
pub notes: Vec<String>,
}
impl Report {
pub fn failure(
entry: &Path,
operation: &str,
exit_code: u8,
code: &str,
message: String,
) -> Self {
let mut report = Self::empty(entry, operation);
report.fail(exit_code, code, message);
report
}
fn empty(entry: &Path, operation: &str) -> Self {
Self {
format_version: 1,
entry: entry.to_string_lossy().into_owned(),
operation: operation.into(),
status: "checked".into(),
exit_code: 0,
written: false,
diagnostics: vec![],
changes: vec![],
identities_to_stabilize: 0,
migration: None,
notes: vec![],
}
}
fn fail(&mut self, exit_code: u8, code: &str, message: String) {
self.status = "rejected".into();
self.exit_code = exit_code;
self.diagnostics.push(Diagnostic {
severity: "error".into(),
code: code.into(),
message,
coordinate_space: "operation".into(),
location: None,
related: vec![],
object_id: None,
field: None,
});
}
}
fn located(issue: SourceDiagnostic, space: &str) -> Diagnostic {
let d = issue.diagnostic;
Diagnostic {
severity: match d.severity {
Severity::Error => "error",
Severity::Warning => "warning",
}
.into(),
code: d.code,
message: d.message,
coordinate_space: space.into(),
location: Some(Location {
path: issue.path,
start: d.span.start,
end: d.span.end,
line: Some(d.line),
column: Some(d.column),
}),
related: issue
.related
.into_iter()
.map(|at| Location {
path: at.path,
start: at.span.start,
end: at.span.end,
line: None,
column: None,
})
.collect(),
object_id: d.object_id.map(|id| id.to_string()),
field: d.field,
}
}
fn disk_error(report: &mut Report, error: SourceFileError) {
let code = match &error {
SourceFileError::Conflict(_) => "external_change",
SourceFileError::RecoveryRequired(_) => "recovery_required",
SourceFileError::RecoveryFailed { .. } => "recovery_failed",
SourceFileError::Path(_) => "unsafe_path",
SourceFileError::Limit(_) => "source_limit",
SourceFileError::UnknownExpectation(_) => "unknown_disk_expectation",
SourceFileError::Invalid(_) => "invalid_save_candidate",
SourceFileError::Io { .. } => "io_error",
};
report.fail(3, code, error.to_string());
}
/// A candidate owns its original expectations and is consumed by saving.
/// Callers cannot replace its source/model/expectations after preparation.
pub struct Prepared {
report: Report,
snapshot: DiskProject,
compiled: CompiledSourceProject,
mutable: bool,
}
impl Prepared {
pub fn report(&self) -> &Report {
&self.report
}
pub fn into_report(self) -> Report {
self.report
}
pub fn write(mut self) -> Report {
if !self.mutable {
self.report
.fail(2, "usage", "check/lint does not accept --write".into());
return self.report;
}
match source_files::save_project(&self.snapshot, &self.compiled, LIMITS) {
Ok(_) => {
self.report.written = !self.report.changes.is_empty();
self.report.status = if self.report.written {
"written"
} else {
"unchanged"
}
.into();
}
Err(error) => disk_error(&mut self.report, error),
}
self.report
}
}
pub fn prepare(entry: &Path, operation: Operation) -> Result<Prepared, Box<Report>> {
let name = match operation {
Operation::Check => "check",
Operation::Stabilize => "stabilize",
Operation::RenameAlias { .. } => "rename-alias",
};
let mut report = Report::empty(entry, name);
let snapshot = source_files::load_project(entry, LIMITS).map_err(|error| {
disk_error(&mut report, error);
Box::new(report.clone())
})?;
let raw = snapshot.reachable_bundle();
// The loader may include warnings from stabilized compilation. Only its
// discovery/IO diagnostics are authoritative on the original bytes.
let mut diagnostics: Vec<_> = snapshot
.diagnostics
.iter()
.filter(|d| {
matches!(
d.diagnostic.code.as_str(),
"pending_save"
| "source_encoding"
| "source_limit"
| "invalid_path"
| "missing_include"
| "project_limit"
)
})
.cloned()
.collect();
for diagnostic in source_project::lint_bundle(&raw, LIMITS) {
if !diagnostics.contains(&diagnostic) {
diagnostics.push(diagnostic);
}
}
let disk_refusal = snapshot.diagnostics.iter().any(|d| {
matches!(
d.diagnostic.code.as_str(),
"pending_save" | "missing_include" | "invalid_path"
)
});
let invalid = diagnostics
.iter()
.any(|d| d.diagnostic.severity == Severity::Error);
report.diagnostics = diagnostics
.into_iter()
.map(|d| located(d, "input"))
.collect();
if invalid {
// Do not run declaration analysis or any updater on invalid raw text.
// Failed lint may also have allocated provisional object identities.
for diagnostic in &mut report.diagnostics {
diagnostic.object_id = None;
}
report.status = "invalid".into();
report.exit_code = if disk_refusal { 3 } else { 1 };
return Err(Box::new(report));
}
let explicit_ids: std::collections::BTreeSet<_> = raw
.files
.values()
.flat_map(|text| language::analyze(text).declarations)
.filter_map(|d| d.id.map(|id| id.to_string()))
.collect();
for diagnostic in &mut report.diagnostics {
if diagnostic
.object_id
.as_ref()
.is_some_and(|id| !explicit_ids.contains(id))
{
diagnostic.object_id = None;
}
}
let mut compiled = source_project::compile_bundle(&raw, LIMITS).map_err(|diagnostics| {
report.status = "invalid".into();
report.exit_code = 1;
report
.diagnostics
.extend(diagnostics.into_iter().map(|d| located(d, "candidate")));
Box::new(report.clone())
})?;
report.identities_to_stabilize = raw
.files
.values()
.map(|text| {
language::analyze(text)
.declarations
.into_iter()
.filter(|d| d.id.is_none())
.count()
})
.sum();
report.migration = compiled.decoded.migration.clone();
let mutable = !matches!(operation, Operation::Check);
if let Operation::RenameAlias { old, new } = operation {
let project = &compiled.decoded.project;
let Some((id, _)) = project
.identities()
.into_iter()
.find(|(_, alias)| *alias == old)
else {
report.fail(
1,
"unknown_alias",
format!("No declaration has alias '{old}'."),
);
return Err(Box::new(report));
};
let command = prepare_command(project, &Command::SetAlias(id, new)).map_err(|error| {
report.fail(1, "semantic_command_rejected", error.to_string());
Box::new(report.clone())
})?;
compiled = source_project::update_bundle_with_identity_map(
&compiled,
&command.project,
&command.identity_map,
LIMITS,
)
.map_err(|diagnostics| {
report.status = "invalid".into();
report.exit_code = 1;
report
.diagnostics
.extend(diagnostics.into_iter().map(|d| located(d, "candidate")));
Box::new(report.clone())
})?;
}
if mutable {
for (path, after) in &compiled.bundle.files {
let before = raw.files.get(path).cloned().unwrap_or_default();
if &before != after {
report.changes.push(Change {
path: path.clone(),
diff: unified_diff(path, &before, after),
before,
after: after.clone(),
});
}
}
report.status = "preview".into();
report.notes.push("This invocation's complete proposed changed-file texts and diffs are reported. Without --write nothing is saved; a later invocation may allocate different missing UUIDs. This is not a saved plan or a digest of unchanged dependencies.".into());
}
report.notes.push("Diagnostic object IDs are omitted for declarations without authored IDs; lint's temporary identities are not the candidate's identities.".into());
report.notes.push("Disk writes use the core journal/conflict checks. This is not cross-process atomic visibility or a power-loss durability guarantee; pending saves are never implicitly recovered. A failed write may require explicit journal recovery.".into());
Ok(Prepared {
report,
snapshot,
compiled,
mutable,
})
}
/// One bounded hunk per file: common prefix/suffix plus three context lines.
/// Linear in input size; no quadratic LCS allocation for large modules.
fn unified_diff(path: &str, before: &str, after: &str) -> String {
let a: Vec<_> = before.split_inclusive('\n').collect();
let b: Vec<_> = after.split_inclusive('\n').collect();
let prefix = a.iter().zip(&b).take_while(|(a, b)| a == b).count();
let suffix = a[prefix..]
.iter()
.rev()
.zip(b[prefix..].iter().rev())
.take_while(|(a, b)| a == b)
.count();
let start = prefix.saturating_sub(3);
let a_end = (a.len() - suffix + 3).min(a.len());
let b_end = (b.len() - suffix + 3).min(b.len());
let a_start = if a_end == start { start } else { start + 1 };
let b_start = if b_end == start { start } else { start + 1 };
let mut out = format!(
"--- a/{path}\n+++ b/{path}\n@@ -{a_start},{} +{b_start},{} @@\n",
a_end - start,
b_end - start
);
let mut emit = |prefix: char, line: &str| {
out.push(prefix);
out.push_str(line);
if !line.ends_with('\n') {
out.push_str("\n\\ No newline at end of file\n");
}
};
for line in &a[start..prefix] {
emit(' ', line);
}
for line in &a[prefix..a.len() - suffix] {
emit('-', line);
}
for line in &b[prefix..b.len() - suffix] {
emit('+', line);
}
for line in &a[a.len() - suffix..a_end] {
emit(' ', line);
}
out
}
pub struct Arguments {
pub entry: PathBuf,
pub operation: Operation,
pub write: bool,
pub json: bool,
}
pub const HELP: &str = "arcadia check ROOT [--json]\narcadia lint ROOT [--json]\narcadia stabilize ROOT [--write] [--json]\narcadia rename-alias ROOT OLD NEW [--write] [--json]\n\nROOT is an explicit .arcadia entry file; includes resolve relative to it.\nMutation commands preview by default. --write applies this invocation's candidate.\nMissing UUIDs in a preview are provisional until written. No canonical formatting.\nExit: 0 valid/success (engineering warnings allowed), 1 invalid source or semantic\ncommand rejection, 2 usage, 3 disk/path/conflict/pending-journal/output failure.\nNo implicit journal recovery. Limits: 64 MiB, 256 files, include depth32/edges1024,\n100000 graph objects, 250000 records, 500000 ordered references.\n";
pub fn arguments(args: &[std::ffi::OsString]) -> Result<Arguments, String> {
let mut positional = Vec::new();
let (mut write, mut json) = (false, false);
for arg in args {
if arg == "--write" && !write {
write = true;
} else if arg == "--json" && !json {
json = true;
} else if arg.to_string_lossy().starts_with('-') {
return Err(format!(
"Unknown or repeated option: {}",
arg.to_string_lossy()
));
} else {
positional.push(arg);
}
}
let command = positional.first().and_then(|s| s.to_str()).unwrap_or("");
let operation = match (command, positional.len()) {
("check" | "lint", 2) if !write => Operation::Check,
("stabilize", 2) => Operation::Stabilize,
("rename-alias", 4) => Operation::RenameAlias {
old: positional[2].to_str().ok_or("Alias must be UTF-8")?.into(),
new: positional[3].to_str().ok_or("Alias must be UTF-8")?.into(),
},
_ => return Err(HELP.into()),
};
Ok(Arguments {
entry: PathBuf::from(positional[1]),
operation,
write,
json,
})
}
+102
View File
@@ -0,0 +1,102 @@
use oa_cli::{Report, arguments, prepare};
use std::{
io::{self, Write},
path::Path,
process::ExitCode,
};
fn human_report(report: &Report, show_diff: bool) -> io::Result<()> {
let mut err = io::stderr().lock();
for d in &report.diagnostics {
let at = d
.location
.as_ref()
.map(|l| {
format!(
"{}:{}:{}",
l.path,
l.line.unwrap_or(0),
l.column.unwrap_or(0)
)
})
.unwrap_or_else(|| report.entry.clone());
writeln!(
err,
"{at}: {}[{}] ({}): {}",
d.severity, d.code, d.coordinate_space, d.message
)?;
}
writeln!(
err,
"{}: {} file changes; {} identities to stabilize",
report.status,
report.changes.len(),
report.identities_to_stabilize
)?;
if let Some(migration) = &report.migration {
writeln!(
err,
"Source migration: schema {} → {} (separate from identity stabilization)",
migration.from_schema, migration.to_schema
)?;
}
for note in &report.notes {
writeln!(err, "{note}")?;
}
if show_diff {
let mut out = io::stdout().lock();
for change in &report.changes {
write!(out, "{}", change.diff)?;
}
out.flush()?;
}
err.flush()
}
fn run() -> io::Result<u8> {
let args: Vec<_> = std::env::args_os().skip(1).collect();
if args.len() == 1 && (args[0] == "--help" || args[0] == "-h") {
io::stdout().write_all(oa_cli::HELP.as_bytes())?;
return Ok(0);
}
if args.len() == 1 && args[0] == "--version" {
writeln!(io::stdout(), "arcadia {}", env!("CARGO_PKG_VERSION"))?;
return Ok(0);
}
let json = args.iter().any(|a| a == "--json");
let mut displayed = false;
let report = match arguments(&args) {
Err(error) => Report::failure(Path::new(""), "usage", 2, "usage", error),
Ok(args) => match prepare(&args.entry, args.operation) {
Err(report) => *report,
Ok(prepared) => {
if args.write {
if !args.json {
human_report(prepared.report(), true)?;
displayed = true;
}
prepared.write()
} else {
prepared.into_report()
}
}
},
};
if json {
serde_json::to_writer_pretty(io::stdout().lock(), &report)?;
writeln!(io::stdout())?;
} else {
human_report(&report, !displayed)?;
}
Ok(report.exit_code)
}
fn main() -> ExitCode {
match run() {
Ok(code) => ExitCode::from(code),
Err(error) => {
let _ = writeln!(io::stderr(), "Output failed: {error}");
ExitCode::from(3)
}
}
}
+425
View File
@@ -0,0 +1,425 @@
use oa_cli::{LIMITS, Operation, Prepared, prepare};
use oa_core::{Id, Project, source_files, source_project};
use serde_json::Value;
use std::{
collections::BTreeMap,
fs,
path::{Path, PathBuf},
process::Command,
};
struct Fixture(PathBuf);
impl Fixture {
fn new() -> Self {
let path = std::env::temp_dir().join(format!("arcadia-cli-test-{}", Id::new_v4()));
fs::create_dir(&path).unwrap();
Self(path)
}
fn put(&self, name: &str, text: &str) {
let path = self.0.join(name);
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(path, text).unwrap();
}
fn entry(&self) -> PathBuf {
self.0.join("project.arcadia")
}
fn read(&self, name: &str) -> String {
fs::read_to_string(self.0.join(name)).unwrap()
}
fn prepare(&self, op: Operation) -> Prepared {
prepare(&self.entry(), op).unwrap_or_else(|report| panic!("{report:#?}"))
}
fn model(&self) -> Project {
let disk = source_files::load_project(&self.entry(), LIMITS).unwrap();
source_project::compile_bundle(&disk.reachable_bundle(), LIMITS)
.unwrap()
.decoded
.project
}
fn cli(&self, args: &[&str]) -> (i32, Value) {
let mut command = Command::new(env!("CARGO_BIN_EXE_arcadia"));
command
.arg(args[0])
.arg(self.entry())
.args(&args[1..])
.arg("--json");
let output = command.output().unwrap();
assert!(
output.stderr.is_empty(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
(
output.status.code().unwrap(),
serde_json::from_slice(&output.stdout).unwrap(),
)
}
}
impl Drop for Fixture {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
const SIMPLE: &str = "// 雨: authored defaults stay omitted\r\narcadia 7;\r\nproject rescue \"Rescue\" {\r\n oa_entity team \"Team\" {}\r\n}\r\n";
#[test]
fn complete_alarm_example_preview_then_write_and_fixed_point() {
let fixture = Fixture::new();
let source = include_str!(
"../../../harness/experiments/textual-authoring-20260911/alarm-response.arcadia"
);
fixture.put("project.arcadia", source);
let (code, check) = fixture.cli(&["check"]);
assert_eq!(code, 0);
assert_eq!(check["diagnostics"], serde_json::json!([]));
let (code, preview) = fixture.cli(&["stabilize"]);
assert_eq!(code, 0);
assert_eq!(preview["identities_to_stabilize"], 40);
assert_eq!(preview["status"], "preview");
assert_eq!(fixture.read("project.arcadia"), source);
assert!(
preview["changes"][0]["diff"]
.as_str()
.unwrap()
.contains("+project alarm_response")
);
let (code, written) = fixture.cli(&["stabilize", "--write"]);
assert_eq!(code, 0);
let saved = fixture.read("project.arcadia");
assert_eq!(written["changes"][0]["after"], saved);
let project = fixture.model();
assert_eq!(project.identities().len(), 40);
let mut stripped = saved.clone();
for (id, _) in project.identities() {
stripped = stripped.replace(&format!(" id \"{id}\""), "");
}
assert_eq!(stripped, source, "only identity clauses may be inserted");
let (code, again) = fixture.cli(&["stabilize", "--write"]);
assert_eq!(code, 0);
assert_eq!(again["changes"], serde_json::json!([]));
assert_eq!(again["status"], "unchanged");
assert_eq!(fixture.model(), project);
assert_eq!(fixture.read("project.arcadia"), saved);
}
#[test]
fn captured_candidate_exact_write_preserves_crlf_comments_and_noop_metadata() {
let fixture = Fixture::new();
fixture.put("project.arcadia", SIMPLE);
let candidate = fixture.prepare(Operation::Stabilize);
let expected = candidate.report().changes[0].after.clone();
assert_eq!(candidate.report().identities_to_stabilize, 2);
assert_eq!(fixture.read("project.arcadia"), SIMPLE);
assert_eq!(candidate.write().exit_code, 0);
assert_eq!(fixture.read("project.arcadia"), expected);
assert!(expected.contains("// 雨: authored defaults stay omitted\r\n"));
assert!(!expected.contains("description"));
let metadata = fs::metadata(fixture.entry()).unwrap().modified().unwrap();
let report = fixture.prepare(Operation::Stabilize).write();
assert_eq!(report.status, "unchanged");
assert!(!report.written);
assert_eq!(
fs::metadata(fixture.entry()).unwrap().modified().unwrap(),
metadata
);
assert_eq!(fs::read_dir(&fixture.0).unwrap().count(), 1);
}
#[test]
fn alias_rename_across_modules_changes_only_live_tokens_and_retains_all_identity() {
let fixture = Fixture::new();
fixture.put("project.arcadia", "arcadia 7; project rescue \"Rescue\" { include \"participants.arcadia\"; include \"work.arcadia\"; }\n");
fixture.put(
"participants.arcadia",
"// @team is quoted documentation\noa_entity team \"Team @team\" {}\n",
);
fixture.put("work.arcadia", "oa_activity assess \"Assess\" {}\noa_activity report \"Report\" {}\nallocation assigned \"Assigned\" { source @team; target @assess; }\ninteraction transfer \"Transfer\" { source @assess; target @report; }\n");
assert_eq!(fixture.prepare(Operation::Stabilize).write().exit_code, 0);
let before = fixture.model();
let original: BTreeMap<_, _> = ["project.arcadia", "participants.arcadia", "work.arcadia"]
.into_iter()
.map(|p| (p, fixture.read(p)))
.collect();
let team = before
.identities()
.into_iter()
.find(|(_, alias)| *alias == "team")
.unwrap()
.0;
let candidate = fixture.prepare(Operation::RenameAlias {
old: "team".into(),
new: "rescue_team".into(),
});
assert_eq!(candidate.report().changes.len(), 2);
assert_eq!(fixture.model(), before, "preview cannot mutate");
assert_eq!(candidate.write().exit_code, 0);
let mut expected = before;
expected.elements.get_mut(&team).unwrap().alias = "rescue_team".into();
assert_eq!(fixture.model(), expected);
assert_eq!(fixture.read("project.arcadia"), original["project.arcadia"]);
assert_eq!(
fixture.read("participants.arcadia"),
original["participants.arcadia"].replacen("oa_entity team ", "oa_entity rescue_team ", 1)
);
assert_eq!(
fixture.read("work.arcadia"),
original["work.arcadia"].replace("source @team;", "source @rescue_team;")
);
}
#[test]
fn invalid_unicode_raw_edit_is_located_and_never_stabilized_or_written() {
let fixture = Fixture::new();
let raw = "arcadia 7; project p \"雨\" {\n oa_activity a \"A\" {}\n allocation alloc \"Alloc\" { source @missing; target @a; }\n}\n";
fixture.put("project.arcadia", raw);
for command in [
vec!["check"],
vec!["stabilize", "--write"],
vec!["rename-alias", "a", "b", "--write"],
] {
let (code, report) = fixture.cli(&command);
assert_eq!(code, 1);
assert_eq!(fixture.read("project.arcadia"), raw);
let diagnostics = report["diagnostics"].as_array().unwrap();
assert!(!diagnostics.is_empty());
for d in diagnostics {
assert_eq!(d["coordinate_space"], "input");
let at = &d["location"];
let start = at["start"].as_u64().unwrap() as usize;
assert!(raw.is_char_boundary(start));
assert_eq!(
at["line"].as_u64().unwrap() as usize,
raw[..start].bytes().filter(|b| *b == b'\n').count() + 1
);
let line = raw[..start]
.rsplit_once('\n')
.map_or(&raw[..start], |(_, tail)| tail);
assert_eq!(
at["column"].as_u64().unwrap() as usize,
line.chars().count() + 1
);
}
}
}
#[test]
fn missing_id_warning_uses_raw_location_and_never_exposes_ephemeral_lint_id() {
let fixture = Fixture::new();
let raw = "arcadia 7;\nproject p \"雨\" {\n oa_activity work \"Work\" {}\n}\n";
fixture.put("project.arcadia", raw);
let (code, result) = fixture.cli(&["lint"]);
assert_eq!(code, 0, "engineering warnings do not reject admission");
let warning = result["diagnostics"]
.as_array()
.unwrap()
.iter()
.find(|d| d["code"] == "work_without_behavior_context")
.unwrap();
assert_eq!(warning["severity"], "warning");
assert_eq!(
warning["location"]["start"],
raw.find("oa_activity").unwrap()
);
assert_eq!(warning["location"]["line"], 3);
assert_eq!(warning["location"]["column"], 5);
assert!(warning["object_id"].is_null());
assert_eq!(fixture.read("project.arcadia"), raw);
}
#[test]
fn captured_dependency_change_refuses_all_writes_even_on_noop() {
let fixture = Fixture::new();
fixture.put("project.arcadia", SIMPLE);
let pending = fixture.prepare(Operation::Stabilize);
let external = format!("{SIMPLE}// editor changed this\n");
fixture.put("project.arcadia", &external);
let result = pending.write();
assert_eq!(result.exit_code, 3);
assert_eq!(result.diagnostics.last().unwrap().code, "external_change");
assert_eq!(fixture.read("project.arcadia"), external);
assert_eq!(fixture.prepare(Operation::Stabilize).write().exit_code, 0);
let noop = fixture.prepare(Operation::Stabilize);
assert!(noop.report().changes.is_empty());
let latest = format!("{}// another edit\n", fixture.read("project.arcadia"));
fixture.put("project.arcadia", &latest);
assert_eq!(noop.write().exit_code, 3);
assert_eq!(fixture.read("project.arcadia"), latest);
}
#[test]
fn pending_journal_refuses_read_preview_and_write_without_recovery() {
let fixture = Fixture::new();
fixture.put("project.arcadia", SIMPLE);
assert_eq!(fixture.prepare(Operation::Stabilize).write().exit_code, 0);
let noop = fixture.prepare(Operation::Stabilize);
let source = fixture.read("project.arcadia");
// An opaque incomplete journal must never be interpreted/recovered by CLI.
let journal = fixture.0.join(".arcadia-save-project.arcadia.journal");
fs::create_dir(&journal).unwrap();
fs::write(journal.join("sentinel"), b"retain").unwrap();
for op in [Operation::Check, Operation::Stabilize] {
let report = prepare(&fixture.entry(), op).err().unwrap();
assert_eq!(report.exit_code, 3);
assert!(report.diagnostics.iter().any(|d| d.code == "pending_save"));
}
assert_eq!(
noop.write().exit_code,
3,
"saver must recheck after preview"
);
assert_eq!(fixture.read("project.arcadia"), source);
assert_eq!(fs::read(journal.join("sentinel")).unwrap(), b"retain");
}
#[test]
fn rejected_alias_and_same_alias_preserve_source() {
let fixture = Fixture::new();
fixture.put("project.arcadia", SIMPLE);
assert_eq!(fixture.prepare(Operation::Stabilize).write().exit_code, 0);
let source = fixture.read("project.arcadia");
for (old, new) in [
("team", "rescue"),
("team", "invalid alias"),
("absent", "valid"),
] {
let (code, result) = fixture.cli(&["rename-alias", old, new, "--write"]);
assert_eq!(code, 1);
assert_eq!(result["status"], "rejected");
assert_eq!(fixture.read("project.arcadia"), source);
}
let (code, result) = fixture.cli(&["rename-alias", "team", "team", "--write"]);
assert_eq!(code, 0);
assert_eq!(result["status"], "unchanged");
assert_eq!(fixture.read("project.arcadia"), source);
}
#[test]
fn rename_shows_missing_identity_and_separate_source_migration() {
let fixture = Fixture::new();
let old = "// retain\narcadia 5; project p \"P\" { oa_entity team \"Team\" {} }\n";
fixture.put("project.arcadia", old);
let candidate = fixture.prepare(Operation::RenameAlias {
old: "team".into(),
new: "crew".into(),
});
let report = candidate.report();
assert_eq!(report.identities_to_stabilize, 2);
assert_eq!(report.migration.as_ref().unwrap().from_schema, 5);
assert_eq!(report.migration.as_ref().unwrap().to_schema, 7);
assert!(report.changes[0].diff.contains("-arcadia 5;"));
assert!(report.changes[0].diff.contains("+arcadia 7;"));
assert!(report.changes[0].after.contains("oa_entity crew"));
assert_eq!(fixture.read("project.arcadia"), old);
assert_eq!(candidate.write().exit_code, 0);
}
#[test]
fn entry_is_explicit_and_machine_readable_usage_errors_do_not_write() {
let fixture = Fixture::new();
fixture.put("project.arcadia", SIMPLE);
let output = Command::new(env!("CARGO_BIN_EXE_arcadia"))
.current_dir(&fixture.0)
.args(["check", "--json"])
.output()
.unwrap();
assert_eq!(output.status.code(), Some(2));
let error: Value = serde_json::from_slice(&output.stdout).unwrap();
assert_eq!(error["diagnostics"][0]["code"], "usage");
assert_eq!(fixture.read("project.arcadia"), SIMPLE);
let (code, _) = fixture.cli(&["check", "--write"]);
assert_eq!(code, 2);
assert!(Path::new(env!("CARGO_BIN_EXE_arcadia")).is_file());
}
#[test]
fn loader_encoding_missing_dependency_and_invalid_old_source_keep_original_bytes() {
let fixture = Fixture::new();
let entry = "arcadia 7; project p \"P\" { include \"part.arcadia\"; }";
fixture.put("project.arcadia", entry);
let (code, report) = fixture.cli(&["stabilize", "--write"]);
assert_eq!(code, 3, "a disk dependency could not be read");
assert!(
report["diagnostics"]
.as_array()
.unwrap()
.iter()
.any(|d| d["code"] == "missing_include")
);
fs::write(fixture.0.join("part.arcadia"), [0xff, 0xfe]).unwrap();
let (code, report) = fixture.cli(&["stabilize", "--write"]);
assert_eq!(code, 1, "invalid encoding is invalid raw source");
assert!(
report["diagnostics"]
.as_array()
.unwrap()
.iter()
.any(|d| d["code"] == "source_encoding")
);
assert_eq!(fixture.read("project.arcadia"), entry);
assert_eq!(
fs::read(fixture.0.join("part.arcadia")).unwrap(),
[0xff, 0xfe]
);
for raw in [
"arcadia 5; project p \"P\" { oa_entity a \"A\" { ??? } }",
"arcadia 2; project p \"P\" {}",
] {
fixture.put("project.arcadia", raw);
let (code, _) = fixture.cli(&["stabilize", "--write"]);
assert_eq!(code, 1);
assert_eq!(fixture.read("project.arcadia"), raw);
}
}
#[test]
fn non_element_alias_detached_file_and_unchanged_dependency_conflict() {
let fixture = Fixture::new();
fixture.put("project.arcadia", "arcadia 7; project p \"P\" { include \"part.arcadia\"; oa_activity a \"A\" {} allocation responsible \"Responsible\" { source @ // keep edge comment\nteam; target @a; } }");
fixture.put("part.arcadia", "oa_entity team \"Team\" {}");
fixture.put(
"detached.arcadia",
"invalid detached draft must stay exact 雨",
);
assert_eq!(fixture.prepare(Operation::Stabilize).write().exit_code, 0);
let before = fixture.model();
let relation = before
.identities()
.into_iter()
.find(|(_, alias)| *alias == "responsible")
.unwrap()
.0;
let (code, _) = fixture.cli(&["rename-alias", "responsible", "performs", "--write"]);
assert_eq!(code, 0);
let mut expected = before;
expected.relations.get_mut(&relation).unwrap().alias = "performs".into();
assert_eq!(fixture.model(), expected);
assert!(
fixture
.read("project.arcadia")
.contains("@ // keep edge comment\nteam")
);
let captured = fixture.prepare(Operation::RenameAlias {
old: "p".into(),
new: "rescue".into(),
});
assert!(
captured
.report()
.changes
.iter()
.all(|c| c.path != "part.arcadia")
);
let root_before = fixture.read("project.arcadia");
fixture.put("part.arcadia", "// external unchanged-dependency edit\n");
assert_eq!(captured.write().exit_code, 3);
assert_eq!(fixture.read("project.arcadia"), root_before);
assert_eq!(
fixture.read("part.arcadia"),
"// external unchanged-dependency edit\n"
);
assert_eq!(
fixture.read("detached.arcadia"),
"invalid detached draft must stay exact 雨"
);
}
@@ -230,7 +230,7 @@ fn fixture() -> (Project, Id, Vec<Id>, Vec<Id>, Vec<Id>) {
)
})
.collect();
p.diagrams.insert(diagram, Diagram { id: diagram, alias: "forty_exchange_view".into(), name: "Forty service exchanges · all concerns".into(), description: "Start with All. Compare first category, overlapping concerns, global coverage and chosen processes. No hand-routed edges.".into(), layer: Layer::Oa, projection: ProjectionKind::FunctionalDataflow, view_options: ViewOptions::default(), nodes, routes: vec![] });
p.diagrams.insert(diagram, Diagram { id: diagram, alias: "forty_exchange_view".into(), name: "Forty service exchanges · all concerns".into(), description: "Start with All. Compare first category, overlapping concerns, global coverage and chosen processes. No hand-routed edges.".into(), layer: Layer::Oa, projection: ProjectionKind::FunctionalDataflow, view_options: ViewOptions::default(), nodes, layout: None, routes: vec![] });
(p, diagram, exchanges, categories, contexts)
}
+1
View File
@@ -81,6 +81,7 @@ fn diagram(
projection,
view_options: Default::default(),
nodes: BTreeMap::new(),
layout: None,
routes: vec![],
},
);
+161 -28
View File
@@ -142,12 +142,87 @@ pub struct Analysis {
pub declarations: Vec<DeclarationSpan>,
}
/// Check the decoded spelling required by declaration-header UUID clauses.
/// This is the shared 36-byte/hyphen shape rule, not a hexadecimal parser:
/// callers must additionally parse the UUID. Uppercase hex and JSON escapes
/// that decode to this spelling remain accepted. URN/braced/compact forms do not.
pub fn has_uuid_header_shape(decoded: &str) -> bool {
decoded.len() == 36
&& [8, 13, 18, 23]
.into_iter()
.all(|index| decoded.as_bytes()[index] == b'-')
}
/// Budgets for one original source buffer. Policy is caller-owned; these
/// limits do not change the grammar or perform model admission.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct OutlineLimits {
pub max_source_bytes: usize,
pub max_tokens: usize,
pub max_declarations: usize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OutlineResource {
SourceBytes,
Tokens,
Declarations,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct OutlineLimit {
pub resource: OutlineResource,
pub limit: usize,
pub observed_at_least: usize,
/// Original byte offset where admission stopped; never a rewritten offset.
pub offset: usize,
}
/// Roles discovered by the existing tolerant declaration walk. Keeping the
/// wrapper preserves DeclarationSpan and Analysis as existing public APIs.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OutlineDeclaration {
pub declaration: DeclarationSpan,
pub kind_span: Span,
pub alias_span: Span,
/// Whole quoted header ID token, even if it is not a valid UUID. In that
/// case declaration.id is None. Missing/non-string ID values have no span.
pub id_literal_span: Option<Span>,
}
/// A full-file tolerant scan, not a successful parse or validated model. Invalid
/// text remains available. Lexical errors and unmatched braces are facts only:
/// their absence does not establish syntactic or semantic validity.
///
/// A budget failure returns Err and no partial Outline. Include reachability,
/// project completeness and editor revision are intentionally not inferred here.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Outline<'a> {
pub source: &'a str,
pub tokens: Vec<TokenSpan>,
pub declarations: Vec<OutlineDeclaration>,
pub has_lexical_errors: bool,
pub has_unmatched_braces: bool,
}
/// Tolerant highlighting tokens cover the whole input, including incomplete
/// strings and unexpected characters. No model is created or modified.
pub fn tokenize(source: &str) -> Vec<TokenSpan> {
tokenize_with_limit(source, usize::MAX).expect("unlimited token scan")
}
fn tokenize_with_limit(source: &str, max_tokens: usize) -> Result<Vec<TokenSpan>, OutlineLimit> {
let mut tokens = Vec::new();
let mut cursor = 0;
while cursor < source.len() {
if tokens.len() == max_tokens {
return Err(OutlineLimit {
resource: OutlineResource::Tokens,
limit: max_tokens,
observed_at_least: tokens.len().saturating_add(1),
offset: cursor,
});
}
let start = cursor;
let ch = source[cursor..].chars().next().expect("in bounds");
let class = if matches!(ch, ' ' | '\t' | '\r' | '\n') {
@@ -224,7 +299,7 @@ pub fn tokenize(source: &str) -> Vec<TokenSpan> {
span: Span { start, end: cursor },
});
}
tokens
Ok(tokens)
}
pub fn lint(source: &str) -> Vec<Diagnostic> {
@@ -1230,11 +1305,7 @@ impl Parser<'_> {
self.cursor += 1;
let offset = self.current().span.start;
let value = self.quoted()?;
if value.len() != 36
|| ![8, 13, 18, 23]
.into_iter()
.all(|i| value.as_bytes()[i] == b'-')
{
if !has_uuid_header_shape(&value) {
return Err(Error {
code: "invalid_source",
offset,
@@ -1752,7 +1823,7 @@ pub(crate) fn resolve_declarations(
return Err(conversion_error(std::format!("Unknown diagram field '{key}' in the frozen pre6 contract.")));
}
let defaults =
serde_json::json!({"description":"","layer":"Oa","projection":"Overview","routes":[],"view_options":crate::view_options::ViewOptions::default()});
serde_json::json!({"description":"","layer":"Oa","projection":"Overview","routes":[],"view_options":crate::view_options::ViewOptions::default(),"layout":null});
let mut record = merge_fields(defaults.as_object().unwrap().clone(), fields)
.map_err(&conversion_error)?;
header(&mut record, d, true);
@@ -1962,8 +2033,49 @@ fn take_required_id(fields: &mut Map<String, Value>, key: &str) -> Result<Id, St
/// Analyze an incomplete source buffer without adopting a candidate. Declaration
/// spans remain available even when a later declaration contains errors.
/// Invalid compact/braced/URN header IDs retain no authored identity, matching
/// the declaration parser rather than the UUID crate's broader accepted forms.
pub fn analyze(source: &str) -> Analysis {
let tokens = tokenize(source);
let (declarations, _) =
declaration_outline(source, &tokens, usize::MAX).expect("unlimited declaration walk");
Analysis {
tokens,
diagnostics: lint(source),
declarations: declarations.into_iter().map(|d| d.declaration).collect(),
}
}
/// Read exact current text without lint, parsing a model, stabilization or I/O.
/// Source bytes are admitted before token allocation; token/declaration limits
/// are checked before lexing the next token or materializing the next record.
pub fn outline(source: &str, limits: OutlineLimits) -> Result<Outline<'_>, OutlineLimit> {
if source.len() > limits.max_source_bytes {
return Err(OutlineLimit {
resource: OutlineResource::SourceBytes,
limit: limits.max_source_bytes,
observed_at_least: source.len(),
offset: 0,
});
}
let tokens = tokenize_with_limit(source, limits.max_tokens)?;
let has_lexical_errors = tokens.iter().any(|token| token.class == TokenClass::Error);
let (declarations, has_unmatched_braces) =
declaration_outline(source, &tokens, limits.max_declarations)?;
Ok(Outline {
source,
tokens,
declarations,
has_lexical_errors,
has_unmatched_braces,
})
}
fn declaration_outline(
source: &str,
tokens: &[TokenSpan],
max_declarations: usize,
) -> Result<(Vec<OutlineDeclaration>, bool), OutlineLimit> {
let significant = tokens
.iter()
.filter(|t| !matches!(t.class, TokenClass::Whitespace | TokenClass::Comment))
@@ -1972,17 +2084,21 @@ pub fn analyze(source: &str) -> Analysis {
// again would make tolerant navigation quadratic on deeply nested drafts.
let mut body_ends = vec![None; significant.len()];
let mut bodies = Vec::new();
let mut unmatched_close = false;
for (index, token) in significant.iter().enumerate() {
match &source[token.span.start..token.span.end] {
"{" => bodies.push(index),
"}" => {
if let Some(open) = bodies.pop() {
body_ends[open] = Some(token.span.end);
} else {
unmatched_close = true;
}
}
_ => {}
}
}
let has_unmatched_braces = unmatched_close || !bodies.is_empty();
let mut declarations = Vec::new();
for (i, t) in significant.iter().enumerate() {
let word = &source[t.span.start..t.span.end];
@@ -2007,7 +2123,16 @@ pub fn analyze(source: &str) -> Analysis {
}
j += 1;
}
if declarations.len() == max_declarations {
return Err(OutlineLimit {
resource: OutlineResource::Declarations,
limit: max_declarations,
observed_at_least: declarations.len().saturating_add(1),
offset: t.span.start,
});
}
let mut id = None;
let mut id_literal_span = None;
if significant
.get(j)
.is_some_and(|t| &source[t.span.start..t.span.end] == "id")
@@ -2015,39 +2140,51 @@ pub fn analyze(source: &str) -> Analysis {
if let Some(value) = significant.get(j + 1)
&& value.class == TokenClass::String
{
id_literal_span = Some(value.span);
let text =
serde_json::from_str::<String>(&source[value.span.start..value.span.end])
.unwrap();
id = Id::parse_str(&text).ok();
if has_uuid_header_shape(&text) {
id = Id::parse_str(&text).ok();
}
}
j += 2;
}
let mut end = significant
.get(j)
.map_or(alias_token.span.end, |t| t.span.end);
// A recognized header ID at EOF must remain inside the whole span.
// Keep the old next-token/body behavior; only the EOF fallback changes.
let header_end = significant
.get(j.saturating_sub(1))
.or_else(|| significant.last())
.map_or(alias_token.span.end, |token| token.span.end);
let mut end = significant.get(j).map_or(header_end, |t| t.span.end);
if significant
.get(j)
.is_some_and(|t| &source[t.span.start..t.span.end] == "{")
{
end = body_ends[j].unwrap_or(source.len());
}
declarations.push(DeclarationSpan {
alias: alias.into(),
id,
kind: word.into(),
span: Span {
start: t.span.start,
end,
declarations.push(OutlineDeclaration {
declaration: DeclarationSpan {
alias: alias.into(),
id,
kind: word.into(),
span: Span {
start: t.span.start,
end,
},
},
kind_span: t.span,
alias_span: alias_token.span,
id_literal_span,
});
}
Analysis {
tokens,
diagnostics: lint(source),
declarations,
}
Ok((declarations, has_unmatched_braces))
}
#[cfg(test)]
#[path = "language_outline_tests.rs"]
mod outline_tests;
#[cfg(test)]
#[path = "language_nesting_tests.rs"]
mod nesting_tests;
@@ -2442,11 +2579,7 @@ pub(crate) mod legacy {
let value = self.quoted()?;
// Require the portable 8-4-4-4-12 spelling, not the UUID crate's URN,
// braces, or compact alternatives. Uppercase hexadecimal is accepted.
if value.len() != 36
|| ![8, 13, 18, 23]
.into_iter()
.all(|index| value.as_bytes()[index] == b'-')
{
if !super::has_uuid_header_shape(&value) {
return Err(Error {
code: "invalid_source",
offset,
@@ -0,0 +1,386 @@
//! Prepared outline tests. Explicit compatibility tests additionally exercise
//! the existing parsers/analyze; the outline entry itself never calls them.
use super::*;
fn limits() -> OutlineLimits {
OutlineLimits {
max_source_bytes: 1024 * 1024,
max_tokens: 100_000,
max_declarations: 1_000,
}
}
fn declaration<'a>(result: &'a Outline<'_>, alias: &str) -> &'a OutlineDeclaration {
result
.declarations
.iter()
.find(|d| d.declaration.alias == alias)
.unwrap()
}
fn text(source: &str, span: Span) -> &str {
&source[span.start..span.end]
}
#[test]
fn outline_roles_preserve_unicode_crlf_and_exact_escaped_uuid_literal() {
let source = "// 検査 🚒\r\noa_actor responder \"救助\" id \"\\u00300000000-0000-0000-0000-000000000001\" {}\r\nsource @ // 使用\r\n responder;";
let before = source.as_bytes().to_vec();
let result = outline(source, limits()).unwrap();
assert_eq!(result.source.as_bytes(), before);
assert_eq!(result.source.as_ptr(), source.as_ptr());
let d = declaration(&result, "responder");
assert_eq!(text(source, d.kind_span), "oa_actor");
assert_eq!(text(source, d.alias_span), "responder");
assert_eq!(
text(source, d.id_literal_span.unwrap()),
"\"\\u00300000000-0000-0000-0000-000000000001\""
);
assert_eq!(d.declaration.id, Some(Id::from_u128(1)));
assert_eq!(d.declaration.span.start, source.find("oa_actor").unwrap());
assert_eq!(d.declaration.span.end, source.find("{}\r\n").unwrap() + 2);
let mut end = 0;
for token in &result.tokens {
assert_eq!(token.span.start, end);
assert!(token.span.start < token.span.end);
assert!(source.is_char_boundary(token.span.start));
assert!(source.is_char_boundary(token.span.end));
end = token.span.end;
}
assert_eq!(end, source.len());
assert!(!result.has_lexical_errors);
assert!(!result.has_unmatched_braces);
}
#[test]
fn closed_id_at_eof_is_inside_the_declaration_extent() {
for source in [
"oa_actor a \"Actor\" id \"00000000-0000-0000-0000-000000000001\"",
"node placed id \"00000000-0000-0000-0000-000000000001\"",
] {
let result = outline(source, limits()).unwrap();
let d = &result.declarations[0];
let literal = d.id_literal_span.unwrap();
assert_eq!(d.declaration.id, Some(Id::from_u128(1)));
assert_eq!(d.declaration.span.end, source.len());
assert!(d.declaration.span.start <= literal.start);
assert!(literal.end <= d.declaration.span.end);
}
for source in ["oa_actor a \"Actor\"", "oa_actor a \"Actor\" id"] {
let result = outline(source, limits()).unwrap();
let d = &result.declarations[0];
assert_eq!(d.declaration.span.end, source.len());
assert_eq!(d.declaration.id, None);
assert_eq!(d.id_literal_span, None);
}
}
#[test]
fn missing_bad_and_historical_ids_never_allocate_authored_identity() {
let source = "oa_actor missing \"No ID\" {}\noa_actor bad \"Bad ID\" id \"not-a-uuid\" {}\noa_actor historical \"00000000-0000-0000-0000-000000000001\" { history uuid \"00000000-0000-0000-0000-000000000001\"; }";
let first = outline(source, limits()).unwrap();
let second = outline(source, limits()).unwrap();
assert_eq!(first, second);
assert!(
first
.declarations
.iter()
.all(|d| d.declaration.id.is_none())
);
assert_eq!(declaration(&first, "missing").id_literal_span, None);
assert_eq!(declaration(&first, "historical").id_literal_span, None);
assert_eq!(
text(source, declaration(&first, "bad").id_literal_span.unwrap()),
"\"not-a-uuid\""
);
// Balanced tokens are not a successful syntax/semantic validation claim.
assert!(!first.has_lexical_errors);
assert!(!first.has_unmatched_braces);
}
#[test]
fn invalid_full_scan_keeps_closed_children_and_reports_lexical_body_facts() {
let source = "oa_entity outer \"Outer\" {\n oa_actor ready \"Ready\" { description \"} stays text\"; }\n oa_actor pending \"Pending\" { description \"unfinished 🌊";
let result = outline(source, limits()).unwrap();
assert_eq!(result.source, source);
assert_eq!(result.tokens.last().unwrap().span.end, source.len());
assert!(result.has_lexical_errors);
assert!(result.has_unmatched_braces);
let ready = declaration(&result, "ready");
assert_eq!(
text(source, ready.declaration.span),
"oa_actor ready \"Ready\" { description \"} stays text\"; }"
);
assert_eq!(
declaration(&result, "outer").declaration.span.end,
source.len()
);
assert_eq!(
declaration(&result, "pending").declaration.span.end,
source.len()
);
let unmatched_close = outline("}\n// brace in comment {\n", limits()).unwrap();
assert!(unmatched_close.has_unmatched_braces);
assert!(!unmatched_close.has_lexical_errors);
}
#[test]
fn missing_body_syntax_is_unchecked_even_when_known_error_flags_are_clear() {
let source = "oa_actor a \"A\" id \"00000000-0000-0000-0000-000000000001\"";
let result = outline(source, limits()).unwrap();
assert_eq!(result.declarations.len(), 1);
assert!(!result.has_lexical_errors);
assert!(!result.has_unmatched_braces);
// There is deliberately no `valid` flag: the known-complete fact is scanning
// these bytes, not the missing body becoming legal or the draft being applied.
assert_eq!(result.source, source);
}
#[test]
fn keyword_alias_and_collisions_keep_every_source_ordered_header() {
let source = "project root_model \"Model\" {\n oa_actor oa_activity \"First\" id \"00000000-0000-0000-0000-000000000001\" {}\n oa_actor oa_activity \"Second\" id \"00000000-0000-0000-0000-000000000001\" {}\n diagram view_one \"View\" {}\n node placement { diagram @view_one; }\n}";
let result = outline(source, limits()).unwrap();
assert_eq!(result.declarations.len(), 5);
let duplicates: Vec<_> = result
.declarations
.iter()
.filter(|d| d.declaration.alias == "oa_activity")
.collect();
assert_eq!(duplicates.len(), 2);
assert!(duplicates[0].alias_span.start < duplicates[1].alias_span.start);
assert_eq!(duplicates[0].declaration.id, duplicates[1].declaration.id);
assert!(
result
.tokens
.iter()
.any(|t| t.span == duplicates[0].alias_span && t.class == TokenClass::Keyword)
);
assert_eq!(
text(source, declaration(&result, "placement").kind_span),
"node"
);
assert_eq!(declaration(&result, "placement").declaration.id, None);
}
#[test]
fn bounds_refuse_before_returning_a_partial_outline() {
let source = "oa_actor a \"A\" {}\noa_actor b \"B\" {}";
let admitted = outline(source, limits()).unwrap();
let exact = OutlineLimits {
max_source_bytes: source.len(),
max_tokens: admitted.tokens.len(),
max_declarations: 2,
};
assert_eq!(outline(source, exact).unwrap(), admitted);
for resource in [
OutlineResource::SourceBytes,
OutlineResource::Tokens,
OutlineResource::Declarations,
] {
let mut lower = exact;
match resource {
OutlineResource::SourceBytes => lower.max_source_bytes -= 1,
OutlineResource::Tokens => lower.max_tokens -= 1,
OutlineResource::Declarations => lower.max_declarations -= 1,
}
let error = outline(source, lower).unwrap_err();
assert_eq!(error.resource, resource);
assert!(error.observed_at_least > error.limit);
assert!(source.is_char_boundary(error.offset));
if resource == OutlineResource::Declarations {
assert_eq!(error.offset, source.find("oa_actor b").unwrap());
}
}
let empty = outline(
"",
OutlineLimits {
max_source_bytes: 0,
max_tokens: 0,
max_declarations: 0,
},
)
.unwrap();
assert!(empty.tokens.is_empty() && empty.declarations.is_empty());
let unicode = "// 🌊\r\noa_actor a \"A\" {}";
let error = outline(
unicode,
OutlineLimits {
max_source_bytes: 4,
..limits()
},
)
.unwrap_err();
assert_eq!(error.resource, OutlineResource::SourceBytes);
assert_eq!(error.offset, 0);
let error = outline(
unicode,
OutlineLimits {
max_tokens: 1,
..limits()
},
)
.unwrap_err();
assert_eq!(error.resource, OutlineResource::Tokens);
assert_eq!(error.offset, unicode.find('\n').unwrap());
}
#[test]
fn outline_ranges_follow_current_bytes_without_identity_stabilization() {
let old = "oa_actor a \"A\" {}\nsource @a;";
let current = format!("// 航海 🚒\r\n{old}");
let old_outline = outline(old, limits()).unwrap();
let current_outline = outline(&current, limits()).unwrap();
let shift = current.len() - old.len();
let old_role = &old_outline.declarations[0];
let current_role = &current_outline.declarations[0];
assert_eq!(
current_role.alias_span.start,
old_role.alias_span.start + shift
);
assert_eq!(
current_role.kind_span.start,
old_role.kind_span.start + shift
);
assert_eq!(
current_role.declaration.span.end,
old_role.declaration.span.end + shift
);
assert_eq!(current_outline.source, current);
assert_eq!(old_outline.source, old);
assert_eq!(current_role.declaration.id, None);
assert!(!current_outline.source.contains(" id "));
// Workspace revision/stale-handle rejection belongs to the query/adapter,
// not a claim that an old owned span can be applied to any other string.
}
#[test]
fn deeply_nested_bounds_use_the_same_iterative_walk_and_declaration_budget() {
let mut source = String::new();
for index in 0..600 {
source.push_str(&format!("oa_activity work_{index} \"Work\" {{\n"));
}
source.push_str(&"}\n".repeat(600));
let result = outline(&source, limits()).unwrap();
assert_eq!(result.declarations.len(), 600);
assert!(!result.has_unmatched_braces);
assert_eq!(
result.declarations[0].declaration.span.end,
source.len() - 1
);
let error = outline(
&source,
OutlineLimits {
max_declarations: 599,
..limits()
},
)
.unwrap_err();
assert_eq!(error.resource, OutlineResource::Declarations);
assert_eq!(error.observed_at_least, 600);
assert_eq!(error.offset, source.find("oa_activity work_599").unwrap());
}
#[test]
fn noncanonical_uuid_spellings_have_no_outline_or_analysis_identity() {
for raw in [
"00000000000000000000000000000001",
"{00000000-0000-0000-0000-000000000001}",
"urn:uuid:00000000-0000-0000-0000-000000000001",
] {
assert!(
Id::parse_str(raw).is_ok(),
"The UUID crate alone is too permissive"
);
assert!(!has_uuid_header_shape(raw));
let literal = format!("\"{raw}\"");
let modern = format!("arcadia 7; project model \"Model\" id {literal} {{}}");
let before = modern.clone();
let result = outline(&modern, limits()).unwrap();
let d = declaration(&result, "model");
assert_eq!(d.declaration.id, None);
assert_eq!(text(&modern, d.id_literal_span.unwrap()), literal);
assert_eq!(modern, before);
let errors = parse(&modern).unwrap_err();
assert!(
errors
.iter()
.any(|error| error.message.contains("8-4-4-4-12")
&& error.span.start == modern.find(&literal).unwrap())
);
// Intentional compatibility correction: old analyze yielded Some(Id(1))
// here despite lint rejecting the header. It now shares outline's None.
let analysis = analyze(&modern);
assert_eq!(
analysis
.declarations
.iter()
.find(|d| d.alias == "model")
.unwrap()
.id,
None
);
assert!(
analysis
.diagnostics
.iter()
.any(|error| error.message.contains("8-4-4-4-12"))
);
let legacy_source = format!("arcadia 1; project \"Legacy\" id {literal} {{}}");
let legacy_errors = legacy::parse(&legacy_source).unwrap_err();
assert!(
legacy_errors
.iter()
.any(|error| error.message.contains("8-4-4-4-12"))
);
}
}
#[test]
fn uppercase_and_json_escaped_header_ids_survive_both_parsers_and_outline() {
let expected = Id::from_u128(0xabcdef);
let canonical = expected.to_string();
let uppercase = canonical.to_uppercase();
assert_ne!(uppercase, canonical);
for raw in [uppercase.clone(), format!("\\u0030{}", &uppercase[1..])] {
let literal = format!("\"{raw}\"");
let decoded: String = serde_json::from_str(&literal).unwrap();
assert!(has_uuid_header_shape(&decoded));
let modern = format!("arcadia 7; project model \"Model\" id {literal} {{}}");
let result = outline(&modern, limits()).unwrap();
let d = declaration(&result, "model");
assert_eq!(d.declaration.id, Some(expected));
assert_eq!(text(&modern, d.id_literal_span.unwrap()), literal);
assert_eq!(result.source.as_bytes(), modern.as_bytes());
assert_eq!(parse(&modern).unwrap().id, expected);
let analysis = analyze(&modern);
assert_eq!(analysis.declarations[0].id, Some(expected));
assert!(
!analysis
.diagnostics
.iter()
.any(|error| error.severity == crate::validation::Severity::Error)
);
let legacy_source = format!("arcadia 1; project \"Legacy\" id {literal} {{}}");
assert_eq!(legacy::parse(&legacy_source).unwrap().id, expected);
}
}
#[test]
fn header_shape_still_requires_valid_hexadecimal_uuid_data() {
let raw = "00000000-0000-0000-0000-00000000000Z";
assert!(has_uuid_header_shape(raw));
assert!(Id::parse_str(raw).is_err());
let literal = format!("\"{raw}\"");
let source = format!("arcadia 7; project model \"Model\" id {literal} {{}}");
let result = outline(&source, limits()).unwrap();
let d = declaration(&result, "model");
assert_eq!(d.declaration.id, None);
assert_eq!(text(&source, d.id_literal_span.unwrap()), literal);
assert!(
parse(&source)
.unwrap_err()
.iter()
.any(|error| error.message == "Invalid UUID identity.")
);
assert_eq!(analyze(&source).declarations[0].id, None);
}
+4
View File
@@ -8,6 +8,7 @@ pub mod ports;
pub mod projection;
pub mod references;
pub mod routing;
pub mod scenario_layout;
pub mod semantic;
pub mod session;
pub mod source_files;
@@ -18,6 +19,9 @@ pub mod validation;
pub mod view_options;
pub use model::*;
pub use ports::*;
pub use scenario_layout::{
DiagramLayout, ScenarioLanePosition, ScenarioLayout, ScenarioMessagePosition,
};
pub use semantic::*;
pub use session::*;
mod demo;
+27
View File
@@ -1436,6 +1436,7 @@ pub enum ProjectionKind {
FunctionalDataflow,
Capabilities,
StateMachine { machine: Id },
Scenario { scenario: Id },
}
impl ProjectionKind {
pub fn state_machine(self) -> Option<Id> {
@@ -1445,8 +1446,30 @@ impl ProjectionKind {
}
}
/// The semantic owner of a saved Scenario representation.
pub fn scenario(self) -> Option<Id> {
match self {
Self::Scenario { scenario } => Some(scenario),
_ => None,
}
}
/// Explicitly classify architecture views; absence of a state machine is insufficient.
pub fn is_architecture(self) -> bool {
match self {
Self::Overview
| Self::Decomposition
| Self::Allocation
| Self::Traceability
| Self::FunctionalDataflow
| Self::Capabilities => true,
Self::StateMachine { .. } | Self::Scenario { .. } => false,
}
}
pub fn includes(self, kind: Kind) -> bool {
match self {
Self::Scenario { .. } => false,
Self::StateMachine { .. } => {
matches!(kind, Kind::StateMachine | Kind::Region) || kind.is_state()
}
@@ -1471,6 +1494,7 @@ impl std::fmt::Display for ProjectionKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
Self::StateMachine { .. } => "State machine",
Self::Scenario { .. } => "Scenario",
Self::Allocation => "Architecture · allocated work",
Self::FunctionalDataflow => "Functional dataflow",
Self::Capabilities => "Missions & capabilities",
@@ -1495,6 +1519,8 @@ pub struct Diagram {
pub nodes: BTreeMap<Id, NodePlacement>,
#[serde(default)]
pub routes: Vec<EdgeRoute>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub layout: Option<crate::scenario_layout::DiagramLayout>,
}
/// Authored presentation for one existing relation or trace in one diagram.
/// Computed endpoints and automatic routing elbows are never persisted here.
@@ -1921,6 +1947,7 @@ impl Project {
view_options: crate::view_options::ViewOptions::default(),
nodes: BTreeMap::new(),
routes: vec![],
layout: None,
},
);
id
+6
View File
@@ -278,6 +278,11 @@ fn project_diagram_impl(
mut availability: Option<&mut BTreeMap<Id, crate::view_options::EdgeAvailability>>,
index: Option<&crate::view_options::ViewIndex>,
) -> ProjectedView {
// Saved Scenario layout is not an architecture/state projection. Its dedicated
// renderer is not wired yet; never interpret its payload through this fallback.
if d.projection.scenario().is_some() {
return ProjectedView::default();
}
let parents = parents(p, d);
let mut nodes = Vec::new();
for n in d.nodes.values() {
@@ -394,6 +399,7 @@ fn project_diagram_impl(
continue;
}
let included = match d.projection {
ProjectionKind::Scenario { .. } => false,
ProjectionKind::StateMachine { machine } => matches!(&r.body,
RelationBody::StateTransition { transition_kind, .. }
if *transition_kind != TransitionKind::Internal
+84 -23
View File
@@ -203,7 +203,81 @@ pub fn visit_relation_refs(r: &mut Relation, mut visit: impl FnMut(&mut Id)) {
_ => {}
}
}
pub fn remap_references(p: &mut Project, old: Id, new: Id) {
/// Every semantic/presentation reference in a diagram, excluding its own identities.
pub fn diagram_references(d: &Diagram) -> Vec<Id> {
let mut copy = d.clone();
let mut refs = Vec::new();
visit_diagram_refs(&mut copy, |id| refs.push(*id));
refs
}
pub fn visit_diagram_refs(d: &mut Diagram, mut visit: impl FnMut(&mut Id)) {
d.view_options.visit_references(&mut visit);
match &mut d.projection {
ProjectionKind::StateMachine { machine } => visit(machine),
ProjectionKind::Scenario { scenario } => visit(scenario),
_ => {}
}
if let Some(layout) = &mut d.layout {
layout.visit_references(&mut visit);
}
for route in &mut d.routes {
visit(&mut route.edge);
}
for node in d.nodes.values_mut() {
for anchor in &mut node.port_anchors {
visit(&mut anchor.port);
}
visit(&mut node.element);
}
}
/// Preflight reference-key collisions before the caller changes object identities.
/// Fresh targets are allowed. This is not whole-project admission: unrelated
/// invalid records remain repairable within a transaction. Future typed reference
/// domains must compose their own preflight here before any remap mutation.
pub fn check_remap(p: &Project, old: Id, new: Id) -> Result<(), ModelError> {
if old == new {
return Ok(());
}
let remap = |id: Id| if id == old { new } else { id };
for diagram in p.diagrams.values() {
let Some(DiagramLayout::Scenario(layout)) = &diagram.layout else {
continue;
};
let mut lanes = std::collections::BTreeMap::new();
for lane in &layout.lanes {
let affected = lane.lifeline == old;
if let Some(previous_affected) = lanes.insert(remap(lane.lifeline), affected)
&& (affected || previous_affected)
{
return Err(ModelError(
"Identity remap would collide with a Scenario lane position.".into(),
));
}
}
let mut messages = std::collections::BTreeMap::new();
for message in &layout.messages {
let mut address = message.address.clone();
let affected = address.occurrence == old || address.references.contains(&old);
address.occurrence = remap(address.occurrence);
for reference in &mut address.references {
*reference = remap(*reference);
}
if let Some(previous_affected) = messages.insert(address, affected)
&& (affected || previous_affected)
{
return Err(ModelError(
"Identity remap would collide with a Scenario message position.".into(),
));
}
}
}
Ok(())
}
pub fn remap_references(p: &mut Project, old: Id, new: Id) -> Result<(), ModelError> {
check_remap(p, old, new)?;
for e in p.elements.values_mut() {
visit_element_refs(e, |id| {
if *id == old {
@@ -219,31 +293,11 @@ pub fn remap_references(p: &mut Project, old: Id, new: Id) {
});
}
for d in p.diagrams.values_mut() {
d.view_options.visit_references(|id| {
visit_diagram_refs(d, |id| {
if *id == old {
*id = new;
}
});
if let ProjectionKind::StateMachine { machine } = &mut d.projection
&& *machine == old
{
*machine = new;
}
for route in &mut d.routes {
if route.edge == old {
route.edge = new;
}
}
for n in d.nodes.values_mut() {
for anchor in &mut n.port_anchors {
if anchor.port == old {
anchor.port = new;
}
}
if n.element == old {
n.element = new;
}
}
}
for a in p.artifact_links.values_mut() {
if a.owner == old {
@@ -265,6 +319,7 @@ pub fn remap_references(p: &mut Project, old: Id, new: Id) {
});
}
}
Ok(())
}
/// IDs removed by a cascade. Cross-layer traced counterparts are never included.
pub fn deletion_impact(p: &Project, id: Id) -> BTreeSet<Id> {
@@ -339,7 +394,8 @@ fn deletion_impact_inner(p: &Project, id: Id, defer_exchange_allocations: bool)
for d in p.diagrams.values() {
if d.projection
.state_machine()
.is_some_and(|machine| removed.contains(&machine))
.or_else(|| d.projection.scenario())
.is_some_and(|owner| removed.contains(&owner))
{
removed.insert(d.id);
}
@@ -412,6 +468,7 @@ fn remove_objects(p: &mut Project, removed: BTreeSet<Id>) {
p.artifact_links.retain(|id, _| !removed.contains(id));
for d in p.diagrams.values_mut() {
d.view_options.retain_references(|id| !removed.contains(id));
crate::scenario_layout::prune_deleted(d, &removed);
d.nodes.retain(|id, _| !removed.contains(id));
d.routes.retain(|route| !removed.contains(&route.edge));
for node in d.nodes.values_mut() {
@@ -589,3 +646,7 @@ fn remove_objects(p: &mut Project, removed: BTreeSet<Id>) {
}
}
}
#[cfg(test)]
#[path = "references/scenario_layout_tests.rs"]
mod scenario_layout_tests;
@@ -0,0 +1,388 @@
use super::*;
use std::collections::BTreeMap;
struct Fixture {
project: Project,
owner: Id,
lanes: [Id; 2],
messages: [Id; 2],
views: [Id; 2],
}
fn address(occurrence: Id) -> OccurrenceEndpoint {
OccurrenceEndpoint {
references: vec![],
occurrence,
}
}
fn layout(project: &Project, view: Id) -> &ScenarioLayout {
let Some(DiagramLayout::Scenario(layout)) = &project.diagrams[&view].layout else {
panic!("expected authored Scenario positions")
};
layout
}
fn layout_mut(project: &mut Project, view: Id) -> &mut ScenarioLayout {
let Some(DiagramLayout::Scenario(layout)) =
&mut project.diagrams.get_mut(&view).unwrap().layout
else {
panic!("expected authored Scenario positions")
};
layout
}
fn admitted(project: &Project) {
let errors: Vec<_> = crate::validation::validate(project)
.into_iter()
.filter(|issue| issue.severity == crate::validation::Severity::Error)
.collect();
assert!(errors.is_empty(), "{errors:?}");
}
fn fixture() -> Fixture {
let mut project = Project::empty();
let owner = project
.add_element(Kind::Scenario, Layer::Oa, "Delivery", None)
.unwrap();
let lanes = ["Sender", "Receiver"].map(|name| {
project
.add_element(Kind::Lifeline, Layer::Oa, name, Some(owner))
.unwrap()
});
let messages = ["First message", "Second message"].map(|name| {
let id = project
.add_element(Kind::Occurrence, Layer::Oa, name, Some(owner))
.unwrap();
*project.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::Occurrence {
body: OccurrenceBody::Exchange {
exchange: None,
sender: Some(lanes[0]),
receiver: Some(lanes[1]),
message_kind: MessageKind::Asynchronous,
reply_to: None,
values: vec![],
operation: None,
},
precondition: String::new(),
postcondition: String::new(),
};
id
});
*project
.elements
.get_mut(&owner)
.unwrap()
.steps_mut()
.unwrap() = messages.to_vec();
let views = ["delivery_view", "review_view"].map(|alias| {
let id = Id::new_v4();
project.diagrams.insert(
id,
Diagram {
id,
alias: alias.into(),
name: alias.into(),
description: String::new(),
layer: Layer::Oa,
projection: ProjectionKind::Scenario { scenario: owner },
view_options: Default::default(),
nodes: BTreeMap::new(),
routes: vec![],
layout: Some(DiagramLayout::Scenario(ScenarioLayout {
lanes: vec![
ScenarioLanePosition {
lifeline: lanes[0],
x: 120.,
},
ScenarioLanePosition {
lifeline: lanes[1],
x: 420.,
},
],
messages: vec![
ScenarioMessagePosition {
address: address(messages[0]),
send_y: 220.,
},
ScenarioMessagePosition {
address: address(messages[1]),
send_y: 360.,
},
],
})),
},
);
id
});
admitted(&project);
Fixture {
project,
owner,
lanes,
messages,
views,
}
}
#[test]
fn fresh_identity_swap_preserves_coordinates_and_semantic_sequence() {
let mut f = fixture();
let before = f.project.clone();
let temporary = Id::new_v4();
for ids in [f.lanes, f.messages] {
f.project = crate::execute_batch(
&f.project,
&[
Command::Reidentify(ids[0], temporary),
Command::Reidentify(ids[1], ids[0]),
Command::Reidentify(temporary, ids[1]),
],
)
.unwrap();
}
admitted(&f.project);
assert_eq!(
f.project.elements[&f.owner].steps().unwrap(),
&vec![f.messages[1], f.messages[0]]
);
for view in f.views {
let rows = layout(&f.project, view);
assert_eq!(
rows.lanes,
vec![
ScenarioLanePosition {
lifeline: f.lanes[1],
x: 120.
},
ScenarioLanePosition {
lifeline: f.lanes[0],
x: 420.
}
]
);
assert_eq!(
rows.messages,
vec![
ScenarioMessagePosition {
address: address(f.messages[1]),
send_y: 220.
},
ScenarioMessagePosition {
address: address(f.messages[0]),
send_y: 360.
}
]
);
}
// Reverse the swaps: no values or identities other than those requested drift.
for ids in [f.lanes, f.messages] {
f.project = crate::execute_batch(
&f.project,
&[
Command::Reidentify(ids[0], temporary),
Command::Reidentify(ids[1], ids[0]),
Command::Reidentify(temporary, ids[1]),
],
)
.unwrap();
}
assert_eq!(f.project, before);
}
#[test]
fn colliding_lane_or_message_remap_rejects_before_any_reference_changes() {
for message in [false, true] {
let mut f = fixture();
let [old, new] = if message { f.messages } else { f.lanes };
let before = f.project.clone();
assert!(check_remap(&f.project, old, new).is_err());
assert!(remap_references(&mut f.project, old, new).is_err());
assert_eq!(f.project, before);
}
}
#[test]
fn reidentify_preflight_precedes_object_map_mutation_for_fresh_colliding_target() {
let mut f = fixture();
let fresh = Id::new_v4();
// A repair candidate can already have a dangling coordinate key. It must not
// silently merge with the live lane, even though the destination ID is vacant.
layout_mut(&mut f.project, f.views[0]).lanes[1].lifeline = fresh;
let before = f.project.clone();
assert!(crate::session::reidentify(&mut f.project, f.lanes[0], fresh).is_err());
assert_eq!(f.project, before);
}
#[test]
fn full_address_paths_are_walked_and_collision_checked_without_admitting_expansion() {
let mut f = fixture();
let [old, new] = [Id::new_v4(), Id::new_v4()];
let rows = layout_mut(&mut f.project, f.views[0]);
// Future/invalid expanded candidates still participate in reference safety.
rows.messages[0].address.references = vec![old];
rows.messages[1].address = OccurrenceEndpoint {
references: vec![new],
occurrence: f.messages[0],
};
let refs = diagram_references(&f.project.diagrams[&f.views[0]]);
assert!(refs.contains(&f.owner) && refs.contains(&old) && refs.contains(&new));
let before = f.project.clone();
assert!(remap_references(&mut f.project, old, new).is_err());
assert_eq!(f.project, before);
let fresh = Id::new_v4();
remap_references(&mut f.project, old, fresh).unwrap();
let rows = layout(&f.project, f.views[0]);
assert_eq!(rows.messages[0].address.references, vec![fresh]);
assert_eq!(rows.messages[0].address.occurrence, f.messages[0]);
assert_eq!(rows.messages[1].address.references, vec![new]);
}
#[test]
fn owner_reidentify_and_delete_cover_all_saved_views_without_deleting_other_owners() {
let mut f = fixture();
let other_owner = f
.project
.add_element(Kind::Scenario, Layer::Oa, "Independent", None)
.unwrap();
let mut other_view = f.project.diagrams[&f.views[0]].clone();
other_view.id = Id::new_v4();
other_view.alias = "independent_view".into();
other_view.projection = ProjectionKind::Scenario {
scenario: other_owner,
};
other_view.layout = None;
f.project.diagrams.insert(other_view.id, other_view.clone());
admitted(&f.project);
let fresh_owner = Id::new_v4();
f.project = crate::prepare_command(&f.project, &Command::Reidentify(f.owner, fresh_owner))
.unwrap()
.project;
for view in f.views {
assert_eq!(
f.project.diagrams[&view].projection.scenario(),
Some(fresh_owner)
);
}
let impact = deletion_impact(&f.project, fresh_owner);
assert!(
f.views
.iter()
.chain(&f.lanes)
.chain(&f.messages)
.all(|id| impact.contains(id))
);
assert!(!impact.contains(&other_owner) && !impact.contains(&other_view.id));
delete_cascade(&mut f.project, fresh_owner);
assert_eq!(f.project.diagrams[&other_view.id], other_view);
assert!(
f.views
.iter()
.all(|id| !f.project.diagrams.contains_key(id))
);
admitted(&f.project);
}
#[test]
fn deletion_then_recreation_never_resurrects_lane_or_message_coordinates() {
let mut f = fixture();
let lane = f.project.elements[&f.lanes[0]].clone();
let message = f.project.elements[&f.messages[0]].clone();
delete_cascade(&mut f.project, lane.id);
for view in f.views {
let rows = layout(&f.project, view);
assert_eq!(rows.lanes.len(), 1);
assert_eq!(rows.lanes[0].lifeline, f.lanes[1]);
// Missing endpoint assignment does not erase a still-live message's spacing.
assert_eq!(rows.messages.len(), 2);
}
let mut pending = BTreeSet::new();
delete_cascade_deferred(&mut f.project, message.id, &mut pending);
// Pruning happens during deletion, before later batch commands can recreate IDs.
assert!(
layout(&f.project, f.views[0])
.messages
.iter()
.all(|row| row.address.occurrence != message.id)
);
f.project.elements.insert(lane.id, lane);
f.project.elements.insert(message.id, message);
f.project
.elements
.get_mut(&f.owner)
.unwrap()
.steps_mut()
.unwrap()
.insert(0, f.messages[0]);
finish_deletions(&mut f.project, &pending);
for view in f.views {
let rows = layout(&f.project, view);
assert_eq!(rows.lanes.len(), 1);
assert_eq!(
rows.messages,
vec![ScenarioMessagePosition {
address: address(f.messages[1]),
send_y: 360.
}]
);
}
admitted(&f.project);
}
#[test]
fn unrelated_invalid_records_do_not_block_scoped_remap_or_get_repaired_implicitly() {
let mut f = fixture();
let unrelated = Id::new_v4();
let rows = layout_mut(&mut f.project, f.views[1]);
let invalid = ScenarioLanePosition {
lifeline: unrelated,
x: MAX_COORDINATE * 2.,
};
rows.lanes.extend([invalid.clone(), invalid]);
let fresh = Id::new_v4();
let untouched = f.project.diagrams[&f.views[1]].clone();
let before = f.project.clone();
remap_references(&mut f.project, f.owner, f.owner).unwrap();
assert_eq!(f.project, before);
// The duplicate lane records do not involve the remapped message identity.
remap_references(&mut f.project, f.messages[0], fresh).unwrap();
assert_eq!(
layout(&f.project, f.views[1]).lanes,
layout_from(&untouched).lanes
);
assert_eq!(
layout(&f.project, f.views[1]).messages[0]
.address
.occurrence,
fresh
);
assert_eq!(layout(&f.project, f.views[1]).messages[0].send_y, 220.);
}
fn layout_from(diagram: &Diagram) -> &ScenarioLayout {
let Some(DiagramLayout::Scenario(layout)) = &diagram.layout else {
panic!("missing layout")
};
layout
}
#[test]
fn deletion_prunes_an_explicit_address_path_without_pruning_other_message_rows() {
let mut f = fixture();
// Expanded addresses are not admitted in this slice, but deleting their
// explicit references must still make an intermediate repair candidate safe.
layout_mut(&mut f.project, f.views[0]).messages[0]
.address
.references = vec![f.lanes[0]];
delete_cascade(&mut f.project, f.lanes[0]);
assert_eq!(
layout(&f.project, f.views[0]).messages,
vec![ScenarioMessagePosition {
address: address(f.messages[1]),
send_y: 360.
}]
);
assert_eq!(layout(&f.project, f.views[1]).messages.len(), 2);
assert!(f.project.elements.contains_key(&f.messages[0]));
admitted(&f.project);
}
+429
View File
@@ -0,0 +1,429 @@
//! Authored Scenario presentation. Structural admission never changes event order.
use crate::{
Diagram, Id, Kind, Layer, MAX_COORDINATE, ModelError, OccurrenceBody, OccurrenceEndpoint,
Project, SharedBody, validation,
};
use serde::{Deserialize, Serialize};
use std::collections::BTreeSet;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub enum DiagramLayout {
Scenario(ScenarioLayout),
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct ScenarioLayout {
#[serde(default)]
pub lanes: Vec<ScenarioLanePosition>,
#[serde(default)]
pub messages: Vec<ScenarioMessagePosition>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ScenarioLanePosition {
pub lifeline: Id,
pub x: f32,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ScenarioMessagePosition {
pub address: OccurrenceEndpoint,
/// Actual send endpoint, not the renderer's receive-oriented lifecycle row.
pub send_y: f32,
}
impl DiagramLayout {
/// Visit every explicit reference, including unsupported paths in a candidate.
/// The caller owns remap preflight and final atomic validation.
pub fn visit_references(&mut self, mut visit: impl FnMut(&mut Id)) {
match self {
Self::Scenario(layout) => {
for lane in &mut layout.lanes {
visit(&mut lane.lifeline);
}
for message in &mut layout.messages {
for reference in &mut message.address.references {
visit(reference);
}
visit(&mut message.address.occurrence);
}
}
}
}
}
/// Proposed aggregate accounting contribution; the shared admission owner applies it.
/// One payload plus one record per lane/message. No graph objects are introduced.
pub fn record_count(diagram: &Diagram) -> usize {
match &diagram.layout {
None => 0,
Some(DiagramLayout::Scenario(layout)) => 1usize
.saturating_add(layout.lanes.len())
.saturating_add(layout.messages.len()),
}
}
fn coordinate(value: f32) -> bool {
value.is_finite() && value.abs() <= MAX_COORDINATE
}
fn owner(p: &Project, diagram: &Diagram) -> Result<Id, ModelError> {
let scenario = diagram
.projection
.scenario()
.ok_or_else(|| ModelError("Scenario layout requires a Scenario projection.".into()))?;
if !p
.elements
.get(&scenario)
.is_some_and(|element| element.kind() == Kind::Scenario && element.layer() == diagram.layer)
{
return Err(ModelError(
"Scenario projection requires an existing Scenario in the diagram layer.".into(),
));
}
Ok(scenario)
}
fn lane_member(p: &Project, owner: Id, layer: Layer, lifeline: Id) -> bool {
p.elements.get(&lifeline).is_some_and(|element| {
element.kind() == Kind::Lifeline
&& element.parent == Some(owner)
&& element.layer() == layer
})
}
/// Only actual steps of this Scenario and its own Fragment/Branch sequences.
/// ScenarioUse/SequenceReference are deliberately never expanded. Invalid graph
/// cycles terminate, and malformed parent/step links do not manufacture membership.
fn direct_messages(p: &Project, owner: Id, layer: Layer) -> BTreeSet<Id> {
let mut messages = BTreeSet::new();
let mut visited = BTreeSet::new();
let mut pending = vec![owner];
while let Some(scope) = pending.pop() {
if !visited.insert(scope) {
continue;
}
let Some(element) = p.elements.get(&scope) else {
continue;
};
for id in element.steps().into_iter().flatten() {
let Some(item) = p.elements.get(id) else {
continue;
};
if item.parent != Some(scope) || item.layer() != layer {
continue;
}
match item.shared() {
Some(SharedBody::Occurrence {
body: OccurrenceBody::Exchange { .. },
..
}) => {
messages.insert(*id);
}
Some(SharedBody::Occurrence {
body: OccurrenceBody::Fragment { branches, .. },
..
}) => {
for branch in branches {
if p.elements.get(branch).is_some_and(|operand| {
operand.parent == Some(*id)
&& operand.layer() == layer
&& matches!(
operand.shared(),
Some(SharedBody::Occurrence {
body: OccurrenceBody::Branch { .. },
..
})
)
}) {
pending.push(*branch);
}
}
}
_ => {}
}
}
}
messages
}
pub(crate) fn validate_diagram(p: &Project, diagram: &Diagram, out: &mut Vec<validation::Issue>) {
if diagram.projection.scenario().is_none() {
if diagram.layout.is_some() {
validation::error(
out,
diagram.id,
"scenario_layout_projection",
"layout",
"Only a Scenario projection accepts a Scenario layout, including an empty payload.",
);
}
return;
}
let scenario = match owner(p, diagram) {
Ok(owner) => owner,
Err(error) => {
validation::error(
out,
diagram.id,
"scenario_diagram_context",
"projection",
error.0,
);
return;
}
};
if !diagram.nodes.is_empty() || !diagram.routes.is_empty() {
validation::error(
out,
diagram.id,
"scenario_diagram_content",
"layout",
"Scenario views do not accept architecture node placements or edge routes.",
);
}
let Some(DiagramLayout::Scenario(layout)) = &diagram.layout else {
return;
};
let mut lanes = BTreeSet::new();
for lane in &layout.lanes {
if !lanes.insert(lane.lifeline) {
validation::error(
out,
diagram.id,
"duplicate_scenario_lane",
"layout",
"A Scenario layout has at most one position per lifeline.",
);
}
if !lane_member(p, scenario, diagram.layer, lane.lifeline) {
validation::error(
out,
diagram.id,
"scenario_layout_lifeline",
"layout",
"Lane positions reference a Lifeline directly owned by the projected Scenario.",
);
}
if !coordinate(lane.x) {
validation::error(
out,
diagram.id,
"scenario_layout_coordinate",
"layout",
"Scenario positions must be finite and within the shared coordinate bounds.",
);
}
}
let members = direct_messages(p, scenario, diagram.layer);
let mut addresses = BTreeSet::new();
for message in &layout.messages {
// Reject expanded paths without cloning or indexing their potentially large contents.
if !message.address.references.is_empty() {
validation::error(
out,
diagram.id,
"scenario_layout_address",
"layout",
"Expanded-reference message positions are not supported; references must be empty.",
);
} else if !addresses.insert(message.address.occurrence) {
validation::error(
out,
diagram.id,
"duplicate_scenario_message",
"layout",
"A Scenario layout has at most one position per message address.",
);
}
if !members.contains(&message.address.occurrence) {
validation::error(
out,
diagram.id,
"scenario_layout_message",
"layout",
"Message positions reference Exchange occurrences in the Scenario's own ordered sequence tree.",
);
}
if !coordinate(message.send_y) {
validation::error(
out,
diagram.id,
"scenario_layout_coordinate",
"layout",
"Scenario positions must be finite and within the shared coordinate bounds.",
);
}
}
}
fn editable(p: &Project, id: Id) -> Result<(&Diagram, Id), ModelError> {
let diagram = p
.diagrams
.get(&id)
.ok_or_else(|| ModelError("Scenario diagram does not exist.".into()))?;
let scenario = owner(p, diagram)?;
if !diagram.nodes.is_empty() || !diagram.routes.is_empty() {
return Err(ModelError(
"Scenario views do not accept architecture node placements or edge routes.".into(),
));
}
Ok((diagram, scenario))
}
fn check_message(
p: &Project,
diagram: &Diagram,
owner: Id,
address: &OccurrenceEndpoint,
) -> Result<(), ModelError> {
if !address.references.is_empty() {
return Err(ModelError(
"Expanded-reference message positions are not supported; references must be empty."
.into(),
));
}
if !direct_messages(p, owner, diagram.layer).contains(&address.occurrence) {
return Err(ModelError("Message position requires an Exchange occurrence in the Scenario's own ordered sequence tree.".into()));
}
Ok(())
}
fn payload(diagram: &mut Diagram) -> &mut ScenarioLayout {
let DiagramLayout::Scenario(layout) = diagram
.layout
.get_or_insert_with(|| DiagramLayout::Scenario(ScenarioLayout::default()));
layout
}
/// Target-scoped preparation, not whole-project or whole-layout validation.
/// Unrelated intermediate errors remain repairable by later commands in a batch.
pub fn set_lane(
p: &Project,
diagram: Id,
position: ScenarioLanePosition,
) -> Result<Diagram, ModelError> {
let (current, owner) = editable(p, diagram)?;
if !lane_member(p, owner, current.layer, position.lifeline) || !coordinate(position.x) {
return Err(ModelError(
"Lane position requires an owned Lifeline and a finite bounded x coordinate.".into(),
));
}
let mut result = current.clone();
let layout = payload(&mut result);
if layout
.lanes
.iter()
.filter(|row| row.lifeline == position.lifeline)
.count()
> 1
{
return Err(ModelError(
"Cannot set an ambiguous duplicate lane position.".into(),
));
}
if let Some(existing) = layout
.lanes
.iter_mut()
.find(|row| row.lifeline == position.lifeline)
{
if existing.x != position.x {
*existing = position;
}
} else {
layout.lanes.push(position);
}
Ok(result)
}
pub fn remove_lane(p: &Project, diagram: Id, lifeline: Id) -> Result<Diagram, ModelError> {
let (current, owner) = editable(p, diagram)?;
if !lane_member(p, owner, current.layer, lifeline) {
return Err(ModelError(
"Removing a lane position requires an owned Lifeline.".into(),
));
}
let mut result = current.clone();
if let Some(DiagramLayout::Scenario(layout)) = &mut result.layout {
layout.lanes.retain(|row| row.lifeline != lifeline);
}
Ok(result)
}
pub fn set_message(
p: &Project,
diagram: Id,
position: ScenarioMessagePosition,
) -> Result<Diagram, ModelError> {
let (current, owner) = editable(p, diagram)?;
check_message(p, current, owner, &position.address)?;
if !coordinate(position.send_y) {
return Err(ModelError(
"Message position requires a finite bounded send_y coordinate.".into(),
));
}
let mut result = current.clone();
let layout = payload(&mut result);
if layout
.messages
.iter()
.filter(|row| row.address == position.address)
.count()
> 1
{
return Err(ModelError(
"Cannot set an ambiguous duplicate message position.".into(),
));
}
if let Some(existing) = layout
.messages
.iter_mut()
.find(|row| row.address == position.address)
{
if existing.send_y != position.send_y {
*existing = position;
}
} else {
layout.messages.push(position);
}
Ok(result)
}
pub fn remove_message(
p: &Project,
diagram: Id,
address: &OccurrenceEndpoint,
) -> Result<Diagram, ModelError> {
let (current, owner) = editable(p, diagram)?;
check_message(p, current, owner, address)?;
let mut result = current.clone();
if let Some(DiagramLayout::Scenario(layout)) = &mut result.layout {
layout.messages.retain(|row| &row.address != address);
}
Ok(result)
}
/// Prune only identities in the caller's actual deletion closure, never visibility
/// or missing endpoint assignments. Owner-view cascade remains the caller's job.
pub fn prune_deleted(diagram: &mut Diagram, deleted: &BTreeSet<Id>) {
if let Some(DiagramLayout::Scenario(layout)) = &mut diagram.layout {
layout
.lanes
.retain(|lane| !deleted.contains(&lane.lifeline));
layout.messages.retain(|message| {
!deleted.contains(&message.address.occurrence)
&& !message
.address
.references
.iter()
.any(|id| deleted.contains(id))
});
}
}
#[cfg(test)]
mod tests;
+800
View File
@@ -0,0 +1,800 @@
use super::*;
use crate::{
Element, FragmentOperator, MessageKind, ProjectionKind, Relation, RelationBody, RelationKind,
ScenarioViewpoint,
};
use std::collections::BTreeMap;
struct Fixture {
project: Project,
owner: Id,
performer: Id,
lane: Id,
repeated_lane: Id,
first: Id,
second: Id,
nested: Id,
fragment: Id,
branch: Id,
view: Id,
other_view: Id,
exchange: Id,
}
fn set_body(element: &mut Element, body: OccurrenceBody) {
*element.shared_mut().unwrap() = SharedBody::Occurrence {
body,
precondition: String::new(),
postcondition: String::new(),
};
}
fn address(occurrence: Id) -> OccurrenceEndpoint {
OccurrenceEndpoint {
references: vec![],
occurrence,
}
}
fn diagram(owner: Id, alias: &str) -> Diagram {
Diagram {
id: Id::new_v4(),
alias: alias.into(),
name: alias.into(),
description: String::new(),
layer: Layer::Oa,
projection: ProjectionKind::Scenario { scenario: owner },
view_options: Default::default(),
nodes: BTreeMap::new(),
routes: vec![],
layout: None,
}
}
fn assert_admitted(project: &Project) {
let errors: Vec<_> = validation::validate(project)
.into_iter()
.filter(|issue| issue.severity == validation::Severity::Error)
.collect();
assert!(errors.is_empty(), "{errors:?}");
}
fn has_code(project: &Project, code: &str) -> bool {
validation::validate(project)
.iter()
.any(|issue| issue.code == code)
}
fn layout(diagram: &Diagram) -> &ScenarioLayout {
let Some(DiagramLayout::Scenario(layout)) = &diagram.layout else {
panic!("expected saved payload")
};
layout
}
fn save(project: &mut Project, replacement: Diagram) {
project.diagrams.insert(replacement.id, replacement);
}
fn fixture() -> Fixture {
let mut project = Project::empty();
let owner = project
.add_element(Kind::Scenario, Layer::Oa, "Nominal", None)
.unwrap();
let Some(SharedBody::Scenario { viewpoint, .. }) =
project.elements.get_mut(&owner).unwrap().shared_mut()
else {
panic!("Scenario fixture body");
};
*viewpoint = ScenarioViewpoint::ParticipantInteraction;
let performer = project
.add_element(Kind::Entity, Layer::Oa, "Controller", None)
.unwrap();
let receiver = project
.add_element(Kind::Entity, Layer::Oa, "Provider", None)
.unwrap();
let lane = project
.add_element(Kind::Lifeline, Layer::Oa, "Controller lane", Some(owner))
.unwrap();
let repeated_lane = project
.add_element(
Kind::Lifeline,
Layer::Oa,
"Controller review lane",
Some(owner),
)
.unwrap();
let receiver_lane = project
.add_element(Kind::Lifeline, Layer::Oa, "Provider lane", Some(owner))
.unwrap();
for (id, represented) in [
(lane, performer),
(repeated_lane, performer),
(receiver_lane, receiver),
] {
*project.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::Lifeline {
represented: Some(represented),
};
}
let exchange = Id::new_v4();
project.relations.insert(
exchange,
Relation {
id: exchange,
alias: "delivery_channel".into(),
name: "Delivery".into(),
description: String::new(),
body: RelationBody::new(RelationKind::CommunicationMean, performer, receiver),
},
);
let first = project
.add_element(Kind::Occurrence, Layer::Oa, "First delivery", Some(owner))
.unwrap();
let second = project
.add_element(Kind::Occurrence, Layer::Oa, "Second delivery", Some(owner))
.unwrap();
for (id, sender) in [(first, lane), (second, repeated_lane)] {
set_body(
project.elements.get_mut(&id).unwrap(),
OccurrenceBody::Exchange {
exchange: Some(exchange),
sender: Some(sender),
receiver: Some(receiver_lane),
message_kind: MessageKind::Asynchronous,
reply_to: None,
values: vec![],
operation: None,
},
);
}
let fragment = project
.add_element(
Kind::Occurrence,
Layer::Oa,
"Optional delivery",
Some(owner),
)
.unwrap();
set_body(
project.elements.get_mut(&fragment).unwrap(),
OccurrenceBody::Fragment {
operator: FragmentOperator::Optional,
branches: vec![],
},
);
let branch = project
.add_element(Kind::Occurrence, Layer::Oa, "Option body", Some(fragment))
.unwrap();
let nested = project
.add_element(
Kind::Occurrence,
Layer::Oa,
"Unfinished nested delivery",
Some(branch),
)
.unwrap();
set_body(
project.elements.get_mut(&nested).unwrap(),
OccurrenceBody::Exchange {
exchange: None,
sender: None,
receiver: None,
message_kind: MessageKind::Asynchronous,
reply_to: None,
values: vec![],
operation: None,
},
);
set_body(
project.elements.get_mut(&branch).unwrap(),
OccurrenceBody::Branch {
guard: "requested".into(),
occurrences: vec![nested],
},
);
set_body(
project.elements.get_mut(&fragment).unwrap(),
OccurrenceBody::Fragment {
operator: FragmentOperator::Optional,
branches: vec![branch],
},
);
*project
.elements
.get_mut(&owner)
.unwrap()
.steps_mut()
.unwrap() = vec![first, second, fragment];
let view_record = diagram(owner, "nominal_view");
let other_record = diagram(owner, "review_view");
let view = view_record.id;
let other_view = other_record.id;
save(&mut project, view_record);
save(&mut project, other_record);
assert_admitted(&project);
Fixture {
project,
owner,
performer,
lane,
repeated_lane,
first,
second,
nested,
fragment,
branch,
view,
other_view,
exchange,
}
}
#[test]
fn two_views_and_repeated_semantic_definitions_keep_independent_overrides() {
let mut f = fixture();
let semantics = (f.project.elements.clone(), f.project.relations.clone());
for (view, line, x) in [
(f.view, f.lane, 420.),
(f.view, f.repeated_lane, 680.),
(f.other_view, f.lane, 900.),
] {
let result =
set_lane(&f.project, view, ScenarioLanePosition { lifeline: line, x }).unwrap();
save(&mut f.project, result);
}
for (view, id, y) in [
(f.view, f.first, 200.),
(f.view, f.second, 350.),
(f.other_view, f.first, 260.),
] {
let result = set_message(
&f.project,
view,
ScenarioMessagePosition {
address: address(id),
send_y: y,
},
)
.unwrap();
save(&mut f.project, result);
}
assert_eq!(
layout(&f.project.diagrams[&f.view]).lanes,
vec![
ScenarioLanePosition {
lifeline: f.lane,
x: 420.
},
ScenarioLanePosition {
lifeline: f.repeated_lane,
x: 680.
},
]
);
assert_eq!(
layout(&f.project.diagrams[&f.view])
.messages
.iter()
.map(|r| (r.address.occurrence, r.send_y))
.collect::<Vec<_>>(),
vec![(f.first, 200.), (f.second, 350.)]
);
assert_eq!(
layout(&f.project.diagrams[&f.other_view]).messages[0].send_y,
260.
);
assert_eq!(layout(&f.project.diagrams[&f.other_view]).lanes[0].x, 900.);
assert_eq!(
(f.project.elements.clone(), f.project.relations.clone()),
semantics
);
assert_admitted(&f.project);
}
#[test]
fn own_nested_unfinished_message_is_admitted_but_expansion_is_not() {
let mut f = fixture();
let result = set_message(
&f.project,
f.view,
ScenarioMessagePosition {
address: address(f.nested),
send_y: -80.,
},
)
.unwrap();
save(&mut f.project, result);
assert_admitted(&f.project);
let before = f.project.clone();
let expanded = OccurrenceEndpoint {
references: vec![f.fragment],
occurrence: f.nested,
};
assert!(
set_message(
&f.project,
f.view,
ScenarioMessagePosition {
address: expanded.clone(),
send_y: 400.
}
)
.is_err()
);
assert!(remove_message(&f.project, f.view, &expanded).is_err());
assert_eq!(f.project, before);
payload(f.project.diagrams.get_mut(&f.view).unwrap()).messages[0].address = expanded;
assert!(has_code(&f.project, "scenario_layout_address"));
}
#[test]
fn only_ordered_message_appearances_and_owned_lifelines_are_targets() {
let mut f = fixture();
assert!(
set_lane(
&f.project,
f.view,
ScenarioLanePosition {
lifeline: f.performer,
x: 10.
}
)
.is_err()
);
for id in [f.exchange, f.fragment, f.branch, f.owner, Id::new_v4()] {
assert!(
set_message(
&f.project,
f.view,
ScenarioMessagePosition {
address: address(id),
send_y: 100.
}
)
.is_err()
);
}
let foreign = f
.project
.add_element(Kind::Scenario, Layer::Oa, "Foreign", None)
.unwrap();
let foreign_lane = f
.project
.add_element(Kind::Lifeline, Layer::Oa, "Foreign lane", Some(foreign))
.unwrap();
assert!(remove_lane(&f.project, f.view, foreign_lane).is_err());
f.project.elements.get_mut(&f.first).unwrap().parent = Some(foreign);
assert!(
set_message(
&f.project,
f.view,
ScenarioMessagePosition {
address: address(f.first),
send_y: 100.
}
)
.is_err()
);
f.project.elements.get_mut(&f.first).unwrap().parent = Some(f.owner);
f.project
.elements
.get_mut(&f.owner)
.unwrap()
.steps_mut()
.unwrap()
.retain(|id| *id != f.first);
assert!(
set_message(
&f.project,
f.view,
ScenarioMessagePosition {
address: address(f.first),
send_y: 100.
}
)
.is_err()
);
}
#[test]
fn duplicate_positions_reject_even_when_values_match() {
let mut f = fixture();
let lane = ScenarioLanePosition {
lifeline: f.lane,
x: 10.,
};
let message = ScenarioMessagePosition {
address: address(f.first),
send_y: 100.,
};
let entries = payload(f.project.diagrams.get_mut(&f.view).unwrap());
entries.lanes = vec![lane.clone(), lane.clone()];
entries.messages = vec![message.clone(), message.clone()];
assert!(has_code(&f.project, "duplicate_scenario_lane"));
assert!(has_code(&f.project, "duplicate_scenario_message"));
assert!(set_lane(&f.project, f.view, lane).is_err());
assert!(set_message(&f.project, f.view, message).is_err());
// Explicit removal can repair all duplicates of the targeted identity.
let result = remove_lane(&f.project, f.view, f.lane).unwrap();
save(&mut f.project, result);
let result = remove_message(&f.project, f.view, &address(f.first)).unwrap();
save(&mut f.project, result);
assert_admitted(&f.project);
}
#[test]
fn sparse_absence_empty_payload_and_signed_zero_are_preserved() {
let mut f = fixture();
let absent = f.project.diagrams[&f.view].clone();
assert_eq!(remove_lane(&f.project, f.view, f.lane).unwrap(), absent);
assert_eq!(
remove_message(&f.project, f.view, &address(f.first)).unwrap(),
absent
);
assert!(
serde_json::to_value(&absent)
.unwrap()
.get("layout")
.is_none()
);
let result = set_lane(
&f.project,
f.view,
ScenarioLanePosition {
lifeline: f.lane,
x: -0.,
},
)
.unwrap();
save(&mut f.project, result);
let result = set_message(
&f.project,
f.view,
ScenarioMessagePosition {
address: address(f.first),
send_y: -0.,
},
)
.unwrap();
save(&mut f.project, result);
let result = set_lane(
&f.project,
f.view,
ScenarioLanePosition {
lifeline: f.lane,
x: 0.,
},
)
.unwrap();
assert_eq!(layout(&result).lanes[0].x.to_bits(), (-0.0_f32).to_bits());
let result = set_message(
&f.project,
f.view,
ScenarioMessagePosition {
address: address(f.first),
send_y: 0.,
},
)
.unwrap();
assert_eq!(
layout(&result).messages[0].send_y.to_bits(),
(-0.0_f32).to_bits()
);
assert_eq!(
serde_json::to_string(&result).unwrap(),
serde_json::to_string(&f.project.diagrams[&f.view]).unwrap()
);
let result = remove_lane(&f.project, f.view, f.lane).unwrap();
save(&mut f.project, result);
let result = remove_message(&f.project, f.view, &address(f.first)).unwrap();
assert_eq!(
result.layout,
Some(DiagramLayout::Scenario(ScenarioLayout::default()))
);
assert_ne!(result, absent);
}
#[test]
fn bounds_are_structural_and_rejected_helpers_do_not_mutate_project() {
let mut f = fixture();
for value in [-MAX_COORDINATE, MAX_COORDINATE, -7., 0.] {
let result = set_lane(
&f.project,
f.view,
ScenarioLanePosition {
lifeline: f.lane,
x: value,
},
)
.unwrap();
save(&mut f.project, result);
let result = set_message(
&f.project,
f.view,
ScenarioMessagePosition {
address: address(f.first),
send_y: value,
},
)
.unwrap();
save(&mut f.project, result);
assert_admitted(&f.project);
}
let before = serde_json::to_string(&f.project).unwrap();
for value in [
f32::NAN,
f32::INFINITY,
f32::NEG_INFINITY,
MAX_COORDINATE + 1.,
] {
assert!(
set_lane(
&f.project,
f.view,
ScenarioLanePosition {
lifeline: f.lane,
x: value
}
)
.is_err()
);
assert!(
set_message(
&f.project,
f.view,
ScenarioMessagePosition {
address: address(f.first),
send_y: value
}
)
.is_err()
);
}
assert_eq!(serde_json::to_string(&f.project).unwrap(), before);
payload(f.project.diagrams.get_mut(&f.view).unwrap()).messages[0].send_y = f32::NAN;
assert!(has_code(&f.project, "scenario_layout_coordinate"));
}
#[test]
fn helpers_do_not_validate_unrelated_transient_records_in_repair_batches() {
let mut f = fixture();
payload(f.project.diagrams.get_mut(&f.view).unwrap())
.messages
.push(ScenarioMessagePosition {
address: address(f.second),
send_y: f32::INFINITY,
});
let result = set_lane(
&f.project,
f.view,
ScenarioLanePosition {
lifeline: f.lane,
x: 420.,
},
)
.unwrap();
save(&mut f.project, result);
assert!(has_code(&f.project, "scenario_layout_coordinate"));
let result = set_message(
&f.project,
f.view,
ScenarioMessagePosition {
address: address(f.second),
send_y: 300.,
},
)
.unwrap();
save(&mut f.project, result);
assert_admitted(&f.project);
}
#[test]
fn projection_context_and_architecture_payloads_are_explicitly_separate() {
let mut f = fixture();
let scenario = f.project.diagrams[&f.view].projection;
assert_eq!(scenario.scenario(), Some(f.owner));
assert!(!scenario.is_architecture());
assert!(!scenario.includes(Kind::Lifeline));
assert!(ProjectionKind::Overview.is_architecture());
assert!(!ProjectionKind::StateMachine { machine: f.owner }.is_architecture());
f.project.diagrams.get_mut(&f.view).unwrap().layer = Layer::Sa;
assert!(has_code(&f.project, "scenario_diagram_context"));
assert!(remove_lane(&f.project, f.view, f.lane).is_err());
f.project.diagrams.get_mut(&f.view).unwrap().layer = Layer::Oa;
f.project
.diagrams
.get_mut(&f.view)
.unwrap()
.routes
.push(crate::EdgeRoute {
edge: f.exchange,
waypoints: vec![],
label: None,
});
assert!(has_code(&f.project, "scenario_diagram_content"));
f.project.diagrams.get_mut(&f.view).unwrap().routes.clear();
payload(f.project.diagrams.get_mut(&f.view).unwrap());
f.project.diagrams.get_mut(&f.view).unwrap().projection = ProjectionKind::Overview;
assert!(has_code(&f.project, "scenario_layout_projection"));
assert!(
set_lane(
&f.project,
f.view,
ScenarioLanePosition {
lifeline: f.lane,
x: 10.
}
)
.is_err()
);
}
#[test]
fn typed_json_records_round_trip_and_reject_unknown_or_missing_fields() {
let mut f = fixture();
let result = set_message(
&f.project,
f.view,
ScenarioMessagePosition {
address: address(f.first),
send_y: 200.,
},
)
.unwrap();
save(&mut f.project, result);
let encoded = serde_json::to_value(&f.project.diagrams[&f.view]).unwrap();
assert_eq!(
serde_json::from_value::<Diagram>(encoded.clone()).unwrap(),
f.project.diagrams[&f.view]
);
assert_eq!(
encoded["layout"]["Scenario"]["messages"][0]["address"]["occurrence"],
serde_json::json!(f.first)
);
let mut unknown = encoded.clone();
unknown["layout"]["Scenario"]["messages"][0]["extra"] = serde_json::json!(1);
assert!(serde_json::from_value::<Diagram>(unknown).is_err());
let mut missing = encoded;
missing["layout"]["Scenario"]["messages"][0]["address"]
.as_object_mut()
.unwrap()
.remove("references");
assert!(serde_json::from_value::<Diagram>(missing).is_err());
}
#[test]
fn deleting_only_actual_ids_preserves_latent_message_positions_and_other_views() {
let mut f = fixture();
let result = set_lane(
&f.project,
f.view,
ScenarioLanePosition {
lifeline: f.lane,
x: 420.,
},
)
.unwrap();
save(&mut f.project, result);
let result = set_message(
&f.project,
f.view,
ScenarioMessagePosition {
address: address(f.first),
send_y: 200.,
},
)
.unwrap();
save(&mut f.project, result);
let mut copied = f.project.diagrams[&f.view].clone();
copied.id = f.other_view;
copied.alias = "review_view".into();
copied.name = "Review view".into();
save(&mut f.project, copied.clone());
assert_admitted(&f.project);
assert_eq!(layout(&copied).messages[0].address.occurrence, f.first);
let mut candidate = f.project.diagrams[&f.view].clone();
prune_deleted(&mut candidate, &BTreeSet::from([f.lane, f.exchange]));
assert!(layout(&candidate).lanes.is_empty());
assert_eq!(layout(&candidate).messages.len(), 1);
prune_deleted(&mut candidate, &BTreeSet::from([f.first]));
assert!(layout(&candidate).messages.is_empty());
assert!(candidate.layout.is_some());
assert_eq!(f.project.diagrams[&f.other_view], copied);
}
#[test]
fn reference_visitor_and_record_count_cover_explicit_rows() {
let f = fixture();
let mut d = f.project.diagrams[&f.view].clone();
assert_eq!(record_count(&d), 0);
let rows = payload(&mut d);
rows.lanes.push(ScenarioLanePosition {
lifeline: f.lane,
x: 50.,
});
rows.messages.push(ScenarioMessagePosition {
address: address(f.first),
send_y: 100.,
});
assert_eq!(record_count(&d), 3);
let replacement = Id::new_v4();
d.layout.as_mut().unwrap().visit_references(|id| {
if *id == f.first {
*id = replacement;
}
});
assert_eq!(layout(&d).messages[0].address.occurrence, replacement);
assert_eq!(layout(&d).messages[0].send_y, 100.);
assert_eq!(layout(&d).lanes[0].lifeline, f.lane);
}
#[test]
fn a_real_scenario_use_never_imports_its_called_message_as_a_direct_target() {
let mut f = fixture();
let called = f
.project
.add_element(Kind::Scenario, Layer::Oa, "Called sequence", None)
.unwrap();
let Some(SharedBody::Scenario { viewpoint, .. }) =
f.project.elements.get_mut(&called).unwrap().shared_mut()
else {
panic!("Called Scenario fixture body");
};
*viewpoint = ScenarioViewpoint::ParticipantInteraction;
let called_message = f
.project
.add_element(Kind::Occurrence, Layer::Oa, "Called delivery", Some(called))
.unwrap();
set_body(
f.project.elements.get_mut(&called_message).unwrap(),
OccurrenceBody::Exchange {
exchange: None,
sender: None,
receiver: None,
message_kind: MessageKind::Asynchronous,
reply_to: None,
values: vec![],
operation: None,
},
);
*f.project
.elements
.get_mut(&called)
.unwrap()
.steps_mut()
.unwrap() = vec![called_message];
let usage = f
.project
.add_element(Kind::Occurrence, Layer::Oa, "Call sequence", Some(f.owner))
.unwrap();
set_body(
f.project.elements.get_mut(&usage).unwrap(),
OccurrenceBody::ScenarioUse {
scenario: Some(called),
bindings: vec![],
},
);
f.project
.elements
.get_mut(&f.owner)
.unwrap()
.steps_mut()
.unwrap()
.push(usage);
assert_admitted(&f.project);
for target in [
address(called_message),
address(usage),
OccurrenceEndpoint {
references: vec![usage],
occurrence: called_message,
},
] {
assert!(
set_message(
&f.project,
f.view,
ScenarioMessagePosition {
address: target,
send_y: 200.
}
)
.is_err()
);
}
}
mod source_lifecycle;
@@ -0,0 +1,245 @@
//! Source/command lifecycle checks for the candidate payload. Public version
//! assignment remains the coordinated codec integration gate.
use super::*;
use crate::{Command, Session, language, prepare_command, source_project::*};
fn authored(f: &Fixture) -> CompiledSourceProject {
let mut bundle = format_bundle(&f.project);
let initial = compile_bundle(&bundle, ProjectLimits::default()).unwrap();
let location = &initial.index.declarations[&f.view];
let source = bundle.files.get_mut(&location.path).unwrap();
let text = format!(
r#"diagram nominal_view "Nominal review" id "{}" {{
projection {{ Scenario {{ scenario @{}; }}; }};
layout {{ Scenario {{
lanes [
// Controller spacing rationale.
{{ lifeline @ // first lane identity
{}; x 4.20e2; }},
// Repeated performer has its own lane.
{{ lifeline @{}; x 680; }}
];
messages [
// First delivery timing rationale.
{{ address {{ references []; occurrence @{}; }}; send_y 2.00e2; }},
// Second delivery timing rationale.
{{ address {{ references []; occurrence @{}; }}; send_y 350; }}
];
}}; }};
}}
"#,
f.view,
f.project.elements[&f.owner].alias,
f.project.elements[&f.lane].alias,
f.project.elements[&f.repeated_lane].alias,
f.project.elements[&f.first].alias,
f.project.elements[&f.second].alias
);
source.replace_range(location.span.start..location.span.end, &text);
compile_bundle(&bundle, ProjectLimits::default()).unwrap()
}
#[test]
fn source_rows_follow_identity_order_and_keep_comments_numeric_spelling_and_file_ownership() {
let f = fixture();
let original = authored(&f);
let mut changed = original.decoded.project.clone();
let rows = payload(changed.diagrams.get_mut(&f.view).unwrap());
rows.lanes.swap(0, 1);
rows.messages.swap(0, 1);
rows.messages[0].send_y = 390.;
changed.elements.get_mut(&f.lane).unwrap().alias = "renamed_controller_lane".into();
let updated = update_bundle(&original, &changed, ProjectLimits::default()).unwrap();
assert_eq!(updated.decoded.project, changed);
let path = &original.index.declarations[&f.view].path;
assert_eq!(&updated.index.declarations[&f.view].path, path);
let text = &updated.bundle.files[path];
assert!(
text.find("// Repeated performer").unwrap() < text.find("// Controller spacing").unwrap(),
"{text}"
);
assert!(
text.find("// Second delivery").unwrap() < text.find("// First delivery").unwrap(),
"{text}"
);
assert!(
text.contains(
"lifeline @ // first lane identity\n renamed_controller_lane; x 4.20e2;"
),
"{text}"
);
assert!(text.contains("send_y 2.00e2"), "{text}");
assert!(text.contains("send_y 390"), "{text}");
for (other, bytes) in &original.bundle.files {
if other != path && !bytes.contains(&f.project.elements[&f.lane].alias) {
assert_eq!(&updated.bundle.files[other], bytes);
}
}
assert_eq!(
compile_bundle(&updated.bundle, ProjectLimits::default())
.unwrap()
.decoded
.project,
changed
);
}
#[test]
fn source_identity_swap_keeps_each_coordinate_comment_with_its_original_object() {
let f = fixture();
let original = authored(&f);
let temporary = Id::new_v4();
let command = Command::Batch(vec![
Command::Reidentify(f.first, temporary),
Command::Reidentify(f.second, f.first),
Command::Reidentify(temporary, f.second),
]);
let prepared = prepare_command(&original.decoded.project, &command).unwrap();
let mut changed = prepared.project;
payload(changed.diagrams.get_mut(&f.view).unwrap())
.messages
.swap(0, 1);
let updated = update_bundle_with_identity_map(
&original,
&changed,
&prepared.identity_map,
ProjectLimits::default(),
)
.unwrap();
assert_eq!(updated.decoded.project, changed);
let path = &original.index.declarations[&f.view].path;
let text = &updated.bundle.files[path];
assert!(
text.find("// Second delivery").unwrap() < text.find("// First delivery").unwrap(),
"{text}"
);
let first_comment = text.find("// First delivery").unwrap();
assert!(text[first_comment..].contains(&format!(
"occurrence @{}",
changed.elements[&f.second].alias
)));
assert!(text[first_comment..].contains("send_y 2.00e2"));
}
#[test]
fn coordinate_batch_is_one_undo_and_numeric_noop_keeps_source_exact() {
let f = fixture();
let original = authored(&f);
let mut session = Session::new(original.decoded.project.clone());
session
.execute(Command::Batch(vec![
Command::SetScenarioLanePosition {
diagram: f.view,
position: ScenarioLanePosition {
lifeline: f.lane,
x: 510.,
},
},
Command::SetScenarioMessagePosition {
diagram: f.view,
position: ScenarioMessagePosition {
address: address(f.second),
send_y: 430.,
},
},
]))
.unwrap();
let changed = session.project().clone();
assert_eq!(session.revision(), 1);
assert_eq!(changed.elements, original.decoded.project.elements);
assert_eq!(changed.relations, original.decoded.project.relations);
let updated = update_bundle(&original, &changed, ProjectLimits::default()).unwrap();
session.undo();
assert_eq!(session.project(), &original.decoded.project);
assert!(!session.can_undo());
session.redo();
assert_eq!(session.project(), &changed);
session
.execute(Command::SetScenarioMessagePosition {
diagram: f.view,
position: ScenarioMessagePosition {
address: address(f.second),
send_y: 430.,
},
})
.unwrap();
assert_eq!(session.revision(), 3);
let noop = update_bundle(&updated, session.project(), ProjectLimits::default()).unwrap();
assert_eq!(noop.bundle, updated.bundle);
}
#[test]
fn absent_and_empty_payload_round_trip_distinctly_and_remove_does_not_materialize_absence() {
let f = fixture();
let before = language::format(&f.project);
let mut session = Session::new(f.project.clone());
session
.execute(Command::RemoveScenarioLanePosition {
diagram: f.view,
lifeline: f.lane,
})
.unwrap();
assert_eq!(session.project(), &f.project);
assert!(!session.can_undo());
assert_eq!(language::format(session.project()), before);
let mut present = f.project.clone();
present.diagrams.get_mut(&f.view).unwrap().layout =
Some(DiagramLayout::Scenario(ScenarioLayout::default()));
let text = language::format(&present);
assert_ne!(text, before);
assert_eq!(language::parse(&text).unwrap(), present);
assert_eq!(language::parse(&before).unwrap(), f.project);
}
#[test]
fn invalid_layout_source_rejects_without_changing_last_compiled_workspace() {
let f = fixture();
let original = authored(&f);
let before = original.bundle.clone();
let path = original.index.declarations[&f.view].path.clone();
for replacement in ["x 1e30", "x not_a_number"] {
let mut candidate = before.clone();
let source = candidate.files.get_mut(&path).unwrap();
*source = source.replace("x 4.20e2", replacement);
assert!(compile_bundle(&candidate, ProjectLimits::default()).is_err());
assert_eq!(original.bundle, before);
}
let mut candidate = before.clone();
let source = candidate.files.get_mut(&path).unwrap();
*source = source.replace(
&format!("occurrence @{}", f.project.elements[&f.first].alias),
&format!("occurrence @{}", f.project.elements[&f.performer].alias),
);
assert!(compile_bundle(&candidate, ProjectLimits::default()).is_err());
assert_eq!(original.bundle, before);
}
#[test]
fn explicit_none_survives_other_edits_while_real_payload_clear_removes_values() {
let f = fixture();
let mut bundle = format_bundle(&f.project);
let compiled = compile_bundle(&bundle, ProjectLimits::default()).unwrap();
let location = &compiled.index.declarations[&f.view];
let path = location.path.clone();
bundle.files.get_mut(&path).unwrap().insert_str(
location.span.end - 1,
"\n layout // explicitly automatic\n none;\n",
);
let original = compile_bundle(&bundle, ProjectLimits::default()).unwrap();
let mut described = original.decoded.project.clone();
described.diagrams.get_mut(&f.view).unwrap().description =
"Review spacing independently".into();
let updated = update_bundle(&original, &described, ProjectLimits::default()).unwrap();
assert!(updated.bundle.files[&path].contains("layout // explicitly automatic\n none;"));
assert_eq!(updated.decoded.project, described);
let mut empty = described.clone();
empty.diagrams.get_mut(&f.view).unwrap().layout =
Some(DiagramLayout::Scenario(ScenarioLayout::default()));
let present = update_bundle(&updated, &empty, ProjectLimits::default()).unwrap();
assert_eq!(present.decoded.project, empty);
assert!(present.bundle.files[&path].contains("// explicitly automatic"));
let cleared = update_bundle(&present, &described, ProjectLimits::default()).unwrap();
assert_eq!(cleared.decoded.project, described);
assert!(!cleared.bundle.files[&path].contains("layout"));
assert!(cleared.bundle.files[&path].contains("// explicitly automatic"));
}
+43 -1
View File
@@ -23,6 +23,22 @@ pub enum Command {
EditRelation(Id, Relation),
CreateDiagram(Diagram),
EditDiagram(Id, Diagram),
SetScenarioLanePosition {
diagram: Id,
position: ScenarioLanePosition,
},
RemoveScenarioLanePosition {
diagram: Id,
lifeline: Id,
},
SetScenarioMessagePosition {
diagram: Id,
position: ScenarioMessagePosition,
},
RemoveScenarioMessagePosition {
diagram: Id,
address: OccurrenceEndpoint,
},
CreateArtifact(ArtifactLink),
EditArtifact(Id, ArtifactLink),
CreateTrace(Trace),
@@ -488,6 +504,26 @@ fn apply_command(
reidentify_tracked(p, *old, value.id, provenance)?;
Some(value.id)
}
Command::SetScenarioLanePosition { diagram, position } => {
let replacement = crate::scenario_layout::set_lane(p, *diagram, position.clone())?;
p.diagrams.insert(*diagram, replacement);
Some(*diagram)
}
Command::RemoveScenarioLanePosition { diagram, lifeline } => {
let replacement = crate::scenario_layout::remove_lane(p, *diagram, *lifeline)?;
p.diagrams.insert(*diagram, replacement);
Some(*diagram)
}
Command::SetScenarioMessagePosition { diagram, position } => {
let replacement = crate::scenario_layout::set_message(p, *diagram, position.clone())?;
p.diagrams.insert(*diagram, replacement);
Some(*diagram)
}
Command::RemoveScenarioMessagePosition { diagram, address } => {
let replacement = crate::scenario_layout::remove_message(p, *diagram, address)?;
p.diagrams.insert(*diagram, replacement);
Some(*diagram)
}
Command::CreateArtifact(value) => {
vacant(p, value.id)?;
p.artifact_links.insert(value.id, value.clone());
@@ -878,6 +914,7 @@ pub fn reidentify(p: &mut Project, old: Id, new: Id) -> Result<(), ModelError> {
return Ok(());
}
vacant(p, new)?;
crate::references::check_remap(p, old, new)?;
if p.id == old {
p.id = new;
} else if let Some(mut v) = p.elements.remove(&old) {
@@ -904,12 +941,17 @@ pub fn reidentify(p: &mut Project, old: Id, new: Id) -> Result<(), ModelError> {
}
}
}
crate::references::remap_references(p, old, new);
crate::references::remap_references(p, old, new)?;
Ok(())
}
fn layout(p: &mut Project, diagram: Id) -> Result<(), ModelError> {
use std::collections::{BTreeMap, BTreeSet};
let d = p.diagrams.get(&diagram).ok_or_else(missing)?;
if d.projection.scenario().is_some() {
return Err(ModelError(
"Scenario layout requires the Scenario geometry operation.".into(),
));
}
let parents = crate::projection::parents(p, d);
let mut children: BTreeMap<Option<Id>, Vec<Id>> = BTreeMap::new();
for (id, parent) in &parents {
+109 -7
View File
@@ -930,7 +930,18 @@ pub fn update_bundle_with_identity_map(
};
let local = edits.entry(path.clone()).or_default();
update.header(&actual.header, &previous.header, &next.header, local);
update.value(&actual.fields, Some(&previous.fields), &next.fields, local);
let context = if old.diagrams.contains_key(&id) {
EditContext::Diagram
} else {
EditContext::Other
};
update.value(
&actual.fields,
Some(&previous.fields),
&next.fields,
context,
local,
);
}
}
if old.id != project.id || old.alias != project.alias || old.name != project.name {
@@ -1119,6 +1130,29 @@ fn inserted_value(text: &str, indent: &str, source: &str) -> String {
output
}
// Schema context is explicit: similarly named fields in arbitrary records must
// never acquire Scenario row identity or move their authored trivia.
#[derive(Clone, Copy)]
enum EditContext {
Other,
Diagram,
Layout,
Scenario,
Lanes,
Messages,
}
impl EditContext {
fn field(self, name: &str) -> Self {
match (self, name) {
(Self::Diagram, "layout") => Self::Layout,
(Self::Layout, "Scenario") => Self::Scenario,
(Self::Scenario, "lanes") => Self::Lanes,
(Self::Scenario, "messages") => Self::Messages,
_ => Self::Other,
}
}
}
struct SyntaxUpdate<'a> {
source: &'a str,
before: &'a str,
@@ -1185,6 +1219,7 @@ impl SyntaxUpdate<'_> {
actual: &language::EditValue,
before: Option<&language::EditValue>,
after: &language::EditValue,
context: EditContext,
edits: &mut LocalEdits,
) {
use language::EditKind;
@@ -1200,7 +1235,7 @@ impl SyntaxUpdate<'_> {
None
}
});
self.record(actual.span, fields, previous, next, edits);
self.record(actual.span, fields, previous, next, context, edits);
}
(EditKind::List(items), EditKind::List(next)) => {
let previous = before.and_then(|old| {
@@ -1210,7 +1245,7 @@ impl SyntaxUpdate<'_> {
None
}
});
self.list(actual.span, items, previous, next, edits);
self.list(actual.span, items, previous, next, context, edits);
}
(EditKind::Reference(_), EditKind::Reference(alias)) => {
let token = significant(&self.source[actual.span.start..actual.span.end])
@@ -1263,6 +1298,7 @@ impl SyntaxUpdate<'_> {
actual: &BTreeMap<String, language::EditField>,
before: Option<&BTreeMap<String, language::EditField>>,
after: &BTreeMap<String, language::EditField>,
context: EditContext,
edits: &mut LocalEdits,
) {
// A typed constructor tag change retains shared fields and their trivia.
@@ -1286,7 +1322,13 @@ impl SyntaxUpdate<'_> {
},
new_key.clone(),
));
self.value(&field.value, Some(&old.value), &new.value, edits);
self.value(
&field.value,
Some(&old.value),
&new.value,
context.field(new_key),
edits,
);
return;
}
}
@@ -1298,9 +1340,16 @@ impl SyntaxUpdate<'_> {
.and_then(|fields| fields.get(key))
.map(|field| &field.value),
&next.value,
context.field(key),
edits,
);
} else {
} else if !(matches!(context, EditContext::Diagram)
&& key == "layout"
&& before.is_some_and(|fields| !fields.contains_key(key)))
{
// Canonical None omits layout. Preserve an authored explicit
// none while another field changes; a real Some->None clear
// still removes the former payload and retains its trivia.
erase_syntax(self.source, field.span, edits);
}
}
@@ -1355,6 +1404,7 @@ impl SyntaxUpdate<'_> {
actual: &[language::EditValue],
before: Option<&[language::EditValue]>,
after: &[language::EditValue],
context: EditContext,
edits: &mut LocalEdits,
) {
let previous = before.unwrap_or(&[]);
@@ -1367,6 +1417,7 @@ impl SyntaxUpdate<'_> {
self.old_aliases,
self.legacy_value_paths,
None,
context,
)
})
.collect();
@@ -1379,6 +1430,7 @@ impl SyntaxUpdate<'_> {
self.new_aliases,
self.legacy_value_paths,
self.identity_origins,
context,
)
})
.collect();
@@ -1407,7 +1459,7 @@ impl SyntaxUpdate<'_> {
.all(|(i, source)| *source == Some(i))
{
for (i, (current, next)) in actual.iter().zip(after).enumerate() {
self.value(current, previous.get(i), next, edits);
self.value(current, previous.get(i), next, EditContext::Other, edits);
}
return;
}
@@ -1440,7 +1492,13 @@ impl SyntaxUpdate<'_> {
let chunk = chunks[index];
text.push_str(&self.source[chunk.start..current.span.start]);
let mut local = Vec::new();
self.value(current, previous.get(index), next, &mut local);
self.value(
current,
previous.get(index),
next,
EditContext::Other,
&mut local,
);
text.push_str(&changed_text(self.source, current.span, local));
suffix = &self.source[current.span.end..chunk.end];
} else {
@@ -1483,14 +1541,58 @@ impl SyntaxUpdate<'_> {
}
}
fn scenario_item_key(
value: &language::EditValue,
aliases: &BTreeMap<String, String>,
context: EditContext,
) -> Option<String> {
use language::EditKind;
let EditKind::Record(fields) = &value.kind else {
return None;
};
let reference = |value: &language::EditValue| {
if let EditKind::Reference(alias) = &value.kind {
aliases.get(alias).cloned()
} else {
None
}
};
match context {
EditContext::Lanes => {
reference(&fields.get("lifeline")?.value).map(|id| format!("scenario-lane:{id}"))
}
EditContext::Messages => {
let EditKind::Record(address) = &fields.get("address")?.value.kind else {
return None;
};
let occurrence = reference(&address.get("occurrence")?.value)?;
let path: Vec<_> = match address.get("references") {
None => Vec::new(),
Some(field) => {
let EditKind::List(path) = &field.value.kind else {
return None;
};
path.iter().map(reference).collect::<Option<Vec<_>>>()?
}
};
Some(format!("scenario-message:{}:{occurrence}", path.join("/")))
}
_ => None,
}
}
fn item_key(
value: &language::EditValue,
source: &str,
aliases: &BTreeMap<String, String>,
legacy_value_paths: bool,
origins: Option<&BTreeMap<Id, Id>>,
context: EditContext,
) -> Option<String> {
use language::EditKind;
if matches!(context, EditContext::Lanes | EditContext::Messages) {
return scenario_item_key(value, aliases, context);
}
if matches!(value.kind, EditKind::Record(_))
&& let Some(key) = language::semantic_row_key(
&source[value.span.start..value.span.end],
@@ -68,6 +68,11 @@ pub(super) fn upgrade(
&actual.fields,
Some(&actual.fields),
&next.fields,
if project.diagrams.contains_key(&id) {
EditContext::Diagram
} else {
EditContext::Other
},
edits.entry(path.clone()).or_default(),
);
}
+1
View File
@@ -619,6 +619,7 @@ pub fn validate(p: &Project) -> Vec<Issue> {
}
text(&mut out, d.id, "name", &d.name);
text(&mut out, d.id, "description", &d.description);
crate::scenario_layout::validate_diagram(p, d, &mut out);
let option_issues = if d.projection == ProjectionKind::Traceability {
crate::view_options::validate_options_cross_layer(p, &d.view_options)
} else {
+1
View File
@@ -41,6 +41,7 @@ fn fixture() -> Fixture {
projection: ProjectionKind::Overview,
view_options: Default::default(),
nodes: BTreeMap::new(),
layout: None,
routes: vec![],
},
);
+1 -1
View File
@@ -261,7 +261,7 @@ fn source7_context_lifecycle_source_history_and_closed_admission() {
assert_eq!(session.project(), &after);
let fresh = Id::new_v4();
let mut reidentified = after.clone();
references::remap_references(&mut reidentified, f.r1, fresh);
references::remap_references(&mut reidentified, f.r1, fresh).unwrap();
let mut e = reidentified.elements.remove(&f.r1).unwrap();
e.id = fresh;
reidentified.elements.insert(fresh, e);
+1
View File
@@ -20,6 +20,7 @@ fn view(p: &mut Project, machine: Id, alias: &str) -> Id {
projection: ProjectionKind::StateMachine { machine },
view_options: Default::default(),
nodes: BTreeMap::new(),
layout: None,
routes: vec![],
},
);
+1
View File
@@ -18,6 +18,7 @@ serde_json.workspace = true
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
tokio = { version = "1", features = ["rt", "time"] }
tempfile = "3"
unicode-segmentation = "1.13.3"
[dev-dependencies]
iced_runtime = "0.14"
File diff suppressed because it is too large Load Diff
@@ -3,6 +3,8 @@ use super::*;
use iced::widget::{button, column, pick_list, row, scrollable, text, text_input};
use serde::{Deserialize, Serialize};
mod palette;
#[cfg(test)]
mod tests;
pub use palette::view as palette;
#[derive(Debug, Clone)]
@@ -21,9 +23,35 @@ pub enum Event {
Filter(Filter),
ShowPlaced(bool),
Page(usize),
Edit,
CapturedEscape,
Cancel,
}
pub fn subscription(app: &App) -> Subscription<Message> {
if app.architecture.as_ref().is_some_and(State::inline) {
iced::event::listen_with(captured_escape)
} else {
Subscription::none()
}
}
fn captured_escape(
event: iced::Event,
status: iced::event::Status,
_: iced::window::Id,
) -> Option<Message> {
// A focused Iced text input captures Escape while dropping its focus. Only
// inline architecture naming opts into also cancelling that same keypress.
(status == iced::event::Status::Captured
&& matches!(
event,
iced::Event::Keyboard(iced::keyboard::Event::KeyPressed {
key: iced::keyboard::Key::Named(iced::keyboard::key::Named::Escape),
..
})
))
.then_some(Message::Architecture(Event::CapturedEscape))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub enum Filter {
@@ -110,7 +138,7 @@ pub fn validate_saved(p: &Project, s: &Saved) -> Result<(), String> {
.diagrams
.get(&s.diagram)
.ok_or("The creation draft's diagram is missing.")?;
if matches!(d.projection, ProjectionKind::StateMachine { .. })
if !d.projection.is_architecture()
|| s.query.len() > 16384
|| s.page > 1_000_000
|| s.point
@@ -152,7 +180,7 @@ pub fn validate_saved(p: &Project, s: &Saved) -> Result<(), String> {
}
fn context_kinds(p: &Project, d: &Diagram) -> Vec<Kind> {
let mut values = match (d.layer, d.projection) {
(_, ProjectionKind::StateMachine { .. }) => vec![],
(_, ProjectionKind::StateMachine { .. } | ProjectionKind::Scenario { .. }) => vec![],
(Layer::Oa, ProjectionKind::FunctionalDataflow) => vec![Kind::Activity],
(Layer::Sa, ProjectionKind::FunctionalDataflow) => vec![Kind::Function],
(Layer::Oa, ProjectionKind::Capabilities) => vec![
@@ -180,11 +208,12 @@ pub fn kinds(app: &App) -> Vec<Kind> {
.unwrap_or_default()
}
fn compatible(p: &Project, d: &Diagram, id: Id) -> bool {
p.elements.get(&id).is_some_and(|e| {
e.kind() != Kind::Port
&& d.projection.includes(e.kind())
&& (e.layer() == d.layer || d.projection == ProjectionKind::Traceability)
})
d.projection.is_architecture()
&& p.elements.get(&id).is_some_and(|e| {
e.kind() != Kind::Port
&& d.projection.includes(e.kind())
&& (e.layer() == d.layer || d.projection == ProjectionKind::Traceability)
})
}
fn suggested(p: &Project, d: &Diagram, kind: Kind) -> bool {
// Existing System remains a useful choice even when the singleton exists.
@@ -229,6 +258,17 @@ pub fn candidates(app: &App) -> Vec<(Id, String, bool)> {
}
impl App {
pub(super) fn architecture_event(&mut self, event: Event) -> Task<Message> {
if matches!(
&event,
Event::Start(..) | Event::Allocate(..) | Event::Existing(..)
) && !self
.active_diagram
.and_then(|id| self.project().diagrams.get(&id))
.is_some_and(|d| d.projection.is_architecture())
{
self.status = "Choose an architecture view before adding architecture content.".into();
return Task::none();
}
match event {
Event::Start(_, point) | Event::Allocate(_, _, point) | Event::Existing(point) => {
if self.architecture.is_some() {
@@ -336,9 +376,15 @@ impl App {
}
return iced::widget::operation::focus("architecture-name");
}
Event::CapturedEscape => {
if self.architecture.as_ref().is_some_and(State::inline) {
return self.architecture_event(Event::Cancel);
}
}
Event::Cancel => {
self.architecture = None;
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
self.status = "Cancelled · Select / move".into();
}
Event::Name(value) => {
if value.len() <= 16384
@@ -375,13 +421,6 @@ impl App {
s.saved.page = page.min(1_000_000);
}
}
Event::Edit => {
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
if let Some(s) = &mut self.architecture {
s.saved.placing = false;
}
return iced::widget::operation::focus("architecture-name");
}
Event::Pick(id) => {
if !candidates(self)
.iter()
@@ -560,9 +599,10 @@ pub fn inline_editor(app: &App) -> Option<Element<'_, Message>> {
row![
iced::widget::Space::new().width(x),
iced::widget::opaque(
iced::widget::container(name_editor(app))
iced::widget::container(scrollable(name_editor(app)))
.padding(10)
.width(width)
.max_height((size.height - y - 8.).max(0.))
.style(crate::style::card)
)
]
@@ -4,7 +4,7 @@ use iced::{
Point, Rectangle, Size, Theme, mouse,
widget::{
canvas::{self, Frame, Geometry, Path, Stroke},
column, container, tooltip,
container, tooltip,
},
};
@@ -165,7 +165,7 @@ fn tool<'a>(
.into()
}
pub fn view(app: &App) -> Element<'_, Message> {
let mut content = column![tool(
let mut content = iced::widget::column![tool(
app,
Icon::Collapse(app.architecture_palette_expanded),
if app.architecture_palette_expanded {
@@ -0,0 +1,379 @@
use super::*;
fn setup(layer: Layer) -> App {
let mut app = App::with_project(Project::empty());
app.layer = layer;
app.active_diagram = None;
let _ = app.update(Message::AddDiagram);
app.zoom = 0.75;
app.pan = iced::Vector::new(37., -19.);
app
}
fn send(app: &mut App, event: Event) {
let _ = app.update(Message::Architecture(event));
}
fn create(app: &mut App, kind: Kind, name: &str) -> Id {
send(app, Event::Start(kind, Some(Position { x: 120., y: 160. })));
send(app, Event::Name(name.into()));
send(app, Event::Submit);
assert!(app.architecture.is_none(), "{}", app.status);
app.selected
}
#[test]
fn architecture_allocated_work_is_root_owned_and_one_exact_history_change() {
for (layer, participant, work) in [
(Layer::Oa, Kind::Entity, Kind::Activity),
(Layer::Sa, Kind::System, Kind::Function),
] {
let mut app = setup(layer);
let performer = create(&mut app, participant, "Control team");
let before = app.document.bundle().clone();
let camera = (app.zoom, app.pan);
let point = Position { x: 151., y: 221. };
send(&mut app, Event::Allocate(work, performer, Some(point)));
send(&mut app, Event::Name(" Report 雨 ".into()));
assert_eq!(app.document.bundle(), &before);
send(&mut app, Event::Submit);
assert!(app.architecture.is_none(), "{}", app.status);
let created = app.selected;
let element = &app.project().elements[&created];
assert_eq!(element.kind(), work);
assert_eq!(element.name, " Report 雨 ");
assert_eq!(element.parent, None);
assert!(app.project().relations.values().any(|r| {
r.kind() == RelationKind::Allocation && r.source() == performer && r.target() == created
}));
let d = &app.project().diagrams[&app.active_diagram.unwrap()];
assert_eq!(d.nodes[&app.selected_node.unwrap()].position, point);
assert_eq!((app.zoom, app.pan), camera);
let accepted = app.document.bundle().clone();
let _ = app.update(Message::Undo);
assert_eq!(app.document.bundle(), &before);
let _ = app.update(Message::Redo);
assert_eq!(app.document.bundle(), &accepted);
}
}
#[test]
fn architecture_f2_routes_selected_node_to_inline_rename_and_exact_undo() {
use iced::widget::canvas::{Event as CanvasEvent, Program};
use iced::{keyboard, mouse};
let mut app = setup(Layer::Oa);
let element = create(&mut app, Kind::Entity, "Original team");
let before = app.document.bundle().clone();
let diagrams = app.project().diagrams.clone();
let camera = (app.zoom, app.pan);
let bounds = iced::Rectangle::new(iced::Point::ORIGIN, iced::Size::new(600., 380.));
let canvas = crate::diagram::DiagramCanvas {
project: app.project(),
view_index: None,
diagram: app.active_diagram.unwrap(),
selected: element,
navigation_focus: None,
zoom: app.zoom,
pan: app.pan,
context: app.route_context().unwrap(),
selected_bend: None,
editable: true,
placing: false,
connection: None,
};
let mut state = crate::diagram::State::default();
let key = CanvasEvent::Keyboard(keyboard::Event::KeyPressed {
key: keyboard::Key::Named(keyboard::key::Named::F2),
modified_key: keyboard::Key::Named(keyboard::key::Named::F2),
physical_key: keyboard::key::Physical::Code(keyboard::key::Code::F2),
location: keyboard::Location::Standard,
modifiers: keyboard::Modifiers::empty(),
text: None,
repeat: false,
});
assert!(
canvas
.update(&mut state, &key, bounds, mouse::Cursor::Unavailable)
.is_none()
);
let _ = canvas.update(
&mut state,
&CanvasEvent::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
bounds,
mouse::Cursor::Available(iced::Point::new(150., 120.)),
);
let action = canvas
.update(&mut state, &key, bounds, mouse::Cursor::Unavailable)
.unwrap();
let message = action.into_inner().0.unwrap();
let _ = app.update(message);
assert!(app.architecture.as_ref().unwrap().inline());
assert!(matches!(&app.architecture.as_ref().unwrap().saved.intent,
Intent::Rename { element: id, name } if *id == element && name == "Original team"));
send(&mut app, Event::Name(" Renamed team 雨 ".into()));
assert_eq!(app.document.bundle(), &before);
send(&mut app, Event::Submit);
assert!(app.architecture.is_none(), "{}", app.status);
assert_eq!(app.project().elements[&element].name, " Renamed team 雨 ");
assert_eq!(app.project().diagrams, diagrams);
assert_eq!((app.zoom, app.pan), camera);
let accepted = app.document.bundle().clone();
let _ = app.update(Message::Undo);
assert_eq!(app.document.bundle(), &before);
let _ = app.update(Message::Redo);
assert_eq!(app.document.bundle(), &accepted);
}
#[test]
fn architecture_blank_names_and_escape_never_accept_placeholder_or_rename() {
let mut app = setup(Layer::Oa);
let element = create(&mut app, Kind::Actor, "Dispatcher");
let before = app.document.bundle().clone();
let context = app.route_context().unwrap();
for event in [
Event::Start(Kind::Activity, Some(Position { x: 220., y: 330. })),
Event::Rename(context, element, Position { x: 150., y: 200. }),
] {
// Every cancel changes the gesture context; use the current context for F2.
let event = match event {
Event::Rename(_, element, point) => {
Event::Rename(app.route_context().unwrap(), element, point)
}
event => event,
};
send(&mut app, event);
send(&mut app, Event::Name(" \t ".into()));
send(&mut app, Event::Submit);
assert!(app.architecture.as_ref().unwrap().inline());
assert!(app.architecture.as_ref().unwrap().error.is_some());
assert_eq!(app.document.bundle(), &before);
let _ = app.update(Message::Escape);
assert!(app.architecture.is_none());
assert_eq!(app.document.bundle(), &before);
}
}
#[test]
fn architecture_invalid_click_cannot_poison_inline_or_checkpoint_geometry() {
let mut app = setup(Layer::Oa);
send(&mut app, Event::Start(Kind::Entity, None));
let before = app.document.bundle().clone();
let context = app.route_context().unwrap();
for point in [
Position { x: f32::NAN, y: 0. },
Position {
x: 0.,
y: f32::INFINITY,
},
] {
send(&mut app, Event::Click(context, point));
let draft = app.architecture.as_ref().unwrap();
assert!(draft.unnamed_tool());
assert!(validate_saved(app.project(), &draft.saved).is_ok());
assert_eq!(app.document.bundle(), &before);
}
send(&mut app, Event::Click(context, Position { x: 25., y: 30. }));
assert!(app.architecture.as_ref().unwrap().inline());
}
#[test]
fn architecture_stale_submit_keeps_create_and_rename_visible_and_exact() {
for rename in [false, true] {
for stale_document in [false, true] {
let mut app = setup(Layer::Oa);
let element = create(&mut app, Kind::Entity, "Accepted team");
let point = Position {
x: 151.5,
y: 227.25,
};
let event = if rename {
Event::Rename(app.route_context().unwrap(), element, point)
} else {
Event::Start(Kind::Activity, Some(point))
};
send(&mut app, event);
send(
&mut app,
Event::Name(" Retained engineering name 雨 ".into()),
);
// Change only the binding. The accepted model/source must not be
// rewritten or a rejected draft silently rebound to the new context.
if stale_document {
app.architecture.as_mut().unwrap().document = Id::new_v4();
} else {
app.architecture.as_mut().unwrap().revision = app.document.revision() + 1;
}
let draft = app.architecture.as_ref().unwrap();
let saved = serde_json::to_value(&draft.saved).unwrap();
let binding = (draft.document, draft.revision);
let model = app.project().clone();
let source = app.document.bundle().clone();
let revision = app.document.revision();
let camera = (app.zoom, app.pan);
for _ in 0..2 {
send(&mut app, Event::Submit);
let draft = app.architecture.as_ref().unwrap();
assert!(
draft.inline(),
"rename={rename}, stale_document={stale_document}"
);
assert!(inline_editor(&app).is_some());
assert_eq!(draft.error.as_deref(), Some(app.status.as_str()));
assert!(app.status.contains("cancel and restart"));
assert_eq!(serde_json::to_value(&draft.saved).unwrap(), saved);
assert_eq!((draft.document, draft.revision), binding);
assert_eq!(app.project(), &model);
assert_eq!(app.document.bundle(), &source);
assert_eq!(app.document.revision(), revision);
assert_eq!((app.zoom, app.pan), camera);
}
let _ = app.update(Message::Escape);
assert!(app.architecture.is_none());
assert_eq!(app.project(), &model);
assert_eq!(app.document.bundle(), &source);
let fresh = create(&mut app, Kind::Actor, "Fresh context");
assert_eq!(app.project().elements[&fresh].name, "Fresh context");
let _ = app.update(Message::Undo);
assert_eq!(app.project(), &model);
assert_eq!(app.document.bundle(), &source);
}
}
}
fn key_event(named: iced::keyboard::key::Named) -> iced::Event {
use iced::keyboard;
iced::Event::Keyboard(keyboard::Event::KeyPressed {
key: keyboard::Key::Named(named),
modified_key: keyboard::Key::Named(named),
physical_key: keyboard::key::Physical::Code(keyboard::key::Code::Escape),
location: keyboard::Location::Standard,
modifiers: keyboard::Modifiers::empty(),
text: None,
repeat: false,
})
}
fn focused_text_input_escape(name: &str) -> Message {
use iced::advanced::{Layout, Shell, layout, widget};
let mut field: iced::Element<'_, (), iced::Theme, ()> = text_input("Engineering name", name)
.id("architecture-name")
.on_input(|_| ())
.into();
let mut tree = widget::Tree::new(field.as_widget());
let size = iced::Size::new(300., 40.);
let node =
field
.as_widget_mut()
.layout(&mut tree, &(), &layout::Limits::new(iced::Size::ZERO, size));
tree.state
.downcast_mut::<iced::widget::text_input::State<()>>()
.focus();
let escape = key_event(iced::keyboard::key::Named::Escape);
let mut messages = Vec::new();
let mut shell = Shell::new(&mut messages);
field.as_widget_mut().update(
&mut tree,
&escape,
Layout::new(&node),
iced::mouse::Cursor::Unavailable,
&(),
&mut iced::advanced::clipboard::Null,
&mut shell,
&iced::Rectangle::new(iced::Point::ORIGIN, size),
);
assert!(
shell.is_event_captured(),
"Iced consumes focused-input Escape"
);
assert!(
!tree
.state
.downcast_ref::<iced::widget::text_input::State<()>>()
.is_focused()
);
captured_escape(
escape,
iced::event::Status::Captured,
iced::window::Id::unique(),
)
.unwrap()
}
#[test]
fn architecture_first_focused_input_escape_cancels_create_and_rename() {
for rename in [false, true] {
let mut app = setup(Layer::Oa);
let element = create(&mut app, Kind::Actor, "Accepted dispatcher");
let model = app.project().clone();
let source = app.document.bundle().clone();
let revision = app.document.revision();
let camera = (app.zoom, app.pan);
let point = Position { x: 439., y: 328.2 };
let event = if rename {
Event::Rename(app.route_context().unwrap(), element, point)
} else {
Event::Start(Kind::Actor, Some(point))
};
send(&mut app, event);
let name = " Unaccepted long engineering name 雨 ";
send(&mut app, Event::Name(name.into()));
assert!(app.architecture.as_ref().unwrap().inline());
let message = focused_text_input_escape(name);
let _ = app.update(message);
assert!(app.architecture.is_none());
assert!(inline_editor(&app).is_none());
assert_eq!(app.status, "Cancelled · Select / move");
assert_eq!(app.project(), &model);
assert_eq!(app.document.bundle(), &source);
assert_eq!(app.document.revision(), revision);
assert_eq!((app.zoom, app.pan), camera);
}
}
#[test]
fn architecture_captured_escape_does_not_cancel_other_controls_or_placement() {
let mut app = setup(Layer::Oa);
for event in [
None,
Some(Event::Start(Kind::Entity, None)),
Some(Event::Existing(None)),
] {
send(&mut app, Event::Cancel);
if let Some(event) = event {
send(&mut app, event);
}
let saved = app
.architecture
.as_ref()
.map(|s| serde_json::to_value(&s.saved).unwrap());
let before = app.document.bundle().clone();
let status = app.status.clone();
let epoch = app.canvas_epoch;
let message = focused_text_input_escape("Other field or existing-element search");
let _ = app.update(message);
assert_eq!(
app.architecture
.as_ref()
.map(|s| serde_json::to_value(&s.saved).unwrap()),
saved
);
assert_eq!(app.document.bundle(), &before);
assert_eq!(app.status, status);
assert_eq!(app.canvas_epoch, epoch);
}
assert!(
captured_escape(
key_event(iced::keyboard::key::Named::Escape),
iced::event::Status::Ignored,
iced::window::Id::unique(),
)
.is_none()
);
assert!(
captured_escape(
key_event(iced::keyboard::key::Named::Enter),
iced::event::Status::Captured,
iced::window::Id::unique(),
)
.is_none()
);
}
@@ -225,6 +225,7 @@ fn architecture_canvas_left_click_uses_world_coordinate_once_at_zoom_and_pan() {
view_index: None,
diagram: app.active_diagram.unwrap(),
selected: app.selected,
navigation_focus: None,
zoom: app.zoom,
pan: app.pan,
context: app.route_context().unwrap(),
@@ -299,6 +300,7 @@ fn architecture_f2_rename_preserves_identity_placement_and_atomic_history() {
view_index: None,
diagram: app.active_diagram.unwrap(),
selected: element,
navigation_focus: None,
zoom: app.zoom,
pan: app.pan,
context: app.route_context().unwrap(),
+197 -2
View File
@@ -173,7 +173,12 @@ impl App {
self.behavior = restored.behavior;
self.workflow = restored.workflow;
let focus = self.behavior.pending_owner().unwrap_or(self.selected);
if let Some(layer) = self.project().layer_of(focus) {
if let Some(layer) = self
.behavior
.creation
.pending_layer()
.or_else(|| self.project().layer_of(focus))
{
self.layer = layer;
self.explorer_index = crate::explorer::Index::new(self.project(), layer);
self.active_diagram = self.diagram_for(layer, focus);
@@ -202,7 +207,26 @@ impl App {
} else {
"Recovered an unsaved local copy. Choose Save as to save it; the original file and hosted model are unchanged.".into()
};
self.request_lint()
let lint = self.request_lint();
if self
.architecture
.as_ref()
.is_some_and(architecture_authoring::State::inline)
{
Task::batch([lint, iced::widget::operation::focus("architecture-name")])
} else if self.page == Page::Behavior && self.behavior.source_open() {
Task::batch([
lint,
iced::widget::operation::focus(behavior::source_draft::NAME_INPUT),
])
} else if self.page == Page::Behavior && self.behavior.creation.created().is_some() {
Task::batch([
lint,
iced::widget::operation::focus(behavior::creation::NAME_INPUT),
])
} else {
lint
}
}
}
@@ -331,6 +355,177 @@ mod tests {
.unwrap()
}
async fn run_recovery_tasks(app: &mut App, task: Task<Message>) -> (usize, bool, bool) {
use iced::advanced::{Layout, layout, widget};
use iced::futures::StreamExt;
// Mount real inputs, with focus initially in another control. This
// verifies the returned operation targets the naming field by its ID.
let mut fields: iced::Element<'_, (), iced::Theme, ()> = iced::widget::column![
iced::widget::text_input("Engineering name", "Retained name")
.id("architecture-name")
.on_input(|_| ()),
iced::widget::text_input("Other control", "Other value")
.id("unrelated-field")
.on_input(|_| ())
]
.into();
let mut tree = widget::Tree::new(fields.as_widget());
let node = fields.as_widget_mut().layout(
&mut tree,
&(),
&layout::Limits::new(iced::Size::ZERO, iced::Size::new(320., 100.)),
);
tree.children[1]
.state
.downcast_mut::<iced::widget::text_input::State<()>>()
.focus();
let mut operations = 0;
let mut lint_completions = 0;
let mut stream = iced_runtime::task::into_stream(task).expect("Recovery must still lint");
while let Some(action) = stream.next().await {
match action {
iced_runtime::Action::Widget(mut operation) => {
operations += 1;
fields.as_widget_mut().operate(
&mut tree,
Layout::new(&node),
&(),
operation.as_mut(),
);
}
iced_runtime::Action::Output(message @ Message::Linted(_, _)) => {
lint_completions += 1;
let _ = app.update(message);
}
_ => panic!("Unexpected recovery task action"),
}
}
assert_eq!(
lint_completions, 1,
"Autofocus must not replace request_lint"
);
assert!(app.lint_running.is_none());
let focused = |index: usize| {
tree.children[index]
.state
.downcast_ref::<iced::widget::text_input::State<()>>()
.is_focused()
};
(operations, focused(0), focused(1))
}
fn architecture_recovery_app() -> App {
let mut app = App::with_project(Project::empty());
let _ = app.update(Message::AddDiagram);
let _ = app.update(Message::Architecture(architecture_authoring::Event::Start(
Kind::Entity,
Some(Position { x: 80., y: 90. }),
)));
let _ = app.update(Message::Architecture(architecture_authoring::Event::Name(
"Accepted team".into(),
)));
let _ = app.update(Message::Architecture(architecture_authoring::Event::Submit));
assert!(app.architecture.is_none());
app
}
#[tokio::test]
async fn checkpoint_inline_create_and_rename_restore_focus_without_changing_drafts_or_history()
{
use architecture_authoring::Event;
for rename in [false, true] {
let mut app = architecture_recovery_app();
let point = Position { x: 439., y: 328.2 };
let event = if rename {
Event::Rename(app.route_context().unwrap(), app.selected, point)
} else {
Event::Start(Kind::Actor, Some(point))
};
let _ = app.update(Message::Architecture(event));
let _ = app.update(Message::Architecture(Event::Name(
" Recovered engineering name 雨 ".into(),
)));
let saved = serde_json::to_value(&app.architecture.as_ref().unwrap().saved).unwrap();
let snapshot = retained_snapshot(&app).with_architecture(app.architecture.as_ref());
let expected = snapshot.clone().restore().unwrap();
let checkpoint = snapshot.id;
let mut recovered = App::with_project(Project::empty());
let task = recovered.checkpoint_loaded(Ok(Arc::new(snapshot)));
assert_eq!(
run_recovery_tasks(&mut recovered, task).await,
(1, true, false)
);
assert_eq!(recovered.page, Page::Diagram);
assert!(recovered.architecture.as_ref().unwrap().inline());
assert_eq!(
serde_json::to_value(&recovered.architecture.as_ref().unwrap().saved).unwrap(),
saved
);
assert_eq!(recovered.project(), expected.document.project());
assert_eq!(recovered.document.bundle(), expected.document.bundle());
assert_eq!(recovered.document.revision(), expected.document.revision());
assert_eq!(recovered.document.can_undo(), expected.document.can_undo());
assert_eq!(recovered.document.can_redo(), expected.document.can_redo());
assert_eq!(
recovered.source_workspace.bundle(),
expected.workspace.bundle()
);
assert_eq!(recovered.recovery.restored, Some(checkpoint));
assert!(recovered.path.is_none());
}
}
#[tokio::test]
async fn checkpoint_noninline_contexts_keep_other_focus_and_still_lint() {
use architecture_authoring::Event;
for context in ["accepted", "armed", "picker", "property", "source"] {
let mut app = architecture_recovery_app();
match context {
"armed" => {
let _ = app.update(Message::Architecture(Event::Start(Kind::Actor, None)));
}
"picker" => {
let _ = app.update(Message::Architecture(Event::Existing(None)));
}
"property" => {
app.draft
.as_mut()
.unwrap()
.input("/name", "Unapplied property".into());
assert!(app.draft_dirty());
}
"source" => {
app.source_workspace
.edit("incomplete source 雨".into(), app.source_editor.cursor());
assert!(app.source_dirty());
}
_ => {}
}
let snapshot = retained_snapshot(&app).with_architecture(app.architecture.as_ref());
let expected = snapshot.clone().restore().unwrap();
let mut recovered = App::with_project(Project::empty());
let task = recovered.checkpoint_loaded(Ok(Arc::new(snapshot)));
assert_eq!(
run_recovery_tasks(&mut recovered, task).await,
(0, false, true),
"{context}"
);
assert!(
!recovered
.architecture
.as_ref()
.is_some_and(architecture_authoring::State::inline)
);
assert_eq!(recovered.project(), expected.document.project());
assert_eq!(recovered.document.bundle(), expected.document.bundle());
assert_eq!(recovered.document.revision(), expected.document.revision());
assert_eq!(
recovered.source_workspace.bundle(),
expected.workspace.bundle()
);
}
}
#[test]
fn saving_an_older_snapshot_keeps_recovery_for_newer_unsaved_work() {
let directory = tempfile::tempdir().unwrap();
+161
View File
@@ -0,0 +1,161 @@
//! Complete a navigation camera after the destination reports its real viewport.
use super::*;
use crate::behavior::scenario_layout::Representation;
use crate::diagram_usage::{Context, Surface};
#[cfg(test)]
mod tests;
impl App {
pub(super) fn is_diagram_viewport(message: &Message) -> bool {
matches!(
message,
Message::CanvasResized(_)
| Message::DiagramRevealViewport(..)
| Message::TopologyCanvas(behavior::topology::CanvasEvent::Viewport(..))
| Message::ScenarioCanvas(behavior::scenario::CanvasEvent::Viewport(..))
| Message::StateDiagram(behavior::state_edit::Event::Viewport(..))
)
}
pub(crate) fn architecture_reveal_stamp(&self, diagram: Id) -> Option<(Context, u64)> {
self.pending_diagram_reveal
.as_ref()
.filter(|(_, u, _)| u.surface == Surface::Saved(diagram))
.map(|(context, _, epoch)| (*context, *epoch))
}
pub(super) fn diagram_reveal_viewport(&self, message: &Message) -> Option<Surface> {
let (context, usage, epoch) = self.pending_diagram_reveal.as_ref()?;
if context.document != self.canvas_document
|| context.revision != self.document.revision()
|| *epoch != self.canvas_epoch
{
return None;
}
let (surface, size, valid) = match message {
Message::DiagramRevealViewport(c, e, size) => (
usage.surface,
*size,
c == context
&& e == epoch
&& self.page == Page::Diagram
&& Some(usage.surface.id()) == self.active_diagram,
),
Message::TopologyCanvas(behavior::topology::CanvasEvent::Viewport(c, size)) => (
Surface::Process(c.owner),
*size,
c.document == context.document
&& c.revision == context.revision
&& c.epoch == *epoch,
),
Message::ScenarioCanvas(behavior::scenario::CanvasEvent::Viewport(c, size)) => (
match c.representation {
Representation::Derived { owner } => Surface::Scenario(owner),
Representation::Saved { diagram } => Surface::Saved(diagram),
},
*size,
c.document == context.document
&& c.revision == context.revision
&& c.epoch == *epoch
&& self.pending_scenario_reveal_visit == Some(c.visit)
&& self.page == Page::Behavior
&& self.behavior.active_owner == Some(c.scenario)
&& self.behavior.scenario.context(
self.canvas_document,
c.scenario,
self.document.revision(),
self.canvas_epoch,
) == Some(*c),
),
Message::StateDiagram(behavior::state_edit::Event::Viewport(c, size)) => (
Surface::Saved(c.diagram),
*size,
c.document == context.document
&& c.revision == context.revision
&& c.epoch == *epoch,
),
_ => return None,
};
(valid
&& surface == usage.surface
&& size.width.is_finite()
&& size.height.is_finite()
&& size.width > 0.
&& size.height > 0.)
.then_some(surface)
}
pub(super) fn reconcile_pending_diagram_reveal(&mut self, message: &Message) {
if !matches!(
message,
Message::CheckpointTick
| Message::CheckpointWritten(..)
| Message::Lint(_)
| Message::Linted(..)
| Message::SourceViewport(_)
| Message::WindowResized(_)
| Message::CanvasResized(_)
| Message::DiagramRevealViewport(..)
| Message::DiagramUsage(_)
| Message::DiagramUsagesReady(_)
| Message::SourceSymbolsReady(_)
| Message::SourceRenameReady(_)
| Message::TopologyCanvas(behavior::topology::CanvasEvent::Viewport(..))
| Message::ScenarioCanvas(behavior::scenario::CanvasEvent::Viewport(..))
| Message::StateDiagram(behavior::state_edit::Event::Viewport(..))
) {
self.pending_diagram_reveal = None;
self.pending_scenario_reveal_visit = None;
if !Self::is_navigation_camera(message) {
self.diagram_navigation_focus = None;
}
}
}
fn is_navigation_camera(message: &Message) -> bool {
if let Message::BehaviorControl(intent) = message {
return Self::is_navigation_camera(&intent.message);
}
matches!(
message,
Message::Pan(_)
| Message::Zoom(_)
| Message::Fit
| Message::TopologyCanvas(
behavior::topology::CanvasEvent::Camera(..)
| behavior::topology::CanvasEvent::Zoom(..)
| behavior::topology::CanvasEvent::Scrolled(..)
| behavior::topology::CanvasEvent::Focus(..)
)
| Message::ScenarioCanvas(
behavior::scenario::CanvasEvent::Camera(..)
| behavior::scenario::CanvasEvent::Zoom(..)
| behavior::scenario::CanvasEvent::Scrolled(..)
| behavior::scenario::CanvasEvent::Focus(..)
)
| Message::StateDiagram(
behavior::state_edit::Event::Pan(..)
| behavior::state_edit::Event::Zoom(..)
| behavior::state_edit::Event::Fit(..)
)
)
}
pub(super) fn finish_diagram_reveal(&mut self, surface: Surface) {
let Some((context, usage, _)) = self.pending_diagram_reveal.take() else {
return;
};
if context.document == self.canvas_document
&& context.revision == self.document.revision()
&& usage.surface == surface
&& !self.draft_dirty()
&& !self.source_dirty()
&& !self.behavior.has_pending()
{
// The normal viewport handler has now stored the measured size.
// Target identity was validated for this revision before navigation.
self.reveal_diagram_usage(&usage);
}
self.pending_diagram_reveal = None;
self.pending_scenario_reveal_visit = None;
}
}
@@ -0,0 +1,418 @@
//! Independent first-layout navigation oracles, through the real App boundary.
use super::*;
use crate::diagram_usage::{self as usage, Target, Usage};
use iced::{Size, Vector};
use std::collections::BTreeMap;
#[derive(Clone, Copy, Debug)]
enum Canvas {
Architecture,
Process,
Scenario,
State,
}
const CANVASES: [Canvas; 4] = [
Canvas::Architecture,
Canvas::Process,
Canvas::Scenario,
Canvas::State,
];
const WIDE: Size = Size::new(1200., 700.);
const ACTUAL: Size = Size::new(500., 300.);
fn send(app: &mut App, message: Message) {
let _ = app.update(message);
}
fn fixture(canvas: Canvas) -> (App, Usage) {
let mut p = Project::empty();
let work = p
.add_element(Kind::Activity, Layer::Oa, "Respond", None)
.unwrap();
let (surface, subject) = match canvas {
Canvas::Architecture => {
let diagram = p.ensure_diagram(Layer::Oa);
p.place(
diagram,
work,
Position {
x: 12_000.,
y: 9_000.,
},
);
(Surface::Saved(diagram), work)
}
Canvas::Process | Canvas::Scenario => {
let kind = if matches!(canvas, Canvas::Process) {
Kind::Process
} else {
Kind::Scenario
};
let owner = p.add_element(kind, Layer::Oa, "Response", None).unwrap();
let occurrence = p
.add_element(Kind::Occurrence, Layer::Oa, "Respond here", Some(owner))
.unwrap();
*p.elements
.get_mut(&occurrence)
.unwrap()
.shared_mut()
.unwrap() = SharedBody::Occurrence {
body: OccurrenceBody::Action {
action: Some(work),
lifeline: None,
},
precondition: String::new(),
postcondition: String::new(),
};
let steps = p.elements.get_mut(&owner).unwrap().steps_mut().unwrap();
if !steps.contains(&occurrence) {
steps.push(occurrence);
}
(
if matches!(canvas, Canvas::Process) {
Surface::Process(owner)
} else {
Surface::Scenario(owner)
},
occurrence,
)
}
Canvas::State => {
let machine = p
.add_element(Kind::StateMachine, Layer::Oa, "Modes", None)
.unwrap();
let region = p
.add_element(Kind::Region, Layer::Oa, "Main", Some(machine))
.unwrap();
let state = p
.add_element(Kind::State, Layer::Oa, "Ready", Some(region))
.unwrap();
let diagram = Id::new_v4();
p.diagrams.insert(
diagram,
Diagram {
id: diagram,
alias: "ready_view".into(),
name: "Ready view".into(),
description: String::new(),
layer: Layer::Oa,
projection: ProjectionKind::StateMachine { machine },
nodes: BTreeMap::new(),
layout: None,
routes: vec![],
view_options: Default::default(),
},
);
p.place(
diagram,
state,
Position {
x: 8_000.,
y: 11_000.,
},
);
(Surface::Saved(diagram), state)
}
};
let mut app = App::with_project(p);
let found = usage::collect(
app.project(),
subject,
&app.behavior.topology,
&app.behavior.scenario,
)
.into_iter()
.find(|u| u.surface == surface && u.target != Target::Whole)
.unwrap();
app.canvas_size = Some(WIDE);
app.behavior.diagram.viewport = Some(WIDE);
if let Surface::Process(owner) = surface {
app.behavior.topology.enter(app.canvas_document, owner);
app.behavior.topology.viewport(WIDE);
}
if let Surface::Scenario(owner) = surface {
app.behavior.scenario.enter(app.canvas_document, owner);
app.behavior.scenario.viewport(WIDE);
}
app.page = Page::Text;
(app, found)
}
fn measurement(
canvas: Canvas,
context: Context,
epoch: u64,
surface: Surface,
size: Size,
visit: Id,
) -> Message {
match canvas {
Canvas::Architecture => Message::DiagramRevealViewport(context, epoch, size),
Canvas::Process => Message::TopologyCanvas(behavior::topology::CanvasEvent::Viewport(
behavior::topology::Context {
document: context.document,
owner: surface.id(),
revision: context.revision,
epoch,
},
size,
)),
Canvas::Scenario => Message::ScenarioCanvas(behavior::scenario::CanvasEvent::Viewport(
behavior::scenario::Context {
document: context.document,
scenario: surface.id(),
representation: Representation::Derived {
owner: surface.id(),
},
visit,
revision: context.revision,
epoch,
},
size,
)),
Canvas::State => Message::StateDiagram(behavior::state_edit::Event::Viewport(
behavior::state_edit::Context {
document: context.document,
diagram: surface.id(),
revision: context.revision,
epoch,
},
size,
)),
}
}
fn stamp(app: &App) -> (Context, u64, Id) {
let (context, _, epoch) = app
.pending_diagram_reveal
.as_ref()
.expect("reveal awaits measured destination");
(
*context,
*epoch,
app.pending_scenario_reveal_visit.unwrap_or(Id::nil()),
)
}
fn assert_centered(app: &App, canvas: Canvas, target: &Usage) {
// All independently authored fixture targets fit ACTUAL at the readable cap.
// This oracle does not call the production camera/reveal implementation.
let zoom = 1.25;
let pan = Vector::new(
250. - target.bounds.center_x() * zoom,
150. - target.bounds.center_y() * zoom,
);
match canvas {
Canvas::Architecture => {
assert_eq!(app.canvas_size, Some(ACTUAL));
assert_eq!(app.zoom, zoom);
assert_eq!(app.pan, pan);
assert!(!app.fit_to_view);
}
Canvas::State => {
assert_eq!(app.behavior.diagram.viewport, Some(ACTUAL));
assert_eq!(app.behavior.diagram.zoom, zoom);
assert_eq!(app.behavior.diagram.pan, pan);
assert!(!app.behavior.diagram.fit);
}
Canvas::Process => {
let mut expected = behavior::topology::State::default();
expected.enter(app.canvas_document, target.surface.id());
expected.viewport(ACTUAL);
expected.zoom(Some(zoom));
expected.camera(behavior::canvas_workspace::Event::Pan(pan));
assert!(app.behavior.topology.camera_boundary() == expected.camera_boundary());
}
Canvas::Scenario => {
let mut expected = behavior::scenario::State::default();
expected.enter(app.canvas_document, target.surface.id());
expected.choose_tool(target.surface.id(), behavior::scenario::CanvasTool::Select);
expected.viewport(ACTUAL);
expected.zoom(Some(zoom));
expected.camera(
app.project(),
target.surface.id(),
behavior::canvas_workspace::Event::Pan(pan),
);
assert!(app.behavior.scenario.camera_boundary() == expected.camera_boundary());
}
}
}
#[test]
fn all_reveal_surfaces_recenter_once_after_the_real_viewport_and_preserve_authored_state() {
for canvas in CANVASES {
let (mut app, target) = fixture(canvas);
let accepted = app.document.snapshot();
let drafts = app.source_workspace.bundle();
let revision = app.document.revision();
let history = (
app.document.can_undo(),
app.document.can_redo(),
app.document.dirty(),
);
assert!(app.reveal_diagram_usage(&target));
let (context, epoch, visit) = stamp(&app);
send(
&mut app,
measurement(canvas, context, epoch, target.surface, ACTUAL, visit),
);
assert!(app.pending_diagram_reveal.is_none());
assert_centered(&app, canvas, &target);
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
assert_eq!(app.source_workspace.bundle(), drafts);
assert_eq!(app.document.revision(), revision);
assert_eq!(
(
app.document.can_undo(),
app.document.can_redo(),
app.document.dirty()
),
history
);
// The exact delivered callback is a replay after the one-shot is consumed.
let camera = app.camera_boundary();
send(
&mut app,
measurement(canvas, context, epoch, target.surface, ACTUAL, visit),
);
assert!(app.pending_diagram_reveal.is_none());
assert!(app.camera_boundary() == camera);
assert_centered(&app, canvas, &target);
}
}
#[test]
fn malformed_or_wrong_stamp_measurements_cannot_poison_a_pending_reveal() {
for canvas in CANVASES {
for invalid in 0..6 {
let (mut app, target) = fixture(canvas);
assert!(app.reveal_diagram_usage(&target));
let (context, epoch, visit) = stamp(&app);
let camera = app.camera_boundary();
let mut stale = context;
let mut wrong_epoch = epoch;
let mut size = Size::new(111., 122.);
match invalid {
0 => stale.document = Id::new_v4(),
1 => stale.revision = stale.revision.wrapping_add(1),
2 => wrong_epoch = wrong_epoch.wrapping_add(1),
3 => size.width = f32::NAN,
4 => size.height = f32::INFINITY,
_ => size.width = 0.,
}
send(
&mut app,
measurement(canvas, stale, wrong_epoch, target.surface, size, visit),
);
assert!(
app.pending_diagram_reveal.is_some(),
"{canvas:?} invalid case {invalid}"
);
assert_eq!(stamp(&app), (context, epoch, visit));
assert_eq!(app.canvas_epoch, epoch);
assert!(
app.camera_boundary() == camera,
"{canvas:?} invalid case {invalid}"
);
send(
&mut app,
measurement(canvas, context, epoch, target.surface, ACTUAL, visit),
);
assert!(app.pending_diagram_reveal.is_none());
assert_centered(&app, canvas, &target);
}
}
}
#[test]
fn actual_window_resize_before_first_measurement_retains_focus_for_the_new_context() {
for canvas in CANVASES {
let (mut app, target) = fixture(canvas);
let accepted = app.document.snapshot();
assert!(app.reveal_diagram_usage(&target));
let (context, old_epoch, visit) = stamp(&app);
send(&mut app, Message::WindowResized(Size::new(1100., 700.)));
let (current, epoch, current_visit) = stamp(&app);
assert_eq!(current_visit, visit);
assert_eq!(current, context);
assert_eq!(epoch, app.canvas_epoch);
if matches!(canvas, Canvas::Process | Canvas::Scenario) {
assert_ne!(epoch, old_epoch);
let camera = app.camera_boundary();
send(
&mut app,
measurement(canvas, context, old_epoch, target.surface, ACTUAL, visit),
);
assert_eq!(stamp(&app), (current, epoch, visit));
assert!(app.camera_boundary() == camera);
}
send(
&mut app,
measurement(canvas, current, epoch, target.surface, ACTUAL, visit),
);
assert!(app.pending_diagram_reveal.is_none());
assert_centered(&app, canvas, &target);
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
}
}
#[test]
fn user_camera_edit_and_navigation_cancel_deferred_focus_without_late_snapping() {
for canvas in CANVASES {
for action in 0..3 {
let (mut app, target) = fixture(canvas);
let accepted = app.document.snapshot();
assert!(app.reveal_diagram_usage(&target));
let (context, epoch, visit) = stamp(&app);
let event = match action {
0 => match canvas {
Canvas::Architecture => Message::Pan(Vector::new(31., -17.)),
Canvas::State => Message::StateDiagram(behavior::state_edit::Event::Pan(
app.state_context().unwrap(),
Vector::new(31., -17.),
)),
Canvas::Process => {
Message::TopologyCanvas(behavior::topology::CanvasEvent::Camera(
app.topology_context().unwrap(),
app.camera_epoch,
behavior::canvas_workspace::Event::Pan(Vector::new(31., -17.)),
))
}
Canvas::Scenario => {
Message::ScenarioCanvas(behavior::scenario::CanvasEvent::Camera(
app.scenario_context().unwrap(),
app.camera_epoch,
behavior::canvas_workspace::Event::Pan(Vector::new(31., -17.)),
))
}
},
1 => Message::Field("/name".into(), "Pending title edit".into()),
_ => Message::Page(Page::Catalog),
};
send(&mut app, event);
assert!(
app.pending_diagram_reveal.is_none(),
"{canvas:?} action {action}"
);
let camera = app.camera_boundary();
let page = app.page;
// A stale layout callback at the already retained size cannot
// restore the old navigation camera after deliberate user input.
send(
&mut app,
measurement(canvas, context, epoch, target.surface, WIDE, visit),
);
assert!(app.pending_diagram_reveal.is_none());
assert!(
app.camera_boundary() == camera,
"{canvas:?} action {action}"
);
assert_eq!(app.page, page);
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
if action == 1 {
assert!(app.draft_dirty());
}
}
}
}
+535
View File
@@ -0,0 +1,535 @@
//! Navigation-only adapter: validates destinations before changing presentation.
use super::*;
use crate::behavior::scenario_layout::{Representation, presentation};
use crate::diagram_usage::{self as usage, Context, Event, Panel, Surface, Target, Usage};
#[cfg(test)]
mod tests;
impl App {
pub(crate) fn diagram_usage_message(&self, subject: Id) -> Message {
Message::DiagramUsage(Event::Open(Context {
document: self.canvas_document,
revision: self.document.revision(),
subject,
}))
}
fn usage_navigation_available(&self) -> bool {
!self.busy
&& !self.source_rename.open()
&& self.pending.is_none()
&& self.file_prompt.is_none()
&& !self.delete_pending
&& self.workflow.is_none()
&& !self.hosted.visible
&& self.architecture.is_none()
&& !self.reference_input_active()
&& !self.behavior.has_pending()
&& !self.draft_dirty()
&& !self.source_dirty()
}
/// Admit at most one expensive lookup, including one accepted Project clone.
/// A replacement panel waits for the previous worker's exact completion.
fn start_diagram_lookup(&mut self) -> Task<Message> {
let Some(panel) = self.diagram_usages.as_ref() else {
return Task::none();
};
if !panel.loading || self.diagram_lookup_in_flight.is_some() {
return Task::none();
}
let (token, context) = (panel.token, panel.context);
if context.document != self.canvas_document
|| context.revision != self.document.revision()
|| !self.usage_navigation_available()
{
let panel = self.diagram_usages.as_mut().unwrap();
panel.loading = false;
panel.notice = Some(
"The model or pending edits changed. Reopen diagram navigation when ready.".into(),
);
return Task::none();
}
let project = self.project().clone();
let presentation = self.behavior.topology.usage_snapshot();
self.diagram_lookup_in_flight = Some(token);
Task::perform(
async move {
let result = tokio::task::spawn_blocking(move || {
let process = presentation.into_query_state();
let scenario = behavior::scenario::State::default();
usage::collect(&project, context.subject, &process, &scenario)
})
.await
.map_err(|error| format!("Diagram lookup failed: {error}. Close and try again."));
usage::Completion {
token,
context,
result,
}
},
Message::DiagramUsagesReady,
)
}
fn refresh_diagram_lookup(&mut self, notice: &str) -> Task<Message> {
let Some(panel) = self.diagram_usages.as_mut() else {
return Task::none();
};
panel.token = Id::new_v4();
panel.context.revision = self.document.revision();
panel.usages.clear();
panel.loading = true;
panel.page = 0;
panel.cursor = 0;
panel.destination = None;
panel.notice = Some(notice.into());
self.start_diagram_lookup()
}
/// Must run before modal/input dispatch. A closed or replaced panel still
/// owns its worker slot until this exact completion arrives.
pub(super) fn diagram_usages_ready(&mut self, completion: usage::Completion) -> Task<Message> {
if self.diagram_lookup_in_flight != Some(completion.token) {
return Task::none();
}
self.diagram_lookup_in_flight = None;
let Some(panel) = self.diagram_usages.as_ref() else {
return Task::none();
};
if panel.context.document != self.canvas_document {
self.diagram_usages = None;
return Task::none();
}
if panel.context.revision != self.document.revision() {
return self
.refresh_diagram_lookup("The model changed. Review the refreshed destinations.");
}
if panel.token != completion.token {
// A newer Open/refresh waited without cloning or spawning a second job.
return self.start_diagram_lookup();
}
let panel = self.diagram_usages.as_mut().unwrap();
if panel.context != completion.context || !panel.loading {
panel.loading = false;
panel.notice = Some(
"This lookup no longer matches the requested model. Close and try again.".into(),
);
return Task::none();
}
panel.loading = false;
match completion.result {
Ok(usages) => panel.usages = usages,
Err(error) => panel.notice = Some(error),
}
Task::none()
}
/// Selection validates one destination only; opening the chooser does the
/// whole-project scan on its worker, never on the GUI thread.
fn current_surface_usages(&self, subject: Id, surface: Surface) -> Vec<Usage> {
match surface {
Surface::Saved(diagram) => {
usage::saved_usages_for(self.project(), subject, Some(diagram))
}
Surface::Process(owner) => {
self.behavior
.topology
.diagram_usages(self.project(), owner, subject)
}
Surface::Scenario(owner) => {
self.behavior
.scenario
.diagram_usages(self.project(), owner, subject)
}
}
}
pub(super) fn diagram_usage_event(&mut self, event: Event) -> Task<Message> {
if let Event::Open(context) = event {
if context.document != self.canvas_document
|| context.revision != self.document.revision()
|| !self.project().contains(context.subject)
{
return Task::none();
}
if !self.usage_navigation_available() {
self.status = "Apply or revert pending edits before opening diagram navigation. Your inputs are retained.".into();
return Task::none();
}
let mut panel = Panel::new(context, Vec::new());
panel.loading = true;
let filter_id = format!("diagram-usage-filter-{}", panel.token);
self.diagram_usages = Some(panel);
self.model_menu = None;
// Only an accepted new chooser supersedes the previous reveal.
// Rejected/stale panel envelopes leave its first measurement intact.
self.pending_diagram_reveal = None;
self.pending_scenario_reveal_visit = None;
self.diagram_navigation_focus = None;
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
return Task::batch([
self.start_diagram_lookup(),
iced::widget::operation::focus(filter_id),
]);
}
let token = match &event {
Event::Open(_) => unreachable!(),
Event::Close(t)
| Event::Search(t, _)
| Event::Page(t, _)
| Event::Move(t, _)
| Event::Activate(t)
| Event::Destination(t, _)
| Event::Choose(t, _) => *t,
};
let Some(panel) = &self.diagram_usages else {
return Task::none();
};
if panel.token != token {
return Task::none();
}
if matches!(event, Event::Close(_)) {
self.diagram_usages = None;
if self.page == Page::Text {
self.restore_source_editor_focus();
}
return Task::none();
}
if panel.context.document != self.canvas_document {
self.diagram_usages = None;
return Task::none();
}
if panel.context.revision != self.document.revision() {
return self
.refresh_diagram_lookup("The model changed. Review the refreshed destinations.");
}
if panel.loading {
// Search and Close remain useful while loading; queued activation
// must not perform a synchronous fallback or choose an old row.
if let Event::Search(_, query) = event {
self.diagram_usages.as_mut().unwrap().search = query;
}
return Task::none();
}
match event {
Event::Move(_, direction) => {
let panel = self.diagram_usages.as_ref().unwrap();
let count = panel.choices(self.project()).len();
let panel = self.diagram_usages.as_mut().unwrap();
if count > 0 {
panel.cursor = if direction < 0 {
(panel.cursor + count - 1) % count
} else {
(panel.cursor + 1) % count
};
panel.page = panel.cursor / 6;
}
}
Event::Activate(_) => {
let panel = self.diagram_usages.as_ref().unwrap();
if let Some(event) = panel.choices(self.project()).get(panel.cursor).cloned() {
return self.diagram_usage_event(event);
}
}
Event::Search(_, query) => {
let panel = self.diagram_usages.as_mut().unwrap();
panel.search = query;
panel.page = 0;
panel.cursor = 0;
}
Event::Page(_, page) => {
let panel = self.diagram_usages.as_mut().unwrap();
panel.page = page;
panel.cursor = page.saturating_mul(6);
}
Event::Destination(_, surface) => {
let panel = self.diagram_usages.as_mut().unwrap();
if surface.is_none() || panel.usages.iter().any(|u| Some(u.surface) == surface) {
panel.destination = surface;
panel.page = 0;
panel.cursor = 0;
}
}
Event::Choose(_, requested) => {
if !self.usage_navigation_available() {
return Task::none();
}
let subject = self.diagram_usages.as_ref().unwrap().context.subject;
let fresh = self
.current_surface_usages(subject, requested.surface)
.into_iter()
.find(|u| u.subject == requested.subject && u.target == requested.target);
let Some(fresh) = fresh else {
return self.refresh_diagram_lookup(
"This representation changed. Review the refreshed destinations.",
);
};
if self.reveal_diagram_usage(&fresh) {
self.diagram_usages = None;
self.status = format!(
"{} · {} in {}",
self.project().named(subject),
fresh.role,
self.project().named(fresh.surface.id())
);
return Task::batch(["model-explorer", "model-browser", "inspector"].map(
|id| {
iced::widget::operation::snap_to(
id,
iced::widget::scrollable::RelativeOffset::START,
)
},
));
}
}
Event::Open(_) | Event::Close(_) => unreachable!(),
}
Task::none()
}
/// Explicit saved-Scenario reveal runs before generic Apply. An absent target
/// never selects the owner or another saved arrangement as a fallback.
pub(super) fn reveal_saved_scenario(&mut self, diagram: Id, selected: Id) -> Task<Message> {
if self.diagram_usages.is_some() || !self.usage_navigation_available() {
self.status = "Apply or revert pending edits before revealing a saved Scenario. Your inputs are retained.".into();
return Task::none();
}
if presentation::checked_owner(self.project(), Representation::Saved { diagram }).is_none()
{
return Task::none();
}
let Some(found) = self
.current_surface_usages(selected, Surface::Saved(diagram))
.into_iter()
.find(|row| matches!(row.target, Target::Whole | Target::ScenarioObject(_)))
else {
self.status = "This object has no visible target in that saved Scenario.".into();
return Task::none();
};
self.reveal_diagram_usage(&found);
Task::none()
}
pub(super) fn reveal_diagram_usage(&mut self, usage: &Usage) -> bool {
if !self.usage_navigation_available() {
return false;
}
let previous_layer = self.layer;
let scenario_representation = match usage.surface {
Surface::Saved(diagram) if self.saved_scenario_diagram(diagram) => {
Some(Representation::Saved { diagram })
}
Surface::Scenario(owner) => Some(Representation::Derived { owner }),
_ => None,
};
if let Some(representation) = scenario_representation {
let Some(owner) = presentation::checked_owner(self.project(), representation) else {
return false;
};
if !self
.behavior
.scenario
.representation_usages(self.project(), representation, usage.subject)
.iter()
.any(|row| row == usage)
{
return false;
}
// Finish target/camera admission before changing the App route. The
// checked immutable owner also makes the following entry infallible.
let revealed = match representation {
Representation::Derived { .. } => self.behavior.scenario.reveal_usage(
self.document.project(),
self.canvas_document,
owner,
&usage.target,
),
Representation::Saved { .. } => self.behavior.scenario.reveal_representation_usage(
self.document.project(),
self.canvas_document,
owner,
representation,
&usage.target,
),
};
if !revealed {
return false;
}
if !self.enter_scenario_representation(representation) {
return false;
}
}
let p = self.document.project();
let (selected, placement) = match &usage.target {
Target::Whole => (usage.surface.id(), None),
Target::Placement(id) => {
let Some(node) = p
.diagrams
.get(&usage.surface.id())
.and_then(|d| d.nodes.get(id))
else {
return false;
};
(node.element, Some(*id))
}
Target::Port { port, placement } => (*port, Some(*placement)),
Target::StateRow {
placement,
relation,
} => (*relation, Some(*placement)),
Target::Edge(id)
| Target::ProcessLink(id)
| Target::ScenarioObject(id)
| Target::AllocationGrouping { relation: id, .. }
| Target::ProcessPreviewLink { relation: id, .. } => (*id, None),
Target::ProcessOccurrence(endpoint) => (
endpoint
.references
.first()
.copied()
.unwrap_or(endpoint.occurrence),
None,
),
};
match usage.surface {
Surface::Saved(id) => {
let Some(diagram) = p.diagrams.get(&id) else {
return false;
};
self.layer = diagram.layer;
self.active_diagram = Some(id);
if let Some(owner) = diagram.projection.scenario() {
self.behavior.active_owner = Some(owner);
self.page = Page::Behavior;
} else if let Some(machine) = diagram.projection.state_machine() {
let viewport = self.behavior.diagram.viewport.unwrap_or(iced::Size::new(
(self.window_size.width - 610.).max(280.),
(self.window_size.height - 320.).max(240.),
));
let (zoom, pan) = usage::camera_for(&usage.target, usage.bounds, viewport);
self.behavior.diagram = behavior::state_view::State {
diagram: Some(id),
placement,
viewport: Some(viewport),
zoom,
pan,
fit: false,
..Default::default()
};
self.behavior.active_owner = Some(machine);
self.page = Page::Behavior;
} else {
let viewport = self.canvas_size.unwrap_or(iced::Size::new(
(self.window_size.width - 550.).max(280.),
(self.window_size.height - 284.).max(240.),
));
(self.zoom, self.pan) =
usage::camera_for(&usage.target, usage.bounds, viewport);
self.fit_to_view = false;
self.page = Page::Diagram;
}
}
Surface::Process(owner) => {
if !self.behavior.topology.reveal_usage(
p,
self.canvas_document,
owner,
&usage.target,
) {
return false;
}
self.layer = p.elements[&owner].layer();
self.behavior.active_owner = Some(owner);
self.page = Page::Behavior;
}
Surface::Scenario(owner) => {
self.layer = p.elements[&owner].layer();
self.behavior.active_owner = Some(owner);
self.page = Page::Behavior;
}
}
if scenario_representation.is_none() {
self.behavior.scenario.forget_view();
}
self.selected = selected;
self.selected_node = placement;
self.model_menu = None;
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
self.selected_bend = None;
self.connection = None;
self.last_workspace_page = self.page;
if previous_layer != self.layer {
self.data_state = Default::default();
self.explorer_index = crate::explorer::Index::new(self.project(), self.layer);
self.explorer_focus = None;
self.explorer_group = None;
self.explorer_page = 0;
}
self.normalize_creation();
self.reveal_explorer_selection();
self.refresh_draft();
self.diagram_navigation_focus = Some(usage::Focus {
context: Context {
document: self.canvas_document,
revision: self.document.revision(),
subject: usage.subject,
},
usage: usage.clone(),
selected,
});
self.pending_scenario_reveal_visit = scenario_representation.and_then(|representation| {
let owner = presentation::checked_owner(self.project(), representation)?;
self.behavior
.scenario
.context(
self.canvas_document,
owner,
self.document.revision(),
self.canvas_epoch,
)
.map(|context| context.visit)
});
self.pending_diagram_reveal = Some((
Context {
document: self.canvas_document,
revision: self.document.revision(),
subject: usage.subject,
},
usage.clone(),
self.canvas_epoch,
));
true
}
/// Validated presentation identity. Edit selection and Delete capture remain
/// semantic; a port owner or allocation work box is only a visual marker.
pub(crate) fn diagram_focus(&self, surface: Surface) -> Option<&Usage> {
let focus = self.diagram_navigation_focus.as_ref()?;
let current_surface = match surface {
Surface::Saved(id) => {
self.active_diagram == Some(id)
&& matches!(self.page, Page::Diagram | Page::Behavior)
&& (!self.saved_scenario_diagram(id)
|| (self.page == Page::Behavior
&& self.behavior.scenario.representation()
== Some(Representation::Saved { diagram: id })))
}
Surface::Process(owner) => {
self.page == Page::Behavior && self.behavior.active_owner == Some(owner)
}
Surface::Scenario(owner) => {
self.page == Page::Behavior
&& self.behavior.active_owner == Some(owner)
&& self.behavior.scenario.representation()
== Some(Representation::Derived { owner })
}
};
(current_surface
&& focus.usage.surface == surface
&& focus.context.document == self.canvas_document
&& focus.context.revision == self.document.revision()
&& focus.selected == self.selected)
.then_some(&focus.usage)
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,108 @@
arcadia 7;
project incident_workspace "Incident report review" id "70200000-0000-4000-8000-000000000001" {
// Two operational activities, with one lifeline appearance per Scenario.
oa_activity report_incident "Report incident" id "70200000-0000-4000-8000-000000000002" {}
oa_activity assess_incident "Assess incident" id "70200000-0000-4000-8000-000000000003" {}
interaction incident_report "Incident report" id "70200000-0000-4000-8000-000000000004" {
source @report_incident;
target @assess_incident;
}
oa_scenario incident_response "Review a reported incident" id "70200000-0000-4000-8000-000000000010" {
viewpoint OperationalActivity;
steps [@initial_report, @updated_report, @assess_call, @assess_reply, @unfinished_notice, @use_review];
}
oa_lifeline reporter "Reporting activity" id "70200000-0000-4000-8000-000000000011" {
parent @incident_response; represented @report_incident;
}
oa_lifeline assessor "Assessment activity" id "70200000-0000-4000-8000-000000000012" {
parent @incident_response; represented @assess_incident;
}
// Both appearances reuse one definition. Navigation must retain the occurrence.
oa_occurrence initial_report "Initial incident report" id "70200000-0000-4000-8000-000000000020" {
parent @incident_response;
body { Exchange { exchange @incident_report; sender @reporter; receiver @assessor; message_kind Asynchronous; }; };
}
oa_occurrence updated_report "Updated incident report" id "70200000-0000-4000-8000-000000000021" {
parent @incident_response;
body { Exchange { exchange @incident_report; sender @reporter; receiver @assessor; message_kind Asynchronous; }; };
}
// A correlated self call/reply with deliberately unassigned exchange definitions.
oa_occurrence assess_call "Assess report details" id "70200000-0000-4000-8000-000000000022" {
parent @incident_response;
body { Exchange { exchange none; sender @assessor; receiver @assessor; message_kind Call; }; };
}
oa_occurrence assess_reply "Assessment ready" id "70200000-0000-4000-8000-000000000023" {
parent @incident_response;
body { Exchange { exchange none; sender @assessor; receiver @assessor; message_kind Reply; reply_to @assess_call; }; };
}
// Intentional unfinished work: no receiver and therefore no fabricated arrow.
oa_occurrence unfinished_notice "Follow-up recipient undecided" id "70200000-0000-4000-8000-000000000024" {
parent @incident_response;
body { Exchange { exchange @incident_report; sender @reporter; receiver none; message_kind Asynchronous; }; };
}
oa_occurrence use_review "Use the reviewed reporting procedure" id "70200000-0000-4000-8000-000000000025" {
parent @incident_response;
body { ScenarioUse {
bindings [{ local @reporter; referenced @review_reporter; }, { local @assessor; referenced @review_assessor; }];
scenario @reporting_review;
}; };
}
// This definition stays opaque in the caller. Open it, then Back to the saved view.
oa_scenario reporting_review "Reporting procedure detail" id "70200000-0000-4000-8000-000000000030" {
viewpoint OperationalActivity;
steps [@review_delivery];
}
oa_lifeline review_reporter "Reporting activity in detail" id "70200000-0000-4000-8000-000000000031" {
parent @reporting_review; represented @report_incident;
}
oa_lifeline review_assessor "Assessment activity in detail" id "70200000-0000-4000-8000-000000000032" {
parent @reporting_review; represented @assess_incident;
}
oa_occurrence review_delivery "Check the incident report" id "70200000-0000-4000-8000-000000000033" {
parent @reporting_review;
body { Exchange { exchange @incident_report; sender @review_reporter; receiver @review_assessor; message_kind Asynchronous; }; };
}
diagram incident_readable "A - Incident review readable" id "70200000-0000-4000-8000-000000000040" {
layer Oa;
projection { Scenario { scenario @incident_response; }; };
layout { Scenario {
lanes [{ lifeline @reporter; x 160; }, { lifeline @assessor; x 640; }];
messages [
{ address { references []; occurrence @initial_report; }; send_y 160; },
{ address { references []; occurrence @updated_report; }; send_y 252; },
{ address { references []; occurrence @assess_call; }; send_y 344; },
{ address { references []; occurrence @assess_reply; }; send_y 436; }
];
}; };
}
diagram incident_wide "B - Incident review negative and far" id "70200000-0000-4000-8000-000000000041" {
layer Oa;
projection { Scenario { scenario @incident_response; }; };
layout { Scenario {
lanes [{ lifeline @reporter; x -500; }, { lifeline @assessor; x 50000; }];
messages [
{ address { references []; occurrence @initial_report; }; send_y 160; },
{ address { references []; occurrence @updated_report; }; send_y 252; },
{ address { references []; occurrence @assess_call; }; send_y 344; },
{ address { references []; occurrence @assess_reply; }; send_y 436; }
];
}; };
}
diagram incident_repair "C - Incident review needs layout repair" id "70200000-0000-4000-8000-000000000042" {
layer Oa;
projection { Scenario { scenario @incident_response; }; };
layout { Scenario {
// These valid authored lanes must remain saved during complete fallback.
lanes [{ lifeline @reporter; x 900; }, { lifeline @assessor; x -500; }];
messages [
// REPAIR: change only this 300 to 424, then Undo and Redo the edit.
{ address { references []; occurrence @assess_reply; }; send_y 300; },
// This authored value stays latent while the receiver is missing.
{ address { references []; occurrence @unfinished_notice; }; send_y 2000; }
];
}; };
}
}
@@ -0,0 +1,356 @@
//! Actual App chooser/explicit-reveal checks for saved Scenario identity and source history.
use super::*;
use crate::behavior::scenario::{CanvasEvent, State};
use crate::behavior::scenario_layout::{
Representation,
presentation::{self, PresentationStatus},
};
use iced::{Size, Vector};
fn fixture() -> App {
let p = language::parse(include_str!("saved_scenario.arcadia")).unwrap();
let errors: Vec<_> = oa_core::validation::validate(&p)
.into_iter()
.filter(|d| d.severity == oa_core::validation::Severity::Error)
.collect();
assert!(errors.is_empty(), "{errors:?}");
App::with_project(p)
}
fn id(app: &App, alias: &str) -> Id {
app.project()
.identities()
.into_iter()
.find(|(_, a)| *a == alias)
.unwrap()
.0
}
fn representation(view: Id) -> Representation {
Representation::Saved { diagram: view }
}
fn find(app: &App, subject: Id, view: Id) -> Usage {
usage::saved_usages_for(app.project(), subject, Some(view))
.into_iter()
.find(|row| row.target == Target::ScenarioObject(subject))
.expect("exact saved occurrence")
}
fn context(app: &App) -> behavior::scenario::Context {
let owner = app.behavior.active_owner.unwrap();
app.behavior
.scenario
.context(
app.canvas_document,
owner,
app.document.revision(),
app.canvas_epoch,
)
.unwrap()
}
fn navigate(app: &mut App, subject: Id, view: Id) -> Id {
let (token, rows) = open(app, subject);
let found = rows
.into_iter()
.find(|u| u.surface == Surface::Saved(view) && u.target == Target::ScenarioObject(subject))
.expect("saved chooser destination");
choose(app, token, found);
assert!(app.diagram_usages.is_none());
assert_eq!(app.page, Page::Behavior);
assert_eq!(app.active_diagram, Some(view));
assert_eq!(
app.behavior.scenario.representation(),
Some(representation(view))
);
assert_eq!(app.selected, subject);
token
}
#[test]
fn saved_queries_use_each_actual_presentation_and_keep_repeated_appearances_and_references_opaque()
{
let app = fixture();
let owner = id(&app, "incident_response");
let first = id(&app, "initial_report");
let second = id(&app, "updated_report");
let exchange = id(&app, "incident_report");
let latent = id(&app, "unfinished_notice");
let callee_message = id(&app, "review_delivery");
let views = ["incident_readable", "incident_wide", "incident_repair"].map(|a| id(&app, a));
let derived = app
.behavior
.scenario
.diagram_usages(app.project(), owner, first)
.pop()
.unwrap();
let rows = views.map(|view| find(&app, first, view));
assert_eq!(rows[0].bounds.y - derived.bounds.y, 12.);
assert_ne!(rows[0].bounds, rows[1].bounds);
assert_eq!(
rows[2].bounds, derived.bounds,
"complete fallback cannot retain the authored lane override"
);
for view in views {
let represented = usage::saved_usages_for(app.project(), exchange, Some(view));
assert_eq!(represented.len(), 2);
assert!(
represented
.iter()
.any(|u| u.target == Target::ScenarioObject(first))
);
assert!(
represented
.iter()
.any(|u| u.target == Target::ScenarioObject(second))
);
assert!(
represented
.iter()
.all(|u| u.surface == Surface::Saved(view))
);
assert!(usage::saved_usages_for(app.project(), callee_message, Some(view)).is_empty());
assert_eq!(
find(&app, latent, view).target,
Target::ScenarioObject(latent)
);
let shown = presentation::present(app.project(), representation(view), owner);
let whole = usage::saved_usages_for(app.project(), view, Some(view));
assert_eq!(whole.len(), 1);
assert_eq!(whole[0].target, Target::Whole);
assert_eq!(Some(whole[0].bounds), shown.bounds());
}
let wide = presentation::present(app.project(), representation(views[1]), owner)
.bounds()
.unwrap();
assert!(wide.x < -500. && wide.x + wide.width > 50000.);
let fallback = presentation::present(app.project(), representation(views[2]), owner);
assert!(matches!(
fallback.status(),
PresentationStatus::Fallback { .. }
));
assert_eq!(fallback.latent_messages(), &[latent]);
assert!(!fallback.capabilities().coordinate_input);
}
#[test]
fn saved_chooser_and_explicit_reveal_preserve_complete_source_and_actual_undo_redo() {
for alias in ["incident_readable", "incident_wide", "incident_repair"] {
let mut app = fixture();
let view = id(&app, alias);
let first = id(&app, "initial_report");
let owner = id(&app, "incident_response");
let original = app.document.snapshot();
app.document
.execute(Command::Rename(first, "Reviewed incident report".into()))
.unwrap();
let edited = app.document.snapshot();
app.sync_source();
app.refresh_draft();
let before = authored(&app);
navigate(&mut app, first, view);
assert_eq!(app.behavior.active_owner, Some(owner));
assert_eq!(authored(&app), before);
send(&mut app, Message::RevealInDiagram(view, first));
assert_eq!(app.active_diagram, Some(view));
assert_eq!(authored(&app), before);
assert!(app.document.undo());
assert_eq!(app.project(), &original.decoded.project);
assert_eq!(app.document.bundle(), &original.bundle);
assert!(app.document.redo());
assert_eq!(app.project(), &edited.decoded.project);
assert_eq!(app.document.bundle(), &edited.bundle);
}
}
#[test]
fn saved_reveal_and_chooser_refuse_source_property_and_behavior_drafts() {
for chooser in [false, true] {
for block in 0..3 {
let mut app = fixture();
let view = id(&app, "incident_repair");
let first = id(&app, "initial_report");
let choice = chooser.then(|| {
let (token, rows) = open(&mut app, first);
(
token,
rows.into_iter()
.find(|u| u.surface == Surface::Saved(view))
.unwrap(),
)
});
match block {
0 => {
let cursor = app.source_editor.cursor();
app.source_workspace
.edit("? preserve unfinished source".into(), cursor);
}
1 => {
app.draft
.as_mut()
.unwrap()
.input("/name", "Unapplied inspector value".into());
assert!(app.draft_dirty());
}
_ => {
let p = app.project().clone();
app.behavior
.creation
.update(&p, behavior::creation::Event::Open(Layer::Oa))
.unwrap();
assert!(app.behavior.has_pending());
}
}
let before = authored(&app);
let prior = location(&app);
let draft = app
.draft
.as_ref()
.map(|d| serde_json::to_value(d.recovery_snapshot()).unwrap());
if let Some((token, row)) = choice {
choose(&mut app, token, row);
} else {
send(&mut app, Message::RevealInDiagram(view, first));
}
assert_eq!(authored(&app), before);
assert_eq!(location(&app), prior);
assert_eq!(
app.draft
.as_ref()
.map(|d| serde_json::to_value(d.recovery_snapshot()).unwrap()),
draft
);
}
}
}
#[test]
fn saved_a_b_a_rejects_old_visit_and_panel_callbacks_before_deferred_measurement() {
let mut app = fixture();
let a = id(&app, "incident_readable");
let b = id(&app, "incident_wide");
let first = id(&app, "initial_report");
let before = authored(&app);
let old_token = navigate(&mut app, first, a);
let old = context(&app);
navigate(&mut app, first, b);
navigate(&mut app, first, a);
let current = context(&app);
assert_ne!(old.visit, current.visit);
let held = behavior::scenario::Context {
epoch: current.epoch,
..old
};
let camera = app.behavior.scenario.camera_boundary();
send(
&mut app,
Message::ScenarioCanvas(CanvasEvent::Viewport(held, Size::new(900., 900.))),
);
send(&mut app, Message::ScenarioCanvas(CanvasEvent::Focus(held)));
send(
&mut app,
Message::DiagramUsage(Event::Search(old_token, "obsolete filter".into())),
);
send(&mut app, Message::DiagramUsage(Event::Close(old_token)));
assert_eq!(app.pending_scenario_reveal_visit, Some(current.visit));
assert!(app.pending_diagram_reveal.is_some());
assert!(app.behavior.scenario.camera_boundary() == camera);
let bounds = find(&app, first, a).bounds;
send(
&mut app,
Message::ScenarioCanvas(CanvasEvent::Viewport(current, Size::new(500., 300.))),
);
assert!(app.pending_diagram_reveal.is_none());
assert_eq!(context(&app).visit, current.visit);
let mut expected = State::default();
expected.enter_representation(app.canvas_document, current.scenario, representation(a));
expected.viewport(Size::new(500., 300.));
expected.zoom(Some(1.25));
expected.camera(
app.project(),
current.scenario,
behavior::canvas_workspace::Event::Pan(Vector::new(
250. - bounds.center_x() * 1.25,
150. - bounds.center_y() * 1.25,
)),
);
assert!(app.behavior.scenario.camera_boundary() == expected.camera_boundary());
assert_eq!(authored(&app), before);
}
#[test]
fn retargeted_saved_view_cannot_reveal_a_former_owner_occurrence() {
let mut app = fixture();
let first = id(&app, "initial_report");
let view = id(&app, "incident_readable");
let other = id(&app, "reporting_review");
let (token, rows) = open(&mut app, first);
let old = rows
.into_iter()
.find(|u| u.surface == Surface::Saved(view))
.unwrap();
let mut replacement = app.project().diagrams[&view].clone();
replacement.projection = ProjectionKind::Scenario { scenario: other };
replacement.layout = None;
app.document
.execute(Command::EditDiagram(view, replacement))
.unwrap();
app.sync_source();
app.refresh_draft();
let before = authored(&app);
let prior = location(&app);
choose(&mut app, token, old);
assert_eq!(location(&app), prior);
assert_eq!(authored(&app), before);
finish_lookup(&mut app);
let token = app.diagram_usages.as_ref().unwrap().token;
assert!(
app.diagram_usages
.as_ref()
.unwrap()
.usages
.iter()
.all(|u| u.surface != Surface::Saved(view))
);
send(&mut app, Message::DiagramUsage(Event::Close(token)));
send(&mut app, Message::RevealInDiagram(view, first));
assert_eq!(location(&app), prior);
assert_eq!(authored(&app), before);
}
#[test]
fn explicit_saved_layout_repair_undo_redo_requeries_authored_and_fallback_geometry_without_extra_history()
{
let mut app = fixture();
let view = id(&app, "incident_repair");
let first = id(&app, "initial_report");
let reply = id(&app, "assess_reply");
let before = app.document.snapshot();
let fallback = find(&app, first, view);
app.document
.execute(Command::SetScenarioMessagePosition {
diagram: view,
position: oa_core::ScenarioMessagePosition {
address: oa_core::OccurrenceEndpoint {
references: vec![],
occurrence: reply,
},
send_y: 424.,
},
})
.unwrap();
app.sync_source();
app.refresh_draft();
let repaired = app.document.snapshot();
let authored_row = find(&app, first, view);
assert_ne!(authored_row.bounds, fallback.bounds);
navigate(&mut app, first, view);
assert!(app.document.undo());
app.sync_source();
app.refresh_draft();
assert_eq!(app.project(), &before.decoded.project);
assert_eq!(app.document.bundle(), &before.bundle);
assert_eq!(find(&app, first, view).bounds, fallback.bounds);
assert!(app.document.redo());
app.sync_source();
app.refresh_draft();
assert_eq!(app.project(), &repaired.decoded.project);
assert_eq!(app.document.bundle(), &repaired.bundle);
assert_eq!(find(&app, first, view).bounds, authored_row.bounds);
}
+16
View File
@@ -213,6 +213,14 @@ impl App {
self.pending = None;
return Task::none();
}
if self.path.is_some()
|| self.hosted.session.as_ref().is_none_or(|session| {
session.resource_id != remote.resource_id
|| session.connection.base() != request.connection.base()
})
{
self.source_workspace.reset_navigation_session();
}
self.hosted.session = Some(Session {
connection: request.connection.clone(),
resource_id: remote.resource_id,
@@ -345,6 +353,14 @@ impl App {
match self.document.apply_compiled((*remote.compiled).clone()) {
Ok(_) => {
self.document.mark_saved(remote.compiled.clone());
if self.path.is_some()
|| self.hosted.session.as_ref().is_none_or(|session| {
session.resource_id != remote.resource_id
|| session.connection.base() != connection.base()
})
{
self.source_workspace.reset_navigation_session();
}
self.hosted.session = Some(Session {
connection,
resource_id: remote.resource_id,
+80 -35
View File
@@ -15,18 +15,15 @@ impl App {
epoch: self.canvas_epoch,
})
}
pub(super) fn reveal_process_selection(&self) -> Task<Message> {
let Some(context) = self.topology_context() else {
return Task::none();
};
let Some(offset) =
self.behavior
.topology
.selection_offset(self.project(), context.owner, self.selected)
else {
return Task::none();
};
iced::widget::operation::scroll_to("process-canvas", offset)
pub(super) fn reveal_process_selection(&mut self) -> Task<Message> {
if let Some(context) = self.topology_context() {
let mut state = std::mem::take(&mut self.behavior.topology);
if state.reveal_selection(self.project(), context.owner, self.selected) {
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
}
self.behavior.topology = state;
}
Task::none()
}
pub(super) fn edit_process_canvas(&mut self, event: CanvasEvent) -> Task<Message> {
let context = event.context();
@@ -52,6 +49,48 @@ impl App {
}
return Task::none();
}
if let CanvasEvent::Camera(_, lifetime, input) = event {
use crate::behavior::canvas_workspace::{self, Event};
if lifetime != self.camera_epoch
|| !self.topology_context().is_some_and(|c| {
c.document == context.document
&& c.owner == context.owner
&& c.revision == context.revision
})
{
return Task::none();
}
let scope = canvas_workspace::Scope {
document: context.document,
owner: context.owner,
revision: context.revision,
epoch: lifetime,
scenario_view: None,
};
if self.behavior.has_pending()
|| self.draft_dirty()
|| self.source_dirty()
|| !canvas_workspace::keyboard_enabled(self)
{
return Task::none();
}
match input {
Event::Focus
| Event::Traverse(_)
| Event::ControlFocus(_)
| Event::ToggleFocus
| Event::Leave => {
return canvas_workspace::focus_task(scope, input);
}
Event::CancelGesture => self.canvas_epoch = self.canvas_epoch.wrapping_add(1),
input => {
if self.behavior.topology.camera(input) {
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
}
}
}
return Task::none();
}
if self.topology_context() != Some(context) {
return Task::none();
}
@@ -81,6 +120,7 @@ impl App {
.first()
.copied()
.unwrap_or(endpoint.occurrence),
resolved.defining_sequence,
);
self.behavior.active_owner = Some(resolved.defining_sequence);
self.select(endpoint.occurrence);
@@ -118,14 +158,18 @@ impl App {
}
}
CanvasEvent::Choose(_, tool) => {
if self.behavior.has_pending() || !self.apply() {
if self.behavior.has_pending() || self.draft_dirty() || self.source_dirty() {
self.status =
"Apply or cancel pending edits before choosing a process tool.".into();
return Task::none();
}
self.behavior.topology.choose(tool);
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
}
CanvasEvent::Zoom(_, zoom) => {
self.behavior.topology.zoom(zoom);
let mut state = std::mem::take(&mut self.behavior.topology);
state.camera_zoom(self.project(), context.owner, zoom);
self.behavior.topology = state;
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
}
CanvasEvent::Expand(_, id) => {
@@ -149,7 +193,9 @@ impl App {
.get(&target)
.is_some_and(|e| matches!(e.kind(), Kind::Process | Kind::FunctionalChain))
{
self.behavior.topology.remember(context.owner, occurrence);
self.behavior
.topology
.remember(context.owner, occurrence, target);
self.behavior.active_owner = Some(target);
}
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
@@ -524,32 +570,30 @@ mod tests {
assert!(!app.behavior.has_pending());
}
#[test]
fn direct_process_app_save_scroll_resize_and_passive_geometry_keep_drafts_and_current_origin() {
fn direct_process_app_save_details_resize_and_passive_geometry_keep_drafts_and_camera() {
let (mut app, f) = setup();
let context = app.topology_context().unwrap();
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Camera(
context,
app.camera_epoch,
behavior::canvas_workspace::Event::Pan(iced::Vector::new(25., 100.)),
)));
let context = app.topology_context().unwrap();
let original = app.document.snapshot();
app.busy = true;
for (area, offset) in [
(
behavior::scenario::ScrollArea::Canvas,
iced::Vector::new(25., 100.),
),
(
behavior::scenario::ScrollArea::Page,
iced::Vector::new(0., 40.),
),
] {
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Scrolled(
context, area, offset,
)));
}
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Scrolled(
context,
behavior::scenario::ScrollArea::Page,
iced::Vector::new(0., 40.),
)));
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Viewport(
context,
iced::Size::new(500., 300.),
)));
let camera = app.behavior.topology.camera_boundary();
assert_eq!(
app.behavior.topology.scroll_offset(),
iced::Vector::new(25., 140.)
app.behavior.topology.details_offset(),
iced::Vector::new(0., 40.)
);
assert_ne!(app.topology_context(), Some(context));
assert!(app.busy);
@@ -557,12 +601,12 @@ mod tests {
stale.document = Id::new_v4();
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Scrolled(
stale,
behavior::scenario::ScrollArea::Canvas,
behavior::scenario::ScrollArea::Page,
iced::Vector::new(0., 900.),
)));
assert_eq!(
app.behavior.topology.scroll_offset(),
iced::Vector::new(25., 140.)
app.behavior.topology.details_offset(),
iced::Vector::new(0., 40.)
);
let root = PathBuf::from("process-scroll-save-fixture");
let receipt = FileSnapshot {
@@ -582,6 +626,7 @@ mod tests {
assert!(!app.busy);
assert!(!app.document.can_undo());
assert_eq!(app.project(), &f.p);
assert!(app.behavior.topology.camera_boundary() == camera);
let context = app.topology_context().unwrap();
let size = app.window_size;
let _ = app.update(Message::WindowResized(size));
@@ -0,0 +1,304 @@
//! One transient chooser for the currently mounted Inspector or review field.
//! Browsing never calls a draft setter or semantic planner.
use super::{App, Message, Page, Pending};
use crate::{
forms::{
Draft,
reference_chooser::{self as chooser, Action, Close, Outcome, adapter, trigger},
},
navigation::InspectorTab,
semantic_review::{self, RowAddress, RowEvent},
workflow,
};
use iced::{Element, Rectangle, Task};
use oa_core::{FacetSelector, Id, Layer};
#[derive(Debug, Clone, PartialEq, Eq)]
enum Host {
Inspector,
Review {
address: Option<RowAddress>,
facet: FacetSelector,
},
}
impl Host {
fn message(&self, event: chooser::Event) -> Message {
match self {
Self::Inspector => Message::FieldReferenceEvent(event),
Self::Review { address, facet } => {
Message::Workflow(workflow::Event::semantic(semantic_review::Event::Row {
address: *address,
facet: Some(facet.clone()),
event: RowEvent::ReferenceEvent(event),
}))
}
}
}
}
struct Opened {
host: Host,
request: adapter::Open,
document_session: Id,
page: Page,
layer: Layer,
chooser: chooser::State,
}
#[derive(Default)]
pub(super) struct State {
opened: Option<Opened>,
}
impl App {
pub(crate) fn reference_input_active(&self) -> bool {
self.reference_authoring.opened.is_some()
}
fn reference_draft(&self, host: &Host, path: &str) -> Option<&Draft> {
match host {
Host::Inspector
if self.workflow.is_none() && self.inspector_tab == InspectorTab::Properties =>
{
self.draft.as_ref()
}
Host::Review { address, facet } => match self.workflow.as_ref()? {
workflow::Workflow::Semantic(state) => {
state.reference_draft(self.project(), *address, facet, path)
}
_ => None,
},
_ => None,
}
}
fn reference_available(&self) -> bool {
!self.busy
&& !self.hosted.visible
&& self.architecture.is_none()
&& self.model_menu.is_none()
&& self.file_prompt.is_none()
&& !self.delete_pending
&& self.pending.is_none()
&& !self.source_dirty()
&& !self.behavior.has_pending()
&& !matches!(self.page, Page::Home | Page::Guide | Page::Text)
}
fn reference_valid(&self, opened: &Opened) -> bool {
self.reference_available()
&& self.canvas_document == opened.document_session
&& self.page == opened.page
&& self.layer == opened.layer
&& self.document.revision() == opened.chooser.stamp().context.revision
&& self.project().id == opened.request.document
&& self
.reference_draft(&opened.host, &opened.request.path)
.is_some_and(|draft| draft.reference_matches(&opened.request))
}
pub(super) fn reconcile_reference_chooser(&mut self) {
if self
.reference_authoring
.opened
.as_ref()
.is_some_and(|opened| !self.reference_valid(opened))
{
self.reference_authoring
.opened
.as_mut()
.unwrap()
.chooser
.invalidate();
self.reference_authoring.opened = None;
}
}
fn open_reference(&mut self, host: Host, request: adapter::Open) -> Task<Message> {
if !self.reference_available() {
return Task::none();
}
let Some(draft) = self.reference_draft(&host, &request.path) else {
return Task::none();
};
let Ok(chooser) =
draft.reference_catalog(self.project(), &request, self.document.revision())
else {
return Task::none();
};
self.reference_authoring.opened = Some(Opened {
host,
request,
document_session: self.canvas_document,
page: self.page,
layer: self.layer,
chooser,
});
Task::none()
}
fn reference_event(&mut self, host: Host, event: chooser::Event) -> Task<Message> {
let Some(mut opened) = self.reference_authoring.opened.take() else {
return Task::none();
};
if !self.reference_valid(&opened) {
return Task::none();
}
if opened.host != host {
self.reference_authoring.opened = Some(opened);
return Task::none();
}
match opened.chooser.update(event) {
Outcome::Ignored | Outcome::Browsed => {
self.reference_authoring.opened = Some(opened);
Task::none()
}
Outcome::Closed(reason) => self.reference_focus(
&opened,
match reason {
Close::Escape => Some(0),
Close::Tab { backwards } => Some(if backwards { -1 } else { 1 }),
Close::Outside | Close::WindowChanged => None,
},
),
Outcome::Write { context, value } => {
let intent = adapter::Set {
context,
generation: opened.request.generation,
value,
};
let result = match &opened.host {
Host::Inspector => self
.draft
.as_mut()
.ok_or_else(|| "The property editor closed.".to_owned())
.and_then(|draft| draft.reference_set(self.document.project(), &intent)),
Host::Review { address, facet } => match self.workflow.as_mut() {
Some(workflow::Workflow::Semantic(state)) => {
state.set_reference(self.document.project(), *address, facet, intent)
}
_ => Err("The review editor closed.".into()),
},
};
if let Err(error) = result {
self.status = error;
}
// A scoped write rotates field generation. The task targets the
// newly rendered field, while a later structural edit makes it inert.
self.reference_focus(&opened, Some(0))
}
}
}
fn reference_focus(&self, opened: &Opened, step: Option<i32>) -> Task<Message> {
let Some(step) = step else {
return Task::none();
};
let Some(draft) = self.reference_draft(&opened.host, &opened.request.path) else {
return Task::none();
};
if draft.reference_session() != opened.request.session {
return Task::none();
}
trigger::focus(
draft.reference_session(),
draft.reference_generation(),
&opened.request.path,
step,
)
}
/// Called before generic modal dispatch: the review owns a modal, and this
/// chooser owns only its active field. All browser events stop here.
pub(super) fn reference_message(&mut self, message: &Message) -> Option<Task<Message>> {
self.reconcile_reference_chooser();
match message {
Message::FieldReferenceOpen(request) => {
Some(self.open_reference(Host::Inspector, request.clone()))
}
Message::FieldReferenceEvent(event) => {
Some(self.reference_event(Host::Inspector, event.clone()))
}
Message::Workflow(workflow::Event::Semantic(review_event)) => {
let semantic_review::Event::Row {
address,
facet: Some(facet),
event,
} = review_event.as_ref()
else {
return None;
};
let host = Host::Review {
address: *address,
facet: facet.clone(),
};
match event {
RowEvent::ReferenceOpen(request) => {
Some(self.open_reference(host, request.clone()))
}
RowEvent::ReferenceEvent(event) => {
Some(self.reference_event(host, event.clone()))
}
_ => None,
}
}
Message::Escape | Message::Request(Pending::Close(_))
if self.reference_authoring.opened.is_some() =>
{
let opened = self.reference_authoring.opened.as_ref().unwrap();
let host = opened.host.clone();
let event = opened.chooser.event(Action::Close(Close::Escape));
Some(self.reference_event(host, event))
}
// Global shortcuts are also emitted for keys captured by the search
// input. They must not apply drafts, navigate, or undo the model.
Message::Save(_)
| Message::ShortcutUndo(_)
| Message::Request(Pending::Open | Pending::New)
| Message::SourceNavigation(_)
| Message::Page(Page::Guide)
if self.reference_authoring.opened.is_some() =>
{
Some(Task::none())
}
_ => None,
}
}
pub(super) fn reference_overlay<
'a,
R: iced::advanced::text::Renderer<Font = iced::Font> + 'a,
>(
&'a self,
content: Element<'a, Message, iced::Theme, R>,
) -> Element<'a, Message, iced::Theme, R> {
let opened = self
.reference_authoring
.opened
.as_ref()
.filter(|opened| self.reference_valid(opened));
let host = opened
.map(|opened| opened.host.clone())
.unwrap_or(Host::Inspector);
let focus_scope = if self.reference_available() {
match self.workflow.as_ref() {
None if self.inspector_tab == InspectorTab::Properties => {
self.draft.as_ref().map(|draft| trigger::FormScope {
session: draft.reference_session(),
modal: false,
})
}
Some(workflow::Workflow::Semantic(state)) => state
.reference_form(self.project())
.map(|draft| trigger::FormScope {
session: draft.reference_session(),
modal: true,
}),
_ => None,
}
} else {
None
};
chooser::widget::Popup::new(
content,
opened.map(|opened| &opened.chooser),
opened.map_or(Rectangle::default(), |opened| opened.request.anchor),
move |event| host.message(event),
)
.focus_scope(focus_scope)
.into()
}
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,586 @@
use super::*;
use crate::forms::Draft;
use oa_core::*;
use serde_json::{Value, json};
const OWNER: Id = Id::from_u128(100);
const BASE: u128 = 10_000;
const PATH: &str = "/body/Oa/Shared/DataType/definition/Record/supertype";
fn element(p: &mut Project, id: Id, kind: Kind, layer: Layer, name: &str, parent: Option<Id>) {
let generated = p.add_element(kind, layer, name, parent).unwrap();
let mut element = p.elements.remove(&generated).unwrap();
element.id = id;
element.alias = format!("object_{}", id.as_u128());
if let Some(SharedBody::DataType { definition, .. }) = element.shared_mut() {
*definition = DataTypeDefinition::Record { supertype: None };
}
p.elements.insert(id, element);
}
fn catalog() -> Project {
let mut p = Project::empty();
p.id = Id::from_u128(1);
p.name = "Reference catalog".into();
element(
&mut p,
OWNER,
Kind::DataType,
Layer::Oa,
"Edited record",
None,
);
for n in (0..1_000).rev() {
let id = Id::from_u128(BASE + n);
element(&mut p, id, Kind::DataType, Layer::Oa, "Controller", None);
p.elements.get_mut(&id).unwrap().alias = format!("controller_{n:04}");
}
element(
&mut p,
Id::from_u128(200),
Kind::DataType,
Layer::Sa,
"Controller",
None,
);
element(
&mut p,
Id::from_u128(201),
Kind::Activity,
Layer::Oa,
"Controller",
None,
);
p
}
fn inspector(p: Project, selected: Id) -> App {
let mut app = App::with_project(p);
let _ = app.update(Message::Select(selected));
app
}
fn request(app: &App, host: &Host, path: &str) -> adapter::Open {
app.reference_draft(host, path).unwrap().reference_open(
app.project(),
path,
Rectangle {
x: 790.,
y: 640.,
width: 278.,
height: 32.,
},
)
}
fn open(app: &mut App, host: &Host, path: &str) {
let request = request(app, host, path);
let message = match host {
Host::Inspector => Message::FieldReferenceOpen(request),
Host::Review { address, facet } => {
Message::Workflow(workflow::Event::semantic(semantic_review::Event::Row {
address: *address,
facet: Some(facet.clone()),
event: RowEvent::ReferenceOpen(request),
}))
}
};
let _ = app.update(message);
assert!(
app.reference_authoring.opened.is_some(),
"field must open: {}",
app.status
);
}
fn event(app: &App, action: Action) -> Message {
let opened = app.reference_authoring.opened.as_ref().unwrap();
opened.host.message(opened.chooser.event(action))
}
fn search(app: &mut App, query: &str) {
let serial = app
.reference_authoring
.opened
.as_ref()
.unwrap()
.chooser
.query_serial()
+ 1;
let event = event(
app,
Action::Query {
text: query.into(),
serial,
},
);
let _ = app.update(event);
}
fn choose(app: &mut App, id: Id) {
let event = event(app, Action::Choose(id));
let _ = app.update(event);
}
fn raw(app: &App) -> Value {
serde_json::to_value(app.draft.as_ref().unwrap().recovery_snapshot()).unwrap()
}
fn review(app: &App) -> &semantic_review::State {
let Some(workflow::Workflow::Semantic(state)) = app.workflow.as_ref() else {
panic!("Review is open")
};
state
}
fn send_review(app: &mut App, event: RowEvent) {
let addressed = review(app).address_event(event);
let _ = app.update(Message::Workflow(workflow::Event::semantic(addressed)));
}
fn review_host(app: &App) -> Host {
let semantic_review::Event::Row {
address,
facet: Some(facet),
..
} = review(app).address_event(RowEvent::Preview)
else {
panic!("Active facet")
};
Host::Review { address, facet }
}
fn trace(p: &mut Project, id: Id, oa: Id, sa: Id) {
p.traces.insert(
id,
Trace {
id,
alias: format!("trace_{}", id.as_u128()),
name: "Rationale".into(),
description: String::new(),
oa,
sa,
kind: TraceKind::Manual,
baseline: None,
},
);
}
#[test]
fn inspector_reference_browse_is_read_only_then_one_scoped_value_applies_with_exact_undo_redo() {
let p = catalog();
let mut app = inspector(p.clone(), OWNER);
let before = app.document.bundle().clone();
let revision = app.document.revision();
// Malformed escaped text is unrelated to the selected reference. Browsing
// and the scoped write must keep its exact spelling and inactive payload.
app.draft.as_mut().unwrap().set_exact(true).unwrap();
let _ = app.update(Message::Field("/name".into(), "\"unfinished".into()));
let raw_before = raw(&app);
open(&mut app, &Host::Inspector, PATH);
assert_eq!(
app.reference_authoring
.opened
.as_ref()
.unwrap()
.chooser
.count(),
1_000
);
search(&mut app, "controller @controller_0720 OA");
let picker = &app.reference_authoring.opened.as_ref().unwrap().chooser;
assert_eq!(picker.count(), 1);
assert_eq!(picker.highlighted().unwrap().id, Id::from_u128(BASE + 720));
assert_eq!(raw(&app), raw_before);
assert_eq!(app.document.bundle(), &before);
assert_eq!(app.document.revision(), revision);
assert!(!app.document.can_undo());
// Captured search shortcuts cannot accidentally save/apply or undo a model.
let _ = app.update(Message::Save(false));
let _ = app.update(Message::ShortcutUndo(false));
assert_eq!(raw(&app), raw_before);
assert_eq!(app.document.bundle(), &before);
choose(&mut app, Id::from_u128(BASE + 720));
let mut expected_raw = raw_before;
*expected_raw["value"].pointer_mut(PATH).unwrap() = json!(Id::from_u128(BASE + 720));
expected_raw["inputs"][PATH] = json!(Id::from_u128(BASE + 720).to_string());
assert_eq!(raw(&app), expected_raw);
assert_eq!(app.document.bundle(), &before);
assert!(!app.document.can_undo());
let _ = app.update(Message::Apply);
assert_eq!(
app.document.bundle(),
&before,
"Unrelated invalid text remains an Apply error"
);
let _ = app.update(Message::Field("/name".into(), "\"Edited record\"".into()));
let _ = app.update(Message::Apply);
let mut expected = p;
if let Some(SharedBody::DataType { definition, .. }) =
expected.elements.get_mut(&OWNER).unwrap().shared_mut()
{
*definition = DataTypeDefinition::Record {
supertype: Some(Id::from_u128(BASE + 720)),
};
}
assert_eq!(app.project(), &expected);
let accepted = app.document.bundle().clone();
assert_ne!(accepted, before);
let _ = app.update(Message::Undo);
assert_eq!(app.document.bundle(), &before);
let _ = app.update(Message::Redo);
assert_eq!(app.document.bundle(), &accepted);
assert_eq!(app.project(), &expected);
}
#[test]
fn inspector_reference_retains_invalid_missing_and_incompatible_current_until_explicit_clear() {
for (raw_id, expected) in [
("-".into(), "invalid"),
(Id::from_u128(999).to_string(), "missing"),
(Id::from_u128(200).to_string(), "incompatible"),
] {
let mut app = inspector(catalog(), OWNER);
app.draft
.as_mut()
.unwrap()
.shape_scoped(PATH, json!(raw_id))
.unwrap();
let original = raw(&app);
let accepted = app.document.bundle().clone();
open(&mut app, &Host::Inspector, PATH);
let current = &app
.reference_authoring
.opened
.as_ref()
.unwrap()
.chooser
.current;
assert!(matches!(
(expected, current),
("invalid", chooser::Current::InvalidRaw(_))
| ("missing", chooser::Current::Missing(_))
| ("incompatible", chooser::Current::Incompatible(_))
));
let _ = app.update(Message::Escape);
assert_eq!(raw(&app), original);
open(&mut app, &Host::Inspector, PATH);
let clear = event(&app, Action::Clear);
let _ = app.update(clear);
assert!(
app.draft
.as_ref()
.unwrap()
.value
.pointer(PATH)
.unwrap()
.is_null()
);
assert_eq!(app.document.bundle(), &accepted);
assert!(!app.document.can_undo());
}
}
#[test]
fn chooser_rejects_old_results_after_context_recovery_reload_or_modal_and_keeps_history_text_out() {
for change in 0..7 {
let mut app = inspector(catalog(), OWNER);
open(&mut app, &Host::Inspector, PATH);
let delayed = event(&app, Action::Choose(Id::from_u128(BASE + 1)));
match change {
0 => {
app.draft
.as_mut()
.unwrap()
.shape_scoped(PATH, json!(Id::from_u128(BASE + 2)))
.unwrap();
}
1 => {
let saved = app.draft.as_ref().unwrap().recovery_snapshot();
app.draft = Some(Draft::from_recovery(app.project(), saved).unwrap());
}
2 => {
app.canvas_document = Id::new_v4();
}
3 => {
app.hosted.visible = true;
}
4 => {
app.draft
.as_mut()
.unwrap()
.set_context(Some(json!({"different":"scope"})));
}
5 => {
let _ = app.update(Message::Command(Command::Rename(
Id::from_u128(BASE + 3),
"Changed model".into(),
)));
}
_ => {
app.source_workspace.edit(
"unfinished source".into(),
iced::widget::text_editor::Cursor {
position: iced::widget::text_editor::Position { line: 0, column: 0 },
selection: None,
},
);
}
}
let expected = raw(&app);
let source = app.document.bundle().clone();
let _ = app.update(delayed);
assert!(app.reference_authoring.opened.is_none());
assert_eq!(raw(&app), expected);
assert_eq!(app.document.bundle(), &source);
}
let mut p = catalog();
trace(&mut p, Id::from_u128(300), OWNER, Id::from_u128(200));
let app = inspector(p, Id::from_u128(300));
let draft = app.draft.as_ref().unwrap();
let request = draft.reference_open(
app.project(),
"/baseline/semantic/facets/0/references/0/target",
Rectangle::default(),
);
assert!(draft.reference_catalog(app.project(), &request, 0).is_err());
}
#[test]
fn reference_array_row_reorder_and_constructor_switch_invalidate_exact_old_field() {
let mut p = Project::empty();
for n in 0..3 {
element(
&mut p,
Id::from_u128(BASE + n),
Kind::ExchangeItem,
Layer::Oa,
"Signal item",
None,
);
}
element(&mut p, OWNER, Kind::Event, Layer::Oa, "Signal", None);
*p.elements.get_mut(&OWNER).unwrap().shared_mut().unwrap() = SharedBody::Event {
body: EventBody::Signal {
items: vec![Id::from_u128(BASE), Id::from_u128(BASE + 1)],
},
};
let mut app = inspector(p, OWNER);
let list = "/body/Oa/Shared/Event/body/Signal/items";
let path = format!("{list}/0");
open(&mut app, &Host::Inspector, &path);
let delayed = event(&app, Action::Choose(Id::from_u128(BASE + 2)));
let _ = app.update(Message::FieldArray(list.into(), Some(0), 1));
let expected = raw(&app);
assert_eq!(
app.draft.as_ref().unwrap().value.pointer(list),
Some(&json!([Id::from_u128(BASE + 1), Id::from_u128(BASE)]))
);
let _ = app.update(delayed);
assert_eq!(raw(&app), expected);
open(&mut app, &Host::Inspector, &path);
let delayed = event(&app, Action::Choose(Id::from_u128(BASE + 2)));
let _ = app.update(Message::FieldVariant(
"/body/Oa/Shared/Event/body".into(),
json!({"Change":{"expression":"unfinished"}}),
));
let expected = raw(&app);
let _ = app.update(delayed);
assert_eq!(raw(&app), expected);
assert!(!app.document.can_undo());
}
#[test]
fn review_reference_facets_groups_and_attachment_hosts_browse_without_replanning_and_reject_old_facet()
{
for mode in 0..3 {
let mut p = Project::empty();
p.id = Id::from_u128(1);
for (id, layer, parent) in [
(100, Layer::Oa, None),
(101, Layer::Sa, None),
(102, Layer::Sa, None),
(103, Layer::Oa, Some(Id::from_u128(100))),
(104, Layer::Sa, Some(Id::from_u128(101))),
] {
element(
&mut p,
Id::from_u128(id),
if parent.is_some() {
Kind::DataField
} else {
Kind::DataType
},
layer,
"Record",
parent,
);
}
trace(
&mut p,
Id::from_u128(300),
Id::from_u128(100),
Id::from_u128(101),
);
let facet = match mode {
0 => FacetSelector::Data(DataFacet::RecordSupertype),
1 => FacetSelector::Data(DataFacet::Constructor),
_ => FacetSelector::Child(KeyPair {
oa: Some(Id::from_u128(103)),
sa: Some(Id::from_u128(104)),
}),
};
let path = if mode == 2 {
"/parent"
} else {
"/body/Sa/Shared/DataType/definition/Record/supertype"
};
let mut app = App::with_project(p.clone());
let _ = app.update(Message::StartSemanticReview(Id::from_u128(100)));
if mode == 2 {
// A paired attachment is a real catalog facet only after explicit
// correspondence; selecting an unstaged synthetic pair is rejected.
for (id, oa) in [(103, true), (104, false)] {
let choice = crate::app::Choice {
id: Id::from_u128(id),
label: format!("Record · @object_{id}"),
};
send_review(
&mut app,
if oa {
RowEvent::ReferenceOa(choice)
} else {
RowEvent::ReferenceSa(choice)
},
);
}
send_review(&mut app, RowEvent::AddReference);
}
send_review(&mut app, RowEvent::Select(facet.clone()));
send_review(
&mut app,
RowEvent::Decide(facet.clone(), Some(SemanticDecision::Candidate)),
);
let host = review_host(&app);
let record = if mode == 2 {
Id::from_u128(104)
} else {
Id::from_u128(101)
};
assert_eq!(
app.reference_draft(&host, path)
.unwrap_or_else(|| {
panic!(
"Reference fixture mode={mode}, facet={facet:?}, selected={:?}, decisions={:?}, error={:?}",
review(&app).selected,
review(&app).decisions,
review(&app).error
)
})
.original,
record
);
let before = review(&app).recovery_snapshot().unwrap();
let plan = serde_json::to_value(&review(&app).plan).unwrap();
let proposed = review(&app).proposed.clone();
let source = app.document.bundle().clone();
open(&mut app, &host, path);
search(&mut app, "@object_102 SA");
assert_eq!(
app.reference_authoring
.opened
.as_ref()
.unwrap()
.chooser
.count(),
1
);
assert_eq!(review(&app).recovery_snapshot().unwrap(), before);
assert_eq!(serde_json::to_value(&review(&app).plan).unwrap(), plan);
assert_eq!(review(&app).proposed, proposed);
assert_eq!(app.document.bundle(), &source);
let old = event(&app, Action::Choose(Id::from_u128(102)));
send_review(
&mut app,
RowEvent::Select(FacetSelector::Header(HeaderFacet::Name)),
);
let switched = review(&app).recovery_snapshot().unwrap();
let _ = app.update(old);
assert_eq!(review(&app).recovery_snapshot().unwrap(), switched);
send_review(&mut app, RowEvent::Select(facet));
open(&mut app, &host, path);
choose(&mut app, Id::from_u128(102));
let draft = app.reference_draft(&host, path).unwrap();
let mut expected_record = serde_json::to_value(&p.elements[&record]).unwrap();
*expected_record.pointer_mut(path).unwrap() = json!(Id::from_u128(102));
assert_eq!(draft.materialize().unwrap(), expected_record);
assert_eq!(app.document.bundle(), &source);
assert!(
!app.document.can_undo(),
"A choice only stages the active review candidate"
);
}
}
mod mounted;
#[test]
fn review_reference_cannot_reopen_or_write_a_banked_row_after_another_row_takes_focus() {
let mut p = Project::empty();
for (id, layer) in [
(100, Layer::Oa),
(101, Layer::Sa),
(102, Layer::Sa),
(110, Layer::Oa),
(111, Layer::Sa),
] {
element(
&mut p,
Id::from_u128(id),
Kind::DataType,
layer,
"Record",
None,
);
}
trace(
&mut p,
Id::from_u128(300),
Id::from_u128(100),
Id::from_u128(101),
);
trace(
&mut p,
Id::from_u128(310),
Id::from_u128(110),
Id::from_u128(111),
);
let mut app = App::with_project(p);
let _ = app.update(Message::StartSemanticReview(Id::from_u128(300)));
let facet = FacetSelector::Data(DataFacet::RecordSupertype);
send_review(&mut app, RowEvent::Select(facet.clone()));
send_review(
&mut app,
RowEvent::Decide(facet, Some(SemanticDecision::Candidate)),
);
let host = review_host(&app);
let path = "/body/Sa/Shared/DataType/definition/Record/supertype";
let old_open = request(&app, &host, path);
open(&mut app, &host, path);
let old_choice = event(&app, Action::Choose(Id::from_u128(102)));
for event in [
semantic_review::Event::ChooseTrace(crate::app::Choice {
id: Id::from_u128(310),
label: "Second exact trace".into(),
}),
semantic_review::Event::OpenTrace,
] {
let _ = app.update(Message::Workflow(workflow::Event::semantic(event)));
}
assert_eq!(review(&app).target(app.project()), Some(Id::from_u128(111)));
let expected = review(&app).recovery_snapshot().unwrap();
let source = app.document.bundle().clone();
let _ = app.update(old_choice);
let Host::Review { address, facet } = host else {
unreachable!()
};
let _ = app.update(Message::Workflow(workflow::Event::semantic(
semantic_review::Event::Row {
address,
facet: Some(facet),
event: RowEvent::ReferenceOpen(old_open),
},
)));
assert!(app.reference_authoring.opened.is_none());
assert_eq!(review(&app).recovery_snapshot().unwrap(), expected);
assert_eq!(app.document.bundle(), &source);
assert!(!app.document.can_undo());
}
@@ -0,0 +1,910 @@
//! Real field + app-root overlay interaction. The null text renderer lets the
//! same retained widget tree exercise focus and geometry without a GPU/window.
use super::*;
use iced::futures::StreamExt;
use iced::{
Element, Event as Input, Point, Size, Theme, Vector,
advanced::{
Layout, Shell, layout,
widget::{Id as WidgetId, Operation, Tree, operation},
},
keyboard, mouse,
widget::{Space, column, container, opaque, scrollable, stack, text_input},
};
use oa_core::transitions::TransitionDirection;
#[derive(Default)]
struct Clipboard;
impl iced::advanced::Clipboard for Clipboard {
fn read(&self, _: iced::advanced::clipboard::Kind) -> Option<String> {
None
}
fn write(&mut self, _: iced::advanced::clipboard::Kind, _: String) {}
}
#[derive(Default)]
struct Fields {
values: Vec<(WidgetId, Rectangle, bool)>,
scrolls: Vec<(Rectangle, Vector)>,
}
impl Operation for Fields {
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn Operation)) {
visit(self);
}
fn focusable(
&mut self,
id: Option<&WidgetId>,
bounds: Rectangle,
state: &mut dyn operation::Focusable,
) {
if let Some(id) = id {
self.values.push((id.clone(), bounds, state.is_focused()));
}
}
fn scrollable(
&mut self,
_: Option<&WidgetId>,
bounds: Rectangle,
_: Rectangle,
translation: Vector,
_: &mut dyn operation::Scrollable,
) {
self.scrolls.push((bounds, translation));
}
}
impl Fields {
fn field(&self, id: &WidgetId) -> (Rectangle, bool) {
self.values
.iter()
.find(|(actual, _, _)| actual == id)
.map(|(_, bounds, focused)| (*bounds, *focused))
.expect("Mounted field identity")
}
}
fn view(
app: &App,
scrolled: bool,
nested: bool,
show_review: bool,
) -> Element<'_, Message, Theme, ()> {
let draft = app.draft.as_ref().unwrap();
let form = column![
text_input("Name", &draft.inputs["/name"].raw)
.id("before-reference")
.on_input(|raw| Message::Field("/name".into(), raw)),
adapter::field(draft, app.project(), PATH),
text_input("Description", &draft.inputs["/description"].raw)
.id("after-reference")
.on_input(|raw| Message::Field("/description".into(), raw)),
]
.spacing(9);
let form = trigger::scope(form, draft.reference_session());
let base: Element<'_, Message, Theme, ()> = if scrolled {
container(
scrollable(column![Space::new().height(500), form])
.id("reference-scroll")
.height(180),
)
.width(300)
.into()
} else {
container(form).width(300).height(iced::Fill).into()
};
let base: Element<'_, Message, Theme, ()> = if nested {
scrollable(column![Space::new().height(400), base])
.id("outer-reference-scroll")
.height(180)
.width(300)
.into()
} else {
base
};
let base = if show_review {
let review: Element<'_, Message, Theme, ()> = match app.workflow.as_ref() {
Some(workflow::Workflow::Semantic(state)) => {
match state.reference_form(app.project()) {
Some(draft) => {
let mut content = column![];
if let Some(raw) = &state.operation_omission {
content = content.push(
text_input("Required engineering rationale", raw)
.id("review-rationale")
.on_input(|raw| {
Message::Workflow(workflow::Event::semantic(
state.address_event(RowEvent::OperationRationale(raw)),
))
}),
);
}
content = content.push(trigger::scope(
adapter::field(draft, app.project(), "/parent").map(|message| {
match message {
Message::FieldReferenceOpen(request) => {
Message::Workflow(workflow::Event::semantic(
state.address_event(RowEvent::ReferenceOpen(request)),
))
}
other => other,
}
}),
draft.reference_session(),
));
opaque(container(trigger::modal(content)).width(300).height(180))
}
None => Space::new().into(),
}
}
_ => Space::new().into(),
};
stack![base, review].into()
} else {
base
};
app.reference_overlay(base)
}
struct Mount {
tree: Option<Tree>,
size: Size,
scrolled: bool,
nested: bool,
show_review: bool,
}
impl Default for Mount {
fn default() -> Self {
Self {
tree: None,
size: Size::new(360., 320.),
scrolled: false,
nested: false,
show_review: false,
}
}
}
impl Mount {
fn frame(
&mut self,
app: &App,
events: &[(Input, Option<Point>)],
) -> (Vec<Message>, Fields, Option<Rectangle>) {
let mut view = view(app, self.scrolled, self.nested, self.show_review);
let tree = self.tree.get_or_insert_with(|| Tree::new(view.as_widget()));
view.as_widget().diff(tree);
let node =
view.as_widget_mut()
.layout(tree, &(), &layout::Limits::new(Size::ZERO, self.size));
let viewport = Rectangle::with_size(self.size);
let overlay_node = view
.as_widget_mut()
.overlay(tree, Layout::new(&node), &(), &viewport, Vector::ZERO)
.map(|mut overlay| overlay.as_overlay_mut().layout(&(), self.size));
let mut messages = Vec::new();
for (input, point) in events {
let mut shell = Shell::new(&mut messages);
let cursor = point.map_or(mouse::Cursor::Unavailable, mouse::Cursor::Available);
if let Some(overlay_node) = &overlay_node {
let mut overlay = view
.as_widget_mut()
.overlay(tree, Layout::new(&node), &(), &viewport, Vector::ZERO)
.unwrap();
overlay.as_overlay_mut().update(
input,
Layout::new(overlay_node),
cursor,
&(),
&mut Clipboard,
&mut shell,
);
assert!(shell.is_event_captured());
} else {
view.as_widget_mut().update(
tree,
input,
Layout::new(&node),
cursor,
&(),
&mut Clipboard,
&mut shell,
&viewport,
);
}
}
let mut fields = Fields::default();
view.as_widget_mut()
.operate(tree, Layout::new(&node), &(), &mut fields);
(
messages,
fields,
overlay_node.map(|node| node.children()[0].bounds()),
)
}
fn click(&mut self, app: &App, point: Point) -> Vec<Message> {
self.frame(
app,
&[
(
Input::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
Some(point),
),
(
Input::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)),
Some(point),
),
],
)
.0
}
async fn perform(&mut self, app: &App, task: Task<Message>) {
let Some(mut stream) = iced_runtime::task::into_stream(task) else {
return;
};
while let Some(action) = stream.next().await {
let iced_runtime::Action::Widget(mut operation) = action else {
panic!("Chooser only requests scoped widget focus")
};
let mut view = view(app, self.scrolled, self.nested, self.show_review);
let tree = self.tree.get_or_insert_with(|| Tree::new(view.as_widget()));
view.as_widget().diff(tree);
let node =
view.as_widget_mut()
.layout(tree, &(), &layout::Limits::new(Size::ZERO, self.size));
loop {
view.as_widget_mut()
.operate(tree, Layout::new(&node), &(), operation.as_mut());
match operation.finish() {
operation::Outcome::Chain(next) => operation = next,
_ => break,
}
}
}
}
async fn dispatch(&mut self, app: &mut App, messages: Vec<Message>) {
for message in messages {
let task = app.update(message);
self.perform(app, task).await;
}
}
}
fn key(key: keyboard::key::Named, shift: bool) -> Input {
Input::Keyboard(keyboard::Event::KeyPressed {
key: keyboard::Key::Named(key),
modified_key: keyboard::Key::Named(key),
physical_key: keyboard::key::Physical::Code(keyboard::key::Code::Space),
location: keyboard::Location::Standard,
modifiers: if shift {
keyboard::Modifiers::SHIFT
} else {
keyboard::Modifiers::empty()
},
text: None,
repeat: false,
})
}
fn typing(raw: &str) -> Vec<(Input, Option<Point>)> {
raw.chars()
.map(|character| {
let raw = character.to_string();
(
Input::Keyboard(keyboard::Event::KeyPressed {
key: keyboard::Key::Character(raw.clone().into()),
modified_key: keyboard::Key::Character(raw.clone().into()),
physical_key: keyboard::key::Physical::Code(keyboard::key::Code::KeyA),
location: keyboard::Location::Standard,
modifiers: keyboard::Modifiers::empty(),
text: Some(raw.into()),
repeat: false,
}),
None,
)
})
.collect()
}
#[tokio::test]
async fn mounted_reference_trigger_tabs_to_root_popup_search_escape_and_exact_adjacent_fields() {
let mut app = inspector(catalog(), OWNER);
let accepted = app.document.bundle().clone();
let original = raw(&app);
let mut mount = Mount::default();
let fields = mount.frame(&app, &[]).1;
let messages = mount.click(
&app,
fields.field(&WidgetId::new("before-reference")).0.center(),
);
mount.dispatch(&mut app, messages).await;
let (_, fields, _) = mount.frame(&app, &[(key(keyboard::key::Named::Tab, false), None)]);
let id = trigger::field_id(app.draft.as_ref().unwrap().reference_generation(), PATH);
assert!(
fields.field(&id).1,
"Tab reaches the real reference trigger"
);
let messages = mount
.frame(&app, &[(key(keyboard::key::Named::Enter, false), None)])
.0;
assert_eq!(messages.len(), 1, "One keyboard activation opens one field");
mount.dispatch(&mut app, messages).await;
let popup = mount
.frame(&app, &[])
.2
.expect("App root overlay is mounted");
assert!(
popup.x >= 0.
&& popup.y >= 0.
&& popup.x + popup.width <= 360.
&& popup.y + popup.height <= 320.
);
let mut inputs = typing("@controller_0720");
inputs.push((key(keyboard::key::Named::Escape, false), None));
let messages = mount.frame(&app, &inputs).0;
mount.dispatch(&mut app, messages).await;
let (_, fields, popup) = mount.frame(&app, &[]);
assert!(popup.is_none());
assert!(
fields.field(&id).1,
"Escape returns to its exact reference field"
);
assert_eq!(raw(&app), original);
assert_eq!(app.document.bundle(), &accepted);
for (backwards, expected) in [(false, "after-reference"), (true, "before-reference")] {
let messages = mount.click(&app, fields.field(&id).0.center());
mount.dispatch(&mut app, messages).await;
let messages = mount
.frame(&app, &[(key(keyboard::key::Named::Tab, backwards), None)])
.0;
mount.dispatch(&mut app, messages).await;
let (_, fields, popup) = mount.frame(&app, &[]);
assert!(popup.is_none());
assert!(
fields.field(&WidgetId::new(expected)).1,
"Chooser Tab stays within the originating form"
);
}
assert_eq!(app.document.bundle(), &accepted);
}
#[tokio::test]
async fn mounted_reference_choice_stages_literal_identity_and_delayed_focus_cannot_hit_replacement_field()
{
let mut app = inspector(catalog(), OWNER);
let accepted = app.document.bundle().clone();
let mut mount = Mount::default();
let id = trigger::field_id(app.draft.as_ref().unwrap().reference_generation(), PATH);
let fields = mount.frame(&app, &[]).1;
let messages = mount.click(&app, fields.field(&id).0.center());
mount.dispatch(&mut app, messages).await;
let messages = mount.frame(&app, &typing("@controller_0720")).0;
mount.dispatch(&mut app, messages).await;
assert_eq!(
app.reference_authoring
.opened
.as_ref()
.unwrap()
.chooser
.count(),
1
);
let messages = mount
.frame(&app, &[(key(keyboard::key::Named::Enter, false), None)])
.0;
assert_eq!(messages.len(), 1);
let delayed_focus = app.update(messages.into_iter().next().unwrap());
assert_eq!(
app.draft.as_ref().unwrap().value.pointer(PATH),
Some(&json!(Id::from_u128(BASE + 720)))
);
assert_eq!(app.document.bundle(), &accepted);
// A structural edit occurs after Choose but before its asynchronous focus.
app.draft
.as_mut()
.unwrap()
.shape_scoped(PATH, Value::Null)
.unwrap();
let fields = mount.frame(&app, &[]).1;
let messages = mount.click(
&app,
fields.field(&WidgetId::new("after-reference")).0.center(),
);
mount.dispatch(&mut app, messages).await;
mount.perform(&app, delayed_focus).await;
let fields = mount.frame(&app, &[]).1;
assert!(
fields.field(&WidgetId::new("after-reference")).1,
"A missing old field target makes no focus changes"
);
assert!(
!fields
.field(&trigger::field_id(
app.draft.as_ref().unwrap().reference_generation(),
PATH
))
.1
);
assert_eq!(app.document.bundle(), &accepted);
}
#[tokio::test]
async fn mounted_scrolled_reference_anchor_uses_window_geometry_for_pointer_and_keyboard() {
let mut app = inspector(catalog(), OWNER);
let mut mount = Mount {
scrolled: true,
..Default::default()
};
mount.frame(&app, &[]);
mount
.perform(
&app,
iced::widget::operation::snap_to("reference-scroll", scrollable::RelativeOffset::END),
)
.await;
let fields = mount.frame(&app, &[]).1;
let id = trigger::field_id(app.draft.as_ref().unwrap().reference_generation(), PATH);
let content = fields.field(&id).0;
let (viewport, offset) = fields.scrolls[0];
assert!(offset.y > 400., "Fixture is genuinely scrolled");
let expected = Rectangle {
x: content.x - offset.x,
y: content.y - offset.y,
..content
}
.intersection(&viewport)
.unwrap();
let messages = mount.click(&app, expected.center());
let [Message::FieldReferenceOpen(request)] = messages.as_slice() else {
panic!("Scrolled field should emit one actual Open")
};
assert_eq!(request.anchor, expected);
mount.dispatch(&mut app, messages).await;
let close = app.update(Message::Escape);
mount.perform(&app, close).await;
let messages = mount
.frame(&app, &[(key(keyboard::key::Named::Enter, false), None)])
.0;
let [Message::FieldReferenceOpen(request)] = messages.as_slice() else {
panic!("Focused scrolled field opens without a pointer")
};
assert_eq!(request.anchor, expected);
}
#[tokio::test]
async fn mounted_fresh_tab_reveals_offscreen_reference_and_return_reveals_it_again() {
for nested in [false, true] {
let mut app = inspector(catalog(), OWNER);
let original = raw(&app);
let source = app.document.bundle().clone();
let mut mount = Mount {
scrolled: true,
nested,
..Default::default()
};
let fields = mount.frame(&app, &[]).1;
assert!(
fields
.scrolls
.iter()
.all(|(_, offset)| *offset == Vector::ZERO)
);
assert!(fields.field(&WidgetId::new("before-reference")).0.y > 180.);
let messages = mount
.frame(&app, &[(key(keyboard::key::Named::Tab, false), None)])
.0;
assert!(
messages.is_empty(),
"Keyboard reveal must not author any input"
);
let fields = mount.frame(&app, &[]).1;
assert!(fields.field(&WidgetId::new("before-reference")).1);
assert!(
fields.scrolls.iter().all(|(_, offset)| offset.y > 0.),
"Every enclosing viewport reveals fresh focus"
);
let id = trigger::field_id(app.draft.as_ref().unwrap().reference_generation(), PATH);
let offset: f32 = fields.scrolls.iter().map(|(_, offset)| offset.y).sum();
assert!(
fields.field(&id).0.y - offset >= 180.,
"The next reference starts below the fold"
);
let messages = mount
.frame(&app, &[(key(keyboard::key::Named::Tab, false), None)])
.0;
assert!(messages.is_empty());
let fields = mount.frame(&app, &[]).1;
let offset: f32 = fields.scrolls.iter().map(|(_, offset)| offset.y).sum();
let (bounds, focused) = fields.field(&id);
assert!(focused);
assert!(bounds.y - offset >= -0.01 && bounds.y + bounds.height - offset <= 180.01);
let messages = mount
.frame(&app, &[(key(keyboard::key::Named::Enter, false), None)])
.0;
assert_eq!(
messages.len(),
1,
"A newly revealed field opens through Enter"
);
mount.dispatch(&mut app, messages).await;
let return_focus = app.update(Message::Escape);
mount
.perform(
&app,
iced::widget::operation::snap_to(
"reference-scroll",
scrollable::RelativeOffset::START,
),
)
.await;
if nested {
mount
.perform(
&app,
iced::widget::operation::snap_to(
"outer-reference-scroll",
scrollable::RelativeOffset::START,
),
)
.await;
}
assert!(
mount
.frame(&app, &[])
.1
.scrolls
.iter()
.all(|(_, offset)| *offset == Vector::ZERO)
);
mount.perform(&app, return_focus).await;
let fields = mount.frame(&app, &[]).1;
let offset: f32 = fields.scrolls.iter().map(|(_, offset)| offset.y).sum();
let (bounds, focused) = fields.field(&id);
assert!(
focused && bounds.y - offset >= -0.01 && bounds.y + bounds.height - offset <= 180.01
);
assert_eq!(raw(&app), original);
assert_eq!(app.document.bundle(), &source);
}
}
#[tokio::test]
async fn mounted_form_tab_enters_in_both_directions_and_cycles_boundaries_without_losing_focus() {
for backwards in [false, true] {
let app = inspector(catalog(), OWNER);
let original = raw(&app);
let mut mount = Mount::default();
let reference = trigger::field_id(app.draft.as_ref().unwrap().reference_generation(), PATH);
let forward = [
WidgetId::new("before-reference"),
reference.clone(),
WidgetId::new("after-reference"),
];
let reverse = [
WidgetId::new("after-reference"),
reference,
WidgetId::new("before-reference"),
];
let expected = if backwards { &reverse } else { &forward };
for n in 0..7 {
let (messages, fields, popup) =
mount.frame(&app, &[(key(keyboard::key::Named::Tab, backwards), None)]);
assert!(messages.is_empty() && popup.is_none());
assert!(fields.field(&expected[n % 3]).1);
assert_eq!(
fields
.values
.iter()
.filter(|(_, _, focused)| *focused)
.count(),
1
);
}
assert_eq!(raw(&app), original);
assert!(!app.document.can_undo());
}
}
#[tokio::test]
async fn mounted_delayed_return_cannot_focus_under_review_and_fresh_modal_tab_owns_its_form() {
let mut p = catalog();
trace(&mut p, Id::from_u128(300), OWNER, Id::from_u128(200));
let mut app = inspector(p, OWNER);
let source = app.document.bundle().clone();
let original = raw(&app);
let mut mount = Mount {
show_review: true,
..Default::default()
};
let id = trigger::field_id(app.draft.as_ref().unwrap().reference_generation(), PATH);
let session = app.draft.as_ref().unwrap().reference_session();
let fields = mount.frame(&app, &[]).1;
let messages = mount.click(&app, fields.field(&id).0.center());
mount.dispatch(&mut app, messages).await;
let return_focus = app.update(Message::Escape);
let fields = mount.frame(&app, &[]).1;
let messages = mount.click(
&app,
fields.field(&WidgetId::new("after-reference")).0.center(),
);
mount.dispatch(&mut app, messages).await;
let _ = app.update(Message::StartSemanticReview(Id::from_u128(300)));
let facet = FacetSelector::Header(HeaderFacet::Parent);
send_review(&mut app, RowEvent::Select(facet.clone()));
send_review(
&mut app,
RowEvent::Decide(facet, Some(SemanticDecision::Candidate)),
);
assert_eq!(
app.draft.as_ref().unwrap().reference_session(),
session,
"Old Inspector really remains mounted"
);
assert_eq!(
trigger::field_id(app.draft.as_ref().unwrap().reference_generation(), PATH),
id
);
let review_before = review(&app).recovery_snapshot().unwrap();
let review_field = trigger::field_id(
review(&app)
.reference_form(app.project())
.unwrap()
.reference_generation(),
"/parent",
);
mount.perform(&app, return_focus).await;
let fields = mount.frame(&app, &[]).1;
assert!(
!fields.field(&id).1,
"A live old field underneath a modal cannot receive the stale focus task"
);
assert!(fields.field(&WidgetId::new("after-reference")).1);
assert!(!fields.field(&review_field).1);
let (messages, fields, _) = mount.frame(&app, &[(key(keyboard::key::Named::Tab, false), None)]);
assert!(messages.is_empty());
assert!(
fields.field(&review_field).1,
"Review entry ignores retained focus under its modal"
);
assert!(
!fields.field(&WidgetId::new("after-reference")).1,
"Modal focus acquisition deactivates background inputs"
);
let messages = mount.frame(&app, &typing("x")).0;
assert!(
messages.is_empty(),
"A character at the focused Review trigger cannot type into the Inspector below it"
);
let (messages, fields, _) = mount.frame(&app, &[(key(keyboard::key::Named::Tab, true), None)]);
assert!(messages.is_empty());
assert!(fields.field(&review_field).1);
assert!(!fields.field(&id).1);
assert_eq!(review(&app).recovery_snapshot().unwrap(), review_before);
assert_eq!(raw(&app), original);
assert_eq!(app.document.bundle(), &source);
}
#[tokio::test]
async fn mounted_review_pointer_open_and_outside_dismissal_keep_background_input_inactive() {
let mut p = catalog();
trace(&mut p, Id::from_u128(300), OWNER, Id::from_u128(200));
let mut app = inspector(p, OWNER);
let original = raw(&app);
let source = app.document.bundle().clone();
let mut mount = Mount {
show_review: true,
..Default::default()
};
let fields = mount.frame(&app, &[]).1;
let messages = mount.click(
&app,
fields.field(&WidgetId::new("after-reference")).0.center(),
);
mount.dispatch(&mut app, messages).await;
let _ = app.update(Message::StartSemanticReview(Id::from_u128(300)));
let facet = FacetSelector::Header(HeaderFacet::Parent);
send_review(&mut app, RowEvent::Select(facet.clone()));
send_review(
&mut app,
RowEvent::Decide(facet, Some(SemanticDecision::Candidate)),
);
let saved = review(&app).recovery_snapshot().unwrap();
let id = trigger::field_id(
review(&app)
.reference_form(app.project())
.unwrap()
.reference_generation(),
"/parent",
);
let fields = mount.frame(&app, &[]).1;
assert!(
fields.field(&WidgetId::new("after-reference")).1,
"Fixture retains Inspector focus before the modal receives input"
);
let messages = mount.click(&app, fields.field(&id).0.center());
mount.dispatch(&mut app, messages).await;
assert!(matches!(
&app.reference_authoring.opened.as_ref().unwrap().host,
Host::Review { .. }
));
let fields = mount.frame(&app, &[]).1;
assert!(!fields.field(&WidgetId::new("after-reference")).1);
let messages = mount.click(&app, Point::new(359., 319.));
assert!(matches!(
messages.as_slice(),
[Message::Workflow(workflow::Event::Semantic(event))] if matches!(event.as_ref(),
semantic_review::Event::Row {
event: RowEvent::ReferenceEvent(chooser::Event {
action: Action::Close(Close::Outside),
..
}),
..
}
)
));
for message in messages {
let task = app.update(message);
assert!(
iced_runtime::task::into_stream(task).is_none(),
"Outside dismissal must not request focus or apply"
);
}
let (messages, fields, popup) = mount.frame(&app, &typing("x"));
assert!(messages.is_empty() && popup.is_none());
assert!(!fields.field(&WidgetId::new("after-reference")).1);
assert_eq!(review(&app).recovery_snapshot().unwrap(), saved);
assert_eq!(raw(&app), original);
assert_eq!(app.document.bundle(), &source);
}
#[tokio::test]
async fn mounted_reverse_review_required_rationale_remains_editable_beside_candidate_reference_form()
{
let mut p = persistence::decode(include_bytes!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../harness/fixtures/interface-operation-probe/output/boundary-only.json"
)))
.unwrap();
let sa = p
.elements
.values()
.find(|element| element.alias == "operation_probe_43")
.unwrap()
.id;
let allocation = p
.relations
.values()
.find(|relation| relation.alias == "operation_probe_30")
.unwrap()
.id;
// The boundary fixture deliberately has no selected operation. Author one
// through the validated command path before testing reverse omission.
let mut message = p.elements[&sa].clone();
let Some(SharedBody::Occurrence {
body: OccurrenceBody::Exchange { operation, .. },
..
}) = message.shared_mut()
else {
panic!("The operation fixture must contain an Exchange call")
};
*operation = Some(OperationUse {
allocation: Some(allocation),
});
p = execute_batch(&p, &[Command::EditElement(sa, message)]).unwrap();
assert!(matches!(
oa_core::operations::resolve(&p, sa).unwrap(),
oa_core::operations::OperationResolution::Selected(value)
if value.allocation == allocation
));
let inspector_id = Id::new_v4();
element(
&mut p,
inspector_id,
Kind::DataType,
Layer::Oa,
"Inspector record",
None,
);
let scenario = p
.add_element(Kind::Scenario, Layer::Oa, "Operational counterpart", None)
.unwrap();
let oa = p
.add_element(
Kind::Occurrence,
Layer::Oa,
"Operational message",
Some(scenario),
)
.unwrap();
*p.elements.get_mut(&oa).unwrap().shared_mut().unwrap() = SharedBody::Occurrence {
body: OccurrenceBody::Exchange {
exchange: None,
sender: None,
receiver: None,
message_kind: MessageKind::Asynchronous,
reply_to: None,
values: vec![],
operation: None,
},
precondition: String::new(),
postcondition: String::new(),
};
p.elements
.get_mut(&scenario)
.unwrap()
.steps_mut()
.unwrap()
.push(oa);
let trace_id = Id::new_v4();
trace(&mut p, trace_id, oa, sa);
let mut app = inspector(p, inspector_id);
let source = app.document.bundle().clone();
let inspector_raw = raw(&app);
let _ = app.update(Message::StartSemanticReview(trace_id));
let _ = app.update(Message::Workflow(workflow::Event::semantic(
semantic_review::Event::Direction(TransitionDirection::SaToOa),
)));
let facet = FacetSelector::Header(HeaderFacet::Parent);
send_review(&mut app, RowEvent::Select(facet.clone()));
send_review(
&mut app,
RowEvent::Decide(facet, Some(SemanticDecision::Candidate)),
);
send_review(&mut app, RowEvent::OperationOmission(true));
assert_eq!(review(&app).direction, TransitionDirection::SaToOa);
assert_eq!(review(&app).operation_omission.as_deref(), Some(""));
let candidate = serde_json::to_value(
review(&app)
.reference_form(app.project())
.unwrap_or_else(|| {
panic!(
"Reverse review candidate: selected={:?}, decisions={:?}, error={:?}",
review(&app).selected,
review(&app).decisions,
review(&app).error
)
})
.recovery_snapshot(),
)
.unwrap();
let mut mount = Mount {
show_review: true,
..Default::default()
};
let fields = mount.frame(&app, &[]).1;
let messages = mount.click(
&app,
fields.field(&WidgetId::new("review-rationale")).0.center(),
);
mount.dispatch(&mut app, messages).await;
let messages = mount.frame(&app, &typing("Engineering reason")).0;
assert_eq!(
messages.len(),
"Engineering reason".chars().count(),
"Every key reaches the legitimate sibling inside Review"
);
assert!(messages.iter().all(|message| matches!(
message,
Message::Workflow(workflow::Event::Semantic(event)) if matches!(event.as_ref(), semantic_review::Event::Row {
event: RowEvent::OperationRationale(_),
..
})
)));
mount.dispatch(&mut app, messages).await;
assert_eq!(
review(&app).operation_omission.as_deref(),
Some("Engineering reason")
);
assert!(
mount
.frame(&app, &[])
.1
.field(&WidgetId::new("review-rationale"))
.1
);
assert_eq!(
serde_json::to_value(
review(&app)
.reference_form(app.project())
.unwrap()
.recovery_snapshot()
)
.unwrap(),
candidate
);
assert_eq!(raw(&app), inspector_raw);
assert_eq!(app.document.bundle(), &source);
assert!(!app.document.can_undo());
}
@@ -0,0 +1,263 @@
use super::*;
const VALID: &str = concat!(
"arcadia 7;\r\n",
"project repair \"Repair 救援\" id \"9e8f0000-0000-4000-8000-000000000001\" {\r\n",
" oa_activity assess \"Assess\" id \"9e8f0000-0000-4000-8000-000000000002\" {}\r\n",
"}\r\n",
);
fn open(app: &mut App, path: &std::path::Path) {
let _ = app.update(Message::Loaded(Ok(Some(
read_snapshot(path.to_owned()).unwrap(),
))));
}
fn edit(app: &mut App, text: &str) {
let _ = app.update(Message::SourceEdit(text_editor::Action::SelectAll));
let _ = app.update(Message::SourceEdit(text_editor::Action::Edit(
text_editor::Edit::Paste(Arc::new(text.into())),
)));
}
fn save(app: &mut App, path: &std::path::Path) {
let receipt = write_snapshot(
app.canvas_document,
app.document.revision(),
app.document.snapshot(),
path.to_owned(),
app.disk_project.clone(),
)
.unwrap();
let _ = app.update(Message::Saved(Ok(Some(receipt))));
}
#[test]
fn repair_to_previous_saved_bytes_requires_save_at_new_destination_and_guards_close() {
for extra in [
" include \"missing.arcadia\";\r\n",
" oa_activity assess \"Duplicate\" id \"9e8f0000-0000-4000-8000-000000000003\" {}\r\n",
] {
let first = tempfile::tempdir().unwrap();
let second = tempfile::tempdir().unwrap();
let first_path = first.path().canonicalize().unwrap().join("project.arcadia");
let second_path = second
.path()
.canonicalize()
.unwrap()
.join("project.arcadia");
let invalid = VALID.strip_suffix("}\r\n").unwrap().to_owned() + extra + "}\r\n";
std::fs::write(&first_path, VALID).unwrap();
std::fs::write(&second_path, &invalid).unwrap();
let mut app = App::with_project(Project::empty());
open(&mut app, &first_path);
assert!(!app.dirty());
let before = app.document.snapshot();
let revision = app.document.revision();
open(&mut app, &second_path);
assert!(!app.source_workspace.applied);
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
edit(&mut app, VALID);
assert!(app.apply_source(), "{}", app.status);
assert!(app.source_workspace.applied);
assert!(!app.source_dirty());
assert_eq!(app.document.bundle(), &before.bundle);
assert_eq!(app.project(), &before.decoded.project);
assert_eq!(app.document.revision(), revision);
assert!(
!app.document.can_undo(),
"Equal content creates no history step"
);
assert!(app.document.dirty());
assert_eq!(app.hosted_label(), "Unsaved changes");
assert_eq!(std::fs::read(&second_path).unwrap(), invalid.as_bytes());
assert_eq!(std::fs::read(&first_path).unwrap(), VALID.as_bytes());
let close = window::Id::unique();
let task = app.update(Message::Request(Pending::Close(close)));
assert!(iced_runtime::task::into_stream(task).is_none());
assert!(matches!(app.pending, Some(Pending::Close(id)) if id == close));
let _ = app.update(Message::Cancel);
save(&mut app, &second_path);
assert!(!app.dirty());
assert_eq!(app.hosted_label(), "All changes saved");
assert_eq!(std::fs::read(&second_path).unwrap(), VALID.as_bytes());
assert_eq!(std::fs::read(&first_path).unwrap(), VALID.as_bytes());
}
}
#[test]
fn repair_history_compares_to_new_disk_baseline_and_then_successful_save() {
let directory = tempfile::tempdir().unwrap();
let path = directory
.path()
.canonicalize()
.unwrap()
.join("project.arcadia");
std::fs::write(&path, VALID).unwrap();
let mut app = App::with_project(Project::empty());
open(&mut app, &path);
let before = app.document.snapshot();
let invalid = format!("{VALID} unfinished {{");
std::fs::write(&path, &invalid).unwrap();
open(&mut app, &path);
let repaired = format!("{VALID}// Repaired rationale\r\n");
edit(&mut app, &repaired);
assert!(app.apply_source());
assert!(app.dirty());
let _ = app.update(Message::Undo);
assert_eq!(app.document.bundle(), &before.bundle);
assert!(
app.dirty(),
"Old saved bytes are not saved at the repair destination"
);
let _ = app.update(Message::Redo);
assert!(app.dirty());
assert_eq!(std::fs::read(&path).unwrap(), invalid.as_bytes());
std::fs::write(&path, "external edit").unwrap();
let failed = write_snapshot(
app.canvas_document,
app.document.revision(),
app.document.snapshot(),
path.clone(),
app.disk_project.clone(),
)
.unwrap_err();
let _ = app.update(Message::Saved(Err(failed)));
assert!(app.dirty());
assert_eq!(std::fs::read(&path).unwrap(), b"external edit");
std::fs::write(&path, &invalid).unwrap();
save(&mut app, &path);
assert!(!app.dirty());
let _ = app.update(Message::Undo);
assert!(app.dirty());
assert_eq!(app.document.bundle(), &before.bundle);
let _ = app.update(Message::Redo);
assert!(!app.dirty(), "Redo to the new saved snapshot is clean");
assert_eq!(std::fs::read(&path).unwrap(), repaired.as_bytes());
}
#[test]
fn valid_equal_content_at_distinct_destinations_stays_clean() {
let first = tempfile::tempdir().unwrap();
let second = tempfile::tempdir().unwrap();
let first_path = first.path().canonicalize().unwrap().join("project.arcadia");
let second_path = second
.path()
.canonicalize()
.unwrap()
.join("project.arcadia");
std::fs::write(&first_path, VALID).unwrap();
std::fs::write(&second_path, VALID).unwrap();
let mut app = App::with_project(Project::empty());
open(&mut app, &first_path);
let before = app.document.bundle().clone();
open(&mut app, &second_path);
assert_eq!(app.document.bundle(), &before);
assert_eq!(app.path.as_ref(), Some(&second_path));
assert!(!app.dirty());
assert_eq!(app.hosted_label(), "All changes saved");
}
#[test]
fn recovering_applied_repair_rebinds_rolled_back_disk_bytes_without_changing_model() {
let first = tempfile::tempdir().unwrap();
let second = tempfile::tempdir().unwrap();
let first_path = first.path().canonicalize().unwrap().join("project.arcadia");
let root = second.path().canonicalize().unwrap();
let path = root.join("project.arcadia");
let before = VALID.replace("Repair 救援", "Before 救援");
std::fs::write(&first_path, VALID).unwrap();
std::fs::write(&path, VALID).unwrap();
let journal = root.join(".arcadia-save-project.arcadia.journal");
std::fs::create_dir(&journal).unwrap();
std::fs::write(
journal.join("journal.json"),
br#"{"version":1,"entry":"project.arcadia","files":[{"path":"project.arcadia","before":true,"write":true}]}"#,
)
.unwrap();
std::fs::write(journal.join("0.before"), &before).unwrap();
std::fs::write(journal.join("0.after"), VALID).unwrap();
let mut app = App::with_project(Project::empty());
open(&mut app, &first_path);
let accepted = app.document.snapshot();
open(&mut app, &path);
assert_eq!(app.pending_recovery.as_ref(), Some(&path));
assert!(!app.source_workspace.applied);
assert!(app.apply_source());
assert!(
!app.dirty(),
"Current bytes still match disk before rollback"
);
let _ = app.update(Message::SourceRecovered(recover_snapshot(path.clone())));
assert!(app.pending_recovery.is_none());
assert!(!journal.exists());
assert_eq!(std::fs::read(&path).unwrap(), before.as_bytes());
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
assert!(!app.source_dirty());
assert!(app.dirty(), "Recovery changed disk, not the accepted model");
assert_eq!(app.hosted_label(), "Unsaved changes");
save(&mut app, &path);
assert!(!app.dirty());
assert_eq!(std::fs::read(&path).unwrap(), VALID.as_bytes());
}
#[test]
fn recovery_missing_entry_or_failed_reload_preserves_current_destination_save_authority() {
for (current_destination, failed_reload) in
[(true, false), (false, false), (true, true), (false, true)]
{
let directory = tempfile::tempdir().unwrap();
let root = directory.path().canonicalize().unwrap();
let path = root.join("project.arcadia");
std::fs::write(&path, VALID).unwrap();
let mut app = App::with_project(Project::empty());
open(&mut app, &path);
let accepted = app.document.snapshot();
let journal = root.join(".arcadia-save-project.arcadia.journal");
std::fs::create_dir(&journal).unwrap();
std::fs::write(
journal.join("journal.json"),
br#"{"version":1,"entry":"project.arcadia","files":[{"path":"project.arcadia","before":false,"write":true}]}"#,
)
.unwrap();
std::fs::write(journal.join("0.after"), VALID).unwrap();
open(&mut app, &path);
assert!(app.apply_source());
assert!(!app.dirty());
let destination = tempfile::tempdir().unwrap();
let saved_elsewhere = destination
.path()
.canonicalize()
.unwrap()
.join("project.arcadia");
if !current_destination {
save(&mut app, &saved_elsewhere);
assert_eq!(app.path.as_ref(), Some(&saved_elsewhere));
assert_eq!(app.pending_recovery.as_ref(), Some(&path));
}
let mut receipt = recover_snapshot(path.clone()).unwrap();
assert!(
receipt.disk.as_ref().unwrap().bundle.files.is_empty(),
"Missing entry reloads as an empty repair workspace"
);
if failed_reload {
// Separately exercise the adapter's successful-recovery/failed-read
// boundary; first-save rollback itself does not cause a read error.
receipt.disk = Err("Injected post-recovery reload failure".into());
}
let _ = app.update(Message::SourceRecovered(Ok(receipt)));
assert!(!path.exists());
assert!(!journal.exists());
assert!(app.pending_recovery.is_none());
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
assert_eq!(app.dirty(), current_destination);
if current_destination {
let _ = app.update(Message::Request(Pending::New));
assert!(matches!(app.pending, Some(Pending::New)));
} else {
assert_eq!(app.path.as_ref(), Some(&saved_elsewhere));
assert_eq!(std::fs::read(&saved_elsewhere).unwrap(), VALID.as_bytes());
}
}
}
@@ -16,7 +16,7 @@ fn state(app: &App) -> &State {
}
fn row_event(app: &mut App, event: RowEvent) {
let event = state(app).address_event(event);
send(app, Message::Workflow(Event::Semantic(event)));
send(app, Message::Workflow(Event::semantic(event)));
}
fn fixture(reverse: bool, destination_view: bool) -> (App, Id, Id, Id, Option<Id>) {
let mut p = Project::empty();
@@ -0,0 +1,281 @@
//! Actual arrangement selection and current-record Details admission.
use super::*;
use crate::behavior::canvas_workspace::tests::Mounted;
use crate::behavior::scenario::{CanvasEvent, Context, Shelf};
use crate::behavior::scenario_layout::Representation;
use iced::{Point, Size, advanced::widget};
const FIXTURE: &str = include_str!("diagram_usages/tests/saved_scenario.arcadia");
fn id(app: &App, alias: &str) -> Id {
app.project()
.identities()
.into_iter()
.find(|(_, name)| *name == alias)
.unwrap()
.0
}
fn setup() -> (App, Id, Id, Id) {
let mut app = App::with_project(oa_core::language::parse(FIXTURE).unwrap());
let a = id(&app, "incident_readable");
let b = id(&app, "incident_wide");
let owner = id(&app, "incident_response");
let _ = app.update(Message::Select(owner));
let _ = app.update(Message::Page(Page::Behavior));
choose(&mut app, Representation::Saved { diagram: a });
let _ = app.update(Message::Select(a));
app.window_size = Size::new(1440., 1000.);
(app, a, b, owner)
}
fn send(app: &mut App, event: impl FnOnce(Context) -> CanvasEvent) {
let context = app.scenario_context().unwrap();
let _ = app.update(Message::ScenarioCanvas(event(context)));
}
fn choose(app: &mut App, representation: Representation) {
send(app, |context| {
CanvasEvent::Representation(context, representation)
});
}
fn model_state(app: &App) -> (u64, bool, bool, bool) {
(
app.document.revision(),
app.document.dirty(),
app.document.can_undo(),
app.document.can_redo(),
)
}
#[tokio::test]
async fn actual_arrangement_picker_follows_selected_saved_record_and_opens_b_properties() {
let (mut app, a, b, _) = setup();
let mut mounted = Mounted::new(&mut app).await;
let snapshot = app.document.snapshot();
let before = model_state(&app);
let captured = app.scenario_context().unwrap();
let picker = mounted.geometry(&app).bounds("scenario-arrangement-picker");
assert!(
app.window_size.height - (picker.y + picker.height) > picker.y,
"fixture has room for Iced's menu below the actual picker"
);
assert_eq!(app.selected, a);
mounted.click(&mut app, picker.center()).await;
// iced_widget 0.14.2 overlay/menu.rs uses default line height plus
// vertical padding for each row. The real workspace orders Automatic,
// then the fixture's A/B/C Diagram IDs. Select B from the actual overlay.
let row_height =
f32::from(iced::advanced::text::LineHeight::default().to_absolute(12.into())) + 10.;
let b_row = Point::new(
picker.center_x(),
picker.y + picker.height + row_height * 2.5,
);
assert!(b_row.y < app.window_size.height);
let events = mounted.click(&mut app, b_row).await;
assert!(events.iter().any(|event| matches!(event,
Message::ScenarioCanvas(CanvasEvent::Representation(context, Representation::Saved { diagram }))
if *context == captured && *diagram == b)), "must exercise the actual arrangement callback");
assert_eq!(app.selected, b);
assert_eq!(app.draft.as_ref().unwrap().original, b);
assert_eq!(
app.draft.as_ref().unwrap().value["name"],
"B - Incident review negative and far"
);
assert_eq!(app.active_diagram, Some(b));
assert_eq!(
app.scenario_context().unwrap().representation,
Representation::Saved { diagram: b }
);
assert_ne!(app.scenario_context().unwrap().visit, captured.visit);
send(&mut app, CanvasEvent::Focus);
send(&mut app, |context| {
CanvasEvent::Shelf(context, Shelf::Selection)
});
mounted.settle(&mut app).await;
let geometry = mounted.geometry(&app);
let (details, offset) = geometry.scrolls[&widget::Id::new("scenario-details")];
let point = geometry.bounds("scenario-properties").center() - offset;
assert!(details.contains(point));
let events = mounted.click(&mut app, point).await;
assert!(events.iter().any(|event| matches!(event,
Message::ScenarioCanvas(CanvasEvent::Properties(_, selected)) if *selected == b)));
mounted.settle(&mut app).await;
let geometry = mounted.geometry(&app);
let (inspector, offset) = geometry.scrolls[&widget::Id::new("inspector")];
assert!(
geometry
.text_inputs
.iter()
.any(|bounds| inspector.contains(bounds.center() - offset)),
"current B Properties must expose an actual editable field"
);
assert!(!app.behavior.scenario.focused());
assert!(
!geometry
.scrolls
.contains_key(&widget::Id::new("scenario-details"))
);
assert_eq!(
app.inspector_tab,
crate::navigation::InspectorTab::Properties
);
assert_eq!(app.selected, b);
assert_eq!(app.draft.as_ref().unwrap().original, b);
assert!(Arc::ptr_eq(&snapshot, &app.document.snapshot()));
assert_eq!(app.project(), &snapshot.decoded.project);
assert_eq!(app.document.bundle(), &snapshot.bundle);
assert_eq!(model_state(&app), before);
}
#[test]
fn arrangement_change_preserves_independent_semantic_and_foreign_view_selection() {
for alias in [
"initial_report",
"reporter",
"incident_response",
"incident_repair",
] {
let (mut app, _, b, _) = setup();
let selected = id(&app, alias);
let _ = app.update(Message::Select(selected));
let snapshot = app.document.snapshot();
let before = model_state(&app);
choose(&mut app, Representation::Saved { diagram: b });
assert_eq!(app.selected, selected, "independent selection {alias}");
assert_eq!(app.draft.as_ref().unwrap().original, selected);
assert_eq!(app.active_diagram, Some(b));
assert!(Arc::ptr_eq(&snapshot, &app.document.snapshot()));
assert_eq!(app.document.bundle(), &snapshot.bundle);
assert_eq!(model_state(&app), before);
if alias == "incident_repair" {
assert!(!behavior::scenario::properties_target(
app.project(),
app.scenario_context().unwrap(),
selected
));
}
}
}
#[test]
fn arrangement_follows_view_record_to_automatic_owner_but_not_owner_back_to_saved() {
let (mut app, _, b, owner) = setup();
let snapshot = app.document.snapshot();
let before = model_state(&app);
choose(&mut app, Representation::Derived { owner });
assert_eq!(app.selected, owner);
assert_eq!(app.draft.as_ref().unwrap().original, owner);
assert_eq!(app.active_diagram, None);
assert!(behavior::scenario::properties_target(
app.project(),
app.scenario_context().unwrap(),
owner
));
choose(&mut app, Representation::Saved { diagram: b });
assert_eq!(
app.selected, owner,
"Scenario owner is now independent semantic selection"
);
assert_eq!(app.draft.as_ref().unwrap().original, owner);
assert_eq!(app.active_diagram, Some(b));
assert!(Arc::ptr_eq(&snapshot, &app.document.snapshot()));
assert_eq!(app.document.bundle(), &snapshot.bundle);
assert_eq!(model_state(&app), before);
}
#[test]
fn arrangement_applies_only_the_old_view_draft_then_follows_b_with_one_undo() {
let (mut app, a, b, _) = setup();
let snapshot = app.document.snapshot();
let mut expected = app.project().clone();
expected.diagrams.get_mut(&a).unwrap().name = "Reviewed A".into();
let revision = app.document.revision();
let _ = app.update(Message::Field("/name".into(), "Reviewed A".into()));
choose(&mut app, Representation::Saved { diagram: b });
assert_eq!(app.project(), &expected);
assert_eq!(app.selected, b);
assert_eq!(app.draft.as_ref().unwrap().original, b);
assert!(!app.draft_dirty());
assert_eq!(app.document.revision(), revision + 1);
let accepted = app.document.snapshot();
let _ = app.update(Message::Undo);
assert_eq!(app.project(), &snapshot.decoded.project);
assert_eq!(app.document.bundle(), &snapshot.bundle);
assert!(!app.document.can_undo() && app.document.can_redo());
let _ = app.update(Message::Redo);
assert_eq!(app.project(), &expected);
assert_eq!(app.document.bundle(), &accepted.bundle);
assert!(app.document.can_undo() && !app.document.can_redo());
}
#[test]
fn same_arrangement_uuid_edit_follows_the_reidentified_record_and_round_trips_source() {
let (mut app, a, _, _) = setup();
let snapshot = app.document.snapshot();
let replacement = Id::parse_str("70200000-0000-4000-8000-000000000099").unwrap();
let mut expected = app.project().clone();
let mut diagram = expected.diagrams.remove(&a).unwrap();
diagram.id = replacement;
expected.diagrams.insert(replacement, diagram);
let _ = app.update(Message::Field("/id".into(), replacement.to_string()));
choose(&mut app, Representation::Saved { diagram: a });
assert_eq!(app.project(), &expected);
assert_eq!(app.selected, replacement);
assert_eq!(app.draft.as_ref().unwrap().original, replacement);
assert_eq!(app.active_diagram, Some(replacement));
assert_eq!(
app.scenario_context().unwrap().representation,
Representation::Saved {
diagram: replacement
}
);
let accepted = app.document.snapshot();
let _ = app.update(Message::Undo);
assert_eq!(app.project(), &snapshot.decoded.project);
assert_eq!(app.document.bundle(), &snapshot.bundle);
assert!(!app.document.can_undo() && app.document.can_redo());
let _ = app.update(Message::Redo);
assert_eq!(app.project(), &expected);
assert_eq!(app.document.bundle(), &accepted.bundle);
assert!(!app.document.can_redo());
}
#[test]
fn invalid_draft_and_stale_arrangement_preserve_selection_inputs_and_accepted_state() {
let (mut app, a, b, _) = setup();
let old = app.scenario_context().unwrap();
let snapshot = app.document.snapshot();
let before = model_state(&app);
let _ = app.update(Message::Field("/id".into(), "not-a-uuid".into()));
let draft_value = app.draft.as_ref().unwrap().value.clone();
choose(&mut app, Representation::Saved { diagram: b });
assert_eq!(app.selected, a);
assert_eq!(app.active_diagram, Some(a));
assert_eq!(
app.scenario_context().unwrap().representation,
old.representation
);
assert_eq!(app.draft.as_ref().unwrap().value, draft_value);
assert_eq!(app.draft.as_ref().unwrap().inputs["/id"].raw, "not-a-uuid");
assert!(app.draft_dirty());
assert!(Arc::ptr_eq(&snapshot, &app.document.snapshot()));
assert_eq!(model_state(&app), before);
let _ = app.update(Message::RevertProperties);
choose(&mut app, Representation::Saved { diagram: b });
let current = app.scenario_context().unwrap();
let _ = app.update(Message::Field("/name".into(), "Retained B input".into()));
let selected = app.selected;
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Representation(
old,
Representation::Saved { diagram: a },
)));
assert_eq!(app.selected, selected);
assert_eq!(app.active_diagram, Some(b));
assert_eq!(app.scenario_context().unwrap().visit, current.visit);
assert_eq!(
app.draft.as_ref().unwrap().inputs["/name"].raw,
"Retained B input"
);
assert!(Arc::ptr_eq(&snapshot, &app.document.snapshot()));
assert_eq!(app.document.bundle(), &snapshot.bundle);
assert_eq!(model_state(&app), before);
}
@@ -0,0 +1,96 @@
//! Saved Scenario navigation is presentation state, never a document edit.
use super::*;
use crate::behavior::scenario_layout::{Representation, presentation::checked_owner};
impl App {
pub(super) fn enter_scenario_representation(&mut self, representation: Representation) -> bool {
let Some(owner) = checked_owner(self.project(), representation) else {
self.status =
"This Scenario view is no longer available or has an incompatible owner.".into();
return false;
};
self.layer = self.project().elements[&owner].layer();
self.page = Page::Behavior;
self.behavior.active_owner = Some(owner);
match representation {
Representation::Saved { diagram } => self.active_diagram = Some(diagram),
Representation::Derived { .. } => {
if self
.active_diagram
.is_some_and(|id| self.saved_scenario_diagram(id))
{
self.active_diagram = None;
}
}
}
self.selected_node = None;
if self
.behavior
.scenario
.enter_representation(self.canvas_document, owner, representation)
{
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
}
true
}
/// Reconcile after navigation/adoption without selecting or applying a draft.
/// A retained invalid Saved identity remains unavailable; it must not silently
/// turn into a different automatic Scenario or another saved arrangement.
pub(super) fn reconcile_scenario_workspace(&mut self) {
if self.page == Page::Diagram
&& !self.behavior.has_pending()
&& let Some(diagram) = self
.active_diagram
.filter(|id| self.saved_scenario_diagram(*id))
{
self.enter_scenario_representation(Representation::Saved { diagram });
}
if self.page != Page::Behavior {
self.behavior.scenario.leave();
return;
}
if let Some(rep @ Representation::Saved { diagram }) =
self.behavior.scenario.representation()
{
// The requested view is authoritative until explicit navigation
// retires it. This also recovers the same view when Undo restores it.
if let Some(owner) = checked_owner(self.project(), rep) {
self.active_diagram = Some(diagram);
self.layer = self.project().elements[&owner].layer();
self.behavior.active_owner = Some(owner);
self.behavior
.scenario
.enter_representation(self.canvas_document, owner, rep);
} else if let Some(owner) = self.behavior.scenario.owner() {
self.behavior
.scenario
.enter_representation(self.canvas_document, owner, rep);
}
return;
}
let Some(owner) = behavior::selected_owner(self) else {
self.behavior.scenario.leave();
return;
};
if self
.project()
.elements
.get(&owner)
.is_some_and(|e| e.kind() == Kind::Scenario)
{
self.behavior.scenario.enter(self.canvas_document, owner);
} else {
self.behavior.scenario.leave();
}
}
}
#[cfg(test)]
mod tests;
#[cfg(test)]
mod adoption_tests;
#[cfg(test)]
mod owner_navigation_tests;
@@ -0,0 +1,264 @@
//! Independent adoption oracles: expected Projects are edited directly in the
//! fixture, then admitted through the actual source editor/Document transaction.
//! Navigation is checked separately from the expected authored mutation.
use super::*;
use crate::behavior::canvas_workspace::tests::Mounted;
use iced::advanced::widget;
use iced::widget::text_editor;
use oa_core::language;
use std::sync::Arc;
struct Fixture {
app: App,
owner: Id,
same_layer: Id,
other_layer: Id,
view: Id,
spare_view: Id,
}
fn fixture() -> Fixture {
let mut project = Project::empty();
let owner = project
.add_element(Kind::Scenario, Layer::Oa, "Original owner", None)
.unwrap();
let same_layer = project
.add_element(Kind::Scenario, Layer::Oa, "Other OA owner", None)
.unwrap();
let other_layer = project
.add_element(Kind::Scenario, Layer::Sa, "SA owner", None)
.unwrap();
// A remaining topology makes silent fallback observably different from the
// required unavailable Saved view after its owner is deleted.
project
.add_element(Kind::Process, Layer::Oa, "Remaining process", None)
.unwrap();
let mut make_view = |alias: &str, name: &str, owner| {
let id = Id::new_v4();
project.diagrams.insert(
id,
Diagram {
id,
alias: alias.into(),
name: name.into(),
description: String::new(),
layer: Layer::Oa,
projection: ProjectionKind::Scenario { scenario: owner },
layout: None,
nodes: Default::default(),
routes: vec![],
view_options: Default::default(),
},
);
id
};
let view = make_view("retained_view", "Retained arrangement", owner);
let spare_view = make_view("spare_view", "Spare arrangement", same_layer);
let mut app = App::with_project(project);
let _ = app.update(Message::Select(owner));
choose(&mut app, view);
assert_saved(&app, view, owner, Layer::Oa);
assert!(!app.document.can_undo());
Fixture {
app,
owner,
same_layer,
other_layer,
view,
spare_view,
}
}
fn choose(app: &mut App, id: Id) {
let _ = app.update(Message::Diagram(Choice {
id,
label: "Previously captured picker item".into(),
}));
}
fn assert_saved(app: &App, view: Id, owner: Id, layer: Layer) {
assert_eq!(app.page, Page::Behavior);
assert_eq!(app.layer, layer);
assert_eq!(app.active_diagram, Some(view));
let context = app
.scenario_context()
.expect("saved owner remains the visible context");
assert_eq!(context.scenario, owner);
assert_eq!(
context.representation,
Representation::Saved { diagram: view }
);
assert!(!context.visit.is_nil());
}
fn adopt_from_source(app: &mut App, expected: &Project) {
let text = language::format(expected);
// Parser equality is a fixture precondition, not the navigation oracle.
assert_eq!(language::parse(&text).unwrap(), *expected);
let _ = app.update(Message::Page(Page::Text));
let _ = app.update(Message::SourceEdit(text_editor::Action::SelectAll));
let _ = app.update(Message::SourceEdit(text_editor::Action::Edit(
text_editor::Edit::Paste(Arc::new(text)),
)));
assert!(app.source_dirty());
let _ = app.update(Message::ApplySource);
assert!(!app.source_dirty(), "{}", app.status);
assert_eq!(
app.project(),
expected,
"actual source transaction must adopt exactly the fixture change"
);
let _ = app.update(Message::Page(Page::Behavior));
}
fn retarget(cross_layer: bool) {
let mut f = fixture();
let before = f.app.document.snapshot();
let old_context = f.app.scenario_context().unwrap();
let revision = f.app.document.revision();
let target = if cross_layer {
f.other_layer
} else {
f.same_layer
};
let layer = if cross_layer { Layer::Sa } else { Layer::Oa };
let mut expected = before.decoded.project.clone();
let view = expected.diagrams.get_mut(&f.view).unwrap();
view.projection = ProjectionKind::Scenario { scenario: target };
view.layer = layer;
adopt_from_source(&mut f.app, &expected);
assert_saved(&f.app, f.view, target, layer);
assert_ne!(f.app.scenario_context().unwrap().visit, old_context.visit);
assert_eq!(
f.app.document.revision(),
revision + 1,
"routing must not create a second transaction"
);
assert_eq!(
f.app.project().diagrams[&f.spare_view],
before.decoded.project.diagrams[&f.spare_view]
);
let adopted_bundle = f.app.document.bundle().clone();
let _ = f.app.update(Message::Undo);
assert_saved(&f.app, f.view, f.owner, Layer::Oa);
assert_eq!(f.app.project(), &before.decoded.project);
assert_eq!(f.app.document.bundle(), &before.bundle);
assert!(
!f.app.document.can_undo(),
"exactly one authored adoption must be undone"
);
let _ = f.app.update(Message::Redo);
assert_saved(&f.app, f.view, target, layer);
assert_eq!(f.app.project(), &expected);
assert_eq!(f.app.document.bundle(), &adopted_bundle);
assert!(!f.app.document.can_redo());
}
#[test]
fn source_same_layer_owner_retarget_undo_redo_keeps_requested_saved_uuid() {
retarget(false);
}
#[test]
fn source_cross_layer_owner_retarget_undo_redo_keeps_requested_saved_uuid() {
retarget(true);
}
#[tokio::test]
async fn source_deleted_owner_and_saved_view_mount_unavailable_instead_of_another_behavior() {
let mut f = fixture();
let before = f.app.document.snapshot();
let mut expected = before.decoded.project.clone();
assert!(expected.elements.remove(&f.owner).is_some());
assert!(expected.diagrams.remove(&f.view).is_some());
assert!(expected.elements.contains_key(&f.same_layer));
assert!(
expected
.elements
.values()
.any(|e| e.kind() == Kind::Process)
);
adopt_from_source(&mut f.app, &expected);
assert_eq!(
f.app.behavior.scenario.representation(),
Some(Representation::Saved { diagram: f.view })
);
let adopted = f.app.document.snapshot();
let revision = f.app.document.revision();
let mut mounted = Mounted::new(&mut f.app).await;
let geometry = mounted.geometry(&f.app);
assert!(
geometry
.containers
.contains_key(&widget::Id::from("scenario-viewport-unavailable"))
);
assert!(
!geometry
.containers
.contains_key(&widget::Id::from("scenario-viewport"))
);
assert_eq!(f.app.project(), &expected);
assert_eq!(f.app.document.bundle(), &adopted.bundle);
assert_eq!(
f.app.document.revision(),
revision,
"mounting unavailable must remain passive"
);
let _ = f.app.update(Message::Undo);
assert_saved(&f.app, f.view, f.owner, Layer::Oa);
assert_eq!(f.app.document.bundle(), &before.bundle);
let _ = f.app.update(Message::Redo);
assert_eq!(
f.app.behavior.scenario.representation(),
Some(Representation::Saved { diagram: f.view })
);
mounted.settle(&mut f.app).await;
assert!(
mounted
.geometry(&f.app)
.containers
.contains_key(&widget::Id::from("scenario-viewport-unavailable"))
);
assert_eq!(f.app.project(), &expected);
assert_eq!(f.app.document.bundle(), &adopted.bundle);
}
#[test]
fn missing_and_deleted_picker_ids_cannot_apply_an_unrelated_valid_property_draft() {
let mut f = fixture();
let mut expected = f.app.project().clone();
expected.diagrams.remove(&f.spare_view).unwrap();
adopt_from_source(&mut f.app, &expected);
let _ = f.app.update(Message::Select(f.owner));
let _ = f.app.update(Message::Field(
"/name".into(),
"Pending valid owner rename".into(),
));
assert!(f.app.draft_dirty());
let before = f.app.document.snapshot();
let revision = f.app.document.revision();
let selected = f.app.selected;
for invalid in [Id::nil(), f.spare_view] {
choose(&mut f.app, invalid);
assert_saved(&f.app, f.view, f.owner, Layer::Oa);
assert_eq!(f.app.selected, selected);
assert!(f.app.draft_dirty());
assert_eq!(f.app.project(), &before.decoded.project);
assert_eq!(f.app.document.bundle(), &before.bundle);
assert_eq!(f.app.document.revision(), revision);
}
// Establish that the retained draft really was valid, not merely refused by
// the property validator: explicit Apply commits it in exactly one step.
assert!(f.app.apply(), "{}", f.app.status);
assert_eq!(
f.app.project().elements[&f.owner].name,
"Pending valid owner rename"
);
assert_eq!(f.app.document.revision(), revision + 1);
let _ = f.app.update(Message::Undo);
assert_eq!(f.app.project(), &before.decoded.project);
assert_eq!(f.app.document.bundle(), &before.bundle);
}
@@ -0,0 +1,113 @@
//! Explicit owner navigation must retire a retained Saved Scenario, including
//! when selection was made on a different page. These are real App events.
use super::*;
use crate::behavior::scenario::CanvasEvent;
fn setup(kind: Kind) -> (App, Id, Id) {
let mut project = Project::empty();
let scenario = project
.add_element(Kind::Scenario, Layer::Oa, "Incident review", None)
.unwrap();
let target = project
.add_element(kind, Layer::Oa, "Explicit destination", None)
.unwrap();
let view = Id::new_v4();
project.diagrams.insert(
view,
Diagram {
id: view,
alias: "incident_saved".into(),
name: "Incident saved arrangement".into(),
description: String::new(),
layer: Layer::Oa,
projection: ProjectionKind::Scenario { scenario },
layout: None,
nodes: Default::default(),
routes: vec![],
view_options: Default::default(),
},
);
let errors: Vec<_> = oa_core::validation::validate(&project)
.into_iter()
.filter(|issue| issue.severity == oa_core::validation::Severity::Error)
.collect();
assert!(errors.is_empty(), "{errors:?}");
let mut app = App::with_project(project);
let _ = app.update(Message::Select(scenario));
let _ = app.update(Message::Diagram(Choice {
id: view,
label: "Incident saved arrangement".into(),
}));
assert_eq!(app.page, Page::Behavior);
assert_eq!(
app.scenario_context().unwrap().representation,
Representation::Saved { diagram: view }
);
assert_eq!(app.selected, scenario);
(app, scenario, target)
}
fn assert_destination(app: &App, target: Id) {
assert_eq!(app.page, Page::Behavior);
assert_eq!(app.selected, target);
assert_eq!(app.behavior.active_owner, Some(target));
assert_eq!(behavior::selected_owner(app), Some(target));
assert_eq!(
app.behavior.scenario.representation(),
None,
"explicit non-Scenario destination must not retain an overriding Saved request"
);
assert!(app.scenario_context().is_none());
}
fn exercise(via_guide: bool) {
for kind in [Kind::Process, Kind::StateMachine] {
let (mut app, scenario, target) = setup(kind);
assert_ne!(scenario, target);
let held = app.scenario_context().unwrap();
let accepted = app.document.snapshot();
let drafts = app.source_workspace.bundle();
let revision = app.document.revision();
let dirty = (app.document.dirty(), app.dirty(), app.source_dirty());
assert!(!app.document.can_undo() && !app.document.can_redo());
if via_guide {
let _ = app.update(Message::Page(Page::Guide));
assert_eq!(app.page, Page::Guide);
assert!(app.scenario_context().is_none());
}
let _ = app.update(Message::Select(target));
if via_guide {
assert_eq!(
app.page,
Page::Guide,
"explorer selection must not itself switch the page"
);
let _ = app.update(Message::Page(Page::Behavior));
}
assert_destination(&app, target);
let camera = app.camera_boundary();
// The prior saved canvas cannot regain focus after either owner route.
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Focus(held)));
assert_destination(&app, target);
assert!(app.camera_boundary() == camera);
assert_eq!(app.project(), &accepted.decoded.project);
assert_eq!(app.document.bundle(), &accepted.bundle);
assert_eq!(app.source_workspace.bundle(), drafts);
assert_eq!(app.document.revision(), revision);
assert_eq!(
(app.document.dirty(), app.dirty(), app.source_dirty()),
dirty
);
assert!(!app.document.can_undo() && !app.document.can_redo());
}
}
#[test]
fn saved_scenario_guide_selection_returns_to_explicit_process_or_state_owner() {
exercise(true);
}
#[test]
fn saved_scenario_direct_behavior_selection_enters_explicit_process_or_state_owner() {
exercise(false);
}
@@ -0,0 +1,205 @@
use super::*;
use crate::behavior::scenario::{CanvasEvent, ScrollArea};
fn saved(p: &mut Project, owner: Id, name: &str) -> Id {
let id = Id::new_v4();
p.diagrams.insert(
id,
Diagram {
id,
alias: format!("view_{}", p.diagrams.len()),
name: name.into(),
description: String::new(),
layer: Layer::Oa,
projection: ProjectionKind::Scenario { scenario: owner },
layout: None,
routes: vec![],
nodes: Default::default(),
view_options: Default::default(),
},
);
id
}
fn setup() -> (App, Id, Id, Id, Id) {
let (mut p, [owner, callee, _], [reference, _, _]) =
behavior::scenario::direct_tests::navigation_fixture();
let a = saved(&mut p, owner, "Review A");
let b = saved(&mut p, owner, "Review B");
let mut app = App::with_project(p);
app.select(owner);
(app, a, b, reference, callee)
}
fn choose(app: &mut App, diagram: Id) {
let _ = app.update(Message::Diagram(Choice {
id: diagram,
label: "Captured view".into(),
}));
}
fn event(app: &mut App, f: impl FnOnce(behavior::scenario::Context) -> CanvasEvent) {
let context = app.scenario_context().unwrap();
let _ = app.update(Message::ScenarioCanvas(f(context)));
}
#[test]
fn saved_view_switches_are_distinct_passive_visits_and_preserve_actual_history() {
let (mut app, a, b, _, _) = setup();
let original = app.document.snapshot();
let owner = app.selected;
assert!(app.execute(Command::Rename(owner, "Previously edited caller".into())));
let edited = app.document.snapshot();
let edited_revision = app.document.revision();
choose(&mut app, a);
let first = app.scenario_context().unwrap();
choose(&mut app, b);
let second = app.scenario_context().unwrap();
choose(&mut app, a);
let third = app.scenario_context().unwrap();
assert_eq!(first.scenario, owner);
assert_eq!(first.representation, Representation::Saved { diagram: a });
assert_eq!(second.representation, Representation::Saved { diagram: b });
assert_ne!(first.visit, second.visit);
assert_ne!(first.visit, third.visit);
assert_eq!(app.page, Page::Behavior);
assert_eq!(app.selected, owner);
assert_eq!(app.document.bundle(), &edited.bundle);
assert_eq!(app.project(), &edited.decoded.project);
assert_eq!(app.document.revision(), edited_revision);
let _ = app.update(Message::Undo);
assert_eq!(app.document.bundle(), &original.bundle);
assert_eq!(app.project(), &original.decoded.project);
let _ = app.update(Message::Redo);
assert_eq!(app.document.bundle(), &edited.bundle);
assert_eq!(app.project(), &edited.decoded.project);
}
#[test]
fn saved_view_return_rejects_old_passive_events_without_an_intermediate_frame() {
let (mut app, a, b, _, _) = setup();
choose(&mut app, a);
let old = app.scenario_context().unwrap();
choose(&mut app, b);
choose(&mut app, a);
let current = app.scenario_context().unwrap();
let camera = app.behavior.scenario.camera_boundary();
let before = app.document.snapshot();
for stale in [
CanvasEvent::Viewport(old, iced::Size::new(432., 321.)),
CanvasEvent::Scrolled(old, ScrollArea::Page, iced::Vector::new(0., 800.)),
CanvasEvent::FormShown(old, Id::new_v4()),
CanvasEvent::Restored(
old,
Id::new_v4(),
ScrollArea::Page,
iced::Vector::new(0., 800.),
),
CanvasEvent::Camera(
old,
app.camera_epoch,
behavior::canvas_workspace::Event::Pan(iced::Vector::new(90., 80.)),
),
] {
let _ = app.update(Message::ScenarioCanvas(stale));
assert_eq!(app.scenario_context(), Some(current));
assert!(app.behavior.scenario.camera_boundary() == camera);
assert_eq!(app.document.bundle(), &before.bundle);
}
}
#[test]
fn saved_reference_back_restores_exact_view_with_a_fresh_visit() {
let (mut app, a, _, reference, callee) = setup();
choose(&mut app, a);
let caller = app.scenario_context().unwrap();
let before = app.document.snapshot();
event(&mut app, |ctx| CanvasEvent::OpenUse(ctx, reference));
let target = app.scenario_context().unwrap();
assert_eq!(target.scenario, callee);
assert_eq!(
target.representation,
Representation::Derived { owner: callee }
);
assert_eq!(app.active_diagram, None);
event(&mut app, CanvasEvent::Back);
let returned = app.scenario_context().unwrap();
assert_eq!(returned.representation, caller.representation);
assert_eq!(app.active_diagram, Some(a));
assert_eq!(app.selected, reference);
assert_ne!(returned.visit, caller.visit);
assert_eq!(app.document.bundle(), &before.bundle);
assert_eq!(app.project(), &before.decoded.project);
assert!(!app.document.can_undo());
}
#[test]
fn saved_picker_applies_valid_old_draft_and_uuid_edit_follows_that_same_view() {
let (mut app, a, b, _, _) = setup();
choose(&mut app, a);
let _ = app.update(Message::Select(a));
let before = app.document.snapshot();
let _ = app.update(Message::Field("/name".into(), "Renamed A".into()));
choose(&mut app, b);
assert_eq!(app.project().diagrams[&a].name, "Renamed A");
assert_eq!(app.selected, b);
assert_eq!(
app.scenario_context().unwrap().representation,
Representation::Saved { diagram: b }
);
let _ = app.update(Message::Undo);
assert_eq!(app.document.bundle(), &before.bundle);
choose(&mut app, a);
let replacement = Id::new_v4();
let _ = app.update(Message::Field("/id".into(), replacement.to_string()));
choose(&mut app, a);
assert!(!app.project().diagrams.contains_key(&a));
assert_eq!(app.active_diagram, Some(replacement));
assert_eq!(app.selected, replacement);
assert_eq!(
app.scenario_context().unwrap().representation,
Representation::Saved {
diagram: replacement
}
);
}
#[test]
fn leaving_and_reentering_saved_view_preserves_source_and_retires_passive_visit() {
let (mut app, a, _, _, _) = setup();
choose(&mut app, a);
let old = app.scenario_context().unwrap();
let before = app.document.snapshot();
let _ = app.update(Message::Page(Page::Guide));
assert!(app.scenario_context().is_none());
let _ = app.update(Message::Page(Page::Behavior));
let fresh = app.scenario_context().unwrap();
assert_eq!(fresh.representation, old.representation);
assert_ne!(fresh.visit, old.visit);
assert_eq!(app.document.bundle(), &before.bundle);
}
#[test]
fn arrangement_picker_rejects_previous_visit_and_switches_automatic_without_model_changes() {
let (mut app, a, b, _, _) = setup();
choose(&mut app, a);
let stale = app.scenario_context().unwrap();
choose(&mut app, b);
let current = app.scenario_context().unwrap();
let before = app.document.snapshot();
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Representation(
stale,
Representation::Saved { diagram: a },
)));
assert_eq!(app.scenario_context(), Some(current));
event(&mut app, |context| {
CanvasEvent::Representation(
context,
Representation::Derived {
owner: context.scenario,
},
)
});
assert_eq!(
app.scenario_context().unwrap().representation,
Representation::Derived {
owner: current.scenario
}
);
assert_eq!(app.active_diagram, None);
assert_eq!(app.document.bundle(), &before.bundle);
assert_eq!(app.project(), &before.decoded.project);
assert!(!app.document.can_undo());
}
@@ -0,0 +1,88 @@
//! Admit spacing packets before ordinary navigation can retire a newer reveal.
use super::*;
use behavior::scenario::spacing::{Event, controller::Finish};
#[cfg(test)]
mod tests;
impl App {
pub(super) fn spacing_enabled(&self) -> bool {
self.pending_diagram_reveal.is_none()
&& !self.behavior.has_pending()
&& !self.draft_dirty()
&& !self.source_dirty()
&& !self.source_rename.open()
&& behavior::canvas_workspace::keyboard_enabled(self)
}
pub(super) fn scenario_spacing(&mut self, event: Event) -> Task<Message> {
let stamp = event.stamp();
if self.scenario_context() != Some(stamp.context) || self.camera_epoch != stamp.camera_epoch
{
return Task::none();
}
let state = &self.behavior.scenario;
let matches =
state.spacing_token() == Some(event.token()) && state.spacing_stamp() == Some(stamp);
if match event {
Event::Begin { token, .. } => token.is_nil() || state.spacing_token().is_some(),
_ => !matches,
} {
return Task::none();
}
if matches!(event, Event::Cancel { .. }) || !self.spacing_enabled() {
if self.behavior.scenario.cancel_spacing() {
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
}
return Task::none();
}
match event {
Event::Begin { .. } => {
let mut state = std::mem::take(&mut self.behavior.scenario);
let result = state.begin_spacing(self.project(), event);
self.behavior.scenario = state;
if result.is_err() {
self.status = "The drag could not start in this view. Try again.".into();
}
}
Event::Move { screen, .. } => self.behavior.scenario.move_spacing(screen),
Event::Finish { screen, .. } => {
let finished = self.behavior.scenario.finish_spacing(screen);
// Retire queued packets even for zero motion or a refused finish.
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
match finished {
Ok(Finish::Click(target)) => self.select(target),
Ok(Finish::Noop) => {}
Ok(Finish::Commit {
command,
selected,
scale,
pan,
}) => {
// Document prepares and writes the source candidate before
// committing its snapshot. Failure leaves camera/selection
// and source/history at their accepted values.
match self.document.execute(*command) {
Ok(_) => {
self.selected = selected;
self.selected_node = None;
self.diagram_navigation_focus = None;
self.behavior.scenario.accept_spacing_camera(scale, pan);
self.sync();
self.status = "Scenario spacing updated.".into();
}
Err(error) => self.status = format!("Spacing was not applied: {error}"),
}
}
Err(_) => {
self.status =
"That position cannot be applied. The saved arrangement is unchanged."
.into()
}
}
}
Event::Cancel { .. } => unreachable!(),
}
Task::none()
}
}
@@ -0,0 +1,272 @@
use super::*;
use behavior::scenario::{
CanvasEvent,
spacing::{
Target,
controller::tests::{fixture, packet},
},
};
use iced::{Point, Size, Vector};
fn setup() -> (App, Id, Id) {
let (document, owner, diagram, lane, message) = fixture();
let mut app = App::with_project(document.project().clone());
let _ = app.update(Message::Select(owner));
let _ = app.update(Message::Diagram(Choice {
id: diagram,
label: "Saved".into(),
}));
let c = app.scenario_context().unwrap();
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Viewport(
c,
Size::new(900., 600.),
)));
assert!(app.spacing_enabled());
(app, lane, message)
}
fn begin(app: &mut App, target: Target) -> (Event, Point, f32) {
let event = packet(
app.project(),
&app.behavior.scenario,
app.scenario_context().unwrap(),
app.camera_epoch,
target,
);
let Event::Begin {
press_screen,
scale,
..
} = event
else {
unreachable!()
};
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Spacing(event)));
assert_eq!(app.behavior.scenario.spacing_token(), Some(event.token()));
(event, press_screen, scale)
}
fn send(app: &mut App, event: Event) {
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Spacing(event)));
}
fn move_to(app: &mut App, begin: Event, screen: Point) {
send(
app,
Event::Move {
stamp: begin.stamp(),
token: begin.token(),
screen,
},
);
}
fn finish(app: &mut App, begin: Event, screen: Point) {
send(
app,
Event::Finish {
stamp: begin.stamp(),
token: begin.token(),
screen,
},
);
}
#[test]
fn same_batch_preview_is_immutable_and_one_commit_undo_redo_has_exact_source_parity() {
let (mut app, lane, _) = setup();
let original = app.document.snapshot();
let source = app.source_workspace.bundle();
let selected = app.selected;
let epoch = app.canvas_epoch;
let (event, start, scale) = begin(&mut app, Target::Lane(lane));
move_to(&mut app, event, start + Vector::new(40., 300.));
assert_eq!(app.selected, selected);
assert_eq!(app.canvas_epoch, epoch);
assert_eq!(app.project(), &original.decoded.project);
assert_eq!(app.source_workspace.bundle(), source);
assert!(!app.document.can_undo() && !app.document.can_redo());
finish(&mut app, event, start + Vector::new(40., 300.));
assert_eq!(app.selected, lane);
assert!(app.document.can_undo() && !app.document.can_redo());
let accepted = app.document.snapshot();
assert_ne!(accepted.bundle, original.bundle);
let expected = behavior::scenario::spacing::controller::tests::expected(
&original.decoded.project,
match event.stamp().context.representation {
behavior::scenario_layout::Representation::Saved { diagram } => diagram,
_ => unreachable!(),
},
Target::Lane(lane),
40. / scale,
);
assert_eq!(app.project(), &expected);
let _ = app.update(Message::ShortcutUndo(false));
assert_eq!(app.project(), &original.decoded.project);
assert_eq!(app.document.bundle(), &original.bundle);
assert!(!app.document.can_undo() && app.document.can_redo());
let _ = app.update(Message::ShortcutUndo(true));
assert_eq!(app.project(), &accepted.decoded.project);
assert_eq!(app.document.bundle(), &accepted.bundle);
}
#[test]
fn click_selects_but_crossed_return_and_cancel_preserve_selection_camera_and_history() {
let (mut app, lane, message) = setup();
let original = app.document.snapshot();
let camera = app.behavior.scenario.camera_boundary();
let (event, start, _) = begin(&mut app, Target::Message(message));
finish(&mut app, event, start + Vector::new(30., 2.));
assert_eq!(app.selected, message);
assert!(camera == app.behavior.scenario.camera_boundary());
for cancel in [false, true] {
let (event, start, _) = begin(&mut app, Target::Lane(lane));
move_to(&mut app, event, start + Vector::new(30., 0.));
if cancel {
send(
&mut app,
Event::Cancel {
stamp: event.stamp(),
token: event.token(),
},
);
} else {
finish(&mut app, event, start);
}
assert_eq!(app.selected, message);
assert!(camera == app.behavior.scenario.camera_boundary());
assert_eq!(app.document.bundle(), &original.bundle);
assert_eq!(app.project(), &original.decoded.project);
assert!(!app.document.can_undo() && !app.document.can_redo());
}
}
#[test]
fn stale_finish_after_new_view_and_forged_token_are_inert_before_reveal_reconciliation() {
let (mut app, lane, _) = setup();
let (event, start, _) = begin(&mut app, Target::Lane(lane));
let held_epoch = app.canvas_epoch;
send(
&mut app,
Event::Finish {
stamp: event.stamp(),
token: Id::new_v4(),
screen: start + Vector::new(30., 0.),
},
);
assert_eq!(app.canvas_epoch, held_epoch);
assert_eq!(app.behavior.scenario.spacing_token(), Some(event.token()));
let owner = event.stamp().context.scenario;
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Representation(
event.stamp().context,
behavior::scenario_layout::Representation::Derived { owner },
)));
let current = app.scenario_context();
let epoch = app.canvas_epoch;
let original = app.document.snapshot();
finish(&mut app, event, start + Vector::new(50., 0.));
assert_eq!(app.scenario_context(), current);
assert_eq!(app.canvas_epoch, epoch);
assert!(app.behavior.scenario.spacing_token().is_none());
assert_eq!(app.document.bundle(), &original.bundle);
}
#[test]
fn source_write_failure_cannot_commit_candidate_or_materialize_camera() {
let (mut app, lane, _) = setup();
let bundle = app.document.bundle().clone();
let mut limits = oa_core::source_project::ProjectLimits::default();
limits.model.max_source_bytes = bundle.files.values().map(String::len).sum();
app.document = Document::from_bundle(bundle, limits).unwrap();
app.sync();
let original = app.document.snapshot();
let selected = app.selected;
let camera = app.behavior.scenario.camera_boundary();
let (event, start, _) = begin(&mut app, Target::Lane(lane));
move_to(&mut app, event, start + Vector::new(40., 0.));
finish(&mut app, event, start + Vector::new(40., 0.));
assert!(
app.status.starts_with("Spacing was not applied:"),
"{}",
app.status
);
assert_eq!(app.selected, selected);
assert!(camera == app.behavior.scenario.camera_boundary());
assert_eq!(app.project(), &original.decoded.project);
assert_eq!(app.document.bundle(), &original.bundle);
assert!(!app.document.can_undo() && !app.document.can_redo());
assert!(app.behavior.scenario.spacing_token().is_none());
}
#[test]
fn pending_semantic_draft_and_source_edit_cancel_and_reject_held_finish() {
for source in [false, true] {
let (mut app, lane, _) = setup();
let original = app.document.snapshot();
let selected = app.selected;
let (event, start, _) = begin(&mut app, Target::Lane(lane));
move_to(&mut app, event, start + Vector::new(30., 0.));
if source {
let _ = app.update(Message::SourceEdit(text_editor::Action::Edit(
text_editor::Edit::Insert(' '),
)));
assert!(app.source_dirty());
} else {
let _ = app.update(Message::Behavior(behavior::Event::Scenario(
behavior::scenario::Event::Start(
event.stamp().context.scenario,
behavior::scenario::Tool::Lifeline,
),
)));
assert!(app.behavior.has_pending());
}
let draft = app.source_workspace.bundle();
assert!(app.behavior.scenario.spacing_token().is_none());
finish(&mut app, event, start + Vector::new(30., 0.));
assert_eq!(app.project(), &original.decoded.project);
assert_eq!(app.document.bundle(), &original.bundle);
assert_eq!(app.source_workspace.bundle(), draft);
assert_eq!(app.selected, selected);
assert!(!app.document.can_undo());
}
}
#[test]
fn stale_finish_cannot_remove_new_same_view_deferred_reveal() {
let (mut app, lane, message) = setup();
let (event, start, _) = begin(&mut app, Target::Lane(lane));
let behavior::scenario_layout::Representation::Saved { diagram } =
event.stamp().context.representation
else {
unreachable!()
};
let _ = app.update(Message::RevealInDiagram(diagram, message));
assert!(app.pending_diagram_reveal.is_some());
let epoch = app.canvas_epoch;
let selected = app.selected;
let visit = app.pending_scenario_reveal_visit;
let original = app.document.snapshot();
finish(&mut app, event, start + Vector::new(50., 0.));
assert_eq!(app.canvas_epoch, epoch);
assert_eq!(app.pending_scenario_reveal_visit, visit);
assert!(app.pending_diagram_reveal.is_some());
assert_eq!(app.selected, selected);
assert_eq!(app.document.bundle(), &original.bundle);
assert!(!app.document.can_undo());
}
#[test]
fn modal_open_cancels_preview_and_captured_begin_stays_retired_after_close() {
let (mut app, lane, _) = setup();
let original = app.document.snapshot();
let camera = app.behavior.scenario.camera_boundary();
let (event, start, _) = begin(&mut app, Target::Lane(lane));
move_to(&mut app, event, start + Vector::new(30., 0.));
let _ = app.update(Message::Delete);
assert!(app.delete_pending);
assert!(app.behavior.scenario.spacing_token().is_none());
finish(&mut app, event, start + Vector::new(30., 0.));
let _ = app.update(Message::CancelDelete);
send(&mut app, event);
assert!(app.behavior.scenario.spacing_token().is_none());
assert_eq!(app.project(), &original.decoded.project);
assert_eq!(app.document.bundle(), &original.bundle);
assert!(camera == app.behavior.scenario.camera_boundary());
assert!(!app.document.can_undo());
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,219 @@
//! Keep Iced's single-line input from silently filtering pasted control bytes.
//! Only the alias field uses this adapter. The child retains editing and focus.
use iced::advanced::{
Clipboard, Layout, Shell, Widget, clipboard, layout, overlay, renderer, text, widget,
};
use iced::{Element, Event, Length, Rectangle, Size, Vector, keyboard, mouse};
pub(super) fn guard<'a, Message: Clone + 'a, Theme: 'a, R: text::Renderer + 'a>(
input: Element<'a, Message, Theme, R>,
rejected: Option<Message>,
) -> Element<'a, Message, Theme, R> {
Element::new(AliasInput { input, rejected })
}
struct AliasInput<'a, Message, Theme, R> {
input: Element<'a, Message, Theme, R>,
rejected: Option<Message>,
}
#[derive(Default)]
struct State {
modifiers: keyboard::Modifiers,
}
// Each fresh child clipboard read uses the checked snapshot. Iced may reuse its
// previously admitted paste while V is held, until KeyReleased clears that cache.
struct CapturedClipboard<'a> {
content: Option<String>,
original: &'a mut dyn Clipboard,
}
impl Clipboard for CapturedClipboard<'_> {
fn read(&self, kind: clipboard::Kind) -> Option<String> {
if kind == clipboard::Kind::Standard {
self.content.clone()
} else {
self.original.read(kind)
}
}
fn write(&mut self, kind: clipboard::Kind, contents: String) {
self.original.write(kind, contents);
}
}
impl<Message: Clone, Theme, R: text::Renderer> Widget<Message, Theme, R>
for AliasInput<'_, Message, Theme, R>
{
fn size(&self) -> Size<Length> {
self.input.as_widget().size()
}
fn size_hint(&self) -> Size<Length> {
self.input.as_widget().size_hint()
}
fn tag(&self) -> widget::tree::Tag {
widget::tree::Tag::of::<State>()
}
fn state(&self) -> widget::tree::State {
widget::tree::State::new(State::default())
}
fn children(&self) -> Vec<widget::Tree> {
vec![widget::Tree::new(self.input.as_widget())]
}
fn diff(&self, tree: &mut widget::Tree) {
tree.diff_children(std::slice::from_ref(&self.input));
}
fn layout(
&mut self,
tree: &mut widget::Tree,
renderer: &R,
limits: &layout::Limits,
) -> layout::Node {
self.input
.as_widget_mut()
.layout(&mut tree.children[0], renderer, limits)
}
fn draw(
&self,
tree: &widget::Tree,
renderer: &mut R,
theme: &Theme,
style: &renderer::Style,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
) {
self.input.as_widget().draw(
&tree.children[0],
renderer,
theme,
style,
layout,
cursor,
viewport,
);
}
fn operate(
&mut self,
tree: &mut widget::Tree,
layout: Layout<'_>,
renderer: &R,
operation: &mut dyn widget::Operation,
) {
self.input
.as_widget_mut()
.operate(&mut tree.children[0], layout, renderer, operation);
}
fn update(
&mut self,
tree: &mut widget::Tree,
event: &Event,
layout: Layout<'_>,
cursor: mouse::Cursor,
renderer: &R,
clipboard: &mut dyn Clipboard,
shell: &mut Shell<'_, Message>,
viewport: &Rectangle,
) {
let focused = tree.children[0]
.state
.downcast_ref::<iced::widget::text_input::State<R::Paragraph>>()
.is_focused();
let state = tree.state.downcast_mut::<State>();
// Iced 0.14.2 uses stored ModifiersChanged state, not KeyPressed.modifiers.
// Mirror its Escape reset and its Latin/physical-key paste shortcut.
if let Event::Keyboard(keyboard::Event::ModifiersChanged(modifiers)) = event {
state.modifiers = *modifiers;
}
let paste = matches!(event,
Event::Keyboard(keyboard::Event::KeyPressed { key, physical_key, .. })
if key.to_latin(*physical_key) == Some('v')
&& state.modifiers.command() && !state.modifiers.alt());
if focused
&& paste
&& let Some(rejected) = &self.rejected
{
let content = clipboard.read(clipboard::Kind::Standard);
if content
.as_ref()
.is_some_and(|value| value.chars().any(char::is_control))
{
// Do not invoke the child: even an empty replacement would erase
// its selection. Approval invalidation is an explicit App event.
shell.publish(rejected.clone());
shell.capture_event();
return;
}
self.input.as_widget_mut().update(
&mut tree.children[0],
event,
layout,
cursor,
renderer,
&mut CapturedClipboard {
content,
original: clipboard,
},
shell,
viewport,
);
return;
}
self.input.as_widget_mut().update(
&mut tree.children[0],
event,
layout,
cursor,
renderer,
clipboard,
shell,
viewport,
);
// In Iced 0.14.2 the only KeyPressed branch that removes focus is its
// Escape branch, which also resets stored modifiers. Observe that branch
// after its text/shortcut early returns and effective-key conversion.
if focused
&& matches!(event, Event::Keyboard(keyboard::Event::KeyPressed { .. }))
&& !tree.children[0]
.state
.downcast_ref::<iced::widget::text_input::State<R::Paragraph>>()
.is_focused()
{
tree.state.downcast_mut::<State>().modifiers = keyboard::Modifiers::default();
}
}
fn mouse_interaction(
&self,
tree: &widget::Tree,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
renderer: &R,
) -> mouse::Interaction {
self.input.as_widget().mouse_interaction(
&tree.children[0],
layout,
cursor,
viewport,
renderer,
)
}
fn overlay<'a>(
&'a mut self,
tree: &'a mut widget::Tree,
layout: Layout<'a>,
renderer: &R,
viewport: &Rectangle,
translation: Vector,
) -> Option<overlay::Element<'a, Message, Theme, R>> {
self.input.as_widget_mut().overlay(
&mut tree.children[0],
layout,
renderer,
viewport,
translation,
)
}
}
#[cfg(all(test, debug_assertions))]
mod tests;
@@ -0,0 +1,220 @@
use super::*;
use crate::views::source_tests::FontLayout;
use iced::keyboard::{
Key, Modifiers,
key::{Code, Named, Physical},
};
use std::cell::Cell;
#[derive(Debug, Clone, PartialEq)]
enum Signal {
Input(String),
Rejected,
}
struct ClipboardText {
text: String,
reads: Cell<usize>,
}
impl Clipboard for ClipboardText {
fn read(&self, _: clipboard::Kind) -> Option<String> {
self.reads.set(self.reads.get() + 1);
Some(self.text.clone())
}
fn write(&mut self, _: clipboard::Kind, _: String) {}
}
type InputState = iced::widget::text_input::State<<FontLayout as text::Renderer>::Paragraph>;
struct Mounted {
input: Element<'static, Signal, iced::Theme, FontLayout>,
tree: widget::Tree,
node: layout::Node,
clipboard: ClipboardText,
messages: Vec<Signal>,
}
impl Mounted {
fn new(focused: bool, enabled: bool, modifiers: Modifiers) -> Self {
let mut child = iced::widget::text_input("Alias", "send_water");
if enabled {
child = child.on_input(Signal::Input);
}
let mut input = guard(child.into(), enabled.then_some(Signal::Rejected));
let mut tree = widget::Tree::new(input.as_widget());
let node = input.as_widget_mut().layout(
&mut tree,
&FontLayout,
&layout::Limits::new(Size::ZERO, Size::new(500., 40.)),
);
if focused {
let state = tree.children[0].state.downcast_mut::<InputState>();
state.focus();
state.select_range(0, 10);
}
let mut mounted = Self {
input,
tree,
node,
clipboard: ClipboardText {
text: "foo\nbar".into(),
reads: Cell::new(0),
},
messages: vec![],
};
mounted.event(Event::Keyboard(keyboard::Event::ModifiersChanged(
modifiers,
)));
mounted
}
fn state(&self) -> &InputState {
self.tree.children[0].state.downcast_ref::<InputState>()
}
fn event(&mut self, event: Event) -> bool {
let mut shell = Shell::new(&mut self.messages);
self.input.as_widget_mut().update(
&mut self.tree,
&event,
Layout::new(&self.node),
mouse::Cursor::Unavailable,
&FontLayout,
&mut self.clipboard,
&mut shell,
&Rectangle::new(iced::Point::ORIGIN, Size::new(500., 40.)),
);
shell.is_event_captured()
}
}
fn key(key: Key, modified_key: Key, code: Code) -> Event {
Event::Keyboard(keyboard::Event::KeyPressed {
key,
modified_key,
physical_key: Physical::Code(code),
location: keyboard::Location::Standard,
modifiers: Modifiers::empty(),
text: None,
repeat: false,
})
}
fn paste() -> Event {
key(
Key::Character("v".into()),
Key::Character("v".into()),
Code::KeyV,
)
}
#[test]
fn paste_refusal_matches_child_focus_enabled_and_stored_modifier_protocol() {
for focused in [false, true] {
for enabled in [false, true] {
for modifiers in [
Modifiers::empty(),
Modifiers::SHIFT,
Modifiers::COMMAND,
Modifiers::COMMAND | Modifiers::ALT,
Modifiers::COMMAND | Modifiers::SHIFT,
] {
let mut mounted = Mounted::new(focused, enabled, modifiers);
let before = mounted.state().cursor();
let expected = focused && enabled && modifiers.command() && !modifiers.alt();
let captured = mounted.event(paste());
assert_eq!(
mounted.messages,
if expected {
vec![Signal::Rejected]
} else {
vec![]
}
);
assert_eq!(mounted.clipboard.reads.get(), usize::from(expected));
assert_eq!(mounted.state().cursor(), before);
if expected {
assert!(captured);
}
}
}
}
}
#[test]
fn effective_escape_reset_follows_child_after_early_returns() {
// Raw Escape mapped to Home must not clear only the guard's Command state.
let mut mounted = Mounted::new(true, true, Modifiers::COMMAND);
mounted.event(key(
Key::Named(Named::Escape),
Key::Named(Named::Home),
Code::Escape,
));
assert!(mounted.state().is_focused());
assert!(mounted.event(paste()));
assert_eq!(mounted.messages, vec![Signal::Rejected]);
// Effective Escape clears both states; a later focus operation does not
// invent a new ModifiersChanged event or revive the previous Command state.
let mut mounted = Mounted::new(true, true, Modifiers::COMMAND);
mounted.event(key(
Key::Named(Named::Home),
Key::Named(Named::Escape),
Code::Home,
));
assert!(!mounted.state().is_focused());
mounted.tree.children[0]
.state
.downcast_mut::<InputState>()
.focus();
mounted.event(paste());
assert!(mounted.messages.is_empty());
assert_eq!(mounted.clipboard.reads.get(), 0);
// Printable text returns before effective Escape and leaves modifiers live.
let mut mounted = Mounted::new(true, true, Modifiers::COMMAND);
let mut event = key(
Key::Named(Named::Home),
Key::Named(Named::Escape),
Code::Home,
);
if let Event::Keyboard(keyboard::Event::KeyPressed { text, .. }) = &mut event {
*text = Some("x".into());
}
mounted.event(event);
assert!(mounted.state().is_focused());
assert_eq!(mounted.messages, vec![Signal::Input("x".into())]);
mounted.messages.clear();
mounted.event(paste());
assert_eq!(mounted.messages, vec![Signal::Rejected]);
}
#[test]
fn held_paste_cache_contains_only_previously_admitted_text() {
let mut mounted = Mounted::new(true, true, Modifiers::COMMAND);
mounted.clipboard.text = "water".into();
mounted.event(paste());
assert_eq!(mounted.messages, vec![Signal::Input("water".into())]);
mounted.messages.clear();
mounted.clipboard.text = "other".into();
mounted.event(paste());
assert_eq!(
mounted.messages,
vec![Signal::Input("waterwater".into())],
"Iced reuses its first admitted paste while the key is held"
);
mounted.messages.clear();
let before = mounted.state().cursor();
mounted.clipboard.text = "bad\tvalue".into();
mounted.event(paste());
assert_eq!(mounted.messages, vec![Signal::Rejected]);
assert_eq!(mounted.state().cursor(), before);
mounted.messages.clear();
mounted.event(Event::Keyboard(keyboard::Event::KeyReleased {
key: Key::Character("v".into()),
modified_key: Key::Character("v".into()),
physical_key: Physical::Code(Code::KeyV),
location: keyboard::Location::Standard,
modifiers: Modifiers::empty(),
}));
mounted.clipboard.text = "later".into();
mounted.event(paste());
assert_eq!(
mounted.messages,
vec![Signal::Input("waterwaterlater".into())]
);
}
@@ -0,0 +1,303 @@
//! Display-only reuse after a guarded alias rename. This is not model authority.
use iced::{advanced::text::Renderer, widget::text_editor};
use std::sync::Arc;
/// Replace exact, unchanged-line-count source in an existing editor.
///
/// The caller must first authorize/commit the model change and must reconstruct
/// from accepted source on false. Preflight failures do not mutate the editor;
/// a failed final readback can leave it changed. On changing success the cursor
/// is at 0:0 without selection; the caller restores its approved origin/reveal.
/// Direct Content actions deliberately bypass App draft edits and Undo history.
pub(super) fn replace<R: Renderer>(
editor: &mut text_editor::Content<R>,
before: &str,
after: &str,
) -> bool {
if editor.text() != before {
return false;
}
let Some(old_lines) = lines(before) else {
return false;
};
let Some(new_lines) = lines(after) else {
return false;
};
if old_lines.len() != new_lines.len() || old_lines.len() != editor.line_count() {
return false;
}
let mut patches = Vec::new();
for (line, ((old, old_ending), (new, new_ending))) in
old_lines.into_iter().zip(new_lines).enumerate()
{
let Some(actual) = editor.line(line) else {
return false;
};
// Cosmic also recognizes bare CR and LFCR. Comparing the actual lines
// catches its greedy LFCR interpretation of e.g. "x\n\r\ny" too.
if old_ending != new_ending || actual.text != old || actual.ending != old_ending {
return false;
}
if old == new {
continue;
}
let mut start = old
.bytes()
.zip(new.bytes())
.take_while(|(a, b)| a == b)
.count();
while !old.is_char_boundary(start) || !new.is_char_boundary(start) {
start -= 1;
}
let mut suffix = old[start..]
.bytes()
.rev()
.zip(new[start..].bytes().rev())
.take_while(|(a, b)| a == b)
.count();
while !old.is_char_boundary(old.len() - suffix) || !new.is_char_boundary(new.len() - suffix)
{
suffix -= 1;
}
// All positions and owned payloads are prepared before the first edit.
patches.push((
line,
start,
old.len() - suffix,
Arc::new(new[start..new.len() - suffix].to_owned()),
));
}
if patches.is_empty() {
return true;
}
let empty = Arc::new(String::new());
for (line, start, end, replacement) in patches.into_iter().rev() {
select(editor, line, start, end);
editor.perform(text_editor::Action::Edit(text_editor::Edit::Paste(
replacement,
)));
}
// Iced overwrites its earliest-change marker on each Edit. An explicit
// zero-width selection + empty Paste changes no bytes, but marks line zero.
// This also refreshes previously unhighlighted lines before the first patch.
// It resets only line zero's text cache; other unchanged lines retain theirs.
select(editor, 0, 0, 0);
editor.perform(text_editor::Action::Edit(text_editor::Edit::Paste(empty)));
editor.text() == after
}
fn select<R: Renderer>(
editor: &mut text_editor::Content<R>,
line: usize,
start: usize,
end: usize,
) {
editor.move_to(text_editor::Cursor {
position: text_editor::Position {
line,
column: start,
},
// move_to(None) does not clear a previous selection in this Iced version.
selection: Some(text_editor::Position { line, column: end }),
});
}
fn lines(source: &str) -> Option<Vec<(&str, text_editor::LineEnding)>> {
use text_editor::LineEnding;
let mut result = Vec::new();
let mut parts = source.split('\n').peekable();
while let Some(part) = parts.next() {
let (text, ending) = if parts.peek().is_none() {
(part, LineEnding::None)
} else if let Some(text) = part.strip_suffix('\r') {
(text, LineEnding::CrLf)
} else {
(part, LineEnding::Lf)
};
if text.contains('\r')
|| (text.is_empty()
&& ending == LineEnding::CrLf
&& result
.last()
.is_some_and(|(_, previous)| *previous == LineEnding::Lf))
{
return None;
}
result.push((text, ending));
}
Some(result)
}
#[cfg(all(test, debug_assertions))]
mod tests {
use super::*;
use crate::views::source_tests::FontLayout;
use iced::advanced::{layout, text::Highlighter, widget::Tree};
use iced::{Element, Font, Length, Size};
use std::{cell::RefCell, ops::Range};
#[test]
fn exact_text_matrix_and_reverse_preserve_endings() {
for (before, after) in [
("", ""),
("", "alias"),
("alias", ""),
("same", "same"),
("a\n", "alias\n"),
("a\r\n", "alias\r\n"),
("\r\na\r\n\r\n", "\r\nlong_alias\r\n\r\n"),
("\na\n\n", "\nlong_alias\n\n"),
("a\r\nb\nc", "long_a\r\nlong_b\nlong_c"),
(
"雨🙂 e\u{301} @a @a // a",
"雨🙂 e\u{301} @a_long @a_long // a",
),
("@abc", "@abcdef"),
("@abcdef", "@abc"),
("@abc_suffix", "@def_suffix"),
("@none", "@identifier"),
// Shared prefix/suffix bytes inside distinct UTF-8 codepoints must
// not be used as String split positions, even outside alias scope.
("雨xé", "雪xê"),
("é", "©"),
(
"unchanged\n@a\nunchanged\n@a\n",
"unchanged\n@long\nunchanged\n@long\n",
),
] {
let mut editor = text_editor::Content::<FontLayout>::with_text(before);
assert!(
replace(&mut editor, before, after),
"{before:?} -> {after:?}"
);
assert_eq!(editor.text(), after);
let expected = text_editor::Content::<FontLayout>::with_text(after);
assert_eq!(editor.line_count(), expected.line_count());
for (actual, expected) in editor.lines().zip(expected.lines()) {
assert_eq!(actual.text, expected.text);
assert_eq!(actual.ending, expected.ending);
}
if before != after {
assert_eq!(
editor.cursor().position,
text_editor::Position { line: 0, column: 0 }
);
assert_eq!(editor.cursor().selection, None);
}
assert!(replace(&mut editor, after, before));
assert_eq!(editor.text(), before);
}
}
#[test]
fn all_preflight_refusals_leave_text_cursor_and_selection_unchanged() {
for (actual, before, after) in [
("live", "stale", "changed"),
("a\nb", "a\nb", "long\nb\nc"),
("a\nb", "a\nb", "long\r\nb"),
// Refusal on a later line must not apply the earlier planned edit.
("a\nb\r\n", "a\nb\r\n", "long\nb\n"),
("a\rb", "a\rb", "long\rb"),
("a\n\rb", "a\n\rb", "long\n\rb"),
("a\n\r\nb", "a\n\r\nb", "long\n\r\nb"),
// The replacement must not introduce a greedy LFCR boundary either.
("a\nx\r\n", "a\nx\r\n", "a\n\r\n"),
("a", "a", "long\r"),
] {
let mut editor = text_editor::Content::<FontLayout>::with_text(actual);
select(&mut editor, 0, 0, 1);
let cursor = editor.cursor();
let selection = editor.selection();
assert!(
!replace(&mut editor, before, after),
"{before:?} -> {after:?}"
);
assert_eq!(editor.text(), actual);
assert_eq!(editor.cursor(), cursor);
assert_eq!(editor.selection(), selection);
}
}
#[test]
fn unchanged_content_preserves_selection_and_next_direct_edit_works() {
let mut editor = text_editor::Content::<FontLayout>::with_text("@a");
select(&mut editor, 0, 1, 2);
let cursor = editor.cursor();
assert!(replace(&mut editor, "@a", "@a"));
assert_eq!(editor.cursor(), cursor);
assert!(replace(&mut editor, "@a", "@long"));
editor.move_to(text_editor::Cursor {
position: text_editor::Position { line: 0, column: 5 },
selection: None,
});
editor.perform(text_editor::Action::Edit(text_editor::Edit::Insert('!')));
assert_eq!(editor.text(), "@long!");
}
thread_local! {
static CHANGES: RefCell<Vec<usize>> = const { RefCell::new(Vec::new()) };
}
struct Tracked(crate::highlight::ArcadiaHighlighter);
impl Highlighter for Tracked {
type Settings = Arc<str>;
type Highlight = oa_core::language::TokenClass;
type Iterator<'a> = std::vec::IntoIter<(Range<usize>, Self::Highlight)>;
fn new(settings: &Self::Settings) -> Self {
Self(crate::highlight::ArcadiaHighlighter::new(settings))
}
fn update(&mut self, settings: &Self::Settings) {
self.0.update(settings);
}
fn change_line(&mut self, line: usize) {
CHANGES.with(|changes| changes.borrow_mut().push(line));
self.0.change_line(line);
}
fn highlight_line(&mut self, line: &str) -> Self::Iterator<'_> {
self.0.highlight_line(line)
}
fn current_line(&self) -> usize {
self.0.current_line()
}
}
fn view(
editor: &text_editor::Content<FontLayout>,
source: Arc<str>,
) -> Element<'_, (), iced::Theme, FontLayout> {
text_editor(editor)
.font(Font::MONOSPACE)
.size(14)
.height(Length::Fill)
.highlight_with::<Tracked>(source, crate::highlight::format)
.into()
}
#[test]
fn already_laid_out_editor_notifies_highlighter_from_zero_after_later_line_patch() {
let before = "// earlier\n// unmodified\n@a";
let after = "// earlier\n// unmodified\n@none";
let mut editor = text_editor::Content::<FontLayout>::with_text(before);
let limits = layout::Limits::new(Size::ZERO, Size::new(400., 60.));
let mut tree = {
let mut widget = view(&editor, Arc::from(before));
let mut tree = Tree::new(widget.as_widget());
let _ = widget
.as_widget_mut()
.layout(&mut tree, &FontLayout, &limits);
tree
};
CHANGES.with(|changes| changes.borrow_mut().clear());
assert!(replace(&mut editor, before, after));
{
let mut widget = view(&editor, Arc::from(after));
let _ = widget
.as_widget_mut()
.layout(&mut tree, &FontLayout, &limits);
}
// Font/metrics/bounds are already stable. This notification must come
// from the helper's empty edit, not a fresh renderer initialization.
CHANGES.with(|changes| assert_eq!(*changes.borrow(), [0]));
assert_eq!(editor.text(), after);
}
}
@@ -0,0 +1,448 @@
//! Combined App-boundary oracles: the workers are completed independently and
//! no returned Task is polled. Rendering measurements use the real reveal path.
use super::*;
use crate::diagram_usage::{self as usage, Surface, Target as UsageTarget, Usage};
use iced::{Size, Vector};
fn model_id(number: u8) -> Id {
Id::parse_str(&format!("00000000-0000-0000-0000-{number:012}")).unwrap()
}
fn navigation_fixture() -> App {
let mut source = bundle();
let entry = source.files.get_mut("project.arcadia").unwrap();
*entry = entry.replace(
"include \"uses.arcadia\";",
"include \"uses.arcadia\"; include \"views.arcadia\";",
);
source.files.insert(
"views.arcadia".into(),
concat!(
"// Keep @pump and the exact distant placement.\r\n",
"diagram overview \"Pump overview\" id \"00000000-0000-0000-0000-000000000008\" { layer Oa; }\r\n",
"node placed_pump id \"00000000-0000-0000-0000-000000000009\" { diagram @overview; element @pump; position { x 12000; y 9000; }; size { width 260; height 94; }; }\r\n",
).into(),
);
let mut app = fixture();
app.document = Document::from_bundle(source.clone(), LIMITS).unwrap();
assert_eq!(app.document.bundle(), &source);
assert!(
!validate(app.project())
.iter()
.any(|issue| issue.severity == validation::Severity::Error)
);
app.source_workspace = Workspace::new(app.document.bundle(), true);
app.selected = app.project().id;
app.refresh_draft();
app.source_workspace.active = "uses.arcadia".into();
app.show_source_file(false);
seek_pump(&mut app);
app.canvas_size = Some(Size::new(1200., 700.));
app.zoom = 0.8;
app.pan = Vector::new(31., 47.);
app
}
fn seek_pump(app: &mut App) {
let source = app.source_workspace.text();
let offset = source.rfind("pump;").unwrap();
app.source_editor
.move_to(cursor(source, offset, offset + 4));
}
fn source_completion(app: &App, request: nav::Request) -> nav::Completion {
let snapshot = Arc::new(
app.with_source_input(|input| query::capture(input, query::Limits::default()).unwrap()),
);
let results = snapshot
.query(query::Query {
stamp: request.stamp,
path: &request.path,
byte_offset: request.offset,
action: request.action,
})
.unwrap();
nav::Completion {
request,
result: Ok((snapshot, Arc::new(results))),
}
}
fn diagram_completion(app: &App) -> usage::Completion {
let panel = app.diagram_usages.as_ref().unwrap();
let process = app.behavior.topology.usage_snapshot().into_query_state();
usage::Completion {
token: panel.token,
context: panel.context,
result: Ok(usage::collect(
app.project(),
panel.context.subject,
&process,
&behavior::scenario::State::default(),
)),
}
}
fn finish_lookup(app: &mut App) -> (Id, Usage) {
let completion = diagram_completion(app);
send(app, Message::DiagramUsagesReady(completion));
let panel = app.diagram_usages.as_ref().unwrap();
assert!(!panel.loading);
assert!(app.diagram_lookup_in_flight.is_none());
let found = panel
.usages
.iter()
.find(|u| {
u.surface == Surface::Saved(model_id(8))
&& u.target == UsageTarget::Placement(model_id(9))
})
.expect("the exact placed pump is represented")
.clone();
assert_eq!(found.subject, model_id(2));
assert_eq!((found.bounds.x, found.bounds.y), (12000., 9000.));
(panel.token, found)
}
fn navigate(app: &mut App) -> Usage {
let message = app.diagram_usage_message(model_id(2));
send(app, message);
let (token, found) = finish_lookup(app);
send(
app,
Message::DiagramUsage(usage::Event::Choose(token, found.clone())),
);
assert_eq!(app.page, Page::Diagram);
assert_eq!(app.active_diagram, Some(model_id(8)));
assert_eq!(app.selected, model_id(2));
assert_eq!(app.selected_node, Some(model_id(9)));
assert!(app.diagram_usages.is_none());
assert!(app.pending_diagram_reveal.is_some());
assert!(app.diagram_focus(found.surface).is_some());
found
}
#[derive(Debug, PartialEq)]
struct Navigation {
page: Page,
selected: Id,
placement: Option<Id>,
diagram: Option<Id>,
zoom: f32,
pan: Vector,
pending: Option<(usage::Context, Usage, u64)>,
focus: Option<(usage::Context, Usage, Id)>,
canvas_epoch: u64,
camera_epoch: u64,
source_interaction: Id,
source_reveal: Option<Id>,
}
fn navigation(app: &App) -> Navigation {
Navigation {
page: app.page,
selected: app.selected,
placement: app.selected_node,
diagram: app.active_diagram,
zoom: app.zoom,
pan: app.pan,
pending: app.pending_diagram_reveal.clone(),
focus: app
.diagram_navigation_focus
.as_ref()
.map(|f| (f.context, f.usage.clone(), f.selected)),
canvas_epoch: app.canvas_epoch,
camera_epoch: app.camera_epoch,
source_interaction: app.source_symbols.interaction,
source_reveal: app.source_reveal_ticket(),
}
}
fn first_measurement(app: &mut App, found: &Usage) {
let (context, _, epoch) = app.pending_diagram_reveal.as_ref().unwrap();
let (context, epoch) = (*context, *epoch);
let actual = Size::new(500., 300.);
send(app, Message::DiagramRevealViewport(context, epoch, actual));
assert!(app.pending_diagram_reveal.is_none());
assert_eq!(app.canvas_size, Some(actual));
assert!((found.bounds.center_x() * app.zoom + app.pan.x - 250.).abs() < 0.05);
assert!((found.bounds.center_y() * app.zoom + app.pan.y - 150.).abs() < 0.05);
assert_eq!(app.selected, model_id(2));
assert_eq!(app.selected_node, Some(model_id(9)));
assert!(app.diagram_focus(found.surface).is_some());
}
#[test]
fn rename_modal_rejects_queued_diagram_and_source_navigation_without_losing_approval() {
let mut app = navigation_fixture();
let original = evidence(&app);
let snapshot = app.document.snapshot();
let queued = app.diagram_usage_message(model_id(2));
begin(&mut app);
input(&mut app, "send_water");
let confirm = preview(&mut app);
let form = app.source_rename.form.as_ref().unwrap();
let approved = (
form.invocation,
form.generation,
form.approval.as_ref().unwrap().id,
);
send(&mut app, queued);
let source = app.source_symbol_message(nav::SymbolEvent::DiagramUsages);
send(&mut app, source);
let definition = app.source_symbol_message(nav::SymbolEvent::Query(query::Action::Definition));
send(&mut app, definition);
assert!(app.diagram_usages.is_none());
assert!(app.diagram_lookup_in_flight.is_none());
assert!(app.source_symbols.in_flight.is_none());
assert!(!app.source_navigation_available());
let form = app.source_rename.form.as_ref().unwrap();
assert_eq!(
(
form.invocation,
form.generation,
form.approval.as_ref().unwrap().id
),
approved
);
assert_eq!(form.replacement, "send_water");
assert_eq!(evidence(&app), original);
assert!(Arc::ptr_eq(&snapshot, &app.document.snapshot()));
send(&mut app, Message::Escape);
send(&mut app, confirm);
assert!(!app.source_rename.open());
assert_eq!(evidence(&app), original);
}
#[test]
fn loading_and_ready_diagram_choosers_reject_a_previously_captured_rename_begin() {
for finish in [false, true] {
let mut app = navigation_fixture();
let before = evidence(&app);
let queued_rename = app.source_rename_message();
let open = app.diagram_usage_message(model_id(2));
send(&mut app, open);
if finish {
finish_lookup(&mut app);
}
let panel = app.diagram_usages.as_ref().unwrap();
let token = panel.token;
let loading = panel.loading;
assert!(app.rename_admission().is_err());
assert!(!app.source_navigation_available());
send(&mut app, queued_rename);
assert!(!app.source_rename.open());
assert!(app.source_rename.in_flight.is_none());
let panel = app.diagram_usages.as_ref().unwrap();
assert_eq!((panel.token, panel.loading), (token, loading));
assert_eq!(evidence(&app), before);
send(&mut app, Message::DiagramUsage(usage::Event::Close(token)));
let rename = app.source_rename_message();
send(&mut app, rename);
assert!(
app.source_rename.open(),
"the dismissed chooser no longer owns input"
);
}
}
#[test]
fn late_target_and_preview_results_release_only_their_slot_and_preserve_first_camera_measurement() {
for preview_pending in [false, true] {
let mut app = navigation_fixture();
let before = evidence(&app);
let completion = if preview_pending {
begin(&mut app);
input(&mut app, "send_water");
let job = start_preview(&mut app);
preview_completion(&app, job)
} else {
let rename = app.source_rename_message();
send(&mut app, rename);
target_completion(&app, current_job(&app))
};
let occupied = completion.job.id;
event(&mut app, Event::Close(completion.job.invocation));
assert_eq!(app.source_rename.in_flight, Some(occupied));
let found = navigate(&mut app);
let location = navigation(&app);
assert_eq!(evidence(&app), before);
let mut forged = completion.clone();
forged.job.id = Id::new_v4();
send(&mut app, Message::SourceRenameReady(forged));
assert_eq!(app.source_rename.in_flight, Some(occupied));
assert_eq!(navigation(&app), location);
send(&mut app, Message::SourceRenameReady(completion.clone()));
assert!(app.source_rename.in_flight.is_none());
assert!(!app.source_rename.open());
assert_eq!(navigation(&app), location);
assert_eq!(evidence(&app), before);
send(&mut app, Message::SourceRenameReady(completion));
assert_eq!(
navigation(&app),
location,
"duplicate completion is passive too"
);
first_measurement(&mut app, &found);
assert_eq!(evidence(&app), before);
}
}
#[test]
fn stale_begin_close_and_actual_confirm_leave_new_diagram_focus_and_pending_measurement_unchanged()
{
let mut app = navigation_fixture();
let before = evidence(&app);
let snapshot = app.document.snapshot();
let queued_begin = app.source_rename_message();
begin(&mut app);
input(&mut app, "send_water");
let confirm = preview(&mut app);
let invocation = app.source_rename.form.as_ref().unwrap().invocation;
event(&mut app, Event::Close(invocation));
let found = navigate(&mut app);
let location = navigation(&app);
for stale in [
queued_begin,
Message::SourceRename(Event::Close(invocation)),
confirm,
] {
send(&mut app, stale);
assert_eq!(navigation(&app), location);
assert_eq!(evidence(&app), before);
assert!(Arc::ptr_eq(&snapshot, &app.document.snapshot()));
}
first_measurement(&mut app, &found);
}
#[test]
fn document_reset_retains_all_three_worker_slots_until_their_exact_results_arrive() {
let mut app = navigation_fixture();
let source = app.source_symbol_message(nav::SymbolEvent::Query(query::Action::Definition));
send(&mut app, source);
let request = app.source_symbols.request.clone().unwrap();
let source_ready = source_completion(&app, request.clone());
let rename = app.source_rename_message();
send(&mut app, rename);
let rename_ready = target_completion(&app, current_job(&app));
let rename_job = rename_ready.job.id;
event(&mut app, Event::Close(rename_ready.job.invocation));
let open = app.diagram_usage_message(model_id(2));
send(&mut app, open);
let old_diagram = diagram_completion(&app);
let diagram_job = old_diagram.token;
let old_document = app.canvas_document;
let project = app.project().clone();
app.reset(project, None);
assert_ne!(app.canvas_document, old_document);
assert_eq!(app.source_symbols.in_flight, Some(request.job));
assert_eq!(app.source_rename.in_flight, Some(rename_job));
assert_eq!(app.diagram_lookup_in_flight, Some(diagram_job));
assert!(!app.source_rename.open());
assert!(app.diagram_usages.is_none());
let before = evidence(&app);
let snapshot = app.document.snapshot();
let new_open = app.diagram_usage_message(model_id(2));
send(&mut app, new_open);
let new_token = app.diagram_usages.as_ref().unwrap().token;
assert_ne!(new_token, diagram_job);
assert_eq!(
app.diagram_lookup_in_flight,
Some(diagram_job),
"replacement waits for old worker"
);
send(&mut app, Message::SourceRenameReady(rename_ready));
assert!(app.source_rename.in_flight.is_none());
assert_eq!(app.source_symbols.in_flight, Some(request.job));
assert_eq!(app.diagram_lookup_in_flight, Some(diagram_job));
send(&mut app, Message::SourceSymbolsReady(source_ready));
assert!(app.source_symbols.in_flight.is_none());
assert!(!app.source_symbols.open);
send(&mut app, Message::DiagramUsagesReady(old_diagram));
assert_eq!(app.diagram_lookup_in_flight, Some(new_token));
assert!(app.diagram_usages.as_ref().unwrap().loading);
finish_lookup(&mut app);
assert_eq!(evidence(&app), before);
assert!(Arc::ptr_eq(&snapshot, &app.document.snapshot()));
assert!(!app.source_rename.open());
}
#[test]
fn rename_then_exact_caret_diagram_navigation_preserves_complete_model_undo_redo_history() {
let mut app = navigation_fixture();
let baseline = app.document.snapshot();
let project = app.project().id;
app.document
.execute(Command::Rename(project, "Previously accepted name".into()))
.unwrap();
app.sync_source();
app.refresh_draft();
seek_pump(&mut app);
let before = app.document.snapshot();
let mut expected_project = app.project().clone();
expected_project
.elements
.get_mut(&model_id(2))
.unwrap()
.alias = "send_water".into();
let mut expected_source = app.document.bundle().clone();
let defs = expected_source.files.get_mut("defs.arcadia").unwrap();
*defs = defs.replace("oa_activity pump ", "oa_activity send_water ");
let uses = expected_source.files.get_mut("uses.arcadia").unwrap();
*uses = uses
.replace("source @pump;", "source @send_water;")
.replace("// keep @pump\r\n pump;", "// keep @pump\r\n send_water;");
let views = expected_source.files.get_mut("views.arcadia").unwrap();
*views = views.replace("element @pump;", "element @send_water;");
begin(&mut app);
input(&mut app, "send_water");
let confirm = preview(&mut app);
send(&mut app, confirm);
assert_eq!(app.project(), &expected_project);
assert_eq!(app.document.bundle(), &expected_source);
let renamed = app.document.snapshot();
let accepted = evidence(&app);
let queued_undo_rename = app
.source_rename_undo_message()
.expect("fresh rename offers its guarded undo");
let message = app.source_symbol_message(nav::SymbolEvent::DiagramUsages);
send(&mut app, message);
let request = app
.source_symbols
.request
.clone()
.expect("renamed exact caret remains queryable");
let ready = source_completion(&app, request);
send(&mut app, Message::SourceSymbolsReady(ready));
let (token, found) = finish_lookup(&mut app);
send(
&mut app,
Message::DiagramUsage(usage::Event::Choose(token, found.clone())),
);
assert_eq!(app.selected, model_id(2));
assert_eq!(app.selected_node, Some(model_id(9)));
let destination = navigation(&app);
send(&mut app, queued_undo_rename);
assert_eq!(navigation(&app), destination);
assert_eq!(evidence(&app), accepted);
first_measurement(&mut app, &found);
assert_eq!(evidence(&app), accepted);
assert!(Arc::ptr_eq(&renamed, &app.document.snapshot()));
for (message, expected) in [
(Message::Undo, before.clone()),
(Message::Redo, renamed.clone()),
(Message::Undo, before.clone()),
(Message::Undo, baseline.clone()),
(Message::Redo, before),
(Message::Redo, renamed),
] {
send(&mut app, message);
assert!(
Arc::ptr_eq(&expected, &app.document.snapshot()),
"exact history entry restored"
);
assert_eq!(app.project(), &expected.decoded.project);
assert_eq!(app.document.bundle(), &expected.bundle);
assert_eq!(app.source_workspace.bundle(), expected.bundle);
assert!(!app.source_dirty());
}
}
@@ -0,0 +1,449 @@
//! Real paragraph advances and clipping through the actual preview dialog.
//! Native screenshots additionally check raster ink and the platform renderer.
use super::*;
use crate::views::source_tests::FontLayout;
use iced::advanced::{Layout, layout, renderer, text as shaped, widget};
use iced::{Background, Color, Font, Pixels, Point, Rectangle, Size, Transformation, Vector};
use std::collections::{BTreeMap, BTreeSet};
struct Glyph {
text: String,
paragraph_y: u32,
line: usize,
bytes: std::ops::Range<usize>,
bounds: Rectangle,
clip: Rectangle,
}
struct GlyphRecorder {
wanted: BTreeSet<String>,
transforms: Vec<Transformation>,
clips: Vec<Rectangle>,
glyphs: Vec<Glyph>,
quads: Vec<Rectangle>,
}
impl GlyphRecorder {
fn new(wanted: BTreeSet<String>, viewport: Rectangle) -> Self {
Self {
wanted,
transforms: vec![Transformation::IDENTITY],
clips: vec![viewport],
glyphs: vec![],
quads: vec![],
}
}
fn transform(&self) -> Transformation {
*self.transforms.last().unwrap()
}
fn clip(&self, bounds: Rectangle) -> Rectangle {
self.clips
.last()
.unwrap()
.intersection(&(bounds * self.transform()))
.unwrap_or(Rectangle::with_size(Size::ZERO))
}
}
impl renderer::Renderer for GlyphRecorder {
fn start_layer(&mut self, bounds: Rectangle) {
self.clips.push(self.clip(bounds));
}
fn end_layer(&mut self) {
self.clips.pop().unwrap();
}
fn start_transformation(&mut self, transform: Transformation) {
self.transforms.push(self.transform() * transform);
}
fn end_transformation(&mut self) {
self.transforms.pop().unwrap();
}
fn reset(&mut self, bounds: Rectangle) {
self.transforms = vec![Transformation::IDENTITY];
self.clips = vec![bounds];
self.glyphs.clear();
self.quads.clear();
}
fn fill_quad(&mut self, quad: renderer::Quad, _: impl Into<Background>) {
self.quads.push(quad.bounds * self.transform());
}
fn allocate_image(
&mut self,
handle: &iced::advanced::image::Handle,
callback: impl FnOnce(Result<iced::advanced::image::Allocation, iced::advanced::image::Error>)
+ Send
+ 'static,
) {
renderer::Renderer::allocate_image(&mut FontLayout, handle, callback);
}
}
impl shaped::Renderer for GlyphRecorder {
type Font = Font;
type Paragraph = <FontLayout as shaped::Renderer>::Paragraph;
type Editor = <FontLayout as shaped::Renderer>::Editor;
const ICON_FONT: Font = <FontLayout as shaped::Renderer>::ICON_FONT;
const CHECKMARK_ICON: char = <FontLayout as shaped::Renderer>::CHECKMARK_ICON;
const ARROW_DOWN_ICON: char = <FontLayout as shaped::Renderer>::ARROW_DOWN_ICON;
const SCROLL_UP_ICON: char = <FontLayout as shaped::Renderer>::SCROLL_UP_ICON;
const SCROLL_DOWN_ICON: char = <FontLayout as shaped::Renderer>::SCROLL_DOWN_ICON;
const SCROLL_LEFT_ICON: char = <FontLayout as shaped::Renderer>::SCROLL_LEFT_ICON;
const SCROLL_RIGHT_ICON: char = <FontLayout as shaped::Renderer>::SCROLL_RIGHT_ICON;
const ICED_LOGO: char = <FontLayout as shaped::Renderer>::ICED_LOGO;
fn default_font(&self) -> Font {
shaped::Renderer::default_font(&FontLayout)
}
fn default_size(&self) -> Pixels {
shaped::Renderer::default_size(&FontLayout)
}
fn fill_paragraph(
&mut self,
paragraph: &Self::Paragraph,
position: Point,
_: Color,
clip: Rectangle,
) {
let buffer = paragraph.buffer();
let text = buffer
.lines
.iter()
.map(|line| line.text())
.collect::<Vec<_>>()
.join("\n");
if !self.wanted.contains(&text) {
return;
}
let clip = self.clip(clip);
for run in buffer.layout_runs() {
for glyph in run.glyphs {
if run.text[glyph.start..glyph.end]
.chars()
.all(char::is_whitespace)
{
continue;
}
let bounds = Rectangle {
x: position.x + glyph.x + glyph.x_offset * glyph.font_size,
y: position.y + run.line_top,
width: glyph.w,
height: run.line_height,
} * self.transform();
self.glyphs.push(Glyph {
text: text.clone(),
paragraph_y: position.y.to_bits(),
line: run.line_i,
bytes: glyph.start..glyph.end,
bounds,
clip,
});
}
}
}
fn fill_editor(
&mut self,
editor: &Self::Editor,
position: Point,
color: Color,
clip: Rectangle,
) {
shaped::Renderer::fill_editor(&mut FontLayout, editor, position, color, clip);
}
fn fill_text(&mut self, text: shaped::Text, position: Point, color: Color, clip: Rectangle) {
shaped::Renderer::fill_text(&mut FontLayout, text, position, color, clip);
}
}
#[derive(Default)]
struct ScrollGeometry {
id: Option<widget::Id>,
bounds: Option<Rectangle>,
content: Option<Rectangle>,
offset: Vector,
}
impl widget::Operation for ScrollGeometry {
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn widget::Operation)) {
visit(self);
}
fn scrollable(
&mut self,
id: Option<&widget::Id>,
bounds: Rectangle,
content: Rectangle,
offset: Vector,
_: &mut dyn widget::operation::Scrollable,
) {
if id == self.id.as_ref() {
self.bounds = Some(bounds);
self.content = Some(content);
self.offset = offset;
}
}
}
fn normalized(text: &str) -> String {
text.replace("\r\n", "\n").replace('\r', "\n")
}
fn required_chars(text: &str) -> BTreeSet<(usize, usize)> {
text.split('\n')
.enumerate()
.flat_map(|(line, text)| {
text.char_indices()
.filter(|(_, ch)| !ch.is_whitespace())
.map(move |(byte, _)| (line, byte))
})
.collect()
}
fn declarations(long: bool) -> App {
let name = if long {
format!("Assess {} request", "界🚀".repeat(90))
} else {
"Assess 救援 request".into()
};
let source = SourceBundle { entry: "project.arcadia".into(), files: BTreeMap::from([
("project.arcadia".into(), "arcadia 7;\r\nproject navigation_trial \"Navigation — 救援\" id \"9e8f0000-0000-4000-8000-000000000001\" {\r\n include \"救援/assess.arcadia\";\r\n}\r\n".into()),
("救援/assess.arcadia".into(), format!("// Comment @assess must not be a lexical use.\r\noa_activity assess \"{name}\" id \"9e8f0000-0000-4000-8000-000000000002\" {{\r\n description \"Keep @assess as prose, not a use.\";\r\n}}\r\n")),
]) };
let mut app = fixture();
app.document = Document::from_bundle(source, LIMITS).unwrap();
app.source_workspace = Workspace::new(app.document.bundle(), true);
app.source_workspace.active = "救援/assess.arcadia".into();
app.selected = app.project().id;
app.refresh_draft();
app.show_source_file(false);
let start = app
.source_workspace
.text()
.find("oa_activity assess")
.unwrap()
+ "oa_activity ".len();
app.source_editor.move_to(cursor(
app.source_workspace.text(),
start,
start + "assess".len(),
));
begin(&mut app);
let replacement = if long {
"a".repeat(255)
} else {
"assess_damage".into()
};
input(&mut app, &replacement);
let _ = preview(&mut app);
app
}
fn assert_page_glyphs_visible(app: &App, host: Size) {
let form = app.source_rename.form.as_ref().unwrap();
let ready = form.approval.as_ref().unwrap();
let file = &ready.preview.files()[form.file];
let mut expected = BTreeMap::<String, usize>::new();
for line in file.hunks()[form.hunk]
.lines()
.iter()
.skip(form.line)
.take(LINES_PER_PAGE)
{
for source in [
line.before_text(&ready.prepared.before().bundle.files[file.path()])
.unwrap(),
line.after_text(&ready.prepared.after().bundle.files[file.path()])
.unwrap(),
] {
if let Some(fragment) = preview::text_segment(source, form.segment) {
let text = normalized(fragment);
if !required_chars(&text).is_empty() {
*expected.entry(text).or_default() += 1;
}
}
}
}
assert!(!expected.is_empty());
let viewport = Rectangle::new(Point::ORIGIN, host);
let mut renderer = GlyphRecorder::new(expected.keys().cloned().collect(), viewport);
let editor = text_editor::Content::<GlyphRecorder>::new();
let mut view = dialog_content(app, &editor);
let mut tree = widget::Tree::new(view.as_widget());
let node =
view.as_widget_mut()
.layout(&mut tree, &renderer, &layout::Limits::new(Size::ZERO, host));
let mut geometry = ScrollGeometry {
id: Some(preview_scroll_id(form, ready.id)),
..ScrollGeometry::default()
};
view.as_widget_mut()
.operate(&mut tree, Layout::new(&node), &renderer, &mut geometry);
let bounds = geometry.bounds.unwrap();
let content = geometry.content.unwrap();
assert!(bounds.height >= 40.);
let maximum = (content.height - bounds.height).max(0.);
let steps = (maximum / (bounds.height * 0.5)).ceil() as usize;
let mut visible = BTreeMap::<(String, u32), BTreeSet<(usize, usize)>>::new();
let mut reserved_bar_gap = true;
for step in 0..=steps {
let offset = (step as f32 * bounds.height * 0.5).min(maximum);
view.as_widget_mut().operate(
&mut tree,
Layout::new(&node),
&renderer,
&mut widget::operation::scrollable::scroll_to(
preview_scroll_id(form, ready.id),
widget::operation::scrollable::AbsoluteOffset {
x: None,
y: Some(offset),
},
),
);
view.as_widget_mut()
.operate(&mut tree, Layout::new(&node), &renderer, &mut geometry);
assert!(
(geometry.offset.y - offset).abs() <= 0.5,
"actual scroll operation reaches the requested source offset"
);
renderer::Renderer::reset(&mut renderer, viewport);
view.as_widget().draw(
&tree,
&mut renderer,
&iced::Theme::Light,
&renderer::Style::default(),
Layout::new(&node),
iced::mouse::Cursor::Unavailable,
&viewport,
);
// The vertical scrollbar's actual painted thumb fixes its excluded x
// region; the outer scroll clip alone cannot detect overpainting.
let scrollbar_left = renderer
.quads
.iter()
.filter(|quad| {
quad.width > 0.
&& quad.width <= 16.
&& quad.height > 0.
&& quad.x >= bounds.x + bounds.width - 18.
&& quad.x + quad.width <= bounds.x + bounds.width + 0.5
&& quad.y >= bounds.y - 0.5
&& quad.y + quad.height <= bounds.y + bounds.height + 0.5
})
.map(|quad| quad.x)
.min_by(f32::total_cmp);
if maximum > 0. {
let left = scrollbar_left.expect("overflowing actual preview must paint its scrollbar");
reserved_bar_gap &= content.x + content.width <= left - 7.5;
}
for glyph in &renderer.glyphs {
let right = glyph.bounds.x + glyph.bounds.width;
if let Some(left) = scrollbar_left {
assert!(
right <= left - 0.5,
"source glyph under scrollbar: text={:?} cluster={:?} glyph={:?} bar x={left}",
glyph.text,
glyph.bytes,
glyph.bounds
);
}
assert!(
glyph.bounds.x >= content.x - 0.5 && right <= content.x + content.width + 0.5,
"source glyph escaped reserved width: {:?} {:?}",
glyph.text,
glyph.bounds
);
assert!(
glyph.bounds.x >= glyph.clip.x - 0.5
&& right <= glyph.clip.x + glyph.clip.width + 0.5,
"source glyph horizontally clipped: {:?} {:?} {:?}",
glyph.text,
glyph.bounds,
glyph.clip
);
if glyph.bounds.y >= glyph.clip.y - 0.5
&& glyph.bounds.y + glyph.bounds.height <= glyph.clip.y + glyph.clip.height + 0.5
{
let logical_line = glyph.text.split('\n').nth(glyph.line).unwrap();
let chars = logical_line
.char_indices()
.filter(|(byte, ch)| glyph.bytes.contains(byte) && !ch.is_whitespace())
.map(|(byte, _)| (glyph.line, byte));
visible
.entry((glyph.text.clone(), glyph.paragraph_y))
.or_default()
.extend(chars);
}
}
}
assert!(
reserved_bar_gap,
"source content must reserve actual scrollbar width plus gap"
);
assert!(
bounds.width >= host.width - 30.,
"preview must use available dialog width: {bounds:?} / {host:?}"
);
for (text, count) in expected {
let required = required_chars(&text);
let instances: Vec<_> = visible
.iter()
.filter(|((value, _), _)| value == &text)
.collect();
assert_eq!(
instances.len(),
count,
"all before/after occurrences must be seen: {text:?}"
);
for (_, seen) in instances {
assert_eq!(
seen, &required,
"every non-whitespace source cluster must be fully visible at some vertical offset: {text:?}"
);
}
}
}
#[test]
fn actual_preview_source_glyphs_clear_scrollbar_and_all_long_fragments_are_reachable() {
for long in [false, true] {
let mut app = declarations(long);
let ready = app
.source_rename
.form
.as_ref()
.unwrap()
.approval
.as_ref()
.unwrap()
.clone();
let invocation = app.source_rename.form.as_ref().unwrap().invocation;
let before = evidence(&app);
for (width, height) in [(1100., 700.), (580., 700.)] {
app.window_size = Size::new(width, height);
let host = Size::new((width - 84.).min(708.), height - 120.);
let total = segment_total(app.source_rename.form.as_ref().unwrap());
if long {
assert!(
total >= 3,
"include unbroken ASCII and wide Unicode across segments"
);
}
for segment in 0..total {
assert_page_glyphs_visible(&app, host);
if segment + 1 < total {
event(&mut app, Event::Segment(invocation, ready.id, true));
}
}
for _ in 1..total {
event(&mut app, Event::Segment(invocation, ready.id, false));
}
}
assert_eq!(
evidence(&app),
before,
"preview geometry/paging cannot alter transaction inputs"
);
assert_eq!(
app.source_rename
.form
.as_ref()
.unwrap()
.approval
.as_ref()
.unwrap()
.id,
ready.id
);
}
}
@@ -0,0 +1,385 @@
//! Execute the controller's scroll operations on a retained, scrolled dialog.
use super::*;
use crate::views::source_tests::FontLayout;
use iced::advanced::{Layout, layout, widget};
use iced::futures::StreamExt;
use iced::{Rectangle, Size, Vector};
const HOST: Size = Size::new(720., 660.);
fn paged_fixture() -> App {
let mut source = bundle();
let name = "Long engineering name 雨 ".repeat(18);
source.files.get_mut("defs.arcadia").unwrap().replace_range(
..,
&format!(
"oa_activity pump \"{name}\" id \"00000000-0000-0000-0000-000000000002\" {{}}\r\n\
oa_activity backup \"Backup\" id \"00000000-0000-0000-0000-000000000004\" {{}}\r\n"
),
);
let mut uses = String::new();
for index in 0..16 {
if index == 12 {
uses.push_str(&"// unchanged gap\r\n".repeat(10));
}
uses.push_str(&format!(
"interaction use_{index} \"{name}\" id \"00000000-0000-0000-0001-{index:012}\" {{ source @pump; target @backup; }}\r\n"
));
}
source.files.insert("uses.arcadia".into(), uses);
let mut app = fixture();
app.window_size = HOST;
app.document = Document::from_bundle(source, LIMITS).unwrap();
app.source_workspace = Workspace::new(app.document.bundle(), true);
app.source_workspace.active = "uses.arcadia".into();
app.show_source_file(false);
let start = app.source_workspace.text().find("source @pump").unwrap() + "source @".len();
app.source_editor
.move_to(cursor(app.source_workspace.text(), start, start + 4));
begin(&mut app);
input(&mut app, "pump_long");
let _ = preview(&mut app);
app
}
fn scroll_id(app: &App) -> widget::Id {
let form = app.source_rename.form.as_ref().unwrap();
preview_scroll_id(form, form.approval.as_ref().unwrap().id)
}
struct Geometry {
id: widget::Id,
offset: Option<Vector>,
}
impl widget::Operation for Geometry {
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn widget::Operation)) {
visit(self);
}
fn scrollable(
&mut self,
id: Option<&widget::Id>,
_: Rectangle,
_: Rectangle,
offset: Vector,
_: &mut dyn widget::operation::Scrollable,
) {
if id == Some(&self.id) {
self.offset = Some(offset);
}
}
}
struct Mounted {
editor: text_editor::Content<FontLayout>,
tree: widget::Tree,
}
impl Mounted {
fn new(app: &App) -> Self {
let editor = text_editor::Content::<FontLayout>::new();
let tree = widget::Tree::new(dialog_content(app, &editor).as_widget());
Self { editor, tree }
}
fn operate(&mut self, app: &App, operation: &mut dyn widget::Operation) {
let mut view = dialog_content(app, &self.editor);
// Preserve the actual scroll state across the new page's view diff.
self.tree.diff(view.as_widget());
let node = view.as_widget_mut().layout(
&mut self.tree,
&FontLayout,
&layout::Limits::new(Size::ZERO, HOST),
);
view.as_widget_mut()
.operate(&mut self.tree, Layout::new(&node), &FontLayout, operation);
}
fn offset(&mut self, app: &App) -> f32 {
let mut geometry = Geometry {
id: scroll_id(app),
offset: None,
};
self.operate(app, &mut geometry);
geometry.offset.expect("mounted preview scrollable").y
}
fn scroll_down(&mut self, app: &App) -> f32 {
self.operate(
app,
&mut widget::operation::scrollable::scroll_to(
scroll_id(app),
widget::operation::scrollable::AbsoluteOffset {
x: None,
y: Some(10_000.),
},
),
);
let offset = self.offset(app);
assert!(offset > 10., "fixture must really be scrolled");
offset
}
async fn execute(&mut self, app: &App, task: Task<Message>) -> usize {
let mut count = 0;
if let Some(mut actions) = iced_runtime::task::into_stream(task) {
while let Some(action) = actions.next().await {
let iced_runtime::Action::Widget(mut operation) = action else {
panic!("preview paging must only schedule a widget operation");
};
self.operate(app, operation.as_mut());
count += 1;
}
}
count
}
}
#[tokio::test]
async fn explicit_navigation_resets_retained_scroll_but_id_change_alone_does_not() {
let mut app = paged_fixture();
let before = evidence(&app);
let form = app.source_rename.form.as_ref().unwrap();
let invocation = form.invocation;
let approval = form.approval.as_ref().unwrap().id;
let mut mounted = Mounted::new(&app);
mounted.scroll_down(&app);
let old_id = scroll_id(&app);
let task = app.update(Message::SourceRename(Event::File(
invocation, approval, true,
)));
assert_ne!(old_id, scroll_id(&app));
// Negative control: even after diff/layout with the new id and new file,
// Iced retains the previous offset until the returned operation executes.
assert!(mounted.offset(&app) > 10.);
assert_eq!(mounted.execute(&app, task).await, 1);
assert_eq!(mounted.offset(&app), 0.);
for navigation in [
Event::Line(invocation, approval, true),
Event::Segment(invocation, approval, true),
Event::Hunk(invocation, approval, true),
Event::File(invocation, approval, false),
Event::Search(invocation, "uses".into()),
Event::Search(invocation, "no-path-match".into()),
Event::Search(invocation, String::new()),
] {
mounted.scroll_down(&app);
let task = app.update(Message::SourceRename(navigation));
assert_eq!(mounted.execute(&app, task).await, 1);
assert_eq!(mounted.offset(&app), 0.);
assert_eq!(evidence(&app), before);
}
// Passive redraw and unchanged filter input preserve deliberate scrolling.
let offset = mounted.scroll_down(&app);
assert_eq!(mounted.offset(&app), offset);
let task = app.update(Message::SourceRename(Event::Search(
invocation,
String::new(),
)));
assert_eq!(mounted.execute(&app, task).await, 0);
assert_eq!(mounted.offset(&app), offset);
let task = app.update(Message::SourceRename(Event::Input(
invocation,
"pump_long".into(),
)));
assert_eq!(mounted.execute(&app, task).await, 0);
assert_eq!(mounted.offset(&app), offset);
let task = app.update(Message::SourceRename(Event::File(
invocation, approval, false,
)));
assert_eq!(
mounted.execute(&app, task).await,
0,
"clamped first-page request is a no-op"
);
assert_eq!(mounted.offset(&app), offset);
assert_eq!(evidence(&app), before);
}
#[tokio::test]
async fn delayed_page_and_approval_operations_cannot_reset_a_new_destination() {
let mut app = paged_fixture();
let before = evidence(&app);
let form = app.source_rename.form.as_ref().unwrap();
let invocation = form.invocation;
let old_approval = form.approval.as_ref().unwrap().id;
let mut mounted = Mounted::new(&app);
let old_page_task = app.update(Message::SourceRename(Event::File(
invocation,
old_approval,
true,
)));
let next_page_task = app.update(Message::SourceRename(Event::Line(
invocation,
old_approval,
true,
)));
assert_eq!(mounted.execute(&app, next_page_task).await, 1);
let offset = mounted.scroll_down(&app);
assert_eq!(mounted.execute(&app, old_page_task).await, 1);
assert_eq!(mounted.offset(&app), offset, "old page target is absent");
let old_approval_task = app.update(Message::SourceRename(Event::Search(
invocation,
"defs".into(),
)));
input(&mut app, "pump_new");
let _ = preview(&mut app); // Real new approval in the same invocation/page.
assert_ne!(
app.source_rename
.form
.as_ref()
.unwrap()
.approval
.as_ref()
.unwrap()
.id,
old_approval
);
let offset = mounted.scroll_down(&app);
assert_eq!(mounted.execute(&app, old_approval_task).await, 1);
assert_eq!(
mounted.offset(&app),
offset,
"old approval target is absent"
);
let task = app.update(Message::SourceRename(Event::File(
invocation,
old_approval,
true,
)));
assert_eq!(
mounted.execute(&app, task).await,
0,
"stale envelope is rejected before scheduling"
);
assert_eq!(mounted.offset(&app), offset);
assert_eq!(evidence(&app), before);
}
#[tokio::test]
async fn repeated_page_visits_filters_and_fast_preview_completion_have_distinct_destinations() {
let mut app = paged_fixture();
let before = evidence(&app);
let form = app.source_rename.form.as_ref().unwrap();
let invocation = form.invocation;
let approval = form.approval.as_ref().unwrap().id;
let mut mounted = Mounted::new(&app);
let first_visit = app.update(Message::SourceRename(Event::File(
invocation, approval, true,
)));
let next_page = app.update(Message::SourceRename(Event::Line(
invocation, approval, true,
)));
assert_eq!(mounted.execute(&app, next_page).await, 1);
let return_visit = app.update(Message::SourceRename(Event::Line(
invocation, approval, false,
)));
assert_eq!(mounted.execute(&app, return_visit).await, 1);
let offset = mounted.scroll_down(&app);
assert_eq!(mounted.execute(&app, first_visit).await, 1);
assert_eq!(
mounted.offset(&app),
offset,
"old visit to the identical page is stale"
);
let old_filter = app.update(Message::SourceRename(Event::Search(
invocation,
"uses".into(),
)));
let new_filter = app.update(Message::SourceRename(Event::Search(
invocation,
"us".into(),
)));
assert_eq!(mounted.execute(&app, new_filter).await, 1);
let offset = mounted.scroll_down(&app);
assert_eq!(mounted.execute(&app, old_filter).await, 1);
assert_eq!(
mounted.offset(&app),
offset,
"old filter on the same page is stale"
);
input(&mut app, "pump_new");
let job = start_preview(&mut app);
let completion = preview_completion(&app, job);
// No intermediate frame mounts the temporary no-approval view. The old
// Scrollable survives even though the new approval has a different id.
let ready = app.update(Message::SourceRenameReady(completion));
assert!(mounted.offset(&app) > 10.);
assert_eq!(mounted.execute(&app, ready).await, 1);
assert_eq!(mounted.offset(&app), 0.);
assert_eq!(evidence(&app), before);
}
#[tokio::test]
async fn details_round_trip_retires_held_reset_without_changing_page_or_repeated_visibility_scroll()
{
// Exercise both an actually mounted Details view and a fast round trip
// between frames, where Iced retains the same preview Scrollable state.
for mount_details in [true, false] {
let mut app = paged_fixture();
let before = evidence(&app);
let form = app.source_rename.form.as_ref().unwrap();
let invocation = form.invocation;
let approval = form.approval.as_ref().unwrap().id;
let mut mounted = Mounted::new(&app);
mounted.scroll_down(&app);
let held_reset = app.update(Message::SourceRename(Event::File(
invocation, approval, true,
)));
let old_preview = scroll_id(&app);
let form = app.source_rename.form.as_ref().unwrap();
let page = (form.file, form.hunk, form.line, form.segment);
let show_details = app.update(Message::SourceRename(Event::Details(invocation, true)));
assert_eq!(mounted.execute(&app, show_details).await, 0);
let details_visit = scroll_id(&app);
assert_ne!(details_visit, old_preview);
if mount_details {
let mut hidden = Geometry {
id: old_preview.clone(),
offset: None,
};
mounted.operate(&app, &mut hidden);
assert!(
hidden.offset.is_none(),
"Details actually replaced the preview"
);
}
let repeat_details = app.update(Message::SourceRename(Event::Details(invocation, true)));
assert_eq!(mounted.execute(&app, repeat_details).await, 0);
assert_eq!(
scroll_id(&app),
details_visit,
"unchanged visibility keeps its visit"
);
let show_preview = app.update(Message::SourceRename(Event::Details(invocation, false)));
assert_eq!(
mounted.execute(&app, show_preview).await,
0,
"returning does not force a scroll reset"
);
let returned_visit = scroll_id(&app);
assert_ne!(returned_visit, old_preview);
assert_ne!(returned_visit, details_visit);
let form = app.source_rename.form.as_ref().unwrap();
assert_eq!((form.file, form.hunk, form.line, form.segment), page);
assert_eq!(form.approval.as_ref().unwrap().id, approval);
let offset = mounted.scroll_down(&app);
let repeat_preview = app.update(Message::SourceRename(Event::Details(invocation, false)));
assert_eq!(mounted.execute(&app, repeat_preview).await, 0);
assert_eq!(scroll_id(&app), returned_visit);
assert_eq!(
mounted.offset(&app),
offset,
"repeated Preview preserves deliberate scroll"
);
assert_eq!(mounted.execute(&app, held_reset).await, 1);
assert_eq!(
mounted.offset(&app),
offset,
"the previous visit cannot reset the returned preview"
);
assert_eq!(evidence(&app), before);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,313 @@
//! Manual replacement + next-layout/highlight evaluator; no GPU or native paint.
//! Child of source_rename_timing_tests, preserving the separate real App test.
use super::*;
use crate::{highlight, views::source_tests::FontLayout};
use iced::advanced::widget::operation::Focusable;
use iced::advanced::{Layout, graphics, layout, renderer, text as shaped, widget::Tree};
use iced::{
Background, Color, Element, Font, Length, Pixels, Point, Rectangle, Size, Transformation,
};
// The same real Paragraph and Editor as FontLayout. During draw, retain only a
// Weak and fixed geometry; inspect glyphs after timing, never in fill_editor.
#[derive(Default)]
struct ObservedFontLayout {
drawn: Option<(graphics::text::editor::Weak, Point, Rectangle)>,
}
impl renderer::Renderer for ObservedFontLayout {
fn start_layer(&mut self, _: Rectangle) {}
fn end_layer(&mut self) {}
fn start_transformation(&mut self, _: Transformation) {}
fn end_transformation(&mut self) {}
fn reset(&mut self, _: Rectangle) {
self.drawn = None;
}
fn fill_quad(&mut self, _: renderer::Quad, _: impl Into<Background>) {}
fn allocate_image(
&mut self,
handle: &iced::advanced::image::Handle,
callback: impl FnOnce(Result<iced::advanced::image::Allocation, iced::advanced::image::Error>)
+ Send
+ 'static,
) {
renderer::Renderer::allocate_image(&mut FontLayout, handle, callback);
}
}
impl shaped::Renderer for ObservedFontLayout {
type Font = Font;
type Paragraph = <FontLayout as shaped::Renderer>::Paragraph;
type Editor = <FontLayout as shaped::Renderer>::Editor;
const ICON_FONT: Font = <FontLayout as shaped::Renderer>::ICON_FONT;
const CHECKMARK_ICON: char = <FontLayout as shaped::Renderer>::CHECKMARK_ICON;
const ARROW_DOWN_ICON: char = <FontLayout as shaped::Renderer>::ARROW_DOWN_ICON;
const SCROLL_UP_ICON: char = <FontLayout as shaped::Renderer>::SCROLL_UP_ICON;
const SCROLL_DOWN_ICON: char = <FontLayout as shaped::Renderer>::SCROLL_DOWN_ICON;
const SCROLL_LEFT_ICON: char = <FontLayout as shaped::Renderer>::SCROLL_LEFT_ICON;
const SCROLL_RIGHT_ICON: char = <FontLayout as shaped::Renderer>::SCROLL_RIGHT_ICON;
const ICED_LOGO: char = <FontLayout as shaped::Renderer>::ICED_LOGO;
fn default_font(&self) -> Font {
shaped::Renderer::default_font(&FontLayout)
}
fn default_size(&self) -> Pixels {
shaped::Renderer::default_size(&FontLayout)
}
fn fill_paragraph(&mut self, _: &Self::Paragraph, _: Point, _: Color, _: Rectangle) {}
fn fill_editor(&mut self, editor: &Self::Editor, position: Point, _: Color, clip: Rectangle) {
self.drawn = Some((editor.downgrade(), position, clip));
}
fn fill_text(&mut self, _: shaped::Text, _: Point, _: Color, _: Rectangle) {}
}
type Content = text_editor::Content<ObservedFontLayout>;
const HOST: Size = Size::new(640., 350.);
fn view(editor: &Content, source: Arc<str>) -> Element<'_, (), iced::Theme, ObservedFontLayout> {
text_editor(editor)
.on_action(|_| ())
.font(Font::MONOSPACE)
.size(14)
.padding(16)
.height(Length::Fill)
.highlight_with::<highlight::ArcadiaHighlighter>(source, highlight::format)
.into()
}
fn check_text(editor: &Content, expected: &str, cursor: text_editor::Cursor) {
assert_eq!(editor.text(), expected);
assert_eq!(editor.cursor(), cursor);
let lines: Vec<_> = expected.split('\n').collect();
assert_eq!(editor.line_count(), lines.len());
for (index, part) in lines.iter().enumerate() {
let (text, ending) = if index + 1 == lines.len() {
(*part, text_editor::LineEnding::None)
} else if let Some(text) = part.strip_suffix('\r') {
(text, text_editor::LineEnding::CrLf)
} else {
(*part, text_editor::LineEnding::Lf)
};
let actual = editor.line(index).unwrap();
assert_eq!(actual.text, text);
assert_eq!(actual.ending, ending);
}
}
fn check_draw(renderer: &ObservedFontLayout, cursor: text_editor::Cursor, alias: &str) {
let (weak, position, clip) = renderer.drawn.as_ref().expect("real editor draw");
let editor = weak.upgrade().expect("drawn editor is still live");
let buffer = editor.buffer();
assert_eq!(weak.bounds, Size::new(608., 318.));
assert_eq!(buffer.size(), (Some(608.), Some(318.)));
assert_eq!(buffer.metrics().font_size, 14.);
assert_eq!(
buffer.scroll().horizontal,
0.,
"fixture origin fits the viewport"
);
let start = cursor.position;
let end = cursor.selection.expect("approved alias selection");
assert_eq!(start.line, end.line);
assert_eq!(end.column - start.column, alias.len());
let line = &buffer.lines[start.line];
assert_eq!(&line.text()[start.column..end.column], alias);
let color = graphics::text::to_color(Color::from_rgb8(20, 93, 139));
let font = graphics::text::to_attributes(Font::MONOSPACE);
for byte in start.column..end.column {
let attrs = line.attrs_list().get_span(byte);
assert_eq!(
attrs.color_opt,
Some(color),
"accepted alias syntax attributes"
);
assert_eq!(attrs.family, font.family);
}
let mut covered = vec![false; alias.len()];
for run in buffer.layout_runs().filter(|run| run.line_i == start.line) {
for glyph in run.glyphs {
let from = glyph.start.max(start.column);
let to = glyph.end.min(end.column);
if from >= to {
continue;
}
assert_eq!(glyph.color_opt, Some(color), "drawn alias glyph color");
let x = position.x + glyph.x + glyph.x_offset * glyph.font_size;
let y = position.y + run.line_top;
assert!(x >= clip.x - 0.5 && x + glyph.w <= clip.x + clip.width + 0.5);
assert!(y + run.line_height > clip.y && y < clip.y + clip.height);
covered[from - start.column..to - start.column].fill(true);
}
}
assert!(
covered.into_iter().all(|seen| seen),
"entire alias has visible shaped glyphs"
);
// This temporary strong upgrade is dropped before the next editor mutation.
}
#[derive(Clone, Copy)]
enum Arm {
Constructor,
Reuse,
}
impl Arm {
fn name(self) -> &'static str {
match self {
Self::Constructor => "with_text",
Self::Reuse => "same_line_reuse",
}
}
fn phases(self) -> [&'static str; 4] {
match self {
Self::Constructor => [
"constructor_replace_origin",
"constructor_next_layout",
"constructor_highlight_draw",
"constructor_combined",
],
Self::Reuse => [
"reuse_replace_origin",
"reuse_next_layout",
"reuse_highlight_draw",
"reuse_combined",
],
}
}
}
fn trial(
fixture: &Fixture,
cursor: text_editor::Cursor,
arm: Arm,
trial: usize,
samples: &mut Samples,
) {
let before = &fixture.bundle.files[ORIGIN];
let after = &fixture.expected_bundle.files[ORIGIN];
let mut editor = Content::with_text(before);
editor.move_to(fixture.cursor);
let mut renderer = ObservedFontLayout::default();
let theme = iced::Theme::Light;
let defaults = renderer::Style::default();
let viewport = Rectangle::with_size(HOST);
let limits = layout::Limits::new(Size::ZERO, HOST);
let mut tree = {
let mut widget = view(&editor, Arc::from(before.as_str()));
let mut tree = Tree::new(widget.as_widget());
let node = widget.as_widget_mut().layout(&mut tree, &renderer, &limits);
tree.state
.downcast_mut::<text_editor::State<highlight::ArcadiaHighlighter>>()
.focus();
widget.as_widget().draw(
&tree,
&mut renderer,
&theme,
&defaults,
Layout::new(&node),
iced::mouse::Cursor::Unavailable,
&viewport,
);
tree
};
check_text(&editor, before, fixture.cursor);
check_draw(&renderer, fixture.cursor, OLD);
// No recorder Weak/Strong from setup may perturb replacement ownership.
renderer.drawn = None;
let next_source: Arc<str> = Arc::from(after.as_str());
// Only normal operations and four timestamps occur between start and end.
// Source/model oracles, logging and recorder glyph inspection are outside.
let started = Instant::now();
let replaced = match arm {
Arm::Constructor => {
editor = Content::with_text(after);
true
}
Arm::Reuse => super::super::editor_patch::replace(&mut editor, before, after),
};
editor.move_to(cursor);
let replaced_at = Instant::now();
let mut widget = view(&editor, next_source);
let node = widget.as_widget_mut().layout(&mut tree, &renderer, &limits);
let laid_out_at = Instant::now();
widget.as_widget().draw(
&tree,
&mut renderer,
&theme,
&defaults,
Layout::new(&node),
iced::mouse::Cursor::Unavailable,
&viewport,
);
let drawn_at = Instant::now();
drop(widget);
assert!(replaced, "reuse must not silently time a fallback/refusal");
check_text(&editor, after, cursor);
check_draw(&renderer, cursor, NEW);
renderer.drawn = None;
for (phase, duration) in arm.phases().into_iter().zip([
replaced_at.duration_since(started),
laid_out_at.duration_since(replaced_at),
drawn_at.duration_since(laid_out_at),
drawn_at.duration_since(started),
]) {
println!(
"{}",
serde_json::json!({
"record": "editor_layout_timing", "arm": arm.name(), "phase": phase,
"trial": trial, "warmup": trial == 0,
"elapsed_ns": duration.as_nanos().to_string(),
"elapsed_ms": duration.as_secs_f64() * 1000.0,
})
);
if trial != 0 {
samples.0.entry(phase).or_default().push(duration);
}
}
}
#[test]
#[ignore = "manual Windows dev/debug editor replacement + layout/highlight trial; run alone with --ignored --nocapture --test-threads=1"]
fn accepted_release1_constructor_vs_reuse_through_next_highlight() {
let fixture = Fixture::load();
// Resolve the exact production approval and verify full Project/source once,
// outside all intervals. The actual App Confirm evaluator remains separate.
let cursor = {
let app = fixture.app();
let target = resolve(&app, &app.rename_context());
let prepared = app
.document
.alias_rename_request(target.id, NEW.into())
.unwrap()
.prepare()
.unwrap();
fixture.check_candidate(&prepared);
prepare_approval(prepared, &target, ORIGIN).unwrap().cursor
};
println!(
"{}",
serde_json::json!({
"record": "editor_layout_fixture", "host_width": HOST.width, "host_height": HOST.height,
"font": "MONOSPACE", "font_size": 14, "padding": 16, "height": "Fill",
"active_file": ORIGIN, "before_bytes": fixture.bundle.files[ORIGIN].len(),
"after_bytes": fixture.expected_bundle.files[ORIGIN].len(), "warmups_per_arm": 1,
"measured_trials_per_arm": 5, "debug_assertions": cfg!(debug_assertions),
"scope": "real editor replacement, retained Tree layout and Widget draw/highlight; no GPU, native paint or App Confirm",
})
);
let mut samples = Samples::default();
for index in 0..=5 {
let arms = if index % 2 == 0 {
[Arm::Constructor, Arm::Reuse]
} else {
[Arm::Reuse, Arm::Constructor]
};
println!(
"{}",
serde_json::json!({"record": "editor_layout_order", "trial": index, "first": arms[0].name()})
);
for arm in arms {
trial(&fixture, cursor, arm, index, &mut samples);
}
}
samples.report();
}
@@ -0,0 +1,347 @@
//! Manual timing through the real Document and App paths. No disk writes,
//! executor polling, layout, GPU work or first-paint measurement occurs here.
//! Register as a test child of app::source_rename to use its actual worker helpers.
use super::*;
use crate::source_workspace::Workspace;
use oa_core::source_project::SourceBundle;
use std::{
collections::BTreeMap,
path::PathBuf,
time::{Duration, Instant},
};
#[cfg(debug_assertions)]
#[path = "source_rename_layout_timing_tests.rs"]
mod layout_timing_tests;
const OLD: &str = "oa_restore_work";
const NEW: &str = "oa_restore_pressure";
const DECLARATIONS: &str = "oa/activities/activity_oa_restore_work.arcadia";
const ORIGIN: &str = "traceability.arcadia";
struct Fixture {
bundle: SourceBundle,
project: Project,
expected_bundle: SourceBundle,
expected_project: Project,
target: Id,
cursor: text_editor::Cursor,
}
impl Fixture {
fn load() -> Self {
// The optional entry selects a parent-pinned copy of this exact fixture,
// not an arbitrary performance generator or a mutable GUI destination.
let entry = std::env::var_os("ARCADIA_RENAME_TIMING_ENTRY")
.map(PathBuf::from)
.unwrap_or_else(|| {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../examples/release1/project.arcadia")
});
let loaded = oa_core::source_files::load_project(&entry, LIMITS).unwrap();
let bundle = loaded.reachable_bundle();
assert_eq!(
bundle.files.len(),
30,
"use the preserved release1 walkthrough"
);
let document = Document::from_bundle(bundle.clone(), LIMITS).unwrap();
assert_eq!(
document.bundle(),
&bundle,
"fixture must need no stabilization/migration"
);
assert!(!document.dirty());
let project = document.project().clone();
let target = Id::parse_str("1b91f8a7-0bdb-514e-b1cd-955658e09927").unwrap();
assert_eq!(project.elements[&target].alias, OLD);
assert!(!project.identities().iter().any(|(_, alias)| *alias == NEW));
let mut expected_project = project.clone();
expected_project.elements.get_mut(&target).unwrap().alias = NEW.into();
// Independent literal source oracle, authored before running the rename.
// Do not replace the alias substring in filenames or the trace's alias.
let mut expected_bundle = bundle.clone();
let declarations = expected_bundle.files.get_mut(DECLARATIONS).unwrap();
assert_eq!(
declarations.matches("oa_activity oa_restore_work ").count(),
1
);
assert_eq!(declarations.matches("parent @oa_restore_work;").count(), 4);
*declarations = declarations
.replace(
"oa_activity oa_restore_work ",
"oa_activity oa_restore_pressure ",
)
.replace("parent @oa_restore_work;", "parent @oa_restore_pressure;");
let traces = expected_bundle.files.get_mut(ORIGIN).unwrap();
assert_eq!(traces.matches("oa @oa_restore_work;").count(), 1);
*traces = traces.replace("oa @oa_restore_work;", "oa @oa_restore_pressure;");
let source = &bundle.files[ORIGIN];
let start = source.rfind("@oa_restore_work;").unwrap() + 1;
let position = |offset| {
let prefix = &source[..offset];
text_editor::Position {
line: prefix.bytes().filter(|byte| *byte == b'\n').count(),
column: prefix
.rsplit_once('\n')
.map_or(prefix.len(), |(_, tail)| tail.len()),
}
};
let cursor = text_editor::Cursor {
position: position(start),
selection: Some(position(start + OLD.len())),
};
assert!(nav::exact_cursor(source, cursor));
println!(
"{}",
serde_json::json!({
"record": "fixture", "entry": entry, "reachable_files": bundle.files.len(),
"source_bytes": bundle.files.values().map(String::len).sum::<usize>(),
"identities": project.identities().len(), "active_file": ORIGIN,
"active_file_bytes": source.len(), "old": OLD, "new": NEW,
"warmups": 1, "measured_trials": 5, "debug_assertions": cfg!(debug_assertions),
"scope": "real worker helpers, guarded Document commit, App sync and Confirm update; no task polling or rendering"
})
);
Self {
bundle,
project,
expected_bundle,
expected_project,
target,
cursor,
}
}
fn app(&self) -> App {
let mut app = App::with_project(Project::empty());
app.document = Document::from_bundle(self.bundle.clone(), LIMITS).unwrap();
app.source_workspace = Workspace::new(app.document.bundle(), true);
app.source_workspace.active = ORIGIN.into();
app.selected = app.project().id;
app.page = Page::Text;
app.sync();
app.source_editor.move_to(self.cursor);
assert_eq!(app.project(), &self.project);
assert_eq!(app.document.bundle(), &self.bundle);
assert_eq!(app.document.revision(), 0);
assert!(!app.document.dirty() && !app.document.can_undo() && !app.document.can_redo());
assert!(app.rename_admission().is_ok());
app
}
fn check_candidate(&self, prepared: &PreparedAliasRename) {
assert!(prepared.changed());
assert_eq!(prepared.before().bundle, self.bundle);
assert_eq!(prepared.before().decoded.project, self.project);
assert_eq!(prepared.after().bundle, self.expected_bundle);
assert_eq!(prepared.after().decoded.project, self.expected_project);
}
fn check_adopted(&self, app: &mut App, candidate_address: *const CompiledSourceProject) {
assert!(
std::ptr::eq(app.document.compiled(), candidate_address),
"adopt exact candidate allocation"
);
assert_eq!(app.project(), &self.expected_project);
assert_eq!(app.document.bundle(), &self.expected_bundle);
assert_eq!(app.source_workspace.bundle(), self.expected_bundle);
assert_eq!(app.source_editor.text(), self.expected_bundle.files[ORIGIN]);
assert_eq!(app.document.revision(), 1);
assert!(app.document.dirty() && app.document.can_undo() && !app.document.can_redo());
assert!(app.document.undo());
assert_eq!(app.project(), &self.project);
assert_eq!(app.document.bundle(), &self.bundle);
assert!(
!app.document.dirty(),
"live known saved baseline remains authoritative"
);
assert!(
!app.document.can_undo(),
"exactly one preceding model transaction"
);
assert!(app.document.redo());
assert_eq!(app.project(), &self.expected_project);
assert_eq!(app.document.bundle(), &self.expected_bundle);
assert!(std::ptr::eq(app.document.compiled(), candidate_address));
}
}
#[derive(Default)]
struct Samples(BTreeMap<&'static str, Vec<Duration>>);
impl Samples {
fn time<T>(&mut self, phase: &'static str, trial: usize, action: impl FnOnce() -> T) -> T {
let started = Instant::now();
let result = action();
let elapsed = started.elapsed();
// Printing, assertions and dropping the returned value are outside this
// interval. Full Confirm still includes production's own local drops.
println!(
"{}",
serde_json::json!({
"record": "timing", "phase": phase, "trial": trial,
"warmup": trial == 0, "elapsed_ns": elapsed.as_nanos().to_string(),
"elapsed_ms": elapsed.as_secs_f64() * 1000.0
})
);
if trial != 0 {
self.0.entry(phase).or_default().push(elapsed);
}
result
}
fn report(mut self) {
for (phase, values) in &mut self.0 {
assert_eq!(values.len(), 5);
values.sort();
println!(
"{}",
serde_json::json!({
"record": "summary", "phase": phase, "samples": values.len(),
"min_ms": values[0].as_secs_f64() * 1000.0,
"median_ms": values[2].as_secs_f64() * 1000.0,
"max_ms": values[4].as_secs_f64() * 1000.0
})
);
}
}
}
fn resolve(app: &App, context: &Context) -> Target {
let snapshot = app
.with_source_input(|input| query::admit_owned(input, query::Limits::default()))
.unwrap()
.capture()
.unwrap();
let offset = nav::cursor_offset(app.source_workspace.text(), context.begin.cursor).unwrap();
resolve_target(&snapshot, context, offset).unwrap()
}
fn active_job(app: &App) -> Job {
let form = app.source_rename.form.as_ref().unwrap();
let id = form.pending.unwrap();
assert_eq!(app.source_rename.in_flight, Some(id));
Job {
id,
invocation: form.invocation,
generation: form.generation,
context: form.context.clone(),
}
}
fn separate_phases(fixture: &Fixture, samples: &mut Samples, trial: usize) {
let mut app = fixture.app();
let target = resolve(&app, &app.rename_context());
assert_eq!(target.id, fixture.target);
let request = samples
.time("document_request_mint", trial, || {
app.document.alias_rename_request(target.id, NEW.into())
})
.unwrap();
let prepared = samples
.time("worker_document_prepare", trial, || request.prepare())
.unwrap();
fixture.check_candidate(&prepared);
let approval = samples
.time("worker_preview_and_origin", trial, || {
prepare_approval(prepared, &target, ORIGIN)
})
.unwrap();
assert_eq!(approval.preview.files().len(), 2);
assert_eq!(approval.preview.changed_line_count(), 6);
let candidate_address = approval.prepared.after() as *const CompiledSourceProject;
// Same-destination save completion is a legitimate live update after Preview.
app.document.mark_saved(app.document.snapshot());
let changed = samples
.time("document_guarded_commit", trial, || {
app.document.commit_alias_rename(&approval.prepared)
})
.unwrap();
assert!(changed);
samples.time("app_sync", trial, || app.sync());
fixture.check_adopted(&mut app, candidate_address);
}
fn whole_confirm(fixture: &Fixture, samples: &mut Samples, trial: usize) {
let mut app = fixture.app();
let begin = app.source_rename_message();
// These Tasks are deliberately not polled: deterministic worker helpers
// supply the same results through the real readiness messages below.
drop(app.update(begin));
let target_job = active_job(&app);
let target = Arc::new(resolve(&app, &target_job.context));
assert_eq!(target.id, fixture.target);
drop(app.update(Message::SourceRenameReady(Completion {
job: target_job,
payload: Payload::Target(Ok(target.clone())),
})));
let invocation = app.source_rename.form.as_ref().unwrap().invocation;
drop(app.update(Message::SourceRename(Event::Input(invocation, NEW.into()))));
let generation = app.source_rename.form.as_ref().unwrap().generation;
drop(app.update(Message::SourceRename(Event::Preview(
invocation, generation,
))));
let preview_job = active_job(&app);
let prepared = app
.document
.alias_rename_request(target.id, NEW.into())
.unwrap()
.prepare()
.unwrap();
fixture.check_candidate(&prepared);
let approval = Arc::new(prepare_approval(prepared, &target, ORIGIN).unwrap());
drop(target);
let candidate_address = approval.prepared.after() as *const CompiledSourceProject;
let expected_cursor = approval.cursor;
let approval_id = approval.id;
drop(app.update(Message::SourceRenameReady(Completion {
job: preview_job,
payload: Payload::Preview(Ok(approval)),
})));
assert_eq!(
app.source_rename
.form
.as_ref()
.unwrap()
.approval
.as_ref()
.unwrap()
.id,
approval_id
);
app.document.mark_saved(app.document.snapshot());
let confirm = Message::SourceRename(Event::Confirm(invocation, generation, approval_id));
let task = samples.time("app_confirm_update", trial, || app.update(confirm));
samples.time("confirm_returned_task_drop_without_polling", trial, || {
drop(task)
});
assert!(!app.source_rename.open());
assert!(app.source_rename.in_flight.is_none());
assert!(app.source_rename_undo_message().is_some());
assert_eq!(app.source_editor.cursor(), expected_cursor);
fixture.check_adopted(&mut app, candidate_address);
}
#[test]
#[ignore = "manual Windows dev/debug rename latency trial; run alone with --ignored --nocapture --test-threads=1"]
fn accepted_release1_prepare_commit_sync_and_confirm_timing() {
let fixture = Fixture::load();
let mut samples = Samples::default();
for trial in 0..=5 {
println!(
"{}",
serde_json::json!({ "record": "order", "trial": trial,
"warmup": trial == 0, "separate_phases_first": trial % 2 == 0 })
);
if trial % 2 == 0 {
separate_phases(&fixture, &mut samples, trial);
whole_confirm(&fixture, &mut samples, trial);
} else {
whole_confirm(&fixture, &mut samples, trial);
separate_phases(&fixture, &mut samples, trial);
}
}
samples.report();
}
+508
View File
@@ -0,0 +1,508 @@
//! Current draft navigation, isolated from model application and disk discovery.
use super::*;
use crate::source_navigation::{self as nav, query};
use std::sync::atomic::Ordering;
fn admission_notice(error: query::AdmissionError) -> String {
use nav::symbols::{BuildError, InputProblem, Resource};
let (limit, resource) = match error {
query::AdmissionError::Index(BuildError::Limit { limit, resource, .. }) => (limit, resource),
query::AdmissionError::Outline { error, .. } => (error.limit, match error.resource {
language::OutlineResource::SourceBytes => Resource::SourceBytes,
language::OutlineResource::Tokens => Resource::Tokens,
language::OutlineResource::Declarations => Resource::Records,
}),
query::AdmissionError::Index(BuildError::Input { problem, .. }) => return match problem {
InputProblem::NonCanonicalPath | InputProblem::DuplicateOrCaseCollidingPath =>
"Navigation needs distinct relative source paths. Check file names and include paths.".into(),
_ => "Navigation could not verify the current source locations. Correct source errors and try again.".into(),
},
};
let unit = match resource {
Resource::Files => "loaded files",
Resource::SourceBytes => "source bytes",
Resource::PathBytes => "bytes in file paths",
Resource::Tokens => "source tokens",
Resource::Records => "declarations and references",
};
format!(
"Navigation reached its limit of {limit} {unit}. Reduce the loaded source size and try again."
)
}
impl App {
pub fn source_symbol_context(&self) -> nav::SymbolContext {
nav::SymbolContext {
session: self.source_workspace.session(),
path: self.source_workspace.active.clone(),
}
}
pub fn source_symbol_message(&self, event: nav::SymbolEvent) -> Message {
Message::SourceSymbols(self.source_symbol_context(), event)
}
pub fn source_find_close_message(&self) -> Message {
Message::SourceFindClose(self.source_symbol_context(), self.source_find_instance)
}
pub fn source_key(&self) -> query::SnapshotKey {
query::SnapshotKey {
workspace: self.source_workspace.session(),
revision: self.source_workspace.revision(),
}
}
pub(super) fn with_source_input<T>(&self, use_input: impl FnOnce(query::Input<'_>) -> T) -> T {
let files: Vec<_> = self
.source_workspace
.files
.iter()
.map(|(path, buffer)| query::DraftFile {
path,
text: &buffer.text,
})
.collect();
let accepted = self.document.bundle();
let validated: Vec<_> = accepted
.files
.iter()
.map(|(path, text)| query::DraftFile { path, text })
.collect();
use_input(query::Input {
key: self.source_key(),
entry: &self.source_workspace.entry,
files: &files,
applied: self.source_workspace.applied,
validation: self
.source_workspace
.applied
.then_some(query::ValidatedSources {
entry: &accepted.entry,
files: &validated,
}),
})
}
pub(super) fn source_snapshot_matches(&self, snapshot: &query::Snapshot) -> bool {
snapshot.key() == self.source_key()
&& self.with_source_input(|input| snapshot.matches(input))
}
pub fn source_navigation_available(&self) -> bool {
self.page == Page::Text
&& !self.source_rename.open()
&& !self.busy
&& self.pending.is_none()
&& self.file_prompt.is_none()
&& !self.delete_pending
&& self.workflow.is_none()
&& !self.hosted.visible
&& self.model_menu.is_none()
&& self.diagram_usages.is_none()
&& !self.reference_input_active()
&& self.architecture.is_none()
&& !self.behavior.has_pending()
}
/// Mounted widget IDs change when another interaction supersedes a reveal.
/// A queued focus operation therefore cannot find a subsequently reused editor.
pub fn source_editor_id(&self) -> iced::widget::Id {
iced::widget::Id::from(format!(
"source-editor-{}-{}-{}",
self.source_workspace.session(),
self.source_workspace.active,
self.source_symbols.interaction
))
}
pub fn source_reveal_ticket(&self) -> Option<Id> {
self.source_symbols
.reveal
.as_ref()
.map(|reveal| reveal.ticket)
}
pub(super) fn source_interaction(&mut self) {
self.source_symbols.interaction = Id::new_v4();
self.source_symbols.request = None;
self.source_symbols.reveal = None;
self.source_jump = None;
}
pub(super) fn queue_source_reveal(&mut self, cursor: text_editor::Cursor, select_line: bool) {
self.source_jump = Some(cursor);
self.source_select_line = select_line;
self.source_symbols.reveal = Some(nav::Reveal {
ticket: Id::new_v4(),
interaction: self.source_symbols.interaction,
raw_interaction: self.source_symbols.raw_interaction.load(Ordering::SeqCst),
snapshot: self.source_key(),
path: self.source_workspace.active.clone(),
});
}
pub(super) fn restore_source_editor_focus(&mut self) {
let cursor = self.source_editor.cursor();
if self.source_navigation_available()
&& nav::exact_cursor(self.source_workspace.text(), cursor)
{
self.queue_source_reveal(cursor, false);
}
}
pub(super) fn source_viewport(&mut self, ticket: Option<Id>) -> Task<Message> {
let Some(reveal) = &self.source_symbols.reveal else {
return Task::none();
};
if Some(reveal.ticket) != ticket
|| reveal.interaction != self.source_symbols.interaction
|| !nav::interaction::is_current(
&self.source_symbols.raw_interaction,
reveal.raw_interaction,
)
|| reveal.snapshot != self.source_key()
|| reveal.path != self.source_workspace.active
|| !self.source_navigation_available()
{
return Task::none();
}
let raw_interaction = reveal.raw_interaction;
self.source_symbols.reveal = None;
if let Some(cursor) = self.source_jump.take() {
if !nav::exact_cursor(self.source_workspace.text(), cursor) {
return Task::none();
}
self.source_editor.move_to(cursor);
if self.source_select_line && cursor.selection.is_none() {
self.source_editor.perform(text_editor::Action::SelectLine);
}
return nav::interaction::guarded_focus(
self.source_editor_id(),
self.source_symbols.raw_interaction.clone(),
raw_interaction,
);
}
Task::none()
}
pub(super) fn source_symbol_event(&mut self, event: nav::SymbolEvent) -> Task<Message> {
if !self.source_navigation_available() {
return Task::none();
}
match event {
nav::SymbolEvent::DiagramUsages => {
if self.source_dirty() || !self.source_workspace.applied {
self.source_symbols.open = true;
self.source_symbols.notice = Some("Apply source before finding diagram representations. Your current text is retained.".into());
return Task::none();
}
let task = self.start_source_query(query::Action::Definition);
self.source_diagram_job = self
.source_symbols
.request
.as_ref()
.map(|request| request.job);
return task;
}
nav::SymbolEvent::Close => {
self.source_symbols.open = false;
self.source_symbols.results = None;
self.source_symbols.notice = None;
}
nav::SymbolEvent::Back => {
self.source_symbol_back();
// A successful Back needs no empty results panel. Retain an
// already open panel, or reveal an actionable return notice.
self.source_symbols.open |= self.source_symbols.notice.is_some();
}
nav::SymbolEvent::Choose {
stamp,
reference,
index,
} => {
let Some(results) = self.source_symbols.results.clone() else {
return Task::none();
};
if results.stamp != stamp
|| stamp.snapshot != self.source_key()
|| self
.source_symbols
.cache
.as_ref()
.is_none_or(|cache| !self.source_snapshot_matches(cache))
{
self.source_symbols.notice =
Some("Source changed. Run navigation again for current locations.".into());
return Task::none();
}
let destination = if reference {
results
.references
.get(index)
.map(|row| (&row.path, row.position, row.end_position))
} else {
results
.definitions
.get(index)
.map(|row| (&row.path, row.position, row.end_position))
};
if let Some((path, start, end)) = destination {
self.source_symbol_jump(path, start, end);
}
}
nav::SymbolEvent::Query(action) => return self.start_source_query(action),
}
Task::none()
}
fn start_source_query(&mut self, action: query::Action) -> Task<Message> {
self.source_symbols.open = true;
if !nav::interaction::is_current(
&self.source_symbols.raw_interaction,
self.source_symbols.raw_interaction.load(Ordering::SeqCst),
) {
self.source_symbols.notice = Some("Reopen this workspace to reset navigation.".into());
return Task::none();
}
if self.source_symbols.in_flight.is_some() {
self.source_symbols.notice = Some("Navigation is finishing. Try again shortly.".into());
return Task::none();
}
let cursor = self.source_editor.cursor();
let Some(offset) = nav::cursor_offset(self.source_workspace.text(), cursor) else {
self.source_symbols.notice =
Some("Place the cursor on an alias or its @ reference.".into());
return Task::none();
};
self.source_symbols.serial = match self.source_symbols.serial.checked_add(1) {
Some(serial) => serial,
None => {
self.source_workspace.reset_navigation_session();
self.source_symbols.back.clear();
self.source_symbols.cache = None;
0
}
};
let request = nav::Request {
job: Id::new_v4(),
interaction: self.source_symbols.interaction,
raw_interaction: self.source_symbols.raw_interaction.load(Ordering::SeqCst),
stamp: query::QueryStamp {
snapshot: self.source_key(),
serial: self.source_symbols.serial,
},
path: self.source_workspace.active.clone(),
offset,
cursor,
action,
};
let cache = self
.source_symbols
.cache
.as_ref()
.filter(|cache| self.source_snapshot_matches(cache))
.cloned();
let input = if cache.is_none() {
match self
.with_source_input(|input| query::admit_owned(input, query::Limits::default()))
{
Ok(input) => Some(input),
Err(error) => {
self.source_symbols.notice = Some(admission_notice(error));
return Task::none();
}
}
} else {
None
};
self.source_symbols.in_flight = Some(request.job);
self.source_symbols.request = Some(request.clone());
self.source_symbols.results = None;
self.source_symbols.notice = None;
Task::perform(
async move {
let worker_request = request.clone();
let result = tokio::task::spawn_blocking(move || {
let snapshot = match cache {
Some(cache) => cache,
None => Arc::new(
input
.expect("admitted input for uncached request")
.capture()
.map_err(admission_notice)?,
),
};
let results = snapshot
.query(query::Query {
stamp: worker_request.stamp,
path: &worker_request.path,
byte_offset: worker_request.offset,
action: worker_request.action,
})
.map_err(|error| match error {
query::QueryError::ResultEnvelopeLimit => "These result details exceed the display limit. Try literal Find for this alias.".to_owned(),
_ => "Navigation could not verify this location. Place the cursor on an alias and try again.".to_owned(),
})?;
Ok((snapshot, Arc::new(results)))
})
.await
.unwrap_or_else(|error| Err(format!("Navigation task failed: {error}")));
nav::Completion { request, result }
},
Message::SourceSymbolsReady,
)
}
pub(super) fn source_symbols_ready(&mut self, completion: nav::Completion) -> Task<Message> {
let request = completion.request;
if self.source_symbols.in_flight != Some(request.job) {
return Task::none();
}
self.source_symbols.in_flight = None;
let diagram_query = self.source_diagram_job == Some(request.job);
if diagram_query {
self.source_diagram_job = None;
}
let wanted = self
.source_symbols
.request
.take()
.is_some_and(|current| current.job == request.job)
&& request.interaction == self.source_symbols.interaction
&& request.stamp.snapshot == self.source_key()
&& nav::interaction::is_current(
&self.source_symbols.raw_interaction,
request.raw_interaction,
)
&& request.path == self.source_workspace.active
&& request.cursor == self.source_editor.cursor()
&& self.source_navigation_available();
if !wanted {
return Task::none();
}
match completion.result {
Ok((snapshot, results)) if self.source_snapshot_matches(&snapshot) => {
self.source_symbols.cache = Some(snapshot);
self.source_symbols.results = Some(results.clone());
if diagram_query {
if results.disposition == query::Disposition::Direct
&& results.scope.validated
&& results.omitted_definitions == 0
&& let [row] = results.definitions.as_slice()
&& let query::Identity::Unique(identity) = row.declaration.identity
&& row.membership == query::Membership::Included
&& row.evidence == query::Evidence::Validated
&& self.project().contains(identity.id())
&& crate::diagram_usage::alias(self.project(), identity.id())
== row.declaration.alias
{
self.source_symbols.open = false;
return self.diagram_usage_event(crate::diagram_usage::Event::Open(
crate::diagram_usage::Context {
document: self.canvas_document,
revision: self.document.revision(),
subject: identity.id(),
},
));
}
self.source_symbols.notice = Some("Place the caret on a uniquely resolved alias in applied source to find diagram representations.".into());
return Task::none();
}
if results.disposition == query::Disposition::Direct
&& let Some(row) = results.definitions.first()
{
self.source_symbol_jump(&row.path, row.position, row.end_position);
}
}
Ok(_) => {
self.source_symbols.notice = Some("Source changed. Run navigation again.".into())
}
Err(error) => self.source_symbols.notice = Some(error),
}
Task::none()
}
fn source_symbol_jump(&mut self, path: &str, start: query::Position, end: query::Position) {
let cursor = text_editor::Cursor {
position: text_editor::Position {
line: start.line,
column: start.byte_column,
},
selection: Some(text_editor::Position {
line: end.line,
column: end.byte_column,
}),
};
if self
.source_workspace
.files
.get(path)
.is_none_or(|buffer| !nav::exact_cursor(&buffer.text, cursor))
{
self.source_symbols.notice =
Some("That source location is no longer available. Run navigation again.".into());
return;
}
if let Some(buffer) = self
.source_workspace
.files
.get(&self.source_workspace.active)
{
let origin = nav::HistoryEntry {
session: self.source_workspace.session(),
path: self.source_workspace.active.clone(),
generation: buffer.generation(),
cursor: self.source_editor.cursor(),
};
self.source_symbols
.back
.retain(|entry| entry.session == origin.session);
if self.source_symbols.back.len() == 64 {
self.source_symbols.back.remove(0);
}
self.source_symbols.back.push(origin);
}
self.source_workspace
.switch(path, self.source_editor.cursor());
self.show_source_file(false);
self.queue_source_reveal(cursor, false);
}
fn source_symbol_back(&mut self) {
while let Some(origin) = self.source_symbols.back.pop() {
if origin.session != self.source_workspace.session() {
continue;
}
let Some(buffer) = self.source_workspace.files.get(&origin.path) else {
self.source_symbols.notice = Some("The previous file is no longer loaded.".into());
return;
};
let unchanged = origin.generation == buffer.generation();
let cursor = if unchanged {
origin.cursor
} else if origin.path == self.source_workspace.active {
self.source_editor.cursor()
} else {
buffer.cursor
};
if !nav::exact_cursor(&buffer.text, cursor) {
self.source_symbols.notice = Some(
"The previous location changed. Open the file to choose a current position."
.into(),
);
return;
}
self.source_workspace
.switch(&origin.path, self.source_editor.cursor());
self.show_source_file(false);
self.queue_source_reveal(cursor, false);
self.source_symbols.notice =
(!unchanged).then(|| "This draft changed; its current cursor was restored.".into());
return;
}
self.source_symbols.notice = Some("No previous source location in this workspace.".into());
}
}
#[cfg(test)]
mod tests;
File diff suppressed because it is too large Load Diff
@@ -24,6 +24,7 @@ impl App {
if self.behavior.has_pending() {
return;
}
self.behavior.scenario.forget_view();
self.layer = layer;
self.page = Page::Behavior;
self.behavior.active_owner = Some(machine);
@@ -49,11 +49,28 @@ fn fixture(layer: Layer, kind: Kind) -> (App, Id, Id, Id) {
p.diagrams.get_mut(&view).unwrap().name = "Unrelated architecture".into();
p.place(view, reused, Position { x: 873., y: 421. });
let mut app = App::with_project(p);
app.layer = layer;
app.page = Page::Behavior;
app.selected = root;
app.behavior.active_owner = Some(root);
app.refresh_draft();
let accepted = app.document.snapshot();
// Enter through the actual navigation path: assigning Page/owner alone does
// not create the representation and live visit required by canvas events.
send(&mut app, Message::Select(root));
send(&mut app, Message::Page(Page::Behavior));
assert_eq!(app.layer, layer);
assert_eq!(app.page, Page::Behavior);
assert_eq!(app.selected, root);
assert_eq!(app.behavior.active_owner, Some(root));
if kind == Kind::Scenario {
let context = app.scenario_context().unwrap();
assert_eq!(context.document, app.canvas_document);
assert_eq!(context.scenario, root);
assert_eq!(
context.representation,
crate::behavior::scenario_layout::Representation::Derived { owner: root }
);
assert!(!context.visit.is_nil());
}
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
assert_eq!(app.source_workspace.bundle(), accepted.bundle);
assert!(!app.document.can_undo() && !app.document.can_redo());
(app, root, work, reused)
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,309 @@
//! Shared presentation and input contract; contains no model commands.
use crate::app::{App, Message};
use iced::widget::{button, column, container, opaque, row, scrollable, stack, text, tooltip};
use iced::{Element, Length::Fill, Point, Theme, Vector};
use oa_core::Id;
mod control;
mod focus;
mod viewport;
pub use control::Control;
pub use viewport::Viewport;
pub fn keyboard_enabled(app: &crate::app::App) -> bool {
!app.busy
&& !app.hosted.visible
&& app.workflow.is_none()
&& app.file_prompt.is_none()
&& !app.delete_pending
&& app.pending.is_none()
&& app.model_menu.is_none()
&& app.diagram_usages.is_none()
&& !app.reference_input_active()
}
pub fn controls<'a>(
scope: Scope,
keyboard: bool,
scale: f32,
details: Message,
focus: Option<Message>,
zoom: impl Fn(Option<f32>) -> Message,
publish: impl Fn(Event) -> Message + Copy + 'a,
) -> Element<'a, Message> {
let control = |name: &'static str, label: &'static str, message: Option<Message>| {
let message = message.map(|message| intent(scope, name, message));
Control::new(
button(text(label).size(12))
.padding(6)
.on_press_maybe(message.clone()),
scope,
name,
keyboard,
message,
publish,
)
};
row![
control("Details", "Details", Some(details)),
tooltip(control("Canvas", "Canvas", focus),
container(text("F6: enter/leave canvas controls · Tab: next · Enter/Space: activate · Esc: leave").size(12)).padding(7).style(crate::style::card),
tooltip::Position::Bottom),
control("Fit", "Fit", Some(zoom(None))),
control("ZoomOut", "−", Some(zoom(Some(scale / 1.25)))),
text(zoom_label(scale)).size(12),
control("ZoomIn", "+", Some(zoom(Some(scale * 1.25)))),
control("ActualSize", "100%", Some(zoom(Some(1.)))),
].spacing(4).align_y(iced::Center).into()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Scope {
pub(crate) scenario_view: Option<(super::scenario_layout::Representation, Id)>,
pub document: Id,
pub owner: Id,
pub revision: u64,
pub epoch: u64,
}
impl Scope {
pub(crate) fn current(app: &App) -> Option<Self> {
if let Some(context) = app.scenario_context() {
Some(Self {
document: context.document,
owner: context.scenario,
scenario_view: Some((context.representation, context.visit)),
revision: context.revision,
epoch: app.camera_epoch,
})
} else {
let context = app.topology_context()?;
Some(Self {
document: context.document,
owner: context.owner,
scenario_view: None,
revision: context.revision,
epoch: app.camera_epoch,
})
}
}
pub(crate) fn input_owner(app: &App) -> Option<Self> {
if keyboard_enabled(app)
&& !app.behavior.has_pending()
&& !app.draft_dirty()
&& !app.source_dirty()
{
Self::current(app)
} else {
None
}
}
pub fn control_id(self, name: &str) -> iced::advanced::widget::Id {
iced::advanced::widget::Id::from(format!(
"behavior-control:{}:{}:{}:{:?}:{name}",
self.document, self.owner, self.revision, self.scenario_view
))
}
}
/// Only a live presentation intent may carry a toolbar action across its own pan.
#[derive(Debug, Clone)]
pub struct Intent {
pub scope: Scope,
pub name: &'static str,
pub message: Box<Message>,
}
pub fn intent(scope: Scope, name: &'static str, message: Message) -> Message {
Message::BehaviorControl(Intent {
scope,
name,
message: Box::new(message),
})
}
#[derive(Debug, Clone, Copy)]
pub enum Event {
CancelGesture,
Pan(Vector),
Zoom { factor: f32, at: Point, base: f32 },
Focus,
Traverse(bool),
ControlFocus(&'static str),
ToggleFocus,
Leave,
}
pub use focus::{focus_task, root as focus_root};
/// Fold relative input in event order. The base scale is used only on leaving Fit.
pub fn change(pan: &mut Vector, zoom: &mut Option<f32>, fit: &mut bool, event: Event) -> bool {
change_with_minimum(pan, zoom, fit, event, 0.05)
}
/// Scenario extents can require scales below the topology canvas's 5% floor.
pub(super) fn change_with_minimum(
pan: &mut Vector,
zoom: &mut Option<f32>,
fit: &mut bool,
event: Event,
minimum: f32,
) -> bool {
if !minimum.is_finite() || minimum <= 0. || minimum > 3. {
return false;
}
match event {
Event::Pan(delta) if delta.x.is_finite() && delta.y.is_finite() => {
let next = *pan + delta;
if next.x.is_finite() && next.y.is_finite() && next != *pan {
*pan = next;
true
} else {
false
}
}
Event::Zoom { factor, at, base }
if factor.is_finite()
&& factor > 0.
&& base.is_finite()
&& base > 0.
&& at.x.is_finite()
&& at.y.is_finite() =>
{
let old = if *fit { base } else { zoom.unwrap_or(base) };
if !old.is_finite() || old <= 0. {
return false;
}
let next = (old * factor).clamp(minimum, 3.);
if old == next {
return false;
}
let anchor = Vector::new(at.x, at.y);
let next_pan = anchor - (anchor - *pan) * (next / old);
if !next_pan.x.is_finite() || !next_pan.y.is_finite() {
return false;
}
*pan = next_pan;
*zoom = Some(next);
*fit = false;
true
}
_ => false,
}
}
fn zoom_label(scale: f32) -> String {
let percent = f64::from(scale) * 100.;
if !percent.is_finite() || percent <= 0. {
return "—".into();
}
if percent >= 1. {
format!("{percent:.0}%")
} else if percent >= 0.01 {
format!("{percent:.3}%")
} else {
format!("{percent:.2e}%")
}
}
#[cfg(test)]
mod camera_policy_tests {
use super::*;
#[test]
fn topology_retains_five_percent_floor() {
let (mut pan, mut zoom, mut fit) = (Vector::ZERO, Some(0.06), false);
assert!(change(
&mut pan,
&mut zoom,
&mut fit,
Event::Zoom {
factor: 0.1,
at: Point::ORIGIN,
base: 0.06,
}
));
assert_eq!(zoom, Some(0.05));
}
#[test]
fn tiny_zoom_label_is_nonzero_and_changes_with_zoom() {
assert_eq!(zoom_label(1.), "100%");
assert_eq!(zoom_label(0.05), "5%");
assert_eq!(zoom_label(0.00034193), "0.034%");
assert_eq!(zoom_label(0.00034193 * 1.25), "0.043%");
assert_eq!(zoom_label(1e-7), "1.00e-5%");
assert_ne!(zoom_label(f32::from_bits(1)), "0%");
}
}
/// Constant-width labelled palette. Arming a tool never reallocates the canvas.
pub fn palette<'a>(
scope: Scope,
keyboard: bool,
publish: impl Fn(Event) -> Message + Copy + 'a,
entries: impl IntoIterator<Item = (&'static str, &'static str, bool, Option<Message>)>,
) -> Element<'a, Message> {
let mut tools = column![].spacing(3);
for (glyph, label, selected, message) in entries {
let message = message.map(|message| intent(scope, glyph, message));
tools = tools.push(tooltip(
container(Control::new(
button(text(glyph).size(12))
.width(34)
.height(30)
.padding(3)
.style(if selected {
button::primary
} else {
crate::style::nav
})
.on_press_maybe(message.clone()),
scope,
glyph,
keyboard,
message,
publish,
))
.id(format!("behavior-tool:{glyph}")),
container(text(label).size(12))
.padding(7)
.style(crate::style::card),
tooltip::Position::Left,
));
}
container(scrollable(tools).id(scope.control_id("palette-scroll")))
.width(40)
.height(Fill)
.padding(3)
.style(crate::style::panel)
.into()
}
/// Header and palette are permanent. Details/editors overlay the same viewport.
pub fn host<'a, Message: 'a, R: iced::advanced::text::Renderer + 'a>(
header: Element<'a, Message, Theme, R>,
palette: Element<'a, Message, Theme, R>,
canvas: Element<'a, Message, Theme, R>,
editor: Option<Element<'a, Message, Theme, R>>,
) -> Element<'a, Message, Theme, R> {
let mut body = stack![canvas];
if let Some(editor) = editor {
body = body.push(
container(opaque(
container(editor)
.width(300)
.height(Fill)
.style(crate::style::card),
))
.align_right(Fill)
.height(Fill)
.padding(8),
);
}
column![header, row![body, palette].height(Fill)]
.spacing(6)
.padding(8)
.width(Fill)
.height(Fill)
.into()
}
#[cfg(test)]
pub(crate) mod tests;
@@ -0,0 +1,256 @@
//! A real focusable keyboard target around the existing styled pointer button.
use super::{Event as CameraEvent, Scope, focus::Member};
use iced::advanced::{
Clipboard, Layout, Shell, Widget, layout, overlay, renderer,
widget::{Operation, Tree, operation, tree},
};
use iced::{Border, Element, Event, Length, Rectangle, Size, Theme, Vector, keyboard, mouse};
#[derive(Default)]
struct Input {
identity: Option<iced::advanced::widget::Id>,
focused: bool,
}
impl operation::Focusable for Input {
fn is_focused(&self) -> bool {
self.focused
}
fn focus(&mut self) {
self.focused = true;
}
fn unfocus(&mut self) {
self.focused = false;
}
}
pub struct Control<'a, Message, R = iced::Renderer>
where
R: renderer::Renderer,
{
content: Element<'a, Message, Theme, R>,
scope: Scope,
name: &'static str,
enabled: bool,
activate: Option<Message>,
publish: Box<dyn Fn(CameraEvent) -> Message + 'a>,
}
impl<'a, Message, R: renderer::Renderer> Control<'a, Message, R> {
pub fn new(
content: impl Into<Element<'a, Message, Theme, R>>,
scope: Scope,
name: &'static str,
enabled: bool,
activate: Option<Message>,
publish: impl Fn(CameraEvent) -> Message + 'a,
) -> Self {
Self {
content: content.into(),
scope,
name,
enabled: enabled && activate.is_some(),
activate,
publish: Box::new(publish),
}
}
}
impl<Message: Clone, R: renderer::Renderer> Widget<Message, Theme, R> for Control<'_, Message, R> {
fn tag(&self) -> tree::Tag {
tree::Tag::of::<Input>()
}
fn state(&self) -> tree::State {
tree::State::new(Input {
identity: Some(self.scope.control_id(self.name)),
focused: false,
})
}
fn children(&self) -> Vec<Tree> {
vec![Tree::new(self.content.as_widget())]
}
fn diff(&self, tree: &mut Tree) {
let state = tree.state.downcast_mut::<Input>();
let identity = self.scope.control_id(self.name);
if state.identity.as_ref() != Some(&identity) || !self.enabled {
*state = Input {
identity: Some(identity),
focused: false,
};
}
tree.diff_children(std::slice::from_ref(&self.content));
}
fn size(&self) -> Size<Length> {
self.content.as_widget().size()
}
fn layout(&mut self, tree: &mut Tree, renderer: &R, limits: &layout::Limits) -> layout::Node {
self.content
.as_widget_mut()
.layout(&mut tree.children[0], renderer, limits)
}
fn operate(
&mut self,
tree: &mut Tree,
layout: Layout<'_>,
renderer: &R,
operation: &mut dyn Operation,
) {
let id = self.scope.control_id(self.name);
operation.container(Some(&id), layout.bounds());
if self.enabled {
operation.custom(Some(&id), layout.bounds(), &mut Member(self.scope));
operation.focusable(
Some(&id),
layout.bounds(),
tree.state.downcast_mut::<Input>(),
);
}
operation.traverse(&mut |operation| {
self.content.as_widget_mut().operate(
&mut tree.children[0],
layout,
renderer,
operation,
);
});
}
fn update(
&mut self,
tree: &mut Tree,
event: &Event,
layout: Layout<'_>,
cursor: mouse::Cursor,
renderer: &R,
clipboard: &mut dyn Clipboard,
shell: &mut Shell<'_, Message>,
viewport: &Rectangle,
) {
if shell.is_event_captured() {
return;
}
let state = tree.state.downcast_mut::<Input>();
if self.enabled {
if let Event::Keyboard(keyboard::Event::KeyPressed {
key,
modifiers,
repeat,
..
}) = event
&& state.focused
{
use keyboard::{Key, key::Named};
let action = match key.as_ref() {
Key::Named(Named::Tab) => {
Some((self.publish)(CameraEvent::Traverse(modifiers.shift())))
}
Key::Named(Named::Escape) => Some((self.publish)(CameraEvent::Leave)),
Key::Named(Named::F6) if modifiers.is_empty() && !repeat => {
Some((self.publish)(CameraEvent::ToggleFocus))
}
Key::Named(Named::Enter | Named::Space) if modifiers.is_empty() && !repeat => {
self.activate.clone()
}
_ => None,
};
if let Some(action) = action {
shell.publish(action);
shell.capture_event();
shell.request_redraw();
return;
}
}
if matches!(
event,
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left))
) && !layout
.bounds()
.intersection(viewport)
.is_some_and(|bounds| cursor.is_over(bounds))
{
state.focused = false;
}
}
if matches!(event, Event::Window(iced::window::Event::Unfocused)) {
state.focused = false;
}
self.content.as_widget_mut().update(
&mut tree.children[0],
event,
layout,
cursor,
renderer,
clipboard,
shell,
viewport,
);
}
fn draw(
&self,
tree: &Tree,
renderer: &mut R,
theme: &Theme,
style: &renderer::Style,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
) {
self.content.as_widget().draw(
&tree.children[0],
renderer,
theme,
style,
layout,
cursor,
viewport,
);
if self.enabled && tree.state.downcast_ref::<Input>().focused {
renderer.fill_quad(
renderer::Quad {
bounds: layout.bounds(),
border: Border {
color: crate::style::INK,
width: 2.,
..Default::default()
},
..Default::default()
},
iced::Color::TRANSPARENT,
);
}
}
fn mouse_interaction(
&self,
tree: &Tree,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
renderer: &R,
) -> mouse::Interaction {
self.content.as_widget().mouse_interaction(
&tree.children[0],
layout,
cursor,
viewport,
renderer,
)
}
fn overlay<'a>(
&'a mut self,
tree: &'a mut Tree,
layout: Layout<'a>,
renderer: &R,
viewport: &Rectangle,
translation: Vector,
) -> Option<overlay::Element<'a, Message, Theme, R>> {
self.content.as_widget_mut().overlay(
&mut tree.children[0],
layout,
renderer,
viewport,
translation,
)
}
}
impl<'a, Message: Clone + 'a, R: renderer::Renderer + 'a> From<Control<'a, Message, R>>
for Element<'a, Message, Theme, R>
{
fn from(control: Control<'a, Message, R>) -> Self {
Element::new(control)
}
}
@@ -0,0 +1,293 @@
//! Explicit canvas entry; ordinary initial Tab continues to belong to Inspector.
use super::{Event, Scope};
use iced::advanced::widget::{Id, Operation, Tree, operation};
use iced::advanced::{Clipboard, Layout, Shell, Widget, layout, overlay, renderer};
use iced::{Element, Event as InputEvent, Length, Rectangle, Size, Theme, Vector, mouse};
pub(super) struct Member(pub Scope);
pub fn focus_task<Message: Send + 'static>(scope: Scope, event: Event) -> iced::Task<Message> {
iced::advanced::widget::operate(Collect {
scope,
valid: false,
event,
members: vec![],
focused: None,
palette: None,
})
}
struct Collect {
scope: Scope,
valid: bool,
event: Event,
members: Vec<(Id, Rectangle)>,
focused: Option<usize>,
palette: Option<(Rectangle, Rectangle)>,
}
impl<T: Send + 'static> Operation<T> for Collect {
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn Operation<T>)) {
visit(self);
}
fn custom(&mut self, id: Option<&Id>, bounds: Rectangle, value: &mut dyn std::any::Any) {
if let Some(owner) = value.downcast_ref::<Owner>() {
self.valid = owner.0 == Some(self.scope);
}
if self.valid
&& value
.downcast_ref::<Member>()
.is_some_and(|m| m.0 == self.scope)
&& let Some(id) = id
{
self.members.push((id.clone(), bounds));
}
}
fn focusable(&mut self, id: Option<&Id>, _: Rectangle, value: &mut dyn operation::Focusable) {
if value.is_focused() {
self.focused = self
.members
.iter()
.position(|(member, _)| Some(member) == id)
.or(self.focused);
}
}
fn scrollable(
&mut self,
id: Option<&Id>,
bounds: Rectangle,
content: Rectangle,
_: Vector,
_: &mut dyn operation::Scrollable,
) {
if id == Some(&self.scope.control_id("palette-scroll")) {
self.palette = Some((bounds, content));
}
}
fn finish(&self) -> operation::Outcome<T> {
let canvas = self.scope.control_id("viewport");
// An enabled viewport witnesses the live lifetime. Hidden/dirty/modal
// owners expose no members; a delayed request cannot clear their focus.
if !self.valid || !self.members.iter().any(|(id, _)| *id == canvas) {
return operation::Outcome::None;
}
let target = match self.event {
Event::Focus => Some(canvas),
Event::ControlFocus(name) => Some(self.scope.control_id(name)),
Event::ToggleFocus if self.focused.is_none() => Some(canvas),
Event::ToggleFocus | Event::Leave if self.focused.is_some() => None,
Event::Traverse(back) => {
let Some(current) = self.focused else {
return operation::Outcome::None;
};
let next = if back {
(current + self.members.len() - 1) % self.members.len()
} else {
(current + 1) % self.members.len()
};
Some(self.members[next].0.clone())
}
_ => return operation::Outcome::None,
};
if target
.as_ref()
.is_some_and(|target| !self.members.iter().any(|(id, _)| id == target))
{
return operation::Outcome::None;
}
let reveal = target.as_ref().and_then(|target| {
let (_, field) = self.members.iter().find(|(id, _)| id == target)?;
let (bounds, content) = self.palette?;
// Only the palette has member controls inside this scrollable.
(field.x >= bounds.x && field.x < bounds.x + bounds.width).then(|| {
(field.center_y() - bounds.y - bounds.height / 2.)
.clamp(0., (content.height - bounds.height).max(0.))
})
});
operation::Outcome::Chain(Box::new(Apply {
scope: self.scope,
valid: false,
target,
members: self.members.iter().map(|(id, _)| id.clone()).collect(),
palette: self.scope.control_id("palette-scroll"),
reveal,
}))
}
}
struct Apply {
scope: Scope,
valid: bool,
target: Option<Id>,
members: Vec<Id>,
palette: Id,
reveal: Option<f32>,
}
impl<T> Operation<T> for Apply {
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn Operation<T>)) {
visit(self);
}
fn custom(&mut self, _: Option<&Id>, _: Rectangle, value: &mut dyn std::any::Any) {
if let Some(owner) = value.downcast_ref::<Owner>() {
self.valid = owner.0 == Some(self.scope);
}
}
fn focusable(&mut self, id: Option<&Id>, _: Rectangle, state: &mut dyn operation::Focusable) {
if !self.valid {
return;
}
if self
.target
.as_ref()
.is_some_and(|target| Some(target) == id)
{
state.focus();
} else if self.target.is_some() || id.is_some_and(|id| self.members.contains(id)) {
state.unfocus();
}
}
fn scrollable(
&mut self,
id: Option<&Id>,
_: Rectangle,
_: Rectangle,
_: Vector,
state: &mut dyn operation::Scrollable,
) {
if self.valid
&& id == Some(&self.palette)
&& let Some(y) = self.reveal
{
state.scroll_to(operation::scrollable::AbsoluteOffset {
x: None,
y: Some(y),
});
}
}
}
// Every Collect/Apply pass observes current ownership before touching any field.
struct Owner(Option<Scope>);
struct Root<'a, Message, R: renderer::Renderer> {
owner: Option<Scope>,
content: Element<'a, Message, Theme, R>,
}
pub fn root<'a, Message: 'a, R: renderer::Renderer + 'a>(
content: impl Into<Element<'a, Message, Theme, R>>,
owner: Option<Scope>,
) -> Element<'a, Message, Theme, R> {
Element::new(Root {
owner,
content: content.into(),
})
}
impl<Message, R: renderer::Renderer> Widget<Message, Theme, R> for Root<'_, Message, R> {
fn children(&self) -> Vec<Tree> {
vec![Tree::new(self.content.as_widget())]
}
fn diff(&self, tree: &mut Tree) {
tree.diff_children(std::slice::from_ref(&self.content));
}
fn size(&self) -> Size<Length> {
self.content.as_widget().size()
}
fn layout(&mut self, tree: &mut Tree, renderer: &R, limits: &layout::Limits) -> layout::Node {
self.content
.as_widget_mut()
.layout(&mut tree.children[0], renderer, limits)
}
fn operate(
&mut self,
tree: &mut Tree,
layout: Layout<'_>,
renderer: &R,
operation: &mut dyn Operation,
) {
operation.custom(None, layout.bounds(), &mut Owner(self.owner));
self.content
.as_widget_mut()
.operate(&mut tree.children[0], layout, renderer, operation);
}
fn update(
&mut self,
tree: &mut Tree,
event: &InputEvent,
layout: Layout<'_>,
cursor: mouse::Cursor,
renderer: &R,
clipboard: &mut dyn Clipboard,
shell: &mut Shell<'_, Message>,
viewport: &Rectangle,
) {
self.content.as_widget_mut().update(
&mut tree.children[0],
event,
layout,
cursor,
renderer,
clipboard,
shell,
viewport,
);
}
fn draw(
&self,
tree: &Tree,
renderer: &mut R,
theme: &Theme,
style: &renderer::Style,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
) {
self.content.as_widget().draw(
&tree.children[0],
renderer,
theme,
style,
layout,
cursor,
viewport,
);
}
fn mouse_interaction(
&self,
tree: &Tree,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
renderer: &R,
) -> mouse::Interaction {
self.content.as_widget().mouse_interaction(
&tree.children[0],
layout,
cursor,
viewport,
renderer,
)
}
fn overlay<'b>(
&'b mut self,
tree: &'b mut Tree,
layout: Layout<'b>,
renderer: &R,
viewport: &Rectangle,
translation: Vector,
) -> Option<overlay::Element<'b, Message, Theme, R>> {
self.content.as_widget_mut().overlay(
&mut tree.children[0],
layout,
renderer,
viewport,
translation,
)
}
}
@@ -0,0 +1,760 @@
use super::*;
use crate::app::{App, Message};
use iced::advanced::{Layout, Shell, layout, widget};
use iced::futures::{
StreamExt,
stream::{BoxStream, SelectAll},
};
use iced::{Rectangle, Size, mouse};
use iced_runtime::{
Action,
user_interface::{Cache, UserInterface},
};
use std::collections::HashMap;
type Pending = SelectAll<BoxStream<'static, Action<Message>>>;
// iced_winit drains messages while collecting immediately ready actions. A
// pending Task stream survives until its widget operation sends its result.
fn enqueue(task: iced::Task<Message>, ready: &mut Vec<Action<Message>>, pending: &mut Pending) {
if let Some(mut stream) = iced_runtime::task::into_stream(task) {
let waker = iced::futures::task::noop_waker();
let mut context = std::task::Context::from_waker(&waker);
loop {
match stream.poll_next_unpin(&mut context) {
std::task::Poll::Ready(Some(action)) => ready.push(action),
std::task::Poll::Ready(None) => break,
std::task::Poll::Pending => {
pending.push(stream);
break;
}
}
}
}
}
#[derive(Default)]
pub(crate) struct Geometry {
pub containers: HashMap<widget::Id, Rectangle>,
pub focused: Vec<(Option<widget::Id>, Rectangle, bool)>,
pub text_inputs: Vec<Rectangle>,
pub scrolls: HashMap<widget::Id, (Rectangle, Vector)>,
}
impl widget::Operation for Geometry {
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn widget::Operation)) {
visit(self);
}
fn focusable(
&mut self,
id: Option<&widget::Id>,
bounds: Rectangle,
state: &mut dyn widget::operation::Focusable,
) {
self.focused.push((id.cloned(), bounds, state.is_focused()));
}
fn text_input(
&mut self,
_: Option<&widget::Id>,
bounds: Rectangle,
_: &mut dyn widget::operation::TextInput,
) {
self.text_inputs.push(bounds);
}
fn container(&mut self, id: Option<&widget::Id>, bounds: Rectangle) {
if let Some(id) = id {
self.containers.insert(id.clone(), bounds);
}
}
fn scrollable(
&mut self,
id: Option<&widget::Id>,
bounds: Rectangle,
_: Rectangle,
offset: Vector,
_: &mut dyn widget::operation::Scrollable,
) {
if let Some(id) = id {
self.scrolls.insert(id.clone(), (bounds, offset));
}
}
}
impl Geometry {
pub fn bounds(&self, id: &str) -> Rectangle {
self.containers[&widget::Id::from(id.to_owned())]
}
}
/// Mount the actual App root, production widgets and renderer. Events are
/// delivered in order with Iced's final-batch cursor, then fed through App::update.
/// This deliberately does not seed any private canvas gesture state.
pub(crate) struct Mounted {
pub renderer: iced::Renderer,
cache: Cache,
pub operations: usize,
}
impl Mounted {
pub async fn unshown() -> Self {
use iced::advanced::renderer::Headless;
let renderer = <iced::Renderer as Headless>::new(iced::Font::DEFAULT, 16.into(), None)
.await
.expect("production mounted tests require the supported WGPU headless renderer");
Self {
renderer,
cache: Cache::new(),
operations: 0,
}
}
pub async fn new(app: &mut App) -> Self {
let mut mounted = Self::unshown().await;
mounted.settle(app).await;
mounted
}
pub fn geometry(&mut self, app: &App) -> Geometry {
let mut geometry = Geometry::default();
self.operate(app, &mut geometry);
geometry
}
/// One operation pass, including every nested overlay, via installed Iced.
pub fn operate(&mut self, app: &App, operation: &mut dyn widget::Operation) {
let mut ui = UserInterface::build(
app.view(),
app.window_size,
std::mem::take(&mut self.cache),
&mut self.renderer,
);
ui.operate(&self.renderer, operation);
self.cache = ui.into_cache();
}
pub async fn settle(&mut self, app: &mut App) {
for _ in 0..3 {
self.batch(
app,
&[iced::Event::Window(iced::window::Event::RedrawRequested(
std::time::Instant::now(),
))],
mouse::Cursor::Unavailable,
)
.await;
}
}
pub async fn batch(
&mut self,
app: &mut App,
events: &[iced::Event],
cursor: mouse::Cursor,
) -> Vec<Message> {
let messages = self.input(app, events, cursor);
let mut ready = vec![];
let mut pending = Pending::new();
for message in messages.iter().cloned() {
enqueue(app.update(message), &mut ready, &mut pending);
}
self.drain(app, ready, pending).await;
messages
}
pub fn input(
&mut self,
app: &App,
events: &[iced::Event],
cursor: mouse::Cursor,
) -> Vec<Message> {
let mut messages = vec![];
let mut ui = UserInterface::build(
app.view(),
app.window_size,
std::mem::take(&mut self.cache),
&mut self.renderer,
);
let _ = ui.update(
events,
cursor,
&mut self.renderer,
&mut iced::advanced::clipboard::Null,
&mut messages,
);
self.cache = ui.into_cache();
messages
}
pub async fn perform(&mut self, app: &mut App, task: iced::Task<Message>) {
let mut ready = vec![];
let mut pending = Pending::new();
enqueue(task, &mut ready, &mut pending);
self.drain(app, ready, pending).await;
}
async fn drain(
&mut self,
app: &mut App,
mut ready: Vec<Action<Message>>,
mut pending: Pending,
) {
loop {
let mut outputs = vec![];
if !ready.is_empty() {
// Build only after all App messages in this cycle have run.
let mut ui = UserInterface::build(
app.view(),
app.window_size,
std::mem::take(&mut self.cache),
&mut self.renderer,
);
for action in ready.drain(..) {
match action {
Action::Output(message) => outputs.push(message),
Action::Widget(mut operation) => {
self.operations += 1;
loop {
ui.operate(&self.renderer, &mut *operation);
match operation.finish() {
widget::operation::Outcome::Chain(next) => operation = next,
widget::operation::Outcome::None
| widget::operation::Outcome::Some(()) => break,
}
}
}
action => {
panic!("Unexpected native action in mounted App harness: {action:?}")
}
}
}
self.cache = ui.into_cache();
}
for message in outputs {
enqueue(app.update(message), &mut ready, &mut pending);
}
if !ready.is_empty() {
continue;
}
let Some(action) = pending.next().await else {
break;
};
ready.push(action);
let waker = iced::futures::task::noop_waker();
let mut context = std::task::Context::from_waker(&waker);
while let std::task::Poll::Ready(Some(action)) = pending.poll_next_unpin(&mut context) {
ready.push(action);
}
}
}
pub fn pixels(&mut self, app: &App) -> Vec<u8> {
use iced::advanced::renderer::Headless;
let mut ui = UserInterface::build(
app.view(),
app.window_size,
std::mem::take(&mut self.cache),
&mut self.renderer,
);
// update initializes the nested overlay layout used by draw.
let mut messages = vec![];
let _ = ui.update(
&[],
mouse::Cursor::Unavailable,
&mut self.renderer,
&mut iced::advanced::clipboard::Null,
&mut messages,
);
assert!(
messages.is_empty(),
"An empty draw preparation must not drop App messages"
);
ui.draw(
&mut self.renderer,
&crate::style::theme(),
&iced::advanced::renderer::Style {
text_color: crate::style::INK,
},
mouse::Cursor::Unavailable,
);
self.cache = ui.into_cache();
Headless::screenshot(
&mut self.renderer,
Size::new(app.window_size.width as u32, app.window_size.height as u32),
1.,
crate::style::BG,
)
}
pub async fn click(&mut self, app: &mut App, point: Point) -> Vec<Message> {
self.batch(
app,
&[
iced::Event::Mouse(mouse::Event::CursorMoved { position: point }),
iced::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
iced::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)),
],
mouse::Cursor::Available(point),
)
.await
}
pub async fn pan(&mut self, app: &mut App, start: Point, end: Point) -> Vec<Message> {
self.batch(
app,
&[
iced::Event::Mouse(mouse::Event::CursorMoved { position: start }),
iced::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Middle)),
iced::Event::Mouse(mouse::Event::CursorMoved { position: end }),
iced::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Middle)),
],
mouse::Cursor::Available(end),
)
.await
}
pub fn scroll(&mut self, app: &App, id: &str, offset: f32) {
let mut operation = widget::operation::scrollable::scroll_to::<()>(
widget::Id::from(id.to_owned()),
widget::operation::scrollable::AbsoluteOffset {
x: None,
y: Some(offset),
},
);
self.operate(app, &mut operation);
}
}
pub(crate) fn key(named: iced::keyboard::key::Named) -> iced::Event {
use iced::keyboard::{self, Key};
iced::Event::Keyboard(keyboard::Event::KeyPressed {
key: Key::Named(named),
modified_key: Key::Named(named),
physical_key: keyboard::key::Physical::Unidentified(
keyboard::key::NativeCode::Unidentified,
),
location: keyboard::Location::Standard,
modifiers: keyboard::Modifiers::empty(),
text: None,
repeat: false,
})
}
#[test]
fn relative_camera_zoom_keeps_the_cursor_world_point_and_rejects_nonfinite_inputs() {
for scale in [0.05, 0.5, 1., 2.] {
let mut pan = Vector::new(-130., 75.);
let mut zoom = Some(scale);
let mut fit = false;
let at = Point::new(171., 93.);
let world = (at - Point::ORIGIN - pan) / scale;
change(
&mut pan,
&mut zoom,
&mut fit,
Event::Zoom {
factor: 1.25,
at,
base: scale,
},
);
let after = (at - Point::ORIGIN - pan) / zoom.unwrap();
assert!((after.x - world.x).abs() < 0.001 && (after.y - world.y).abs() < 0.001);
let before = (pan, zoom, fit);
for input in [
Event::Pan(Vector::new(f32::INFINITY, 0.)),
Event::Zoom {
factor: f32::NAN,
at,
base: scale,
},
Event::Zoom {
factor: 0.,
at,
base: scale,
},
] {
assert!(!change(&mut pan, &mut zoom, &mut fit, input));
assert_eq!((pan, zoom, fit), before);
}
}
}
#[test]
fn viewport_ordered_batch_uses_each_event_position_and_cancels_before_camera_motion() {
use iced::widget::Space;
let scope = Scope {
scenario_view: None,
document: Id::new_v4(),
owner: Id::new_v4(),
revision: 2,
epoch: 0,
};
let mut view: Element<'_, Event, Theme, ()> = Viewport::new(
Space::new().width(Fill).height(Fill),
scope,
1.,
true,
|e| e,
)
.into();
let mut tree = widget::Tree::new(view.as_widget());
let node = view
.as_widget_mut()
.layout(
&mut tree,
&(),
&layout::Limits::new(Size::ZERO, Size::new(400., 300.)),
)
.move_to(Point::new(30., 80.));
let start = Point::new(90., 160.);
let middle = Point::new(125., 175.);
let end = Point::new(145., 185.);
let mut messages = vec![];
for event in [
mouse::Event::CursorMoved { position: start },
mouse::Event::ButtonPressed(mouse::Button::Middle),
mouse::Event::CursorMoved { position: middle },
mouse::Event::CursorMoved { position: end },
mouse::Event::ButtonReleased(mouse::Button::Middle),
] {
view.as_widget_mut().update(
&mut tree,
&iced::Event::Mouse(event),
Layout::new(&node),
mouse::Cursor::Available(end),
&(),
&mut iced::advanced::clipboard::Null,
&mut Shell::new(&mut messages),
&Rectangle::with_size(Size::new(700., 600.)),
);
}
assert!(matches!(messages.first(), Some(Event::CancelGesture)));
let total = messages
.iter()
.fold(Vector::ZERO, |total, event| match event {
Event::Pan(delta) => total + *delta,
_ => total,
});
assert_eq!(total, end - start);
}
pub(crate) fn scope(app: &App) -> Scope {
if let Some(context) = app.scenario_context() {
Scope {
document: context.document,
owner: context.scenario,
scenario_view: Some((context.representation, context.visit)),
revision: context.revision,
epoch: app.camera_epoch,
}
} else {
let context = app.topology_context().expect("mounted behavior owner");
Scope {
document: context.document,
owner: context.owner,
scenario_view: None,
revision: context.revision,
epoch: app.camera_epoch,
}
}
}
pub(crate) fn focused(mounted: &mut Mounted, app: &App, name: &str) -> bool {
let id = scope(app).control_id(name);
mounted
.geometry(app)
.focused
.iter()
.any(|(member, _, focused)| member.as_ref() == Some(&id) && *focused)
}
pub(crate) async fn tab_to(mounted: &mut Mounted, app: &mut App, name: &str) {
for _ in 0..32 {
if focused(mounted, app, name) {
return;
}
mounted
.batch(
app,
&[key(iced::keyboard::key::Named::Tab)],
mouse::Cursor::Unavailable,
)
.await;
}
panic!("Keyboard traversal did not reach {name}");
}
pub(crate) fn camera_delta(messages: &[Message]) -> Message {
messages.iter().find(|m| matches!(m,
Message::ScenarioCanvas(crate::behavior::scenario::CanvasEvent::Camera(_, _, Event::Pan(delta)))
| Message::TopologyCanvas(crate::behavior::topology::CanvasEvent::Camera(_, _, Event::Pan(delta))) if *delta != Vector::ZERO
)).expect("actual nonzero camera delta from a mounted gesture").clone()
}
pub(crate) async fn press_middle(mounted: &mut Mounted, app: &mut App, point: Point) {
mounted
.batch(
app,
&[
iced::Event::Mouse(mouse::Event::CursorMoved { position: point }),
iced::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Middle)),
],
mouse::Cursor::Available(point),
)
.await;
}
pub(crate) async fn release_middle(
mounted: &mut Mounted,
app: &mut App,
point: Point,
) -> Vec<Message> {
mounted
.batch(
app,
&[
iced::Event::Mouse(mouse::Event::CursorMoved { position: point }),
iced::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Middle)),
],
mouse::Cursor::Available(point),
)
.await
}
pub(crate) fn assert_drawn_focus(before: &[u8], after: &[u8], size: Size, bounds: Rectangle) {
let width = size.width as usize;
assert_eq!(before.len(), width * size.height as usize * 4);
assert_eq!(after.len(), before.len());
let mut changed = 0;
for y in bounds.y.ceil() as usize..(bounds.y + bounds.height).floor() as usize {
for x in bounds.x.ceil() as usize..(bounds.x + bounds.width).floor() as usize {
if x as f32 - bounds.x > 2.
&& bounds.x + bounds.width - x as f32 > 2.
&& y as f32 - bounds.y > 2.
&& bounds.y + bounds.height - y as f32 > 2.
{
continue;
}
let start = (y * width + x) * 4;
if before[start..start + 3] != after[start..start + 3] {
changed += 1;
}
}
}
assert!(
changed >= bounds.width.min(bounds.height) as usize,
"Focus must change visible border pixels, changed={changed}, bounds={bounds:?}"
);
}
pub(crate) fn character(value: &str) -> iced::Event {
let mut event = key(iced::keyboard::key::Named::Space);
if let iced::Event::Keyboard(iced::keyboard::Event::KeyPressed {
key,
modified_key,
text,
..
}) = &mut event
{
*key = iced::keyboard::Key::Character(value.into());
*modified_key = key.clone();
*text = Some(value.into());
}
event
}
/// Inspect the action without stripping the lifetime from retained packets.
pub(crate) fn intent_action(message: &Message) -> &Message {
match message {
Message::BehaviorControl(intent) => &intent.message,
message => message,
}
}
/// Deliberately retain a returned Chain to test the stronger ownership invariant.
/// Native Iced runs Chain passes synchronously; this is not a native interleaving.
pub(crate) async fn retained_focus_apply(
mounted: &mut Mounted,
app: &mut App,
) -> Box<dyn widget::Operation> {
let entry = mounted.input(
app,
&[key(iced::keyboard::key::Named::F6)],
mouse::Cursor::Unavailable,
);
assert_eq!(
entry.len(),
1,
"actual initial F6 must emit one canvas focus request"
);
let mut stream = iced_runtime::task::into_stream(app.update(entry[0].clone()))
.expect("F6 returns a widget Task");
let Some(Action::Widget(mut operation)) = stream.next().await else {
panic!("F6 first action must be Collect");
};
mounted.operate(app, &mut *operation);
match operation.finish() {
widget::operation::Outcome::Chain(apply) => apply,
_ => panic!("Live Collect must produce its guarded Apply pass"),
}
}
pub(crate) async fn overflow_last_tool(mounted: &mut Mounted, app: &mut App, last: &'static str) {
use iced::keyboard::key::Named;
let geometry = mounted.geometry(app);
let last_bounds = geometry.containers[&scope(app).control_id(last)];
let (rail, offset) = geometry.scrolls[&scope(app).control_id("palette-scroll")];
assert_eq!(offset.y, 0.);
assert!(
last_bounds.y >= rail.y + rail.height,
"last tool starts fully below the overflowing palette: tool={last_bounds:?}, rail={rail:?}"
);
mounted
.batch(app, &[key(Named::F6)], mouse::Cursor::Unavailable)
.await;
tab_to(mounted, app, last).await;
let geometry = mounted.geometry(app);
let mut visible = geometry.containers[&scope(app).control_id(last)];
let (rail, offset) = geometry.scrolls[&scope(app).control_id("palette-scroll")];
assert!(offset.y > 0., "keyboard traversal must scroll the palette");
visible.x -= offset.x;
visible.y -= offset.y;
assert!(
visible.y >= rail.y && visible.y + visible.height <= rail.y + rail.height + 0.5,
"the entire last tool is revealed: tool={visible:?}, rail={rail:?}"
);
let messages = mounted
.batch(app, &[key(Named::Enter)], mouse::Cursor::Unavailable)
.await;
assert!(
messages.iter().any(
|message| matches!(message, Message::BehaviorControl(intent) if intent.name == last)
),
"the revealed last tool is keyboard activated"
);
assert!(focused(mounted, app, last));
let on = mounted.pixels(app);
mounted
.batch(app, &[key(Named::Escape)], mouse::Cursor::Unavailable)
.await;
let off = mounted.pixels(app);
assert_drawn_focus(&off, &on, app.window_size, visible);
}
pub(crate) async fn retained_toolbar_boundaries(
mounted: &mut Mounted,
app: &mut App,
tool: &'static str,
) {
use iced::keyboard::key::Named;
for boundary in ["Chooser", "Dirty", "Picker"] {
let mut packets = vec![];
for name in ["Fit", tool, "Details", "Panels", "Canvas"] {
packets.push(capture_toolbar(mounted, app, name));
}
let lifetime = app.camera_epoch;
let epoch = app.canvas_epoch;
let process = app.behavior.topology.camera_boundary();
let scenario = app.behavior.scenario.camera_boundary();
match boundary {
"Chooser" => {
let draft = app.draft.as_ref().unwrap();
let id = crate::forms::reference_chooser::trigger::field_id(
draft.reference_generation(),
"/parent",
);
let bounds = mounted
.geometry(app)
.focused
.iter()
.find(|(field, _, _)| field.as_ref() == Some(&id))
.expect("real Inspector parent reference trigger")
.1;
let request = draft.reference_open(app.project(), "/parent", bounds);
let task = app.update(Message::FieldReferenceOpen(request));
mounted.perform(app, task).await;
assert!(app.reference_input_active());
mounted
.batch(app, &[key(Named::Escape)], mouse::Cursor::Unavailable)
.await;
assert!(!app.reference_input_active());
}
"Dirty" => {
let task = app.update(Message::Field(
"/name".into(),
"Unsaved camera boundary".into(),
));
mounted.perform(app, task).await;
assert!(app.draft_dirty());
// Every retained packet is also rejected while the owner is dirty.
for packet in packets.iter().cloned() {
let task = app.update(packet);
mounted.perform(app, task).await;
}
assert!(app.draft_dirty());
let task = app.update(Message::RevertProperties);
mounted.perform(app, task).await;
assert!(!app.draft_dirty());
}
"Picker" => {
// Public transitions, with cancellation injected at the external
// picker boundary. Never poll the native dialog future here.
let picker = app.update(Message::Save(true));
if cfg!(target_os = "linux") {
assert!(app.file_prompt.is_some());
mounted.perform(app, picker).await;
let task = app.update(Message::CancelFile);
mounted.perform(app, task).await;
} else {
assert!(app.busy, "Save As enters the real native-picker busy state");
drop(picker);
for packet in packets.iter().cloned() {
let task = app.update(packet);
mounted.perform(app, task).await;
}
let task = app.update(Message::SavePath(None));
mounted.perform(app, task).await;
assert!(!app.busy);
}
}
_ => unreachable!(),
}
assert_ne!(
app.camera_epoch, lifetime,
"{boundary} changes the presentation lifetime"
);
assert_eq!(
app.canvas_epoch, epoch,
"{boundary} cycle leaves semantic context unchanged"
);
let focus_before = mounted
.geometry(app)
.focused
.into_iter()
.filter(|(_, _, value)| *value)
.collect::<Vec<_>>();
let current_lifetime = app.camera_epoch;
for packet in packets {
let task = app.update(packet);
mounted.perform(app, task).await;
}
assert_eq!(
app.camera_epoch, current_lifetime,
"{boundary} retained intents cannot start a new lifetime"
);
assert!(
app.behavior.topology.camera_boundary() == process,
"{boundary} retained toolbar packet changed process presentation"
);
assert!(
app.behavior.scenario.camera_boundary() == scenario,
"{boundary} retained toolbar packet changed scenario presentation"
);
assert_eq!(
mounted
.geometry(app)
.focused
.into_iter()
.filter(|(_, _, value)| *value)
.collect::<Vec<_>>(),
focus_before,
"{boundary} retained toolbar packet stole field focus"
);
}
}
pub(crate) fn capture_toolbar(mounted: &mut Mounted, app: &App, name: &'static str) -> Message {
let point = mounted.geometry(app).containers[&scope(app).control_id(name)].center();
mounted
.input(
app,
&[
iced::Event::Mouse(mouse::Event::CursorMoved { position: point }),
iced::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
iced::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)),
],
mouse::Cursor::Available(point),
)
.into_iter()
.find(|message| matches!(message, Message::BehaviorControl(intent) if intent.name == name))
.expect("actual production toolbar packet")
}
@@ -0,0 +1,426 @@
use super::{Event as CameraEvent, Scope};
use iced::advanced::{
Clipboard, Layout, Shell, Widget, layout, overlay, renderer,
widget::{Operation, Tree, operation, tree},
};
use iced::{Border, Element, Event, Length, Point, Rectangle, Size, Theme, Vector};
use iced::{keyboard, mouse};
#[derive(Default)]
struct Input {
scope: Option<Scope>,
focused: bool,
pointer: Option<Point>,
pan: Option<(mouse::Button, Point)>,
space: bool,
bounds: Option<Rectangle>,
}
impl operation::Focusable for Input {
fn is_focused(&self) -> bool {
self.focused
}
fn focus(&mut self) {
self.focused = true;
}
fn unfocus(&mut self) {
self.focused = false;
self.pan = None;
self.space = false;
}
}
pub struct Viewport<'a, Message, R = iced::Renderer>
where
R: renderer::Renderer,
{
content: Element<'a, Message, Theme, R>,
scope: Scope,
scale: f32,
keyboard: bool,
blank_pan: bool,
publish: Box<dyn Fn(CameraEvent) -> Message + 'a>,
}
impl<'a, Message, R: renderer::Renderer> Viewport<'a, Message, R> {
pub fn new(
content: impl Into<Element<'a, Message, Theme, R>>,
scope: Scope,
scale: f32,
keyboard: bool,
publish: impl Fn(CameraEvent) -> Message + 'a,
) -> Self {
Self {
content: content.into(),
scope,
scale,
keyboard,
blank_pan: false,
publish: Box::new(publish),
}
}
pub fn pan_background(mut self, enabled: bool) -> Self {
self.blank_pan = enabled;
self
}
}
impl<Message, R: renderer::Renderer> Widget<Message, Theme, R> for Viewport<'_, Message, R> {
fn tag(&self) -> tree::Tag {
tree::Tag::of::<Input>()
}
fn state(&self) -> tree::State {
tree::State::new(Input {
scope: Some(self.scope),
..Default::default()
})
}
fn children(&self) -> Vec<Tree> {
vec![Tree::new(self.content.as_widget())]
}
fn diff(&self, tree: &mut Tree) {
let state = tree.state.downcast_mut::<Input>();
if state.scope.is_none_or(|s| {
(s.document, s.owner, s.revision)
!= (self.scope.document, self.scope.owner, self.scope.revision)
}) {
*state = Input::default();
}
if state
.scope
.is_some_and(|scope| scope.epoch != self.scope.epoch)
|| !self.keyboard
{
state.pan = None;
state.space = false;
}
if !self.keyboard {
state.focused = false;
}
state.scope = Some(self.scope);
tree.diff_children(std::slice::from_ref(&self.content));
}
fn size(&self) -> Size<Length> {
Size::new(Length::Fill, Length::Fill)
}
fn layout(&mut self, tree: &mut Tree, renderer: &R, limits: &layout::Limits) -> layout::Node {
self.content
.as_widget_mut()
.layout(&mut tree.children[0], renderer, limits)
}
fn operate(
&mut self,
tree: &mut Tree,
layout: Layout<'_>,
renderer: &R,
operation: &mut dyn Operation,
) {
if self.keyboard {
let id = self.scope.control_id("viewport");
operation.custom(
Some(&id),
layout.bounds(),
&mut super::focus::Member(self.scope),
);
operation.focusable(
Some(&id),
layout.bounds(),
tree.state.downcast_mut::<Input>(),
);
}
self.content
.as_widget_mut()
.operate(&mut tree.children[0], layout, renderer, operation);
}
fn update(
&mut self,
tree: &mut Tree,
event: &Event,
layout: Layout<'_>,
cursor: mouse::Cursor,
renderer: &R,
clipboard: &mut dyn Clipboard,
shell: &mut Shell<'_, Message>,
viewport: &Rectangle,
) {
if shell.is_event_captured() {
return;
}
let state = tree.state.downcast_mut::<Input>();
let changed_bounds = state.bounds.is_some_and(|bounds| bounds != layout.bounds());
state.bounds = Some(layout.bounds());
if changed_bounds || !self.keyboard {
state.pan = None;
state.space = false;
}
if !self.keyboard {
state.focused = false;
}
if let Event::Mouse(mouse::Event::CursorMoved { position }) = event {
state.pointer = Some(*position);
}
let pointer = match event {
Event::Mouse(
mouse::Event::ButtonPressed(_)
| mouse::Event::ButtonReleased(_)
| mouse::Event::WheelScrolled { .. },
) => state.pointer.or_else(|| cursor.position()),
Event::Mouse(mouse::Event::CursorMoved { position }) => Some(*position),
_ => cursor.position().or(state.pointer),
};
let visible = layout.bounds().intersection(viewport);
let inside = pointer.is_some_and(|p| visible.is_some_and(|v| v.contains(p)))
&& cursor.position().is_some();
if !self.keyboard {
state.space = false;
}
let mut cancel = changed_bounds;
let mut camera = None;
let mut capture = false;
match event {
Event::Window(iced::window::Event::Unfocused | iced::window::Event::Resized(_)) => {
state.pan = None;
state.space = false;
state.focused = false;
}
Event::Mouse(mouse::Event::CursorLeft) => {
state.pan = None;
state.pointer = None;
}
Event::Mouse(mouse::Event::ButtonPressed(button)) => {
if state.pan.is_some_and(|(held, _)| held != *button) || !inside {
state.pan = None;
state.space = false;
cancel = true;
}
state.focused = inside && self.keyboard;
if inside
&& self.keyboard
&& (*button == mouse::Button::Middle
|| (*button == mouse::Button::Left && state.space))
{
state.pan = pointer.map(|p| (*button, p));
cancel = true;
camera = Some(CameraEvent::CancelGesture);
capture = true;
}
}
Event::Mouse(mouse::Event::CursorMoved { position }) => {
if let Some((button, previous)) = state.pan {
state.pan = Some((button, *position));
camera = Some(CameraEvent::Pan(*position - previous));
capture = true;
}
}
Event::Mouse(mouse::Event::ButtonReleased(button)) => {
if let Some((held, previous)) = state.pan
&& held == *button
{
state.pan = None;
camera = pointer
.filter(|_| inside)
.map(|p| CameraEvent::Pan(p - previous));
capture = true;
}
}
Event::Mouse(mouse::Event::WheelScrolled { delta }) if inside && state.focused => {
state.pan = None;
cancel = true;
let y = match delta {
mouse::ScrollDelta::Lines { y, .. } => *y,
mouse::ScrollDelta::Pixels { y, .. } => *y / 40.,
};
if y != 0. {
let at = pointer.unwrap() - Vector::new(layout.bounds().x, layout.bounds().y);
camera = Some(CameraEvent::Zoom {
factor: 1.1_f32.powf(y.clamp(-8., 8.)),
at,
base: self.scale,
});
capture = true;
}
}
Event::Keyboard(keyboard::Event::KeyPressed {
key: keyboard::Key::Named(keyboard::key::Named::F6),
modifiers,
repeat: false,
..
}) if self.keyboard && modifiers.is_empty() => {
camera = Some(CameraEvent::ToggleFocus);
capture = true;
cancel = true;
}
Event::Keyboard(keyboard::Event::KeyPressed { key, modifiers, .. })
if state.focused && self.keyboard =>
{
use keyboard::{Key, key::Named};
match key.as_ref() {
Key::Named(Named::Space) => {
state.space = true;
cancel = true;
camera = Some(CameraEvent::CancelGesture);
capture = true;
}
Key::Named(Named::Tab) => {
camera = Some(CameraEvent::Traverse(modifiers.shift()));
capture = true;
}
Key::Named(Named::Escape) => {
state.pan = None;
state.space = false;
camera = Some(CameraEvent::Leave);
capture = true;
}
Key::Named(key) if modifiers.is_empty() => {
let delta = match key {
Named::ArrowLeft => Some(Vector::new(60., 0.)),
Named::ArrowRight => Some(Vector::new(-60., 0.)),
Named::ArrowUp => Some(Vector::new(0., 60.)),
Named::ArrowDown => Some(Vector::new(0., -60.)),
_ => None,
};
camera = delta.map(CameraEvent::Pan);
capture = camera.is_some();
cancel = capture;
}
_ => {}
}
}
Event::Keyboard(keyboard::Event::KeyReleased { key, .. })
if *key == keyboard::Key::Named(keyboard::key::Named::Space) =>
{
state.space = false;
}
_ => {}
}
if cancel {
self.content.as_widget_mut().update(
&mut tree.children[0],
&Event::Window(iced::window::Event::Unfocused),
layout,
cursor,
renderer,
clipboard,
shell,
viewport,
);
}
if let Some(event) = camera {
shell.publish((self.publish)(event));
}
if capture {
shell.capture_event();
shell.request_redraw();
return;
}
if matches!(event, Event::Keyboard(_)) && (!state.focused || !self.keyboard) {
return;
}
let child_cursor = if inside {
pointer.map_or(mouse::Cursor::Unavailable, mouse::Cursor::Available)
} else {
mouse::Cursor::Unavailable
};
self.content.as_widget_mut().update(
&mut tree.children[0],
event,
layout,
child_cursor,
renderer,
clipboard,
shell,
viewport,
);
// A semantic hit or armed creation tool gets first refusal. In Select,
// an otherwise unused blank press starts the same camera gesture.
if self.blank_pan
&& self.keyboard
&& inside
&& !shell.is_event_captured()
&& matches!(
event,
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left))
)
{
state.pan = pointer.map(|p| (mouse::Button::Left, p));
shell.publish((self.publish)(CameraEvent::CancelGesture));
shell.capture_event();
shell.request_redraw();
}
}
fn draw(
&self,
tree: &Tree,
renderer: &mut R,
theme: &Theme,
style: &renderer::Style,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
) {
renderer.with_layer(layout.bounds(), |renderer| {
self.content.as_widget().draw(
&tree.children[0],
renderer,
theme,
style,
layout,
cursor,
viewport,
);
});
if tree.state.downcast_ref::<Input>().focused && self.keyboard {
renderer.fill_quad(
renderer::Quad {
bounds: layout.bounds(),
border: Border {
color: crate::style::TEAL,
width: 1.,
..Default::default()
},
..Default::default()
},
iced::Color::TRANSPARENT,
);
}
}
fn mouse_interaction(
&self,
tree: &Tree,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
renderer: &R,
) -> mouse::Interaction {
if tree.state.downcast_ref::<Input>().pan.is_some() {
mouse::Interaction::Grabbing
} else {
self.content.as_widget().mouse_interaction(
&tree.children[0],
layout,
cursor,
viewport,
renderer,
)
}
}
fn overlay<'a>(
&'a mut self,
tree: &'a mut Tree,
layout: Layout<'a>,
renderer: &R,
viewport: &Rectangle,
translation: Vector,
) -> Option<overlay::Element<'a, Message, Theme, R>> {
self.content.as_widget_mut().overlay(
&mut tree.children[0],
layout,
renderer,
viewport,
translation,
)
}
}
impl<'a, Message: 'a, R: renderer::Renderer + 'a> From<Viewport<'a, Message, R>>
for Element<'a, Message, Theme, R>
{
fn from(viewport: Viewport<'a, Message, R>) -> Self {
Element::new(viewport)
}
}
File diff suppressed because it is too large Load Diff
+766
View File
@@ -0,0 +1,766 @@
//! Local behavior creation. A draft is not a model object: only its validated
//! batch may cross Document::execute, and acknowledgement follows success.
use iced::{
Element as UiElement, Fill,
widget::{button, checkbox, column, container, row, scrollable, text, text_input},
};
use oa_core::*;
use serde::{Deserialize, Serialize};
const PAGE_SIZE: usize = 6;
pub const NAME_INPUT: &str = "behavior-creation-name";
// A focused text input consumes Escape. Preserve the application's modal
// priority by emitting its ordinary Escape message from this narrow bridge.
pub fn subscription(active: bool) -> iced::Subscription<()> {
if active {
iced::event::listen_with(captured_escape)
} else {
iced::Subscription::none()
}
}
fn captured_escape(
event: iced::Event,
status: iced::event::Status,
_: iced::window::Id,
) -> Option<()> {
(status == iced::event::Status::Captured
&& matches!(
event,
iced::Event::Keyboard(iced::keyboard::Event::KeyPressed {
key: iced::keyboard::Key::Named(iced::keyboard::key::Named::Escape),
..
})
))
.then_some(())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum Family {
Sequence,
Scenario,
Machine,
}
impl Family {
fn kind(self, layer: Layer) -> Kind {
match (self, layer) {
(Self::Sequence, Layer::Oa) => Kind::Process,
(Self::Sequence, Layer::Sa) => Kind::FunctionalChain,
(Self::Scenario, _) => Kind::Scenario,
(Self::Machine, _) => Kind::StateMachine,
}
}
fn title(self, layer: Layer) -> &'static str {
match (self, layer) {
(Self::Sequence, Layer::Oa) => "Operational process",
(Self::Sequence, Layer::Sa) => "Functional chain",
(Self::Scenario, _) => "Scenario",
(Self::Machine, _) => "State machine",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Picker {
Owner,
Context,
}
impl Picker {
fn index(self) -> usize {
match self {
Self::Owner => 0,
Self::Context => 1,
}
}
}
#[derive(Debug, Clone)]
pub enum Event {
Source(super::source_draft::Event),
Open(Layer),
Choose(Family),
Name(String),
Owner(Id),
Viewpoint(ScenarioViewpoint),
Context(Option<Id>),
ContextOptions(bool),
FirstView(bool),
Search(Picker, String),
Page(Picker, usize),
Cancel,
Submit,
}
#[derive(Clone, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct Browser {
query: String,
page: usize,
}
#[derive(Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct Draft {
#[serde(default)]
source: super::source_draft::State,
#[serde(default = "Id::new_v4")]
invocation: Id,
version: u32,
document: Id,
layer: Layer,
family: Option<Family>,
name: String,
owner: Option<Id>,
viewpoint: Option<ScenarioViewpoint>,
context: Option<Id>,
#[serde(default)]
context_open: bool,
first_view: bool,
element: Id,
diagram: Id,
placement: Id,
browsers: [Browser; 2],
}
#[derive(Default)]
pub struct State {
draft: Option<Draft>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Created {
pub behavior: Id,
pub layer: Layer,
pub diagram: Option<Id>,
}
impl State {
pub fn source_active(&self) -> bool {
self.draft.as_ref().is_some_and(|d| d.source.engaged())
}
pub fn source_open(&self) -> bool {
self.draft.as_ref().is_some_and(|d| d.source.is_open())
}
pub fn source_back(&mut self) -> bool {
self.draft.as_mut().is_some_and(|d| d.source.back())
}
pub fn has_pending(&self) -> bool {
self.draft.is_some()
}
pub fn pending_layer(&self) -> Option<Layer> {
self.draft.as_ref().map(|d| d.layer)
}
/// Capture this before successful acknowledgement; never select these IDs
/// on mere planning or a rejected source/model transaction.
pub fn created(&self) -> Option<Created> {
let d = self.draft.as_ref()?;
d.family.map(|family| Created {
behavior: d.element,
layer: d.layer,
diagram: (family == Family::Machine && d.first_view).then_some(d.diagram),
})
}
/// Match before execution, then apply this target only after success.
/// Identity alone is insufficient: changed bodies or trailing operations
/// must not inherit the prepared creation's selection/opening contract.
pub fn created_by(&self, p: &Project, command: &Command) -> Option<Created> {
let target = self.created()?;
(self.prepare_pending(p).ok().as_ref() == Some(command)).then_some(target)
}
pub fn recovery_snapshot(&self) -> Result<Option<serde_json::Value>, String> {
self.draft
.as_ref()
.map(serde_json::to_value)
.transpose()
.map_err(|e| e.to_string())
}
pub fn from_recovery(value: serde_json::Value) -> Result<Self, String> {
let draft: Draft = serde_json::from_value(value).map_err(|e| e.to_string())?;
if draft.version != 1 {
return Err("Unsupported behavior creation draft version.".into());
}
// Raw values and stale references remain editable; planning validates
// against the current document rather than silently fixing the draft.
Ok(Self { draft: Some(draft) })
}
pub fn acknowledge_commit(&mut self) {
self.draft = None;
}
pub fn prepare_pending(&self, p: &Project) -> Result<Command, String> {
self.draft
.as_ref()
.ok_or("Choose New to start a behavior.")?
.command(p)
}
pub fn update(&mut self, p: &Project, event: Event) -> Result<Option<Command>, String> {
if let Event::Source(event) = event {
if self
.draft
.as_mut()
.ok_or("Start the consumer form first.")?
.source
.dismiss(&event)?
{
return Ok(None);
}
let d = self
.draft
.as_ref()
.ok_or("Start the consumer form first.")?;
if d.family != Some(Family::Machine) {
return Err("This reference does not support staged source creation.".into());
}
if d.document != p.id {
return Err("This behavior draft belongs to another document.".into());
}
let mut next = d.clone();
if let Some(id) = next.source.update(
p,
super::source_draft::Scope::Machine { layer: d.layer },
d.invocation,
event,
)? {
next.owner = Some(id);
let preview = next
.source
.preview(p, next.owner)?
.ok_or("New owner preview is missing.")?;
if !eligible(&preview, next.layer, Picker::Owner, id) {
return Err("This participant cannot own the machine.".into());
}
}
self.draft = Some(next);
return Ok(None);
}
if self.source_open() && !matches!(&event, Event::Cancel) {
return Err("Use the new source or go Back before editing the parent form.".into());
}
match event {
Event::Cancel => {
self.draft = None;
return Ok(None);
}
Event::Open(layer) => {
if self.has_pending() {
return Err("Create or cancel the pending behavior first.".into());
}
self.draft = Some(Draft {
source: Default::default(),
invocation: Id::new_v4(),
version: 1,
document: p.id,
layer,
family: None,
name: String::new(),
owner: None,
viewpoint: None,
context: None,
context_open: false,
first_view: false,
element: Id::new_v4(),
diagram: Id::new_v4(),
placement: Id::new_v4(),
browsers: Default::default(),
});
return Ok(None);
}
Event::Submit => return self.prepare_pending(p).map(Some),
_ => {}
}
let d = self
.draft
.as_mut()
.ok_or("Choose New to start a behavior.")?;
if d.document != p.id {
return Err("This draft belongs to another document. Cancel it to start here.".into());
}
match event {
Event::Choose(family) => {
if d.family.is_some_and(|old| old != family) {
return Err(
"Cancel this draft before choosing a different behavior family.".into(),
);
}
d.family = Some(family);
}
Event::Name(name) => d.name = name,
Event::Owner(id) if d.family == Some(Family::Machine) => {
if !eligible(p, d.layer, Picker::Owner, id) {
return Err("Choose an existing participant in this layer.".into());
}
d.owner = Some(id);
}
Event::Viewpoint(viewpoint) if d.family == Some(Family::Scenario) => {
if !viewpoints(d.layer).contains(&viewpoint) {
return Err("Choose a scenario viewpoint from this layer.".into());
}
d.viewpoint = Some(viewpoint);
}
Event::Context(id) if d.family == Some(Family::Scenario) => {
if id.is_some_and(|id| !eligible(p, d.layer, Picker::Context, id)) {
return Err("Choose an existing same-layer scenario context.".into());
}
d.context = id;
}
Event::ContextOptions(open) if d.family == Some(Family::Scenario) => {
d.context_open = open;
}
Event::FirstView(value) if d.family == Some(Family::Machine) => d.first_view = value,
Event::Search(picker, query) => d.browsers[picker.index()] = Browser { query, page: 0 },
Event::Page(picker, page) => d.browsers[picker.index()].page = page,
Event::Cancel | Event::Open(_) | Event::Submit => unreachable!(),
_ => return Err("That field does not apply to this behavior family.".into()),
}
Ok(None)
}
}
fn viewpoints(layer: Layer) -> [ScenarioViewpoint; 2] {
match layer {
Layer::Oa => [
ScenarioViewpoint::OperationalActivity,
ScenarioViewpoint::ParticipantInteraction,
],
Layer::Sa => [
ScenarioViewpoint::Functional,
ScenarioViewpoint::BoundaryInterface,
],
}
}
fn viewpoint_title(viewpoint: ScenarioViewpoint) -> &'static str {
match viewpoint {
ScenarioViewpoint::OperationalActivity => "Activities · operational exchanges",
ScenarioViewpoint::ParticipantInteraction => "Participants · interactions",
ScenarioViewpoint::Functional => "Functions · functional exchanges",
ScenarioViewpoint::BoundaryInterface => "System boundary · interface exchanges",
ScenarioViewpoint::MixedLegacy => "Legacy mixed viewpoint",
}
}
fn eligible(p: &Project, layer: Layer, picker: Picker, id: Id) -> bool {
p.elements.get(&id).is_some_and(|e| {
e.layer() == layer
&& match picker {
Picker::Owner => validation::parent_permits(e.kind(), Kind::StateMachine),
Picker::Context => {
e.kind().is_sequence()
|| e.kind().is_work()
|| e.kind().is_performer()
|| matches!(
e.kind(),
Kind::Capability
| Kind::SystemCapability
| Kind::OperationalMission
| Kind::Mission
)
}
}
})
}
fn choices<'a>(p: &'a Project, layer: Layer, picker: Picker, query: &str) -> Vec<&'a Element> {
let query = query.trim().to_lowercase();
let mut matches: Vec<_> = p
.elements
.values()
.filter(|e| eligible(p, layer, picker, e.id))
.filter(|e| query.is_empty() || reference_label(p, e.id).to_lowercase().contains(&query))
.collect();
matches.sort_by(|a, b| {
a.name
.to_lowercase()
.cmp(&b.name.to_lowercase())
.then(a.id.cmp(&b.id))
});
matches
}
fn reference_context(p: &Project, e: &Element) -> String {
let mut names = Vec::new();
let mut parent = e.parent;
let mut seen = std::collections::BTreeSet::new();
while let Some(id) = parent {
if !seen.insert(id) {
break;
}
let Some(ancestor) = p.elements.get(&id) else {
break;
};
names.push(ancestor.name.as_str());
parent = ancestor.parent;
}
names.reverse();
let location = if names.is_empty() {
format!("{} root", e.layer())
} else {
format!("{} / {}", e.layer(), names.join(" / "))
};
format!("{} · {} · {location}", e.kind(), e.alias)
}
pub(super) fn reference_label(p: &Project, id: Id) -> String {
p.elements
.get(&id)
.map(|e| format!("{} · {}", e.name, reference_context(p, e)))
.unwrap_or_else(|| "Missing element — choose a replacement".into())
}
impl Draft {
fn command(&self, p: &Project) -> Result<Command, String> {
self.source.check_scope(
super::source_draft::Scope::Machine { layer: self.layer },
self.owner,
)?;
let preview = self.source.preview(p, self.owner)?;
let command = self.consumer_command(preview.as_ref().unwrap_or(p))?;
self.source.combine(p, self.owner, command)
}
fn consumer_command(&self, p: &Project) -> Result<Command, String> {
if self.document != p.id {
return Err("This draft belongs to another document.".into());
}
let family = self
.family
.ok_or("Choose the behavior you want to create.")?;
if self.name.trim().is_empty() {
return Err("Enter the behavior's name before creating it.".into());
}
if [self.element, self.diagram, self.placement]
.into_iter()
.any(|id| p.contains(id))
|| self.element == self.diagram
|| self.element == self.placement
|| self.diagram == self.placement
{
return Err(
"A reserved creation identity is already in use. Cancel and start a fresh draft."
.into(),
);
}
let kind = family.kind(self.layer);
let parent = if family == Family::Machine {
Some(
self.owner
.filter(|id| eligible(p, self.layer, Picker::Owner, *id))
.ok_or("Choose the participant that owns this state machine.")?,
)
} else {
if self.owner.is_some() {
return Err("Processes and scenarios belong at the layer root; context is a separate reference.".into());
}
None
};
if family != Family::Machine && self.first_view {
return Err("A state view can only accompany a state machine.".into());
}
let mut body = ElementBody::new(kind, self.layer)
.ok_or("This behavior is unavailable in this layer.")?;
if family == Family::Scenario {
let viewpoint = self
.viewpoint
.filter(|v| viewpoints(self.layer).contains(v))
.ok_or("Choose the scenario's viewpoint explicitly.")?;
if self
.context
.is_some_and(|id| !eligible(p, self.layer, Picker::Context, id))
{
return Err(
"The selected scenario context is missing or no longer compatible.".into(),
);
}
body = ElementBody::shared_in(
self.layer,
SharedBody::Scenario {
viewpoint,
context: self.context,
precondition: String::new(),
postcondition: String::new(),
steps: Vec::new(),
},
);
} else if self.viewpoint.is_some() || self.context.is_some() {
return Err(
"Scenario viewpoint and context cannot be applied to another behavior family."
.into(),
);
}
let element = Element {
id: self.element,
alias: p.fresh_alias(&self.name, "behavior", self.element),
name: self.name.clone(),
description: String::new(),
parent,
body,
};
let mut commands = vec![Command::CreateElement(element)];
if self.first_view {
let working = execute_batch(p, &commands).map_err(|e| e.to_string())?;
// Reuse the state editor's geometry contract. A new machine has one
// root placement; reserve both presentation IDs across retries.
let plan = super::state_edit::create_view(&working, self.element)
.map_err(|e| e.to_string())?;
let Command::Batch(mut view_commands) = plan.command else {
return Err("Unexpected state-view plan.".into());
};
if view_commands.len() != 2 {
return Err("Unexpected additional operation in the first state-view plan.".into());
}
let Some(Command::Layout(layout)) = view_commands.pop() else {
return Err("State-view plan must finish with its layout operation.".into());
};
let Some(Command::CreateDiagram(mut diagram)) = view_commands.pop() else {
return Err("State-view plan is missing its diagram.".into());
};
if diagram.id != layout || diagram.nodes.len() != 1 {
return Err("A first state view must contain only its new machine.".into());
}
let mut node = diagram
.nodes
.into_values()
.next()
.ok_or("State-view root is missing.")?;
if node.element != self.element {
return Err("State-view root differs from the new machine.".into());
}
diagram.id = self.diagram;
diagram.alias = working.fresh_alias(&diagram.name, "state_view", self.diagram);
node.id = self.placement;
node.alias = working.fresh_alias(
&format!("{} placement", self.name),
"placement",
self.placement,
);
diagram.nodes = [(node.id, node)].into_iter().collect();
commands.push(Command::CreateDiagram(diagram));
commands.push(Command::Layout(self.diagram));
}
let command = Command::Batch(commands);
execute_batch(p, std::slice::from_ref(&command)).map_err(|e| e.to_string())?;
Ok(command)
}
}
impl State {
/// Parent maps these local events into the existing guarded Behavior route.
/// The card's fields scroll; Create/Cancel stay outside that scroll region.
pub fn view<'a>(&'a self, p: &'a Project) -> Option<UiElement<'a, Event>> {
self.view_with(p)
}
fn view_with<'a, R: iced::advanced::text::Renderer + 'a>(
&'a self,
p: &'a Project,
) -> Option<UiElement<'a, Event, iced::Theme, R>> {
let d = self.draft.as_ref()?;
if let Some(view) = d.source.view(p) {
return Some(
column![
text(format!(
"State machine name: {}",
crate::views::short(&d.name, 60)
))
.size(12),
view.map(Event::Source)
]
.spacing(6)
.height(Fill)
.into(),
);
}
let mut fields = column![].spacing(10);
let title = if let Some(family) = d.family {
fields = fields.push(
text_input("Exact behavior name", &d.name)
.id(NAME_INPUT)
.on_input(Event::Name)
.on_submit(Event::Submit)
.padding(8),
);
match family {
Family::Sequence => {
fields = fields.push(
text(format!(
"Owned by the {} layer root. Add work and exchange contributions after creation.",
d.layer
))
.size(12),
);
}
Family::Scenario => {
fields = fields.push(
text(format!(
"Owned by the {} layer root. Choose what the lifelines represent:",
d.layer
))
.size(12),
);
for viewpoint in viewpoints(d.layer) {
fields = fields.push(
button(text(viewpoint_title(viewpoint)).size(13))
.style(if d.viewpoint == Some(viewpoint) {
button::primary
} else {
button::secondary
})
.on_press(Event::Viewpoint(viewpoint))
.padding(7),
);
}
let context_label = if d.context_open {
"Hide optional context".into()
} else if let Some(id) = d.context {
format!("Context: {} · Change…", reference_label(p, id))
} else {
"Add optional context…".into()
};
fields = fields.push(
button(text(context_label).size(12))
.on_press(Event::ContextOptions(!d.context_open))
.padding(6),
);
if d.context_open {
fields = fields.push(picker_view(p, d, Picker::Context));
}
}
Family::Machine => {
if let Some(label) = d.source.label(d.owner) {
fields = fields.push(text(label).size(13)).push(text("Create and open commits the new owner and machine together, including the first view only when selected.").size(12));
}
fields = fields.push(picker_view(p, d, Picker::Owner));
fields = fields.push(
button("Create new owner…")
.on_press(Event::Source(super::source_draft::Event::Open(
d.invocation,
)))
.padding(7),
);
fields = fields.push(
checkbox(d.first_view)
.label("Create the first state view")
.on_toggle(Event::FirstView),
);
fields = fields.push(text("The machine starts without regions or states. Add those explicitly in its workspace.").size(12));
}
}
format!("New {}", family.title(d.layer))
} else {
for family in [Family::Sequence, Family::Scenario, Family::Machine] {
let purpose = match family {
Family::Sequence => "A path through work and exchanges",
Family::Scenario => "Ordered interactions from an explicit viewpoint",
Family::Machine => "States and modes of an explicitly chosen participant",
};
fields = fields.push(
button(
column![text(family.title(d.layer)).size(15), text(purpose).size(12)]
.spacing(3),
)
.on_press(Event::Choose(family))
.padding(10)
.width(Fill),
);
}
format!("New behavior · {}", d.layer)
};
let mut actions =
row![button("Cancel · Esc").on_press(Event::Cancel).padding(8)].spacing(8);
if d.family.is_some() {
actions = actions.push(button("Create and open").on_press(Event::Submit).padding(8));
}
Some(
container(
column![
text(title).size(20),
scrollable(fields)
.id("behavior-creation-fields")
.height(Fill),
container(actions).id("behavior-creation-actions")
]
.spacing(12),
)
.padding(18)
.max_width(680)
.height(Fill)
.style(crate::style::card)
.into(),
)
}
}
fn picker_view<'a, R: iced::advanced::text::Renderer + 'a>(
p: &'a Project,
d: &'a Draft,
picker: Picker,
) -> UiElement<'a, Event, iced::Theme, R> {
let browser = &d.browsers[picker.index()];
let options = choices(p, d.layer, picker, &browser.query);
let page = browser
.page
.min(options.len().saturating_sub(1) / PAGE_SIZE);
let selected = match picker {
Picker::Owner => d.owner,
Picker::Context => d.context,
};
let staged_owner = picker == Picker::Owner && d.source.label(selected).is_some();
let title = match picker {
Picker::Owner if staged_owner => "Or choose an existing participant",
Picker::Owner => "Owned by participant",
Picker::Context => "Scenario context · optional",
};
let mut out = column![
text(title).size(13),
text_input("Search names, aliases or parent context", &browser.query)
.on_input(move |query| Event::Search(picker, query))
.padding(6)
]
.spacing(5);
if let Some(id) = selected
&& !staged_owner
{
out = out.push(text(format!("Selected: {}", reference_label(p, id))).size(12));
} else if picker == Picker::Context {
out = out.push(text("No context selected").size(12));
}
if options.is_empty() {
out = out.push(text(match picker {
Picker::Owner if staged_owner => "No existing participants match this search. The new owner above remains selected.",
Picker::Owner => "No matching participant. Refine the search or choose Create new owner.",
Picker::Context => "No matching context. Refine the search, or leave this optional reference empty.",
}).size(12));
}
for e in options.iter().skip(page * PAGE_SIZE).take(PAGE_SIZE) {
let message = match picker {
Picker::Owner => Event::Owner(e.id),
Picker::Context => Event::Context(Some(e.id)),
};
out = out.push(
container(
button(
column![
text(&e.name).size(13),
text(reference_context(p, e)).size(11)
]
.spacing(2),
)
.style(if selected == Some(e.id) {
button::primary
} else {
button::secondary
})
.on_press(message)
.padding(6)
.width(Fill),
)
.id(format!("behavior-creation-candidate-{}", e.id)),
);
}
let mut navigation = row![text(format!("{} matches", options.len())).size(11)].spacing(8);
if page > 0 {
navigation = navigation.push(button("Previous").on_press(Event::Page(picker, page - 1)));
}
if (page + 1) * PAGE_SIZE < options.len() {
navigation = navigation.push(button("Next").on_press(Event::Page(picker, page + 1)));
}
if picker == Picker::Context && selected.is_some() {
navigation = navigation.push(button("Clear context").on_press(Event::Context(None)));
}
out.push(navigation).into()
}
#[cfg(test)]
mod app_tests;
#[cfg(test)]
mod tests;
@@ -0,0 +1,340 @@
//! App-boundary regressions for the coordinated parent wiring. No I/O task is
//! polled: file dialogs, checkpoint writes and lesson loading remain inactive.
use super::*;
use crate::app::{App, Message, Page, Pending};
use std::sync::Arc;
fn send(app: &mut App, event: Event) {
let _ = app.update(Message::Behavior(crate::behavior::Event::Creation(event)));
}
fn raw_property(app: &App) -> serde_json::Value {
serde_json::to_value(app.draft.as_ref().unwrap().recovery_snapshot()).unwrap()
}
fn begin(app: &mut App, layer: Layer, family: Family, name: &str) {
let _ = app.update(Message::Layer(layer));
let _ = app.update(Message::Page(Page::Behavior));
send(app, Event::Open(layer));
send(app, Event::Choose(family));
send(app, Event::Name(name.into()));
}
fn focused_escape() -> Message {
use iced::advanced::{Layout, Shell, layout, widget};
use iced::keyboard::{self, key::Named};
let mut input: iced::Element<'_, (), iced::Theme, ()> = text_input("Name", "Unfinished name")
.id(NAME_INPUT)
.on_input(|_| ())
.into();
let mut tree = widget::Tree::new(input.as_widget());
let size = iced::Size::new(300., 40.);
let node =
input
.as_widget_mut()
.layout(&mut tree, &(), &layout::Limits::new(iced::Size::ZERO, size));
tree.state
.downcast_mut::<iced::widget::text_input::State<()>>()
.focus();
let event = iced::Event::Keyboard(keyboard::Event::KeyPressed {
key: keyboard::Key::Named(Named::Escape),
modified_key: keyboard::Key::Named(Named::Escape),
physical_key: keyboard::key::Physical::Code(keyboard::key::Code::Escape),
location: keyboard::Location::Standard,
modifiers: keyboard::Modifiers::empty(),
text: None,
repeat: false,
});
let mut messages = Vec::new();
let mut shell = Shell::new(&mut messages);
input.as_widget_mut().update(
&mut tree,
&event,
Layout::new(&node),
iced::mouse::Cursor::Unavailable,
&(),
&mut iced::advanced::clipboard::Null,
&mut shell,
&iced::Rectangle::new(iced::Point::ORIGIN, size),
);
assert!(shell.is_event_captured());
assert_eq!(
captured_escape(
event.clone(),
iced::event::Status::Ignored,
iced::window::Id::unique()
),
None
);
captured_escape(
event,
iced::event::Status::Captured,
iced::window::Id::unique(),
)
.unwrap();
Message::Escape
}
#[test]
fn app_first_captured_escape_dismisses_file_or_pending_overlay_before_creation() {
for file in [false, true] {
let mut app = App::with_project(Project::empty());
begin(
&mut app,
Layer::Oa,
Family::Sequence,
"Keep while overlay closes",
);
let raw = app.behavior.recovery_snapshot().unwrap();
let accepted = app.document.snapshot();
if file {
app.file_prompt = Some(crate::app::FilePrompt {
operation: crate::app::FileOperation::Save,
path: "unsubmitted-name.arcadia".into(),
overwrite: false,
});
} else {
app.pending = Some(Pending::Walkthrough);
}
let _ = app.update(focused_escape());
assert!(app.file_prompt.is_none());
assert!(app.pending.is_none());
assert_eq!(app.behavior.recovery_snapshot().unwrap(), raw);
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
let _ = app.update(focused_escape());
assert!(!app.behavior.has_pending());
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
assert!(!app.document.can_undo());
}
}
#[test]
fn app_creation_opens_the_actual_new_owner_instead_of_first_or_previously_active_behavior() {
for layer in [Layer::Oa, Layer::Sa] {
let mut p = Project::empty();
let old = p
.add_element(Family::Sequence.kind(layer), layer, "Old behavior", None)
.unwrap();
let mut app = App::with_project(p.clone());
let _ = app.update(Message::Select(old));
begin(&mut app, layer, Family::Sequence, " New exact path 雨 ");
let target = app.behavior.creation.created().unwrap();
assert_ne!(target.behavior, old);
let before = app.document.bundle().clone();
send(&mut app, Event::Submit);
assert_eq!(app.page, Page::Behavior);
assert_eq!(app.layer, layer);
assert_eq!(app.selected, target.behavior);
assert_eq!(app.behavior.active_owner, Some(target.behavior));
assert_eq!(crate::behavior::selected_owner(&app), Some(target.behavior));
assert_eq!(
app.project().elements[&target.behavior].name,
" New exact path 雨 "
);
assert!(!app.behavior.has_pending());
let after = app.document.bundle().clone();
let _ = app.update(Message::Undo);
assert_eq!(app.document.bundle(), &before);
assert!(!app.document.can_undo());
let _ = app.update(Message::Redo);
assert_eq!(app.document.bundle(), &after);
assert_eq!(app.project().elements[&old], p.elements[&old]);
}
}
#[test]
fn app_machine_behavior_submit_opens_its_opted_in_view_as_one_undoable_transaction() {
for first_view in [false, true] {
let mut p = Project::empty();
let owner = p
.add_element(Kind::System, Layer::Sa, "Flight system", None)
.unwrap();
let mut app = App::with_project(p.clone());
begin(&mut app, Layer::Sa, Family::Machine, " Flight modes 雨 ");
send(&mut app, Event::Owner(owner));
send(&mut app, Event::FirstView(first_view));
let target = app.behavior.creation.created().unwrap();
let before = app.document.bundle().clone();
let _ = app.update(Message::Behavior(crate::behavior::Event::Submit));
assert!(!app.behavior.has_pending(), "{}", app.status);
assert_eq!(app.selected, target.behavior);
assert_eq!(app.behavior.active_owner, Some(target.behavior));
assert_eq!(app.behavior.diagram.diagram, target.diagram);
assert_eq!(
crate::behavior::state_view::diagram(&app, target.behavior),
target.diagram
);
assert_eq!(app.project().elements[&target.behavior].parent, Some(owner));
assert_eq!(app.page, Page::Behavior);
let after = app.document.bundle().clone();
let _ = app.update(Message::Undo);
assert_eq!(app.document.bundle(), &before);
assert_eq!(app.project(), &p);
assert!(!app.document.can_undo());
let _ = app.update(Message::Redo);
assert_eq!(app.document.bundle(), &after);
}
}
#[test]
fn app_stale_layer_open_never_starts_or_replaces_a_draft() {
let mut app = App::with_project(Project::empty());
let _ = app.update(Message::Layer(Layer::Sa));
app.page = Page::Behavior;
let accepted = app.document.snapshot();
send(&mut app, Event::Open(Layer::Oa));
assert!(!app.behavior.has_pending());
assert_eq!(app.layer, Layer::Sa);
send(&mut app, Event::Open(Layer::Sa));
send(&mut app, Event::Choose(Family::Sequence));
send(&mut app, Event::Name("Retained system chain".into()));
let raw = app.behavior.recovery_snapshot().unwrap();
send(&mut app, Event::Open(Layer::Oa));
assert_eq!(app.behavior.recovery_snapshot().unwrap(), raw);
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
}
#[test]
fn app_new_never_auto_applies_valid_or_invalid_property_and_source_drafts() {
for dirty in [
"property-valid",
"property-invalid",
"source-valid",
"source-invalid",
] {
let mut app = App::with_project(Project::empty());
app.page = Page::Behavior;
match dirty {
"property-valid" => app
.draft
.as_mut()
.unwrap()
.input("/name", "Raw property name".into()),
"property-invalid" => app.draft.as_mut().unwrap().input("/id", "-".into()),
"source-valid" => app.source_workspace.edit(
format!("{}\n// Retained comment\n", app.source_workspace.text()),
app.source_editor.cursor(),
),
_ => app
.source_workspace
.edit("unfinished source 雨".into(), app.source_editor.cursor()),
}
let accepted = app.document.snapshot();
let property = raw_property(&app);
let workspace = app.source_workspace.recovery_snapshot();
send(&mut app, Event::Open(Layer::Oa));
assert!(
!app.behavior.creation.has_pending(),
"{dirty}: New must refuse without applying another editor"
);
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
assert_eq!(raw_property(&app), property);
assert_eq!(
serde_json::to_value(app.source_workspace.recovery_snapshot()).unwrap(),
serde_json::to_value(workspace).unwrap()
);
assert!(!app.document.can_undo());
}
}
#[test]
fn app_choosing_another_behavior_or_layer_cannot_commit_a_creation_draft() {
let mut p = Project::empty();
let old = p
.add_element(Kind::Process, Layer::Oa, "Old process", None)
.unwrap();
let mut app = App::with_project(p);
begin(&mut app, Layer::Oa, Family::Sequence, "Retain this process");
let raw = app.behavior.recovery_snapshot().unwrap();
let accepted = app.document.snapshot();
let _ = app.update(Message::Layer(Layer::Sa));
let _ = app.update(Message::BehaviorOwner(crate::app::Choice {
id: old,
label: "Old process".into(),
}));
assert_eq!(app.layer, Layer::Oa);
assert_eq!(app.behavior.recovery_snapshot().unwrap(), raw);
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
let _ = app.update(Message::Escape);
assert!(!app.behavior.has_pending());
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
assert!(!app.document.can_undo());
}
#[test]
fn app_lesson_request_cancel_retains_creation_then_escape_discards_only_the_draft() {
let mut app = App::with_project(Project::empty());
begin(
&mut app,
Layer::Oa,
Family::Sequence,
"Unfinished engineering path",
);
let raw = app.behavior.recovery_snapshot().unwrap();
let accepted = app.document.snapshot();
let task = app.update(Message::Home(crate::home::Event::Walkthrough));
assert!(matches!(app.pending, Some(Pending::Walkthrough)));
assert!(iced_runtime::task::into_stream(task).is_none());
let _ = app.update(Message::Escape);
assert!(app.pending.is_none());
assert_eq!(app.behavior.recovery_snapshot().unwrap(), raw);
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
let _ = app.update(Message::Escape);
assert!(!app.behavior.has_pending());
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
}
#[test]
fn app_recovered_rootless_sa_creation_retains_layer_ids_and_other_raw_drafts() {
let mut app = App::with_project(Project::empty());
begin(
&mut app,
Layer::Sa,
Family::Scenario,
" Recovered system scenario 雨 ",
);
send(
&mut app,
Event::Viewpoint(ScenarioViewpoint::BoundaryInterface),
);
// Recovery can contain simultaneous drafts even though new input is guarded.
app.draft
.as_mut()
.unwrap()
.input("/name", "Retained property".into());
app.source_workspace.edit(
"unfinished source preserved".into(),
app.source_editor.cursor(),
);
let snapshot = crate::recovery::Snapshot::capture(
app.canvas_document,
&app.document,
&app.source_workspace,
app.draft.as_ref(),
&app.behavior,
None,
app.selected,
)
.unwrap();
let expected = snapshot.clone().restore().unwrap();
let raw = app.behavior.recovery_snapshot().unwrap();
let mut restored = App::with_project(Project::empty());
let _ = restored.update(Message::CheckpointLoaded(Ok(Arc::new(snapshot))));
assert_eq!(restored.page, Page::Behavior);
assert_eq!(restored.layer, Layer::Sa);
assert_eq!(restored.behavior.recovery_snapshot().unwrap(), raw);
assert_eq!(
restored.behavior.creation.created(),
app.behavior.creation.created()
);
assert_eq!(restored.project(), expected.document.project());
assert_eq!(restored.document.bundle(), expected.document.bundle());
assert_eq!(
restored.source_workspace.bundle(),
expected.workspace.bundle()
);
assert_eq!(raw_property(&restored), raw_property(&app));
let before = restored.document.snapshot();
send(&mut restored, Event::Submit);
assert!(Arc::ptr_eq(&before, &restored.document.snapshot()));
assert_eq!(restored.behavior.recovery_snapshot().unwrap(), raw);
assert_eq!(raw_property(&restored), raw_property(&app));
}
@@ -0,0 +1,599 @@
use super::*;
use crate::document::Document;
use oa_core::source_project::ProjectLimits;
fn start(p: &Project, layer: Layer, family: Family, name: &str) -> State {
let mut state = State::default();
assert!(state.update(p, Event::Open(layer)).unwrap().is_none());
state.update(p, Event::Choose(family)).unwrap();
state.update(p, Event::Name(name.into())).unwrap();
state
}
#[test]
fn mounted_creation_keeps_actions_visible_and_renders_only_six_owner_candidates() {
use iced::advanced::{Layout, layout, widget};
use iced::{Rectangle, Size, Vector};
struct Geometry {
candidates: Vec<widget::Id>,
rendered: Vec<widget::Id>,
actions: Option<Rectangle>,
fields: Option<Rectangle>,
}
impl widget::Operation for Geometry {
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn widget::Operation)) {
visit(self);
}
fn container(&mut self, id: Option<&widget::Id>, bounds: Rectangle) {
if id == Some(&widget::Id::new("behavior-creation-actions")) {
self.actions = Some(bounds);
}
if let Some(id) = id.filter(|id| self.candidates.contains(id)) {
self.rendered.push(id.clone());
}
}
fn scrollable(
&mut self,
id: Option<&widget::Id>,
bounds: Rectangle,
_: Rectangle,
_: Vector,
_: &mut dyn widget::operation::Scrollable,
) {
if id == Some(&widget::Id::new("behavior-creation-fields")) {
self.fields = Some(bounds);
}
}
}
let mut p = Project::empty();
for index in 0..105 {
p.add_element(
Kind::Entity,
Layer::Oa,
&format!(
"{index:03} Long engineering participant name {}",
"雨 extended ".repeat(20)
),
None,
)
.unwrap();
}
let candidates: Vec<_> = p
.elements
.keys()
.map(|id| widget::Id::from(format!("behavior-creation-candidate-{id}")))
.collect();
for family in [Family::Sequence, Family::Scenario, Family::Machine] {
let state = start(&p, Layer::Oa, family, "Very long exact behavior name 雨");
for size in [
Size::new(500., 570.),
Size::new(1050., 570.),
Size::new(500., 320.),
] {
let mut view: UiElement<'_, Event, iced::Theme, ()> = state.view_with(&p).unwrap();
let mut tree = widget::Tree::new(view.as_widget());
let node =
view.as_widget_mut()
.layout(&mut tree, &(), &layout::Limits::new(Size::ZERO, size));
let mut geometry = Geometry {
candidates: candidates.clone(),
rendered: Vec::new(),
actions: None,
fields: None,
};
view.as_widget_mut()
.operate(&mut tree, Layout::new(&node), &(), &mut geometry);
let actions = geometry.actions.unwrap();
let fields = geometry.fields.unwrap();
assert!(actions.x >= 0. && actions.y >= 0.);
assert!(
actions.x + actions.width <= size.width
&& actions.y + actions.height <= size.height
);
assert!(fields.height >= 100. && fields.y + fields.height <= actions.y);
assert_eq!(
geometry.rendered.len(),
if family == Family::Machine { 6 } else { 0 }
);
}
}
}
fn roundtrip(p: Project, state: &mut State) -> Project {
let command = state.update(&p, Event::Submit).unwrap().unwrap();
assert_eq!(
command,
state.prepare_pending(&p).unwrap(),
"preparation must retain reserved IDs"
);
let api: Command = serde_json::from_value(serde_json::to_value(&command).unwrap()).unwrap();
assert_eq!(api, command);
let expected = execute_batch(&p, &[api]).unwrap();
let target = state.created().unwrap();
let mut document = Document::from_project(p.clone(), ProjectLimits::default()).unwrap();
let before_source = document.bundle().clone();
document.execute(command).unwrap();
assert_eq!(document.project(), &expected);
assert!(
state.has_pending(),
"only successful acknowledgement clears raw fields"
);
state.acknowledge_commit();
assert!(!state.has_pending());
assert!(document.project().elements.contains_key(&target.behavior));
let after_source = document.bundle().clone();
assert!(document.undo());
assert_eq!(document.project(), &p);
assert_eq!(document.bundle(), &before_source);
assert!(!document.can_undo(), "one creation must be one Undo entry");
assert!(document.redo());
assert_eq!(document.project(), &expected);
assert_eq!(document.bundle(), &after_source);
let reopened = Document::from_bundle(after_source, ProjectLimits::default()).unwrap();
assert_eq!(reopened.project(), &expected);
expected
}
#[test]
fn oa_process_and_sa_chain_keep_exact_names_root_ownership_and_one_history_entry() {
for layer in [Layer::Oa, Layer::Sa] {
let p = Project::empty();
let name = " Supply \"water\" — режим ";
let mut state = start(&p, layer, Family::Sequence, name);
let target = state.created().unwrap();
let result = roundtrip(p, &mut state);
let element = &result.elements[&target.behavior];
assert_eq!(element.name, name);
assert_eq!(element.layer(), layer);
assert_eq!(
element.kind(),
if layer == Layer::Oa {
Kind::Process
} else {
Kind::FunctionalChain
}
);
assert_eq!(element.parent, None);
assert!(element.steps().unwrap().is_empty());
assert!(result.diagrams.is_empty());
assert!(result.relations.is_empty());
}
}
#[test]
fn all_four_viewpoints_are_explicit_and_survive_source_history_reopen() {
for layer in [Layer::Oa, Layer::Sa] {
for viewpoint in viewpoints(layer) {
let mut p = Project::empty();
let context = p
.add_element(
if layer == Layer::Oa {
Kind::Capability
} else {
Kind::SystemCapability
},
layer,
"Water supply",
None,
)
.unwrap();
let mut state = start(&p, layer, Family::Scenario, "Nominal supply");
assert!(
state
.prepare_pending(&p)
.unwrap_err()
.contains("explicitly")
);
assert!(
state
.update(&p, Event::Viewpoint(ScenarioViewpoint::MixedLegacy))
.is_err()
);
let wrong = viewpoints(if layer == Layer::Oa {
Layer::Sa
} else {
Layer::Oa
})[0];
assert!(state.update(&p, Event::Viewpoint(wrong)).is_err());
state.update(&p, Event::Viewpoint(viewpoint)).unwrap();
state.update(&p, Event::Context(Some(context))).unwrap();
let id = state.created().unwrap().behavior;
let result = roundtrip(p, &mut state);
assert_eq!(
result.elements[&id].parent, None,
"context is not containment"
);
assert!(
matches!(result.elements[&id].shared(), Some(SharedBody::Scenario { viewpoint: actual, context: Some(actual_context), steps, .. }) if *actual == viewpoint && *actual_context == context && steps.is_empty())
);
}
}
}
#[test]
fn machine_requires_valid_owner_and_first_view_adds_no_region_or_state() {
for layer in [Layer::Oa, Layer::Sa] {
for first_view in [false, true] {
let mut p = Project::empty();
let owner = p
.add_element(
if layer == Layer::Oa {
Kind::Entity
} else {
Kind::System
},
layer,
"Pump",
None,
)
.unwrap();
let wrong_kind = p
.add_element(
if layer == Layer::Oa {
Kind::Activity
} else {
Kind::Function
},
layer,
"Pump water",
None,
)
.unwrap();
let opposite = if layer == Layer::Oa {
Layer::Sa
} else {
Layer::Oa
};
let wrong_layer = p
.add_element(
if opposite == Layer::Oa {
Kind::Entity
} else {
Kind::System
},
opposite,
"Other layer pump",
None,
)
.unwrap();
let mut state = start(&p, layer, Family::Machine, "Pump modes");
assert!(
state
.prepare_pending(&p)
.unwrap_err()
.contains("participant")
);
assert!(state.update(&p, Event::Owner(wrong_kind)).is_err());
assert!(state.update(&p, Event::Owner(wrong_layer)).is_err());
state.update(&p, Event::Owner(owner)).unwrap();
state.update(&p, Event::FirstView(first_view)).unwrap();
let target = state.created().unwrap();
let expected_count = p.elements.len() + 1;
let result = roundtrip(p, &mut state);
assert_eq!(result.elements.len(), expected_count);
assert_eq!(result.elements[&target.behavior].parent, Some(owner));
assert_eq!(result.elements[&target.behavior].kind(), Kind::StateMachine);
assert_eq!(result.diagrams.len(), usize::from(first_view));
if let Some(view) = target.diagram {
let diagram = &result.diagrams[&view];
assert_eq!(
diagram.projection,
ProjectionKind::StateMachine {
machine: target.behavior
}
);
assert_eq!(diagram.nodes.len(), 1);
assert_eq!(
diagram.nodes.values().next().unwrap().element,
target.behavior
);
}
}
}
}
#[test]
fn cancel_and_illegal_family_layer_switches_never_create_model_objects() {
let p = Project::empty();
let mut state = start(&p, Layer::Oa, Family::Scenario, "Raw name");
state
.update(
&p,
Event::Viewpoint(ScenarioViewpoint::ParticipantInteraction),
)
.unwrap();
let snapshot = state.recovery_snapshot().unwrap();
assert!(state.update(&p, Event::Open(Layer::Sa)).is_err());
assert!(state.update(&p, Event::Choose(Family::Sequence)).is_err());
assert_eq!(state.recovery_snapshot().unwrap(), snapshot);
assert!(state.update(&p, Event::Cancel).unwrap().is_none());
assert!(!state.has_pending());
assert!(state.created().is_none());
assert!(state.prepare_pending(&p).is_err());
assert!(p.elements.is_empty());
assert!(p.diagrams.is_empty());
}
#[test]
fn recovery_preserves_exact_raw_inputs_reserved_ids_and_revalidates_stale_owner() {
let mut p = Project::empty();
let owner = p
.add_element(Kind::Entity, Layer::Oa, "Plant", None)
.unwrap();
let mut state = start(&p, Layer::Oa, Family::Machine, " \nRaw rejected name\0 ");
state.update(&p, Event::Owner(owner)).unwrap();
state.update(&p, Event::FirstView(true)).unwrap();
state
.update(&p, Event::Search(Picker::Owner, " Plant ".into()))
.unwrap();
let raw = state.recovery_snapshot().unwrap().unwrap();
let mut restored = State::from_recovery(raw.clone()).unwrap();
assert_eq!(restored.recovery_snapshot().unwrap(), Some(raw.clone()));
assert_eq!(restored.created(), state.created());
assert_eq!(restored.pending_layer(), Some(Layer::Oa));
assert!(restored.prepare_pending(&p).is_err());
restored
.update(&p, Event::Name("Plant modes".into()))
.unwrap();
let command = restored.prepare_pending(&p).unwrap();
let again = State::from_recovery(restored.recovery_snapshot().unwrap().unwrap()).unwrap();
assert_eq!(again.prepare_pending(&p).unwrap(), command);
p.elements.remove(&owner);
let before = restored.recovery_snapshot().unwrap();
assert!(
restored
.prepare_pending(&p)
.unwrap_err()
.contains("participant")
);
assert_eq!(restored.recovery_snapshot().unwrap(), before);
let different = Project::empty();
assert!(
restored
.prepare_pending(&different)
.unwrap_err()
.contains("another document")
);
}
#[test]
fn rejected_source_transaction_keeps_draft_and_every_accepted_source_byte() {
let p = Project::empty();
let initial = Document::from_project(p.clone(), ProjectLimits::default()).unwrap();
let bytes: usize = initial.bundle().files.values().map(String::len).sum();
let limits = ProjectLimits {
model: language::ParseLimits {
max_source_bytes: bytes + 32,
..language::ParseLimits::UNLIMITED
},
..ProjectLimits::default()
};
let mut doc = Document::from_bundle(initial.bundle().clone(), limits).unwrap();
let mut state = start(
&p,
Layer::Oa,
Family::Sequence,
"A long exact name retained after the source budget refuses this new process",
);
let raw = state.recovery_snapshot().unwrap();
let command = state.prepare_pending(&p).unwrap();
assert!(doc.execute(command).is_err());
assert_eq!(doc.project(), &p);
assert_eq!(doc.bundle(), initial.bundle());
assert!(!doc.can_undo());
assert_eq!(state.recovery_snapshot().unwrap(), raw);
state.update(&p, Event::Cancel).unwrap();
}
#[test]
fn owner_search_is_name_ordered_filtered_and_page_sized_not_a_catalog() {
let mut p = Project::empty();
for index in (0..105).rev() {
p.add_element(Kind::Entity, Layer::Oa, &format!("Pump {index:03}"), None)
.unwrap();
}
p.add_element(Kind::System, Layer::Sa, "Pump 999", None)
.unwrap();
p.add_element(Kind::Activity, Layer::Oa, "Pump work", None)
.unwrap();
let mut state = start(&p, Layer::Oa, Family::Machine, "Pump modes");
let all = choices(&p, Layer::Oa, Picker::Owner, "");
assert_eq!(all.len(), 105);
assert_eq!(all[0].name, "Pump 000");
state
.update(&p, Event::Page(Picker::Owner, usize::MAX))
.unwrap();
assert!(
state.view(&p).is_some(),
"stale pages are clamped before slicing"
);
state
.update(&p, Event::Search(Picker::Owner, "Pump 104".into()))
.unwrap();
assert_eq!(state.draft.as_ref().unwrap().browsers[0].page, 0);
let found = choices(&p, Layer::Oa, Picker::Owner, "Pump 104");
assert_eq!(found.len(), 1);
state.update(&p, Event::Owner(found[0].id)).unwrap();
assert_eq!(state.draft.as_ref().unwrap().owner, Some(found[0].id));
}
#[test]
fn repeated_owner_names_remain_distinguishable_by_searchable_parent_context_and_alias() {
let mut p = Project::empty();
let north = p
.add_element(Kind::Entity, Layer::Oa, "North station", None)
.unwrap();
let south = p
.add_element(Kind::Entity, Layer::Oa, "South station", None)
.unwrap();
let first = p
.add_element(Kind::Entity, Layer::Oa, "Pump", Some(north))
.unwrap();
let second = p
.add_element(Kind::Entity, Layer::Oa, "Pump", Some(south))
.unwrap();
let label = reference_label(&p, second);
assert_ne!(reference_label(&p, first), label);
assert!(label.contains("South station") && label.contains("Pump"));
assert!(label.contains(&p.elements[&second].alias));
let found = choices(&p, Layer::Oa, Picker::Owner, "south station");
assert!(found.iter().any(|e| e.id == second));
assert!(!found.iter().any(|e| e.id == first));
let mut state = start(&p, Layer::Oa, Family::Machine, "Pump states");
state.update(&p, Event::Owner(second)).unwrap();
let result = execute_batch(&p, &[state.prepare_pending(&p).unwrap()]).unwrap();
assert_eq!(
result.elements[&state.created().unwrap().behavior].parent,
Some(second)
);
}
#[test]
fn corrupted_recovery_and_reserved_identity_collision_refuse_without_repairing_raw_values() {
let p = Project::empty();
let state = start(&p, Layer::Sa, Family::Sequence, "Supply");
let raw = state.recovery_snapshot().unwrap().unwrap();
for (field, value) in [
("version", serde_json::json!(99)),
("unexpected", serde_json::json!(true)),
] {
let mut bad = raw.clone();
bad[field] = value;
assert!(State::from_recovery(bad).is_err());
}
let mut bad = raw;
bad["element"] = serde_json::json!(p.id);
let restored = State::from_recovery(bad.clone()).unwrap();
assert!(
restored
.prepare_pending(&p)
.unwrap_err()
.contains("identity")
);
assert_eq!(restored.recovery_snapshot().unwrap(), Some(bad));
}
#[test]
fn post_commit_selection_is_correlated_with_exact_created_behavior_and_view() {
let mut p = Project::empty();
let owner = p
.add_element(Kind::Entity, Layer::Oa, "Pump", None)
.unwrap();
let mut state = start(&p, Layer::Oa, Family::Machine, "Pump modes");
state.update(&p, Event::Owner(owner)).unwrap();
state.update(&p, Event::FirstView(true)).unwrap();
let command = state.prepare_pending(&p).unwrap();
assert_eq!(state.created_by(&p, &command), state.created());
assert_eq!(
state.created_by(&p, &Command::Rename(owner, "Unrelated change".into())),
None
);
for edit in 0..4 {
let mut changed = command.clone();
let Command::Batch(ref mut commands) = changed else {
panic!("creation batch");
};
if edit < 3 {
let Command::CreateElement(element) = &mut commands[0] else {
panic!("new behavior");
};
match edit {
0 => element.id = Id::new_v4(),
1 => element.name = "A different authored draft".into(),
_ => element.parent = None,
}
} else {
let Command::CreateDiagram(diagram) = &mut commands[1] else {
panic!("new view");
};
diagram.projection = ProjectionKind::StateMachine { machine: owner };
}
assert_eq!(state.created_by(&p, &changed), None);
}
let Command::Batch(commands) = command else {
panic!("creation batch");
};
assert_eq!(
state.created_by(&p, &commands[0]),
None,
"an opt-in view must be part of the actual committed batch"
);
}
#[test]
fn optional_context_collapses_without_losing_authored_reference_or_raw_search() {
let mut p = Project::empty();
let context = p
.add_element(Kind::Capability, Layer::Oa, "Water supply", None)
.unwrap();
let mut state = start(&p, Layer::Oa, Family::Scenario, "Supply nominal");
assert!(!state.draft.as_ref().unwrap().context_open);
state
.update(&p, Event::Viewpoint(ScenarioViewpoint::OperationalActivity))
.unwrap();
state.update(&p, Event::ContextOptions(true)).unwrap();
state
.update(&p, Event::Search(Picker::Context, " Water ".into()))
.unwrap();
state.update(&p, Event::Context(Some(context))).unwrap();
let command = state.prepare_pending(&p).unwrap();
state.update(&p, Event::ContextOptions(false)).unwrap();
let restored = State::from_recovery(state.recovery_snapshot().unwrap().unwrap()).unwrap();
assert!(!restored.draft.as_ref().unwrap().context_open);
assert_eq!(
restored.draft.as_ref().unwrap().browsers[1].query,
" Water "
);
assert_eq!(restored.prepare_pending(&p).unwrap(), command);
assert_eq!(restored.created_by(&p, &command), restored.created());
}
#[test]
fn creation_selection_rejects_changed_scenario_body_and_post_creation_operations() {
let mut p = Project::empty();
let capability = p
.add_element(Kind::SystemCapability, Layer::Sa, "Supply", None)
.unwrap();
let mut state = start(&p, Layer::Sa, Family::Scenario, "Nominal functions");
state
.update(&p, Event::Viewpoint(ScenarioViewpoint::Functional))
.unwrap();
let command = state.prepare_pending(&p).unwrap();
assert_eq!(state.created_by(&p, &command), state.created());
for edit in 0..4 {
let mut altered = command.clone();
let Command::Batch(commands) = &mut altered else {
panic!("creation batch");
};
match edit {
0 => {
let Command::CreateElement(element) = &mut commands[0] else {
panic!("scenario");
};
let Some(SharedBody::Scenario { viewpoint, .. }) = element.shared_mut() else {
panic!("scenario body");
};
*viewpoint = ScenarioViewpoint::BoundaryInterface;
}
1 => {
let Command::CreateElement(element) = &mut commands[0] else {
panic!("scenario");
};
let Some(SharedBody::Scenario { context, .. }) = element.shared_mut() else {
panic!("scenario body");
};
*context = Some(capability);
}
2 => commands.push(Command::Rename(
state.created().unwrap().behavior,
"Changed afterward".into(),
)),
_ => commands.push(Command::Delete(state.created().unwrap().behavior)),
}
assert!(
execute_batch(&p, std::slice::from_ref(&altered)).is_ok(),
"the alternate command is valid, but is not this authored creation"
);
assert_eq!(state.created_by(&p, &altered), None);
}
}
@@ -0,0 +1,148 @@
//! Keep behavior field state within one editing session and reset it on replacement.
use iced::advanced::widget::{Operation, Tree, tree};
use iced::advanced::{Clipboard, Layout, Shell, Widget, layout, overlay, renderer};
use iced::{Element, Event, Length, Rectangle, Size, Theme, Vector, mouse};
use oa_core::Id;
struct Identity(Id);
struct Session<'a, Message, R: renderer::Renderer> {
identity: Id,
content: Element<'a, Message, Theme, R>,
}
pub(super) fn reset_on_change<'a, Message: 'a, R: renderer::Renderer + 'a>(
identity: Id,
content: impl Into<Element<'a, Message, Theme, R>>,
) -> Element<'a, Message, Theme, R> {
Element::new(Session {
identity,
content: content.into(),
})
}
impl<Message, R: renderer::Renderer> Widget<Message, Theme, R> for Session<'_, Message, R> {
fn tag(&self) -> tree::Tag {
tree::Tag::of::<Identity>()
}
fn state(&self) -> tree::State {
tree::State::new(Identity(self.identity))
}
fn children(&self) -> Vec<Tree> {
vec![Tree::new(self.content.as_widget())]
}
fn diff(&self, tree: &mut Tree) {
let retained = tree.state.downcast_mut::<Identity>();
if retained.0 != self.identity {
retained.0 = self.identity;
// Iced's keyed column preserves same-length child replacements.
// An editing-session replacement must instead get fresh field state.
tree.children = self.children();
} else {
tree.diff_children(std::slice::from_ref(&self.content));
}
}
fn size(&self) -> Size<Length> {
self.content.as_widget().size()
}
fn layout(&mut self, tree: &mut Tree, renderer: &R, limits: &layout::Limits) -> layout::Node {
self.content
.as_widget_mut()
.layout(&mut tree.children[0], renderer, limits)
}
fn operate(
&mut self,
tree: &mut Tree,
layout: Layout<'_>,
renderer: &R,
operation: &mut dyn Operation,
) {
self.content
.as_widget_mut()
.operate(&mut tree.children[0], layout, renderer, operation);
}
fn update(
&mut self,
tree: &mut Tree,
event: &Event,
layout: Layout<'_>,
cursor: mouse::Cursor,
renderer: &R,
clipboard: &mut dyn Clipboard,
shell: &mut Shell<'_, Message>,
viewport: &Rectangle,
) {
self.content.as_widget_mut().update(
&mut tree.children[0],
event,
layout,
cursor,
renderer,
clipboard,
shell,
viewport,
);
}
fn draw(
&self,
tree: &Tree,
renderer: &mut R,
theme: &Theme,
style: &renderer::Style,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
) {
self.content.as_widget().draw(
&tree.children[0],
renderer,
theme,
style,
layout,
cursor,
viewport,
);
}
fn mouse_interaction(
&self,
tree: &Tree,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
renderer: &R,
) -> mouse::Interaction {
self.content.as_widget().mouse_interaction(
&tree.children[0],
layout,
cursor,
viewport,
renderer,
)
}
fn overlay<'b>(
&'b mut self,
tree: &'b mut Tree,
layout: Layout<'b>,
renderer: &R,
viewport: &Rectangle,
translation: Vector,
) -> Option<overlay::Element<'b, Message, Theme, R>> {
self.content.as_widget_mut().overlay(
&mut tree.children[0],
layout,
renderer,
viewport,
translation,
)
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,284 @@
//! Scenario camera policy. Bounds are from the exact displayed presentation,
//! including its origin and automatic fallback; no model geometry is authored.
use crate::behavior::canvas_workspace::{self, Event};
use iced::{Rectangle, Size, Vector};
pub(super) const MIN_SCALE: f32 = f32::from_bits(1);
#[derive(Clone, Copy, Debug, PartialEq)]
pub(super) struct Camera {
pub(super) scale: f32,
pub(super) pan: Vector,
}
/// Fit never enlarges above 100%. Use f64 intermediates so a valid f32 origin
/// and size cannot overflow while finding the center. Unrepresentable cameras
/// are refused rather than silently clamped to an unrelated minimum scale.
pub(super) fn fit(bounds: Rectangle, viewport: Size) -> Option<Camera> {
if [
bounds.x,
bounds.y,
bounds.width,
bounds.height,
viewport.width,
viewport.height,
]
.iter()
.any(|n| !n.is_finite())
|| bounds.width < 0.
|| bounds.height < 0.
|| viewport.width <= 0.
|| viewport.height <= 0.
{
return None;
}
let width = f64::from(viewport.width);
let height = f64::from(viewport.height);
let scale = ((width - 2. * (width / 4.).min(8.)) / f64::from(bounds.width).max(1.))
.min((height - 2. * (height / 4.).min(8.)) / f64::from(bounds.height).max(1.))
.min(1.) as f32;
if !scale.is_finite() || scale <= 0. {
return None;
}
let x = width / 2. - (f64::from(bounds.x) + f64::from(bounds.width) / 2.) * f64::from(scale);
let y = height / 2. - (f64::from(bounds.y) + f64::from(bounds.height) / 2.) * f64::from(scale);
let pan = Vector::new(x as f32, y as f32);
(pan.x.is_finite() && pan.y.is_finite()).then_some(Camera { scale, pan })
}
pub(super) fn effective_camera(
pan: Vector,
zoom: Option<f32>,
fitting: bool,
viewport: Option<Size>,
bounds: Rectangle,
) -> Camera {
if fitting && let Some(camera) = viewport.and_then(|size| fit(bounds, size)) {
return camera;
}
Camera {
scale: zoom.unwrap_or(1.),
pan,
}
}
/// None preserves Fit as a mode, including before the first viewport arrives.
pub(super) fn set_zoom(
pan: &mut Vector,
zoom: &mut Option<f32>,
fitting: &mut bool,
scale: Option<f32>,
) -> bool {
if scale.is_some_and(|value| !value.is_finite() || value <= 0.) {
return false;
}
let next = scale.map(|value| value.min(3.));
let next_pan = if scale.is_none() { Vector::ZERO } else { *pan };
let changed = *zoom != next || *fitting != scale.is_none() || *pan != next_pan;
*zoom = next;
*fitting = scale.is_none();
*pan = next_pan;
changed
}
/// Materialize the visible fitted camera only when a camera gesture succeeds.
/// A zero-motion or rejected event preserves the original mode and fields.
pub(super) fn change(
pan: &mut Vector,
zoom: &mut Option<f32>,
fitting: &mut bool,
viewport: Option<Size>,
bounds: Rectangle,
event: Event,
) -> bool {
let camera = effective_camera(*pan, *zoom, *fitting, viewport, bounds);
let (mut next_pan, mut next_zoom, mut next_fit) = (camera.pan, Some(camera.scale), false);
let event = match event {
Event::Zoom { base, .. } if !base.is_finite() || base <= 0. => return false,
Event::Zoom { factor, at, .. } => Event::Zoom {
factor,
at,
base: camera.scale,
},
event => event,
};
if !canvas_workspace::change_with_minimum(
&mut next_pan,
&mut next_zoom,
&mut next_fit,
event,
MIN_SCALE,
) {
return false;
}
*pan = next_pan;
*zoom = next_zoom;
*fitting = next_fit;
true
}
#[cfg(test)]
mod tests {
use super::*;
use iced::Point;
fn bounds() -> Rectangle {
Rectangle {
x: -1_000_160.,
y: -200.,
width: 2_000_400.,
height: 1_000.,
}
}
fn near(actual: f32, expected: f32, tolerance: f32) {
assert!(
(actual - expected).abs() <= tolerance,
"{actual} != {expected}"
);
}
fn projected(camera: Camera, point: Point) -> Point {
Point::new(
point.x * camera.scale + camera.pan.x,
point.y * camera.scale + camera.pan.y,
)
}
#[test]
fn negative_origin_fit_contains_independently_known_extremes() {
let camera = fit(bounds(), Size::new(700., 500.)).unwrap();
near(camera.scale, 0.0003419316, 1e-10);
near(camera.pan.x, 349.98633, 0.0001);
near(
projected(camera, Point::new(-1_000_160., -200.)).x,
8.,
0.0001,
);
near(
projected(camera, Point::new(1_000_240., 800.)).x,
692.,
0.0001,
);
near(projected(camera, Point::new(40., 300.)).y, 250., 0.0001);
}
#[test]
fn fit_remains_active_across_first_and_changed_viewport() {
let (mut pan, mut zoom, mut fitting) = (Vector::new(50., 80.), Some(0.8), false);
assert!(set_zoom(&mut pan, &mut zoom, &mut fitting, None));
let retained = effective_camera(pan, zoom, fitting, Some(Size::new(1200., 500.)), bounds());
let actual = effective_camera(pan, zoom, fitting, Some(Size::new(500., 500.)), bounds());
assert!(actual.scale < retained.scale);
near(
projected(actual, Point::new(-1_000_160., -200.)).x,
8.,
0.0001,
);
near(
projected(actual, Point::new(1_000_240., 800.)).x,
492.,
0.0001,
);
assert_eq!((pan, zoom, fitting), (Vector::ZERO, None, true));
}
#[test]
fn tiny_fit_zoom_in_out_preserves_pointer_world_anchor_without_floor_jump() {
let size = Size::new(700., 500.);
let viewport = Some(size);
let start = fit(bounds(), size).unwrap();
let (mut pan, mut zoom, mut fitting) = (Vector::ZERO, None, true);
let at = Point::new(210., 180.);
let world = Point::new(
(at.x - start.pan.x) / start.scale,
(at.y - start.pan.y) / start.scale,
);
for factor in [1.25, 0.8] {
let prior_scale = zoom.unwrap_or(start.scale);
assert!(change(
&mut pan,
&mut zoom,
&mut fitting,
viewport,
bounds(),
Event::Zoom {
factor,
at,
base: start.scale,
}
));
let camera = effective_camera(pan, zoom, fitting, viewport, bounds());
near(camera.scale, prior_scale * factor, 1e-10);
assert!(camera.scale < 0.001);
near(projected(camera, world).x, at.x, 0.0001);
near(projected(camera, world).y, at.y, 0.0001);
}
near(zoom.unwrap(), start.scale, 1e-10);
near(pan.x, start.pan.x, 0.0001);
assert!(!fitting);
}
#[test]
fn pan_leaves_fit_from_displayed_position_and_zero_motion_preserves_fit() {
let size = Size::new(700., 500.);
let viewport = Some(size);
let (mut pan, mut zoom, mut fitting) = (Vector::ZERO, None, true);
for event in [
Event::Pan(Vector::ZERO),
Event::Zoom {
factor: 1.,
at: Point::ORIGIN,
base: 1.,
},
] {
assert!(!change(
&mut pan,
&mut zoom,
&mut fitting,
viewport,
bounds(),
event
));
assert_eq!((pan, zoom, fitting), (Vector::ZERO, None, true));
}
let start = fit(bounds(), size).unwrap();
assert!(change(
&mut pan,
&mut zoom,
&mut fitting,
viewport,
bounds(),
Event::Pan(Vector::new(20., -30.))
));
assert_eq!(pan, start.pan + Vector::new(20., -30.));
assert_eq!(zoom, Some(start.scale));
assert!(!fitting);
}
#[test]
fn invalid_inputs_are_passive_and_large_finite_bounds_do_not_overflow_center() {
assert!(fit(bounds(), Size::new(f32::NAN, 500.)).is_none());
let extreme = Rectangle {
x: f32::MAX,
y: 0.,
width: f32::MAX,
height: 100.,
};
let camera = fit(extreme, Size::new(700., 500.)).unwrap();
assert!(camera.scale > 0. && camera.scale < 1e-30 && camera.pan.x.is_finite());
let (mut pan, mut zoom, mut fitting) = (Vector::ZERO, None, true);
assert!(!set_zoom(&mut pan, &mut zoom, &mut fitting, Some(f32::NAN)));
assert!(!change(
&mut pan,
&mut zoom,
&mut fitting,
Some(Size::new(700., 500.)),
bounds(),
Event::Zoom {
factor: 1.25,
at: Point::ORIGIN,
base: f32::NAN
}
));
assert_eq!((pan, zoom, fitting), (Vector::ZERO, None, true));
}
}
@@ -0,0 +1,340 @@
//! A delayed focus request must still own the live parent tree when executed.
//! Collect first: a missing target must not unfocus unrelated fields.
use super::Context;
use iced::advanced::widget::{Id, Operation, Tree, operation};
use iced::advanced::{Clipboard, Layout, Shell, Widget, layout, overlay, renderer};
use iced::{Element, Event, Length, Rectangle, Size, Task, Theme, Vector, mouse};
use oa_core::Id as ModelId;
use std::collections::BTreeMap;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct Stamp {
document: ModelId,
owner: ModelId,
representation: super::Representation,
visit: ModelId,
revision: u64,
session: ModelId,
mount: ModelId,
}
impl Stamp {
pub(crate) fn new(context: Context, session: ModelId, mount: ModelId) -> Self {
Self {
document: context.document,
owner: context.scenario,
representation: context.representation,
visit: context.visit,
revision: context.revision,
session,
mount,
}
}
}
pub(crate) fn name_id(stamp: Stamp) -> Id {
Id::from(format!(
"{}:{}:{}:{:?}:{}:{}:{}:{}",
super::NAME_INPUT,
stamp.document,
stamp.owner,
stamp.representation,
stamp.visit,
stamp.revision,
stamp.session,
stamp.mount
))
}
struct Owner(Option<Stamp>);
struct Root<'a, Message, R: renderer::Renderer> {
content: Element<'a, Message, Theme, R>,
owner: Option<Stamp>,
}
pub(crate) fn root<'a, Message: 'a, R: renderer::Renderer + 'a>(
content: impl Into<Element<'a, Message, Theme, R>>,
owner: Option<Stamp>,
) -> Element<'a, Message, Theme, R> {
Element::new(Root {
content: content.into(),
owner,
})
}
impl<Message, R: renderer::Renderer> Widget<Message, Theme, R> for Root<'_, Message, R> {
fn children(&self) -> Vec<Tree> {
vec![Tree::new(self.content.as_widget())]
}
fn diff(&self, tree: &mut Tree) {
tree.diff_children(std::slice::from_ref(&self.content));
}
fn size(&self) -> Size<Length> {
self.content.as_widget().size()
}
fn layout(&mut self, tree: &mut Tree, renderer: &R, limits: &layout::Limits) -> layout::Node {
self.content
.as_widget_mut()
.layout(&mut tree.children[0], renderer, limits)
}
fn operate(
&mut self,
tree: &mut Tree,
layout: Layout<'_>,
renderer: &R,
op: &mut dyn Operation,
) {
// This is computed from the current App view, never retained widget state.
op.custom(None, layout.bounds(), &mut Owner(self.owner));
self.content
.as_widget_mut()
.operate(&mut tree.children[0], layout, renderer, op);
}
fn update(
&mut self,
tree: &mut Tree,
event: &Event,
layout: Layout<'_>,
cursor: mouse::Cursor,
renderer: &R,
clipboard: &mut dyn Clipboard,
shell: &mut Shell<'_, Message>,
viewport: &Rectangle,
) {
self.content.as_widget_mut().update(
&mut tree.children[0],
event,
layout,
cursor,
renderer,
clipboard,
shell,
viewport,
);
}
fn draw(
&self,
tree: &Tree,
renderer: &mut R,
theme: &Theme,
style: &renderer::Style,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
) {
self.content.as_widget().draw(
&tree.children[0],
renderer,
theme,
style,
layout,
cursor,
viewport,
);
}
fn mouse_interaction(
&self,
tree: &Tree,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
renderer: &R,
) -> mouse::Interaction {
self.content.as_widget().mouse_interaction(
&tree.children[0],
layout,
cursor,
viewport,
renderer,
)
}
fn overlay<'b>(
&'b mut self,
tree: &'b mut Tree,
layout: Layout<'b>,
renderer: &R,
viewport: &Rectangle,
translation: Vector,
) -> Option<overlay::Element<'b, Message, Theme, R>> {
self.content.as_widget_mut().overlay(
&mut tree.children[0],
layout,
renderer,
viewport,
translation,
)
}
}
#[derive(Clone)]
struct Scroll {
index: usize,
bounds: Rectangle,
capacity: Vector,
offset: Vector,
}
struct Collect {
stamp: Stamp,
valid: bool,
translation: Vector,
scrolls: Vec<Scroll>,
pending: Option<Scroll>,
index: usize,
target: Option<(Rectangle, Vec<Scroll>)>,
}
pub(crate) fn task<Message: Send + 'static>(stamp: Stamp) -> Task<Message> {
iced::advanced::widget::operate(Collect {
stamp,
valid: false,
translation: Vector::ZERO,
scrolls: vec![],
pending: None,
index: 0,
target: None,
})
.discard()
}
impl Operation for Collect {
fn custom(&mut self, _: Option<&Id>, _: Rectangle, state: &mut dyn std::any::Any) {
if let Some(owner) = state.downcast_ref::<Owner>() {
self.valid = owner.0 == Some(self.stamp);
}
}
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn Operation)) {
let previous = (self.translation, self.scrolls.len());
if let Some(scroll) = self.pending.take() {
self.translation += scroll.offset;
self.scrolls.push(scroll);
}
visit(self);
self.translation = previous.0;
self.scrolls.truncate(previous.1);
}
fn scrollable(
&mut self,
_: Option<&Id>,
bounds: Rectangle,
content: Rectangle,
offset: Vector,
_: &mut dyn operation::Scrollable,
) {
self.pending = Some(Scroll {
index: self.index,
bounds: translated(bounds, self.translation),
capacity: Vector::new(
(content.width - bounds.width).max(0.),
(content.height - bounds.height).max(0.),
),
offset,
});
self.index += 1;
}
fn focusable(&mut self, id: Option<&Id>, bounds: Rectangle, _: &mut dyn operation::Focusable) {
if id == Some(&name_id(self.stamp)) {
self.target = Some((translated(bounds, self.translation), self.scrolls.clone()));
}
}
fn finish(&self) -> operation::Outcome<()> {
if !self.valid {
return operation::Outcome::None;
}
let Some((mut bounds, ancestors)) = self.target.clone() else {
return operation::Outcome::None;
};
let mut shifts = BTreeMap::new();
for ancestor in ancestors.iter().rev() {
let dx = reveal(
bounds.x,
bounds.width,
ancestor.bounds.x,
ancestor.bounds.width,
);
let dy = reveal(
bounds.y,
bounds.height,
ancestor.bounds.y,
ancestor.bounds.height,
);
// Drawing offsets are rounded; an already-visible axis must not
// acquire a scroll delta from a fractional capacity alone.
let delta = Vector::new(
if dx == 0. {
0.
} else {
(ancestor.offset.x + dx).clamp(0., ancestor.capacity.x) - ancestor.offset.x
},
if dy == 0. {
0.
} else {
(ancestor.offset.y + dy).clamp(0., ancestor.capacity.y) - ancestor.offset.y
},
);
if delta != Vector::ZERO {
shifts.insert(ancestor.index, delta);
}
bounds = translated(bounds, delta);
}
operation::Outcome::Chain(Box::new(Apply {
stamp: self.stamp,
valid: false,
shifts,
index: 0,
}))
}
}
fn translated(bounds: Rectangle, delta: Vector) -> Rectangle {
Rectangle {
x: bounds.x - delta.x,
y: bounds.y - delta.y,
..bounds
}
}
fn reveal(start: f32, length: f32, viewport: f32, size: f32) -> f32 {
if start < viewport || length > size {
start - viewport
} else {
(start + length - viewport - size).max(0.)
}
}
struct Apply {
stamp: Stamp,
valid: bool,
shifts: BTreeMap<usize, Vector>,
index: usize,
}
impl Operation for Apply {
fn custom(&mut self, _: Option<&Id>, _: Rectangle, state: &mut dyn std::any::Any) {
if let Some(owner) = state.downcast_ref::<Owner>() {
self.valid = owner.0 == Some(self.stamp);
}
}
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn Operation)) {
visit(self);
}
fn focusable(&mut self, id: Option<&Id>, _: Rectangle, state: &mut dyn operation::Focusable) {
if self.valid {
if id == Some(&name_id(self.stamp)) {
state.focus();
} else {
state.unfocus();
}
}
}
fn scrollable(
&mut self,
_: Option<&Id>,
bounds: Rectangle,
content: Rectangle,
_: Vector,
state: &mut dyn operation::Scrollable,
) {
if self.valid
&& let Some(delta) = self.shifts.get(&self.index)
{
state.scroll_by(
operation::scrollable::AbsoluteOffset {
x: delta.x,
y: delta.y,
},
bounds,
content,
);
}
self.index += 1;
}
}
@@ -0,0 +1,348 @@
//! Controller identity and actual presentation input, independent of App routing.
use super::*;
use oa_core::{Diagram, DiagramLayout, ProjectionKind, ScenarioLanePosition, ScenarioLayout};
fn saved(p: &mut Project, owner: Id, lanes: &[(Id, f32)]) -> Representation {
let id = Id::new_v4();
p.diagrams.insert(
id,
Diagram {
id,
alias: format!("saved_{}", p.diagrams.len()),
name: "Saved scenario".into(),
description: String::new(),
layer: p.elements[&owner].layer(),
projection: ProjectionKind::Scenario { scenario: owner },
view_options: Default::default(),
nodes: Default::default(),
routes: vec![],
layout: Some(DiagramLayout::Scenario(ScenarioLayout {
lanes: lanes
.iter()
.map(|&(lifeline, x)| ScenarioLanePosition { lifeline, x })
.collect(),
messages: vec![],
})),
},
);
let errors: Vec<_> = oa_core::validation::validate(p)
.into_iter()
.filter(|issue| issue.severity == oa_core::validation::Severity::Error)
.collect();
assert!(errors.is_empty(), "{errors:?}");
Representation::Saved { diagram: id }
}
#[test]
fn saved_visits_distinguish_same_owner_roundtrips_and_suspended_return_without_editing() {
let mut f = direct_tests::fixture();
let a = saved(&mut f.project, f.owner, &[(f.sender, -500.)]);
let b = saved(&mut f.project, f.owner, &[(f.sender, 900.)]);
let original = f.project.clone();
let document = Id::new_v4();
let mut state = State::default();
assert!(state.enter_representation(document, f.owner, a));
let first = state.context(document, f.owner, 7, 9).unwrap();
assert!(!state.enter_representation(document, f.owner, a));
assert_eq!(state.context(document, f.owner, 7, 9), Some(first));
state.presentation.pan = iced::Vector::new(23., -17.);
state.leave();
assert_eq!(state.representation(), Some(a));
assert_eq!(state.owner(), Some(f.owner));
assert!(state.context(document, f.owner, 7, 9).is_none());
assert!(state.enter_representation(document, f.owner, a));
let returned = state.context(document, f.owner, 7, 9).unwrap();
assert_ne!(returned.visit, first.visit);
assert_eq!(state.presentation.pan, iced::Vector::new(23., -17.));
assert!(state.enter_representation(document, f.owner, b));
let middle = state.context(document, f.owner, 7, 9).unwrap();
assert!(state.enter_representation(document, f.owner, a));
let last = state.context(document, f.owner, 7, 9).unwrap();
assert_ne!(last.visit, returned.visit);
assert_ne!(last.visit, middle.visit);
assert!(!state.coordinate_input(&f.project, first));
assert!(state.coordinate_input(&f.project, last));
let session = Id::new_v4();
let mount = Id::new_v4();
assert_ne!(
focus::Stamp::new(first, session, mount),
focus::Stamp::new(last, session, mount)
);
assert_eq!(f.project, original);
state.forget_view();
assert_eq!(state.representation(), None);
assert_eq!(state.owner(), None);
assert!(state.context(document, f.owner, 7, 9).is_none());
}
#[test]
fn saved_caller_restores_exact_representation_with_fresh_visit_and_rejects_deleted_view() {
let (mut project, [a, b, _], [reference, _, _]) = direct_tests::navigation_fixture();
let view = saved(&mut project, a, &[]);
let original = project.clone();
let document = Id::new_v4();
let mut state = State::default();
state.enter_representation(document, a, view);
let initial = state.context(document, a, 0, 0).unwrap();
state.presentation.pan = iced::Vector::new(11., 29.);
state.details_offset = iced::Vector::new(0., 71.);
assert_eq!(state.open_use(&project, initial, reference).unwrap(), b);
state.enter(document, b);
let callee = state.context(document, b, 0, 0).unwrap();
let frame = state.back(&project, callee).unwrap();
assert_eq!(frame.representation, view);
state.enter_representation(document, a, frame.representation);
let returned = state.context(document, a, 0, 0).unwrap();
state.restore_caller(returned, frame);
assert_ne!(returned.visit, initial.visit);
assert_eq!(state.representation(), Some(view));
assert_eq!(state.presentation.pan, iced::Vector::new(11., 29.));
assert!(state.scroll_restore(initial, ScrollArea::Page).is_none());
assert_eq!(
state
.scroll_restore(returned, ScrollArea::Page)
.unwrap()
.offset,
iced::Vector::new(0., 71.)
);
assert_eq!(project, original);
state.open_use(&project, returned, reference).unwrap();
state.enter(document, b);
let Representation::Saved { diagram } = view else {
unreachable!()
};
project.diagrams.remove(&diagram);
let callee = state.context(document, b, 0, 0).unwrap();
assert!(state.back(&project, callee).is_none());
}
#[test]
fn complete_fallback_scene_selects_but_cannot_capture_or_finish_coordinate_gestures() {
let mut f = direct_tests::fixture();
// Equal authored lane centers are structurally valid, but cannot render a distinct-endpoint call.
let rep = saved(
&mut f.project,
f.owner,
&[(f.sender, 900.), (f.receiver, 900.)],
);
let original = f.project.clone();
let mut state = State::default();
state.enter_representation(Id::new_v4(), f.owner, rep);
let document = state.presentation.identity.unwrap().0;
let context = state.context(document, f.owner, 0, 0).unwrap();
let shown = state.display(&f.project, f.owner, f.call).unwrap();
assert!(matches!(
shown.status(),
presentation::PresentationStatus::Fallback { .. }
));
assert!(!state.coordinate_input(&f.project, context));
let automatic = presentation::present(
&f.project,
Representation::Derived { owner: f.owner },
f.call,
);
let automatic = automatic.scene().unwrap();
assert_eq!(shown.scene().unwrap().render_lanes, automatic.render_lanes);
assert_eq!(shown.scene().unwrap().activations, automatic.activations);
assert!(!automatic.activations.is_empty());
let mut scene = shown.into_scene().unwrap();
scene.coordinate_input = false;
let line = scene.lines.iter().find(|line| line.id == f.call).unwrap();
let point = Point::new(
(line.points[0].x + line.points.last().unwrap().x) / 2.,
line.points[0].y,
);
assert_eq!(scene.hit(point), Some(f.call));
let bounds = Rectangle::with_size(Size::new(1600., 1000.));
for tool in [
CanvasTool::Select,
CanvasTool::Message(MessageStyle::CallReply),
CanvasTool::Add(Tool::Reuse),
] {
scene.interaction = Some((context, tool, true));
let mut input = SequenceInput::default();
let mut messages = vec![];
for event in [
mouse::Event::CursorMoved { position: point },
mouse::Event::ButtonPressed(mouse::Button::Left),
mouse::Event::CursorMoved {
position: Point::new(point.x, point.y + 100.),
},
mouse::Event::ButtonReleased(mouse::Button::Left),
] {
if let Some(message) = canvas::Program::update(
&scene,
&mut input,
&canvas::Event::Mouse(event),
bounds,
mouse::Cursor::Available(point),
)
.and_then(|action| action.into_inner().0)
{
messages.push(message);
}
}
assert_eq!(messages.len(), 1);
assert!(
matches!(messages[0], Message::ScenarioCanvas(CanvasEvent::Select(c, id)) if c == context && id == f.call)
);
assert!(input.press.is_none() && input.source.is_none());
}
// Capture from an actual Ready presentation, then revoke coordinate input
// with the complete fallback frame of the same Saved representation.
let Representation::Saved { diagram } = rep else {
unreachable!()
};
let mut ready_project = f.project.clone();
ready_project.diagrams.get_mut(&diagram).unwrap().layout =
Some(DiagramLayout::Scenario(ScenarioLayout::default()));
let ready_original = ready_project.clone();
let ready = state.display(&ready_project, f.owner, f.call).unwrap();
assert!(matches!(
ready.status(),
presentation::PresentationStatus::Ready
));
let mut ready_scene = ready.into_scene().unwrap();
let line = ready_scene
.lines
.iter()
.find(|line| line.id == f.call)
.unwrap();
let ready_point = Point::new(
(line.points[0].x + line.points.last().unwrap().x) / 2.,
line.points[0].y,
);
let hit = ready_scene.hit_item(ready_point).unwrap();
assert_eq!(hit.id, f.call);
assert_eq!(hit.role, SceneHitRole::MessageBody);
let stamp = spacing::Stamp {
context,
camera_epoch: 17,
};
ready_scene.coordinate_input = true;
ready_scene.interaction = Some((context, CanvasTool::Select, true));
ready_scene.spacing_stamp = Some(stamp);
let mut input = SequenceInput::default();
let begin = canvas::Program::update(
&ready_scene,
&mut input,
&canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
bounds,
mouse::Cursor::Available(ready_point),
)
.and_then(|action| action.into_inner().0)
.unwrap();
let Message::ScenarioCanvas(CanvasEvent::Spacing(spacing::Event::Begin {
stamp: captured_stamp,
token,
target: spacing::Target::Message(target),
..
})) = begin
else {
panic!("Ready Saved message must begin a stamped spacing gesture");
};
assert_eq!(captured_stamp, stamp);
assert_eq!(target, f.call);
assert!(matches!(input.press, Some(SequencePress::Spacing {
stamp: held_stamp, token: held_token
}) if held_stamp == stamp && held_token == token));
scene.interaction = Some((context, CanvasTool::Select, true));
assert!(!scene.coordinate_input && scene.spacing_stamp.is_none());
let release = canvas::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left));
let result = canvas::Program::update(
&scene,
&mut input,
&release,
bounds,
mouse::Cursor::Available(ready_point),
)
.and_then(|action| action.into_inner().0);
assert!(
matches!(result, Some(Message::ScenarioCanvas(CanvasEvent::Spacing(
spacing::Event::Cancel { stamp: cancelled_stamp, token: cancelled_token }
))) if cancelled_stamp == stamp && cancelled_token == token)
);
assert!(input.press.is_none() && input.source.is_none());
assert!(
canvas::Program::update(
&scene,
&mut input,
&release,
bounds,
mouse::Cursor::Available(ready_point),
)
.and_then(|action| action.into_inner().0)
.is_none()
);
assert_eq!(ready_project, ready_original);
assert_eq!(f.project, original);
}
#[test]
fn saved_camera_fits_actual_negative_origin_and_tiny_scale_without_authored_mutation() {
let mut f = direct_tests::fixture();
let rep = saved(
&mut f.project,
f.owner,
&[(f.sender, -1_000_000.), (f.receiver, 1_000_000.)],
);
let original = f.project.clone();
let mut state = State::default();
state.enter_representation(Id::new_v4(), f.owner, rep);
state.viewport(Size::new(700., 500.));
assert!(
state.presentation.fit,
"new saved views await their real viewport in Fit mode"
);
assert!(
!state.camera_zoom(&f.project, f.owner, None),
"already-active Fit is idempotent"
);
let shown = state.display(&f.project, f.owner, f.owner).unwrap();
assert!(matches!(
shown.status(),
presentation::PresentationStatus::Ready
));
let bounds = shown.bounds().unwrap();
assert!(bounds.x < -1_000_000. && bounds.x + bounds.width > 1_000_000.);
let fitted = state.presentation.camera(bounds);
assert!(fitted.scale > 0. && fitted.scale < 0.001);
let left = bounds.x * fitted.scale + fitted.pan.x;
let right = (bounds.x + bounds.width) * fitted.scale + fitted.pan.x;
assert!((left - 8.).abs() < 0.001 && (right - 692.).abs() < 0.001);
assert!(state.camera(
&f.project,
f.owner,
super::super::canvas_workspace::Event::Pan(iced::Vector::new(13., -7.))
));
let moved = state.presentation.camera(bounds);
assert_eq!(moved.scale, fitted.scale);
assert_eq!(moved.pan, fitted.pan + iced::Vector::new(13., -7.));
assert!(!state.presentation.fit);
assert_eq!(f.project, original);
}
#[test]
fn deleted_saved_identity_remains_unavailable_and_never_falls_into_derived_geometry() {
let mut f = direct_tests::fixture();
let rep = saved(&mut f.project, f.owner, &[]);
let mut state = State::default();
let document = Id::new_v4();
state.enter_representation(document, f.owner, rep);
let Representation::Saved { diagram } = rep else {
unreachable!()
};
f.project.diagrams.remove(&diagram);
let original = f.project.clone();
let shown = state.display(&f.project, f.owner, f.owner).unwrap();
assert_eq!(shown.requested(), rep);
assert!(matches!(
shown.status(),
presentation::PresentationStatus::Unavailable { .. }
));
assert!(shown.scene().is_none() && shown.bounds().is_none());
assert_eq!(state.owner(), Some(f.owner));
assert!(state.context(document, f.owner, 0, 0).is_some());
assert!(!state.camera_zoom(&f.project, f.owner, None));
assert_eq!(f.project, original);
}
@@ -0,0 +1,160 @@
//! Pure Saved-only spacing preparation. Delta is measured from the original
//! pointer in the captured camera's world coordinates, never from the last
//! preview. The controller must own document/revision/visit, camera and hit-role
//! admission; this module neither dispatches commands nor changes selection.
use crate::behavior::scenario_layout::{
GeometryIssue, Representation, ResolvedScenarioPlan, presentation::checked_owner, resolve,
};
use oa_core::{
Command, Id, Kind, OccurrenceBody, OccurrenceEndpoint, Project, ScenarioLanePosition,
ScenarioMessagePosition, SharedBody, execute_batch,
};
pub(crate) mod controller;
#[cfg(test)]
mod tests;
pub(crate) use controller::{Event, Stamp};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Target {
Lane(Id),
Message(Id),
}
impl Target {
pub(crate) fn id(self) -> Id {
match self {
Self::Lane(id) | Self::Message(id) => id,
}
}
}
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum Refusal {
InvalidSavedView(Id),
TargetNotRendered(Target),
InvalidDelta,
InvalidGesture,
CameraChanged,
TargetNotHit(Target),
Command(String),
Geometry(Vec<GeometryIssue>),
}
pub(super) struct Drag {
original: Project,
diagram: Id,
target: Target,
origin: f32,
original_geometry: ResolvedScenarioPlan,
}
pub(super) struct Preview {
pub(super) command: Command,
#[cfg(test)]
pub(super) project: Project,
pub(super) geometry: ResolvedScenarioPlan,
}
/// Capture an accepted, actually drawn target. A Saved view displaying automatic
/// fallback is not an editable original, even if the target appears in fallback.
pub(super) fn capture(project: &Project, diagram: Id, target: Target) -> Result<Drag, Refusal> {
let representation = Representation::Saved { diagram };
let owner = checked_owner(project, representation).ok_or(Refusal::InvalidSavedView(diagram))?;
let geometry = resolve(project, representation, target.id()).map_err(Refusal::Geometry)?;
let element = project.elements.get(&target.id());
let origin = match target {
Target::Lane(id)
if element.is_some_and(|e| e.kind() == Kind::Lifeline && e.parent == Some(owner)) =>
{
// Unassigned lifelines still have drawn headers. `scene.lanes`
// excludes them only from semantic message-endpoint hit testing.
geometry.scene.render_lanes.get(&id).copied()
}
Target::Message(id)
if element.is_some_and(|e| {
matches!(
e.shared(),
Some(SharedBody::Occurrence {
body: OccurrenceBody::Exchange { .. },
..
})
)
}) =>
{
// A lifecycle row can denote receive y; the public position means
// send y. Read the actual outgoing endpoint, including self calls.
geometry
.scene
.lines
.iter()
.find(|line| line.id == id)
.and_then(|line| line.points.first())
.map(|point| point.y)
}
_ => None,
}
.ok_or(Refusal::TargetNotRendered(target))?;
if !origin.is_finite() {
return Err(Refusal::TargetNotRendered(target));
}
Ok(Drag {
original: project.clone(),
diagram,
target,
origin,
original_geometry: geometry,
})
}
impl Drag {
/// None means there is no command to commit. In particular +0/-0, return to
/// start, and sub-precision motion never materialize a missing override or
/// normalize an existing explicit value. Document/redo are never touched.
pub(super) fn prepare(&self, original_delta: f32) -> Result<Option<Preview>, Refusal> {
if !original_delta.is_finite() {
return Err(Refusal::InvalidDelta);
}
if original_delta == 0. {
return Ok(None);
}
let position = (f64::from(self.origin) + f64::from(original_delta)) as f32;
if position == self.origin {
return Ok(None);
}
let command = match self.target {
Target::Lane(lifeline) => Command::SetScenarioLanePosition {
diagram: self.diagram,
position: ScenarioLanePosition {
lifeline,
x: position,
},
},
Target::Message(occurrence) => Command::SetScenarioMessagePosition {
diagram: self.diagram,
position: ScenarioMessagePosition {
address: OccurrenceEndpoint {
references: vec![],
occurrence,
},
send_y: position,
},
},
};
let project = execute_batch(&self.original, std::slice::from_ref(&command))
.map_err(|error| Refusal::Command(error.to_string()))?;
let geometry = resolve(
&project,
Representation::Saved {
diagram: self.diagram,
},
self.target.id(),
)
.map_err(Refusal::Geometry)?;
Ok(Some(Preview {
command,
#[cfg(test)]
project,
geometry,
}))
}
}
@@ -0,0 +1,300 @@
//! One press owns one immutable accepted Project and one frozen display camera.
use super::*;
use crate::behavior::{
Scene, SceneHitRole,
scenario::{CanvasTool, Context, State},
scenario_layout::Diagnostic,
};
use iced::{Point, Rectangle, Vector};
#[cfg(test)]
pub(crate) mod tests;
#[cfg(test)]
mod timing_tests;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Stamp {
pub(crate) context: Context,
pub(crate) camera_epoch: u64,
}
#[derive(Clone, Copy, Debug)]
pub enum Event {
Begin {
stamp: Stamp,
token: Id,
target: Target,
press_screen: Point,
canvas_bounds: Rectangle,
scale: f32,
pan: Vector,
},
Move {
stamp: Stamp,
token: Id,
screen: Point,
},
Finish {
stamp: Stamp,
token: Id,
screen: Point,
},
Cancel {
stamp: Stamp,
token: Id,
},
}
impl Event {
pub(crate) fn stamp(self) -> Stamp {
match self {
Self::Begin { stamp, .. }
| Self::Move { stamp, .. }
| Self::Finish { stamp, .. }
| Self::Cancel { stamp, .. } => stamp,
}
}
pub(crate) fn token(self) -> Id {
match self {
Self::Begin { token, .. }
| Self::Move { token, .. }
| Self::Finish { token, .. }
| Self::Cancel { token, .. } => token,
}
}
}
pub(crate) struct Session {
stamp: Stamp,
token: Id,
press_screen: Point,
scale: f32,
pan: Vector,
drag: Drag,
preview: Option<Preview>,
refusal: Option<Refusal>,
ever_crossed: bool,
}
pub(in crate::behavior) struct Paint<'a> {
pub(in crate::behavior) scene: &'a Scene,
#[cfg(test)]
pub(in crate::behavior) bounds: Rectangle,
pub(in crate::behavior) refusal: Option<&'a Refusal>,
pub(in crate::behavior) original_diagnostics: &'a [Diagnostic],
pub(in crate::behavior) original_latent: &'a [Id],
pub(in crate::behavior) diagnostics: &'a [Diagnostic],
}
pub(crate) enum Finish {
Click(Id),
Noop,
Commit {
command: Box<Command>,
selected: Id,
scale: f32,
pan: Vector,
},
}
impl Session {
fn configure(&self, scene: &mut Scene) {
scene.selected = self.drag.target.id();
scene.scale = self.scale;
scene.camera_pan = self.pan;
scene.interaction = Some((self.stamp.context, CanvasTool::Select, true));
scene.spacing_stamp = Some(self.stamp);
scene.coordinate_input = true;
scene.pending_placement = None;
}
fn update(&mut self, screen: Point) {
self.preview = None;
self.refusal = None;
if !screen.x.is_finite() || !screen.y.is_finite() {
self.refusal = Some(Refusal::InvalidDelta);
return;
}
let distance = match self.drag.target {
Target::Lane(_) => screen.x - self.press_screen.x,
Target::Message(_) => screen.y - self.press_screen.y,
};
self.ever_crossed |= distance.abs() >= 3.;
if !self.ever_crossed {
return;
}
match self.drag.prepare(distance / self.scale) {
Ok(Some(mut preview)) => {
self.configure(&mut preview.geometry.scene);
self.preview = Some(preview);
}
Ok(None) => {}
Err(error) => self.refusal = Some(error),
}
}
}
impl State {
pub(crate) fn spacing_token(&self) -> Option<Id> {
self.spacing.as_ref().map(|s| s.token)
}
pub(crate) fn spacing_stamp(&self) -> Option<Stamp> {
self.spacing.as_ref().map(|s| s.stamp)
}
pub(crate) fn cancel_spacing(&mut self) -> bool {
self.spacing.take().is_some()
}
pub(in crate::behavior) fn spacing_paint(
&self,
context: Context,
camera_epoch: u64,
) -> Option<Paint<'_>> {
let session = self.spacing.as_ref()?;
if session.stamp
!= (Stamp {
context,
camera_epoch,
})
{
return None;
}
let original = &session.drag.original_geometry;
let shown = session.preview.as_ref().map_or(original, |p| &p.geometry);
Some(Paint {
scene: &shown.scene,
#[cfg(test)]
bounds: shown.bounds,
refusal: session.refusal.as_ref(),
original_diagnostics: &original.plan.diagnostics,
original_latent: &original.latent_messages,
diagnostics: &shown.plan.diagnostics,
})
}
pub(crate) fn begin_spacing(&mut self, project: &Project, event: Event) -> Result<(), Refusal> {
let Event::Begin {
stamp,
token,
target,
press_screen,
canvas_bounds,
scale,
pan,
} = event
else {
return Err(Refusal::InvalidGesture);
};
let context = stamp.context;
if self.spacing.is_some()
|| token.is_nil()
|| self.context(
context.document,
context.scenario,
context.revision,
context.epoch,
) != Some(context)
|| self.canvas_tool(context.scenario) != CanvasTool::Select
|| ![
press_screen.x,
press_screen.y,
canvas_bounds.x,
canvas_bounds.y,
canvas_bounds.width,
canvas_bounds.height,
scale,
pan.x,
pan.y,
]
.into_iter()
.all(f32::is_finite)
|| scale <= 0.
|| canvas_bounds.width <= 0.
|| canvas_bounds.height <= 0.
|| !canvas_bounds.contains(press_screen)
{
return Err(Refusal::InvalidGesture);
}
let Representation::Saved { diagram } = context.representation else {
return Err(Refusal::InvalidGesture);
};
let mut drag = capture(project, diagram, target)?;
let camera = self.presentation.camera(drag.original_geometry.bounds);
if self.presentation.viewport != Some(canvas_bounds.size())
|| camera.scale != scale
|| camera.pan != pan
{
return Err(Refusal::CameraChanged);
}
let world = Point::new(
(press_screen.x - canvas_bounds.x - pan.x) / scale,
(press_screen.y - canvas_bounds.y - pan.y) / scale,
);
// Hit tolerance is measured in displayed pixels, just like the widget.
drag.original_geometry.scene.scale = scale;
drag.original_geometry.scene.camera_pan = pan;
let hit = drag.original_geometry.scene.hit_item(world);
if !hit.is_some_and(|hit| {
hit.id == target.id()
&& match target {
Target::Lane(_) => hit.role == SceneHitRole::LifelineHeader,
Target::Message(_) => matches!(
hit.role,
SceneHitRole::MessageBody | SceneHitRole::MessageLabel
),
}
}) {
return Err(Refusal::TargetNotHit(target));
}
// Configure baseline without changing the persistent camera or selection.
let scene = &mut drag.original_geometry.scene;
scene.selected = target.id();
scene.scale = scale;
scene.camera_pan = pan;
scene.interaction = Some((context, CanvasTool::Select, true));
scene.spacing_stamp = Some(stamp);
scene.coordinate_input = true;
scene.pending_placement = None;
self.spacing = Some(Session {
stamp,
token,
press_screen,
scale,
pan,
drag,
preview: None,
refusal: None,
ever_crossed: false,
});
Ok(())
}
pub(crate) fn move_spacing(&mut self, screen: Point) {
if let Some(session) = &mut self.spacing {
session.update(screen);
}
}
pub(crate) fn finish_spacing(&mut self, screen: Point) -> Result<Finish, Refusal> {
let Some(mut session) = self.spacing.take() else {
return Err(Refusal::InvalidGesture);
};
session.update(screen);
if let Some(error) = session.refusal {
return Err(error);
}
if !session.ever_crossed {
return Ok(Finish::Click(session.drag.target.id()));
}
Ok(match session.preview {
None => Finish::Noop,
Some(preview) => Finish::Commit {
command: Box::new(preview.command),
selected: session.drag.target.id(),
scale: session.scale,
pan: session.pan,
},
})
}
pub(crate) fn accept_spacing_camera(&mut self, scale: f32, pan: Vector) {
self.presentation.zoom = Some(scale);
self.presentation.pan = pan;
self.presentation.fit = false;
}
}
@@ -0,0 +1,421 @@
use super::*;
use crate::document::Document;
use iced::Size;
use oa_core::{Diagram, Layer, ProjectionKind, language, source_project::ProjectLimits};
pub(crate) fn fixture() -> (Document, Id, Id, Id, Id) {
let mut p = language::parse(r#"arcadia 7;
project spacing "Spacing" {
oa_activity work "Work" {}
oa_activity response "Response" {}
oa_scenario incident "Incident" { viewpoint OperationalActivity; steps [@first, @second]; }
oa_lifeline reporter "Reporter" { parent @incident; represented @work; }
oa_lifeline operator "Operator" { parent @incident; represented @response; }
oa_occurrence first "First" { parent @incident; body { Exchange { exchange none; sender @reporter; receiver @operator; message_kind Asynchronous; }; }; }
oa_occurrence second "Second" { parent @incident; body { Exchange { exchange none; sender @operator; receiver @reporter; message_kind Asynchronous; }; }; }
}"#).unwrap();
let id = |name: &str| {
p.identities()
.into_iter()
.find(|(_, alias)| *alias == name)
.unwrap()
.0
};
let (owner, lane, message) = (id("incident"), id("reporter"), id("first"));
let diagram = Id::new_v4();
p.diagrams.insert(
diagram,
Diagram {
id: diagram,
alias: "saved".into(),
name: "Saved".into(),
description: String::new(),
layer: Layer::Oa,
projection: ProjectionKind::Scenario { scenario: owner },
nodes: Default::default(),
routes: vec![],
view_options: Default::default(),
layout: None,
},
);
(
Document::from_project(p, ProjectLimits::default()).unwrap(),
owner,
diagram,
lane,
message,
)
}
pub(crate) fn expected(p: &Project, diagram: Id, target: Target, delta: f32) -> Project {
capture(p, diagram, target)
.unwrap()
.prepare(delta)
.unwrap()
.unwrap()
.project
}
pub(crate) fn packet(
p: &Project,
state: &State,
context: Context,
camera_epoch: u64,
target: Target,
) -> Event {
let geometry = resolve(p, context.representation, target.id()).unwrap();
let world = match target {
Target::Lane(id) => geometry
.scene
.boxes
.iter()
.find(|item| item.id == id && item.role == SceneHitRole::LifelineHeader)
.unwrap()
.rect
.center(),
Target::Message(id) => {
let line = geometry
.scene
.lines
.iter()
.find(|line| line.id == id)
.unwrap();
Point::new((line.points[0].x + line.points[1].x) / 2., line.points[0].y)
}
};
assert!(
geometry
.scene
.hit_item(world)
.is_some_and(|hit| hit.id == target.id())
);
let camera = state.presentation.camera(geometry.bounds);
let canvas_bounds = Rectangle::new(Point::new(17., 23.), state.presentation.viewport.unwrap());
Event::Begin {
stamp: Stamp {
context,
camera_epoch,
},
token: Id::new_v4(),
target,
press_screen: Point::new(
canvas_bounds.x + world.x * camera.scale + camera.pan.x,
canvas_bounds.y + world.y * camera.scale + camera.pan.y,
),
canvas_bounds,
scale: camera.scale,
pan: camera.pan,
}
}
fn setup(target_message: bool) -> (Document, State, Event) {
let (doc, owner, diagram, lane, message) = fixture();
let mut state = State::default();
state.enter_representation(Id::new_v4(), owner, Representation::Saved { diagram });
state.presentation.viewport = Some(Size::new(900., 600.));
let context = state
.context(
state.presentation.identity.unwrap().0,
owner,
doc.revision(),
4,
)
.unwrap();
let target = if target_message {
Target::Message(message)
} else {
Target::Lane(lane)
};
let event = packet(doc.project(), &state, context, 9, target);
(doc, state, event)
}
fn press(event: Event) -> Point {
let Event::Begin { press_screen, .. } = event else {
unreachable!()
};
press_screen
}
#[test]
fn real_preview_moves_only_active_axis_from_original_and_never_changes_document() {
for message in [false, true] {
let (doc, mut state, begin) = setup(message);
let original = doc.snapshot();
let camera = state.camera_boundary();
let start = press(begin);
state.begin_spacing(doc.project(), begin).unwrap();
let first = if message {
Vector::new(500., 30.)
} else {
Vector::new(30., 500.)
};
state.move_spacing(start + first);
let session = state.spacing.as_ref().unwrap();
let candidate = session.preview.as_ref().unwrap();
let delta = 30. / session.scale;
let actual_anchor = match session.drag.target {
Target::Lane(id) => candidate.geometry.scene.render_lanes[&id],
Target::Message(id) => {
candidate
.geometry
.scene
.lines
.iter()
.find(|line| line.id == id)
.unwrap()
.points[0]
.y
}
};
let wanted = (f64::from(session.drag.origin) + f64::from(delta)) as f32;
assert_eq!(actual_anchor.to_bits(), wanted.to_bits());
let expected = session.drag.prepare(delta).unwrap().unwrap();
assert_eq!(candidate.project, expected.project);
assert_eq!(candidate.geometry.scene.rows, expected.geometry.scene.rows);
assert_eq!(
candidate.geometry.scene.render_lanes,
expected.geometry.scene.render_lanes
);
state.move_spacing(start + first * 2.);
let session = state.spacing.as_ref().unwrap();
assert_eq!(
session.preview.as_ref().unwrap().project,
session.drag.prepare(delta * 2.).unwrap().unwrap().project
);
assert_eq!(doc.project(), &original.decoded.project);
assert_eq!(doc.compiled().bundle, original.bundle);
assert!(!doc.dirty() && !doc.can_undo() && !doc.can_redo());
assert!(camera == state.camera_boundary());
}
}
#[test]
fn click_threshold_is_active_axis_and_return_to_zero_is_not_a_click() {
let (doc, mut state, begin) = setup(false);
let start = press(begin);
state.begin_spacing(doc.project(), begin).unwrap();
state.move_spacing(start + Vector::new(2., 100.));
assert!(matches!(
state.finish_spacing(start).unwrap(),
Finish::Click(_)
));
state.begin_spacing(doc.project(), begin).unwrap();
state.move_spacing(start + Vector::new(4., 0.));
assert!(matches!(state.finish_spacing(start).unwrap(), Finish::Noop));
assert!(!doc.can_undo());
}
#[test]
fn invalid_move_discards_prior_candidate_and_finish_rechecks_final_pointer() {
let (doc, mut state, begin) = setup(false);
let start = press(begin);
state.begin_spacing(doc.project(), begin).unwrap();
state.move_spacing(start + Vector::new(20., 0.));
state.move_spacing(Point::new(f32::NAN, start.y));
let paint = state.spacing_paint(begin.stamp().context, 9).unwrap();
assert!(matches!(paint.refusal, Some(Refusal::InvalidDelta)));
assert_eq!(
paint.scene.render_lanes,
state
.spacing
.as_ref()
.unwrap()
.drag
.original_geometry
.scene
.render_lanes
);
assert!(matches!(
state.finish_spacing(Point::new(f32::NAN, start.y)),
Err(Refusal::InvalidDelta)
));
assert!(state.spacing_token().is_none());
}
#[test]
fn forged_camera_hit_role_and_retired_visit_cannot_capture() {
let (doc, mut state, begin) = setup(false);
let Event::Begin {
stamp,
token,
target,
press_screen,
canvas_bounds,
scale,
pan,
} = begin
else {
unreachable!()
};
assert!(matches!(
state.begin_spacing(
doc.project(),
Event::Begin {
stamp,
token,
target,
press_screen,
canvas_bounds,
scale: scale * 2.,
pan
}
),
Err(Refusal::CameraChanged)
));
assert!(matches!(
state.begin_spacing(
doc.project(),
Event::Begin {
stamp,
token,
target,
press_screen: canvas_bounds.center(),
canvas_bounds,
scale,
pan
}
),
Err(Refusal::TargetNotHit(_))
));
state.leave();
assert!(matches!(
state.begin_spacing(doc.project(), begin),
Err(Refusal::InvalidGesture)
));
}
#[test]
fn zoomed_out_message_tolerance_matches_actual_canvas_hit() {
let (doc, mut state, _) = setup(true);
state.presentation.fit = false;
state.presentation.zoom = Some(0.25);
state.presentation.pan = Vector::ZERO;
let context = state
.context(
state.presentation.identity.unwrap().0,
state.owner().unwrap(),
doc.revision(),
4,
)
.unwrap();
let message = doc
.project()
.identities()
.into_iter()
.find(|(_, alias)| *alias == "first")
.map(|(id, _)| id)
.unwrap();
let Event::Begin {
stamp,
token,
target,
press_screen,
canvas_bounds,
scale,
pan,
} = packet(doc.project(), &state, context, 9, Target::Message(message))
else {
unreachable!()
};
let event = Event::Begin {
stamp,
token,
target,
press_screen: press_screen + Vector::new(0., 3.),
canvas_bounds,
scale,
pan,
};
state.begin_spacing(doc.project(), event).unwrap();
assert_eq!(state.spacing_token(), Some(token));
}
#[test]
fn fractional_screen_packet_preserves_subpixel_motion_in_preview_and_finish() {
let (doc, owner, diagram, _, message) = fixture();
let original = doc.snapshot();
assert!(doc.project().diagrams[&diagram].layout.is_none());
let mut state = State::default();
let document = Id::new_v4();
state.enter_representation(document, owner, Representation::Saved { diagram });
state.presentation.viewport = Some(Size::new(900., 600.));
state.presentation.fit = false;
state.presentation.zoom = Some(0.5);
// 406 + 2^-15 puts the press just below the 512 screen-coordinate boundary.
state.presentation.pan = Vector::new(0., f32::from_bits(0x43cb_0001));
let camera = state.camera_boundary();
let context = state.context(document, owner, doc.revision(), 4).unwrap();
let baseline = resolve(doc.project(), context.representation, message).unwrap();
let line = baseline
.scene
.lines
.iter()
.find(|line| line.id == message)
.unwrap();
assert_eq!(line.points[0].y, 148.);
let begin = packet(doc.project(), &state, context, 9, Target::Message(message));
let Event::Begin {
press_screen,
canvas_bounds,
scale,
pan,
..
} = begin
else {
unreachable!()
};
assert_eq!(canvas_bounds.y, 23.);
assert_eq!(scale, 0.5);
assert_eq!(pan.y.to_bits(), 0x43cb_0001);
assert_eq!(press_screen.y.to_bits(), 0x43fb_8001); // 503 + 2^-15.
let end = press_screen + Vector::new(0., 10.);
assert_eq!(end.y, 513.);
assert!(canvas_bounds.contains(press_screen) && canvas_bounds.contains(end));
// The submitted f32 displacement is 10 - 2^-15, not exactly 10.
assert_eq!((end.y - press_screen.y).to_bits(), 0x411f_ffe0);
assert_eq!(((end.y - press_screen.y) / scale).to_bits(), 0x419f_ffe0);
// Independent oracle: 148 + (10 - 2^-15) / .5 = 168 - 2^-14.
let expected_y = f32::from_bits(0x4327_fffc);
let mut expected = original.decoded.project.clone();
expected.diagrams.get_mut(&diagram).unwrap().layout =
Some(oa_core::DiagramLayout::Scenario(oa_core::ScenarioLayout {
lanes: vec![],
messages: vec![oa_core::ScenarioMessagePosition {
address: oa_core::OccurrenceEndpoint {
references: vec![],
occurrence: message,
},
send_y: expected_y,
}],
}));
state.begin_spacing(doc.project(), begin).unwrap();
state.move_spacing(end);
let preview = state.spacing.as_ref().unwrap().preview.as_ref().unwrap();
assert_eq!(preview.project, expected);
let line = preview
.geometry
.scene
.lines
.iter()
.find(|line| line.id == message)
.unwrap();
assert_eq!(line.points[0].y.to_bits(), expected_y.to_bits());
let Finish::Commit {
command,
selected,
scale,
pan,
} = state.finish_spacing(end).unwrap()
else {
panic!("fractional motion must remain a commit")
};
assert_eq!(selected, message);
assert_eq!(scale, 0.5);
assert_eq!(pan.y.to_bits(), 0x43cb_0001);
let committed = oa_core::execute_batch(doc.project(), &[*command]).unwrap();
assert_eq!(committed, expected);
assert!(state.spacing_token().is_none());
assert_eq!(doc.project(), &original.decoded.project);
assert_eq!(doc.compiled().bundle, original.bundle);
assert!(!doc.dirty() && !doc.can_undo() && !doc.can_redo());
assert!(camera == state.camera_boundary());
}
@@ -0,0 +1,304 @@
//! Diagnostic only: production capture/prepare, excluding fixture construction,
//! oracle work, App source writes, widget rebuilds, text shaping and GPU paint.
use super::*;
use crate::document::Document;
use oa_core::{
DiagramLayout, Layer, MessageKind, RelationKind, ScenarioLayout, source_project::ProjectLimits,
};
use serde_json::{Value, json};
use std::{collections::BTreeSet, sync::Arc, time::Instant};
const MESSAGE_COUNT: usize = 1000;
const TARGET_INDEX: usize = 500;
fn large_fixture() -> (Document, Id, [Id; 3], Vec<Id>, Vec<Id>) {
let (seed, owner, a, reporter, first) = super::tests::fixture();
let mut p = seed.project().clone();
let old_steps = p.elements[&owner].steps().unwrap().clone();
let template = p.elements[&first].clone();
let work = |alias: &str| {
p.identities()
.into_iter()
.find(|(_, name)| *name == alias)
.unwrap()
.0
};
let (source, target) = (work("work"), work("response"));
let operator = work("operator");
let relation = p.relate(
RelationKind::Interaction,
"Repeated incident report",
source,
target,
);
let mut lanes = vec![];
for index in 0..20 {
let original = if index < 10 { reporter } else { operator };
let mut lane = p.elements[&original].clone();
lane.id = Id::new_v4();
lane.alias = format!("timing_lane_{index:02}");
lane.name = format!("Review participant {index:02}");
lanes.push(lane.id);
p.elements.insert(lane.id, lane);
}
p.elements.remove(&reporter);
p.elements.remove(&operator);
for id in old_steps {
p.elements.remove(&id);
}
let mut messages = vec![];
for index in 0..MESSAGE_COUNT {
let mut occurrence = template.clone();
occurrence.id = Id::new_v4();
occurrence.alias = format!("timing_message_{index:04}");
occurrence.name = format!("Validate incident report {index:04}");
*occurrence.shared_mut().unwrap() = SharedBody::Occurrence {
body: OccurrenceBody::Exchange {
exchange: Some(relation),
sender: Some(lanes[index % 10]),
receiver: Some(lanes[10 + index % 10]),
message_kind: MessageKind::Asynchronous,
operation: None,
reply_to: None,
values: vec![],
},
precondition: String::new(),
postcondition: String::new(),
};
messages.push(occurrence.id);
p.elements.insert(occurrence.id, occurrence);
}
*p.elements.get_mut(&owner).unwrap().steps_mut().unwrap() = messages.clone();
p.diagrams.get_mut(&a).unwrap().layout = Some(DiagramLayout::Scenario(ScenarioLayout {
lanes: lanes
.iter()
.enumerate()
.map(|(i, &lifeline)| ScenarioLanePosition {
lifeline,
x: 160. + i as f32 * 240.,
})
.collect(),
messages: vec![],
}));
let mut views = [a, Id::nil(), Id::nil()];
for (index, x, offset) in [(1, -500., 12.), (2, 6500., -12.)] {
let mut view = p.diagrams[&a].clone();
view.id = Id::new_v4();
view.alias = format!("timing_saved_{index}");
view.name = format!("Review arrangement {index}");
view.layout = Some(DiagramLayout::Scenario(ScenarioLayout {
lanes: vec![ScenarioLanePosition {
lifeline: lanes[0],
x,
}],
messages: vec![ScenarioMessagePosition {
address: OccurrenceEndpoint {
references: vec![],
occurrence: messages[TARGET_INDEX],
},
send_y: 148. + TARGET_INDEX as f32 * 92. + offset,
}],
}));
views[index] = view.id;
p.diagrams.insert(view.id, view);
}
let document = Document::from_project(p, ProjectLimits::default()).unwrap();
assert_eq!(
document
.project()
.children(owner)
.filter(|e| e.kind() == Kind::Lifeline)
.count(),
20
);
assert_eq!(document.project().elements[&owner].layer(), Layer::Oa);
assert_eq!(document.project().diagrams.len(), 3);
(document, owner, views, lanes, messages)
}
fn assert_scene(
scene: &Scene,
lanes: &[Id],
messages: &[Id],
moved_message: Option<(Id, f32)>,
moved_lane: Option<(Id, f32)>,
) {
assert_eq!(scene.render_lanes.len(), 20);
assert_eq!(
scene
.boxes
.iter()
.filter(|b| b.role == SceneHitRole::LifelineHeader)
.count(),
20
);
assert_eq!(scene.lines.len(), MESSAGE_COUNT);
assert_eq!(
scene
.lines
.iter()
.map(|line| line.id)
.collect::<BTreeSet<_>>(),
messages.iter().copied().collect::<BTreeSet<_>>()
);
assert!(scene.activations.is_empty() && scene.open_calls.is_empty());
let x = |index: usize| {
160. + index as f32 * 240.
+ moved_lane
.filter(|(id, _)| *id == lanes[index])
.map_or(0., |(_, delta)| delta)
};
for (index, &lane) in lanes.iter().enumerate() {
assert_eq!(scene.render_lanes[&lane], x(index));
}
for (index, (line, &id)) in scene.lines.iter().zip(messages).enumerate() {
let y = 148.
+ index as f32 * 92.
+ moved_message
.filter(|(target, _)| *target == id)
.map_or(0., |(_, delta)| delta);
assert_eq!(line.id, id);
assert_eq!(
line.points,
vec![
Point::new(x(index % 10), y),
Point::new(x(10 + index % 10), y)
]
);
}
}
fn summary(samples: &[Value], phase: &str) -> Value {
let mut times: Vec<u64> = samples
.iter()
.filter(|sample| sample["phase"] == phase)
.map(|sample| sample["elapsed_ns"].as_u64().unwrap())
.collect();
times.sort_unstable();
let n = times.len();
let median = if n.is_multiple_of(2) {
((u128::from(times[n / 2 - 1]) + u128::from(times[n / 2])) / 2) as u64
} else {
times[n / 2]
};
json!({"samples": n, "min_ns": times[0], "median_ns": median, "max_ns": times[n-1]})
}
#[test]
#[ignore = "Diagnostic timing only; run alone with --exact --ignored --nocapture after correctness gates; no latency acceptance threshold"]
fn saved_spacing_capture_prepare_20_lanes_1000_messages_3_views() {
let (document, owner, views, lanes, messages) = large_fixture();
let original = document.snapshot();
let target = messages[TARGET_INDEX];
let mut samples = vec![];
let mut retained = None;
for iteration in 0..4 {
let start = Instant::now();
let result = capture(document.project(), views[0], Target::Message(target));
let elapsed_ns = u64::try_from(start.elapsed().as_nanos()).unwrap();
let drag = result.expect("complete asynchronous fixture must resolve");
samples.push(json!({"phase":"capture_message", "iteration":iteration,
"first_in_probe":iteration==0, "elapsed_ns":elapsed_ns, "outcome":"captured"}));
assert_scene(&drag.original_geometry.scene, &lanes, &messages, None, None);
if iteration == 0 {
retained = Some(drag);
}
}
let drag = retained.unwrap();
// Every sample is relative to the original press, including duplicate,
// reverse and return-to-start samples. Oracle work is outside each timer.
let deltas = [
4., 8., 12., 16., 20., 24., 28., 32., 32., 28., 24., 20., 16., 12., 8., 4., -4., -8., -4.,
0.,
];
for (iteration, delta) in deltas.into_iter().enumerate() {
let start = Instant::now();
let result = drag.prepare(delta);
let elapsed_ns = u64::try_from(start.elapsed().as_nanos()).unwrap();
let candidate = result.expect("these isolated asynchronous offsets are feasible");
samples.push(json!({"phase":"prepare_message", "iteration":iteration,
"delta_world":delta, "elapsed_ns":elapsed_ns, "outcome":if candidate.is_some(){"preview"}else{"noop"}}));
if delta == 0. {
assert!(candidate.is_none());
} else {
let candidate = candidate.unwrap();
let mut expected = original.decoded.project.clone();
let Some(DiagramLayout::Scenario(layout)) =
&mut expected.diagrams.get_mut(&views[0]).unwrap().layout
else {
panic!()
};
layout.messages.push(ScenarioMessagePosition {
address: OccurrenceEndpoint {
references: vec![],
occurrence: target,
},
send_y: 148. + TARGET_INDEX as f32 * 92. + delta,
});
assert_eq!(candidate.project, expected);
assert_scene(
&candidate.geometry.scene,
&lanes,
&messages,
Some((target, delta)),
None,
);
}
assert_eq!(
document.project().elements[&owner].steps().unwrap(),
&messages
);
assert_eq!(document.project(), &original.decoded.project);
assert!(Arc::ptr_eq(&original, &document.snapshot()));
}
let lane = lanes[10];
let start = Instant::now();
let result = capture(document.project(), views[0], Target::Lane(lane));
let elapsed_ns = u64::try_from(start.elapsed().as_nanos()).unwrap();
let drag = result.unwrap();
samples.push(json!({"phase":"capture_lane", "iteration":0, "elapsed_ns":elapsed_ns, "outcome":"captured"}));
for (iteration, delta) in [20., 40., -20.].into_iter().enumerate() {
let start = Instant::now();
let result = drag.prepare(delta);
let elapsed_ns = u64::try_from(start.elapsed().as_nanos()).unwrap();
let candidate = result.unwrap().unwrap();
samples.push(json!({"phase":"prepare_lane", "iteration":iteration,
"delta_world":delta, "elapsed_ns":elapsed_ns, "outcome":"preview"}));
let mut expected = original.decoded.project.clone();
let Some(DiagramLayout::Scenario(layout)) =
&mut expected.diagrams.get_mut(&views[0]).unwrap().layout
else {
panic!()
};
layout.lanes[10].x = 2560. + delta;
assert_eq!(candidate.project, expected);
assert_scene(
&candidate.geometry.scene,
&lanes,
&messages,
None,
Some((lane, delta)),
);
}
assert_eq!(document.project(), &original.decoded.project);
assert_eq!(document.bundle(), &original.bundle);
for view in views {
assert_eq!(
document.project().diagrams[&view],
original.decoded.project.diagrams[&view]
);
}
assert_eq!(document.revision(), 0);
assert!(!document.dirty() && !document.can_undo() && !document.can_redo());
let report = json!({
"schema":"scenario_spacing_timing_v1",
"fixture":{"lanes":20,"messages":MESSAGE_COUNT,"saved_views":3,"target_index":TARGET_INDEX,"distinct_message_ids":messages.iter().copied().collect::<BTreeSet<_>>().len()},
"profile_context":{"debug_assertions":cfg!(debug_assertions),"os":std::env::consts::OS,"arch":std::env::consts::ARCH,
"source_pin_from_runner":std::env::var("SCENARIO_SPACING_SOURCE_PIN").ok(),"source_binary_toolchain_binding":"external gate receipt required"},
"summaries":{"capture_message":summary(&samples,"capture_message"),"prepare_message":summary(&samples,"prepare_message"),"capture_lane":summary(&samples,"capture_lane"),"prepare_lane":summary(&samples,"prepare_lane")},
"samples":samples,
"invariant_status":"passed",
"limits":"First capture is first in this probe, not cold OS caches. Timers exclude construction/oracles, prior-preview disposal, source write/history, widget rebuild, label shaping, GPU paint and queued input; no responsiveness acceptance or latency threshold. All messages asynchronous; no nested frame/lifecycle stress."
});
println!("SPACING_TIMING_JSON={report}");
}
@@ -0,0 +1,522 @@
use super::*;
use crate::behavior::scenario_layout::Diagnostic;
use crate::document::Document;
use oa_core::{
Diagram, DiagramLayout, ProjectionKind, ScenarioLayout, language, source_project::ProjectLimits,
};
fn id(project: &Project, alias: &str) -> Id {
project
.identities()
.into_iter()
.find(|(_, name)| *name == alias)
.unwrap()
.0
}
fn fixture(layout: Option<ScenarioLayout>) -> (Document, Id, Id) {
let mut project = language::parse(FIXTURE).unwrap();
let owner = id(&project, "conversation");
let mut add = |alias: &str, layout: Option<ScenarioLayout>| {
let diagram = Id::new_v4();
project.diagrams.insert(
diagram,
Diagram {
id: diagram,
alias: alias.into(),
name: alias.into(),
description: String::new(),
layer: oa_core::Layer::Oa,
projection: ProjectionKind::Scenario { scenario: owner },
nodes: Default::default(),
routes: vec![],
view_options: Default::default(),
layout: layout.map(DiagramLayout::Scenario),
},
);
diagram
};
let a = add("saved_a", layout);
let b = add("saved_b", Some(ScenarioLayout::default()));
let document = Document::from_project(project, ProjectLimits::default()).unwrap();
(document, a, b)
}
fn resolved(project: &Project, diagram: Id, selected: Id) -> ResolvedScenarioPlan {
resolve(project, Representation::Saved { diagram }, selected).unwrap()
}
fn same_geometry(a: &ResolvedScenarioPlan, b: &ResolvedScenarioPlan) {
assert_eq!(a.bounds, b.bounds);
assert_eq!(a.lanes, b.lanes);
assert_eq!(a.plan.frames, b.plan.frames);
assert_eq!(a.plan.scopes, b.plan.scopes);
let (a, b) = (&a.scene, &b.scene);
assert_eq!(a.rows, b.rows);
assert_eq!(a.sequence_bounds, b.sequence_bounds);
assert_eq!(a.size, b.size);
assert_eq!(a.layout_origin, b.layout_origin);
assert_eq!(a.lanes, b.lanes);
assert_eq!(a.render_lanes, b.render_lanes);
assert_eq!(a.activations, b.activations);
assert_eq!(a.lifecycle, b.lifecycle);
assert_eq!(a.open_calls, b.open_calls);
let slots = |scene: &crate::behavior::Scene| {
scene
.slots
.iter()
.map(|item| {
(
item.slot.parent,
item.slot.before,
item.y,
item.label.clone(),
)
})
.collect::<Vec<_>>()
};
assert_eq!(slots(a), slots(b));
let lines = |scene: &crate::behavior::Scene| {
scene
.lines
.iter()
.map(|line| {
(
line.id,
line.points.clone(),
line.label_at,
line.label.clone(),
line.dashed,
)
})
.collect::<Vec<_>>()
};
let boxes = |scene: &crate::behavior::Scene| {
scene
.boxes
.iter()
.map(|item| {
(
item.id,
item.rect,
item.title.clone(),
item.detail.clone(),
item.marker,
)
})
.collect::<Vec<_>>()
};
assert_eq!(lines(a), lines(b));
assert_eq!(boxes(a), boxes(b));
}
fn expected_message(project: &Project, diagram: Id, occurrence: Id, y: f32) -> Project {
// Independent expected mutation: tests begin with no message overrides.
let mut expected = project.clone();
expected.diagrams.get_mut(&diagram).unwrap().layout =
Some(DiagramLayout::Scenario(ScenarioLayout {
lanes: vec![],
messages: vec![ScenarioMessagePosition {
address: OccurrenceEndpoint {
references: vec![],
occurrence,
},
send_y: y,
}],
}));
expected
}
#[test]
fn one_repeated_message_moves_in_one_view_with_exact_document_undo_redo_and_reopen() {
let (mut document, a, b) = fixture(None);
let original = document.snapshot();
let before = document.project().clone();
let first = id(&before, "first");
let second = id(&before, "second");
let baseline = resolved(&before, a, second);
assert_eq!(baseline.scene.rows[&first].1, 148.);
assert_eq!(baseline.scene.rows[&second].1, 240.);
let untouched = resolved(&before, b, second);
let drag = capture(&before, a, Target::Message(second)).unwrap();
let preview = drag.prepare(-40.).unwrap().unwrap();
let expected = expected_message(&before, a, second, 200.);
assert_eq!(preview.project, expected);
assert_eq!(preview.geometry.scene.rows[&first].1, 148.);
assert_eq!(preview.geometry.scene.rows[&second].1, 200.);
let line = preview
.geometry
.scene
.lines
.iter()
.find(|line| line.id == second)
.unwrap();
assert_eq!(line.points.first().unwrap().y, 200.);
assert_eq!(line.points.last().unwrap().y, 200.);
assert_eq!(
preview.geometry.scene.activations,
baseline.scene.activations
);
assert_eq!(preview.geometry.scene.lifecycle, baseline.scene.lifecycle);
same_geometry(&resolved(&preview.project, b, second), &untouched);
assert_eq!(drag.original, before);
assert!(std::sync::Arc::ptr_eq(&original, &document.snapshot()));
assert!(!document.can_undo());
let returned_selection = document.execute(preview.command).unwrap();
assert_eq!(
returned_selection,
Some(a),
"controller must preserve the dragged semantic selection separately"
);
assert_eq!(document.project(), &expected);
assert_eq!(document.revision(), 1);
let accepted = document.bundle().clone();
same_geometry(&resolved(document.project(), a, second), &preview.geometry);
assert!(document.undo());
assert_eq!(document.project(), &before);
assert_eq!(document.bundle(), &original.bundle);
assert!(!document.can_undo());
assert!(document.redo());
assert_eq!(document.project(), &expected);
assert_eq!(document.bundle(), &accepted);
let reopened = Document::from_bundle(accepted, ProjectLimits::default()).unwrap();
assert_eq!(reopened.project(), &expected);
same_geometry(&resolved(reopened.project(), a, second), &preview.geometry);
assert!(!reopened.dirty() && !reopened.can_undo());
}
#[test]
fn horizontal_lane_move_changes_actual_header_and_attached_endpoints_only_in_selected_view() {
let (document, a, b) = fixture(None);
let before = document.project();
let right = id(before, "right");
let first = id(before, "first");
let other = resolved(before, b, right);
let preview = capture(before, a, Target::Lane(right))
.unwrap()
.prepare(100.)
.unwrap()
.unwrap();
let mut expected = before.clone();
expected.diagrams.get_mut(&a).unwrap().layout = Some(DiagramLayout::Scenario(ScenarioLayout {
lanes: vec![ScenarioLanePosition {
lifeline: right,
x: 500.,
}],
messages: vec![],
}));
assert_eq!(preview.project, expected);
assert_eq!(preview.geometry.scene.lanes[&right], 500.);
let header = preview
.geometry
.scene
.boxes
.iter()
.find(|item| item.id == right)
.unwrap();
assert_eq!(header.rect.center_x(), 500.);
let line = preview
.geometry
.scene
.lines
.iter()
.find(|line| line.id == first)
.unwrap();
assert_eq!(line.points.first().unwrap().x, 160.);
assert_eq!(line.points.last().unwrap().x, 500.);
assert!(line.points.iter().all(|point| point.y == 148.));
same_geometry(&resolved(&preview.project, b, right), &other);
assert_eq!(document.project(), before);
assert!(!document.dirty() && !document.can_undo());
}
#[test]
fn drawn_unassigned_lifeline_can_move_without_assigning_a_semantic_participant() {
let (document, a, _) = fixture(None);
let mut project = document.project().clone();
let owner = id(&project, "conversation");
let lane = project
.add_element(
Kind::Lifeline,
oa_core::Layer::Oa,
"Unassigned header",
Some(owner),
)
.unwrap();
let document = Document::from_project(project, ProjectLimits::default()).unwrap();
let baseline = resolved(document.project(), a, lane);
assert!(!baseline.scene.lanes.contains_key(&lane));
let before_header = baseline
.scene
.boxes
.iter()
.find(|item| item.id == lane)
.unwrap()
.rect;
let preview = capture(document.project(), a, Target::Lane(lane))
.unwrap()
.prepare(70.)
.unwrap()
.unwrap();
let after_header = preview
.geometry
.scene
.boxes
.iter()
.find(|item| item.id == lane)
.unwrap()
.rect;
assert_eq!(after_header.x, before_header.x + 70.);
assert_eq!(after_header.y, before_header.y);
assert_eq!(after_header.size(), before_header.size());
assert_eq!(preview.project.elements, document.project().elements);
assert_eq!(preview.project.relations, document.project().relations);
assert!(!document.can_undo());
}
#[test]
fn self_call_spacing_uses_send_y_and_preserves_reply_and_actual_activation_finish() {
let (document, a, _) = fixture(None);
let before = document.project();
let call = id(before, "call");
let reply = id(before, "reply");
let baseline = resolved(before, a, call);
let preview = capture(before, a, Target::Message(call))
.unwrap()
.prepare(-22.)
.unwrap()
.unwrap();
assert_eq!(preview.project, expected_message(before, a, call, 310.));
let line = preview
.geometry
.scene
.lines
.iter()
.find(|line| line.id == call)
.unwrap();
assert_eq!(line.points.first().unwrap().y, 310.);
assert_eq!(line.points.last().unwrap().y, 334.);
assert_eq!(preview.geometry.scene.rows[&reply].1, 424.);
assert_eq!(preview.geometry.scene.activations.len(), 1);
let mut expected_activation = baseline.scene.activations.clone();
expected_activation[0].rect.y = 334.;
expected_activation[0].rect.height = 90.;
assert_eq!(preview.geometry.scene.activations, expected_activation);
assert_eq!(preview.geometry.scene.lifecycle, baseline.scene.lifecycle);
}
#[test]
fn feasible_spacing_crossing_remains_authored_with_diagnostic_and_unchanged_steps() {
let (document, a, _) = fixture(None);
let before = document.project();
let first = id(before, "first");
let second = id(before, "second");
let owner = id(before, "conversation");
let preview = capture(before, a, Target::Message(second))
.unwrap()
.prepare(-120.)
.unwrap()
.unwrap();
assert_eq!(preview.project, expected_message(before, a, second, 120.));
assert_eq!(preview.geometry.scene.rows[&second].1, 120.);
assert_eq!(preview.geometry.scene.rows[&first].1, 148.);
assert_eq!(
preview.project.elements[&owner].steps(),
before.elements[&owner].steps()
);
let shown = crate::behavior::scenario_layout::presentation::present(
&preview.project,
Representation::Saved { diagram: a },
second,
);
assert!(shown.capabilities().coordinate_input);
assert!(
shown
.diagnostics()
.contains(&Diagnostic::SourceOrderCrossing {
scope: owner,
before: first,
after: second
})
);
}
#[test]
fn unsupported_self_call_move_is_refused_and_automatic_fallback_is_not_editable_capture() {
let (document, a, _) = fixture(None);
let before = document.project();
let call = id(before, "call");
let drag = capture(before, a, Target::Message(call)).unwrap();
let Err(Refusal::Geometry(issues)) = drag.prepare(118.) else {
panic!("send450 / receive474 after reply424 must be refused")
};
assert!(issues.iter().any(|issue| matches!(issue, GeometryIssue::TypedActivationCrossingNotSupported { call: id, .. } if *id == call)));
assert_eq!(&drag.original, before);
let invalid_geometry = expected_message(before, a, call, 450.);
let shown = crate::behavior::scenario_layout::presentation::present(
&invalid_geometry,
Representation::Saved { diagram: a },
call,
);
assert!(shown.capabilities().geometry_navigation && !shown.capabilities().coordinate_input);
assert!(matches!(
capture(&invalid_geometry, a, Target::Message(call)),
Err(Refusal::Geometry(_))
));
assert!(!document.dirty() && !document.can_undo());
}
#[test]
fn invalid_view_foreign_identity_and_latent_message_are_not_spacing_targets() {
let (document, a, b) = fixture(None);
let p = document.project();
let unfinished = id(p, "unfinished");
let source = id(p, "source");
assert!(matches!(
capture(p, Id::nil(), Target::Lane(source)),
Err(Refusal::InvalidSavedView(_))
));
for target in [
Target::Lane(source),
Target::Lane(b),
Target::Message(source),
Target::Message(unfinished),
] {
assert!(
matches!(capture(p, a, target), Err(Refusal::TargetNotRendered(found)) if found == target)
);
}
// A stored latent coordinate is not permission to fabricate a painted arrow.
let mut latent = expected_message(p, a, unfinished, 2000.);
let foreign_owner = latent
.add_element(Kind::Scenario, oa_core::Layer::Oa, "Foreign", None)
.unwrap();
let foreign_lane = latent
.add_element(
Kind::Lifeline,
oa_core::Layer::Oa,
"Foreign lane",
Some(foreign_owner),
)
.unwrap();
*latent
.elements
.get_mut(&foreign_lane)
.unwrap()
.shared_mut()
.unwrap() = SharedBody::Lifeline {
represented: Some(source),
};
let latent_document = Document::from_project(latent, ProjectLimits::default()).unwrap();
assert!(matches!(
capture(latent_document.project(), a, Target::Message(unfinished)),
Err(Refusal::TargetNotRendered(_))
));
assert!(matches!(
capture(latent_document.project(), a, Target::Lane(foreign_lane)),
Err(Refusal::TargetNotRendered(_))
));
}
#[test]
fn zero_motion_return_to_start_and_subprecision_preserve_sparse_intent_and_real_redo() {
let parsed = language::parse(FIXTURE).unwrap();
let left = id(&parsed, "left");
let second = id(&parsed, "second");
let layouts = [
None,
Some(ScenarioLayout::default()),
Some(ScenarioLayout {
lanes: vec![],
messages: vec![ScenarioMessagePosition {
address: OccurrenceEndpoint {
references: vec![],
occurrence: second,
},
send_y: 240.,
}],
}),
Some(ScenarioLayout {
lanes: vec![ScenarioLanePosition {
lifeline: left,
x: -0.,
}],
messages: vec![],
}),
];
for (index, layout) in layouts.into_iter().enumerate() {
let (mut document, a, _) = fixture(layout);
let owner = id(document.project(), "conversation");
document
.execute(Command::Rename(owner, "A real redo entry".into()))
.unwrap();
let redo = document.snapshot();
assert!(document.undo());
let before = document.snapshot();
let revision = document.revision();
let target = if index == 3 {
Target::Lane(left)
} else {
Target::Message(second)
};
let drag = capture(document.project(), a, target).unwrap();
if index == 3 {
let Some(DiagramLayout::Scenario(layout)) = &drag.original.diagrams[&a].layout else {
panic!()
};
assert_eq!(layout.lanes[0].x.to_bits(), (-0_f32).to_bits());
}
assert!(drag.prepare(10.).unwrap().is_some());
for delta in [0., -0.] {
assert!(drag.prepare(delta).unwrap().is_none());
}
if index != 3 {
assert!(drag.prepare(f32::from_bits(1)).unwrap().is_none());
}
assert_eq!(
serde_json::to_vec(&drag.original).unwrap(),
serde_json::to_vec(document.project()).unwrap()
);
assert!(std::sync::Arc::ptr_eq(&before, &document.snapshot()));
assert_eq!(document.bundle(), &before.bundle);
assert_eq!(document.revision(), revision);
assert!(document.can_redo());
assert!(document.redo());
assert_eq!(document.project(), &redo.decoded.project);
assert_eq!(document.bundle(), &redo.bundle);
}
}
#[test]
fn malformed_or_out_of_range_motion_cannot_mutate_original() {
let (document, a, _) = fixture(None);
let drag = capture(
document.project(),
a,
Target::Message(id(document.project(), "second")),
)
.unwrap();
for delta in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] {
assert!(matches!(drag.prepare(delta), Err(Refusal::InvalidDelta)));
}
assert!(matches!(drag.prepare(f32::MAX), Err(Refusal::Command(_))));
assert_eq!(&drag.original, document.project());
assert!(!document.dirty() && !document.can_undo());
}
const FIXTURE: &str = r#"arcadia 7;
project spacing_probe "Saved spacing probe" id "71000000-0000-4000-8000-000000000001" {
oa_activity source "Source" id "71000000-0000-4000-8000-000000000002" {}
oa_activity target "Target" id "71000000-0000-4000-8000-000000000003" {}
interaction exchange "Repeated exchange" id "71000000-0000-4000-8000-000000000004" { source @source; target @target; }
oa_scenario conversation "Conversation" id "71000000-0000-4000-8000-000000000005" { viewpoint OperationalActivity; steps [@first, @second, @call, @reply, @unfinished]; }
oa_lifeline left "Left" id "71000000-0000-4000-8000-000000000006" { parent @conversation; represented @source; }
oa_lifeline right "Right" id "71000000-0000-4000-8000-000000000007" { parent @conversation; represented @target; }
oa_occurrence first "First" id "71000000-0000-4000-8000-000000000008" { parent @conversation; body { Exchange { exchange @exchange; sender @left; receiver @right; message_kind Asynchronous; }; }; }
oa_occurrence second "Second" id "71000000-0000-4000-8000-000000000009" { parent @conversation; body { Exchange { exchange @exchange; sender @left; receiver @right; message_kind Asynchronous; }; }; }
oa_occurrence call "Call" id "71000000-0000-4000-8000-00000000000a" { parent @conversation; body { Exchange { exchange none; sender @right; receiver @right; message_kind Call; }; }; }
oa_occurrence reply "Reply" id "71000000-0000-4000-8000-00000000000b" { parent @conversation; body { Exchange { exchange none; sender @right; receiver @right; message_kind Reply; reply_to @call; }; }; }
oa_occurrence unfinished "Unfinished" id "71000000-0000-4000-8000-00000000000c" { parent @conversation; body { Exchange { exchange @exchange; sender @left; receiver none; message_kind Asynchronous; }; }; }
}
"#;
@@ -0,0 +1,259 @@
//! Navigation is derived from the same opaque-reference scene that is painted.
use super::*;
use crate::behavior::scenario_layout::{Representation, presentation};
use crate::diagram_usage::{Surface, Target, Usage};
/// The renderer collapses whitespace before shortening. Require at least one
/// actual character of this field, rather than an ellipsis at its start.
fn label_field_visible(full: &str, prefix: &str, value: &str) -> bool {
if value.trim().is_empty() {
return false;
}
let normalized = full.split_whitespace().collect::<Vec<_>>().join(" ");
let normalized_prefix = prefix.split_whitespace().collect::<Vec<_>>().join(" ");
let start = normalized_prefix.len()
+ usize::from(prefix.ends_with(char::is_whitespace) && !normalized_prefix.is_empty());
let painted = crate::views::short(full, 32);
let retained = normalized
.chars()
.zip(painted.chars())
.take_while(|(a, b)| a == b)
.map(|(c, _)| c.len_utf8())
.sum::<usize>();
start < retained
}
fn usages(
p: &Project,
owner: Id,
subject: Id,
surface: Surface,
bounds: Rectangle,
scene: &Scene,
) -> Vec<Usage> {
let mut result = Vec::new();
let mut add = |id, bounds, role: &str| {
// Activation bars share the occurrence identity with a labelled message;
// they must not inflate its target count.
if !result
.iter()
.any(|u: &Usage| u.target == Target::ScenarioObject(id))
{
result.push(Usage {
subject,
surface,
target: Target::ScenarioObject(id),
role: role.into(),
bounds,
});
}
};
for item in scene.boxes.iter().filter(|item| !item.title.is_empty()) {
let Some(element) = p.elements.get(&item.id) else {
continue;
};
if subject == item.id {
// A fragment's selectable header is useful even when its frame is
// much larger than the viewport.
let mut bounds = item.rect;
if item.marker == Some(Kind::Occurrence) {
bounds.height = bounds.height.min(38.);
}
add(item.id, bounds, "scenario object");
}
let represented = match element.shared() {
Some(SharedBody::Lifeline { represented }) => represented.map(|id| (id, "lifeline")),
Some(SharedBody::Occurrence { body, .. }) => match body {
OccurrenceBody::Action { action, .. } => action.map(|id| (id, "action occurrence")),
OccurrenceBody::State { state, .. } => state.map(|id| (id, "state observation")),
OccurrenceBody::SequenceReference { sequence } => {
sequence.map(|id| (id, "process reference"))
}
OccurrenceBody::ScenarioUse { scenario, .. } => {
scenario.map(|id| (id, "scenario reference"))
}
// An incomplete message fallback box only paints the occurrence
// name and a repair hint, not its exchange or operation.
_ => None,
},
_ => None,
};
if let Some((id, role)) = represented.filter(|(id, _)| *id == subject) {
let _ = id;
add(item.id, item.rect, role);
}
}
for line in scene.lines.iter().filter(|line| !line.label.is_empty()) {
let Some(element) = p.elements.get(&line.id) else {
continue;
};
let Some(SharedBody::Occurrence {
body: OccurrenceBody::Exchange { exchange, .. },
..
}) = element.shared()
else {
continue;
};
let bounds = line.label_bounds();
if subject == line.id || *exchange == Some(subject) {
add(line.id, bounds, "message occurrence");
}
// Replies derive their operation from the exact call, matching the
// operation label formatter rather than scanning raw reference fields.
if let Ok(operations::OperationResolution::Selected(operation)) =
operations::resolve(p, line.id)
&& let Some(label) = crate::data_values::operation_label(p, element)
&& let Some(prefix) = line.label.strip_suffix(&label)
{
let allocation = &p.relations[&operation.allocation];
let operation_prefix = if matches!(
element.shared(),
Some(SharedBody::Occurrence {
body: OccurrenceBody::Exchange {
message_kind: MessageKind::Reply,
..
},
..
})
) {
"Reply operation from Call: "
} else {
"Operation: "
};
let fields = [
(allocation.source(), format!("{prefix}{operation_prefix}")),
(
allocation.target(),
format!(
"{prefix}{operation_prefix}{} / ",
p.named(allocation.source())
),
),
(
allocation.id,
format!(
"{prefix}{operation_prefix}{} / {} · ",
p.named(allocation.source()),
p.named(allocation.target())
),
),
];
for (id, prefix) in fields {
if id == subject && label_field_visible(&line.label, &prefix, p.named(id)) {
add(line.id, bounds, "operation label");
}
}
}
}
if subject == owner || subject == surface.id() {
result.insert(
0,
Usage {
subject,
surface,
target: Target::Whole,
role: "scenario".into(),
bounds,
},
);
}
result
}
impl State {
/// Membership comes from the complete presentation chosen for this exact view,
/// including automatic fallback geometry while retaining its Saved identity.
pub(crate) fn representation_usages(
&self,
p: &Project,
representation: Representation,
subject: Id,
) -> Vec<Usage> {
let shown = presentation::present(p, representation, subject);
let (Some(owner), Some(bounds), Some(scene)) =
(shown.owner(), shown.bounds(), shown.scene())
else {
return vec![];
};
let surface = match representation {
Representation::Derived { owner } => Surface::Scenario(owner),
Representation::Saved { diagram } => Surface::Saved(diagram),
};
usages(p, owner, subject, surface, bounds, scene)
}
pub(crate) fn diagram_usages(&self, p: &Project, owner: Id, subject: Id) -> Vec<Usage> {
self.representation_usages(p, Representation::Derived { owner }, subject)
}
pub(crate) fn reveal_usage(
&mut self,
p: &Project,
document: Id,
owner: Id,
target: &Target,
) -> bool {
self.reveal_representation_usage(
p,
document,
owner,
Representation::Derived { owner },
target,
)
}
pub(crate) fn reveal_representation_usage(
&mut self,
p: &Project,
document: Id,
owner: Id,
representation: Representation,
target: &Target,
) -> bool {
if self.draft.is_some() || presentation::checked_owner(p, representation) != Some(owner) {
return false;
}
let id = match target {
Target::Whole => owner,
Target::ScenarioObject(id) => *id,
_ => return false,
};
let Some(usage) = self
.representation_usages(p, representation, id)
.into_iter()
.find(|u| &u.target == target)
else {
return false;
};
// Preserve an existing mounted viewport across enter's owner reset.
let viewport = self.presentation.viewport.unwrap_or(Size::new(500., 300.));
if !viewport.width.is_finite()
|| !viewport.height.is_finite()
|| viewport.width <= 0.
|| viewport.height <= 0.
{
return false;
}
let (zoom, pan) = if matches!(target, Target::Whole) {
let Some(camera) = super::camera::fit(usage.bounds, viewport) else {
return false;
};
(None, camera.pan)
} else {
let (zoom, pan) = crate::diagram_usage::camera_for(target, usage.bounds, viewport);
if !zoom.is_finite() || !pan.x.is_finite() || !pan.y.is_finite() {
return false;
}
(Some(zoom), pan)
};
self.enter_representation(document, owner, representation);
self.presentation.viewport = Some(viewport);
self.canvas_tool = Some((owner, CanvasTool::Select));
self.presentation.zoom(zoom);
self.presentation.pan = pan;
true
}
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,276 @@
use super::*;
fn add_occurrence(p: &mut Project, owner: Id, name: &str, body: OccurrenceBody) -> Id {
let layer = p.elements[&owner].layer();
let id = p
.add_element(Kind::Occurrence, layer, name, Some(owner))
.unwrap();
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::Occurrence {
body,
precondition: String::new(),
postcondition: String::new(),
};
if let Some(steps) = p.elements.get_mut(&owner).unwrap().steps_mut()
&& !steps.contains(&id)
{
steps.push(id);
}
id
}
fn targets(state: &State, p: &Project, owner: Id, subject: Id) -> BTreeSet<Id> {
state
.diagram_usages(p, owner, subject)
.into_iter()
.filter_map(|u| match u.target {
Target::ScenarioObject(id) => Some(id),
_ => None,
})
.collect()
}
#[test]
fn scenario_usage_counts_nested_occurrences_and_lifelines_but_keeps_reuse_opaque() {
for (layer, work_kind, participant_kind) in [
(Layer::Oa, Kind::Activity, Kind::Entity),
(Layer::Sa, Kind::Function, Kind::SystemActor),
] {
let mut p = Project::empty();
let owner = p
.add_element(Kind::Scenario, layer, "Caller", None)
.unwrap();
let other = p
.add_element(Kind::Scenario, layer, "Definition", None)
.unwrap();
let work = p.add_element(work_kind, layer, "Respond", None).unwrap();
let unused = p.add_element(work_kind, layer, "Respond", None).unwrap();
let participant = p
.add_element(participant_kind, layer, "Operator", None)
.unwrap();
let lanes = ["First role", "Second role"].map(|name| {
let id = p
.add_element(Kind::Lifeline, layer, name, Some(owner))
.unwrap();
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::Lifeline {
represented: Some(participant),
};
id
});
let first = add_occurrence(
&mut p,
owner,
"First action",
OccurrenceBody::Action {
action: Some(work),
lifeline: Some(lanes[0]),
},
);
let fragment = add_occurrence(
&mut p,
owner,
"Decision",
OccurrenceBody::Fragment {
operator: FragmentOperator::Alternative,
branches: vec![],
},
);
let branch = add_occurrence(
&mut p,
fragment,
"When ready",
OccurrenceBody::Branch {
guard: "ready".into(),
occurrences: vec![],
},
);
if let Some(SharedBody::Occurrence {
body: OccurrenceBody::Fragment { branches, .. },
..
}) = p.elements.get_mut(&fragment).unwrap().shared_mut()
{
branches.push(branch);
}
let nested = add_occurrence(
&mut p,
branch,
"Again",
OccurrenceBody::Action {
action: Some(work),
lifeline: Some(lanes[1]),
},
);
let inside = add_occurrence(
&mut p,
other,
"Hidden inside definition",
OccurrenceBody::Action {
action: Some(unused),
lifeline: None,
},
);
let use_a = add_occurrence(
&mut p,
owner,
"Use A",
OccurrenceBody::ScenarioUse {
scenario: Some(other),
bindings: vec![],
},
);
let use_b = add_occurrence(
&mut p,
owner,
"Use B",
OccurrenceBody::ScenarioUse {
scenario: Some(other),
bindings: vec![],
},
);
let before = p.clone();
let state = State::default();
assert_eq!(
targets(&state, &p, owner, work),
BTreeSet::from([first, nested])
);
assert_eq!(
targets(&state, &p, owner, participant),
BTreeSet::from(lanes)
);
assert_eq!(
targets(&state, &p, owner, other),
BTreeSet::from([use_a, use_b])
);
assert!(targets(&state, &p, owner, unused).is_empty());
assert!(targets(&state, &p, owner, inside).is_empty());
assert_eq!(targets(&state, &p, owner, branch), BTreeSet::from([branch]));
assert_eq!(
state.diagram_usages(&p, owner, owner)[0].target,
Target::Whole
);
assert_eq!(p, before);
}
}
#[test]
fn scenario_usage_keeps_repeated_messages_and_reveal_is_read_only_and_revalidated() {
let f = super::super::direct_tests::fixture();
let mut p = f.project;
let mut state = State::default();
assert_eq!(
targets(&state, &p, f.owner, f.exchange),
BTreeSet::from([f.call, f.reply])
);
let before = p.clone();
let target = Target::ScenarioObject(f.reply);
state.viewport(Size::new(900., 500.));
assert!(state.reveal_usage(&p, Id::new_v4(), f.owner, &target));
let usage = state
.diagram_usages(&p, f.owner, f.reply)
.into_iter()
.find(|u| u.target == target)
.unwrap();
let zoom = state
.presentation
.scale(Scene::sequence(&p, f.owner, f.reply).size);
let centered = Point::new(
usage.bounds.center_x() * zoom,
usage.bounds.center_y() * zoom,
) + state.presentation.pan;
assert!((centered.x - 450.).abs() < 0.01 && (centered.y - 250.).abs() < 0.01);
assert!(zoom >= 0.5);
assert_eq!(p, before);
// Keeping an orphan semantic occurrence does not keep its painted target.
p.elements
.get_mut(&f.owner)
.unwrap()
.steps_mut()
.unwrap()
.retain(|id| *id != f.reply);
let camera = state.camera_boundary();
assert!(!state.reveal_usage(&p, Id::new_v4(), f.owner, &target));
assert!(camera == state.camera_boundary());
assert!(!state.reveal_usage(&p, Id::new_v4(), f.owner, &Target::Edge(f.exchange)));
// A message whose endpoints are incomplete paints only its repair box.
if let Some(SharedBody::Occurrence {
body: OccurrenceBody::Exchange { receiver, .. },
..
}) = p.elements.get_mut(&f.call).unwrap().shared_mut()
{
*receiver = None;
}
assert!(targets(&state, &p, f.owner, f.exchange).is_empty());
assert_eq!(
targets(&state, &p, f.owner, f.call),
BTreeSet::from([f.call])
);
}
#[test]
fn scenario_operation_label_membership_excludes_ellipsis_only_and_normalizes_whitespace() {
assert!(label_field_visible(
"1. call · X · Operation: I / O",
"1. call · X · Operation: ",
"I"
));
assert!(!label_field_visible(
"1234567890123456789012345678901Ixyz",
"1234567890123456789012345678901",
"Ixyz"
));
assert!(!label_field_visible(
"A very long occurrence label · Operation: I",
"A very long occurrence label · Operation: ",
"I"
));
assert!(label_field_visible(
"call\n\t· Operation: 界",
"call\n\t· Operation: ",
"界"
));
assert!(!label_field_visible("Operation: ", "Operation: ", ""));
}
#[test]
fn scenario_usage_only_reports_the_resolved_operation_fields_actually_in_the_label() {
let mut p = persistence::decode(include_bytes!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../harness/fixtures/interface-operation-probe/output/boundary-only.json"
)))
.unwrap();
let id = |n| Id::parse_str(&format!("52060906-0000-4000-8000-{n:012x}")).unwrap();
let (owner, call, exchange, allocation) = (id(40), id(43), id(34), id(30));
let (interface, item) = (
p.relations[&allocation].source(),
p.relations[&allocation].target(),
);
for (id, name) in [(call, "x"), (interface, "I"), (item, "O")] {
p.elements.get_mut(&id).unwrap().name = name.into();
}
p.relations.get_mut(&exchange).unwrap().name = "x".into();
p.relations.get_mut(&allocation).unwrap().name = "A".into();
if let Some(SharedBody::Occurrence {
body: OccurrenceBody::Exchange { operation, .. },
..
}) = p.elements.get_mut(&call).unwrap().shared_mut()
{
*operation = Some(OperationUse {
allocation: Some(allocation),
});
}
assert!(matches!(
operations::resolve(&p, call),
Ok(operations::OperationResolution::Selected(_))
));
let state = State::default();
let before = p.clone();
assert_eq!(
targets(&state, &p, owner, interface),
BTreeSet::from([call])
);
assert_eq!(targets(&state, &p, owner, item), BTreeSet::from([call]));
assert!(targets(&state, &p, owner, allocation).is_empty());
assert_eq!(p, before);
p.elements.get_mut(&call).unwrap().name = "This occurrence has a deliberately long name".into();
assert!(targets(&state, &p, owner, interface).is_empty());
assert!(targets(&state, &p, owner, item).is_empty());
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,443 @@
//! Mounted Scenario Details → inspector regression and retained-intent guards.
use super::*;
use crate::behavior::canvas_workspace::tests::Mounted;
use crate::behavior::scenario_layout::resolve;
use iced::advanced::widget;
use iced::{Vector, keyboard};
use std::sync::Arc;
const FIXTURE: &str = include_str!("../../../app/diagram_usages/tests/saved_scenario.arcadia");
fn id(app: &App, alias: &str) -> Id {
app.project()
.identities()
.into_iter()
.find(|(_, name)| *name == alias)
.unwrap()
.0
}
fn send(app: &mut App, event: impl FnOnce(Context) -> CanvasEvent) {
let context = app.scenario_context().unwrap();
let _ = app.update(Message::ScenarioCanvas(event(context)));
}
fn setup() -> (App, Id, Id) {
let mut app = App::with_project(oa_core::language::parse(FIXTURE).unwrap());
let owner = id(&app, "incident_response");
let diagram = id(&app, "incident_readable");
let message = id(&app, "initial_report");
let _ = app.update(Message::Select(owner));
let _ = app.update(Message::Diagram(crate::app::Choice {
id: diagram,
label: "A".into(),
}));
send(&mut app, |c| CanvasEvent::Select(c, message));
app.window_size = Size::new(1440., 1000.);
(app, diagram, message)
}
fn open_focused_details(app: &mut App) {
if !app.behavior.scenario.focused() {
send(app, CanvasEvent::Focus);
}
if app.behavior.scenario.presentation.shelf.is_none() {
send(app, |c| CanvasEvent::Shelf(c, Shelf::Selection));
}
}
fn stored_camera(app: &App) -> (Vector, Option<f32>, bool) {
let p = &app.behavior.scenario.presentation;
(p.pan, p.zoom, p.fit)
}
#[tokio::test]
async fn focused_saved_details_opens_real_editable_inspector_and_returns_to_same_view() {
let (mut app, diagram, message) = setup();
let _ = app.update(Message::InspectorTab(
crate::navigation::InspectorTab::Connections,
));
let mut mounted = Mounted::new(&mut app).await;
open_focused_details(&mut app);
mounted.settle(&mut app).await;
let before = mounted.geometry(&app);
assert!(!before.scrolls.contains_key(&widget::Id::new("inspector")));
let (details, offset) = before.scrolls[&widget::Id::new("scenario-details")];
let button = before.bounds("scenario-properties").center() - offset;
assert!(
details.contains(button),
"Properties must be actually visible"
);
let context = app.scenario_context().unwrap();
let camera = stored_camera(&app);
let original = app.document.snapshot();
let bundle = app.document.bundle().clone();
let messages = mounted.click(&mut app, button).await;
assert!(messages.iter().any(|event| matches!(event,
Message::ScenarioCanvas(CanvasEvent::Properties(c, selected))
if *c == context && *selected == message)));
mounted.settle(&mut app).await;
assert!(!app.behavior.scenario.focused());
assert!(app.behavior.scenario.presentation.shelf.is_none());
assert_eq!(
app.inspector_tab,
crate::navigation::InspectorTab::Properties
);
assert_eq!(app.selected, message);
assert_eq!(app.active_diagram, Some(diagram));
assert_eq!(app.page, crate::app::Page::Behavior);
let current = app.scenario_context().unwrap();
assert_eq!(current.representation, context.representation);
assert_eq!(current.visit, context.visit);
assert_eq!(
stored_camera(&app),
camera,
"restoring panels retains camera intent"
);
assert!(Arc::ptr_eq(&original, &app.document.snapshot()));
assert_eq!(app.document.bundle(), &bundle);
assert!(!app.document.can_undo() && !app.document.can_redo());
let shown = mounted.geometry(&app);
assert!(
!shown
.scrolls
.contains_key(&widget::Id::new("scenario-details"))
);
let (inspector, offset) = shown.scrolls[&widget::Id::new("inspector")];
assert!(inspector.width > 250. && inspector.height > 500.);
// Production Properties places Name before the other scalar text inputs.
// Account for the retained inspector scroll, rather than clicking content coordinates.
let input = shown
.text_inputs
.iter()
.map(|bounds| bounds.center() - offset)
.filter(|point| inspector.contains(*point))
.min_by(|a, b| a.y.total_cmp(&b.y))
.expect("visible editable property");
mounted.click(&mut app, input).await;
let typed = mounted
.batch(
&mut app,
&[iced::Event::Keyboard(keyboard::Event::KeyPressed {
key: keyboard::Key::Character("x".into()),
modified_key: keyboard::Key::Character("x".into()),
physical_key: keyboard::key::Physical::Unidentified(
keyboard::key::NativeCode::Unidentified,
),
location: keyboard::Location::Standard,
modifiers: keyboard::Modifiers::empty(),
text: Some("x".into()),
repeat: false,
})],
mouse::Cursor::Available(input),
)
.await;
assert!(
typed
.iter()
.any(|message| matches!(message, Message::Field(path, _) if path == "/name")),
"real Name editor must publish its edit: {typed:?}"
);
assert!(app.draft_dirty());
assert!(
Arc::ptr_eq(&original, &app.document.snapshot()),
"typing is only a draft"
);
let _ = app.update(Message::RevertProperties);
assert!(!app.draft_dirty());
send(&mut app, CanvasEvent::Focus);
mounted.settle(&mut app).await;
let returned = mounted.geometry(&app);
assert!(!returned.scrolls.contains_key(&widget::Id::new("inspector")));
let viewport = returned.bounds("scenario-viewport");
assert_eq!(app.selected, message);
assert_eq!(app.scenario_context().unwrap().visit, context.visit);
assert_eq!(
app.scenario_context().unwrap().representation,
context.representation
);
assert_eq!(stored_camera(&app), camera);
let geometry = resolve(app.project(), context.representation, message).unwrap();
let line = geometry
.scene
.lines
.iter()
.find(|line| line.id == message)
.unwrap();
let effective = app.behavior.scenario.presentation.camera(geometry.bounds);
let point = viewport.position()
+ effective.pan
+ Vector::new(line.points[0].x, line.points[0].y) * effective.scale;
assert!(
viewport.contains(point),
"the same selected message remains in fitted view"
);
assert!(Arc::ptr_eq(&original, &app.document.snapshot()));
assert_eq!(app.document.bundle(), &bundle);
assert!(!app.document.can_undo() && !app.document.can_redo());
}
#[test]
fn details_properties_refuses_pending_property_source_and_behavior_drafts_without_apply() {
for kind in 0..3 {
let (mut app, _, message) = setup();
open_focused_details(&mut app);
match kind {
0 => {
let _ = app.update(Message::Field(
"/name".into(),
"Retained property draft".into(),
));
assert!(app.draft_dirty());
}
1 => {
let _ = app.update(Message::SourceEdit(
iced::widget::text_editor::Action::Edit(
iced::widget::text_editor::Edit::Insert('x'),
),
));
assert!(app.source_dirty());
}
_ => {
let owner = app.scenario_context().unwrap().scenario;
let _ = app.update(Message::Behavior(super::super::super::Event::Scenario(
Event::Start(owner, Tool::Lifeline),
)));
assert!(app.behavior.has_pending());
}
}
let snapshot = app.document.snapshot();
let bundle = app.document.bundle().clone();
let property = app.draft.as_ref().unwrap().materialize();
let source = app.source_editor.text();
let behavior = app.behavior.recovery_snapshot();
let camera = app.behavior.scenario.camera_boundary();
let context = app.scenario_context().unwrap();
let epochs = (app.canvas_epoch, app.camera_epoch);
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Properties(
context, message,
)));
assert!(app.behavior.scenario.camera_boundary() == camera);
assert_eq!(app.scenario_context(), Some(context));
assert_eq!((app.canvas_epoch, app.camera_epoch), epochs);
assert_eq!(app.draft.as_ref().unwrap().materialize(), property);
assert_eq!(app.source_editor.text(), source);
assert_eq!(app.behavior.recovery_snapshot(), behavior);
assert_eq!(app.selected, message);
assert!(Arc::ptr_eq(&snapshot, &app.document.snapshot()));
assert_eq!(app.document.bundle(), &bundle);
assert!(!app.document.can_undo() && !app.document.can_redo());
}
}
#[tokio::test]
async fn focused_details_opens_current_saved_view_properties_after_following_diagram_picker() {
let (mut app, a, _) = setup();
let b = id(&app, "incident_wide");
// Selecting the current view record makes the real picker follow that
// record selection to B. Do not seed selected=B or its property draft.
let _ = app.update(Message::Select(a));
assert_eq!(app.selected, a);
let _ = app.update(Message::Diagram(crate::app::Choice {
id: b,
label: "B".into(),
}));
assert_eq!(app.selected, b);
assert_eq!(app.draft.as_ref().unwrap().original, b);
assert_eq!(
app.scenario_context().unwrap().representation,
Representation::Saved { diagram: b }
);
let _ = app.update(Message::InspectorTab(
crate::navigation::InspectorTab::Connections,
));
let mut mounted = Mounted::new(&mut app).await;
open_focused_details(&mut app);
mounted.settle(&mut app).await;
let context = app.scenario_context().unwrap();
let camera = stored_camera(&app);
let snapshot = app.document.snapshot();
let bundle = app.document.bundle().clone();
let before = mounted.geometry(&app);
assert!(!before.scrolls.contains_key(&widget::Id::new("inspector")));
let (details, offset) = before.scrolls[&widget::Id::new("scenario-details")];
let point = before.bounds("scenario-properties").center() - offset;
assert!(details.contains(point));
let events = mounted.click(&mut app, point).await;
assert!(events.iter().any(|event| matches!(event,
Message::ScenarioCanvas(CanvasEvent::Properties(c, selected))
if *c == context && *selected == b)));
mounted.settle(&mut app).await;
let shown = mounted.geometry(&app);
assert!(
!shown
.scrolls
.contains_key(&widget::Id::new("scenario-details"))
);
let (inspector, offset) = shown.scrolls[&widget::Id::new("inspector")];
assert!(
shown
.text_inputs
.iter()
.any(|bounds| inspector.contains(bounds.center() - offset)),
"current Saved diagram has a visible editable inspector"
);
assert!(!app.behavior.scenario.focused());
assert_eq!(
app.inspector_tab,
crate::navigation::InspectorTab::Properties
);
assert_eq!(app.selected, b);
assert_eq!(app.draft.as_ref().unwrap().original, b);
assert_eq!(app.active_diagram, Some(b));
assert_eq!(app.page, crate::app::Page::Behavior);
assert_eq!(
app.scenario_context().unwrap().representation,
context.representation
);
assert_eq!(app.scenario_context().unwrap().visit, context.visit);
assert_eq!(stored_camera(&app), camera);
assert!(Arc::ptr_eq(&snapshot, &app.document.snapshot()));
assert_eq!(app.document.bundle(), &bundle);
assert!(!app.document.can_undo() && !app.document.can_redo());
}
#[test]
fn details_properties_refuses_another_saved_view_even_with_the_same_scenario_owner() {
let (mut app, a, _) = setup();
let b = id(&app, "incident_wide");
let _ = app.update(Message::Diagram(crate::app::Choice {
id: b,
label: "B".into(),
}));
let _ = app.update(Message::Select(a));
assert_eq!(app.selected, a);
open_focused_details(&mut app);
let context = app.scenario_context().unwrap();
assert_eq!(context.representation, Representation::Saved { diagram: b });
assert_eq!(
presentation::checked_owner(app.project(), Representation::Saved { diagram: a }),
Some(context.scenario)
);
assert!(!properties_target(app.project(), context, a));
let camera = app.behavior.scenario.camera_boundary();
let epochs = (app.canvas_epoch, app.camera_epoch);
let snapshot = app.document.snapshot();
let bundle = app.document.bundle().clone();
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Properties(context, a)));
assert!(app.behavior.scenario.camera_boundary() == camera);
assert_eq!((app.canvas_epoch, app.camera_epoch), epochs);
assert_eq!(app.scenario_context(), Some(context));
assert_eq!(app.selected, a);
assert_eq!(app.draft.as_ref().unwrap().original, a);
assert!(Arc::ptr_eq(&snapshot, &app.document.snapshot()));
assert_eq!(app.document.bundle(), &bundle);
assert!(!app.document.can_undo() && !app.document.can_redo());
}
#[test]
fn details_properties_rejects_retired_view_selection_and_epoch_without_changing_panels() {
for kind in 0..3 {
let (mut app, diagram, message) = setup();
open_focused_details(&mut app);
let captured = app.scenario_context().unwrap();
match kind {
0 => {
let other = id(&app, "incident_wide");
for view in [other, diagram] {
let _ = app.update(Message::Diagram(crate::app::Choice {
id: view,
label: "view".into(),
}));
}
open_focused_details(&mut app);
assert_ne!(app.scenario_context().unwrap().visit, captured.visit);
}
1 => {
let other = id(&app, "updated_report");
send(&mut app, |c| CanvasEvent::Select(c, other));
}
_ => {
send(&mut app, |c| CanvasEvent::Zoom(c, Some(0.5)));
}
}
let camera = app.behavior.scenario.camera_boundary();
let context = app.scenario_context();
let selected = app.selected;
let tab = app.inspector_tab;
let epochs = (app.canvas_epoch, app.camera_epoch);
let snapshot = app.document.snapshot();
let bundle = app.document.bundle().clone();
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Properties(
captured, message,
)));
assert!(app.behavior.scenario.camera_boundary() == camera);
assert_eq!(app.scenario_context(), context);
assert_eq!(app.selected, selected);
assert_eq!(app.inspector_tab, tab);
assert_eq!((app.canvas_epoch, app.camera_epoch), epochs);
assert!(Arc::ptr_eq(&snapshot, &app.document.snapshot()));
assert_eq!(app.document.bundle(), &bundle);
}
}
#[test]
fn opening_scenario_properties_cancels_held_spacing_and_rejects_its_late_finish() {
use super::super::spacing::{Event as Spacing, Target, controller::tests::packet};
let (mut app, _, message) = setup();
send(&mut app, |c| {
CanvasEvent::Viewport(c, Size::new(900., 600.))
});
open_focused_details(&mut app);
let begin = packet(
app.project(),
&app.behavior.scenario,
app.scenario_context().unwrap(),
app.camera_epoch,
Target::Message(message),
);
let Spacing::Begin { press_screen, .. } = begin else {
panic!()
};
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Spacing(begin)));
assert_eq!(app.behavior.scenario.spacing_token(), Some(begin.token()));
let snapshot = app.document.snapshot();
let end = press_screen + Vector::new(0., 12.);
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Spacing(
Spacing::Move {
stamp: begin.stamp(),
token: begin.token(),
screen: end,
},
)));
let paint = app
.behavior
.scenario
.spacing_paint(begin.stamp().context, begin.stamp().camera_epoch)
.unwrap();
assert!(paint.refusal.is_none());
let line = paint
.scene
.lines
.iter()
.find(|line| line.id == message)
.unwrap();
assert!(
line.points[0].y > 160.,
"a real proposed scene moved the first message"
);
assert!(
Arc::ptr_eq(&snapshot, &app.document.snapshot()),
"preview is not authored"
);
let camera = stored_camera(&app);
send(&mut app, |c| CanvasEvent::Properties(c, message));
assert!(app.behavior.scenario.spacing_token().is_none());
assert_eq!(stored_camera(&app), camera);
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Spacing(
Spacing::Finish {
stamp: begin.stamp(),
token: begin.token(),
screen: end,
},
)));
assert!(Arc::ptr_eq(&snapshot, &app.document.snapshot()));
assert!(!app.document.can_undo() && !app.document.can_redo());
}
@@ -0,0 +1,438 @@
//! Real mounted widgets dispatch the gesture; expectations name authored fields
//! and actual displayed anchors independently of controller command results.
use super::*;
use crate::behavior::canvas_workspace::tests::{Mounted, key};
use crate::behavior::scenario_layout::{ResolvedScenarioPlan, resolve};
use crate::document::Document;
use iced::Vector;
use std::sync::Arc;
const FIXTURE: &str = include_str!("../../../app/diagram_usages/tests/saved_scenario.arcadia");
fn id(app: &App, alias: &str) -> Id {
app.project()
.identities()
.into_iter()
.find(|(_, name)| *name == alias)
.unwrap()
.0
}
async fn setup(fit: bool) -> (App, Mounted, Id) {
let mut app = App::with_project(oa_core::language::parse(FIXTURE).unwrap());
let owner = id(&app, "incident_response");
let diagram = id(&app, "incident_readable");
let _ = app.update(Message::Select(owner));
let _ = app.update(Message::Diagram(crate::app::Choice {
id: diagram,
label: "A - Incident review readable".into(),
}));
app.window_size = Size::new(1440., 900.);
let mut mounted = Mounted::new(&mut app).await;
if !fit {
let context = app.scenario_context().unwrap();
let task = app.update(Message::ScenarioCanvas(CanvasEvent::Zoom(
context,
Some(0.5),
)));
mounted.perform(&mut app, task).await;
mounted.settle(&mut app).await;
}
assert_eq!(
app.scenario_context().unwrap().representation,
Representation::Saved { diagram }
);
assert!(!app.document.can_undo() && !app.document.can_redo());
(app, mounted, diagram)
}
fn resolved(app: &App) -> ResolvedScenarioPlan {
resolve(
app.project(),
app.scenario_context().unwrap().representation,
app.selected,
)
.unwrap()
}
fn stored_camera(app: &App) -> (Vector, Option<f32>, bool) {
let p = &app.behavior.scenario.presentation;
(p.pan, p.zoom, p.fit)
}
fn screen(mounted: &mut Mounted, app: &App, world: Point) -> Point {
let bounds = mounted.geometry(app).bounds("scenario-viewport");
let geometry = resolved(app);
let camera = app.behavior.scenario.presentation.camera(geometry.bounds);
let point = bounds.position() + camera.pan + Vector::new(world.x, world.y) * camera.scale;
assert!(
bounds.contains(point),
"actual visible world {world:?}: {point:?} in {bounds:?}"
);
point
}
fn mouse_event(event: mouse::Event) -> iced::Event {
iced::Event::Mouse(event)
}
async fn press(mounted: &mut Mounted, app: &mut App, point: Point) -> Vec<Message> {
mounted
.batch(
app,
&[
mouse_event(mouse::Event::CursorMoved { position: point }),
mouse_event(mouse::Event::ButtonPressed(mouse::Button::Left)),
],
mouse::Cursor::Available(point),
)
.await
}
async fn move_to(mounted: &mut Mounted, app: &mut App, point: Point) -> Vec<Message> {
mounted
.batch(
app,
&[mouse_event(mouse::Event::CursorMoved { position: point })],
mouse::Cursor::Available(point),
)
.await
}
async fn release(mounted: &mut Mounted, app: &mut App, point: Point) -> Vec<Message> {
mounted
.batch(
app,
&[mouse_event(mouse::Event::ButtonReleased(
mouse::Button::Left,
))],
mouse::Cursor::Available(point),
)
.await
}
fn set_expected_message(project: &mut oa_core::Project, diagram: Id, occurrence: Id, y: f32) {
let Some(oa_core::DiagramLayout::Scenario(layout)) =
&mut project.diagrams.get_mut(&diagram).unwrap().layout
else {
panic!()
};
layout
.messages
.iter_mut()
.find(|m| m.address.occurrence == occurrence)
.unwrap()
.send_y = y;
}
fn set_expected_lane(project: &mut oa_core::Project, diagram: Id, lifeline: Id, x: f32) {
let Some(oa_core::DiagramLayout::Scenario(layout)) =
&mut project.diagrams.get_mut(&diagram).unwrap().layout
else {
panic!()
};
layout
.lanes
.iter_mut()
.find(|lane| lane.lifeline == lifeline)
.unwrap()
.x = x;
}
fn assert_reopens_exactly(app: &App) {
let reopened = Document::from_bundle(
app.document.bundle().clone(),
oa_core::source_project::ProjectLimits::default(),
)
.unwrap();
assert_eq!(reopened.project(), app.project());
assert_eq!(reopened.bundle(), app.document.bundle());
}
#[tokio::test]
async fn saved_message_same_batch_pointer_events_commit_once_and_one_undo_redo_restores_exact_source()
{
let (mut app, mut mounted, diagram) = setup(false).await;
let target = id(&app, "updated_report");
let before = app.document.snapshot();
let revision = app.document.revision();
let projected = screen(&mut mounted, &app, Point::new(400., 252.));
// This fixture asks for exactly +20 world units. A fractional start near a
// screen-coordinate exponent boundary need not retain an exact +10 delta.
let start = Point::new(projected.x.round(), projected.y.round());
let mut geometry = resolved(&app);
let camera = app.behavior.scenario.presentation.camera(geometry.bounds);
assert_eq!(camera.scale, 0.5);
let bounds = mounted.geometry(&app).bounds("scenario-viewport");
let world = Point::new(
(start.x - bounds.x - camera.pan.x) / camera.scale,
(start.y - bounds.y - camera.pan.y) / camera.scale,
);
geometry.scene.scale = camera.scale;
geometry.scene.camera_pan = camera.pan;
let hit = geometry.scene.hit_item(world).unwrap();
assert_eq!(hit.id, target);
assert_eq!(hit.role, crate::behavior::SceneHitRole::MessageBody);
let end = start + Vector::new(0., 10.); // .5 display scale: +20 authored y.
assert_eq!(end.y - start.y, 10.);
assert!(bounds.contains(start) && bounds.contains(end));
let messages = mounted
.batch(
&mut app,
&[
mouse_event(mouse::Event::CursorMoved { position: start }),
mouse_event(mouse::Event::ButtonPressed(mouse::Button::Left)),
mouse_event(mouse::Event::CursorMoved { position: end }),
mouse_event(mouse::Event::ButtonReleased(mouse::Button::Left)),
],
mouse::Cursor::Available(end),
)
.await;
assert_eq!(
messages
.iter()
.filter(|m| matches!(
m,
Message::ScenarioCanvas(CanvasEvent::Spacing(spacing::Event::Begin { .. }))
))
.count(),
1
);
assert_eq!(
messages
.iter()
.filter(|m| matches!(
m,
Message::ScenarioCanvas(CanvasEvent::Spacing(spacing::Event::Finish { .. }))
))
.count(),
1
);
let mut expected = before.decoded.project.clone();
set_expected_message(&mut expected, diagram, target, 272.);
assert_eq!(
app.project(),
&expected,
"Only Saved A's addressed y changes; steps, other occurrences and B/C are exact"
);
assert_eq!(app.document.revision(), revision + 1);
assert_eq!(app.selected, target);
assert!(app.behavior.scenario.spacing_token().is_none());
assert_reopens_exactly(&app);
let after = app.document.snapshot();
let task = app.update(Message::Undo);
mounted.perform(&mut app, task).await;
assert_eq!(app.project(), &before.decoded.project);
assert_eq!(app.document.bundle(), &before.bundle);
assert!(!app.document.can_undo() && app.document.can_redo());
let task = app.update(Message::Redo);
mounted.perform(&mut app, task).await;
assert_eq!(app.project(), &after.decoded.project);
assert_eq!(app.document.bundle(), &after.bundle);
}
#[tokio::test]
async fn saved_header_preview_survives_frames_and_freezes_fit_while_actual_bounds_expand() {
let (mut app, mut mounted, diagram) = setup(true).await;
let assessor = id(&app, "assessor");
let before = app.document.snapshot();
let original = resolved(&app);
let original_camera = app.behavior.scenario.presentation.camera(original.bounds);
let stored = stored_camera(&app);
assert!(stored.2, "test must exercise original Fit");
let start = screen(&mut mounted, &app, Point::new(640., 45.));
press(&mut mounted, &mut app, start).await;
let token = app
.behavior
.scenario
.spacing_token()
.expect("actual header press begins");
for x in [670., 700.] {
let end = start + Vector::new((x - 640.) * original_camera.scale, 0.);
move_to(&mut mounted, &mut app, end).await;
mounted.settle(&mut app).await; // Actual borrowed Scene/Stack tree is rebuilt.
assert_eq!(app.behavior.scenario.spacing_token(), Some(token));
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
assert_eq!(stored_camera(&app), stored);
let paint = app
.behavior
.scenario
.spacing_paint(app.scenario_context().unwrap(), app.camera_epoch)
.unwrap();
assert!(paint.refusal.is_none());
assert!((paint.scene.render_lanes[&assessor] - x).abs() < 0.001);
assert!(paint.bounds.width > original.bounds.width);
assert_eq!(paint.scene.scale, original_camera.scale);
assert_eq!(paint.scene.camera_pan, original_camera.pan);
let header = paint.scene.boxes.iter().find(|b| b.id == assessor).unwrap();
let displayed = Vector::new(header.rect.center().x, header.rect.center().y)
* paint.scene.scale
+ paint.scene.camera_pan;
let baseline = Vector::new(640., 50.) * original_camera.scale + original_camera.pan;
assert!((displayed.x - baseline.x - (x - 640.) * original_camera.scale).abs() < 0.001);
assert_eq!(displayed.y, baseline.y);
}
let end = start + Vector::new(60. * original_camera.scale, 0.);
release(&mut mounted, &mut app, end).await;
let final_geometry = resolved(&app);
let actual_x = final_geometry.scene.render_lanes[&assessor];
assert!((actual_x - 700.).abs() < 0.001);
let mut expected = before.decoded.project.clone();
set_expected_lane(&mut expected, diagram, assessor, actual_x);
assert_eq!(app.project(), &expected);
let final_camera = app
.behavior
.scenario
.presentation
.camera(final_geometry.bounds);
assert_eq!(
final_camera, original_camera,
"commit must not refit underneath pointer"
);
assert!(!app.behavior.scenario.presentation.fit);
assert_eq!(app.selected, assessor);
assert_reopens_exactly(&app);
}
#[tokio::test]
async fn saved_body_and_header_clicks_select_without_materializing_spacing() {
let (mut app, mut mounted, _) = setup(false).await;
let before = app.document.snapshot();
let original_camera = stored_camera(&app);
for (alias, world) in [
("updated_report", Point::new(400., 252.)),
("assessor", Point::new(640., 45.)),
] {
let target = id(&app, alias);
let point = screen(&mut mounted, &app, world);
mounted.click(&mut app, point).await;
assert_eq!(app.selected, target);
assert!(app.behavior.scenario.spacing_token().is_none());
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
assert_eq!(stored_camera(&app), original_camera);
}
assert!(!app.document.can_undo() && !app.document.can_redo());
}
#[tokio::test]
async fn saved_valid_preview_then_refused_self_call_never_commits_last_valid_candidate() {
let (mut app, mut mounted, _) = setup(false).await;
let target = id(&app, "assess_call");
let before = app.document.snapshot();
let camera = stored_camera(&app);
let start = screen(&mut mounted, &app, Point::new(680., 344.));
press(&mut mounted, &mut app, start).await;
let valid = start + Vector::new(0., -10.); // send324, receive348, reply436 unchanged.
move_to(&mut mounted, &mut app, valid).await;
mounted.settle(&mut app).await;
let paint = app
.behavior
.scenario
.spacing_paint(app.scenario_context().unwrap(), app.camera_epoch)
.unwrap();
assert!(paint.refusal.is_none());
let line = paint.scene.lines.iter().find(|l| l.id == target).unwrap();
assert_eq!(line.points[0].y, 324.);
assert_eq!(line.points.last().unwrap().y, 348.);
let refused = start + Vector::new(0., 53.); // send450, receive474 would follow reply436.
move_to(&mut mounted, &mut app, refused).await;
mounted.settle(&mut app).await;
let paint = app
.behavior
.scenario
.spacing_paint(app.scenario_context().unwrap(), app.camera_epoch)
.unwrap();
assert!(paint.refusal.is_some());
assert_eq!(
paint
.scene
.lines
.iter()
.find(|l| l.id == target)
.unwrap()
.points[0]
.y,
344.
);
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
release(&mut mounted, &mut app, refused).await;
assert!(app.behavior.scenario.spacing_token().is_none());
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
assert_eq!(stored_camera(&app), camera);
assert!(!app.document.can_undo() && !app.document.can_redo());
}
#[tokio::test]
async fn saved_escape_and_outside_pointer_cancel_actual_held_preview() {
for escape in [true, false] {
let (mut app, mut mounted, _) = setup(false).await;
let before = app.document.snapshot();
let camera = stored_camera(&app);
let start = screen(&mut mounted, &app, Point::new(400., 252.));
let end = start + Vector::new(0., 10.);
press(&mut mounted, &mut app, start).await;
move_to(&mut mounted, &mut app, end).await;
mounted.settle(&mut app).await;
assert!(app.behavior.scenario.spacing_token().is_some());
let release_at = if escape {
mounted
.batch(
&mut app,
&[key(iced::keyboard::key::Named::Escape)],
mouse::Cursor::Available(end),
)
.await;
end
} else {
let bounds = mounted.geometry(&app).bounds("scenario-viewport");
let outside = Point::new(bounds.x - 5., bounds.y + 100.);
move_to(&mut mounted, &mut app, outside).await;
outside
};
assert!(app.behavior.scenario.spacing_token().is_none());
release(&mut mounted, &mut app, release_at).await;
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
assert_eq!(stored_camera(&app), camera);
assert!(!app.document.can_undo() && !app.document.can_redo());
}
}
#[tokio::test]
async fn saved_drag_return_to_origin_preserves_redo_selection_and_original_fit_camera() {
let (mut app, mut mounted, _) = setup(true).await;
let owner = id(&app, "incident_response");
app.document
.execute(oa_core::Command::Rename(
owner,
"Previously edited owner".into(),
))
.unwrap();
let redo = app.document.snapshot();
let task = app.update(Message::Undo);
mounted.perform(&mut app, task).await;
mounted.settle(&mut app).await;
assert!(app.document.can_redo());
let before = app.document.snapshot();
let camera = stored_camera(&app);
let selected = app.selected;
let revision = app.document.revision();
let start = screen(&mut mounted, &app, Point::new(400., 252.));
press(&mut mounted, &mut app, start).await;
move_to(&mut mounted, &mut app, start + Vector::new(0., 12.)).await;
mounted.settle(&mut app).await;
assert!(app.behavior.scenario.spacing_token().is_some());
move_to(&mut mounted, &mut app, start).await;
mounted.settle(&mut app).await;
release(&mut mounted, &mut app, start).await;
assert!(app.behavior.scenario.spacing_token().is_none());
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
assert_eq!(app.document.bundle(), &before.bundle);
assert_eq!(app.document.revision(), revision);
assert_eq!(stored_camera(&app), camera);
assert_eq!(app.selected, selected);
assert!(app.document.can_redo());
let task = app.update(Message::Redo);
mounted.perform(&mut app, task).await;
assert_eq!(app.project(), &redo.decoded.project);
assert_eq!(app.document.bundle(), &redo.bundle);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,492 @@
//! Derived call geometry. Correlation and branch ownership come from the model;
//! columns separate overlapping drawings and do not assert an execution thread.
use super::*;
const WIDTH: f32 = 12.;
const OFFSET: f32 = 6.;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum Finish {
Reply(Id),
Boundary,
/// Still open at this scope boundary; the visible span is not an isolated stub.
Continuation,
Open,
/// The receiver was deleted without answering this call.
Terminated(Id),
/// Lifetime clipping ended this visible piece before its logical terminal.
Clipped,
}
#[derive(Clone, Debug, PartialEq)]
pub(super) struct Segment {
pub call: Id,
pub receiver: Id,
pub scope: Id,
pub rect: Rectangle,
pub column: usize,
pub finish: Finish,
}
impl Segment {
fn bottom(&self) -> f32 {
self.rect.y + self.rect.height
}
fn overlaps(&self, other: &Self) -> bool {
self.receiver == other.receiver
&& self.scope == other.scope
&& self.rect.y < other.bottom()
&& other.rect.y < self.bottom()
}
fn edge(&self, right: bool) -> f32 {
self.rect.x + if right { self.rect.width } else { 0. }
}
}
impl Scene {
pub(super) fn layout_activations(&mut self, p: &Project, lanes: &BTreeMap<Id, f32>) {
// Logical endpoints precede clipping and remain available even when a
// lifetime hides a whole execution or terminates it before its Reply.
let correlations = logical_correlations(p, &self.rows, &self.lines);
let mut calls: Vec<_> = self
.rows
.iter()
.filter_map(|(&call, &(scope, y))| {
let Some(SharedBody::Occurrence {
body:
OccurrenceBody::Exchange {
message_kind: MessageKind::Call,
receiver: Some(receiver),
..
},
..
}) = p.elements.get(&call).and_then(|e| e.shared())
else {
return None;
};
Some((call, *receiver, scope, y, *lanes.get(receiver)?))
})
.collect();
// Rows are laid out in semantic order. UUID order must not choose which
// rectangle obscures another (especially after source reidentification).
calls.sort_by(|a, b| a.3.total_cmp(&b.3).then_with(|| a.0.cmp(&b.0)));
for &(call, receiver, scope, _, x) in &calls {
let first = self.activations.len();
let first_cue = self.open_calls.len();
self.activation_sequence(p, scope, call, receiver, x, None);
let column = self.activations[first..]
.iter()
.flat_map(|segment| {
self.activations[..first]
.iter()
.filter(move |other| segment.overlaps(other))
})
.map(|other| other.column + 1)
.max()
.unwrap_or(0);
for segment in &mut self.activations[first..] {
segment.column = column;
segment.rect.x += column as f32 * OFFSET;
}
for cue in &mut self.open_calls[first_cue..] {
cue.x += column as f32 * OFFSET;
}
}
// Shallow executions paint first; columns cannot collide within a scope.
self.activations.sort_by(|a, b| {
a.column
.cmp(&b.column)
.then_with(|| a.rect.y.total_cmp(&b.rect.y))
.then_with(|| a.call.cmp(&b.call))
});
self.anchor_activation_messages(p, lanes, &correlations);
}
fn activation_segment(
&mut self,
call: Id,
receiver: Id,
scope: Id,
x: f32,
span: std::ops::Range<f32>,
finish: Finish,
) {
// Cues describe unanswered logical paths, including wholly invisible
// ones. Never derive them only from the surviving paint rectangles.
if matches!(
finish,
Finish::Open | Finish::Continuation | Finish::Terminated(_)
) {
self.open_calls
.push(Point::new(x + WIDTH / 2. + 4., span.end - 7.));
}
for alive in self
.lifecycle
.alive_spans(receiver, scope, span.start, span.end)
{
self.activations.push(Segment {
call,
receiver,
scope,
rect: Rectangle::new(
Point::new(x - WIDTH / 2., alive.start),
Size::new(WIDTH, alive.end - alive.start),
),
column: 0,
finish: if alive.end == span.end {
finish
} else {
Finish::Clipped
},
});
}
}
/// Follow one exact correlation. A segment belongs to one ordered scope;
/// vertically stacked operands must not become a single chronological span.
fn activation_sequence(
&mut self,
p: &Project,
owner: Id,
call: Id,
receiver: Id,
x: f32,
mut start: Option<f32>,
) -> Outcome {
let entered_open = start.is_some();
let mut split = false;
let mut may_be_terminated = false;
for id in p.elements[&owner].steps().into_iter().flatten() {
let Some(&(_, y)) = self.rows.get(id) else {
continue;
};
if *id == call {
// A reflective call is received at the lower end of its loop.
start = Some(
self.lines
.iter()
.find(|line| line.id == call)
.and_then(|line| line.points.last())
.map_or(y, |point| point.y),
);
}
let Some(top) = start else {
continue;
};
match p.elements[id].shared() {
Some(SharedBody::Occurrence {
body:
OccurrenceBody::Exchange {
message_kind: MessageKind::Delete,
receiver: Some(deleted),
..
},
..
}) if *deleted == receiver => {
self.activation_segment(
call,
receiver,
owner,
x,
top..y,
Finish::Terminated(*id),
);
start = None;
may_be_terminated = true;
}
Some(SharedBody::Occurrence {
body:
OccurrenceBody::Exchange {
message_kind: MessageKind::Reply,
reply_to: Some(correlation),
..
},
..
}) if *correlation == call => {
// Execution ends when its reply is sent, including a self reply.
self.activation_segment(call, receiver, owner, x, top..y, Finish::Reply(*id));
start = None;
}
Some(SharedBody::Occurrence {
body: OccurrenceBody::Fragment { operator, branches },
..
}) => {
split = true;
self.activation_segment(call, receiver, owner, x, top..y, Finish::Boundary);
let bottom = self
.boxes
.iter()
.find(|b| b.id == *id && !b.title.is_empty())
.map(|b| b.rect.y + b.rect.height)
.unwrap_or(y);
let outcomes: Vec<_> = branches
.iter()
.filter_map(|branch| {
let &(top, _) = self.sequence_bounds.get(branch)?;
Some(self.activation_sequence(p, *branch, call, receiver, x, Some(top)))
})
.collect();
let open = match operator {
FragmentOperator::Alternative => {
outcomes.is_empty() || outcomes.iter().any(|outcome| outcome.open)
}
FragmentOperator::Parallel => outcomes.iter().all(|outcome| outcome.open),
FragmentOperator::Optional | FragmentOperator::Loop => true,
};
if outcomes.iter().any(|outcome| outcome.may_be_terminated) {
// Sibling bars retain their own entry paths, but a
// conditional death cannot become a definite parent
// execution again after a later Create.
self.open_calls
.push(Point::new(x + WIDTH / 2. + 4., bottom - 7.));
may_be_terminated = true;
start = None;
} else {
start = open.then_some(bottom);
}
}
_ => {}
}
}
if let Some(top) = start {
let bottom = if entered_open || split {
self.sequence_bounds
.get(&owner)
.map_or(top + 26., |(_, bottom)| (*bottom).max(top + 26.))
} else {
top + 26.
};
let finish = if entered_open || split {
Finish::Continuation
} else {
Finish::Open
};
self.activation_segment(call, receiver, owner, x, top..bottom, finish);
}
Outcome {
open: start.is_some(),
may_be_terminated,
}
}
fn anchor_activation_messages(
&mut self,
p: &Project,
lanes: &BTreeMap<Id, f32>,
correlations: &BTreeMap<Id, Correlation>,
) {
let segments = &self.activations;
for line in &mut self.lines {
let Some(SharedBody::Occurrence {
body:
OccurrenceBody::Exchange {
sender: Some(sender),
receiver: Some(receiver),
message_kind,
reply_to,
..
},
..
}) = p.elements.get(&line.id).and_then(|e| e.shared())
else {
continue;
};
if !matches!(
message_kind,
MessageKind::Call | MessageKind::Reply | MessageKind::Asynchronous
) {
continue;
}
let (Some(&from), Some(&to), Some(&(scope, _))) = (
lanes.get(sender),
lanes.get(receiver),
self.rows.get(&line.id),
) else {
continue;
};
let send_y = line.points[0].y;
let receive_y = line.points.last().unwrap().y;
let reflective = sender == receiver;
let source = if *message_kind == MessageKind::Reply {
reply_to.and_then(|call| {
segments.iter().find(|a| {
a.call == call
&& a.scope == scope
&& a.finish == Finish::Reply(line.id)
&& a.bottom() == send_y
})
})
} else {
active_segment(
p,
segments,
correlations,
*sender,
scope,
send_y,
Some(line.id),
)
};
let target = if *message_kind == MessageKind::Call {
segments
.iter()
.find(|a| a.call == line.id && a.scope == scope && a.rect.y == receive_y)
} else {
active_segment(
p,
segments,
correlations,
*receiver,
scope,
receive_y,
*reply_to,
)
};
let start = source.map_or(from, |a| a.edge(reflective || to > from));
let end = target.map_or(to, |a| a.edge(reflective || from > to));
if reflective {
let outside = start.max(end) + 60.;
line.points = vec![
Point::new(start, send_y),
Point::new(outside, send_y),
Point::new(outside, receive_y),
Point::new(end, receive_y),
];
} else {
line.points = vec![Point::new(start, send_y), Point::new(end, receive_y)];
}
line.label_at.x = start.min(end) + 12.;
}
}
}
struct Outcome {
open: bool,
may_be_terminated: bool,
}
fn logical_correlations(
p: &Project,
rows: &BTreeMap<Id, (Id, f32)>,
lines: &[Line],
) -> BTreeMap<Id, Correlation> {
let mut result = BTreeMap::new();
let points: BTreeMap<_, _> = lines.iter().map(|line| (line.id, &line.points)).collect();
for (&id, &(_, y)) in rows {
if matches!(
p.elements.get(&id).and_then(|e| e.shared()),
Some(SharedBody::Occurrence {
body: OccurrenceBody::Exchange {
message_kind: MessageKind::Call,
..
},
..
})
) {
result.insert(
id,
Correlation {
start: points.get(&id).and_then(|p| p.last()).map_or(y, |p| p.y),
replies: vec![],
},
);
}
}
for (&id, &(scope, y)) in rows {
if let Some(SharedBody::Occurrence {
body:
OccurrenceBody::Exchange {
message_kind: MessageKind::Reply,
reply_to: Some(call),
..
},
..
}) = p.elements.get(&id).and_then(|e| e.shared())
&& let Some(correlation) = result.get_mut(call)
{
let sent = points.get(&id).and_then(|p| p.first()).map_or(y, |p| p.y);
correlation.replies.push((scope, sent));
}
}
result
}
/// A caller has no implicit execution just because it sent a message. Only an
/// actually drawn, known-duration execution can supply its anchor. Crossed
/// intervals provide no unambiguous innermost execution, so fall back to the lane.
fn active_segment<'a>(
p: &Project,
segments: &'a [Segment],
correlations: &BTreeMap<Id, Correlation>,
receiver: Id,
scope: Id,
y: f32,
exclude: Option<Id>,
) -> Option<&'a Segment> {
let candidates: Vec<_> = segments
.iter()
.filter(|a| {
a.receiver == receiver
&& a.scope == scope
&& Some(a.call) != exclude
&& a.rect.y <= y
&& y < a.bottom()
&& a.finish != Finish::Open
})
.collect();
let inner = candidates.iter().max_by_key(|a| a.column).copied()?;
candidates
.iter()
.all(|a| {
a.call == inner.call
|| correlations[&a.call].contains(p, scope, &correlations[&inner.call])
})
.then_some(inner)
}
struct Correlation {
start: f32,
replies: Vec<(Id, f32)>,
}
impl Correlation {
fn contains(&self, p: &Project, scope: Id, inner: &Self) -> bool {
// Segment clipping at a fragment can make crossed calls appear nested.
// Compare actual correlated replies on compatible scope paths instead.
let relevant = |&(reply_scope, _): &(Id, f32)| {
ancestor(p, scope, reply_scope) || ancestor(p, reply_scope, scope)
};
let mut compared = false;
for &(inner_scope, inner_end) in inner.replies.iter().filter(|reply| relevant(reply)) {
let mut matched = false;
for &(outer_scope, outer_end) in self.replies.iter().filter(|reply| relevant(reply)) {
if ancestor(p, inner_scope, outer_scope) || ancestor(p, outer_scope, inner_scope) {
// Alternative operands are not chronological. Compare only
// returns that can belong to the same enclosing branch path.
if outer_end < inner_end {
return false;
}
matched = true;
}
}
if !matched {
return false;
}
compared = true;
}
self.start <= inner.start && compared
}
}
fn ancestor(p: &Project, expected: Id, mut scope: Id) -> bool {
for _ in 0..=p.elements.len() {
if scope == expected {
return true;
}
let Some(parent) = p.elements.get(&scope).and_then(|e| e.parent) else {
return false;
};
scope = parent;
}
false
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,842 @@
use super::*;
fn id(n: u128) -> Id {
Id::from_u128(0x21ca1100000040008000000000000000 + n)
}
struct Fixture {
project: Project,
owner: Id,
caller: Id,
callee: Id,
call: Id,
inner: Id,
reply: Id,
inner_reply: Id,
}
fn seed(p: &mut Project, layer: Layer, n: u128, kind: Kind, parent: Option<Id>) -> Id {
let mut e = p
.make_element(kind, layer, &format!("Element {n}"), parent)
.unwrap();
e.id = id(n);
e.alias = format!("element_{n}");
p.elements.insert(e.id, e);
id(n)
}
fn message(
p: &mut Project,
layer: Layer,
n: u128,
parent: Id,
endpoints: (Id, Id),
reply: Option<Id>,
) -> Id {
let e = seed(p, layer, n, Kind::Occurrence, Some(parent));
*p.elements.get_mut(&e).unwrap().shared_mut().unwrap() = SharedBody::Occurrence {
body: OccurrenceBody::Exchange {
operation: None,
exchange: None,
sender: Some(endpoints.0),
receiver: Some(endpoints.1),
message_kind: if reply.is_some() {
MessageKind::Reply
} else {
MessageKind::Call
},
reply_to: reply,
values: vec![],
},
precondition: String::new(),
postcondition: String::new(),
};
e
}
fn fixture(layer: Layer, reverse_uuid_order: bool) -> Fixture {
let mut p = Project::empty();
let work = if layer == Layer::Oa {
Kind::Activity
} else {
Kind::Function
};
let a = seed(&mut p, layer, 1, work, None);
let b = seed(&mut p, layer, 2, work, None);
let owner = seed(&mut p, layer, 3, Kind::Scenario, None);
if let SharedBody::Scenario { viewpoint, .. } =
p.elements.get_mut(&owner).unwrap().shared_mut().unwrap()
{
*viewpoint = if layer == Layer::Oa {
ScenarioViewpoint::OperationalActivity
} else {
ScenarioViewpoint::Functional
};
}
let caller = seed(&mut p, layer, 4, Kind::Lifeline, Some(owner));
let callee = seed(&mut p, layer, 5, Kind::Lifeline, Some(owner));
for (line, represented) in [(caller, a), (callee, b)] {
*p.elements.get_mut(&line).unwrap().shared_mut().unwrap() = SharedBody::Lifeline {
represented: Some(represented),
};
}
let (first, second) = if reverse_uuid_order {
(40, 30)
} else {
(30, 40)
};
let call = message(&mut p, layer, first, owner, (caller, callee), None);
let inner = message(&mut p, layer, second, owner, (caller, callee), None);
let reply = message(&mut p, layer, 50, owner, (callee, caller), Some(call));
let inner_reply = message(&mut p, layer, 60, owner, (callee, caller), Some(inner));
*p.elements.get_mut(&owner).unwrap().steps_mut().unwrap() =
vec![call, inner, inner_reply, reply];
Fixture {
project: p,
owner,
caller,
callee,
call,
inner,
reply,
inner_reply,
}
}
fn scene(f: &Fixture) -> Scene {
let errors: Vec<_> = validation::validate(&f.project)
.into_iter()
.filter(|i| i.severity == validation::Severity::Error)
.collect();
assert!(errors.is_empty(), "Actual core fixture: {errors:#?}");
let source = language::format(&f.project);
let reparsed = language::parse(&source).unwrap();
assert_eq!(language::format(&reparsed), source);
let result = Scene::sequence(&reparsed, f.owner, f.owner);
assert_eq!(
language::format(&reparsed),
source,
"Projection must not write geometry into the model"
);
result
}
fn bar(scene: &Scene, call: Id) -> &Segment {
scene.activations.iter().find(|a| a.call == call).unwrap()
}
fn long_labels(f: &mut Fixture) {
for occurrence in [f.call, f.inner, f.inner_reply, f.reply] {
f.project.elements.get_mut(&occurrence).unwrap().name =
"Pressure verification with a descriptive operation label".into();
}
}
#[test]
fn activation_shoulders_under_label_backplates_select_their_call() {
for layer in [Layer::Oa, Layer::Sa] {
let mut f = fixture(layer, true);
long_labels(&mut f);
let scene = scene(&f);
let outer = bar(&scene, f.call);
for occurrence in [f.inner, f.inner_reply, f.reply] {
let message = line(&scene, occurrence);
let shoulder = Point::new(outer.rect.x + 1., message.label_at.y + 11.);
assert!(outer.rect.contains(shoulder));
assert!(Rectangle::new(message.label_at, Size::new(225., 22.)).contains(shoulder));
assert!(message.label.chars().count() > 32);
assert!(message.points.windows(2).all(|p| {
crate::diagram::distance_to_segment(shoulder, p[0], p[1]) > 8. / scene.scale
}));
assert_eq!(scene.hit(shoulder), Some(f.call));
assert_eq!(
scene.hit(message.label_at + iced::Vector::new(4., 4.)),
Some(occurrence),
"Label text outside activations remains selectable"
);
}
for occurrence in [f.call, f.inner, f.inner_reply, f.reply] {
let message = line(&scene, occurrence);
for endpoint in [message.points[0], *message.points.last().unwrap()] {
assert_eq!(scene.hit(endpoint), Some(occurrence));
}
}
}
}
#[tokio::test]
async fn long_message_backplates_do_not_erase_selected_activation_border_pixels() {
use iced::advanced::renderer::Headless;
use iced_runtime::user_interface::{Cache, UserInterface};
let mut mounted = crate::behavior::canvas_workspace::tests::Mounted::unshown().await;
let mut f = fixture(Layer::Oa, true);
long_labels(&mut f);
let mut scene = scene(&f);
scene.selected = f.call;
let border_x = bar(&scene, f.call).rect.x;
let label_spans: Vec<_> = [f.inner, f.inner_reply, f.reply]
.into_iter()
.map(|id| {
let message = line(&scene, id);
assert!(
Rectangle::new(message.label_at, Size::new(225., 22.))
.contains(Point::new(border_x, message.label_at.y + 11.))
);
(message.label_at.y + 3.) as usize..(message.label_at.y + 20.) as usize
})
.collect();
let dimensions = Size::new(800., 700.);
let content: Element<'_, Message> = canvas::Canvas::new(scene)
.width(dimensions.width)
.height(dimensions.height)
.into();
let mut ui = UserInterface::build(content, dimensions, Cache::new(), &mut mounted.renderer);
ui.draw(
&mut mounted.renderer,
&style::theme(),
&iced::advanced::renderer::Style {
text_color: style::INK,
},
mouse::Cursor::Unavailable,
);
let pixels = Headless::screenshot(&mut mounted.renderer, Size::new(800, 700), 1., Color::WHITE);
let expected = [style::TEAL.r, style::TEAL.g, style::TEAL.b].map(|v| v * 255.);
for span in label_spans {
for y in span {
assert!(
((border_x as usize - 2)..=(border_x as usize + 2)).any(|x| {
let offset = (y * 800 + x) * 4;
pixels[offset..offset + 3]
.iter()
.zip(expected)
.all(|(actual, target)| (*actual as f32 - target).abs() < 30.)
}),
"Selected activation border was erased at y={y}"
);
}
}
}
fn line(scene: &Scene, occurrence: Id) -> &super::super::Line {
scene.lines.iter().find(|a| a.id == occurrence).unwrap()
}
fn endpoints(p: &mut Project, occurrence: Id, from: Id, to: Id) {
if let SharedBody::Occurrence {
body: OccurrenceBody::Exchange {
sender, receiver, ..
},
..
} = p
.elements
.get_mut(&occurrence)
.unwrap()
.shared_mut()
.unwrap()
{
*sender = Some(from);
*receiver = Some(to);
}
}
#[test]
fn nested_calls_expose_both_bars_and_end_at_their_exact_replies_in_both_directions() {
for layer in [Layer::Oa, Layer::Sa] {
for reverse in [false, true] {
let mut f = fixture(layer, true);
if reverse {
for occurrence in [f.call, f.inner] {
endpoints(&mut f.project, occurrence, f.callee, f.caller);
}
for occurrence in [f.reply, f.inner_reply] {
endpoints(&mut f.project, occurrence, f.caller, f.callee);
}
}
let scene = scene(&f);
let outer = bar(&scene, f.call);
let inner = bar(&scene, f.inner);
assert!(outer.rect.y < inner.rect.y && outer.bottom() > inner.bottom());
assert_eq!(outer.column, 0);
assert_eq!(inner.column, 1);
assert!(outer.rect.x < inner.rect.x && outer.edge(true) < inner.edge(true));
for (call, reply) in [(f.call, f.reply), (f.inner, f.inner_reply)] {
let execution = bar(&scene, call);
let received = *line(&scene, call).points.last().unwrap();
let returned = line(&scene, reply).points[0];
assert_eq!(execution.rect.y, received.y);
assert_eq!(execution.bottom(), returned.y);
assert_eq!(received.x, execution.edge(reverse));
assert_eq!(returned.x, execution.edge(reverse));
assert_eq!(execution.finish, Finish::Reply(reply));
}
assert!(scene.open_calls.is_empty());
}
}
}
#[test]
fn call_uuid_order_does_not_choose_activation_columns_or_message_anchors() {
let f = fixture(Layer::Oa, false);
let g = fixture(Layer::Oa, true);
let a = scene(&f);
let b = scene(&g);
for (x, y) in [
(f.call, g.call),
(f.inner, g.inner),
(f.reply, g.reply),
(f.inner_reply, g.inner_reply),
] {
assert_eq!(line(&a, x).points, line(&b, y).points);
}
for (x, y) in [(f.call, g.call), (f.inner, g.inner)] {
assert_eq!(bar(&a, x).rect, bar(&b, y).rect);
}
}
#[test]
fn sequential_calls_reuse_column_and_crossed_replies_keep_their_correlations() {
for layer in [Layer::Oa, Layer::Sa] {
let mut f = fixture(layer, true);
*f.project
.elements
.get_mut(&f.owner)
.unwrap()
.steps_mut()
.unwrap() = vec![f.call, f.reply, f.inner, f.inner_reply];
let sequential = scene(&f);
assert_eq!(
bar(&sequential, f.call).rect.x,
bar(&sequential, f.inner).rect.x
);
*f.project
.elements
.get_mut(&f.owner)
.unwrap()
.steps_mut()
.unwrap() = vec![f.call, f.inner, f.reply, f.inner_reply];
let crossed = scene(&f);
assert!(bar(&crossed, f.call).bottom() < bar(&crossed, f.inner).bottom());
assert!(bar(&crossed, f.call).rect.x < bar(&crossed, f.inner).rect.x);
assert_eq!(
line(&crossed, f.reply).points[0].x,
bar(&crossed, f.call).edge(false)
);
assert_eq!(
line(&crossed, f.inner_reply).points[0].x,
bar(&crossed, f.inner).edge(false)
);
}
}
#[test]
fn self_call_receives_at_lower_loop_end_and_self_reply_returns_to_the_parent() {
for layer in [Layer::Oa, Layer::Sa] {
let mut f = fixture(layer, true);
endpoints(&mut f.project, f.inner, f.callee, f.callee);
endpoints(&mut f.project, f.inner_reply, f.callee, f.callee);
let scene = scene(&f);
let parent = bar(&scene, f.call);
let child = bar(&scene, f.inner);
let call = line(&scene, f.inner);
let reply = line(&scene, f.inner_reply);
assert_eq!(call.points[0].x, parent.edge(true));
assert_eq!(call.points[3], Point::new(child.edge(true), child.rect.y));
assert_eq!(child.rect.y, scene.rows[&f.inner].1 + 24.);
assert_eq!(
reply.points[0],
Point::new(child.edge(true), child.bottom())
);
assert_eq!(reply.points[3].x, parent.edge(true));
assert!(call.points[1].x > child.edge(true));
assert!(reply.points[1].x > child.edge(true));
}
}
#[test]
fn an_inactive_self_call_has_no_fabricated_parent_and_three_nested_calls_expose_three_columns() {
let mut f = fixture(Layer::Oa, true);
endpoints(&mut f.project, f.inner, f.callee, f.callee);
endpoints(&mut f.project, f.inner_reply, f.callee, f.callee);
f.project.elements.remove(&f.call);
f.project.elements.remove(&f.reply);
*f.project
.elements
.get_mut(&f.owner)
.unwrap()
.steps_mut()
.unwrap() = vec![f.inner, f.inner_reply];
let isolated = scene(&f);
assert_eq!(isolated.activations.len(), 1);
assert_eq!(bar(&isolated, f.inner).column, 0);
assert_eq!(
line(&isolated, f.inner).points[0].x,
isolated.lanes[&f.callee]
);
assert_eq!(
line(&isolated, f.inner_reply).points[3].x,
isolated.lanes[&f.callee]
);
assert_eq!(
bar(&isolated, f.inner).rect.y,
line(&isolated, f.inner).points[3].y
);
let mut f = fixture(Layer::Oa, true);
let third = message(
&mut f.project,
Layer::Oa,
20,
f.owner,
(f.caller, f.callee),
None,
);
let third_reply = message(
&mut f.project,
Layer::Oa,
21,
f.owner,
(f.callee, f.caller),
Some(third),
);
*f.project
.elements
.get_mut(&f.owner)
.unwrap()
.steps_mut()
.unwrap() = vec![f.call, f.inner, third, third_reply, f.inner_reply, f.reply];
let scene = scene(&f);
let calls: Vec<_> = scene.activations.iter().map(|a| a.call).collect();
assert_eq!(
calls,
vec![f.call, f.inner, third],
"Paint order is shallow to deep"
);
for (column, call) in [f.call, f.inner, third].into_iter().enumerate() {
let execution = bar(&scene, call);
assert_eq!(execution.column, column);
let y = (bar(&scene, third).rect.y + bar(&scene, third).bottom()) / 2.;
assert_eq!(
scene.hit(Point::new(execution.rect.x + OFFSET / 2., y)),
Some(call)
);
}
}
#[test]
fn reentrant_call_uses_the_other_receiver_without_inventing_a_caller_execution() {
let mut f = fixture(Layer::Oa, true);
endpoints(&mut f.project, f.inner, f.callee, f.caller);
endpoints(&mut f.project, f.inner_reply, f.caller, f.callee);
let scene = scene(&f);
assert_eq!(bar(&scene, f.call).column, 0);
assert_eq!(bar(&scene, f.inner).column, 0);
assert_eq!(bar(&scene, f.inner).receiver, f.caller);
assert_eq!(line(&scene, f.call).points[0].x, scene.lanes[&f.caller]);
assert_eq!(
line(&scene, f.inner).points[0].x,
bar(&scene, f.call).edge(false)
);
}
#[test]
fn actual_canvas_pointer_selects_each_exposed_shoulder_under_zoom_and_pan() {
let f = fixture(Layer::Oa, true);
let mut scene = scene(&f);
let context = scenario::direct_tests::context(f.owner);
scene.interaction = Some((context, scenario::CanvasTool::Select, true));
let y = (bar(&scene, f.inner).rect.y + bar(&scene, f.inner).bottom()) / 2.;
let outer = Point::new(bar(&scene, f.call).rect.x + OFFSET / 2., y);
let inner = Point::new(bar(&scene, f.inner).edge(true) - OFFSET / 2., y);
for scale in [1., 0.25, 0.05] {
scene.scale = scale;
scene.camera_pan = iced::Vector::new(50., 25.);
let bounds = Rectangle::new(Point::new(30., 40.), Size::new(1000., 700.));
for (point, call) in [(outer, f.call), (inner, f.inner)] {
assert_eq!(scene.hit(point), Some(call));
let cursor = bounds.position()
+ scene.camera_pan
+ iced::Vector::new(point.x * scale, point.y * scale);
let mut state = SequenceInput::default();
let _ = canvas::Program::update(
&scene,
&mut state,
&canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
bounds,
mouse::Cursor::Available(cursor),
);
let action = canvas::Program::update(
&scene,
&mut state,
&canvas::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)),
bounds,
mouse::Cursor::Available(cursor),
)
.unwrap();
assert!(matches!(action.into_inner().0,
Some(Message::ScenarioCanvas(scenario::CanvasEvent::Select(_, selected))) if selected == call));
}
}
}
#[test]
fn missing_reply_is_a_short_explicit_open_span_and_an_uncorrelated_reply_closes_nothing() {
let mut f = fixture(Layer::Oa, true);
if let SharedBody::Occurrence {
body: OccurrenceBody::Exchange { reply_to, .. },
..
} = f
.project
.elements
.get_mut(&f.reply)
.unwrap()
.shared_mut()
.unwrap()
{
*reply_to = None;
}
let scene = scene(&f);
assert_eq!(bar(&scene, f.call).finish, Finish::Open);
assert_eq!(bar(&scene, f.call).rect.height, 26.);
assert_eq!(scene.open_calls.len(), 1);
assert_eq!(
bar(&scene, f.inner).column,
0,
"Unknown duration does not prove later nesting"
);
assert_eq!(bar(&scene, f.inner).finish, Finish::Reply(f.inner_reply));
}
#[test]
fn alternative_and_parallel_operand_local_calls_do_not_nest_from_screen_order() {
for layer in [Layer::Oa, Layer::Sa] {
for operator in [FragmentOperator::Alternative, FragmentOperator::Parallel] {
let mut f = fixture(layer, true);
let fragment = seed(&mut f.project, layer, 70, Kind::Occurrence, Some(f.owner));
let branches = [id(71), id(72)];
*f.project
.elements
.get_mut(&fragment)
.unwrap()
.shared_mut()
.unwrap() = SharedBody::Occurrence {
body: OccurrenceBody::Fragment {
operator,
branches: branches.to_vec(),
},
precondition: String::new(),
postcondition: String::new(),
};
for (index, occurrences) in [vec![f.call, f.reply], vec![f.inner, f.inner_reply]]
.into_iter()
.enumerate()
{
let branch = seed(
&mut f.project,
layer,
71 + index as u128,
Kind::Occurrence,
Some(fragment),
);
for occurrence in &occurrences {
f.project.elements.get_mut(occurrence).unwrap().parent = Some(branch);
}
*f.project
.elements
.get_mut(&branch)
.unwrap()
.shared_mut()
.unwrap() = SharedBody::Occurrence {
body: OccurrenceBody::Branch {
guard: format!("condition_{index}"),
occurrences,
},
precondition: String::new(),
postcondition: String::new(),
};
}
*f.project
.elements
.get_mut(&f.owner)
.unwrap()
.steps_mut()
.unwrap() = vec![fragment];
let scene = scene(&f);
assert_eq!(bar(&scene, f.call).column, 0);
assert_eq!(bar(&scene, f.inner).column, 0);
assert_ne!(bar(&scene, f.call).scope, bar(&scene, f.inner).scope);
assert!(scene.open_calls.is_empty());
}
}
}
#[test]
fn inherited_execution_anchors_a_self_call_inside_an_operand_before_returning_after_the_fragment() {
let mut f = fixture(Layer::Oa, true);
endpoints(&mut f.project, f.inner, f.callee, f.callee);
endpoints(&mut f.project, f.inner_reply, f.callee, f.callee);
let fragment = seed(
&mut f.project,
Layer::Oa,
70,
Kind::Occurrence,
Some(f.owner),
);
*f.project
.elements
.get_mut(&fragment)
.unwrap()
.shared_mut()
.unwrap() = SharedBody::Occurrence {
body: OccurrenceBody::Fragment {
operator: FragmentOperator::Alternative,
branches: vec![id(71), id(72)],
},
precondition: String::new(),
postcondition: String::new(),
};
for (index, occurrences) in [vec![f.inner, f.inner_reply], vec![]]
.into_iter()
.enumerate()
{
let branch = seed(
&mut f.project,
Layer::Oa,
71 + index as u128,
Kind::Occurrence,
Some(fragment),
);
for occurrence in &occurrences {
f.project.elements.get_mut(occurrence).unwrap().parent = Some(branch);
}
*f.project
.elements
.get_mut(&branch)
.unwrap()
.shared_mut()
.unwrap() = SharedBody::Occurrence {
body: OccurrenceBody::Branch {
guard: format!("condition_{index}"),
occurrences,
},
precondition: String::new(),
postcondition: String::new(),
};
}
*f.project
.elements
.get_mut(&f.owner)
.unwrap()
.steps_mut()
.unwrap() = vec![f.call, fragment, f.reply];
let scene = scene(&f);
let parent = scene
.activations
.iter()
.find(|a| a.call == f.call && a.scope == id(71))
.unwrap();
assert_eq!(parent.finish, Finish::Continuation);
assert_eq!(bar(&scene, f.inner).column, parent.column + 1);
assert_eq!(line(&scene, f.inner).points[0].x, parent.edge(true));
assert_eq!(line(&scene, f.inner_reply).points[3].x, parent.edge(true));
assert!(
scene
.activations
.iter()
.any(|a| a.call == f.call && a.finish == Finish::Reply(f.reply))
);
}
#[test]
fn a_fragment_cannot_make_crossed_calls_an_unambiguous_caller_execution() {
for placement in ["plain", "before_fragment", "in_operand"] {
let mut f = fixture(Layer::Oa, true);
let outgoing = message(
&mut f.project,
Layer::Oa,
80,
f.owner,
(f.callee, f.caller),
None,
);
if let SharedBody::Occurrence {
body: OccurrenceBody::Exchange { message_kind, .. },
..
} = f
.project
.elements
.get_mut(&outgoing)
.unwrap()
.shared_mut()
.unwrap()
{
*message_kind = MessageKind::Asynchronous;
}
let mut ordered = vec![f.call, f.inner];
if placement != "in_operand" {
ordered.push(outgoing);
}
if placement != "plain" {
let fragment = seed(
&mut f.project,
Layer::Oa,
70,
Kind::Occurrence,
Some(f.owner),
);
*f.project
.elements
.get_mut(&fragment)
.unwrap()
.shared_mut()
.unwrap() = SharedBody::Occurrence {
body: OccurrenceBody::Fragment {
operator: FragmentOperator::Alternative,
branches: vec![id(71), id(72)],
},
precondition: String::new(),
postcondition: String::new(),
};
for index in 0..2u128 {
let branch = seed(
&mut f.project,
Layer::Oa,
71 + index,
Kind::Occurrence,
Some(fragment),
);
let occurrences = if index == 0 && placement == "in_operand" {
f.project.elements.get_mut(&outgoing).unwrap().parent = Some(branch);
vec![outgoing]
} else {
vec![]
};
*f.project
.elements
.get_mut(&branch)
.unwrap()
.shared_mut()
.unwrap() = SharedBody::Occurrence {
body: OccurrenceBody::Branch {
guard: format!("condition_{index}"),
occurrences,
},
precondition: String::new(),
postcondition: String::new(),
};
}
ordered.push(fragment);
}
ordered.extend([f.reply, f.inner_reply]);
*f.project
.elements
.get_mut(&f.owner)
.unwrap()
.steps_mut()
.unwrap() = ordered;
let scene = scene(&f);
assert_eq!(
line(&scene, outgoing).points[0].x,
scene.lanes[&f.callee],
"{placement}"
);
}
}
#[test]
fn nested_correlations_returning_in_alternative_operands_remain_nested_before_the_fragment() {
let mut f = fixture(Layer::Oa, true);
let outgoing = message(
&mut f.project,
Layer::Oa,
80,
f.owner,
(f.callee, f.caller),
None,
);
if let SharedBody::Occurrence {
body: OccurrenceBody::Exchange { message_kind, .. },
..
} = f
.project
.elements
.get_mut(&outgoing)
.unwrap()
.shared_mut()
.unwrap()
{
*message_kind = MessageKind::Asynchronous;
}
let fragment = seed(
&mut f.project,
Layer::Oa,
70,
Kind::Occurrence,
Some(f.owner),
);
*f.project
.elements
.get_mut(&fragment)
.unwrap()
.shared_mut()
.unwrap() = SharedBody::Occurrence {
body: OccurrenceBody::Fragment {
operator: FragmentOperator::Alternative,
branches: vec![id(71), id(72)],
},
precondition: String::new(),
postcondition: String::new(),
};
for index in 0..2u128 {
let branch = seed(
&mut f.project,
Layer::Oa,
71 + index,
Kind::Occurrence,
Some(fragment),
);
let occurrences = if index == 0 {
for reply in [f.inner_reply, f.reply] {
f.project.elements.get_mut(&reply).unwrap().parent = Some(branch);
}
vec![f.inner_reply, f.reply]
} else {
vec![
message(
&mut f.project,
Layer::Oa,
90,
branch,
(f.callee, f.caller),
Some(f.inner),
),
message(
&mut f.project,
Layer::Oa,
91,
branch,
(f.callee, f.caller),
Some(f.call),
),
]
};
*f.project
.elements
.get_mut(&branch)
.unwrap()
.shared_mut()
.unwrap() = SharedBody::Occurrence {
body: OccurrenceBody::Branch {
guard: format!("condition_{index}"),
occurrences,
},
precondition: String::new(),
postcondition: String::new(),
};
}
*f.project
.elements
.get_mut(&f.owner)
.unwrap()
.steps_mut()
.unwrap() = vec![f.call, f.inner, outgoing, fragment];
let scene = scene(&f);
let inner = scene
.activations
.iter()
.find(|a| a.call == f.inner && a.scope == f.owner)
.unwrap();
assert_eq!(line(&scene, outgoing).points[0].x, inner.edge(false));
}
@@ -0,0 +1,386 @@
//! Actual Scene primitives, with independently chosen points and semantic IDs.
//! These preserve the existing label hit band; they do not establish ink bounds
//! or authoring eligibility, and do not dispatch a spacing gesture.
use super::*;
use crate::document::Document;
struct Fixture {
project: Project,
owner: Id,
sender: Id,
receiver: Id,
call: Id,
reply: Id,
exchange: Id,
}
fn fixture() -> Fixture {
let project = language::parse(r#"arcadia 7;
project hits "Hit roles" {
oa_activity source "Source" {}
oa_activity target "Target" {}
interaction exchange "Contract" { source @source; target @target; }
oa_scenario conversation "Conversation" { viewpoint OperationalActivity; steps [@call, @reply]; }
oa_lifeline left "Left" { parent @conversation; represented @source; }
oa_lifeline right "Right" { parent @conversation; represented @target; }
oa_occurrence call "Call" { parent @conversation; body { Exchange { exchange @exchange; sender @left; receiver @right; message_kind Call; }; }; }
oa_occurrence reply "Reply" { parent @conversation; body { Exchange { exchange @exchange; sender @right; receiver @left; message_kind Reply; reply_to @call; }; }; }
}
"#).unwrap();
let id = |alias: &str| {
project
.identities()
.into_iter()
.find(|(_, name)| *name == alias)
.unwrap()
.0
};
Fixture {
owner: id("conversation"),
sender: id("left"),
receiver: id("right"),
call: id("call"),
reply: id("reply"),
exchange: id("exchange"),
project,
}
}
fn scene(f: &Fixture) -> Scene {
let document =
Document::from_project(f.project.clone(), source_project::ProjectLimits::default())
.unwrap();
let before = document.snapshot();
let result = Scene::sequence_geometry(
document.project(),
f.owner,
f.call,
Some(&BTreeMap::from([(f.sender, 160.), (f.receiver, 400.)])),
None,
);
assert_eq!(document.project(), &before.decoded.project);
assert_eq!(document.bundle(), &before.bundle);
assert_eq!(document.revision(), 0);
assert!(!document.can_undo() && !document.can_redo() && !document.dirty());
result
}
fn hit(scene: &Scene, point: Point, id: Id, role: SceneHitRole) {
assert_eq!(scene.hit_item(point), Some(SceneHit { id, role }));
assert_eq!(scene.hit(point), Some(id));
}
fn occurrence(p: &mut Project, parent: Id, body: OccurrenceBody) -> Id {
let mut element = p
.make_element(Kind::Occurrence, Layer::Oa, "Hit target", Some(parent))
.unwrap();
let id = element.id;
*element.shared_mut().unwrap() = SharedBody::Occurrence {
body,
precondition: String::new(),
postcondition: String::new(),
};
p.elements.insert(id, element);
p.elements
.get_mut(&parent)
.unwrap()
.steps_mut()
.unwrap()
.push(id);
id
}
fn exchange_mut(p: &mut Project, id: Id) -> &mut OccurrenceBody {
let SharedBody::Occurrence { body, .. } =
p.elements.get_mut(&id).unwrap().shared_mut().unwrap()
else {
panic!("expected occurrence")
};
body
}
#[test]
fn exact_headers_include_unassigned_lifelines_but_guides_are_not_headers() {
let mut f = fixture();
*f.project
.elements
.get_mut(&f.receiver)
.unwrap()
.shared_mut()
.unwrap() = SharedBody::Lifeline { represented: None };
let s = scene(&f);
assert!(!s.lanes.contains_key(&f.receiver));
assert_eq!(s.render_lanes[&f.receiver], 400.);
for (id, x) in [(f.sender, 160.), (f.receiver, 400.)] {
// The actual header is x-102..x+102, y15..85, including its padding.
for point in [Point::new(x - 101., 16.), Point::new(x + 101., 84.)] {
hit(&s, point, id, SceneHitRole::LifelineHeader);
}
hit(&s, Point::new(x, 95.), id, SceneHitRole::LifelineGuide);
}
}
#[test]
fn repeated_exchange_occurrences_keep_distinct_message_body_and_label_hits() {
let f = fixture();
let s = scene(&f);
assert_eq!(s.rows[&f.call].1, 148.);
assert_eq!(s.rows[&f.reply].1, 240.);
for (id, y) in [(f.call, 148.), (f.reply, 240.)] {
hit(&s, Point::new(280., y), id, SceneHitRole::MessageBody);
hit(
&s,
Point::new(180., y - 16.),
id,
SceneHitRole::MessageLabel,
);
assert_ne!(id, f.exchange);
}
}
#[test]
fn self_call_elbow_and_receive_endpoint_are_messages_not_execution_hits() {
let mut f = fixture();
for id in [f.call, f.reply] {
let OccurrenceBody::Exchange {
sender,
receiver,
exchange,
..
} = exchange_mut(&mut f.project, id)
else {
unreachable!()
};
*sender = Some(f.sender);
*receiver = Some(f.sender);
*exchange = None;
}
let s = scene(&f);
let call = s.lines.iter().find(|l| l.id == f.call).unwrap();
assert_eq!(call.points.len(), 4);
assert_eq!(call.points[0].y, 148.);
assert_eq!(call.points[3].y, 172.);
hit(
&s,
Point::new(call.points[1].x, 160.),
f.call,
SceneHitRole::MessageBody,
);
hit(&s, call.points[3], f.call, SceneHitRole::MessageBody);
hit(
&s,
Point::new(180., 132.),
f.call,
SceneHitRole::MessageLabel,
);
let bar = s.activations.iter().find(|a| a.call == f.call).unwrap();
hit(
&s,
Point::new(bar.rect.x + 2., 190.),
f.call,
SceneHitRole::Activation,
);
}
#[test]
fn activation_shoulder_beats_another_calls_label_and_broad_message_tolerance() {
let mut f = fixture();
let mut inner = f.project.elements[&f.call].clone();
inner.id = Id::new_v4();
inner.alias = "inner_call_hit".into();
inner.name = "Pressure verification with a descriptive operation label".into();
let inner_id = inner.id;
f.project.elements.insert(inner_id, inner);
let mut inner_reply = f.project.elements[&f.reply].clone();
inner_reply.id = Id::new_v4();
inner_reply.alias = "inner_reply_hit".into();
let reply_id = inner_reply.id;
f.project.elements.insert(reply_id, inner_reply);
let OccurrenceBody::Exchange { reply_to, .. } = exchange_mut(&mut f.project, reply_id) else {
unreachable!()
};
*reply_to = Some(inner_id);
*f.project
.elements
.get_mut(&f.owner)
.unwrap()
.steps_mut()
.unwrap() = vec![f.call, inner_id, reply_id, f.reply];
let mut s = scene(&f);
let inner = s.lines.iter().find(|l| l.id == inner_id).unwrap();
let outer = s.activations.iter().find(|a| a.call == f.call).unwrap();
let shoulder = Point::new(outer.rect.x + 1., 225.);
assert!(outer.rect.contains(shoulder));
assert!(inner.label_bounds().contains(shoulder));
let endpoint = *inner.points.last().unwrap();
for scale in [1., 0.05, 0.0003] {
s.scale = scale;
hit(&s, shoulder, f.call, SceneHitRole::Activation);
hit(&s, endpoint, inner_id, SceneHitRole::MessageBody);
hit(
&s,
Point::new(180., 225.),
inner_id,
SceneHitRole::MessageLabel,
);
}
}
#[test]
fn create_delete_markers_are_distinct_from_their_own_message_body_and_label() {
let mut f = fixture();
for (id, kind) in [
(f.call, MessageKind::Create),
(f.reply, MessageKind::Delete),
] {
let OccurrenceBody::Exchange {
sender,
receiver,
message_kind,
reply_to,
..
} = exchange_mut(&mut f.project, id)
else {
unreachable!()
};
*sender = Some(f.sender);
*receiver = Some(f.receiver);
*message_kind = kind;
*reply_to = None;
}
let s = scene(&f);
for (id, y, label_y) in [(f.call, 148., 118.), (f.reply, 240., 224.)] {
hit(&s, Point::new(400., y), id, SceneHitRole::LifecycleMarker);
hit(&s, Point::new(280., y), id, SceneHitRole::MessageBody);
hit(
&s,
Point::new(180., label_y),
id,
SceneHitRole::MessageLabel,
);
}
// Capsule boundary owns its contact with the incoming Create arrow.
hit(
&s,
Point::new(330., 148.),
f.call,
SceneHitRole::LifecycleMarker,
);
}
#[test]
fn tiny_scale_nearest_hit_retains_the_winning_marker_or_message_role() {
let mut f = fixture();
let OccurrenceBody::Exchange {
sender,
receiver,
message_kind,
reply_to,
..
} = exchange_mut(&mut f.project, f.reply)
else {
unreachable!()
};
*sender = Some(f.sender);
*receiver = Some(f.receiver);
*message_kind = MessageKind::Delete;
*reply_to = None;
let mut s = scene(&f);
s.scale = 0.0003;
// Both are outside exact strokes/labels. Comparing all broad candidates
// chooses the actual nearer shape, even when their UUID is identical.
hit(
&s,
Point::new(280., 246.),
f.reply,
SceneHitRole::MessageBody,
);
hit(
&s,
Point::new(400., 265.),
f.reply,
SceneHitRole::LifecycleMarker,
);
hit(
&s,
Point::new(280., 154.),
f.call,
SceneHitRole::MessageBody,
);
}
#[test]
fn action_frame_and_use_boxes_do_not_advertise_message_or_header_roles() {
for body in [
OccurrenceBody::Action {
action: None,
lifeline: None,
},
OccurrenceBody::Fragment {
operator: FragmentOperator::Alternative,
branches: vec![],
},
OccurrenceBody::ScenarioUse {
scenario: None,
bindings: vec![],
},
OccurrenceBody::Exchange {
exchange: None,
sender: None,
receiver: None,
message_kind: MessageKind::Asynchronous,
operation: None,
reply_to: None,
values: vec![],
},
] {
let mut f = fixture();
let id = occurrence(&mut f.project, f.owner, body);
let s = scene(&f);
let item = s.boxes.iter().find(|b| b.id == id).unwrap();
hit(
&s,
item.rect.position() + iced::Vector::new(15., 15.),
id,
SceneHitRole::Box,
);
}
}
#[test]
fn winning_primitive_role_does_not_come_from_its_shared_uuid() {
let f = fixture();
let mut s = scene(&f);
// A controlled overlapping primitive verifies reverse box order without
// inventing a core alias. Its ID intentionally equals a real header's ID.
s.boxes.push(BoxItem {
id: f.sender,
role: SceneHitRole::Box,
rect: Rectangle::new(Point::new(150., 20.), Size::new(20., 20.)),
title: "Overlay control".into(),
detail: String::new(),
marker: None,
});
hit(&s, Point::new(160., 30.), f.sender, SceneHitRole::Box);
hit(
&s,
Point::new(180., 30.),
f.sender,
SceneHitRole::LifelineHeader,
);
}
#[test]
fn invalid_points_never_produce_a_semantic_or_spacing_hit() {
let f = fixture();
let s = scene(&f);
for point in [
Point::new(f32::NAN, 148.),
Point::new(280., f32::NAN),
Point::new(f32::INFINITY, 148.),
Point::new(280., f32::NEG_INFINITY),
] {
assert_eq!(s.hit_item(point), None);
assert_eq!(s.hit(point), None);
}
}

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