Compare commits

...
Author SHA1 Message Date
sorokin.oleg.edu cbc49a7802 Reset alias preview scroll for each explicit destination 2026-09-12 15:51:09 -07:00
sorokin.oleg.edu 4686d05003 Record completed native Text alias rename verification 2026-09-12 15:37:10 -07:00
sorokin.oleg.edu dfb6102ba5 Add guarded Text alias rename with complete preview and editor reuse 2026-09-12 15:01:08 -07:00
sorokin.oleg.edu be7a8cd8d7 Publish verified navigation Windows prerelease checkpoint 2026-09-12 03:58:54 -07:00
sorokin.oleg.edu f5e1c734da Record independent package and native Windows navigation acceptance 2026-09-12 03:51:26 -07:00
sorokin.oleg.edu 9613cf71aa Sync agent instructions from main 2026-09-12 13:43:44 +03:00
sorokin.oleg.edu 08350bb3f4 Sync agent instructions from main 2026-09-12 13:40:27 +03:00
sorokin.oleg.edu 36c105f3cd Sync agent instructions from main 2026-09-12 13:16:07 +03:00
sorokin.oleg.edu c5ea82ed50 Preserve navigation Windows package preparation and exact source provenance 2026-09-11 14:24:43 -07:00
sorokin.oleg.edu 8cf64cedb2 Verify repair and recovery save state in native Windows workflows 2026-09-11 14:12:09 -07:00
sorokin.oleg.edu 33c6d83e9b Bind repaired source save state to its actual destination 2026-09-11 13:43:37 -07:00
sorokin.oleg.edu 1e63dd69ed Choose guarded worker preparation for Text alias rename 2026-09-11 13:42:07 -07:00
sorokin.oleg.edu 00a21d3322 Record corrected navigation trial and alias transaction measurements 2026-09-11 13:34:48 -07:00
sorokin.oleg.edu eeeaa12627 Fix native Text navigation layout and focus regressions 2026-09-11 12:44:17 -07:00
sorokin.oleg.edu f4e426ae2a Preserve preliminary alias-rename preview timing and unchanged inputs 2026-09-11 12:14:38 -07:00
sorokin.oleg.edu fb16c4200f Record Text alias rename proposal and updated navigation acceptance scope 2026-09-11 12:08:32 -07:00
sorokin.oleg.edu 2dedde29e9 Preserve initial navigation native trial and confirmed interaction defects 2026-09-11 12:07:30 -07:00
sorokin.oleg.edu 8e00d3ce4f Record matching navigation GUI build and focused coordination entrypoints 2026-09-11 11:34:00 -07:00
sorokin.oleg.edu 36db9356cc Add bounded definition and reference navigation for current source drafts
Preserve loaded draft bytes and model history, reject stale workspace and input events, and keep captured Escape under modal priority. Verified 181 focused Windows tests and strict desktop Clippy on exact inputs4; earlier full737 scope and component/native preparation evidence are archived separately. Native navigation acceptance remains pending.
2026-09-11 11:23:20 -07:00
sorokin.oleg.edu fb24def2f5 Record verified contextual Windows prerelease and Gitea handoff 2026-09-11 10:14:53 -07:00
sorokin.oleg.edu b70bf1df45 Sync agent instructions from main 2026-09-11 19:52:14 +03:00
sorokin.oleg.edu b2c3e54001 Verify Windows contextual authoring and preserve postboot evidence 2026-09-11 09:04:30 -07:00
sorokin.oleg.edu ca53f574e5 Save reboot checkpoint with source layout WIP and native evidence 2026-09-11 06:42:46 -07:00
sorokin.oleg.edu c4bad4b8de Preserve complete OA scenario native history and reopen evidence 2026-09-11 06:26:18 -07:00
sorokin.oleg.edu d95b1ab178 Preserve published standalone CLI release records 2026-09-11 06:20:22 -07:00
sorokin.oleg.edu df6f595626 Stage explicit prerequisite sources inside behavior creation forms 2026-09-11 06:09:29 -07:00
sorokin.oleg.edu 07eb3cd12b Sync agent instructions from main 2026-09-11 15:55:38 +03:00
sorokin.oleg.edu 27df4a3945 Package standalone source CLI with verified runtime and notices 2026-09-11 05:48:05 -07:00
sorokin.oleg.edu 6a610616e7 Publish verified Windows authoring prerelease evidence 2026-09-11 05:43:28 -07:00
sorokin.oleg.edu 01b76a1e89 Add source-first CLI with guarded writes and reviewed evidence 2026-09-11 05:14:13 -07:00
sorokin.oleg.edu 2ec72d4241 Verify native behavior creation recovery and source authoring contracts 2026-09-11 04:21:56 -07:00
sorokin.oleg.edu 4bb4aaf3dc Add named behavior creation and verified textual authoring examples 2026-09-11 01:54:55 -07:00
sorokin.oleg.edu d114a271c5 Refresh scenario viewport measurement when its identity changes 2026-09-11 01:21:24 -07:00
sorokin.oleg.edu 3978bbe23c Keep scenario authoring visible and restore exact caller context 2026-09-10 12:03:17 -07:00
Codex 81668f94ed Seed optional history values from explicit constructors 2026-09-10 11:52:36 -07:00
sorokin.oleg.edu ee97fcb1e3 Record updated usability objective and integration evidence 2026-09-10 11:40:45 -07:00
sorokin.oleg.edu b98f135799 Add portable Linux packaging and verify distribution notices 2026-09-10 11:38:10 -07:00
sorokin.oleg.edu a70de55ecd Integrate nullable stored-history constructor correction 2026-09-10 11:25:59 -07:00
sorokin.oleg.edu 31b7dc3e15 Restore inline naming focus after recovery 2026-09-10 11:25:06 -07:00
sorokin.oleg.edu 057edddef8 Integrate process, scenario and typed history authoring checkpoint 2026-09-10 11:14:43 -07:00
Codex b6b76efc0d Fix nullable stored-history constructor switching
Rust checks / check (ubuntu-latest) (push) Canceled after 0s
Rust checks / check (windows-latest) (push) Canceled after 0s
2026-09-10 11:13:27 -07:00
sorokin.oleg.edu b24a97360f Record release evidence and coordinated integration work 2026-09-10 11:13:11 -07:00
sorokin.oleg.edu 7d1df4f369 Fix inline architecture naming and captured Escape cancellation
Rust checks / check (ubuntu-latest) (push) Canceled after 0s
Rust checks / check (windows-latest) (push) Canceled after 0s
2026-09-10 11:10:24 -07:00
Codex 01bb52a76d Improve behavior authoring and typed trace history (untested checkpoint)
Rust checks / check (ubuntu-latest) (push) Canceled after 0s
Rust checks / check (windows-latest) (push) Canceled after 0s
2026-09-10 11:07:07 -07:00
sorokin.oleg.edu 1797516f46 Add 'PROJECT_GOAL.md'
Rust checks / check (ubuntu-latest) (push) Canceled after 0s
Rust checks / check (windows-latest) (push) Canceled after 0s
2026-09-10 10:26:35 -07:00
Codex e2ecfea69b Checkpoint source 7 workflows and in-progress diagram authoring redesign
Rust checks / check (ubuntu-latest) (push) Canceled after 0s
Rust checks / check (windows-latest) (push) Canceled after 0s
Preserve current core, source, REST, desktop, recovery, documentation and validation work, including AGENTS.md and the development summary. This is a work-in-progress checkpoint: the new compact palette has outstanding compilation errors and native acceptance; earlier passing gates retain their documented scope.
2026-09-10 10:06:55 -07:00
Codex 4b6b5fc197 Complete source 6 engineering workflows and preserve release validation evidence 2026-09-06 21:49:04 -07:00
Codex 2b2d2bc18b Integrate release recovery, semantic review, coverage and Windows packaging 2026-09-06 16:31:55 -07:00
sorokin.oleg.edu c091f80e6e docs: record integrated review gates and native constructor evidence
Rust checks / check (ubuntu-latest) (push) Canceled after 0s
Rust checks / check (windows-latest) (push) Canceled after 0s
2026-09-06 08:22:58 +03:00
sorokin.oleg.edu 14b8e187ea fix(desktop): invalidate filtered catalog on changed hosted head 2026-09-06 08:15:31 +03:00
sorokin.oleg.edu 00ac6eb86c fix(desktop): identify stale hosted catalog results 2026-09-06 08:15:31 +03:00
sorokin.oleg.edu 843635ed26 Add exact semantic history resets and unordered member review 2026-09-06 08:09:44 +03:00
sorokin.oleg.edu 2c0f93839f fix(desktop): detach hosted destination for invalid local source 2026-09-06 08:07:47 +03:00
sorokin.oleg.edu 09b7ebddc4 Record hosted native recovery and destination review findings 2026-09-06 07:57:02 +03:00
sorokin.oleg.edu cf04ff1442 Document hosted desktop workflows and local save behavior 2026-09-06 07:49:02 +03:00
sorokin.oleg.edu 1a68ae0795 Review closed semantic constructor groups with retained typed drafts 2026-09-06 07:49:01 +03:00
sorokin.oleg.edu 0e47384532 Record native hosted conflict and process verification 2026-09-06 07:48:15 +03:00
sorokin.oleg.edu 28ebbbbc4c Allow review of explicitly mapped existing OA Legacy ports 2026-09-06 07:39:43 +03:00
sorokin.oleg.edu 4f5906b0ff Normalize constructor review members without losing local history 2026-09-06 07:39:43 +03:00
sorokin.oleg.edu f08507a789 Keep process contribution editors fully visible in compact windows 2026-09-06 07:29:57 +03:00
sorokin.oleg.edu 691ef26a01 Record verified hosted Windows trial and reproducible checks 2026-09-06 07:23:18 +03:00
sorokin.oleg.edu 5930f91ed6 Polish hosted request controls and record full desktop checks 2026-09-06 07:23:18 +03:00
sorokin.oleg.edu 1b80671dd0 Add optional hosted desktop editing with conditional source saves 2026-09-06 07:23:18 +03:00
sorokin.oleg.edu 49a2446f85 Record native scalar review and integrated constructor verification 2026-09-06 07:11:44 +03:00
sorokin.oleg.edu 67ed8e09be Review nested message kind groups with exact timer scope 2026-09-06 07:05:50 +03:00
sorokin.oleg.edu 1086e54ecc Record integrated semantics and native authoring verification 2026-09-06 06:57:20 +03:00
sorokin.oleg.edu f4cb486243 Record cross-platform behavior scroll identity verification 2026-09-06 06:55:50 +03:00
sorokin.oleg.edu 8c600087dc Bind behavior scroll state to its document and owner 2026-09-06 06:51:59 +03:00
sorokin.oleg.edu 1b0b072c4b Record process camera regression gates and native retest binaries 2026-09-06 06:45:03 +03:00
sorokin.oleg.edu cf337851ff Preserve process camera and viewport while opening authoring tools 2026-09-06 06:41:57 +03:00
sorokin.oleg.edu 767634aea5 Record scalar review correction checks and frozen platform binaries 2026-09-06 06:32:45 +03:00
sorokin.oleg.edu e7fa061e23 Fix scoped semantic drafts and dependent review controls 2026-09-06 06:27:25 +03:00
sorokin.oleg.edu d114a5d40c Record integrated semantic Windows and REST contract verification 2026-09-06 06:16:34 +03:00
sorokin.oleg.edu c6db9d41d6 Keep integrated HTTP regression modules rustfmt clean 2026-09-06 06:14:09 +03:00
sorokin.oleg.edu 332e46bb9e Integrate semantic review core source API and scalar desktop workflow 2026-09-06 06:10:14 +03:00
sorokin.oleg.edu 1a07475cfc Record combined behavior checks and current native evidence 2026-09-06 05:53:45 +03:00
sorokin.oleg.edu 60d162c259 Merge commit '1a3164b' into codex/data-contract-integration 2026-09-06 05:45:58 +03:00
sorokin.oleg.edu ae1602ad7a Preserve scenario viewport when editing a diagram message 2026-09-06 05:43:56 +03:00
sorokin.oleg.edu 1a3164bf16 Record exchange continuation review fix and updated freeze
Rust checks / check (ubuntu-latest) (push) Canceled after 0s
Rust checks / check (windows-latest) (push) Canceled after 0s
2026-09-06 05:43:48 +03:00
sorokin.oleg.edu 8bc564b4b2 Fix exchange-only process continuation with explicit append path 2026-09-06 05:42:01 +03:00
sorokin.oleg.edu dfc14bcd1f Record direct process editing verification and native gates 2026-09-06 05:35:47 +03:00
sorokin.oleg.edu 7a1e12bc7c Add direct process and chain occurrence editing 2026-09-06 05:33:42 +03:00
sorokin.oleg.edu c6c07cc3a3 Improve scenario tool feedback and keep draft actions visible 2026-09-06 05:32:34 +03:00
sorokin.oleg.edu a306d6b2a8 Plan direct process and chain editing milestone 2026-09-06 05:12:38 +03:00
sorokin.oleg.edu b2d713e774 Record native property browser acceptance and remaining runtime gates 2026-09-06 05:10:47 +03:00
sorokin.oleg.edu 1bf8ebca96 Record combined DATA scenario verification and frozen Windows artifact
Rust checks / check (ubuntu-latest) (push) Canceled after 0s
Rust checks / check (windows-latest) (push) Canceled after 0s
2026-09-06 05:04:32 +03:00
sorokin.oleg.edu dfd2e41259 Merge tested R6 data workflows into direct scenario workspace 2026-09-06 05:04:04 +03:00
sorokin.oleg.edu 05a84b217d Record final scenario save-scroll review and build evidence 2026-09-06 04:59:01 +03:00
sorokin.oleg.edu e7e37d485d Keep observed scenario scrolling current during async saves 2026-09-06 04:58:45 +03:00
sorokin.oleg.edu b35dede75f Browse message value properties by record with bounded pages 2026-09-06 04:56:41 +03:00
sorokin.oleg.edu 5fa9c905c8 Record verified scenario review fixes and fresh binary evidence 2026-09-06 04:54:22 +03:00
sorokin.oleg.edu 7a666076bf Fix scenario scroll cancellation, operand activations and picker cost 2026-09-06 04:53:23 +03:00
sorokin.oleg.edu f5467e50c5 Preserve authored source and reports through desktop migration 2026-09-06 04:43:41 +03:00
sorokin.oleg.edu 74989b6f25 Keep nested data field names visible in compact pickers 2026-09-06 04:40:08 +03:00
sorokin.oleg.edu 524dabb9c3 Record direct scenario checks and pending native acceptance 2026-09-06 04:33:36 +03:00
sorokin.oleg.edu d1d8f0bbed Add direct scenario gestures and atomic ordered message insertion 2026-09-06 04:33:13 +03:00
sorokin.oleg.edu 0edadeebdb Record R6 cross-platform tests and native data workflows 2026-09-06 04:29:29 +03:00
sorokin.oleg.edu 93e3137342 Plan direct scenario drawing and ordered insertion milestone 2026-09-06 04:14:22 +03:00
2441 changed files with 469753 additions and 2939 deletions
+63
View File
@@ -12,3 +12,66 @@
*.png binary
*.jpg binary
*.zip binary
# Exact source/recovery oracles and archived ledgers retain their measured bytes.
# Keep ordinary text diffs; only disable checkout/check-in newline conversion.
harness/fixtures/release1-native/** -text
harness/fixtures/interface-operation-probe/** -text
harness/history/2026-09-06-ledgers-before-consolidation/** -text
harness/history/2026-09-10-ledgers-before-second-consolidation/** -text whitespace=blank-at-eol,blank-at-eof,space-before-tab,cr-at-eol
harness/experiments/textual-authoring-20260911-local/** -text whitespace=blank-at-eol,blank-at-eof,space-before-tab,cr-at-eol
harness/experiments/constructor-defaults-20260911/** -text whitespace=blank-at-eol,blank-at-eof,space-before-tab,cr-at-eol
harness/experiments/constructor-defaults-20260911/results/*.arcadia whitespace=-blank-at-eol,cr-at-eol
harness/experiments/behavior-creation-native-20260911/** -text whitespace=blank-at-eol,blank-at-eof,space-before-tab,cr-at-eol
harness/experiments/analysis-context-20260911/** -text whitespace=cr-at-eol
harness/experiments/textual-authoring-20260911/** -text whitespace=cr-at-eol
harness/experiments/textual-authoring-20260911/observed/split-entry.arcadia whitespace=-blank-at-eol,cr-at-eol
harness/fixtures/scenario-authoring/base/** -text whitespace=cr-at-eol
crates/oa-desktop/src/recovery/fixtures/*.json -text
harness/fixtures/source7-native/** -text
harness/fixtures/basic-editing/** -text
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-native-20260911/** -text whitespace=cr-at-eol
harness/experiments/contextual-source-reopen-20260911/** -text whitespace=cr-at-eol
harness/experiments/contextual-source-native-20260911/**/*.arcadia whitespace=-blank-at-eol,-blank-at-eof,cr-at-eol
harness/experiments/contextual-source-layout-reboot-20260911/** -text whitespace=cr-at-eol
harness/experiments/contextual-source-layout-reboot-20260911/run-gates2.ps1 whitespace=-blank-at-eof,cr-at-eol
harness/experiments/contextual-source-20260911/**/*.log whitespace=-blank-at-eof,cr-at-eol
harness/experiments/scenario-native-local-20260911/** -text whitespace=cr-at-eol
harness/experiments/scenario-native-local-20260911/**/*.log whitespace=-blank-at-eof,cr-at-eol
harness/experiments/scenario-native-local-20260911/**/*.arcadia whitespace=-blank-at-eol,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.
harness/experiments/wsl-capacity-20260911/** -text whitespace=cr-at-eol
harness/experiments/wsl-postboot-9e8r6t-20260911/** -text whitespace=-blank-at-eof,cr-at-eol
harness/experiments/contextual-source-layout-postboot-20260911/** -text whitespace=-blank-at-eof,cr-at-eol
harness/experiments/contextual-source-layout-verified-20260911/** -text whitespace=-blank-at-eof,cr-at-eol
harness/experiments/contextual-source-layout-full-20260911/** -text whitespace=-blank-at-eof,cr-at-eol
scripts/windows-notices/GNOME-Cantarell-61362600e104-COPYING -text
scripts/windows-notices/Fira-OFL.txt -text
scripts/windows-notices/FiraSans-Regular-NOTICE.txt -text
harness/experiments/contextual-source-recovery-20260911/** -text whitespace=-blank-at-eof,cr-at-eol
harness/experiments/contextual-source-machine-v2-20260911/** -text whitespace=-blank-at-eof,cr-at-eol
harness/experiments/contextual-source-lifeline-v2-20260911/** -text whitespace=-blank-at-eof,cr-at-eol
harness/experiments/contextual-source-lifeline-v2-20260911/**/*.arcadia whitespace=-blank-at-eol,-blank-at-eof,cr-at-eol
harness/experiments/contextual-source-machine-v2-20260911/**/*.arcadia whitespace=-blank-at-eol,-blank-at-eof,cr-at-eol
harness/experiments/contextual-source-reopen-20260911/launch-method.ps1 whitespace=-blank-at-eof,cr-at-eol
# Raw compact.exe output includes CR-CR-LF and incidental trailing spaces.
harness/experiments/wsl-capacity-20260911/*.log -whitespace
harness/experiments/contextual-prerelease-20260911/** -text whitespace=cr-at-eol
harness/experiments/contextual-prerelease-publication-20260911/** -text whitespace=cr-at-eol
harness/history/2026-09-11-state-before-gitea-migration/** -text whitespace=cr-at-eol
harness/experiments/contextual-prerelease-20260911/native/**/*.arcadia whitespace=-blank-at-eol,-blank-at-eof,cr-at-eol
harness/experiments/contextual-prerelease-20260911/producer/package*.log whitespace=-blank-at-eof,cr-at-eol
harness/experiments/contextual-prerelease-20260911/producer/source-push.log -whitespace
harness/experiments/source-alias-rename-native-20260912/** -text whitespace=cr-at-eol
harness/experiments/source-alias-rename-completed-20260912/** -text whitespace=cr-at-eol
harness/experiments/source-alias-rename-refresh-20260912/** -text whitespace=cr-at-eol
harness/experiments/source-alias-rename-native-closure-20260912/** -text whitespace=cr-at-eol
+1
View File
@@ -11,3 +11,4 @@
/model-store/
/.local/
.arcadia-save-*/
__pycache__/
+73
View File
@@ -0,0 +1,73 @@
# Agent working agreement
## Working method
- Follow `PROJECT_GOAL.md` and the user's current instructions. Work autonomously within scope. Resolve discoverable questions yourself; record, test, and revise reversible assumptions. Ask only when a consequential decision needs information or authorization not already available. Continue independent useful work while blocked elsewhere.
- Map unfamiliar domains through primary sources, comparable systems, and real user workflows. Identify underlying needs, valuable concepts, and avoidable friction; evaluate improvements rather than merely reproducing features. Synthesize findings into decisions or experiments, and stop research when another action offers greater value.
- Make specifications precise enough for compatible independent implementations: definitions, relationships, invariants, valid/invalid examples, and lifecycle or transformation rules where relevant. Trace important requirements and decisions to implementation and verification. Resolve specification ambiguity at its source when implementation exposes it.
- For consequential uncertain choices, compare materially different candidates against explicit user-derived criteria. Ask independent critics to challenge assumptions and attempt to falsify proposals; refine or replace candidates as evidence warrants. Record the choice, evidence, tradeoffs, rejected approaches, and reasons to revisit it. Use proportionate effort for reversible local choices.
- Plan milestones that resolve uncertainty or deliver useful capability, with explicit dependencies. Test foundational assumptions before substantial downstream work. At checkpoints, assess progress toward the actual objective and replan when evidence invalidates the strategy or progress stalls.
- Define observable success and suitable evaluators before repeated improvement: invariants, examples, round trips, transformations, performance checks, realistic user tasks, or explicit review rubrics. Ensure the evaluator measures the intended outcome. For experiments, record the question, expectations, method, result, and implications; isolate exploratory prototypes from production architecture.
- Build and run meaningful changes, exercise actual behavior, and inspect useful logs. Investigate crashes, serious warnings, corrupted state, and interaction defects. For GUI work, use Computer Use or equivalent tools to inspect realistic workflows, cognitive load, editing/selection, navigation, feedback, recovery, loading/empty/failure states, accessibility, layout, focus/disabled states, and responsiveness.
- Match independent reviews to the artifact and risks. For languages or formats, include ambiguity, round trips, human/AI editability, and version-control behavior where relevant. Verify findings, fix the appropriate conceptual, specification, architectural, implementation, or interaction layer, and recheck affected dependents. Reuse valid evidence; repeat checks for concrete risks, changes, findings, or required gates. Compilation, screenshots, and worker reports alone do not establish correctness or usability.
- Favor the simplest maintainable design satisfying known requirements and justified extensibility. Avoid speculative abstraction, unrelated refactoring, and repeating approaches already invalidated by evidence.
## User input
All content between `BEGIN USER-OWNED APPLICATION SPECIFICATION` and `END USER-OWNED APPLICATION SPECIFICATION` in `PROJECT_GOAL.md` belongs to the user. Its headings and structure are unrestricted. Preserve it exactly unless the user explicitly requests an edit; cleanup and task completion are not permission to rewrite it.
Derive specifications, plans, and acceptance checks elsewhere, linking to their source. Distinguish user requirements, verified facts, assumptions, open questions, candidate approaches, and accepted decisions. Interpretations must not silently replace the user's intent. When input changes, reconcile affected work. Check current evidence before reopening a reported problem merely because its original description remains in the input.
## Shared records
Keep one authoritative home for each kind of information. Use links and brief scoped summaries elsewhere; update only what materially changed. Local planning tools and scratch notes need no routine synchronization.
| Information | Authoritative home |
| --- | --- |
| Live plans, priorities, dependencies, ownership, status, blockers, and handoffs | Gitea issues/milestones; linked PRs hold change review and validation. |
| Specifications, architecture, interfaces, and build/run documentation | Established version-controlled project documents. |
| Durable decisions and reusable evidence | `harness/DECISIONS.md` and relevant topic notes, or existing equivalents. |
| Navigation and integrated-state checkpoint | `harness/STATE.md`: links, verified code commit/release, capabilities, and material limitations. No live task ledger. |
| Shared entrypoint | Gitea Wiki: overview and links, without duplicating versioned documentation. |
| Published deliverables | Gitea releases, tied to source commits and verification. |
Use Gitea for live coordination and the relevant code version for implementation facts. Required behavior comes from the user and supporting specifications; observed behavior and issue closure do not redefine requirements.
## Coordination and integration
1. On starting or resuming, including after compaction, inspect the working branch, local changes, applicable instructions, relevant shared records, and existing implementation. Recover context from authoritative records. Check for newer shared instructions on the actual remote default branch without overwriting local work. Read what is relevant; avoid repeated full-history scans and constant polling.
2. Search before creating tasks. Claim a meaningful outcome in its Gitea issue with a stable session identity, scope, branch/worktree, and acceptance criteria. Keep small steps within that task. Recheck after claiming; comments are not atomic locks. Respect existing ownership and resolve overlapping claims before overlapping work.
3. Independent sessions are peers. Each owns its contribution and delegated work. Record one integration/release owner when a milestone needs shared coordination. Use separate worktrees or checkouts, partition files/interfaces, and coordinate changes to shared specifications and documents.
4. At meaningful checkpoints or handoffs, record changed results, commit/PR links, verification commands and environment, blockers, and next actions. Identify the tested code version and uncommitted or unpublished work; make required artifacts accessible to other sessions. Do not assume another agent is running or has read a message; silence alone does not establish abandonment. Record evidence and preserve existing work when reassigning a task.
5. Follow project review and merge conventions. Inspect upstream changes, resolve conflicts while preserving others' edits, and verify the combined result. Do not overwrite others' work or force-push shared history to resolve coordination problems.
6. Close work when its acceptance criteria are met and the result is integrated or delivered as appropriate. Mark duplicates and superseded tasks explicitly with replacement links. Then choose further useful unowned work while the requested objective remains incomplete.
Use Gitea MCP for shared coordination. If unavailable, report the failure, buffer necessary handoff information locally, and continue already-owned or clearly non-overlapping work. Reconcile with current Gitea state when access returns; do not claim an ownership update, review, or publication succeeded without confirmation.
## Delegation and reasoning
Use GPT-6 Astra exclusively. Delegate bounded independent research, design, implementation, or review when it improves progress, judgment, or preserves the parent's context. Give subagents the relevant context, ownership boundaries, result format, and evidence requirements. Use fresh critics when practical. The parent owns synthesis and integration and normally consolidates results into Gitea. Choose concurrency according to useful independent work and available resources, including other sessions; there is no agent-count quota.
Optimize reasoning effort for verified outcomes per cost, including review, retries, and repair:
Main agent: default to high for planning, synthesis, integration, and acceptance decisions; medium for dispatching a settled plan and routine coordination.
Subagents: default to medium for bounded implementation and targeted research; low for straightforward retrieval, mechanical edits, and running established checks; high for complex debugging, correctness review, adversarial analysis, or ambiguous design.
Use extra-high selectively for difficult foundational semantic, methodological, formal, or cross-domain decisions. Reserve max for exceptional unresolved problems where additional reasoning is likely to justify its cost. Retain higher settings when evidence supports their value.
Allocate by task difficulty and consequences, not role or length alone. Start higher when a clearly difficult decision has expensive downstream consequences. Escalate for concrete reasoning difficulties; resolve missing evidence or unclear requirements at their source. Set subagent effort explicitly through supported controls to avoid unintended inheritance. Do not claim an unapplied change or alter global configuration merely to enforce these preferences.
## Memory and maintenance
Preserve concise records of decisions and rationale, useful experiments, failed approaches, validation evidence, and remaining work. Link substantial evidence rather than copying logs into indexes. Update the integrated checkpoint after meaningful integration; mark branch-specific evidence as such.
Consolidate duplicates, repair stale guidance, and remove obsolete temporary files alongside useful project work. Before removing material, check references, active ownership, and ongoing use; preserve unique knowledge and update affected links. Prefer closing, superseding, or archiving history over deletion. User input is excluded from autonomous cleanup.
If legacy local task ledgers or plans duplicate Gitea, migrate their live content once into existing or appropriately scoped issues, preserve useful decisions/evidence, and mark the old records superseded. Record the migration so later sessions do not repeat it. Do not create empty documents or maintain records for ceremony.
## Delivery
Publish versioned Gitea releases at meaningful working milestones; mark unfinished versions as prereleases. Check existing releases/tags and coordinate one owner and version before publishing. Build from an identified integrated commit and verify the packaged result and launch steps.
Include runnable builds or appropriate artifacts, concise release notes, build/run/deployment instructions as applicable, known limitations, tested platforms, and verification evidence. Confirm publication and artifact availability. Use existing authorized API access or project tooling when MCP lacks a needed release/upload operation. Do not silently replace published versions with different contents.
Generated
+16
View File
@@ -2139,10 +2139,20 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "oa-cli"
version = "0.2.0"
dependencies = [
"oa-core",
"serde",
"serde_json",
]
[[package]]
name = "oa-core"
version = "0.2.0"
dependencies = [
"libc",
"same-file",
"serde",
"serde_json",
@@ -2155,10 +2165,16 @@ dependencies = [
name = "oa-desktop"
version = "0.2.0"
dependencies = [
"axum",
"iced",
"iced_runtime",
"oa-core",
"oa-server",
"reqwest",
"rfd",
"serde",
"serde_json",
"tempfile",
"tokio",
]
+1 -1
View File
@@ -1,5 +1,5 @@
[workspace]
members = ["crates/oa-core", "crates/oa-desktop", "crates/oa-server"]
members = ["crates/oa-core", "crates/oa-desktop", "crates/oa-server", "crates/oa-cli"]
resolver = "2"
[workspace.package]
+74
View File
@@ -0,0 +1,74 @@
# ARCADIA Studio — development-run summary
Latest correction: your review identified a misplaced priority in this run. Advanced counterpart review received attention while basic diagram editing remained awkward. The first attempted fix added large creation buttons; you rejected that too. Actual use of the installed Capella 7.1 has now informed a redesign around a compact contextual palette, in-place naming and renaming, relevant existing elements, explicit activity allocation and clear counterpart destinations. Implementation and native verification remain in progress; the downloadable preview described below predates these corrections. [Observed Capella behavior and design decisions](harness/release-capella-basic-editing-20260907.md).
Updated 7 September 2026. This summarizes the whole continuation run: work integrated from earlier branches, new engineering features, defects found through actual use, and verification on this machine. **The product has advanced substantially, but the complete Windows/Linux release has not yet been accepted.** The latest Windows preview is built, packaged and available for your inspection below.
## Objective and starting point
The objective is a usable Operational Analysis (OA) and System Analysis (SA) engineering application, with equivalent graphical, textual and REST workflows, maintained traceability, offline guidance, and working Windows/Linux distributions. The run treated domain semantics, interaction design, recovery, examples and verification as part of the product—not merely as supporting documentation.
The initial checkout lagged behind other branches. An ancestry, patch-equivalence and code audit found that direct process/scenario/state editing, data contracts, hosted integration and earlier semantic review were already combined in `codex/data-contract-integration`. Work continued from that baseline on `codex/release1-continuation`; duplicate preliminary changes were preserved rather than overwriting integrated work. Source6 work and its evidence were subsequently committed at `4b6b5fc`; source7 and later improvements remain a separately reviewed continuation. No remote publication is implied. [Branch integration](harness/release-branch-integration-20260906.md)
## Engineering features and editing workflows
The integrated foundation includes architecture views and ports; typed data, interfaces and values; direct scenario/process gestures; and persisted state-machine views with regions, pseudostates, transitions and maintained action references. The run extended and tested those workflows rather than replacing their shared model.
Source6 added first-class OA **Communication Means** between participants, with allocated interactions and explicit direction. OA→SA transition requires reviewed component-port choices; it does not silently invent responsibility or substitute a participant for a port. Scenario endpoint resolution handles reverse communication on unoriented channels and correlated Call/Reply behavior. Native testing also corrected transitions that left users on the wrong layer or diagram despite producing the right model.
Saved views gained exchange categories, hidden-edge choices, relation-kind selection, behavior coverage/context filters and exchange-name/item labels. Filters preserve semantic identities and stable port geometry. Traceability views explicitly support both layers. Advisory checks now locate incomplete behavior coverage, non-leaf work exchanges and mission/capability gaps without turning legitimate unfinished engineering work into structural corruption. [Coverage and implementation map](harness/release-current-coverage-20260906.md)
Semantic reconciliation became an editable, atomic **multi-source review**. Engineers can review scalar values, constructors, keyed collections, ordered members, child ownership, incident relationships and artifact lifecycle changes. Included rows compose through the shared planner; excluded invalid rows retain their raw input. Conflicting intentions are checked against the actual destination field. Artifact creation, movement, deletion and all seven metadata fields preserve distinctions such as absent versus empty text. Native tests exercised complete Apply/Save/Undo/Redo cycles, not just individual controls. [Artifact evidence](harness/release-artifacts-native-20260906.md), [attachment evidence](harness/release-attachments-native-20260906.md)
The optional hosted workflow continues to use the same core model and REST service, with conditional ETags, explicit publish/save, conflict handling and retained local drafts. Connection credentials remain in memory. Typed commands, modular source, diagnostics and OpenAPI were kept aligned as representations evolved. Existing hosted acceptance is recorded separately from still-required current-platform transport cases. [Hosted workflow](harness/release-hosted-desktop.md)
## Source, history, recovery and onboarding
Home now offers New/Open, examples, the guide, recent local models and recoverable sessions. Missing recent files remain visible with a recovery route. The searchable offline engineering guide has related topics and guarded workspace actions; navigation and scroll behavior were corrected through minimum-window testing.
Periodic user-local checkpoints preserve the accepted source bundle and raw source, property, behavior, workflow and semantic-review drafts. They do not silently save model files or restore an executable command plan. Recovery creates an unsaved copy, rebuilds previews and requires explicit saving. Write preflight preserves the last good checkpoint if a new snapshot is invalid; close/write races and stale save receipts were covered with regressions.
Frozen, nonempty old5/old6 fixtures support bounded migration to source7. Accepted headers are upgraded while comments, includes and raw invalid edits are retained. Unsupported newer intent hidden in old structural fields or constructor caches is rejected without destroying the checkpoint; ordinary prose containing those words remains valid raw text. Native old5, old6 and current7 recovery cases compared full bundles, model exports and draft DTOs. [Recovery contract](harness/release-source7-recovery-20260906.md)
A real Save As defect was fixed: changing the entry filename had created an extra Undo transaction and cleared Redo. Save As now adopts file identity across retained history without an artificial model edit, with conservative filename-collision preflight before disk writes. Journal publication was hardened against Windows scanner/share races and accidental takeover of another writer; Linux uses no-replace publication with fail-closed unsupported behavior. These safeguards do not claim model-file compare-and-swap or universal power-loss guarantees. [Save As](harness/release-save-as-runtime-packaging.md), [journal review](harness/release-journal-publication-review-20260906.md)
A coherent water-pressure restoration lesson now connects OA responsibilities and communication to explicit SA ports, interfaces, processes, scenarios, state behavior, saved views and evidence. It contains 94 elements, 62 relations, five views, 68 traces and 30 source files, plus deterministic replay inputs and one deliberate warning. Home and the guide open it. Copy the **whole example folder**, including its evidence, before editing. [Walkthrough](examples/release1/README.md)
## Source7: two substantive semantic gaps closed
Independent probes established two gaps that existing constructs could not faithfully represent:
* **Caller-specific process assembly:** ContextualLink addresses include the caller and complete reference-occurrence path. The same leaf reused through two references remains two distinct contributions. Precedence, exchange assembly and common-work purposes have separate meanings; common work does not imply execution fusion. Deletion removes dependent links rather than silently shortening their addresses.
* **Exact interface-operation identity:** Messages can retain unresolved operation intent or select a specific InterfaceItemAllocation. Selection requires proof through actual compatible component ports. Reply derives its operation from the exact Call while validating its own channel and correlation; parameterless operations remain distinguishable. Removing an allocation never silently selects another equivalent-looking allocation, and ordinary channel validity still applies.
Both landed under one source/schema7 contract across core, parser/updater, REST, graphical forms, semantic review and recovery. Old versions reject new fields even when explicitly null. Reference/path admission and bounded contextual analysis are deterministic and independent of cache size; exhausted analysis reports incompleteness rather than fabricated reachability. [Ratified contract](harness/release-source7-integration-decision-20260906.md)
Native inspection then exposed obscured contextual routes, endpoint hit-priority conflicts, and a separate process Fit problem. Routing and qualified selection were corrected and independently checked. The latest Fit change measures the actual remaining viewport, adds bounded tools/details disclosures and keeps pending-form actions reachable. Its first six tests caught two real failures: Iced discarded zero-size slots and reset the canvas tree. A narrow slot-preserving fix passed all six unchanged expectations, the existing pending-scroll and branch-fit regressions, and the full desktop suite. [Fit review](harness/release-process-fit-independent-review-20260907.md)
## What has actually been verified
Windows checkpoints include core **433 passed/1 ignored**, corrected full server **56/56**, and now full desktop **570/570** after the Fit fix. The latest locked workspace binaries build, formatting and strict workspace/all-target Clippy all passed. Native checks used separately hashed binaries and independent complete-model/source oracles for artifacts, batch review, communication, teaching edits, source7 operation choices, nested links and crash recovery—including Save, Undo, Redo and reopening. These are scoped results, not one blanket final-release pass. [Current evidence ledger](harness/STATE.md), [source7 native report](harness/release-source7-native-acceptance-20260907.md)
Linux verification ran in a real Ubuntu guest under QEMU using Rust1.90, ext4 and immutable source snapshots. Current core completed **436 passed/2 ignored**. Server compilation passed; unit **2/2** and HTTP **50/51** passed. One timing-dependent test returned the legitimate 30-second deadline response504 instead of412, including an isolated rerun. Its replacement uses a private test-only preparation gate to prove real HTTP overlap and retain exact conflict/model/disk assertions; that correction passed on Windows and still needs Linux execution. [Linux gate](harness/release-source7-linux-gate-20260907.md)
WSL installation was authorized, but importing the isolated distro remains pending a Windows restart. QEMU resources, shutdown budgets and caches were preserved. A cross-built probe actually executed on Linux. After VMware clock adjustments interrupted one watchdog and another run paused for your Windows build, the **full Linux desktop cross-build passed** with Rust1.90 on a fresh snapshot containing the Fit fix. Its final cached build took2m20s; the ELF dependencies, glibc2.28 link baseline and all37 teaching files were checked. Actual Linux GUI execution remains unverified. The built snapshot precedes the subsequent guide-label wording update. [Cross-build status](harness/release-source7-linux-gui-cross-build-20260907.md)
The next Linux test session failed during kernel startup on three bounded attempts, before any corrected server tests ran. We preserved the failures and extracted the exact verified Ubuntu boot assets for a temporary timer-check workaround. Automatic approval review rejected its preparation/launch command as “blocked by policy,” so that experiment did not run. All task VM processes are stopped. [Boot evidence and pending gate](harness/release-source7-linux-server-corrected-gate-20260907.md)
## Latest preview and remaining release work
**Run the latest Windows preview:** open [arcadia-studio.exe](dist/ArcadiaStudio-preview-source7-20260907-x86_64-pc-windows-gnullvm/arcadia-studio.exe). Keep its folder intact, including `libunwind.dll`; no compiler installation or PATH change is needed. To move it elsewhere, extract the whole [preview ZIP](dist/ArcadiaStudio-preview-source7-20260907-x86_64-pc-windows-gnullvm.zip). Home opens the guide and examples. This is a development-profile preview, not the final optimized release.
Or run this in PowerShell:
```powershell
& 'C:\Users\User\Documents\Git\ARCADIA-Studio\dist\ArcadiaStudio-preview-source7-20260907-x86_64-pc-windows-gnullvm\arcadia-studio.exe'
```
The package contains 565 files, including all 37 teaching files; independent integrity verification and source/build provenance are recorded alongside the ZIP. The packaged executable's SHA-256 begins `DC50C887`; the ZIP's is `1fac10b30daf2f8e1dac29e9469485e4bea142e77ff5a13e4c9d1e41ff03abb2`. [Provenance](dist/ArcadiaStudio-preview-source7-20260907-x86_64-pc-windows-gnullvm.zip.provenance.json)
The packaged app launched with a clean PATH. At **1444×932**, the accepted OA caller graph fitted at **68%** with all four cards visible. A **1102×932** width was also observed; this does **not** establish the 700-pixel minimum-height case. Your Escape stopped Computer Use before the final UI checks; no clean close is claimed. A subsequent read-only oracle confirmed the saved model and both source files were unchanged; this does not establish unobserved in-memory state. Broader native acceptance remains open.
The subsequent guide update names the actual Tools, Selection details, Restore panels and Reply operation controls. Formatting, independent label review and a fresh Windows workspace-binary build passed; the downloadable preview above retains its original, already-tested bytes.
Remaining work is to finish native Fit checks, complete outstanding engineering/hosted workflows, execute the corrected Linux server gate, verify actual Linux GUI behavior, and produce optimized, independently reviewed final packages including the updated guide. Export is explicitly **Architecture SVG**; faithful process/scenario/state SVG is deferred. Logical/physical architecture layers and simulation are outside this OA/SA release. The applicable Windows/Linux acceptance obligations remain open. [Release plan](harness/PLAN.md)
+111
View File
@@ -0,0 +1,111 @@
# Project goal
## Objective
Take the existing project to the complete, coherent, usable, and verified outcome defined by the user input below. Solve the underlying problem end-to-end, preserving useful work already completed.
Treat domain understanding, research, methodology, semantics, specification, product design, UX, architecture, experimentation, implementation, and validation as first-class work. Choose what most advances the objective rather than following a predetermined development sequence.
Follow `AGENTS.md` for execution, coordination, record ownership, and delivery. Continue until the completion criteria are met; plans, prototypes, successful builds, and individual milestones are intermediate results. Do not silently weaken requirements or completion standards to finish sooner.
## Completion criteria
Apply these criteria to the scope requested by the user:
- Required capabilities address the intended problem and support realistic user workflows.
- Important concepts, semantics, and interfaces are precise and consistent; specifications agree with implemented behavior.
- Consequential decisions have evidence and rationale. Major assumptions have been validated, explicitly accepted, or isolated without compromising required behavior.
- The result builds, runs, and operates on the required platforms. Relevant tests and evaluators pass; integrations, edge cases, and failure states have been exercised against the actual result.
- User-facing behavior and usability have been exercised directly, using representative data and workflows.
- A fresh independent review covers requirements, domain/semantic consistency, usability, architecture, correctness, regressions, runtime behavior, documentation, and remaining risks. Valid material findings are addressed, blocking and high-impact issues resolved, and affected behavior reverified; no known blocker or high-severity defect remains.
- Deliverables are maintainable, appropriately extensible, documented, and published as required by `AGENTS.md`. Obsolete scaffolding is removed; documentation and shared state accurately describe the result. Another engineer or agent can reproduce the verification and continue the work.
If required work or verification is blocked, record the specific gap and continue independent useful work without claiming completion.
## Final handoff
Link the delivered result and summarize capabilities, discoveries, architecture and decisions, relevant rejected approaches, verification and independent review, remaining limitations, and exact build/run/deployment/verification instructions. Refer to authoritative records instead of repeating the project history.
## USER-OWNED APPLICATION SPECIFICATION
The block below accepts all project-specific requirements, context, priorities, and feedback, with any headings or organization the user chooses.
<!-- BEGIN USER-OWNED APPLICATION SPECIFICATION -->
## Application specification
### General description
Create a modern clone of ARCADIA Capella using Rust Iced framework. It should be cross platform (supporting Windows and Linux at least). Use Rust exclusively (wherever possible & practical).
Focus on making the application modern, extensible, intuitive and easy to use. When writing code and making software architecture decisions focus on long term maintainability, extensibility while making the project friendly for active development
Try to address all known problems / downsides of the original ARCADIA Capella software.
Version 1 MVP in the form of working Operational Analysis layer is done.
Version 2 MVP in the form of working Operation Analysis + System Analysis is done.
You should make the 1st release version of the project with fully feature complete Operation Analysis + System Architecture, according to this application specification.
Remaining ARCADIA layers will be added in future releases.
Transitioning of model elements between layers (in both directions) should be easy and intuitive.
Keep in mind that the target audience for the application are system engineers. And you should satisfy their needs and use cases
The application should be a general systems modeling tool, like ARCADIA Capella or SysML v2
You should implement most of the ARCADIA semantics, while adressing original ARCADIA Capella problems / downsides
The original ARCADIA Capella UI is very bloated, clunky, unintuitive and hard to use. In addition, it lacks a number of quality of life features. You should at all cost avoid making the same mistakes as the developers of the original ARCADIA Capella
You should explore the functionality & UI of the original ARCADIA Capella to take the most useful and powerful ideas from it. While making them modern, easy to use and understand
Original ARCADIA Capella uses "nested" elements (visually nested) to signify various relationships, such as "Containment relationship" or "Deployment relationship". Instead of using visually noisy arrows / graph edges. I pretty much like this approach and want to see something similar to it in this application
To explore the original ARCADIA Capella UI & semantics you may use web resources / web search / official and community docs. Or open the original ARCADIA Capella installed on this desktop machine for exploration
Your second, parallel, goal is to create a textual representation for ARCADIA, similar to SysML v2. So a user can either edit a system model using UI (graphical representation) or text. Both graphical and textual representations should be fully equivalent and have full parity. You may take inspiration from SysML v2. Both graphical and textual representations should have one overarching, concrete, unambiguous spec in reST format, similar to SysML v2 strictness
The application should support both forms of editing
The textual representation should be easily editable and understandble for both humans and AI
The textual representation should be easily handled by version control tools such as Git
The underlying semantics of the textual / graphical representation should support traceability of various development artifacts (for example - code, CAD files, electrical schematics, Docx files and anything in between)
The textual representation editor should have syntax highlighting and linting
The project should also have a backend server / service that you can deploy. The backend server purpose is to give programmatic access to stored system models. So, there also should be a clear REST API for querying, editing and working with models. The purpose of the REST API is to allow automation, scripting and AI-driven workflows
An example could be - writing custom plugins for various domain specific software (version control software, CAD software etc...) to establish traceability between development artifacts and a system model
The desktop application should contain built-in guide / help / tutorial with clear and concise info on modeling concepts, underlying semantics, common modeling scenarios and application features / how to use the app. It should be easy to navigate and explore
@Computer
### Semantic model / metamodel
Requirements:
- Textual notation.
- Graphical notation.
- Constraints and calculations.
- Quantities and units.
- State and behavior modeling.
- Reusable model libraries.
- Strong, standardized specification.
- Strong, standardized API.
#### Textual notation
Requirements:
- Should not have visual noise and clutter.
- Should have means to reduce cognitive load when developing system models.
- Should be easily editable by humans. And AI agents.
- System engineers should be able to easily develop system models (both big and small) using textual notation only.
### UI
The focus should be on reducing system engineer's cognitive load when creating / working with a system model. Regardless of the model size (big or small).
Long, homogeneous lists in the UI that require user to scroll far or search for a desired option for too long - is a bad design.
## Active issues / current guidance
Right now, the behavior & scenarios UI is hard to use. It contains static / uneditable diagrams, which is highly dubious. In addition, user workflow with the behavior & scenarios UI differ significantly from the other UIs - you can't edit diagrams and instead suppose to click on menus for model development
---
The current project state is nowhere near the usability of the original ARCADIA Capella (despite its downfalls) or its functionality / semantics. You should focus on that by studying original ARCADIA Capella very closely and implementing its features in a modern, ergonomic and easy to use way
<!-- END USER-OWNED APPLICATION SPECIFICATION -->
+62 -17
View File
@@ -2,14 +2,14 @@
Arcadia Studio is a native Rust/Iced modeling workbench for system engineers. V2 covers Operational Analysis (OA) and System Analysis (SA), with graphical and textual editing of one typed model and a deployable Rust REST service. It is an independent implementation inspired by Arcadia and Eclipse Capella, not a complete Capella or SysML implementation.
The normative [language and editing contract](docs/language.rst) defines supported semantics, both representations, validation and migration. [Scope](docs/scope.md) states the boundaries. The [acceptance](docs/acceptance.md), [validation](docs/validation.md) and [current state](harness/STATE.md) records document V2 tests and native Windows/Linux Computer Use verification.
The normative [language and editing contract](docs/language.rst) defines supported semantics, both representations, validation and migration. [Scope](docs/scope.md) states the boundaries. The [acceptance](docs/acceptance.md) and [validation](docs/validation.md) records document the earlier V2 baseline. [Current state](harness/STATE.md) and the [2026-09-06 coverage audit](harness/release-current-coverage-20260906.md) track subsequent implementation and outstanding release gates; the current first release is not yet accepted.
## Build and run
Install stable Rust and your platform's native compiler. The workspace declares Rust 1.90; the recorded development builds use Rust 1.98.1. Windows requires Visual Studio Build Tools with **Desktop development with C++**. Ubuntu/Debian development dependencies are:
Install stable Rust and your platform's native compiler. The workspace declares Rust 1.90; the recorded development builds use Rust 1.98.1. The standard Windows MSVC path uses Visual Studio Build Tools with **Desktop development with C++**. Ubuntu/Debian development dependencies are:
```sh
sudo apt-get install build-essential pkg-config libxkbcommon-dev libwayland-dev libvulkan-dev libx11-dev libxrandr-dev libxi-dev
sudo apt-get install build-essential pkg-config libxkbcommon-dev libxkbcommon-x11-0 libwayland-dev libvulkan-dev libx11-dev libxrandr-dev libxi-dev
```
From the repository root:
@@ -18,14 +18,26 @@ From the repository root:
cargo run --locked -p oa-desktop --bin arcadia-studio
```
Build both desktop and server release executables:
Build the desktop, server and source CLI release executables:
```sh
cargo build --release --workspace --locked
```
Stage the complete teaching examples beside these executables so **Home → Open water-pressure walkthrough** works in an optimized source build. From the repository root, use `Copy-Item -Recurse -Force examples target/release/` in PowerShell or `cp -R examples target/release/` on Linux. If you set a different Cargo target directory or pass `--target`, use that build's executable directory instead of `target/release`. The packaging scripts include these examples automatically.
Run `target/release/arcadia-studio.exe` on Windows or `./target/release/arcadia-studio` on Linux. No task-specific cache directory is required. A graphical desktop and compatible graphics driver are required; the application uses Iced 0.14 with WGPU and X11/Wayland support. It does not require Java, Eclipse, a browser, an account or a running server.
An optional Windows path uses the `stable-x86_64-pc-windows-gnullvm` Rust toolchain with LLVM-MinGW. Put LLVM-MinGW's `bin` directory on `PATH`, including `clang.exe` and `llvm-windres.exe`, then run:
```powershell
rustup toolchain install stable-x86_64-pc-windows-gnullvm
$env:CARGO_TARGET_X86_64_PC_WINDOWS_GNULLVM_LINKER = 'clang.exe'
cargo +stable-x86_64-pc-windows-gnullvm build --release --workspace --locked
```
GNU/gnullvm builds require a resource compiler to embed the normal-user (`asInvoker`) manifest in executables and tests. The build selects `llvm-windres` for gnullvm or `windres` for GNU; set `RC` to its executable path if it is not on `PATH`. This avoids Windows treating installer-like test filenames as elevation requests. The current machine uses user-local Rust 1.98.1 and LLVM-MinGW 20260826 UCRT; compilation and test progress are recorded in [current state](harness/STATE.md). That evidence is separate from native workflow and release-package acceptance.
On the tested Ubuntu 24.04/WSLg environment, Mesa's Vulkan software adapter is required:
```sh
@@ -35,20 +47,39 @@ WGPU_BACKEND=vulkan VK_DRIVER_FILES=/usr/share/vulkan/icd.d/lvp_icd.x86_64.json
A compatible native Linux graphics adapter can use the ordinary command. This software-adapter workaround requires the indicated driver file. See the [platform verification record](docs/validation.md) for tested environments.
V2 release bundles are `dist/ArcadiaStudio-Windows.zip` and `dist/ArcadiaStudio-Linux.tar.gz`, with archive checksums in `dist/SHA256SUMS.txt`. Extract the bundle and run its `arcadia-studio.exe` or `./arcadia-studio`; Rust is not needed to run a packaged executable. Both bundles include the headless server, examples, specifications and deployment instructions. Linux bundles target the documented Ubuntu 24.04 runtime. Packaged executable and archive evidence is recorded in [package verification](harness/verification-v2-packages.md).
The earlier V2 package record describes `dist/ArcadiaStudio-Windows.zip` and `dist/ArcadiaStudio-Linux.tar.gz`, with archive checksums in `dist/SHA256SUMS.txt`. Extract a verified bundle and run its `arcadia-studio.exe` or `./arcadia-studio`; Rust is not needed to run a packaged executable. Those bundles include the headless server, examples, specifications and deployment instructions; Linux targets the documented Ubuntu 24.04 runtime. [Package verification](harness/verification-v2-packages.md) applies to that recorded revision. Current changes still require fresh packages, checksums and acceptance.
The current [Windows packaging instructions](docs/windows-packaging.md) stage both executables, verify their runtime DLL dependencies, and produce an archive with checksums and provenance. Native acceptance fixtures and independent expected results are retained in [the release fixture directory](harness/fixtures/release1-native/README.md).
## Work with source files from the command line
The `arcadia` CLI checks an explicit `.arcadia` entry file and its includes without
starting the desktop or server:
```sh
cargo run --locked -p oa-cli --bin arcadia -- check examples/text-first/project.arcadia
```
`stabilize ROOT` previews the identities needed for persistent editing;
`rename-alias ROOT OLD NEW` previews an alias change and its references. Add
`--write` to save that invocation's candidate, or `--json` for structured
diagnostics and changed-file text/diffs. Preview leaves files untouched.
See [source CLI commands](docs/source-cli.md) for examples and exit codes.
## Model operational needs and system responsibilities
The coastal rescue example combines people, an organization, a System of Interest, information and physical exchange items, normal/degraded behavior and code/CAD evidence. Choose **New** to start an empty project.
The desktop starts at **Home**, where you can create a model, open a local project, continue recent files or recover retained work. Missing recent files stay visible with **Locate**; **Remove** only forgets their history entry. Choose **Explore rescue example** for a model combining people, an organization, a System of Interest, information and physical exchange items, normal/degraded behavior and code/CAD evidence.
The example opens in an architecture view with work nested inside its performers. Each layer also has dedicated functional dataflow and mission/capability views. Regenerate both example formats with matching identities using `cargo run --locked -p oa-core --example generate_demo`; the command verifies both saved models. Historical migration fixtures remain separate.
1. In **Operational Analysis**, create entities, actors, activities and capabilities. Use **In selected** for semantic decomposition and **Allocated** for new leaf work performed by a participant. Activities can decompose independently of their performer; roles capture shared operational responsibility.
The architecture diagram has a compact side palette; expand it to see tool names. Choose an element tool, click its position, enter its name in place and press **Enter**. **Escape** cancels the draft. Select a card and press **F2** to rename it. Right-click a participant and choose **Add activity** or **Add function** to create work with its performer allocation in one undoable edit. Ordinary palette creation leaves work unallocated. **Add existing** places a compatible model element in the current view; **Include already shown** offers **Locate** for existing placements.
1. In **Operational Analysis**, create entities, actors, activities and capabilities. Use **In selected** for semantic decomposition and **Allocated** for new leaf work performed by a participant. Activities can decompose independently of their performer; roles capture shared operational responsibility. **Communication means** connects operational Entities or Actors directly, with direction and carried items; **Exchange allocation** associates the Interactions it carries. Operational communication requires no system ports.
2. Switch to **System Analysis**. Define one System of Interest and external Actors, then functions, missions, capabilities and functional chains. The system boundary has no internal LA/PA components in this version.
3. Select a System or System Actor and choose **Add port** in the browser or Properties. Choose Standard or Flow, then **Create port**. Use **Boundary exchange** in the diagram palette, or **Draw boundary exchange** on a selected visible source port, to connect compatible component ports directly. Create the other participant's matching port first if none exists. Functions expose separate input/output port actions; the **Functional exchange** palette tool connects them. **Actions** exposes other engineering relationships and off-diagram configuration; **Data & interfaces** holds reusable payloads and contracts.
4. Create focused views. **Containment** nests semantic children; **Architecture · allocated work** nests work within performers; **Functional dataflow** shows work decomposition and exchanges; **Missions & capabilities** shows objectives and participation. Dashed, italic parent work denotes an allocation summary inferred from its leaves. Ports appear on their owner's boundary. Placement dragging changes geometry. **Hide** removes a placement; **Delete from model** removes semantics and dependent references. Use **Placement** and **View properties** for exact values.
5. In **Behavior & scenarios**, author processes/chains, scenario lifelines and ordered occurrences, including repeated uses of the same exchange and explicit fragments/branches. Select a State machine and choose **Create diagram** to place its existing regions and states/modes in a saved view. State machines also support availability and state-action links. Conditions are descriptive, not executable simulation code. Behavior and Data authoring preserve your other diagram layouts.
6. Select an OA or SA element and choose **Create / update counterpart**. Review dependency scope and counterpart choices, then apply one undoable transaction. Existing counterparts retain their structural choices; reviewed updates compare names/descriptions. Manual traces link other engineering correspondences. **Traceability** also edits artifact URI, revision and fragment metadata.
4. Create focused views. **Containment** nests semantic children; **Architecture · allocated work** nests work within performers; **Functional dataflow** shows work decomposition and exchanges; **Missions & capabilities** shows objectives and participation. Dashed, italic parent work denotes an allocation summary inferred from its leaves. Ports appear on their owner's boundary. **View properties** stages saved line-kind, category, behavior-coverage, context, label and individual-hide choices. Right-click empty diagram space for **Duplicate view**, retaining the same semantic records in an independent presentation. Placement **Hide** removes a placement; line hiding retains its relationship; **Delete from model** removes semantics and dependent references.
5. In **Behavior & scenarios**, choose **New**, select Operational process (OA), Functional chain (SA), Scenario or State machine, then enter its name. For a scenario, choose what its lifelines represent; **Add optional context…** links a context without changing its layer-root ownership. For a state machine, search for its existing participant owner; optionally select **Create the first state view**. Choose **Create and open** to start authoring work contributions, lifelines, ordered occurrences or states. **Cancel · Esc** discards the creation draft; if another dialog is open, Escape dismisses that dialog first. Creation and its optional first view form one Undo operation. A new state machine starts without regions or states; **Create diagram** can later place its existing contents in a saved view. Behavior supports repeated exchanges, fragments/branches, availability and state-action links. Conditions are descriptive, not executable simulation code. Behavior and Data authoring preserve your other diagram layouts.
6. Select an OA or SA element and choose **Create / update counterpart**. Review dependency scope and counterpart choices, then apply one undoable transaction. For an existing trace, **Review semantics…** compares typed content and prior observations, with explicit keep, take or final-value decisions for supported structural, scalar, constructor, individual-row and ordered-collection changes. Open additional trace rows and choose **Include this row** to combine reviewed source contributions into one validated Apply/Undo transaction; conflicting decisions require resolution. Review does not automatically synchronize the perspectives. **Attachments** reviews an existing child’s parent or a relationship endpoint. **Artifact links** reviews metadata, owner moves, explicit source-absence deletion and creation of a reserved counterpart after all introduced metadata is reviewed. These controls have automated coverage; their native acceptance remains pending. Manual traces link other engineering correspondences. **Traceability** also edits artifact URI, revision and fragment metadata.
7. Review **Model checks**, then save `.arcadia` source or `.json`. Warnings allow incomplete authoring; structural errors preserve the committed model and rejected draft.
**Model explorer** groups roots by engineering purpose and opens one container's direct children. Breadcrumbs navigate ownership; **Allocated work** is listed separately. Search names, kinds or `@aliases` to find elements with ancestor context. Results are paged in groups of 100. **Collapse all** folds the outline; **Locate** clears the filter and reveals the selected element's ancestry.
@@ -57,7 +88,9 @@ The example opens in an architecture view with work nested inside its performers
The inspector exposes typed properties, references, ordered lists, aliases and UUID identity. Applying or navigating away attempts to commit the draft; invalid values remain editable. **Escaped strings (advanced)** uses quoted JSON escapes for exact tabs, line breaks and carriage returns, for example `"First\nSecond"`. UUID changes atomically update dependent references. Accepted model/source history retains up to 100 edits within an estimated 32 MiB history budget.
Choose **Interaction**, **Functional exchange** or **Boundary exchange** in the diagram palette, then click the source and a highlighted compatible target, or drag between them. Functional exchanges use output/input ports; function cards create or reuse real ports atomically. Boundary exchanges use compatible component ports. Middle-button drag pans without losing the chosen source; arrow keys also pan while drawing with the canvas focused. Click empty space to restart, or Escape to cancel. Each completed connection is one model/source history operation; edit its name and carried items in Properties.
Choose **Interaction**, **Communication means**, **Functional exchange** or **Boundary exchange** in the diagram palette, then click the source and a highlighted compatible target, or drag between them. Communication means use OA participants directly. Functional exchanges use output/input ports; function cards create or reuse real ports atomically. Boundary exchanges use compatible component ports. Middle-button drag pans without losing the chosen source; arrow keys also pan while drawing with the canvas focused. Click empty space to restart, or Escape to cancel. Each completed connection is one model/source history operation; edit its name and carried items in Properties.
Create named **Exchange categories** at the perspective root to group Work exchanges or Participant exchanges. In **View properties**, each family defaults to All, including uncategorized exchanges. Selected categories combine by union; an explicit empty selection shows none of that family. Coverage and selected process/chain/scenario contexts intersect with category choices. Explicit hides survive changes to categories and labels. These settings change the saved presentation, not model relationships or node geometry. The [native record](harness/release-current-native-verification.md) covers the source-6 batch crash-recovery/Apply/Undo/Redo flow and a scoped forty-exchange view, including filtering, duplication, hiding and reopening. Communication authoring, remaining view combinations and latest attachment/artifact native acceptance remain pending.
In **Select / move**, drag cards on a 10-unit grid, drag empty space to pan, and scroll to zoom. Drag ports along their owner's border or onto any of its four sides. Automatic sides account for direction and visible connections. Manual positions are per diagram and remain relative to the owner through movement or resize; right-click **Reset automatic position** restores automatic placement. **Arrange** changes the current view layout; **Fit** frames its projected bounds. SVG exports the current architecture diagram as a view, not an editable model file.
@@ -69,10 +102,12 @@ In the state view, **Select / move** drags vertices and containers without repar
## Edit the same model as text
For a short text-only start, open [the distress-response model](examples/text-first/project.arcadia) and follow [its three-step walkthrough](examples/text-first/README.md). Four declarations and three explicit relationships describe participants, work, responsibility and an exchange; no diagram or handwritten UUID is needed.
**Textual model** has a source-file folder tree, per-file drafts, syntax highlighting, live linting and explicit **Apply all**. Collapse/Locate navigate the hierarchy; collapsed folders retain draft and diagnostic indicators. Right-click a file for contextual actions. Projects can use relative includes to organize source into files. For example:
```text
arcadia 4;
arcadia 7;
project harbor "Harbor assistance" {
oa_entity control "Harbor control" {}
oa_activity respond "Respond to requests" {
@@ -102,9 +137,13 @@ Reparenting a nested element through Properties moves its declaration, descendan
## Save and reopen
Ctrl+S saves, Ctrl+Shift+S saves as, and Ctrl+O opens. An `.arcadia` suffix selects text; other suffixes select JSON. Opening detects content rather than relying on the filename. Windows uses native dialogs; Linux uses a path-entry dialog with overwrite confirmation and no portal requirement.
Ctrl+S saves to the current local or hosted destination, Ctrl+Shift+S opens Save as, and Ctrl+O opens a local file. An `.arcadia` suffix selects text; other suffixes select JSON. Opening detects content rather than relying on the filename. Windows uses native dialogs; Linux uses a path-entry dialog with overwrite confirmation and no portal requirement.
**Save all** validates a snapshot and saves the source bundle, comparing previously read dependency bytes to reject detected external changes. Each file is replaced atomically; a staged transaction journal allows explicit recovery of interrupted multi-file saves. This is not an atomic multi-file filesystem operation or distributed locking. If recovery is needed, **Recover interrupted save** appears in Textual model. Dirty New/Open/Close operations offer save/discard/cancel. There is no periodic autosave of unsaved edits. Share or commit the entry file and all included source files together.
**Save all** validates a snapshot and saves the source bundle, comparing previously read dependency bytes to reject detected external changes. Each file is replaced atomically; a staged transaction journal allows explicit recovery of interrupted multi-file saves. This is not an atomic multi-file filesystem operation or distributed locking. If recovery is needed, **Recover interrupted save** appears in Textual model. Dirty New/Open/Close operations offer save/discard/cancel. Share or commit the entry file and all included source files together.
While work is unsaved, the desktop attempts a private recovery checkpoint every 10 seconds. It retains the accepted source bundle, raw source buffers, supported pending property/behavior forms and workflow drafts, including rejected input and semantic-review choices. **Home → Recover** opens an unsaved copy with no local or hosted save destination; use **Save as** to choose one. Undo/redo history is not restored. Checkpoints never write model files or save to the server, and credentials are excluded. A failed checkpoint is reported on Home; recent edits since the last completed checkpoint may be unavailable after a crash. Local history and checkpoints live under `%LOCALAPPDATA%/ArcadiaStudio` on Windows or `$XDG_DATA_HOME/arcadia-studio` (otherwise `~/.local/share/arcadia-studio`) on Linux. Explicit saves and version control remain the durable record.
The [current Windows native record](harness/release-current-native-verification.md) verifies scoped Home/recents workflows and forced-stop recovery of raw invalid source, invalid UUID properties, a pending timed transition, keyed semantic choices and a retained rejected order draft. Accepted repairs were saved and compared with independent source oracles, including exact Undo/Redo cases. It also records the older binary's transient journal-publication failure; the bounded fix has automated evidence in the [journal review](harness/release-journal-publication-review-20260906.md) and awaits new-binary native verification. Subsequent source-6 batch recovery and exact Apply/Save/Undo/Redo comparisons are recorded there too. These results do not accept all recovery variants, latest attachment/artifact workflows, Linux or the complete release.
## REST service and external tools
@@ -118,19 +157,25 @@ The default is `127.0.0.1:8787`, with a platform-local data directory. `--data-d
The API queries and edits complete models, canonical source, multi-file source projects, elements, relationships, artifacts and traces. Transactions and transition previews use the same core validation and source writer as the desktop. Writes require the returned ETag in `If-Match`. One revision protects the accepted model and source bundle together, including comment-only edits. The server retains the current revision only.
Desktop files and hosted resources transfer explicitly; the desktop has no direct server connection. Use **Save as** to export `system.json`, import it, then edit using the API. The model interchange endpoint accepts a complete JSON model or standalone canonical source:
Use **Hosted models...** in the desktop top bar to connect, **Publish as new** to upload the current authored source project, or select a catalog entry and choose **Open selected**. Diagrams, properties and text share the same local editing history. **Save to server** / Ctrl+S explicitly sends valid changes; invalid drafts and failed writes retain your work. A revision conflict preserves local work and checks the server for review, with read-only source files and an explicit, undoable **Reload server version**. **Save as local...** switches back to a local destination after successfully saving an `.arcadia` source project; JSON export keeps the hosted destination. See the [hosted desktop walkthrough](docs/server.md#edit-hosted-models-in-the-desktop) for conflict recovery and connection setup.
The desktop keeps bearer credentials in memory and accepts HTTPS, with HTTP allowed only on localhost/loopback. Hosted editing does not require a cloud account and does not provide automatic synchronization or merges.
Scripts can also import a complete JSON model or standalone canonical source through the model interchange endpoint:
```sh
curl --fail-with-body -X POST http://127.0.0.1:8787/api/v1/projects/import \
-H 'Content-Type: application/json' --data-binary @system.json
```
The response supplies the service `resource_id` and ETag. After API changes, export `/api/v1/projects/<resource-id>/source` to a new `.arcadia` file and open it in the desktop. This preserves model UUIDs, aliases, diagrams, traces and artifact metadata. Importing creates a separate hosted resource. Artifact URIs and revisions are opaque references for code, CAD, schematics, documents and other evidence; the application does not fetch or execute them.
The response supplies the service `resource_id` and ETag. Refresh the desktop catalog to open that resource; after API changes to an open model, use **Check server** to review them. Alternatively, export `/api/v1/projects/<resource-id>/source` to a new `.arcadia` file for local interchange. This preserves model UUIDs, aliases, diagrams, traces and artifact metadata. Importing creates a separate hosted resource. Artifact URIs and revisions are opaque references for code, CAD, schematics, documents and other evidence; the application does not fetch or execute them.
To preserve authored files and comments, use `POST /api/v1/projects/import-source-project` with a JSON bundle containing `entry` and a `files` map of relative paths to source strings. `GET` and conditional `PUT /api/v1/projects/<resource-id>/source-project` exchange that complete accepted bundle. Include resolution uses only the submitted files; the server never opens the client's disk paths. See the [REST contract](docs/api.rst) for payloads, located diagnostics and admission limits.
## Development
Source7 integration adds caller-qualified reusable-process links and explicit interface-operation identity. Its [contract decision](harness/release-source7-integration-decision-20260906.md) and [current state](harness/STATE.md) distinguish prepared code from tested behavior. Current OpenAPI validation passes72structural fixtures across204schemas; source7 core, desktop, HTTP, recovery and native acceptance remain separate gates. Earlier source6 Windows and Linux results retain their exact recorded revision scope. Final full suites, strict checks, optimized/MSRV builds and current release packages are still required.
```sh
cargo fmt --all -- --check
cargo clippy --workspace --all-targets --locked -- -D warnings
@@ -142,4 +187,4 @@ cargo build --release --workspace --locked
- `crates/oa-desktop`: Iced state/drafts, typed forms, architecture/behavior views, source highlighting and file workflows.
- `crates/oa-server`: REST transport, conditional writes, bounded queries and atomic local store.
The [architecture guide](docs/architecture.md) explains extension boundaries. Windows/Linux CI is configured; remote CI was not run in this environment. LA/PA, simulation, native Capella interchange, complete SysML/Capella conformance, direct hosted editing, semantic merge tooling and production-scale collaboration remain outside this prototype.
The [architecture guide](docs/architecture.md) explains extension boundaries. Windows/Linux CI is configured; remote CI was not run in this environment. LA/PA, simulation, native Capella interchange, complete SysML/Capella conformance, semantic merge tooling and production-scale collaboration remain outside this prototype.
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
<security><requestedPrivileges>
<requestedExecutionLevel level="asInvoker" uiAccess="false"/>
</requestedPrivileges></security>
</trustInfo>
</assembly>
+1
View File
@@ -0,0 +1 @@
1 24 "windows.manifest"
+35
View File
@@ -0,0 +1,35 @@
// GNU Windows linkers need an explicit normal-user manifest, including tests
// named "updates", to avoid Windows installer detection. No elevation is needed.
fn main() {
println!("cargo:rerun-if-changed=../../build/windows_manifest.rs");
println!("cargo:rerun-if-changed=../../build/windows.manifest");
println!("cargo:rerun-if-changed=../../build/windows.rc");
println!("cargo:rerun-if-env-changed=RC");
let target = std::env::var("TARGET").unwrap_or_default();
if !target.ends_with("windows-gnu") && !target.ends_with("windows-gnullvm") {
return;
}
let root = std::path::PathBuf::from(std::env::var_os("CARGO_MANIFEST_DIR").unwrap())
.join("../../build");
let object = std::path::PathBuf::from(std::env::var_os("OUT_DIR").unwrap())
.join("normal-user-manifest.o");
let compiler = std::env::var_os("RC").unwrap_or_else(|| {
if target.ends_with("gnullvm") {
"llvm-windres"
} else {
"windres"
}
.into()
});
let result = std::process::Command::new(compiler)
.current_dir(root)
.args(["-i", "windows.rc", "-O", "coff", "-o"])
.arg(&object)
.status()
.expect("Windows GNU resource compiler is required (set RC if not on PATH)");
assert!(
result.success(),
"Cannot compile the normal-user Windows manifest"
);
println!("cargo:rustc-link-arg={}", object.display());
}
+15
View File
@@ -0,0 +1,15 @@
[package]
name = "oa-cli"
version.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true
[[bin]]
name = "arcadia"
path = "src/main.rs"
[dependencies]
oa-core = { path = "../oa-core" }
serde.workspace = true
serde_json.workspace = true
+451
View File
@@ -0,0 +1,451 @@
//! Source-only authoring. Preparation captures bytes; only core owns disk writes.
use oa_core::{
Command, language,
persistence::MigrationReport,
prepare_command,
source_files::{self, DiskProject, SourceFileError},
source_project::{self, CompiledSourceProject, ProjectLimits, SourceDiagnostic},
validation::Severity,
};
use serde::Serialize;
use std::path::{Path, PathBuf};
pub const LIMITS: ProjectLimits = ProjectLimits {
max_files: 256,
max_include_depth: 32,
max_include_edges: 1024,
model: language::ParseLimits {
max_source_bytes: 64 * 1024 * 1024,
max_graph_objects: 100_000,
max_records: 250_000,
max_step_references: 500_000,
},
};
#[derive(Debug, Clone)]
pub enum Operation {
Check,
Stabilize,
RenameAlias { old: String, new: String },
}
#[derive(Debug, Clone, Serialize)]
pub struct Location {
pub path: String,
/// Half-open UTF-8 byte offsets. Line/column are one-based Unicode scalars.
pub start: usize,
pub end: usize,
pub line: Option<usize>,
pub column: Option<usize>,
}
#[derive(Debug, Clone, Serialize)]
pub struct Diagnostic {
pub severity: String,
pub code: String,
pub message: String,
pub coordinate_space: String,
pub location: Option<Location>,
pub related: Vec<Location>,
pub object_id: Option<String>,
pub field: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct Change {
pub path: String,
/// Exact captured and proposed UTF-8 text, including line endings.
pub before: String,
pub after: String,
pub diff: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct Report {
pub format_version: u32,
pub entry: String,
pub operation: String,
pub status: String,
pub exit_code: u8,
/// True only after a successful changed save. False on failure does not
/// promise rollback: consult the specific error/recovery outcome.
pub written: bool,
pub diagnostics: Vec<Diagnostic>,
pub changes: Vec<Change>,
pub identities_to_stabilize: usize,
pub migration: Option<MigrationReport>,
pub notes: Vec<String>,
}
impl Report {
pub fn failure(
entry: &Path,
operation: &str,
exit_code: u8,
code: &str,
message: String,
) -> Self {
let mut report = Self::empty(entry, operation);
report.fail(exit_code, code, message);
report
}
fn empty(entry: &Path, operation: &str) -> Self {
Self {
format_version: 1,
entry: entry.to_string_lossy().into_owned(),
operation: operation.into(),
status: "checked".into(),
exit_code: 0,
written: false,
diagnostics: vec![],
changes: vec![],
identities_to_stabilize: 0,
migration: None,
notes: vec![],
}
}
fn fail(&mut self, exit_code: u8, code: &str, message: String) {
self.status = "rejected".into();
self.exit_code = exit_code;
self.diagnostics.push(Diagnostic {
severity: "error".into(),
code: code.into(),
message,
coordinate_space: "operation".into(),
location: None,
related: vec![],
object_id: None,
field: None,
});
}
}
fn located(issue: SourceDiagnostic, space: &str) -> Diagnostic {
let d = issue.diagnostic;
Diagnostic {
severity: match d.severity {
Severity::Error => "error",
Severity::Warning => "warning",
}
.into(),
code: d.code,
message: d.message,
coordinate_space: space.into(),
location: Some(Location {
path: issue.path,
start: d.span.start,
end: d.span.end,
line: Some(d.line),
column: Some(d.column),
}),
related: issue
.related
.into_iter()
.map(|at| Location {
path: at.path,
start: at.span.start,
end: at.span.end,
line: None,
column: None,
})
.collect(),
object_id: d.object_id.map(|id| id.to_string()),
field: d.field,
}
}
fn disk_error(report: &mut Report, error: SourceFileError) {
let code = match &error {
SourceFileError::Conflict(_) => "external_change",
SourceFileError::RecoveryRequired(_) => "recovery_required",
SourceFileError::RecoveryFailed { .. } => "recovery_failed",
SourceFileError::Path(_) => "unsafe_path",
SourceFileError::Limit(_) => "source_limit",
SourceFileError::UnknownExpectation(_) => "unknown_disk_expectation",
SourceFileError::Invalid(_) => "invalid_save_candidate",
SourceFileError::Io { .. } => "io_error",
};
report.fail(3, code, error.to_string());
}
/// A candidate owns its original expectations and is consumed by saving.
/// Callers cannot replace its source/model/expectations after preparation.
pub struct Prepared {
report: Report,
snapshot: DiskProject,
compiled: CompiledSourceProject,
mutable: bool,
}
impl Prepared {
pub fn report(&self) -> &Report {
&self.report
}
pub fn into_report(self) -> Report {
self.report
}
pub fn write(mut self) -> Report {
if !self.mutable {
self.report
.fail(2, "usage", "check/lint does not accept --write".into());
return self.report;
}
match source_files::save_project(&self.snapshot, &self.compiled, LIMITS) {
Ok(_) => {
self.report.written = !self.report.changes.is_empty();
self.report.status = if self.report.written {
"written"
} else {
"unchanged"
}
.into();
}
Err(error) => disk_error(&mut self.report, error),
}
self.report
}
}
pub fn prepare(entry: &Path, operation: Operation) -> Result<Prepared, Box<Report>> {
let name = match operation {
Operation::Check => "check",
Operation::Stabilize => "stabilize",
Operation::RenameAlias { .. } => "rename-alias",
};
let mut report = Report::empty(entry, name);
let snapshot = source_files::load_project(entry, LIMITS).map_err(|error| {
disk_error(&mut report, error);
Box::new(report.clone())
})?;
let raw = snapshot.reachable_bundle();
// The loader may include warnings from stabilized compilation. Only its
// discovery/IO diagnostics are authoritative on the original bytes.
let mut diagnostics: Vec<_> = snapshot
.diagnostics
.iter()
.filter(|d| {
matches!(
d.diagnostic.code.as_str(),
"pending_save"
| "source_encoding"
| "source_limit"
| "invalid_path"
| "missing_include"
| "project_limit"
)
})
.cloned()
.collect();
for diagnostic in source_project::lint_bundle(&raw, LIMITS) {
if !diagnostics.contains(&diagnostic) {
diagnostics.push(diagnostic);
}
}
let disk_refusal = snapshot.diagnostics.iter().any(|d| {
matches!(
d.diagnostic.code.as_str(),
"pending_save" | "missing_include" | "invalid_path"
)
});
let invalid = diagnostics
.iter()
.any(|d| d.diagnostic.severity == Severity::Error);
report.diagnostics = diagnostics
.into_iter()
.map(|d| located(d, "input"))
.collect();
if invalid {
// Do not run declaration analysis or any updater on invalid raw text.
// Failed lint may also have allocated provisional object identities.
for diagnostic in &mut report.diagnostics {
diagnostic.object_id = None;
}
report.status = "invalid".into();
report.exit_code = if disk_refusal { 3 } else { 1 };
return Err(Box::new(report));
}
let explicit_ids: std::collections::BTreeSet<_> = raw
.files
.values()
.flat_map(|text| language::analyze(text).declarations)
.filter_map(|d| d.id.map(|id| id.to_string()))
.collect();
for diagnostic in &mut report.diagnostics {
if diagnostic
.object_id
.as_ref()
.is_some_and(|id| !explicit_ids.contains(id))
{
diagnostic.object_id = None;
}
}
let mut compiled = source_project::compile_bundle(&raw, LIMITS).map_err(|diagnostics| {
report.status = "invalid".into();
report.exit_code = 1;
report
.diagnostics
.extend(diagnostics.into_iter().map(|d| located(d, "candidate")));
Box::new(report.clone())
})?;
report.identities_to_stabilize = raw
.files
.values()
.map(|text| {
language::analyze(text)
.declarations
.into_iter()
.filter(|d| d.id.is_none())
.count()
})
.sum();
report.migration = compiled.decoded.migration.clone();
let mutable = !matches!(operation, Operation::Check);
if let Operation::RenameAlias { old, new } = operation {
let project = &compiled.decoded.project;
let Some((id, _)) = project
.identities()
.into_iter()
.find(|(_, alias)| *alias == old)
else {
report.fail(
1,
"unknown_alias",
format!("No declaration has alias '{old}'."),
);
return Err(Box::new(report));
};
let command = prepare_command(project, &Command::SetAlias(id, new)).map_err(|error| {
report.fail(1, "semantic_command_rejected", error.to_string());
Box::new(report.clone())
})?;
compiled = source_project::update_bundle_with_identity_map(
&compiled,
&command.project,
&command.identity_map,
LIMITS,
)
.map_err(|diagnostics| {
report.status = "invalid".into();
report.exit_code = 1;
report
.diagnostics
.extend(diagnostics.into_iter().map(|d| located(d, "candidate")));
Box::new(report.clone())
})?;
}
if mutable {
for (path, after) in &compiled.bundle.files {
let before = raw.files.get(path).cloned().unwrap_or_default();
if &before != after {
report.changes.push(Change {
path: path.clone(),
diff: unified_diff(path, &before, after),
before,
after: after.clone(),
});
}
}
report.status = "preview".into();
report.notes.push("This invocation's complete proposed changed-file texts and diffs are reported. Without --write nothing is saved; a later invocation may allocate different missing UUIDs. This is not a saved plan or a digest of unchanged dependencies.".into());
}
report.notes.push("Diagnostic object IDs are omitted for declarations without authored IDs; lint's temporary identities are not the candidate's identities.".into());
report.notes.push("Disk writes use the core journal/conflict checks. This is not cross-process atomic visibility or a power-loss durability guarantee; pending saves are never implicitly recovered. A failed write may require explicit journal recovery.".into());
Ok(Prepared {
report,
snapshot,
compiled,
mutable,
})
}
/// One bounded hunk per file: common prefix/suffix plus three context lines.
/// Linear in input size; no quadratic LCS allocation for large modules.
fn unified_diff(path: &str, before: &str, after: &str) -> String {
let a: Vec<_> = before.split_inclusive('\n').collect();
let b: Vec<_> = after.split_inclusive('\n').collect();
let prefix = a.iter().zip(&b).take_while(|(a, b)| a == b).count();
let suffix = a[prefix..]
.iter()
.rev()
.zip(b[prefix..].iter().rev())
.take_while(|(a, b)| a == b)
.count();
let start = prefix.saturating_sub(3);
let a_end = (a.len() - suffix + 3).min(a.len());
let b_end = (b.len() - suffix + 3).min(b.len());
let a_start = if a_end == start { start } else { start + 1 };
let b_start = if b_end == start { start } else { start + 1 };
let mut out = format!(
"--- a/{path}\n+++ b/{path}\n@@ -{a_start},{} +{b_start},{} @@\n",
a_end - start,
b_end - start
);
let mut emit = |prefix: char, line: &str| {
out.push(prefix);
out.push_str(line);
if !line.ends_with('\n') {
out.push_str("\n\\ No newline at end of file\n");
}
};
for line in &a[start..prefix] {
emit(' ', line);
}
for line in &a[prefix..a.len() - suffix] {
emit('-', line);
}
for line in &b[prefix..b.len() - suffix] {
emit('+', line);
}
for line in &a[a.len() - suffix..a_end] {
emit(' ', line);
}
out
}
pub struct Arguments {
pub entry: PathBuf,
pub operation: Operation,
pub write: bool,
pub json: bool,
}
pub const HELP: &str = "arcadia check ROOT [--json]\narcadia lint ROOT [--json]\narcadia stabilize ROOT [--write] [--json]\narcadia rename-alias ROOT OLD NEW [--write] [--json]\n\nROOT is an explicit .arcadia entry file; includes resolve relative to it.\nMutation commands preview by default. --write applies this invocation's candidate.\nMissing UUIDs in a preview are provisional until written. No canonical formatting.\nExit: 0 valid/success (engineering warnings allowed), 1 invalid source or semantic\ncommand rejection, 2 usage, 3 disk/path/conflict/pending-journal/output failure.\nNo implicit journal recovery. Limits: 64 MiB, 256 files, include depth32/edges1024,\n100000 graph objects, 250000 records, 500000 ordered references.\n";
pub fn arguments(args: &[std::ffi::OsString]) -> Result<Arguments, String> {
let mut positional = Vec::new();
let (mut write, mut json) = (false, false);
for arg in args {
if arg == "--write" && !write {
write = true;
} else if arg == "--json" && !json {
json = true;
} else if arg.to_string_lossy().starts_with('-') {
return Err(format!(
"Unknown or repeated option: {}",
arg.to_string_lossy()
));
} else {
positional.push(arg);
}
}
let command = positional.first().and_then(|s| s.to_str()).unwrap_or("");
let operation = match (command, positional.len()) {
("check" | "lint", 2) if !write => Operation::Check,
("stabilize", 2) => Operation::Stabilize,
("rename-alias", 4) => Operation::RenameAlias {
old: positional[2].to_str().ok_or("Alias must be UTF-8")?.into(),
new: positional[3].to_str().ok_or("Alias must be UTF-8")?.into(),
},
_ => return Err(HELP.into()),
};
Ok(Arguments {
entry: PathBuf::from(positional[1]),
operation,
write,
json,
})
}
+102
View File
@@ -0,0 +1,102 @@
use oa_cli::{Report, arguments, prepare};
use std::{
io::{self, Write},
path::Path,
process::ExitCode,
};
fn human_report(report: &Report, show_diff: bool) -> io::Result<()> {
let mut err = io::stderr().lock();
for d in &report.diagnostics {
let at = d
.location
.as_ref()
.map(|l| {
format!(
"{}:{}:{}",
l.path,
l.line.unwrap_or(0),
l.column.unwrap_or(0)
)
})
.unwrap_or_else(|| report.entry.clone());
writeln!(
err,
"{at}: {}[{}] ({}): {}",
d.severity, d.code, d.coordinate_space, d.message
)?;
}
writeln!(
err,
"{}: {} file changes; {} identities to stabilize",
report.status,
report.changes.len(),
report.identities_to_stabilize
)?;
if let Some(migration) = &report.migration {
writeln!(
err,
"Source migration: schema {} → {} (separate from identity stabilization)",
migration.from_schema, migration.to_schema
)?;
}
for note in &report.notes {
writeln!(err, "{note}")?;
}
if show_diff {
let mut out = io::stdout().lock();
for change in &report.changes {
write!(out, "{}", change.diff)?;
}
out.flush()?;
}
err.flush()
}
fn run() -> io::Result<u8> {
let args: Vec<_> = std::env::args_os().skip(1).collect();
if args.len() == 1 && (args[0] == "--help" || args[0] == "-h") {
io::stdout().write_all(oa_cli::HELP.as_bytes())?;
return Ok(0);
}
if args.len() == 1 && args[0] == "--version" {
writeln!(io::stdout(), "arcadia {}", env!("CARGO_PKG_VERSION"))?;
return Ok(0);
}
let json = args.iter().any(|a| a == "--json");
let mut displayed = false;
let report = match arguments(&args) {
Err(error) => Report::failure(Path::new(""), "usage", 2, "usage", error),
Ok(args) => match prepare(&args.entry, args.operation) {
Err(report) => *report,
Ok(prepared) => {
if args.write {
if !args.json {
human_report(prepared.report(), true)?;
displayed = true;
}
prepared.write()
} else {
prepared.into_report()
}
}
},
};
if json {
serde_json::to_writer_pretty(io::stdout().lock(), &report)?;
writeln!(io::stdout())?;
} else {
human_report(&report, !displayed)?;
}
Ok(report.exit_code)
}
fn main() -> ExitCode {
match run() {
Ok(code) => ExitCode::from(code),
Err(error) => {
let _ = writeln!(io::stderr(), "Output failed: {error}");
ExitCode::from(3)
}
}
}
+425
View File
@@ -0,0 +1,425 @@
use oa_cli::{LIMITS, Operation, Prepared, prepare};
use oa_core::{Id, Project, source_files, source_project};
use serde_json::Value;
use std::{
collections::BTreeMap,
fs,
path::{Path, PathBuf},
process::Command,
};
struct Fixture(PathBuf);
impl Fixture {
fn new() -> Self {
let path = std::env::temp_dir().join(format!("arcadia-cli-test-{}", Id::new_v4()));
fs::create_dir(&path).unwrap();
Self(path)
}
fn put(&self, name: &str, text: &str) {
let path = self.0.join(name);
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(path, text).unwrap();
}
fn entry(&self) -> PathBuf {
self.0.join("project.arcadia")
}
fn read(&self, name: &str) -> String {
fs::read_to_string(self.0.join(name)).unwrap()
}
fn prepare(&self, op: Operation) -> Prepared {
prepare(&self.entry(), op).unwrap_or_else(|report| panic!("{report:#?}"))
}
fn model(&self) -> Project {
let disk = source_files::load_project(&self.entry(), LIMITS).unwrap();
source_project::compile_bundle(&disk.reachable_bundle(), LIMITS)
.unwrap()
.decoded
.project
}
fn cli(&self, args: &[&str]) -> (i32, Value) {
let mut command = Command::new(env!("CARGO_BIN_EXE_arcadia"));
command
.arg(args[0])
.arg(self.entry())
.args(&args[1..])
.arg("--json");
let output = command.output().unwrap();
assert!(
output.stderr.is_empty(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
(
output.status.code().unwrap(),
serde_json::from_slice(&output.stdout).unwrap(),
)
}
}
impl Drop for Fixture {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
const SIMPLE: &str = "// 雨: authored defaults stay omitted\r\narcadia 7;\r\nproject rescue \"Rescue\" {\r\n oa_entity team \"Team\" {}\r\n}\r\n";
#[test]
fn complete_alarm_example_preview_then_write_and_fixed_point() {
let fixture = Fixture::new();
let source = include_str!(
"../../../harness/experiments/textual-authoring-20260911/alarm-response.arcadia"
);
fixture.put("project.arcadia", source);
let (code, check) = fixture.cli(&["check"]);
assert_eq!(code, 0);
assert_eq!(check["diagnostics"], serde_json::json!([]));
let (code, preview) = fixture.cli(&["stabilize"]);
assert_eq!(code, 0);
assert_eq!(preview["identities_to_stabilize"], 40);
assert_eq!(preview["status"], "preview");
assert_eq!(fixture.read("project.arcadia"), source);
assert!(
preview["changes"][0]["diff"]
.as_str()
.unwrap()
.contains("+project alarm_response")
);
let (code, written) = fixture.cli(&["stabilize", "--write"]);
assert_eq!(code, 0);
let saved = fixture.read("project.arcadia");
assert_eq!(written["changes"][0]["after"], saved);
let project = fixture.model();
assert_eq!(project.identities().len(), 40);
let mut stripped = saved.clone();
for (id, _) in project.identities() {
stripped = stripped.replace(&format!(" id \"{id}\""), "");
}
assert_eq!(stripped, source, "only identity clauses may be inserted");
let (code, again) = fixture.cli(&["stabilize", "--write"]);
assert_eq!(code, 0);
assert_eq!(again["changes"], serde_json::json!([]));
assert_eq!(again["status"], "unchanged");
assert_eq!(fixture.model(), project);
assert_eq!(fixture.read("project.arcadia"), saved);
}
#[test]
fn captured_candidate_exact_write_preserves_crlf_comments_and_noop_metadata() {
let fixture = Fixture::new();
fixture.put("project.arcadia", SIMPLE);
let candidate = fixture.prepare(Operation::Stabilize);
let expected = candidate.report().changes[0].after.clone();
assert_eq!(candidate.report().identities_to_stabilize, 2);
assert_eq!(fixture.read("project.arcadia"), SIMPLE);
assert_eq!(candidate.write().exit_code, 0);
assert_eq!(fixture.read("project.arcadia"), expected);
assert!(expected.contains("// 雨: authored defaults stay omitted\r\n"));
assert!(!expected.contains("description"));
let metadata = fs::metadata(fixture.entry()).unwrap().modified().unwrap();
let report = fixture.prepare(Operation::Stabilize).write();
assert_eq!(report.status, "unchanged");
assert!(!report.written);
assert_eq!(
fs::metadata(fixture.entry()).unwrap().modified().unwrap(),
metadata
);
assert_eq!(fs::read_dir(&fixture.0).unwrap().count(), 1);
}
#[test]
fn alias_rename_across_modules_changes_only_live_tokens_and_retains_all_identity() {
let fixture = Fixture::new();
fixture.put("project.arcadia", "arcadia 7; project rescue \"Rescue\" { include \"participants.arcadia\"; include \"work.arcadia\"; }\n");
fixture.put(
"participants.arcadia",
"// @team is quoted documentation\noa_entity team \"Team @team\" {}\n",
);
fixture.put("work.arcadia", "oa_activity assess \"Assess\" {}\noa_activity report \"Report\" {}\nallocation assigned \"Assigned\" { source @team; target @assess; }\ninteraction transfer \"Transfer\" { source @assess; target @report; }\n");
assert_eq!(fixture.prepare(Operation::Stabilize).write().exit_code, 0);
let before = fixture.model();
let original: BTreeMap<_, _> = ["project.arcadia", "participants.arcadia", "work.arcadia"]
.into_iter()
.map(|p| (p, fixture.read(p)))
.collect();
let team = before
.identities()
.into_iter()
.find(|(_, alias)| *alias == "team")
.unwrap()
.0;
let candidate = fixture.prepare(Operation::RenameAlias {
old: "team".into(),
new: "rescue_team".into(),
});
assert_eq!(candidate.report().changes.len(), 2);
assert_eq!(fixture.model(), before, "preview cannot mutate");
assert_eq!(candidate.write().exit_code, 0);
let mut expected = before;
expected.elements.get_mut(&team).unwrap().alias = "rescue_team".into();
assert_eq!(fixture.model(), expected);
assert_eq!(fixture.read("project.arcadia"), original["project.arcadia"]);
assert_eq!(
fixture.read("participants.arcadia"),
original["participants.arcadia"].replacen("oa_entity team ", "oa_entity rescue_team ", 1)
);
assert_eq!(
fixture.read("work.arcadia"),
original["work.arcadia"].replace("source @team;", "source @rescue_team;")
);
}
#[test]
fn invalid_unicode_raw_edit_is_located_and_never_stabilized_or_written() {
let fixture = Fixture::new();
let raw = "arcadia 7; project p \"雨\" {\n oa_activity a \"A\" {}\n allocation alloc \"Alloc\" { source @missing; target @a; }\n}\n";
fixture.put("project.arcadia", raw);
for command in [
vec!["check"],
vec!["stabilize", "--write"],
vec!["rename-alias", "a", "b", "--write"],
] {
let (code, report) = fixture.cli(&command);
assert_eq!(code, 1);
assert_eq!(fixture.read("project.arcadia"), raw);
let diagnostics = report["diagnostics"].as_array().unwrap();
assert!(!diagnostics.is_empty());
for d in diagnostics {
assert_eq!(d["coordinate_space"], "input");
let at = &d["location"];
let start = at["start"].as_u64().unwrap() as usize;
assert!(raw.is_char_boundary(start));
assert_eq!(
at["line"].as_u64().unwrap() as usize,
raw[..start].bytes().filter(|b| *b == b'\n').count() + 1
);
let line = raw[..start]
.rsplit_once('\n')
.map_or(&raw[..start], |(_, tail)| tail);
assert_eq!(
at["column"].as_u64().unwrap() as usize,
line.chars().count() + 1
);
}
}
}
#[test]
fn missing_id_warning_uses_raw_location_and_never_exposes_ephemeral_lint_id() {
let fixture = Fixture::new();
let raw = "arcadia 7;\nproject p \"雨\" {\n oa_activity work \"Work\" {}\n}\n";
fixture.put("project.arcadia", raw);
let (code, result) = fixture.cli(&["lint"]);
assert_eq!(code, 0, "engineering warnings do not reject admission");
let warning = result["diagnostics"]
.as_array()
.unwrap()
.iter()
.find(|d| d["code"] == "work_without_behavior_context")
.unwrap();
assert_eq!(warning["severity"], "warning");
assert_eq!(
warning["location"]["start"],
raw.find("oa_activity").unwrap()
);
assert_eq!(warning["location"]["line"], 3);
assert_eq!(warning["location"]["column"], 5);
assert!(warning["object_id"].is_null());
assert_eq!(fixture.read("project.arcadia"), raw);
}
#[test]
fn captured_dependency_change_refuses_all_writes_even_on_noop() {
let fixture = Fixture::new();
fixture.put("project.arcadia", SIMPLE);
let pending = fixture.prepare(Operation::Stabilize);
let external = format!("{SIMPLE}// editor changed this\n");
fixture.put("project.arcadia", &external);
let result = pending.write();
assert_eq!(result.exit_code, 3);
assert_eq!(result.diagnostics.last().unwrap().code, "external_change");
assert_eq!(fixture.read("project.arcadia"), external);
assert_eq!(fixture.prepare(Operation::Stabilize).write().exit_code, 0);
let noop = fixture.prepare(Operation::Stabilize);
assert!(noop.report().changes.is_empty());
let latest = format!("{}// another edit\n", fixture.read("project.arcadia"));
fixture.put("project.arcadia", &latest);
assert_eq!(noop.write().exit_code, 3);
assert_eq!(fixture.read("project.arcadia"), latest);
}
#[test]
fn pending_journal_refuses_read_preview_and_write_without_recovery() {
let fixture = Fixture::new();
fixture.put("project.arcadia", SIMPLE);
assert_eq!(fixture.prepare(Operation::Stabilize).write().exit_code, 0);
let noop = fixture.prepare(Operation::Stabilize);
let source = fixture.read("project.arcadia");
// An opaque incomplete journal must never be interpreted/recovered by CLI.
let journal = fixture.0.join(".arcadia-save-project.arcadia.journal");
fs::create_dir(&journal).unwrap();
fs::write(journal.join("sentinel"), b"retain").unwrap();
for op in [Operation::Check, Operation::Stabilize] {
let report = prepare(&fixture.entry(), op).err().unwrap();
assert_eq!(report.exit_code, 3);
assert!(report.diagnostics.iter().any(|d| d.code == "pending_save"));
}
assert_eq!(
noop.write().exit_code,
3,
"saver must recheck after preview"
);
assert_eq!(fixture.read("project.arcadia"), source);
assert_eq!(fs::read(journal.join("sentinel")).unwrap(), b"retain");
}
#[test]
fn rejected_alias_and_same_alias_preserve_source() {
let fixture = Fixture::new();
fixture.put("project.arcadia", SIMPLE);
assert_eq!(fixture.prepare(Operation::Stabilize).write().exit_code, 0);
let source = fixture.read("project.arcadia");
for (old, new) in [
("team", "rescue"),
("team", "invalid alias"),
("absent", "valid"),
] {
let (code, result) = fixture.cli(&["rename-alias", old, new, "--write"]);
assert_eq!(code, 1);
assert_eq!(result["status"], "rejected");
assert_eq!(fixture.read("project.arcadia"), source);
}
let (code, result) = fixture.cli(&["rename-alias", "team", "team", "--write"]);
assert_eq!(code, 0);
assert_eq!(result["status"], "unchanged");
assert_eq!(fixture.read("project.arcadia"), source);
}
#[test]
fn rename_shows_missing_identity_and_separate_source_migration() {
let fixture = Fixture::new();
let old = "// retain\narcadia 5; project p \"P\" { oa_entity team \"Team\" {} }\n";
fixture.put("project.arcadia", old);
let candidate = fixture.prepare(Operation::RenameAlias {
old: "team".into(),
new: "crew".into(),
});
let report = candidate.report();
assert_eq!(report.identities_to_stabilize, 2);
assert_eq!(report.migration.as_ref().unwrap().from_schema, 5);
assert_eq!(report.migration.as_ref().unwrap().to_schema, 7);
assert!(report.changes[0].diff.contains("-arcadia 5;"));
assert!(report.changes[0].diff.contains("+arcadia 7;"));
assert!(report.changes[0].after.contains("oa_entity crew"));
assert_eq!(fixture.read("project.arcadia"), old);
assert_eq!(candidate.write().exit_code, 0);
}
#[test]
fn entry_is_explicit_and_machine_readable_usage_errors_do_not_write() {
let fixture = Fixture::new();
fixture.put("project.arcadia", SIMPLE);
let output = Command::new(env!("CARGO_BIN_EXE_arcadia"))
.current_dir(&fixture.0)
.args(["check", "--json"])
.output()
.unwrap();
assert_eq!(output.status.code(), Some(2));
let error: Value = serde_json::from_slice(&output.stdout).unwrap();
assert_eq!(error["diagnostics"][0]["code"], "usage");
assert_eq!(fixture.read("project.arcadia"), SIMPLE);
let (code, _) = fixture.cli(&["check", "--write"]);
assert_eq!(code, 2);
assert!(Path::new(env!("CARGO_BIN_EXE_arcadia")).is_file());
}
#[test]
fn loader_encoding_missing_dependency_and_invalid_old_source_keep_original_bytes() {
let fixture = Fixture::new();
let entry = "arcadia 7; project p \"P\" { include \"part.arcadia\"; }";
fixture.put("project.arcadia", entry);
let (code, report) = fixture.cli(&["stabilize", "--write"]);
assert_eq!(code, 3, "a disk dependency could not be read");
assert!(
report["diagnostics"]
.as_array()
.unwrap()
.iter()
.any(|d| d["code"] == "missing_include")
);
fs::write(fixture.0.join("part.arcadia"), [0xff, 0xfe]).unwrap();
let (code, report) = fixture.cli(&["stabilize", "--write"]);
assert_eq!(code, 1, "invalid encoding is invalid raw source");
assert!(
report["diagnostics"]
.as_array()
.unwrap()
.iter()
.any(|d| d["code"] == "source_encoding")
);
assert_eq!(fixture.read("project.arcadia"), entry);
assert_eq!(
fs::read(fixture.0.join("part.arcadia")).unwrap(),
[0xff, 0xfe]
);
for raw in [
"arcadia 5; project p \"P\" { oa_entity a \"A\" { ??? } }",
"arcadia 2; project p \"P\" {}",
] {
fixture.put("project.arcadia", raw);
let (code, _) = fixture.cli(&["stabilize", "--write"]);
assert_eq!(code, 1);
assert_eq!(fixture.read("project.arcadia"), raw);
}
}
#[test]
fn non_element_alias_detached_file_and_unchanged_dependency_conflict() {
let fixture = Fixture::new();
fixture.put("project.arcadia", "arcadia 7; project p \"P\" { include \"part.arcadia\"; oa_activity a \"A\" {} allocation responsible \"Responsible\" { source @ // keep edge comment\nteam; target @a; } }");
fixture.put("part.arcadia", "oa_entity team \"Team\" {}");
fixture.put(
"detached.arcadia",
"invalid detached draft must stay exact 雨",
);
assert_eq!(fixture.prepare(Operation::Stabilize).write().exit_code, 0);
let before = fixture.model();
let relation = before
.identities()
.into_iter()
.find(|(_, alias)| *alias == "responsible")
.unwrap()
.0;
let (code, _) = fixture.cli(&["rename-alias", "responsible", "performs", "--write"]);
assert_eq!(code, 0);
let mut expected = before;
expected.relations.get_mut(&relation).unwrap().alias = "performs".into();
assert_eq!(fixture.model(), expected);
assert!(
fixture
.read("project.arcadia")
.contains("@ // keep edge comment\nteam")
);
let captured = fixture.prepare(Operation::RenameAlias {
old: "p".into(),
new: "rescue".into(),
});
assert!(
captured
.report()
.changes
.iter()
.all(|c| c.path != "part.arcadia")
);
let root_before = fixture.read("project.arcadia");
fixture.put("part.arcadia", "// external unchanged-dependency edit\n");
assert_eq!(captured.write().exit_code, 3);
assert_eq!(fixture.read("project.arcadia"), root_before);
assert_eq!(
fixture.read("part.arcadia"),
"// external unchanged-dependency edit\n"
);
assert_eq!(
fixture.read("detached.arcadia"),
"invalid detached draft must stay exact 雨"
);
}
+3
View File
@@ -12,3 +12,6 @@ uuid.workspace = true
thiserror.workspace = true
tempfile = "3"
same-file = "1"
[target.'cfg(target_os = "linux")'.dependencies]
libc = "0.2"
+1
View File
@@ -0,0 +1 @@
include!("../../build/windows_manifest.rs");
@@ -0,0 +1,53 @@
//! Generate only the new teaching-project files; old compatibility examples stay untouched.
#[path = "../../../examples/release1/model.rs"]
mod teaching;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let directory = std::env::args_os()
.nth(1)
.map(std::path::PathBuf::from)
.unwrap_or_else(|| "examples/release1".into());
let lesson = teaching::build();
let bundle = teaching::checked_bundle(&lesson.project);
for (name, source) in &bundle.files {
let path = directory.join(name);
std::fs::create_dir_all(path.parent().unwrap())?;
std::fs::write(path, source)?;
}
for (name, value) in [
("model.json", serde_json::to_value(&lesson.project)?),
(
"transition-before.json",
serde_json::to_value(&lesson.before_transition)?,
),
(
"transition-request.json",
serde_json::to_value(&lesson.request)?,
),
(
"expected-diagnostics.json",
serde_json::to_value(oa_core::validation::validate(&lesson.project))?,
),
] {
std::fs::write(
directory.join(name),
serde_json::to_string_pretty(&value)? + "\n",
)?;
}
println!(
"Validated {} elements, {} relations, {} views, {} traces; wrote {} source files to {}",
lesson.project.elements.len(),
lesson.project.relations.len(),
lesson.project.diagrams.len(),
lesson.project.traces.len(),
bundle.files.len(),
directory.display()
);
for issue in oa_core::validation::validate(&lesson.project) {
println!(
"{:?} {} {:?}: {}",
issue.severity, issue.code, issue.element, issue.message
);
}
Ok(())
}
@@ -0,0 +1,442 @@
//! Deterministic dense-view fixture and semantic oracle. No GUI or authored routing.
//! cargo run -p oa-core --example release_dense_views -- OUTPUT_DIRECTORY
use oa_core::{view_options::*, *};
use serde_json::json;
use std::{
collections::{BTreeMap, BTreeSet},
error::Error,
fs,
path::PathBuf,
time::Instant,
};
fn id(n: u32) -> Id {
format!("d4060906-0000-4000-8000-{n:012x}").parse().unwrap()
}
fn element(p: &mut Project, n: u32, kind: Kind, alias: &str, name: &str, parent: Option<Id>) -> Id {
let identity = id(n);
assert!(
p.elements
.insert(
identity,
Element {
id: identity,
alias: alias.into(),
name: name.into(),
description: String::new(),
parent,
body: ElementBody::new(kind, Layer::Oa).unwrap()
}
)
.is_none()
);
identity
}
fn relation(p: &mut Project, n: u32, alias: &str, name: &str, body: RelationBody) -> Id {
let identity = id(n);
assert!(
p.relations
.insert(
identity,
Relation {
id: identity,
alias: alias.into(),
name: name.into(),
description: String::new(),
body
}
)
.is_none()
);
identity
}
fn fixture() -> (Project, Id, Vec<Id>, Vec<Id>, Vec<Id>) {
let mut p = Project::empty();
p.id = id(1);
p.alias = "dense_service_views".into();
p.name = "Forty service exchanges".into();
p.description = "Deterministic release fixture. Forty actual work exchanges share two endpoints; no authored routes or synthetic endpoints.".into();
let requester = element(&mut p, 10, Kind::Entity, "requester", "Requester", None);
let provider = element(&mut p, 11, Kind::Entity, "provider", "Provider", None);
let a = element(
&mut p,
20,
Kind::Activity,
"request_service",
"Request service",
None,
);
let b = element(
&mut p,
21,
Kind::Activity,
"provide_service",
"Provide service",
None,
);
relation(
&mut p,
3000,
"requester_work",
"Requester responsibility",
RelationBody::Allocation {
source: requester,
target: a,
},
);
relation(
&mut p,
3001,
"provider_work",
"Provider responsibility",
RelationBody::Allocation {
source: provider,
target: b,
},
);
let group_names = ["Commands", "Status", "Alerts", "Measurements"];
let items: Vec<_> = group_names
.iter()
.enumerate()
.map(|(i, name)| {
element(
&mut p,
100 + i as u32,
Kind::ExchangeItem,
&format!("payload_{}", i + 1),
&format!("{name} payload"),
None,
)
})
.collect();
let exchanges: Vec<_> = (0..40)
.map(|i| {
relation(
&mut p,
2000 + i,
&format!("service_data_{:02}", i + 1),
&format!("Service data {:02}", i + 1),
RelationBody::Interaction {
source: a,
target: b,
items: vec![items[(i / 10) as usize]],
},
)
})
.collect();
let mut categories = vec![];
for (i, name) in group_names.iter().enumerate() {
let category = element(
&mut p,
200 + i as u32,
Kind::ExchangeCategory,
&format!("concern_{}", i + 1),
name,
None,
);
*p.elements.get_mut(&category).unwrap().shared_mut().unwrap() =
SharedBody::ExchangeCategory {
family: ExchangeFamily::Work,
exchanges: exchanges[i * 10..i * 10 + 10].to_vec(),
};
categories.push(category);
}
let overlapping = element(
&mut p,
204,
Kind::ExchangeCategory,
"critical_concern",
"Critical cross-cutting exchanges",
None,
);
*p.elements
.get_mut(&overlapping)
.unwrap()
.shared_mut()
.unwrap() = SharedBody::ExchangeCategory {
family: ExchangeFamily::Work,
exchanges: [0, 10, 20, 30].map(|i| exchanges[i]).to_vec(),
};
categories.push(overlapping);
let mut contexts = vec![];
for (group, name) in group_names[..3].iter().enumerate() {
let process = element(
&mut p,
300 + group as u32,
Kind::Process,
&format!("process_{}", group + 1),
&format!("{name} process"),
None,
);
for offset in 0..10 {
let index = group * 10 + offset;
let occurrence = element(
&mut p,
1000 + index as u32,
Kind::Occurrence,
&format!("use_data_{:02}", index + 1),
&format!("Use service data {:02}", index + 1),
Some(process),
);
*p.elements
.get_mut(&occurrence)
.unwrap()
.shared_mut()
.unwrap() = SharedBody::Occurrence {
body: OccurrenceBody::Exchange {
operation: None,
exchange: Some(exchanges[index]),
sender: None,
receiver: None,
message_kind: MessageKind::Asynchronous,
reply_to: None,
values: vec![],
},
precondition: String::new(),
postcondition: String::new(),
};
p.elements
.get_mut(&process)
.unwrap()
.steps_mut()
.unwrap()
.push(occurrence);
}
contexts.push(process);
}
let diagram = id(4000);
let nodes = [(a, 80.), (b, 760.)]
.into_iter()
.enumerate()
.map(|(i, (element, x))| {
let id = id(4100 + i as u32);
(
id,
NodePlacement {
id,
alias: format!("service_placement_{}", i + 1),
element,
position: Position { x, y: 160. },
size: Size {
width: 230.,
height: 150.,
},
collapsed: false,
port_anchors: vec![],
},
)
})
.collect();
p.diagrams.insert(diagram, Diagram { id: diagram, alias: "forty_exchange_view".into(), name: "Forty service exchanges · all concerns".into(), description: "Start with All. Compare first category, overlapping concerns, global coverage and chosen processes. No hand-routed edges.".into(), layer: Layer::Oa, projection: ProjectionKind::FunctionalDataflow, view_options: ViewOptions::default(), nodes, routes: vec![] });
(p, diagram, exchanges, categories, contexts)
}
fn projected(p: &Project, diagram: &Diagram) -> BTreeSet<Id> {
projection::project_diagram(p, diagram)
.edges
.into_iter()
.map(|edge| edge.id)
.collect()
}
fn overlap(a: routing::RoutingBounds, b: routing::RoutingBounds) -> bool {
a.position.x < b.position.x + b.size.width
&& b.position.x < a.position.x + a.size.width
&& a.position.y < b.position.y + b.size.height
&& b.position.y < a.position.y + a.size.height
}
fn main() -> Result<(), Box<dyn Error>> {
let output = PathBuf::from(
std::env::args_os()
.nth(1)
.ok_or("Supply an output directory for the dense-view fixture.")?,
);
let (p, diagram, exchanges, categories, contexts) = fixture();
let issues = validation::validate(&p);
assert!(
!issues
.iter()
.any(|issue| issue.severity == validation::Severity::Error),
"{issues:?}"
);
let pristine = p.clone();
let original = &p.diagrams[&diagram];
assert!(original.routes.is_empty());
let all = projection::project_view(&p, diagram);
assert_eq!(all.nodes.len(), 2);
assert_eq!(all.edges.len(), 40);
assert_eq!(
all.edges
.iter()
.map(|edge| (edge.source, edge.target))
.collect::<BTreeSet<_>>()
.len(),
1
);
let sets = |ids: &[Id]| ids.iter().copied().collect::<BTreeSet<_>>();
let cases = vec![
("all", ViewOptions::default(), sets(&exchanges)),
(
"first_category",
ViewOptions {
work_categories: CategorySelection::Any(vec![categories[0]]),
..ViewOptions::default()
},
sets(&exchanges[..10]),
),
(
"first_and_overlap",
ViewOptions {
work_categories: CategorySelection::Any(vec![categories[0], categories[4]]),
..ViewOptions::default()
},
exchanges[..10]
.iter()
.copied()
.chain([exchanges[10], exchanges[20], exchanges[30]])
.collect(),
),
(
"in_behavior",
ViewOptions {
coverage: Coverage::InBehavior,
..ViewOptions::default()
},
sets(&exchanges[..30]),
),
(
"outside_behavior",
ViewOptions {
coverage: Coverage::OutsideBehavior,
..ViewOptions::default()
},
sets(&exchanges[30..]),
),
(
"second_process",
ViewOptions {
work_contexts: ContextSelection::Any(vec![contexts[1]]),
..ViewOptions::default()
},
sets(&exchanges[10..20]),
),
(
"outside_second_process",
ViewOptions {
coverage: Coverage::OutsideBehavior,
work_contexts: ContextSelection::Any(vec![contexts[1]]),
..ViewOptions::default()
},
BTreeSet::new(),
),
(
"empty_categories",
ViewOptions {
work_categories: CategorySelection::Any(vec![]),
..ViewOptions::default()
},
BTreeSet::new(),
),
(
"empty_contexts",
ViewOptions {
work_contexts: ContextSelection::Any(vec![]),
..ViewOptions::default()
},
BTreeSet::new(),
),
(
"no_lines",
ViewOptions {
relation_kinds: RelationKinds::Only(vec![]),
..ViewOptions::default()
},
BTreeSet::new(),
),
(
"first_hide_05",
ViewOptions {
hidden_edges: vec![exchanges[4]],
work_categories: CategorySelection::Any(vec![categories[0]]),
exchange_labels: ExchangeLabels::NameAndItems,
..ViewOptions::default()
},
exchanges[..10]
.iter()
.filter(|edge| **edge != exchanges[4])
.copied()
.collect(),
),
];
let mut case_results = BTreeMap::new();
for (name, options, expected) in cases {
assert!(validate_options(&p, Layer::Oa, &options).is_empty());
let mut changed = original.clone();
changed.view_options = options;
assert_eq!(projected(&p, &changed), expected, "{name}");
let view = projection::project_diagram(&p, &changed);
assert_eq!(
view.nodes, all.nodes,
"{name}: visibility must not move nodes"
);
assert_eq!(
view.ports, all.ports,
"{name}: visibility must not move ports"
);
assert!(changed.routes.is_empty());
case_results.insert(name, json!({"count":expected.len(),"edges":expected}));
}
assert_eq!(
ExchangeLabels::Items.label(&p, exchanges[0]),
"Commands payload"
);
assert_eq!(
ExchangeLabels::NameAndItems.label(&p, exchanges[39]),
"Service data 40 · Measurements payload"
);
let began = Instant::now();
let routed = routing::route_diagram(&p, original, &all);
let route_ms = began.elapsed().as_millis();
assert_eq!(routed.edges.len(), 40);
let edges: Vec<_> = routed.edges.values().collect();
let mut label_overlaps = vec![];
let mut coincident_paths = vec![];
for (i, first) in edges.iter().enumerate() {
for second in &edges[i + 1..] {
if overlap(first.label.bounds, second.label.bounds) {
label_overlaps.push([first.id, second.id]);
}
if first.points == second.points {
coincident_paths.push([first.id, second.id]);
}
}
}
let source = language::format(&p);
assert_eq!(
language::parse(&source).expect("Canonical fixture source must reopen"),
p
);
let native_json = serde_json::to_vec_pretty(&p)?;
assert_eq!(persistence::decode(&native_json)?, p);
assert_eq!(
p, pristine,
"Oracles and routing must not mutate model facts"
);
let report = json!({"schema":p.schema_version,"project":p.id,"diagram":diagram,"semantic_oracles":"passed","elements":p.elements.len(),"relations":p.relations.len(),"cases":case_results,
"routing_ms":route_ms,"label_overlap_pairs":label_overlaps,"exactly_coincident_path_pairs":coincident_paths,
"routing_warnings":edges.iter().map(|edge| json!({"edge":edge.id,"warnings":edge.warnings.iter().map(ToString::to_string).collect::<Vec<_>>()})).collect::<Vec<_>>(),
"native_readability":"pending; metrics do not establish a usability pass"});
fs::create_dir_all(&output)?;
fs::write(output.join("dense-40.arcadia"), source)?;
fs::write(output.join("dense-40.json"), native_json)?;
fs::write(
output.join("dense-40-oracle.json"),
serde_json::to_vec_pretty(&report)?,
)?;
println!("{}", serde_json::to_string_pretty(&report)?);
Ok(())
}
+2
View File
@@ -15,6 +15,7 @@ fn occurrence(
let id = add(p, Kind::Occurrence, layer, name, Some(owner));
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::Occurrence {
body: OccurrenceBody::Exchange {
operation: None,
exchange: Some(exchange),
sender,
receiver,
@@ -78,6 +79,7 @@ fn diagram(
description: "A focused projection of the shared engineering model.".into(),
layer,
projection,
view_options: Default::default(),
nodes: BTreeMap::new(),
routes: vec![],
},
+213 -35
View File
@@ -84,7 +84,20 @@ impl ParseLimits {
.elements
.values()
.map(|e| e.steps().map_or(0, Vec::len))
.sum();
.sum::<usize>()
.saturating_add(
project
.relations
.values()
.map(|r| match &r.body {
RelationBody::ContextualLink { source, target, .. } => source
.references
.len()
.saturating_add(target.references.len()),
_ => 0,
})
.sum::<usize>(),
);
self.check_counts(graph, records, steps)
}
}
@@ -129,12 +142,87 @@ pub struct Analysis {
pub declarations: Vec<DeclarationSpan>,
}
/// Check the decoded spelling required by declaration-header UUID clauses.
/// This is the shared 36-byte/hyphen shape rule, not a hexadecimal parser:
/// callers must additionally parse the UUID. Uppercase hex and JSON escapes
/// that decode to this spelling remain accepted. URN/braced/compact forms do not.
pub fn has_uuid_header_shape(decoded: &str) -> bool {
decoded.len() == 36
&& [8, 13, 18, 23]
.into_iter()
.all(|index| decoded.as_bytes()[index] == b'-')
}
/// Budgets for one original source buffer. Policy is caller-owned; these
/// limits do not change the grammar or perform model admission.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct OutlineLimits {
pub max_source_bytes: usize,
pub max_tokens: usize,
pub max_declarations: usize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OutlineResource {
SourceBytes,
Tokens,
Declarations,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct OutlineLimit {
pub resource: OutlineResource,
pub limit: usize,
pub observed_at_least: usize,
/// Original byte offset where admission stopped; never a rewritten offset.
pub offset: usize,
}
/// Roles discovered by the existing tolerant declaration walk. Keeping the
/// wrapper preserves DeclarationSpan and Analysis as existing public APIs.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OutlineDeclaration {
pub declaration: DeclarationSpan,
pub kind_span: Span,
pub alias_span: Span,
/// Whole quoted header ID token, even if it is not a valid UUID. In that
/// case declaration.id is None. Missing/non-string ID values have no span.
pub id_literal_span: Option<Span>,
}
/// A full-file tolerant scan, not a successful parse or validated model. Invalid
/// text remains available. Lexical errors and unmatched braces are facts only:
/// their absence does not establish syntactic or semantic validity.
///
/// A budget failure returns Err and no partial Outline. Include reachability,
/// project completeness and editor revision are intentionally not inferred here.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Outline<'a> {
pub source: &'a str,
pub tokens: Vec<TokenSpan>,
pub declarations: Vec<OutlineDeclaration>,
pub has_lexical_errors: bool,
pub has_unmatched_braces: bool,
}
/// Tolerant highlighting tokens cover the whole input, including incomplete
/// strings and unexpected characters. No model is created or modified.
pub fn tokenize(source: &str) -> Vec<TokenSpan> {
tokenize_with_limit(source, usize::MAX).expect("unlimited token scan")
}
fn tokenize_with_limit(source: &str, max_tokens: usize) -> Result<Vec<TokenSpan>, OutlineLimit> {
let mut tokens = Vec::new();
let mut cursor = 0;
while cursor < source.len() {
if tokens.len() == max_tokens {
return Err(OutlineLimit {
resource: OutlineResource::Tokens,
limit: max_tokens,
observed_at_least: tokens.len().saturating_add(1),
offset: cursor,
});
}
let start = cursor;
let ch = source[cursor..].chars().next().expect("in bounds");
let class = if matches!(ch, ' ' | '\t' | '\r' | '\n') {
@@ -211,7 +299,7 @@ pub fn tokenize(source: &str) -> Vec<TokenSpan> {
span: Span { start, end: cursor },
});
}
tokens
Ok(tokens)
}
pub fn lint(source: &str) -> Vec<Diagnostic> {
@@ -343,6 +431,10 @@ fn reference_field(key: &str) -> bool {
| "local"
| "field"
| "path"
| "references"
| "occurrence"
| "allocation"
| "work"
| "supertype"
| "sender"
| "receiver"
@@ -350,6 +442,9 @@ fn reference_field(key: &str) -> bool {
| "lifeline"
| "state"
| "constrained"
| "exchanges"
| "hidden_edges"
| "Any"
| "source"
| "target"
| "source_port"
@@ -1210,11 +1305,7 @@ impl Parser<'_> {
self.cursor += 1;
let offset = self.current().span.start;
let value = self.quoted()?;
if value.len() != 36
|| ![8, 13, 18, 23]
.into_iter()
.all(|i| value.as_bytes()[i] == b'-')
{
if !has_uuid_header_shape(&value) {
return Err(Error {
code: "invalid_source",
offset,
@@ -1383,9 +1474,10 @@ impl Parser<'_> {
fn unit(&mut self, entry: bool, includes_allowed: bool) -> Result<SourceUnit, Error> {
let mut header = if entry {
self.take("arcadia")?;
if !matches!(self.text(), "3" | "4" | "5") {
return Err(self
.error("Unsupported schema version: expected 'arcadia 3;', 'arcadia 4;' or 'arcadia 5;'."));
if !matches!(self.text(), "3" | "4" | "5" | "6" | "7") {
return Err(
self.error("Unsupported schema version: expected arcadia 3, 4, 5, 6 or 7.")
);
}
let schema_version = self.text().parse().expect("checked source version");
self.cursor += 1;
@@ -1603,6 +1695,10 @@ pub(crate) fn resolve_declarations(
};
match d.kind {
DeclKind::Element(kind, layer) => {
if project.schema_version < 7 { crate::persistence::reject_source7_fields(&Value::Object(fields.clone())).map_err(&conversion_error)?; }
if project.schema_version < 6 && !crate::persistence::pre6_element_kind(kind) {
return Err(conversion_error(std::format!("{kind:?} requires schema/source 6.")));
}
if legacy && !crate::persistence::v3::element_allowed(serde_json::to_value(kind).unwrap().as_str().unwrap()) {
return Err(conversion_error("Element constructor was not part of schema3.".into()));
}
@@ -1677,12 +1773,18 @@ pub(crate) fn resolve_declarations(
);
}
DeclKind::Relation(kind) => {
if project.schema_version < 7 && kind == RelationKind::ContextualLink { return Err(conversion_error("ContextualLink requires schema/source 7.".into())); }
if project.schema_version < 6 && !crate::persistence::pre6_relation_kind(kind) {
return Err(conversion_error(std::format!("{kind:?} requires schema/source 6.")));
}
if legacy && !crate::persistence::v3::relation_allowed(serde_json::to_value(kind).unwrap().as_str().unwrap()) {
return Err(conversion_error("Relation constructor was not part of schema3.".into()));
}
let description =
take_string(&mut fields, "description", "").map_err(&conversion_error)?;
let required: &[&str] = if kind == RelationKind::FunctionalExchange {
let required: &[&str] = if kind == RelationKind::ContextualLink {
&["owner", "source", "target", "purpose"]
} else if kind == RelationKind::FunctionalExchange {
&["source", "target", "source_port", "target_port"]
} else {
&["source", "target"]
@@ -1713,8 +1815,15 @@ pub(crate) fn resolve_declarations(
);
}
DeclKind::Diagram => {
if project.schema_version < 7 { crate::persistence::reject_source7_fields(&Value::Object(fields.clone())).map_err(&conversion_error)?; }
if project.schema_version < 6 && fields.contains_key("view_options") {
return Err(conversion_error("Diagram.view_options requires schema/source 6, including an explicitly supplied default value.".into()));
}
if project.schema_version < 6 && let Some(key)=fields.keys().find(|key| !matches!(key.as_str(),"description"|"layer"|"projection"|"routes")) {
return Err(conversion_error(std::format!("Unknown diagram field '{key}' in the frozen pre6 contract.")));
}
let defaults =
serde_json::json!({"description":"","layer":"Oa","projection":"Overview","routes":[]});
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)
.map_err(&conversion_error)?;
header(&mut record, d, true);
@@ -1837,8 +1946,15 @@ pub(crate) fn resolve_declarations(
}
return Ok(migrated);
}
if project.schema_version == 4 {
return crate::persistence::v4::finish(project, limits).map_err(|error| {
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 {
crate::persistence::migrate_v6(project, limits)
})
.map_err(|error| {
(
usize::MAX,
Error {
@@ -1917,8 +2033,49 @@ fn take_required_id(fields: &mut Map<String, Value>, key: &str) -> Result<Id, St
/// Analyze an incomplete source buffer without adopting a candidate. Declaration
/// spans remain available even when a later declaration contains errors.
/// Invalid compact/braced/URN header IDs retain no authored identity, matching
/// the declaration parser rather than the UUID crate's broader accepted forms.
pub fn analyze(source: &str) -> Analysis {
let tokens = tokenize(source);
let (declarations, _) =
declaration_outline(source, &tokens, usize::MAX).expect("unlimited declaration walk");
Analysis {
tokens,
diagnostics: lint(source),
declarations: declarations.into_iter().map(|d| d.declaration).collect(),
}
}
/// Read exact current text without lint, parsing a model, stabilization or I/O.
/// Source bytes are admitted before token allocation; token/declaration limits
/// are checked before lexing the next token or materializing the next record.
pub fn outline(source: &str, limits: OutlineLimits) -> Result<Outline<'_>, OutlineLimit> {
if source.len() > limits.max_source_bytes {
return Err(OutlineLimit {
resource: OutlineResource::SourceBytes,
limit: limits.max_source_bytes,
observed_at_least: source.len(),
offset: 0,
});
}
let tokens = tokenize_with_limit(source, limits.max_tokens)?;
let has_lexical_errors = tokens.iter().any(|token| token.class == TokenClass::Error);
let (declarations, has_unmatched_braces) =
declaration_outline(source, &tokens, limits.max_declarations)?;
Ok(Outline {
source,
tokens,
declarations,
has_lexical_errors,
has_unmatched_braces,
})
}
fn declaration_outline(
source: &str,
tokens: &[TokenSpan],
max_declarations: usize,
) -> Result<(Vec<OutlineDeclaration>, bool), OutlineLimit> {
let significant = tokens
.iter()
.filter(|t| !matches!(t.class, TokenClass::Whitespace | TokenClass::Comment))
@@ -1927,17 +2084,21 @@ pub fn analyze(source: &str) -> Analysis {
// again would make tolerant navigation quadratic on deeply nested drafts.
let mut body_ends = vec![None; significant.len()];
let mut bodies = Vec::new();
let mut unmatched_close = false;
for (index, token) in significant.iter().enumerate() {
match &source[token.span.start..token.span.end] {
"{" => bodies.push(index),
"}" => {
if let Some(open) = bodies.pop() {
body_ends[open] = Some(token.span.end);
} else {
unmatched_close = true;
}
}
_ => {}
}
}
let has_unmatched_braces = unmatched_close || !bodies.is_empty();
let mut declarations = Vec::new();
for (i, t) in significant.iter().enumerate() {
let word = &source[t.span.start..t.span.end];
@@ -1962,7 +2123,16 @@ pub fn analyze(source: &str) -> Analysis {
}
j += 1;
}
if declarations.len() == max_declarations {
return Err(OutlineLimit {
resource: OutlineResource::Declarations,
limit: max_declarations,
observed_at_least: declarations.len().saturating_add(1),
offset: t.span.start,
});
}
let mut id = None;
let mut id_literal_span = None;
if significant
.get(j)
.is_some_and(|t| &source[t.span.start..t.span.end] == "id")
@@ -1970,39 +2140,51 @@ pub fn analyze(source: &str) -> Analysis {
if let Some(value) = significant.get(j + 1)
&& value.class == TokenClass::String
{
id_literal_span = Some(value.span);
let text =
serde_json::from_str::<String>(&source[value.span.start..value.span.end])
.unwrap();
id = Id::parse_str(&text).ok();
if has_uuid_header_shape(&text) {
id = Id::parse_str(&text).ok();
}
}
j += 2;
}
let mut end = significant
.get(j)
.map_or(alias_token.span.end, |t| t.span.end);
// A recognized header ID at EOF must remain inside the whole span.
// Keep the old next-token/body behavior; only the EOF fallback changes.
let header_end = significant
.get(j.saturating_sub(1))
.or_else(|| significant.last())
.map_or(alias_token.span.end, |token| token.span.end);
let mut end = significant.get(j).map_or(header_end, |t| t.span.end);
if significant
.get(j)
.is_some_and(|t| &source[t.span.start..t.span.end] == "{")
{
end = body_ends[j].unwrap_or(source.len());
}
declarations.push(DeclarationSpan {
alias: alias.into(),
id,
kind: word.into(),
span: Span {
start: t.span.start,
end,
declarations.push(OutlineDeclaration {
declaration: DeclarationSpan {
alias: alias.into(),
id,
kind: word.into(),
span: Span {
start: t.span.start,
end,
},
},
kind_span: t.span,
alias_span: alias_token.span,
id_literal_span,
});
}
Analysis {
tokens,
diagnostics: lint(source),
declarations,
}
Ok((declarations, has_unmatched_braces))
}
#[cfg(test)]
#[path = "language_outline_tests.rs"]
mod outline_tests;
#[cfg(test)]
#[path = "language_nesting_tests.rs"]
mod nesting_tests;
@@ -2397,11 +2579,7 @@ pub(crate) mod legacy {
let value = self.quoted()?;
// Require the portable 8-4-4-4-12 spelling, not the UUID crate's URN,
// braces, or compact alternatives. Uppercase hexadecimal is accepted.
if value.len() != 36
|| ![8, 13, 18, 23]
.into_iter()
.all(|index| value.as_bytes()[index] == b'-')
{
if !super::has_uuid_header_shape(&value) {
return Err(Error {
code: "invalid_source",
offset,
@@ -109,8 +109,16 @@ pub(super) fn resolve_baseline(
continue;
}
let json = raw(value)?;
if version < 7 {
crate::persistence::reject_source7_fields(&json)
.map_err(|cause| error(value, cause))?;
}
let baseline: SemanticBaseline = serde_json::from_value(json.clone())
.map_err(|cause| error(value, format!("Invalid semantic baseline: {cause}")))?;
if version < 6 {
crate::persistence::check_pre6_baseline(&baseline)
.map_err(|cause| error(value, cause.to_string()))?;
}
let slots = historical_source_slots(&baseline).into_iter().collect();
validate_tokens(value, &mut vec![], &slots)?;
resolved.insert(key.clone(), json);
+33 -18
View File
@@ -402,6 +402,37 @@ fn checked_in_source_project() -> SourceBundle {
}
}
fn assert_architecture_example_warnings(compiled: &crate::source_project::CompiledSourceProject) {
// This architecture-only example intentionally has no process/scenario
// context. Preserve its explicit incompleteness instead of inventing work.
let expected: std::collections::BTreeSet<_> = [
("unspecified_exchange_mechanism", "oa_report_data"),
("unspecified_exchange_mechanism", "sa_report_data"),
("work_without_behavior_context", "report_distress"),
("work_without_behavior_context", "assess_incident"),
("work_without_behavior_context", "transmit_report"),
("work_without_behavior_context", "assess_report"),
("work_without_behavior_context", "review_response"),
("exchange_without_behavior_context", "distress_signal"),
("exchange_without_behavior_context", "validated_report"),
("capability_without_behavior_context", "protect_life"),
("capability_without_behavior_context", "coordinate_response"),
]
.into_iter()
.map(|(code, alias)| (code, Some(identity(&compiled.decoded.project, alias))))
.collect();
let actual: std::collections::BTreeSet<_> = compiled
.diagnostics
.iter()
.map(|issue| {
assert_eq!(issue.diagnostic.severity, validation::Severity::Warning);
(issue.diagnostic.code.as_str(), issue.diagnostic.object_id)
})
.collect();
assert_eq!(actual, expected);
assert_eq!(compiled.diagnostics.len(), expected.len());
}
#[test]
fn checked_in_nested_example_has_stable_ids_typed_exchanges_and_three_clear_views() {
let input = checked_in_source_project();
@@ -428,15 +459,7 @@ fn checked_in_nested_example_has_stable_ids_typed_exchanges_and_three_clear_view
let reopened = compile_bundle(&compiled.bundle, ProjectLimits::default()).unwrap();
assert_eq!(reopened.bundle, compiled.bundle);
assert!(reopened.decoded.migration.is_none());
assert_eq!(compiled.diagnostics.len(), 2);
assert!(
compiled
.diagnostics
.iter()
.all(|issue| issue.diagnostic.code == "unspecified_exchange_mechanism"),
"{:?}",
compiled.diagnostics
);
assert_architecture_example_warnings(&compiled);
let project = &compiled.decoded.project;
assert_eq!(project.diagrams.len(), 3);
let assess = identity(project, "assess_report");
@@ -495,15 +518,7 @@ fn checked_in_example_supports_cross_file_reparent_without_changing_allocations(
ProjectLimits::default(),
)
.unwrap();
assert_eq!(result.diagnostics.len(), 2);
assert!(
result
.diagnostics
.iter()
.all(|issue| issue.diagnostic.code == "unspecified_exchange_mechanism"),
"{:?}",
result.diagnostics
);
assert_architecture_example_warnings(&result);
assert_eq!(
result.index.declarations[&assess].path,
"sa/assurance.arcadia"
@@ -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);
}
+3
View File
@@ -2,6 +2,7 @@
pub mod data;
pub mod language;
pub mod model;
pub mod operations;
pub mod persistence;
pub mod ports;
pub mod projection;
@@ -11,8 +12,10 @@ pub mod semantic;
pub mod session;
pub mod source_files;
pub mod source_project;
pub mod topology_context;
pub mod transitions;
pub mod validation;
pub mod view_options;
pub use model::*;
pub use ports::*;
pub use semantic::*;
+4
View File
@@ -67,6 +67,8 @@ impl Project4 {
if self.schema_version != 4 {
return Err(FileError::Invalid("Expected schema_version 4.".into()));
}
crate::persistence::check_pre6_relations(self.relations.values())?;
crate::persistence::check_pre6_elements(self.elements.values())?;
Ok(Project {
schema_version: SCHEMA_VERSION,
id: self.id,
@@ -102,6 +104,8 @@ pub(crate) fn review_defaults(project: &Project, report: &mut MigrationReport) {
}
pub(crate) fn finish(mut project: Project, limits: ParseLimits) -> Result<DecodeResult, FileError> {
crate::persistence::check_pre6_relations(project.relations.values())?;
crate::persistence::check_pre6_elements(project.elements.values())?;
project.schema_version = SCHEMA_VERSION;
limits.check_model(&project).map_err(FileError::Limit)?;
let mut report = MigrationReport::data_upgrade(4);
+111 -6
View File
@@ -6,7 +6,7 @@ use std::{
use uuid::Uuid;
pub type Id = Uuid;
pub const SCHEMA_VERSION: u32 = 5;
pub const SCHEMA_VERSION: u32 = 7;
pub const MAX_COORDINATE: f32 = 1_000_000.;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
@@ -39,6 +39,7 @@ pub enum Kind {
Mission,
FunctionalChain,
ExchangeItem,
ExchangeCategory,
DataType,
DataField,
Interface,
@@ -65,7 +66,7 @@ pub enum Kind {
Constraint,
}
impl Kind {
pub const ALL: [Self; 38] = [
pub const ALL: [Self; 39] = [
Self::Entity,
Self::Actor,
Self::Role,
@@ -80,6 +81,7 @@ impl Kind {
Self::Mission,
Self::FunctionalChain,
Self::ExchangeItem,
Self::ExchangeCategory,
Self::DataType,
Self::DataField,
Self::Interface,
@@ -121,6 +123,7 @@ impl Kind {
Self::Mission => "Mission",
Self::FunctionalChain => "Functional chain",
Self::ExchangeItem => "Exchange item",
Self::ExchangeCategory => "Exchange category",
Self::DataType => "Data type",
Self::DataField => "Data field",
Self::Interface => "Interface",
@@ -520,6 +523,28 @@ pub struct MessageValue {
pub field: Id,
pub expression: String,
}
/// Explicit operation intent. A missing allocation remains an unfinished operation,
/// whereas an absent OperationUse retains ordinary channel-message semantics.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct OperationUse {
pub allocation: Option<Id>,
}
/// A contribution in a caller instance. Paths contain reference occurrence IDs,
/// never sequence definitions; repeated references therefore remain distinct.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct OccurrenceEndpoint {
pub references: Vec<Id>,
pub occurrence: Id,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub enum ContextualLinkPurpose {
Precedence { guard: String },
Exchange { exchange: Id },
CommonWork { work: Id },
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub enum OccurrenceBody {
@@ -533,6 +558,8 @@ pub enum OccurrenceBody {
reply_to: Option<Id>,
#[serde(default)]
values: Vec<MessageValue>,
#[serde(default, skip_serializing_if = "Option::is_none")]
operation: Option<OperationUse>,
},
Action {
action: Option<Id>,
@@ -564,6 +591,10 @@ pub enum OccurrenceBody {
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub enum SharedBody {
ExchangeCategory {
family: crate::view_options::ExchangeFamily,
exchanges: Vec<Id>,
},
ExchangeItem {
kind: ExchangeItemKind,
mechanism: ExchangeMechanism,
@@ -754,6 +785,10 @@ impl ElementBody {
default: None,
unit: String::new(),
},
Kind::ExchangeCategory => SharedBody::ExchangeCategory {
family: crate::view_options::ExchangeFamily::Work,
exchanges: vec![],
},
Kind::DataField => SharedBody::DataField {
type_ref: TypeRef::String,
minimum: 1,
@@ -786,6 +821,7 @@ impl ElementBody {
message_kind: MessageKind::Asynchronous,
reply_to: None,
values: vec![],
operation: None,
},
precondition: String::new(),
postcondition: String::new(),
@@ -815,6 +851,7 @@ impl SharedBody {
pub fn kind(&self) -> Kind {
match self {
Self::ExchangeItem { .. } => Kind::ExchangeItem,
Self::ExchangeCategory { .. } => Kind::ExchangeCategory,
Self::DataType { .. } => Kind::DataType,
Self::DataField { .. } => Kind::DataField,
Self::Interface { .. } => Kind::Interface,
@@ -844,12 +881,14 @@ pub enum RelationKind {
RoleAssignment,
FunctionalExchange,
BoundaryExchange,
CommunicationMean,
MissionCapability,
CapabilityInclude,
CapabilityExtend,
CapabilityGeneralization,
MissionInvolvement,
SequenceLink,
ContextualLink,
StateTransition,
Availability,
ExchangeAllocation,
@@ -858,7 +897,7 @@ pub enum RelationKind {
StateAction,
}
impl RelationKind {
pub const ALL: [Self; 23] = [
pub const ALL: [Self; 25] = [
Self::InterfaceItemAllocation,
Self::DataAssociation,
Self::Allocation,
@@ -870,12 +909,14 @@ impl RelationKind {
Self::RoleAssignment,
Self::FunctionalExchange,
Self::BoundaryExchange,
Self::CommunicationMean,
Self::MissionCapability,
Self::CapabilityInclude,
Self::CapabilityExtend,
Self::CapabilityGeneralization,
Self::MissionInvolvement,
Self::SequenceLink,
Self::ContextualLink,
Self::StateTransition,
Self::Availability,
Self::ExchangeAllocation,
@@ -902,6 +943,10 @@ impl RelationKind {
Self::RoleAssignment => from == Kind::Role && to == Kind::Activity,
Self::FunctionalExchange => from == Kind::Function && to == Kind::Function,
Self::BoundaryExchange => from == Kind::Port && to == Kind::Port,
Self::CommunicationMean => {
matches!(from, Kind::Entity | Kind::Actor)
&& matches!(to, Kind::Entity | Kind::Actor)
}
Self::MissionCapability => matches!(
(from, to),
(Kind::OperationalMission, Kind::Capability)
@@ -919,7 +964,9 @@ impl RelationKind {
(Kind::Entity | Kind::Actor, Kind::OperationalMission)
| (Kind::System | Kind::SystemActor, Kind::Mission)
),
Self::SequenceLink => from == Kind::Occurrence && to == Kind::Occurrence,
Self::SequenceLink | Self::ContextualLink => {
from == Kind::Occurrence && to == Kind::Occurrence
}
Self::StateTransition => from.is_state() && to.is_state(),
Self::ExchangeAllocation => false,
Self::InterfaceProvision | Self::InterfaceRequirement => {
@@ -953,6 +1000,7 @@ impl RelationKind {
Self::RoleAssignment => "OA role → activity",
Self::FunctionalExchange => "SA function → SA function",
Self::BoundaryExchange => "Output port → input port",
Self::CommunicationMean => "OA entity/actor → OA entity/actor",
Self::MissionCapability => "Mission → capability in the same perspective",
Self::CapabilityInclude => "Including capability → included capability",
Self::CapabilityExtend => {
@@ -961,9 +1009,12 @@ impl RelationKind {
Self::CapabilityGeneralization => "Specific capability → general capability",
Self::MissionInvolvement => "Participant → mission in the same perspective",
Self::SequenceLink => "Preceding → following contribution in the same process or chain",
Self::ContextualLink => {
"Contributions addressed in an explicit process or chain caller"
}
Self::StateTransition => "State → state in one machine",
Self::ExchangeAllocation => {
"Boundary exchange → operational interaction / functional exchange"
"Communication means / boundary exchange → operational interaction / functional exchange"
}
Self::InterfaceProvision => "Participant/port → provided interface",
Self::InterfaceRequirement => "Participant/port → required interface",
@@ -986,12 +1037,14 @@ impl fmt::Display for RelationKind {
Self::RoleAssignment => "Role assignment",
Self::FunctionalExchange => "Functional exchange",
Self::BoundaryExchange => "Boundary exchange",
Self::CommunicationMean => "Communication means",
Self::MissionCapability => "Mission capability",
Self::CapabilityInclude => "Capability include",
Self::CapabilityExtend => "Capability extend",
Self::CapabilityGeneralization => "Capability generalization",
Self::MissionInvolvement => "Mission involvement",
Self::SequenceLink => "Sequence link",
Self::ContextualLink => "Contextual link",
Self::StateTransition => "State transition",
Self::ExchangeAllocation => "Exchange allocation",
Self::InterfaceProvision => "Interface provision",
@@ -1074,6 +1127,12 @@ pub enum RelationBody {
oriented: bool,
items: Vec<Id>,
},
CommunicationMean {
source: Id,
target: Id,
oriented: bool,
items: Vec<Id>,
},
MissionCapability {
source: Id,
target: Id,
@@ -1100,6 +1159,12 @@ pub enum RelationBody {
target: Id,
guard: String,
},
ContextualLink {
owner: Id,
source: OccurrenceEndpoint,
target: OccurrenceEndpoint,
purpose: ContextualLinkPurpose,
},
StateTransition {
source: Id,
target: Id,
@@ -1136,6 +1201,13 @@ pub enum RelationBody {
},
}
impl Relation {
/// Caller ownership cannot be inferred from a contextual link's leaf IDs.
pub fn contextual_owner(&self) -> Option<Id> {
match self.body {
RelationBody::ContextualLink { owner, .. } => Some(owner),
_ => None,
}
}
pub fn kind(&self) -> RelationKind {
self.body.kind()
}
@@ -1160,12 +1232,14 @@ impl RelationBody {
Self::RoleAssignment { .. } => RelationKind::RoleAssignment,
Self::FunctionalExchange { .. } => RelationKind::FunctionalExchange,
Self::BoundaryExchange { .. } => RelationKind::BoundaryExchange,
Self::CommunicationMean { .. } => RelationKind::CommunicationMean,
Self::MissionCapability { .. } => RelationKind::MissionCapability,
Self::CapabilityInclude { .. } => RelationKind::CapabilityInclude,
Self::CapabilityExtend { .. } => RelationKind::CapabilityExtend,
Self::CapabilityGeneralization { .. } => RelationKind::CapabilityGeneralization,
Self::MissionInvolvement { .. } => RelationKind::MissionInvolvement,
Self::SequenceLink { .. } => RelationKind::SequenceLink,
Self::ContextualLink { .. } => RelationKind::ContextualLink,
Self::StateTransition { .. } => RelationKind::StateTransition,
Self::ExchangeAllocation { .. } => RelationKind::ExchangeAllocation,
Self::InterfaceProvision { .. } => RelationKind::InterfaceProvision,
@@ -1176,6 +1250,7 @@ impl RelationBody {
}
pub fn endpoints(&self) -> (Id, Id) {
match self {
Self::ContextualLink { source, target, .. } => (source.occurrence, target.occurrence),
Self::InterfaceItemAllocation { source, target, .. }
| Self::DataAssociation { source, target, .. }
| Self::Allocation { source, target }
@@ -1187,6 +1262,7 @@ impl RelationBody {
| Self::RoleAssignment { source, target }
| Self::FunctionalExchange { source, target, .. }
| Self::BoundaryExchange { source, target, .. }
| Self::CommunicationMean { source, target, .. }
| Self::MissionCapability { source, target }
| Self::CapabilityInclude { source, target }
| Self::CapabilityExtend { source, target, .. }
@@ -1203,6 +1279,10 @@ impl RelationBody {
}
pub fn set_endpoints(&mut self, a: Id, b: Id) {
match self {
Self::ContextualLink { source, target, .. } => {
source.occurrence = a;
target.occurrence = b;
}
Self::InterfaceItemAllocation { source, target, .. }
| Self::DataAssociation { source, target, .. }
| Self::Allocation { source, target }
@@ -1214,6 +1294,7 @@ impl RelationBody {
| Self::RoleAssignment { source, target }
| Self::FunctionalExchange { source, target, .. }
| Self::BoundaryExchange { source, target, .. }
| Self::CommunicationMean { source, target, .. }
| Self::MissionCapability { source, target }
| Self::CapabilityInclude { source, target }
| Self::CapabilityExtend { source, target, .. }
@@ -1235,7 +1316,8 @@ impl RelationBody {
match self {
Self::Interaction { items, .. }
| Self::FunctionalExchange { items, .. }
| Self::BoundaryExchange { items, .. } => items,
| Self::BoundaryExchange { items, .. }
| Self::CommunicationMean { items, .. } => items,
_ => &[],
}
}
@@ -1285,6 +1367,12 @@ impl RelationBody {
oriented: true,
items: vec![],
},
RelationKind::CommunicationMean => Self::CommunicationMean {
source,
target,
oriented: false,
items: vec![],
},
RelationKind::MissionCapability => Self::MissionCapability { source, target },
RelationKind::CapabilityInclude => Self::CapabilityInclude { source, target },
RelationKind::CapabilityExtend => Self::CapabilityExtend {
@@ -1301,6 +1389,20 @@ impl RelationBody {
target,
guard: String::new(),
},
RelationKind::ContextualLink => Self::ContextualLink {
owner: Id::nil(),
source: OccurrenceEndpoint {
references: vec![],
occurrence: source,
},
target: OccurrenceEndpoint {
references: vec![],
occurrence: target,
},
purpose: ContextualLinkPurpose::Precedence {
guard: String::new(),
},
},
RelationKind::StateTransition => Self::StateTransition {
source,
target,
@@ -1387,6 +1489,8 @@ pub struct Diagram {
pub description: String,
pub layer: Layer,
pub projection: ProjectionKind,
#[serde(default)]
pub view_options: crate::view_options::ViewOptions,
#[serde(deserialize_with = "unique_id_map")]
pub nodes: BTreeMap<Id, NodePlacement>,
#[serde(default)]
@@ -1814,6 +1918,7 @@ impl Project {
description: String::new(),
layer,
projection: ProjectionKind::Overview,
view_options: crate::view_options::ViewOptions::default(),
nodes: BTreeMap::new(),
routes: vec![],
},
+345
View File
@@ -0,0 +1,345 @@
//! One operation resolver for admission, forms and scenario labels.
use crate::*;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolvedOperation {
/// The actual Call/Asynchronous declaration, also when resolving its Reply.
pub invocation: Id,
pub allocation: Id,
pub interface: Id,
pub item: Id,
pub caller_port: Id,
pub provider_port: Id,
pub transmission: TransmissionProtocol,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum OperationResolution {
Ordinary,
Unresolved { invocation: Id, reason: String },
Selected(ResolvedOperation),
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[error("{message}")]
pub struct OperationError {
pub code: &'static str,
pub message: String,
}
fn error(code: &'static str, message: &str) -> OperationError {
OperationError {
code,
message: message.into(),
}
}
pub fn resolve(p: &Project, occurrence: Id) -> Result<OperationResolution, OperationError> {
let e = p
.elements
.get(&occurrence)
.ok_or_else(|| error("operation_context", "The message no longer exists."))?;
let Some(SharedBody::Occurrence {
body:
OccurrenceBody::Exchange {
operation,
message_kind,
reply_to,
..
},
..
}) = e.shared()
else {
return Ok(OperationResolution::Ordinary);
};
let invocation = if *message_kind == MessageKind::Reply {
if operation.is_some() {
return Err(error(
"reply_operation",
"A Reply derives its operation from its exact Call; it cannot store a second selection.",
));
}
let Some(call) = reply_to else {
return Ok(OperationResolution::Ordinary);
};
if !matches!(
p.elements.get(call).and_then(Element::shared),
Some(SharedBody::Occurrence {
body: OccurrenceBody::Exchange {
message_kind: MessageKind::Call,
..
},
..
})
) {
return Ok(OperationResolution::Ordinary);
}
*call
} else {
occurrence
};
let Some(SharedBody::Occurrence {
body:
body @ OccurrenceBody::Exchange {
operation: Some(operation),
message_kind,
exchange,
sender,
receiver,
..
},
..
}) = p.elements.get(&invocation).and_then(Element::shared)
else {
return Ok(OperationResolution::Ordinary);
};
if p.layer_of(invocation) != Some(Layer::Sa)
|| !matches!(message_kind, MessageKind::Call | MessageKind::Asynchronous)
|| validation::ancestor_of_kind(p, invocation, Kind::Scenario)
.and_then(|id| p.elements.get(&id))
.and_then(Element::shared)
.is_none_or(|body| {
!matches!(
body,
SharedBody::Scenario {
viewpoint: ScenarioViewpoint::BoundaryInterface,
..
}
)
})
{
return Err(error(
"operation_context",
"Operation use belongs to a Call or Asynchronous message in an SA BoundaryInterface scenario.",
));
}
let unresolved = |reason: &str| {
Ok(OperationResolution::Unresolved {
invocation,
reason: reason.into(),
})
};
if exchange
.and_then(|id| p.relations.get(&id))
.is_some_and(|r| r.kind() != RelationKind::BoundaryExchange)
{
return Err(error(
"operation_channel",
"Operation use requires an explicit boundary exchange channel.",
));
}
let Some(allocation) = operation.allocation else {
return unresolved(
"Choose the interface operation allocation; existing value expressions are preserved.",
);
};
let Some(relation) = p.relations.get(&allocation) else {
return Err(error(
"operation_allocation",
"The selected interface operation allocation no longer exists.",
));
};
let RelationBody::InterfaceItemAllocation {
source: interface,
target: item,
transmission,
..
} = relation.body
else {
return Err(error(
"operation_allocation",
"Choose an InterfaceItemAllocation, not an item or another relationship.",
));
};
if p.layer_of(allocation) != Some(Layer::Sa)
|| !matches!(
p.elements.get(&item).and_then(Element::shared),
Some(SharedBody::ExchangeItem {
kind: ExchangeItemKind::Information,
mechanism: ExchangeMechanism::Operation,
..
})
)
{
return Err(error(
"operation_allocation",
"The selected SA allocation must declare an Information item with Operation mechanism.",
));
}
let Some(channel) = exchange.and_then(|id| p.relations.get(&id)) else {
return Err(error(
"operation_channel",
"A selected operation requires an existing boundary exchange carrying its item; clear the allocation to retain unresolved intent.",
));
};
if channel.kind() != RelationKind::BoundaryExchange
|| !channel
.body
.items()
.iter()
.any(|carried| data::item_satisfies(p, *carried, item))
{
return Err(error(
"operation_channel",
"The boundary exchange must carry the selected operation item or an admitted subtype.",
));
}
if sender.is_none() || receiver.is_none() {
return Err(error(
"operation_lifelines",
"A selected operation requires actual caller and provider lifelines; clear the allocation to retain unresolved intent.",
));
}
let (caller_port, provider_port) = match validation::resolve_message_endpoints(p, body) {
validation::MessageEndpointResolution::Resolved { source, target } => (source, target),
validation::MessageEndpointResolution::Conflict => {
return Err(error(
"operation_direction",
"The selected lifelines conflict with the boundary channel's actual caller/provider direction.",
));
}
_ => {
return Err(error(
"operation_direction",
"A selected operation requires a proven actual caller/provider channel direction; clear the allocation to retain unresolved intent.",
));
}
};
for port in [caller_port, provider_port] {
if !matches!(
p.elements.get(&port).and_then(ports::port_contract),
Some(PortContract::Component {
kind: ComponentPortKind::Standard,
..
})
) {
return Err(error(
"operation_ports",
"Operation proof requires concrete Standard component ports.",
));
}
}
let exposes = |interface| {
data::effective_interface_allocations(p, interface)
.iter()
.any(|r| r.id == allocation)
};
let providers: Vec<_> = p
.relations
.values()
.filter(|r| {
r.kind() == RelationKind::InterfaceProvision
&& r.source() == provider_port
&& exposes(r.target())
})
.map(Relation::target)
.collect();
let valid = p.relations.values().any(|r| {
r.kind() == RelationKind::InterfaceRequirement
&& r.source() == caller_port
&& exposes(r.target())
&& providers
.iter()
.any(|provided| data::interface_satisfies(p, *provided, r.target()))
});
if !valid {
return Err(error(
"operation_roles",
"The actual receiver port must provide, and sender port require, compatible interfaces exposing this exact allocation.",
));
}
if !matches!(
(message_kind, transmission),
(_, TransmissionProtocol::Unspecified)
| (MessageKind::Call, TransmissionProtocol::Synchronous)
| (
MessageKind::Asynchronous,
TransmissionProtocol::Asynchronous
)
) {
return Err(error(
"operation_transmission",
"Calls require synchronous transmission; asynchronous operation messages require asynchronous transmission.",
));
}
let selected = ResolvedOperation {
invocation,
allocation,
interface,
item,
caller_port,
provider_port,
transmission,
};
if occurrence != invocation {
let Some(SharedBody::Occurrence {
body:
OccurrenceBody::Exchange {
exchange: reply_exchange,
..
},
..
}) = e.shared()
else {
unreachable!()
};
if let Some(response) = reply_exchange.and_then(|id| p.relations.get(&id))
&& response.id != channel.id
&& !response
.body
.items()
.iter()
.any(|carried| data::item_satisfies(p, *carried, item))
{
return Err(error(
"operation_reply_channel",
"A separate reply channel must carry the Call's selected operation item or an admitted subtype, including parameterless returns.",
));
}
}
Ok(OperationResolution::Selected(selected))
}
/// Valid selections under the proposed message context, without mutating the model.
pub fn eligible_allocations(p: &Project, occurrence: Id) -> Vec<Id> {
let mut candidate = p.clone();
let ids: Vec<_> = p
.relations
.values()
.filter(|r| r.kind() == RelationKind::InterfaceItemAllocation)
.map(|r| r.id)
.collect();
ids.into_iter()
.filter(|id| {
let Some(SharedBody::Occurrence {
body: OccurrenceBody::Exchange { operation, .. },
..
}) = candidate
.elements
.get_mut(&occurrence)
.and_then(Element::shared_mut)
else {
return false;
};
*operation = Some(OperationUse {
allocation: Some(*id),
});
matches!(
resolve(&candidate, occurrence),
Ok(OperationResolution::Selected(_))
)
})
.collect()
}
/// Reuse the existing bounded field-path engine with exactly one selected item.
pub fn signature_relation(operation: &ResolvedOperation) -> Relation {
Relation {
id: operation.allocation,
alias: String::new(),
name: String::new(),
description: String::new(),
body: RelationBody::Interaction {
source: operation.caller_port,
target: operation.provider_port,
items: vec![operation.item],
},
}
}
+245 -2
View File
@@ -139,6 +139,43 @@ pub fn decode_with_report_limited(
}
let decoded = if bytes.iter().find(|b| !b.is_ascii_whitespace()) == Some(&b'{') {
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)?;
}
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)
@@ -151,13 +188,15 @@ pub fn decode_with_report_limited(
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) => DecodeResult {
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 or 5.".into(),
"Unsupported or missing schema_version; expected 1, 2, 3, 4, 5, 6 or 7.".into(),
));
}
}
@@ -186,6 +225,210 @@ pub fn decode_with_report_limited(
Ok(decoded)
}
/// 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 {
matches!(
kind,
RelationKind::InterfaceItemAllocation
| RelationKind::DataAssociation
| RelationKind::Allocation
| RelationKind::PortAllocation
| RelationKind::Interaction
| RelationKind::CapabilityInvolvement
| RelationKind::ChainInvolvement
| RelationKind::RoleRealization
| RelationKind::RoleAssignment
| RelationKind::FunctionalExchange
| RelationKind::BoundaryExchange
| RelationKind::MissionCapability
| RelationKind::CapabilityInclude
| RelationKind::CapabilityExtend
| RelationKind::CapabilityGeneralization
| RelationKind::MissionInvolvement
| RelationKind::SequenceLink
| RelationKind::StateTransition
| RelationKind::Availability
| RelationKind::ExchangeAllocation
| RelationKind::InterfaceProvision
| RelationKind::InterfaceRequirement
| RelationKind::StateAction
)
}
pub(crate) fn pre6_element_kind(kind: Kind) -> bool {
matches!(
kind,
Kind::Entity
| Kind::Actor
| Kind::Role
| Kind::Activity
| Kind::Capability
| Kind::OperationalMission
| Kind::Process
| Kind::System
| Kind::SystemActor
| Kind::Function
| Kind::SystemCapability
| Kind::Mission
| Kind::FunctionalChain
| Kind::ExchangeItem
| Kind::DataType
| Kind::DataField
| Kind::Interface
| Kind::Port
| Kind::Scenario
| Kind::Lifeline
| Kind::Occurrence
| Kind::StateMachine
| Kind::Region
| Kind::State
| Kind::Mode
| Kind::Initial
| Kind::Final
| Kind::Choice
| Kind::Junction
| Kind::Fork
| Kind::Join
| Kind::ShallowHistory
| Kind::DeepHistory
| Kind::EntryPoint
| Kind::ExitPoint
| Kind::Terminate
| Kind::Event
| Kind::Constraint
)
}
pub(crate) fn check_pre6_relations<'a>(
relations: impl Iterator<Item = &'a Relation>,
) -> Result<(), FileError> {
for relation in relations {
if !pre6_relation_kind(relation.kind()) {
return Err(FileError::Invalid(format!(
"{:?} requires schema/source 6.",
relation.kind()
)));
}
}
Ok(())
}
pub(crate) fn check_pre6_elements<'a>(
elements: impl Iterator<Item = &'a Element>,
) -> Result<(), FileError> {
for element in elements {
if !pre6_element_kind(element.kind()) {
return Err(FileError::Invalid(format!(
"{:?} requires schema/source 6.",
element.kind()
)));
}
}
Ok(())
}
pub(crate) fn check_pre6_baseline(baseline: &SemanticBaseline) -> Result<(), FileError> {
if baseline.facets.iter().any(|f| {
matches!(
f,
FacetReview::Category(_)
| FacetReview::Member {
collection: ReferenceCollection::CategoryExchanges,
..
}
)
}) {
return Err(FileError::Invalid(
"Exchange category semantic facts require schema/source 6.".into(),
));
}
Ok(())
}
pub(crate) fn migrate_v5(
mut project: Project,
limits: crate::language::ParseLimits,
) -> Result<DecodeResult, FileError> {
if project.schema_version != 5 {
return Err(FileError::Invalid("Expected schema_version 5.".into()));
}
check_pre6_relations(project.relations.values())?;
check_pre6_elements(project.elements.values())?;
for baseline in project
.traces
.values()
.filter_map(|t| t.baseline.as_ref().and_then(|b| b.semantic.as_ref()))
{
check_pre6_baseline(baseline)?;
}
limits.check_model(&project).map_err(FileError::Limit)?;
project.schema_version = SCHEMA_VERSION;
Ok(DecodeResult {
project,
migration: Some(MigrationReport::data_upgrade(5)),
})
}
/// 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> {
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(),
);
}
for nested in fields.values() {
reject_source7_fields(nested)?;
}
}
serde_json::Value::Array(values) => {
for nested in values {
reject_source7_fields(nested)?;
}
}
_ => {}
}
Ok(())
}
pub(crate) fn migrate_v6(
mut project: Project,
limits: crate::language::ParseLimits,
) -> Result<DecodeResult, FileError> {
if project.schema_version != 6 {
return Err(FileError::Invalid("Expected schema_version 6.".into()));
}
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;
Ok(DecodeResult {
project,
migration: Some(MigrationReport::data_upgrade(6)),
})
}
pub(crate) fn migrate_v1_with_report_limited(
old: v1::Project,
limits: crate::language::ParseLimits,
+47 -1
View File
@@ -1182,6 +1182,48 @@ pub(crate) fn validate_exchange_allocation(p: &Project, r: &Relation, out: &mut
else {
return;
};
if let RelationBody::CommunicationMean {
source,
target,
oriented,
..
} = &boundary.body
{
let actual = (
actual_performer(p, work.source()),
actual_performer(p, work.target()),
);
let compatible = |a, b| {
actual.0.is_none_or(|owner| owner == a) && actual.1.is_none_or(|owner| owner == b)
};
if !compatible(*source, *target) && (*oriented || !compatible(*target, *source)) {
error(
out,
r.id,
"exchange_allocation_performer",
"endpoints",
"Actual activity allocations must agree with one permitted communication direction; review the means or the activity allocations.",
);
} else if actual.0.is_none() || actual.1.is_none() {
warning(
out,
r.id,
"incomplete_exchange_allocation",
"endpoints",
"Add or review actual activity-to-participant Allocations; role responsibility alone does not resolve communication allocation.",
);
}
if !items_satisfy(p, work.body.items(), boundary.body.items()) {
error(
out,
r.id,
"exchange_allocation_items",
"items",
"Communication means must declare every allocated interaction item; review the carried item contract explicitly.",
);
}
return;
}
let RelationBody::BoundaryExchange {
source: a,
target: b,
@@ -1219,7 +1261,11 @@ pub(crate) fn validate_exchange_allocation(p: &Project, r: &Relation, out: &mut
r.id,
"legacy_exchange_allocation",
"endpoints",
"Resolve legacy ports and explicitly allocate the corresponding typed ports.",
if p.layer_of(boundary.id) == Some(Layer::Oa) {
"This preserved OA boundary uses legacy ports; review its communication representation without inventing SA port contracts."
} else {
"Resolve legacy ports and explicitly allocate the corresponding typed ports."
},
);
for (function, port) in [(work.source(), *a), (work.target(), *b)] {
match (actual_performer(p, function), owner(port)) {
+104 -9
View File
@@ -226,8 +226,58 @@ pub fn project_view(p: &Project, diagram: Id) -> ProjectedView {
};
project_diagram(p, d)
}
/// Reuse an index built for this accepted semantic revision during presentation
/// previews. Callers must invalidate it when semantic records change.
pub fn project_view_with_index(
p: &Project,
diagram: Id,
index: &crate::view_options::ViewIndex,
) -> ProjectedView {
p.diagrams
.get(&diagram)
.map_or_else(ProjectedView::default, |d| {
project_diagram_with_index(p, d, index)
})
}
/// Project alternate placement geometry without copying or mutating semantics.
pub fn project_diagram(p: &Project, d: &Diagram) -> ProjectedView {
let index = crate::view_options::ViewIndex::new(p);
project_diagram_with_index(p, d, &index)
}
pub fn project_diagram_with_index(
p: &Project,
d: &Diagram,
index: &crate::view_options::ViewIndex,
) -> ProjectedView {
project_diagram_impl(p, d, None, Some(index))
}
/// Unfiltered eligibility from the same projection/anchor/fold decisions used
/// for drawing. Compute once for a view-options editor, then combine with the
/// prepared filter's reasons; hidden lines remain geometrically eligible here.
pub fn edge_availability(
p: &Project,
d: &Diagram,
) -> BTreeMap<Id, crate::view_options::EdgeAvailability> {
let mut availability = p
.relations
.keys()
.chain(p.traces.keys())
.map(|id| {
(
*id,
crate::view_options::EdgeAvailability::OutsideProjection,
)
})
.collect();
project_diagram_impl(p, d, Some(&mut availability), None);
availability
}
fn project_diagram_impl(
p: &Project,
d: &Diagram,
mut availability: Option<&mut BTreeMap<Id, crate::view_options::EdgeAvailability>>,
index: Option<&crate::view_options::ViewIndex>,
) -> ProjectedView {
let parents = parents(p, d);
let mut nodes = Vec::new();
for n in d.nodes.values() {
@@ -354,6 +404,7 @@ pub fn project_diagram(p: &Project, d: &Diagram) -> ProjectedView {
RelationKind::Interaction
| RelationKind::FunctionalExchange
| RelationKind::BoundaryExchange
| RelationKind::CommunicationMean
),
ProjectionKind::FunctionalDataflow => matches!(
r.kind(),
@@ -367,20 +418,36 @@ pub fn project_diagram(p: &Project, d: &Diagram) -> ProjectedView {
| RelationKind::MissionCapability
| RelationKind::MissionInvolvement
| RelationKind::CapabilityInvolvement
| RelationKind::CommunicationMean
),
_ => true,
};
if !included {
continue;
}
if let (Some(source), Some(target)) = (anchor(r.source()), anchor(r.target()))
&& (source != target
|| ((d.projection.state_machine().is_some()
|| r.kind() == RelationKind::DataAssociation)
&& r.source() == r.target()
&& nodes
.iter()
.any(|n| n.id == source && n.element == r.source())))
let ends = (anchor(r.source()), anchor(r.target()));
let self_loop = (d.projection.state_machine().is_some()
|| r.kind() == RelationKind::DataAssociation)
&& r.source() == r.target()
&& nodes
.iter()
.any(|n| Some(n.id) == ends.0 && n.element == r.source());
if let Some(availability) = availability.as_deref_mut() {
use crate::view_options::EdgeAvailability as A;
availability.insert(
r.id,
match ends {
(Some(a), Some(b)) if a != b || self_loop => A::Visible,
(Some(_), Some(_)) => A::FoldedInternal,
(a, b) => A::EndpointNotShown {
source: a.is_none(),
target: b.is_none(),
},
},
);
}
if let (Some(source), Some(target)) = ends
&& (source != target || self_loop)
{
let (source_port, target_port) = match &r.body {
RelationBody::FunctionalExchange {
@@ -402,6 +469,9 @@ pub fn project_diagram(p: &Project, d: &Diagram) -> ProjectedView {
RelationBody::BoundaryExchange {
oriented: false,
..
} | RelationBody::CommunicationMean {
oriented: false,
..
} | RelationBody::DataAssociation { .. }
),
source_port: source_port.filter(|id| visible_ports.contains(id)),
@@ -411,7 +481,22 @@ pub fn project_diagram(p: &Project, d: &Diagram) -> ProjectedView {
}
if d.projection == ProjectionKind::Traceability {
for t in p.traces.values() {
if let (Some(source), Some(target)) = (anchor(t.oa), anchor(t.sa))
let ends = (anchor(t.oa), anchor(t.sa));
if let Some(availability) = availability.as_deref_mut() {
use crate::view_options::EdgeAvailability as A;
availability.insert(
t.id,
match ends {
(Some(a), Some(b)) if a != b => A::Visible,
(Some(_), Some(_)) => A::FoldedInternal,
(a, b) => A::EndpointNotShown {
source: a.is_none(),
target: b.is_none(),
},
},
);
}
if let (Some(source), Some(target)) = ends
&& source != target
{
edges.push(ProjectedEdge {
@@ -433,6 +518,16 @@ pub fn project_diagram(p: &Project, d: &Diagram) -> ProjectedView {
} else {
port_layout::layout(p, d, &mut nodes, &edges)
};
// Port geometry uses every eligible edge. Presentation filters must never
// move visible ports when another line is hidden or a category is selected.
if let Some(index) = index {
let filter = if d.projection == ProjectionKind::Traceability {
index.prepare_cross_layer(d.layer, &d.view_options)
} else {
index.prepare(d.layer, &d.view_options)
};
edges.retain(|edge| filter.includes(edge.id));
}
ProjectedView {
nodes,
edges,
+6 -1
View File
@@ -53,7 +53,12 @@ pub fn state_header_height(p: &Project, element: Id) -> f32 {
if *source == element)
})
.count();
64. + if actions { 28. } else { 0. } + internal as f32 * 56.
let typed_actions = p
.relations
.values()
.filter(|r| r.kind() == RelationKind::StateAction && r.source() == element)
.count();
64. + if actions { 28. } else { 0. } + typed_actions as f32 * 28. + internal as f32 * 56.
}
pub(crate) fn node_size(p: &Project, e: &Element, size: Size, collapsed: bool) -> Size {
+50 -3
View File
@@ -29,6 +29,11 @@ pub fn visit_element_refs(e: &mut Element, mut visit: impl FnMut(&mut Id)) {
}
if let Some(shared) = e.shared_mut() {
match shared {
SharedBody::ExchangeCategory { exchanges, .. } => {
for id in exchanges {
visit(id);
}
}
SharedBody::DataField {
type_ref: TypeRef::Named(id),
..
@@ -82,8 +87,15 @@ pub fn visit_element_refs(e: &mut Element, mut visit: impl FnMut(&mut Id)) {
receiver,
reply_to,
values,
operation,
..
} => {
if let Some(id) = operation
.as_mut()
.and_then(|operation| operation.allocation.as_mut())
{
visit(id);
}
for id in [exchange, sender, receiver, reply_to].into_iter().flatten() {
visit(id);
}
@@ -145,6 +157,22 @@ pub fn visit_relation_refs(r: &mut Relation, mut visit: impl FnMut(&mut Id)) {
visit(&mut target);
r.body.set_endpoints(source, target);
match &mut r.body {
RelationBody::ContextualLink {
owner,
source,
target,
purpose,
} => {
visit(owner);
for id in source.references.iter_mut().chain(&mut target.references) {
visit(id);
}
match purpose {
ContextualLinkPurpose::Exchange { exchange } => visit(exchange),
ContextualLinkPurpose::CommonWork { work } => visit(work),
ContextualLinkPurpose::Precedence { .. } => {}
}
}
RelationBody::StateTransition {
triggers, effects, ..
} => {
@@ -152,7 +180,9 @@ pub fn visit_relation_refs(r: &mut Relation, mut visit: impl FnMut(&mut Id)) {
visit(id);
}
}
RelationBody::Interaction { items, .. } | RelationBody::BoundaryExchange { items, .. } => {
RelationBody::Interaction { items, .. }
| RelationBody::BoundaryExchange { items, .. }
| RelationBody::CommunicationMean { items, .. } => {
for id in items {
visit(id);
}
@@ -189,6 +219,11 @@ pub fn remap_references(p: &mut Project, old: Id, new: Id) {
});
}
for d in p.diagrams.values_mut() {
d.view_options.visit_references(|id| {
if *id == old {
*id = new;
}
});
if let ProjectionKind::StateMachine { machine } = &mut d.projection
&& *machine == old
{
@@ -262,6 +297,8 @@ fn deletion_impact_inner(p: &Project, id: Id, defer_exchange_allocations: bool)
});
if removed.contains(&r.source())
|| removed.contains(&r.target())
|| (r.kind() == RelationKind::ContextualLink
&& relation_references(r).iter().any(|id| removed.contains(id)))
|| port_removed
|| work_allocation_removed
{
@@ -374,6 +411,7 @@ fn remove_objects(p: &mut Project, removed: BTreeSet<Id>) {
p.traces.retain(|id, _| !removed.contains(id));
p.artifact_links.retain(|id, _| !removed.contains(id));
for d in p.diagrams.values_mut() {
d.view_options.retain_references(|id| !removed.contains(id));
d.nodes.retain(|id, _| !removed.contains(id));
d.routes.retain(|route| !removed.contains(&route.edge));
for node in d.nodes.values_mut() {
@@ -417,7 +455,11 @@ fn remove_objects(p: &mut Project, removed: BTreeSet<Id>) {
}
SharedBody::Scenario { context, .. } => clear(context),
SharedBody::Lifeline { represented } => clear(represented),
SharedBody::Constraint { constrained, .. } => {
SharedBody::ExchangeCategory {
exchanges: constrained,
..
}
| SharedBody::Constraint { constrained, .. } => {
constrained.retain(|id| !removed.contains(id))
}
SharedBody::Occurrence { body, .. } => match body {
@@ -427,8 +469,12 @@ fn remove_objects(p: &mut Project, removed: BTreeSet<Id>) {
receiver,
reply_to,
values,
operation,
..
} => {
if let Some(operation) = operation {
clear(&mut operation.allocation);
}
clear(exchange);
clear(sender);
clear(receiver);
@@ -487,7 +533,8 @@ fn remove_objects(p: &mut Project, removed: BTreeSet<Id>) {
}
}
RelationBody::Interaction { items, .. }
| RelationBody::BoundaryExchange { items, .. } => {
| RelationBody::BoundaryExchange { items, .. }
| RelationBody::CommunicationMean { items, .. } => {
items.retain(|id| !removed.contains(id))
}
RelationBody::FunctionalExchange { items, .. } => {
+20 -1
View File
@@ -607,6 +607,7 @@ fn route_diagram_impl(
.flatten();
routed.label = make_label(
project,
diagram.view_options.exchange_labels,
edge.id,
&routed.points,
authored_label,
@@ -1232,6 +1233,7 @@ fn label_text(project: &Project, edge: Id) -> String {
RelationKind::Interaction
| RelationKind::FunctionalExchange
| RelationKind::BoundaryExchange
| RelationKind::CommunicationMean
| RelationKind::DataAssociation => relation.name.as_str(),
RelationKind::CapabilityInclude => "«include»",
RelationKind::CapabilityExtend => "«extend»",
@@ -1262,8 +1264,11 @@ fn empty_label() -> EdgeLabelGeometry {
}
}
// Keep semantic label inputs and the independent collision sets explicit.
#[allow(clippy::too_many_arguments)]
fn make_label(
project: &Project,
labels: crate::view_options::ExchangeLabels,
edge: Id,
points: &[Position],
authored: Option<&crate::EdgeLabel>,
@@ -1271,7 +1276,21 @@ fn make_label(
previous_labels: &[RoutingBounds],
strokes: &[Segment],
) -> EdgeLabelGeometry {
let text = label_text(project, edge);
let text = if labels != crate::view_options::ExchangeLabels::Name
&& project
.relations
.get(&edge)
.is_some_and(|r| crate::view_options::exchange_family(r.kind()).is_some())
{
let label = labels.label(project, edge);
if label.chars().count() > 48 {
label.chars().take(47).chain(std::iter::once('…')).collect()
} else {
label
}
} else {
label_text(project, edge)
};
let size = Size {
width: if text.is_empty() {
0.0
+58 -4
View File
@@ -81,6 +81,35 @@ impl Own {
}
let kind = enum_tag(value)?.to_string();
prefix.push(kind.clone());
let mut whole = whole;
if let Some(SharedBody::Occurrence {
body:
OccurrenceBody::Exchange {
message_kind: MessageKind::Call | MessageKind::Asynchronous,
..
},
..
}) = project.elements.get(&id).and_then(Element::shared)
&& project.layer_of(id) == Some(Layer::Sa)
&& crate::validation::ancestor_of_kind(project, id, Kind::Scenario)
.and_then(|id| project.elements.get(&id))
.and_then(Element::shared)
.is_some_and(|body| {
matches!(
body,
SharedBody::Scenario {
viewpoint: ScenarioViewpoint::BoundaryInterface,
..
}
)
})
{
let mut operation_path = prefix.clone();
operation_path.extend(["body".into(), "Exchange".into()]);
if let Some(body) = at_mut(&mut whole, &operation_path).and_then(Value::as_object_mut) {
body.entry("operation").or_insert(Value::Null);
}
}
Some(Self {
whole,
prefix,
@@ -163,6 +192,9 @@ fn path(own: &Own, facet: &FacetSelector) -> Option<Vec<String>> {
F::Occurrence(OccurrenceFacet::ReplyTo) => {
("Occurrence", &["body", "Exchange", "reply_to"])
}
F::Occurrence(OccurrenceFacet::Operation) => {
("Occurrence", &["body", "Exchange", "operation"])
}
F::Occurrence(OccurrenceFacet::Action) => ("Occurrence", &["body", "Action", "action"]),
F::Occurrence(OccurrenceFacet::ActionLifeline) => {
("Occurrence", &["body", "Action", "lifeline"])
@@ -191,8 +223,13 @@ fn path(own: &Own, facet: &FacetSelector) -> Option<Vec<String>> {
F::State(StateFacet::Exit) => ("State", &["exit"]),
F::State(StateFacet::DoActivity) => ("State", &["do_activity"]),
F::Constraint(ConstraintFacet::Expression) => ("Constraint", &["expression"]),
F::Relation(RelationFacet::Source) => ("*", &["source"]),
F::Relation(RelationFacet::Target) => ("*", &["target"]),
F::Category(CategoryFacet::Family) => ("ExchangeCategory", &["family"]),
F::Relation(RelationFacet::Source) if own.kind != "ContextualLink" => ("*", &["source"]),
F::Relation(RelationFacet::Target) if own.kind != "ContextualLink" => ("*", &["target"]),
F::Relation(RelationFacet::ContextOwner) => ("ContextualLink", &["owner"]),
F::Relation(RelationFacet::ContextSource) => ("ContextualLink", &["source"]),
F::Relation(RelationFacet::ContextTarget) => ("ContextualLink", &["target"]),
F::Relation(RelationFacet::ContextPurpose) => ("ContextualLink", &["purpose"]),
F::Relation(RelationFacet::AllocationOrder) => ("InterfaceItemAllocation", &["order"]),
F::Relation(RelationFacet::Transmission) => ("InterfaceItemAllocation", &["transmission"]),
F::Relation(RelationFacet::Acquisition) => ("InterfaceItemAllocation", &["acquisition"]),
@@ -206,7 +243,7 @@ fn path(own: &Own, facet: &FacetSelector) -> Option<Vec<String>> {
F::Relation(RelationFacet::TargetNavigable) => ("DataAssociation", &["target_navigable"]),
F::Relation(RelationFacet::SourcePort) => ("FunctionalExchange", &["source_port"]),
F::Relation(RelationFacet::TargetPort) => ("FunctionalExchange", &["target_port"]),
F::Relation(RelationFacet::Oriented) => ("BoundaryExchange", &["oriented"]),
F::Relation(RelationFacet::Oriented) => ("*", &["oriented"]),
F::Relation(RelationFacet::ExtendCondition) => ("CapabilityExtend", &["condition"]),
F::Relation(RelationFacet::SequenceGuard) => ("SequenceLink", &["guard"]),
F::Relation(RelationFacet::TransitionKind) => ("StateTransition", &["transition_kind"]),
@@ -311,7 +348,8 @@ pub(crate) fn collection(project: &Project, id: Id, which: ReferenceCollection)
Some(
RelationBody::Interaction { items, .. }
| RelationBody::FunctionalExchange { items, .. }
| RelationBody::BoundaryExchange { items, .. },
| RelationBody::BoundaryExchange { items, .. }
| RelationBody::CommunicationMean { items, .. },
) => Some(items.clone()),
_ => None,
},
@@ -327,6 +365,10 @@ pub(crate) fn collection(project: &Project, id: Id, which: ReferenceCollection)
Some(SharedBody::Constraint { constrained, .. }) => Some(constrained.clone()),
_ => None,
},
C::CategoryExchanges => match shared {
Some(SharedBody::ExchangeCategory { exchanges, .. }) => Some(exchanges.clone()),
_ => None,
},
}
}
pub(crate) fn order_collection(which: OrderedCollection) -> ReferenceCollection {
@@ -575,6 +617,7 @@ fn catalog_facets(
ReferenceCollection::FunctionOutputItems,
ReferenceCollection::SignalItems,
ReferenceCollection::ExchangeItems,
ReferenceCollection::CategoryExchanges,
ReferenceCollection::TransitionTriggers,
ReferenceCollection::TransitionEffects,
ReferenceCollection::Constrained,
@@ -919,6 +962,7 @@ mod coverage {
] {
add(SharedBody::Occurrence {
body: OccurrenceBody::Exchange {
operation: None,
exchange: Some(id),
sender: Some(id),
receiver: Some(id),
@@ -1036,6 +1080,11 @@ mod coverage {
p
};
for (kind, parts, which) in [
(
"ExchangeCategory",
vec!["exchanges"],
ReferenceCollection::CategoryExchanges,
),
("Process", vec!["steps"], ReferenceCollection::Steps),
("FunctionalChain", vec!["steps"], ReferenceCollection::Steps),
("Scenario", vec!["steps"], ReferenceCollection::Steps),
@@ -1079,6 +1128,11 @@ mod coverage {
vec!["items"],
ReferenceCollection::ExchangeItems,
),
(
"CommunicationMean",
vec!["items"],
ReferenceCollection::ExchangeItems,
),
(
"StateTransition",
vec!["triggers"],
+26
View File
@@ -73,6 +73,7 @@ macro_rules! enums {
} )* };
}
enums!(
crate::view_options::ExchangeFamily,
ExchangeItemKind,
ExchangeMechanism,
ParameterDirection,
@@ -170,6 +171,31 @@ impl Historical for LifelineBinding {
w.at("local", |w| self.local.walk(w));
}
}
impl Historical for OperationUse {
fn walk(&mut self, w: &mut Walk<'_>) {
w.at("allocation", |w| self.allocation.walk(w));
}
}
impl Historical for OccurrenceEndpoint {
fn walk(&mut self, w: &mut Walk<'_>) {
if self.references.len() > crate::topology_context::MAX_REFERENCE_DEPTH {
w.error("historical contextual path depth exceeded");
}
w.at("references", |w| self.references.walk(w));
w.at("occurrence", |w| self.occurrence.walk(w));
}
}
impl Historical for ContextualLinkPurpose {
fn walk(&mut self, w: &mut Walk<'_>) {
match self {
Self::Precedence { guard } => w.at("Precedence", |w| w.at("guard", |w| guard.walk(w))),
Self::Exchange { exchange } => {
w.at("Exchange", |w| w.at("exchange", |w| exchange.walk(w)))
}
Self::CommonWork { work } => w.at("CommonWork", |w| w.at("work", |w| work.walk(w))),
}
}
}
impl Historical for DataValue {
fn walk(&mut self, w: &mut Walk<'_>) {
match self {
+34 -10
View File
@@ -63,7 +63,8 @@ pub(crate) fn set_collection(
Some(
RelationBody::Interaction { items, .. }
| RelationBody::FunctionalExchange { items, .. }
| RelationBody::BoundaryExchange { items, .. },
| RelationBody::BoundaryExchange { items, .. }
| RelationBody::CommunicationMean { items, .. },
) => Some(items),
_ => None,
},
@@ -79,6 +80,10 @@ pub(crate) fn set_collection(
Some(SharedBody::Constraint { constrained, .. }) => Some(constrained),
_ => None,
},
C::CategoryExchanges => match p.elements.get_mut(&id).and_then(Element::shared_mut) {
Some(SharedBody::ExchangeCategory { exchanges, .. }) => Some(exchanges),
_ => None,
},
}
.ok_or("Collection is inapplicable on target")?;
*slot = list;
@@ -350,10 +355,7 @@ pub(crate) fn write_fact(
else {
return Err("Target has no message annotations".into());
};
values.retain(|v| AnnotationKey::from(v) != *key);
if let Some(row) = row {
values.push(row);
}
replace_keyed_row(values, row, |v| AnnotationKey::from(v) == *key);
Ok(())
}
FacetReview::Binding { keys, review } => {
@@ -374,10 +376,7 @@ pub(crate) fn write_fact(
else {
return Err("Target has no scenario bindings".into());
};
bindings.retain(|v| v.referenced != key);
if let Some(row) = row {
bindings.push(row);
}
replace_keyed_row(bindings, row, |v| v.referenced == key);
Ok(())
}
FacetReview::Child { keys, review } => {
@@ -467,6 +466,27 @@ pub(crate) fn write_fact(
}
}
// Reviewing an unordered row does not authorize changing its authored position.
// Preserve surviving entries in place; only genuinely new rows append.
fn replace_keyed_row<T: PartialEq>(
rows: &mut Vec<T>,
row: Option<T>,
matches: impl FnMut(&T) -> bool,
) {
match (rows.iter().position(matches), row) {
(Some(index), Some(row)) => {
if rows[index] != row {
rows[index] = row;
}
}
(Some(index), None) => {
rows.remove(index);
}
(None, Some(row)) => rows.push(row),
(None, None) => {}
}
}
pub(crate) fn constructor_contains(group: &FacetSelector, facet: &FacetSelector) -> bool {
use FacetSelector as F;
match group {
@@ -498,7 +518,11 @@ pub(crate) fn constructor_contains(group: &FacetSelector, facet: &FacetSelector)
}
F::Occurrence(OccurrenceFacet::MessageKind) => matches!(
facet,
F::Occurrence(OccurrenceFacet::MessageKind | OccurrenceFacet::TimerExpression)
F::Occurrence(
OccurrenceFacet::MessageKind
| OccurrenceFacet::TimerExpression
| OccurrenceFacet::Operation
)
),
F::Event(EventFacet::Constructor) => matches!(
facet,
+50 -13
View File
@@ -1,5 +1,5 @@
use super::catalog::{capture_observation, collection, is_constructor, replace_observation, sides};
use super::historical::keyed_slots_overlap;
use super::historical::{keyed_slots_overlap, selector_slots};
use super::mutate::*;
use super::*;
use crate::model::*;
@@ -297,16 +297,22 @@ fn apply_canonical_slot(
let fact = replace_observation(fact, Layer::Sa, desired.clone())?;
write_fact(project, owner, &fact, Layer::Sa, Layer::Sa, false)
}
fn target_only(selector: &FacetSelector, source: Layer, target: Layer) -> bool {
match selector {
FacetSelector::Member { keys, .. } | FacetSelector::Binding(keys) => {
chosen(keys, source).is_none() && chosen(keys, target).is_some()
fn member_present(fact: &FacetReview, layer: Layer) -> bool {
fn observed<T>(review: &Reviewed<T>, layer: Layer) -> &Observed<T> {
match layer {
Layer::Oa => &review.oa,
Layer::Sa => &review.sa,
}
FacetSelector::Literal(keys) => {
chosen(keys, source).is_none() && chosen(keys, target).is_some()
}
match fact {
FacetReview::Member { review, .. } | FacetReview::Literal { review, .. } => {
matches!(observed(review, layer), Observed::Value(true))
}
FacetSelector::Annotation(keys) => {
chosen(keys, source).is_none() && chosen(keys, target).is_some()
FacetReview::Annotation { review, .. } => {
matches!(observed(review, layer), Observed::Value(Some(_)))
}
FacetReview::Binding { review, .. } => {
matches!(observed(review, layer), Observed::Value(Some(_)))
}
_ => false,
}
@@ -320,15 +326,19 @@ fn preserve_constructor_members(
source_layer: Layer,
target_layer: Layer,
) -> Result<(), String> {
// A correspondence key can exist without membership on that side (for
// example an OA literal automatically projects the same SA literal key).
// Preserve actual target-only values, except previously reviewed removals.
for fact in initial_facts {
if !constructor_contains(group, &fact.selector)
|| !target_only(&fact.selector, source_layer, target_layer)
|| member_present(&fact.current, source_layer)
|| !member_present(&fact.current, target_layer)
{
continue;
}
let slot = target_slot(target, &fact.selector, target_layer)?;
let was_corresponding = baseline.facets.iter().any(|old| {
!target_only(&old.selector(), source_layer, target_layer)
member_present(old, source_layer)
&& target_slot(target, &old.selector(), target_layer).ok() == Some(slot.clone())
});
if was_corresponding {
@@ -723,12 +733,28 @@ fn build_review(
constructor_actions.push((target, selected.clone(), local_record));
}
let refreshed = semantic_facets(observed, &trace, &references)?;
let group_selectors: BTreeSet<_> = refreshed
let mut group_selectors: BTreeSet<_> = refreshed
.iter()
.chain(all.iter())
.map(|f| f.selector.clone())
.filter(|f| constructor_contains(selected, f))
.collect();
// A constructor copy can turn a live one-sided member into a
// pair. Its initial projection is not another reviewed fact.
// Established one-sided history still requires an explicit reset.
let paired_sides: BTreeSet<_> = group_selectors
.iter()
.map(selector_slots)
.filter(|slots| slots.len() == 2)
.flatten()
.collect();
group_selectors.retain(|selector| {
!paired_sides.contains(selector)
|| baseline
.facets
.iter()
.any(|old| old.selector() == *selector)
});
let mut group = vec![];
for selector in group_selectors {
match capture_facet(observed, oa, sa, &selector) {
@@ -769,7 +795,18 @@ fn build_review(
current.clone()
};
if resolution.decision == SemanticDecision::TakeSource {
fact = remap_fact(&fact, &map, source_layer)?;
fact = if let Some(adapted) = transitions::review_endpoint_fact(
initial,
&plan.request.transition,
&map,
request.source,
target,
&fact,
)? {
adapted
} else {
remap_fact(&fact, &map, source_layer)?
};
}
// A Keep reads the frozen target, never a value written by an earlier source.
if resolution.decision != SemanticDecision::KeepTarget {
+5
View File
@@ -86,6 +86,7 @@ macro_rules! scalar_families {
ExchangeItem, ExchangeItemFacet, ExchangeItemReview {
Nature: ExchangeItemKind, Mechanism: ExchangeMechanism, Supertype: Option<Id>
}
Category, CategoryFacet, CategoryReview { Family: crate::view_options::ExchangeFamily }
Interface, InterfaceFacet, InterfaceReview { Supertype: Option<Id> }
Data, DataFacet, DataReview {
Constructor: DataConstructor,
@@ -111,6 +112,7 @@ macro_rules! scalar_families {
Constructor: OccurrenceConstructor, Precondition: String, Postcondition: String,
Exchange: Option<Id>, Sender: Option<Id>, Receiver: Option<Id>,
MessageKind: MessageConstructor, TimerExpression: String, ReplyTo: Option<Id>,
Operation: Option<OperationUse>,
Action: Option<Id>, ActionLifeline: Option<Id>, ControlKind: ControlKind,
Sequence: Option<Id>, Scenario: Option<Id>, State: Option<Id>,
StateLifeline: Option<Id>, FragmentOperator: FragmentOperator, BranchGuard: String
@@ -128,6 +130,8 @@ macro_rules! scalar_families {
SourcePort: Id, TargetPort: Id, Oriented: bool, ExtendCondition: String,
SequenceGuard: String, TransitionKind: TransitionKind, Trigger: String,
Guard: String, Effect: String, StateActionPhase: StateActionKind
, ContextOwner: Id, ContextSource: OccurrenceEndpoint, ContextTarget: OccurrenceEndpoint,
ContextPurpose: ContextualLinkPurpose
}
}};
}
@@ -197,6 +201,7 @@ pub enum ReferenceCollection {
FunctionOutputItems,
SignalItems,
ExchangeItems,
CategoryExchanges,
TransitionTriggers,
TransitionEffects,
Constrained,
+1
View File
@@ -660,6 +660,7 @@ fn apply_command(
.unwrap();
*occ.shared_mut().unwrap() = SharedBody::Occurrence {
body: OccurrenceBody::Exchange {
operation: None,
exchange: Some(*exchange),
sender: None,
receiver: None,
+706 -5
View File
@@ -9,6 +9,13 @@
//! may remain after interruption during staging/completed cleanup. They contain
//! no pending transaction unless named `.arcadia-save-ENTRY.journal`. Windows
//! has no portable directory fsync; this is not a power-loss durability promise.
//! Journal v2 records whether writes may have begun with a flushed marker.
//! Recovery of a v2 journal without that marker discards only journal metadata;
//! v1 journals retain their prior rollback rules.
//! Journal publication refuses replacement on Windows and Linux, including a
//! competing empty directory. Linux requires kernel/filesystem support for
//! `renameat2(RENAME_NOREPLACE)` and fails without a fallback when unavailable.
//! Other operating systems retain standard rename semantics.
use crate::{language, source_project::*};
use serde::{Deserialize, Serialize};
use std::{
@@ -22,6 +29,8 @@ use thiserror::Error;
const MAX_PATH_BYTES: usize = 4096;
const MAX_JOURNAL_BYTES: usize = 32 * 1024 * 1024;
const MAX_JOURNAL_FILES: usize = 4096;
const WRITE_STARTED: &str = "write-started";
const WRITE_STARTED_BYTES: &[u8] = b"Arcadia journal v2: model writes may have started\n";
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DiskExpectation {
@@ -588,6 +597,127 @@ fn staged_path(journal: &Path, index: usize, suffix: &str) -> PathBuf {
journal.join(format!("{index}.{suffix}"))
}
fn publish_journal(stage: &Path, journal: &Path) -> Result<(), SourceFileError> {
#[cfg(windows)]
{
publish_journal_with_retry(
stage,
journal,
rename_journal_without_replacement,
std::thread::sleep,
)
}
#[cfg(target_os = "linux")]
{
rename_journal_without_replacement(stage, journal).map_err(|error| io_at(journal, error))
}
// Other platforms retain their existing rename behavior; the native
// no-replacement publication guarantee is currently Windows/Linux only.
#[cfg(not(any(windows, target_os = "linux")))]
{
fs::rename(stage, journal).map_err(|error| io_at(journal, error))
}
}
#[cfg(target_os = "linux")]
fn rename_journal_without_replacement(stage: &Path, journal: &Path) -> io::Result<()> {
use std::{ffi::CString, os::unix::ffi::OsStrExt};
let path_bytes = |path: &Path| {
CString::new(path.as_os_str().as_bytes()).map_err(|_| {
io::Error::new(
io::ErrorKind::InvalidInput,
"Journal path contains an interior NUL.",
)
})
};
let (stage, journal) = (path_bytes(stage)?, path_bytes(journal)?);
// Use libc's architecture-specific syscall number, avoiding a dependency on
// the newer glibc renameat2 wrapper. Both NUL-terminated byte buffers remain
// alive for the call. RENAME_NOREPLACE makes even an empty competing directory
// an error. Unsupported kernels/filesystems fail closed: never fall back to
// ordinary rename, whose replacement semantics would lose journal ownership.
let result = unsafe {
libc::syscall(
libc::SYS_renameat2,
libc::AT_FDCWD as libc::c_long,
stage.as_ptr(),
libc::AT_FDCWD as libc::c_long,
journal.as_ptr(),
libc::RENAME_NOREPLACE as libc::c_ulong,
)
};
if result == -1 {
Err(io::Error::last_os_error())
} else {
Ok(())
}
}
#[cfg(windows)]
fn rename_journal_without_replacement(stage: &Path, journal: &Path) -> io::Result<()> {
use std::os::windows::ffi::OsStrExt;
#[link(name = "kernel32")]
unsafe extern "system" {
fn MoveFileExW(existing: *const u16, destination: *const u16, flags: u32) -> i32;
}
let wide = |path: &Path| -> io::Result<Vec<u16>> {
let mut value: Vec<_> = path.as_os_str().encode_wide().collect();
if value.contains(&0) {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"Journal path contains an interior NUL.",
));
}
value.push(0);
Ok(value)
};
let (stage, journal) = (wide(stage)?, wide(journal)?);
// Both terminated UTF-16 buffers live through the call. Flags=0 requests
// neither replacement, cross-volume copying nor deferred execution. Unlike
// fs::rename on modern Windows, an existing empty directory is not replaced.
if unsafe { MoveFileExW(stage.as_ptr(), journal.as_ptr(), 0) } == 0 {
Err(io::Error::last_os_error())
} else {
Ok(())
}
}
// Windows scanners may briefly open newly written stage files without delete
// sharing. Retry only publication, before any model file has been replaced.
// Ownership still transfers only after one successful rename. Never overwrite
// a journal that appeared while waiting, and retain the final OS error/path.
#[cfg(any(windows, test))]
fn publish_journal_with_retry(
stage: &Path,
journal: &Path,
mut rename: impl FnMut(&Path, &Path) -> io::Result<()>,
mut pause: impl FnMut(std::time::Duration),
) -> Result<(), SourceFileError> {
let mut delays = [5, 10, 25, 50, 100, 200].into_iter();
loop {
match fs::symlink_metadata(journal) {
Ok(_) => return Err(SourceFileError::RecoveryRequired(journal.into())),
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
Err(error) => return Err(io_at(journal, error)),
}
match rename(stage, journal) {
Ok(()) => return Ok(()),
Err(error) => {
// ERROR_ACCESS_DENIED, ERROR_SHARING_VIOLATION, ERROR_LOCK_VIOLATION.
// A permanent ACL failure is still returned after at most 390 ms.
if matches!(error.raw_os_error(), Some(5 | 32 | 33))
&& let Some(delay) = delays.next()
{
pause(std::time::Duration::from_millis(delay));
continue;
}
return Err(io_at(journal, error));
}
}
}
}
fn write_synced(path: &Path, bytes: &[u8]) -> Result<(), SourceFileError> {
let mut file = File::options()
.write(true)
@@ -699,6 +829,26 @@ fn save_with_hook(
compiled: &CompiledSourceProject,
limits: ProjectLimits,
mut after_replace: impl FnMut(usize) -> io::Result<()>,
) -> Result<DiskProject, SourceFileError> {
save_with_phases(snapshot, compiled, limits, |phase| match phase {
SavePhase::AfterReplace(count) => after_replace(count),
_ => Ok(()),
})
}
enum SavePhase {
BeforePublish,
AfterPublish,
AfterWriteMarker,
AfterReplace(usize),
BeforeRetire,
}
fn save_with_phases(
snapshot: &DiskProject,
compiled: &CompiledSourceProject,
limits: ProjectLimits,
mut phase: impl FnMut(SavePhase) -> io::Result<()>,
) -> Result<DiskProject, SourceFileError> {
check_snapshot(snapshot, limits)?;
let checked = compile_bundle(&compiled.bundle, limits).map_err(|diagnostics| {
@@ -739,7 +889,7 @@ fn save_with_hook(
// Stable dependency order, with the project entry committed last.
entries.sort_by_key(|file| (file.path == snapshot.bundle.entry, file.path.clone()));
let journal = Journal {
version: 1,
version: 2,
entry: snapshot.bundle.entry.clone(),
files: entries,
};
@@ -775,20 +925,46 @@ fn save_with_hook(
}
write_synced(&stage.path().join("journal.json"), &metadata)?;
sync_directory(stage.path())?;
phase(SavePhase::BeforePublish).map_err(|error| io_at(&journal_dir, error))?;
// Publishing a complete directory makes interrupted staging harmless.
fs::rename(stage.path(), &journal_dir).map_err(|error| io_at(&journal_dir, error))?;
publish_journal(stage.path(), &journal_dir)?;
let _ = stage.keep(); // The renamed directory now belongs to recovery.
sync_directory(&snapshot.root)?;
phase(SavePhase::AfterPublish).map_err(|error| io_at(&journal_dir, error))?;
// Publication may have waited while another writer completed the same
// candidate. Nothing has been written by this save: never interpret their
// matching after-bytes as ours and roll them back. The absent v2 marker
// also preserves this distinction after interruption or retirement failure.
if let Err(error) = check_conflicts(&snapshot.root, &snapshot.expectations) {
let retirement = phase(SavePhase::BeforeRetire)
.map_err(|error| io_at(&journal_dir, error))
.and_then(|()| retire_journal(&snapshot.root, &journal_dir, &journal));
return match retirement {
Ok(()) => Err(error),
Err(retirement) => Err(SourceFileError::RecoveryFailed {
save: error.to_string(),
recovery: retirement.to_string(),
journal: journal_dir,
}),
};
}
write_synced(&journal_dir.join(WRITE_STARTED), WRITE_STARTED_BYTES)?;
sync_directory(&journal_dir)?;
phase(SavePhase::AfterWriteMarker).map_err(|error| io_at(&journal_dir, error))?;
let result = (|| {
let mut current = snapshot.expectations.clone();
let mut replacements = 0;
for file in journal.files.iter().filter(|file| file.write) {
check_conflicts(&snapshot.root, &current)?;
// The first check is above, before the durable write-attempt marker.
// Further checks follow real write attempts and may need rollback.
if replacements != 0 {
check_conflicts(&snapshot.root, &current)?;
}
let bytes = compiled.bundle.files[&file.path].as_bytes();
atomic_write(&snapshot.root, &file.path, bytes)?;
current.insert(file.path.clone(), DiskExpectation::Bytes(bytes.to_vec()));
replacements += 1;
after_replace(replacements)
phase(SavePhase::AfterReplace(replacements))
.map_err(|error| io_at(&snapshot.root.join(&file.path), error))?;
}
check_conflicts(&snapshot.root, &current)?;
@@ -804,6 +980,7 @@ fn save_with_hook(
}),
};
}
phase(SavePhase::BeforeRetire).map_err(|error| io_at(&journal_dir, error))?;
retire_journal(&snapshot.root, &journal_dir, &journal)?;
Ok(saved_snapshot(snapshot, compiled))
}
@@ -833,6 +1010,14 @@ fn remove_journal(directory: &Path, journal: &Journal) -> Result<(), SourceFileE
}
}
}
if journal.version == 2 {
let marker = directory.join(WRITE_STARTED);
match fs::remove_file(&marker) {
Ok(()) => {}
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
Err(error) => return Err(io_at(&marker, error)),
}
}
let metadata = directory.join("journal.json");
fs::remove_file(&metadata).map_err(|error| io_at(&metadata, error))?;
fs::remove_dir(directory).map_err(|error| io_at(directory, error))
@@ -898,7 +1083,10 @@ fn recover_with_hook(
let bytes = read_journal_file(&metadata_path, MAX_JOURNAL_BYTES)?;
let journal: Journal = serde_json::from_slice(&bytes)
.map_err(|error| SourceFileError::Invalid(format!("Invalid recovery journal: {error}")))?;
if journal.version != 1 || journal.entry != entry || journal.files.len() > limits.max_files {
if !matches!(journal.version, 1 | 2)
|| journal.entry != entry
|| journal.files.len() > limits.max_files
{
return Err(SourceFileError::Invalid(
"Recovery journal version, entry or file budget is invalid.".into(),
));
@@ -908,6 +1096,25 @@ fn recover_with_hook(
let mut folded = BTreeSet::new();
let (mut before_bytes, mut after_bytes) = (0usize, 0usize);
let mut allowed = BTreeSet::from(["journal.json".to_string()]);
let write_started = if journal.version == 2 {
let marker = directory.join(WRITE_STARTED);
match fs::symlink_metadata(&marker) {
Ok(_) => {
if read_journal_file(&marker, WRITE_STARTED_BYTES.len())? != WRITE_STARTED_BYTES {
return Err(SourceFileError::Invalid(
"Invalid journal write-started marker.".into(),
));
}
allowed.insert(WRITE_STARTED.into());
true
}
Err(error) if error.kind() == io::ErrorKind::NotFound => false,
Err(error) => return Err(io_at(&marker, error)),
}
} else {
// Frozen v1 journals predate the marker and retain their rollback rules.
true
};
for (index, file) in journal.files.iter().enumerate() {
disk_path(&root, &file.path)?;
if !folded.insert(file.path.to_lowercase()) {
@@ -952,6 +1159,10 @@ fn recover_with_hook(
));
}
}
if !write_started {
retire_journal(&root, &directory, &journal)?;
return Ok(true);
}
let mut current = BTreeMap::new();
let mut conflicts = Vec::new();
for (path, before) in &original {
@@ -995,6 +1206,342 @@ fn recover_with_hook(
mod transaction_tests {
use super::*;
#[cfg(target_os = "linux")]
#[test]
fn linux_journal_publication_preserves_competing_destinations_and_our_stage() {
use std::os::unix::{fs::MetadataExt, fs::symlink};
for kind in ["empty directory", "nonempty directory", "file", "symlink"] {
let directory = tempfile::tempdir().unwrap();
let stage = directory.path().join("stage");
let journal = directory.path().join("journal");
fs::create_dir(&stage).unwrap();
fs::write(stage.join("journal.json"), b"our complete metadata").unwrap();
// Simulate a competitor arriving after the caller's absence check.
assert!(!journal.exists());
match kind {
"empty directory" => fs::create_dir(&journal).unwrap(),
"nonempty directory" => {
fs::create_dir(&journal).unwrap();
fs::write(journal.join("journal.json"), b"competitor metadata").unwrap();
}
"file" => fs::write(&journal, b"competitor file").unwrap(),
"symlink" => symlink("absent competitor target", &journal).unwrap(),
_ => unreachable!(),
}
let before = fs::symlink_metadata(&journal).unwrap();
let error = publish_journal(&stage, &journal).unwrap_err();
assert!(
matches!(error, SourceFileError::Io { path, source }
if path == journal && source.raw_os_error() == Some(libc::EEXIST)),
"{kind} must refuse publication"
);
let after = fs::symlink_metadata(&journal).unwrap();
assert_eq!((before.dev(), before.ino()), (after.dev(), after.ino()));
assert_eq!(
fs::read(stage.join("journal.json")).unwrap(),
b"our complete metadata"
);
match kind {
"empty directory" => assert_eq!(fs::read_dir(&journal).unwrap().count(), 0),
"nonempty directory" => assert_eq!(
fs::read(journal.join("journal.json")).unwrap(),
b"competitor metadata"
),
"file" => assert_eq!(fs::read(&journal).unwrap(), b"competitor file"),
"symlink" => assert_eq!(
fs::read_link(&journal).unwrap(),
Path::new("absent competitor target")
),
_ => unreachable!(),
}
}
}
#[cfg(target_os = "linux")]
#[test]
fn linux_journal_publication_preserves_raw_path_bytes_and_rejects_nul() {
use std::{ffi::OsStr, os::unix::ffi::OsStrExt};
let directory = tempfile::tempdir().unwrap();
let stage = directory.path().join(OsStr::from_bytes(b"stage-\xff"));
let journal = directory.path().join(OsStr::from_bytes(b"journal-\xfe"));
fs::create_dir(&stage).unwrap();
fs::write(stage.join("journal.json"), b"complete metadata").unwrap();
for (a, b) in [
(Path::new("bad\0path"), journal.as_path()),
(stage.as_path(), Path::new("bad\0path")),
] {
let error = rename_journal_without_replacement(a, b).unwrap_err();
assert_eq!(error.kind(), io::ErrorKind::InvalidInput);
assert!(stage.join("journal.json").exists());
assert!(!journal.exists());
}
publish_journal(&stage, &journal).unwrap();
assert!(!stage.exists());
assert_eq!(
fs::read(journal.join("journal.json")).unwrap(),
b"complete metadata"
);
}
#[cfg(target_os = "linux")]
#[test]
fn linux_journal_publication_has_one_winner_for_concurrent_stages() {
use std::sync::Barrier;
let directory = tempfile::tempdir().unwrap();
let journal = directory.path().join("journal");
let stages = [
directory.path().join("stage-a"),
directory.path().join("stage-b"),
];
for (i, stage) in stages.iter().enumerate() {
fs::create_dir(stage).unwrap();
fs::write(stage.join("journal.json"), format!("complete writer {i}")).unwrap();
}
let barrier = Barrier::new(2);
let results = std::thread::scope(|scope| {
let journal = &journal;
let barrier = &barrier;
let handles: Vec<_> = stages
.iter()
.map(|stage| {
scope.spawn(move || {
assert!(!journal.exists());
barrier.wait();
publish_journal(stage, journal)
})
})
.collect();
handles
.into_iter()
.map(|handle| handle.join().unwrap())
.collect::<Vec<_>>()
});
assert_eq!(results.iter().filter(|result| result.is_ok()).count(), 1);
for (i, result) in results.into_iter().enumerate() {
match result {
Ok(()) => {
assert!(!stages[i].exists());
assert_eq!(
fs::read_to_string(journal.join("journal.json")).unwrap(),
format!("complete writer {i}")
);
}
Err(SourceFileError::Io { path, source }) => {
assert_eq!(path, journal);
assert_eq!(source.raw_os_error(), Some(libc::EEXIST));
assert_eq!(
fs::read_to_string(stages[i].join("journal.json")).unwrap(),
format!("complete writer {i}")
);
}
Err(error) => panic!("unexpected publication error: {error}"),
}
}
}
#[cfg(target_os = "linux")]
#[test]
#[ignore = "requires strace renameat2 error injection and OA_TEST_RENAMEAT2_ERRNO"]
fn linux_journal_publication_injected_unsupported_error_is_fail_closed() {
let errno: i32 = std::env::var("OA_TEST_RENAMEAT2_ERRNO")
.expect("set OA_TEST_RENAMEAT2_ERRNO to the injected Linux errno")
.parse()
.unwrap();
assert!(matches!(
errno,
libc::ENOSYS | libc::EINVAL | libc::EOPNOTSUPP
));
let (directory, snapshot, compiled) = fixture();
let journal = journal_path(&snapshot.root, &snapshot.bundle.entry);
let original: Vec<_> = snapshot
.bundle
.files
.keys()
.map(|path| (path.clone(), fs::read(directory.path().join(path)).unwrap()))
.collect();
let error = save_with_phases(&snapshot, &compiled, ProjectLimits::default(), |phase| {
if matches!(
phase,
SavePhase::AfterPublish | SavePhase::AfterWriteMarker | SavePhase::AfterReplace(_)
) {
panic!("unsupported no-replace publication must stop before ownership transfer");
}
Ok(())
})
.unwrap_err();
assert!(matches!(error, SourceFileError::Io { path, source }
if path == journal && source.raw_os_error() == Some(errno)));
assert!(!journal.exists());
for (path, bytes) in original {
assert_eq!(fs::read(directory.path().join(path)).unwrap(), bytes);
}
check_conflicts(&snapshot.root, &snapshot.expectations).unwrap();
}
#[cfg(target_os = "linux")]
#[test]
fn linux_journal_publication_refusal_never_starts_model_writes() {
let (directory, snapshot, compiled) = fixture();
let journal = journal_path(&snapshot.root, &snapshot.bundle.entry);
let original: Vec<_> = snapshot
.bundle
.files
.keys()
.map(|path| (path.clone(), fs::read(directory.path().join(path)).unwrap()))
.collect();
let error = save_with_phases(&snapshot, &compiled, ProjectLimits::default(), |phase| {
match phase {
SavePhase::BeforePublish => fs::create_dir(&journal).unwrap(),
SavePhase::AfterPublish
| SavePhase::AfterWriteMarker
| SavePhase::AfterReplace(_) => {
panic!("a rejected publisher must not obtain ownership or write source files")
}
_ => {}
}
Ok(())
})
.unwrap_err();
assert!(matches!(error, SourceFileError::Io { path, source }
if path == journal && source.raw_os_error() == Some(libc::EEXIST)));
assert_eq!(fs::read_dir(&journal).unwrap().count(), 0);
assert!(!journal.join(WRITE_STARTED).exists());
for (path, bytes) in original {
assert_eq!(fs::read(directory.path().join(path)).unwrap(), bytes);
}
check_conflicts(&snapshot.root, &snapshot.expectations).unwrap();
}
#[cfg(windows)]
#[test]
fn journal_publication_does_not_replace_empty_target_created_after_precheck() {
let directory = tempfile::tempdir().unwrap();
let stage = directory.path().join("stage");
let journal = directory.path().join("journal");
fs::create_dir(&stage).unwrap();
fs::write(stage.join("journal.json"), b"our journal").unwrap();
let mut called = false;
let result = publish_journal_with_retry(
&stage,
&journal,
|a, b| {
assert!(!called);
called = true;
fs::create_dir(b).unwrap();
rename_journal_without_replacement(a, b)
},
|_| {},
);
assert!(result.is_err());
assert!(called);
assert_eq!(
fs::read(stage.join("journal.json")).unwrap(),
b"our journal"
);
assert_eq!(fs::read_dir(&journal).unwrap().count(), 0);
assert!(
matches!(rename_journal_without_replacement(Path::new("bad\0path"), &journal), Err(error) if error.kind() == io::ErrorKind::InvalidInput)
);
}
#[test]
fn journal_publication_retries_only_transient_windows_errors_before_transferring_ownership() {
let directory = tempfile::tempdir().unwrap();
let stage = directory.path().join("stage");
let journal = directory.path().join("journal");
fs::create_dir(&stage).unwrap();
fs::write(stage.join("journal.json"), b"complete staged metadata").unwrap();
let mut errors = [5, 32, 33].into_iter();
let mut waits = vec![];
publish_journal_with_retry(
&stage,
&journal,
|a, b| {
assert!(a.join("journal.json").exists());
assert!(!b.exists());
match errors.next() {
Some(code) => Err(io::Error::from_raw_os_error(code)),
None => fs::rename(a, b),
}
},
|delay| waits.push(delay.as_millis()),
)
.unwrap();
assert_eq!(waits, [5, 10, 25]);
assert!(!stage.exists());
assert_eq!(
fs::read(journal.join("journal.json")).unwrap(),
b"complete staged metadata"
);
}
#[test]
fn journal_publication_exhaustion_and_permanent_errors_keep_stage_and_report_original_path() {
for (code, expected_calls, expected_wait) in [(5, 7, 390), (123, 1, 0)] {
let directory = tempfile::tempdir().unwrap();
let stage = directory.path().join("stage");
let journal = directory.path().join("journal");
fs::create_dir(&stage).unwrap();
fs::write(stage.join("journal.json"), b"before publication").unwrap();
let (mut calls, mut waited) = (0, 0);
let error = publish_journal_with_retry(
&stage,
&journal,
|_, _| {
calls += 1;
Err(io::Error::from_raw_os_error(code))
},
|delay| waited += delay.as_millis(),
)
.unwrap_err();
assert_eq!((calls, waited), (expected_calls, expected_wait));
assert!(
matches!(error, SourceFileError::Io { path, source } if path == journal && source.raw_os_error() == Some(code))
);
assert_eq!(
fs::read(stage.join("journal.json")).unwrap(),
b"before publication"
);
assert!(!journal.exists());
}
}
#[test]
fn journal_publication_never_replaces_another_journal_that_appears_during_retry() {
let directory = tempfile::tempdir().unwrap();
let stage = directory.path().join("stage");
let journal = directory.path().join("journal");
fs::create_dir(&stage).unwrap();
fs::write(stage.join("journal.json"), b"our staged journal").unwrap();
let mut calls = 0;
let error = publish_journal_with_retry(
&stage,
&journal,
|_, _| {
calls += 1;
Err(io::Error::from_raw_os_error(32))
},
|_| {
fs::create_dir(&journal).unwrap();
fs::write(journal.join("journal.json"), b"other save owns this").unwrap();
},
)
.unwrap_err();
assert_eq!(calls, 1);
assert!(matches!(error, SourceFileError::RecoveryRequired(path) if path == journal));
assert_eq!(
fs::read(journal.join("journal.json")).unwrap(),
b"other save owns this"
);
assert_eq!(
fs::read(stage.join("journal.json")).unwrap(),
b"our staged journal"
);
}
fn fixture() -> (tempfile::TempDir, DiskProject, CompiledSourceProject) {
let directory = tempfile::tempdir().unwrap();
fs::write(
@@ -1014,6 +1561,160 @@ mod transaction_tests {
(directory, snapshot, compiled)
}
#[test]
fn unwritten_journal_never_rolls_back_a_completed_writer_with_identical_after_bytes() {
for interrupt_retirement in [false, true] {
let (directory, snapshot, compiled) = fixture();
let error = save_with_phases(&snapshot, &compiled, ProjectLimits::default(), |phase| {
match phase {
SavePhase::BeforePublish => {
// A competing save finishes during our publication wait.
save_project(&snapshot, &compiled, ProjectLimits::default()).unwrap();
}
SavePhase::BeforeRetire if interrupt_retirement => {
return Err(io::Error::other("injected no-write retirement failure"));
}
SavePhase::AfterWriteMarker | SavePhase::AfterReplace(_) => {
panic!("losing writer must not start writes")
}
_ => {}
}
Ok(())
})
.unwrap_err();
assert!(matches!(
error,
SourceFileError::Conflict(_) | SourceFileError::RecoveryFailed { .. }
));
for (path, bytes) in &compiled.bundle.files {
assert_eq!(
fs::read_to_string(directory.path().join(path)).unwrap(),
*bytes
);
}
let journal = journal_path(&snapshot.root, &snapshot.bundle.entry);
assert_eq!(journal.exists(), interrupt_retirement);
if interrupt_retirement {
assert!(!journal.join(WRITE_STARTED).exists());
// Explicit recovery after a failed retirement must also leave
// the completed other writer untouched, even matching after bytes.
assert!(
recover_project(
&directory.path().join("project.arcadia"),
ProjectLimits::default()
)
.unwrap()
);
for (path, bytes) in &compiled.bundle.files {
assert_eq!(
fs::read_to_string(directory.path().join(path)).unwrap(),
*bytes
);
}
}
assert!(!journal.exists());
}
}
#[test]
fn recovery_before_any_write_accepts_v2_marker_and_keeps_v1_rollback_compatibility() {
for version in [1, 2] {
let (directory, snapshot, compiled) = fixture();
let interrupted = std::panic::catch_unwind(|| {
let _ = save_with_phases(&snapshot, &compiled, ProjectLimits::default(), |phase| {
if matches!(phase, SavePhase::AfterWriteMarker) {
panic!("interrupted before atomic write");
}
Ok(())
});
});
assert!(interrupted.is_err());
check_conflicts(&snapshot.root, &snapshot.expectations).unwrap();
let journal = journal_path(&snapshot.root, &snapshot.bundle.entry);
assert_eq!(
fs::read(journal.join(WRITE_STARTED)).unwrap(),
WRITE_STARTED_BYTES
);
if version == 1 {
// A frozen v1 journal has no marker and means writes may have begun.
let path = journal.join("journal.json");
let mut metadata: Journal =
serde_json::from_slice(&fs::read(&path).unwrap()).unwrap();
metadata.version = 1;
fs::write(path, serde_json::to_vec(&metadata).unwrap()).unwrap();
fs::remove_file(journal.join(WRITE_STARTED)).unwrap();
for (path, bytes) in &compiled.bundle.files {
fs::write(directory.path().join(path), bytes).unwrap();
}
}
assert!(
recover_project(
&directory.path().join("project.arcadia"),
ProjectLimits::default()
)
.unwrap()
);
check_conflicts(&snapshot.root, &snapshot.expectations).unwrap();
}
}
#[test]
fn interrupted_publication_without_write_marker_never_rolls_back_external_matching_bytes() {
let (directory, snapshot, compiled) = fixture();
let interrupted = std::panic::catch_unwind(|| {
let _ = save_with_phases(&snapshot, &compiled, ProjectLimits::default(), |phase| {
if matches!(phase, SavePhase::AfterPublish) {
panic!("interrupted before write marker");
}
Ok(())
});
});
assert!(interrupted.is_err());
let journal = journal_path(&snapshot.root, &snapshot.bundle.entry);
assert!(!journal.join(WRITE_STARTED).exists());
for (path, bytes) in &compiled.bundle.files {
fs::write(directory.path().join(path), bytes).unwrap();
}
assert!(
recover_project(
&directory.path().join("project.arcadia"),
ProjectLimits::default()
)
.unwrap()
);
for (path, bytes) in &compiled.bundle.files {
assert_eq!(
fs::read_to_string(directory.path().join(path)).unwrap(),
*bytes
);
}
assert!(!journal.exists());
}
#[test]
fn malformed_v2_write_marker_refuses_recovery_without_changing_source_bytes() {
let (directory, snapshot, compiled) = fixture();
let _ = std::panic::catch_unwind(|| {
let _ = save_with_phases(&snapshot, &compiled, ProjectLimits::default(), |phase| {
if matches!(phase, SavePhase::AfterWriteMarker) {
panic!("interrupted");
}
Ok(())
});
});
let journal = journal_path(&snapshot.root, &snapshot.bundle.entry);
fs::write(journal.join(WRITE_STARTED), b"partial").unwrap();
assert!(matches!(
recover_project(
&directory.path().join("project.arcadia"),
ProjectLimits::default()
),
Err(SourceFileError::Invalid(_))
));
check_conflicts(&snapshot.root, &snapshot.expectations).unwrap();
assert!(journal.exists());
}
#[test]
fn partial_save_failures_restore_every_original_and_write_entry_last() {
for stop in 1..=3 {
+5 -1
View File
@@ -1553,7 +1553,11 @@ fn semantic_owner(project: &Project, id: Id) -> Option<Id> {
return element.parent;
}
if let Some(relation) = project.relations.get(&id) {
return Some(relation.source());
return Some(
relation
.contextual_owner()
.unwrap_or_else(|| relation.source()),
);
}
if let Some(artifact) = project.artifact_links.get(&id) {
return Some(artifact.owner);
+352
View File
@@ -0,0 +1,352 @@
//! Caller-qualified process/chain addresses. Cache residency never affects admission.
use crate::*;
use std::{
cell::RefCell,
collections::{BTreeMap, BTreeSet},
};
mod analysis;
pub use analysis::*;
pub const MAX_REFERENCE_DEPTH: usize = 64;
pub const MAX_ANALYSIS_NODES: usize = 50_000;
pub const MAX_ANALYSIS_EDGES: usize = 100_000;
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct ContextAddress {
pub owner: Id,
pub endpoint: OccurrenceEndpoint,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolvedOccurrence {
pub address: ContextAddress,
pub defining_sequence: Id,
pub work: Option<Id>,
pub exchange: Option<Id>,
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[error("{message}")]
pub struct ContextError {
pub code: &'static str,
pub message: String,
}
fn failure(code: &'static str, message: &str) -> ContextError {
ContextError {
code,
message: message.into(),
}
}
struct Record {
parent: Option<Id>,
layer: Layer,
kind: Kind,
reference: Option<Id>,
work: Option<Id>,
exchange: Option<Id>,
contribution: bool,
}
pub struct TopologyIndex<'a> {
project: &'a Project,
records: BTreeMap<Id, Record>,
children: BTreeMap<Id, Vec<Id>>,
link_definitions: BTreeMap<Id, Vec<Id>>,
contextual_callers: BTreeSet<Id>,
cache: RefCell<BTreeMap<ContextAddress, ResolvedOccurrence>>,
cache_capacity: usize,
}
impl<'a> TopologyIndex<'a> {
pub fn new(project: &'a Project) -> Self {
Self::with_cache_capacity(project, 4096)
}
pub fn with_cache_capacity(project: &'a Project, cache_capacity: usize) -> Self {
let records: BTreeMap<_, _> = project
.elements
.values()
.map(|e| {
let (reference, work, exchange, contribution) = match e.shared() {
Some(SharedBody::Occurrence { body, .. }) => match body {
OccurrenceBody::SequenceReference { sequence } => {
(*sequence, None, None, true)
}
OccurrenceBody::Action { action, .. } => (None, *action, None, true),
OccurrenceBody::Exchange { exchange, .. } => (None, None, *exchange, true),
OccurrenceBody::Control { .. } => (None, None, None, true),
_ => (None, None, None, false),
},
_ => (None, None, None, false),
};
(
e.id,
Record {
parent: e.parent,
layer: e.layer(),
kind: e.kind(),
reference,
work,
exchange,
contribution,
},
)
})
.collect();
let mut children: BTreeMap<_, _> = project
.elements
.values()
.filter_map(|e| e.steps().map(|steps| (e.id, steps.clone())))
.collect();
let mut child_sets: BTreeMap<_, BTreeSet<_>> = children
.iter()
.map(|(owner, children)| (*owner, children.iter().copied().collect()))
.collect();
for (id, record) in &records {
if record.contribution
&& let Some(parent) = record.parent
{
let list = children.entry(parent).or_default();
if child_sets.entry(parent).or_default().insert(*id) {
list.push(*id);
}
}
}
for (owner, list) in &mut children {
list.retain(|id| {
records
.get(id)
.is_some_and(|r| r.parent == Some(*owner) && r.contribution)
});
}
let mut link_definitions: BTreeMap<_, Vec<_>> = BTreeMap::new();
let mut contextual_callers = BTreeSet::new();
for relation in project.relations.values() {
let owner = relation.contextual_owner().or_else(|| {
if relation.kind() == RelationKind::SequenceLink {
records.get(&relation.source()).and_then(|r| r.parent)
} else {
None
}
});
if let Some(owner) = owner {
link_definitions.entry(owner).or_default().push(relation.id);
}
if let Some(owner) = relation.contextual_owner() {
contextual_callers.insert(owner);
}
}
let mut callers: BTreeMap<_, Vec<_>> = BTreeMap::new();
for record in records.values() {
if let (Some(parent), Some(reference)) = (record.parent, record.reference) {
callers.entry(reference).or_default().push(parent);
}
}
let mut pending: Vec<_> = contextual_callers.iter().copied().collect();
while let Some(sequence) = pending.pop() {
for caller in callers.get(&sequence).into_iter().flatten() {
if contextual_callers.insert(*caller) {
pending.push(*caller);
}
}
}
Self {
project,
records,
children,
link_definitions,
contextual_callers,
cache: RefCell::new(BTreeMap::new()),
cache_capacity,
}
}
/// Resolve a caller path while an endpoint leaf is still being edited.
pub fn resolve_scope(&self, owner: Id, references: &[Id]) -> Result<Id, ContextError> {
if references.len() > MAX_REFERENCE_DEPTH {
return Err(failure(
"context_depth",
"A contextual path exceeds 64 references.",
));
}
let root = self.records.get(&owner).ok_or_else(|| {
failure(
"context_owner",
"The caller must be an existing process or functional chain.",
)
})?;
if !matches!(root.kind, Kind::Process | Kind::FunctionalChain) {
return Err(failure(
"context_owner",
"The caller must be a process or functional chain.",
));
}
let mut current = owner;
let mut visited = BTreeSet::from([owner]);
for step in references {
let reference = self.records.get(step).ok_or_else(|| {
failure(
"context_path",
"A reference occurrence in the endpoint path no longer exists.",
)
})?;
if reference.parent != Some(current) || reference.layer != root.layer {
return Err(failure(
"context_path",
"Each reference occurrence must belong directly to the preceding caller context.",
));
}
let next = reference.reference.ok_or_else(|| {
failure(
"context_path",
"Each path component must be a resolved sequence-reference occurrence.",
)
})?;
let target = self.records.get(&next).ok_or_else(|| {
failure(
"context_path",
"A referenced process or chain no longer exists.",
)
})?;
if target.layer != root.layer || target.kind != root.kind {
return Err(failure(
"context_path",
"References must resolve to sequences of the same kind and layer.",
));
}
if !visited.insert(next) {
return Err(failure(
"context_cycle",
"A contextual endpoint cannot traverse recursive sequence reuse.",
));
}
current = next;
}
Ok(current)
}
pub fn resolve(
&self,
owner: Id,
endpoint: &OccurrenceEndpoint,
) -> Result<ResolvedOccurrence, ContextError> {
if endpoint.references.len() > MAX_REFERENCE_DEPTH {
return Err(failure(
"context_depth",
"A contextual endpoint exceeds the 64-reference depth limit.",
));
}
let address = ContextAddress {
owner,
endpoint: endpoint.clone(),
};
if let Some(cached) = self.cache.borrow().get(&address) {
return Ok(cached.clone());
}
let current = self.resolve_scope(owner, &endpoint.references)?;
let root = &self.records[&owner];
let leaf = self.records.get(&endpoint.occurrence).ok_or_else(|| {
failure(
"context_leaf",
"The endpoint contribution no longer exists.",
)
})?;
if leaf.parent != Some(current) || leaf.layer != root.layer || !leaf.contribution {
return Err(failure(
"context_leaf",
"The endpoint must be a direct contribution of its resolved process or chain.",
));
}
let resolved = ResolvedOccurrence {
address: address.clone(),
defining_sequence: current,
work: leaf.work,
exchange: leaf.exchange,
};
if self.cache_capacity > 0 {
let mut cache = self.cache.borrow_mut();
if cache.len() >= self.cache_capacity {
cache.pop_first();
}
cache.insert(address, resolved.clone());
}
Ok(resolved)
}
/// Validate a caller-owned semantic connection, independent of graphical expansion.
pub fn validate_link(
&self,
owner: Id,
source: &OccurrenceEndpoint,
target: &OccurrenceEndpoint,
purpose: &ContextualLinkPurpose,
) -> Result<(ResolvedOccurrence, ResolvedOccurrence), ContextError> {
let a = self.resolve(owner, source)?;
let b = self.resolve(owner, target)?;
if a.address == b.address {
return Err(failure(
"context_self",
"A contextual link needs two distinct occurrence addresses.",
));
}
match purpose {
ContextualLinkPurpose::Precedence { guard } => {
if source.references.is_empty() && target.references.is_empty() {
return Err(failure(
"context_direct_precedence",
"Use a SequenceLink for precedence between two direct contributions.",
));
}
if !validation::valid_text(guard) {
return Err(failure(
"invalid_text",
"The precedence guard contains invalid text.",
));
}
let previous = a.exchange.and_then(|id| self.project.relations.get(&id));
let next = b.exchange.and_then(|id| self.project.relations.get(&id));
let mismatch = match (a.work, previous, b.work, next) {
(Some(work), _, _, Some(exchange)) => work != exchange.source(),
(_, Some(exchange), Some(work), _) => exchange.target() != work,
(_, Some(previous), _, Some(next)) => previous.target() != next.source(),
_ => false,
};
if mismatch {
return Err(failure(
"sequence_exchange_endpoint",
"Adjacent work/exchange contributions must agree with the referenced exchange endpoints.",
));
}
}
ContextualLinkPurpose::Exchange { exchange } => {
let relation = self.project.relations.get(exchange).ok_or_else(|| {
failure("context_exchange", "Choose an existing work exchange.")
})?;
let expected = if self.project.layer_of(owner) == Some(Layer::Oa) {
RelationKind::Interaction
} else {
RelationKind::FunctionalExchange
};
if relation.kind() != expected
|| self.project.layer_of(*exchange) != self.project.layer_of(owner)
|| a.work != Some(relation.source())
|| b.work != Some(relation.target())
{
return Err(failure(
"context_exchange",
"The selected same-layer exchange must connect the exact work referenced by the source and target Actions.",
));
}
}
ContextualLinkPurpose::CommonWork { work } => {
if !self.project.elements.get(work).is_some_and(|e| {
e.kind().is_work() && Some(e.layer()) == self.project.layer_of(owner)
}) || a.work != Some(*work)
|| b.work != Some(*work)
{
return Err(failure(
"context_common_work",
"Both Actions must reference the exact same existing work in this layer.",
));
}
}
}
Ok((a, b))
}
/// Direct child candidates for lazy UI exploration; never expands a Cartesian reuse tree.
pub fn contributions(&self, sequence: Id) -> Vec<Id> {
self.children.get(&sequence).cloned().unwrap_or_default()
}
}
@@ -0,0 +1,529 @@
use super::*;
#[cfg(test)]
#[path = "analysis_tests.rs"]
mod tests;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum ReferenceBoundary {
Entry,
Exit,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct ContextVertex {
pub address: ContextAddress,
pub boundary: Option<ReferenceBoundary>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ContextEdgeKind {
Authored,
SyntheticBoundary,
LegacyOrder,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ContextEdge {
pub relation: Option<Id>,
pub instance: Vec<Id>,
pub source: ContextVertex,
pub target: ContextVertex,
pub guard: String,
pub kind: ContextEdgeKind,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ContextAssembly {
pub relation: Id,
pub instance: Vec<Id>,
pub source: ContextAddress,
pub target: ContextAddress,
pub purpose: ContextualLinkPurpose,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ContextNotice {
pub address: ContextAddress,
pub code: &'static str,
pub message: String,
pub relations: Vec<Id>,
}
#[derive(Debug, Clone, Copy)]
pub struct AnalysisLimits {
pub nodes: usize,
pub edges: usize,
}
impl Default for AnalysisLimits {
fn default() -> Self {
Self {
nodes: MAX_ANALYSIS_NODES,
edges: MAX_ANALYSIS_EDGES,
}
}
}
#[derive(Debug, Clone, Default)]
pub struct ContextAnalysis {
pub nodes: BTreeMap<ContextAddress, ResolvedOccurrence>,
pub precedence: Vec<ContextEdge>,
pub assembly: Vec<ContextAssembly>,
pub entries: Vec<ContextVertex>,
pub exits: Vec<ContextVertex>,
pub notices: Vec<ContextNotice>,
/// A partial expansion must never be used for complete reachability conclusions.
pub complete: bool,
}
struct Builder<'a, 'p> {
index: &'a TopologyIndex<'p>,
root: Id,
limits: AnalysisLimits,
result: ContextAnalysis,
definitions: BTreeMap<Id, Vec<&'p Relation>>,
examined_edges: usize,
exhausted: bool,
}
impl TopologyIndex<'_> {
pub fn analyze(&self, owner: Id, limits: AnalysisLimits) -> ContextAnalysis {
self.analyze_counted(owner, limits).0
}
fn analyze_counted(&self, owner: Id, limits: AnalysisLimits) -> (ContextAnalysis, usize) {
let mut definitions: BTreeMap<_, Vec<_>> = BTreeMap::new();
for (owner, relations) in &self.link_definitions {
definitions.insert(
*owner,
relations
.iter()
.filter_map(|id| self.project.relations.get(id))
.collect(),
);
}
let mut b = Builder {
index: self,
root: owner,
limits,
result: ContextAnalysis {
complete: true,
..ContextAnalysis::default()
},
definitions,
examined_edges: 0,
exhausted: false,
};
(b.result.entries, b.result.exits) = b.expand(owner, &[], &mut BTreeSet::new());
if b.result.complete {
b.cardinality();
b.connectivity();
}
(b.result, b.examined_edges)
}
pub fn has_contextual_links(&self, owner: Id) -> bool {
self.contextual_callers.contains(&owner)
}
}
impl Builder<'_, '_> {
fn address(&self, path: &[Id], occurrence: Id) -> ContextAddress {
ContextAddress {
owner: self.root,
endpoint: OccurrenceEndpoint {
references: path.to_vec(),
occurrence,
},
}
}
fn vertex(&self, address: ContextAddress, source: bool) -> ContextVertex {
let reference = self
.index
.project
.elements
.get(&address.endpoint.occurrence)
.and_then(Element::shared)
.is_some_and(|s| {
matches!(
s,
SharedBody::Occurrence {
body: OccurrenceBody::SequenceReference { .. },
..
}
)
});
ContextVertex {
address,
boundary: reference.then_some(if source {
ReferenceBoundary::Exit
} else {
ReferenceBoundary::Entry
}),
}
}
fn incomplete(&mut self, address: ContextAddress, code: &'static str, message: &str) {
self.result.complete = false;
if !self
.result
.notices
.iter()
.any(|n| n.address == address && n.code == code)
{
self.result.notices.push(ContextNotice {
address,
code,
message: message.into(),
relations: vec![],
});
}
}
// Reserve work before resolving an authored endpoint, not merely before storing
// its output. Repeated malformed or filtered endpoints consume the same budget.
fn reserve_edge(&mut self, address: ContextAddress) -> bool {
if self.exhausted {
return false;
}
if self.examined_edges >= self.limits.edges {
self.exhausted = true;
self.incomplete(
address,
"context_analysis_limit",
"Contextual edge limit reached; connectivity analysis is incomplete.",
);
return false;
}
self.examined_edges += 1;
true
}
fn edge(&mut self, edge: ContextEdge) -> bool {
if !self.reserve_edge(edge.source.address.clone()) {
return false;
}
self.result.precedence.push(edge);
true
}
fn expand(
&mut self,
sequence: Id,
prefix: &[Id],
active: &mut BTreeSet<Id>,
) -> (Vec<ContextVertex>, Vec<ContextVertex>) {
if self.exhausted {
return (vec![], vec![]);
}
if prefix.len() > MAX_REFERENCE_DEPTH || !active.insert(sequence) {
self.incomplete(
self.address(prefix, sequence),
"context_analysis_incomplete",
"Recursive or over-depth reuse has no proven boundary path.",
);
return (vec![], vec![]);
}
let children = self.index.contributions(sequence);
if children.is_empty() {
self.incomplete(
self.address(prefix, sequence),
"context_analysis_incomplete",
"An empty or unresolved sequence has no proven boundary path.",
);
active.remove(&sequence);
return (vec![], vec![]);
}
let start = self.result.precedence.len();
for id in &children {
let address = self.address(prefix, *id);
if self.result.nodes.len() >= self.limits.nodes {
self.exhausted = true;
self.incomplete(
address,
"context_analysis_limit",
"Contextual node limit reached; connectivity analysis is incomplete.",
);
break;
}
match self.index.resolve(self.root, &address.endpoint) {
Ok(resolved) => {
self.result.nodes.insert(address.clone(), resolved);
}
Err(e) => {
self.incomplete(address, "context_analysis_incomplete", &e.message);
continue;
}
}
let reference = self.index.project.elements[id]
.shared()
.and_then(|s| match s {
SharedBody::Occurrence {
body: OccurrenceBody::SequenceReference { sequence },
..
} => Some(*sequence),
_ => None,
});
if let Some(reference) = reference {
let Some(callee) = reference else {
self.incomplete(
address,
"context_analysis_incomplete",
"An unresolved reference has no proven boundary path.",
);
continue;
};
let mut path = prefix.to_vec();
path.push(*id);
let (entries, exits) = self.expand(callee, &path, active);
if self.exhausted {
active.remove(&sequence);
return (vec![], vec![]);
}
if entries.is_empty() || exits.is_empty() {
self.incomplete(
address.clone(),
"context_analysis_incomplete",
"No referenced entry or exit is proven; no bypass is inferred.",
);
}
for target in entries {
if !self.edge(ContextEdge {
relation: None,
instance: prefix.to_vec(),
source: self.vertex(address.clone(), false),
target,
guard: String::new(),
kind: ContextEdgeKind::SyntheticBoundary,
}) {
break;
}
}
for source in exits {
if !self.edge(ContextEdge {
relation: None,
instance: prefix.to_vec(),
source,
target: self.vertex(address.clone(), true),
guard: String::new(),
kind: ContextEdgeKind::SyntheticBoundary,
}) {
break;
}
}
}
if self.exhausted {
break;
}
}
if self.exhausted {
active.remove(&sequence);
return (vec![], vec![]);
}
let relations = self.definitions.get(&sequence).cloned().unwrap_or_default();
for r in &relations {
if !self.reserve_edge(self.address(prefix, r.source())) {
break;
}
let (source, target, purpose) = match &r.body {
RelationBody::SequenceLink {
source,
target,
guard,
} => (
OccurrenceEndpoint {
references: vec![],
occurrence: *source,
},
OccurrenceEndpoint {
references: vec![],
occurrence: *target,
},
ContextualLinkPurpose::Precedence {
guard: guard.clone(),
},
),
RelationBody::ContextualLink {
source,
target,
purpose,
..
} => (source.clone(), target.clone(), purpose.clone()),
_ => continue,
};
let qualify = |mut endpoint: OccurrenceEndpoint| {
let mut path = prefix.to_vec();
path.extend(endpoint.references);
endpoint.references = path;
ContextAddress {
owner: self.root,
endpoint,
}
};
let source = qualify(source);
let target = qualify(target);
if !self.result.nodes.contains_key(&source) || !self.result.nodes.contains_key(&target)
{
self.incomplete(
source,
"context_analysis_incomplete",
"A link endpoint is unresolved in this bounded analysis.",
);
continue;
}
match purpose {
ContextualLinkPurpose::Precedence { guard } => {
self.result.precedence.push(ContextEdge {
relation: Some(r.id),
instance: prefix.to_vec(),
source: self.vertex(source, true),
target: self.vertex(target, false),
guard,
kind: ContextEdgeKind::Authored,
})
}
purpose => {
self.result.assembly.push(ContextAssembly {
relation: r.id,
instance: prefix.to_vec(),
source,
target,
purpose,
});
}
}
}
if self.exhausted {
active.remove(&sequence);
return (vec![], vec![]);
}
if relations.is_empty()
&& children.iter().all(|id| {
matches!(
self.index.project.elements[id].shared(),
Some(SharedBody::Occurrence {
body: OccurrenceBody::Exchange { .. },
..
})
)
})
{
for pair in children.windows(2) {
if !self.edge(ContextEdge {
relation: None,
instance: prefix.to_vec(),
source: self.vertex(self.address(prefix, pair[0]), true),
target: self.vertex(self.address(prefix, pair[1]), false),
guard: String::new(),
kind: ContextEdgeKind::LegacyOrder,
}) {
break;
}
}
}
if self.exhausted {
active.remove(&sequence);
return (vec![], vec![]);
}
// Intrinsic boundaries are determined before any enclosing caller links.
let edges = &self.result.precedence[start..];
let targets: BTreeSet<_> = edges.iter().map(|e| &e.target).collect();
let sources: BTreeSet<_> = edges.iter().map(|e| &e.source).collect();
let entries = children
.iter()
.map(|id| self.vertex(self.address(prefix, *id), false))
.filter(|v| !targets.contains(v))
.collect();
let exits = children
.iter()
.map(|id| self.vertex(self.address(prefix, *id), true))
.filter(|v| !sources.contains(v))
.collect();
active.remove(&sequence);
(entries, exits)
}
fn cardinality(&mut self) {
// Index authored incidence once; synthetic boundaries never count as controls.
let mut incoming: BTreeMap<_, Vec<_>> = BTreeMap::new();
let mut outgoing: BTreeMap<_, Vec<_>> = BTreeMap::new();
for edge in &self.result.precedence {
if edge.kind == ContextEdgeKind::Authored {
incoming.entry(&edge.target.address).or_default().push(edge);
outgoing.entry(&edge.source.address).or_default().push(edge);
}
}
for address in self.result.nodes.keys() {
let ins = incoming.get(address).cloned().unwrap_or_default();
let outs = outgoing.get(address).cloned().unwrap_or_default();
let before = ins.len();
let after = outs.len();
let control = match self.index.project.elements[&address.endpoint.occurrence].shared() {
Some(SharedBody::Occurrence {
body: OccurrenceBody::Control { kind },
..
}) => Some(*kind),
_ => None,
};
let issue = match control {
Some(kind) => { let valid = match kind { ControlKind::Choice | ControlKind::Fork => before == 1 && after >= 2, ControlKind::Merge | ControlKind::Join => before >= 2 && after == 1, ControlKind::Loop => before >= 1 && after >= 2 }; (!valid).then_some(("control_cardinality", format!("{kind:?} has {before} incoming and {after} outgoing authored precedence links in this caller context. Review the intended paths."))) }
None => (before > 1 || after > 1).then_some(("ambiguous_sequence_branch", "This contribution has multiple incoming or outgoing precedence links in this caller context. Review whether explicit merge/join or branching control is needed to describe the intended paths.".into())),
};
if let Some((code, message)) = issue {
self.result.notices.push(ContextNotice {
address: address.clone(),
code,
message,
relations: ins.iter().chain(&outs).filter_map(|e| e.relation).collect(),
});
}
if matches!(control, Some(ControlKind::Choice | ControlKind::Loop)) {
for edge in outs.iter().filter(|e| e.guard.trim().is_empty()) {
self.result.notices.push(ContextNotice { address: address.clone(), code: "missing_sequence_guard", message: "Describe the choice/loop condition for this path, or use an explicit 'else' guard.".into(), relations: edge.relation.into_iter().collect() });
}
}
}
}
fn connectivity(&mut self) {
let mut forward: BTreeMap<_, Vec<_>> = BTreeMap::new();
let mut reverse: BTreeMap<_, Vec<_>> = BTreeMap::new();
for edge in &self.result.precedence {
forward
.entry(edge.source.clone())
.or_default()
.push(edge.target.clone());
reverse
.entry(edge.target.clone())
.or_default()
.push(edge.source.clone());
}
let visit = |starts: &[ContextVertex],
graph: &BTreeMap<ContextVertex, Vec<ContextVertex>>| {
let mut pending = starts.to_vec();
let mut seen = BTreeSet::new();
while let Some(vertex) = pending.pop() {
if seen.insert(vertex.clone()) {
pending.extend(graph.get(&vertex).into_iter().flatten().cloned());
}
}
seen
};
let from_start = visit(&self.result.entries, &forward);
let to_end = visit(&self.result.exits, &reverse);
for address in self.result.nodes.keys() {
let entry = self.vertex(address.clone(), false);
let exit = self.vertex(address.clone(), true);
for (missing, code, message) in [
(
!from_start.contains(&entry),
"unreachable_contribution",
"No path from a structural caller entry reaches this contribution in this context.",
),
(
!to_end.contains(&exit),
"nonterminating_contribution",
"No structural caller exit is reachable from this contribution in this context; review the intended loop.",
),
(
self.result.nodes.len() > 1
&& !reverse.contains_key(&entry)
&& !forward.contains_key(&exit),
"isolated_contribution",
"This contribution has no incoming or outgoing precedence path in this caller context; assembly incidence is separate.",
),
] {
if missing {
self.result.notices.push(ContextNotice {
address: address.clone(),
code,
message: message.into(),
relations: vec![],
});
}
}
}
}
}
@@ -0,0 +1,172 @@
use super::*;
fn occurrence(p: &mut Project, owner: Id, name: &str, body: OccurrenceBody) -> Id {
let id = p
.add_element(Kind::Occurrence, Layer::Sa, name, Some(owner))
.unwrap();
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::Occurrence {
body,
precondition: String::new(),
postcondition: String::new(),
};
p.elements
.get_mut(&owner)
.unwrap()
.steps_mut()
.unwrap()
.push(id);
id
}
fn repeated(references: usize, links: usize) -> (Project, Id, Id) {
let mut p = Project::empty();
let root = p
.add_element(Kind::FunctionalChain, Layer::Sa, "Caller", None)
.unwrap();
let procedure = p
.add_element(Kind::FunctionalChain, Layer::Sa, "Reusable procedure", None)
.unwrap();
let mut leaves = Vec::new();
for name in ["A", "B"] {
let action = p
.add_element(Kind::Function, Layer::Sa, name, None)
.unwrap();
leaves.push(occurrence(
&mut p,
procedure,
name,
OccurrenceBody::Action {
action: Some(action),
lifeline: None,
},
));
}
for _ in 0..links {
p.relate(
RelationKind::SequenceLink,
"Authored A to B",
leaves[0],
leaves[1],
);
}
for _ in 0..references {
occurrence(
&mut p,
root,
"Reuse",
OccurrenceBody::SequenceReference {
sequence: Some(procedure),
},
);
}
(p, root, procedure)
}
#[test]
fn edge_work_budget_stops_repeated_expansion_not_just_output_storage() {
// Only ~3,000 possible contribution nodes, but a million authored edge
// instances. The edge budget must stop actual work before later references.
let (p, root, _) = repeated(1_000, 1_000);
let limits = AnalysisLimits::default();
let index = TopologyIndex::with_cache_capacity(&p, 1);
let (first, attempts) = index.analyze_counted(root, limits);
assert_eq!(attempts, limits.edges);
assert_eq!(first.precedence.len(), limits.edges);
assert!(!first.complete);
assert!(first.entries.is_empty() && first.exits.is_empty());
assert!(
first.nodes.len() < 1_000,
"later caller references must not be expanded"
);
assert_eq!(
first
.notices
.iter()
.filter(|n| n.code == "context_analysis_limit")
.count(),
1
);
assert!(
first
.notices
.iter()
.all(|n| n.code == "context_analysis_limit")
);
let (warm, warm_attempts) = index.analyze_counted(root, limits);
assert_eq!(warm_attempts, attempts);
assert_eq!(warm.nodes, first.nodes);
assert_eq!(warm.precedence, first.precedence);
assert_eq!(warm.notices, first.notices);
drop(warm);
let (uncached, uncached_attempts) =
TopologyIndex::with_cache_capacity(&p, 0).analyze_counted(root, limits);
assert_eq!(uncached_attempts, attempts);
assert_eq!(uncached.precedence, first.precedence);
assert_eq!(uncached.notices, first.notices);
}
#[test]
fn unresolved_authored_edges_consume_work_budget_without_stored_output() {
let (mut p, root, _) = repeated(20, 100);
let missing = Id::nil();
for relation in p.relations.values_mut() {
relation.body.set_endpoints(relation.source(), missing);
}
let (result, attempts) = TopologyIndex::new(&p).analyze_counted(
root,
AnalysisLimits {
nodes: 1_000,
edges: 17,
},
);
assert_eq!(attempts, 17);
assert!(result.precedence.is_empty());
assert_eq!(result.nodes.len(), 3);
assert!(!result.complete);
assert!(result.entries.is_empty() && result.exits.is_empty());
assert_eq!(
result
.notices
.iter()
.filter(|n| n.code == "context_analysis_limit")
.count(),
1
);
}
#[test]
fn exact_edge_budget_includes_reference_boundaries_and_node_abort_has_no_bypass() {
let (p, root, _) = repeated(1, 2);
let index = TopologyIndex::new(&p);
let (complete, count) = index.analyze_counted(root, AnalysisLimits { nodes: 3, edges: 4 });
assert!(complete.complete);
assert_eq!(count, 4); // two authored edges plus entry and exit connectors
assert_eq!(
complete
.precedence
.iter()
.filter(|e| e.kind == ContextEdgeKind::SyntheticBoundary)
.count(),
2
);
for limits in [
AnalysisLimits { nodes: 3, edges: 3 },
AnalysisLimits {
nodes: 1,
edges: 10,
},
] {
let (partial, count) = index.analyze_counted(root, limits);
assert!(!partial.complete);
assert!(partial.entries.is_empty() && partial.exits.is_empty());
assert!(count <= limits.edges);
assert_eq!(
partial
.notices
.iter()
.filter(|n| n.code == "context_analysis_limit")
.count(),
1
);
}
}
+235 -15
View File
@@ -1,6 +1,8 @@
use crate::*;
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
mod communication;
pub(crate) use communication::review_endpoint_fact;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum TransitionDirection {
@@ -53,6 +55,36 @@ pub struct FieldResolution {
pub field: TransitionField,
pub decision: FieldDecision,
}
/// Technical endpoint choices are scoped to one OA exchange. Participant
/// correspondence remains in `mappings`, never in these port identities.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub enum CommunicationPortChoice {
Reuse {
port: Id,
},
Create {
id: Id,
alias: String,
name: String,
description: String,
kind: ComponentPortKind,
orientation: ComponentPortOrientation,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CommunicationEndpointDecision {
pub source: Id,
pub source_port: CommunicationPortChoice,
pub target_port: CommunicationPortChoice,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct OperationOmission {
pub source: Id,
pub rationale: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct TransitionRequest {
@@ -63,6 +95,10 @@ pub struct TransitionRequest {
pub fields: Vec<TransitionField>,
pub resolutions: Vec<FieldResolution>,
pub destination_diagram: Option<Id>,
#[serde(default)]
pub communication_endpoints: Vec<CommunicationEndpointDecision>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub operation_omissions: Vec<OperationOmission>,
}
impl TransitionRequest {
pub fn new(direction: TransitionDirection, sources: Vec<Id>) -> Self {
@@ -74,6 +110,8 @@ impl TransitionRequest {
fields: vec![TransitionField::Name, TransitionField::Description],
resolutions: vec![],
destination_diagram: None,
communication_endpoints: vec![],
operation_omissions: vec![],
}
}
}
@@ -177,6 +215,7 @@ fn inferred_kind(kind: Kind, direction: TransitionDirection) -> Option<Kind> {
|| matches!(
k,
Kind::ExchangeItem
| Kind::ExchangeCategory
| Kind::DataType
| Kind::DataField
| Kind::Interface
@@ -255,6 +294,28 @@ fn mapped_relation(r: &Relation, direction: TransitionDirection) -> Option<Relat
items: items.clone(),
}
}
(
RelationBody::CommunicationMean {
oriented, items, ..
},
TransitionDirection::OaToSa,
) => RelationBody::BoundaryExchange {
source,
target,
oriented: *oriented,
items: items.clone(),
},
(
RelationBody::BoundaryExchange {
oriented, items, ..
},
TransitionDirection::SaToOa,
) => RelationBody::CommunicationMean {
source,
target,
oriented: *oriented,
items: items.clone(),
},
(
RelationBody::Allocation { .. }
| RelationBody::InterfaceItemAllocation { .. }
@@ -267,6 +328,7 @@ fn mapped_relation(r: &Relation, direction: TransitionDirection) -> Option<Relat
| RelationBody::MissionInvolvement { .. }
| RelationBody::MissionCapability { .. }
| RelationBody::SequenceLink { .. }
| RelationBody::ContextualLink { .. }
| RelationBody::ChainInvolvement { .. }
| RelationBody::BoundaryExchange { .. }
| RelationBody::InterfaceProvision { .. }
@@ -318,7 +380,7 @@ fn trace_kind(p: &Project, oa: Id, sa: Id) -> TraceKind {
_ => TraceKind::Manual,
},
_ => {
if matches!((p.relations.get(&oa),p.relations.get(&sa)),(Some(a),Some(b))if a.kind()==RelationKind::Interaction&&b.kind()==RelationKind::FunctionalExchange)
if matches!((p.relations.get(&oa),p.relations.get(&sa)),(Some(a),Some(b))if exchange_counterparts(a.kind(), b.kind()))
{
TraceKind::Exchange
} else {
@@ -349,11 +411,12 @@ fn sa_port_omissions(p: &Project) -> BTreeSet<Id> {
.collect();
loop {
let before = omitted.len();
for r in p
.relations
.values()
.filter(|r| r.kind() != RelationKind::FunctionalExchange)
{
for r in p.relations.values().filter(|r| {
!matches!(
r.kind(),
RelationKind::FunctionalExchange | RelationKind::BoundaryExchange
)
}) {
if crate::references::relation_references(r)
.iter()
.any(|id| omitted.contains(id))
@@ -395,11 +458,30 @@ pub fn plan_transition(
commands: vec![],
};
let mut selected: BTreeSet<_> = request.sources.iter().copied().collect();
let omitted = if request.direction == TransitionDirection::SaToOa {
let mut omitted = if request.direction == TransitionDirection::SaToOa {
sa_port_omissions(project)
} else {
BTreeSet::new()
};
// An explicit existing Legacy counterpart can be compared and reviewed
// without inventing an OA typed port. Only this port is retained: dependent
// SA implementation records and new counterpart requests stay omitted.
for mapping in &request.mappings {
if project
.elements
.get(&mapping.source)
.is_some_and(|e| e.layer() == Layer::Sa && crate::ports::port_contract(e).is_some())
&& mapping
.target
.and_then(|target| project.elements.get(&target))
.is_some_and(|e| {
e.layer() == Layer::Oa
&& crate::ports::port_contract(e) == Some(&PortContract::Legacy)
})
{
omitted.remove(&mapping.source);
}
}
let mut reported_omissions = BTreeSet::new();
if selected.len() != request.sources.len() {
conflict(
@@ -428,7 +510,16 @@ pub fn plan_transition(
continue;
}
if let Some(e) = project.elements.get(&id) {
selected.extend(crate::references::element_references(e));
let mut dependencies = e.clone();
if request.direction == TransitionDirection::SaToOa
&& let Some(SharedBody::Occurrence {
body: OccurrenceBody::Exchange { operation, .. },
..
}) = dependencies.shared_mut()
{
*operation = None;
}
selected.extend(crate::references::element_references(&dependencies));
// Allocations are owned by interfaces. Independent data
// associations travel only when both endpoint types are
// already required; they do not expand the dictionary.
@@ -451,12 +542,13 @@ pub fn plan_transition(
// their endpoint involvements, independently of list order.
if matches!(e.kind(), Kind::Process | Kind::FunctionalChain) {
selected.extend(project.relations.values().filter_map(|relation| {
(relation.kind() == RelationKind::SequenceLink
&& project
.elements
.get(&relation.source())
.and_then(|e| e.parent)
== Some(id))
(relation.contextual_owner() == Some(id)
|| (relation.kind() == RelationKind::SequenceLink
&& project
.elements
.get(&relation.source())
.and_then(|e| e.parent)
== Some(id)))
.then_some(relation.id)
}));
}
@@ -480,6 +572,15 @@ pub fn plan_transition(
}
} else if let Some(r) = project.relations.get(&id) {
if request.direction == TransitionDirection::SaToOa
&& r.kind() == RelationKind::BoundaryExchange
{
selected.extend(
[r.source(), r.target()].into_iter().filter_map(|port| {
project.elements.get(&port).and_then(|e| e.parent)
}),
);
selected.extend(r.body.items());
} else if request.direction == TransitionDirection::SaToOa
&& let RelationBody::FunctionalExchange {
source,
target,
@@ -513,6 +614,67 @@ pub fn plan_transition(
false
} else { true }
});
let mut operation_omissions = BTreeSet::new();
for decision in &request.operation_omissions {
if request.direction != TransitionDirection::SaToOa
|| !selected.contains(&decision.source)
|| !matches!(
project
.elements
.get(&decision.source)
.and_then(Element::shared),
Some(SharedBody::Occurrence {
body: OccurrenceBody::Exchange {
operation: Some(_),
message_kind: MessageKind::Call | MessageKind::Asynchronous,
..
},
..
})
)
|| decision.rationale.trim().is_empty()
|| !validation::valid_text(&decision.rationale)
|| !operation_omissions.insert(decision.source)
{
conflict(
&mut plan,
Some(decision.source),
None,
None,
"Operation omission needs one selected SA invocation, SA→OA direction, and a nonempty rationale; irrelevant or duplicate decisions reject.",
);
} else {
plan.warnings.push(format!(
"Reviewed operation omission: {} ({}): {}. Source contract is preserved.",
project.named(decision.source),
decision.source,
decision.rationale
));
}
}
if request.direction == TransitionDirection::SaToOa {
for source in &selected {
if matches!(
project.elements.get(source).and_then(Element::shared),
Some(SharedBody::Occurrence {
body: OccurrenceBody::Exchange {
operation: Some(_),
..
},
..
})
) && !operation_omissions.contains(source)
{
conflict(
&mut plan,
Some(*source),
None,
None,
"This technical operation has no OA operation slot. Review an explicit per-invocation omission with rationale; Reply derives the Call's decision.",
);
}
}
}
let mut choices = BTreeMap::new();
for mapping in &request.mappings {
if choices.insert(mapping.source, mapping).is_some() {
@@ -676,6 +838,8 @@ pub fn plan_transition(
}
}
let mut working = project.clone();
let communication_pairs =
communication::prepare_ports(project, &mut working, &targets, &mut plan);
let ordered_creations: Vec<_> = selected
.iter()
.filter(|id| creating.contains(id) && project.elements.contains_key(id))
@@ -704,6 +868,15 @@ pub fn plan_transition(
let mut created = e.clone();
created.id = target;
created.body = body;
if request.direction == TransitionDirection::SaToOa
&& operation_omissions.contains(source)
&& let Some(SharedBody::Occurrence {
body: OccurrenceBody::Exchange { operation, .. },
..
}) = created.shared_mut()
{
*operation = None;
}
created.alias = working.fresh_alias(
&format!(
"{}_{}",
@@ -745,6 +918,25 @@ pub fn plan_transition(
let mut created = r.clone();
created.id = target;
created.body = body;
// SA technical ports project through their reviewed owners; never
// place a port→participant pair in the global correspondence map.
if request.direction == TransitionDirection::SaToOa
&& r.kind() == RelationKind::BoundaryExchange
{
let owners = [r.source(), r.target()]
.map(|port| project.elements.get(&port).and_then(|e| e.parent));
if let [Some(a), Some(b)] = owners {
created.body.set_endpoints(a, b);
} else {
conflict(
&mut plan,
Some(*source),
Some(target),
None,
"Boundary endpoints need participant owners before communication transition.",
);
}
}
created.alias = working.fresh_alias(
&format!(
"{}_{}",
@@ -759,6 +951,21 @@ pub fn plan_transition(
*id = *mapped;
}
});
if request.direction == TransitionDirection::OaToSa
&& r.kind() == RelationKind::CommunicationMean
{
if let Some((a, b)) = communication_pairs.get(source) {
created.body.set_endpoints(*a, *b);
} else {
conflict(
&mut plan,
Some(*source),
Some(target),
None,
"Choose an explicit SA component-port pair for this communication means; participant mappings cannot stand in for ports.",
);
}
}
for dependency in crate::references::relation_references(&created) {
if dependency.is_nil()
&& request.direction == TransitionDirection::OaToSa
@@ -1120,9 +1327,22 @@ fn compatible_existing(
(Some(a), Some(b)) => compatible_kinds(a.kind(), b.kind(), direction),
_ => match (p.relations.get(&source), p.relations.get(&target)) {
(Some(a), Some(b)) => {
mapped_relation(a, direction).is_some_and(|body| body.kind() == b.kind())
(a.kind() == RelationKind::BoundaryExchange
&& b.kind() == RelationKind::BoundaryExchange)
|| mapped_relation(a, direction).is_some_and(|body| body.kind() == b.kind())
}
_ => false,
},
}
}
pub(crate) fn exchange_counterparts(oa: RelationKind, sa: RelationKind) -> bool {
matches!(
(oa, sa),
(RelationKind::Interaction, RelationKind::FunctionalExchange)
| (
RelationKind::CommunicationMean,
RelationKind::BoundaryExchange
)
)
}
@@ -0,0 +1,210 @@
use super::*;
fn port_id(choice: &CommunicationPortChoice) -> Id {
match choice {
CommunicationPortChoice::Reuse { port } => *port,
CommunicationPortChoice::Create { id, .. } => *id,
}
}
pub(super) fn prepare_ports(
original: &Project,
working: &mut Project,
targets: &BTreeMap<Id, Id>,
plan: &mut TransitionPlan,
) -> BTreeMap<Id, (Id, Id)> {
let mut pairs = BTreeMap::new();
for decision in plan.request.communication_endpoints.clone() {
let result = (|| -> Result<(Id, Id), String> {
if plan.request.direction != TransitionDirection::OaToSa {
return Err(
"SA endpoint choices apply only to OA→SA communication transition.".into(),
);
}
let means = original
.relations
.get(&decision.source)
.filter(|r| r.kind() == RelationKind::CommunicationMean)
.ok_or("Endpoint decision must select an OA communication means.")?;
if !plan.request.sources.contains(&means.id) && !targets.contains_key(&means.id) {
return Err("Endpoint decision is outside this transition selection.".into());
}
if pairs.contains_key(&decision.source) {
return Err("Choose one component-port pair per communication means.".into());
}
let mut pending = vec![];
for (participant, choice) in [
(means.source(), &decision.source_port),
(means.target(), &decision.target_port),
] {
let owner = targets.get(&participant).copied().ok_or("Review each OA participant's SA counterpart before selecting its component port.")?;
if working.elements.get(&owner).is_some_and(|e| {
e.layer() != Layer::Sa || !matches!(e.kind(), Kind::System | Kind::SystemActor)
}) {
return Err(
"Component-port owner must be the reviewed SA participant counterpart."
.into(),
);
}
match choice {
CommunicationPortChoice::Reuse { port } => {
let e = working
.elements
.get(port)
.ok_or("Selected component port does not exist.")?;
if e.parent != Some(owner)
|| e.layer() != Layer::Sa
|| !matches!(
crate::ports::port_contract(e),
Some(PortContract::Component { .. })
)
{
return Err("Selected component port must belong to the reviewed SA participant and have an explicit component contract.".into());
}
}
CommunicationPortChoice::Create {
id,
alias,
name,
description,
kind,
orientation,
} => {
if id.is_nil()
|| working.contains(*id)
|| pending.iter().any(|e: &Element| e.id == *id)
{
return Err("New component-port identity must be unused; choose Reuse for an existing port.".into());
}
pending.push(Element {
id: *id,
alias: alias.clone(),
name: name.clone(),
description: description.clone(),
parent: Some(owner),
body: ElementBody::shared_in(
Layer::Sa,
SharedBody::Port {
contract: PortContract::Component {
kind: *kind,
orientation: *orientation,
},
legacy: None,
},
),
});
}
}
}
let pair = (
port_id(&decision.source_port),
port_id(&decision.target_port),
);
if pair.0 == pair.1 || targets.get(&means.source()) == targets.get(&means.target()) {
return Err("Communication counterparts must have different participant owners; review the participant boundary mapping.".into());
}
for e in pending {
working.elements.insert(e.id, e.clone());
plan.commands.push(Command::CreateElement(e));
}
Ok(pair)
})();
match result {
Ok(pair) => {
pairs.insert(decision.source, pair);
}
Err(message) => conflict(
plan,
Some(decision.source),
targets.get(&decision.source).copied(),
None,
message,
),
}
}
pairs
}
/// Adapt only the transferred endpoint value. Reviewed baseline facts continue
/// to retain the actual participant/port identities on their respective sides.
pub(crate) fn review_endpoint_fact(
project: &Project,
request: &TransitionRequest,
map: &BTreeMap<Id, Id>,
source: Id,
target: Id,
fact: &FacetReview,
) -> Result<Option<FacetReview>, String> {
let (Some(a), Some(b)) = (
project.relations.get(&source),
project.relations.get(&target),
) else {
return Ok(None);
};
let forward =
a.kind() == RelationKind::CommunicationMean && b.kind() == RelationKind::BoundaryExchange;
let reverse =
a.kind() == RelationKind::BoundaryExchange && b.kind() == RelationKind::CommunicationMean;
if !forward && !reverse {
return Ok(None);
}
let (endpoint, is_source) = match fact {
FacetReview::Relation(RelationReview::Source(_)) => (a.source(), true),
FacetReview::Relation(RelationReview::Target(_)) => (a.target(), false),
_ => return Ok(None),
};
let mapped = if forward {
let decision = request.communication_endpoints.iter().find(|d| d.source == source)
.ok_or("Taking an OA endpoint requires this exchange's explicit SA component-port decision.")?;
let port = port_id(if is_source {
&decision.source_port
} else {
&decision.target_port
});
let expected_owner = map.get(&endpoint).ok_or(
"Review the participant counterpart before taking its communication endpoint.",
)?;
let e = project
.elements
.get(&port)
.ok_or("Selected communication endpoint port does not exist.")?;
if e.parent != Some(*expected_owner)
|| e.layer() != Layer::Sa
|| !matches!(
crate::ports::port_contract(e),
Some(PortContract::Component { .. })
)
{
return Err(
"Selected component port disagrees with the reviewed participant counterpart."
.into(),
);
}
port
} else {
let owner = project
.elements
.get(&endpoint)
.and_then(|e| e.parent)
.ok_or("SA boundary port needs a participant owner.")?;
let participant = *map.get(&owner).ok_or(
"Review the SA port owner's OA participant counterpart before taking this endpoint.",
)?;
if !project.elements.get(&participant).is_some_and(|e| {
e.layer() == Layer::Oa && matches!(e.kind(), Kind::Entity | Kind::Actor)
}) {
return Err("Boundary endpoint owner must map to an OA entity or actor.".into());
}
participant
};
let mut result = fact.clone();
if let FacetReview::Relation(RelationReview::Source(values) | RelationReview::Target(values)) =
&mut result
{
match request.direction.source_layer() {
Layer::Oa => values.oa = Observed::Value(mapped),
Layer::Sa => values.sa = Observed::Value(mapped),
}
}
Ok(Some(result))
}
+330 -30
View File
@@ -2,6 +2,8 @@ use crate::*;
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
mod coverage;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum Severity {
Error,
@@ -179,6 +181,8 @@ fn check_ids(out: &mut Vec<Issue>, owner: Id, field: &str, ids: &[Id]) {
pub fn validate(p: &Project) -> Vec<Issue> {
let mut out = Vec::new();
let coverage = coverage::Index::new(p);
let topology_index = crate::topology_context::TopologyIndex::new(p);
if p.schema_version != SCHEMA_VERSION {
error(
&mut out,
@@ -346,19 +350,15 @@ pub fn validate(p: &Project) -> Vec<Issue> {
);
}
if matches!(e.kind(), Kind::Capability | Kind::SystemCapability)
&& !p.relations.values().any(|r| {
matches!(
r.kind(),
RelationKind::CapabilityInvolvement | RelationKind::ChainInvolvement
) && r.target() == e.id
})
&& !coverage.grouping.contains(&e.id)
&& !coverage.supported_capabilities.contains(&e.id)
{
warning(
&mut out,
e.id,
"unsupported_capability",
"involvement",
"Connect the capability to involved participants, work, or a process/chain.",
"Connect the capability to involved participants, work, a process/chain, or an explicit scenario context.",
);
}
if let Some(steps) = e.steps() {
@@ -384,10 +384,27 @@ pub fn validate(p: &Project) -> Vec<Issue> {
"Add ordered occurrences.",
);
}
validate_sequence(p, e, steps, &mut out);
validate_sequence(p, e, steps, &topology_index, &mut out);
}
if let Some(shared) = e.shared() {
match shared {
SharedBody::ExchangeCategory { family, exchanges } => {
for issue in crate::view_options::validate_category(
p,
e.layer(),
e.parent,
*family,
exchanges,
) {
error(
&mut out,
e.id,
"exchange_category",
issue.field,
issue.message,
);
}
}
SharedBody::DataType { .. }
| SharedBody::DataField { .. }
| SharedBody::Interface { .. }
@@ -584,7 +601,7 @@ pub fn validate(p: &Project) -> Vec<Issue> {
}
text(&mut out, r.id, "name", &r.name);
text(&mut out, r.id, "description", &r.description);
validate_relation(p, r, &mut out);
validate_relation(p, r, &topology_index, &mut out);
crate::data::validate_relation(p, r, &mut out);
crate::ports::validate_boundary_interface_requirements(p, r, &mut out);
validate_relation_context(p, r, &mut out);
@@ -602,6 +619,14 @@ pub fn validate(p: &Project) -> Vec<Issue> {
}
text(&mut out, d.id, "name", &d.name);
text(&mut out, d.id, "description", &d.description);
let option_issues = if d.projection == ProjectionKind::Traceability {
crate::view_options::validate_options_cross_layer(p, &d.view_options)
} else {
crate::view_options::validate_options(p, d.layer, &d.view_options)
};
for issue in option_issues {
error(&mut out, d.id, "view_options", issue.field, issue.message);
}
if let Some(machine) = d.projection.state_machine()
&& !p
.elements
@@ -921,12 +946,62 @@ pub fn validate(p: &Project) -> Vec<Issue> {
);
}
}
if !out.iter().any(|issue| issue.severity == Severity::Error) {
coverage.validate(p, &mut out);
}
out
}
fn validate_sequence(p: &Project, e: &Element, steps: &[Id], out: &mut Vec<Issue>) {
fn validate_sequence(
p: &Project,
e: &Element,
steps: &[Id],
topology: &crate::topology_context::TopologyIndex<'_>,
out: &mut Vec<Issue>,
) {
if !matches!(e.kind(), Kind::Process | Kind::FunctionalChain) {
return;
}
if topology.has_contextual_links(e.id) {
let analysis = topology.analyze(e.id, crate::topology_context::AnalysisLimits::default());
for notice in analysis.notices {
let breadcrumb = notice
.address
.endpoint
.references
.iter()
.chain(std::iter::once(&notice.address.endpoint.occurrence))
.map(|id| format!("{} ({id})", p.named(*id)))
.collect::<Vec<_>>()
.join(" / ");
warning(
out,
e.id,
notice.code,
"steps",
format!("Caller '{}', {breadcrumb}: {}", e.name, notice.message),
);
}
if analysis.complete {
for (code, label, count) in [
("sequence_starts", "start", analysis.entries.len()),
("sequence_ends", "end", analysis.exits.len()),
] {
if count != 1 {
warning(
out,
e.id,
code,
"steps",
format!(
"Caller '{}' has {count} structural {label} boundaries; review the intended independent paths.",
e.name
),
);
}
}
}
return;
}
let links: Vec<_> = p
.relations
.values()
@@ -1254,40 +1329,149 @@ pub fn semantic_representation_compatible(
pub fn lifeline_matches_endpoint(p: &Project, line: Id, endpoint: Id) -> Option<bool> {
semantic_representation_compatible(p, represented(p, line)?, endpoint)
}
/// Effective semantic direction, including a Reply that reuses its Call exchange.
/// An incomplete reply correlation and a local timer have no inferred endpoints.
pub fn message_endpoints(p: &Project, body: &OccurrenceBody) -> Option<(Id, Id)> {
/// A single shared answer for scenario admission, drawing, and endpoint pickers.
/// Incomplete or ambiguous bindings never choose an arbitrary direction.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MessageEndpointResolution {
NotApplicable,
Incomplete,
Resolved { source: Id, target: Id },
Ambiguous,
Conflict,
}
pub fn resolve_message_endpoints(p: &Project, body: &OccurrenceBody) -> MessageEndpointResolution {
use MessageEndpointResolution as R;
let OccurrenceBody::Exchange {
exchange: Some(exchange),
message_kind,
reply_to,
sender,
receiver,
..
} = body
else {
return None;
return R::NotApplicable;
};
if matches!(message_kind, MessageKind::Timer { .. }) {
return None;
return R::NotApplicable;
}
let Some(relation) = p.relations.get(exchange) else {
return R::Incomplete;
};
let mut candidates = vec![relation.body.endpoints()];
// A mapped unoriented SA boundary retains the OA communication's possible
// directions. Fixed oriented/legacy exchange behavior remains unchanged.
let communication = matches!(
relation.body,
RelationBody::CommunicationMean { .. }
| RelationBody::BoundaryExchange {
oriented: false,
..
}
);
if matches!(
relation.body,
RelationBody::CommunicationMean {
oriented: false,
..
} | RelationBody::BoundaryExchange {
oriented: false,
..
}
) {
candidates.push((relation.target(), relation.source()));
}
if matches!(
relation.body,
RelationBody::BoundaryExchange {
oriented: false,
..
}
) {
candidates.retain(|(source, target)| {
match (
p.elements.get(source).and_then(crate::ports::port_contract),
p.elements.get(target).and_then(crate::ports::port_contract),
) {
(
Some(a @ PortContract::Component { .. }),
Some(b @ PortContract::Component { .. }),
) => {
crate::ports::component_allows(a, true)
&& crate::ports::component_allows(b, false)
}
// Legacy direction contracts remain fixed even for an
// unoriented declaration; do not reinterpret old ports.
_ => (*source, *target) == relation.body.endpoints(),
}
});
}
let relation = p.relations.get(exchange)?;
if message_kind == &MessageKind::Reply {
let Some(reply_to) = reply_to else {
return R::Incomplete;
};
let Some(SharedBody::Occurrence {
body:
OccurrenceBody::Exchange {
call_body @ OccurrenceBody::Exchange {
message_kind: MessageKind::Call,
exchange: Some(call_exchange),
..
},
..
}) = p.elements.get(&(*reply_to)?).and_then(Element::shared)
}) = p.elements.get(reply_to).and_then(Element::shared)
else {
return None;
return R::Incomplete;
};
if call_exchange == exchange {
return Some((relation.target(), relation.source()));
let R::Resolved { source, target } = resolve_message_endpoints(p, call_body) else {
return R::Incomplete;
};
candidates = vec![(target, source)];
} else if communication {
let R::Resolved { source, target } = resolve_message_endpoints(p, call_body) else {
return R::Incomplete;
};
let owner = |id| {
p.elements
.get(&id)
.filter(|e| e.kind() == Kind::Port)
.and_then(|e| e.parent)
.unwrap_or(id)
};
candidates.retain(|(a, b)| {
semantic_representation_compatible(p, owner(*a), owner(target)) == Some(true)
&& semantic_representation_compatible(p, owner(*b), owner(source)) == Some(true)
});
}
}
Some(relation.body.endpoints())
if communication {
candidates.retain(|(a, b)| {
sender.is_none_or(|line| lifeline_matches_endpoint(p, line, *a) != Some(false))
&& receiver.is_none_or(|line| lifeline_matches_endpoint(p, line, *b) != Some(false))
});
if candidates.is_empty() {
return R::Conflict;
}
if sender.and_then(|line| represented(p, line)).is_none()
|| receiver.and_then(|line| represented(p, line)).is_none()
{
return R::Incomplete;
}
if candidates.len() > 1 {
return R::Ambiguous;
}
}
let Some((source, target)) = candidates.first().copied() else {
return R::Conflict;
};
R::Resolved { source, target }
}
/// Compatibility adapter: only a resolved direction has inferred endpoints.
pub fn message_endpoints(p: &Project, body: &OccurrenceBody) -> Option<(Id, Id)> {
match resolve_message_endpoints(p, body) {
MessageEndpointResolution::Resolved { source, target } => Some((source, target)),
_ => None,
}
}
/// Only report order guaranteed by a common sequence or an enclosing branch path.
/// A call in one alternative does not become an unconditional call after the fragment.
@@ -1418,6 +1602,7 @@ fn validate_message(p: &Project, e: &Element, body: &OccurrenceBody, out: &mut V
receiver,
reply_to,
values,
..
} = body
else {
return;
@@ -1440,7 +1625,9 @@ fn validate_message(p: &Project, e: &Element, body: &OccurrenceBody, out: &mut V
ScenarioViewpoint::Functional => relation.kind() == RelationKind::FunctionalExchange,
ScenarioViewpoint::ParticipantInteraction => matches!(
relation.kind(),
RelationKind::Interaction | RelationKind::BoundaryExchange
RelationKind::Interaction
| RelationKind::BoundaryExchange
| RelationKind::CommunicationMean
),
ScenarioViewpoint::BoundaryInterface => matches!(
relation.kind(),
@@ -1628,6 +1815,34 @@ fn validate_message(p: &Project, e: &Element, body: &OccurrenceBody, out: &mut V
}
}
let relation = exchange.and_then(|id| p.relations.get(&id));
let operation = crate::operations::resolve(p, e.id);
let signature = match &operation {
Ok(crate::operations::OperationResolution::Selected(selected)) => {
if selected.transmission == TransmissionProtocol::Unspecified {
warning(
out,
e.id,
"operation_transmission_unspecified",
"operation",
"The selected operation transmission protocol is unspecified.",
);
}
Some(crate::operations::signature_relation(selected))
}
Ok(crate::operations::OperationResolution::Unresolved { reason, .. }) => {
warning(out, e.id, "operation_unresolved", "operation", reason);
None
}
Err(problem) => {
error(out, e.id, problem.code, "operation", &problem.message);
None
}
_ => None,
};
let value_relation = match &operation {
Ok(crate::operations::OperationResolution::Ordinary) => relation,
_ => signature.as_ref(),
};
if message_kind == &MessageKind::Call
&& relation.is_some_and(|r| {
!r.body
@@ -1673,10 +1888,48 @@ fn validate_message(p: &Project, e: &Element, body: &OccurrenceBody, out: &mut V
"The value path exceeds the bounded authoring depth.",
);
}
if let Some(r) = relation {
if matches!(
operation,
Ok(crate::operations::OperationResolution::Unresolved { .. })
) && let Some(owner) = value
.path
.first()
.or(Some(&value.field))
.and_then(|id| p.elements.get(id))
.and_then(|field| field.parent)
{
let path_context = Relation {
id: e.id,
alias: String::new(),
name: String::new(),
description: String::new(),
body: RelationBody::Interaction {
source: e.id,
target: e.id,
items: vec![owner],
},
};
if crate::data::carried_value_path(p, &path_context, &value.path, value.field).is_none()
{
error(
out,
e.id,
"message_value_path",
"values.path",
"Even while the operation is unresolved, a value path must follow existing Value record fields in order.",
);
}
}
if let Some(r) = value_relation {
if let Some(path) = crate::data::carried_value_path(p, r, &value.path, value.field) {
if crate::data::item_mechanism(p, path.item) == Some(ExchangeMechanism::Operation) {
let permitted = match message_kind {
MessageKind::Asynchronous if signature.is_some() => matches!(
path.direction,
ParameterDirection::Unspecified
| ParameterDirection::In
| ParameterDirection::InOut
),
MessageKind::Call => matches!(
path.direction,
ParameterDirection::Unspecified
@@ -1920,6 +2173,7 @@ fn validate_occurrence(p: &Project, e: &Element, body: &OccurrenceBody, out: &mu
RelationKind::Interaction
| RelationKind::FunctionalExchange
| RelationKind::BoundaryExchange
| RelationKind::CommunicationMean
) => {}
_ => error(
out,
@@ -2115,7 +2369,24 @@ fn relation_path_exists(p: &Project, kind: RelationKind, from: Id, to: Id) -> bo
false
}
fn validate_relation(p: &Project, r: &Relation, out: &mut Vec<Issue>) {
fn validate_relation(
p: &Project,
r: &Relation,
topology: &crate::topology_context::TopologyIndex<'_>,
out: &mut Vec<Issue>,
) {
if let RelationBody::ContextualLink {
owner,
source,
target,
purpose,
} = &r.body
{
if let Err(problem) = topology.validate_link(*owner, source, target, purpose) {
error(out, r.id, problem.code, "body", problem.message);
}
return;
}
let (source, target) = r.body.endpoints();
for id in crate::references::relation_references(r) {
// Most relation references name elements. Avoid constructing all model
@@ -2134,18 +2405,20 @@ fn validate_relation(p: &Project, r: &Relation, out: &mut Vec<Issue>) {
if let RelationBody::ExchangeAllocation { .. } = r.body {
match (p.relations.get(&source), p.relations.get(&target)) {
(Some(a), Some(b))
if a.kind() == RelationKind::BoundaryExchange
if ((a.kind() == RelationKind::BoundaryExchange
&& matches!(
b.kind(),
RelationKind::FunctionalExchange | RelationKind::Interaction
)
))
|| (a.kind() == RelationKind::CommunicationMean
&& b.kind() == RelationKind::Interaction))
&& p.layer_of(source) == p.layer_of(target) => {}
_ => error(
out,
r.id,
"exchange_allocation",
"endpoints",
"Exchange allocation relates a boundary exchange to a work exchange in the same layer.",
"Exchange allocation relates communication means to an OA interaction, or a boundary exchange to a work exchange, in the same layer.",
),
};
validate_exchange_allocation(p, r, out);
@@ -2396,7 +2669,7 @@ pub fn trace_permits(p: &Project, t: &Trace) -> bool {
_ => false,
},
_ => {
matches!((p.relations.get(&t.oa),p.relations.get(&t.sa)),(Some(a),Some(b))if t.kind==TraceKind::Exchange&&a.kind()==RelationKind::Interaction&&b.kind()==RelationKind::FunctionalExchange)
matches!((p.relations.get(&t.oa),p.relations.get(&t.sa)),(Some(a),Some(b))if t.kind==TraceKind::Exchange&&crate::transitions::exchange_counterparts(a.kind(), b.kind()))
}
}
}
@@ -2491,7 +2764,12 @@ pub fn can_connect(p: &Project, kind: RelationKind, source: Id, target: Id) -> b
*transition_kind = TransitionKind::Local;
}
let mut issues = vec![];
validate_relation(p, &candidate, &mut issues);
validate_relation(
p,
&candidate,
&crate::topology_context::TopologyIndex::new(p),
&mut issues,
);
validate_relation_context(p, &candidate, &mut issues);
if kind == RelationKind::StateTransition {
for endpoint in [source, target] {
@@ -2521,7 +2799,12 @@ pub fn can_connect_boundary(p: &Project, source: Id, target: Id, oriented: bool)
},
};
let mut issues = vec![];
validate_relation(p, &candidate, &mut issues);
validate_relation(
p,
&candidate,
&crate::topology_context::TopologyIndex::new(p),
&mut issues,
);
validate_relation_context(p, &candidate, &mut issues);
!issues.iter().any(|i| i.severity == Severity::Error)
}
@@ -2737,6 +3020,23 @@ fn validate_occurrence_context(
OccurrenceBody::Exchange {
sender, receiver, ..
} => {
match resolve_message_endpoints(p, body) {
MessageEndpointResolution::Conflict => error(
out,
e.id,
"scenario_endpoint",
"sender",
"Sender and receiver must agree with one permitted communication direction and the correlated Call.",
),
MessageEndpointResolution::Ambiguous => warning(
out,
e.id,
"ambiguous_message_direction",
"sender",
"Bind explicit participant lifelines to resolve the communication direction; shared roles allow both directions.",
),
_ => {}
}
if let Some((source, target)) = message_endpoints(p, body) {
check(out, *sender, Some(source), "sender");
check(out, *receiver, Some(target), "receiver");
+174
View File
@@ -0,0 +1,174 @@
//! Advisory presence of explicit behavior evidence, never inferred execution.
use super::*;
#[derive(Default)]
pub(super) struct Index {
pub grouping: BTreeSet<Id>,
pub supported_capabilities: BTreeSet<Id>,
contextual_capabilities: BTreeSet<Id>,
supported_missions: BTreeSet<Id>,
}
impl Index {
pub fn new(p: &Project) -> Self {
let mut index = Self::default();
for element in p.elements.values() {
if let Some(parent) = element.parent.and_then(|id| p.elements.get(&id))
&& parent.kind() == element.kind()
&& parent.layer() == element.layer()
{
index.grouping.insert(parent.id);
}
if let Some(SharedBody::Scenario {
context: Some(id), ..
}) = element.shared()
&& p.elements.get(id).is_some_and(|context| {
matches!(context.kind(), Kind::Capability | Kind::SystemCapability)
&& context.layer() == element.layer()
})
{
index.contextual_capabilities.insert(*id);
index.supported_capabilities.insert(*id);
}
}
for relation in p.relations.values() {
let (Some(source), Some(target)) = (
p.elements.get(&relation.source()),
p.elements.get(&relation.target()),
) else {
continue;
};
if source.layer() != target.layer()
|| !relation.kind().permits(source.kind(), target.kind())
{
continue;
}
match relation.kind() {
RelationKind::CapabilityInvolvement => {
index.supported_capabilities.insert(target.id);
}
RelationKind::ChainInvolvement => {
index.supported_capabilities.insert(target.id);
index.contextual_capabilities.insert(target.id);
}
RelationKind::MissionCapability => {
index.supported_missions.insert(source.id);
}
_ => {}
}
}
index
}
pub fn validate(&self, p: &Project, out: &mut Vec<Issue>) {
let (work, exchanges) = included_evidence(p);
for element in p.elements.values() {
if element.kind().is_work()
&& !self.grouping.contains(&element.id)
&& !work.contains(&element.id)
{
warning(
out,
element.id,
"work_without_behavior_context",
"",
"Include this leaf activity/function in a process/chain or scenario through a work action or exchange occurrence.",
);
}
if matches!(element.kind(), Kind::OperationalMission | Kind::Mission)
&& !self.supported_missions.contains(&element.id)
{
warning(
out,
element.id,
"mission_without_capability",
"",
"Connect this mission to a capability that delivers it.",
);
}
if matches!(element.kind(), Kind::Capability | Kind::SystemCapability)
&& !self.grouping.contains(&element.id)
&& self.supported_capabilities.contains(&element.id)
&& !self.contextual_capabilities.contains(&element.id)
{
warning(
out,
element.id,
"capability_without_behavior_context",
"",
"Add an explicit behavior context: link a process/chain with Chain involvement, or set a scenario's Context to this capability.",
);
}
}
for relation in p.relations.values().filter(|r| work_exchange(r.kind())) {
let mut non_leaf = false;
for (field, endpoint) in [("source", relation.source()), ("target", relation.target())]
{
if self.grouping.contains(&endpoint) {
non_leaf = true;
warning(
out,
relation.id,
"non_leaf_work_exchange",
field,
"Choose the leaf activity/function responsible for this exchange; keep any typed port owned by that function.",
);
}
}
if !non_leaf && !exchanges.contains(&relation.id) {
warning(
out,
relation.id,
"exchange_without_behavior_context",
"",
"Use this exchange in a process/chain or scenario.",
);
}
}
}
}
fn work_exchange(kind: RelationKind) -> bool {
matches!(
kind,
RelationKind::Interaction | RelationKind::FunctionalExchange
)
}
/// Only ordered, owned occurrences contribute. Each identity is visited once,
/// including reused containers; this remains bounded even on malformed drafts.
fn included_evidence(p: &Project) -> (BTreeSet<Id>, BTreeSet<Id>) {
let crate::view_options::BehaviorEvidence { work, exchanges } =
crate::view_options::all_behavior_evidence(p);
(work, exchanges)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn malformed_self_reuse_is_bounded_even_without_structural_gate() {
let mut p = Project::empty();
let scenario = p
.add_element(Kind::Scenario, Layer::Oa, "Self reuse", None)
.unwrap();
let occurrence = p
.add_element(Kind::Occurrence, Layer::Oa, "Cycle", Some(scenario))
.unwrap();
if let Some(SharedBody::Occurrence { body, .. }) =
p.elements.get_mut(&occurrence).unwrap().shared_mut()
{
*body = OccurrenceBody::ScenarioUse {
scenario: Some(scenario),
bindings: vec![],
};
}
p.elements
.get_mut(&scenario)
.unwrap()
.steps_mut()
.unwrap()
.push(occurrence);
assert_eq!(included_evidence(&p), (BTreeSet::new(), BTreeSet::new()));
}
}
+733
View File
@@ -0,0 +1,733 @@
//! Persisted view intent and pure visibility decisions over actual model identities.
//! Build `ViewIndex` once per accepted model revision. Preparing a view performs
//! set unions/reachability once; testing an edge never walks the model.
use crate::{
ContextualLinkPurpose, Id, Kind, Layer, OccurrenceBody, Project, RelationBody, RelationKind,
SharedBody,
};
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ExchangeFamily {
Work,
Participant,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub enum EdgeKind {
Relation(RelationKind),
Trace,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub enum RelationKinds {
#[default]
All,
Only(Vec<EdgeKind>),
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub enum CategorySelection {
#[default]
All,
Any(Vec<Id>),
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub enum ContextSelection {
#[default]
All,
Any(Vec<Id>),
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum Coverage {
#[default]
All,
InBehavior,
OutsideBehavior,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum ExchangeLabels {
#[default]
Name,
Items,
NameAndItems,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct ViewOptions {
pub hidden_edges: Vec<Id>,
pub relation_kinds: RelationKinds,
pub work_categories: CategorySelection,
pub participant_categories: CategorySelection,
pub coverage: Coverage,
pub work_contexts: ContextSelection,
pub exchange_labels: ExchangeLabels,
}
impl ViewOptions {
pub fn visit_references(&mut self, mut visit: impl FnMut(&mut Id)) {
for id in &mut self.hidden_edges {
visit(id);
}
for selection in [&mut self.work_categories, &mut self.participant_categories] {
if let CategorySelection::Any(ids) = selection {
for id in ids {
visit(id);
}
}
}
if let ContextSelection::Any(ids) = &mut self.work_contexts {
for id in ids {
visit(id);
}
}
}
/// Removing the last selected identity retains the explicit empty selector.
pub fn retain_references(&mut self, mut keep: impl FnMut(&Id) -> bool) {
self.hidden_edges.retain(&mut keep);
for selection in [&mut self.work_categories, &mut self.participant_categories] {
if let CategorySelection::Any(ids) = selection {
ids.retain(&mut keep);
}
}
if let ContextSelection::Any(ids) = &mut self.work_contexts {
ids.retain(keep);
}
}
}
/// Stable field names allow core/source adapters to locate the offending option.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OptionError {
pub field: &'static str,
pub reference: Option<Id>,
pub message: String,
}
pub fn exchange_family(kind: RelationKind) -> Option<ExchangeFamily> {
match kind {
RelationKind::Interaction | RelationKind::FunctionalExchange => Some(ExchangeFamily::Work),
RelationKind::BoundaryExchange | RelationKind::CommunicationMean => {
Some(ExchangeFamily::Participant)
}
_ => None,
}
}
fn category_member(p: &Project, layer: Layer, family: ExchangeFamily, id: Id) -> bool {
p.relations.get(&id).is_some_and(|r| {
p.layer_of(id) == Some(layer)
&& matches!(
(family, layer, r.kind()),
(ExchangeFamily::Work, Layer::Oa, RelationKind::Interaction)
| (
ExchangeFamily::Work,
Layer::Sa,
RelationKind::FunctionalExchange
)
| (
ExchangeFamily::Participant,
Layer::Oa,
RelationKind::CommunicationMean
)
| (
ExchangeFamily::Participant,
_,
RelationKind::BoundaryExchange
)
)
})
}
fn context_kind(kind: Kind) -> bool {
matches!(kind, Kind::Process | Kind::FunctionalChain | Kind::Scenario)
}
fn edge_layers(p: &Project, id: Id) -> Vec<Layer> {
if p.relations.contains_key(&id) {
p.layer_of(id).into_iter().collect()
} else if p.traces.get(&id).is_some_and(|trace| {
p.layer_of(trace.oa) == Some(Layer::Oa) && p.layer_of(trace.sa) == Some(Layer::Sa)
}) {
vec![Layer::Oa, Layer::Sa]
} else {
vec![]
}
}
fn error(field: &'static str, reference: Option<Id>, message: &str) -> OptionError {
OptionError {
field,
reference,
message: message.into(),
}
}
fn distinct_ids(field: &'static str, ids: &[Id], out: &mut Vec<OptionError>) {
let mut seen = BTreeSet::new();
for id in ids {
if !seen.insert(*id) {
out.push(error(
field,
Some(*id),
"Each identity must occur only once.",
));
}
}
}
pub fn validate_category(
p: &Project,
layer: Layer,
parent: Option<Id>,
family: ExchangeFamily,
exchanges: &[Id],
) -> Vec<OptionError> {
let mut out = vec![];
if parent.is_some() {
out.push(error(
"parent",
parent,
"Exchange categories belong at the layer root.",
));
}
distinct_ids("exchanges", exchanges, &mut out);
for id in exchanges {
if !category_member(p, layer, family, *id) {
out.push(error(
"exchanges",
Some(*id),
"Choose an existing exchange of this category's family and layer.",
));
}
}
out
}
pub fn validate_options(p: &Project, layer: Layer, options: &ViewOptions) -> Vec<OptionError> {
validate_options_in(p, Some(layer), options)
}
/// Traceability views admit references from either architecture layer.
pub fn validate_options_cross_layer(p: &Project, options: &ViewOptions) -> Vec<OptionError> {
validate_options_in(p, None, options)
}
fn validate_options_in(
p: &Project,
layer: Option<Layer>,
options: &ViewOptions,
) -> Vec<OptionError> {
let mut out = vec![];
distinct_ids("hidden_edges", &options.hidden_edges, &mut out);
for id in &options.hidden_edges {
let layers = edge_layers(p, *id);
if layers.is_empty() || layer.is_some_and(|layer| !layers.contains(&layer)) {
out.push(error(
"hidden_edges",
Some(*id),
"Choose an existing relationship or applicable trace in this view's layer.",
));
}
}
if let RelationKinds::Only(kinds) = &options.relation_kinds {
for (index, kind) in kinds.iter().enumerate() {
if kinds[..index].contains(kind) {
out.push(error(
"relation_kinds",
None,
"Each relationship-line kind must occur only once.",
));
}
}
}
for (field, selection, family) in [
(
"work_categories",
&options.work_categories,
ExchangeFamily::Work,
),
(
"participant_categories",
&options.participant_categories,
ExchangeFamily::Participant,
),
] {
if let CategorySelection::Any(ids) = selection {
distinct_ids(field, ids, &mut out);
for id in ids {
if !p.elements.get(id).is_some_and(|e| {
layer.is_none_or(|layer| e.layer() == layer)
&& matches!(e.shared(), Some(SharedBody::ExchangeCategory { family: f, .. }) if *f == family)
}) {
out.push(error(field, Some(*id), "Choose a category of this selector's family and layer."));
}
}
}
}
if let ContextSelection::Any(ids) = &options.work_contexts {
distinct_ids("work_contexts", ids, &mut out);
for id in ids {
if !p.elements.get(id).is_some_and(|e| {
layer.is_none_or(|layer| e.layer() == layer) && context_kind(e.kind())
}) {
out.push(error(
"work_contexts",
Some(*id),
"Choose a Process, Functional chain or Scenario in this view's layer.",
));
}
}
}
out
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct BehaviorEvidence {
pub work: BTreeSet<Id>,
pub exchanges: BTreeSet<Id>,
}
/// Explicit inclusion/reuse graph, independent of semantic-parent descendants.
#[derive(Debug, Clone, Default)]
struct BehaviorIndex {
roots: BTreeMap<Id, Layer>,
next: BTreeMap<Id, Vec<Id>>,
facts: BTreeMap<Id, BehaviorEvidence>,
}
impl BehaviorIndex {
fn new(p: &Project) -> Self {
let mut index = Self::default();
for element in p.elements.values() {
let id = element.id;
if context_kind(element.kind()) {
index.roots.insert(id, element.layer());
}
let next = index.next.entry(id).or_default();
if let Some(steps) = element.steps() {
next.extend(
steps
.iter()
.filter(|step| {
p.elements.get(step).is_some_and(|child| {
child.kind() == Kind::Occurrence
&& child.parent == Some(id)
&& child.layer() == element.layer()
})
})
.copied(),
);
}
let Some(SharedBody::Occurrence { body, .. }) = element.shared() else {
continue;
};
match body {
OccurrenceBody::Action {
action: Some(work), ..
} => {
if p.elements
.get(work)
.is_some_and(|e| e.kind().is_work() && e.layer() == element.layer())
{
index.facts.entry(id).or_default().work.insert(*work);
}
}
OccurrenceBody::Exchange {
exchange: Some(exchange),
..
} => {
if let Some(r) = p.relations.get(exchange)
&& exchange_family(r.kind()) == Some(ExchangeFamily::Work)
&& [r.source(), r.target()].iter().all(|id| {
p.elements
.get(id)
.is_some_and(|e| e.kind().is_work() && e.layer() == element.layer())
})
{
let facts = index.facts.entry(id).or_default();
facts.exchanges.insert(*exchange);
facts.work.extend([r.source(), r.target()]);
}
}
OccurrenceBody::Fragment { branches, .. } => {
next.extend(
branches
.iter()
.filter(|branch| {
p.elements.get(branch).is_some_and(|child| {
child.parent == Some(id)
&& child.layer() == element.layer()
&& matches!(
child.shared(),
Some(SharedBody::Occurrence {
body: OccurrenceBody::Branch { .. },
..
})
)
})
})
.copied(),
);
}
OccurrenceBody::SequenceReference {
sequence: Some(sequence),
} => {
if let Some(owner) = element.parent.and_then(|id| p.elements.get(&id))
&& matches!(owner.kind(), Kind::Process | Kind::FunctionalChain)
&& p.elements.get(sequence).is_some_and(|target| {
target.kind() == owner.kind() && target.layer() == element.layer()
})
{
next.push(*sequence);
}
}
OccurrenceBody::ScenarioUse {
scenario: Some(scenario),
..
} if p.elements.get(scenario).is_some_and(|target| {
target.kind() == Kind::Scenario && target.layer() == element.layer()
}) =>
{
next.push(*scenario);
}
_ => {}
}
}
let topology = crate::topology_context::TopologyIndex::new(p);
for relation in p.relations.values() {
if let RelationBody::ContextualLink {
owner,
source,
target,
purpose: purpose @ ContextualLinkPurpose::Exchange { exchange },
} = &relation.body
&& topology
.validate_link(*owner, source, target, purpose)
.is_ok()
{
let facts = index.facts.entry(*owner).or_default();
facts.exchanges.insert(*exchange);
if let Some(exchange) = p.relations.get(exchange) {
facts.work.extend([exchange.source(), exchange.target()]);
}
}
}
index
}
fn evidence(&self, roots: impl IntoIterator<Item = Id>) -> BehaviorEvidence {
let mut pending: Vec<_> = roots
.into_iter()
.filter(|id| self.roots.contains_key(id))
.collect();
let mut seen = BTreeSet::new();
let mut result = BehaviorEvidence::default();
while let Some(id) = pending.pop() {
if !seen.insert(id) {
continue;
}
if let Some(next) = self.next.get(&id) {
pending.extend(next);
}
if let Some(facts) = self.facts.get(&id) {
result.work.extend(&facts.work);
result.exchanges.extend(&facts.exchanges);
}
}
result
}
}
pub fn behavior_evidence(p: &Project, roots: impl IntoIterator<Item = Id>) -> BehaviorEvidence {
BehaviorIndex::new(p).evidence(roots)
}
pub fn all_behavior_evidence(p: &Project) -> BehaviorEvidence {
let index = BehaviorIndex::new(p);
index.evidence(index.roots.keys().copied())
}
#[derive(Debug, Clone)]
struct EdgeFact {
kind: EdgeKind,
family: Option<ExchangeFamily>,
layers: Vec<Layer>,
}
#[derive(Debug, Clone)]
struct CategoryFact {
layer: Layer,
family: ExchangeFamily,
exchanges: BTreeSet<Id>,
}
#[derive(Debug, Clone)]
pub struct ViewIndex {
edges: BTreeMap<Id, EdgeFact>,
categories: BTreeMap<Id, CategoryFact>,
behavior: BehaviorIndex,
global: BehaviorEvidence,
}
impl ViewIndex {
pub fn new(p: &Project) -> Self {
let behavior = BehaviorIndex::new(p);
let global = behavior.evidence(behavior.roots.keys().copied());
let mut edges: BTreeMap<_, _> = p
.relations
.values()
.map(|r| {
(
r.id,
EdgeFact {
kind: EdgeKind::Relation(r.kind()),
family: exchange_family(r.kind()),
layers: edge_layers(p, r.id),
},
)
})
.collect();
edges.extend(p.traces.keys().map(|id| {
(
*id,
EdgeFact {
kind: EdgeKind::Trace,
family: None,
layers: edge_layers(p, *id),
},
)
}));
let categories = p
.elements
.values()
.filter_map(|e| {
let SharedBody::ExchangeCategory { family, exchanges } = e.shared()? else {
return None;
};
Some((
e.id,
CategoryFact {
layer: e.layer(),
family: *family,
exchanges: exchanges
.iter()
.filter(|id| category_member(p, e.layer(), *family, **id))
.copied()
.collect(),
},
))
})
.collect();
Self {
edges,
categories,
behavior,
global,
}
}
pub fn all_evidence(&self) -> &BehaviorEvidence {
&self.global
}
/// Options must pass `validate_options` before model admission. Malformed
/// selectors are nevertheless fail-closed here, useful for draft previews.
pub fn prepare(&self, layer: Layer, options: &ViewOptions) -> PreparedFilter<'_> {
self.prepare_in(layer, options, false)
}
fn prepare_in(
&self,
layer: Layer,
options: &ViewOptions,
cross_layer: bool,
) -> PreparedFilter<'_> {
let category_union = |selection: &CategorySelection, family| match selection {
CategorySelection::All => None,
CategorySelection::Any(ids) => Some(
ids.iter()
.filter_map(|id| self.categories.get(id))
.filter(|c| (cross_layer || c.layer == layer) && c.family == family)
.flat_map(|c| c.exchanges.iter().copied())
.collect(),
),
};
let contexts = match &options.work_contexts {
ContextSelection::All => None,
ContextSelection::Any(ids) => Some(
self.behavior
.evidence(
ids.iter()
.filter(|id| {
self.behavior
.roots
.get(id)
.is_some_and(|candidate| cross_layer || *candidate == layer)
})
.copied(),
)
.exchanges,
),
};
PreparedFilter {
index: self,
layer,
cross_layer,
hidden: options.hidden_edges.iter().copied().collect(),
kinds: options.relation_kinds.clone(),
coverage: options.coverage,
work_categories: category_union(&options.work_categories, ExchangeFamily::Work),
participant_categories: category_union(
&options.participant_categories,
ExchangeFamily::Participant,
),
contexts,
}
}
/// Traceability diagrams admit edges, categories and contexts in both layers.
pub fn prepare_cross_layer(&self, layer: Layer, options: &ViewOptions) -> PreparedFilter<'_> {
self.prepare_in(layer, options, true)
}
}
/// Projection reports why an actual edge cannot have a drawable endpoint pair.
/// Filtering does not perform anchoring, folding, layout or placement insertion.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum EdgeAvailability {
Visible,
OutsideProjection,
EndpointNotShown { source: bool, target: bool },
FoldedInternal,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum OmissionReason {
ExplicitHide,
RelationKind,
Category(ExchangeFamily),
Coverage,
WorkContext,
OutsideProjection,
EndpointNotShown { source: bool, target: bool },
FoldedInternal,
WrongLayer,
UnknownEdge,
}
pub struct PreparedFilter<'a> {
index: &'a ViewIndex,
layer: Layer,
cross_layer: bool,
hidden: BTreeSet<Id>,
kinds: RelationKinds,
coverage: Coverage,
work_categories: Option<BTreeSet<Id>>,
participant_categories: Option<BTreeSet<Id>>,
contexts: Option<BTreeSet<Id>>,
}
impl PreparedFilter<'_> {
pub fn includes(&self, edge: Id) -> bool {
self.reasons(edge, EdgeAvailability::Visible).is_empty()
}
pub fn reasons(&self, edge: Id, availability: EdgeAvailability) -> Vec<OmissionReason> {
let mut out = vec![];
if self.hidden.contains(&edge) {
out.push(OmissionReason::ExplicitHide);
}
match availability {
EdgeAvailability::Visible => {}
EdgeAvailability::OutsideProjection => out.push(OmissionReason::OutsideProjection),
EdgeAvailability::EndpointNotShown { source, target } => {
out.push(OmissionReason::EndpointNotShown { source, target })
}
EdgeAvailability::FoldedInternal => out.push(OmissionReason::FoldedInternal),
}
let Some(fact) = self.index.edges.get(&edge) else {
out.push(OmissionReason::UnknownEdge);
return out;
};
if !fact.layers.contains(&self.layer) && !(self.cross_layer && !fact.layers.is_empty()) {
out.push(OmissionReason::WrongLayer);
}
if let RelationKinds::Only(kinds) = &self.kinds
&& !kinds.contains(&fact.kind)
{
out.push(OmissionReason::RelationKind);
}
if let Some(family) = fact.family {
let categories = match family {
ExchangeFamily::Work => &self.work_categories,
ExchangeFamily::Participant => &self.participant_categories,
};
if categories.as_ref().is_some_and(|ids| !ids.contains(&edge)) {
out.push(OmissionReason::Category(family));
}
if family == ExchangeFamily::Work {
let covered = self.index.global.exchanges.contains(&edge);
if matches!(self.coverage, Coverage::InBehavior) && !covered
|| matches!(self.coverage, Coverage::OutsideBehavior) && covered
{
out.push(OmissionReason::Coverage);
}
if self
.contexts
.as_ref()
.is_some_and(|ids| !ids.contains(&edge))
{
out.push(OmissionReason::WorkContext);
}
}
}
out
}
}
impl ExchangeLabels {
pub fn label(self, p: &Project, edge: Id) -> String {
let name = p.named(edge);
let Some(relation) = p
.relations
.get(&edge)
.filter(|r| exchange_family(r.kind()).is_some())
else {
return name.into();
};
if self == Self::Name {
return name.into();
}
let items = if relation.body.items().is_empty() {
"No items".into()
} else {
relation
.body
.items()
.iter()
.map(|id| p.named(*id))
.collect::<Vec<_>>()
.join(", ")
};
if self == Self::Items {
items
} else {
format!("{name} · {items}")
}
}
}
#[cfg(test)]
#[path = "view_options_tests.rs"]
mod tests;
+621
View File
@@ -0,0 +1,621 @@
use super::*;
use crate::{FragmentOperator, MessageKind, RelationBody, Trace, TraceKind};
fn add(p: &mut Project, kind: Kind, layer: Layer, name: &str) -> Id {
p.add_element(kind, layer, name, None).unwrap()
}
fn occurrence(p: &mut Project, owner: Id, body: OccurrenceBody) -> Id {
let id = p
.add_element(
Kind::Occurrence,
p.elements[&owner].layer(),
"Contribution",
Some(owner),
)
.unwrap();
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::Occurrence {
body,
precondition: String::new(),
postcondition: String::new(),
};
if let Some(steps) = p.elements.get_mut(&owner).unwrap().steps_mut() {
steps.push(id);
}
id
}
fn exchange(id: Id) -> OccurrenceBody {
OccurrenceBody::Exchange {
operation: None,
exchange: Some(id),
sender: None,
receiver: None,
message_kind: MessageKind::Asynchronous,
reply_to: None,
values: vec![],
}
}
fn category(p: &mut Project, layer: Layer, family: ExchangeFamily, exchanges: Vec<Id>) -> Id {
let id = add(p, Kind::ExchangeCategory, layer, "Concern");
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() =
SharedBody::ExchangeCategory { family, exchanges };
id
}
struct Model {
p: Project,
work: [Id; 4],
means: [Id; 2],
categories: [Id; 3],
contexts: [Id; 2],
allocation: Id,
trace: Id,
}
fn model() -> Model {
let mut p = Project::empty();
let a = add(&mut p, Kind::Activity, Layer::Oa, "Request");
let b = add(&mut p, Kind::Activity, Layer::Oa, "Provide");
let c = add(&mut p, Kind::Activity, Layer::Oa, "Monitor");
let x = add(&mut p, Kind::Entity, Layer::Oa, "Requester");
let y = add(&mut p, Kind::Entity, Layer::Oa, "Provider");
let z = add(&mut p, Kind::Entity, Layer::Oa, "Observer");
let allocation = p.relate(RelationKind::Allocation, "Responsible", x, a);
p.relate(RelationKind::Allocation, "Responsible", y, b);
p.relate(RelationKind::Allocation, "Responsible", z, c);
let work = [
p.relate(RelationKind::Interaction, "Request data", a, b),
p.relate(RelationKind::Interaction, "Report", b, c),
p.relate(RelationKind::Interaction, "Feedback", c, a),
p.relate(RelationKind::Interaction, "Direct monitor", a, c),
];
let means = [
p.relate(RelationKind::CommunicationMean, "Service channel", x, y),
p.relate(RelationKind::CommunicationMean, "Monitoring channel", y, z),
];
let contexts = [
add(&mut p, Kind::Process, Layer::Oa, "Service path"),
add(&mut p, Kind::Process, Layer::Oa, "Feedback path"),
];
for edge in &work[..2] {
occurrence(&mut p, contexts[0], exchange(*edge));
}
for edge in &work[1..3] {
occurrence(&mut p, contexts[1], exchange(*edge));
}
let categories = [
category(&mut p, Layer::Oa, ExchangeFamily::Work, work[..2].to_vec()),
category(&mut p, Layer::Oa, ExchangeFamily::Work, work[1..3].to_vec()),
category(
&mut p,
Layer::Oa,
ExchangeFamily::Participant,
vec![means[0]],
),
];
let function = add(&mut p, Kind::Function, Layer::Sa, "System request");
let trace = Id::new_v4();
p.traces.insert(
trace,
Trace {
id: trace,
alias: "request_realization".into(),
name: "Request realization".into(),
description: String::new(),
oa: a,
sa: function,
kind: TraceKind::Refinement,
baseline: None,
},
);
Model {
p,
work,
means,
categories,
contexts,
allocation,
trace,
}
}
fn accepted(filter: &PreparedFilter<'_>, ids: &[Id]) -> Vec<Id> {
ids.iter()
.filter(|id| filter.includes(**id))
.copied()
.collect()
}
#[test]
fn traceability_defaults_preserve_both_layers_and_explicit_selectors_still_apply() {
let mut m = model();
let a = add(&mut m.p, Kind::Function, Layer::Sa, "Request in system");
let b = add(&mut m.p, Kind::Function, Layer::Sa, "Provide in system");
let sa_exchange =
m.p.relate(RelationKind::FunctionalExchange, "System data", a, b);
let sa_category = category(&mut m.p, Layer::Sa, ExchangeFamily::Work, vec![sa_exchange]);
let sa_context = add(
&mut m.p,
Kind::FunctionalChain,
Layer::Sa,
"System service path",
);
occurrence(&mut m.p, sa_context, exchange(sa_exchange));
let index = ViewIndex::new(&m.p);
let defaults = ViewOptions::default();
assert!(!index.prepare(Layer::Oa, &defaults).includes(sa_exchange));
let cross_layer = index.prepare_cross_layer(Layer::Oa, &defaults);
assert!(cross_layer.includes(sa_exchange));
assert!(cross_layer.includes(m.work[0]));
assert!(cross_layer.includes(m.trace));
let selected = ViewOptions {
work_categories: CategorySelection::Any(vec![m.categories[0]]),
..defaults
};
let cross_layer = index.prepare_cross_layer(Layer::Oa, &selected);
assert!(cross_layer.includes(m.work[0]));
assert_eq!(
cross_layer.reasons(sa_exchange, EdgeAvailability::Visible),
vec![OmissionReason::Category(ExchangeFamily::Work)]
);
let none = ViewOptions {
relation_kinds: RelationKinds::Only(vec![]),
..ViewOptions::default()
};
assert!(
!index
.prepare_cross_layer(Layer::Oa, &none)
.includes(sa_exchange)
);
let mixed = ViewOptions {
work_categories: CategorySelection::Any(vec![m.categories[0], sa_category]),
work_contexts: ContextSelection::Any(vec![m.contexts[0], sa_context]),
hidden_edges: vec![sa_exchange],
..ViewOptions::default()
};
assert!(validate_options_cross_layer(&m.p, &mixed).is_empty());
assert!(!validate_options(&m.p, Layer::Oa, &mixed).is_empty());
let cross_layer = index.prepare_cross_layer(Layer::Oa, &mixed);
assert!(cross_layer.includes(m.work[0]));
assert_eq!(
cross_layer.reasons(sa_exchange, EdgeAvailability::Visible),
vec![OmissionReason::ExplicitHide]
);
}
#[test]
fn separate_category_families_obey_all_any_empty_and_union_truth_table() {
let m = model();
let index = ViewIndex::new(&m.p);
let work_cases = [
(CategorySelection::All, m.work.to_vec()),
(CategorySelection::Any(vec![]), vec![]),
(
CategorySelection::Any(vec![m.categories[0]]),
m.work[..2].to_vec(),
),
(
CategorySelection::Any(m.categories[..2].to_vec()),
m.work[..3].to_vec(),
),
];
let participant_cases = [
(CategorySelection::All, m.means.to_vec()),
(CategorySelection::Any(vec![]), vec![]),
(
CategorySelection::Any(vec![m.categories[2]]),
vec![m.means[0]],
),
];
for (work_categories, expected_work) in &work_cases {
for (participant_categories, expected_means) in &participant_cases {
let options = ViewOptions {
work_categories: work_categories.clone(),
participant_categories: participant_categories.clone(),
..ViewOptions::default()
};
assert!(validate_options(&m.p, Layer::Oa, &options).is_empty());
let filter = index.prepare(Layer::Oa, &options);
assert_eq!(&accepted(&filter, &m.work), expected_work);
assert_eq!(&accepted(&filter, &m.means), expected_means);
assert!(filter.includes(m.allocation));
assert!(filter.includes(m.trace));
}
}
}
#[test]
fn global_behavior_coverage_and_chosen_context_are_independent_intersecting_predicates() {
let m = model();
let index = ViewIndex::new(&m.p);
let coverage_cases = [
(Coverage::All, m.work.to_vec()),
(Coverage::InBehavior, m.work[..3].to_vec()),
(Coverage::OutsideBehavior, vec![m.work[3]]),
];
let context_cases = [
(ContextSelection::All, m.work.to_vec()),
(ContextSelection::Any(vec![]), vec![]),
(
ContextSelection::Any(vec![m.contexts[0]]),
m.work[..2].to_vec(),
),
(
ContextSelection::Any(vec![m.contexts[1]]),
m.work[1..3].to_vec(),
),
(
ContextSelection::Any(m.contexts.to_vec()),
m.work[..3].to_vec(),
),
];
for (coverage, global) in &coverage_cases {
for (work_contexts, chosen) in &context_cases {
let expected: Vec<_> = global
.iter()
.filter(|id| chosen.contains(id))
.copied()
.collect();
let options = ViewOptions {
coverage: *coverage,
work_contexts: work_contexts.clone(),
..ViewOptions::default()
};
let filter = index.prepare(Layer::Oa, &options);
assert_eq!(accepted(&filter, &m.work), expected);
assert_eq!(accepted(&filter, &m.means), m.means);
assert!(filter.includes(m.allocation));
}
}
}
#[test]
fn no_lines_and_explicit_trace_selection_include_actual_trace_identities() {
let m = model();
let index = ViewIndex::new(&m.p);
let all: Vec<_> = m
.work
.into_iter()
.chain(m.means)
.chain([m.allocation, m.trace])
.collect();
for (relation_kinds, expected) in [
(RelationKinds::All, all.clone()),
(RelationKinds::Only(vec![]), vec![]),
(RelationKinds::Only(vec![EdgeKind::Trace]), vec![m.trace]),
(
RelationKinds::Only(vec![
EdgeKind::Relation(RelationKind::Interaction),
EdgeKind::Trace,
]),
m.work.into_iter().chain([m.trace]).collect(),
),
] {
let options = ViewOptions {
relation_kinds,
..ViewOptions::default()
};
assert_eq!(
accepted(&index.prepare(Layer::Oa, &options), &all),
expected
);
}
}
#[test]
fn hidden_identity_survives_label_and_category_changes_without_mutating_the_model() {
let mut m = model();
let item = add(&mut m.p, Kind::ExchangeItem, Layer::Oa, "Request payload");
if let RelationBody::Interaction { items, .. } =
&mut m.p.relations.get_mut(&m.work[0]).unwrap().body
{
items.push(item);
}
let before = m.p.clone();
let index = ViewIndex::new(&m.p);
let mut options = ViewOptions {
hidden_edges: vec![m.work[0]],
..ViewOptions::default()
};
for labels in [
ExchangeLabels::Name,
ExchangeLabels::Items,
ExchangeLabels::NameAndItems,
] {
options.exchange_labels = labels;
for categories in [
CategorySelection::All,
CategorySelection::Any(vec![m.categories[0]]),
CategorySelection::Any(vec![]),
] {
options.work_categories = categories;
let filter = index.prepare(Layer::Oa, &options);
assert!(!filter.includes(m.work[0]));
assert!(
filter
.reasons(m.work[0], EdgeAvailability::Visible)
.contains(&OmissionReason::ExplicitHide)
);
}
}
assert_eq!(
ExchangeLabels::Items.label(&m.p, m.work[0]),
"Request payload"
);
assert_eq!(
ExchangeLabels::NameAndItems.label(&m.p, m.work[0]),
"Request data · Request payload"
);
assert_eq!(ExchangeLabels::Items.label(&m.p, m.work[1]), "No items");
assert_eq!(
ExchangeLabels::Items.label(&m.p, m.trace),
"Request realization"
);
assert_eq!(m.p, before);
assert_eq!(options.hidden_edges, vec![m.work[0]]);
}
#[test]
fn omission_reasons_distinguish_missing_placements_folded_edges_and_explicit_hides() {
let m = model();
let index = ViewIndex::new(&m.p);
let options = ViewOptions::default();
let filter = index.prepare(Layer::Oa, &options);
assert_eq!(
filter.reasons(
m.work[0],
EdgeAvailability::EndpointNotShown {
source: false,
target: true
}
),
vec![OmissionReason::EndpointNotShown {
source: false,
target: true
}]
);
assert_eq!(
filter.reasons(m.work[0], EdgeAvailability::FoldedInternal),
vec![OmissionReason::FoldedInternal]
);
assert_eq!(
filter.reasons(m.work[0], EdgeAvailability::OutsideProjection),
vec![OmissionReason::OutsideProjection]
);
let options = ViewOptions {
hidden_edges: vec![m.work[0]],
work_categories: CategorySelection::Any(vec![]),
..options
};
let reasons = index.prepare(Layer::Oa, &options).reasons(
m.work[0],
EdgeAvailability::EndpointNotShown {
source: true,
target: true,
},
);
assert!(reasons.contains(&OmissionReason::ExplicitHide));
assert!(reasons.contains(&OmissionReason::Category(ExchangeFamily::Work)));
assert!(reasons.contains(&OmissionReason::EndpointNotShown {
source: true,
target: true
}));
}
#[test]
fn wrong_family_layer_and_unknown_references_reject_and_draft_filters_fail_closed() {
let m = model();
let index = ViewIndex::new(&m.p);
for wrong in [m.categories[2], m.work[0], Id::new_v4()] {
let options = ViewOptions {
work_categories: CategorySelection::Any(vec![wrong]),
..ViewOptions::default()
};
assert!(
validate_options(&m.p, Layer::Oa, &options)
.iter()
.any(|e| e.field == "work_categories")
);
assert!(accepted(&index.prepare(Layer::Oa, &options), &m.work).is_empty());
}
let options = ViewOptions {
work_categories: CategorySelection::Any(vec![m.categories[0]]),
work_contexts: ContextSelection::Any(vec![m.contexts[0]]),
hidden_edges: vec![m.work[0]],
..ViewOptions::default()
};
let errors = validate_options(&m.p, Layer::Sa, &options);
for field in ["work_categories", "work_contexts", "hidden_edges"] {
assert!(errors.iter().any(|e| e.field == field));
}
assert!(validate_category(&m.p, Layer::Oa, None, ExchangeFamily::Work, &m.work).is_empty());
assert!(
validate_category(&m.p, Layer::Oa, None, ExchangeFamily::Participant, &m.means).is_empty()
);
for (family, members) in [
(ExchangeFamily::Work, m.means.to_vec()),
(ExchangeFamily::Participant, m.work.to_vec()),
(ExchangeFamily::Work, vec![m.work[0], m.work[0]]),
] {
assert!(!validate_category(&m.p, Layer::Oa, None, family, &members).is_empty());
}
assert!(
!validate_category(
&m.p,
Layer::Oa,
Some(m.contexts[0]),
ExchangeFamily::Work,
&[]
)
.is_empty()
);
}
#[test]
fn persisted_empty_selectors_and_lifecycle_never_widen_to_all() {
let m = model();
let mut options = ViewOptions {
hidden_edges: vec![m.work[0]],
relation_kinds: RelationKinds::Only(vec![]),
work_categories: CategorySelection::Any(vec![m.categories[0]]),
participant_categories: CategorySelection::Any(vec![m.categories[2]]),
work_contexts: ContextSelection::Any(vec![m.contexts[0]]),
coverage: Coverage::OutsideBehavior,
exchange_labels: ExchangeLabels::Items,
};
let replacements: BTreeMap<_, _> = [m.work[0], m.categories[0], m.categories[2], m.contexts[0]]
.into_iter()
.map(|id| (id, Id::new_v4()))
.collect();
options.visit_references(|id| *id = replacements[id]);
assert_eq!(options.hidden_edges, vec![replacements[&m.work[0]]]);
assert_eq!(
options.work_categories,
CategorySelection::Any(vec![replacements[&m.categories[0]]])
);
assert_eq!(
options.participant_categories,
CategorySelection::Any(vec![replacements[&m.categories[2]]])
);
assert_eq!(
options.work_contexts,
ContextSelection::Any(vec![replacements[&m.contexts[0]]])
);
options.retain_references(|_| false);
assert_eq!(options.work_categories, CategorySelection::Any(vec![]));
assert_eq!(
options.participant_categories,
CategorySelection::Any(vec![])
);
assert_eq!(options.work_contexts, ContextSelection::Any(vec![]));
assert_eq!(options.relation_kinds, RelationKinds::Only(vec![]));
assert_eq!(options.coverage, Coverage::OutsideBehavior);
assert_eq!(options.exchange_labels, ExchangeLabels::Items);
assert_eq!(
serde_json::from_value::<ViewOptions>(serde_json::to_value(&options).unwrap()).unwrap(),
options
);
assert_ne!(options, ViewOptions::default());
assert_eq!(
serde_json::from_str::<ViewOptions>("{}").unwrap(),
ViewOptions::default()
);
assert!(serde_json::from_str::<ViewOptions>(r#"{"future_filter":true}"#).is_err());
assert!(serde_json::from_str::<ViewOptions>(r#"{"coverage":"Maybe"}"#).is_err());
let duplicate = ViewOptions {
relation_kinds: RelationKinds::Only(vec![EdgeKind::Trace, EdgeKind::Trace]),
hidden_edges: vec![m.trace, m.trace],
..ViewOptions::default()
};
let errors = validate_options(&m.p, Layer::Oa, &duplicate);
assert!(errors.iter().any(|e| e.field == "relation_kinds"));
assert!(errors.iter().any(|e| e.field == "hidden_edges"));
}
#[test]
fn reusable_sequences_and_nested_scenarios_use_listed_contributions_in_both_layers() {
for (layer, work_kind, sequence_kind, exchange_kind) in [
(
Layer::Oa,
Kind::Activity,
Kind::Process,
RelationKind::Interaction,
),
(
Layer::Sa,
Kind::Function,
Kind::FunctionalChain,
RelationKind::FunctionalExchange,
),
] {
let mut p = Project::empty();
let a = add(&mut p, work_kind, layer, "Request");
let b = add(&mut p, work_kind, layer, "Provide");
let used = p.relate(exchange_kind, "Included", a, b);
let unlisted = p.relate(exchange_kind, "Unlisted", b, a);
let inner = add(&mut p, sequence_kind, layer, "Reusable sequence");
let outer = add(&mut p, sequence_kind, layer, "Chosen sequence");
occurrence(&mut p, inner, exchange(used));
occurrence(
&mut p,
outer,
OccurrenceBody::SequenceReference {
sequence: Some(inner),
},
);
let unused = occurrence(&mut p, inner, exchange(unlisted));
p.elements
.get_mut(&inner)
.unwrap()
.steps_mut()
.unwrap()
.retain(|id| *id != unused);
assert_eq!(
behavior_evidence(&p, [outer]).exchanges,
BTreeSet::from([used])
);
assert!(behavior_evidence(&p, [unused]).exchanges.is_empty());
let scenario = add(&mut p, Kind::Scenario, layer, "Conversation");
let fragment = occurrence(
&mut p,
scenario,
OccurrenceBody::Fragment {
operator: FragmentOperator::Optional,
branches: vec![],
},
);
let branch = occurrence(
&mut p,
fragment,
OccurrenceBody::Branch {
guard: "requested".into(),
occurrences: vec![],
},
);
if let Some(SharedBody::Occurrence {
body: OccurrenceBody::Fragment { branches, .. },
..
}) = p.elements.get_mut(&fragment).unwrap().shared_mut()
{
branches.push(branch);
}
occurrence(&mut p, branch, exchange(unlisted));
let reuse = add(&mut p, Kind::Scenario, layer, "Chosen conversation");
occurrence(
&mut p,
reuse,
OccurrenceBody::ScenarioUse {
scenario: Some(scenario),
bindings: vec![],
},
);
assert_eq!(
behavior_evidence(&p, [reuse]).exchanges,
BTreeSet::from([unlisted])
);
assert_eq!(
all_behavior_evidence(&p).exchanges,
BTreeSet::from([used, unlisted])
);
assert_eq!(behavior_evidence(&p, [outer]).work, BTreeSet::from([a, b]));
p.elements
.get_mut(&scenario)
.unwrap()
.steps_mut()
.unwrap()
.clear();
assert!(behavior_evidence(&p, [reuse]).exchanges.is_empty());
// A malformed reuse cycle must terminate without inventing an occurrence.
occurrence(
&mut p,
scenario,
OccurrenceBody::ScenarioUse {
scenario: Some(reuse),
bindings: vec![],
},
);
assert!(behavior_evidence(&p, [reuse]).exchanges.is_empty());
}
}
@@ -0,0 +1,249 @@
//! Independent oracle for the OA communication / SA boundary direction seam.
use oa_core::{
transitions::*,
validation::{self, MessageEndpointResolution as Resolution},
*,
};
fn line(p: &mut Project, scenario: Id, owner: Id) -> Id {
let id = p
.add_element(Kind::Lifeline, Layer::Sa, "Line", Some(scenario))
.unwrap();
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::Lifeline {
represented: Some(owner),
};
id
}
fn message(
exchange: Id,
sender: Id,
receiver: Id,
kind: MessageKind,
reply: Option<Id>,
) -> OccurrenceBody {
OccurrenceBody::Exchange {
operation: None,
exchange: Some(exchange),
sender: Some(sender),
receiver: Some(receiver),
message_kind: kind,
reply_to: reply,
values: vec![],
}
}
fn occurrence(p: &mut Project, scenario: Id, body: OccurrenceBody) -> Id {
let id = p
.add_element(Kind::Occurrence, Layer::Sa, "Message", Some(scenario))
.unwrap();
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::Occurrence {
body,
precondition: String::new(),
postcondition: String::new(),
};
let steps = p.elements.get_mut(&scenario).unwrap().steps_mut().unwrap();
if !steps.contains(&id) {
steps.push(id);
}
id
}
fn boundary_fixture() -> (Project, Id, Id, Id, Id) {
let mut p = Project::empty();
let oa = p
.add_element(Kind::Entity, Layer::Oa, "Team", None)
.unwrap();
let ob = p
.add_element(Kind::Actor, Layer::Oa, "Control", None)
.unwrap();
let sa = p
.add_element(Kind::System, Layer::Sa, "Radio", None)
.unwrap();
let sb = p
.add_element(Kind::SystemActor, Layer::Sa, "Radio control", None)
.unwrap();
let mean = p.relate(RelationKind::CommunicationMean, "Channel", oa, ob);
let mut request = TransitionRequest::new(TransitionDirection::OaToSa, vec![mean]);
request.mappings = vec![
TransitionMapping {
source: oa,
target: Some(sa),
target_kind: None,
},
TransitionMapping {
source: ob,
target: Some(sb),
target_kind: None,
},
];
let port = |alias: &str| CommunicationPortChoice::Create {
id: Id::new_v4(),
alias: alias.into(),
name: alias.into(),
description: String::new(),
kind: ComponentPortKind::Standard,
orientation: ComponentPortOrientation::Unset,
};
request
.communication_endpoints
.push(CommunicationEndpointDecision {
source: mean,
source_port: port("radio_a"),
target_port: port("radio_b"),
});
let plan = plan_transition(&p, &request).unwrap();
assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts);
p = apply_transition(&p, &plan).unwrap();
let boundary = p.traces.values().find(|t| t.oa == mean).unwrap().sa;
let scenario = p
.add_element(Kind::Scenario, Layer::Sa, "Reverse request", None)
.unwrap();
if let Some(SharedBody::Scenario { viewpoint, .. }) =
p.elements.get_mut(&scenario).unwrap().shared_mut()
{
*viewpoint = ScenarioViewpoint::BoundaryInterface;
}
let a = line(&mut p, scenario, sa);
let b = line(&mut p, scenario, sb);
(p, boundary, scenario, a, b)
}
#[test]
fn mapped_unoriented_boundary_resolves_reverse_call_and_reply_from_actual_binding() {
let (mut p, boundary, scenario, a, b) = boundary_fixture();
let (source, target) = p.relations[&boundary].body.endpoints();
let call = message(boundary, b, a, MessageKind::Call, None);
assert_eq!(
validation::resolve_message_endpoints(&p, &call),
Resolution::Resolved {
source: target,
target: source
}
);
let call = occurrence(&mut p, scenario, call);
let reply = message(boundary, a, b, MessageKind::Reply, Some(call));
assert_eq!(
validation::resolve_message_endpoints(&p, &reply),
Resolution::Resolved { source, target }
);
occurrence(&mut p, scenario, reply);
let errors: Vec<_> = validation::validate(&p)
.into_iter()
.filter(|i| i.severity == validation::Severity::Error)
.collect();
assert!(errors.is_empty(), "{errors:?}");
}
#[test]
fn unoriented_flow_boundary_honors_ordinary_direction_but_preserves_correlated_reply_exception() {
let (mut p, boundary, scenario, a, b) = boundary_fixture();
let (source, target) = p.relations[&boundary].body.endpoints();
for (port, orientation) in [
(source, ComponentPortOrientation::Out),
(target, ComponentPortOrientation::In),
] {
if let Some(SharedBody::Port { contract, .. }) =
p.elements.get_mut(&port).unwrap().shared_mut()
{
*contract = PortContract::Component {
kind: ComponentPortKind::Flow,
orientation,
};
}
}
let reverse = message(boundary, b, a, MessageKind::Call, None);
assert_eq!(
validation::resolve_message_endpoints(&p, &reverse),
Resolution::Conflict
);
let forward = message(boundary, a, b, MessageKind::Call, None);
assert_eq!(
validation::resolve_message_endpoints(&p, &forward),
Resolution::Resolved { source, target }
);
let call = occurrence(&mut p, scenario, forward);
let reply = message(boundary, b, a, MessageKind::Reply, Some(call));
assert_eq!(
validation::resolve_message_endpoints(&p, &reply),
Resolution::Resolved {
source: target,
target: source
}
);
occurrence(&mut p, scenario, reply);
let errors: Vec<_> = validation::validate(&p)
.into_iter()
.filter(|i| i.severity == validation::Severity::Error)
.collect();
assert!(errors.is_empty(), "{errors:?}");
}
#[test]
fn reverse_boundary_scenario_transition_keeps_call_orientation_and_reply_identity() {
let (mut p, boundary, scenario, a, b) = boundary_fixture();
let mean = p.traces.values().find(|t| t.sa == boundary).unwrap().oa;
let call = message(boundary, b, a, MessageKind::Call, None);
let call = occurrence(&mut p, scenario, call);
let reply = message(boundary, a, b, MessageKind::Reply, Some(call));
let reply = occurrence(&mut p, scenario, reply);
let owner = |port| p.elements[&port].parent.unwrap();
let mut request = TransitionRequest::new(TransitionDirection::SaToOa, vec![scenario]);
request.scope = DependencyScope::Subtree;
request.mappings = [
(boundary, mean),
(
owner(p.relations[&boundary].source()),
p.relations[&mean].source(),
),
(
owner(p.relations[&boundary].target()),
p.relations[&mean].target(),
),
]
.into_iter()
.map(|(source, target)| TransitionMapping {
source,
target: Some(target),
target_kind: None,
})
.collect();
// The explicit participant counterparts intentionally use different names.
// Review those prose differences without changing the endpoint decision.
request.resolutions = request
.mappings
.iter()
.map(|mapping| FieldResolution {
source: mapping.source,
field: TransitionField::Name,
decision: FieldDecision::KeepTarget,
})
.collect();
let plan = plan_transition(&p, &request).unwrap();
assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts);
let q = apply_transition(&p, &plan).unwrap();
let mapped = |source| q.traces.values().find(|t| t.sa == source).unwrap().oa;
let body = |id| match q.elements[&id].shared().unwrap() {
SharedBody::Occurrence { body, .. } => body,
_ => panic!("expected occurrence"),
};
let mapped_call = mapped(call);
let mapped_reply = mapped(reply);
assert_eq!(
validation::resolve_message_endpoints(&q, body(mapped_call)),
Resolution::Resolved {
source: q.relations[&mean].target(),
target: q.relations[&mean].source(),
}
);
assert_eq!(
validation::resolve_message_endpoints(&q, body(mapped_reply)),
Resolution::Resolved {
source: q.relations[&mean].source(),
target: q.relations[&mean].target(),
}
);
assert!(
matches!(body(mapped_reply), OccurrenceBody::Exchange { reply_to: Some(id), .. } if *id == mapped_call)
);
assert_eq!(q.elements[&scenario], p.elements[&scenario]);
assert_eq!(q.elements[&call], p.elements[&call]);
assert_eq!(q.elements[&reply], p.elements[&reply]);
}
+523
View File
@@ -0,0 +1,523 @@
use oa_core::{
transitions::*,
validation::{self, MessageEndpointResolution as Resolution, Severity},
*,
};
use std::collections::BTreeMap;
fn add(p: &mut Project, kind: Kind, layer: Layer, name: &str) -> Id {
p.add_element(kind, layer, name, None).unwrap()
}
fn fixture() -> (Project, Id, Id, Id) {
let mut p = Project::empty();
let a = add(&mut p, Kind::Entity, Layer::Oa, "Rescue team");
let b = add(&mut p, Kind::Actor, Layer::Oa, "Control centre");
let mean = p.relate(RelationKind::CommunicationMean, "Radio", a, b);
(p, a, b, mean)
}
fn errors(p: &Project) -> Vec<validation::Issue> {
validation::validate(p)
.into_iter()
.filter(|i| i.severity == Severity::Error)
.collect()
}
fn valid(p: &Project) {
assert!(errors(p).is_empty(), "{:?}", errors(p));
}
fn oriented(p: &mut Project, id: Id, value: bool) {
if let RelationBody::CommunicationMean { oriented, .. } =
&mut p.relations.get_mut(&id).unwrap().body
{
*oriented = value;
}
}
fn items(p: &mut Project, id: Id, values: Vec<Id>) {
match &mut p.relations.get_mut(&id).unwrap().body {
RelationBody::CommunicationMean { items, .. } | RelationBody::Interaction { items, .. } => {
*items = values
}
_ => panic!(),
}
}
fn has(p: &Project, id: Id, code: &str, severity: Severity) -> bool {
validation::validate(p)
.iter()
.any(|i| i.element == Some(id) && i.code == code && i.severity == severity)
}
fn line(p: &mut Project, scenario: Id, represented: Id) -> Id {
if let Some(SharedBody::Scenario { viewpoint, .. }) =
p.elements.get_mut(&scenario).unwrap().shared_mut()
{
*viewpoint = ScenarioViewpoint::ParticipantInteraction;
}
let id = p
.add_element(
Kind::Lifeline,
Layer::Oa,
&format!("line_{represented}"),
Some(scenario),
)
.unwrap();
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::Lifeline {
represented: Some(represented),
};
id
}
fn message(
mean: Id,
sender: Id,
receiver: Id,
kind: MessageKind,
reply_to: Option<Id>,
) -> OccurrenceBody {
OccurrenceBody::Exchange {
operation: None,
exchange: Some(mean),
sender: Some(sender),
receiver: Some(receiver),
message_kind: kind,
reply_to,
values: vec![],
}
}
fn occurrence(p: &mut Project, scenario: Id, body: OccurrenceBody) -> Id {
let id = p
.add_element(Kind::Occurrence, Layer::Oa, "Message", Some(scenario))
.unwrap();
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::Occurrence {
body,
precondition: String::new(),
postcondition: String::new(),
};
let steps = p.elements.get_mut(&scenario).unwrap().steps_mut().unwrap();
if !steps.contains(&id) {
steps.push(id);
}
id
}
#[test]
fn communication_means_are_distinct_oa_participant_relations_and_strict_source6_records() {
let (mut p, a, b, mean) = fixture();
valid(&p);
assert!(matches!(
p.relations[&mean].body,
RelationBody::CommunicationMean {
oriented: false,
..
}
));
p.relate(RelationKind::CommunicationMean, "Telephone", a, b);
valid(&p);
let text = language::format(&p);
assert!(text.contains("communication_mean"));
assert!(text.starts_with(&format!("arcadia {SCHEMA_VERSION};")));
assert_eq!(language::parse(&text).unwrap(), p);
let bytes = serde_json::to_vec(&p).unwrap();
assert_eq!(persistence::decode(&bytes).unwrap(), p);
for version in 1..=5 {
let mut old = serde_json::to_value(&p).unwrap();
old["schema_version"] = version.into();
assert!(
persistence::decode(&serde_json::to_vec(&old).unwrap()).is_err(),
"schema {version}"
);
assert!(
language::parse(&text.replacen(
&format!("arcadia {SCHEMA_VERSION};"),
&format!("arcadia {version};"),
1
))
.is_err(),
"source {version}"
);
}
let mut bad = serde_json::to_value(&p).unwrap();
bad["relations"][mean.to_string()]["body"]["CommunicationMean"]["typo"] = true.into();
assert!(persistence::decode(&serde_json::to_vec(&bad).unwrap()).is_err());
for endpoint in [
a,
add(&mut p, Kind::Role, Layer::Oa, "Role"),
add(&mut p, Kind::System, Layer::Sa, "System"),
] {
let mut q = p.clone();
q.relations
.get_mut(&mean)
.unwrap()
.body
.set_endpoints(a, endpoint);
assert!(!errors(&q).is_empty());
}
}
#[test]
fn communication_schema5_upgrade_preserves_legacy_data_and_exact_source_trivia() {
let p = Project::demo();
let mut json = serde_json::to_value(&p).unwrap();
json["schema_version"] = 5.into();
for d in json["diagrams"].as_object_mut().unwrap().values_mut() {
d.as_object_mut().unwrap().remove("view_options");
}
let result = persistence::decode_with_report(&serde_json::to_vec(&json).unwrap()).unwrap();
assert_eq!(result.project, p);
assert_eq!(result.migration.unwrap().from_schema, 5);
let mut without_views = p.clone();
without_views.diagrams.clear();
let old = language::format(&without_views).replacen(
&format!("arcadia {SCHEMA_VERSION};"),
"arcadia // old contract\n5;",
1,
);
let bundle = source_project::SourceBundle {
entry: "entry.arcadia".into(),
files: BTreeMap::from([("entry.arcadia".into(), old.clone())]),
};
let compiled =
source_project::compile_bundle(&bundle, source_project::ProjectLimits::default()).unwrap();
assert_eq!(
compiled.bundle.files["entry.arcadia"],
old.replacen("\n5;", &format!("\n{SCHEMA_VERSION};"), 1)
);
assert_eq!(compiled.decoded.project, without_views);
}
#[test]
fn communication_allocation_checks_whole_partial_pair_and_does_not_guess_roles() {
let (mut p, a, b, mean) = fixture();
let x = add(&mut p, Kind::Activity, Layer::Oa, "Send");
let y = add(&mut p, Kind::Activity, Layer::Oa, "Receive");
let work = p.relate(RelationKind::Interaction, "Report", x, y);
let allocation = p.relate(
RelationKind::ExchangeAllocation,
"Radio carries report",
mean,
work,
);
let role = add(&mut p, Kind::Role, Layer::Oa, "Communicator");
p.relate(RelationKind::RoleRealization, "Role", a, role);
p.relate(RelationKind::RoleAssignment, "Responsibility", role, x);
valid(&p);
assert!(has(
&p,
allocation,
"incomplete_exchange_allocation",
Severity::Warning
));
p.relate(RelationKind::Allocation, "Actual receiver", a, y);
valid(&p); // reverse candidate survives
oriented(&mut p, mean, true);
assert!(has(
&p,
allocation,
"exchange_allocation_performer",
Severity::Error
));
oriented(&mut p, mean, false);
p.relate(RelationKind::Allocation, "Actual sender", b, x);
valid(&p);
let mut q = p.clone();
q.relations.get_mut(&mean).unwrap().body.set_endpoints(b, a);
valid(&q);
let third = add(&mut q, Kind::Entity, Layer::Oa, "Third");
q.relations
.get_mut(&mean)
.unwrap()
.body
.set_endpoints(a, third);
assert!(has(
&q,
allocation,
"exchange_allocation_performer",
Severity::Error
));
}
#[test]
fn communication_allocation_items_use_specialization_and_empty_is_not_wildcard() {
let (mut p, a, b, mean) = fixture();
let x = add(&mut p, Kind::Activity, Layer::Oa, "Send");
let y = add(&mut p, Kind::Activity, Layer::Oa, "Receive");
p.relate(RelationKind::Allocation, "Send allocation", a, x);
p.relate(RelationKind::Allocation, "Receive allocation", b, y);
let work = p.relate(RelationKind::Interaction, "Report", x, y);
let allocation = p.relate(RelationKind::ExchangeAllocation, "Carried", mean, work);
let base = add(&mut p, Kind::ExchangeItem, Layer::Oa, "Report item");
let specialized = add(&mut p, Kind::ExchangeItem, Layer::Oa, "Distress item");
if let Some(SharedBody::ExchangeItem { supertype, .. }) =
p.elements.get_mut(&specialized).unwrap().shared_mut()
{
*supertype = Some(base);
}
items(&mut p, work, vec![specialized]);
assert!(has(
&p,
allocation,
"exchange_allocation_items",
Severity::Error
));
items(&mut p, mean, vec![base]);
valid(&p);
items(&mut p, work, vec![base]);
items(&mut p, mean, vec![specialized]);
assert!(has(
&p,
allocation,
"exchange_allocation_items",
Severity::Error
));
}
#[test]
fn communication_scenario_reverse_call_and_reply_follow_actual_direction() {
let (mut p, a, b, mean) = fixture();
let scenario = add(&mut p, Kind::Scenario, Layer::Oa, "Radio conversation");
let la = line(&mut p, scenario, a);
let lb = line(&mut p, scenario, b);
let call_body = message(mean, lb, la, MessageKind::Call, None);
let call = occurrence(&mut p, scenario, call_body.clone());
let reply_body = message(mean, la, lb, MessageKind::Reply, Some(call));
occurrence(&mut p, scenario, reply_body.clone());
assert_eq!(
validation::resolve_message_endpoints(&p, &call_body),
Resolution::Resolved {
source: b,
target: a
}
);
assert_eq!(
validation::resolve_message_endpoints(&p, &reply_body),
Resolution::Resolved {
source: a,
target: b
}
);
valid(&p);
let mut edited = p.relations[&mean].clone();
if let RelationBody::CommunicationMean { oriented, .. } = &mut edited.body {
*oriented = true;
}
assert!(prepare_command(&p, &Command::EditRelation(mean, edited)).is_err());
valid(&p);
// The established operation-response exception also allows same-means Reply on oriented means.
if let Some(SharedBody::Occurrence { body, .. }) =
p.elements.get_mut(&call).unwrap().shared_mut()
{
*body = message(mean, la, lb, MessageKind::Call, None);
}
oriented(&mut p, mean, true);
assert_eq!(
validation::resolve_message_endpoints(
&p,
&message(mean, lb, la, MessageKind::Reply, Some(call))
),
Resolution::Resolved {
source: b,
target: a
}
);
}
#[test]
fn communication_scenario_ambiguous_and_incomplete_bindings_never_guess() {
let (mut p, a, b, mean) = fixture();
let scenario = add(&mut p, Kind::Scenario, Layer::Oa, "Roles");
let role = add(&mut p, Kind::Role, Layer::Oa, "Shared role");
p.relate(RelationKind::RoleRealization, "A role", a, role);
p.relate(RelationKind::RoleRealization, "B role", b, role);
let one = line(&mut p, scenario, role);
let two = line(&mut p, scenario, role);
let mut body = message(mean, one, two, MessageKind::Asynchronous, None);
assert_eq!(
validation::resolve_message_endpoints(&p, &body),
Resolution::Ambiguous
);
assert_eq!(validation::message_endpoints(&p, &body), None);
if let OccurrenceBody::Exchange { receiver, .. } = &mut body {
*receiver = None;
}
assert_eq!(
validation::resolve_message_endpoints(&p, &body),
Resolution::Incomplete
);
let third = add(&mut p, Kind::Entity, Layer::Oa, "Other");
let other = line(&mut p, scenario, third);
if let OccurrenceBody::Exchange { sender, .. } = &mut body {
*sender = Some(other);
}
assert_eq!(
validation::resolve_message_endpoints(&p, &body),
Resolution::Conflict
);
}
fn transition_fixture() -> (Project, Id, Id, Id, TransitionRequest) {
let (mut p, a, b, mean) = fixture();
let sa = add(&mut p, Kind::System, Layer::Sa, "System");
let sb = add(&mut p, Kind::SystemActor, Layer::Sa, "External");
let mut r = TransitionRequest::new(TransitionDirection::OaToSa, vec![mean]);
r.mappings = vec![
TransitionMapping {
source: a,
target: Some(sa),
target_kind: None,
},
TransitionMapping {
source: b,
target: Some(sb),
target_kind: None,
},
];
let port = |name: &str| CommunicationPortChoice::Create {
id: Id::new_v4(),
alias: name.into(),
name: name.into(),
description: "Explicit technical choice".into(),
kind: ComponentPortKind::Standard,
orientation: ComponentPortOrientation::Unset,
};
r.communication_endpoints
.push(CommunicationEndpointDecision {
source: mean,
source_port: port("radio_source"),
target_port: port("radio_target"),
});
(p, mean, sa, sb, r)
}
#[test]
fn communication_transition_requires_explicit_ports_and_preserves_participant_mapping() {
let (p, mean, sa, sb, r) = transition_fixture();
let mut incomplete = r.clone();
incomplete.communication_endpoints.clear();
assert!(
!plan_transition(&p, &incomplete)
.unwrap()
.conflicts
.is_empty()
);
let plan = plan_transition(&p, &r).unwrap();
assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts);
let q = apply_transition(&p, &plan).unwrap();
valid(&q);
let trace = q.traces.values().find(|t| t.oa == mean).unwrap();
assert_eq!(trace.kind, TraceKind::Exchange);
let boundary = &q.relations[&trace.sa];
assert_eq!(boundary.kind(), RelationKind::BoundaryExchange);
assert_eq!(q.elements[&boundary.source()].parent, Some(sa));
assert_eq!(q.elements[&boundary.target()].parent, Some(sb));
assert_eq!(q.relations[&mean], p.relations[&mean]);
assert_eq!(q.elements.len(), p.elements.len() + 2);
let mut collision = r.clone();
collision.mappings[1].target = Some(sa);
assert!(
!plan_transition(&p, &collision)
.unwrap()
.conflicts
.is_empty()
);
let mut duplicate = r.clone();
duplicate
.communication_endpoints
.push(r.communication_endpoints[0].clone());
assert!(
!plan_transition(&p, &duplicate)
.unwrap()
.conflicts
.is_empty()
);
}
#[test]
fn communication_reverse_transition_projects_owners_without_oa_ports() {
let (p, mean, sa, sb, r) = transition_fixture();
let q = apply_transition(&p, &plan_transition(&p, &r).unwrap()).unwrap();
let boundary = q.traces.values().find(|t| t.oa == mean).unwrap().sa;
let mut standalone = q.clone();
standalone.traces.clear();
let mut reverse = TransitionRequest::new(TransitionDirection::SaToOa, vec![boundary]);
reverse.mappings = vec![
TransitionMapping {
source: sa,
target: Some(p.relations[&mean].source()),
target_kind: None,
},
TransitionMapping {
source: sb,
target: Some(p.relations[&mean].target()),
target_kind: None,
},
];
let plan = plan_transition(&standalone, &reverse).unwrap();
assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts);
let result = apply_transition(&standalone, &plan).unwrap();
assert_eq!(result.elements, standalone.elements);
let counterpart = &result.relations[&result
.traces
.values()
.find(|t| t.sa == boundary)
.unwrap()
.oa];
assert_eq!(counterpart.kind(), RelationKind::CommunicationMean);
assert_eq!(
counterpart.body.endpoints(),
p.relations[&mean].body.endpoints()
);
}
#[test]
fn communication_semantic_endpoint_take_needs_scoped_decision_and_exact_history() {
let (p, mean, _, _, r) = transition_fixture();
let q = apply_transition(&p, &plan_transition(&p, &r).unwrap()).unwrap();
let trace = q.traces.values().find(|t| t.oa == mean).unwrap();
let boundary = trace.sa;
let mut transition = TransitionRequest::new(TransitionDirection::OaToSa, vec![mean]);
transition.mappings = r.mappings.clone();
transition.mappings.push(TransitionMapping {
source: mean,
target: Some(boundary),
target_kind: None,
});
let mut request = SemanticTransitionRequest::new(transition);
request.references = r
.mappings
.iter()
.map(|m| (m.source, m.target.unwrap()))
.collect();
request.reviews.push(SemanticReviewRequest {
source: mean,
resolutions: vec![FacetResolution {
facet: FacetSelector::Relation(RelationFacet::Source),
decision: SemanticDecision::TakeSource,
}],
reset: vec![],
});
assert!(
!plan_semantic_transition(&q, request.clone())
.unwrap()
.conflicts
.is_empty()
);
request
.transition
.communication_endpoints
.push(CommunicationEndpointDecision {
source: mean,
source_port: CommunicationPortChoice::Reuse {
port: q.relations[&boundary].source(),
},
target_port: CommunicationPortChoice::Reuse {
port: q.relations[&boundary].target(),
},
});
let plan = plan_semantic_transition(&q, request).unwrap();
assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts);
let result = apply_semantic_transition(&q, &plan).unwrap();
let baseline = result.traces[&trace.id]
.baseline
.as_ref()
.unwrap()
.semantic
.as_ref()
.unwrap();
assert!(baseline.facets.iter().any(|f|matches!(f,FacetReview::Relation(RelationReview::Source(Reviewed{oa:Observed::Value(a),sa:Observed::Value(b)})) if *a==p.relations[&mean].source()&&*b==q.relations[&boundary].source())));
}
@@ -0,0 +1,585 @@
use oa_core::{
language,
source_project::*,
validation::{self, Severity},
*,
};
use std::collections::{BTreeMap, BTreeSet};
const CODES: [&str; 5] = [
"non_leaf_work_exchange",
"work_without_behavior_context",
"exchange_without_behavior_context",
"mission_without_capability",
"capability_without_behavior_context",
];
type Located = (String, Id, Option<String>);
fn coverage(p: &Project) -> BTreeSet<Located> {
let issues = validation::validate(p);
assert!(
!issues.iter().any(|i| i.severity == Severity::Error),
"{issues:?}"
);
issues
.into_iter()
.filter(|i| CODES.contains(&i.code.as_str()))
.map(|i| {
assert_eq!(i.severity, Severity::Warning);
(i.code, i.element.unwrap(), i.field)
})
.collect()
}
fn located(code: &str, id: Id, field: &str) -> Located {
(code.into(), id, (!field.is_empty()).then(|| field.into()))
}
fn add(p: &mut Project, kind: Kind, layer: Layer, name: &str) -> Id {
p.add_element(kind, layer, name, None).unwrap()
}
fn occurrence(p: &mut Project, owner: Id, body: OccurrenceBody) -> Id {
let id = p
.add_element(
Kind::Occurrence,
p.elements[&owner].layer(),
"Contribution",
Some(owner),
)
.unwrap();
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::Occurrence {
body,
precondition: String::new(),
postcondition: String::new(),
};
if let Some(steps) = p.elements.get_mut(&owner).unwrap().steps_mut() {
steps.push(id);
}
id
}
fn exchange(id: Id) -> OccurrenceBody {
OccurrenceBody::Exchange {
operation: None,
exchange: Some(id),
sender: None,
receiver: None,
message_kind: MessageKind::Asynchronous,
reply_to: None,
values: vec![],
}
}
fn action(id: Id) -> OccurrenceBody {
OccurrenceBody::Action {
action: Some(id),
lifeline: None,
}
}
fn line(p: &mut Project, scenario: Id, work: Id) -> Id {
let id = p
.add_element(
Kind::Lifeline,
p.elements[&scenario].layer(),
"Work line",
Some(scenario),
)
.unwrap();
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::Lifeline {
represented: Some(work),
};
id
}
fn set_context(p: &mut Project, scenario: Id, capability: Id) {
if let Some(SharedBody::Scenario { context, .. }) =
p.elements.get_mut(&scenario).unwrap().shared_mut()
{
*context = Some(capability);
}
}
#[test]
fn included_process_and_nested_scenario_evidence_is_identity_based_in_both_layers() {
for layer in [Layer::Oa, Layer::Sa] {
let mut p = Project::empty();
let (
work_kind,
sequence_kind,
capability_kind,
mission_kind,
performer_kind,
exchange_kind,
) = if layer == Layer::Oa {
(
Kind::Activity,
Kind::Process,
Kind::Capability,
Kind::OperationalMission,
Kind::Entity,
RelationKind::Interaction,
)
} else {
(
Kind::Function,
Kind::FunctionalChain,
Kind::SystemCapability,
Kind::Mission,
Kind::System,
RelationKind::FunctionalExchange,
)
};
let performer = add(&mut p, performer_kind, layer, "Service provider");
let a = add(&mut p, work_kind, layer, "Request service");
let b = add(&mut p, work_kind, layer, "Provide service");
for work in [a, b] {
p.relate(RelationKind::Allocation, "Performed work", performer, work);
}
let e = p.relate(exchange_kind, "Request", a, b);
let inner = add(&mut p, sequence_kind, layer, "Service path");
let outer = add(&mut p, sequence_kind, layer, "Reusable path");
occurrence(&mut p, inner, exchange(e));
occurrence(
&mut p,
outer,
OccurrenceBody::SequenceReference {
sequence: Some(inner),
},
);
let capability = add(&mut p, capability_kind, layer, "Deliver service");
p.relate(
RelationKind::ChainInvolvement,
"Service context",
outer,
capability,
);
let mission = add(&mut p, mission_kind, layer, "Serve users");
p.relate(
RelationKind::MissionCapability,
"Delivered capability",
mission,
capability,
);
let scenario = add(&mut p, Kind::Scenario, layer, "Conversation");
let sender = line(&mut p, scenario, a);
let receiver = line(&mut p, scenario, b);
let fragment = occurrence(
&mut p,
scenario,
OccurrenceBody::Fragment {
operator: FragmentOperator::Optional,
branches: vec![],
},
);
let branch = occurrence(
&mut p,
fragment,
OccurrenceBody::Branch {
guard: "service_requested".into(),
occurrences: vec![],
},
);
if let Some(SharedBody::Occurrence {
body: OccurrenceBody::Fragment { branches, .. },
..
}) = p.elements.get_mut(&fragment).unwrap().shared_mut()
{
branches.push(branch);
}
let call = occurrence(
&mut p,
branch,
OccurrenceBody::Exchange {
operation: None,
exchange: Some(e),
sender: Some(sender),
receiver: Some(receiver),
message_kind: MessageKind::Call,
reply_to: None,
values: vec![],
},
);
occurrence(
&mut p,
branch,
OccurrenceBody::Exchange {
operation: None,
exchange: Some(e),
sender: Some(receiver),
receiver: Some(sender),
message_kind: MessageKind::Reply,
reply_to: Some(call),
values: vec![],
},
);
let reuse = add(&mut p, Kind::Scenario, layer, "Reused conversation");
let outer_sender = line(&mut p, reuse, a);
let outer_receiver = line(&mut p, reuse, b);
occurrence(
&mut p,
reuse,
OccurrenceBody::ScenarioUse {
scenario: Some(scenario),
bindings: vec![
LifelineBinding {
referenced: sender,
local: outer_sender,
},
LifelineBinding {
referenced: receiver,
local: outer_receiver,
},
],
},
);
assert!(coverage(&p).is_empty());
let inner_steps = std::mem::take(p.elements.get_mut(&inner).unwrap().steps_mut().unwrap());
p.elements
.get_mut(&scenario)
.unwrap()
.steps_mut()
.unwrap()
.clear();
assert_eq!(
coverage(&p),
BTreeSet::from([
located("work_without_behavior_context", a, ""),
located("work_without_behavior_context", b, ""),
located("exchange_without_behavior_context", e, ""),
]),
"Unordered fragments and reused containers cannot activate omitted messages"
);
*p.elements.get_mut(&inner).unwrap().steps_mut().unwrap() = inner_steps;
p.elements
.get_mut(&scenario)
.unwrap()
.steps_mut()
.unwrap()
.push(fragment);
assert!(coverage(&p).is_empty());
// Removing every message leaves lifelines and reuse intact: neither is
// an execution claim. Empty sequences remain saveable, with old warnings.
for element in p.elements.values_mut() {
if let Some(SharedBody::Occurrence {
body: OccurrenceBody::Exchange { .. },
..
}) = element.shared()
{
*element.shared_mut().unwrap() = SharedBody::Occurrence {
body: OccurrenceBody::Action {
action: None,
lifeline: None,
},
precondition: String::new(),
postcondition: String::new(),
};
}
}
assert_eq!(
coverage(&p),
BTreeSet::from([
located("work_without_behavior_context", a, ""),
located("work_without_behavior_context", b, ""),
located("exchange_without_behavior_context", e, "")
])
);
occurrence(&mut p, scenario, action(a)); // known work, unfinished lifeline
assert_eq!(
coverage(&p),
BTreeSet::from([
located("work_without_behavior_context", b, ""),
located("exchange_without_behavior_context", e, "")
])
);
assert_eq!(language::parse(&language::format(&p)).unwrap(), p);
}
}
#[test]
fn incomplete_oa_repairs_exact_records_and_ordered_membership_without_inference() {
let mut p = Project::empty();
let performer = add(&mut p, Kind::Entity, Layer::Oa, "Crew");
let parent = add(&mut p, Kind::Activity, Layer::Oa, "Manage service");
let a = p
.add_element(Kind::Activity, Layer::Oa, "Prepare", Some(parent))
.unwrap();
let b = p
.add_element(Kind::Activity, Layer::Oa, "Deliver", Some(parent))
.unwrap();
let c = add(&mut p, Kind::Activity, Layer::Oa, "Verify");
for work in [a, b, c] {
p.relate(RelationKind::Allocation, "Responsibility", performer, work);
}
let ab = p.relate(RelationKind::Interaction, "Prepared service", a, b);
let pc = p.relate(RelationKind::Interaction, "Grouped service", parent, c);
let ca = p.relate(RelationKind::Interaction, "Verified service", c, a);
let process = add(&mut p, Kind::Process, Layer::Oa, "Delivery path");
occurrence(&mut p, process, exchange(ab));
let mission = add(
&mut p,
Kind::OperationalMission,
Layer::Oa,
"Deliver reliably",
);
let capability = add(&mut p, Kind::Capability, Layer::Oa, "Reliable delivery");
p.relate(
RelationKind::CapabilityInvolvement,
"Crew support",
performer,
capability,
);
p.relate(
RelationKind::MissionInvolvement,
"Mission responsibility",
performer,
mission,
);
let machine = p
.add_element(Kind::StateMachine, Layer::Oa, "Crew modes", Some(performer))
.unwrap();
let region = p
.add_element(Kind::Region, Layer::Oa, "Crew region", Some(machine))
.unwrap();
let state = p
.add_element(Kind::State, Layer::Oa, "Available", Some(region))
.unwrap();
p.relate(RelationKind::StateAction, "Verification entry", state, c);
let observation = add(&mut p, Kind::Scenario, Layer::Oa, "Observation only");
let observed = line(&mut p, observation, c);
occurrence(
&mut p,
observation,
OccurrenceBody::State {
state: Some(state),
lifeline: Some(observed),
},
);
let expected = BTreeSet::from([
located("non_leaf_work_exchange", pc, "source"),
located("work_without_behavior_context", c, ""),
located("exchange_without_behavior_context", ca, ""),
located("mission_without_capability", mission, ""),
located("capability_without_behavior_context", capability, ""),
]);
assert_eq!(coverage(&p), expected);
let unordered = occurrence(&mut p, process, exchange(ca));
p.elements
.get_mut(&process)
.unwrap()
.steps_mut()
.unwrap()
.retain(|id| *id != unordered);
assert_eq!(coverage(&p), expected);
assert!(
validation::validate(&p)
.iter()
.any(|i| i.element == Some(unordered) && i.code == "unordered_occurrence")
);
let before = p.clone();
p.elements
.get_mut(&process)
.unwrap()
.steps_mut()
.unwrap()
.push(unordered);
if let RelationBody::Interaction { source, .. } = &mut p.relations.get_mut(&pc).unwrap().body {
*source = a;
}
occurrence(&mut p, process, exchange(pc));
p.relate(
RelationKind::MissionCapability,
"Mission delivery",
mission,
capability,
);
p.relate(
RelationKind::ChainInvolvement,
"Capability path",
process,
capability,
);
assert!(coverage(&p).is_empty());
// Public session admission and undo retain the previous exact warning set.
let mut session = Session::new(before.clone());
session
.execute(Command::Batch(diff_commands(&before, &p)))
.unwrap();
assert!(coverage(session.project()).is_empty());
session.undo();
assert_eq!(session.project(), &before);
assert_eq!(coverage(session.project()), expected);
}
#[test]
fn non_leaf_exchange_locations_use_work_endpoints_including_typed_sa_ports() {
for layer in [Layer::Oa, Layer::Sa] {
let mut p = Project::empty();
let (kind, relation_kind) = if layer == Layer::Oa {
(Kind::Activity, RelationKind::Interaction)
} else {
(Kind::Function, RelationKind::FunctionalExchange)
};
let source = add(&mut p, kind, layer, "Source group");
let target = add(&mut p, kind, layer, "Target group");
let a = p
.add_element(kind, layer, "Source leaf", Some(source))
.unwrap();
let b = p
.add_element(kind, layer, "Target leaf", Some(target))
.unwrap();
let edge = p.relate(relation_kind, "Decompose exchange", source, target);
assert_eq!(
coverage(&p),
BTreeSet::from([
located("non_leaf_work_exchange", edge, "source"),
located("non_leaf_work_exchange", edge, "target"),
located("work_without_behavior_context", a, ""),
located("work_without_behavior_context", b, ""),
])
);
if let RelationBody::FunctionalExchange {
source_port,
target_port,
..
} = p.relations[&edge].body
{
assert_eq!(p.elements[&source_port].parent, Some(source));
assert_eq!(p.elements[&target_port].parent, Some(target));
assert!(
coverage(&p)
.iter()
.all(|(_, id, _)| *id != source_port && *id != target_port)
);
}
}
}
#[test]
fn grouping_and_scenario_only_capabilities_do_not_duplicate_support_warnings() {
let mut p = Project::empty();
let parent = add(&mut p, Kind::Capability, Layer::Oa, "Capability group");
let leaf = p
.add_element(Kind::Capability, Layer::Oa, "Leaf capability", Some(parent))
.unwrap();
let issues = validation::validate(&p);
assert!(
issues
.iter()
.any(|i| i.element == Some(leaf) && i.code == "unsupported_capability")
);
assert!(
!issues
.iter()
.any(|i| i.element == Some(parent) && i.code.contains("capability"))
);
assert!(coverage(&p).is_empty());
let scenario = add(&mut p, Kind::Scenario, Layer::Oa, "Capability scenario");
set_context(&mut p, scenario, leaf);
let issues = validation::validate(&p);
assert!(!issues.iter().any(|i| i.code == "unsupported_capability"));
assert!(
issues
.iter()
.any(|i| i.element == Some(scenario) && i.code == "empty_sequence")
);
assert!(coverage(&p).is_empty());
}
#[test]
fn structural_errors_suppress_completeness_cascades_and_rejection_keeps_the_model() {
let mut p = Project::empty();
let a = add(&mut p, Kind::Activity, Layer::Oa, "Request");
let b = add(&mut p, Kind::Activity, Layer::Oa, "Respond");
let e = p.relate(RelationKind::Interaction, "Request data", a, b);
let process = add(&mut p, Kind::Process, Layer::Oa, "Path");
let step = occurrence(&mut p, process, exchange(e));
assert!(coverage(&p).is_empty());
let mut bad = p.clone();
if let RelationBody::Interaction { source, .. } = &mut bad.relations.get_mut(&e).unwrap().body {
*source = Id::new_v4();
}
let mut wrong_owner = p.clone();
wrong_owner.elements.get_mut(&step).unwrap().parent = Some(a);
let mut recursive = p.clone();
let scenario = add(&mut recursive, Kind::Scenario, Layer::Oa, "Cycle");
occurrence(
&mut recursive,
scenario,
OccurrenceBody::ScenarioUse {
scenario: Some(scenario),
bindings: vec![],
},
);
for (candidate, expected_error) in [
(bad, "invalid_endpoints"),
(wrong_owner, "invalid_occurrence"),
(recursive, "recursive_scenario_use"),
] {
let issues = validation::validate(&candidate);
assert!(
issues
.iter()
.any(|i| i.severity == Severity::Error && i.code == expected_error),
"{issues:?}"
);
assert!(
!issues.iter().any(|i| CODES.contains(&i.code.as_str())),
"{issues:?}"
);
let mut session = Session::new(p.clone());
assert!(
session
.execute(Command::Batch(diff_commands(&p, &candidate)))
.is_err()
);
assert_eq!(session.project(), &p);
assert_eq!(session.revision(), 0);
}
}
#[test]
fn multifile_lint_preserves_core_codes_ids_and_actual_owning_source_paths() {
let source = SourceBundle { entry: "project.arcadia".into(), files: BTreeMap::from([
("project.arcadia".into(), "arcadia 5; project p \"Coverage\" { include \"work.arcadia\"; include \"exchanges.arcadia\"; }".into()),
("work.arcadia".into(), "oa_activity a \"Request\" {}\noa_activity b \"Respond\" {}".into()),
("exchanges.arcadia".into(), "interaction e \"Data\" { source @a; target @b; }".into()),
]) };
let compiled = compile_bundle(&source, ProjectLimits::default()).unwrap();
let expected = coverage(&compiled.decoded.project);
assert_eq!(expected.len(), 3);
let diagnostics: Vec<_> = lint_bundle(&compiled.bundle, ProjectLimits::default())
.into_iter()
.filter(|i| CODES.contains(&i.diagnostic.code.as_str()))
.collect();
let actual: BTreeSet<_> = diagnostics
.iter()
.map(|i| {
assert_eq!(i.diagnostic.severity, Severity::Warning);
let id = i.diagnostic.object_id.unwrap();
assert_eq!(i.path, compiled.index.declarations[&id].path);
assert!(i.diagnostic.span.end > i.diagnostic.span.start);
(i.diagnostic.code.clone(), id, i.diagnostic.field.clone())
})
.collect();
assert_eq!(actual, expected);
assert_eq!(diagnostics.len(), expected.len());
}
// Authoring-equivalent complete record edits keep the test independent of a
// private completeness helper and exercise actual transaction/undo boundaries.
fn diff_commands(before: &Project, after: &Project) -> Vec<Command> {
let mut commands = Vec::new();
for (id, element) in &after.elements {
match before.elements.get(id) {
None => commands.push(Command::CreateElement(element.clone())),
Some(old) if old != element => {
commands.push(Command::EditElement(*id, element.clone()))
}
_ => {}
}
}
for (id, relation) in &after.relations {
match before.relations.get(id) {
None => commands.push(Command::CreateRelation(relation.clone())),
Some(old) if old != relation => {
commands.push(Command::EditRelation(*id, relation.clone()))
}
_ => {}
}
}
commands
}
+79 -8
View File
@@ -339,6 +339,73 @@ project mission "Harbor safety — 港 🌊" {
component_port,
function_port,
);
let participants: Vec<_> = p
.elements
.values()
.filter(|e| matches!(e.kind(), Kind::Entity | Kind::Actor))
.map(|e| e.id)
.collect();
let means = p.relate(
RelationKind::CommunicationMean,
"Operational communication",
participants[0],
participants[1],
);
let category = p
.add_element(
Kind::ExchangeCategory,
Layer::Oa,
"Operational exchange category",
None,
)
.unwrap();
*p.elements.get_mut(&category).unwrap().shared_mut().unwrap() = SharedBody::ExchangeCategory {
family: oa_core::view_options::ExchangeFamily::Participant,
exchanges: vec![means],
};
let caller = p
.add_element(Kind::Process, Layer::Oa, "Common work caller", None)
.unwrap();
let work = p
.elements
.values()
.find(|e| e.kind() == Kind::Activity)
.unwrap()
.id;
let mut occurrences = vec![];
for name in ["First contribution", "Second contribution"] {
let id = p
.add_element(Kind::Occurrence, Layer::Oa, name, Some(caller))
.unwrap();
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::Occurrence {
body: OccurrenceBody::Action {
action: Some(work),
lifeline: None,
},
precondition: String::new(),
postcondition: String::new(),
};
occurrences.push(id);
}
*p.elements.get_mut(&caller).unwrap().steps_mut().unwrap() = occurrences.clone();
let id = p.relate(
RelationKind::ContextualLink,
"Shared work incidence",
occurrences[0],
occurrences[1],
);
p.relations.get_mut(&id).unwrap().body = RelationBody::ContextualLink {
owner: caller,
source: OccurrenceEndpoint {
references: vec![],
occurrence: occurrences[0],
},
target: OccurrenceEndpoint {
references: vec![],
occurrence: occurrences[1],
},
purpose: ContextualLinkPurpose::CommonWork { work },
};
p
}
@@ -491,7 +558,7 @@ fn authoring_defaults_and_forward_references_remain_strict() {
#[test]
fn typed_unknown_fields_enums_duplicates_and_reference_spelling_are_rejected() {
for (source, expected) in [
("arcadia 6; project mission \"x\" {}", "Unsupported schema"),
("arcadia 8; project mission \"x\" {}", "Unsupported schema"),
(
"arcadia 2; project mission \"x\" { oa_activity a \"A\" { nonsense 1; } }",
"Unknown field",
@@ -795,7 +862,7 @@ fn original_v2_text_and_json_preserve_identity_and_mixed_scenarios_after_extensi
assert_eq!(from_source, from_json);
let original: serde_json::Value = serde_json::from_slice(old_json).unwrap();
let mut retained = serde_json::to_value(&from_json).unwrap();
assert_eq!(retained["schema_version"], 5);
assert_eq!(retained["schema_version"], oa_core::SCHEMA_VERSION);
retained["schema_version"] = 2.into();
let allocations: Vec<_> = from_json
.relations
@@ -904,6 +971,11 @@ fn original_v2_text_and_json_preserve_identity_and_mixed_scenarios_after_extensi
}
}
for diagram in retained["diagrams"].as_object_mut().unwrap().values_mut() {
assert_eq!(
diagram["view_options"],
serde_json::to_value(oa_core::view_options::ViewOptions::default()).unwrap()
);
diagram.as_object_mut().unwrap().remove("view_options");
assert_eq!(diagram["routes"], serde_json::json!([]));
diagram.as_object_mut().unwrap().remove("routes");
}
@@ -991,7 +1063,7 @@ fn v1_text_and_json_migrate_deterministically_without_losing_legacy_semantics()
assert_eq!(from_text, from_json);
assert_eq!(read(text), from_text);
assert_eq!(persistence::decode(json).unwrap(), from_json);
assert_eq!(from_text.schema_version, 5);
assert_eq!(from_text.schema_version, oa_core::SCHEMA_VERSION);
assert!(!from_text.elements.values().any(|e| e.layer() == Layer::Sa));
let raw: serde_json::Value = serde_json::from_slice(json).unwrap();
for (id, record) in raw["elements"].as_object().unwrap() {
@@ -1190,11 +1262,10 @@ fn complete_normative_examples_are_accepted_by_the_real_parser() {
break;
}
}
if code.trim_start().starts_with("arcadia 2;")
|| code.trim_start().starts_with("arcadia 3;")
|| code.trim_start().starts_with("arcadia 4;")
|| code.trim_start().starts_with("arcadia 5;")
{
if (1..=SCHEMA_VERSION).any(|version| {
code.trim_start()
.starts_with(&format!("arcadia {version};"))
}) {
read(&code);
examples += 1;
}
+11 -3
View File
@@ -39,7 +39,10 @@ fn frozen_original_v2_reader_preserves_every_original_record() {
assert_eq!(decoded.project, from_source.project);
assert_eq!(decoded.migration, from_source.migration);
let report = decoded.migration.unwrap();
assert_eq!((report.from_schema, report.to_schema), (2, 5));
assert_eq!(
(report.from_schema, report.to_schema),
(2, oa_core::SCHEMA_VERSION)
);
assert_eq!(report.preserved_ports.len(), 4);
assert!(report.generated_endpoints.is_empty());
assert_eq!(report.orientation_defaults.len(), 1);
@@ -60,6 +63,11 @@ fn frozen_original_v2_reader_preserves_every_original_record() {
);
let mut current = serde_json::to_value(&decoded.project).unwrap();
for diagram in current["diagrams"].as_object_mut().unwrap().values_mut() {
assert_eq!(
diagram["view_options"],
serde_json::to_value(oa_core::view_options::ViewOptions::default()).unwrap()
);
diagram.as_object_mut().unwrap().remove("view_options");
assert_eq!(diagram["routes"], json!([]), "V2 adds no authored routing");
diagram.as_object_mut().unwrap().remove("routes");
}
@@ -144,7 +152,7 @@ fn missing_v2_endpoints_are_deterministic_reported_and_independently_typed() {
}
}
let canonical = language::format(&first.project);
assert!(canonical.starts_with("arcadia 5;"));
assert!(canonical.starts_with(&format!("arcadia {SCHEMA_VERSION};")));
let reopened = persistence::decode_with_report(canonical.as_bytes()).unwrap();
assert!(reopened.migration.is_none());
assert_eq!(reopened.project, first.project);
@@ -274,7 +282,7 @@ fn old_contract_is_validated_before_new_shapes_and_v1_flows_through_v2() {
assert!(error.contains("V2") && error.contains(&fe), "{error}");
let v1 = include_bytes!("../../../examples/legacy-v1-coastal-rescue.oa.json");
let result = persistence::decode_with_report(v1).unwrap();
assert_eq!(result.project.schema_version, 5);
assert_eq!(result.project.schema_version, oa_core::SCHEMA_VERSION);
assert_eq!(result.migration.unwrap().from_schema, 1);
assert_eq!(persistence::decode(v1).unwrap(), result.project);
}
+4 -1
View File
@@ -30,7 +30,10 @@ project typed_ports "Typed ports" {
fn source4_covers_typed_contracts_roles_allocations_and_nested_legacy_references() {
let decoded = language::parse_with_report(SOURCE).unwrap();
let migration = decoded.migration.as_ref().unwrap();
assert_eq!((migration.from_schema, migration.to_schema), (4, 5));
assert_eq!(
(migration.from_schema, migration.to_schema),
(4, oa_core::SCHEMA_VERSION)
);
let canonical = language::format(&decoded.project);
for field in [
"incoming_items [@data]",
+5 -1
View File
@@ -102,6 +102,7 @@ fn fixture(family: Family) -> (Project, Id, Id, Id, FacetSelector, FacetSelector
id,
SharedBody::Occurrence {
body: OccurrenceBody::Exchange {
operation: None,
exchange: None,
sender: None,
receiver: None,
@@ -351,7 +352,10 @@ fn migrated_equal_constructor_can_complete_semantic_review_without_model_changes
for reverse in [false, true] {
let decoded = persistence::decode_with_report(json.as_bytes()).unwrap();
let report = decoded.migration.unwrap();
assert_eq!((report.from_schema, report.to_schema), (4, 5));
assert_eq!(
(report.from_schema, report.to_schema),
(4, oa_core::SCHEMA_VERSION)
);
let p = decoded.project;
let t = p.traces.values().next().unwrap();
assert!(t.baseline.as_ref().unwrap().semantic.is_none());
+12 -6
View File
@@ -11,9 +11,12 @@ fn frozen_v4_json_and_source_preserve_the_old_review_without_fabricating_semanti
let source = language::parse_with_report(SOURCE4).unwrap();
assert_eq!(json.project, source.project);
for decoded in [json, source] {
assert_eq!(decoded.project.schema_version, 5);
assert_eq!(decoded.project.schema_version, oa_core::SCHEMA_VERSION);
let report = decoded.migration.unwrap();
assert_eq!((report.from_schema, report.to_schema), (4, 5));
assert_eq!(
(report.from_schema, report.to_schema),
(4, oa_core::SCHEMA_VERSION)
);
let trace = decoded.project.traces.values().next().unwrap();
let baseline = trace.baseline.as_ref().unwrap();
assert!(baseline.semantic.is_none());
@@ -22,7 +25,7 @@ fn frozen_v4_json_and_source_preserve_the_old_review_without_fabricating_semanti
assert_eq!(baseline.sa.name, "Approved SA name");
assert_ne!(baseline.oa.name, decoded.project.named(trace.oa));
let mut expected: Value = serde_json::from_slice(JSON4).unwrap();
expected["schema_version"] = 5.into();
expected["schema_version"] = oa_core::SCHEMA_VERSION.into();
assert_eq!(serde_json::to_value(&decoded.project).unwrap(), expected);
let reopened =
persistence::decode_with_report(&serde_json::to_vec(&decoded.project).unwrap())
@@ -117,7 +120,7 @@ fn authored_v4_modules_upgrade_only_the_version_and_preserve_nested_comments() {
assert_eq!(compiled.bundle.files["contracts.arcadia"], module);
assert_eq!(
compiled.bundle.files["project.arcadia"],
entry.replacen("arcadia 4;", "arcadia 5;", 1)
entry.replacen("arcadia 4;", &format!("arcadia {SCHEMA_VERSION};"), 1)
);
assert_eq!(
compiled.index.lexical_owners
@@ -152,8 +155,11 @@ fn original_v1_v2_v3_inputs_complete_the_explicit_upgrade_to_five() {
] {
let decoded = persistence::decode_with_report(bytes).unwrap();
let report = decoded.migration.unwrap();
assert_eq!((report.from_schema, report.to_schema), (version, 5));
assert_eq!(decoded.project.schema_version, 5);
assert_eq!(
(report.from_schema, report.to_schema),
(version, oa_core::SCHEMA_VERSION)
);
assert_eq!(decoded.project.schema_version, oa_core::SCHEMA_VERSION);
assert!(
decoded
.project
+467
View File
@@ -515,6 +515,7 @@ fn payload(p: &mut Project, layer: Layer, prefix: &str) -> Payload {
scenario,
&format!("{prefix} message"),
OccurrenceBody::Exchange {
operation: None,
exchange: Some(exchange),
sender: None,
receiver: None,
@@ -642,6 +643,11 @@ fn atomic_annotation_full_path_updates_only_selected_row() {
let rows = values_mut(&mut q, b.message);
assert_eq!(rows.len(), 2);
assert!(rows.contains(&retained));
assert_eq!(AnnotationKey::from(&rows[0]), b.first);
assert_eq!(
rows[1], retained,
"Unselected rows retain their authored position"
);
assert!(
rows.iter()
.any(|v| AnnotationKey::from(v) == b.first && v.expression == "new first")
@@ -651,6 +657,73 @@ fn atomic_annotation_full_path_updates_only_selected_row() {
.any(|v| AnnotationKey::from(v) == b.second && v.expression == retained.expression)
);
}
#[test]
fn keeping_keyed_rows_with_an_unrelated_header_candidate_preserves_exact_authored_order() {
for binding in [false, true] {
for reverse in [false, true] {
let mut p = Project::empty();
let (oa, sa, selector, references) = if binding {
let a = scenario_use(&mut p, Layer::Oa, "oa");
let b = scenario_use(&mut p, Layer::Sa, "sa");
(
a.use_id,
b.use_id,
FacetSelector::Binding(KeyPair {
oa: Some(a.referenced),
sa: Some(b.referenced),
}),
BTreeMap::from([(a.referenced, b.referenced)]),
)
} else {
let a = payload(&mut p, Layer::Oa, "oa");
let b = payload(&mut p, Layer::Sa, "sa");
let mut references: BTreeMap<_, _> = a
.first
.path
.iter()
.copied()
.zip(b.first.path.iter().copied())
.collect();
references.insert(a.leaf, b.leaf);
(
a.message,
b.message,
FacetSelector::Annotation(KeyPair {
oa: Some(a.first),
sa: Some(b.first),
}),
references,
)
};
trace(&mut p, oa, sa);
let (source, target) = if reverse { (sa, oa) } else { (oa, sa) };
let mut candidate = p.elements[&target].clone();
candidate.description = "Review the header while retaining authored rows".into();
let mut review = request(
source,
target,
vec![
(selector, SemanticDecision::KeepTarget),
(
FacetSelector::Header(HeaderFacet::Description),
SemanticDecision::Candidate,
),
],
);
if reverse {
review.transition.direction = TransitionDirection::SaToOa;
}
review.references = references;
review
.candidates
.push(SemanticRecord::Element(candidate.clone()));
let accepted = applied(&p, review);
assert_eq!(accepted.elements[&target], candidate);
assert_eq!(accepted.elements[&source], p.elements[&source]);
}
}
}
#[test]
fn atomic_binding_updates_local_and_rejects_pair_collision_and_remapping_without_reset() {
let mut p = Project::empty();
@@ -671,6 +744,8 @@ fn atomic_binding_updates_local_and_rejects_pair_collision_and_remapping_without
r.references = BTreeMap::from([(a.referenced, b.referenced), (a.alternate, b.alternate)]);
let mut q = applied(&p, r.clone());
assert!(bindings_mut(&mut q, b.use_id).contains(&retained));
assert_eq!(bindings_mut(&mut q, b.use_id)[0].referenced, b.referenced);
assert_eq!(bindings_mut(&mut q, b.use_id)[1], retained);
assert!(
bindings_mut(&mut q, b.use_id)
.iter()
@@ -1917,3 +1992,395 @@ fn retired_source_branch_retains_historical_member_pair_in_both_directions() {
);
}
}
#[test]
fn constructor_closure_preserves_target_only_literals_in_both_directions() {
for reverse in [false, true] {
let mut p = Project::empty();
let oa = add(&mut p, Kind::DataType, Layer::Oa, "oa");
let sa = add(&mut p, Kind::DataType, Layer::Sa, "sa");
let (source, target) = if reverse { (sa, oa) } else { (oa, sa) };
enumeration(&mut p, target, &["local"]);
let t = trace(&mut p, oa, sa);
let facts = semantic_facets(&p, &p.traces[&t], &BTreeMap::new()).unwrap();
p.traces
.get_mut(&t)
.unwrap()
.baseline
.as_mut()
.unwrap()
.semantic = Some(SemanticBaseline {
facets: facts.into_iter().map(|f| f.current).collect(),
});
enumeration(&mut p, source, &["incoming"]);
let mut r = request(
source,
target,
vec![(
FacetSelector::Data(DataFacet::Constructor),
SemanticDecision::TakeSource,
)],
);
if reverse {
r.transition.direction = TransitionDirection::SaToOa;
}
let q = applied(&p, r);
assert!(
matches!(q.elements[&target].shared(), Some(SharedBody::DataType {
definition: DataTypeDefinition::Enumeration { literals }, ..
}) if literals == &["incoming", "local"]),
"reverse={reverse}"
);
let base = q.traces[&t]
.baseline
.as_ref()
.unwrap()
.semantic
.as_ref()
.unwrap();
validate_semantic_baseline(base).unwrap();
let incoming = FacetSelector::Literal(KeyPair {
oa: Some("incoming".into()),
sa: Some("incoming".into()),
});
assert_eq!(
base.facets
.iter()
.filter(|f| f.selector() == incoming)
.count(),
1
);
let facts = semantic_facets(&q, &q.traces[&t], &BTreeMap::new()).unwrap();
assert!(
facts
.iter()
.filter(|f| matches!(f.selector, FacetSelector::Literal(_)))
.all(|f| f.drift == Drift::Unchanged)
);
}
}
#[test]
fn constructor_closure_pairs_signal_members_and_requires_history_reset_in_both_directions() {
for reverse in [false, true] {
for historical in [false, true] {
let mut p = Project::empty();
let oa = add(&mut p, Kind::Event, Layer::Oa, "oa");
let sa = add(&mut p, Kind::Event, Layer::Sa, "sa");
let oa_item = add(&mut p, Kind::ExchangeItem, Layer::Oa, "oa_item");
let sa_item = add(&mut p, Kind::ExchangeItem, Layer::Sa, "sa_item");
let (source, target, source_item) = if reverse {
(sa, oa, sa_item)
} else {
(oa, sa, oa_item)
};
set(
&mut p,
source,
SharedBody::Event {
body: EventBody::Signal {
items: vec![source_item],
},
},
);
set(
&mut p,
target,
SharedBody::Event {
body: EventBody::Change {
expression: "old".into(),
},
},
);
let t = trace(&mut p, oa, sa);
let previous = FacetSelector::Member {
collection: ReferenceCollection::SignalItems,
keys: if reverse {
KeyPair {
oa: None,
sa: Some(sa_item),
}
} else {
KeyPair {
oa: Some(oa_item),
sa: None,
}
},
};
if historical {
let facts = semantic_facets(&p, &p.traces[&t], &BTreeMap::new()).unwrap();
let old = facts
.into_iter()
.find(|f| f.selector == previous)
.unwrap()
.current;
p.traces
.get_mut(&t)
.unwrap()
.baseline
.as_mut()
.unwrap()
.semantic = Some(SemanticBaseline { facets: vec![old] });
}
let mut r = request(
source,
target,
vec![(
FacetSelector::Event(EventFacet::Constructor),
SemanticDecision::TakeSource,
)],
);
if reverse {
r.transition.direction = TransitionDirection::SaToOa;
}
r.references.insert(oa_item, sa_item);
if historical {
let invalid = plan_semantic_transition(&p, r.clone()).unwrap();
assert!(
!invalid.conflicts.is_empty(),
"Established one-sided history requires reset: reverse={reverse}"
);
r.reviews[0].reset.push(previous);
}
let q = applied(&p, r);
let target_item = if reverse { oa_item } else { sa_item };
assert!(
matches!(q.elements[&target].shared(), Some(SharedBody::Event { body: EventBody::Signal { items } }) if items == &[target_item])
);
let base = q.traces[&t]
.baseline
.as_ref()
.unwrap()
.semantic
.as_ref()
.unwrap();
validate_semantic_baseline(base).unwrap();
let members: Vec<_> = base
.facets
.iter()
.filter(|f| {
matches!(
f.selector(),
FacetSelector::Member {
collection: ReferenceCollection::SignalItems,
..
}
)
})
.collect();
assert_eq!(
members.len(),
1,
"reverse={reverse}, historical={historical}"
);
assert_eq!(
members[0].selector(),
FacetSelector::Member {
collection: ReferenceCollection::SignalItems,
keys: KeyPair {
oa: Some(oa_item),
sa: Some(sa_item)
}
}
);
assert!(
validation::validate(&q)
.iter()
.all(|issue| issue.severity != validation::Severity::Error)
);
}
}
}
#[test]
fn constructor_closure_does_not_restore_previously_corresponding_removed_members() {
for reverse in [false, true] {
let mut p = Project::empty();
let oa = add(&mut p, Kind::DataType, Layer::Oa, "oa");
let sa = add(&mut p, Kind::DataType, Layer::Sa, "sa");
let (source, target) = if reverse { (sa, oa) } else { (oa, sa) };
enumeration(&mut p, source, &["paired"]);
let t = trace(&mut p, oa, sa);
let constructor = FacetSelector::Data(DataFacet::Constructor);
let directed = |facets| {
let mut r = request(source, target, facets);
if reverse {
r.transition.direction = TransitionDirection::SaToOa;
}
r
};
p = applied(
&p,
directed(vec![(constructor.clone(), SemanticDecision::KeepTarget)]),
);
if reverse {
// Explicitly establish the counterpart key while its target branch
// is absent, rather than silently changing the one-sided history.
let mut r = directed(vec![(
FacetSelector::Literal(KeyPair {
oa: Some("paired".into()),
sa: Some("paired".into()),
}),
SemanticDecision::KeepTarget,
)]);
r.reviews[0].reset.push(FacetSelector::Literal(KeyPair {
oa: None,
sa: Some("paired".into()),
}));
p = applied(&p, r);
}
enumeration(&mut p, source, &["incoming"]);
enumeration(&mut p, target, &["paired", "local"]);
let q = applied(
&p,
directed(vec![(constructor, SemanticDecision::TakeSource)]),
);
assert!(
matches!(q.elements[&target].shared(), Some(SharedBody::DataType {
definition: DataTypeDefinition::Enumeration { literals }, ..
}) if literals == &["incoming", "local"]),
"reverse={reverse}"
);
validate_semantic_baseline(
q.traces[&t]
.baseline
.as_ref()
.unwrap()
.semantic
.as_ref()
.unwrap(),
)
.unwrap();
}
}
fn reverse_legacy_port_fixture() -> (Project, Id, Id, Id) {
let mut p = Project::empty();
let oa_owner = add(&mut p, Kind::Actor, Layer::Oa, "oa owner");
let sa_owner = add(&mut p, Kind::SystemActor, Layer::Sa, "sa owner");
let oa = Id::new_v4();
let sa = Id::new_v4();
let mut legacy = make_port(&p, oa, oa_owner, "oa port", PortContract::Legacy).unwrap();
*legacy.shared_mut().unwrap() = SharedBody::Port {
contract: PortContract::Legacy,
legacy: Some(LegacyPortContract {
direction: PortDirection::InOut,
interface: None,
}),
};
p.elements.insert(oa, legacy);
let typed = make_port(
&p,
sa,
sa_owner,
"sa port",
PortContract::Component {
kind: ComponentPortKind::Flow,
orientation: ComponentPortOrientation::Out,
},
)
.unwrap();
p.elements.insert(sa, typed);
let t = trace(&mut p, oa, sa);
(p, oa, sa, t)
}
#[test]
fn reverse_existing_port_review_retains_legacy_and_roundtrips_typed_history() {
for decision in [SemanticDecision::KeepTarget, SemanticDecision::Candidate] {
let (p, oa, sa, t) = reverse_legacy_port_fixture();
let mut r = request(
sa,
oa,
vec![(FacetSelector::Port(PortFacet::Constructor), decision)],
);
r.transition.direction = TransitionDirection::SaToOa;
r.traces.insert(sa, t);
let expected_direction = if decision == SemanticDecision::Candidate {
let mut candidate = p.elements[&oa].clone();
let Some(SharedBody::Port {
legacy: Some(old), ..
}) = candidate.shared_mut()
else {
panic!()
};
old.direction = PortDirection::In;
r.candidates.push(SemanticRecord::Element(candidate));
PortDirection::In
} else {
PortDirection::InOut
};
let q = applied(&p, r);
assert_eq!(q.elements[&sa], p.elements[&sa]);
assert_eq!(q.elements.len(), p.elements.len());
assert!(matches!(q.elements[&oa].shared(), Some(SharedBody::Port {
contract: PortContract::Legacy, legacy: Some(LegacyPortContract { direction, interface: None }),
}) if *direction == expected_direction));
let baseline = q.traces[&t]
.baseline
.as_ref()
.unwrap()
.semantic
.as_ref()
.unwrap();
validate_semantic_baseline(baseline).unwrap();
assert!(baseline.facets.iter().any(|f| matches!(
f,
FacetReview::Port(PortReview::Constructor(Reviewed {
oa: Observed::Value(PortConstructor::Legacy),
sa: Observed::Value(PortConstructor::Component),
}))
)));
assert_eq!(language::parse(&language::format(&q)).unwrap(), q);
assert_eq!(
persistence::decode(&serde_json::to_vec(&q).unwrap()).unwrap(),
q
);
}
}
#[test]
fn reverse_existing_port_review_rejects_typed_oa_proposals_without_mutation() {
for decision in [SemanticDecision::TakeSource, SemanticDecision::Candidate] {
let (p, oa, sa, _) = reverse_legacy_port_fixture();
let snapshot = p.clone();
let mut r = request(
sa,
oa,
vec![(FacetSelector::Port(PortFacet::Constructor), decision)],
);
r.transition.direction = TransitionDirection::SaToOa;
if decision == SemanticDecision::Candidate {
let mut candidate = p.elements[&oa].clone();
*candidate.shared_mut().unwrap() = p.elements[&sa].shared().unwrap().clone();
r.candidates.push(SemanticRecord::Element(candidate));
}
let plan = plan_semantic_transition(&p, r).unwrap();
assert!(!plan.conflicts.is_empty());
assert!(
plan.conflicts
.iter()
.all(|c| !c.contains("outside selected transition")),
"Must reach structural validation: {:?}",
plan.conflicts
);
assert!(prepare_semantic_transition(&p, &plan).is_err());
assert_eq!(p, snapshot);
}
}
#[test]
fn reverse_new_port_counterpart_remains_an_explicit_sa_only_omission() {
let (p, _, sa, _) = reverse_legacy_port_fixture();
let mut r = TransitionRequest::new(TransitionDirection::SaToOa, vec![sa]);
r.mappings.push(TransitionMapping {
source: sa,
target: None,
target_kind: Some(Kind::Port),
});
let plan = plan_transition(&p, &r).unwrap();
assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts);
assert!(plan.changes.is_empty());
assert!(plan.warnings.iter().any(|w| w.contains("SA-only omission")));
assert_eq!(apply_transition(&p, &plan).unwrap(), p);
}
+1 -1
View File
@@ -131,7 +131,7 @@ fn typed_historical_facets_roundtrip_deleted_references_and_all_leaf_categories(
]);
assert!(!model.contains(id(90)));
let source = language::format(&model);
assert!(source.starts_with("arcadia 5;"));
assert!(source.starts_with(&format!("arcadia {SCHEMA_VERSION};")));
assert!(
source.contains(&format!("Named uuid \"{}\"", id(90))),
"{source}"
+1
View File
@@ -91,6 +91,7 @@ fn message(
id,
SharedBody::Occurrence {
body: OccurrenceBody::Exchange {
operation: None,
exchange: Some(exchange),
sender: None,
receiver: None,
+7 -4
View File
@@ -21,7 +21,10 @@ fn genuine_v3_preserves_typed_ports_and_allocates_deterministic_relationships()
assert_eq!(decoded.migration, source.migration);
let project = &decoded.project;
let report = decoded.migration.as_ref().unwrap();
assert_eq!((report.from_schema, report.to_schema), (3, 5));
assert_eq!(
(report.from_schema, report.to_schema),
(3, oa_core::SCHEMA_VERSION)
);
assert_eq!(project.identities().len(), 19);
assert!(report.preserved_ports.is_empty());
assert!(report.generated_endpoints.is_empty());
@@ -71,7 +74,7 @@ fn genuine_v3_preserves_typed_ports_and_allocates_deterministic_relationships()
.any(|issue| issue.code == "message_value_ambiguous_path")
);
let canonical = language::format(project);
assert!(canonical.starts_with("arcadia 5;"));
assert!(canonical.starts_with(&format!("arcadia {SCHEMA_VERSION};")));
let reopened = language::parse_with_report(&canonical).unwrap();
assert_eq!(&reopened.project, project);
assert!(reopened.migration.is_none());
@@ -154,12 +157,12 @@ fn v1_and_frozen_v2_inputs_reach_four_through_the_shared_data_upgrade() {
language::parse_with_report(include_str!("fixtures/v2-coastal-rescue.arcadia")).unwrap();
assert_eq!(v2.project, text.project);
assert_eq!(v2.migration, text.migration);
assert_eq!(v2.migration.unwrap().to_schema, 5);
assert_eq!(v2.migration.unwrap().to_schema, oa_core::SCHEMA_VERSION);
let v1 = language::parse_with_report(
"arcadia 1; project \"Old\" id \"00000000-0000-0000-0000-000000000001\" {}",
)
.unwrap();
assert_eq!(v1.project.schema_version, 5);
assert_eq!(v1.project.schema_version, oa_core::SCHEMA_VERSION);
assert_eq!(v1.migration.unwrap().from_schema, 1);
}
+3 -1
View File
@@ -26,7 +26,9 @@ fn authored_v3_bundle_upgrade_preserves_includes_nested_ownership_comments_and_i
};
let compiled = compile_bundle(&bundle, ProjectLimits::default()).unwrap();
assert_eq!(compiled.bundle.files.len(), 2);
assert!(compiled.bundle.files["project.arcadia"].contains("arcadia 5;"));
assert!(
compiled.bundle.files["project.arcadia"].contains(&format!("arcadia {SCHEMA_VERSION};"))
);
assert!(compiled.bundle.files["project.arcadia"].contains("include \"contracts.arcadia\";"));
for text in [
"// Contracts stay in their original module.",
+256
View File
@@ -0,0 +1,256 @@
//! Engineering oracles for the distributable current-profile lesson.
#[path = "../../../examples/release1/model.rs"]
mod teaching;
use oa_core::{projection, source_project, transitions::*, validation, *};
use teaching::id;
#[test]
fn release1_teaching_regeneration_and_shipped_source_are_exact_with_one_intentional_warning() {
let first = teaching::build();
let second = teaching::build();
assert_eq!(
first.project, second.project,
"fresh planner UUIDs must not churn the teaching source"
);
assert_eq!(first.before_transition, second.before_transition);
assert_eq!(first.request, second.request);
let bundle = teaching::checked_bundle(&first.project);
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../examples/release1");
for (name, bytes) in bundle.files {
assert_eq!(
std::fs::read_to_string(root.join(&name)).unwrap(),
bytes,
"regenerate {name}"
);
}
assert_eq!(
persistence::decode(&std::fs::read(root.join("model.json")).unwrap()).unwrap(),
first.project
);
for (path, expected) in [
(
"transition-before.json",
serde_json::to_value(&first.before_transition).unwrap(),
),
(
"transition-request.json",
serde_json::to_value(&first.request).unwrap(),
),
(
"expected-diagnostics.json",
serde_json::to_value(validation::validate(&first.project)).unwrap(),
),
] {
let shipped: serde_json::Value =
serde_json::from_slice(&std::fs::read(root.join(path)).unwrap()).unwrap();
assert_eq!(shipped, expected, "regenerate {path}");
}
let issues = validation::validate(&first.project);
assert_eq!(issues.len(), 1, "{issues:?}");
assert_eq!(issues[0].severity, validation::Severity::Warning);
assert_eq!(issues[0].code, "work_without_behavior_context");
assert_eq!(issues[0].element, Some(id("sa_audit_thresholds")));
assert!(
first
.project
.elements
.values()
.filter(|e| e.layer() == Layer::Oa)
.all(|e| e.kind() != Kind::Port)
);
}
#[test]
fn release1_teaching_preserves_explicit_transport_direction_reuse_and_view_identity() {
let lesson = teaching::build();
let p = &lesson.project;
for prefix in ["oa", "sa"] {
assert!(
matches!(p.elements[&id(&format!("{prefix}_assess_step"))].shared(), Some(SharedBody::Occurrence { body: OccurrenceBody::SequenceReference { sequence: Some(target) }, .. }) if *target == id(&format!("{prefix}_assessment_procedure")))
);
assert!(
matches!(p.elements[&id(&format!("{prefix}_assessment_action"))].shared(), Some(SharedBody::Occurrence { body: OccurrenceBody::Action { action: Some(target), .. }, .. }) if *target == id(&format!("{prefix}_assess")))
);
assert!(
p.relations
.values()
.any(|relation| relation.kind() == RelationKind::ChainInvolvement
&& relation.source() == id(&format!("{prefix}_assessment_procedure"))
&& relation.target() == id(&format!("{prefix}_triage")))
);
assert!(
matches!(p.elements[&id(&format!("{prefix}_conversation"))].shared(), Some(SharedBody::Scenario { context: Some(context), .. }) if *context == id(&format!("{prefix}_restore")))
);
assert!(p.relations.values().any(|relation| relation.kind()
== RelationKind::CapabilityInvolvement
&& relation.source() == id(&format!("{prefix}_assess"))
&& relation.target() == id(&format!("{prefix}_triage"))));
let call = &p.elements[&id(&format!("{prefix}_call"))];
let reply = &p.elements[&id(&format!("{prefix}_reply"))];
let body = |e: &Element| {
if let Some(SharedBody::Occurrence { body, .. }) = e.shared() {
body.clone()
} else {
panic!()
}
};
let validation::MessageEndpointResolution::Resolved { source, target } =
validation::resolve_message_endpoints(p, &body(call))
else {
panic!("call not resolved")
};
assert_eq!(
validation::resolve_message_endpoints(p, &body(reply)),
validation::MessageEndpointResolution::Resolved {
source: target,
target: source
}
);
assert!(
matches!(p.elements[&id(&format!("{prefix}_use_field"))].shared(), Some(SharedBody::Occurrence { body: OccurrenceBody::SequenceReference { sequence: Some(target) }, .. }) if *target == id(&format!("{prefix}_field_procedure")))
);
assert!(
matches!(p.elements[&id(&format!("{prefix}_use_conversation"))].shared(), Some(SharedBody::Occurrence { body: OccurrenceBody::ScenarioUse { scenario: Some(target), bindings }, .. }) if *target == id(&format!("{prefix}_conversation")) && bindings.len() == 2)
);
}
let full = projection::project_view(p, id("sa_allocation_view"));
let focused = projection::project_view(p, id("sa_service_view"));
let edges: std::collections::BTreeSet<_> = full.edges.iter().map(|e| e.id).collect();
assert_eq!(
edges,
[
id("sa_contact"),
id("sa_repair_request"),
id("sa_repair_report")
]
.into()
);
assert_eq!(focused.edges.len(), 1);
assert_eq!(focused.edges[0].id, id("sa_contact"));
assert!(
p.diagrams[&id("sa_service_view")]
.view_options
.exchange_labels
.label(p, focused.edges[0].id)
.contains("Valve repair")
);
let ports = |view: &projection::ProjectedView| {
view.ports
.iter()
.map(|v| (v.element, v.position))
.collect::<std::collections::BTreeMap<_, _>>()
};
assert_eq!(
ports(&full),
ports(&focused),
"filtering must not move the shared technical port geometry"
);
assert!(
p.traces
.values()
.any(|t| t.oa == id("oa_contact") && t.sa == id("sa_contact"))
);
}
#[test]
fn release1_teaching_rejects_guessed_ports_and_wrong_performer_without_changing_source() {
let lesson = teaching::build();
let mut missing_decisions = lesson.request.clone();
missing_decisions.communication_endpoints.clear();
let plan = plan_transition(&lesson.before_transition, &missing_decisions).unwrap();
assert!(
!plan.conflicts.is_empty(),
"an operational channel does not determine technical ports"
);
let compiled = source_project::compile_bundle(
&teaching::checked_bundle(&lesson.project),
source_project::ProjectLimits::default(),
)
.unwrap();
let mut wrong = lesson.project.relations[&id("oa_repair_request")].clone();
wrong.body.set_endpoints(id("oa_assess"), id("oa_reset"));
assert!(
execute_batch(
&lesson.project,
&[Command::EditRelation(wrong.id, wrong.clone())]
)
.is_err()
);
let mut candidate = lesson.project.clone();
candidate.relations.insert(wrong.id, wrong);
assert!(
validation::validate(&candidate)
.iter()
.any(|i| i.severity == validation::Severity::Error
&& i.code == "exchange_allocation_performer")
);
assert!(
source_project::update_bundle(
&compiled,
&candidate,
source_project::ProjectLimits::default()
)
.is_err()
);
assert_eq!(compiled.decoded.project, lesson.project);
}
#[test]
fn release1_teaching_review_changes_only_the_selected_counterpart_and_preserves_modules() {
let lesson = teaching::build();
let mut changed = lesson.project.clone();
changed.elements.get_mut(&id("oa_confirm")).unwrap().name = "Confirm stable pressure".into();
let compiled = source_project::compile_bundle(
&teaching::checked_bundle(&changed),
source_project::ProjectLimits::default(),
)
.unwrap();
let mut transition =
TransitionRequest::new(TransitionDirection::OaToSa, vec![id("oa_confirm")]);
transition.fields.clear();
transition.mappings.push(TransitionMapping {
source: id("oa_confirm"),
target: Some(id("sa_confirm")),
target_kind: None,
});
let mut request = SemanticTransitionRequest::new(transition);
request.reviews.push(SemanticReviewRequest {
source: id("oa_confirm"),
resolutions: vec![FacetResolution {
facet: FacetSelector::Header(HeaderFacet::Name),
decision: SemanticDecision::TakeSource,
}],
reset: vec![],
});
let plan = plan_semantic_transition(&changed, request).unwrap();
assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts);
let accepted = apply_semantic_transition(&changed, &plan).unwrap();
assert_eq!(
accepted.elements[&id("sa_confirm")].name,
"Confirm stable pressure"
);
assert_eq!(
accepted.elements[&id("sa_control")].name,
lesson.project.elements[&id("sa_control")].name
);
let updated = source_project::update_bundle(
&compiled,
&accepted,
source_project::ProjectLimits::default(),
)
.unwrap();
assert_eq!(updated.decoded.project, accepted);
assert_eq!(
updated.bundle.files.keys().collect::<Vec<_>>(),
compiled.bundle.files.keys().collect::<Vec<_>>()
);
for (path, original) in &compiled.bundle.files {
if path != "sa/functions/function_sa_restore_work.arcadia" && path != "traceability.arcadia"
{
assert_eq!(
&updated.bundle.files[path], original,
"unrelated module changed: {path}"
);
}
}
}
+47 -1
View File
@@ -733,12 +733,36 @@ fn transitions_create_sa_ports_and_omit_sa_implementation_from_portless_oa() {
0
);
let (mut p, a, b, _, fe) = fixture();
allocate(&mut p, a, b);
let (system, partner) = allocate(&mut p, a, b);
let component_req = component_request(&p, fe, ComponentPortKind::Flow);
let boundary = boundary_id(&component_req);
p = execute_batch(&p, &plan_component_exchange(&p, &component_req).unwrap()).unwrap();
let mut reverse = TransitionRequest::new(TransitionDirection::SaToOa, vec![fe, boundary]);
reverse.scope = DependencyScope::Required;
// A boundary now has a portless OA CommunicationMean counterpart. Its
// participant owners require an explicit operational boundary decision.
let incomplete = plan_transition(&p, &reverse).unwrap();
for owner in [system, partner] {
assert!(
incomplete
.conflicts
.iter()
.any(|conflict| conflict.source == Some(owner)
&& conflict.message.contains("counterpart kind explicitly"))
);
}
reverse.mappings = vec![
TransitionMapping {
source: system,
target: None,
target_kind: Some(Kind::Entity),
},
TransitionMapping {
source: partner,
target: None,
target_kind: Some(Kind::Actor),
},
];
let plan = plan_transition(&p, &reverse).unwrap();
assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts);
assert!(plan.warnings.iter().any(|w| w.contains("SA-only omission")));
@@ -757,6 +781,28 @@ fn transitions_create_sa_ports_and_omit_sa_implementation_from_portless_oa() {
.values()
.any(|r| r.kind() == RelationKind::Interaction)
);
let mean = result
.traces
.values()
.find(|trace| trace.sa == boundary)
.unwrap()
.oa;
let relation = &result.relations[&mean];
assert_eq!(relation.kind(), RelationKind::CommunicationMean);
assert_eq!(result.elements[&relation.source()].kind(), Kind::Entity);
assert_eq!(result.elements[&relation.target()].kind(), Kind::Actor);
assert!(
result
.traces
.values()
.any(|trace| trace.sa == system && trace.oa == relation.source())
);
assert!(
result
.traces
.values()
.any(|trace| trace.sa == partner && trace.oa == relation.target())
);
for e in p.elements.values() {
assert_eq!(result.elements[&e.id], *e);
}
+1
View File
@@ -39,6 +39,7 @@ fn fixture() -> Fixture {
description: String::new(),
layer: Layer::Sa,
projection: ProjectionKind::Overview,
view_options: Default::default(),
nodes: BTreeMap::new(),
routes: vec![],
},
@@ -51,6 +51,7 @@ fn message(
call: Option<Id>,
) -> OccurrenceBody {
OccurrenceBody::Exchange {
operation: None,
exchange,
sender: Some(sender),
receiver,
+1
View File
@@ -271,6 +271,7 @@ fn contextual_links_match_work_exchange_endpoints_and_reject_foreign_owners() {
owner,
"Contextual message",
OccurrenceBody::Exchange {
operation: None,
exchange: Some(global),
sender: None,
receiver: None,
@@ -0,0 +1,140 @@
//! Independent GUI boundary: Presence observes attachment, not record existence.
use oa_core::{transitions::*, *};
fn artifact(p: &mut Project, owner: Id, name: &str) -> Id {
let id = Id::new_v4();
p.artifact_links.insert(
id,
ArtifactLink {
id,
alias: format!("artifact_{}", id.simple()),
name: name.into(),
description: String::new(),
owner,
relation: ArtifactRelation::References,
uri: "urn:engineering:requirement".into(),
revision: None,
fragment: Some(String::new()),
},
);
id
}
fn fixture(reverse: bool) -> (Project, Id, Id, TransitionDirection) {
let mut p = Project::empty();
let oa = p
.add_element(Kind::Activity, Layer::Oa, "OA owner", None)
.unwrap();
let sa = p
.add_element(Kind::Function, Layer::Sa, "SA owner", None)
.unwrap();
let id = Id::new_v4();
p.traces.insert(
id,
Trace {
id,
alias: format!("trace_{}", id.simple()),
name: "Owner review".into(),
description: String::new(),
oa,
sa,
kind: TraceKind::Manual,
baseline: Some(TraceBaseline {
oa: p.properties(oa).unwrap(),
sa: p.properties(sa).unwrap(),
semantic: None,
}),
},
);
if reverse {
(p, sa, oa, TransitionDirection::SaToOa)
} else {
(p, oa, sa, TransitionDirection::OaToSa)
}
}
fn removal(
source: Id,
target: Id,
direction: TransitionDirection,
source_link: Option<Id>,
target_link: Id,
) -> SemanticTransitionRequest {
let mut transition = TransitionRequest::new(direction, vec![source]);
transition.mappings.push(TransitionMapping {
source,
target: Some(target),
target_kind: None,
});
let mut request = SemanticTransitionRequest::new(transition);
let keys = if direction == TransitionDirection::OaToSa {
KeyPair {
oa: source_link,
sa: Some(target_link),
}
} else {
KeyPair {
oa: Some(target_link),
sa: source_link,
}
};
if let (Some(oa), Some(sa)) = (keys.oa, keys.sa) {
request.references.insert(oa, sa);
}
request.reviews.push(SemanticReviewRequest {
source,
reset: vec![],
resolutions: vec![FacetResolution {
facet: FacetSelector::Artifact {
keys,
facet: ArtifactFacet::Presence,
},
decision: SemanticDecision::TakeSource,
}],
});
request
}
#[test]
fn target_only_artifact_take_preserves_without_fabricated_source_identity() {
for reverse in [false, true] {
let (mut p, source, target, direction) = fixture(reverse);
let link = artifact(&mut p, target, "Target-only requirement");
let before = p.clone();
let plan =
plan_semantic_transition(&p, removal(source, target, direction, None, link)).unwrap();
assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts);
assert_eq!(p, before, "planning must be pure");
let after = apply_semantic_transition(&p, &plan).unwrap();
assert_eq!(after.artifact_links, before.artifact_links);
assert_eq!(after.elements, before.elements);
assert_eq!(after.relations, before.relations);
}
}
#[test]
fn moved_away_source_artifact_counts_as_absent_from_reviewed_owner() {
for reverse in [false, true] {
let (mut p, source, target, direction) = fixture(reverse);
let (kind, layer) = if reverse {
(Kind::Function, Layer::Sa)
} else {
(Kind::Activity, Layer::Oa)
};
let elsewhere = p
.add_element(kind, layer, "Moved source owner", None)
.unwrap();
let source_link = artifact(&mut p, elsewhere, "Still exists elsewhere");
let target_link = artifact(&mut p, target, "Old counterpart attachment");
let original_source = p.artifact_links[&source_link].clone();
let plan = plan_semantic_transition(
&p,
removal(source, target, direction, Some(source_link), target_link),
)
.unwrap();
assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts);
let after = apply_semantic_transition(&p, &plan).unwrap();
assert!(!after.artifact_links.contains_key(&target_link));
assert_eq!(after.artifact_links[&source_link], original_source);
}
}
@@ -0,0 +1,398 @@
//! Direct core oracles for the next existing-record graphical attachment slice.
use oa_core::{transitions::*, *};
struct Fixture {
p: Project,
owners: [Id; 2],
attached: [Id; 2],
outside: [Id; 2],
trace: Id,
child: bool,
end: IncidentEnd,
}
fn trace(p: &mut Project, oa: Id, sa: Id, name: &str) -> Id {
let id = Id::new_v4();
p.traces.insert(
id,
Trace {
id,
alias: format!("trace_{}", id.simple()),
name: name.into(),
description: String::new(),
oa,
sa,
kind: TraceKind::Manual,
baseline: None,
},
);
id
}
fn fixture(child: bool, end: IncidentEnd) -> Fixture {
let mut p = Project::empty();
let mut owners = vec![];
let mut attached = vec![];
let mut outside = vec![];
for layer in [Layer::Oa, Layer::Sa] {
let kind = if child {
if layer == Layer::Oa {
Kind::Activity
} else {
Kind::Function
}
} else {
Kind::DataType
};
let owner = p.add_element(kind, layer, "Current owner", None).unwrap();
let replacement = p
.add_element(kind, layer, "Reviewed replacement", None)
.unwrap();
let record = if child {
p.add_element(kind, layer, "Attached work", Some(owner))
.unwrap()
} else {
let peer = p.add_element(kind, layer, "Other endpoint", None).unwrap();
let (source, target) = if end == IncidentEnd::Source {
(owner, peer)
} else {
(peer, owner)
};
p.relate(
RelationKind::DataAssociation,
"Incident data association",
source,
target,
)
};
owners.push(owner);
attached.push(record);
outside.push(replacement);
}
for element in p.elements.values_mut() {
if let Some(SharedBody::DataType { definition, .. }) = element.shared_mut() {
*definition = DataTypeDefinition::Record { supertype: None };
}
}
let trace = trace(&mut p, owners[0], owners[1], "Owner attachment rationale");
validate_candidate(&p).unwrap();
Fixture {
p,
owners: [owners[0], owners[1]],
attached: [attached[0], attached[1]],
outside: [outside[0], outside[1]],
trace,
child,
end,
}
}
fn selector(f: &Fixture) -> FacetSelector {
let keys = KeyPair {
oa: Some(f.attached[0]),
sa: Some(f.attached[1]),
};
if f.child {
FacetSelector::Child(keys)
} else {
FacetSelector::Incident { keys, end: f.end }
}
}
fn candidate(f: &Fixture, side: usize) -> SemanticRecord {
if f.child {
let mut value = f.p.elements[&f.attached[side]].clone();
value.parent = Some(f.outside[side]);
SemanticRecord::Element(value)
} else {
let mut value = f.p.relations[&f.attached[side]].clone();
let (a, b) = value.body.endpoints();
value.body.set_endpoints(
if f.end == IncidentEnd::Source {
f.outside[side]
} else {
a
},
if f.end == IncidentEnd::Target {
f.outside[side]
} else {
b
},
);
SemanticRecord::Relation(value)
}
}
fn request(f: &Fixture, reverse: bool, decision: SemanticDecision) -> SemanticTransitionRequest {
let source = usize::from(reverse);
let target = 1 - source;
let mut transition = TransitionRequest::new(
if reverse {
TransitionDirection::SaToOa
} else {
TransitionDirection::OaToSa
},
vec![f.owners[source]],
);
transition.mappings.push(TransitionMapping {
source: f.owners[source],
target: Some(f.owners[target]),
target_kind: None,
});
let mut result = SemanticTransitionRequest::new(transition);
result.traces.insert(f.owners[source], f.trace);
result.references.insert(f.owners[0], f.owners[1]);
result.references.insert(f.attached[0], f.attached[1]);
result.reviews.push(SemanticReviewRequest {
source: f.owners[source],
resolutions: vec![FacetResolution {
facet: selector(f),
decision,
}],
reset: vec![],
});
result
}
fn prepared(p: &Project, request: SemanticTransitionRequest) -> (Project, SemanticTransitionPlan) {
let plan = plan_semantic_transition(p, request).unwrap();
assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts);
let result = prepare_semantic_transition(p, &plan).unwrap().project;
(result, plan)
}
#[test]
fn attachment_candidate_changes_existing_parent_or_endpoint_without_attached_record_trace() {
for child in [true, false] {
for end in [IncidentEnd::Source, IncidentEnd::Target] {
if child && end == IncidentEnd::Target {
continue;
}
for reverse in [false, true] {
let f = fixture(child, end);
assert_eq!(f.p.traces.len(), 1);
let side = usize::from(!reverse);
let expected = candidate(&f, side);
let mut request = request(&f, reverse, SemanticDecision::Candidate);
request.candidates.push(expected.clone());
let original_source = semantic_record(&f.p, f.attached[1 - side]);
let (result, plan) = prepared(&f.p, request);
assert_eq!(semantic_record(&result, f.attached[side]), Some(expected));
assert_eq!(
semantic_record(&result, f.attached[1 - side]),
original_source
);
assert_eq!(
result.traces.len(),
1,
"No relation/child trace is invented"
);
assert_eq!(plan.reviews[0].acknowledged, vec![selector(&f)]);
let source = language::format(&result);
assert_eq!(language::parse(&source).unwrap(), result);
let mut document = f.p.clone();
let receipt = apply_semantic_transition(&document, &plan).unwrap();
document = receipt;
assert_eq!(document, result);
}
}
}
}
#[test]
fn incident_absence_take_refuses_but_explicit_candidate_endpoint_is_reviewable() {
for end in [IncidentEnd::Source, IncidentEnd::Target] {
for reverse in [false, true] {
let mut f = fixture(false, end);
let source = usize::from(reverse);
let SemanticRecord::Relation(detached) = candidate(&f, source) else {
unreachable!()
};
f.p.relations.insert(f.attached[source], detached);
let take =
plan_semantic_transition(&f.p, request(&f, reverse, SemanticDecision::TakeSource))
.unwrap();
assert!(
take.conflicts
.iter()
.any(|e| e.contains("explicit final endpoint")),
"{:?}",
take.conflicts
);
let mut request = request(&f, reverse, SemanticDecision::Candidate);
request.candidates.push(candidate(&f, 1 - source));
let _ = prepared(&f.p, request);
}
}
}
#[test]
fn attachment_candidate_and_direct_record_keep_conflict_by_actual_slot_in_either_order() {
for child in [true, false] {
for end in [IncidentEnd::Source, IncidentEnd::Target] {
if child && end == IncidentEnd::Target {
continue;
}
for reverse in [false, true] {
let mut f = fixture(child, end);
let linked = trace(
&mut f.p,
f.attached[0],
f.attached[1],
"Independent record rationale",
);
let source = usize::from(reverse);
let target = 1 - source;
let mut request = request(&f, reverse, SemanticDecision::Candidate);
request.candidates.push(candidate(&f, target));
request.transition.sources.push(f.attached[source]);
request.transition.mappings.push(TransitionMapping {
source: f.attached[source],
target: Some(f.attached[target]),
target_kind: None,
});
request.traces.insert(f.attached[source], linked);
let facet = if child {
FacetSelector::Header(HeaderFacet::Parent)
} else {
FacetSelector::Relation(if end == IncidentEnd::Source {
RelationFacet::Source
} else {
RelationFacet::Target
})
};
request.reviews.push(SemanticReviewRequest {
source: f.attached[source],
resolutions: vec![FacetResolution {
facet,
decision: SemanticDecision::KeepTarget,
}],
reset: vec![],
});
for permute in [false, true] {
let mut request = request.clone();
if permute {
request.reviews.reverse();
request.transition.sources.reverse();
request.transition.mappings.reverse();
}
let plan = plan_semantic_transition(&f.p, request).unwrap();
assert!(
plan.conflicts.iter().any(|e| e.contains("Contradictory")),
"{child}/{end:?}/{reverse}: {:?}",
plan.conflicts
);
}
}
}
}
}
#[test]
fn convergent_owner_attachment_candidates_agree_and_keep_cannot_override_them() {
for child in [true, false] {
for end in [IncidentEnd::Source, IncidentEnd::Target] {
if child && end == IncidentEnd::Target {
continue;
}
let mut f = fixture(child, end);
let second_owner =
f.p.add_element(
f.p.elements[&f.owners[0]].kind(),
Layer::Oa,
"Second operational concern",
None,
)
.unwrap();
if let Some(SharedBody::DataType { definition, .. }) =
f.p.elements.get_mut(&second_owner).unwrap().shared_mut()
{
*definition = DataTypeDefinition::Record { supertype: None };
}
let second_record = Id::new_v4();
if child {
let mut value = f.p.elements[&f.attached[0]].clone();
value.id = second_record;
value.alias = format!("second_{}", second_record.simple());
value.parent = Some(second_owner);
f.p.elements.insert(second_record, value);
} else {
let mut value = f.p.relations[&f.attached[0]].clone();
value.id = second_record;
value.alias = format!("second_{}", second_record.simple());
let (a, b) = value.body.endpoints();
value.body.set_endpoints(
if end == IncidentEnd::Source {
second_owner
} else {
a
},
if end == IncidentEnd::Target {
second_owner
} else {
b
},
);
f.p.relations.insert(second_record, value);
}
let second_trace = trace(
&mut f.p,
second_owner,
f.owners[1],
"Second owner correspondence",
);
validate_candidate(&f.p).unwrap();
let mut request = request(&f, false, SemanticDecision::Candidate);
request.candidates.push(candidate(&f, 1));
request.transition.sources.push(second_owner);
request.transition.mappings.push(TransitionMapping {
source: second_owner,
target: Some(f.owners[1]),
target_kind: None,
});
request.references.insert(second_owner, f.owners[1]);
request.references.insert(second_record, f.attached[1]);
request.traces.insert(second_owner, second_trace);
let keys = KeyPair {
oa: Some(second_record),
sa: Some(f.attached[1]),
};
let facet = if child {
FacetSelector::Child(keys)
} else {
FacetSelector::Incident { keys, end }
};
request.reviews.push(SemanticReviewRequest {
source: second_owner,
resolutions: vec![FacetResolution {
facet,
decision: SemanticDecision::Candidate,
}],
reset: vec![],
});
let (agreed, _) = prepared(&f.p, request.clone());
assert_eq!(
semantic_record(&agreed, f.attached[1]),
Some(candidate(&f, 1))
);
for keep in [false, true] {
for permute in [false, true] {
let mut review = request.clone();
if keep {
review.reviews[1].resolutions[0].decision = SemanticDecision::KeepTarget;
}
if permute {
review.reviews.reverse();
review.transition.sources.reverse();
review.transition.mappings.reverse();
}
let plan = plan_semantic_transition(&f.p, review).unwrap();
if keep {
assert!(
plan.conflicts.iter().any(|e| e.contains("Contradictory")),
"{:?}",
plan.conflicts
);
} else {
assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts);
assert_eq!(
prepare_semantic_transition(&f.p, &plan).unwrap().project,
agreed
);
}
}
}
}
}
}
@@ -0,0 +1,410 @@
use oa_core::{source_project::*, transitions::*, view_options::*, *};
use std::collections::BTreeMap;
#[test]
fn source6_traceability_default_keeps_both_layers_and_indexed_projection_matches() {
let mut p = Project::empty();
let oa = p
.add_element(Kind::Activity, Layer::Oa, "OA send", None)
.unwrap();
let ob = p
.add_element(Kind::Activity, Layer::Oa, "OA receive", None)
.unwrap();
let sa = p
.add_element(Kind::Function, Layer::Sa, "SA send", None)
.unwrap();
let sb = p
.add_element(Kind::Function, Layer::Sa, "SA receive", None)
.unwrap();
let interaction = p.relate(RelationKind::Interaction, "OA report", oa, ob);
let fe = Id::new_v4();
let request = FunctionalExchangeRequest {
id: fe,
alias: "sa_report".into(),
name: "SA report".into(),
description: String::new(),
source: sa,
target: sb,
source_port: PortChoice::Auto,
target_port: PortChoice::Auto,
items: vec![],
};
p = execute_batch(&p, &plan_functional_exchange(&p, &request).unwrap()).unwrap();
let d = p.ensure_diagram(Layer::Oa);
p.diagrams.get_mut(&d).unwrap().projection = ProjectionKind::Traceability;
for (i, id) in [oa, ob, sa, sb].into_iter().enumerate() {
p.place(
d,
id,
Position {
x: 300. * i as f32,
y: 0.,
},
);
}
let index = ViewIndex::new(&p);
let view = projection::project_view_with_index(&p, d, &index);
assert!(view.edges.iter().any(|e| e.id == interaction));
assert!(view.edges.iter().any(|e| e.id == fe));
assert_eq!(view, projection::project_view(&p, d));
p.diagrams
.get_mut(&d)
.unwrap()
.view_options
.hidden_edges
.push(fe);
validate_candidate(&p).unwrap();
assert!(
!projection::project_view_with_index(&p, d, &index)
.edges
.iter()
.any(|e| e.id == fe)
);
assert!(
projection::project_view_with_index(&p, d, &index)
.edges
.iter()
.any(|e| e.id == interaction)
);
p.diagrams.get_mut(&d).unwrap().projection = ProjectionKind::Overview;
assert!(validate_candidate(&p).is_err());
p.diagrams.get_mut(&d).unwrap().projection = ProjectionKind::Traceability;
p.diagrams.get_mut(&d).unwrap().view_options.relation_kinds = RelationKinds::Only(vec![]);
assert!(
projection::project_view_with_index(&p, d, &index)
.edges
.is_empty()
);
}
#[test]
fn source6_edge_availability_uses_actual_shared_projection_fold_and_placement_rules() {
let mut p = Project::empty();
let parent = p
.add_element(Kind::Entity, Layer::Oa, "Team", None)
.unwrap();
let a = p
.add_element(Kind::Entity, Layer::Oa, "A", Some(parent))
.unwrap();
let b = p
.add_element(Kind::Entity, Layer::Oa, "B", Some(parent))
.unwrap();
let mean = p.relate(
RelationKind::CommunicationMean,
"Internal communication",
a,
b,
);
let d = p.ensure_diagram(Layer::Oa);
let outer = p.place(d, parent, Position { x: 0., y: 0. });
p.place(d, a, Position { x: 20., y: 100. });
let nb = p.place(d, b, Position { x: 350., y: 100. });
assert_eq!(
projection::edge_availability(&p, &p.diagrams[&d])[&mean],
EdgeAvailability::Visible
);
p.diagrams
.get_mut(&d)
.unwrap()
.view_options
.hidden_edges
.push(mean);
assert_eq!(
projection::edge_availability(&p, &p.diagrams[&d])[&mean],
EdgeAvailability::Visible
);
p.diagrams
.get_mut(&d)
.unwrap()
.nodes
.get_mut(&outer)
.unwrap()
.collapsed = true;
assert_eq!(
projection::edge_availability(&p, &p.diagrams[&d])[&mean],
EdgeAvailability::FoldedInternal
);
p.diagrams
.get_mut(&d)
.unwrap()
.nodes
.get_mut(&outer)
.unwrap()
.collapsed = false;
p.diagrams.get_mut(&d).unwrap().nodes.remove(&nb);
assert_eq!(
projection::edge_availability(&p, &p.diagrams[&d])[&mean],
EdgeAvailability::EndpointNotShown {
source: false,
target: true
}
);
p.diagrams.get_mut(&d).unwrap().projection = ProjectionKind::FunctionalDataflow;
assert_eq!(
projection::edge_availability(&p, &p.diagrams[&d])[&mean],
EdgeAvailability::OutsideProjection
);
}
fn setup() -> (Project, Id, Id, Id, Id) {
let mut p = Project::empty();
let a = p.add_element(Kind::Entity, Layer::Oa, "A", None).unwrap();
let b = p.add_element(Kind::Actor, Layer::Oa, "B", None).unwrap();
let exchange = p.relate(RelationKind::CommunicationMean, "Communication", a, b);
let category = p
.add_element(
Kind::ExchangeCategory,
Layer::Oa,
"Participant exchanges",
None,
)
.unwrap();
*p.elements.get_mut(&category).unwrap().shared_mut().unwrap() = SharedBody::ExchangeCategory {
family: ExchangeFamily::Participant,
exchanges: vec![exchange],
};
let diagram = p.ensure_diagram(Layer::Oa);
p.place(diagram, a, Position { x: 0., y: 0. });
p.place(diagram, b, Position { x: 400., y: 0. });
p.diagrams.get_mut(&diagram).unwrap().view_options = ViewOptions {
participant_categories: CategorySelection::Any(vec![category]),
hidden_edges: vec![exchange],
exchange_labels: ExchangeLabels::NameAndItems,
..Default::default()
};
(p, exchange, category, diagram, a)
}
#[test]
fn source6_views_round_trip_strict_fields_and_reference_aliases() {
let (p, _, category, _, _) = setup();
validate_candidate(&p).unwrap();
let source = language::format(&p);
assert!(source.contains("exchange_category"));
assert!(source.contains(&format!("@{}", p.elements[&category].alias)));
assert_eq!(language::parse(&source).unwrap(), p);
assert_eq!(
persistence::decode(&serde_json::to_vec(&p).unwrap()).unwrap(),
p
);
let bundle = SourceBundle {
entry: "model.arcadia".into(),
files: BTreeMap::from([("model.arcadia".into(), source)]),
};
assert_eq!(
compile_bundle(&bundle, ProjectLimits::default())
.unwrap()
.decoded
.project,
p
);
}
#[test]
fn source6_view_defaults_cannot_be_smuggled_under_old_json_headers() {
let mut p = Project::empty();
p.ensure_diagram(Layer::Oa);
let current = serde_json::to_value(&p).unwrap();
for version in 3..=5 {
let mut old = current.clone();
old["schema_version"] = version.into();
for diagram in old["diagrams"].as_object_mut().unwrap().values_mut() {
diagram["view_options"] = serde_json::json!({});
}
let error = persistence::decode(&serde_json::to_vec(&old).unwrap())
.unwrap_err()
.to_string();
assert!(
error.contains("requires schema/source 6"),
"{version}: {error}"
);
for diagram in old["diagrams"].as_object_mut().unwrap().values_mut() {
diagram.as_object_mut().unwrap().remove("view_options");
}
assert_eq!(
persistence::decode(&serde_json::to_vec(&old).unwrap()).unwrap(),
p
);
}
}
#[test]
fn source6_category_and_saved_selection_reidentify_delete_preserve_empty_filters() {
let (p, exchange, category, diagram, a) = setup();
let replacement = Id::new_v4();
let q = execute_batch(&p, &[Command::Reidentify(exchange, replacement)]).unwrap();
assert!(
matches!(q.elements[&category].shared(),Some(SharedBody::ExchangeCategory{exchanges,..}) if exchanges==&[replacement])
);
assert_eq!(
q.diagrams[&diagram].view_options.hidden_edges,
vec![replacement]
);
let r = execute_batch(&q, &[Command::Delete(replacement)]).unwrap();
assert!(r.contains(category));
assert!(r.contains(a));
assert!(r.diagrams[&diagram].view_options.hidden_edges.is_empty());
assert!(
matches!(r.elements[&category].shared(),Some(SharedBody::ExchangeCategory{exchanges,..}) if exchanges.is_empty())
);
let s = execute_batch(&r, &[Command::Delete(category)]).unwrap();
assert_eq!(
s.diagrams[&diagram].view_options.participant_categories,
CategorySelection::Any(vec![])
);
}
#[test]
fn source6_category_transition_requires_mapped_members_and_reviews_exact_set() {
let (mut p, exchange, category, _, _) = setup();
let sa = p
.add_element(Kind::System, Layer::Sa, "System", None)
.unwrap();
let sb = p
.add_element(Kind::SystemActor, Layer::Sa, "External", None)
.unwrap();
let port = |p: &mut Project, owner, name: &str| {
let id = Id::new_v4();
p.elements.insert(
id,
Element {
id,
alias: p.fresh_alias(name, "port", id),
name: name.into(),
description: String::new(),
parent: Some(owner),
body: ElementBody::shared_in(
Layer::Sa,
SharedBody::Port {
contract: PortContract::Component {
kind: ComponentPortKind::Standard,
orientation: ComponentPortOrientation::Unset,
},
legacy: None,
},
),
},
);
id
};
let pa = port(&mut p, sa, "A endpoint");
let pb = port(&mut p, sb, "B endpoint");
let boundary = p.relate(RelationKind::BoundaryExchange, "Boundary", pa, pb);
let mut request = TransitionRequest::new(TransitionDirection::OaToSa, vec![category]);
assert!(!plan_transition(&p, &request).unwrap().conflicts.is_empty());
request.mappings.push(TransitionMapping {
source: exchange,
target: Some(boundary),
target_kind: None,
});
let plan = plan_transition(&p, &request).unwrap();
assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts);
let q = apply_transition(&p, &plan).unwrap();
let trace = q.traces.values().find(|t| t.oa == category).unwrap();
let target = trace.sa;
assert!(
matches!(q.elements[&target].shared(),Some(SharedBody::ExchangeCategory{family:ExchangeFamily::Participant,exchanges}) if exchanges==&[boundary])
);
let facets = semantic_facets(&q, trace, &BTreeMap::from([(exchange, boundary)])).unwrap();
assert!(
facets
.iter()
.any(|f| f.selector == FacetSelector::Category(CategoryFacet::Family))
);
assert!(facets.iter().any(|f| matches!(
f.selector,
FacetSelector::Member {
collection: ReferenceCollection::CategoryExchanges,
..
}
)));
}
#[test]
fn source6_new_historical_facts_are_rejected_under_source5_even_without_live_categories() {
let mut p = Project::empty();
let oa = p.add_element(Kind::Entity, Layer::Oa, "OA", None).unwrap();
let sa = p
.add_element(Kind::SystemActor, Layer::Sa, "SA", None)
.unwrap();
for fact in [
FacetReview::Category(CategoryReview::Family(Reviewed {
oa: Observed::NotApplicable,
sa: Observed::NotApplicable,
})),
FacetReview::Member {
collection: ReferenceCollection::CategoryExchanges,
keys: KeyPair {
oa: Some(Id::new_v4()),
sa: None,
},
review: Reviewed {
oa: Observed::NotApplicable,
sa: Observed::NotApplicable,
},
},
] {
let id = Id::new_v4();
p.traces.clear();
p.traces.insert(
id,
Trace {
id,
alias: "review".into(),
name: "History".into(),
description: String::new(),
oa,
sa,
kind: TraceKind::Manual,
baseline: Some(TraceBaseline {
oa: p.properties(oa).unwrap(),
sa: p.properties(sa).unwrap(),
semantic: Some(SemanticBaseline { facets: vec![fact] }),
}),
},
);
let mut json = serde_json::to_value(&p).unwrap();
json["schema_version"] = 5.into();
let error = persistence::decode(&serde_json::to_vec(&json).unwrap())
.unwrap_err()
.to_string();
assert!(
error.contains("category semantic facts require schema/source 6"),
"{error}"
);
let old =
language::format(&p).replacen(&format!("arcadia {SCHEMA_VERSION};"), "arcadia 5;", 1);
assert!(language::parse(&old).unwrap_err().iter().any(|d| {
d.message
.contains("category semantic facts require schema/source 6")
}));
}
}
#[test]
fn source6_projection_filters_keep_placements_and_label_modes_are_presentation_only() {
let (mut p, exchange, _, diagram, _) = setup();
let hidden = projection::project_view(&p, diagram);
assert!(hidden.edges.is_empty());
p.diagrams
.get_mut(&diagram)
.unwrap()
.view_options
.hidden_edges
.clear();
let shown = projection::project_view(&p, diagram);
assert_eq!(shown.edges.len(), 1);
assert_eq!(shown.edges[0].id, exchange);
assert_eq!(shown.nodes, hidden.nodes);
assert_eq!(shown.edges[0].source_port, None);
assert_eq!(shown.edges[0].target_port, None);
assert!(!shown.edges[0].oriented);
let route = routing::route_diagram(&p, &p.diagrams[&diagram], &shown);
assert!(route.edges[&exchange].label.text.contains("No items"));
p.diagrams
.get_mut(&diagram)
.unwrap()
.view_options
.relation_kinds = RelationKinds::Only(vec![]);
assert!(projection::project_view(&p, diagram).edges.is_empty());
}
File diff suppressed because it is too large Load Diff
+13 -1
View File
@@ -157,7 +157,19 @@ fn cross_file_typed_ports_global_forward_references_and_stable_ids() {
assert_eq!(again.decoded.project, result.decoded.project);
assert_eq!(again.bundle, result.bundle);
assert_eq!(again.index, result.index);
assert!(result.decoded.migration.is_none());
let migration = result.decoded.migration.as_ref().unwrap();
assert_eq!(
(migration.from_schema, migration.to_schema),
(5, oa_core::SCHEMA_VERSION)
);
assert!(input.files[&input.entry].contains("arcadia 5;"));
assert!(
result.bundle.files[&result.bundle.entry].contains(&format!("arcadia {SCHEMA_VERSION};"))
);
assert!(
again.decoded.migration.is_none(),
"stabilized current source must not migrate twice"
);
let project = &result.decoded.project;
assert_eq!(
project.relations[&id(project, "message")].source(),
+1
View File
@@ -18,6 +18,7 @@ fn view(p: &mut Project, machine: Id, alias: &str) -> Id {
description: String::new(),
layer: Layer::Oa,
projection: ProjectionKind::StateMachine { machine },
view_options: Default::default(),
nodes: BTreeMap::new(),
routes: vec![],
},
+9
View File
@@ -13,5 +13,14 @@ path = "src/main.rs"
oa-core = { path = "../oa-core" }
iced = { version = "0.14", default-features = false, features = ["wgpu", "canvas", "tokio", "x11", "wayland", "advanced"] }
rfd = "0.15"
serde.workspace = true
serde_json.workspace = true
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
tokio = { version = "1", features = ["rt", "time"] }
tempfile = "3"
[dev-dependencies]
iced_runtime = "0.14"
oa-server = { path = "../oa-server" }
axum = { version = "0.8", default-features = false, features = ["http1", "json", "tokio"] }
tokio = { version = "1", features = ["macros", "rt-multi-thread", "net", "sync"] }
+1
View File
@@ -0,0 +1 @@
include!("../../build/windows_manifest.rs");
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,718 @@
//! Named, view-local creation. A draft never enters the accepted model.
use super::*;
use iced::widget::{button, column, pick_list, row, scrollable, text, text_input};
use serde::{Deserialize, Serialize};
mod palette;
#[cfg(test)]
mod tests;
pub use palette::view as palette;
#[derive(Debug, Clone)]
pub enum Event {
Start(Kind, Option<Position>),
Allocate(Kind, Id, Option<Position>),
Rename(crate::route_edit::Context, Id, Position),
TogglePalette,
Existing(Option<Position>),
Name(String),
Submit,
Click(crate::route_edit::Context, Position),
Pick(Id),
Locate(Id),
Search(String),
Filter(Filter),
ShowPlaced(bool),
Page(usize),
CapturedEscape,
Cancel,
}
pub fn subscription(app: &App) -> Subscription<Message> {
if app.architecture.as_ref().is_some_and(State::inline) {
iced::event::listen_with(captured_escape)
} else {
Subscription::none()
}
}
fn captured_escape(
event: iced::Event,
status: iced::event::Status,
_: iced::window::Id,
) -> Option<Message> {
// A focused Iced text input captures Escape while dropping its focus. Only
// inline architecture naming opts into also cancelling that same keypress.
(status == iced::event::Status::Captured
&& matches!(
event,
iced::Event::Keyboard(iced::keyboard::Event::KeyPressed {
key: iced::keyboard::Key::Named(iced::keyboard::key::Named::Escape),
..
})
))
.then_some(Message::Architecture(Event::CapturedEscape))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub enum Filter {
Context,
AllCompatible,
Kind(Kind),
}
impl std::fmt::Display for Filter {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Context => f.write_str("Suggested for this view"),
Self::AllCompatible => f.write_str("All compatible types"),
Self::Kind(k) => f.write_str(k.label()),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub enum Intent {
Create {
kind: Kind,
name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
performer: Option<Id>,
},
Rename {
element: Id,
name: String,
},
Existing {
selected: Option<Id>,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Saved {
pub version: u32,
pub base: String,
pub diagram: Id,
pub intent: Intent,
pub point: Option<Position>,
pub placing: bool,
pub query: String,
pub filter: Filter,
pub show_placed: bool,
pub page: usize,
}
#[derive(Debug, Clone)]
pub struct State {
pub saved: Saved,
document: Id,
revision: u64,
pub error: Option<String>,
}
impl State {
pub fn restore(app: &App, saved: Saved) -> Result<Self, String> {
validate_saved(app.project(), &saved)?;
Ok(Self {
saved,
document: app.canvas_document,
revision: app.document.revision(),
error: None,
})
}
pub fn modal(&self) -> bool {
!self.saved.placing && matches!(self.saved.intent, Intent::Existing { .. })
}
pub fn inline(&self) -> bool {
!self.saved.placing
&& self.saved.point.is_some()
&& !matches!(self.saved.intent, Intent::Existing { .. })
}
pub fn unnamed_tool(&self) -> bool {
self.saved.placing
&& self.saved.point.is_none()
&& matches!(&self.saved.intent, Intent::Create { name, .. } if name.is_empty())
}
}
pub fn validate_saved(p: &Project, s: &Saved) -> Result<(), String> {
if s.version != 1 || s.base != oa_core::transitions::project_fingerprint(p) {
return Err("The architecture draft does not match its accepted model.".into());
}
let d = p
.diagrams
.get(&s.diagram)
.ok_or("The creation draft's diagram is missing.")?;
if matches!(d.projection, ProjectionKind::StateMachine { .. })
|| s.query.len() > 16384
|| s.page > 1_000_000
|| s.point
.is_some_and(|p| !p.x.is_finite() || !p.y.is_finite())
{
return Err("Invalid architecture creation draft context.".into());
}
match &s.intent {
Intent::Create {
kind,
name,
performer,
} if name.len() > 16384
|| !kind.supports(d.layer)
|| !d.projection.includes(*kind)
|| *kind == Kind::Port
|| performer.is_some_and(|id| {
!kind.is_work()
|| !p
.elements
.get(&id)
.is_some_and(|e| e.kind().is_performer() && e.layer() == d.layer)
}) =>
{
Err("Invalid architecture creation draft type or name.".into())
}
Intent::Existing { selected: Some(id) } if !compatible(p, d, *id) => {
Err("The creation draft's existing element is incompatible.".into())
}
Intent::Rename { element, name }
if name.len() > 16384
|| !p.elements.contains_key(element)
|| !d.nodes.values().any(|n| n.element == *element) =>
{
Err("The rename draft's element is not shown in this view.".into())
}
_ => Ok(()),
}
}
fn context_kinds(p: &Project, d: &Diagram) -> Vec<Kind> {
let mut values = match (d.layer, d.projection) {
(_, ProjectionKind::StateMachine { .. }) => vec![],
(Layer::Oa, ProjectionKind::FunctionalDataflow) => vec![Kind::Activity],
(Layer::Sa, ProjectionKind::FunctionalDataflow) => vec![Kind::Function],
(Layer::Oa, ProjectionKind::Capabilities) => vec![
Kind::OperationalMission,
Kind::Capability,
Kind::Entity,
Kind::Actor,
],
(Layer::Sa, ProjectionKind::Capabilities) => vec![
Kind::Mission,
Kind::SystemCapability,
Kind::System,
Kind::SystemActor,
],
(Layer::Oa, _) => vec![Kind::Entity, Kind::Actor, Kind::Activity],
(Layer::Sa, _) => vec![Kind::System, Kind::SystemActor, Kind::Function],
};
values.retain(|k| *k != Kind::System || !p.elements.values().any(|e| e.kind() == Kind::System));
values
}
pub fn kinds(app: &App) -> Vec<Kind> {
app.active_diagram
.and_then(|id| app.project().diagrams.get(&id))
.map(|d| context_kinds(app.project(), d))
.unwrap_or_default()
}
fn compatible(p: &Project, d: &Diagram, id: Id) -> bool {
p.elements.get(&id).is_some_and(|e| {
e.kind() != Kind::Port
&& d.projection.includes(e.kind())
&& (e.layer() == d.layer || d.projection == ProjectionKind::Traceability)
})
}
fn suggested(p: &Project, d: &Diagram, kind: Kind) -> bool {
// Existing System remains a useful choice even when the singleton exists.
context_kinds(p, d).contains(&kind)
|| (kind == Kind::System
&& d.layer == Layer::Sa
&& d.projection != ProjectionKind::FunctionalDataflow)
}
pub fn candidates(app: &App) -> Vec<(Id, String, bool)> {
let Some(s) = &app.architecture else {
return vec![];
};
let p = app.project();
let Some(d) = p.diagrams.get(&s.saved.diagram) else {
return vec![];
};
let query = s.saved.query.to_lowercase();
let mut entries: Vec<_> = p
.elements
.values()
.filter(|e| compatible(p, d, e.id))
.filter(|e| match s.saved.filter {
Filter::Context => suggested(p, d, e.kind()),
Filter::AllCompatible => true,
Filter::Kind(k) => e.kind() == k,
})
.filter_map(|e| {
let shown = d.nodes.values().any(|n| n.element == e.id);
let label = format!(
"{} · @{} · {} · {}",
e.name,
e.alias,
e.kind().label(),
e.parent.map(|id| p.named(id)).unwrap_or("At root")
);
((!shown || s.saved.show_placed) && label.to_lowercase().contains(&query))
.then_some((e.id, label, shown))
})
.collect();
entries.sort_by(|a, b| a.1.cmp(&b.1).then(a.0.cmp(&b.0)));
entries
}
impl App {
pub(super) fn architecture_event(&mut self, event: Event) -> Task<Message> {
match event {
Event::Start(_, point) | Event::Allocate(_, _, point) | Event::Existing(point) => {
if self.architecture.is_some() {
if self.architecture.as_ref().is_some_and(State::unnamed_tool) {
self.architecture = None;
} else {
self.status =
"Finish or cancel the current name before choosing another tool."
.into();
return Task::none();
}
}
if self.page != Page::Diagram
|| self.source_dirty()
|| self.draft_dirty()
|| self.behavior.has_pending()
|| self.workflow.is_some()
{
self.status =
"Apply or cancel pending edits before creating diagram content.".into();
return Task::none();
}
let Some(diagram) = self.active_diagram else {
return Task::none();
};
let intent = match event {
Event::Start(kind, _) if kinds(self).contains(&kind) => Intent::Create {
kind,
name: String::new(),
performer: None,
},
Event::Allocate(kind, performer, _)
if kinds(self).contains(&kind) && kind.is_work() =>
{
Intent::Create {
kind,
name: String::new(),
performer: Some(performer),
}
}
Event::Existing(_) => Intent::Existing { selected: None },
_ => return Task::none(),
};
let placing = point.is_none() && matches!(intent, Intent::Create { .. });
let saved = Saved {
version: 1,
base: oa_core::transitions::project_fingerprint(self.project()),
diagram,
intent,
point,
placing,
query: String::new(),
filter: Filter::Context,
show_placed: false,
page: 0,
};
if let Ok(state) = State::restore(self, saved) {
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
self.architecture = Some(state);
self.connection = None;
self.fit_to_view = false;
self.status =
"Click in the diagram to position the new element · Esc cancels".into();
}
return iced::widget::operation::focus("architecture-name");
}
Event::TogglePalette => {
self.architecture_palette_expanded = !self.architecture_palette_expanded;
self.fit_to_view = false;
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
}
Event::Rename(context, element, point) => {
if self.architecture.is_some()
|| self.route_context() != Some(context)
|| self.page != Page::Diagram
|| self.draft_dirty()
|| self.source_dirty()
|| self.behavior.has_pending()
{
return Task::none();
}
let Some(e) = self.project().elements.get(&element) else {
return Task::none();
};
let saved = Saved {
version: 1,
base: oa_core::transitions::project_fingerprint(self.project()),
diagram: context.diagram,
intent: Intent::Rename {
element,
name: e.name.clone(),
},
point: Some(point),
placing: false,
query: String::new(),
filter: Filter::Context,
show_placed: false,
page: 0,
};
if let Ok(state) = State::restore(self, saved) {
self.architecture = Some(state);
self.connection = None;
self.fit_to_view = false;
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
}
return iced::widget::operation::focus("architecture-name");
}
Event::CapturedEscape => {
if self.architecture.as_ref().is_some_and(State::inline) {
return self.architecture_event(Event::Cancel);
}
}
Event::Cancel => {
self.architecture = None;
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
self.status = "Cancelled · Select / move".into();
}
Event::Name(value) => {
if value.len() <= 16384
&& let Some(s) = &mut self.architecture
&& let Intent::Create { name, .. } | Intent::Rename { name, .. } =
&mut s.saved.intent
{
*name = value;
s.error = None;
}
}
Event::Search(query) => {
if query.len() <= 16384
&& let Some(s) = &mut self.architecture
{
s.saved.query = query;
s.saved.page = 0;
}
}
Event::Filter(filter) => {
if let Some(s) = &mut self.architecture {
s.saved.filter = filter;
s.saved.page = 0;
}
}
Event::ShowPlaced(value) => {
if let Some(s) = &mut self.architecture {
s.saved.show_placed = value;
s.saved.page = 0;
}
}
Event::Page(page) => {
if let Some(s) = &mut self.architecture {
s.saved.page = page.min(1_000_000);
}
}
Event::Pick(id) => {
if !candidates(self)
.iter()
.any(|(candidate, _, shown)| *candidate == id && !shown)
{
return Task::none();
}
if let Some(s) = &mut self.architecture {
s.saved.intent = Intent::Existing { selected: Some(id) };
}
return self.architecture_event(Event::Submit);
}
Event::Locate(id) => {
if candidates(self)
.iter()
.any(|(candidate, _, shown)| *candidate == id && *shown)
&& let Some(s) = self.architecture.take()
{
return self.update(Message::RevealInDiagram(s.saved.diagram, id));
}
}
Event::Submit => {
let Some(s) = &mut self.architecture else {
return Task::none();
};
if matches!(&s.saved.intent, Intent::Create { name, .. } | Intent::Rename { name, .. } if name.trim().is_empty())
{
s.error = Some("Enter an engineering name before placing this element.".into());
return Task::none();
}
if matches!(s.saved.intent, Intent::Existing { selected: None }) {
return Task::none();
}
s.saved.placing = true;
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
if let Some(point) = s.saved.point {
self.commit_architecture(point);
}
}
Event::Click(context, point) => {
if self.route_context() != Some(context)
|| !point.x.is_finite()
|| !point.y.is_finite()
{
return Task::none();
}
if self.architecture.as_ref().is_some_and(|s| s.saved.placing) {
if matches!(
self.architecture.as_ref().unwrap().saved.intent,
Intent::Existing { .. }
) {
self.commit_architecture(point);
} else {
let s = self.architecture.as_mut().unwrap();
s.saved.point = Some(point);
s.saved.placing = false;
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
return iced::widget::operation::focus("architecture-name");
}
}
}
}
Task::none()
}
fn commit_architecture(&mut self, point: Position) {
let Some(s) = self.architecture.as_ref() else {
return;
};
if s.document != self.canvas_document
|| s.revision != self.document.revision()
|| Some(s.saved.diagram) != self.active_diagram
|| self.page != Page::Diagram
|| self.source_dirty()
|| self.draft_dirty()
|| self.behavior.has_pending()
|| !point.x.is_finite()
|| !point.y.is_finite()
{
self.status =
"The diagram changed. The name is retained; cancel and restart this placement."
.into();
if let Some(s) = &mut self.architecture {
s.saved.placing = false;
s.error = Some(self.status.clone());
}
return;
}
let diagram = s.saved.diagram;
if let Intent::Rename { element, name } = &s.saved.intent {
let element = *element;
let name = name.clone();
if name.trim().is_empty() {
return;
}
if self.execute(Command::Rename(element, name)) {
self.architecture = None;
self.selected = element;
self.refresh_draft();
self.status = "Name updated · Undo restores the previous name".into();
} else if let Some(s) = &mut self.architecture {
s.error = Some(self.status.clone());
s.saved.placing = false;
}
return;
}
let mut candidate = self.project().clone();
let mut commands = vec![];
let id = match &s.saved.intent {
Intent::Create {
kind,
name,
performer,
} => {
if name.trim().is_empty() || !kinds(self).contains(kind) {
return;
}
let Some(element) =
candidate.make_element(*kind, candidate.diagrams[&diagram].layer, name, None)
else {
return;
};
let id = element.id;
candidate.elements.insert(id, element.clone());
commands.push(Command::CreateElement(element));
if let Some(performer) = performer {
commands.push(Command::Connect(
RelationKind::Allocation,
"Performs".into(),
*performer,
id,
));
}
id
}
Intent::Existing { selected: Some(id) }
if compatible(&candidate, &candidate.diagrams[&diagram], *id)
&& !candidate.diagrams[&diagram]
.nodes
.values()
.any(|n| n.element == *id) =>
{
*id
}
_ => return,
};
let node = candidate.place(diagram, id, point);
commands.push(Command::CreateNode {
diagram,
node: candidate.diagrams[&diagram].nodes[&node].clone(),
});
if self.execute(Command::Batch(commands)) {
self.architecture = None;
self.selected = id;
self.selected_node = Some(node);
self.selected_bend = None;
self.inspector_tab = crate::navigation::InspectorTab::Properties;
self.refresh_draft();
self.fit_to_view = false;
self.status = "Element placed · Undo restores the whole change. Drag to move; Fit is available when needed.".into();
} else if let Some(s) = &mut self.architecture {
s.error = Some(self.status.clone());
s.saved.placing = false;
}
}
}
pub fn inline_editor(app: &App) -> Option<Element<'_, Message>> {
let s = app.architecture.as_ref().filter(|s| s.inline())?;
let point = s.saved.point?;
let size = app.canvas_size.unwrap_or(iced::Size::new(450., 350.));
let width = (size.width - 16.).clamp(120., 320.);
let x = (point.x * app.zoom + app.pan.x).clamp(8., (size.width - width - 8.).max(8.));
let y = (point.y * app.zoom + app.pan.y).clamp(8., (size.height - 152.).max(8.));
Some(
column![
iced::widget::Space::new().height(y),
row![
iced::widget::Space::new().width(x),
iced::widget::opaque(
iced::widget::container(scrollable(name_editor(app)))
.padding(10)
.width(width)
.max_height((size.height - y - 8.).max(0.))
.style(crate::style::card)
)
]
]
.into(),
)
}
fn name_editor(app: &App) -> Element<'_, Message> {
let s = app.architecture.as_ref().unwrap();
let (title, name, action) = match &s.saved.intent {
Intent::Create {
kind,
name,
performer,
} => (
performer
.map(|id| {
format!(
"{} · performed by {}",
kind.label(),
crate::views::short(app.project().named(id), 32)
)
})
.unwrap_or_else(|| format!("New {}", kind.label())),
name,
"Create",
),
Intent::Rename { name, .. } => ("Rename".into(), name, "Apply"),
Intent::Existing { .. } => unreachable!(),
};
let mut content = column![
text(title).size(12),
text_input("Engineering name", name)
.id("architecture-name")
.on_input(|v| Message::Architecture(Event::Name(v)))
.on_submit(Message::Architecture(Event::Submit))
.padding(7)
.size(13),
row![
button(text(action).size(12))
.padding([5, 9])
.style(crate::style::nav)
.on_press(Message::Architecture(Event::Submit)),
button(text("Cancel").size(12))
.padding([5, 9])
.style(crate::style::nav)
.on_press(Message::Architecture(Event::Cancel))
]
.spacing(6)
]
.spacing(7);
if let Some(error) = &s.error {
content = content.push(text(crate::views::short(error, 100)).size(11));
}
content.into()
}
pub fn dialog(app: &App) -> Element<'_, Message> {
let s = app.architecture.as_ref().unwrap();
let action = |label: String, event| {
button(text(label).size(12))
.padding(8)
.on_press(Message::Architecture(event))
};
let mut content = column![].spacing(12);
match &s.saved.intent {
Intent::Create { .. } | Intent::Rename { .. } => return name_editor(app),
Intent::Existing { .. } => {
let p = app.project();
let d = &p.diagrams[&s.saved.diagram];
let mut filters = vec![Filter::Context, Filter::AllCompatible];
filters.extend(
Kind::ALL
.into_iter()
.filter(|k| {
*k != Kind::Port
&& d.projection.includes(*k)
&& (k.supports(d.layer) || d.projection == ProjectionKind::Traceability)
})
.map(Filter::Kind),
);
let entries = candidates(app);
let page = s.saved.page.min(entries.len().saturating_sub(1) / 30);
let mut rows = column![].spacing(5);
for (id, label, shown) in entries.iter().skip(page * 30).take(30) {
rows = rows.push(
action(
format!("{}{}", if *shown { "Locate · " } else { "Add · " }, label),
if *shown {
Event::Locate(*id)
} else {
Event::Pick(*id)
},
)
.width(iced::Fill),
);
}
content = content.push(text("Add existing to this diagram").size(22))
.push(text("Add a model element to this view. Its name and properties stay shared across views.").size(13))
.push(text_input("Optional name, alias or owner filter", &s.saved.query).id("architecture-name").on_input(|v| Message::Architecture(Event::Search(v))).padding(9))
.push(pick_list(filters, Some(s.saved.filter), |f| Message::Architecture(Event::Filter(f))))
.push(iced::widget::checkbox(s.saved.show_placed).label("Include already shown (Locate)").on_toggle(|v| Message::Architecture(Event::ShowPlaced(v))))
.push(text(format!("{} compatible matches · page {}", entries.len(), page + 1)).size(12));
if entries.is_empty() {
content = content.push(text("No unplaced matches. Try All compatible types or include already shown elements.").size(13));
}
content = content
.push(scrollable(rows).height((app.window_size.height - 500.).clamp(120., 260.)))
.push(
row![
action("Previous".into(), Event::Page(page.saturating_sub(1))),
action(
"Next".into(),
Event::Page((page + 1).min(entries.len().saturating_sub(1) / 30))
),
action("Cancel".into(), Event::Cancel)
]
.spacing(8),
);
}
}
if let Some(error) = &s.error {
content = content.push(text(error).size(13));
}
content.into()
}
@@ -0,0 +1,268 @@
//! Compact neutral tool palette. Tool state never changes its allocated width.
use super::*;
use iced::{
Point, Rectangle, Size, Theme, mouse,
widget::{
canvas::{self, Frame, Geometry, Path, Stroke},
container, tooltip,
},
};
#[derive(Clone, Copy)]
enum Icon {
Element(Kind),
Arrow,
Existing,
Select,
Collapse(bool),
}
impl canvas::Program<Message> for Icon {
type State = ();
fn draw(
&self,
_: &(),
renderer: &iced::Renderer,
_: &Theme,
bounds: Rectangle,
_: mouse::Cursor,
) -> Vec<Geometry> {
let mut f = Frame::new(renderer, bounds.size());
let color = match self {
Self::Element(k) => crate::style::kind_color(*k),
_ => crate::style::INK,
};
let stroke = Stroke::default().with_color(color).with_width(1.4);
let line = |points: &[(f32, f32)]| {
Path::new(|p| {
p.move_to(Point::new(points[0].0, points[0].1));
for &(x, y) in &points[1..] {
p.line_to(Point::new(x, y));
}
})
};
match self {
Self::Element(Kind::Actor | Kind::SystemActor | Kind::Role) => {
f.stroke(&Path::circle(Point::new(8., 4.), 2.5), stroke);
for points in [
&[(8., 6.5), (8., 11.)][..],
&[(3., 8.), (13., 8.)],
&[(4., 15.), (8., 11.), (12., 15.)],
] {
f.stroke(&line(points), stroke);
}
}
Self::Element(Kind::Capability | Kind::SystemCapability) => {
f.stroke(&Path::circle(Point::new(8., 8.), 6.), stroke);
}
Self::Element(Kind::Mission | Kind::OperationalMission) => {
f.stroke(
&line(&[(3., 15.), (3., 2.), (13., 2.), (10., 6.), (3., 6.)]),
stroke,
);
}
Self::Element(k) => {
f.stroke(
&Path::rounded_rectangle(
Point::new(1., 3.),
Size::new(14., 10.),
if k.is_work() { 3.into() } else { 0.into() },
),
stroke,
);
if !k.is_work() {
f.stroke(
&Path::rectangle(Point::new(4., 6.), Size::new(8., 4.)),
stroke,
);
}
}
Self::Arrow => {
f.stroke(&line(&[(1., 8.), (14., 8.), (10., 4.)]), stroke);
f.stroke(&line(&[(14., 8.), (10., 12.)]), stroke);
}
Self::Existing => {
f.stroke(
&Path::rectangle(Point::new(1., 1.), Size::new(10., 10.)),
stroke,
);
f.stroke(
&Path::rectangle(Point::new(5., 5.), Size::new(10., 10.)),
stroke,
);
}
Self::Select => {
f.stroke(
&line(&[
(3., 1.),
(3., 14.),
(7., 10.),
(11., 15.),
(13., 14.),
(9., 9.),
(15., 8.),
(3., 1.),
]),
stroke,
);
}
Self::Collapse(expanded) => {
let (a, b) = if *expanded { (5., 11.) } else { (11., 5.) };
f.stroke(&line(&[(a, 3.), (b, 8.), (a, 13.)]), stroke);
}
}
vec![f.into_geometry()]
}
}
fn tool<'a>(
app: &App,
icon: Icon,
label: &'a str,
message: Message,
selected: bool,
) -> Element<'a, Message> {
let width = if app.architecture_palette_expanded {
156.
} else {
36.
};
let graphic = iced::widget::canvas(icon).width(16).height(16);
let mut content = row![graphic].spacing(7).align_y(iced::Center);
if app.architecture_palette_expanded {
content = content.push(text(label).size(11).wrapping(text::Wrapping::None));
}
let mut enabled = app.architecture.as_ref().is_none_or(State::unnamed_tool)
|| matches!(
&message,
Message::Architecture(Event::Cancel | Event::TogglePalette)
);
let mut hint = label.to_owned();
if let Message::Connection(crate::connection_edit::Event::Choose(Some(connection))) = &message {
let view = oa_core::projection::project_view(app.project(), app.active_diagram.unwrap());
if !crate::connection_edit::visible(&view).any(|id| connection.can_start(app.project(), id))
{
enabled = false;
hint.push_str(" — add or show a compatible source first");
}
}
let control = button(content)
.padding([5, 7])
.height(28)
.width(width - 4.)
.clip(true)
.style(if selected {
crate::style::selected
} else {
crate::style::nav
})
.on_press_maybe(enabled.then_some(message));
tooltip(
control,
container(text(hint).size(12))
.padding(7)
.style(crate::style::card),
tooltip::Position::Left,
)
.into()
}
pub fn view(app: &App) -> Element<'_, Message> {
let mut content = iced::widget::column![tool(
app,
Icon::Collapse(app.architecture_palette_expanded),
if app.architecture_palette_expanded {
"Collapse tools"
} else {
"Expand tools"
},
Message::Architecture(Event::TogglePalette),
false
)]
.spacing(3);
let group = |name: &'static str| -> Element<'_, Message> {
if app.architecture_palette_expanded {
container(text(name).size(10).color(crate::style::MUTED))
.padding([5, 4])
.into()
} else {
iced::widget::rule::horizontal(1).into()
}
};
content = content.push(tool(
app,
Icon::Select,
if app.architecture.is_some() {
"Cancel / select"
} else {
"Select / move"
},
if app.architecture.is_some() {
Message::Architecture(Event::Cancel)
} else {
Message::Connection(crate::connection_edit::Event::Choose(None))
},
app.architecture.is_none() && app.connection.is_none(),
));
for (heading, work) in [
("Participants / capabilities", false),
("Activities / functions", true),
] {
let choices: Vec<_> = kinds(app)
.into_iter()
.filter(|k| k.is_work() == work)
.collect();
if !choices.is_empty() {
content = content.push(group(heading));
}
for kind in choices {
let active = app
.architecture
.as_ref()
.is_some_and(|s| matches!(s.saved.intent,Intent::Create{kind:k,..} if kind==k));
content = content.push(tool(
app,
Icon::Element(kind),
kind.label(),
Message::Architecture(Event::Start(kind, None)),
active,
));
}
}
content = content.push(group("Connections"));
for connection in crate::connection_tool::Tool::for_layer(app.layer) {
let d = &app.project().diagrams[&app.active_diagram.unwrap()];
let kind = match connection {
crate::connection_tool::Tool::Interaction => Kind::Activity,
crate::connection_tool::Tool::FunctionalExchange => Kind::Function,
crate::connection_tool::Tool::CommunicationMean => Kind::Entity,
crate::connection_tool::Tool::BoundaryExchange => Kind::System,
};
if !d.projection.includes(kind) {
continue;
}
content = content.push(tool(
app,
Icon::Arrow,
connection.label(),
Message::Connection(crate::connection_edit::Event::Choose(Some(*connection))),
app.connection
.as_ref()
.is_some_and(|s| s.tool == *connection),
));
}
content = content.push(group("Common")).push(tool(
app,
Icon::Existing,
"Add existing…",
Message::Architecture(Event::Existing(None)),
false,
));
container(scrollable(content))
.width(if app.architecture_palette_expanded {
156
} else {
36
})
.height(iced::Fill)
.padding(2)
.style(crate::style::panel)
.into()
}
@@ -0,0 +1,378 @@
use super::*;
fn setup(layer: Layer) -> App {
let mut app = App::with_project(Project::empty());
app.layer = layer;
app.active_diagram = None;
let _ = app.update(Message::AddDiagram);
app.zoom = 0.75;
app.pan = iced::Vector::new(37., -19.);
app
}
fn send(app: &mut App, event: Event) {
let _ = app.update(Message::Architecture(event));
}
fn create(app: &mut App, kind: Kind, name: &str) -> Id {
send(app, Event::Start(kind, Some(Position { x: 120., y: 160. })));
send(app, Event::Name(name.into()));
send(app, Event::Submit);
assert!(app.architecture.is_none(), "{}", app.status);
app.selected
}
#[test]
fn architecture_allocated_work_is_root_owned_and_one_exact_history_change() {
for (layer, participant, work) in [
(Layer::Oa, Kind::Entity, Kind::Activity),
(Layer::Sa, Kind::System, Kind::Function),
] {
let mut app = setup(layer);
let performer = create(&mut app, participant, "Control team");
let before = app.document.bundle().clone();
let camera = (app.zoom, app.pan);
let point = Position { x: 151., y: 221. };
send(&mut app, Event::Allocate(work, performer, Some(point)));
send(&mut app, Event::Name(" Report 雨 ".into()));
assert_eq!(app.document.bundle(), &before);
send(&mut app, Event::Submit);
assert!(app.architecture.is_none(), "{}", app.status);
let created = app.selected;
let element = &app.project().elements[&created];
assert_eq!(element.kind(), work);
assert_eq!(element.name, " Report 雨 ");
assert_eq!(element.parent, None);
assert!(app.project().relations.values().any(|r| {
r.kind() == RelationKind::Allocation && r.source() == performer && r.target() == created
}));
let d = &app.project().diagrams[&app.active_diagram.unwrap()];
assert_eq!(d.nodes[&app.selected_node.unwrap()].position, point);
assert_eq!((app.zoom, app.pan), camera);
let accepted = app.document.bundle().clone();
let _ = app.update(Message::Undo);
assert_eq!(app.document.bundle(), &before);
let _ = app.update(Message::Redo);
assert_eq!(app.document.bundle(), &accepted);
}
}
#[test]
fn architecture_f2_routes_selected_node_to_inline_rename_and_exact_undo() {
use iced::widget::canvas::{Event as CanvasEvent, Program};
use iced::{keyboard, mouse};
let mut app = setup(Layer::Oa);
let element = create(&mut app, Kind::Entity, "Original team");
let before = app.document.bundle().clone();
let diagrams = app.project().diagrams.clone();
let camera = (app.zoom, app.pan);
let bounds = iced::Rectangle::new(iced::Point::ORIGIN, iced::Size::new(600., 380.));
let canvas = crate::diagram::DiagramCanvas {
project: app.project(),
view_index: None,
diagram: app.active_diagram.unwrap(),
selected: element,
zoom: app.zoom,
pan: app.pan,
context: app.route_context().unwrap(),
selected_bend: None,
editable: true,
placing: false,
connection: None,
};
let mut state = crate::diagram::State::default();
let key = CanvasEvent::Keyboard(keyboard::Event::KeyPressed {
key: keyboard::Key::Named(keyboard::key::Named::F2),
modified_key: keyboard::Key::Named(keyboard::key::Named::F2),
physical_key: keyboard::key::Physical::Code(keyboard::key::Code::F2),
location: keyboard::Location::Standard,
modifiers: keyboard::Modifiers::empty(),
text: None,
repeat: false,
});
assert!(
canvas
.update(&mut state, &key, bounds, mouse::Cursor::Unavailable)
.is_none()
);
let _ = canvas.update(
&mut state,
&CanvasEvent::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
bounds,
mouse::Cursor::Available(iced::Point::new(150., 120.)),
);
let action = canvas
.update(&mut state, &key, bounds, mouse::Cursor::Unavailable)
.unwrap();
let message = action.into_inner().0.unwrap();
let _ = app.update(message);
assert!(app.architecture.as_ref().unwrap().inline());
assert!(matches!(&app.architecture.as_ref().unwrap().saved.intent,
Intent::Rename { element: id, name } if *id == element && name == "Original team"));
send(&mut app, Event::Name(" Renamed team 雨 ".into()));
assert_eq!(app.document.bundle(), &before);
send(&mut app, Event::Submit);
assert!(app.architecture.is_none(), "{}", app.status);
assert_eq!(app.project().elements[&element].name, " Renamed team 雨 ");
assert_eq!(app.project().diagrams, diagrams);
assert_eq!((app.zoom, app.pan), camera);
let accepted = app.document.bundle().clone();
let _ = app.update(Message::Undo);
assert_eq!(app.document.bundle(), &before);
let _ = app.update(Message::Redo);
assert_eq!(app.document.bundle(), &accepted);
}
#[test]
fn architecture_blank_names_and_escape_never_accept_placeholder_or_rename() {
let mut app = setup(Layer::Oa);
let element = create(&mut app, Kind::Actor, "Dispatcher");
let before = app.document.bundle().clone();
let context = app.route_context().unwrap();
for event in [
Event::Start(Kind::Activity, Some(Position { x: 220., y: 330. })),
Event::Rename(context, element, Position { x: 150., y: 200. }),
] {
// Every cancel changes the gesture context; use the current context for F2.
let event = match event {
Event::Rename(_, element, point) => {
Event::Rename(app.route_context().unwrap(), element, point)
}
event => event,
};
send(&mut app, event);
send(&mut app, Event::Name(" \t ".into()));
send(&mut app, Event::Submit);
assert!(app.architecture.as_ref().unwrap().inline());
assert!(app.architecture.as_ref().unwrap().error.is_some());
assert_eq!(app.document.bundle(), &before);
let _ = app.update(Message::Escape);
assert!(app.architecture.is_none());
assert_eq!(app.document.bundle(), &before);
}
}
#[test]
fn architecture_invalid_click_cannot_poison_inline_or_checkpoint_geometry() {
let mut app = setup(Layer::Oa);
send(&mut app, Event::Start(Kind::Entity, None));
let before = app.document.bundle().clone();
let context = app.route_context().unwrap();
for point in [
Position { x: f32::NAN, y: 0. },
Position {
x: 0.,
y: f32::INFINITY,
},
] {
send(&mut app, Event::Click(context, point));
let draft = app.architecture.as_ref().unwrap();
assert!(draft.unnamed_tool());
assert!(validate_saved(app.project(), &draft.saved).is_ok());
assert_eq!(app.document.bundle(), &before);
}
send(&mut app, Event::Click(context, Position { x: 25., y: 30. }));
assert!(app.architecture.as_ref().unwrap().inline());
}
#[test]
fn architecture_stale_submit_keeps_create_and_rename_visible_and_exact() {
for rename in [false, true] {
for stale_document in [false, true] {
let mut app = setup(Layer::Oa);
let element = create(&mut app, Kind::Entity, "Accepted team");
let point = Position {
x: 151.5,
y: 227.25,
};
let event = if rename {
Event::Rename(app.route_context().unwrap(), element, point)
} else {
Event::Start(Kind::Activity, Some(point))
};
send(&mut app, event);
send(
&mut app,
Event::Name(" Retained engineering name 雨 ".into()),
);
// Change only the binding. The accepted model/source must not be
// rewritten or a rejected draft silently rebound to the new context.
if stale_document {
app.architecture.as_mut().unwrap().document = Id::new_v4();
} else {
app.architecture.as_mut().unwrap().revision = app.document.revision() + 1;
}
let draft = app.architecture.as_ref().unwrap();
let saved = serde_json::to_value(&draft.saved).unwrap();
let binding = (draft.document, draft.revision);
let model = app.project().clone();
let source = app.document.bundle().clone();
let revision = app.document.revision();
let camera = (app.zoom, app.pan);
for _ in 0..2 {
send(&mut app, Event::Submit);
let draft = app.architecture.as_ref().unwrap();
assert!(
draft.inline(),
"rename={rename}, stale_document={stale_document}"
);
assert!(inline_editor(&app).is_some());
assert_eq!(draft.error.as_deref(), Some(app.status.as_str()));
assert!(app.status.contains("cancel and restart"));
assert_eq!(serde_json::to_value(&draft.saved).unwrap(), saved);
assert_eq!((draft.document, draft.revision), binding);
assert_eq!(app.project(), &model);
assert_eq!(app.document.bundle(), &source);
assert_eq!(app.document.revision(), revision);
assert_eq!((app.zoom, app.pan), camera);
}
let _ = app.update(Message::Escape);
assert!(app.architecture.is_none());
assert_eq!(app.project(), &model);
assert_eq!(app.document.bundle(), &source);
let fresh = create(&mut app, Kind::Actor, "Fresh context");
assert_eq!(app.project().elements[&fresh].name, "Fresh context");
let _ = app.update(Message::Undo);
assert_eq!(app.project(), &model);
assert_eq!(app.document.bundle(), &source);
}
}
}
fn key_event(named: iced::keyboard::key::Named) -> iced::Event {
use iced::keyboard;
iced::Event::Keyboard(keyboard::Event::KeyPressed {
key: keyboard::Key::Named(named),
modified_key: keyboard::Key::Named(named),
physical_key: keyboard::key::Physical::Code(keyboard::key::Code::Escape),
location: keyboard::Location::Standard,
modifiers: keyboard::Modifiers::empty(),
text: None,
repeat: false,
})
}
fn focused_text_input_escape(name: &str) -> Message {
use iced::advanced::{Layout, Shell, layout, widget};
let mut field: iced::Element<'_, (), iced::Theme, ()> = text_input("Engineering name", name)
.id("architecture-name")
.on_input(|_| ())
.into();
let mut tree = widget::Tree::new(field.as_widget());
let size = iced::Size::new(300., 40.);
let node =
field
.as_widget_mut()
.layout(&mut tree, &(), &layout::Limits::new(iced::Size::ZERO, size));
tree.state
.downcast_mut::<iced::widget::text_input::State<()>>()
.focus();
let escape = key_event(iced::keyboard::key::Named::Escape);
let mut messages = Vec::new();
let mut shell = Shell::new(&mut messages);
field.as_widget_mut().update(
&mut tree,
&escape,
Layout::new(&node),
iced::mouse::Cursor::Unavailable,
&(),
&mut iced::advanced::clipboard::Null,
&mut shell,
&iced::Rectangle::new(iced::Point::ORIGIN, size),
);
assert!(
shell.is_event_captured(),
"Iced consumes focused-input Escape"
);
assert!(
!tree
.state
.downcast_ref::<iced::widget::text_input::State<()>>()
.is_focused()
);
captured_escape(
escape,
iced::event::Status::Captured,
iced::window::Id::unique(),
)
.unwrap()
}
#[test]
fn architecture_first_focused_input_escape_cancels_create_and_rename() {
for rename in [false, true] {
let mut app = setup(Layer::Oa);
let element = create(&mut app, Kind::Actor, "Accepted dispatcher");
let model = app.project().clone();
let source = app.document.bundle().clone();
let revision = app.document.revision();
let camera = (app.zoom, app.pan);
let point = Position { x: 439., y: 328.2 };
let event = if rename {
Event::Rename(app.route_context().unwrap(), element, point)
} else {
Event::Start(Kind::Actor, Some(point))
};
send(&mut app, event);
let name = " Unaccepted long engineering name 雨 ";
send(&mut app, Event::Name(name.into()));
assert!(app.architecture.as_ref().unwrap().inline());
let message = focused_text_input_escape(name);
let _ = app.update(message);
assert!(app.architecture.is_none());
assert!(inline_editor(&app).is_none());
assert_eq!(app.status, "Cancelled · Select / move");
assert_eq!(app.project(), &model);
assert_eq!(app.document.bundle(), &source);
assert_eq!(app.document.revision(), revision);
assert_eq!((app.zoom, app.pan), camera);
}
}
#[test]
fn architecture_captured_escape_does_not_cancel_other_controls_or_placement() {
let mut app = setup(Layer::Oa);
for event in [
None,
Some(Event::Start(Kind::Entity, None)),
Some(Event::Existing(None)),
] {
send(&mut app, Event::Cancel);
if let Some(event) = event {
send(&mut app, event);
}
let saved = app
.architecture
.as_ref()
.map(|s| serde_json::to_value(&s.saved).unwrap());
let before = app.document.bundle().clone();
let status = app.status.clone();
let epoch = app.canvas_epoch;
let message = focused_text_input_escape("Other field or existing-element search");
let _ = app.update(message);
assert_eq!(
app.architecture
.as_ref()
.map(|s| serde_json::to_value(&s.saved).unwrap()),
saved
);
assert_eq!(app.document.bundle(), &before);
assert_eq!(app.status, status);
assert_eq!(app.canvas_epoch, epoch);
}
assert!(
captured_escape(
key_event(iced::keyboard::key::Named::Escape),
iced::event::Status::Ignored,
iced::window::Id::unique(),
)
.is_none()
);
assert!(
captured_escape(
key_event(iced::keyboard::key::Named::Enter),
iced::event::Status::Captured,
iced::window::Id::unique(),
)
.is_none()
);
}
@@ -0,0 +1,488 @@
use super::*;
use crate::app::architecture_authoring::{Event, kinds};
fn setup(layer: Layer) -> App {
let mut app = App::with_project(Project::empty());
app.layer = layer;
app.active_diagram = None;
let _ = app.update(Message::AddDiagram);
app.zoom = 0.75;
app.pan = iced::Vector::new(37., -19.);
app
}
fn send(app: &mut App, event: Event) {
let _ = app.update(Message::Architecture(event));
}
#[test]
fn architecture_creation_names_before_commit_preserves_camera_and_undoes_once() {
let mut app = setup(Layer::Oa);
let before = app.document.bundle().clone();
let model = app.project().clone();
let camera = (app.zoom, app.pan);
send(&mut app, Event::Start(Kind::Entity, None));
assert_eq!(app.project(), &model);
let context = app.route_context().unwrap();
send(
&mut app,
Event::Click(context, Position { x: 219., y: 117. }),
);
assert_eq!(app.project(), &model);
assert!(app.architecture.as_ref().unwrap().inline());
send(&mut app, Event::Name(" Response team 雨 ".into()));
send(&mut app, Event::Submit);
assert!(app.architecture.is_none());
assert_eq!((app.zoom, app.pan), camera);
let element = &app.project().elements[&app.selected];
assert_eq!(element.name, " Response team 雨 ");
assert_eq!(element.parent, None);
let node =
&app.project().diagrams[&app.active_diagram.unwrap()].nodes[&app.selected_node.unwrap()];
assert_eq!(node.position, Position { x: 219., y: 117. });
let accepted = app.document.bundle().clone();
let _ = app.update(Message::Undo);
assert_eq!(app.document.bundle(), &before);
let _ = app.update(Message::Redo);
assert_eq!(app.document.bundle(), &accepted);
}
#[test]
fn architecture_context_choices_are_small_and_system_is_singleton() {
let mut app = setup(Layer::Oa);
assert_eq!(kinds(&app), vec![Kind::Entity, Kind::Actor, Kind::Activity]);
app = setup(Layer::Sa);
assert_eq!(
kinds(&app),
vec![Kind::System, Kind::SystemActor, Kind::Function]
);
send(
&mut app,
Event::Start(Kind::System, Some(Position { x: 60., y: 60. })),
);
send(&mut app, Event::Name("Pump controller".into()));
send(&mut app, Event::Submit);
assert_eq!(kinds(&app), vec![Kind::SystemActor, Kind::Function]);
}
#[test]
fn architecture_name_survives_rejected_click_resize_navigation_and_escape_is_explicit() {
let mut app = setup(Layer::Oa);
let before = app.document.bundle().clone();
send(&mut app, Event::Start(Kind::Activity, None));
send(&mut app, Event::Name("Diagnose pressure".into()));
send(&mut app, Event::Start(Kind::Entity, None));
assert!(
matches!(&app.architecture.as_ref().unwrap().saved.intent, architecture_authoring::Intent::Create { name, kind: Kind::Activity, .. } if name == "Diagnose pressure")
);
send(&mut app, Event::Submit);
let mut stale = app.route_context().unwrap();
stale.revision += 1;
send(&mut app, Event::Click(stale, Position { x: 1., y: 2. }));
let _ = app.update(Message::Page(Page::Home));
let _ = app.update(Message::WindowResized(iced::Size::new(1100., 700.)));
assert_eq!(app.page, Page::Diagram);
assert!(app.architecture.is_some());
assert_eq!(app.document.bundle(), &before);
let _ = app.update(Message::Escape);
assert!(app.architecture.is_none());
assert_eq!(app.document.bundle(), &before);
}
#[test]
fn architecture_existing_inventory_is_contextual_and_placement_never_implies_allocation() {
let mut app = setup(Layer::Oa);
let entity = app
.project()
.make_element(Kind::Entity, Layer::Oa, "Response team", None)
.unwrap();
let entity_id = entity.id;
let work = app
.project()
.make_element(Kind::Activity, Layer::Oa, "Report status", None)
.unwrap();
let work_id = work.id;
let data = app
.project()
.make_element(Kind::DataType, Layer::Oa, "Pressure", None)
.unwrap();
let data_id = data.id;
assert!(app.execute(Command::Batch(vec![
Command::CreateElement(entity),
Command::CreateElement(work),
Command::CreateElement(data)
])));
send(&mut app, Event::Existing(Some(Position { x: 80., y: 80. })));
let ids: Vec<_> = architecture_authoring::candidates(&app)
.iter()
.map(|row| row.0)
.collect();
assert!(ids.contains(&entity_id) && ids.contains(&work_id));
assert!(!ids.contains(&data_id));
send(&mut app, Event::Pick(entity_id));
let semantic = app.project().elements.clone();
let before = app.document.bundle().clone();
let camera = (app.zoom, app.pan);
send(
&mut app,
Event::Existing(Some(Position { x: 90., y: 100. })),
);
assert!(
!architecture_authoring::candidates(&app)
.iter()
.any(|row| row.0 == entity_id)
);
send(&mut app, Event::ShowPlaced(true));
assert!(
architecture_authoring::candidates(&app)
.iter()
.any(|row| row.0 == entity_id && row.2)
);
send(&mut app, Event::Pick(work_id));
assert_eq!(app.project().elements, semantic);
assert!(app.project().relations.is_empty());
assert_eq!((app.zoom, app.pan), camera);
let _ = app.update(Message::Undo);
assert_eq!(app.document.bundle(), &before);
}
#[test]
fn architecture_checkpoint_retains_exact_name_point_and_fresh_binding_without_commands() {
for (name, placing) in [(" Team 雨 ", true), (" ", false)] {
let mut app = setup(Layer::Oa);
send(&mut app, Event::Start(Kind::Entity, None));
send(&mut app, Event::Name(name.into()));
if placing {
send(&mut app, Event::Submit);
}
app.architecture.as_mut().unwrap().saved.point = Some(Position { x: 127.5, y: 215. });
app.architecture.as_mut().unwrap().saved.placing = placing;
let snapshot = crate::recovery::Snapshot::capture(
app.canvas_document,
&app.document,
&app.source_workspace,
app.draft.as_ref(),
&app.behavior,
None,
app.selected,
)
.unwrap()
.with_architecture(app.architecture.as_ref());
let bytes = serde_json::to_vec(&snapshot).unwrap();
let decoded: crate::recovery::Snapshot = serde_json::from_slice(&bytes).unwrap();
let accepted = app.document.bundle().clone();
let mut restored = setup(Layer::Sa);
let _ = restored.update(Message::CheckpointLoaded(Ok(Arc::new(decoded))));
assert_eq!(restored.document.bundle(), &accepted);
let draft = &restored.architecture.as_ref().unwrap().saved;
assert!(
matches!(&draft.intent, architecture_authoring::Intent::Create { name: actual, .. } if actual == name)
);
assert_eq!(draft.placing, placing);
assert_eq!(draft.point, Some(Position { x: 127.5, y: 215. }));
if placing {
let context = restored.route_context().unwrap();
send(
&mut restored,
Event::Click(context, Position { x: 20., y: 30. }),
);
assert!(restored.architecture.as_ref().unwrap().inline());
send(&mut restored, Event::Submit);
assert!(restored.architecture.is_none());
assert_eq!(restored.project().elements[&restored.selected].name, name);
} else {
send(&mut restored, Event::Submit);
assert!(restored.architecture.is_some());
assert_eq!(restored.document.bundle(), &accepted);
}
let mut value: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
value["architecture"]["base"] = json!("stale");
assert!(
serde_json::from_value::<crate::recovery::Snapshot>(value)
.unwrap()
.restore()
.is_err()
);
}
}
#[test]
fn architecture_canvas_left_click_uses_world_coordinate_once_at_zoom_and_pan() {
use iced::widget::canvas::Program;
let mut app = setup(Layer::Oa);
send(&mut app, Event::Start(Kind::Actor, None));
send(&mut app, Event::Name("Control centre".into()));
send(&mut app, Event::Submit);
let bounds = iced::Rectangle {
x: 260.,
y: 180.,
width: 600.,
height: 380.,
};
let local = iced::Point::new(190., 100.);
let expected = Position {
x: (local.x - app.pan.x) / app.zoom,
y: (local.y - app.pan.y) / app.zoom,
};
let canvas = crate::diagram::DiagramCanvas {
project: app.project(),
view_index: None,
diagram: app.active_diagram.unwrap(),
selected: app.selected,
zoom: app.zoom,
pan: app.pan,
context: app.route_context().unwrap(),
selected_bend: None,
editable: true,
placing: true,
connection: None,
};
let mut state = crate::diagram::State::default();
let action = canvas
.update(
&mut state,
&iced::widget::canvas::Event::Mouse(iced::mouse::Event::ButtonPressed(
iced::mouse::Button::Left,
)),
bounds,
iced::mouse::Cursor::Available(iced::Point::new(
bounds.x + local.x,
bounds.y + local.y,
)),
)
.unwrap();
let message = action
.into_inner()
.0
.expect("named placement emits a command");
let _ = app.update(message);
assert_eq!(
app.architecture.as_ref().unwrap().saved.point,
Some(expected)
);
send(&mut app, Event::Submit);
let node =
&app.project().diagrams[&app.active_diagram.unwrap()].nodes[&app.selected_node.unwrap()];
assert_eq!(node.position, expected);
}
#[test]
fn architecture_cancelled_gesture_cannot_commit_a_later_named_draft() {
let mut app = setup(Layer::Oa);
send(&mut app, Event::Start(Kind::Entity, None));
send(&mut app, Event::Name("First".into()));
send(&mut app, Event::Submit);
let stale = app.route_context().unwrap();
send(&mut app, Event::Cancel);
send(&mut app, Event::Start(Kind::Actor, None));
send(&mut app, Event::Name("Second".into()));
send(&mut app, Event::Submit);
let before = app.document.bundle().clone();
send(&mut app, Event::Click(stale, Position { x: 1., y: 1. }));
assert_eq!(app.document.bundle(), &before);
assert!(app.architecture.is_some());
}
#[test]
fn architecture_f2_rename_preserves_identity_placement_and_atomic_history() {
use iced::{keyboard, mouse, widget::canvas::Program};
let mut app = setup(Layer::Oa);
send(
&mut app,
Event::Start(Kind::Entity, Some(Position { x: 200., y: 120. })),
);
send(&mut app, Event::Name("Response team".into()));
send(&mut app, Event::Submit);
let before = app.document.bundle().clone();
let element = app.selected;
let node = app.selected_node;
let diagrams = app.project().diagrams.clone();
let camera = (app.zoom, app.pan);
let canvas = crate::diagram::DiagramCanvas {
project: app.project(),
view_index: None,
diagram: app.active_diagram.unwrap(),
selected: element,
zoom: app.zoom,
pan: app.pan,
context: app.route_context().unwrap(),
selected_bend: None,
editable: true,
placing: false,
connection: None,
};
let bounds = iced::Rectangle::with_size(iced::Size::new(800., 600.));
let cursor = mouse::Cursor::Available(iced::Point::new(500., 400.));
let mut state = crate::diagram::State::default();
let key = iced::widget::canvas::Event::Keyboard(keyboard::Event::KeyPressed {
key: keyboard::Key::Named(keyboard::key::Named::F2),
modified_key: keyboard::Key::Named(keyboard::key::Named::F2),
physical_key: keyboard::key::Physical::Code(keyboard::key::Code::F2),
location: keyboard::Location::Standard,
modifiers: keyboard::Modifiers::empty(),
text: None,
repeat: false,
});
assert!(canvas.update(&mut state, &key, bounds, cursor).is_none());
let _ = canvas.update(
&mut state,
&iced::widget::canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
bounds,
cursor,
);
let message = canvas
.update(&mut state, &key, bounds, cursor)
.expect("focused F2 opens the selected semantic name")
.into_inner()
.0
.unwrap();
let _ = app.update(message);
assert!(app.architecture.as_ref().unwrap().inline());
send(&mut app, Event::Name(" Emergency team 雨 ".into()));
assert_eq!(app.document.bundle(), &before);
send(&mut app, Event::Submit);
assert!(app.architecture.is_none());
assert_eq!((app.selected, app.selected_node), (element, node));
assert_eq!(
app.project().elements[&element].name,
" Emergency team 雨 "
);
assert_eq!(app.project().diagrams, diagrams);
assert_eq!((app.zoom, app.pan), camera);
let accepted = app.document.bundle().clone();
let _ = app.update(Message::Undo);
assert_eq!(app.document.bundle(), &before);
let _ = app.update(Message::Redo);
assert_eq!(app.document.bundle(), &accepted);
let context = app.route_context().unwrap();
send(
&mut app,
Event::Rename(context, element, Position { x: 200., y: 120. }),
);
send(&mut app, Event::Name("Cancelled name".into()));
let _ = app.update(Message::Escape);
assert!(app.architecture.is_none());
assert_eq!(app.document.bundle(), &accepted);
}
#[test]
fn architecture_contextual_allocation_creates_work_relation_and_node_in_one_undo() {
for (layer, performer_kind, work_kind) in [
(Layer::Oa, Kind::Entity, Kind::Activity),
(Layer::Sa, Kind::System, Kind::Function),
] {
let mut app = setup(layer);
send(
&mut app,
Event::Start(performer_kind, Some(Position { x: 20., y: 20. })),
);
send(&mut app, Event::Name("Control team".into()));
send(&mut app, Event::Submit);
let performer = app.selected;
let before = app.document.bundle().clone();
send(
&mut app,
Event::Allocate(work_kind, performer, Some(Position { x: 55., y: 90. })),
);
send(&mut app, Event::Name("Assess pressure".into()));
assert_eq!(app.document.bundle(), &before);
send(&mut app, Event::Submit);
assert!(app.architecture.is_none(), "{}", app.status);
let work = app.selected;
assert_eq!(app.project().elements[&work].parent, None);
assert!(
app.project()
.relations
.values()
.any(|r| r.kind() == RelationKind::Allocation
&& r.source() == performer
&& r.target() == work)
);
let node = &app.project().diagrams[&app.active_diagram.unwrap()].nodes
[&app.selected_node.unwrap()];
assert_eq!(node.position, Position { x: 55., y: 90. });
let accepted = app.document.bundle().clone();
let _ = app.update(Message::Undo);
assert_eq!(app.document.bundle(), &before);
let _ = app.update(Message::Redo);
assert_eq!(app.document.bundle(), &accepted);
}
}
#[test]
fn architecture_rejected_submission_keeps_inline_name_visible_and_invalid_click_is_ignored() {
let mut app = setup(Layer::Oa);
send(&mut app, Event::Start(Kind::Entity, None));
let context = app.route_context().unwrap();
send(
&mut app,
Event::Click(
context,
Position {
x: f32::NAN,
y: 30.,
},
),
);
assert!(app.architecture.as_ref().unwrap().unnamed_tool());
send(&mut app, Event::Click(context, Position { x: 40., y: 30. }));
send(&mut app, Event::Name("Retained team".into()));
// A replaced document must not accept a queued draft submission.
app.canvas_document = Id::new_v4();
let before = app.document.bundle().clone();
send(&mut app, Event::Submit);
let draft = app.architecture.as_ref().unwrap();
assert!(draft.inline());
assert!(draft.error.is_some());
assert!(
matches!(&draft.saved.intent, architecture_authoring::Intent::Create { name, .. } if name == "Retained team")
);
assert_eq!(app.document.bundle(), &before);
let _ = app.update(Message::Escape);
assert!(app.architecture.is_none());
assert_eq!(app.document.bundle(), &before);
}
#[test]
fn basic_transition_explicit_overview_focus_wins_over_an_already_populated_architecture() {
use crate::workflow::{Event as W, Workflow};
let mut p = Project::empty();
let work = p
.add_element(Kind::Activity, Layer::Oa, "Report status", None)
.unwrap();
let architecture = p.ensure_diagram(Layer::Sa);
p.diagrams.get_mut(&architecture).unwrap().projection = ProjectionKind::Allocation;
let mut first = Workflow::transition(&p, work).unwrap();
let Workflow::Transition(t) = &first else {
unreachable!()
};
let target = t
.plan
.changes
.iter()
.find(|c| c.source == work)
.unwrap()
.target;
let mut p = first.update(&p, W::Apply).unwrap().unwrap();
let mut overview = p.diagrams[&architecture].clone();
overview.id = Id::new_v4();
overview.alias = "chosen_overview".into();
overview.name = "Chosen system overview".into();
overview.projection = ProjectionKind::Overview;
overview.nodes.clear();
let chosen = overview.id;
p.diagrams.insert(chosen, overview);
let placement = p.place(chosen, target, Position { x: 823., y: 417. });
let mut app = App::with_project(p);
app.selected = work;
app.active_diagram = Some(architecture);
app.refresh_draft();
let _ = app.update(Message::StartTransition);
let _ = app.update(Message::Workflow(W::Destination(Choice {
id: chosen,
label: "Chosen system overview".into(),
})));
let before = app.project().diagrams.clone();
let _ = app.update(Message::Workflow(W::Apply));
assert!(app.workflow.is_none(), "{}", app.status);
assert_eq!(app.active_diagram, Some(chosen));
assert_eq!(app.selected, target);
assert_eq!(app.selected_node, Some(placement));
assert_eq!(app.project().diagrams, before);
}
@@ -322,14 +322,18 @@ fn passive_behavior_canvas_events_do_not_apply_property_drafts() {
Message::Field("/name".into(), "Unapplied state name".into()),
);
assert!(app.draft_dirty());
let context = behavior::topology::Context {
document: app.canvas_document,
owner: fixture.machine,
revision: app.document.revision(),
epoch: app.canvas_epoch,
};
for event in [
behavior::Event::Topology(behavior::topology::Event::Viewport(iced::Size::new(
900., 600.,
))),
behavior::Event::Topology(behavior::topology::Event::Zoom(0.7)),
behavior::Event::Topology(behavior::topology::Event::Fit),
behavior::topology::CanvasEvent::Viewport(context, iced::Size::new(900., 600.)),
behavior::topology::CanvasEvent::Zoom(context, Some(0.7)),
behavior::topology::CanvasEvent::Zoom(context, None),
] {
send(app, Message::Behavior(event));
send(app, Message::TopologyCanvas(event));
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
assert!(app.draft_dirty());
assert!(!app.behavior.has_pending());
+713
View File
@@ -0,0 +1,713 @@
//! Home navigation and asynchronous private checkpoints share the normal guards.
use super::*;
use crate::{home, recovery};
impl App {
pub(super) fn remember_local(&mut self) {
if let Some(path) = self.path.clone() {
let name = self.project().name.clone();
if let Err(error) = self.home.remember(&path, &name) {
self.home.notice = Some(error);
}
}
}
pub(super) fn home_event(&mut self, event: home::Event) -> Task<Message> {
match event {
home::Event::New => self.handle(Message::Request(Pending::New)),
home::Event::Open => self.handle(Message::Request(Pending::Open)),
home::Event::Example => self.handle(Message::Request(Pending::Demo)),
home::Event::Walkthrough => self.handle(Message::Request(Pending::Walkthrough)),
home::Event::Guide => self.update(Message::Page(Page::Guide)),
home::Event::Resume => self.update(Message::Page(self.last_workspace_page)),
home::Event::OpenRecent(path) => {
self.recent_target = Some(path);
self.handle(Message::Request(Pending::Recent))
}
home::Event::Recover(id) => self.handle(Message::Request(Pending::Recover(id))),
home::Event::Forget(path) => {
if let Err(error) = self.home.forget(&path) {
self.home.notice = Some(error);
}
Task::none()
}
home::Event::Refresh => {
self.home.refresh();
self.recovery.refresh();
self.recovery.entries.retain(|entry| {
entry.id != self.canvas_document && Some(entry.id) != self.recovery.restored
});
Task::none()
}
}
}
pub(super) fn checkpoint_tick(&mut self) -> Task<Message> {
if self.recovery.writing || self.busy {
return Task::none();
}
if !self.dirty() {
self.retire_checkpoint();
return Task::none();
}
let Some(directory) = self.recovery.directory.clone() else {
return Task::none();
};
let id = self.canvas_document;
let snapshot = match recovery::Snapshot::capture(
id,
&self.document,
&self.source_workspace,
self.draft.as_ref(),
&self.behavior,
self.path.clone(),
self.selected,
)
.and_then(|snapshot| snapshot.with_workflow(self.workflow.as_ref()))
.map(|snapshot| snapshot.with_architecture(self.architecture.as_ref()))
{
Ok(snapshot) => snapshot,
Err(error) => {
self.recovery.warning = Some(error);
return Task::none();
}
};
self.recovery.writing = true;
Task::perform(
async move {
tokio::task::spawn_blocking(move || recovery::write(&directory, &snapshot))
.await
.map_err(|e| e.to_string())?
},
move |result| Message::CheckpointWritten(id, result),
)
}
pub(super) fn checkpoint_written(&mut self, id: Id, result: Result<(), String>) {
self.recovery.writing = false;
match result {
Ok(()) if id == self.canvas_document && self.dirty() => {
self.recovery.active = Some(id);
}
Ok(()) => {
if let Some(directory) = &self.recovery.directory
&& let Err(error) = recovery::remove(directory, id)
{
self.recovery.warning = Some(error);
}
}
Err(error) => {
self.recovery.warning = Some(format!(
"Recovery checkpoint failed: {error}. Save your work explicitly."
))
}
}
}
pub(super) fn retire_checkpoint(&mut self) {
let Some(directory) = &self.recovery.directory else {
return;
};
for id in [self.recovery.active.take(), self.recovery.restored.take()]
.into_iter()
.flatten()
{
if let Err(error) = recovery::remove(directory, id) {
self.recovery.warning = Some(error);
} else {
self.recovery.entries.retain(|e| e.id != id);
}
}
}
pub(super) fn load_checkpoint(&mut self, id: Id) -> Task<Message> {
let Some(directory) = self.recovery.directory.clone() else {
return Task::none();
};
self.busy = true;
Task::perform(
async move {
tokio::task::spawn_blocking(move || recovery::read(&directory, id).map(Arc::new))
.await
.map_err(|e| e.to_string())?
},
Message::CheckpointLoaded,
)
}
pub(super) fn checkpoint_loaded(
&mut self,
result: Result<Arc<recovery::Snapshot>, String>,
) -> Task<Message> {
self.busy = false;
let snapshot = match result {
Ok(s) => s,
Err(error) => {
self.status = error;
return Task::none();
}
};
let id = snapshot.id;
let restored = match (*snapshot).clone().restore() {
Ok(r) => r,
Err(error) => {
self.status = error;
return Task::none();
}
};
// Fully prepare recovery before touching the currently accepted document.
self.reset(restored.document.project().clone(), None);
self.hosted.leave_document();
self.document = restored.document;
self.migration = self.document.compiled().decoded.migration.clone();
self.selected = if self.project().layer_of(restored.selected).is_some()
|| restored.selected == self.project().id
{
restored.selected
} else {
self.project().id
};
// Installing the raw workspace must follow all accepted-source synchronization.
self.source_workspace = restored.workspace;
self.show_source_file(false);
self.refresh_draft();
if let Some(draft) = restored.property {
self.selected = draft.original;
self.exact_strings = draft.exact_strings;
self.draft = Some(draft);
}
self.behavior = restored.behavior;
self.workflow = restored.workflow;
let focus = self.behavior.pending_owner().unwrap_or(self.selected);
if let Some(layer) = self
.behavior
.creation
.pending_layer()
.or_else(|| self.project().layer_of(focus))
{
self.layer = layer;
self.explorer_index = crate::explorer::Index::new(self.project(), layer);
self.active_diagram = self.diagram_for(layer, focus);
}
self.recovery.restored = Some(id);
self.recovery.entries.retain(|entry| entry.id != id);
self.page = if self.behavior.has_pending() {
Page::Behavior
} else if self.source_dirty() {
Page::Text
} else {
Page::Diagram
};
if let Some(saved) = restored.architecture {
self.active_diagram = Some(saved.diagram);
self.layer = self.project().diagrams[&saved.diagram].layer;
self.explorer_index = crate::explorer::Index::new(self.project(), self.layer);
self.page = Page::Diagram;
self.architecture = architecture_authoring::State::restore(self, saved).ok();
}
self.status = if let Some(report) = &self.migration {
format!(
"Recovered source {} as source {} in an unsaved local copy. Draft text is preserved; workflow previews were rebuilt and semantic impact approvals need review. Choose Save as to save it.",
report.from_schema, report.to_schema
)
} else {
"Recovered an unsaved local copy. Choose Save as to save it; the original file and hosted model are unchanged.".into()
};
let lint = self.request_lint();
if self
.architecture
.as_ref()
.is_some_and(architecture_authoring::State::inline)
{
Task::batch([lint, iced::widget::operation::focus("architecture-name")])
} else if self.page == Page::Behavior && self.behavior.source_open() {
Task::batch([
lint,
iced::widget::operation::focus(behavior::source_draft::NAME_INPUT),
])
} else if self.page == Page::Behavior && self.behavior.creation.created().is_some() {
Task::batch([
lint,
iced::widget::operation::focus(behavior::creation::NAME_INPUT),
])
} else {
lint
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn home_resume_returns_to_each_actual_workspace_without_model_edits() {
let mut app = App::with_project(Project::demo());
let accepted = app.document.snapshot();
for page in [
Page::Diagram,
Page::Catalog,
Page::Behavior,
Page::Data,
Page::Traces,
Page::Text,
Page::Validation,
Page::Guide,
] {
let _ = app.update(Message::Page(page));
assert_eq!(app.page, page);
let selected = app.selected;
let _ = app.update(Message::Page(Page::Home));
assert_eq!(app.last_workspace_page, page);
// Repeated Home events must not replace the remembered workspace.
let _ = app.update(Message::Page(Page::Home));
let _ = app.update(Message::Home(home::Event::Resume));
assert_eq!(app.page, page);
assert_eq!(app.selected, selected);
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
}
}
#[test]
fn home_resume_keeps_state_view_while_explicit_architecture_remains_reachable() {
let mut project = Project::empty();
let owner = project
.add_element(Kind::System, Layer::Sa, "System", None)
.unwrap();
let machine = project
.add_element(Kind::StateMachine, Layer::Sa, "Modes", Some(owner))
.unwrap();
let plan = crate::behavior::state_edit::create_view(&project, machine).unwrap();
project = prepare_command(&project, &plan.command).unwrap().project;
let mut app = App::with_project(project);
assert_eq!(app.page, Page::Behavior);
let accepted = app.document.snapshot();
let _ = app.update(Message::Page(Page::Home));
let _ = app.update(Message::Home(home::Event::Resume));
assert_eq!(app.page, Page::Behavior);
assert_eq!(app.state_context().unwrap().diagram, plan.diagram);
assert_eq!(app.behavior.active_owner, Some(machine));
let _ = app.update(Message::Page(Page::Diagram));
assert_eq!(app.page, Page::Diagram);
assert!(app.active_diagram.is_none());
let _ = app.update(Message::Page(Page::Home));
let _ = app.update(Message::Home(home::Event::Resume));
assert_eq!(app.page, Page::Diagram);
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
}
#[test]
fn home_resume_retains_invalid_source_and_property_drafts() {
let mut app = App::with_project(Project::demo());
let accepted = app.document.snapshot();
let _ = app.update(Message::Page(Page::Text));
let raw = format!("{}\nunfinished draft", app.source_workspace.text());
app.source_workspace
.edit(raw.clone(), app.source_editor.cursor());
let _ = app.update(Message::Page(Page::Home));
let _ = app.update(Message::Home(home::Event::Resume));
assert_eq!(app.page, Page::Text);
assert_eq!(app.source_workspace.text(), raw);
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
let mut app = App::with_project(Project::demo());
let accepted = app.document.snapshot();
let _ = app.update(Message::Page(Page::Data));
let _ = app.update(Message::Field("/id".into(), "-".into()));
let raw = serde_json::to_value(app.draft.as_ref().unwrap().recovery_snapshot()).unwrap();
assert!(app.draft_dirty());
let _ = app.update(Message::Page(Page::Home));
let _ = app.update(Message::Home(home::Event::Resume));
assert_eq!(app.page, Page::Data);
assert_eq!(
serde_json::to_value(app.draft.as_ref().unwrap().recovery_snapshot()).unwrap(),
raw
);
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
}
#[test]
fn walkthrough_request_keeps_unfinished_source_behind_the_normal_pending_guard() {
let mut app = App::with_project(Project::demo());
let accepted = app.document.snapshot();
let raw = format!(
"{}\nunfinished walkthrough draft",
app.source_workspace.text()
);
app.source_workspace
.edit(raw.clone(), app.source_editor.cursor());
let task = app.update(Message::Home(home::Event::Walkthrough));
assert!(matches!(app.pending, Some(Pending::Walkthrough)));
assert!(!app.busy);
assert!(iced_runtime::task::into_stream(task).is_none());
assert_eq!(app.source_workspace.text(), raw);
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
let _ = app.update(Message::Escape);
assert!(app.pending.is_none());
assert_eq!(app.source_workspace.text(), raw);
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
}
fn retained_snapshot(app: &App) -> recovery::Snapshot {
recovery::Snapshot::capture(
app.canvas_document,
&app.document,
&app.source_workspace,
app.draft.as_ref(),
&app.behavior,
app.path.clone(),
app.selected,
)
.unwrap()
}
async fn run_recovery_tasks(app: &mut App, task: Task<Message>) -> (usize, bool, bool) {
use iced::advanced::{Layout, layout, widget};
use iced::futures::StreamExt;
// Mount real inputs, with focus initially in another control. This
// verifies the returned operation targets the naming field by its ID.
let mut fields: iced::Element<'_, (), iced::Theme, ()> = iced::widget::column![
iced::widget::text_input("Engineering name", "Retained name")
.id("architecture-name")
.on_input(|_| ()),
iced::widget::text_input("Other control", "Other value")
.id("unrelated-field")
.on_input(|_| ())
]
.into();
let mut tree = widget::Tree::new(fields.as_widget());
let node = fields.as_widget_mut().layout(
&mut tree,
&(),
&layout::Limits::new(iced::Size::ZERO, iced::Size::new(320., 100.)),
);
tree.children[1]
.state
.downcast_mut::<iced::widget::text_input::State<()>>()
.focus();
let mut operations = 0;
let mut lint_completions = 0;
let mut stream = iced_runtime::task::into_stream(task).expect("Recovery must still lint");
while let Some(action) = stream.next().await {
match action {
iced_runtime::Action::Widget(mut operation) => {
operations += 1;
fields.as_widget_mut().operate(
&mut tree,
Layout::new(&node),
&(),
operation.as_mut(),
);
}
iced_runtime::Action::Output(message @ Message::Linted(_, _)) => {
lint_completions += 1;
let _ = app.update(message);
}
_ => panic!("Unexpected recovery task action"),
}
}
assert_eq!(
lint_completions, 1,
"Autofocus must not replace request_lint"
);
assert!(app.lint_running.is_none());
let focused = |index: usize| {
tree.children[index]
.state
.downcast_ref::<iced::widget::text_input::State<()>>()
.is_focused()
};
(operations, focused(0), focused(1))
}
fn architecture_recovery_app() -> App {
let mut app = App::with_project(Project::empty());
let _ = app.update(Message::AddDiagram);
let _ = app.update(Message::Architecture(architecture_authoring::Event::Start(
Kind::Entity,
Some(Position { x: 80., y: 90. }),
)));
let _ = app.update(Message::Architecture(architecture_authoring::Event::Name(
"Accepted team".into(),
)));
let _ = app.update(Message::Architecture(architecture_authoring::Event::Submit));
assert!(app.architecture.is_none());
app
}
#[tokio::test]
async fn checkpoint_inline_create_and_rename_restore_focus_without_changing_drafts_or_history()
{
use architecture_authoring::Event;
for rename in [false, true] {
let mut app = architecture_recovery_app();
let point = Position { x: 439., y: 328.2 };
let event = if rename {
Event::Rename(app.route_context().unwrap(), app.selected, point)
} else {
Event::Start(Kind::Actor, Some(point))
};
let _ = app.update(Message::Architecture(event));
let _ = app.update(Message::Architecture(Event::Name(
" Recovered engineering name 雨 ".into(),
)));
let saved = serde_json::to_value(&app.architecture.as_ref().unwrap().saved).unwrap();
let snapshot = retained_snapshot(&app).with_architecture(app.architecture.as_ref());
let expected = snapshot.clone().restore().unwrap();
let checkpoint = snapshot.id;
let mut recovered = App::with_project(Project::empty());
let task = recovered.checkpoint_loaded(Ok(Arc::new(snapshot)));
assert_eq!(
run_recovery_tasks(&mut recovered, task).await,
(1, true, false)
);
assert_eq!(recovered.page, Page::Diagram);
assert!(recovered.architecture.as_ref().unwrap().inline());
assert_eq!(
serde_json::to_value(&recovered.architecture.as_ref().unwrap().saved).unwrap(),
saved
);
assert_eq!(recovered.project(), expected.document.project());
assert_eq!(recovered.document.bundle(), expected.document.bundle());
assert_eq!(recovered.document.revision(), expected.document.revision());
assert_eq!(recovered.document.can_undo(), expected.document.can_undo());
assert_eq!(recovered.document.can_redo(), expected.document.can_redo());
assert_eq!(
recovered.source_workspace.bundle(),
expected.workspace.bundle()
);
assert_eq!(recovered.recovery.restored, Some(checkpoint));
assert!(recovered.path.is_none());
}
}
#[tokio::test]
async fn checkpoint_noninline_contexts_keep_other_focus_and_still_lint() {
use architecture_authoring::Event;
for context in ["accepted", "armed", "picker", "property", "source"] {
let mut app = architecture_recovery_app();
match context {
"armed" => {
let _ = app.update(Message::Architecture(Event::Start(Kind::Actor, None)));
}
"picker" => {
let _ = app.update(Message::Architecture(Event::Existing(None)));
}
"property" => {
app.draft
.as_mut()
.unwrap()
.input("/name", "Unapplied property".into());
assert!(app.draft_dirty());
}
"source" => {
app.source_workspace
.edit("incomplete source 雨".into(), app.source_editor.cursor());
assert!(app.source_dirty());
}
_ => {}
}
let snapshot = retained_snapshot(&app).with_architecture(app.architecture.as_ref());
let expected = snapshot.clone().restore().unwrap();
let mut recovered = App::with_project(Project::empty());
let task = recovered.checkpoint_loaded(Ok(Arc::new(snapshot)));
assert_eq!(
run_recovery_tasks(&mut recovered, task).await,
(0, false, true),
"{context}"
);
assert!(
!recovered
.architecture
.as_ref()
.is_some_and(architecture_authoring::State::inline)
);
assert_eq!(recovered.project(), expected.document.project());
assert_eq!(recovered.document.bundle(), expected.document.bundle());
assert_eq!(recovered.document.revision(), expected.document.revision());
assert_eq!(
recovered.source_workspace.bundle(),
expected.workspace.bundle()
);
}
}
#[test]
fn saving_an_older_snapshot_keeps_recovery_for_newer_unsaved_work() {
let directory = tempfile::tempdir().unwrap();
let mut app = App::with_project(Project::demo());
app.recovery.directory = Some(directory.path().join("recovery"));
let id = app.project().id;
app.execute(Command::Rename(id, "Saved milestone".into()));
let path = directory.path().join(&app.document.bundle().entry);
let receipt = write_snapshot(
app.canvas_document,
app.document.revision(),
app.document.snapshot(),
path,
None,
)
.unwrap();
app.execute(Command::Rename(id, "Later unsaved engineering".into()));
let snapshot = retained_snapshot(&app);
let recovery_directory = app.recovery.directory.clone().unwrap();
recovery::write(&recovery_directory, &snapshot).unwrap();
app.recovery.active = Some(app.canvas_document);
let _ = app.update(Message::Saved(Ok(Some(receipt))));
assert!(app.dirty());
assert_eq!(app.project().name, "Later unsaved engineering");
assert_eq!(app.recovery.active, Some(app.canvas_document));
let restored = recovery::read(&recovery_directory, app.canvas_document)
.unwrap()
.restore()
.unwrap();
assert_eq!(
restored.document.project().name,
"Later unsaved engineering"
);
}
#[test]
fn checkpoint_completion_releases_the_writer_while_a_context_menu_is_open() {
let directory = tempfile::tempdir().unwrap();
let mut app = App::with_project(Project::demo());
app.recovery.directory = Some(directory.path().into());
let id = app.project().id;
app.execute(Command::Rename(id, "Unsaved engineering".into()));
let snapshot = retained_snapshot(&app);
recovery::write(directory.path(), &snapshot).unwrap();
app.recovery.writing = true;
let _ = app.update(Message::Menu(crate::model_menu::Event::Open(
crate::model_menu::Request::object(id, iced::Point::ORIGIN),
)));
assert!(app.model_menu.is_some());
let _ = app.update(Message::CheckpointWritten(app.canvas_document, Ok(())));
assert!(
!app.recovery.writing,
"A passive completion cannot be swallowed by an open menu"
);
assert_eq!(app.recovery.active, Some(app.canvas_document));
assert!(
app.model_menu.is_some(),
"Saving a checkpoint does not dismiss the menu"
);
assert_eq!(app.project().name, "Unsaved engineering");
}
#[test]
fn discard_and_close_waits_for_the_late_checkpoint_then_retires_it() {
let directory = tempfile::tempdir().unwrap();
let mut app = App::with_project(Project::demo());
app.recovery.directory = Some(directory.path().into());
app.execute(Command::Rename(
app.project().id,
"Discarded on close".into(),
));
let snapshot = retained_snapshot(&app);
let document = app.canvas_document;
app.recovery.writing = true;
let window = window::Id::unique();
let _ = app.update(Message::Request(Pending::Close(window)));
assert!(matches!(app.pending, Some(Pending::Close(id)) if id == window));
let closing = app.update(Message::Discard);
assert!(
iced_runtime::task::into_stream(closing).is_none(),
"The window must remain until its outstanding writer finishes"
);
assert_eq!(app.checkpoint_close, Some(window));
assert!(app.busy);
recovery::write(directory.path(), &snapshot).unwrap();
assert!(recovery::read(directory.path(), document).is_ok());
let close = app.update(Message::CheckpointWritten(document, Ok(())));
assert!(
iced_runtime::task::into_stream(close).is_some(),
"Close is emitted after checkpoint cleanup"
);
assert!(app.checkpoint_close.is_none());
assert!(recovery::read(directory.path(), document).is_err());
assert!(app.recovery.active.is_none());
}
#[test]
fn recent_and_recovery_navigation_respect_unsaved_decision() {
let mut app = App::with_project(Project::demo());
let id = app.project().id;
app.execute(Command::Rename(id, "Retained work".into()));
let _ = app.update(Message::Home(home::Event::OpenRecent(PathBuf::from(
"missing.arcadia",
))));
assert!(matches!(app.pending, Some(Pending::Recent)));
assert!(!app.busy);
assert_eq!(app.project().name, "Retained work");
let _ = app.update(Message::Cancel);
let recovery_id = Id::new_v4();
let _ = app.update(Message::Home(home::Event::Recover(recovery_id)));
assert!(matches!(app.pending,Some(Pending::Recover(id)) if id==recovery_id));
assert!(!app.busy);
}
#[test]
fn restored_copy_keeps_raw_property_and_requires_save_without_local_or_hosted_binding() {
let mut app = App::with_project(Project::demo());
let selected = app.project().id;
app.draft
.as_mut()
.unwrap()
.input("/name", "Not yet applied".into());
let snapshot = recovery::Snapshot::capture(
Id::new_v4(),
&app.document,
&app.source_workspace,
app.draft.as_ref(),
&app.behavior,
Some(PathBuf::from("original.arcadia")),
selected,
)
.unwrap();
let checkpoint_id = snapshot.id;
app.recovery.entries.push(recovery::Entry {
id: checkpoint_id,
name: "Retained work".into(),
origin: None,
modified: snapshot.modified,
});
let _ = app.checkpoint_loaded(Ok(Arc::new(snapshot)));
assert!(app.path.is_none());
assert!(app.disk_project.is_none());
assert!(app.hosted.session.is_none());
assert!(app.document.dirty());
assert!(app.draft_dirty());
assert_eq!(app.recovery.restored, Some(checkpoint_id));
assert!(
app.recovery.entries.is_empty(),
"Home must not offer the copy currently being recovered again"
);
assert_eq!(
app.draft.as_ref().unwrap().inputs["/name"].raw,
"Not yet applied"
);
assert_ne!(app.project().name, "Not yet applied");
}
#[test]
fn stale_write_completion_cannot_recover_a_discarded_document() {
let directory = tempfile::tempdir().unwrap();
let mut app = App::with_project(Project::demo());
app.recovery.directory = Some(directory.path().into());
let old = app.canvas_document;
let s = recovery::Snapshot::capture(
old,
&app.document,
&app.source_workspace,
None,
&app.behavior,
None,
app.selected,
)
.unwrap();
app.reset(Project::empty(), None);
recovery::write(directory.path(), &s).unwrap();
app.checkpoint_written(old, Ok(()));
assert!(recovery::read(directory.path(), old).is_err());
assert!(app.recovery.active.is_none());
}
#[test]
fn malformed_checkpoint_does_not_replace_the_current_model() {
let mut app = App::with_project(Project::demo());
let original = app.document.bundle().clone();
let _ = app.checkpoint_loaded(Err("Damaged checkpoint".into()));
assert_eq!(app.document.bundle(), &original);
assert!(app.status.contains("Damaged"));
}
}
@@ -0,0 +1,150 @@
//! A view picker follows a view inspector without stealing semantic selection.
use super::*;
fn fixture() -> (App, Id, Id, Id) {
let project = language::parse(
r#"arcadia 6; project views "View switching" {
oa_activity work "Inspect work" {}
diagram first "Original concern" {}
diagram second "Commands review" {}
node original_work { diagram @first; element @work; }
node copied_work { diagram @second; element @work; }
}"#,
)
.unwrap();
let id = |alias| {
project
.identities()
.into_iter()
.find(|(_, value)| *value == alias)
.unwrap()
.0
};
let (first, second, work) = (id("first"), id("second"), id("work"));
let mut app = App::with_project(project);
app.page = Page::Diagram;
app.active_diagram = Some(second);
app.selected = second;
app.refresh_draft();
(app, first, second, work)
}
fn choose(app: &mut App, id: Id) {
let _ = app.update(Message::Diagram(Choice {
id,
label: "Captured choice".into(),
}));
}
fn field(app: &mut App, path: &str, value: &str) {
let _ = app.update(Message::Field(path.into(), value.into()));
}
#[test]
fn changing_visible_view_follows_its_clean_inspector_without_a_model_edit() {
let (mut app, original, copy, _) = fixture();
let before = app.document.snapshot();
let revision = app.document.revision();
choose(&mut app, original);
assert_eq!(app.active_diagram, Some(original));
assert_eq!(app.selected, original);
assert_eq!(app.draft.as_ref().unwrap().original, original);
assert_eq!(
app.draft.as_ref().unwrap().inputs["/name"].raw,
"Original concern"
);
assert_eq!(app.project(), &before.decoded.project);
assert_eq!(app.document.bundle(), &before.bundle);
assert_eq!(app.document.revision(), revision);
assert!(!app.document.can_undo());
field(&mut app, "/name", "Edited visible original");
assert!(app.apply());
assert_eq!(
app.project().diagrams[&original].name,
"Edited visible original"
);
assert_eq!(app.project().diagrams[&copy].name, "Commands review");
}
#[test]
fn invalid_view_draft_blocks_the_switch_and_retains_raw_input() {
let (mut app, original, copy, _) = fixture();
field(&mut app, "/id", "-");
let raw = serde_json::to_value(app.draft.as_ref().unwrap().recovery_snapshot()).unwrap();
let before = app.document.snapshot();
choose(&mut app, original);
assert_eq!(app.active_diagram, Some(copy));
assert_eq!(app.selected, copy);
assert_eq!(
serde_json::to_value(app.draft.as_ref().unwrap().recovery_snapshot()).unwrap(),
raw
);
assert_eq!(app.project(), &before.decoded.project);
assert_eq!(app.document.bundle(), &before.bundle);
assert!(!app.document.can_undo());
}
#[test]
fn valid_pending_view_edit_applies_to_old_view_then_inspects_the_new_view() {
let (mut app, original, copy, _) = fixture();
let before = app.document.snapshot();
field(&mut app, "/name", "Reviewed copy");
choose(&mut app, original);
assert_eq!(app.project().diagrams[&copy].name, "Reviewed copy");
assert_eq!(
app.project().diagrams[&original],
before.decoded.project.diagrams[&original]
);
assert_eq!(app.selected, original);
assert_eq!(app.draft.as_ref().unwrap().original, original);
let _ = app.update(Message::Undo);
assert_eq!(app.project(), &before.decoded.project);
assert_eq!(app.document.bundle(), &before.bundle);
assert!(!app.document.can_undo());
}
#[test]
fn diagram_uuid_edit_follows_the_same_view_or_a_different_picker_target() {
for same_view in [false, true] {
let (mut app, original, copy, _) = fixture();
let changed = Id::from_u128(0xabcd123440008000000000000001);
field(&mut app, "/id", &changed.to_string());
choose(&mut app, if same_view { copy } else { original });
let expected = if same_view { changed } else { original };
assert!(!app.project().diagrams.contains_key(&copy));
assert!(app.project().diagrams.contains_key(&changed));
assert_eq!(app.active_diagram, Some(expected));
assert_eq!(app.selected, expected);
assert_eq!(app.draft.as_ref().unwrap().original, expected);
assert!(!app.draft_dirty());
}
}
#[test]
fn diagram_switch_preserves_an_independent_semantic_inspection() {
let (mut app, original, _, work) = fixture();
let _ = app.update(Message::Select(work));
let before = app.document.snapshot();
choose(&mut app, original);
assert_eq!(app.active_diagram, Some(original));
assert_eq!(app.selected, work);
assert_eq!(app.draft.as_ref().unwrap().original, work);
assert_eq!(app.project(), &before.decoded.project);
assert_eq!(app.document.bundle(), &before.bundle);
}
#[test]
fn stale_diagram_choice_never_applies_an_unrelated_valid_draft() {
let (mut app, _, copy, _) = fixture();
field(&mut app, "/name", "Pending copy name");
let before = app.document.snapshot();
choose(&mut app, Id::new_v4());
assert_eq!(app.active_diagram, Some(copy));
assert_eq!(app.selected, copy);
assert_eq!(
app.draft.as_ref().unwrap().inputs["/name"].raw,
"Pending copy name"
);
assert_eq!(app.project(), &before.decoded.project);
assert_eq!(app.document.bundle(), &before.bundle);
assert!(!app.document.can_undo());
assert!(app.status.contains("no longer exists"));
}
+584
View File
@@ -0,0 +1,584 @@
//! Desktop destination lifecycle; HTTP never becomes a second editing history.
use super::*;
use crate::hosted::{
Event, Operation, Outcome, Request, Review, Session,
client::{Connection, Failure, Remote},
};
impl App {
pub fn hosted_label(&self) -> String {
self.hosted.session.as_ref().map_or_else(
|| {
if self.dirty() {
"Unsaved changes".into()
} else if self.path.is_some() {
"All changes saved".into()
} else {
"Local project".into()
}
},
|session| {
format!(
"Hosted · revision {} · {}",
session.revision,
if self.dirty() {
"local changes"
} else {
"saved"
}
)
},
)
}
pub fn save_label(&self) -> &'static str {
if self.hosted.session.is_some() {
"Save to server"
} else {
"Save all"
}
}
fn hosted_problem(&mut self, detail: impl Into<String>) {
let detail = detail.into();
self.status = detail.clone();
self.hosted.error = Some(Failure {
detail,
code: "desktop_hosted".into(),
status: None,
uncertain: false,
published: None,
});
}
fn hosted_ready(&mut self) -> bool {
if self.pending_recovery.is_some() {
self.continue_after_save = false;
self.pending = None;
self.hosted.visible = false;
self.page = Page::Text;
self.status =
"Recover the interrupted source save before changing storage destinations.".into();
return false;
}
if !self.apply_behavior() || !self.apply_source() || !self.apply() {
self.hosted.visible = false;
self.continue_after_save = false;
self.pending = None;
return false;
}
if self.source_workspace.detached_dirty() {
self.hosted.visible = false;
self.page = Page::Text;
self.status =
"Reattach or revert detached source drafts before saving to a server.".into();
self.continue_after_save = false;
self.pending = None;
return false;
}
true
}
fn hosted_start(
&mut self,
operation: Operation,
connection: Connection,
resource_id: Option<Id>,
) -> Task<Message> {
let request = Request {
id: Id::new_v4(),
document: self.canvas_document,
revision: self.document.revision(),
operation,
connection,
resource_id,
etag: self.hosted.session.as_ref().map(|s| s.etag.clone()),
snapshot: self.document.snapshot(),
};
self.hosted.request = Some(request.clone());
if operation != Operation::Check {
self.hosted.error = None;
}
self.busy = true;
self.status = match operation {
Operation::Catalog(_) => "Reading hosted models…",
Operation::Open(_) => "Opening hosted model…",
Operation::Publish => "Publishing source project…",
Operation::Save => "Saving to server…",
Operation::Check => "Checking current server revision…",
}
.into();
let query = self.hosted.query.clone();
let after = if operation == Operation::Catalog(true) {
self.hosted.next
} else {
None
};
Task::perform(
async move {
let result = match operation {
Operation::Catalog(_) => request
.connection
.catalog(&query, after)
.await
.map(Outcome::Catalog),
Operation::Open(id) => request.connection.open(id).await.map(Outcome::Remote),
Operation::Publish => request
.connection
.publish(request.snapshot.bundle.clone())
.await
.map(Outcome::Remote),
Operation::Save => request
.connection
.save(
request.resource_id.expect("save has a resource"),
request.etag.as_deref().expect("save has a precondition"),
request.snapshot.bundle.clone(),
)
.await
.map(Outcome::Remote),
Operation::Check => request
.connection
.open(request.resource_id.expect("check has a resource"))
.await
.map(Outcome::Remote),
};
Message::Hosted(Event::Completed(request.id, request.document, result))
},
|message| message,
)
}
pub(super) fn open_hosted(&mut self) -> Task<Message> {
if self.pending_recovery.is_some() {
self.hosted_problem(
"Recover the interrupted source save before opening another model.",
);
return Task::none();
}
if let Some((connection, id)) = self.hosted.open_target.take() {
return self.hosted_start(Operation::Open(id), connection, Some(id));
}
Task::none()
}
pub(super) fn save_hosted(&mut self) -> Task<Message> {
if !self.hosted_ready() {
return Task::none();
}
if self.hosted.review.is_some() {
self.hosted.visible = true;
self.hosted_problem("The server has a different version. Review it before saving; your local work is retained.");
self.continue_after_save = false;
self.pending = None;
return Task::none();
}
if let Some(session) = &self.hosted.session {
return self.hosted_start(
Operation::Save,
session.connection.clone(),
Some(session.resource_id),
);
}
Task::none()
}
fn check_hosted(&mut self) -> Task<Message> {
let destination = self
.hosted
.uncertain
.as_ref()
.and_then(|r| r.resource_id.map(|id| (r.connection.clone(), id)))
.or_else(|| {
self.hosted
.session
.as_ref()
.map(|s| (s.connection.clone(), s.resource_id))
});
if let Some((connection, id)) = destination {
return self.hosted_start(Operation::Check, connection, Some(id));
}
self.hosted_problem("The publication result is unknown. Refresh the catalog and inspect the hosted models before choosing another action.");
Task::none()
}
fn acknowledge_hosted(&mut self, request: &Request, remote: Remote) -> Task<Message> {
if remote.compiled.bundle != request.snapshot.bundle {
self.hosted.review = Some(Review {
connection: request.connection.clone(),
remote,
});
self.hosted.review_path = None;
self.hosted.visible = true;
self.hosted_problem("The server returned a different source version. Review it; the local history has been preserved.");
self.continue_after_save = false;
self.pending = None;
return Task::none();
}
if self.path.is_some()
|| self.hosted.session.as_ref().is_none_or(|session| {
session.resource_id != remote.resource_id
|| session.connection.base() != request.connection.base()
})
{
self.source_workspace.reset_navigation_session();
}
self.hosted.session = Some(Session {
connection: request.connection.clone(),
resource_id: remote.resource_id,
revision: remote.revision,
etag: remote.etag,
});
self.path = None;
self.disk_project = None;
self.document.mark_saved(request.snapshot.clone());
self.hosted.uncertain = None;
self.hosted.review = None;
self.hosted.review_path = None;
self.hosted.error = None;
self.status = format!(
"Hosted revision {} saved. Undo remains local until your next Save.",
remote.revision
);
// A late receipt may save an older snapshot; it must not close newer work.
if self.continue_after_save {
self.continue_after_save = false;
if request.revision == self.document.revision()
&& !self.dirty()
&& let Some(pending) = self.pending.take()
{
return self.proceed(pending);
}
self.pending = None;
}
Task::none()
}
pub(super) fn hosted_event(&mut self, event: Event) -> Task<Message> {
match event {
Event::Show => {
self.hosted.visible = true;
self.model_menu = None;
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
}
Event::Close => self.hosted.visible = false,
Event::Base(base) => {
self.hosted.base = base;
self.hosted.connection = None;
self.hosted.catalog.clear();
self.hosted.invalidate_catalog();
self.hosted.selected = None;
}
Event::Token(token) => {
self.hosted.token = token;
self.hosted.connection = None;
self.hosted.catalog.clear();
self.hosted.selected = None;
self.hosted.invalidate_catalog();
}
Event::Query(query) => {
if self.hosted.query != query {
self.hosted.query = query;
self.hosted.selected = None;
self.hosted.invalidate_catalog();
}
}
Event::Connect => match Connection::new(&self.hosted.base, self.hosted.token.clone()) {
Ok(connection) => {
self.hosted.invalidate_catalog();
return self.hosted_start(Operation::Catalog(false), connection, None);
}
Err(error) => {
self.status = error.detail.clone();
self.hosted.error = Some(error);
}
},
Event::More => {
if !self.hosted.catalog_stale
&& self.hosted.next.is_some()
&& let Some(connection) = &self.hosted.connection
{
return self.hosted_start(Operation::Catalog(true), connection.clone(), None);
}
}
Event::Select(id) => self.hosted.selected = Some(id),
Event::OpenSelected => {
if let (Some(connection), Some(id)) =
(&self.hosted.connection, self.hosted.selected)
{
self.hosted.open_target = Some((connection.clone(), id));
self.hosted.visible = false;
return self.handle(Message::Request(Pending::HostedOpen));
}
}
Event::Publish => {
if !self.hosted_ready() {
return Task::none();
}
let connection = match Connection::new(&self.hosted.base, self.hosted.token.clone())
{
Ok(connection) => connection,
Err(error) => {
self.status = error.detail.clone();
self.hosted.error = Some(error);
return Task::none();
}
};
return self.hosted_start(Operation::Publish, connection, None);
}
Event::Save => return self.save_hosted(),
Event::Check => return self.check_hosted(),
Event::ReviewFile(path) => {
if let Some(source) = self
.hosted
.review
.as_ref()
.and_then(|remote| remote.remote.compiled.bundle.files.get(&path))
{
self.hosted.review_editor = text_editor::Content::with_text(source);
self.hosted.review_path = Some(path);
}
}
Event::ReviewAction(action) => {
if !action.is_edit() {
self.hosted.review_editor.perform(action);
}
}
Event::ReviewBack => self.hosted.review_path = None,
Event::Reload => {
if !self.hosted_ready() {
return Task::none();
}
if let Some(review) = self.hosted.review.clone() {
let remote = review.remote;
let connection = review.connection;
{
match self.document.apply_compiled((*remote.compiled).clone()) {
Ok(_) => {
self.document.mark_saved(remote.compiled.clone());
if self.path.is_some()
|| self.hosted.session.as_ref().is_none_or(|session| {
session.resource_id != remote.resource_id
|| session.connection.base() != connection.base()
})
{
self.source_workspace.reset_navigation_session();
}
self.hosted.session = Some(Session {
connection,
resource_id: remote.resource_id,
revision: remote.revision,
etag: remote.etag,
});
self.path = None;
self.disk_project = None;
self.hosted.review = None;
self.hosted.review_path = None;
self.hosted.uncertain = None;
self.hosted.error = None;
self.sync();
self.hosted.visible = false;
self.status = "Server version loaded. Undo restores the previous accepted local version.".into();
}
Err(error) => self.hosted_problem(error.to_string()),
}
}
}
}
Event::Local => {
self.hosted.visible = false;
return self.handle(Message::Save(true));
}
Event::Forget => {
if self.hosted.session.is_some() {
self.hosted_problem("Save as local before disconnecting this hosted model.");
} else {
self.hosted = Default::default();
self.hosted.visible = true;
}
}
Event::Completed(id, document, result) => {
let Some(request) = self.hosted.request.as_ref() else {
return Task::none();
};
if request.id != id
|| request.document != document
|| document != self.canvas_document
{
return Task::none();
}
let request = self.hosted.request.take().expect("matching request");
self.busy = false;
// A catalog is a read snapshot, never a live mirror. Known or
// uncertain writes and observed newer revisions invalidate only
// the service whose results are currently displayed.
let catalog_changed = match &result {
Ok(Outcome::Remote(remote)) => {
matches!(request.operation, Operation::Publish | Operation::Save)
// A changed bound head can enter/leave the active
// filter or page even when no row was displayed.
|| self.hosted.session.as_ref().is_some_and(|session| {
session.connection.base() == request.connection.base()
&& session.resource_id == remote.resource_id
&& session.etag != remote.etag
})
|| self.hosted.uncertain.as_ref().is_some_and(|failed| {
failed.operation == Operation::Save
&& failed.connection.base() == request.connection.base()
&& failed.resource_id == Some(remote.resource_id)
&& failed.etag.as_ref().is_some_and(|etag| etag != &remote.etag)
})
|| self.hosted.catalog.iter().any(|item| {
item.resource_id == remote.resource_id
&& item.revision != remote.revision
})
}
Err(error) => {
matches!(request.operation, Operation::Publish | Operation::Save)
&& (error.uncertain || error.status == Some(412))
}
Ok(Outcome::Catalog(_)) => false,
};
if catalog_changed
&& self
.hosted
.connection
.as_ref()
.is_some_and(|connection| connection.base() == request.connection.base())
{
self.hosted.invalidate_catalog();
}
match result {
Err(error) => {
let conflict =
error.status == Some(412) && request.operation == Operation::Save;
if error.uncertain || conflict {
let mut failed = request.clone();
if let Some(id) = error.published {
failed.resource_id = Some(id);
}
if matches!(request.operation, Operation::Save | Operation::Publish) {
self.hosted.uncertain = Some(failed);
}
}
self.status = error.detail.clone();
self.hosted.error = Some(error);
self.hosted.visible = true;
self.continue_after_save = false;
self.pending = None;
if conflict {
return self.check_hosted();
}
}
Ok(Outcome::Catalog(catalog)) => {
if let Some(session) = &mut self.hosted.session
&& session.connection.base() == request.connection.base()
{
session.connection = request.connection.clone();
}
if let Some(failed) = &mut self.hosted.uncertain
&& failed.connection.base() == request.connection.base()
{
failed.connection = request.connection.clone();
}
if let Some(review) = &mut self.hosted.review
&& review.connection.base() == request.connection.base()
{
review.connection = request.connection.clone();
}
self.hosted.connection = Some(request.connection);
if request.operation != Operation::Catalog(true) {
self.hosted.catalog.clear();
}
for item in catalog.items {
if !self
.hosted
.catalog
.iter()
.any(|old| old.resource_id == item.resource_id)
{
self.hosted.catalog.push(item);
}
}
self.hosted.next = catalog.next_after;
self.hosted.catalog_stale = false;
if !self
.hosted
.catalog
.iter()
.any(|item| Some(item.resource_id) == self.hosted.selected)
{
self.hosted.selected = None;
}
self.status = format!("{} hosted models shown", self.hosted.catalog.len());
}
Ok(Outcome::Remote(remote)) => match request.operation {
Operation::Open(_) => {
match Document::from_bundle(remote.compiled.bundle.clone(), LIMITS) {
Ok(document) => {
self.reset(document.project().clone(), None);
self.document = document;
self.source_workspace =
Workspace::new(self.document.bundle(), true);
self.sync();
self.hosted.session = Some(Session {
connection: request.connection,
resource_id: remote.resource_id,
revision: remote.revision,
etag: remote.etag,
});
self.status = "Hosted source project opened. Edits and Undo stay local until Save to server.".into();
}
Err(error) => self.hosted_problem(error.to_string()),
}
}
Operation::Publish | Operation::Save => {
return self.acknowledge_hosted(&request, remote);
}
Operation::Check => {
let acknowledged = self
.hosted
.uncertain
.as_ref()
.filter(|failed| {
failed.connection.base() == request.connection.base()
&& failed.resource_id == Some(remote.resource_id)
&& failed.snapshot.bundle == remote.compiled.bundle
})
.cloned();
if let Some(failed) = acknowledged {
return self.acknowledge_hosted(&failed, remote);
}
if self.hosted.session.as_ref().is_some_and(|session| {
session.connection.base() == request.connection.base()
&& session.resource_id == remote.resource_id
&& session.etag == remote.etag
}) {
self.hosted.review = None;
self.hosted.review_path = None;
self.status =
"The server revision is unchanged. Local edits are retained."
.into();
} else {
self.status = format!(
"Server revision {} is available. Review before replacing local work.",
remote.revision
);
self.hosted.review = Some(Review {
connection: request.connection.clone(),
remote,
});
self.hosted.review_path = None;
self.hosted.visible = true;
}
}
Operation::Catalog(_) => {
self.hosted_problem("Unexpected response to catalog request.")
}
},
}
}
}
Task::none()
}
}
#[cfg(test)]
mod tests;
+817
View File
@@ -0,0 +1,817 @@
use super::*;
use crate::hosted::client::Secret;
fn client() -> Connection {
Connection::new("http://127.0.0.1:8787", Secret::default()).unwrap()
}
fn send(app: &mut App, event: Event) {
let _ = app.update(Message::Hosted(event));
}
fn rename(app: &mut App, name: &str) {
app.document
.execute(Command::Rename(app.project().id, name.into()))
.unwrap();
app.sync();
}
fn bind(app: &mut App) -> Id {
let resource = Id::new_v4();
app.hosted.session = Some(Session {
connection: client(),
resource_id: resource,
revision: 1,
etag: "\"opaque-base\"".into(),
});
resource
}
fn remote(snapshot: Arc<CompiledSourceProject>, resource_id: Id, revision: u64) -> Remote {
Remote {
resource_id,
revision,
etag: format!("\"opaque-{revision}\""),
compiled: snapshot,
}
}
fn complete(app: &mut App, result: Result<Outcome, Failure>) {
let r = app.hosted.request.as_ref().unwrap();
send(app, Event::Completed(r.id, r.document, result));
}
fn failure(status: Option<u16>, uncertain: bool) -> Failure {
Failure {
detail: "Test failure".into(),
code: "test".into(),
status,
uncertain,
published: None,
}
}
#[test]
fn hosted_publish_acknowledges_only_the_sent_snapshot_and_retains_undo() {
let mut app = App::with_project(Project::empty());
rename(&mut app, "Published revision");
app.path = Some(PathBuf::from("local.arcadia"));
send(&mut app, Event::Publish);
let captured = app.hosted.request.as_ref().unwrap().snapshot.clone();
assert!(app.busy);
rename(&mut app, "Later local edit");
let resource = Id::new_v4();
complete(&mut app, Ok(Outcome::Remote(remote(captured, resource, 1))));
assert!(!app.busy);
assert_eq!(app.hosted.session.as_ref().unwrap().resource_id, resource);
assert!(app.path.is_none());
assert_eq!(app.project().name, "Later local edit");
assert!(app.document.dirty());
let _ = app.update(Message::Undo);
assert_eq!(app.project().name, "Published revision");
assert!(!app.document.dirty());
}
#[test]
fn hosted_conflict_fetches_review_without_adoption_and_reload_is_undoable() {
let mut app = App::with_project(Project::empty());
let resource = bind(&mut app);
let mut other = Document::from_bundle(app.document.bundle().clone(), LIMITS).unwrap();
other
.execute(Command::Rename(other.project().id, "Other engineer".into()))
.unwrap();
rename(&mut app, "Local engineering work");
let local = app.document.snapshot();
send(&mut app, Event::Save);
complete(&mut app, Err(failure(Some(412), false)));
assert_eq!(
app.hosted.request.as_ref().unwrap().operation,
Operation::Check
);
assert!(Arc::ptr_eq(&local, &app.document.snapshot()));
complete(
&mut app,
Ok(Outcome::Remote(remote(other.snapshot(), resource, 2))),
);
assert!(Arc::ptr_eq(&local, &app.document.snapshot()));
assert_eq!(app.hosted.session.as_ref().unwrap().etag, "\"opaque-base\"");
send(&mut app, Event::Save);
assert!(!app.busy);
assert!(app.hosted.request.is_none());
send(&mut app, Event::Reload);
assert_eq!(app.project().name, "Other engineer");
assert!(!app.document.dirty());
let _ = app.update(Message::Undo);
assert_eq!(app.project().name, "Local engineering work");
assert!(app.document.dirty());
}
#[test]
fn hosted_unknown_save_is_acknowledged_by_exact_readback_without_retry() {
let mut app = App::with_project(Project::empty());
let resource = bind(&mut app);
rename(&mut app, "Saved despite lost response");
send(&mut app, Event::Save);
let captured = app.hosted.request.as_ref().unwrap().snapshot.clone();
complete(&mut app, Err(failure(None, true)));
assert!(app.document.dirty());
assert!(!app.busy);
assert!(app.hosted.request.is_none());
send(&mut app, Event::Check);
complete(&mut app, Ok(Outcome::Remote(remote(captured, resource, 2))));
assert!(!app.document.dirty());
assert!(app.hosted.uncertain.is_none());
assert_eq!(app.hosted.session.as_ref().unwrap().revision, 2);
}
#[test]
fn hosted_stale_completions_do_not_clear_current_busy_or_adopt_another_document() {
let mut app = App::with_project(Project::empty());
bind(&mut app);
send(&mut app, Event::Save);
let request = app.hosted.request.as_ref().unwrap().clone();
send(
&mut app,
Event::Completed(Id::new_v4(), request.document, Err(failure(None, true))),
);
assert!(app.busy);
assert_eq!(app.hosted.request.as_ref().unwrap().id, request.id);
app.reset(Project::empty(), None);
let current = app.document.snapshot();
send(
&mut app,
Event::Completed(
request.id,
request.document,
Ok(Outcome::Remote(remote(request.snapshot, Id::new_v4(), 3))),
),
);
assert!(Arc::ptr_eq(&current, &app.document.snapshot()));
assert!(app.hosted.session.is_none());
}
#[test]
fn hosted_invalid_and_detached_source_drafts_never_start_network_writes() {
let mut app = App::with_project(Project::empty());
bind(&mut app);
app.source_workspace
.edit("arcadia 5; ?".into(), app.source_editor.cursor());
let draft = app.source_workspace.bundle();
let accepted = app.document.snapshot();
send(&mut app, Event::Save);
assert!(app.hosted.request.is_none());
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
assert_eq!(app.source_workspace.bundle(), draft);
assert!(!app.busy);
}
#[test]
fn hosted_open_waits_for_unsaved_guard_and_failed_open_preserves_current_document() {
let mut app = App::with_project(Project::empty());
rename(&mut app, "Keep this work");
app.hosted.connection = Some(client());
app.hosted.selected = Some(Id::new_v4());
send(&mut app, Event::OpenSelected);
assert!(matches!(app.pending, Some(Pending::HostedOpen)));
assert!(app.hosted.request.is_none());
let _ = app.update(Message::Discard);
let previous = app.document.snapshot();
complete(&mut app, Err(failure(Some(401), false)));
assert!(Arc::ptr_eq(&previous, &app.document.snapshot()));
assert_eq!(app.project().name, "Keep this work");
assert!(app.document.dirty());
}
#[test]
fn hosted_only_successful_source_save_as_changes_the_active_destination() {
let mut app = App::with_project(Project::empty());
let resource = bind(&mut app);
rename(&mut app, "Local fallback");
let failure = SaveFailure {
document: app.canvas_document,
message: "Disk failed".into(),
recovery: None,
};
let _ = app.update(Message::Saved(Err(failure)));
assert_eq!(app.hosted.session.as_ref().unwrap().resource_id, resource);
let directory = tempfile::tempdir().unwrap();
let target = directory.path().join(&app.document.bundle().entry);
let receipt = write_snapshot(
app.canvas_document,
app.document.revision(),
app.document.snapshot(),
target.clone(),
None,
)
.unwrap();
let _ = app.update(Message::Saved(Ok(Some(receipt))));
assert!(app.hosted.session.is_none());
assert_eq!(app.path, Some(target));
assert!(!app.document.dirty());
assert!(app.document.can_undo());
}
#[test]
fn hosted_modal_blocks_underlying_edits_and_credentials_are_redacted() {
let mut app = App::with_project(Project::empty());
send(&mut app, Event::Show);
let original = app.document.snapshot();
let _ = app.update(Message::Command(Command::Rename(
app.project().id,
"Underlying edit".into(),
)));
assert!(Arc::ptr_eq(&original, &app.document.snapshot()));
let token = "a-secret-never-in-a-diagnostic";
let event = Message::Hosted(Event::Token(Secret(token.into())));
assert!(!format!("{event:?}").contains(token));
let _ = app.update(event);
send(&mut app, Event::Forget);
assert!(app.hosted.token.0.is_empty());
}
#[test]
fn hosted_detached_drafts_and_recovery_remain_available_after_rejected_publish() {
let mut app = App::with_project(Project::empty());
let mut bundle = app.document.bundle().clone();
bundle
.files
.insert("detached.arcadia".into(), "// kept draft".into());
let reachable = app.document.bundle().files.keys().cloned().collect();
app.source_workspace.discovered(&bundle, reachable);
app.source_workspace
.switch("detached.arcadia", app.source_editor.cursor());
app.source_workspace.edit(
"// unsaved detached rationale".into(),
app.source_editor.cursor(),
);
send(&mut app, Event::Publish);
assert!(app.hosted.request.is_none());
assert!(app.source_workspace.detached_dirty());
assert_eq!(
app.source_workspace.files["detached.arcadia"].text,
"// unsaved detached rationale"
);
app.pending_recovery = Some(PathBuf::from("interrupted.arcadia"));
send(&mut app, Event::Publish);
assert!(app.hosted.request.is_none());
assert_eq!(
app.pending_recovery,
Some(PathBuf::from("interrupted.arcadia"))
);
}
#[test]
fn hosted_failed_cross_service_publication_checks_and_reloads_its_own_resource() {
let mut app = App::with_project(Project::empty());
let old_id = bind(&mut app);
app.hosted.base = "http://127.0.0.1:9876".into();
rename(&mut app, "New publication");
send(&mut app, Event::Publish);
let new_id = Id::new_v4();
let mut error = failure(None, true);
error.published = Some(new_id);
complete(&mut app, Err(error));
send(&mut app, Event::Check);
let request = app.hosted.request.as_ref().unwrap();
assert_eq!(request.resource_id, Some(new_id));
assert_eq!(request.connection.base(), "http://127.0.0.1:9876/");
let mut changed = Document::from_bundle(app.document.bundle().clone(), LIMITS).unwrap();
changed
.execute(Command::Rename(
changed.project().id,
"Changed after publication".into(),
))
.unwrap();
complete(
&mut app,
Ok(Outcome::Remote(remote(changed.snapshot(), new_id, 2))),
);
assert_eq!(app.hosted.session.as_ref().unwrap().resource_id, old_id);
send(&mut app, Event::Reload);
let session = app.hosted.session.as_ref().unwrap();
assert_eq!(session.resource_id, new_id);
assert_eq!(session.connection.base(), "http://127.0.0.1:9876/");
assert_eq!(app.project().name, "Changed after publication");
}
async fn dispatch(app: &mut App, message: Message) {
use iced::futures::StreamExt;
let mut tasks = vec![app.update(message)];
while let Some(task) = tasks.pop() {
if let Some(mut stream) = iced_runtime::task::into_stream(task) {
while let Some(action) = stream.next().await {
if let iced_runtime::Action::Output(message) = action {
tasks.push(app.update(message));
}
}
}
}
}
#[tokio::test]
async fn hosted_actual_app_tasks_publish_edit_conflict_correct_credentials_reload_and_open() {
let directory = tempfile::tempdir().unwrap();
let store = Arc::new(oa_server::store::Store::open(directory.path()).unwrap());
let token = "current-secret-for-hosted-regression-0123456789";
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let base = format!("http://{}", listener.local_addr().unwrap());
let (stop, done) = tokio::sync::oneshot::channel::<()>();
let server = tokio::spawn(async move {
axum::serve(listener, oa_server::app(store, Some(token.into())))
.with_graceful_shutdown(async {
let _ = done.await;
})
.await
.unwrap();
});
let mut app = App::with_project(Project::empty());
let mut bundle = app.document.bundle().clone();
bundle
.files
.get_mut(&bundle.entry)
.unwrap()
.insert_str(0, "// Hosted rationale\n");
app.document.apply_bundle(bundle).unwrap();
app.sync();
app.hosted.base = base.clone();
app.hosted.token = Secret(token.into());
dispatch(&mut app, Message::Hosted(Event::Connect)).await;
assert!(!app.hosted.catalog_stale);
assert!(app.hosted.catalog.is_empty());
assert!(
app.hosted
.catalog_message()
.unwrap()
.starts_with("No models found")
);
dispatch(&mut app, Message::Hosted(Event::Publish)).await;
assert!(app.hosted.catalog_stale);
assert!(
!app.hosted
.catalog_message()
.unwrap()
.starts_with("No models found")
);
dispatch(&mut app, Message::Hosted(Event::Close)).await;
dispatch(&mut app, Message::Hosted(Event::Show)).await;
assert!(app.hosted.catalog_stale);
assert!(
app.hosted.request.is_none(),
"Showing the dialog does not poll"
);
dispatch(&mut app, Message::Hosted(Event::Connect)).await;
assert!(!app.hosted.catalog_stale);
assert_eq!(app.hosted.catalog.len(), 1);
assert_eq!(app.hosted.catalog[0].revision, 1);
dispatch(&mut app, Message::Hosted(Event::Close)).await;
let session = app
.hosted
.session
.clone()
.expect("publish binds a resource");
assert!(!app.busy);
assert!(!app.document.dirty());
assert!(
app.document.bundle().files[&app.document.bundle().entry].contains("// Hosted rationale")
);
let project_id = app.project().id;
dispatch(
&mut app,
Message::Command(Command::Rename(project_id, "Graphical update".into())),
)
.await;
dispatch(&mut app, Message::Save(false)).await;
assert!(app.hosted.catalog_stale);
assert_eq!(
app.hosted.catalog[0].revision, 1,
"Old results stay identified as a snapshot"
);
dispatch(&mut app, Message::Hosted(Event::Connect)).await;
assert!(!app.hosted.catalog_stale);
assert_eq!(app.hosted.catalog[0].revision, 2);
assert_eq!(app.hosted.catalog[0].name, "Graphical update");
let external = Connection::new(&base, Secret(token.into())).unwrap();
let remote = external.open(session.resource_id).await.unwrap();
assert_eq!(remote.compiled.decoded.project.name, "Graphical update");
let mut other = Document::from_bundle(remote.compiled.bundle.clone(), LIMITS).unwrap();
other
.execute(Command::Rename(project_id, "API engineering change".into()))
.unwrap();
external
.save(session.resource_id, &remote.etag, other.bundle().clone())
.await
.unwrap();
dispatch(
&mut app,
Message::Command(Command::Rename(project_id, "Local pending change".into())),
)
.await;
dispatch(&mut app, Message::Save(false)).await;
assert!(app.hosted.review.is_some());
assert_eq!(app.project().name, "Local pending change");
assert!(app.document.dirty());
// Simulate a retired credential in every preexisting reconciliation handle.
let old =
Connection::new(&base, Secret("retired-secret-012345678901234567890".into())).unwrap();
app.hosted.session.as_mut().unwrap().connection = old.clone();
app.hosted.uncertain.as_mut().unwrap().connection = old.clone();
app.hosted.review.as_mut().unwrap().connection = old;
dispatch(&mut app, Message::Hosted(Event::Connect)).await;
let reconciled_write = app.hosted.uncertain.clone().unwrap();
// Reload the captured review directly. A fresh Check must not hide a stale
// credential in that review by replacing it with a newer response.
dispatch(&mut app, Message::Hosted(Event::Reload)).await;
assert_eq!(app.project().name, "API engineering change");
dispatch(&mut app, Message::Undo).await;
assert_eq!(app.project().name, "Local pending change");
dispatch(&mut app, Message::Save(false)).await;
assert!(!app.document.dirty());
// Independently exercise the corrected uncertain-write connection.
app.hosted.uncertain = Some(reconciled_write);
dispatch(&mut app, Message::Hosted(Event::Check)).await;
assert!(app.hosted.error.is_none());
assert!(app.hosted.uncertain.is_none());
let accepted = external.open(session.resource_id).await.unwrap();
assert_eq!(accepted.compiled.bundle, *app.document.bundle());
assert_eq!(
accepted.compiled.decoded.project.name,
"Local pending change"
);
let mut second = App::with_project(Project::empty());
second.hosted.base = base;
second.hosted.token = Secret(token.into());
dispatch(&mut second, Message::Hosted(Event::Connect)).await;
assert!(
second
.hosted
.catalog
.iter()
.any(|item| item.resource_id == session.resource_id)
);
send(&mut second, Event::Select(session.resource_id));
dispatch(&mut second, Message::Hosted(Event::OpenSelected)).await;
assert_eq!(second.document.bundle(), app.document.bundle());
assert_eq!(
second.hosted.session.as_ref().unwrap().resource_id,
session.resource_id
);
dispatch(
&mut second,
Message::Command(Command::Rename(project_id, "Saved before New".into())),
)
.await;
dispatch(&mut second, Message::Request(Pending::New)).await;
assert!(second.pending.is_some());
dispatch(&mut second, Message::SaveAndContinue).await;
assert!(second.hosted.session.is_none());
assert!(second.pending.is_none());
assert_ne!(second.project().id, project_id);
assert_eq!(
external
.open(session.resource_id)
.await
.unwrap()
.compiled
.decoded
.project
.name,
"Saved before New"
);
drop(external);
drop(app);
drop(second);
let _ = stop.send(());
tokio::time::timeout(std::time::Duration::from_secs(5), server)
.await
.unwrap()
.unwrap();
}
#[tokio::test]
async fn hosted_invalid_local_open_repair_saves_locally_and_never_replaces_old_resource() {
let directory = tempfile::tempdir().unwrap();
let store = Arc::new(oa_server::store::Store::open(directory.path()).unwrap());
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let base = format!("http://{}", listener.local_addr().unwrap());
let (stop, done) = tokio::sync::oneshot::channel::<()>();
let server = tokio::spawn(async move {
axum::serve(listener, oa_server::app(store, None))
.with_graceful_shutdown(async {
let _ = done.await;
})
.await
.unwrap();
});
let mut app = App::with_project(Project::empty());
app.hosted.base = base.clone();
dispatch(&mut app, Message::Hosted(Event::Publish)).await;
let session = app.hosted.session.clone().unwrap();
let accepted = app.document.snapshot();
let original_identity = app.canvas_document;
let external = Connection::new(&base, Secret::default()).unwrap();
let old = external.open(session.resource_id).await.unwrap();
let local = tempfile::tempdir().unwrap();
let path = local.path().join("repair.arcadia");
let invalid = "arcadia 5; ? // unfinished local work";
std::fs::write(&path, invalid).unwrap();
let path = path.canonicalize().unwrap();
dispatch(
&mut app,
Message::Loaded(Ok(Some(read_snapshot(path.clone()).unwrap()))),
)
.await;
assert_ne!(app.canvas_document, original_identity);
assert_eq!(app.path.as_ref(), Some(&path));
assert!(!app.source_workspace.applied);
assert_eq!(app.source_editor.text(), invalid);
assert!(
Arc::ptr_eq(&accepted, &app.document.snapshot()),
"A repair workspace must preserve the last valid graphical model"
);
assert!(!app.source_diagnostics.is_empty());
assert!(
app.hosted.session.is_none(),
"The installed local repair workspace must own its save destination"
);
let mut repaired = Project::empty();
repaired.name = "Independent local repair".into();
let repaired_source = language::format(&repaired);
dispatch(
&mut app,
Message::SourceEdit(text_editor::Action::SelectAll),
)
.await;
dispatch(
&mut app,
Message::SourceEdit(text_editor::Action::Edit(text_editor::Edit::Paste(
Arc::new(repaired_source),
))),
)
.await;
dispatch(&mut app, Message::ApplySource).await;
assert_eq!(app.project(), &repaired);
assert!(!app.source_dirty());
dispatch(&mut app, Message::Save(false)).await;
let remote_after = external.open(session.resource_id).await.unwrap();
assert_eq!(
remote_after.etag, old.etag,
"Saving a repaired local workspace must never target the old hosted resource"
);
assert_eq!(remote_after.compiled.bundle, old.compiled.bundle);
assert!(app.hosted.session.is_none());
assert!(app.hosted.review.is_none());
assert!(app.hosted.uncertain.is_none());
assert!(app.hosted.request.is_none());
assert_eq!(app.path.as_ref(), Some(&path));
assert!(!app.dirty());
let saved = source_files::load_project(&path, LIMITS).unwrap();
assert_eq!(saved.reachable_bundle(), *app.document.bundle());
assert_eq!(
app.hosted.base, base,
"Connection settings remain available in memory"
);
drop(external);
drop(app);
let _ = stop.send(());
tokio::time::timeout(std::time::Duration::from_secs(5), server)
.await
.unwrap()
.unwrap();
}
#[test]
fn hosted_failed_or_cancelled_local_open_keeps_the_existing_document_and_destination() {
let mut app = App::with_project(Project::empty());
let resource = bind(&mut app);
let accepted = app.document.snapshot();
let identity = app.canvas_document;
let source = app.source_workspace.bundle();
let directory = tempfile::tempdir().unwrap();
let unreadable = directory.path().join("missing.json");
let error = read_snapshot(unreadable).unwrap_err();
for result in [Ok(None), Err(error)] {
let _ = app.update(Message::Loaded(result));
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
assert_eq!(app.canvas_document, identity);
assert_eq!(app.source_workspace.bundle(), source);
assert!(app.path.is_none());
assert_eq!(app.hosted.session.as_ref().unwrap().resource_id, resource);
}
}
fn catalog_result(app: &App, resource: Id, revision: u64) -> Outcome {
Outcome::Catalog(crate::hosted::client::Catalog {
items: vec![crate::hosted::client::Summary {
resource_id: resource,
revision,
model_id: app.project().id,
name: format!("Catalog snapshot {revision}"),
elements: app.project().elements.len(),
relations: app.project().relations.len(),
}],
next_after: Some(resource),
})
}
#[test]
fn hosted_catalog_search_credentials_and_failed_refresh_never_mix_result_scopes() {
let mut app = App::with_project(Project::empty());
let first = Id::new_v4();
send(&mut app, Event::Connect);
let catalog = catalog_result(&app, first, 1);
complete(&mut app, Ok(catalog));
assert!(!app.hosted.catalog_stale);
send(&mut app, Event::More);
assert_eq!(
app.hosted.request.as_ref().unwrap().operation,
Operation::Catalog(true)
);
let next_page = catalog_result(&app, Id::new_v4(), 1);
complete(&mut app, Ok(next_page));
assert_eq!(app.hosted.catalog.len(), 2);
send(&mut app, Event::Select(first));
send(&mut app, Event::Query("different search".into()));
assert!(app.hosted.catalog_stale);
assert!(app.hosted.next.is_none());
assert!(app.hosted.selected.is_none());
assert_eq!(app.hosted.catalog.len(), 2);
assert!(
app.hosted
.catalog_message()
.unwrap()
.contains("Connect / refresh")
);
send(&mut app, Event::More);
assert!(app.hosted.request.is_none());
send(&mut app, Event::Connect);
complete(&mut app, Err(failure(None, false)));
assert!(app.hosted.catalog_stale);
assert_eq!(app.hosted.catalog[0].resource_id, first);
send(&mut app, Event::Connect);
let second = Id::new_v4();
let catalog = catalog_result(&app, second, 1);
complete(&mut app, Ok(catalog));
assert!(!app.hosted.catalog_stale);
assert_eq!(app.hosted.catalog.len(), 1);
assert_eq!(app.hosted.catalog[0].resource_id, second);
send(&mut app, Event::Query("different search".into()));
assert!(
!app.hosted.catalog_stale,
"Unchanged query preserves the snapshot"
);
send(
&mut app,
Event::Token(Secret("different-credential".into())),
);
assert!(app.hosted.catalog_stale);
assert!(app.hosted.catalog.is_empty());
assert!(app.hosted.connection.is_none());
assert!(app.hosted.next.is_none());
assert!(app.hosted.selected.is_none());
assert!(
!app.hosted
.catalog_message()
.unwrap()
.starts_with("No models found")
);
send(&mut app, Event::Connect);
let catalog = catalog_result(&app, second, 2);
complete(&mut app, Ok(catalog));
send(&mut app, Event::Base("http://127.0.0.1:9876".into()));
assert!(app.hosted.catalog.is_empty());
assert!(app.hosted.connection.is_none());
assert!(app.hosted.catalog_stale);
assert!(app.hosted.request.is_none());
}
#[test]
fn hosted_catalog_invalidation_follows_service_writes_and_observed_revision_changes() {
let mut app = App::with_project(Project::empty());
let resource = bind(&mut app);
let other = Id::new_v4();
send(&mut app, Event::Base("http://127.0.0.1:9876".into()));
send(&mut app, Event::Connect);
let catalog = catalog_result(&app, other, 7);
complete(&mut app, Ok(catalog));
rename(&mut app, "Saved to original service");
send(&mut app, Event::Save);
let saved = remote(app.document.snapshot(), resource, 2);
complete(&mut app, Ok(Outcome::Remote(saved)));
assert!(
!app.hosted.catalog_stale,
"A write to A cannot invalidate B's catalog"
);
assert_eq!(app.hosted.next, Some(other));
assert_eq!(app.hosted.catalog[0].resource_id, other);
send(&mut app, Event::Base("http://127.0.0.1:8787".into()));
send(&mut app, Event::Connect);
let catalog = catalog_result(&app, resource, 2);
complete(&mut app, Ok(catalog));
send(&mut app, Event::Check);
let changed = remote(app.document.snapshot(), resource, 3);
complete(&mut app, Ok(Outcome::Remote(changed)));
assert!(
app.hosted.catalog_stale,
"A known newer server revision invalidates old metadata"
);
assert!(app.hosted.next.is_none());
send(&mut app, Event::Connect);
let catalog = catalog_result(&app, resource, 3);
complete(&mut app, Ok(catalog));
send(&mut app, Event::Reload);
rename(&mut app, "Write with uncertain outcome");
send(&mut app, Event::Save);
complete(&mut app, Err(failure(None, true)));
assert!(app.hosted.catalog_stale);
assert!(app.hosted.next.is_none());
assert_eq!(app.hosted.catalog[0].revision, 3);
app.hosted.leave_document();
assert!(
app.hosted.catalog_stale,
"Leaving the hosted destination cannot make old results fresh"
);
}
#[test]
fn hosted_new_revision_invalidates_filtered_catalog_even_without_a_displayed_row() {
let mut app = App::with_project(Project::empty());
rename(&mut app, "Original");
let resource = bind(&mut app);
send(&mut app, Event::Query("Renamed".into()));
send(&mut app, Event::Connect);
complete(
&mut app,
Ok(Outcome::Catalog(crate::hosted::client::Catalog {
items: vec![],
next_after: None,
})),
);
assert!(!app.hosted.catalog_stale);
assert!(
app.hosted
.catalog_message()
.unwrap()
.starts_with("No models found")
);
let mut unchanged = remote(app.document.snapshot(), resource, 1);
unchanged.etag = app.hosted.session.as_ref().unwrap().etag.clone();
send(&mut app, Event::Check);
complete(&mut app, Ok(Outcome::Remote(unchanged)));
assert!(
!app.hosted.catalog_stale,
"An unchanged bound head keeps the filtered snapshot fresh"
);
// A different engineer changes the hosted model so it now matches the filter.
let mut changed = Document::from_bundle(app.document.bundle().clone(), LIMITS).unwrap();
changed
.execute(Command::Rename(changed.project().id, "Renamed".into()))
.unwrap();
send(&mut app, Event::Check);
complete(
&mut app,
Ok(Outcome::Remote(remote(changed.snapshot(), resource, 2))),
);
assert_eq!(app.hosted.review.as_ref().unwrap().remote.revision, 2);
assert!(
app.hosted.request.is_none(),
"No polling or automatic catalog request"
);
assert!(
app.hosted.catalog_stale,
"Known newer revision can change membership in an empty filtered catalog"
);
assert!(
!app.hosted
.catalog_message()
.unwrap()
.starts_with("No models found")
);
}
#[test]
fn hosted_changed_bound_head_does_not_invalidate_another_services_empty_filter() {
let mut app = App::with_project(Project::empty());
let resource = bind(&mut app);
send(&mut app, Event::Base("http://127.0.0.1:9876".into()));
send(&mut app, Event::Query("Renamed".into()));
send(&mut app, Event::Connect);
complete(
&mut app,
Ok(Outcome::Catalog(crate::hosted::client::Catalog {
items: vec![],
next_after: None,
})),
);
send(&mut app, Event::Check);
let changed = remote(app.document.snapshot(), resource, 2);
complete(&mut app, Ok(Outcome::Remote(changed)));
assert!(app.hosted.review.is_some());
assert!(!app.hosted.catalog_stale);
assert!(
app.hosted
.catalog_message()
.unwrap()
.starts_with("No models found")
);
assert!(app.hosted.request.is_none());
}
+670
View File
@@ -0,0 +1,670 @@
use super::*;
use crate::behavior::topology::{self, CanvasEvent};
impl App {
pub(crate) fn topology_context(&self) -> Option<topology::Context> {
let owner = crate::behavior::selected_owner(self)?;
(self.page == Page::Behavior
&& matches!(
self.project().elements.get(&owner)?.kind(),
Kind::Process | Kind::FunctionalChain
))
.then_some(topology::Context {
document: self.canvas_document,
owner,
revision: self.document.revision(),
epoch: self.canvas_epoch,
})
}
pub(super) fn reveal_process_selection(&self) -> Task<Message> {
let Some(context) = self.topology_context() else {
return Task::none();
};
let Some(offset) =
self.behavior
.topology
.selection_offset(self.project(), context.owner, self.selected)
else {
return Task::none();
};
iced::widget::operation::scroll_to("process-canvas", offset)
}
pub(super) fn edit_process_canvas(&mut self, event: CanvasEvent) -> Task<Message> {
let context = event.context();
if let CanvasEvent::Scrolled(_, area, offset) = event {
if self.topology_context().is_some_and(|c| {
c.document == context.document
&& c.owner == context.owner
&& c.revision == context.revision
}) && self.behavior.topology.scrolled(area, offset)
{
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
}
return Task::none();
}
if let CanvasEvent::Viewport(_, size) = event {
if self.topology_context().is_some_and(|c| {
c.document == context.document
&& c.owner == context.owner
&& c.revision == context.revision
}) && self.behavior.topology.viewport(size)
{
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
}
return Task::none();
}
if self.topology_context() != Some(context) {
return Task::none();
}
self.behavior
.topology
.enter(context.document, context.owner);
match event {
CanvasEvent::ExpandQualified(_, endpoint) => {
let mut state = std::mem::take(&mut self.behavior.topology);
let changed = state.expand_context(self.project(), context.owner, endpoint);
self.behavior.topology = state;
if changed {
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
}
}
CanvasEvent::OpenQualified(_, endpoint) => {
if self.behavior.has_pending() || !self.apply() {
return Task::none();
}
let resolved = oa_core::topology_context::TopologyIndex::new(self.project())
.resolve(context.owner, &endpoint);
if let Ok(resolved) = resolved {
self.behavior.topology.remember(
context.owner,
endpoint
.references
.first()
.copied()
.unwrap_or(endpoint.occurrence),
);
self.behavior.active_owner = Some(resolved.defining_sequence);
self.select(endpoint.occurrence);
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
}
}
CanvasEvent::SelectQualified(_, endpoint) => {
if !self.behavior.has_pending()
&& !self.draft_dirty()
&& !self.source_dirty()
&& let Ok(resolved) =
oa_core::topology_context::TopologyIndex::new(self.project())
.resolve(context.owner, &endpoint)
{
let reference = endpoint
.references
.first()
.copied()
.unwrap_or(endpoint.occurrence);
self.select(reference);
self.behavior.topology.select_context(resolved.address);
}
}
CanvasEvent::Focus(_) => {
self.behavior.topology.focused = !self.behavior.topology.focused;
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
}
CanvasEvent::Select(_, id) => {
if !self.behavior.has_pending()
&& crate::behavior::selected_behavior(self.project(), id) == Some(context.owner)
{
self.select(id);
self.selected_node = None;
self.behavior.topology.clear_context();
}
}
CanvasEvent::Choose(_, tool) => {
if self.behavior.has_pending() || !self.apply() {
return Task::none();
}
self.behavior.topology.choose(tool);
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
}
CanvasEvent::Zoom(_, zoom) => {
self.behavior.topology.zoom(zoom);
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
}
CanvasEvent::Expand(_, id) => {
let mut state = std::mem::take(&mut self.behavior.topology);
let changed = state.expand(self.project(), context.owner, id);
self.behavior.topology = state;
if changed {
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
}
}
CanvasEvent::Open(_, occurrence) => {
if self.behavior.has_pending() || !self.apply() {
return Task::none();
}
if let Some(target) =
topology::referenced(self.project(), context.owner, occurrence)
{
if self
.project()
.elements
.get(&target)
.is_some_and(|e| matches!(e.kind(), Kind::Process | Kind::FunctionalChain))
{
self.behavior.topology.remember(context.owner, occurrence);
self.behavior.active_owner = Some(target);
}
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
self.select(target);
}
}
CanvasEvent::Back(_) => {
if self.behavior.has_pending() || !self.apply() {
return Task::none();
}
let mut state = std::mem::take(&mut self.behavior.topology);
let back = state.back(self.project());
self.behavior.topology = state;
if let Some((owner, occurrence)) = back {
self.behavior.active_owner = Some(owner);
self.select(occurrence);
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
}
}
mutation => {
if self.behavior.has_pending() || self.draft_dirty() || self.source_dirty() {
self.status =
"Apply or cancel pending edits before changing the process.".into();
return Task::none();
}
match mutation {
CanvasEvent::Begin {
tool,
control,
insertion,
..
} => {
let mut state = std::mem::take(&mut self.behavior.topology);
let result = state.begin(self.project(), context, tool, control, insertion);
self.behavior.topology = state;
match result {
Ok(()) => {
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
self.status="Choose the contextual contribution and apply at the captured position.".into();
}
Err(e) => self.status = e,
}
}
CanvasEvent::AppendExchange(_) => {
let mut state = std::mem::take(&mut self.behavior.topology);
let result = state.append_exchange(self.project(), context.owner);
self.behavior.topology = state;
match result {
Ok(()) => {
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
self.status = "Choose the next exchange; Create appends it with an explicit sequence link.".into();
}
Err(e) => self.status = e,
}
}
CanvasEvent::Connect { source, target, .. } => {
let mut state = std::mem::take(&mut self.behavior.topology);
let result = state.connect(self.project(), context.owner, source, target);
self.behavior.topology = state;
match result {
Ok(()) => {
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
self.status = "Describe the path condition, then Create.".into();
}
Err(e) => self.status = e,
}
}
CanvasEvent::Reconnect {
link, end, target, ..
} => {
match topology::reconnect(self.project(), context.owner, link, end, target)
{
Ok(command) => {
if self.execute(command) {
self.selected = link;
self.refresh_draft();
return self.reveal_process_selection();
}
}
Err(e) => self.status = e,
}
}
CanvasEvent::Reorder {
occurrence, before, ..
} => {
match topology::reorder(self.project(), context.owner, occurrence, before) {
Ok(command) => {
if self.execute(command) {
self.selected = occurrence;
self.refresh_draft();
return self.reveal_process_selection();
}
}
Err(e) => self.status = e,
}
}
_ => unreachable!(),
}
}
}
Task::none()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::behavior::{
self,
topology::{
CanvasTool, Event, Insertion, Tool,
direct_tests::{self, Fixture},
},
};
fn setup() -> (App, Fixture) {
let f = direct_tests::fixture(Layer::Oa);
let mut app = App::with_project(f.p.clone());
let _ = app.update(Message::Select(f.owner));
let _ = app.update(Message::Page(Page::Behavior));
assert_eq!(app.topology_context().unwrap().owner, f.owner);
(app, f)
}
fn form(app: &mut App, event: Event) {
let _ = app.update(Message::Behavior(behavior::Event::Topology(event)));
}
fn begin(app: &App) -> CanvasEvent {
CanvasEvent::Begin {
context: app.topology_context().unwrap(),
tool: Tool::Work,
control: ControlKind::Choice,
insertion: Insertion::default(),
}
}
#[test]
fn process_focus_retains_rejected_draft_and_guards_selection_until_panels_restore() {
let (mut app, f) = setup();
let original = app.document.snapshot();
let initial = app.selected;
let event = begin(&app);
let _ = app.update(Message::TopologyCanvas(event));
form(
&mut app,
Event::Name(" unfinished contextual work ".into()),
);
let context = app.topology_context().unwrap();
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Focus(context)));
assert!(app.behavior.topology.focused);
let raw = app.behavior.recovery_snapshot().unwrap();
let context = app.topology_context().unwrap();
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Select(
context, f.nodes[1],
)));
assert_eq!(app.selected, initial);
form(&mut app, Event::Submit);
assert!(app.behavior.has_pending());
assert_eq!(app.behavior.recovery_snapshot().unwrap(), raw);
assert!(Arc::ptr_eq(&original, &app.document.snapshot()));
let context = app.topology_context().unwrap();
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Focus(context)));
assert!(!app.behavior.topology.focused);
assert_eq!(app.behavior.recovery_snapshot().unwrap(), raw);
}
#[test]
fn direct_process_append_empty_first_second_exchange_has_exact_source_history() {
for layer in [Layer::Oa, Layer::Sa] {
let (f, next) = direct_tests::empty_exchange_fixture(layer);
let mut app = App::with_project(f.p.clone());
let _ = app.update(Message::Select(f.owner));
let _ = app.update(Message::Page(Page::Behavior));
let empty = app.document.snapshot();
let context = app.topology_context().unwrap();
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Begin {
context,
tool: Tool::Exchange,
control: ControlKind::Choice,
insertion: Insertion::default(),
}));
assert!(app.behavior.has_pending());
form(
&mut app,
Event::Target(Choice {
id: f.exchange,
label: "Assessment".into(),
}),
);
form(&mut app, Event::Name("First contribution".into()));
form(&mut app, Event::Submit);
assert!(!app.behavior.has_pending(), "{}", app.status);
let first_id = app.selected;
let first = app.document.snapshot();
assert_eq!(
app.project().elements[&f.owner].steps().unwrap(),
&[first_id]
);
assert_eq!(app.project().relations, empty.decoded.project.relations);
let context = app.topology_context().unwrap();
let _ = app.update(Message::TopologyCanvas(CanvasEvent::AppendExchange(
context,
)));
assert!(
app.behavior.has_pending(),
"reachable single-exchange continuation: {}",
app.status
);
assert!(Arc::ptr_eq(&first, &app.document.snapshot()));
form(
&mut app,
Event::Target(Choice {
id: next,
label: "Response".into(),
}),
);
form(&mut app, Event::Name("Second contribution".into()));
form(&mut app, Event::Submit);
assert!(!app.behavior.has_pending(), "{}", app.status);
let second_id = app.selected;
let second = app.document.snapshot();
assert_ne!(first_id, second_id);
assert_eq!(
app.project().elements[&f.owner].steps().unwrap(),
&[first_id, second_id]
);
let links = app
.project()
.relations
.values()
.filter(|r| r.kind() == RelationKind::SequenceLink)
.collect::<Vec<_>>();
assert_eq!(links.len(), 1);
assert_eq!(
(links[0].source(), links[0].target()),
(first_id, second_id)
);
assert_eq!(
app.project().elements.len(),
empty.decoded.project.elements.len() + 2
);
for (id, relation) in &empty.decoded.project.relations {
assert_eq!(app.project().relations[id], *relation);
}
for work in f.work {
assert_eq!(
app.project().elements[&work],
empty.decoded.project.elements[&work]
);
}
let reopened = Document::from_bundle(
second.bundle.clone(),
oa_core::source_project::ProjectLimits::default(),
)
.unwrap();
assert_eq!(reopened.project(), app.project());
assert_eq!(reopened.bundle(), &second.bundle);
for expected in [&first, &empty] {
let _ = app.update(Message::Undo);
assert_eq!(app.document.bundle(), &expected.bundle);
assert_eq!(app.project(), &expected.decoded.project);
}
assert!(
!app.document.can_undo(),
"each Create must add exactly one history entry"
);
for expected in [&first, &second] {
let _ = app.update(Message::Redo);
assert_eq!(app.document.bundle(), &expected.bundle);
assert_eq!(app.project(), &expected.decoded.project);
}
assert!(!app.document.can_redo());
}
}
#[test]
fn direct_process_app_stale_context_dirty_source_and_properties_never_mutate() {
let (mut app, _) = setup();
let snapshot = app.document.snapshot();
for field in 0..4 {
let mut event = begin(&app);
let CanvasEvent::Begin { context, .. } = &mut event else {
unreachable!()
};
match field {
0 => context.document = Id::new_v4(),
1 => context.owner = Id::new_v4(),
2 => context.revision += 1,
_ => context.epoch += 1,
};
let _ = app.update(Message::TopologyCanvas(event));
assert!(!app.behavior.has_pending());
assert!(Arc::ptr_eq(&snapshot, &app.document.snapshot()));
}
let _ = app.update(Message::Field("/name".into(), "Pending owner name".into()));
let event = begin(&app);
let _ = app.update(Message::TopologyCanvas(event));
assert!(!app.behavior.has_pending());
assert!(app.draft_dirty());
let context = app.topology_context().unwrap();
for event in [
CanvasEvent::Zoom(context, Some(0.7)),
CanvasEvent::Viewport(context, iced::Size::new(510., 360.)),
] {
let _ = app.update(Message::TopologyCanvas(event));
assert!(app.draft_dirty());
assert!(Arc::ptr_eq(&snapshot, &app.document.snapshot()));
}
}
#[test]
fn direct_process_app_source_failure_retains_form_then_commits_once_and_selects_occurrence() {
let (mut app, f) = setup();
let bundle = app.document.bundle().clone();
let mut limits = oa_core::source_project::ProjectLimits::default();
limits.model.max_source_bytes =
bundle.files.values().map(String::len).sum::<usize>() + 3000;
app.document = Document::from_bundle(bundle.clone(), limits).unwrap();
app.sync();
let event = begin(&app);
let _ = app.update(Message::TopologyCanvas(event));
form(
&mut app,
Event::Target(Choice {
id: f.work[0],
label: "Assess".into(),
}),
);
form(&mut app, Event::Name("Retain this long input".repeat(1000)));
let _ = app.update(Message::Page(Page::Catalog));
assert_eq!(app.page, Page::Behavior);
form(&mut app, Event::Submit);
assert!(app.behavior.has_pending());
assert_eq!(app.document.bundle(), &bundle);
assert!(!app.document.can_undo());
form(&mut app, Event::Name("Recovered contribution".into()));
form(&mut app, Event::Submit);
assert!(!app.behavior.has_pending());
assert_ne!(app.selected, f.owner);
assert_eq!(app.project().named(app.selected), "Recovered contribution");
let created = app.selected;
let after = app.document.bundle().clone();
let _ = app.update(Message::Undo);
assert_eq!(app.document.bundle(), &bundle);
let _ = app.update(Message::Redo);
assert_eq!(app.document.bundle(), &after);
assert!(app.project().contains(created));
}
#[test]
fn direct_process_app_same_owner_selection_preserves_tool_but_owner_change_resets_it() {
let (mut app, f) = setup();
let context = app.topology_context().unwrap();
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Choose(
context,
CanvasTool::Connect,
)));
let context = app.topology_context().unwrap();
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Select(
context, f.nodes[0],
)));
assert_eq!(app.topology_context(), Some(context));
let other = app
.project()
.make_element(Kind::Process, Layer::Oa, "Other", None)
.unwrap();
let other_id = other.id;
assert!(app.execute(Command::CreateElement(other)));
let _ = app.update(Message::BehaviorOwner(Choice {
id: other_id,
label: "Other".into(),
}));
assert_eq!(app.topology_context().unwrap().owner, other_id);
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Connect {
context,
source: f.nodes[0],
target: f.nodes[2],
}));
assert!(!app.behavior.has_pending());
}
#[test]
fn direct_process_app_save_scroll_resize_and_passive_geometry_keep_drafts_and_current_origin() {
let (mut app, f) = setup();
let context = app.topology_context().unwrap();
let original = app.document.snapshot();
app.busy = true;
for (area, offset) in [
(
behavior::scenario::ScrollArea::Canvas,
iced::Vector::new(25., 100.),
),
(
behavior::scenario::ScrollArea::Page,
iced::Vector::new(0., 40.),
),
] {
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Scrolled(
context, area, offset,
)));
}
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Viewport(
context,
iced::Size::new(500., 300.),
)));
assert_eq!(
app.behavior.topology.scroll_offset(),
iced::Vector::new(25., 140.)
);
assert_ne!(app.topology_context(), Some(context));
assert!(app.busy);
let mut stale = context;
stale.document = Id::new_v4();
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Scrolled(
stale,
behavior::scenario::ScrollArea::Canvas,
iced::Vector::new(0., 900.),
)));
assert_eq!(
app.behavior.topology.scroll_offset(),
iced::Vector::new(25., 140.)
);
let root = PathBuf::from("process-scroll-save-fixture");
let receipt = FileSnapshot {
document: context.document,
revision: context.revision,
snapshot: original.clone(),
path: root.join(&original.bundle.entry),
disk: Some(source_files::DiskProject {
root,
bundle: original.bundle.clone(),
expectations: Default::default(),
reachable: original.bundle.files.keys().cloned().collect(),
diagnostics: vec![],
}),
};
let _ = app.update(Message::Saved(Ok(Some(receipt))));
assert!(!app.busy);
assert!(!app.document.can_undo());
assert_eq!(app.project(), &f.p);
let context = app.topology_context().unwrap();
let size = app.window_size;
let _ = app.update(Message::WindowResized(size));
assert_eq!(app.topology_context(), Some(context));
let _ = app.update(Message::WindowResized(iced::Size::new(
size.width + 20.,
size.height,
)));
assert_ne!(app.topology_context(), Some(context));
let event = begin(&app);
let _ = app.update(Message::TopologyCanvas(event));
form(&mut app, Event::Name("Keep this form".into()));
let context = app.topology_context().unwrap();
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Scrolled(
context,
behavior::scenario::ScrollArea::Page,
iced::Vector::new(0., 80.),
)));
assert!(app.behavior.has_pending());
assert_eq!(app.document.bundle(), &original.bundle);
}
#[test]
fn direct_process_app_open_source_and_back_preserve_local_reference_identity() {
let (mut app, f) = setup();
let source = app
.project()
.make_element(Kind::Process, Layer::Oa, "Reusable source", None)
.unwrap();
let source_id = source.id;
assert!(app.execute(Command::CreateElement(source)));
let mut occurrence = app
.project()
.make_element(Kind::Occurrence, Layer::Oa, "Local use", Some(f.owner))
.unwrap();
let occurrence_id = occurrence.id;
if let Some(SharedBody::Occurrence { body, .. }) = occurrence.shared_mut() {
*body = OccurrenceBody::SequenceReference {
sequence: Some(source_id),
};
}
assert!(app.execute(Command::CreateElement(occurrence)));
let snapshot = app.document.snapshot();
let context = app.topology_context().unwrap();
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Open(
context,
occurrence_id,
)));
assert_eq!(app.selected, source_id);
assert_eq!(app.topology_context().unwrap().owner, source_id);
let context = app.topology_context().unwrap();
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Back(context)));
assert_eq!(app.selected, occurrence_id);
assert_eq!(app.topology_context().unwrap().owner, f.owner);
assert!(Arc::ptr_eq(&snapshot, &app.document.snapshot()));
}
}
#[cfg(test)]
#[test]
fn direct_process_dirty_source_blocks_canvas_creation_without_losing_text() {
let f = topology::direct_tests::fixture(Layer::Oa);
let mut app = App::with_project(f.p.clone());
let _ = app.update(Message::Select(f.owner));
let _ = app.update(Message::Page(Page::Behavior));
let original = app.document.snapshot();
let edited = format!(
"{}\n// Retained source draft\n",
app.source_workspace.text()
);
let _ = app.update(Message::SourceEdit(text_editor::Action::SelectAll));
let _ = app.update(Message::SourceEdit(text_editor::Action::Edit(
text_editor::Edit::Paste(edited.clone().into()),
)));
assert!(app.source_dirty());
let context = app.topology_context().unwrap();
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Begin {
context,
tool: topology::Tool::Work,
control: ControlKind::Choice,
insertion: topology::Insertion::default(),
}));
assert!(!app.behavior.has_pending());
assert!(app.source_dirty());
assert_eq!(app.source_workspace.text(), edited);
assert!(Arc::ptr_eq(&original, &app.document.snapshot()));
}
@@ -0,0 +1,263 @@
use super::*;
const VALID: &str = concat!(
"arcadia 7;\r\n",
"project repair \"Repair 救援\" id \"9e8f0000-0000-4000-8000-000000000001\" {\r\n",
" oa_activity assess \"Assess\" id \"9e8f0000-0000-4000-8000-000000000002\" {}\r\n",
"}\r\n",
);
fn open(app: &mut App, path: &std::path::Path) {
let _ = app.update(Message::Loaded(Ok(Some(
read_snapshot(path.to_owned()).unwrap(),
))));
}
fn edit(app: &mut App, text: &str) {
let _ = app.update(Message::SourceEdit(text_editor::Action::SelectAll));
let _ = app.update(Message::SourceEdit(text_editor::Action::Edit(
text_editor::Edit::Paste(Arc::new(text.into())),
)));
}
fn save(app: &mut App, path: &std::path::Path) {
let receipt = write_snapshot(
app.canvas_document,
app.document.revision(),
app.document.snapshot(),
path.to_owned(),
app.disk_project.clone(),
)
.unwrap();
let _ = app.update(Message::Saved(Ok(Some(receipt))));
}
#[test]
fn repair_to_previous_saved_bytes_requires_save_at_new_destination_and_guards_close() {
for extra in [
" include \"missing.arcadia\";\r\n",
" oa_activity assess \"Duplicate\" id \"9e8f0000-0000-4000-8000-000000000003\" {}\r\n",
] {
let first = tempfile::tempdir().unwrap();
let second = tempfile::tempdir().unwrap();
let first_path = first.path().canonicalize().unwrap().join("project.arcadia");
let second_path = second
.path()
.canonicalize()
.unwrap()
.join("project.arcadia");
let invalid = VALID.strip_suffix("}\r\n").unwrap().to_owned() + extra + "}\r\n";
std::fs::write(&first_path, VALID).unwrap();
std::fs::write(&second_path, &invalid).unwrap();
let mut app = App::with_project(Project::empty());
open(&mut app, &first_path);
assert!(!app.dirty());
let before = app.document.snapshot();
let revision = app.document.revision();
open(&mut app, &second_path);
assert!(!app.source_workspace.applied);
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
edit(&mut app, VALID);
assert!(app.apply_source(), "{}", app.status);
assert!(app.source_workspace.applied);
assert!(!app.source_dirty());
assert_eq!(app.document.bundle(), &before.bundle);
assert_eq!(app.project(), &before.decoded.project);
assert_eq!(app.document.revision(), revision);
assert!(
!app.document.can_undo(),
"Equal content creates no history step"
);
assert!(app.document.dirty());
assert_eq!(app.hosted_label(), "Unsaved changes");
assert_eq!(std::fs::read(&second_path).unwrap(), invalid.as_bytes());
assert_eq!(std::fs::read(&first_path).unwrap(), VALID.as_bytes());
let close = window::Id::unique();
let task = app.update(Message::Request(Pending::Close(close)));
assert!(iced_runtime::task::into_stream(task).is_none());
assert!(matches!(app.pending, Some(Pending::Close(id)) if id == close));
let _ = app.update(Message::Cancel);
save(&mut app, &second_path);
assert!(!app.dirty());
assert_eq!(app.hosted_label(), "All changes saved");
assert_eq!(std::fs::read(&second_path).unwrap(), VALID.as_bytes());
assert_eq!(std::fs::read(&first_path).unwrap(), VALID.as_bytes());
}
}
#[test]
fn repair_history_compares_to_new_disk_baseline_and_then_successful_save() {
let directory = tempfile::tempdir().unwrap();
let path = directory
.path()
.canonicalize()
.unwrap()
.join("project.arcadia");
std::fs::write(&path, VALID).unwrap();
let mut app = App::with_project(Project::empty());
open(&mut app, &path);
let before = app.document.snapshot();
let invalid = format!("{VALID} unfinished {{");
std::fs::write(&path, &invalid).unwrap();
open(&mut app, &path);
let repaired = format!("{VALID}// Repaired rationale\r\n");
edit(&mut app, &repaired);
assert!(app.apply_source());
assert!(app.dirty());
let _ = app.update(Message::Undo);
assert_eq!(app.document.bundle(), &before.bundle);
assert!(
app.dirty(),
"Old saved bytes are not saved at the repair destination"
);
let _ = app.update(Message::Redo);
assert!(app.dirty());
assert_eq!(std::fs::read(&path).unwrap(), invalid.as_bytes());
std::fs::write(&path, "external edit").unwrap();
let failed = write_snapshot(
app.canvas_document,
app.document.revision(),
app.document.snapshot(),
path.clone(),
app.disk_project.clone(),
)
.unwrap_err();
let _ = app.update(Message::Saved(Err(failed)));
assert!(app.dirty());
assert_eq!(std::fs::read(&path).unwrap(), b"external edit");
std::fs::write(&path, &invalid).unwrap();
save(&mut app, &path);
assert!(!app.dirty());
let _ = app.update(Message::Undo);
assert!(app.dirty());
assert_eq!(app.document.bundle(), &before.bundle);
let _ = app.update(Message::Redo);
assert!(!app.dirty(), "Redo to the new saved snapshot is clean");
assert_eq!(std::fs::read(&path).unwrap(), repaired.as_bytes());
}
#[test]
fn valid_equal_content_at_distinct_destinations_stays_clean() {
let first = tempfile::tempdir().unwrap();
let second = tempfile::tempdir().unwrap();
let first_path = first.path().canonicalize().unwrap().join("project.arcadia");
let second_path = second
.path()
.canonicalize()
.unwrap()
.join("project.arcadia");
std::fs::write(&first_path, VALID).unwrap();
std::fs::write(&second_path, VALID).unwrap();
let mut app = App::with_project(Project::empty());
open(&mut app, &first_path);
let before = app.document.bundle().clone();
open(&mut app, &second_path);
assert_eq!(app.document.bundle(), &before);
assert_eq!(app.path.as_ref(), Some(&second_path));
assert!(!app.dirty());
assert_eq!(app.hosted_label(), "All changes saved");
}
#[test]
fn recovering_applied_repair_rebinds_rolled_back_disk_bytes_without_changing_model() {
let first = tempfile::tempdir().unwrap();
let second = tempfile::tempdir().unwrap();
let first_path = first.path().canonicalize().unwrap().join("project.arcadia");
let root = second.path().canonicalize().unwrap();
let path = root.join("project.arcadia");
let before = VALID.replace("Repair 救援", "Before 救援");
std::fs::write(&first_path, VALID).unwrap();
std::fs::write(&path, VALID).unwrap();
let journal = root.join(".arcadia-save-project.arcadia.journal");
std::fs::create_dir(&journal).unwrap();
std::fs::write(
journal.join("journal.json"),
br#"{"version":1,"entry":"project.arcadia","files":[{"path":"project.arcadia","before":true,"write":true}]}"#,
)
.unwrap();
std::fs::write(journal.join("0.before"), &before).unwrap();
std::fs::write(journal.join("0.after"), VALID).unwrap();
let mut app = App::with_project(Project::empty());
open(&mut app, &first_path);
let accepted = app.document.snapshot();
open(&mut app, &path);
assert_eq!(app.pending_recovery.as_ref(), Some(&path));
assert!(!app.source_workspace.applied);
assert!(app.apply_source());
assert!(
!app.dirty(),
"Current bytes still match disk before rollback"
);
let _ = app.update(Message::SourceRecovered(recover_snapshot(path.clone())));
assert!(app.pending_recovery.is_none());
assert!(!journal.exists());
assert_eq!(std::fs::read(&path).unwrap(), before.as_bytes());
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
assert!(!app.source_dirty());
assert!(app.dirty(), "Recovery changed disk, not the accepted model");
assert_eq!(app.hosted_label(), "Unsaved changes");
save(&mut app, &path);
assert!(!app.dirty());
assert_eq!(std::fs::read(&path).unwrap(), VALID.as_bytes());
}
#[test]
fn recovery_missing_entry_or_failed_reload_preserves_current_destination_save_authority() {
for (current_destination, failed_reload) in
[(true, false), (false, false), (true, true), (false, true)]
{
let directory = tempfile::tempdir().unwrap();
let root = directory.path().canonicalize().unwrap();
let path = root.join("project.arcadia");
std::fs::write(&path, VALID).unwrap();
let mut app = App::with_project(Project::empty());
open(&mut app, &path);
let accepted = app.document.snapshot();
let journal = root.join(".arcadia-save-project.arcadia.journal");
std::fs::create_dir(&journal).unwrap();
std::fs::write(
journal.join("journal.json"),
br#"{"version":1,"entry":"project.arcadia","files":[{"path":"project.arcadia","before":false,"write":true}]}"#,
)
.unwrap();
std::fs::write(journal.join("0.after"), VALID).unwrap();
open(&mut app, &path);
assert!(app.apply_source());
assert!(!app.dirty());
let destination = tempfile::tempdir().unwrap();
let saved_elsewhere = destination
.path()
.canonicalize()
.unwrap()
.join("project.arcadia");
if !current_destination {
save(&mut app, &saved_elsewhere);
assert_eq!(app.path.as_ref(), Some(&saved_elsewhere));
assert_eq!(app.pending_recovery.as_ref(), Some(&path));
}
let mut receipt = recover_snapshot(path.clone()).unwrap();
assert!(
receipt.disk.as_ref().unwrap().bundle.files.is_empty(),
"Missing entry reloads as an empty repair workspace"
);
if failed_reload {
// Separately exercise the adapter's successful-recovery/failed-read
// boundary; first-save rollback itself does not cause a read error.
receipt.disk = Err("Injected post-recovery reload failure".into());
}
let _ = app.update(Message::SourceRecovered(Ok(receipt)));
assert!(!path.exists());
assert!(!journal.exists());
assert!(app.pending_recovery.is_none());
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
assert_eq!(app.dirty(), current_destination);
if current_destination {
let _ = app.update(Message::Request(Pending::New));
assert!(matches!(app.pending, Some(Pending::New)));
} else {
assert_eq!(app.path.as_ref(), Some(&saved_elsewhere));
assert_eq!(std::fs::read(&saved_elsewhere).unwrap(), VALID.as_bytes());
}
}
}
@@ -0,0 +1,184 @@
//! Accepted semantic review keeps selection, layer and visible diagram coherent.
use super::*;
use crate::{
semantic_review::{RowEvent, State},
workflow::{Event, Workflow},
};
fn send(app: &mut App, message: Message) {
let _ = app.update(message);
}
fn state(app: &App) -> &State {
let Some(Workflow::Semantic(state)) = &app.workflow else {
panic!("{}", app.status)
};
state
}
fn row_event(app: &mut App, event: RowEvent) {
let event = state(app).address_event(event);
send(app, Message::Workflow(Event::Semantic(event)));
}
fn fixture(reverse: bool, destination_view: bool) -> (App, Id, Id, Id, Option<Id>) {
let mut p = Project::empty();
let oa = p
.add_element(Kind::Activity, Layer::Oa, "Confirm stable pressure", None)
.unwrap();
let sa = p
.add_element(Kind::Function, Layer::Sa, "Confirm safe pressure", None)
.unwrap();
let trace = Id::new_v4();
p.traces.insert(
trace,
Trace {
id: trace,
alias: "confirm_trace".into(),
name: "Confirmation refinement".into(),
description: String::new(),
oa,
sa,
kind: TraceKind::Refinement,
baseline: None,
},
);
let (source, target, source_layer, target_layer) = if reverse {
(sa, oa, Layer::Sa, Layer::Oa)
} else {
(oa, sa, Layer::Oa, Layer::Sa)
};
let from = p.ensure_diagram(source_layer);
p.place(from, source, Position { x: 91., y: 143. });
let to = destination_view.then(|| {
let view = p.ensure_diagram(target_layer);
p.place(view, target, Position { x: 487., y: 213. });
view
});
let mut app = App::with_project(p);
app.page = Page::Diagram;
app.layer = source_layer;
app.active_diagram = Some(from);
app.selected = source;
app.refresh_draft();
(app, source, target, from, to)
}
fn name_take(app: &mut App, source: Id) -> Project {
send(app, Message::StartSemanticReview(source));
let facet = FacetSelector::Header(HeaderFacet::Name);
row_event(app, RowEvent::Select(facet.clone()));
row_event(
app,
RowEvent::Decide(facet, Some(SemanticDecision::TakeSource)),
);
assert!(state(app).can_apply(), "{:?}", state(app).error);
let Some(Workflow::Semantic(state)) = &mut app.workflow else {
unreachable!()
};
let command = state.command(app.document.project()).unwrap();
execute_batch(app.document.project(), &[command]).unwrap()
}
#[test]
fn semantic_name_take_selects_the_counterpart_view_in_both_directions_with_one_exact_history_entry()
{
for reverse in [false, true] {
let (mut app, source, target, from, to) = fixture(reverse, true);
app.search = format!("@{}", app.project().elements[&source].alias);
let query = app.search.clone();
let before = app.document.snapshot();
let expected = name_take(&mut app, source);
app.selected_node = app.project().diagrams[&from].nodes.keys().next().copied();
send(&mut app, Message::Workflow(Event::Apply));
assert_eq!(app.project(), &expected);
assert_eq!(app.project().diagrams, before.decoded.project.diagrams);
assert_eq!(app.selected, target);
assert_eq!(app.layer, app.project().elements[&target].layer());
assert_eq!(app.active_diagram, to);
assert_eq!(
app.project().diagrams[&app.active_diagram.unwrap()].layer,
app.layer
);
// refresh_draft deliberately selects the target's visible placement.
// The invariant is that the old-layer placement cannot survive.
let placement = app.selected_node.expect("visible counterpart placement");
assert_eq!(
app.project().diagrams[&to.unwrap()].nodes[&placement].element,
target
);
assert!(!app.project().diagrams[&from].nodes.contains_key(&placement));
assert_eq!(app.page, Page::Diagram);
assert_eq!(app.search, query);
assert!(app.workflow.is_none());
assert_eq!(app.document.revision(), 1);
let accepted = app.document.snapshot();
assert_eq!(
source_project::compile_bundle(&accepted.bundle, LIMITS)
.unwrap()
.decoded
.project,
expected
);
send(&mut app, Message::Undo);
assert_eq!(app.project(), &before.decoded.project);
assert_eq!(app.document.bundle(), &before.bundle);
assert!(!app.document.can_undo());
send(&mut app, Message::Redo);
assert_eq!(app.project(), &accepted.decoded.project);
assert_eq!(app.document.bundle(), &accepted.bundle);
}
}
#[test]
fn semantic_review_without_a_destination_view_clears_the_old_layer_canvas_without_creating_a_view()
{
let (mut app, source, target, _, _) = fixture(false, false);
let expected = name_take(&mut app, source);
send(&mut app, Message::Workflow(Event::Apply));
assert_eq!(app.project(), &expected);
assert_eq!(app.selected, target);
assert_eq!(app.layer, Layer::Sa);
assert_eq!(app.active_diagram, None);
assert_eq!(app.project().diagrams.len(), 1);
}
#[test]
fn review_already_showing_the_target_keeps_its_camera_and_query() {
let (mut app, source, target, _, to) = fixture(false, true);
app.layer = Layer::Sa;
app.active_diagram = to;
app.selected = target;
app.zoom = 1.31;
app.pan = iced::Vector::new(-219., 88.);
app.search = "pressure".into();
app.refresh_draft();
let expected = name_take(&mut app, source);
send(&mut app, Message::Workflow(Event::Apply));
assert_eq!(app.project(), &expected);
assert_eq!(app.active_diagram, to);
assert_eq!(app.zoom, 1.31);
assert_eq!(app.pan, iced::Vector::new(-219., 88.));
assert_eq!(app.search, "pressure");
}
#[test]
fn rejected_semantic_source_writeback_retains_review_camera_and_original_navigation() {
let (mut app, source, _, from, _) = fixture(false, true);
let bundle = app.document.bundle().clone();
let mut limits = source_project::ProjectLimits::default();
limits.model.max_source_bytes = bundle.files.values().map(String::len).sum::<usize>() + 1;
app.document = Document::from_bundle(bundle, limits).unwrap();
app.zoom = 1.21;
app.pan = iced::Vector::new(72., -44.);
app.search = "confirmation".into();
let before = app.document.snapshot();
let _valid_model = name_take(&mut app, source);
send(&mut app, Message::Workflow(Event::Apply));
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
assert_eq!(app.selected, source);
assert_eq!(app.layer, Layer::Oa);
assert_eq!(app.active_diagram, Some(from));
assert_eq!(app.page, Page::Diagram);
assert_eq!(app.zoom, 1.21);
assert_eq!(app.pan, iced::Vector::new(72., -44.));
assert_eq!(app.search, "confirmation");
assert!(state(&app).error.is_some());
assert!(!app.document.can_undo());
}
+114
View File
@@ -0,0 +1,114 @@
use super::*;
fn complete_save(app: &mut App, path: PathBuf) {
let receipt = write_snapshot(
app.canvas_document,
app.document.revision(),
app.document.snapshot(),
path,
app.disk_project.clone(),
)
.unwrap();
let _ = app.update(Message::Saved(Ok(Some(receipt))));
}
#[test]
fn save_as_receipt_then_one_undo_restores_meaning_and_saving_preserves_redo() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("semantic-accepted.arcadia");
let mut app = App::with_project(Project::empty());
app.document.mark_recovered();
let original = app.document.bundle().files[&app.document.bundle().entry].clone();
app.execute(Command::Rename(
app.project().id,
"Accepted semantic change".into(),
));
let after = app.document.bundle().files[&app.document.bundle().entry].clone();
complete_save(&mut app, path.clone());
assert!(!app.dirty());
assert_eq!(app.path.as_ref(), Some(&path));
assert_eq!(std::fs::read_to_string(&path).unwrap(), after);
let _ = app.update(Message::Undo);
assert_eq!(app.document.bundle().entry, "semantic-accepted.arcadia");
assert_eq!(
app.document.bundle().files[&app.document.bundle().entry],
original
);
assert!(
!app.document.can_undo(),
"Save as did not insert a history step"
);
assert!(app.document.can_redo());
complete_save(&mut app, path.clone());
assert_eq!(std::fs::read_to_string(&path).unwrap(), original);
assert!(
app.document.can_redo(),
"Saving an undone state preserves the future"
);
let _ = app.update(Message::Redo);
assert_eq!(
app.document.bundle().files[&app.document.bundle().entry],
after
);
assert!(app.dirty());
assert_eq!(
source_project::compile_bundle(app.document.bundle(), LIMITS)
.unwrap()
.decoded
.project,
*app.project()
);
}
#[test]
fn save_as_preflight_rejects_historical_collision_before_starting_io() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("taken.arcadia");
let mut app = App::with_project(Project::empty());
let base = app.document.bundle().clone();
let mut included = base.clone();
let text = included.files.get_mut(&included.entry).unwrap();
text.insert_str(text.rfind('}').unwrap(), "\n include \"taken.arcadia\";\n");
included
.files
.insert("taken.arcadia".into(), "// retained\n".into());
app.document.apply_bundle(included).unwrap();
app.document.apply_bundle(base).unwrap();
app.sync_source();
let before = app.document.snapshot();
let task = app.save_to(path.clone());
assert!(iced_runtime::task::into_stream(task).is_none());
assert!(!path.exists());
assert!(!app.busy);
assert!(app.status.contains("Undo/Redo"));
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
let json = app.save_to(directory.path().join("taken.json"));
assert!(
iced_runtime::task::into_stream(json).is_some(),
"JSON exports do not relocate source entries"
);
}
#[test]
fn save_as_late_receipt_preserves_newer_raw_source_draft_and_destination() {
let directory = tempfile::tempdir().unwrap();
let mut app = App::with_project(Project::empty());
let entry = app.document.bundle().entry.clone();
let receipt = write_snapshot(
app.canvas_document,
app.document.revision(),
app.document.snapshot(),
directory.path().join("accepted.arcadia"),
None,
)
.unwrap();
let raw = format!("{}\nunfinished {{", app.source_workspace.text());
let cursor = app.source_workspace.files[&entry].cursor;
app.source_workspace.edit(raw.clone(), cursor);
let _ = app.update(Message::Saved(Ok(Some(receipt))));
assert_eq!(app.source_workspace.text(), raw);
assert_eq!(app.document.bundle().entry, entry);
assert!(app.path.is_none());
assert!(app.dirty());
assert!(app.status.contains("Newer edits"));
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,219 @@
//! Keep Iced's single-line input from silently filtering pasted control bytes.
//! Only the alias field uses this adapter. The child retains editing and focus.
use iced::advanced::{
Clipboard, Layout, Shell, Widget, clipboard, layout, overlay, renderer, text, widget,
};
use iced::{Element, Event, Length, Rectangle, Size, Vector, keyboard, mouse};
pub(super) fn guard<'a, Message: Clone + 'a, Theme: 'a, R: text::Renderer + 'a>(
input: Element<'a, Message, Theme, R>,
rejected: Option<Message>,
) -> Element<'a, Message, Theme, R> {
Element::new(AliasInput { input, rejected })
}
struct AliasInput<'a, Message, Theme, R> {
input: Element<'a, Message, Theme, R>,
rejected: Option<Message>,
}
#[derive(Default)]
struct State {
modifiers: keyboard::Modifiers,
}
// Each fresh child clipboard read uses the checked snapshot. Iced may reuse its
// previously admitted paste while V is held, until KeyReleased clears that cache.
struct CapturedClipboard<'a> {
content: Option<String>,
original: &'a mut dyn Clipboard,
}
impl Clipboard for CapturedClipboard<'_> {
fn read(&self, kind: clipboard::Kind) -> Option<String> {
if kind == clipboard::Kind::Standard {
self.content.clone()
} else {
self.original.read(kind)
}
}
fn write(&mut self, kind: clipboard::Kind, contents: String) {
self.original.write(kind, contents);
}
}
impl<Message: Clone, Theme, R: text::Renderer> Widget<Message, Theme, R>
for AliasInput<'_, Message, Theme, R>
{
fn size(&self) -> Size<Length> {
self.input.as_widget().size()
}
fn size_hint(&self) -> Size<Length> {
self.input.as_widget().size_hint()
}
fn tag(&self) -> widget::tree::Tag {
widget::tree::Tag::of::<State>()
}
fn state(&self) -> widget::tree::State {
widget::tree::State::new(State::default())
}
fn children(&self) -> Vec<widget::Tree> {
vec![widget::Tree::new(self.input.as_widget())]
}
fn diff(&self, tree: &mut widget::Tree) {
tree.diff_children(std::slice::from_ref(&self.input));
}
fn layout(
&mut self,
tree: &mut widget::Tree,
renderer: &R,
limits: &layout::Limits,
) -> layout::Node {
self.input
.as_widget_mut()
.layout(&mut tree.children[0], renderer, limits)
}
fn draw(
&self,
tree: &widget::Tree,
renderer: &mut R,
theme: &Theme,
style: &renderer::Style,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
) {
self.input.as_widget().draw(
&tree.children[0],
renderer,
theme,
style,
layout,
cursor,
viewport,
);
}
fn operate(
&mut self,
tree: &mut widget::Tree,
layout: Layout<'_>,
renderer: &R,
operation: &mut dyn widget::Operation,
) {
self.input
.as_widget_mut()
.operate(&mut tree.children[0], layout, renderer, operation);
}
fn update(
&mut self,
tree: &mut widget::Tree,
event: &Event,
layout: Layout<'_>,
cursor: mouse::Cursor,
renderer: &R,
clipboard: &mut dyn Clipboard,
shell: &mut Shell<'_, Message>,
viewport: &Rectangle,
) {
let focused = tree.children[0]
.state
.downcast_ref::<iced::widget::text_input::State<R::Paragraph>>()
.is_focused();
let state = tree.state.downcast_mut::<State>();
// Iced 0.14.2 uses stored ModifiersChanged state, not KeyPressed.modifiers.
// Mirror its Escape reset and its Latin/physical-key paste shortcut.
if let Event::Keyboard(keyboard::Event::ModifiersChanged(modifiers)) = event {
state.modifiers = *modifiers;
}
let paste = matches!(event,
Event::Keyboard(keyboard::Event::KeyPressed { key, physical_key, .. })
if key.to_latin(*physical_key) == Some('v')
&& state.modifiers.command() && !state.modifiers.alt());
if focused
&& paste
&& let Some(rejected) = &self.rejected
{
let content = clipboard.read(clipboard::Kind::Standard);
if content
.as_ref()
.is_some_and(|value| value.chars().any(char::is_control))
{
// Do not invoke the child: even an empty replacement would erase
// its selection. Approval invalidation is an explicit App event.
shell.publish(rejected.clone());
shell.capture_event();
return;
}
self.input.as_widget_mut().update(
&mut tree.children[0],
event,
layout,
cursor,
renderer,
&mut CapturedClipboard {
content,
original: clipboard,
},
shell,
viewport,
);
return;
}
self.input.as_widget_mut().update(
&mut tree.children[0],
event,
layout,
cursor,
renderer,
clipboard,
shell,
viewport,
);
// In Iced 0.14.2 the only KeyPressed branch that removes focus is its
// Escape branch, which also resets stored modifiers. Observe that branch
// after its text/shortcut early returns and effective-key conversion.
if focused
&& matches!(event, Event::Keyboard(keyboard::Event::KeyPressed { .. }))
&& !tree.children[0]
.state
.downcast_ref::<iced::widget::text_input::State<R::Paragraph>>()
.is_focused()
{
tree.state.downcast_mut::<State>().modifiers = keyboard::Modifiers::default();
}
}
fn mouse_interaction(
&self,
tree: &widget::Tree,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
renderer: &R,
) -> mouse::Interaction {
self.input.as_widget().mouse_interaction(
&tree.children[0],
layout,
cursor,
viewport,
renderer,
)
}
fn overlay<'a>(
&'a mut self,
tree: &'a mut widget::Tree,
layout: Layout<'a>,
renderer: &R,
viewport: &Rectangle,
translation: Vector,
) -> Option<overlay::Element<'a, Message, Theme, R>> {
self.input.as_widget_mut().overlay(
&mut tree.children[0],
layout,
renderer,
viewport,
translation,
)
}
}
#[cfg(all(test, debug_assertions))]
mod tests;
@@ -0,0 +1,220 @@
use super::*;
use crate::views::source_tests::FontLayout;
use iced::keyboard::{
Key, Modifiers,
key::{Code, Named, Physical},
};
use std::cell::Cell;
#[derive(Debug, Clone, PartialEq)]
enum Signal {
Input(String),
Rejected,
}
struct ClipboardText {
text: String,
reads: Cell<usize>,
}
impl Clipboard for ClipboardText {
fn read(&self, _: clipboard::Kind) -> Option<String> {
self.reads.set(self.reads.get() + 1);
Some(self.text.clone())
}
fn write(&mut self, _: clipboard::Kind, _: String) {}
}
type InputState = iced::widget::text_input::State<<FontLayout as text::Renderer>::Paragraph>;
struct Mounted {
input: Element<'static, Signal, iced::Theme, FontLayout>,
tree: widget::Tree,
node: layout::Node,
clipboard: ClipboardText,
messages: Vec<Signal>,
}
impl Mounted {
fn new(focused: bool, enabled: bool, modifiers: Modifiers) -> Self {
let mut child = iced::widget::text_input("Alias", "send_water");
if enabled {
child = child.on_input(Signal::Input);
}
let mut input = guard(child.into(), enabled.then_some(Signal::Rejected));
let mut tree = widget::Tree::new(input.as_widget());
let node = input.as_widget_mut().layout(
&mut tree,
&FontLayout,
&layout::Limits::new(Size::ZERO, Size::new(500., 40.)),
);
if focused {
let state = tree.children[0].state.downcast_mut::<InputState>();
state.focus();
state.select_range(0, 10);
}
let mut mounted = Self {
input,
tree,
node,
clipboard: ClipboardText {
text: "foo\nbar".into(),
reads: Cell::new(0),
},
messages: vec![],
};
mounted.event(Event::Keyboard(keyboard::Event::ModifiersChanged(
modifiers,
)));
mounted
}
fn state(&self) -> &InputState {
self.tree.children[0].state.downcast_ref::<InputState>()
}
fn event(&mut self, event: Event) -> bool {
let mut shell = Shell::new(&mut self.messages);
self.input.as_widget_mut().update(
&mut self.tree,
&event,
Layout::new(&self.node),
mouse::Cursor::Unavailable,
&FontLayout,
&mut self.clipboard,
&mut shell,
&Rectangle::new(iced::Point::ORIGIN, Size::new(500., 40.)),
);
shell.is_event_captured()
}
}
fn key(key: Key, modified_key: Key, code: Code) -> Event {
Event::Keyboard(keyboard::Event::KeyPressed {
key,
modified_key,
physical_key: Physical::Code(code),
location: keyboard::Location::Standard,
modifiers: Modifiers::empty(),
text: None,
repeat: false,
})
}
fn paste() -> Event {
key(
Key::Character("v".into()),
Key::Character("v".into()),
Code::KeyV,
)
}
#[test]
fn paste_refusal_matches_child_focus_enabled_and_stored_modifier_protocol() {
for focused in [false, true] {
for enabled in [false, true] {
for modifiers in [
Modifiers::empty(),
Modifiers::SHIFT,
Modifiers::COMMAND,
Modifiers::COMMAND | Modifiers::ALT,
Modifiers::COMMAND | Modifiers::SHIFT,
] {
let mut mounted = Mounted::new(focused, enabled, modifiers);
let before = mounted.state().cursor();
let expected = focused && enabled && modifiers.command() && !modifiers.alt();
let captured = mounted.event(paste());
assert_eq!(
mounted.messages,
if expected {
vec![Signal::Rejected]
} else {
vec![]
}
);
assert_eq!(mounted.clipboard.reads.get(), usize::from(expected));
assert_eq!(mounted.state().cursor(), before);
if expected {
assert!(captured);
}
}
}
}
}
#[test]
fn effective_escape_reset_follows_child_after_early_returns() {
// Raw Escape mapped to Home must not clear only the guard's Command state.
let mut mounted = Mounted::new(true, true, Modifiers::COMMAND);
mounted.event(key(
Key::Named(Named::Escape),
Key::Named(Named::Home),
Code::Escape,
));
assert!(mounted.state().is_focused());
assert!(mounted.event(paste()));
assert_eq!(mounted.messages, vec![Signal::Rejected]);
// Effective Escape clears both states; a later focus operation does not
// invent a new ModifiersChanged event or revive the previous Command state.
let mut mounted = Mounted::new(true, true, Modifiers::COMMAND);
mounted.event(key(
Key::Named(Named::Home),
Key::Named(Named::Escape),
Code::Home,
));
assert!(!mounted.state().is_focused());
mounted.tree.children[0]
.state
.downcast_mut::<InputState>()
.focus();
mounted.event(paste());
assert!(mounted.messages.is_empty());
assert_eq!(mounted.clipboard.reads.get(), 0);
// Printable text returns before effective Escape and leaves modifiers live.
let mut mounted = Mounted::new(true, true, Modifiers::COMMAND);
let mut event = key(
Key::Named(Named::Home),
Key::Named(Named::Escape),
Code::Home,
);
if let Event::Keyboard(keyboard::Event::KeyPressed { text, .. }) = &mut event {
*text = Some("x".into());
}
mounted.event(event);
assert!(mounted.state().is_focused());
assert_eq!(mounted.messages, vec![Signal::Input("x".into())]);
mounted.messages.clear();
mounted.event(paste());
assert_eq!(mounted.messages, vec![Signal::Rejected]);
}
#[test]
fn held_paste_cache_contains_only_previously_admitted_text() {
let mut mounted = Mounted::new(true, true, Modifiers::COMMAND);
mounted.clipboard.text = "water".into();
mounted.event(paste());
assert_eq!(mounted.messages, vec![Signal::Input("water".into())]);
mounted.messages.clear();
mounted.clipboard.text = "other".into();
mounted.event(paste());
assert_eq!(
mounted.messages,
vec![Signal::Input("waterwater".into())],
"Iced reuses its first admitted paste while the key is held"
);
mounted.messages.clear();
let before = mounted.state().cursor();
mounted.clipboard.text = "bad\tvalue".into();
mounted.event(paste());
assert_eq!(mounted.messages, vec![Signal::Rejected]);
assert_eq!(mounted.state().cursor(), before);
mounted.messages.clear();
mounted.event(Event::Keyboard(keyboard::Event::KeyReleased {
key: Key::Character("v".into()),
modified_key: Key::Character("v".into()),
physical_key: Physical::Code(Code::KeyV),
location: keyboard::Location::Standard,
modifiers: Modifiers::empty(),
}));
mounted.clipboard.text = "later".into();
mounted.event(paste());
assert_eq!(
mounted.messages,
vec![Signal::Input("waterwaterlater".into())]
);
}
@@ -0,0 +1,303 @@
//! Display-only reuse after a guarded alias rename. This is not model authority.
use iced::{advanced::text::Renderer, widget::text_editor};
use std::sync::Arc;
/// Replace exact, unchanged-line-count source in an existing editor.
///
/// The caller must first authorize/commit the model change and must reconstruct
/// from accepted source on false. Preflight failures do not mutate the editor;
/// a failed final readback can leave it changed. On changing success the cursor
/// is at 0:0 without selection; the caller restores its approved origin/reveal.
/// Direct Content actions deliberately bypass App draft edits and Undo history.
pub(super) fn replace<R: Renderer>(
editor: &mut text_editor::Content<R>,
before: &str,
after: &str,
) -> bool {
if editor.text() != before {
return false;
}
let Some(old_lines) = lines(before) else {
return false;
};
let Some(new_lines) = lines(after) else {
return false;
};
if old_lines.len() != new_lines.len() || old_lines.len() != editor.line_count() {
return false;
}
let mut patches = Vec::new();
for (line, ((old, old_ending), (new, new_ending))) in
old_lines.into_iter().zip(new_lines).enumerate()
{
let Some(actual) = editor.line(line) else {
return false;
};
// Cosmic also recognizes bare CR and LFCR. Comparing the actual lines
// catches its greedy LFCR interpretation of e.g. "x\n\r\ny" too.
if old_ending != new_ending || actual.text != old || actual.ending != old_ending {
return false;
}
if old == new {
continue;
}
let mut start = old
.bytes()
.zip(new.bytes())
.take_while(|(a, b)| a == b)
.count();
while !old.is_char_boundary(start) || !new.is_char_boundary(start) {
start -= 1;
}
let mut suffix = old[start..]
.bytes()
.rev()
.zip(new[start..].bytes().rev())
.take_while(|(a, b)| a == b)
.count();
while !old.is_char_boundary(old.len() - suffix) || !new.is_char_boundary(new.len() - suffix)
{
suffix -= 1;
}
// All positions and owned payloads are prepared before the first edit.
patches.push((
line,
start,
old.len() - suffix,
Arc::new(new[start..new.len() - suffix].to_owned()),
));
}
if patches.is_empty() {
return true;
}
let empty = Arc::new(String::new());
for (line, start, end, replacement) in patches.into_iter().rev() {
select(editor, line, start, end);
editor.perform(text_editor::Action::Edit(text_editor::Edit::Paste(
replacement,
)));
}
// Iced overwrites its earliest-change marker on each Edit. An explicit
// zero-width selection + empty Paste changes no bytes, but marks line zero.
// This also refreshes previously unhighlighted lines before the first patch.
// It resets only line zero's text cache; other unchanged lines retain theirs.
select(editor, 0, 0, 0);
editor.perform(text_editor::Action::Edit(text_editor::Edit::Paste(empty)));
editor.text() == after
}
fn select<R: Renderer>(
editor: &mut text_editor::Content<R>,
line: usize,
start: usize,
end: usize,
) {
editor.move_to(text_editor::Cursor {
position: text_editor::Position {
line,
column: start,
},
// move_to(None) does not clear a previous selection in this Iced version.
selection: Some(text_editor::Position { line, column: end }),
});
}
fn lines(source: &str) -> Option<Vec<(&str, text_editor::LineEnding)>> {
use text_editor::LineEnding;
let mut result = Vec::new();
let mut parts = source.split('\n').peekable();
while let Some(part) = parts.next() {
let (text, ending) = if parts.peek().is_none() {
(part, LineEnding::None)
} else if let Some(text) = part.strip_suffix('\r') {
(text, LineEnding::CrLf)
} else {
(part, LineEnding::Lf)
};
if text.contains('\r')
|| (text.is_empty()
&& ending == LineEnding::CrLf
&& result
.last()
.is_some_and(|(_, previous)| *previous == LineEnding::Lf))
{
return None;
}
result.push((text, ending));
}
Some(result)
}
#[cfg(all(test, debug_assertions))]
mod tests {
use super::*;
use crate::views::source_tests::FontLayout;
use iced::advanced::{layout, text::Highlighter, widget::Tree};
use iced::{Element, Font, Length, Size};
use std::{cell::RefCell, ops::Range};
#[test]
fn exact_text_matrix_and_reverse_preserve_endings() {
for (before, after) in [
("", ""),
("", "alias"),
("alias", ""),
("same", "same"),
("a\n", "alias\n"),
("a\r\n", "alias\r\n"),
("\r\na\r\n\r\n", "\r\nlong_alias\r\n\r\n"),
("\na\n\n", "\nlong_alias\n\n"),
("a\r\nb\nc", "long_a\r\nlong_b\nlong_c"),
(
"雨🙂 e\u{301} @a @a // a",
"雨🙂 e\u{301} @a_long @a_long // a",
),
("@abc", "@abcdef"),
("@abcdef", "@abc"),
("@abc_suffix", "@def_suffix"),
("@none", "@identifier"),
// Shared prefix/suffix bytes inside distinct UTF-8 codepoints must
// not be used as String split positions, even outside alias scope.
("雨xé", "雪xê"),
("é", "©"),
(
"unchanged\n@a\nunchanged\n@a\n",
"unchanged\n@long\nunchanged\n@long\n",
),
] {
let mut editor = text_editor::Content::<FontLayout>::with_text(before);
assert!(
replace(&mut editor, before, after),
"{before:?} -> {after:?}"
);
assert_eq!(editor.text(), after);
let expected = text_editor::Content::<FontLayout>::with_text(after);
assert_eq!(editor.line_count(), expected.line_count());
for (actual, expected) in editor.lines().zip(expected.lines()) {
assert_eq!(actual.text, expected.text);
assert_eq!(actual.ending, expected.ending);
}
if before != after {
assert_eq!(
editor.cursor().position,
text_editor::Position { line: 0, column: 0 }
);
assert_eq!(editor.cursor().selection, None);
}
assert!(replace(&mut editor, after, before));
assert_eq!(editor.text(), before);
}
}
#[test]
fn all_preflight_refusals_leave_text_cursor_and_selection_unchanged() {
for (actual, before, after) in [
("live", "stale", "changed"),
("a\nb", "a\nb", "long\nb\nc"),
("a\nb", "a\nb", "long\r\nb"),
// Refusal on a later line must not apply the earlier planned edit.
("a\nb\r\n", "a\nb\r\n", "long\nb\n"),
("a\rb", "a\rb", "long\rb"),
("a\n\rb", "a\n\rb", "long\n\rb"),
("a\n\r\nb", "a\n\r\nb", "long\n\r\nb"),
// The replacement must not introduce a greedy LFCR boundary either.
("a\nx\r\n", "a\nx\r\n", "a\n\r\n"),
("a", "a", "long\r"),
] {
let mut editor = text_editor::Content::<FontLayout>::with_text(actual);
select(&mut editor, 0, 0, 1);
let cursor = editor.cursor();
let selection = editor.selection();
assert!(
!replace(&mut editor, before, after),
"{before:?} -> {after:?}"
);
assert_eq!(editor.text(), actual);
assert_eq!(editor.cursor(), cursor);
assert_eq!(editor.selection(), selection);
}
}
#[test]
fn unchanged_content_preserves_selection_and_next_direct_edit_works() {
let mut editor = text_editor::Content::<FontLayout>::with_text("@a");
select(&mut editor, 0, 1, 2);
let cursor = editor.cursor();
assert!(replace(&mut editor, "@a", "@a"));
assert_eq!(editor.cursor(), cursor);
assert!(replace(&mut editor, "@a", "@long"));
editor.move_to(text_editor::Cursor {
position: text_editor::Position { line: 0, column: 5 },
selection: None,
});
editor.perform(text_editor::Action::Edit(text_editor::Edit::Insert('!')));
assert_eq!(editor.text(), "@long!");
}
thread_local! {
static CHANGES: RefCell<Vec<usize>> = const { RefCell::new(Vec::new()) };
}
struct Tracked(crate::highlight::ArcadiaHighlighter);
impl Highlighter for Tracked {
type Settings = Arc<str>;
type Highlight = oa_core::language::TokenClass;
type Iterator<'a> = std::vec::IntoIter<(Range<usize>, Self::Highlight)>;
fn new(settings: &Self::Settings) -> Self {
Self(crate::highlight::ArcadiaHighlighter::new(settings))
}
fn update(&mut self, settings: &Self::Settings) {
self.0.update(settings);
}
fn change_line(&mut self, line: usize) {
CHANGES.with(|changes| changes.borrow_mut().push(line));
self.0.change_line(line);
}
fn highlight_line(&mut self, line: &str) -> Self::Iterator<'_> {
self.0.highlight_line(line)
}
fn current_line(&self) -> usize {
self.0.current_line()
}
}
fn view(
editor: &text_editor::Content<FontLayout>,
source: Arc<str>,
) -> Element<'_, (), iced::Theme, FontLayout> {
text_editor(editor)
.font(Font::MONOSPACE)
.size(14)
.height(Length::Fill)
.highlight_with::<Tracked>(source, crate::highlight::format)
.into()
}
#[test]
fn already_laid_out_editor_notifies_highlighter_from_zero_after_later_line_patch() {
let before = "// earlier\n// unmodified\n@a";
let after = "// earlier\n// unmodified\n@none";
let mut editor = text_editor::Content::<FontLayout>::with_text(before);
let limits = layout::Limits::new(Size::ZERO, Size::new(400., 60.));
let mut tree = {
let mut widget = view(&editor, Arc::from(before));
let mut tree = Tree::new(widget.as_widget());
let _ = widget
.as_widget_mut()
.layout(&mut tree, &FontLayout, &limits);
tree
};
CHANGES.with(|changes| changes.borrow_mut().clear());
assert!(replace(&mut editor, before, after));
{
let mut widget = view(&editor, Arc::from(after));
let _ = widget
.as_widget_mut()
.layout(&mut tree, &FontLayout, &limits);
}
// Font/metrics/bounds are already stable. This notification must come
// from the helper's empty edit, not a fresh renderer initialization.
CHANGES.with(|changes| assert_eq!(*changes.borrow(), [0]));
assert_eq!(editor.text(), after);
}
}
@@ -0,0 +1,449 @@
//! Real paragraph advances and clipping through the actual preview dialog.
//! Native screenshots additionally check raster ink and the platform renderer.
use super::*;
use crate::views::source_tests::FontLayout;
use iced::advanced::{Layout, layout, renderer, text as shaped, widget};
use iced::{Background, Color, Font, Pixels, Point, Rectangle, Size, Transformation, Vector};
use std::collections::{BTreeMap, BTreeSet};
struct Glyph {
text: String,
paragraph_y: u32,
line: usize,
bytes: std::ops::Range<usize>,
bounds: Rectangle,
clip: Rectangle,
}
struct GlyphRecorder {
wanted: BTreeSet<String>,
transforms: Vec<Transformation>,
clips: Vec<Rectangle>,
glyphs: Vec<Glyph>,
quads: Vec<Rectangle>,
}
impl GlyphRecorder {
fn new(wanted: BTreeSet<String>, viewport: Rectangle) -> Self {
Self {
wanted,
transforms: vec![Transformation::IDENTITY],
clips: vec![viewport],
glyphs: vec![],
quads: vec![],
}
}
fn transform(&self) -> Transformation {
*self.transforms.last().unwrap()
}
fn clip(&self, bounds: Rectangle) -> Rectangle {
self.clips
.last()
.unwrap()
.intersection(&(bounds * self.transform()))
.unwrap_or(Rectangle::with_size(Size::ZERO))
}
}
impl renderer::Renderer for GlyphRecorder {
fn start_layer(&mut self, bounds: Rectangle) {
self.clips.push(self.clip(bounds));
}
fn end_layer(&mut self) {
self.clips.pop().unwrap();
}
fn start_transformation(&mut self, transform: Transformation) {
self.transforms.push(self.transform() * transform);
}
fn end_transformation(&mut self) {
self.transforms.pop().unwrap();
}
fn reset(&mut self, bounds: Rectangle) {
self.transforms = vec![Transformation::IDENTITY];
self.clips = vec![bounds];
self.glyphs.clear();
self.quads.clear();
}
fn fill_quad(&mut self, quad: renderer::Quad, _: impl Into<Background>) {
self.quads.push(quad.bounds * self.transform());
}
fn allocate_image(
&mut self,
handle: &iced::advanced::image::Handle,
callback: impl FnOnce(Result<iced::advanced::image::Allocation, iced::advanced::image::Error>)
+ Send
+ 'static,
) {
renderer::Renderer::allocate_image(&mut FontLayout, handle, callback);
}
}
impl shaped::Renderer for GlyphRecorder {
type Font = Font;
type Paragraph = <FontLayout as shaped::Renderer>::Paragraph;
type Editor = <FontLayout as shaped::Renderer>::Editor;
const ICON_FONT: Font = <FontLayout as shaped::Renderer>::ICON_FONT;
const CHECKMARK_ICON: char = <FontLayout as shaped::Renderer>::CHECKMARK_ICON;
const ARROW_DOWN_ICON: char = <FontLayout as shaped::Renderer>::ARROW_DOWN_ICON;
const SCROLL_UP_ICON: char = <FontLayout as shaped::Renderer>::SCROLL_UP_ICON;
const SCROLL_DOWN_ICON: char = <FontLayout as shaped::Renderer>::SCROLL_DOWN_ICON;
const SCROLL_LEFT_ICON: char = <FontLayout as shaped::Renderer>::SCROLL_LEFT_ICON;
const SCROLL_RIGHT_ICON: char = <FontLayout as shaped::Renderer>::SCROLL_RIGHT_ICON;
const ICED_LOGO: char = <FontLayout as shaped::Renderer>::ICED_LOGO;
fn default_font(&self) -> Font {
shaped::Renderer::default_font(&FontLayout)
}
fn default_size(&self) -> Pixels {
shaped::Renderer::default_size(&FontLayout)
}
fn fill_paragraph(
&mut self,
paragraph: &Self::Paragraph,
position: Point,
_: Color,
clip: Rectangle,
) {
let buffer = paragraph.buffer();
let text = buffer
.lines
.iter()
.map(|line| line.text())
.collect::<Vec<_>>()
.join("\n");
if !self.wanted.contains(&text) {
return;
}
let clip = self.clip(clip);
for run in buffer.layout_runs() {
for glyph in run.glyphs {
if run.text[glyph.start..glyph.end]
.chars()
.all(char::is_whitespace)
{
continue;
}
let bounds = Rectangle {
x: position.x + glyph.x + glyph.x_offset * glyph.font_size,
y: position.y + run.line_top,
width: glyph.w,
height: run.line_height,
} * self.transform();
self.glyphs.push(Glyph {
text: text.clone(),
paragraph_y: position.y.to_bits(),
line: run.line_i,
bytes: glyph.start..glyph.end,
bounds,
clip,
});
}
}
}
fn fill_editor(
&mut self,
editor: &Self::Editor,
position: Point,
color: Color,
clip: Rectangle,
) {
shaped::Renderer::fill_editor(&mut FontLayout, editor, position, color, clip);
}
fn fill_text(&mut self, text: shaped::Text, position: Point, color: Color, clip: Rectangle) {
shaped::Renderer::fill_text(&mut FontLayout, text, position, color, clip);
}
}
#[derive(Default)]
struct ScrollGeometry {
id: Option<widget::Id>,
bounds: Option<Rectangle>,
content: Option<Rectangle>,
offset: Vector,
}
impl widget::Operation for ScrollGeometry {
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn widget::Operation)) {
visit(self);
}
fn scrollable(
&mut self,
id: Option<&widget::Id>,
bounds: Rectangle,
content: Rectangle,
offset: Vector,
_: &mut dyn widget::operation::Scrollable,
) {
if id == self.id.as_ref() {
self.bounds = Some(bounds);
self.content = Some(content);
self.offset = offset;
}
}
}
fn normalized(text: &str) -> String {
text.replace("\r\n", "\n").replace('\r', "\n")
}
fn required_chars(text: &str) -> BTreeSet<(usize, usize)> {
text.split('\n')
.enumerate()
.flat_map(|(line, text)| {
text.char_indices()
.filter(|(_, ch)| !ch.is_whitespace())
.map(move |(byte, _)| (line, byte))
})
.collect()
}
fn declarations(long: bool) -> App {
let name = if long {
format!("Assess {} request", "界🚀".repeat(90))
} else {
"Assess 救援 request".into()
};
let source = SourceBundle { entry: "project.arcadia".into(), files: BTreeMap::from([
("project.arcadia".into(), "arcadia 7;\r\nproject navigation_trial \"Navigation — 救援\" id \"9e8f0000-0000-4000-8000-000000000001\" {\r\n include \"救援/assess.arcadia\";\r\n}\r\n".into()),
("救援/assess.arcadia".into(), format!("// Comment @assess must not be a lexical use.\r\noa_activity assess \"{name}\" id \"9e8f0000-0000-4000-8000-000000000002\" {{\r\n description \"Keep @assess as prose, not a use.\";\r\n}}\r\n")),
]) };
let mut app = fixture();
app.document = Document::from_bundle(source, LIMITS).unwrap();
app.source_workspace = Workspace::new(app.document.bundle(), true);
app.source_workspace.active = "救援/assess.arcadia".into();
app.selected = app.project().id;
app.refresh_draft();
app.show_source_file(false);
let start = app
.source_workspace
.text()
.find("oa_activity assess")
.unwrap()
+ "oa_activity ".len();
app.source_editor.move_to(cursor(
app.source_workspace.text(),
start,
start + "assess".len(),
));
begin(&mut app);
let replacement = if long {
"a".repeat(255)
} else {
"assess_damage".into()
};
input(&mut app, &replacement);
let _ = preview(&mut app);
app
}
fn assert_page_glyphs_visible(app: &App, host: Size) {
let form = app.source_rename.form.as_ref().unwrap();
let ready = form.approval.as_ref().unwrap();
let file = &ready.preview.files()[form.file];
let mut expected = BTreeMap::<String, usize>::new();
for line in file.hunks()[form.hunk]
.lines()
.iter()
.skip(form.line)
.take(LINES_PER_PAGE)
{
for source in [
line.before_text(&ready.prepared.before().bundle.files[file.path()])
.unwrap(),
line.after_text(&ready.prepared.after().bundle.files[file.path()])
.unwrap(),
] {
if let Some(fragment) = preview::text_segment(source, form.segment) {
let text = normalized(fragment);
if !required_chars(&text).is_empty() {
*expected.entry(text).or_default() += 1;
}
}
}
}
assert!(!expected.is_empty());
let viewport = Rectangle::new(Point::ORIGIN, host);
let mut renderer = GlyphRecorder::new(expected.keys().cloned().collect(), viewport);
let editor = text_editor::Content::<GlyphRecorder>::new();
let mut view = dialog_content(app, &editor);
let mut tree = widget::Tree::new(view.as_widget());
let node =
view.as_widget_mut()
.layout(&mut tree, &renderer, &layout::Limits::new(Size::ZERO, host));
let mut geometry = ScrollGeometry {
id: Some(preview_scroll_id(form, ready.id)),
..ScrollGeometry::default()
};
view.as_widget_mut()
.operate(&mut tree, Layout::new(&node), &renderer, &mut geometry);
let bounds = geometry.bounds.unwrap();
let content = geometry.content.unwrap();
assert!(bounds.height >= 40.);
let maximum = (content.height - bounds.height).max(0.);
let steps = (maximum / (bounds.height * 0.5)).ceil() as usize;
let mut visible = BTreeMap::<(String, u32), BTreeSet<(usize, usize)>>::new();
let mut reserved_bar_gap = true;
for step in 0..=steps {
let offset = (step as f32 * bounds.height * 0.5).min(maximum);
view.as_widget_mut().operate(
&mut tree,
Layout::new(&node),
&renderer,
&mut widget::operation::scrollable::scroll_to(
preview_scroll_id(form, ready.id),
widget::operation::scrollable::AbsoluteOffset {
x: None,
y: Some(offset),
},
),
);
view.as_widget_mut()
.operate(&mut tree, Layout::new(&node), &renderer, &mut geometry);
assert!(
(geometry.offset.y - offset).abs() <= 0.5,
"actual scroll operation reaches the requested source offset"
);
renderer::Renderer::reset(&mut renderer, viewport);
view.as_widget().draw(
&tree,
&mut renderer,
&iced::Theme::Light,
&renderer::Style::default(),
Layout::new(&node),
iced::mouse::Cursor::Unavailable,
&viewport,
);
// The vertical scrollbar's actual painted thumb fixes its excluded x
// region; the outer scroll clip alone cannot detect overpainting.
let scrollbar_left = renderer
.quads
.iter()
.filter(|quad| {
quad.width > 0.
&& quad.width <= 16.
&& quad.height > 0.
&& quad.x >= bounds.x + bounds.width - 18.
&& quad.x + quad.width <= bounds.x + bounds.width + 0.5
&& quad.y >= bounds.y - 0.5
&& quad.y + quad.height <= bounds.y + bounds.height + 0.5
})
.map(|quad| quad.x)
.min_by(f32::total_cmp);
if maximum > 0. {
let left = scrollbar_left.expect("overflowing actual preview must paint its scrollbar");
reserved_bar_gap &= content.x + content.width <= left - 7.5;
}
for glyph in &renderer.glyphs {
let right = glyph.bounds.x + glyph.bounds.width;
if let Some(left) = scrollbar_left {
assert!(
right <= left - 0.5,
"source glyph under scrollbar: text={:?} cluster={:?} glyph={:?} bar x={left}",
glyph.text,
glyph.bytes,
glyph.bounds
);
}
assert!(
glyph.bounds.x >= content.x - 0.5 && right <= content.x + content.width + 0.5,
"source glyph escaped reserved width: {:?} {:?}",
glyph.text,
glyph.bounds
);
assert!(
glyph.bounds.x >= glyph.clip.x - 0.5
&& right <= glyph.clip.x + glyph.clip.width + 0.5,
"source glyph horizontally clipped: {:?} {:?} {:?}",
glyph.text,
glyph.bounds,
glyph.clip
);
if glyph.bounds.y >= glyph.clip.y - 0.5
&& glyph.bounds.y + glyph.bounds.height <= glyph.clip.y + glyph.clip.height + 0.5
{
let logical_line = glyph.text.split('\n').nth(glyph.line).unwrap();
let chars = logical_line
.char_indices()
.filter(|(byte, ch)| glyph.bytes.contains(byte) && !ch.is_whitespace())
.map(|(byte, _)| (glyph.line, byte));
visible
.entry((glyph.text.clone(), glyph.paragraph_y))
.or_default()
.extend(chars);
}
}
}
assert!(
reserved_bar_gap,
"source content must reserve actual scrollbar width plus gap"
);
assert!(
bounds.width >= host.width - 30.,
"preview must use available dialog width: {bounds:?} / {host:?}"
);
for (text, count) in expected {
let required = required_chars(&text);
let instances: Vec<_> = visible
.iter()
.filter(|((value, _), _)| value == &text)
.collect();
assert_eq!(
instances.len(),
count,
"all before/after occurrences must be seen: {text:?}"
);
for (_, seen) in instances {
assert_eq!(
seen, &required,
"every non-whitespace source cluster must be fully visible at some vertical offset: {text:?}"
);
}
}
}
#[test]
fn actual_preview_source_glyphs_clear_scrollbar_and_all_long_fragments_are_reachable() {
for long in [false, true] {
let mut app = declarations(long);
let ready = app
.source_rename
.form
.as_ref()
.unwrap()
.approval
.as_ref()
.unwrap()
.clone();
let invocation = app.source_rename.form.as_ref().unwrap().invocation;
let before = evidence(&app);
for (width, height) in [(1100., 700.), (580., 700.)] {
app.window_size = Size::new(width, height);
let host = Size::new((width - 84.).min(708.), height - 120.);
let total = segment_total(app.source_rename.form.as_ref().unwrap());
if long {
assert!(
total >= 3,
"include unbroken ASCII and wide Unicode across segments"
);
}
for segment in 0..total {
assert_page_glyphs_visible(&app, host);
if segment + 1 < total {
event(&mut app, Event::Segment(invocation, ready.id, true));
}
}
for _ in 1..total {
event(&mut app, Event::Segment(invocation, ready.id, false));
}
}
assert_eq!(
evidence(&app),
before,
"preview geometry/paging cannot alter transaction inputs"
);
assert_eq!(
app.source_rename
.form
.as_ref()
.unwrap()
.approval
.as_ref()
.unwrap()
.id,
ready.id
);
}
}
@@ -0,0 +1,310 @@
//! Execute the controller's scroll operations on a retained, scrolled dialog.
use super::*;
use crate::views::source_tests::FontLayout;
use iced::advanced::{Layout, layout, widget};
use iced::futures::StreamExt;
use iced::{Rectangle, Size, Vector};
const HOST: Size = Size::new(720., 660.);
fn paged_fixture() -> App {
let mut source = bundle();
let name = "Long engineering name 雨 ".repeat(18);
source.files.get_mut("defs.arcadia").unwrap().replace_range(
..,
&format!(
"oa_activity pump \"{name}\" id \"00000000-0000-0000-0000-000000000002\" {{}}\r\n\
oa_activity backup \"Backup\" id \"00000000-0000-0000-0000-000000000004\" {{}}\r\n"
),
);
let mut uses = String::new();
for index in 0..16 {
if index == 12 {
uses.push_str(&"// unchanged gap\r\n".repeat(10));
}
uses.push_str(&format!(
"interaction use_{index} \"{name}\" id \"00000000-0000-0000-0001-{index:012}\" {{ source @pump; target @backup; }}\r\n"
));
}
source.files.insert("uses.arcadia".into(), uses);
let mut app = fixture();
app.window_size = HOST;
app.document = Document::from_bundle(source, LIMITS).unwrap();
app.source_workspace = Workspace::new(app.document.bundle(), true);
app.source_workspace.active = "uses.arcadia".into();
app.show_source_file(false);
let start = app.source_workspace.text().find("source @pump").unwrap() + "source @".len();
app.source_editor
.move_to(cursor(app.source_workspace.text(), start, start + 4));
begin(&mut app);
input(&mut app, "pump_long");
let _ = preview(&mut app);
app
}
fn scroll_id(app: &App) -> widget::Id {
let form = app.source_rename.form.as_ref().unwrap();
preview_scroll_id(form, form.approval.as_ref().unwrap().id)
}
struct Geometry {
id: widget::Id,
offset: Option<Vector>,
}
impl widget::Operation for Geometry {
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn widget::Operation)) {
visit(self);
}
fn scrollable(
&mut self,
id: Option<&widget::Id>,
_: Rectangle,
_: Rectangle,
offset: Vector,
_: &mut dyn widget::operation::Scrollable,
) {
if id == Some(&self.id) {
self.offset = Some(offset);
}
}
}
struct Mounted {
editor: text_editor::Content<FontLayout>,
tree: widget::Tree,
}
impl Mounted {
fn new(app: &App) -> Self {
let editor = text_editor::Content::<FontLayout>::new();
let tree = widget::Tree::new(dialog_content(app, &editor).as_widget());
Self { editor, tree }
}
fn operate(&mut self, app: &App, operation: &mut dyn widget::Operation) {
let mut view = dialog_content(app, &self.editor);
// Preserve the actual scroll state across the new page's view diff.
self.tree.diff(view.as_widget());
let node = view.as_widget_mut().layout(
&mut self.tree,
&FontLayout,
&layout::Limits::new(Size::ZERO, HOST),
);
view.as_widget_mut()
.operate(&mut self.tree, Layout::new(&node), &FontLayout, operation);
}
fn offset(&mut self, app: &App) -> f32 {
let mut geometry = Geometry {
id: scroll_id(app),
offset: None,
};
self.operate(app, &mut geometry);
geometry.offset.expect("mounted preview scrollable").y
}
fn scroll_down(&mut self, app: &App) -> f32 {
self.operate(
app,
&mut widget::operation::scrollable::scroll_to(
scroll_id(app),
widget::operation::scrollable::AbsoluteOffset {
x: None,
y: Some(10_000.),
},
),
);
let offset = self.offset(app);
assert!(offset > 10., "fixture must really be scrolled");
offset
}
async fn execute(&mut self, app: &App, task: Task<Message>) -> usize {
let mut count = 0;
if let Some(mut actions) = iced_runtime::task::into_stream(task) {
while let Some(action) = actions.next().await {
let iced_runtime::Action::Widget(mut operation) = action else {
panic!("preview paging must only schedule a widget operation");
};
self.operate(app, operation.as_mut());
count += 1;
}
}
count
}
}
#[tokio::test]
async fn explicit_navigation_resets_retained_scroll_but_id_change_alone_does_not() {
let mut app = paged_fixture();
let before = evidence(&app);
let form = app.source_rename.form.as_ref().unwrap();
let invocation = form.invocation;
let approval = form.approval.as_ref().unwrap().id;
let mut mounted = Mounted::new(&app);
mounted.scroll_down(&app);
let old_id = scroll_id(&app);
let task = app.update(Message::SourceRename(Event::File(
invocation, approval, true,
)));
assert_ne!(old_id, scroll_id(&app));
// Negative control: even after diff/layout with the new id and new file,
// Iced retains the previous offset until the returned operation executes.
assert!(mounted.offset(&app) > 10.);
assert_eq!(mounted.execute(&app, task).await, 1);
assert_eq!(mounted.offset(&app), 0.);
for navigation in [
Event::Line(invocation, approval, true),
Event::Segment(invocation, approval, true),
Event::Hunk(invocation, approval, true),
Event::File(invocation, approval, false),
Event::Search(invocation, "uses".into()),
Event::Search(invocation, "no-path-match".into()),
Event::Search(invocation, String::new()),
] {
mounted.scroll_down(&app);
let task = app.update(Message::SourceRename(navigation));
assert_eq!(mounted.execute(&app, task).await, 1);
assert_eq!(mounted.offset(&app), 0.);
assert_eq!(evidence(&app), before);
}
// Passive redraw and unchanged filter input preserve deliberate scrolling.
let offset = mounted.scroll_down(&app);
assert_eq!(mounted.offset(&app), offset);
let task = app.update(Message::SourceRename(Event::Search(
invocation,
String::new(),
)));
assert_eq!(mounted.execute(&app, task).await, 0);
assert_eq!(mounted.offset(&app), offset);
let task = app.update(Message::SourceRename(Event::Input(
invocation,
"pump_long".into(),
)));
assert_eq!(mounted.execute(&app, task).await, 0);
assert_eq!(mounted.offset(&app), offset);
let task = app.update(Message::SourceRename(Event::File(
invocation, approval, false,
)));
assert_eq!(
mounted.execute(&app, task).await,
0,
"clamped first-page request is a no-op"
);
assert_eq!(mounted.offset(&app), offset);
assert_eq!(evidence(&app), before);
}
#[tokio::test]
async fn delayed_page_and_approval_operations_cannot_reset_a_new_destination() {
let mut app = paged_fixture();
let before = evidence(&app);
let form = app.source_rename.form.as_ref().unwrap();
let invocation = form.invocation;
let old_approval = form.approval.as_ref().unwrap().id;
let mut mounted = Mounted::new(&app);
let old_page_task = app.update(Message::SourceRename(Event::File(
invocation,
old_approval,
true,
)));
let next_page_task = app.update(Message::SourceRename(Event::Line(
invocation,
old_approval,
true,
)));
assert_eq!(mounted.execute(&app, next_page_task).await, 1);
let offset = mounted.scroll_down(&app);
assert_eq!(mounted.execute(&app, old_page_task).await, 1);
assert_eq!(mounted.offset(&app), offset, "old page target is absent");
let old_approval_task = app.update(Message::SourceRename(Event::Search(
invocation,
"defs".into(),
)));
input(&mut app, "pump_new");
let _ = preview(&mut app); // Real new approval in the same invocation/page.
assert_ne!(
app.source_rename
.form
.as_ref()
.unwrap()
.approval
.as_ref()
.unwrap()
.id,
old_approval
);
let offset = mounted.scroll_down(&app);
assert_eq!(mounted.execute(&app, old_approval_task).await, 1);
assert_eq!(
mounted.offset(&app),
offset,
"old approval target is absent"
);
let task = app.update(Message::SourceRename(Event::File(
invocation,
old_approval,
true,
)));
assert_eq!(
mounted.execute(&app, task).await,
0,
"stale envelope is rejected before scheduling"
);
assert_eq!(mounted.offset(&app), offset);
assert_eq!(evidence(&app), before);
}
#[tokio::test]
async fn repeated_page_visits_filters_and_fast_preview_completion_have_distinct_destinations() {
let mut app = paged_fixture();
let before = evidence(&app);
let form = app.source_rename.form.as_ref().unwrap();
let invocation = form.invocation;
let approval = form.approval.as_ref().unwrap().id;
let mut mounted = Mounted::new(&app);
let first_visit = app.update(Message::SourceRename(Event::File(
invocation, approval, true,
)));
let next_page = app.update(Message::SourceRename(Event::Line(
invocation, approval, true,
)));
assert_eq!(mounted.execute(&app, next_page).await, 1);
let return_visit = app.update(Message::SourceRename(Event::Line(
invocation, approval, false,
)));
assert_eq!(mounted.execute(&app, return_visit).await, 1);
let offset = mounted.scroll_down(&app);
assert_eq!(mounted.execute(&app, first_visit).await, 1);
assert_eq!(
mounted.offset(&app),
offset,
"old visit to the identical page is stale"
);
let old_filter = app.update(Message::SourceRename(Event::Search(
invocation,
"uses".into(),
)));
let new_filter = app.update(Message::SourceRename(Event::Search(
invocation,
"us".into(),
)));
assert_eq!(mounted.execute(&app, new_filter).await, 1);
let offset = mounted.scroll_down(&app);
assert_eq!(mounted.execute(&app, old_filter).await, 1);
assert_eq!(
mounted.offset(&app),
offset,
"old filter on the same page is stale"
);
input(&mut app, "pump_new");
let job = start_preview(&mut app);
let completion = preview_completion(&app, job);
// No intermediate frame mounts the temporary no-approval view. The old
// Scrollable survives even though the new approval has a different id.
let ready = app.update(Message::SourceRenameReady(completion));
assert!(mounted.offset(&app) > 10.);
assert_eq!(mounted.execute(&app, ready).await, 1);
assert_eq!(mounted.offset(&app), 0.);
assert_eq!(evidence(&app), before);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,313 @@
//! Manual replacement + next-layout/highlight evaluator; no GPU or native paint.
//! Child of source_rename_timing_tests, preserving the separate real App test.
use super::*;
use crate::{highlight, views::source_tests::FontLayout};
use iced::advanced::widget::operation::Focusable;
use iced::advanced::{Layout, graphics, layout, renderer, text as shaped, widget::Tree};
use iced::{
Background, Color, Element, Font, Length, Pixels, Point, Rectangle, Size, Transformation,
};
// The same real Paragraph and Editor as FontLayout. During draw, retain only a
// Weak and fixed geometry; inspect glyphs after timing, never in fill_editor.
#[derive(Default)]
struct ObservedFontLayout {
drawn: Option<(graphics::text::editor::Weak, Point, Rectangle)>,
}
impl renderer::Renderer for ObservedFontLayout {
fn start_layer(&mut self, _: Rectangle) {}
fn end_layer(&mut self) {}
fn start_transformation(&mut self, _: Transformation) {}
fn end_transformation(&mut self) {}
fn reset(&mut self, _: Rectangle) {
self.drawn = None;
}
fn fill_quad(&mut self, _: renderer::Quad, _: impl Into<Background>) {}
fn allocate_image(
&mut self,
handle: &iced::advanced::image::Handle,
callback: impl FnOnce(Result<iced::advanced::image::Allocation, iced::advanced::image::Error>)
+ Send
+ 'static,
) {
renderer::Renderer::allocate_image(&mut FontLayout, handle, callback);
}
}
impl shaped::Renderer for ObservedFontLayout {
type Font = Font;
type Paragraph = <FontLayout as shaped::Renderer>::Paragraph;
type Editor = <FontLayout as shaped::Renderer>::Editor;
const ICON_FONT: Font = <FontLayout as shaped::Renderer>::ICON_FONT;
const CHECKMARK_ICON: char = <FontLayout as shaped::Renderer>::CHECKMARK_ICON;
const ARROW_DOWN_ICON: char = <FontLayout as shaped::Renderer>::ARROW_DOWN_ICON;
const SCROLL_UP_ICON: char = <FontLayout as shaped::Renderer>::SCROLL_UP_ICON;
const SCROLL_DOWN_ICON: char = <FontLayout as shaped::Renderer>::SCROLL_DOWN_ICON;
const SCROLL_LEFT_ICON: char = <FontLayout as shaped::Renderer>::SCROLL_LEFT_ICON;
const SCROLL_RIGHT_ICON: char = <FontLayout as shaped::Renderer>::SCROLL_RIGHT_ICON;
const ICED_LOGO: char = <FontLayout as shaped::Renderer>::ICED_LOGO;
fn default_font(&self) -> Font {
shaped::Renderer::default_font(&FontLayout)
}
fn default_size(&self) -> Pixels {
shaped::Renderer::default_size(&FontLayout)
}
fn fill_paragraph(&mut self, _: &Self::Paragraph, _: Point, _: Color, _: Rectangle) {}
fn fill_editor(&mut self, editor: &Self::Editor, position: Point, _: Color, clip: Rectangle) {
self.drawn = Some((editor.downgrade(), position, clip));
}
fn fill_text(&mut self, _: shaped::Text, _: Point, _: Color, _: Rectangle) {}
}
type Content = text_editor::Content<ObservedFontLayout>;
const HOST: Size = Size::new(640., 350.);
fn view(editor: &Content, source: Arc<str>) -> Element<'_, (), iced::Theme, ObservedFontLayout> {
text_editor(editor)
.on_action(|_| ())
.font(Font::MONOSPACE)
.size(14)
.padding(16)
.height(Length::Fill)
.highlight_with::<highlight::ArcadiaHighlighter>(source, highlight::format)
.into()
}
fn check_text(editor: &Content, expected: &str, cursor: text_editor::Cursor) {
assert_eq!(editor.text(), expected);
assert_eq!(editor.cursor(), cursor);
let lines: Vec<_> = expected.split('\n').collect();
assert_eq!(editor.line_count(), lines.len());
for (index, part) in lines.iter().enumerate() {
let (text, ending) = if index + 1 == lines.len() {
(*part, text_editor::LineEnding::None)
} else if let Some(text) = part.strip_suffix('\r') {
(text, text_editor::LineEnding::CrLf)
} else {
(*part, text_editor::LineEnding::Lf)
};
let actual = editor.line(index).unwrap();
assert_eq!(actual.text, text);
assert_eq!(actual.ending, ending);
}
}
fn check_draw(renderer: &ObservedFontLayout, cursor: text_editor::Cursor, alias: &str) {
let (weak, position, clip) = renderer.drawn.as_ref().expect("real editor draw");
let editor = weak.upgrade().expect("drawn editor is still live");
let buffer = editor.buffer();
assert_eq!(weak.bounds, Size::new(608., 318.));
assert_eq!(buffer.size(), (Some(608.), Some(318.)));
assert_eq!(buffer.metrics().font_size, 14.);
assert_eq!(
buffer.scroll().horizontal,
0.,
"fixture origin fits the viewport"
);
let start = cursor.position;
let end = cursor.selection.expect("approved alias selection");
assert_eq!(start.line, end.line);
assert_eq!(end.column - start.column, alias.len());
let line = &buffer.lines[start.line];
assert_eq!(&line.text()[start.column..end.column], alias);
let color = graphics::text::to_color(Color::from_rgb8(20, 93, 139));
let font = graphics::text::to_attributes(Font::MONOSPACE);
for byte in start.column..end.column {
let attrs = line.attrs_list().get_span(byte);
assert_eq!(
attrs.color_opt,
Some(color),
"accepted alias syntax attributes"
);
assert_eq!(attrs.family, font.family);
}
let mut covered = vec![false; alias.len()];
for run in buffer.layout_runs().filter(|run| run.line_i == start.line) {
for glyph in run.glyphs {
let from = glyph.start.max(start.column);
let to = glyph.end.min(end.column);
if from >= to {
continue;
}
assert_eq!(glyph.color_opt, Some(color), "drawn alias glyph color");
let x = position.x + glyph.x + glyph.x_offset * glyph.font_size;
let y = position.y + run.line_top;
assert!(x >= clip.x - 0.5 && x + glyph.w <= clip.x + clip.width + 0.5);
assert!(y + run.line_height > clip.y && y < clip.y + clip.height);
covered[from - start.column..to - start.column].fill(true);
}
}
assert!(
covered.into_iter().all(|seen| seen),
"entire alias has visible shaped glyphs"
);
// This temporary strong upgrade is dropped before the next editor mutation.
}
#[derive(Clone, Copy)]
enum Arm {
Constructor,
Reuse,
}
impl Arm {
fn name(self) -> &'static str {
match self {
Self::Constructor => "with_text",
Self::Reuse => "same_line_reuse",
}
}
fn phases(self) -> [&'static str; 4] {
match self {
Self::Constructor => [
"constructor_replace_origin",
"constructor_next_layout",
"constructor_highlight_draw",
"constructor_combined",
],
Self::Reuse => [
"reuse_replace_origin",
"reuse_next_layout",
"reuse_highlight_draw",
"reuse_combined",
],
}
}
}
fn trial(
fixture: &Fixture,
cursor: text_editor::Cursor,
arm: Arm,
trial: usize,
samples: &mut Samples,
) {
let before = &fixture.bundle.files[ORIGIN];
let after = &fixture.expected_bundle.files[ORIGIN];
let mut editor = Content::with_text(before);
editor.move_to(fixture.cursor);
let mut renderer = ObservedFontLayout::default();
let theme = iced::Theme::Light;
let defaults = renderer::Style::default();
let viewport = Rectangle::with_size(HOST);
let limits = layout::Limits::new(Size::ZERO, HOST);
let mut tree = {
let mut widget = view(&editor, Arc::from(before.as_str()));
let mut tree = Tree::new(widget.as_widget());
let node = widget.as_widget_mut().layout(&mut tree, &renderer, &limits);
tree.state
.downcast_mut::<text_editor::State<highlight::ArcadiaHighlighter>>()
.focus();
widget.as_widget().draw(
&tree,
&mut renderer,
&theme,
&defaults,
Layout::new(&node),
iced::mouse::Cursor::Unavailable,
&viewport,
);
tree
};
check_text(&editor, before, fixture.cursor);
check_draw(&renderer, fixture.cursor, OLD);
// No recorder Weak/Strong from setup may perturb replacement ownership.
renderer.drawn = None;
let next_source: Arc<str> = Arc::from(after.as_str());
// Only normal operations and four timestamps occur between start and end.
// Source/model oracles, logging and recorder glyph inspection are outside.
let started = Instant::now();
let replaced = match arm {
Arm::Constructor => {
editor = Content::with_text(after);
true
}
Arm::Reuse => super::super::editor_patch::replace(&mut editor, before, after),
};
editor.move_to(cursor);
let replaced_at = Instant::now();
let mut widget = view(&editor, next_source);
let node = widget.as_widget_mut().layout(&mut tree, &renderer, &limits);
let laid_out_at = Instant::now();
widget.as_widget().draw(
&tree,
&mut renderer,
&theme,
&defaults,
Layout::new(&node),
iced::mouse::Cursor::Unavailable,
&viewport,
);
let drawn_at = Instant::now();
drop(widget);
assert!(replaced, "reuse must not silently time a fallback/refusal");
check_text(&editor, after, cursor);
check_draw(&renderer, cursor, NEW);
renderer.drawn = None;
for (phase, duration) in arm.phases().into_iter().zip([
replaced_at.duration_since(started),
laid_out_at.duration_since(replaced_at),
drawn_at.duration_since(laid_out_at),
drawn_at.duration_since(started),
]) {
println!(
"{}",
serde_json::json!({
"record": "editor_layout_timing", "arm": arm.name(), "phase": phase,
"trial": trial, "warmup": trial == 0,
"elapsed_ns": duration.as_nanos().to_string(),
"elapsed_ms": duration.as_secs_f64() * 1000.0,
})
);
if trial != 0 {
samples.0.entry(phase).or_default().push(duration);
}
}
}
#[test]
#[ignore = "manual Windows dev/debug editor replacement + layout/highlight trial; run alone with --ignored --nocapture --test-threads=1"]
fn accepted_release1_constructor_vs_reuse_through_next_highlight() {
let fixture = Fixture::load();
// Resolve the exact production approval and verify full Project/source once,
// outside all intervals. The actual App Confirm evaluator remains separate.
let cursor = {
let app = fixture.app();
let target = resolve(&app, &app.rename_context());
let prepared = app
.document
.alias_rename_request(target.id, NEW.into())
.unwrap()
.prepare()
.unwrap();
fixture.check_candidate(&prepared);
prepare_approval(prepared, &target, ORIGIN).unwrap().cursor
};
println!(
"{}",
serde_json::json!({
"record": "editor_layout_fixture", "host_width": HOST.width, "host_height": HOST.height,
"font": "MONOSPACE", "font_size": 14, "padding": 16, "height": "Fill",
"active_file": ORIGIN, "before_bytes": fixture.bundle.files[ORIGIN].len(),
"after_bytes": fixture.expected_bundle.files[ORIGIN].len(), "warmups_per_arm": 1,
"measured_trials_per_arm": 5, "debug_assertions": cfg!(debug_assertions),
"scope": "real editor replacement, retained Tree layout and Widget draw/highlight; no GPU, native paint or App Confirm",
})
);
let mut samples = Samples::default();
for index in 0..=5 {
let arms = if index % 2 == 0 {
[Arm::Constructor, Arm::Reuse]
} else {
[Arm::Reuse, Arm::Constructor]
};
println!(
"{}",
serde_json::json!({"record": "editor_layout_order", "trial": index, "first": arms[0].name()})
);
for arm in arms {
trial(&fixture, cursor, arm, index, &mut samples);
}
}
samples.report();
}
@@ -0,0 +1,347 @@
//! Manual timing through the real Document and App paths. No disk writes,
//! executor polling, layout, GPU work or first-paint measurement occurs here.
//! Register as a test child of app::source_rename to use its actual worker helpers.
use super::*;
use crate::source_workspace::Workspace;
use oa_core::source_project::SourceBundle;
use std::{
collections::BTreeMap,
path::PathBuf,
time::{Duration, Instant},
};
#[cfg(debug_assertions)]
#[path = "source_rename_layout_timing_tests.rs"]
mod layout_timing_tests;
const OLD: &str = "oa_restore_work";
const NEW: &str = "oa_restore_pressure";
const DECLARATIONS: &str = "oa/activities/activity_oa_restore_work.arcadia";
const ORIGIN: &str = "traceability.arcadia";
struct Fixture {
bundle: SourceBundle,
project: Project,
expected_bundle: SourceBundle,
expected_project: Project,
target: Id,
cursor: text_editor::Cursor,
}
impl Fixture {
fn load() -> Self {
// The optional entry selects a parent-pinned copy of this exact fixture,
// not an arbitrary performance generator or a mutable GUI destination.
let entry = std::env::var_os("ARCADIA_RENAME_TIMING_ENTRY")
.map(PathBuf::from)
.unwrap_or_else(|| {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../examples/release1/project.arcadia")
});
let loaded = oa_core::source_files::load_project(&entry, LIMITS).unwrap();
let bundle = loaded.reachable_bundle();
assert_eq!(
bundle.files.len(),
30,
"use the preserved release1 walkthrough"
);
let document = Document::from_bundle(bundle.clone(), LIMITS).unwrap();
assert_eq!(
document.bundle(),
&bundle,
"fixture must need no stabilization/migration"
);
assert!(!document.dirty());
let project = document.project().clone();
let target = Id::parse_str("1b91f8a7-0bdb-514e-b1cd-955658e09927").unwrap();
assert_eq!(project.elements[&target].alias, OLD);
assert!(!project.identities().iter().any(|(_, alias)| *alias == NEW));
let mut expected_project = project.clone();
expected_project.elements.get_mut(&target).unwrap().alias = NEW.into();
// Independent literal source oracle, authored before running the rename.
// Do not replace the alias substring in filenames or the trace's alias.
let mut expected_bundle = bundle.clone();
let declarations = expected_bundle.files.get_mut(DECLARATIONS).unwrap();
assert_eq!(
declarations.matches("oa_activity oa_restore_work ").count(),
1
);
assert_eq!(declarations.matches("parent @oa_restore_work;").count(), 4);
*declarations = declarations
.replace(
"oa_activity oa_restore_work ",
"oa_activity oa_restore_pressure ",
)
.replace("parent @oa_restore_work;", "parent @oa_restore_pressure;");
let traces = expected_bundle.files.get_mut(ORIGIN).unwrap();
assert_eq!(traces.matches("oa @oa_restore_work;").count(), 1);
*traces = traces.replace("oa @oa_restore_work;", "oa @oa_restore_pressure;");
let source = &bundle.files[ORIGIN];
let start = source.rfind("@oa_restore_work;").unwrap() + 1;
let position = |offset| {
let prefix = &source[..offset];
text_editor::Position {
line: prefix.bytes().filter(|byte| *byte == b'\n').count(),
column: prefix
.rsplit_once('\n')
.map_or(prefix.len(), |(_, tail)| tail.len()),
}
};
let cursor = text_editor::Cursor {
position: position(start),
selection: Some(position(start + OLD.len())),
};
assert!(nav::exact_cursor(source, cursor));
println!(
"{}",
serde_json::json!({
"record": "fixture", "entry": entry, "reachable_files": bundle.files.len(),
"source_bytes": bundle.files.values().map(String::len).sum::<usize>(),
"identities": project.identities().len(), "active_file": ORIGIN,
"active_file_bytes": source.len(), "old": OLD, "new": NEW,
"warmups": 1, "measured_trials": 5, "debug_assertions": cfg!(debug_assertions),
"scope": "real worker helpers, guarded Document commit, App sync and Confirm update; no task polling or rendering"
})
);
Self {
bundle,
project,
expected_bundle,
expected_project,
target,
cursor,
}
}
fn app(&self) -> App {
let mut app = App::with_project(Project::empty());
app.document = Document::from_bundle(self.bundle.clone(), LIMITS).unwrap();
app.source_workspace = Workspace::new(app.document.bundle(), true);
app.source_workspace.active = ORIGIN.into();
app.selected = app.project().id;
app.page = Page::Text;
app.sync();
app.source_editor.move_to(self.cursor);
assert_eq!(app.project(), &self.project);
assert_eq!(app.document.bundle(), &self.bundle);
assert_eq!(app.document.revision(), 0);
assert!(!app.document.dirty() && !app.document.can_undo() && !app.document.can_redo());
assert!(app.rename_admission().is_ok());
app
}
fn check_candidate(&self, prepared: &PreparedAliasRename) {
assert!(prepared.changed());
assert_eq!(prepared.before().bundle, self.bundle);
assert_eq!(prepared.before().decoded.project, self.project);
assert_eq!(prepared.after().bundle, self.expected_bundle);
assert_eq!(prepared.after().decoded.project, self.expected_project);
}
fn check_adopted(&self, app: &mut App, candidate_address: *const CompiledSourceProject) {
assert!(
std::ptr::eq(app.document.compiled(), candidate_address),
"adopt exact candidate allocation"
);
assert_eq!(app.project(), &self.expected_project);
assert_eq!(app.document.bundle(), &self.expected_bundle);
assert_eq!(app.source_workspace.bundle(), self.expected_bundle);
assert_eq!(app.source_editor.text(), self.expected_bundle.files[ORIGIN]);
assert_eq!(app.document.revision(), 1);
assert!(app.document.dirty() && app.document.can_undo() && !app.document.can_redo());
assert!(app.document.undo());
assert_eq!(app.project(), &self.project);
assert_eq!(app.document.bundle(), &self.bundle);
assert!(
!app.document.dirty(),
"live known saved baseline remains authoritative"
);
assert!(
!app.document.can_undo(),
"exactly one preceding model transaction"
);
assert!(app.document.redo());
assert_eq!(app.project(), &self.expected_project);
assert_eq!(app.document.bundle(), &self.expected_bundle);
assert!(std::ptr::eq(app.document.compiled(), candidate_address));
}
}
#[derive(Default)]
struct Samples(BTreeMap<&'static str, Vec<Duration>>);
impl Samples {
fn time<T>(&mut self, phase: &'static str, trial: usize, action: impl FnOnce() -> T) -> T {
let started = Instant::now();
let result = action();
let elapsed = started.elapsed();
// Printing, assertions and dropping the returned value are outside this
// interval. Full Confirm still includes production's own local drops.
println!(
"{}",
serde_json::json!({
"record": "timing", "phase": phase, "trial": trial,
"warmup": trial == 0, "elapsed_ns": elapsed.as_nanos().to_string(),
"elapsed_ms": elapsed.as_secs_f64() * 1000.0
})
);
if trial != 0 {
self.0.entry(phase).or_default().push(elapsed);
}
result
}
fn report(mut self) {
for (phase, values) in &mut self.0 {
assert_eq!(values.len(), 5);
values.sort();
println!(
"{}",
serde_json::json!({
"record": "summary", "phase": phase, "samples": values.len(),
"min_ms": values[0].as_secs_f64() * 1000.0,
"median_ms": values[2].as_secs_f64() * 1000.0,
"max_ms": values[4].as_secs_f64() * 1000.0
})
);
}
}
}
fn resolve(app: &App, context: &Context) -> Target {
let snapshot = app
.with_source_input(|input| query::admit_owned(input, query::Limits::default()))
.unwrap()
.capture()
.unwrap();
let offset = nav::cursor_offset(app.source_workspace.text(), context.begin.cursor).unwrap();
resolve_target(&snapshot, context, offset).unwrap()
}
fn active_job(app: &App) -> Job {
let form = app.source_rename.form.as_ref().unwrap();
let id = form.pending.unwrap();
assert_eq!(app.source_rename.in_flight, Some(id));
Job {
id,
invocation: form.invocation,
generation: form.generation,
context: form.context.clone(),
}
}
fn separate_phases(fixture: &Fixture, samples: &mut Samples, trial: usize) {
let mut app = fixture.app();
let target = resolve(&app, &app.rename_context());
assert_eq!(target.id, fixture.target);
let request = samples
.time("document_request_mint", trial, || {
app.document.alias_rename_request(target.id, NEW.into())
})
.unwrap();
let prepared = samples
.time("worker_document_prepare", trial, || request.prepare())
.unwrap();
fixture.check_candidate(&prepared);
let approval = samples
.time("worker_preview_and_origin", trial, || {
prepare_approval(prepared, &target, ORIGIN)
})
.unwrap();
assert_eq!(approval.preview.files().len(), 2);
assert_eq!(approval.preview.changed_line_count(), 6);
let candidate_address = approval.prepared.after() as *const CompiledSourceProject;
// Same-destination save completion is a legitimate live update after Preview.
app.document.mark_saved(app.document.snapshot());
let changed = samples
.time("document_guarded_commit", trial, || {
app.document.commit_alias_rename(&approval.prepared)
})
.unwrap();
assert!(changed);
samples.time("app_sync", trial, || app.sync());
fixture.check_adopted(&mut app, candidate_address);
}
fn whole_confirm(fixture: &Fixture, samples: &mut Samples, trial: usize) {
let mut app = fixture.app();
let begin = app.source_rename_message();
// These Tasks are deliberately not polled: deterministic worker helpers
// supply the same results through the real readiness messages below.
drop(app.update(begin));
let target_job = active_job(&app);
let target = Arc::new(resolve(&app, &target_job.context));
assert_eq!(target.id, fixture.target);
drop(app.update(Message::SourceRenameReady(Completion {
job: target_job,
payload: Payload::Target(Ok(target.clone())),
})));
let invocation = app.source_rename.form.as_ref().unwrap().invocation;
drop(app.update(Message::SourceRename(Event::Input(invocation, NEW.into()))));
let generation = app.source_rename.form.as_ref().unwrap().generation;
drop(app.update(Message::SourceRename(Event::Preview(
invocation, generation,
))));
let preview_job = active_job(&app);
let prepared = app
.document
.alias_rename_request(target.id, NEW.into())
.unwrap()
.prepare()
.unwrap();
fixture.check_candidate(&prepared);
let approval = Arc::new(prepare_approval(prepared, &target, ORIGIN).unwrap());
drop(target);
let candidate_address = approval.prepared.after() as *const CompiledSourceProject;
let expected_cursor = approval.cursor;
let approval_id = approval.id;
drop(app.update(Message::SourceRenameReady(Completion {
job: preview_job,
payload: Payload::Preview(Ok(approval)),
})));
assert_eq!(
app.source_rename
.form
.as_ref()
.unwrap()
.approval
.as_ref()
.unwrap()
.id,
approval_id
);
app.document.mark_saved(app.document.snapshot());
let confirm = Message::SourceRename(Event::Confirm(invocation, generation, approval_id));
let task = samples.time("app_confirm_update", trial, || app.update(confirm));
samples.time("confirm_returned_task_drop_without_polling", trial, || {
drop(task)
});
assert!(!app.source_rename.open());
assert!(app.source_rename.in_flight.is_none());
assert!(app.source_rename_undo_message().is_some());
assert_eq!(app.source_editor.cursor(), expected_cursor);
fixture.check_adopted(&mut app, candidate_address);
}
#[test]
#[ignore = "manual Windows dev/debug rename latency trial; run alone with --ignored --nocapture --test-threads=1"]
fn accepted_release1_prepare_commit_sync_and_confirm_timing() {
let fixture = Fixture::load();
let mut samples = Samples::default();
for trial in 0..=5 {
println!(
"{}",
serde_json::json!({ "record": "order", "trial": trial,
"warmup": trial == 0, "separate_phases_first": trial % 2 == 0 })
);
if trial % 2 == 0 {
separate_phases(&fixture, &mut samples, trial);
whole_confirm(&fixture, &mut samples, trial);
} else {
whole_confirm(&fixture, &mut samples, trial);
separate_phases(&fixture, &mut samples, trial);
}
}
samples.report();
}

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