Compare commits

..
Author SHA1 Message Date
Codex-ytr83r 17f1f89fef Align traceability placement assertions with captured-view reveal 2026-09-22 23:32:36 -07:00
Codex-ytr83r 4fbdf6f385 Correct traceability test type and enabled menu assertions 2026-09-22 23:17:00 -07:00
Codex-ytr83r bfc259854f Explain traceability views and preserve cross-layer placement context 2026-09-22 23:09:20 -07:00
Codex-ytr83r b8685416f5 Preserve read-only panning while canceling State edits 2026-09-22 21:21:50 -07:00
Codex-ytr83r 26979c1093 Enable direct and menu bend editing in State diagrams 2026-09-22 21:01:35 -07:00
Codex-ytr83r 53d01a3d44 Cancel menu-armed State attachment tool on Escape 2026-09-22 19:26:28 -07:00
Codex-ytr83r 3aa80de89c Fix attachment overshoot and semantic trace port geometry 2026-09-22 18:13:07 -07:00
Codex-ytr83r 79cc9d5551 Support per-view connection boundary attachments in ordinary and state diagrams 2026-09-22 17:51:13 -07:00
sorokin.oleg.edu 3f9e6a18af Record portable ordinary-text native acceptance evidence 2026-09-13 07:38:59 -07:00
sorokin.oleg.edu 7836a93edb Record portable ordinary-text integration gate evidence 2026-09-13 07:31:29 -07:00
sorokin.oleg.edu f714a25bbc Retire the obsolete source close event and use current drawer controls in tests 2026-09-13 07:10:23 -07:00
sorokin.oleg.edu cb941367d7 Keep portable evidence hashes valid after Git newline normalization 2026-09-13 06:55:39 -07:00
sorokin.oleg.edu 96b756bece Publish the reviewed ordinary and Text compatibility checkpoint 2026-09-13 06:54:43 -07:00
sorokin.oleg.edu e624d5b9c4 Align the teaching source and language contract with compact defaults 2026-09-13 06:46:08 -07:00
sorokin.oleg.edu d7c065991d Compose ordinary source defaults with the reviewed Text workspace 2026-09-13 06:33:04 -07:00
sorokin.oleg.edu 136ed6e586 Preserve original review inventory bytes in Git 2026-09-13 05:37:32 -07:00
sorokin.oleg.edu 46124ce76c Record Text integration source review and recovery correction 2026-09-13 05:36:04 -07:00
sorokin.oleg.edu 554cfbd9fe Preserve diagnostic source authority when recovery removes save notice 2026-09-13 05:06:50 -07:00
sorokin.oleg.edu 92ff99eb54 Integrate diagnostic decorations and current-source Text input boundaries 2026-09-13 04:47:30 -07:00
sorokin.oleg.edu 16904398db Align root retention assertions with exact current-source drawer ownership 2026-09-13 04:12:18 -07:00
sorokin.oleg.edu 74542ae2c1 Compose compact Text workspace with guarded root navigation 2026-09-13 04:10:27 -07:00
sorokin.oleg.edu feab2020d8 Distinguish reported and rendered preview scroll offsets 2026-09-13 03:30:09 -07:00
sorokin.oleg.edu bebf9f594c Keep rename preview scroll reports outside source boundaries 2026-09-13 02:44:11 -07:00
sorokin.oleg.edu 022d8cda2a Preserve rename preview scroll through full details 2026-09-13 02:27:04 -07:00
sorokin.oleg.edu 357da24542 Sync agent instructions from main 2026-09-13 11:39:49 +03:00
sorokin.oleg.edu 44095f78c2 test: distinguish rename Escape from canvas key notification 2026-09-12 23:46:26 -07:00
sorokin.oleg.edu 0ecdadbb9c Record the executed typed-source checkpoint and retained failures 2026-09-12 23:04:59 -07:00
sorokin.oleg.edu c28abcf13a Account for the canvas keyboard notification in Escape tests 2026-09-12 23:03:31 -07:00
sorokin.oleg.edu 0d8c144355 Use the union's source Undo message in the Escape regression 2026-09-12 22:44:43 -07:00
sorokin.oleg.edu 7f2947d651 fix(text): preserve departure selections and scope Escape dismissal 2026-09-12 22:34:16 -07:00
sorokin.oleg.edu 3412d4f369 Exercise Find selection restoration on the navigation union 2026-09-12 22:24:51 -07:00
sorokin.oleg.edu 5e62d88c4f Preserve live Text selection when restoring Find focus
Keep existing reveal tickets and raw-input guards while separating focus restoration from cursor movement. This separable production patch is extracted from the Text workspace union verified by mounted red/green, full Windows gates and native replacement trials. The CBC-only intermediate tree was not separately built; the following contribution records exact tested union inputs and four-test integration guidance.
2026-09-12 22:16:22 -07:00
sorokin.oleg.edu 13f7e6b0d1 Record analysis and Scenario version integration requirements 2026-09-12 22:00:33 -07:00
sorokin.oleg.edu 29525d7823 Record verified source3 migration bridge and containment gates 2026-09-12 21:51:59 -07:00
sorokin.oleg.edu 0f002b4254 Archive actual staged source checks and unresolved failures 2026-09-12 21:35:51 -07:00
sorokin.oleg.edu 3ac74825f8 Preserve native comparator and migration preparation evidence 2026-09-12 21:17:58 -07:00
sorokin.oleg.edu 64fea0a505 Record combined navigation canvas Windows gate checkpoint 2026-09-12 20:57:53 -07:00
sorokin.oleg.edu e1240295bd Resolve nested union test modules through explicit paths 2026-09-12 20:11:07 -07:00
sorokin.oleg.edu 659b7a655a Reject stale navigation envelopes before shared boundaries 2026-09-12 19:49:27 -07:00
sorokin.oleg.edu a17ca5d066 Combine source navigation and alias rename with canvas ownership guards 2026-09-12 19:49:26 -07:00
sorokin.oleg.edu 95e0e12716 Record lifecycle and palette native acceptance and runtime provenance 2026-09-12 19:49:08 -07:00
sorokin.oleg.edu dd39e1fdc1 Dereference pixel arrays for readonly color comparison 2026-09-12 18:36:16 -07:00
sorokin.oleg.edu f555193172 Use fixed-size pixel chunks in readonly rendering test 2026-09-12 18:31:26 -07:00
sorokin.oleg.edu e44f07565c Lay out mounted overlays before asserting their rendered cues 2026-09-12 17:53:09 -07:00
sorokin.oleg.edu a357b22778 Make custom overlay keyboard targets and controls distinct 2026-09-12 17:42:34 -07:00
sorokin.oleg.edu 05b613ea19 Record palette decisions and passing rendered selection gate 2026-09-12 17:38:46 -07:00
sorokin.oleg.edu afa74a3059 Keep read-only input selections readable and copyable 2026-09-12 17:29:24 -07:00
sorokin.oleg.edu 200aa90689 Preserve exact lifecycle fixture and core preflight evidence 2026-09-12 17:28:38 -07:00
sorokin.oleg.edu 2c6ededc51 Keep process topology node outlines visible 2026-09-12 17:28:20 -07:00
sorokin.oleg.edu bbd3b0e843 Use essential canvas boundaries and outline selected state rows 2026-09-12 17:21:13 -07:00
sorokin.oleg.edu 8c3e7acd14 Strengthen palette roles and source selection contrast 2026-09-12 17:21:13 -07:00
sorokin.oleg.edu 26ca4a4c81 Record lifecycle integration gates and independent native paint review 2026-09-12 17:20:31 -07:00
sorokin.oleg.edu aadc6396d3 Integrate lifecycle rendering with typed scenario activations
Reuse published lifecycle bridge and renderer checks from peer checkpoint 7aa007b. Preserve logical correlation independently from clipped paint spans, terminate executions on receiver death, and retain conservative sibling uncertainty without resurrection.
2026-09-12 16:56:20 -07:00
sorokin.oleg.edu e0685b8e52 Record verified canvas paint correction and integration evidence 2026-09-12 16:55:24 -07:00
sorokin.oleg.edu d61f1f3877 Keep activation borders above message label backplates 2026-09-12 16:00:25 -07:00
sorokin.oleg.edu 419c78c071 Record same-source Windows gates and port-policy falsifiers 2026-09-12 15:43:18 -07:00
sorokin.oleg.edu 83f53c7c6e Update atomic call reply test for typed activation geometry 2026-09-12 15:23:18 -07:00
sorokin.oleg.edu 40154983cc Remove obsolete canvas panel focus mode 2026-09-12 15:06:57 -07:00
sorokin.oleg.edu 8112dea7a3 Preserve canvas correction failures and bound scenario evaluators 2026-09-12 14:59:08 -07:00
sorokin.oleg.edu e2c027e54c Keep state canvas focus tree stable when placement controls change 2026-09-12 07:19:23 -07:00
sorokin.oleg.edu b25065c442 Remove misleading canvas activation controls 2026-09-12 07:07:15 -07:00
sorokin.oleg.edu 956a584132 Render nested call activations with correlation-aware anchors 2026-09-12 07:05:36 -07:00
sorokin.oleg.edu e57c390d49 Record exact native deletion evidence and resize constraint experiments 2026-09-12 06:52:07 -07:00
sorokin.oleg.edu 24bb24839b Preserve diagram focus across edits, history and passive saves 2026-09-12 06:40:43 -07:00
sorokin.oleg.edu f7d84faf30 Scope allocation inspection accessor to tests and record resize falsifiers 2026-09-12 05:56:03 -07:00
sorokin.oleg.edu 260d773852 Remove redundant test event clones and record 818-test pass 2026-09-12 05:49:39 -07:00
sorokin.oleg.edu 42511f0f8a Preserve native focus sequencing in delayed-owner regression 2026-09-12 05:37:27 -07:00
sorokin.oleg.edu c3c2d01453 Consolidate verified state and record derived deletion review 2026-09-12 05:26:33 -07:00
sorokin.oleg.edu 4c387ccd64 Expose scenario tool state to captured removal guards 2026-09-12 05:24:49 -07:00
sorokin.oleg.edu cb46ef4054 Verify real behavior Inspector and keyboard reentry and preserve prior gates 2026-09-12 05:23:36 -07:00
sorokin.oleg.edu 2d9e9c1849 Specify focused removal semantics and document diagram shortcuts 2026-09-12 05:19:31 -07:00
sorokin.oleg.edu 12b2237ee9 Explain captured removal impact and exercise rendered confirmation buttons 2026-09-12 05:18:07 -07:00
sorokin.oleg.edu 49a4cb17b1 Add captured process and scenario canvas removal 2026-09-12 05:17:11 -07:00
sorokin.oleg.edu 28e21a0ae8 Support state relation row removal and verify F2 at widget focus boundary 2026-09-12 05:13:55 -07:00
sorokin.oleg.edu dc1506f2e8 Resolve diagram Delete target after placement properties selection 2026-09-12 05:04:09 -07:00
sorokin.oleg.edu 5b174a9fa6 Correct stale stop notices and describe resumed source checkpoint 2026-09-12 05:01:18 -07:00
sorokin.oleg.edu 0d6f677329 Distinguish active outside-canvas drag feedback from cancellation 2026-09-12 04:59:09 -07:00
sorokin.oleg.edu 60ea483430 WIP: save unverified diagram Delete candidate at user pause 2026-09-12 04:58:44 -07:00
sorokin.oleg.edu 0c38980c70 Save allocation verification and unintegrated Delete handoff at user stop 2026-09-12 04:54:10 -07:00
sorokin.oleg.edu f1bd5149db Record gesture-local name inspection and drop authority boundaries 2026-09-12 04:47:30 -07:00
sorokin.oleg.edu 3840ca2965 Verify tooltip inspection through the installed renderer overlay 2026-09-12 04:46:09 -07:00
sorokin.oleg.edu 5ce798b713 Keep long destination names inspectable without granting stale drop authority 2026-09-12 04:41:50 -07:00
sorokin.oleg.edu 1b3c401a19 Keep allocation destinations readable without blocking collapsed header drops 2026-09-12 04:34:02 -07:00
sorokin.oleg.edu 76209cc186 Align allocation guide and graphical contract with destination feedback 2026-09-12 04:31:15 -07:00
sorokin.oleg.edu 5a7018117d Archive combined Windows allocation and repair baseline with review limits 2026-09-12 04:27:56 -07:00
sorokin.oleg.edu 85173a3c95 Refine allocation feedback to preserve collapsed-target reachability 2026-09-12 04:26:05 -07:00
sorokin.oleg.edu d9b3f0dc91 Format saved allocation readability regressions 2026-09-12 04:18:42 -07:00
sorokin.oleg.edu 94b5eecb1c Merge remote-tracking branch 'refs/remotes/gitea/release1-956atf' into codex/behavior-authoring-20260910 2026-09-12 04:18:04 -07:00
sorokin.oleg.edu 6b9cc77ece Save unverified allocation and repair work at user-requested pause 2026-09-12 04:01:44 -07:00
sorokin.oleg.edu bc8bc4f5a1 Archive exact a31b40d Windows and OA native acceptance evidence 2026-09-12 03:54:36 -07:00
sorokin.oleg.edu f52d81c1b9 Integrate allocation drag candidate with guarded input and atomic history 2026-09-12 03:47:45 -07:00
sorokin.oleg.edu 97c91cc06c Sync agent instructions from main 2026-09-12 13:43:41 +03:00
sorokin.oleg.edu 0bd7886608 Merge branch 'codex/behavior-authoring-20260910' of http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio into codex/behavior-authoring-20260910 2026-09-12 03:43:17 -07:00
sorokin.oleg.edu 563772dc62 Sync agent instructions from main 2026-09-12 13:43:11 +03:00
sorokin.oleg.edu eefdedaf47 Merge 2026-09-12 03:41:44 -07:00
sorokin.oleg.edu bc88acd932 Sync agent instructions from main 2026-09-12 13:40:24 +03:00
sorokin.oleg.edu 17d56c06f7 Sync agent instructions from main 2026-09-12 13:39:54 +03:00
sorokin.oleg.edu bcb7057ab2 Sync agent instructions from main 2026-09-12 13:16:01 +03:00
sorokin.oleg.edu 36eff7225a Sync agent instructions from main 2026-09-12 13:15:23 +03:00
sorokin.oleg.edu dd38c46a4b Record user pause and exact Windows acceptance handoff 2026-09-11 12:28:15 -07:00
sorokin.oleg.edu a31b40d0f8 Retire obsolete behavior canvas scroll and tool paths 2026-09-11 11:28:08 -07:00
sorokin.oleg.edu 88c17a448f Record verified canvas union gates and allocation drag contract 2026-09-11 11:24:41 -07:00
sorokin.oleg.edu abffa4b3f0 Simplify canvas control focus guard for strict lint 2026-09-11 11:08:37 -07:00
sorokin.oleg.edu 82fd0acf64 Update behavior guide for canvas palette and camera controls 2026-09-11 11:03:39 -07:00
sorokin.oleg.edu ff0175488a Keep concrete canvas App access within the desktop crate 2026-09-11 10:55:15 -07:00
sorokin.oleg.edu 06ec962129 Adapt mounted canvas tests to installed renderer and workflow APIs 2026-09-11 10:48:35 -07:00
sorokin.oleg.edu 6969c825a8 Integrate shared behavior canvas with current form focus and recovery 2026-09-11 10:45:44 -07:00
sorokin.oleg.edu 638d06c4e3 Merge remote-tracking branch 'gitea/release1-956atf' into codex/behavior-authoring-20260910 2026-09-11 10:30:29 -07:00
sorokin.oleg.edu 41fa5d530c Record Windows scenario and codec gates with shared handoff 2026-09-11 10:29:03 -07:00
sorokin.oleg.edu 828c50c491 Merge updated user goal and shared working agreement 2026-09-11 10:05:23 -07:00
sorokin.oleg.edu f8a6a5264a Sync agent instructions from main 2026-09-11 19:52:09 +03:00
sorokin.oleg.edu b4c8fd7102 Sync agent instructions from main 2026-09-11 19:51:34 +03:00
Codex e7491fb00a test(desktop): preserve final 720-test and strict lint acceptance 2026-09-11 09:25:52 -07:00
Codex bef1ea33e4 docs(harness): record Windows gates and defer Linux execution 2026-09-11 09:12:30 -07:00
Codex 3c9e6eeb64 fix(review): keep workflow messages compact 2026-09-11 09:08:46 -07:00
Codex 80f2588214 fix(forms): reset replacement sessions and preserve visible scroll 2026-09-11 08:33:06 -07:00
Codex 8895dd2e0f fix(scenario): restore focus only to the current parent form 2026-09-11 08:07:41 -07:00
Codex 8d55d2d2eb Save complete restart handoff for user-requested reboot 2026-09-11 06:47:37 -07:00
Codex 03d20ec296 Preserve exact numeric gate logs and bounded decoder review 2026-09-11 06:41:33 -07:00
sorokin.oleg.edu c0e66f94e3 Stage explicit prerequisite sources inside behavior creation forms 2026-09-11 06:26:16 -07:00
Codex 7f081ad457 Integrate scoped reference chooser and scenario form focus and reveal 2026-09-11 06:24:20 -07:00
Codex c7ed894900 Merge shared instruction checkpoint preserving exact authoritative goal 2026-09-11 06:14:11 -07:00
Codex 98427b9620 Adopt shared Gitea and harness maintenance instructions 2026-09-11 06:14:05 -07:00
sorokin.oleg.edu cab01c3d7f Sync agent instructions from main 2026-09-11 15:55:34 +03:00
sorokin.oleg.edu d8bdbd9e48 Sync agent instructions from main 2026-09-11 15:55:01 +03:00
sorokin.oleg.edu 1e9f0c2a97 Sync agent instructions from main 2026-09-11 14:34:18 +03:00
1810 changed files with 549922 additions and 18309 deletions
+44 -2
View File
@@ -35,6 +35,13 @@ 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/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
@@ -44,6 +51,41 @@ harness/experiments/behavior-creation-native-20260911/** -text whitespace=cr-at-
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
# Preserve the independently reviewed codec handoff bytes.
harness/release-analysis-codec-implementation-20260911.md -text whitespace=-blank-at-eof,cr-at-eol
# Preserve the pinned Iced dependency bytes and upstream manifest.
crates/vendor/iced_widget/** -text whitespace=cr-at-eol
Generated
+1 -44
View File
@@ -65,15 +65,6 @@ dependencies = [
"libc",
]
[[package]]
name = "ar_archive_writer"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73cd58deff2140a0a8eae87e417bd01db68a33e148aa93d1e8cd837e55e312b6"
dependencies = [
"object",
]
[[package]]
name = "arrayref"
version = "0.3.9"
@@ -1576,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",
@@ -2187,7 +2176,6 @@ dependencies = [
"same-file",
"serde",
"serde_json",
"stacker",
"tempfile",
"thiserror 2.0.20",
"uuid",
@@ -2208,6 +2196,7 @@ dependencies = [
"serde_json",
"tempfile",
"tokio",
"unicode-segmentation",
]
[[package]]
@@ -2518,15 +2507,6 @@ dependencies = [
"objc2-foundation 0.2.2",
]
[[package]]
name = "object"
version = "0.39.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e5a6c098c7a3b6547378093f5cc30bc54fd361ce711e05293a5cc589562739b"
dependencies = [
"memchr",
]
[[package]]
name = "once_cell"
version = "1.21.4"
@@ -2750,16 +2730,6 @@ version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5"
[[package]]
name = "psm"
version = "0.1.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4dcd034599e63b970727f70d79e02d62390a4a84f7c6b827c27c46d5ac3fa622"
dependencies = [
"ar_archive_writer",
"cc",
]
[[package]]
name = "quick-xml"
version = "0.41.0"
@@ -3480,19 +3450,6 @@ version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]]
name = "stacker"
version = "0.1.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "707f49d46706bacf8a2b00d51dace3f9de527c13eec3778f570c411f89e69967"
dependencies = [
"cc",
"cfg-if",
"libc",
"psm",
"windows-sys 0.61.2",
]
[[package]]
name = "static_assertions"
version = "1.1.0"
+4
View File
@@ -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"
+2 -2
View File
@@ -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()
+1 -2
View File
@@ -7,8 +7,7 @@ rust-version.workspace = true
[dependencies]
serde.workspace = true
serde_json = { workspace = true, features = ["unbounded_depth"] }
stacker = "0.1.25"
serde_json.workspace = true
uuid.workspace = true
thiserror.workspace = true
tempfile = "3"
@@ -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,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(())
}
-58
View File
@@ -1,58 +0,0 @@
//! Schema 8 engineering definitions and static admission. No expression evaluation.
mod admission;
mod expression_lifecycle;
pub(crate) mod expression_serde;
pub mod references;
mod resolve;
mod typecheck;
mod types;
pub(crate) use admission::admit_structure_checked;
pub use admission::{AdmissionLimits, AdmissionUsage, admit};
pub use resolve::{EffectiveBinding, KindHandle, ResolvedAnalysis, ResolvedScalar, ResolvedType};
pub use types::*;
/// Ratified semantic bounds. Aggregate admission work is a separate caller policy.
pub const MAX_EXPRESSION_DEPTH: usize = 128;
pub const MAX_CLOSURE_NODES: usize = 65_536;
pub const MAX_LIST_LENGTH: usize = 1_024;
pub const MAX_CALL_DEPTH: usize = 32;
/// Additional persisted records for existing transport ParseLimits. Definitions
/// themselves count as graph objects there; this counts parameters, overrides,
/// and every stored expression node, with saturating arithmetic and no recursion.
pub(crate) fn nested_record_count(project: &crate::Project) -> usize {
let mut count = 0usize;
fn binding(value: &Binding, count: &mut usize) {
if let Binding::Expression(expression) = value {
references::walk(expression, &mut |_| {
*count = count.saturating_add(1);
});
}
}
for definition in project.definitions.values() {
if let DefinitionBody::Calculation(calculation) = &definition.body {
count = count.saturating_add(calculation.parameters.len());
binding(&calculation.body, &mut count);
}
}
for element in project.elements.values() {
match element.shared() {
Some(crate::SharedBody::EngineeringProperty { base, .. }) => binding(base, &mut count),
Some(crate::SharedBody::AnalysisCase { overrides, .. }) => {
count = count.saturating_add(overrides.len());
for value in overrides.values() {
binding(value, &mut count);
}
}
Some(crate::SharedBody::AnalysisCheck { predicate, .. }) => {
binding(predicate, &mut count)
}
_ => {}
}
}
count
}
#[cfg(test)]
mod tests;
File diff suppressed because it is too large Load Diff
@@ -1,377 +0,0 @@
//! Ordinary expression ownership never consumes stack proportional to tree depth.
//! This also covers a partially decoded expression and rejected programmatic input.
use super::Expr;
use std::{fmt, mem};
impl Expr {
fn clone_shell(&self) -> Self {
let leaf = || Box::new(Self::Boolean(false));
match self {
Self::Boolean(value) => Self::Boolean(*value),
Self::Number(value) => Self::Number(value.clone()),
Self::Quantity { magnitude, unit } => Self::Quantity {
magnitude: magnitude.clone(),
unit: *unit,
},
Self::Property(id) => Self::Property(*id),
Self::Parameter(id) => Self::Parameter(*id),
Self::List(values) => {
Self::List((0..values.len()).map(|_| Self::Boolean(false)).collect())
}
Self::Unary { op, .. } => Self::Unary {
op: *op,
operand: leaf(),
},
Self::Binary { op, .. } => Self::Binary {
op: *op,
left: leaf(),
right: leaf(),
},
Self::If { .. } => Self::If {
condition: leaf(),
when_true: leaf(),
when_false: leaf(),
},
Self::Call {
definition,
arguments,
} => Self::Call {
definition: *definition,
arguments: arguments
.keys()
.map(|id| (*id, Self::Boolean(false)))
.collect(),
},
Self::Sum { .. } => Self::Sum {
values: leaf(),
initial: leaf(),
},
Self::Power { exponent, .. } => Self::Power {
base: leaf(),
exponent: *exponent,
},
Self::AsKind { kind, .. } => Self::AsKind {
kind: *kind,
value: leaf(),
},
}
}
fn detach_children(&mut self, pending: &mut Vec<Self>) {
let mut take = |child: &mut Box<Self>| {
pending.push(mem::replace(child.as_mut(), Self::Boolean(false)));
};
match self {
Self::Unary { operand, .. } => take(operand),
Self::Binary { left, right, .. } => {
take(left);
take(right);
}
Self::If {
condition,
when_true,
when_false,
} => {
take(condition);
take(when_true);
take(when_false);
}
Self::Sum { values, initial } => {
take(values);
take(initial);
}
Self::Power { base, .. } => take(base),
Self::AsKind { value, .. } => take(value),
Self::List(values) => pending.append(values),
Self::Call { arguments, .. } => {
pending.extend(mem::take(arguments).into_values());
}
Self::Boolean(_)
| Self::Number(_)
| Self::Quantity { .. }
| Self::Property(_)
| Self::Parameter(_) => {}
}
}
}
impl Drop for Expr {
fn drop(&mut self) {
let mut pending = Vec::new();
self.detach_children(&mut pending);
while let Some(mut node) = pending.pop() {
node.detach_children(&mut pending);
// node now owns only leaves/empty collections. Its ordinary drop
// reenters this method at most once, independently of original depth.
}
}
}
impl Clone for Expr {
fn clone(&self) -> Self {
let mut result = Self::Boolean(false);
let mut pending = vec![(self, &mut result)];
while let Some((source, destination)) = pending.pop() {
*destination = source.clone_shell();
match (source, destination) {
(Self::List(a), Self::List(b)) => pending.extend(a.iter().zip(b.iter_mut())),
(Self::Unary { operand: a, .. }, Self::Unary { operand: b, .. })
| (Self::Power { base: a, .. }, Self::Power { base: b, .. })
| (Self::AsKind { value: a, .. }, Self::AsKind { value: b, .. }) => {
pending.push((a, b));
}
(
Self::Binary {
left: a, right: b, ..
},
Self::Binary {
left: c, right: d, ..
},
)
| (
Self::Sum {
values: a,
initial: b,
},
Self::Sum {
values: c,
initial: d,
},
) => {
pending.push((a, c));
pending.push((b, d));
}
(
Self::If {
condition: a,
when_true: b,
when_false: c,
},
Self::If {
condition: d,
when_true: e,
when_false: f,
},
) => {
pending.push((a, d));
pending.push((b, e));
pending.push((c, f));
}
(Self::Call { arguments: a, .. }, Self::Call { arguments: b, .. }) => {
pending.extend(a.values().zip(b.values_mut()));
}
_ => {}
}
}
result
}
}
impl PartialEq for Expr {
fn eq(&self, other: &Self) -> bool {
let mut pending = vec![(self, other)];
while let Some((a, b)) = pending.pop() {
match (a, b) {
(Self::Boolean(a), Self::Boolean(b)) if a == b => {}
(Self::Number(a), Self::Number(b)) if a == b => {}
(
Self::Quantity {
magnitude: a,
unit: b,
},
Self::Quantity {
magnitude: c,
unit: d,
},
) if a == c && b == d => {}
(Self::Property(a), Self::Property(b)) if a == b => {}
(Self::Parameter(a), Self::Parameter(b)) if a == b => {}
(Self::List(a), Self::List(b)) if a.len() == b.len() => {
pending.extend(a.iter().zip(b.iter()));
}
(Self::Unary { op: a, operand: b }, Self::Unary { op: c, operand: d })
if a == c =>
{
pending.push((b, d));
}
(
Self::Binary {
op: a,
left: b,
right: c,
},
Self::Binary {
op: d,
left: e,
right: f,
},
) if a == d => {
pending.push((b, e));
pending.push((c, f));
}
(
Self::If {
condition: a,
when_true: b,
when_false: c,
},
Self::If {
condition: d,
when_true: e,
when_false: f,
},
) => {
pending.push((a, d));
pending.push((b, e));
pending.push((c, f));
}
(
Self::Call {
definition: a,
arguments: b,
},
Self::Call {
definition: c,
arguments: d,
},
) if a == c && b.len() == d.len() => {
for ((key_a, value_a), (key_b, value_b)) in b.iter().zip(d.iter()) {
if key_a != key_b {
return false;
}
pending.push((value_a, value_b));
}
}
(
Self::Sum {
values: a,
initial: b,
},
Self::Sum {
values: c,
initial: d,
},
) => {
pending.push((a, c));
pending.push((b, d));
}
(
Self::Power {
base: a,
exponent: b,
},
Self::Power {
base: c,
exponent: d,
},
) if b == d => {
pending.push((a, c));
}
(Self::AsKind { kind: a, value: b }, Self::AsKind { kind: c, value: d })
if a == c =>
{
pending.push((b, d));
}
_ => return false,
}
}
true
}
}
impl Eq for Expr {}
impl fmt::Debug for Expr {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
enum Task<'a> {
Node(&'a Expr),
Text(&'static str),
Scalar(&'a dyn fmt::Debug),
}
use Task::*;
let mut pending = vec![Node(self)];
while let Some(task) = pending.pop() {
match task {
Text(text) => f.write_str(text)?,
Scalar(value) => write!(f, "{value:?}")?,
Node(node) => match node {
Self::Boolean(value) => write!(f, "Boolean({value:?})")?,
Self::Number(value) => write!(f, "Number({value:?})")?,
Self::Quantity { magnitude, unit } => {
write!(f, "Quantity {{ magnitude: {magnitude:?}, unit: {unit:?} }}")?;
}
Self::Property(id) => write!(f, "Property({id:?})")?,
Self::Parameter(id) => write!(f, "Parameter({id:?})")?,
Self::List(values) => {
f.write_str("List([")?;
pending.push(Text("])"));
for (index, value) in values.iter().enumerate().rev() {
pending.push(Node(value));
if index > 0 {
pending.push(Text(", "));
}
}
}
Self::Unary { op, operand } => {
write!(f, "Unary {{ op: {op:?}, operand: ")?;
pending.extend([Text(" }"), Node(operand)]);
}
Self::Binary { op, left, right } => {
write!(f, "Binary {{ op: {op:?}, left: ")?;
pending.extend([Text(" }"), Node(right), Text(", right: "), Node(left)]);
}
Self::If {
condition,
when_true,
when_false,
} => {
f.write_str("If { condition: ")?;
pending.extend([
Text(" }"),
Node(when_false),
Text(", when_false: "),
Node(when_true),
Text(", when_true: "),
Node(condition),
]);
}
Self::Call {
definition,
arguments,
} => {
write!(f, "Call {{ definition: {definition:?}, arguments: {{")?;
pending.push(Text("} }"));
for (index, (id, value)) in arguments.iter().enumerate().rev() {
pending.extend([Node(value), Text(": "), Scalar(id)]);
if index > 0 {
pending.push(Text(", "));
}
}
}
Self::Sum { values, initial } => {
f.write_str("Sum { values: ")?;
pending.extend([
Text(" }"),
Node(initial),
Text(", initial: "),
Node(values),
]);
}
Self::Power { base, exponent } => {
f.write_str("Power { base: ")?;
pending.extend([
Text(" }"),
Scalar(exponent),
Text(", exponent: "),
Node(base),
]);
}
Self::AsKind { kind, value } => {
write!(f, "AsKind {{ kind: {kind:?}, value: ")?;
pending.extend([Text(" }"), Node(value)]);
}
},
}
}
Ok(())
}
}
@@ -1,261 +0,0 @@
//! The existing externally tagged Serde schema, with admission at each Expr edge.
//! The private remote derive is the sole wire grammar: it still rejects unknown
//! fields/variants, duplicate fields and malformed UUIDs/Exact values.
use super::{
BinaryOp, DefinitionRef, Expr, MAX_CLOSURE_NODES, MAX_EXPRESSION_DEPTH, MAX_LIST_LENGTH,
ParameterId, UnaryOp,
};
use crate::{Id, analysis_numeric::Exact};
use serde::{
Deserialize, Serialize,
de::{DeserializeSeed, MapAccess, SeqAccess, Visitor},
};
use std::{cell::Cell, collections::BTreeMap, fmt};
pub(crate) const MAX_JSON_EXPRESSION_NODES: usize = 262_144;
// These are requested segment capacities, not an allocator or RSS guarantee.
const RED_ZONE: usize = 64 * 1024;
const STACK_SEGMENT: usize = 512 * 1024;
pub(crate) fn with_stack<R>(operation: impl FnOnce() -> R) -> R {
stacker::maybe_grow(RED_ZONE, STACK_SEGMENT, operation)
}
#[derive(Clone, Copy)]
struct Meter {
active: bool,
depth: usize,
nodes: usize,
tree_nodes: usize,
maximum: usize,
}
impl Meter {
const DEFAULT: Self = Self {
active: false,
depth: 0,
nodes: 0,
tree_nodes: 0,
maximum: MAX_JSON_EXPRESSION_NODES,
};
}
thread_local! {
static DECODING: Cell<Meter> = const { Cell::new(Meter::DEFAULT) };
static ENCODING: Cell<Meter> = const { Cell::new(Meter::DEFAULT) };
}
type LocalMeter = &'static std::thread::LocalKey<Cell<Meter>>;
/// One codec operation owns the aggregate meter across all disjoint bindings.
/// Scope restoration also runs on errors and unwinding; no mutable TLS borrow is
/// held across Serde/user code or a stack switch.
pub(crate) struct Scope {
local: LocalMeter,
previous: Meter,
}
impl Scope {
pub(crate) fn decoding(maximum: usize) -> Self {
Self::new(&DECODING, maximum)
}
pub(crate) fn encoding(maximum: usize) -> Self {
Self::new(&ENCODING, maximum)
}
fn new(local: LocalMeter, maximum: usize) -> Self {
let next = Meter {
active: true,
maximum: maximum.min(MAX_JSON_EXPRESSION_NODES),
..Meter::DEFAULT
};
let previous = local.with(|meter| meter.replace(next));
Self { local, previous }
}
}
impl Drop for Scope {
fn drop(&mut self) {
self.local.with(|meter| meter.set(self.previous));
}
}
struct NodeGuard {
local: LocalMeter,
root: bool,
}
impl NodeGuard {
fn enter(local: LocalMeter) -> Result<Self, &'static str> {
local.with(|slot| {
let mut meter = slot.get();
let root = !meter.active;
if root {
meter = Meter {
active: true,
..Meter::DEFAULT
};
}
if meter.depth == 0 {
meter.tree_nodes = 0;
}
if meter.depth >= MAX_EXPRESSION_DEPTH {
return Err("Expression logical depth exceeds 128.");
}
if meter.nodes >= meter.maximum {
return Err("JSON expression allocation count exceeded.");
}
if meter.tree_nodes >= MAX_CLOSURE_NODES {
return Err("Stored expression node count exceeds 65536.");
}
meter.depth += 1;
meter.nodes += 1;
meter.tree_nodes += 1;
slot.set(meter);
Ok(Self { local, root })
})
}
}
impl Drop for NodeGuard {
fn drop(&mut self) {
self.local.with(|slot| {
let mut meter = slot.get();
meter.depth -= 1;
if self.root {
meter.active = false;
}
slot.set(meter);
});
}
}
impl<'de> Deserialize<'de> for Expr {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let _node = NodeGuard::enter(&DECODING).map_err(serde::de::Error::custom)?;
with_stack(|| WireExpr::deserialize(deserializer))
}
}
impl Serialize for Expr {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
let _node = NodeGuard::enter(&ENCODING).map_err(serde::ser::Error::custom)?;
match self {
Self::List(values) if values.len() > MAX_LIST_LENGTH => {
return Err(serde::ser::Error::custom(
"Expression List exceeds 1024 items.",
));
}
Self::Call { arguments, .. } if arguments.len() > MAX_CLOSURE_NODES => {
return Err(serde::ser::Error::custom(
"Call argument count exceeds 65536.",
));
}
_ => {}
}
with_stack(|| WireExpr::serialize(self, serializer))
}
}
// A private remote derive avoids exposing an unguarded inherent deserialize on
// public Expr. Children remain Expr, so every recursive edge enters the guard.
#[derive(Serialize, Deserialize)]
#[serde(remote = "Expr", deny_unknown_fields)]
enum WireExpr {
Boolean(bool),
Number(Exact),
Quantity {
magnitude: Exact,
unit: DefinitionRef,
},
Property(Id),
Parameter(ParameterId),
List(#[serde(deserialize_with = "bounded_list")] Vec<Expr>),
Unary {
op: UnaryOp,
operand: Box<Expr>,
},
Binary {
op: BinaryOp,
left: Box<Expr>,
right: Box<Expr>,
},
If {
condition: Box<Expr>,
when_true: Box<Expr>,
when_false: Box<Expr>,
},
Call {
definition: DefinitionRef,
#[serde(deserialize_with = "bounded_arguments")]
arguments: BTreeMap<ParameterId, Expr>,
},
Sum {
values: Box<Expr>,
initial: Box<Expr>,
},
Power {
base: Box<Expr>,
exponent: i8,
},
AsKind {
kind: DefinitionRef,
value: Box<Expr>,
},
}
struct ListItem {
allowed: bool,
}
impl<'de> DeserializeSeed<'de> for ListItem {
type Value = Expr;
fn deserialize<D: serde::Deserializer<'de>>(self, deserializer: D) -> Result<Expr, D::Error> {
if !self.allowed {
return Err(serde::de::Error::custom(
"Expression List exceeds 1024 items.",
));
}
Expr::deserialize(deserializer)
}
}
fn bounded_list<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Result<Vec<Expr>, D::Error> {
struct ListVisitor;
impl<'de> Visitor<'de> for ListVisitor {
type Value = Vec<Expr>;
fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("at most 1024 expression items")
}
fn visit_seq<A: SeqAccess<'de>>(self, mut access: A) -> Result<Self::Value, A::Error> {
let mut values = Vec::new();
while let Some(value) = access.next_element_seed(ListItem {
allowed: values.len() < MAX_LIST_LENGTH,
})? {
values.push(value);
}
Ok(values)
}
}
deserializer.deserialize_seq(ListVisitor)
}
fn bounded_arguments<'de, D: serde::Deserializer<'de>>(
deserializer: D,
) -> Result<BTreeMap<ParameterId, Expr>, D::Error> {
struct Arguments;
impl<'de> Visitor<'de> for Arguments {
type Value = BTreeMap<ParameterId, Expr>;
fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("unique scoped parameter UUID keys")
}
fn visit_map<A: MapAccess<'de>>(self, mut access: A) -> Result<Self::Value, A::Error> {
let mut values = BTreeMap::new();
while let Some(id) = access.next_key::<ParameterId>()? {
if values.contains_key(&id) {
return Err(serde::de::Error::custom(format!(
"Duplicate parameter ID {}",
id.0
)));
}
if values.len() >= MAX_CLOSURE_NODES {
return Err(serde::de::Error::custom(
"Call argument count exceeds 65536.",
));
}
// Count/duplicate checks precede construction of the next subtree.
values.insert(id, access.next_value()?);
}
Ok(values)
}
}
deserializer.deserialize_map(Arguments)
}
-246
View File
@@ -1,246 +0,0 @@
//! Typed live reference domains; parameter IDs and opaque historical captures are separate.
use super::*;
use crate::{Element, Id, Project, SharedBody};
use std::collections::BTreeSet;
pub fn expression_properties(expression: &Expr) -> BTreeSet<Id> {
let mut found = BTreeSet::new();
walk(expression, &mut |node| {
if let Expr::Property(id) = node {
found.insert(*id);
}
});
found
}
pub fn expression_definitions(expression: &Expr) -> BTreeSet<Id> {
let mut found = BTreeSet::new();
walk(expression, &mut |node| match node {
Expr::Quantity { unit, .. } => {
found.insert(unit.local());
}
Expr::Call { definition, .. } => {
found.insert(definition.local());
}
Expr::AsKind { kind, .. } => {
found.insert(kind.local());
}
_ => {}
});
found
}
pub(crate) fn walk<'a>(expression: &'a Expr, visit: &mut impl FnMut(&'a Expr)) {
let mut pending = vec![expression];
while let Some(node) = pending.pop() {
visit(node);
match node {
Expr::List(items) => pending.extend(items.iter().rev()),
Expr::Unary { operand, .. } => pending.push(operand),
Expr::Binary { left, right, .. } => {
pending.push(right);
pending.push(left);
}
Expr::If {
condition,
when_true,
when_false,
} => {
pending.push(when_false);
pending.push(when_true);
pending.push(condition);
}
Expr::Call { arguments, .. } => pending.extend(arguments.values().rev()),
Expr::Sum { values, initial } => {
pending.push(values);
pending.push(initial);
}
Expr::Power { base, .. } => pending.push(base),
Expr::AsKind { value, .. } => pending.push(value),
_ => {}
}
}
}
fn walk_mut(expression: &mut Expr, visit: &mut impl FnMut(&mut Expr)) {
let mut pending = vec![expression];
while let Some(node) = pending.pop() {
visit(node);
match node {
Expr::List(items) => pending.extend(items.iter_mut()),
Expr::Unary { operand, .. } => pending.push(operand),
Expr::Binary { left, right, .. } => {
pending.push(left);
pending.push(right);
}
Expr::If {
condition,
when_true,
when_false,
} => {
pending.push(condition);
pending.push(when_true);
pending.push(when_false);
}
Expr::Call { arguments, .. } => pending.extend(arguments.values_mut()),
Expr::Sum { values, initial } => {
pending.push(values);
pending.push(initial);
}
Expr::Power { base, .. } => pending.push(base),
Expr::AsKind { value, .. } => pending.push(value),
_ => {}
}
}
}
pub fn element_model_references(element: &Element) -> BTreeSet<Id> {
let mut result = BTreeSet::new();
let mut binding = |value: &Binding| {
if let Binding::Expression(expression) = value {
result.extend(expression_properties(expression));
}
};
match element.shared() {
Some(SharedBody::EngineeringProperty { base, .. }) => binding(base),
Some(SharedBody::AnalysisCase { overrides, .. }) => {
for value in overrides.values() {
binding(value);
}
}
Some(SharedBody::AnalysisCheck { predicate, .. }) => binding(predicate),
_ => {}
}
match element.shared() {
Some(SharedBody::EngineeringProperty { subject, .. }) => {
result.insert(subject.id());
}
Some(SharedBody::AnalysisCase {
subject, overrides, ..
}) => {
result.extend(subject.map(AnalysisSubject::id));
result.extend(overrides.keys());
}
Some(SharedBody::AnalysisCheck {
subject,
documented_constraint,
..
}) => {
result.extend(subject.map(AnalysisSubject::id));
result.extend(documented_constraint);
}
_ => {}
}
result
}
fn remap_ref(reference: &mut DefinitionRef, old: Id, new: Id) {
let DefinitionRef::Local(id) = reference;
if *id == old {
*id = new;
}
}
fn remap_type(value: &mut AnalysisType, old: Id, new: Id) {
let (AnalysisType::Scalar(scalar) | AnalysisType::List(scalar)) = value;
if let ScalarType::Quantity(reference) = scalar {
remap_ref(reference, old, new);
}
}
fn remap_binding(value: &mut Binding, old: Id, new: Id) {
if let Binding::Expression(expression) = value {
walk_mut(expression, &mut |node| match node {
Expr::Property(id) if *id == old => *id = new,
Expr::Quantity { unit, .. } => remap_ref(unit, old, new),
Expr::Call { definition, .. } => remap_ref(definition, old, new),
Expr::AsKind { kind, .. } => remap_ref(kind, old, new),
_ => {} // Parameter nodes and argument keys are scoped, never global.
});
}
}
fn remap_subject(subject: &mut AnalysisSubject, old: Id, new: Id) {
let (AnalysisSubject::Element(id) | AnalysisSubject::Relation(id)) = subject;
if *id == old {
*id = new;
}
}
/// Check before any mutation so a map-key collision never silently discards a binding.
pub(crate) fn check_remap(project: &Project, old: Id, new: Id) -> Result<(), AnalysisError> {
if old != new {
for element in project.elements.values() {
if let Some(SharedBody::AnalysisCase { overrides, .. }) = element.shared()
&& overrides.contains_key(&old)
&& overrides.contains_key(&new)
{
return Err(AnalysisError::new(
element.id,
"overrides",
"analysis_reference_collision",
"Reidentification would merge two distinct override keys.",
));
}
}
}
Ok(())
}
pub(crate) fn remap_project(project: &mut Project, old: Id, new: Id) {
for element in project.elements.values_mut() {
match element.shared_mut() {
Some(SharedBody::EngineeringProperty {
subject,
value_type,
display_unit,
base,
}) => {
remap_subject(subject, old, new);
remap_type(value_type, old, new);
if let Some(reference) = display_unit {
remap_ref(reference, old, new);
}
remap_binding(base, old, new);
}
Some(SharedBody::AnalysisCase {
subject, overrides, ..
}) => {
if let Some(subject) = subject {
remap_subject(subject, old, new);
}
if old != new
&& let Some(value) = overrides.remove(&old)
{
overrides.insert(new, value);
}
for value in overrides.values_mut() {
remap_binding(value, old, new);
}
}
Some(SharedBody::AnalysisCheck {
subject,
documented_constraint,
predicate,
}) => {
if let Some(subject) = subject {
remap_subject(subject, old, new);
}
if *documented_constraint == Some(old) {
*documented_constraint = Some(new);
}
remap_binding(predicate, old, new);
}
_ => {}
}
}
for definition in project.definitions.values_mut() {
match &mut definition.body {
DefinitionBody::QuantityKind(QuantityKindDefinition {
algebra: QuantityAlgebra::Affine { difference },
..
}) => remap_ref(difference, old, new),
DefinitionBody::Unit(unit) => remap_ref(&mut unit.kind, old, new),
DefinitionBody::Calculation(calculation) => {
for parameter in &mut calculation.parameters {
remap_type(&mut parameter.value_type, old, new);
}
remap_type(&mut calculation.result, old, new);
remap_binding(&mut calculation.body, old, new);
}
_ => {}
}
}
}
-146
View File
@@ -1,146 +0,0 @@
use super::*;
use crate::analysis_numeric::{NumericType, ResolvedKind, Unit};
use crate::{Id, Project, SharedBody};
use std::collections::BTreeMap;
/// Bounded local nominal origin; no rooted graph is cloned during arithmetic.
/// Full ResolvedKind equality additionally includes dimension/algebra/difference.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct KindHandle {
model: Id,
definition: Id,
}
impl KindHandle {
pub fn definition(self) -> Id {
self.definition
}
pub(crate) fn local(model: Id, definition: Id) -> Self {
Self { model, definition }
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ResolvedScalar {
Boolean,
Numeric(NumericType<KindHandle>),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ResolvedType {
Scalar(ResolvedScalar),
List(ResolvedScalar),
}
impl ResolvedType {
pub(crate) fn boolean() -> Self {
Self::Scalar(ResolvedScalar::Boolean)
}
pub(crate) fn numeric(value: NumericType<KindHandle>) -> Self {
Self::Scalar(ResolvedScalar::Numeric(value.normalized()))
}
}
#[derive(Debug, Clone, Copy)]
pub struct EffectiveBinding<'a> {
pub binding: &'a Binding,
pub origin: BindingOrigin,
}
/// Constructed only by admission; exact source/capture provenance stays with its owner.
/// Values from different accepted environments must not be mixed by an evaluator.
pub struct ResolvedAnalysis<'p> {
pub(super) project: &'p Project,
pub(super) kinds: BTreeMap<Id, ResolvedKind<KindHandle>>,
pub(super) units: BTreeMap<Id, Unit<KindHandle>>,
pub(super) limits: AdmissionLimits,
pub(super) usage: AdmissionUsage,
}
impl<'p> ResolvedAnalysis<'p> {
pub fn project(&self) -> &'p Project {
self.project
}
pub fn admission_usage(&self) -> AdmissionUsage {
self.usage
}
pub fn admission_limits(&self) -> AdmissionLimits {
self.limits
}
pub fn definition(&self, reference: DefinitionRef) -> Option<&'p Definition> {
self.project.definitions.get(&reference.local())
}
pub fn kind(&self, reference: DefinitionRef) -> Option<&ResolvedKind<KindHandle>> {
self.kinds.get(&reference.local())
}
pub fn unit(&self, reference: DefinitionRef) -> Option<&Unit<KindHandle>> {
self.units.get(&reference.local())
}
pub fn calculation(&self, reference: DefinitionRef) -> Option<&'p CalculationDefinition> {
match &self.definition(reference)?.body {
DefinitionBody::Calculation(value) => Some(value),
_ => None,
}
}
pub fn declared_type(&self, value: &AnalysisType) -> Option<ResolvedType> {
let (AnalysisType::Scalar(scalar) | AnalysisType::List(scalar)) = value;
let scalar = match scalar {
ScalarType::Boolean => ResolvedScalar::Boolean,
ScalarType::Number => ResolvedScalar::Numeric(NumericType::Number),
ScalarType::Quantity(reference) => {
ResolvedScalar::Numeric(NumericType::Named(self.kind(*reference)?.clone()))
}
};
Some(match value {
AnalysisType::Scalar(_) => ResolvedType::Scalar(scalar),
AnalysisType::List(_) => ResolvedType::List(scalar),
})
}
pub fn effective_binding(
&self,
property: Id,
context: EvaluationContext,
) -> Result<EffectiveBinding<'p>, AnalysisError> {
let element = self.project.elements.get(&property).ok_or_else(|| {
AnalysisError::new(
property,
"context",
"analysis_property",
"Property does not exist.",
)
})?;
let Some(SharedBody::EngineeringProperty { base, .. }) = element.shared() else {
return Err(AnalysisError::new(
property,
"context",
"analysis_property",
"Target is not an engineering property.",
));
};
if let EvaluationContext::Case(id) = context {
let case = self.project.elements.get(&id).ok_or_else(|| {
AnalysisError::new(id, "context", "analysis_case", "Case does not exist.")
})?;
let Some(SharedBody::AnalysisCase { overrides, .. }) = case.shared() else {
return Err(AnalysisError::new(
id,
"context",
"analysis_case",
"Context is not an analysis case.",
));
};
if case.layer() != element.layer() {
return Err(AnalysisError::new(
id,
"context",
"analysis_layer",
"Case and property must share a layer.",
));
}
if let Some(binding) = overrides.get(&property) {
return Ok(EffectiveBinding {
binding,
origin: BindingOrigin::CaseOverride(id),
});
}
}
Ok(EffectiveBinding {
binding: base,
origin: BindingOrigin::ModelBaseline,
})
}
}
File diff suppressed because it is too large Load Diff
-458
View File
@@ -1,458 +0,0 @@
//! Static expression checking shares numeric operator algebra with execution.
use super::admission::Budget;
use super::*;
use crate::analysis_numeric::{NumericError, NumericType, ResolvedKind};
use crate::{Id, Layer, SharedBody};
use std::collections::BTreeMap;
pub(super) struct BindingContext<'a> {
pub owner: Id,
pub field: &'a str,
pub layer: Option<Layer>,
pub parameters: &'a BTreeMap<ParameterId, ResolvedType>,
}
pub(super) fn binding(
environment: &ResolvedAnalysis<'_>,
context: BindingContext<'_>,
value: &Binding,
expected: &ResolvedType,
budget: &mut Budget,
) -> Result<(), AnalysisError> {
if let Binding::Expression(expression) = value {
let mut checker = Checker {
environment,
owner: context.owner,
field: context.field,
layer: context.layer,
parameters: context.parameters,
budget,
};
checker.expression(expression, Some(expected))?;
}
Ok(())
}
struct Checker<'a, 'p, 'b> {
environment: &'a ResolvedAnalysis<'p>,
owner: Id,
field: &'a str,
layer: Option<Layer>,
parameters: &'a BTreeMap<ParameterId, ResolvedType>,
budget: &'b mut Budget,
}
/// One suspended expression per logical depth. No Expr is owned or cloned by
/// a frame, and every stored type/nominal handle has fixed, nonrecursive size.
struct Frame<'e, 'p> {
expression: &'e Expr,
expected: Option<ResolvedType>,
state: State<'p>,
}
enum State<'p> {
Enter,
Unary,
BinaryLeft,
BinaryRight(ResolvedType),
IfCondition,
IfTrue,
IfFalse(ResolvedType),
List {
next: usize,
},
Call {
calculation: &'p CalculationDefinition,
next: usize,
},
SumInitial,
SumValues(ResolvedType),
Power,
AsKind(ResolvedKind<KindHandle>),
}
enum Step<'e> {
Child(&'e Expr, Option<ResolvedType>),
Complete(ResolvedType),
}
impl<'p> Checker<'_, 'p, '_> {
fn error(&self, code: &str, message: impl Into<String>) -> AnalysisError {
AnalysisError::new(self.owner, self.field, code, message)
}
fn numeric(&self, value: ResolvedType) -> Result<NumericType<KindHandle>, AnalysisError> {
match value {
ResolvedType::Scalar(ResolvedScalar::Numeric(value)) => Ok(value),
_ => Err(self.error("analysis_type", "A scalar numeric operand is required.")),
}
}
fn operation(
&self,
result: Result<NumericType<KindHandle>, NumericError>,
) -> Result<ResolvedType, AnalysisError> {
result
.map(ResolvedType::numeric)
.map_err(|error| self.error("analysis_type", error.to_string()))
}
/// Explicit continuations replace Rust recursion. Width never expands the
/// frame stack: lists and calls schedule only their next ordered child.
/// At most 128 frames and one returned scalar/list type are retained. The
/// per-node/per-argument meter charges remain identical to recursive entry;
/// continuation steps do constant bounded work within those charges.
fn expression(
&mut self,
expression: &Expr,
expected: Option<&ResolvedType>,
) -> Result<ResolvedType, AnalysisError> {
let mut frames = Vec::with_capacity(MAX_EXPRESSION_DEPTH);
frames.push(Frame {
expression,
expected: expected.cloned(),
state: State::Enter,
});
let mut returned = None;
loop {
let frame = frames.last_mut().expect("active expression frame");
match self.advance(frame, returned.take())? {
Step::Child(expression, expected) => {
if frames.len() == MAX_EXPRESSION_DEPTH {
// Preserve entry charging/error precedence without ever
// allocating the rejected 129th frame.
self.budget.charge(self.owner, self.field, 1)?;
return Err(self.error(
"analysis_depth",
"Expression exceeds the logical depth limit.",
));
}
frames.push(Frame {
expression,
expected,
state: State::Enter,
});
}
Step::Complete(actual) => {
let frame = frames.pop().expect("completed expression frame");
let expected = frame.expected.as_ref();
if expected.is_some_and(|expected| expected != &actual) {
return Err(self.error(
"analysis_type",
format!("Expected {expected:?}; expression has {actual:?}."),
));
}
if frames.is_empty() {
return Ok(actual);
}
returned = Some(actual);
}
}
}
}
fn advance<'e>(
&mut self,
frame: &mut Frame<'e, 'p>,
returned: Option<ResolvedType>,
) -> Result<Step<'e>, AnalysisError> {
let state = std::mem::replace(&mut frame.state, State::Enter);
if let State::Enter = state {
self.budget.charge(self.owner, self.field, 1)?;
return self.enter(frame);
}
let child = returned.expect("continuation follows a completed child");
match state {
State::Enter => unreachable!("handled before consuming child"),
State::Unary => {
let Expr::Unary { op, .. } = frame.expression else {
unreachable!()
};
Ok(Step::Complete(match op {
UnaryOp::Not => ResolvedType::boolean(),
UnaryOp::Negate | UnaryOp::Absolute => {
self.operation(self.numeric(child)?.unary_result())?
}
}))
}
State::BinaryLeft => {
let Expr::Binary { right, .. } = frame.expression else {
unreachable!()
};
frame.state = State::BinaryRight(child);
Ok(Step::Child(right, None))
}
State::BinaryRight(left) => {
let Expr::Binary { op, .. } = frame.expression else {
unreachable!()
};
Ok(Step::Complete(self.binary(*op, left, child)?))
}
State::IfCondition => {
let Expr::If { when_true, .. } = frame.expression else {
unreachable!()
};
frame.state = State::IfTrue;
Ok(Step::Child(when_true, frame.expected.clone()))
}
State::IfTrue => {
let Expr::If { when_false, .. } = frame.expression else {
unreachable!()
};
frame.state = State::IfFalse(child.clone());
Ok(Step::Child(when_false, Some(child)))
}
State::IfFalse(value) => Ok(Step::Complete(value)),
State::List { next } => {
let ResolvedType::Scalar(scalar) = child else {
return Err(self.error(
"analysis_list",
"Lists are flat; nested lists are not admitted.",
));
};
let Expr::List(items) = frame.expression else {
unreachable!()
};
if let Some(item) = items.get(next) {
frame.state = State::List { next: next + 1 };
Ok(Step::Child(item, Some(ResolvedType::Scalar(scalar))))
} else {
Ok(Step::Complete(ResolvedType::List(scalar)))
}
}
State::Call { calculation, next } => self.call_argument(frame, calculation, next),
State::SumInitial => {
let numeric = self.numeric(child.clone())?;
self.operation(numeric.unary_result())?;
let ResolvedType::Scalar(scalar) = &child else {
unreachable!()
};
let expected = ResolvedType::List(scalar.clone());
let Expr::Sum { values, .. } = frame.expression else {
unreachable!()
};
frame.state = State::SumValues(child);
Ok(Step::Child(values, Some(expected)))
}
State::SumValues(initial) => {
let numeric = self.numeric(initial.clone())?;
self.operation(numeric.additive_result(&numeric, false))?;
Ok(Step::Complete(initial))
}
State::Power => {
let Expr::Power { exponent, .. } = frame.expression else {
unreachable!()
};
Ok(Step::Complete(
self.operation(self.numeric(child)?.power_result(*exponent))?,
))
}
State::AsKind(kind) => Ok(Step::Complete(
self.operation(self.numeric(child)?.assertion_result(&kind))?,
)),
}
}
fn enter<'e>(&mut self, frame: &mut Frame<'e, 'p>) -> Result<Step<'e>, AnalysisError> {
let actual = match frame.expression {
Expr::Boolean(_) => ResolvedType::boolean(),
Expr::Number(_) => ResolvedType::numeric(NumericType::Number),
Expr::Quantity { unit, .. } => {
let unit = self.environment.unit(*unit).ok_or_else(|| {
self.error(
"analysis_unit",
"Quantity literal must reference an existing unit definition.",
)
})?;
ResolvedType::numeric(NumericType::Named(unit.kind().clone()))
}
Expr::Property(id) => {
let layer = self.layer.ok_or_else(|| {
self.error(
"analysis_purity",
"Reusable calculations cannot capture project properties.",
)
})?;
let element =
self.environment.project.elements.get(id).ok_or_else(|| {
self.error("analysis_property", "Property does not exist.")
})?;
let Some(SharedBody::EngineeringProperty { value_type, .. }) = element.shared()
else {
return Err(self.error(
"analysis_property",
"Reference must target an engineering property.",
));
};
if element.layer() != layer {
return Err(self.error(
"analysis_layer",
"Property references must stay in the same layer.",
));
}
self.environment.declared_type(value_type).ok_or_else(|| {
self.error("analysis_kind", "Declared property kind does not resolve.")
})?
}
Expr::Parameter(id) => self.parameters.get(id).cloned().ok_or_else(|| {
self.error(
"analysis_parameter",
"Parameter does not belong to this calculation definition.",
)
})?,
Expr::List(items) => {
if items.len() > MAX_LIST_LENGTH {
return Err(
self.error("analysis_list", "List exceeds the flat-list length limit.")
);
}
let scalar = match &frame.expected {
Some(ResolvedType::List(value)) => Some(value.clone()),
_ => None,
};
if let Some(item) = items.first() {
frame.state = State::List { next: 1 };
return Ok(Step::Child(item, scalar.map(ResolvedType::Scalar)));
}
ResolvedType::List(scalar.ok_or_else(|| {
self.error(
"analysis_list",
"An empty list requires an explicit surrounding list type.",
)
})?)
}
Expr::Unary { op, operand } => {
frame.state = State::Unary;
let expected = (*op == UnaryOp::Not).then(ResolvedType::boolean);
return Ok(Step::Child(operand, expected));
}
Expr::Binary { left, .. } => {
frame.state = State::BinaryLeft;
return Ok(Step::Child(left, None));
}
Expr::If { condition, .. } => {
frame.state = State::IfCondition;
return Ok(Step::Child(condition, Some(ResolvedType::boolean())));
}
Expr::Call {
definition,
arguments,
} => {
let calculation = self.environment.calculation(*definition).ok_or_else(|| {
self.error(
"analysis_calculation",
"Call must reference a calculation definition.",
)
})?;
if arguments.len() != calculation.parameters.len() {
return Err(self.error("analysis_arguments", "Call arguments must bind every declared parameter exactly once, by its scoped identity."));
}
// Charge the complete key scan before any parameter lookup.
self.budget
.charge(self.owner, self.field, calculation.parameters.len() as u64)?;
if calculation
.parameters
.iter()
.any(|parameter| !arguments.contains_key(&parameter.id))
{
return Err(self.error("analysis_arguments", "Call arguments must bind every declared parameter exactly once, by its scoped identity."));
}
return self.call_argument(frame, calculation, 0);
}
Expr::Sum { initial, .. } => {
frame.state = State::SumInitial;
return Ok(Step::Child(initial, None));
}
Expr::Power { base, .. } => {
frame.state = State::Power;
return Ok(Step::Child(base, None));
}
Expr::AsKind { kind, value } => {
let kind = self
.environment
.kind(*kind)
.ok_or_else(|| {
self.error(
"analysis_kind",
"Kind assertion must reference a quantity kind.",
)
})?
.clone();
frame.state = State::AsKind(kind);
return Ok(Step::Child(value, None));
}
};
Ok(Step::Complete(actual))
}
/// Arguments follow parameter declaration order, not UUID map order. Retain
/// the resolved calculation by reference and schedule one argument at a time.
fn call_argument<'e>(
&mut self,
frame: &mut Frame<'e, 'p>,
calculation: &'p CalculationDefinition,
next: usize,
) -> Result<Step<'e>, AnalysisError> {
if let Some(parameter) = calculation.parameters.get(next) {
self.budget.charge(self.owner, self.field, 1)?;
let expected = self
.environment
.declared_type(&parameter.value_type)
.ok_or_else(|| self.error("analysis_kind", "Parameter kind does not resolve."))?;
let Expr::Call { arguments, .. } = frame.expression else {
unreachable!()
};
frame.state = State::Call {
calculation,
next: next + 1,
};
Ok(Step::Child(&arguments[&parameter.id], Some(expected)))
} else {
let result = self
.environment
.declared_type(&calculation.result)
.ok_or_else(|| {
self.error("analysis_kind", "Calculation result kind does not resolve.")
})?;
Ok(Step::Complete(result))
}
}
fn binary(
&self,
op: BinaryOp,
left: ResolvedType,
right: ResolvedType,
) -> Result<ResolvedType, AnalysisError> {
match op {
BinaryOp::And | BinaryOp::Or => {
if left != ResolvedType::boolean() || right != ResolvedType::boolean() {
return Err(self.error("analysis_type", "Boolean operands are required."));
}
Ok(ResolvedType::boolean())
}
BinaryOp::Equal | BinaryOp::NotEqual
if left == ResolvedType::boolean() && right == ResolvedType::boolean() =>
{
Ok(ResolvedType::boolean())
}
BinaryOp::Equal
| BinaryOp::NotEqual
| BinaryOp::Less
| BinaryOp::LessOrEqual
| BinaryOp::Greater
| BinaryOp::GreaterOrEqual => {
self.numeric(left)?
.comparison_compatible(&self.numeric(right)?)
.map_err(|error| self.error("analysis_type", error.to_string()))?;
Ok(ResolvedType::boolean())
}
BinaryOp::Add | BinaryOp::Subtract => self.operation(
self.numeric(left)?
.additive_result(&self.numeric(right)?, op == BinaryOp::Subtract),
),
BinaryOp::Multiply | BinaryOp::Divide => self.operation(
self.numeric(left)?
.multiplicative_result(&self.numeric(right)?, op == BinaryOp::Divide),
),
}
}
}
-213
View File
@@ -1,213 +0,0 @@
//! Persistent analysis vocabulary. This is part of Project, not an evaluation model.
use crate::Id;
use crate::analysis_numeric::{Dimension, Exact};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
/// Only real local definitions are admitted in schema 8's first environment.
/// Imported references require the separately implemented pinned package catalog.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub enum DefinitionRef {
Local(Id),
}
impl DefinitionRef {
pub fn local(self) -> Id {
match self {
Self::Local(id) => id,
}
}
}
/// Qualified by its owning CalculationDefinition; never a global model identity.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct ParameterId(pub Id);
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Definition {
pub id: Id,
pub alias: String,
pub name: String,
pub description: String,
pub body: DefinitionBody,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub enum DefinitionBody {
QuantityKind(QuantityKindDefinition),
Unit(UnitDefinition),
Calculation(CalculationDefinition),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct QuantityKindDefinition {
pub dimension: Dimension,
pub algebra: QuantityAlgebra,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub enum QuantityAlgebra {
Linear,
Affine { difference: DefinitionRef },
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct UnitDefinition {
pub kind: DefinitionRef,
pub scale: Exact,
pub offset: Exact,
pub symbol: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub enum ScalarType {
Boolean,
Number,
Quantity(DefinitionRef),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub enum AnalysisType {
Scalar(ScalarType),
List(ScalarType),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Parameter {
pub id: ParameterId,
pub name: String,
pub value_type: AnalysisType,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CalculationDefinition {
pub parameters: Vec<Parameter>,
pub result: AnalysisType,
pub body: Binding,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub enum Binding {
Unbound,
Expression(Expr),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum UnaryOp {
Negate,
Absolute,
Not,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum BinaryOp {
Add,
Subtract,
Multiply,
Divide,
Equal,
NotEqual,
Less,
LessOrEqual,
Greater,
GreaterOrEqual,
And,
Or,
}
pub enum Expr {
Boolean(bool),
Number(Exact),
Quantity {
magnitude: Exact,
unit: DefinitionRef,
},
Property(Id),
Parameter(ParameterId),
List(Vec<Expr>),
Unary {
op: UnaryOp,
operand: Box<Expr>,
},
Binary {
op: BinaryOp,
left: Box<Expr>,
right: Box<Expr>,
},
If {
condition: Box<Expr>,
when_true: Box<Expr>,
when_false: Box<Expr>,
},
Call {
definition: DefinitionRef,
arguments: BTreeMap<ParameterId, Expr>,
},
Sum {
values: Box<Expr>,
initial: Box<Expr>,
},
Power {
base: Box<Expr>,
exponent: i8,
},
AsKind {
kind: DefinitionRef,
value: Box<Expr>,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub enum AnalysisSubject {
Element(Id),
Relation(Id),
}
impl AnalysisSubject {
pub fn id(self) -> Id {
match self {
Self::Element(id) | Self::Relation(id) => id,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum BaselineBase {
ModelBaseline,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub enum EvaluationContext {
ModelBaseline,
Case(Id),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub enum BindingOrigin {
ModelBaseline,
CaseOverride(Id),
}
/// Located structural/static admission failure, not a false or failed evaluation.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
#[serde(deny_unknown_fields)]
#[error("{code} at {object}.{field}: {message}")]
pub struct AnalysisError {
pub object: Id,
pub field: String,
pub code: String,
pub message: String,
}
impl AnalysisError {
pub(crate) fn new(
object: Id,
field: impl Into<String>,
code: &str,
message: impl Into<String>,
) -> Self {
Self {
object,
field: field.into(),
code: code.into(),
message: message.into(),
}
}
}
+98 -105
View File
@@ -153,7 +153,7 @@ impl<K> NumericType<K> {
matches!(self, Self::Named(kind) if kind.is_affine())
}
pub fn anonymous(dimension: Dimension) -> Self {
fn anonymous(dimension: Dimension) -> Self {
if dimension.is_dimensionless() {
Self::Number
} else {
@@ -161,7 +161,7 @@ impl<K> NumericType<K> {
}
}
pub fn normalized(self) -> Self {
fn normalized(self) -> Self {
match self {
Self::Anonymous(dimension) => Self::anonymous(dimension),
other => other,
@@ -169,93 +169,6 @@ impl<K> NumericType<K> {
}
}
impl<K: Clone + Eq> NumericType<K> {
/// Pure R6 algebra; does not evaluate coordinates or domain conditions.
pub fn additive_result(&self, rhs: &Self, subtract: bool) -> Result<Self, NumericError> {
use NumericType::{Anonymous, Named, Number};
let (left, right) = (self.clone().normalized(), rhs.clone().normalized());
match (&left, &right) {
(Number, Number) => Ok(Number),
(Anonymous(a), Anonymous(b)) if a == b => Ok(Anonymous(*a)),
(Named(a), Named(b)) => match (&a.difference, &b.difference) {
(None, None) if a == b => Ok(Named(a.clone())),
(Some(difference), Some(_)) if subtract && a == b => {
Ok(Named(ResolvedKind::linear(difference.clone(), a.dimension)))
}
(Some(difference), None) if difference == &b.key && a.dimension == b.dimension => {
Ok(Named(a.clone()))
}
(None, Some(difference))
if !subtract && difference == &a.key && a.dimension == b.dimension =>
{
Ok(Named(b.clone()))
}
_ => Err(NumericError::IncompatibleTypes),
},
_ => Err(NumericError::IncompatibleTypes),
}
}
pub fn multiplicative_result(&self, rhs: &Self, divide: bool) -> Result<Self, NumericError> {
use NumericType::Number;
let (left, right) = (self.clone().normalized(), rhs.clone().normalized());
if left.is_affine() || right.is_affine() {
return Err(NumericError::AffineOperation);
}
match (&left, &right) {
(Number, Number) => Ok(Number),
(_, Number) => Ok(left),
(Number, _) if !divide => Ok(right),
_ => Ok(Self::anonymous(if divide {
left.dimension().checked_quotient(right.dimension())?
} else {
left.dimension().checked_product(right.dimension())?
})),
}
}
pub fn unary_result(&self) -> Result<Self, NumericError> {
if self.is_affine() {
Err(NumericError::AffineOperation)
} else {
Ok(self.clone().normalized())
}
}
pub fn power_result(&self, exponent: i8) -> Result<Self, NumericError> {
if !(-MAX_INTEGER_POWER..=MAX_INTEGER_POWER).contains(&exponent) {
return Err(NumericError::IntegerPower);
}
if self.is_affine() {
return Err(NumericError::AffineOperation);
}
Ok(match exponent {
0 => Self::Number,
1 => self.clone().normalized(),
_ => Self::anonymous(self.dimension().checked_power(exponent)?),
})
}
pub fn comparison_compatible(&self, rhs: &Self) -> Result<(), NumericError> {
if self.clone().normalized() == rhs.clone().normalized() {
Ok(())
} else {
Err(NumericError::IncompatibleTypes)
}
}
pub fn assertion_result(&self, target: &ResolvedKind<K>) -> Result<Self, NumericError> {
if target.is_affine()
|| matches!(self, Self::Named(_))
|| self.dimension() != target.dimension
{
Err(NumericError::KindAssertion)
} else {
Ok(Self::Named(target.clone()))
}
}
}
/// A canonical rational coordinate with an internal numerical type.
///
/// Derived Eq compares stored representation. Formal equality/ordering must call
@@ -310,7 +223,7 @@ impl<K: Clone + Eq> NumericValue<K> {
pub fn add(&self, rhs: &Self, meter: &mut WorkMeter) -> Result<Self, NumericError> {
meter.ensure_active()?;
let output = self.value_type.additive_result(&rhs.value_type, false)?;
let output = self.additive_type(rhs, false)?;
Ok(Self::new(
output,
self.coordinate.add(&rhs.coordinate, meter)?,
@@ -319,18 +232,44 @@ impl<K: Clone + Eq> NumericValue<K> {
pub fn subtract(&self, rhs: &Self, meter: &mut WorkMeter) -> Result<Self, NumericError> {
meter.ensure_active()?;
let output = self.value_type.additive_result(&rhs.value_type, true)?;
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
.value_type
.multiplicative_result(&rhs.value_type, false)?;
let output = self.multiplicative_type(rhs, false)?;
Ok(Self::new(
output,
self.coordinate.multiply(&rhs.coordinate, meter)?,
@@ -339,36 +278,82 @@ impl<K: Clone + Eq> NumericValue<K> {
pub fn divide(&self, rhs: &Self, meter: &mut WorkMeter) -> Result<Self, NumericError> {
meter.ensure_active()?;
let output = self
.value_type
.multiplicative_result(&rhs.value_type, true)?;
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()?;
let output = self.value_type.unary_result()?;
Ok(Self::new(output, self.coordinate.negated(meter)?))
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()?;
let output = self.value_type.unary_result()?;
Ok(Self::new(output, self.coordinate.absolute(meter)?))
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()?;
let output = self.value_type.power_result(exponent)?;
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()?;
self.value_type.comparison_compatible(&rhs.value_type)?;
if self.value_type != rhs.value_type {
return Err(NumericError::IncompatibleTypes);
}
self.coordinate.compare(&rhs.coordinate, meter)
}
@@ -378,8 +363,16 @@ impl<K: Clone + Eq> NumericValue<K> {
meter: &mut WorkMeter,
) -> Result<Self, NumericError> {
meter.ensure_active()?;
let output = self.value_type.assertion_result(target)?;
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(output, self.coordinate.clone()))
Ok(Self::new(
NumericType::Named(target.clone()),
self.coordinate.clone(),
))
}
}
+296 -61
View File
@@ -1,14 +1,10 @@
//! Typed OA/SA language, tolerant source analysis, and explicit V1/V2/V3/V4 migration.
#[path = "language/semantic_source.rs"]
mod semantic_source;
mod source3_bridge;
mod raw_tokens;
// Staged syntax foundation only. Public source8 admission waits for complete
// declaration lowering, source updates and the root-owned resource gate.
#[cfg(test)]
mod analysis_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};
@@ -66,8 +62,7 @@ impl ParseLimits {
let graph = project
.elements
.len()
.saturating_add(project.relations.len())
.saturating_add(project.definitions.len());
.saturating_add(project.relations.len());
let records = graph
.saturating_add(1)
.saturating_add(project.diagrams.len())
@@ -86,8 +81,7 @@ impl ParseLimits {
.sum::<usize>(),
)
.saturating_add(project.artifact_links.len())
.saturating_add(project.traces.len())
.saturating_add(crate::analysis::nested_record_count(project));
.saturating_add(project.traces.len());
let steps = project
.elements
.values()
@@ -150,10 +144,164 @@ pub struct Analysis {
pub declarations: Vec<DeclarationSpan>,
}
/// Check the decoded spelling required by declaration-header UUID clauses.
/// This is the shared 36-byte/hyphen shape rule, not a hexadecimal parser:
/// callers must additionally parse the UUID. Uppercase hex and JSON escapes
/// that decode to this spelling remain accepted. URN/braced/compact forms do not.
pub fn has_uuid_header_shape(decoded: &str) -> bool {
decoded.len() == 36
&& [8, 13, 18, 23]
.into_iter()
.all(|index| decoded.as_bytes()[index] == b'-')
}
/// Budgets for one original source buffer. Policy is caller-owned; these
/// limits do not change the grammar or perform model admission.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct OutlineLimits {
pub max_source_bytes: usize,
pub max_tokens: usize,
pub max_declarations: usize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OutlineResource {
SourceBytes,
Tokens,
Declarations,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct OutlineLimit {
pub resource: OutlineResource,
pub limit: usize,
pub observed_at_least: usize,
/// Original byte offset where admission stopped; never a rewritten offset.
pub offset: usize,
}
/// Roles discovered by the existing tolerant declaration walk. Keeping the
/// wrapper preserves DeclarationSpan and Analysis as existing public APIs.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OutlineDeclaration {
pub declaration: DeclarationSpan,
pub kind_span: Span,
pub alias_span: Span,
/// Whole quoted header ID token, even if it is not a valid UUID. In that
/// case declaration.id is None. Missing/non-string ID values have no span.
pub id_literal_span: Option<Span>,
}
/// A full-file tolerant scan, not a successful parse or validated model. Invalid
/// text remains available. Lexical errors and unmatched braces are facts only:
/// their absence does not establish syntactic or semantic validity.
///
/// A budget failure returns Err and no partial Outline. Include reachability,
/// project completeness and editor revision are intentionally not inferred here.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Outline<'a> {
pub source: &'a str,
pub tokens: Vec<TokenSpan>,
pub declarations: Vec<OutlineDeclaration>,
pub has_lexical_errors: bool,
pub has_unmatched_braces: bool,
}
/// Tolerant highlighting tokens cover the whole input, including incomplete
/// strings and unexpected characters. No model is created or modified.
pub fn tokenize(source: &str) -> Vec<TokenSpan> {
raw_tokens::legacy(source)
tokenize_with_limit(source, usize::MAX).expect("unlimited token scan")
}
fn tokenize_with_limit(source: &str, max_tokens: usize) -> Result<Vec<TokenSpan>, OutlineLimit> {
let mut tokens = Vec::new();
let mut cursor = 0;
while cursor < source.len() {
if tokens.len() == max_tokens {
return Err(OutlineLimit {
resource: OutlineResource::Tokens,
limit: max_tokens,
observed_at_least: tokens.len().saturating_add(1),
offset: cursor,
});
}
let start = cursor;
let ch = source[cursor..].chars().next().expect("in bounds");
let class = if matches!(ch, ' ' | '\t' | '\r' | '\n') {
cursor += 1;
while source
.as_bytes()
.get(cursor)
.is_some_and(|next| matches!(*next, b' ' | b'\t' | b'\r' | b'\n'))
{
cursor += 1;
}
TokenClass::Whitespace
} else if source[cursor..].starts_with("//") {
cursor += source[cursor..].find('\n').unwrap_or(source.len() - cursor);
TokenClass::Comment
} else if ch == '"' {
cursor += 1;
let mut escaped = false;
let mut closed = false;
while cursor < source.len() {
let next = source[cursor..].chars().next().expect("in bounds");
cursor += next.len_utf8();
if escaped {
escaped = false;
} else if next == '\\' {
escaped = true;
} else if next == '"' {
closed = true;
break;
}
}
if closed && serde_json::from_str::<String>(&source[start..cursor]).is_ok() {
TokenClass::String
} else {
TokenClass::Error
}
} else if ch.is_ascii_alphabetic() || ch == '_' {
cursor += 1;
while source
.as_bytes()
.get(cursor)
.is_some_and(|next| next.is_ascii_alphanumeric() || *next == b'_')
{
cursor += 1;
}
if reserved(&source[start..cursor])
|| matches!(&source[start..cursor], "true" | "false" | "none")
{
TokenClass::Keyword
} else {
TokenClass::Identifier
}
} else if ch.is_ascii_digit() || matches!(ch, '-' | '+' | '.') {
cursor += 1;
while source.as_bytes().get(cursor).is_some_and(|next| {
next.is_ascii_alphanumeric() || matches!(*next, b'.' | b'+' | b'-' | b'_')
}) {
cursor += 1;
}
if serde_json::from_str::<serde_json::Number>(&source[start..cursor]).is_ok() {
TokenClass::Number
} else {
TokenClass::Error
}
} else if "{}();,[]:=@".contains(ch) {
cursor += ch.len_utf8();
TokenClass::Punctuation
} else {
cursor += ch.len_utf8();
TokenClass::Error
};
tokens.push(TokenSpan {
class,
span: Span { start, end: cursor },
});
}
Ok(tokens)
}
pub fn lint(source: &str) -> Vec<Diagnostic> {
@@ -499,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()
@@ -529,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()),
@@ -544,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()),
@@ -559,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",
@@ -573,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));
}
@@ -585,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));
@@ -598,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));
@@ -1159,11 +1320,7 @@ impl Parser<'_> {
self.cursor += 1;
let offset = self.current().span.start;
let value = self.quoted()?;
if value.len() != 36
|| ![8, 13, 18, 23]
.into_iter()
.all(|i| value.as_bytes()[i] == b'-')
{
if !has_uuid_header_shape(&value) {
return Err(Error {
code: "invalid_source",
offset,
@@ -1355,7 +1512,6 @@ impl Parser<'_> {
alias,
name,
description: String::new(),
definitions: BTreeMap::new(),
elements: BTreeMap::new(),
relations: BTreeMap::new(),
diagrams: BTreeMap::new(),
@@ -1576,9 +1732,6 @@ pub(crate) fn resolve_declarations(
}
parent = Some(owner);
}
if kind.is_analysis() {
return Err(conversion_error("Analysis records require the source8 authoring implementation.".into()));
}
let mut initial = if kind == Kind::Port {
if !fields.contains_key("contract") {
return Err(conversion_error(
@@ -1604,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))
@@ -1661,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()))?;
@@ -1686,7 +1846,10 @@ pub(crate) fn resolve_declarations(
}
let defaults =
serde_json::json!({"description":"","layer":"Oa","projection":"Overview","routes":[],"view_options":crate::view_options::ViewOptions::default()});
let mut record = merge_fields(defaults.as_object().unwrap().clone(), fields)
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!({}));
@@ -1701,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))
@@ -1713,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))
@@ -1729,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 {
@@ -1757,7 +1929,10 @@ pub(crate) fn resolve_declarations(
target.nodes.insert(node.id, node);
}
if legacy {
let migrated = source3_bridge::from_scratch(project, legacy_elements)
let mut value = serde_json::to_value(&project).expect("project serialization");
value["elements"] = Value::Object(legacy_elements);
let migrated = serde_json::from_value(value)
.map_err(crate::persistence::FileError::Json)
.and_then(|old| crate::persistence::v3::migrate(old, limits))
.map_err(|error| {
let issue = if let crate::persistence::FileError::Model { issues, .. } = &error {
@@ -1805,15 +1980,13 @@ pub(crate) fn resolve_declarations(
}
return Ok(migrated);
}
if matches!(project.schema_version, 4..=7) {
if matches!(project.schema_version, 4..=6) {
return (if project.schema_version == 4 {
crate::persistence::v4::finish(project, limits)
} else if project.schema_version == 5 {
crate::persistence::migrate_v5(project, limits)
} else if project.schema_version == 6 {
crate::persistence::migrate_v6(project, limits)
} else {
crate::persistence::migrate_v7(project, limits)
crate::persistence::migrate_v6(project, limits)
})
.map_err(|error| {
(
@@ -1894,8 +2067,49 @@ fn take_required_id(fields: &mut Map<String, Value>, key: &str) -> Result<Id, St
/// Analyze an incomplete source buffer without adopting a candidate. Declaration
/// spans remain available even when a later declaration contains errors.
/// Invalid compact/braced/URN header IDs retain no authored identity, matching
/// the declaration parser rather than the UUID crate's broader accepted forms.
pub fn analyze(source: &str) -> Analysis {
let tokens = tokenize(source);
let (declarations, _) =
declaration_outline(source, &tokens, usize::MAX).expect("unlimited declaration walk");
Analysis {
tokens,
diagnostics: lint(source),
declarations: declarations.into_iter().map(|d| d.declaration).collect(),
}
}
/// Read exact current text without lint, parsing a model, stabilization or I/O.
/// Source bytes are admitted before token allocation; token/declaration limits
/// are checked before lexing the next token or materializing the next record.
pub fn outline(source: &str, limits: OutlineLimits) -> Result<Outline<'_>, OutlineLimit> {
if source.len() > limits.max_source_bytes {
return Err(OutlineLimit {
resource: OutlineResource::SourceBytes,
limit: limits.max_source_bytes,
observed_at_least: source.len(),
offset: 0,
});
}
let tokens = tokenize_with_limit(source, limits.max_tokens)?;
let has_lexical_errors = tokens.iter().any(|token| token.class == TokenClass::Error);
let (declarations, has_unmatched_braces) =
declaration_outline(source, &tokens, limits.max_declarations)?;
Ok(Outline {
source,
tokens,
declarations,
has_lexical_errors,
has_unmatched_braces,
})
}
fn declaration_outline(
source: &str,
tokens: &[TokenSpan],
max_declarations: usize,
) -> Result<(Vec<OutlineDeclaration>, bool), OutlineLimit> {
let significant = tokens
.iter()
.filter(|t| !matches!(t.class, TokenClass::Whitespace | TokenClass::Comment))
@@ -1904,17 +2118,21 @@ pub fn analyze(source: &str) -> Analysis {
// again would make tolerant navigation quadratic on deeply nested drafts.
let mut body_ends = vec![None; significant.len()];
let mut bodies = Vec::new();
let mut unmatched_close = false;
for (index, token) in significant.iter().enumerate() {
match &source[token.span.start..token.span.end] {
"{" => bodies.push(index),
"}" => {
if let Some(open) = bodies.pop() {
body_ends[open] = Some(token.span.end);
} else {
unmatched_close = true;
}
}
_ => {}
}
}
let has_unmatched_braces = unmatched_close || !bodies.is_empty();
let mut declarations = Vec::new();
for (i, t) in significant.iter().enumerate() {
let word = &source[t.span.start..t.span.end];
@@ -1939,7 +2157,16 @@ pub fn analyze(source: &str) -> Analysis {
}
j += 1;
}
if declarations.len() == max_declarations {
return Err(OutlineLimit {
resource: OutlineResource::Declarations,
limit: max_declarations,
observed_at_least: declarations.len().saturating_add(1),
offset: t.span.start,
});
}
let mut id = None;
let mut id_literal_span = None;
if significant
.get(j)
.is_some_and(|t| &source[t.span.start..t.span.end] == "id")
@@ -1947,39 +2174,51 @@ pub fn analyze(source: &str) -> Analysis {
if let Some(value) = significant.get(j + 1)
&& value.class == TokenClass::String
{
id_literal_span = Some(value.span);
let text =
serde_json::from_str::<String>(&source[value.span.start..value.span.end])
.unwrap();
id = Id::parse_str(&text).ok();
if has_uuid_header_shape(&text) {
id = Id::parse_str(&text).ok();
}
}
j += 2;
}
let mut end = significant
.get(j)
.map_or(alias_token.span.end, |t| t.span.end);
// A recognized header ID at EOF must remain inside the whole span.
// Keep the old next-token/body behavior; only the EOF fallback changes.
let header_end = significant
.get(j.saturating_sub(1))
.or_else(|| significant.last())
.map_or(alias_token.span.end, |token| token.span.end);
let mut end = significant.get(j).map_or(header_end, |t| t.span.end);
if significant
.get(j)
.is_some_and(|t| &source[t.span.start..t.span.end] == "{")
{
end = body_ends[j].unwrap_or(source.len());
}
declarations.push(DeclarationSpan {
alias: alias.into(),
id,
kind: word.into(),
span: Span {
start: t.span.start,
end,
declarations.push(OutlineDeclaration {
declaration: DeclarationSpan {
alias: alias.into(),
id,
kind: word.into(),
span: Span {
start: t.span.start,
end,
},
},
kind_span: t.span,
alias_span: alias_token.span,
id_literal_span,
});
}
Analysis {
tokens,
diagnostics: lint(source),
declarations,
}
Ok((declarations, has_unmatched_braces))
}
#[cfg(test)]
#[path = "language_outline_tests.rs"]
mod outline_tests;
#[cfg(test)]
#[path = "language_nesting_tests.rs"]
mod nesting_tests;
@@ -2374,11 +2613,7 @@ pub(crate) mod legacy {
let value = self.quoted()?;
// Require the portable 8-4-4-4-12 spelling, not the UUID crate's URN,
// braces, or compact alternatives. Uppercase hexadecimal is accepted.
if value.len() != 36
|| ![8, 13, 18, 23]
.into_iter()
.all(|index| value.as_bytes()[index] == b'-')
{
if !super::has_uuid_header_shape(&value) {
return Err(Error {
code: "invalid_source",
offset,
@@ -1,113 +0,0 @@
//! Unresolved typed source8 syntax, staged for the complete source adapter.
//! This module does not admit a Project or enable public source8.
mod budget;
mod cursor;
mod declarations;
mod lex;
mod lower;
mod syntax;
mod write;
#[cfg(test)]
mod model_oracle;
use super::Span;
use crate::analysis::{BinaryOp, UnaryOp};
use crate::analysis_numeric::Exact;
use budget::Budget;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Error {
pub span: Span,
pub message: String,
}
fn error(span: Span, message: impl Into<String>) -> Error {
Error {
span,
message: message.into(),
}
}
#[derive(Debug, PartialEq, Eq)]
pub struct Reference {
pub name: String,
pub span: Span,
}
#[derive(Debug, PartialEq, Eq)]
pub struct Argument {
pub parameter: Reference,
pub value: Expression,
}
#[derive(Debug, PartialEq, Eq)]
pub struct Expression {
pub kind: ExpressionKind,
pub span: Span,
pub depth: usize,
pub grouped: bool,
/// Original numeric tokens, including constructor punctuation, excluding
/// trivia. The full span retains intervening comments and spelling.
pub literal: Option<LiteralSyntax>,
}
#[derive(Debug, PartialEq, Eq)]
pub struct LiteralSyntax {
pub span: Span,
pub tokens: Vec<Span>,
}
#[derive(Debug, PartialEq, Eq)]
pub enum ExpressionKind {
Boolean(bool),
Number(Exact),
Quantity {
magnitude: Exact,
unit: Reference,
},
Property(Reference),
Parameter(Reference),
List(Vec<Expression>),
Unary {
op: UnaryOp,
operand: Box<Expression>,
},
Binary {
op: BinaryOp,
left: Box<Expression>,
right: Box<Expression>,
},
If {
condition: Box<Expression>,
when_true: Box<Expression>,
when_false: Box<Expression>,
},
Call {
definition: Reference,
arguments: Vec<Argument>,
},
Sum {
values: Box<Expression>,
initial: Box<Expression>,
},
Power {
base: Box<Expression>,
exponent: i8,
},
AsKind {
kind: Reference,
value: Box<Expression>,
},
}
#[derive(Debug, PartialEq, Eq)]
pub enum Binding {
Unbound(Span),
Expression(Expression),
}
pub fn parse(source: &str, budget: &mut Budget) -> Result<Binding, Error> {
let tokens = lex::tokens(source, budget)?;
let mut cursor = cursor::Cursor::new(source, &tokens, budget);
let binding = syntax::binding(&mut cursor)?;
cursor.end()?;
Ok(binding)
}
#[cfg(test)]
mod tests;
@@ -1,154 +0,0 @@
//! Candidate limits for measured adoption; one owner spans all parsed files.
use super::{Error, Span, error};
use crate::analysis_numeric::{Exact, WorkLimits, WorkMeter};
use crate::language::ParseLimits;
#[derive(Clone, Copy, Debug)]
pub struct Limits {
pub bytes: usize,
pub tokens: usize,
pub entries: usize,
pub references: usize,
pub frames: usize,
pub numeric_units: u64,
}
impl Limits {
pub fn source8(caller: ParseLimits) -> Self {
let bytes = caller.max_source_bytes.min(64 * 1024 * 1024);
Self {
bytes,
tokens: bytes.min(1_048_576),
entries: caller.max_records.min(262_144),
references: caller.max_step_references.min(500_000),
frames: 256,
numeric_units: 50_000_000,
}
}
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub struct Usage {
pub bytes: usize,
pub tokens: usize,
pub entries: usize,
pub references: usize,
pub deepest_frame: usize,
}
pub struct Budget {
limits: Limits,
pub usage: Usage,
numeric: WorkMeter,
failed: Option<Error>,
}
impl Budget {
pub fn new(limits: Limits) -> Result<Self, Error> {
let hard = Limits::source8(ParseLimits::UNLIMITED);
if limits.bytes > hard.bytes
|| limits.tokens > hard.tokens
|| limits.entries > hard.entries
|| limits.references > hard.references
|| limits.frames > hard.frames
|| limits.numeric_units > hard.numeric_units
{
return Err(error(
Span::default(),
"Source8 profile cannot exceed candidate hard limits.",
));
}
Ok(Self {
limits,
usage: Usage::default(),
numeric: WorkMeter::new(
WorkLimits::new(0, limits.numeric_units)
.map_err(|e| error(Span::default(), e.to_string()))?,
),
failed: None,
})
}
pub fn active(&self) -> Result<(), Error> {
self.failed.clone().map_or(Ok(()), Err)
}
fn charge(
&mut self,
used: usize,
amount: usize,
maximum: usize,
span: Span,
name: &str,
) -> Result<(), Error> {
self.active()?;
if amount > maximum.saturating_sub(used) {
let failure = error(span, format!("Source8 {name} limit exceeded."));
self.failed = Some(failure.clone());
return Err(failure);
}
Ok(())
}
pub fn bytes(&mut self, amount: usize, span: Span) -> Result<(), Error> {
self.charge(self.usage.bytes, amount, self.limits.bytes, span, "bytes")?;
self.usage.bytes += amount;
Ok(())
}
pub fn token(&mut self, span: Span) -> Result<(), Error> {
self.charge(self.usage.tokens, 1, self.limits.tokens, span, "tokens")?;
self.usage.tokens += 1;
Ok(())
}
pub fn entry(&mut self, span: Span) -> Result<(), Error> {
self.charge(
self.usage.entries,
1,
self.limits.entries,
span,
"syntax entries",
)?;
self.usage.entries += 1;
Ok(())
}
pub fn reference(&mut self, span: Span) -> Result<(), Error> {
self.charge(
self.usage.references,
1,
self.limits.references,
span,
"references",
)?;
self.usage.references += 1;
Ok(())
}
pub fn frame(&mut self, depth: usize, span: Span) -> Result<(), Error> {
self.charge(0, depth, self.limits.frames, span, "syntax frames")?;
self.usage.deepest_frame = self.usage.deepest_frame.max(depth);
Ok(())
}
pub fn exact(&mut self, spelling: &str, span: Span) -> Result<Exact, Error> {
self.active()?;
match Exact::parse_literal(spelling, &mut self.numeric) {
Ok(value) => Ok(value),
Err(cause) => {
let failure = error(span, cause.to_string());
self.failed = Some(failure.clone());
Err(failure)
}
}
}
pub fn numeric_units(&self) -> u64 {
self.numeric.usage().numeric_units
}
/// Check phase allocation bounds without recharging parsed syntax or exact
/// work. S4 must also charge ordinary records through the inherited budget.
pub fn lowering_entries(&self, count: usize, span: Span) -> Result<(), Error> {
self.active()?;
if count > self.limits.entries {
return Err(error(span, "Source8 lowering entry limit exceeded."));
}
Ok(())
}
pub fn lowering_occurrences(&self, count: usize, span: Span) -> Result<(), Error> {
self.active()?;
// Parameter declarations are syntax entries, not reference uses.
if count > self.limits.references.saturating_add(self.limits.entries) {
return Err(error(span, "Source8 lowering occurrence limit exceeded."));
}
Ok(())
}
}
@@ -1,171 +0,0 @@
//! A borrowed significant-token cursor. Spans always refer to the full source.
use super::*;
use lex::{Class, Token};
pub(super) struct Cursor<'a, 'b> {
pub source: &'a str,
pub tokens: &'a [Token],
pub cursor: usize,
pub frames: usize,
pub budget: &'b mut Budget,
}
impl<'a, 'b> Cursor<'a, 'b> {
pub(super) fn new(source: &'a str, tokens: &'a [Token], budget: &'b mut Budget) -> Self {
Self {
source,
tokens,
cursor: 0,
frames: 0,
budget,
}
}
}
impl Cursor<'_, '_> {
pub(super) fn span(&self) -> Span {
self.tokens.get(self.cursor).map_or(
Span {
start: self.source.len(),
end: self.source.len(),
},
|token| token.span,
)
}
pub(super) fn text(&self) -> &str {
let span = self.span();
&self.source[span.start..span.end]
}
pub(super) fn take(&mut self, expected: &str) -> Result<Span, Error> {
if self.text() != expected {
return Err(error(self.span(), format!("Expected '{expected}'.")));
}
let span = self.span();
self.cursor += 1;
Ok(span)
}
pub(super) fn end(&self) -> Result<(), Error> {
if self.cursor != self.tokens.len() {
Err(error(
self.span(),
"Unexpected content after analysis expression.",
))
} else {
Ok(())
}
}
pub(super) fn open(&mut self) -> Result<(), Error> {
self.budget.frame(self.frames + 1, self.span())?;
self.frames += 1;
Ok(())
}
pub(super) fn close(&mut self) {
self.frames -= 1;
}
pub(super) fn reference(&mut self) -> Result<Reference, Error> {
self.budget.reference(self.span())?;
self.name()
}
pub(super) fn name(&mut self) -> Result<Reference, Error> {
let span = self.span();
if !self
.tokens
.get(self.cursor)
.is_some_and(|token| token.class == Class::Word)
|| !crate::valid_alias(self.text())
{
return Err(error(span, "Expected a valid reference or parameter name."));
}
let name = self.text().to_owned();
self.cursor += 1;
Ok(Reference { name, span })
}
pub(super) fn quoted(&mut self) -> Result<String, Error> {
let span = self.span();
if !self
.tokens
.get(self.cursor)
.is_some_and(|token| token.class == Class::String)
{
return Err(error(span, "Expected a quoted string."));
}
let value =
serde_json::from_str(self.text()).map_err(|_| error(span, "Invalid quoted string."))?;
self.cursor += 1;
Ok(value)
}
pub(super) fn last_end(&self) -> usize {
self.tokens[self.cursor - 1].span.end
}
pub(super) fn exact_literal(&mut self) -> Result<(Exact, LiteralSyntax), Error> {
if self.text() == "ratio" {
return self.ratio();
}
let span = self.span();
if !self
.tokens
.get(self.cursor)
.is_some_and(|token| token.class == Class::Number)
{
return Err(error(span, "Expected an exact numeric atom or ratio."));
}
let value = self
.budget
.exact(&self.source[span.start..span.end], span)?;
self.cursor += 1;
Ok((
value,
LiteralSyntax {
span,
tokens: vec![span],
},
))
}
pub(super) fn integer(&mut self) -> Result<Span, Error> {
let span = self.span();
let text = self.text();
if text.is_empty() || !text.bytes().all(|b| b.is_ascii_digit()) {
return Err(error(span, "Expected unsigned integer digits."));
}
if text.len() > crate::analysis_numeric::MAX_COMPONENT_DIGITS {
return Err(error(span, "Integer component exceeds digit limit."));
}
self.cursor += 1;
Ok(span)
}
pub(super) fn ratio(&mut self) -> Result<(Exact, LiteralSyntax), Error> {
let first_token = self.cursor;
let start = self.take("ratio")?.start;
self.open()?;
self.take("(")?;
let negative = if self.text() == "-" {
self.cursor += 1;
true
} else {
false
};
let numerator = self.integer()?;
self.take(",")?;
let denominator = self.integer()?;
let end = self.take(")")?.end;
self.close();
let n = &self.source[numerator.start..numerator.end];
let d = &self.source[denominator.start..denominator.end];
let length = 9 + usize::from(negative) + n.len() + d.len();
if length > crate::analysis_numeric::MAX_LITERAL_BYTES {
return Err(error(
Span { start, end },
"Numeric reconstruction exceeds literal byte limit.",
));
}
let normalized = format!("ratio({}{n}, {d})", if negative { "-" } else { "" });
Ok((
self.budget.exact(&normalized, Span { start, end })?,
LiteralSyntax {
span: Span { start, end },
tokens: self.tokens[first_token..self.cursor]
.iter()
.map(|token| token.span)
.collect(),
},
))
}
}
@@ -1,635 +0,0 @@
//! Required typed declaration syntax. No model construction or resolution.
use super::cursor::Cursor;
use super::*;
use crate::{Id, Layer};
use std::collections::BTreeSet;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DeclarationKind {
QuantityKind,
Unit,
Calculation,
Property(Layer),
Check(Layer),
Case(Layer),
}
/// Contextual recognition only: never add these words to the global alias ban.
pub fn declaration_kind(word: &str) -> Option<DeclarationKind> {
use DeclarationKind::*;
Some(match word {
"quantity_kind" => QuantityKind,
"unit" => Unit,
"calculation" => Calculation,
"oa_property" => Property(Layer::Oa),
"sa_property" => Property(Layer::Sa),
"oa_check" => Check(Layer::Oa),
"sa_check" => Check(Layer::Sa),
"oa_analysis" => Case(Layer::Oa),
"sa_analysis" => Case(Layer::Sa),
_ => return None,
})
}
#[derive(Debug, PartialEq, Eq)]
pub struct IdentitySyntax {
/// Missing IDs stay unresolved here. The bundle stabilization owner assigns
/// and inserts an ID once, then reparses the actual returned bytes.
pub value: Option<Id>,
pub keyword: Option<Span>,
pub literal: Option<Span>,
pub insertion: Option<usize>,
}
#[derive(Debug, PartialEq, Eq)]
pub struct Header {
pub kind: DeclarationKind,
pub kind_span: Span,
pub alias: Reference,
pub name: String,
pub name_span: Span,
pub identity: IdentitySyntax,
pub span: Span,
}
#[derive(Debug, PartialEq, Eq)]
pub struct Field<T> {
/// Complete field including its keyword and trailing semicolon.
pub span: Span,
pub keyword: Span,
/// Same meaning as an ordinary top-level field value span.
pub value_span: Span,
pub value: T,
}
#[derive(Debug, PartialEq, Eq)]
pub struct ScalarSyntax {
pub span: Span,
pub kind: ScalarKind,
}
#[derive(Debug, PartialEq, Eq)]
pub enum ScalarKind {
Boolean,
Number,
Quantity(Reference),
}
#[derive(Debug, PartialEq, Eq)]
pub struct TypeSyntax {
pub span: Span,
pub scalar: ScalarSyntax,
pub list: bool,
}
#[derive(Debug, PartialEq, Eq)]
pub struct ParameterSyntax {
pub span: Span,
pub name: Reference,
pub identity: IdentitySyntax,
pub value_type: TypeSyntax,
}
#[derive(Debug, PartialEq, Eq)]
pub struct DimensionEntry {
pub axis: Span,
pub exponent: Span,
pub span: Span,
}
#[derive(Debug, PartialEq, Eq)]
pub struct DimensionSyntax {
pub exponents: [i16; 7],
pub entries: Vec<DimensionEntry>,
}
#[derive(Debug, PartialEq, Eq)]
pub enum AlgebraSyntax {
Linear,
Affine(Reference),
}
#[derive(Debug, PartialEq, Eq)]
pub struct ExactSyntax {
pub value: Exact,
pub literal: LiteralSyntax,
}
#[derive(Debug, PartialEq, Eq)]
pub struct OverrideSyntax {
pub span: Span,
pub target: Reference,
pub binding: Binding,
pub binding_span: Span,
}
#[derive(Debug, PartialEq, Eq)]
pub enum Body {
QuantityKind {
dimension: Field<DimensionSyntax>,
algebra: Field<AlgebraSyntax>,
},
Unit {
kind: Field<Reference>,
scale: Field<ExactSyntax>,
offset: Field<ExactSyntax>,
symbol: Field<String>,
},
Calculation {
parameters: Vec<ParameterSyntax>,
result: Field<TypeSyntax>,
body: Field<Binding>,
},
Property {
subject: Field<Reference>,
value_type: Field<TypeSyntax>,
base: Field<Binding>,
display_unit: Option<Field<Option<Reference>>>,
},
Check {
subject: Option<Field<Option<Reference>>>,
documented_constraint: Option<Field<Option<Reference>>>,
predicate: Field<Binding>,
},
Case {
subject: Option<Field<Option<Reference>>>,
base: Field<()>,
overrides: Field<Vec<OverrideSyntax>>,
},
}
#[derive(Debug, PartialEq, Eq)]
pub struct Declaration {
pub header: Header,
pub span: Span,
pub body_span: Span,
pub description: Option<Field<String>>,
/// Analysis roots may explicitly spell `parent none`; definitions may not.
pub parent: Option<Field<()>>,
pub body: Body,
}
#[derive(Default)]
struct Fields {
dimension: Option<Field<DimensionSyntax>>,
algebra: Option<Field<AlgebraSyntax>>,
kind: Option<Field<Reference>>,
scale: Option<Field<ExactSyntax>>,
offset: Option<Field<ExactSyntax>>,
symbol: Option<Field<String>>,
result: Option<Field<TypeSyntax>>,
body: Option<Field<Binding>>,
subject: Option<Field<Reference>>,
optional_subject: Option<Field<Option<Reference>>>,
value_type: Option<Field<TypeSyntax>>,
base: Option<Field<Binding>>,
display_unit: Option<Field<Option<Reference>>>,
documented_constraint: Option<Field<Option<Reference>>>,
predicate: Option<Field<Binding>>,
baseline: Option<Field<()>>,
overrides: Option<Field<Vec<OverrideSyntax>>>,
}
fn required<T>(value: Option<T>, name: &str, at: Span) -> Result<T, Error> {
value.ok_or_else(|| error(at, format!("Missing required field '{name}'.")))
}
impl Fields {
fn finish(
self,
kind: DeclarationKind,
parameters: Vec<ParameterSyntax>,
at: Span,
) -> Result<Body, Error> {
Ok(match kind {
DeclarationKind::QuantityKind => Body::QuantityKind {
dimension: required(self.dimension, "dimension", at)?,
algebra: required(self.algebra, "algebra", at)?,
},
DeclarationKind::Unit => Body::Unit {
kind: required(self.kind, "kind", at)?,
scale: required(self.scale, "scale", at)?,
offset: required(self.offset, "offset", at)?,
symbol: required(self.symbol, "symbol", at)?,
},
DeclarationKind::Calculation => Body::Calculation {
parameters,
result: required(self.result, "result", at)?,
body: required(self.body, "body", at)?,
},
DeclarationKind::Property(_) => Body::Property {
subject: required(self.subject, "subject", at)?,
value_type: required(self.value_type, "type", at)?,
base: required(self.base, "base", at)?,
display_unit: self.display_unit,
},
DeclarationKind::Check(_) => Body::Check {
subject: self.optional_subject,
documented_constraint: self.documented_constraint,
predicate: required(self.predicate, "predicate", at)?,
},
DeclarationKind::Case(_) => Body::Case {
subject: self.optional_subject,
base: required(self.baseline, "base", at)?,
overrides: required(self.overrides, "overrides", at)?,
},
})
}
}
/// Consume exactly one declaration from already charged full-file tokens.
/// `version` is inherited from the caller's entry policy; fragments have no
/// independent version header. Public parsing does not call this staged API.
pub(super) fn parse(
p: &mut Cursor<'_, '_>,
version: u32,
lexical_owner: Option<Id>,
) -> Result<Declaration, Error> {
p.budget.active()?;
let kind_span = p.span();
let kind = declaration_kind(p.text())
.ok_or_else(|| error(kind_span, "Expected a short typed declaration word."))?;
if version != 8 {
return Err(error(kind_span, "Analysis declarations require source8."));
}
if lexical_owner.is_some() {
return Err(error(
kind_span,
"Analysis declarations and definitions must be roots.",
));
}
p.budget.entry(kind_span)?;
p.cursor += 1;
let alias = p.name()?;
let name_span = p.span();
let name = p.quoted()?;
let identity = p.identity()?;
p.open()?;
let opening = p.take("{")?;
let header = Header {
kind,
kind_span,
alias,
name,
name_span,
identity,
span: Span {
start: kind_span.start,
end: opening.start,
},
};
let mut fields = Fields::default();
let mut description = None;
let mut parent = None;
let mut parameters = Vec::new();
let mut seen = BTreeSet::new();
let mut parameter_names = BTreeSet::new();
let mut parameter_ids = BTreeSet::new();
while p.text() != "}" {
let keyword = p.span();
p.budget.entry(keyword)?;
if p.text() == "input" && kind == DeclarationKind::Calculation {
let parameter = p.parameter()?;
if !parameter_names.insert(parameter.name.name.clone()) {
return Err(error(parameter.name.span, "Duplicate parameter name."));
}
if let Some(id) = parameter.identity.value
&& !parameter_ids.insert(id)
{
return Err(error(
parameter.identity.literal.expect("explicit identity"),
"Duplicate parameter identity.",
));
}
parameters.push(parameter);
continue;
}
// Borrow the original spelling for duplicate detection; no field-name
// allocation and no map insertion can overwrite an earlier value.
let source = p.source;
let key = &source[keyword.start..keyword.end];
if !seen.insert(key) {
return Err(error(keyword, format!("Duplicate field '{key}'.")));
}
p.cursor += 1;
use DeclarationKind::*;
match (kind, key) {
(_, "description") => description = Some(p.field(keyword, |p| p.quoted())?),
(Property(_) | Check(_) | Case(_), "parent") => {
parent = Some(p.field(keyword, |p| {
p.take("none")?;
Ok(())
})?)
}
(QuantityKind, "dimension") => {
fields.dimension = Some(p.field(keyword, |p| p.dimension())?)
}
(QuantityKind, "algebra") => fields.algebra = Some(p.field(keyword, |p| p.algebra())?),
(Unit, "kind") => fields.kind = Some(p.field(keyword, |p| p.at_reference())?),
(Unit, "scale") => fields.scale = Some(p.field(keyword, |p| p.exact_value())?),
(Unit, "offset") => fields.offset = Some(p.field(keyword, |p| p.exact_value())?),
(Unit, "symbol") => fields.symbol = Some(p.field(keyword, |p| p.quoted())?),
(Calculation, "result") => fields.result = Some(p.field(keyword, |p| p.value_type())?),
(Calculation, "body") => fields.body = Some(p.field(keyword, syntax::binding)?),
(Property(_), "subject") => {
fields.subject = Some(p.field(keyword, |p| p.at_reference())?)
}
(Property(_), "type") => {
fields.value_type = Some(p.field(keyword, |p| p.value_type())?)
}
(Property(_), "base") => fields.base = Some(p.field(keyword, syntax::binding)?),
(Property(_), "display_unit") => {
fields.display_unit = Some(p.field(keyword, |p| p.optional_reference())?)
}
(Check(_) | Case(_), "subject") => {
fields.optional_subject = Some(p.field(keyword, |p| p.optional_reference())?)
}
(Check(_), "documented_constraint") => {
fields.documented_constraint = Some(p.field(keyword, |p| p.optional_reference())?)
}
(Check(_), "predicate") => fields.predicate = Some(p.field(keyword, syntax::binding)?),
(Case(_), "base") => {
fields.baseline = Some(p.field(keyword, |p| {
p.take("ModelBaseline")?;
Ok(())
})?)
}
(Case(_), "overrides") => fields.overrides = Some(p.field(keyword, |p| p.overrides())?),
_ => {
return Err(error(
keyword,
format!("Unknown field '{key}' for this typed declaration."),
));
}
}
}
let closing = p.take("}")?;
p.close();
let body = fields.finish(kind, parameters, closing)?;
Ok(Declaration {
header,
span: Span {
start: kind_span.start,
end: closing.end,
},
body_span: Span {
start: opening.end,
end: closing.start,
},
description,
parent,
body,
})
}
impl Cursor<'_, '_> {
fn field<T>(
&mut self,
keyword: Span,
parse: impl FnOnce(&mut Self) -> Result<T, Error>,
) -> Result<Field<T>, Error> {
let start = self.span().start;
let value = parse(self)?;
let end = self.last_end();
let terminator = self.take(";")?;
Ok(Field {
span: Span {
start: keyword.start,
end: terminator.end,
},
keyword,
value_span: Span { start, end },
value,
})
}
fn identity(&mut self) -> Result<IdentitySyntax, Error> {
if self.text() != "id" {
return Ok(IdentitySyntax {
value: None,
keyword: None,
literal: None,
insertion: Some(self.span().start),
});
}
let keyword = self.take("id")?;
let literal = self.span();
let spelling = self.quoted()?;
if spelling.len() != 36
|| ![8, 13, 18, 23]
.into_iter()
.all(|at| spelling.as_bytes()[at] == b'-')
{
return Err(error(
literal,
"Expected UUID in 8-4-4-4-12 hexadecimal form.",
));
}
let id = Id::parse_str(&spelling).map_err(|_| error(literal, "Invalid UUID identity."))?;
Ok(IdentitySyntax {
value: Some(id),
keyword: Some(keyword),
literal: Some(literal),
insertion: None,
})
}
fn parameter(&mut self) -> Result<ParameterSyntax, Error> {
let start = self.take("input")?.start;
let name = self.name()?;
let identity = self.identity()?;
self.take(":")?;
let value_type = self.value_type()?;
let end = self.take(";")?.end;
Ok(ParameterSyntax {
span: Span { start, end },
name,
identity,
value_type,
})
}
fn at_reference(&mut self) -> Result<Reference, Error> {
self.take("@")?;
self.reference()
}
fn optional_reference(&mut self) -> Result<Option<Reference>, Error> {
if self.text() == "none" {
self.cursor += 1;
Ok(None)
} else {
self.at_reference().map(Some)
}
}
fn exact_value(&mut self) -> Result<ExactSyntax, Error> {
let (value, literal) = self.exact_literal()?;
Ok(ExactSyntax { value, literal })
}
fn value_type(&mut self) -> Result<TypeSyntax, Error> {
let start = self.span().start;
self.budget.entry(self.span())?;
let list = self.text() == "List";
if list {
self.open()?;
self.cursor += 1;
self.take("(")?;
}
let scalar = self.scalar_type()?;
if list {
self.take(")")?;
self.close();
}
Ok(TypeSyntax {
span: Span {
start,
end: self.last_end(),
},
scalar,
list,
})
}
fn scalar_type(&mut self) -> Result<ScalarSyntax, Error> {
let start = self.span().start;
let kind = match self.text() {
"Boolean" => {
self.cursor += 1;
ScalarKind::Boolean
}
"Number" => {
self.cursor += 1;
ScalarKind::Number
}
"Quantity" => {
self.open()?;
self.cursor += 1;
self.take("(")?;
let reference = self.at_reference()?;
self.take(")")?;
self.close();
ScalarKind::Quantity(reference)
}
_ => {
return Err(error(
self.span(),
"Expected Boolean, Number or Quantity(@kind); nested List is not a scalar type.",
));
}
};
Ok(ScalarSyntax {
span: Span {
start,
end: self.last_end(),
},
kind,
})
}
fn dimension(&mut self) -> Result<DimensionSyntax, Error> {
self.open()?;
self.take("{")?;
let mut exponents = [0; 7];
let mut seen = [false; 7];
let mut entries = Vec::new();
while self.text() != "}" {
let axis = self.span();
self.budget.entry(axis)?;
let index = match self.text() {
"length" => 0,
"mass" => 1,
"time" => 2,
"electric_current" => 3,
"temperature" => 4,
"amount" => 5,
"luminous_intensity" => 6,
_ => return Err(error(axis, "Unknown dimension axis.")),
};
if seen[index] {
return Err(error(axis, "Duplicate dimension axis."));
}
seen[index] = true;
self.cursor += 1;
let start = self.span().start;
let negative = self.text() == "-";
if negative {
self.cursor += 1;
}
let digits = self.integer()?;
let value = self.source[digits.start..digits.end]
.parse::<i16>()
.map_err(|_| error(digits, "Dimension exponent exceeds -32..32."))?;
if value > 32 {
return Err(error(digits, "Dimension exponent exceeds -32..32."));
}
exponents[index] = if negative { -value } else { value };
let exponent = Span {
start,
end: digits.end,
};
let end = self.take(";")?.end;
entries.push(DimensionEntry {
axis,
exponent,
span: Span {
start: axis.start,
end,
},
});
}
self.take("}")?;
self.close();
Ok(DimensionSyntax { exponents, entries })
}
fn algebra(&mut self) -> Result<AlgebraSyntax, Error> {
if self.text() == "Linear" {
self.cursor += 1;
return Ok(AlgebraSyntax::Linear);
}
self.take("Affine")?;
self.open()?;
self.take("(")?;
let difference = self.at_reference()?;
self.take(")")?;
self.close();
Ok(AlgebraSyntax::Affine(difference))
}
fn overrides(&mut self) -> Result<Vec<OverrideSyntax>, Error> {
self.open()?;
self.take("{")?;
let mut entries = Vec::new();
let mut names = BTreeSet::new();
while self.text() != "}" {
let start = self.span().start;
self.budget.entry(self.span())?;
let target = self.at_reference()?;
if !names.insert(target.name.clone()) {
return Err(error(target.span, "Duplicate override target."));
}
self.take("=")?;
let binding_start = self.span().start;
let binding = syntax::binding(self)?;
let binding_span = Span {
start: binding_start,
end: self.last_end(),
};
let end = self.take(";")?.end;
entries.push(OverrideSyntax {
span: Span { start, end },
target,
binding,
binding_span,
});
}
self.take("}")?;
self.close();
Ok(entries)
}
}
#[cfg(test)]
mod tests;
@@ -1,613 +0,0 @@
use super::*;
use crate::language::ParseLimits;
use crate::language::analysis_source::budget::Limits;
fn limits() -> Limits {
Limits::source8(ParseLimits::UNLIMITED)
}
fn budget() -> Budget {
Budget::new(limits()).unwrap()
}
fn declaration(source: &str) -> Declaration {
let mut budget = budget();
let tokens = lex::tokens(source, &mut budget).unwrap();
let mut cursor = Cursor::new(source, &tokens, &mut budget);
let result = parse(&mut cursor, 8, None).unwrap();
cursor.end().unwrap();
result
}
fn failure(source: &str) -> Error {
let mut budget = budget();
let tokens = lex::tokens(source, &mut budget).unwrap();
parse(&mut Cursor::new(source, &tokens, &mut budget), 8, None).unwrap_err()
}
fn text(source: &str, span: Span) -> &str {
&source[span.start..span.end]
}
#[test]
fn required_declarations_have_independent_typed_body_and_header_oracles() {
let source = "quantity_kind duration \"Duration\" id \"188b911c-5f8b-431c-9eea-2d41f3953767\" { dimension { time 1; length -2; }; algebra Affine(@difference); description \"雪\"; }";
let actual = declaration(source);
assert_eq!(actual.header.kind, DeclarationKind::QuantityKind);
assert_eq!(actual.header.alias.name, "duration");
assert_eq!(actual.header.name, "Duration");
assert_eq!(
actual.header.identity.value,
Some(Id::parse_str("188b911c-5f8b-431c-9eea-2d41f3953767").unwrap())
);
assert_eq!(actual.description.as_ref().unwrap().value, "雪");
assert!(actual.parent.is_none());
let Body::QuantityKind { dimension, algebra } = actual.body else {
panic!("quantity kind")
};
assert_eq!(dimension.value.exponents, [-2, 0, 1, 0, 0, 0, 0]);
assert_eq!(text(source, dimension.keyword), "dimension");
assert_eq!(text(source, dimension.value_span), "{ time 1; length -2; }");
assert_eq!(text(source, dimension.value.entries[1].exponent), "-2");
let AlgebraSyntax::Affine(difference) = algebra.value else {
panic!("affine")
};
assert_eq!(difference.name, "difference");
let Body::Unit { kind, scale, offset, symbol } = declaration("unit millisecond \"Millisecond\" { kind @duration; scale ratio (1, // divisor\n 1000); offset 0; symbol \"ms\"; }").body else { panic!("unit") };
assert_eq!(kind.value.name, "duration");
assert_eq!(scale.value.value.canonical_literal(), "ratio(1, 1000)");
assert_eq!(offset.value.value.canonical_literal(), "0");
assert_eq!(symbol.value, "ms");
let Body::Calculation { parameters, result, body } = declaration("calculation combine \"Combine durations\" { input a id \"28ff40b0-1b8f-4735-8536-5a6ab21b56cb\" : Quantity(@duration); input b id \"7a47cc1e-8ae5-4551-b2d8-8a68108e0976\" : Quantity(@duration); result Quantity(@duration); body $a + $b; }").body else { panic!("calculation") };
assert_eq!(
parameters
.iter()
.map(|p| p.name.name.as_str())
.collect::<Vec<_>>(),
["a", "b"]
);
assert!(!result.value.list);
assert!(
matches!(result.value.scalar.kind, ScalarKind::Quantity(ref r) if r.name == "duration")
);
let Binding::Expression(Expression {
kind: ExpressionKind::Binary { op, left, right },
..
}) = body.value
else {
panic!("binary")
};
assert_eq!(op, BinaryOp::Add);
assert!(matches!(left.kind, ExpressionKind::Parameter(ref r) if r.name == "a"));
assert!(matches!(right.kind, ExpressionKind::Parameter(ref r) if r.name == "b"));
for (word, layer) in [("oa_property", Layer::Oa), ("sa_property", Layer::Sa)] {
let actual = declaration(&format!(
"{word} receive_time \"Receive time\" {{ subject @receive; type Quantity(@duration); base 750 [@millisecond]; display_unit @millisecond; parent none; }}"
));
assert_eq!(actual.header.kind, DeclarationKind::Property(layer));
assert!(actual.parent.is_some());
let Body::Property {
subject,
value_type,
base,
display_unit,
} = actual.body
else {
panic!("property")
};
assert_eq!(subject.value.name, "receive");
assert!(matches!(
value_type.value.scalar.kind,
ScalarKind::Quantity(_)
));
assert!(matches!(
base.value,
Binding::Expression(Expression {
kind: ExpressionKind::Quantity { .. },
..
})
));
assert_eq!(display_unit.unwrap().value.unwrap().name, "millisecond");
}
for word in ["oa_check", "sa_check"] {
let Body::Check { subject, documented_constraint, predicate } = declaration(&format!("{word} budget \"Response budget\" {{ subject none; documented_constraint @constraint; predicate @total_time <= @limit; }}")).body else { panic!("check") };
assert!(subject.unwrap().value.is_none());
assert_eq!(
documented_constraint.unwrap().value.unwrap().name,
"constraint"
);
assert!(matches!(
predicate.value,
Binding::Expression(Expression {
kind: ExpressionKind::Binary {
op: BinaryOp::LessOrEqual,
..
},
..
})
));
}
for word in ["oa_analysis", "sa_analysis"] {
let Body::Case { subject, base, overrides } = declaration(&format!("{word} degraded \"Degraded radio\" {{ subject @respond; base ModelBaseline; overrides {{ @receive_time = unbound; }}; }}")).body else { panic!("case") };
assert_eq!(subject.unwrap().value.unwrap().name, "respond");
assert!(base.value_span.end > base.value_span.start);
assert_eq!(overrides.value.len(), 1);
assert_eq!(overrides.value[0].target.name, "receive_time");
assert!(matches!(overrides.value[0].binding, Binding::Unbound(_)));
}
}
#[test]
fn empty_unbound_and_all_scalar_list_types_are_explicit_valid_syntax() {
for (spelling, list, kind) in [
("Boolean", false, 0),
("Number", false, 1),
("Quantity(@duration)", false, 2),
("List(Boolean)", true, 0),
("List(Number)", true, 1),
("List(Quantity(@duration))", true, 2),
] {
let Body::Calculation {
parameters,
result,
body,
} = declaration(&format!(
"calculation c \"C\" {{ result {spelling}; body unbound; }}"
))
.body
else {
panic!()
};
assert!(parameters.is_empty());
assert_eq!(result.value.list, list);
assert_eq!(
match result.value.scalar.kind {
ScalarKind::Boolean => 0,
ScalarKind::Number => 1,
ScalarKind::Quantity(_) => 2,
},
kind
);
assert!(matches!(body.value, Binding::Unbound(_)));
}
let Body::QuantityKind { dimension, algebra } =
declaration("quantity_kind q \"Q\" { dimension {}; algebra Linear; }").body
else {
panic!()
};
assert_eq!(dimension.value.exponents, [0; 7]);
assert!(dimension.value.entries.is_empty());
assert_eq!(algebra.value, AlgebraSyntax::Linear);
let Body::Case {
subject, overrides, ..
} = declaration("oa_analysis c \"C\" { base ModelBaseline; overrides {}; }").body
else {
panic!()
};
assert!(subject.is_none());
assert!(overrides.value.is_empty());
let Body::Check {
subject,
documented_constraint,
..
} = declaration("oa_check c \"C\" { predicate unbound; }").body
else {
panic!()
};
assert!(subject.is_none() && documented_constraint.is_none());
let Body::Property { display_unit, .. } =
declaration("oa_property p \"P\" { subject @s; type Number; base unbound; }").body
else {
panic!()
};
assert!(display_unit.is_none());
}
#[test]
fn missing_required_fields_and_unknown_or_duplicate_entries_reject_locally() {
let required_sets: &[(&str, &[&str])] = &[
(
"quantity_kind q \"Q\"",
&["dimension {};", "algebra Linear;"],
),
(
"unit u \"U\"",
&["kind @q;", "scale 1;", "offset 0;", "symbol \"u\";"],
),
("calculation c \"C\"", &["result Number;", "body unbound;"]),
(
"oa_property p \"P\"",
&["subject @s;", "type Number;", "base unbound;"],
),
("oa_check c \"C\"", &["predicate unbound;"]),
(
"oa_analysis a \"A\"",
&["base ModelBaseline;", "overrides {};"],
),
];
for (header, fields) in required_sets {
declaration(&format!("{header} {{ {} }}", fields.join(" ")));
for removed in 0..fields.len() {
let remaining = fields
.iter()
.enumerate()
.filter_map(|(i, field)| (i != removed).then_some(*field))
.collect::<Vec<_>>()
.join(" ");
let error = failure(&format!("{header} {{ {remaining} }}"));
assert!(
error.message.contains("Missing required field"),
"{error:?}"
);
let duplicate = format!("{header} {{ {} {} }}", fields.join(" "), fields[removed]);
assert!(failure(&duplicate).message.contains("Duplicate field"));
}
}
for source in [
"quantity_kind q \"Q\" { dimension { time 1; time 2; }; algebra Linear; }",
"quantity_kind q \"Q\" { dimension { unknown 1; }; algebra Linear; }",
"quantity_kind q \"Q\" { dimension { time 33; }; algebra Linear; }",
"unit u \"U\" { kind @q; scale 1 + 2; offset 0; symbol \"u\"; }",
"unit u \"U\" { kind @q; scale 1 [@u]; offset 0; symbol \"u\"; }",
"unit u \"U\" { kind @q; scale ratio(1, 0); offset 0; symbol \"u\"; }",
"calculation c \"C\" { result List(List(Number)); body unbound; }",
"calculation c \"C\" { input x : Number; input x : Number; result Number; body unbound; }",
"calculation c \"C\" { input x id \"28ff40b0-1b8f-4735-8536-5a6ab21b56cb\" : Number; input y id \"28ff40b0-1b8f-4735-8536-5a6ab21b56cb\" : Number; result Number; body unbound; }",
"oa_property p \"P\" { subject none; type Number; base unbound; }",
"oa_property p \"P\" { subject @s; type Number; base none; }",
"oa_property p \"P\" { subject @s; type Number; base unbound; parent @s; }",
"oa_analysis a \"A\" { base @another; overrides {}; }",
"oa_analysis a \"A\" { base ModelBaseline; overrides { @p = clear; }; }",
"oa_analysis a \"A\" { base ModelBaseline; overrides { @p = 1; @p = 2; }; }",
"oa_check c \"C\" { predicate @f(x: 1, x: 2); }",
"quantity_kind q \"Q\" { dimension {}; algebra Linear; parent none; }",
"oa_check c \"C\" { predicate unbound; invented none; }",
] {
failure(source);
}
let duplicate = "oa_analysis a \"A\" { base ModelBaseline; overrides { @p = 1; @p = 2; }; }";
let error = failure(duplicate);
assert_eq!(text(duplicate, error.span), "p");
assert!(error.span.start > duplicate.find("@p").unwrap() + 1);
}
#[test]
fn borrowed_cursor_preserves_full_file_spans_and_does_not_rescan_fields() {
let source = "// 雪\r\ncalculation c \"C\" { input a : Number; result Number; body @combine(a: ratio (1, // first\r\n 2), b: $a) + @combine(a: 2, b: $a); }\r\nunit u \"U\" { kind @q; scale 1; offset 0; symbol \"u\"; }";
let mut shared = budget();
let tokens = lex::tokens(source, &mut shared).unwrap();
let lexical = shared.usage;
let mut cursor = Cursor::new(source, &tokens, &mut shared);
let first = parse(&mut cursor, 8, None).unwrap();
assert_eq!(cursor.text(), "unit");
let first_end = first.span.end;
let second = parse(&mut cursor, 8, None).unwrap();
cursor.end().unwrap();
assert!(second.span.start > first_end);
assert_eq!(text(source, first.header.kind_span), "calculation");
assert_eq!(text(source, first.header.name_span), "\"C\"");
assert_eq!(text(source, second.header.alias.span), "u");
assert_eq!(
text(source, first.body_span).trim().strip_prefix("input"),
Some(
" a : Number; result Number; body @combine(a: ratio (1, // first\r\n 2), b: $a) + @combine(a: 2, b: $a);"
)
);
let Body::Calculation {
parameters, body, ..
} = first.body
else {
panic!()
};
assert_eq!(text(source, parameters[0].span), "input a : Number;");
assert_eq!(text(source, parameters[0].value_type.span), "Number");
let Binding::Expression(Expression {
kind: ExpressionKind::Binary { left, right, .. },
..
}) = body.value
else {
panic!()
};
let ExpressionKind::Call {
arguments: left, ..
} = left.kind
else {
panic!()
};
let ExpressionKind::Call {
arguments: right, ..
} = right.kind
else {
panic!()
};
let spans = [
left[0].parameter.span,
left[1].parameter.span,
right[0].parameter.span,
right[1].parameter.span,
];
assert_eq!(spans.map(|span| text(source, span)), ["a", "b", "a", "b"]);
assert!(spans.windows(2).all(|pair| pair[0].end < pair[1].start));
let literal = left[0].value.literal.as_ref().unwrap();
assert_eq!(text(source, literal.span), "ratio (1, // first\r\n 2)");
assert_eq!(
literal
.tokens
.iter()
.map(|span| text(source, *span))
.collect::<Vec<_>>(),
["ratio", "(", "1", ",", "2", ")"]
);
assert_eq!(shared.usage.bytes, source.len());
assert_eq!(shared.usage.bytes, lexical.bytes);
assert_eq!(shared.usage.tokens, lexical.tokens);
assert_eq!(shared.usage.references, 9); // two calls, four keys, two $a, unit kind.
}
#[test]
fn missing_id_metadata_is_stable_and_parameter_ids_are_owner_scoped() {
let source =
"calculation c \"C\" { input a // identity here\r\n : Number; result Number; body $a; }";
let first = declaration(source);
let second = declaration(source);
assert_eq!(first, second);
assert_eq!(
first.header.identity.insertion,
Some(source.find('{').unwrap())
);
let Body::Calculation { parameters, .. } = first.body else {
panic!()
};
let id = &parameters[0].identity;
assert!(id.value.is_none() && id.literal.is_none() && id.keyword.is_none());
assert_eq!(id.insertion, Some(source.find(':').unwrap()));
let explicit = "28ff40b0-1b8f-4735-8536-5a6ab21b56cb";
// Syntax does not add ParameterIds to a global identity registry, and an
// explicit parameter ID can equal its own calculation's global UUID.
for name in ["one", "two"] {
let source = format!(
"calculation {name} \"C\" id \"{explicit}\" {{ input a id \"{explicit}\" : Number; result Number; body $a; }}"
);
let actual = declaration(&source);
let Body::Calculation { parameters, .. } = actual.body else {
panic!()
};
assert_eq!(parameters[0].identity.value, actual.header.identity.value);
assert!(parameters[0].identity.insertion.is_none());
}
}
#[test]
fn inherited_version_root_and_short_word_contracts_do_not_enable_public8() {
let source = "oa_check c \"C\" { predicate unbound; }";
for version in [1, 2, 3, 4, 5, 6, 7, 9] {
let mut budget = budget();
let tokens = lex::tokens(source, &mut budget).unwrap();
let error = parse(
&mut Cursor::new(source, &tokens, &mut budget),
version,
None,
)
.unwrap_err();
assert_eq!(text(source, error.span), "oa_check");
assert!(error.message.contains("source8"));
}
let mut budget = budget();
let tokens = lex::tokens(source, &mut budget).unwrap();
assert!(
parse(
&mut Cursor::new(source, &tokens, &mut budget),
8,
Some(Id::nil())
)
.unwrap_err()
.message
.contains("roots")
);
for word in [
"oa_engineering_property",
"sa_engineering_property",
"oa_analysis_check",
"sa_analysis_check",
"oa_analysis_case",
"sa_analysis_case",
] {
assert!(declaration_kind(word).is_none());
}
for alias in [
"unit",
"calculation",
"quantity_kind",
"oa_property",
"if",
"and",
"List",
] {
let old = format!("arcadia 7; project {alias} \"P\" {{}}");
assert!(crate::language::parse(&old).is_ok(), "{old}");
}
let contextual =
declaration("calculation unit \"C\" { input and : Number; result Number; body $and; }");
assert_eq!(contextual.header.alias.name, "unit");
assert!(crate::language::parse("arcadia 8; project p \"P\" {}").is_err());
for version in 3..=7 {
assert!(
crate::language::parse(&format!(
"arcadia {version}; project p \"P\" {{ {source} }}"
))
.is_err()
);
}
}
#[test]
fn declaration_budgets_share_literals_types_references_and_failure_state() {
let source = "unit u \"U\" { kind @q; scale ratio(1, 3); offset ratio(2, 7); symbol \"u\"; }";
let mut measured = budget();
let tokens = lex::tokens(source, &mut measured).unwrap();
parse(&mut Cursor::new(source, &tokens, &mut measured), 8, None).unwrap();
let numeric = measured.numeric_units();
assert!(numeric > 0);
// All declaration, field and type entries share the same preallocation
// ceiling. Measure a deterministic valid counterpart before reducing it.
for (maximum, succeeds) in [
(measured.usage.entries, true),
(measured.usage.entries - 1, false),
] {
let mut profile = limits();
profile.entries = maximum;
let mut shared = Budget::new(profile).unwrap();
let tokens = lex::tokens(source, &mut shared).unwrap();
let result = parse(&mut Cursor::new(source, &tokens, &mut shared), 8, None);
assert_eq!(result.is_ok(), succeeds);
if !succeeds {
assert!(result.unwrap_err().message.contains("entries"));
}
}
for (maximum, succeeds) in [(numeric, true), (numeric - 1, false)] {
let mut profile = limits();
profile.numeric_units = maximum;
let mut shared = Budget::new(profile).unwrap();
let tokens = lex::tokens(source, &mut shared).unwrap();
let result = parse(&mut Cursor::new(source, &tokens, &mut shared), 8, None);
assert_eq!(result.is_ok(), succeeds);
}
let first =
"calculation c \"C\" { input x : Quantity(@q); result Quantity(@q); body unbound; }";
let second = "oa_property p \"P\" { subject @s; type Quantity(@q); base 1 [@u]; }";
let mut profile = limits();
profile.references = 4;
let mut shared = Budget::new(profile).unwrap();
let tokens = lex::tokens(first, &mut shared).unwrap();
parse(&mut Cursor::new(first, &tokens, &mut shared), 8, None).unwrap();
assert_eq!(shared.usage.references, 2);
let tokens = lex::tokens(second, &mut shared).unwrap();
let error = parse(&mut Cursor::new(second, &tokens, &mut shared), 8, None).unwrap_err();
assert_eq!(text(second, error.span), "u");
assert_eq!(shared.usage.references, 4);
assert_eq!(shared.active().unwrap_err(), error);
let numeric_after_failure = shared.numeric_units();
assert!(lex::tokens("1", &mut shared).is_err());
assert_eq!(shared.numeric_units(), numeric_after_failure);
let unbound_tokens = lex::tokens("unbound", &mut budget()).unwrap();
assert_eq!(
syntax::binding(&mut Cursor::new("unbound", &unbound_tokens, &mut shared)).unwrap_err(),
error
);
assert!(
declaration("oa_check fresh \"Fresh\" { predicate true; }")
.parent
.is_none()
);
}
#[test]
fn typed_frame_budget_and_truncated_declaration_cleanup_have_nearby_successes() {
let source = "calculation c \"C\" { result List(Quantity(@q)); body unbound; }";
for (maximum, succeeds) in [(3, true), (2, false)] {
let mut profile = limits();
profile.frames = maximum;
let mut shared = Budget::new(profile).unwrap();
let tokens = lex::tokens(source, &mut shared).unwrap();
assert_eq!(
parse(&mut Cursor::new(source, &tokens, &mut shared), 8, None).is_ok(),
succeeds
);
}
let source = "oa_analysis a \"A\" { base ModelBaseline; overrides { @p = if(true, ratio(1, 2), @f(a: 1)); }; }";
let tokens = lex::tokens(source, &mut budget()).unwrap();
for token in tokens
.iter()
.filter(|token| matches!(text(source, token.span), "{" | "(" | "," | ":" | "="))
{
let prefix = &source[..token.span.end];
let mut shared = budget();
let tokens = lex::tokens(prefix, &mut shared).unwrap();
assert!(parse(&mut Cursor::new(prefix, &tokens, &mut shared), 8, None).is_err());
declaration("oa_check fresh \"Fresh\" { predicate true; }");
}
}
#[test]
fn typed_brace_frames_have_exact_zero_one_two_boundaries() {
let mut profile = limits();
profile.frames = 0;
let mut shared = Budget::new(profile).unwrap();
let tokens = lex::tokens("unbound", &mut shared).unwrap();
let mut cursor = Cursor::new("unbound", &tokens, &mut shared);
assert!(matches!(
syntax::binding(&mut cursor).unwrap(),
Binding::Unbound(_)
));
assert_eq!(cursor.frames, 0);
assert_eq!(shared.usage.deepest_frame, 0);
for (source, needed) in [
("calculation c \"C\" { result Number; body unbound; }", 1),
("quantity_kind q \"Q\" { dimension {}; algebra Linear; }", 2),
(
"oa_analysis a \"A\" { base ModelBaseline; overrides {}; }",
2,
),
] {
for maximum in 0..=2 {
let mut profile = limits();
profile.frames = maximum;
let mut shared = Budget::new(profile).unwrap();
let tokens = lex::tokens(source, &mut shared).unwrap();
let mut cursor = Cursor::new(source, &tokens, &mut shared);
let result = parse(&mut cursor, 8, None);
assert_eq!(
result.is_ok(),
maximum >= needed,
"{source}, limit {maximum}"
);
if let Err(error) = result {
assert!(error.message.contains("frames"));
assert_eq!(text(source, error.span), "{");
assert_eq!(
error.span.start,
if maximum == 0 {
source.find('{').unwrap()
} else {
source.rfind('{').unwrap()
}
);
} else {
assert_eq!(cursor.frames, 0);
cursor.end().unwrap();
assert_eq!(shared.usage.deepest_frame, needed);
}
}
}
}
#[test]
fn declaration_frames_restore_the_callers_inherited_context() {
let source = "calculation a \"A\" { result Number; body unbound; } calculation b \"B\" { result Number; body unbound; }";
for maximum in [1, 2] {
let mut profile = limits();
profile.frames = maximum;
let mut shared = Budget::new(profile).unwrap();
let tokens = lex::tokens(source, &mut shared).unwrap();
let mut cursor = Cursor::new(source, &tokens, &mut shared);
// Future project/include integration owns this outer context. The
// declaration consumer must add to it, then restore it after closing.
cursor.frames = 1;
let first = parse(&mut cursor, 8, None);
if maximum == 1 {
let error = first.unwrap_err();
assert!(error.message.contains("frames"));
assert_eq!(text(source, error.span), "{");
} else {
assert!(first.is_ok());
assert_eq!(cursor.frames, 1);
parse(&mut cursor, 8, None).unwrap();
assert_eq!(cursor.frames, 1);
cursor.end().unwrap();
assert_eq!(shared.usage.deepest_frame, 2);
}
}
}
@@ -1,52 +0,0 @@
//! Source8 significant-token adapter over the shared raw scanner.
use super::super::raw_tokens::{Class as RawClass, Scanner};
use super::{Budget, Error, Span, error};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Class {
Word,
Number,
String,
Punctuation,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Token {
pub class: Class,
pub span: Span,
}
pub fn tokens(source: &str, budget: &mut Budget) -> Result<Vec<Token>, Error> {
budget.bytes(
source.len(),
Span {
start: 0,
end: source.len(),
},
)?;
let mut scanner = Scanner::new(source);
let mut result = Vec::new();
let mut offset = 0;
while offset < source.len() {
// Charge trivia too, before the scanner advances or a token is stored.
budget.token(Span {
start: offset,
end: offset,
})?;
let token = scanner.next().expect("source remains");
offset = token.span.end;
let class = match token.class {
RawClass::Word => Class::Word,
RawClass::Number => Class::Number,
RawClass::String => Class::String,
RawClass::Punctuation => Class::Punctuation,
RawClass::Whitespace | RawClass::Comment => continue,
RawClass::Error => return Err(error(token.span, "Invalid source token.")),
};
result.push(Token {
class,
span: token.span,
});
}
Ok(result)
}
@@ -1,939 +0,0 @@
//! Staged typed resolution. The caller owns parsing, ID stabilization and the
//! single complete-Project semantic admission after merging these additions.
use super::declarations::{self as d, Declaration, DeclarationKind};
use super::*;
use crate::analysis as a;
use crate::analysis_numeric::Dimension;
use crate::{Element, ElementBody, Id, Kind, Layer, Project, SharedBody};
use std::collections::{BTreeMap, BTreeSet};
/// Caller-assigned file key; spans are original offsets within that file.
pub(super) struct Unit {
pub file: usize,
pub declarations: Vec<Declaration>,
}
#[derive(Debug, PartialEq, Eq)]
pub(super) struct Failure {
/// None denotes an invalid ordinary skeleton, before typed resolution.
pub file: Option<usize>,
pub error: Error,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum Category {
Project,
Element(Kind, Layer),
Relation,
Diagram,
Node,
Artifact,
Trace,
QuantityKind,
Unit,
Calculation,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum Target {
Global(Id),
Parameter {
calculation: Id,
parameter: a::ParameterId,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum Field {
Algebra,
Kind,
Input(a::ParameterId),
Result,
Body,
Subject,
Type,
Base,
DisplayUnit,
DocumentedConstraint,
Predicate,
Override(Id),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum Role {
Reference,
ParameterDeclaration,
ParameterUse,
CallTarget,
ArgumentLabel,
OverrideTarget,
}
/// Stable structural edges, independent of alias spelling and argument order.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum Child {
Item(usize),
Operand,
Left,
Right,
Condition,
WhenTrue,
WhenFalse,
Argument(a::ParameterId),
Values,
Initial,
Base,
Value,
}
/// An arena index is only local to this lowering result. Follow parent edges
/// to obtain the owner/field/path identity for S3; never persist the index.
#[derive(Debug, PartialEq, Eq)]
pub(super) struct ExpressionSite {
pub file: usize,
pub owner: Id,
pub field: Field,
pub parent: Option<(usize, Child)>,
pub span: Span,
}
#[derive(Debug, PartialEq, Eq)]
pub(super) struct Occurrence {
pub file: usize,
pub owner: Id,
pub field: Field,
pub expression: Option<usize>,
pub role: Role,
pub target: Target,
/// Alias/name token only, excluding @/$ and surrounding trivia.
pub span: Span,
}
#[derive(Debug)]
pub(super) struct Lowered {
pub definitions: BTreeMap<Id, a::Definition>,
pub elements: BTreeMap<Id, Element>,
pub occurrences: Vec<Occurrence>,
pub expressions: Vec<ExpressionSite>,
}
#[derive(Clone, Copy)]
struct Symbol {
id: Id,
category: Category,
}
#[derive(Default)]
struct Tables {
aliases: BTreeMap<String, Symbol>,
ids: BTreeSet<Id>,
parameters: BTreeMap<Id, BTreeMap<String, a::ParameterId>>,
entries: usize,
}
fn category(kind: DeclarationKind) -> Category {
match kind {
DeclarationKind::QuantityKind => Category::QuantityKind,
DeclarationKind::Unit => Category::Unit,
DeclarationKind::Calculation => Category::Calculation,
DeclarationKind::Property(layer) => Category::Element(Kind::EngineeringProperty, layer),
DeclarationKind::Check(layer) => Category::Element(Kind::AnalysisCheck, layer),
DeclarationKind::Case(layer) => Category::Element(Kind::AnalysisCase, layer),
}
}
fn identity(identity: &d::IdentitySyntax, span: Span) -> Result<Id, Error> {
identity.value.ok_or_else(|| {
error(
span,
"Missing ID requires source stabilization before lowering.",
)
})
}
impl Tables {
fn reserve(&mut self, span: Span, budget: &Budget) -> Result<(), Error> {
budget.lowering_entries(self.entries + 1, span)?;
self.entries += 1;
Ok(())
}
fn insert(
&mut self,
id: Id,
alias: &str,
category: Category,
span: Span,
budget: &Budget,
) -> Result<(), Error> {
// A phase allocation bound, not a second syntax charge. Ordinary
// records, typed headers and scoped parameters share this bound.
self.reserve(span, budget)?;
if self.ids.contains(&id) {
return Err(error(span, "Duplicate global identity."));
}
if self.aliases.contains_key(alias) {
return Err(error(span, format!("Duplicate global alias '{alias}'.")));
}
self.ids.insert(id);
self.aliases
.insert(alias.to_owned(), Symbol { id, category });
Ok(())
}
fn ordinary(&mut self, project: &Project, budget: &Budget) -> Result<(), Error> {
let at = Span::default();
self.insert(project.id, &project.alias, Category::Project, at, budget)?;
for value in project.definitions.values() {
let category = match &value.body {
a::DefinitionBody::QuantityKind(_) => Category::QuantityKind,
a::DefinitionBody::Unit(_) => Category::Unit,
a::DefinitionBody::Calculation(_) => Category::Calculation,
};
self.insert(value.id, &value.alias, category, at, budget)?;
if let a::DefinitionBody::Calculation(calculation) = &value.body {
let mut names = BTreeMap::new();
let mut ids = BTreeSet::new();
for parameter in &calculation.parameters {
self.reserve(at, budget)?;
if names.contains_key(&parameter.name) || !ids.insert(parameter.id) {
return Err(error(
at,
"Duplicate parameter name or identity in ordinary catalog.",
));
}
names.insert(parameter.name.clone(), parameter.id);
}
self.parameters.insert(value.id, names);
}
}
for value in project.elements.values() {
self.insert(
value.id,
&value.alias,
Category::Element(value.kind(), value.layer()),
at,
budget,
)?;
}
for value in project.relations.values() {
self.insert(value.id, &value.alias, Category::Relation, at, budget)?;
}
for value in project.diagrams.values() {
self.insert(value.id, &value.alias, Category::Diagram, at, budget)?;
for node in value.nodes.values() {
self.insert(node.id, &node.alias, Category::Node, at, budget)?;
}
}
for value in project.artifact_links.values() {
self.insert(value.id, &value.alias, Category::Artifact, at, budget)?;
}
for value in project.traces.values() {
self.insert(value.id, &value.alias, Category::Trace, at, budget)?;
}
Ok(())
}
fn typed(&mut self, declaration: &Declaration, budget: &Budget) -> Result<(), Error> {
let header = &declaration.header;
let id = identity(&header.identity, header.alias.span)?;
self.insert(
id,
&header.alias.name,
category(header.kind),
header.alias.span,
budget,
)?;
if let d::Body::Calculation { parameters, .. } = &declaration.body {
let mut names = BTreeMap::new();
let mut ids = BTreeSet::new();
for parameter in parameters {
self.reserve(parameter.span, budget)?;
let id = a::ParameterId(identity(&parameter.identity, parameter.name.span)?);
if names.contains_key(&parameter.name.name) || !ids.insert(id) {
return Err(error(
parameter.name.span,
"Duplicate parameter name or identity.",
));
}
names.insert(parameter.name.name.clone(), id);
}
self.parameters.insert(id, names);
}
Ok(())
}
}
/// Inputs must come from the same live Budget and stabilization pass. No scan,
/// exact conversion or reference charge occurs here. The ordinary skeleton is
/// not semantically admitted: its typed forward references may need additions.
pub(super) fn lower(
ordinary: &Project,
units: &[Unit],
budget: &Budget,
) -> Result<Lowered, Failure> {
budget
.active()
.map_err(|error| Failure { file: None, error })?;
let mut tables = Tables::default();
tables
.ordinary(ordinary, budget)
.map_err(|error| Failure { file: None, error })?;
let mut files = BTreeSet::new();
for unit in units {
budget
.lowering_entries(files.len() + 1, Span::default())
.map_err(|error| Failure {
file: Some(unit.file),
error,
})?;
if !files.insert(unit.file) {
return Err(Failure {
file: Some(unit.file),
error: error(Span::default(), "Duplicate source file key."),
});
}
for declaration in &unit.declarations {
tables.typed(declaration, budget).map_err(|error| Failure {
file: Some(unit.file),
error,
})?;
}
}
let mut output = Lowered {
definitions: BTreeMap::new(),
elements: BTreeMap::new(),
occurrences: Vec::new(),
expressions: Vec::new(),
};
for unit in units {
for declaration in &unit.declarations {
let owner = declaration
.header
.identity
.value
.expect("collected stable identity");
let mut resolver = Resolver {
tables: &tables,
output: &mut output,
budget,
file: unit.file,
owner,
};
resolver.declaration(declaration).map_err(|error| Failure {
file: Some(unit.file),
error,
})?;
}
}
Ok(output)
}
struct Resolver<'a> {
tables: &'a Tables,
output: &'a mut Lowered,
budget: &'a Budget,
file: usize,
owner: Id,
}
impl Resolver<'_> {
fn occurrence(
&mut self,
reference: &Reference,
field: Field,
expression: Option<usize>,
role: Role,
target: Target,
) -> Result<(), Error> {
self.budget
.lowering_occurrences(self.output.occurrences.len() + 1, reference.span)?;
self.output.occurrences.push(Occurrence {
file: self.file,
owner: self.owner,
field,
expression,
role,
target,
span: reference.span,
});
Ok(())
}
fn global(
&mut self,
reference: &Reference,
field: Field,
expression: Option<usize>,
role: Role,
expected: impl FnOnce(Category) -> bool,
) -> Result<Symbol, Error> {
let symbol = *self.tables.aliases.get(&reference.name).ok_or_else(|| {
error(
reference.span,
format!("Unknown global reference '@{}'.", reference.name),
)
})?;
if !expected(symbol.category) {
return Err(error(
reference.span,
format!(
"Wrong reference domain for '@{}': {:?}.",
reference.name, symbol.category
),
));
}
self.occurrence(
reference,
field,
expression,
role,
Target::Global(symbol.id),
)?;
Ok(symbol)
}
fn definition_ref(
&mut self,
reference: &Reference,
field: Field,
expression: Option<usize>,
expected: Category,
) -> Result<a::DefinitionRef, Error> {
self.global(reference, field, expression, Role::Reference, |category| {
category == expected
})
.map(|symbol| a::DefinitionRef::Local(symbol.id))
}
fn value_type(
&mut self,
value: &d::TypeSyntax,
field: Field,
) -> Result<a::AnalysisType, Error> {
let scalar =
match &value.scalar.kind {
d::ScalarKind::Boolean => a::ScalarType::Boolean,
d::ScalarKind::Number => a::ScalarType::Number,
d::ScalarKind::Quantity(reference) => a::ScalarType::Quantity(
self.definition_ref(reference, field, None, Category::QuantityKind)?,
),
};
Ok(if value.list {
a::AnalysisType::List(scalar)
} else {
a::AnalysisType::Scalar(scalar)
})
}
fn subject(&mut self, reference: &Reference) -> Result<a::AnalysisSubject, Error> {
let symbol = self.global(
reference,
Field::Subject,
None,
Role::Reference,
|category| matches!(category, Category::Element(..) | Category::Relation),
)?;
Ok(if symbol.category == Category::Relation {
a::AnalysisSubject::Relation(symbol.id)
} else {
a::AnalysisSubject::Element(symbol.id)
})
}
fn optional_subject(
&mut self,
subject: &Option<d::Field<Option<Reference>>>,
) -> Result<Option<a::AnalysisSubject>, Error> {
subject
.as_ref()
.and_then(|field| field.value.as_ref())
.map(|reference| self.subject(reference))
.transpose()
}
fn declaration(&mut self, declaration: &Declaration) -> Result<(), Error> {
let header = &declaration.header;
let definition = match &declaration.body {
d::Body::QuantityKind { dimension, algebra } => {
Some(a::DefinitionBody::QuantityKind(a::QuantityKindDefinition {
dimension: Dimension::new(dimension.value.exponents)
.map_err(|cause| error(dimension.value_span, cause.to_string()))?,
algebra: match &algebra.value {
d::AlgebraSyntax::Linear => a::QuantityAlgebra::Linear,
d::AlgebraSyntax::Affine(reference) => a::QuantityAlgebra::Affine {
difference: self.definition_ref(
reference,
Field::Algebra,
None,
Category::QuantityKind,
)?,
},
},
}))
}
d::Body::Unit {
kind,
scale,
offset,
symbol,
} => Some(a::DefinitionBody::Unit(a::UnitDefinition {
kind: self.definition_ref(
&kind.value,
Field::Kind,
None,
Category::QuantityKind,
)?,
scale: scale.value.value.clone(),
offset: offset.value.value.clone(),
symbol: symbol.value.clone(),
})),
d::Body::Calculation {
parameters,
result,
body,
} => {
let mut lowered = Vec::new();
for parameter in parameters {
self.budget
.lowering_entries(lowered.len() + 1, parameter.span)?;
let id = self.tables.parameters[&self.owner][&parameter.name.name];
self.occurrence(
&parameter.name,
Field::Input(id),
None,
Role::ParameterDeclaration,
Target::Parameter {
calculation: self.owner,
parameter: id,
},
)?;
lowered.push(a::Parameter {
id,
name: parameter.name.name.clone(),
value_type: self.value_type(&parameter.value_type, Field::Input(id))?,
});
}
Some(a::DefinitionBody::Calculation(a::CalculationDefinition {
parameters: lowered,
result: self.value_type(&result.value, Field::Result)?,
body: self.binding(&body.value, Field::Body, Some(self.owner))?,
}))
}
_ => None,
};
let description = declaration
.description
.as_ref()
.map_or_else(String::new, |field| field.value.clone());
if let Some(body) = definition {
self.output.definitions.insert(
self.owner,
a::Definition {
id: self.owner,
alias: header.alias.name.clone(),
name: header.name.clone(),
description,
body,
},
);
return Ok(());
}
let (layer, body) = match (&declaration.body, header.kind) {
(
d::Body::Property {
subject,
value_type,
base,
display_unit,
},
DeclarationKind::Property(layer),
) => (
layer,
SharedBody::EngineeringProperty {
subject: self.subject(&subject.value)?,
value_type: self.value_type(&value_type.value, Field::Type)?,
base: self.binding(&base.value, Field::Base, None)?,
display_unit: display_unit
.as_ref()
.and_then(|field| field.value.as_ref())
.map(|reference| {
self.definition_ref(reference, Field::DisplayUnit, None, Category::Unit)
})
.transpose()?,
},
),
(
d::Body::Check {
subject,
documented_constraint,
predicate,
},
DeclarationKind::Check(layer),
) => (
layer,
SharedBody::AnalysisCheck {
subject: self.optional_subject(subject)?,
documented_constraint: documented_constraint
.as_ref()
.and_then(|field| field.value.as_ref())
.map(|reference| {
self.global(
reference,
Field::DocumentedConstraint,
None,
Role::Reference,
|category| {
matches!(category, Category::Element(Kind::Constraint, _))
},
)
.map(|symbol| symbol.id)
})
.transpose()?,
predicate: self.binding(&predicate.value, Field::Predicate, None)?,
},
),
(
d::Body::Case {
subject, overrides, ..
},
DeclarationKind::Case(layer),
) => {
let subject = self.optional_subject(subject)?;
let mut lowered = BTreeMap::new();
for entry in &overrides.value {
let property = self
.global(
&entry.target,
Field::Base,
None,
Role::OverrideTarget,
|category| {
matches!(category, Category::Element(Kind::EngineeringProperty, _))
},
)?
.id;
// Replace the placeholder field after resolution so the key
// and its binding share their stable override identity.
self.output
.occurrences
.last_mut()
.expect("override occurrence")
.field = Field::Override(property);
if lowered.contains_key(&property) {
return Err(error(
entry.target.span,
"Duplicate resolved override target.",
));
}
self.budget
.lowering_entries(lowered.len() + 1, entry.span)?;
lowered.insert(
property,
self.binding(&entry.binding, Field::Override(property), None)?,
);
}
(
layer,
SharedBody::AnalysisCase {
subject,
base: a::BaselineBase::ModelBaseline,
overrides: lowered,
},
)
}
_ => {
return Err(error(
header.kind_span,
"Typed declaration header/body mismatch.",
));
}
};
self.output.elements.insert(
self.owner,
Element {
id: self.owner,
alias: header.alias.name.clone(),
name: header.name.clone(),
description,
parent: None,
body: ElementBody::shared_in(layer, body),
},
);
Ok(())
}
fn binding(
&mut self,
binding: &Binding,
field: Field,
calculation: Option<Id>,
) -> Result<a::Binding, Error> {
match binding {
Binding::Unbound(_) => Ok(a::Binding::Unbound),
Binding::Expression(expression) => self
.expression(expression, field, calculation)
.map(a::Binding::Expression),
}
}
fn call(
&mut self,
definition: &Reference,
arguments: &[Argument],
field: Field,
site: usize,
) -> Result<Call, Error> {
let calculation = self
.global(
definition,
field,
Some(site),
Role::CallTarget,
|category| category == Category::Calculation,
)?
.id;
let tables = self.tables;
let signature = &tables.parameters[&calculation];
let mut parameters = Vec::new();
let mut seen = BTreeSet::new();
for argument in arguments {
self.budget
.lowering_entries(parameters.len() + 1, argument.parameter.span)?;
let parameter = *signature.get(&argument.parameter.name).ok_or_else(|| {
error(
argument.parameter.span,
"Unknown argument name in called calculation.",
)
})?;
if !seen.insert(parameter) {
return Err(error(
argument.parameter.span,
"Duplicate resolved argument parameter.",
));
}
self.occurrence(
&argument.parameter,
field,
Some(site),
Role::ArgumentLabel,
Target::Parameter {
calculation,
parameter,
},
)?;
parameters.push(parameter);
}
// Argument-set/type checking remains in existing semantic admission.
Ok(Call {
calculation,
parameters,
})
}
fn expression(
&mut self,
root: &Expression,
field: Field,
calculation: Option<Id>,
) -> Result<a::Expr, Error> {
enum Work<'a> {
Visit(&'a Expression, Option<(usize, Child)>),
Build(&'a Expression, usize, Option<Call>, usize),
}
let mut work = vec![Work::Visit(root, None)];
let mut values = Vec::new();
while let Some(task) = work.pop() {
match task {
Work::Visit(expression, parent) => {
self.budget
.lowering_entries(self.output.expressions.len() + 1, expression.span)?;
let site = self.output.expressions.len();
self.output.expressions.push(ExpressionSite {
file: self.file,
owner: self.owner,
field,
parent,
span: expression.span,
});
let call = if let ExpressionKind::Call {
definition,
arguments,
} = &expression.kind
{
Some(self.call(definition, arguments, field, site)?)
} else {
None
};
let child_count = match &expression.kind {
ExpressionKind::List(items) => items.len(),
ExpressionKind::Call { arguments, .. } => arguments.len(),
ExpressionKind::Unary { .. }
| ExpressionKind::Power { .. }
| ExpressionKind::AsKind { .. } => 1,
ExpressionKind::Binary { .. } | ExpressionKind::Sum { .. } => 2,
ExpressionKind::If { .. } => 3,
_ => 0,
};
self.budget
.lowering_entries(work.len() + child_count + 1, expression.span)?;
let mut children = Vec::with_capacity(child_count);
match &expression.kind {
ExpressionKind::List(items) => children.extend(
items
.iter()
.enumerate()
.map(|(index, child)| (child, Child::Item(index))),
),
ExpressionKind::Unary { operand, .. } => {
children.push((operand.as_ref(), Child::Operand))
}
ExpressionKind::Binary { left, right, .. } => children
.extend([(left.as_ref(), Child::Left), (right.as_ref(), Child::Right)]),
ExpressionKind::If {
condition,
when_true,
when_false,
} => children.extend([
(condition.as_ref(), Child::Condition),
(when_true.as_ref(), Child::WhenTrue),
(when_false.as_ref(), Child::WhenFalse),
]),
ExpressionKind::Call { arguments, .. } => children.extend(
arguments
.iter()
.zip(&call.as_ref().expect("call signature").parameters)
.map(|(argument, id)| (&argument.value, Child::Argument(*id))),
),
ExpressionKind::Sum { values, initial } => children.extend([
(values.as_ref(), Child::Values),
(initial.as_ref(), Child::Initial),
]),
ExpressionKind::Power { base, .. } => {
children.push((base.as_ref(), Child::Base))
}
ExpressionKind::AsKind { value, .. } => {
children.push((value.as_ref(), Child::Value))
}
_ => {}
}
work.push(Work::Build(expression, site, call, children.len()));
work.extend(
children
.into_iter()
.rev()
.map(|(child, edge)| Work::Visit(child, Some((site, edge)))),
);
}
Work::Build(expression, site, call, count) => {
let start = values.len() - count;
let mut children = values.split_off(start).into_iter();
let next = |children: &mut std::vec::IntoIter<a::Expr>| {
Box::new(children.next().expect("visited expression child"))
};
use ExpressionKind as E;
let value = match &expression.kind {
E::Boolean(value) => a::Expr::Boolean(*value),
E::Number(value) => a::Expr::Number(value.clone()),
E::Quantity { magnitude, unit } => a::Expr::Quantity {
magnitude: magnitude.clone(),
unit: self.definition_ref(unit, field, Some(site), Category::Unit)?,
},
E::Property(reference) => a::Expr::Property(
self.global(
reference,
field,
Some(site),
Role::Reference,
|category| {
matches!(
category,
Category::Element(Kind::EngineeringProperty, _)
)
},
)?
.id,
),
E::Parameter(reference) => {
let calculation = calculation.ok_or_else(|| {
error(
reference.span,
"Parameter use is only valid in its calculation body.",
)
})?;
let parameter = *self.tables.parameters[&calculation]
.get(&reference.name)
.ok_or_else(|| {
error(
reference.span,
"Unknown parameter in owning calculation.",
)
})?;
self.occurrence(
reference,
field,
Some(site),
Role::ParameterUse,
Target::Parameter {
calculation,
parameter,
},
)?;
a::Expr::Parameter(parameter)
}
E::List(_) => a::Expr::List(children.collect()),
E::Unary { op, .. } => a::Expr::Unary {
op: *op,
operand: next(&mut children),
},
E::Binary { op, .. } => a::Expr::Binary {
op: *op,
left: next(&mut children),
right: next(&mut children),
},
E::If { .. } => a::Expr::If {
condition: next(&mut children),
when_true: next(&mut children),
when_false: next(&mut children),
},
E::Call { .. } => {
let call = call.expect("resolved call");
a::Expr::Call {
definition: a::DefinitionRef::Local(call.calculation),
arguments: call.parameters.into_iter().zip(children).collect(),
}
}
E::Sum { .. } => a::Expr::Sum {
values: next(&mut children),
initial: next(&mut children),
},
E::Power { exponent, .. } => a::Expr::Power {
base: next(&mut children),
exponent: *exponent,
},
E::AsKind { kind, .. } => a::Expr::AsKind {
kind: self.definition_ref(
kind,
field,
Some(site),
Category::QuantityKind,
)?,
value: next(&mut children),
},
};
self.budget
.lowering_entries(values.len() + 1, expression.span)?;
values.push(value);
}
}
}
Ok(values.pop().expect("one lowered expression"))
}
}
struct Call {
calculation: Id,
parameters: Vec<a::ParameterId>,
}
#[cfg(test)]
mod tests;
@@ -1,7 +0,0 @@
arcadia 7;
project response_budget "Response budget" id "4210b9f2-7046-47f0-aa3b-153f626a579d" {
oa_activity respond "Respond" id "cabec6a1-7d5d-48f3-8d2d-a7fc8cf78f59" {
oa_activity receive "Receive" id "4ab9a8a7-7552-488d-9862-e14b89e7b353" {}
oa_activity dispatch "Dispatch" id "6df991ba-4c5e-4535-99e2-524ae274ee8b" {}
}
}
@@ -1,7 +0,0 @@
arcadia 7;
project response_budget "Response budget" id "c210b9f2-7046-47f0-aa3b-153f626a579d" {
sa_function respond "Respond" id "4abec6a1-7d5d-48f3-8d2d-a7fc8cf78f59" {
sa_function receive "Receive" id "cab9a8a7-7552-488d-9862-e14b89e7b353" {}
sa_function dispatch "Dispatch" id "edf991ba-4c5e-4535-99e2-524ae274ee8b" {}
}
}
@@ -1,43 +0,0 @@
// Staged typed roots from the r2 example; ordinary roots are supplied separately.
// Forward uses precede the neutral definitions. 雪 keeps offsets UTF-8 sensitive.
oa_property receive_time "Receive time" id "63a931c2-675a-4fce-89e5-04361cb99450" {
subject @receive; type Quantity(@duration); display_unit @millisecond;
base 750 [@millisecond];
}
oa_property dispatch_time "Dispatch time" id "c33fc772-2a60-4044-9af9-73c61c563ace" {
subject @dispatch; type Quantity(@duration); display_unit @second;
base 1 [@second];
}
oa_property total_time "Total time" id "bd5b68e8-65fc-4e35-9071-a73061316a4b" {
subject @respond; type Quantity(@duration); display_unit @second;
base @combine(a: @receive_time, b: @dispatch_time);
}
oa_property limit "Allowed duration" id "437bedbb-2cf4-44ee-a139-6563cc5086ec" {
subject @respond; type Quantity(@duration); display_unit @second;
base 2 [@second];
}
oa_check budget "Response budget" id "bac0de99-9d2f-4e53-9158-d424ecac61bf" {
subject none; documented_constraint none; predicate @total_time <= @limit;
}
oa_analysis degraded "Degraded radio" id "38036c65-16d4-449c-837b-91eb141c1d93" {
subject none; base ModelBaseline;
overrides { @receive_time = 1250 [@millisecond]; };
}
oa_analysis unmeasured "Unmeasured radio" id "99d3e09a-0ec3-4194-9dd4-9f3de6625cde" {
subject none; base ModelBaseline;
overrides { @receive_time = unbound; };
}
calculation combine "Combine durations" id "fd7ce7ab-f789-40bc-b699-0c37f1b6902f" {
input a id "28ff40b0-1b8f-4735-8536-5a6ab21b56cb" : Quantity(@duration);
input b id "7a47cc1e-8ae5-4551-b2d8-8a68108e0976" : Quantity(@duration);
result Quantity(@duration); body $a + $b;
}
unit second "Second" id "534f4672-1ba5-4157-8b0a-b9cf41d0a3ce" {
kind @duration; scale 1; offset 0; symbol "s";
}
unit millisecond "Millisecond" id "39053f37-fec9-4708-9f0a-3959d223f891" {
kind @duration; scale ratio(1, 1000); offset 0; symbol "ms";
}
quantity_kind duration "Duration" id "188b911c-5f8b-431c-9eea-2d41f3953767" {
dimension { time 1; }; algebra Linear;
}
@@ -1,43 +0,0 @@
// Staged typed roots from the r2 example; ordinary roots are supplied separately.
// Forward uses precede the neutral definitions. 雪 keeps offsets UTF-8 sensitive.
sa_property receive_time "Receive time" id "e3a931c2-675a-4fce-89e5-04361cb99450" {
subject @receive; type Quantity(@duration); display_unit @millisecond;
base 750 [@millisecond];
}
sa_property dispatch_time "Dispatch time" id "433fc772-2a60-4044-9af9-73c61c563ace" {
subject @dispatch; type Quantity(@duration); display_unit @second;
base 1 [@second];
}
sa_property total_time "Total time" id "3d5b68e8-65fc-4e35-9071-a73061316a4b" {
subject @respond; type Quantity(@duration); display_unit @second;
base @combine(a: @receive_time, b: @dispatch_time);
}
sa_property limit "Allowed duration" id "c37bedbb-2cf4-44ee-a139-6563cc5086ec" {
subject @respond; type Quantity(@duration); display_unit @second;
base 2 [@second];
}
sa_check budget "Response budget" id "3ac0de99-9d2f-4e53-9158-d424ecac61bf" {
subject none; documented_constraint none; predicate @total_time <= @limit;
}
sa_analysis degraded "Degraded radio" id "b8036c65-16d4-449c-837b-91eb141c1d93" {
subject none; base ModelBaseline;
overrides { @receive_time = 1250 [@millisecond]; };
}
sa_analysis unmeasured "Unmeasured radio" id "19d3e09a-0ec3-4194-9dd4-9f3de6625cde" {
subject none; base ModelBaseline;
overrides { @receive_time = unbound; };
}
calculation combine "Combine durations" id "7d7ce7ab-f789-40bc-b699-0c37f1b6902f" {
input a id "28ff40b0-1b8f-4735-8536-5a6ab21b56cb" : Quantity(@duration);
input b id "7a47cc1e-8ae5-4551-b2d8-8a68108e0976" : Quantity(@duration);
result Quantity(@duration); body $a + $b;
}
unit second "Second" id "d34f4672-1ba5-4157-8b0a-b9cf41d0a3ce" {
kind @duration; scale 1; offset 0; symbol "s";
}
unit millisecond "Millisecond" id "b9053f37-fec9-4708-9f0a-3959d223f891" {
kind @duration; scale ratio(1, 1000); offset 0; symbol "ms";
}
quantity_kind duration "Duration" id "988b911c-5f8b-431c-9eea-2d41f3953767" {
dimension { time 1; }; algebra Linear;
}
@@ -1,888 +0,0 @@
use super::*;
use crate::language::ParseLimits;
use crate::language::analysis_source::{budget::Limits, cursor::Cursor};
use super::super::model_oracle as oracle;
const OA: &str = include_str!("fixtures/typed-oa.arcadia");
const SA: &str = include_str!("fixtures/typed-sa.arcadia");
const ORDINARY_OA: &str = include_str!("fixtures/ordinary-oa.arcadia");
const ORDINARY_SA: &str = include_str!("fixtures/ordinary-sa.arcadia");
fn budget() -> Budget {
Budget::new(Limits::source8(ParseLimits::UNLIMITED)).unwrap()
}
fn id(text: &str) -> Id {
Id::parse_str(text).unwrap()
}
fn by_alias(project: &Project, alias: &str) -> Id {
project
.identities()
.into_iter()
.find(|(_, name)| *name == alias)
.unwrap()
.0
}
fn ordinary(source: &str) -> Project {
crate::language::parse(source).unwrap()
}
fn unit(source: &str, file: usize, budget: &mut Budget) -> Unit {
let tokens = lex::tokens(source, budget).unwrap();
let mut cursor = Cursor::new(source, &tokens, budget);
let mut declarations = Vec::new();
while cursor.cursor < tokens.len() {
declarations.push(d::parse(&mut cursor, 8, None).unwrap());
}
Unit { file, declarations }
}
fn merge(mut ordinary: Project, lowered: Lowered) -> Project {
ordinary.definitions.extend(lowered.definitions);
ordinary.elements.extend(lowered.elements);
ordinary
}
fn admit(project: &Project) {
a::admit(project, a::AdmissionLimits::REFERENCE).unwrap();
}
fn failure(source: &str, ordinary: &Project) -> Failure {
let mut budget = budget();
let unit = unit(source, 7, &mut budget);
lower(ordinary, &[unit], &budget).unwrap_err()
}
#[test]
fn staged_oa_and_sa_lowering_match_complete_independent_17_uuid_oracles() {
for (layer, ordinary_source, typed_source) in
[(Layer::Oa, ORDINARY_OA, OA), (Layer::Sa, ORDINARY_SA, SA)]
{
let expected = oracle::project(layer);
assert_eq!(expected.name, "Response budget");
assert_eq!(expected.identities().len(), 15); // two additional scoped IDs
for (alias, name) in oracle::NAMES {
let object = by_alias(&expected, alias);
let actual_name = expected
.definitions
.get(&object)
.map(|value| value.name.as_str())
.or_else(|| {
expected
.elements
.get(&object)
.map(|value| value.name.as_str())
})
.unwrap();
assert_eq!(actual_name, name);
assert_ne!(actual_name, alias);
}
let mut budget = budget();
let units = [unit(typed_source, 17, &mut budget)];
let usage = budget.usage;
let numeric = budget.numeric_units();
let skeleton = ordinary(ordinary_source);
let first = lower(&skeleton, &units, &budget).unwrap();
let second = lower(&skeleton, &units, &budget).unwrap();
assert_eq!(first.occurrences, second.occurrences);
assert_eq!(first.expressions, second.expressions);
assert_eq!(budget.usage, usage);
assert_eq!(budget.numeric_units(), numeric);
assert_eq!(
first
.occurrences
.iter()
.filter(|occurrence| occurrence.role != Role::ParameterDeclaration)
.count(),
usage.references
);
let actual = merge(skeleton, first);
assert_eq!(actual, expected);
admit(&actual); // the complete model's one semantic admission
}
assert!(crate::language::parse(&ORDINARY_OA.replacen("arcadia 7", "arcadia 8", 1)).is_err());
}
#[test]
fn forward_cross_unit_resolution_keeps_original_offsets_and_shared_work() {
let source = OA.replace('\n', "\r\n");
let split = source.find("calculation combine").unwrap();
let sources = [&source[..split], &source[split..]];
let mut budget = budget();
let mut units = [
unit(sources[0], 4, &mut budget),
unit(sources[1], 9, &mut budget),
];
let usage = budget.usage;
assert_eq!(usage.bytes, source.len());
let lowered = lower(&ordinary(ORDINARY_OA), &units, &budget).unwrap();
units.reverse();
// This second order uses the same bounded lowerer; output models must not
// depend on declaration/file traversal order. It is not an include parser.
let reordered = lower(&ordinary(ORDINARY_OA), &units, &budget).unwrap();
assert_eq!(
merge(ordinary(ORDINARY_OA), reordered),
oracle::project(Layer::Oa)
);
for occurrence in &lowered.occurrences {
let source = if occurrence.file == 4 {
sources[0]
} else {
assert_eq!(occurrence.file, 9);
sources[1]
};
let text = &source[occurrence.span.start..occurrence.span.end];
assert!(crate::valid_alias(text));
assert!(!text.starts_with('@') && !text.starts_with('$'));
}
assert_eq!(budget.usage, usage);
assert_eq!(
merge(ordinary(ORDINARY_OA), lowered),
oracle::project(Layer::Oa)
);
}
fn number(value: i64) -> a::Expr {
a::Expr::Number(crate::analysis_numeric::Exact::from_integer(value))
}
fn binary(op: a::BinaryOp, left: a::Expr, right: a::Expr) -> a::Expr {
a::Expr::Binary {
op,
left: Box::new(left),
right: Box::new(right),
}
}
#[test]
fn all_thirteen_expression_variants_lower_to_real_typed_values_and_admit() {
let mut project = oracle::project(Layer::Oa);
let duration = by_alias(&project, "duration");
let second = by_alias(&project, "second");
let property = by_alias(&project, "receive_time");
let owner = by_alias(&project, "total_time");
let calculation = by_alias(&project, "combine");
let parameter = a::ParameterId(id("28ff40b0-1b8f-4735-8536-5a6ab21b56cb"));
let b = a::ParameterId(id("7a47cc1e-8ae5-4551-b2d8-8a68108e0976"));
let quantity =
a::AnalysisType::Scalar(a::ScalarType::Quantity(a::DefinitionRef::Local(duration)));
let numeric = a::AnalysisType::Scalar(a::ScalarType::Number);
let boolean = a::AnalysisType::Scalar(a::ScalarType::Boolean);
let cases = [
("true", a::Expr::Boolean(true), boolean.clone()),
(
"ratio(3, 2)",
a::Expr::Number(
Exact::from_wire(
&crate::analysis_numeric::ExactWire {
numerator: "3".into(),
denominator: "2".into(),
},
&mut crate::analysis_numeric::WorkMeter::new(
crate::analysis_numeric::WorkLimits::REFERENCE,
),
)
.unwrap(),
),
numeric.clone(),
),
(
"2 [@second]",
a::Expr::Quantity {
magnitude: Exact::from_integer(2),
unit: a::DefinitionRef::Local(second),
},
quantity.clone(),
),
(
"@receive_time",
a::Expr::Property(property),
quantity.clone(),
),
("$a", a::Expr::Parameter(parameter), quantity.clone()),
(
"[1, 2]",
a::Expr::List(vec![number(1), number(2)]),
a::AnalysisType::List(a::ScalarType::Number),
),
(
"abs(-2)",
a::Expr::Unary {
op: a::UnaryOp::Absolute,
operand: Box::new(a::Expr::Unary {
op: a::UnaryOp::Negate,
operand: Box::new(number(2)),
}),
},
numeric.clone(),
),
(
"1 + 2",
binary(a::BinaryOp::Add, number(1), number(2)),
numeric.clone(),
),
(
"if(true, 1, 2)",
a::Expr::If {
condition: Box::new(a::Expr::Boolean(true)),
when_true: Box::new(number(1)),
when_false: Box::new(number(2)),
},
numeric.clone(),
),
(
"@combine(b: 2 [@second], a: 1 [@second])",
a::Expr::Call {
definition: a::DefinitionRef::Local(calculation),
arguments: BTreeMap::from([
(
parameter,
a::Expr::Quantity {
magnitude: Exact::from_integer(1),
unit: a::DefinitionRef::Local(second),
},
),
(
b,
a::Expr::Quantity {
magnitude: Exact::from_integer(2),
unit: a::DefinitionRef::Local(second),
},
),
]),
},
quantity.clone(),
),
(
"sum([1, 2], 0)",
a::Expr::Sum {
values: Box::new(a::Expr::List(vec![number(1), number(2)])),
initial: Box::new(number(0)),
},
numeric.clone(),
),
(
"power(2, -2)",
a::Expr::Power {
base: Box::new(number(2)),
exponent: -2,
},
numeric.clone(),
),
(
"as_kind(@duration, power(1 [@second], 2) / 1 [@second])",
a::Expr::AsKind {
kind: a::DefinitionRef::Local(duration),
value: Box::new(binary(
a::BinaryOp::Divide,
a::Expr::Power {
base: Box::new(a::Expr::Quantity {
magnitude: Exact::from_integer(1),
unit: a::DefinitionRef::Local(second),
}),
exponent: 2,
},
a::Expr::Quantity {
magnitude: Exact::from_integer(1),
unit: a::DefinitionRef::Local(second),
},
)),
},
quantity.clone(),
),
];
// Remove the original check's dependent quantity comparison: each case
// installs a different declared result type on total_time below.
project.elements.remove(&by_alias(&project, "budget"));
for (source, expected, result_type) in cases {
let mut budget = budget();
let Binding::Expression(syntax) = super::super::parse(source, &mut budget).unwrap() else {
panic!("expression")
};
let mut tables = Tables::default();
tables.ordinary(&project, &budget).unwrap();
let mut output = Lowered {
definitions: BTreeMap::new(),
elements: BTreeMap::new(),
occurrences: Vec::new(),
expressions: Vec::new(),
};
let is_parameter = source == "$a";
let expression_owner = if is_parameter { calculation } else { owner };
let actual = Resolver {
tables: &tables,
output: &mut output,
budget: &budget,
file: 3,
owner: expression_owner,
}
.expression(
&syntax,
if is_parameter {
Field::Body
} else {
Field::Base
},
is_parameter.then_some(calculation),
)
.unwrap();
assert_eq!(actual, expected, "{source}");
assert_eq!(output.expressions[0].span, syntax.span);
assert!(output.expressions[0].parent.is_none());
let mut candidate = project.clone();
if is_parameter {
let a::DefinitionBody::Calculation(definition) =
&mut candidate.definitions.get_mut(&calculation).unwrap().body
else {
panic!("calculation")
};
definition.body = a::Binding::Expression(actual);
} else {
let SharedBody::EngineeringProperty {
value_type,
display_unit,
base,
..
} = candidate
.elements
.get_mut(&owner)
.unwrap()
.body
.shared_mut()
.unwrap()
else {
panic!("property")
};
*value_type = result_type;
*display_unit = None;
*base = a::Binding::Expression(actual);
}
admit(&candidate);
}
}
#[test]
fn call_sites_and_parameter_scopes_preserve_distinct_original_occurrences() {
let source = OA.replace("base @combine(a: @receive_time, b: @dispatch_time);", "base @combine(a: @receive_time, // first\n b: @dispatch_time) + @combine(// second\n b: @dispatch_time, a: @receive_time);") + r#"
calculation other "Other" id "00000000-0000-0000-0000-000000000020" {
input a id "28ff40b0-1b8f-4735-8536-5a6ab21b56cb" : Number;
result Number; body $a;
}
quantity_kind collision "Global ID equals a scoped ID" id "28ff40b0-1b8f-4735-8536-5a6ab21b56cb" { dimension {}; algebra Linear; }
calculation caller "Caller" id "00000000-0000-0000-0000-000000000021" {
input a id "00000000-0000-0000-0000-000000000022" : Number;
result Number; body @other(a: $a) + @other(a: 3);
}
"#;
let mut budget = budget();
let units = [unit(&source, 2, &mut budget)];
let output = lower(&ordinary(ORDINARY_OA), &units, &budget).unwrap();
let total = id("bd5b68e8-65fc-4e35-9071-a73061316a4b");
let combine = id("fd7ce7ab-f789-40bc-b699-0c37f1b6902f");
let labels: Vec<_> = output
.occurrences
.iter()
.filter(|value| value.owner == total && value.role == Role::ArgumentLabel)
.collect();
assert_eq!(labels.len(), 4);
assert_eq!(
labels
.iter()
.map(|value| value.span.start)
.collect::<BTreeSet<_>>()
.len(),
4
);
let sites: BTreeSet<_> = labels
.iter()
.map(|value| value.expression.unwrap())
.collect();
assert_eq!(sites.len(), 2);
let paths: Vec<_> = sites
.iter()
.map(|site| output.expressions[*site].parent.unwrap().1)
.collect();
assert_eq!(paths, [Child::Left, Child::Right]);
for label in labels {
assert_eq!(label.field, Field::Base);
assert!(
matches!(label.target, Target::Parameter { calculation, .. } if calculation == combine)
);
assert!(matches!(
&source[label.span.start..label.span.end],
"a" | "b"
));
}
let reused = a::ParameterId(id("28ff40b0-1b8f-4735-8536-5a6ab21b56cb"));
let scopes: BTreeSet<_> = output
.occurrences
.iter()
.filter_map(|occurrence| match occurrence.target {
Target::Parameter {
calculation,
parameter,
} if parameter == reused => Some(calculation),
_ => None,
})
.collect();
assert_eq!(
scopes,
BTreeSet::from([combine, id("00000000-0000-0000-0000-000000000020")])
);
let caller = id("00000000-0000-0000-0000-000000000021");
let use_in_caller = output
.occurrences
.iter()
.find(|value| value.owner == caller && value.role == Role::ParameterUse)
.unwrap();
assert_eq!(
use_in_caller.target,
Target::Parameter {
calculation: caller,
parameter: a::ParameterId(id("00000000-0000-0000-0000-000000000022"))
}
);
for site in &output.expressions {
assert_eq!(site.file, 2);
assert!(source.get(site.span.start..site.span.end).is_some());
}
admit(&merge(ordinary(ORDINARY_OA), output));
}
#[test]
fn missing_ids_require_stabilization_without_uuid_or_budget_churn() {
for source in [
r#"calculation pending "Pending" { result Number; body unbound; }"#,
r#"calculation pending "Pending" id "00000000-0000-0000-0000-000000000040" { input value : Number; result Number; body $value; }"#,
] {
let mut budget = budget();
let units = [unit(source, 3, &mut budget)];
let usage = budget.usage;
let skeleton = ordinary(ORDINARY_OA);
let first = lower(&skeleton, &units, &budget).unwrap_err();
assert_eq!(first, lower(&skeleton, &units, &budget).unwrap_err());
assert!(first.error.message.contains("stabilization"));
assert_eq!(budget.usage, usage);
let declaration = &units[0].declarations[0];
if declaration.header.identity.value.is_none() {
assert!(declaration.header.identity.insertion.is_some());
} else {
let d::Body::Calculation { parameters, .. } = &declaration.body else {
panic!("calculation")
};
assert!(parameters[0].identity.value.is_none());
assert!(parameters[0].identity.insertion.is_some());
}
}
}
#[test]
fn duplicate_global_aliases_and_ids_fail_before_body_resolution() {
let ordinary = ordinary(ORDINARY_OA);
for (source, message) in [
(
OA.replace("quantity_kind duration", "quantity_kind respond"),
"Duplicate global alias",
),
(
OA.replace(
"188b911c-5f8b-431c-9eea-2d41f3953767",
"cabec6a1-7d5d-48f3-8d2d-a7fc8cf78f59",
),
"Duplicate global identity",
),
(format!("{OA}\n{OA}"), "Duplicate global"),
] {
let failed = failure(&source, &ordinary);
assert_eq!(failed.file, Some(7));
assert!(failed.error.message.contains(message), "{:?}", failed);
assert!(!failed.error.message.contains("Unknown"));
}
let mut budget = budget();
let units = [unit(OA, 3, &mut budget), unit(OA, 5, &mut budget)];
let failed = lower(&ordinary, &units, &budget).unwrap_err();
assert_eq!(failed.file, Some(5));
assert!(failed.error.message.contains("Duplicate global identity"));
}
#[test]
fn typed_reference_domains_unknown_names_and_parameter_scope_fail_at_tokens() {
let skeleton = ordinary(ORDINARY_OA);
for (old, new, token, message) in [
(
"subject @receive",
"subject @second",
"second",
"Wrong reference domain",
),
(
"Quantity(@duration)",
"Quantity(@second)",
"second",
"Wrong reference domain",
),
(
"display_unit @second",
"display_unit @duration",
"duration",
"Wrong reference domain",
),
(
"750 [@millisecond]",
"750 [@duration]",
"duration",
"Wrong reference domain",
),
(
"base 1 [@second]",
"base @respond",
"respond",
"Wrong reference domain",
),
(
"@combine(a:",
"@second(a:",
"second",
"Wrong reference domain",
),
(
"@combine(a:",
"@combine(unknown:",
"unknown",
"Unknown argument",
),
(
"body $a + $b",
"body $outside + $b",
"outside",
"Unknown parameter",
),
(
"base 1 [@second]",
"base $a",
"a",
"only valid in its calculation",
),
(
"subject @receive",
"subject @missing",
"missing",
"Unknown global",
),
(
"documented_constraint none",
"documented_constraint @respond",
"respond",
"Wrong reference domain",
),
(
"@receive_time = unbound",
"@second = unbound",
"second",
"Wrong reference domain",
),
] {
let source = OA.replace(old, new);
assert_ne!(source, OA, "mutation {old}");
let failed = failure(&source, &skeleton);
assert!(
failed.error.message.contains(message),
"{new}: {:?}",
failed
);
assert_eq!(
&source[failed.error.span.start..failed.error.span.end],
token
);
}
}
#[test]
fn ordinary_non_element_categories_are_collected_and_never_property_fallbacks() {
let mut skeleton = ordinary(ORDINARY_OA);
let respond = by_alias(&skeleton, "respond");
let receive = by_alias(&skeleton, "receive");
let relation = Id::from_u128(100);
skeleton.relations.insert(
relation,
crate::Relation {
id: relation,
alias: "link".into(),
name: "Link".into(),
description: String::new(),
body: crate::RelationBody::new(
crate::RelationKind::FunctionalExchange,
respond,
receive,
),
},
);
let diagram = skeleton.ensure_diagram(Layer::Oa);
skeleton.diagrams.get_mut(&diagram).unwrap().alias = "view".into();
let node = skeleton.place(diagram, respond, crate::Position { x: 0., y: 0. });
skeleton
.diagrams
.get_mut(&diagram)
.unwrap()
.nodes
.get_mut(&node)
.unwrap()
.alias = "placement".into();
let artifact = Id::from_u128(101);
skeleton.artifact_links.insert(
artifact,
crate::ArtifactLink {
id: artifact,
alias: "document".into(),
name: "Document".into(),
description: String::new(),
owner: respond,
relation: crate::ArtifactRelation::References,
uri: "urn:test".into(),
revision: None,
fragment: None,
},
);
let trace = Id::from_u128(102);
skeleton.traces.insert(
trace,
crate::Trace {
id: trace,
alias: "mapping".into(),
name: "Mapping".into(),
description: String::new(),
oa: respond,
sa: receive,
kind: crate::TraceKind::Manual,
baseline: None,
},
);
// Resolution classification is independent of whole-model admission; the
// synthetic trace is deliberately not asserted to be a valid OA/SA trace.
let budget = budget();
let mut tables = Tables::default();
tables.ordinary(&skeleton, &budget).unwrap();
for (alias, category) in [
("response_budget", Category::Project),
("link", Category::Relation),
("view", Category::Diagram),
("placement", Category::Node),
("document", Category::Artifact),
("mapping", Category::Trace),
] {
assert_eq!(tables.aliases[alias].category, category);
let failed = failure(
&OA.replace("base 1 [@second]", &format!("base @{alias}")),
&skeleton,
);
assert!(
failed.error.message.contains("Wrong reference domain"),
"{:?}",
failed
);
}
let source = OA.replace("subject @receive", "subject @link");
let mut budget = self::budget();
let units = [unit(&source, 1, &mut budget)];
let output = lower(&skeleton, &units, &budget).unwrap();
let property = output
.elements
.values()
.find(|value| value.alias == "receive_time")
.unwrap();
assert!(
matches!(property.body.shared(), Some(SharedBody::EngineeringProperty { subject: a::AnalysisSubject::Relation(value), .. }) if *value == relation)
);
}
#[test]
fn structural_lowering_defers_type_purity_layer_and_argument_sets_to_admission() {
for (old, new) in [
("base 750 [@millisecond]", "base true"),
(
"base 750 [@millisecond]",
"base as_kind(@duration, 1 [@second])",
),
("body $a + $b", "body @receive_time"),
("a: @receive_time, b: @dispatch_time", "a: @receive_time"),
("oa_property receive_time", "sa_property receive_time"),
("base 1 [@second]", "base @dispatch_time"),
] {
let source = OA.replace(old, new);
let mut budget = budget();
let units = [unit(&source, 1, &mut budget)];
let output = lower(&ordinary(ORDINARY_OA), &units, &budget).unwrap();
let project = merge(ordinary(ORDINARY_OA), output);
assert!(
a::admit(&project, a::AdmissionLimits::REFERENCE).is_err(),
"{new}"
);
}
}
#[test]
fn lowering_phase_bounds_count_ordinary_globals_and_scoped_parameters_together() {
let ordinary = oracle::project(Layer::Oa);
for (maximum, passes) in [(0, false), (16, false), (17, true)] {
let mut limits = Limits::source8(ParseLimits::UNLIMITED);
limits.entries = maximum;
let budget = Budget::new(limits).unwrap();
let result = lower(&ordinary, &[], &budget);
assert_eq!(result.is_ok(), passes, "entry bound {maximum}");
if let Err(failure) = result {
assert!(failure.error.message.contains("lowering entry limit"));
assert_eq!(failure.file, None);
}
assert_eq!(budget.usage, budget::Usage::default());
}
}
#[test]
fn affine_kinds_optional_fields_list_signatures_and_unused_unbound_definitions_survive() {
let source = r#"
unit celsius "Celsius" id "00000000-0000-0000-0000-000000000061" {
kind @point; scale 1; offset ratio(27315, 100); symbol "°C";
}
quantity_kind point "Temperature point" id "00000000-0000-0000-0000-000000000062" {
dimension { temperature 1; }; algebra Affine(@interval);
}
quantity_kind interval "Temperature interval" id "00000000-0000-0000-0000-000000000063" {
dimension { temperature 1; }; algebra Linear;
}
calculation unused "Unused list calculation" id "00000000-0000-0000-0000-000000000064" {
input xs id "00000000-0000-0000-0000-000000000065" : List(Quantity(@interval));
result List(Quantity(@interval)); body unbound; description "保留";
}
calculation empty "Empty calculation" id "00000000-0000-0000-0000-000000000066" {
result Boolean; body unbound;
}
oa_property ambient "Ambient temperature" id "00000000-0000-0000-0000-000000000067" {
subject @respond; type Quantity(@point); display_unit none; base 0 [@celsius]; parent none;
}
oa_check documented "Documented check" id "00000000-0000-0000-0000-000000000068" {
subject @respond; documented_constraint @requirement; predicate unbound;
}
oa_analysis changed "Changed temperature" id "00000000-0000-0000-0000-000000000069" {
subject @respond; base ModelBaseline; overrides { @ambient = unbound; };
}
"#;
let mut skeleton = ordinary(ORDINARY_OA);
let requirement = Id::from_u128(70);
skeleton.elements.insert(
requirement,
Element {
id: requirement,
alias: "requirement".into(),
name: "Requirement".into(),
description: String::new(),
parent: None,
body: ElementBody::shared_in(
Layer::Oa,
SharedBody::Constraint {
expression: String::new(),
constrained: Vec::new(),
},
),
},
);
let mut budget = budget();
let units = [unit(source, 8, &mut budget)];
let output = lower(&skeleton, &units, &budget).unwrap();
assert_eq!(output.definitions.len(), 5);
let point = id("00000000-0000-0000-0000-000000000062");
let interval = id("00000000-0000-0000-0000-000000000063");
let a::DefinitionBody::QuantityKind(kind) = &output.definitions[&point].body else {
panic!("quantity kind")
};
assert_eq!(
kind.algebra,
a::QuantityAlgebra::Affine {
difference: a::DefinitionRef::Local(interval)
}
);
let unused = &output.definitions[&id("00000000-0000-0000-0000-000000000064")];
assert_eq!(unused.description, "保留");
let a::DefinitionBody::Calculation(calculation) = &unused.body else {
panic!("calculation")
};
assert_eq!(calculation.body, a::Binding::Unbound);
assert_eq!(
calculation.parameters[0].value_type,
a::AnalysisType::List(a::ScalarType::Quantity(a::DefinitionRef::Local(interval)))
);
assert_eq!(calculation.result, calculation.parameters[0].value_type);
assert!(
output
.occurrences
.iter()
.any(|occurrence| occurrence.field == Field::DocumentedConstraint
&& occurrence.target == Target::Global(requirement))
);
let override_id = id("00000000-0000-0000-0000-000000000067");
assert!(output.occurrences.iter().any(|occurrence| occurrence.field
== Field::Override(override_id)
&& occurrence.role == Role::OverrideTarget));
assert!(
output
.occurrences
.iter()
.any(|occurrence| occurrence.owner == point
&& occurrence.field == Field::Algebra
&& occurrence.target == Target::Global(interval))
);
admit(&merge(skeleton, output));
}
#[test]
fn duplicate_file_keys_and_exhausted_shared_budget_cannot_enter_lowering() {
let mut budget = budget();
let units = [unit("", 3, &mut budget), unit("", 3, &mut budget)];
let failed = lower(&ordinary(ORDINARY_OA), &units, &budget).unwrap_err();
assert_eq!(failed.file, Some(3));
assert!(failed.error.message.contains("Duplicate source file"));
let failure = budget
.bytes(usize::MAX, Span { start: 5, end: 6 })
.unwrap_err();
let failed = lower(&ordinary(ORDINARY_OA), &[], &budget).unwrap_err();
assert_eq!(failed.error, failure);
}
#[test]
fn iterative_lowering_preserves_128_deep_and_1024_wide_accepted_expressions() {
let deep = format!("{}1{}", "abs(".repeat(127), ")".repeat(127));
let wide = std::iter::repeat_n("1", 1024)
.collect::<Vec<_>>()
.join(", ");
let source = format!(
r#"
calculation deep "Deep" id "00000000-0000-0000-0000-000000000081" {{ result Number; body {deep}; }}
calculation wide "Wide" id "00000000-0000-0000-0000-000000000082" {{ result List(Number); body [{wide}]; }}
"#
);
let mut budget = budget();
let units = [unit(&source, 1, &mut budget)];
let usage = budget.usage;
let output = lower(&ordinary(ORDINARY_OA), &units, &budget).unwrap();
assert_eq!(output.expressions.len(), 128 + 1025);
let a::DefinitionBody::Calculation(deep) = &output.definitions[&Id::from_u128(0x81)].body
else {
panic!("deep calculation")
};
let a::Binding::Expression(root) = &deep.body else {
panic!("deep expression")
};
let mut current = root;
let mut depth = 1;
while let a::Expr::Unary { operand, .. } = current {
depth += 1;
current = operand;
}
assert_eq!(depth, 128);
assert_eq!(current, &number(1));
let a::DefinitionBody::Calculation(wide) = &output.definitions[&Id::from_u128(0x82)].body
else {
panic!("wide calculation")
};
let a::Binding::Expression(a::Expr::List(values)) = &wide.body else {
panic!("wide list")
};
assert_eq!(values.len(), 1024);
assert!(values.iter().all(|value| *value == number(1)));
assert_eq!(budget.usage, usage);
admit(&merge(ordinary(ORDINARY_OA), output));
}
@@ -1,263 +0,0 @@
//! Independent model construction: never parses source or calls the writer.
use crate::analysis::*;
use crate::analysis_numeric::{Dimension, Exact, ExactWire, WorkLimits, WorkMeter};
use crate::*;
use std::collections::BTreeMap;
pub(super) const NAMES: [(&str, &str); 14] = [
("duration", "Duration"),
("second", "Second"),
("millisecond", "Millisecond"),
("combine", "Combine durations"),
("respond", "Respond"),
("receive", "Receive"),
("dispatch", "Dispatch"),
("receive_time", "Receive time"),
("dispatch_time", "Dispatch time"),
("total_time", "Total time"),
("limit", "Allowed duration"),
("budget", "Response budget"),
("degraded", "Degraded radio"),
("unmeasured", "Unmeasured radio"),
];
fn named(alias: &str) -> String {
NAMES
.iter()
.find(|(key, _)| *key == alias)
.unwrap()
.1
.into()
}
fn local(id: Id) -> DefinitionRef {
DefinitionRef::Local(id)
}
fn quantity(value: i64, unit: Id) -> Expr {
Expr::Quantity {
magnitude: Exact::from_integer(value),
unit: local(unit),
}
}
fn expression(value: Expr) -> Binding {
Binding::Expression(value)
}
fn binary(op: BinaryOp, left: Expr, right: Expr) -> Expr {
Expr::Binary {
op,
left: Box::new(left),
right: Box::new(right),
}
}
pub(super) fn project(layer: Layer) -> Project {
// OA is the fixed r2 set; SA independently constructs fresh global IDs.
let id = |text| {
let id = Id::parse_str(text).unwrap();
if layer == Layer::Oa {
id
} else {
Id::from_u128(id.as_u128() ^ (1 << 127))
}
};
let project_id = id("4210b9f2-7046-47f0-aa3b-153f626a579d");
let duration = id("188b911c-5f8b-431c-9eea-2d41f3953767");
let second = id("534f4672-1ba5-4157-8b0a-b9cf41d0a3ce");
let millisecond = id("39053f37-fec9-4708-9f0a-3959d223f891");
let combine = id("fd7ce7ab-f789-40bc-b699-0c37f1b6902f");
// Parameter IDs may be reused in another calculation/project.
let a = ParameterId(Id::parse_str("28ff40b0-1b8f-4735-8536-5a6ab21b56cb").unwrap());
let b = ParameterId(Id::parse_str("7a47cc1e-8ae5-4551-b2d8-8a68108e0976").unwrap());
let respond = id("cabec6a1-7d5d-48f3-8d2d-a7fc8cf78f59");
let receive = id("4ab9a8a7-7552-488d-9862-e14b89e7b353");
let dispatch = id("6df991ba-4c5e-4535-99e2-524ae274ee8b");
let receive_time = id("63a931c2-675a-4fce-89e5-04361cb99450");
let dispatch_time = id("c33fc772-2a60-4044-9af9-73c61c563ace");
let total = id("bd5b68e8-65fc-4e35-9071-a73061316a4b");
let limit = id("437bedbb-2cf4-44ee-a139-6563cc5086ec");
let check = id("bac0de99-9d2f-4e53-9158-d424ecac61bf");
let degraded = id("38036c65-16d4-449c-837b-91eb141c1d93");
let unmeasured = id("99d3e09a-0ec3-4194-9dd4-9f3de6625cde");
let duration_type = || AnalysisType::Scalar(ScalarType::Quantity(local(duration)));
let definition = |id, alias: &str, body| Definition {
id,
alias: alias.into(),
name: named(alias),
description: String::new(),
body,
};
let shared = |id, alias: &str, body| Element {
id,
alias: alias.into(),
name: named(alias),
description: String::new(),
parent: None,
body: ElementBody::shared_in(layer, body),
};
let work = |id, alias: &str, parent| Element {
id,
alias: alias.into(),
name: named(alias),
description: String::new(),
parent,
body: if layer == Layer::Oa {
ElementBody::Oa(OaBody::Activity)
} else {
ElementBody::Sa(SaBody::Function)
},
};
let definitions = [
definition(
duration,
"duration",
DefinitionBody::QuantityKind(QuantityKindDefinition {
dimension: Dimension::new([0, 0, 1, 0, 0, 0, 0]).unwrap(),
algebra: QuantityAlgebra::Linear,
}),
),
definition(
second,
"second",
DefinitionBody::Unit(UnitDefinition {
kind: local(duration),
scale: Exact::from_integer(1),
offset: Exact::from_integer(0),
symbol: "s".into(),
}),
),
definition(
millisecond,
"millisecond",
DefinitionBody::Unit(UnitDefinition {
kind: local(duration),
scale: Exact::from_wire(
&ExactWire {
numerator: "1".into(),
denominator: "1000".into(),
},
&mut WorkMeter::new(WorkLimits::REFERENCE),
)
.unwrap(),
offset: Exact::from_integer(0),
symbol: "ms".into(),
}),
),
definition(
combine,
"combine",
DefinitionBody::Calculation(CalculationDefinition {
parameters: vec![
Parameter {
id: a,
name: "a".into(),
value_type: duration_type(),
},
Parameter {
id: b,
name: "b".into(),
value_type: duration_type(),
},
],
result: duration_type(),
body: expression(binary(
BinaryOp::Add,
Expr::Parameter(a),
Expr::Parameter(b),
)),
}),
),
]
.into_iter()
.map(|definition| (definition.id, definition))
.collect();
let property = |id, alias, subject, display, base| {
shared(
id,
alias,
SharedBody::EngineeringProperty {
subject: AnalysisSubject::Element(subject),
value_type: duration_type(),
display_unit: Some(local(display)),
base: expression(base),
},
)
};
let case = |id, alias, binding| {
shared(
id,
alias,
SharedBody::AnalysisCase {
subject: None,
base: BaselineBase::ModelBaseline,
overrides: BTreeMap::from([(receive_time, binding)]),
},
)
};
let elements = [
work(respond, "respond", None),
work(receive, "receive", Some(respond)),
work(dispatch, "dispatch", Some(respond)),
property(
receive_time,
"receive_time",
receive,
millisecond,
quantity(750, millisecond),
),
property(
dispatch_time,
"dispatch_time",
dispatch,
second,
quantity(1, second),
),
property(
total,
"total_time",
respond,
second,
Expr::Call {
definition: local(combine),
arguments: BTreeMap::from([
(a, Expr::Property(receive_time)),
(b, Expr::Property(dispatch_time)),
]),
},
),
property(limit, "limit", respond, second, quantity(2, second)),
shared(
check,
"budget",
SharedBody::AnalysisCheck {
subject: None,
documented_constraint: None,
predicate: expression(binary(
BinaryOp::LessOrEqual,
Expr::Property(total),
Expr::Property(limit),
)),
},
),
case(
degraded,
"degraded",
expression(quantity(1250, millisecond)),
),
case(unmeasured, "unmeasured", Binding::Unbound),
]
.into_iter()
.map(|element| (element.id, element))
.collect();
Project {
schema_version: 8,
id: project_id,
alias: "response_budget".into(),
name: "Response budget".into(),
description: String::new(),
definitions,
elements,
relations: BTreeMap::new(),
diagrams: BTreeMap::new(),
artifact_links: BTreeMap::new(),
traces: BTreeMap::new(),
}
}
@@ -1,432 +0,0 @@
//! Iterative Pratt machine. Source nesting does not recurse on the native stack.
use super::cursor::Cursor;
use super::*;
use lex::Class;
use std::collections::BTreeSet;
enum SequenceKind {
List,
If,
Sum,
Unary(UnaryOp),
Call(Reference),
}
struct Sequence {
kind: SequenceKind,
start: usize,
values: Vec<(Option<Reference>, Expression)>,
keys: BTreeSet<String>,
}
enum Task {
Expression(u8),
Continue(u8),
Binary(Expression, BinaryOp),
Unary(usize, UnaryOp),
Group(usize),
Next(Sequence),
Collected(Sequence, Option<Reference>),
Power(usize),
AsKind(usize, Reference),
}
pub(super) fn binding(p: &mut Cursor<'_, '_>) -> Result<Binding, Error> {
p.budget.active()?;
if p.text() == "unbound" {
let span = p.span();
p.cursor += 1;
return Ok(Binding::Unbound(span));
}
let mut tasks = vec![Task::Expression(0)];
let mut values = Vec::new();
while let Some(task) = tasks.pop() {
p.budget.active()?;
match task {
Task::Expression(minimum) => {
let start = p.span().start;
if p.text() != "(" {
p.budget.entry(p.span())?;
}
tasks.push(Task::Continue(minimum));
match p.text() {
"true" | "false" => {
let value = p.text() == "true";
let span = p.span();
p.cursor += 1;
values.push(p.node(ExpressionKind::Boolean(value), span)?);
}
"-" => {
p.open()?;
p.cursor += 1;
tasks.push(Task::Unary(start, UnaryOp::Negate));
tasks.push(Task::Expression(7));
}
"(" => {
p.open()?;
p.cursor += 1;
tasks.push(Task::Group(start));
tasks.push(Task::Expression(0));
}
"[" => {
p.open()?;
p.cursor += 1;
tasks.push(Task::Next(Sequence::new(SequenceKind::List, start)));
}
"@" => {
p.cursor += 1;
let reference = p.reference()?;
if p.text() == "(" {
p.open()?;
p.cursor += 1;
tasks.push(Task::Next(Sequence::new(
SequenceKind::Call(reference),
start,
)));
} else {
let end = reference.span.end;
values.push(
p.node(ExpressionKind::Property(reference), Span { start, end })?,
);
}
}
"$" => {
p.cursor += 1;
let reference = p.reference()?;
let end = reference.span.end;
values.push(
p.node(ExpressionKind::Parameter(reference), Span { start, end })?,
);
}
"ratio" => {
let (value, literal) = p.exact_literal()?;
values.push(p.number(value, literal)?);
}
"abs" | "not" | "if" | "sum" => {
let kind = match p.text() {
"abs" => SequenceKind::Unary(UnaryOp::Absolute),
"not" => SequenceKind::Unary(UnaryOp::Not),
"if" => SequenceKind::If,
_ => SequenceKind::Sum,
};
p.cursor += 1;
p.open()?;
p.take("(")?;
tasks.push(Task::Next(Sequence::new(kind, start)));
}
"power" => {
p.cursor += 1;
p.open()?;
p.take("(")?;
tasks.push(Task::Power(start));
tasks.push(Task::Expression(0));
}
"as_kind" => {
p.cursor += 1;
p.open()?;
p.take("(")?;
p.take("@")?;
let kind = p.reference()?;
p.take(",")?;
tasks.push(Task::AsKind(start, kind));
tasks.push(Task::Expression(0));
}
_ if p
.tokens
.get(p.cursor)
.is_some_and(|token| token.class == Class::Number) =>
{
let (value, literal) = p.exact_literal()?;
values.push(p.number(value, literal)?);
}
_ => return Err(error(p.span(), "Expected an analysis expression.")),
}
}
Task::Continue(minimum) => {
if let Some((op, precedence)) =
operator(p.text()).filter(|(_, prec)| *prec >= minimum)
{
let left = values.pop().expect("completed Pratt operand");
if comparison(op) && ungrouped_comparison(&left) {
return Err(error(p.span(), "Comparison children require parentheses."));
}
p.budget.entry(p.span())?;
p.cursor += 1;
tasks.push(Task::Continue(minimum));
tasks.push(Task::Binary(left, op));
tasks.push(Task::Expression(precedence + 1));
}
}
Task::Binary(left, op) => {
let right = values.pop().expect("completed right operand");
if comparison(op) && ungrouped_comparison(&right) {
return Err(error(
right.span,
"Comparison children require parentheses.",
));
}
let span = Span {
start: left.span.start,
end: right.span.end,
};
values.push(p.node(
ExpressionKind::Binary {
op,
left: Box::new(left),
right: Box::new(right),
},
span,
)?);
}
Task::Unary(start, op) => {
let operand = values.pop().expect("completed unary operand");
let end = operand.span.end;
p.close();
values.push(p.node(
ExpressionKind::Unary {
op,
operand: Box::new(operand),
},
Span { start, end },
)?);
}
Task::Group(start) => {
let end = p.take(")")?.end;
p.close();
let value = values.last_mut().expect("completed grouped expression");
value.grouped = true;
value.span = Span { start, end };
}
Task::Next(mut sequence) => {
let closing = if matches!(sequence.kind, SequenceKind::List) {
"]"
} else {
")"
};
if p.text() == closing {
let end = p.take(closing)?.end;
p.close();
values.push(p.sequence(sequence, end)?);
} else {
let count = sequence.values.len();
let maximum = match sequence.kind {
SequenceKind::List => crate::analysis::MAX_LIST_LENGTH,
SequenceKind::Unary(_) => 1,
SequenceKind::If => 3,
SequenceKind::Sum => 2,
SequenceKind::Call(_) => usize::MAX,
};
if count >= maximum {
return Err(error(
p.span(),
"Too many list members or constructor arguments.",
));
}
let key = if matches!(sequence.kind, SequenceKind::Call(_)) {
p.budget.entry(p.span())?;
let key = p.reference()?;
if !sequence.keys.insert(key.name.clone()) {
return Err(error(key.span, "Duplicate call argument name."));
}
p.take(":")?;
Some(key)
} else {
None
};
tasks.push(Task::Collected(sequence, key));
tasks.push(Task::Expression(0));
}
}
Task::Collected(mut sequence, key) => {
sequence
.values
.push((key, values.pop().expect("completed sequence value")));
let closing = if matches!(sequence.kind, SequenceKind::List) {
"]"
} else {
")"
};
if p.text() != closing {
p.take(",")?;
if p.text() == closing {
return Err(error(p.span(), "Trailing comma is not an expression."));
}
}
tasks.push(Task::Next(sequence));
}
Task::Power(start) => {
let base = values.pop().expect("completed power base");
p.take(",")?;
let negative = if p.text() == "-" {
p.cursor += 1;
true
} else {
false
};
let span = p.integer()?;
let magnitude = p.source[span.start..span.end]
.parse::<u8>()
.map_err(|_| error(span, "Power exponent is outside -16..16."))?;
if magnitude > 16 {
return Err(error(span, "Power exponent is outside -16..16."));
}
let exponent = if negative {
-(magnitude as i8)
} else {
magnitude as i8
};
let end = p.take(")")?.end;
p.close();
values.push(p.node(
ExpressionKind::Power {
base: Box::new(base),
exponent,
},
Span { start, end },
)?);
}
Task::AsKind(start, kind) => {
let value = values.pop().expect("completed kind assertion value");
let end = p.take(")")?.end;
p.close();
values.push(p.node(
ExpressionKind::AsKind {
kind,
value: Box::new(value),
},
Span { start, end },
)?);
}
}
}
Ok(Binding::Expression(
values.pop().expect("one complete expression"),
))
}
impl Sequence {
fn new(kind: SequenceKind, start: usize) -> Self {
Self {
kind,
start,
values: Vec::new(),
keys: BTreeSet::new(),
}
}
}
impl Cursor<'_, '_> {
fn number(&mut self, magnitude: Exact, literal: LiteralSyntax) -> Result<Expression, Error> {
let Span { start, end } = literal.span;
let mut expression = if self.text() == "[" {
self.open()?;
self.cursor += 1;
self.take("@")?;
let unit = self.reference()?;
let end = self.take("]")?.end;
self.close();
self.node(
ExpressionKind::Quantity { magnitude, unit },
Span { start, end },
)
} else {
self.node(ExpressionKind::Number(magnitude), Span { start, end })
}?;
expression.literal = Some(literal);
Ok(expression)
}
fn node(&mut self, kind: ExpressionKind, span: Span) -> Result<Expression, Error> {
let child_depth = match &kind {
ExpressionKind::Unary { operand, .. } => operand.depth,
ExpressionKind::Binary { left, right, .. } => left.depth.max(right.depth),
ExpressionKind::If {
condition,
when_true,
when_false,
} => condition.depth.max(when_true.depth).max(when_false.depth),
ExpressionKind::List(values) => values.iter().map(|v| v.depth).max().unwrap_or(0),
ExpressionKind::Call { arguments, .. } => {
arguments.iter().map(|a| a.value.depth).max().unwrap_or(0)
}
ExpressionKind::Sum { values, initial } => values.depth.max(initial.depth),
ExpressionKind::Power { base, .. } => base.depth,
ExpressionKind::AsKind { value, .. } => value.depth,
_ => 0,
};
if child_depth >= crate::analysis::MAX_EXPRESSION_DEPTH {
return Err(error(span, "Expression logical depth exceeds 128."));
}
Ok(Expression {
kind,
span,
depth: child_depth + 1,
grouped: false,
literal: None,
})
}
fn sequence(&mut self, sequence: Sequence, end: usize) -> Result<Expression, Error> {
let span = Span {
start: sequence.start,
end,
};
let mut values = sequence.values.into_iter();
let mut next = || {
values
.next()
.map(|(_, value)| Box::new(value))
.ok_or_else(|| error(span, "Missing required constructor argument."))
};
let kind = match sequence.kind {
SequenceKind::If => ExpressionKind::If {
condition: next()?,
when_true: next()?,
when_false: next()?,
},
SequenceKind::Sum => ExpressionKind::Sum {
values: next()?,
initial: next()?,
},
SequenceKind::Unary(op) => ExpressionKind::Unary {
op,
operand: next()?,
},
SequenceKind::List => ExpressionKind::List(values.map(|(_, value)| value).collect()),
SequenceKind::Call(definition) => ExpressionKind::Call {
definition,
arguments: values
.map(|(parameter, value)| Argument {
parameter: parameter.expect("parsed named argument"),
value,
})
.collect(),
},
};
self.node(kind, span)
}
}
fn comparison(op: BinaryOp) -> bool {
matches!(
op,
BinaryOp::Equal
| BinaryOp::NotEqual
| BinaryOp::Less
| BinaryOp::LessOrEqual
| BinaryOp::Greater
| BinaryOp::GreaterOrEqual
)
}
fn ungrouped_comparison(value: &Expression) -> bool {
!value.grouped && matches!(&value.kind, ExpressionKind::Binary { op, .. } if comparison(*op))
}
fn operator(text: &str) -> Option<(BinaryOp, u8)> {
Some(match text {
"or" => (BinaryOp::Or, 1),
"and" => (BinaryOp::And, 2),
"==" => (BinaryOp::Equal, 3),
"!=" => (BinaryOp::NotEqual, 3),
"<" => (BinaryOp::Less, 4),
"<=" => (BinaryOp::LessOrEqual, 4),
">" => (BinaryOp::Greater, 4),
">=" => (BinaryOp::GreaterOrEqual, 4),
"+" => (BinaryOp::Add, 5),
"-" => (BinaryOp::Subtract, 5),
"*" => (BinaryOp::Multiply, 6),
"/" => (BinaryOp::Divide, 6),
_ => return None,
})
}
@@ -1,503 +0,0 @@
use super::*;
use crate::language::ParseLimits;
use budget::{Budget, Limits};
fn limits() -> Limits {
Limits::source8(ParseLimits::UNLIMITED)
}
fn budget() -> Budget {
Budget::new(limits()).unwrap()
}
fn expression(source: &str) -> Expression {
match parse(source, &mut budget()).unwrap() {
Binding::Expression(value) => value,
Binding::Unbound(_) => panic!("expected expression"),
}
}
fn number(value: &Expression) -> String {
let ExpressionKind::Number(exact) = &value.kind else {
panic!("expected Number");
};
exact.canonical_literal()
}
fn binary_children(value: Expression, expected: BinaryOp) -> (Expression, Expression) {
let ExpressionKind::Binary { op, left, right } = value.kind else {
panic!("expected Binary {expected:?}");
};
assert_eq!(op, expected);
(*left, *right)
}
#[test]
fn mixed_precedence_and_grouping_have_independent_expected_child_topology() {
let (one, product) = binary_children(expression("1 + 2 * 3"), BinaryOp::Add);
assert_eq!(number(&one), "1");
let (two, three) = binary_children(product, BinaryOp::Multiply);
assert_eq!(
(number(&two), number(&three)),
("2".to_owned(), "3".to_owned())
);
let (conjunction, last) =
binary_children(expression("not(false) and true or false"), BinaryOp::Or);
assert_eq!(last.kind, ExpressionKind::Boolean(false));
let (negated, middle) = binary_children(conjunction, BinaryOp::And);
assert_eq!(middle.kind, ExpressionKind::Boolean(true));
let ExpressionKind::Unary { op, operand } = negated.kind else {
panic!("expected Not");
};
assert_eq!(op, UnaryOp::Not);
assert_eq!(operand.kind, ExpressionKind::Boolean(false));
let (negative, two) = binary_children(expression("-1 * 2"), BinaryOp::Multiply);
assert_eq!(number(&two), "2");
let ExpressionKind::Unary { op, operand } = negative.kind else {
panic!("expected Negate");
};
assert_eq!(op, UnaryOp::Negate);
assert_eq!(number(&operand), "1");
let (quotient, three) = binary_children(expression("1 / 2 * 3"), BinaryOp::Multiply);
assert_eq!(number(&three), "3");
let (one, two) = binary_children(quotient, BinaryOp::Divide);
assert_eq!(
(number(&one), number(&two)),
("1".to_owned(), "2".to_owned())
);
let (one, grouped) = binary_children(expression("1 + (2 + 3)"), BinaryOp::Add);
assert_eq!(number(&one), "1");
assert!(grouped.grouped);
let (two, three) = binary_children(grouped, BinaryOp::Add);
assert_eq!(
(number(&two), number(&three)),
("2".to_owned(), "3".to_owned())
);
}
#[test]
fn flat_binary_depth_and_malformed_constructor_retain_bounded_completed_children() {
let flat = |count: usize| vec!["true"; count].join(" and ");
let deep = flat(128);
let mut tree = expression(&deep);
assert_eq!(tree.depth, 128);
// Independently verify the complete left-associated shape, not just depth.
for _ in 1..128 {
let (left, right) = binary_children(tree, BinaryOp::And);
assert_eq!(right.kind, ExpressionKind::Boolean(true));
tree = left;
}
assert_eq!(tree.kind, ExpressionKind::Boolean(true));
assert!(
parse(&flat(129), &mut budget())
.unwrap_err()
.message
.contains("logical depth")
);
// The first argument is already a complete depth128 tree when parsing the
// second fails. Refusal tears down the held child without building Sum.
let malformed = format!("sum({deep}, @)");
let failure = parse(&malformed, &mut budget()).unwrap_err();
assert_eq!(
failure.message,
"Expected a valid reference or parameter name."
);
assert_eq!(&malformed[failure.span.start..failure.span.end], ")");
assert_eq!(number(&expression("1")), "1");
}
#[test]
fn significant_ratio_and_quantity_trivia_preserve_original_spans() {
let source = "ratio // explanation café\n ( - // sign\n 3, // denominator\n 4 ) // measurement\n [@millisecond]";
let parsed = expression(source);
assert_eq!(
parsed.span,
Span {
start: 0,
end: source.len()
}
);
let literal = parsed.literal.as_ref().unwrap();
assert!(source[literal.span.start..literal.span.end].contains("// denominator"));
assert_eq!(
literal
.tokens
.iter()
.map(|span| &source[span.start..span.end])
.collect::<Vec<_>>(),
["ratio", "(", "-", "3", ",", "4", ")"]
);
let ExpressionKind::Quantity { magnitude, unit } = parsed.kind else {
panic!("expected Quantity");
};
assert_eq!(magnitude.canonical_literal(), "ratio(-3, 4)");
assert_eq!(unit.name, "millisecond");
assert_eq!(&source[unit.span.start..unit.span.end], "millisecond");
let mut renamed = source.to_owned();
renamed.replace_range(unit.span.start..unit.span.end, "ms");
assert_eq!(renamed, source.replacen("millisecond", "ms", 1));
assert_eq!(
number(&expression("ratio (1, // rationale\n 2)")),
"ratio(1, 2)"
);
for invalid in [
"1 e - 2",
"1e // x\n -2",
"1 . 2",
"ratio(1 + 2, 3)",
"ratio(1, -2)",
"ratio(+1, 2)",
"ratio(1.0, 2)",
"ratio(1, 0)",
] {
assert!(parse(invalid, &mut budget()).is_err(), "{invalid}");
}
}
#[test]
fn raw_numeric_lexing_preserves_exact_atoms_and_operator_boundaries() {
let source = "1-2 1e-2 ratio(-1,2) -(1) <= >= == !=";
let tokens = lex::tokens(source, &mut budget()).unwrap();
let text: Vec<_> = tokens
.iter()
.map(|t| &source[t.span.start..t.span.end])
.collect();
assert_eq!(
text,
[
"1", "-", "2", "1e-2", "ratio", "(", "-", "1", ",", "2", ")", "-", "(", "1", ")", "<=",
">=", "==", "!="
]
);
let huge = "9".repeat(400);
assert_eq!(
lex::tokens(&huge, &mut budget()).unwrap()[0].class,
lex::Class::Number
);
assert_eq!(number(&expression(&huge)), huge);
for invalid in ["1e", "1e+", "1_0", "0x1", "1.2.3"] {
assert!(lex::tokens(invalid, &mut budget()).is_err(), "{invalid}");
}
let quoted = "\"quoted\\\" café\"";
let tokens = lex::tokens(quoted, &mut budget()).unwrap();
assert_eq!(tokens[0].class, lex::Class::String);
assert_eq!(&quoted[tokens[0].span.start..tokens[0].span.end], quoted);
}
#[test]
fn unary_literals_precedence_and_comparison_children_keep_distinct_trees() {
assert_eq!(number(&expression("ratio(-1, 1)")), "ratio(-1, 1)");
let negative = expression("-(1)");
assert!(matches!(
negative.kind,
ExpressionKind::Unary {
op: UnaryOp::Negate,
..
}
));
let left = expression("(1 - 2) - 3");
let right = expression("1 - (2 - 3)");
let ExpressionKind::Binary {
left, right: last, ..
} = left.kind
else {
panic!();
};
assert!(matches!(
left.kind,
ExpressionKind::Binary {
op: BinaryOp::Subtract,
..
}
));
assert_eq!(number(&last), "3");
let ExpressionKind::Binary {
left: first, right, ..
} = right.kind
else {
panic!();
};
assert_eq!(number(&first), "1");
assert!(matches!(
right.kind,
ExpressionKind::Binary {
op: BinaryOp::Subtract,
..
}
));
for valid in [
"(1 < 2) == true",
"(true == false) == true",
"true == (1 < 2)",
"1 + 2 * 3",
"not(false) and true or false",
] {
parse(valid, &mut budget()).unwrap();
}
for invalid in [
"1 < 2 == true",
"true == false == true",
"1 < 2 < 3",
"true == 1 < 2",
] {
assert!(parse(invalid, &mut budget()).is_err(), "{invalid}");
}
}
#[test]
fn every_expression_shape_and_exact_constructor_arity_is_represented() {
let tree = expression("if(true, sum([@p, $x], 0), as_kind(@duration, power(abs(-(2)), -2)))");
let ExpressionKind::If {
condition,
when_true,
when_false,
} = tree.kind
else {
panic!();
};
assert_eq!(condition.kind, ExpressionKind::Boolean(true));
let ExpressionKind::Sum { values, initial } = when_true.kind else {
panic!();
};
assert_eq!(number(&initial), "0");
let ExpressionKind::List(values) = values.kind else {
panic!();
};
assert!(matches!(&values[0].kind, ExpressionKind::Property(r) if r.name == "p"));
assert!(matches!(&values[1].kind, ExpressionKind::Parameter(r) if r.name == "x"));
let ExpressionKind::AsKind { kind, value } = when_false.kind else {
panic!();
};
assert_eq!(kind.name, "duration");
assert!(matches!(
value.kind,
ExpressionKind::Power { exponent: -2, .. }
));
assert!(matches!(expression("[]").kind, ExpressionKind::List(v) if v.is_empty()));
assert!(
matches!(expression("@zero()").kind, ExpressionKind::Call { arguments, .. } if arguments.is_empty())
);
assert!(matches!(
parse(" unbound // pending", &mut budget()).unwrap(),
Binding::Unbound(_)
));
for invalid in [
"sum([1])",
"sum([1], 0, 2)",
"if(true, 1)",
"abs()",
"not(true, false)",
"power(2, 17)",
"power(2, 1.0)",
"as_kind(duration, 1)",
"[1,]",
"[1 2]",
"(1+2)[@unit]",
] {
assert!(parse(invalid, &mut budget()).is_err(), "{invalid}");
}
}
#[test]
fn repeated_call_argument_occurrences_have_distinct_original_spans() {
let source = "@combine(a: @p, b: 2) + @combine(a: @q, b: 4)";
let tree = expression(source);
let ExpressionKind::Binary { left, right, .. } = tree.kind else {
panic!();
};
let ExpressionKind::Call {
definition: first,
arguments: a,
} = left.kind
else {
panic!();
};
let ExpressionKind::Call {
definition: second,
arguments: b,
} = right.kind
else {
panic!();
};
assert_eq!(first.name, second.name);
assert_ne!(first.span, second.span);
assert_eq!(a[0].parameter.name, b[0].parameter.name);
assert_ne!(a[0].parameter.span, b[0].parameter.span);
for argument in a.iter().chain(&b) {
assert_eq!(
&source[argument.parameter.span.start..argument.parameter.span.end],
argument.parameter.name
);
}
assert!(
parse("@combine(a: 1, a: 2)", &mut budget())
.unwrap_err()
.message
.contains("Duplicate")
);
assert!(parse("@combine(a: 1, b:)", &mut budget()).is_err());
// Resolution to owner-qualified ParameterId and actual source rewrites are
// deliberately a later stage; this oracle establishes unique syntax spans.
}
#[test]
fn budgets_aggregate_across_fragments_and_latch_numeric_exhaustion() {
let mut first = budget();
parse("ratio(1, 3)", &mut first).unwrap();
let cost = first.numeric_units();
assert!(cost > 0);
let mut settings = limits();
settings.numeric_units = cost;
let mut shared = Budget::new(settings).unwrap();
parse("ratio (1, // same numeric cost\n 3)", &mut shared).unwrap();
assert_eq!(shared.numeric_units(), cost);
let failure = parse("2", &mut shared).unwrap_err();
let usage = shared.usage;
assert_eq!(parse("unbound", &mut shared).unwrap_err(), failure);
assert_eq!(shared.usage, usage);
parse("2", &mut budget()).unwrap();
let mut settings = limits();
settings.entries = 2;
let mut shared = Budget::new(settings).unwrap();
parse("true", &mut shared).unwrap();
parse("false", &mut shared).unwrap();
assert!(
parse("true", &mut shared)
.unwrap_err()
.message
.contains("entries")
);
assert_eq!(shared.usage.entries, 2);
let mut settings = limits();
settings.references = 1;
let mut shared = Budget::new(settings).unwrap();
parse("@one", &mut shared).unwrap();
assert!(
parse("$two", &mut shared)
.unwrap_err()
.message
.contains("references")
);
assert_eq!(shared.usage.references, 1);
}
#[test]
fn byte_token_frame_logical_and_list_boundaries_have_nearby_successes() {
let mut settings = limits();
settings.bytes = 4;
let mut shared = Budget::new(settings).unwrap();
parse("true", &mut shared).unwrap();
assert!(
parse(" ", &mut shared)
.unwrap_err()
.message
.contains("bytes")
);
assert_eq!(shared.usage.bytes, 4);
let mut settings = limits();
settings.tokens = 2;
let mut shared = Budget::new(settings).unwrap();
parse("true ", &mut shared).unwrap();
assert!(
parse("// note", &mut shared)
.unwrap_err()
.message
.contains("tokens")
);
assert_eq!(shared.usage.tokens, 2);
let nested = |depth: usize| format!("{}true{}", "(".repeat(depth), ")".repeat(depth));
let mut shared = budget();
assert_eq!(
match parse(&nested(256), &mut shared).unwrap() {
Binding::Expression(e) => e.depth,
_ => 0,
},
1
);
assert_eq!(shared.usage.deepest_frame, 256);
assert!(
parse(&nested(257), &mut budget())
.unwrap_err()
.message
.contains("frames")
);
let unary = |depth: usize| format!("{}true{}", "not(".repeat(depth - 1), ")".repeat(depth - 1));
assert_eq!(expression(&unary(128)).depth, 128);
assert!(
parse(&unary(129), &mut budget())
.unwrap_err()
.message
.contains("logical depth")
);
let list = |count: usize| format!("[{}]", vec!["true"; count].join(","));
parse(&list(1024), &mut budget()).unwrap();
assert!(parse(&list(1025), &mut budget()).is_err());
let lowered = Limits::source8(ParseLimits {
max_source_bytes: 8,
max_graph_objects: 3,
max_records: 5,
max_step_references: 2,
});
assert_eq!(
(
lowered.bytes,
lowered.tokens,
lowered.entries,
lowered.references
),
(8, 8, 5, 2)
);
let mut above = limits();
above.frames += 1;
assert!(Budget::new(above).is_err());
}
#[test]
fn partial_wide_and_deep_input_cleanup_does_not_poison_a_fresh_parse() {
let source = "@f(a: if(true, [ratio (1, //why\n 2), @p], sum([1, 2], 0)), b: power(2, -3))";
let tokens = lex::tokens(source, &mut budget()).unwrap();
for token in tokens.iter().filter(|token| {
matches!(
&source[token.span.start..token.span.end],
"(" | "[" | "," | ":" | "@"
)
}) {
assert!(parse(&source[..token.span.end], &mut budget()).is_err());
assert_eq!(number(&expression("ratio(2, 3)")), "ratio(2, 3)");
}
for malformed in [
format!("{}true", "(".repeat(256)),
format!("[{}", vec!["true"; 1024].join(",")),
"-".repeat(10_000),
] {
assert!(parse(&malformed, &mut budget()).is_err());
parse("true", &mut budget()).unwrap();
}
}
#[test]
fn public_source_headers_and_existing_numeric_token_controls_are_unchanged() {
assert!(crate::language::parse("arcadia 8; project p \"P\" {}").is_err());
for accepted in ["-1.5", "1e-2", "0", "-0"] {
let tokens = crate::language::tokenize(accepted);
assert_eq!(tokens.len(), 1);
assert_eq!(tokens[0].class, crate::language::TokenClass::Number);
assert_eq!(
tokens[0].span,
Span {
start: 0,
end: accepted.len()
}
);
}
for rejected in ["+1", "1_0", "0x1", "1e"] {
assert!(
crate::language::tokenize(rejected)
.iter()
.any(|t| t.class == crate::language::TokenClass::Error)
);
}
}
@@ -1,664 +0,0 @@
//! Private typed canonical output. Complete semantic admission belongs to the
//! caller. No public formatter, version dispatch or source edits use this yet.
mod expression;
pub(super) use expression::{Parent, Side};
use super::{budget, lower::Field};
use crate::analysis as a;
use crate::analysis_numeric::Exact;
use crate::{Id, Kind, Layer, Project, SharedBody};
use std::collections::{BTreeMap, BTreeSet};
use std::io::Write;
#[derive(Clone, Copy)]
pub(super) struct Limits {
pub bytes: usize,
pub entries: usize,
}
impl Limits {
pub fn source8(caller: crate::language::ParseLimits) -> Self {
let limits = budget::Limits::source8(caller);
Self {
bytes: limits.bytes,
entries: limits.entries,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct Error {
pub owner: Option<Id>,
pub field: &'static str,
pub message: String,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub(super) struct Usage {
pub bytes: usize,
pub entries: usize,
}
struct Sink {
buffer: Vec<u8>,
written: usize,
maximum: usize,
}
impl Sink {
fn room(&self, bytes: usize) -> std::io::Result<()> {
if bytes > self.maximum.saturating_sub(self.written) {
return Err(std::io::Error::other(
"Source8 writer output byte limit exceeded.",
));
}
Ok(())
}
}
impl Write for Sink {
fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
self.room(bytes.len())?;
self.buffer.extend_from_slice(bytes);
self.written += bytes.len();
Ok(bytes.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
pub(super) struct Writer<'p> {
project: &'p Project,
parameters: BTreeMap<Id, BTreeMap<a::ParameterId, &'p a::Parameter>>,
limits: Limits,
entries: usize,
sink: Sink,
owner: Option<Id>,
field: &'static str,
failed: Option<Error>,
}
impl<'p> Writer<'p> {
pub fn new(project: &'p Project, limits: Limits) -> Result<Self, Error> {
let hard = Limits::source8(crate::language::ParseLimits::UNLIMITED);
if limits.bytes > hard.bytes || limits.entries > hard.entries {
return Err(Error {
owner: None,
field: "writer",
message: "Writer limits exceed the source8 profile.".into(),
});
}
let mut writer = Self {
project,
parameters: BTreeMap::new(),
limits,
entries: 0,
sink: Sink {
buffer: Vec::new(),
written: 0,
maximum: limits.bytes,
},
owner: None,
field: "writer",
failed: None,
};
for definition in project.definitions.values() {
writer.owner = Some(definition.id);
writer.field = "parameters";
writer.entry()?;
if let a::DefinitionBody::Calculation(calculation) = &definition.body {
let mut parameters = BTreeMap::new();
let mut names = BTreeSet::new();
for parameter in &calculation.parameters {
writer.entry()?;
if parameters.contains_key(&parameter.id)
|| !names.insert(parameter.name.as_str())
{
return Err(writer.error("Duplicate scoped parameter identity or name."));
}
parameters.insert(parameter.id, parameter);
}
writer.parameters.insert(definition.id, parameters);
}
}
Ok(writer)
}
pub fn usage(&self) -> Usage {
Usage {
bytes: self.sink.written,
entries: self.entries,
}
}
fn error(&self, message: impl Into<String>) -> Error {
Error {
owner: self.owner,
field: self.field,
message: message.into(),
}
}
fn entry(&mut self) -> Result<(), Error> {
if self.entries == self.limits.entries {
return Err(self.error("Source8 writer entry limit exceeded."));
}
self.entries += 1;
Ok(())
}
fn pending(&self, count: usize) -> Result<(), Error> {
if count > self.limits.entries {
return Err(self.error("Source8 writer pending work limit exceeded."));
}
Ok(())
}
fn text(&mut self, text: &str) -> Result<(), Error> {
self.sink
.write_all(text.as_bytes())
.map_err(|cause| self.error(cause.to_string()))
}
fn quote(&mut self, text: &str) -> Result<(), Error> {
// Identical serde_json string escaping to language::quote, streamed
// directly to the checked sink instead of an unbounded scratch String.
self.sink
.room(text.len().saturating_add(2))
.map_err(|cause| self.error(cause.to_string()))?;
serde_json::to_writer(&mut self.sink, text).map_err(|cause| self.error(cause.to_string()))
}
fn name(&mut self, name: &str) -> Result<(), Error> {
self.sink
.room(name.len())
.map_err(|cause| self.error(cause.to_string()))?;
if !crate::valid_alias(name) {
return Err(self.error("Invalid alias or parameter name."));
}
self.text(name)
}
fn exact(&mut self, exact: &Exact) -> Result<(), Error> {
// At most two already-bounded 4096-bit components, never an Expr-sized
// temporary or a second parse/normalization of the stored value.
self.text(&exact.canonical_literal())
}
fn run(
&mut self,
owner: Id,
field: &'static str,
emit: impl FnOnce(&mut Self) -> Result<(), Error>,
) -> Result<String, Error> {
if let Some(error) = &self.failed {
return Err(error.clone());
}
self.owner = Some(owner);
self.field = field;
debug_assert!(self.sink.buffer.is_empty());
if let Err(error) = emit(self) {
self.sink.buffer.clear();
self.failed = Some(error.clone());
return Err(error);
}
Ok(String::from_utf8(std::mem::take(&mut self.sink.buffer))
.expect("text and JSON string writes are UTF-8"))
}
fn definition_name(
&self,
reference: a::DefinitionRef,
kind: DefinitionKind,
) -> Result<&'p str, Error> {
let definition = self
.project
.definitions
.get(&reference.local())
.ok_or_else(|| self.error("Unknown definition reference."))?;
let matches = matches!(
(&definition.body, kind),
(
a::DefinitionBody::QuantityKind(_),
DefinitionKind::QuantityKind
) | (a::DefinitionBody::Unit(_), DefinitionKind::Unit)
| (
a::DefinitionBody::Calculation(_),
DefinitionKind::Calculation
)
);
if !matches {
return Err(self.error("Wrong definition reference domain."));
}
Ok(&definition.alias)
}
fn definition_ref(
&mut self,
reference: a::DefinitionRef,
kind: DefinitionKind,
) -> Result<(), Error> {
let alias = self.definition_name(reference, kind)?;
self.text("@")?;
self.name(alias)
}
fn property_name(&self, id: Id) -> Result<&'p str, Error> {
self.project
.elements
.get(&id)
.filter(|value| value.kind() == Kind::EngineeringProperty)
.map(|value| value.alias.as_str())
.ok_or_else(|| self.error("Unknown property reference or wrong reference domain."))
}
fn subject(&mut self, subject: a::AnalysisSubject) -> Result<(), Error> {
let alias = match subject {
a::AnalysisSubject::Element(id) => self
.project
.elements
.get(&id)
.filter(|value| !value.kind().is_analysis())
.map(|value| value.alias.as_str()),
a::AnalysisSubject::Relation(id) => self
.project
.relations
.get(&id)
.map(|value| value.alias.as_str()),
}
.ok_or_else(|| self.error("Unknown architectural subject or wrong reference domain."))?;
self.text("@")?;
self.name(alias)
}
fn optional_subject(&mut self, subject: Option<a::AnalysisSubject>) -> Result<(), Error> {
if let Some(subject) = subject {
self.subject(subject)
} else {
self.text("none")
}
}
fn type_value(&mut self, value: &a::AnalysisType) -> Result<(), Error> {
self.entry()?;
let (scalar, list) = match value {
a::AnalysisType::Scalar(value) => (value, false),
a::AnalysisType::List(value) => (value, true),
};
if list {
self.text("List(")?;
}
match scalar {
a::ScalarType::Boolean => self.text("Boolean")?,
a::ScalarType::Number => self.text("Number")?,
a::ScalarType::Quantity(kind) => {
self.text("Quantity(")?;
self.definition_ref(*kind, DefinitionKind::QuantityKind)?;
self.text(")")?;
}
}
if list {
self.text(")")?;
}
Ok(())
}
pub fn value_type(&mut self, owner: Id, value: &a::AnalysisType) -> Result<String, Error> {
self.run(owner, "type", |writer| writer.type_value(value))
}
fn model_binding(
&self,
owner: Id,
field: Field,
) -> Result<(&'p a::Binding, Option<Id>), Error> {
if field == Field::Body {
if let Some(a::Definition {
body: a::DefinitionBody::Calculation(calculation),
..
}) = self.project.definitions.get(&owner)
{
return Ok((&calculation.body, Some(owner)));
}
} else if let Some(element) = self.project.elements.get(&owner) {
match (element.shared(), field) {
(Some(SharedBody::EngineeringProperty { base, .. }), Field::Base) => {
return Ok((base, None));
}
(Some(SharedBody::AnalysisCheck { predicate, .. }), Field::Predicate) => {
return Ok((predicate, None));
}
(Some(SharedBody::AnalysisCase { overrides, .. }), Field::Override(property)) => {
return overrides
.get(&property)
.map(|binding| (binding, None))
.ok_or_else(|| self.error("Missing override binding."));
}
_ => {}
}
}
Err(self.error("Binding field does not belong to the requested owner."))
}
fn binding_value(
&mut self,
binding: &a::Binding,
calculation: Option<Id>,
) -> Result<(), Error> {
match binding {
a::Binding::Unbound => {
self.entry()?;
self.text("unbound")
}
a::Binding::Expression(expression) => {
self.emit_expression(expression, calculation, Parent::Root)
}
}
}
pub fn binding(&mut self, owner: Id, field: Field) -> Result<String, Error> {
self.run(owner, binding_field(field), |writer| {
let (binding, scope) = writer.model_binding(owner, field)?;
writer.binding_value(binding, scope)
})
}
pub fn expression(
&mut self,
owner: Id,
field: Field,
expression: &a::Expr,
parent: Parent,
) -> Result<String, Error> {
self.run(owner, binding_field(field), |writer| {
let (_, scope) = writer.model_binding(owner, field)?;
writer.emit_expression(expression, scope, parent)
})
}
fn parameter_value(&mut self, calculation: Id, id: a::ParameterId) -> Result<(), Error> {
self.entry()?;
let parameter = *self
.parameters
.get(&calculation)
.and_then(|parameters| parameters.get(&id))
.ok_or_else(|| self.error("Unknown scoped parameter."))?;
self.text("input ")?;
self.name(&parameter.name)?;
self.text(" id \"")?;
self.text(&parameter.id.0.to_string())?;
self.text("\" : ")?;
self.type_value(&parameter.value_type)?;
self.text(";")
}
pub fn parameter(
&mut self,
calculation: Id,
parameter: a::ParameterId,
) -> Result<String, Error> {
self.run(calculation, "input", |writer| {
writer.parameter_value(calculation, parameter)
})
}
fn override_value(&mut self, property: Id, binding: &a::Binding) -> Result<(), Error> {
self.entry()?;
let alias = self.property_name(property)?;
self.text("@")?;
self.name(alias)?;
self.text(" = ")?;
self.binding_value(binding, None)?;
self.text(";")
}
pub fn override_entry(&mut self, case: Id, property: Id) -> Result<String, Error> {
self.run(case, "overrides", |writer| {
let (binding, _) = writer.model_binding(case, Field::Override(property))?;
writer.override_value(property, binding)
})
}
fn header(
&mut self,
word: &str,
alias: &str,
name: &str,
id: Id,
description: &str,
) -> Result<(), Error> {
self.entry()?;
self.text(" ")?;
self.text(word)?;
self.text(" ")?;
self.name(alias)?;
self.text(" ")?;
self.quote(name)?;
self.text(" id \"")?;
self.text(&id.to_string())?;
self.text("\" {\n")?;
self.start_field("description")?;
self.quote(description)?;
self.text(";\n")
}
fn start_field(&mut self, name: &'static str) -> Result<(), Error> {
self.field = name;
self.entry()?;
self.text(" ")?;
self.text(name)?;
self.text(" ")
}
pub fn definition(&mut self, id: Id) -> Result<String, Error> {
self.run(id, "definition", |writer| {
let project = writer.project;
let definition = project
.definitions
.get(&id)
.ok_or_else(|| writer.error("Unknown definition."))?;
let word = match &definition.body {
a::DefinitionBody::QuantityKind(_) => "quantity_kind",
a::DefinitionBody::Unit(_) => "unit",
a::DefinitionBody::Calculation(_) => "calculation",
};
writer.header(
word,
&definition.alias,
&definition.name,
id,
&definition.description,
)?;
match &definition.body {
a::DefinitionBody::QuantityKind(kind) => {
writer.start_field("dimension")?;
writer.text("{")?;
const AXES: [&str; 7] = [
"length",
"mass",
"time",
"electric_current",
"temperature",
"amount",
"luminous_intensity",
];
let mut nonzero = false;
for (axis, exponent) in AXES.into_iter().zip(kind.dimension.exponents()) {
if exponent != 0 {
writer.entry()?;
writer.text(" ")?;
writer.text(axis)?;
writer.text(" ")?;
writer.text(&exponent.to_string())?;
writer.text(";")?;
nonzero = true;
}
}
if nonzero {
writer.text(" ")?;
}
writer.text("};\n")?;
writer.start_field("algebra")?;
match kind.algebra {
a::QuantityAlgebra::Linear => writer.text("Linear")?,
a::QuantityAlgebra::Affine { difference } => {
writer.text("Affine(")?;
writer.definition_ref(difference, DefinitionKind::QuantityKind)?;
writer.text(")")?;
}
}
writer.text(";\n")?;
}
a::DefinitionBody::Unit(unit) => {
writer.start_field("kind")?;
writer.definition_ref(unit.kind, DefinitionKind::QuantityKind)?;
writer.text(";\n")?;
writer.start_field("scale")?;
writer.exact(&unit.scale)?;
writer.text(";\n")?;
writer.start_field("offset")?;
writer.exact(&unit.offset)?;
writer.text(";\n")?;
writer.start_field("symbol")?;
writer.quote(&unit.symbol)?;
writer.text(";\n")?;
}
a::DefinitionBody::Calculation(calculation) => {
for parameter in &calculation.parameters {
writer.field = "input";
writer.text(" ")?;
writer.parameter_value(id, parameter.id)?;
writer.text("\n")?;
}
writer.start_field("result")?;
writer.type_value(&calculation.result)?;
writer.text(";\n")?;
writer.start_field("body")?;
writer.binding_value(&calculation.body, Some(id))?;
writer.text(";\n")?;
}
}
writer.text(" }\n\n")
})
}
pub fn analysis_element(&mut self, id: Id) -> Result<String, Error> {
self.run(id, "element", |writer| {
let project = writer.project;
let element = project
.elements
.get(&id)
.ok_or_else(|| writer.error("Unknown analysis element."))?;
let word = match (element.kind(), element.layer()) {
(Kind::EngineeringProperty, Layer::Oa) => "oa_property",
(Kind::EngineeringProperty, Layer::Sa) => "sa_property",
(Kind::AnalysisCheck, Layer::Oa) => "oa_check",
(Kind::AnalysisCheck, Layer::Sa) => "sa_check",
(Kind::AnalysisCase, Layer::Oa) => "oa_analysis",
(Kind::AnalysisCase, Layer::Sa) => "sa_analysis",
_ => return Err(writer.error("Expected an analysis element.")),
};
if element.parent.is_some() {
return Err(writer.error("Analysis declarations must be roots."));
}
writer.header(
word,
&element.alias,
&element.name,
id,
&element.description,
)?;
writer.start_field("parent")?;
writer.text("none;\n")?;
match element.shared().expect("analysis kind is a shared body") {
SharedBody::EngineeringProperty {
subject,
value_type,
display_unit,
base,
} => {
writer.start_field("subject")?;
writer.subject(*subject)?;
writer.text(";\n")?;
writer.start_field("type")?;
writer.type_value(value_type)?;
writer.text(";\n")?;
writer.start_field("display_unit")?;
if let Some(unit) = display_unit {
writer.definition_ref(*unit, DefinitionKind::Unit)?;
} else {
writer.text("none")?;
}
writer.text(";\n")?;
writer.start_field("base")?;
writer.binding_value(base, None)?;
writer.text(";\n")?;
}
SharedBody::AnalysisCheck {
subject,
documented_constraint,
predicate,
} => {
writer.start_field("subject")?;
writer.optional_subject(*subject)?;
writer.text(";\n")?;
writer.start_field("documented_constraint")?;
if let Some(id) = documented_constraint {
let alias = project
.elements
.get(id)
.filter(|element| element.kind() == Kind::Constraint)
.map(|element| element.alias.as_str())
.ok_or_else(|| {
writer.error("Unknown constraint or wrong reference domain.")
})?;
writer.text("@")?;
writer.name(alias)?;
} else {
writer.text("none")?;
}
writer.text(";\n")?;
writer.start_field("predicate")?;
writer.binding_value(predicate, None)?;
writer.text(";\n")?;
}
SharedBody::AnalysisCase {
subject,
base: a::BaselineBase::ModelBaseline,
overrides,
} => {
writer.start_field("subject")?;
writer.optional_subject(*subject)?;
writer.text(";\n")?;
writer.start_field("base")?;
writer.text("ModelBaseline;\n")?;
writer.start_field("overrides")?;
if overrides.is_empty() {
writer.text("{};\n")?;
} else {
writer.text("{\n")?;
for (property, binding) in overrides {
writer.text(" ")?;
writer.override_value(*property, binding)?;
writer.text("\n")?;
}
writer.text(" };\n")?;
}
}
_ => return Err(writer.error("Expected an analysis body.")),
}
writer.text(" }\n\n")
})
}
pub fn typed_declarations(&mut self) -> Result<Vec<(Id, String)>, Error> {
if let Some(error) = &self.failed {
return Err(error.clone());
}
let project = self.project;
let mut declarations = Vec::new();
for id in project.definitions.keys() {
declarations.push((*id, self.definition(*id)?));
}
for element in project
.elements
.values()
.filter(|element| element.kind().is_analysis())
{
declarations.push((element.id, self.analysis_element(element.id)?));
}
Ok(declarations)
}
}
#[derive(Clone, Copy)]
enum DefinitionKind {
QuantityKind,
Unit,
Calculation,
}
fn binding_field(field: Field) -> &'static str {
match field {
Field::Body => "body",
Field::Base => "base",
Field::Predicate => "predicate",
Field::Override(_) => "overrides",
_ => "binding",
}
}
#[cfg(test)]
mod tests;
@@ -1,284 +0,0 @@
use super::*;
#[derive(Clone, Copy)]
pub enum Side {
Left,
Right,
}
#[derive(Clone, Copy)]
pub enum Parent {
Root,
Binary(a::BinaryOp, Side),
Negate,
}
fn operator(op: a::BinaryOp) -> (&'static str, u8) {
use a::BinaryOp::*;
match op {
Or => (" or ", 1),
And => (" and ", 2),
Equal => (" == ", 3),
NotEqual => (" != ", 3),
Less => (" < ", 4),
LessOrEqual => (" <= ", 4),
Greater => (" > ", 4),
GreaterOrEqual => (" >= ", 4),
Add => (" + ", 5),
Subtract => (" - ", 5),
Multiply => (" * ", 6),
Divide => (" / ", 6),
}
}
fn comparison(op: a::BinaryOp) -> bool {
matches!(operator(op).1, 3 | 4)
}
fn group(expression: &a::Expr, parent: Parent) -> bool {
let a::Expr::Binary { op, .. } = expression else {
return false;
};
match parent {
Parent::Root => false,
Parent::Negate => true,
Parent::Binary(parent, side) => {
(comparison(*op) && comparison(parent))
|| operator(*op).1 < operator(parent).1
|| (operator(*op).1 == operator(parent).1 && matches!(side, Side::Right))
}
}
}
#[derive(Clone, Copy)]
enum Task<'a> {
Expression(&'a a::Expr, Parent, usize),
Text(&'static str),
Alias(&'a str),
Literal(&'a Exact),
Exponent(i8),
}
fn schedule<'a>(
writer: &Writer<'_>,
tasks: &mut Vec<Task<'a>>,
ordered: &[Task<'a>],
) -> Result<(), Error> {
writer.pending(tasks.len().saturating_add(ordered.len()))?;
tasks.extend(ordered.iter().rev().copied());
Ok(())
}
impl Writer<'_> {
pub(super) fn emit_expression(
&mut self,
expression: &a::Expr,
calculation: Option<Id>,
parent: Parent,
) -> Result<(), Error> {
self.pending(1)?;
let mut tasks = vec![Task::Expression(expression, parent, 1)];
while let Some(task) = tasks.pop() {
match task {
Task::Text(text) => self.text(text)?,
Task::Alias(name) => {
self.entry()?;
self.name(name)?;
}
Task::Literal(exact) => self.exact(exact)?,
Task::Exponent(exponent) => self.text(&exponent.to_string())?,
Task::Expression(expression, parent, depth) => {
self.entry()?;
if depth > a::MAX_EXPRESSION_DEPTH {
return Err(self.error("Expression depth exceeds128."));
}
if group(expression, parent) {
self.text("(")?;
schedule(self, &mut tasks, &[Task::Text(")")])?;
}
use Task::{Alias, Exponent, Expression as Child, Literal, Text};
use a::Expr as E;
match expression {
E::Boolean(value) => self.text(if *value { "true" } else { "false" })?,
E::Number(value) => self.exact(value)?,
E::Quantity { magnitude, unit } => {
let alias = self.definition_name(*unit, DefinitionKind::Unit)?;
schedule(
self,
&mut tasks,
&[Literal(magnitude), Text(" [@"), Alias(alias), Text("]")],
)?;
}
E::Property(id) => {
let alias = self.property_name(*id)?;
schedule(self, &mut tasks, &[Text("@"), Alias(alias)])?;
}
E::Parameter(parameter) => {
let calculation = calculation.ok_or_else(|| {
self.error("Parameter use requires an owning calculation.")
})?;
let parameter = *self
.parameters
.get(&calculation)
.and_then(|parameters| parameters.get(parameter))
.ok_or_else(|| {
self.error("Unknown parameter in owning calculation.")
})?;
schedule(self, &mut tasks, &[Text("$"), Alias(&parameter.name)])?;
}
E::List(items) => {
if items.len() > a::MAX_LIST_LENGTH {
return Err(self.error("List exceeds1024 items."));
}
self.pending(
tasks
.len()
.saturating_add(items.len().saturating_mul(2))
.saturating_add(1),
)?;
self.text("[")?;
tasks.push(Text("]"));
for (index, item) in items.iter().enumerate().rev() {
tasks.push(Child(item, Parent::Root, depth + 1));
if index != 0 {
tasks.push(Text(", "));
}
}
}
E::Unary { op, operand } => match op {
a::UnaryOp::Negate => schedule(
self,
&mut tasks,
&[Text("- "), Child(operand, Parent::Negate, depth + 1)],
)?,
a::UnaryOp::Absolute | a::UnaryOp::Not => schedule(
self,
&mut tasks,
&[
Text(if *op == a::UnaryOp::Absolute {
"abs("
} else {
"not("
}),
Child(operand, Parent::Root, depth + 1),
Text(")"),
],
)?,
},
E::Binary { op, left, right } => schedule(
self,
&mut tasks,
&[
Child(left, Parent::Binary(*op, Side::Left), depth + 1),
Text(operator(*op).0),
Child(right, Parent::Binary(*op, Side::Right), depth + 1),
],
)?,
E::If {
condition,
when_true,
when_false,
} => schedule(
self,
&mut tasks,
&[
Text("if("),
Child(condition, Parent::Root, depth + 1),
Text(", "),
Child(when_true, Parent::Root, depth + 1),
Text(", "),
Child(when_false, Parent::Root, depth + 1),
Text(")"),
],
)?,
E::Call {
definition,
arguments,
} => {
let alias =
self.definition_name(*definition, DefinitionKind::Calculation)?;
let project = self.project;
let a::DefinitionBody::Calculation(callee) =
&project.definitions[&definition.local()].body
else {
unreachable!("checked calculation domain")
};
if arguments.len() != callee.parameters.len()
|| callee
.parameters
.iter()
.any(|parameter| !arguments.contains_key(&parameter.id))
{
return Err(self.error(
"Call argument IDs must match the called signature exactly.",
));
}
self.pending(
tasks
.len()
.saturating_add(arguments.len().saturating_mul(4))
.saturating_add(1),
)?;
self.entry()?;
self.text("@")?;
self.name(alias)?;
self.text("(")?;
tasks.push(Text(")"));
for (index, parameter) in callee.parameters.iter().enumerate().rev() {
tasks.push(Child(
&arguments[&parameter.id],
Parent::Root,
depth + 1,
));
tasks.push(Text(": "));
tasks.push(Alias(&parameter.name));
if index != 0 {
tasks.push(Text(", "));
}
}
}
E::Sum { values, initial } => schedule(
self,
&mut tasks,
&[
Text("sum("),
Child(values, Parent::Root, depth + 1),
Text(", "),
Child(initial, Parent::Root, depth + 1),
Text(")"),
],
)?,
E::Power { base, exponent } => {
if !(-16..=16).contains(exponent) {
return Err(self.error("Power exponent exceeds -16..16."));
}
schedule(
self,
&mut tasks,
&[
Text("power("),
Child(base, Parent::Root, depth + 1),
Text(", "),
Exponent(*exponent),
Text(")"),
],
)?;
}
E::AsKind { kind, value } => {
let alias =
self.definition_name(*kind, DefinitionKind::QuantityKind)?;
schedule(
self,
&mut tasks,
&[
Text("as_kind(@"),
Alias(alias),
Text(", "),
Child(value, Parent::Root, depth + 1),
Text(")"),
],
)?;
}
}
}
}
}
Ok(())
}
}
@@ -1,910 +0,0 @@
use super::super::{cursor::Cursor, declarations, lex, lower, model_oracle};
use super::*;
use crate::language::ParseLimits;
use a::{
AnalysisType, BinaryOp, Binding, DefinitionBody, DefinitionRef, Expr, ParameterId, ScalarType,
UnaryOp,
};
fn limits() -> Limits {
Limits::source8(ParseLimits::UNLIMITED)
}
fn writer(project: &Project) -> Writer<'_> {
Writer::new(project, limits()).unwrap()
}
fn id(text: &str) -> Id {
Id::parse_str(text).unwrap()
}
fn object(project: &Project, alias: &str) -> Id {
project
.identities()
.into_iter()
.find(|(_, name)| *name == alias)
.unwrap()
.0
}
fn number(value: i64) -> Expr {
Expr::Number(Exact::from_integer(value))
}
fn unary(op: UnaryOp, value: Expr) -> Expr {
Expr::Unary {
op,
operand: Box::new(value),
}
}
fn binary(op: BinaryOp, left: Expr, right: Expr) -> Expr {
Expr::Binary {
op,
left: Box::new(left),
right: Box::new(right),
}
}
fn quantity(value: i64, unit: Id) -> Expr {
Expr::Quantity {
magnitude: Exact::from_integer(value),
unit: DefinitionRef::Local(unit),
}
}
fn budget() -> budget::Budget {
budget::Budget::new(budget::Limits::source8(ParseLimits::UNLIMITED)).unwrap()
}
fn unit(source: &str, budget: &mut budget::Budget) -> Result<lower::Unit, super::super::Error> {
let tokens = lex::tokens(source, budget)?;
let mut cursor = Cursor::new(source, &tokens, budget);
let mut parsed = Vec::new();
while cursor.cursor < tokens.len() {
parsed.push(declarations::parse(&mut cursor, 8, None)?);
}
Ok(lower::Unit {
file: 1,
declarations: parsed,
})
}
fn merge(mut project: Project, lowered: lower::Lowered) -> Project {
project.definitions.extend(lowered.definitions);
project.elements.extend(lowered.elements);
project
}
fn parse_typed(project: Project, source: &str) -> Project {
let mut budget = budget();
let unit = unit(source, &mut budget).unwrap();
let lowered = lower::lower(&project, &[unit], &budget).unwrap();
merge(project, lowered)
}
fn skeleton(project: &Project) -> Project {
let mut project = project.clone();
project.definitions.clear();
project
.elements
.retain(|_, element| !element.kind().is_analysis());
project
}
fn canonical(project: &Project) -> String {
writer(project)
.typed_declarations()
.unwrap()
.into_iter()
.map(|(_, text)| text)
.collect()
}
fn read_expression(project: &Project, source: &str) -> Expr {
// A staged syntax/lowering wrapper, not a whole public source8 parser.
let source = format!(
r#"calculation writer_probe "Probe" id "00000000-0000-0000-0000-000000000099" {{
input a id "28ff40b0-1b8f-4735-8536-5a6ab21b56cb" : Quantity(@duration);
input b id "7a47cc1e-8ae5-4551-b2d8-8a68108e0976" : Quantity(@duration);
result Number; body {source};
}}"#
);
let mut parsed = parse_typed(project.clone(), &source);
let definition = parsed.definitions.remove(&Id::from_u128(0x99)).unwrap();
let DefinitionBody::Calculation(calculation) = definition.body else {
panic!("calculation")
};
let Binding::Expression(expression) = calculation.body else {
panic!("expression")
};
expression
}
#[test]
fn typed_oa_sa_canonical_roundtrips_match_independent_projects() {
for (layer, ordinary_source) in [
(
Layer::Oa,
include_str!("../lower/fixtures/ordinary-oa.arcadia"),
),
(
Layer::Sa,
include_str!("../lower/fixtures/ordinary-sa.arcadia"),
),
] {
let expected = model_oracle::project(layer);
for (alias, name) in model_oracle::NAMES {
let id = object(&expected, alias);
assert_eq!(
expected
.definitions
.get(&id)
.map(|value| value.name.as_str())
.or_else(|| expected.elements.get(&id).map(|value| value.name.as_str()))
.unwrap(),
name
);
}
let text = canonical(&expected);
let actual = parse_typed(crate::language::parse(ordinary_source).unwrap(), &text);
assert_eq!(actual, expected);
a::admit(&actual, a::AdmissionLimits::REFERENCE).unwrap();
assert_eq!(canonical(&actual), text);
assert!(
!text.contains("engineering_property")
&& !text.contains("analysis_case")
&& !text.contains("analysis_check")
);
let prefix = if layer == Layer::Oa { "oa" } else { "sa" };
for word in ["property", "check", "analysis"] {
assert!(text.contains(&format!("{prefix}_{word} ")));
}
}
assert!(crate::language::parse("arcadia 8; project p \"P\" {}").is_err());
}
#[test]
fn typed_declaration_order_and_required_empty_values_are_canonical() {
let mut project = model_oracle::project(Layer::Oa);
let duration = object(&project, "duration");
let total = object(&project, "total_time");
if let Some(SharedBody::EngineeringProperty { display_unit, .. }) =
project.elements.get_mut(&total).unwrap().body.shared_mut()
{
*display_unit = None;
}
let empty_case = object(&project, "unmeasured");
if let Some(SharedBody::AnalysisCase { overrides, .. }) = project
.elements
.get_mut(&empty_case)
.unwrap()
.body
.shared_mut()
{
overrides.clear();
}
let degraded = object(&project, "degraded");
let dispatch = object(&project, "dispatch_time");
let second = object(&project, "second");
if let Some(SharedBody::AnalysisCase { overrides, .. }) = project
.elements
.get_mut(&degraded)
.unwrap()
.body
.shared_mut()
{
overrides.insert(dispatch, Binding::Expression(quantity(1, second)));
}
let definitions = [
(
100,
"dimensionless",
DefinitionBody::QuantityKind(a::QuantityKindDefinition {
dimension: crate::analysis_numeric::Dimension::ZERO,
algebra: a::QuantityAlgebra::Linear,
}),
),
(
101,
"point",
DefinitionBody::QuantityKind(a::QuantityKindDefinition {
dimension: crate::analysis_numeric::Dimension::new([0, 0, 1, 0, 0, 0, 0]).unwrap(),
algebra: a::QuantityAlgebra::Affine {
difference: DefinitionRef::Local(duration),
},
}),
),
(
102,
"point_unit",
DefinitionBody::Unit(a::UnitDefinition {
kind: DefinitionRef::Local(Id::from_u128(101)),
scale: Exact::from_integer(1),
offset: Exact::from_integer(-2),
symbol: "\"café\"\n雪".into(),
}),
),
(
103,
"unused",
DefinitionBody::Calculation(a::CalculationDefinition {
parameters: Vec::new(),
result: AnalysisType::List(ScalarType::Number),
body: Binding::Unbound,
}),
),
];
for (id, alias, body) in definitions {
let id = Id::from_u128(id);
project.definitions.insert(
id,
a::Definition {
id,
alias: alias.into(),
name: alias.into(),
description: String::new(),
body,
},
);
}
let mut writer = writer(&project);
let declarations = writer.typed_declarations().unwrap();
let ids: Vec<_> = declarations.iter().map(|(id, _)| *id).collect();
let expected_ids: Vec<_> = project
.definitions
.keys()
.copied()
.chain(
project
.elements
.values()
.filter(|element| element.kind().is_analysis())
.map(|element| element.id),
)
.collect();
assert_eq!(ids, expected_ids);
let text: String = declarations.into_iter().map(|(_, text)| text).collect();
assert!(text.find("@receive_time =").unwrap() < text.find("@dispatch_time =").unwrap());
for expected in [
"dimension {};",
"dimension { time 1; };",
"algebra Affine(@duration);",
"offset ratio(-2, 1);",
"result List(Number);",
"body unbound;",
"display_unit none;",
"overrides {};",
"parent none;",
"subject none;",
"documented_constraint none;",
] {
assert!(text.contains(expected), "{expected}");
}
assert_eq!(
text.matches("\" id \"").count(),
project.definitions.len() + 7
);
let actual = parse_typed(skeleton(&project), &text);
assert_eq!(actual, project);
a::admit(&actual, a::AdmissionLimits::REFERENCE).unwrap();
assert_eq!(canonical(&actual), text);
assert_eq!(
self::writer(&project)
.override_entry(empty_case, total)
.unwrap_err()
.field,
"overrides"
);
}
#[test]
fn all_expr_variants_and_precedence_preserve_exact_trees() {
let mut project = model_oracle::project(Layer::Oa);
let second = object(&project, "second");
let duration = object(&project, "duration");
let property = object(&project, "receive_time");
let total = object(&project, "total_time");
let combine = object(&project, "combine");
let a = ParameterId(id("28ff40b0-1b8f-4735-8536-5a6ab21b56cb"));
let b = ParameterId(id("7a47cc1e-8ae5-4551-b2d8-8a68108e0976"));
let numeric = AnalysisType::Scalar(ScalarType::Number);
let boolean = AnalysisType::Scalar(ScalarType::Boolean);
let duration_type = AnalysisType::Scalar(ScalarType::Quantity(DefinitionRef::Local(duration)));
let cases = [
(Expr::Boolean(true), "true", boolean.clone()),
(number(-1), "ratio(-1, 1)", numeric.clone()),
(
quantity(-1, second),
"ratio(-1, 1) [@second]",
duration_type.clone(),
),
(
Expr::Property(property),
"@receive_time",
duration_type.clone(),
),
(Expr::Parameter(a), "$a", duration_type.clone()),
(
Expr::List(vec![number(1), number(2)]),
"[1, 2]",
AnalysisType::List(ScalarType::Number),
),
(
unary(UnaryOp::Absolute, unary(UnaryOp::Negate, number(2))),
"abs(- 2)",
numeric.clone(),
),
(
binary(BinaryOp::Add, number(1), number(2)),
"1 + 2",
numeric.clone(),
),
(
Expr::If {
condition: Box::new(Expr::Boolean(true)),
when_true: Box::new(number(1)),
when_false: Box::new(number(2)),
},
"if(true, 1, 2)",
numeric.clone(),
),
(
Expr::Call {
definition: DefinitionRef::Local(combine),
arguments: BTreeMap::from([(b, quantity(2, second)), (a, quantity(1, second))]),
},
"@combine(a: 1 [@second], b: 2 [@second])",
duration_type.clone(),
),
(
Expr::Sum {
values: Box::new(Expr::List(vec![number(1), number(2)])),
initial: Box::new(number(0)),
},
"sum([1, 2], 0)",
numeric.clone(),
),
(
Expr::Power {
base: Box::new(number(2)),
exponent: -2,
},
"power(2, -2)",
numeric.clone(),
),
(
Expr::AsKind {
kind: DefinitionRef::Local(duration),
value: Box::new(binary(
BinaryOp::Divide,
Expr::Power {
base: Box::new(quantity(1, second)),
exponent: 2,
},
quantity(1, second),
)),
},
"as_kind(@duration, power(1 [@second], 2) / 1 [@second])",
duration_type,
),
];
project.elements.remove(&object(&project, "budget"));
for (expected, golden, value_type) in cases {
let parameter = matches!(expected, Expr::Parameter(_));
let owner = if parameter { combine } else { total };
let field = if parameter { Field::Body } else { Field::Base };
let text = writer(&project)
.expression(owner, field, &expected, Parent::Root)
.unwrap();
assert_eq!(text, golden);
let actual = read_expression(&project, &text);
assert_eq!(actual, expected);
let mut candidate = project.clone();
if parameter {
let DefinitionBody::Calculation(calculation) =
&mut candidate.definitions.get_mut(&combine).unwrap().body
else {
panic!()
};
calculation.body = Binding::Expression(actual);
} else {
let Some(SharedBody::EngineeringProperty {
base,
value_type: ty,
display_unit,
..
}) = candidate
.elements
.get_mut(&total)
.unwrap()
.body
.shared_mut()
else {
panic!()
};
*base = Binding::Expression(actual);
*ty = value_type;
*display_unit = None;
}
a::admit(&candidate, a::AdmissionLimits::REFERENCE).unwrap();
}
let cases = [
(unary(UnaryOp::Negate, number(1)), "- 1"),
(unary(UnaryOp::Negate, number(-1)), "- ratio(-1, 1)"),
(
unary(UnaryOp::Negate, unary(UnaryOp::Negate, number(1))),
"- - 1",
),
(
unary(UnaryOp::Negate, binary(BinaryOp::Add, number(1), number(2))),
"- (1 + 2)",
),
(unary(UnaryOp::Not, Expr::Boolean(true)), "not(true)"),
(
binary(
BinaryOp::Subtract,
number(1),
binary(BinaryOp::Subtract, number(2), number(3)),
),
"1 - (2 - 3)",
),
(
binary(
BinaryOp::Subtract,
binary(BinaryOp::Subtract, number(1), number(2)),
number(3),
),
"1 - 2 - 3",
),
(
binary(
BinaryOp::Add,
number(1),
binary(BinaryOp::Add, number(2), number(3)),
),
"1 + (2 + 3)",
),
(
binary(
BinaryOp::Multiply,
binary(BinaryOp::Add, number(1), number(2)),
number(3),
),
"(1 + 2) * 3",
),
(
binary(
BinaryOp::Equal,
binary(BinaryOp::Less, number(1), number(2)),
Expr::Boolean(true),
),
"(1 < 2) == true",
),
(
binary(
BinaryOp::Equal,
binary(BinaryOp::Equal, Expr::Boolean(true), Expr::Boolean(false)),
Expr::Boolean(true),
),
"(true == false) == true",
),
(
binary(
BinaryOp::Equal,
Expr::Boolean(true),
binary(BinaryOp::Less, number(1), number(2)),
),
"true == (1 < 2)",
),
(Expr::List(Vec::new()), "[]"),
];
for (expected, golden) in cases {
let text = writer(&project)
.expression(total, Field::Base, &expected, Parent::Root)
.unwrap();
assert_eq!(text, golden);
assert_eq!(read_expression(&project, &text), expected);
}
}
#[test]
fn canonical_calls_use_callee_parameter_order_and_qualified_names() {
let mut project = model_oracle::project(Layer::Oa);
let combine = object(&project, "combine");
let total = object(&project, "total_time");
let duration = object(&project, "duration");
let a = ParameterId(id("28ff40b0-1b8f-4735-8536-5a6ab21b56cb"));
let b = ParameterId(id("7a47cc1e-8ae5-4551-b2d8-8a68108e0976"));
let DefinitionBody::Calculation(calculation) =
&mut project.definitions.get_mut(&combine).unwrap().body
else {
panic!()
};
calculation.parameters.reverse();
let other = Id::from_u128(120);
project.definitions.insert(
other,
a::Definition {
id: other,
alias: "other".into(),
name: "Other".into(),
description: String::new(),
body: DefinitionBody::Calculation(a::CalculationDefinition {
parameters: vec![a::Parameter {
id: a,
name: "first".into(),
value_type: AnalysisType::Scalar(ScalarType::Quantity(DefinitionRef::Local(
duration,
))),
}],
result: AnalysisType::Scalar(ScalarType::Quantity(DefinitionRef::Local(duration))),
body: Binding::Expression(Expr::Parameter(a)),
}),
},
);
project.definitions.insert(
a.0,
a::Definition {
id: a.0,
alias: "collision".into(),
name: "Collision".into(),
description: String::new(),
body: DefinitionBody::QuantityKind(a::QuantityKindDefinition {
dimension: crate::analysis_numeric::Dimension::ZERO,
algebra: a::QuantityAlgebra::Linear,
}),
},
);
let mut out = writer(&project);
let text = out.binding(total, Field::Base).unwrap();
assert!(text.starts_with("@combine(b: @dispatch_time, a: @receive_time)"));
let call = Expr::Call {
definition: DefinitionRef::Local(other),
arguments: BTreeMap::from([(a, Expr::Parameter(a))]),
};
assert_eq!(
out.expression(combine, Field::Body, &call, Parent::Root)
.unwrap(),
"@other(first: $a)"
);
assert!(out.parameter(combine, a).unwrap().starts_with("input a id"));
assert!(
out.parameter(other, a)
.unwrap()
.starts_with("input first id")
);
assert!(out.parameter(combine, b).unwrap().starts_with("input b id"));
let text = canonical(&project);
let actual = parse_typed(skeleton(&project), &text);
assert_eq!(actual, project);
a::admit(&actual, a::AdmissionLimits::REFERENCE).unwrap();
}
#[test]
fn subtree_output_respects_parent_context_and_retained_delimiters() {
let project = model_oracle::project(Layer::Oa);
let owner = object(&project, "total_time");
for replacement in [
binary(BinaryOp::Add, number(1), number(2)),
number(-1),
unary(UnaryOp::Negate, number(1)),
] {
let text = writer(&project)
.expression(owner, Field::Base, &replacement, Parent::Negate)
.unwrap();
let source = format!("-{text}"); // replace the entire original operand span `(1)` in `-(1)`
assert_eq!(
read_expression(&project, &source),
unary(UnaryOp::Negate, replacement)
);
}
let value = binary(BinaryOp::Subtract, number(2), number(3));
let text = writer(&project)
.expression(
owner,
Field::Base,
&value,
Parent::Binary(BinaryOp::Subtract, Side::Right),
)
.unwrap();
assert_eq!(text, "(2 - 3)");
assert_eq!(
read_expression(&project, &format!("1 - {text}")),
binary(BinaryOp::Subtract, number(1), value)
);
assert_eq!(
writer(&project)
.value_type(owner, &AnalysisType::List(ScalarType::Boolean))
.unwrap(),
"List(Boolean)"
);
let case = object(&project, "unmeasured");
let property = object(&project, "receive_time");
assert_eq!(
writer(&project).override_entry(case, property).unwrap(),
"@receive_time = unbound;"
);
}
#[test]
fn canonical_strings_and_exact_literals_use_existing_encodings() {
let mut project = model_oracle::project(Layer::Oa);
let second = object(&project, "second");
let text = "\"\\\n\r\t\u{0000} café 雪";
project.definitions.get_mut(&second).unwrap().name = text.into();
project.definitions.get_mut(&second).unwrap().description = text.into();
let DefinitionBody::Unit(unit) = &mut project.definitions.get_mut(&second).unwrap().body else {
panic!()
};
unit.symbol = text.into();
let output = writer(&project).definition(second).unwrap();
assert_eq!(output.matches(&crate::language::quote(text)).count(), 3);
let actual = parse_typed(skeleton(&project), &canonical(&project));
assert_eq!(actual, project);
let owner = object(&project, "total_time");
for source in ["0", "ratio(-1, 1)", "ratio(3, 2)", "1e100"] {
let expression = read_expression(&project, source);
let Expr::Number(value) = &expression else {
panic!()
};
assert_eq!(
writer(&project)
.expression(owner, Field::Base, &expression, Parent::Root)
.unwrap(),
value.canonical_literal()
);
}
}
#[test]
fn writer_limits_are_aggregate_exact_and_latched_without_partial_results() {
let project = model_oracle::project(Layer::Oa);
let owner = object(&project, "total_time");
let mut one = Writer::new(
&project,
Limits {
bytes: 1,
..limits()
},
)
.unwrap();
assert_eq!(
one.expression(owner, Field::Base, &number(1), Parent::Root)
.unwrap(),
"1"
);
let failure = one
.expression(owner, Field::Base, &number(2), Parent::Root)
.unwrap_err();
assert_eq!(
failure,
one.expression(owner, Field::Base, &number(0), Parent::Root)
.unwrap_err()
);
assert_eq!(one.usage().bytes, 1);
let mut measured = writer(&project);
measured
.expression(owner, Field::Base, &number(1), Parent::Root)
.unwrap();
let usage = measured.usage();
for (maximum, pass) in [(usage.entries, true), (usage.entries - 1, false)] {
let mut bounded = Writer::new(
&project,
Limits {
entries: maximum,
..limits()
},
)
.unwrap();
assert_eq!(
bounded
.expression(owner, Field::Base, &number(1), Parent::Root)
.is_ok(),
pass
);
}
let output = canonical(&project);
let mut exact = Writer::new(
&project,
Limits {
bytes: output.len(),
..limits()
},
)
.unwrap();
assert_eq!(exact.typed_declarations().unwrap().len(), 11);
let mut short = Writer::new(
&project,
Limits {
bytes: output.len() - 1,
..limits()
},
)
.unwrap();
assert!(short.typed_declarations().is_err());
assert_eq!(canonical(&project), output);
let mut quoted = project.clone();
let unit_id = object(&quoted, "second");
quoted.definitions.get_mut(&unit_id).unwrap().name = "雪\n\"".into();
let text = writer(&quoted).definition(unit_id).unwrap();
for (maximum, pass) in [(text.len(), true), (text.len() - 1, false)] {
assert_eq!(
Writer::new(
&quoted,
Limits {
bytes: maximum,
..limits()
}
)
.unwrap()
.definition(unit_id)
.is_ok(),
pass
);
}
assert!(
Writer::new(
&project,
Limits {
bytes: usize::MAX,
..limits()
}
)
.is_err()
);
}
#[test]
fn writer_lookup_scope_shape_and_depth_failures_are_model_located() {
let project = model_oracle::project(Layer::Oa);
let owner = object(&project, "total_time");
let combine = object(&project, "combine");
let duration = object(&project, "duration");
let a = ParameterId(id("28ff40b0-1b8f-4735-8536-5a6ab21b56cb"));
let cases = [
Expr::Property(duration),
Expr::Property(Id::nil()),
Expr::Quantity {
magnitude: Exact::from_integer(1),
unit: DefinitionRef::Local(duration),
},
Expr::Parameter(a),
Expr::Call {
definition: DefinitionRef::Local(duration),
arguments: BTreeMap::new(),
},
Expr::Call {
definition: DefinitionRef::Local(combine),
arguments: BTreeMap::new(),
},
Expr::Call {
definition: DefinitionRef::Local(combine),
arguments: BTreeMap::from([(ParameterId(Id::nil()), number(1)), (a, number(2))]),
},
Expr::Power {
base: Box::new(number(1)),
exponent: 17,
},
Expr::List((0..1025).map(|_| number(1)).collect()),
];
for expression in cases {
let error = writer(&project)
.expression(owner, Field::Base, &expression, Parent::Root)
.unwrap_err();
assert_eq!(error.owner, Some(owner));
assert_eq!(error.field, "base");
}
assert!(
writer(&project)
.expression(
combine,
Field::Body,
&Expr::Parameter(ParameterId(Id::nil())),
Parent::Root
)
.is_err()
);
assert!(writer(&project).binding(owner, Field::Body).is_err());
assert!(
writer(&project)
.analysis_element(object(&project, "respond"))
.is_err()
);
let mut deep = number(-1);
for _ in 0..128 {
deep = unary(UnaryOp::Negate, deep);
}
assert!(
writer(&project)
.expression(owner, Field::Base, &deep, Parent::Root)
.unwrap_err()
.message
.contains("depth")
);
}
#[test]
fn negate_override_roundtrip_preserves_depth128_within_frame256() {
let original = model_oracle::project(Layer::Oa);
let prefix = format!("{}ratio(-1, 1)", "- ".repeat(127));
let grouped = format!("{}ratio(-1, 1){}", "-(".repeat(127), ")".repeat(127));
let property = r#"oa_property measure "Measure" id "00000000-0000-0000-0000-000000000130" { subject @respond; type Number; base unbound; }"#;
let case = |expression: &str| {
format!(
r#"oa_analysis measured "Measured" id "00000000-0000-0000-0000-000000000131" {{ base ModelBaseline; overrides {{ @measure = {expression}; }}; }}"#
)
};
let calculation = |expression: &str| {
format!(
r#"calculation measured_calc "Measured calculation" id "00000000-0000-0000-0000-000000000132" {{ result Number; body {expression}; }}"#
)
};
for (source, frames) in [
(case(&prefix), 130),
(calculation(&prefix), 129),
(calculation(&grouped), 256),
] {
let mut budget = budget();
unit(&source, &mut budget).unwrap();
assert_eq!(budget.usage.deepest_frame, frames);
}
let mut failed_budget = budget();
let error = unit(&case(&grouped), &mut failed_budget).err().unwrap();
assert!(error.message.contains("syntax frames"));
assert_eq!(failed_budget.usage.deepest_frame, 256);
let project = parse_typed(original, &format!("{property}\n{}", case(&prefix)));
a::admit(&project, a::AdmissionLimits::REFERENCE).unwrap();
let case_id = Id::from_u128(0x131);
let property_id = Id::from_u128(0x130);
let Some(SharedBody::AnalysisCase { overrides, .. }) = project.elements[&case_id].shared()
else {
panic!()
};
let Binding::Expression(root) = &overrides[&property_id] else {
panic!()
};
let mut current = root;
let mut negates = 0;
while let Expr::Unary {
op: UnaryOp::Negate,
operand,
} = current
{
current = operand;
negates += 1;
}
assert_eq!(negates, 127);
assert_eq!(current, &number(-1));
let text = writer(&project).analysis_element(case_id).unwrap();
assert!(text.contains(&prefix));
let mut budget = budget();
unit(&text, &mut budget).unwrap();
assert_eq!(budget.usage.deepest_frame, 130);
let mut before_case = project.clone();
before_case.elements.remove(&case_id);
let actual = parse_typed(before_case, &text);
assert_eq!(actual, project);
assert_eq!(writer(&actual).analysis_element(case_id).unwrap(), text);
}
#[test]
fn iterative_writer_preserves_width1024_and_empty_call_topology() {
let mut project = model_oracle::project(Layer::Oa);
let owner = object(&project, "total_time");
let expected = Expr::List((0..1024).map(|_| number(1)).collect());
let text = writer(&project)
.expression(owner, Field::Base, &expected, Parent::Root)
.unwrap();
assert_eq!(read_expression(&project, &text), expected);
let empty = Id::from_u128(140);
project.definitions.insert(
empty,
a::Definition {
id: empty,
alias: "empty".into(),
name: "Empty".into(),
description: String::new(),
body: DefinitionBody::Calculation(a::CalculationDefinition {
parameters: Vec::new(),
result: AnalysisType::Scalar(ScalarType::Number),
body: Binding::Unbound,
}),
},
);
let call = Expr::Call {
definition: DefinitionRef::Local(empty),
arguments: BTreeMap::new(),
};
let text = writer(&project)
.expression(owner, Field::Base, &call, Parent::Root)
.unwrap();
assert_eq!(text, "@empty()");
assert_eq!(read_expression(&project, &text), call);
}
-347
View File
@@ -1,347 +0,0 @@
//! One raw lexical scan for legacy adapters and staged typed source syntax.
//! Numbers remain source spans; only consumers choose a numeric domain.
use super::Span;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum Class {
Word,
Number,
String,
Punctuation,
Whitespace,
Comment,
Error,
}
#[derive(Debug, Clone, Copy)]
pub(super) struct Token {
pub class: Class,
pub span: Span,
/// Entire token belongs to the old greedy numeric-token alphabet.
pub legacy_number_part: bool,
}
pub(super) struct Scanner<'a> {
source: &'a str,
cursor: usize,
}
impl<'a> Scanner<'a> {
pub fn new(source: &'a str) -> Self {
Self { source, cursor: 0 }
}
}
impl Iterator for Scanner<'_> {
type Item = Token;
fn next(&mut self) -> Option<Token> {
let source = self.source;
let bytes = source.as_bytes();
let start = self.cursor;
let first = *bytes.get(start)?;
let mut legacy_number_part = false;
let class = if matches!(first, b' ' | b'\t' | b'\r' | b'\n') {
self.cursor += 1;
while bytes
.get(self.cursor)
.is_some_and(|b| matches!(b, b' ' | b'\t' | b'\r' | b'\n'))
{
self.cursor += 1;
}
Class::Whitespace
} else if bytes[start..].starts_with(b"//") {
self.cursor += 2;
while bytes.get(self.cursor).is_some_and(|b| *b != b'\n') {
self.cursor += 1;
}
Class::Comment
} else if first.is_ascii_alphabetic() || first == b'_' {
self.cursor += 1;
while bytes
.get(self.cursor)
.is_some_and(|b| b.is_ascii_alphanumeric() || *b == b'_')
{
self.cursor += 1;
}
legacy_number_part = true;
Class::Word
} else if first.is_ascii_digit() {
while bytes.get(self.cursor).is_some_and(u8::is_ascii_digit) {
self.cursor += 1;
}
let mut valid = true;
if bytes.get(self.cursor) == Some(&b'.') {
self.cursor += 1;
let fraction = self.cursor;
while bytes.get(self.cursor).is_some_and(u8::is_ascii_digit) {
self.cursor += 1;
}
valid &= self.cursor != fraction;
}
if matches!(bytes.get(self.cursor), Some(b'e' | b'E')) {
self.cursor += 1;
if matches!(bytes.get(self.cursor), Some(b'+' | b'-')) {
self.cursor += 1;
}
let exponent = self.cursor;
while bytes.get(self.cursor).is_some_and(u8::is_ascii_digit) {
self.cursor += 1;
}
valid &= self.cursor != exponent;
}
if bytes
.get(self.cursor)
.is_some_and(|b| b.is_ascii_alphabetic() || matches!(b, b'_' | b'.'))
{
valid = false;
while bytes
.get(self.cursor)
.is_some_and(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'.'))
{
self.cursor += 1;
}
}
legacy_number_part = true;
if valid { Class::Number } else { Class::Error }
} else if first == b'"' {
self.cursor += 1;
let mut escaped = false;
let mut closed = false;
while self.cursor < bytes.len() {
let ch = source[self.cursor..]
.chars()
.next()
.expect("UTF-8 boundary");
self.cursor += ch.len_utf8();
if escaped {
escaped = false;
} else if ch == '\\' {
escaped = true;
} else if ch == '"' {
closed = true;
break;
}
}
if closed && serde_json::from_str::<String>(&source[start..self.cursor]).is_ok() {
Class::String
} else {
Class::Error
}
} else if b"{}();,[]:=@$+-*/<>!".contains(&first) {
self.cursor += 1;
if matches!(first, b'<' | b'>' | b'=' | b'!') && bytes.get(self.cursor) == Some(&b'=') {
self.cursor += 1;
}
legacy_number_part = matches!(first, b'+' | b'-');
Class::Punctuation
} else {
self.cursor += source[start..]
.chars()
.next()
.expect("UTF-8 boundary")
.len_utf8();
legacy_number_part = first == b'.';
Class::Error
};
Some(Token {
class,
span: Span {
start,
end: self.cursor,
},
legacy_number_part,
})
}
}
/// Preserve the existing public token shapes, including greedy signed JSON
/// numbers and single-character rejection of newer expression operators.
pub(super) fn legacy(source: &str) -> Vec<super::TokenSpan> {
use super::{TokenClass, TokenSpan};
let mut scanner = Scanner::new(source).peekable();
let mut out = Vec::new();
while let Some(token) = scanner.next() {
let mut span = token.span;
let first = source.as_bytes()[span.start];
if first.is_ascii_digit() || matches!(first, b'+' | b'-' | b'.') {
while scanner
.peek()
.is_some_and(|next| next.legacy_number_part && next.span.start == span.end)
{
span.end = scanner.next().expect("peeked token").span.end;
}
let class = if serde_json::from_str::<serde_json::Number>(&source[span.start..span.end])
.is_ok()
{
TokenClass::Number
} else {
TokenClass::Error
};
out.push(TokenSpan { class, span });
continue;
}
let class = match token.class {
Class::Word => {
let word = &source[span.start..span.end];
if super::reserved(word) || matches!(word, "true" | "false" | "none") {
TokenClass::Keyword
} else {
TokenClass::Identifier
}
}
Class::String => TokenClass::String,
Class::Whitespace => TokenClass::Whitespace,
Class::Comment => TokenClass::Comment,
Class::Punctuation => {
// The raw scanner joins comparison operators. Legacy syntax
// classified their ASCII characters individually.
for at in span.start..span.end {
let class = if b"{}();,[]:=@".contains(&source.as_bytes()[at]) {
TokenClass::Punctuation
} else {
TokenClass::Error
};
out.push(TokenSpan {
class,
span: Span {
start: at,
end: at + 1,
},
});
}
continue;
}
Class::Error => TokenClass::Error,
Class::Number => unreachable!("number tokens start with a digit"),
};
out.push(TokenSpan { class, span });
}
out
}
#[cfg(test)]
mod tests {
use super::super::{TokenClass, tokenize};
#[test]
fn legacy_adapter_retains_authored_numeric_operator_and_trivia_tokens() {
use TokenClass::*;
let cases: &[(&str, &[(TokenClass, &str)])] = &[
(
"-1.5 1e-2 -0",
&[
(Number, "-1.5"),
(Whitespace, " "),
(Number, "1e-2"),
(Whitespace, " "),
(Number, "-0"),
],
),
(
"1-2 1 -2 1 - 2",
&[
(Error, "1-2"),
(Whitespace, " "),
(Number, "1"),
(Whitespace, " "),
(Number, "-2"),
(Whitespace, " "),
(Number, "1"),
(Whitespace, " "),
(Error, "-"),
(Whitespace, " "),
(Number, "2"),
],
),
(
"+1 .5 1_0 0x1 1e 1e+",
&[
(Error, "+1"),
(Whitespace, " "),
(Error, ".5"),
(Whitespace, " "),
(Error, "1_0"),
(Whitespace, " "),
(Error, "0x1"),
(Whitespace, " "),
(Error, "1e"),
(Whitespace, " "),
(Error, "1e+"),
],
),
(
"<= != == >= / * $ !",
&[
(Error, "<"),
(Punctuation, "="),
(Whitespace, " "),
(Error, "!"),
(Punctuation, "="),
(Whitespace, " "),
(Punctuation, "="),
(Punctuation, "="),
(Whitespace, " "),
(Error, ">"),
(Punctuation, "="),
(Whitespace, " "),
(Error, "/"),
(Whitespace, " "),
(Error, "*"),
(Whitespace, " "),
(Error, "$"),
(Whitespace, " "),
(Error, "!"),
],
),
(
"// π\r\n\"雪\" @alias",
&[
(Comment, "// π\r"),
(Whitespace, "\n"),
(String, "\"雪\""),
(Whitespace, " "),
(Punctuation, "@"),
(Keyword, "alias"),
],
),
("\"incomplete 雪", &[(Error, "\"incomplete 雪")]),
("\"bad\\q\"", &[(Error, "\"bad\\q\"")]),
(
"雪\u{b}a",
&[(Error, "雪"), (Error, "\u{b}"), (Identifier, "a")],
),
(
"unit calculation quantity_kind if and List Number",
&[
(Identifier, "unit"),
(Whitespace, " "),
(Identifier, "calculation"),
(Whitespace, " "),
(Identifier, "quantity_kind"),
(Whitespace, " "),
(Identifier, "if"),
(Whitespace, " "),
(Identifier, "and"),
(Whitespace, " "),
(Identifier, "List"),
(Whitespace, " "),
(Identifier, "Number"),
],
),
];
for (source, expected) in cases {
let actual = tokenize(source);
assert_eq!(actual.len(), expected.len(), "{source:?}");
let mut offset = 0;
for (token, (class, spelling)) in actual.iter().zip(*expected) {
assert_eq!(token.class, *class, "{source:?} at {offset}");
assert_eq!(token.span.start, offset);
offset += spelling.len();
assert_eq!(token.span.end, offset);
assert_eq!(&source[token.span.start..token.span.end], *spelling);
}
assert_eq!(offset, source.len());
}
}
}
@@ -1,52 +0,0 @@
//! Convert only source3 lowering scratch state into its frozen migration input.
//! This is not a current-to-legacy exporter: fields outside that scratch state
//! must reject instead of being silently discarded.
use crate::Project;
use crate::persistence::{FileError, v3};
use serde_json::{Map, Value};
pub(super) fn from_scratch(
project: Project,
legacy_elements: Map<String, Value>,
) -> Result<v3::Project3, FileError> {
// No `..`: a future Project field requires an explicit compatibility decision.
let Project {
schema_version,
id,
alias,
name,
description,
definitions,
elements,
relations,
diagrams,
artifact_links,
traces,
} = project;
if schema_version != 3 {
return Err(FileError::Invalid(
"Expected source3 lowering state.".into(),
));
}
if !definitions.is_empty() || !elements.is_empty() {
return Err(FileError::Invalid(
"Source3 lowering state cannot contain current definitions or elements.".into(),
));
}
Ok(v3::Project3 {
schema_version,
id,
alias,
name,
description,
elements: serde_json::from_value(Value::Object(legacy_elements))?,
// Keep the frozen nested contracts, including strict trace baselines.
relations: serde_json::from_value(serde_json::to_value(relations)?)?,
diagrams,
artifact_links,
traces: serde_json::from_value(serde_json::to_value(traces)?)?,
})
}
#[cfg(test)]
mod tests;
@@ -1,188 +0,0 @@
use super::*;
use crate::{Id, language, persistence};
use serde_json::json;
const SOURCE3: &str = include_str!("../../../tests/fixtures/v3-data-contract.arcadia");
const JSON3: &[u8] = include_bytes!("../../../tests/fixtures/v3-data-contract.json");
const PROSE: &str = r#"arcadia 3;
project review "Review" id "00000000-0000-0000-0000-000000000001" {
oa_activity reviewed_activity "Live OA" id "00000000-0000-0000-0000-000000000002" {}
sa_function function "Live SA" id "00000000-0000-0000-0000-000000000003" {}
trace review_link "Review link" id "00000000-0000-0000-0000-000000000004" {
oa @reviewed_activity; sa @function; kind Refinement;
baseline {
oa { name "Approved OA"; description "First\nΩ rationale"; };
sa { name "Approved SA"; description "Prior SA prose"; };
};
}
}"#;
fn id(value: u128) -> Id {
Id::from_u128(value)
}
fn prose_json() -> Value {
json!({
"schema_version":3,"id":id(1),"alias":"review","name":"Review","description":"",
"elements": {
id(2).to_string(): {"id":id(2),"alias":"reviewed_activity","name":"Live OA","description":"","parent":null,"body":{"Oa":"Activity"}},
id(3).to_string(): {"id":id(3),"alias":"function","name":"Live SA","description":"","parent":null,"body":{"Sa":"Function"}}
},
"relations":{},"diagrams":{},"artifact_links":{},
"traces": {id(4).to_string():{
"id":id(4),"alias":"review_link","name":"Review link","description":"",
"oa":id(2),"sa":id(3),"kind":"Refinement",
"baseline":{"oa":{"name":"Approved OA","description":"First\nΩ rationale"},"sa":{"name":"Approved SA","description":"Prior SA prose"}}
}}
})
}
#[test]
fn frozen_source3_and_json3_have_complete_model_and_migration_parity() {
let source = language::parse_with_report(SOURCE3).unwrap();
let json = persistence::decode_with_report(JSON3).unwrap();
assert_eq!(source.project, json.project);
assert_eq!(source.migration, json.migration);
assert!(source.project.definitions.is_empty());
let report = source.migration.unwrap();
assert_eq!(
(report.from_schema, report.to_schema),
(3, crate::SCHEMA_VERSION)
);
// This tests direct legacy lowering. Canonical source8 reopening remains a
// separate, unchanged public integration gate.
}
#[test]
fn frozen_wire_still_rejects_new_unknown_and_duplicate_fields() {
let original: Value = serde_json::from_slice(JSON3).unwrap();
for definitions in [
json!({}),
Value::Null,
json!({id(90).to_string(): {"name":"new"}}),
] {
let mut bad = original.clone();
bad["definitions"] = definitions;
let error = persistence::decode(&serde_json::to_vec(&bad).unwrap())
.err()
.unwrap()
.to_string();
assert!(error.contains("unknown field `definitions`"), "{error}");
}
let mut bad = original;
bad["unrecognized"] = json!(true);
assert!(
persistence::decode(&serde_json::to_vec(&bad).unwrap())
.err()
.unwrap()
.to_string()
.contains("unknown field `unrecognized`")
);
let raw = std::str::from_utf8(JSON3).unwrap();
let duplicate = raw.replacen(
"\"schema_version\": 3",
"\"schema_version\": 3, \"schema_version\": 3",
1,
);
assert_ne!(duplicate, raw);
assert!(
persistence::decode(duplicate.as_bytes())
.err()
.unwrap()
.to_string()
.contains("duplicate")
);
}
#[test]
fn old_review_prose_survives_without_admitting_semantic_fields() {
let source = language::parse_with_report(PROSE).unwrap();
let json =
persistence::decode_with_report(&serde_json::to_vec(&prose_json()).unwrap()).unwrap();
assert_eq!(source.project, json.project);
assert_eq!(source.migration, json.migration);
let baseline = source.project.traces[&id(4)].baseline.as_ref().unwrap();
assert_eq!(baseline.oa.name, "Approved OA");
assert_eq!(baseline.oa.description, "First\nΩ rationale");
assert_eq!(baseline.sa.description, "Prior SA prose");
assert!(baseline.semantic.is_none());
for semantic in [Value::Null, json!({"facets":[]})] {
let mut bad = prose_json();
bad["traces"][id(4).to_string()]["baseline"]["semantic"] = semantic;
let error = persistence::decode(&serde_json::to_vec(&bad).unwrap())
.err()
.unwrap()
.to_string();
assert!(error.contains("semantic"), "{error}");
}
for field in ["semantic none;", "semantic { facets []; };"] {
let bad = PROSE.replace("baseline {", &format!("baseline {{ {field}"));
let errors = language::parse(&bad).err().unwrap();
assert!(
errors
.iter()
.any(|e| e.message.contains("require schema/source5")),
"{errors:?}"
);
}
}
#[test]
fn scratch_bridge_refuses_to_discard_current_only_data() {
let mut empty = Project::empty();
empty.schema_version = 3;
assert!(from_scratch(empty.clone(), Map::new()).is_ok());
let mut wrong_version = empty.clone();
wrong_version.schema_version = crate::SCHEMA_VERSION;
assert!(from_scratch(wrong_version, Map::new()).is_err());
let mut definition = empty;
definition.definitions.insert(
id(90),
crate::analysis::Definition {
id: id(90),
alias: "dimensionless".into(),
name: "Dimensionless".into(),
description: String::new(),
body: crate::analysis::DefinitionBody::QuantityKind(
crate::analysis::QuantityKindDefinition {
dimension: crate::analysis_numeric::Dimension::ZERO,
algebra: crate::analysis::QuantityAlgebra::Linear,
},
),
},
);
assert!(
from_scratch(definition, Map::new())
.err()
.unwrap()
.to_string()
.contains("definitions or elements")
);
let mut current = persistence::decode_with_report(&serde_json::to_vec(&prose_json()).unwrap())
.unwrap()
.project;
current.schema_version = 3;
assert!(
from_scratch(current.clone(), Map::new())
.err()
.unwrap()
.to_string()
.contains("definitions or elements")
);
current.elements.clear();
current
.traces
.get_mut(&id(4))
.unwrap()
.baseline
.as_mut()
.unwrap()
.semantic = Some(crate::semantic::SemanticBaseline { facets: Vec::new() });
assert!(
from_scratch(current, Map::new())
.err()
.unwrap()
.to_string()
.contains("semantic")
);
}
@@ -0,0 +1,386 @@
//! Prepared outline tests. Explicit compatibility tests additionally exercise
//! the existing parsers/analyze; the outline entry itself never calls them.
use super::*;
fn limits() -> OutlineLimits {
OutlineLimits {
max_source_bytes: 1024 * 1024,
max_tokens: 100_000,
max_declarations: 1_000,
}
}
fn declaration<'a>(result: &'a Outline<'_>, alias: &str) -> &'a OutlineDeclaration {
result
.declarations
.iter()
.find(|d| d.declaration.alias == alias)
.unwrap()
}
fn text(source: &str, span: Span) -> &str {
&source[span.start..span.end]
}
#[test]
fn outline_roles_preserve_unicode_crlf_and_exact_escaped_uuid_literal() {
let source = "// 検査 🚒\r\noa_actor responder \"救助\" id \"\\u00300000000-0000-0000-0000-000000000001\" {}\r\nsource @ // 使用\r\n responder;";
let before = source.as_bytes().to_vec();
let result = outline(source, limits()).unwrap();
assert_eq!(result.source.as_bytes(), before);
assert_eq!(result.source.as_ptr(), source.as_ptr());
let d = declaration(&result, "responder");
assert_eq!(text(source, d.kind_span), "oa_actor");
assert_eq!(text(source, d.alias_span), "responder");
assert_eq!(
text(source, d.id_literal_span.unwrap()),
"\"\\u00300000000-0000-0000-0000-000000000001\""
);
assert_eq!(d.declaration.id, Some(Id::from_u128(1)));
assert_eq!(d.declaration.span.start, source.find("oa_actor").unwrap());
assert_eq!(d.declaration.span.end, source.find("{}\r\n").unwrap() + 2);
let mut end = 0;
for token in &result.tokens {
assert_eq!(token.span.start, end);
assert!(token.span.start < token.span.end);
assert!(source.is_char_boundary(token.span.start));
assert!(source.is_char_boundary(token.span.end));
end = token.span.end;
}
assert_eq!(end, source.len());
assert!(!result.has_lexical_errors);
assert!(!result.has_unmatched_braces);
}
#[test]
fn closed_id_at_eof_is_inside_the_declaration_extent() {
for source in [
"oa_actor a \"Actor\" id \"00000000-0000-0000-0000-000000000001\"",
"node placed id \"00000000-0000-0000-0000-000000000001\"",
] {
let result = outline(source, limits()).unwrap();
let d = &result.declarations[0];
let literal = d.id_literal_span.unwrap();
assert_eq!(d.declaration.id, Some(Id::from_u128(1)));
assert_eq!(d.declaration.span.end, source.len());
assert!(d.declaration.span.start <= literal.start);
assert!(literal.end <= d.declaration.span.end);
}
for source in ["oa_actor a \"Actor\"", "oa_actor a \"Actor\" id"] {
let result = outline(source, limits()).unwrap();
let d = &result.declarations[0];
assert_eq!(d.declaration.span.end, source.len());
assert_eq!(d.declaration.id, None);
assert_eq!(d.id_literal_span, None);
}
}
#[test]
fn missing_bad_and_historical_ids_never_allocate_authored_identity() {
let source = "oa_actor missing \"No ID\" {}\noa_actor bad \"Bad ID\" id \"not-a-uuid\" {}\noa_actor historical \"00000000-0000-0000-0000-000000000001\" { history uuid \"00000000-0000-0000-0000-000000000001\"; }";
let first = outline(source, limits()).unwrap();
let second = outline(source, limits()).unwrap();
assert_eq!(first, second);
assert!(
first
.declarations
.iter()
.all(|d| d.declaration.id.is_none())
);
assert_eq!(declaration(&first, "missing").id_literal_span, None);
assert_eq!(declaration(&first, "historical").id_literal_span, None);
assert_eq!(
text(source, declaration(&first, "bad").id_literal_span.unwrap()),
"\"not-a-uuid\""
);
// Balanced tokens are not a successful syntax/semantic validation claim.
assert!(!first.has_lexical_errors);
assert!(!first.has_unmatched_braces);
}
#[test]
fn invalid_full_scan_keeps_closed_children_and_reports_lexical_body_facts() {
let source = "oa_entity outer \"Outer\" {\n oa_actor ready \"Ready\" { description \"} stays text\"; }\n oa_actor pending \"Pending\" { description \"unfinished 🌊";
let result = outline(source, limits()).unwrap();
assert_eq!(result.source, source);
assert_eq!(result.tokens.last().unwrap().span.end, source.len());
assert!(result.has_lexical_errors);
assert!(result.has_unmatched_braces);
let ready = declaration(&result, "ready");
assert_eq!(
text(source, ready.declaration.span),
"oa_actor ready \"Ready\" { description \"} stays text\"; }"
);
assert_eq!(
declaration(&result, "outer").declaration.span.end,
source.len()
);
assert_eq!(
declaration(&result, "pending").declaration.span.end,
source.len()
);
let unmatched_close = outline("}\n// brace in comment {\n", limits()).unwrap();
assert!(unmatched_close.has_unmatched_braces);
assert!(!unmatched_close.has_lexical_errors);
}
#[test]
fn missing_body_syntax_is_unchecked_even_when_known_error_flags_are_clear() {
let source = "oa_actor a \"A\" id \"00000000-0000-0000-0000-000000000001\"";
let result = outline(source, limits()).unwrap();
assert_eq!(result.declarations.len(), 1);
assert!(!result.has_lexical_errors);
assert!(!result.has_unmatched_braces);
// There is deliberately no `valid` flag: the known-complete fact is scanning
// these bytes, not the missing body becoming legal or the draft being applied.
assert_eq!(result.source, source);
}
#[test]
fn keyword_alias_and_collisions_keep_every_source_ordered_header() {
let source = "project root_model \"Model\" {\n oa_actor oa_activity \"First\" id \"00000000-0000-0000-0000-000000000001\" {}\n oa_actor oa_activity \"Second\" id \"00000000-0000-0000-0000-000000000001\" {}\n diagram view_one \"View\" {}\n node placement { diagram @view_one; }\n}";
let result = outline(source, limits()).unwrap();
assert_eq!(result.declarations.len(), 5);
let duplicates: Vec<_> = result
.declarations
.iter()
.filter(|d| d.declaration.alias == "oa_activity")
.collect();
assert_eq!(duplicates.len(), 2);
assert!(duplicates[0].alias_span.start < duplicates[1].alias_span.start);
assert_eq!(duplicates[0].declaration.id, duplicates[1].declaration.id);
assert!(
result
.tokens
.iter()
.any(|t| t.span == duplicates[0].alias_span && t.class == TokenClass::Keyword)
);
assert_eq!(
text(source, declaration(&result, "placement").kind_span),
"node"
);
assert_eq!(declaration(&result, "placement").declaration.id, None);
}
#[test]
fn bounds_refuse_before_returning_a_partial_outline() {
let source = "oa_actor a \"A\" {}\noa_actor b \"B\" {}";
let admitted = outline(source, limits()).unwrap();
let exact = OutlineLimits {
max_source_bytes: source.len(),
max_tokens: admitted.tokens.len(),
max_declarations: 2,
};
assert_eq!(outline(source, exact).unwrap(), admitted);
for resource in [
OutlineResource::SourceBytes,
OutlineResource::Tokens,
OutlineResource::Declarations,
] {
let mut lower = exact;
match resource {
OutlineResource::SourceBytes => lower.max_source_bytes -= 1,
OutlineResource::Tokens => lower.max_tokens -= 1,
OutlineResource::Declarations => lower.max_declarations -= 1,
}
let error = outline(source, lower).unwrap_err();
assert_eq!(error.resource, resource);
assert!(error.observed_at_least > error.limit);
assert!(source.is_char_boundary(error.offset));
if resource == OutlineResource::Declarations {
assert_eq!(error.offset, source.find("oa_actor b").unwrap());
}
}
let empty = outline(
"",
OutlineLimits {
max_source_bytes: 0,
max_tokens: 0,
max_declarations: 0,
},
)
.unwrap();
assert!(empty.tokens.is_empty() && empty.declarations.is_empty());
let unicode = "// 🌊\r\noa_actor a \"A\" {}";
let error = outline(
unicode,
OutlineLimits {
max_source_bytes: 4,
..limits()
},
)
.unwrap_err();
assert_eq!(error.resource, OutlineResource::SourceBytes);
assert_eq!(error.offset, 0);
let error = outline(
unicode,
OutlineLimits {
max_tokens: 1,
..limits()
},
)
.unwrap_err();
assert_eq!(error.resource, OutlineResource::Tokens);
assert_eq!(error.offset, unicode.find('\n').unwrap());
}
#[test]
fn outline_ranges_follow_current_bytes_without_identity_stabilization() {
let old = "oa_actor a \"A\" {}\nsource @a;";
let current = format!("// 航海 🚒\r\n{old}");
let old_outline = outline(old, limits()).unwrap();
let current_outline = outline(&current, limits()).unwrap();
let shift = current.len() - old.len();
let old_role = &old_outline.declarations[0];
let current_role = &current_outline.declarations[0];
assert_eq!(
current_role.alias_span.start,
old_role.alias_span.start + shift
);
assert_eq!(
current_role.kind_span.start,
old_role.kind_span.start + shift
);
assert_eq!(
current_role.declaration.span.end,
old_role.declaration.span.end + shift
);
assert_eq!(current_outline.source, current);
assert_eq!(old_outline.source, old);
assert_eq!(current_role.declaration.id, None);
assert!(!current_outline.source.contains(" id "));
// Workspace revision/stale-handle rejection belongs to the query/adapter,
// not a claim that an old owned span can be applied to any other string.
}
#[test]
fn deeply_nested_bounds_use_the_same_iterative_walk_and_declaration_budget() {
let mut source = String::new();
for index in 0..600 {
source.push_str(&format!("oa_activity work_{index} \"Work\" {{\n"));
}
source.push_str(&"}\n".repeat(600));
let result = outline(&source, limits()).unwrap();
assert_eq!(result.declarations.len(), 600);
assert!(!result.has_unmatched_braces);
assert_eq!(
result.declarations[0].declaration.span.end,
source.len() - 1
);
let error = outline(
&source,
OutlineLimits {
max_declarations: 599,
..limits()
},
)
.unwrap_err();
assert_eq!(error.resource, OutlineResource::Declarations);
assert_eq!(error.observed_at_least, 600);
assert_eq!(error.offset, source.find("oa_activity work_599").unwrap());
}
#[test]
fn noncanonical_uuid_spellings_have_no_outline_or_analysis_identity() {
for raw in [
"00000000000000000000000000000001",
"{00000000-0000-0000-0000-000000000001}",
"urn:uuid:00000000-0000-0000-0000-000000000001",
] {
assert!(
Id::parse_str(raw).is_ok(),
"The UUID crate alone is too permissive"
);
assert!(!has_uuid_header_shape(raw));
let literal = format!("\"{raw}\"");
let modern = format!("arcadia 7; project model \"Model\" id {literal} {{}}");
let before = modern.clone();
let result = outline(&modern, limits()).unwrap();
let d = declaration(&result, "model");
assert_eq!(d.declaration.id, None);
assert_eq!(text(&modern, d.id_literal_span.unwrap()), literal);
assert_eq!(modern, before);
let errors = parse(&modern).unwrap_err();
assert!(
errors
.iter()
.any(|error| error.message.contains("8-4-4-4-12")
&& error.span.start == modern.find(&literal).unwrap())
);
// Intentional compatibility correction: old analyze yielded Some(Id(1))
// here despite lint rejecting the header. It now shares outline's None.
let analysis = analyze(&modern);
assert_eq!(
analysis
.declarations
.iter()
.find(|d| d.alias == "model")
.unwrap()
.id,
None
);
assert!(
analysis
.diagnostics
.iter()
.any(|error| error.message.contains("8-4-4-4-12"))
);
let legacy_source = format!("arcadia 1; project \"Legacy\" id {literal} {{}}");
let legacy_errors = legacy::parse(&legacy_source).unwrap_err();
assert!(
legacy_errors
.iter()
.any(|error| error.message.contains("8-4-4-4-12"))
);
}
}
#[test]
fn uppercase_and_json_escaped_header_ids_survive_both_parsers_and_outline() {
let expected = Id::from_u128(0xabcdef);
let canonical = expected.to_string();
let uppercase = canonical.to_uppercase();
assert_ne!(uppercase, canonical);
for raw in [uppercase.clone(), format!("\\u0030{}", &uppercase[1..])] {
let literal = format!("\"{raw}\"");
let decoded: String = serde_json::from_str(&literal).unwrap();
assert!(has_uuid_header_shape(&decoded));
let modern = format!("arcadia 7; project model \"Model\" id {literal} {{}}");
let result = outline(&modern, limits()).unwrap();
let d = declaration(&result, "model");
assert_eq!(d.declaration.id, Some(expected));
assert_eq!(text(&modern, d.id_literal_span.unwrap()), literal);
assert_eq!(result.source.as_bytes(), modern.as_bytes());
assert_eq!(parse(&modern).unwrap().id, expected);
let analysis = analyze(&modern);
assert_eq!(analysis.declarations[0].id, Some(expected));
assert!(
!analysis
.diagnostics
.iter()
.any(|error| error.severity == crate::validation::Severity::Error)
);
let legacy_source = format!("arcadia 1; project \"Legacy\" id {literal} {{}}");
assert_eq!(legacy::parse(&legacy_source).unwrap().id, expected);
}
}
#[test]
fn header_shape_still_requires_valid_hexadecimal_uuid_data() {
let raw = "00000000-0000-0000-0000-00000000000Z";
assert!(has_uuid_header_shape(raw));
assert!(Id::parse_str(raw).is_err());
let literal = format!("\"{raw}\"");
let source = format!("arcadia 7; project model \"Model\" id {literal} {{}}");
let result = outline(&source, limits()).unwrap();
let d = declaration(&result, "model");
assert_eq!(d.declaration.id, None);
assert_eq!(text(&source, d.id_literal_span.unwrap()), literal);
assert!(
parse(&source)
.unwrap_err()
.iter()
.any(|error| error.message == "Invalid UUID identity.")
);
assert_eq!(analyze(&source).declarations[0].id, None);
}
+1 -1
View File
@@ -1,5 +1,4 @@
//! Framework-independent OA/SA model, transactional commands and editing services.
pub mod analysis;
pub mod analysis_numeric;
pub mod data;
pub mod language;
@@ -12,6 +11,7 @@ pub mod references;
pub mod routing;
pub mod semantic;
pub mod session;
mod source_defaults;
pub mod source_files;
pub mod source_project;
pub mod topology_context;
-1
View File
@@ -75,7 +75,6 @@ impl Project4 {
alias: self.alias,
name: self.name,
description: self.description,
definitions: BTreeMap::new(),
elements: self.elements,
relations: self.relations,
diagrams: self.diagrams,
-62
View File
@@ -1,62 +0,0 @@
//! Frozen schema5/6/7 top-level envelope. No schema8 definition catalog/defaults.
//! Unchanged nested records are shared only after the closed old-kind check.
use crate::persistence::FileError;
use crate::*;
use serde::Deserialize;
use std::collections::BTreeMap;
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct Project7 {
pub schema_version: u32,
pub id: Id,
pub alias: String,
pub name: String,
pub description: String,
#[serde(deserialize_with = "crate::unique_id_map")]
pub elements: BTreeMap<Id, Element>,
#[serde(deserialize_with = "crate::unique_id_map")]
pub relations: BTreeMap<Id, Relation>,
#[serde(deserialize_with = "crate::unique_id_map")]
pub diagrams: BTreeMap<Id, Diagram>,
#[serde(deserialize_with = "crate::unique_id_map")]
pub artifact_links: BTreeMap<Id, ArtifactLink>,
#[serde(deserialize_with = "crate::unique_id_map")]
pub traces: BTreeMap<Id, Trace>,
}
impl Project7 {
pub(crate) fn into_current(self) -> Result<Project, FileError> {
if !(5..=7).contains(&self.schema_version) {
return Err(FileError::Invalid(
"Expected frozen schema5, 6 or 7.".into(),
));
}
reject_elements(self.elements.values())?;
Ok(Project {
schema_version: self.schema_version,
id: self.id,
alias: self.alias,
name: self.name,
description: self.description,
definitions: BTreeMap::new(),
elements: self.elements,
relations: self.relations,
diagrams: self.diagrams,
artifact_links: self.artifact_links,
traces: self.traces,
})
}
}
pub(crate) fn reject_elements<'a>(
elements: impl Iterator<Item = &'a Element>,
) -> Result<(), FileError> {
for element in elements {
if element.kind().is_analysis() {
return Err(FileError::Invalid(format!(
"{:?} requires schema/source 8.",
element.kind()
)));
}
}
Ok(())
}
+37 -43
View File
@@ -6,7 +6,7 @@ use std::{
use uuid::Uuid;
pub type Id = Uuid;
pub const SCHEMA_VERSION: u32 = 8;
pub const SCHEMA_VERSION: u32 = 7;
pub const MAX_COORDINATE: f32 = 1_000_000.;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
@@ -64,12 +64,9 @@ pub enum Kind {
Terminate,
Event,
Constraint,
EngineeringProperty,
AnalysisCase,
AnalysisCheck,
}
impl Kind {
pub const ALL: [Self; 42] = [
pub const ALL: [Self; 39] = [
Self::Entity,
Self::Actor,
Self::Role,
@@ -109,9 +106,6 @@ impl Kind {
Self::Terminate,
Self::Event,
Self::Constraint,
Self::EngineeringProperty,
Self::AnalysisCase,
Self::AnalysisCheck,
];
pub fn label(self) -> &'static str {
match self {
@@ -154,9 +148,6 @@ impl Kind {
Self::Terminate => "Terminate",
Self::Event => "Event",
Self::Constraint => "Constraint",
Self::EngineeringProperty => "Engineering property",
Self::AnalysisCase => "Analysis case",
Self::AnalysisCheck => "Analysis check",
}
}
pub fn is_performer(self) -> bool {
@@ -165,9 +156,6 @@ impl Kind {
Self::Entity | Self::Actor | Self::System | Self::SystemActor
)
}
pub fn is_analysis(self) -> bool {
matches!(self, Self::EngineeringProperty | Self::AnalysisCase | Self::AnalysisCheck)
}
pub fn is_work(self) -> bool {
matches!(self, Self::Activity | Self::Function)
}
@@ -664,23 +652,6 @@ pub enum SharedBody {
expression: String,
constrained: Vec<Id>,
},
EngineeringProperty {
subject: crate::analysis::AnalysisSubject,
value_type: crate::analysis::AnalysisType,
display_unit: Option<crate::analysis::DefinitionRef>,
base: crate::analysis::Binding,
},
AnalysisCase {
subject: Option<crate::analysis::AnalysisSubject>,
base: crate::analysis::BaselineBase,
#[serde(deserialize_with = "unique_id_map")]
overrides: BTreeMap<Id, crate::analysis::Binding>,
},
AnalysisCheck {
subject: Option<crate::analysis::AnalysisSubject>,
documented_constraint: Option<Id>,
predicate: crate::analysis::Binding,
},
}
impl Element {
pub fn layer(&self) -> Layer {
@@ -781,8 +752,7 @@ impl ElementBody {
}
}
pub fn new(kind: Kind, layer: Layer) -> Option<Self> {
// Analysis creation requires explicitly authored types and bindings.
if !kind.supports(layer) || kind.is_analysis() {
if !kind.supports(layer) {
return None;
}
Some(match kind {
@@ -894,9 +864,6 @@ impl SharedBody {
Self::Region => Kind::Region,
Self::State { kind, .. } => kind.kind(),
Self::Constraint { .. } => Kind::Constraint,
Self::EngineeringProperty { .. } => Kind::EngineeringProperty,
Self::AnalysisCase { .. } => Kind::AnalysisCase,
Self::AnalysisCheck { .. } => Kind::AnalysisCheck,
}
}
}
@@ -1535,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 {
@@ -1658,8 +1659,6 @@ pub struct Project {
pub name: String,
pub description: String,
#[serde(deserialize_with = "unique_id_map")]
pub definitions: BTreeMap<Id, crate::analysis::Definition>,
#[serde(deserialize_with = "unique_id_map")]
pub elements: BTreeMap<Id, Element>,
#[serde(deserialize_with = "unique_id_map")]
pub relations: BTreeMap<Id, Relation>,
@@ -1752,7 +1751,6 @@ impl Project {
alias: "mission".into(),
name: "Untitled mission".into(),
description: String::new(),
definitions: BTreeMap::new(),
elements: BTreeMap::new(),
relations: BTreeMap::new(),
diagrams: BTreeMap::new(),
@@ -1762,7 +1760,6 @@ impl Project {
}
pub fn identities(&self) -> Vec<(Id, &str)> {
let mut out = vec![(self.id, self.alias.as_str())];
out.extend(self.definitions.values().map(|v| (v.id, v.alias.as_str())));
out.extend(self.elements.values().map(|v| (v.id, v.alias.as_str())));
out.extend(self.relations.values().map(|v| (v.id, v.alias.as_str())));
out.extend(self.diagrams.values().map(|v| (v.id, v.alias.as_str())));
@@ -1820,9 +1817,6 @@ impl Project {
if id == self.id {
return &self.name;
}
if let Some(v) = self.definitions.get(&id) {
return &v.name;
}
if let Some(v) = self.elements.get(&id) {
return &v.name;
}
+97 -209
View File
@@ -2,10 +2,6 @@ use crate::*;
use serde::{Deserialize, Serialize};
use std::{io::Write, path::Path};
use thiserror::Error;
mod json_codec;
#[cfg(test)]
#[path = "persistence/source_save_tests.rs"]
mod source_save_tests;
#[path = "migration_v2/mod.rs"]
pub(crate) mod v2;
pub(crate) use v2::adapter::migrate as migrate_v2_limited;
@@ -13,8 +9,6 @@ pub(crate) use v2::adapter::migrate as migrate_v2_limited;
pub(crate) mod v3;
#[path = "migration_v4.rs"]
pub(crate) mod v4;
#[path = "migration_v7.rs"]
pub(crate) mod v7;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
@@ -136,19 +130,6 @@ pub fn decode_limited(
pub fn decode_with_report_limited(
bytes: &[u8],
limits: crate::language::ParseLimits,
) -> Result<DecodeResult, FileError> {
decode_with_report_profile(
bytes,
limits,
json_codec::Profile::with_byte_limit(limits.max_source_bytes),
json_codec::DEPTH_128_VALIDATED,
)
}
fn decode_with_report_profile(
bytes: &[u8],
limits: crate::language::ParseLimits,
profile: json_codec::Profile,
depth_128: bool,
) -> Result<DecodeResult, FileError> {
if bytes.len() > limits.max_source_bytes {
return Err(FileError::Limit(format!(
@@ -157,43 +138,65 @@ fn decode_with_report_profile(
)));
}
let decoded = if bytes.iter().find(|b| !b.is_ascii_whitespace()) == Some(&b'{') {
json_codec::preflight(bytes, profile)?;
let version = json_codec::version(bytes)?;
if version.is_some_and(|version| version <= 6) {
let header = json_codec::legacy(bytes)?;
let result = check_legacy_header(&header, version);
json_codec::discard_value(header);
result?;
let header: serde_json::Value = serde_json::from_slice(bytes)?;
if header
.get("schema_version")
.and_then(serde_json::Value::as_u64)
.is_some_and(|v| v <= 6)
{
reject_source7_fields(&header).map_err(FileError::Invalid)?;
}
match version {
if header
.get("schema_version")
.and_then(serde_json::Value::as_u64)
.is_some_and(|v| v <= 5)
&& let Some(diagrams) = header
.get("diagrams")
.and_then(serde_json::Value::as_object)
{
for diagram in diagrams.values().filter_map(serde_json::Value::as_object) {
for key in diagram.keys() {
if key == "view_options" {
return Err(FileError::Invalid("Diagram.view_options requires schema/source 6, including an explicitly supplied default value.".into()));
}
if !matches!(
key.as_str(),
"id" | "alias"
| "name"
| "description"
| "layer"
| "projection"
| "nodes"
| "routes"
) {
return Err(FileError::Invalid(format!(
"Unknown diagram field '{key}' in the frozen pre6 contract."
)));
}
}
}
}
match header
.get("schema_version")
.and_then(serde_json::Value::as_u64)
{
Some(1) => {
let old = json_codec::legacy(bytes)?;
let old = serde_json::from_slice(bytes)?;
check_v1_limits(&old, limits).map_err(FileError::Limit)?;
migrate_v1_with_report_limited(old, limits)?
}
Some(2) => migrate_v2_limited(json_codec::legacy(bytes)?, limits)?,
Some(3) => v3::migrate(json_codec::legacy(bytes)?, limits)?,
Some(4) => v4::migrate(json_codec::legacy(bytes)?, limits)?,
Some(5) => migrate_v5(
json_codec::legacy::<v7::Project7>(bytes)?.into_current()?,
limits,
)?,
Some(6) => migrate_v6(
json_codec::legacy::<v7::Project7>(bytes)?.into_current()?,
limits,
)?,
Some(7) => migrate_v7(
json_codec::legacy::<v7::Project7>(bytes)?.into_current()?,
limits,
)?,
Some(8) => DecodeResult {
project: json_codec::current(bytes, profile, depth_128)?,
Some(2) => migrate_v2_limited(serde_json::from_slice(bytes)?, limits)?,
Some(3) => v3::migrate(serde_json::from_slice(bytes)?, limits)?,
Some(4) => v4::migrate(serde_json::from_slice(bytes)?, limits)?,
Some(5) => migrate_v5(serde_json::from_slice(bytes)?, limits)?,
Some(6) => migrate_v6(serde_json::from_slice(bytes)?, limits)?,
Some(7) => DecodeResult {
project: serde_json::from_slice(bytes)?,
migration: None,
},
_ => {
return Err(FileError::Invalid(
"Unsupported or missing schema_version; expected 1, 2, 3, 4, 5, 6, 7 or 8."
.into(),
"Unsupported or missing schema_version; expected 1, 2, 3, 4, 5, 6 or 7.".into(),
));
}
}
@@ -222,38 +225,6 @@ fn decode_with_report_profile(
Ok(decoded)
}
fn check_legacy_header(header: &serde_json::Value, version: Option<u64>) -> Result<(), FileError> {
reject_source7_fields(header).map_err(FileError::Invalid)?;
if version.is_some_and(|version| version <= 5)
&& let Some(diagrams) = header
.get("diagrams")
.and_then(serde_json::Value::as_object)
{
for diagram in diagrams.values().filter_map(serde_json::Value::as_object) {
for key in diagram.keys() {
if key == "view_options" {
return Err(FileError::Invalid("Diagram.view_options requires schema/source 6, including an explicitly supplied default value.".into()));
}
if !matches!(
key.as_str(),
"id" | "alias"
| "name"
| "description"
| "layer"
| "projection"
| "nodes"
| "routes"
) {
return Err(FileError::Invalid(format!(
"Unknown diagram field '{key}' in the frozen pre6 contract."
)));
}
}
}
}
Ok(())
}
/// Versions 4 and 5 share unchanged typed records, but their constructor set
/// remains frozen. Apply this before normalizing the advertised version.
pub(crate) fn pre6_relation_kind(kind: RelationKind) -> bool {
@@ -377,7 +348,6 @@ pub(crate) fn migrate_v5(
if project.schema_version != 5 {
return Err(FileError::Invalid("Expected schema_version 5.".into()));
}
reject_pre8_project(&project)?;
check_pre6_relations(project.relations.values())?;
check_pre6_elements(project.elements.values())?;
for baseline in project
@@ -398,49 +368,48 @@ pub(crate) fn migrate_v5(
/// Reject explicit new fields before serde defaults can erase their presence.
/// This is also applied to source's resolved structured values.
pub(crate) fn reject_source7_fields(value: &serde_json::Value) -> Result<(), String> {
let mut pending = vec![value];
while let Some(value) = pending.pop() {
match value {
serde_json::Value::Object(fields) => {
if fields.contains_key("ContextualLink") {
return Err("ContextualLink requires schema/source 7.".into());
}
if fields.get("Relation").and_then(serde_json::Value::as_str)
== Some("ContextualLink")
{
return Err("ContextualLink view selectors require schema/source 7.".into());
}
if fields.keys().any(|key| {
matches!(
key.as_str(),
"Operation"
| "ContextOwner"
| "ContextSource"
| "ContextTarget"
| "ContextPurpose"
)
}) {
return Err(
"Operation/contextual-link semantic facets require schema/source 7.".into(),
);
}
if fields
.get("Exchange")
.and_then(serde_json::Value::as_object)
.is_some_and(|body| body.contains_key("operation"))
{
return Err(
"Exchange.operation requires schema/source 7, including explicit null."
.into(),
);
}
pending.extend(fields.values().rev());
match value {
serde_json::Value::Object(fields) => {
if fields.contains_key("ContextualLink") {
return Err("ContextualLink requires schema/source 7.".into());
}
serde_json::Value::Array(values) => {
pending.extend(values.iter().rev());
if fields.get("Relation").and_then(serde_json::Value::as_str) == Some("ContextualLink")
{
return Err("ContextualLink view selectors require schema/source 7.".into());
}
if fields.keys().any(|key| {
matches!(
key.as_str(),
"Operation"
| "ContextOwner"
| "ContextSource"
| "ContextTarget"
| "ContextPurpose"
)
}) {
return Err(
"Operation/contextual-link semantic facets require schema/source 7.".into(),
);
}
if fields
.get("Exchange")
.and_then(serde_json::Value::as_object)
.is_some_and(|body| body.contains_key("operation"))
{
return Err(
"Exchange.operation requires schema/source 7, including explicit null.".into(),
);
}
for nested in fields.values() {
reject_source7_fields(nested)?;
}
_ => {}
}
serde_json::Value::Array(values) => {
for nested in values {
reject_source7_fields(nested)?;
}
}
_ => {}
}
Ok(())
}
@@ -451,7 +420,6 @@ pub(crate) fn migrate_v6(
if project.schema_version != 6 {
return Err(FileError::Invalid("Expected schema_version 6.".into()));
}
reject_pre8_project(&project)?;
reject_source7_fields(&serde_json::to_value(&project)?).map_err(FileError::Invalid)?;
limits.check_model(&project).map_err(FileError::Limit)?;
project.schema_version = SCHEMA_VERSION;
@@ -461,30 +429,6 @@ pub(crate) fn migrate_v6(
})
}
pub(crate) fn migrate_v7(
mut project: Project,
limits: crate::language::ParseLimits,
) -> Result<DecodeResult, FileError> {
if project.schema_version != 7 {
return Err(FileError::Invalid("Expected schema_version 7.".into()));
}
reject_pre8_project(&project)?;
limits.check_model(&project).map_err(FileError::Limit)?;
project.schema_version = SCHEMA_VERSION;
Ok(DecodeResult {
project,
migration: Some(MigrationReport::data_upgrade(7)),
})
}
fn reject_pre8_project(project: &Project) -> Result<(), FileError> {
if !project.definitions.is_empty() {
return Err(FileError::Invalid(
"Definitions require schema/source 8.".into(),
));
}
v7::reject_elements(project.elements.values())
}
pub(crate) fn migrate_v1_with_report_limited(
old: v1::Project,
limits: crate::language::ParseLimits,
@@ -902,9 +846,6 @@ pub(crate) mod v1 {
}
fn validate_project(p: &Project) -> Result<(), FileError> {
json_codec::document_stack(|| validate_project_inner(p))
}
fn validate_project_inner(p: &Project) -> Result<(), FileError> {
if let Some(i) = validation::validate(p)
.into_iter()
.find(|i| i.severity == validation::Severity::Error)
@@ -920,10 +861,7 @@ pub fn load(path: &Path) -> Result<Project, FileError> {
load_with_report(path).map(|decoded| decoded.project)
}
pub fn load_with_report(path: &Path) -> Result<DecodeResult, FileError> {
decode_with_report(&json_codec::read_bounded(
path,
json_codec::MAX_DOCUMENT_BYTES,
)?)
decode_with_report(&std::fs::read(path)?)
}
/// Serialize to a sibling temporary file, flush, then atomically replace the destination.
pub fn save(path: &Path, p: &Project) -> Result<(), FileError> {
@@ -936,69 +874,19 @@ pub fn save_checked(
path: &Path,
p: &Project,
expected: Option<&[u8]>,
) -> Result<Vec<u8>, FileError> {
save_checked_profile(
path,
p,
expected,
json_codec::Profile::DOCUMENT,
json_codec::DEPTH_128_VALIDATED,
)
}
fn save_checked_profile(
path: &Path,
p: &Project,
expected: Option<&[u8]>,
profile: json_codec::Profile,
depth_128: bool,
) -> Result<Vec<u8>, FileError> {
save_checked_profile_with_source(path, p, expected, profile, depth_128, |project| {
crate::language::format(project).into_bytes()
})
}
// Keep the source writer replaceable only at this private seam so regressions
// can prove that malformed or lossy output never reaches filesystem staging.
fn save_checked_profile_with_source(
path: &Path,
p: &Project,
expected: Option<&[u8]>,
profile: json_codec::Profile,
depth_128: bool,
source: impl FnOnce(&Project) -> Vec<u8>,
) -> Result<Vec<u8>, FileError> {
validate_project(p)?;
let is_source = path
let mut bytes = if path
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("arcadia"));
let mut bytes = if is_source {
source(p)
.is_some_and(|ext| ext.eq_ignore_ascii_case("arcadia"))
{
crate::language::format(p).into_bytes()
} else {
json_codec::encode(p, profile, depth_128)?
serde_json::to_vec_pretty(p)?
};
if !bytes.ends_with(b"\n") {
bytes.push(b'\n');
}
if is_source {
// Check exactly the final bytes, including their newline, through the
// public reader before creating a temp file or replacing a destination.
let decoded = decode_with_report_limited(
&bytes,
crate::language::ParseLimits {
max_source_bytes: profile.bytes,
..crate::language::ParseLimits::UNLIMITED
},
)?;
if decoded.project != *p {
return Err(FileError::Invalid(
"Generated source does not reproduce the complete requested project.".into(),
));
}
if decoded.migration.is_some() {
return Err(FileError::Invalid(
"Generated source unexpectedly requires migration.".into(),
));
}
}
let parent = path
.parent()
.filter(|p| !p.as_os_str().is_empty())
@@ -1007,8 +895,8 @@ fn save_checked_profile_with_source(
temp.write_all(&bytes)?;
temp.as_file().sync_all()?;
if let Some(expected) = expected {
match json_codec::matches_file(path, expected) {
Ok(true) => {}
match std::fs::read(path) {
Ok(current) if current == expected => {}
Ok(_) => return Err(FileError::Conflict),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Err(FileError::Conflict),
Err(e) => return Err(FileError::Io(e)),
@@ -1,427 +0,0 @@
//! Private persistence JSON boundary. Scan original bytes before Serde allocates;
//! the scanner is a resource meter, never a second accepting JSON grammar.
use super::FileError;
use crate::{
Project,
analysis::expression_serde::{self, Scope},
};
use serde::{
Deserialize,
de::{IgnoredAny, MapAccess, Visitor},
};
use std::{
fmt,
io::{self, Read, Write},
};
pub(super) const MAX_DOCUMENT_BYTES: usize = 64 * 1024 * 1024;
const MAX_CONTAINERS: usize = 512;
// This switch stays false until Windows/Linux debug/release codec + ordinary
// lifecycle gates pass. Tests exercise the candidate through the same private
// entry point with depth_128=true. It is not a user-controlled parser option.
pub(super) const DEPTH_128_VALIDATED: bool = false;
/// Allocation cardinalities, independent of graph admission and numeric work.
/// The default deliberately admits the existing CLI's 100000 graph objects,
/// 250000 records and 500000 ordered references within its 64 MiB envelope.
/// Strings have no new 1 MiB restriction: any span fitting that envelope fits.
/// These are explicit slot/text bounds, not an assertion of process RSS.
#[derive(Clone, Copy, Debug)]
pub(super) struct Profile {
pub bytes: usize,
pub tokens: usize,
pub entries: usize,
pub container_entries: usize,
pub string_bytes: usize,
pub total_string_bytes: usize,
pub expression_nodes: usize,
}
impl Profile {
pub const DOCUMENT: Self = Self {
bytes: MAX_DOCUMENT_BYTES,
tokens: 16 * 1024 * 1024,
entries: 8 * 1024 * 1024,
container_entries: 500_000,
string_bytes: MAX_DOCUMENT_BYTES,
total_string_bytes: MAX_DOCUMENT_BYTES,
expression_nodes: expression_serde::MAX_JSON_EXPRESSION_NODES,
};
pub fn with_byte_limit(maximum: usize) -> Self {
Self {
bytes: maximum.min(MAX_DOCUMENT_BYTES),
..Self::DOCUMENT
}
}
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub(super) struct Usage {
pub tokens: usize,
pub entries: usize,
pub string_bytes: usize,
pub deepest_container: usize,
}
#[derive(Clone, Copy)]
struct Container {
delimiter: u8,
entries: usize,
first: bool,
}
const EMPTY: Container = Container {
delimiter: 0,
entries: 0,
first: true,
};
fn limit(message: &str) -> FileError {
FileError::Limit(message.into())
}
fn charge(used: &mut usize, amount: usize, maximum: usize, message: &str) -> Result<(), FileError> {
if amount > maximum.saturating_sub(*used) {
return Err(limit(message));
}
*used += amount;
Ok(())
}
pub(super) fn preflight(bytes: &[u8], profile: Profile) -> Result<Usage, FileError> {
if bytes.len() > profile.bytes {
return Err(limit("JSON input byte limit exceeded."));
}
// Fixed storage: no Vec/Value/String and no recursive calls during preflight.
let mut containers = [EMPTY; MAX_CONTAINERS];
let mut depth = 0usize;
let mut usage = Usage::default();
let mut cursor = 0usize;
while cursor < bytes.len() {
let byte = bytes[cursor];
if byte.is_ascii_whitespace() {
cursor += 1;
continue;
}
charge(
&mut usage.tokens,
1,
profile.tokens,
"JSON token limit exceeded.",
)?;
if !matches!(byte, b',' | b':' | b'}' | b']') && depth > 0 {
let frame = &mut containers[depth - 1];
if frame.first {
charge(
&mut frame.entries,
1,
profile.container_entries,
"JSON container entry limit exceeded.",
)?;
charge(
&mut usage.entries,
1,
profile.entries,
"JSON aggregate entry limit exceeded.",
)?;
frame.first = false;
}
}
match byte {
b'{' | b'[' => {
if depth == MAX_CONTAINERS {
return Err(limit("JSON container depth exceeds 512."));
}
containers[depth] = Container {
delimiter: byte,
..EMPTY
};
depth += 1;
usage.deepest_container = usage.deepest_container.max(depth);
cursor += 1;
}
b'}' | b']' => {
if depth == 0
|| !matches!(
(containers[depth - 1].delimiter, byte),
(b'{', b'}') | (b'[', b']')
)
{
return Err(FileError::Invalid(
"Mismatched JSON container delimiter.".into(),
));
}
depth -= 1;
cursor += 1;
}
b',' => {
if depth > 0 {
containers[depth - 1].first = true;
}
cursor += 1;
}
b':' => cursor += 1,
b'"' => {
cursor += 1;
let start = cursor;
let mut closed = false;
while cursor < bytes.len() {
match bytes[cursor] {
b'"' => {
closed = true;
break;
}
b'\\' => {
// Skip exactly one escaped byte, including an escaped
// backslash. Serde validates actual escapes/UTF-8.
cursor += 1;
if cursor < bytes.len() {
cursor += 1;
}
}
_ => cursor += 1,
}
}
let span = cursor - start;
if span > profile.string_bytes {
return Err(limit("JSON string byte limit exceeded."));
}
charge(
&mut usage.string_bytes,
span,
profile.total_string_bytes,
"JSON aggregate string byte limit exceeded.",
)?;
if !closed {
return Err(FileError::Invalid("Unterminated JSON string.".into()));
}
cursor += 1;
}
_ => {
// Count a scalar token without interpreting its value. Invalid
// tokens and invalid whitespace must still fail the Serde pass.
cursor += 1;
while cursor < bytes.len()
&& !bytes[cursor].is_ascii_whitespace()
&& !matches!(
bytes[cursor],
b'{' | b'[' | b'}' | b']' | b',' | b':' | b'"'
)
{
cursor += 1;
}
}
}
}
if depth != 0 {
return Err(FileError::Invalid("Unclosed JSON container.".into()));
}
Ok(usage)
}
struct Version(Option<u64>);
impl<'de> Deserialize<'de> for Version {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
struct Header;
impl<'de> Visitor<'de> for Header {
type Value = Version;
fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("a project header")
}
fn visit_map<A: MapAccess<'de>>(self, mut access: A) -> Result<Version, A::Error> {
#[derive(Deserialize)]
#[serde(field_identifier)]
enum Key {
#[serde(rename = "schema_version")]
Schema,
#[serde(other)]
Other,
}
let mut version = None;
while let Some(key) = access.next_key::<Key>()? {
match key {
Key::Schema => {
if version.is_some() {
return Err(serde::de::Error::duplicate_field("schema_version"));
}
version = Some(access.next_value()?);
}
Key::Other => {
access.next_value::<IgnoredAny>()?;
}
}
}
Ok(Version(version))
}
}
deserializer.deserialize_map(Header)
}
}
/// IgnoredAny in serde_json walks arbitrary ignored containers iteratively.
/// This probe does not materialize ignored subtrees. Serde's scratch buffer for
/// container markers/escaped keys is bounded by the preceding raw scan. It
/// consumes the entire original stream and rejects duplicate/escaped versions.
pub(super) fn version(bytes: &[u8]) -> Result<Option<u64>, FileError> {
document_stack(|| {
let mut deserializer = serde_json::Deserializer::from_slice(bytes);
let version = Version::deserialize(&mut deserializer)?;
deserializer.end()?;
Ok(version.0)
})
}
pub(super) fn document_stack<R>(operation: impl FnOnce() -> R) -> R {
// Non-Expr Project and historical schemas are finitely nested by their
// concrete Rust types. Reserve for that outer generated Serde frame chain;
// only recursive Expr edges use the smaller repeated boundary guard.
stacker::maybe_grow(512 * 1024, 2 * 1024 * 1024, operation)
}
pub(super) fn current(
bytes: &[u8],
profile: Profile,
depth_128: bool,
) -> Result<Project, FileError> {
preflight(bytes, profile)?;
let _scope = Scope::decoding(profile.expression_nodes);
document_stack(|| {
let mut deserializer = serde_json::Deserializer::from_slice(bytes);
if depth_128 {
deserializer.disable_recursion_limit();
}
let project = Project::deserialize(&mut deserializer)?;
deserializer.end()?;
Ok(project)
})
}
pub(super) fn legacy<T: serde::de::DeserializeOwned>(bytes: &[u8]) -> Result<T, FileError> {
// Never disable the legacy recursion guard, including its temporary Value.
document_stack(|| Ok(serde_json::from_slice(bytes)?))
}
pub(super) fn discard_value(value: serde_json::Value) {
let mut pending = vec![value];
while let Some(value) = pending.pop() {
match value {
serde_json::Value::Array(values) => pending.extend(values),
serde_json::Value::Object(values) => pending.extend(values.into_values()),
_ => {}
}
}
}
fn reserve_bounded(bytes: &mut Vec<u8>, additional: usize, maximum: usize) -> io::Result<()> {
let required = bytes.len() + additional;
if required > bytes.capacity() {
let next = bytes
.capacity()
.max(4096)
.saturating_mul(2)
.max(required)
.min(maximum);
bytes
.try_reserve_exact(next - bytes.len())
.map_err(io::Error::other)?;
}
Ok(())
}
struct CappedWriter {
bytes: Vec<u8>,
maximum: usize,
exceeded: bool,
}
impl CappedWriter {
fn new(maximum: usize) -> Self {
Self {
bytes: Vec::new(),
maximum,
exceeded: false,
}
}
}
impl Write for CappedWriter {
fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
if bytes.len() > self.maximum.saturating_sub(self.bytes.len()) {
self.exceeded = true;
return Err(io::Error::other("JSON output byte limit exceeded."));
}
// Check before reserve/extend; failed output never creates a giant Vec.
reserve_bounded(&mut self.bytes, bytes.len(), self.maximum)?;
self.bytes.extend_from_slice(bytes);
Ok(bytes.len())
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
/// Compact output avoids indentation multiplying the cost of legal depth128.
/// The returned bytes have already passed the identical reader resource profile.
pub(super) fn encode(
project: &Project,
profile: Profile,
depth_128: bool,
) -> Result<Vec<u8>, FileError> {
let _scope = Scope::encoding(profile.expression_nodes);
let mut writer = CappedWriter::new(profile.bytes);
let result = document_stack(|| serde_json::to_writer(&mut writer, project));
if writer.exceeded {
return Err(limit("JSON output byte limit exceeded."));
}
result?;
writer.write_all(b"\n").map_err(|error| {
if writer.exceeded {
limit("JSON output byte limit exceeded.")
} else {
FileError::Io(error)
}
})?;
// Establish save/reopen symmetry before filesystem mutation, including the
// conservative production guard until the deeper profile is validated.
let reopened = current(&writer.bytes, profile, depth_128)?;
if reopened != *project {
return Err(FileError::Invalid(
"JSON serialization did not preserve the project value.".into(),
));
}
Ok(writer.bytes)
}
pub(super) fn read_bounded(path: &std::path::Path, maximum: usize) -> Result<Vec<u8>, FileError> {
let mut file = std::fs::File::open(path)?;
if file.metadata()?.len() > maximum as u64 {
return Err(limit("Input file byte limit exceeded."));
}
let mut bytes = Vec::new();
let mut chunk = [0u8; 8192];
loop {
let count = file.read(&mut chunk)?;
if count == 0 {
return Ok(bytes);
}
if count > maximum.saturating_sub(bytes.len()) {
return Err(limit("Input file byte limit exceeded."));
}
reserve_bounded(&mut bytes, count, maximum)?;
bytes.extend_from_slice(&chunk[..count]);
}
}
pub(super) fn matches_file(path: &std::path::Path, expected: &[u8]) -> io::Result<bool> {
let mut file = std::fs::File::open(path)?;
if file.metadata()?.len() != expected.len() as u64 {
return Ok(false);
}
let mut compared = 0usize;
let mut chunk = [0u8; 8192];
loop {
let count = file.read(&mut chunk)?;
if count == 0 {
return Ok(compared == expected.len());
}
if expected.get(compared..compared + count) != Some(&chunk[..count]) {
return Ok(false);
}
compared += count;
}
}
#[cfg(test)]
mod tests;
@@ -1,612 +0,0 @@
use super::*;
use crate::{
Element, ElementBody, Id, OaBody,
analysis::{
AnalysisType, Binding, CalculationDefinition, Definition, DefinitionBody, Expr, Parameter,
ParameterId, ScalarType, UnaryOp,
},
language::ParseLimits,
persistence,
};
use std::{
fmt::Write as _,
process::{Command, Stdio},
time::{Duration, Instant},
};
const IDENTITY: &str = "00000000-0000-0000-0000-000000000001";
const PARAMETER: &str = "00000000-0000-0000-0000-000000000002";
const MAIN: &str = "00000000-0000-0000-0000-000000000003";
fn id(value: &str) -> Id {
Id::parse_str(value).unwrap()
}
fn boolean_type() -> AnalysisType {
AnalysisType::Scalar(ScalarType::Boolean)
}
fn definition(id: Id, alias: &str, parameters: Vec<Parameter>, body: Expr) -> Definition {
Definition {
id,
alias: alias.into(),
name: alias.into(),
description: String::new(),
body: DefinitionBody::Calculation(CalculationDefinition {
parameters,
result: boolean_type(),
body: Binding::Expression(body),
}),
}
}
fn project() -> Project {
let mut project = Project::empty();
project.id = Id::from_u128(1000);
project.definitions.insert(
id(IDENTITY),
definition(
id(IDENTITY),
"identity",
vec![Parameter {
id: ParameterId(id(PARAMETER)),
name: "value".into(),
value_type: boolean_type(),
}],
Expr::Parameter(ParameterId(id(PARAMETER))),
),
);
project.definitions.insert(
id(MAIN),
definition(id(MAIN), "main", vec![], Expr::Boolean(true)),
);
project
}
fn wire(expression: &str) -> Vec<u8> {
let shallow = serde_json::to_string(&project()).unwrap();
assert_eq!(shallow.matches(r#"{"Boolean":true}"#).count(), 1);
shallow
.replace(r#"{"Boolean":true}"#, expression)
.into_bytes()
}
fn chain(depth: usize, call: bool) -> String {
assert!(depth > 0);
let prefix = if call {
format!(r#"{{"Call":{{"definition":{{"Local":"{IDENTITY}"}},"arguments":{{"{PARAMETER}":"#)
} else {
r#"{"Unary":{"op":"Not","operand":"#.into()
};
let suffix = if call { "}}}" } else { "}}" };
format!(
"{}{}{}",
prefix.repeat(depth - 1),
r#"{"Boolean":true}"#,
suffix.repeat(depth - 1)
)
}
fn candidate(bytes: &[u8], profile: Profile) -> Result<Project, FileError> {
persistence::decode_with_report_profile(bytes, ParseLimits::UNLIMITED, profile, true)
.map(|decoded| decoded.project)
}
#[test]
fn resource_preflight_has_exact_lowerable_boundaries() {
let bytes = br#"{"key":["a\\\"[{}]",0,true],"other":"\u0061"}"#;
let usage = preflight(bytes, Profile::DOCUMENT).unwrap();
let exact = Profile {
bytes: bytes.len(),
tokens: usage.tokens,
entries: usage.entries,
container_entries: 3,
string_bytes: 9,
total_string_bytes: usage.string_bytes,
..Profile::DOCUMENT
};
preflight(bytes, exact).unwrap();
for lower in [
Profile {
bytes: exact.bytes - 1,
..exact
},
Profile {
tokens: exact.tokens - 1,
..exact
},
Profile {
entries: exact.entries - 1,
..exact
},
Profile {
container_entries: 2,
..exact
},
Profile {
total_string_bytes: exact.total_string_bytes - 1,
..exact
},
Profile {
string_bytes: exact.string_bytes - 1,
..exact
},
] {
assert!(matches!(preflight(bytes, lower), Err(FileError::Limit(_))));
}
let deepest = format!("{}0{}", "[".repeat(512), "]".repeat(512));
assert_eq!(
preflight(deepest.as_bytes(), Profile::DOCUMENT)
.unwrap()
.deepest_container,
512
);
assert!(matches!(
preflight(format!("[{deepest}]").as_bytes(), Profile::DOCUMENT),
Err(FileError::Limit(_))
));
// A huge ignored scalar array exhausts preflight before a header is decoded.
assert!(matches!(
preflight(
br#"{"schema_version":8,"junk":[0,0,0,0]}"#,
Profile {
container_entries: 3,
..Profile::DOCUMENT
}
),
Err(FileError::Limit(_))
));
}
#[test]
fn scanner_is_not_a_parallel_json_grammar() {
for bytes in [
br#"{"schema_version":8}{}"#.as_slice(),
br#"{"schema_version":8,}"#,
br#"{"schema_version":8 "junk":0}"#,
br#"{"schema_version":8,"schema_version":8}"#,
br#"{"schema_version":8,"\u0073chema_version":8}"#,
br#"{"schema_version":8,"junk":"\q"}"#,
br#"{"schema_version":8,"junk":"unfinished}"#,
br#"{"schema_version":8,"junk":[}"#,
] {
assert!(candidate(bytes, Profile::DOCUMENT).is_err(), "{bytes:?}");
}
let mut invalid_utf8 = wire(r#"{"Boolean":true}"#);
let offset = invalid_utf8
.windows(7)
.position(|window| window == b"mission")
.unwrap();
invalid_utf8[offset] = 0xff;
assert!(candidate(&invalid_utf8, Profile::DOCUMENT).is_err());
let escaped = String::from_utf8(wire(r#"{"Boolean":true}"#))
.unwrap()
.replace(r#""schema_version""#, r#""\u0073chema_version""#);
assert_eq!(
candidate(escaped.as_bytes(), Profile::DOCUMENT).unwrap(),
project()
);
}
#[test]
fn remote_serde_keeps_each_existing_expression_variant() {
let n = r#"{"Number":{"numerator":"1","denominator":"1"}}"#;
let b = r#"{"Boolean":true}"#;
let expressions = [
b.to_string(),
n.to_string(),
format!(
r#"{{"Quantity":{{"magnitude":{{"numerator":"1","denominator":"1"}},"unit":{{"Local":"{IDENTITY}"}}}}}}"#
),
format!(r#"{{"Property":"{IDENTITY}"}}"#),
format!(r#"{{"Parameter":"{PARAMETER}"}}"#),
format!(r#"{{"List":[{n},{n}]}}"#),
format!(r#"{{"Unary":{{"op":"Not","operand":{b}}}}}"#),
format!(r#"{{"Binary":{{"op":"Add","left":{n},"right":{n}}}}}"#),
format!(r#"{{"If":{{"condition":{b},"when_true":{n},"when_false":{n}}}}}"#),
format!(
r#"{{"Call":{{"definition":{{"Local":"{IDENTITY}"}},"arguments":{{"{PARAMETER}":{b}}}}}}}"#
),
format!(r#"{{"Sum":{{"values":{{"List":[{n}]}},"initial":{n}}}}}"#),
format!(r#"{{"Power":{{"base":{n},"exponent":2}}}}"#),
format!(r#"{{"AsKind":{{"kind":{{"Local":"{IDENTITY}"}},"value":{n}}}}}"#),
];
for original in expressions {
let value: Expr = serde_json::from_str(&original).unwrap();
let copy = value.clone();
assert!(value == copy);
assert_eq!(serde_json::to_string(&value).unwrap(), original);
assert_eq!(format!("{value:?}"), format!("{copy:?}"));
}
let list = Expr::List(vec![Expr::Boolean(true), Expr::Boolean(false)]);
assert_eq!(format!("{list:?}"), "List([Boolean(true), Boolean(false)])");
assert!(list != Expr::List(vec![Expr::Boolean(false), Expr::Boolean(true)]));
}
#[test]
fn json_profile_does_not_contract_declared_cli_collection_counts() {
// Exercise original-byte typed construction at the existing graph ceiling.
// This gate checks codec/count compatibility, not 100000-object UI speed.
let mut model = Project::empty();
model.id = Id::from_u128(1_000_000);
for value in 1..=100_000 {
let id = Id::from_u128(value);
model.elements.insert(
id,
Element {
id,
alias: format!("activity_{value}"),
name: String::new(),
description: String::new(),
parent: None,
body: ElementBody::Oa(OaBody::Activity),
},
);
}
let bytes = encode(&model, Profile::DOCUMENT, true).unwrap();
let decoded = current(&bytes, Profile::DOCUMENT, true).unwrap();
assert_eq!(decoded.elements.len(), 100_000);
let cli = ParseLimits {
max_source_bytes: 64 * 1024 * 1024,
max_graph_objects: 100_000,
max_records: 250_000,
max_step_references: 500_000,
};
cli.check_model(&decoded).unwrap();
let references = format!(
"[{}\"{IDENTITY}\"]",
format!("\"{IDENTITY}\",").repeat(499_999)
);
preflight(references.as_bytes(), Profile::DOCUMENT).unwrap();
assert!(matches!(
preflight(
format!("[\"{IDENTITY}\",{}]", &references[1..references.len() - 1]).as_bytes(),
Profile::DOCUMENT
),
Err(FileError::Limit(_))
));
// Metadata over the initially proposed 1 MiB limit remains admissible.
let mut text = Project::empty();
text.description = "x".repeat(1024 * 1024 + 1);
let bytes = encode(&text, Profile::DOCUMENT, true).unwrap();
assert_eq!(current(&bytes, Profile::DOCUMENT, true).unwrap(), text);
eprintln!(
"JSON profile: graph bytes={}, ordered-reference bytes={}, max container entries={}",
serde_json::to_string(&model).unwrap().len(),
references.len(),
Profile::DOCUMENT.container_entries
);
}
#[test]
fn production_save_always_reopens_with_its_reader_profile() {
let deep = candidate(&wire(&chain(128, true)), Profile::DOCUMENT).unwrap();
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("project.json");
std::fs::write(&path, b"previous").unwrap();
let saved = persistence::save_checked(&path, &deep, Some(b"previous"));
if DEPTH_128_VALIDATED {
let bytes = saved.unwrap();
assert_eq!(persistence::decode(&bytes).unwrap(), deep);
} else {
assert!(saved.is_err());
assert_eq!(std::fs::read(&path).unwrap(), b"previous");
}
}
fn accepted_phase() {
for call in [false, true] {
let bytes = wire(&chain(128, call));
let usage = preflight(&bytes, Profile::DOCUMENT).unwrap();
let model = candidate(&bytes, Profile::DOCUMENT).unwrap();
let copy = model.clone();
assert!(model == copy);
let debug = format!("{model:?}");
assert!(!debug.is_empty());
let output = encode(&model, Profile::DOCUMENT, true).unwrap();
assert!(candidate(&output, Profile::DOCUMENT).unwrap() == model);
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("deep.json");
let saved = persistence::save_checked_profile(&path, &model, None, Profile::DOCUMENT, true)
.unwrap();
assert!(
candidate(
&read_bounded(&path, MAX_DOCUMENT_BYTES).unwrap(),
Profile::DOCUMENT
)
.unwrap()
== model
);
assert_eq!(saved, output);
eprintln!(
"accepted call={call} depth128 input={} output={} tokens={} entries={} raw strings={} containers={} debug={}",
bytes.len(),
output.len(),
usage.tokens,
usage.entries,
usage.string_bytes,
usage.deepest_container,
debug.len()
);
}
}
fn refused_phase() {
for call in [false, true] {
let error = candidate(&wire(&chain(129, call)), Profile::DOCUMENT).unwrap_err();
assert!(error.to_string().contains("logical depth"), "{error}");
}
let wide = format!(
r#"{{"List":[{}null]}}"#,
r#"{"Boolean":true},"#.repeat(1024)
);
let error = candidate(&wire(&wide), Profile::DOCUMENT).unwrap_err();
assert!(error.to_string().contains("1024"), "{error}");
let bytes = wire(&chain(128, true));
let exact_model = candidate(
&bytes,
Profile {
expression_nodes: 129,
..Profile::DOCUMENT
},
)
.unwrap();
let error = candidate(
&bytes,
Profile {
expression_nodes: 128,
..Profile::DOCUMENT
},
)
.unwrap_err();
// Identity's Parameter plus main's 128-node expression exceed this total.
assert!(error.to_string().contains("allocation count"), "{error}");
let scan = preflight(&bytes, Profile::DOCUMENT).unwrap();
assert!(matches!(
candidate(
&bytes,
Profile {
tokens: scan.tokens - 1,
..Profile::DOCUMENT
}
),
Err(FileError::Limit(_))
));
document_stack(|| {
use crate::analysis::{AdmissionLimits, admit};
let events = admit(&exact_model, AdmissionLimits::REFERENCE)
.unwrap()
.admission_usage()
.static_events;
admit(
&exact_model,
AdmissionLimits {
max_static_events: events,
},
)
.unwrap();
let error = match admit(
&exact_model,
AdmissionLimits {
max_static_events: events - 1,
},
) {
Ok(_) => panic!("Static work below the measured exact count must refuse."),
Err(error) => error,
};
assert_eq!(error.code, "analysis_limit");
});
let mut tree = r#"{"Boolean":true}"#.to_owned();
for _ in 0..15 {
tree = format!(r#"{{"Binary":{{"op":"And","left":{tree},"right":{tree}}}}}"#);
}
// 65535 balanced nodes plus one unary node is the stored-tree boundary.
tree = format!(r#"{{"Unary":{{"op":"Not","operand":{tree}}}}}"#);
let at_tree_limit = wire(&tree);
current(&at_tree_limit, Profile::DOCUMENT, true).unwrap();
let beyond = wire(&format!(r#"{{"Unary":{{"op":"Not","operand":{tree}}}}}"#));
assert!(
current(&beyond, Profile::DOCUMENT, true)
.unwrap_err()
.to_string()
.contains("65536")
);
// No downstream work after refusal poisons the next ordinary operation.
candidate(&wire(&chain(128, true)), Profile::DOCUMENT).unwrap();
}
fn malformed_phase() {
let deep = chain(127, false);
for (label, expression) in [
(
"duplicate operand",
format!(r#"{{"Unary":{{"op":"Not","operand":{deep},"operand":null}}}}"#),
),
(
"escaped duplicate",
format!(r#"{{"Unary":{{"op":"Not","operand":{deep},"\u006fperand":null}}}}"#),
),
(
"unknown field",
format!(r#"{{"Unary":{{"op":"Not","operand":{deep},"surprise":null}}}}"#),
),
(
"duplicate enum",
format!(r#"{{"Unary":{{"op":"Not","operand":{deep}}},"Boolean":true}}"#),
),
(
"duplicate parameter",
format!(
r#"{{"Call":{{"definition":{{"Local":"{IDENTITY}"}},"arguments":{{"{PARAMETER}":{deep},"\u0030{}":null}}}}}}"#,
&PARAMETER[1..]
),
),
] {
let error = candidate(&wire(&expression), Profile::DOCUMENT).unwrap_err();
assert!(
!error.to_string().contains("logical depth"),
"{label}: {error}"
);
eprintln!("{label}: {error}");
}
let complete = String::from_utf8(wire(&chain(128, true))).unwrap();
let duplicate_late = format!(
r#"{},"name":"late duplicate"}}"#,
&complete[..complete.len() - 1]
);
assert!(candidate(duplicate_late.as_bytes(), Profile::DOCUMENT).is_err());
assert!(candidate(format!("{complete} null").as_bytes(), Profile::DOCUMENT).is_err());
let invalid_surrogate = complete.replace(r#""description":"""#, r#""description":"\uD800""#);
assert!(candidate(invalid_surrogate.as_bytes(), Profile::DOCUMENT).is_err());
// Fully rejected raw depth also tears down without a recursive JSON Value.
let excessive = format!("{}0{}", "[".repeat(100_000), "]".repeat(100_000));
assert!(matches!(
preflight(excessive.as_bytes(), Profile::DOCUMENT),
Err(FileError::Limit(_))
));
}
fn ownership_phase() {
let mut value = Expr::Boolean(true);
for _ in 1..100_000 {
value = Expr::Unary {
op: UnaryOp::Not,
operand: Box::new(value),
};
}
let mut copy = value.clone();
assert!(value == copy);
let mut cursor = &mut copy;
loop {
match cursor {
Expr::Unary { operand, .. } => cursor = operand,
Expr::Boolean(leaf) => {
*leaf = false;
break;
}
_ => unreachable!(),
}
}
assert!(value != copy);
// A streaming formatter avoids retaining a 100000-node Debug string.
struct Counter(usize);
impl fmt::Write for Counter {
fn write_str(&mut self, value: &str) -> fmt::Result {
self.0 += value.len();
Ok(())
}
}
let mut debug = Counter(0);
write!(&mut debug, "{value:?}").unwrap();
assert!(debug.0 > 100_000);
assert!(serde_json::to_writer(io::sink(), &value).is_err());
drop(value);
drop(copy);
eprintln!(
"iterative manual100000 ownership/debug/drop completed; debug bytes={}",
debug.0
);
}
fn atomic_phase() {
let model = project();
let bytes = encode(&model, Profile::DOCUMENT, true).unwrap();
let exact = Profile {
bytes: bytes.len(),
..Profile::DOCUMENT
};
assert_eq!(encode(&model, exact, true).unwrap(), bytes);
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("preserved.json");
std::fs::write(&path, b"original").unwrap();
let error = persistence::save_checked_profile(
&path,
&model,
Some(b"original"),
Profile {
bytes: bytes.len() - 1,
..Profile::DOCUMENT
},
true,
)
.unwrap_err();
assert!(matches!(error, FileError::Limit(_)));
assert_eq!(std::fs::read(&path).unwrap(), b"original");
assert_eq!(std::fs::read_dir(directory.path()).unwrap().count(), 1);
let error = persistence::save_checked_profile(
&path,
&model,
Some(b"different"),
Profile::DOCUMENT,
true,
)
.unwrap_err();
assert!(matches!(error, FileError::Conflict));
assert_eq!(std::fs::read(&path).unwrap(), b"original");
assert_eq!(std::fs::read_dir(directory.path()).unwrap().count(), 1);
assert!(read_bounded(&path, 7).is_err());
assert_eq!(read_bounded(&path, 8).unwrap(), b"original");
let mut writer = CappedWriter::new(3);
writer.write_all(b"abc").unwrap();
let capacity = writer.bytes.capacity();
assert!(writer.write_all(b"d").is_err());
assert_eq!(writer.bytes, b"abc");
assert_eq!(writer.bytes.capacity(), capacity);
}
/// Root runs this exact test in each approved target/profile. Each phase runs in
/// another process so a stack overflow is an explicit failed phase, and inside
/// a 128 KiB thread (smaller than the ordinary test stack). No RUST_MIN_STACK
/// override, ignored test, release-only path or increased test stack.
#[test]
fn codec_lifecycle_subprocess() {
const PHASE: &str = "ARCADIA_CODEC_LIFECYCLE_PHASE";
if let Ok(phase) = std::env::var(PHASE) {
eprintln!("codec child phase={phase}, configured thread stack=131072");
std::thread::Builder::new()
.name(format!("codec-{phase}"))
.stack_size(128 * 1024)
.spawn(move || match phase.as_str() {
"accepted" => accepted_phase(),
"refused" => refused_phase(),
"malformed" => malformed_phase(),
"ownership" => ownership_phase(),
"atomic" => atomic_phase(),
_ => panic!("Unknown codec phase"),
})
.unwrap()
.join()
.unwrap();
return;
}
for phase in ["accepted", "refused", "malformed", "ownership", "atomic"] {
let mut child = Command::new(std::env::current_exe().unwrap())
.args([
"--exact",
"persistence::json_codec::tests::codec_lifecycle_subprocess",
"--test-threads=1",
"--nocapture",
])
.env(PHASE, phase)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
let start = Instant::now();
loop {
if child.try_wait().unwrap().is_some() {
break;
}
if start.elapsed() > Duration::from_secs(120) {
child.kill().unwrap();
let output = child.wait_with_output().unwrap();
panic!(
"codec {phase} timed out: {}",
String::from_utf8_lossy(&output.stderr)
);
}
std::thread::sleep(Duration::from_millis(20));
}
let output = child.wait_with_output().unwrap();
eprintln!(
"codec {phase}: status={} elapsed={:?}\n{}{}",
output.status,
start.elapsed(),
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
assert!(
output.status.success(),
"codec phase {phase} aborted or failed"
);
}
}
@@ -1,145 +0,0 @@
//! Containment while the complete source8 adapter remains unavailable.
use super::*;
use crate::analysis::{Definition, DefinitionBody, QuantityAlgebra, QuantityKindDefinition};
use crate::analysis_numeric::Dimension;
use std::fs;
const ORDINARY_SOURCE7: &str = concat!(
"arcadia 7;\n",
"project saved \"Saved model\" id \"4210b9f2-7046-47f0-aa3b-153f626a579d\" {\n",
" description \"Retain café and exact identity\";\n",
"}",
);
fn ordinary() -> Project {
let decoded = decode_with_report(ORDINARY_SOURCE7.as_bytes()).unwrap();
assert!(decoded.migration.is_some());
decoded.project
}
fn directory_entries(path: &Path) -> Vec<std::ffi::OsString> {
let mut names = fs::read_dir(path)
.unwrap()
.map(|entry| entry.unwrap().file_name())
.collect::<Vec<_>>();
names.sort();
names
}
// A missing parent makes a mistakenly early NamedTempFile creation observable
// as Io, even when the temp would otherwise be removed again on error.
fn rejected_output(
project: &Project,
output: &[u8],
profile: json_codec::Profile,
check: impl Fn(&FileError),
) {
let root = tempfile::tempdir().unwrap();
let existing = root.path().join("existing.arcadia");
let sentinel = b"external bytes must survive\r\n";
fs::write(&existing, sentinel).unwrap();
for path in [
existing.clone(),
root.path().join("new.arcadia"),
root.path().join("missing-parent/new.arcadia"),
] {
let before = directory_entries(root.path());
let expected = (path == existing).then_some(sentinel.as_slice());
let error =
save_checked_profile_with_source(&path, project, expected, profile, false, |_| {
output.to_vec()
})
.unwrap_err();
check(&error);
assert_eq!(directory_entries(root.path()), before);
assert_eq!(fs::read(&existing).unwrap(), sentinel);
if path != existing {
assert!(!path.exists());
}
}
}
#[test]
fn malformed_generated_source_rejects_before_any_filesystem_staging() {
rejected_output(
&ordinary(),
b"arcadia",
json_codec::Profile::DOCUMENT,
|error| assert!(matches!(error, FileError::Text(_)), "{error}"),
);
}
#[test]
fn parseable_source_omitting_a_definition_cannot_replace_the_whole_project() {
let mut project = ordinary();
let id = Id::parse_str("188b911c-5f8b-431c-9eea-2d41f3953767").unwrap();
project.definitions.insert(
id,
Definition {
id,
alias: "duration".into(),
name: "Duration".into(),
description: "Unused definitions are still authored model data.".into(),
body: DefinitionBody::QuantityKind(QuantityKindDefinition {
dimension: Dimension::new([0, 0, 1, 0, 0, 0, 0]).unwrap(),
algebra: QuantityAlgebra::Linear,
}),
},
);
validate_project(&project).unwrap();
let decoded = decode(ORDINARY_SOURCE7.as_bytes()).unwrap();
assert_eq!(decoded.id, project.id);
assert_eq!(decoded.name, project.name);
assert_eq!(decoded.elements, project.elements);
assert!(decoded.definitions.is_empty());
assert_ne!(decoded, project);
rejected_output(
&project,
ORDINARY_SOURCE7.as_bytes(),
json_codec::Profile::DOCUMENT,
|error| {
assert!(
matches!(error, FileError::Invalid(message)
if message == "Generated source does not reproduce the complete requested project."),
"{error}"
);
},
);
}
#[test]
fn equal_project_from_generated_legacy_source_still_rejects_migration() {
rejected_output(
&ordinary(),
ORDINARY_SOURCE7.as_bytes(),
json_codec::Profile::DOCUMENT,
|error| {
assert!(
matches!(error, FileError::Invalid(message)
if message == "Generated source unexpectedly requires migration."),
"{error}"
);
},
);
}
#[test]
fn generated_source_admission_checks_final_newline_under_the_effective_byte_limit() {
let project = ordinary();
let profile = json_codec::Profile::with_byte_limit(ORDINARY_SOURCE7.len());
rejected_output(&project, ORDINARY_SOURCE7.as_bytes(), profile, |error| {
assert!(matches!(error, FileError::Limit(_)), "{error}");
});
let mut terminated = ORDINARY_SOURCE7.as_bytes().to_vec();
terminated.push(b'\n');
let profile = json_codec::Profile::with_byte_limit(terminated.len());
// Existing final newline is not added twice: decoding now reaches the
// independent migration refusal, rather than exceeding this exact limit.
rejected_output(&project, &terminated, profile, |error| {
assert!(
matches!(error, FileError::Invalid(message)
if message == "Generated source unexpectedly requires migration."),
"{error}"
);
});
}
+2 -17
View File
@@ -1,14 +1,8 @@
use crate::*;
use std::collections::BTreeSet;
/// Architectural/project references, including analysis subjects/properties.
/// Layer-neutral definition references and scoped parameters have separate domains.
/// Every live reference in a typed element, including structural ownership.
pub fn element_references(e: &Element) -> Vec<Id> {
if e.kind().is_analysis() {
let mut references: Vec<_> = crate::analysis::references::element_model_references(e).into_iter().collect();
references.extend(e.parent);
return references;
}
let mut copy = e.clone();
let mut refs = Vec::new();
visit_element_refs(&mut copy, |id| refs.push(*id));
@@ -20,8 +14,6 @@ pub fn relation_references(r: &Relation) -> Vec<Id> {
visit_relation_refs(&mut copy, |id| refs.push(*id));
refs
}
/// Architectural mutable slots only. Analysis map keys require the fallible
/// whole-project remap; use analysis::references for its typed domains.
pub fn visit_element_refs(e: &mut Element, mut visit: impl FnMut(&mut Id)) {
if let Some(parent) = &mut e.parent {
visit(parent);
@@ -211,10 +203,7 @@ pub fn visit_relation_refs(r: &mut Relation, mut visit: impl FnMut(&mut Id)) {
_ => {}
}
}
pub fn remap_references(p: &mut Project, old: Id, new: Id) -> Result<(), ModelError> {
crate::analysis::references::check_remap(p, old, new)
.map_err(|error| ModelError(error.to_string()))?;
crate::analysis::references::remap_project(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 {
@@ -276,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> {
@@ -417,9 +405,6 @@ fn remove_objects(p: &mut Project, removed: BTreeSet<Id>) {
.iter()
.map(|id| (*id, p.named(*id).to_string()))
.collect();
// Analysis dependencies remain live: deleting their target rejects the
// transaction unless that transaction also explicitly repairs/removes them.
p.definitions.retain(|id, _| !removed.contains(id));
p.elements.retain(|id, _| !removed.contains(id));
p.relations.retain(|id, _| !removed.contains(id));
p.diagrams.retain(|id, _| !removed.contains(id));
+132 -38
View File
@@ -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,
+160
View File
@@ -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 }
}
-3
View File
@@ -604,9 +604,6 @@ fn catalog_facets(
include_linked: bool,
) -> Result<Vec<FacetDrift>, String> {
let (oa, sa) = (trace.oa, trace.sa);
if [oa, sa].iter().any(|id| project.elements.get(id).is_some_and(|element| element.kind().is_analysis())) {
return Err("Semantic review of analysis records is not implemented; header-only review is refused.".into());
}
let mut selectors: BTreeSet<_> = scalar_selectors()
.into_iter()
.filter(|s| {
+1 -66
View File
@@ -19,21 +19,6 @@ pub enum Command {
},
CreateElement(Element),
EditElement(Id, Element),
CreateDefinition(crate::analysis::Definition),
EditDefinition(Id, crate::analysis::Definition),
SetAnalysisBase {
property: Id,
binding: crate::analysis::Binding,
},
SetAnalysisOverride {
case: Id,
property: Id,
binding: crate::analysis::Binding,
},
ClearAnalysisOverride {
case: Id,
property: Id,
},
CreateRelation(Relation),
EditRelation(Id, Relation),
CreateDiagram(Diagram),
@@ -468,47 +453,6 @@ fn apply_command(
}
Some(value.id)
}
Command::CreateDefinition(value) => {
vacant(p, value.id)?;
p.definitions.insert(value.id, value.clone());
Some(value.id)
}
Command::EditDefinition(old, value) => {
if !p.definitions.contains_key(old) { return Err(missing()); }
let mut definition = value.clone();
definition.id = *old;
p.definitions.insert(*old, definition);
reidentify_tracked(p, *old, value.id, provenance)?;
Some(value.id)
}
Command::SetAnalysisBase { property, binding } => {
let element = p.elements.get_mut(property).ok_or_else(missing)?;
let Some(SharedBody::EngineeringProperty { base, .. }) = element.shared_mut() else {
return Err(ModelError("SetAnalysisBase requires an engineering property.".into()));
};
*base = binding.clone();
Some(*property)
}
Command::SetAnalysisOverride { case, property, binding } => {
let element = p.elements.get_mut(case).ok_or_else(missing)?;
let Some(SharedBody::AnalysisCase { overrides, .. }) = element.shared_mut() else {
return Err(ModelError("SetAnalysisOverride requires an analysis case.".into()));
};
overrides.insert(*property, binding.clone());
Some(*case)
}
Command::ClearAnalysisOverride { case, property } => {
let target = p.elements.get(property).ok_or_else(missing)?;
if target.kind() != Kind::EngineeringProperty { return Err(ModelError("ClearAnalysisOverride requires an engineering property target.".into())); }
let layer = target.layer();
let element = p.elements.get_mut(case).ok_or_else(missing)?;
if element.layer() != layer { return Err(ModelError("Case and property must share a layer.".into())); }
let Some(SharedBody::AnalysisCase { overrides, .. }) = element.shared_mut() else {
return Err(ModelError("ClearAnalysisOverride requires an analysis case.".into()));
};
overrides.remove(property);
Some(*case)
}
Command::CreateRelation(value) => {
vacant(p, value.id)?;
p.relations.insert(value.id, value.clone());
@@ -891,10 +835,6 @@ fn edit_header(
assign(&mut p.name, &mut p.description, &mut p.alias);
return Ok(());
}
if let Some(v) = p.definitions.get_mut(&id) {
assign(&mut v.name, &mut v.description, &mut v.alias);
return Ok(());
}
if let Some(v) = p.elements.get_mut(&id) {
assign(&mut v.name, &mut v.description, &mut v.alias);
return Ok(());
@@ -938,13 +878,8 @@ pub fn reidentify(p: &mut Project, old: Id, new: Id) -> Result<(), ModelError> {
return Ok(());
}
vacant(p, new)?;
crate::analysis::references::check_remap(p, old, new)
.map_err(|error| ModelError(error.to_string()))?;
if p.id == old {
p.id = new;
} else if let Some(mut v) = p.definitions.remove(&old) {
v.id = new;
p.definitions.insert(new, v);
} else if let Some(mut v) = p.elements.remove(&old) {
v.id = new;
p.elements.insert(new, v);
@@ -969,7 +904,7 @@ 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> {
+399
View File
@@ -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;
+718
View File
@@ -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);
}
}
+6 -4
View File
@@ -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() {
+131 -20
View File
@@ -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,
@@ -953,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,
@@ -1011,7 +1054,7 @@ pub fn update_bundle_with_identity_map(
project,
identities,
&original_order,
&after,
&compact,
limits,
)?;
let result = compile_bundle(&bundle, limits)?;
@@ -1119,10 +1162,73 @@ fn inserted_value(text: &str, indent: &str, source: &str) -> String {
output
}
// 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<'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);
}
}
_ => {}
}
}
}
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
@@ -1131,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>,
@@ -1243,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,
);
@@ -1308,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))
@@ -1326,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)));
@@ -1450,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() {
@@ -11,7 +11,10 @@ pub(super) fn upgrade(
.as_ref()
.expect("an explicit source migration report");
let project = &current.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,
-10
View File
@@ -449,13 +449,6 @@ pub fn plan_transition(
request: &TransitionRequest,
) -> Result<TransitionPlan, ModelError> {
validate_candidate(project)?;
let is_analysis = |id: &Id| project.elements.get(id).is_some_and(|element| element.kind().is_analysis());
if request.sources.iter().any(is_analysis)
|| request.mappings.iter().any(|mapping| is_analysis(&mapping.source)
|| mapping.target.as_ref().is_some_and(is_analysis)
|| mapping.target_kind.is_some_and(Kind::is_analysis)) {
return Err(ModelError("Analysis transition semantics are not implemented; copying or reviewing only record headers is refused.".into()));
}
let mut plan = TransitionPlan {
base_fingerprint: project_fingerprint(project),
request: request.clone(),
@@ -615,9 +608,6 @@ pub fn plan_transition(
}
}
}
if selected.iter().any(is_analysis) {
return Err(ModelError("Transition dependency closure includes analysis records; explicit analysis transition semantics are required.".into()));
}
selected.retain(|id| {
if omitted.contains(id) {
if reported_omissions.insert(*id) { plan.warnings.push(format!("SA-only omission: {} ({id}) has no OA port counterpart; the original is preserved.", project.named(*id))); }
+14 -12
View File
@@ -180,18 +180,6 @@ fn check_ids(out: &mut Vec<Issue>, owner: Id, field: &str, ids: &[Id]) {
}
pub fn validate(p: &Project) -> Vec<Issue> {
// Bound analysis trees before architectural visitors can clone an element.
// Admission itself never enters this public validator recursively.
if let Err(failure) = crate::analysis::admit_structure_checked(p, crate::analysis::AdmissionLimits::REFERENCE) {
let mut out = Vec::new();
error(&mut out, failure.object, &failure.code, &failure.field, failure.message);
return out;
}
validate_structure(p)
}
/// Architecture/header validation without recursively re-entering analysis admission.
pub(crate) fn validate_structure(p: &Project) -> Vec<Issue> {
let mut out = Vec::new();
let coverage = coverage::Index::new(p);
let topology_index = crate::topology_context::TopologyIndex::new(p);
@@ -655,6 +643,20 @@ pub(crate) fn validate_structure(p: &Project) -> Vec<Issue> {
}
let mut routed_edges = BTreeSet::new();
for (index, route) in d.routes.iter().enumerate() {
for attachment in [route.source_attachment, route.target_attachment]
.into_iter()
.flatten()
{
if !attachment.offset.is_finite() || !(0.0..=1.0).contains(&attachment.offset) {
error(
&mut out,
d.id,
"invalid_attachment_offset",
"routes",
"Connection attachment offset must be finite and between zero and one.",
);
}
}
if !routed_edges.insert(route.edge) {
error(
&mut out,
+385
View File
@@ -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, &current.diagrams[&diagram]);
let node = view
.nodes
.iter()
.find(|n| n.element == replacement)
.unwrap();
let preference = current.diagrams[&diagram].routes[0]
.target_attachment
.unwrap();
let expected_tip =
attachment_point(current, &current.diagrams[&diagram], node, &preference);
let result = routed(current, diagram);
assert_eq!(result.edges[&edge].points.last(), Some(&expected_tip));
assert_eq!(
result.edges[&edge].pins,
p.diagrams[&diagram].routes[0].waypoints
);
}
assert_ne!(
routed(session.project(), d).edges[&edge].points.last(),
before.edges[&edge].points.last()
);
session.undo();
assert_eq!(session.project(), &p);
assert_eq!(routed(session.project(), d), before);
session.redo();
assert_eq!(session.project(), &expected);
}
#[test]
fn attachment_direct_port_endpoints_remain_authoritative_when_their_glyphs_are_filtered() {
let mut p = Project::empty();
let system = p
.add_element(Kind::System, Layer::Sa, "System", None)
.unwrap();
let actor = p
.add_element(Kind::SystemActor, Layer::Sa, "Actor", None)
.unwrap();
let function = p
.add_element(Kind::Function, Layer::Sa, "Send", None)
.unwrap();
let mut ports = Vec::new();
for (owner, name, contract) in [
(
system,
"Output",
PortContract::Component {
kind: ComponentPortKind::Flow,
orientation: ComponentPortOrientation::Out,
},
),
(
actor,
"Input",
PortContract::Component {
kind: ComponentPortKind::Flow,
orientation: ComponentPortOrientation::In,
},
),
(
function,
"Function output",
PortContract::FunctionOutput {
outgoing_items: vec![],
},
),
] {
let port = Id::new_v4();
p.elements
.insert(port, make_port(&p, port, owner, name, contract).unwrap());
ports.push(port);
}
p.relate(
RelationKind::Allocation,
"Function allocation",
system,
function,
);
let boundary = p.relate(
RelationKind::BoundaryExchange,
"Boundary",
ports[0],
ports[1],
);
let allocation = p.relate(
RelationKind::PortAllocation,
"Port allocation",
ports[0],
ports[2],
);
let d = p.ensure_diagram(Layer::Sa);
p.place(d, system, pos(0., 0.));
p.place(d, actor, pos(700., 0.));
p.place(d, function, pos(0., 450.));
let original = p.clone();
let baseline = routed(&p, d);
for (edge, target) in [(boundary, ports[1]), (allocation, ports[2])] {
assert!(baseline.edges.contains_key(&edge));
assert_eq!(
semantic_port_endpoint(&p, edge, EdgeEnd::Source),
Some(ports[0])
);
assert_eq!(
semantic_port_endpoint(&p, edge, EdgeEnd::Target),
Some(target)
);
let mut route = authored(edge);
route.waypoints.clear();
route.label = None;
p.diagrams.get_mut(&d).unwrap().routes.push(route);
}
assert_eq!(routed(&p, d), baseline);
let mut hidden = project_diagram(&p, &p.diagrams[&d]);
hidden.ports.clear();
for edge in &mut hidden.edges {
edge.source_port = None;
edge.target_port = None;
}
assert_eq!(
route_diagram(&p, &p.diagrams[&d], &hidden),
route_diagram(&original, &original.diagrams[&d], &hidden),
"filtered direct Port endpoints must not activate their stored preferences"
);
}
@@ -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);
}
+154
View File
@@ -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()
);
}
+290
View File
@@ -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(&current, &project, ProjectLimits::default()).unwrap();
assert_eq!(changed.decoded.project, project);
for path in ["p.arcadia", "model.arcadia"] {
assert_eq!(changed.bundle.files[path], current.bundle.files[path]);
}
assert_eq!(
changed.bundle.files["views.arcadia"],
current.bundle.files["views.arcadia"].replace("offset 0.25 ;", "offset 0.375 ;")
);
assert_eq!(
compile_bundle(&changed.bundle, ProjectLimits::default())
.unwrap()
.bundle,
changed.bundle
);
assert_eq!(
update_bundle(&changed, &project, ProjectLimits::default())
.unwrap()
.bundle,
changed.bundle
);
}
+34 -13
View File
@@ -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,
+11 -1
View File
@@ -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 {
+4
View File
@@ -70,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,
@@ -689,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
}
+2
View File
@@ -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,
});
+2
View File
@@ -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()),
});
+1 -1
View File
@@ -261,7 +261,7 @@ fn source7_context_lifecycle_source_history_and_closed_admission() {
assert_eq!(session.project(), &after);
let fresh = Id::new_v4();
let mut reidentified = after.clone();
references::remap_references(&mut reidentified, f.r1, fresh).unwrap();
references::remap_references(&mut reidentified, f.r1, fresh);
let mut e = reidentified.elements.remove(&f.r1).unwrap();
e.id = fresh;
reidentified.elements.insert(fresh, e);
+84
View File
@@ -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);
}
+16
View File
@@ -301,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,
});
@@ -362,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,
});
@@ -422,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,
});
@@ -456,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,
},
@@ -583,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,
@@ -652,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,
});
@@ -730,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,
+1
View File
@@ -18,6 +18,7 @@ serde_json.workspace = true
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
tokio = { version = "1", features = ["rt", "time"] }
tempfile = "3"
unicode-segmentation = "1.13.3"
[dev-dependencies]
iced_runtime = "0.14"
+836
View File
@@ -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
+268
View File
@@ -0,0 +1,268 @@
//! 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 {
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,
)
}
}
+1192 -109
View File
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"
);
}
@@ -301,6 +301,17 @@ impl App {
_ => return Task::none(),
};
let placing = point.is_none() && matches!(intent, Intent::Create { .. });
let filter = if matches!(intent, Intent::Existing { .. })
&& self
.project()
.diagrams
.get(&diagram)
.is_some_and(|d| d.projection == ProjectionKind::Traceability)
{
Filter::AllCompatible
} else {
Filter::Context
};
let saved = Saved {
version: 1,
base: oa_core::transitions::project_fingerprint(self.project()),
@@ -309,7 +320,7 @@ impl App {
point,
placing,
query: String::new(),
filter: Filter::Context,
filter,
show_placed: false,
page: 0,
};
@@ -1,4 +1,5 @@
use super::*;
mod traceability;
fn setup(layer: Layer) -> App {
let mut app = App::with_project(Project::empty());
@@ -70,13 +71,16 @@ fn architecture_f2_routes_selected_node_to_inline_rename_and_exact_undo() {
view_index: None,
diagram: app.active_diagram.unwrap(),
selected: element,
navigation_focus: None,
zoom: app.zoom,
pan: app.pan,
context: app.route_context().unwrap(),
selected_bend: None,
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 {
@@ -88,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();
@@ -0,0 +1,124 @@
use super::*;
#[test]
fn traceability_existing_defaults_show_opposite_layer_and_place_only_one_node() {
let mut project = Project::empty();
let sa = project
.add_element(Kind::Function, Layer::Sa, "Deliver report", None)
.unwrap();
let system = project
.add_element(Kind::System, Layer::Sa, "System", None)
.unwrap();
let diagram = project.ensure_diagram(Layer::Oa);
project.diagrams.get_mut(&diagram).unwrap().projection = ProjectionKind::Traceability;
let mut app = App::with_project(project);
app.page = Page::Diagram;
app.layer = Layer::Oa;
app.active_diagram = Some(diagram);
let before = app.document.snapshot();
send(&mut app, Event::Existing(None));
assert_eq!(
app.architecture.as_ref().unwrap().saved.filter,
Filter::AllCompatible
);
let listed = candidates(&app);
assert!(listed.iter().any(|(id, _, shown)| *id == sa && !shown));
assert!(listed.iter().any(|(id, _, shown)| *id == system && !shown));
send(&mut app, Event::Pick(sa));
assert!(app.architecture.as_ref().unwrap().saved.placing);
assert_eq!(app.project(), &before.decoded.project);
let context = app.route_context().unwrap();
let point = Position { x: 345., y: 215. };
send(&mut app, Event::Click(context, point));
assert!(app.architecture.is_none(), "{}", app.status);
assert_eq!(app.active_diagram, Some(diagram));
let placed = app.project().diagrams[&diagram]
.nodes
.values()
.find(|n| n.element == sa)
.unwrap()
.clone();
assert_eq!(placed.position, point);
let mut expected = before.decoded.project.clone();
expected
.diagrams
.get_mut(&diagram)
.unwrap()
.nodes
.insert(placed.id, placed);
assert_eq!(app.project(), &expected);
let accepted = app.document.bundle().clone();
let _ = app.update(Message::Undo);
assert_eq!(app.document.bundle(), &before.bundle);
assert!(!app.document.can_undo());
let _ = app.update(Message::Redo);
assert_eq!(app.project(), &expected);
assert_eq!(app.document.bundle(), &accepted);
}
#[test]
fn traceability_default_does_not_change_creation_or_ordinary_picker_guards() {
let mut app = setup(Layer::Oa);
let diagram = app.active_diagram.unwrap();
send(&mut app, Event::Existing(None));
assert_eq!(
app.architecture.as_ref().unwrap().saved.filter,
Filter::Context
);
send(&mut app, Event::Cancel);
let _ = app.update(Message::Projection(ProjectionKind::Traceability));
send(&mut app, Event::Start(Kind::Entity, None));
assert_eq!(
app.architecture.as_ref().unwrap().saved.filter,
Filter::Context
);
send(&mut app, Event::Cancel);
assert_eq!(app.active_diagram, Some(diagram));
let mut project = app.project().clone();
let sa = project
.add_element(Kind::Function, Layer::Sa, "Other layer", None)
.unwrap();
let port = Id::new_v4();
project.elements.insert(
port,
oa_core::Element {
id: port,
alias: "legacy_port".into(),
name: "Port".into(),
description: String::new(),
parent: Some(sa),
body: ElementBody::shared_in(
Layer::Sa,
SharedBody::Port {
contract: PortContract::Legacy,
legacy: Some(LegacyPortContract {
direction: PortDirection::InOut,
interface: None,
}),
},
),
},
);
oa_core::validate_candidate(&project).unwrap();
for projection in [ProjectionKind::Overview, ProjectionKind::Traceability] {
let mut p = project.clone();
p.diagrams.get_mut(&diagram).unwrap().projection = projection;
let mut trial = App::with_project(p);
trial.page = Page::Diagram;
trial.active_diagram = Some(diagram);
trial.layer = Layer::Oa;
let before = trial.document.bundle().clone();
send(&mut trial, Event::Existing(None));
send(&mut trial, Event::Filter(Filter::AllCompatible));
assert_eq!(
candidates(&trial).iter().any(|(id, _, _)| *id == sa),
projection == ProjectionKind::Traceability
);
assert!(!candidates(&trial).iter().any(|(id, _, _)| *id == port));
send(&mut trial, Event::Pick(port));
assert!(!trial.architecture.as_ref().unwrap().saved.placing);
send(&mut trial, Event::Cancel);
assert_eq!(trial.document.bundle(), &before);
}
}
@@ -225,13 +225,16 @@ fn architecture_canvas_left_click_uses_world_coordinate_once_at_zoom_and_pan() {
view_index: None,
diagram: app.active_diagram.unwrap(),
selected: app.selected,
navigation_focus: None,
zoom: app.zoom,
pan: app.pan,
context: app.route_context().unwrap(),
selected_bend: None,
selected_attachment: None,
editable: true,
placing: true,
connection: None,
allocation: None,
};
let mut state = crate::diagram::State::default();
let action = canvas
@@ -299,13 +302,16 @@ fn architecture_f2_rename_preserves_identity_placement_and_atomic_history() {
view_index: None,
diagram: app.active_diagram.unwrap(),
selected: element,
navigation_focus: None,
zoom: app.zoom,
pan: app.pan,
context: app.route_context().unwrap(),
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.));
@@ -319,16 +325,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,594 @@
//! 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::TopologyCanvas(topology::CanvasEvent::SelectQualified(..))
)));
assert_eq!(app.selected, references[0]);
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);
}
}
+5
View File
@@ -214,6 +214,11 @@ impl App {
.is_some_and(architecture_authoring::State::inline)
{
Task::batch([lint, iced::widget::operation::focus("architecture-name")])
} else if self.page == Page::Behavior && self.behavior.source_open() {
Task::batch([
lint,
iced::widget::operation::focus(behavior::source_draft::NAME_INPUT),
])
} else if self.page == Page::Behavior && self.behavior.creation.created().is_some() {
Task::batch([
lint,
@@ -0,0 +1,156 @@
//! 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());
}
}
}
@@ -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;
}
+146
View File
@@ -0,0 +1,146 @@
//! Complete a navigation camera after the destination reports its real viewport.
use super::*;
use crate::diagram_usage::{Context, Surface};
#[cfg(test)]
mod tests;
impl App {
pub(super) fn is_diagram_viewport(message: &Message) -> bool {
matches!(
message,
Message::CanvasResized(_)
| Message::DiagramRevealViewport(..)
| Message::TopologyCanvas(behavior::topology::CanvasEvent::Viewport(..))
| Message::ScenarioCanvas(behavior::scenario::CanvasEvent::Viewport(..))
| Message::StateDiagram(behavior::state_edit::Event::Viewport(..))
)
}
pub(crate) fn architecture_reveal_stamp(&self, diagram: Id) -> Option<(Context, u64)> {
self.pending_diagram_reveal
.as_ref()
.filter(|(_, u, _)| u.surface == Surface::Saved(diagram))
.map(|(context, _, epoch)| (*context, *epoch))
}
pub(super) fn diagram_reveal_viewport(&self, message: &Message) -> Option<Surface> {
let (context, usage, epoch) = self.pending_diagram_reveal.as_ref()?;
if context.document != self.canvas_document
|| context.revision != self.document.revision()
|| *epoch != self.canvas_epoch
{
return None;
}
let (surface, size, valid) = match message {
Message::DiagramRevealViewport(c, e, size) => (
usage.surface,
*size,
c == context
&& e == epoch
&& self.page == Page::Diagram
&& Some(usage.surface.id()) == self.active_diagram,
),
Message::TopologyCanvas(behavior::topology::CanvasEvent::Viewport(c, size)) => (
Surface::Process(c.owner),
*size,
c.document == context.document
&& c.revision == context.revision
&& c.epoch == *epoch,
),
Message::ScenarioCanvas(behavior::scenario::CanvasEvent::Viewport(c, size)) => (
Surface::Scenario(c.scenario),
*size,
c.document == context.document
&& c.revision == context.revision
&& c.epoch == *epoch,
),
Message::StateDiagram(behavior::state_edit::Event::Viewport(c, size)) => (
Surface::Saved(c.diagram),
*size,
c.document == context.document
&& c.revision == context.revision
&& c.epoch == *epoch,
),
_ => return None,
};
(valid
&& surface == usage.surface
&& size.width.is_finite()
&& size.height.is_finite()
&& size.width > 0.
&& size.height > 0.)
.then_some(surface)
}
pub(super) fn reconcile_pending_diagram_reveal(&mut self, message: &Message) {
if !matches!(
message,
Message::CheckpointTick
| Message::CheckpointWritten(..)
| Message::Lint(_)
| Message::Linted(..)
| Message::SourceViewport(_)
| Message::WindowResized(_)
| Message::CanvasResized(_)
| Message::DiagramRevealViewport(..)
| Message::DiagramUsage(_)
| Message::DiagramUsagesReady(_)
| Message::SourceSymbolsReady(_)
| Message::SourceRenameReady(_)
| Message::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;
if !Self::is_navigation_camera(message) {
self.diagram_navigation_focus = None;
}
}
}
fn is_navigation_camera(message: &Message) -> bool {
if let Message::BehaviorControl(intent) = message {
return Self::is_navigation_camera(&intent.message);
}
matches!(
message,
Message::Pan(_)
| Message::Zoom(_)
| Message::Fit
| Message::TopologyCanvas(
behavior::topology::CanvasEvent::Camera(..)
| behavior::topology::CanvasEvent::Zoom(..)
| behavior::topology::CanvasEvent::Scrolled(..)
)
| Message::ScenarioCanvas(
behavior::scenario::CanvasEvent::Camera(..)
| behavior::scenario::CanvasEvent::Zoom(..)
| behavior::scenario::CanvasEvent::Scrolled(..)
)
| Message::StateDiagram(
behavior::state_edit::Event::Pan(..)
| behavior::state_edit::Event::Zoom(..)
| behavior::state_edit::Event::Fit(..)
)
)
}
pub(super) fn finish_diagram_reveal(&mut self, surface: Surface) {
let Some((context, usage, _)) = self.pending_diagram_reveal.take() else {
return;
};
if context.document == self.canvas_document
&& context.revision == self.document.revision()
&& usage.surface == surface
&& !self.draft_dirty()
&& !self.source_dirty()
&& !self.behavior.has_pending()
{
// The normal viewport handler has now stored the measured size.
// Target identity was validated for this revision before navigation.
self.reveal_diagram_usage(&usage);
}
self.pending_diagram_reveal = None;
}
}
@@ -0,0 +1,403 @@
//! Independent first-layout navigation oracles, through the real App boundary.
use super::*;
use crate::diagram_usage::{self as usage, Target, Usage};
use iced::{Size, Vector};
use std::collections::BTreeMap;
#[derive(Clone, Copy, Debug)]
enum Canvas {
Architecture,
Process,
Scenario,
State,
}
const CANVASES: [Canvas; 4] = [
Canvas::Architecture,
Canvas::Process,
Canvas::Scenario,
Canvas::State,
];
const WIDE: Size = Size::new(1200., 700.);
const ACTUAL: Size = Size::new(500., 300.);
fn send(app: &mut App, message: Message) {
let _ = app.update(message);
}
fn fixture(canvas: Canvas) -> (App, Usage) {
let mut p = Project::empty();
let work = p
.add_element(Kind::Activity, Layer::Oa, "Respond", None)
.unwrap();
let (surface, subject) = match canvas {
Canvas::Architecture => {
let diagram = p.ensure_diagram(Layer::Oa);
p.place(
diagram,
work,
Position {
x: 12_000.,
y: 9_000.,
},
);
(Surface::Saved(diagram), work)
}
Canvas::Process | Canvas::Scenario => {
let kind = if matches!(canvas, Canvas::Process) {
Kind::Process
} else {
Kind::Scenario
};
let owner = p.add_element(kind, Layer::Oa, "Response", None).unwrap();
let occurrence = p
.add_element(Kind::Occurrence, Layer::Oa, "Respond here", Some(owner))
.unwrap();
*p.elements
.get_mut(&occurrence)
.unwrap()
.shared_mut()
.unwrap() = SharedBody::Occurrence {
body: OccurrenceBody::Action {
action: Some(work),
lifeline: None,
},
precondition: String::new(),
postcondition: String::new(),
};
let steps = p.elements.get_mut(&owner).unwrap().steps_mut().unwrap();
if !steps.contains(&occurrence) {
steps.push(occurrence);
}
(
if matches!(canvas, Canvas::Process) {
Surface::Process(owner)
} else {
Surface::Scenario(owner)
},
occurrence,
)
}
Canvas::State => {
let machine = p
.add_element(Kind::StateMachine, Layer::Oa, "Modes", None)
.unwrap();
let region = p
.add_element(Kind::Region, Layer::Oa, "Main", Some(machine))
.unwrap();
let state = p
.add_element(Kind::State, Layer::Oa, "Ready", Some(region))
.unwrap();
let diagram = Id::new_v4();
p.diagrams.insert(
diagram,
Diagram {
id: diagram,
alias: "ready_view".into(),
name: "Ready view".into(),
description: String::new(),
layer: Layer::Oa,
projection: ProjectionKind::StateMachine { machine },
nodes: BTreeMap::new(),
routes: vec![],
view_options: Default::default(),
},
);
p.place(
diagram,
state,
Position {
x: 8_000.,
y: 11_000.,
},
);
(Surface::Saved(diagram), state)
}
};
let mut app = App::with_project(p);
let found = usage::collect(
app.project(),
subject,
&app.behavior.topology,
&app.behavior.scenario,
)
.into_iter()
.find(|u| u.surface == surface && u.target != Target::Whole)
.unwrap();
app.canvas_size = Some(WIDE);
app.behavior.diagram.viewport = Some(WIDE);
if let Surface::Process(owner) = surface {
app.behavior.topology.enter(app.canvas_document, owner);
app.behavior.topology.viewport(WIDE);
}
if let Surface::Scenario(owner) = surface {
app.behavior.scenario.enter(app.canvas_document, owner);
app.behavior.scenario.viewport(WIDE);
}
app.page = Page::Text;
(app, found)
}
fn measurement(
canvas: Canvas,
context: Context,
epoch: u64,
surface: Surface,
size: Size,
) -> Message {
match canvas {
Canvas::Architecture => Message::DiagramRevealViewport(context, epoch, size),
Canvas::Process => Message::TopologyCanvas(behavior::topology::CanvasEvent::Viewport(
behavior::topology::Context {
document: context.document,
owner: surface.id(),
revision: context.revision,
epoch,
},
size,
)),
Canvas::Scenario => Message::ScenarioCanvas(behavior::scenario::CanvasEvent::Viewport(
behavior::scenario::Context {
document: context.document,
scenario: surface.id(),
revision: context.revision,
epoch,
},
size,
)),
Canvas::State => Message::StateDiagram(behavior::state_edit::Event::Viewport(
behavior::state_edit::Context {
document: context.document,
diagram: surface.id(),
revision: context.revision,
epoch,
},
size,
)),
}
}
fn stamp(app: &App) -> (Context, u64) {
let (context, _, epoch) = app
.pending_diagram_reveal
.as_ref()
.expect("reveal awaits measured destination");
(*context, *epoch)
}
fn assert_centered(app: &App, canvas: Canvas, target: &Usage) {
// All independently authored fixture targets fit ACTUAL at the readable cap.
// This oracle does not call the production camera/reveal implementation.
let zoom = 1.25;
let pan = Vector::new(
250. - target.bounds.center_x() * zoom,
150. - target.bounds.center_y() * zoom,
);
match canvas {
Canvas::Architecture => {
assert_eq!(app.canvas_size, Some(ACTUAL));
assert_eq!(app.zoom, zoom);
assert_eq!(app.pan, pan);
assert!(!app.fit_to_view);
}
Canvas::State => {
assert_eq!(app.behavior.diagram.viewport, Some(ACTUAL));
assert_eq!(app.behavior.diagram.zoom, zoom);
assert_eq!(app.behavior.diagram.pan, pan);
assert!(!app.behavior.diagram.fit);
}
Canvas::Process => {
let mut expected = behavior::topology::State::default();
expected.enter(app.canvas_document, target.surface.id());
expected.viewport(ACTUAL);
expected.zoom(Some(zoom));
expected.camera(behavior::canvas_workspace::Event::Pan(pan));
assert!(app.behavior.topology.camera_boundary() == expected.camera_boundary());
}
Canvas::Scenario => {
let mut expected = behavior::scenario::State::default();
expected.enter(app.canvas_document, target.surface.id());
expected.choose_tool(target.surface.id(), behavior::scenario::CanvasTool::Select);
expected.viewport(ACTUAL);
expected.zoom(Some(zoom));
expected.camera(behavior::canvas_workspace::Event::Pan(pan));
assert!(app.behavior.scenario.camera_boundary() == expected.camera_boundary());
}
}
}
#[test]
fn all_reveal_surfaces_recenter_once_after_the_real_viewport_and_preserve_authored_state() {
for canvas in CANVASES {
let (mut app, target) = fixture(canvas);
let accepted = app.document.snapshot();
let drafts = app.source_workspace.bundle();
let revision = app.document.revision();
let history = (
app.document.can_undo(),
app.document.can_redo(),
app.document.dirty(),
);
assert!(app.reveal_diagram_usage(&target));
let (context, epoch) = stamp(&app);
send(
&mut app,
measurement(canvas, context, epoch, target.surface, ACTUAL),
);
assert!(app.pending_diagram_reveal.is_none());
assert_centered(&app, canvas, &target);
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
assert_eq!(app.source_workspace.bundle(), drafts);
assert_eq!(app.document.revision(), revision);
assert_eq!(
(
app.document.can_undo(),
app.document.can_redo(),
app.document.dirty()
),
history
);
// The exact delivered callback is a replay after the one-shot is consumed.
let camera = app.camera_boundary();
send(
&mut app,
measurement(canvas, context, epoch, target.surface, ACTUAL),
);
assert!(app.pending_diagram_reveal.is_none());
assert!(app.camera_boundary() == camera);
assert_centered(&app, canvas, &target);
}
}
#[test]
fn malformed_or_wrong_stamp_measurements_cannot_poison_a_pending_reveal() {
for canvas in CANVASES {
for invalid in 0..6 {
let (mut app, target) = fixture(canvas);
assert!(app.reveal_diagram_usage(&target));
let (context, epoch) = stamp(&app);
let camera = app.camera_boundary();
let mut stale = context;
let mut wrong_epoch = epoch;
let mut size = Size::new(111., 122.);
match invalid {
0 => stale.document = Id::new_v4(),
1 => stale.revision = stale.revision.wrapping_add(1),
2 => wrong_epoch = wrong_epoch.wrapping_add(1),
3 => size.width = f32::NAN,
4 => size.height = f32::INFINITY,
_ => size.width = 0.,
}
send(
&mut app,
measurement(canvas, stale, wrong_epoch, target.surface, size),
);
assert!(
app.pending_diagram_reveal.is_some(),
"{canvas:?} invalid case {invalid}"
);
assert_eq!(stamp(&app), (context, epoch));
assert_eq!(app.canvas_epoch, epoch);
assert!(
app.camera_boundary() == camera,
"{canvas:?} invalid case {invalid}"
);
send(
&mut app,
measurement(canvas, context, epoch, target.surface, ACTUAL),
);
assert!(app.pending_diagram_reveal.is_none());
assert_centered(&app, canvas, &target);
}
}
}
#[test]
fn actual_window_resize_before_first_measurement_retains_focus_for_the_new_context() {
for canvas in CANVASES {
let (mut app, target) = fixture(canvas);
let accepted = app.document.snapshot();
assert!(app.reveal_diagram_usage(&target));
let (context, old_epoch) = stamp(&app);
send(&mut app, Message::WindowResized(Size::new(1100., 700.)));
let (current, epoch) = stamp(&app);
assert_eq!(current, context);
assert_eq!(epoch, app.canvas_epoch);
if matches!(canvas, Canvas::Process | Canvas::Scenario) {
assert_ne!(epoch, old_epoch);
let camera = app.camera_boundary();
send(
&mut app,
measurement(canvas, context, old_epoch, target.surface, ACTUAL),
);
assert_eq!(stamp(&app), (current, epoch));
assert!(app.camera_boundary() == camera);
}
send(
&mut app,
measurement(canvas, current, epoch, target.surface, ACTUAL),
);
assert!(app.pending_diagram_reveal.is_none());
assert_centered(&app, canvas, &target);
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
}
}
#[test]
fn user_camera_edit_and_navigation_cancel_deferred_focus_without_late_snapping() {
for canvas in CANVASES {
for action in 0..3 {
let (mut app, target) = fixture(canvas);
let accepted = app.document.snapshot();
assert!(app.reveal_diagram_usage(&target));
let (context, epoch) = stamp(&app);
let event = match action {
0 => match canvas {
Canvas::Architecture => Message::Pan(Vector::new(31., -17.)),
Canvas::State => Message::StateDiagram(behavior::state_edit::Event::Pan(
app.state_context().unwrap(),
Vector::new(31., -17.),
)),
Canvas::Process => {
Message::TopologyCanvas(behavior::topology::CanvasEvent::Camera(
app.topology_context().unwrap(),
app.camera_epoch,
behavior::canvas_workspace::Event::Pan(Vector::new(31., -17.)),
))
}
Canvas::Scenario => {
Message::ScenarioCanvas(behavior::scenario::CanvasEvent::Camera(
app.scenario_context().unwrap(),
app.camera_epoch,
behavior::canvas_workspace::Event::Pan(Vector::new(31., -17.)),
))
}
},
1 => Message::Field("/name".into(), "Pending title edit".into()),
_ => Message::Page(Page::Catalog),
};
send(&mut app, event);
assert!(
app.pending_diagram_reveal.is_none(),
"{canvas:?} action {action}"
);
let camera = app.camera_boundary();
let page = app.page;
// A stale layout callback at the already retained size cannot
// restore the old navigation camera after deliberate user input.
send(
&mut app,
measurement(canvas, context, epoch, target.surface, WIDE),
);
assert!(app.pending_diagram_reveal.is_none());
assert!(
app.camera_boundary() == camera,
"{canvas:?} action {action}"
);
assert_eq!(app.page, page);
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
if action == 1 {
assert!(app.draft_dirty());
}
}
}
}

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