Compare commits
141
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d99aacab8 | ||
|
|
b743a10d51 | ||
|
|
ff712b2419 | ||
|
|
b15345123a | ||
|
|
1711bff650 | ||
|
|
bed7f825fd | ||
|
|
3480b54201 | ||
|
|
2a3b9ba822 | ||
|
|
7e348d967d | ||
|
|
f6a6010bae | ||
|
|
12a7352dde | ||
|
|
b8685416f5 | ||
|
|
26979c1093 | ||
|
|
53d01a3d44 | ||
|
|
3aa80de89c | ||
|
|
79cc9d5551 | ||
|
|
3f9e6a18af | ||
|
|
7836a93edb | ||
|
|
f714a25bbc | ||
|
|
cb941367d7 | ||
|
|
96b756bece | ||
|
|
e624d5b9c4 | ||
|
|
d7c065991d | ||
|
|
136ed6e586 | ||
|
|
46124ce76c | ||
|
|
554cfbd9fe | ||
|
|
92ff99eb54 | ||
|
|
16904398db | ||
|
|
74542ae2c1 | ||
|
|
feab2020d8 | ||
|
|
bebf9f594c | ||
|
|
022d8cda2a | ||
|
|
357da24542 | ||
|
|
44095f78c2 | ||
|
|
0ecdadbb9c | ||
|
|
c28abcf13a | ||
|
|
0d8c144355 | ||
|
|
7f2947d651 | ||
|
|
3412d4f369 | ||
|
|
5e62d88c4f | ||
|
|
13f7e6b0d1 | ||
|
|
29525d7823 | ||
|
|
0f002b4254 | ||
|
|
3ac74825f8 | ||
|
|
64fea0a505 | ||
|
|
e1240295bd | ||
|
|
659b7a655a | ||
|
|
a17ca5d066 | ||
|
|
95e0e12716 | ||
|
|
dd39e1fdc1 | ||
|
|
f555193172 | ||
|
|
e44f07565c | ||
|
|
a357b22778 | ||
|
|
05b613ea19 | ||
|
|
afa74a3059 | ||
|
|
200aa90689 | ||
|
|
2c6ededc51 | ||
|
|
bbd3b0e843 | ||
|
|
8c3e7acd14 | ||
|
|
26ca4a4c81 | ||
|
|
aadc6396d3 | ||
|
|
e0685b8e52 | ||
|
|
d61f1f3877 | ||
|
|
419c78c071 | ||
|
|
83f53c7c6e | ||
|
|
40154983cc | ||
|
|
8112dea7a3 | ||
|
|
e2c027e54c | ||
|
|
b25065c442 | ||
|
|
956a584132 | ||
|
|
e57c390d49 | ||
|
|
24bb24839b | ||
|
|
f7d84faf30 | ||
|
|
260d773852 | ||
|
|
42511f0f8a | ||
|
|
c3c2d01453 | ||
|
|
4c387ccd64 | ||
|
|
cb46ef4054 | ||
|
|
2d9e9c1849 | ||
|
|
12b2237ee9 | ||
|
|
49a4cb17b1 | ||
|
|
28e21a0ae8 | ||
|
|
dc1506f2e8 | ||
|
|
5b174a9fa6 | ||
|
|
0d6f677329 | ||
|
|
60ea483430 | ||
|
|
0c38980c70 | ||
|
|
f1bd5149db | ||
|
|
3840ca2965 | ||
|
|
5ce798b713 | ||
|
|
1b3c401a19 | ||
|
|
76209cc186 | ||
|
|
5a7018117d | ||
|
|
85173a3c95 | ||
|
|
d9b3f0dc91 | ||
|
|
94b5eecb1c | ||
|
|
6b9cc77ece | ||
|
|
bc8bc4f5a1 | ||
|
|
f52d81c1b9 | ||
|
|
97c91cc06c | ||
|
|
0bd7886608 | ||
|
|
563772dc62 | ||
|
|
eefdedaf47 | ||
|
|
bc88acd932 | ||
|
|
17d56c06f7 | ||
|
|
bcb7057ab2 | ||
|
|
36eff7225a | ||
|
|
dd38c46a4b | ||
|
|
a31b40d0f8 | ||
|
|
88c17a448f | ||
|
|
abffa4b3f0 | ||
|
|
82fd0acf64 | ||
|
|
ff0175488a | ||
|
|
06ec962129 | ||
|
|
6969c825a8 | ||
|
|
638d06c4e3 | ||
|
|
41fa5d530c | ||
|
|
828c50c491 | ||
|
|
f8a6a5264a | ||
|
|
b4c8fd7102 | ||
|
|
e7491fb00a | ||
|
|
bef1ea33e4 | ||
|
|
3c9e6eeb64 | ||
|
|
80f2588214 | ||
|
|
8895dd2e0f | ||
|
|
8d55d2d2eb | ||
|
|
03d20ec296 | ||
|
|
c0e66f94e3 | ||
|
|
7f081ad457 | ||
|
|
c7ed894900 | ||
|
|
98427b9620 | ||
|
|
cab01c3d7f | ||
|
|
d8bdbd9e48 | ||
|
|
7d14bd0dd1 | ||
|
|
dc0f2e12f3 | ||
|
|
bc311bc076 | ||
|
|
a34558d3be | ||
|
|
2cc4d115d0 | ||
|
|
1e9f0c2a97 | ||
|
|
1516949567 | ||
|
|
c4e1580bb0 |
+52
-47
@@ -22,65 +22,70 @@ 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
|
||||
crates/oa-core/src/analysis_numeric/fixtures/** -text whitespace=cr-at-eol
|
||||
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/experiments/analysis-baseline-20260911/** -text whitespace=cr-at-eol
|
||||
harness/experiments/library-compatibility-20260911/** -text whitespace=cr-at-eol
|
||||
harness/experiments/refined-contracts-review-20260911/** -text whitespace=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
|
||||
harness/experiments/authoring-prerelease-20260911/** -text whitespace=cr-at-eol
|
||||
harness/experiments/cli-packaging-20260911/** -text whitespace=cr-at-eol
|
||||
harness/experiments/cli-prerelease-20260911/** -text whitespace=cr-at-eol
|
||||
harness/experiments/contextual-source-20260911/** -text whitespace=cr-at-eol
|
||||
harness/experiments/contextual-source-20260911/**/*.log whitespace=-blank-at-eof,cr-at-eol
|
||||
harness/fixtures/staged-source-authoring/** -text whitespace=cr-at-eol
|
||||
harness/staged-source-authoring-contract.rs -text whitespace=cr-at-eol
|
||||
|
||||
# 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/behavior-creation-native-20260911/** -text whitespace=cr-at-eol
|
||||
harness/experiments/constructor-defaults-20260911/** -text whitespace=cr-at-eol
|
||||
harness/experiments/constructor-defaults-20260911/results/*.arcadia whitespace=-blank-at-eol,cr-at-eol
|
||||
harness/release-scenario-native-9e8r6t-20260911.md -text whitespace=cr-at-eol
|
||||
harness/experiments/analysis-numeric-gate-20260911/** -text
|
||||
harness/experiments/chooser-correction-20260911/** -text whitespace=-blank-at-eof,cr-at-eol
|
||||
harness/experiments/chooser-lint-gate-20260911/** -text whitespace=-blank-at-eof,cr-at-eol
|
||||
harness/history/2026-09-11-behavior-wiki-before-refresh.md -text whitespace=cr-at-eol
|
||||
# Preserve the independently fingerprinted historical review verbatim.
|
||||
harness/release-behavior-canvas-phase1-review-20260911.md -text whitespace=-blank-at-eof,cr-at-eol
|
||||
harness/experiments/scenario-native-3c9e6ee-20260911/** -text whitespace=-blank-at-eof,cr-at-eol
|
||||
harness/experiments/scenario-native-3c9e6ee-20260911/**/*.arcadia whitespace=-blank-at-eol,-blank-at-eof,cr-at-eol
|
||||
harness/experiments/analysis-codec-windows-20260911/** -text whitespace=-blank-at-eof,cr-at-eol
|
||||
harness/history/2026-09-11-ledgers-before-gitea-migration/** -text whitespace=-blank-at-eol,-blank-at-eof,cr-at-eol
|
||||
harness/experiments/canvas-union-windows-20260911/** -text whitespace=-blank-at-eof,cr-at-eol
|
||||
harness/experiments/canvas-a31b40d-windows-native-20260911/** -text whitespace=-blank-at-eol,-blank-at-eof,cr-at-eol
|
||||
harness/experiments/allocation-d9b3f0d-windows-20260912/** -text whitespace=-blank-at-eol,-blank-at-eof,cr-at-eol
|
||||
harness/experiments/allocation-3840ca2-pause-20260912/** -text whitespace=-blank-at-eol,-blank-at-eof,cr-at-eol
|
||||
harness/experiments/diagram-removal-windows-20260912/** -text whitespace=-blank-at-eol,-blank-at-eof,cr-at-eol
|
||||
harness/experiments/diagram-resize-foundation-20260912/** -text whitespace=-blank-at-eol,-blank-at-eof,cr-at-eol
|
||||
harness/experiments/delete-native-f7-20260912/** -text whitespace=-blank-at-eol,-blank-at-eof,cr-at-eol
|
||||
harness/experiments/canvas-editing-followup-20260912/** -text whitespace=-blank-at-eol,-blank-at-eof,cr-at-eol
|
||||
harness/experiments/resize-port-policy-20260912/** -text whitespace=-blank-at-eol,-blank-at-eof,cr-at-eol
|
||||
harness/experiments/canvas-native-83f53c7-20260912/** -text whitespace=-blank-at-eol,-blank-at-eof,cr-at-eol
|
||||
harness/experiments/nested-lifecycle-union-20260912/** -text whitespace=-blank-at-eol,-blank-at-eof,cr-at-eol
|
||||
harness/experiments/canvas-native-d61f1f3-20260912/** -text whitespace=-blank-at-eol,-blank-at-eof,cr-at-eol
|
||||
harness/experiments/lifecycle-fixture-preflight-20260913/** -text whitespace=-blank-at-eol,-blank-at-eof,cr-at-eol
|
||||
harness/experiments/palette-readability-20260913/** -text whitespace=-blank-at-eol,-blank-at-eof,cr-at-eol
|
||||
harness/release-analysis-next-acceptance-20260913.md -text whitespace=-blank-at-eol,-blank-at-eof,cr-at-eol
|
||||
harness/experiments/runtime-notices-20260913/** -text whitespace=-blank-at-eol,-blank-at-eof,cr-at-eol
|
||||
harness/experiments/analysis-checked-save-windows-20260913/** -text whitespace=-blank-at-eol,-blank-at-eof,cr-at-eol
|
||||
harness/experiments/canvas-native-dd39e1f-20260913/** -text whitespace=-blank-at-eol,-blank-at-eof,cr-at-eol
|
||||
harness/experiments/navigation-palette-e124029-windows-20260913/** -text whitespace=-blank-at-eol,-blank-at-eof,cr-at-eol
|
||||
harness/experiments/navigation-native-preparation-e124029-20260913/** -text whitespace=-blank-at-eol,-blank-at-eof,cr-at-eol
|
||||
harness/experiments/analysis-source-s0-s1-windows-20260913/** -text whitespace=-blank-at-eol,-blank-at-eof,cr-at-eol
|
||||
harness/experiments/analysis-source3-bridge-windows-20260913/** -text whitespace=-blank-at-eol,-blank-at-eof,cr-at-eol
|
||||
harness/scenario-analysis-version-integration-review-20260913.md -text whitespace=cr-at-eol
|
||||
harness/scenario-analysis-version-integration-inputs-20260913.json -text whitespace=cr-at-eol
|
||||
harness/experiments/analysis-source-s2a-windows-20260913/** -text whitespace=-blank-at-eol,-blank-at-eof,cr-at-eol
|
||||
|
||||
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
|
||||
# Preserve the pinned Iced dependency bytes and upstream manifest.
|
||||
crates/vendor/iced_widget/** -text whitespace=cr-at-eol
|
||||
|
||||
@@ -4,12 +4,12 @@
|
||||
|
||||
- Follow `PROJECT_GOAL.md` and the user's current instructions. Work autonomously within scope. Resolve discoverable questions yourself; record, test, and revise reversible assumptions. Ask only when a consequential decision needs information or authorization not already available. Continue independent useful work while blocked elsewhere.
|
||||
- Map unfamiliar domains through primary sources, comparable systems, and real user workflows. Identify underlying needs, valuable concepts, and avoidable friction; evaluate improvements rather than merely reproducing features. Synthesize findings into decisions or experiments, and stop research when another action offers greater value.
|
||||
- Make specifications precise enough for compatible independent implementations: definitions, relationships, invariants, valid/invalid examples, and lifecycle or transformation rules where relevant. Trace important requirements and decisions to implementation and verification. Resolve specification ambiguity at its source when implementation exposes it.
|
||||
- Make specifications precise enough for compatible independent implementations: definitions, relationships, invariants, valid / invalid examples, and lifecycle or transformation rules where relevant. Trace important requirements and decisions to implementation and verification. Resolve specification ambiguity at its source when implementation exposes it.
|
||||
- For consequential uncertain choices, compare materially different candidates against explicit user-derived criteria. Ask independent critics to challenge assumptions and attempt to falsify proposals; refine or replace candidates as evidence warrants. Record the choice, evidence, tradeoffs, rejected approaches, and reasons to revisit it. Use proportionate effort for reversible local choices.
|
||||
- Plan milestones that resolve uncertainty or deliver useful capability, with explicit dependencies. Test foundational assumptions before substantial downstream work. At checkpoints, assess progress toward the actual objective and replan when evidence invalidates the strategy or progress stalls.
|
||||
- Define observable success and suitable evaluators before repeated improvement: invariants, examples, round trips, transformations, performance checks, realistic user tasks, or explicit review rubrics. Ensure the evaluator measures the intended outcome. For experiments, record the question, expectations, method, result, and implications; isolate exploratory prototypes from production architecture.
|
||||
- Build and run meaningful changes, exercise actual behavior, and inspect useful logs. Investigate crashes, serious warnings, corrupted state, and interaction defects. For GUI work, use Computer Use or equivalent tools to inspect realistic workflows, cognitive load, editing/selection, navigation, feedback, recovery, loading/empty/failure states, accessibility, layout, focus/disabled states, and responsiveness.
|
||||
- Match independent reviews to the artifact and risks. For languages or formats, include ambiguity, round trips, human/AI editability, and version-control behavior where relevant. Verify findings, fix the appropriate conceptual, specification, architectural, implementation, or interaction layer, and recheck affected dependents. Reuse valid evidence; repeat checks for concrete risks, changes, findings, or required gates. Compilation, screenshots, and worker reports alone do not establish correctness or usability.
|
||||
- Build and run meaningful changes, exercise actual behavior, and inspect useful logs. Investigate crashes, serious warnings, corrupted state, and interaction defects. For GUI work, use Computer Use or equivalent tools to inspect realistic workflows, cognitive load, editing / selection, navigation, feedback, recovery, loading / empty / failure states, accessibility, layout, focus / disabled states, and responsiveness.
|
||||
- Match independent reviews to the artifact and risks. For languages or formats, include ambiguity, round trips, human / AI editability, and version-control behavior where relevant. Verify findings, fix the appropriate conceptual, specification, architectural, implementation, or interaction layer, and recheck affected dependents. Reuse valid evidence; repeat checks for concrete risks, changes, findings, or required gates. Compilation, screenshots, and worker reports alone do not establish correctness or usability.
|
||||
- Favor the simplest maintainable design satisfying known requirements and justified extensibility. Avoid speculative abstraction, unrelated refactoring, and repeating approaches already invalidated by evidence.
|
||||
|
||||
## User input
|
||||
@@ -24,10 +24,10 @@ Keep one authoritative home for each kind of information. Use links and brief sc
|
||||
|
||||
| Information | Authoritative home |
|
||||
| --- | --- |
|
||||
| Live plans, priorities, dependencies, ownership, status, blockers, and handoffs | Gitea issues/milestones; linked PRs hold change review and validation. |
|
||||
| Specifications, architecture, interfaces, and build/run documentation | Established version-controlled project documents. |
|
||||
| Live plans, priorities, dependencies, ownership, status, blockers, and handoffs | Gitea issues / milestones; linked PRs hold change review and validation. |
|
||||
| Specifications, architecture, interfaces, and build / run documentation | Established version-controlled project documents. |
|
||||
| Durable decisions and reusable evidence | `harness/DECISIONS.md` and relevant topic notes, or existing equivalents. |
|
||||
| Navigation and integrated-state checkpoint | `harness/STATE.md`: links, verified code commit/release, capabilities, and material limitations. No live task ledger. |
|
||||
| Navigation and integrated-state checkpoint | `harness/STATE.md`: links, verified code commit / release, capabilities, and material limitations. No live task ledger. |
|
||||
| Shared entrypoint | Gitea Wiki: overview and links, without duplicating versioned documentation. |
|
||||
| Published deliverables | Gitea releases, tied to source commits and verification. |
|
||||
|
||||
@@ -38,9 +38,9 @@ Use Gitea for live coordination and the relevant code version for implementation
|
||||
All independent agents coordinating through Gitea run on completely separate machines, each with its own clone, filesystem, tools, processes, and runtime. Gitea and pushed Git history are the shared communication and delivery channels. Local files (including `harness`), unpushed commits, worktrees, test results, and localhost services are not accessible to peers. Matching branch names or paths do not imply synchronized contents.
|
||||
|
||||
1. On starting or resuming, including after compaction, inspect the working branch, local changes, applicable instructions, relevant shared records, and existing implementation. Recover context from authoritative records. Check for newer shared instructions on the actual remote default branch without overwriting local work. Read what is relevant; avoid repeated full-history scans and constant polling.
|
||||
2. Search before creating tasks. Claim a meaningful outcome in its Gitea issue with a stable session identity, scope, remote repository/branch, and acceptance criteria. Keep small steps within that task. Recheck after claiming; comments are not atomic locks. Respect existing ownership and resolve overlapping claims before overlapping work.
|
||||
3. Independent sessions are peers. Each owns its contribution and delegated work on its own machine. Record one integration/release owner when a milestone needs shared coordination. Use distinct remote work branches, partition files/interfaces, and coordinate changes to shared specifications and documents. Worktrees provide local isolation only; they do not share work between machines.
|
||||
4. At meaningful checkpoints or handoffs, record changed results, commit/PR links, verification commands and environment, blockers, and next actions. Identify the tested code version and label uncommitted or unpublished work as local-only. Before asking peers to consume results, push the required code and versioned documentation, including relevant `harness` updates; publish other required artifacts in Gitea and link them. Use repository-relative paths with commit references, reproducible commands, and necessary environment details; local paths and localhost URLs alone are not handoffs. Do not assume another agent is running or has read a message; silence alone does not establish abandonment. Record evidence and preserve existing work when reassigning a task.
|
||||
2. Search before creating tasks. Claim a meaningful outcome in its Gitea issue with a stable session identity, scope, remote repository / branch, and acceptance criteria. Keep small steps within that task. Recheck after claiming; comments are not atomic locks. Respect existing ownership and resolve overlapping claims before overlapping work.
|
||||
3. Independent sessions are peers. Each owns its contribution and delegated work on its own machine. Record one integration / release owner when a milestone needs shared coordination. Use distinct remote work branches, partition files / interfaces, and coordinate changes to shared specifications and documents. Worktrees provide local isolation only; they do not share work between machines.
|
||||
4. At meaningful checkpoints or handoffs, record changed results, commit / PR links, verification commands and environment, blockers, and next actions. Identify the tested code version and label uncommitted or unpublished work as local-only. Before asking peers to consume results, push the required code and versioned documentation, including relevant `harness` updates; publish other required artifacts in Gitea and link them. Use repository-relative paths with commit references, reproducible commands, and necessary environment details; local paths and localhost URLs alone are not handoffs. Do not assume another agent is running or has read a message; silence alone does not establish abandonment. Record evidence and preserve existing work when reassigning a task.
|
||||
5. Follow project review and merge conventions. Fetch the relevant remote branches and inspect the identified commits before reviewing or integrating another machine's work. Resolve conflicts while preserving others' edits, and verify the combined result locally; report material environment differences. Local instruction-file edits reach peers only after publication and retrieval. Do not overwrite others' work or force-push shared history to resolve coordination problems.
|
||||
6. Close work when its acceptance criteria are met and the result is integrated or delivered as appropriate. Mark duplicates and superseded tasks explicitly with replacement links. Then choose further useful unowned work while the requested objective remains incomplete.
|
||||
|
||||
@@ -64,10 +64,10 @@ Preserve concise records of decisions and rationale, useful experiments, failed
|
||||
|
||||
Consolidate duplicates, repair stale guidance, and remove obsolete temporary files alongside useful project work. Before removing material, check references, active ownership, and ongoing use; preserve unique knowledge and update affected links. Prefer closing, superseding, or archiving history over deletion. User input is excluded from autonomous cleanup.
|
||||
|
||||
If legacy local task ledgers or plans duplicate Gitea, migrate their live content once into existing or appropriately scoped issues, preserve useful decisions/evidence, and mark the old records superseded. Record the migration so later sessions do not repeat it. Do not create empty documents or maintain records for ceremony.
|
||||
If legacy local task ledgers or plans duplicate Gitea, migrate their live content once into existing or appropriately scoped issues, preserve useful decisions / evidence, and mark the old records superseded. Record the migration so later sessions do not repeat it. Do not create empty documents or maintain records for ceremony.
|
||||
|
||||
## Delivery
|
||||
|
||||
Publish versioned Gitea releases at meaningful working milestones; mark unfinished versions as prereleases. Check existing releases/tags and coordinate one owner and version before publishing. Build from an identified integrated commit and verify the packaged result and launch steps.
|
||||
Publish versioned Gitea releases at meaningful working milestones; mark unfinished versions as prereleases. Check existing releases / tags and coordinate one owner and version before publishing. Build from an identified integrated commit and verify the packaged result and launch steps.
|
||||
|
||||
Include runnable builds or appropriate artifacts, concise release notes, build/run/deployment instructions as applicable, known limitations, tested platforms, and verification evidence. Confirm publication and artifact availability. Use existing authorized API access or project tooling when MCP lacks a needed release/upload operation. Do not silently replace published versions with different contents.
|
||||
Include runnable builds or appropriate artifacts, concise release notes, build / run / deployment instructions as applicable, known limitations, tested platforms, and verification evidence. Confirm publication and artifact availability. Use existing authorized API access or project tooling when MCP lacks a needed release / upload operation. Do not silently replace published versions with different contents.
|
||||
|
||||
Generated
+22
-2
@@ -1567,8 +1567,6 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "iced_widget"
|
||||
version = "0.14.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b1596afa0d3109c2618e8bc12bae6c11d3064df8f95c42dfce570397dbe957ab"
|
||||
dependencies = [
|
||||
"iced_renderer",
|
||||
"log",
|
||||
@@ -2107,6 +2105,25 @@ dependencies = [
|
||||
"jni-sys 0.3.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-bigint"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "93e7820bc0a80a0238e650327316f929ba18d5be054b647490a3a6a339f3e7c0"
|
||||
dependencies = [
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-integer"
|
||||
version = "0.1.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-traits"
|
||||
version = "0.2.19"
|
||||
@@ -2153,6 +2170,9 @@ name = "oa-core"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"num-bigint",
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
"same-file",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
[workspace]
|
||||
members = ["crates/oa-core", "crates/oa-desktop", "crates/oa-server", "crates/oa-cli"]
|
||||
exclude = ["crates/vendor/iced_widget"]
|
||||
resolver = "2"
|
||||
|
||||
[patch.crates-io]
|
||||
iced_widget = { path = "crates/vendor/iced_widget" }
|
||||
|
||||
[workspace.package]
|
||||
version = "0.2.0"
|
||||
edition = "2024"
|
||||
|
||||
@@ -94,11 +94,13 @@ Create named **Exchange categories** at the perspective root to group Work excha
|
||||
|
||||
In **Select / move**, drag cards on a 10-unit grid, drag empty space to pan, and scroll to zoom. Drag ports along their owner's border or onto any of its four sides. Automatic sides account for direction and visible connections. Manual positions are per diagram and remain relative to the owner through movement or resize; right-click **Reset automatic position** restores automatic placement. **Arrange** changes the current view layout; **Fit** frames its projected bounds. SVG exports the current architecture diagram as a view, not an editable model file.
|
||||
|
||||
In saved architecture and State views, **Shift+click** adds or removes an exact displayed item; its context menu also offers **Add to selection** and **Remove from selection**. Properties edits only the primary item. Drag a selected member to move a placement-only group in one operation, including visual descendants once and the authored pins of connections whose endpoint owners move together. A stationary click selects only that member; Escape during a drag cancels it and keeps the group. **Hide selected** removes only selected placements, preserving model elements, unselected descendant placements and other views; one Undo restores the operation. Groups containing connections, ports or State action/internal-transition rows cannot be moved or hidden as placement groups. State rows select their relation, never their owning placement. Dragging a group of allocated work cards and deleting a group from the model remain unavailable; these actions refuse the whole group instead of editing just its primary item. Selection itself is temporary and is not saved.
|
||||
|
||||
Connections start with orthogonal routes that avoid cards and container headers. In Select mode, select a connection and drag a segment to add a bend, or right-click for **Add bend here**. Drag its circles to adjust the route; the menu offers **Remove bend** and **Reset routing**. With the canvas focused, Insert adds a bend and Delete removes the selected bend. Shift+F10 opens the selected item's menu; **Actions** in Properties provides the same commands. Drag the connection label independently. Each completed gesture is one undoable edit; Escape cancels a drag. Routes belong to the current diagram and persist in source, JSON, REST and SVG export. Folding an endpoint container temporarily uses an automatic route; expanding it restores your authored route. Dense diagrams can still need manual adjustment; automatic routing is bounded and does not guarantee an overlap-free result.
|
||||
|
||||
For state diagrams, use **+ Region** inside a highlighted machine or State/Mode, then choose **+ State / symbol** and click its owning region. **Transition** supports dragging between compatible vertices or clicking the source and then the target. Select a drawn transition and drag its endpoint handle, or right-click **Reconnect source/target**. Right-click a state or the background for **New transition with details…** to create Local, Internal or timed transitions; an existing transition's **Edit transition** form edits its kind, triggers, guard and effects. EntryPoint outgoing transitions use Local.
|
||||
|
||||
In the state view, **Select / move** drags vertices and containers without reparenting; container movement includes its descendants. Middle-button drag pans, scroll or **−/+** zooms, and **Fit** frames the view. Right-click for **Fold contents**, **Unfold contents**, **Placement properties**, or **Hide from this diagram**. The **Show an element not yet in this view…** selector restores missing placements. Escape cancels a gesture; **Undo** restores a completed edit. **+ View** creates an independent layout. The persisted diagram's machine reference and placements are the same records in source, JSON and REST; opening the page alone creates no diagram.
|
||||
In the state view, **Select / move** drags vertices and containers without reparenting or grid snapping; container movement includes its descendants. Multiple selected placements share the same fractional displacement, including authored positions of boundary symbols. State route bends retain their separate 5-unit grid. Middle-button drag pans, scroll or **−/+** zooms, and **Fit** frames the view. Right-click for **Fold contents**, **Unfold contents**, **Placement properties**, or **Hide from this diagram**. The **Show an element not yet in this view…** selector restores missing placements. Escape cancels a gesture; **Undo** restores a completed edit. **+ View** creates an independent layout. The persisted diagram's machine reference and placements are the same records in source, JSON and REST; opening the page alone creates no diagram.
|
||||
|
||||
## Edit the same model as text
|
||||
|
||||
|
||||
@@ -222,8 +222,8 @@ pub fn prepare(entry: &Path, operation: Operation) -> Result<Prepared, Box<Repor
|
||||
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.
|
||||
// Keep discovery/IO failures that lint cannot recover from the bundle alone.
|
||||
// Recompute source diagnostics below, against the same original bytes.
|
||||
let mut diagnostics: Vec<_> = snapshot
|
||||
.diagnostics
|
||||
.iter()
|
||||
|
||||
@@ -12,6 +12,9 @@ uuid.workspace = true
|
||||
thiserror.workspace = true
|
||||
tempfile = "3"
|
||||
same-file = "1"
|
||||
num-bigint = "0.5.1"
|
||||
num-integer = "0.1.47"
|
||||
num-traits = "0.2.19"
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
libc = "0.2"
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
//! Read and validate a saved acceptance fixture without rewriting its source.
|
||||
//! cargo run --locked -p oa-core --example attachment_snapshot -- input.arcadia output.json
|
||||
use oa_core::{persistence, validate_candidate};
|
||||
use std::{fs::OpenOptions, io::Write, path::Path};
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let args: Vec<_> = std::env::args_os().skip(1).collect();
|
||||
if args.len() != 2 {
|
||||
return Err("Expected input source/JSON and a new output JSON path".into());
|
||||
}
|
||||
let project = persistence::load(Path::new(&args[0]))?;
|
||||
validate_candidate(&project)?;
|
||||
let bytes = serde_json::to_vec_pretty(&project)?;
|
||||
let mut output = OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(&args[1])?;
|
||||
output.write_all(&bytes)?;
|
||||
output.write_all(b"\n")?;
|
||||
output.sync_all()?;
|
||||
println!(
|
||||
"Validated {} objects and {} views; saved a complete Project snapshot",
|
||||
project.identities().len(),
|
||||
project.diagrams.len()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,716 @@
|
||||
//! Native evidence fixture/checker; install as a temporary oa-core example.
|
||||
//! This invariant evaluator derives delta from saved A, NOT from pointer input.
|
||||
use oa_core::{source_project::*, *};
|
||||
use std::{fs, io::Write, path::Path};
|
||||
type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
|
||||
|
||||
fn id(alias: &str) -> Id {
|
||||
// Make the intended OA view first for default UUID-ordered view selection.
|
||||
if alias == "oa_view" {
|
||||
return Id::from_u128(0x29_0001);
|
||||
}
|
||||
Id::new_v5(
|
||||
&Id::from_u128(0xc1192026_0923_5000_8000_000000000029),
|
||||
alias.as_bytes(),
|
||||
)
|
||||
}
|
||||
fn point(x: f32, y: f32) -> Position {
|
||||
Position { x, y }
|
||||
}
|
||||
fn element(p: &mut Project, alias: &str, name: &str, kind: Kind, layer: Layer, parent: Option<Id>) {
|
||||
p.elements.insert(
|
||||
id(alias),
|
||||
Element {
|
||||
id: id(alias),
|
||||
alias: alias.into(),
|
||||
name: name.into(),
|
||||
description: format!("Preserve engineering record {alias}."),
|
||||
parent,
|
||||
body: ElementBody::new(kind, layer).unwrap(),
|
||||
},
|
||||
);
|
||||
}
|
||||
fn diagram(p: &mut Project, alias: &str, name: &str, layer: Layer) {
|
||||
p.diagrams.insert(
|
||||
id(alias),
|
||||
Diagram {
|
||||
id: id(alias),
|
||||
alias: alias.into(),
|
||||
name: name.into(),
|
||||
description: "Selection changes placement geometry only.".into(),
|
||||
layer,
|
||||
projection: ProjectionKind::Overview,
|
||||
view_options: Default::default(),
|
||||
nodes: Default::default(),
|
||||
routes: vec![],
|
||||
},
|
||||
);
|
||||
}
|
||||
fn place(p: &mut Project, view: &str, alias: &str, object: &str, position: Position, size: Size) {
|
||||
p.diagrams.get_mut(&id(view)).unwrap().nodes.insert(
|
||||
id(alias),
|
||||
NodePlacement {
|
||||
id: id(alias),
|
||||
alias: alias.into(),
|
||||
element: id(object),
|
||||
position,
|
||||
size,
|
||||
collapsed: false,
|
||||
port_anchors: vec![],
|
||||
},
|
||||
);
|
||||
}
|
||||
fn route(edge: &str, pins: Vec<Position>) -> EdgeRoute {
|
||||
EdgeRoute {
|
||||
edge: id(edge),
|
||||
waypoints: pins,
|
||||
source_attachment: Some(EdgeAttachment {
|
||||
side: PortAnchorSide::Right,
|
||||
offset: 0.25,
|
||||
}),
|
||||
target_attachment: Some(EdgeAttachment {
|
||||
side: PortAnchorSide::Left,
|
||||
offset: 0.7,
|
||||
}),
|
||||
label: Some(EdgeLabel {
|
||||
fraction: 0.45,
|
||||
offset: point(7., -19.),
|
||||
}),
|
||||
}
|
||||
}
|
||||
fn fixture() -> Result<Project> {
|
||||
let mut p = Project::empty();
|
||||
p.id = id("project");
|
||||
p.alias = "multiselect_native".into();
|
||||
p.name = "Multiselect native".into();
|
||||
p.description =
|
||||
"Shift-select A and B; move together, retain C and engineering semantics.".into();
|
||||
for (alias, name, parent) in [
|
||||
("activity_a", "A - receive request", None),
|
||||
("activity_b", "B - assess request", None),
|
||||
("activity_c", "C - dispatch response", None),
|
||||
(
|
||||
"activity_child",
|
||||
"A.1 - classify request",
|
||||
Some(id("activity_a")),
|
||||
),
|
||||
] {
|
||||
element(&mut p, alias, name, Kind::Activity, Layer::Oa, parent);
|
||||
}
|
||||
element(
|
||||
&mut p,
|
||||
"sa_function",
|
||||
"Technical request processing",
|
||||
Kind::Function,
|
||||
Layer::Sa,
|
||||
None,
|
||||
);
|
||||
for (alias, name, source, target) in [
|
||||
("internal", "Request assessment", "activity_a", "activity_b"),
|
||||
("external", "Response decision", "activity_b", "activity_c"),
|
||||
] {
|
||||
let old = p.relate(RelationKind::Interaction, name, id(source), id(target));
|
||||
reidentify(&mut p, old, id(alias))?;
|
||||
p.relations.get_mut(&id(alias)).unwrap().alias = alias.into();
|
||||
}
|
||||
diagram(
|
||||
&mut p,
|
||||
"oa_view",
|
||||
"Multiselect native - OA overview",
|
||||
Layer::Oa,
|
||||
);
|
||||
diagram(&mut p, "oa_other", "Other OA view - preserve", Layer::Oa);
|
||||
diagram(&mut p, "sa_view", "SA engineering - preserve", Layer::Sa);
|
||||
let size = Size {
|
||||
width: 224.,
|
||||
height: 94.,
|
||||
};
|
||||
place(
|
||||
&mut p,
|
||||
"oa_view",
|
||||
"node_a",
|
||||
"activity_a",
|
||||
point(80., 80.),
|
||||
Size {
|
||||
width: 310.,
|
||||
height: 260.,
|
||||
},
|
||||
);
|
||||
place(
|
||||
&mut p,
|
||||
"oa_view",
|
||||
"node_b",
|
||||
"activity_b",
|
||||
point(470., 120.),
|
||||
size,
|
||||
);
|
||||
place(
|
||||
&mut p,
|
||||
"oa_view",
|
||||
"node_c",
|
||||
"activity_c",
|
||||
point(780., 270.),
|
||||
size,
|
||||
);
|
||||
place(
|
||||
&mut p,
|
||||
"oa_view",
|
||||
"node_child",
|
||||
"activity_child",
|
||||
point(120., 210.),
|
||||
size,
|
||||
);
|
||||
place(
|
||||
&mut p,
|
||||
"oa_other",
|
||||
"other_a",
|
||||
"activity_a",
|
||||
point(40., 60.),
|
||||
size,
|
||||
);
|
||||
place(
|
||||
&mut p,
|
||||
"sa_view",
|
||||
"sa_node",
|
||||
"sa_function",
|
||||
point(100., 110.),
|
||||
size,
|
||||
);
|
||||
p.diagrams.get_mut(&id("oa_view")).unwrap().routes = vec![
|
||||
route("internal", vec![point(420., 145.), point(420., 186.)]),
|
||||
route("external", vec![point(725., 150.), point(725., 336.)]),
|
||||
];
|
||||
p.traces.insert(
|
||||
id("trace"),
|
||||
Trace {
|
||||
id: id("trace"),
|
||||
alias: "preserved_refinement".into(),
|
||||
name: "Reviewed request refinement".into(),
|
||||
description: "Group geometry and Hide preserve this trace and both endpoints.".into(),
|
||||
oa: id("activity_a"),
|
||||
sa: id("sa_function"),
|
||||
kind: TraceKind::Refinement,
|
||||
baseline: Some(TraceBaseline {
|
||||
oa: p.properties(id("activity_a")).unwrap(),
|
||||
sa: p.properties(id("sa_function")).unwrap(),
|
||||
semantic: None,
|
||||
}),
|
||||
},
|
||||
);
|
||||
validate_candidate(&p)?;
|
||||
Ok(p)
|
||||
}
|
||||
fn load(path: &Path) -> Result<Project> {
|
||||
if path
|
||||
.extension()
|
||||
.is_some_and(|ext| ext.eq_ignore_ascii_case("arcadia"))
|
||||
{
|
||||
let disk = source_files::load_project(path, ProjectLimits::default())?;
|
||||
// This deliberately partial engineering fixture has completeness
|
||||
// warnings (allocation/behavior context). They are not source errors.
|
||||
// Compilation, candidate validation and whole-Project equality still
|
||||
// reject malformed source or any unexpected model change.
|
||||
if disk
|
||||
.diagnostics
|
||||
.iter()
|
||||
.any(|d| d.diagnostic.severity == validation::Severity::Error)
|
||||
{
|
||||
return Err(format!("Source discovery diagnostics: {:?}", disk.diagnostics).into());
|
||||
}
|
||||
let compiled = compile_bundle(&disk.reachable_bundle(), ProjectLimits::default())
|
||||
.map_err(|e| format!("Source compile diagnostics: {e:?}"))?;
|
||||
Ok(compiled.decoded.project)
|
||||
} else {
|
||||
Ok(persistence::load(path)?)
|
||||
}
|
||||
}
|
||||
fn equal(expected: &Project, actual: &Project) -> Result<()> {
|
||||
if expected == actual {
|
||||
return Ok(());
|
||||
}
|
||||
let e = serde_json::to_value(expected)?;
|
||||
let a = serde_json::to_value(actual)?;
|
||||
let fields: Vec<_> = e
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.keys()
|
||||
.filter(|key| e[*key] != a[*key])
|
||||
.cloned()
|
||||
.collect();
|
||||
Err(format!("Whole Project mismatch in {fields:?}; no partial PASS").into())
|
||||
}
|
||||
fn roundtrip(p: &Project) -> Result<SourceBundle> {
|
||||
validate_candidate(p)?;
|
||||
equal(p, &persistence::decode(&serde_json::to_vec(p)?)?)?;
|
||||
let bundle = format_bundle(p);
|
||||
let compiled = compile_bundle(&bundle, ProjectLimits::default())
|
||||
.map_err(|e| format!("Roundtrip: {e:?}"))?;
|
||||
equal(p, &compiled.decoded.project)?;
|
||||
Ok(bundle)
|
||||
}
|
||||
fn write_new(path: &Path, bytes: &[u8]) -> Result<()> {
|
||||
fs::create_dir_all(path.parent().ok_or("Missing parent directory")?)?;
|
||||
fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(path)?
|
||||
.write_all(bytes)?;
|
||||
Ok(())
|
||||
}
|
||||
fn generate(out: &Path) -> Result<()> {
|
||||
if out.exists() {
|
||||
return Err("Output directory already exists; choose a fresh path".into());
|
||||
}
|
||||
let p = fixture()?;
|
||||
let bundle = roundtrip(&p)?;
|
||||
write_new(&out.join("baseline.json"), &serde_json::to_vec_pretty(&p)?)?;
|
||||
for (path, source) in &bundle.files {
|
||||
write_new(&out.join("source").join(path), source.as_bytes())?;
|
||||
}
|
||||
let names = [
|
||||
"project",
|
||||
"oa_view",
|
||||
"oa_other",
|
||||
"sa_view",
|
||||
"activity_a",
|
||||
"activity_b",
|
||||
"activity_c",
|
||||
"activity_child",
|
||||
"node_a",
|
||||
"node_b",
|
||||
"node_c",
|
||||
"node_child",
|
||||
"internal",
|
||||
"external",
|
||||
"trace",
|
||||
"sa_function",
|
||||
"sa_node",
|
||||
"other_a",
|
||||
];
|
||||
let ids: std::collections::BTreeMap<_, _> =
|
||||
names.into_iter().map(|name| (name, id(name))).collect();
|
||||
write_new(
|
||||
&out.join("manifest.json"),
|
||||
&serde_json::to_vec_pretty(&serde_json::json!({
|
||||
"fixture": "multiselect-native-20260923", "ids": ids, "source_entry": bundle.entry,
|
||||
"move_members": ["node_a", "node_b", "node_child"], "fixed_member": "node_c",
|
||||
"evaluation": "Whole Project invariant comparison; saved A supplies bounded shared delta, not an exact pointer oracle",
|
||||
"hide": "Remove node_a/node_b only; retain child, stored routes, all model records and other views",
|
||||
}))?,
|
||||
)?;
|
||||
equal(&p, &load(&out.join("baseline.json"))?)?;
|
||||
equal(&p, &load(&out.join("source/project.arcadia"))?)?;
|
||||
println!("PASS generated JSON and source bundle: {}", out.display());
|
||||
Ok(())
|
||||
}
|
||||
fn check(mode: &str, baseline: &Path, saved: &Path) -> Result<()> {
|
||||
let before = load(baseline)?;
|
||||
let actual = load(saved)?;
|
||||
validate_candidate(&before)?;
|
||||
validate_candidate(&actual)?;
|
||||
if before.id != id("project") {
|
||||
return Err("Not this fixture's baseline project".into());
|
||||
}
|
||||
let mut expected = before.clone();
|
||||
let mut delta = None;
|
||||
match mode {
|
||||
"unchanged" => {}
|
||||
"hide" => {
|
||||
let view = expected
|
||||
.diagrams
|
||||
.get_mut(&id("oa_view"))
|
||||
.ok_or("Missing OA view")?;
|
||||
for alias in ["node_a", "node_b"] {
|
||||
view.nodes
|
||||
.remove(&id(alias))
|
||||
.ok_or("Baseline selected placement missing")?;
|
||||
}
|
||||
// references::remove_objects removes routes only when their semantic
|
||||
// edge is deleted. Hide deletes placements; both routes MUST survive.
|
||||
}
|
||||
"moved" => {
|
||||
let old = before
|
||||
.diagrams
|
||||
.get(&id("oa_view"))
|
||||
.ok_or("Missing baseline OA view")?;
|
||||
let new = actual
|
||||
.diagrams
|
||||
.get(&id("oa_view"))
|
||||
.ok_or("Missing saved OA view")?;
|
||||
let a = old
|
||||
.nodes
|
||||
.get(&id("node_a"))
|
||||
.ok_or("Missing baseline A")?
|
||||
.position;
|
||||
let moved_a = new
|
||||
.nodes
|
||||
.get(&id("node_a"))
|
||||
.ok_or("Missing saved A")?
|
||||
.position;
|
||||
let d = point(moved_a.x - a.x, moved_a.y - a.y);
|
||||
if !d.x.is_finite()
|
||||
|| !d.y.is_finite()
|
||||
|| d.x.abs().max(d.y.abs()) > 500.
|
||||
|| d.x.hypot(d.y) < 10.
|
||||
{
|
||||
return Err(format!(
|
||||
"Move delta must have length >=10 and each axis <=500 world units: {d:?}"
|
||||
)
|
||||
.into());
|
||||
}
|
||||
if moved_a.x % 10. != 0. || moved_a.y % 10. != 0. {
|
||||
return Err("Saved anchor is not on 10-unit grid".into());
|
||||
}
|
||||
let view = expected.diagrams.get_mut(&id("oa_view")).unwrap();
|
||||
// Independent explicit fixture enumeration: no group/descendant/
|
||||
// routing planner is called to predict the result.
|
||||
for alias in ["node_a", "node_b", "node_child"] {
|
||||
let node = view
|
||||
.nodes
|
||||
.get_mut(&id(alias))
|
||||
.ok_or("Missing expected moved placement")?;
|
||||
node.position.x += d.x;
|
||||
node.position.y += d.y;
|
||||
}
|
||||
let internal = view
|
||||
.routes
|
||||
.iter_mut()
|
||||
.find(|route| route.edge == id("internal"))
|
||||
.ok_or("Missing internal pins")?;
|
||||
for pin in &mut internal.waypoints {
|
||||
pin.x += d.x;
|
||||
pin.y += d.y;
|
||||
}
|
||||
delta = Some(d);
|
||||
}
|
||||
_ => return Err("Mode must be moved, hide, or unchanged".into()),
|
||||
}
|
||||
equal(&expected, &actual)?;
|
||||
roundtrip(&actual)?;
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&serde_json::json!({
|
||||
"result": "PASS", "mode": mode, "baseline": baseline, "saved": saved,
|
||||
"comparison": "complete Project equality", "inferred_common_delta": delta,
|
||||
"limits": "Invariant evaluator only; no pointer/action/focus/Undo/source-byte acceptance claim",
|
||||
}))?
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
fn main() -> Result<()> {
|
||||
let args: Vec<_> = std::env::args().skip(1).collect();
|
||||
match args.as_slice() {
|
||||
[command, out] if command == "generate" => generate(Path::new(out)),
|
||||
[command, mode, baseline, saved] if command == "check" => check(mode, Path::new(baseline), Path::new(saved)),
|
||||
_ => Err("Usage: multiselect-native-producer generate DIRECTORY | check moved|hide|unchanged BASELINE SAVED".into()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
// Produce evidence through real validated commands; the checker itself uses
|
||||
// explicit fixture enumeration and never calls this movement implementation.
|
||||
fn moved(before: &Project, delta: Position) -> Project {
|
||||
let command = diagram_edit::translate_placements(
|
||||
before,
|
||||
id("oa_view"),
|
||||
&BTreeSet::from([id("node_a"), id("node_b")]),
|
||||
delta,
|
||||
)
|
||||
.unwrap();
|
||||
let mut session = Session::new(before.clone());
|
||||
session.execute(command).unwrap();
|
||||
session.project().clone()
|
||||
}
|
||||
fn hidden(before: &Project) -> Project {
|
||||
let mut session = Session::new(before.clone());
|
||||
session
|
||||
.execute(Command::Batch(vec![
|
||||
Command::Delete(id("node_a")),
|
||||
Command::Delete(id("node_b")),
|
||||
]))
|
||||
.unwrap();
|
||||
session.project().clone()
|
||||
}
|
||||
fn through_saved_json(mode: &str, baseline: &Project, actual: &Project) -> Result<()> {
|
||||
let dir = tempfile::tempdir()?;
|
||||
let before = dir.path().join("before.json");
|
||||
let saved = dir.path().join("saved.json");
|
||||
// These assertions distinguish meaningful invariant corruptions from
|
||||
// malformed projects rejected before the evaluator even sees them.
|
||||
validate_candidate(baseline).expect("test baseline must be valid");
|
||||
validate_candidate(actual).expect("corruption must remain a valid model");
|
||||
persistence::save(&before, baseline)?;
|
||||
persistence::save(&saved, actual)?;
|
||||
check(mode, &before, &saved)
|
||||
}
|
||||
fn mismatch(mode: &str, before: &Project, actual: &Project, case: &str) {
|
||||
let error = through_saved_json(mode, before, actual)
|
||||
.expect_err(case)
|
||||
.to_string();
|
||||
assert!(
|
||||
error.contains("Whole Project mismatch"),
|
||||
"{case}: unexpected rejection: {error}"
|
||||
);
|
||||
}
|
||||
fn edge<'a>(p: &'a mut Project, alias: &str) -> &'a mut EdgeRoute {
|
||||
p.diagrams
|
||||
.get_mut(&id("oa_view"))
|
||||
.unwrap()
|
||||
.routes
|
||||
.iter_mut()
|
||||
.find(|route| route.edge == id(alias))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_json_and_real_source_bundle_are_accepted_unchanged() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let out = dir.path().join("fixture");
|
||||
generate(&out).unwrap();
|
||||
let baseline = out.join("baseline.json");
|
||||
let saved = out.join("saved.json");
|
||||
persistence::save(&saved, &fixture().unwrap()).unwrap();
|
||||
check("unchanged", &baseline, &saved).unwrap();
|
||||
check("unchanged", &baseline, &out.join("source/project.arcadia")).unwrap();
|
||||
let bytes = fs::read(&baseline).unwrap();
|
||||
assert!(
|
||||
generate(&out).is_err(),
|
||||
"generation cannot overwrite evidence"
|
||||
);
|
||||
assert_eq!(fs::read(&baseline).unwrap(), bytes);
|
||||
let invalid = dir.path().join("invalid.arcadia");
|
||||
fs::write(&invalid, "project {").unwrap();
|
||||
assert!(load(&invalid).is_err(), "source errors must still reject");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn real_group_command_outputs_pass_and_are_not_unchanged() {
|
||||
let before = fixture().unwrap();
|
||||
for delta in [point(40., 60.), point(-20., 30.)] {
|
||||
let actual = moved(&before, delta);
|
||||
through_saved_json("moved", &before, &actual).unwrap();
|
||||
mismatch(
|
||||
"unchanged",
|
||||
&before,
|
||||
&actual,
|
||||
"real movement cannot count as unchanged",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn real_hide_command_outputs_pass_before_and_after_movement() {
|
||||
let initial = fixture().unwrap();
|
||||
for before in [initial.clone(), moved(&initial, point(40., 60.))] {
|
||||
let actual = hidden(&before);
|
||||
through_saved_json("hide", &before, &actual).unwrap();
|
||||
mismatch(
|
||||
"unchanged",
|
||||
&before,
|
||||
&actual,
|
||||
"real Hide cannot count as unchanged",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn omitted_and_double_moved_child_are_rejected() {
|
||||
let before = fixture().unwrap();
|
||||
let delta = point(40., 60.);
|
||||
let correct = moved(&before, delta);
|
||||
let initial = before.diagrams[&id("oa_view")].nodes[&id("node_child")].position;
|
||||
for position in [
|
||||
initial,
|
||||
point(initial.x + 2. * delta.x, initial.y + 2. * delta.y),
|
||||
] {
|
||||
let mut bad = correct.clone();
|
||||
bad.diagrams
|
||||
.get_mut(&id("oa_view"))
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&id("node_child"))
|
||||
.unwrap()
|
||||
.position = position;
|
||||
mismatch("moved", &before, &bad, "child did not move exactly once");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn internal_pins_left_behind_and_external_pins_moved_are_rejected() {
|
||||
let mut before = fixture().unwrap();
|
||||
let original_pins = edge(&mut before, "internal").waypoints.clone();
|
||||
let correct = moved(&before, point(40., 60.));
|
||||
let mut bad = correct.clone();
|
||||
edge(&mut bad, "internal").waypoints = original_pins;
|
||||
mismatch("moved", &before, &bad, "internal pins left behind");
|
||||
let mut bad = correct;
|
||||
for pin in &mut edge(&mut bad, "external").waypoints {
|
||||
pin.x += 40.;
|
||||
pin.y += 60.;
|
||||
}
|
||||
mismatch("moved", &before, &bad, "one-ended pins moved");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manual_attachments_and_label_data_must_remain_exact() {
|
||||
let before = fixture().unwrap();
|
||||
let correct = moved(&before, point(40., 60.));
|
||||
let mutations: [fn(&mut EdgeRoute); 4] = [
|
||||
|r| r.source_attachment.as_mut().unwrap().offset = 0.65,
|
||||
|r| r.target_attachment.as_mut().unwrap().side = PortAnchorSide::Top,
|
||||
|r| r.label.as_mut().unwrap().offset.x += 40.,
|
||||
|r| r.label.as_mut().unwrap().fraction = 0.75,
|
||||
];
|
||||
for mutate in mutations {
|
||||
let mut bad = correct.clone();
|
||||
mutate(edge(&mut bad, "internal"));
|
||||
mismatch("moved", &before, &bad, "relative route metadata changed");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn semantic_other_view_fixed_node_and_trace_drift_are_rejected() {
|
||||
let before = fixture().unwrap();
|
||||
let correct = moved(&before, point(40., 60.));
|
||||
let mutations: [fn(&mut Project); 5] = [
|
||||
|p| {
|
||||
p.elements
|
||||
.get_mut(&id("activity_a"))
|
||||
.unwrap()
|
||||
.description
|
||||
.push_str(" drift")
|
||||
},
|
||||
|p| {
|
||||
p.diagrams
|
||||
.get_mut(&id("oa_other"))
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&id("other_a"))
|
||||
.unwrap()
|
||||
.position
|
||||
.x += 40.
|
||||
},
|
||||
|p| {
|
||||
p.diagrams
|
||||
.get_mut(&id("sa_view"))
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&id("sa_node"))
|
||||
.unwrap()
|
||||
.position
|
||||
.y += 60.
|
||||
},
|
||||
|p| {
|
||||
p.diagrams
|
||||
.get_mut(&id("oa_view"))
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&id("node_c"))
|
||||
.unwrap()
|
||||
.position
|
||||
.x += 40.
|
||||
},
|
||||
|p| {
|
||||
p.traces
|
||||
.get_mut(&id("trace"))
|
||||
.unwrap()
|
||||
.description
|
||||
.push_str(" drift")
|
||||
},
|
||||
];
|
||||
for mutate in mutations {
|
||||
let mut bad = correct.clone();
|
||||
mutate(&mut bad);
|
||||
mismatch(
|
||||
"moved",
|
||||
&before,
|
||||
&bad,
|
||||
"record outside permitted movement changed",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_small_excessive_and_offgrid_moves_cannot_satisfy_move_evidence() {
|
||||
let before = fixture().unwrap();
|
||||
for (delta, reason) in [
|
||||
(point(0., 0.), "length >=10"),
|
||||
(point(3., 0.), "length >=10"),
|
||||
(point(510., 0.), "each axis <=500"),
|
||||
(point(20.5, 0.), "10-unit grid"),
|
||||
] {
|
||||
let actual = moved(&before, delta);
|
||||
let error = through_saved_json("moved", &before, &actual)
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(error.contains(reason), "{delta:?}: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hide_must_preserve_child_routes_and_semantic_objects() {
|
||||
let before = fixture().unwrap();
|
||||
let correct = hidden(&before);
|
||||
let mut missing_child = correct.clone();
|
||||
missing_child
|
||||
.diagrams
|
||||
.get_mut(&id("oa_view"))
|
||||
.unwrap()
|
||||
.nodes
|
||||
.remove(&id("node_child"));
|
||||
mismatch(
|
||||
"hide",
|
||||
&before,
|
||||
&missing_child,
|
||||
"Hide removed unselected child placement",
|
||||
);
|
||||
let mut missing_routes = correct;
|
||||
missing_routes
|
||||
.diagrams
|
||||
.get_mut(&id("oa_view"))
|
||||
.unwrap()
|
||||
.routes
|
||||
.clear();
|
||||
mismatch(
|
||||
"hide",
|
||||
&before,
|
||||
&missing_routes,
|
||||
"Hide removed persistent route records",
|
||||
);
|
||||
let mut semantic_delete = Session::new(before.clone());
|
||||
semantic_delete
|
||||
.execute(Command::Batch(vec![
|
||||
Command::Delete(id("activity_a")),
|
||||
Command::Delete(id("activity_b")),
|
||||
]))
|
||||
.unwrap();
|
||||
mismatch(
|
||||
"hide",
|
||||
&before,
|
||||
semantic_delete.project(),
|
||||
"Hide became semantic deletion",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unchanged_rejects_valid_semantic_drift_without_geometry_changes() {
|
||||
let before = fixture().unwrap();
|
||||
let mut actual = before.clone();
|
||||
actual
|
||||
.elements
|
||||
.get_mut(&id("activity_b"))
|
||||
.unwrap()
|
||||
.name
|
||||
.push_str(" edited");
|
||||
mismatch(
|
||||
"unchanged",
|
||||
&before,
|
||||
&actual,
|
||||
"geometry equality is not complete Project equality",
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,548 @@
|
||||
//! Deterministic Process native fixture and independent complete-Project oracle.
|
||||
//! Expected order is literal; no desktop reorder/layout planner is used here.
|
||||
use oa_core::{source_project::*, *};
|
||||
use std::{fs, io::Write, path::Path};
|
||||
type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
|
||||
|
||||
fn id(alias: &str) -> Id {
|
||||
Id::new_v5(
|
||||
&Id::from_u128(0xc1192026_0923_5000_8000_000000002930),
|
||||
alias.as_bytes(),
|
||||
)
|
||||
}
|
||||
fn element(p: &mut Project, alias: &str, name: &str, kind: Kind, layer: Layer, parent: Option<Id>) {
|
||||
p.elements.insert(
|
||||
id(alias),
|
||||
Element {
|
||||
id: id(alias),
|
||||
alias: alias.into(),
|
||||
name: name.into(),
|
||||
description: format!("Preserve engineering record {alias}."),
|
||||
parent,
|
||||
body: ElementBody::new(kind, layer).unwrap(),
|
||||
},
|
||||
);
|
||||
}
|
||||
fn occurrence(
|
||||
p: &mut Project,
|
||||
alias: &str,
|
||||
name: &str,
|
||||
layer: Layer,
|
||||
owner: &str,
|
||||
value: OccurrenceBody,
|
||||
) {
|
||||
element(p, alias, name, Kind::Occurrence, layer, Some(id(owner)));
|
||||
let Some(SharedBody::Occurrence { body, .. }) =
|
||||
p.elements.get_mut(&id(alias)).unwrap().shared_mut()
|
||||
else {
|
||||
panic!("Occurrence body");
|
||||
};
|
||||
*body = value;
|
||||
}
|
||||
fn set_steps(p: &mut Project, owner: &str, aliases: &[&str]) {
|
||||
*p.elements.get_mut(&id(owner)).unwrap().steps_mut().unwrap() =
|
||||
aliases.iter().map(|alias| id(alias)).collect();
|
||||
}
|
||||
fn sequence_link(
|
||||
p: &mut Project,
|
||||
alias: &str,
|
||||
name: &str,
|
||||
source: &str,
|
||||
target: &str,
|
||||
) -> Result<()> {
|
||||
let old = p.relate(RelationKind::SequenceLink, name, id(source), id(target));
|
||||
reidentify(p, old, id(alias))?;
|
||||
let relation = p.relations.get_mut(&id(alias)).unwrap();
|
||||
relation.alias = alias.into();
|
||||
relation.description = format!("Preserve link {alias} exactly.");
|
||||
if let RelationBody::SequenceLink { guard, .. } = &mut relation.body {
|
||||
*guard = "ready".into();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn fixture() -> Result<Project> {
|
||||
let mut p = Project::empty();
|
||||
p.id = id("project");
|
||||
p.alias = "process_multiselect_native".into();
|
||||
p.name = "Process group native".into();
|
||||
p.description = "Select Card C then Shift-select Card A; drop after Card B. Other columns and reusable engineering records remain exact.".into();
|
||||
for (layer, prefix, owner, name, owner_kind, work_kind) in [
|
||||
(
|
||||
Layer::Oa,
|
||||
"oa",
|
||||
"oa_process",
|
||||
"01 OA group order",
|
||||
Kind::Process,
|
||||
Kind::Activity,
|
||||
),
|
||||
(
|
||||
Layer::Sa,
|
||||
"sa",
|
||||
"sa_chain",
|
||||
"SA chain - preserve",
|
||||
Kind::FunctionalChain,
|
||||
Kind::Function,
|
||||
),
|
||||
] {
|
||||
element(&mut p, owner, name, owner_kind, layer, None);
|
||||
let shared = format!("{prefix}_shared_work");
|
||||
let downstream = format!("{prefix}_downstream_work");
|
||||
element(
|
||||
&mut p,
|
||||
&shared,
|
||||
"Shared request work",
|
||||
work_kind,
|
||||
layer,
|
||||
None,
|
||||
);
|
||||
element(
|
||||
&mut p,
|
||||
&downstream,
|
||||
"Downstream verification",
|
||||
work_kind,
|
||||
layer,
|
||||
None,
|
||||
);
|
||||
for (suffix, label) in [
|
||||
("a", "Card A"),
|
||||
("u", "Downstream U"),
|
||||
("b", "Card B"),
|
||||
("v", "Downstream V"),
|
||||
("c", "Card C"),
|
||||
] {
|
||||
let body = if suffix == "b" {
|
||||
OccurrenceBody::Control {
|
||||
kind: ControlKind::Choice,
|
||||
}
|
||||
} else {
|
||||
OccurrenceBody::Action {
|
||||
action: Some(id(if suffix == "a" || suffix == "c" {
|
||||
&shared
|
||||
} else {
|
||||
&downstream
|
||||
})),
|
||||
lifeline: None,
|
||||
}
|
||||
};
|
||||
occurrence(
|
||||
&mut p,
|
||||
&format!("{prefix}_{suffix}"),
|
||||
label,
|
||||
layer,
|
||||
owner,
|
||||
body,
|
||||
);
|
||||
}
|
||||
let order: Vec<_> = ["a", "u", "b", "v", "c"]
|
||||
.map(|suffix| format!("{prefix}_{suffix}"))
|
||||
.into_iter()
|
||||
.collect();
|
||||
set_steps(
|
||||
&mut p,
|
||||
owner,
|
||||
&order.iter().map(String::as_str).collect::<Vec<_>>(),
|
||||
);
|
||||
sequence_link(
|
||||
&mut p,
|
||||
&format!("{prefix}_a_u"),
|
||||
"A to U",
|
||||
&format!("{prefix}_a"),
|
||||
&format!("{prefix}_u"),
|
||||
)?;
|
||||
sequence_link(
|
||||
&mut p,
|
||||
&format!("{prefix}_b_v"),
|
||||
"B to V",
|
||||
&format!("{prefix}_b"),
|
||||
&format!("{prefix}_v"),
|
||||
)?;
|
||||
}
|
||||
element(
|
||||
&mut p,
|
||||
"reusable",
|
||||
"Reusable request detail",
|
||||
Kind::Process,
|
||||
Layer::Oa,
|
||||
None,
|
||||
);
|
||||
occurrence(
|
||||
&mut p,
|
||||
"reused_leaf",
|
||||
"Shared detail",
|
||||
Layer::Oa,
|
||||
"reusable",
|
||||
OccurrenceBody::Action {
|
||||
action: Some(id("oa_shared_work")),
|
||||
lifeline: None,
|
||||
},
|
||||
);
|
||||
set_steps(&mut p, "reusable", &["reused_leaf"]);
|
||||
element(
|
||||
&mut p,
|
||||
"reuse_trial",
|
||||
"02 Read-only preview trial",
|
||||
Kind::Process,
|
||||
Layer::Oa,
|
||||
None,
|
||||
);
|
||||
for (alias, name) in [
|
||||
("reference_one", "First use"),
|
||||
("reference_two", "Second use"),
|
||||
] {
|
||||
occurrence(
|
||||
&mut p,
|
||||
alias,
|
||||
name,
|
||||
Layer::Oa,
|
||||
"reuse_trial",
|
||||
OccurrenceBody::SequenceReference {
|
||||
sequence: Some(id("reusable")),
|
||||
},
|
||||
);
|
||||
}
|
||||
set_steps(&mut p, "reuse_trial", &["reference_one", "reference_two"]);
|
||||
p.traces.insert(
|
||||
id("trace"),
|
||||
Trace {
|
||||
id: id("trace"),
|
||||
alias: "preserved_work_refinement".into(),
|
||||
name: "Shared request refinement".into(),
|
||||
description: "Process arrangement must preserve cross-layer correspondence.".into(),
|
||||
oa: id("oa_shared_work"),
|
||||
sa: id("sa_shared_work"),
|
||||
kind: TraceKind::Refinement,
|
||||
baseline: Some(TraceBaseline {
|
||||
oa: p.properties(id("oa_shared_work")).unwrap(),
|
||||
sa: p.properties(id("sa_shared_work")).unwrap(),
|
||||
semantic: None,
|
||||
}),
|
||||
},
|
||||
);
|
||||
validate_candidate(&p)?;
|
||||
Ok(p)
|
||||
}
|
||||
fn load(path: &Path) -> Result<Project> {
|
||||
if path
|
||||
.extension()
|
||||
.is_some_and(|ext| ext.eq_ignore_ascii_case("arcadia"))
|
||||
{
|
||||
let disk = source_files::load_project(path, ProjectLimits::default())?;
|
||||
if disk
|
||||
.diagnostics
|
||||
.iter()
|
||||
.any(|d| d.diagnostic.severity == validation::Severity::Error)
|
||||
{
|
||||
return Err(format!("Source discovery diagnostics: {:?}", disk.diagnostics).into());
|
||||
}
|
||||
let compiled = compile_bundle(&disk.reachable_bundle(), ProjectLimits::default())
|
||||
.map_err(|e| format!("Source compile diagnostics: {e:?}"))?;
|
||||
Ok(compiled.decoded.project)
|
||||
} else {
|
||||
Ok(persistence::load(path)?)
|
||||
}
|
||||
}
|
||||
fn equal(expected: &Project, actual: &Project) -> Result<()> {
|
||||
if expected == actual {
|
||||
return Ok(());
|
||||
}
|
||||
let expected = serde_json::to_value(expected)?;
|
||||
let actual = serde_json::to_value(actual)?;
|
||||
let fields: Vec<_> = expected
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.keys()
|
||||
.filter(|key| expected[*key] != actual[*key])
|
||||
.cloned()
|
||||
.collect();
|
||||
Err(format!("Whole Project mismatch in {fields:?}; no partial PASS").into())
|
||||
}
|
||||
fn roundtrip(p: &Project) -> Result<SourceBundle> {
|
||||
validate_candidate(p)?;
|
||||
equal(p, &persistence::decode(&serde_json::to_vec(p)?)?)?;
|
||||
let bundle = format_bundle(p);
|
||||
let compiled = compile_bundle(&bundle, ProjectLimits::default())
|
||||
.map_err(|e| format!("Roundtrip: {e:?}"))?;
|
||||
equal(p, &compiled.decoded.project)?;
|
||||
Ok(bundle)
|
||||
}
|
||||
fn write_new(path: &Path, bytes: &[u8]) -> Result<()> {
|
||||
fs::create_dir_all(path.parent().ok_or("Missing parent directory")?)?;
|
||||
fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(path)?
|
||||
.write_all(bytes)?;
|
||||
Ok(())
|
||||
}
|
||||
fn generate(out: &Path) -> Result<()> {
|
||||
if out.exists() {
|
||||
return Err("Output directory already exists; choose a fresh path".into());
|
||||
}
|
||||
let p = fixture()?;
|
||||
let bundle = roundtrip(&p)?;
|
||||
write_new(&out.join("baseline.json"), &serde_json::to_vec_pretty(&p)?)?;
|
||||
for (path, source) in &bundle.files {
|
||||
write_new(&out.join("source").join(path), source.as_bytes())?;
|
||||
}
|
||||
let ids: std::collections::BTreeMap<_, _> = [
|
||||
"project",
|
||||
"oa_process",
|
||||
"oa_a",
|
||||
"oa_u",
|
||||
"oa_b",
|
||||
"oa_v",
|
||||
"oa_c",
|
||||
"oa_a_u",
|
||||
"oa_b_v",
|
||||
"oa_shared_work",
|
||||
"oa_downstream_work",
|
||||
"sa_chain",
|
||||
"sa_shared_work",
|
||||
"sa_downstream_work",
|
||||
"sa_a",
|
||||
"sa_u",
|
||||
"sa_b",
|
||||
"sa_v",
|
||||
"sa_c",
|
||||
"sa_a_u",
|
||||
"sa_b_v",
|
||||
"trace",
|
||||
"reusable",
|
||||
"reused_leaf",
|
||||
"reuse_trial",
|
||||
"reference_one",
|
||||
"reference_two",
|
||||
]
|
||||
.into_iter()
|
||||
.map(|name| (name, id(name)))
|
||||
.collect();
|
||||
write_new(
|
||||
&out.join("manifest.json"),
|
||||
&serde_json::to_vec_pretty(&serde_json::json!({
|
||||
"fixture": "process-multiselect-native-20260923", "ids": ids, "source_entry": bundle.entry,
|
||||
"original_oa_steps": ["oa_a", "oa_u", "oa_b", "oa_v", "oa_c"],
|
||||
"reordered_oa_steps": ["oa_b", "oa_u", "oa_a", "oa_v", "oa_c"],
|
||||
"gesture": "Select Card C then add Card A; Inspect Card C without changing membership, then drag Card C to After Card B",
|
||||
"fixed_slots": {"1": "oa_u", "3": "oa_v"},
|
||||
"read_only_trial": "02 Read-only preview trial has two qualified tiles of reused_leaf through distinct references",
|
||||
"receipt": "Pin baseline.json, manifest.json and generation command externally before interaction. Never replace baseline with a later saved snapshot.",
|
||||
"evaluation": "Literal expected OA steps and complete Project equality; all SA/reuse/trace records exact",
|
||||
"limits": "No pointer, focus, selection-paint, history or source-byte acceptance claim",
|
||||
}))?,
|
||||
)?;
|
||||
equal(&p, &load(&out.join("baseline.json"))?)?;
|
||||
equal(&p, &load(&out.join("source").join(&bundle.entry))?)?;
|
||||
println!("PASS generated JSON and source bundle: {}", out.display());
|
||||
Ok(())
|
||||
}
|
||||
fn check(mode: &str, baseline: &Path, saved: &Path) -> Result<()> {
|
||||
let before = load(baseline)?;
|
||||
let actual = load(saved)?;
|
||||
validate_candidate(&before)?;
|
||||
validate_candidate(&actual)?;
|
||||
// Pin the exact deterministic original, not merely a reusable project UUID.
|
||||
equal(&fixture()?, &before)
|
||||
.map_err(|e| format!("Baseline is not the immutable original: {e}"))?;
|
||||
let mut expected = before;
|
||||
match mode {
|
||||
"unchanged" => {}
|
||||
"reordered" => set_steps(
|
||||
&mut expected,
|
||||
"oa_process",
|
||||
&["oa_b", "oa_u", "oa_a", "oa_v", "oa_c"],
|
||||
),
|
||||
_ => return Err("Mode must be reordered or unchanged".into()),
|
||||
}
|
||||
equal(&expected, &actual)?;
|
||||
roundtrip(&actual)?;
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&serde_json::json!({
|
||||
"result": "PASS", "mode": mode, "baseline": baseline, "saved": saved,
|
||||
"comparison": "complete Project equality; literal expected order",
|
||||
"limits": "No pointer/action/focus/Undo/source-byte acceptance claim",
|
||||
}))?
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
fn main() -> Result<()> {
|
||||
let args: Vec<_> = std::env::args().skip(1).collect();
|
||||
match args.as_slice() {
|
||||
[command, out] if command == "generate" => generate(Path::new(out)),
|
||||
[command, mode, baseline, saved] if command == "check" => check(mode, Path::new(baseline), Path::new(saved)),
|
||||
_ => Err("Usage: process_multiselect_native generate DIRECTORY | check reordered|unchanged BASELINE SAVED".into()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
fn reordered(before: &Project) -> Project {
|
||||
let mut session = Session::new(before.clone());
|
||||
session
|
||||
.execute(Command::SetSteps(
|
||||
id("oa_process"),
|
||||
["oa_b", "oa_u", "oa_a", "oa_v", "oa_c"].map(id).to_vec(),
|
||||
))
|
||||
.unwrap();
|
||||
session.project().clone()
|
||||
}
|
||||
fn through_json(mode: &str, baseline: &Project, actual: &Project) -> Result<()> {
|
||||
validate_candidate(baseline).expect("Baseline must be valid");
|
||||
validate_candidate(actual).expect("Corruption must remain semantically valid");
|
||||
let dir = tempfile::tempdir()?;
|
||||
let before = dir.path().join("before.json");
|
||||
let saved = dir.path().join("saved.json");
|
||||
persistence::save(&before, baseline)?;
|
||||
persistence::save(&saved, actual)?;
|
||||
check(mode, &before, &saved)
|
||||
}
|
||||
fn mismatch(before: &Project, actual: &Project) {
|
||||
let error = through_json("reordered", before, actual)
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(
|
||||
error.contains("Whole Project mismatch"),
|
||||
"Unexpected rejection: {error}"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn generate_is_deterministic_create_only_and_checks_real_source() {
|
||||
equal(&fixture().unwrap(), &fixture().unwrap()).unwrap();
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let out = dir.path().join("fixture");
|
||||
generate(&out).unwrap();
|
||||
let baseline = out.join("baseline.json");
|
||||
let bytes = fs::read(&baseline).unwrap();
|
||||
check("unchanged", &baseline, &out.join("source/project.arcadia")).unwrap();
|
||||
assert!(generate(&out).is_err());
|
||||
assert_eq!(fs::read(&baseline).unwrap(), bytes);
|
||||
let invalid = dir.path().join("invalid.arcadia");
|
||||
fs::write(&invalid, "project {").unwrap();
|
||||
assert!(load(&invalid).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn real_set_steps_passes_but_unchanged_and_mutated_baseline_do_not() {
|
||||
let before = fixture().unwrap();
|
||||
let actual = reordered(&before);
|
||||
through_json("reordered", &before, &actual).unwrap();
|
||||
through_json("unchanged", &before, &before).unwrap();
|
||||
mismatch(&before, &before);
|
||||
assert!(through_json("unchanged", &before, &actual).is_err());
|
||||
let error = through_json("unchanged", &actual, &actual)
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(error.contains("immutable original"), "{error}");
|
||||
}
|
||||
#[test]
|
||||
fn global_splice_reversed_block_and_singleton_c_results_are_rejected() {
|
||||
let before = fixture().unwrap();
|
||||
// Native primary/pressed C is already after B, so a singleton C move
|
||||
// is unchanged. Group {C,A} instead puts B before both selected cards.
|
||||
// If A were primary, its singleton result could equal the group result.
|
||||
mismatch(&before, &before);
|
||||
for order in [
|
||||
["oa_u", "oa_b", "oa_a", "oa_c", "oa_v"],
|
||||
["oa_b", "oa_u", "oa_c", "oa_v", "oa_a"],
|
||||
["oa_a", "oa_u", "oa_b", "oa_c", "oa_v"],
|
||||
] {
|
||||
let mut actual = before.clone();
|
||||
set_steps(&mut actual, "oa_process", &order);
|
||||
mismatch(&before, &actual);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn semantic_link_work_sa_reuse_and_trace_changes_are_rejected() {
|
||||
let before = fixture().unwrap();
|
||||
let correct = reordered(&before);
|
||||
let mutations: [fn(&mut Project); 7] = [
|
||||
|p| {
|
||||
if let RelationBody::SequenceLink { guard, .. } =
|
||||
&mut p.relations.get_mut(&id("oa_a_u")).unwrap().body
|
||||
{
|
||||
*guard = "changed".into();
|
||||
}
|
||||
},
|
||||
|p| {
|
||||
if let RelationBody::SequenceLink { target, .. } =
|
||||
&mut p.relations.get_mut(&id("oa_a_u")).unwrap().body
|
||||
{
|
||||
*target = id("oa_v");
|
||||
}
|
||||
},
|
||||
|p| {
|
||||
p.elements
|
||||
.get_mut(&id("oa_shared_work"))
|
||||
.unwrap()
|
||||
.name
|
||||
.push_str(" drift");
|
||||
},
|
||||
|p| {
|
||||
p.elements
|
||||
.get_mut(&id("sa_shared_work"))
|
||||
.unwrap()
|
||||
.description
|
||||
.push_str(" drift");
|
||||
},
|
||||
|p| {
|
||||
set_steps(p, "sa_chain", &["sa_b", "sa_u", "sa_a", "sa_v", "sa_c"]);
|
||||
},
|
||||
|p| {
|
||||
p.elements
|
||||
.get_mut(&id("reused_leaf"))
|
||||
.unwrap()
|
||||
.description
|
||||
.push_str(" drift");
|
||||
},
|
||||
|p| {
|
||||
p.traces.get_mut(&id("trace")).unwrap().baseline = None;
|
||||
},
|
||||
];
|
||||
for mutate in mutations {
|
||||
let mut bad = correct.clone();
|
||||
mutate(&mut bad);
|
||||
mismatch(&before, &bad);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn changed_source_roundtrip_preserves_both_layers_and_checker_rejects_source_drift() {
|
||||
let before = fixture().unwrap();
|
||||
let actual = reordered(&before);
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let baseline = dir.path().join("baseline.json");
|
||||
persistence::save(&baseline, &before).unwrap();
|
||||
for corrupt in [false, true] {
|
||||
let mut saved = actual.clone();
|
||||
if corrupt {
|
||||
saved
|
||||
.elements
|
||||
.get_mut(&id("sa_c"))
|
||||
.unwrap()
|
||||
.name
|
||||
.push_str(" drift");
|
||||
}
|
||||
let bundle = roundtrip(&saved).unwrap();
|
||||
let folder = dir.path().join(if corrupt { "corrupt" } else { "saved" });
|
||||
for (path, text) in &bundle.files {
|
||||
write_new(&folder.join(path), text.as_bytes()).unwrap();
|
||||
}
|
||||
let parsed = load(&folder.join(&bundle.entry)).unwrap();
|
||||
assert_eq!(parsed.elements[&id("oa_process")].kind(), Kind::Process);
|
||||
assert_eq!(
|
||||
parsed.elements[&id("sa_chain")].kind(),
|
||||
Kind::FunctionalChain
|
||||
);
|
||||
let result = check("reordered", &baseline, &folder.join(&bundle.entry));
|
||||
if corrupt {
|
||||
assert!(
|
||||
result
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("Whole Project mismatch")
|
||||
);
|
||||
} else {
|
||||
result.unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -230,7 +230,7 @@ fn fixture() -> (Project, Id, Vec<Id>, Vec<Id>, Vec<Id>) {
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
p.diagrams.insert(diagram, Diagram { id: diagram, alias: "forty_exchange_view".into(), name: "Forty service exchanges · all concerns".into(), description: "Start with All. Compare first category, overlapping concerns, global coverage and chosen processes. No hand-routed edges.".into(), layer: Layer::Oa, projection: ProjectionKind::FunctionalDataflow, view_options: ViewOptions::default(), nodes, layout: None, routes: vec![] });
|
||||
p.diagrams.insert(diagram, Diagram { id: diagram, alias: "forty_exchange_view".into(), name: "Forty service exchanges · all concerns".into(), description: "Start with All. Compare first category, overlapping concerns, global coverage and chosen processes. No hand-routed edges.".into(), layer: Layer::Oa, projection: ProjectionKind::FunctionalDataflow, view_options: ViewOptions::default(), nodes, routes: vec![] });
|
||||
(p, diagram, exchanges, categories, contexts)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
//! Read-only saved-State geometry evidence for a predeclared native bend trial.
|
||||
//!
|
||||
//! Usage:
|
||||
//! state_bend_routing_snapshot INPUT.arcadia NEW_OUTPUT.json
|
||||
//!
|
||||
//! Loads and validates the complete Project, then independently projects/routes
|
||||
//! every saved State view through core APIs. The complete Project permits exact
|
||||
//! semantic and presentation comparison; derived geometry permits declaring a
|
||||
//! segment and insertion position before native actions. No desktop command,
|
||||
//! planner, gesture, or pin insertion helper is called. Input is never written;
|
||||
//! output uses create_new and fails rather than overwriting an existing file.
|
||||
//!
|
||||
//! Limits: this shares production core routing, so it is not an independent
|
||||
//! mathematical test of that router, nor evidence of UI picking/gesture success.
|
||||
//! It does not generate expected mutations or adapt an oracle to observed edits.
|
||||
//! The acceptance recipe must declare the intended segment/index/point and exact
|
||||
//! Project mutation before UI execution, and bind this executable/source/input
|
||||
//! and output hashes externally. Warning strings are diagnostic Debug output,
|
||||
//! not a stable interchange schema. Missing/hidden/internal transitions have no
|
||||
//! routed edge; their semantic records remain in the complete Project snapshot.
|
||||
|
||||
use oa_core::{persistence, projection, routing, validate_candidate};
|
||||
use serde_json::json;
|
||||
use std::{fs::OpenOptions, io::Write, path::Path};
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let args: Vec<_> = std::env::args_os().skip(1).collect();
|
||||
if args.len() != 2 {
|
||||
return Err("Expected input source/JSON and a new output JSON path".into());
|
||||
}
|
||||
let project = persistence::load(Path::new(&args[0]))?;
|
||||
validate_candidate(&project)?;
|
||||
let mut views = Vec::new();
|
||||
for (diagram_id, diagram) in &project.diagrams {
|
||||
let Some(machine) = diagram.projection.state_machine() else {
|
||||
continue;
|
||||
};
|
||||
let projected = projection::project_diagram(&project, diagram);
|
||||
let routed = routing::route_diagram(&project, diagram, &projected);
|
||||
let edges: Vec<_> = routed
|
||||
.edges
|
||||
.iter()
|
||||
.map(|(edge_id, edge)| {
|
||||
json!({
|
||||
"edge": edge_id,
|
||||
"id": edge.id,
|
||||
"points": edge.points,
|
||||
"pins": edge.pins,
|
||||
"pin_fractions": edge.pin_fractions,
|
||||
"custom_suspended": edge.custom_suspended,
|
||||
"warnings": edge.warnings.iter().map(|warning| format!("{warning:?}")).collect::<Vec<_>>(),
|
||||
"label": {
|
||||
"text": edge.label.text,
|
||||
"fraction": edge.label.fraction,
|
||||
"anchor": edge.label.anchor,
|
||||
"center": edge.label.center,
|
||||
"bounds": {
|
||||
"position": edge.label.bounds.position,
|
||||
"size": edge.label.bounds.size
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
views.push(json!({
|
||||
"diagram": diagram_id,
|
||||
"machine": machine,
|
||||
"projected": projected,
|
||||
"routed_bounds": {
|
||||
"position": routed.bounds.position,
|
||||
"size": routed.bounds.size
|
||||
},
|
||||
"routed_edges": edges
|
||||
}));
|
||||
}
|
||||
// Preserve the canonical typed Project serialization (including f32
|
||||
// formatting). Serializing it through Value directly promotes f32 to f64.
|
||||
let project_json: serde_json::Value = serde_json::from_slice(&serde_json::to_vec(&project)?)?;
|
||||
let snapshot = json!({
|
||||
"schema": "state-bend-routing-snapshot/v1",
|
||||
"project": project_json,
|
||||
"state_views": views
|
||||
});
|
||||
// Finish all reading, validation, routing and serialization before creating
|
||||
// the new evidence file. A failed load cannot leave an apparent snapshot.
|
||||
let mut bytes = serde_json::to_vec_pretty(&snapshot)?;
|
||||
bytes.push(b'\n');
|
||||
let mut output = OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(&args[1])?;
|
||||
output.write_all(&bytes)?;
|
||||
output.sync_all()?;
|
||||
println!(
|
||||
"Validated {} objects and {} saved views; snapshotted {} State views and the complete Project",
|
||||
project.identities().len(),
|
||||
project.diagrams.len(),
|
||||
views.len()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,911 @@
|
||||
//! Deterministic persisted-State native fixture and complete-Project checker.
|
||||
//! This invariant evaluator derives delta from saved A, NOT from pointer input.
|
||||
use oa_core::{source_project::*, *};
|
||||
use std::{fs, io::Write, path::Path};
|
||||
type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
|
||||
|
||||
fn id(alias: &str) -> Id {
|
||||
// Dedicated State view is the smallest diagram UUID, for native opening.
|
||||
if alias == "state_view" {
|
||||
return Id::from_u128(0x29_0101);
|
||||
}
|
||||
Id::new_v5(
|
||||
&Id::from_u128(0xc1192026_0923_5000_8000_000000002929),
|
||||
alias.as_bytes(),
|
||||
)
|
||||
}
|
||||
fn point(x: f32, y: f32) -> Position {
|
||||
Position { x, y }
|
||||
}
|
||||
fn element(p: &mut Project, alias: &str, name: &str, kind: Kind, layer: Layer, parent: Option<Id>) {
|
||||
p.elements.insert(
|
||||
id(alias),
|
||||
Element {
|
||||
id: id(alias),
|
||||
alias: alias.into(),
|
||||
name: name.into(),
|
||||
description: format!("Preserve engineering record {alias}."),
|
||||
parent,
|
||||
body: ElementBody::new(kind, layer).unwrap(),
|
||||
},
|
||||
);
|
||||
}
|
||||
fn diagram(p: &mut Project, alias: &str, name: &str, layer: Layer) {
|
||||
p.diagrams.insert(
|
||||
id(alias),
|
||||
Diagram {
|
||||
id: id(alias),
|
||||
alias: alias.into(),
|
||||
name: name.into(),
|
||||
description: "Selection changes placement geometry only.".into(),
|
||||
layer,
|
||||
projection: ProjectionKind::Overview,
|
||||
view_options: Default::default(),
|
||||
nodes: Default::default(),
|
||||
routes: vec![],
|
||||
},
|
||||
);
|
||||
}
|
||||
fn place(p: &mut Project, view: &str, alias: &str, object: &str, position: Position, size: Size) {
|
||||
p.diagrams.get_mut(&id(view)).unwrap().nodes.insert(
|
||||
id(alias),
|
||||
NodePlacement {
|
||||
id: id(alias),
|
||||
alias: alias.into(),
|
||||
element: id(object),
|
||||
position,
|
||||
size,
|
||||
collapsed: false,
|
||||
port_anchors: vec![],
|
||||
},
|
||||
);
|
||||
}
|
||||
fn route(edge: &str, pins: Vec<Position>) -> EdgeRoute {
|
||||
EdgeRoute {
|
||||
edge: id(edge),
|
||||
waypoints: pins,
|
||||
source_attachment: Some(EdgeAttachment {
|
||||
side: PortAnchorSide::Right,
|
||||
offset: 0.25,
|
||||
}),
|
||||
target_attachment: Some(EdgeAttachment {
|
||||
side: PortAnchorSide::Left,
|
||||
offset: 0.7,
|
||||
}),
|
||||
label: Some(EdgeLabel {
|
||||
fraction: 0.45,
|
||||
offset: point(7., -19.),
|
||||
}),
|
||||
}
|
||||
}
|
||||
fn transition(
|
||||
p: &mut Project,
|
||||
alias: &str,
|
||||
name: &str,
|
||||
source: &str,
|
||||
target: &str,
|
||||
kind: TransitionKind,
|
||||
) -> Result<()> {
|
||||
let old = p.relate(RelationKind::StateTransition, name, id(source), id(target));
|
||||
reidentify(p, old, id(alias))?;
|
||||
let relation = p.relations.get_mut(&id(alias)).unwrap();
|
||||
relation.alias = alias.into();
|
||||
relation.description = format!("Preserve transition {alias}.");
|
||||
if let RelationBody::StateTransition {
|
||||
transition_kind,
|
||||
guard,
|
||||
..
|
||||
} = &mut relation.body
|
||||
{
|
||||
*transition_kind = kind;
|
||||
*guard = if alias == "between" {
|
||||
"request_ready"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
.into();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn fixture() -> Result<Project> {
|
||||
let mut p = Project::empty();
|
||||
p.id = id("project");
|
||||
p.alias = "state_multiselect_native".into();
|
||||
p.name = "State group native".into();
|
||||
p.description = "Shift-select A and B; move their placements together. C and shared engineering records stay fixed.".into();
|
||||
for (alias, name, kind, parent) in [
|
||||
("crew", "Operations crew", Kind::Entity, None),
|
||||
(
|
||||
"machine",
|
||||
"Request lifecycle",
|
||||
Kind::StateMachine,
|
||||
Some(id("crew")),
|
||||
),
|
||||
("region", "Main region", Kind::Region, Some(id("machine"))),
|
||||
("state_a", "A - Processing", Kind::State, Some(id("region"))),
|
||||
("state_b", "B - Ready", Kind::State, Some(id("region"))),
|
||||
("state_c", "C - Complete", Kind::State, Some(id("region"))),
|
||||
(
|
||||
"inner",
|
||||
"A nested region",
|
||||
Kind::Region,
|
||||
Some(id("state_a")),
|
||||
),
|
||||
(
|
||||
"state_leaf",
|
||||
"A.1 - Classify",
|
||||
Kind::State,
|
||||
Some(id("inner")),
|
||||
),
|
||||
("entry", "Enter A", Kind::EntryPoint, Some(id("inner"))),
|
||||
("exit", "Leave A", Kind::ExitPoint, Some(id("inner"))),
|
||||
("shared_work", "Record request", Kind::Activity, None),
|
||||
] {
|
||||
element(&mut p, alias, name, kind, Layer::Oa, parent);
|
||||
}
|
||||
element(
|
||||
&mut p,
|
||||
"sa_function",
|
||||
"Technical record request",
|
||||
Kind::Function,
|
||||
Layer::Sa,
|
||||
None,
|
||||
);
|
||||
for (alias, name, source, target, kind) in [
|
||||
(
|
||||
"between",
|
||||
"Ready\nfor review",
|
||||
"state_a",
|
||||
"state_b",
|
||||
TransitionKind::External,
|
||||
),
|
||||
(
|
||||
"external",
|
||||
"Complete request",
|
||||
"state_b",
|
||||
"state_c",
|
||||
TransitionKind::External,
|
||||
),
|
||||
(
|
||||
"entry_route",
|
||||
"Begin classification",
|
||||
"entry",
|
||||
"state_leaf",
|
||||
TransitionKind::Local,
|
||||
),
|
||||
(
|
||||
"exit_route",
|
||||
"Classification complete",
|
||||
"state_leaf",
|
||||
"exit",
|
||||
TransitionKind::External,
|
||||
),
|
||||
(
|
||||
"refresh",
|
||||
"Refresh record",
|
||||
"state_a",
|
||||
"state_a",
|
||||
TransitionKind::Internal,
|
||||
),
|
||||
] {
|
||||
transition(&mut p, alias, name, source, target, kind)?;
|
||||
}
|
||||
for (alias, source, phase) in [
|
||||
("action_a", "state_a", StateActionKind::Entry),
|
||||
("action_c", "state_c", StateActionKind::Do),
|
||||
] {
|
||||
let old = p.relate(
|
||||
RelationKind::StateAction,
|
||||
"Record shared work",
|
||||
id(source),
|
||||
id("shared_work"),
|
||||
);
|
||||
reidentify(&mut p, old, id(alias))?;
|
||||
let relation = p.relations.get_mut(&id(alias)).unwrap();
|
||||
relation.alias = alias.into();
|
||||
relation.description = format!("Preserve shared-work action {alias}.");
|
||||
if let RelationBody::StateAction { phase: value, .. } = &mut relation.body {
|
||||
*value = phase;
|
||||
}
|
||||
}
|
||||
for (alias, name) in [
|
||||
("state_view", "State multiselect - main"),
|
||||
("state_other", "Other State view - preserve"),
|
||||
] {
|
||||
diagram(&mut p, alias, name, Layer::Oa);
|
||||
p.diagrams.get_mut(&id(alias)).unwrap().projection = ProjectionKind::StateMachine {
|
||||
machine: id("machine"),
|
||||
};
|
||||
}
|
||||
diagram(&mut p, "sa_view", "SA engineering - preserve", Layer::Sa);
|
||||
// The main machine and outer Region are deliberately unplaced to keep
|
||||
// the target headers readable in a 1442 x 932 native window after Fit.
|
||||
// A starts at the authored origin so saved A recovers the exact f32 delta;
|
||||
// subtracting a nonzero original can round it differently from sibling XY.
|
||||
for (alias, object, x, y, width, height) in [
|
||||
("node_a", "state_a", 0., 0., 340., 450.),
|
||||
("node_b", "state_b", 410., 30., 210., 150.),
|
||||
("node_c", "state_c", 410., 330., 210., 140.),
|
||||
("node_inner", "inner", 25., 220., 290., 200.),
|
||||
("node_leaf", "state_leaf", 60., 290., 180., 90.),
|
||||
("node_entry", "entry", -210., 280., 24., 24.),
|
||||
("node_exit", "exit", 870., 350., 24., 24.),
|
||||
] {
|
||||
place(
|
||||
&mut p,
|
||||
"state_view",
|
||||
alias,
|
||||
object,
|
||||
point(x, y),
|
||||
Size { width, height },
|
||||
);
|
||||
}
|
||||
let size = Size {
|
||||
width: 224.,
|
||||
height: 94.,
|
||||
};
|
||||
place(
|
||||
&mut p,
|
||||
"state_other",
|
||||
"other_a",
|
||||
"state_a",
|
||||
point(40., 60.),
|
||||
Size {
|
||||
width: 340.,
|
||||
height: 220.,
|
||||
},
|
||||
);
|
||||
place(
|
||||
&mut p,
|
||||
"state_other",
|
||||
"other_b",
|
||||
"state_b",
|
||||
point(460., 80.),
|
||||
size,
|
||||
);
|
||||
place(
|
||||
&mut p,
|
||||
"sa_view",
|
||||
"sa_node",
|
||||
"sa_function",
|
||||
point(100., 110.),
|
||||
size,
|
||||
);
|
||||
p.diagrams.get_mut(&id("state_view")).unwrap().routes = vec![
|
||||
route("between", vec![point(365., 70.), point(365., 120.)]),
|
||||
route("external", vec![point(660., 140.), point(660., 360.)]),
|
||||
route("entry_route", vec![point(40., 280.), point(40., 320.)]),
|
||||
route("exit_route", vec![point(290., 335.), point(290., 370.)]),
|
||||
];
|
||||
p.traces.insert(
|
||||
id("trace"),
|
||||
Trace {
|
||||
id: id("trace"),
|
||||
alias: "preserved_refinement".into(),
|
||||
name: "Reviewed recording refinement".into(),
|
||||
description:
|
||||
"Moving states or hiding placements must preserve shared work and its refinement."
|
||||
.into(),
|
||||
oa: id("shared_work"),
|
||||
sa: id("sa_function"),
|
||||
kind: TraceKind::Refinement,
|
||||
baseline: Some(TraceBaseline {
|
||||
oa: p.properties(id("shared_work")).unwrap(),
|
||||
sa: p.properties(id("sa_function")).unwrap(),
|
||||
semantic: None,
|
||||
}),
|
||||
},
|
||||
);
|
||||
validate_candidate(&p)?;
|
||||
Ok(p)
|
||||
}
|
||||
fn load(path: &Path) -> Result<Project> {
|
||||
if path
|
||||
.extension()
|
||||
.is_some_and(|ext| ext.eq_ignore_ascii_case("arcadia"))
|
||||
{
|
||||
let disk = source_files::load_project(path, ProjectLimits::default())?;
|
||||
// This deliberately partial engineering fixture has completeness
|
||||
// warnings (allocation/behavior context). They are not source errors.
|
||||
// Compilation, candidate validation and whole-Project equality still
|
||||
// reject malformed source or any unexpected model change.
|
||||
if disk
|
||||
.diagnostics
|
||||
.iter()
|
||||
.any(|d| d.diagnostic.severity == validation::Severity::Error)
|
||||
{
|
||||
return Err(format!("Source discovery diagnostics: {:?}", disk.diagnostics).into());
|
||||
}
|
||||
let compiled = compile_bundle(&disk.reachable_bundle(), ProjectLimits::default())
|
||||
.map_err(|e| format!("Source compile diagnostics: {e:?}"))?;
|
||||
Ok(compiled.decoded.project)
|
||||
} else {
|
||||
Ok(persistence::load(path)?)
|
||||
}
|
||||
}
|
||||
fn equal(expected: &Project, actual: &Project) -> Result<()> {
|
||||
if expected == actual {
|
||||
return Ok(());
|
||||
}
|
||||
let e = serde_json::to_value(expected)?;
|
||||
let a = serde_json::to_value(actual)?;
|
||||
let fields: Vec<_> = e
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.keys()
|
||||
.filter(|key| e[*key] != a[*key])
|
||||
.cloned()
|
||||
.collect();
|
||||
Err(format!("Whole Project mismatch in {fields:?}; no partial PASS").into())
|
||||
}
|
||||
fn roundtrip(p: &Project) -> Result<SourceBundle> {
|
||||
validate_candidate(p)?;
|
||||
equal(p, &persistence::decode(&serde_json::to_vec(p)?)?)?;
|
||||
let bundle = format_bundle(p);
|
||||
let compiled = compile_bundle(&bundle, ProjectLimits::default())
|
||||
.map_err(|e| format!("Roundtrip: {e:?}"))?;
|
||||
equal(p, &compiled.decoded.project)?;
|
||||
Ok(bundle)
|
||||
}
|
||||
fn write_new(path: &Path, bytes: &[u8]) -> Result<()> {
|
||||
fs::create_dir_all(path.parent().ok_or("Missing parent directory")?)?;
|
||||
fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(path)?
|
||||
.write_all(bytes)?;
|
||||
Ok(())
|
||||
}
|
||||
fn generate(out: &Path) -> Result<()> {
|
||||
if out.exists() {
|
||||
return Err("Output directory already exists; choose a fresh path".into());
|
||||
}
|
||||
let p = fixture()?;
|
||||
let bundle = roundtrip(&p)?;
|
||||
write_new(&out.join("baseline.json"), &serde_json::to_vec_pretty(&p)?)?;
|
||||
for (path, source) in &bundle.files {
|
||||
write_new(&out.join("source").join(path), source.as_bytes())?;
|
||||
}
|
||||
let names = [
|
||||
"crew",
|
||||
"machine",
|
||||
"region",
|
||||
"inner",
|
||||
"entry",
|
||||
"exit",
|
||||
"shared_work",
|
||||
"node_inner",
|
||||
"node_entry",
|
||||
"node_exit",
|
||||
"entry_route",
|
||||
"exit_route",
|
||||
"refresh",
|
||||
"action_a",
|
||||
"action_c",
|
||||
"other_b",
|
||||
"project",
|
||||
"state_view",
|
||||
"state_other",
|
||||
"sa_view",
|
||||
"state_a",
|
||||
"state_b",
|
||||
"state_c",
|
||||
"state_leaf",
|
||||
"node_a",
|
||||
"node_b",
|
||||
"node_c",
|
||||
"node_leaf",
|
||||
"between",
|
||||
"external",
|
||||
"trace",
|
||||
"sa_function",
|
||||
"sa_node",
|
||||
"other_a",
|
||||
];
|
||||
let ids: std::collections::BTreeMap<_, _> =
|
||||
names.into_iter().map(|name| (name, id(name))).collect();
|
||||
write_new(
|
||||
&out.join("manifest.json"),
|
||||
&serde_json::to_vec_pretty(&serde_json::json!({
|
||||
"fixture": "state-multiselect-native-20260923", "ids": ids, "source_entry": bundle.entry,
|
||||
"move_members": ["node_a", "node_b", "node_inner", "node_leaf", "node_entry", "node_exit"],
|
||||
"translated_routes": ["between", "entry_route", "exit_route"],
|
||||
"fixed_route": "external",
|
||||
"window": [1442, 932],
|
||||
"target_headers_world": {"A": [40, 35], "B": [450, 65]},
|
||||
"delta_limits": "finite, nonzero length >=0.01, each axis <=300; no grid quantization",
|
||||
"receipt": "Pin baseline.json, manifest.json and generation command externally before interaction; baseline identity alone does not authenticate evidence", "fixed_member": "node_c",
|
||||
"evaluation": "Whole Project invariant comparison; saved A supplies bounded shared delta, not an exact pointer oracle",
|
||||
"hide": "Remove node_a/node_b only; retain nested Region/leaf/entry/exit, stored routes, all model records and other views",
|
||||
}))?,
|
||||
)?;
|
||||
equal(&p, &load(&out.join("baseline.json"))?)?;
|
||||
equal(&p, &load(&out.join("source/project.arcadia"))?)?;
|
||||
println!("PASS generated JSON and source bundle: {}", out.display());
|
||||
Ok(())
|
||||
}
|
||||
fn check(mode: &str, baseline: &Path, saved: &Path) -> Result<()> {
|
||||
let before = load(baseline)?;
|
||||
let actual = load(saved)?;
|
||||
validate_candidate(&before)?;
|
||||
validate_candidate(&actual)?;
|
||||
if before.id != id("project") {
|
||||
return Err("Not this fixture's baseline project".into());
|
||||
}
|
||||
let mut expected = before.clone();
|
||||
let mut delta = None;
|
||||
match mode {
|
||||
"unchanged" => {}
|
||||
"hide" => {
|
||||
let view = expected
|
||||
.diagrams
|
||||
.get_mut(&id("state_view"))
|
||||
.ok_or("Missing State view")?;
|
||||
for alias in ["node_a", "node_b"] {
|
||||
view.nodes
|
||||
.remove(&id(alias))
|
||||
.ok_or("Baseline selected placement missing")?;
|
||||
}
|
||||
// references::remove_objects removes routes only when their semantic
|
||||
// edge is deleted. Hide deletes placements; both routes MUST survive.
|
||||
}
|
||||
"moved" => {
|
||||
let old = before
|
||||
.diagrams
|
||||
.get(&id("state_view"))
|
||||
.ok_or("Missing baseline State view")?;
|
||||
let new = actual
|
||||
.diagrams
|
||||
.get(&id("state_view"))
|
||||
.ok_or("Missing saved State view")?;
|
||||
let a = old
|
||||
.nodes
|
||||
.get(&id("node_a"))
|
||||
.ok_or("Missing baseline A")?
|
||||
.position;
|
||||
let moved_a = new
|
||||
.nodes
|
||||
.get(&id("node_a"))
|
||||
.ok_or("Missing saved A")?
|
||||
.position;
|
||||
let d = point(moved_a.x - a.x, moved_a.y - a.y);
|
||||
if !d.x.is_finite()
|
||||
|| !d.y.is_finite()
|
||||
|| d.x.abs().max(d.y.abs()) > 300.
|
||||
|| d.x.hypot(d.y) < 0.01
|
||||
{
|
||||
return Err(format!(
|
||||
"Move delta must have length >=0.01 and each axis <=300 world units: {d:?}"
|
||||
)
|
||||
.into());
|
||||
}
|
||||
let view = expected.diagrams.get_mut(&id("state_view")).unwrap();
|
||||
// Independent explicit fixture enumeration: no group/descendant/
|
||||
// routing planner is called to predict the result.
|
||||
for alias in [
|
||||
"node_a",
|
||||
"node_b",
|
||||
"node_inner",
|
||||
"node_leaf",
|
||||
"node_entry",
|
||||
"node_exit",
|
||||
] {
|
||||
let node = view
|
||||
.nodes
|
||||
.get_mut(&id(alias))
|
||||
.ok_or("Missing expected moved placement")?;
|
||||
node.position.x += d.x;
|
||||
node.position.y += d.y;
|
||||
}
|
||||
for alias in ["between", "entry_route", "exit_route"] {
|
||||
let route = view
|
||||
.routes
|
||||
.iter_mut()
|
||||
.find(|route| route.edge == id(alias))
|
||||
.ok_or("Missing explicitly expected moved route")?;
|
||||
for pin in &mut route.waypoints {
|
||||
pin.x += d.x;
|
||||
pin.y += d.y;
|
||||
}
|
||||
}
|
||||
delta = Some(d);
|
||||
}
|
||||
_ => return Err("Mode must be moved, hide, or unchanged".into()),
|
||||
}
|
||||
equal(&expected, &actual)?;
|
||||
roundtrip(&actual)?;
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&serde_json::json!({
|
||||
"result": "PASS", "mode": mode, "baseline": baseline, "saved": saved,
|
||||
"comparison": "complete Project equality", "inferred_common_delta": delta,
|
||||
"limits": "Invariant evaluator only; no pointer/action/focus/Undo/source-byte acceptance claim",
|
||||
}))?
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
fn main() -> Result<()> {
|
||||
let args: Vec<_> = std::env::args().skip(1).collect();
|
||||
match args.as_slice() {
|
||||
[command, out] if command == "generate" => generate(Path::new(out)),
|
||||
[command, mode, baseline, saved] if command == "check" => check(mode, Path::new(baseline), Path::new(saved)),
|
||||
_ => Err("Usage: state_multiselect_native generate DIRECTORY | check moved|hide|unchanged BASELINE SAVED".into()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
// Produce evidence through real validated commands; the checker itself uses
|
||||
// explicit fixture enumeration and never calls this movement implementation.
|
||||
fn moved(before: &Project, delta: Position) -> Project {
|
||||
let command = diagram_edit::translate_placements(
|
||||
before,
|
||||
id("state_view"),
|
||||
&BTreeSet::from([id("node_a"), id("node_b")]),
|
||||
delta,
|
||||
)
|
||||
.unwrap();
|
||||
let mut session = Session::new(before.clone());
|
||||
session.execute(command).unwrap();
|
||||
session.project().clone()
|
||||
}
|
||||
fn hidden(before: &Project) -> Project {
|
||||
let mut session = Session::new(before.clone());
|
||||
session
|
||||
.execute(Command::Batch(vec![
|
||||
Command::Delete(id("node_a")),
|
||||
Command::Delete(id("node_b")),
|
||||
]))
|
||||
.unwrap();
|
||||
session.project().clone()
|
||||
}
|
||||
fn through_saved_json(mode: &str, baseline: &Project, actual: &Project) -> Result<()> {
|
||||
let dir = tempfile::tempdir()?;
|
||||
let before = dir.path().join("before.json");
|
||||
let saved = dir.path().join("saved.json");
|
||||
// These assertions distinguish meaningful invariant corruptions from
|
||||
// malformed projects rejected before the evaluator even sees them.
|
||||
validate_candidate(baseline).expect("test baseline must be valid");
|
||||
validate_candidate(actual).expect("corruption must remain a valid model");
|
||||
persistence::save(&before, baseline)?;
|
||||
persistence::save(&saved, actual)?;
|
||||
check(mode, &before, &saved)
|
||||
}
|
||||
fn mismatch(mode: &str, before: &Project, actual: &Project, case: &str) {
|
||||
let error = through_saved_json(mode, before, actual)
|
||||
.expect_err(case)
|
||||
.to_string();
|
||||
assert!(
|
||||
error.contains("Whole Project mismatch"),
|
||||
"{case}: unexpected rejection: {error}"
|
||||
);
|
||||
}
|
||||
fn edge<'a>(p: &'a mut Project, alias: &str) -> &'a mut EdgeRoute {
|
||||
p.diagrams
|
||||
.get_mut(&id("state_view"))
|
||||
.unwrap()
|
||||
.routes
|
||||
.iter_mut()
|
||||
.find(|route| route.edge == id(alias))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_json_and_real_source_bundle_are_accepted_unchanged() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let out = dir.path().join("fixture");
|
||||
generate(&out).unwrap();
|
||||
let baseline = out.join("baseline.json");
|
||||
let saved = out.join("saved.json");
|
||||
persistence::save(&saved, &fixture().unwrap()).unwrap();
|
||||
check("unchanged", &baseline, &saved).unwrap();
|
||||
check("unchanged", &baseline, &out.join("source/project.arcadia")).unwrap();
|
||||
let bytes = fs::read(&baseline).unwrap();
|
||||
assert!(
|
||||
generate(&out).is_err(),
|
||||
"generation cannot overwrite evidence"
|
||||
);
|
||||
assert_eq!(fs::read(&baseline).unwrap(), bytes);
|
||||
let invalid = dir.path().join("invalid.arcadia");
|
||||
fs::write(&invalid, "project {").unwrap();
|
||||
assert!(load(&invalid).is_err(), "source errors must still reject");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn real_group_command_outputs_pass_and_are_not_unchanged() {
|
||||
let before = fixture().unwrap();
|
||||
for delta in [
|
||||
point(7.25, -2.75),
|
||||
point(-20.5, 30.125),
|
||||
point(0.25, 0.),
|
||||
point(0.01, 10.),
|
||||
point(0.1, -0.2),
|
||||
] {
|
||||
let actual = moved(&before, delta);
|
||||
through_saved_json("moved", &before, &actual).unwrap();
|
||||
mismatch(
|
||||
"unchanged",
|
||||
&before,
|
||||
&actual,
|
||||
"real movement cannot count as unchanged",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn real_hide_command_outputs_pass_before_and_after_movement() {
|
||||
let initial = fixture().unwrap();
|
||||
for before in [initial.clone(), moved(&initial, point(40., 60.))] {
|
||||
let actual = hidden(&before);
|
||||
through_saved_json("hide", &before, &actual).unwrap();
|
||||
mismatch(
|
||||
"unchanged",
|
||||
&before,
|
||||
&actual,
|
||||
"real Hide cannot count as unchanged",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn omitted_and_double_moved_child_are_rejected() {
|
||||
let before = fixture().unwrap();
|
||||
let delta = point(40., 60.);
|
||||
let correct = moved(&before, delta);
|
||||
for alias in ["node_inner", "node_leaf", "node_entry", "node_exit"] {
|
||||
let initial = before.diagrams[&id("state_view")].nodes[&id(alias)].position;
|
||||
for position in [
|
||||
initial,
|
||||
point(initial.x + 2. * delta.x, initial.y + 2. * delta.y),
|
||||
] {
|
||||
let mut bad = correct.clone();
|
||||
bad.diagrams
|
||||
.get_mut(&id("state_view"))
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&id(alias))
|
||||
.unwrap()
|
||||
.position = position;
|
||||
mismatch("moved", &before, &bad, "child did not move exactly once");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn internal_pins_left_behind_and_external_pins_moved_are_rejected() {
|
||||
let mut before = fixture().unwrap();
|
||||
let correct = moved(&before, point(40., 60.));
|
||||
for alias in ["between", "entry_route", "exit_route"] {
|
||||
let original_pins = edge(&mut before, alias).waypoints.clone();
|
||||
let mut bad = correct.clone();
|
||||
edge(&mut bad, alias).waypoints = original_pins;
|
||||
mismatch("moved", &before, &bad, "internal pins left behind");
|
||||
}
|
||||
let mut bad = correct;
|
||||
for pin in &mut edge(&mut bad, "external").waypoints {
|
||||
pin.x += 40.;
|
||||
pin.y += 60.;
|
||||
}
|
||||
mismatch("moved", &before, &bad, "one-ended pins moved");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manual_attachments_and_label_data_must_remain_exact() {
|
||||
let before = fixture().unwrap();
|
||||
let correct = moved(&before, point(40., 60.));
|
||||
let mutations: [fn(&mut EdgeRoute); 4] = [
|
||||
|r| r.source_attachment.as_mut().unwrap().offset = 0.65,
|
||||
|r| r.target_attachment.as_mut().unwrap().side = PortAnchorSide::Top,
|
||||
|r| r.label.as_mut().unwrap().offset.x += 40.,
|
||||
|r| r.label.as_mut().unwrap().fraction = 0.75,
|
||||
];
|
||||
for mutate in mutations {
|
||||
let mut bad = correct.clone();
|
||||
mutate(edge(&mut bad, "between"));
|
||||
mismatch("moved", &before, &bad, "relative route metadata changed");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn semantic_other_view_fixed_node_and_trace_drift_are_rejected() {
|
||||
let before = fixture().unwrap();
|
||||
let correct = moved(&before, point(40., 60.));
|
||||
let mutations: [fn(&mut Project); 9] = [
|
||||
|p| p.traces.get_mut(&id("trace")).unwrap().baseline = None,
|
||||
|p| {
|
||||
p.elements
|
||||
.get_mut(&id("shared_work"))
|
||||
.unwrap()
|
||||
.description
|
||||
.push_str(" drift")
|
||||
},
|
||||
|p| {
|
||||
if let RelationBody::StateAction { phase, .. } =
|
||||
&mut p.relations.get_mut(&id("action_a")).unwrap().body
|
||||
{
|
||||
*phase = StateActionKind::Exit;
|
||||
}
|
||||
},
|
||||
|p| {
|
||||
if let RelationBody::StateTransition { guard, .. } =
|
||||
&mut p.relations.get_mut(&id("refresh")).unwrap().body
|
||||
{
|
||||
*guard = "changed_refresh_guard".into();
|
||||
}
|
||||
},
|
||||
|p| {
|
||||
p.elements
|
||||
.get_mut(&id("state_a"))
|
||||
.unwrap()
|
||||
.description
|
||||
.push_str(" drift")
|
||||
},
|
||||
|p| {
|
||||
p.diagrams
|
||||
.get_mut(&id("state_other"))
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&id("other_a"))
|
||||
.unwrap()
|
||||
.position
|
||||
.x += 40.
|
||||
},
|
||||
|p| {
|
||||
p.diagrams
|
||||
.get_mut(&id("sa_view"))
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&id("sa_node"))
|
||||
.unwrap()
|
||||
.position
|
||||
.y += 60.
|
||||
},
|
||||
|p| {
|
||||
p.diagrams
|
||||
.get_mut(&id("state_view"))
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&id("node_c"))
|
||||
.unwrap()
|
||||
.position
|
||||
.x += 40.
|
||||
},
|
||||
|p| {
|
||||
p.traces
|
||||
.get_mut(&id("trace"))
|
||||
.unwrap()
|
||||
.description
|
||||
.push_str(" drift")
|
||||
},
|
||||
];
|
||||
for mutate in mutations {
|
||||
let mut bad = correct.clone();
|
||||
mutate(&mut bad);
|
||||
mismatch(
|
||||
"moved",
|
||||
&before,
|
||||
&bad,
|
||||
"record outside permitted movement changed",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_and_excessive_moves_cannot_satisfy_move_evidence() {
|
||||
let before = fixture().unwrap();
|
||||
for (delta, reason) in [
|
||||
(point(0., 0.), "length >=0.01"),
|
||||
(point(310., 0.), "each axis <=300"),
|
||||
] {
|
||||
let actual = moved(&before, delta);
|
||||
let error = through_saved_json("moved", &before, &actual)
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(error.contains(reason), "{delta:?}: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hide_must_preserve_child_routes_and_semantic_objects() {
|
||||
let before = fixture().unwrap();
|
||||
let correct = hidden(&before);
|
||||
let mut missing_child = correct.clone();
|
||||
missing_child
|
||||
.diagrams
|
||||
.get_mut(&id("state_view"))
|
||||
.unwrap()
|
||||
.nodes
|
||||
.remove(&id("node_leaf"));
|
||||
mismatch(
|
||||
"hide",
|
||||
&before,
|
||||
&missing_child,
|
||||
"Hide removed unselected child placement",
|
||||
);
|
||||
let mut missing_routes = correct;
|
||||
missing_routes
|
||||
.diagrams
|
||||
.get_mut(&id("state_view"))
|
||||
.unwrap()
|
||||
.routes
|
||||
.clear();
|
||||
mismatch(
|
||||
"hide",
|
||||
&before,
|
||||
&missing_routes,
|
||||
"Hide removed persistent route records",
|
||||
);
|
||||
let mut semantic_delete = Session::new(before.clone());
|
||||
semantic_delete
|
||||
.execute(Command::Batch(vec![
|
||||
Command::Delete(id("state_a")),
|
||||
Command::Delete(id("state_b")),
|
||||
]))
|
||||
.unwrap();
|
||||
mismatch(
|
||||
"hide",
|
||||
&before,
|
||||
semantic_delete.project(),
|
||||
"Hide became semantic deletion",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unchanged_rejects_valid_semantic_drift_without_geometry_changes() {
|
||||
let before = fixture().unwrap();
|
||||
let mut actual = before.clone();
|
||||
actual
|
||||
.elements
|
||||
.get_mut(&id("state_b"))
|
||||
.unwrap()
|
||||
.name
|
||||
.push_str(" edited");
|
||||
mismatch(
|
||||
"unchanged",
|
||||
&before,
|
||||
&actual,
|
||||
"geometry equality is not complete Project equality",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moved_and_hidden_source_inputs_are_checked_against_complete_expected_projects() {
|
||||
let before = fixture().unwrap();
|
||||
for (mode, actual) in [
|
||||
("moved", moved(&before, point(7.25, -2.75))),
|
||||
("hide", hidden(&before)),
|
||||
] {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let baseline = dir.path().join("baseline.json");
|
||||
persistence::save(&baseline, &before).unwrap();
|
||||
let bundle = roundtrip(&actual).unwrap();
|
||||
for (path, source) in &bundle.files {
|
||||
write_new(&dir.path().join("saved").join(path), source.as_bytes()).unwrap();
|
||||
}
|
||||
check(
|
||||
mode,
|
||||
&baseline,
|
||||
&dir.path().join("saved").join(&bundle.entry),
|
||||
)
|
||||
.unwrap();
|
||||
let mut corrupt = actual.clone();
|
||||
corrupt.elements.get_mut(&id("shared_work")).unwrap().name =
|
||||
"Unrequested source edit".into();
|
||||
let bundle = roundtrip(&corrupt).unwrap();
|
||||
for (path, source) in &bundle.files {
|
||||
write_new(&dir.path().join("corrupt").join(path), source.as_bytes()).unwrap();
|
||||
}
|
||||
let error = check(
|
||||
mode,
|
||||
&baseline,
|
||||
&dir.path().join("corrupt").join(&bundle.entry),
|
||||
)
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(error.contains("Whole Project mismatch"), "{error}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
//! Exact numeric foundation for analysis; no model, expression, or package resolver.
|
||||
//!
|
||||
//! Values use reduced rational coordinates. All nontrivial arithmetic takes one
|
||||
//! request-local [WorkMeter]. The future evaluator must also charge *expanded*
|
||||
//! expression/call/property/list/target visits and retain the same logical walk on
|
||||
//! cache hits. Stored AST size is not a dynamic execution limit.
|
||||
//!
|
||||
//! Approximate engineering functions/irrational unit scales require a subsequent
|
||||
//! explicit numeric profile; rationals do not implement degree/radian conversion
|
||||
//! or transcendental mathematics.
|
||||
|
||||
mod dimension;
|
||||
mod exact;
|
||||
mod meter;
|
||||
mod quantity;
|
||||
|
||||
pub use dimension::{Dimension, DimensionAxis};
|
||||
pub use exact::{Exact, ExactWire};
|
||||
pub use meter::{
|
||||
EvaluationStep, NumericOperation, WorkExhaustion, WorkLimits, WorkMeter, WorkResource,
|
||||
WorkSite, WorkUsage,
|
||||
};
|
||||
pub use quantity::{NumericType, NumericValue, ResolvedKind, Unit};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
|
||||
/// Profile identity includes the accounting rules, not crate-internal timing.
|
||||
pub const WORK_PROFILE: &str = "Q1-logical-work-1";
|
||||
pub const MAX_COMPONENT_BITS: u64 = 4096;
|
||||
/// Cross products require 8192 bits; their sum can require one additional bit.
|
||||
pub const MAX_INTERMEDIATE_BITS: u64 = 8193;
|
||||
pub const MAX_COMPONENT_DIGITS: usize = 1234;
|
||||
pub const MAX_DECIMAL_EXPONENT: i32 = 128;
|
||||
pub const MAX_INTEGER_POWER: i8 = 16;
|
||||
/// Includes ratio punctuation/whitespace without permitting unbounded scanning.
|
||||
pub const MAX_LITERAL_BYTES: usize = 2 * MAX_COMPONENT_DIGITS + 32;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum NumberComponent {
|
||||
Numerator,
|
||||
Denominator,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Error, Serialize, Deserialize)]
|
||||
pub enum NumericLimit {
|
||||
#[error("request work exhausted: {0}")]
|
||||
Work(WorkExhaustion),
|
||||
#[error("{component:?} uses {bits} bits, above the {limit}-bit {stage} limit")]
|
||||
Bits {
|
||||
component: NumberComponent,
|
||||
bits: u64,
|
||||
limit: u64,
|
||||
stage: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Error, Serialize, Deserialize)]
|
||||
pub enum NumericError {
|
||||
#[error("numeric limit: {0}")]
|
||||
Limit(NumericLimit),
|
||||
#[error("invalid numeric literal")]
|
||||
InvalidLiteral,
|
||||
#[error("numeric literal exceeds its digit or byte limit")]
|
||||
LiteralTooLong,
|
||||
#[error("decimal exponent is outside -128 through 128")]
|
||||
DecimalExponent,
|
||||
#[error("wire rational is not in canonical reduced form")]
|
||||
NonCanonicalWire,
|
||||
#[error("rational denominator is zero")]
|
||||
ZeroDenominator,
|
||||
#[error("division by zero")]
|
||||
DivisionByZero,
|
||||
#[error("zero to the power zero is undefined in this profile")]
|
||||
ZeroPowerZero,
|
||||
#[error("integer power is outside -16 through 16")]
|
||||
IntegerPower,
|
||||
#[error("dimension exponent at {axis:?} is outside -32 through 32: {value}")]
|
||||
DimensionExponent { axis: DimensionAxis, value: i32 },
|
||||
#[error("unit scale must be strictly positive")]
|
||||
UnitScale,
|
||||
#[error("a linear unit must have zero offset")]
|
||||
LinearOffset,
|
||||
#[error("an affine point must have a distinct linear difference kind")]
|
||||
DifferenceKind,
|
||||
#[error("numeric kinds or dimensions are incompatible")]
|
||||
IncompatibleTypes,
|
||||
#[error("operation is not defined for affine points")]
|
||||
AffineOperation,
|
||||
#[error("AsKind requires an anonymous matching dimension and a linear target")]
|
||||
KindAssertion,
|
||||
#[error("request work limits exceed the provisional reference ceilings")]
|
||||
InvalidWorkLimits,
|
||||
}
|
||||
|
||||
impl NumericError {
|
||||
pub fn is_terminal_work_exhaustion(&self) -> bool {
|
||||
matches!(self, Self::Limit(NumericLimit::Work(_)))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,107 @@
|
||||
use super::NumericError;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum DimensionAxis {
|
||||
Length,
|
||||
Mass,
|
||||
Time,
|
||||
ElectricCurrent,
|
||||
Temperature,
|
||||
Amount,
|
||||
LuminousIntensity,
|
||||
}
|
||||
|
||||
impl DimensionAxis {
|
||||
pub const ALL: [Self; 7] = [
|
||||
Self::Length,
|
||||
Self::Mass,
|
||||
Self::Time,
|
||||
Self::ElectricCurrent,
|
||||
Self::Temperature,
|
||||
Self::Amount,
|
||||
Self::LuminousIntensity,
|
||||
];
|
||||
}
|
||||
|
||||
/// SI base-dimension vector in DimensionAxis::ALL order, each entry -32..=32.
|
||||
/// This is a dimension, never a nominal quantity-kind compatibility proof.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(try_from = "[i16; 7]", into = "[i16; 7]")]
|
||||
pub struct Dimension([i16; 7]);
|
||||
|
||||
impl Dimension {
|
||||
pub const ZERO: Self = Self([0; 7]);
|
||||
|
||||
pub fn new(exponents: [i16; 7]) -> Result<Self, NumericError> {
|
||||
for (index, value) in exponents.iter().copied().enumerate() {
|
||||
if !(-32..=32).contains(&value) {
|
||||
return Err(NumericError::DimensionExponent {
|
||||
axis: DimensionAxis::ALL[index],
|
||||
value: i32::from(value),
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(Self(exponents))
|
||||
}
|
||||
|
||||
pub fn exponents(self) -> [i16; 7] {
|
||||
self.0
|
||||
}
|
||||
|
||||
pub fn is_dimensionless(self) -> bool {
|
||||
self == Self::ZERO
|
||||
}
|
||||
|
||||
pub fn checked_product(self, rhs: Self) -> Result<Self, NumericError> {
|
||||
self.combine(rhs, 1)
|
||||
}
|
||||
|
||||
pub fn checked_quotient(self, rhs: Self) -> Result<Self, NumericError> {
|
||||
self.combine(rhs, -1)
|
||||
}
|
||||
|
||||
pub fn checked_power(self, exponent: i8) -> Result<Self, NumericError> {
|
||||
if !(-super::MAX_INTEGER_POWER..=super::MAX_INTEGER_POWER).contains(&exponent) {
|
||||
return Err(NumericError::IntegerPower);
|
||||
}
|
||||
let mut output = [0; 7];
|
||||
for (index, value) in self.0.iter().enumerate() {
|
||||
output[index] = Self::checked_exponent(index, i32::from(*value) * i32::from(exponent))?;
|
||||
}
|
||||
Ok(Self(output))
|
||||
}
|
||||
|
||||
fn combine(self, rhs: Self, sign: i32) -> Result<Self, NumericError> {
|
||||
let mut output = [0; 7];
|
||||
for (index, value) in self.0.iter().enumerate() {
|
||||
output[index] =
|
||||
Self::checked_exponent(index, i32::from(*value) + sign * i32::from(rhs.0[index]))?;
|
||||
}
|
||||
Ok(Self(output))
|
||||
}
|
||||
|
||||
fn checked_exponent(index: usize, value: i32) -> Result<i16, NumericError> {
|
||||
if !(-32..=32).contains(&value) {
|
||||
return Err(NumericError::DimensionExponent {
|
||||
axis: DimensionAxis::ALL[index],
|
||||
value,
|
||||
});
|
||||
}
|
||||
Ok(value as i16)
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<[i16; 7]> for Dimension {
|
||||
type Error = NumericError;
|
||||
|
||||
fn try_from(value: [i16; 7]) -> Result<Self, Self::Error> {
|
||||
Self::new(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Dimension> for [i16; 7] {
|
||||
fn from(value: Dimension) -> Self {
|
||||
value.0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,469 @@
|
||||
use super::meter::{normalization_cost, parse_cost, product_cost, sum_cost, words};
|
||||
use super::{
|
||||
MAX_COMPONENT_BITS, MAX_COMPONENT_DIGITS, MAX_DECIMAL_EXPONENT, MAX_INTEGER_POWER,
|
||||
MAX_INTERMEDIATE_BITS, MAX_LITERAL_BYTES, NumberComponent, NumericError, NumericLimit,
|
||||
NumericOperation, WorkLimits, WorkMeter,
|
||||
};
|
||||
use num_bigint::BigInt;
|
||||
use num_integer::Integer;
|
||||
use num_traits::{One, Signed, Zero};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::cmp::Ordering;
|
||||
|
||||
/// The interchange representation deliberately does not use num-bigint's serde
|
||||
/// format or JSON numeric values. Duplicate/missing/unknown fields reject.
|
||||
/// Its derived decoder allocates String fields before numeric validation; a
|
||||
/// transport/document adapter must bound the encoded envelope and decoded text.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ExactWire {
|
||||
pub numerator: String,
|
||||
pub denominator: String,
|
||||
}
|
||||
|
||||
/// Reduced rational with positive denominator and <=4096-bit components.
|
||||
///
|
||||
/// There are no unmetered arithmetic/ordering traits. Eq is structural equality
|
||||
/// of already normalized bounded values, not an expression comparison API.
|
||||
/// Standalone serde admission bounds the subsequent BigInt validation, not the
|
||||
/// preceding String decoding. Document adapters must bound encoded/decoded input
|
||||
/// and aggregate admission work. Evaluation uses one explicit WorkMeter.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(try_from = "ExactWire", into = "ExactWire")]
|
||||
pub struct Exact {
|
||||
numerator: BigInt,
|
||||
denominator: BigInt,
|
||||
}
|
||||
|
||||
impl Exact {
|
||||
pub fn from_integer(value: i64) -> Self {
|
||||
Self {
|
||||
numerator: BigInt::from(value),
|
||||
denominator: BigInt::one(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_zero(&self) -> bool {
|
||||
self.numerator.is_zero()
|
||||
}
|
||||
|
||||
pub fn is_positive(&self) -> bool {
|
||||
self.numerator.is_positive()
|
||||
}
|
||||
|
||||
pub fn component_bits(&self) -> (u64, u64) {
|
||||
(self.numerator.bits(), self.denominator.bits())
|
||||
}
|
||||
|
||||
pub fn to_wire(&self) -> ExactWire {
|
||||
ExactWire {
|
||||
numerator: self.numerator.to_string(),
|
||||
denominator: self.denominator.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Canonical *literal* spelling, not a generic expression formatter.
|
||||
/// Negative literal nodes use ratio(-n,d), even when d=1. A leading minus
|
||||
/// belongs to the future expression parser's UnaryNegate node instead.
|
||||
pub fn canonical_literal(&self) -> String {
|
||||
if self.denominator.is_one() && !self.numerator.is_negative() {
|
||||
self.numerator.to_string()
|
||||
} else {
|
||||
format!("ratio({}, {})", self.numerator, self.denominator)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_wire(wire: &ExactWire, meter: &mut WorkMeter) -> Result<Self, NumericError> {
|
||||
meter.ensure_active()?;
|
||||
let n_digits = wire_digits(&wire.numerator, true)?;
|
||||
let d_digits = wire_digits(&wire.denominator, false)?;
|
||||
let n_bound = decimal_bits(n_digits.len());
|
||||
let d_bound = decimal_bits(d_digits.len());
|
||||
meter.numeric(
|
||||
NumericOperation::ParseWire,
|
||||
parse_cost(n_digits.len(), n_bound)
|
||||
+ parse_cost(d_digits.len(), d_bound)
|
||||
+ normalization_cost(n_bound, d_bound),
|
||||
)?;
|
||||
let numerator = parse_integer(&wire.numerator)?;
|
||||
let denominator = parse_integer(&wire.denominator)?;
|
||||
Self::check_bits(&numerator, &denominator, MAX_COMPONENT_BITS, "wire")?;
|
||||
if denominator.is_zero() {
|
||||
return Err(NumericError::ZeroDenominator);
|
||||
}
|
||||
if !numerator.gcd(&denominator).is_one() {
|
||||
return Err(NumericError::NonCanonicalWire);
|
||||
}
|
||||
Ok(Self {
|
||||
numerator,
|
||||
denominator,
|
||||
})
|
||||
}
|
||||
|
||||
/// Reads one unsigned decimal/integer atom or a ratio(n,d) literal only.
|
||||
/// It does not parse expressions, unary operators, units, calls or names.
|
||||
/// Source ratios/decimals may reduce; wire values must already be canonical.
|
||||
pub fn parse_literal(source: &str, meter: &mut WorkMeter) -> Result<Self, NumericError> {
|
||||
meter.ensure_active()?;
|
||||
if source.len() > MAX_LITERAL_BYTES {
|
||||
return Err(NumericError::LiteralTooLong);
|
||||
}
|
||||
let source = trim_ascii(source);
|
||||
if let Some(body) = source.strip_prefix("ratio(") {
|
||||
let body = body.strip_suffix(')').ok_or(NumericError::InvalidLiteral)?;
|
||||
let (n, d) = body.split_once(',').ok_or(NumericError::InvalidLiteral)?;
|
||||
return Self::parse_ratio(trim_ascii(n), trim_ascii(d), meter);
|
||||
}
|
||||
Self::parse_decimal(source, meter)
|
||||
}
|
||||
|
||||
fn parse_ratio(n: &str, d: &str, meter: &mut WorkMeter) -> Result<Self, NumericError> {
|
||||
let n_digits = source_integer_digits(n, true)?;
|
||||
let d_digits = source_integer_digits(d, false)?;
|
||||
let n_bound = decimal_bits(n_digits.len());
|
||||
let d_bound = decimal_bits(d_digits.len());
|
||||
Self::check_estimates(n_bound, d_bound)?;
|
||||
meter.numeric(
|
||||
NumericOperation::ParseLiteral,
|
||||
parse_cost(n_digits.len(), n_bound)
|
||||
+ parse_cost(d_digits.len(), d_bound)
|
||||
+ normalization_cost(n_bound, d_bound),
|
||||
)?;
|
||||
Self::normalize_precharged(parse_integer(n)?, parse_integer(d)?)
|
||||
}
|
||||
|
||||
fn parse_decimal(source: &str, meter: &mut WorkMeter) -> Result<Self, NumericError> {
|
||||
let mut parts = source.split(['e', 'E']);
|
||||
let mantissa = parts.next().ok_or(NumericError::InvalidLiteral)?;
|
||||
let exponent = match parts.next() {
|
||||
Some(text) => parse_exponent(text)?,
|
||||
None => 0,
|
||||
};
|
||||
if parts.next().is_some() {
|
||||
return Err(NumericError::InvalidLiteral);
|
||||
}
|
||||
let (whole, fraction) = match mantissa.split_once('.') {
|
||||
Some((whole, fraction)) if !fraction.is_empty() => (whole, fraction),
|
||||
Some(_) => return Err(NumericError::InvalidLiteral),
|
||||
None => (mantissa, ""),
|
||||
};
|
||||
source_integer_digits(whole, false)?;
|
||||
if !fraction.bytes().all(|byte| byte.is_ascii_digit()) {
|
||||
return Err(NumericError::InvalidLiteral);
|
||||
}
|
||||
let digit_count = whole.len() + fraction.len();
|
||||
if digit_count > MAX_COMPONENT_DIGITS {
|
||||
return Err(NumericError::LiteralTooLong);
|
||||
}
|
||||
let adjustment = exponent - fraction.len() as i32;
|
||||
let power = adjustment.unsigned_abs();
|
||||
let mantissa_bound = decimal_bits(digit_count);
|
||||
let scale_bound = decimal_bits(power as usize + 1);
|
||||
let (n_bound, d_bound) = if adjustment >= 0 {
|
||||
(mantissa_bound + scale_bound, 1)
|
||||
} else {
|
||||
(mantissa_bound, scale_bound)
|
||||
};
|
||||
Self::check_estimates(n_bound, d_bound)?;
|
||||
// Fixed logical allowance for constructing 10^power; independent of
|
||||
// num-bigint's selected multiplication/exponentiation implementation.
|
||||
let power_steps = 1 + 2 * u64::from(u32::BITS - power.leading_zeros());
|
||||
meter.numeric(
|
||||
NumericOperation::ParseLiteral,
|
||||
parse_cost(digit_count, mantissa_bound)
|
||||
+ power_steps * product_cost(scale_bound, scale_bound)
|
||||
+ product_cost(mantissa_bound, scale_bound)
|
||||
+ normalization_cost(n_bound, d_bound),
|
||||
)?;
|
||||
let digits = format!("{whole}{fraction}");
|
||||
let mut numerator = parse_integer(&digits)?;
|
||||
let scale = BigInt::from(10_u8).pow(power);
|
||||
let denominator = if adjustment >= 0 {
|
||||
numerator *= scale;
|
||||
BigInt::one()
|
||||
} else {
|
||||
scale
|
||||
};
|
||||
Self::normalize_precharged(numerator, denominator)
|
||||
}
|
||||
|
||||
pub fn add(&self, rhs: &Self, meter: &mut WorkMeter) -> Result<Self, NumericError> {
|
||||
self.add_or_subtract(rhs, false, meter)
|
||||
}
|
||||
|
||||
pub fn subtract(&self, rhs: &Self, meter: &mut WorkMeter) -> Result<Self, NumericError> {
|
||||
self.add_or_subtract(rhs, true, meter)
|
||||
}
|
||||
|
||||
fn add_or_subtract(
|
||||
&self,
|
||||
rhs: &Self,
|
||||
subtract: bool,
|
||||
meter: &mut WorkMeter,
|
||||
) -> Result<Self, NumericError> {
|
||||
let (a, b) = self.component_bits();
|
||||
let (c, d) = rhs.component_bits();
|
||||
let left_bound = a + d;
|
||||
let right_bound = c + b;
|
||||
let n_bound = left_bound.max(right_bound) + 1;
|
||||
let d_bound = b + d;
|
||||
Self::check_estimates(n_bound, d_bound)?;
|
||||
meter.numeric(
|
||||
if subtract {
|
||||
NumericOperation::Subtract
|
||||
} else {
|
||||
NumericOperation::Add
|
||||
},
|
||||
product_cost(a, d)
|
||||
+ product_cost(c, b)
|
||||
+ sum_cost(left_bound, right_bound)
|
||||
+ product_cost(b, d)
|
||||
+ normalization_cost(n_bound, d_bound),
|
||||
)?;
|
||||
let left = &self.numerator * &rhs.denominator;
|
||||
let right = &rhs.numerator * &self.denominator;
|
||||
let numerator = if subtract { left - right } else { left + right };
|
||||
let denominator = &self.denominator * &rhs.denominator;
|
||||
Self::normalize_precharged(numerator, denominator)
|
||||
}
|
||||
|
||||
pub fn multiply(&self, rhs: &Self, meter: &mut WorkMeter) -> Result<Self, NumericError> {
|
||||
let (a, b) = self.component_bits();
|
||||
let (c, d) = rhs.component_bits();
|
||||
Self::check_estimates(a + c, b + d)?;
|
||||
meter.numeric(
|
||||
NumericOperation::Multiply,
|
||||
product_cost(a, c) + product_cost(b, d) + normalization_cost(a + c, b + d),
|
||||
)?;
|
||||
Self::normalize_precharged(
|
||||
&self.numerator * &rhs.numerator,
|
||||
&self.denominator * &rhs.denominator,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn divide(&self, rhs: &Self, meter: &mut WorkMeter) -> Result<Self, NumericError> {
|
||||
let (a, b) = self.component_bits();
|
||||
let (c, d) = rhs.component_bits();
|
||||
Self::check_estimates(a + d, b + c)?;
|
||||
meter.numeric(
|
||||
NumericOperation::Divide,
|
||||
product_cost(a, d) + product_cost(b, c) + normalization_cost(a + d, b + c),
|
||||
)?;
|
||||
if rhs.is_zero() {
|
||||
return Err(NumericError::DivisionByZero);
|
||||
}
|
||||
Self::normalize_precharged(
|
||||
&self.numerator * &rhs.denominator,
|
||||
&self.denominator * &rhs.numerator,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn compare(&self, rhs: &Self, meter: &mut WorkMeter) -> Result<Ordering, NumericError> {
|
||||
let (a, b) = self.component_bits();
|
||||
let (c, d) = rhs.component_bits();
|
||||
Self::check_estimates((a + d).max(c + b), 1)?;
|
||||
meter.numeric(
|
||||
NumericOperation::Compare,
|
||||
product_cost(a, d) + product_cost(c, b) + sum_cost(a + d, c + b),
|
||||
)?;
|
||||
Ok((&self.numerator * &rhs.denominator).cmp(&(&rhs.numerator * &self.denominator)))
|
||||
}
|
||||
|
||||
pub fn negated(&self, meter: &mut WorkMeter) -> Result<Self, NumericError> {
|
||||
meter.numeric(NumericOperation::Negate, self.copy_cost())?;
|
||||
Ok(Self {
|
||||
numerator: -&self.numerator,
|
||||
denominator: self.denominator.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn absolute(&self, meter: &mut WorkMeter) -> Result<Self, NumericError> {
|
||||
meter.numeric(NumericOperation::Absolute, self.copy_cost())?;
|
||||
Ok(Self {
|
||||
numerator: self.numerator.abs(),
|
||||
denominator: self.denominator.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn reciprocal(&self, meter: &mut WorkMeter) -> Result<Self, NumericError> {
|
||||
meter.numeric(NumericOperation::Reciprocal, self.copy_cost())?;
|
||||
if self.is_zero() {
|
||||
return Err(NumericError::DivisionByZero);
|
||||
}
|
||||
Ok(Self {
|
||||
numerator: if self.numerator.is_negative() {
|
||||
-&self.denominator
|
||||
} else {
|
||||
self.denominator.clone()
|
||||
},
|
||||
denominator: self.numerator.abs(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Fixed left-to-right repeated multiplication, at most 16 multiplications.
|
||||
/// Each reduced stage obeys the same component bound; no large power is
|
||||
/// allocated and subsequently discarded after discovering it is oversized.
|
||||
pub fn power(&self, exponent: i8, meter: &mut WorkMeter) -> Result<Self, NumericError> {
|
||||
meter.numeric(NumericOperation::Power, self.copy_cost())?;
|
||||
if !(-MAX_INTEGER_POWER..=MAX_INTEGER_POWER).contains(&exponent) {
|
||||
return Err(NumericError::IntegerPower);
|
||||
}
|
||||
if self.is_zero() && exponent == 0 {
|
||||
return Err(NumericError::ZeroPowerZero);
|
||||
}
|
||||
let factor = if exponent < 0 {
|
||||
self.reciprocal(meter)?
|
||||
} else {
|
||||
self.clone()
|
||||
};
|
||||
let mut value = Self::from_integer(1);
|
||||
for _ in 0..exponent.unsigned_abs() {
|
||||
value = value.multiply(&factor, meter)?;
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
pub(super) fn copy_cost(&self) -> u64 {
|
||||
let (n, d) = self.component_bits();
|
||||
1 + words(n) + words(d)
|
||||
}
|
||||
|
||||
fn normalize_precharged(
|
||||
mut numerator: BigInt,
|
||||
mut denominator: BigInt,
|
||||
) -> Result<Self, NumericError> {
|
||||
Self::check_bits(
|
||||
&numerator,
|
||||
&denominator,
|
||||
MAX_INTERMEDIATE_BITS,
|
||||
"intermediate",
|
||||
)?;
|
||||
if denominator.is_zero() {
|
||||
return Err(NumericError::ZeroDenominator);
|
||||
}
|
||||
if denominator.is_negative() {
|
||||
numerator = -numerator;
|
||||
denominator = -denominator;
|
||||
}
|
||||
let gcd = numerator.gcd(&denominator);
|
||||
numerator /= &gcd;
|
||||
denominator /= gcd;
|
||||
Self::check_bits(&numerator, &denominator, MAX_COMPONENT_BITS, "reduced")?;
|
||||
Ok(Self {
|
||||
numerator,
|
||||
denominator,
|
||||
})
|
||||
}
|
||||
|
||||
fn check_estimates(n: u64, d: u64) -> Result<(), NumericError> {
|
||||
Self::check_bit_count(
|
||||
n,
|
||||
NumberComponent::Numerator,
|
||||
MAX_INTERMEDIATE_BITS,
|
||||
"predicted",
|
||||
)?;
|
||||
Self::check_bit_count(
|
||||
d,
|
||||
NumberComponent::Denominator,
|
||||
MAX_INTERMEDIATE_BITS,
|
||||
"predicted",
|
||||
)
|
||||
}
|
||||
|
||||
fn check_bits(n: &BigInt, d: &BigInt, limit: u64, stage: &str) -> Result<(), NumericError> {
|
||||
Self::check_bit_count(n.bits(), NumberComponent::Numerator, limit, stage)?;
|
||||
Self::check_bit_count(d.bits(), NumberComponent::Denominator, limit, stage)
|
||||
}
|
||||
|
||||
fn check_bit_count(
|
||||
bits: u64,
|
||||
component: NumberComponent,
|
||||
limit: u64,
|
||||
stage: &str,
|
||||
) -> Result<(), NumericError> {
|
||||
if bits > limit {
|
||||
return Err(NumericError::Limit(NumericLimit::Bits {
|
||||
component,
|
||||
bits,
|
||||
limit,
|
||||
stage: stage.to_owned(),
|
||||
}));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<ExactWire> for Exact {
|
||||
type Error = NumericError;
|
||||
|
||||
fn try_from(value: ExactWire) -> Result<Self, Self::Error> {
|
||||
// BigInt validation is bounded after ExactWire strings have decoded.
|
||||
// The adapter owns envelope/text/aggregate admission bounds and must not
|
||||
// reset evaluation meters.
|
||||
Self::from_wire(&value, &mut WorkMeter::new(WorkLimits::REFERENCE))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Exact> for ExactWire {
|
||||
fn from(value: Exact) -> Self {
|
||||
value.to_wire()
|
||||
}
|
||||
}
|
||||
|
||||
fn trim_ascii(text: &str) -> &str {
|
||||
text.trim_matches(|ch: char| ch.is_ascii_whitespace())
|
||||
}
|
||||
|
||||
fn source_integer_digits(text: &str, signed: bool) -> Result<&str, NumericError> {
|
||||
let digits = if signed {
|
||||
text.strip_prefix('-').unwrap_or(text)
|
||||
} else {
|
||||
text
|
||||
};
|
||||
if digits.len() > MAX_COMPONENT_DIGITS {
|
||||
return Err(NumericError::LiteralTooLong);
|
||||
}
|
||||
if digits.is_empty() || !digits.bytes().all(|byte| byte.is_ascii_digit()) {
|
||||
return Err(NumericError::InvalidLiteral);
|
||||
}
|
||||
Ok(digits)
|
||||
}
|
||||
|
||||
fn wire_digits(text: &str, signed: bool) -> Result<&str, NumericError> {
|
||||
let digits = source_integer_digits(text, signed).map_err(|error| match error {
|
||||
NumericError::LiteralTooLong => error,
|
||||
_ => NumericError::NonCanonicalWire,
|
||||
})?;
|
||||
if (digits.len() > 1 && digits.starts_with('0')) || (text.starts_with('-') && digits == "0") {
|
||||
return Err(NumericError::NonCanonicalWire);
|
||||
}
|
||||
Ok(digits)
|
||||
}
|
||||
|
||||
fn parse_integer(text: &str) -> Result<BigInt, NumericError> {
|
||||
BigInt::parse_bytes(text.as_bytes(), 10).ok_or(NumericError::InvalidLiteral)
|
||||
}
|
||||
|
||||
fn parse_exponent(text: &str) -> Result<i32, NumericError> {
|
||||
let (negative, digits) = if let Some(digits) = text.strip_prefix('-') {
|
||||
(true, digits)
|
||||
} else {
|
||||
(false, text.strip_prefix('+').unwrap_or(text))
|
||||
};
|
||||
if digits.is_empty() || !digits.bytes().all(|byte| byte.is_ascii_digit()) {
|
||||
return Err(NumericError::InvalidLiteral);
|
||||
}
|
||||
let mut value = 0_i32;
|
||||
for digit in digits.bytes() {
|
||||
value = value * 10 + i32::from(digit - b'0');
|
||||
if value > MAX_DECIMAL_EXPONENT {
|
||||
return Err(NumericError::DecimalExponent);
|
||||
}
|
||||
}
|
||||
Ok(if negative { -value } else { value })
|
||||
}
|
||||
|
||||
fn decimal_bits(digits: usize) -> u64 {
|
||||
// 3.322 exceeds log2(10); dimensions are bounded before this conversion.
|
||||
(digits as u64 * 3322).div_ceil(1000).max(1)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
[
|
||||
{
|
||||
"canonical_source": "1000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000",
|
||||
"expression": {
|
||||
"Number": {
|
||||
"denominator": "1",
|
||||
"numerator": "1000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"
|
||||
}
|
||||
},
|
||||
"name": "ten_to_300"
|
||||
},
|
||||
{
|
||||
"canonical_source": "ratio(1, 522194440706576253345876355358312191289982124523691890192116741641976953985778728424413405967498779170445053357219631418993786719092896803631618043925682638972978488271854999170180795067191859157214035005927973113188159419698856372836167342172293308748403954352901852035642024370059304557233988891799014503343469488440893892973452815095130470299789726716411734651513348221529512507986199933857107770846917779942645743159118957217248367043905936319748237550094520674504208530837546834166925275516486044134775384991808184705966507606898412918594045916828375610659246423184062775112999150206172392431297837246097308511903252956622805412865917690043804311051417135098849101156584508839003337597742539960818209685142687562392007453579567729991395256699805775897135553415567045292136442139895777424891477161767258532611634530697452993846501061481697843891439474220308003706472837459911525285821188577408160690315522951458068463354171428220365223949985950890732881736611925133626529949897998045399734600887312408859224933727829625089164535236559716582775403784110923285873186648442456409760158728501220463308455437074192539205964902261490928669488824051563042951500651206733594863336608245755565801460390869016718045121902354170201577095168)",
|
||||
"expression": {
|
||||
"Number": {
|
||||
"denominator": "522194440706576253345876355358312191289982124523691890192116741641976953985778728424413405967498779170445053357219631418993786719092896803631618043925682638972978488271854999170180795067191859157214035005927973113188159419698856372836167342172293308748403954352901852035642024370059304557233988891799014503343469488440893892973452815095130470299789726716411734651513348221529512507986199933857107770846917779942645743159118957217248367043905936319748237550094520674504208530837546834166925275516486044134775384991808184705966507606898412918594045916828375610659246423184062775112999150206172392431297837246097308511903252956622805412865917690043804311051417135098849101156584508839003337597742539960818209685142687562392007453579567729991395256699805775897135553415567045292136442139895777424891477161767258532611634530697452993846501061481697843891439474220308003706472837459911525285821188577408160690315522951458068463354171428220365223949985950890732881736611925133626529949897998045399734600887312408859224933727829625089164535236559716582775403784110923285873186648442456409760158728501220463308455437074192539205964902261490928669488824051563042951500651206733594863336608245755565801460390869016718045121902354170201577095168",
|
||||
"numerator": "1"
|
||||
}
|
||||
},
|
||||
"name": "one_over_two_to_4095"
|
||||
},
|
||||
{
|
||||
"canonical_source": "ratio(-1044388881413152506691752710716624382579964249047383780384233483283953907971557456848826811934997558340890106714439262837987573438185793607263236087851365277945956976543709998340361590134383718314428070011855946226376318839397712745672334684344586617496807908705803704071284048740118609114467977783598029006686938976881787785946905630190260940599579453432823469303026696443059025015972399867714215541693835559885291486318237914434496734087811872639496475100189041349008417061675093668333850551032972088269550769983616369411933015213796825837188091833656751221318492846368125550225998300412344784862595674492194617023806505913245610825731835380087608622102834270197698202313169017678006675195485079921636419370285375124784014907159135459982790513399611551794271106831134090584272884279791554849782954323534517065223269061394905987693002122963395687782878948440616007412945674919823050571642377154816321380631045902916136926708342856440730447899971901781465763473223850267253059899795996090799469201774624817718449867455659250178329070473119433165550807568221846571746373296884912819520317457002440926616910874148385078411929804522981857338977648103126085903001302413467189726673216491511131602920781738033436090243804708340403154190335, 1)",
|
||||
"expression": {
|
||||
"Number": {
|
||||
"denominator": "1",
|
||||
"numerator": "-1044388881413152506691752710716624382579964249047383780384233483283953907971557456848826811934997558340890106714439262837987573438185793607263236087851365277945956976543709998340361590134383718314428070011855946226376318839397712745672334684344586617496807908705803704071284048740118609114467977783598029006686938976881787785946905630190260940599579453432823469303026696443059025015972399867714215541693835559885291486318237914434496734087811872639496475100189041349008417061675093668333850551032972088269550769983616369411933015213796825837188091833656751221318492846368125550225998300412344784862595674492194617023806505913245610825731835380087608622102834270197698202313169017678006675195485079921636419370285375124784014907159135459982790513399611551794271106831134090584272884279791554849782954323534517065223269061394905987693002122963395687782878948440616007412945674919823050571642377154816321380631045902916136926708342856440730447899971901781465763473223850267253059899795996090799469201774624817718449867455659250178329070473119433165550807568221846571746373296884912819520317457002440926616910874148385078411929804522981857338977648103126085903001302413467189726673216491511131602920781738033436090243804708340403154190335"
|
||||
}
|
||||
},
|
||||
"name": "negative_4096_bit_bound"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,240 @@
|
||||
use super::{NumericError, NumericLimit};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
|
||||
/// Every event is charged once per dynamic occurrence, before entering it.
|
||||
///
|
||||
/// A Call expression therefore charges Expression and CalculationEntry; a
|
||||
/// Property read charges Expression and PropertyBinding. List element visits
|
||||
/// are additional to evaluation of their expressions. Only executed branches
|
||||
/// are charged. Targets are sorted by category (properties, then checks), then
|
||||
/// stable UUID bytes, by the integrating evaluator.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum EvaluationStep {
|
||||
Target,
|
||||
Expression,
|
||||
CalculationEntry,
|
||||
PropertyBinding,
|
||||
ListElement,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum NumericOperation {
|
||||
ParseWire,
|
||||
ParseLiteral,
|
||||
Add,
|
||||
Subtract,
|
||||
Multiply,
|
||||
Divide,
|
||||
Compare,
|
||||
Negate,
|
||||
Absolute,
|
||||
Reciprocal,
|
||||
Power,
|
||||
KindAssertion,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum WorkResource {
|
||||
DynamicEvents,
|
||||
NumericUnits,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum WorkSite {
|
||||
Evaluation(EvaluationStep),
|
||||
Numeric(NumericOperation),
|
||||
}
|
||||
|
||||
/// Failed reservations consume no partial charge. The first failure is latched.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Error, Serialize, Deserialize)]
|
||||
#[error("{resource:?}: used {used}, requested {requested}, limit {limit}, at {site:?}")]
|
||||
pub struct WorkExhaustion {
|
||||
pub resource: WorkResource,
|
||||
pub used: u64,
|
||||
pub requested: u64,
|
||||
pub limit: u64,
|
||||
pub site: WorkSite,
|
||||
}
|
||||
|
||||
/// Provisional calibration ceilings, not measured execution-time guarantees.
|
||||
///
|
||||
/// Effective limits, profile identity, capture, targets and context must all be
|
||||
/// recorded in request/result/frozen identity. A caller may lower these ceilings.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(try_from = "WorkLimitsWire", into = "WorkLimitsWire")]
|
||||
pub struct WorkLimits {
|
||||
dynamic_events: u64,
|
||||
numeric_units: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct WorkLimitsWire {
|
||||
dynamic_events: u64,
|
||||
numeric_units: u64,
|
||||
}
|
||||
|
||||
impl WorkLimits {
|
||||
pub const REFERENCE: Self = Self {
|
||||
dynamic_events: 1_000_000,
|
||||
numeric_units: 50_000_000,
|
||||
};
|
||||
|
||||
pub fn new(dynamic_events: u64, numeric_units: u64) -> Result<Self, NumericError> {
|
||||
if dynamic_events > Self::REFERENCE.dynamic_events
|
||||
|| numeric_units > Self::REFERENCE.numeric_units
|
||||
{
|
||||
return Err(NumericError::InvalidWorkLimits);
|
||||
}
|
||||
Ok(Self {
|
||||
dynamic_events,
|
||||
numeric_units,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn dynamic_events(self) -> u64 {
|
||||
self.dynamic_events
|
||||
}
|
||||
|
||||
pub fn numeric_units(self) -> u64 {
|
||||
self.numeric_units
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<WorkLimitsWire> for WorkLimits {
|
||||
type Error = NumericError;
|
||||
|
||||
fn try_from(value: WorkLimitsWire) -> Result<Self, Self::Error> {
|
||||
Self::new(value.dynamic_events, value.numeric_units)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<WorkLimits> for WorkLimitsWire {
|
||||
fn from(value: WorkLimits) -> Self {
|
||||
Self {
|
||||
dynamic_events: value.dynamic_events,
|
||||
numeric_units: value.numeric_units,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct WorkUsage {
|
||||
pub dynamic_events: u64,
|
||||
pub numeric_units: u64,
|
||||
}
|
||||
|
||||
/// Intentionally neither Clone nor Default: pass the same mutable meter through
|
||||
/// every target, numeric operation and expanded call in one evaluation request.
|
||||
///
|
||||
/// Exhaustion is terminal. Integrators must stop traversing siblings/targets and
|
||||
/// mark already collected causes as incomplete. Replaying an aggregate cache
|
||||
/// cost is insufficient if it changes the exact exhaustion location; initially
|
||||
/// retain logical traversal even when numerical values come from a cache.
|
||||
#[derive(Debug)]
|
||||
pub struct WorkMeter {
|
||||
limits: WorkLimits,
|
||||
usage: WorkUsage,
|
||||
exhausted: Option<WorkExhaustion>,
|
||||
}
|
||||
|
||||
impl WorkMeter {
|
||||
pub fn new(limits: WorkLimits) -> Self {
|
||||
Self {
|
||||
limits,
|
||||
usage: WorkUsage::default(),
|
||||
exhausted: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn limits(&self) -> WorkLimits {
|
||||
self.limits
|
||||
}
|
||||
|
||||
pub fn usage(&self) -> WorkUsage {
|
||||
self.usage
|
||||
}
|
||||
|
||||
pub fn exhaustion(&self) -> Option<&WorkExhaustion> {
|
||||
self.exhausted.as_ref()
|
||||
}
|
||||
|
||||
pub fn ensure_active(&self) -> Result<(), NumericError> {
|
||||
match &self.exhausted {
|
||||
Some(failure) => Err(NumericError::Limit(NumericLimit::Work(failure.clone()))),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn visit(&mut self, step: EvaluationStep) -> Result<(), NumericError> {
|
||||
self.reserve(WorkResource::DynamicEvents, 1, WorkSite::Evaluation(step))
|
||||
}
|
||||
|
||||
pub(super) fn numeric(
|
||||
&mut self,
|
||||
operation: NumericOperation,
|
||||
units: u64,
|
||||
) -> Result<(), NumericError> {
|
||||
self.reserve(
|
||||
WorkResource::NumericUnits,
|
||||
units,
|
||||
WorkSite::Numeric(operation),
|
||||
)
|
||||
}
|
||||
|
||||
fn reserve(
|
||||
&mut self,
|
||||
resource: WorkResource,
|
||||
requested: u64,
|
||||
site: WorkSite,
|
||||
) -> Result<(), NumericError> {
|
||||
self.ensure_active()?;
|
||||
let (used, limit) = match resource {
|
||||
WorkResource::DynamicEvents => (self.usage.dynamic_events, self.limits.dynamic_events),
|
||||
WorkResource::NumericUnits => (self.usage.numeric_units, self.limits.numeric_units),
|
||||
};
|
||||
// Subtraction is safe: only successful reservations update usage.
|
||||
if requested > limit - used {
|
||||
let failure = WorkExhaustion {
|
||||
resource,
|
||||
used,
|
||||
requested,
|
||||
limit,
|
||||
site,
|
||||
};
|
||||
self.exhausted = Some(failure.clone());
|
||||
return Err(NumericError::Limit(NumericLimit::Work(failure)));
|
||||
}
|
||||
match resource {
|
||||
WorkResource::DynamicEvents => self.usage.dynamic_events += requested,
|
||||
WorkResource::NumericUnits => self.usage.numeric_units += requested,
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// Logical 64-bit words are platform independent. These are deterministic
|
||||
// accounting weights, not a claim about num-bigint's internal algorithm/timing.
|
||||
pub(super) fn words(bits: u64) -> u64 {
|
||||
bits.max(1).div_ceil(64)
|
||||
}
|
||||
|
||||
pub(super) fn product_cost(a_bits: u64, b_bits: u64) -> u64 {
|
||||
1 + words(a_bits) * words(b_bits)
|
||||
}
|
||||
|
||||
pub(super) fn sum_cost(a_bits: u64, b_bits: u64) -> u64 {
|
||||
1 + words(a_bits.max(b_bits))
|
||||
}
|
||||
|
||||
pub(super) fn normalization_cost(n_bits: u64, d_bits: u64) -> u64 {
|
||||
let width = words(n_bits.max(d_bits));
|
||||
// 64*w² for GCD plus 2*w² for the two quotient operations.
|
||||
1 + 66 * width * width
|
||||
}
|
||||
|
||||
pub(super) fn parse_cost(digits: usize, estimated_bits: u64) -> u64 {
|
||||
1 + digits as u64 * words(estimated_bits)
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
use super::{Dimension, Exact, MAX_INTEGER_POWER, NumericError, NumericOperation, WorkMeter};
|
||||
use std::cmp::Ordering;
|
||||
|
||||
/// K is supplied by the common resolver's verified nominal compatibility
|
||||
/// relation. It is not inferred from dimension/name/symbol/archive bytes here.
|
||||
/// Coordinate basis/algebra/difference-kind closure must be part of that proof.
|
||||
/// Exact definition references and pins remain the caller's provenance.
|
||||
/// K must be a bounded, cheap-to-clone/compare verified handle. Constructing or
|
||||
/// comparing an unbounded rooted contract graph is resolver admission work and
|
||||
/// must not be hidden inside K's Clone/Eq implementations. Rust trait bounds do
|
||||
/// not enforce this resource contract.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ResolvedKind<K> {
|
||||
key: K,
|
||||
dimension: Dimension,
|
||||
difference: Option<K>,
|
||||
}
|
||||
|
||||
impl<K> ResolvedKind<K> {
|
||||
pub fn linear(key: K, dimension: Dimension) -> Self {
|
||||
Self {
|
||||
key,
|
||||
dimension,
|
||||
difference: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn key(&self) -> &K {
|
||||
&self.key
|
||||
}
|
||||
|
||||
pub fn dimension(&self) -> Dimension {
|
||||
self.dimension
|
||||
}
|
||||
|
||||
pub fn is_affine(&self) -> bool {
|
||||
self.difference.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
impl<K: Clone + Eq> ResolvedKind<K> {
|
||||
pub fn affine(key: K, difference: &Self) -> Result<Self, NumericError> {
|
||||
if difference.is_affine() || key == difference.key {
|
||||
return Err(NumericError::DifferenceKind);
|
||||
}
|
||||
Ok(Self {
|
||||
key,
|
||||
dimension: difference.dimension,
|
||||
difference: Some(difference.key.clone()),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn difference_kind(&self) -> Option<Self> {
|
||||
self.difference
|
||||
.as_ref()
|
||||
.map(|key| Self::linear(key.clone(), self.dimension))
|
||||
}
|
||||
}
|
||||
|
||||
/// Canonical coordinate = magnitude * scale + offset.
|
||||
/// Unit symbols and display preferences are outside this numeric representation.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Unit<K> {
|
||||
kind: ResolvedKind<K>,
|
||||
scale: Exact,
|
||||
offset: Exact,
|
||||
}
|
||||
|
||||
impl<K: Clone + Eq> Unit<K> {
|
||||
pub fn new(kind: ResolvedKind<K>, scale: Exact, offset: Exact) -> Result<Self, NumericError> {
|
||||
if !scale.is_positive() {
|
||||
return Err(NumericError::UnitScale);
|
||||
}
|
||||
if !kind.is_affine() && !offset.is_zero() {
|
||||
return Err(NumericError::LinearOffset);
|
||||
}
|
||||
Ok(Self {
|
||||
kind,
|
||||
scale,
|
||||
offset,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn kind(&self) -> &ResolvedKind<K> {
|
||||
&self.kind
|
||||
}
|
||||
|
||||
pub fn scale(&self) -> &Exact {
|
||||
&self.scale
|
||||
}
|
||||
|
||||
pub fn offset(&self) -> &Exact {
|
||||
&self.offset
|
||||
}
|
||||
|
||||
/// Fixed multiply-then-add path. Each reduced arithmetic stage is bounded.
|
||||
/// A mathematically cancelling wider intermediate still reports NumericLimit;
|
||||
/// replacing this path with a fused operation requires a new work profile.
|
||||
pub fn to_canonical(
|
||||
&self,
|
||||
magnitude: &Exact,
|
||||
meter: &mut WorkMeter,
|
||||
) -> Result<Exact, NumericError> {
|
||||
magnitude
|
||||
.multiply(&self.scale, meter)?
|
||||
.add(&self.offset, meter)
|
||||
}
|
||||
|
||||
/// Fixed subtract-then-divide path; never rounds a coordinate.
|
||||
pub fn from_canonical(
|
||||
&self,
|
||||
coordinate: &Exact,
|
||||
meter: &mut WorkMeter,
|
||||
) -> Result<Exact, NumericError> {
|
||||
coordinate
|
||||
.subtract(&self.offset, meter)?
|
||||
.divide(&self.scale, meter)
|
||||
}
|
||||
|
||||
pub fn convert(
|
||||
&self,
|
||||
magnitude: &Exact,
|
||||
target: &Self,
|
||||
meter: &mut WorkMeter,
|
||||
) -> Result<Exact, NumericError> {
|
||||
meter.ensure_active()?;
|
||||
if self.kind != target.kind {
|
||||
return Err(NumericError::IncompatibleTypes);
|
||||
}
|
||||
target.from_canonical(&self.to_canonical(magnitude, meter)?, meter)
|
||||
}
|
||||
}
|
||||
|
||||
/// Internal numerical types. Boolean/list/check semantics belong to the common
|
||||
/// expression evaluator. Anonymous zero dimension normalizes to Number.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum NumericType<K> {
|
||||
Number,
|
||||
Named(ResolvedKind<K>),
|
||||
Anonymous(Dimension),
|
||||
}
|
||||
|
||||
impl<K> NumericType<K> {
|
||||
pub fn dimension(&self) -> Dimension {
|
||||
match self {
|
||||
Self::Number => Dimension::ZERO,
|
||||
Self::Named(kind) => kind.dimension,
|
||||
Self::Anonymous(dimension) => *dimension,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_affine(&self) -> bool {
|
||||
matches!(self, Self::Named(kind) if kind.is_affine())
|
||||
}
|
||||
|
||||
fn anonymous(dimension: Dimension) -> Self {
|
||||
if dimension.is_dimensionless() {
|
||||
Self::Number
|
||||
} else {
|
||||
Self::Anonymous(dimension)
|
||||
}
|
||||
}
|
||||
|
||||
fn normalized(self) -> Self {
|
||||
match self {
|
||||
Self::Anonymous(dimension) => Self::anonymous(dimension),
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A canonical rational coordinate with an internal numerical type.
|
||||
///
|
||||
/// Derived Eq compares stored representation. Formal equality/ordering must call
|
||||
/// compare(), which rejects mismatched nominal kinds instead of treating them
|
||||
/// as a false predicate. Public property output must still check its declared
|
||||
/// type and anchor the result to that declaration's exact definition reference.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct NumericValue<K> {
|
||||
value_type: NumericType<K>,
|
||||
coordinate: Exact,
|
||||
}
|
||||
|
||||
impl<K: Clone + Eq> NumericValue<K> {
|
||||
pub fn new(value_type: NumericType<K>, coordinate: Exact) -> Self {
|
||||
Self {
|
||||
value_type: value_type.normalized(),
|
||||
coordinate,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn number(coordinate: Exact) -> Self {
|
||||
Self::new(NumericType::Number, coordinate)
|
||||
}
|
||||
|
||||
pub fn from_unit(
|
||||
magnitude: &Exact,
|
||||
unit: &Unit<K>,
|
||||
meter: &mut WorkMeter,
|
||||
) -> Result<Self, NumericError> {
|
||||
meter.ensure_active()?;
|
||||
Ok(Self::new(
|
||||
NumericType::Named(unit.kind.clone()),
|
||||
unit.to_canonical(magnitude, meter)?,
|
||||
))
|
||||
}
|
||||
|
||||
pub fn value_type(&self) -> &NumericType<K> {
|
||||
&self.value_type
|
||||
}
|
||||
|
||||
pub fn coordinate(&self) -> &Exact {
|
||||
&self.coordinate
|
||||
}
|
||||
|
||||
pub fn display_in(&self, unit: &Unit<K>, meter: &mut WorkMeter) -> Result<Exact, NumericError> {
|
||||
meter.ensure_active()?;
|
||||
if !matches!(&self.value_type, NumericType::Named(kind) if kind == &unit.kind) {
|
||||
return Err(NumericError::IncompatibleTypes);
|
||||
}
|
||||
unit.from_canonical(&self.coordinate, meter)
|
||||
}
|
||||
|
||||
pub fn add(&self, rhs: &Self, meter: &mut WorkMeter) -> Result<Self, NumericError> {
|
||||
meter.ensure_active()?;
|
||||
let output = self.additive_type(rhs, false)?;
|
||||
Ok(Self::new(
|
||||
output,
|
||||
self.coordinate.add(&rhs.coordinate, meter)?,
|
||||
))
|
||||
}
|
||||
|
||||
pub fn subtract(&self, rhs: &Self, meter: &mut WorkMeter) -> Result<Self, NumericError> {
|
||||
meter.ensure_active()?;
|
||||
let output = self.additive_type(rhs, true)?;
|
||||
Ok(Self::new(
|
||||
output,
|
||||
self.coordinate.subtract(&rhs.coordinate, meter)?,
|
||||
))
|
||||
}
|
||||
|
||||
fn additive_type(&self, rhs: &Self, subtract: bool) -> Result<NumericType<K>, NumericError> {
|
||||
use NumericType::{Anonymous, Named, Number};
|
||||
match (&self.value_type, &rhs.value_type) {
|
||||
(Number, Number) => Ok(Number),
|
||||
(Anonymous(left), Anonymous(right)) if left == right => Ok(Anonymous(*left)),
|
||||
(Named(left), Named(right)) => match (&left.difference, &right.difference) {
|
||||
(None, None) if left == right => Ok(Named(left.clone())),
|
||||
(Some(difference), Some(_)) if subtract && left == right => Ok(Named(
|
||||
ResolvedKind::linear(difference.clone(), left.dimension),
|
||||
)),
|
||||
(Some(difference), None)
|
||||
if difference == &right.key && left.dimension == right.dimension =>
|
||||
{
|
||||
Ok(Named(left.clone()))
|
||||
}
|
||||
(None, Some(difference))
|
||||
if !subtract
|
||||
&& difference == &left.key
|
||||
&& left.dimension == right.dimension =>
|
||||
{
|
||||
Ok(Named(right.clone()))
|
||||
}
|
||||
_ => Err(NumericError::IncompatibleTypes),
|
||||
},
|
||||
_ => Err(NumericError::IncompatibleTypes),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn multiply(&self, rhs: &Self, meter: &mut WorkMeter) -> Result<Self, NumericError> {
|
||||
meter.ensure_active()?;
|
||||
let output = self.multiplicative_type(rhs, false)?;
|
||||
Ok(Self::new(
|
||||
output,
|
||||
self.coordinate.multiply(&rhs.coordinate, meter)?,
|
||||
))
|
||||
}
|
||||
|
||||
pub fn divide(&self, rhs: &Self, meter: &mut WorkMeter) -> Result<Self, NumericError> {
|
||||
meter.ensure_active()?;
|
||||
let output = self.multiplicative_type(rhs, true)?;
|
||||
Ok(Self::new(
|
||||
output,
|
||||
self.coordinate.divide(&rhs.coordinate, meter)?,
|
||||
))
|
||||
}
|
||||
|
||||
fn multiplicative_type(
|
||||
&self,
|
||||
rhs: &Self,
|
||||
divide: bool,
|
||||
) -> Result<NumericType<K>, NumericError> {
|
||||
use NumericType::Number;
|
||||
if self.value_type.is_affine() || rhs.value_type.is_affine() {
|
||||
return Err(NumericError::AffineOperation);
|
||||
}
|
||||
match (&self.value_type, &rhs.value_type) {
|
||||
(Number, Number) => Ok(Number),
|
||||
(_, Number) => Ok(self.value_type.clone()),
|
||||
(Number, _) if !divide => Ok(rhs.value_type.clone()),
|
||||
_ => {
|
||||
let left = self.value_type.dimension();
|
||||
let right = rhs.value_type.dimension();
|
||||
let dimension = if divide {
|
||||
left.checked_quotient(right)?
|
||||
} else {
|
||||
left.checked_product(right)?
|
||||
};
|
||||
Ok(NumericType::anonymous(dimension))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn negated(&self, meter: &mut WorkMeter) -> Result<Self, NumericError> {
|
||||
meter.ensure_active()?;
|
||||
if self.value_type.is_affine() {
|
||||
return Err(NumericError::AffineOperation);
|
||||
}
|
||||
Ok(Self::new(
|
||||
self.value_type.clone(),
|
||||
self.coordinate.negated(meter)?,
|
||||
))
|
||||
}
|
||||
|
||||
pub fn absolute(&self, meter: &mut WorkMeter) -> Result<Self, NumericError> {
|
||||
meter.ensure_active()?;
|
||||
if self.value_type.is_affine() {
|
||||
return Err(NumericError::AffineOperation);
|
||||
}
|
||||
Ok(Self::new(
|
||||
self.value_type.clone(),
|
||||
self.coordinate.absolute(meter)?,
|
||||
))
|
||||
}
|
||||
|
||||
pub fn power(&self, exponent: i8, meter: &mut WorkMeter) -> Result<Self, NumericError> {
|
||||
meter.ensure_active()?;
|
||||
if !(-MAX_INTEGER_POWER..=MAX_INTEGER_POWER).contains(&exponent) {
|
||||
return Err(NumericError::IntegerPower);
|
||||
}
|
||||
if self.value_type.is_affine() {
|
||||
return Err(NumericError::AffineOperation);
|
||||
}
|
||||
let output = match exponent {
|
||||
0 => NumericType::Number,
|
||||
1 => self.value_type.clone(),
|
||||
_ => NumericType::anonymous(self.value_type.dimension().checked_power(exponent)?),
|
||||
};
|
||||
Ok(Self::new(output, self.coordinate.power(exponent, meter)?))
|
||||
}
|
||||
|
||||
pub fn compare(&self, rhs: &Self, meter: &mut WorkMeter) -> Result<Ordering, NumericError> {
|
||||
meter.ensure_active()?;
|
||||
if self.value_type != rhs.value_type {
|
||||
return Err(NumericError::IncompatibleTypes);
|
||||
}
|
||||
self.coordinate.compare(&rhs.coordinate, meter)
|
||||
}
|
||||
|
||||
pub fn as_kind(
|
||||
&self,
|
||||
target: &ResolvedKind<K>,
|
||||
meter: &mut WorkMeter,
|
||||
) -> Result<Self, NumericError> {
|
||||
meter.ensure_active()?;
|
||||
if target.is_affine()
|
||||
|| matches!(self.value_type, NumericType::Named(_))
|
||||
|| self.value_type.dimension() != target.dimension
|
||||
{
|
||||
return Err(NumericError::KindAssertion);
|
||||
}
|
||||
meter.numeric(NumericOperation::KindAssertion, self.coordinate.copy_cost())?;
|
||||
Ok(Self::new(
|
||||
NumericType::Named(target.clone()),
|
||||
self.coordinate.clone(),
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,804 @@
|
||||
use super::*;
|
||||
use num_bigint::BigInt;
|
||||
use num_traits::One;
|
||||
use std::cmp::Ordering;
|
||||
|
||||
fn meter() -> WorkMeter {
|
||||
WorkMeter::new(WorkLimits::REFERENCE)
|
||||
}
|
||||
|
||||
fn exact(n: impl ToString, d: impl ToString) -> Exact {
|
||||
Exact::from_wire(
|
||||
&ExactWire {
|
||||
numerator: n.to_string(),
|
||||
denominator: d.to_string(),
|
||||
},
|
||||
&mut meter(),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn source(text: &str) -> Exact {
|
||||
Exact::parse_literal(text, &mut meter()).unwrap()
|
||||
}
|
||||
|
||||
fn assert_fraction(value: &Exact, n: impl ToString, d: impl ToString) {
|
||||
assert_eq!(
|
||||
value.to_wire(),
|
||||
ExactWire {
|
||||
numerator: n.to_string(),
|
||||
denominator: d.to_string(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Independent bounded i128 oracle. It does not call the production Exact/GCD
|
||||
// implementation when deriving expected arithmetic or ordering.
|
||||
fn small_fraction(mut n: i128, mut d: i128) -> (i128, i128) {
|
||||
assert_ne!(d, 0);
|
||||
if d < 0 {
|
||||
n = -n;
|
||||
d = -d;
|
||||
}
|
||||
let (mut a, mut b) = (n.abs(), d);
|
||||
while b != 0 {
|
||||
(a, b) = (b, a % b);
|
||||
}
|
||||
(n / a, d / a)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arithmetic_matches_independent_small_integer_oracle() {
|
||||
for a in [-5_i128, -2, 0, 1, 4] {
|
||||
for b in [1_i128, 2, 3, 7] {
|
||||
for c in [-5_i128, -2, 0, 1, 4] {
|
||||
for d in [1_i128, 2, 3, 7] {
|
||||
let (an, ad) = small_fraction(a, b);
|
||||
let (bn, bd) = small_fraction(c, d);
|
||||
let left = exact(an, ad);
|
||||
let right = exact(bn, bd);
|
||||
let mut work = meter();
|
||||
let (n, den) = small_fraction(a * d + c * b, b * d);
|
||||
assert_fraction(&left.add(&right, &mut work).unwrap(), n, den);
|
||||
let (n, den) = small_fraction(a * d - c * b, b * d);
|
||||
assert_fraction(&left.subtract(&right, &mut work).unwrap(), n, den);
|
||||
let (n, den) = small_fraction(a * c, b * d);
|
||||
assert_fraction(&left.multiply(&right, &mut work).unwrap(), n, den);
|
||||
if c != 0 {
|
||||
let (n, den) = small_fraction(a * d, b * c);
|
||||
assert_fraction(&left.divide(&right, &mut work).unwrap(), n, den);
|
||||
} else {
|
||||
assert_eq!(
|
||||
left.divide(&right, &mut work),
|
||||
Err(NumericError::DivisionByZero)
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
left.compare(&right, &mut work).unwrap(),
|
||||
(a * d).cmp(&(c * b))
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decimals_are_exact_and_source_ratios_reduce() {
|
||||
assert_fraction(
|
||||
&source("0.1").add(&source("0.2"), &mut meter()).unwrap(),
|
||||
3,
|
||||
10,
|
||||
);
|
||||
assert_fraction(&source("0.50"), 1, 2);
|
||||
assert_fraction(&source("12.50e-2"), 1, 8);
|
||||
assert_fraction(&source("1e+3"), 1000, 1);
|
||||
assert_fraction(&source("0001.2500"), 5, 4);
|
||||
assert_fraction(&source(" ratio( -6, 8 ) "), -3, 4);
|
||||
assert_fraction(&source("ratio(-0, 27)"), 0, 1);
|
||||
assert_eq!(source("0.5").canonical_literal(), "ratio(1, 2)");
|
||||
assert_eq!(source("42").canonical_literal(), "42");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn powers_and_signs_have_explicit_domains() {
|
||||
let x = exact(-2, 3);
|
||||
assert_fraction(&x.power(3, &mut meter()).unwrap(), -8, 27);
|
||||
assert_fraction(&x.power(-3, &mut meter()).unwrap(), -27, 8);
|
||||
assert_fraction(&x.power(0, &mut meter()).unwrap(), 1, 1);
|
||||
assert_fraction(&x.absolute(&mut meter()).unwrap(), 2, 3);
|
||||
assert_fraction(&x.negated(&mut meter()).unwrap(), 2, 3);
|
||||
assert_fraction(&x.reciprocal(&mut meter()).unwrap(), -3, 2);
|
||||
let zero = Exact::from_integer(0);
|
||||
assert_eq!(
|
||||
zero.power(0, &mut meter()),
|
||||
Err(NumericError::ZeroPowerZero)
|
||||
);
|
||||
assert_eq!(
|
||||
zero.power(-1, &mut meter()),
|
||||
Err(NumericError::DivisionByZero)
|
||||
);
|
||||
assert_eq!(x.power(17, &mut meter()), Err(NumericError::IntegerPower));
|
||||
assert_eq!(
|
||||
x.power(i8::MIN, &mut meter()),
|
||||
Err(NumericError::IntegerPower)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonical_wire_rejects_both_component_noncanonical_forms() {
|
||||
for (n, d) in [
|
||||
("+1", "1"),
|
||||
("01", "1"),
|
||||
("-0", "1"),
|
||||
("1", "+1"),
|
||||
("1", "-1"),
|
||||
("1", "01"),
|
||||
("2", "4"),
|
||||
("0", "3"),
|
||||
("", "1"),
|
||||
("1", ""),
|
||||
("1.0", "1"),
|
||||
("1", "1.0"),
|
||||
("1e1", "1"),
|
||||
("1", "1e1"),
|
||||
(" 1", "1"),
|
||||
("1", "1 "),
|
||||
] {
|
||||
assert!(
|
||||
Exact::from_wire(
|
||||
&ExactWire {
|
||||
numerator: n.into(),
|
||||
denominator: d.into()
|
||||
},
|
||||
&mut meter(),
|
||||
)
|
||||
.is_err(),
|
||||
"accepted {n}/{d}"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
Exact::from_wire(
|
||||
&ExactWire {
|
||||
numerator: "1".into(),
|
||||
denominator: "0".into()
|
||||
},
|
||||
&mut meter()
|
||||
),
|
||||
Err(NumericError::ZeroDenominator)
|
||||
);
|
||||
assert_fraction(&exact(0, 1), 0, 1);
|
||||
assert_fraction(&exact(-1, 2), -1, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serde_uses_closed_canonical_string_wire() {
|
||||
let value = exact(-3, 7);
|
||||
let encoded = serde_json::to_value(&value).unwrap();
|
||||
assert_eq!(
|
||||
encoded,
|
||||
serde_json::json!({"numerator":"-3","denominator":"7"})
|
||||
);
|
||||
assert_eq!(serde_json::from_value::<Exact>(encoded).unwrap(), value);
|
||||
for invalid in [
|
||||
r#"{"numerator":1,"denominator":"2"}"#,
|
||||
r#"{"numerator":"1","denominator":2}"#,
|
||||
r#"{"numerator":"1","denominator":"2","other":0}"#,
|
||||
r#"{"numerator":"1","numerator":"2","denominator":"3"}"#,
|
||||
r#"{"numerator":"1"}"#,
|
||||
r#"{"numerator":"2","denominator":"4"}"#,
|
||||
] {
|
||||
assert!(serde_json::from_str::<Exact>(invalid).is_err(), "{invalid}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn literal_reader_does_not_fold_expression_unary_minus() {
|
||||
assert_eq!(
|
||||
Exact::parse_literal("-2", &mut meter()),
|
||||
Err(NumericError::InvalidLiteral)
|
||||
);
|
||||
let negative_literal = source("ratio(-2, 1)");
|
||||
assert_eq!(negative_literal.canonical_literal(), "ratio(-2, 1)");
|
||||
assert_eq!(source("2").negated(&mut meter()).unwrap(), negative_literal);
|
||||
// Equality of coordinates does not claim AST equality. The expression
|
||||
// parser must preserve UnaryNegate independently of its operand literal.
|
||||
assert_eq!(source("2").canonical_literal(), "2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn literal_syntax_and_decimal_exponent_are_bounded() {
|
||||
for invalid in [
|
||||
"",
|
||||
"-",
|
||||
"+1",
|
||||
".5",
|
||||
"1.",
|
||||
"1.2.3",
|
||||
"1e",
|
||||
"1ee2",
|
||||
"1e+-2",
|
||||
"1_000",
|
||||
"NaN",
|
||||
"Infinity",
|
||||
"1/2",
|
||||
"ratio(1)",
|
||||
"ratio(1,2,3)",
|
||||
"ratio(1,-2)",
|
||||
"ratio(+1,2)",
|
||||
"ratio(1,2) + 1",
|
||||
"12",
|
||||
] {
|
||||
assert!(
|
||||
Exact::parse_literal(invalid, &mut meter()).is_err(),
|
||||
"{invalid}"
|
||||
);
|
||||
}
|
||||
for invalid in ["1e129", "1e-129", "1e9999999999999999999999"] {
|
||||
assert_eq!(
|
||||
Exact::parse_literal(invalid, &mut meter()),
|
||||
Err(NumericError::DecimalExponent)
|
||||
);
|
||||
}
|
||||
assert!(Exact::parse_literal("1e128", &mut meter()).is_ok());
|
||||
assert!(Exact::parse_literal("1e-128", &mut meter()).is_ok());
|
||||
assert_eq!(
|
||||
Exact::parse_literal(&"1".repeat(MAX_COMPONENT_DIGITS + 1), &mut meter()),
|
||||
Err(NumericError::LiteralTooLong)
|
||||
);
|
||||
assert_eq!(
|
||||
Exact::parse_literal(&" ".repeat(MAX_LITERAL_BYTES + 1), &mut meter()),
|
||||
Err(NumericError::LiteralTooLong)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_independent_r7_packets_round_trip_as_literals_and_wire() {
|
||||
let packets: serde_json::Value =
|
||||
serde_json::from_str(include_str!("fixtures/r7-literal-boundaries.json")).unwrap();
|
||||
for packet in packets.as_array().unwrap() {
|
||||
let wire: ExactWire =
|
||||
serde_json::from_value(packet["expression"]["Number"].clone()).unwrap();
|
||||
let expected = Exact::from_wire(&wire, &mut meter()).unwrap();
|
||||
let spelling = packet["canonical_source"].as_str().unwrap();
|
||||
assert_eq!(expected.canonical_literal(), spelling);
|
||||
assert_eq!(
|
||||
Exact::parse_literal(spelling, &mut meter()).unwrap(),
|
||||
expected
|
||||
);
|
||||
let serialized = serde_json::to_string(&expected).unwrap();
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Exact>(&serialized).unwrap(),
|
||||
expected
|
||||
);
|
||||
assert_eq!(expected.to_wire(), wire);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_8193_bit_sum_intermediate_can_reduce_to_admitted_value() {
|
||||
// N=(2^4096)-1, D=N-1. gcd(N,D)=1 and D is even.
|
||||
// N/D + N/D = N/(D/2), although its un-reduced numerator needs 8193 bits.
|
||||
let n = (BigInt::one() << 4096_usize) - 1_u8;
|
||||
let d = &n - 1_u8;
|
||||
let value = exact(&n, &d);
|
||||
let result = value.add(&value, &mut meter()).unwrap();
|
||||
assert_fraction(&result, &n, &d / 2_u8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cross_products_cancel_before_reduced_component_limit() {
|
||||
let n = BigInt::one() << 4095_usize;
|
||||
let d = &n - 1_u8;
|
||||
let left = exact(&n, &d);
|
||||
let right = exact(&d, &n);
|
||||
assert_fraction(&left.multiply(&right, &mut meter()).unwrap(), 1, 1);
|
||||
assert_fraction(
|
||||
&left
|
||||
.add(&left.negated(&mut meter()).unwrap(), &mut meter())
|
||||
.unwrap(),
|
||||
0,
|
||||
1,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn both_reduced_component_overflows_are_reported() {
|
||||
let n = BigInt::one() << 4095_usize;
|
||||
let mut work = meter();
|
||||
let failure = exact(&n, 1)
|
||||
.multiply(&Exact::from_integer(2), &mut work)
|
||||
.unwrap_err();
|
||||
assert!(matches!(
|
||||
failure,
|
||||
NumericError::Limit(NumericLimit::Bits {
|
||||
component: NumberComponent::Numerator,
|
||||
bits: 4097,
|
||||
..
|
||||
})
|
||||
));
|
||||
assert!(work.exhaustion().is_none());
|
||||
let failure = exact(1, &n)
|
||||
.divide(&Exact::from_integer(2), &mut work)
|
||||
.unwrap_err();
|
||||
assert!(matches!(
|
||||
failure,
|
||||
NumericError::Limit(NumericLimit::Bits {
|
||||
component: NumberComponent::Denominator,
|
||||
bits: 4097,
|
||||
..
|
||||
})
|
||||
));
|
||||
// Value overflow is a nonterminal supported-operation failure; fuel
|
||||
// exhaustion, in contrast, terminates the entire request.
|
||||
assert_fraction(&source("1").add(&source("1"), &mut work).unwrap(), 2, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wire_rejects_4097_bits_before_gcd_can_reduce_them() {
|
||||
let oversized = (BigInt::one() << 4096_usize).to_string();
|
||||
for wire in [
|
||||
ExactWire {
|
||||
numerator: oversized.clone(),
|
||||
denominator: "1".into(),
|
||||
},
|
||||
ExactWire {
|
||||
numerator: "1".into(),
|
||||
denominator: oversized.clone(),
|
||||
},
|
||||
ExactWire {
|
||||
numerator: oversized.clone(),
|
||||
denominator: oversized,
|
||||
},
|
||||
] {
|
||||
assert!(matches!(
|
||||
Exact::from_wire(&wire, &mut meter()),
|
||||
Err(NumericError::Limit(NumericLimit::Bits { stage, .. })) if stage == "wire"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numeric_precharge_precedes_big_integer_result_allocation() {
|
||||
let n = BigInt::one() << 4095_usize;
|
||||
let left = exact(&n, 1);
|
||||
let mut work = WorkMeter::new(WorkLimits::new(10, 0).unwrap());
|
||||
let failure = left
|
||||
.multiply(&Exact::from_integer(2), &mut work)
|
||||
.unwrap_err();
|
||||
assert!(failure.is_terminal_work_exhaustion());
|
||||
assert_eq!(work.usage().numeric_units, 0);
|
||||
assert!(matches!(
|
||||
work.exhaustion().unwrap().site,
|
||||
WorkSite::Numeric(NumericOperation::Multiply)
|
||||
));
|
||||
// With available fuel the same operation reaches the 4097-bit result check.
|
||||
assert!(matches!(
|
||||
left.multiply(&Exact::from_integer(2), &mut meter()),
|
||||
Err(NumericError::Limit(NumericLimit::Bits { .. }))
|
||||
));
|
||||
let too_wide_wire = ExactWire {
|
||||
numerator: (BigInt::one() << 4096_usize).to_string(),
|
||||
denominator: "1".into(),
|
||||
};
|
||||
let mut parse_work = WorkMeter::new(WorkLimits::new(0, 0).unwrap());
|
||||
assert!(
|
||||
Exact::from_wire(&too_wide_wire, &mut parse_work)
|
||||
.unwrap_err()
|
||||
.is_terminal_work_exhaustion()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provisional_limits_and_effective_limits_round_trip() {
|
||||
let limits = WorkLimits::new(500, 2000).unwrap();
|
||||
let encoded = serde_json::to_value(limits).unwrap();
|
||||
assert_eq!(
|
||||
encoded,
|
||||
serde_json::json!({"dynamic_events":500,"numeric_units":2000})
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_value::<WorkLimits>(encoded).unwrap(),
|
||||
limits
|
||||
);
|
||||
assert_eq!(
|
||||
WorkLimits::new(1_000_001, 0),
|
||||
Err(NumericError::InvalidWorkLimits)
|
||||
);
|
||||
assert_eq!(
|
||||
WorkLimits::new(0, 50_000_001),
|
||||
Err(NumericError::InvalidWorkLimits)
|
||||
);
|
||||
assert!(
|
||||
serde_json::from_str::<WorkLimits>(r#"{"dynamic_events":1000001,"numeric_units":0}"#)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_accounting_boundary_and_terminal_failure_are_stable() {
|
||||
// One-word addition: three products at 2, one sum at 2,
|
||||
// normalization at 67 => 75 logical numeric units.
|
||||
let mut work = WorkMeter::new(WorkLimits::new(4, 75).unwrap());
|
||||
assert_fraction(&exact(1, 2).add(&exact(1, 3), &mut work).unwrap(), 5, 6);
|
||||
assert_eq!(work.usage().numeric_units, 75);
|
||||
assert!(work.exhaustion().is_none());
|
||||
let first = Exact::from_integer(1).negated(&mut work).unwrap_err();
|
||||
let at_failure = work.usage();
|
||||
assert_eq!(
|
||||
work.exhaustion().unwrap(),
|
||||
&WorkExhaustion {
|
||||
resource: WorkResource::NumericUnits,
|
||||
used: 75,
|
||||
requested: 3,
|
||||
limit: 75,
|
||||
site: WorkSite::Numeric(NumericOperation::Negate),
|
||||
}
|
||||
);
|
||||
assert_eq!(work.visit(EvaluationStep::Target).unwrap_err(), first);
|
||||
assert_eq!(
|
||||
Exact::from_integer(1)
|
||||
.divide(&Exact::from_integer(0), &mut work)
|
||||
.unwrap_err(),
|
||||
first
|
||||
);
|
||||
assert_eq!(
|
||||
work.numeric(NumericOperation::Compare, 0).unwrap_err(),
|
||||
first
|
||||
);
|
||||
assert_eq!(work.usage(), at_failure);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_cost_and_maximum_reservation_do_not_underflow_or_reactivate() {
|
||||
let mut work = WorkMeter::new(WorkLimits::new(0, 0).unwrap());
|
||||
work.numeric(NumericOperation::Compare, 0).unwrap();
|
||||
let failure = work
|
||||
.numeric(NumericOperation::Multiply, u64::MAX)
|
||||
.unwrap_err();
|
||||
assert!(failure.is_terminal_work_exhaustion());
|
||||
assert_eq!(work.usage(), WorkUsage::default());
|
||||
assert_eq!(
|
||||
work.numeric(NumericOperation::Compare, 0).unwrap_err(),
|
||||
failure
|
||||
);
|
||||
let mut dynamic = WorkMeter::new(WorkLimits::new(0, 5).unwrap());
|
||||
assert!(dynamic.visit(EvaluationStep::Target).is_err());
|
||||
assert_eq!(dynamic.usage(), WorkUsage::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_targets_share_one_aggregate_meter() {
|
||||
let mut work = WorkMeter::new(WorkLimits::new(3, 50).unwrap());
|
||||
work.visit(EvaluationStep::Target).unwrap();
|
||||
work.visit(EvaluationStep::Expression).unwrap();
|
||||
work.visit(EvaluationStep::Target).unwrap();
|
||||
let failure = work.visit(EvaluationStep::Expression).unwrap_err();
|
||||
assert!(failure.is_terminal_work_exhaustion());
|
||||
assert_eq!(work.usage().dynamic_events, 3);
|
||||
}
|
||||
|
||||
// A direct bounded call-tree workload, not an expression interpreter/dialect.
|
||||
fn expanded_calls(
|
||||
depth: u8,
|
||||
x: u64,
|
||||
work: &mut WorkMeter,
|
||||
entered: &mut u64,
|
||||
) -> Result<u64, NumericError> {
|
||||
work.visit(EvaluationStep::CalculationEntry)?;
|
||||
*entered += 1;
|
||||
if depth == 0 {
|
||||
return Ok(x);
|
||||
}
|
||||
let left = expanded_calls(depth - 1, 2 * x, work, entered)?;
|
||||
let right = expanded_calls(depth - 1, 2 * x + 1, work, entered)?;
|
||||
Ok(left + right)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn c1_expanded_call_witness_terminates_at_dynamic_limit() {
|
||||
let mut work = WorkMeter::new(WorkLimits::new(10_000, 0).unwrap());
|
||||
let mut entered = 0;
|
||||
let failure = expanded_calls(30, 0, &mut work, &mut entered).unwrap_err();
|
||||
assert!(failure.is_terminal_work_exhaustion());
|
||||
assert_eq!(entered, 10_000);
|
||||
assert_eq!(work.usage().dynamic_events, 10_000);
|
||||
assert_eq!(
|
||||
work.exhaustion().unwrap().site,
|
||||
WorkSite::Evaluation(EvaluationStep::CalculationEntry)
|
||||
);
|
||||
// Nearby finite control: 63 invocations, leaves 0..31, sum 496.
|
||||
let mut bounded = WorkMeter::new(WorkLimits::new(63, 0).unwrap());
|
||||
let mut entered = 0;
|
||||
assert_eq!(
|
||||
expanded_calls(5, 0, &mut bounded, &mut entered).unwrap(),
|
||||
496
|
||||
);
|
||||
assert_eq!(entered, 63);
|
||||
assert!(bounded.exhaustion().is_none());
|
||||
}
|
||||
|
||||
fn dim(value: [i16; 7]) -> Dimension {
|
||||
Dimension::new(value).unwrap()
|
||||
}
|
||||
|
||||
fn linear(key: &str, dimension: Dimension) -> ResolvedKind<String> {
|
||||
ResolvedKind::linear(key.to_owned(), dimension)
|
||||
}
|
||||
|
||||
fn named(n: i64, kind: &ResolvedKind<String>) -> NumericValue<String> {
|
||||
NumericValue::new(NumericType::Named(kind.clone()), Exact::from_integer(n))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dimensions_use_checked_seven_axis_integer_algebra() {
|
||||
let length = dim([1, 0, 0, 0, 0, 0, 0]);
|
||||
let mass = dim([0, 1, 0, 0, 0, 0, 0]);
|
||||
let time = dim([0, 0, 1, 0, 0, 0, 0]);
|
||||
assert_eq!(
|
||||
mass.checked_product(length)
|
||||
.unwrap()
|
||||
.checked_quotient(time.checked_power(2).unwrap())
|
||||
.unwrap(),
|
||||
dim([1, 1, -2, 0, 0, 0, 0])
|
||||
);
|
||||
assert!(time.checked_quotient(time).unwrap().is_dimensionless());
|
||||
assert!(matches!(
|
||||
dim([32, 0, 0, 0, 0, 0, 0]).checked_product(length),
|
||||
Err(NumericError::DimensionExponent {
|
||||
axis: DimensionAxis::Length,
|
||||
value: 33
|
||||
})
|
||||
));
|
||||
assert!(dim([0, 0, -32, 0, 0, 0, 0]).checked_quotient(time).is_err());
|
||||
assert!(length.checked_power(i8::MIN).is_err());
|
||||
assert!(Dimension::new([33, 0, 0, 0, 0, 0, 0]).is_err());
|
||||
assert_eq!(
|
||||
serde_json::to_value(time).unwrap(),
|
||||
serde_json::json!([0, 0, 1, 0, 0, 0, 0])
|
||||
);
|
||||
for invalid in ["[0,0,33,0,0,0,0]", "[0,0,1]", "[0,0,1,0,0,0,0,0]"] {
|
||||
assert!(serde_json::from_str::<Dimension>(invalid).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duration_unit_coordinates_are_exact_and_nominal() {
|
||||
let duration = linear("duration", dim([0, 0, 1, 0, 0, 0, 0]));
|
||||
let s = Unit::new(duration.clone(), source("1"), source("0")).unwrap();
|
||||
let ms = Unit::new(duration.clone(), source("0.001"), source("0")).unwrap();
|
||||
assert_fraction(&ms.convert(&source("750"), &s, &mut meter()).unwrap(), 3, 4);
|
||||
let receive = NumericValue::from_unit(&source("750"), &ms, &mut meter()).unwrap();
|
||||
let dispatch = NumericValue::from_unit(&source("1"), &s, &mut meter()).unwrap();
|
||||
let total = receive.add(&dispatch, &mut meter()).unwrap();
|
||||
assert_fraction(total.coordinate(), 7, 4);
|
||||
assert_fraction(&total.display_in(&ms, &mut meter()).unwrap(), 1750, 1);
|
||||
let different = Unit::new(
|
||||
linear("different-kind", duration.dimension()),
|
||||
source("1"),
|
||||
source("0"),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
ms.convert(&source("750"), &different, &mut meter()),
|
||||
Err(NumericError::IncompatibleTypes)
|
||||
);
|
||||
let inconsistent = Unit::new(
|
||||
linear("duration", dim([1, 0, 0, 0, 0, 0, 0])),
|
||||
source("1"),
|
||||
source("0"),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
ms.convert(&source("1"), &inconsistent, &mut meter())
|
||||
.is_err()
|
||||
);
|
||||
assert!(Unit::new(duration.clone(), source("0"), source("0")).is_err());
|
||||
assert!(Unit::new(duration.clone(), source("ratio(-1,1)"), source("0")).is_err());
|
||||
assert_eq!(
|
||||
Unit::new(duration, source("1"), source("1")).unwrap_err(),
|
||||
NumericError::LinearOffset
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn affine_point_and_difference_arithmetic_has_distinct_kinds() {
|
||||
let interval = linear("temperature-difference", dim([0, 0, 0, 0, 1, 0, 0]));
|
||||
let point = ResolvedKind::affine("temperature-point".to_owned(), &interval).unwrap();
|
||||
let celsius = Unit::new(point.clone(), source("1"), source("273.15")).unwrap();
|
||||
let fahrenheit = Unit::new(
|
||||
point.clone(),
|
||||
source("ratio(5,9)"),
|
||||
source("ratio(45967,180)"),
|
||||
)
|
||||
.unwrap();
|
||||
let kelvin_interval = Unit::new(interval.clone(), source("1"), source("0")).unwrap();
|
||||
assert_fraction(
|
||||
&celsius
|
||||
.convert(&source("0"), &fahrenheit, &mut meter())
|
||||
.unwrap(),
|
||||
32,
|
||||
1,
|
||||
);
|
||||
assert_fraction(
|
||||
&fahrenheit
|
||||
.convert(&source("70"), &celsius, &mut meter())
|
||||
.unwrap(),
|
||||
190,
|
||||
9,
|
||||
);
|
||||
let p20 = NumericValue::from_unit(&source("20"), &celsius, &mut meter()).unwrap();
|
||||
let p10 = NumericValue::from_unit(&source("10"), &celsius, &mut meter()).unwrap();
|
||||
let difference = p20.subtract(&p10, &mut meter()).unwrap();
|
||||
assert_eq!(
|
||||
difference.value_type(),
|
||||
&NumericType::Named(interval.clone())
|
||||
);
|
||||
assert_fraction(
|
||||
&difference
|
||||
.display_in(&kelvin_interval, &mut meter())
|
||||
.unwrap(),
|
||||
10,
|
||||
1,
|
||||
);
|
||||
let delta5 = named(5, &interval);
|
||||
let p25 = p20.add(&delta5, &mut meter()).unwrap();
|
||||
assert_fraction(&p25.display_in(&celsius, &mut meter()).unwrap(), 25, 1);
|
||||
assert_eq!(delta5.add(&p20, &mut meter()).unwrap(), p25);
|
||||
assert!(p20.add(&p10, &mut meter()).is_err());
|
||||
assert!(delta5.subtract(&p20, &mut meter()).is_err());
|
||||
assert_eq!(
|
||||
p20.power(1, &mut meter()),
|
||||
Err(NumericError::AffineOperation)
|
||||
);
|
||||
assert_eq!(
|
||||
p20.negated(&mut meter()),
|
||||
Err(NumericError::AffineOperation)
|
||||
);
|
||||
assert_eq!(p20.compare(&p10, &mut meter()).unwrap(), Ordering::Greater);
|
||||
assert!(p20.display_in(&kelvin_interval, &mut meter()).is_err());
|
||||
assert!(ResolvedKind::affine(interval.key().clone(), &interval).is_err());
|
||||
assert!(ResolvedKind::affine("other".to_owned(), &point).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn affine_conversion_rejects_a_wider_stage_even_when_the_final_value_would_fit() {
|
||||
// A fused coordinate calculation 2*m-m equals m, where m=2^4095 fits.
|
||||
// The fixed multiply stage produces 2^4096, so it must fail before offset
|
||||
// cancellation. The inverse path likewise rejects (m-(-m))/2 at subtraction.
|
||||
let large = BigInt::one() << 4095_usize;
|
||||
let magnitude = exact(&large, 1);
|
||||
let interval = linear("temperature-interval", dim([0, 0, 0, 0, 1, 0, 0]));
|
||||
let point = ResolvedKind::affine("temperature-point".to_owned(), &interval).unwrap();
|
||||
let unit = Unit::new(point, Exact::from_integer(2), exact(-&large, 1)).unwrap();
|
||||
let mut work = meter();
|
||||
for outcome in [
|
||||
unit.to_canonical(&magnitude, &mut work),
|
||||
unit.from_canonical(&magnitude, &mut work),
|
||||
] {
|
||||
assert!(matches!(
|
||||
outcome,
|
||||
Err(NumericError::Limit(NumericLimit::Bits {
|
||||
component: NumberComponent::Numerator,
|
||||
bits: 4097,
|
||||
limit: MAX_COMPONENT_BITS,
|
||||
..
|
||||
}))
|
||||
));
|
||||
}
|
||||
assert!(work.exhaustion().is_none());
|
||||
let half = exact(&large >> 1_usize, 1);
|
||||
assert_fraction(&unit.to_canonical(&half, &mut work).unwrap(), 0, 1);
|
||||
assert_eq!(
|
||||
unit.from_canonical(&Exact::from_integer(0), &mut work)
|
||||
.unwrap(),
|
||||
half
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn r6_anonymous_compound_energy_needs_one_outer_assertion() {
|
||||
let force = linear("force", dim([1, 1, -2, 0, 0, 0, 0]));
|
||||
let length = linear("length", dim([1, 0, 0, 0, 0, 0, 0]));
|
||||
let energy = linear("energy", dim([2, 1, -2, 0, 0, 0, 0]));
|
||||
let a = named(3, &force)
|
||||
.multiply(&named(4, &length), &mut meter())
|
||||
.unwrap();
|
||||
let b = named(2, &force)
|
||||
.multiply(&named(7, &length), &mut meter())
|
||||
.unwrap();
|
||||
assert_eq!(a.value_type(), &NumericType::Anonymous(energy.dimension()));
|
||||
let sum = a.add(&b, &mut meter()).unwrap();
|
||||
let result = sum.as_kind(&energy, &mut meter()).unwrap();
|
||||
assert_fraction(result.coordinate(), 26, 1);
|
||||
assert_eq!(result.value_type(), &NumericType::Named(energy.clone()));
|
||||
assert_eq!(a.compare(&b, &mut meter()).unwrap(), Ordering::Less);
|
||||
assert!(result.add(&sum, &mut meter()).is_err());
|
||||
assert_eq!(
|
||||
result.as_kind(&energy, &mut meter()),
|
||||
Err(NumericError::KindAssertion)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anonymous_product_then_quotient_closes_and_preserves_scaling() {
|
||||
let mass = linear("mass", dim([0, 1, 0, 0, 0, 0, 0]));
|
||||
let length = linear("length", dim([1, 0, 0, 0, 0, 0, 0]));
|
||||
let time = linear("duration", dim([0, 0, 1, 0, 0, 0, 0]));
|
||||
let momentum = linear("momentum", dim([1, 1, -1, 0, 0, 0, 0]));
|
||||
let result = named(2, &mass)
|
||||
.multiply(&named(3, &length), &mut meter())
|
||||
.unwrap()
|
||||
.divide(&named(2, &time), &mut meter())
|
||||
.unwrap()
|
||||
.as_kind(&momentum, &mut meter())
|
||||
.unwrap();
|
||||
assert_fraction(result.coordinate(), 3, 1);
|
||||
let scalar = NumericValue::number(source("2"));
|
||||
let scaled = scalar.multiply(&named(3, &time), &mut meter()).unwrap();
|
||||
assert_eq!(scaled.value_type(), &NumericType::Named(time.clone()));
|
||||
assert_fraction(scaled.coordinate(), 6, 1);
|
||||
assert_eq!(
|
||||
named(3, &time)
|
||||
.divide(&scalar, &mut meter())
|
||||
.unwrap()
|
||||
.value_type(),
|
||||
&NumericType::Named(time.clone())
|
||||
);
|
||||
assert_eq!(
|
||||
scalar
|
||||
.divide(&named(3, &time), &mut meter())
|
||||
.unwrap()
|
||||
.value_type(),
|
||||
&NumericType::Anonymous(dim([0, 0, -1, 0, 0, 0, 0]))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nominal_kinds_survive_comparison_but_explicit_quotients_can_erase_dimension() {
|
||||
let energy = linear("energy", dim([2, 1, -2, 0, 0, 0, 0]));
|
||||
let torque = linear("torque", energy.dimension());
|
||||
let left = named(7, &energy);
|
||||
let right = named(7, &torque);
|
||||
assert!(left.add(&right, &mut meter()).is_err());
|
||||
assert!(left.compare(&right, &mut meter()).is_err());
|
||||
let ratio = left.divide(&right, &mut meter()).unwrap();
|
||||
assert_eq!(ratio.value_type(), &NumericType::Number);
|
||||
assert_fraction(ratio.coordinate(), 1, 1);
|
||||
let named_ratio = linear("chosen-ratio", Dimension::ZERO);
|
||||
let asserted = ratio.as_kind(&named_ratio, &mut meter()).unwrap();
|
||||
assert_eq!(
|
||||
asserted.value_type(),
|
||||
&NumericType::Named(named_ratio.clone())
|
||||
);
|
||||
assert!(asserted.add(&ratio, &mut meter()).is_err());
|
||||
assert_eq!(
|
||||
NumericValue::<String>::new(NumericType::Anonymous(Dimension::ZERO), source("1")),
|
||||
NumericValue::number(source("1"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numerical_power_types_and_dimension_overflow_are_checked() {
|
||||
let length = linear("length", dim([1, 0, 0, 0, 0, 0, 0]));
|
||||
let value = named(2, &length);
|
||||
assert_eq!(
|
||||
value.power(0, &mut meter()).unwrap().value_type(),
|
||||
&NumericType::Number
|
||||
);
|
||||
assert_eq!(
|
||||
value.power(1, &mut meter()).unwrap().value_type(),
|
||||
value.value_type()
|
||||
);
|
||||
assert_eq!(
|
||||
value.power(-2, &mut meter()).unwrap().value_type(),
|
||||
&NumericType::Anonymous(dim([-2, 0, 0, 0, 0, 0, 0]))
|
||||
);
|
||||
let large_dimension = linear("large-dimension", dim([32, 0, 0, 0, 0, 0, 0]));
|
||||
assert!(
|
||||
named(1, &large_dimension)
|
||||
.multiply(&value, &mut meter())
|
||||
.is_err()
|
||||
);
|
||||
assert!(named(1, &large_dimension).power(2, &mut meter()).is_err());
|
||||
}
|
||||
@@ -81,7 +81,6 @@ fn diagram(
|
||||
projection,
|
||||
view_options: Default::default(),
|
||||
nodes: BTreeMap::new(),
|
||||
layout: None,
|
||||
routes: vec![],
|
||||
},
|
||||
);
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
//! Pure diagram edits shared by movement previews and committed transactions.
|
||||
use crate::{Command, Id, MAX_COORDINATE, ModelError, Position, Project, projection};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
/// Translate selected placements and their projected descendants simultaneously.
|
||||
///
|
||||
/// Membership and geometry are read from one snapshot, including folded children.
|
||||
/// Overlapping roots move each placement once. Route pins follow the combined
|
||||
/// movement of their actual endpoint owners (and State boundaries). Semantic
|
||||
/// ownership and other views are untouched. Apply the returned batch through the
|
||||
/// normal validated command path; its selected identity is not a primary selection.
|
||||
pub fn translate_placements(
|
||||
project: &Project,
|
||||
diagram: Id,
|
||||
selected: &BTreeSet<Id>,
|
||||
delta: Position,
|
||||
) -> Result<Command, ModelError> {
|
||||
let view = project
|
||||
.diagrams
|
||||
.get(&diagram)
|
||||
.ok_or_else(|| ModelError("Movement requires an existing diagram.".into()))?;
|
||||
if selected.is_empty() {
|
||||
return Err(ModelError(
|
||||
"Movement requires at least one placement.".into(),
|
||||
));
|
||||
}
|
||||
if selected.iter().any(|id| !view.nodes.contains_key(id)) {
|
||||
return Err(ModelError(
|
||||
"Every selected placement must belong to the diagram.".into(),
|
||||
));
|
||||
}
|
||||
if !delta.x.is_finite() || !delta.y.is_finite() {
|
||||
return Err(ModelError("Movement displacement must be finite.".into()));
|
||||
}
|
||||
if delta.x == 0. && delta.y == 0. {
|
||||
return Ok(Command::Batch(vec![]));
|
||||
}
|
||||
let mut children: BTreeMap<Id, Vec<Id>> = BTreeMap::new();
|
||||
for (child, parent) in projection::parents(project, view) {
|
||||
if let Some(parent) = parent {
|
||||
children.entry(parent).or_default().push(child);
|
||||
}
|
||||
}
|
||||
let mut moved = BTreeSet::new();
|
||||
let mut pending: Vec<_> = selected.iter().copied().collect();
|
||||
while let Some(id) = pending.pop() {
|
||||
if moved.insert(id) {
|
||||
pending.extend(children.get(&id).into_iter().flatten().copied());
|
||||
}
|
||||
}
|
||||
let deltas: BTreeMap<_, _> = moved.iter().map(|id| (*id, delta)).collect();
|
||||
let routes = projection::translated_routes(project, view, &deltas);
|
||||
let bounded = |value: f32| value.is_finite() && value.abs() <= MAX_COORDINATE;
|
||||
let mut commands = Vec::with_capacity(moved.len() + routes.len());
|
||||
for id in moved {
|
||||
let mut node = view.nodes[&id].clone();
|
||||
node.position.x += delta.x;
|
||||
node.position.y += delta.y;
|
||||
if ![
|
||||
node.position.x,
|
||||
node.position.y,
|
||||
node.size.width,
|
||||
node.size.height,
|
||||
]
|
||||
.into_iter()
|
||||
.all(bounded)
|
||||
|| node.size.width <= 0.
|
||||
|| node.size.height <= 0.
|
||||
{
|
||||
return Err(ModelError(
|
||||
"Movement produces invalid placement bounds.".into(),
|
||||
));
|
||||
}
|
||||
commands.push(Command::EditNode {
|
||||
diagram,
|
||||
original: id,
|
||||
node,
|
||||
});
|
||||
}
|
||||
for route in routes {
|
||||
if route
|
||||
.waypoints
|
||||
.iter()
|
||||
.any(|point| !bounded(point.x) || !bounded(point.y))
|
||||
{
|
||||
return Err(ModelError("Movement produces invalid route bounds.".into()));
|
||||
}
|
||||
commands.push(Command::SetEdgeRoute { diagram, route });
|
||||
}
|
||||
Ok(Command::Batch(commands))
|
||||
}
|
||||
@@ -3,6 +3,8 @@
|
||||
mod semantic_source;
|
||||
#[path = "migration_v2/source.rs"]
|
||||
mod v2_source;
|
||||
pub(crate) use crate::source_defaults::Mode as DeclarationFormat;
|
||||
use crate::source_defaults::{self, Context as DefaultContext};
|
||||
use crate::*;
|
||||
use serde_json::{Map, Value};
|
||||
use std::{collections::BTreeMap, fmt};
|
||||
@@ -645,25 +647,34 @@ fn offset_for(source: &str, line: usize, column: usize) -> usize {
|
||||
source.len()
|
||||
}
|
||||
|
||||
/// Canonical output uses persistent aliases and explicit identities. Typed
|
||||
/// nested records are ordinary language fields; JSON documents are not embedded.
|
||||
/// Canonical output uses persistent aliases and explicit identities, omitting
|
||||
/// only values supplied by source defaults. Typed nested records are ordinary
|
||||
/// language fields; JSON documents are not embedded.
|
||||
pub fn format(project: &Project) -> String {
|
||||
let mut out = std::format!(
|
||||
"arcadia {SCHEMA_VERSION};\nproject {} {} id \"{}\" {{\n description {};\n\n",
|
||||
"arcadia {SCHEMA_VERSION};\nproject {} {} id \"{}\" {{\n",
|
||||
project.alias,
|
||||
quote(&project.name),
|
||||
project.id,
|
||||
quote(&project.description)
|
||||
);
|
||||
for (_, declaration) in format_declarations(project) {
|
||||
if !project.description.is_empty() {
|
||||
out.push_str(&std::format!(
|
||||
" description {};\n",
|
||||
quote(&project.description)
|
||||
));
|
||||
}
|
||||
out.push('\n');
|
||||
for (_, declaration) in format_declarations(project, DeclarationFormat::Compact) {
|
||||
out.push_str(&declaration);
|
||||
}
|
||||
out.push_str("}\n");
|
||||
out
|
||||
}
|
||||
|
||||
/// The same typed declaration writer is used by flat and modular source output.
|
||||
pub(crate) fn format_declarations(project: &Project) -> Vec<(Id, String)> {
|
||||
/// Shared typed writer for canonical output and lossless editing. Complete edit
|
||||
/// comparisons retain defaults; emitted declarations and value fragments apply
|
||||
/// only the omission rules valid in their selected source context.
|
||||
pub(crate) fn format_declarations(project: &Project, mode: DeclarationFormat) -> Vec<(Id, String)> {
|
||||
let aliases = project
|
||||
.identities()
|
||||
.into_iter()
|
||||
@@ -675,6 +686,7 @@ pub(crate) fn format_declarations(project: &Project) -> Vec<(Id, String)> {
|
||||
let mut fields = payload(&serde_json::to_value(&e.body).unwrap());
|
||||
fields.insert("description".into(), Value::String(e.description.clone()));
|
||||
fields.insert("parent".into(), serde_json::to_value(e.parent).unwrap());
|
||||
let fields = source_defaults::fields(DefaultContext::Element(e.kind()), &fields, mode);
|
||||
write_declaration(
|
||||
&mut out,
|
||||
&element_word(e.kind(), e.layer()),
|
||||
@@ -690,6 +702,7 @@ pub(crate) fn format_declarations(project: &Project) -> Vec<(Id, String)> {
|
||||
let mut out = String::new();
|
||||
let mut fields = payload(&serde_json::to_value(&r.body).unwrap());
|
||||
fields.insert("description".into(), Value::String(r.description.clone()));
|
||||
let fields = source_defaults::fields(DefaultContext::Relation(r.kind()), &fields, mode);
|
||||
write_declaration(
|
||||
&mut out,
|
||||
&relation_word(r.kind()),
|
||||
@@ -705,6 +718,7 @@ pub(crate) fn format_declarations(project: &Project) -> Vec<(Id, String)> {
|
||||
let mut out = String::new();
|
||||
let mut fields = record_fields(d);
|
||||
fields.remove("nodes");
|
||||
let fields = source_defaults::fields(DefaultContext::Diagram, &fields, mode);
|
||||
write_declaration(
|
||||
&mut out,
|
||||
"diagram",
|
||||
@@ -719,6 +733,7 @@ pub(crate) fn format_declarations(project: &Project) -> Vec<(Id, String)> {
|
||||
let mut out = String::new();
|
||||
let mut fields = record_fields(n);
|
||||
fields.insert("diagram".into(), Value::String(d.id.to_string()));
|
||||
let fields = source_defaults::fields(DefaultContext::Node, &fields, mode);
|
||||
write_declaration(&mut out, "node", &n.alias, None, n.id, &fields, &aliases);
|
||||
declarations.push((n.id, out));
|
||||
}
|
||||
@@ -731,7 +746,7 @@ pub(crate) fn format_declarations(project: &Project) -> Vec<(Id, String)> {
|
||||
&a.alias,
|
||||
Some(&a.name),
|
||||
a.id,
|
||||
&record_fields(a),
|
||||
&source_defaults::fields(DefaultContext::Artifact, &record_fields(a), mode),
|
||||
&aliases,
|
||||
);
|
||||
declarations.push((a.id, out));
|
||||
@@ -744,7 +759,7 @@ pub(crate) fn format_declarations(project: &Project) -> Vec<(Id, String)> {
|
||||
&t.alias,
|
||||
Some(&t.name),
|
||||
t.id,
|
||||
&record_fields(t),
|
||||
&source_defaults::fields(DefaultContext::Trace, &record_fields(t), mode),
|
||||
&aliases,
|
||||
);
|
||||
declarations.push((t.id, out));
|
||||
@@ -1742,7 +1757,9 @@ pub(crate) fn resolve_declarations(
|
||||
*viewpoint = ScenarioViewpoint::MixedLegacy;
|
||||
}
|
||||
let mut body = serde_json::to_value(initial).unwrap();
|
||||
let defaults = if legacy {
|
||||
let defaults = if project.schema_version >= 7 {
|
||||
source_defaults::parser_fields(DefaultContext::Element(kind))
|
||||
} else if legacy {
|
||||
crate::persistence::v3::legacy_defaults(kind)
|
||||
.and_then(|value| value.as_object().cloned())
|
||||
.unwrap_or_else(|| payload(&body))
|
||||
@@ -1799,7 +1816,12 @@ pub(crate) fn resolve_declarations(
|
||||
let mut body =
|
||||
serde_json::to_value(RelationBody::new(kind, Id::nil(), Id::nil()))
|
||||
.unwrap();
|
||||
let merged = merge_fields(payload(&body), fields).map_err(&conversion_error)?;
|
||||
let defaults = if project.schema_version >= 7 {
|
||||
source_defaults::parser_fields(DefaultContext::Relation(kind))
|
||||
} else {
|
||||
payload(&body)
|
||||
};
|
||||
let merged = merge_fields(defaults, fields).map_err(&conversion_error)?;
|
||||
replace_payload(&mut body, merged);
|
||||
let body = serde_json::from_value(body)
|
||||
.map_err(|e| conversion_error(e.to_string()))?;
|
||||
@@ -1823,8 +1845,11 @@ 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(),"layout":null});
|
||||
let mut record = merge_fields(defaults.as_object().unwrap().clone(), fields)
|
||||
serde_json::json!({"description":"","layer":"Oa","projection":"Overview","routes":[],"view_options":crate::view_options::ViewOptions::default()});
|
||||
let defaults = if project.schema_version >= 7 {
|
||||
source_defaults::parser_fields(DefaultContext::Diagram)
|
||||
} else { defaults.as_object().unwrap().clone() };
|
||||
let mut record = merge_fields(defaults, fields)
|
||||
.map_err(&conversion_error)?;
|
||||
header(&mut record, d, true);
|
||||
record.insert("nodes".into(), serde_json::json!({}));
|
||||
@@ -1839,7 +1864,10 @@ pub(crate) fn resolve_declarations(
|
||||
return Err(conversion_error("Missing required field 'element'.".into()));
|
||||
}
|
||||
let defaults = serde_json::json!({"element":null,"position":{"x":0.0,"y":0.0},"size":{"width":224.0,"height":94.0},"collapsed":false,"port_anchors":[]});
|
||||
let mut record = merge_fields(defaults.as_object().unwrap().clone(), fields)
|
||||
let defaults = if project.schema_version >= 7 {
|
||||
source_defaults::parser_fields(DefaultContext::Node)
|
||||
} else { defaults.as_object().unwrap().clone() };
|
||||
let mut record = merge_fields(defaults, fields)
|
||||
.map_err(&conversion_error)?;
|
||||
header(&mut record, d, false);
|
||||
let node: NodePlacement = serde_json::from_value(Value::Object(record))
|
||||
@@ -1851,7 +1879,10 @@ pub(crate) fn resolve_declarations(
|
||||
return Err(conversion_error("Missing required field 'owner'.".into()));
|
||||
}
|
||||
let defaults = serde_json::json!({"description":"","owner":null,"relation":"References","uri":"","revision":null,"fragment":null});
|
||||
let mut record = merge_fields(defaults.as_object().unwrap().clone(), fields)
|
||||
let defaults = if project.schema_version >= 7 {
|
||||
source_defaults::parser_fields(DefaultContext::Artifact)
|
||||
} else { defaults.as_object().unwrap().clone() };
|
||||
let mut record = merge_fields(defaults, fields)
|
||||
.map_err(&conversion_error)?;
|
||||
header(&mut record, d, true);
|
||||
let artifact = serde_json::from_value(Value::Object(record))
|
||||
@@ -1867,7 +1898,10 @@ pub(crate) fn resolve_declarations(
|
||||
}
|
||||
}
|
||||
let defaults = serde_json::json!({"description":"","oa":null,"sa":null,"kind":"Refinement","baseline":null});
|
||||
let mut record = merge_fields(defaults.as_object().unwrap().clone(), fields)
|
||||
let defaults = if project.schema_version >= 7 {
|
||||
source_defaults::parser_fields(DefaultContext::Trace)
|
||||
} else { defaults.as_object().unwrap().clone() };
|
||||
let mut record = merge_fields(defaults, fields)
|
||||
.map_err(&conversion_error)?;
|
||||
header(&mut record, d, true);
|
||||
let trace = if project.schema_version < 5 {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
//! Framework-independent OA/SA model, transactional commands and editing services.
|
||||
pub mod analysis_numeric;
|
||||
pub mod data;
|
||||
pub mod diagram_edit;
|
||||
pub mod language;
|
||||
pub mod model;
|
||||
pub mod operations;
|
||||
@@ -8,9 +10,9 @@ pub mod ports;
|
||||
pub mod projection;
|
||||
pub mod references;
|
||||
pub mod routing;
|
||||
pub mod scenario_layout;
|
||||
pub mod semantic;
|
||||
pub mod session;
|
||||
mod source_defaults;
|
||||
pub mod source_files;
|
||||
pub mod source_project;
|
||||
pub mod topology_context;
|
||||
@@ -19,9 +21,6 @@ pub mod validation;
|
||||
pub mod view_options;
|
||||
pub use model::*;
|
||||
pub use ports::*;
|
||||
pub use scenario_layout::{
|
||||
DiagramLayout, ScenarioLanePosition, ScenarioLayout, ScenarioMessagePosition,
|
||||
};
|
||||
pub use semantic::*;
|
||||
pub use session::*;
|
||||
mod demo;
|
||||
|
||||
+34
-27
@@ -1436,7 +1436,6 @@ pub enum ProjectionKind {
|
||||
FunctionalDataflow,
|
||||
Capabilities,
|
||||
StateMachine { machine: Id },
|
||||
Scenario { scenario: Id },
|
||||
}
|
||||
impl ProjectionKind {
|
||||
pub fn state_machine(self) -> Option<Id> {
|
||||
@@ -1446,30 +1445,8 @@ impl ProjectionKind {
|
||||
}
|
||||
}
|
||||
|
||||
/// The semantic owner of a saved Scenario representation.
|
||||
pub fn scenario(self) -> Option<Id> {
|
||||
match self {
|
||||
Self::Scenario { scenario } => Some(scenario),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Explicitly classify architecture views; absence of a state machine is insufficient.
|
||||
pub fn is_architecture(self) -> bool {
|
||||
match self {
|
||||
Self::Overview
|
||||
| Self::Decomposition
|
||||
| Self::Allocation
|
||||
| Self::Traceability
|
||||
| Self::FunctionalDataflow
|
||||
| Self::Capabilities => true,
|
||||
Self::StateMachine { .. } | Self::Scenario { .. } => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn includes(self, kind: Kind) -> bool {
|
||||
match self {
|
||||
Self::Scenario { .. } => false,
|
||||
Self::StateMachine { .. } => {
|
||||
matches!(kind, Kind::StateMachine | Kind::Region) || kind.is_state()
|
||||
}
|
||||
@@ -1494,7 +1471,6 @@ impl std::fmt::Display for ProjectionKind {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(match self {
|
||||
Self::StateMachine { .. } => "State machine",
|
||||
Self::Scenario { .. } => "Scenario",
|
||||
Self::Allocation => "Architecture · allocated work",
|
||||
Self::FunctionalDataflow => "Functional dataflow",
|
||||
Self::Capabilities => "Missions & capabilities",
|
||||
@@ -1519,8 +1495,6 @@ pub struct Diagram {
|
||||
pub nodes: BTreeMap<Id, NodePlacement>,
|
||||
#[serde(default)]
|
||||
pub routes: Vec<EdgeRoute>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub layout: Option<crate::scenario_layout::DiagramLayout>,
|
||||
}
|
||||
/// Authored presentation for one existing relation or trace in one diagram.
|
||||
/// Computed endpoints and automatic routing elbows are never persisted here.
|
||||
@@ -1528,11 +1502,45 @@ pub struct Diagram {
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct EdgeRoute {
|
||||
pub edge: Id,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub source_attachment: Option<EdgeAttachment>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub target_attachment: Option<EdgeAttachment>,
|
||||
#[serde(default)]
|
||||
pub waypoints: Vec<Position>,
|
||||
#[serde(default)]
|
||||
pub label: Option<EdgeLabel>,
|
||||
}
|
||||
|
||||
/// Presentation preference on one end of a connection. Real ports take precedence.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct EdgeAttachment {
|
||||
pub side: PortAnchorSide,
|
||||
pub offset: f32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum EdgeEnd {
|
||||
Source,
|
||||
Target,
|
||||
}
|
||||
|
||||
impl EdgeRoute {
|
||||
pub fn attachment(&self, end: EdgeEnd) -> Option<EdgeAttachment> {
|
||||
match end {
|
||||
EdgeEnd::Source => self.source_attachment,
|
||||
EdgeEnd::Target => self.target_attachment,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_attachment(&mut self, end: EdgeEnd, attachment: Option<EdgeAttachment>) {
|
||||
match end {
|
||||
EdgeEnd::Source => self.source_attachment = attachment,
|
||||
EdgeEnd::Target => self.target_attachment = attachment,
|
||||
}
|
||||
}
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct EdgeLabel {
|
||||
@@ -1947,7 +1955,6 @@ impl Project {
|
||||
view_options: crate::view_options::ViewOptions::default(),
|
||||
nodes: BTreeMap::new(),
|
||||
routes: vec![],
|
||||
layout: None,
|
||||
},
|
||||
);
|
||||
id
|
||||
|
||||
@@ -278,11 +278,6 @@ fn project_diagram_impl(
|
||||
mut availability: Option<&mut BTreeMap<Id, crate::view_options::EdgeAvailability>>,
|
||||
index: Option<&crate::view_options::ViewIndex>,
|
||||
) -> ProjectedView {
|
||||
// Saved Scenario layout is not an architecture/state projection. Its dedicated
|
||||
// renderer is not wired yet; never interpret its payload through this fallback.
|
||||
if d.projection.scenario().is_some() {
|
||||
return ProjectedView::default();
|
||||
}
|
||||
let parents = parents(p, d);
|
||||
let mut nodes = Vec::new();
|
||||
for n in d.nodes.values() {
|
||||
@@ -399,7 +394,6 @@ fn project_diagram_impl(
|
||||
continue;
|
||||
}
|
||||
let included = match d.projection {
|
||||
ProjectionKind::Scenario { .. } => false,
|
||||
ProjectionKind::StateMachine { machine } => matches!(&r.body,
|
||||
RelationBody::StateTransition { transition_kind, .. }
|
||||
if *transition_kind != TransitionKind::Internal
|
||||
|
||||
@@ -203,81 +203,7 @@ pub fn visit_relation_refs(r: &mut Relation, mut visit: impl FnMut(&mut Id)) {
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
/// Every semantic/presentation reference in a diagram, excluding its own identities.
|
||||
pub fn diagram_references(d: &Diagram) -> Vec<Id> {
|
||||
let mut copy = d.clone();
|
||||
let mut refs = Vec::new();
|
||||
visit_diagram_refs(&mut copy, |id| refs.push(*id));
|
||||
refs
|
||||
}
|
||||
|
||||
pub fn visit_diagram_refs(d: &mut Diagram, mut visit: impl FnMut(&mut Id)) {
|
||||
d.view_options.visit_references(&mut visit);
|
||||
match &mut d.projection {
|
||||
ProjectionKind::StateMachine { machine } => visit(machine),
|
||||
ProjectionKind::Scenario { scenario } => visit(scenario),
|
||||
_ => {}
|
||||
}
|
||||
if let Some(layout) = &mut d.layout {
|
||||
layout.visit_references(&mut visit);
|
||||
}
|
||||
for route in &mut d.routes {
|
||||
visit(&mut route.edge);
|
||||
}
|
||||
for node in d.nodes.values_mut() {
|
||||
for anchor in &mut node.port_anchors {
|
||||
visit(&mut anchor.port);
|
||||
}
|
||||
visit(&mut node.element);
|
||||
}
|
||||
}
|
||||
|
||||
/// Preflight reference-key collisions before the caller changes object identities.
|
||||
/// Fresh targets are allowed. This is not whole-project admission: unrelated
|
||||
/// invalid records remain repairable within a transaction. Future typed reference
|
||||
/// domains must compose their own preflight here before any remap mutation.
|
||||
pub fn check_remap(p: &Project, old: Id, new: Id) -> Result<(), ModelError> {
|
||||
if old == new {
|
||||
return Ok(());
|
||||
}
|
||||
let remap = |id: Id| if id == old { new } else { id };
|
||||
for diagram in p.diagrams.values() {
|
||||
let Some(DiagramLayout::Scenario(layout)) = &diagram.layout else {
|
||||
continue;
|
||||
};
|
||||
let mut lanes = std::collections::BTreeMap::new();
|
||||
for lane in &layout.lanes {
|
||||
let affected = lane.lifeline == old;
|
||||
if let Some(previous_affected) = lanes.insert(remap(lane.lifeline), affected)
|
||||
&& (affected || previous_affected)
|
||||
{
|
||||
return Err(ModelError(
|
||||
"Identity remap would collide with a Scenario lane position.".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
let mut messages = std::collections::BTreeMap::new();
|
||||
for message in &layout.messages {
|
||||
let mut address = message.address.clone();
|
||||
let affected = address.occurrence == old || address.references.contains(&old);
|
||||
address.occurrence = remap(address.occurrence);
|
||||
for reference in &mut address.references {
|
||||
*reference = remap(*reference);
|
||||
}
|
||||
if let Some(previous_affected) = messages.insert(address, affected)
|
||||
&& (affected || previous_affected)
|
||||
{
|
||||
return Err(ModelError(
|
||||
"Identity remap would collide with a Scenario message position.".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn remap_references(p: &mut Project, old: Id, new: Id) -> Result<(), ModelError> {
|
||||
check_remap(p, old, new)?;
|
||||
pub fn remap_references(p: &mut Project, old: Id, new: Id) {
|
||||
for e in p.elements.values_mut() {
|
||||
visit_element_refs(e, |id| {
|
||||
if *id == old {
|
||||
@@ -293,11 +219,31 @@ pub fn remap_references(p: &mut Project, old: Id, new: Id) -> Result<(), ModelEr
|
||||
});
|
||||
}
|
||||
for d in p.diagrams.values_mut() {
|
||||
visit_diagram_refs(d, |id| {
|
||||
d.view_options.visit_references(|id| {
|
||||
if *id == old {
|
||||
*id = new;
|
||||
}
|
||||
});
|
||||
if let ProjectionKind::StateMachine { machine } = &mut d.projection
|
||||
&& *machine == old
|
||||
{
|
||||
*machine = new;
|
||||
}
|
||||
for route in &mut d.routes {
|
||||
if route.edge == old {
|
||||
route.edge = new;
|
||||
}
|
||||
}
|
||||
for n in d.nodes.values_mut() {
|
||||
for anchor in &mut n.port_anchors {
|
||||
if anchor.port == old {
|
||||
anchor.port = new;
|
||||
}
|
||||
}
|
||||
if n.element == old {
|
||||
n.element = new;
|
||||
}
|
||||
}
|
||||
}
|
||||
for a in p.artifact_links.values_mut() {
|
||||
if a.owner == old {
|
||||
@@ -319,7 +265,6 @@ pub fn remap_references(p: &mut Project, old: Id, new: Id) -> Result<(), ModelEr
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// IDs removed by a cascade. Cross-layer traced counterparts are never included.
|
||||
pub fn deletion_impact(p: &Project, id: Id) -> BTreeSet<Id> {
|
||||
@@ -394,8 +339,7 @@ fn deletion_impact_inner(p: &Project, id: Id, defer_exchange_allocations: bool)
|
||||
for d in p.diagrams.values() {
|
||||
if d.projection
|
||||
.state_machine()
|
||||
.or_else(|| d.projection.scenario())
|
||||
.is_some_and(|owner| removed.contains(&owner))
|
||||
.is_some_and(|machine| removed.contains(&machine))
|
||||
{
|
||||
removed.insert(d.id);
|
||||
}
|
||||
@@ -468,7 +412,6 @@ fn remove_objects(p: &mut Project, removed: BTreeSet<Id>) {
|
||||
p.artifact_links.retain(|id, _| !removed.contains(id));
|
||||
for d in p.diagrams.values_mut() {
|
||||
d.view_options.retain_references(|id| !removed.contains(id));
|
||||
crate::scenario_layout::prune_deleted(d, &removed);
|
||||
d.nodes.retain(|id, _| !removed.contains(id));
|
||||
d.routes.retain(|route| !removed.contains(&route.edge));
|
||||
for node in d.nodes.values_mut() {
|
||||
@@ -646,7 +589,3 @@ fn remove_objects(p: &mut Project, removed: BTreeSet<Id>) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "references/scenario_layout_tests.rs"]
|
||||
mod scenario_layout_tests;
|
||||
|
||||
@@ -1,388 +0,0 @@
|
||||
use super::*;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
struct Fixture {
|
||||
project: Project,
|
||||
owner: Id,
|
||||
lanes: [Id; 2],
|
||||
messages: [Id; 2],
|
||||
views: [Id; 2],
|
||||
}
|
||||
|
||||
fn address(occurrence: Id) -> OccurrenceEndpoint {
|
||||
OccurrenceEndpoint {
|
||||
references: vec![],
|
||||
occurrence,
|
||||
}
|
||||
}
|
||||
|
||||
fn layout(project: &Project, view: Id) -> &ScenarioLayout {
|
||||
let Some(DiagramLayout::Scenario(layout)) = &project.diagrams[&view].layout else {
|
||||
panic!("expected authored Scenario positions")
|
||||
};
|
||||
layout
|
||||
}
|
||||
|
||||
fn layout_mut(project: &mut Project, view: Id) -> &mut ScenarioLayout {
|
||||
let Some(DiagramLayout::Scenario(layout)) =
|
||||
&mut project.diagrams.get_mut(&view).unwrap().layout
|
||||
else {
|
||||
panic!("expected authored Scenario positions")
|
||||
};
|
||||
layout
|
||||
}
|
||||
|
||||
fn admitted(project: &Project) {
|
||||
let errors: Vec<_> = crate::validation::validate(project)
|
||||
.into_iter()
|
||||
.filter(|issue| issue.severity == crate::validation::Severity::Error)
|
||||
.collect();
|
||||
assert!(errors.is_empty(), "{errors:?}");
|
||||
}
|
||||
|
||||
fn fixture() -> Fixture {
|
||||
let mut project = Project::empty();
|
||||
let owner = project
|
||||
.add_element(Kind::Scenario, Layer::Oa, "Delivery", None)
|
||||
.unwrap();
|
||||
let lanes = ["Sender", "Receiver"].map(|name| {
|
||||
project
|
||||
.add_element(Kind::Lifeline, Layer::Oa, name, Some(owner))
|
||||
.unwrap()
|
||||
});
|
||||
let messages = ["First message", "Second message"].map(|name| {
|
||||
let id = project
|
||||
.add_element(Kind::Occurrence, Layer::Oa, name, Some(owner))
|
||||
.unwrap();
|
||||
*project.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange {
|
||||
exchange: None,
|
||||
sender: Some(lanes[0]),
|
||||
receiver: Some(lanes[1]),
|
||||
message_kind: MessageKind::Asynchronous,
|
||||
reply_to: None,
|
||||
values: vec![],
|
||||
operation: None,
|
||||
},
|
||||
precondition: String::new(),
|
||||
postcondition: String::new(),
|
||||
};
|
||||
id
|
||||
});
|
||||
*project
|
||||
.elements
|
||||
.get_mut(&owner)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap() = messages.to_vec();
|
||||
let views = ["delivery_view", "review_view"].map(|alias| {
|
||||
let id = Id::new_v4();
|
||||
project.diagrams.insert(
|
||||
id,
|
||||
Diagram {
|
||||
id,
|
||||
alias: alias.into(),
|
||||
name: alias.into(),
|
||||
description: String::new(),
|
||||
layer: Layer::Oa,
|
||||
projection: ProjectionKind::Scenario { scenario: owner },
|
||||
view_options: Default::default(),
|
||||
nodes: BTreeMap::new(),
|
||||
routes: vec![],
|
||||
layout: Some(DiagramLayout::Scenario(ScenarioLayout {
|
||||
lanes: vec![
|
||||
ScenarioLanePosition {
|
||||
lifeline: lanes[0],
|
||||
x: 120.,
|
||||
},
|
||||
ScenarioLanePosition {
|
||||
lifeline: lanes[1],
|
||||
x: 420.,
|
||||
},
|
||||
],
|
||||
messages: vec![
|
||||
ScenarioMessagePosition {
|
||||
address: address(messages[0]),
|
||||
send_y: 220.,
|
||||
},
|
||||
ScenarioMessagePosition {
|
||||
address: address(messages[1]),
|
||||
send_y: 360.,
|
||||
},
|
||||
],
|
||||
})),
|
||||
},
|
||||
);
|
||||
id
|
||||
});
|
||||
admitted(&project);
|
||||
Fixture {
|
||||
project,
|
||||
owner,
|
||||
lanes,
|
||||
messages,
|
||||
views,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fresh_identity_swap_preserves_coordinates_and_semantic_sequence() {
|
||||
let mut f = fixture();
|
||||
let before = f.project.clone();
|
||||
let temporary = Id::new_v4();
|
||||
for ids in [f.lanes, f.messages] {
|
||||
f.project = crate::execute_batch(
|
||||
&f.project,
|
||||
&[
|
||||
Command::Reidentify(ids[0], temporary),
|
||||
Command::Reidentify(ids[1], ids[0]),
|
||||
Command::Reidentify(temporary, ids[1]),
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
admitted(&f.project);
|
||||
assert_eq!(
|
||||
f.project.elements[&f.owner].steps().unwrap(),
|
||||
&vec![f.messages[1], f.messages[0]]
|
||||
);
|
||||
for view in f.views {
|
||||
let rows = layout(&f.project, view);
|
||||
assert_eq!(
|
||||
rows.lanes,
|
||||
vec![
|
||||
ScenarioLanePosition {
|
||||
lifeline: f.lanes[1],
|
||||
x: 120.
|
||||
},
|
||||
ScenarioLanePosition {
|
||||
lifeline: f.lanes[0],
|
||||
x: 420.
|
||||
}
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
rows.messages,
|
||||
vec![
|
||||
ScenarioMessagePosition {
|
||||
address: address(f.messages[1]),
|
||||
send_y: 220.
|
||||
},
|
||||
ScenarioMessagePosition {
|
||||
address: address(f.messages[0]),
|
||||
send_y: 360.
|
||||
}
|
||||
]
|
||||
);
|
||||
}
|
||||
// Reverse the swaps: no values or identities other than those requested drift.
|
||||
for ids in [f.lanes, f.messages] {
|
||||
f.project = crate::execute_batch(
|
||||
&f.project,
|
||||
&[
|
||||
Command::Reidentify(ids[0], temporary),
|
||||
Command::Reidentify(ids[1], ids[0]),
|
||||
Command::Reidentify(temporary, ids[1]),
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
assert_eq!(f.project, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colliding_lane_or_message_remap_rejects_before_any_reference_changes() {
|
||||
for message in [false, true] {
|
||||
let mut f = fixture();
|
||||
let [old, new] = if message { f.messages } else { f.lanes };
|
||||
let before = f.project.clone();
|
||||
assert!(check_remap(&f.project, old, new).is_err());
|
||||
assert!(remap_references(&mut f.project, old, new).is_err());
|
||||
assert_eq!(f.project, before);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reidentify_preflight_precedes_object_map_mutation_for_fresh_colliding_target() {
|
||||
let mut f = fixture();
|
||||
let fresh = Id::new_v4();
|
||||
// A repair candidate can already have a dangling coordinate key. It must not
|
||||
// silently merge with the live lane, even though the destination ID is vacant.
|
||||
layout_mut(&mut f.project, f.views[0]).lanes[1].lifeline = fresh;
|
||||
let before = f.project.clone();
|
||||
assert!(crate::session::reidentify(&mut f.project, f.lanes[0], fresh).is_err());
|
||||
assert_eq!(f.project, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_address_paths_are_walked_and_collision_checked_without_admitting_expansion() {
|
||||
let mut f = fixture();
|
||||
let [old, new] = [Id::new_v4(), Id::new_v4()];
|
||||
let rows = layout_mut(&mut f.project, f.views[0]);
|
||||
// Future/invalid expanded candidates still participate in reference safety.
|
||||
rows.messages[0].address.references = vec![old];
|
||||
rows.messages[1].address = OccurrenceEndpoint {
|
||||
references: vec![new],
|
||||
occurrence: f.messages[0],
|
||||
};
|
||||
let refs = diagram_references(&f.project.diagrams[&f.views[0]]);
|
||||
assert!(refs.contains(&f.owner) && refs.contains(&old) && refs.contains(&new));
|
||||
let before = f.project.clone();
|
||||
assert!(remap_references(&mut f.project, old, new).is_err());
|
||||
assert_eq!(f.project, before);
|
||||
let fresh = Id::new_v4();
|
||||
remap_references(&mut f.project, old, fresh).unwrap();
|
||||
let rows = layout(&f.project, f.views[0]);
|
||||
assert_eq!(rows.messages[0].address.references, vec![fresh]);
|
||||
assert_eq!(rows.messages[0].address.occurrence, f.messages[0]);
|
||||
assert_eq!(rows.messages[1].address.references, vec![new]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn owner_reidentify_and_delete_cover_all_saved_views_without_deleting_other_owners() {
|
||||
let mut f = fixture();
|
||||
let other_owner = f
|
||||
.project
|
||||
.add_element(Kind::Scenario, Layer::Oa, "Independent", None)
|
||||
.unwrap();
|
||||
let mut other_view = f.project.diagrams[&f.views[0]].clone();
|
||||
other_view.id = Id::new_v4();
|
||||
other_view.alias = "independent_view".into();
|
||||
other_view.projection = ProjectionKind::Scenario {
|
||||
scenario: other_owner,
|
||||
};
|
||||
other_view.layout = None;
|
||||
f.project.diagrams.insert(other_view.id, other_view.clone());
|
||||
admitted(&f.project);
|
||||
let fresh_owner = Id::new_v4();
|
||||
f.project = crate::prepare_command(&f.project, &Command::Reidentify(f.owner, fresh_owner))
|
||||
.unwrap()
|
||||
.project;
|
||||
for view in f.views {
|
||||
assert_eq!(
|
||||
f.project.diagrams[&view].projection.scenario(),
|
||||
Some(fresh_owner)
|
||||
);
|
||||
}
|
||||
let impact = deletion_impact(&f.project, fresh_owner);
|
||||
assert!(
|
||||
f.views
|
||||
.iter()
|
||||
.chain(&f.lanes)
|
||||
.chain(&f.messages)
|
||||
.all(|id| impact.contains(id))
|
||||
);
|
||||
assert!(!impact.contains(&other_owner) && !impact.contains(&other_view.id));
|
||||
delete_cascade(&mut f.project, fresh_owner);
|
||||
assert_eq!(f.project.diagrams[&other_view.id], other_view);
|
||||
assert!(
|
||||
f.views
|
||||
.iter()
|
||||
.all(|id| !f.project.diagrams.contains_key(id))
|
||||
);
|
||||
admitted(&f.project);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deletion_then_recreation_never_resurrects_lane_or_message_coordinates() {
|
||||
let mut f = fixture();
|
||||
let lane = f.project.elements[&f.lanes[0]].clone();
|
||||
let message = f.project.elements[&f.messages[0]].clone();
|
||||
delete_cascade(&mut f.project, lane.id);
|
||||
for view in f.views {
|
||||
let rows = layout(&f.project, view);
|
||||
assert_eq!(rows.lanes.len(), 1);
|
||||
assert_eq!(rows.lanes[0].lifeline, f.lanes[1]);
|
||||
// Missing endpoint assignment does not erase a still-live message's spacing.
|
||||
assert_eq!(rows.messages.len(), 2);
|
||||
}
|
||||
let mut pending = BTreeSet::new();
|
||||
delete_cascade_deferred(&mut f.project, message.id, &mut pending);
|
||||
// Pruning happens during deletion, before later batch commands can recreate IDs.
|
||||
assert!(
|
||||
layout(&f.project, f.views[0])
|
||||
.messages
|
||||
.iter()
|
||||
.all(|row| row.address.occurrence != message.id)
|
||||
);
|
||||
f.project.elements.insert(lane.id, lane);
|
||||
f.project.elements.insert(message.id, message);
|
||||
f.project
|
||||
.elements
|
||||
.get_mut(&f.owner)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap()
|
||||
.insert(0, f.messages[0]);
|
||||
finish_deletions(&mut f.project, &pending);
|
||||
for view in f.views {
|
||||
let rows = layout(&f.project, view);
|
||||
assert_eq!(rows.lanes.len(), 1);
|
||||
assert_eq!(
|
||||
rows.messages,
|
||||
vec![ScenarioMessagePosition {
|
||||
address: address(f.messages[1]),
|
||||
send_y: 360.
|
||||
}]
|
||||
);
|
||||
}
|
||||
admitted(&f.project);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unrelated_invalid_records_do_not_block_scoped_remap_or_get_repaired_implicitly() {
|
||||
let mut f = fixture();
|
||||
let unrelated = Id::new_v4();
|
||||
let rows = layout_mut(&mut f.project, f.views[1]);
|
||||
let invalid = ScenarioLanePosition {
|
||||
lifeline: unrelated,
|
||||
x: MAX_COORDINATE * 2.,
|
||||
};
|
||||
rows.lanes.extend([invalid.clone(), invalid]);
|
||||
let fresh = Id::new_v4();
|
||||
let untouched = f.project.diagrams[&f.views[1]].clone();
|
||||
let before = f.project.clone();
|
||||
remap_references(&mut f.project, f.owner, f.owner).unwrap();
|
||||
assert_eq!(f.project, before);
|
||||
// The duplicate lane records do not involve the remapped message identity.
|
||||
remap_references(&mut f.project, f.messages[0], fresh).unwrap();
|
||||
assert_eq!(
|
||||
layout(&f.project, f.views[1]).lanes,
|
||||
layout_from(&untouched).lanes
|
||||
);
|
||||
assert_eq!(
|
||||
layout(&f.project, f.views[1]).messages[0]
|
||||
.address
|
||||
.occurrence,
|
||||
fresh
|
||||
);
|
||||
assert_eq!(layout(&f.project, f.views[1]).messages[0].send_y, 220.);
|
||||
}
|
||||
|
||||
fn layout_from(diagram: &Diagram) -> &ScenarioLayout {
|
||||
let Some(DiagramLayout::Scenario(layout)) = &diagram.layout else {
|
||||
panic!("missing layout")
|
||||
};
|
||||
layout
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deletion_prunes_an_explicit_address_path_without_pruning_other_message_rows() {
|
||||
let mut f = fixture();
|
||||
// Expanded addresses are not admitted in this slice, but deleting their
|
||||
// explicit references must still make an intermediate repair candidate safe.
|
||||
layout_mut(&mut f.project, f.views[0]).messages[0]
|
||||
.address
|
||||
.references = vec![f.lanes[0]];
|
||||
delete_cascade(&mut f.project, f.lanes[0]);
|
||||
assert_eq!(
|
||||
layout(&f.project, f.views[0]).messages,
|
||||
vec![ScenarioMessagePosition {
|
||||
address: address(f.messages[1]),
|
||||
send_y: 360.
|
||||
}]
|
||||
);
|
||||
assert_eq!(layout(&f.project, f.views[1]).messages.len(), 2);
|
||||
assert!(f.project.elements.contains_key(&f.messages[0]));
|
||||
admitted(&f.project);
|
||||
}
|
||||
+132
-38
@@ -1,7 +1,12 @@
|
||||
//! Deterministic, bounded diagram geometry. Only authored pins belong in the model;
|
||||
//! callers share this derived geometry for drawing, picking, fitting and export.
|
||||
use crate::projection::{self, ProjectedEdge, ProjectedNode, ProjectedView};
|
||||
use crate::{Diagram, Id, Position, Project, Relation, RelationBody, RelationKind, Size};
|
||||
use crate::{
|
||||
Diagram, EdgeAttachment, EdgeEnd, Id, PortAnchorSide, Position, Project, Relation,
|
||||
RelationBody, RelationKind, Size,
|
||||
};
|
||||
mod attachments;
|
||||
pub use attachments::{attachment_from_point, attachment_point, semantic_port_endpoint};
|
||||
use std::cmp::Ordering;
|
||||
use std::collections::{BTreeMap, BTreeSet, BinaryHeap};
|
||||
use std::fmt;
|
||||
@@ -41,6 +46,7 @@ pub struct RoutedEdge {
|
||||
// Derived solver state, never persisted. A preview must inherit the same
|
||||
// remaining work budget as this edge had in stable-ID settled traversal.
|
||||
search_budget_before: usize,
|
||||
attachments: [Option<EdgeAttachment>; 2],
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
@@ -173,6 +179,7 @@ pub fn association_end_geometry(
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum RouteWarning {
|
||||
CoincidentAttachments,
|
||||
PinInsideObstacle { pin: usize, obstacle: Id },
|
||||
NoClearPath,
|
||||
SearchBudgetExhausted,
|
||||
@@ -181,6 +188,9 @@ pub enum RouteWarning {
|
||||
impl fmt::Display for RouteWarning {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::CoincidentAttachments => f.write_str(
|
||||
"The two attachments coincide; move either end to separate the loop tips.",
|
||||
),
|
||||
Self::PinInsideObstacle { pin, .. } => {
|
||||
write!(f, "Bend {} is inside a card or its clearance", pin + 1)
|
||||
}
|
||||
@@ -374,7 +384,14 @@ struct Endpoint {
|
||||
point: Position,
|
||||
stub: Position,
|
||||
}
|
||||
fn endpoints(node: &ProjectedNode, port: Option<Id>, view: &ProjectedView) -> Vec<Endpoint> {
|
||||
fn endpoints(
|
||||
project: &Project,
|
||||
diagram: &Diagram,
|
||||
node: &ProjectedNode,
|
||||
port: Option<Id>,
|
||||
view: &ProjectedView,
|
||||
attachment: Option<EdgeAttachment>,
|
||||
) -> Vec<Endpoint> {
|
||||
if let Some(port) = port.and_then(|id| {
|
||||
view.ports
|
||||
.iter()
|
||||
@@ -389,26 +406,18 @@ fn endpoints(node: &ProjectedNode, port: Option<Id>, view: &ProjectedView) -> Ve
|
||||
),
|
||||
}];
|
||||
}
|
||||
let r = Rect::node(node);
|
||||
let mid = pos((r.left + r.right) / 2.0, (r.top + r.bottom) / 2.0);
|
||||
vec![
|
||||
Endpoint {
|
||||
point: pos(r.left, mid.y),
|
||||
stub: pos(r.left - PORT_STUB, mid.y),
|
||||
},
|
||||
Endpoint {
|
||||
point: pos(r.right, mid.y),
|
||||
stub: pos(r.right + PORT_STUB, mid.y),
|
||||
},
|
||||
Endpoint {
|
||||
point: pos(mid.x, r.top),
|
||||
stub: pos(mid.x, r.top - PORT_STUB),
|
||||
},
|
||||
Endpoint {
|
||||
point: pos(mid.x, r.bottom),
|
||||
stub: pos(mid.x, r.bottom + PORT_STUB),
|
||||
},
|
||||
if let Some(attachment) = attachment {
|
||||
return vec![attachments::endpoint(project, diagram, node, attachment)];
|
||||
}
|
||||
[
|
||||
PortAnchorSide::Left,
|
||||
PortAnchorSide::Right,
|
||||
PortAnchorSide::Top,
|
||||
PortAnchorSide::Bottom,
|
||||
]
|
||||
.into_iter()
|
||||
.map(|side| attachments::endpoint(project, diagram, node, EdgeAttachment { side, offset: 0.5 }))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn ancestors(id: Id, nodes: &BTreeMap<Id, &ProjectedNode>, out: &mut BTreeSet<Id>) {
|
||||
@@ -507,6 +516,12 @@ fn route_diagram_impl(
|
||||
.is_some_and(|(source, target)| source != edge.source || target != edge.target);
|
||||
let custom_suspended = promoted;
|
||||
let route = if promoted { None } else { stored };
|
||||
let attachments = [EdgeEnd::Source, EdgeEnd::Target].map(|end| {
|
||||
semantic_port_endpoint(project, edge.id, end)
|
||||
.is_none()
|
||||
.then(|| route.and_then(|r| r.attachment(end)))
|
||||
.flatten()
|
||||
});
|
||||
let pins = route.map_or_else(Vec::new, |r| r.waypoints.clone());
|
||||
let obstacles = edge_obstacles(edge, &nodes, &rectangles, &headers);
|
||||
|
||||
@@ -517,7 +532,10 @@ fn route_diagram_impl(
|
||||
});
|
||||
if let Some(cached) = cached {
|
||||
budget = cached.search_budget_before;
|
||||
if cached.pins == pins && cached.custom_suspended == custom_suspended {
|
||||
if cached.pins == pins
|
||||
&& cached.attachments == attachments
|
||||
&& cached.custom_suspended == custom_suspended
|
||||
{
|
||||
// Label-only editing cannot change the connection beneath the
|
||||
// pointer, including a settled fallback with routing warnings.
|
||||
for &point in &cached.points {
|
||||
@@ -543,9 +561,22 @@ fn route_diagram_impl(
|
||||
}
|
||||
let (points, pin_lengths, failure) = route_edge(
|
||||
edge,
|
||||
source,
|
||||
target,
|
||||
view,
|
||||
&endpoints(
|
||||
project,
|
||||
diagram,
|
||||
source,
|
||||
semantic_port_endpoint(project, edge.id, EdgeEnd::Source).or(edge.source_port),
|
||||
view,
|
||||
attachments[0],
|
||||
),
|
||||
&endpoints(
|
||||
project,
|
||||
diagram,
|
||||
target,
|
||||
semantic_port_endpoint(project, edge.id, EdgeEnd::Target).or(edge.target_port),
|
||||
view,
|
||||
attachments[1],
|
||||
),
|
||||
&pins,
|
||||
&obstacles,
|
||||
&previous,
|
||||
@@ -574,6 +605,7 @@ fn route_diagram_impl(
|
||||
warnings,
|
||||
custom_suspended,
|
||||
search_budget_before,
|
||||
attachments,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -679,6 +711,46 @@ struct RoutingSpace<'a> {
|
||||
xs: Vec<f32>,
|
||||
ys: Vec<f32>,
|
||||
}
|
||||
|
||||
fn coincident_detour(a: Endpoint, obstacles: &[Rect]) -> [Position; 3] {
|
||||
let direction = |delta: f32| if delta == 0. { 0. } else { delta.signum() };
|
||||
let normal = pos(
|
||||
direction(a.stub.x - a.point.x),
|
||||
direction(a.stub.y - a.point.y),
|
||||
);
|
||||
let tangent = pos(-normal.y, normal.x);
|
||||
let candidates: Vec<_> = [36., 72., 144., 288.]
|
||||
.into_iter()
|
||||
.flat_map(|extent| {
|
||||
[-1., 1.].into_iter().map(move |side| {
|
||||
[
|
||||
pos(a.stub.x + normal.x * extent, a.stub.y + normal.y * extent),
|
||||
pos(
|
||||
a.stub.x + (normal.x + tangent.x * side) * extent,
|
||||
a.stub.y + (normal.y + tangent.y * side) * extent,
|
||||
),
|
||||
pos(
|
||||
a.stub.x + tangent.x * side * extent,
|
||||
a.stub.y + tangent.y * side * extent,
|
||||
),
|
||||
]
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
// These are computed loop guards, not authored hard pins. Prefer a clear
|
||||
// orientation/size before asking the bounded router to connect them.
|
||||
candidates
|
||||
.iter()
|
||||
.find(|p| clear_path(&[a.stub, p[0], p[1], p[2], a.stub], obstacles))
|
||||
.or_else(|| {
|
||||
candidates.iter().find(|p| {
|
||||
p.iter()
|
||||
.all(|point| !obstacles.iter().any(|r| r.contains(*point)))
|
||||
})
|
||||
})
|
||||
.copied()
|
||||
.unwrap_or(candidates[0])
|
||||
}
|
||||
impl<'a> RoutingSpace<'a> {
|
||||
fn new(obstacles: &'a [Rect]) -> Self {
|
||||
Self {
|
||||
@@ -692,24 +764,41 @@ impl<'a> RoutingSpace<'a> {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn route_edge(
|
||||
edge: &ProjectedEdge,
|
||||
source: &ProjectedNode,
|
||||
target: &ProjectedNode,
|
||||
view: &ProjectedView,
|
||||
source_ends: &[Endpoint],
|
||||
target_ends: &[Endpoint],
|
||||
pins: &[Position],
|
||||
obstacles: &[Rect],
|
||||
previous: &[Segment],
|
||||
budget: &mut usize,
|
||||
) -> EdgePath {
|
||||
// Coincident authored tips are valid presentation, including on self loops.
|
||||
// Preserve them and add an external detour; never index an empty candidate
|
||||
// list or turn the connection into an out-and-back degenerate stub.
|
||||
let coincident = edge.source == edge.target
|
||||
&& source_ends.len() == 1
|
||||
&& target_ends.len() == 1
|
||||
&& source_ends[0].point == target_ends[0].point
|
||||
&& source_ends[0].stub == target_ends[0].stub;
|
||||
let authored_pin_count = pins.len();
|
||||
let mut loop_pins = Vec::new();
|
||||
if coincident {
|
||||
let a = source_ends[0];
|
||||
loop_pins.extend_from_slice(pins);
|
||||
loop_pins.extend(coincident_detour(a, obstacles));
|
||||
}
|
||||
let pins = if coincident { &loop_pins[..] } else { pins };
|
||||
let space = RoutingSpace::new(obstacles);
|
||||
let mut candidates = Vec::new();
|
||||
for a in endpoints(source, edge.source_port, view) {
|
||||
for b in endpoints(target, edge.target_port, view) {
|
||||
for &a in source_ends {
|
||||
for &b in target_ends {
|
||||
// A real self-relation must leave and return at distinct anchors.
|
||||
// Reusing one anchor makes the cheapest path a degenerate stub.
|
||||
if edge.source == edge.target
|
||||
&& edge.source_port.is_none()
|
||||
&& edge.target_port.is_none()
|
||||
&& a.point == b.point
|
||||
&& a.stub == b.stub
|
||||
&& !coincident
|
||||
{
|
||||
continue;
|
||||
}
|
||||
@@ -729,10 +818,9 @@ fn route_edge(
|
||||
// Try all side combinations cheaply before spending the bounded grid budget.
|
||||
for search in [false, true] {
|
||||
for &(estimate, a, b) in &candidates {
|
||||
if best
|
||||
.as_ref()
|
||||
.is_some_and(|(cost, _, _)| estimate + 2.0 * PORT_STUB > *cost)
|
||||
{
|
||||
if best.as_ref().is_some_and(|(cost, _, _)| {
|
||||
estimate + manhattan(a.point, a.stub) + manhattan(b.point, b.stub) > *cost
|
||||
}) {
|
||||
continue;
|
||||
}
|
||||
if obstacles
|
||||
@@ -748,7 +836,7 @@ fn route_edge(
|
||||
let mut occupied = previous.to_vec();
|
||||
record_segments(&mut occupied, &points);
|
||||
let mut pin_lengths = Vec::new();
|
||||
let mut distance = PORT_STUB;
|
||||
let mut distance = manhattan(a.point, a.stub);
|
||||
let mut start = a.stub;
|
||||
let mut failed = false;
|
||||
for (index, end) in pins
|
||||
@@ -792,15 +880,20 @@ fn route_edge(
|
||||
break;
|
||||
}
|
||||
}
|
||||
if let Some((_, points, pin_lengths)) = best {
|
||||
return (points, pin_lengths, None);
|
||||
if let Some((_, points, mut pin_lengths)) = best {
|
||||
pin_lengths.truncate(authored_pin_count);
|
||||
return (
|
||||
points,
|
||||
pin_lengths,
|
||||
coincident.then_some(RouteWarning::CoincidentAttachments),
|
||||
);
|
||||
}
|
||||
// Recoverable fallback retains every hard pin, in order, and the actual port
|
||||
// leads. Its warning is deliberately visible to consumers.
|
||||
let (_, a, b) = candidates[0];
|
||||
let mut points = vec![a.point, a.stub];
|
||||
let mut pin_lengths = Vec::new();
|
||||
let mut distance = PORT_STUB;
|
||||
let mut distance = manhattan(a.point, a.stub);
|
||||
let mut start = a.stub;
|
||||
for (index, end) in pins
|
||||
.iter()
|
||||
@@ -833,6 +926,7 @@ fn route_edge(
|
||||
start = end;
|
||||
}
|
||||
points.push(b.point);
|
||||
pin_lengths.truncate(authored_pin_count);
|
||||
(
|
||||
points,
|
||||
pin_lengths,
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
//! One boundary definition shared by persisted routing and attachment gestures.
|
||||
use super::*;
|
||||
use crate::{EdgeAttachment, EdgeEnd, Kind, PortAnchorSide};
|
||||
|
||||
/// Semantic authority, independent of whether projection currently exposes a port.
|
||||
pub fn semantic_port_endpoint(project: &Project, edge: Id, end: EdgeEnd) -> Option<Id> {
|
||||
let endpoint = if let Some(relation) = project.relations.get(&edge) {
|
||||
match (&relation.body, end) {
|
||||
(RelationBody::FunctionalExchange { source_port, .. }, EdgeEnd::Source) => *source_port,
|
||||
(RelationBody::FunctionalExchange { target_port, .. }, EdgeEnd::Target) => *target_port,
|
||||
(_, EdgeEnd::Source) => relation.source(),
|
||||
(_, EdgeEnd::Target) => relation.target(),
|
||||
}
|
||||
} else {
|
||||
let trace = project.traces.get(&edge)?;
|
||||
match end {
|
||||
EdgeEnd::Source => trace.oa,
|
||||
EdgeEnd::Target => trace.sa,
|
||||
}
|
||||
};
|
||||
project
|
||||
.elements
|
||||
.get(&endpoint)
|
||||
.filter(|e| e.kind() == Kind::Port)
|
||||
.map(|_| endpoint)
|
||||
}
|
||||
|
||||
/// Intersect the chosen cardinal scan line with the painted shape. Offset is
|
||||
/// measured over the complete projected bounding side, including its corners.
|
||||
/// Terminate's X uses its outermost stroke intersection rather than a contour.
|
||||
pub fn attachment_point(
|
||||
project: &Project,
|
||||
diagram: &Diagram,
|
||||
node: &ProjectedNode,
|
||||
attachment: &EdgeAttachment,
|
||||
) -> Position {
|
||||
let vertical = matches!(
|
||||
attachment.side,
|
||||
PortAnchorSide::Left | PortAnchorSide::Right
|
||||
);
|
||||
let (extent, depth) = if vertical {
|
||||
(node.size.height, node.size.width)
|
||||
} else {
|
||||
(node.size.width, node.size.height)
|
||||
};
|
||||
let along = attachment.offset.clamp(0., 1.) * extent;
|
||||
let t = 2. * attachment.offset.clamp(0., 1.) - 1.;
|
||||
let kind = diagram
|
||||
.projection
|
||||
.state_machine()
|
||||
.and_then(|_| project.elements.get(&node.element))
|
||||
.map(|e| e.kind());
|
||||
let inset = match kind {
|
||||
Some(Kind::Choice) => depth * 0.5 * t.abs(),
|
||||
Some(Kind::Terminate) => {
|
||||
depth
|
||||
* attachment
|
||||
.offset
|
||||
.clamp(0., 1.)
|
||||
.min(1. - attachment.offset.clamp(0., 1.))
|
||||
}
|
||||
Some(
|
||||
Kind::Initial
|
||||
| Kind::Junction
|
||||
| Kind::Final
|
||||
| Kind::ShallowHistory
|
||||
| Kind::DeepHistory
|
||||
| Kind::EntryPoint
|
||||
| Kind::ExitPoint,
|
||||
) => depth * 0.5 * (1. - (1. - t * t).max(0.).sqrt()),
|
||||
Some(Kind::Region | Kind::Fork | Kind::Join) => 0.,
|
||||
_ => {
|
||||
let radius = 10_f32.min(extent / 2.).min(depth / 2.);
|
||||
let corner = (radius - along.min(extent - along)).max(0.);
|
||||
radius - (radius * radius - corner * corner).max(0.).sqrt()
|
||||
}
|
||||
};
|
||||
match attachment.side {
|
||||
PortAnchorSide::Left => pos(node.position.x + inset, node.position.y + along),
|
||||
PortAnchorSide::Right => pos(
|
||||
node.position.x + node.size.width - inset,
|
||||
node.position.y + along,
|
||||
),
|
||||
PortAnchorSide::Top => pos(node.position.x + along, node.position.y + inset),
|
||||
PortAnchorSide::Bottom => pos(
|
||||
node.position.x + along,
|
||||
node.position.y + node.size.height - inset,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Choose a cardinal bounding side, then map its fraction onto the painted
|
||||
/// contour. Contour distances cannot distinguish the sides of a circle at its
|
||||
/// extrema: Right/0 and Top/0.5 denote the same point but different lead directions.
|
||||
/// Hysteresis is in diagram units, capped for small symbols. Only UI snapping
|
||||
/// rounds to hundredths: source/API values retain their complete precision.
|
||||
pub fn attachment_from_point(
|
||||
node: &ProjectedNode,
|
||||
cursor: Position,
|
||||
previous: Option<PortAnchorSide>,
|
||||
hysteresis: f32,
|
||||
) -> EdgeAttachment {
|
||||
let candidate = |side| EdgeAttachment {
|
||||
side,
|
||||
offset: projection::port_anchor_offset(node, side, cursor),
|
||||
};
|
||||
// Measure side intent at the box, not from a distant overshoot: Euclidean
|
||||
// distance differences converge outside a small symbol and would otherwise
|
||||
// let hysteresis retain a perpendicular side indefinitely.
|
||||
let boundary_cursor = pos(
|
||||
cursor
|
||||
.x
|
||||
.clamp(node.position.x, node.position.x + node.size.width),
|
||||
cursor
|
||||
.y
|
||||
.clamp(node.position.y, node.position.y + node.size.height),
|
||||
);
|
||||
let distance = |side| {
|
||||
length(
|
||||
projection::port_anchor_position(node, side, candidate(side).offset),
|
||||
boundary_cursor,
|
||||
)
|
||||
};
|
||||
let mut side = [
|
||||
PortAnchorSide::Left,
|
||||
PortAnchorSide::Right,
|
||||
PortAnchorSide::Top,
|
||||
PortAnchorSide::Bottom,
|
||||
]
|
||||
.into_iter()
|
||||
.min_by(|a, b| distance(*a).total_cmp(&distance(*b)))
|
||||
.unwrap();
|
||||
let hysteresis = hysteresis
|
||||
.max(0.)
|
||||
.min(node.size.width.min(node.size.height) * 0.2);
|
||||
if let Some(old) = previous
|
||||
&& distance(old) <= distance(side) + hysteresis
|
||||
{
|
||||
side = old;
|
||||
}
|
||||
let mut attachment = candidate(side);
|
||||
attachment.offset = (attachment.offset * 100.).round() / 100.;
|
||||
attachment
|
||||
}
|
||||
|
||||
pub(super) fn endpoint(
|
||||
project: &Project,
|
||||
diagram: &Diagram,
|
||||
node: &ProjectedNode,
|
||||
attachment: EdgeAttachment,
|
||||
) -> Endpoint {
|
||||
let point = attachment_point(project, diagram, node, &attachment);
|
||||
let stub = match attachment.side {
|
||||
PortAnchorSide::Left => pos(node.position.x - PORT_STUB, point.y),
|
||||
PortAnchorSide::Right => pos(node.position.x + node.size.width + PORT_STUB, point.y),
|
||||
PortAnchorSide::Top => pos(point.x, node.position.y - PORT_STUB),
|
||||
PortAnchorSide::Bottom => pos(point.x, node.position.y + node.size.height + PORT_STUB),
|
||||
};
|
||||
Endpoint { point, stub }
|
||||
}
|
||||
@@ -1,429 +0,0 @@
|
||||
//! Authored Scenario presentation. Structural admission never changes event order.
|
||||
use crate::{
|
||||
Diagram, Id, Kind, Layer, MAX_COORDINATE, ModelError, OccurrenceBody, OccurrenceEndpoint,
|
||||
Project, SharedBody, validation,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub enum DiagramLayout {
|
||||
Scenario(ScenarioLayout),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ScenarioLayout {
|
||||
#[serde(default)]
|
||||
pub lanes: Vec<ScenarioLanePosition>,
|
||||
#[serde(default)]
|
||||
pub messages: Vec<ScenarioMessagePosition>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ScenarioLanePosition {
|
||||
pub lifeline: Id,
|
||||
pub x: f32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ScenarioMessagePosition {
|
||||
pub address: OccurrenceEndpoint,
|
||||
/// Actual send endpoint, not the renderer's receive-oriented lifecycle row.
|
||||
pub send_y: f32,
|
||||
}
|
||||
|
||||
impl DiagramLayout {
|
||||
/// Visit every explicit reference, including unsupported paths in a candidate.
|
||||
/// The caller owns remap preflight and final atomic validation.
|
||||
pub fn visit_references(&mut self, mut visit: impl FnMut(&mut Id)) {
|
||||
match self {
|
||||
Self::Scenario(layout) => {
|
||||
for lane in &mut layout.lanes {
|
||||
visit(&mut lane.lifeline);
|
||||
}
|
||||
for message in &mut layout.messages {
|
||||
for reference in &mut message.address.references {
|
||||
visit(reference);
|
||||
}
|
||||
visit(&mut message.address.occurrence);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Proposed aggregate accounting contribution; the shared admission owner applies it.
|
||||
/// One payload plus one record per lane/message. No graph objects are introduced.
|
||||
pub fn record_count(diagram: &Diagram) -> usize {
|
||||
match &diagram.layout {
|
||||
None => 0,
|
||||
Some(DiagramLayout::Scenario(layout)) => 1usize
|
||||
.saturating_add(layout.lanes.len())
|
||||
.saturating_add(layout.messages.len()),
|
||||
}
|
||||
}
|
||||
|
||||
fn coordinate(value: f32) -> bool {
|
||||
value.is_finite() && value.abs() <= MAX_COORDINATE
|
||||
}
|
||||
|
||||
fn owner(p: &Project, diagram: &Diagram) -> Result<Id, ModelError> {
|
||||
let scenario = diagram
|
||||
.projection
|
||||
.scenario()
|
||||
.ok_or_else(|| ModelError("Scenario layout requires a Scenario projection.".into()))?;
|
||||
if !p
|
||||
.elements
|
||||
.get(&scenario)
|
||||
.is_some_and(|element| element.kind() == Kind::Scenario && element.layer() == diagram.layer)
|
||||
{
|
||||
return Err(ModelError(
|
||||
"Scenario projection requires an existing Scenario in the diagram layer.".into(),
|
||||
));
|
||||
}
|
||||
Ok(scenario)
|
||||
}
|
||||
|
||||
fn lane_member(p: &Project, owner: Id, layer: Layer, lifeline: Id) -> bool {
|
||||
p.elements.get(&lifeline).is_some_and(|element| {
|
||||
element.kind() == Kind::Lifeline
|
||||
&& element.parent == Some(owner)
|
||||
&& element.layer() == layer
|
||||
})
|
||||
}
|
||||
|
||||
/// Only actual steps of this Scenario and its own Fragment/Branch sequences.
|
||||
/// ScenarioUse/SequenceReference are deliberately never expanded. Invalid graph
|
||||
/// cycles terminate, and malformed parent/step links do not manufacture membership.
|
||||
fn direct_messages(p: &Project, owner: Id, layer: Layer) -> BTreeSet<Id> {
|
||||
let mut messages = BTreeSet::new();
|
||||
let mut visited = BTreeSet::new();
|
||||
let mut pending = vec![owner];
|
||||
while let Some(scope) = pending.pop() {
|
||||
if !visited.insert(scope) {
|
||||
continue;
|
||||
}
|
||||
let Some(element) = p.elements.get(&scope) else {
|
||||
continue;
|
||||
};
|
||||
for id in element.steps().into_iter().flatten() {
|
||||
let Some(item) = p.elements.get(id) else {
|
||||
continue;
|
||||
};
|
||||
if item.parent != Some(scope) || item.layer() != layer {
|
||||
continue;
|
||||
}
|
||||
match item.shared() {
|
||||
Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange { .. },
|
||||
..
|
||||
}) => {
|
||||
messages.insert(*id);
|
||||
}
|
||||
Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Fragment { branches, .. },
|
||||
..
|
||||
}) => {
|
||||
for branch in branches {
|
||||
if p.elements.get(branch).is_some_and(|operand| {
|
||||
operand.parent == Some(*id)
|
||||
&& operand.layer() == layer
|
||||
&& matches!(
|
||||
operand.shared(),
|
||||
Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Branch { .. },
|
||||
..
|
||||
})
|
||||
)
|
||||
}) {
|
||||
pending.push(*branch);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
messages
|
||||
}
|
||||
|
||||
pub(crate) fn validate_diagram(p: &Project, diagram: &Diagram, out: &mut Vec<validation::Issue>) {
|
||||
if diagram.projection.scenario().is_none() {
|
||||
if diagram.layout.is_some() {
|
||||
validation::error(
|
||||
out,
|
||||
diagram.id,
|
||||
"scenario_layout_projection",
|
||||
"layout",
|
||||
"Only a Scenario projection accepts a Scenario layout, including an empty payload.",
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let scenario = match owner(p, diagram) {
|
||||
Ok(owner) => owner,
|
||||
Err(error) => {
|
||||
validation::error(
|
||||
out,
|
||||
diagram.id,
|
||||
"scenario_diagram_context",
|
||||
"projection",
|
||||
error.0,
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
if !diagram.nodes.is_empty() || !diagram.routes.is_empty() {
|
||||
validation::error(
|
||||
out,
|
||||
diagram.id,
|
||||
"scenario_diagram_content",
|
||||
"layout",
|
||||
"Scenario views do not accept architecture node placements or edge routes.",
|
||||
);
|
||||
}
|
||||
let Some(DiagramLayout::Scenario(layout)) = &diagram.layout else {
|
||||
return;
|
||||
};
|
||||
let mut lanes = BTreeSet::new();
|
||||
for lane in &layout.lanes {
|
||||
if !lanes.insert(lane.lifeline) {
|
||||
validation::error(
|
||||
out,
|
||||
diagram.id,
|
||||
"duplicate_scenario_lane",
|
||||
"layout",
|
||||
"A Scenario layout has at most one position per lifeline.",
|
||||
);
|
||||
}
|
||||
if !lane_member(p, scenario, diagram.layer, lane.lifeline) {
|
||||
validation::error(
|
||||
out,
|
||||
diagram.id,
|
||||
"scenario_layout_lifeline",
|
||||
"layout",
|
||||
"Lane positions reference a Lifeline directly owned by the projected Scenario.",
|
||||
);
|
||||
}
|
||||
if !coordinate(lane.x) {
|
||||
validation::error(
|
||||
out,
|
||||
diagram.id,
|
||||
"scenario_layout_coordinate",
|
||||
"layout",
|
||||
"Scenario positions must be finite and within the shared coordinate bounds.",
|
||||
);
|
||||
}
|
||||
}
|
||||
let members = direct_messages(p, scenario, diagram.layer);
|
||||
let mut addresses = BTreeSet::new();
|
||||
for message in &layout.messages {
|
||||
// Reject expanded paths without cloning or indexing their potentially large contents.
|
||||
if !message.address.references.is_empty() {
|
||||
validation::error(
|
||||
out,
|
||||
diagram.id,
|
||||
"scenario_layout_address",
|
||||
"layout",
|
||||
"Expanded-reference message positions are not supported; references must be empty.",
|
||||
);
|
||||
} else if !addresses.insert(message.address.occurrence) {
|
||||
validation::error(
|
||||
out,
|
||||
diagram.id,
|
||||
"duplicate_scenario_message",
|
||||
"layout",
|
||||
"A Scenario layout has at most one position per message address.",
|
||||
);
|
||||
}
|
||||
if !members.contains(&message.address.occurrence) {
|
||||
validation::error(
|
||||
out,
|
||||
diagram.id,
|
||||
"scenario_layout_message",
|
||||
"layout",
|
||||
"Message positions reference Exchange occurrences in the Scenario's own ordered sequence tree.",
|
||||
);
|
||||
}
|
||||
if !coordinate(message.send_y) {
|
||||
validation::error(
|
||||
out,
|
||||
diagram.id,
|
||||
"scenario_layout_coordinate",
|
||||
"layout",
|
||||
"Scenario positions must be finite and within the shared coordinate bounds.",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn editable(p: &Project, id: Id) -> Result<(&Diagram, Id), ModelError> {
|
||||
let diagram = p
|
||||
.diagrams
|
||||
.get(&id)
|
||||
.ok_or_else(|| ModelError("Scenario diagram does not exist.".into()))?;
|
||||
let scenario = owner(p, diagram)?;
|
||||
if !diagram.nodes.is_empty() || !diagram.routes.is_empty() {
|
||||
return Err(ModelError(
|
||||
"Scenario views do not accept architecture node placements or edge routes.".into(),
|
||||
));
|
||||
}
|
||||
Ok((diagram, scenario))
|
||||
}
|
||||
|
||||
fn check_message(
|
||||
p: &Project,
|
||||
diagram: &Diagram,
|
||||
owner: Id,
|
||||
address: &OccurrenceEndpoint,
|
||||
) -> Result<(), ModelError> {
|
||||
if !address.references.is_empty() {
|
||||
return Err(ModelError(
|
||||
"Expanded-reference message positions are not supported; references must be empty."
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
if !direct_messages(p, owner, diagram.layer).contains(&address.occurrence) {
|
||||
return Err(ModelError("Message position requires an Exchange occurrence in the Scenario's own ordered sequence tree.".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn payload(diagram: &mut Diagram) -> &mut ScenarioLayout {
|
||||
let DiagramLayout::Scenario(layout) = diagram
|
||||
.layout
|
||||
.get_or_insert_with(|| DiagramLayout::Scenario(ScenarioLayout::default()));
|
||||
layout
|
||||
}
|
||||
|
||||
/// Target-scoped preparation, not whole-project or whole-layout validation.
|
||||
/// Unrelated intermediate errors remain repairable by later commands in a batch.
|
||||
pub fn set_lane(
|
||||
p: &Project,
|
||||
diagram: Id,
|
||||
position: ScenarioLanePosition,
|
||||
) -> Result<Diagram, ModelError> {
|
||||
let (current, owner) = editable(p, diagram)?;
|
||||
if !lane_member(p, owner, current.layer, position.lifeline) || !coordinate(position.x) {
|
||||
return Err(ModelError(
|
||||
"Lane position requires an owned Lifeline and a finite bounded x coordinate.".into(),
|
||||
));
|
||||
}
|
||||
let mut result = current.clone();
|
||||
let layout = payload(&mut result);
|
||||
if layout
|
||||
.lanes
|
||||
.iter()
|
||||
.filter(|row| row.lifeline == position.lifeline)
|
||||
.count()
|
||||
> 1
|
||||
{
|
||||
return Err(ModelError(
|
||||
"Cannot set an ambiguous duplicate lane position.".into(),
|
||||
));
|
||||
}
|
||||
if let Some(existing) = layout
|
||||
.lanes
|
||||
.iter_mut()
|
||||
.find(|row| row.lifeline == position.lifeline)
|
||||
{
|
||||
if existing.x != position.x {
|
||||
*existing = position;
|
||||
}
|
||||
} else {
|
||||
layout.lanes.push(position);
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub fn remove_lane(p: &Project, diagram: Id, lifeline: Id) -> Result<Diagram, ModelError> {
|
||||
let (current, owner) = editable(p, diagram)?;
|
||||
if !lane_member(p, owner, current.layer, lifeline) {
|
||||
return Err(ModelError(
|
||||
"Removing a lane position requires an owned Lifeline.".into(),
|
||||
));
|
||||
}
|
||||
let mut result = current.clone();
|
||||
if let Some(DiagramLayout::Scenario(layout)) = &mut result.layout {
|
||||
layout.lanes.retain(|row| row.lifeline != lifeline);
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub fn set_message(
|
||||
p: &Project,
|
||||
diagram: Id,
|
||||
position: ScenarioMessagePosition,
|
||||
) -> Result<Diagram, ModelError> {
|
||||
let (current, owner) = editable(p, diagram)?;
|
||||
check_message(p, current, owner, &position.address)?;
|
||||
if !coordinate(position.send_y) {
|
||||
return Err(ModelError(
|
||||
"Message position requires a finite bounded send_y coordinate.".into(),
|
||||
));
|
||||
}
|
||||
let mut result = current.clone();
|
||||
let layout = payload(&mut result);
|
||||
if layout
|
||||
.messages
|
||||
.iter()
|
||||
.filter(|row| row.address == position.address)
|
||||
.count()
|
||||
> 1
|
||||
{
|
||||
return Err(ModelError(
|
||||
"Cannot set an ambiguous duplicate message position.".into(),
|
||||
));
|
||||
}
|
||||
if let Some(existing) = layout
|
||||
.messages
|
||||
.iter_mut()
|
||||
.find(|row| row.address == position.address)
|
||||
{
|
||||
if existing.send_y != position.send_y {
|
||||
*existing = position;
|
||||
}
|
||||
} else {
|
||||
layout.messages.push(position);
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub fn remove_message(
|
||||
p: &Project,
|
||||
diagram: Id,
|
||||
address: &OccurrenceEndpoint,
|
||||
) -> Result<Diagram, ModelError> {
|
||||
let (current, owner) = editable(p, diagram)?;
|
||||
check_message(p, current, owner, address)?;
|
||||
let mut result = current.clone();
|
||||
if let Some(DiagramLayout::Scenario(layout)) = &mut result.layout {
|
||||
layout.messages.retain(|row| &row.address != address);
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Prune only identities in the caller's actual deletion closure, never visibility
|
||||
/// or missing endpoint assignments. Owner-view cascade remains the caller's job.
|
||||
pub fn prune_deleted(diagram: &mut Diagram, deleted: &BTreeSet<Id>) {
|
||||
if let Some(DiagramLayout::Scenario(layout)) = &mut diagram.layout {
|
||||
layout
|
||||
.lanes
|
||||
.retain(|lane| !deleted.contains(&lane.lifeline));
|
||||
layout.messages.retain(|message| {
|
||||
!deleted.contains(&message.address.occurrence)
|
||||
&& !message
|
||||
.address
|
||||
.references
|
||||
.iter()
|
||||
.any(|id| deleted.contains(id))
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -1,800 +0,0 @@
|
||||
use super::*;
|
||||
use crate::{
|
||||
Element, FragmentOperator, MessageKind, ProjectionKind, Relation, RelationBody, RelationKind,
|
||||
ScenarioViewpoint,
|
||||
};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
struct Fixture {
|
||||
project: Project,
|
||||
owner: Id,
|
||||
performer: Id,
|
||||
lane: Id,
|
||||
repeated_lane: Id,
|
||||
first: Id,
|
||||
second: Id,
|
||||
nested: Id,
|
||||
fragment: Id,
|
||||
branch: Id,
|
||||
view: Id,
|
||||
other_view: Id,
|
||||
exchange: Id,
|
||||
}
|
||||
|
||||
fn set_body(element: &mut Element, body: OccurrenceBody) {
|
||||
*element.shared_mut().unwrap() = SharedBody::Occurrence {
|
||||
body,
|
||||
precondition: String::new(),
|
||||
postcondition: String::new(),
|
||||
};
|
||||
}
|
||||
fn address(occurrence: Id) -> OccurrenceEndpoint {
|
||||
OccurrenceEndpoint {
|
||||
references: vec![],
|
||||
occurrence,
|
||||
}
|
||||
}
|
||||
fn diagram(owner: Id, alias: &str) -> Diagram {
|
||||
Diagram {
|
||||
id: Id::new_v4(),
|
||||
alias: alias.into(),
|
||||
name: alias.into(),
|
||||
description: String::new(),
|
||||
layer: Layer::Oa,
|
||||
projection: ProjectionKind::Scenario { scenario: owner },
|
||||
view_options: Default::default(),
|
||||
nodes: BTreeMap::new(),
|
||||
routes: vec![],
|
||||
layout: None,
|
||||
}
|
||||
}
|
||||
fn assert_admitted(project: &Project) {
|
||||
let errors: Vec<_> = validation::validate(project)
|
||||
.into_iter()
|
||||
.filter(|issue| issue.severity == validation::Severity::Error)
|
||||
.collect();
|
||||
assert!(errors.is_empty(), "{errors:?}");
|
||||
}
|
||||
fn has_code(project: &Project, code: &str) -> bool {
|
||||
validation::validate(project)
|
||||
.iter()
|
||||
.any(|issue| issue.code == code)
|
||||
}
|
||||
fn layout(diagram: &Diagram) -> &ScenarioLayout {
|
||||
let Some(DiagramLayout::Scenario(layout)) = &diagram.layout else {
|
||||
panic!("expected saved payload")
|
||||
};
|
||||
layout
|
||||
}
|
||||
fn save(project: &mut Project, replacement: Diagram) {
|
||||
project.diagrams.insert(replacement.id, replacement);
|
||||
}
|
||||
fn fixture() -> Fixture {
|
||||
let mut project = Project::empty();
|
||||
let owner = project
|
||||
.add_element(Kind::Scenario, Layer::Oa, "Nominal", None)
|
||||
.unwrap();
|
||||
let Some(SharedBody::Scenario { viewpoint, .. }) =
|
||||
project.elements.get_mut(&owner).unwrap().shared_mut()
|
||||
else {
|
||||
panic!("Scenario fixture body");
|
||||
};
|
||||
*viewpoint = ScenarioViewpoint::ParticipantInteraction;
|
||||
let performer = project
|
||||
.add_element(Kind::Entity, Layer::Oa, "Controller", None)
|
||||
.unwrap();
|
||||
let receiver = project
|
||||
.add_element(Kind::Entity, Layer::Oa, "Provider", None)
|
||||
.unwrap();
|
||||
let lane = project
|
||||
.add_element(Kind::Lifeline, Layer::Oa, "Controller lane", Some(owner))
|
||||
.unwrap();
|
||||
let repeated_lane = project
|
||||
.add_element(
|
||||
Kind::Lifeline,
|
||||
Layer::Oa,
|
||||
"Controller review lane",
|
||||
Some(owner),
|
||||
)
|
||||
.unwrap();
|
||||
let receiver_lane = project
|
||||
.add_element(Kind::Lifeline, Layer::Oa, "Provider lane", Some(owner))
|
||||
.unwrap();
|
||||
for (id, represented) in [
|
||||
(lane, performer),
|
||||
(repeated_lane, performer),
|
||||
(receiver_lane, receiver),
|
||||
] {
|
||||
*project.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::Lifeline {
|
||||
represented: Some(represented),
|
||||
};
|
||||
}
|
||||
let exchange = Id::new_v4();
|
||||
project.relations.insert(
|
||||
exchange,
|
||||
Relation {
|
||||
id: exchange,
|
||||
alias: "delivery_channel".into(),
|
||||
name: "Delivery".into(),
|
||||
description: String::new(),
|
||||
body: RelationBody::new(RelationKind::CommunicationMean, performer, receiver),
|
||||
},
|
||||
);
|
||||
let first = project
|
||||
.add_element(Kind::Occurrence, Layer::Oa, "First delivery", Some(owner))
|
||||
.unwrap();
|
||||
let second = project
|
||||
.add_element(Kind::Occurrence, Layer::Oa, "Second delivery", Some(owner))
|
||||
.unwrap();
|
||||
for (id, sender) in [(first, lane), (second, repeated_lane)] {
|
||||
set_body(
|
||||
project.elements.get_mut(&id).unwrap(),
|
||||
OccurrenceBody::Exchange {
|
||||
exchange: Some(exchange),
|
||||
sender: Some(sender),
|
||||
receiver: Some(receiver_lane),
|
||||
message_kind: MessageKind::Asynchronous,
|
||||
reply_to: None,
|
||||
values: vec![],
|
||||
operation: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
let fragment = project
|
||||
.add_element(
|
||||
Kind::Occurrence,
|
||||
Layer::Oa,
|
||||
"Optional delivery",
|
||||
Some(owner),
|
||||
)
|
||||
.unwrap();
|
||||
set_body(
|
||||
project.elements.get_mut(&fragment).unwrap(),
|
||||
OccurrenceBody::Fragment {
|
||||
operator: FragmentOperator::Optional,
|
||||
branches: vec![],
|
||||
},
|
||||
);
|
||||
let branch = project
|
||||
.add_element(Kind::Occurrence, Layer::Oa, "Option body", Some(fragment))
|
||||
.unwrap();
|
||||
let nested = project
|
||||
.add_element(
|
||||
Kind::Occurrence,
|
||||
Layer::Oa,
|
||||
"Unfinished nested delivery",
|
||||
Some(branch),
|
||||
)
|
||||
.unwrap();
|
||||
set_body(
|
||||
project.elements.get_mut(&nested).unwrap(),
|
||||
OccurrenceBody::Exchange {
|
||||
exchange: None,
|
||||
sender: None,
|
||||
receiver: None,
|
||||
message_kind: MessageKind::Asynchronous,
|
||||
reply_to: None,
|
||||
values: vec![],
|
||||
operation: None,
|
||||
},
|
||||
);
|
||||
set_body(
|
||||
project.elements.get_mut(&branch).unwrap(),
|
||||
OccurrenceBody::Branch {
|
||||
guard: "requested".into(),
|
||||
occurrences: vec![nested],
|
||||
},
|
||||
);
|
||||
set_body(
|
||||
project.elements.get_mut(&fragment).unwrap(),
|
||||
OccurrenceBody::Fragment {
|
||||
operator: FragmentOperator::Optional,
|
||||
branches: vec![branch],
|
||||
},
|
||||
);
|
||||
*project
|
||||
.elements
|
||||
.get_mut(&owner)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap() = vec![first, second, fragment];
|
||||
let view_record = diagram(owner, "nominal_view");
|
||||
let other_record = diagram(owner, "review_view");
|
||||
let view = view_record.id;
|
||||
let other_view = other_record.id;
|
||||
save(&mut project, view_record);
|
||||
save(&mut project, other_record);
|
||||
assert_admitted(&project);
|
||||
Fixture {
|
||||
project,
|
||||
owner,
|
||||
performer,
|
||||
lane,
|
||||
repeated_lane,
|
||||
first,
|
||||
second,
|
||||
nested,
|
||||
fragment,
|
||||
branch,
|
||||
view,
|
||||
other_view,
|
||||
exchange,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_views_and_repeated_semantic_definitions_keep_independent_overrides() {
|
||||
let mut f = fixture();
|
||||
let semantics = (f.project.elements.clone(), f.project.relations.clone());
|
||||
for (view, line, x) in [
|
||||
(f.view, f.lane, 420.),
|
||||
(f.view, f.repeated_lane, 680.),
|
||||
(f.other_view, f.lane, 900.),
|
||||
] {
|
||||
let result =
|
||||
set_lane(&f.project, view, ScenarioLanePosition { lifeline: line, x }).unwrap();
|
||||
save(&mut f.project, result);
|
||||
}
|
||||
for (view, id, y) in [
|
||||
(f.view, f.first, 200.),
|
||||
(f.view, f.second, 350.),
|
||||
(f.other_view, f.first, 260.),
|
||||
] {
|
||||
let result = set_message(
|
||||
&f.project,
|
||||
view,
|
||||
ScenarioMessagePosition {
|
||||
address: address(id),
|
||||
send_y: y,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
save(&mut f.project, result);
|
||||
}
|
||||
assert_eq!(
|
||||
layout(&f.project.diagrams[&f.view]).lanes,
|
||||
vec![
|
||||
ScenarioLanePosition {
|
||||
lifeline: f.lane,
|
||||
x: 420.
|
||||
},
|
||||
ScenarioLanePosition {
|
||||
lifeline: f.repeated_lane,
|
||||
x: 680.
|
||||
},
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
layout(&f.project.diagrams[&f.view])
|
||||
.messages
|
||||
.iter()
|
||||
.map(|r| (r.address.occurrence, r.send_y))
|
||||
.collect::<Vec<_>>(),
|
||||
vec![(f.first, 200.), (f.second, 350.)]
|
||||
);
|
||||
assert_eq!(
|
||||
layout(&f.project.diagrams[&f.other_view]).messages[0].send_y,
|
||||
260.
|
||||
);
|
||||
assert_eq!(layout(&f.project.diagrams[&f.other_view]).lanes[0].x, 900.);
|
||||
assert_eq!(
|
||||
(f.project.elements.clone(), f.project.relations.clone()),
|
||||
semantics
|
||||
);
|
||||
assert_admitted(&f.project);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn own_nested_unfinished_message_is_admitted_but_expansion_is_not() {
|
||||
let mut f = fixture();
|
||||
let result = set_message(
|
||||
&f.project,
|
||||
f.view,
|
||||
ScenarioMessagePosition {
|
||||
address: address(f.nested),
|
||||
send_y: -80.,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
save(&mut f.project, result);
|
||||
assert_admitted(&f.project);
|
||||
let before = f.project.clone();
|
||||
let expanded = OccurrenceEndpoint {
|
||||
references: vec![f.fragment],
|
||||
occurrence: f.nested,
|
||||
};
|
||||
assert!(
|
||||
set_message(
|
||||
&f.project,
|
||||
f.view,
|
||||
ScenarioMessagePosition {
|
||||
address: expanded.clone(),
|
||||
send_y: 400.
|
||||
}
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(remove_message(&f.project, f.view, &expanded).is_err());
|
||||
assert_eq!(f.project, before);
|
||||
payload(f.project.diagrams.get_mut(&f.view).unwrap()).messages[0].address = expanded;
|
||||
assert!(has_code(&f.project, "scenario_layout_address"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_ordered_message_appearances_and_owned_lifelines_are_targets() {
|
||||
let mut f = fixture();
|
||||
assert!(
|
||||
set_lane(
|
||||
&f.project,
|
||||
f.view,
|
||||
ScenarioLanePosition {
|
||||
lifeline: f.performer,
|
||||
x: 10.
|
||||
}
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
for id in [f.exchange, f.fragment, f.branch, f.owner, Id::new_v4()] {
|
||||
assert!(
|
||||
set_message(
|
||||
&f.project,
|
||||
f.view,
|
||||
ScenarioMessagePosition {
|
||||
address: address(id),
|
||||
send_y: 100.
|
||||
}
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
let foreign = f
|
||||
.project
|
||||
.add_element(Kind::Scenario, Layer::Oa, "Foreign", None)
|
||||
.unwrap();
|
||||
let foreign_lane = f
|
||||
.project
|
||||
.add_element(Kind::Lifeline, Layer::Oa, "Foreign lane", Some(foreign))
|
||||
.unwrap();
|
||||
assert!(remove_lane(&f.project, f.view, foreign_lane).is_err());
|
||||
f.project.elements.get_mut(&f.first).unwrap().parent = Some(foreign);
|
||||
assert!(
|
||||
set_message(
|
||||
&f.project,
|
||||
f.view,
|
||||
ScenarioMessagePosition {
|
||||
address: address(f.first),
|
||||
send_y: 100.
|
||||
}
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
f.project.elements.get_mut(&f.first).unwrap().parent = Some(f.owner);
|
||||
f.project
|
||||
.elements
|
||||
.get_mut(&f.owner)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap()
|
||||
.retain(|id| *id != f.first);
|
||||
assert!(
|
||||
set_message(
|
||||
&f.project,
|
||||
f.view,
|
||||
ScenarioMessagePosition {
|
||||
address: address(f.first),
|
||||
send_y: 100.
|
||||
}
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_positions_reject_even_when_values_match() {
|
||||
let mut f = fixture();
|
||||
let lane = ScenarioLanePosition {
|
||||
lifeline: f.lane,
|
||||
x: 10.,
|
||||
};
|
||||
let message = ScenarioMessagePosition {
|
||||
address: address(f.first),
|
||||
send_y: 100.,
|
||||
};
|
||||
let entries = payload(f.project.diagrams.get_mut(&f.view).unwrap());
|
||||
entries.lanes = vec![lane.clone(), lane.clone()];
|
||||
entries.messages = vec![message.clone(), message.clone()];
|
||||
assert!(has_code(&f.project, "duplicate_scenario_lane"));
|
||||
assert!(has_code(&f.project, "duplicate_scenario_message"));
|
||||
assert!(set_lane(&f.project, f.view, lane).is_err());
|
||||
assert!(set_message(&f.project, f.view, message).is_err());
|
||||
// Explicit removal can repair all duplicates of the targeted identity.
|
||||
let result = remove_lane(&f.project, f.view, f.lane).unwrap();
|
||||
save(&mut f.project, result);
|
||||
let result = remove_message(&f.project, f.view, &address(f.first)).unwrap();
|
||||
save(&mut f.project, result);
|
||||
assert_admitted(&f.project);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sparse_absence_empty_payload_and_signed_zero_are_preserved() {
|
||||
let mut f = fixture();
|
||||
let absent = f.project.diagrams[&f.view].clone();
|
||||
assert_eq!(remove_lane(&f.project, f.view, f.lane).unwrap(), absent);
|
||||
assert_eq!(
|
||||
remove_message(&f.project, f.view, &address(f.first)).unwrap(),
|
||||
absent
|
||||
);
|
||||
assert!(
|
||||
serde_json::to_value(&absent)
|
||||
.unwrap()
|
||||
.get("layout")
|
||||
.is_none()
|
||||
);
|
||||
let result = set_lane(
|
||||
&f.project,
|
||||
f.view,
|
||||
ScenarioLanePosition {
|
||||
lifeline: f.lane,
|
||||
x: -0.,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
save(&mut f.project, result);
|
||||
let result = set_message(
|
||||
&f.project,
|
||||
f.view,
|
||||
ScenarioMessagePosition {
|
||||
address: address(f.first),
|
||||
send_y: -0.,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
save(&mut f.project, result);
|
||||
let result = set_lane(
|
||||
&f.project,
|
||||
f.view,
|
||||
ScenarioLanePosition {
|
||||
lifeline: f.lane,
|
||||
x: 0.,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(layout(&result).lanes[0].x.to_bits(), (-0.0_f32).to_bits());
|
||||
let result = set_message(
|
||||
&f.project,
|
||||
f.view,
|
||||
ScenarioMessagePosition {
|
||||
address: address(f.first),
|
||||
send_y: 0.,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
layout(&result).messages[0].send_y.to_bits(),
|
||||
(-0.0_f32).to_bits()
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_string(&result).unwrap(),
|
||||
serde_json::to_string(&f.project.diagrams[&f.view]).unwrap()
|
||||
);
|
||||
let result = remove_lane(&f.project, f.view, f.lane).unwrap();
|
||||
save(&mut f.project, result);
|
||||
let result = remove_message(&f.project, f.view, &address(f.first)).unwrap();
|
||||
assert_eq!(
|
||||
result.layout,
|
||||
Some(DiagramLayout::Scenario(ScenarioLayout::default()))
|
||||
);
|
||||
assert_ne!(result, absent);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounds_are_structural_and_rejected_helpers_do_not_mutate_project() {
|
||||
let mut f = fixture();
|
||||
for value in [-MAX_COORDINATE, MAX_COORDINATE, -7., 0.] {
|
||||
let result = set_lane(
|
||||
&f.project,
|
||||
f.view,
|
||||
ScenarioLanePosition {
|
||||
lifeline: f.lane,
|
||||
x: value,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
save(&mut f.project, result);
|
||||
let result = set_message(
|
||||
&f.project,
|
||||
f.view,
|
||||
ScenarioMessagePosition {
|
||||
address: address(f.first),
|
||||
send_y: value,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
save(&mut f.project, result);
|
||||
assert_admitted(&f.project);
|
||||
}
|
||||
let before = serde_json::to_string(&f.project).unwrap();
|
||||
for value in [
|
||||
f32::NAN,
|
||||
f32::INFINITY,
|
||||
f32::NEG_INFINITY,
|
||||
MAX_COORDINATE + 1.,
|
||||
] {
|
||||
assert!(
|
||||
set_lane(
|
||||
&f.project,
|
||||
f.view,
|
||||
ScenarioLanePosition {
|
||||
lifeline: f.lane,
|
||||
x: value
|
||||
}
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
set_message(
|
||||
&f.project,
|
||||
f.view,
|
||||
ScenarioMessagePosition {
|
||||
address: address(f.first),
|
||||
send_y: value
|
||||
}
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
assert_eq!(serde_json::to_string(&f.project).unwrap(), before);
|
||||
payload(f.project.diagrams.get_mut(&f.view).unwrap()).messages[0].send_y = f32::NAN;
|
||||
assert!(has_code(&f.project, "scenario_layout_coordinate"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn helpers_do_not_validate_unrelated_transient_records_in_repair_batches() {
|
||||
let mut f = fixture();
|
||||
payload(f.project.diagrams.get_mut(&f.view).unwrap())
|
||||
.messages
|
||||
.push(ScenarioMessagePosition {
|
||||
address: address(f.second),
|
||||
send_y: f32::INFINITY,
|
||||
});
|
||||
let result = set_lane(
|
||||
&f.project,
|
||||
f.view,
|
||||
ScenarioLanePosition {
|
||||
lifeline: f.lane,
|
||||
x: 420.,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
save(&mut f.project, result);
|
||||
assert!(has_code(&f.project, "scenario_layout_coordinate"));
|
||||
let result = set_message(
|
||||
&f.project,
|
||||
f.view,
|
||||
ScenarioMessagePosition {
|
||||
address: address(f.second),
|
||||
send_y: 300.,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
save(&mut f.project, result);
|
||||
assert_admitted(&f.project);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn projection_context_and_architecture_payloads_are_explicitly_separate() {
|
||||
let mut f = fixture();
|
||||
let scenario = f.project.diagrams[&f.view].projection;
|
||||
assert_eq!(scenario.scenario(), Some(f.owner));
|
||||
assert!(!scenario.is_architecture());
|
||||
assert!(!scenario.includes(Kind::Lifeline));
|
||||
assert!(ProjectionKind::Overview.is_architecture());
|
||||
assert!(!ProjectionKind::StateMachine { machine: f.owner }.is_architecture());
|
||||
f.project.diagrams.get_mut(&f.view).unwrap().layer = Layer::Sa;
|
||||
assert!(has_code(&f.project, "scenario_diagram_context"));
|
||||
assert!(remove_lane(&f.project, f.view, f.lane).is_err());
|
||||
f.project.diagrams.get_mut(&f.view).unwrap().layer = Layer::Oa;
|
||||
f.project
|
||||
.diagrams
|
||||
.get_mut(&f.view)
|
||||
.unwrap()
|
||||
.routes
|
||||
.push(crate::EdgeRoute {
|
||||
edge: f.exchange,
|
||||
waypoints: vec![],
|
||||
label: None,
|
||||
});
|
||||
assert!(has_code(&f.project, "scenario_diagram_content"));
|
||||
f.project.diagrams.get_mut(&f.view).unwrap().routes.clear();
|
||||
payload(f.project.diagrams.get_mut(&f.view).unwrap());
|
||||
f.project.diagrams.get_mut(&f.view).unwrap().projection = ProjectionKind::Overview;
|
||||
assert!(has_code(&f.project, "scenario_layout_projection"));
|
||||
assert!(
|
||||
set_lane(
|
||||
&f.project,
|
||||
f.view,
|
||||
ScenarioLanePosition {
|
||||
lifeline: f.lane,
|
||||
x: 10.
|
||||
}
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typed_json_records_round_trip_and_reject_unknown_or_missing_fields() {
|
||||
let mut f = fixture();
|
||||
let result = set_message(
|
||||
&f.project,
|
||||
f.view,
|
||||
ScenarioMessagePosition {
|
||||
address: address(f.first),
|
||||
send_y: 200.,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
save(&mut f.project, result);
|
||||
let encoded = serde_json::to_value(&f.project.diagrams[&f.view]).unwrap();
|
||||
assert_eq!(
|
||||
serde_json::from_value::<Diagram>(encoded.clone()).unwrap(),
|
||||
f.project.diagrams[&f.view]
|
||||
);
|
||||
assert_eq!(
|
||||
encoded["layout"]["Scenario"]["messages"][0]["address"]["occurrence"],
|
||||
serde_json::json!(f.first)
|
||||
);
|
||||
let mut unknown = encoded.clone();
|
||||
unknown["layout"]["Scenario"]["messages"][0]["extra"] = serde_json::json!(1);
|
||||
assert!(serde_json::from_value::<Diagram>(unknown).is_err());
|
||||
let mut missing = encoded;
|
||||
missing["layout"]["Scenario"]["messages"][0]["address"]
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.remove("references");
|
||||
assert!(serde_json::from_value::<Diagram>(missing).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_only_actual_ids_preserves_latent_message_positions_and_other_views() {
|
||||
let mut f = fixture();
|
||||
let result = set_lane(
|
||||
&f.project,
|
||||
f.view,
|
||||
ScenarioLanePosition {
|
||||
lifeline: f.lane,
|
||||
x: 420.,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
save(&mut f.project, result);
|
||||
let result = set_message(
|
||||
&f.project,
|
||||
f.view,
|
||||
ScenarioMessagePosition {
|
||||
address: address(f.first),
|
||||
send_y: 200.,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
save(&mut f.project, result);
|
||||
let mut copied = f.project.diagrams[&f.view].clone();
|
||||
copied.id = f.other_view;
|
||||
copied.alias = "review_view".into();
|
||||
copied.name = "Review view".into();
|
||||
save(&mut f.project, copied.clone());
|
||||
assert_admitted(&f.project);
|
||||
assert_eq!(layout(&copied).messages[0].address.occurrence, f.first);
|
||||
let mut candidate = f.project.diagrams[&f.view].clone();
|
||||
prune_deleted(&mut candidate, &BTreeSet::from([f.lane, f.exchange]));
|
||||
assert!(layout(&candidate).lanes.is_empty());
|
||||
assert_eq!(layout(&candidate).messages.len(), 1);
|
||||
prune_deleted(&mut candidate, &BTreeSet::from([f.first]));
|
||||
assert!(layout(&candidate).messages.is_empty());
|
||||
assert!(candidate.layout.is_some());
|
||||
assert_eq!(f.project.diagrams[&f.other_view], copied);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_visitor_and_record_count_cover_explicit_rows() {
|
||||
let f = fixture();
|
||||
let mut d = f.project.diagrams[&f.view].clone();
|
||||
assert_eq!(record_count(&d), 0);
|
||||
let rows = payload(&mut d);
|
||||
rows.lanes.push(ScenarioLanePosition {
|
||||
lifeline: f.lane,
|
||||
x: 50.,
|
||||
});
|
||||
rows.messages.push(ScenarioMessagePosition {
|
||||
address: address(f.first),
|
||||
send_y: 100.,
|
||||
});
|
||||
assert_eq!(record_count(&d), 3);
|
||||
let replacement = Id::new_v4();
|
||||
d.layout.as_mut().unwrap().visit_references(|id| {
|
||||
if *id == f.first {
|
||||
*id = replacement;
|
||||
}
|
||||
});
|
||||
assert_eq!(layout(&d).messages[0].address.occurrence, replacement);
|
||||
assert_eq!(layout(&d).messages[0].send_y, 100.);
|
||||
assert_eq!(layout(&d).lanes[0].lifeline, f.lane);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_real_scenario_use_never_imports_its_called_message_as_a_direct_target() {
|
||||
let mut f = fixture();
|
||||
let called = f
|
||||
.project
|
||||
.add_element(Kind::Scenario, Layer::Oa, "Called sequence", None)
|
||||
.unwrap();
|
||||
let Some(SharedBody::Scenario { viewpoint, .. }) =
|
||||
f.project.elements.get_mut(&called).unwrap().shared_mut()
|
||||
else {
|
||||
panic!("Called Scenario fixture body");
|
||||
};
|
||||
*viewpoint = ScenarioViewpoint::ParticipantInteraction;
|
||||
let called_message = f
|
||||
.project
|
||||
.add_element(Kind::Occurrence, Layer::Oa, "Called delivery", Some(called))
|
||||
.unwrap();
|
||||
set_body(
|
||||
f.project.elements.get_mut(&called_message).unwrap(),
|
||||
OccurrenceBody::Exchange {
|
||||
exchange: None,
|
||||
sender: None,
|
||||
receiver: None,
|
||||
message_kind: MessageKind::Asynchronous,
|
||||
reply_to: None,
|
||||
values: vec![],
|
||||
operation: None,
|
||||
},
|
||||
);
|
||||
*f.project
|
||||
.elements
|
||||
.get_mut(&called)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap() = vec![called_message];
|
||||
let usage = f
|
||||
.project
|
||||
.add_element(Kind::Occurrence, Layer::Oa, "Call sequence", Some(f.owner))
|
||||
.unwrap();
|
||||
set_body(
|
||||
f.project.elements.get_mut(&usage).unwrap(),
|
||||
OccurrenceBody::ScenarioUse {
|
||||
scenario: Some(called),
|
||||
bindings: vec![],
|
||||
},
|
||||
);
|
||||
f.project
|
||||
.elements
|
||||
.get_mut(&f.owner)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap()
|
||||
.push(usage);
|
||||
assert_admitted(&f.project);
|
||||
for target in [
|
||||
address(called_message),
|
||||
address(usage),
|
||||
OccurrenceEndpoint {
|
||||
references: vec![usage],
|
||||
occurrence: called_message,
|
||||
},
|
||||
] {
|
||||
assert!(
|
||||
set_message(
|
||||
&f.project,
|
||||
f.view,
|
||||
ScenarioMessagePosition {
|
||||
address: target,
|
||||
send_y: 200.
|
||||
}
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
mod source_lifecycle;
|
||||
@@ -1,245 +0,0 @@
|
||||
//! Source/command lifecycle checks for the candidate payload. Public version
|
||||
//! assignment remains the coordinated codec integration gate.
|
||||
use super::*;
|
||||
use crate::{Command, Session, language, prepare_command, source_project::*};
|
||||
|
||||
fn authored(f: &Fixture) -> CompiledSourceProject {
|
||||
let mut bundle = format_bundle(&f.project);
|
||||
let initial = compile_bundle(&bundle, ProjectLimits::default()).unwrap();
|
||||
let location = &initial.index.declarations[&f.view];
|
||||
let source = bundle.files.get_mut(&location.path).unwrap();
|
||||
let text = format!(
|
||||
r#"diagram nominal_view "Nominal review" id "{}" {{
|
||||
projection {{ Scenario {{ scenario @{}; }}; }};
|
||||
layout {{ Scenario {{
|
||||
lanes [
|
||||
// Controller spacing rationale.
|
||||
{{ lifeline @ // first lane identity
|
||||
{}; x 4.20e2; }},
|
||||
// Repeated performer has its own lane.
|
||||
{{ lifeline @{}; x 680; }}
|
||||
];
|
||||
messages [
|
||||
// First delivery timing rationale.
|
||||
{{ address {{ references []; occurrence @{}; }}; send_y 2.00e2; }},
|
||||
// Second delivery timing rationale.
|
||||
{{ address {{ references []; occurrence @{}; }}; send_y 350; }}
|
||||
];
|
||||
}}; }};
|
||||
}}
|
||||
"#,
|
||||
f.view,
|
||||
f.project.elements[&f.owner].alias,
|
||||
f.project.elements[&f.lane].alias,
|
||||
f.project.elements[&f.repeated_lane].alias,
|
||||
f.project.elements[&f.first].alias,
|
||||
f.project.elements[&f.second].alias
|
||||
);
|
||||
source.replace_range(location.span.start..location.span.end, &text);
|
||||
compile_bundle(&bundle, ProjectLimits::default()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_rows_follow_identity_order_and_keep_comments_numeric_spelling_and_file_ownership() {
|
||||
let f = fixture();
|
||||
let original = authored(&f);
|
||||
let mut changed = original.decoded.project.clone();
|
||||
let rows = payload(changed.diagrams.get_mut(&f.view).unwrap());
|
||||
rows.lanes.swap(0, 1);
|
||||
rows.messages.swap(0, 1);
|
||||
rows.messages[0].send_y = 390.;
|
||||
changed.elements.get_mut(&f.lane).unwrap().alias = "renamed_controller_lane".into();
|
||||
let updated = update_bundle(&original, &changed, ProjectLimits::default()).unwrap();
|
||||
assert_eq!(updated.decoded.project, changed);
|
||||
let path = &original.index.declarations[&f.view].path;
|
||||
assert_eq!(&updated.index.declarations[&f.view].path, path);
|
||||
let text = &updated.bundle.files[path];
|
||||
assert!(
|
||||
text.find("// Repeated performer").unwrap() < text.find("// Controller spacing").unwrap(),
|
||||
"{text}"
|
||||
);
|
||||
assert!(
|
||||
text.find("// Second delivery").unwrap() < text.find("// First delivery").unwrap(),
|
||||
"{text}"
|
||||
);
|
||||
assert!(
|
||||
text.contains(
|
||||
"lifeline @ // first lane identity\n renamed_controller_lane; x 4.20e2;"
|
||||
),
|
||||
"{text}"
|
||||
);
|
||||
assert!(text.contains("send_y 2.00e2"), "{text}");
|
||||
assert!(text.contains("send_y 390"), "{text}");
|
||||
for (other, bytes) in &original.bundle.files {
|
||||
if other != path && !bytes.contains(&f.project.elements[&f.lane].alias) {
|
||||
assert_eq!(&updated.bundle.files[other], bytes);
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
compile_bundle(&updated.bundle, ProjectLimits::default())
|
||||
.unwrap()
|
||||
.decoded
|
||||
.project,
|
||||
changed
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_identity_swap_keeps_each_coordinate_comment_with_its_original_object() {
|
||||
let f = fixture();
|
||||
let original = authored(&f);
|
||||
let temporary = Id::new_v4();
|
||||
let command = Command::Batch(vec![
|
||||
Command::Reidentify(f.first, temporary),
|
||||
Command::Reidentify(f.second, f.first),
|
||||
Command::Reidentify(temporary, f.second),
|
||||
]);
|
||||
let prepared = prepare_command(&original.decoded.project, &command).unwrap();
|
||||
let mut changed = prepared.project;
|
||||
payload(changed.diagrams.get_mut(&f.view).unwrap())
|
||||
.messages
|
||||
.swap(0, 1);
|
||||
let updated = update_bundle_with_identity_map(
|
||||
&original,
|
||||
&changed,
|
||||
&prepared.identity_map,
|
||||
ProjectLimits::default(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(updated.decoded.project, changed);
|
||||
let path = &original.index.declarations[&f.view].path;
|
||||
let text = &updated.bundle.files[path];
|
||||
assert!(
|
||||
text.find("// Second delivery").unwrap() < text.find("// First delivery").unwrap(),
|
||||
"{text}"
|
||||
);
|
||||
let first_comment = text.find("// First delivery").unwrap();
|
||||
assert!(text[first_comment..].contains(&format!(
|
||||
"occurrence @{}",
|
||||
changed.elements[&f.second].alias
|
||||
)));
|
||||
assert!(text[first_comment..].contains("send_y 2.00e2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coordinate_batch_is_one_undo_and_numeric_noop_keeps_source_exact() {
|
||||
let f = fixture();
|
||||
let original = authored(&f);
|
||||
let mut session = Session::new(original.decoded.project.clone());
|
||||
session
|
||||
.execute(Command::Batch(vec![
|
||||
Command::SetScenarioLanePosition {
|
||||
diagram: f.view,
|
||||
position: ScenarioLanePosition {
|
||||
lifeline: f.lane,
|
||||
x: 510.,
|
||||
},
|
||||
},
|
||||
Command::SetScenarioMessagePosition {
|
||||
diagram: f.view,
|
||||
position: ScenarioMessagePosition {
|
||||
address: address(f.second),
|
||||
send_y: 430.,
|
||||
},
|
||||
},
|
||||
]))
|
||||
.unwrap();
|
||||
let changed = session.project().clone();
|
||||
assert_eq!(session.revision(), 1);
|
||||
assert_eq!(changed.elements, original.decoded.project.elements);
|
||||
assert_eq!(changed.relations, original.decoded.project.relations);
|
||||
let updated = update_bundle(&original, &changed, ProjectLimits::default()).unwrap();
|
||||
session.undo();
|
||||
assert_eq!(session.project(), &original.decoded.project);
|
||||
assert!(!session.can_undo());
|
||||
session.redo();
|
||||
assert_eq!(session.project(), &changed);
|
||||
session
|
||||
.execute(Command::SetScenarioMessagePosition {
|
||||
diagram: f.view,
|
||||
position: ScenarioMessagePosition {
|
||||
address: address(f.second),
|
||||
send_y: 430.,
|
||||
},
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(session.revision(), 3);
|
||||
let noop = update_bundle(&updated, session.project(), ProjectLimits::default()).unwrap();
|
||||
assert_eq!(noop.bundle, updated.bundle);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absent_and_empty_payload_round_trip_distinctly_and_remove_does_not_materialize_absence() {
|
||||
let f = fixture();
|
||||
let before = language::format(&f.project);
|
||||
let mut session = Session::new(f.project.clone());
|
||||
session
|
||||
.execute(Command::RemoveScenarioLanePosition {
|
||||
diagram: f.view,
|
||||
lifeline: f.lane,
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(session.project(), &f.project);
|
||||
assert!(!session.can_undo());
|
||||
assert_eq!(language::format(session.project()), before);
|
||||
let mut present = f.project.clone();
|
||||
present.diagrams.get_mut(&f.view).unwrap().layout =
|
||||
Some(DiagramLayout::Scenario(ScenarioLayout::default()));
|
||||
let text = language::format(&present);
|
||||
assert_ne!(text, before);
|
||||
assert_eq!(language::parse(&text).unwrap(), present);
|
||||
assert_eq!(language::parse(&before).unwrap(), f.project);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_layout_source_rejects_without_changing_last_compiled_workspace() {
|
||||
let f = fixture();
|
||||
let original = authored(&f);
|
||||
let before = original.bundle.clone();
|
||||
let path = original.index.declarations[&f.view].path.clone();
|
||||
for replacement in ["x 1e30", "x not_a_number"] {
|
||||
let mut candidate = before.clone();
|
||||
let source = candidate.files.get_mut(&path).unwrap();
|
||||
*source = source.replace("x 4.20e2", replacement);
|
||||
assert!(compile_bundle(&candidate, ProjectLimits::default()).is_err());
|
||||
assert_eq!(original.bundle, before);
|
||||
}
|
||||
let mut candidate = before.clone();
|
||||
let source = candidate.files.get_mut(&path).unwrap();
|
||||
*source = source.replace(
|
||||
&format!("occurrence @{}", f.project.elements[&f.first].alias),
|
||||
&format!("occurrence @{}", f.project.elements[&f.performer].alias),
|
||||
);
|
||||
assert!(compile_bundle(&candidate, ProjectLimits::default()).is_err());
|
||||
assert_eq!(original.bundle, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_none_survives_other_edits_while_real_payload_clear_removes_values() {
|
||||
let f = fixture();
|
||||
let mut bundle = format_bundle(&f.project);
|
||||
let compiled = compile_bundle(&bundle, ProjectLimits::default()).unwrap();
|
||||
let location = &compiled.index.declarations[&f.view];
|
||||
let path = location.path.clone();
|
||||
bundle.files.get_mut(&path).unwrap().insert_str(
|
||||
location.span.end - 1,
|
||||
"\n layout // explicitly automatic\n none;\n",
|
||||
);
|
||||
let original = compile_bundle(&bundle, ProjectLimits::default()).unwrap();
|
||||
let mut described = original.decoded.project.clone();
|
||||
described.diagrams.get_mut(&f.view).unwrap().description =
|
||||
"Review spacing independently".into();
|
||||
let updated = update_bundle(&original, &described, ProjectLimits::default()).unwrap();
|
||||
assert!(updated.bundle.files[&path].contains("layout // explicitly automatic\n none;"));
|
||||
assert_eq!(updated.decoded.project, described);
|
||||
let mut empty = described.clone();
|
||||
empty.diagrams.get_mut(&f.view).unwrap().layout =
|
||||
Some(DiagramLayout::Scenario(ScenarioLayout::default()));
|
||||
let present = update_bundle(&updated, &empty, ProjectLimits::default()).unwrap();
|
||||
assert_eq!(present.decoded.project, empty);
|
||||
assert!(present.bundle.files[&path].contains("// explicitly automatic"));
|
||||
let cleared = update_bundle(&present, &described, ProjectLimits::default()).unwrap();
|
||||
assert_eq!(cleared.decoded.project, described);
|
||||
assert!(!cleared.bundle.files[&path].contains("layout"));
|
||||
assert!(cleared.bundle.files[&path].contains("// explicitly automatic"));
|
||||
}
|
||||
@@ -23,22 +23,6 @@ pub enum Command {
|
||||
EditRelation(Id, Relation),
|
||||
CreateDiagram(Diagram),
|
||||
EditDiagram(Id, Diagram),
|
||||
SetScenarioLanePosition {
|
||||
diagram: Id,
|
||||
position: ScenarioLanePosition,
|
||||
},
|
||||
RemoveScenarioLanePosition {
|
||||
diagram: Id,
|
||||
lifeline: Id,
|
||||
},
|
||||
SetScenarioMessagePosition {
|
||||
diagram: Id,
|
||||
position: ScenarioMessagePosition,
|
||||
},
|
||||
RemoveScenarioMessagePosition {
|
||||
diagram: Id,
|
||||
address: OccurrenceEndpoint,
|
||||
},
|
||||
CreateArtifact(ArtifactLink),
|
||||
EditArtifact(Id, ArtifactLink),
|
||||
CreateTrace(Trace),
|
||||
@@ -504,26 +488,6 @@ fn apply_command(
|
||||
reidentify_tracked(p, *old, value.id, provenance)?;
|
||||
Some(value.id)
|
||||
}
|
||||
Command::SetScenarioLanePosition { diagram, position } => {
|
||||
let replacement = crate::scenario_layout::set_lane(p, *diagram, position.clone())?;
|
||||
p.diagrams.insert(*diagram, replacement);
|
||||
Some(*diagram)
|
||||
}
|
||||
Command::RemoveScenarioLanePosition { diagram, lifeline } => {
|
||||
let replacement = crate::scenario_layout::remove_lane(p, *diagram, *lifeline)?;
|
||||
p.diagrams.insert(*diagram, replacement);
|
||||
Some(*diagram)
|
||||
}
|
||||
Command::SetScenarioMessagePosition { diagram, position } => {
|
||||
let replacement = crate::scenario_layout::set_message(p, *diagram, position.clone())?;
|
||||
p.diagrams.insert(*diagram, replacement);
|
||||
Some(*diagram)
|
||||
}
|
||||
Command::RemoveScenarioMessagePosition { diagram, address } => {
|
||||
let replacement = crate::scenario_layout::remove_message(p, *diagram, address)?;
|
||||
p.diagrams.insert(*diagram, replacement);
|
||||
Some(*diagram)
|
||||
}
|
||||
Command::CreateArtifact(value) => {
|
||||
vacant(p, value.id)?;
|
||||
p.artifact_links.insert(value.id, value.clone());
|
||||
@@ -914,7 +878,6 @@ pub fn reidentify(p: &mut Project, old: Id, new: Id) -> Result<(), ModelError> {
|
||||
return Ok(());
|
||||
}
|
||||
vacant(p, new)?;
|
||||
crate::references::check_remap(p, old, new)?;
|
||||
if p.id == old {
|
||||
p.id = new;
|
||||
} else if let Some(mut v) = p.elements.remove(&old) {
|
||||
@@ -941,17 +904,12 @@ pub fn reidentify(p: &mut Project, old: Id, new: Id) -> Result<(), ModelError> {
|
||||
}
|
||||
}
|
||||
}
|
||||
crate::references::remap_references(p, old, new)?;
|
||||
crate::references::remap_references(p, old, new);
|
||||
Ok(())
|
||||
}
|
||||
fn layout(p: &mut Project, diagram: Id) -> Result<(), ModelError> {
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
let d = p.diagrams.get(&diagram).ok_or_else(missing)?;
|
||||
if d.projection.scenario().is_some() {
|
||||
return Err(ModelError(
|
||||
"Scenario layout requires the Scenario geometry operation.".into(),
|
||||
));
|
||||
}
|
||||
let parents = crate::projection::parents(p, d);
|
||||
let mut children: BTreeMap<Option<Id>, Vec<Id>> = BTreeMap::new();
|
||||
for (id, parent) in &parents {
|
||||
|
||||
@@ -0,0 +1,399 @@
|
||||
//! Ordinary source defaults shared by current parsing and declaration rendering.
|
||||
//!
|
||||
//! These are source omission defaults, not graphical creation defaults. The
|
||||
//! policy operates on complete typed declaration maps. Required named fields
|
||||
//! and selected constructor payloads remain distinct from omission defaults.
|
||||
//! Historical versions use their existing frozen parser paths. Semantic baseline
|
||||
//! history is never traversed or normalized here.
|
||||
use crate::{Kind, RelationKind};
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum Context {
|
||||
Element(Kind),
|
||||
Relation(RelationKind),
|
||||
Diagram,
|
||||
Node,
|
||||
Artifact,
|
||||
Trace,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum Mode {
|
||||
CompleteForEdit,
|
||||
Compact,
|
||||
/// Internal lossless-edit fragments: retain every top-level value while
|
||||
/// compacting its selected constructor's optional nested fields.
|
||||
CompactValues,
|
||||
}
|
||||
|
||||
/// Top-level field inventory for the current ordinary reader. Callers must
|
||||
/// check required fields before merging; null entries only admit their names.
|
||||
/// This never supplies defaults inside a selected constructor.
|
||||
pub(crate) fn parser_fields(context: Context) -> Map<String, Value> {
|
||||
let mut fields = declaration_defaults(context);
|
||||
let required: &[&str] = match context {
|
||||
Context::Element(kind) => {
|
||||
fields.remove("description");
|
||||
fields.remove("parent");
|
||||
if kind == Kind::Port {
|
||||
&["contract"]
|
||||
} else {
|
||||
&[]
|
||||
}
|
||||
}
|
||||
Context::Relation(kind) => {
|
||||
fields.remove("description");
|
||||
match kind {
|
||||
RelationKind::ContextualLink => &["owner", "source", "target", "purpose"],
|
||||
RelationKind::FunctionalExchange => {
|
||||
&["source", "target", "source_port", "target_port"]
|
||||
}
|
||||
_ => &["source", "target"],
|
||||
}
|
||||
}
|
||||
Context::Diagram => &[],
|
||||
Context::Node => &["element"],
|
||||
Context::Artifact => &["owner"],
|
||||
Context::Trace => &["oa", "sa"],
|
||||
};
|
||||
for name in required {
|
||||
fields.insert((*name).into(), Value::Null);
|
||||
}
|
||||
fields
|
||||
}
|
||||
|
||||
pub(crate) fn fields(
|
||||
context: Context,
|
||||
input: &Map<String, Value>,
|
||||
mode: Mode,
|
||||
) -> Map<String, Value> {
|
||||
let mut result = input.clone();
|
||||
restore_serialization_skips(context, &mut result);
|
||||
if mode != Mode::CompleteForEdit {
|
||||
compact_nested(context, &mut result);
|
||||
}
|
||||
if mode == Mode::Compact {
|
||||
// Compact a composite and its declared default by the same nested rules
|
||||
// before whole-field comparison. This also makes compact input stable:
|
||||
// e.g. String{minimum_length:0} still denotes the default definition.
|
||||
let mut defaults = declaration_defaults(context);
|
||||
compact_nested(context, &mut defaults);
|
||||
omit_defaults(&mut result, &defaults);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn restore_serialization_skips(context: Context, fields: &mut Map<String, Value>) {
|
||||
match context {
|
||||
Context::Element(Kind::Occurrence) => {
|
||||
if let Some(exchange) = constructor_payload(fields, "body", "Exchange") {
|
||||
exchange
|
||||
.entry("operation".to_owned())
|
||||
.or_insert(Value::Null);
|
||||
}
|
||||
}
|
||||
Context::Diagram => {
|
||||
if let Some(routes) = fields.get_mut("routes").and_then(Value::as_array_mut) {
|
||||
for route in routes.iter_mut().filter_map(Value::as_object_mut) {
|
||||
route
|
||||
.entry("source_attachment".to_owned())
|
||||
.or_insert(Value::Null);
|
||||
route
|
||||
.entry("target_attachment".to_owned())
|
||||
.or_insert(Value::Null);
|
||||
}
|
||||
}
|
||||
}
|
||||
Context::Node => {
|
||||
fields.entry("port_anchors".to_owned()).or_insert(json!([]));
|
||||
}
|
||||
Context::Trace => {
|
||||
if let Some(baseline) = fields.get_mut("baseline").and_then(Value::as_object_mut) {
|
||||
baseline.entry("semantic".to_owned()).or_insert(Value::Null);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn declaration_defaults(context: Context) -> Map<String, Value> {
|
||||
let mut defaults = match context {
|
||||
Context::Element(kind) => element_defaults(kind),
|
||||
Context::Relation(kind) => relation_defaults(kind),
|
||||
Context::Diagram => object(json!({
|
||||
"description": "", "layer": "Oa", "projection": "Overview",
|
||||
"routes": [], "view_options": {
|
||||
"hidden_edges": [], "relation_kinds": "All",
|
||||
"work_categories": "All", "participant_categories": "All",
|
||||
"coverage": "All", "work_contexts": "All", "exchange_labels": "Name"
|
||||
}
|
||||
})),
|
||||
Context::Node => object(json!({
|
||||
"position": {"x": 0.0, "y": 0.0},
|
||||
"size": {"width": 224.0, "height": 94.0},
|
||||
"collapsed": false, "port_anchors": []
|
||||
})),
|
||||
Context::Artifact => object(json!({
|
||||
"description": "", "relation": "References", "uri": "",
|
||||
"revision": null, "fragment": null
|
||||
})),
|
||||
Context::Trace => object(json!({
|
||||
"description": "", "kind": "Refinement", "baseline": null
|
||||
})),
|
||||
};
|
||||
match context {
|
||||
Context::Element(_) => {
|
||||
defaults.insert("description".into(), json!(""));
|
||||
defaults.insert("parent".into(), Value::Null);
|
||||
}
|
||||
Context::Relation(_) => {
|
||||
defaults.insert("description".into(), json!(""));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
defaults
|
||||
}
|
||||
|
||||
fn element_defaults(kind: Kind) -> Map<String, Value> {
|
||||
if let Some(state_kind) = kind.state_kind() {
|
||||
// The declaration selects this discriminator; no GUI factory is used.
|
||||
return object(json!({
|
||||
"kind": state_kind, "entry": "", "exit": "", "do_activity": ""
|
||||
}));
|
||||
}
|
||||
match kind {
|
||||
Kind::Process | Kind::FunctionalChain => object(json!({"steps": []})),
|
||||
Kind::ExchangeItem => object(json!({
|
||||
"kind": "Information", "mechanism": "Unspecified", "supertype": null
|
||||
})),
|
||||
Kind::ExchangeCategory => object(json!({"family": "Work", "exchanges": []})),
|
||||
Kind::DataType => object(json!({
|
||||
"definition": {"String": {"minimum_length": 0, "maximum_length": null}},
|
||||
"default": null, "unit": ""
|
||||
})),
|
||||
Kind::DataField => object(json!({
|
||||
"type_ref": "String", "minimum": 1, "maximum": 1, "unit": "",
|
||||
"direction": "Unspecified", "storage": "Value", "default": null
|
||||
})),
|
||||
Kind::Interface => object(json!({"supertype": null})),
|
||||
// Port.contract is required, even when its explicit value is Legacy.
|
||||
Kind::Port => object(json!({"legacy": null})),
|
||||
Kind::Scenario => object(json!({
|
||||
"viewpoint": "MixedLegacy", "context": null,
|
||||
"precondition": "", "postcondition": "", "steps": []
|
||||
})),
|
||||
Kind::Lifeline => object(json!({"represented": null})),
|
||||
Kind::Occurrence => object(json!({
|
||||
"body": {"Exchange": {
|
||||
"exchange": null, "sender": null, "receiver": null,
|
||||
"message_kind": "Asynchronous", "reply_to": null,
|
||||
"values": [], "operation": null
|
||||
}},
|
||||
"precondition": "", "postcondition": ""
|
||||
})),
|
||||
Kind::Event => object(json!({"body": {"Signal": {"items": []}}})),
|
||||
Kind::Constraint => object(json!({"expression": "", "constrained": []})),
|
||||
// Unit-body declarations have no additional omission defaults. A future
|
||||
// Kind reaches this branch without gaining speculative payload rules.
|
||||
_ => Map::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn relation_defaults(kind: RelationKind) -> Map<String, Value> {
|
||||
match kind {
|
||||
RelationKind::InterfaceItemAllocation => object(json!({
|
||||
"order": 0, "transmission": "Unspecified", "acquisition": "Unspecified"
|
||||
})),
|
||||
RelationKind::DataAssociation => object(json!({
|
||||
"source_role": "", "target_role": "",
|
||||
"source_minimum": 0, "target_minimum": 0,
|
||||
"source_maximum": null, "target_maximum": null,
|
||||
"source_navigable": false, "target_navigable": false
|
||||
})),
|
||||
RelationKind::Interaction | RelationKind::FunctionalExchange => {
|
||||
object(json!({"items": []}))
|
||||
}
|
||||
RelationKind::BoundaryExchange => object(json!({"items": [], "oriented": true})),
|
||||
RelationKind::CommunicationMean => object(json!({"items": [], "oriented": false})),
|
||||
RelationKind::CapabilityExtend => object(json!({"condition": ""})),
|
||||
RelationKind::SequenceLink => object(json!({"guard": ""})),
|
||||
RelationKind::StateTransition => object(json!({
|
||||
"transition_kind": "External", "trigger": "", "guard": "", "effect": "",
|
||||
"triggers": [], "effects": []
|
||||
})),
|
||||
RelationKind::StateAction => object(json!({"phase": "Do"})),
|
||||
// source/target, FunctionalExchange ports and ContextualLink's owner,
|
||||
// endpoints and purpose are required, despite factory placeholder values.
|
||||
_ => Map::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn compact_nested(context: Context, fields: &mut Map<String, Value>) {
|
||||
match context {
|
||||
Context::Element(Kind::DataType) => compact_data_definition(fields),
|
||||
Context::Element(Kind::Port) => {
|
||||
if let Some(legacy) = fields.get_mut("legacy").and_then(Value::as_object_mut) {
|
||||
omit_nulls(legacy, &["interface"]);
|
||||
}
|
||||
// Every selected PortContract payload field remains required.
|
||||
}
|
||||
Context::Element(Kind::Occurrence) => compact_occurrence(fields),
|
||||
Context::Diagram => {
|
||||
if let Some(options) = fields
|
||||
.get_mut("view_options")
|
||||
.and_then(Value::as_object_mut)
|
||||
{
|
||||
omit_defaults(
|
||||
options,
|
||||
&object(json!({
|
||||
"hidden_edges": [], "relation_kinds": "All",
|
||||
"work_categories": "All", "participant_categories": "All",
|
||||
"coverage": "All", "work_contexts": "All", "exchange_labels": "Name"
|
||||
})),
|
||||
);
|
||||
}
|
||||
if let Some(routes) = fields.get_mut("routes").and_then(Value::as_array_mut) {
|
||||
for route in routes {
|
||||
if let Some(route) = route.as_object_mut() {
|
||||
omit_defaults(
|
||||
route,
|
||||
&object(
|
||||
json!({"waypoints": [], "label": null, "source_attachment": null, "target_attachment": null}),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Route entries, EdgeLabel coordinates, projection machine IDs, and
|
||||
// Only([])/Any([]) selectors remain even when their values are empty.
|
||||
}
|
||||
Context::Trace => {
|
||||
if let Some(baseline) = fields.get_mut("baseline").and_then(Value::as_object_mut) {
|
||||
omit_nulls(baseline, &["semantic"]);
|
||||
}
|
||||
// No recursion through snapshots or semantic historical facets.
|
||||
}
|
||||
// DataValue/TypeRef payloads, EventBody fields, Node geometry/anchors,
|
||||
// contextual endpoints/purposes, and other required records stay intact.
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn compact_data_definition(fields: &mut Map<String, Value>) {
|
||||
for tag in ["Integer", "Real"] {
|
||||
if let Some(payload) = constructor_payload(fields, "definition", tag) {
|
||||
omit_nulls(payload, &["minimum", "maximum"]);
|
||||
}
|
||||
}
|
||||
if let Some(payload) = constructor_payload(fields, "definition", "String") {
|
||||
omit_nulls(payload, &["maximum_length"]);
|
||||
// minimum_length is required inside a selected String constructor.
|
||||
}
|
||||
if let Some(payload) = constructor_payload(fields, "definition", "Record") {
|
||||
omit_nulls(payload, &["supertype"]);
|
||||
}
|
||||
// Boolean has no payload; Enumeration.literals remains required even [].
|
||||
}
|
||||
|
||||
fn compact_occurrence(fields: &mut Map<String, Value>) {
|
||||
if let Some(exchange) = constructor_payload(fields, "body", "Exchange") {
|
||||
omit_defaults(
|
||||
exchange,
|
||||
&object(json!({
|
||||
"exchange": null, "sender": null, "receiver": null,
|
||||
"message_kind": "Asynchronous", "reply_to": null,
|
||||
"values": [], "operation": null
|
||||
})),
|
||||
);
|
||||
if let Some(operation) = exchange.get_mut("operation").and_then(Value::as_object_mut) {
|
||||
omit_nulls(operation, &["allocation"]);
|
||||
// Keep the containing operation field even if its record becomes {}:
|
||||
// explicit unresolved operation intent is not an absent operation.
|
||||
}
|
||||
if let Some(values) = exchange.get_mut("values").and_then(Value::as_array_mut) {
|
||||
for value in values {
|
||||
if let Some(message_value) = value.as_object_mut() {
|
||||
omit_defaults(message_value, &object(json!({"path": []})));
|
||||
}
|
||||
}
|
||||
}
|
||||
// MessageKind::Timer.expression, MessageValue.field/expression remain.
|
||||
}
|
||||
for (tag, optional) in [
|
||||
("Action", &["action", "lifeline"][..]),
|
||||
("State", &["state", "lifeline"][..]),
|
||||
("SequenceReference", &["sequence"][..]),
|
||||
("ScenarioUse", &["scenario"][..]),
|
||||
] {
|
||||
if let Some(payload) = constructor_payload(fields, "body", tag) {
|
||||
omit_nulls(payload, optional);
|
||||
}
|
||||
}
|
||||
// ScenarioUse.bindings, Control.kind, Fragment.operator/branches and
|
||||
// Branch.guard/occurrences are required, including empty strings/lists.
|
||||
}
|
||||
|
||||
fn constructor_payload<'a>(
|
||||
fields: &'a mut Map<String, Value>,
|
||||
key: &str,
|
||||
tag: &str,
|
||||
) -> Option<&'a mut Map<String, Value>> {
|
||||
let wrapper = fields.get_mut(key)?.as_object_mut()?;
|
||||
if wrapper.len() != 1 {
|
||||
return None;
|
||||
}
|
||||
wrapper.get_mut(tag)?.as_object_mut()
|
||||
}
|
||||
|
||||
fn omit_nulls(fields: &mut Map<String, Value>, names: &[&str]) {
|
||||
for name in names {
|
||||
if fields.get(*name).is_some_and(Value::is_null) {
|
||||
fields.remove(*name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn omit_defaults(fields: &mut Map<String, Value>, defaults: &Map<String, Value>) {
|
||||
for (name, default) in defaults {
|
||||
if fields.get(name).is_some_and(|value| same(value, default)) {
|
||||
fields.remove(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Comparison recurses only to compare a known field with its exact declared
|
||||
// default. It never traverses arbitrary data looking for values to remove.
|
||||
fn same(value: &Value, default: &Value) -> bool {
|
||||
match (value, default) {
|
||||
(Value::Object(a), Value::Object(b)) => {
|
||||
a.len() == b.len()
|
||||
&& a.iter()
|
||||
.all(|(key, value)| b.get(key).is_some_and(|d| same(value, d)))
|
||||
}
|
||||
(Value::Array(a), Value::Array(b)) => {
|
||||
a.len() == b.len() && a.iter().zip(b).all(|(value, d)| same(value, d))
|
||||
}
|
||||
(Value::Number(a), Value::Number(b)) => {
|
||||
// serde_json numeric equality alone can equate -0.0 with +0.0.
|
||||
// Extracted f32 coordinates are represented exactly as f64 here.
|
||||
a == b
|
||||
&& match (a.as_f64(), b.as_f64()) {
|
||||
(Some(x), Some(y)) if x == 0.0 && y == 0.0 => x.to_bits() == y.to_bits(),
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
_ => value == default,
|
||||
}
|
||||
}
|
||||
|
||||
fn object(value: Value) -> Map<String, Value> {
|
||||
match value {
|
||||
Value::Object(fields) => fields,
|
||||
_ => unreachable!("literal source default is an object"),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "source_defaults_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,718 @@
|
||||
//! Independent literal oracles for the production source-default policy.
|
||||
use super::{self as policy, Context, Mode};
|
||||
use crate::{Kind, RelationKind};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
#[test]
|
||||
fn complete_comparison_keeps_populated_serializer_skip_values() {
|
||||
for (context, input) in [
|
||||
(
|
||||
Context::Element(Kind::Occurrence),
|
||||
json!({"body":{"Exchange":{"operation":{"allocation":"allocation-id"},"values":[]}}}),
|
||||
),
|
||||
(
|
||||
Context::Node,
|
||||
json!({"port_anchors":[{"port":"port-id","side":"Left","offset":0.0}]}),
|
||||
),
|
||||
(
|
||||
Context::Trace,
|
||||
json!({"baseline":{"semantic":{"historical":"payload"}}}),
|
||||
),
|
||||
] {
|
||||
assert_eq!(rendered(context, &input, Mode::CompleteForEdit), input);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compact_value_fragments_keep_selected_constructor_requirements() {
|
||||
for (context, input, expected) in [
|
||||
(
|
||||
Context::Element(Kind::DataType),
|
||||
json!({"definition":{"String":{"minimum_length":0,"maximum_length":null}},"default":null,"unit":""}),
|
||||
json!({"definition":{"String":{"minimum_length":0}},"default":null,"unit":""}),
|
||||
),
|
||||
(
|
||||
Context::Element(Kind::Event),
|
||||
json!({"body":{"Signal":{"items":[]}}}),
|
||||
json!({"body":{"Signal":{"items":[]}}}),
|
||||
),
|
||||
(
|
||||
Context::Element(Kind::Occurrence),
|
||||
json!({"body":{"Exchange":{"operation":{"allocation":null},"values":[]}},"precondition":""}),
|
||||
json!({"body":{"Exchange":{"operation":{}}},"precondition":""}),
|
||||
),
|
||||
(
|
||||
Context::Element(Kind::Occurrence),
|
||||
json!({"body":{"ScenarioUse":{"scenario":null,"bindings":[]}}}),
|
||||
json!({"body":{"ScenarioUse":{"bindings":[]}}}),
|
||||
),
|
||||
] {
|
||||
assert_eq!(rendered(context, &input, Mode::CompactValues), expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_scenario_default_is_independent_of_graphical_creation() {
|
||||
use crate::{ElementBody, Layer, ScenarioViewpoint, SharedBody, language};
|
||||
let project = language::parse(
|
||||
"arcadia 7; project p \"P\" { oa_scenario o \"O\" {} sa_scenario s \"S\" {} }",
|
||||
)
|
||||
.unwrap();
|
||||
for element in project.elements.values() {
|
||||
assert!(matches!(
|
||||
element.body.shared(),
|
||||
Some(SharedBody::Scenario {
|
||||
viewpoint: ScenarioViewpoint::MixedLegacy,
|
||||
..
|
||||
})
|
||||
));
|
||||
assert!(!matches!(
|
||||
ElementBody::new(Kind::Scenario, element.layer())
|
||||
.unwrap()
|
||||
.shared(),
|
||||
Some(SharedBody::Scenario {
|
||||
viewpoint: ScenarioViewpoint::MixedLegacy,
|
||||
..
|
||||
})
|
||||
));
|
||||
}
|
||||
let source = language::format(&project);
|
||||
assert!(!source.contains("description"));
|
||||
assert!(!source.contains("parent"));
|
||||
assert!(!source.contains("viewpoint"));
|
||||
assert_eq!(language::parse(&source).unwrap(), project);
|
||||
|
||||
let mut typed = project;
|
||||
for element in typed.elements.values_mut() {
|
||||
let viewpoint = if element.layer() == Layer::Oa {
|
||||
ScenarioViewpoint::OperationalActivity
|
||||
} else {
|
||||
ScenarioViewpoint::Functional
|
||||
};
|
||||
*element.body.shared_mut().unwrap() = SharedBody::Scenario {
|
||||
viewpoint,
|
||||
context: None,
|
||||
precondition: String::new(),
|
||||
postcondition: String::new(),
|
||||
steps: vec![],
|
||||
};
|
||||
}
|
||||
let source = language::format(&typed);
|
||||
assert!(source.contains("viewpoint OperationalActivity;"));
|
||||
assert!(source.contains("viewpoint Functional;"));
|
||||
assert_eq!(language::parse(&source).unwrap(), typed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_parser_defaults_preserve_required_fields_and_kind_checks() {
|
||||
for (declaration, message) in [
|
||||
(
|
||||
"oa_port p0 \"Port\" {}",
|
||||
"Missing required field 'contract'",
|
||||
),
|
||||
(
|
||||
"allocation r \"R\" { source @owner; }",
|
||||
"Missing required field 'target'",
|
||||
),
|
||||
(
|
||||
"functional_exchange r \"R\" { source @owner; target @work; }",
|
||||
"Missing required field 'source_port'",
|
||||
),
|
||||
(
|
||||
"contextual_link r \"R\" {}",
|
||||
"Missing required field 'owner'",
|
||||
),
|
||||
("node n { diagram @d; }", "Missing required field 'element'"),
|
||||
("artifact a \"A\" {}", "Missing required field 'owner'"),
|
||||
("trace t \"T\" {}", "Missing required field 'oa'"),
|
||||
(
|
||||
"oa_state s \"S\" { kind Initial; }",
|
||||
"Declaration kind disagrees",
|
||||
),
|
||||
(
|
||||
"oa_activity a \"A\" { unknown true; }",
|
||||
"Unknown field 'unknown'",
|
||||
),
|
||||
(
|
||||
"oa_data_type t \"T\" { definition { String {}; }; }",
|
||||
"minimum_length",
|
||||
),
|
||||
(
|
||||
"oa_occurrence o \"O\" { body { ScenarioUse {}; }; }",
|
||||
"bindings",
|
||||
),
|
||||
] {
|
||||
let source = format!(
|
||||
"arcadia 7; project p \"P\" {{ oa_entity owner \"Owner\" {{}} oa_activity work \"Work\" {{}} diagram d \"D\" {{}} {declaration} }}"
|
||||
);
|
||||
let errors = crate::language::parse(&source).unwrap_err();
|
||||
assert!(
|
||||
errors.iter().any(|error| error.message.contains(message)),
|
||||
"{declaration}: {errors:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn rendered(context: Context, input: &Value, mode: Mode) -> Value {
|
||||
Value::Object(policy::fields(context, input.as_object().unwrap(), mode))
|
||||
}
|
||||
|
||||
fn compact(context: Context, input: Value, expected: Value) {
|
||||
let original = input.clone();
|
||||
let result = rendered(context, &input, Mode::Compact);
|
||||
assert_eq!(result, expected, "context {context:?}");
|
||||
assert_eq!(input, original, "the input remains immutable");
|
||||
assert_eq!(
|
||||
rendered(context, &result, Mode::Compact),
|
||||
result,
|
||||
"compact policy is idempotent"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn complete_comparison_restores_only_the_three_known_skips() {
|
||||
let exchange = json!({"body":{"Exchange":{"exchange":null,"sender":null,"receiver":null,"message_kind":"Asynchronous","reply_to":null,"values":[]}}});
|
||||
let mut complete = exchange.clone();
|
||||
complete["body"]["Exchange"]["operation"] = Value::Null;
|
||||
assert_eq!(
|
||||
rendered(
|
||||
Context::Element(Kind::Occurrence),
|
||||
&exchange,
|
||||
Mode::CompleteForEdit
|
||||
),
|
||||
complete
|
||||
);
|
||||
assert_eq!(
|
||||
rendered(
|
||||
Context::Node,
|
||||
&json!({"element":"e","diagram":"d"}),
|
||||
Mode::CompleteForEdit
|
||||
),
|
||||
json!({"element":"e","diagram":"d","port_anchors":[]})
|
||||
);
|
||||
assert_eq!(
|
||||
rendered(
|
||||
Context::Trace,
|
||||
&json!({"baseline":{"oa":{"name":"","description":""},"sa":{"name":"","description":""}}}),
|
||||
Mode::CompleteForEdit
|
||||
),
|
||||
json!({"baseline":{"oa":{"name":"","description":""},"sa":{"name":"","description":""},"semantic":null}})
|
||||
);
|
||||
for input in [
|
||||
json!({"body":{"Action":{"action":null,"lifeline":null}}}),
|
||||
json!({"body":{"State":{"state":null,"lifeline":null}}}),
|
||||
] {
|
||||
assert_eq!(
|
||||
rendered(
|
||||
Context::Element(Kind::Occurrence),
|
||||
&input,
|
||||
Mode::CompleteForEdit
|
||||
),
|
||||
input
|
||||
);
|
||||
}
|
||||
for input in [
|
||||
json!({"baseline":null}),
|
||||
json!({"baseline":{"semantic":{"historical":[null,[],false,0,""]},"oa":{"name":"n","description":""},"sa":{"name":"s","description":""}}}),
|
||||
] {
|
||||
assert_eq!(
|
||||
rendered(Context::Trace, &input, Mode::CompleteForEdit),
|
||||
input
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_element_omits_only_empty_common_defaults() {
|
||||
for kind in Kind::ALL {
|
||||
// Future/unknown payloads are preservation sentinels, not admitted source.
|
||||
compact(
|
||||
Context::Element(kind),
|
||||
json!({"description":"","parent":null,"future":{"empty":[],"absent":null,"zero":0,"flag":false}}),
|
||||
json!({"future":{"empty":[],"absent":null,"zero":0,"flag":false}}),
|
||||
);
|
||||
compact(
|
||||
Context::Element(kind),
|
||||
json!({"description":" \n","parent":"owner"}),
|
||||
json!({"description":" \n","parent":"owner"}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ordinary_element_defaults_are_context_specific() {
|
||||
for kind in [Kind::Process, Kind::FunctionalChain] {
|
||||
compact(Context::Element(kind), json!({"steps":[]}), json!({}));
|
||||
compact(
|
||||
Context::Element(kind),
|
||||
json!({"steps":["second","first"]}),
|
||||
json!({"steps":["second","first"]}),
|
||||
);
|
||||
}
|
||||
compact(
|
||||
Context::Element(Kind::ExchangeItem),
|
||||
json!({"kind":"Information","mechanism":"Unspecified","supertype":null}),
|
||||
json!({}),
|
||||
);
|
||||
compact(
|
||||
Context::Element(Kind::ExchangeItem),
|
||||
json!({"kind":"Energy","mechanism":"Flow","supertype":"base"}),
|
||||
json!({"kind":"Energy","mechanism":"Flow","supertype":"base"}),
|
||||
);
|
||||
compact(
|
||||
Context::Element(Kind::ExchangeCategory),
|
||||
json!({"family":"Work","exchanges":[]}),
|
||||
json!({}),
|
||||
);
|
||||
compact(
|
||||
Context::Element(Kind::ExchangeCategory),
|
||||
json!({"family":"Participant","exchanges":["x"]}),
|
||||
json!({"family":"Participant","exchanges":["x"]}),
|
||||
);
|
||||
compact(
|
||||
Context::Element(Kind::Interface),
|
||||
json!({"supertype":null}),
|
||||
json!({}),
|
||||
);
|
||||
compact(
|
||||
Context::Element(Kind::Lifeline),
|
||||
json!({"represented":null}),
|
||||
json!({}),
|
||||
);
|
||||
compact(
|
||||
Context::Element(Kind::Constraint),
|
||||
json!({"expression":"","constrained":[]}),
|
||||
json!({}),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scenario_source_default_never_uses_the_graphical_viewpoint_default() {
|
||||
compact(
|
||||
Context::Element(Kind::Scenario),
|
||||
json!({"viewpoint":"MixedLegacy","context":null,"precondition":"","postcondition":"","steps":[]}),
|
||||
json!({}),
|
||||
);
|
||||
for viewpoint in [
|
||||
"OperationalActivity",
|
||||
"Functional",
|
||||
"ParticipantInteraction",
|
||||
"BoundaryInterface",
|
||||
] {
|
||||
compact(
|
||||
Context::Element(Kind::Scenario),
|
||||
json!({"viewpoint":viewpoint,"context":"scope","precondition":"ready","postcondition":"","steps":["two","one"]}),
|
||||
json!({"viewpoint":viewpoint,"context":"scope","precondition":"ready","steps":["two","one"]}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_state_header_supplies_its_own_kind() {
|
||||
for (kind, name) in [
|
||||
(Kind::State, "State"),
|
||||
(Kind::Mode, "Mode"),
|
||||
(Kind::Initial, "Initial"),
|
||||
(Kind::Final, "Final"),
|
||||
(Kind::Choice, "Choice"),
|
||||
(Kind::Junction, "Junction"),
|
||||
(Kind::Fork, "Fork"),
|
||||
(Kind::Join, "Join"),
|
||||
(Kind::ShallowHistory, "ShallowHistory"),
|
||||
(Kind::DeepHistory, "DeepHistory"),
|
||||
(Kind::EntryPoint, "EntryPoint"),
|
||||
(Kind::ExitPoint, "ExitPoint"),
|
||||
(Kind::Terminate, "Terminate"),
|
||||
] {
|
||||
compact(
|
||||
Context::Element(kind),
|
||||
json!({"kind":name,"entry":"","exit":"","do_activity":""}),
|
||||
json!({}),
|
||||
);
|
||||
}
|
||||
// Invalid mismatches must not be silently normalized by the writer policy.
|
||||
compact(
|
||||
Context::Element(Kind::Mode),
|
||||
json!({"kind":"State","entry":"work"}),
|
||||
json!({"kind":"State","entry":"work"}),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn data_field_unbounded_and_present_empty_values_are_not_defaults() {
|
||||
compact(
|
||||
Context::Element(Kind::DataField),
|
||||
json!({"type_ref":"String","minimum":1,"maximum":1,"unit":"","direction":"Unspecified","storage":"Value","default":null}),
|
||||
json!({}),
|
||||
);
|
||||
compact(
|
||||
Context::Element(Kind::DataField),
|
||||
json!({"type_ref":{"Named":"record"},"minimum":0,"maximum":null,"unit":"m","direction":"Out","storage":"Reference","default":[]}),
|
||||
json!({"type_ref":{"Named":"record"},"minimum":0,"maximum":null,"unit":"m","direction":"Out","storage":"Reference","default":[]}),
|
||||
);
|
||||
for value in [
|
||||
json!({"Boolean":false}),
|
||||
json!({"Integer":0}),
|
||||
json!({"Real":0.0}),
|
||||
json!({"String":""}),
|
||||
] {
|
||||
compact(
|
||||
Context::Element(Kind::DataField),
|
||||
json!({"default":[value.clone()]}),
|
||||
json!({"default":[value]}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn data_definition_whole_default_and_selected_required_fields_differ() {
|
||||
compact(
|
||||
Context::Element(Kind::DataType),
|
||||
json!({"definition":{"String":{"minimum_length":0,"maximum_length":null}},"default":null,"unit":""}),
|
||||
json!({}),
|
||||
);
|
||||
for (input, expected) in [
|
||||
(
|
||||
json!({"Integer":{"minimum":null,"maximum":null}}),
|
||||
json!({"Integer":{}}),
|
||||
),
|
||||
(
|
||||
json!({"Real":{"minimum":null,"maximum":2.5}}),
|
||||
json!({"Real":{"maximum":2.5}}),
|
||||
),
|
||||
(
|
||||
json!({"String":{"minimum_length":4,"maximum_length":null}}),
|
||||
json!({"String":{"minimum_length":4}}),
|
||||
),
|
||||
(json!({"Record":{"supertype":null}}), json!({"Record":{}})),
|
||||
(
|
||||
json!({"Enumeration":{"literals":[]}}),
|
||||
json!({"Enumeration":{"literals":[]}}),
|
||||
),
|
||||
] {
|
||||
compact(
|
||||
Context::Element(Kind::DataType),
|
||||
json!({"definition":input}),
|
||||
json!({"definition":expected}),
|
||||
);
|
||||
}
|
||||
compact(
|
||||
Context::Element(Kind::DataType),
|
||||
json!({"definition":"Boolean","default":{"Boolean":false}}),
|
||||
json!({"definition":"Boolean","default":{"Boolean":false}}),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn port_contract_and_its_required_payload_stay_explicit() {
|
||||
compact(
|
||||
Context::Element(Kind::Port),
|
||||
json!({"contract":"Legacy","legacy":{"direction":"Out","interface":null}}),
|
||||
json!({"contract":"Legacy","legacy":{"direction":"Out"}}),
|
||||
);
|
||||
for contract in [
|
||||
json!({"FunctionInput":{"incoming_items":[]}}),
|
||||
json!({"FunctionOutput":{"outgoing_items":[]}}),
|
||||
json!({"Component":{"kind":"Standard","orientation":"Unset"}}),
|
||||
] {
|
||||
compact(
|
||||
Context::Element(Kind::Port),
|
||||
json!({"contract":contract.clone(),"legacy":null}),
|
||||
json!({"contract":contract}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn occurrence_whole_default_is_distinct_from_selected_constructor_payload() {
|
||||
compact(
|
||||
Context::Element(Kind::Occurrence),
|
||||
json!({"body":{"Exchange":{"exchange":null,"sender":null,"receiver":null,"message_kind":"Asynchronous","reply_to":null,"values":[]}},"precondition":"","postcondition":""}),
|
||||
json!({}),
|
||||
);
|
||||
for (input, expected) in [
|
||||
(
|
||||
json!({"Action":{"action":null,"lifeline":null}}),
|
||||
json!({"Action":{}}),
|
||||
),
|
||||
(
|
||||
json!({"State":{"state":null,"lifeline":null}}),
|
||||
json!({"State":{}}),
|
||||
),
|
||||
(
|
||||
json!({"SequenceReference":{"sequence":null}}),
|
||||
json!({"SequenceReference":{}}),
|
||||
),
|
||||
(
|
||||
json!({"ScenarioUse":{"scenario":null,"bindings":[]}}),
|
||||
json!({"ScenarioUse":{"bindings":[]}}),
|
||||
),
|
||||
(
|
||||
json!({"Branch":{"guard":"","occurrences":[]}}),
|
||||
json!({"Branch":{"guard":"","occurrences":[]}}),
|
||||
),
|
||||
(
|
||||
json!({"Fragment":{"operator":"Parallel","branches":[]}}),
|
||||
json!({"Fragment":{"operator":"Parallel","branches":[]}}),
|
||||
),
|
||||
(
|
||||
json!({"Control":{"kind":"Choice"}}),
|
||||
json!({"Control":{"kind":"Choice"}}),
|
||||
),
|
||||
] {
|
||||
compact(
|
||||
Context::Element(Kind::Occurrence),
|
||||
json!({"body":input}),
|
||||
json!({"body":expected}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unresolved_operation_and_required_message_value_are_preserved() {
|
||||
compact(
|
||||
Context::Element(Kind::Occurrence),
|
||||
json!({"body":{"Exchange":{"exchange":null,"sender":null,"receiver":null,"message_kind":"Asynchronous","reply_to":null,"values":[{"field":"f","path":[],"expression":""}],"operation":{"allocation":null}}}}),
|
||||
json!({"body":{"Exchange":{"values":[{"field":"f","expression":""}],"operation":{}}}}),
|
||||
);
|
||||
compact(
|
||||
Context::Element(Kind::Occurrence),
|
||||
json!({"body":{"Exchange":{"exchange":"flow","sender":"a","receiver":"b","message_kind":{"Timer":{"expression":""}},"reply_to":"request","values":[],"operation":null}}}),
|
||||
json!({"body":{"Exchange":{"exchange":"flow","sender":"a","receiver":"b","message_kind":{"Timer":{"expression":""}},"reply_to":"request"}}}),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn event_body_whole_default_does_not_prune_selected_required_fields() {
|
||||
compact(
|
||||
Context::Element(Kind::Event),
|
||||
json!({"body":{"Signal":{"items":[]}}}),
|
||||
json!({}),
|
||||
);
|
||||
for body in [
|
||||
json!({"Signal":{"items":["i"]}}),
|
||||
json!({"Time":{"kind":"After","expression":""}}),
|
||||
json!({"Change":{"expression":""}}),
|
||||
] {
|
||||
compact(
|
||||
Context::Element(Kind::Event),
|
||||
json!({"body":body.clone()}),
|
||||
json!({"body":body}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relation_required_endpoints_and_contextual_payloads_remain() {
|
||||
for kind in RelationKind::ALL {
|
||||
compact(
|
||||
Context::Relation(kind),
|
||||
json!({"description":"","source":"s","target":"t","future":[]}),
|
||||
json!({"source":"s","target":"t","future":[]}),
|
||||
);
|
||||
}
|
||||
compact(
|
||||
Context::Relation(RelationKind::FunctionalExchange),
|
||||
json!({"source":"s","target":"t","source_port":"sp","target_port":"tp","items":[]}),
|
||||
json!({"source":"s","target":"t","source_port":"sp","target_port":"tp"}),
|
||||
);
|
||||
let contextual = json!({"owner":"o","source":{"references":[],"occurrence":"s"},"target":{"references":[],"occurrence":"t"},"purpose":{"Precedence":{"guard":""}}});
|
||||
compact(
|
||||
Context::Relation(RelationKind::ContextualLink),
|
||||
contextual.clone(),
|
||||
contextual,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relation_defaults_preserve_contextual_orientation_and_multiplicity() {
|
||||
for (kind, orientation) in [
|
||||
(RelationKind::BoundaryExchange, true),
|
||||
(RelationKind::CommunicationMean, false),
|
||||
] {
|
||||
compact(
|
||||
Context::Relation(kind),
|
||||
json!({"oriented":orientation,"items":[]}),
|
||||
json!({}),
|
||||
);
|
||||
compact(
|
||||
Context::Relation(kind),
|
||||
json!({"oriented":!orientation}),
|
||||
json!({"oriented":!orientation}),
|
||||
);
|
||||
}
|
||||
compact(
|
||||
Context::Relation(RelationKind::DataAssociation),
|
||||
json!({"source_role":"","target_role":"","source_minimum":0,"target_minimum":0,"source_maximum":null,"target_maximum":null,"source_navigable":false,"target_navigable":false}),
|
||||
json!({}),
|
||||
);
|
||||
compact(
|
||||
Context::Relation(RelationKind::DataAssociation),
|
||||
json!({"source_maximum":0,"target_maximum":1,"source_navigable":true}),
|
||||
json!({"source_maximum":0,"target_maximum":1,"source_navigable":true}),
|
||||
);
|
||||
compact(
|
||||
Context::Relation(RelationKind::InterfaceItemAllocation),
|
||||
json!({"order":0,"transmission":"Unspecified","acquisition":"Unspecified"}),
|
||||
json!({}),
|
||||
);
|
||||
compact(
|
||||
Context::Relation(RelationKind::CapabilityExtend),
|
||||
json!({"condition":""}),
|
||||
json!({}),
|
||||
);
|
||||
compact(
|
||||
Context::Relation(RelationKind::SequenceLink),
|
||||
json!({"guard":""}),
|
||||
json!({}),
|
||||
);
|
||||
compact(
|
||||
Context::Relation(RelationKind::StateAction),
|
||||
json!({"phase":"Do"}),
|
||||
json!({}),
|
||||
);
|
||||
compact(
|
||||
Context::Relation(RelationKind::StateTransition),
|
||||
json!({"transition_kind":"External","trigger":"","guard":"","effect":"","triggers":[],"effects":[]}),
|
||||
json!({}),
|
||||
);
|
||||
compact(
|
||||
Context::Relation(RelationKind::StateTransition),
|
||||
json!({"transition_kind":"Internal","triggers":["t"],"effects":["e"]}),
|
||||
json!({"transition_kind":"Internal","triggers":["t"],"effects":["e"]}),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diagram_options_remove_only_unrestricted_defaults() {
|
||||
let defaults = json!({"hidden_edges":[],"relation_kinds":"All","work_categories":"All","participant_categories":"All","coverage":"All","work_contexts":"All","exchange_labels":"Name"});
|
||||
compact(
|
||||
Context::Diagram,
|
||||
json!({"description":"","layer":"Oa","projection":"Overview","routes":[],"view_options":defaults}),
|
||||
json!({}),
|
||||
);
|
||||
compact(
|
||||
Context::Diagram,
|
||||
json!({"layer":"Sa","projection":{"StateMachine":{"machine":"m"}},"view_options":{"hidden_edges":[],"relation_kinds":{"Only":[]},"work_categories":{"Any":[]},"participant_categories":"All","coverage":"All","work_contexts":{"Any":[]},"exchange_labels":"Name"}}),
|
||||
json!({"layer":"Sa","projection":{"StateMachine":{"machine":"m"}},"view_options":{"relation_kinds":{"Only":[]},"work_categories":{"Any":[]},"work_contexts":{"Any":[]}}}),
|
||||
);
|
||||
compact(
|
||||
Context::Diagram,
|
||||
json!({"routes":[{"edge":"e","waypoints":[],"label":null}]}),
|
||||
json!({"routes":[{"edge":"e"}]}),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn node_whole_defaults_preserve_negative_zero_and_required_coordinates() {
|
||||
compact(
|
||||
Context::Node,
|
||||
json!({"diagram":"d","element":"e","position":{"x":0.0,"y":0.0},"size":{"width":224.0,"height":94.0},"collapsed":false}),
|
||||
json!({"diagram":"d","element":"e"}),
|
||||
);
|
||||
for position in [
|
||||
json!({"x":-0.0,"y":0.0}),
|
||||
json!({"x":0.0,"y":-0.0}),
|
||||
json!({"x":1.0,"y":0.0}),
|
||||
] {
|
||||
let result = rendered(
|
||||
Context::Node,
|
||||
&json!({"position":position.clone()}),
|
||||
Mode::Compact,
|
||||
);
|
||||
assert_eq!(result, json!({"position":position.clone()}));
|
||||
for coordinate in ["x", "y"] {
|
||||
assert_eq!(
|
||||
result["position"][coordinate].as_f64().unwrap().to_bits(),
|
||||
position[coordinate].as_f64().unwrap().to_bits(),
|
||||
"coordinate sign is part of fidelity"
|
||||
);
|
||||
}
|
||||
}
|
||||
compact(
|
||||
Context::Node,
|
||||
json!({"size":{"width":300.0,"height":94.0},"port_anchors":[{"port":"p","side":"Left","offset":0.0}]}),
|
||||
json!({"size":{"width":300.0,"height":94.0},"port_anchors":[{"port":"p","side":"Left","offset":0.0}]}),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn artifact_present_empty_strings_and_trace_history_stay_distinct() {
|
||||
compact(
|
||||
Context::Artifact,
|
||||
json!({"owner":"o","description":"","relation":"References","uri":"","revision":null,"fragment":null}),
|
||||
json!({"owner":"o"}),
|
||||
);
|
||||
compact(
|
||||
Context::Artifact,
|
||||
json!({"owner":"o","relation":"Implements","revision":"","fragment":""}),
|
||||
json!({"owner":"o","relation":"Implements","revision":"","fragment":""}),
|
||||
);
|
||||
compact(
|
||||
Context::Trace,
|
||||
json!({"oa":"o","sa":"s","description":"","kind":"Refinement","baseline":null}),
|
||||
json!({"oa":"o","sa":"s"}),
|
||||
);
|
||||
compact(
|
||||
Context::Trace,
|
||||
json!({"baseline":{"oa":{"name":"","description":""},"sa":{"name":"s","description":""}}}),
|
||||
json!({"baseline":{"oa":{"name":"","description":""},"sa":{"name":"s","description":""}}}),
|
||||
);
|
||||
let baseline = json!({"oa":{"name":"o","description":""},"sa":{"name":"s","description":""},"semantic":{"facets":[{"Value":null},{"Value":[]},{"Value":false},{"Value":0},{"Value":""}]}});
|
||||
compact(
|
||||
Context::Trace,
|
||||
json!({"kind":"Manual","baseline":baseline.clone()}),
|
||||
json!({"kind":"Manual","baseline":baseline}),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_future_fields_prevent_whole_default_removal() {
|
||||
// These sentinels exercise preservation, not current parser admission of
|
||||
// unknown fields. Only known slots may lose their redundant defaults.
|
||||
compact(
|
||||
Context::Element(Kind::DataType),
|
||||
json!({"definition":{"String":{"minimum_length":0,"maximum_length":null,"future":[]}}}),
|
||||
json!({"definition":{"String":{"minimum_length":0,"future":[]}}}),
|
||||
);
|
||||
compact(
|
||||
Context::Element(Kind::Occurrence),
|
||||
json!({"body":{"Exchange":{"exchange":null,"sender":null,"receiver":null,"message_kind":"Asynchronous","reply_to":null,"values":[],"operation":null,"future":false}}}),
|
||||
json!({"body":{"Exchange":{"future":false}}}),
|
||||
);
|
||||
compact(
|
||||
Context::Diagram,
|
||||
json!({"view_options":{"hidden_edges":[],"relation_kinds":"All","work_categories":"All","participant_categories":"All","coverage":"All","work_contexts":"All","exchange_labels":"Name","future":null}}),
|
||||
json!({"view_options":{"future":null}}),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_and_multikey_constructor_wrappers_are_not_reinterpreted() {
|
||||
for definition in [
|
||||
json!({"Future":{"minimum":null,"maximum":null}}),
|
||||
json!({"String":{"minimum_length":0,"maximum_length":null},"Future":{}}),
|
||||
] {
|
||||
compact(
|
||||
Context::Element(Kind::DataType),
|
||||
json!({"definition":definition.clone()}),
|
||||
json!({"definition":definition}),
|
||||
);
|
||||
}
|
||||
for body in [
|
||||
json!({"Future":{"operation":null,"values":[]}}),
|
||||
json!({"Exchange":{"exchange":null,"values":[]},"Future":{}}),
|
||||
] {
|
||||
let fields = json!({"body":body});
|
||||
assert_eq!(
|
||||
rendered(
|
||||
Context::Element(Kind::Occurrence),
|
||||
&fields,
|
||||
Mode::CompleteForEdit
|
||||
),
|
||||
fields,
|
||||
"do not invent a selected Exchange constructor in an unknown wrapper",
|
||||
);
|
||||
compact(Context::Element(Kind::Occurrence), fields.clone(), fields);
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,7 @@ pub enum DiskExpectation {
|
||||
|
||||
/// `bundle` retains detached and invalid buffers. Compile `reachable_bundle()`;
|
||||
/// use `diagnostics` to explain files that discovery could not read or parse.
|
||||
/// Diagnostic spans describe `bundle` bytes, including before UUID stabilization.
|
||||
/// Expectations always describe disk bytes before UUID stabilization.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DiskProject {
|
||||
@@ -526,10 +527,11 @@ pub fn discover_missing(
|
||||
result.expectations.keys().chain(result.bundle.files.keys()),
|
||||
)?;
|
||||
if result.diagnostics.is_empty() {
|
||||
match compile_bundle(&result.reachable_bundle(), limits) {
|
||||
Ok(compiled) => result.diagnostics = compiled.diagnostics,
|
||||
Err(diagnostics) => result.diagnostics = diagnostics,
|
||||
}
|
||||
// Discovery retains raw editor bytes. Compilation may insert UUIDs or
|
||||
// rewrite older syntax, so its returned locations belong to a different
|
||||
// bundle. Lint still performs semantic migration and model validation;
|
||||
// Open/Apply separately compile and install the rewritten source.
|
||||
result.diagnostics = lint_bundle(&result.reachable_bundle(), limits);
|
||||
}
|
||||
let pending = journal_path(&result.root, &result.bundle.entry);
|
||||
if fs::symlink_metadata(&pending).is_ok() {
|
||||
|
||||
@@ -726,7 +726,8 @@ fn apply_edits(bundle: &mut SourceBundle, edits: Edits) -> Result<(), Vec<Source
|
||||
pub fn format_bundle(project: &Project) -> SourceBundle {
|
||||
let ownership = default_ownership(project);
|
||||
let aliases: BTreeMap<_, _> = project.identities().into_iter().collect();
|
||||
let mut declarations = language::format_declarations(project);
|
||||
let mut declarations =
|
||||
language::format_declarations(project, language::DeclarationFormat::Compact);
|
||||
declarations.sort_by_key(|(id, _)| declaration_order(project, *id, &aliases));
|
||||
let mut files: BTreeMap<LogicalPath, String> = BTreeMap::new();
|
||||
for (id, declaration) in declarations {
|
||||
@@ -735,11 +736,13 @@ pub fn format_bundle(project: &Project) -> SourceBundle {
|
||||
.or_default()
|
||||
.push_str(&unindent(&declaration));
|
||||
}
|
||||
let mut entry = format!(
|
||||
"arcadia {SCHEMA_VERSION};\n{}{{\n description {};\n",
|
||||
project_header(project),
|
||||
quote(&project.description)
|
||||
);
|
||||
let mut entry = format!("arcadia {SCHEMA_VERSION};\n{}{{\n", project_header(project));
|
||||
if !project.description.is_empty() {
|
||||
entry.push_str(&format!(
|
||||
" description {};\n",
|
||||
quote(&project.description)
|
||||
));
|
||||
}
|
||||
for path in files.keys() {
|
||||
entry.push_str(&format!(" include {};\n", quote(path)));
|
||||
}
|
||||
@@ -850,8 +853,25 @@ pub fn update_bundle_with_identity_map(
|
||||
)]);
|
||||
}
|
||||
let origins: BTreeMap<_, _> = identities.iter().map(|(old, new)| (*new, *old)).collect();
|
||||
let before: BTreeMap<_, _> = language::format_declarations(old).into_iter().collect();
|
||||
let after: BTreeMap<_, _> = language::format_declarations(project).into_iter().collect();
|
||||
// Comparison must include defaults even when persistence serialization or
|
||||
// compact output omits them. Otherwise an unrelated payload edit erases
|
||||
// unchanged explicit fields from the authored document.
|
||||
let before: BTreeMap<_, _> =
|
||||
language::format_declarations(old, language::DeclarationFormat::CompleteForEdit)
|
||||
.into_iter()
|
||||
.collect();
|
||||
let after: BTreeMap<_, _> =
|
||||
language::format_declarations(project, language::DeclarationFormat::CompleteForEdit)
|
||||
.into_iter()
|
||||
.collect();
|
||||
let compact: BTreeMap<_, _> =
|
||||
language::format_declarations(project, language::DeclarationFormat::Compact)
|
||||
.into_iter()
|
||||
.collect();
|
||||
let compact_values: BTreeMap<_, _> =
|
||||
language::format_declarations(project, language::DeclarationFormat::CompactValues)
|
||||
.into_iter()
|
||||
.collect();
|
||||
let old_aliases: BTreeMap<_, _> = old
|
||||
.identities()
|
||||
.into_iter()
|
||||
@@ -907,6 +927,27 @@ pub fn update_bundle_with_identity_map(
|
||||
};
|
||||
let mut previous = parse(old_text)?;
|
||||
let mut next = parse(new_text)?;
|
||||
let next_id = identities[&id];
|
||||
let value_text = &compact_values[&next_id];
|
||||
let insertion = InsertionSyntax::new(
|
||||
&next.fields,
|
||||
&parse(value_text)?.fields,
|
||||
value_text,
|
||||
&parse(&compact[&next_id])?.fields,
|
||||
);
|
||||
let old_kind = previous.header["kind"];
|
||||
let new_kind = next.header["kind"];
|
||||
if old_text[old_kind.start..old_kind.end] != new_text[new_kind.start..new_kind.end]
|
||||
&& let language::EditKind::Record(actual_fields) = &actual.fields.kind
|
||||
&& let language::EditKind::Record(previous_fields) = &mut previous.fields.kind
|
||||
{
|
||||
// An implicit value is supplied by its declaration context.
|
||||
// After a kind change, an unchanged value may need explicit
|
||||
// syntax (e.g. BoundaryExchange true -> CommunicationMean true).
|
||||
// Reconsider only absent fields; authored explicit values still
|
||||
// compare against their complete old values and keep trivia.
|
||||
previous_fields.retain(|key, _| actual_fields.contains_key(key));
|
||||
}
|
||||
if actual.lexical_owner.is_some()
|
||||
&& let language::EditKind::Record(fields) = &actual.fields.kind
|
||||
&& !fields.contains_key("parent")
|
||||
@@ -923,6 +964,7 @@ pub fn update_bundle_with_identity_map(
|
||||
source,
|
||||
before: old_text,
|
||||
after: new_text,
|
||||
insertion: Some(&insertion),
|
||||
old_aliases: &old_aliases,
|
||||
new_aliases: &new_aliases,
|
||||
legacy_value_paths: false,
|
||||
@@ -930,18 +972,7 @@ pub fn update_bundle_with_identity_map(
|
||||
};
|
||||
let local = edits.entry(path.clone()).or_default();
|
||||
update.header(&actual.header, &previous.header, &next.header, local);
|
||||
let context = if old.diagrams.contains_key(&id) {
|
||||
EditContext::Diagram
|
||||
} else {
|
||||
EditContext::Other
|
||||
};
|
||||
update.value(
|
||||
&actual.fields,
|
||||
Some(&previous.fields),
|
||||
&next.fields,
|
||||
context,
|
||||
local,
|
||||
);
|
||||
update.value(&actual.fields, Some(&previous.fields), &next.fields, local);
|
||||
}
|
||||
}
|
||||
if old.id != project.id || old.alias != project.alias || old.name != project.name {
|
||||
@@ -964,6 +995,7 @@ pub fn update_bundle_with_identity_map(
|
||||
source,
|
||||
before: &before,
|
||||
after: &after,
|
||||
insertion: None,
|
||||
old_aliases: &old_aliases,
|
||||
new_aliases: &new_aliases,
|
||||
legacy_value_paths: false,
|
||||
@@ -1022,7 +1054,7 @@ pub fn update_bundle_with_identity_map(
|
||||
project,
|
||||
identities,
|
||||
&original_order,
|
||||
&after,
|
||||
&compact,
|
||||
limits,
|
||||
)?;
|
||||
let result = compile_bundle(&bundle, limits)?;
|
||||
@@ -1130,25 +1162,64 @@ 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,
|
||||
// Value fragments retain required payload fields even when the enclosing
|
||||
// declaration field is itself omittable. For example, a selected String type
|
||||
// needs minimum_length; an Event Signal needs items. The complete tree remains
|
||||
// the comparison authority; this parallel tree is only an insertion template.
|
||||
struct InsertionSyntax<'a> {
|
||||
source: &'a str,
|
||||
values: BTreeMap<usize, Span>,
|
||||
omitted_fields: BTreeSet<usize>,
|
||||
}
|
||||
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,
|
||||
impl<'a> InsertionSyntax<'a> {
|
||||
fn new(
|
||||
complete: &language::EditValue,
|
||||
compact_values: &language::EditValue,
|
||||
source: &'a str,
|
||||
compact: &language::EditValue,
|
||||
) -> Self {
|
||||
let mut result = Self {
|
||||
source,
|
||||
values: BTreeMap::new(),
|
||||
omitted_fields: BTreeSet::new(),
|
||||
};
|
||||
result.collect(complete, compact_values);
|
||||
if let (language::EditKind::Record(all), language::EditKind::Record(kept)) =
|
||||
(&complete.kind, &compact.kind)
|
||||
{
|
||||
for (key, field) in all {
|
||||
if !kept.contains_key(key) {
|
||||
result.omitted_fields.insert(field.value.span.start);
|
||||
}
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
fn collect(&mut self, complete: &language::EditValue, compact: &language::EditValue) {
|
||||
// Borrow the existing compact text once. Copying each containing record
|
||||
// here would duplicate a large nested payload at every depth.
|
||||
self.values.insert(complete.span.start, compact.span);
|
||||
match (&complete.kind, &compact.kind) {
|
||||
(language::EditKind::Record(all), language::EditKind::Record(kept)) => {
|
||||
for (key, field) in all {
|
||||
if let Some(next) = kept.get(key) {
|
||||
self.collect(&field.value, &next.value);
|
||||
} else {
|
||||
self.omitted_fields.insert(field.value.span.start);
|
||||
}
|
||||
}
|
||||
}
|
||||
(language::EditKind::List(all), language::EditKind::List(kept)) => {
|
||||
assert_eq!(
|
||||
all.len(),
|
||||
kept.len(),
|
||||
"compaction preserves array membership"
|
||||
);
|
||||
for (field, next) in all.iter().zip(kept) {
|
||||
self.collect(field, next);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1157,6 +1228,7 @@ struct SyntaxUpdate<'a> {
|
||||
source: &'a str,
|
||||
before: &'a str,
|
||||
after: &'a str,
|
||||
insertion: Option<&'a InsertionSyntax<'a>>,
|
||||
old_aliases: &'a BTreeMap<String, String>,
|
||||
new_aliases: &'a BTreeMap<String, String>,
|
||||
// Source3 migration only adds path qualification; the existing field identity
|
||||
@@ -1165,6 +1237,16 @@ struct SyntaxUpdate<'a> {
|
||||
identity_origins: Option<&'a BTreeMap<Id, Id>>,
|
||||
}
|
||||
impl SyntaxUpdate<'_> {
|
||||
fn inserted(&self, value: &language::EditValue) -> &str {
|
||||
self.insertion
|
||||
.and_then(|syntax| {
|
||||
syntax
|
||||
.values
|
||||
.get(&value.span.start)
|
||||
.map(|span| &syntax.source[span.start..span.end])
|
||||
})
|
||||
.unwrap_or(&self.after[value.span.start..value.span.end])
|
||||
}
|
||||
fn header(
|
||||
&self,
|
||||
actual: &BTreeMap<&str, Span>,
|
||||
@@ -1219,7 +1301,6 @@ impl SyntaxUpdate<'_> {
|
||||
actual: &language::EditValue,
|
||||
before: Option<&language::EditValue>,
|
||||
after: &language::EditValue,
|
||||
context: EditContext,
|
||||
edits: &mut LocalEdits,
|
||||
) {
|
||||
use language::EditKind;
|
||||
@@ -1235,7 +1316,7 @@ impl SyntaxUpdate<'_> {
|
||||
None
|
||||
}
|
||||
});
|
||||
self.record(actual.span, fields, previous, next, context, edits);
|
||||
self.record(actual.span, fields, previous, next, edits);
|
||||
}
|
||||
(EditKind::List(items), EditKind::List(next)) => {
|
||||
let previous = before.and_then(|old| {
|
||||
@@ -1245,7 +1326,7 @@ impl SyntaxUpdate<'_> {
|
||||
None
|
||||
}
|
||||
});
|
||||
self.list(actual.span, items, previous, next, context, edits);
|
||||
self.list(actual.span, items, previous, next, edits);
|
||||
}
|
||||
(EditKind::Reference(_), EditKind::Reference(alias)) => {
|
||||
let token = significant(&self.source[actual.span.start..actual.span.end])
|
||||
@@ -1278,7 +1359,7 @@ impl SyntaxUpdate<'_> {
|
||||
_ => {
|
||||
erase_syntax(self.source, actual.span, edits);
|
||||
let text = inserted_value(
|
||||
&self.after[after.span.start..after.span.end],
|
||||
self.inserted(after),
|
||||
indent_at(self.source, actual.span.start),
|
||||
self.source,
|
||||
);
|
||||
@@ -1298,7 +1379,6 @@ impl SyntaxUpdate<'_> {
|
||||
actual: &BTreeMap<String, language::EditField>,
|
||||
before: Option<&BTreeMap<String, language::EditField>>,
|
||||
after: &BTreeMap<String, language::EditField>,
|
||||
context: EditContext,
|
||||
edits: &mut LocalEdits,
|
||||
) {
|
||||
// A typed constructor tag change retains shared fields and their trivia.
|
||||
@@ -1322,13 +1402,7 @@ impl SyntaxUpdate<'_> {
|
||||
},
|
||||
new_key.clone(),
|
||||
));
|
||||
self.value(
|
||||
&field.value,
|
||||
Some(&old.value),
|
||||
&new.value,
|
||||
context.field(new_key),
|
||||
edits,
|
||||
);
|
||||
self.value(&field.value, Some(&old.value), &new.value, edits);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -1340,16 +1414,9 @@ impl SyntaxUpdate<'_> {
|
||||
.and_then(|fields| fields.get(key))
|
||||
.map(|field| &field.value),
|
||||
&next.value,
|
||||
context.field(key),
|
||||
edits,
|
||||
);
|
||||
} else if !(matches!(context, EditContext::Diagram)
|
||||
&& key == "layout"
|
||||
&& before.is_some_and(|fields| !fields.contains_key(key)))
|
||||
{
|
||||
// Canonical None omits layout. Preserve an authored explicit
|
||||
// none while another field changes; a real Some->None clear
|
||||
// still removes the former payload and retains its trivia.
|
||||
} else {
|
||||
erase_syntax(self.source, field.span, edits);
|
||||
}
|
||||
}
|
||||
@@ -1357,6 +1424,9 @@ impl SyntaxUpdate<'_> {
|
||||
.iter()
|
||||
.filter(|(key, field)| {
|
||||
!actual.contains_key(*key)
|
||||
&& self.insertion.is_none_or(|syntax| {
|
||||
!syntax.omitted_fields.contains(&field.value.span.start)
|
||||
})
|
||||
&& before
|
||||
.and_then(|fields| fields.get(*key))
|
||||
.is_none_or(|old| !self.same(&old.value, &field.value))
|
||||
@@ -1375,11 +1445,7 @@ impl SyntaxUpdate<'_> {
|
||||
.unwrap_or_else(|| format!("{} ", indent_at(self.source, span.start)));
|
||||
let mut text = String::new();
|
||||
for (key, field) in added {
|
||||
let value = inserted_value(
|
||||
&self.after[field.value.span.start..field.value.span.end],
|
||||
&indent,
|
||||
self.source,
|
||||
);
|
||||
let value = inserted_value(self.inserted(&field.value), &indent, self.source);
|
||||
if multiline {
|
||||
text.push_str(&indent);
|
||||
text.push_str(&format!("{key} {value};{}", newline(self.source)));
|
||||
@@ -1404,7 +1470,6 @@ impl SyntaxUpdate<'_> {
|
||||
actual: &[language::EditValue],
|
||||
before: Option<&[language::EditValue]>,
|
||||
after: &[language::EditValue],
|
||||
context: EditContext,
|
||||
edits: &mut LocalEdits,
|
||||
) {
|
||||
let previous = before.unwrap_or(&[]);
|
||||
@@ -1417,7 +1482,6 @@ impl SyntaxUpdate<'_> {
|
||||
self.old_aliases,
|
||||
self.legacy_value_paths,
|
||||
None,
|
||||
context,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
@@ -1430,7 +1494,6 @@ impl SyntaxUpdate<'_> {
|
||||
self.new_aliases,
|
||||
self.legacy_value_paths,
|
||||
self.identity_origins,
|
||||
context,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
@@ -1459,7 +1522,7 @@ impl SyntaxUpdate<'_> {
|
||||
.all(|(i, source)| *source == Some(i))
|
||||
{
|
||||
for (i, (current, next)) in actual.iter().zip(after).enumerate() {
|
||||
self.value(current, previous.get(i), next, EditContext::Other, edits);
|
||||
self.value(current, previous.get(i), next, edits);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -1492,13 +1555,7 @@ impl SyntaxUpdate<'_> {
|
||||
let chunk = chunks[index];
|
||||
text.push_str(&self.source[chunk.start..current.span.start]);
|
||||
let mut local = Vec::new();
|
||||
self.value(
|
||||
current,
|
||||
previous.get(index),
|
||||
next,
|
||||
EditContext::Other,
|
||||
&mut local,
|
||||
);
|
||||
self.value(current, previous.get(index), next, &mut local);
|
||||
text.push_str(&changed_text(self.source, current.span, local));
|
||||
suffix = &self.source[current.span.end..chunk.end];
|
||||
} else {
|
||||
@@ -1508,11 +1565,7 @@ impl SyntaxUpdate<'_> {
|
||||
} else {
|
||||
text.push(' ');
|
||||
}
|
||||
text.push_str(&inserted_value(
|
||||
&self.after[next.span.start..next.span.end],
|
||||
&indent,
|
||||
self.source,
|
||||
));
|
||||
text.push_str(&inserted_value(self.inserted(next), &indent, self.source));
|
||||
suffix = "";
|
||||
}
|
||||
if i + 1 < after.len() {
|
||||
@@ -1541,58 +1594,14 @@ impl SyntaxUpdate<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
fn scenario_item_key(
|
||||
value: &language::EditValue,
|
||||
aliases: &BTreeMap<String, String>,
|
||||
context: EditContext,
|
||||
) -> Option<String> {
|
||||
use language::EditKind;
|
||||
let EditKind::Record(fields) = &value.kind else {
|
||||
return None;
|
||||
};
|
||||
let reference = |value: &language::EditValue| {
|
||||
if let EditKind::Reference(alias) = &value.kind {
|
||||
aliases.get(alias).cloned()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
};
|
||||
match context {
|
||||
EditContext::Lanes => {
|
||||
reference(&fields.get("lifeline")?.value).map(|id| format!("scenario-lane:{id}"))
|
||||
}
|
||||
EditContext::Messages => {
|
||||
let EditKind::Record(address) = &fields.get("address")?.value.kind else {
|
||||
return None;
|
||||
};
|
||||
let occurrence = reference(&address.get("occurrence")?.value)?;
|
||||
let path: Vec<_> = match address.get("references") {
|
||||
None => Vec::new(),
|
||||
Some(field) => {
|
||||
let EditKind::List(path) = &field.value.kind else {
|
||||
return None;
|
||||
};
|
||||
path.iter().map(reference).collect::<Option<Vec<_>>>()?
|
||||
}
|
||||
};
|
||||
Some(format!("scenario-message:{}:{occurrence}", path.join("/")))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn item_key(
|
||||
value: &language::EditValue,
|
||||
source: &str,
|
||||
aliases: &BTreeMap<String, String>,
|
||||
legacy_value_paths: bool,
|
||||
origins: Option<&BTreeMap<Id, Id>>,
|
||||
context: EditContext,
|
||||
) -> Option<String> {
|
||||
use language::EditKind;
|
||||
if matches!(context, EditContext::Lanes | EditContext::Messages) {
|
||||
return scenario_item_key(value, aliases, context);
|
||||
}
|
||||
if matches!(value.kind, EditKind::Record(_))
|
||||
&& let Some(key) = language::semantic_row_key(
|
||||
&source[value.span.start..value.span.end],
|
||||
|
||||
@@ -11,7 +11,10 @@ pub(super) fn upgrade(
|
||||
.as_ref()
|
||||
.expect("an explicit source migration report");
|
||||
let project = ¤t.decoded.project;
|
||||
let canonical: BTreeMap<_, _> = language::format_declarations(project).into_iter().collect();
|
||||
let canonical: BTreeMap<_, _> =
|
||||
language::format_declarations(project, language::DeclarationFormat::CompleteForEdit)
|
||||
.into_iter()
|
||||
.collect();
|
||||
let changed: BTreeSet<_> = report
|
||||
.semantic_defaults
|
||||
.iter()
|
||||
@@ -59,6 +62,7 @@ pub(super) fn upgrade(
|
||||
source,
|
||||
before: source,
|
||||
after,
|
||||
insertion: None,
|
||||
old_aliases: &aliases,
|
||||
new_aliases: &aliases,
|
||||
legacy_value_paths: true,
|
||||
@@ -68,11 +72,6 @@ pub(super) fn upgrade(
|
||||
&actual.fields,
|
||||
Some(&actual.fields),
|
||||
&next.fields,
|
||||
if project.diagrams.contains_key(&id) {
|
||||
EditContext::Diagram
|
||||
} else {
|
||||
EditContext::Other
|
||||
},
|
||||
edits.entry(path.clone()).or_default(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -619,7 +619,6 @@ pub fn validate(p: &Project) -> Vec<Issue> {
|
||||
}
|
||||
text(&mut out, d.id, "name", &d.name);
|
||||
text(&mut out, d.id, "description", &d.description);
|
||||
crate::scenario_layout::validate_diagram(p, d, &mut out);
|
||||
let option_issues = if d.projection == ProjectionKind::Traceability {
|
||||
crate::view_options::validate_options_cross_layer(p, &d.view_options)
|
||||
} else {
|
||||
@@ -644,6 +643,20 @@ pub fn validate(p: &Project) -> Vec<Issue> {
|
||||
}
|
||||
let mut routed_edges = BTreeSet::new();
|
||||
for (index, route) in d.routes.iter().enumerate() {
|
||||
for attachment in [route.source_attachment, route.target_attachment]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
if !attachment.offset.is_finite() || !(0.0..=1.0).contains(&attachment.offset) {
|
||||
error(
|
||||
&mut out,
|
||||
d.id,
|
||||
"invalid_attachment_offset",
|
||||
"routes",
|
||||
"Connection attachment offset must be finite and between zero and one.",
|
||||
);
|
||||
}
|
||||
}
|
||||
if !routed_edges.insert(route.edge) {
|
||||
error(
|
||||
&mut out,
|
||||
|
||||
@@ -0,0 +1,385 @@
|
||||
use oa_core::{projection::*, routing::*, *};
|
||||
|
||||
fn pos(x: f32, y: f32) -> Position {
|
||||
Position { x, y }
|
||||
}
|
||||
fn anchor(side: PortAnchorSide, offset: f32) -> EdgeAttachment {
|
||||
EdgeAttachment { side, offset }
|
||||
}
|
||||
fn fixture() -> (Project, Id, Id, Id, Id) {
|
||||
let mut p = Project::empty();
|
||||
let a = p
|
||||
.add_element(Kind::Activity, Layer::Oa, "Report", None)
|
||||
.unwrap();
|
||||
let b = p
|
||||
.add_element(Kind::Activity, Layer::Oa, "Receive", None)
|
||||
.unwrap();
|
||||
let edge = p.relate(RelationKind::Interaction, "Report status", a, b);
|
||||
let d = p.ensure_diagram(Layer::Oa);
|
||||
p.place(d, a, pos(50., 50.));
|
||||
p.place(d, b, pos(750., 200.));
|
||||
(p, d, a, b, edge)
|
||||
}
|
||||
fn stored(edge: Id) -> EdgeRoute {
|
||||
EdgeRoute {
|
||||
edge,
|
||||
source_attachment: None,
|
||||
target_attachment: None,
|
||||
waypoints: vec![],
|
||||
label: None,
|
||||
}
|
||||
}
|
||||
fn routed(p: &Project, d: Id) -> RoutedView {
|
||||
let diagram = &p.diagrams[&d];
|
||||
route_diagram(p, diagram, &project_diagram(p, diagram))
|
||||
}
|
||||
fn near(a: f32, b: f32) {
|
||||
assert!((a - b).abs() < 0.002, "{a} != {b}");
|
||||
}
|
||||
fn orthogonal(points: &[Position]) {
|
||||
assert!(points.len() >= 2);
|
||||
for span in points.windows(2) {
|
||||
assert!(span[0].x == span[1].x || span[0].y == span[1].y, "{span:?}");
|
||||
}
|
||||
}
|
||||
fn length(points: &[Position]) -> f32 {
|
||||
points
|
||||
.windows(2)
|
||||
.map(|p| (p[0].x - p[1].x).abs() + (p[0].y - p[1].y).abs())
|
||||
.sum()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authored_boundary_follows_projected_owner_and_preview_matches_committed_geometry() {
|
||||
let (mut p, d, a, _, edge) = fixture();
|
||||
let initial = routed(&p, d);
|
||||
let mut route = stored(edge);
|
||||
route.source_attachment = Some(anchor(PortAnchorSide::Bottom, 0.25));
|
||||
route.target_attachment = Some(anchor(PortAnchorSide::Top, 0.75));
|
||||
route.waypoints = vec![pos(460., 350.)];
|
||||
p.diagrams.get_mut(&d).unwrap().routes.push(route.clone());
|
||||
let view = project_diagram(&p, &p.diagrams[&d]);
|
||||
let preview = route_edge_preview(&p, &p.diagrams[&d], &view, &initial, edge);
|
||||
let full = routed(&p, d);
|
||||
assert_eq!(
|
||||
preview, full,
|
||||
"attachment-only changes must invalidate the label-only path cache"
|
||||
);
|
||||
let n = view.nodes.iter().find(|n| n.element == a).unwrap();
|
||||
assert_eq!(
|
||||
full.edges[&edge].points[0],
|
||||
pos(
|
||||
n.position.x + n.size.width * 0.25,
|
||||
n.position.y + n.size.height
|
||||
)
|
||||
);
|
||||
assert_eq!(full.edges[&edge].pins, route.waypoints);
|
||||
orthogonal(&full.edges[&edge].points);
|
||||
let node = p
|
||||
.diagrams
|
||||
.get_mut(&d)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.values_mut()
|
||||
.find(|n| n.element == a)
|
||||
.unwrap();
|
||||
node.position.x += 75.;
|
||||
node.size.width += 100.;
|
||||
node.size.height += 50.;
|
||||
let moved = routed(&p, d);
|
||||
near(
|
||||
moved.edges[&edge].points[0].x - full.edges[&edge].points[0].x,
|
||||
100.,
|
||||
);
|
||||
near(
|
||||
moved.edges[&edge].points[0].y - full.edges[&edge].points[0].y,
|
||||
50.,
|
||||
);
|
||||
assert_eq!(p.diagrams[&d].routes[0], route);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attachment_only_preview_cannot_reuse_old_endpoints_with_unchanged_pins() {
|
||||
let (mut p, d, _, _, edge) = fixture();
|
||||
let baseline = routed(&p, d);
|
||||
let mut route = stored(edge);
|
||||
route.source_attachment = Some(anchor(PortAnchorSide::Top, 0.2));
|
||||
p.diagrams.get_mut(&d).unwrap().routes.push(route);
|
||||
let view = project_diagram(&p, &p.diagrams[&d]);
|
||||
let preview = route_edge_preview(&p, &p.diagrams[&d], &view, &baseline, edge);
|
||||
assert_ne!(preview.edges[&edge].points, baseline.edges[&edge].points);
|
||||
assert_eq!(preview, routed(&p, d));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ports_remain_authoritative_even_when_projection_filters_the_port() {
|
||||
let mut p = Project::empty();
|
||||
let a = p
|
||||
.add_element(Kind::Function, Layer::Sa, "Send", None)
|
||||
.unwrap();
|
||||
let b = p
|
||||
.add_element(Kind::Function, Layer::Sa, "Receive", None)
|
||||
.unwrap();
|
||||
let edge = p.relate(RelationKind::FunctionalExchange, "Data", a, b);
|
||||
let d = p.ensure_diagram(Layer::Sa);
|
||||
p.place(d, a, pos(0., 0.));
|
||||
p.place(d, b, pos(600., 0.));
|
||||
let diagram = p.diagrams[&d].clone();
|
||||
let view = project_diagram(&p, &diagram);
|
||||
let expected = route_diagram(&p, &diagram, &view);
|
||||
assert!(semantic_port_endpoint(&p, edge, EdgeEnd::Source).is_some());
|
||||
let mut route = stored(edge);
|
||||
route.source_attachment = Some(anchor(PortAnchorSide::Bottom, 0.1));
|
||||
route.target_attachment = Some(anchor(PortAnchorSide::Top, 0.9));
|
||||
p.diagrams.get_mut(&d).unwrap().routes.push(route);
|
||||
assert_eq!(routed(&p, d), expected);
|
||||
let mut hidden = view.clone();
|
||||
hidden.ports.clear();
|
||||
for e in &mut hidden.edges {
|
||||
e.source_port = None;
|
||||
e.target_port = None;
|
||||
}
|
||||
let absent = route_diagram(&p, &diagram, &hidden);
|
||||
assert_eq!(
|
||||
route_diagram(&p, &p.diagrams[&d], &hidden),
|
||||
absent,
|
||||
"hidden ports must not activate a dormant node preference"
|
||||
);
|
||||
}
|
||||
|
||||
fn state(kind: Kind) -> (Project, Id, Id, Id) {
|
||||
let mut p = Project::empty();
|
||||
let owner = p
|
||||
.add_element(Kind::Entity, Layer::Oa, "Crew", None)
|
||||
.unwrap();
|
||||
let machine = p
|
||||
.add_element(Kind::StateMachine, Layer::Oa, "Modes", Some(owner))
|
||||
.unwrap();
|
||||
let region = p
|
||||
.add_element(Kind::Region, Layer::Oa, "Main", Some(machine))
|
||||
.unwrap();
|
||||
let a = p.add_element(kind, Layer::Oa, "A", Some(region)).unwrap();
|
||||
let b = p
|
||||
.add_element(Kind::State, Layer::Oa, "B", Some(region))
|
||||
.unwrap();
|
||||
let edge = p.relate(RelationKind::StateTransition, "Next", a, b);
|
||||
let d = p.ensure_diagram(Layer::Oa);
|
||||
p.diagrams.get_mut(&d).unwrap().projection = ProjectionKind::StateMachine { machine };
|
||||
p.place(d, a, pos(100., 100.));
|
||||
p.place(d, b, pos(600., 100.));
|
||||
(p, d, a, edge)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nonrectangular_state_attachments_intersect_the_painted_shape() {
|
||||
for kind in [
|
||||
Kind::Initial,
|
||||
Kind::Junction,
|
||||
Kind::Final,
|
||||
Kind::Choice,
|
||||
Kind::ShallowHistory,
|
||||
Kind::DeepHistory,
|
||||
Kind::EntryPoint,
|
||||
Kind::ExitPoint,
|
||||
Kind::Terminate,
|
||||
Kind::Fork,
|
||||
Kind::Join,
|
||||
Kind::State,
|
||||
] {
|
||||
let (p, d, a, _) = state(kind);
|
||||
let diagram = &p.diagrams[&d];
|
||||
let view = project_diagram(&p, diagram);
|
||||
let n = view.nodes.iter().find(|n| n.element == a).unwrap();
|
||||
for side in [
|
||||
PortAnchorSide::Left,
|
||||
PortAnchorSide::Right,
|
||||
PortAnchorSide::Top,
|
||||
PortAnchorSide::Bottom,
|
||||
] {
|
||||
for offset in [0., 0.2, 0.5, 0.8, 1.] {
|
||||
let at = attachment_point(&p, diagram, n, &anchor(side, offset));
|
||||
let x = (at.x - n.position.x) / n.size.width;
|
||||
let y = (at.y - n.position.y) / n.size.height;
|
||||
match kind {
|
||||
Kind::Choice => near((2. * x - 1.).abs() + (2. * y - 1.).abs(), 1.),
|
||||
Kind::Terminate => near((x - y).abs().min((x + y - 1.).abs()), 0.),
|
||||
Kind::Fork | Kind::Join => near(x.min(1. - x).min(y.min(1. - y)), 0.),
|
||||
Kind::State => {
|
||||
assert!((0. ..=1.).contains(&x) && (0. ..=1.).contains(&y));
|
||||
if offset == 0. || offset == 1. {
|
||||
assert!(
|
||||
x != 0. && x != 1. || y != 0. && y != 1.,
|
||||
"rounded corner cannot use bounding-box corner"
|
||||
);
|
||||
}
|
||||
}
|
||||
_ => near((2. * x - 1.).powi(2) + (2. * y - 1.).powi(2), 1.),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inset_contour_leads_keep_pin_fractions_and_labels_on_the_complete_path() {
|
||||
let (mut p, d, _, edge) = state(Kind::Choice);
|
||||
let mut route = stored(edge);
|
||||
route.source_attachment = Some(anchor(PortAnchorSide::Right, 0.));
|
||||
route.waypoints = vec![pos(380., 40.)];
|
||||
route.label = Some(EdgeLabel {
|
||||
fraction: 0.3,
|
||||
offset: pos(0., 0.),
|
||||
});
|
||||
p.diagrams.get_mut(&d).unwrap().routes.push(route);
|
||||
let result = routed(&p, d);
|
||||
let r = &result.edges[&edge];
|
||||
orthogonal(&r.points);
|
||||
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
|
||||
let pin = r.pins[0];
|
||||
let pin_index = r.points.iter().position(|p| *p == pin).unwrap();
|
||||
near(
|
||||
r.pin_fractions[0],
|
||||
length(&r.points[..=pin_index]) / length(&r.points),
|
||||
);
|
||||
assert_eq!(
|
||||
r.label.anchor,
|
||||
point_along_polyline(&r.points, 0.3).unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn small_symbol_drag_chooses_the_intended_exit_side_at_low_zoom() {
|
||||
for kind in [
|
||||
Kind::Initial,
|
||||
Kind::Choice,
|
||||
Kind::Terminate,
|
||||
Kind::Fork,
|
||||
Kind::State,
|
||||
] {
|
||||
let (p, d, a, _) = state(kind);
|
||||
let view = project_diagram(&p, &p.diagrams[&d]);
|
||||
let n = view.nodes.iter().find(|n| n.element == a).unwrap();
|
||||
for zoom in [0.5, 1., 2.] {
|
||||
for overshoot in [0., 20.] {
|
||||
let cursor = pos(n.position.x + n.size.width / 2., n.position.y - overshoot);
|
||||
let chosen =
|
||||
attachment_from_point(n, cursor, Some(PortAnchorSide::Right), 6. / zoom);
|
||||
assert_eq!(
|
||||
chosen,
|
||||
anchor(PortAnchorSide::Top, 0.5),
|
||||
"{kind:?}, zoom {zoom}, overshoot {overshoot}"
|
||||
);
|
||||
let painted = attachment_point(&p, &p.diagrams[&d], n, &chosen);
|
||||
near(painted.x, n.position.x + n.size.width / 2.);
|
||||
near(
|
||||
painted.y,
|
||||
n.position.y
|
||||
+ if kind == Kind::Terminate {
|
||||
n.size.height / 2.
|
||||
} else {
|
||||
0.
|
||||
},
|
||||
);
|
||||
}
|
||||
let near_corner = pos(n.position.x + n.size.width - 1., n.position.y + 1.);
|
||||
let stable =
|
||||
attachment_from_point(n, near_corner, Some(PortAnchorSide::Right), 6. / zoom);
|
||||
assert_eq!(
|
||||
stable.side,
|
||||
PortAnchorSide::Right,
|
||||
"corner hysteresis stays stable"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coincident_self_attachments_retain_hard_pins_and_form_a_visible_loop() {
|
||||
let (mut p, d, a, edge) = state(Kind::State);
|
||||
if let RelationBody::StateTransition { target, .. } =
|
||||
&mut p.relations.get_mut(&edge).unwrap().body
|
||||
{
|
||||
*target = a;
|
||||
}
|
||||
for pins in [vec![], vec![pos(400., 30.)]] {
|
||||
let mut route = stored(edge);
|
||||
route.source_attachment = Some(anchor(PortAnchorSide::Top, 0.5));
|
||||
route.target_attachment = route.source_attachment;
|
||||
route.waypoints = pins.clone();
|
||||
p.diagrams.get_mut(&d).unwrap().routes = vec![route.clone()];
|
||||
let result = routed(&p, d);
|
||||
let r = &result.edges[&edge];
|
||||
orthogonal(&r.points);
|
||||
assert_eq!(r.points.first(), r.points.last());
|
||||
assert!(length(&r.points) > 2. * PORT_STUB + 70.);
|
||||
assert_eq!(r.pins, pins);
|
||||
assert_eq!(r.pin_fractions.len(), pins.len());
|
||||
assert!(
|
||||
r.warnings.contains(&RouteWarning::CoincidentAttachments),
|
||||
"{:?}",
|
||||
r.warnings
|
||||
);
|
||||
assert_eq!(routed(&p, d), result, "deterministic fallback");
|
||||
assert_eq!(p.diagrams[&d].routes[0], route);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coincident_loop_chooses_clear_side_instead_of_inventing_an_obstructed_pin() {
|
||||
for blocker_side in [-1., 1.] {
|
||||
let (mut p, d, a, edge) = state(Kind::State);
|
||||
if let RelationBody::StateTransition { target, .. } =
|
||||
&mut p.relations.get_mut(&edge).unwrap().body
|
||||
{
|
||||
*target = a;
|
||||
}
|
||||
let mut route = stored(edge);
|
||||
route.source_attachment = Some(anchor(PortAnchorSide::Top, 0.5));
|
||||
route.target_attachment = route.source_attachment;
|
||||
p.diagrams.get_mut(&d).unwrap().routes.push(route);
|
||||
let view = project_diagram(&p, &p.diagrams[&d]);
|
||||
let source = view.nodes.iter().find(|n| n.element == a).unwrap();
|
||||
let tip = attachment_point(
|
||||
&p,
|
||||
&p.diagrams[&d],
|
||||
source,
|
||||
&anchor(PortAnchorSide::Top, 0.5),
|
||||
);
|
||||
let region = p.elements[&a].parent;
|
||||
let blocker = p
|
||||
.add_element(Kind::State, Layer::Oa, "Detour obstruction", region)
|
||||
.unwrap();
|
||||
let left = if blocker_side > 0. {
|
||||
tip.x + 30.
|
||||
} else {
|
||||
tip.x - 30. - 224.
|
||||
};
|
||||
p.place(d, blocker, pos(left, tip.y - 144.));
|
||||
let result = routed(&p, d);
|
||||
let r = &result.edges[&edge];
|
||||
assert_eq!(r.warnings, vec![RouteWarning::CoincidentAttachments]);
|
||||
orthogonal(&r.points);
|
||||
let view = project_diagram(&p, &p.diagrams[&d]);
|
||||
let n = view.nodes.iter().find(|n| n.element == blocker).unwrap();
|
||||
for segment in r.points.windows(2) {
|
||||
for step in 0..=100 {
|
||||
let t = step as f32 / 100.;
|
||||
let q = pos(
|
||||
segment[0].x + (segment[1].x - segment[0].x) * t,
|
||||
segment[0].y + (segment[1].y - segment[0].y) * t,
|
||||
);
|
||||
assert!(
|
||||
!(q.x > n.position.x - CLEARANCE
|
||||
&& q.x < n.position.x + n.size.width + CLEARANCE
|
||||
&& q.y > n.position.y - CLEARANCE
|
||||
&& q.y < n.position.y + n.size.height + CLEARANCE),
|
||||
"route crosses blocker: {q:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
r.pins.is_empty(),
|
||||
"generated detours must never become authored pins"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
use oa_core::{language, projection::*, routing::*, *};
|
||||
|
||||
fn pos(x: f32, y: f32) -> Position {
|
||||
Position { x, y }
|
||||
}
|
||||
|
||||
fn id(project: &Project, alias: &str) -> Id {
|
||||
project
|
||||
.identities()
|
||||
.into_iter()
|
||||
.find(|(_, a)| *a == alias)
|
||||
.unwrap()
|
||||
.0
|
||||
}
|
||||
|
||||
fn authored(edge: Id) -> EdgeRoute {
|
||||
EdgeRoute {
|
||||
edge,
|
||||
source_attachment: Some(EdgeAttachment {
|
||||
side: PortAnchorSide::Bottom,
|
||||
offset: 0.2,
|
||||
}),
|
||||
target_attachment: Some(EdgeAttachment {
|
||||
side: PortAnchorSide::Top,
|
||||
offset: 0.8,
|
||||
}),
|
||||
waypoints: vec![pos(520., 420.)],
|
||||
label: Some(EdgeLabel {
|
||||
fraction: 0.35,
|
||||
offset: pos(17., -23.),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn routed(project: &Project, diagram: Id) -> RoutedView {
|
||||
let d = &project.diagrams[&diagram];
|
||||
route_diagram(project, d, &project_diagram(project, d))
|
||||
}
|
||||
|
||||
fn fixture() -> (Project, Id, Id, Id, Id, Id, Id) {
|
||||
let mut p = language::parse(
|
||||
r#"arcadia 7;
|
||||
project lifecycle "Attachment lifecycle" {
|
||||
oa_activity group "Group" { oa_activity first "First" {} }
|
||||
oa_activity second "Second" {}
|
||||
oa_activity replacement "Replacement" {}
|
||||
interaction signal "Signal" { source @first; target @second; }
|
||||
diagram view "View" {}
|
||||
diagram other "Other" {}
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let (d, other, group, first, second, replacement, edge) = (
|
||||
id(&p, "view"),
|
||||
id(&p, "other"),
|
||||
id(&p, "group"),
|
||||
id(&p, "first"),
|
||||
id(&p, "second"),
|
||||
id(&p, "replacement"),
|
||||
id(&p, "signal"),
|
||||
);
|
||||
for diagram in [d, other] {
|
||||
p.place(diagram, group, pos(20., 20.));
|
||||
p.place(diagram, first, pos(100., 130.));
|
||||
p.place(diagram, second, pos(750., 150.));
|
||||
p.place(diagram, replacement, pos(750., 570.));
|
||||
p.diagrams
|
||||
.get_mut(&diagram)
|
||||
.unwrap()
|
||||
.routes
|
||||
.push(authored(edge));
|
||||
}
|
||||
(p, d, other, group, first, replacement, edge)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attachment_fold_promotion_suspends_the_complete_route_and_expand_restores_it() {
|
||||
let (mut p, d, other, group, _, _, edge) = fixture();
|
||||
assert_eq!(semantic_port_endpoint(&p, edge, EdgeEnd::Source), None);
|
||||
assert_eq!(semantic_port_endpoint(&p, edge, EdgeEnd::Target), None);
|
||||
let before = p.clone();
|
||||
let expanded = routed(&p, d);
|
||||
let other_geometry = routed(&p, other);
|
||||
let parent = p.diagrams[&d]
|
||||
.nodes
|
||||
.values()
|
||||
.find(|n| n.element == group)
|
||||
.unwrap()
|
||||
.id;
|
||||
p.diagrams
|
||||
.get_mut(&d)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&parent)
|
||||
.unwrap()
|
||||
.collapsed = true;
|
||||
let folded = routed(&p, d);
|
||||
assert!(folded.edges[&edge].custom_suspended);
|
||||
assert_eq!(folded.edges[&edge].pins, []);
|
||||
assert!(
|
||||
folded.edges[&edge]
|
||||
.warnings
|
||||
.contains(&RouteWarning::PromotedEndpoints)
|
||||
);
|
||||
assert_eq!(p.diagrams[&d].routes, before.diagrams[&d].routes);
|
||||
let mut automatic = p.clone();
|
||||
automatic.diagrams.get_mut(&d).unwrap().routes.clear();
|
||||
assert_eq!(
|
||||
folded,
|
||||
routed(&automatic, d),
|
||||
"attachments, pins and label must all be dormant on promotion"
|
||||
);
|
||||
assert_eq!(p.diagrams[&other], before.diagrams[&other]);
|
||||
assert_eq!(routed(&p, other), other_geometry);
|
||||
p.diagrams
|
||||
.get_mut(&d)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&parent)
|
||||
.unwrap()
|
||||
.collapsed = false;
|
||||
assert_eq!(p, before);
|
||||
assert_eq!(routed(&p, d), expanded);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attachment_reconnect_retains_slot_preferences_and_other_view_records_with_exact_history() {
|
||||
let (p, d, other, _, _, replacement, edge) = fixture();
|
||||
let before = routed(&p, d);
|
||||
let mut relation = p.relations[&edge].clone();
|
||||
let RelationBody::Interaction { target, .. } = &mut relation.body else {
|
||||
unreachable!()
|
||||
};
|
||||
*target = replacement;
|
||||
let mut expected = p.clone();
|
||||
expected.relations.insert(edge, relation.clone());
|
||||
let mut session = Session::new(p.clone());
|
||||
session
|
||||
.execute(Command::EditRelation(edge, relation))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
session.project(),
|
||||
&expected,
|
||||
"reconnect changes only the semantic endpoint; all per-view records remain exact"
|
||||
);
|
||||
assert_eq!(session.project().diagrams[&other], p.diagrams[&other]);
|
||||
for diagram in [d, other] {
|
||||
let current = session.project();
|
||||
let view = project_diagram(current, ¤t.diagrams[&diagram]);
|
||||
let node = view
|
||||
.nodes
|
||||
.iter()
|
||||
.find(|n| n.element == replacement)
|
||||
.unwrap();
|
||||
let preference = current.diagrams[&diagram].routes[0]
|
||||
.target_attachment
|
||||
.unwrap();
|
||||
let expected_tip =
|
||||
attachment_point(current, ¤t.diagrams[&diagram], node, &preference);
|
||||
let result = routed(current, diagram);
|
||||
assert_eq!(result.edges[&edge].points.last(), Some(&expected_tip));
|
||||
assert_eq!(
|
||||
result.edges[&edge].pins,
|
||||
p.diagrams[&diagram].routes[0].waypoints
|
||||
);
|
||||
}
|
||||
assert_ne!(
|
||||
routed(session.project(), d).edges[&edge].points.last(),
|
||||
before.edges[&edge].points.last()
|
||||
);
|
||||
session.undo();
|
||||
assert_eq!(session.project(), &p);
|
||||
assert_eq!(routed(session.project(), d), before);
|
||||
session.redo();
|
||||
assert_eq!(session.project(), &expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attachment_direct_port_endpoints_remain_authoritative_when_their_glyphs_are_filtered() {
|
||||
let mut p = Project::empty();
|
||||
let system = p
|
||||
.add_element(Kind::System, Layer::Sa, "System", None)
|
||||
.unwrap();
|
||||
let actor = p
|
||||
.add_element(Kind::SystemActor, Layer::Sa, "Actor", None)
|
||||
.unwrap();
|
||||
let function = p
|
||||
.add_element(Kind::Function, Layer::Sa, "Send", None)
|
||||
.unwrap();
|
||||
let mut ports = Vec::new();
|
||||
for (owner, name, contract) in [
|
||||
(
|
||||
system,
|
||||
"Output",
|
||||
PortContract::Component {
|
||||
kind: ComponentPortKind::Flow,
|
||||
orientation: ComponentPortOrientation::Out,
|
||||
},
|
||||
),
|
||||
(
|
||||
actor,
|
||||
"Input",
|
||||
PortContract::Component {
|
||||
kind: ComponentPortKind::Flow,
|
||||
orientation: ComponentPortOrientation::In,
|
||||
},
|
||||
),
|
||||
(
|
||||
function,
|
||||
"Function output",
|
||||
PortContract::FunctionOutput {
|
||||
outgoing_items: vec![],
|
||||
},
|
||||
),
|
||||
] {
|
||||
let port = Id::new_v4();
|
||||
p.elements
|
||||
.insert(port, make_port(&p, port, owner, name, contract).unwrap());
|
||||
ports.push(port);
|
||||
}
|
||||
p.relate(
|
||||
RelationKind::Allocation,
|
||||
"Function allocation",
|
||||
system,
|
||||
function,
|
||||
);
|
||||
let boundary = p.relate(
|
||||
RelationKind::BoundaryExchange,
|
||||
"Boundary",
|
||||
ports[0],
|
||||
ports[1],
|
||||
);
|
||||
let allocation = p.relate(
|
||||
RelationKind::PortAllocation,
|
||||
"Port allocation",
|
||||
ports[0],
|
||||
ports[2],
|
||||
);
|
||||
let d = p.ensure_diagram(Layer::Sa);
|
||||
p.place(d, system, pos(0., 0.));
|
||||
p.place(d, actor, pos(700., 0.));
|
||||
p.place(d, function, pos(0., 450.));
|
||||
let original = p.clone();
|
||||
let baseline = routed(&p, d);
|
||||
for (edge, target) in [(boundary, ports[1]), (allocation, ports[2])] {
|
||||
assert!(baseline.edges.contains_key(&edge));
|
||||
assert_eq!(
|
||||
semantic_port_endpoint(&p, edge, EdgeEnd::Source),
|
||||
Some(ports[0])
|
||||
);
|
||||
assert_eq!(
|
||||
semantic_port_endpoint(&p, edge, EdgeEnd::Target),
|
||||
Some(target)
|
||||
);
|
||||
let mut route = authored(edge);
|
||||
route.waypoints.clear();
|
||||
route.label = None;
|
||||
p.diagrams.get_mut(&d).unwrap().routes.push(route);
|
||||
}
|
||||
assert_eq!(routed(&p, d), baseline);
|
||||
let mut hidden = project_diagram(&p, &p.diagrams[&d]);
|
||||
hidden.ports.clear();
|
||||
for edge in &mut hidden.edges {
|
||||
edge.source_port = None;
|
||||
edge.target_port = None;
|
||||
}
|
||||
assert_eq!(
|
||||
route_diagram(&p, &p.diagrams[&d], &hidden),
|
||||
route_diagram(&original, &original.diagrams[&d], &hidden),
|
||||
"filtered direct Port endpoints must not activate their stored preferences"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
use oa_core::{projection::*, routing::*, *};
|
||||
|
||||
#[test]
|
||||
fn valid_item_trace_follows_manual_port_anchors_and_ignores_dormant_attachments() {
|
||||
let mut project = Project::empty();
|
||||
let oa = project
|
||||
.add_element(Kind::Activity, Layer::Oa, "Send", None)
|
||||
.unwrap();
|
||||
let sa = project
|
||||
.add_element(Kind::Function, Layer::Sa, "Receive", None)
|
||||
.unwrap();
|
||||
let ports = [(oa, Layer::Oa), (sa, Layer::Sa)].map(|(owner, layer)| {
|
||||
let id = Id::new_v4();
|
||||
project.elements.insert(
|
||||
id,
|
||||
Element {
|
||||
id,
|
||||
alias: format!("port_{}", id.simple()),
|
||||
name: "Status port".into(),
|
||||
description: "Existing legacy port retained by Item trace".into(),
|
||||
parent: Some(owner),
|
||||
body: ElementBody::shared_in(
|
||||
layer,
|
||||
SharedBody::Port {
|
||||
contract: PortContract::Legacy,
|
||||
legacy: Some(LegacyPortContract {
|
||||
direction: PortDirection::InOut,
|
||||
interface: None,
|
||||
}),
|
||||
},
|
||||
),
|
||||
},
|
||||
);
|
||||
id
|
||||
});
|
||||
let trace = Trace {
|
||||
id: Id::new_v4(),
|
||||
alias: "status_correspondence".into(),
|
||||
name: "Status correspondence".into(),
|
||||
description: "Preserve this semantic evidence".into(),
|
||||
oa: ports[0],
|
||||
sa: ports[1],
|
||||
kind: TraceKind::Item,
|
||||
baseline: None,
|
||||
};
|
||||
project.traces.insert(trace.id, trace.clone());
|
||||
let diagram = project.ensure_diagram(Layer::Oa);
|
||||
project.diagrams.get_mut(&diagram).unwrap().projection = ProjectionKind::Traceability;
|
||||
let owners = [
|
||||
project.place(diagram, oa, Position { x: 100., y: 100. }),
|
||||
project.place(diagram, sa, Position { x: 800., y: 300. }),
|
||||
];
|
||||
validate_candidate(&project).unwrap();
|
||||
let semantic_elements = project.elements.clone();
|
||||
let semantic_relations = project.relations.clone();
|
||||
let semantic_traces = project.traces.clone();
|
||||
let view = project_diagram(&project, &project.diagrams[&diagram]);
|
||||
let edge = view.edges.iter().find(|edge| edge.id == trace.id).unwrap();
|
||||
assert!(edge.trace);
|
||||
assert_eq!((edge.source, edge.target), (owners[0], owners[1]));
|
||||
assert_eq!((edge.source_port, edge.target_port), (None, None));
|
||||
let initial = route_diagram(&project, &project.diagrams[&diagram], &view);
|
||||
for (index, (side, offset)) in [(PortAnchorSide::Top, 0.25), (PortAnchorSide::Bottom, 0.75)]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
project
|
||||
.diagrams
|
||||
.get_mut(&diagram)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&owners[index])
|
||||
.unwrap()
|
||||
.port_anchors
|
||||
.push(PortAnchor {
|
||||
port: ports[index],
|
||||
side,
|
||||
offset,
|
||||
});
|
||||
}
|
||||
validate_candidate(&project).unwrap();
|
||||
let moved_view = project_diagram(&project, &project.diagrams[&diagram]);
|
||||
let moved = route_diagram(&project, &project.diagrams[&diagram], &moved_view);
|
||||
for index in 0..2 {
|
||||
let marker = moved_view
|
||||
.ports
|
||||
.iter()
|
||||
.find(|port| port.element == ports[index] && port.owner == owners[index])
|
||||
.unwrap();
|
||||
let points = &moved.edges[&trace.id].points;
|
||||
let tip = if index == 0 {
|
||||
points[0]
|
||||
} else {
|
||||
*points.last().unwrap()
|
||||
};
|
||||
assert_eq!(tip, marker.position);
|
||||
let old = &initial.edges[&trace.id].points;
|
||||
assert_ne!(
|
||||
tip,
|
||||
if index == 0 {
|
||||
old[0]
|
||||
} else {
|
||||
*old.last().unwrap()
|
||||
}
|
||||
);
|
||||
}
|
||||
project
|
||||
.diagrams
|
||||
.get_mut(&diagram)
|
||||
.unwrap()
|
||||
.routes
|
||||
.push(EdgeRoute {
|
||||
edge: trace.id,
|
||||
waypoints: vec![],
|
||||
label: None,
|
||||
source_attachment: Some(EdgeAttachment {
|
||||
side: PortAnchorSide::Bottom,
|
||||
offset: 0.9,
|
||||
}),
|
||||
target_attachment: Some(EdgeAttachment {
|
||||
side: PortAnchorSide::Top,
|
||||
offset: 0.1,
|
||||
}),
|
||||
});
|
||||
validate_candidate(&project).unwrap();
|
||||
let dormant_view = project_diagram(&project, &project.diagrams[&diagram]);
|
||||
assert_eq!(dormant_view, moved_view);
|
||||
assert_eq!(
|
||||
route_diagram(&project, &project.diagrams[&diagram], &dormant_view),
|
||||
moved
|
||||
);
|
||||
assert_eq!(project.elements, semantic_elements);
|
||||
assert_eq!(project.relations, semantic_relations);
|
||||
assert_eq!(project.traces, semantic_traces);
|
||||
assert_eq!(project.traces[&trace.id], trace);
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
//! Route the exact native acceptance fixture and intended saved checkpoints.
|
||||
use oa_core::{projection::project_diagram, routing::route_diagram, *};
|
||||
|
||||
const FIXTURE: &str =
|
||||
include_str!("../../../harness/experiments/edge-attachments-20260922/native-fixture.arcadia");
|
||||
|
||||
fn id(project: &Project, alias: &str) -> Id {
|
||||
project
|
||||
.identities()
|
||||
.into_iter()
|
||||
.find(|(_, name)| *name == alias)
|
||||
.unwrap()
|
||||
.0
|
||||
}
|
||||
|
||||
fn set(
|
||||
project: &mut Project,
|
||||
view: &str,
|
||||
edge: &str,
|
||||
end: EdgeEnd,
|
||||
attachment: Option<(PortAnchorSide, f32)>,
|
||||
) {
|
||||
let view = id(project, view);
|
||||
let edge = id(project, edge);
|
||||
let route = project
|
||||
.diagrams
|
||||
.get_mut(&view)
|
||||
.unwrap()
|
||||
.routes
|
||||
.iter_mut()
|
||||
.find(|route| route.edge == edge)
|
||||
.unwrap();
|
||||
route.set_attachment(
|
||||
end,
|
||||
attachment.map(|(side, offset)| EdgeAttachment { side, offset }),
|
||||
);
|
||||
}
|
||||
|
||||
fn clear(project: &Project, checkpoint: &str) {
|
||||
validate_candidate(project).unwrap();
|
||||
for (view, edges) in [
|
||||
("dispatch_view", &["dispatch_request"][..]),
|
||||
("readiness_view", &["activate", "initialize", "finish"][..]),
|
||||
] {
|
||||
let diagram = &project.diagrams[&id(project, view)];
|
||||
let routed = route_diagram(project, diagram, &project_diagram(project, diagram));
|
||||
for alias in edges {
|
||||
let edge = &routed.edges[&id(project, alias)];
|
||||
assert!(
|
||||
edge.warnings.is_empty(),
|
||||
"{checkpoint}/{view}/{alias}: {:?}",
|
||||
edge.warnings
|
||||
);
|
||||
let stored = diagram
|
||||
.routes
|
||||
.iter()
|
||||
.find(|r| r.edge == id(project, alias))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
edge.pins, stored.waypoints,
|
||||
"{checkpoint}/{alias}: manual pins retained"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_workshop_baseline_and_planned_attachments_have_clear_routes() {
|
||||
use EdgeEnd::{Source, Target};
|
||||
use PortAnchorSide::{Bottom, Top};
|
||||
let mut project = language::parse(FIXTURE).unwrap();
|
||||
let baseline = project.clone();
|
||||
clear(&project, "B");
|
||||
set(
|
||||
&mut project,
|
||||
"dispatch_view",
|
||||
"dispatch_request",
|
||||
Source,
|
||||
Some((Top, 0.25)),
|
||||
);
|
||||
clear(&project, "O1");
|
||||
set(
|
||||
&mut project,
|
||||
"dispatch_view",
|
||||
"dispatch_request",
|
||||
Target,
|
||||
Some((Bottom, 0.75)),
|
||||
);
|
||||
clear(&project, "O2");
|
||||
set(
|
||||
&mut project,
|
||||
"dispatch_view",
|
||||
"dispatch_request",
|
||||
Source,
|
||||
None,
|
||||
);
|
||||
clear(&project, "O3");
|
||||
set(
|
||||
&mut project,
|
||||
"readiness_view",
|
||||
"activate",
|
||||
Source,
|
||||
Some((Top, 0.25)),
|
||||
);
|
||||
clear(&project, "S1");
|
||||
set(
|
||||
&mut project,
|
||||
"readiness_view",
|
||||
"activate",
|
||||
Target,
|
||||
Some((Bottom, 0.75)),
|
||||
);
|
||||
clear(&project, "S2");
|
||||
set(&mut project, "readiness_view", "activate", Target, None);
|
||||
clear(&project, "S3");
|
||||
set(
|
||||
&mut project,
|
||||
"readiness_view",
|
||||
"initialize",
|
||||
Source,
|
||||
Some((Top, 0.5)),
|
||||
);
|
||||
clear(&project, "S4");
|
||||
set(
|
||||
&mut project,
|
||||
"readiness_view",
|
||||
"finish",
|
||||
Source,
|
||||
Some((Top, 0.5)),
|
||||
);
|
||||
clear(&project, "S5");
|
||||
assert_eq!(project.elements, baseline.elements);
|
||||
assert_eq!(project.relations, baseline.relations);
|
||||
for alias in ["dispatch_reference", "readiness_reference", "ports_view"] {
|
||||
let view = id(&project, alias);
|
||||
assert_eq!(project.diagrams[&view], baseline.diagrams[&view]);
|
||||
}
|
||||
for alias in ["dispatch_view", "readiness_view"] {
|
||||
let view = id(&project, alias);
|
||||
assert_eq!(
|
||||
project.diagrams[&view].nodes,
|
||||
baseline.diagrams[&view].nodes
|
||||
);
|
||||
for route in &project.diagrams[&view].routes {
|
||||
let original = baseline.diagrams[&view]
|
||||
.routes
|
||||
.iter()
|
||||
.find(|r| r.edge == route.edge)
|
||||
.unwrap();
|
||||
assert_eq!(route.waypoints, original.waypoints);
|
||||
assert_eq!(route.label, original.label);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
//! Existing source-7 nested omission compatibility, independently of formatting.
|
||||
//!
|
||||
//! Selected constructors use their own field contracts. These handwritten
|
||||
//! sources and literal models distinguish nullable optional slots from required
|
||||
//! fields; they do not prescribe compact canonical output or broaden admission.
|
||||
|
||||
use oa_core::{source_project::*, *};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
fn id(number: u8) -> Id {
|
||||
Id::parse_str(&format!("00000000-0000-4000-8000-{number:012}")).unwrap()
|
||||
}
|
||||
|
||||
fn bundle(declarations: &str) -> SourceBundle {
|
||||
let source = format!(
|
||||
"arcadia 7;\nproject p \"Admission\" id \"00000000-0000-4000-8000-000000000001\" {{\n{declarations}\n}}\n"
|
||||
);
|
||||
SourceBundle {
|
||||
entry: "project.arcadia".into(),
|
||||
files: BTreeMap::from([("project.arcadia".into(), source)]),
|
||||
}
|
||||
}
|
||||
|
||||
fn element(number: u8, alias: &str, name: &str, parent: Option<Id>, body: ElementBody) -> Element {
|
||||
Element {
|
||||
id: id(number),
|
||||
alias: alias.into(),
|
||||
name: name.into(),
|
||||
description: String::new(),
|
||||
parent,
|
||||
body,
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_admitted(input: &SourceBundle, elements: Vec<Element>) -> Project {
|
||||
let expected = Project {
|
||||
schema_version: 7,
|
||||
id: id(1),
|
||||
alias: "p".into(),
|
||||
name: "Admission".into(),
|
||||
description: String::new(),
|
||||
elements: elements.into_iter().map(|e| (e.id, e)).collect(),
|
||||
relations: BTreeMap::new(),
|
||||
diagrams: BTreeMap::new(),
|
||||
artifact_links: BTreeMap::new(),
|
||||
traces: BTreeMap::new(),
|
||||
};
|
||||
let compiled = compile_bundle(input, ProjectLimits::default()).unwrap();
|
||||
assert_eq!(compiled.decoded.project, expected, "complete literal model");
|
||||
assert_eq!(
|
||||
compiled.bundle, *input,
|
||||
"fixed IDs require no source stabilization"
|
||||
);
|
||||
compiled.decoded.project
|
||||
}
|
||||
|
||||
fn prefix(layer: Layer) -> &'static str {
|
||||
match layer {
|
||||
Layer::Oa => "oa",
|
||||
Layer::Sa => "sa",
|
||||
}
|
||||
}
|
||||
|
||||
fn data_bundle(layer: Layer, definition: &str) -> SourceBundle {
|
||||
bundle(&format!(
|
||||
" {}_data_type subject \"Subject\" id \"00000000-0000-4000-8000-000000000002\" {{ definition {{ {definition}; }}; }}",
|
||||
prefix(layer)
|
||||
))
|
||||
}
|
||||
|
||||
fn admit_data(layer: Layer, source: &str, definition: DataTypeDefinition) -> Project {
|
||||
assert_admitted(
|
||||
&data_bundle(layer, source),
|
||||
vec![element(
|
||||
2,
|
||||
"subject",
|
||||
"Subject",
|
||||
None,
|
||||
ElementBody::shared_in(
|
||||
layer,
|
||||
SharedBody::DataType {
|
||||
definition,
|
||||
default: None,
|
||||
unit: String::new(),
|
||||
},
|
||||
),
|
||||
)],
|
||||
)
|
||||
}
|
||||
|
||||
fn assert_missing(input: &SourceBundle, field: &str) {
|
||||
let errors = compile_bundle(input, ProjectLimits::default()).unwrap_err();
|
||||
assert_eq!(
|
||||
errors.len(),
|
||||
1,
|
||||
"expect the targeted admission error: {errors:?}"
|
||||
);
|
||||
let diagnostic = &errors[0].diagnostic;
|
||||
assert_eq!(diagnostic.code, "invalid_source", "{errors:?}");
|
||||
assert!(
|
||||
diagnostic.message.to_lowercase().contains("missing") && diagnostic.message.contains(field),
|
||||
"must reject the missing constructor field {field}, not an unrelated model rule: {errors:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn integer_optional_bounds_can_be_omitted_individually_and_together() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
for (omitted, explicit, minimum, maximum) in [
|
||||
(
|
||||
"Integer {}",
|
||||
"Integer { minimum none; maximum none; }",
|
||||
None,
|
||||
None,
|
||||
),
|
||||
(
|
||||
"Integer { maximum 9; }",
|
||||
"Integer { minimum none; maximum 9; }",
|
||||
None,
|
||||
Some(9),
|
||||
),
|
||||
(
|
||||
"Integer { minimum -3; }",
|
||||
"Integer { minimum -3; maximum none; }",
|
||||
Some(-3),
|
||||
None,
|
||||
),
|
||||
] {
|
||||
let expected = DataTypeDefinition::Integer { minimum, maximum };
|
||||
assert_eq!(
|
||||
admit_data(layer, omitted, expected.clone()),
|
||||
admit_data(layer, explicit, expected),
|
||||
"omitted and explicit optional Integer bounds agree"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn real_optional_bounds_can_be_omitted_individually_and_together() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
for (omitted, explicit, minimum, maximum) in [
|
||||
(
|
||||
"Real {}",
|
||||
"Real { minimum none; maximum none; }",
|
||||
None,
|
||||
None,
|
||||
),
|
||||
(
|
||||
"Real { maximum 2.5; }",
|
||||
"Real { minimum none; maximum 2.5; }",
|
||||
None,
|
||||
Some(2.5),
|
||||
),
|
||||
(
|
||||
"Real { minimum -1.25; }",
|
||||
"Real { minimum -1.25; maximum none; }",
|
||||
Some(-1.25),
|
||||
None,
|
||||
),
|
||||
] {
|
||||
let expected = DataTypeDefinition::Real { minimum, maximum };
|
||||
assert_eq!(
|
||||
admit_data(layer, omitted, expected.clone()),
|
||||
admit_data(layer, explicit, expected),
|
||||
"omitted and explicit optional Real bounds agree"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn string_optional_maximum_can_be_omitted_without_resetting_required_minimum() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let expected = DataTypeDefinition::String {
|
||||
minimum_length: 4,
|
||||
maximum_length: None,
|
||||
};
|
||||
assert_eq!(
|
||||
admit_data(layer, "String { minimum_length 4; }", expected.clone()),
|
||||
admit_data(
|
||||
layer,
|
||||
"String { minimum_length 4; maximum_length none; }",
|
||||
expected,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicitly_selected_string_requires_minimum_even_when_whole_definition_has_a_default() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
assert_missing(&data_bundle(layer, "String {}"), "minimum_length");
|
||||
assert_missing(
|
||||
&data_bundle(layer, "String { maximum_length none; }"),
|
||||
"minimum_length",
|
||||
);
|
||||
admit_data(
|
||||
layer,
|
||||
"String { minimum_length 0; }",
|
||||
DataTypeDefinition::String {
|
||||
minimum_length: 0,
|
||||
maximum_length: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absent_numeric_maximum_is_distinct_from_an_explicit_zero_bound() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let unbounded = admit_data(
|
||||
layer,
|
||||
"Integer { minimum -3; }",
|
||||
DataTypeDefinition::Integer {
|
||||
minimum: Some(-3),
|
||||
maximum: None,
|
||||
},
|
||||
);
|
||||
let bounded = admit_data(
|
||||
layer,
|
||||
"Integer { minimum -3; maximum 0; }",
|
||||
DataTypeDefinition::Integer {
|
||||
minimum: Some(-3),
|
||||
maximum: Some(0),
|
||||
},
|
||||
);
|
||||
assert_ne!(unbounded, bounded, "zero is a bound, not omitted syntax");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn record_has_no_required_payload_field_and_omitted_supertype_means_none() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let expected = DataTypeDefinition::Record { supertype: None };
|
||||
assert_eq!(
|
||||
admit_data(layer, "Record {}", expected.clone()),
|
||||
admit_data(layer, "Record { supertype none; }", expected),
|
||||
);
|
||||
let errors = compile_bundle(
|
||||
&data_bundle(layer, "Record { fields []; }"),
|
||||
ProjectLimits::default(),
|
||||
)
|
||||
.unwrap_err();
|
||||
assert_eq!(errors.len(), 1, "{errors:?}");
|
||||
assert_eq!(errors[0].diagnostic.code, "invalid_source", "{errors:?}");
|
||||
assert!(
|
||||
errors[0]
|
||||
.diagnostic
|
||||
.message
|
||||
.contains("unknown field `fields`"),
|
||||
"Record remains a closed constructor: {errors:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enumeration_still_requires_its_list_even_when_empty_list_is_admitted() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
assert_missing(&data_bundle(layer, "Enumeration {}"), "literals");
|
||||
admit_data(
|
||||
layer,
|
||||
"Enumeration { literals []; }",
|
||||
DataTypeDefinition::Enumeration { literals: vec![] },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn port_bundle(layer: Layer, fields: &str) -> SourceBundle {
|
||||
let owner_kind = match layer {
|
||||
Layer::Oa => "oa_entity",
|
||||
Layer::Sa => "sa_system",
|
||||
};
|
||||
bundle(&format!(
|
||||
" {owner_kind} owner \"Owner\" id \"00000000-0000-4000-8000-000000000002\" {{}}\n {}_port subject \"Subject\" id \"00000000-0000-4000-8000-000000000003\" {{ parent @owner; {fields} }}",
|
||||
prefix(layer)
|
||||
))
|
||||
}
|
||||
|
||||
fn admit_port(layer: Layer, fields: &str) -> Project {
|
||||
let owner_body = match layer {
|
||||
Layer::Oa => ElementBody::Oa(OaBody::Entity),
|
||||
Layer::Sa => ElementBody::Sa(SaBody::System),
|
||||
};
|
||||
assert_admitted(
|
||||
&port_bundle(layer, fields),
|
||||
vec![
|
||||
element(2, "owner", "Owner", None, owner_body),
|
||||
element(
|
||||
3,
|
||||
"subject",
|
||||
"Subject",
|
||||
Some(id(2)),
|
||||
ElementBody::shared_in(
|
||||
layer,
|
||||
SharedBody::Port {
|
||||
contract: PortContract::Legacy,
|
||||
legacy: Some(LegacyPortContract {
|
||||
direction: PortDirection::Out,
|
||||
interface: None,
|
||||
}),
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_port_interface_can_be_omitted_but_preserves_direction() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
assert_eq!(
|
||||
admit_port(layer, "contract Legacy; legacy { direction Out; };"),
|
||||
admit_port(
|
||||
layer,
|
||||
"contract Legacy; legacy { direction Out; interface none; };"
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_port_direction_remains_required_inside_selected_record() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
assert_missing(
|
||||
&port_bundle(layer, "contract Legacy; legacy {};"),
|
||||
"direction",
|
||||
);
|
||||
assert_missing(
|
||||
&port_bundle(layer, "contract Legacy; legacy { interface none; };"),
|
||||
"direction",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn port_declaration_still_requires_explicit_contract() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
assert_missing(
|
||||
&port_bundle(layer, "legacy { direction Out; };"),
|
||||
"contract",
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,445 @@
|
||||
//! Public flat/modular formatter fidelity over frozen input and bounded mutations.
|
||||
//! Legacy fixture migration uses the public parser. The original adapter tests
|
||||
//! and exact inputs remain archived at source commit 2a64344; this suite exercises
|
||||
//! production formatters, not that frozen experiment or lossless model edits.
|
||||
|
||||
use oa_core::{language, source_project::*, *};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
const COASTAL: &str = include_str!("../../../examples/coastal-rescue.arcadia");
|
||||
const KEYED: &str =
|
||||
include_str!("../../../harness/fixtures/release1-native/semantic-keyed.arcadia");
|
||||
const STATES: &str =
|
||||
include_str!("../../../harness/fixtures/release1-native/state-actions.arcadia");
|
||||
const DENSE: &str =
|
||||
include_str!("../../../harness/fixtures/release1-native/dense-40/dense-40.arcadia");
|
||||
const OPERATIONS: &str = include_str!(
|
||||
"../../../harness/fixtures/interface-operation-probe/output/boundary-only.arcadia"
|
||||
);
|
||||
|
||||
fn read(source: &str) -> Project {
|
||||
language::parse(source).unwrap()
|
||||
}
|
||||
|
||||
fn identity(project: &Project, alias: &str) -> Id {
|
||||
project
|
||||
.identities()
|
||||
.into_iter()
|
||||
.find(|(_, name)| *name == alias)
|
||||
.unwrap()
|
||||
.0
|
||||
}
|
||||
|
||||
fn assert_common_omissions(project: &Project, compiled: &CompiledSourceProject) {
|
||||
if project.description.is_empty() {
|
||||
assert!(compiled.index.project_description.is_none());
|
||||
}
|
||||
let descriptions = project
|
||||
.elements
|
||||
.values()
|
||||
.map(|e| (e.id, &e.description))
|
||||
.chain(project.relations.values().map(|r| (r.id, &r.description)))
|
||||
.chain(project.diagrams.values().map(|d| (d.id, &d.description)))
|
||||
.chain(
|
||||
project
|
||||
.artifact_links
|
||||
.values()
|
||||
.map(|a| (a.id, &a.description)),
|
||||
)
|
||||
.chain(project.traces.values().map(|t| (t.id, &t.description)));
|
||||
for (id, description) in descriptions {
|
||||
if description.is_empty() {
|
||||
assert!(
|
||||
!compiled
|
||||
.index
|
||||
.fields
|
||||
.contains_key(&(id, "description".into()))
|
||||
);
|
||||
}
|
||||
}
|
||||
for element in project.elements.values().filter(|e| e.parent.is_none()) {
|
||||
assert!(
|
||||
!compiled
|
||||
.index
|
||||
.fields
|
||||
.contains_key(&(element.id, "parent".into()))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_fidelity(label: &str, project: &Project) -> (Project, String) {
|
||||
let flat = language::format(project);
|
||||
let compact_model = read(&flat);
|
||||
assert_eq!(
|
||||
compact_model, *project,
|
||||
"{label}: every persistent field survives"
|
||||
);
|
||||
|
||||
let input = SourceBundle {
|
||||
entry: "project.arcadia".into(),
|
||||
files: BTreeMap::from([("project.arcadia".into(), flat.clone())]),
|
||||
};
|
||||
let compiled = compile_bundle(&input, ProjectLimits::default()).unwrap();
|
||||
assert_eq!(
|
||||
compiled.decoded.project, *project,
|
||||
"{label}: source-project compile"
|
||||
);
|
||||
assert_eq!(
|
||||
compiled.bundle, input,
|
||||
"{label}: compact identities/source are stable"
|
||||
);
|
||||
|
||||
assert_common_omissions(project, &compiled);
|
||||
assert_eq!(
|
||||
language::format(&compact_model),
|
||||
flat,
|
||||
"{label}: flat idempotence"
|
||||
);
|
||||
|
||||
let modular = format_bundle(project);
|
||||
assert!(
|
||||
modular.files.len() > 1,
|
||||
"{label}: exercise generated includes"
|
||||
);
|
||||
let modular_compiled = compile_bundle(&modular, ProjectLimits::default()).unwrap();
|
||||
assert_eq!(
|
||||
modular_compiled.decoded.project, *project,
|
||||
"{label}: complete modular model"
|
||||
);
|
||||
assert_eq!(
|
||||
modular_compiled.bundle, modular,
|
||||
"{label}: modular source/IDs are stable"
|
||||
);
|
||||
assert_common_omissions(project, &modular_compiled);
|
||||
assert_eq!(
|
||||
format_bundle(&compact_model),
|
||||
modular,
|
||||
"{label}: flat-to-modular determinism"
|
||||
);
|
||||
assert_eq!(
|
||||
format_bundle(&modular_compiled.decoded.project),
|
||||
modular,
|
||||
"{label}: modular idempotence"
|
||||
);
|
||||
|
||||
// Pinned measured compact bytes from the published fidelity-1 stderr.log at
|
||||
// 2a64344, not a comparison against today's (now compact) canonical writer.
|
||||
// A small header-layout allowance avoids enforcing incidental blank lines.
|
||||
let measured_compact_bytes = match label {
|
||||
"coastal-source3" => 28_465,
|
||||
"keyed-source5-history" => 12_066,
|
||||
"state-actions-source5" => 6_673,
|
||||
"dense40-source6" => 19_342,
|
||||
"boundary-operations-source6" => 3_660,
|
||||
"state-negative-zero" => 6_672,
|
||||
"keyed-present-empty-default" => 12_108,
|
||||
"coastal-present-empty-artifact-strings" => 28_483,
|
||||
"boundary-selected-and-unresolved-operations" => 3_806,
|
||||
_ => panic!("missing independent compact measurement for {label}"),
|
||||
};
|
||||
assert!(
|
||||
flat.len() <= measured_compact_bytes + 16,
|
||||
"{label}: {} bytes exceeds compact evidence budget {}",
|
||||
flat.len(),
|
||||
measured_compact_bytes + 16
|
||||
);
|
||||
(compact_model, flat)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migrated_coastal_oa_sa_views_ports_artifacts_and_traces_roundtrip() {
|
||||
assert!(COASTAL.starts_with("arcadia 3;"));
|
||||
let project = read(COASTAL);
|
||||
assert!(project.elements.values().any(|e| e.layer() == Layer::Oa));
|
||||
assert!(project.elements.values().any(|e| e.layer() == Layer::Sa));
|
||||
assert!(
|
||||
project
|
||||
.elements
|
||||
.values()
|
||||
.any(|e| matches!(e.shared(), Some(SharedBody::Port { .. })))
|
||||
);
|
||||
assert!(project.elements.values().any(
|
||||
|e| matches!(e.shared(), Some(SharedBody::Scenario { steps, .. }) if !steps.is_empty())
|
||||
));
|
||||
assert!(project.diagrams.values().any(|d| d.layer == Layer::Oa
|
||||
&& d.projection == ProjectionKind::FunctionalDataflow
|
||||
&& !d.nodes.is_empty()));
|
||||
assert!(project.diagrams.values().any(|d| d.layer == Layer::Sa
|
||||
&& d.projection == ProjectionKind::Allocation
|
||||
&& !d.nodes.is_empty()));
|
||||
assert!(!project.artifact_links.is_empty());
|
||||
assert!(project.traces.values().any(|t| t.baseline.is_some()));
|
||||
assert_fidelity("coastal-source3", &project);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keyed_scenarios_records_and_historical_binding_annotation_facets_roundtrip() {
|
||||
let project = read(KEYED);
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
assert!(project.elements.values().any(|e| e.layer() == layer
|
||||
&& matches!(
|
||||
e.shared(),
|
||||
Some(SharedBody::DataType {
|
||||
definition: DataTypeDefinition::Record { .. },
|
||||
..
|
||||
})
|
||||
)));
|
||||
assert!(project.elements.values().any(|e| e.layer() == layer && matches!(e.shared(), Some(SharedBody::Occurrence { body: OccurrenceBody::ScenarioUse { bindings, .. }, .. }) if !bindings.is_empty())));
|
||||
}
|
||||
assert!(project.elements.values().any(|e| matches!(e.shared(), Some(SharedBody::Occurrence { body: OccurrenceBody::Exchange { values, .. }, .. }) if values.iter().any(|v| !v.path.is_empty() && !v.expression.is_empty()))));
|
||||
let facets: Vec<_> = project
|
||||
.traces
|
||||
.values()
|
||||
.filter_map(|t| t.baseline.as_ref()?.semantic.as_ref())
|
||||
.flat_map(|baseline| &baseline.facets)
|
||||
.collect();
|
||||
assert!(
|
||||
facets
|
||||
.iter()
|
||||
.any(|facet| matches!(facet, semantic::FacetReview::Binding { .. }))
|
||||
);
|
||||
assert!(
|
||||
facets
|
||||
.iter()
|
||||
.any(|facet| matches!(facet, semantic::FacetReview::Annotation { .. }))
|
||||
);
|
||||
assert!(
|
||||
KEYED.contains("uuid \""),
|
||||
"fixture contains historical identity atoms"
|
||||
);
|
||||
let (_, source) = assert_fidelity("keyed-source5-history", &project);
|
||||
assert!(
|
||||
source.contains("uuid \""),
|
||||
"historical atoms remain historical"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_machine_projection_typed_actions_and_bounded_integer_roundtrip() {
|
||||
let project = read(STATES);
|
||||
let machine = identity(&project, "operating");
|
||||
let view = &project.diagrams[&identity(&project, "state_view")];
|
||||
assert_eq!(view.projection, ProjectionKind::StateMachine { machine });
|
||||
assert!(!view.nodes.is_empty());
|
||||
for phase in [
|
||||
StateActionKind::Entry,
|
||||
StateActionKind::Do,
|
||||
StateActionKind::Exit,
|
||||
] {
|
||||
assert!(project.relations.values().any(|r| matches!(&r.body, RelationBody::StateAction { phase: actual, .. } if *actual == phase)));
|
||||
}
|
||||
assert!(project.relations.values().any(|r| matches!(&r.body, RelationBody::StateTransition { transition_kind: TransitionKind::Internal, triggers, effects, .. } if !triggers.is_empty() && !effects.is_empty())));
|
||||
assert!(
|
||||
matches!(project.elements[&identity(&project, "retry_budget")].shared(), Some(SharedBody::DataType { definition: DataTypeDefinition::Integer { minimum: Some(0), maximum: Some(4) }, unit, .. }) if unit == "attempts")
|
||||
);
|
||||
assert_fidelity("state-actions-source5", &project);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forty_exchange_diagram_with_categories_and_processes_roundtrip() {
|
||||
let project = read(DENSE);
|
||||
assert_eq!(
|
||||
project
|
||||
.relations
|
||||
.values()
|
||||
.filter(|r| r.kind() == RelationKind::Interaction)
|
||||
.count(),
|
||||
40
|
||||
);
|
||||
assert_eq!(
|
||||
project
|
||||
.elements
|
||||
.values()
|
||||
.filter(|e| e.kind() == Kind::ExchangeCategory)
|
||||
.count(),
|
||||
5
|
||||
);
|
||||
assert_eq!(
|
||||
project
|
||||
.elements
|
||||
.values()
|
||||
.filter(|e| e.kind() == Kind::Process)
|
||||
.count(),
|
||||
3
|
||||
);
|
||||
let view = &project.diagrams[&identity(&project, "forty_exchange_view")];
|
||||
assert_eq!(view.projection, ProjectionKind::FunctionalDataflow);
|
||||
assert_eq!(view.nodes.len(), 2);
|
||||
assert!(view.routes.is_empty());
|
||||
assert_eq!(
|
||||
view.view_options.relation_kinds,
|
||||
view_options::RelationKinds::All
|
||||
);
|
||||
assert_fidelity("dense40-source6", &project);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn component_ports_interface_operations_and_boundary_calls_roundtrip() {
|
||||
let project = read(OPERATIONS);
|
||||
assert_eq!(
|
||||
project
|
||||
.relations
|
||||
.values()
|
||||
.filter(|r| r.kind() == RelationKind::InterfaceItemAllocation)
|
||||
.count(),
|
||||
2
|
||||
);
|
||||
assert_eq!(
|
||||
project
|
||||
.elements
|
||||
.values()
|
||||
.filter(|e| matches!(
|
||||
e.shared(),
|
||||
Some(SharedBody::Port {
|
||||
contract: PortContract::Component {
|
||||
kind: ComponentPortKind::Standard,
|
||||
..
|
||||
},
|
||||
..
|
||||
})
|
||||
))
|
||||
.count(),
|
||||
2
|
||||
);
|
||||
assert!(project.elements.values().any(|e| matches!(
|
||||
e.shared(),
|
||||
Some(SharedBody::ExchangeItem {
|
||||
mechanism: ExchangeMechanism::Operation,
|
||||
..
|
||||
})
|
||||
)));
|
||||
assert!(project.elements.values().any(|e| matches!(e.shared(), Some(SharedBody::Scenario { viewpoint: ScenarioViewpoint::BoundaryInterface, steps, .. }) if steps.len() == 2)));
|
||||
assert_eq!(
|
||||
project
|
||||
.elements
|
||||
.values()
|
||||
.filter(|e| matches!(
|
||||
e.shared(),
|
||||
Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange {
|
||||
message_kind: MessageKind::Call,
|
||||
operation: None,
|
||||
..
|
||||
},
|
||||
..
|
||||
})
|
||||
))
|
||||
.count(),
|
||||
2
|
||||
);
|
||||
assert_fidelity("boundary-operations-source6", &project);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn negative_zero_node_coordinate_survives_canonical_compact_and_bundle_compile() {
|
||||
let mut project = read(STATES);
|
||||
let diagram = identity(&project, "state_view");
|
||||
let node = identity(&project, "view_machine");
|
||||
project
|
||||
.diagrams
|
||||
.get_mut(&diagram)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&node)
|
||||
.unwrap()
|
||||
.position = Position { x: -0.0, y: 0.0 };
|
||||
let (decoded, flat) = assert_fidelity("state-negative-zero", &project);
|
||||
let bundle = SourceBundle {
|
||||
entry: "project.arcadia".into(),
|
||||
files: BTreeMap::from([("project.arcadia".into(), flat)]),
|
||||
};
|
||||
let compiled = compile_bundle(&bundle, ProjectLimits::default()).unwrap();
|
||||
let modular = compile_bundle(&format_bundle(&project), ProjectLimits::default()).unwrap();
|
||||
// Project/Position PartialEq alone equates the signs of zero. Check bits in
|
||||
// each independently decoded representation, including source-project compile.
|
||||
for actual in [
|
||||
&decoded,
|
||||
&compiled.decoded.project,
|
||||
&modular.decoded.project,
|
||||
] {
|
||||
let position = &actual.diagrams[&diagram].nodes[&node].position;
|
||||
assert_eq!(position.x.to_bits(), (-0.0_f32).to_bits());
|
||||
assert_eq!(position.y.to_bits(), 0.0_f32.to_bits());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn present_empty_field_default_and_unbounded_maximum_remain_distinct_from_omission() {
|
||||
let mut project = read(KEYED);
|
||||
let field_id = identity(&project, "oa_navigation_latitude");
|
||||
let Some(SharedBody::DataField {
|
||||
minimum,
|
||||
maximum,
|
||||
default,
|
||||
storage,
|
||||
..
|
||||
}) = project.elements.get_mut(&field_id).unwrap().shared_mut()
|
||||
else {
|
||||
panic!("fixture must contain the claimed DataField");
|
||||
};
|
||||
assert_eq!(*minimum, 0);
|
||||
assert_eq!(*maximum, Some(1));
|
||||
assert_eq!(*default, None);
|
||||
assert_eq!(*storage, FieldStorage::Value);
|
||||
*maximum = None;
|
||||
*default = Some(vec![]);
|
||||
let (decoded, _) = assert_fidelity("keyed-present-empty-default", &project);
|
||||
assert!(
|
||||
matches!(decoded.elements[&field_id].shared(), Some(SharedBody::DataField { maximum: None, default: Some(values), .. }) if values.is_empty())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn present_empty_artifact_revision_and_fragment_survive() {
|
||||
let mut project = read(COASTAL);
|
||||
let artifact_id = identity(&project, "console_enclosure_cad");
|
||||
let artifact = project.artifact_links.get_mut(&artifact_id).unwrap();
|
||||
artifact.revision = Some(String::new());
|
||||
artifact.fragment = Some(String::new());
|
||||
let (decoded, _) = assert_fidelity("coastal-present-empty-artifact-strings", &project);
|
||||
assert_eq!(
|
||||
decoded.artifact_links[&artifact_id].revision.as_deref(),
|
||||
Some("")
|
||||
);
|
||||
assert_eq!(
|
||||
decoded.artifact_links[&artifact_id].fragment.as_deref(),
|
||||
Some("")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_and_unresolved_operation_intent_survive_as_distinct_present_records() {
|
||||
let mut project = read(OPERATIONS);
|
||||
let allocation = identity(&project, "operation_probe_30");
|
||||
assert_eq!(
|
||||
project.relations[&allocation].kind(),
|
||||
RelationKind::InterfaceItemAllocation
|
||||
);
|
||||
let selected = identity(&project, "operation_probe_43");
|
||||
let unresolved = identity(&project, "operation_probe_44");
|
||||
for (subject, value) in [(selected, Some(allocation)), (unresolved, None)] {
|
||||
let Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange { operation, .. },
|
||||
..
|
||||
}) = project.elements.get_mut(&subject).unwrap().shared_mut()
|
||||
else {
|
||||
panic!("fixture must contain a Call Exchange");
|
||||
};
|
||||
assert_eq!(*operation, None);
|
||||
*operation = Some(OperationUse { allocation: value });
|
||||
}
|
||||
let (decoded, _) = assert_fidelity("boundary-selected-and-unresolved-operations", &project);
|
||||
assert!(
|
||||
matches!(decoded.elements[&selected].shared(), Some(SharedBody::Occurrence { body: OccurrenceBody::Exchange { operation: Some(OperationUse { allocation: Some(actual) }), .. }, .. }) if *actual == allocation)
|
||||
);
|
||||
assert!(matches!(
|
||||
decoded.elements[&unresolved].shared(),
|
||||
Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange {
|
||||
operation: Some(OperationUse { allocation: None }),
|
||||
..
|
||||
},
|
||||
..
|
||||
})
|
||||
));
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
//! Focused public-output contracts. Field locations come from the real compiler;
|
||||
//! token comparisons ignore layout while retaining typed tags and required syntax.
|
||||
use oa_core::{language, source_project::*, *};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
fn project(declarations: &str) -> Project {
|
||||
language::parse(&format!(
|
||||
"arcadia 7; project p \"P\" id \"00000000-0000-4000-8000-000000000001\" {{ {declarations} }}"
|
||||
)).unwrap()
|
||||
}
|
||||
|
||||
fn exports(project: &Project) -> Vec<CompiledSourceProject> {
|
||||
let flat = language::format(project);
|
||||
assert_eq!(language::parse(&flat).unwrap(), *project);
|
||||
let flat_bundle = SourceBundle {
|
||||
entry: "project.arcadia".into(),
|
||||
files: BTreeMap::from([("project.arcadia".into(), flat)]),
|
||||
};
|
||||
[flat_bundle, format_bundle(project)]
|
||||
.into_iter()
|
||||
.map(|bundle| {
|
||||
let compiled = compile_bundle(&bundle, ProjectLimits::default()).unwrap();
|
||||
assert_eq!(compiled.decoded.project, *project);
|
||||
assert_eq!(
|
||||
compiled.bundle, bundle,
|
||||
"all identities are explicit and stable"
|
||||
);
|
||||
compiled
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn identity(project: &Project, alias: &str) -> Id {
|
||||
project
|
||||
.identities()
|
||||
.into_iter()
|
||||
.find(|(_, name)| *name == alias)
|
||||
.unwrap()
|
||||
.0
|
||||
}
|
||||
|
||||
fn field(compiled: &CompiledSourceProject, alias: &str, name: &str) -> Option<String> {
|
||||
let location = compiled
|
||||
.index
|
||||
.fields
|
||||
.get(&(identity(&compiled.decoded.project, alias), name.into()))?;
|
||||
let text = &compiled.bundle.files[&location.path][location.span.start..location.span.end];
|
||||
Some(
|
||||
language::tokenize(text)
|
||||
.into_iter()
|
||||
.filter(|token| {
|
||||
!matches!(
|
||||
token.class,
|
||||
language::TokenClass::Whitespace | language::TokenClass::Comment
|
||||
)
|
||||
})
|
||||
.map(|token| &text[token.span.start..token.span.end])
|
||||
.collect::<Vec<_>>()
|
||||
.join(" "),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_project_has_no_synthetic_description_or_module() {
|
||||
let empty = project("");
|
||||
for compiled in exports(&empty) {
|
||||
assert!(compiled.index.project_description.is_none());
|
||||
assert!(compiled.index.fields.is_empty());
|
||||
assert!(compiled.index.includes.is_empty());
|
||||
assert_eq!(compiled.bundle.files.len(), 1);
|
||||
assert_eq!(compiled.index.declarations.len(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_empty_descriptions_disappear_whitespace_and_text_survive_exactly() {
|
||||
let mut model =
|
||||
project("oa_activity work \"Work\" id \"00000000-0000-4000-8000-000000000002\" {}");
|
||||
let work = identity(&model, "work");
|
||||
for description in ["", " \t\n", "A quoted \"requirement\" — 雨"] {
|
||||
model.description = description.into();
|
||||
model.elements.get_mut(&work).unwrap().description = description.into();
|
||||
for compiled in exports(&model) {
|
||||
assert_eq!(
|
||||
compiled.index.project_description.is_some(),
|
||||
!description.is_empty()
|
||||
);
|
||||
assert_eq!(
|
||||
field(&compiled, "work", "description").is_some(),
|
||||
!description.is_empty()
|
||||
);
|
||||
assert!(field(&compiled, "work", "parent").is_none());
|
||||
// exports compares the complete model, including exact whitespace
|
||||
// and escapes in both project and element descriptions.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sa_view_keeps_its_layer_and_node_references_but_omits_presentation_defaults() {
|
||||
let model = project(
|
||||
r#"
|
||||
sa_function work "Work" id "00000000-0000-4000-8000-000000000002" {}
|
||||
diagram view "View" id "00000000-0000-4000-8000-000000000003" { layer Sa; }
|
||||
node placement id "00000000-0000-4000-8000-000000000004" {
|
||||
diagram @view; element @work;
|
||||
position { x 0.0; y 0.0; }; size { width 224.0; height 94.0; };
|
||||
collapsed false; port_anchors [];
|
||||
}
|
||||
"#,
|
||||
);
|
||||
for compiled in exports(&model) {
|
||||
assert_eq!(field(&compiled, "view", "layer").as_deref(), Some("Sa"));
|
||||
for name in ["description", "projection", "routes", "view_options"] {
|
||||
assert!(field(&compiled, "view", name).is_none(), "view.{name}");
|
||||
}
|
||||
assert_eq!(
|
||||
field(&compiled, "placement", "diagram").as_deref(),
|
||||
Some("@ view")
|
||||
);
|
||||
assert_eq!(
|
||||
field(&compiled, "placement", "element").as_deref(),
|
||||
Some("@ work")
|
||||
);
|
||||
for name in ["position", "size", "collapsed", "port_anchors"] {
|
||||
assert!(
|
||||
field(&compiled, "placement", name).is_none(),
|
||||
"placement.{name}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_type_defaults_disappear_without_losing_required_or_present_empty_values() {
|
||||
let model = project(
|
||||
r#"
|
||||
sa_data_type implicit "Implicit" id "00000000-0000-4000-8000-000000000002" {}
|
||||
sa_data_type integer "Integer" id "00000000-0000-4000-8000-000000000003" {
|
||||
definition { Integer { minimum none; maximum none; }; };
|
||||
}
|
||||
sa_data_type text "Text" id "00000000-0000-4000-8000-000000000004" {
|
||||
definition { String { minimum_length 4; maximum_length none; }; };
|
||||
}
|
||||
sa_data_type record "Record" id "00000000-0000-4000-8000-000000000005" {
|
||||
definition { Record { supertype none; }; };
|
||||
}
|
||||
sa_data_type flag "Flag" id "00000000-0000-4000-8000-000000000006" {
|
||||
definition Boolean; default { Boolean false; };
|
||||
}
|
||||
sa_data_field optional "Optional" id "00000000-0000-4000-8000-000000000007" {
|
||||
parent @record; minimum 0; maximum none; default [];
|
||||
}
|
||||
"#,
|
||||
);
|
||||
for compiled in exports(&model) {
|
||||
assert!(field(&compiled, "implicit", "definition").is_none());
|
||||
assert_eq!(
|
||||
field(&compiled, "integer", "definition").as_deref(),
|
||||
Some("{ Integer { } ; }")
|
||||
);
|
||||
assert_eq!(
|
||||
field(&compiled, "text", "definition").as_deref(),
|
||||
Some("{ String { minimum_length 4 ; } ; }")
|
||||
);
|
||||
assert_eq!(
|
||||
field(&compiled, "record", "definition").as_deref(),
|
||||
Some("{ Record { } ; }")
|
||||
);
|
||||
assert_eq!(
|
||||
field(&compiled, "flag", "definition").as_deref(),
|
||||
Some("Boolean")
|
||||
);
|
||||
assert_eq!(
|
||||
field(&compiled, "flag", "default").as_deref(),
|
||||
Some("{ Boolean false ; }")
|
||||
);
|
||||
assert_eq!(
|
||||
field(&compiled, "optional", "minimum").as_deref(),
|
||||
Some("0")
|
||||
);
|
||||
assert_eq!(
|
||||
field(&compiled, "optional", "maximum").as_deref(),
|
||||
Some("none")
|
||||
);
|
||||
assert_eq!(
|
||||
field(&compiled, "optional", "default").as_deref(),
|
||||
Some("[ ]")
|
||||
);
|
||||
assert!(field(&compiled, "optional", "type_ref").is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn required_port_contract_and_empty_item_list_survive_while_optional_interface_disappears() {
|
||||
let model = project(
|
||||
r#"
|
||||
sa_function work "Work" id "00000000-0000-4000-8000-000000000002" {}
|
||||
sa_port sink "Sink" id "00000000-0000-4000-8000-000000000003" {
|
||||
parent @work; contract { FunctionInput { incoming_items []; }; };
|
||||
}
|
||||
sa_system host "Host" id "00000000-0000-4000-8000-000000000004" {}
|
||||
sa_port preserved "Preserved" id "00000000-0000-4000-8000-000000000005" {
|
||||
parent @host; contract Legacy; legacy { direction Out; interface none; };
|
||||
}
|
||||
"#,
|
||||
);
|
||||
for compiled in exports(&model) {
|
||||
assert_eq!(
|
||||
field(&compiled, "sink", "contract").as_deref(),
|
||||
Some("{ FunctionInput { incoming_items [ ] ; } ; }")
|
||||
);
|
||||
assert!(field(&compiled, "sink", "legacy").is_none());
|
||||
assert_eq!(
|
||||
field(&compiled, "preserved", "contract").as_deref(),
|
||||
Some("Legacy")
|
||||
);
|
||||
assert_eq!(
|
||||
field(&compiled, "preserved", "legacy").as_deref(),
|
||||
Some("{ direction Out ; }")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_scenario_default_and_selected_occurrence_constructor_remain_distinct() {
|
||||
let model = project(
|
||||
r#"
|
||||
sa_scenario mixed "Mixed" id "00000000-0000-4000-8000-000000000002" { viewpoint MixedLegacy; }
|
||||
sa_scenario strict "Strict" id "00000000-0000-4000-8000-000000000003" { viewpoint Functional; }
|
||||
oa_process flow "Flow" id "00000000-0000-4000-8000-000000000004" { steps [@exchange, @action]; }
|
||||
oa_occurrence exchange "Exchange" id "00000000-0000-4000-8000-000000000005" {
|
||||
parent @flow; body { Exchange {}; };
|
||||
}
|
||||
oa_occurrence action "Action" id "00000000-0000-4000-8000-000000000006" {
|
||||
parent @flow; body { Action { action none; lifeline none; }; };
|
||||
}
|
||||
"#,
|
||||
);
|
||||
for compiled in exports(&model) {
|
||||
assert!(field(&compiled, "mixed", "viewpoint").is_none());
|
||||
assert_eq!(
|
||||
field(&compiled, "strict", "viewpoint").as_deref(),
|
||||
Some("Functional")
|
||||
);
|
||||
assert!(field(&compiled, "exchange", "body").is_none());
|
||||
assert_eq!(
|
||||
field(&compiled, "action", "body").as_deref(),
|
||||
Some("{ Action { } ; }")
|
||||
);
|
||||
assert_eq!(
|
||||
field(&compiled, "action", "parent").as_deref(),
|
||||
Some("@ flow")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,612 @@
|
||||
//! Exact-source regressions for fields skipped by persistence serialization.
|
||||
//!
|
||||
//! The archived Windows baseline confirmed three exact-source failures after
|
||||
//! full model equality passed. Complete comparison now preserves these fields;
|
||||
//! the former ignored witnesses are normal regression tests.
|
||||
//! No-op and same-record header controls remain normal tests: they must preserve
|
||||
//! the same fixtures without entering the changed-payload record-erasure branch.
|
||||
|
||||
use oa_core::{source_project::*, *};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
const EXCHANGE_SOURCE: &str = r#"arcadia 7;
|
||||
project p "Preservation" id "00000000-0000-4000-8000-000000000001" {
|
||||
oa_activity first "First" id "00000000-0000-4000-8000-000000000002" {}
|
||||
oa_activity second "Second" id "00000000-0000-4000-8000-000000000003" {}
|
||||
interaction flow "Flow" id "00000000-0000-4000-8000-000000000004" {
|
||||
source @first; target @second;
|
||||
}
|
||||
oa_process owner "Owner" id "00000000-0000-4000-8000-000000000005" {
|
||||
steps [@subject];
|
||||
}
|
||||
oa_occurrence subject "Subject" id "00000000-0000-4000-8000-000000000006" {
|
||||
parent @owner;
|
||||
body { Exchange {
|
||||
exchange none;
|
||||
sender none; receiver none;
|
||||
message_kind Asynchronous; reply_to none; values [];
|
||||
operation none ; // Keep this authored default and its spelling.
|
||||
}; };
|
||||
}
|
||||
}
|
||||
"#;
|
||||
|
||||
const NODE_SOURCE: &str = r#"arcadia 7;
|
||||
project p "Preservation" id "00000000-0000-4000-8000-000000000001" {
|
||||
oa_activity work "Work" id "00000000-0000-4000-8000-000000000002" {}
|
||||
diagram view "View" id "00000000-0000-4000-8000-000000000003" {}
|
||||
node placement id "00000000-0000-4000-8000-000000000004" {
|
||||
diagram @view; element @work;
|
||||
position { x 12.5; y -4.0; };
|
||||
port_anchors [] ; // Keep explicit automatic placement.
|
||||
}
|
||||
}
|
||||
"#;
|
||||
|
||||
const TRACE_SOURCE: &str = r#"arcadia 7;
|
||||
project p "Preservation" id "00000000-0000-4000-8000-000000000001" {
|
||||
oa_activity work "Work" id "00000000-0000-4000-8000-000000000002" {}
|
||||
sa_function function "Function" id "00000000-0000-4000-8000-000000000003" {}
|
||||
trace mapping "Mapping" id "00000000-0000-4000-8000-000000000004" {
|
||||
oa @work; sa @function; kind Manual;
|
||||
baseline {
|
||||
oa { name "Work"; description "Old OA snapshot"; };
|
||||
sa { name "Function"; description "Old SA snapshot"; };
|
||||
semantic none ; // Keep the explicit prose-only baseline.
|
||||
};
|
||||
}
|
||||
}
|
||||
"#;
|
||||
|
||||
const CONSTRUCTOR_SOURCE: &str = r#"arcadia 7;
|
||||
project p "Preservation" id "00000000-0000-4000-8000-000000000001" {
|
||||
oa_scenario owner "Owner" id "00000000-0000-4000-8000-000000000002" {
|
||||
steps [@subject];
|
||||
}
|
||||
oa_occurrence subject "Subject" id "00000000-0000-4000-8000-000000000003" {
|
||||
parent @owner;
|
||||
body { Action {
|
||||
action none;
|
||||
lifeline none ; // Keep this shared field's distinctive spelling.
|
||||
}; };
|
||||
}
|
||||
}
|
||||
"#;
|
||||
|
||||
const RELATION_CONTEXT_SOURCE: &str = r#"arcadia 7;
|
||||
project p "Preservation" id "00000000-0000-4000-8000-000000000001" {
|
||||
oa_entity first "First" id "00000000-0000-4000-8000-000000000002" {}
|
||||
oa_entity second "Second" id "00000000-0000-4000-8000-000000000003" {}
|
||||
// These port declarations are untouched by the relation context change.
|
||||
oa_port output "Output" id "00000000-0000-4000-8000-000000000005" {
|
||||
parent @first; contract Legacy;
|
||||
legacy { direction Out; interface none; };
|
||||
}
|
||||
oa_port input "Input" id "00000000-0000-4000-8000-000000000006" {
|
||||
parent @second; contract Legacy;
|
||||
legacy { direction In; interface none; };
|
||||
}
|
||||
boundary_exchange link "Link" id "00000000-0000-4000-8000-000000000004" {
|
||||
source @output; target @input;
|
||||
oriented true ; // Retain this rationale when changing the default value.
|
||||
items [];
|
||||
}
|
||||
}
|
||||
"#;
|
||||
|
||||
fn id(number: u8) -> Id {
|
||||
Id::parse_str(&format!("00000000-0000-4000-8000-{number:012}")).unwrap()
|
||||
}
|
||||
|
||||
fn source_bundle(source: &str) -> SourceBundle {
|
||||
SourceBundle {
|
||||
entry: "project.arcadia".into(),
|
||||
files: BTreeMap::from([("project.arcadia".into(), source.into())]),
|
||||
}
|
||||
}
|
||||
|
||||
fn project() -> Project {
|
||||
Project {
|
||||
schema_version: 7,
|
||||
id: id(1),
|
||||
alias: "p".into(),
|
||||
name: "Preservation".into(),
|
||||
description: String::new(),
|
||||
elements: BTreeMap::new(),
|
||||
relations: BTreeMap::new(),
|
||||
diagrams: BTreeMap::new(),
|
||||
artifact_links: BTreeMap::new(),
|
||||
traces: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn element(number: u8, alias: &str, name: &str, parent: Option<Id>, body: ElementBody) -> Element {
|
||||
Element {
|
||||
id: id(number),
|
||||
alias: alias.into(),
|
||||
name: name.into(),
|
||||
description: String::new(),
|
||||
parent,
|
||||
body,
|
||||
}
|
||||
}
|
||||
|
||||
// Handwritten typed expectations are independent of canonical serialization and
|
||||
// source hydration. No model factories or generated/random identities are used.
|
||||
fn exchange_project() -> Project {
|
||||
let mut expected = project();
|
||||
expected.elements = [
|
||||
element(2, "first", "First", None, ElementBody::Oa(OaBody::Activity)),
|
||||
element(
|
||||
3,
|
||||
"second",
|
||||
"Second",
|
||||
None,
|
||||
ElementBody::Oa(OaBody::Activity),
|
||||
),
|
||||
element(
|
||||
5,
|
||||
"owner",
|
||||
"Owner",
|
||||
None,
|
||||
ElementBody::Oa(OaBody::Process { steps: vec![id(6)] }),
|
||||
),
|
||||
element(
|
||||
6,
|
||||
"subject",
|
||||
"Subject",
|
||||
Some(id(5)),
|
||||
ElementBody::Oa(OaBody::Shared(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(),
|
||||
})),
|
||||
),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|element| (element.id, element))
|
||||
.collect();
|
||||
expected.relations.insert(
|
||||
id(4),
|
||||
Relation {
|
||||
id: id(4),
|
||||
alias: "flow".into(),
|
||||
name: "Flow".into(),
|
||||
description: String::new(),
|
||||
body: RelationBody::Interaction {
|
||||
source: id(2),
|
||||
target: id(3),
|
||||
items: vec![],
|
||||
},
|
||||
},
|
||||
);
|
||||
expected
|
||||
}
|
||||
|
||||
fn node_project() -> Project {
|
||||
let mut expected = project();
|
||||
expected.elements.insert(
|
||||
id(2),
|
||||
element(2, "work", "Work", None, ElementBody::Oa(OaBody::Activity)),
|
||||
);
|
||||
expected.diagrams.insert(
|
||||
id(3),
|
||||
Diagram {
|
||||
id: id(3),
|
||||
alias: "view".into(),
|
||||
name: "View".into(),
|
||||
description: String::new(),
|
||||
layer: Layer::Oa,
|
||||
projection: ProjectionKind::Overview,
|
||||
view_options: view_options::ViewOptions {
|
||||
hidden_edges: vec![],
|
||||
relation_kinds: view_options::RelationKinds::All,
|
||||
work_categories: view_options::CategorySelection::All,
|
||||
participant_categories: view_options::CategorySelection::All,
|
||||
coverage: view_options::Coverage::All,
|
||||
work_contexts: view_options::ContextSelection::All,
|
||||
exchange_labels: view_options::ExchangeLabels::Name,
|
||||
},
|
||||
nodes: BTreeMap::from([(
|
||||
id(4),
|
||||
NodePlacement {
|
||||
id: id(4),
|
||||
alias: "placement".into(),
|
||||
element: id(2),
|
||||
position: Position { x: 12.5, y: -4.0 },
|
||||
size: Size {
|
||||
width: 224.0,
|
||||
height: 94.0,
|
||||
},
|
||||
collapsed: false,
|
||||
port_anchors: vec![],
|
||||
},
|
||||
)]),
|
||||
routes: vec![],
|
||||
},
|
||||
);
|
||||
expected
|
||||
}
|
||||
|
||||
fn trace_project() -> Project {
|
||||
let mut expected = project();
|
||||
expected.elements = [
|
||||
element(2, "work", "Work", None, ElementBody::Oa(OaBody::Activity)),
|
||||
element(
|
||||
3,
|
||||
"function",
|
||||
"Function",
|
||||
None,
|
||||
ElementBody::Sa(SaBody::Function),
|
||||
),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|element| (element.id, element))
|
||||
.collect();
|
||||
expected.traces.insert(
|
||||
id(4),
|
||||
Trace {
|
||||
id: id(4),
|
||||
alias: "mapping".into(),
|
||||
name: "Mapping".into(),
|
||||
description: String::new(),
|
||||
oa: id(2),
|
||||
sa: id(3),
|
||||
kind: TraceKind::Manual,
|
||||
baseline: Some(TraceBaseline {
|
||||
oa: TracedProperties {
|
||||
name: "Work".into(),
|
||||
description: "Old OA snapshot".into(),
|
||||
},
|
||||
sa: TracedProperties {
|
||||
name: "Function".into(),
|
||||
description: "Old SA snapshot".into(),
|
||||
},
|
||||
semantic: None,
|
||||
}),
|
||||
},
|
||||
);
|
||||
expected
|
||||
}
|
||||
|
||||
fn checked_fixture(source: &str, expected: &Project) -> CompiledSourceProject {
|
||||
let input = source_bundle(source);
|
||||
let compiled = compile_bundle(&input, ProjectLimits::default()).unwrap();
|
||||
assert_eq!(
|
||||
compiled.decoded.project, *expected,
|
||||
"fixture model admission"
|
||||
);
|
||||
assert_eq!(
|
||||
compiled.bundle, input,
|
||||
"fixed IDs must avoid stabilization edits"
|
||||
);
|
||||
compiled
|
||||
}
|
||||
|
||||
fn replacement(source: &str, before: &str, after: &str) -> String {
|
||||
assert_eq!(
|
||||
source.matches(before).count(),
|
||||
1,
|
||||
"unique authored edit token"
|
||||
);
|
||||
source.replacen(before, after, 1)
|
||||
}
|
||||
|
||||
fn assert_preserved_update(
|
||||
original: &CompiledSourceProject,
|
||||
expected: &Project,
|
||||
expected_source: &str,
|
||||
) {
|
||||
// Independently admit the exact expected spelling before testing the updater.
|
||||
let expected_bundle = source_bundle(expected_source);
|
||||
let authored = compile_bundle(&expected_bundle, ProjectLimits::default()).unwrap();
|
||||
assert_eq!(
|
||||
authored.decoded.project, *expected,
|
||||
"expected source/model agree"
|
||||
);
|
||||
assert_eq!(
|
||||
authored.bundle, expected_bundle,
|
||||
"expected source is already stable"
|
||||
);
|
||||
|
||||
let updated = update_bundle(original, expected, ProjectLimits::default()).unwrap();
|
||||
assert_eq!(
|
||||
updated.decoded.project, *expected,
|
||||
"entire intended model survives"
|
||||
);
|
||||
assert_eq!(
|
||||
updated.bundle, expected_bundle,
|
||||
"only the requested token may change; preserve explicit defaults and all trivia"
|
||||
);
|
||||
}
|
||||
|
||||
// Context changes may introduce/remove redundant default syntax. Admit each
|
||||
// narrowly enumerated spelling against the same literal target model, and then
|
||||
// compare complete source bundles. No normalization may mask edits to unrelated
|
||||
// fields, declarations, whitespace, or comments.
|
||||
fn assert_context_update(
|
||||
original: &CompiledSourceProject,
|
||||
expected: &Project,
|
||||
allowed_sources: &[String],
|
||||
) {
|
||||
let allowed: Vec<_> = allowed_sources
|
||||
.iter()
|
||||
.map(|source| {
|
||||
let bundle = source_bundle(source);
|
||||
let authored = compile_bundle(&bundle, ProjectLimits::default()).unwrap();
|
||||
assert_eq!(
|
||||
authored.decoded.project, *expected,
|
||||
"allowed source/model agree"
|
||||
);
|
||||
assert_eq!(authored.bundle, bundle, "allowed source is already stable");
|
||||
bundle
|
||||
})
|
||||
.collect();
|
||||
let updated = update_bundle(original, expected, ProjectLimits::default()).unwrap();
|
||||
assert_eq!(
|
||||
updated.decoded.project, *expected,
|
||||
"entire context-change model survives"
|
||||
);
|
||||
assert!(
|
||||
allowed.contains(&updated.bundle),
|
||||
"preserve unchanged source exactly; only listed changed/default syntax alternatives are allowed\nactual: {:?}\nallowed: {:?}",
|
||||
updated.bundle,
|
||||
allowed
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_exchange_operation_none_survives_noop() {
|
||||
let expected = exchange_project();
|
||||
let original = checked_fixture(EXCHANGE_SOURCE, &expected);
|
||||
assert_preserved_update(&original, &expected, EXCHANGE_SOURCE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_exchange_operation_none_survives_header_rename() {
|
||||
let mut expected = exchange_project();
|
||||
let original = checked_fixture(EXCHANGE_SOURCE, &expected);
|
||||
expected.elements.get_mut(&id(6)).unwrap().name = "Renamed subject".into();
|
||||
let source = replacement(EXCHANGE_SOURCE, "\"Subject\"", "\"Renamed subject\"");
|
||||
assert_preserved_update(&original, &expected, &source);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_exchange_operation_none_survives_exchange_binding_edit() {
|
||||
let mut expected = exchange_project();
|
||||
let original = checked_fixture(EXCHANGE_SOURCE, &expected);
|
||||
let Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange { exchange, .. },
|
||||
..
|
||||
}) = expected.elements.get_mut(&id(6)).unwrap().body.shared_mut()
|
||||
else {
|
||||
panic!("literal fixture must be an Exchange occurrence");
|
||||
};
|
||||
*exchange = Some(id(4));
|
||||
let source = replacement(EXCHANGE_SOURCE, "exchange none;", "exchange @flow;");
|
||||
assert_preserved_update(&original, &expected, &source);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_node_empty_port_anchors_survive_noop() {
|
||||
let expected = node_project();
|
||||
let original = checked_fixture(NODE_SOURCE, &expected);
|
||||
assert_preserved_update(&original, &expected, NODE_SOURCE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_node_empty_port_anchors_survive_header_alias_rename() {
|
||||
let mut expected = node_project();
|
||||
let original = checked_fixture(NODE_SOURCE, &expected);
|
||||
// Nodes have no display-name header. Rename the same node's header alias.
|
||||
expected
|
||||
.diagrams
|
||||
.get_mut(&id(3))
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&id(4))
|
||||
.unwrap()
|
||||
.alias = "renamed_placement".into();
|
||||
let source = replacement(
|
||||
NODE_SOURCE,
|
||||
"node placement id",
|
||||
"node renamed_placement id",
|
||||
);
|
||||
assert_preserved_update(&original, &expected, &source);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_node_empty_port_anchors_survive_position_edit() {
|
||||
let mut expected = node_project();
|
||||
let original = checked_fixture(NODE_SOURCE, &expected);
|
||||
expected
|
||||
.diagrams
|
||||
.get_mut(&id(3))
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&id(4))
|
||||
.unwrap()
|
||||
.position
|
||||
.x = 25.0;
|
||||
let source = replacement(NODE_SOURCE, "x 12.5;", "x 25.0;");
|
||||
assert_preserved_update(&original, &expected, &source);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_trace_semantic_none_survives_noop() {
|
||||
let expected = trace_project();
|
||||
let original = checked_fixture(TRACE_SOURCE, &expected);
|
||||
assert_preserved_update(&original, &expected, TRACE_SOURCE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_trace_semantic_none_survives_header_rename() {
|
||||
let mut expected = trace_project();
|
||||
let original = checked_fixture(TRACE_SOURCE, &expected);
|
||||
expected.traces.get_mut(&id(4)).unwrap().name = "Renamed mapping".into();
|
||||
let source = replacement(TRACE_SOURCE, "\"Mapping\"", "\"Renamed mapping\"");
|
||||
assert_preserved_update(&original, &expected, &source);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_trace_semantic_none_survives_snapshot_prose_edit() {
|
||||
let mut expected = trace_project();
|
||||
let original = checked_fixture(TRACE_SOURCE, &expected);
|
||||
expected
|
||||
.traces
|
||||
.get_mut(&id(4))
|
||||
.unwrap()
|
||||
.baseline
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.oa
|
||||
.description = "Revised OA snapshot".into();
|
||||
let source = replacement(
|
||||
TRACE_SOURCE,
|
||||
"\"Old OA snapshot\"",
|
||||
"\"Revised OA snapshot\"",
|
||||
);
|
||||
assert_preserved_update(&original, &expected, &source);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constructor_change_removes_inapplicable_default_and_keeps_shared_default() {
|
||||
let mut expected = project();
|
||||
expected.elements = [
|
||||
element(
|
||||
2,
|
||||
"owner",
|
||||
"Owner",
|
||||
None,
|
||||
ElementBody::Oa(OaBody::Shared(SharedBody::Scenario {
|
||||
viewpoint: ScenarioViewpoint::MixedLegacy,
|
||||
context: None,
|
||||
precondition: String::new(),
|
||||
postcondition: String::new(),
|
||||
steps: vec![id(3)],
|
||||
})),
|
||||
),
|
||||
element(
|
||||
3,
|
||||
"subject",
|
||||
"Subject",
|
||||
Some(id(2)),
|
||||
ElementBody::Oa(OaBody::Shared(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Action {
|
||||
action: None,
|
||||
lifeline: None,
|
||||
},
|
||||
precondition: String::new(),
|
||||
postcondition: String::new(),
|
||||
})),
|
||||
),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|element| (element.id, element))
|
||||
.collect();
|
||||
let original = checked_fixture(CONSTRUCTOR_SOURCE, &expected);
|
||||
expected.elements.get_mut(&id(3)).unwrap().body =
|
||||
ElementBody::Oa(OaBody::Shared(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::State {
|
||||
state: None,
|
||||
lifeline: None,
|
||||
},
|
||||
precondition: String::new(),
|
||||
postcondition: String::new(),
|
||||
}));
|
||||
// Remove only the inapplicable field's syntax: its separating space and all
|
||||
// surrounding trivia stay. The shared lifeline spelling/comment is exact.
|
||||
let source = replacement(CONSTRUCTOR_SOURCE, "body { Action {", "body { State {");
|
||||
let compact = replacement(&source, "action none;", " ");
|
||||
let expanded = replacement(
|
||||
&compact,
|
||||
" }; };",
|
||||
" state none;\n }; };",
|
||||
);
|
||||
// Omission of newly applicable state and explicit state none both mean the
|
||||
// literal State target; neither may retain the old action field.
|
||||
assert_context_update(&original, &expected, &[compact, expanded]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relation_kind_change_updates_explicit_value_when_context_default_changes() {
|
||||
let mut expected = project();
|
||||
expected.elements = [
|
||||
element(2, "first", "First", None, ElementBody::Oa(OaBody::Entity)),
|
||||
element(3, "second", "Second", None, ElementBody::Oa(OaBody::Entity)),
|
||||
element(
|
||||
5,
|
||||
"output",
|
||||
"Output",
|
||||
Some(id(2)),
|
||||
ElementBody::Oa(OaBody::Shared(SharedBody::Port {
|
||||
contract: PortContract::Legacy,
|
||||
legacy: Some(LegacyPortContract {
|
||||
direction: PortDirection::Out,
|
||||
interface: None,
|
||||
}),
|
||||
})),
|
||||
),
|
||||
element(
|
||||
6,
|
||||
"input",
|
||||
"Input",
|
||||
Some(id(3)),
|
||||
ElementBody::Oa(OaBody::Shared(SharedBody::Port {
|
||||
contract: PortContract::Legacy,
|
||||
legacy: Some(LegacyPortContract {
|
||||
direction: PortDirection::In,
|
||||
interface: None,
|
||||
}),
|
||||
})),
|
||||
),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|element| (element.id, element))
|
||||
.collect();
|
||||
expected.relations.insert(
|
||||
id(4),
|
||||
Relation {
|
||||
id: id(4),
|
||||
alias: "link".into(),
|
||||
name: "Link".into(),
|
||||
description: String::new(),
|
||||
body: RelationBody::BoundaryExchange {
|
||||
source: id(5),
|
||||
target: id(6),
|
||||
oriented: true,
|
||||
items: vec![],
|
||||
},
|
||||
},
|
||||
);
|
||||
let original = checked_fixture(RELATION_CONTEXT_SOURCE, &expected);
|
||||
// BoundaryExchange connects participant ports; CommunicationMean connects
|
||||
// their OA entity owners. Endpoint changes are explicit parts of this target,
|
||||
// not inferred or normalized away. Port records remain exactly unchanged.
|
||||
expected.relations.get_mut(&id(4)).unwrap().body = RelationBody::CommunicationMean {
|
||||
source: id(2),
|
||||
target: id(3),
|
||||
oriented: false,
|
||||
items: vec![],
|
||||
};
|
||||
let source = replacement(
|
||||
RELATION_CONTEXT_SOURCE,
|
||||
"boundary_exchange link",
|
||||
"communication_mean link",
|
||||
);
|
||||
let source = replacement(&source, "source @output;", "source @first;");
|
||||
let source = replacement(&source, "target @input;", "target @second;");
|
||||
let explicit = replacement(&source, "oriented true ;", "oriented false ;");
|
||||
// Three spaces between name/value plus one before the semicolon survive
|
||||
// syntax removal. The following rationale comment and all other bytes stay.
|
||||
let omitted = replacement(&source, "oriented true ;", " ");
|
||||
assert_context_update(&original, &expected, &[explicit, omitted]);
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
//! Public update witnesses for compact insertion and complete comparison.
|
||||
use oa_core::{language, source_project::*, *};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
fn compile(body: &str) -> CompiledSourceProject {
|
||||
compile_bundle(
|
||||
&SourceBundle {
|
||||
entry: "project.arcadia".into(),
|
||||
files: BTreeMap::from([
|
||||
(
|
||||
"project.arcadia".into(),
|
||||
"arcadia 7; project p \"P\" { include \"model.arcadia\"; }".into(),
|
||||
),
|
||||
("model.arcadia".into(), body.into()),
|
||||
]),
|
||||
},
|
||||
ProjectLimits::default(),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
fn id(project: &Project, alias: &str) -> Id {
|
||||
project
|
||||
.identities()
|
||||
.into_iter()
|
||||
.find(|(_, a)| *a == alias)
|
||||
.unwrap()
|
||||
.0
|
||||
}
|
||||
fn update(before: &CompiledSourceProject, project: &Project) -> CompiledSourceProject {
|
||||
let result = update_bundle(before, project, ProjectLimits::default()).unwrap();
|
||||
assert_eq!(result.decoded.project, *project);
|
||||
assert_eq!(
|
||||
result.bundle.files["project.arcadia"],
|
||||
before.bundle.files["project.arcadia"]
|
||||
);
|
||||
assert_eq!(
|
||||
compile_bundle(&result.bundle, ProjectLimits::default())
|
||||
.unwrap()
|
||||
.bundle,
|
||||
result.bundle
|
||||
);
|
||||
assert_eq!(
|
||||
update_bundle(&result, project, ProjectLimits::default())
|
||||
.unwrap()
|
||||
.bundle,
|
||||
result.bundle
|
||||
);
|
||||
result
|
||||
}
|
||||
fn words(text: &str) -> Vec<&str> {
|
||||
language::tokenize(text)
|
||||
.into_iter()
|
||||
.filter(|t| {
|
||||
!matches!(
|
||||
t.class,
|
||||
language::TokenClass::Whitespace | language::TokenClass::Comment
|
||||
)
|
||||
})
|
||||
.map(|t| &text[t.span.start..t.span.end])
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn omitted_exchange_body_changes_to_one_compact_action_constructor() {
|
||||
let before = compile(
|
||||
"oa_scenario s \"S\" { steps [@o]; }\noa_occurrence o \"O\" { parent @s; // keep this\n}\n",
|
||||
);
|
||||
let mut project = before.decoded.project.clone();
|
||||
let occurrence = id(&project, "o");
|
||||
let Some(SharedBody::Occurrence { body, .. }) =
|
||||
project.elements.get_mut(&occurrence).unwrap().shared_mut()
|
||||
else {
|
||||
panic!("occurrence")
|
||||
};
|
||||
assert!(matches!(body, OccurrenceBody::Exchange { .. }));
|
||||
*body = OccurrenceBody::Action {
|
||||
action: None,
|
||||
lifeline: None,
|
||||
};
|
||||
let result = update(&before, &project);
|
||||
let text = &result.bundle.files["model.arcadia"];
|
||||
let tokens = words(text);
|
||||
assert_eq!(tokens.iter().filter(|&&t| t == "body").count(), 1);
|
||||
assert!(tokens.contains(&"Action"));
|
||||
for absent in ["Exchange", "action", "lifeline", "operation", "description"] {
|
||||
assert!(!tokens.contains(&absent), "{absent}: {text}");
|
||||
}
|
||||
assert!(text.contains("parent @s; // keep this"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_signal_constructor_still_inserts_its_required_empty_items() {
|
||||
let before =
|
||||
compile("oa_event e \"E\" { body { Time { expression \"1 s\"; kind After; }; }; }\n");
|
||||
let mut project = before.decoded.project.clone();
|
||||
let event = id(&project, "e");
|
||||
let Some(SharedBody::Event { body }) = project.elements.get_mut(&event).unwrap().shared_mut()
|
||||
else {
|
||||
panic!("event")
|
||||
};
|
||||
*body = EventBody::Signal { items: vec![] };
|
||||
let result = update(&before, &project);
|
||||
let tokens = words(&result.bundle.files["model.arcadia"]);
|
||||
assert!(tokens.contains(&"Signal"));
|
||||
assert!(tokens.windows(4).any(|w| w == ["items", "[", "]", ";"]));
|
||||
assert!(!tokens.contains(&"expression"));
|
||||
assert!(!tokens.contains(&"kind"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scalar_to_default_string_record_retains_required_minimum_only() {
|
||||
let before = compile("oa_data_type t \"T\" { definition Boolean; // typed rationale\n}\n");
|
||||
let mut project = before.decoded.project.clone();
|
||||
let datatype = id(&project, "t");
|
||||
let Some(SharedBody::DataType { definition, .. }) =
|
||||
project.elements.get_mut(&datatype).unwrap().shared_mut()
|
||||
else {
|
||||
panic!("data type")
|
||||
};
|
||||
*definition = DataTypeDefinition::String {
|
||||
minimum_length: 0,
|
||||
maximum_length: None,
|
||||
};
|
||||
let result = update(&before, &project);
|
||||
let text = &result.bundle.files["model.arcadia"];
|
||||
let tokens = words(text);
|
||||
assert!(tokens.contains(&"String"));
|
||||
assert!(tokens.windows(3).any(|w| w == ["minimum_length", "0", ";"]));
|
||||
assert!(!tokens.contains(&"maximum_length"));
|
||||
assert!(text.contains("// typed rationale"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inserted_route_list_item_omits_only_optional_fields() {
|
||||
let before = compile(
|
||||
"oa_activity a \"A\" {}\noa_activity b \"B\" {}\ninteraction flow \"Flow\" { source @a; target @b; }\ndiagram d \"D\" { routes []; // routing rationale\n}\n",
|
||||
);
|
||||
let mut project = before.decoded.project.clone();
|
||||
let diagram = id(&project, "d");
|
||||
let edge = id(&project, "flow");
|
||||
// Deserialize only the explicit witness value, independently of omission policy.
|
||||
project.diagrams.get_mut(&diagram).unwrap().routes =
|
||||
serde_json::from_value(serde_json::json!([{"edge":edge,"waypoints":[],"label":null}]))
|
||||
.unwrap();
|
||||
let result = update(&before, &project);
|
||||
let text = &result.bundle.files["model.arcadia"];
|
||||
let tokens = words(text);
|
||||
assert!(tokens.windows(4).any(|w| w == ["edge", "@", "flow", ";"]));
|
||||
assert!(!tokens.contains(&"waypoints"));
|
||||
assert!(!tokens.contains(&"label"));
|
||||
assert!(text.contains("// routing rationale"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_sign_change_inserts_position_without_default_siblings() {
|
||||
let before = compile(
|
||||
"oa_activity a \"A\" {}\ndiagram d \"D\" {}\nnode n { diagram @d; element @a; // placement rationale\n}\n",
|
||||
);
|
||||
let mut project = before.decoded.project.clone();
|
||||
let diagram = id(&project, "d");
|
||||
let node = id(&project, "n");
|
||||
assert_eq!(
|
||||
project.diagrams[&diagram].nodes[&node].position.x.to_bits(),
|
||||
0.0_f32.to_bits()
|
||||
);
|
||||
project
|
||||
.diagrams
|
||||
.get_mut(&diagram)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&node)
|
||||
.unwrap()
|
||||
.position
|
||||
.x = -0.0;
|
||||
let result = update(&before, &project);
|
||||
assert_eq!(
|
||||
result.decoded.project.diagrams[&diagram].nodes[&node]
|
||||
.position
|
||||
.x
|
||||
.to_bits(),
|
||||
(-0.0_f32).to_bits()
|
||||
);
|
||||
let text = &result.bundle.files["model.arcadia"];
|
||||
let tokens = words(text);
|
||||
assert!(tokens.windows(3).any(|w| w == ["x", "-0.0", ";"]));
|
||||
for absent in ["size", "collapsed", "port_anchors"] {
|
||||
assert!(!tokens.contains(&absent));
|
||||
}
|
||||
assert!(text.contains("// placement rationale"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kind_change_materializes_unchanged_value_when_its_old_omission_default_changes() {
|
||||
let before = compile(
|
||||
"oa_entity a \"A\" {}\noa_entity b \"B\" {}\noa_port out \"Out\" { parent @a; contract Legacy; legacy { direction Out; }; }\noa_port input \"In\" { parent @b; contract Legacy; legacy { direction In; }; }\nboundary_exchange flow \"Flow\" { source @out; target @input; // orientation inherited\n}\n",
|
||||
);
|
||||
let mut project = before.decoded.project.clone();
|
||||
let flow = id(&project, "flow");
|
||||
assert!(matches!(
|
||||
project.relations[&flow].body,
|
||||
RelationBody::BoundaryExchange { oriented: true, .. }
|
||||
));
|
||||
project.relations.get_mut(&flow).unwrap().body = RelationBody::CommunicationMean {
|
||||
source: id(&before.decoded.project, "a"),
|
||||
target: id(&before.decoded.project, "b"),
|
||||
oriented: true,
|
||||
items: vec![],
|
||||
};
|
||||
let result = update(&before, &project);
|
||||
let text = &result.bundle.files["model.arcadia"];
|
||||
assert!(
|
||||
words(text)
|
||||
.windows(3)
|
||||
.any(|w| w == ["oriented", "true", ";"])
|
||||
);
|
||||
assert!(text.contains("// orientation inherited"));
|
||||
let before_ports = &before.bundle.files["model.arcadia"];
|
||||
assert_eq!(
|
||||
text.split("communication_mean").next().unwrap(),
|
||||
before_ports.split("boundary_exchange").next().unwrap()
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
use oa_core::{language, persistence, source_project::*, *};
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
const SOURCE: &str = r#"arcadia 7;
|
||||
project attachments "Connection ends" id "70000000-0000-4000-8000-000000000001" {
|
||||
oa_activity first "First" id "70000000-0000-4000-8000-000000000002" {}
|
||||
oa_activity second "Second" id "70000000-0000-4000-8000-000000000003" {}
|
||||
interaction signal "Signal" id "70000000-0000-4000-8000-000000000004" { source @first; target @second; }
|
||||
diagram view "View" id "70000000-0000-4000-8000-000000000005" {
|
||||
routes [{ edge @signal; waypoints [{ x 120; y 80; }];
|
||||
label { fraction 0.5; offset { x 3; y -7; }; };
|
||||
source_attachment { side Left; offset 0.25; };
|
||||
target_attachment { side Bottom; offset 1; };
|
||||
}];
|
||||
}
|
||||
diagram other "Other" id "70000000-0000-4000-8000-000000000006" { routes [{ edge @signal; }]; }
|
||||
}
|
||||
"#;
|
||||
|
||||
fn id(project: &Project, alias: &str) -> Id {
|
||||
project
|
||||
.identities()
|
||||
.into_iter()
|
||||
.find(|(_, a)| *a == alias)
|
||||
.unwrap()
|
||||
.0
|
||||
}
|
||||
|
||||
fn original() -> Project {
|
||||
language::parse(SOURCE).unwrap()
|
||||
}
|
||||
|
||||
fn set(diagram: Id, route: EdgeRoute) -> Command {
|
||||
Command::SetEdgeRoute { diagram, route }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edge_attachments_roundtrip_source_json_bundle_and_automatic_defaults() {
|
||||
let project = original();
|
||||
let view = id(&project, "view");
|
||||
let route = &project.diagrams[&view].routes[0];
|
||||
assert_eq!(
|
||||
route.source_attachment,
|
||||
Some(EdgeAttachment {
|
||||
side: PortAnchorSide::Left,
|
||||
offset: 0.25
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
route.target_attachment,
|
||||
Some(EdgeAttachment {
|
||||
side: PortAnchorSide::Bottom,
|
||||
offset: 1.0
|
||||
})
|
||||
);
|
||||
for text in [SOURCE.to_owned(), language::format(&project)] {
|
||||
assert_eq!(language::parse(&text).unwrap(), project);
|
||||
}
|
||||
assert_eq!(
|
||||
persistence::decode(&serde_json::to_vec(&project).unwrap()).unwrap(),
|
||||
project
|
||||
);
|
||||
assert_eq!(
|
||||
compile_bundle(&format_bundle(&project), ProjectLimits::default())
|
||||
.unwrap()
|
||||
.decoded
|
||||
.project,
|
||||
project
|
||||
);
|
||||
|
||||
let automatic = &project.diagrams[&id(&project, "other")].routes[0];
|
||||
assert_eq!(automatic.source_attachment, None);
|
||||
assert_eq!(automatic.target_attachment, None);
|
||||
let encoded = serde_json::to_value(automatic).unwrap();
|
||||
assert!(encoded.get("source_attachment").is_none());
|
||||
assert!(encoded.get("target_attachment").is_none());
|
||||
let mut with_null = encoded.clone();
|
||||
with_null["source_attachment"] = Value::Null;
|
||||
with_null["target_attachment"] = Value::Null;
|
||||
assert_eq!(
|
||||
serde_json::from_value::<EdgeRoute>(with_null).unwrap(),
|
||||
*automatic
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_value::<EdgeRoute>(encoded).unwrap(),
|
||||
*automatic
|
||||
);
|
||||
|
||||
// Old source without either new field still means automatic presentation.
|
||||
let old = SOURCE
|
||||
.replace(" source_attachment { side Left; offset 0.25; };\n", "")
|
||||
.replace(" target_attachment { side Bottom; offset 1; };\n", "");
|
||||
let old = language::parse(&old).unwrap();
|
||||
assert!(
|
||||
old.diagrams
|
||||
.values()
|
||||
.flat_map(|d| &d.routes)
|
||||
.all(|r| r.source_attachment.is_none() && r.target_attachment.is_none())
|
||||
);
|
||||
assert!(!language::format(&old).contains("_attachment"));
|
||||
assert_eq!(
|
||||
persistence::decode(&serde_json::to_vec(&old).unwrap()).unwrap(),
|
||||
old
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edge_attachments_reject_malformed_nested_source_and_json_without_defaults() {
|
||||
let project = original();
|
||||
let edge = id(&project, "signal");
|
||||
for bad in [
|
||||
json!({}),
|
||||
json!({"side":"Top"}),
|
||||
json!({"offset":0.5}),
|
||||
json!({"side":"Diagonal","offset":0.5}),
|
||||
json!({"side":"Top","offset":null}),
|
||||
json!({"side":"Top","offset":0.5,"unknown":true}),
|
||||
json!({"side":"Top","offset":"NaN"}),
|
||||
] {
|
||||
for field in ["source_attachment", "target_attachment"] {
|
||||
let mut route = json!({"edge":edge});
|
||||
route[field] = bad.clone();
|
||||
assert!(
|
||||
serde_json::from_value::<EdgeRoute>(route).is_err(),
|
||||
"{field}: {bad}"
|
||||
);
|
||||
}
|
||||
}
|
||||
for bad in [
|
||||
"{}",
|
||||
"{ side Top; }",
|
||||
"{ offset 0.5; }",
|
||||
"{ side Diagonal; offset 0.5; }",
|
||||
"{ side Top; offset null; }",
|
||||
"{ side Top; offset 0.5; unknown true; }",
|
||||
"{ side Top; offset -0.01; }",
|
||||
"{ side Top; offset 1.01; }",
|
||||
"{ side Top; offset NaN; }",
|
||||
"{ side Top; offset 1e999; }",
|
||||
] {
|
||||
let source = SOURCE.replace("{ side Left; offset 0.25; }", bad);
|
||||
assert!(language::parse(&source).is_err(), "{bad}");
|
||||
let bundle = SourceBundle {
|
||||
entry: "p.arcadia".into(),
|
||||
files: BTreeMap::from([("p.arcadia".into(), source)]),
|
||||
};
|
||||
assert!(
|
||||
compile_bundle(&bundle, ProjectLimits::default()).is_err(),
|
||||
"{bad}"
|
||||
);
|
||||
}
|
||||
for version in [1, 2] {
|
||||
let legacy =
|
||||
format!("arcadia {version}; project p \"P\" {{ diagram v \"V\" {{ routes []; }} }}");
|
||||
assert!(
|
||||
language::parse(&legacy).is_err(),
|
||||
"legacy route extension must not be silently admitted"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edge_attachments_nonfinite_and_out_of_range_commands_reject_atomically() {
|
||||
let project = original();
|
||||
let view = id(&project, "view");
|
||||
let first = id(&project, "first");
|
||||
for offset in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, -0.01, 1.01] {
|
||||
for end in [EdgeEnd::Source, EdgeEnd::Target] {
|
||||
let mut route = project.diagrams[&view].routes[0].clone();
|
||||
route.set_attachment(
|
||||
end,
|
||||
Some(EdgeAttachment {
|
||||
side: PortAnchorSide::Top,
|
||||
offset,
|
||||
}),
|
||||
);
|
||||
let mut session = Session::new(project.clone());
|
||||
assert!(
|
||||
session
|
||||
.execute(Command::Batch(vec![
|
||||
Command::Rename(first, "Must roll back".into()),
|
||||
set(view, route)
|
||||
]))
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(session.project(), &project);
|
||||
assert!(!session.can_undo() && !session.can_redo());
|
||||
}
|
||||
}
|
||||
for offset in [-0.01, 1.01] {
|
||||
let mut value = serde_json::to_value(&project).unwrap();
|
||||
value["diagrams"][view.to_string()]["routes"][0]["source_attachment"]["offset"] =
|
||||
json!(offset);
|
||||
assert!(persistence::decode(&serde_json::to_vec(&value).unwrap()).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edge_attachments_reset_one_end_and_all_routing_have_exact_history_and_view_scope() {
|
||||
let project = original();
|
||||
let view = id(&project, "view");
|
||||
let edge = id(&project, "signal");
|
||||
let mut session = Session::new(project.clone());
|
||||
let mut route = project.diagrams[&view].routes[0].clone();
|
||||
route.source_attachment = Some(EdgeAttachment {
|
||||
side: PortAnchorSide::Right,
|
||||
offset: 0.75,
|
||||
});
|
||||
let command = set(view, route.clone());
|
||||
assert_eq!(
|
||||
serde_json::from_value::<Command>(serde_json::to_value(&command).unwrap()).unwrap(),
|
||||
command
|
||||
);
|
||||
session.execute(command).unwrap();
|
||||
let mut expected = project.clone();
|
||||
expected.diagrams.get_mut(&view).unwrap().routes[0] = route.clone();
|
||||
assert_eq!(
|
||||
session.project(),
|
||||
&expected,
|
||||
"only this view/end changes; semantic fields and other view stay exact"
|
||||
);
|
||||
session.undo();
|
||||
assert_eq!(session.project(), &project);
|
||||
assert!(!session.can_undo() && session.can_redo());
|
||||
session.redo();
|
||||
assert_eq!(session.project(), &expected);
|
||||
route.source_attachment = None;
|
||||
session.execute(set(view, route.clone())).unwrap();
|
||||
let mut reset = expected.clone();
|
||||
reset.diagrams.get_mut(&view).unwrap().routes[0] = route;
|
||||
assert_eq!(
|
||||
session.project(),
|
||||
&reset,
|
||||
"opposite attachment, pins and label survive endpoint reset"
|
||||
);
|
||||
session.undo();
|
||||
assert_eq!(session.project(), &expected);
|
||||
session
|
||||
.execute(Command::RemoveEdgeRoute {
|
||||
diagram: view,
|
||||
edge,
|
||||
})
|
||||
.unwrap();
|
||||
let mut removed = expected.clone();
|
||||
removed.diagrams.get_mut(&view).unwrap().routes.clear();
|
||||
assert_eq!(session.project(), &removed);
|
||||
session.undo();
|
||||
assert_eq!(session.project(), &expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edge_attachments_nested_writeback_preserves_comments_adjacent_tokens_and_other_files() {
|
||||
let current = compile_bundle(&SourceBundle {
|
||||
entry: "p.arcadia".into(),
|
||||
files: BTreeMap::from([
|
||||
("p.arcadia".into(), "// project rationale\narcadia 7; project p \"P\" { include \"model.arcadia\"; include \"views.arcadia\"; }\n".into()),
|
||||
("model.arcadia".into(), "// untouched semantic ownership\noa_activity a \"A\" {}\noa_activity b \"B\" {}\ninteraction signal \"Signal\" { source @a; target @b; }\n".into()),
|
||||
("views.arcadia".into(), "// view rationale\r\ndiagram view \"View\" {\r\n routes [ { edge @signal; // endpoint explanation\r\n source_attachment { side Left; offset 0.25 ; // exact nested rationale\r\n }; target_attachment { side Bottom; offset 1; };\r\n waypoints [{ x 120; y 80; }]; label { fraction 0.5; offset { x 3; y -7; }; }; } ];\r\n}\r\ndiagram other \"Other\" { routes [{ edge @signal; }]; } // adjacent view\r\n".into()),
|
||||
]),
|
||||
}, ProjectLimits::default()).unwrap();
|
||||
let mut project = current.decoded.project.clone();
|
||||
let view = id(&project, "view");
|
||||
project.diagrams.get_mut(&view).unwrap().routes[0]
|
||||
.source_attachment
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.offset = 0.375;
|
||||
let changed = update_bundle(¤t, &project, ProjectLimits::default()).unwrap();
|
||||
assert_eq!(changed.decoded.project, project);
|
||||
for path in ["p.arcadia", "model.arcadia"] {
|
||||
assert_eq!(changed.bundle.files[path], current.bundle.files[path]);
|
||||
}
|
||||
assert_eq!(
|
||||
changed.bundle.files["views.arcadia"],
|
||||
current.bundle.files["views.arcadia"].replace("offset 0.25 ;", "offset 0.375 ;")
|
||||
);
|
||||
assert_eq!(
|
||||
compile_bundle(&changed.bundle, ProjectLimits::default())
|
||||
.unwrap()
|
||||
.bundle,
|
||||
changed.bundle
|
||||
);
|
||||
assert_eq!(
|
||||
update_bundle(&changed, &project, ProjectLimits::default())
|
||||
.unwrap()
|
||||
.bundle,
|
||||
changed.bundle
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,427 @@
|
||||
use oa_core::{diagram_edit::translate_placements, source_project::*, *};
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
fn point(x: f32, y: f32) -> Position {
|
||||
Position { x, y }
|
||||
}
|
||||
fn route(edge: Id, x: f32, y: f32) -> EdgeRoute {
|
||||
EdgeRoute {
|
||||
edge,
|
||||
source_attachment: Some(EdgeAttachment {
|
||||
side: PortAnchorSide::Bottom,
|
||||
offset: 0.2,
|
||||
}),
|
||||
target_attachment: Some(EdgeAttachment {
|
||||
side: PortAnchorSide::Top,
|
||||
offset: 0.8,
|
||||
}),
|
||||
waypoints: vec![point(x, y)],
|
||||
label: Some(EdgeLabel {
|
||||
fraction: 0.4,
|
||||
offset: point(3., -9.),
|
||||
}),
|
||||
}
|
||||
}
|
||||
struct Fixture {
|
||||
project: Project,
|
||||
diagram: Id,
|
||||
a: Id,
|
||||
b: Id,
|
||||
child: Id,
|
||||
foreign: Id,
|
||||
}
|
||||
fn fixture() -> Fixture {
|
||||
let mut p = Project::empty();
|
||||
let a = p.add_element(Kind::Activity, Layer::Oa, "A", None).unwrap();
|
||||
let b = p.add_element(Kind::Activity, Layer::Oa, "B", None).unwrap();
|
||||
let c = p.add_element(Kind::Activity, Layer::Oa, "C", None).unwrap();
|
||||
let child = p
|
||||
.add_element(Kind::Activity, Layer::Oa, "Child", Some(a))
|
||||
.unwrap();
|
||||
let internal = p.relate(RelationKind::Interaction, "Internal", a, b);
|
||||
let external = p.relate(RelationKind::Interaction, "External", b, c);
|
||||
let d = p.ensure_diagram(Layer::Oa);
|
||||
let na = p.place(d, a, point(10., 20.));
|
||||
let nb = p.place(d, b, point(400., 20.));
|
||||
let nc = p.place(d, child, point(30., 80.));
|
||||
p.place(d, c, point(800., 20.));
|
||||
p.diagrams.get_mut(&d).unwrap().routes =
|
||||
vec![route(internal, 320., 150.), route(external, 720., 170.)];
|
||||
p.diagrams
|
||||
.get_mut(&d)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&na)
|
||||
.unwrap()
|
||||
.collapsed = true;
|
||||
let other = Id::new_v4();
|
||||
let mut other_view = p.diagrams[&d].clone();
|
||||
other_view.id = other;
|
||||
other_view.alias = "other_view".into();
|
||||
other_view.nodes.clear();
|
||||
other_view.routes.clear();
|
||||
p.diagrams.insert(other, other_view);
|
||||
let foreign = p.place(other, a, point(-40., -50.));
|
||||
validate_candidate(&p).unwrap();
|
||||
Fixture {
|
||||
project: p,
|
||||
diagram: d,
|
||||
a: na,
|
||||
b: nb,
|
||||
child: nc,
|
||||
foreign,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn simultaneous_siblings_translate_internal_pins_once_and_preserve_complete_project() {
|
||||
let f = fixture();
|
||||
let before = f.project.clone();
|
||||
let command = translate_placements(
|
||||
&before,
|
||||
f.diagram,
|
||||
&BTreeSet::from([f.a, f.b]),
|
||||
point(40., 60.),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
before, f.project,
|
||||
"command construction must not mutate input"
|
||||
);
|
||||
// Independently enumerated expected edits, without the projection helpers.
|
||||
let mut expected = before.clone();
|
||||
let view = expected.diagrams.get_mut(&f.diagram).unwrap();
|
||||
view.nodes.get_mut(&f.a).unwrap().position = point(50., 80.);
|
||||
view.nodes.get_mut(&f.b).unwrap().position = point(440., 80.);
|
||||
view.nodes.get_mut(&f.child).unwrap().position = point(70., 140.);
|
||||
view.routes[0].waypoints = vec![point(360., 210.)];
|
||||
let mut session = Session::new(before.clone());
|
||||
session.execute(command.clone()).unwrap();
|
||||
assert_eq!(session.project(), &expected);
|
||||
session.undo();
|
||||
assert_eq!(session.project(), &before);
|
||||
assert!(
|
||||
!session.can_undo(),
|
||||
"one movement must be one history entry"
|
||||
);
|
||||
session.redo();
|
||||
assert_eq!(session.project(), &expected);
|
||||
assert!(!session.can_redo());
|
||||
|
||||
// Independent one-node moves cannot see the shared endpoint displacement.
|
||||
let mut sequential = Session::new(before.clone());
|
||||
for (node, position) in [(f.a, point(50., 80.)), (f.b, point(440., 80.))] {
|
||||
sequential
|
||||
.execute(Command::MoveNode {
|
||||
diagram: f.diagram,
|
||||
node,
|
||||
position,
|
||||
with_descendants: true,
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
assert_eq!(
|
||||
sequential.project().diagrams[&f.diagram].routes[0],
|
||||
before.diagrams[&f.diagram].routes[0]
|
||||
);
|
||||
assert_ne!(sequential.project(), &expected);
|
||||
|
||||
let json = serde_json::to_string(&expected).unwrap();
|
||||
assert_eq!(serde_json::from_str::<Project>(&json).unwrap(), expected);
|
||||
let mut bundle = format_bundle(&before);
|
||||
bundle
|
||||
.files
|
||||
.get_mut(&bundle.entry.clone())
|
||||
.unwrap()
|
||||
.insert_str(0, "// preserve movement rationale\n");
|
||||
let original = compile_bundle(&bundle, ProjectLimits::default()).unwrap();
|
||||
let prepared = prepare_command(&before, &command).unwrap();
|
||||
let updated = update_bundle_with_identity_map(
|
||||
&original,
|
||||
&prepared.project,
|
||||
&prepared.identity_map,
|
||||
ProjectLimits::default(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(updated.decoded.project, expected);
|
||||
assert!(updated.bundle.files[&bundle.entry].starts_with("// preserve movement rationale\n"));
|
||||
let reread = compile_bundle(&updated.bundle, ProjectLimits::default()).unwrap();
|
||||
assert_eq!(reread.decoded.project, expected);
|
||||
assert_eq!(reread.bundle, updated.bundle);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selecting_folded_ancestor_and_hidden_descendant_moves_each_once() {
|
||||
let f = fixture();
|
||||
assert!(
|
||||
!projection::project_view(&f.project, f.diagram)
|
||||
.nodes
|
||||
.iter()
|
||||
.any(|n| n.id == f.child)
|
||||
);
|
||||
let ancestor = translate_placements(
|
||||
&f.project,
|
||||
f.diagram,
|
||||
&BTreeSet::from([f.a]),
|
||||
point(-15., 25.),
|
||||
)
|
||||
.unwrap();
|
||||
let overlap = translate_placements(
|
||||
&f.project,
|
||||
f.diagram,
|
||||
&BTreeSet::from([f.a, f.child]),
|
||||
point(-15., 25.),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(overlap, ancestor);
|
||||
let after = prepare_command(&f.project, &overlap).unwrap().project;
|
||||
assert_eq!(
|
||||
after.diagrams[&f.diagram].nodes[&f.child].position,
|
||||
point(15., 105.)
|
||||
);
|
||||
assert_eq!(
|
||||
after.diagrams[&f.diagram].routes,
|
||||
f.project.diagrams[&f.diagram].routes
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_movements_refuse_atomically_and_zero_has_no_history() {
|
||||
let f = fixture();
|
||||
let selected = BTreeSet::from([f.a, f.b]);
|
||||
for (diagram, ids, delta) in [
|
||||
(Id::new_v4(), selected.clone(), point(1., 1.)),
|
||||
(f.diagram, BTreeSet::new(), point(1., 1.)),
|
||||
(
|
||||
f.diagram,
|
||||
BTreeSet::from([f.a, Id::new_v4()]),
|
||||
point(1., 1.),
|
||||
),
|
||||
(f.diagram, BTreeSet::from([f.a, f.foreign]), point(0., 0.)),
|
||||
(f.diagram, selected.clone(), point(f32::NAN, 1.)),
|
||||
(f.diagram, selected.clone(), point(1., f32::INFINITY)),
|
||||
(f.diagram, selected.clone(), point(MAX_COORDINATE, 0.)),
|
||||
(f.diagram, selected.clone(), point(f32::MAX, f32::MAX)),
|
||||
] {
|
||||
let before = f.project.clone();
|
||||
assert!(translate_placements(&f.project, diagram, &ids, delta).is_err());
|
||||
assert_eq!(f.project, before);
|
||||
}
|
||||
let mut bad_route = f.project.clone();
|
||||
bad_route.diagrams.get_mut(&f.diagram).unwrap().routes[0].waypoints =
|
||||
vec![point(MAX_COORDINATE, 0.)];
|
||||
validate_candidate(&bad_route).unwrap();
|
||||
let snapshot = bad_route.clone();
|
||||
assert!(translate_placements(&bad_route, f.diagram, &selected, point(1., 0.)).is_err());
|
||||
assert_eq!(bad_route, snapshot);
|
||||
let zero = translate_placements(&f.project, f.diagram, &selected, point(0., -0.)).unwrap();
|
||||
assert_eq!(zero, Command::Batch(vec![]));
|
||||
let mut session = Session::new(f.project.clone());
|
||||
session.execute(zero).unwrap();
|
||||
assert_eq!(session.project(), &f.project);
|
||||
assert!(!session.can_undo());
|
||||
assert_eq!(session.revision(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typed_component_port_endpoints_follow_their_owner_placements() {
|
||||
let mut p = Project::empty();
|
||||
let a = p
|
||||
.add_element(Kind::System, Layer::Sa, "System", None)
|
||||
.unwrap();
|
||||
let b = p
|
||||
.add_element(Kind::SystemActor, Layer::Sa, "Actor", None)
|
||||
.unwrap();
|
||||
let output = Id::new_v4();
|
||||
let input = Id::new_v4();
|
||||
for (id, owner, name, orientation) in [
|
||||
(output, a, "Output", ComponentPortOrientation::Out),
|
||||
(input, b, "Input", ComponentPortOrientation::In),
|
||||
] {
|
||||
let port = make_port(
|
||||
&p,
|
||||
id,
|
||||
owner,
|
||||
name,
|
||||
PortContract::Component {
|
||||
kind: ComponentPortKind::Flow,
|
||||
orientation,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
p.elements.insert(id, port);
|
||||
}
|
||||
let edge = p.relate(RelationKind::BoundaryExchange, "Boundary", output, input);
|
||||
let d = p.ensure_diagram(Layer::Sa);
|
||||
let na = p.place(d, a, point(0., 0.));
|
||||
let nb = p.place(d, b, point(400., 0.));
|
||||
for (node, port, side, offset) in [
|
||||
(na, output, PortAnchorSide::Bottom, 0.3),
|
||||
(nb, input, PortAnchorSide::Top, 0.7),
|
||||
] {
|
||||
p.diagrams
|
||||
.get_mut(&d)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&node)
|
||||
.unwrap()
|
||||
.port_anchors
|
||||
.push(PortAnchor { port, side, offset });
|
||||
}
|
||||
p.diagrams
|
||||
.get_mut(&d)
|
||||
.unwrap()
|
||||
.routes
|
||||
.push(route(edge, 300., 100.));
|
||||
validate_candidate(&p).unwrap();
|
||||
let command = translate_placements(&p, d, &BTreeSet::from([na, nb]), point(10., -20.)).unwrap();
|
||||
let after = prepare_command(&p, &command).unwrap().project;
|
||||
let mut expected = p.clone();
|
||||
let view = expected.diagrams.get_mut(&d).unwrap();
|
||||
view.nodes.get_mut(&na).unwrap().position = point(10., -20.);
|
||||
view.nodes.get_mut(&nb).unwrap().position = point(410., -20.);
|
||||
view.routes[0].waypoints = vec![point(310., 80.)];
|
||||
assert_eq!(after, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allocation_movement_follows_visual_performers_and_same_performer_work_groups() {
|
||||
let mut p = Project::empty();
|
||||
let performer_a = p
|
||||
.add_element(Kind::Entity, Layer::Oa, "Performer A", None)
|
||||
.unwrap();
|
||||
let performer_b = p
|
||||
.add_element(Kind::Entity, Layer::Oa, "Performer B", None)
|
||||
.unwrap();
|
||||
let mixed = p
|
||||
.add_element(Kind::Activity, Layer::Oa, "Mixed work", None)
|
||||
.unwrap();
|
||||
let branch = p
|
||||
.add_element(Kind::Activity, Layer::Oa, "A work", Some(mixed))
|
||||
.unwrap();
|
||||
let leaf_a = p
|
||||
.add_element(Kind::Activity, Layer::Oa, "A leaf", Some(branch))
|
||||
.unwrap();
|
||||
let leaf_b = p
|
||||
.add_element(Kind::Activity, Layer::Oa, "B leaf", Some(mixed))
|
||||
.unwrap();
|
||||
p.relate(RelationKind::Allocation, "A performs", performer_a, leaf_a);
|
||||
p.relate(RelationKind::Allocation, "B performs", performer_b, leaf_b);
|
||||
let d = p.ensure_diagram(Layer::Oa);
|
||||
p.diagrams.get_mut(&d).unwrap().projection = ProjectionKind::Allocation;
|
||||
let na = p.place(d, performer_a, point(0., 0.));
|
||||
p.place(d, performer_b, point(600., 0.));
|
||||
let nm = p.place(d, mixed, point(1000., 0.));
|
||||
let branch_node = p.place(d, branch, point(20., 80.));
|
||||
let leaf_node = p.place(d, leaf_a, point(40., 160.));
|
||||
p.place(d, leaf_b, point(620., 80.));
|
||||
validate_candidate(&p).unwrap();
|
||||
|
||||
// Mixed work retains semantic ownership of both branches, but its leaves
|
||||
// have different performers. A work is visually nested under Performer A,
|
||||
// with its own same-performer leaf nested inside it.
|
||||
let command =
|
||||
translate_placements(&p, d, &BTreeSet::from([na, branch_node]), point(15., -25.)).unwrap();
|
||||
let after = prepare_command(&p, &command).unwrap().project;
|
||||
let mut expected = p.clone();
|
||||
let view = expected.diagrams.get_mut(&d).unwrap();
|
||||
view.nodes.get_mut(&na).unwrap().position = point(15., -25.);
|
||||
view.nodes.get_mut(&branch_node).unwrap().position = point(35., 55.);
|
||||
view.nodes.get_mut(&leaf_node).unwrap().position = point(55., 135.);
|
||||
assert_eq!(after, expected);
|
||||
|
||||
// Moving the semantic parent does not move children displayed elsewhere.
|
||||
let command = translate_placements(&p, d, &BTreeSet::from([nm]), point(15., -25.)).unwrap();
|
||||
let after = prepare_command(&p, &command).unwrap().project;
|
||||
let mut expected = p.clone();
|
||||
expected
|
||||
.diagrams
|
||||
.get_mut(&d)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&nm)
|
||||
.unwrap()
|
||||
.position = point(1015., -25.);
|
||||
assert_eq!(after, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn traceability_movement_is_flat_even_for_a_semantic_parent() {
|
||||
let mut f = fixture();
|
||||
f.project.diagrams.get_mut(&f.diagram).unwrap().projection = ProjectionKind::Traceability;
|
||||
validate_candidate(&f.project).unwrap();
|
||||
let command = translate_placements(
|
||||
&f.project,
|
||||
f.diagram,
|
||||
&BTreeSet::from([f.a]),
|
||||
point(40., 60.),
|
||||
)
|
||||
.unwrap();
|
||||
let after = prepare_command(&f.project, &command).unwrap().project;
|
||||
let mut expected = f.project.clone();
|
||||
expected
|
||||
.diagrams
|
||||
.get_mut(&f.diagram)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&f.a)
|
||||
.unwrap()
|
||||
.position = point(50., 80.);
|
||||
assert_eq!(
|
||||
after, expected,
|
||||
"flat view movement must preserve semantic descendants, routes, other views and all semantics"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_entry_pins_follow_the_boundary_only_when_it_also_moves() {
|
||||
let mut p = Project::empty();
|
||||
let machine = p
|
||||
.add_element(Kind::StateMachine, Layer::Oa, "Machine", None)
|
||||
.unwrap();
|
||||
let region = p
|
||||
.add_element(Kind::Region, Layer::Oa, "Region", Some(machine))
|
||||
.unwrap();
|
||||
let composite = p
|
||||
.add_element(Kind::State, Layer::Oa, "Composite", Some(region))
|
||||
.unwrap();
|
||||
let inner = p
|
||||
.add_element(Kind::Region, Layer::Oa, "Inner", Some(composite))
|
||||
.unwrap();
|
||||
let entry = p
|
||||
.add_element(Kind::EntryPoint, Layer::Oa, "Entry", Some(inner))
|
||||
.unwrap();
|
||||
let leaf = p
|
||||
.add_element(Kind::State, Layer::Oa, "Leaf", Some(inner))
|
||||
.unwrap();
|
||||
let edge = p.relate(RelationKind::StateTransition, "Enter", entry, leaf);
|
||||
if let RelationBody::StateTransition {
|
||||
transition_kind, ..
|
||||
} = &mut p.relations.get_mut(&edge).unwrap().body
|
||||
{
|
||||
*transition_kind = TransitionKind::Local;
|
||||
}
|
||||
let d = p.ensure_diagram(Layer::Oa);
|
||||
p.diagrams.get_mut(&d).unwrap().projection = ProjectionKind::StateMachine { machine };
|
||||
let boundary = p.place(d, composite, point(0., 0.));
|
||||
let ne = p.place(d, entry, point(20., 80.));
|
||||
let nl = p.place(d, leaf, point(100., 80.));
|
||||
p.diagrams
|
||||
.get_mut(&d)
|
||||
.unwrap()
|
||||
.routes
|
||||
.push(route(edge, 90., 100.));
|
||||
validate_candidate(&p).unwrap();
|
||||
let endpoints =
|
||||
translate_placements(&p, d, &BTreeSet::from([ne, nl]), point(10., 20.)).unwrap();
|
||||
let after = prepare_command(&p, &endpoints).unwrap().project;
|
||||
assert_eq!(after.diagrams[&d].routes, p.diagrams[&d].routes);
|
||||
let with_boundary =
|
||||
translate_placements(&p, d, &BTreeSet::from([boundary, ne, nl]), point(10., 20.)).unwrap();
|
||||
let after = prepare_command(&p, &with_boundary).unwrap().project;
|
||||
assert_eq!(
|
||||
after.diagrams[&d].routes[0].waypoints,
|
||||
vec![point(100., 120.)]
|
||||
);
|
||||
assert_eq!(after.diagrams[&d].nodes[&ne].position, point(30., 100.));
|
||||
}
|
||||
@@ -3,6 +3,16 @@ use oa_core::{language, persistence, validation, *};
|
||||
fn read(source: &str) -> Project {
|
||||
language::parse(source).unwrap_or_else(|e| panic!("{e:?}\n{source}"))
|
||||
}
|
||||
fn replace_once(source: &str, before: &str, after: &str) -> String {
|
||||
assert_eq!(
|
||||
source.matches(before).count(),
|
||||
1,
|
||||
"unique mutation anchor {before:?}"
|
||||
);
|
||||
let changed = source.replacen(before, after, 1);
|
||||
assert_ne!(changed, source, "test mutation must change the source");
|
||||
changed
|
||||
}
|
||||
fn rejected(source: &str, fragment: &str) {
|
||||
let errors = language::parse(source).expect_err(source);
|
||||
assert!(
|
||||
@@ -753,8 +763,8 @@ fn typed_events_vertices_and_transition_kinds_round_trip_in_both_layers() {
|
||||
source.push('}');
|
||||
let model = read(&source);
|
||||
let canonical = language::format(&model);
|
||||
assert!(!canonical.contains("transition_kind External;"));
|
||||
for fragment in [
|
||||
"transition_kind External;",
|
||||
"transition_kind Local;",
|
||||
"transition_kind Internal;",
|
||||
"triggers [@deadline, @assess];",
|
||||
@@ -776,6 +786,7 @@ fn typed_events_vertices_and_transition_kinds_round_trip_in_both_layers() {
|
||||
.find(|r| r.alias == "recover")
|
||||
.unwrap();
|
||||
let RelationBody::StateTransition {
|
||||
transition_kind,
|
||||
trigger,
|
||||
guard,
|
||||
effect,
|
||||
@@ -784,32 +795,42 @@ fn typed_events_vertices_and_transition_kinds_round_trip_in_both_layers() {
|
||||
else {
|
||||
panic!("expected transition")
|
||||
};
|
||||
assert_eq!(*transition_kind, TransitionKind::External);
|
||||
assert_eq!(trigger, "Operator confirms\nНе запускать код");
|
||||
assert_eq!(guard, "available && status == \"Ready\"");
|
||||
assert_eq!(effect, "Record the assessment");
|
||||
// External is omitted in compact output. Insert an explicit invalid
|
||||
// discriminator into Recover using its unique, nondefault trigger list.
|
||||
let invalid_kind = replace_once(
|
||||
&canonical,
|
||||
"triggers [@deadline, @assess];",
|
||||
"transition_kind Unsupported;\n triggers [@deadline, @assess];",
|
||||
);
|
||||
rejected(&invalid_kind, "Unsupported");
|
||||
for invalid in [
|
||||
canonical.replace("transition_kind External;", "transition_kind Unsupported;"),
|
||||
canonical.replace(
|
||||
replace_once(
|
||||
&canonical,
|
||||
"triggers [@deadline, @assess];",
|
||||
"triggers [\"deadline\", @assess];",
|
||||
),
|
||||
canonical.replace(
|
||||
replace_once(
|
||||
&canonical,
|
||||
"triggers [@deadline, @assess];",
|
||||
"triggers [@deadline, @deadline];",
|
||||
),
|
||||
canonical.replace("effects [@assess];", "effects [@payload];"),
|
||||
canonical.replace("kind After;", "kind Relative;"),
|
||||
canonical.replace("items [@payload];", "items [@assess];"),
|
||||
replace_once(&canonical, "effects [@assess];", "effects [@payload];"),
|
||||
replace_once(&canonical, "kind After;", "kind Relative;"),
|
||||
replace_once(&canonical, "items [@payload];", "items [@assess];"),
|
||||
] {
|
||||
assert!(
|
||||
language::parse(&invalid).is_err(),
|
||||
"invalid typed state/event source accepted"
|
||||
);
|
||||
}
|
||||
let legacy = canonical
|
||||
.replace("transition_kind External;", "")
|
||||
.replace("triggers [@deadline, @assess];", "")
|
||||
.replace("effects [@assess];", "");
|
||||
// Canonical source already omits External. Removing the supplied lists
|
||||
// independently exercises their empty omission defaults and retained prose.
|
||||
let legacy = replace_once(&canonical, "triggers [@deadline, @assess];", "");
|
||||
let legacy = replace_once(&legacy, "effects [@assess];", "");
|
||||
let legacy = read(&legacy);
|
||||
let RelationBody::StateTransition {
|
||||
transition_kind,
|
||||
@@ -829,7 +850,7 @@ fn typed_events_vertices_and_transition_kinds_round_trip_in_both_layers() {
|
||||
(retained_trigger, retained_guard, retained_effect),
|
||||
(trigger, guard, effect)
|
||||
);
|
||||
let draft = canonical.replace("expression \"30 s\";", "expression \"\";");
|
||||
let draft = replace_once(&canonical, "expression \"30 s\";", "expression \"\";");
|
||||
assert!(language::parse(&draft).is_ok());
|
||||
let deadline = model
|
||||
.elements
|
||||
@@ -1038,7 +1059,7 @@ fn original_v2_text_and_json_preserve_identity_and_mixed_scenarios_after_extensi
|
||||
"fixture must cover actual old scenario content"
|
||||
);
|
||||
let canonical = language::format(&from_source);
|
||||
assert!(canonical.contains("viewpoint MixedLegacy;"));
|
||||
assert!(!canonical.contains("viewpoint MixedLegacy;"));
|
||||
for relation in from_source.relations.values() {
|
||||
if let RelationBody::StateTransition {
|
||||
transition_kind,
|
||||
|
||||
@@ -441,6 +441,8 @@ fn nested_owner_bounds_group_movement_and_authored_routes_share_the_same_geometr
|
||||
diagram: d,
|
||||
route: EdgeRoute {
|
||||
edge,
|
||||
source_attachment: None,
|
||||
target_attachment: None,
|
||||
waypoints: vec![pos(500., 420.)],
|
||||
label: None,
|
||||
},
|
||||
@@ -767,7 +769,15 @@ fn resetting_last_source_anchors_preserves_comments_and_can_add_a_new_manual_rec
|
||||
assert_eq!(cleared.decoded.project, prepared.project);
|
||||
let text = &cleared.bundle.files["project.arcadia"];
|
||||
assert!(text.contains("// first rationale") && text.contains("// second rationale"));
|
||||
assert!(!text.contains("port_anchors"));
|
||||
// Lossless editing may retain the authored empty field and its annotations.
|
||||
// Canonical export, rather than a model update, normalizes default syntax.
|
||||
assert!(!language::format(&cleared.decoded.project).contains("port_anchors"));
|
||||
assert_eq!(
|
||||
update_bundle(&cleared, &cleared.decoded.project, ProjectLimits::default())
|
||||
.unwrap()
|
||||
.bundle,
|
||||
cleared.bundle
|
||||
);
|
||||
let prepared = prepare_command(
|
||||
&cleared.decoded.project,
|
||||
&Command::SetPortAnchor {
|
||||
|
||||
@@ -41,7 +41,6 @@ fn fixture() -> Fixture {
|
||||
projection: ProjectionKind::Overview,
|
||||
view_options: Default::default(),
|
||||
nodes: BTreeMap::new(),
|
||||
layout: None,
|
||||
routes: vec![],
|
||||
},
|
||||
);
|
||||
@@ -71,6 +70,8 @@ fn fixture() -> Fixture {
|
||||
fn route(edge: Id) -> EdgeRoute {
|
||||
EdgeRoute {
|
||||
edge,
|
||||
source_attachment: None,
|
||||
target_attachment: None,
|
||||
waypoints: vec![Position { x: 550., y: 240. }, Position { x: 550., y: 360. }],
|
||||
label: Some(EdgeLabel {
|
||||
fraction: 0.4,
|
||||
@@ -690,6 +691,8 @@ fn old_models_get_empty_routes_and_route_json_is_strict() {
|
||||
serde_json::from_value::<EdgeRoute>(minimal).unwrap(),
|
||||
EdgeRoute {
|
||||
edge: f.edge,
|
||||
source_attachment: None,
|
||||
target_attachment: None,
|
||||
waypoints: vec![],
|
||||
label: None
|
||||
}
|
||||
|
||||
@@ -27,6 +27,8 @@ fn route(p: &Project, d: Id) -> RoutedView {
|
||||
fn set_route(p: &mut Project, d: Id, edge: Id, pins: Vec<Position>, label: Option<EdgeLabel>) {
|
||||
p.diagrams.get_mut(&d).unwrap().routes.push(EdgeRoute {
|
||||
edge,
|
||||
source_attachment: None,
|
||||
target_attachment: None,
|
||||
waypoints: pins,
|
||||
label,
|
||||
});
|
||||
|
||||
@@ -117,6 +117,8 @@ fn authored_overlap_is_exact_and_reset_only_repositions_automatic_text() {
|
||||
.routes
|
||||
.push(EdgeRoute {
|
||||
edge: fe,
|
||||
source_attachment: None,
|
||||
target_attachment: None,
|
||||
waypoints: vec![],
|
||||
label: Some(authored.clone()),
|
||||
});
|
||||
|
||||
@@ -261,7 +261,7 @@ fn source7_context_lifecycle_source_history_and_closed_admission() {
|
||||
assert_eq!(session.project(), &after);
|
||||
let fresh = Id::new_v4();
|
||||
let mut reidentified = after.clone();
|
||||
references::remap_references(&mut reidentified, f.r1, fresh).unwrap();
|
||||
references::remap_references(&mut reidentified, f.r1, fresh);
|
||||
let mut e = reidentified.elements.remove(&f.r1).unwrap();
|
||||
e.id = fresh;
|
||||
reidentified.elements.insert(fresh, e);
|
||||
|
||||
@@ -383,3 +383,87 @@ fn atomic_save_preserves_existing_unix_permissions() {
|
||||
0o640
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discovery_diagnostics_keep_raw_no_uuid_spans() {
|
||||
let directory = setup();
|
||||
let path = directory.path().join("parts/a.arcadia");
|
||||
let raw =
|
||||
"// Unicode: 航海\nsa_function first \"First\" {}\nsa_function second \"Second\" {}\n";
|
||||
fs::write(&path, raw).unwrap();
|
||||
let disk = load_project(
|
||||
&directory.path().join("project.arcadia"),
|
||||
ProjectLimits::default(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(disk.bundle.files["parts/a.arcadia"], raw);
|
||||
let issue = disk
|
||||
.diagnostics
|
||||
.iter()
|
||||
.find(|issue| {
|
||||
issue.diagnostic.code == "unallocated_leaf"
|
||||
&& issue.diagnostic.message.contains("Second")
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(issue.path, "parts/a.arcadia");
|
||||
assert_eq!(issue.diagnostic.line, 3);
|
||||
assert_eq!(issue.diagnostic.column, 1);
|
||||
let span = issue.diagnostic.span;
|
||||
assert_eq!(span.start, raw.find("sa_function second").unwrap());
|
||||
assert_eq!(
|
||||
&raw[span.start..span.end],
|
||||
"sa_function second \"Second\" {}"
|
||||
);
|
||||
let compiled = compile_bundle(&disk.reachable_bundle(), ProjectLimits::default()).unwrap();
|
||||
assert_ne!(compiled.bundle, disk.bundle);
|
||||
assert!(compiled.diagnostics.iter().any(|later| {
|
||||
later.diagnostic.code == issue.diagnostic.code
|
||||
&& later.diagnostic.message == issue.diagnostic.message
|
||||
&& later.diagnostic.span.start > span.start
|
||||
}));
|
||||
assert_eq!(fs::read_to_string(path).unwrap(), raw);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discovery_diagnoses_legacy_semantics_before_source_rewrite() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let path = directory.path().join("project.arcadia");
|
||||
let raw = include_str!("fixtures/v3-data-contract.arcadia");
|
||||
fs::write(&path, raw).unwrap();
|
||||
let disk = load_project(&path, ProjectLimits::default()).unwrap();
|
||||
assert_eq!(disk.bundle.files["project.arcadia"], raw);
|
||||
assert!(!disk.diagnostics.is_empty());
|
||||
assert!(
|
||||
disk.diagnostics
|
||||
.iter()
|
||||
.all(|issue| { issue.diagnostic.severity == oa_core::validation::Severity::Warning })
|
||||
);
|
||||
assert_eq!(
|
||||
disk.diagnostics,
|
||||
lint_bundle(&disk.reachable_bundle(), ProjectLimits::default())
|
||||
);
|
||||
let compiled = compile_bundle(&disk.reachable_bundle(), ProjectLimits::default()).unwrap();
|
||||
assert_eq!(compiled.decoded.migration.as_ref().unwrap().from_schema, 3);
|
||||
assert_ne!(compiled.bundle, disk.bundle);
|
||||
for issue in &disk.diagnostics {
|
||||
assert!(compiled.diagnostics.iter().any(|later| {
|
||||
later.diagnostic.code == issue.diagnostic.code
|
||||
&& later.diagnostic.object_id == issue.diagnostic.object_id
|
||||
&& later.diagnostic.message == issue.diagnostic.message
|
||||
}));
|
||||
let span = issue.diagnostic.span;
|
||||
assert!(raw.get(span.start..span.end).is_some());
|
||||
}
|
||||
// Invalid legacy references still fail during semantic resolution on the
|
||||
// authored bytes; deferring the source rewrite does not hide those errors.
|
||||
let invalid = raw.replace("@coordinate", "@missing");
|
||||
fs::write(&path, &invalid).unwrap();
|
||||
let broken = load_project(&path, ProjectLimits::default()).unwrap();
|
||||
assert!(broken.diagnostics.iter().any(|issue| {
|
||||
issue.diagnostic.severity == oa_core::validation::Severity::Error
|
||||
&& issue.diagnostic.message.contains("@missing")
|
||||
}));
|
||||
assert_eq!(broken.bundle.files["project.arcadia"], invalid);
|
||||
assert!(compile_bundle(&broken.reachable_bundle(), ProjectLimits::default()).is_err());
|
||||
assert_eq!(fs::read_to_string(path).unwrap(), invalid);
|
||||
}
|
||||
|
||||
@@ -20,7 +20,6 @@ fn view(p: &mut Project, machine: Id, alias: &str) -> Id {
|
||||
projection: ProjectionKind::StateMachine { machine },
|
||||
view_options: Default::default(),
|
||||
nodes: BTreeMap::new(),
|
||||
layout: None,
|
||||
routes: vec![],
|
||||
},
|
||||
);
|
||||
@@ -302,6 +301,8 @@ fn folding_retains_authored_routes_without_false_self_transitions() {
|
||||
let pins = vec![point(690., 480.), point(690., 230.)];
|
||||
f.p.diagrams.get_mut(&f.d).unwrap().routes.push(EdgeRoute {
|
||||
edge: out,
|
||||
source_attachment: None,
|
||||
target_attachment: None,
|
||||
waypoints: pins.clone(),
|
||||
label: None,
|
||||
});
|
||||
@@ -363,11 +364,15 @@ fn grouped_moves_translate_hidden_descendants_and_pins_only_in_one_view() {
|
||||
let pins = vec![point(570., 460.)];
|
||||
f.p.diagrams.get_mut(&f.d).unwrap().routes.push(EdgeRoute {
|
||||
edge: f.local,
|
||||
source_attachment: None,
|
||||
target_attachment: None,
|
||||
waypoints: pins.clone(),
|
||||
label: Some(label.clone()),
|
||||
});
|
||||
f.p.diagrams.get_mut(&f.d).unwrap().routes.push(EdgeRoute {
|
||||
edge: f.external,
|
||||
source_attachment: None,
|
||||
target_attachment: None,
|
||||
waypoints: vec![point(690., 230.)],
|
||||
label: None,
|
||||
});
|
||||
@@ -423,6 +428,8 @@ fn cross_machine_reparent_requires_atomic_placement_and_route_repair() {
|
||||
let placement = f.p.place(f.d, orphan, point(30., 750.));
|
||||
f.p.diagrams.get_mut(&f.d).unwrap().routes.push(EdgeRoute {
|
||||
edge,
|
||||
source_attachment: None,
|
||||
target_attachment: None,
|
||||
waypoints: vec![],
|
||||
label: None,
|
||||
});
|
||||
@@ -457,6 +464,8 @@ fn cross_machine_reparent_requires_atomic_placement_and_route_repair() {
|
||||
diagram: other,
|
||||
route: EdgeRoute {
|
||||
edge,
|
||||
source_attachment: None,
|
||||
target_attachment: None,
|
||||
waypoints: vec![],
|
||||
label: None,
|
||||
},
|
||||
@@ -584,6 +593,8 @@ fn layout_reserves_internal_rows_and_state_routes_retain_hard_pins() {
|
||||
let pins = vec![point(730., 130.), point(1060., 130.)];
|
||||
f.p.diagrams.get_mut(&f.d).unwrap().routes.push(EdgeRoute {
|
||||
edge: f.self_edge,
|
||||
source_attachment: None,
|
||||
target_attachment: None,
|
||||
waypoints: pins.clone(),
|
||||
label: Some(EdgeLabel {
|
||||
fraction: 0.25,
|
||||
@@ -653,6 +664,8 @@ fn moving_a_region_does_not_translate_pins_attached_to_a_stationary_state_bounda
|
||||
let pins = vec![point(280., 390.), point(280., 470.)];
|
||||
f.p.diagrams.get_mut(&f.d).unwrap().routes.push(EdgeRoute {
|
||||
edge: f.local,
|
||||
source_attachment: None,
|
||||
target_attachment: None,
|
||||
waypoints: pins.clone(),
|
||||
label: None,
|
||||
});
|
||||
@@ -731,6 +744,8 @@ fn initial_transition_attaches_to_the_compact_symbol_and_preview_keeps_settled_g
|
||||
);
|
||||
f.p.diagrams.get_mut(&f.d).unwrap().routes.push(EdgeRoute {
|
||||
edge: f.self_edge,
|
||||
source_attachment: None,
|
||||
target_attachment: None,
|
||||
waypoints: vec![],
|
||||
label: Some(EdgeLabel {
|
||||
fraction: 0.3,
|
||||
|
||||
@@ -0,0 +1,836 @@
|
||||
//! Pure, bounded allocation-drag composition. See the v2 interaction contract.
|
||||
//!
|
||||
//! These values are not input capabilities. The App must independently admit a
|
||||
//! clean press and retire its token on every document/input/camera lifetime
|
||||
//! change. Cache a SemanticPlan per token and destination; only `place` belongs
|
||||
//! on pointer motion. `finish` rechecks the live accepted Project and commands,
|
||||
//! but Document::execute still owns source admission and atomic history.
|
||||
use oa_core::{
|
||||
projection::{self, ProjectedNode, ProjectedView},
|
||||
validation::{self, Issue, Severity},
|
||||
view_options::ViewIndex,
|
||||
*,
|
||||
};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Destination {
|
||||
LayoutOnly,
|
||||
Performer(Id),
|
||||
OutsidePerformers,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Uniform {
|
||||
Assigned(Id),
|
||||
Unassigned,
|
||||
MixedOrUnresolved,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Leaf {
|
||||
pub direct: Option<Relation>,
|
||||
pub role_assignments: Vec<Relation>,
|
||||
pub role_realizations: Vec<Relation>,
|
||||
pub effective_performer: Option<Id>,
|
||||
}
|
||||
|
||||
/// Exact semantic scope S, leaf scope L and active placement scope Q, including
|
||||
/// folded/unplaced leaves. Retaining records detects first-child responsibility
|
||||
/// transfer. Revision and input-epoch guards remain the caller's responsibility.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Scope {
|
||||
pub project: Id,
|
||||
pub work: Id,
|
||||
pub placement: Id,
|
||||
pub work_records: BTreeMap<Id, Element>,
|
||||
pub leaves: BTreeMap<Id, Leaf>,
|
||||
pub placements: BTreeMap<Id, NodePlacement>,
|
||||
pub diagram: Diagram,
|
||||
pub uniform: Uniform,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ChangedRecord {
|
||||
pub id: Id,
|
||||
pub name: String,
|
||||
pub category: &'static str,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum Refusal {
|
||||
Ineligible(String),
|
||||
StaleScope,
|
||||
MixedLayoutRequired,
|
||||
RoleExitRequired {
|
||||
work: Id,
|
||||
roles: Vec<Id>,
|
||||
performers: Vec<Id>,
|
||||
message: String,
|
||||
},
|
||||
MissingAllocationId(Id),
|
||||
InvalidCandidate(String),
|
||||
CollateralChangeRequired(Vec<ChangedRecord>),
|
||||
LayoutRequired(String),
|
||||
}
|
||||
|
||||
pub fn capture(p: &Project, diagram: Id, work: Id, placement: Id) -> Result<Scope, Refusal> {
|
||||
validate_candidate(p).map_err(|e| Refusal::InvalidCandidate(e.to_string()))?;
|
||||
let d = p.diagrams.get(&diagram).ok_or(Refusal::StaleScope)?;
|
||||
let root = p.elements.get(&work).ok_or(Refusal::StaleScope)?;
|
||||
if !root.kind().is_work()
|
||||
|| d.projection != ProjectionKind::Allocation
|
||||
|| d.layer != root.layer()
|
||||
{
|
||||
return Err(Refusal::Ineligible(
|
||||
"Select an activity or function in an Allocation view.".into(),
|
||||
));
|
||||
}
|
||||
if d.nodes.get(&placement).is_none_or(|n| n.element != work) {
|
||||
return Err(Refusal::StaleScope);
|
||||
}
|
||||
let mut work_records = BTreeMap::new();
|
||||
let mut leaves = BTreeMap::new();
|
||||
let mut pending = vec![work];
|
||||
while let Some(id) = pending.pop() {
|
||||
let element = p.elements.get(&id).ok_or(Refusal::StaleScope)?;
|
||||
if element.kind() != root.kind() || work_records.insert(id, element.clone()).is_some() {
|
||||
return Err(Refusal::StaleScope);
|
||||
}
|
||||
let children: Vec<_> = p
|
||||
.children(id)
|
||||
.filter(|e| e.kind() == root.kind())
|
||||
.map(|e| e.id)
|
||||
.collect();
|
||||
if !children.is_empty() {
|
||||
pending.extend(children);
|
||||
continue;
|
||||
}
|
||||
let direct: Vec<_> = p
|
||||
.relations
|
||||
.values()
|
||||
.filter(|r| r.kind() == RelationKind::Allocation && r.target() == id)
|
||||
.cloned()
|
||||
.collect();
|
||||
if direct.len() > 1 {
|
||||
return Err(Refusal::StaleScope);
|
||||
}
|
||||
let role_assignments: Vec<_> = p
|
||||
.relations
|
||||
.values()
|
||||
.filter(|r| r.kind() == RelationKind::RoleAssignment && r.target() == id)
|
||||
.cloned()
|
||||
.collect();
|
||||
let roles: BTreeSet<_> = role_assignments.iter().map(Relation::source).collect();
|
||||
let role_realizations = p
|
||||
.relations
|
||||
.values()
|
||||
.filter(|r| r.kind() == RelationKind::RoleRealization && roles.contains(&r.target()))
|
||||
.cloned()
|
||||
.collect();
|
||||
leaves.insert(
|
||||
id,
|
||||
Leaf {
|
||||
direct: direct.into_iter().next(),
|
||||
role_assignments,
|
||||
role_realizations,
|
||||
effective_performer: validation::effective_performer(p, id),
|
||||
},
|
||||
);
|
||||
}
|
||||
if leaves.is_empty() {
|
||||
return Err(Refusal::StaleScope);
|
||||
}
|
||||
let uniform = if leaves
|
||||
.values()
|
||||
.all(|l| l.direct.is_none() && l.role_assignments.is_empty())
|
||||
{
|
||||
Uniform::Unassigned
|
||||
} else if let Some(performer) = leaves.values().next().and_then(|l| l.effective_performer)
|
||||
&& leaves
|
||||
.values()
|
||||
.all(|l| l.effective_performer == Some(performer))
|
||||
{
|
||||
Uniform::Assigned(performer)
|
||||
} else {
|
||||
Uniform::MixedOrUnresolved
|
||||
};
|
||||
let placements = d
|
||||
.nodes
|
||||
.iter()
|
||||
.filter(|(_, n)| work_records.contains_key(&n.element))
|
||||
.map(|(id, n)| (*id, n.clone()))
|
||||
.collect();
|
||||
Ok(Scope {
|
||||
project: p.id,
|
||||
work,
|
||||
placement,
|
||||
work_records,
|
||||
leaves,
|
||||
placements,
|
||||
diagram: d.clone(),
|
||||
uniform,
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct SemanticPlan {
|
||||
pub scope: Scope,
|
||||
pub destination: Destination,
|
||||
pub label: String,
|
||||
pub warnings: Vec<Issue>,
|
||||
accepted: Project,
|
||||
candidate: Project,
|
||||
index: ViewIndex,
|
||||
commands: Vec<Command>,
|
||||
before_visible: ProjectedView,
|
||||
before_expanded: ProjectedView,
|
||||
}
|
||||
|
||||
impl SemanticPlan {
|
||||
/// All preview drawing, ports and routing must use this candidate and index.
|
||||
pub fn project(&self) -> &Project {
|
||||
&self.candidate
|
||||
}
|
||||
#[cfg(test)]
|
||||
pub fn index(&self) -> &ViewIndex {
|
||||
&self.index
|
||||
}
|
||||
}
|
||||
|
||||
/// Allocation identities are allocated once by the gesture owner, keyed by leaf,
|
||||
/// and reused on target revisits and release. No random identity generation here.
|
||||
pub fn plan_semantics(
|
||||
p: &Project,
|
||||
scope: &Scope,
|
||||
destination: Destination,
|
||||
allocation_ids: &BTreeMap<Id, Id>,
|
||||
) -> Result<SemanticPlan, Refusal> {
|
||||
if &capture(p, scope.diagram.id, scope.work, scope.placement)? != scope {
|
||||
return Err(Refusal::StaleScope);
|
||||
}
|
||||
if scope.work_records.len() > 1 && scope.uniform == Uniform::MixedOrUnresolved {
|
||||
return Err(Refusal::MixedLayoutRequired);
|
||||
}
|
||||
if let Destination::Performer(id) = destination {
|
||||
let valid = p
|
||||
.elements
|
||||
.get(&id)
|
||||
.is_some_and(|e| e.kind().is_performer() && e.layer() == scope.diagram.layer);
|
||||
if !valid {
|
||||
return Err(Refusal::Ineligible(
|
||||
"Choose a performer in this layer.".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
let before_index = ViewIndex::new(p);
|
||||
let before_visible = projection::project_diagram_with_index(p, &scope.diagram, &before_index);
|
||||
let before_expanded = expanded_view(p, &scope.diagram, scope, &before_index);
|
||||
if scope.work_records.len() > 1 && !coherent(scope, &before_expanded) {
|
||||
return Err(Refusal::MixedLayoutRequired);
|
||||
}
|
||||
let same_performer =
|
||||
matches!(destination, Destination::Performer(id) if scope.uniform == Uniform::Assigned(id));
|
||||
let mut expected = p.clone();
|
||||
let mut commands = Vec::new();
|
||||
let mut deleted = BTreeSet::new();
|
||||
if destination != Destination::LayoutOnly && !same_performer {
|
||||
for (work, leaf) in &scope.leaves {
|
||||
match destination {
|
||||
Destination::Performer(performer) => {
|
||||
if let Some(existing) = &leaf.direct {
|
||||
if existing.source() != performer {
|
||||
let mut edited = existing.clone();
|
||||
edited.body = RelationBody::Allocation {
|
||||
source: performer,
|
||||
target: *work,
|
||||
};
|
||||
expected.relations.insert(edited.id, edited.clone());
|
||||
commands.push(Command::EditRelation(edited.id, edited));
|
||||
}
|
||||
} else {
|
||||
let id = *allocation_ids
|
||||
.get(work)
|
||||
.ok_or(Refusal::MissingAllocationId(*work))?;
|
||||
if id.is_nil() || expected.contains(id) {
|
||||
return Err(Refusal::InvalidCandidate(
|
||||
"The gesture's allocation identity is already in use.".into(),
|
||||
));
|
||||
}
|
||||
let name = format!("{} performs {}", p.named(performer), p.named(*work));
|
||||
let relation = Relation {
|
||||
id,
|
||||
alias: expected.fresh_alias(&name, "allocation", id),
|
||||
name,
|
||||
description: String::new(),
|
||||
body: RelationBody::Allocation {
|
||||
source: performer,
|
||||
target: *work,
|
||||
},
|
||||
};
|
||||
expected.relations.insert(id, relation.clone());
|
||||
commands.push(Command::CreateRelation(relation));
|
||||
}
|
||||
}
|
||||
Destination::OutsidePerformers => {
|
||||
if !leaf.role_assignments.is_empty() {
|
||||
let roles: Vec<_> = leaf
|
||||
.role_assignments
|
||||
.iter()
|
||||
.map(Relation::source)
|
||||
.collect::<BTreeSet<_>>()
|
||||
.into_iter()
|
||||
.collect();
|
||||
let performers: Vec<_> = leaf
|
||||
.role_realizations
|
||||
.iter()
|
||||
.map(Relation::source)
|
||||
.collect::<BTreeSet<_>>()
|
||||
.into_iter()
|
||||
.collect();
|
||||
let message = if leaf.direct.is_some() && performers.len() == 1 {
|
||||
format!(
|
||||
"Removing direct assignment would place {} in {} through its role.",
|
||||
p.named(*work),
|
||||
p.named(performers[0])
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"{} retains role responsibility through {}{}; edit that responsibility before moving outside performers.",
|
||||
p.named(*work),
|
||||
roles
|
||||
.iter()
|
||||
.map(|id| p.named(*id))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", "),
|
||||
if performers.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(
|
||||
" ({})",
|
||||
performers
|
||||
.iter()
|
||||
.map(|id| p.named(*id))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
)
|
||||
}
|
||||
)
|
||||
};
|
||||
return Err(Refusal::RoleExitRequired {
|
||||
work: *work,
|
||||
roles,
|
||||
performers,
|
||||
message,
|
||||
});
|
||||
}
|
||||
if let Some(existing) = &leaf.direct {
|
||||
expected.relations.remove(&existing.id);
|
||||
deleted.insert(existing.id);
|
||||
commands.push(Command::Delete(existing.id));
|
||||
}
|
||||
}
|
||||
Destination::LayoutOnly => unreachable!(),
|
||||
}
|
||||
}
|
||||
}
|
||||
// A route cannot survive the intentional deletion of its own edge, even in
|
||||
// another diagram. No other saved-view cleanup is authorized by this drag.
|
||||
for diagram in expected.diagrams.values_mut() {
|
||||
diagram.routes.retain(|r| !deleted.contains(&r.edge));
|
||||
}
|
||||
let actual = prepare_command(p, &Command::Batch(commands.clone()))
|
||||
.map_err(|e| Refusal::InvalidCandidate(e.to_string()))?
|
||||
.project;
|
||||
exact_project(&expected, &actual)?;
|
||||
let retained_roles = scope
|
||||
.leaves
|
||||
.values()
|
||||
.any(|l| !l.role_assignments.is_empty());
|
||||
let label = match destination {
|
||||
Destination::LayoutOnly => "Arrange only".into(),
|
||||
Destination::Performer(id) if same_performer => format!("Arrange within {}", p.named(id)),
|
||||
Destination::Performer(id) => format!(
|
||||
"Assign {} leaf {} to {}{}",
|
||||
scope.leaves.len(),
|
||||
match (scope.diagram.layer, scope.leaves.len()) {
|
||||
(Layer::Oa, 1) => "activity",
|
||||
(Layer::Oa, _) => "activities",
|
||||
(Layer::Sa, 1) => "function",
|
||||
(Layer::Sa, _) => "functions",
|
||||
},
|
||||
p.named(id),
|
||||
if retained_roles {
|
||||
" — explicit assignment; role responsibility retained"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
),
|
||||
Destination::OutsidePerformers if commands.is_empty() => {
|
||||
"Arrange unassigned work — decomposition retained".into()
|
||||
}
|
||||
Destination::OutsidePerformers => "Remove assignment — decomposition retained".into(),
|
||||
};
|
||||
let warnings = validation::validate(&actual)
|
||||
.into_iter()
|
||||
.filter(|i| i.severity == Severity::Warning)
|
||||
.collect();
|
||||
let index = ViewIndex::new(&actual);
|
||||
Ok(SemanticPlan {
|
||||
scope: scope.clone(),
|
||||
destination,
|
||||
label,
|
||||
warnings,
|
||||
accepted: p.clone(),
|
||||
candidate: actual,
|
||||
index,
|
||||
commands,
|
||||
before_visible,
|
||||
before_expanded,
|
||||
})
|
||||
}
|
||||
|
||||
/// World-space rectangle in the gesture's frozen transform. The UI resolver
|
||||
/// owns half-open pointer hits, semantic target precedence and occlusion.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct Rect {
|
||||
pub position: Position,
|
||||
pub size: Size,
|
||||
}
|
||||
|
||||
impl Rect {
|
||||
fn valid(self) -> bool {
|
||||
[
|
||||
self.position.x,
|
||||
self.position.y,
|
||||
self.size.width,
|
||||
self.size.height,
|
||||
]
|
||||
.iter()
|
||||
.all(|n| n.is_finite() && n.abs() <= MAX_COORDINATE)
|
||||
&& self.size.width > 0.
|
||||
&& self.size.height > 0.
|
||||
}
|
||||
fn contains(self, other: Self) -> bool {
|
||||
other.position.x >= self.position.x
|
||||
&& other.position.y >= self.position.y
|
||||
&& other.position.x + other.size.width <= self.position.x + self.size.width
|
||||
&& other.position.y + other.size.height <= self.position.y + self.size.height
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct TargetPresentation {
|
||||
pub placement: Id,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct PresentationContext {
|
||||
/// Visible world rectangle derived from the actual captured pan/zoom/bounds.
|
||||
pub viewport: Rect,
|
||||
pub zoom: f32,
|
||||
pub target: Option<TargetPresentation>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct PlacementPlan {
|
||||
pub diagram: Diagram,
|
||||
pub view: ProjectedView,
|
||||
pub delta: Position,
|
||||
pub unfolded: Option<Id>,
|
||||
requested: Position,
|
||||
presentation: PresentationContext,
|
||||
commands: Vec<Command>,
|
||||
}
|
||||
|
||||
/// `requested` is the original selected placement position plus pointer motion
|
||||
/// in the frozen transform (therefore preserving the press offset). Snap only
|
||||
/// this root; apply the resulting identical delta to captured Q and its routes.
|
||||
/// No whole Project clone, validator or semantic ViewIndex rebuild occurs here.
|
||||
pub fn place(
|
||||
plan: &SemanticPlan,
|
||||
requested: Position,
|
||||
presentation: PresentationContext,
|
||||
) -> Result<PlacementPlan, Refusal> {
|
||||
if !requested.x.is_finite()
|
||||
|| !requested.y.is_finite()
|
||||
|| !presentation.viewport.valid()
|
||||
|| !presentation.zoom.is_finite()
|
||||
|| presentation.zoom <= 0.
|
||||
{
|
||||
return Err(Refusal::LayoutRequired(
|
||||
"Make room or adjust the view, then drag again.".into(),
|
||||
));
|
||||
}
|
||||
let root = &plan.scope.placements[&plan.scope.placement];
|
||||
let delta = Position {
|
||||
x: (requested.x / 10.).round() * 10. - root.position.x,
|
||||
y: (requested.y / 10.).round() * 10. - root.position.y,
|
||||
};
|
||||
let mut diagram = plan.candidate.diagrams[&plan.scope.diagram.id].clone();
|
||||
let mut commands = Vec::new();
|
||||
let mut deltas = BTreeMap::new();
|
||||
for (id, original) in &plan.scope.placements {
|
||||
let node = diagram.nodes.get_mut(id).ok_or(Refusal::StaleScope)?;
|
||||
node.position = Position {
|
||||
x: original.position.x + delta.x,
|
||||
y: original.position.y + delta.y,
|
||||
};
|
||||
if !(Rect {
|
||||
position: node.position,
|
||||
size: node.size,
|
||||
})
|
||||
.valid()
|
||||
{
|
||||
return Err(Refusal::InvalidCandidate(
|
||||
"Moved coordinates exceed the model bounds.".into(),
|
||||
));
|
||||
}
|
||||
deltas.insert(*id, delta);
|
||||
if node != original {
|
||||
commands.push(Command::EditNode {
|
||||
diagram: diagram.id,
|
||||
original: *id,
|
||||
node: node.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
// Resolve endpoints using the captured accepted semantics, not a newly
|
||||
// regrouped descendant walk. Own-edge deletions were removed above already.
|
||||
for route in projection::translated_routes(&plan.accepted, &plan.scope.diagram, &deltas) {
|
||||
if let Some(existing) = diagram.routes.iter_mut().find(|r| r.edge == route.edge) {
|
||||
if route.waypoints.iter().any(|p| {
|
||||
!p.x.is_finite()
|
||||
|| !p.y.is_finite()
|
||||
|| p.x.abs() > MAX_COORDINATE
|
||||
|| p.y.abs() > MAX_COORDINATE
|
||||
}) {
|
||||
return Err(Refusal::InvalidCandidate(
|
||||
"Moved route exceeds the model bounds.".into(),
|
||||
));
|
||||
}
|
||||
*existing = route.clone();
|
||||
commands.push(Command::SetEdgeRoute {
|
||||
diagram: diagram.id,
|
||||
route,
|
||||
});
|
||||
}
|
||||
}
|
||||
let mut unfolded = None;
|
||||
if let Destination::Performer(performer) = plan.destination {
|
||||
let target = presentation.target.ok_or_else(|| {
|
||||
Refusal::LayoutRequired("A visible accepted performer target is required.".into())
|
||||
})?;
|
||||
let node = diagram
|
||||
.nodes
|
||||
.get_mut(&target.placement)
|
||||
.ok_or(Refusal::StaleScope)?;
|
||||
let visible_target = plan
|
||||
.before_visible
|
||||
.nodes
|
||||
.iter()
|
||||
.any(|n| n.id == target.placement && n.element == performer);
|
||||
if node.element != performer || !visible_target {
|
||||
return Err(Refusal::LayoutRequired(
|
||||
"Make room or adjust the view, then drag again.".into(),
|
||||
));
|
||||
}
|
||||
if node.collapsed && !plan.commands.is_empty() {
|
||||
node.collapsed = false;
|
||||
unfolded = Some(node.id);
|
||||
commands.push(Command::EditNode {
|
||||
diagram: diagram.id,
|
||||
original: node.id,
|
||||
node: node.clone(),
|
||||
});
|
||||
}
|
||||
} else if presentation.target.is_some() {
|
||||
return Err(Refusal::Ineligible(
|
||||
"This layout/outside destination has no performer target.".into(),
|
||||
));
|
||||
}
|
||||
let view = projection::project_diagram_with_index(&plan.candidate, &diagram, &plan.index);
|
||||
let expanded = expanded_view(&plan.candidate, &diagram, &plan.scope, &plan.index);
|
||||
check_geometry(plan, &view, &expanded, delta)?;
|
||||
let selected = view
|
||||
.nodes
|
||||
.iter()
|
||||
.find(|n| n.id == plan.scope.placement)
|
||||
.ok_or_else(|| {
|
||||
Refusal::LayoutRequired("The selected work header would be hidden.".into())
|
||||
})?;
|
||||
let header = Rect {
|
||||
position: selected.position,
|
||||
size: Size {
|
||||
width: selected.size.width,
|
||||
height: selected.size.height.min(62.),
|
||||
},
|
||||
};
|
||||
if !presentation.viewport.contains(header) {
|
||||
return Err(Refusal::LayoutRequired(
|
||||
"Make room or adjust the view, then drag again.".into(),
|
||||
));
|
||||
}
|
||||
// Destination identity lives in a fixed toolbar slot. Frozen hit geometry
|
||||
// and its outline never introduce an opaque label over this work header.
|
||||
Ok(PlacementPlan {
|
||||
diagram,
|
||||
view,
|
||||
delta,
|
||||
unfolded,
|
||||
requested,
|
||||
presentation,
|
||||
commands,
|
||||
})
|
||||
}
|
||||
|
||||
/// Final semantic/presentation equality gate, after the App has checked its
|
||||
/// current token, owner, revision, clean drafts, frozen target and transform.
|
||||
/// Recompute geometry to avoid accepting a modified/stale placement result.
|
||||
/// None means a snapped no-op: the caller must not execute or add history.
|
||||
pub fn finish(
|
||||
plan: &SemanticPlan,
|
||||
live: &Project,
|
||||
placement: &PlacementPlan,
|
||||
) -> Result<Option<Command>, Refusal> {
|
||||
if live != &plan.accepted {
|
||||
return Err(Refusal::StaleScope);
|
||||
}
|
||||
let checked = place(plan, placement.requested, placement.presentation)?;
|
||||
if checked.diagram != placement.diagram
|
||||
|| checked.view != placement.view
|
||||
|| checked.delta != placement.delta
|
||||
|| checked.unfolded != placement.unfolded
|
||||
|| checked.commands != placement.commands
|
||||
{
|
||||
return Err(Refusal::StaleScope);
|
||||
}
|
||||
let mut expected = plan.candidate.clone();
|
||||
expected
|
||||
.diagrams
|
||||
.insert(checked.diagram.id, checked.diagram);
|
||||
let mut commands = plan.commands.clone();
|
||||
commands.extend(checked.commands);
|
||||
let command = Command::Batch(commands);
|
||||
let actual = prepare_command(live, &command)
|
||||
.map_err(|e| Refusal::InvalidCandidate(e.to_string()))?
|
||||
.project;
|
||||
exact_project(&expected, &actual)?;
|
||||
Ok((actual != *live).then_some(command))
|
||||
}
|
||||
|
||||
fn expanded_view(
|
||||
p: &Project,
|
||||
diagram: &Diagram,
|
||||
scope: &Scope,
|
||||
index: &ViewIndex,
|
||||
) -> ProjectedView {
|
||||
let mut expanded = diagram.clone();
|
||||
for id in scope.placements.keys() {
|
||||
if let Some(node) = expanded.nodes.get_mut(id) {
|
||||
node.collapsed = false;
|
||||
}
|
||||
}
|
||||
projection::project_diagram_with_index(p, &expanded, index)
|
||||
}
|
||||
|
||||
fn ancestors(view: &ProjectedView) -> BTreeMap<Id, BTreeSet<Id>> {
|
||||
let parents: BTreeMap<_, _> = view.nodes.iter().map(|n| (n.id, n.visual_parent)).collect();
|
||||
parents
|
||||
.keys()
|
||||
.map(|node| {
|
||||
let mut current = parents[node];
|
||||
let mut seen = BTreeSet::new();
|
||||
while let Some(id) = current {
|
||||
if !seen.insert(id) {
|
||||
break;
|
||||
}
|
||||
current = parents.get(&id).copied().flatten();
|
||||
}
|
||||
(*node, seen)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn coherent(scope: &Scope, view: &ProjectedView) -> bool {
|
||||
let ancestors = ancestors(view);
|
||||
scope.placements.keys().all(|id| {
|
||||
ancestors
|
||||
.get(id)
|
||||
.is_some_and(|parents| *id == scope.placement || parents.contains(&scope.placement))
|
||||
})
|
||||
}
|
||||
|
||||
fn overlap(a: &ProjectedNode, b: &ProjectedNode) -> f32 {
|
||||
let width = (a.position.x + a.size.width).min(b.position.x + b.size.width)
|
||||
- a.position.x.max(b.position.x);
|
||||
let height = (a.position.y + a.size.height).min(b.position.y + b.size.height)
|
||||
- a.position.y.max(b.position.y);
|
||||
width.max(0.) * height.max(0.)
|
||||
}
|
||||
|
||||
fn overlaps(before: &ProjectedView, after: &ProjectedView) -> bool {
|
||||
// Build geometry ancestry once, avoiding a repeated descendant walk for
|
||||
// every pair during pointer motion. Pairwise collision checking is bounded
|
||||
// to these two projected scenes and does not traverse/clone model semantics.
|
||||
let before_ancestors = ancestors(before);
|
||||
let after_ancestors = ancestors(after);
|
||||
let prior_nodes: BTreeMap<_, _> = before.nodes.iter().map(|n| (n.id, n)).collect();
|
||||
for (i, a) in after.nodes.iter().enumerate() {
|
||||
for b in &after.nodes[i + 1..] {
|
||||
if after_ancestors[&a.id].contains(&b.id) || after_ancestors[&b.id].contains(&a.id) {
|
||||
continue;
|
||||
}
|
||||
let prior = match (prior_nodes.get(&a.id), prior_nodes.get(&b.id)) {
|
||||
(Some(x), Some(y))
|
||||
if !before_ancestors[&x.id].contains(&y.id)
|
||||
&& !before_ancestors[&y.id].contains(&x.id) =>
|
||||
{
|
||||
overlap(x, y)
|
||||
}
|
||||
_ => 0.,
|
||||
};
|
||||
if overlap(a, b) > prior + 0.01 {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn check_geometry(
|
||||
plan: &SemanticPlan,
|
||||
visible: &ProjectedView,
|
||||
expanded: &ProjectedView,
|
||||
delta: Position,
|
||||
) -> Result<(), Refusal> {
|
||||
if visible.nodes.iter().chain(&expanded.nodes).any(|n| {
|
||||
!(Rect {
|
||||
position: n.position,
|
||||
size: n.size,
|
||||
})
|
||||
.valid()
|
||||
}) {
|
||||
return Err(Refusal::InvalidCandidate(
|
||||
"The projected enclosure exceeds the model bounds.".into(),
|
||||
));
|
||||
}
|
||||
if plan.scope.work_records.len() > 1 {
|
||||
if !coherent(&plan.scope, expanded) {
|
||||
return Err(Refusal::MixedLayoutRequired);
|
||||
}
|
||||
for id in plan.scope.placements.keys() {
|
||||
let old = plan
|
||||
.before_expanded
|
||||
.nodes
|
||||
.iter()
|
||||
.find(|n| n.id == *id)
|
||||
.ok_or(Refusal::MixedLayoutRequired)?;
|
||||
let new = expanded
|
||||
.nodes
|
||||
.iter()
|
||||
.find(|n| n.id == *id)
|
||||
.ok_or(Refusal::MixedLayoutRequired)?;
|
||||
if (new.position.x - old.position.x - delta.x).abs() > 0.01
|
||||
|| (new.position.y - old.position.y - delta.y).abs() > 0.01
|
||||
|| (new.size.width - old.size.width).abs() > 0.01
|
||||
|| (new.size.height - old.size.height).abs() > 0.01
|
||||
{
|
||||
return Err(Refusal::LayoutRequired("This work subtree requires an explicit layout; its footprint cannot be translated unchanged.".into()));
|
||||
}
|
||||
}
|
||||
}
|
||||
if overlaps(&plan.before_visible, visible) || overlaps(&plan.before_expanded, expanded) {
|
||||
return Err(Refusal::LayoutRequired("Make room before moving work: the candidate would introduce or enlarge overlapping cards.".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Compare complete records, not just removed IDs. Deleting an Allocation can
|
||||
/// rewrite a surviving Constraint, remove artifacts/traces or prune view options.
|
||||
fn exact_project(expected: &Project, actual: &Project) -> Result<(), Refusal> {
|
||||
if expected == actual {
|
||||
return Ok(());
|
||||
}
|
||||
let mut records = Vec::new();
|
||||
fn changes<T: PartialEq>(
|
||||
expected: &BTreeMap<Id, T>,
|
||||
actual: &BTreeMap<Id, T>,
|
||||
category: &'static str,
|
||||
project: &Project,
|
||||
other: &Project,
|
||||
records: &mut Vec<ChangedRecord>,
|
||||
) {
|
||||
let ids: BTreeSet<_> = expected.keys().chain(actual.keys()).copied().collect();
|
||||
for id in ids {
|
||||
if expected.get(&id) != actual.get(&id) {
|
||||
records.push(ChangedRecord {
|
||||
id,
|
||||
name: if project.contains(id) {
|
||||
project.named(id)
|
||||
} else {
|
||||
other.named(id)
|
||||
}
|
||||
.into(),
|
||||
category,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
changes(
|
||||
&expected.elements,
|
||||
&actual.elements,
|
||||
"element",
|
||||
expected,
|
||||
actual,
|
||||
&mut records,
|
||||
);
|
||||
changes(
|
||||
&expected.relations,
|
||||
&actual.relations,
|
||||
"relation",
|
||||
expected,
|
||||
actual,
|
||||
&mut records,
|
||||
);
|
||||
changes(
|
||||
&expected.diagrams,
|
||||
&actual.diagrams,
|
||||
"diagram",
|
||||
expected,
|
||||
actual,
|
||||
&mut records,
|
||||
);
|
||||
changes(
|
||||
&expected.artifact_links,
|
||||
&actual.artifact_links,
|
||||
"artifact",
|
||||
expected,
|
||||
actual,
|
||||
&mut records,
|
||||
);
|
||||
changes(
|
||||
&expected.traces,
|
||||
&actual.traces,
|
||||
"trace",
|
||||
expected,
|
||||
actual,
|
||||
&mut records,
|
||||
);
|
||||
if expected.schema_version != actual.schema_version
|
||||
|| expected.id != actual.id
|
||||
|| expected.alias != actual.alias
|
||||
|| expected.name != actual.name
|
||||
|| expected.description != actual.description
|
||||
{
|
||||
records.push(ChangedRecord {
|
||||
id: expected.id,
|
||||
name: expected.name.clone(),
|
||||
category: "project",
|
||||
});
|
||||
}
|
||||
Err(Refusal::CollateralChangeRequired(records))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,269 @@
|
||||
//! A narrow dispatch boundary for Allocation work gestures. Iced rows visit
|
||||
//! siblings after capture; refusing inside Canvas alone still blurs an inspector
|
||||
//! field. Refuse before dispatching that press to any ordinary child instead.
|
||||
use crate::{
|
||||
app::{
|
||||
App, Message,
|
||||
allocation_drag::{self, Event as WorkEvent},
|
||||
},
|
||||
diagram::DiagramCanvas,
|
||||
};
|
||||
use iced::advanced::{
|
||||
Clipboard, Layout, Shell, Widget, layout, overlay, renderer,
|
||||
widget::{Id, Operation, Tree, tree},
|
||||
};
|
||||
use iced::{
|
||||
Element, Event, Length, Point, Rectangle, Renderer, Size, Theme, Vector, keyboard, mouse,
|
||||
};
|
||||
|
||||
pub const CANVAS_ID: &str = "allocation-work-canvas";
|
||||
pub const ARRANGE_SHORTCUT: &str = "Alt+A";
|
||||
|
||||
struct Root<'a> {
|
||||
app: &'a App,
|
||||
content: Element<'a, Message>,
|
||||
}
|
||||
#[derive(Default)]
|
||||
struct State {
|
||||
pointer: Option<Point>,
|
||||
refused_left: bool,
|
||||
}
|
||||
#[derive(Default)]
|
||||
struct CanvasBounds(Option<Rectangle>);
|
||||
impl Operation for CanvasBounds {
|
||||
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn Operation)) {
|
||||
visit(self);
|
||||
}
|
||||
fn container(&mut self, id: Option<&Id>, bounds: Rectangle) {
|
||||
if id == Some(&Id::new(CANVAS_ID)) {
|
||||
self.0 = Some(bounds);
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn root<'a>(app: &'a App, content: impl Into<Element<'a, Message>>) -> Element<'a, Message> {
|
||||
Element::new(Root {
|
||||
app,
|
||||
content: content.into(),
|
||||
})
|
||||
}
|
||||
fn arrange_key(event: &Event) -> bool {
|
||||
matches!(event, Event::Keyboard(keyboard::Event::KeyPressed {
|
||||
key: keyboard::Key::Character(key), modifiers, repeat: false, ..
|
||||
}) if key.eq_ignore_ascii_case("a") && *modifiers == keyboard::Modifiers::ALT)
|
||||
}
|
||||
impl Root<'_> {
|
||||
fn dirty_object(&self, bounds: Rectangle, screen: Point) -> bool {
|
||||
let Some(diagram) = self.app.active_diagram else {
|
||||
return false;
|
||||
};
|
||||
if !allocation_drag::contains(bounds, screen) {
|
||||
return false;
|
||||
}
|
||||
let canvas = DiagramCanvas {
|
||||
selection: self.app.diagram_selection.as_ref(),
|
||||
allocation: Some(self.app.allocation_capability()),
|
||||
project: self.app.project(),
|
||||
view_index: Some(self.app.document.view_index()),
|
||||
diagram,
|
||||
selected: self.app.selected,
|
||||
navigation_focus: None,
|
||||
zoom: self.app.zoom,
|
||||
pan: self.app.pan,
|
||||
context: self.app.route_context().unwrap(),
|
||||
selected_bend: None,
|
||||
selected_attachment: None,
|
||||
editable: false,
|
||||
placing: false,
|
||||
connection: None,
|
||||
};
|
||||
canvas.work_dirty_point(bounds, screen)
|
||||
}
|
||||
}
|
||||
impl Widget<Message, Theme, Renderer> for Root<'_> {
|
||||
fn tag(&self) -> tree::Tag {
|
||||
tree::Tag::of::<State>()
|
||||
}
|
||||
fn state(&self) -> tree::State {
|
||||
tree::State::new(State::default())
|
||||
}
|
||||
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: &Renderer,
|
||||
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: &Renderer,
|
||||
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: &Renderer,
|
||||
clipboard: &mut dyn Clipboard,
|
||||
shell: &mut Shell<'_, Message>,
|
||||
viewport: &Rectangle,
|
||||
) {
|
||||
let state = tree.state.downcast_mut::<State>();
|
||||
match event {
|
||||
Event::Mouse(mouse::Event::CursorMoved { position }) => state.pointer = Some(*position),
|
||||
Event::Mouse(mouse::Event::CursorLeft) => state.pointer = None,
|
||||
Event::Window(iced::window::Event::RedrawRequested(_)) => {
|
||||
state.pointer = cursor.position()
|
||||
}
|
||||
Event::Window(iced::window::Event::Unfocused) => {
|
||||
state.pointer = None;
|
||||
state.refused_left = false;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if state.refused_left {
|
||||
if matches!(
|
||||
event,
|
||||
Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left))
|
||||
) {
|
||||
state.refused_left = false;
|
||||
shell.capture_event();
|
||||
return;
|
||||
}
|
||||
if matches!(event, Event::Mouse(mouse::Event::CursorMoved { .. })) {
|
||||
shell.capture_event();
|
||||
return;
|
||||
}
|
||||
}
|
||||
if self.app.allocation_input_owner() && !shell.is_event_captured() {
|
||||
if arrange_key(event) {
|
||||
// App rechecks clean ownership before arming. A dirty field
|
||||
// retains its actual widget focus and selection on refusal.
|
||||
shell.publish(Message::WorkDrag(WorkEvent::ArrangeNext));
|
||||
shell.capture_event();
|
||||
return;
|
||||
}
|
||||
if self.app.allocation_drag.arrange_next
|
||||
&& matches!(
|
||||
event,
|
||||
Event::Keyboard(keyboard::Event::KeyPressed {
|
||||
key: keyboard::Key::Named(keyboard::key::Named::Escape),
|
||||
..
|
||||
})
|
||||
)
|
||||
{
|
||||
shell.publish(Message::Escape);
|
||||
shell.capture_event();
|
||||
return;
|
||||
}
|
||||
if !self.app.allocation_drag_clean()
|
||||
&& matches!(
|
||||
event,
|
||||
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left))
|
||||
)
|
||||
&& let Some(screen) = state.pointer.or_else(|| cursor.position())
|
||||
{
|
||||
let mut canvas = CanvasBounds::default();
|
||||
self.content.as_widget_mut().operate(
|
||||
&mut tree.children[0],
|
||||
layout,
|
||||
renderer,
|
||||
&mut canvas,
|
||||
);
|
||||
if canvas
|
||||
.0
|
||||
.is_some_and(|bounds| self.dirty_object(bounds, screen))
|
||||
{
|
||||
state.refused_left = true;
|
||||
shell.publish(Message::WorkDrag(WorkEvent::Refused(
|
||||
self.app.allocation_drag.epoch,
|
||||
allocation_drag::FINISH_EDIT.into(),
|
||||
)));
|
||||
shell.capture_event();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
self.content.as_widget_mut().update(
|
||||
&mut tree.children[0],
|
||||
event,
|
||||
layout,
|
||||
cursor,
|
||||
renderer,
|
||||
clipboard,
|
||||
shell,
|
||||
viewport,
|
||||
);
|
||||
}
|
||||
fn draw(
|
||||
&self,
|
||||
tree: &Tree,
|
||||
renderer: &mut Renderer,
|
||||
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: &Renderer,
|
||||
) -> 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: &Renderer,
|
||||
viewport: &Rectangle,
|
||||
translation: Vector,
|
||||
) -> Option<overlay::Element<'b, Message, Theme, Renderer>> {
|
||||
self.content.as_widget_mut().overlay(
|
||||
&mut tree.children[0],
|
||||
layout,
|
||||
renderer,
|
||||
viewport,
|
||||
translation,
|
||||
)
|
||||
}
|
||||
}
|
||||
+828
-747
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,672 @@
|
||||
//! Allocation work-body input admission. Gesture authority lives here, while
|
||||
//! the canvas retains ordered native pointer positions and frozen hit geometry.
|
||||
use super::{App, Message, Page};
|
||||
use crate::{allocation_drag as plan, route_edit::Context};
|
||||
use iced::{Point, Rectangle, Task, Vector};
|
||||
use oa_core::{projection::ProjectedView, routing::RoutedView, *};
|
||||
use std::{collections::BTreeMap, sync::Arc};
|
||||
|
||||
pub const FINISH_EDIT: &str = "Finish or cancel the current edit before selecting or moving work.";
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct Stamp {
|
||||
pub context: Context,
|
||||
pub epoch: u64,
|
||||
pub snapshot: usize,
|
||||
pub bounds: Rectangle,
|
||||
pub pan: Vector,
|
||||
pub zoom: f32,
|
||||
}
|
||||
impl Stamp {
|
||||
pub fn world(self, screen: Point) -> Point {
|
||||
Point::new(
|
||||
(screen.x - self.bounds.x - self.pan.x) / self.zoom,
|
||||
(screen.y - self.bounds.y - self.pan.y) / self.zoom,
|
||||
)
|
||||
}
|
||||
pub fn viewport(self) -> plan::Rect {
|
||||
let p = self.world(Point::new(self.bounds.x, self.bounds.y));
|
||||
plan::Rect {
|
||||
position: Position { x: p.x, y: p.y },
|
||||
size: Size {
|
||||
width: self.bounds.width / self.zoom,
|
||||
height: self.bounds.height / self.zoom,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Target {
|
||||
pub element: Id,
|
||||
pub placement: Id,
|
||||
pub region: Rectangle,
|
||||
pub name: String,
|
||||
pub ancestors: Vec<Id>,
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Frozen {
|
||||
pub targets: Vec<Target>,
|
||||
cards: Vec<Rectangle>,
|
||||
controls: Vec<Rectangle>,
|
||||
edges: Vec<(Point, Point)>,
|
||||
radius: f32,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum Hit {
|
||||
Destination(plan::Destination, Option<usize>),
|
||||
OutsideViewport,
|
||||
Occluded,
|
||||
Ambiguous(Vec<usize>),
|
||||
}
|
||||
pub fn contains(r: Rectangle, p: Point) -> bool {
|
||||
p.x >= r.x && p.y >= r.y && p.x < r.x + r.width && p.y < r.y + r.height
|
||||
}
|
||||
impl Frozen {
|
||||
pub fn capture(
|
||||
project: &Project,
|
||||
scope: &plan::Scope,
|
||||
view: &ProjectedView,
|
||||
routed: &RoutedView,
|
||||
zoom: f32,
|
||||
) -> Self {
|
||||
let mut targets = Vec::new();
|
||||
let mut cards = Vec::new();
|
||||
for n in &view.nodes {
|
||||
if scope.placements.contains_key(&n.id) {
|
||||
continue;
|
||||
}
|
||||
let region = Rectangle::new(
|
||||
Point::new(n.position.x, n.position.y),
|
||||
iced::Size::new(n.size.width, n.size.height),
|
||||
);
|
||||
let Some(e) = project.elements.get(&n.element) else {
|
||||
cards.push(region);
|
||||
continue;
|
||||
};
|
||||
if e.kind().is_performer() && e.layer() == scope.diagram.layer {
|
||||
let mut ancestors = Vec::new();
|
||||
let mut parent = e.parent;
|
||||
while let Some(id) = parent {
|
||||
if ancestors.contains(&id) {
|
||||
break;
|
||||
}
|
||||
ancestors.push(id);
|
||||
parent = project.elements.get(&id).and_then(|e| e.parent);
|
||||
}
|
||||
let name = project.named(e.id).to_owned();
|
||||
let collapsed = scope.diagram.nodes[&n.id].collapsed;
|
||||
targets.push(Target {
|
||||
element: e.id,
|
||||
placement: n.id,
|
||||
region: if collapsed {
|
||||
Rectangle {
|
||||
height: region.height.min(62.),
|
||||
..region
|
||||
}
|
||||
} else {
|
||||
region
|
||||
},
|
||||
name,
|
||||
ancestors,
|
||||
});
|
||||
} else {
|
||||
cards.push(region);
|
||||
}
|
||||
}
|
||||
let radius = (10. / zoom).max(10.);
|
||||
let mut controls: Vec<_> = view
|
||||
.ports
|
||||
.iter()
|
||||
.filter(|port| !scope.placements.contains_key(&port.owner))
|
||||
.map(|port| {
|
||||
Rectangle::new(
|
||||
Point::new(port.position.x - radius, port.position.y - radius),
|
||||
iced::Size::new(radius * 2., radius * 2.),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let mut edges = Vec::new();
|
||||
for edge in routed.edges.values() {
|
||||
if let Some(relation) = project.relations.get(&edge.id) {
|
||||
for end in routing::association_end_geometry(relation, edge) {
|
||||
if !end.label.text.is_empty() {
|
||||
controls.push(Rectangle::new(
|
||||
Point::new(end.label.bounds.position.x, end.label.bounds.position.y),
|
||||
iced::Size::new(
|
||||
end.label.bounds.size.width,
|
||||
end.label.bounds.size.height,
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
if !edge.label.text.is_empty() {
|
||||
controls.push(Rectangle::new(
|
||||
Point::new(edge.label.bounds.position.x, edge.label.bounds.position.y),
|
||||
iced::Size::new(edge.label.bounds.size.width, edge.label.bounds.size.height),
|
||||
));
|
||||
}
|
||||
for pin in &edge.pins {
|
||||
controls.push(Rectangle::new(
|
||||
Point::new(pin.x - 8. / zoom, pin.y - 8. / zoom),
|
||||
iced::Size::new(16. / zoom, 16. / zoom),
|
||||
));
|
||||
}
|
||||
for points in edge.points.windows(2) {
|
||||
edges.push((
|
||||
Point::new(points[0].x, points[0].y),
|
||||
Point::new(points[1].x, points[1].y),
|
||||
));
|
||||
}
|
||||
}
|
||||
Self {
|
||||
targets,
|
||||
cards,
|
||||
controls,
|
||||
edges,
|
||||
radius: 6. / zoom,
|
||||
}
|
||||
}
|
||||
pub fn resolve(&self, stamp: Stamp, screen: Option<Point>, arrange: bool) -> Hit {
|
||||
let Some(screen) = screen.filter(|p| contains(stamp.bounds, *p)) else {
|
||||
return Hit::OutsideViewport;
|
||||
};
|
||||
let p = stamp.world(screen);
|
||||
// Drag status lives in the toolbar, outside this actual canvas viewport.
|
||||
if self
|
||||
.controls
|
||||
.iter()
|
||||
.chain(&self.cards)
|
||||
.any(|r| contains(*r, p))
|
||||
|| self.edges.iter().any(|(a, b)| {
|
||||
let d = *b - *a;
|
||||
let length = d.x * d.x + d.y * d.y;
|
||||
let t = if length == 0. {
|
||||
0.
|
||||
} else {
|
||||
(((p.x - a.x) * d.x + (p.y - a.y) * d.y) / length).clamp(0., 1.)
|
||||
};
|
||||
p.distance(*a + d * t) <= self.radius
|
||||
})
|
||||
{
|
||||
return Hit::Occluded;
|
||||
}
|
||||
if arrange {
|
||||
return Hit::Destination(plan::Destination::LayoutOnly, None);
|
||||
}
|
||||
let hits: Vec<_> = self
|
||||
.targets
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, t)| contains(t.region, p))
|
||||
.map(|(i, _)| i)
|
||||
.collect();
|
||||
for (i, a) in hits.iter().enumerate() {
|
||||
for b in &hits[i + 1..] {
|
||||
let a = &self.targets[*a];
|
||||
let b = &self.targets[*b];
|
||||
if !a.ancestors.contains(&b.element) && !b.ancestors.contains(&a.element) {
|
||||
return Hit::Ambiguous(hits);
|
||||
}
|
||||
}
|
||||
}
|
||||
match hits
|
||||
.into_iter()
|
||||
.max_by_key(|i| self.targets[*i].ancestors.len())
|
||||
{
|
||||
Some(i) => Hit::Destination(
|
||||
plan::Destination::Performer(self.targets[i].element),
|
||||
Some(i),
|
||||
),
|
||||
None => Hit::Destination(plan::Destination::OutsidePerformers, None),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Press {
|
||||
pub stamp: Stamp,
|
||||
pub token: Id,
|
||||
pub start: Point,
|
||||
pub scope: plan::Scope,
|
||||
pub frozen: Frozen,
|
||||
pub arrange: bool,
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Event {
|
||||
Press(Arc<Press>),
|
||||
Preview(Stamp, Id, Option<Point>),
|
||||
Release(Stamp, Id, Option<Point>),
|
||||
Refused(u64, String),
|
||||
Cancel(Id),
|
||||
ArrangeNext,
|
||||
Unfocused,
|
||||
KeyboardBoundary,
|
||||
}
|
||||
pub struct Preview {
|
||||
pub token: Id,
|
||||
pub semantic: Arc<plan::SemanticPlan>,
|
||||
pub placement: plan::PlacementPlan,
|
||||
pub routed: Arc<RoutedView>,
|
||||
}
|
||||
struct Active {
|
||||
press: Arc<Press>,
|
||||
inspection_targets: Vec<usize>,
|
||||
ids: BTreeMap<Id, Id>,
|
||||
cache: Vec<(
|
||||
plan::Destination,
|
||||
Result<Arc<plan::SemanticPlan>, plan::Refusal>,
|
||||
)>,
|
||||
}
|
||||
pub struct Feedback {
|
||||
pub press: Arc<Press>,
|
||||
pub targets: Vec<usize>,
|
||||
/// Last captured target names for inspection only; never resolver input.
|
||||
pub inspection_targets: Vec<usize>,
|
||||
pub text: String,
|
||||
pub refused: bool,
|
||||
}
|
||||
impl Feedback {
|
||||
pub fn inspection_identity(&self) -> String {
|
||||
if self.refused && self.targets.is_empty() && !self.inspection_targets.is_empty() {
|
||||
let names = self
|
||||
.inspection_targets
|
||||
.iter()
|
||||
.map(|index| self.press.frozen.targets[*index].name.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" / ");
|
||||
format!(
|
||||
"{}\nLast target{}: {}\nReturn to the canvas to choose a destination; releasing here cancels the drag.",
|
||||
self.destination_identity(),
|
||||
if self.inspection_targets.len() > 1 {
|
||||
"s (ambiguous)"
|
||||
} else {
|
||||
""
|
||||
},
|
||||
names
|
||||
)
|
||||
} else {
|
||||
self.destination_identity()
|
||||
}
|
||||
}
|
||||
/// Captured accepted names, independent of clipped status/refusal prose.
|
||||
pub fn destination_identity(&self) -> String {
|
||||
match self.targets.as_slice() {
|
||||
[] => if self.press.arrange {
|
||||
"Arrange only"
|
||||
} else if self.refused {
|
||||
"No eligible destination"
|
||||
} else {
|
||||
"Outside performers"
|
||||
}
|
||||
.into(),
|
||||
[index] => self.press.frozen.targets[*index].name.clone(),
|
||||
indices => format!(
|
||||
"Ambiguous: {}",
|
||||
indices
|
||||
.iter()
|
||||
.map(|index| self.press.frozen.targets[*index].name.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" / ")
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
#[derive(Default)]
|
||||
pub struct State {
|
||||
pub epoch: u64,
|
||||
pub arrange_next: bool,
|
||||
active: Option<Active>,
|
||||
pub preview: Option<Arc<Preview>>,
|
||||
pub feedback: Option<Feedback>,
|
||||
}
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct Capability<'a> {
|
||||
pub state: &'a State,
|
||||
pub clean: bool,
|
||||
pub snapshot: usize,
|
||||
}
|
||||
impl State {
|
||||
fn retire(&mut self) {
|
||||
self.epoch = self.epoch.wrapping_add(1);
|
||||
self.active = None;
|
||||
self.preview = None;
|
||||
self.feedback = None;
|
||||
self.arrange_next = false;
|
||||
}
|
||||
}
|
||||
pub fn refusal(r: &plan::Refusal) -> String {
|
||||
match r {
|
||||
plan::Refusal::Ineligible(s) | plan::Refusal::InvalidCandidate(s) | plan::Refusal::LayoutRequired(s) => s.clone(),
|
||||
plan::Refusal::StaleScope => "The view changed. Start this drag again.".into(),
|
||||
plan::Refusal::MixedLayoutRequired => "This work has a distributed or mixed assignment. Drag a specific leaf; arranging this group needs a layout step.".into(),
|
||||
plan::Refusal::RoleExitRequired { message, .. } => message.clone(),
|
||||
plan::Refusal::MissingAllocationId(_) => "Assignment identity expired. Start this drag again.".into(),
|
||||
plan::Refusal::CollateralChangeRequired(records) => format!(
|
||||
"Assignment needs dependent edits to {}. Resolve these before dragging again.",
|
||||
records.iter().map(|r| r.name.as_str()).collect::<Vec<_>>().join(", ")
|
||||
),
|
||||
}
|
||||
}
|
||||
impl App {
|
||||
pub fn allocation_drag_clean(&self) -> bool {
|
||||
self.allocation_input_owner()
|
||||
&& !self.draft_dirty()
|
||||
&& !self.source_dirty()
|
||||
&& !self.behavior.has_pending()
|
||||
}
|
||||
pub(crate) fn allocation_input_owner(&self) -> bool {
|
||||
self.page == Page::Diagram
|
||||
&& self
|
||||
.active_diagram
|
||||
.and_then(|id| self.project().diagrams.get(&id))
|
||||
.is_some_and(|d| d.projection == ProjectionKind::Allocation)
|
||||
&& self.architecture.is_none()
|
||||
&& self.connection.is_none()
|
||||
&& self.model_menu.is_none()
|
||||
&& !self.reference_input_active()
|
||||
&& !self.busy
|
||||
&& !self.hosted.visible
|
||||
&& self.workflow.is_none()
|
||||
&& self.pending.is_none()
|
||||
&& self.file_prompt.is_none()
|
||||
&& !self.delete_pending
|
||||
&& self.diagram_usages.is_none()
|
||||
}
|
||||
pub fn allocation_capability(&self) -> Capability<'_> {
|
||||
Capability {
|
||||
state: &self.allocation_drag,
|
||||
clean: self.allocation_drag_clean(),
|
||||
snapshot: Arc::as_ptr(&self.document.snapshot()) as usize,
|
||||
}
|
||||
}
|
||||
fn allocation_stamp_valid(&self, stamp: Stamp) -> bool {
|
||||
self.allocation_drag_clean()
|
||||
&& self.route_context() == Some(stamp.context)
|
||||
&& self.allocation_drag.epoch == stamp.epoch
|
||||
&& Arc::as_ptr(&self.document.snapshot()) as usize == stamp.snapshot
|
||||
&& self.pan == stamp.pan
|
||||
&& self.zoom == stamp.zoom
|
||||
&& self.canvas_size.is_none_or(|s| s == stamp.bounds.size())
|
||||
&& stamp.bounds.width > 0.
|
||||
&& stamp.bounds.height > 0.
|
||||
}
|
||||
/// Called before general input dispatch. Even equal-valued actions (Fit,
|
||||
/// Undo/Redo, tool/modal round trips) retire old render-time capabilities.
|
||||
pub(super) fn allocation_before(&mut self, message: &Message) {
|
||||
if !matches!(
|
||||
message,
|
||||
Message::WorkDrag(_)
|
||||
| Message::CheckpointTick
|
||||
| Message::CheckpointWritten(..)
|
||||
| Message::Lint(_)
|
||||
| Message::Linted(..)
|
||||
| Message::CanvasResized(_)
|
||||
| Message::WindowResized(_)
|
||||
| Message::SourceViewport(_)
|
||||
| Message::SourceSymbolsReady(_)
|
||||
| Message::SourceRenameReady(_)
|
||||
| Message::DiagramUsagesReady(_)
|
||||
| Message::DiagramRevealViewport(..)
|
||||
) {
|
||||
self.allocation_drag.retire();
|
||||
}
|
||||
}
|
||||
pub(super) fn allocation_boundary(&self) -> impl PartialEq + use<> {
|
||||
(
|
||||
self.route_context(),
|
||||
self.pan,
|
||||
self.zoom,
|
||||
self.canvas_size,
|
||||
self.window_size,
|
||||
self.allocation_drag_clean(),
|
||||
Arc::as_ptr(&self.document.snapshot()) as usize,
|
||||
)
|
||||
}
|
||||
pub(super) fn allocation_retire(&mut self) {
|
||||
self.allocation_drag.retire();
|
||||
}
|
||||
fn select_work_clean(&mut self, press: &Press) -> bool {
|
||||
if !self.allocation_stamp_valid(press.stamp) {
|
||||
return false;
|
||||
}
|
||||
self.selected = press.scope.work;
|
||||
self.selected_node = Some(press.scope.placement);
|
||||
self.selected_bend = None;
|
||||
self.refresh_draft();
|
||||
true
|
||||
}
|
||||
pub(super) fn allocation_press_owns_current_view(&self, press: &Press) -> bool {
|
||||
self.allocation_drag.active.is_none()
|
||||
&& self.allocation_stamp_valid(press.stamp)
|
||||
&& press.arrange == self.allocation_drag.arrange_next
|
||||
}
|
||||
pub(super) fn allocation_event_envelope(&self, event: &Event) -> bool {
|
||||
match event {
|
||||
Event::Cancel(token) | Event::Preview(_, token, _) | Event::Release(_, token, _) => {
|
||||
self.allocation_drag
|
||||
.active
|
||||
.as_ref()
|
||||
.is_some_and(|active| active.press.token == *token)
|
||||
}
|
||||
Event::Refused(epoch, _) => *epoch == self.allocation_drag.epoch,
|
||||
// Invalid Press packets deliberately report why the view or draft
|
||||
// changed. Leave their existing validation and feedback intact.
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
pub(super) fn allocation_event(&mut self, event: Event) -> Task<Message> {
|
||||
if !self.allocation_event_envelope(&event) {
|
||||
return Task::none();
|
||||
}
|
||||
match event {
|
||||
Event::ArrangeNext => {
|
||||
self.allocation_drag.retire();
|
||||
if self.allocation_drag_clean() {
|
||||
self.allocation_drag.arrange_next = true;
|
||||
self.status = "Arrange next drag armed · moves work only · Esc cancels".into();
|
||||
} else {
|
||||
self.status = FINISH_EDIT.into();
|
||||
}
|
||||
}
|
||||
Event::Unfocused => self.allocation_drag.retire(),
|
||||
Event::KeyboardBoundary => {
|
||||
if self.allocation_drag.active.is_some() {
|
||||
self.allocation_drag.retire();
|
||||
}
|
||||
}
|
||||
Event::Cancel(token) => {
|
||||
if self
|
||||
.allocation_drag
|
||||
.active
|
||||
.as_ref()
|
||||
.is_some_and(|a| a.press.token == token)
|
||||
{
|
||||
self.allocation_drag.retire();
|
||||
self.status = "Work drag canceled".into();
|
||||
}
|
||||
}
|
||||
Event::Refused(epoch, text) => {
|
||||
if epoch == self.allocation_drag.epoch {
|
||||
self.allocation_drag.retire();
|
||||
self.status = text;
|
||||
}
|
||||
}
|
||||
Event::Press(press) => {
|
||||
if self.allocation_drag.active.is_some() {
|
||||
return Task::none();
|
||||
}
|
||||
if !self.allocation_press_owns_current_view(&press) {
|
||||
self.status = if self.allocation_drag_clean() {
|
||||
"The view changed. Start this drag again.".into()
|
||||
} else {
|
||||
FINISH_EDIT.into()
|
||||
};
|
||||
return Task::none();
|
||||
}
|
||||
let checked = plan::capture(
|
||||
self.project(),
|
||||
press.scope.diagram.id,
|
||||
press.scope.work,
|
||||
press.scope.placement,
|
||||
);
|
||||
if checked.as_ref() != Ok(&press.scope) || !self.select_work_clean(&press) {
|
||||
self.status = checked
|
||||
.err()
|
||||
.as_ref()
|
||||
.map(refusal)
|
||||
.unwrap_or_else(|| "The view changed. Start this drag again.".into());
|
||||
self.allocation_drag.retire();
|
||||
return Task::none();
|
||||
}
|
||||
let ids = press
|
||||
.scope
|
||||
.leaves
|
||||
.keys()
|
||||
.map(|id| (*id, Id::new_v4()))
|
||||
.collect();
|
||||
self.allocation_drag.arrange_next = false;
|
||||
self.allocation_drag.preview = None;
|
||||
self.allocation_drag.feedback = None;
|
||||
self.allocation_drag.active = Some(Active {
|
||||
press,
|
||||
inspection_targets: Vec::new(),
|
||||
ids,
|
||||
cache: Vec::new(),
|
||||
});
|
||||
}
|
||||
Event::Preview(stamp, token, pointer) => {
|
||||
self.allocation_motion(stamp, token, pointer, false)
|
||||
}
|
||||
Event::Release(stamp, token, pointer) => {
|
||||
self.allocation_motion(stamp, token, pointer, true)
|
||||
}
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
fn allocation_motion(
|
||||
&mut self,
|
||||
stamp: Stamp,
|
||||
token: Id,
|
||||
pointer: Option<Point>,
|
||||
release: bool,
|
||||
) {
|
||||
if self
|
||||
.allocation_drag
|
||||
.active
|
||||
.as_ref()
|
||||
.is_none_or(|a| a.press.token != token)
|
||||
{
|
||||
return; // An old packet cannot cancel a newer gesture.
|
||||
}
|
||||
if !self.allocation_stamp_valid(stamp) {
|
||||
self.allocation_drag.retire();
|
||||
self.status = if self.allocation_drag_clean() {
|
||||
"The view changed. Start this drag again.".into()
|
||||
} else {
|
||||
FINISH_EDIT.into()
|
||||
};
|
||||
return;
|
||||
}
|
||||
let Some(mut active) = self.allocation_drag.active.take() else {
|
||||
return;
|
||||
};
|
||||
if active.press.token != token || active.press.stamp != stamp {
|
||||
self.allocation_drag.active = Some(active);
|
||||
return;
|
||||
}
|
||||
let press = active.press.clone();
|
||||
let moved = pointer.is_some_and(|p| p.distance(press.start) > 3.);
|
||||
let hit = press.frozen.resolve(stamp, pointer, press.arrange);
|
||||
if moved {
|
||||
match &hit {
|
||||
Hit::Destination(_, Some(target)) => active.inspection_targets = vec![*target],
|
||||
Hit::Ambiguous(targets) => active.inspection_targets = targets.clone(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let mut targets = Vec::new();
|
||||
let mut preview = None;
|
||||
let result: Result<String, String> = match hit {
|
||||
Hit::OutsideViewport => Err(if release {
|
||||
"Work drag canceled outside the canvas."
|
||||
} else {
|
||||
"Pointer outside canvas · return to choose a destination; releasing here cancels the drag."
|
||||
}.into()),
|
||||
_ if !moved => Ok("Work selected".into()),
|
||||
Hit::Occluded => Err("Aim at exposed performer space or empty canvas; this card, port, connection or control blocks the drop.".into()),
|
||||
Hit::Ambiguous(indices) => {
|
||||
targets = indices;
|
||||
Err("Two unrelated performers overlap here. Use an exposed region.".into())
|
||||
}
|
||||
Hit::Destination(destination, target) => {
|
||||
targets.extend(target);
|
||||
let semantic = if let Some((_, result)) = active.cache.iter().find(|(d, _)| *d == destination) {
|
||||
result.clone()
|
||||
} else {
|
||||
let result = plan::plan_semantics(self.project(), &press.scope, destination, &active.ids).map(Arc::new);
|
||||
active.cache.push((destination, result.clone()));
|
||||
result
|
||||
};
|
||||
semantic.map_err(|r| refusal(&r)).and_then(|semantic| {
|
||||
if stamp.zoom * 16. < 9. {
|
||||
return Err("Make room or adjust the view, then drag again. Zoom in to read the work and destination.".into());
|
||||
}
|
||||
let pointer = pointer.unwrap();
|
||||
let delta = pointer - press.start;
|
||||
let original = press.scope.placements[&press.scope.placement].position;
|
||||
let requested = Position { x: original.x + delta.x / stamp.zoom, y: original.y + delta.y / stamp.zoom };
|
||||
let presentation = plan::PresentationContext {
|
||||
viewport: stamp.viewport(), zoom: stamp.zoom,
|
||||
target: target.map(|i| {
|
||||
let t = &press.frozen.targets[i];
|
||||
plan::TargetPresentation { placement: t.placement }
|
||||
}),
|
||||
};
|
||||
let placement = plan::place(&semantic, requested, presentation).map_err(|r| refusal(&r))?;
|
||||
let label = if semantic.warnings.is_empty() { semantic.label.clone() } else {
|
||||
format!("{} · {} model warning(s) retained", semantic.label, semantic.warnings.len())
|
||||
};
|
||||
if release {
|
||||
let command = plan::finish(&semantic, self.project(), &placement).map_err(|r| refusal(&r))?;
|
||||
if let Some(command) = command {
|
||||
// Bypass applying selectors and unrelated repair helpers. The
|
||||
// planner's exact Batch enters the authoritative source pipeline.
|
||||
self.document.execute(command).map_err(|e| format!("{} · retry this drag after correcting the source issue", e))?;
|
||||
self.sync();
|
||||
self.selected = press.scope.work;
|
||||
self.selected_node = Some(press.scope.placement);
|
||||
self.refresh_draft();
|
||||
}
|
||||
} else {
|
||||
let routed = Arc::new(routing::route_diagram(semantic.project(), &placement.diagram, &placement.view));
|
||||
preview = Some(Arc::new(Preview { token, semantic, placement, routed }));
|
||||
}
|
||||
Ok(label)
|
||||
})
|
||||
}
|
||||
};
|
||||
let refused = result.is_err();
|
||||
let text = result.unwrap_or_else(|text| text);
|
||||
self.status = text.clone();
|
||||
if release {
|
||||
self.allocation_drag.retire();
|
||||
} else {
|
||||
let inspection_targets = active.inspection_targets.clone();
|
||||
self.allocation_drag.active = Some(active);
|
||||
self.allocation_drag.preview = preview;
|
||||
self.allocation_drag.feedback = Some(Feedback {
|
||||
press,
|
||||
targets,
|
||||
inspection_targets,
|
||||
text,
|
||||
refused,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "allocation_drag_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,728 @@
|
||||
//! Actual App root, retained Iced tree, property widgets and production renderer.
|
||||
//! Unlike packet-only tests, these prove the next key still reaches the field.
|
||||
use super::*;
|
||||
use crate::behavior::canvas_workspace::tests::{Mounted, key};
|
||||
use iced::{Event as Input, keyboard};
|
||||
|
||||
fn character(text: &str, modifiers: keyboard::Modifiers) -> Input {
|
||||
Input::Keyboard(keyboard::Event::KeyPressed {
|
||||
key: keyboard::Key::Character(text.into()),
|
||||
modified_key: keyboard::Key::Character(text.into()),
|
||||
physical_key: keyboard::key::Physical::Code(keyboard::key::Code::KeyA),
|
||||
location: keyboard::Location::Standard,
|
||||
modifiers,
|
||||
text: Some(text.into()),
|
||||
repeat: false,
|
||||
})
|
||||
}
|
||||
async fn mount() -> (Fixture, Mounted) {
|
||||
let mut f = fixture(Layer::Oa);
|
||||
f.app.window_size = iced::Size::new(2000., 1400.);
|
||||
let _ = f.app.update(Message::Select(f.work));
|
||||
let mut mounted = Mounted::new(&mut f.app).await;
|
||||
// First sensor measurement intentionally Fits; now use a fixed known camera.
|
||||
f.app.zoom = 1.;
|
||||
f.app.pan = Vector::ZERO;
|
||||
f.app.fit_to_view = false;
|
||||
mounted.settle(&mut f.app).await;
|
||||
(f, mounted)
|
||||
}
|
||||
fn canvas_bounds(mounted: &mut Mounted, app: &App) -> Rectangle {
|
||||
mounted
|
||||
.geometry(app)
|
||||
.bounds(crate::allocation_input::CANVAS_ID)
|
||||
}
|
||||
fn at(mounted: &mut Mounted, f: &Fixture, world: Position) -> Point {
|
||||
let bounds = canvas_bounds(mounted, &f.app);
|
||||
Point::new(
|
||||
bounds.x + world.x * f.app.zoom + f.app.pan.x,
|
||||
bounds.y + world.y * f.app.zoom + f.app.pan.y,
|
||||
)
|
||||
}
|
||||
fn name_field(mounted: &mut Mounted, app: &App) -> Rectangle {
|
||||
mounted
|
||||
.geometry(app)
|
||||
.text_inputs
|
||||
.into_iter()
|
||||
.filter(|b| b.x > app.window_size.width - 320.)
|
||||
.min_by(|a, b| a.y.total_cmp(&b.y))
|
||||
.expect("actual inspector Name TextInput")
|
||||
}
|
||||
fn description_field(mounted: &mut Mounted, app: &App) -> Rectangle {
|
||||
mounted
|
||||
.geometry(app)
|
||||
.focused
|
||||
.into_iter()
|
||||
.map(|(_, bounds, _)| bounds)
|
||||
.filter(|b| b.x > app.window_size.width - 320. && b.height >= 90.)
|
||||
.min_by(|a, b| a.y.total_cmp(&b.y))
|
||||
.expect("actual inspector Description TextEditor")
|
||||
}
|
||||
fn is_focused(mounted: &mut Mounted, app: &App, field: Rectangle) -> bool {
|
||||
mounted
|
||||
.geometry(app)
|
||||
.focused
|
||||
.iter()
|
||||
.any(|(_, bounds, focused)| *bounds == field && *focused)
|
||||
}
|
||||
async fn type_key(mounted: &mut Mounted, app: &mut App, text: &str) -> Vec<Message> {
|
||||
mounted
|
||||
.batch(
|
||||
app,
|
||||
&[character(text, keyboard::Modifiers::empty())],
|
||||
mouse::Cursor::Unavailable,
|
||||
)
|
||||
.await
|
||||
}
|
||||
async fn refused_click_keeps_field(mounted: &mut Mounted, f: &mut Fixture, field: Rectangle) {
|
||||
let before = f.app.document.bundle().clone();
|
||||
let selected = (f.app.selected, f.app.selected_node);
|
||||
let point = at(mounted, f, xy(140., 535.));
|
||||
let messages = mounted.click(&mut f.app, point).await;
|
||||
assert!(
|
||||
messages
|
||||
.iter()
|
||||
.any(|m| matches!(m, Message::WorkDrag(Event::Refused(..))))
|
||||
);
|
||||
assert!(!messages.iter().any(|m| matches!(
|
||||
m,
|
||||
Message::Select(_) | Message::SelectNode(_) | Message::MoveNode(..)
|
||||
)));
|
||||
assert_eq!(f.app.document.bundle(), &before);
|
||||
assert_eq!((f.app.selected, f.app.selected_node), selected);
|
||||
assert!(
|
||||
is_focused(mounted, &f.app, field),
|
||||
"refused press must not reach the outside-press unfocus handler"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn actual_app_tree_dirty_name_retains_focus_and_middle_caret_for_next_key() {
|
||||
let (mut f, mut mounted) = mount().await;
|
||||
let field = name_field(&mut mounted, &f.app);
|
||||
mounted.click(&mut f.app, field.center()).await;
|
||||
mounted
|
||||
.batch(
|
||||
&mut f.app,
|
||||
&[key(keyboard::key::Named::Home)],
|
||||
mouse::Cursor::Unavailable,
|
||||
)
|
||||
.await;
|
||||
type_key(&mut mounted, &mut f.app, "X").await;
|
||||
mounted
|
||||
.batch(
|
||||
&mut f.app,
|
||||
&[key(keyboard::key::Named::ArrowLeft)],
|
||||
mouse::Cursor::Unavailable,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(f.app.draft.as_ref().unwrap().inputs["/name"].raw, "XWork");
|
||||
refused_click_keeps_field(&mut mounted, &mut f, field).await;
|
||||
let messages = type_key(&mut mounted, &mut f.app, "Y").await;
|
||||
assert!(
|
||||
messages
|
||||
.iter()
|
||||
.any(|m| matches!(m, Message::Field(path, _) if path == "/name"))
|
||||
);
|
||||
assert_eq!(f.app.draft.as_ref().unwrap().inputs["/name"].raw, "YXWork");
|
||||
assert!(!f.app.document.can_undo());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn actual_app_tree_dirty_description_retains_focus_and_middle_caret_for_next_key() {
|
||||
let (mut f, mut mounted) = mount().await;
|
||||
let field = description_field(&mut mounted, &f.app);
|
||||
mounted
|
||||
.click(&mut f.app, Point::new(field.x + 16., field.y + 16.))
|
||||
.await;
|
||||
type_key(&mut mounted, &mut f.app, "A").await;
|
||||
type_key(&mut mounted, &mut f.app, "B").await;
|
||||
mounted
|
||||
.batch(
|
||||
&mut f.app,
|
||||
&[key(keyboard::key::Named::ArrowLeft)],
|
||||
mouse::Cursor::Unavailable,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
f.app.draft.as_ref().unwrap().inputs["/description"].raw,
|
||||
"AB"
|
||||
);
|
||||
refused_click_keeps_field(&mut mounted, &mut f, field).await;
|
||||
let messages = type_key(&mut mounted, &mut f.app, "X").await;
|
||||
assert!(
|
||||
messages
|
||||
.iter()
|
||||
.any(|m| matches!(m, Message::FieldText(path, _) if path == "/description"))
|
||||
);
|
||||
assert_eq!(
|
||||
f.app.draft.as_ref().unwrap().inputs["/description"].raw,
|
||||
"AXB"
|
||||
);
|
||||
assert!(!f.app.document.can_undo());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn actual_keyboard_arrange_shortcut_consumes_once_escape_cancels_and_dirty_owner_refuses() {
|
||||
let (mut f, mut mounted) = mount().await;
|
||||
let shortcut = character("a", keyboard::Modifiers::ALT);
|
||||
let messages = mounted
|
||||
.batch(
|
||||
&mut f.app,
|
||||
std::slice::from_ref(&shortcut),
|
||||
mouse::Cursor::Unavailable,
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
messages
|
||||
.iter()
|
||||
.any(|m| matches!(m, Message::WorkDrag(Event::ArrangeNext)))
|
||||
);
|
||||
assert!(f.app.allocation_drag.arrange_next);
|
||||
mounted
|
||||
.batch(
|
||||
&mut f.app,
|
||||
&[key(keyboard::key::Named::Escape)],
|
||||
mouse::Cursor::Unavailable,
|
||||
)
|
||||
.await;
|
||||
assert!(!f.app.allocation_drag.arrange_next);
|
||||
mounted
|
||||
.batch(
|
||||
&mut f.app,
|
||||
std::slice::from_ref(&shortcut),
|
||||
mouse::Cursor::Unavailable,
|
||||
)
|
||||
.await;
|
||||
let from = at(&mut mounted, &f, xy(140., 535.));
|
||||
let to = from + Vector::new(30., 0.);
|
||||
let messages = mounted
|
||||
.batch(
|
||||
&mut f.app,
|
||||
&[
|
||||
Input::Mouse(mouse::Event::CursorMoved { position: from }),
|
||||
Input::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
|
||||
Input::Mouse(mouse::Event::CursorMoved { position: to }),
|
||||
Input::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)),
|
||||
],
|
||||
mouse::Cursor::Available(to),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
messages
|
||||
.iter()
|
||||
.any(|m| matches!(m, Message::WorkDrag(Event::Press(p)) if p.arrange))
|
||||
);
|
||||
assert!(!f.app.allocation_drag.arrange_next);
|
||||
assert_eq!(f.app.document.revision(), 1, "{}", f.app.status);
|
||||
assert!(f.app.project().relations.is_empty());
|
||||
let field = name_field(&mut mounted, &f.app);
|
||||
mounted.click(&mut f.app, field.center()).await;
|
||||
type_key(&mut mounted, &mut f.app, "X").await;
|
||||
let raw = f.app.draft.as_ref().unwrap().inputs["/name"].raw.clone();
|
||||
let before = f.app.document.bundle().clone();
|
||||
mounted
|
||||
.batch(&mut f.app, &[shortcut], mouse::Cursor::Unavailable)
|
||||
.await;
|
||||
assert!(!f.app.allocation_drag.arrange_next);
|
||||
assert_eq!(f.app.status, FINISH_EDIT);
|
||||
assert!(is_focused(&mut mounted, &f.app, field));
|
||||
assert_eq!(f.app.draft.as_ref().unwrap().inputs["/name"].raw, raw);
|
||||
assert_eq!(f.app.document.bundle(), &before);
|
||||
}
|
||||
|
||||
fn canvas_pixels(pixels: &[u8], width: usize, canvas: Rectangle) -> Vec<u8> {
|
||||
let x = canvas.x.ceil() as usize;
|
||||
let y = canvas.y.ceil() as usize;
|
||||
let right = (canvas.x + canvas.width).floor() as usize;
|
||||
let bottom = (canvas.y + canvas.height).floor() as usize;
|
||||
(y..bottom)
|
||||
.flat_map(|row| {
|
||||
pixels[(row * width + x) * 4..(row * width + right) * 4]
|
||||
.iter()
|
||||
.copied()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn actual_rendered_canvas_pixels_do_not_change_when_toolbar_feedback_text_changes() {
|
||||
let (mut f, mut mounted) = mount().await;
|
||||
let mut project = f.app.project().clone();
|
||||
let target = project
|
||||
.diagrams
|
||||
.get_mut(&f.diagram)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.values_mut()
|
||||
.find(|n| n.element == f.a)
|
||||
.unwrap();
|
||||
target.position.y = -41.;
|
||||
target.size.height = 300.;
|
||||
replace(&mut f, project);
|
||||
f.app.zoom = 0.75;
|
||||
f.app.pan = Vector::new(15., 15.);
|
||||
let from = at(&mut mounted, &f, xy(140., 535.));
|
||||
let to = at(&mut mounted, &f, xy(200., 80.));
|
||||
mounted
|
||||
.batch(
|
||||
&mut f.app,
|
||||
&[
|
||||
Input::Mouse(mouse::Event::CursorMoved { position: from }),
|
||||
Input::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
|
||||
Input::Mouse(mouse::Event::CursorMoved { position: to }),
|
||||
],
|
||||
mouse::Cursor::Available(to),
|
||||
)
|
||||
.await;
|
||||
let feedback = f
|
||||
.app
|
||||
.allocation_drag
|
||||
.feedback
|
||||
.as_ref()
|
||||
.expect("active frozen destination");
|
||||
assert!(!feedback.refused, "{}", feedback.text);
|
||||
let target = &feedback.press.frozen.targets[feedback.targets[0]];
|
||||
assert!(target.region.y * f.app.zoom + f.app.pan.y < 0.);
|
||||
assert_eq!(feedback.destination_identity(), "A");
|
||||
let canvas = canvas_bounds(&mut mounted, &f.app);
|
||||
let first = mounted.pixels(&f.app);
|
||||
f.app.allocation_drag.feedback.as_mut().unwrap().text =
|
||||
"Very different drag status, retained outside the readable canvas. ".repeat(10);
|
||||
let second = mounted.pixels(&f.app);
|
||||
assert_ne!(first, second, "the actual toolbar status changes");
|
||||
assert_eq!(
|
||||
canvas_pixels(&first, f.app.window_size.width as usize, canvas),
|
||||
canvas_pixels(&second, f.app.window_size.width as usize, canvas),
|
||||
"status must not obscure any candidate header or destination identity"
|
||||
);
|
||||
}
|
||||
|
||||
fn destination_bounds(mounted: &mut Mounted, app: &App) -> Rectangle {
|
||||
mounted.geometry(app).bounds("allocation-destination")
|
||||
}
|
||||
fn assert_on_screen(bounds: Rectangle, app: &App) {
|
||||
assert!(
|
||||
bounds.x >= 0. && bounds.y >= 0. && bounds.width >= 400. && bounds.height == 26.,
|
||||
"{bounds:?}"
|
||||
);
|
||||
assert!(
|
||||
bounds.x + bounds.width <= app.window_size.width
|
||||
&& bounds.y + bounds.height <= app.window_size.height,
|
||||
"{bounds:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn actual_app_tree_exact_incoming_drag_keeps_destination_visible_and_canvas_stable() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
for (minimum, name, collapsed, zoom, pan) in [
|
||||
(false, "Receiver", false, 1., Vector::ZERO),
|
||||
(false, "Receiver", false, 0.75, Vector::new(37., -15.)),
|
||||
(
|
||||
false,
|
||||
"Receiving subsystem",
|
||||
true,
|
||||
1.25,
|
||||
Vector::new(-30., 23.),
|
||||
),
|
||||
(
|
||||
false,
|
||||
"Receiving subsystem",
|
||||
true,
|
||||
1.,
|
||||
Vector::new(0., -100.),
|
||||
),
|
||||
(true, "Receiving subsystem", true, 1., Vector::ZERO),
|
||||
(
|
||||
true,
|
||||
"Receiving subsystem WWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWW",
|
||||
true,
|
||||
1.,
|
||||
Vector::new(0., -100.),
|
||||
),
|
||||
] {
|
||||
let mut f = fixture(layer);
|
||||
let mut project = f.app.project().clone();
|
||||
project.elements.get_mut(&f.a).unwrap().name = name.into();
|
||||
let diagram = project.diagrams.get_mut(&f.diagram).unwrap();
|
||||
let target = diagram
|
||||
.nodes
|
||||
.values_mut()
|
||||
.find(|n| n.element == f.a)
|
||||
.unwrap();
|
||||
target.collapsed = collapsed;
|
||||
let target_id = target.id;
|
||||
// At minimum size, keep the source in the visible canvas; preserve
|
||||
// the same standard (40,35) name grab and original target geometry.
|
||||
let source_y = if minimum { 210. } else { 500. };
|
||||
diagram.nodes.get_mut(&f.node).unwrap().position.y = source_y;
|
||||
replace(&mut f, project);
|
||||
f.app.layer = layer;
|
||||
f.app.window_size = if minimum {
|
||||
iced::Size::new(1100., 700.)
|
||||
} else {
|
||||
iced::Size::new(2000., 1400.)
|
||||
};
|
||||
let _ = f.app.update(Message::Select(f.work));
|
||||
let mut mounted = Mounted::new(&mut f.app).await;
|
||||
f.app.zoom = zoom;
|
||||
f.app.pan = pan;
|
||||
f.app.fit_to_view = false;
|
||||
mounted.settle(&mut f.app).await;
|
||||
let canvas = canvas_bounds(&mut mounted, &f.app);
|
||||
let slot = destination_bounds(&mut mounted, &f.app);
|
||||
assert_on_screen(slot, &f.app);
|
||||
assert!(slot.y + slot.height <= canvas.y);
|
||||
let idle = mounted.pixels(&f.app);
|
||||
let before = f.app.project().clone();
|
||||
let source = f.app.document.bundle().clone();
|
||||
let from = at(&mut mounted, &f, xy(140., source_y + 35.));
|
||||
// Use the middle of the snap cell. Fractional App-layout origins
|
||||
// can perturb an exact half-grid tie by one floating-point ULP.
|
||||
// The packet/planner cases retain the original y140 counterexample.
|
||||
let to = at(&mut mounted, &f, xy(160., 145.));
|
||||
assert!(
|
||||
canvas.contains(from) && canvas.contains(to),
|
||||
"{canvas:?} {from:?} {to:?}"
|
||||
);
|
||||
mounted
|
||||
.batch(
|
||||
&mut f.app,
|
||||
&[
|
||||
Input::Mouse(mouse::Event::CursorMoved { position: from }),
|
||||
Input::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
|
||||
Input::Mouse(mouse::Event::CursorMoved { position: to }),
|
||||
],
|
||||
mouse::Cursor::Available(to),
|
||||
)
|
||||
.await;
|
||||
let feedback = f
|
||||
.app
|
||||
.allocation_drag
|
||||
.feedback
|
||||
.as_ref()
|
||||
.expect(&f.app.status);
|
||||
assert!(!feedback.refused, "{}", feedback.text);
|
||||
assert_eq!(feedback.destination_identity(), name);
|
||||
let preview = f.app.allocation_drag.preview.as_ref().expect(&f.app.status);
|
||||
assert_eq!(
|
||||
preview.placement.diagram.nodes[&f.node].position,
|
||||
xy(120., 110.),
|
||||
"{layer:?}, minimum={minimum}, zoom={zoom}, pan={pan:?}, canvas={canvas:?}"
|
||||
);
|
||||
assert!(!preview.placement.diagram.nodes[&target_id].collapsed);
|
||||
let selected = preview
|
||||
.placement
|
||||
.view
|
||||
.nodes
|
||||
.iter()
|
||||
.find(|node| node.id == f.node)
|
||||
.unwrap();
|
||||
let selected_header = Rectangle::new(
|
||||
Point::new(
|
||||
canvas.x + selected.position.x * zoom + pan.x,
|
||||
canvas.y + selected.position.y * zoom + pan.y,
|
||||
),
|
||||
iced::Size::new(
|
||||
selected.size.width * zoom,
|
||||
selected.size.height.min(62.) * zoom,
|
||||
),
|
||||
);
|
||||
assert!(canvas.contains(Point::new(selected_header.x, selected_header.y)));
|
||||
assert!(
|
||||
selected_header.x + selected_header.width <= canvas.x + canvas.width
|
||||
&& selected_header.y + selected_header.height <= canvas.y + canvas.height
|
||||
);
|
||||
let mut expected = preview.semantic.project().clone();
|
||||
expected
|
||||
.diagrams
|
||||
.insert(f.diagram, preview.placement.diagram.clone());
|
||||
assert_eq!(f.app.project(), &before);
|
||||
assert_eq!(f.app.document.bundle(), &source);
|
||||
assert!(!f.app.document.can_undo());
|
||||
assert_eq!(canvas_bounds(&mut mounted, &f.app), canvas);
|
||||
assert_eq!(destination_bounds(&mut mounted, &f.app), slot);
|
||||
let active = mounted.pixels(&f.app);
|
||||
if minimum {
|
||||
record_render_evidence(
|
||||
&format!(
|
||||
"{layer:?}-minimum-{}",
|
||||
if pan.y < 0. { "top" } else { "normal" }
|
||||
),
|
||||
&active,
|
||||
&f.app,
|
||||
slot,
|
||||
canvas,
|
||||
);
|
||||
}
|
||||
let width = f.app.window_size.width as usize;
|
||||
assert_ne!(
|
||||
canvas_pixels(&idle, width, slot),
|
||||
canvas_pixels(&active, width, slot),
|
||||
"actual destination text is painted in its dedicated visible slot"
|
||||
);
|
||||
f.app.allocation_drag.feedback.as_mut().unwrap().text =
|
||||
"Long refusal/status prose must not obscure work or destination. ".repeat(30);
|
||||
let verbose = mounted.pixels(&f.app);
|
||||
assert_ne!(active, verbose);
|
||||
assert_eq!(
|
||||
canvas_pixels(&active, width, canvas),
|
||||
canvas_pixels(&verbose, width, canvas)
|
||||
);
|
||||
assert_eq!(
|
||||
canvas_pixels(&active, width, slot),
|
||||
canvas_pixels(&verbose, width, slot)
|
||||
);
|
||||
assert_eq!(canvas_bounds(&mut mounted, &f.app), canvas);
|
||||
if pan.y == -100. {
|
||||
// A genuine clipping refusal in the same held gesture, then
|
||||
// recovery at the original exact point: no camera rebasing.
|
||||
let clipped = at(&mut mounted, &f, xy(160., 110.));
|
||||
mounted
|
||||
.batch(
|
||||
&mut f.app,
|
||||
&[Input::Mouse(mouse::Event::CursorMoved {
|
||||
position: clipped,
|
||||
})],
|
||||
mouse::Cursor::Available(clipped),
|
||||
)
|
||||
.await;
|
||||
assert!(f.app.allocation_drag.feedback.as_ref().unwrap().refused);
|
||||
assert!(f.app.allocation_drag.preview.is_none());
|
||||
assert_eq!(canvas_bounds(&mut mounted, &f.app), canvas);
|
||||
assert_eq!(destination_bounds(&mut mounted, &f.app), slot);
|
||||
assert_eq!(f.app.project(), &before);
|
||||
assert_eq!(f.app.document.bundle(), &source);
|
||||
mounted
|
||||
.batch(
|
||||
&mut f.app,
|
||||
&[Input::Mouse(mouse::Event::CursorMoved { position: to })],
|
||||
mouse::Cursor::Available(to),
|
||||
)
|
||||
.await;
|
||||
assert!(!f.app.allocation_drag.feedback.as_ref().unwrap().refused);
|
||||
}
|
||||
mounted
|
||||
.batch(
|
||||
&mut f.app,
|
||||
&[Input::Mouse(mouse::Event::ButtonReleased(
|
||||
mouse::Button::Left,
|
||||
))],
|
||||
mouse::Cursor::Available(to),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(f.app.project(), &expected, "{}", f.app.status);
|
||||
assert_eq!(f.app.document.revision(), 1);
|
||||
assert_eq!(canvas_bounds(&mut mounted, &f.app), canvas);
|
||||
assert_eq!(destination_bounds(&mut mounted, &f.app), slot);
|
||||
let after = f.app.document.bundle().clone();
|
||||
let _ = f.app.update(Message::Undo);
|
||||
assert_eq!(f.app.project(), &before);
|
||||
assert_eq!(f.app.document.bundle(), &source);
|
||||
assert!(!f.app.document.can_undo());
|
||||
let _ = f.app.update(Message::Redo);
|
||||
assert_eq!(f.app.project(), &expected);
|
||||
assert_eq!(f.app.document.bundle(), &after);
|
||||
assert!(!f.app.document.can_redo());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Optional reproducible visual evidence; normal test runs create no artifacts.
|
||||
fn record_render_evidence(
|
||||
name: &str,
|
||||
pixels: &[u8],
|
||||
app: &App,
|
||||
slot: Rectangle,
|
||||
canvas: Rectangle,
|
||||
) {
|
||||
let Some(path) = std::env::var_os("ARCADIA_RENDER_EVIDENCE_DIR") else {
|
||||
return;
|
||||
};
|
||||
let path = std::path::PathBuf::from(path);
|
||||
std::fs::create_dir_all(&path).unwrap();
|
||||
let mut file = std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(path.join(format!("{name}.rgba")))
|
||||
.unwrap();
|
||||
std::io::Write::write_all(&mut file, pixels).unwrap();
|
||||
let metadata = serde_json::json!({
|
||||
"width": app.window_size.width as u32, "height": app.window_size.height as u32,
|
||||
"destination": [slot.x, slot.y, slot.width, slot.height],
|
||||
"canvas": [canvas.x, canvas.y, canvas.width, canvas.height],
|
||||
"identity": app.allocation_drag.feedback.as_ref().map(|f| f.destination_identity()),
|
||||
});
|
||||
let mut file = std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(path.join(format!("{name}.json")))
|
||||
.unwrap();
|
||||
std::io::Write::write_all(&mut file, &serde_json::to_vec_pretty(&metadata).unwrap()).unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn actual_toolbar_hover_inspects_last_full_name_without_authorizing_an_outside_drop() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let mut f = fixture(layer);
|
||||
let name = "Receiving subsystem with a long exact identity beyond the compact label";
|
||||
let mut project = f.app.project().clone();
|
||||
project.elements.get_mut(&f.a).unwrap().name = name.into();
|
||||
project
|
||||
.diagrams
|
||||
.get_mut(&f.diagram)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&f.node)
|
||||
.unwrap()
|
||||
.position
|
||||
.y = 210.;
|
||||
replace(&mut f, project);
|
||||
f.app.layer = layer;
|
||||
f.app.window_size = iced::Size::new(1100., 700.);
|
||||
let _ = f.app.update(Message::Select(f.work));
|
||||
let mut mounted = Mounted::new(&mut f.app).await;
|
||||
f.app.zoom = 1.;
|
||||
f.app.pan = Vector::ZERO;
|
||||
f.app.fit_to_view = false;
|
||||
mounted.settle(&mut f.app).await;
|
||||
let canvas = canvas_bounds(&mut mounted, &f.app);
|
||||
let slot = destination_bounds(&mut mounted, &f.app);
|
||||
let from = at(&mut mounted, &f, xy(140., 245.));
|
||||
let to = at(&mut mounted, &f, xy(160., 145.));
|
||||
let before = f.app.document.bundle().clone();
|
||||
mounted
|
||||
.batch(
|
||||
&mut f.app,
|
||||
&[
|
||||
Input::Mouse(mouse::Event::CursorMoved { position: from }),
|
||||
Input::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
|
||||
Input::Mouse(mouse::Event::CursorMoved { position: to }),
|
||||
],
|
||||
mouse::Cursor::Available(to),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
f.app
|
||||
.allocation_drag
|
||||
.feedback
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.destination_identity(),
|
||||
name
|
||||
);
|
||||
assert!(f.app.allocation_drag.preview.is_some());
|
||||
let hover = Point::new(slot.x + 45., slot.y + 8.);
|
||||
mounted
|
||||
.batch(
|
||||
&mut f.app,
|
||||
&[Input::Mouse(mouse::Event::CursorMoved { position: hover })],
|
||||
mouse::Cursor::Available(hover),
|
||||
)
|
||||
.await;
|
||||
let feedback = f.app.allocation_drag.feedback.as_ref().unwrap();
|
||||
assert!(feedback.refused);
|
||||
assert!(feedback.targets.is_empty());
|
||||
assert_eq!(feedback.destination_identity(), "No eligible destination");
|
||||
assert!(
|
||||
feedback
|
||||
.inspection_identity()
|
||||
.contains(&format!("Last target: {name}"))
|
||||
);
|
||||
assert!(f.app.allocation_drag.preview.is_none());
|
||||
assert!(f.app.allocation_drag.active.is_some());
|
||||
assert!(f.app.status.starts_with("Pointer outside canvas"));
|
||||
let inspected = mounted.pixels_at(&f.app, mouse::Cursor::Available(hover));
|
||||
// Iced's Tooltip overlay draws its child but exposes no Operation
|
||||
// traversal. Compare the real rendered overlay instead of inventing
|
||||
// container geometry that the installed widget cannot report.
|
||||
let inspection = std::mem::take(
|
||||
&mut f
|
||||
.app
|
||||
.allocation_drag
|
||||
.feedback
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.inspection_targets,
|
||||
);
|
||||
let without_name = mounted.pixels_at(&f.app, mouse::Cursor::Available(hover));
|
||||
let width = f.app.window_size.width as usize;
|
||||
assert_ne!(
|
||||
inspected, without_name,
|
||||
"full captured name changes the actual hovered tooltip"
|
||||
);
|
||||
assert_eq!(
|
||||
canvas_pixels(&inspected, width, canvas),
|
||||
canvas_pixels(&without_name, width, canvas)
|
||||
);
|
||||
assert_eq!(
|
||||
canvas_pixels(&inspected, width, slot),
|
||||
canvas_pixels(&without_name, width, slot)
|
||||
);
|
||||
f.app
|
||||
.allocation_drag
|
||||
.feedback
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.inspection_targets = inspection;
|
||||
assert_eq!(canvas_bounds(&mut mounted, &f.app), canvas);
|
||||
record_render_evidence(
|
||||
&format!("{layer:?}-name-inspection"),
|
||||
&inspected,
|
||||
&f.app,
|
||||
slot,
|
||||
canvas,
|
||||
);
|
||||
assert_eq!(f.app.document.bundle(), &before);
|
||||
assert!(!f.app.document.can_undo());
|
||||
|
||||
// Inspection grants no mutation authority. Releasing over it cancels.
|
||||
mounted
|
||||
.batch(
|
||||
&mut f.app,
|
||||
&[Input::Mouse(mouse::Event::ButtonReleased(
|
||||
mouse::Button::Left,
|
||||
))],
|
||||
mouse::Cursor::Available(hover),
|
||||
)
|
||||
.await;
|
||||
assert!(f.app.allocation_drag.active.is_none());
|
||||
assert!(f.app.allocation_drag.feedback.is_none());
|
||||
assert_eq!(f.app.status, "Work drag canceled outside the canvas.");
|
||||
assert_eq!(f.app.document.bundle(), &before);
|
||||
assert!(!f.app.document.can_undo());
|
||||
// A fresh gesture cannot inherit the previous inspection name.
|
||||
mounted
|
||||
.batch(
|
||||
&mut f.app,
|
||||
&[
|
||||
Input::Mouse(mouse::Event::CursorMoved { position: from }),
|
||||
Input::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
|
||||
Input::Mouse(mouse::Event::CursorMoved { position: hover }),
|
||||
],
|
||||
mouse::Cursor::Available(hover),
|
||||
)
|
||||
.await;
|
||||
let feedback = f.app.allocation_drag.feedback.as_ref().unwrap();
|
||||
assert!(feedback.inspection_targets.is_empty());
|
||||
assert!(!feedback.inspection_identity().contains(name));
|
||||
mounted
|
||||
.batch(
|
||||
&mut f.app,
|
||||
&[
|
||||
Input::Mouse(mouse::Event::CursorMoved { position: to }),
|
||||
Input::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)),
|
||||
],
|
||||
mouse::Cursor::Available(to),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
validation::effective_performer(f.app.project(), f.work),
|
||||
Some(f.a)
|
||||
);
|
||||
assert_eq!(f.app.document.revision(), 1);
|
||||
let _ = f.app.update(Message::Undo);
|
||||
assert_eq!(f.app.document.bundle(), &before);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,203 @@
|
||||
//! Captured real canvas packets against a later navigation or live gesture.
|
||||
use super::*;
|
||||
use crate::diagram_usage::{self as usage, Target};
|
||||
|
||||
fn navigate_work(f: &mut Fixture) -> usage::Usage {
|
||||
let message = f.app.diagram_usage_message(f.work);
|
||||
let _ = f.app.update(message);
|
||||
let panel = f.app.diagram_usages.as_ref().unwrap();
|
||||
let token = panel.token;
|
||||
let context = panel.context;
|
||||
let process = f.app.behavior.topology.usage_snapshot().into_query_state();
|
||||
let completion = usage::Completion {
|
||||
token,
|
||||
context,
|
||||
result: Ok(usage::collect(
|
||||
f.app.project(),
|
||||
f.work,
|
||||
&process,
|
||||
&crate::behavior::scenario::State::default(),
|
||||
)),
|
||||
};
|
||||
let _ = f.app.update(Message::DiagramUsagesReady(completion));
|
||||
let found = f
|
||||
.app
|
||||
.diagram_usages
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.usages
|
||||
.iter()
|
||||
.find(|usage| usage.target == Target::Placement(f.node))
|
||||
.unwrap()
|
||||
.clone();
|
||||
let _ = f.app.update(Message::DiagramUsage(usage::Event::Choose(
|
||||
token,
|
||||
found.clone(),
|
||||
)));
|
||||
assert!(f.app.pending_diagram_reveal.is_some());
|
||||
assert!(f.app.diagram_focus(found.surface).is_some());
|
||||
found
|
||||
}
|
||||
|
||||
fn assert_stale_noop(app: &mut App, message: Message) {
|
||||
let before = app.document.snapshot();
|
||||
let selection = (app.selected, app.selected_node, app.active_diagram);
|
||||
let camera = (app.zoom, app.pan, app.canvas_epoch, app.camera_epoch);
|
||||
let pending = app.pending_diagram_reveal.clone();
|
||||
let focus = app
|
||||
.diagram_navigation_focus
|
||||
.as_ref()
|
||||
.map(|focus| (focus.context, focus.usage.clone(), focus.selected));
|
||||
let allocation = (app.allocation_drag.epoch, app.allocation_drag.arrange_next);
|
||||
let active = app
|
||||
.allocation_drag
|
||||
.active
|
||||
.as_ref()
|
||||
.map(|active| (active.press.token, active.cache.len()));
|
||||
let interaction = app.source_symbols.interaction;
|
||||
let status = app.status.clone();
|
||||
let _ = app.update(message);
|
||||
assert_eq!(
|
||||
(app.selected, app.selected_node, app.active_diagram),
|
||||
selection
|
||||
);
|
||||
assert_eq!(
|
||||
(app.zoom, app.pan, app.canvas_epoch, app.camera_epoch),
|
||||
camera
|
||||
);
|
||||
assert_eq!(app.pending_diagram_reveal, pending);
|
||||
assert_eq!(
|
||||
app.diagram_navigation_focus.as_ref().map(|focus| (
|
||||
focus.context,
|
||||
focus.usage.clone(),
|
||||
focus.selected
|
||||
)),
|
||||
focus
|
||||
);
|
||||
assert_eq!(
|
||||
(app.allocation_drag.epoch, app.allocation_drag.arrange_next),
|
||||
allocation
|
||||
);
|
||||
assert_eq!(
|
||||
app.allocation_drag
|
||||
.active
|
||||
.as_ref()
|
||||
.map(|active| (active.press.token, active.cache.len())),
|
||||
active
|
||||
);
|
||||
assert_eq!(app.source_symbols.interaction, interaction);
|
||||
assert_eq!(app.status, status);
|
||||
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
|
||||
assert_eq!(app.source_workspace.bundle(), before.bundle);
|
||||
}
|
||||
|
||||
fn stale_packets(press: &Press) -> Vec<Message> {
|
||||
vec![
|
||||
Message::WorkDrag(Event::Cancel(press.token)),
|
||||
Message::WorkDrag(Event::Preview(press.stamp, press.token, Some(press.start))),
|
||||
Message::WorkDrag(Event::Release(press.stamp, press.token, Some(press.start))),
|
||||
Message::WorkDrag(Event::Refused(press.stamp.epoch, "old refusal".into())),
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_gesture_packets_preserve_navigation_and_a_new_live_cancel_still_works() {
|
||||
let mut f = fixture(Layer::Oa);
|
||||
let before = f.app.document.snapshot();
|
||||
let old = press(&f);
|
||||
let _ = f.app.update(Message::WorkDrag(Event::Press(old.clone())));
|
||||
assert_eq!(
|
||||
f.app.allocation_drag.active.as_ref().unwrap().press.token,
|
||||
old.token
|
||||
);
|
||||
let found = navigate_work(&mut f);
|
||||
assert!(f.app.allocation_drag.active.is_none());
|
||||
assert_ne!(f.app.allocation_drag.epoch, old.stamp.epoch);
|
||||
for message in stale_packets(&old) {
|
||||
assert_stale_noop(&mut f.app, message);
|
||||
}
|
||||
// A rejected press still explains the invalid view, but cannot consume the
|
||||
// new destination's pending first measurement.
|
||||
let pending = f.app.pending_diagram_reveal.clone();
|
||||
let epoch = f.app.allocation_drag.epoch;
|
||||
let _ = f.app.update(Message::WorkDrag(Event::Press(old.clone())));
|
||||
assert_eq!(f.app.pending_diagram_reveal, pending);
|
||||
assert_eq!(f.app.allocation_drag.epoch, epoch);
|
||||
assert!(f.app.diagram_focus(found.surface).is_some());
|
||||
assert_eq!(f.app.status, "The view changed. Start this drag again.");
|
||||
let (context, _, epoch) = f.app.pending_diagram_reveal.clone().unwrap();
|
||||
let _ = f.app.update(Message::DiagramRevealViewport(
|
||||
context,
|
||||
epoch,
|
||||
bounds().size(),
|
||||
));
|
||||
assert!(f.app.pending_diagram_reveal.is_none());
|
||||
assert!(f.app.diagram_focus(found.surface).is_some());
|
||||
|
||||
// Restore the fixture's camera through current App actions, then capture a
|
||||
// fresh packet through the actual DiagramCanvas rather than seeding active.
|
||||
let factor = 1. / f.app.zoom;
|
||||
let _ = f.app.update(Message::Zoom(factor));
|
||||
let delta = -f.app.pan;
|
||||
let _ = f.app.update(Message::Pan(delta));
|
||||
let current = press(&f);
|
||||
let _ = f
|
||||
.app
|
||||
.update(Message::WorkDrag(Event::Press(current.clone())));
|
||||
assert_eq!(
|
||||
f.app.allocation_drag.active.as_ref().unwrap().press.token,
|
||||
current.token
|
||||
);
|
||||
assert!(
|
||||
f.app.diagram_navigation_focus.is_none(),
|
||||
"live press retires navigation"
|
||||
);
|
||||
for message in stale_packets(&old) {
|
||||
assert_stale_noop(&mut f.app, message);
|
||||
}
|
||||
let epoch = f.app.allocation_drag.epoch;
|
||||
let _ = f
|
||||
.app
|
||||
.update(Message::WorkDrag(Event::Cancel(current.token)));
|
||||
assert!(f.app.allocation_drag.active.is_none());
|
||||
assert!(f.app.allocation_drag.epoch > epoch);
|
||||
assert_eq!(f.app.status, "Work drag canceled");
|
||||
assert!(Arc::ptr_eq(&before, &f.app.document.snapshot()));
|
||||
assert_eq!(f.app.source_workspace.bundle(), before.bundle);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn old_chooser_envelopes_preserve_a_current_canvas_gesture() {
|
||||
let mut previous = fixture(Layer::Oa);
|
||||
let old_open = previous.app.diagram_usage_message(previous.work);
|
||||
let _ = previous.app.update(old_open.clone());
|
||||
let old_token = previous.app.diagram_usages.as_ref().unwrap().token;
|
||||
let mut f = fixture(Layer::Oa);
|
||||
let current = press(&f);
|
||||
let _ = f
|
||||
.app
|
||||
.update(Message::WorkDrag(Event::Press(current.clone())));
|
||||
assert_stale_noop(&mut f.app, old_open);
|
||||
assert_stale_noop(
|
||||
&mut f.app,
|
||||
Message::DiagramUsage(usage::Event::Close(old_token)),
|
||||
);
|
||||
assert_stale_noop(
|
||||
&mut f.app,
|
||||
Message::DiagramUsage(usage::Event::Search(old_token, "old".into())),
|
||||
);
|
||||
assert_eq!(
|
||||
f.app.allocation_drag.active.as_ref().unwrap().press.token,
|
||||
current.token
|
||||
);
|
||||
let open = f.app.diagram_usage_message(f.work);
|
||||
let _ = f.app.update(open);
|
||||
assert!(
|
||||
f.app.diagram_usages.is_some(),
|
||||
"current chooser remains live"
|
||||
);
|
||||
assert!(
|
||||
f.app.allocation_drag.active.is_none(),
|
||||
"current chooser retires gesture"
|
||||
);
|
||||
}
|
||||
@@ -138,7 +138,7 @@ pub fn validate_saved(p: &Project, s: &Saved) -> Result<(), String> {
|
||||
.diagrams
|
||||
.get(&s.diagram)
|
||||
.ok_or("The creation draft's diagram is missing.")?;
|
||||
if !d.projection.is_architecture()
|
||||
if matches!(d.projection, ProjectionKind::StateMachine { .. })
|
||||
|| s.query.len() > 16384
|
||||
|| s.page > 1_000_000
|
||||
|| s.point
|
||||
@@ -180,7 +180,7 @@ pub fn validate_saved(p: &Project, s: &Saved) -> Result<(), String> {
|
||||
}
|
||||
fn context_kinds(p: &Project, d: &Diagram) -> Vec<Kind> {
|
||||
let mut values = match (d.layer, d.projection) {
|
||||
(_, ProjectionKind::StateMachine { .. } | ProjectionKind::Scenario { .. }) => vec![],
|
||||
(_, ProjectionKind::StateMachine { .. }) => vec![],
|
||||
(Layer::Oa, ProjectionKind::FunctionalDataflow) => vec![Kind::Activity],
|
||||
(Layer::Sa, ProjectionKind::FunctionalDataflow) => vec![Kind::Function],
|
||||
(Layer::Oa, ProjectionKind::Capabilities) => vec![
|
||||
@@ -208,12 +208,11 @@ pub fn kinds(app: &App) -> Vec<Kind> {
|
||||
.unwrap_or_default()
|
||||
}
|
||||
fn compatible(p: &Project, d: &Diagram, id: Id) -> bool {
|
||||
d.projection.is_architecture()
|
||||
&& p.elements.get(&id).is_some_and(|e| {
|
||||
e.kind() != Kind::Port
|
||||
&& d.projection.includes(e.kind())
|
||||
&& (e.layer() == d.layer || d.projection == ProjectionKind::Traceability)
|
||||
})
|
||||
p.elements.get(&id).is_some_and(|e| {
|
||||
e.kind() != Kind::Port
|
||||
&& d.projection.includes(e.kind())
|
||||
&& (e.layer() == d.layer || d.projection == ProjectionKind::Traceability)
|
||||
})
|
||||
}
|
||||
fn suggested(p: &Project, d: &Diagram, kind: Kind) -> bool {
|
||||
// Existing System remains a useful choice even when the singleton exists.
|
||||
@@ -258,17 +257,6 @@ pub fn candidates(app: &App) -> Vec<(Id, String, bool)> {
|
||||
}
|
||||
impl App {
|
||||
pub(super) fn architecture_event(&mut self, event: Event) -> Task<Message> {
|
||||
if matches!(
|
||||
&event,
|
||||
Event::Start(..) | Event::Allocate(..) | Event::Existing(..)
|
||||
) && !self
|
||||
.active_diagram
|
||||
.and_then(|id| self.project().diagrams.get(&id))
|
||||
.is_some_and(|d| d.projection.is_architecture())
|
||||
{
|
||||
self.status = "Choose an architecture view before adding architecture content.".into();
|
||||
return Task::none();
|
||||
}
|
||||
match event {
|
||||
Event::Start(_, point) | Event::Allocate(_, _, point) | Event::Existing(point) => {
|
||||
if self.architecture.is_some() {
|
||||
|
||||
@@ -66,6 +66,7 @@ fn architecture_f2_routes_selected_node_to_inline_rename_and_exact_undo() {
|
||||
let camera = (app.zoom, app.pan);
|
||||
let bounds = iced::Rectangle::new(iced::Point::ORIGIN, iced::Size::new(600., 380.));
|
||||
let canvas = crate::diagram::DiagramCanvas {
|
||||
selection: None,
|
||||
project: app.project(),
|
||||
view_index: None,
|
||||
diagram: app.active_diagram.unwrap(),
|
||||
@@ -75,9 +76,11 @@ fn architecture_f2_routes_selected_node_to_inline_rename_and_exact_undo() {
|
||||
pan: app.pan,
|
||||
context: app.route_context().unwrap(),
|
||||
selected_bend: None,
|
||||
selected_attachment: None,
|
||||
editable: true,
|
||||
placing: false,
|
||||
connection: None,
|
||||
allocation: None,
|
||||
};
|
||||
let mut state = crate::diagram::State::default();
|
||||
let key = CanvasEvent::Keyboard(keyboard::Event::KeyPressed {
|
||||
@@ -89,17 +92,8 @@ fn architecture_f2_routes_selected_node_to_inline_rename_and_exact_undo() {
|
||||
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.)),
|
||||
);
|
||||
// Program receives keys already admitted by diagram::focus::Owner. Real
|
||||
// unfocused/Inspector refusal is covered by app::diagram_focus_tests.
|
||||
let action = canvas
|
||||
.update(&mut state, &key, bounds, mouse::Cursor::Unavailable)
|
||||
.unwrap();
|
||||
|
||||
@@ -221,6 +221,7 @@ fn architecture_canvas_left_click_uses_world_coordinate_once_at_zoom_and_pan() {
|
||||
y: (local.y - app.pan.y) / app.zoom,
|
||||
};
|
||||
let canvas = crate::diagram::DiagramCanvas {
|
||||
selection: None,
|
||||
project: app.project(),
|
||||
view_index: None,
|
||||
diagram: app.active_diagram.unwrap(),
|
||||
@@ -230,9 +231,11 @@ fn architecture_canvas_left_click_uses_world_coordinate_once_at_zoom_and_pan() {
|
||||
pan: app.pan,
|
||||
context: app.route_context().unwrap(),
|
||||
selected_bend: None,
|
||||
selected_attachment: None,
|
||||
editable: true,
|
||||
placing: true,
|
||||
connection: None,
|
||||
allocation: None,
|
||||
};
|
||||
let mut state = crate::diagram::State::default();
|
||||
let action = canvas
|
||||
@@ -296,6 +299,7 @@ fn architecture_f2_rename_preserves_identity_placement_and_atomic_history() {
|
||||
let diagrams = app.project().diagrams.clone();
|
||||
let camera = (app.zoom, app.pan);
|
||||
let canvas = crate::diagram::DiagramCanvas {
|
||||
selection: None,
|
||||
project: app.project(),
|
||||
view_index: None,
|
||||
diagram: app.active_diagram.unwrap(),
|
||||
@@ -305,9 +309,11 @@ fn architecture_f2_rename_preserves_identity_placement_and_atomic_history() {
|
||||
pan: app.pan,
|
||||
context: app.route_context().unwrap(),
|
||||
selected_bend: None,
|
||||
selected_attachment: None,
|
||||
editable: true,
|
||||
placing: false,
|
||||
connection: None,
|
||||
allocation: None,
|
||||
};
|
||||
let bounds = iced::Rectangle::with_size(iced::Size::new(800., 600.));
|
||||
let cursor = mouse::Cursor::Available(iced::Point::new(500., 400.));
|
||||
@@ -321,16 +327,11 @@ fn architecture_f2_rename_preserves_identity_placement_and_atomic_history() {
|
||||
text: None,
|
||||
repeat: false,
|
||||
});
|
||||
assert!(canvas.update(&mut state, &key, bounds, cursor).is_none());
|
||||
let _ = canvas.update(
|
||||
&mut state,
|
||||
&iced::widget::canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
|
||||
bounds,
|
||||
cursor,
|
||||
);
|
||||
// Program receives keys already admitted by diagram::focus::Owner. Real
|
||||
// unfocused/Inspector refusal is covered by app::diagram_focus_tests.
|
||||
let message = canvas
|
||||
.update(&mut state, &key, bounds, cursor)
|
||||
.expect("focused F2 opens the selected semantic name")
|
||||
.expect("admitted F2 opens the selected semantic name")
|
||||
.into_inner()
|
||||
.0
|
||||
.unwrap();
|
||||
|
||||
@@ -291,7 +291,7 @@ fn pending_behavior_blocks_navigation_property_edits_and_model_history() {
|
||||
Message::BrowseGroup(crate::explorer::Group::Work),
|
||||
Message::Field("/name".into(), "Overwrite properties".into()),
|
||||
Message::Command(Command::Rename(fixture.source, "Overwrite model".into())),
|
||||
Message::ConfirmDelete,
|
||||
Message::ConfirmDelete(0),
|
||||
Message::Undo,
|
||||
Message::Redo,
|
||||
Message::ShortcutUndo(false),
|
||||
|
||||
@@ -0,0 +1,601 @@
|
||||
//! Delete through the actual App widget tree, ordinary focus, and source history.
|
||||
use super::*;
|
||||
use crate::behavior::{
|
||||
canvas_workspace::tests::{Mounted, focused, key},
|
||||
scenario, topology,
|
||||
};
|
||||
use iced::{Event as Input, Point, Size, Vector, keyboard, mouse};
|
||||
|
||||
fn app(p: Project, owner: Id) -> App {
|
||||
let mut app = App::with_project(p);
|
||||
let _ = app.update(Message::Select(owner));
|
||||
let _ = app.update(Message::Page(Page::Behavior));
|
||||
app.window_size = Size::new(1900., 1200.);
|
||||
app
|
||||
}
|
||||
fn point(mounted: &mut Mounted, app: &App, id: Id) -> Point {
|
||||
if let Some(context) = app.topology_context() {
|
||||
let bounds = mounted.geometry(app).bounds("process-viewport");
|
||||
let local = app
|
||||
.behavior
|
||||
.topology
|
||||
.removal_test_point(app, context.owner, id, None);
|
||||
bounds.position() + Vector::new(local.x, local.y)
|
||||
} else {
|
||||
let context = app.scenario_context().unwrap();
|
||||
let bounds = mounted.geometry(app).bounds("scenario-viewport");
|
||||
let local = scenario::removal_test_point(app, context.scenario, id);
|
||||
bounds.position() + Vector::new(local.x, local.y)
|
||||
}
|
||||
}
|
||||
async fn select(mounted: &mut Mounted, app: &mut App, id: Id) {
|
||||
let target = point(mounted, app, id);
|
||||
mounted.click(app, target).await;
|
||||
assert_eq!(app.selected, id, "real pointer selects intended occurrence");
|
||||
}
|
||||
fn remove_key(shift: bool) -> Input {
|
||||
let mut event = key(keyboard::key::Named::Delete);
|
||||
if shift && let Input::Keyboard(keyboard::Event::KeyPressed { modifiers, .. }) = &mut event {
|
||||
*modifiers = keyboard::Modifiers::SHIFT;
|
||||
}
|
||||
event
|
||||
}
|
||||
fn removal(message: &Message) -> bool {
|
||||
matches!(
|
||||
message,
|
||||
Message::TopologyCanvas(topology::CanvasEvent::Remove(..))
|
||||
| Message::ScenarioCanvas(scenario::CanvasEvent::Remove(..))
|
||||
)
|
||||
}
|
||||
async fn delete(mounted: &mut Mounted, app: &mut App, shift: bool) -> u64 {
|
||||
let messages = mounted
|
||||
.batch(app, &[remove_key(shift)], mouse::Cursor::Unavailable)
|
||||
.await;
|
||||
assert!(
|
||||
messages.iter().any(removal),
|
||||
"focused viewport emits removal: {messages:?}"
|
||||
);
|
||||
let target = app.delete_target.as_ref().expect("captured confirmation");
|
||||
assert_eq!(target.snapshot.request.object, app.selected);
|
||||
assert_eq!(target.placement, None);
|
||||
assert_eq!(target.snapshot.request.placement, None);
|
||||
target.snapshot.token
|
||||
}
|
||||
async fn send(mounted: &mut Mounted, app: &mut App, message: Message) {
|
||||
let task = app.update(message);
|
||||
mounted.perform(app, task).await;
|
||||
}
|
||||
fn consistent(app: &App) {
|
||||
assert_eq!(
|
||||
source_project::compile_bundle(app.document.bundle(), LIMITS)
|
||||
.unwrap()
|
||||
.decoded
|
||||
.project,
|
||||
*app.project()
|
||||
);
|
||||
assert_eq!(
|
||||
source_workspace::reachable(&app.source_workspace.bundle()).unwrap(),
|
||||
*app.document.bundle()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mounted_process_delete_occurrence_or_link_preserves_reusable_work_and_source_history() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
for link in [false, true] {
|
||||
let f = topology::direct_tests::fixture(layer);
|
||||
let target = if link { f.link } else { f.nodes[0] };
|
||||
let mut app = app(f.p.clone(), f.owner);
|
||||
let mut mounted = Mounted::new(&mut app).await;
|
||||
let original = app.document.bundle().clone();
|
||||
select(&mut mounted, &mut app, target).await;
|
||||
// No extra redraw between release and first Delete.
|
||||
let token = delete(&mut mounted, &mut app, link).await;
|
||||
assert_eq!(
|
||||
app.delete_target.as_ref().unwrap().behavior_owner,
|
||||
Some(f.owner)
|
||||
);
|
||||
assert_eq!(app.document.bundle(), &original);
|
||||
send(&mut mounted, &mut app, Message::ConfirmDelete(token)).await;
|
||||
assert!(!app.project().contains(target));
|
||||
assert!(f.work.iter().all(|id| app.project().contains(*id)));
|
||||
assert!(app.project().contains(f.exchange));
|
||||
assert!(app.project().contains(f.nodes[1]));
|
||||
assert_eq!(app.project().contains(f.nodes[0]), link);
|
||||
assert!(!app.project().contains(f.link));
|
||||
consistent(&app);
|
||||
let removed = app.document.bundle().clone();
|
||||
send(&mut mounted, &mut app, Message::Undo).await;
|
||||
assert_eq!(app.document.bundle(), &original);
|
||||
consistent(&app);
|
||||
send(&mut mounted, &mut app, Message::Redo).await;
|
||||
assert_eq!(app.document.bundle(), &removed);
|
||||
consistent(&app);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mounted_scenario_delete_message_or_participant_preserves_reusable_targets() {
|
||||
for participant in [false, true] {
|
||||
let f = scenario::direct_tests::fixture();
|
||||
let target = if participant { f.sender } else { f.call };
|
||||
let mut app = app(f.project.clone(), f.owner);
|
||||
let mut mounted = Mounted::new(&mut app).await;
|
||||
let original = app.document.bundle().clone();
|
||||
select(&mut mounted, &mut app, target).await;
|
||||
let token = delete(&mut mounted, &mut app, participant).await;
|
||||
send(&mut mounted, &mut app, Message::ConfirmDelete(token)).await;
|
||||
assert!(!app.project().contains(target));
|
||||
assert!(app.project().contains(f.exchange));
|
||||
assert!(app.project().contains(f.receiver));
|
||||
assert!(app.project().contains(f.reply));
|
||||
for element in f
|
||||
.project
|
||||
.elements
|
||||
.values()
|
||||
.filter(|element| element.parent != Some(f.owner))
|
||||
{
|
||||
assert!(
|
||||
app.project().contains(element.id),
|
||||
"referenced definitions survive"
|
||||
);
|
||||
}
|
||||
if participant {
|
||||
assert!(app.project().contains(f.call));
|
||||
for element in app.project().elements.values() {
|
||||
if let Some(SharedBody::Occurrence {
|
||||
body:
|
||||
OccurrenceBody::Exchange {
|
||||
sender, receiver, ..
|
||||
},
|
||||
..
|
||||
}) = element.shared()
|
||||
{
|
||||
assert_ne!(*sender, Some(target));
|
||||
assert_ne!(*receiver, Some(target));
|
||||
}
|
||||
}
|
||||
}
|
||||
consistent(&app);
|
||||
send(&mut mounted, &mut app, Message::Undo).await;
|
||||
assert_eq!(app.document.bundle(), &original);
|
||||
consistent(&app);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mounted_scenario_use_delete_keeps_definition_and_second_use_and_rejects_owner_replay() {
|
||||
let (p, owners, uses) = scenario::direct_tests::navigation_fixture();
|
||||
let mut app = app(p, owners[0]);
|
||||
let mut mounted = Mounted::new(&mut app).await;
|
||||
let original = app.document.bundle().clone();
|
||||
select(&mut mounted, &mut app, uses[0]).await;
|
||||
let stale = delete(&mut mounted, &mut app, false).await;
|
||||
send(&mut mounted, &mut app, Message::CancelDelete).await;
|
||||
send(&mut mounted, &mut app, Message::ConfirmDelete(stale)).await;
|
||||
assert_eq!(app.document.bundle(), &original);
|
||||
select(&mut mounted, &mut app, uses[0]).await;
|
||||
let token = delete(&mut mounted, &mut app, true).await;
|
||||
send(&mut mounted, &mut app, Message::ConfirmDelete(stale)).await;
|
||||
assert!(app.delete_pending, "old token does not cancel new dialog");
|
||||
// Keep selection and epoch deliberately unchanged: owner is an independent witness.
|
||||
app.behavior.active_owner = Some(owners[1]);
|
||||
send(&mut mounted, &mut app, Message::ConfirmDelete(token)).await;
|
||||
assert_eq!(app.document.bundle(), &original);
|
||||
app.behavior.active_owner = Some(owners[0]);
|
||||
mounted.settle(&mut app).await;
|
||||
select(&mut mounted, &mut app, uses[0]).await;
|
||||
let token = delete(&mut mounted, &mut app, false).await;
|
||||
send(&mut mounted, &mut app, Message::ConfirmDelete(token)).await;
|
||||
assert!(!app.project().contains(uses[0]));
|
||||
assert!(app.project().contains(uses[1]));
|
||||
assert!(owners.iter().all(|id| app.project().contains(*id)));
|
||||
consistent(&app);
|
||||
send(&mut mounted, &mut app, Message::Undo).await;
|
||||
assert_eq!(app.document.bundle(), &original);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mounted_behavior_tab_and_f6_retire_same_batch_delete_and_window_focus() {
|
||||
for process in [false, true] {
|
||||
let (mut app, target) = if process {
|
||||
let f = topology::direct_tests::fixture(Layer::Oa);
|
||||
(app(f.p, f.owner), f.nodes[0])
|
||||
} else {
|
||||
let f = scenario::direct_tests::fixture();
|
||||
(app(f.project, f.owner), f.call)
|
||||
};
|
||||
let mut mounted = Mounted::new(&mut app).await;
|
||||
let original = app.document.bundle().clone();
|
||||
for boundary in [
|
||||
keyboard::key::Named::Tab,
|
||||
keyboard::key::Named::F6,
|
||||
keyboard::key::Named::Escape,
|
||||
] {
|
||||
select(&mut mounted, &mut app, target).await;
|
||||
let messages = mounted
|
||||
.batch(
|
||||
&mut app,
|
||||
&[key(boundary), remove_key(false)],
|
||||
mouse::Cursor::Unavailable,
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
!messages.iter().any(removal),
|
||||
"{boundary:?} retires keyboard admission within the batch"
|
||||
);
|
||||
assert!(!app.delete_pending);
|
||||
assert_eq!(app.document.bundle(), &original);
|
||||
assert!(!focused(&mut mounted, &app, "viewport"));
|
||||
if boundary == keyboard::key::Named::Tab {
|
||||
assert_eq!(
|
||||
mounted
|
||||
.geometry(&app)
|
||||
.focused
|
||||
.iter()
|
||||
.filter(|(_, _, value)| *value)
|
||||
.count(),
|
||||
1,
|
||||
"Tab completes traversal to one real control"
|
||||
);
|
||||
let mut backwards = key(keyboard::key::Named::Tab);
|
||||
if let Input::Keyboard(keyboard::Event::KeyPressed { modifiers, .. }) =
|
||||
&mut backwards
|
||||
{
|
||||
*modifiers = keyboard::Modifiers::SHIFT;
|
||||
}
|
||||
mounted
|
||||
.batch(&mut app, &[backwards], mouse::Cursor::Unavailable)
|
||||
.await;
|
||||
} else {
|
||||
let messages = mounted
|
||||
.batch(
|
||||
&mut app,
|
||||
&[key(keyboard::key::Named::F6), remove_key(false)],
|
||||
mouse::Cursor::Unavailable,
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
!messages.iter().any(removal),
|
||||
"F6 entry completes after the batch, before a later Delete"
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
focused(&mut mounted, &app, "viewport"),
|
||||
"{boundary:?} supports keyboard-only return to the viewport"
|
||||
);
|
||||
delete(&mut mounted, &mut app, false).await;
|
||||
send(&mut mounted, &mut app, Message::CancelDelete).await;
|
||||
assert_eq!(app.document.bundle(), &original);
|
||||
}
|
||||
select(&mut mounted, &mut app, target).await;
|
||||
let messages = mounted
|
||||
.batch(
|
||||
&mut app,
|
||||
&[
|
||||
Input::Window(iced::window::Event::Unfocused),
|
||||
remove_key(false),
|
||||
],
|
||||
mouse::Cursor::Unavailable,
|
||||
)
|
||||
.await;
|
||||
assert!(!messages.iter().any(removal));
|
||||
assert!(!app.delete_pending);
|
||||
// A new actual click reacquires ownership after each boundary.
|
||||
select(&mut mounted, &mut app, target).await;
|
||||
delete(&mut mounted, &mut app, false).await;
|
||||
send(&mut mounted, &mut app, Message::CancelDelete).await;
|
||||
assert_eq!(app.document.bundle(), &original);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mounted_behavior_clean_inspector_click_and_tab_own_delete_then_f6_returns_to_canvas() {
|
||||
for process in [false, true] {
|
||||
for tab_entry in [false, true] {
|
||||
let (mut app, target) = if process {
|
||||
let f = topology::direct_tests::fixture(Layer::Oa);
|
||||
(app(f.p, f.owner), f.nodes[0])
|
||||
} else {
|
||||
let f = scenario::direct_tests::fixture();
|
||||
(app(f.project, f.owner), f.call)
|
||||
};
|
||||
let mut mounted = Mounted::new(&mut app).await;
|
||||
select(&mut mounted, &mut app, target).await;
|
||||
let original = app.document.bundle().clone();
|
||||
let original_name = app.draft.as_ref().unwrap().inputs["/name"].raw.clone();
|
||||
// The first text input in the right-hand Inspector is the name.
|
||||
// Verify its actual binding below, rather than seeding focus state.
|
||||
let field = mounted
|
||||
.geometry(&app)
|
||||
.text_inputs
|
||||
.into_iter()
|
||||
.filter(|bounds| bounds.x > app.window_size.width - 320.)
|
||||
.min_by(|a, b| a.y.total_cmp(&b.y))
|
||||
.expect("visible Inspector name input");
|
||||
if tab_entry {
|
||||
// Canvas Tab stays within canvas controls. Escape then ordinary
|
||||
// Tab follows the existing Inspector entry policy.
|
||||
mounted
|
||||
.batch(
|
||||
&mut app,
|
||||
&[key(keyboard::key::Named::Escape)],
|
||||
mouse::Cursor::Unavailable,
|
||||
)
|
||||
.await;
|
||||
mounted
|
||||
.batch(
|
||||
&mut app,
|
||||
&[key(keyboard::key::Named::Tab)],
|
||||
mouse::Cursor::Unavailable,
|
||||
)
|
||||
.await;
|
||||
} else {
|
||||
mounted.click(&mut app, field.center()).await;
|
||||
}
|
||||
assert!(
|
||||
mounted
|
||||
.geometry(&app)
|
||||
.focused
|
||||
.iter()
|
||||
.any(|(_, bounds, value)| *bounds == field && *value),
|
||||
"actual {} focuses the Inspector name",
|
||||
if tab_entry { "Tab" } else { "click" }
|
||||
);
|
||||
assert!(!focused(&mut mounted, &app, "viewport"));
|
||||
assert!(!app.draft_dirty());
|
||||
let messages = mounted
|
||||
.batch(
|
||||
&mut app,
|
||||
&[key(keyboard::key::Named::End), remove_key(true)],
|
||||
mouse::Cursor::Unavailable,
|
||||
)
|
||||
.await;
|
||||
assert!(messages.iter().any(|message| matches!(
|
||||
message,
|
||||
Message::Field(path, value) if path == "/name" && *value == original_name
|
||||
)));
|
||||
assert!(!messages.iter().any(removal));
|
||||
assert!(!app.delete_pending);
|
||||
assert!(!app.draft_dirty(), "Delete at end keeps the field clean");
|
||||
assert_eq!(app.document.bundle(), &original);
|
||||
mounted
|
||||
.batch(
|
||||
&mut app,
|
||||
&[key(keyboard::key::Named::F6)],
|
||||
mouse::Cursor::Unavailable,
|
||||
)
|
||||
.await;
|
||||
assert!(focused(&mut mounted, &app, "viewport"));
|
||||
assert_eq!(
|
||||
mounted
|
||||
.geometry(&app)
|
||||
.focused
|
||||
.iter()
|
||||
.filter(|(_, _, value)| *value)
|
||||
.count(),
|
||||
1,
|
||||
"F6 releases actual Inspector focus"
|
||||
);
|
||||
delete(&mut mounted, &mut app, false).await;
|
||||
send(&mut mounted, &mut app, Message::CancelDelete).await;
|
||||
assert_eq!(app.document.bundle(), &original);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mounted_behavior_clean_text_focus_and_modifiers_cannot_delete_selected_occurrence() {
|
||||
use iced::advanced::widget::{Operation, operation};
|
||||
struct FocusText(iced::Rectangle);
|
||||
impl Operation for FocusText {
|
||||
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn Operation)) {
|
||||
visit(self);
|
||||
}
|
||||
fn focusable(
|
||||
&mut self,
|
||||
_: Option<&iced::advanced::widget::Id>,
|
||||
bounds: iced::Rectangle,
|
||||
state: &mut dyn operation::Focusable,
|
||||
) {
|
||||
if bounds == self.0 {
|
||||
state.focus();
|
||||
} else {
|
||||
state.unfocus();
|
||||
}
|
||||
}
|
||||
}
|
||||
for process in [false, true] {
|
||||
let (mut app, target) = if process {
|
||||
let f = topology::direct_tests::fixture(Layer::Oa);
|
||||
(app(f.p, f.owner), f.nodes[0])
|
||||
} else {
|
||||
let f = scenario::direct_tests::fixture();
|
||||
(app(f.project, f.owner), f.call)
|
||||
};
|
||||
let mut mounted = Mounted::new(&mut app).await;
|
||||
select(&mut mounted, &mut app, target).await;
|
||||
let original = app.document.bundle().clone();
|
||||
for modifiers in [
|
||||
keyboard::Modifiers::CTRL,
|
||||
keyboard::Modifiers::ALT,
|
||||
keyboard::Modifiers::LOGO,
|
||||
] {
|
||||
let mut event = remove_key(false);
|
||||
if let Input::Keyboard(keyboard::Event::KeyPressed {
|
||||
modifiers: value, ..
|
||||
}) = &mut event
|
||||
{
|
||||
*value = modifiers;
|
||||
}
|
||||
let messages = mounted
|
||||
.batch(&mut app, &[event], mouse::Cursor::Unavailable)
|
||||
.await;
|
||||
assert!(!messages.iter().any(removal));
|
||||
}
|
||||
let field = mounted
|
||||
.geometry(&app)
|
||||
.text_inputs
|
||||
.into_iter()
|
||||
.find(|bounds| bounds.width > 40.)
|
||||
.expect("real text field");
|
||||
mounted.operate(&app, &mut FocusText(field));
|
||||
assert!(!app.draft_dirty());
|
||||
let messages = mounted
|
||||
.batch(&mut app, &[remove_key(true)], mouse::Cursor::Unavailable)
|
||||
.await;
|
||||
assert!(!messages.iter().any(removal));
|
||||
assert!(!app.delete_pending);
|
||||
assert_eq!(app.document.bundle(), &original);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mounted_scenario_fragment_and_branch_delete_only_owned_occurrences() {
|
||||
for whole_fragment in [false, true] {
|
||||
let (f, branch, anchor, state, reused, remote) =
|
||||
scenario::direct_tests::placement_fixture();
|
||||
let fragment = f.project.elements[&branch].parent.unwrap();
|
||||
let target = if whole_fragment { fragment } else { branch };
|
||||
let mut app = app(f.project.clone(), f.owner);
|
||||
let mut mounted = Mounted::new(&mut app).await;
|
||||
let original = app.document.bundle().clone();
|
||||
select(&mut mounted, &mut app, target).await;
|
||||
let token = delete(&mut mounted, &mut app, false).await;
|
||||
send(&mut mounted, &mut app, Message::ConfirmDelete(token)).await;
|
||||
assert!(!app.project().contains(target));
|
||||
assert!(!app.project().contains(anchor));
|
||||
assert!(!app.project().contains(branch));
|
||||
assert_eq!(app.project().contains(fragment), !whole_fragment);
|
||||
for id in [
|
||||
f.call, f.reply, f.sender, f.receiver, f.exchange, state, reused, remote,
|
||||
] {
|
||||
assert!(app.project().contains(id));
|
||||
}
|
||||
consistent(&app);
|
||||
send(&mut mounted, &mut app, Message::Undo).await;
|
||||
assert_eq!(app.document.bundle(), &original);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mounted_qualified_process_child_delete_refuses_outer_reference_and_shared_definition() {
|
||||
let f = topology::direct_tests::fixture(Layer::Oa);
|
||||
let mut p = f.p;
|
||||
let caller = p
|
||||
.add_element(Kind::Process, Layer::Oa, "Caller", None)
|
||||
.unwrap();
|
||||
let references = ["First use", "Second use"].map(|name| {
|
||||
let mut element = p
|
||||
.make_element(Kind::Occurrence, Layer::Oa, name, Some(caller))
|
||||
.unwrap();
|
||||
*element.shared_mut().unwrap() = SharedBody::Occurrence {
|
||||
body: OccurrenceBody::SequenceReference {
|
||||
sequence: Some(f.owner),
|
||||
},
|
||||
precondition: String::new(),
|
||||
postcondition: String::new(),
|
||||
};
|
||||
let id = element.id;
|
||||
p = execute_batch(&p, &[Command::CreateElement(element)]).unwrap();
|
||||
id
|
||||
});
|
||||
let mut app = app(p, caller);
|
||||
let mut mounted = Mounted::new(&mut app).await;
|
||||
let original = app.document.bundle().clone();
|
||||
let endpoint = OccurrenceEndpoint {
|
||||
references: vec![references[0]],
|
||||
occurrence: f.nodes[0],
|
||||
};
|
||||
let bounds = mounted.geometry(&app).bounds("process-viewport");
|
||||
let local =
|
||||
app.behavior
|
||||
.topology
|
||||
.removal_test_point(&app, caller, references[0], Some(&endpoint));
|
||||
let messages = mounted
|
||||
.click(&mut app, bounds.position() + Vector::new(local.x, local.y))
|
||||
.await;
|
||||
assert!(messages.iter().any(|message| matches!(
|
||||
message,
|
||||
Message::DiagramSelection(multiselect::Event::Choose(
|
||||
_, crate::diagram_usage::Target::ProcessOccurrence(actual), multiselect::Choice::Replace
|
||||
)) if actual == &endpoint
|
||||
)));
|
||||
assert_eq!(app.selected, f.nodes[0]);
|
||||
assert!(app.draft.is_none(), "Qualified inspection stays read-only");
|
||||
assert_eq!(
|
||||
app.behavior.topology.selection_address().unwrap().endpoint,
|
||||
endpoint
|
||||
);
|
||||
assert!(
|
||||
app.behavior
|
||||
.topology
|
||||
.has_qualified_selection(app.project(), caller)
|
||||
);
|
||||
mounted
|
||||
.batch(&mut app, &[remove_key(false)], mouse::Cursor::Unavailable)
|
||||
.await;
|
||||
assert!(!app.delete_pending);
|
||||
assert_eq!(app.document.bundle(), &original);
|
||||
assert!(app.status.contains("Open the referenced definition"));
|
||||
select(&mut mounted, &mut app, references[0]).await;
|
||||
assert!(
|
||||
!app.behavior
|
||||
.topology
|
||||
.has_qualified_selection(app.project(), caller)
|
||||
);
|
||||
let token = delete(&mut mounted, &mut app, true).await;
|
||||
send(&mut mounted, &mut app, Message::ConfirmDelete(token)).await;
|
||||
assert!(!app.project().contains(references[0]));
|
||||
assert!(app.project().contains(references[1]));
|
||||
assert!(app.project().contains(f.owner));
|
||||
assert!(f.nodes.iter().all(|id| app.project().contains(*id)));
|
||||
consistent(&app);
|
||||
send(&mut mounted, &mut app, Message::Undo).await;
|
||||
assert_eq!(app.document.bundle(), &original);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mounted_behavior_pan_and_stale_removal_intent_do_not_delete() {
|
||||
for process in [false, true] {
|
||||
let (mut app, target) = if process {
|
||||
let f = topology::direct_tests::fixture(Layer::Oa);
|
||||
(app(f.p, f.owner), f.nodes[0])
|
||||
} else {
|
||||
let f = scenario::direct_tests::fixture();
|
||||
(app(f.project, f.owner), f.call)
|
||||
};
|
||||
let mut mounted = Mounted::new(&mut app).await;
|
||||
select(&mut mounted, &mut app, target).await;
|
||||
let original = app.document.bundle().clone();
|
||||
let cursor = point(&mut mounted, &app, target);
|
||||
let messages = mounted
|
||||
.batch(
|
||||
&mut app,
|
||||
&[
|
||||
Input::Mouse(mouse::Event::CursorMoved { position: cursor }),
|
||||
Input::Mouse(mouse::Event::ButtonPressed(mouse::Button::Middle)),
|
||||
remove_key(false),
|
||||
Input::Mouse(mouse::Event::ButtonReleased(mouse::Button::Middle)),
|
||||
],
|
||||
mouse::Cursor::Available(cursor),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
!messages.iter().any(removal),
|
||||
"held pan never becomes an occurrence deletion"
|
||||
);
|
||||
assert!(!app.delete_pending);
|
||||
select(&mut mounted, &mut app, target).await;
|
||||
let messages = mounted.input(&app, &[remove_key(false)], mouse::Cursor::Unavailable);
|
||||
let stale = messages
|
||||
.into_iter()
|
||||
.find(removal)
|
||||
.expect("captured live removal");
|
||||
app.canvas_epoch = app.canvas_epoch.wrapping_add(1);
|
||||
send(&mut mounted, &mut app, stale).await;
|
||||
assert!(!app.delete_pending);
|
||||
assert_eq!(app.document.bundle(), &original);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,593 @@
|
||||
//! Keyboard ownership is exercised on the mounted App, not raw Canvas Program.
|
||||
use super::*;
|
||||
use crate::behavior::canvas_workspace::tests::{Mounted, key};
|
||||
use iced::advanced::widget::{Operation, operation};
|
||||
use iced::{Point, Rectangle, Size, Vector, keyboard::key::Named, mouse};
|
||||
|
||||
struct FocusField(Rectangle);
|
||||
impl Operation for FocusField {
|
||||
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn Operation)) {
|
||||
visit(self);
|
||||
}
|
||||
fn focusable(
|
||||
&mut self,
|
||||
_: Option<&iced::advanced::widget::Id>,
|
||||
bounds: Rectangle,
|
||||
state: &mut dyn operation::Focusable,
|
||||
) {
|
||||
if bounds == self.0 {
|
||||
state.focus();
|
||||
} else {
|
||||
state.unfocus();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn f2(mounted: &mut Mounted, app: &mut App) -> Vec<Message> {
|
||||
mounted
|
||||
.batch(app, &[key(Named::F2)], mouse::Cursor::Unavailable)
|
||||
.await
|
||||
}
|
||||
|
||||
fn rename_emitted(messages: &[Message]) -> bool {
|
||||
messages.iter().any(|message| {
|
||||
matches!(
|
||||
message,
|
||||
Message::Architecture(architecture_authoring::Event::Rename(..))
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mounted_f2_requires_canvas_focus_and_yields_to_clean_inspector() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
for programmatic in [true, false] {
|
||||
let mut project = Project::empty();
|
||||
let object = project
|
||||
.add_element(
|
||||
if layer == Layer::Oa {
|
||||
Kind::Entity
|
||||
} else {
|
||||
Kind::System
|
||||
},
|
||||
layer,
|
||||
"Original team",
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let diagram = project.ensure_diagram(layer);
|
||||
project.diagrams.get_mut(&diagram).unwrap().projection = ProjectionKind::Overview;
|
||||
let node = project.place(diagram, object, Position { x: 80., y: 80. });
|
||||
let mut app = App::with_project(project);
|
||||
app.page = Page::Diagram;
|
||||
app.layer = layer;
|
||||
app.active_diagram = Some(diagram);
|
||||
app.window_size = Size::new(2000., 1400.);
|
||||
let _ = app.update(Message::Select(object));
|
||||
let before = app.document.snapshot();
|
||||
let mut mounted = Mounted::new(&mut app).await;
|
||||
app.zoom = 1.;
|
||||
app.pan = Vector::ZERO;
|
||||
app.fit_to_view = false;
|
||||
mounted.settle(&mut app).await;
|
||||
|
||||
// Selection alone does not grant the canvas keyboard ownership.
|
||||
assert!(!rename_emitted(&f2(&mut mounted, &mut app).await));
|
||||
assert!(app.architecture.is_none());
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
|
||||
let bounds = mounted
|
||||
.geometry(&app)
|
||||
.bounds(crate::allocation_input::CANVAS_ID);
|
||||
let projected = projection::project_view(app.project(), diagram)
|
||||
.nodes
|
||||
.into_iter()
|
||||
.find(|item| item.id == node)
|
||||
.unwrap();
|
||||
let point = Point::new(
|
||||
bounds.x + projected.position.x + 40.,
|
||||
bounds.y + projected.position.y + 35.,
|
||||
);
|
||||
mounted.click(&mut app, point).await;
|
||||
assert_eq!((app.selected, app.selected_node), (object, Some(node)));
|
||||
assert!(rename_emitted(&f2(&mut mounted, &mut app).await));
|
||||
let rename = app.architecture.as_ref().expect("canvas F2 opens rename");
|
||||
assert!(rename.inline());
|
||||
assert!(matches!(
|
||||
&rename.saved.intent,
|
||||
architecture_authoring::Intent::Rename { element, name }
|
||||
if *element == object && name == "Original team"
|
||||
));
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
assert!(
|
||||
!app.document.can_undo(),
|
||||
"opening rename is not a model edit"
|
||||
);
|
||||
|
||||
let task = app.update(Message::Escape);
|
||||
mounted.perform(&mut app, task).await;
|
||||
assert!(app.architecture.is_none());
|
||||
mounted.click(&mut app, point).await;
|
||||
let field = mounted
|
||||
.geometry(&app)
|
||||
.text_inputs
|
||||
.into_iter()
|
||||
.filter(|bounds| bounds.x > app.window_size.width - 320.)
|
||||
.min_by(|a, b| a.y.total_cmp(&b.y))
|
||||
.expect("Inspector name field is mounted");
|
||||
if programmatic {
|
||||
mounted.operate(&app, &mut FocusField(field));
|
||||
} else {
|
||||
let messages = mounted
|
||||
.batch(
|
||||
&mut app,
|
||||
&[key(Named::Tab), key(Named::F2)],
|
||||
mouse::Cursor::Unavailable,
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
!rename_emitted(&messages),
|
||||
"Tab must retire F2 admission before its queued focus task"
|
||||
);
|
||||
}
|
||||
let geometry = mounted.geometry(&app);
|
||||
assert!(
|
||||
geometry
|
||||
.focused
|
||||
.iter()
|
||||
.any(|(_, bounds, focused)| { *bounds == field && *focused })
|
||||
);
|
||||
assert!(geometry.focused.iter().any(|(id, _, focused)| {
|
||||
id.as_ref() == Some(&iced::advanced::widget::Id::from("diagram-keyboard-owner"))
|
||||
&& !*focused
|
||||
}));
|
||||
assert!(!app.draft_dirty(), "the Inspector is still clean");
|
||||
assert!(!rename_emitted(&f2(&mut mounted, &mut app).await));
|
||||
assert!(app.architecture.is_none());
|
||||
assert_eq!(
|
||||
app.draft.as_ref().unwrap().inputs["/name"].raw,
|
||||
"Original team"
|
||||
);
|
||||
assert_eq!(app.project(), &before.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
assert!(!app.document.can_undo());
|
||||
}
|
||||
}
|
||||
}
|
||||
async fn modifier_focus_fixture() -> (App, Mounted, Id, Id, Id) {
|
||||
let mut project = Project::empty();
|
||||
let a = project
|
||||
.add_element(Kind::Entity, Layer::Oa, "First team", None)
|
||||
.unwrap();
|
||||
let b = project
|
||||
.add_element(Kind::Entity, Layer::Oa, "Second team", None)
|
||||
.unwrap();
|
||||
let diagram = project.ensure_diagram(Layer::Oa);
|
||||
let first = project.place(diagram, a, Position { x: 80., y: 80. });
|
||||
let second = project.place(diagram, b, Position { x: 500., y: 80. });
|
||||
let mut app = App::with_project(project);
|
||||
app.page = Page::Diagram;
|
||||
app.layer = Layer::Oa;
|
||||
app.active_diagram = Some(diagram);
|
||||
app.window_size = Size::new(2000., 1400.);
|
||||
let _ = app.update(Message::Select(a));
|
||||
let mut mounted = Mounted::new(&mut app).await;
|
||||
app.zoom = 1.;
|
||||
app.pan = Vector::ZERO;
|
||||
app.fit_to_view = false;
|
||||
mounted.settle(&mut app).await;
|
||||
(app, mounted, diagram, first, second)
|
||||
}
|
||||
|
||||
fn modifier_node_point(mounted: &mut Mounted, app: &App, diagram: Id, placement: Id) -> Point {
|
||||
let bounds = mounted
|
||||
.geometry(app)
|
||||
.bounds(crate::allocation_input::CANVAS_ID);
|
||||
let node = projection::project_view(app.project(), diagram)
|
||||
.nodes
|
||||
.into_iter()
|
||||
.find(|n| n.id == placement)
|
||||
.unwrap();
|
||||
Point::new(
|
||||
bounds.x + node.position.x + 40.,
|
||||
bounds.y + node.position.y + 35.,
|
||||
)
|
||||
}
|
||||
|
||||
fn modifier_canvas_focused(mounted: &mut Mounted, app: &App) -> bool {
|
||||
mounted
|
||||
.geometry(app)
|
||||
.focused
|
||||
.iter()
|
||||
.find(|(id, _, _)| {
|
||||
id.as_ref() == Some(&iced::advanced::widget::Id::from("diagram-keyboard-owner"))
|
||||
})
|
||||
.expect("production focus::Owner is mounted")
|
||||
.2
|
||||
}
|
||||
|
||||
fn modifier_event(shift: bool) -> iced::Event {
|
||||
iced::Event::Keyboard(iced::keyboard::Event::ModifiersChanged(if shift {
|
||||
iced::keyboard::Modifiers::SHIFT
|
||||
} else {
|
||||
iced::keyboard::Modifiers::default()
|
||||
}))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mounted_shift_before_canvas_focus_toggles_exact_node_without_granting_keyboard_focus() {
|
||||
use crate::diagram_usage::Target;
|
||||
let (mut app, mut mounted, diagram, first, second) = modifier_focus_fixture().await;
|
||||
let before = app.document.snapshot();
|
||||
let field = mounted
|
||||
.geometry(&app)
|
||||
.text_inputs
|
||||
.into_iter()
|
||||
.filter(|bounds| bounds.x > app.window_size.width - 320.)
|
||||
.min_by(|a, b| a.y.total_cmp(&b.y))
|
||||
.expect("clean Inspector name input");
|
||||
mounted.operate(&app, &mut FocusField(field));
|
||||
assert!(!modifier_canvas_focused(&mut mounted, &app));
|
||||
assert_eq!(
|
||||
app.multiselect_snapshot().unwrap().targets(),
|
||||
&[Target::Placement(first)]
|
||||
);
|
||||
mounted
|
||||
.batch(
|
||||
&mut app,
|
||||
&[modifier_event(true)],
|
||||
mouse::Cursor::Unavailable,
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
!modifier_canvas_focused(&mut mounted, &app),
|
||||
"modifier tracking must not acquire focus"
|
||||
);
|
||||
let at = modifier_node_point(&mut mounted, &app, diagram, second);
|
||||
let messages = mounted.click(&mut app, at).await;
|
||||
assert!(messages.iter().any(|m| matches!(m,
|
||||
Message::DiagramSelection(multiselect::Event::Choose(_, Target::Placement(id), multiselect::Choice::Toggle)) if *id == second
|
||||
)), "Shift already held before the first canvas press must reach its exact-target adapter");
|
||||
assert_eq!(
|
||||
app.multiselect_snapshot().unwrap().targets(),
|
||||
&[Target::Placement(first), Target::Placement(second)]
|
||||
);
|
||||
assert_eq!(app.selected_node, Some(second));
|
||||
assert_eq!(app.project(), &before.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
assert!(!app.document.can_undo());
|
||||
assert!(!app.draft_dirty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mounted_unfocused_typing_and_delete_remain_denied_after_passive_modifiers() {
|
||||
use crate::diagram_usage::Target;
|
||||
let (mut app, mut mounted, diagram, first, second) = modifier_focus_fixture().await;
|
||||
mounted
|
||||
.batch(
|
||||
&mut app,
|
||||
&[modifier_event(true)],
|
||||
mouse::Cursor::Unavailable,
|
||||
)
|
||||
.await;
|
||||
let at = modifier_node_point(&mut mounted, &app, diagram, second);
|
||||
mounted.click(&mut app, at).await;
|
||||
assert_eq!(
|
||||
app.multiselect_snapshot().unwrap().targets(),
|
||||
&[Target::Placement(first), Target::Placement(second)]
|
||||
);
|
||||
// Revoke every field's focus through the real widget operation. No widget
|
||||
// has this off-window rectangle, so neither Inspector nor canvas owns keys.
|
||||
mounted.operate(
|
||||
&app,
|
||||
&mut FocusField(Rectangle {
|
||||
x: -1000.,
|
||||
y: -1000.,
|
||||
width: 1.,
|
||||
height: 1.,
|
||||
}),
|
||||
);
|
||||
assert!(!modifier_canvas_focused(&mut mounted, &app));
|
||||
let before = app.document.snapshot();
|
||||
let selection = app.multiselect_snapshot().unwrap();
|
||||
let messages = mounted
|
||||
.batch(
|
||||
&mut app,
|
||||
&[
|
||||
modifier_event(false),
|
||||
crate::behavior::canvas_workspace::tests::character("x"),
|
||||
key(Named::Delete),
|
||||
key(Named::F2),
|
||||
],
|
||||
mouse::Cursor::Unavailable,
|
||||
)
|
||||
.await;
|
||||
assert!(!messages.iter().any(|m| matches!(
|
||||
m,
|
||||
Message::DiagramSelection(_)
|
||||
| Message::DiagramRemove(_)
|
||||
| Message::HideSelected
|
||||
| Message::Delete
|
||||
)));
|
||||
assert!(!rename_emitted(&messages));
|
||||
assert!(!modifier_canvas_focused(&mut mounted, &app));
|
||||
assert_eq!(
|
||||
app.multiselect_snapshot().unwrap().targets(),
|
||||
selection.targets()
|
||||
);
|
||||
assert_eq!(app.project(), &before.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
assert!(!app.document.can_undo());
|
||||
assert!(!app.draft_dirty());
|
||||
assert!(!app.delete_pending);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mounted_suspended_modifiers_are_passive_and_survive_until_resumed_click() {
|
||||
use crate::diagram_usage::Target;
|
||||
let (mut app, mut mounted, diagram, first, second) = modifier_focus_fixture().await;
|
||||
let before = app.document.snapshot();
|
||||
let at = modifier_node_point(&mut mounted, &app, diagram, second);
|
||||
app.busy = true;
|
||||
mounted.settle(&mut app).await;
|
||||
assert!(!modifier_canvas_focused(&mut mounted, &app));
|
||||
mounted
|
||||
.batch(
|
||||
&mut app,
|
||||
&[modifier_event(true), key(Named::Delete)],
|
||||
mouse::Cursor::Unavailable,
|
||||
)
|
||||
.await;
|
||||
mounted.click(&mut app, at).await;
|
||||
assert!(
|
||||
!modifier_canvas_focused(&mut mounted, &app),
|
||||
"passive I/O cannot grant canvas focus"
|
||||
);
|
||||
assert_eq!(
|
||||
app.multiselect_snapshot().unwrap().targets(),
|
||||
&[Target::Placement(first)]
|
||||
);
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
app.busy = false;
|
||||
mounted.settle(&mut app).await;
|
||||
assert!(!modifier_canvas_focused(&mut mounted, &app));
|
||||
mounted.click(&mut app, at).await;
|
||||
assert_eq!(
|
||||
app.multiselect_snapshot().unwrap().targets(),
|
||||
&[Target::Placement(first), Target::Placement(second)]
|
||||
);
|
||||
assert_eq!(app.project(), &before.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
assert!(!app.document.can_undo());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mounted_shift_held_before_suspension_survives_without_another_modifier_event() {
|
||||
use crate::diagram_usage::Target;
|
||||
let (mut app, mut mounted, diagram, first, second) = modifier_focus_fixture().await;
|
||||
let before = app.document.snapshot();
|
||||
let field = mounted
|
||||
.geometry(&app)
|
||||
.text_inputs
|
||||
.into_iter()
|
||||
.filter(|bounds| bounds.x > app.window_size.width - 320.)
|
||||
.min_by(|a, b| a.y.total_cmp(&b.y))
|
||||
.expect("clean Inspector name input");
|
||||
mounted.operate(&app, &mut FocusField(field));
|
||||
mounted
|
||||
.batch(
|
||||
&mut app,
|
||||
&[modifier_event(true)],
|
||||
mouse::Cursor::Unavailable,
|
||||
)
|
||||
.await;
|
||||
assert!(!modifier_canvas_focused(&mut mounted, &app));
|
||||
app.busy = true;
|
||||
mounted.settle(&mut app).await;
|
||||
assert!(!modifier_canvas_focused(&mut mounted, &app));
|
||||
app.busy = false;
|
||||
mounted.settle(&mut app).await;
|
||||
assert!(
|
||||
!modifier_canvas_focused(&mut mounted, &app),
|
||||
"restoring passive modifiers must not grant keyboard focus"
|
||||
);
|
||||
assert_eq!(
|
||||
app.multiselect_snapshot().unwrap().targets(),
|
||||
&[Target::Placement(first)]
|
||||
);
|
||||
// No modifier transition follows the subtree replacement during I/O.
|
||||
let at = modifier_node_point(&mut mounted, &app, diagram, second);
|
||||
let messages = mounted.click(&mut app, at).await;
|
||||
assert!(messages.iter().any(|m| matches!(m,
|
||||
Message::DiagramSelection(multiselect::Event::Choose(_, Target::Placement(id), multiselect::Choice::Toggle)) if *id == second
|
||||
)));
|
||||
assert_eq!(
|
||||
app.multiselect_snapshot().unwrap().targets(),
|
||||
&[Target::Placement(first), Target::Placement(second)]
|
||||
);
|
||||
assert_eq!(app.project(), &before.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
assert!(!app.document.can_undo());
|
||||
assert!(!app.draft_dirty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mounted_shift_release_or_window_unfocus_during_suspension_does_not_restore_stale_shift() {
|
||||
use crate::diagram_usage::Target;
|
||||
for release in [
|
||||
modifier_event(false),
|
||||
iced::Event::Window(iced::window::Event::Unfocused),
|
||||
] {
|
||||
let (mut app, mut mounted, diagram, first, second) = modifier_focus_fixture().await;
|
||||
let before = app.document.snapshot();
|
||||
mounted
|
||||
.batch(
|
||||
&mut app,
|
||||
&[modifier_event(true)],
|
||||
mouse::Cursor::Unavailable,
|
||||
)
|
||||
.await;
|
||||
app.busy = true;
|
||||
mounted.settle(&mut app).await;
|
||||
mounted
|
||||
.batch(&mut app, &[release], mouse::Cursor::Unavailable)
|
||||
.await;
|
||||
assert!(!modifier_canvas_focused(&mut mounted, &app));
|
||||
app.busy = false;
|
||||
mounted.settle(&mut app).await;
|
||||
// A further I/O cycle must restore the updated owner state too.
|
||||
app.busy = true;
|
||||
mounted.settle(&mut app).await;
|
||||
app.busy = false;
|
||||
mounted.settle(&mut app).await;
|
||||
assert!(!modifier_canvas_focused(&mut mounted, &app));
|
||||
assert_eq!(
|
||||
app.multiselect_snapshot().unwrap().targets(),
|
||||
&[Target::Placement(first)]
|
||||
);
|
||||
let at = modifier_node_point(&mut mounted, &app, diagram, second);
|
||||
let messages = mounted.click(&mut app, at).await;
|
||||
assert!(!messages.iter().any(|m| matches!(
|
||||
m,
|
||||
Message::DiagramSelection(multiselect::Event::Choose(
|
||||
_,
|
||||
_,
|
||||
multiselect::Choice::Toggle
|
||||
))
|
||||
)));
|
||||
assert_eq!(
|
||||
app.multiselect_snapshot().unwrap().targets(),
|
||||
&[Target::Placement(second)]
|
||||
);
|
||||
assert_eq!(app.project(), &before.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
assert!(!app.document.can_undo());
|
||||
assert!(!app.draft_dirty());
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mounted_window_unfocus_resets_held_shift_before_next_canvas_press() {
|
||||
use crate::diagram_usage::Target;
|
||||
let (mut app, mut mounted, diagram, first, second) = modifier_focus_fixture().await;
|
||||
let at_first = modifier_node_point(&mut mounted, &app, diagram, first);
|
||||
mounted.click(&mut app, at_first).await;
|
||||
let before = app.document.snapshot();
|
||||
mounted
|
||||
.batch(
|
||||
&mut app,
|
||||
&[
|
||||
modifier_event(true),
|
||||
iced::Event::Window(iced::window::Event::Unfocused),
|
||||
],
|
||||
mouse::Cursor::Unavailable,
|
||||
)
|
||||
.await;
|
||||
assert!(!modifier_canvas_focused(&mut mounted, &app));
|
||||
// No ModifiersChanged(empty) follows: a lost window must itself reset it.
|
||||
let at_second = modifier_node_point(&mut mounted, &app, diagram, second);
|
||||
mounted.click(&mut app, at_second).await;
|
||||
assert_eq!(
|
||||
app.multiselect_snapshot().unwrap().targets(),
|
||||
&[Target::Placement(second)]
|
||||
);
|
||||
assert_eq!(app.project(), &before.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
assert!(!app.document.can_undo());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mounted_selection_strip_keeps_canvas_and_fitted_camera_fixed_for_empty_single_group_and_mixed()
|
||||
{
|
||||
use crate::diagram_usage::Target;
|
||||
let mut project = Project::empty();
|
||||
let a = project
|
||||
.add_element(Kind::Activity, Layer::Oa, "First activity", None)
|
||||
.unwrap();
|
||||
let b = project
|
||||
.add_element(
|
||||
Kind::Activity,
|
||||
Layer::Oa,
|
||||
&format!(
|
||||
"Second activity {}",
|
||||
"with a very long primary name ".repeat(20)
|
||||
),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let edge = project.relate(RelationKind::Interaction, "Activity exchange", a, b);
|
||||
let diagram = project.ensure_diagram(Layer::Oa);
|
||||
project.diagrams.get_mut(&diagram).unwrap().projection = ProjectionKind::Overview;
|
||||
let first = project.place(diagram, a, Position { x: 80., y: 80. });
|
||||
let second = project.place(diagram, b, Position { x: 500., y: 80. });
|
||||
let mut app = App::with_project(project);
|
||||
app.page = Page::Diagram;
|
||||
app.layer = Layer::Oa;
|
||||
app.active_diagram = Some(diagram);
|
||||
app.window_size = Size::new(1442., 932.);
|
||||
let _ = app.update(Message::Select(diagram));
|
||||
app.fit_to_view = true;
|
||||
let mut mounted = Mounted::new(&mut app).await;
|
||||
let baseline = app.document.snapshot();
|
||||
let bounds = mounted
|
||||
.geometry(&app)
|
||||
.bounds(crate::allocation_input::CANVAS_ID);
|
||||
let camera = (app.zoom, app.pan, app.fit_to_view, app.canvas_size);
|
||||
assert!(camera.2, "exercise the native resize-triggered refit path");
|
||||
assert!(app.multiselect_snapshot().unwrap().targets().is_empty());
|
||||
assert!(bounds.width > 500. && bounds.height > 300.);
|
||||
for (target, expected) in [
|
||||
(
|
||||
Some(Target::Placement(first)),
|
||||
vec![Target::Placement(first)],
|
||||
),
|
||||
(
|
||||
Some(Target::Placement(second)),
|
||||
vec![Target::Placement(first), Target::Placement(second)],
|
||||
),
|
||||
(
|
||||
Some(Target::Edge(edge)),
|
||||
vec![
|
||||
Target::Placement(first),
|
||||
Target::Placement(second),
|
||||
Target::Edge(edge),
|
||||
],
|
||||
),
|
||||
(
|
||||
Some(Target::Edge(edge)),
|
||||
vec![Target::Placement(first), Target::Placement(second)],
|
||||
),
|
||||
(
|
||||
Some(Target::Placement(second)),
|
||||
vec![Target::Placement(first)],
|
||||
),
|
||||
(None, vec![]),
|
||||
] {
|
||||
let context = app.multiselect_context().unwrap();
|
||||
let event = match target {
|
||||
Some(target) => {
|
||||
multiselect::Event::Choose(context, target, multiselect::Choice::Toggle)
|
||||
}
|
||||
None => multiselect::Event::Clear(context),
|
||||
};
|
||||
let task = app.update(Message::DiagramSelection(event));
|
||||
mounted.perform(&mut app, task).await;
|
||||
mounted.settle(&mut app).await;
|
||||
assert_eq!(app.multiselect_snapshot().unwrap().targets(), expected);
|
||||
assert_eq!(
|
||||
mounted
|
||||
.geometry(&app)
|
||||
.bounds(crate::allocation_input::CANVAS_ID),
|
||||
bounds,
|
||||
"selection feedback must not change the measured canvas rectangle"
|
||||
);
|
||||
assert_eq!(
|
||||
(app.zoom, app.pan, app.fit_to_view, app.canvas_size),
|
||||
camera
|
||||
);
|
||||
assert_eq!(app.project(), &baseline.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &baseline.bundle);
|
||||
assert!(!app.document.can_undo());
|
||||
assert!(!app.draft_dirty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
//! Captured diagram removal and model-confirmation lifetimes.
|
||||
use super::*;
|
||||
use crate::{model_menu, route_edit::Context};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Intent {
|
||||
pub context: Context,
|
||||
pub object: Id,
|
||||
pub placement: Option<Id>,
|
||||
pub model: bool,
|
||||
}
|
||||
|
||||
pub struct Confirmation {
|
||||
pub snapshot: model_menu::Opened,
|
||||
pub selection: Id,
|
||||
pub placement: Option<Id>,
|
||||
pub behavior_owner: Option<Id>,
|
||||
}
|
||||
|
||||
pub fn enabled(app: &App) -> bool {
|
||||
!app.busy && focus_enabled(app)
|
||||
}
|
||||
|
||||
pub(crate) fn focus_enabled(app: &App) -> bool {
|
||||
crate::behavior::canvas_workspace::keyboard_owner_enabled(app)
|
||||
&& !app.draft_dirty()
|
||||
&& !app.source_dirty()
|
||||
&& !app.behavior.has_pending()
|
||||
&& app
|
||||
.architecture
|
||||
.as_ref()
|
||||
.is_none_or(|state| !state.inline())
|
||||
}
|
||||
|
||||
pub(crate) fn behavior_owner(app: &App) -> Option<Id> {
|
||||
app.topology_context()
|
||||
.map(|context| context.owner)
|
||||
.or_else(|| app.scenario_context().map(|context| context.scenario))
|
||||
}
|
||||
|
||||
fn placement(app: &App) -> Option<Id> {
|
||||
if behavior_owner(app).is_some() {
|
||||
None
|
||||
} else if app.page == Page::Behavior {
|
||||
app.behavior.diagram.placement
|
||||
} else {
|
||||
app.selected_node
|
||||
}
|
||||
}
|
||||
|
||||
/// The derived canvases remove semantic occurrences, not reusable definitions.
|
||||
/// Kept beside target capture so confirmation rendering uses the same meaning.
|
||||
pub(crate) fn explanation(app: &App, object: Id) -> &'static str {
|
||||
if app
|
||||
.project()
|
||||
.diagrams
|
||||
.values()
|
||||
.any(|diagram| diagram.nodes.contains_key(&object))
|
||||
{
|
||||
return "Remove only this diagram placement. The model element and its placements in other diagrams remain. Undo restores this placement.";
|
||||
}
|
||||
if app
|
||||
.project()
|
||||
.relations
|
||||
.get(&object)
|
||||
.is_some_and(|relation| {
|
||||
matches!(
|
||||
relation.kind(),
|
||||
RelationKind::StateAction | RelationKind::StateTransition
|
||||
)
|
||||
})
|
||||
{
|
||||
return "Remove this state relation. Its states and referenced activities or functions remain. Undo restores the relation.";
|
||||
}
|
||||
if app
|
||||
.project()
|
||||
.relations
|
||||
.get(&object)
|
||||
.is_some_and(|relation| {
|
||||
matches!(
|
||||
relation.kind(),
|
||||
RelationKind::SequenceLink | RelationKind::ContextualLink
|
||||
)
|
||||
})
|
||||
{
|
||||
return "Remove this process link. Its endpoint occurrences and referenced reusable model elements are preserved. Undo restores the deletion.";
|
||||
}
|
||||
match app
|
||||
.project()
|
||||
.elements
|
||||
.get(&object)
|
||||
.and_then(|element| element.shared())
|
||||
{
|
||||
Some(SharedBody::Lifeline { .. }) => {
|
||||
"Remove this participant from the scenario. Its represented model element is preserved. Messages and other occurrences remain with affected participant references cleared. Undo restores the deletion."
|
||||
}
|
||||
Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Fragment { .. } | OccurrenceBody::Branch { .. },
|
||||
..
|
||||
}) => {
|
||||
"Remove this occurrence and its owned branches and steps. Referenced reusable model elements are preserved. Undo restores the deletion."
|
||||
}
|
||||
Some(SharedBody::Occurrence { .. }) => {
|
||||
"Remove this occurrence and its dependent links. Referenced reusable model elements are preserved. Undo restores the deletion."
|
||||
}
|
||||
_ => {
|
||||
"Owned children, incident relations, occurrences, placements and trace links will also be removed. The traced counterpart itself is preserved. Undo restores the deletion."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl App {
|
||||
pub(super) fn remove_from_diagram(&mut self, intent: Intent) {
|
||||
if !enabled(self) || self.connection.is_some() || self.selected_bend.is_some() {
|
||||
return;
|
||||
}
|
||||
let current = match self.page {
|
||||
Page::Diagram if self.architecture.as_ref().is_none_or(|s| !s.saved.placing) => {
|
||||
self.route_context()
|
||||
}
|
||||
Page::Behavior
|
||||
if self.behavior.diagram.tool == crate::behavior::state_edit::Tool::Select =>
|
||||
{
|
||||
self.state_context()
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let selected_object = self
|
||||
.project()
|
||||
.diagrams
|
||||
.get(&intent.context.diagram)
|
||||
.and_then(|diagram| diagram.nodes.get(&self.selected))
|
||||
.map_or(self.selected, |node| node.element);
|
||||
if current != Some(intent.context) || selected_object != intent.object {
|
||||
return;
|
||||
}
|
||||
let view = projection::project_view(self.project(), intent.context.diagram);
|
||||
let placement = if self.page == Page::Behavior {
|
||||
self.behavior.diagram.placement
|
||||
} else {
|
||||
self.selected_node
|
||||
};
|
||||
if intent.placement != placement || intent.object == intent.context.diagram {
|
||||
return;
|
||||
}
|
||||
let visible = if let Some(id) = intent.placement {
|
||||
self.project().diagrams[&intent.context.diagram]
|
||||
.nodes
|
||||
.get(&id)
|
||||
.is_some_and(|node| node.element == intent.object)
|
||||
&& view
|
||||
.nodes
|
||||
.iter()
|
||||
.any(|node| node.id == id && node.element == intent.object)
|
||||
} else {
|
||||
view.edges.iter().any(|edge| edge.id == intent.object)
|
||||
|| view.ports.iter().any(|port| port.element == intent.object)
|
||||
|| (self.page == Page::Behavior
|
||||
&& crate::behavior::state_canvas::visible_row_relation(
|
||||
self.project(),
|
||||
intent.context.diagram,
|
||||
&view,
|
||||
intent.object,
|
||||
))
|
||||
};
|
||||
if !visible {
|
||||
return;
|
||||
}
|
||||
if !intent.model
|
||||
&& let Some(placement) = intent.placement
|
||||
{
|
||||
if self.execute(Command::Delete(placement)) {
|
||||
self.selected_node = None;
|
||||
self.behavior.diagram.placement = None;
|
||||
self.selected_bend = None;
|
||||
self.status = "Hidden from this diagram · Model element preserved · Undo restores the placement".into();
|
||||
}
|
||||
} else {
|
||||
self.begin_model_delete_target(intent.object, Some(intent.context));
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn begin_model_delete(&mut self, canvas: Option<Context>) {
|
||||
self.begin_model_delete_target(self.selected, canvas);
|
||||
}
|
||||
|
||||
fn begin_model_delete_target(&mut self, object: Id, canvas: Option<Context>) {
|
||||
if object == self.project().id || self.behavior.has_pending() {
|
||||
return;
|
||||
}
|
||||
let mut request = model_menu::Request::object(object, iced::Point::ORIGIN);
|
||||
request.canvas = canvas;
|
||||
request.placement = placement(self);
|
||||
// Opening a modal retires any pending canvas intent before capturing
|
||||
// the confirmation; every new dialog receives its own replay token.
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
if canvas.is_some() {
|
||||
request.canvas = if self.page == Page::Behavior {
|
||||
self.state_context()
|
||||
} else {
|
||||
self.route_context()
|
||||
};
|
||||
}
|
||||
self.menu_sequence = self.menu_sequence.wrapping_add(1);
|
||||
self.delete_target =
|
||||
model_menu::Opened::new(self, request, self.menu_sequence).map(|snapshot| {
|
||||
Confirmation {
|
||||
snapshot,
|
||||
selection: self.selected,
|
||||
placement: placement(self),
|
||||
behavior_owner: behavior_owner(self),
|
||||
}
|
||||
});
|
||||
self.delete_pending = self.delete_target.is_some();
|
||||
}
|
||||
|
||||
pub(super) fn confirm_model_delete(&mut self, token: u64) {
|
||||
let Some(target) = self.delete_target.as_ref() else {
|
||||
return;
|
||||
};
|
||||
if target.snapshot.token != token {
|
||||
return;
|
||||
}
|
||||
let valid = self.delete_pending
|
||||
&& target.snapshot.valid(self)
|
||||
&& target.selection == self.selected
|
||||
&& target.placement == placement(self)
|
||||
&& target.behavior_owner == behavior_owner(self)
|
||||
&& !self.behavior.has_pending()
|
||||
&& !self.source_dirty()
|
||||
&& !self.draft_dirty();
|
||||
let id = target.snapshot.request.object;
|
||||
self.delete_pending = false;
|
||||
self.delete_target = None;
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
if valid {
|
||||
self.execute(Command::Delete(id));
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,146 @@
|
||||
//! Navigate first, then remove through the mounted root canvas and history.
|
||||
use super::*;
|
||||
use crate::diagram_usage::{self as usage, Target};
|
||||
|
||||
async fn navigate_then_remove(project: Project, subject: Id, owner: Id, row: bool) {
|
||||
let mut app = App::with_project(project);
|
||||
app.window_size = Size::new(2000., 1400.);
|
||||
let before = app.document.snapshot();
|
||||
let message = app.diagram_usage_message(subject);
|
||||
let _ = app.update(message);
|
||||
let panel = app.diagram_usages.as_ref().unwrap();
|
||||
let token = panel.token;
|
||||
let context = panel.context;
|
||||
// Complete the same query independently without polling its worker Task.
|
||||
let process = app.behavior.topology.usage_snapshot().into_query_state();
|
||||
let completion = usage::Completion {
|
||||
token,
|
||||
context,
|
||||
result: Ok(usage::collect(
|
||||
app.project(),
|
||||
subject,
|
||||
&process,
|
||||
&behavior::scenario::State::default(),
|
||||
)),
|
||||
};
|
||||
let _ = app.update(Message::DiagramUsagesReady(completion));
|
||||
let found = app
|
||||
.diagram_usages
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.usages
|
||||
.iter()
|
||||
.find(|usage| {
|
||||
if row {
|
||||
matches!(usage.target, Target::StateRow { relation, .. } if relation == subject)
|
||||
} else {
|
||||
matches!(usage.target, Target::Port { port, .. } if port == subject)
|
||||
}
|
||||
})
|
||||
.unwrap()
|
||||
.clone();
|
||||
let _ = app.update(Message::DiagramUsage(usage::Event::Choose(
|
||||
token,
|
||||
found.clone(),
|
||||
)));
|
||||
assert_eq!(app.selected, subject);
|
||||
// refresh_draft normalizes the owner's presentation placement; navigation
|
||||
// must not leave it armed as a removal target.
|
||||
assert!(app.selected_node.is_none());
|
||||
assert!(app.behavior.diagram.placement.is_none());
|
||||
assert_eq!(
|
||||
app.diagram_focus(found.surface).unwrap().target,
|
||||
found.target
|
||||
);
|
||||
|
||||
let mut mounted = Mounted::new(&mut app).await;
|
||||
assert_eq!(app.project(), &before.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
assert!(app.pending_diagram_reveal.is_none());
|
||||
for model in [false, true] {
|
||||
mounted
|
||||
.perform(
|
||||
&mut app,
|
||||
iced::widget::operation::focus(iced::advanced::widget::Id::from(
|
||||
"diagram-keyboard-owner",
|
||||
)),
|
||||
)
|
||||
.await;
|
||||
let messages = delete(&mut mounted, &mut app, model).await;
|
||||
let request = messages
|
||||
.iter()
|
||||
.find_map(|message| match message {
|
||||
Message::DiagramRemove(intent)
|
||||
| Message::StateDiagram(behavior::state_edit::Event::Remove(intent)) => {
|
||||
Some(intent)
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.expect("actual focused canvas emitted a removal intent");
|
||||
assert_eq!(request.object, subject);
|
||||
assert_eq!(request.placement, None);
|
||||
assert_eq!(request.model, model);
|
||||
assert_eq!(
|
||||
app.delete_target.as_ref().unwrap().snapshot.request.object,
|
||||
subject
|
||||
);
|
||||
assert_eq!(app.project(), &before.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
if !model {
|
||||
let task = app.update(Message::CancelDelete);
|
||||
mounted.perform(&mut app, task).await;
|
||||
}
|
||||
}
|
||||
let token = app.delete_target.as_ref().unwrap().snapshot.token;
|
||||
let task = app.update(Message::ConfirmDelete(token));
|
||||
mounted.perform(&mut app, task).await;
|
||||
let expected = execute_batch(&before.decoded.project, &[Command::Delete(subject)]).unwrap();
|
||||
assert_eq!(app.project(), &expected);
|
||||
assert!(app.project().contains(owner));
|
||||
consistent(&app);
|
||||
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());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn port_navigation_then_canvas_delete_preserves_owner_and_restores_exact_history() {
|
||||
let mut project = Project::empty();
|
||||
let owner = project.add(Kind::Function, "Compute", 1200., 1500.);
|
||||
let port = Id::new_v4();
|
||||
let element = oa_core::ports::make_port(
|
||||
&project,
|
||||
port,
|
||||
owner,
|
||||
"Input",
|
||||
oa_core::PortContract::FunctionInput {
|
||||
incoming_items: vec![],
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
project = execute_batch(&project, &[Command::CreateElement(element)]).unwrap();
|
||||
navigate_then_remove(project, port, owner, false).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn state_row_navigation_then_canvas_delete_preserves_work_and_restores_exact_history() {
|
||||
let mut project = Project::empty();
|
||||
let machine = project
|
||||
.add_element(Kind::StateMachine, Layer::Sa, "Modes", None)
|
||||
.unwrap();
|
||||
let region = project
|
||||
.add_element(Kind::Region, Layer::Sa, "Main", Some(machine))
|
||||
.unwrap();
|
||||
let state = project
|
||||
.add_element(Kind::State, Layer::Sa, "Running", Some(region))
|
||||
.unwrap();
|
||||
let work = project
|
||||
.add_element(Kind::Function, Layer::Sa, "Monitor", None)
|
||||
.unwrap();
|
||||
let relation = project.relate(RelationKind::StateAction, "Entry", state, work);
|
||||
let plan = crate::behavior::state_edit::create_view(&project, machine).unwrap();
|
||||
project = execute_batch(&project, &[plan.command]).unwrap();
|
||||
navigate_then_remove(project, relation, work, true).await;
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
//! Complete a navigation camera after the destination reports its real viewport.
|
||||
use super::*;
|
||||
use crate::behavior::scenario_layout::Representation;
|
||||
use crate::diagram_usage::{Context, Surface};
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -48,23 +47,11 @@ impl App {
|
||||
&& c.epoch == *epoch,
|
||||
),
|
||||
Message::ScenarioCanvas(behavior::scenario::CanvasEvent::Viewport(c, size)) => (
|
||||
match c.representation {
|
||||
Representation::Derived { owner } => Surface::Scenario(owner),
|
||||
Representation::Saved { diagram } => Surface::Saved(diagram),
|
||||
},
|
||||
Surface::Scenario(c.scenario),
|
||||
*size,
|
||||
c.document == context.document
|
||||
&& c.revision == context.revision
|
||||
&& c.epoch == *epoch
|
||||
&& self.pending_scenario_reveal_visit == Some(c.visit)
|
||||
&& self.page == Page::Behavior
|
||||
&& self.behavior.active_owner == Some(c.scenario)
|
||||
&& self.behavior.scenario.context(
|
||||
self.canvas_document,
|
||||
c.scenario,
|
||||
self.document.revision(),
|
||||
self.canvas_epoch,
|
||||
) == Some(*c),
|
||||
&& c.epoch == *epoch,
|
||||
),
|
||||
Message::StateDiagram(behavior::state_edit::Event::Viewport(c, size)) => (
|
||||
Surface::Saved(c.diagram),
|
||||
@@ -99,12 +86,13 @@ impl App {
|
||||
| Message::DiagramUsagesReady(_)
|
||||
| Message::SourceSymbolsReady(_)
|
||||
| Message::SourceRenameReady(_)
|
||||
| Message::WorkDrag(allocation_drag::Event::KeyboardBoundary)
|
||||
| Message::WorkDrag(allocation_drag::Event::Unfocused)
|
||||
| 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;
|
||||
}
|
||||
@@ -124,13 +112,11 @@ impl App {
|
||||
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(..)
|
||||
@@ -156,6 +142,5 @@ impl App {
|
||||
self.reveal_diagram_usage(&usage);
|
||||
}
|
||||
self.pending_diagram_reveal = None;
|
||||
self.pending_scenario_reveal_visit = None;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,7 +98,6 @@ fn fixture(canvas: Canvas) -> (App, Usage) {
|
||||
layer: Layer::Oa,
|
||||
projection: ProjectionKind::StateMachine { machine },
|
||||
nodes: BTreeMap::new(),
|
||||
layout: None,
|
||||
routes: vec![],
|
||||
view_options: Default::default(),
|
||||
},
|
||||
@@ -144,7 +143,6 @@ fn measurement(
|
||||
epoch: u64,
|
||||
surface: Surface,
|
||||
size: Size,
|
||||
visit: Id,
|
||||
) -> Message {
|
||||
match canvas {
|
||||
Canvas::Architecture => Message::DiagramRevealViewport(context, epoch, size),
|
||||
@@ -161,10 +159,6 @@ fn measurement(
|
||||
behavior::scenario::Context {
|
||||
document: context.document,
|
||||
scenario: surface.id(),
|
||||
representation: Representation::Derived {
|
||||
owner: surface.id(),
|
||||
},
|
||||
visit,
|
||||
revision: context.revision,
|
||||
epoch,
|
||||
},
|
||||
@@ -182,16 +176,12 @@ fn measurement(
|
||||
}
|
||||
}
|
||||
|
||||
fn stamp(app: &App) -> (Context, u64, Id) {
|
||||
fn stamp(app: &App) -> (Context, u64) {
|
||||
let (context, _, epoch) = app
|
||||
.pending_diagram_reveal
|
||||
.as_ref()
|
||||
.expect("reveal awaits measured destination");
|
||||
(
|
||||
*context,
|
||||
*epoch,
|
||||
app.pending_scenario_reveal_visit.unwrap_or(Id::nil()),
|
||||
)
|
||||
(*context, *epoch)
|
||||
}
|
||||
|
||||
fn assert_centered(app: &App, canvas: Canvas, target: &Usage) {
|
||||
@@ -229,11 +219,7 @@ fn assert_centered(app: &App, canvas: Canvas, target: &Usage) {
|
||||
expected.choose_tool(target.surface.id(), behavior::scenario::CanvasTool::Select);
|
||||
expected.viewport(ACTUAL);
|
||||
expected.zoom(Some(zoom));
|
||||
expected.camera(
|
||||
app.project(),
|
||||
target.surface.id(),
|
||||
behavior::canvas_workspace::Event::Pan(pan),
|
||||
);
|
||||
expected.camera(behavior::canvas_workspace::Event::Pan(pan));
|
||||
assert!(app.behavior.scenario.camera_boundary() == expected.camera_boundary());
|
||||
}
|
||||
}
|
||||
@@ -252,10 +238,10 @@ fn all_reveal_surfaces_recenter_once_after_the_real_viewport_and_preserve_author
|
||||
app.document.dirty(),
|
||||
);
|
||||
assert!(app.reveal_diagram_usage(&target));
|
||||
let (context, epoch, visit) = stamp(&app);
|
||||
let (context, epoch) = stamp(&app);
|
||||
send(
|
||||
&mut app,
|
||||
measurement(canvas, context, epoch, target.surface, ACTUAL, visit),
|
||||
measurement(canvas, context, epoch, target.surface, ACTUAL),
|
||||
);
|
||||
assert!(app.pending_diagram_reveal.is_none());
|
||||
assert_centered(&app, canvas, &target);
|
||||
@@ -274,7 +260,7 @@ fn all_reveal_surfaces_recenter_once_after_the_real_viewport_and_preserve_author
|
||||
let camera = app.camera_boundary();
|
||||
send(
|
||||
&mut app,
|
||||
measurement(canvas, context, epoch, target.surface, ACTUAL, visit),
|
||||
measurement(canvas, context, epoch, target.surface, ACTUAL),
|
||||
);
|
||||
assert!(app.pending_diagram_reveal.is_none());
|
||||
assert!(app.camera_boundary() == camera);
|
||||
@@ -288,7 +274,7 @@ fn malformed_or_wrong_stamp_measurements_cannot_poison_a_pending_reveal() {
|
||||
for invalid in 0..6 {
|
||||
let (mut app, target) = fixture(canvas);
|
||||
assert!(app.reveal_diagram_usage(&target));
|
||||
let (context, epoch, visit) = stamp(&app);
|
||||
let (context, epoch) = stamp(&app);
|
||||
let camera = app.camera_boundary();
|
||||
let mut stale = context;
|
||||
let mut wrong_epoch = epoch;
|
||||
@@ -303,13 +289,13 @@ fn malformed_or_wrong_stamp_measurements_cannot_poison_a_pending_reveal() {
|
||||
}
|
||||
send(
|
||||
&mut app,
|
||||
measurement(canvas, stale, wrong_epoch, target.surface, size, visit),
|
||||
measurement(canvas, stale, wrong_epoch, target.surface, size),
|
||||
);
|
||||
assert!(
|
||||
app.pending_diagram_reveal.is_some(),
|
||||
"{canvas:?} invalid case {invalid}"
|
||||
);
|
||||
assert_eq!(stamp(&app), (context, epoch, visit));
|
||||
assert_eq!(stamp(&app), (context, epoch));
|
||||
assert_eq!(app.canvas_epoch, epoch);
|
||||
assert!(
|
||||
app.camera_boundary() == camera,
|
||||
@@ -317,7 +303,7 @@ fn malformed_or_wrong_stamp_measurements_cannot_poison_a_pending_reveal() {
|
||||
);
|
||||
send(
|
||||
&mut app,
|
||||
measurement(canvas, context, epoch, target.surface, ACTUAL, visit),
|
||||
measurement(canvas, context, epoch, target.surface, ACTUAL),
|
||||
);
|
||||
assert!(app.pending_diagram_reveal.is_none());
|
||||
assert_centered(&app, canvas, &target);
|
||||
@@ -331,10 +317,9 @@ fn actual_window_resize_before_first_measurement_retains_focus_for_the_new_conte
|
||||
let (mut app, target) = fixture(canvas);
|
||||
let accepted = app.document.snapshot();
|
||||
assert!(app.reveal_diagram_usage(&target));
|
||||
let (context, old_epoch, visit) = stamp(&app);
|
||||
let (context, old_epoch) = stamp(&app);
|
||||
send(&mut app, Message::WindowResized(Size::new(1100., 700.)));
|
||||
let (current, epoch, current_visit) = stamp(&app);
|
||||
assert_eq!(current_visit, visit);
|
||||
let (current, epoch) = stamp(&app);
|
||||
assert_eq!(current, context);
|
||||
assert_eq!(epoch, app.canvas_epoch);
|
||||
if matches!(canvas, Canvas::Process | Canvas::Scenario) {
|
||||
@@ -342,14 +327,14 @@ fn actual_window_resize_before_first_measurement_retains_focus_for_the_new_conte
|
||||
let camera = app.camera_boundary();
|
||||
send(
|
||||
&mut app,
|
||||
measurement(canvas, context, old_epoch, target.surface, ACTUAL, visit),
|
||||
measurement(canvas, context, old_epoch, target.surface, ACTUAL),
|
||||
);
|
||||
assert_eq!(stamp(&app), (current, epoch, visit));
|
||||
assert_eq!(stamp(&app), (current, epoch));
|
||||
assert!(app.camera_boundary() == camera);
|
||||
}
|
||||
send(
|
||||
&mut app,
|
||||
measurement(canvas, current, epoch, target.surface, ACTUAL, visit),
|
||||
measurement(canvas, current, epoch, target.surface, ACTUAL),
|
||||
);
|
||||
assert!(app.pending_diagram_reveal.is_none());
|
||||
assert_centered(&app, canvas, &target);
|
||||
@@ -364,7 +349,7 @@ fn user_camera_edit_and_navigation_cancel_deferred_focus_without_late_snapping()
|
||||
let (mut app, target) = fixture(canvas);
|
||||
let accepted = app.document.snapshot();
|
||||
assert!(app.reveal_diagram_usage(&target));
|
||||
let (context, epoch, visit) = stamp(&app);
|
||||
let (context, epoch) = stamp(&app);
|
||||
let event = match action {
|
||||
0 => match canvas {
|
||||
Canvas::Architecture => Message::Pan(Vector::new(31., -17.)),
|
||||
@@ -401,7 +386,7 @@ fn user_camera_edit_and_navigation_cancel_deferred_focus_without_late_snapping()
|
||||
// restore the old navigation camera after deliberate user input.
|
||||
send(
|
||||
&mut app,
|
||||
measurement(canvas, context, epoch, target.surface, WIDE, visit),
|
||||
measurement(canvas, context, epoch, target.surface, WIDE),
|
||||
);
|
||||
assert!(app.pending_diagram_reveal.is_none());
|
||||
assert!(
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
//! Navigation-only adapter: validates destinations before changing presentation.
|
||||
use super::*;
|
||||
use crate::behavior::scenario_layout::{Representation, presentation};
|
||||
use crate::diagram_usage::{self as usage, Context, Event, Panel, Surface, Target, Usage};
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -145,14 +144,31 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
pub(super) fn diagram_usage_envelope(&self, event: &Event) -> bool {
|
||||
match event {
|
||||
Event::Open(context) => {
|
||||
context.document == self.canvas_document
|
||||
&& context.revision == self.document.revision()
|
||||
&& self.project().contains(context.subject)
|
||||
}
|
||||
Event::Close(token)
|
||||
| Event::Search(token, _)
|
||||
| Event::Page(token, _)
|
||||
| Event::Move(token, _)
|
||||
| Event::Activate(token)
|
||||
| Event::Destination(token, _)
|
||||
| Event::Choose(token, _) => self
|
||||
.diagram_usages
|
||||
.as_ref()
|
||||
.is_some_and(|panel| panel.token == *token),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn diagram_usage_event(&mut self, event: Event) -> Task<Message> {
|
||||
if !self.diagram_usage_envelope(&event) {
|
||||
return Task::none();
|
||||
}
|
||||
if let Event::Open(context) = event {
|
||||
if !self.usage_navigation_available() {
|
||||
self.status = "Apply or revert pending edits before opening diagram navigation. Your inputs are retained.".into();
|
||||
return Task::none();
|
||||
@@ -165,7 +181,6 @@ impl App {
|
||||
// Only an accepted new chooser supersedes the previous reveal.
|
||||
// Rejected/stale panel envelopes leave its first measurement intact.
|
||||
self.pending_diagram_reveal = None;
|
||||
self.pending_scenario_reveal_visit = None;
|
||||
self.diagram_navigation_focus = None;
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
return Task::batch([
|
||||
@@ -173,22 +188,10 @@ impl App {
|
||||
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();
|
||||
}
|
||||
let panel = self
|
||||
.diagram_usages
|
||||
.as_ref()
|
||||
.expect("admitted chooser token");
|
||||
if matches!(event, Event::Close(_)) {
|
||||
self.diagram_usages = None;
|
||||
if self.page == Page::Text {
|
||||
@@ -288,78 +291,11 @@ impl App {
|
||||
Task::none()
|
||||
}
|
||||
|
||||
/// Explicit saved-Scenario reveal runs before generic Apply. An absent target
|
||||
/// never selects the owner or another saved arrangement as a fallback.
|
||||
pub(super) fn reveal_saved_scenario(&mut self, diagram: Id, selected: Id) -> Task<Message> {
|
||||
if self.diagram_usages.is_some() || !self.usage_navigation_available() {
|
||||
self.status = "Apply or revert pending edits before revealing a saved Scenario. Your inputs are retained.".into();
|
||||
return Task::none();
|
||||
}
|
||||
if presentation::checked_owner(self.project(), Representation::Saved { diagram }).is_none()
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
let Some(found) = self
|
||||
.current_surface_usages(selected, Surface::Saved(diagram))
|
||||
.into_iter()
|
||||
.find(|row| matches!(row.target, Target::Whole | Target::ScenarioObject(_)))
|
||||
else {
|
||||
self.status = "This object has no visible target in that saved Scenario.".into();
|
||||
return Task::none();
|
||||
};
|
||||
self.reveal_diagram_usage(&found);
|
||||
Task::none()
|
||||
}
|
||||
|
||||
pub(super) fn reveal_diagram_usage(&mut self, usage: &Usage) -> bool {
|
||||
if !self.usage_navigation_available() {
|
||||
return false;
|
||||
}
|
||||
let previous_layer = self.layer;
|
||||
let scenario_representation = match usage.surface {
|
||||
Surface::Saved(diagram) if self.saved_scenario_diagram(diagram) => {
|
||||
Some(Representation::Saved { diagram })
|
||||
}
|
||||
Surface::Scenario(owner) => Some(Representation::Derived { owner }),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(representation) = scenario_representation {
|
||||
let Some(owner) = presentation::checked_owner(self.project(), representation) else {
|
||||
return false;
|
||||
};
|
||||
if !self
|
||||
.behavior
|
||||
.scenario
|
||||
.representation_usages(self.project(), representation, usage.subject)
|
||||
.iter()
|
||||
.any(|row| row == usage)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
// Finish target/camera admission before changing the App route. The
|
||||
// checked immutable owner also makes the following entry infallible.
|
||||
let revealed = match representation {
|
||||
Representation::Derived { .. } => self.behavior.scenario.reveal_usage(
|
||||
self.document.project(),
|
||||
self.canvas_document,
|
||||
owner,
|
||||
&usage.target,
|
||||
),
|
||||
Representation::Saved { .. } => self.behavior.scenario.reveal_representation_usage(
|
||||
self.document.project(),
|
||||
self.canvas_document,
|
||||
owner,
|
||||
representation,
|
||||
&usage.target,
|
||||
),
|
||||
};
|
||||
if !revealed {
|
||||
return false;
|
||||
}
|
||||
if !self.enter_scenario_representation(representation) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
let p = self.document.project();
|
||||
let (selected, placement) = match &usage.target {
|
||||
Target::Whole => (usage.surface.id(), None),
|
||||
@@ -383,14 +319,7 @@ impl App {
|
||||
| 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,
|
||||
),
|
||||
Target::ProcessOccurrence(endpoint) => (endpoint.occurrence, None),
|
||||
};
|
||||
match usage.surface {
|
||||
Surface::Saved(id) => {
|
||||
@@ -399,10 +328,7 @@ impl App {
|
||||
};
|
||||
self.layer = diagram.layer;
|
||||
self.active_diagram = Some(id);
|
||||
if let Some(owner) = diagram.projection.scenario() {
|
||||
self.behavior.active_owner = Some(owner);
|
||||
self.page = Page::Behavior;
|
||||
} else if let Some(machine) = diagram.projection.state_machine() {
|
||||
if let Some(machine) = diagram.projection.state_machine() {
|
||||
let viewport = self.behavior.diagram.viewport.unwrap_or(iced::Size::new(
|
||||
(self.window_size.width - 610.).max(280.),
|
||||
(self.window_size.height - 320.).max(240.),
|
||||
@@ -444,14 +370,19 @@ impl App {
|
||||
self.page = Page::Behavior;
|
||||
}
|
||||
Surface::Scenario(owner) => {
|
||||
if !self.behavior.scenario.reveal_usage(
|
||||
p,
|
||||
self.canvas_document,
|
||||
owner,
|
||||
&usage.target,
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
self.layer = p.elements[&owner].layer();
|
||||
self.behavior.active_owner = Some(owner);
|
||||
self.page = Page::Behavior;
|
||||
}
|
||||
}
|
||||
if scenario_representation.is_none() {
|
||||
self.behavior.scenario.forget_view();
|
||||
}
|
||||
self.selected = selected;
|
||||
self.selected_node = placement;
|
||||
self.model_menu = None;
|
||||
@@ -478,18 +409,6 @@ impl App {
|
||||
usage: usage.clone(),
|
||||
selected,
|
||||
});
|
||||
self.pending_scenario_reveal_visit = scenario_representation.and_then(|representation| {
|
||||
let owner = presentation::checked_owner(self.project(), representation)?;
|
||||
self.behavior
|
||||
.scenario
|
||||
.context(
|
||||
self.canvas_document,
|
||||
owner,
|
||||
self.document.revision(),
|
||||
self.canvas_epoch,
|
||||
)
|
||||
.map(|context| context.visit)
|
||||
});
|
||||
self.pending_diagram_reveal = Some((
|
||||
Context {
|
||||
document: self.canvas_document,
|
||||
@@ -510,20 +429,10 @@ impl App {
|
||||
Surface::Saved(id) => {
|
||||
self.active_diagram == Some(id)
|
||||
&& matches!(self.page, Page::Diagram | Page::Behavior)
|
||||
&& (!self.saved_scenario_diagram(id)
|
||||
|| (self.page == Page::Behavior
|
||||
&& self.behavior.scenario.representation()
|
||||
== Some(Representation::Saved { diagram: id })))
|
||||
}
|
||||
Surface::Process(owner) => {
|
||||
Surface::Process(owner) | Surface::Scenario(owner) => {
|
||||
self.page == Page::Behavior && self.behavior.active_owner == Some(owner)
|
||||
}
|
||||
Surface::Scenario(owner) => {
|
||||
self.page == Page::Behavior
|
||||
&& self.behavior.active_owner == Some(owner)
|
||||
&& self.behavior.scenario.representation()
|
||||
== Some(Representation::Derived { owner })
|
||||
}
|
||||
};
|
||||
(current_surface
|
||||
&& focus.usage.surface == surface
|
||||
|
||||
@@ -881,7 +881,32 @@ fn repeated_process_calls_reveal_the_chosen_caller_and_distinct_tile_camera() {
|
||||
.unwrap()
|
||||
.clone();
|
||||
choose(&mut app, token, row);
|
||||
assert_eq!(app.selected, first);
|
||||
assert_eq!(app.selected, leaf);
|
||||
let context = app.topology_context().unwrap();
|
||||
send(
|
||||
&mut app,
|
||||
Message::TopologyCanvas(behavior::topology::CanvasEvent::Viewport(
|
||||
context,
|
||||
iced::Size::new(850., 460.),
|
||||
)),
|
||||
);
|
||||
assert!(app.pending_diagram_reveal.is_none());
|
||||
assert_eq!(
|
||||
app.behavior.topology.selection_address().unwrap().endpoint,
|
||||
OccurrenceEndpoint {
|
||||
references: vec![first],
|
||||
occurrence: leaf
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
app.multiselect_snapshot().unwrap().primary(),
|
||||
Some(&Target::ProcessOccurrence(OccurrenceEndpoint {
|
||||
references: vec![first],
|
||||
occurrence: leaf
|
||||
}))
|
||||
);
|
||||
assert!(app.process_preview_primary().is_some());
|
||||
assert!(app.draft.is_none());
|
||||
assert_eq!(app.page, Page::Behavior);
|
||||
assert_eq!(app.behavior.active_owner, Some(fixture.owner));
|
||||
let first_camera = app.behavior.topology.camera_boundary();
|
||||
@@ -898,7 +923,33 @@ fn repeated_process_calls_reveal_the_chosen_caller_and_distinct_tile_camera() {
|
||||
})
|
||||
.unwrap();
|
||||
choose(&mut app, token, row);
|
||||
assert_eq!(app.selected, second);
|
||||
assert_eq!(app.selected, leaf);
|
||||
let context = app.topology_context().unwrap();
|
||||
send(
|
||||
&mut app,
|
||||
Message::TopologyCanvas(behavior::topology::CanvasEvent::Viewport(
|
||||
context,
|
||||
iced::Size::new(850., 460.),
|
||||
)),
|
||||
);
|
||||
assert!(app.pending_diagram_reveal.is_none());
|
||||
assert_eq!(app.behavior.active_owner, Some(fixture.owner));
|
||||
assert_eq!(
|
||||
app.behavior.topology.selection_address().unwrap().endpoint,
|
||||
OccurrenceEndpoint {
|
||||
references: vec![second],
|
||||
occurrence: leaf
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
app.multiselect_snapshot().unwrap().primary(),
|
||||
Some(&Target::ProcessOccurrence(OccurrenceEndpoint {
|
||||
references: vec![second],
|
||||
occurrence: leaf
|
||||
}))
|
||||
);
|
||||
assert!(app.process_preview_primary().is_some());
|
||||
assert!(app.draft.is_none());
|
||||
assert_eq!(app.selected_node, None);
|
||||
assert!(app.behavior.topology.camera_boundary() != first_camera);
|
||||
assert_eq!(authored(&app), before);
|
||||
@@ -1400,5 +1451,3 @@ fn completion_releases_worker_behind_busy_modal_without_navigation() {
|
||||
assert_eq!(location(&app), prior);
|
||||
assert_eq!(authored(&app), before);
|
||||
}
|
||||
|
||||
mod saved_scenario;
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
arcadia 7;
|
||||
project incident_workspace "Incident report review" id "70200000-0000-4000-8000-000000000001" {
|
||||
// Two operational activities, with one lifeline appearance per Scenario.
|
||||
oa_activity report_incident "Report incident" id "70200000-0000-4000-8000-000000000002" {}
|
||||
oa_activity assess_incident "Assess incident" id "70200000-0000-4000-8000-000000000003" {}
|
||||
interaction incident_report "Incident report" id "70200000-0000-4000-8000-000000000004" {
|
||||
source @report_incident;
|
||||
target @assess_incident;
|
||||
}
|
||||
|
||||
oa_scenario incident_response "Review a reported incident" id "70200000-0000-4000-8000-000000000010" {
|
||||
viewpoint OperationalActivity;
|
||||
steps [@initial_report, @updated_report, @assess_call, @assess_reply, @unfinished_notice, @use_review];
|
||||
}
|
||||
oa_lifeline reporter "Reporting activity" id "70200000-0000-4000-8000-000000000011" {
|
||||
parent @incident_response; represented @report_incident;
|
||||
}
|
||||
oa_lifeline assessor "Assessment activity" id "70200000-0000-4000-8000-000000000012" {
|
||||
parent @incident_response; represented @assess_incident;
|
||||
}
|
||||
// Both appearances reuse one definition. Navigation must retain the occurrence.
|
||||
oa_occurrence initial_report "Initial incident report" id "70200000-0000-4000-8000-000000000020" {
|
||||
parent @incident_response;
|
||||
body { Exchange { exchange @incident_report; sender @reporter; receiver @assessor; message_kind Asynchronous; }; };
|
||||
}
|
||||
oa_occurrence updated_report "Updated incident report" id "70200000-0000-4000-8000-000000000021" {
|
||||
parent @incident_response;
|
||||
body { Exchange { exchange @incident_report; sender @reporter; receiver @assessor; message_kind Asynchronous; }; };
|
||||
}
|
||||
// A correlated self call/reply with deliberately unassigned exchange definitions.
|
||||
oa_occurrence assess_call "Assess report details" id "70200000-0000-4000-8000-000000000022" {
|
||||
parent @incident_response;
|
||||
body { Exchange { exchange none; sender @assessor; receiver @assessor; message_kind Call; }; };
|
||||
}
|
||||
oa_occurrence assess_reply "Assessment ready" id "70200000-0000-4000-8000-000000000023" {
|
||||
parent @incident_response;
|
||||
body { Exchange { exchange none; sender @assessor; receiver @assessor; message_kind Reply; reply_to @assess_call; }; };
|
||||
}
|
||||
// Intentional unfinished work: no receiver and therefore no fabricated arrow.
|
||||
oa_occurrence unfinished_notice "Follow-up recipient undecided" id "70200000-0000-4000-8000-000000000024" {
|
||||
parent @incident_response;
|
||||
body { Exchange { exchange @incident_report; sender @reporter; receiver none; message_kind Asynchronous; }; };
|
||||
}
|
||||
oa_occurrence use_review "Use the reviewed reporting procedure" id "70200000-0000-4000-8000-000000000025" {
|
||||
parent @incident_response;
|
||||
body { ScenarioUse {
|
||||
bindings [{ local @reporter; referenced @review_reporter; }, { local @assessor; referenced @review_assessor; }];
|
||||
scenario @reporting_review;
|
||||
}; };
|
||||
}
|
||||
|
||||
// This definition stays opaque in the caller. Open it, then Back to the saved view.
|
||||
oa_scenario reporting_review "Reporting procedure detail" id "70200000-0000-4000-8000-000000000030" {
|
||||
viewpoint OperationalActivity;
|
||||
steps [@review_delivery];
|
||||
}
|
||||
oa_lifeline review_reporter "Reporting activity in detail" id "70200000-0000-4000-8000-000000000031" {
|
||||
parent @reporting_review; represented @report_incident;
|
||||
}
|
||||
oa_lifeline review_assessor "Assessment activity in detail" id "70200000-0000-4000-8000-000000000032" {
|
||||
parent @reporting_review; represented @assess_incident;
|
||||
}
|
||||
oa_occurrence review_delivery "Check the incident report" id "70200000-0000-4000-8000-000000000033" {
|
||||
parent @reporting_review;
|
||||
body { Exchange { exchange @incident_report; sender @review_reporter; receiver @review_assessor; message_kind Asynchronous; }; };
|
||||
}
|
||||
|
||||
diagram incident_readable "A - Incident review readable" id "70200000-0000-4000-8000-000000000040" {
|
||||
layer Oa;
|
||||
projection { Scenario { scenario @incident_response; }; };
|
||||
layout { Scenario {
|
||||
lanes [{ lifeline @reporter; x 160; }, { lifeline @assessor; x 640; }];
|
||||
messages [
|
||||
{ address { references []; occurrence @initial_report; }; send_y 160; },
|
||||
{ address { references []; occurrence @updated_report; }; send_y 252; },
|
||||
{ address { references []; occurrence @assess_call; }; send_y 344; },
|
||||
{ address { references []; occurrence @assess_reply; }; send_y 436; }
|
||||
];
|
||||
}; };
|
||||
}
|
||||
diagram incident_wide "B - Incident review negative and far" id "70200000-0000-4000-8000-000000000041" {
|
||||
layer Oa;
|
||||
projection { Scenario { scenario @incident_response; }; };
|
||||
layout { Scenario {
|
||||
lanes [{ lifeline @reporter; x -500; }, { lifeline @assessor; x 50000; }];
|
||||
messages [
|
||||
{ address { references []; occurrence @initial_report; }; send_y 160; },
|
||||
{ address { references []; occurrence @updated_report; }; send_y 252; },
|
||||
{ address { references []; occurrence @assess_call; }; send_y 344; },
|
||||
{ address { references []; occurrence @assess_reply; }; send_y 436; }
|
||||
];
|
||||
}; };
|
||||
}
|
||||
diagram incident_repair "C - Incident review needs layout repair" id "70200000-0000-4000-8000-000000000042" {
|
||||
layer Oa;
|
||||
projection { Scenario { scenario @incident_response; }; };
|
||||
layout { Scenario {
|
||||
// These valid authored lanes must remain saved during complete fallback.
|
||||
lanes [{ lifeline @reporter; x 900; }, { lifeline @assessor; x -500; }];
|
||||
messages [
|
||||
// REPAIR: change only this 300 to 424, then Undo and Redo the edit.
|
||||
{ address { references []; occurrence @assess_reply; }; send_y 300; },
|
||||
// This authored value stays latent while the receiver is missing.
|
||||
{ address { references []; occurrence @unfinished_notice; }; send_y 2000; }
|
||||
];
|
||||
}; };
|
||||
}
|
||||
}
|
||||
@@ -1,356 +0,0 @@
|
||||
//! Actual App chooser/explicit-reveal checks for saved Scenario identity and source history.
|
||||
use super::*;
|
||||
use crate::behavior::scenario::{CanvasEvent, State};
|
||||
use crate::behavior::scenario_layout::{
|
||||
Representation,
|
||||
presentation::{self, PresentationStatus},
|
||||
};
|
||||
use iced::{Size, Vector};
|
||||
|
||||
fn fixture() -> App {
|
||||
let p = language::parse(include_str!("saved_scenario.arcadia")).unwrap();
|
||||
let errors: Vec<_> = oa_core::validation::validate(&p)
|
||||
.into_iter()
|
||||
.filter(|d| d.severity == oa_core::validation::Severity::Error)
|
||||
.collect();
|
||||
assert!(errors.is_empty(), "{errors:?}");
|
||||
App::with_project(p)
|
||||
}
|
||||
fn id(app: &App, alias: &str) -> Id {
|
||||
app.project()
|
||||
.identities()
|
||||
.into_iter()
|
||||
.find(|(_, a)| *a == alias)
|
||||
.unwrap()
|
||||
.0
|
||||
}
|
||||
fn representation(view: Id) -> Representation {
|
||||
Representation::Saved { diagram: view }
|
||||
}
|
||||
fn find(app: &App, subject: Id, view: Id) -> Usage {
|
||||
usage::saved_usages_for(app.project(), subject, Some(view))
|
||||
.into_iter()
|
||||
.find(|row| row.target == Target::ScenarioObject(subject))
|
||||
.expect("exact saved occurrence")
|
||||
}
|
||||
fn context(app: &App) -> behavior::scenario::Context {
|
||||
let owner = app.behavior.active_owner.unwrap();
|
||||
app.behavior
|
||||
.scenario
|
||||
.context(
|
||||
app.canvas_document,
|
||||
owner,
|
||||
app.document.revision(),
|
||||
app.canvas_epoch,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
fn navigate(app: &mut App, subject: Id, view: Id) -> Id {
|
||||
let (token, rows) = open(app, subject);
|
||||
let found = rows
|
||||
.into_iter()
|
||||
.find(|u| u.surface == Surface::Saved(view) && u.target == Target::ScenarioObject(subject))
|
||||
.expect("saved chooser destination");
|
||||
choose(app, token, found);
|
||||
assert!(app.diagram_usages.is_none());
|
||||
assert_eq!(app.page, Page::Behavior);
|
||||
assert_eq!(app.active_diagram, Some(view));
|
||||
assert_eq!(
|
||||
app.behavior.scenario.representation(),
|
||||
Some(representation(view))
|
||||
);
|
||||
assert_eq!(app.selected, subject);
|
||||
token
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_queries_use_each_actual_presentation_and_keep_repeated_appearances_and_references_opaque()
|
||||
{
|
||||
let app = fixture();
|
||||
let owner = id(&app, "incident_response");
|
||||
let first = id(&app, "initial_report");
|
||||
let second = id(&app, "updated_report");
|
||||
let exchange = id(&app, "incident_report");
|
||||
let latent = id(&app, "unfinished_notice");
|
||||
let callee_message = id(&app, "review_delivery");
|
||||
let views = ["incident_readable", "incident_wide", "incident_repair"].map(|a| id(&app, a));
|
||||
let derived = app
|
||||
.behavior
|
||||
.scenario
|
||||
.diagram_usages(app.project(), owner, first)
|
||||
.pop()
|
||||
.unwrap();
|
||||
let rows = views.map(|view| find(&app, first, view));
|
||||
assert_eq!(rows[0].bounds.y - derived.bounds.y, 12.);
|
||||
assert_ne!(rows[0].bounds, rows[1].bounds);
|
||||
assert_eq!(
|
||||
rows[2].bounds, derived.bounds,
|
||||
"complete fallback cannot retain the authored lane override"
|
||||
);
|
||||
for view in views {
|
||||
let represented = usage::saved_usages_for(app.project(), exchange, Some(view));
|
||||
assert_eq!(represented.len(), 2);
|
||||
assert!(
|
||||
represented
|
||||
.iter()
|
||||
.any(|u| u.target == Target::ScenarioObject(first))
|
||||
);
|
||||
assert!(
|
||||
represented
|
||||
.iter()
|
||||
.any(|u| u.target == Target::ScenarioObject(second))
|
||||
);
|
||||
assert!(
|
||||
represented
|
||||
.iter()
|
||||
.all(|u| u.surface == Surface::Saved(view))
|
||||
);
|
||||
assert!(usage::saved_usages_for(app.project(), callee_message, Some(view)).is_empty());
|
||||
assert_eq!(
|
||||
find(&app, latent, view).target,
|
||||
Target::ScenarioObject(latent)
|
||||
);
|
||||
let shown = presentation::present(app.project(), representation(view), owner);
|
||||
let whole = usage::saved_usages_for(app.project(), view, Some(view));
|
||||
assert_eq!(whole.len(), 1);
|
||||
assert_eq!(whole[0].target, Target::Whole);
|
||||
assert_eq!(Some(whole[0].bounds), shown.bounds());
|
||||
}
|
||||
let wide = presentation::present(app.project(), representation(views[1]), owner)
|
||||
.bounds()
|
||||
.unwrap();
|
||||
assert!(wide.x < -500. && wide.x + wide.width > 50000.);
|
||||
let fallback = presentation::present(app.project(), representation(views[2]), owner);
|
||||
assert!(matches!(
|
||||
fallback.status(),
|
||||
PresentationStatus::Fallback { .. }
|
||||
));
|
||||
assert_eq!(fallback.latent_messages(), &[latent]);
|
||||
assert!(!fallback.capabilities().coordinate_input);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_chooser_and_explicit_reveal_preserve_complete_source_and_actual_undo_redo() {
|
||||
for alias in ["incident_readable", "incident_wide", "incident_repair"] {
|
||||
let mut app = fixture();
|
||||
let view = id(&app, alias);
|
||||
let first = id(&app, "initial_report");
|
||||
let owner = id(&app, "incident_response");
|
||||
let original = app.document.snapshot();
|
||||
app.document
|
||||
.execute(Command::Rename(first, "Reviewed incident report".into()))
|
||||
.unwrap();
|
||||
let edited = app.document.snapshot();
|
||||
app.sync_source();
|
||||
app.refresh_draft();
|
||||
let before = authored(&app);
|
||||
navigate(&mut app, first, view);
|
||||
assert_eq!(app.behavior.active_owner, Some(owner));
|
||||
assert_eq!(authored(&app), before);
|
||||
send(&mut app, Message::RevealInDiagram(view, first));
|
||||
assert_eq!(app.active_diagram, Some(view));
|
||||
assert_eq!(authored(&app), before);
|
||||
assert!(app.document.undo());
|
||||
assert_eq!(app.project(), &original.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &original.bundle);
|
||||
assert!(app.document.redo());
|
||||
assert_eq!(app.project(), &edited.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &edited.bundle);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_reveal_and_chooser_refuse_source_property_and_behavior_drafts() {
|
||||
for chooser in [false, true] {
|
||||
for block in 0..3 {
|
||||
let mut app = fixture();
|
||||
let view = id(&app, "incident_repair");
|
||||
let first = id(&app, "initial_report");
|
||||
let choice = chooser.then(|| {
|
||||
let (token, rows) = open(&mut app, first);
|
||||
(
|
||||
token,
|
||||
rows.into_iter()
|
||||
.find(|u| u.surface == Surface::Saved(view))
|
||||
.unwrap(),
|
||||
)
|
||||
});
|
||||
match block {
|
||||
0 => {
|
||||
let cursor = app.source_editor.cursor();
|
||||
app.source_workspace
|
||||
.edit("? preserve unfinished source".into(), cursor);
|
||||
}
|
||||
1 => {
|
||||
app.draft
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.input("/name", "Unapplied inspector value".into());
|
||||
assert!(app.draft_dirty());
|
||||
}
|
||||
_ => {
|
||||
let p = app.project().clone();
|
||||
app.behavior
|
||||
.creation
|
||||
.update(&p, behavior::creation::Event::Open(Layer::Oa))
|
||||
.unwrap();
|
||||
assert!(app.behavior.has_pending());
|
||||
}
|
||||
}
|
||||
let before = authored(&app);
|
||||
let prior = location(&app);
|
||||
let draft = app
|
||||
.draft
|
||||
.as_ref()
|
||||
.map(|d| serde_json::to_value(d.recovery_snapshot()).unwrap());
|
||||
if let Some((token, row)) = choice {
|
||||
choose(&mut app, token, row);
|
||||
} else {
|
||||
send(&mut app, Message::RevealInDiagram(view, first));
|
||||
}
|
||||
assert_eq!(authored(&app), before);
|
||||
assert_eq!(location(&app), prior);
|
||||
assert_eq!(
|
||||
app.draft
|
||||
.as_ref()
|
||||
.map(|d| serde_json::to_value(d.recovery_snapshot()).unwrap()),
|
||||
draft
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_a_b_a_rejects_old_visit_and_panel_callbacks_before_deferred_measurement() {
|
||||
let mut app = fixture();
|
||||
let a = id(&app, "incident_readable");
|
||||
let b = id(&app, "incident_wide");
|
||||
let first = id(&app, "initial_report");
|
||||
let before = authored(&app);
|
||||
let old_token = navigate(&mut app, first, a);
|
||||
let old = context(&app);
|
||||
navigate(&mut app, first, b);
|
||||
navigate(&mut app, first, a);
|
||||
let current = context(&app);
|
||||
assert_ne!(old.visit, current.visit);
|
||||
let held = behavior::scenario::Context {
|
||||
epoch: current.epoch,
|
||||
..old
|
||||
};
|
||||
let camera = app.behavior.scenario.camera_boundary();
|
||||
send(
|
||||
&mut app,
|
||||
Message::ScenarioCanvas(CanvasEvent::Viewport(held, Size::new(900., 900.))),
|
||||
);
|
||||
send(&mut app, Message::ScenarioCanvas(CanvasEvent::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);
|
||||
}
|
||||
@@ -219,7 +219,7 @@ impl App {
|
||||
|| session.connection.base() != request.connection.base()
|
||||
})
|
||||
{
|
||||
self.source_workspace.reset_navigation_session();
|
||||
self.reset_source_navigation_session();
|
||||
}
|
||||
self.hosted.session = Some(Session {
|
||||
connection: request.connection.clone(),
|
||||
@@ -359,7 +359,7 @@ impl App {
|
||||
|| session.connection.base() != connection.base()
|
||||
})
|
||||
{
|
||||
self.source_workspace.reset_navigation_session();
|
||||
self.reset_source_navigation_session();
|
||||
}
|
||||
self.hosted.session = Some(Session {
|
||||
connection,
|
||||
|
||||
@@ -0,0 +1,476 @@
|
||||
//! Exact saved-view selection and atomic group commands.
|
||||
use super::*;
|
||||
use crate::diagram_selection::{Context, Scope, Selection, Snapshot};
|
||||
use crate::diagram_usage::{Surface, Target};
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub(crate) enum Choice {
|
||||
Replace,
|
||||
Add,
|
||||
Remove,
|
||||
Toggle,
|
||||
Inspect,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) enum Event {
|
||||
Choose(Context, Target, Choice),
|
||||
Collapse(Snapshot, Target),
|
||||
Clear(Context),
|
||||
Move(Snapshot, Position),
|
||||
Hide(Snapshot),
|
||||
ProcessReorder(
|
||||
Snapshot,
|
||||
crate::behavior::topology::group_reorder::Destination,
|
||||
),
|
||||
Refuse(Snapshot, String),
|
||||
}
|
||||
|
||||
pub(crate) fn context(c: crate::route_edit::Context) -> Context {
|
||||
Context {
|
||||
scope: Scope {
|
||||
document: c.document,
|
||||
surface: Surface::Saved(c.diagram),
|
||||
},
|
||||
revision: c.revision,
|
||||
epoch: c.epoch,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn process_context(c: crate::behavior::topology::Context) -> Context {
|
||||
Context {
|
||||
scope: Scope {
|
||||
document: c.document,
|
||||
surface: Surface::Process(c.owner),
|
||||
},
|
||||
revision: c.revision,
|
||||
epoch: c.epoch,
|
||||
}
|
||||
}
|
||||
|
||||
/// The entire typed set must support a placement action. Never filter a mixture.
|
||||
pub(crate) fn placements(snapshot: &Snapshot) -> Option<std::collections::BTreeSet<Id>> {
|
||||
snapshot.primary()?;
|
||||
if snapshot.targets().is_empty() {
|
||||
return None;
|
||||
}
|
||||
snapshot
|
||||
.targets()
|
||||
.iter()
|
||||
.map(|target| match target {
|
||||
Target::Placement(id) => Some(*id),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn visible(
|
||||
project: &Project,
|
||||
diagram: Id,
|
||||
view: &projection::ProjectedView,
|
||||
target: &Target,
|
||||
) -> bool {
|
||||
match target {
|
||||
Target::Placement(id) => view.nodes.iter().any(|n| n.id == *id),
|
||||
Target::Port { port, placement } => view
|
||||
.ports
|
||||
.iter()
|
||||
.any(|p| p.element == *port && p.owner == *placement),
|
||||
Target::Edge(id) => view.edges.iter().any(|e| e.id == *id),
|
||||
Target::StateRow {
|
||||
placement,
|
||||
relation,
|
||||
} => crate::behavior::state_canvas::visible_row_target(
|
||||
project, diagram, view, *placement, *relation,
|
||||
),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
impl App {
|
||||
pub(crate) fn multiselect_boundary(&self) -> impl PartialEq + use<> {
|
||||
(
|
||||
self.canvas_document,
|
||||
self.document.revision(),
|
||||
self.page,
|
||||
self.active_diagram,
|
||||
self.selected,
|
||||
self.selected_node,
|
||||
(self.page == Page::Behavior).then(|| self.state_view_identity()),
|
||||
(self.page == Page::Behavior).then_some(self.behavior.diagram.placement),
|
||||
self.topology_context(),
|
||||
self.behavior.topology.selection_address(),
|
||||
)
|
||||
}
|
||||
pub(crate) fn multiselect_context(&self) -> Option<Context> {
|
||||
match self.page {
|
||||
Page::Diagram => self.route_context().map(context),
|
||||
Page::Behavior => {
|
||||
if let Some(current) = self.topology_context() {
|
||||
return Some(process_context(current));
|
||||
}
|
||||
let current = self.state_context()?;
|
||||
crate::behavior::state_edit::validate_context(self.project(), current, current)
|
||||
.ok()?;
|
||||
Some(context(current))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn multiselect_target_visible(&self, target: &Target) -> bool {
|
||||
if let Some(context) = self.topology_context() {
|
||||
return self
|
||||
.behavior
|
||||
.topology
|
||||
.selection_targets(self.project(), context.owner)
|
||||
.contains(target);
|
||||
}
|
||||
let Some(Context {
|
||||
scope:
|
||||
Scope {
|
||||
surface: Surface::Saved(diagram),
|
||||
..
|
||||
},
|
||||
..
|
||||
}) = self.multiselect_context()
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let view = projection::project_view_with_index(
|
||||
self.project(),
|
||||
diagram,
|
||||
self.document.view_index(),
|
||||
);
|
||||
visible(self.project(), diagram, &view, target)
|
||||
}
|
||||
|
||||
pub(crate) fn multiselect_snapshot(&self) -> Option<Snapshot> {
|
||||
self.diagram_selection
|
||||
.as_ref()?
|
||||
.snapshot(self.multiselect_context()?)
|
||||
}
|
||||
|
||||
pub(crate) fn multiselect_envelope(&self, event: &Event) -> bool {
|
||||
let Some(current) = self.multiselect_context() else {
|
||||
return false;
|
||||
};
|
||||
match event {
|
||||
Event::Choose(c, ..) | Event::Clear(c) => *c == current,
|
||||
Event::Move(s, _)
|
||||
| Event::Hide(s)
|
||||
| Event::ProcessReorder(s, _)
|
||||
| Event::Refuse(s, _)
|
||||
| Event::Collapse(s, _) => self
|
||||
.diagram_selection
|
||||
.as_ref()
|
||||
.is_some_and(|selection| selection.matches(s, current)),
|
||||
}
|
||||
}
|
||||
|
||||
fn legacy_target(&self, diagram: Id, view: &projection::ProjectedView) -> Option<Target> {
|
||||
let placement = if self.page == Page::Behavior {
|
||||
self.behavior.diagram.placement
|
||||
} else {
|
||||
self.selected_node
|
||||
};
|
||||
if let Some(id) = placement
|
||||
&& view.nodes.iter().any(|node| {
|
||||
node.id == id && (node.element == self.selected || node.id == self.selected)
|
||||
})
|
||||
{
|
||||
return Some(Target::Placement(id));
|
||||
}
|
||||
if self.page == Page::Behavior {
|
||||
// Keep a row's exact occurrence while inspecting its relation.
|
||||
// Its owner placement is never a node-inspection or Hide target.
|
||||
if let Some(target @ Target::StateRow { relation, .. }) =
|
||||
self.diagram_selection.as_ref().and_then(|s| s.primary())
|
||||
&& *relation == self.selected
|
||||
&& visible(self.project(), diagram, view, target)
|
||||
{
|
||||
return Some(target.clone());
|
||||
}
|
||||
if let Some(node) = view.nodes.iter().find(|node| {
|
||||
crate::behavior::state_canvas::visible_row_target(
|
||||
self.project(),
|
||||
diagram,
|
||||
view,
|
||||
node.id,
|
||||
self.selected,
|
||||
)
|
||||
}) {
|
||||
return Some(Target::StateRow {
|
||||
placement: node.id,
|
||||
relation: self.selected,
|
||||
});
|
||||
}
|
||||
}
|
||||
if let Some(port) = view.ports.iter().find(|port| port.element == self.selected) {
|
||||
return Some(Target::Port {
|
||||
port: port.element,
|
||||
placement: port.owner,
|
||||
});
|
||||
}
|
||||
view.edges
|
||||
.iter()
|
||||
.any(|edge| edge.id == self.selected)
|
||||
.then_some(Target::Edge(self.selected))
|
||||
}
|
||||
|
||||
pub(crate) fn reconcile_multiselect(&mut self) {
|
||||
if self.topology_context().is_some() {
|
||||
self.reconcile_process_multiselect();
|
||||
return;
|
||||
}
|
||||
let Some(current) = self.multiselect_context() else {
|
||||
self.diagram_selection = None;
|
||||
return;
|
||||
};
|
||||
let Surface::Saved(diagram) = current.scope.surface else {
|
||||
return;
|
||||
};
|
||||
let view = projection::project_view_with_index(
|
||||
self.project(),
|
||||
diagram,
|
||||
self.document.view_index(),
|
||||
);
|
||||
let legacy = self.legacy_target(diagram, &view);
|
||||
let lost_primary = self
|
||||
.diagram_selection
|
||||
.as_ref()
|
||||
.filter(|s| s.scope() == current.scope)
|
||||
.and_then(|s| s.primary())
|
||||
.is_some_and(|target| !visible(self.project(), diagram, &view, target));
|
||||
let primary_placement = if self.page == Page::Behavior {
|
||||
self.behavior.diagram.placement
|
||||
} else {
|
||||
self.selected_node
|
||||
};
|
||||
let held_primary = self
|
||||
.diagram_selection
|
||||
.as_ref()
|
||||
.filter(|s| s.scope() == current.scope)
|
||||
.and_then(|s| s.primary())
|
||||
.is_some_and(|target| match target {
|
||||
Target::Placement(id) => primary_placement == Some(*id),
|
||||
Target::Port { port, .. } => self.selected == *port,
|
||||
Target::Edge(id) => self.selected == *id,
|
||||
Target::StateRow { relation, .. } => {
|
||||
self.selected == *relation && primary_placement.is_none()
|
||||
}
|
||||
_ => false,
|
||||
});
|
||||
let project = self.document.project();
|
||||
let selection = self
|
||||
.diagram_selection
|
||||
.get_or_insert_with(|| Selection::new(current.scope));
|
||||
selection.reconcile(current.scope, |target| {
|
||||
visible(project, diagram, &view, target)
|
||||
});
|
||||
// Navigation/property selection remains explicit. A removed exact target
|
||||
// is never rebound through its semantic identity to a different placement.
|
||||
if held_primary || lost_primary {
|
||||
if selection.primary() != legacy.as_ref() {
|
||||
self.inspect_multiselect_primary();
|
||||
}
|
||||
} else if selection.primary() != legacy.as_ref() {
|
||||
if let Some(target) = legacy {
|
||||
selection.replace(target, |target| visible(project, diagram, &view, target));
|
||||
} else {
|
||||
selection.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn inspect_multiselect_primary(&mut self) {
|
||||
if self.topology_context().is_some() {
|
||||
self.inspect_process_multiselect_primary();
|
||||
return;
|
||||
}
|
||||
let diagram = self
|
||||
.multiselect_context()
|
||||
.and_then(|c| match c.scope.surface {
|
||||
Surface::Saved(diagram) => Some(diagram),
|
||||
_ => None,
|
||||
});
|
||||
let primary = self
|
||||
.diagram_selection
|
||||
.as_ref()
|
||||
.and_then(|s| s.primary())
|
||||
.cloned();
|
||||
self.selected_node = None;
|
||||
if self.page == Page::Behavior {
|
||||
self.behavior.diagram.placement = None;
|
||||
}
|
||||
match primary {
|
||||
Some(Target::Placement(id)) => {
|
||||
if let Some(node) = diagram
|
||||
.and_then(|d| self.project().diagrams.get(&d))
|
||||
.and_then(|d| d.nodes.get(&id))
|
||||
{
|
||||
self.selected = node.element;
|
||||
if self.page == Page::Behavior {
|
||||
self.behavior.diagram.placement = Some(id);
|
||||
} else {
|
||||
self.selected_node = Some(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(Target::Port { port, .. }) => self.selected = port,
|
||||
Some(Target::Edge(id)) => self.selected = id,
|
||||
Some(Target::StateRow { relation, .. }) => self.selected = relation,
|
||||
_ => self.selected = diagram.unwrap_or(self.project().id),
|
||||
}
|
||||
self.selected_bend = None;
|
||||
self.selected_attachment = None;
|
||||
self.refresh_draft();
|
||||
}
|
||||
|
||||
pub(crate) fn multiselect_event(&mut self, event: Event) {
|
||||
if !self.multiselect_envelope(&event) {
|
||||
return;
|
||||
}
|
||||
if self.topology_context().is_some() {
|
||||
self.process_multiselect_event(event);
|
||||
return;
|
||||
}
|
||||
if !diagram_removal::enabled(self)
|
||||
|| self.architecture.is_some()
|
||||
|| self.connection.is_some()
|
||||
|| (self.page == Page::Behavior
|
||||
&& self.behavior.diagram.tool != crate::behavior::state_edit::Tool::Select)
|
||||
{
|
||||
self.status =
|
||||
"Finish or cancel the active edit before changing the diagram selection.".into();
|
||||
return;
|
||||
}
|
||||
let current = self.multiselect_context().unwrap();
|
||||
let Surface::Saved(diagram) = current.scope.surface else {
|
||||
return;
|
||||
};
|
||||
let view = projection::project_view_with_index(
|
||||
self.project(),
|
||||
diagram,
|
||||
self.document.view_index(),
|
||||
);
|
||||
match event {
|
||||
Event::ProcessReorder(..) => {}
|
||||
Event::Collapse(_, target) => {
|
||||
if !visible(self.project(), diagram, &view, &target) {
|
||||
return;
|
||||
}
|
||||
let project = self.document.project();
|
||||
self.diagram_selection
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.replace(target, |target| visible(project, diagram, &view, target));
|
||||
self.inspect_multiselect_primary();
|
||||
}
|
||||
Event::Choose(_, target, choice) => {
|
||||
if !visible(self.project(), diagram, &view, &target) {
|
||||
return;
|
||||
}
|
||||
self.reconcile_multiselect();
|
||||
let project = self.document.project();
|
||||
let selection = self.diagram_selection.as_mut().unwrap();
|
||||
let valid = |target: &Target| visible(project, diagram, &view, target);
|
||||
match choice {
|
||||
Choice::Replace => {
|
||||
selection.replace(target, valid);
|
||||
}
|
||||
Choice::Add => {
|
||||
selection.add(target, valid);
|
||||
}
|
||||
Choice::Remove => {
|
||||
selection.remove(&target);
|
||||
}
|
||||
Choice::Toggle => {
|
||||
selection.toggle(target, valid);
|
||||
}
|
||||
Choice::Inspect => {
|
||||
selection.set_primary(&target);
|
||||
}
|
||||
}
|
||||
self.inspect_multiselect_primary();
|
||||
}
|
||||
Event::Clear(_) => {
|
||||
if let Some(selection) = &mut self.diagram_selection {
|
||||
selection.clear();
|
||||
}
|
||||
self.inspect_multiselect_primary();
|
||||
}
|
||||
Event::Refuse(_, reason) => self.status = reason,
|
||||
Event::Move(snapshot, delta) => {
|
||||
let Some(ids) = placements(&snapshot) else {
|
||||
self.status = "Move requires only placed elements. Remove ports, rows and connections from the selection.".into();
|
||||
return;
|
||||
};
|
||||
if !snapshot
|
||||
.targets()
|
||||
.iter()
|
||||
.all(|target| visible(self.project(), diagram, &view, target))
|
||||
{
|
||||
return;
|
||||
}
|
||||
if self.project().diagrams[&diagram].projection == ProjectionKind::Allocation
|
||||
&& ids.iter().any(|id| {
|
||||
self.project().elements
|
||||
[&self.project().diagrams[&diagram].nodes[id].element]
|
||||
.kind()
|
||||
.is_work()
|
||||
})
|
||||
{
|
||||
self.status =
|
||||
"Group allocation drag is not available yet. The selection was retained."
|
||||
.into();
|
||||
return;
|
||||
}
|
||||
if delta.x == 0. && delta.y == 0. {
|
||||
return;
|
||||
}
|
||||
match oa_core::diagram_edit::translate_placements(
|
||||
self.project(),
|
||||
diagram,
|
||||
&ids,
|
||||
delta,
|
||||
) {
|
||||
Ok(command) => {
|
||||
if self.execute(command) {
|
||||
self.inspect_multiselect_primary();
|
||||
self.status = format!(
|
||||
"Moved {} selected elements · Undo restores the group",
|
||||
ids.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(error) => self.status = error.to_string(),
|
||||
}
|
||||
}
|
||||
Event::Hide(snapshot) => {
|
||||
let Some(ids) = placements(&snapshot) else {
|
||||
self.status =
|
||||
"Hide requires only placed elements. Model objects remain unchanged."
|
||||
.into();
|
||||
return;
|
||||
};
|
||||
if !snapshot
|
||||
.targets()
|
||||
.iter()
|
||||
.all(|target| visible(self.project(), diagram, &view, target))
|
||||
{
|
||||
return;
|
||||
}
|
||||
if self.execute(Command::Batch(
|
||||
ids.iter().copied().map(Command::Delete).collect(),
|
||||
)) {
|
||||
if let Some(selection) = &mut self.diagram_selection {
|
||||
selection.clear();
|
||||
}
|
||||
self.inspect_multiselect_primary();
|
||||
self.status = format!("Hid {} placements · Model objects retained", ids.len());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,602 @@
|
||||
//! Saved-view group operations through the real App message boundary.
|
||||
use super::multiselect::{Choice as GroupChoice, Event as GroupEvent};
|
||||
use super::*;
|
||||
use crate::diagram_usage::Target;
|
||||
|
||||
fn point(x: f32, y: f32) -> Position {
|
||||
Position { x, y }
|
||||
}
|
||||
|
||||
struct Fixture {
|
||||
app: App,
|
||||
diagram: Id,
|
||||
a: Id,
|
||||
b: Id,
|
||||
child: Id,
|
||||
edge: Id,
|
||||
}
|
||||
|
||||
fn fixture() -> Fixture {
|
||||
let mut p = Project::empty();
|
||||
let a = p
|
||||
.add_element(Kind::Function, Layer::Sa, "Send", None)
|
||||
.unwrap();
|
||||
let b = p
|
||||
.add_element(Kind::Function, Layer::Sa, "Receive", None)
|
||||
.unwrap();
|
||||
let child = p
|
||||
.add_element(Kind::Function, Layer::Sa, "Prepare", Some(a))
|
||||
.unwrap();
|
||||
let edge = p.relate(RelationKind::FunctionalExchange, "Signal", a, b);
|
||||
let diagram = p.ensure_diagram(Layer::Sa);
|
||||
p.diagrams.get_mut(&diagram).unwrap().projection = ProjectionKind::FunctionalDataflow;
|
||||
let a = p.place(diagram, a, point(10., 20.));
|
||||
let b = p.place(diagram, b, point(500., 20.));
|
||||
let child = p.place(diagram, child, point(40., 100.));
|
||||
p.diagrams
|
||||
.get_mut(&diagram)
|
||||
.unwrap()
|
||||
.routes
|
||||
.push(EdgeRoute {
|
||||
edge,
|
||||
source_attachment: Some(EdgeAttachment {
|
||||
side: PortAnchorSide::Bottom,
|
||||
offset: 0.3,
|
||||
}),
|
||||
target_attachment: Some(EdgeAttachment {
|
||||
side: PortAnchorSide::Top,
|
||||
offset: 0.7,
|
||||
}),
|
||||
waypoints: vec![point(370., 150.)],
|
||||
label: Some(EdgeLabel {
|
||||
fraction: 0.4,
|
||||
offset: point(5., -9.),
|
||||
}),
|
||||
});
|
||||
let other = p
|
||||
.add_element(Kind::Activity, Layer::Oa, "Operational work", None)
|
||||
.unwrap();
|
||||
let other_diagram = p.ensure_diagram(Layer::Oa);
|
||||
p.place(other_diagram, other, point(-50., -100.));
|
||||
let mut app = App::with_project(p);
|
||||
app.page = Page::Diagram;
|
||||
app.layer = Layer::Sa;
|
||||
app.active_diagram = Some(diagram);
|
||||
app.selected = diagram;
|
||||
app.selected_node = None;
|
||||
app.refresh_draft();
|
||||
app.reconcile_multiselect();
|
||||
Fixture {
|
||||
app,
|
||||
diagram,
|
||||
a,
|
||||
b,
|
||||
child,
|
||||
edge,
|
||||
}
|
||||
}
|
||||
|
||||
fn dispatch(app: &mut App, event: GroupEvent) {
|
||||
let _ = app.update(Message::DiagramSelection(event));
|
||||
}
|
||||
fn choose(app: &mut App, target: Target, choice: GroupChoice) {
|
||||
let context = app.multiselect_context().unwrap();
|
||||
dispatch(app, GroupEvent::Choose(context, target, choice));
|
||||
}
|
||||
fn select_pair(f: &mut Fixture) {
|
||||
choose(&mut f.app, Target::Placement(f.a), GroupChoice::Replace);
|
||||
choose(&mut f.app, Target::Placement(f.b), GroupChoice::Toggle);
|
||||
assert_eq!(
|
||||
f.app.multiselect_snapshot().unwrap().targets(),
|
||||
&[Target::Placement(f.a), Target::Placement(f.b)]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queued_singleton_move_or_delete_cannot_partially_edit_a_new_group() {
|
||||
for model in [false, true] {
|
||||
let mut f = fixture();
|
||||
select_pair(&mut f);
|
||||
let before = f.app.document.snapshot();
|
||||
let selected = f.app.multiselect_snapshot().unwrap();
|
||||
let object = f.app.project().diagrams[&f.diagram].nodes[&f.b].element;
|
||||
for message in [
|
||||
Message::MoveNode(f.b, point(700., 600.)),
|
||||
Message::DiagramRemove(super::diagram_removal::Intent {
|
||||
context: f.app.route_context().unwrap(),
|
||||
object,
|
||||
placement: Some(f.b),
|
||||
model,
|
||||
}),
|
||||
Message::Delete,
|
||||
] {
|
||||
let _ = f.app.update(message);
|
||||
assert_eq!(f.app.project(), &before.decoded.project);
|
||||
assert_eq!(f.app.document.bundle(), &before.bundle);
|
||||
assert_eq!(
|
||||
f.app.multiselect_snapshot().unwrap().targets(),
|
||||
selected.targets()
|
||||
);
|
||||
assert!(!f.app.delete_pending);
|
||||
assert!(!f.app.document.can_undo());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_hide_action_applies_to_the_complete_visible_group() {
|
||||
let mut f = fixture();
|
||||
select_pair(&mut f);
|
||||
let before = f.app.document.snapshot();
|
||||
let mut expected = f.app.project().clone();
|
||||
let view = expected.diagrams.get_mut(&f.diagram).unwrap();
|
||||
view.nodes.remove(&f.a);
|
||||
view.nodes.remove(&f.b);
|
||||
let _ = f.app.update(Message::HideSelected);
|
||||
assert_eq!(f.app.project(), &expected);
|
||||
assert_source_matches_model(&f.app);
|
||||
let _ = f.app.update(Message::Undo);
|
||||
assert_eq!(f.app.document.bundle(), &before.bundle);
|
||||
assert!(!f.app.document.can_undo());
|
||||
}
|
||||
fn assert_source_matches_model(app: &App) {
|
||||
let compiled = oa_core::source_project::compile_bundle(app.document.bundle(), LIMITS).unwrap();
|
||||
assert_eq!(&compiled.decoded.project, app.project());
|
||||
assert_eq!(&compiled.bundle, app.document.bundle());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toggled_group_moves_model_and_source_once_and_keeps_primary_through_history() {
|
||||
let mut f = fixture();
|
||||
select_pair(&mut f);
|
||||
let before = f.app.document.snapshot();
|
||||
let snapshot = f.app.multiselect_snapshot().unwrap();
|
||||
assert_eq!(snapshot.primary(), Some(&Target::Placement(f.b)));
|
||||
let mut expected = f.app.project().clone();
|
||||
let view = expected.diagrams.get_mut(&f.diagram).unwrap();
|
||||
view.nodes.get_mut(&f.a).unwrap().position = point(50., 80.);
|
||||
view.nodes.get_mut(&f.b).unwrap().position = point(540., 80.);
|
||||
view.nodes.get_mut(&f.child).unwrap().position = point(80., 160.);
|
||||
view.routes[0].waypoints = vec![point(410., 210.)];
|
||||
dispatch(
|
||||
&mut f.app,
|
||||
GroupEvent::Move(snapshot.clone(), point(40., 60.)),
|
||||
);
|
||||
assert_eq!(f.app.project(), &expected);
|
||||
assert_source_matches_model(&f.app);
|
||||
assert_eq!(f.app.selected_node, Some(f.b));
|
||||
assert_eq!(
|
||||
f.app.multiselect_snapshot().unwrap().targets(),
|
||||
snapshot.targets()
|
||||
);
|
||||
let after = f.app.document.snapshot();
|
||||
let _ = f.app.update(Message::Undo);
|
||||
assert_eq!(f.app.project(), &before.decoded.project);
|
||||
assert_eq!(f.app.document.bundle(), &before.bundle);
|
||||
assert!(!f.app.document.can_undo());
|
||||
let _ = f.app.update(Message::Redo);
|
||||
assert_eq!(f.app.project(), &expected);
|
||||
assert_eq!(f.app.document.bundle(), &after.bundle);
|
||||
assert!(!f.app.document.can_redo());
|
||||
assert_eq!(f.app.selected_node, Some(f.b));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ancestor_and_child_selection_moves_child_exactly_once() {
|
||||
let mut f = fixture();
|
||||
choose(&mut f.app, Target::Placement(f.a), GroupChoice::Replace);
|
||||
choose(&mut f.app, Target::Placement(f.child), GroupChoice::Toggle);
|
||||
let snapshot = f.app.multiselect_snapshot().unwrap();
|
||||
assert_eq!(snapshot.targets().len(), 2);
|
||||
let mut expected = f.app.project().clone();
|
||||
let view = expected.diagrams.get_mut(&f.diagram).unwrap();
|
||||
view.nodes.get_mut(&f.a).unwrap().position = point(0., 45.);
|
||||
view.nodes.get_mut(&f.child).unwrap().position = point(30., 125.);
|
||||
dispatch(&mut f.app, GroupEvent::Move(snapshot, point(-10., 25.)));
|
||||
assert_eq!(f.app.project(), &expected);
|
||||
assert_eq!(f.app.selected_node, Some(f.child));
|
||||
assert_source_matches_model(&f.app);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_edge_or_port_selection_refuses_the_entire_move() {
|
||||
for include_port in [false, true] {
|
||||
let mut f = fixture();
|
||||
choose(&mut f.app, Target::Placement(f.a), GroupChoice::Replace);
|
||||
let extra = if include_port {
|
||||
let view = projection::project_view(f.app.project(), f.diagram);
|
||||
let port = view
|
||||
.ports
|
||||
.iter()
|
||||
.find(|p| p.owner == f.a)
|
||||
.expect("functional exchange exposes its typed owner port");
|
||||
Target::Port {
|
||||
port: port.element,
|
||||
placement: port.owner,
|
||||
}
|
||||
} else {
|
||||
Target::Edge(f.edge)
|
||||
};
|
||||
choose(&mut f.app, extra.clone(), GroupChoice::Toggle);
|
||||
let selection = f.app.multiselect_snapshot().unwrap();
|
||||
assert_eq!(selection.targets(), &[Target::Placement(f.a), extra]);
|
||||
let before = f.app.document.snapshot();
|
||||
let revision = f.app.document.revision();
|
||||
dispatch(
|
||||
&mut f.app,
|
||||
GroupEvent::Move(selection.clone(), point(40., 60.)),
|
||||
);
|
||||
assert_eq!(f.app.project(), &before.decoded.project);
|
||||
assert_eq!(f.app.document.bundle(), &before.bundle);
|
||||
assert_eq!(f.app.document.revision(), revision);
|
||||
assert!(!f.app.document.can_undo());
|
||||
assert_eq!(
|
||||
f.app.multiselect_snapshot().unwrap().targets(),
|
||||
selection.targets()
|
||||
);
|
||||
assert!(f.app.status.contains("Move requires only placed elements"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_operation_envelopes_have_no_selection_history_status_or_allocation_side_effects() {
|
||||
// Isolate each lifetime component, including the same visible set recreated
|
||||
// by a remove/re-add cycle. Accepted setup changes are outside the assertion.
|
||||
for stale_by in ["epoch", "revision", "document", "selection generation"] {
|
||||
for action in ["move", "hide", "refuse", "collapse"] {
|
||||
let mut f = fixture();
|
||||
select_pair(&mut f);
|
||||
let stale = f.app.multiselect_snapshot().unwrap();
|
||||
match stale_by {
|
||||
"epoch" => f.app.canvas_epoch += 1,
|
||||
"revision" => {
|
||||
let semantic = f.app.project().diagrams[&f.diagram].nodes[&f.a].element;
|
||||
// Advance only the document revision, retaining this canvas
|
||||
// and selection epoch to independently test revision checking.
|
||||
f.app
|
||||
.document
|
||||
.execute(Command::Describe(semantic, "New revision".into()))
|
||||
.unwrap();
|
||||
}
|
||||
"document" => f.app.canvas_document = Id::new_v4(),
|
||||
_ => {
|
||||
choose(&mut f.app, Target::Placement(f.b), GroupChoice::Toggle);
|
||||
choose(&mut f.app, Target::Placement(f.b), GroupChoice::Toggle);
|
||||
assert_eq!(
|
||||
f.app.multiselect_snapshot().unwrap().targets(),
|
||||
stale.targets()
|
||||
);
|
||||
}
|
||||
}
|
||||
f.app.status = "Current feedback must survive".into();
|
||||
f.app.allocation_drag.arrange_next = true;
|
||||
let before = f.app.document.snapshot();
|
||||
let selection = f.app.multiselect_snapshot();
|
||||
let legacy = (f.app.selected, f.app.selected_node);
|
||||
let revision = f.app.document.revision();
|
||||
let epochs = (
|
||||
f.app.canvas_epoch,
|
||||
f.app.camera_epoch,
|
||||
f.app.allocation_drag.epoch,
|
||||
);
|
||||
let history = (f.app.document.can_undo(), f.app.document.can_redo());
|
||||
let draft = f
|
||||
.app
|
||||
.draft
|
||||
.as_ref()
|
||||
.map(|d| serde_json::to_value(d.recovery_snapshot()).unwrap());
|
||||
let event = match action {
|
||||
"move" => GroupEvent::Move(stale, point(99., 100.)),
|
||||
"hide" => GroupEvent::Hide(stale),
|
||||
"collapse" => GroupEvent::Collapse(stale, Target::Placement(f.a)),
|
||||
_ => GroupEvent::Refuse(stale, "Obsolete refusal".into()),
|
||||
};
|
||||
dispatch(&mut f.app, event);
|
||||
assert_eq!(
|
||||
f.app.project(),
|
||||
&before.decoded.project,
|
||||
"{stale_by}/{action}"
|
||||
);
|
||||
assert_eq!(f.app.document.bundle(), &before.bundle);
|
||||
assert_eq!(f.app.document.revision(), revision);
|
||||
assert_eq!(f.app.multiselect_snapshot(), selection);
|
||||
assert_eq!((f.app.selected, f.app.selected_node), legacy);
|
||||
assert_eq!(
|
||||
(
|
||||
f.app.canvas_epoch,
|
||||
f.app.camera_epoch,
|
||||
f.app.allocation_drag.epoch
|
||||
),
|
||||
epochs
|
||||
);
|
||||
assert_eq!(
|
||||
(f.app.document.can_undo(), f.app.document.can_redo()),
|
||||
history
|
||||
);
|
||||
assert_eq!(f.app.status, "Current feedback must survive");
|
||||
assert!(f.app.allocation_drag.arrange_next);
|
||||
assert_eq!(
|
||||
f.app
|
||||
.draft
|
||||
.as_ref()
|
||||
.map(|d| serde_json::to_value(d.recovery_snapshot()).unwrap()),
|
||||
draft
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_valid_and_invalid_property_drafts_refuse_group_edits_without_applying_them() {
|
||||
for (path, value) in [("/name", "Unapplied name"), ("/id", "-")] {
|
||||
for hide in [false, true] {
|
||||
let mut f = fixture();
|
||||
select_pair(&mut f);
|
||||
let _ = f.app.update(Message::Field(path.into(), value.into()));
|
||||
assert!(f.app.draft_dirty());
|
||||
let snapshot = f.app.multiselect_snapshot().unwrap();
|
||||
let before = f.app.document.snapshot();
|
||||
let draft =
|
||||
serde_json::to_value(f.app.draft.as_ref().unwrap().recovery_snapshot()).unwrap();
|
||||
let event = if hide {
|
||||
GroupEvent::Hide(snapshot.clone())
|
||||
} else {
|
||||
GroupEvent::Move(snapshot.clone(), point(20., 30.))
|
||||
};
|
||||
dispatch(&mut f.app, event);
|
||||
assert_eq!(f.app.project(), &before.decoded.project);
|
||||
assert_eq!(f.app.document.bundle(), &before.bundle);
|
||||
assert!(!f.app.document.can_undo());
|
||||
assert_eq!(
|
||||
f.app.multiselect_snapshot().unwrap().targets(),
|
||||
snapshot.targets()
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(f.app.draft.as_ref().unwrap().recovery_snapshot()).unwrap(),
|
||||
draft
|
||||
);
|
||||
assert!(f.app.status.contains("Finish or cancel"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hiding_a_group_removes_only_exact_placements_and_roundtrips_in_one_history_step() {
|
||||
let mut f = fixture();
|
||||
select_pair(&mut f);
|
||||
let snapshot = f.app.multiselect_snapshot().unwrap();
|
||||
let before = f.app.document.snapshot();
|
||||
let mut expected = f.app.project().clone();
|
||||
let view = expected.diagrams.get_mut(&f.diagram).unwrap();
|
||||
view.nodes.remove(&f.a);
|
||||
view.nodes.remove(&f.b);
|
||||
dispatch(&mut f.app, GroupEvent::Hide(snapshot));
|
||||
assert_eq!(
|
||||
f.app.project(),
|
||||
&expected,
|
||||
"engineering objects, descendant placement, authored route and opposite-layer view survive"
|
||||
);
|
||||
assert!(f.app.multiselect_snapshot().unwrap().targets().is_empty());
|
||||
assert_source_matches_model(&f.app);
|
||||
let after = f.app.document.snapshot();
|
||||
let _ = f.app.update(Message::Undo);
|
||||
assert_eq!(f.app.project(), &before.decoded.project);
|
||||
assert_eq!(f.app.document.bundle(), &before.bundle);
|
||||
assert!(!f.app.document.can_undo());
|
||||
let _ = f.app.update(Message::Redo);
|
||||
assert_eq!(f.app.project(), &expected);
|
||||
assert_eq!(f.app.document.bundle(), &after.bundle);
|
||||
assert!(!f.app.document.can_redo());
|
||||
}
|
||||
fn assert_surviving_primary(f: &Fixture) {
|
||||
let selection = f.app.multiselect_snapshot().unwrap();
|
||||
assert_eq!(selection.targets(), &[Target::Placement(f.a)]);
|
||||
assert_eq!(selection.primary(), Some(&Target::Placement(f.a)));
|
||||
assert_eq!(f.app.selected_node, Some(f.a));
|
||||
assert_eq!(
|
||||
f.app.selected,
|
||||
f.app.project().diagrams[&f.diagram].nodes[&f.a].element
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removing_primary_placement_from_model_preserves_exact_survivor_after_undo() {
|
||||
let mut f = fixture();
|
||||
select_pair(&mut f);
|
||||
let before = f.app.document.snapshot();
|
||||
let _ = f.app.update(Message::Command(Command::Delete(f.b)));
|
||||
assert!(
|
||||
!f.app.project().diagrams[&f.diagram]
|
||||
.nodes
|
||||
.contains_key(&f.b)
|
||||
);
|
||||
assert_surviving_primary(&f);
|
||||
let _ = f.app.update(Message::Undo);
|
||||
assert_eq!(f.app.project(), &before.decoded.project);
|
||||
assert_eq!(f.app.document.bundle(), &before.bundle);
|
||||
assert_surviving_primary(&f);
|
||||
let _ = f.app.update(Message::Redo);
|
||||
assert_surviving_primary(&f);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_primary_model_object_then_undo_does_not_restore_removed_selection_membership() {
|
||||
let mut f = fixture();
|
||||
select_pair(&mut f);
|
||||
let before = f.app.document.snapshot();
|
||||
let semantic = f.app.project().diagrams[&f.diagram].nodes[&f.b].element;
|
||||
let _ = f.app.update(Message::Command(Command::Delete(semantic)));
|
||||
assert!(!f.app.project().elements.contains_key(&semantic));
|
||||
assert_surviving_primary(&f);
|
||||
let _ = f.app.update(Message::Undo);
|
||||
assert_eq!(f.app.project(), &before.decoded.project);
|
||||
assert_eq!(f.app.document.bundle(), &before.bundle);
|
||||
assert_surviving_primary(&f);
|
||||
let _ = f.app.update(Message::Redo);
|
||||
assert_surviving_primary(&f);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replacing_primary_occurrence_with_same_semantic_placement_does_not_retarget_selection() {
|
||||
let mut f = fixture();
|
||||
select_pair(&mut f);
|
||||
let mut candidate = f.app.project().clone();
|
||||
let view = candidate.diagrams.get_mut(&f.diagram).unwrap();
|
||||
let mut replacement = view.nodes.remove(&f.b).unwrap();
|
||||
let replacement_id = Id::new_v4();
|
||||
replacement.id = replacement_id;
|
||||
replacement.alias = "replacement_receive".into();
|
||||
replacement.position = point(800., 300.);
|
||||
view.nodes.insert(replacement_id, replacement);
|
||||
// Replace preserves the semantic inspector identity. Its normal sync used
|
||||
// to rediscover the new occurrence through that identity and steal the set.
|
||||
let _ = f
|
||||
.app
|
||||
.update(Message::Command(Command::Replace(candidate.clone())));
|
||||
assert_eq!(f.app.project(), &candidate);
|
||||
assert_surviving_primary(&f);
|
||||
assert!(
|
||||
!f.app
|
||||
.multiselect_snapshot()
|
||||
.unwrap()
|
||||
.targets()
|
||||
.contains(&Target::Placement(replacement_id))
|
||||
);
|
||||
assert_source_matches_model(&f.app);
|
||||
let _ = f.app.update(Message::Undo);
|
||||
assert!(
|
||||
f.app.project().diagrams[&f.diagram]
|
||||
.nodes
|
||||
.contains_key(&f.b)
|
||||
);
|
||||
assert_surviving_primary(&f);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_surface_navigation_and_return_never_resurrect_the_group() {
|
||||
for destination in [Page::Catalog, Page::Behavior] {
|
||||
let mut f = fixture();
|
||||
select_pair(&mut f);
|
||||
let before = f.app.document.snapshot();
|
||||
let stale = f.app.multiselect_snapshot().unwrap();
|
||||
let _ = f.app.update(Message::Page(destination));
|
||||
assert!(f.app.multiselect_snapshot().is_none());
|
||||
let _ = f.app.update(Message::Page(Page::Diagram));
|
||||
let live = f.app.multiselect_snapshot().unwrap();
|
||||
assert!(
|
||||
live.targets().len() <= 1,
|
||||
"navigation may inspect one exact member but must not restore the group"
|
||||
);
|
||||
dispatch(&mut f.app, GroupEvent::Move(stale, point(400., 500.)));
|
||||
assert_eq!(f.app.project(), &before.decoded.project);
|
||||
assert_eq!(f.app.document.bundle(), &before.bundle);
|
||||
assert!(!f.app.document.can_undo());
|
||||
}
|
||||
}
|
||||
|
||||
fn open_placement_menu(f: &mut Fixture, placement: Id) {
|
||||
let semantic = f.app.project().diagrams[&f.diagram].nodes[&placement].element;
|
||||
let mut request = crate::model_menu::Request::object(semantic, iced::Point::new(100., 100.));
|
||||
request.placement = Some(placement);
|
||||
request.canvas = f.app.route_context();
|
||||
let _ = f
|
||||
.app
|
||||
.update(Message::Menu(crate::model_menu::Event::Open(request)));
|
||||
assert!(f.app.model_menu.is_some());
|
||||
}
|
||||
|
||||
fn menu_action(app: &App, caption: &str) -> Message {
|
||||
crate::model_menu::popup(app)
|
||||
.unwrap()
|
||||
.entries
|
||||
.into_iter()
|
||||
.find_map(|entry| match entry {
|
||||
crate::context_menu::Entry::Action { label, message, .. } if label == caption => {
|
||||
message
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or_else(|| panic!("enabled menu action {caption:?} must exist"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn context_menu_dismissal_preserves_group_and_add_keeps_pre_menu_members() {
|
||||
let mut f = fixture();
|
||||
select_pair(&mut f);
|
||||
let before = f.app.document.snapshot();
|
||||
let selected = f.app.multiselect_snapshot().unwrap();
|
||||
let child = f.child;
|
||||
open_placement_menu(&mut f, child);
|
||||
assert_eq!(
|
||||
f.app.multiselect_snapshot().unwrap().targets(),
|
||||
selected.targets()
|
||||
);
|
||||
assert_eq!(
|
||||
f.app.multiselect_snapshot().unwrap().primary(),
|
||||
selected.primary()
|
||||
);
|
||||
let _ = f.app.update(Message::Menu(crate::model_menu::Event::Close));
|
||||
assert_eq!(
|
||||
f.app.multiselect_snapshot().unwrap().targets(),
|
||||
selected.targets()
|
||||
);
|
||||
assert_eq!(
|
||||
f.app.multiselect_snapshot().unwrap().primary(),
|
||||
selected.primary()
|
||||
);
|
||||
open_placement_menu(&mut f, child);
|
||||
let add = menu_action(&f.app, "Add to selection");
|
||||
let _ = f.app.update(add);
|
||||
let selected = f.app.multiselect_snapshot().unwrap();
|
||||
assert_eq!(
|
||||
selected.targets(),
|
||||
&[
|
||||
Target::Placement(f.a),
|
||||
Target::Placement(f.b),
|
||||
Target::Placement(child)
|
||||
]
|
||||
);
|
||||
assert_eq!(selected.primary(), Some(&Target::Placement(child)));
|
||||
assert_eq!(f.app.project(), &before.decoded.project);
|
||||
assert_eq!(f.app.document.bundle(), &before.bundle);
|
||||
assert!(!f.app.document.can_undo());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_member_context_menu_exposes_and_executes_group_hide_not_singleton_deletion() {
|
||||
let mut f = fixture();
|
||||
select_pair(&mut f);
|
||||
let before = f.app.document.snapshot();
|
||||
let member = f.a;
|
||||
open_placement_menu(&mut f, member);
|
||||
let popup = crate::model_menu::popup(&f.app).unwrap();
|
||||
for entry in &popup.entries {
|
||||
if let crate::context_menu::Entry::Action {
|
||||
message: Some(Message::Menu(crate::model_menu::Event::Invoke(_, action))),
|
||||
..
|
||||
} = entry
|
||||
{
|
||||
assert!(
|
||||
!matches!(
|
||||
action.as_ref(),
|
||||
Message::HideSelected | Message::Delete | Message::DiagramRemove(_)
|
||||
),
|
||||
"a selected-member group menu must not expose singleton removal"
|
||||
);
|
||||
}
|
||||
}
|
||||
let hide = menu_action(&f.app, "Hide selected placements");
|
||||
let _ = f.app.update(hide);
|
||||
let mut expected = before.decoded.project.clone();
|
||||
let view = expected.diagrams.get_mut(&f.diagram).unwrap();
|
||||
view.nodes.remove(&f.a);
|
||||
view.nodes.remove(&f.b);
|
||||
assert_eq!(f.app.project(), &expected);
|
||||
assert!(!f.app.delete_pending);
|
||||
assert!(f.app.model_menu.is_none());
|
||||
assert_source_matches_model(&f.app);
|
||||
let _ = f.app.update(Message::Undo);
|
||||
assert_eq!(f.app.project(), &before.decoded.project);
|
||||
assert_eq!(f.app.document.bundle(), &before.bundle);
|
||||
assert!(!f.app.document.can_undo());
|
||||
}
|
||||
@@ -65,7 +65,6 @@ impl App {
|
||||
owner: context.owner,
|
||||
revision: context.revision,
|
||||
epoch: lifetime,
|
||||
scenario_view: None,
|
||||
};
|
||||
if self.behavior.has_pending()
|
||||
|| self.draft_dirty()
|
||||
@@ -75,11 +74,7 @@ impl App {
|
||||
return Task::none();
|
||||
}
|
||||
match input {
|
||||
Event::Focus
|
||||
| Event::Traverse(_)
|
||||
| Event::ControlFocus(_)
|
||||
| Event::ToggleFocus
|
||||
| Event::Leave => {
|
||||
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),
|
||||
@@ -98,6 +93,26 @@ impl App {
|
||||
.topology
|
||||
.enter(context.document, context.owner);
|
||||
match event {
|
||||
CanvasEvent::Remove(_, id) => {
|
||||
if diagram_removal::enabled(self)
|
||||
&& self.selected == id
|
||||
&& self.behavior.topology.selecting()
|
||||
{
|
||||
if self
|
||||
.behavior
|
||||
.topology
|
||||
.has_qualified_selection(self.project(), context.owner)
|
||||
{
|
||||
self.status = "Open the referenced definition to delete its contents, or select the outer reference to remove that use.".into();
|
||||
} else if self.behavior.topology.removal_target(
|
||||
self.project(),
|
||||
context.owner,
|
||||
id,
|
||||
) {
|
||||
self.begin_model_delete(None);
|
||||
}
|
||||
}
|
||||
}
|
||||
CanvasEvent::ExpandQualified(_, endpoint) => {
|
||||
let mut state = std::mem::take(&mut self.behavior.topology);
|
||||
let changed = state.expand_context(self.project(), context.owner, endpoint);
|
||||
@@ -113,6 +128,12 @@ impl App {
|
||||
let resolved = oa_core::topology_context::TopologyIndex::new(self.project())
|
||||
.resolve(context.owner, &endpoint);
|
||||
if let Ok(resolved) = resolved {
|
||||
// The definition may be opened from a nonprimary context
|
||||
// target. Remember that exact invocation, not whichever
|
||||
// qualified occurrence happened to be primary beforehand.
|
||||
self.behavior
|
||||
.topology
|
||||
.select_context(resolved.address.clone());
|
||||
self.behavior.topology.remember(
|
||||
context.owner,
|
||||
endpoint
|
||||
@@ -122,31 +143,18 @@ impl App {
|
||||
.unwrap_or(endpoint.occurrence),
|
||||
resolved.defining_sequence,
|
||||
);
|
||||
self.behavior.topology.clear_context();
|
||||
self.behavior.active_owner = Some(resolved.defining_sequence);
|
||||
self.select(endpoint.occurrence);
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
}
|
||||
}
|
||||
CanvasEvent::SelectQualified(_, endpoint) => {
|
||||
if !self.behavior.has_pending()
|
||||
&& !self.draft_dirty()
|
||||
&& !self.source_dirty()
|
||||
&& let Ok(resolved) =
|
||||
oa_core::topology_context::TopologyIndex::new(self.project())
|
||||
.resolve(context.owner, &endpoint)
|
||||
{
|
||||
let reference = endpoint
|
||||
.references
|
||||
.first()
|
||||
.copied()
|
||||
.unwrap_or(endpoint.occurrence);
|
||||
self.select(reference);
|
||||
self.behavior.topology.select_context(resolved.address);
|
||||
}
|
||||
}
|
||||
CanvasEvent::Focus(_) => {
|
||||
self.behavior.topology.focused = !self.behavior.topology.focused;
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
self.multiselect_event(multiselect::Event::Choose(
|
||||
multiselect::process_context(context),
|
||||
crate::diagram_usage::Target::ProcessOccurrence(endpoint),
|
||||
multiselect::Choice::Replace,
|
||||
));
|
||||
}
|
||||
CanvasEvent::Select(_, id) => {
|
||||
if !self.behavior.has_pending()
|
||||
@@ -187,6 +195,9 @@ impl App {
|
||||
if let Some(target) =
|
||||
topology::referenced(self.project(), context.owner, occurrence)
|
||||
{
|
||||
// Opening a local card returns to that local use, even if
|
||||
// a qualified tile was the previous inspection target.
|
||||
self.behavior.topology.clear_context();
|
||||
if self
|
||||
.project()
|
||||
.elements
|
||||
@@ -211,8 +222,20 @@ impl App {
|
||||
self.behavior.topology = state;
|
||||
if let Some((owner, occurrence)) = back {
|
||||
self.behavior.active_owner = Some(owner);
|
||||
self.select(occurrence);
|
||||
// Back restores a use, whereas ordinary select navigates
|
||||
// to a defining object. The validated return address owns
|
||||
// the exact subject; never infer it from the outer card ID.
|
||||
self.selected = self
|
||||
.behavior
|
||||
.topology
|
||||
.selection_address()
|
||||
.filter(|address| address.owner == owner)
|
||||
.map_or(occurrence, |address| address.endpoint.occurrence);
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
// Synchronize the exact primary and inspector before the
|
||||
// returned caller is drawn or accepts property edits.
|
||||
self.reconcile_process_multiselect();
|
||||
self.inspect_process_multiselect_primary();
|
||||
}
|
||||
}
|
||||
mutation => {
|
||||
@@ -282,8 +305,9 @@ impl App {
|
||||
occurrence, before, ..
|
||||
} => {
|
||||
match topology::reorder(self.project(), context.owner, occurrence, before) {
|
||||
Ok(command) => {
|
||||
if self.execute(command) {
|
||||
Ok(topology::group_reorder::Plan::Noop) => {}
|
||||
Ok(topology::group_reorder::Plan::Change(command)) => {
|
||||
if self.execute(*command) {
|
||||
self.selected = occurrence;
|
||||
self.refresh_draft();
|
||||
return self.reveal_process_selection();
|
||||
@@ -330,7 +354,7 @@ mod tests {
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn process_focus_retains_rejected_draft_and_guards_selection_until_panels_restore() {
|
||||
fn process_zoom_retains_rejected_draft_and_guards_selection() {
|
||||
let (mut app, f) = setup();
|
||||
let original = app.document.snapshot();
|
||||
let initial = app.selected;
|
||||
@@ -340,9 +364,14 @@ mod tests {
|
||||
&mut app,
|
||||
Event::Name(" unfinished contextual work ".into()),
|
||||
);
|
||||
let camera = app.behavior.topology.camera_boundary();
|
||||
let context = app.topology_context().unwrap();
|
||||
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Focus(context)));
|
||||
assert!(app.behavior.topology.focused);
|
||||
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Zoom(
|
||||
context,
|
||||
Some(0.75),
|
||||
)));
|
||||
assert_ne!(app.canvas_epoch, context.epoch);
|
||||
assert!(app.behavior.topology.camera_boundary() != camera);
|
||||
let raw = app.behavior.recovery_snapshot().unwrap();
|
||||
let context = app.topology_context().unwrap();
|
||||
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Select(
|
||||
@@ -354,8 +383,8 @@ mod tests {
|
||||
assert_eq!(app.behavior.recovery_snapshot().unwrap(), raw);
|
||||
assert!(Arc::ptr_eq(&original, &app.document.snapshot()));
|
||||
let context = app.topology_context().unwrap();
|
||||
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Focus(context)));
|
||||
assert!(!app.behavior.topology.focused);
|
||||
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Zoom(context, None)));
|
||||
assert_ne!(app.canvas_epoch, context.epoch);
|
||||
assert_eq!(app.behavior.recovery_snapshot().unwrap(), raw);
|
||||
}
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
//! Exact Process presentation selection and one atomic order transaction.
|
||||
use super::*;
|
||||
use crate::behavior::topology::group_reorder::{Plan, Prepared};
|
||||
use crate::diagram_selection::Selection;
|
||||
use crate::diagram_usage::{Surface, Target};
|
||||
use multiselect::{Choice, Event};
|
||||
|
||||
fn subject(target: &Target) -> Option<Id> {
|
||||
match target {
|
||||
Target::ProcessOccurrence(endpoint) => Some(endpoint.occurrence),
|
||||
Target::ProcessLink(id) | Target::ProcessPreviewLink { relation: id, .. } => Some(*id),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
impl App {
|
||||
pub(crate) fn process_preview_primary(&self) -> Option<&Target> {
|
||||
let context = self.topology_context()?;
|
||||
let selection = self.diagram_selection.as_ref()?;
|
||||
if selection.scope() != multiselect::process_context(context).scope {
|
||||
return None;
|
||||
}
|
||||
selection
|
||||
.primary()
|
||||
.filter(|target| subject(target) == Some(self.selected))
|
||||
.filter(|target| match target {
|
||||
Target::ProcessOccurrence(endpoint) => !endpoint.references.is_empty(),
|
||||
Target::ProcessPreviewLink { .. } => true,
|
||||
_ => false,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn reconcile_process_multiselect(&mut self) {
|
||||
let Some(context) = self.topology_context() else {
|
||||
return;
|
||||
};
|
||||
let current = multiselect::process_context(context);
|
||||
let visible = self
|
||||
.behavior
|
||||
.topology
|
||||
.selection_targets(self.project(), context.owner);
|
||||
let old = self
|
||||
.diagram_selection
|
||||
.as_ref()
|
||||
.filter(|s| s.scope() == current.scope)
|
||||
.and_then(Selection::primary)
|
||||
.cloned();
|
||||
let address = self.behavior.topology.selection_address();
|
||||
let focused = self
|
||||
.diagram_focus(Surface::Process(context.owner))
|
||||
.map(|usage| usage.target.clone());
|
||||
let legacy = focused
|
||||
.clone()
|
||||
.or_else(|| {
|
||||
address
|
||||
.as_ref()
|
||||
.filter(|a| a.owner == context.owner && a.endpoint.occurrence == self.selected)
|
||||
.map(|a| Target::ProcessOccurrence(a.endpoint.clone()))
|
||||
})
|
||||
.or_else(|| {
|
||||
visible
|
||||
.iter()
|
||||
.find(|target| match target {
|
||||
Target::ProcessOccurrence(endpoint) => {
|
||||
endpoint.references.is_empty() && endpoint.occurrence == self.selected
|
||||
}
|
||||
Target::ProcessLink(id) => *id == self.selected,
|
||||
_ => false,
|
||||
})
|
||||
.cloned()
|
||||
})
|
||||
.filter(|target| visible.contains(target));
|
||||
let held = old.as_ref().is_some_and(|target| {
|
||||
subject(target) == Some(self.selected)
|
||||
&& focused.as_ref().is_none_or(|focused| focused == target)
|
||||
&& match target {
|
||||
Target::ProcessOccurrence(endpoint) if !endpoint.references.is_empty() => {
|
||||
address
|
||||
.as_ref()
|
||||
.is_some_and(|a| a.owner == context.owner && a.endpoint == *endpoint)
|
||||
}
|
||||
_ => true,
|
||||
}
|
||||
});
|
||||
let lost = old.as_ref().is_some_and(|target| !visible.contains(target));
|
||||
let selection = self
|
||||
.diagram_selection
|
||||
.get_or_insert_with(|| Selection::new(current.scope));
|
||||
selection.reconcile(current.scope, |target| visible.contains(target));
|
||||
if held || lost {
|
||||
if selection.primary() != old.as_ref() {
|
||||
self.inspect_process_multiselect_primary();
|
||||
}
|
||||
} else if selection.primary() != legacy.as_ref() {
|
||||
if let Some(target) = legacy {
|
||||
selection.replace(target, |t| visible.contains(t));
|
||||
} else {
|
||||
selection.clear();
|
||||
}
|
||||
}
|
||||
if self.process_preview_primary().is_some() {
|
||||
self.draft = None;
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn inspect_process_multiselect_primary(&mut self) {
|
||||
let Some(context) = self.topology_context() else {
|
||||
return;
|
||||
};
|
||||
let primary = self
|
||||
.diagram_selection
|
||||
.as_ref()
|
||||
.and_then(Selection::primary)
|
||||
.cloned();
|
||||
self.behavior.topology.clear_context();
|
||||
self.selected = primary.as_ref().and_then(subject).unwrap_or(context.owner);
|
||||
self.selected_node = None;
|
||||
self.selected_bend = None;
|
||||
self.selected_attachment = None;
|
||||
self.diagram_navigation_focus = None;
|
||||
if let Some(Target::ProcessOccurrence(endpoint)) = primary
|
||||
&& !endpoint.references.is_empty()
|
||||
&& let Ok(resolved) = oa_core::topology_context::TopologyIndex::new(self.project())
|
||||
.resolve(context.owner, &endpoint)
|
||||
{
|
||||
self.behavior.topology.select_context(resolved.address);
|
||||
}
|
||||
self.refresh_draft();
|
||||
if self.process_preview_primary().is_some() {
|
||||
self.draft = None;
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn process_multiselect_event(&mut self, event: Event) {
|
||||
let Some(context) = self.topology_context() else {
|
||||
return;
|
||||
};
|
||||
if let Event::Refuse(_, reason) = &event
|
||||
&& diagram_removal::enabled(self)
|
||||
{
|
||||
self.status = reason.clone();
|
||||
return;
|
||||
}
|
||||
if !diagram_removal::enabled(self) || !self.behavior.topology.selecting() {
|
||||
self.status =
|
||||
"Finish or cancel the active edit before changing the process selection.".into();
|
||||
return;
|
||||
}
|
||||
let visible = self
|
||||
.behavior
|
||||
.topology
|
||||
.selection_targets(self.project(), context.owner);
|
||||
match event {
|
||||
Event::Choose(_, target, choice) => {
|
||||
if !visible.contains(&target) {
|
||||
return;
|
||||
}
|
||||
self.reconcile_process_multiselect();
|
||||
let selection = self.diagram_selection.as_mut().unwrap();
|
||||
let valid = |t: &Target| visible.contains(t);
|
||||
match choice {
|
||||
Choice::Replace => {
|
||||
selection.replace(target, valid);
|
||||
}
|
||||
Choice::Add => {
|
||||
selection.add(target, valid);
|
||||
}
|
||||
Choice::Remove => {
|
||||
selection.remove(&target);
|
||||
}
|
||||
Choice::Toggle => {
|
||||
selection.toggle(target, valid);
|
||||
}
|
||||
Choice::Inspect => {
|
||||
selection.set_primary(&target);
|
||||
}
|
||||
}
|
||||
self.inspect_process_multiselect_primary();
|
||||
}
|
||||
Event::Collapse(_, target) => {
|
||||
if !visible.contains(&target) {
|
||||
return;
|
||||
}
|
||||
self.diagram_selection
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.replace(target, |t| visible.contains(t));
|
||||
self.inspect_process_multiselect_primary();
|
||||
}
|
||||
Event::Clear(_) => {
|
||||
self.diagram_selection.as_mut().unwrap().clear();
|
||||
self.inspect_process_multiselect_primary();
|
||||
}
|
||||
Event::Refuse(_, reason) => self.status = reason,
|
||||
Event::Hide(_) => {
|
||||
self.status =
|
||||
"Process cards are model contributions, not placements. Hide is unavailable."
|
||||
.into()
|
||||
}
|
||||
Event::Move(..) => {
|
||||
self.status =
|
||||
"Process positions come from their paths. Reorder within one column instead."
|
||||
.into()
|
||||
}
|
||||
Event::ProcessReorder(snapshot, destination) => {
|
||||
if !snapshot
|
||||
.targets()
|
||||
.iter()
|
||||
.all(|target| visible.contains(target))
|
||||
{
|
||||
return;
|
||||
}
|
||||
match Prepared::new(self.project(), context.owner, snapshot.targets())
|
||||
.and_then(|prepared| prepared.plan(destination))
|
||||
{
|
||||
Ok(Plan::Noop) => {}
|
||||
Ok(Plan::Change(command)) => {
|
||||
if self.execute(*command) {
|
||||
self.inspect_process_multiselect_primary();
|
||||
self.status = format!(
|
||||
"Reordered {} contributions · Undo restores their order",
|
||||
snapshot.targets().len()
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(error) => self.status = error,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A no-op must not retire a pending reveal or allocate a history entry.
|
||||
pub(super) fn process_order_noop(&self, message: &Message) -> bool {
|
||||
let Some(context) = self.topology_context() else {
|
||||
return false;
|
||||
};
|
||||
match message {
|
||||
Message::DiagramSelection(Event::ProcessReorder(snapshot, destination)) => {
|
||||
Prepared::new(self.project(), context.owner, snapshot.targets())
|
||||
.and_then(|prepared| prepared.plan(*destination))
|
||||
.is_ok_and(|p| matches!(p, Plan::Noop))
|
||||
}
|
||||
Message::TopologyCanvas(crate::behavior::topology::CanvasEvent::Reorder {
|
||||
context: captured,
|
||||
occurrence,
|
||||
before,
|
||||
}) if *captured == context => crate::behavior::topology::reorder(
|
||||
self.project(),
|
||||
context.owner,
|
||||
*occurrence,
|
||||
*before,
|
||||
)
|
||||
.is_ok_and(|p| matches!(p, Plan::Noop)),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,979 @@
|
||||
//! Process selection through the real App admission, document and source boundary.
|
||||
use super::*;
|
||||
use crate::behavior::topology::group_reorder::Destination;
|
||||
use crate::behavior::topology::{self, CanvasEvent, CanvasTool};
|
||||
use oa_core::OccurrenceEndpoint;
|
||||
|
||||
struct Fixture {
|
||||
app: App,
|
||||
owner: Id,
|
||||
nodes: [Id; 5],
|
||||
link: Id,
|
||||
work: Id,
|
||||
other_owner: Id,
|
||||
}
|
||||
|
||||
fn local(id: Id) -> Target {
|
||||
Target::ProcessOccurrence(OccurrenceEndpoint {
|
||||
references: vec![],
|
||||
occurrence: id,
|
||||
})
|
||||
}
|
||||
|
||||
fn add(project: &mut Project, owner: Id, name: &str, body: OccurrenceBody) -> Id {
|
||||
let mut element = project
|
||||
.make_element(
|
||||
Kind::Occurrence,
|
||||
project.elements[&owner].layer(),
|
||||
name,
|
||||
Some(owner),
|
||||
)
|
||||
.unwrap();
|
||||
let Some(SharedBody::Occurrence { body: value, .. }) = element.shared_mut() else {
|
||||
unreachable!()
|
||||
};
|
||||
*value = body;
|
||||
let id = element.id;
|
||||
*project = execute_batch(project, &[Command::CreateElement(element)]).unwrap();
|
||||
id
|
||||
}
|
||||
|
||||
fn open(project: Project, owner: Id) -> App {
|
||||
let mut app = App::with_project(project);
|
||||
let _ = app.update(Message::Select(owner));
|
||||
let _ = app.update(Message::Page(Page::Behavior));
|
||||
app.reconcile_multiselect();
|
||||
assert_eq!(app.topology_context().unwrap().owner, owner);
|
||||
assert_eq!(
|
||||
app.multiselect_context().unwrap().scope.surface,
|
||||
Surface::Process(owner)
|
||||
);
|
||||
app
|
||||
}
|
||||
|
||||
fn fixture(layer: Layer) -> Fixture {
|
||||
let (f, _) = topology::direct_tests::empty_exchange_fixture(layer);
|
||||
let mut project = f.p;
|
||||
let mut nodes = vec![];
|
||||
for name in ["a", "u", "b", "v", "c"] {
|
||||
nodes.push(add(
|
||||
&mut project,
|
||||
f.owner,
|
||||
name,
|
||||
OccurrenceBody::Action {
|
||||
action: Some(f.work[0]),
|
||||
lifeline: None,
|
||||
},
|
||||
));
|
||||
}
|
||||
let nodes: [Id; 5] = nodes.try_into().unwrap();
|
||||
let [a, u, b, v, _] = nodes;
|
||||
let link = project.relate(RelationKind::SequenceLink, "a to u", a, u);
|
||||
project.relate(RelationKind::SequenceLink, "b to v", b, v);
|
||||
let other = project
|
||||
.make_element(
|
||||
if layer == Layer::Oa {
|
||||
Kind::Process
|
||||
} else {
|
||||
Kind::FunctionalChain
|
||||
},
|
||||
layer,
|
||||
"Other owner",
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let other_owner = other.id;
|
||||
project = execute_batch(&project, &[Command::CreateElement(other)]).unwrap();
|
||||
validate_candidate(&project).unwrap();
|
||||
Fixture {
|
||||
app: open(project, f.owner),
|
||||
owner: f.owner,
|
||||
nodes,
|
||||
link,
|
||||
work: f.work[0],
|
||||
other_owner,
|
||||
}
|
||||
}
|
||||
|
||||
fn send(app: &mut App, event: Event) {
|
||||
let _ = app.update(Message::DiagramSelection(event));
|
||||
}
|
||||
fn choose(app: &mut App, target: Target, choice: Choice) {
|
||||
let context = app.multiselect_context().unwrap();
|
||||
send(app, Event::Choose(context, target, choice));
|
||||
}
|
||||
fn pair(f: &mut Fixture) {
|
||||
choose(&mut f.app, local(f.nodes[4]), Choice::Replace);
|
||||
choose(&mut f.app, local(f.nodes[0]), Choice::Add);
|
||||
assert_eq!(
|
||||
f.app.multiselect_snapshot().unwrap().targets(),
|
||||
&[local(f.nodes[4]), local(f.nodes[0])]
|
||||
);
|
||||
}
|
||||
fn source_matches(app: &App) {
|
||||
let compiled = source_project::compile_bundle(app.document.bundle(), LIMITS).unwrap();
|
||||
assert_eq!(&compiled.decoded.project, app.project());
|
||||
assert_eq!(&compiled.bundle, app.document.bundle());
|
||||
assert_eq!(
|
||||
source_workspace::reachable(&app.source_workspace.bundle()).unwrap(),
|
||||
*app.document.bundle()
|
||||
);
|
||||
}
|
||||
fn marker(app: &mut App, owner: Id, occurrence: Id) {
|
||||
use crate::diagram_usage as usage;
|
||||
let target = usage::collect(
|
||||
app.project(),
|
||||
occurrence,
|
||||
&app.behavior.topology,
|
||||
&app.behavior.scenario,
|
||||
)
|
||||
.into_iter()
|
||||
.find(|u| u.surface == Surface::Process(owner) && u.target == local(occurrence))
|
||||
.unwrap();
|
||||
let context = usage::Context {
|
||||
document: app.canvas_document,
|
||||
revision: app.document.revision(),
|
||||
subject: occurrence,
|
||||
};
|
||||
app.pending_diagram_reveal = Some((context, target.clone(), app.canvas_epoch));
|
||||
app.diagram_navigation_focus = Some(usage::Focus {
|
||||
context,
|
||||
usage: target,
|
||||
selected: app.selected,
|
||||
});
|
||||
assert!(app.diagram_focus(Surface::Process(owner)).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_group_reverse_click_order_changes_only_owner_steps_and_undoes_once() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let mut f = fixture(layer);
|
||||
let [a, u, b, v, c] = f.nodes;
|
||||
pair(&mut f);
|
||||
let before = f.app.document.snapshot();
|
||||
let selection = f.app.multiselect_snapshot().unwrap();
|
||||
assert_eq!(selection.primary(), Some(&local(a)));
|
||||
send(
|
||||
&mut f.app,
|
||||
Event::ProcessReorder(selection.clone(), Destination::After(b)),
|
||||
);
|
||||
let mut expected = before.decoded.project.clone();
|
||||
*expected
|
||||
.elements
|
||||
.get_mut(&f.owner)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap() = vec![b, u, a, v, c];
|
||||
assert_eq!(f.app.project(), &expected);
|
||||
assert_eq!(f.app.selected, a);
|
||||
let after_selection = f.app.multiselect_snapshot().unwrap();
|
||||
assert_eq!(after_selection.targets(), selection.targets());
|
||||
assert_eq!(after_selection.primary(), selection.primary());
|
||||
source_matches(&f.app);
|
||||
let after = f.app.document.snapshot();
|
||||
let reopened = Document::from_bundle(after.bundle.clone(), LIMITS).unwrap();
|
||||
assert_eq!(reopened.project(), &expected);
|
||||
let _ = f.app.update(Message::Undo);
|
||||
assert_eq!(f.app.project(), &before.decoded.project);
|
||||
assert_eq!(f.app.document.bundle(), &before.bundle);
|
||||
assert!(!f.app.document.can_undo());
|
||||
source_matches(&f.app);
|
||||
let _ = f.app.update(Message::Redo);
|
||||
assert_eq!(f.app.project(), &expected);
|
||||
assert_eq!(f.app.document.bundle(), &after.bundle);
|
||||
assert!(!f.app.document.can_redo());
|
||||
source_matches(&f.app);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_noop_group_and_legacy_reorder_preserve_history_camera_and_pending_reveal() {
|
||||
for group in [true, false] {
|
||||
let mut f = fixture(Layer::Oa);
|
||||
if group {
|
||||
pair(&mut f);
|
||||
} else {
|
||||
choose(&mut f.app, local(f.nodes[0]), Choice::Replace);
|
||||
}
|
||||
marker(&mut f.app, f.owner, f.nodes[0]);
|
||||
let before = f.app.document.snapshot();
|
||||
let selection = f.app.multiselect_snapshot().unwrap();
|
||||
let pending = f.app.pending_diagram_reveal.clone();
|
||||
let camera = f.app.behavior.topology.camera_boundary();
|
||||
let epochs = (f.app.canvas_epoch, f.app.camera_epoch);
|
||||
f.app.status = "Unchanged order feedback".into();
|
||||
let message = if group {
|
||||
Message::DiagramSelection(Event::ProcessReorder(
|
||||
selection.clone(),
|
||||
Destination::Before(f.nodes[4]),
|
||||
))
|
||||
} else {
|
||||
Message::TopologyCanvas(CanvasEvent::Reorder {
|
||||
context: f.app.topology_context().unwrap(),
|
||||
occurrence: f.nodes[0],
|
||||
before: f.nodes[2],
|
||||
})
|
||||
};
|
||||
let _ = f.app.update(message);
|
||||
assert!(Arc::ptr_eq(&before, &f.app.document.snapshot()));
|
||||
assert_eq!(f.app.multiselect_snapshot().unwrap(), selection);
|
||||
assert_eq!(f.app.pending_diagram_reveal, pending);
|
||||
assert!(f.app.behavior.topology.camera_boundary() == camera);
|
||||
assert_eq!((f.app.canvas_epoch, f.app.camera_epoch), epochs);
|
||||
assert_eq!(f.app.status, "Unchanged order feedback");
|
||||
assert!(!f.app.document.can_undo());
|
||||
assert!(!f.app.document.can_redo());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_mixed_link_and_cross_column_groups_refuse_whole_operation() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
for mixed in [true, false] {
|
||||
let mut f = fixture(layer);
|
||||
choose(&mut f.app, local(f.nodes[0]), Choice::Replace);
|
||||
let other = if mixed {
|
||||
Target::ProcessLink(f.link)
|
||||
} else {
|
||||
local(f.nodes[1])
|
||||
};
|
||||
choose(&mut f.app, other.clone(), Choice::Add);
|
||||
let selection = f.app.multiselect_snapshot().unwrap();
|
||||
assert_eq!(selection.targets(), &[local(f.nodes[0]), other]);
|
||||
let before = f.app.document.snapshot();
|
||||
send(
|
||||
&mut f.app,
|
||||
Event::ProcessReorder(selection.clone(), Destination::After(f.nodes[2])),
|
||||
);
|
||||
assert!(Arc::ptr_eq(&before, &f.app.document.snapshot()));
|
||||
assert_eq!(f.app.multiselect_snapshot().unwrap(), selection);
|
||||
assert!(!f.app.document.can_undo());
|
||||
source_matches(&f.app);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_stale_document_revision_owner_epoch_and_selection_aba_reject_before_shared_hooks() {
|
||||
for stale_by in ["document", "revision", "owner", "epoch", "ABA"] {
|
||||
let mut f = fixture(Layer::Oa);
|
||||
pair(&mut f);
|
||||
let stale = f.app.multiselect_snapshot().unwrap();
|
||||
match stale_by {
|
||||
"document" => f.app.canvas_document = Id::new_v4(),
|
||||
"revision" => {
|
||||
f.app
|
||||
.document
|
||||
.execute(Command::Describe(f.work, "New revision".into()))
|
||||
.unwrap();
|
||||
}
|
||||
"owner" => {
|
||||
f.app.behavior.active_owner = Some(f.other_owner);
|
||||
f.app.selected = f.other_owner;
|
||||
f.app.reconcile_multiselect();
|
||||
}
|
||||
"epoch" => f.app.canvas_epoch += 1,
|
||||
_ => {
|
||||
choose(&mut f.app, local(f.nodes[0]), Choice::Toggle);
|
||||
choose(&mut f.app, local(f.nodes[0]), Choice::Toggle);
|
||||
assert_eq!(
|
||||
f.app.multiselect_snapshot().unwrap().targets(),
|
||||
stale.targets()
|
||||
);
|
||||
assert_eq!(
|
||||
f.app.multiselect_snapshot().unwrap().primary(),
|
||||
stale.primary()
|
||||
);
|
||||
}
|
||||
}
|
||||
let before = f.app.document.snapshot();
|
||||
let selection = f.app.multiselect_snapshot();
|
||||
let camera = f.app.behavior.topology.camera_boundary();
|
||||
let selected = f.app.selected;
|
||||
let epochs = (
|
||||
f.app.canvas_epoch,
|
||||
f.app.camera_epoch,
|
||||
f.app.allocation_drag.epoch,
|
||||
);
|
||||
let history = (f.app.document.can_undo(), f.app.document.can_redo());
|
||||
f.app.status = "Current owner feedback".into();
|
||||
f.app.allocation_drag.arrange_next = true;
|
||||
send(
|
||||
&mut f.app,
|
||||
Event::ProcessReorder(stale, Destination::After(f.nodes[2])),
|
||||
);
|
||||
assert!(
|
||||
Arc::ptr_eq(&before, &f.app.document.snapshot()),
|
||||
"{stale_by}"
|
||||
);
|
||||
assert_eq!(f.app.multiselect_snapshot(), selection);
|
||||
assert_eq!(f.app.selected, selected);
|
||||
assert!(f.app.behavior.topology.camera_boundary() == camera);
|
||||
assert_eq!(
|
||||
(
|
||||
f.app.canvas_epoch,
|
||||
f.app.camera_epoch,
|
||||
f.app.allocation_drag.epoch
|
||||
),
|
||||
epochs
|
||||
);
|
||||
assert_eq!(
|
||||
(f.app.document.can_undo(), f.app.document.can_redo()),
|
||||
history
|
||||
);
|
||||
assert_eq!(f.app.status, "Current owner feedback");
|
||||
assert!(f.app.allocation_drag.arrange_next);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_pending_properties_source_form_tool_and_busy_never_implicitly_apply_or_change_group() {
|
||||
for pending in [
|
||||
"properties",
|
||||
"invalid properties",
|
||||
"source",
|
||||
"form",
|
||||
"tool",
|
||||
"busy",
|
||||
] {
|
||||
let mut f = fixture(Layer::Oa);
|
||||
pair(&mut f);
|
||||
match pending {
|
||||
"properties" => {
|
||||
let _ = f.app.update(Message::Field(
|
||||
"/name".into(),
|
||||
"Unapplied contribution".into(),
|
||||
));
|
||||
}
|
||||
"invalid properties" => {
|
||||
let _ = f
|
||||
.app
|
||||
.update(Message::Field("/alias".into(), "invalid alias!".into()));
|
||||
}
|
||||
"source" => {
|
||||
let text = format!("{}\n// Unapplied source\n", f.app.source_workspace.text());
|
||||
let _ = f
|
||||
.app
|
||||
.update(Message::SourceEdit(text_editor::Action::SelectAll));
|
||||
let _ = f.app.update(Message::SourceEdit(text_editor::Action::Edit(
|
||||
text_editor::Edit::Paste(Arc::new(text)),
|
||||
)));
|
||||
}
|
||||
"form" => {
|
||||
let context = f.app.topology_context().unwrap();
|
||||
let _ = f.app.update(Message::TopologyCanvas(CanvasEvent::Begin {
|
||||
context,
|
||||
tool: topology::Tool::Work,
|
||||
control: ControlKind::Choice,
|
||||
insertion: topology::Insertion::default(),
|
||||
}));
|
||||
let _ = f.app.update(Message::Behavior(behavior::Event::Topology(
|
||||
topology::Event::Name("Unsubmitted form".into()),
|
||||
)));
|
||||
assert!(f.app.behavior.has_pending());
|
||||
}
|
||||
"tool" => f.app.behavior.topology.choose(CanvasTool::Connect),
|
||||
_ => f.app.busy = true,
|
||||
}
|
||||
if pending == "source" {
|
||||
assert!(f.app.source_dirty());
|
||||
}
|
||||
if pending.contains("properties") {
|
||||
assert!(f.app.draft_dirty());
|
||||
}
|
||||
let selection = f.app.multiselect_snapshot().unwrap();
|
||||
let before = f.app.document.snapshot();
|
||||
let source = f.app.source_workspace.bundle();
|
||||
let draft = f
|
||||
.app
|
||||
.draft
|
||||
.as_ref()
|
||||
.map(|d| serde_json::to_value(d.recovery_snapshot()).unwrap());
|
||||
let form = f.app.behavior.recovery_snapshot();
|
||||
for event in [
|
||||
Event::ProcessReorder(selection.clone(), Destination::After(f.nodes[2])),
|
||||
Event::Clear(selection.context()),
|
||||
Event::Choose(selection.context(), local(f.nodes[2]), Choice::Replace),
|
||||
] {
|
||||
send(&mut f.app, event);
|
||||
assert!(
|
||||
Arc::ptr_eq(&before, &f.app.document.snapshot()),
|
||||
"{pending}"
|
||||
);
|
||||
assert_eq!(f.app.source_workspace.bundle(), source);
|
||||
assert_eq!(
|
||||
f.app
|
||||
.draft
|
||||
.as_ref()
|
||||
.map(|d| serde_json::to_value(d.recovery_snapshot()).unwrap()),
|
||||
draft
|
||||
);
|
||||
assert_eq!(f.app.behavior.recovery_snapshot(), form);
|
||||
assert_eq!(f.app.multiselect_snapshot().unwrap(), selection);
|
||||
assert!(!f.app.document.can_undo());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_queued_singleton_selection_remove_reorder_and_reconnect_cannot_retire_current_group() {
|
||||
let mut f = fixture(Layer::Oa);
|
||||
pair(&mut f);
|
||||
marker(&mut f.app, f.owner, f.nodes[0]);
|
||||
let context = f.app.topology_context().unwrap();
|
||||
let before = f.app.document.snapshot();
|
||||
let selection = f.app.multiselect_snapshot().unwrap();
|
||||
let pending = f.app.pending_diagram_reveal.clone();
|
||||
let camera = f.app.behavior.topology.camera_boundary();
|
||||
let epochs = (
|
||||
f.app.canvas_epoch,
|
||||
f.app.camera_epoch,
|
||||
f.app.allocation_drag.epoch,
|
||||
);
|
||||
f.app.status = "Group owns input".into();
|
||||
for event in [
|
||||
CanvasEvent::Select(context, f.nodes[2]),
|
||||
CanvasEvent::SelectQualified(
|
||||
context,
|
||||
OccurrenceEndpoint {
|
||||
references: vec![],
|
||||
occurrence: f.nodes[2],
|
||||
},
|
||||
),
|
||||
CanvasEvent::Remove(context, f.nodes[0]),
|
||||
CanvasEvent::Reorder {
|
||||
context,
|
||||
occurrence: f.nodes[0],
|
||||
before: f.nodes[4],
|
||||
},
|
||||
CanvasEvent::Reconnect {
|
||||
context,
|
||||
link: f.link,
|
||||
end: topology::End::Target,
|
||||
target: f.nodes[4],
|
||||
},
|
||||
] {
|
||||
let _ = f.app.update(Message::TopologyCanvas(event));
|
||||
assert!(Arc::ptr_eq(&before, &f.app.document.snapshot()));
|
||||
assert_eq!(f.app.multiselect_snapshot().unwrap(), selection);
|
||||
assert_eq!(f.app.pending_diagram_reveal, pending);
|
||||
assert!(f.app.behavior.topology.camera_boundary() == camera);
|
||||
assert_eq!(
|
||||
(
|
||||
f.app.canvas_epoch,
|
||||
f.app.camera_epoch,
|
||||
f.app.allocation_drag.epoch
|
||||
),
|
||||
epochs
|
||||
);
|
||||
assert_eq!(f.app.status, "Group owns input");
|
||||
assert!(!f.app.delete_pending);
|
||||
assert!(!f.app.document.can_undo());
|
||||
}
|
||||
}
|
||||
|
||||
fn qualified_fixture() -> (Fixture, Id, Id, Id, Id) {
|
||||
let mut f = fixture(Layer::Oa);
|
||||
let mut project = f.app.project().clone();
|
||||
let sequence = project
|
||||
.make_element(Kind::Process, Layer::Oa, "Reusable checks", None)
|
||||
.unwrap();
|
||||
let sequence_id = sequence.id;
|
||||
project = execute_batch(&project, &[Command::CreateElement(sequence)]).unwrap();
|
||||
// Collapsed reuse cards still show four preview entries. Keep the tested
|
||||
// leaf beyond that bound so collapsing really removes its exact target.
|
||||
for index in 0..4 {
|
||||
add(
|
||||
&mut project,
|
||||
sequence_id,
|
||||
&format!("Preview prefix {index}"),
|
||||
OccurrenceBody::Action {
|
||||
action: Some(f.work),
|
||||
lifeline: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
let leaf = add(
|
||||
&mut project,
|
||||
sequence_id,
|
||||
"Shared check",
|
||||
OccurrenceBody::Action {
|
||||
action: Some(f.work),
|
||||
lifeline: None,
|
||||
},
|
||||
);
|
||||
let first = add(
|
||||
&mut project,
|
||||
f.owner,
|
||||
"First call",
|
||||
OccurrenceBody::SequenceReference {
|
||||
sequence: Some(sequence_id),
|
||||
},
|
||||
);
|
||||
let second = add(
|
||||
&mut project,
|
||||
f.owner,
|
||||
"Second call",
|
||||
OccurrenceBody::SequenceReference {
|
||||
sequence: Some(sequence_id),
|
||||
},
|
||||
);
|
||||
f.app = open(project, f.owner);
|
||||
let p = f.app.project().clone();
|
||||
assert!(f.app.behavior.topology.expand(&p, f.owner, first));
|
||||
assert!(f.app.behavior.topology.expand(&p, f.owner, second));
|
||||
(f, sequence_id, first, second, leaf)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_qualified_primary_is_exact_readonly_and_clears_without_semantic_rebinding() {
|
||||
let (mut f, _, first, second, leaf) = qualified_fixture();
|
||||
let p = f.app.project().clone();
|
||||
let endpoint = OccurrenceEndpoint {
|
||||
references: vec![first],
|
||||
occurrence: leaf,
|
||||
};
|
||||
let target = Target::ProcessOccurrence(endpoint.clone());
|
||||
assert!(f.app.multiselect_target_visible(&target));
|
||||
choose(&mut f.app, target.clone(), Choice::Replace);
|
||||
assert_eq!(
|
||||
f.app.selected, leaf,
|
||||
"Inspect exact leaf, not the first caller or shared work"
|
||||
);
|
||||
assert_eq!(f.app.process_preview_primary(), Some(&target));
|
||||
assert_eq!(
|
||||
f.app
|
||||
.behavior
|
||||
.topology
|
||||
.selection_address()
|
||||
.unwrap()
|
||||
.endpoint,
|
||||
endpoint
|
||||
);
|
||||
assert!(f.app.draft.is_none());
|
||||
let before = f.app.document.snapshot();
|
||||
for message in [
|
||||
Message::Field("/name".into(), "Forbidden definition rename".into()),
|
||||
Message::Apply,
|
||||
Message::Delete,
|
||||
] {
|
||||
let _ = f.app.update(message);
|
||||
assert!(Arc::ptr_eq(&before, &f.app.document.snapshot()));
|
||||
assert!(f.app.draft.is_none());
|
||||
assert!(!f.app.delete_pending);
|
||||
assert_eq!(f.app.process_preview_primary(), Some(&target));
|
||||
}
|
||||
choose(&mut f.app, local(f.nodes[0]), Choice::Add);
|
||||
let mixed = f.app.multiselect_snapshot().unwrap();
|
||||
send(
|
||||
&mut f.app,
|
||||
Event::ProcessReorder(mixed.clone(), Destination::After(f.nodes[2])),
|
||||
);
|
||||
assert!(Arc::ptr_eq(&before, &f.app.document.snapshot()));
|
||||
assert_eq!(f.app.multiselect_snapshot().unwrap(), mixed);
|
||||
choose(&mut f.app, target.clone(), Choice::Replace);
|
||||
let current = f.app.multiselect_context().unwrap();
|
||||
send(&mut f.app, Event::Clear(current));
|
||||
assert!(f.app.multiselect_snapshot().unwrap().targets().is_empty());
|
||||
assert_eq!(f.app.selected, f.owner);
|
||||
assert!(f.app.behavior.topology.selection_address().is_none());
|
||||
choose(&mut f.app, target, Choice::Replace);
|
||||
assert!(f.app.behavior.topology.expand(&p, f.owner, first));
|
||||
f.app.reconcile_multiselect();
|
||||
assert!(f.app.multiselect_snapshot().unwrap().targets().is_empty());
|
||||
assert_eq!(f.app.selected, f.owner);
|
||||
assert!(f.app.behavior.topology.selection_address().is_none());
|
||||
assert!(f.app.process_preview_primary().is_none());
|
||||
assert!(
|
||||
f.app
|
||||
.multiselect_target_visible(&Target::ProcessOccurrence(OccurrenceEndpoint {
|
||||
references: vec![second],
|
||||
occurrence: leaf
|
||||
}))
|
||||
);
|
||||
assert!(Arc::ptr_eq(&before, &f.app.document.snapshot()));
|
||||
}
|
||||
|
||||
fn menu(app: &mut App, target: Target, label: &str) -> Message {
|
||||
use crate::context_menu::Entry;
|
||||
use crate::model_menu;
|
||||
let object = match &target {
|
||||
Target::ProcessOccurrence(endpoint) => endpoint.occurrence,
|
||||
_ => panic!("This fixture opens contribution menus"),
|
||||
};
|
||||
let mut request = model_menu::Request::object(object, iced::Point::new(180., 150.));
|
||||
request.process = app.topology_context().map(Box::new);
|
||||
request.selection_target = Some(Box::new(target));
|
||||
let _ = app.update(Message::Menu(model_menu::Event::Open(request)));
|
||||
let opened = app
|
||||
.model_menu
|
||||
.as_ref()
|
||||
.expect("Captured Process menu must survive the open epoch change");
|
||||
assert!(opened.valid(app));
|
||||
assert_eq!(
|
||||
opened.request.process.as_deref().copied(),
|
||||
app.topology_context()
|
||||
);
|
||||
model_menu::popup(app)
|
||||
.unwrap()
|
||||
.entries
|
||||
.into_iter()
|
||||
.find_map(|entry| match entry {
|
||||
Entry::Action {
|
||||
label: name,
|
||||
message: Some(message),
|
||||
..
|
||||
} if name == label => Some(message),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or_else(|| panic!("Missing enabled menu action: {label}"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_menu_add_uses_rebound_epoch_and_preserves_existing_primary_membership() {
|
||||
let mut f = fixture(Layer::Oa);
|
||||
choose(&mut f.app, local(f.nodes[0]), Choice::Replace);
|
||||
let before = f.app.document.snapshot();
|
||||
let old_context = f.app.topology_context().unwrap();
|
||||
let action = menu(&mut f.app, local(f.nodes[4]), "Add to selection");
|
||||
assert_ne!(f.app.topology_context().unwrap().epoch, old_context.epoch);
|
||||
assert_eq!(f.app.selected, f.nodes[0]);
|
||||
let _ = f.app.update(action);
|
||||
let selection = f.app.multiselect_snapshot().unwrap();
|
||||
assert_eq!(selection.targets(), &[local(f.nodes[0]), local(f.nodes[4])]);
|
||||
assert_eq!(selection.primary(), Some(&local(f.nodes[4])));
|
||||
assert_eq!(f.app.selected, f.nodes[4]);
|
||||
assert!(Arc::ptr_eq(&before, &f.app.document.snapshot()));
|
||||
assert!(!f.app.document.can_undo());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_qualified_nonprimary_menu_opens_definition_from_caller_and_back_returns() {
|
||||
let (mut f, definition, first, _, leaf) = qualified_fixture();
|
||||
choose(&mut f.app, local(f.nodes[0]), Choice::Replace);
|
||||
let before = f.app.document.snapshot();
|
||||
let target = Target::ProcessOccurrence(OccurrenceEndpoint {
|
||||
references: vec![first],
|
||||
occurrence: leaf,
|
||||
});
|
||||
let action = menu(&mut f.app, target, "Open referenced definition");
|
||||
assert_eq!(f.app.selected, f.nodes[0]);
|
||||
assert_eq!(f.app.topology_context().unwrap().owner, f.owner);
|
||||
let _ = f.app.update(action);
|
||||
assert_eq!(f.app.behavior.active_owner, Some(definition));
|
||||
assert_eq!(f.app.topology_context().unwrap().owner, definition);
|
||||
let context = f.app.topology_context().unwrap();
|
||||
let _ = f
|
||||
.app
|
||||
.update(Message::TopologyCanvas(CanvasEvent::Back(context)));
|
||||
assert_eq!(f.app.behavior.active_owner, Some(f.owner));
|
||||
assert_eq!(f.app.topology_context().unwrap().owner, f.owner);
|
||||
assert!(Arc::ptr_eq(&before, &f.app.document.snapshot()));
|
||||
assert!(!f.app.document.can_undo());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_qualified_definition_back_restores_exact_invocation_readonly_subject_and_camera() {
|
||||
for open_nonprimary in [false, true] {
|
||||
let (mut f, definition, first, second, leaf) = qualified_fixture();
|
||||
// The shared fixture has already expanded both fifth-tile invocations.
|
||||
let context = f.app.topology_context().unwrap();
|
||||
let _ = f.app.update(Message::TopologyCanvas(CanvasEvent::Zoom(
|
||||
context,
|
||||
Some(0.75),
|
||||
)));
|
||||
let first_target = Target::ProcessOccurrence(OccurrenceEndpoint {
|
||||
references: vec![first],
|
||||
occurrence: leaf,
|
||||
});
|
||||
let second_target = Target::ProcessOccurrence(OccurrenceEndpoint {
|
||||
references: vec![second],
|
||||
occurrence: leaf,
|
||||
});
|
||||
if open_nonprimary {
|
||||
// Keep the requested second path outside the current selection so
|
||||
// its ordinary definition action is available in its context menu.
|
||||
choose(&mut f.app, local(f.nodes[0]), Choice::Replace);
|
||||
choose(&mut f.app, first_target.clone(), Choice::Add);
|
||||
} else {
|
||||
choose(&mut f.app, first_target.clone(), Choice::Replace);
|
||||
choose(&mut f.app, second_target.clone(), Choice::Add);
|
||||
}
|
||||
assert_eq!(f.app.multiselect_snapshot().unwrap().targets().len(), 2);
|
||||
assert_eq!(
|
||||
f.app.multiselect_snapshot().unwrap().primary(),
|
||||
Some(if open_nonprimary {
|
||||
&first_target
|
||||
} else {
|
||||
&second_target
|
||||
})
|
||||
);
|
||||
assert_eq!(f.app.selected, leaf);
|
||||
assert!(f.app.draft.is_none());
|
||||
let original = f.app.document.snapshot();
|
||||
let source = f.app.document.bundle().clone();
|
||||
let camera = f.app.behavior.topology.camera_boundary();
|
||||
let target = second_target;
|
||||
let action = if open_nonprimary {
|
||||
menu(&mut f.app, target.clone(), "Open referenced definition")
|
||||
} else {
|
||||
let Target::ProcessOccurrence(endpoint) = &target else {
|
||||
unreachable!()
|
||||
};
|
||||
Message::TopologyCanvas(CanvasEvent::OpenQualified(
|
||||
f.app.topology_context().unwrap(),
|
||||
endpoint.clone(),
|
||||
))
|
||||
};
|
||||
let _ = f.app.update(action);
|
||||
assert_eq!(f.app.topology_context().unwrap().owner, definition);
|
||||
assert_eq!(f.app.selected, leaf);
|
||||
assert!(f.app.process_preview_primary().is_none());
|
||||
assert!(f.app.draft.is_some(), "Definition view is editable");
|
||||
let context = f.app.topology_context().unwrap();
|
||||
let _ = f
|
||||
.app
|
||||
.update(Message::TopologyCanvas(CanvasEvent::Back(context)));
|
||||
assert_eq!(f.app.topology_context().unwrap().owner, f.owner);
|
||||
assert_eq!(f.app.behavior.active_owner, Some(f.owner));
|
||||
assert_eq!(
|
||||
f.app.multiselect_snapshot().unwrap().targets(),
|
||||
std::slice::from_ref(&target)
|
||||
);
|
||||
assert_eq!(
|
||||
f.app.multiselect_snapshot().unwrap().primary(),
|
||||
Some(&target)
|
||||
);
|
||||
assert_eq!(
|
||||
f.app.selected, leaf,
|
||||
"Back must not inspect the outer reference"
|
||||
);
|
||||
assert_eq!(f.app.process_preview_primary(), Some(&target));
|
||||
let Target::ProcessOccurrence(endpoint) = &target else {
|
||||
unreachable!()
|
||||
};
|
||||
let address = f.app.behavior.topology.selection_address().unwrap();
|
||||
assert_eq!(address.owner, f.owner);
|
||||
assert_eq!(&address.endpoint, endpoint);
|
||||
assert!(
|
||||
f.app.multiselect_target_visible(&target),
|
||||
"Caller expansions survive Back"
|
||||
);
|
||||
assert!(
|
||||
f.app.draft.is_none(),
|
||||
"Returned qualified inspector stays read-only"
|
||||
);
|
||||
assert!(f.app.behavior.topology.camera_boundary() == camera);
|
||||
let _ = f.app.update(Message::Field(
|
||||
"/name".into(),
|
||||
"Must not edit the outer reference or definition".into(),
|
||||
));
|
||||
assert!(f.app.draft.is_none());
|
||||
assert_eq!(f.app.process_preview_primary(), Some(&target));
|
||||
assert!(Arc::ptr_eq(&original, &f.app.document.snapshot()));
|
||||
assert_eq!(f.app.document.bundle(), &source);
|
||||
assert!(!f.app.document.can_undo() && !f.app.document.can_redo());
|
||||
source_matches(&f.app);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_open_current_qualified_primary_and_catalog_navigation_restore_editable_draft() {
|
||||
for catalog in [false, true] {
|
||||
let (mut f, definition, first, _, leaf) = qualified_fixture();
|
||||
let target = Target::ProcessOccurrence(OccurrenceEndpoint {
|
||||
references: vec![first],
|
||||
occurrence: leaf,
|
||||
});
|
||||
choose(&mut f.app, target.clone(), Choice::Replace);
|
||||
assert_eq!(f.app.selected, leaf);
|
||||
assert!(f.app.draft.is_none());
|
||||
let before = f.app.document.snapshot();
|
||||
if catalog {
|
||||
let _ = f.app.update(Message::Page(Page::Catalog));
|
||||
assert_eq!(f.app.page, Page::Catalog);
|
||||
} else {
|
||||
let action = menu(&mut f.app, target, "Open referenced definition");
|
||||
let _ = f.app.update(action);
|
||||
assert_eq!(f.app.topology_context().unwrap().owner, definition);
|
||||
}
|
||||
assert_eq!(f.app.selected, leaf);
|
||||
assert!(f.app.process_preview_primary().is_none());
|
||||
assert!(
|
||||
f.app.draft.is_some(),
|
||||
"Leaving a read-only presentation restores normal property editing"
|
||||
);
|
||||
let _ = f.app.update(Message::Field(
|
||||
"/name".into(),
|
||||
"Definition edit stays unapplied".into(),
|
||||
));
|
||||
assert!(f.app.draft_dirty());
|
||||
assert!(Arc::ptr_eq(&before, &f.app.document.snapshot()));
|
||||
assert!(!f.app.document.can_undo());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_explicit_object_navigation_leaves_qualified_use_even_for_same_leaf_id() {
|
||||
for edit_properties in [false, true] {
|
||||
for defining_leaf in [false, true] {
|
||||
let (mut f, definition, first, _, leaf) = qualified_fixture();
|
||||
let qualified = Target::ProcessOccurrence(OccurrenceEndpoint {
|
||||
references: vec![first],
|
||||
occurrence: leaf,
|
||||
});
|
||||
choose(&mut f.app, qualified.clone(), Choice::Replace);
|
||||
assert_eq!(f.app.selected, leaf);
|
||||
assert_eq!(f.app.process_preview_primary(), Some(&qualified));
|
||||
assert!(f.app.draft.is_none());
|
||||
let before = f.app.document.snapshot();
|
||||
let source = f.app.document.bundle().clone();
|
||||
let camera = f.app.behavior.topology.camera_boundary();
|
||||
let target = if defining_leaf { leaf } else { first };
|
||||
let expected_owner = if defining_leaf { definition } else { f.owner };
|
||||
let message = if edit_properties {
|
||||
Message::EditProperties(target)
|
||||
} else {
|
||||
Message::Select(target)
|
||||
};
|
||||
let _ = f.app.update(message);
|
||||
assert_eq!(f.app.selected, target);
|
||||
assert_eq!(f.app.topology_context().unwrap().owner, expected_owner);
|
||||
assert_eq!(f.app.behavior.active_owner, Some(expected_owner));
|
||||
assert_eq!(
|
||||
f.app.multiselect_snapshot().unwrap().targets(),
|
||||
&[local(target)]
|
||||
);
|
||||
assert_eq!(
|
||||
f.app.multiselect_snapshot().unwrap().primary(),
|
||||
Some(&local(target))
|
||||
);
|
||||
assert!(f.app.behavior.topology.selection_address().is_none());
|
||||
assert!(f.app.process_preview_primary().is_none());
|
||||
assert!(
|
||||
f.app.draft.is_some(),
|
||||
"Explicit model-object navigation restores its editor"
|
||||
);
|
||||
assert!(!f.app.draft_dirty());
|
||||
if !defining_leaf {
|
||||
assert!(f.app.behavior.topology.camera_boundary() == camera);
|
||||
}
|
||||
assert!(Arc::ptr_eq(&before, &f.app.document.snapshot()));
|
||||
assert_eq!(f.app.document.bundle(), &source);
|
||||
assert!(!f.app.document.can_undo() && !f.app.document.can_redo());
|
||||
source_matches(&f.app);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_open_local_reference_after_qualified_inspection_returns_local_reference() {
|
||||
let (mut f, definition, first, _, leaf) = qualified_fixture();
|
||||
choose(
|
||||
&mut f.app,
|
||||
Target::ProcessOccurrence(OccurrenceEndpoint {
|
||||
references: vec![first],
|
||||
occurrence: leaf,
|
||||
}),
|
||||
Choice::Replace,
|
||||
);
|
||||
let before = f.app.document.snapshot();
|
||||
let source = f.app.document.bundle().clone();
|
||||
let camera = f.app.behavior.topology.camera_boundary();
|
||||
let context = f.app.topology_context().unwrap();
|
||||
let _ = f
|
||||
.app
|
||||
.update(Message::TopologyCanvas(CanvasEvent::Open(context, first)));
|
||||
assert_eq!(f.app.topology_context().unwrap().owner, definition);
|
||||
assert_eq!(f.app.selected, definition);
|
||||
assert!(f.app.behavior.topology.selection_address().is_none());
|
||||
let context = f.app.topology_context().unwrap();
|
||||
let _ = f
|
||||
.app
|
||||
.update(Message::TopologyCanvas(CanvasEvent::Back(context)));
|
||||
assert_eq!(f.app.topology_context().unwrap().owner, f.owner);
|
||||
assert_eq!(f.app.selected, first);
|
||||
assert_eq!(
|
||||
f.app.multiselect_snapshot().unwrap().targets(),
|
||||
&[local(first)]
|
||||
);
|
||||
assert!(f.app.behavior.topology.selection_address().is_none());
|
||||
assert!(f.app.process_preview_primary().is_none());
|
||||
assert!(f.app.draft.is_some());
|
||||
assert!(f.app.behavior.topology.camera_boundary() == camera);
|
||||
assert!(Arc::ptr_eq(&before, &f.app.document.snapshot()));
|
||||
assert_eq!(f.app.document.bundle(), &source);
|
||||
assert!(!f.app.document.can_undo() && !f.app.document.can_redo());
|
||||
source_matches(&f.app);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_usage_navigation_between_two_preview_paths_keeps_exact_link_identity() {
|
||||
use crate::diagram_usage as usage;
|
||||
let (mut f, definition, first, second, leaf) = qualified_fixture();
|
||||
let mut project = f.app.project().clone();
|
||||
let next = add(
|
||||
&mut project,
|
||||
definition,
|
||||
"Following check",
|
||||
OccurrenceBody::Action {
|
||||
action: Some(f.work),
|
||||
lifeline: None,
|
||||
},
|
||||
);
|
||||
let relation = project.relate(RelationKind::SequenceLink, "Reusable path", leaf, next);
|
||||
f.app = open(project, f.owner);
|
||||
let project = f.app.project().clone();
|
||||
assert!(f.app.behavior.topology.expand(&project, f.owner, first));
|
||||
assert!(f.app.behavior.topology.expand(&project, f.owner, second));
|
||||
let before = f.app.document.snapshot();
|
||||
for call in [first, second] {
|
||||
let target = Target::ProcessPreviewLink {
|
||||
relation,
|
||||
source: OccurrenceEndpoint {
|
||||
references: vec![call],
|
||||
occurrence: leaf,
|
||||
},
|
||||
target: OccurrenceEndpoint {
|
||||
references: vec![call],
|
||||
occurrence: next,
|
||||
},
|
||||
};
|
||||
let context = usage::Context {
|
||||
document: f.app.canvas_document,
|
||||
revision: f.app.document.revision(),
|
||||
subject: relation,
|
||||
};
|
||||
let _ = f
|
||||
.app
|
||||
.update(Message::DiagramUsage(usage::Event::Open(context)));
|
||||
let token = f.app.diagram_usages.as_ref().unwrap().token;
|
||||
let rows = usage::collect(
|
||||
f.app.project(),
|
||||
relation,
|
||||
&f.app.behavior.topology,
|
||||
&f.app.behavior.scenario,
|
||||
);
|
||||
let row = rows
|
||||
.iter()
|
||||
.find(|row| row.surface == Surface::Process(f.owner) && row.target == target)
|
||||
.unwrap()
|
||||
.clone();
|
||||
let _ = f.app.update(Message::DiagramUsagesReady(usage::Completion {
|
||||
token,
|
||||
context,
|
||||
result: Ok(rows),
|
||||
}));
|
||||
let _ = f
|
||||
.app
|
||||
.update(Message::DiagramUsage(usage::Event::Choose(token, row)));
|
||||
assert_eq!(f.app.selected, relation);
|
||||
assert_eq!(
|
||||
f.app.multiselect_snapshot().unwrap().primary(),
|
||||
Some(&target)
|
||||
);
|
||||
assert_eq!(f.app.process_preview_primary(), Some(&target));
|
||||
assert!(f.app.draft.is_none());
|
||||
assert!(Arc::ptr_eq(&before, &f.app.document.snapshot()));
|
||||
}
|
||||
}
|
||||
@@ -1,281 +0,0 @@
|
||||
//! Actual arrangement selection and current-record Details admission.
|
||||
use super::*;
|
||||
use crate::behavior::canvas_workspace::tests::Mounted;
|
||||
use crate::behavior::scenario::{CanvasEvent, Context, Shelf};
|
||||
use crate::behavior::scenario_layout::Representation;
|
||||
use iced::{Point, Size, advanced::widget};
|
||||
|
||||
const FIXTURE: &str = include_str!("diagram_usages/tests/saved_scenario.arcadia");
|
||||
|
||||
fn id(app: &App, alias: &str) -> Id {
|
||||
app.project()
|
||||
.identities()
|
||||
.into_iter()
|
||||
.find(|(_, name)| *name == alias)
|
||||
.unwrap()
|
||||
.0
|
||||
}
|
||||
fn setup() -> (App, Id, Id, Id) {
|
||||
let mut app = App::with_project(oa_core::language::parse(FIXTURE).unwrap());
|
||||
let a = id(&app, "incident_readable");
|
||||
let b = id(&app, "incident_wide");
|
||||
let owner = id(&app, "incident_response");
|
||||
let _ = app.update(Message::Select(owner));
|
||||
let _ = app.update(Message::Page(Page::Behavior));
|
||||
choose(&mut app, Representation::Saved { diagram: a });
|
||||
let _ = app.update(Message::Select(a));
|
||||
app.window_size = Size::new(1440., 1000.);
|
||||
(app, a, b, owner)
|
||||
}
|
||||
fn send(app: &mut App, event: impl FnOnce(Context) -> CanvasEvent) {
|
||||
let context = app.scenario_context().unwrap();
|
||||
let _ = app.update(Message::ScenarioCanvas(event(context)));
|
||||
}
|
||||
fn choose(app: &mut App, representation: Representation) {
|
||||
send(app, |context| {
|
||||
CanvasEvent::Representation(context, representation)
|
||||
});
|
||||
}
|
||||
fn model_state(app: &App) -> (u64, bool, bool, bool) {
|
||||
(
|
||||
app.document.revision(),
|
||||
app.document.dirty(),
|
||||
app.document.can_undo(),
|
||||
app.document.can_redo(),
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn actual_arrangement_picker_follows_selected_saved_record_and_opens_b_properties() {
|
||||
let (mut app, a, b, _) = setup();
|
||||
let mut mounted = Mounted::new(&mut app).await;
|
||||
let snapshot = app.document.snapshot();
|
||||
let before = model_state(&app);
|
||||
let captured = app.scenario_context().unwrap();
|
||||
let picker = mounted.geometry(&app).bounds("scenario-arrangement-picker");
|
||||
assert!(
|
||||
app.window_size.height - (picker.y + picker.height) > picker.y,
|
||||
"fixture has room for Iced's menu below the actual picker"
|
||||
);
|
||||
assert_eq!(app.selected, a);
|
||||
mounted.click(&mut app, picker.center()).await;
|
||||
// iced_widget 0.14.2 overlay/menu.rs uses default line height plus
|
||||
// vertical padding for each row. The real workspace orders Automatic,
|
||||
// then the fixture's A/B/C Diagram IDs. Select B from the actual overlay.
|
||||
let row_height =
|
||||
f32::from(iced::advanced::text::LineHeight::default().to_absolute(12.into())) + 10.;
|
||||
let b_row = Point::new(
|
||||
picker.center_x(),
|
||||
picker.y + picker.height + row_height * 2.5,
|
||||
);
|
||||
assert!(b_row.y < app.window_size.height);
|
||||
let events = mounted.click(&mut app, b_row).await;
|
||||
assert!(events.iter().any(|event| matches!(event,
|
||||
Message::ScenarioCanvas(CanvasEvent::Representation(context, Representation::Saved { diagram }))
|
||||
if *context == captured && *diagram == b)), "must exercise the actual arrangement callback");
|
||||
assert_eq!(app.selected, b);
|
||||
assert_eq!(app.draft.as_ref().unwrap().original, b);
|
||||
assert_eq!(
|
||||
app.draft.as_ref().unwrap().value["name"],
|
||||
"B - Incident review negative and far"
|
||||
);
|
||||
assert_eq!(app.active_diagram, Some(b));
|
||||
assert_eq!(
|
||||
app.scenario_context().unwrap().representation,
|
||||
Representation::Saved { diagram: b }
|
||||
);
|
||||
assert_ne!(app.scenario_context().unwrap().visit, captured.visit);
|
||||
|
||||
send(&mut app, 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);
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
//! Saved Scenario navigation is presentation state, never a document edit.
|
||||
use super::*;
|
||||
use crate::behavior::scenario_layout::{Representation, presentation::checked_owner};
|
||||
|
||||
impl App {
|
||||
pub(super) fn enter_scenario_representation(&mut self, representation: Representation) -> bool {
|
||||
let Some(owner) = checked_owner(self.project(), representation) else {
|
||||
self.status =
|
||||
"This Scenario view is no longer available or has an incompatible owner.".into();
|
||||
return false;
|
||||
};
|
||||
self.layer = self.project().elements[&owner].layer();
|
||||
self.page = Page::Behavior;
|
||||
self.behavior.active_owner = Some(owner);
|
||||
match representation {
|
||||
Representation::Saved { diagram } => self.active_diagram = Some(diagram),
|
||||
Representation::Derived { .. } => {
|
||||
if self
|
||||
.active_diagram
|
||||
.is_some_and(|id| self.saved_scenario_diagram(id))
|
||||
{
|
||||
self.active_diagram = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
self.selected_node = None;
|
||||
if self
|
||||
.behavior
|
||||
.scenario
|
||||
.enter_representation(self.canvas_document, owner, representation)
|
||||
{
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Reconcile after navigation/adoption without selecting or applying a draft.
|
||||
/// A retained invalid Saved identity remains unavailable; it must not silently
|
||||
/// turn into a different automatic Scenario or another saved arrangement.
|
||||
pub(super) fn reconcile_scenario_workspace(&mut self) {
|
||||
if self.page == Page::Diagram
|
||||
&& !self.behavior.has_pending()
|
||||
&& let Some(diagram) = self
|
||||
.active_diagram
|
||||
.filter(|id| self.saved_scenario_diagram(*id))
|
||||
{
|
||||
self.enter_scenario_representation(Representation::Saved { diagram });
|
||||
}
|
||||
if self.page != Page::Behavior {
|
||||
self.behavior.scenario.leave();
|
||||
return;
|
||||
}
|
||||
if let Some(rep @ Representation::Saved { diagram }) =
|
||||
self.behavior.scenario.representation()
|
||||
{
|
||||
// The requested view is authoritative until explicit navigation
|
||||
// retires it. This also recovers the same view when Undo restores it.
|
||||
if let Some(owner) = checked_owner(self.project(), rep) {
|
||||
self.active_diagram = Some(diagram);
|
||||
self.layer = self.project().elements[&owner].layer();
|
||||
self.behavior.active_owner = Some(owner);
|
||||
self.behavior
|
||||
.scenario
|
||||
.enter_representation(self.canvas_document, owner, rep);
|
||||
} else if let Some(owner) = self.behavior.scenario.owner() {
|
||||
self.behavior
|
||||
.scenario
|
||||
.enter_representation(self.canvas_document, owner, rep);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let Some(owner) = behavior::selected_owner(self) else {
|
||||
self.behavior.scenario.leave();
|
||||
return;
|
||||
};
|
||||
if self
|
||||
.project()
|
||||
.elements
|
||||
.get(&owner)
|
||||
.is_some_and(|e| e.kind() == Kind::Scenario)
|
||||
{
|
||||
self.behavior.scenario.enter(self.canvas_document, owner);
|
||||
} else {
|
||||
self.behavior.scenario.leave();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod adoption_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod owner_navigation_tests;
|
||||
@@ -1,264 +0,0 @@
|
||||
//! Independent adoption oracles: expected Projects are edited directly in the
|
||||
//! fixture, then admitted through the actual source editor/Document transaction.
|
||||
//! Navigation is checked separately from the expected authored mutation.
|
||||
use super::*;
|
||||
use crate::behavior::canvas_workspace::tests::Mounted;
|
||||
use iced::advanced::widget;
|
||||
use iced::widget::text_editor;
|
||||
use oa_core::language;
|
||||
use std::sync::Arc;
|
||||
|
||||
struct Fixture {
|
||||
app: App,
|
||||
owner: Id,
|
||||
same_layer: Id,
|
||||
other_layer: Id,
|
||||
view: Id,
|
||||
spare_view: Id,
|
||||
}
|
||||
|
||||
fn fixture() -> Fixture {
|
||||
let mut project = Project::empty();
|
||||
let owner = project
|
||||
.add_element(Kind::Scenario, Layer::Oa, "Original owner", None)
|
||||
.unwrap();
|
||||
let same_layer = project
|
||||
.add_element(Kind::Scenario, Layer::Oa, "Other OA owner", None)
|
||||
.unwrap();
|
||||
let other_layer = project
|
||||
.add_element(Kind::Scenario, Layer::Sa, "SA owner", None)
|
||||
.unwrap();
|
||||
// A remaining topology makes silent fallback observably different from the
|
||||
// required unavailable Saved view after its owner is deleted.
|
||||
project
|
||||
.add_element(Kind::Process, Layer::Oa, "Remaining process", None)
|
||||
.unwrap();
|
||||
let mut make_view = |alias: &str, name: &str, owner| {
|
||||
let id = Id::new_v4();
|
||||
project.diagrams.insert(
|
||||
id,
|
||||
Diagram {
|
||||
id,
|
||||
alias: alias.into(),
|
||||
name: name.into(),
|
||||
description: String::new(),
|
||||
layer: Layer::Oa,
|
||||
projection: ProjectionKind::Scenario { scenario: owner },
|
||||
layout: None,
|
||||
nodes: Default::default(),
|
||||
routes: vec![],
|
||||
view_options: Default::default(),
|
||||
},
|
||||
);
|
||||
id
|
||||
};
|
||||
let view = make_view("retained_view", "Retained arrangement", owner);
|
||||
let spare_view = make_view("spare_view", "Spare arrangement", same_layer);
|
||||
let mut app = App::with_project(project);
|
||||
let _ = app.update(Message::Select(owner));
|
||||
choose(&mut app, view);
|
||||
assert_saved(&app, view, owner, Layer::Oa);
|
||||
assert!(!app.document.can_undo());
|
||||
Fixture {
|
||||
app,
|
||||
owner,
|
||||
same_layer,
|
||||
other_layer,
|
||||
view,
|
||||
spare_view,
|
||||
}
|
||||
}
|
||||
|
||||
fn choose(app: &mut App, id: Id) {
|
||||
let _ = app.update(Message::Diagram(Choice {
|
||||
id,
|
||||
label: "Previously captured picker item".into(),
|
||||
}));
|
||||
}
|
||||
|
||||
fn assert_saved(app: &App, view: Id, owner: Id, layer: Layer) {
|
||||
assert_eq!(app.page, Page::Behavior);
|
||||
assert_eq!(app.layer, layer);
|
||||
assert_eq!(app.active_diagram, Some(view));
|
||||
let context = app
|
||||
.scenario_context()
|
||||
.expect("saved owner remains the visible context");
|
||||
assert_eq!(context.scenario, owner);
|
||||
assert_eq!(
|
||||
context.representation,
|
||||
Representation::Saved { diagram: view }
|
||||
);
|
||||
assert!(!context.visit.is_nil());
|
||||
}
|
||||
|
||||
fn adopt_from_source(app: &mut App, expected: &Project) {
|
||||
let text = language::format(expected);
|
||||
// Parser equality is a fixture precondition, not the navigation oracle.
|
||||
assert_eq!(language::parse(&text).unwrap(), *expected);
|
||||
let _ = app.update(Message::Page(Page::Text));
|
||||
let _ = app.update(Message::SourceEdit(text_editor::Action::SelectAll));
|
||||
let _ = app.update(Message::SourceEdit(text_editor::Action::Edit(
|
||||
text_editor::Edit::Paste(Arc::new(text)),
|
||||
)));
|
||||
assert!(app.source_dirty());
|
||||
let _ = app.update(Message::ApplySource);
|
||||
assert!(!app.source_dirty(), "{}", app.status);
|
||||
assert_eq!(
|
||||
app.project(),
|
||||
expected,
|
||||
"actual source transaction must adopt exactly the fixture change"
|
||||
);
|
||||
let _ = app.update(Message::Page(Page::Behavior));
|
||||
}
|
||||
|
||||
fn retarget(cross_layer: bool) {
|
||||
let mut f = fixture();
|
||||
let before = f.app.document.snapshot();
|
||||
let old_context = f.app.scenario_context().unwrap();
|
||||
let revision = f.app.document.revision();
|
||||
let target = if cross_layer {
|
||||
f.other_layer
|
||||
} else {
|
||||
f.same_layer
|
||||
};
|
||||
let layer = if cross_layer { Layer::Sa } else { Layer::Oa };
|
||||
let mut expected = before.decoded.project.clone();
|
||||
let view = expected.diagrams.get_mut(&f.view).unwrap();
|
||||
view.projection = ProjectionKind::Scenario { scenario: target };
|
||||
view.layer = layer;
|
||||
|
||||
adopt_from_source(&mut f.app, &expected);
|
||||
assert_saved(&f.app, f.view, target, layer);
|
||||
assert_ne!(f.app.scenario_context().unwrap().visit, old_context.visit);
|
||||
assert_eq!(
|
||||
f.app.document.revision(),
|
||||
revision + 1,
|
||||
"routing must not create a second transaction"
|
||||
);
|
||||
assert_eq!(
|
||||
f.app.project().diagrams[&f.spare_view],
|
||||
before.decoded.project.diagrams[&f.spare_view]
|
||||
);
|
||||
let adopted_bundle = f.app.document.bundle().clone();
|
||||
|
||||
let _ = f.app.update(Message::Undo);
|
||||
assert_saved(&f.app, f.view, f.owner, Layer::Oa);
|
||||
assert_eq!(f.app.project(), &before.decoded.project);
|
||||
assert_eq!(f.app.document.bundle(), &before.bundle);
|
||||
assert!(
|
||||
!f.app.document.can_undo(),
|
||||
"exactly one authored adoption must be undone"
|
||||
);
|
||||
let _ = f.app.update(Message::Redo);
|
||||
assert_saved(&f.app, f.view, target, layer);
|
||||
assert_eq!(f.app.project(), &expected);
|
||||
assert_eq!(f.app.document.bundle(), &adopted_bundle);
|
||||
assert!(!f.app.document.can_redo());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_same_layer_owner_retarget_undo_redo_keeps_requested_saved_uuid() {
|
||||
retarget(false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_cross_layer_owner_retarget_undo_redo_keeps_requested_saved_uuid() {
|
||||
retarget(true);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn source_deleted_owner_and_saved_view_mount_unavailable_instead_of_another_behavior() {
|
||||
let mut f = fixture();
|
||||
let before = f.app.document.snapshot();
|
||||
let mut expected = before.decoded.project.clone();
|
||||
assert!(expected.elements.remove(&f.owner).is_some());
|
||||
assert!(expected.diagrams.remove(&f.view).is_some());
|
||||
assert!(expected.elements.contains_key(&f.same_layer));
|
||||
assert!(
|
||||
expected
|
||||
.elements
|
||||
.values()
|
||||
.any(|e| e.kind() == Kind::Process)
|
||||
);
|
||||
adopt_from_source(&mut f.app, &expected);
|
||||
assert_eq!(
|
||||
f.app.behavior.scenario.representation(),
|
||||
Some(Representation::Saved { diagram: f.view })
|
||||
);
|
||||
let adopted = f.app.document.snapshot();
|
||||
let revision = f.app.document.revision();
|
||||
let mut mounted = Mounted::new(&mut f.app).await;
|
||||
let geometry = mounted.geometry(&f.app);
|
||||
assert!(
|
||||
geometry
|
||||
.containers
|
||||
.contains_key(&widget::Id::from("scenario-viewport-unavailable"))
|
||||
);
|
||||
assert!(
|
||||
!geometry
|
||||
.containers
|
||||
.contains_key(&widget::Id::from("scenario-viewport"))
|
||||
);
|
||||
assert_eq!(f.app.project(), &expected);
|
||||
assert_eq!(f.app.document.bundle(), &adopted.bundle);
|
||||
assert_eq!(
|
||||
f.app.document.revision(),
|
||||
revision,
|
||||
"mounting unavailable must remain passive"
|
||||
);
|
||||
|
||||
let _ = f.app.update(Message::Undo);
|
||||
assert_saved(&f.app, f.view, f.owner, Layer::Oa);
|
||||
assert_eq!(f.app.document.bundle(), &before.bundle);
|
||||
let _ = f.app.update(Message::Redo);
|
||||
assert_eq!(
|
||||
f.app.behavior.scenario.representation(),
|
||||
Some(Representation::Saved { diagram: f.view })
|
||||
);
|
||||
mounted.settle(&mut f.app).await;
|
||||
assert!(
|
||||
mounted
|
||||
.geometry(&f.app)
|
||||
.containers
|
||||
.contains_key(&widget::Id::from("scenario-viewport-unavailable"))
|
||||
);
|
||||
assert_eq!(f.app.project(), &expected);
|
||||
assert_eq!(f.app.document.bundle(), &adopted.bundle);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_and_deleted_picker_ids_cannot_apply_an_unrelated_valid_property_draft() {
|
||||
let mut f = fixture();
|
||||
let mut expected = f.app.project().clone();
|
||||
expected.diagrams.remove(&f.spare_view).unwrap();
|
||||
adopt_from_source(&mut f.app, &expected);
|
||||
let _ = f.app.update(Message::Select(f.owner));
|
||||
let _ = f.app.update(Message::Field(
|
||||
"/name".into(),
|
||||
"Pending valid owner rename".into(),
|
||||
));
|
||||
assert!(f.app.draft_dirty());
|
||||
let before = f.app.document.snapshot();
|
||||
let revision = f.app.document.revision();
|
||||
let selected = f.app.selected;
|
||||
for invalid in [Id::nil(), f.spare_view] {
|
||||
choose(&mut f.app, invalid);
|
||||
assert_saved(&f.app, f.view, f.owner, Layer::Oa);
|
||||
assert_eq!(f.app.selected, selected);
|
||||
assert!(f.app.draft_dirty());
|
||||
assert_eq!(f.app.project(), &before.decoded.project);
|
||||
assert_eq!(f.app.document.bundle(), &before.bundle);
|
||||
assert_eq!(f.app.document.revision(), revision);
|
||||
}
|
||||
// Establish that the retained draft really was valid, not merely refused by
|
||||
// the property validator: explicit Apply commits it in exactly one step.
|
||||
assert!(f.app.apply(), "{}", f.app.status);
|
||||
assert_eq!(
|
||||
f.app.project().elements[&f.owner].name,
|
||||
"Pending valid owner rename"
|
||||
);
|
||||
assert_eq!(f.app.document.revision(), revision + 1);
|
||||
let _ = f.app.update(Message::Undo);
|
||||
assert_eq!(f.app.project(), &before.decoded.project);
|
||||
assert_eq!(f.app.document.bundle(), &before.bundle);
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
//! Explicit owner navigation must retire a retained Saved Scenario, including
|
||||
//! when selection was made on a different page. These are real App events.
|
||||
use super::*;
|
||||
use crate::behavior::scenario::CanvasEvent;
|
||||
|
||||
fn setup(kind: Kind) -> (App, Id, Id) {
|
||||
let mut project = Project::empty();
|
||||
let scenario = project
|
||||
.add_element(Kind::Scenario, Layer::Oa, "Incident review", None)
|
||||
.unwrap();
|
||||
let target = project
|
||||
.add_element(kind, Layer::Oa, "Explicit destination", None)
|
||||
.unwrap();
|
||||
let view = Id::new_v4();
|
||||
project.diagrams.insert(
|
||||
view,
|
||||
Diagram {
|
||||
id: view,
|
||||
alias: "incident_saved".into(),
|
||||
name: "Incident saved arrangement".into(),
|
||||
description: String::new(),
|
||||
layer: Layer::Oa,
|
||||
projection: ProjectionKind::Scenario { scenario },
|
||||
layout: None,
|
||||
nodes: Default::default(),
|
||||
routes: vec![],
|
||||
view_options: Default::default(),
|
||||
},
|
||||
);
|
||||
let errors: Vec<_> = oa_core::validation::validate(&project)
|
||||
.into_iter()
|
||||
.filter(|issue| issue.severity == oa_core::validation::Severity::Error)
|
||||
.collect();
|
||||
assert!(errors.is_empty(), "{errors:?}");
|
||||
let mut app = App::with_project(project);
|
||||
let _ = app.update(Message::Select(scenario));
|
||||
let _ = app.update(Message::Diagram(Choice {
|
||||
id: view,
|
||||
label: "Incident saved arrangement".into(),
|
||||
}));
|
||||
assert_eq!(app.page, Page::Behavior);
|
||||
assert_eq!(
|
||||
app.scenario_context().unwrap().representation,
|
||||
Representation::Saved { diagram: view }
|
||||
);
|
||||
assert_eq!(app.selected, scenario);
|
||||
(app, scenario, target)
|
||||
}
|
||||
|
||||
fn assert_destination(app: &App, target: Id) {
|
||||
assert_eq!(app.page, Page::Behavior);
|
||||
assert_eq!(app.selected, target);
|
||||
assert_eq!(app.behavior.active_owner, Some(target));
|
||||
assert_eq!(behavior::selected_owner(app), Some(target));
|
||||
assert_eq!(
|
||||
app.behavior.scenario.representation(),
|
||||
None,
|
||||
"explicit non-Scenario destination must not retain an overriding Saved request"
|
||||
);
|
||||
assert!(app.scenario_context().is_none());
|
||||
}
|
||||
|
||||
fn exercise(via_guide: bool) {
|
||||
for kind in [Kind::Process, Kind::StateMachine] {
|
||||
let (mut app, scenario, target) = setup(kind);
|
||||
assert_ne!(scenario, target);
|
||||
let held = app.scenario_context().unwrap();
|
||||
let accepted = app.document.snapshot();
|
||||
let drafts = app.source_workspace.bundle();
|
||||
let revision = app.document.revision();
|
||||
let dirty = (app.document.dirty(), app.dirty(), app.source_dirty());
|
||||
assert!(!app.document.can_undo() && !app.document.can_redo());
|
||||
if via_guide {
|
||||
let _ = app.update(Message::Page(Page::Guide));
|
||||
assert_eq!(app.page, Page::Guide);
|
||||
assert!(app.scenario_context().is_none());
|
||||
}
|
||||
let _ = app.update(Message::Select(target));
|
||||
if via_guide {
|
||||
assert_eq!(
|
||||
app.page,
|
||||
Page::Guide,
|
||||
"explorer selection must not itself switch the page"
|
||||
);
|
||||
let _ = app.update(Message::Page(Page::Behavior));
|
||||
}
|
||||
assert_destination(&app, target);
|
||||
let camera = app.camera_boundary();
|
||||
// The prior saved canvas cannot 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);
|
||||
}
|
||||
@@ -1,205 +0,0 @@
|
||||
use super::*;
|
||||
use crate::behavior::scenario::{CanvasEvent, ScrollArea};
|
||||
|
||||
fn saved(p: &mut Project, owner: Id, name: &str) -> Id {
|
||||
let id = Id::new_v4();
|
||||
p.diagrams.insert(
|
||||
id,
|
||||
Diagram {
|
||||
id,
|
||||
alias: format!("view_{}", p.diagrams.len()),
|
||||
name: name.into(),
|
||||
description: String::new(),
|
||||
layer: Layer::Oa,
|
||||
projection: ProjectionKind::Scenario { scenario: owner },
|
||||
layout: None,
|
||||
routes: vec![],
|
||||
nodes: Default::default(),
|
||||
view_options: Default::default(),
|
||||
},
|
||||
);
|
||||
id
|
||||
}
|
||||
fn setup() -> (App, Id, Id, Id, Id) {
|
||||
let (mut p, [owner, callee, _], [reference, _, _]) =
|
||||
behavior::scenario::direct_tests::navigation_fixture();
|
||||
let a = saved(&mut p, owner, "Review A");
|
||||
let b = saved(&mut p, owner, "Review B");
|
||||
let mut app = App::with_project(p);
|
||||
app.select(owner);
|
||||
(app, a, b, reference, callee)
|
||||
}
|
||||
fn choose(app: &mut App, diagram: Id) {
|
||||
let _ = app.update(Message::Diagram(Choice {
|
||||
id: diagram,
|
||||
label: "Captured view".into(),
|
||||
}));
|
||||
}
|
||||
fn event(app: &mut App, f: impl FnOnce(behavior::scenario::Context) -> CanvasEvent) {
|
||||
let context = app.scenario_context().unwrap();
|
||||
let _ = app.update(Message::ScenarioCanvas(f(context)));
|
||||
}
|
||||
#[test]
|
||||
fn saved_view_switches_are_distinct_passive_visits_and_preserve_actual_history() {
|
||||
let (mut app, a, b, _, _) = setup();
|
||||
let original = app.document.snapshot();
|
||||
let owner = app.selected;
|
||||
assert!(app.execute(Command::Rename(owner, "Previously edited caller".into())));
|
||||
let edited = app.document.snapshot();
|
||||
let edited_revision = app.document.revision();
|
||||
choose(&mut app, a);
|
||||
let first = app.scenario_context().unwrap();
|
||||
choose(&mut app, b);
|
||||
let second = app.scenario_context().unwrap();
|
||||
choose(&mut app, a);
|
||||
let third = app.scenario_context().unwrap();
|
||||
assert_eq!(first.scenario, owner);
|
||||
assert_eq!(first.representation, Representation::Saved { diagram: a });
|
||||
assert_eq!(second.representation, Representation::Saved { diagram: b });
|
||||
assert_ne!(first.visit, second.visit);
|
||||
assert_ne!(first.visit, third.visit);
|
||||
assert_eq!(app.page, Page::Behavior);
|
||||
assert_eq!(app.selected, owner);
|
||||
assert_eq!(app.document.bundle(), &edited.bundle);
|
||||
assert_eq!(app.project(), &edited.decoded.project);
|
||||
assert_eq!(app.document.revision(), edited_revision);
|
||||
let _ = app.update(Message::Undo);
|
||||
assert_eq!(app.document.bundle(), &original.bundle);
|
||||
assert_eq!(app.project(), &original.decoded.project);
|
||||
let _ = app.update(Message::Redo);
|
||||
assert_eq!(app.document.bundle(), &edited.bundle);
|
||||
assert_eq!(app.project(), &edited.decoded.project);
|
||||
}
|
||||
#[test]
|
||||
fn saved_view_return_rejects_old_passive_events_without_an_intermediate_frame() {
|
||||
let (mut app, a, b, _, _) = setup();
|
||||
choose(&mut app, a);
|
||||
let old = app.scenario_context().unwrap();
|
||||
choose(&mut app, b);
|
||||
choose(&mut app, a);
|
||||
let current = app.scenario_context().unwrap();
|
||||
let camera = app.behavior.scenario.camera_boundary();
|
||||
let before = app.document.snapshot();
|
||||
for stale in [
|
||||
CanvasEvent::Viewport(old, iced::Size::new(432., 321.)),
|
||||
CanvasEvent::Scrolled(old, ScrollArea::Page, iced::Vector::new(0., 800.)),
|
||||
CanvasEvent::FormShown(old, Id::new_v4()),
|
||||
CanvasEvent::Restored(
|
||||
old,
|
||||
Id::new_v4(),
|
||||
ScrollArea::Page,
|
||||
iced::Vector::new(0., 800.),
|
||||
),
|
||||
CanvasEvent::Camera(
|
||||
old,
|
||||
app.camera_epoch,
|
||||
behavior::canvas_workspace::Event::Pan(iced::Vector::new(90., 80.)),
|
||||
),
|
||||
] {
|
||||
let _ = app.update(Message::ScenarioCanvas(stale));
|
||||
assert_eq!(app.scenario_context(), Some(current));
|
||||
assert!(app.behavior.scenario.camera_boundary() == camera);
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn saved_reference_back_restores_exact_view_with_a_fresh_visit() {
|
||||
let (mut app, a, _, reference, callee) = setup();
|
||||
choose(&mut app, a);
|
||||
let caller = app.scenario_context().unwrap();
|
||||
let before = app.document.snapshot();
|
||||
event(&mut app, |ctx| CanvasEvent::OpenUse(ctx, reference));
|
||||
let target = app.scenario_context().unwrap();
|
||||
assert_eq!(target.scenario, callee);
|
||||
assert_eq!(
|
||||
target.representation,
|
||||
Representation::Derived { owner: callee }
|
||||
);
|
||||
assert_eq!(app.active_diagram, None);
|
||||
event(&mut app, CanvasEvent::Back);
|
||||
let returned = app.scenario_context().unwrap();
|
||||
assert_eq!(returned.representation, caller.representation);
|
||||
assert_eq!(app.active_diagram, Some(a));
|
||||
assert_eq!(app.selected, reference);
|
||||
assert_ne!(returned.visit, caller.visit);
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
assert_eq!(app.project(), &before.decoded.project);
|
||||
assert!(!app.document.can_undo());
|
||||
}
|
||||
#[test]
|
||||
fn saved_picker_applies_valid_old_draft_and_uuid_edit_follows_that_same_view() {
|
||||
let (mut app, a, b, _, _) = setup();
|
||||
choose(&mut app, a);
|
||||
let _ = app.update(Message::Select(a));
|
||||
let before = app.document.snapshot();
|
||||
let _ = app.update(Message::Field("/name".into(), "Renamed A".into()));
|
||||
choose(&mut app, b);
|
||||
assert_eq!(app.project().diagrams[&a].name, "Renamed A");
|
||||
assert_eq!(app.selected, b);
|
||||
assert_eq!(
|
||||
app.scenario_context().unwrap().representation,
|
||||
Representation::Saved { diagram: b }
|
||||
);
|
||||
let _ = app.update(Message::Undo);
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
choose(&mut app, a);
|
||||
let replacement = Id::new_v4();
|
||||
let _ = app.update(Message::Field("/id".into(), replacement.to_string()));
|
||||
choose(&mut app, a);
|
||||
assert!(!app.project().diagrams.contains_key(&a));
|
||||
assert_eq!(app.active_diagram, Some(replacement));
|
||||
assert_eq!(app.selected, replacement);
|
||||
assert_eq!(
|
||||
app.scenario_context().unwrap().representation,
|
||||
Representation::Saved {
|
||||
diagram: replacement
|
||||
}
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn leaving_and_reentering_saved_view_preserves_source_and_retires_passive_visit() {
|
||||
let (mut app, a, _, _, _) = setup();
|
||||
choose(&mut app, a);
|
||||
let old = app.scenario_context().unwrap();
|
||||
let before = app.document.snapshot();
|
||||
let _ = app.update(Message::Page(Page::Guide));
|
||||
assert!(app.scenario_context().is_none());
|
||||
let _ = app.update(Message::Page(Page::Behavior));
|
||||
let fresh = app.scenario_context().unwrap();
|
||||
assert_eq!(fresh.representation, old.representation);
|
||||
assert_ne!(fresh.visit, old.visit);
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arrangement_picker_rejects_previous_visit_and_switches_automatic_without_model_changes() {
|
||||
let (mut app, a, b, _, _) = setup();
|
||||
choose(&mut app, a);
|
||||
let stale = app.scenario_context().unwrap();
|
||||
choose(&mut app, b);
|
||||
let current = app.scenario_context().unwrap();
|
||||
let before = app.document.snapshot();
|
||||
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Representation(
|
||||
stale,
|
||||
Representation::Saved { diagram: a },
|
||||
)));
|
||||
assert_eq!(app.scenario_context(), Some(current));
|
||||
event(&mut app, |context| {
|
||||
CanvasEvent::Representation(
|
||||
context,
|
||||
Representation::Derived {
|
||||
owner: context.scenario,
|
||||
},
|
||||
)
|
||||
});
|
||||
assert_eq!(
|
||||
app.scenario_context().unwrap().representation,
|
||||
Representation::Derived {
|
||||
owner: current.scenario
|
||||
}
|
||||
);
|
||||
assert_eq!(app.active_diagram, None);
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
assert_eq!(app.project(), &before.decoded.project);
|
||||
assert!(!app.document.can_undo());
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
//! Admit spacing packets before ordinary navigation can retire a newer reveal.
|
||||
use super::*;
|
||||
use behavior::scenario::spacing::{Event, controller::Finish};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
impl App {
|
||||
pub(super) fn spacing_enabled(&self) -> bool {
|
||||
self.pending_diagram_reveal.is_none()
|
||||
&& !self.behavior.has_pending()
|
||||
&& !self.draft_dirty()
|
||||
&& !self.source_dirty()
|
||||
&& !self.source_rename.open()
|
||||
&& behavior::canvas_workspace::keyboard_enabled(self)
|
||||
}
|
||||
|
||||
pub(super) fn scenario_spacing(&mut self, event: Event) -> Task<Message> {
|
||||
let stamp = event.stamp();
|
||||
if self.scenario_context() != Some(stamp.context) || self.camera_epoch != stamp.camera_epoch
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
let state = &self.behavior.scenario;
|
||||
let matches =
|
||||
state.spacing_token() == Some(event.token()) && state.spacing_stamp() == Some(stamp);
|
||||
if match event {
|
||||
Event::Begin { token, .. } => token.is_nil() || state.spacing_token().is_some(),
|
||||
_ => !matches,
|
||||
} {
|
||||
return Task::none();
|
||||
}
|
||||
if matches!(event, Event::Cancel { .. }) || !self.spacing_enabled() {
|
||||
if self.behavior.scenario.cancel_spacing() {
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
}
|
||||
return Task::none();
|
||||
}
|
||||
match event {
|
||||
Event::Begin { .. } => {
|
||||
let mut state = std::mem::take(&mut self.behavior.scenario);
|
||||
let result = state.begin_spacing(self.project(), event);
|
||||
self.behavior.scenario = state;
|
||||
if result.is_err() {
|
||||
self.status = "The drag could not start in this view. Try again.".into();
|
||||
}
|
||||
}
|
||||
Event::Move { screen, .. } => self.behavior.scenario.move_spacing(screen),
|
||||
Event::Finish { screen, .. } => {
|
||||
let finished = self.behavior.scenario.finish_spacing(screen);
|
||||
// Retire queued packets even for zero motion or a refused finish.
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
match finished {
|
||||
Ok(Finish::Click(target)) => self.select(target),
|
||||
Ok(Finish::Noop) => {}
|
||||
Ok(Finish::Commit {
|
||||
command,
|
||||
selected,
|
||||
scale,
|
||||
pan,
|
||||
}) => {
|
||||
// Document prepares and writes the source candidate before
|
||||
// committing its snapshot. Failure leaves camera/selection
|
||||
// and source/history at their accepted values.
|
||||
match self.document.execute(*command) {
|
||||
Ok(_) => {
|
||||
self.selected = selected;
|
||||
self.selected_node = None;
|
||||
self.diagram_navigation_focus = None;
|
||||
self.behavior.scenario.accept_spacing_camera(scale, pan);
|
||||
self.sync();
|
||||
self.status = "Scenario spacing updated.".into();
|
||||
}
|
||||
Err(error) => self.status = format!("Spacing was not applied: {error}"),
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
self.status =
|
||||
"That position cannot be applied. The saved arrangement is unchanged."
|
||||
.into()
|
||||
}
|
||||
}
|
||||
}
|
||||
Event::Cancel { .. } => unreachable!(),
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
}
|
||||
@@ -1,272 +0,0 @@
|
||||
use super::*;
|
||||
use behavior::scenario::{
|
||||
CanvasEvent,
|
||||
spacing::{
|
||||
Target,
|
||||
controller::tests::{fixture, packet},
|
||||
},
|
||||
};
|
||||
use iced::{Point, Size, Vector};
|
||||
|
||||
fn setup() -> (App, Id, Id) {
|
||||
let (document, owner, diagram, lane, message) = fixture();
|
||||
let mut app = App::with_project(document.project().clone());
|
||||
let _ = app.update(Message::Select(owner));
|
||||
let _ = app.update(Message::Diagram(Choice {
|
||||
id: diagram,
|
||||
label: "Saved".into(),
|
||||
}));
|
||||
let c = app.scenario_context().unwrap();
|
||||
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Viewport(
|
||||
c,
|
||||
Size::new(900., 600.),
|
||||
)));
|
||||
assert!(app.spacing_enabled());
|
||||
(app, lane, message)
|
||||
}
|
||||
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());
|
||||
}
|
||||
@@ -49,6 +49,7 @@ pub enum Event {
|
||||
Line(Id, Id, bool),
|
||||
Segment(Id, Id, bool),
|
||||
Details(Id, bool),
|
||||
PreviewScrolled(Id, Id, Id, f32),
|
||||
DetailSegment(Id, bool),
|
||||
ReadOnly(Id, text_editor::Action),
|
||||
Undo(Id),
|
||||
@@ -112,6 +113,7 @@ struct Form {
|
||||
line: usize,
|
||||
segment: usize,
|
||||
preview_destination: Id,
|
||||
preview_offset: f32,
|
||||
details: bool,
|
||||
detail_segment: usize,
|
||||
detail_editor: text_editor::Content,
|
||||
@@ -170,6 +172,17 @@ impl App {
|
||||
return false;
|
||||
};
|
||||
match event {
|
||||
Event::PreviewScrolled(invocation, approval, destination, offset) => {
|
||||
*invocation == form.invocation
|
||||
&& !form.details
|
||||
&& form
|
||||
.approval
|
||||
.as_ref()
|
||||
.is_some_and(|ready| ready.id == *approval)
|
||||
&& *destination == form.preview_destination
|
||||
&& offset.is_finite()
|
||||
&& *offset >= 0.
|
||||
}
|
||||
Event::Preview(invocation, generation) => {
|
||||
*invocation == form.invocation && *generation == form.generation
|
||||
}
|
||||
@@ -202,7 +215,7 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
fn rename_admission(&self) -> Result<(), String> {
|
||||
pub(super) fn rename_admission(&self) -> Result<(), String> {
|
||||
if self.page != Page::Text
|
||||
|| self.busy
|
||||
|| self.pending.is_some()
|
||||
@@ -435,11 +448,23 @@ impl App {
|
||||
}
|
||||
form.details = value;
|
||||
// Leaving or returning to the preview ends the old visit. Keep
|
||||
// its selected page and retained scroll; only retire delayed
|
||||
// operations, without scheduling another return-to-top.
|
||||
// its selected page and scroll; the Details widget replaces the
|
||||
// preview subtree, so restore the reported offset on return.
|
||||
form.preview_destination = Id::new_v4();
|
||||
form.detail_segment = 0;
|
||||
refresh_details(form);
|
||||
if !value && let Some(ready) = &form.approval {
|
||||
return iced::widget::operation::scroll_to(
|
||||
preview_scroll_id(form, ready.id),
|
||||
iced::widget::scrollable::AbsoluteOffset {
|
||||
x: None,
|
||||
y: Some(form.preview_offset),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
Event::PreviewScrolled(_, _, _, offset) => {
|
||||
self.source_rename.form.as_mut().unwrap().preview_offset = offset;
|
||||
}
|
||||
Event::DetailSegment(_, next) => {
|
||||
let form = self.source_rename.form.as_mut().unwrap();
|
||||
@@ -516,6 +541,7 @@ impl App {
|
||||
line: 0,
|
||||
segment: 0,
|
||||
preview_destination: Id::new_v4(),
|
||||
preview_offset: 0.,
|
||||
details: false,
|
||||
detail_segment: 0,
|
||||
detail_editor: text_editor::Content::new(),
|
||||
@@ -953,6 +979,7 @@ fn input_id(invocation: Id) -> iced::widget::Id {
|
||||
// or changing a filter that leaves the page tuple unchanged (the ABA case).
|
||||
fn reset_preview_position(form: &mut Form) -> Task<Message> {
|
||||
form.preview_destination = Id::new_v4();
|
||||
form.preview_offset = 0.;
|
||||
if !form.details
|
||||
&& let Some(ready) = &form.approval
|
||||
{
|
||||
@@ -1118,7 +1145,15 @@ where
|
||||
pager(format!("Row segments {}/{} · both sides", form.segment + 1, segments.max(1)),
|
||||
(form.segment > 0).then(|| message(Event::Segment(invocation, preview_id, false))),
|
||||
(form.segment + 1 < segments).then(|| message(Event::Segment(invocation, preview_id, true)))),
|
||||
scrollable(displayed).id(preview_scroll_id(form, preview_id)).height(Fill).width(Fill).spacing(8),
|
||||
scrollable(displayed)
|
||||
.id(preview_scroll_id(form, preview_id))
|
||||
.on_scroll({
|
||||
let destination = form.preview_destination;
|
||||
move |viewport| message(Event::PreviewScrolled(
|
||||
invocation, preview_id, destination, viewport.absolute_offset().y,
|
||||
))
|
||||
})
|
||||
.height(Fill).width(Fill).spacing(8),
|
||||
].spacing(6).height(Fill).into()
|
||||
} else {
|
||||
scrollable(column![
|
||||
|
||||
@@ -4,6 +4,9 @@ use super::*;
|
||||
use crate::diagram_usage::{self as usage, Surface, Target as UsageTarget, Usage};
|
||||
use iced::{Size, Vector};
|
||||
|
||||
#[path = "navigation_union_tests/palette_union.rs"]
|
||||
mod palette_union;
|
||||
|
||||
fn model_id(number: u8) -> Id {
|
||||
Id::parse_str(&format!("00000000-0000-0000-0000-{number:012}")).unwrap()
|
||||
}
|
||||
@@ -356,7 +359,7 @@ fn document_reset_retains_all_three_worker_slots_until_their_exact_results_arriv
|
||||
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);
|
||||
assert!(app.text_workspace.drawer().is_none());
|
||||
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);
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
//! Interactions absent from the independently published navigation branch.
|
||||
use super::*;
|
||||
|
||||
fn assert_stale_navigation_message(message: Message) {
|
||||
let mut app = navigation_fixture();
|
||||
let found = navigate(&mut app);
|
||||
let destination = navigation(&app);
|
||||
let allocation_epoch = app.allocation_drag.epoch;
|
||||
let accepted = app.document.snapshot();
|
||||
let status = app.status.clone();
|
||||
send(&mut app, message);
|
||||
assert_eq!(navigation(&app), destination);
|
||||
assert_eq!(app.allocation_drag.epoch, allocation_epoch);
|
||||
assert_eq!(app.status, status);
|
||||
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
||||
assert_eq!(app.source_workspace.bundle(), accepted.bundle);
|
||||
first_measurement(&mut app, &found);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn captured_old_source_query_and_find_close_preserve_the_new_destination() {
|
||||
let mut previous = navigation_fixture();
|
||||
let query = previous.source_symbol_message(nav::SymbolEvent::Query(query::Action::Definition));
|
||||
send(
|
||||
&mut previous,
|
||||
Message::SourceNavigation(nav::Event::Open(false)),
|
||||
);
|
||||
assert!(previous.source_navigation.open);
|
||||
let close = previous.source_find_close_message();
|
||||
assert!(previous.source_input_envelope(&close));
|
||||
assert_stale_navigation_message(query);
|
||||
assert_stale_navigation_message(close);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn current_source_query_and_checked_find_close_remain_live() {
|
||||
let mut app = navigation_fixture();
|
||||
let before = app.document.snapshot();
|
||||
send(&mut app, Message::SourceNavigation(nav::Event::Open(false)));
|
||||
assert!(app.source_navigation.open);
|
||||
let close = app.source_find_close_message();
|
||||
send(&mut app, close.clone());
|
||||
assert!(!app.source_navigation.open);
|
||||
let epoch = app.allocation_drag.epoch;
|
||||
send(&mut app, close);
|
||||
assert_eq!(app.allocation_drag.epoch, epoch, "retired close is inert");
|
||||
let message = app.source_symbol_message(nav::SymbolEvent::Query(query::Action::Definition));
|
||||
send(&mut app, message);
|
||||
assert!(app.source_symbols.in_flight.is_some());
|
||||
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
|
||||
assert_eq!(app.source_workspace.bundle(), before.bundle);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn captured_old_chooser_context_and_every_token_variant_are_inert() {
|
||||
let mut previous = navigation_fixture();
|
||||
let open = previous.diagram_usage_message(model_id(2));
|
||||
send(&mut previous, open.clone());
|
||||
let (token, found) = finish_lookup(&mut previous);
|
||||
assert_stale_navigation_message(open);
|
||||
for event in [
|
||||
usage::Event::Close(token),
|
||||
usage::Event::Search(token, "old filter".into()),
|
||||
usage::Event::Page(token, 1),
|
||||
usage::Event::Move(token, 1),
|
||||
usage::Event::Activate(token),
|
||||
usage::Event::Destination(token, Some(found.surface)),
|
||||
usage::Event::Choose(token, found),
|
||||
] {
|
||||
assert_stale_navigation_message(Message::DiagramUsage(event));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn current_chooser_open_and_close_keep_their_boundary_and_worker_semantics() {
|
||||
let mut app = navigation_fixture();
|
||||
navigate(&mut app);
|
||||
let epoch = app.allocation_drag.epoch;
|
||||
let message = app.diagram_usage_message(model_id(2));
|
||||
send(&mut app, message);
|
||||
assert!(app.diagram_usages.is_some());
|
||||
assert!(app.pending_diagram_reveal.is_none());
|
||||
assert!(app.diagram_navigation_focus.is_none());
|
||||
assert!(app.allocation_drag.epoch > epoch);
|
||||
let completion = diagram_completion(&app);
|
||||
let token = completion.token;
|
||||
send(&mut app, Message::DiagramUsage(usage::Event::Close(token)));
|
||||
assert!(app.diagram_usages.is_none());
|
||||
assert_eq!(app.diagram_lookup_in_flight, Some(token));
|
||||
send(&mut app, Message::DiagramUsagesReady(completion));
|
||||
assert!(app.diagram_lookup_in_flight.is_none());
|
||||
assert!(
|
||||
app.diagram_usages.is_none(),
|
||||
"completion does not reopen a closed chooser"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allocation_keyboard_notification_preserves_navigation_and_first_measurement() {
|
||||
let mut app = navigation_fixture();
|
||||
let before = app.document.snapshot();
|
||||
let notify = || Message::WorkDrag(crate::app::allocation_drag::Event::KeyboardBoundary);
|
||||
send(&mut app, notify());
|
||||
let found = navigate(&mut app);
|
||||
let destination = navigation(&app);
|
||||
let allocation_epoch = app.allocation_drag.epoch;
|
||||
send(&mut app, notify());
|
||||
assert_eq!(navigation(&app), destination);
|
||||
assert_eq!(app.allocation_drag.epoch, allocation_epoch);
|
||||
first_measurement(&mut app, &found);
|
||||
send(&mut app, notify());
|
||||
assert!(app.diagram_focus(found.surface).is_some());
|
||||
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
|
||||
assert_eq!(app.source_workspace.bundle(), before.bundle);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_rename_begin_cannot_retire_allocation_epoch_or_new_navigation() {
|
||||
let mut app = navigation_fixture();
|
||||
let captured = app.source_rename_begin();
|
||||
let found = navigate(&mut app);
|
||||
let destination = navigation(&app);
|
||||
let allocation_epoch = app.allocation_drag.epoch;
|
||||
let accepted = app.document.snapshot();
|
||||
send(&mut app, Message::SourceRename(Event::Begin(captured)));
|
||||
assert_eq!(navigation(&app), destination);
|
||||
assert_eq!(app.allocation_drag.epoch, allocation_epoch);
|
||||
assert!(!app.source_rename.open());
|
||||
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
||||
first_measurement(&mut app, &found);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn captured_behavior_toolbar_cannot_retire_a_new_documents_navigation() {
|
||||
let mut project = Project::empty();
|
||||
let scenario = project
|
||||
.add_element(Kind::Scenario, Layer::Oa, "Prior scenario", None)
|
||||
.unwrap();
|
||||
let mut previous = App::with_project(project);
|
||||
previous.page = Page::Behavior;
|
||||
previous.behavior.active_owner = Some(scenario);
|
||||
previous
|
||||
.behavior
|
||||
.scenario
|
||||
.enter(previous.canvas_document, scenario);
|
||||
let scope = behavior::canvas_workspace::Scope::input_owner(&previous).unwrap();
|
||||
let captured = behavior::canvas_workspace::intent(
|
||||
scope,
|
||||
"Fit",
|
||||
Message::ScenarioCanvas(behavior::scenario::CanvasEvent::Zoom(
|
||||
previous.scenario_context().unwrap(),
|
||||
None,
|
||||
)),
|
||||
);
|
||||
|
||||
let mut app = navigation_fixture();
|
||||
let found = navigate(&mut app);
|
||||
let destination = navigation(&app);
|
||||
let allocation_epoch = app.allocation_drag.epoch;
|
||||
let accepted = app.document.snapshot();
|
||||
send(&mut app, captured);
|
||||
assert_eq!(navigation(&app), destination);
|
||||
assert_eq!(app.allocation_drag.epoch, allocation_epoch);
|
||||
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
||||
first_measurement(&mut app, &found);
|
||||
}
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
#[test]
|
||||
fn stale_compact_drawer_controls_preserve_current_navigation_and_evidence() {
|
||||
use crate::app::text_workspace::{DrawerTab, Event as TextEvent};
|
||||
for changed in 0..3 {
|
||||
let mut app = navigation_fixture();
|
||||
app.text_workspace.show_drawer(Some(DrawerTab::Status));
|
||||
let origin = app.text_drawer_context();
|
||||
let captured = [
|
||||
Message::TextWorkspace(TextEvent::ShowDrawer(origin, DrawerTab::Problems)),
|
||||
Message::TextWorkspace(TextEvent::HideDrawer(origin)),
|
||||
crate::views::captured_text_drawer_resize(&app),
|
||||
];
|
||||
match changed {
|
||||
0 => app.canvas_document = Id::new_v4(),
|
||||
1 => app.source_workspace.reset_navigation_session(),
|
||||
_ => app.text_workspace.show_drawer(Some(DrawerTab::Status)),
|
||||
}
|
||||
let found = navigate(&mut app);
|
||||
// Keep command availability true to isolate the stale owner check from
|
||||
// the page guard, while a newer measured navigation is still pending.
|
||||
app.page = Page::Text;
|
||||
assert!(app.source_navigation_available());
|
||||
let destination = navigation(&app);
|
||||
let allocation = app.allocation_boundary();
|
||||
let camera = app.camera_boundary();
|
||||
let allocation_epoch = app.allocation_drag.epoch;
|
||||
let interaction = app.source_symbols.interaction;
|
||||
let drawer = app.text_workspace.drawer();
|
||||
let height = app.text_workspace.drawer_height();
|
||||
let visit = app.text_workspace.drawer_visit();
|
||||
let accepted = app.document.snapshot();
|
||||
let raw = app.source_workspace.bundle();
|
||||
let status = app.status.clone();
|
||||
for message in captured {
|
||||
send(&mut app, message);
|
||||
assert_eq!(navigation(&app), destination);
|
||||
assert!(app.allocation_boundary() == allocation);
|
||||
assert!(app.camera_boundary() == camera);
|
||||
assert_eq!(app.allocation_drag.epoch, allocation_epoch);
|
||||
assert_eq!(app.source_symbols.interaction, interaction);
|
||||
assert_eq!(app.text_workspace.drawer(), drawer);
|
||||
assert_eq!(app.text_workspace.drawer_height(), height);
|
||||
assert_eq!(app.text_workspace.drawer_visit(), visit);
|
||||
assert_eq!(app.status, status);
|
||||
assert_eq!(app.source_workspace.bundle(), raw);
|
||||
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
||||
assert!(!app.document.can_undo() && !app.document.can_redo());
|
||||
}
|
||||
app.page = Page::Diagram;
|
||||
first_measurement(&mut app, &found);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
#[test]
|
||||
fn current_compact_drawer_controls_and_mounted_resize_remain_live() {
|
||||
use crate::app::text_workspace::{DrawerTab, Event as TextEvent};
|
||||
let mut app = navigation_fixture();
|
||||
let accepted = app.document.snapshot();
|
||||
let raw = app.source_workspace.bundle();
|
||||
navigate(&mut app);
|
||||
app.page = Page::Text;
|
||||
assert!(app.pending_diagram_reveal.is_some());
|
||||
let epoch = app.allocation_drag.epoch;
|
||||
let show = Message::TextWorkspace(TextEvent::ShowDrawer(
|
||||
app.text_drawer_context(),
|
||||
DrawerTab::Status,
|
||||
));
|
||||
send(&mut app, show);
|
||||
assert_eq!(app.text_workspace.drawer(), Some(DrawerTab::Status));
|
||||
assert!(app.pending_diagram_reveal.is_none());
|
||||
assert!(app.diagram_navigation_focus.is_none());
|
||||
assert!(app.allocation_drag.epoch > epoch);
|
||||
let epoch = app.allocation_drag.epoch;
|
||||
let height = app.text_workspace.drawer_height();
|
||||
let resize = crate::views::captured_text_drawer_resize(&app);
|
||||
send(&mut app, resize);
|
||||
assert_eq!(app.text_workspace.drawer_height(), height + 60.);
|
||||
assert!(app.allocation_drag.epoch > epoch);
|
||||
let epoch = app.allocation_drag.epoch;
|
||||
let hide = Message::TextWorkspace(TextEvent::HideDrawer(app.text_drawer_context()));
|
||||
send(&mut app, hide);
|
||||
assert!(app.text_workspace.drawer().is_none());
|
||||
assert!(app.allocation_drag.epoch > epoch);
|
||||
assert_eq!(app.source_workspace.bundle(), raw);
|
||||
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
||||
assert!(!app.document.can_undo() && !app.document.can_redo());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compact_diagram_usages_command_uses_captured_query_and_existing_chooser() {
|
||||
let mut app = navigation_fixture();
|
||||
let accepted = app.document.snapshot();
|
||||
let raw = app.source_workspace.bundle();
|
||||
let command = app.text_command_message(crate::app::text_workspace::Command::ShowDiagramUsages);
|
||||
send(&mut app, command);
|
||||
let request = app
|
||||
.source_symbols
|
||||
.request
|
||||
.clone()
|
||||
.expect("actual source query started");
|
||||
assert_eq!(app.source_diagram_job, Some(request.job));
|
||||
let completion = source_completion(&app, request);
|
||||
send(&mut app, Message::SourceSymbolsReady(completion));
|
||||
let panel = app
|
||||
.diagram_usages
|
||||
.as_ref()
|
||||
.expect("existing guarded chooser opened");
|
||||
assert_eq!(panel.context.subject, model_id(2));
|
||||
assert_eq!(panel.context.document, app.canvas_document);
|
||||
assert_eq!(panel.context.revision, app.document.revision());
|
||||
assert_eq!(app.diagram_lookup_in_flight, Some(panel.token));
|
||||
assert!(!app.source_navigation_available());
|
||||
let (_, found) = finish_lookup(&mut app);
|
||||
assert_eq!(found.surface, usage::Surface::Saved(model_id(8)));
|
||||
assert_eq!(app.source_workspace.bundle(), raw);
|
||||
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
||||
assert!(app.source_symbols.back.is_empty());
|
||||
assert!(!app.document.can_undo() && !app.document.can_redo());
|
||||
}
|
||||
@@ -50,6 +50,7 @@ fn scroll_id(app: &App) -> widget::Id {
|
||||
struct Geometry {
|
||||
id: widget::Id,
|
||||
offset: Option<Vector>,
|
||||
bounds: Option<Rectangle>,
|
||||
}
|
||||
impl widget::Operation for Geometry {
|
||||
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn widget::Operation)) {
|
||||
@@ -58,13 +59,14 @@ impl widget::Operation for Geometry {
|
||||
fn scrollable(
|
||||
&mut self,
|
||||
id: Option<&widget::Id>,
|
||||
_: Rectangle,
|
||||
bounds: Rectangle,
|
||||
_: Rectangle,
|
||||
offset: Vector,
|
||||
_: &mut dyn widget::operation::Scrollable,
|
||||
) {
|
||||
if id == Some(&self.id) {
|
||||
self.offset = Some(offset);
|
||||
self.bounds = Some(bounds);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -95,6 +97,7 @@ impl Mounted {
|
||||
let mut geometry = Geometry {
|
||||
id: scroll_id(app),
|
||||
offset: None,
|
||||
bounds: None,
|
||||
};
|
||||
self.operate(app, &mut geometry);
|
||||
geometry.offset.expect("mounted preview scrollable").y
|
||||
@@ -114,6 +117,41 @@ impl Mounted {
|
||||
assert!(offset > 10., "fixture must really be scrolled");
|
||||
offset
|
||||
}
|
||||
fn wheel(&mut self, app: &App) -> Vec<Message> {
|
||||
let mut geometry = Geometry {
|
||||
id: scroll_id(app),
|
||||
offset: None,
|
||||
bounds: None,
|
||||
};
|
||||
self.operate(app, &mut geometry);
|
||||
let position = geometry.bounds.expect("visible preview bounds").center();
|
||||
let mut view = dialog_content(app, &self.editor);
|
||||
self.tree.diff(view.as_widget());
|
||||
let node = view.as_widget_mut().layout(
|
||||
&mut self.tree,
|
||||
&FontLayout,
|
||||
&layout::Limits::new(Size::ZERO, HOST),
|
||||
);
|
||||
let mut messages = Vec::new();
|
||||
let mut shell = iced::advanced::Shell::new(&mut messages);
|
||||
view.as_widget_mut().update(
|
||||
&mut self.tree,
|
||||
&iced::Event::Mouse(iced::mouse::Event::WheelScrolled {
|
||||
delta: iced::mouse::ScrollDelta::Pixels { x: 0., y: -120. },
|
||||
}),
|
||||
Layout::new(&node),
|
||||
iced::mouse::Cursor::Available(position),
|
||||
&FontLayout,
|
||||
&mut iced::advanced::clipboard::Null,
|
||||
&mut shell,
|
||||
&Rectangle::with_size(HOST),
|
||||
);
|
||||
assert!(
|
||||
shell.is_event_captured(),
|
||||
"preview must consume wheel input"
|
||||
);
|
||||
messages
|
||||
}
|
||||
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) {
|
||||
@@ -337,6 +375,7 @@ async fn details_round_trip_retires_held_reset_without_changing_page_or_repeated
|
||||
let mut hidden = Geometry {
|
||||
id: old_preview.clone(),
|
||||
offset: None,
|
||||
bounds: None,
|
||||
};
|
||||
mounted.operate(&app, &mut hidden);
|
||||
assert!(
|
||||
@@ -355,8 +394,8 @@ async fn details_round_trip_retires_held_reset_without_changing_page_or_repeated
|
||||
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"
|
||||
1,
|
||||
"returning restores the reported offset in the new preview subtree"
|
||||
);
|
||||
let returned_visit = scroll_id(&app);
|
||||
assert_ne!(returned_visit, old_preview);
|
||||
@@ -383,3 +422,135 @@ async fn details_round_trip_retires_held_reset_without_changing_page_or_repeated
|
||||
assert_eq!(evidence(&app), before);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn details_remount_restores_wheel_offset_before_any_rescroll() {
|
||||
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 page = (form.file, form.hunk, form.line, form.segment);
|
||||
let mut mounted = Mounted::new(&app);
|
||||
let reports = mounted.wheel(&app);
|
||||
assert_eq!(reports.len(), 1, "one actual preview scroll report");
|
||||
let reported_offset = match &reports[0] {
|
||||
Message::SourceRename(Event::PreviewScrolled(_, _, _, offset)) => *offset,
|
||||
_ => panic!("expected an actual preview scroll report"),
|
||||
};
|
||||
assert!(reported_offset.is_finite() && reported_offset > 0.);
|
||||
let offset = mounted.offset(&app);
|
||||
// Iced reports fractional absolute offsets but exposes rounded display
|
||||
// translation to widget operations. Preserve both representations exactly.
|
||||
assert_eq!(reported_offset.round(), offset);
|
||||
assert!(
|
||||
offset > 10.,
|
||||
"real wheel input must move the overflowing fixture"
|
||||
);
|
||||
let interaction = app.source_symbols.interaction;
|
||||
let camera = app.camera_boundary();
|
||||
let allocation = app.allocation_boundary();
|
||||
for message in reports.iter().cloned() {
|
||||
let task = app.update(message);
|
||||
assert_eq!(mounted.execute(&app, task).await, 0);
|
||||
}
|
||||
assert_eq!(app.source_symbols.interaction, interaction);
|
||||
assert!(app.camera_boundary() == camera);
|
||||
assert!(app.allocation_boundary() == allocation);
|
||||
assert_eq!(evidence(&app), before);
|
||||
assert_eq!(
|
||||
app.source_rename.form.as_ref().unwrap().preview_offset,
|
||||
reported_offset
|
||||
);
|
||||
|
||||
let details = app.update(Message::SourceRename(Event::Details(invocation, true)));
|
||||
assert_eq!(mounted.execute(&app, details).await, 0);
|
||||
if mount_details {
|
||||
let mut hidden = Geometry {
|
||||
id: scroll_id(&app),
|
||||
offset: None,
|
||||
bounds: None,
|
||||
};
|
||||
mounted.operate(&app, &mut hidden);
|
||||
assert!(hidden.offset.is_none(), "Details replaced the Scrollable");
|
||||
}
|
||||
// A queued report from the hidden preview cannot overwrite its saved offset.
|
||||
let Message::SourceRename(Event::PreviewScrolled(
|
||||
old_invocation,
|
||||
old_approval,
|
||||
old_visit,
|
||||
_,
|
||||
)) = reports[0].clone()
|
||||
else {
|
||||
unreachable!()
|
||||
};
|
||||
let stale = Event::PreviewScrolled(old_invocation, old_approval, old_visit, offset + 100.);
|
||||
assert!(!app.source_rename_envelope(&stale));
|
||||
let task = app.update(Message::SourceRename(stale.clone()));
|
||||
assert_eq!(mounted.execute(&app, task).await, 0);
|
||||
|
||||
let returned = app.update(Message::SourceRename(Event::Details(invocation, false)));
|
||||
assert_eq!(mounted.execute(&app, returned).await, 1);
|
||||
// This assertion precedes any new scroll: the former test scrolled again here.
|
||||
assert_eq!(mounted.offset(&app), offset);
|
||||
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);
|
||||
assert_eq!(form.preview_offset, reported_offset);
|
||||
assert!(!app.source_rename_envelope(&stale));
|
||||
let task = app.update(Message::SourceRename(stale));
|
||||
assert_eq!(mounted.execute(&app, task).await, 0);
|
||||
assert_eq!(mounted.offset(&app), offset);
|
||||
|
||||
let next = app.update(Message::SourceRename(Event::File(
|
||||
invocation, approval, true,
|
||||
)));
|
||||
assert_eq!(mounted.execute(&app, next).await, 1);
|
||||
assert_eq!(mounted.offset(&app), 0.);
|
||||
assert_eq!(app.source_rename.form.as_ref().unwrap().preview_offset, 0.);
|
||||
assert_eq!(evidence(&app), before);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn diagnostic_publication_during_rename_keeps_actual_preview_report_passive() {
|
||||
let mut app = paged_fixture();
|
||||
app.publish_source_diagnostics(app.source_diagnostics.clone());
|
||||
let publication = app.text_workspace.diagnostic_publication().unwrap();
|
||||
let before = evidence(&app);
|
||||
let interaction = app.source_symbols.interaction;
|
||||
let camera = app.camera_boundary();
|
||||
let allocation = app.allocation_boundary();
|
||||
let cursor = app.source_editor.cursor();
|
||||
let mut mounted = Mounted::new(&app);
|
||||
let reports = mounted.wheel(&app);
|
||||
assert_eq!(reports.len(), 1);
|
||||
let Message::SourceRename(Event::PreviewScrolled(_, _, _, offset)) = &reports[0] else {
|
||||
panic!("actual wheel must publish the retained preview measurement");
|
||||
};
|
||||
assert!(offset.is_finite() && *offset > 0.);
|
||||
app.publish_source_diagnostics(app.source_diagnostics.clone());
|
||||
let current = app.text_workspace.diagnostic_publication().unwrap();
|
||||
assert_ne!(current.generation(), publication.generation());
|
||||
assert_eq!(current.source(), publication.source());
|
||||
assert!(app.source_annotation_snapshot().is_some());
|
||||
assert!(
|
||||
!app.source_navigation_available(),
|
||||
"rename still owns modal input"
|
||||
);
|
||||
for message in reports.iter().cloned() {
|
||||
let task = app.update(message);
|
||||
assert_eq!(mounted.execute(&app, task).await, 0);
|
||||
}
|
||||
assert_eq!(
|
||||
app.source_rename.form.as_ref().unwrap().preview_offset,
|
||||
*offset
|
||||
);
|
||||
assert_eq!(mounted.offset(&app), offset.round());
|
||||
assert_eq!(app.source_symbols.interaction, interaction);
|
||||
assert!(app.camera_boundary() == camera);
|
||||
assert!(app.allocation_boundary() == allocation);
|
||||
assert_eq!(app.source_editor.cursor(), cursor);
|
||||
assert_eq!(evidence(&app), before);
|
||||
}
|
||||
|
||||
@@ -287,7 +287,7 @@ fn alias_editor_sync_fallback_restores_accepted_display_and_preserves_model_hist
|
||||
assert_ne!(app.source_navigation.label(), "0 matches");
|
||||
let mut invalid = original_source.clone();
|
||||
invalid.files.insert(path.clone(), "?".into());
|
||||
app.source_diagnostics = source_workspace::lint(&invalid);
|
||||
app.publish_source_diagnostics(source_workspace::lint(&invalid));
|
||||
assert!(!app.source_diagnostics.is_empty());
|
||||
let version = app.source_version;
|
||||
app.lint_requested = Some(version);
|
||||
@@ -804,13 +804,25 @@ fn captured_escape(app: &App) -> Vec<Message> {
|
||||
.unwrap();
|
||||
let _entered = runtime.enter();
|
||||
let mut messages = Vec::new();
|
||||
let mut keyboard_boundaries = 0;
|
||||
for recipe in subscription::into_recipes(app.subscription()) {
|
||||
let mut output = recipe.stream(Box::pin(stream::iter([event.clone()])));
|
||||
let mut context = PollContext::from_waker(noop_waker_ref());
|
||||
while let Poll::Ready(Some(message)) = output.poll_next_unpin(&mut context) {
|
||||
messages.push(message);
|
||||
if matches!(
|
||||
&message,
|
||||
Message::WorkDrag(crate::app::allocation_drag::Event::KeyboardBoundary)
|
||||
) {
|
||||
keyboard_boundaries += 1;
|
||||
} else {
|
||||
messages.push(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
keyboard_boundaries, 1,
|
||||
"each key has exactly one passive allocation notification"
|
||||
);
|
||||
messages
|
||||
}
|
||||
|
||||
@@ -827,7 +839,8 @@ fn actual_captured_escape_closes_only_its_priority_owner_and_retains_worker() {
|
||||
send(&mut app, Message::SourceRenameReady(ready.clone()));
|
||||
}
|
||||
app.source_navigation.open = true;
|
||||
app.source_symbols.open = true;
|
||||
app.text_workspace
|
||||
.show_drawer(Some(crate::app::text_workspace::DrawerTab::References));
|
||||
match other_modal {
|
||||
1 => app.hosted.visible = true,
|
||||
2 => {
|
||||
@@ -843,16 +856,18 @@ fn actual_captured_escape_closes_only_its_priority_owner_and_retains_worker() {
|
||||
let before = evidence(&app);
|
||||
let mut messages = captured_escape(&app);
|
||||
assert_eq!(messages.len(), 1);
|
||||
assert!(matches!(messages[0], Message::Escape));
|
||||
send(&mut app, messages.remove(0));
|
||||
if other_modal != 0 {
|
||||
assert!(app.source_rename.open(), "existing modal has priority");
|
||||
let mut messages = captured_escape(&app);
|
||||
assert_eq!(messages.len(), 1);
|
||||
assert!(matches!(messages[0], Message::Escape));
|
||||
send(&mut app, messages.remove(0));
|
||||
}
|
||||
assert!(!app.source_rename.open());
|
||||
assert!(
|
||||
app.source_symbols.open && app.source_navigation.open,
|
||||
app.text_workspace.drawer().is_some() && app.source_navigation.open,
|
||||
"rename Escape does not fall through"
|
||||
);
|
||||
if pending {
|
||||
@@ -861,7 +876,7 @@ fn actual_captured_escape_closes_only_its_priority_owner_and_retains_worker() {
|
||||
assert!(!app.source_rename.open());
|
||||
}
|
||||
assert_eq!(evidence(&app), before);
|
||||
app.source_symbols.open = false;
|
||||
app.text_workspace.show_drawer(None);
|
||||
app.source_navigation.open = false;
|
||||
assert!(captured_escape(&app).is_empty());
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user