Compare commits

..
Author SHA1 Message Date
sorokin.oleg.edu d21977fca6 docs: publish verified navigation source and portable evidence 2026-10-02 00:20:04 -07:00
sorokin.oleg.edu 6018ae30b9 Follow accepted placement relocation without losing inspection context 2026-10-01 23:42:47 -07:00
sorokin.oleg.edu c7065db76e Preserve canvas context across property edits and contextual drawing 2026-10-01 22:49:47 -07:00
Codex-ytr83r 6520a89dc0 test: establish independent inspection before preferred-view creation 2026-09-25 00:26:42 -07:00
Codex-ytr83r 3a7221b80c Distinguish connection tools with semantic palette icons 2026-09-24 23:31:02 -07:00
Codex-ytr83r ac69f555af Keep contextual properties and creation bound to their semantic targets 2026-09-24 23:30:56 -07:00
Codex-ytr83r 603a59c178 Verify projection API and preserve same-object explicit navigation 2026-09-24 23:00:05 -07:00
Codex-ytr83r 0341bca846 Preserve authored projection content and contextual canvas selection 2026-09-24 22:40:21 -07:00
sorokin.oleg.edu 715036e132 Record mounted State picker correction and failed gate boundary 2026-09-24 03:31:42 -07:00
Codex-ytr83r a9cd364eac Locate State picker from measured bounds in mounted Escape regression 2026-09-24 00:25:25 -07:00
sorokin.oleg.edu 8ddfadf440 Fix Allocation Text Apply test import and retain compile evidence 2026-09-24 02:49:15 -07:00
sorokin.oleg.edu 897d9f0494 Record corrected Route fixture and failed compilation boundary 2026-09-24 02:36:35 -07:00
sorokin.oleg.edu 4deced53b9 test: qualify route fixture projection helper 2026-09-24 02:35:58 -07:00
sorokin.oleg.edu 9e99618cc3 Freeze reviewed Windows prerelease source handoff 2026-09-24 02:29:36 -07:00
sorokin.oleg.edu a6b9f6029a fix(desktop): reject stale route owners before drag retirement 2026-09-24 02:27:20 -07:00
sorokin.oleg.edu a8c959fed4 Fix regression module path and isolate live drawing context admission 2026-09-24 02:27:19 -07:00
sorokin.oleg.edu 359bea3949 Reject stale drawing packets before retiring Allocation gestures 2026-09-24 02:27:19 -07:00
Codex-ytr83r 17eaa03fd6 docs: describe picker Escape vendor modifications 2026-09-24 01:33:09 -07:00
sorokin.oleg.edu 6ae75fb2cc Document integrated prerelease source handoff and human checks 2026-09-24 01:19:49 -07:00
sorokin.oleg.edu 542b10ac58 Test Process and Allocation stale gestures after real Text Apply 2026-09-24 01:17:40 -07:00
sorokin.oleg.edu 62d3ffe9cb Merge Allocation drawing, Traceability guidance, and picker Escape candidate 2026-09-24 01:10:29 -07:00
sorokin.oleg.edu c1b78acb9a Merge Process and Allocation group authoring into the prerelease candidate 2026-09-24 01:05:22 -07:00
Codex-ytr83r 3e916f623c Disambiguate core imports in picker Escape tests 2026-09-23 21:49:54 -07:00
Codex-ytr83r e9167f5cc8 Dismiss pickers with Escape before cancelling outer drafts 2026-09-23 20:34:33 -07:00
sorokin.oleg.edu 495464e84b Support atomic Allocation group assignment and Arrange gestures 2026-09-23 20:25:26 -07:00
sorokin.oleg.edu 1006b496ae test: reject captured group actions after real Text Apply 2026-09-23 12:12:10 -07:00
sorokin.oleg.edu 16b30a6760 Record verified Allocation assignment planner and portable evidence 2026-09-23 11:29:58 -07:00
sorokin.oleg.edu 33e880103c Plan Allocation work-group assignments as one exact transaction 2026-09-23 11:20:58 -07:00
sorokin.oleg.edu a2a326f183 Merge ordinary and State multiselect into the desktop candidate 2026-09-23 11:17:21 -07:00
sorokin.oleg.edu eed80ffd6e Document Allocation Arrange amendment and publish bounded planner evidence 2026-09-23 07:56:43 -07:00
sorokin.oleg.edu 9b83f7ca0d Add pure combined Allocation Arrange planner with exact source and geometry checks 2026-09-23 07:51:43 -07:00
sorokin.oleg.edu 8dbbaf262e Verify OA and SA Arrange policy boundaries with focused production modules 2026-09-23 07:41:54 -07:00
sorokin.oleg.edu 3a7923b96d experiment: test distributed Allocation arrangement with actual projection 2026-09-23 07:27:42 -07:00
sorokin.oleg.edu 6d99aacab8 docs: publish Process multiselect verification and native correction evidence 2026-09-23 06:51:41 -07:00
sorokin.oleg.edu b743a10d51 Make native group reorder discriminate singleton fallback
Prescribe inspected and pressed Card C after reverse membership selection. A singleton C drop is unchanged while the group changes order; preserve prior evaluator scope and test the unchanged negative outcome explicitly.
2026-09-23 06:27:39 -07:00
sorokin.oleg.edu ff712b2419 Preserve exact Process selection across definition navigation
Keep caller identity and property subjects aligned when opening and returning from qualified contributions, and when explicitly navigating to local references. Validate camera, source, history and read-only boundaries.
2026-09-23 06:25:50 -07:00
sorokin.oleg.edu e34ba27e26 Repair mounted Scenario fixtures for qualified owners and inspector tabs 2026-09-23 06:12:13 -07:00
sorokin.oleg.edu b15345123a Bound Process payload sizes and capture rendered order feedback
Box the optional Process menu context and changed order command without changing identity or command semantics. Add opt-in actual WGPU readbacks to the split-event OA/SA reorder evaluator. Strict all-target lint, format and affected desktop paths pass; native acceptance follows.
2026-09-23 05:47:08 -07:00
sorokin.oleg.edu 1711bff650 Add exact Process selection and atomic column-slot group reorder
Preserve the full-desktop-1 captured source. Qualified previews inspect their exact subject read-only; group movement preserves source order and other columns. Native acceptance and strict-lint payload-size corrections follow separately.
2026-09-23 05:39:10 -07:00
sorokin.oleg.edu bed7f825fd docs: publish State multiselect verification and native evidence 2026-09-23 04:41:42 -07:00
sorokin.oleg.edu 3480b54201 Box the optional exact State menu target to bound message size 2026-09-23 03:37:02 -07:00
sorokin.oleg.edu 2a3b9ba822 Add exact State multiselection, atomic movement and placement Hide
Preserve the desktop-2 captured source (1286 passed, 6 existing ignored). Three menu payload blobs are staged from that verified capture without replacing working files; the strict-lint size correction follows in a separate commit. Native acceptance remains pending.
2026-09-23 03:37:01 -07:00
Codex-ytr83r 301126f609 Use diagram choice messages in Allocation drawing tests 2026-09-23 03:14:26 -07:00
sorokin.oleg.edu 7e348d967d docs: publish saved-view group selection verification and native evidence 2026-09-23 02:19:57 -07:00
sorokin.oleg.edu f6a6010bae fix: keep saved diagram viewport stable across group selection 2026-09-23 01:53:11 -07:00
sorokin.oleg.edu f82e29d6b8 Include the attachment workshop compile-time fixture
Import unchanged from b8685416f56e53e30bd11ecf93a54cea10ac8458; Git blob f641989214. Required by oa-core/tests/attachment_workshop.rs.
2026-09-23 01:29:21 -07:00
sorokin.oleg.edu c4897485b7 Keep the release example compatible with optional Scenario layout 2026-09-23 01:24:05 -07:00
sorokin.oleg.edu 12a7352dde feat: add exact saved-view multiselection and atomic group movement 2026-09-23 01:11:52 -07:00
sorokin.oleg.edu 015273ac8a Retain independent union reviews and corrected Scenario test scope 2026-09-23 01:01:54 -07:00
sorokin.oleg.edu 5e04800be1 Merge commit '2a0579daafb89994730284c108f460782e5e6650' into codex/peer-union-956atf-20260923 2026-09-23 00:59:29 -07:00
Codex-ytr83r cf37c36be1 Add direct Allocation drawing with captured-view context 2026-09-23 00:55:55 -07:00
sorokin.oleg.edu f3dd8356bf Record combined source provenance and pending acceptance 2026-09-23 00:54:22 -07:00
sorokin.oleg.edu 846cae81e7 Admit Scenario layout through the ordinary source parser inventory 2026-09-23 00:51:05 -07:00
sorokin.oleg.edu 2a0579daaf Admit Scenario layout through the ordinary source parser inventory 2026-09-23 00:50:34 -07:00
sorokin.oleg.edu ffa7b16003 Compose transition lifecycle and preserve authored attachments in both directions 2026-09-23 00:49:26 -07:00
sorokin.oleg.edu a9b9c81eab Compose reviewed attachments and State bends with saved Scenario 2026-09-23 00:48:36 -07:00
sorokin.oleg.edu 0b61771d07 Reject stale Scenario actions before shared reveal boundaries 2026-09-23 00:37:07 -07:00
sorokin.oleg.edu 6f4dfc67b3 Compose saved Scenario authoring with current Text and input authority 2026-09-23 00:25:20 -07:00
sorokin.oleg.edu 5960563b23 Refresh verified editor and private analysis checkpoint 2026-09-23 00:12:59 -07:00
sorokin.oleg.edu df4256cb2b Integrate reviewed indentation Windows evidence 2026-09-23 00:12:55 -07:00
sorokin.oleg.edu 1d56386708 Preserve source indentation gates and native Windows evidence 2026-09-23 00:09:49 -07:00
sorokin.oleg.edu c87a0c2bd9 Adopt reviewed private original-span export boundary 2026-09-23 00:03:33 -07:00
sorokin.oleg.edu cc3d6a4875 Record reviewed reuse and trade-study semantic gaps and acceptance fixtures 2026-09-22 23:58:39 -07:00
sorokin.oleg.edu 6a6cc83bc8 docs(harness): preserve private S3 Windows evidence and verifier 2026-09-22 23:55:41 -07:00
sorokin.oleg.edu 9f88938632 Preserve verified private S2c writer evidence and failed full gate 2026-09-22 23:55:17 -07:00
Codex-ytr83r 17f1f89fef Align traceability placement assertions with captured-view reveal 2026-09-22 23:32:36 -07:00
Codex-ytr83r 4fbdf6f385 Correct traceability test type and enabled menu assertions 2026-09-22 23:17:00 -07:00
Codex-ytr83r bfc259854f Explain traceability views and preserve cross-layer placement context 2026-09-22 23:09:20 -07:00
Codex-ytr83r b8685416f5 Preserve read-only panning while canceling State edits 2026-09-22 21:21:50 -07:00
Codex-ytr83r 26979c1093 Enable direct and menu bend editing in State diagrams 2026-09-22 21:01:35 -07:00
Codex-ytr83r 53d01a3d44 Cancel menu-armed State attachment tool on Escape 2026-09-22 19:26:28 -07:00
Codex-ytr83r 3aa80de89c Fix attachment overshoot and semantic trace port geometry 2026-09-22 18:13:07 -07:00
Codex-ytr83r 79cc9d5551 Support per-view connection boundary attachments in ordinary and state diagrams 2026-09-22 17:51:13 -07:00
sorokin.oleg.edu 4ffff0d9d9 Fix indentation test fixture borrowing and focus trait import 2026-09-22 12:02:29 -07:00
sorokin.oleg.edu 836f9e14e7 Integrate source editor Tab and Shift Tab indentation 2026-09-22 11:56:32 -07:00
sorokin.oleg.edu 031c4dbdbb Integrate reviewed compact source and Text workspace 2026-09-22 11:55:32 -07:00
sorokin.oleg.edu b278866e8c Refresh integrated checkpoint with verified scroll restoration 2026-09-22 11:50:49 -07:00
sorokin.oleg.edu 3733a657bb Add source caret and block indentation keyboard editing 2026-09-22 11:50:45 -07:00
sorokin.oleg.edu 84434d55a0 Archive verified Details and Preview restoration evidence 2026-09-22 11:49:23 -07:00
sorokin.oleg.edu 3f9e6a18af Record portable ordinary-text native acceptance evidence 2026-09-13 07:38:59 -07:00
sorokin.oleg.edu 7836a93edb Record portable ordinary-text integration gate evidence 2026-09-13 07:31:29 -07:00
sorokin.oleg.edu f714a25bbc Retire the obsolete source close event and use current drawer controls in tests 2026-09-13 07:10:23 -07:00
sorokin.oleg.edu cb941367d7 Keep portable evidence hashes valid after Git newline normalization 2026-09-13 06:55:39 -07:00
sorokin.oleg.edu 96b756bece Publish the reviewed ordinary and Text compatibility checkpoint 2026-09-13 06:54:43 -07:00
sorokin.oleg.edu e624d5b9c4 Align the teaching source and language contract with compact defaults 2026-09-13 06:46:08 -07:00
sorokin.oleg.edu d7c065991d Compose ordinary source defaults with the reviewed Text workspace 2026-09-13 06:33:04 -07:00
sorokin.oleg.edu f95d031048 Archive closed feab native scroll restoration and Windows gates 2026-09-13 05:44:53 -07:00
sorokin.oleg.edu 8abc144cf2 Integrate verified rename preview scroll restoration 2026-09-13 05:39:56 -07:00
sorokin.oleg.edu 136ed6e586 Preserve original review inventory bytes in Git 2026-09-13 05:37:32 -07:00
sorokin.oleg.edu 46124ce76c Record Text integration source review and recovery correction 2026-09-13 05:36:04 -07:00
sorokin.oleg.edu 554cfbd9fe Preserve diagnostic source authority when recovery removes save notice 2026-09-13 05:06:50 -07:00
sorokin.oleg.edu 92ff99eb54 Integrate diagnostic decorations and current-source Text input boundaries 2026-09-13 04:47:30 -07:00
sorokin.oleg.edu 16904398db Align root retention assertions with exact current-source drawer ownership 2026-09-13 04:12:18 -07:00
sorokin.oleg.edu 74542ae2c1 Compose compact Text workspace with guarded root navigation 2026-09-13 04:10:27 -07:00
sorokin.oleg.edu 9e51cd72ba Update verified Windows navigation and analysis checkpoint 2026-09-13 04:09:22 -07:00
sorokin.oleg.edu feab2020d8 Distinguish reported and rendered preview scroll offsets 2026-09-13 03:30:09 -07:00
sorokin.oleg.edu bebf9f594c Keep rename preview scroll reports outside source boundaries 2026-09-13 02:44:11 -07:00
sorokin.oleg.edu 022d8cda2a Preserve rename preview scroll through full details 2026-09-13 02:27:04 -07:00
1411 changed files with 291987 additions and 2774 deletions
+8
View File
@@ -1,4 +1,5 @@
* text=auto eol=lf
*.rs text eol=lf
*.toml text eol=lf
*.lock text eol=lf
@@ -86,3 +87,10 @@ harness/experiments/analysis-source3-bridge-windows-20260913/** -text whitespace
harness/scenario-analysis-version-integration-review-20260913.md -text whitespace=cr-at-eol
harness/scenario-analysis-version-integration-inputs-20260913.json -text whitespace=cr-at-eol
harness/experiments/analysis-source-s2a-windows-20260913/** -text whitespace=-blank-at-eol,-blank-at-eof,cr-at-eol
# Preserve the pinned Iced dependency bytes and upstream manifest.
crates/vendor/iced_widget/** -text whitespace=cr-at-eol
# Final overrides preserve original validation bytes after generic text rules.
harness/experiments/navigation-validation-20261002/** -text whitespace=-blank-at-eol,-blank-at-eof,cr-at-eol
harness/reviews/navigation-validation-packet-20261002/** -text whitespace=-blank-at-eol,-blank-at-eof,cr-at-eol
+12 -12
View File
@@ -4,12 +4,12 @@
- Follow `PROJECT_GOAL.md` and the user's current instructions. Work autonomously within scope. Resolve discoverable questions yourself; record, test, and revise reversible assumptions. Ask only when a consequential decision needs information or authorization not already available. Continue independent useful work while blocked elsewhere.
- Map unfamiliar domains through primary sources, comparable systems, and real user workflows. Identify underlying needs, valuable concepts, and avoidable friction; evaluate improvements rather than merely reproducing features. Synthesize findings into decisions or experiments, and stop research when another action offers greater value.
- Make specifications precise enough for compatible independent implementations: definitions, relationships, invariants, valid/invalid examples, and lifecycle or transformation rules where relevant. Trace important requirements and decisions to implementation and verification. Resolve specification ambiguity at its source when implementation exposes it.
- Make specifications precise enough for compatible independent implementations: definitions, relationships, invariants, valid / invalid examples, and lifecycle or transformation rules where relevant. Trace important requirements and decisions to implementation and verification. Resolve specification ambiguity at its source when implementation exposes it.
- For consequential uncertain choices, compare materially different candidates against explicit user-derived criteria. Ask independent critics to challenge assumptions and attempt to falsify proposals; refine or replace candidates as evidence warrants. Record the choice, evidence, tradeoffs, rejected approaches, and reasons to revisit it. Use proportionate effort for reversible local choices.
- Plan milestones that resolve uncertainty or deliver useful capability, with explicit dependencies. Test foundational assumptions before substantial downstream work. At checkpoints, assess progress toward the actual objective and replan when evidence invalidates the strategy or progress stalls.
- Define observable success and suitable evaluators before repeated improvement: invariants, examples, round trips, transformations, performance checks, realistic user tasks, or explicit review rubrics. Ensure the evaluator measures the intended outcome. For experiments, record the question, expectations, method, result, and implications; isolate exploratory prototypes from production architecture.
- Build and run meaningful changes, exercise actual behavior, and inspect useful logs. Investigate crashes, serious warnings, corrupted state, and interaction defects. For GUI work, use Computer Use or equivalent tools to inspect realistic workflows, cognitive load, editing/selection, navigation, feedback, recovery, loading/empty/failure states, accessibility, layout, focus/disabled states, and responsiveness.
- Match independent reviews to the artifact and risks. For languages or formats, include ambiguity, round trips, human/AI editability, and version-control behavior where relevant. Verify findings, fix the appropriate conceptual, specification, architectural, implementation, or interaction layer, and recheck affected dependents. Reuse valid evidence; repeat checks for concrete risks, changes, findings, or required gates. Compilation, screenshots, and worker reports alone do not establish correctness or usability.
- Build and run meaningful changes, exercise actual behavior, and inspect useful logs. Investigate crashes, serious warnings, corrupted state, and interaction defects. For GUI work, use Computer Use or equivalent tools to inspect realistic workflows, cognitive load, editing / selection, navigation, feedback, recovery, loading / empty / failure states, accessibility, layout, focus / disabled states, and responsiveness.
- Match independent reviews to the artifact and risks. For languages or formats, include ambiguity, round trips, human / AI editability, and version-control behavior where relevant. Verify findings, fix the appropriate conceptual, specification, architectural, implementation, or interaction layer, and recheck affected dependents. Reuse valid evidence; repeat checks for concrete risks, changes, findings, or required gates. Compilation, screenshots, and worker reports alone do not establish correctness or usability.
- Favor the simplest maintainable design satisfying known requirements and justified extensibility. Avoid speculative abstraction, unrelated refactoring, and repeating approaches already invalidated by evidence.
## User input
@@ -24,10 +24,10 @@ Keep one authoritative home for each kind of information. Use links and brief sc
| Information | Authoritative home |
| --- | --- |
| Live plans, priorities, dependencies, ownership, status, blockers, and handoffs | Gitea issues/milestones; linked PRs hold change review and validation. |
| Specifications, architecture, interfaces, and build/run documentation | Established version-controlled project documents. |
| Live plans, priorities, dependencies, ownership, status, blockers, and handoffs | Gitea issues / milestones; linked PRs hold change review and validation. |
| Specifications, architecture, interfaces, and build / run documentation | Established version-controlled project documents. |
| Durable decisions and reusable evidence | `harness/DECISIONS.md` and relevant topic notes, or existing equivalents. |
| Navigation and integrated-state checkpoint | `harness/STATE.md`: links, verified code commit/release, capabilities, and material limitations. No live task ledger. |
| Navigation and integrated-state checkpoint | `harness/STATE.md`: links, verified code commit / release, capabilities, and material limitations. No live task ledger. |
| Shared entrypoint | Gitea Wiki: overview and links, without duplicating versioned documentation. |
| Published deliverables | Gitea releases, tied to source commits and verification. |
@@ -38,9 +38,9 @@ Use Gitea for live coordination and the relevant code version for implementation
All independent agents coordinating through Gitea run on completely separate machines, each with its own clone, filesystem, tools, processes, and runtime. Gitea and pushed Git history are the shared communication and delivery channels. Local files (including `harness`), unpushed commits, worktrees, test results, and localhost services are not accessible to peers. Matching branch names or paths do not imply synchronized contents.
1. On starting or resuming, including after compaction, inspect the working branch, local changes, applicable instructions, relevant shared records, and existing implementation. Recover context from authoritative records. Check for newer shared instructions on the actual remote default branch without overwriting local work. Read what is relevant; avoid repeated full-history scans and constant polling.
2. Search before creating tasks. Claim a meaningful outcome in its Gitea issue with a stable session identity, scope, remote repository/branch, and acceptance criteria. Keep small steps within that task. Recheck after claiming; comments are not atomic locks. Respect existing ownership and resolve overlapping claims before overlapping work.
3. Independent sessions are peers. Each owns its contribution and delegated work on its own machine. Record one integration/release owner when a milestone needs shared coordination. Use distinct remote work branches, partition files/interfaces, and coordinate changes to shared specifications and documents. Worktrees provide local isolation only; they do not share work between machines.
4. At meaningful checkpoints or handoffs, record changed results, commit/PR links, verification commands and environment, blockers, and next actions. Identify the tested code version and label uncommitted or unpublished work as local-only. Before asking peers to consume results, push the required code and versioned documentation, including relevant `harness` updates; publish other required artifacts in Gitea and link them. Use repository-relative paths with commit references, reproducible commands, and necessary environment details; local paths and localhost URLs alone are not handoffs. Do not assume another agent is running or has read a message; silence alone does not establish abandonment. Record evidence and preserve existing work when reassigning a task.
2. Search before creating tasks. Claim a meaningful outcome in its Gitea issue with a stable session identity, scope, remote repository / branch, and acceptance criteria. Keep small steps within that task. Recheck after claiming; comments are not atomic locks. Respect existing ownership and resolve overlapping claims before overlapping work.
3. Independent sessions are peers. Each owns its contribution and delegated work on its own machine. Record one integration / release owner when a milestone needs shared coordination. Use distinct remote work branches, partition files / interfaces, and coordinate changes to shared specifications and documents. Worktrees provide local isolation only; they do not share work between machines.
4. At meaningful checkpoints or handoffs, record changed results, commit / PR links, verification commands and environment, blockers, and next actions. Identify the tested code version and label uncommitted or unpublished work as local-only. Before asking peers to consume results, push the required code and versioned documentation, including relevant `harness` updates; publish other required artifacts in Gitea and link them. Use repository-relative paths with commit references, reproducible commands, and necessary environment details; local paths and localhost URLs alone are not handoffs. Do not assume another agent is running or has read a message; silence alone does not establish abandonment. Record evidence and preserve existing work when reassigning a task.
5. Follow project review and merge conventions. Fetch the relevant remote branches and inspect the identified commits before reviewing or integrating another machine's work. Resolve conflicts while preserving others' edits, and verify the combined result locally; report material environment differences. Local instruction-file edits reach peers only after publication and retrieval. Do not overwrite others' work or force-push shared history to resolve coordination problems.
6. Close work when its acceptance criteria are met and the result is integrated or delivered as appropriate. Mark duplicates and superseded tasks explicitly with replacement links. Then choose further useful unowned work while the requested objective remains incomplete.
@@ -64,10 +64,10 @@ Preserve concise records of decisions and rationale, useful experiments, failed
Consolidate duplicates, repair stale guidance, and remove obsolete temporary files alongside useful project work. Before removing material, check references, active ownership, and ongoing use; preserve unique knowledge and update affected links. Prefer closing, superseding, or archiving history over deletion. User input is excluded from autonomous cleanup.
If legacy local task ledgers or plans duplicate Gitea, migrate their live content once into existing or appropriately scoped issues, preserve useful decisions/evidence, and mark the old records superseded. Record the migration so later sessions do not repeat it. Do not create empty documents or maintain records for ceremony.
If legacy local task ledgers or plans duplicate Gitea, migrate their live content once into existing or appropriately scoped issues, preserve useful decisions / evidence, and mark the old records superseded. Record the migration so later sessions do not repeat it. Do not create empty documents or maintain records for ceremony.
## Delivery
Publish versioned Gitea releases at meaningful working milestones; mark unfinished versions as prereleases. Check existing releases/tags and coordinate one owner and version before publishing. Build from an identified integrated commit and verify the packaged result and launch steps.
Publish versioned Gitea releases at meaningful working milestones; mark unfinished versions as prereleases. Check existing releases / tags and coordinate one owner and version before publishing. Build from an identified integrated commit and verify the packaged result and launch steps.
Include runnable builds or appropriate artifacts, concise release notes, build/run/deployment instructions as applicable, known limitations, tested platforms, and verification evidence. Confirm publication and artifact availability. Use existing authorized API access or project tooling when MCP lacks a needed release/upload operation. Do not silently replace published versions with different contents.
Include runnable builds or appropriate artifacts, concise release notes, build / run / deployment instructions as applicable, known limitations, tested platforms, and verification evidence. Confirm publication and artifact availability. Use existing authorized API access or project tooling when MCP lacks a needed release / upload operation. Do not silently replace published versions with different contents.
Generated
-2
View File
@@ -1567,8 +1567,6 @@ dependencies = [
[[package]]
name = "iced_widget"
version = "0.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1596afa0d3109c2618e8bc12bae6c11d3064df8f95c42dfce570397dbe957ab"
dependencies = [
"iced_renderer",
"log",
+4
View File
@@ -1,7 +1,11 @@
[workspace]
members = ["crates/oa-core", "crates/oa-desktop", "crates/oa-server", "crates/oa-cli"]
exclude = ["crates/vendor/iced_widget"]
resolver = "2"
[patch.crates-io]
iced_widget = { path = "crates/vendor/iced_widget" }
[workspace.package]
version = "0.2.0"
edition = "2024"
+2 -2
View File
@@ -17,14 +17,14 @@ Apply these criteria to the scope requested by the user:
- 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.
- 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.
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
+3 -1
View File
@@ -94,11 +94,13 @@ Create named **Exchange categories** at the perspective root to group Work excha
In **Select / move**, drag cards on a 10-unit grid, drag empty space to pan, and scroll to zoom. Drag ports along their owner's border or onto any of its four sides. Automatic sides account for direction and visible connections. Manual positions are per diagram and remain relative to the owner through movement or resize; right-click **Reset automatic position** restores automatic placement. **Arrange** changes the current view layout; **Fit** frames its projected bounds. SVG exports the current architecture diagram as a view, not an editable model file.
In saved architecture and State views, **Shift+click** adds or removes an exact displayed item; its context menu also offers **Add to selection** and **Remove from selection**. Properties edits only the primary item. Drag a selected member to move a placement-only group in one operation, including visual descendants once and the authored pins of connections whose endpoint owners move together. A stationary click selects only that member; Escape during a drag cancels it and keeps the group. **Hide selected** removes only selected placements, preserving model elements, unselected descendant placements and other views; one Undo restores the operation. Groups containing connections, ports or State action/internal-transition rows cannot be moved or hidden as placement groups. State rows select their relation, never their owning placement. Dragging a group of allocated work cards and deleting a group from the model remain unavailable; these actions refuse the whole group instead of editing just its primary item. Selection itself is temporary and is not saved.
Connections start with orthogonal routes that avoid cards and container headers. In Select mode, select a connection and drag a segment to add a bend, or right-click for **Add bend here**. Drag its circles to adjust the route; the menu offers **Remove bend** and **Reset routing**. With the canvas focused, Insert adds a bend and Delete removes the selected bend. Shift+F10 opens the selected item's menu; **Actions** in Properties provides the same commands. Drag the connection label independently. Each completed gesture is one undoable edit; Escape cancels a drag. Routes belong to the current diagram and persist in source, JSON, REST and SVG export. Folding an endpoint container temporarily uses an automatic route; expanding it restores your authored route. Dense diagrams can still need manual adjustment; automatic routing is bounded and does not guarantee an overlap-free result.
For state diagrams, use **+ Region** inside a highlighted machine or State/Mode, then choose **+ State / symbol** and click its owning region. **Transition** supports dragging between compatible vertices or clicking the source and then the target. Select a drawn transition and drag its endpoint handle, or right-click **Reconnect source/target**. Right-click a state or the background for **New transition with details…** to create Local, Internal or timed transitions; an existing transition's **Edit transition** form edits its kind, triggers, guard and effects. EntryPoint outgoing transitions use Local.
In the state view, **Select / move** drags vertices and containers without reparenting; container movement includes its descendants. Middle-button drag pans, scroll or **−/+** zooms, and **Fit** frames the view. Right-click for **Fold contents**, **Unfold contents**, **Placement properties**, or **Hide from this diagram**. The **Show an element not yet in this view…** selector restores missing placements. Escape cancels a gesture; **Undo** restores a completed edit. **+ View** creates an independent layout. The persisted diagram's machine reference and placements are the same records in source, JSON and REST; opening the page alone creates no diagram.
In the state view, **Select / move** drags vertices and containers without reparenting or grid snapping; container movement includes its descendants. Multiple selected placements share the same fractional displacement, including authored positions of boundary symbols. State route bends retain their separate 5-unit grid. Middle-button drag pans, scroll or **−/+** zooms, and **Fit** frames the view. Right-click for **Fold contents**, **Unfold contents**, **Placement properties**, or **Hide from this diagram**. The **Show an element not yet in this view…** selector restores missing placements. Escape cancels a gesture; **Undo** restores a completed edit. **+ View** creates an independent layout. The persisted diagram's machine reference and placements are the same records in source, JSON and REST; opening the page alone creates no diagram.
## Edit the same model as text
+2 -2
View File
@@ -222,8 +222,8 @@ pub fn prepare(entry: &Path, operation: Operation) -> Result<Prepared, Box<Repor
Box::new(report.clone())
})?;
let raw = snapshot.reachable_bundle();
// The loader may include warnings from stabilized compilation. Only its
// discovery/IO diagnostics are authoritative on the original bytes.
// Keep discovery/IO failures that lint cannot recover from the bundle alone.
// Recompute source diagnostics below, against the same original bytes.
let mut diagnostics: Vec<_> = snapshot
.diagnostics
.iter()
@@ -0,0 +1,768 @@
//! Deterministic native fixture and literal complete-Project Allocation oracle.
//! No desktop planner, inferred output delta, or GUI input is used by this oracle.
use oa_core::{source_project::*, *};
use std::{collections::BTreeMap, fs, io::Write, path::Path};
type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
const MODES: [&str; 6] = [
"unchanged",
"oa-assigned",
"sa-assigned",
"oa-arranged",
"sa-arranged",
"oa-performers",
];
fn id(alias: &str) -> Id {
if alias == "oa_assign" {
return Id::from_u128(0x292609230001);
}
Id::new_v5(
&Id::from_u128(0xc1192026_0923_5000_8000_000000002931),
alias.as_bytes(),
)
}
fn xy(x: f32, y: f32) -> Position {
Position { x, y }
}
fn named(scope: &str, suffix: &str) -> String {
format!("{scope}_{suffix}")
}
fn need(ok: bool, message: impl Into<String>) -> Result<()> {
if ok {
Ok(())
} else {
Err(message.into().into())
}
}
fn element(p: &mut Project, alias: &str, name: &str, kind: Kind, layer: Layer) {
p.elements.insert(
id(alias),
Element {
id: id(alias),
alias: alias.into(),
name: name.into(),
description: format!("Preserve engineering record {alias}."),
parent: None,
body: ElementBody::new(kind, layer).unwrap(),
},
);
}
fn diagram(p: &mut Project, alias: &str, name: &str, layer: Layer, projection: ProjectionKind) {
p.diagrams.insert(
id(alias),
Diagram {
id: id(alias),
alias: alias.into(),
name: name.into(),
description: "Native group fixture; preserve unrelated records exactly.".into(),
layer,
projection,
layout: None,
view_options: Default::default(),
nodes: Default::default(),
routes: vec![],
},
);
}
fn place(p: &mut Project, scope: &str, suffix: &str, position: Position) {
let alias = named(scope, &format!("node_{suffix}"));
p.diagrams.get_mut(&id(scope)).unwrap().nodes.insert(
id(&alias),
NodePlacement {
id: id(&alias),
alias,
element: id(&named(scope, suffix)),
position,
size: Size {
width: 224.,
height: 94.,
},
collapsed: false,
port_anchors: vec![],
},
);
}
fn relation(
p: &mut Project,
scope: &str,
suffix: &str,
kind: RelationKind,
from: &str,
to: &str,
) -> Result<()> {
let alias = named(scope, suffix);
let old = p.relate(
kind,
&format!("{scope} {suffix}"),
id(&named(scope, from)),
id(&named(scope, to)),
);
reidentify(p, old, id(&alias))?;
let r = p.relations.get_mut(&id(&alias)).unwrap();
r.alias = alias.clone();
r.description = format!("Reviewed identity and provenance {alias} must survive.");
// The actual constructor creates proper SA function contracts. Normalize
// every generated identity/alias; no random UUID can leak into the fixture.
let ports = match r.body {
RelationBody::FunctionalExchange {
source_port,
target_port,
..
} => Some([source_port, target_port]),
_ => None,
};
if let Some(ports) = ports {
for (old_port, suffix) in ports.into_iter().zip(["output", "input"]) {
let alias = format!("{alias}_{suffix}");
reidentify(p, old_port, id(&alias))?;
let port = p.elements.get_mut(&id(&alias)).unwrap();
port.alias = alias.clone();
port.name = format!("{scope} {suffix}");
port.description = format!("Preserved typed endpoint {alias}.");
}
}
Ok(())
}
fn route(scope: &str, pins: [Position; 2]) -> EdgeRoute {
EdgeRoute {
edge: id(&named(scope, "exchange")),
waypoints: pins.to_vec(),
source_attachment: None,
target_attachment: None,
label: Some(EdgeLabel {
fraction: 0.45,
offset: xy(7., -19.),
}),
}
}
fn fixture() -> Result<Project> {
let mut p = Project::empty();
p.id = id("project");
p.alias = "allocation_multiselect_native".into();
p.name = "Allocation group native".into();
p.description = "Four independent compact OA/SA assignment and Arrange trials; ordinary performers use fresh OA assign baseline.".into();
for (layer, prefix, work, performer) in [
(Layer::Oa, "oa", Kind::Activity, Kind::Entity),
(Layer::Sa, "sa", Kind::Function, Kind::SystemActor),
] {
for arrange in [false, true] {
let scope = format!("{prefix}_{}", if arrange { "arrange" } else { "assign" });
let number = match (prefix, arrange) {
("oa", false) => "01",
("sa", false) => "02",
("oa", true) => "03",
_ => "04",
};
diagram(
&mut p,
&scope,
&format!(
"{number} {} {}",
prefix.to_uppercase(),
if arrange { "arrange" } else { "assign" }
),
layer,
ProjectionKind::Allocation,
);
let records: Vec<_> = if arrange {
vec![
("p", "P - team", performer, xy(40., 40.)),
("u", "U - contained work", work, xy(80., 140.)),
("v", "V - independent work", work, xy(440., 140.)),
]
} else {
vec![
("a", "A - origin team", performer, xy(43., 47.)),
// Leave room for SA Y's automatic top port stub below
// B's 64-unit header plus 12-unit routing clearance.
("b", "B - other team", performer, xy(43., 337.)),
("c", "C - destination", performer, xy(500., 40.)),
("x", "X - receive", work, xy(80., 140.)),
("y", "Y - assess", work, xy(80., 440.)),
]
};
for (suffix, label, kind, pos) in records {
// SA admits one System for the entire project, not one per view.
let kind = if layer == Layer::Sa && !arrange && suffix == "a" {
Kind::System
} else {
kind
};
element(&mut p, &named(&scope, suffix), label, kind, layer);
place(&mut p, &scope, suffix, pos);
}
let (from, to, pins) = if arrange {
relation(&mut p, &scope, "pu", RelationKind::Allocation, "p", "u")?;
("u", "v", [xy(360., 180.), xy(360., 230.)])
} else {
p.diagrams
.get_mut(&id(&scope))
.unwrap()
.nodes
.get_mut(&id(&named(&scope, "node_c")))
.unwrap()
.size = Size {
width: 360.,
height: 600.,
};
relation(&mut p, &scope, "ax", RelationKind::Allocation, "a", "x")?;
relation(&mut p, &scope, "by", RelationKind::Allocation, "b", "y")?;
("x", "y", [xy(340., 260.), xy(340., 400.)])
};
relation(
&mut p,
&scope,
"exchange",
if layer == Layer::Oa {
RelationKind::Interaction
} else {
RelationKind::FunctionalExchange
},
from,
to,
)?;
p.diagrams
.get_mut(&id(&scope))
.unwrap()
.routes
.push(route(&scope, pins));
}
}
diagram(
&mut p,
"other_view",
"05 Other saved view - preserve",
Layer::Oa,
ProjectionKind::Overview,
);
for (suffix, pos) in [("x", xy(40., 60.)), ("y", xy(410., 180.))] {
let alias = named("other_view", suffix);
p.diagrams.get_mut(&id("other_view")).unwrap().nodes.insert(
id(&alias),
NodePlacement {
id: id(&alias),
alias,
element: id(&named("oa_assign", suffix)),
position: pos,
size: Size {
width: 224.,
height: 94.,
},
collapsed: false,
port_anchors: vec![],
},
);
}
validate_candidate(&p)?;
Ok(p)
}
fn node_mut<'a>(p: &'a mut Project, scope: &str, suffix: &str) -> &'a mut NodePlacement {
p.diagrams
.get_mut(&id(scope))
.unwrap()
.nodes
.get_mut(&id(&named(scope, &format!("node_{suffix}"))))
.unwrap()
}
fn expected(before: &Project, mode: &str) -> Result<Project> {
let mut p = before.clone();
match mode {
"unchanged" => {}
"oa-assigned" | "sa-assigned" => {
let scope = if mode == "oa-assigned" {
"oa_assign"
} else {
"sa_assign"
};
for (rel, work) in [("ax", "x"), ("by", "y")] {
p.relations.get_mut(&id(&named(scope, rel))).unwrap().body =
RelationBody::Allocation {
source: id(&named(scope, "c")),
target: id(&named(scope, work)),
};
}
node_mut(&mut p, scope, "x").position = xy(560., 140.);
node_mut(&mut p, scope, "y").position = xy(560., 440.);
p.diagrams.get_mut(&id(scope)).unwrap().routes[0].waypoints =
vec![xy(820., 260.), xy(820., 400.)];
}
"oa-arranged" | "sa-arranged" => {
let scope = if mode == "oa-arranged" {
"oa_arrange"
} else {
"sa_arrange"
};
for (suffix, pos) in [
("p", xy(40., 280.)),
("u", xy(80., 380.)),
("v", xy(440., 380.)),
] {
node_mut(&mut p, scope, suffix).position = pos;
}
p.diagrams.get_mut(&id(scope)).unwrap().routes[0].waypoints =
vec![xy(360., 420.), xy(360., 470.)];
}
"oa-performers" => {
for (suffix, pos) in [
("a", xy(120., 90.)),
("b", xy(120., 380.)),
("x", xy(157., 183.)),
("y", xy(157., 483.)),
] {
node_mut(&mut p, "oa_assign", suffix).position = pos;
}
p.diagrams.get_mut(&id("oa_assign")).unwrap().routes[0].waypoints =
vec![xy(417., 303.), xy(417., 443.)];
}
_ => return Err(format!("Unknown mode {mode}; expected {MODES:?}").into()),
}
Ok(p)
}
fn ancestor(view: &projection::ProjectedView, parent: Id, child: Id) -> bool {
let mut next = view
.nodes
.iter()
.find(|n| n.id == child)
.and_then(|n| n.visual_parent);
for _ in 0..view.nodes.len() {
let Some(current) = next else {
return false;
};
if current == parent {
return true;
}
next = view
.nodes
.iter()
.find(|n| n.id == current)
.and_then(|n| n.visual_parent);
}
false
}
/// Fixture-usability preflight only: never calculates expected mutations.
fn preflight(p: &Project) -> Result<serde_json::Value> {
validate_candidate(p)?;
let mut scenes = Vec::new();
for scope in ["oa_assign", "sa_assign", "oa_arrange", "sa_arrange"] {
let d = &p.diagrams[&id(scope)];
let view = projection::project_view(p, d.id);
need(
view.nodes.len() == d.nodes.len(),
format!("{scope}: hidden/missing fixture placement"),
)?;
for n in &view.nodes {
need(
n.position == d.nodes[&n.id].position,
format!("{scope}: unexpectedly shifted projected origin {}", n.id),
)?;
need(
n.position.x >= 0.
&& n.position.y >= 0.
&& n.position.x + n.size.width <= 900.
&& n.position.y + n.size.height <= 750.,
format!("{scope}: fixture exceeds declared compact world envelope: {n:?}"),
)?;
if p.elements[&n.element].kind().is_work() {
let height = if d.layer == Layer::Sa { 132. } else { 94. };
need(
n.size
== Size {
width: 224.,
height,
},
format!("{scope}: unexpected work/port extent {n:?}"),
)?;
}
}
for (i, a) in view.nodes.iter().enumerate() {
for b in &view.nodes[i + 1..] {
if ancestor(&view, a.id, b.id) || ancestor(&view, b.id, a.id) {
continue;
}
let width = (a.position.x + a.size.width).min(b.position.x + b.size.width)
- a.position.x.max(b.position.x);
let height = (a.position.y + a.size.height).min(b.position.y + b.size.height)
- a.position.y.max(b.position.y);
need(
width <= 0. || height <= 0.,
format!("{scope}: unrelated projected overlap {} {}", a.id, b.id),
)?;
}
}
let routed = routing::route_diagram(p, d, &view);
let edge = id(&named(scope, "exchange"));
let r = routed
.edges
.get(&edge)
.ok_or("Authored exchange route is missing")?;
need(
!r.custom_suspended && r.pins == d.routes[0].waypoints,
format!("{scope}: authored pins are not active"),
)?;
need(
r.warnings.is_empty(),
format!(
"{scope}: route warnings require fixture review: {:?}; ports={:?}; points={:?}; nodes={:?}",
r.warnings, view.ports, r.points, view.nodes
),
)?;
need(
view.ports.len() == if d.layer == Layer::Sa { 2 } else { 0 },
format!("{scope}: unexpected visible typed ports"),
)?;
scenes.push(serde_json::json!({"diagram":scope,"nodes":view.nodes,"ports":view.ports,"active_pins":r.pins,"route_warnings":r.warnings.iter().map(ToString::to_string).collect::<Vec<_>>()}));
}
Ok(serde_json::json!(scenes))
}
fn load(path: &Path) -> Result<Project> {
if path
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("arcadia"))
{
let disk = source_files::load_project(path, ProjectLimits::default())?;
need(
!disk
.diagnostics
.iter()
.any(|d| d.diagnostic.severity == validation::Severity::Error),
format!("Source discovery errors: {:?}", disk.diagnostics),
)?;
let compiled = compile_bundle(&disk.reachable_bundle(), ProjectLimits::default())
.map_err(|e| format!("Source compile: {e:?}"))?;
Ok(compiled.decoded.project)
} else {
Ok(persistence::load(path)?)
}
}
fn equal(expected: &Project, actual: &Project) -> Result<()> {
if expected == actual {
return Ok(());
}
let e = serde_json::to_value(expected)?;
let a = serde_json::to_value(actual)?;
let fields: Vec<_> = e
.as_object()
.unwrap()
.keys()
.filter(|key| e[*key] != a[*key])
.cloned()
.collect();
Err(format!("Whole Project mismatch in {fields:?}; no partial PASS").into())
}
fn roundtrip(p: &Project) -> Result<SourceBundle> {
validate_candidate(p)?;
equal(p, &persistence::decode(&serde_json::to_vec(p)?)?)?;
let mut bundle = format_bundle(p);
for (path, text) in &mut bundle.files {
*text = format!("// Native preservation sentinel: {path}\n{text}");
}
let parsed = compile_bundle(&bundle, ProjectLimits::default())
.map_err(|e| format!("Source roundtrip: {e:?}"))?;
equal(p, &parsed.decoded.project)?;
Ok(bundle)
}
fn write_new(path: &Path, bytes: &[u8]) -> Result<()> {
fs::create_dir_all(path.parent().ok_or("Missing parent directory")?)?;
fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(path)?
.write_all(bytes)?;
Ok(())
}
fn generate(out: &Path) -> Result<()> {
need(!out.exists(), "Output exists; choose a fresh directory")?;
let p = fixture()?;
let bundle = roundtrip(&p)?;
let mut geometry = BTreeMap::new();
// All literal candidates must satisfy the same compact-fixture prerequisites
// before any bytes are published for a native run.
for mode in MODES {
let candidate = expected(&p, mode)?;
roundtrip(&candidate)?;
geometry.insert(mode, preflight(&candidate)?);
}
let mut ids = BTreeMap::from([(p.alias.clone(), p.id)]);
ids.extend(p.elements.values().map(|v| (v.alias.clone(), v.id)));
ids.extend(p.relations.values().map(|v| (v.alias.clone(), v.id)));
ids.extend(p.diagrams.values().map(|v| (v.alias.clone(), v.id)));
ids.extend(
p.diagrams
.values()
.flat_map(|d| d.nodes.values().map(|v| (v.alias.clone(), v.id))),
);
write_new(&out.join("baseline.json"), &serde_json::to_vec_pretty(&p)?)?;
for (path, text) in &bundle.files {
write_new(&out.join("source").join(path), text.as_bytes())?;
}
let paths: BTreeMap<_, _> = ids
.iter()
.map(|(alias, id)| {
let needle = id.to_string();
let files: Vec<_> = bundle
.files
.iter()
.filter(|(_, text)| text.contains(&needle))
.map(|(path, _)| path.clone())
.collect();
(alias.clone(), files)
})
.collect();
write_new(
&out.join("manifest.json"),
&serde_json::to_vec_pretty(&serde_json::json!({
"fixture":"allocation-multiselect-native-20260923","source_entry":bundle.entry,"ids":ids,
"source_files":bundle.files.keys().collect::<Vec<_>>(),"id_text_occurrence_files":paths,
"modes":MODES,"geometry_preflight":geometry,
"literal_deltas":{"oa-assigned":[480,0],"sa-assigned":[480,0],"oa-arranged":[0,240],"sa-arranged":[0,240],"oa-performers":[77,43]},
"selection":{"assigned":["X","Y"],"arranged":["P","V"],"ordinary_performers":["A","B"]},
"ordinary_anchor_target":[120,90],
"evaluation":"Literal complete Project oracle, never inferred from saved geometry; baseline pinned to deterministic original",
"limits":"Geometry preflight is not GUI/planner admission. Source comments/history bytes, focus, exact selection, pointer delivery and one Undo/Redo require separate observation/checks. Each mode is one operation from baseline."
}))?,
)?;
equal(&p, &load(&out.join("baseline.json"))?)?;
equal(&p, &load(&out.join("source").join(&bundle.entry))?)?;
println!("PASS generated and reloaded fixture: {}", out.display());
Ok(())
}
fn check(mode: &str, baseline: &Path, saved: &Path) -> Result<()> {
let before = load(baseline)?;
equal(&fixture()?, &before).map_err(|e| format!("Baseline is not immutable original: {e}"))?;
let actual = load(saved)?;
validate_candidate(&actual)?;
equal(&expected(&before, mode)?, &actual)?;
roundtrip(&actual)?;
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
"result":"PASS","mode":mode,"baseline":baseline,"saved":saved,
"comparison":"complete Project equality; literal fixed record edits",
"limits":"No pointer, selection, source-byte preservation, focus, Undo/Redo, or native acceptance claim"
}))?
);
Ok(())
}
fn main() -> Result<()> {
let args: Vec<_> = std::env::args().skip(1).collect();
match args.as_slice() {
[command,out] if command=="generate" => generate(Path::new(out)),
[command,mode,baseline,saved] if command=="check" => check(mode,Path::new(baseline),Path::new(saved)),
_=>Err(format!("Usage: allocation_multiselect_native generate DIRECTORY | check MODE BASELINE SAVED; modes={MODES:?}").into()),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn through_json(mode: &str, before: &Project, actual: &Project) -> Result<()> {
validate_candidate(before).expect("baseline valid");
validate_candidate(actual).expect("falsifier must remain core-valid");
let dir = tempfile::tempdir()?;
let baseline = dir.path().join("baseline.json");
let saved = dir.path().join("saved.json");
persistence::save(&baseline, before)?;
persistence::save(&saved, actual)?;
check(mode, &baseline, &saved)
}
fn mismatch(mode: &str, before: &Project, actual: &Project) {
let error = through_json(mode, before, actual).unwrap_err().to_string();
assert!(
error.contains("Whole Project mismatch"),
"wrong failure cause: {error}"
);
}
#[test]
fn deterministic_create_only_generation_and_all_literal_modes() {
let p = fixture().unwrap();
assert_eq!(p, fixture().unwrap());
let dir = tempfile::tempdir().unwrap();
let out = dir.path().join("fixture");
generate(&out).unwrap();
let before = fs::read(out.join("baseline.json")).unwrap();
assert!(generate(&out).is_err());
assert_eq!(before, fs::read(out.join("baseline.json")).unwrap());
for mode in MODES {
let candidate = expected(&p, mode).unwrap();
preflight(&candidate).unwrap();
through_json(mode, &p, &candidate).unwrap();
if mode != "unchanged" {
mismatch(mode, &p, &p);
}
}
assert!(expected(&p, "invented").is_err());
let changed = expected(&p, "oa-assigned").unwrap();
assert!(
through_json("unchanged", &changed, &changed)
.unwrap_err()
.to_string()
.contains("immutable original")
);
}
#[test]
fn actual_core_assignment_batch_matches_literal_oracle_and_one_step_history() {
let before = fixture().unwrap();
for (mode, scope) in [("oa-assigned", "oa_assign"), ("sa-assigned", "sa_assign")] {
let mut commands = Vec::new();
for (alias, work) in [("ax", "x"), ("by", "y")] {
let relation = id(&named(scope, alias));
let mut value = before.relations[&relation].clone();
value.body = RelationBody::Allocation {
source: id(&named(scope, "c")),
target: id(&named(scope, work)),
};
commands.push(Command::EditRelation(relation, value));
}
for (suffix, position) in [("x", xy(560., 140.)), ("y", xy(560., 440.))] {
let original = id(&named(scope, &format!("node_{suffix}")));
let mut node = before.diagrams[&id(scope)].nodes[&original].clone();
node.position = position;
commands.push(Command::EditNode {
diagram: id(scope),
original,
node,
});
}
let mut route = before.diagrams[&id(scope)].routes[0].clone();
route.waypoints = vec![xy(820., 260.), xy(820., 400.)];
commands.push(Command::SetEdgeRoute {
diagram: id(scope),
route,
});
let mut session = Session::new(before.clone());
session.execute(Command::Batch(commands)).unwrap();
through_json(mode, &before, session.project()).unwrap();
let after = session.project().clone();
session.undo();
assert_eq!(session.project(), &before);
assert!(!session.can_undo());
session.redo();
assert_eq!(session.project(), &after);
assert!(!session.can_redo());
}
}
#[test]
fn assignment_rejects_singleton_semantic_identity_and_route_drift() {
let before = fixture().unwrap();
for mode in ["oa-assigned", "sa-assigned"] {
let scope = if mode == "oa-assigned" {
"oa_assign"
} else {
"sa_assign"
};
let good = expected(&before, mode).unwrap();
let mut bad = good.clone();
node_mut(&mut bad, scope, "y").position = xy(80., 440.);
mismatch(mode, &before, &bad);
let mut bad = good.clone();
bad.relations.insert(
id(&named(scope, "by")),
before.relations[&id(&named(scope, "by"))].clone(),
);
mismatch(mode, &before, &bad);
let mut bad = good.clone();
reidentify(
&mut bad,
id(&named(scope, "ax")),
id("replacement_allocation"),
)
.unwrap();
mismatch(mode, &before, &bad);
let mut bad = good.clone();
bad.relations
.get_mut(&id(&named(scope, "ax")))
.unwrap()
.description
.push_str(" drift");
mismatch(mode, &before, &bad);
for pins in [
vec![xy(340., 260.), xy(340., 400.)],
vec![xy(1300., 260.), xy(1300., 400.)],
] {
let mut bad = good.clone();
bad.diagrams.get_mut(&id(scope)).unwrap().routes[0].waypoints = pins;
mismatch(mode, &before, &bad);
}
}
}
#[test]
fn arrange_and_ordinary_closure_falsifiers_remain_core_valid() {
let before = fixture().unwrap();
for mode in ["oa-arranged", "sa-arranged"] {
let scope = if mode == "oa-arranged" {
"oa_arrange"
} else {
"sa_arrange"
};
let good = expected(&before, mode).unwrap();
for (suffix, pos) in [("u", xy(80., 140.)), ("v", xy(440., 140.))] {
let mut bad = good.clone();
node_mut(&mut bad, scope, suffix).position = pos;
mismatch(mode, &before, &bad);
}
let mut bad = good.clone();
bad.relations.remove(&id(&named(scope, "pu")));
mismatch(mode, &before, &bad);
}
let good = expected(&before, "oa-performers").unwrap();
let mut bad = good.clone();
node_mut(&mut bad, "oa_assign", "x").position = xy(80., 140.);
mismatch("oa-performers", &before, &bad);
let mut bad = good;
bad.relations.get_mut(&id("oa_assign_ax")).unwrap().body = RelationBody::Allocation {
source: id("oa_assign_c"),
target: id("oa_assign_x"),
};
mismatch("oa-performers", &before, &bad);
}
#[test]
fn whole_project_sentinels_and_real_saved_source_are_checked() {
let before = fixture().unwrap();
let dir = tempfile::tempdir().unwrap();
let baseline = dir.path().join("baseline.json");
persistence::save(&baseline, &before).unwrap();
for mode in MODES {
let good = expected(&before, mode).unwrap();
for corrupt in [false, true] {
let mut actual = good.clone();
if corrupt {
actual
.elements
.get_mut(&id("sa_arrange_v"))
.unwrap()
.description
.push_str(" drift");
}
let bundle = roundtrip(&actual).unwrap();
let folder = dir.path().join(format!("{mode}-{corrupt}"));
for (path, text) in &bundle.files {
write_new(&folder.join(path), text.as_bytes()).unwrap();
}
let result = check(mode, &baseline, &folder.join(&bundle.entry));
if corrupt {
assert!(
result
.unwrap_err()
.to_string()
.contains("Whole Project mismatch")
);
} else {
result.unwrap();
}
}
let mut bad = good;
bad.diagrams
.get_mut(&id("other_view"))
.unwrap()
.nodes
.get_mut(&id("other_view_x"))
.unwrap()
.position
.x += 10.;
mismatch(mode, &before, &bad);
}
let invalid = dir.path().join("invalid.arcadia");
write_new(&invalid, b"project {").unwrap();
assert!(load(&invalid).is_err());
}
}
@@ -0,0 +1,27 @@
//! Read and validate a saved acceptance fixture without rewriting its source.
//! cargo run --locked -p oa-core --example attachment_snapshot -- input.arcadia output.json
use oa_core::{persistence, validate_candidate};
use std::{fs::OpenOptions, io::Write, path::Path};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let args: Vec<_> = std::env::args_os().skip(1).collect();
if args.len() != 2 {
return Err("Expected input source/JSON and a new output JSON path".into());
}
let project = persistence::load(Path::new(&args[0]))?;
validate_candidate(&project)?;
let bytes = serde_json::to_vec_pretty(&project)?;
let mut output = OpenOptions::new()
.write(true)
.create_new(true)
.open(&args[1])?;
output.write_all(&bytes)?;
output.write_all(b"\n")?;
output.sync_all()?;
println!(
"Validated {} objects and {} views; saved a complete Project snapshot",
project.identities().len(),
project.diagrams.len()
);
Ok(())
}
@@ -0,0 +1,717 @@
//! Native evidence fixture/checker; install as a temporary oa-core example.
//! This invariant evaluator derives delta from saved A, NOT from pointer input.
use oa_core::{source_project::*, *};
use std::{fs, io::Write, path::Path};
type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
fn id(alias: &str) -> Id {
// Make the intended OA view first for default UUID-ordered view selection.
if alias == "oa_view" {
return Id::from_u128(0x29_0001);
}
Id::new_v5(
&Id::from_u128(0xc1192026_0923_5000_8000_000000000029),
alias.as_bytes(),
)
}
fn point(x: f32, y: f32) -> Position {
Position { x, y }
}
fn element(p: &mut Project, alias: &str, name: &str, kind: Kind, layer: Layer, parent: Option<Id>) {
p.elements.insert(
id(alias),
Element {
id: id(alias),
alias: alias.into(),
name: name.into(),
description: format!("Preserve engineering record {alias}."),
parent,
body: ElementBody::new(kind, layer).unwrap(),
},
);
}
fn diagram(p: &mut Project, alias: &str, name: &str, layer: Layer) {
p.diagrams.insert(
id(alias),
Diagram {
id: id(alias),
alias: alias.into(),
name: name.into(),
description: "Selection changes placement geometry only.".into(),
layer,
projection: ProjectionKind::Overview,
view_options: Default::default(),
layout: None,
nodes: Default::default(),
routes: vec![],
},
);
}
fn place(p: &mut Project, view: &str, alias: &str, object: &str, position: Position, size: Size) {
p.diagrams.get_mut(&id(view)).unwrap().nodes.insert(
id(alias),
NodePlacement {
id: id(alias),
alias: alias.into(),
element: id(object),
position,
size,
collapsed: false,
port_anchors: vec![],
},
);
}
fn route(edge: &str, pins: Vec<Position>) -> EdgeRoute {
EdgeRoute {
edge: id(edge),
waypoints: pins,
source_attachment: Some(EdgeAttachment {
side: PortAnchorSide::Right,
offset: 0.25,
}),
target_attachment: Some(EdgeAttachment {
side: PortAnchorSide::Left,
offset: 0.7,
}),
label: Some(EdgeLabel {
fraction: 0.45,
offset: point(7., -19.),
}),
}
}
fn fixture() -> Result<Project> {
let mut p = Project::empty();
p.id = id("project");
p.alias = "multiselect_native".into();
p.name = "Multiselect native".into();
p.description =
"Shift-select A and B; move together, retain C and engineering semantics.".into();
for (alias, name, parent) in [
("activity_a", "A - receive request", None),
("activity_b", "B - assess request", None),
("activity_c", "C - dispatch response", None),
(
"activity_child",
"A.1 - classify request",
Some(id("activity_a")),
),
] {
element(&mut p, alias, name, Kind::Activity, Layer::Oa, parent);
}
element(
&mut p,
"sa_function",
"Technical request processing",
Kind::Function,
Layer::Sa,
None,
);
for (alias, name, source, target) in [
("internal", "Request assessment", "activity_a", "activity_b"),
("external", "Response decision", "activity_b", "activity_c"),
] {
let old = p.relate(RelationKind::Interaction, name, id(source), id(target));
reidentify(&mut p, old, id(alias))?;
p.relations.get_mut(&id(alias)).unwrap().alias = alias.into();
}
diagram(
&mut p,
"oa_view",
"Multiselect native - OA overview",
Layer::Oa,
);
diagram(&mut p, "oa_other", "Other OA view - preserve", Layer::Oa);
diagram(&mut p, "sa_view", "SA engineering - preserve", Layer::Sa);
let size = Size {
width: 224.,
height: 94.,
};
place(
&mut p,
"oa_view",
"node_a",
"activity_a",
point(80., 80.),
Size {
width: 310.,
height: 260.,
},
);
place(
&mut p,
"oa_view",
"node_b",
"activity_b",
point(470., 120.),
size,
);
place(
&mut p,
"oa_view",
"node_c",
"activity_c",
point(780., 270.),
size,
);
place(
&mut p,
"oa_view",
"node_child",
"activity_child",
point(120., 210.),
size,
);
place(
&mut p,
"oa_other",
"other_a",
"activity_a",
point(40., 60.),
size,
);
place(
&mut p,
"sa_view",
"sa_node",
"sa_function",
point(100., 110.),
size,
);
p.diagrams.get_mut(&id("oa_view")).unwrap().routes = vec![
route("internal", vec![point(420., 145.), point(420., 186.)]),
route("external", vec![point(725., 150.), point(725., 336.)]),
];
p.traces.insert(
id("trace"),
Trace {
id: id("trace"),
alias: "preserved_refinement".into(),
name: "Reviewed request refinement".into(),
description: "Group geometry and Hide preserve this trace and both endpoints.".into(),
oa: id("activity_a"),
sa: id("sa_function"),
kind: TraceKind::Refinement,
baseline: Some(TraceBaseline {
oa: p.properties(id("activity_a")).unwrap(),
sa: p.properties(id("sa_function")).unwrap(),
semantic: None,
}),
},
);
validate_candidate(&p)?;
Ok(p)
}
fn load(path: &Path) -> Result<Project> {
if path
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("arcadia"))
{
let disk = source_files::load_project(path, ProjectLimits::default())?;
// This deliberately partial engineering fixture has completeness
// warnings (allocation/behavior context). They are not source errors.
// Compilation, candidate validation and whole-Project equality still
// reject malformed source or any unexpected model change.
if disk
.diagnostics
.iter()
.any(|d| d.diagnostic.severity == validation::Severity::Error)
{
return Err(format!("Source discovery diagnostics: {:?}", disk.diagnostics).into());
}
let compiled = compile_bundle(&disk.reachable_bundle(), ProjectLimits::default())
.map_err(|e| format!("Source compile diagnostics: {e:?}"))?;
Ok(compiled.decoded.project)
} else {
Ok(persistence::load(path)?)
}
}
fn equal(expected: &Project, actual: &Project) -> Result<()> {
if expected == actual {
return Ok(());
}
let e = serde_json::to_value(expected)?;
let a = serde_json::to_value(actual)?;
let fields: Vec<_> = e
.as_object()
.unwrap()
.keys()
.filter(|key| e[*key] != a[*key])
.cloned()
.collect();
Err(format!("Whole Project mismatch in {fields:?}; no partial PASS").into())
}
fn roundtrip(p: &Project) -> Result<SourceBundle> {
validate_candidate(p)?;
equal(p, &persistence::decode(&serde_json::to_vec(p)?)?)?;
let bundle = format_bundle(p);
let compiled = compile_bundle(&bundle, ProjectLimits::default())
.map_err(|e| format!("Roundtrip: {e:?}"))?;
equal(p, &compiled.decoded.project)?;
Ok(bundle)
}
fn write_new(path: &Path, bytes: &[u8]) -> Result<()> {
fs::create_dir_all(path.parent().ok_or("Missing parent directory")?)?;
fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(path)?
.write_all(bytes)?;
Ok(())
}
fn generate(out: &Path) -> Result<()> {
if out.exists() {
return Err("Output directory already exists; choose a fresh path".into());
}
let p = fixture()?;
let bundle = roundtrip(&p)?;
write_new(&out.join("baseline.json"), &serde_json::to_vec_pretty(&p)?)?;
for (path, source) in &bundle.files {
write_new(&out.join("source").join(path), source.as_bytes())?;
}
let names = [
"project",
"oa_view",
"oa_other",
"sa_view",
"activity_a",
"activity_b",
"activity_c",
"activity_child",
"node_a",
"node_b",
"node_c",
"node_child",
"internal",
"external",
"trace",
"sa_function",
"sa_node",
"other_a",
];
let ids: std::collections::BTreeMap<_, _> =
names.into_iter().map(|name| (name, id(name))).collect();
write_new(
&out.join("manifest.json"),
&serde_json::to_vec_pretty(&serde_json::json!({
"fixture": "multiselect-native-20260923", "ids": ids, "source_entry": bundle.entry,
"move_members": ["node_a", "node_b", "node_child"], "fixed_member": "node_c",
"evaluation": "Whole Project invariant comparison; saved A supplies bounded shared delta, not an exact pointer oracle",
"hide": "Remove node_a/node_b only; retain child, stored routes, all model records and other views",
}))?,
)?;
equal(&p, &load(&out.join("baseline.json"))?)?;
equal(&p, &load(&out.join("source/project.arcadia"))?)?;
println!("PASS generated JSON and source bundle: {}", out.display());
Ok(())
}
fn check(mode: &str, baseline: &Path, saved: &Path) -> Result<()> {
let before = load(baseline)?;
let actual = load(saved)?;
validate_candidate(&before)?;
validate_candidate(&actual)?;
if before.id != id("project") {
return Err("Not this fixture's baseline project".into());
}
let mut expected = before.clone();
let mut delta = None;
match mode {
"unchanged" => {}
"hide" => {
let view = expected
.diagrams
.get_mut(&id("oa_view"))
.ok_or("Missing OA view")?;
for alias in ["node_a", "node_b"] {
view.nodes
.remove(&id(alias))
.ok_or("Baseline selected placement missing")?;
}
// references::remove_objects removes routes only when their semantic
// edge is deleted. Hide deletes placements; both routes MUST survive.
}
"moved" => {
let old = before
.diagrams
.get(&id("oa_view"))
.ok_or("Missing baseline OA view")?;
let new = actual
.diagrams
.get(&id("oa_view"))
.ok_or("Missing saved OA view")?;
let a = old
.nodes
.get(&id("node_a"))
.ok_or("Missing baseline A")?
.position;
let moved_a = new
.nodes
.get(&id("node_a"))
.ok_or("Missing saved A")?
.position;
let d = point(moved_a.x - a.x, moved_a.y - a.y);
if !d.x.is_finite()
|| !d.y.is_finite()
|| d.x.abs().max(d.y.abs()) > 500.
|| d.x.hypot(d.y) < 10.
{
return Err(format!(
"Move delta must have length >=10 and each axis <=500 world units: {d:?}"
)
.into());
}
if moved_a.x % 10. != 0. || moved_a.y % 10. != 0. {
return Err("Saved anchor is not on 10-unit grid".into());
}
let view = expected.diagrams.get_mut(&id("oa_view")).unwrap();
// Independent explicit fixture enumeration: no group/descendant/
// routing planner is called to predict the result.
for alias in ["node_a", "node_b", "node_child"] {
let node = view
.nodes
.get_mut(&id(alias))
.ok_or("Missing expected moved placement")?;
node.position.x += d.x;
node.position.y += d.y;
}
let internal = view
.routes
.iter_mut()
.find(|route| route.edge == id("internal"))
.ok_or("Missing internal pins")?;
for pin in &mut internal.waypoints {
pin.x += d.x;
pin.y += d.y;
}
delta = Some(d);
}
_ => return Err("Mode must be moved, hide, or unchanged".into()),
}
equal(&expected, &actual)?;
roundtrip(&actual)?;
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
"result": "PASS", "mode": mode, "baseline": baseline, "saved": saved,
"comparison": "complete Project equality", "inferred_common_delta": delta,
"limits": "Invariant evaluator only; no pointer/action/focus/Undo/source-byte acceptance claim",
}))?
);
Ok(())
}
fn main() -> Result<()> {
let args: Vec<_> = std::env::args().skip(1).collect();
match args.as_slice() {
[command, out] if command == "generate" => generate(Path::new(out)),
[command, mode, baseline, saved] if command == "check" => check(mode, Path::new(baseline), Path::new(saved)),
_ => Err("Usage: multiselect-native-producer generate DIRECTORY | check moved|hide|unchanged BASELINE SAVED".into()),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::BTreeSet;
// Produce evidence through real validated commands; the checker itself uses
// explicit fixture enumeration and never calls this movement implementation.
fn moved(before: &Project, delta: Position) -> Project {
let command = diagram_edit::translate_placements(
before,
id("oa_view"),
&BTreeSet::from([id("node_a"), id("node_b")]),
delta,
)
.unwrap();
let mut session = Session::new(before.clone());
session.execute(command).unwrap();
session.project().clone()
}
fn hidden(before: &Project) -> Project {
let mut session = Session::new(before.clone());
session
.execute(Command::Batch(vec![
Command::Delete(id("node_a")),
Command::Delete(id("node_b")),
]))
.unwrap();
session.project().clone()
}
fn through_saved_json(mode: &str, baseline: &Project, actual: &Project) -> Result<()> {
let dir = tempfile::tempdir()?;
let before = dir.path().join("before.json");
let saved = dir.path().join("saved.json");
// These assertions distinguish meaningful invariant corruptions from
// malformed projects rejected before the evaluator even sees them.
validate_candidate(baseline).expect("test baseline must be valid");
validate_candidate(actual).expect("corruption must remain a valid model");
persistence::save(&before, baseline)?;
persistence::save(&saved, actual)?;
check(mode, &before, &saved)
}
fn mismatch(mode: &str, before: &Project, actual: &Project, case: &str) {
let error = through_saved_json(mode, before, actual)
.expect_err(case)
.to_string();
assert!(
error.contains("Whole Project mismatch"),
"{case}: unexpected rejection: {error}"
);
}
fn edge<'a>(p: &'a mut Project, alias: &str) -> &'a mut EdgeRoute {
p.diagrams
.get_mut(&id("oa_view"))
.unwrap()
.routes
.iter_mut()
.find(|route| route.edge == id(alias))
.unwrap()
}
#[test]
fn generated_json_and_real_source_bundle_are_accepted_unchanged() {
let dir = tempfile::tempdir().unwrap();
let out = dir.path().join("fixture");
generate(&out).unwrap();
let baseline = out.join("baseline.json");
let saved = out.join("saved.json");
persistence::save(&saved, &fixture().unwrap()).unwrap();
check("unchanged", &baseline, &saved).unwrap();
check("unchanged", &baseline, &out.join("source/project.arcadia")).unwrap();
let bytes = fs::read(&baseline).unwrap();
assert!(
generate(&out).is_err(),
"generation cannot overwrite evidence"
);
assert_eq!(fs::read(&baseline).unwrap(), bytes);
let invalid = dir.path().join("invalid.arcadia");
fs::write(&invalid, "project {").unwrap();
assert!(load(&invalid).is_err(), "source errors must still reject");
}
#[test]
fn real_group_command_outputs_pass_and_are_not_unchanged() {
let before = fixture().unwrap();
for delta in [point(40., 60.), point(-20., 30.)] {
let actual = moved(&before, delta);
through_saved_json("moved", &before, &actual).unwrap();
mismatch(
"unchanged",
&before,
&actual,
"real movement cannot count as unchanged",
);
}
}
#[test]
fn real_hide_command_outputs_pass_before_and_after_movement() {
let initial = fixture().unwrap();
for before in [initial.clone(), moved(&initial, point(40., 60.))] {
let actual = hidden(&before);
through_saved_json("hide", &before, &actual).unwrap();
mismatch(
"unchanged",
&before,
&actual,
"real Hide cannot count as unchanged",
);
}
}
#[test]
fn omitted_and_double_moved_child_are_rejected() {
let before = fixture().unwrap();
let delta = point(40., 60.);
let correct = moved(&before, delta);
let initial = before.diagrams[&id("oa_view")].nodes[&id("node_child")].position;
for position in [
initial,
point(initial.x + 2. * delta.x, initial.y + 2. * delta.y),
] {
let mut bad = correct.clone();
bad.diagrams
.get_mut(&id("oa_view"))
.unwrap()
.nodes
.get_mut(&id("node_child"))
.unwrap()
.position = position;
mismatch("moved", &before, &bad, "child did not move exactly once");
}
}
#[test]
fn internal_pins_left_behind_and_external_pins_moved_are_rejected() {
let mut before = fixture().unwrap();
let original_pins = edge(&mut before, "internal").waypoints.clone();
let correct = moved(&before, point(40., 60.));
let mut bad = correct.clone();
edge(&mut bad, "internal").waypoints = original_pins;
mismatch("moved", &before, &bad, "internal pins left behind");
let mut bad = correct;
for pin in &mut edge(&mut bad, "external").waypoints {
pin.x += 40.;
pin.y += 60.;
}
mismatch("moved", &before, &bad, "one-ended pins moved");
}
#[test]
fn manual_attachments_and_label_data_must_remain_exact() {
let before = fixture().unwrap();
let correct = moved(&before, point(40., 60.));
let mutations: [fn(&mut EdgeRoute); 4] = [
|r| r.source_attachment.as_mut().unwrap().offset = 0.65,
|r| r.target_attachment.as_mut().unwrap().side = PortAnchorSide::Top,
|r| r.label.as_mut().unwrap().offset.x += 40.,
|r| r.label.as_mut().unwrap().fraction = 0.75,
];
for mutate in mutations {
let mut bad = correct.clone();
mutate(edge(&mut bad, "internal"));
mismatch("moved", &before, &bad, "relative route metadata changed");
}
}
#[test]
fn semantic_other_view_fixed_node_and_trace_drift_are_rejected() {
let before = fixture().unwrap();
let correct = moved(&before, point(40., 60.));
let mutations: [fn(&mut Project); 5] = [
|p| {
p.elements
.get_mut(&id("activity_a"))
.unwrap()
.description
.push_str(" drift")
},
|p| {
p.diagrams
.get_mut(&id("oa_other"))
.unwrap()
.nodes
.get_mut(&id("other_a"))
.unwrap()
.position
.x += 40.
},
|p| {
p.diagrams
.get_mut(&id("sa_view"))
.unwrap()
.nodes
.get_mut(&id("sa_node"))
.unwrap()
.position
.y += 60.
},
|p| {
p.diagrams
.get_mut(&id("oa_view"))
.unwrap()
.nodes
.get_mut(&id("node_c"))
.unwrap()
.position
.x += 40.
},
|p| {
p.traces
.get_mut(&id("trace"))
.unwrap()
.description
.push_str(" drift")
},
];
for mutate in mutations {
let mut bad = correct.clone();
mutate(&mut bad);
mismatch(
"moved",
&before,
&bad,
"record outside permitted movement changed",
);
}
}
#[test]
fn zero_small_excessive_and_offgrid_moves_cannot_satisfy_move_evidence() {
let before = fixture().unwrap();
for (delta, reason) in [
(point(0., 0.), "length >=10"),
(point(3., 0.), "length >=10"),
(point(510., 0.), "each axis <=500"),
(point(20.5, 0.), "10-unit grid"),
] {
let actual = moved(&before, delta);
let error = through_saved_json("moved", &before, &actual)
.unwrap_err()
.to_string();
assert!(error.contains(reason), "{delta:?}: {error}");
}
}
#[test]
fn hide_must_preserve_child_routes_and_semantic_objects() {
let before = fixture().unwrap();
let correct = hidden(&before);
let mut missing_child = correct.clone();
missing_child
.diagrams
.get_mut(&id("oa_view"))
.unwrap()
.nodes
.remove(&id("node_child"));
mismatch(
"hide",
&before,
&missing_child,
"Hide removed unselected child placement",
);
let mut missing_routes = correct;
missing_routes
.diagrams
.get_mut(&id("oa_view"))
.unwrap()
.routes
.clear();
mismatch(
"hide",
&before,
&missing_routes,
"Hide removed persistent route records",
);
let mut semantic_delete = Session::new(before.clone());
semantic_delete
.execute(Command::Batch(vec![
Command::Delete(id("activity_a")),
Command::Delete(id("activity_b")),
]))
.unwrap();
mismatch(
"hide",
&before,
semantic_delete.project(),
"Hide became semantic deletion",
);
}
#[test]
fn unchanged_rejects_valid_semantic_drift_without_geometry_changes() {
let before = fixture().unwrap();
let mut actual = before.clone();
actual
.elements
.get_mut(&id("activity_b"))
.unwrap()
.name
.push_str(" edited");
mismatch(
"unchanged",
&before,
&actual,
"geometry equality is not complete Project equality",
);
}
}
@@ -0,0 +1,548 @@
//! Deterministic Process native fixture and independent complete-Project oracle.
//! Expected order is literal; no desktop reorder/layout planner is used here.
use oa_core::{source_project::*, *};
use std::{fs, io::Write, path::Path};
type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
fn id(alias: &str) -> Id {
Id::new_v5(
&Id::from_u128(0xc1192026_0923_5000_8000_000000002930),
alias.as_bytes(),
)
}
fn element(p: &mut Project, alias: &str, name: &str, kind: Kind, layer: Layer, parent: Option<Id>) {
p.elements.insert(
id(alias),
Element {
id: id(alias),
alias: alias.into(),
name: name.into(),
description: format!("Preserve engineering record {alias}."),
parent,
body: ElementBody::new(kind, layer).unwrap(),
},
);
}
fn occurrence(
p: &mut Project,
alias: &str,
name: &str,
layer: Layer,
owner: &str,
value: OccurrenceBody,
) {
element(p, alias, name, Kind::Occurrence, layer, Some(id(owner)));
let Some(SharedBody::Occurrence { body, .. }) =
p.elements.get_mut(&id(alias)).unwrap().shared_mut()
else {
panic!("Occurrence body");
};
*body = value;
}
fn set_steps(p: &mut Project, owner: &str, aliases: &[&str]) {
*p.elements.get_mut(&id(owner)).unwrap().steps_mut().unwrap() =
aliases.iter().map(|alias| id(alias)).collect();
}
fn sequence_link(
p: &mut Project,
alias: &str,
name: &str,
source: &str,
target: &str,
) -> Result<()> {
let old = p.relate(RelationKind::SequenceLink, name, id(source), id(target));
reidentify(p, old, id(alias))?;
let relation = p.relations.get_mut(&id(alias)).unwrap();
relation.alias = alias.into();
relation.description = format!("Preserve link {alias} exactly.");
if let RelationBody::SequenceLink { guard, .. } = &mut relation.body {
*guard = "ready".into();
}
Ok(())
}
fn fixture() -> Result<Project> {
let mut p = Project::empty();
p.id = id("project");
p.alias = "process_multiselect_native".into();
p.name = "Process group native".into();
p.description = "Select Card C then Shift-select Card A; drop after Card B. Other columns and reusable engineering records remain exact.".into();
for (layer, prefix, owner, name, owner_kind, work_kind) in [
(
Layer::Oa,
"oa",
"oa_process",
"01 OA group order",
Kind::Process,
Kind::Activity,
),
(
Layer::Sa,
"sa",
"sa_chain",
"SA chain - preserve",
Kind::FunctionalChain,
Kind::Function,
),
] {
element(&mut p, owner, name, owner_kind, layer, None);
let shared = format!("{prefix}_shared_work");
let downstream = format!("{prefix}_downstream_work");
element(
&mut p,
&shared,
"Shared request work",
work_kind,
layer,
None,
);
element(
&mut p,
&downstream,
"Downstream verification",
work_kind,
layer,
None,
);
for (suffix, label) in [
("a", "Card A"),
("u", "Downstream U"),
("b", "Card B"),
("v", "Downstream V"),
("c", "Card C"),
] {
let body = if suffix == "b" {
OccurrenceBody::Control {
kind: ControlKind::Choice,
}
} else {
OccurrenceBody::Action {
action: Some(id(if suffix == "a" || suffix == "c" {
&shared
} else {
&downstream
})),
lifeline: None,
}
};
occurrence(
&mut p,
&format!("{prefix}_{suffix}"),
label,
layer,
owner,
body,
);
}
let order: Vec<_> = ["a", "u", "b", "v", "c"]
.map(|suffix| format!("{prefix}_{suffix}"))
.into_iter()
.collect();
set_steps(
&mut p,
owner,
&order.iter().map(String::as_str).collect::<Vec<_>>(),
);
sequence_link(
&mut p,
&format!("{prefix}_a_u"),
"A to U",
&format!("{prefix}_a"),
&format!("{prefix}_u"),
)?;
sequence_link(
&mut p,
&format!("{prefix}_b_v"),
"B to V",
&format!("{prefix}_b"),
&format!("{prefix}_v"),
)?;
}
element(
&mut p,
"reusable",
"Reusable request detail",
Kind::Process,
Layer::Oa,
None,
);
occurrence(
&mut p,
"reused_leaf",
"Shared detail",
Layer::Oa,
"reusable",
OccurrenceBody::Action {
action: Some(id("oa_shared_work")),
lifeline: None,
},
);
set_steps(&mut p, "reusable", &["reused_leaf"]);
element(
&mut p,
"reuse_trial",
"02 Read-only preview trial",
Kind::Process,
Layer::Oa,
None,
);
for (alias, name) in [
("reference_one", "First use"),
("reference_two", "Second use"),
] {
occurrence(
&mut p,
alias,
name,
Layer::Oa,
"reuse_trial",
OccurrenceBody::SequenceReference {
sequence: Some(id("reusable")),
},
);
}
set_steps(&mut p, "reuse_trial", &["reference_one", "reference_two"]);
p.traces.insert(
id("trace"),
Trace {
id: id("trace"),
alias: "preserved_work_refinement".into(),
name: "Shared request refinement".into(),
description: "Process arrangement must preserve cross-layer correspondence.".into(),
oa: id("oa_shared_work"),
sa: id("sa_shared_work"),
kind: TraceKind::Refinement,
baseline: Some(TraceBaseline {
oa: p.properties(id("oa_shared_work")).unwrap(),
sa: p.properties(id("sa_shared_work")).unwrap(),
semantic: None,
}),
},
);
validate_candidate(&p)?;
Ok(p)
}
fn load(path: &Path) -> Result<Project> {
if path
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("arcadia"))
{
let disk = source_files::load_project(path, ProjectLimits::default())?;
if disk
.diagnostics
.iter()
.any(|d| d.diagnostic.severity == validation::Severity::Error)
{
return Err(format!("Source discovery diagnostics: {:?}", disk.diagnostics).into());
}
let compiled = compile_bundle(&disk.reachable_bundle(), ProjectLimits::default())
.map_err(|e| format!("Source compile diagnostics: {e:?}"))?;
Ok(compiled.decoded.project)
} else {
Ok(persistence::load(path)?)
}
}
fn equal(expected: &Project, actual: &Project) -> Result<()> {
if expected == actual {
return Ok(());
}
let expected = serde_json::to_value(expected)?;
let actual = serde_json::to_value(actual)?;
let fields: Vec<_> = expected
.as_object()
.unwrap()
.keys()
.filter(|key| expected[*key] != actual[*key])
.cloned()
.collect();
Err(format!("Whole Project mismatch in {fields:?}; no partial PASS").into())
}
fn roundtrip(p: &Project) -> Result<SourceBundle> {
validate_candidate(p)?;
equal(p, &persistence::decode(&serde_json::to_vec(p)?)?)?;
let bundle = format_bundle(p);
let compiled = compile_bundle(&bundle, ProjectLimits::default())
.map_err(|e| format!("Roundtrip: {e:?}"))?;
equal(p, &compiled.decoded.project)?;
Ok(bundle)
}
fn write_new(path: &Path, bytes: &[u8]) -> Result<()> {
fs::create_dir_all(path.parent().ok_or("Missing parent directory")?)?;
fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(path)?
.write_all(bytes)?;
Ok(())
}
fn generate(out: &Path) -> Result<()> {
if out.exists() {
return Err("Output directory already exists; choose a fresh path".into());
}
let p = fixture()?;
let bundle = roundtrip(&p)?;
write_new(&out.join("baseline.json"), &serde_json::to_vec_pretty(&p)?)?;
for (path, source) in &bundle.files {
write_new(&out.join("source").join(path), source.as_bytes())?;
}
let ids: std::collections::BTreeMap<_, _> = [
"project",
"oa_process",
"oa_a",
"oa_u",
"oa_b",
"oa_v",
"oa_c",
"oa_a_u",
"oa_b_v",
"oa_shared_work",
"oa_downstream_work",
"sa_chain",
"sa_shared_work",
"sa_downstream_work",
"sa_a",
"sa_u",
"sa_b",
"sa_v",
"sa_c",
"sa_a_u",
"sa_b_v",
"trace",
"reusable",
"reused_leaf",
"reuse_trial",
"reference_one",
"reference_two",
]
.into_iter()
.map(|name| (name, id(name)))
.collect();
write_new(
&out.join("manifest.json"),
&serde_json::to_vec_pretty(&serde_json::json!({
"fixture": "process-multiselect-native-20260923", "ids": ids, "source_entry": bundle.entry,
"original_oa_steps": ["oa_a", "oa_u", "oa_b", "oa_v", "oa_c"],
"reordered_oa_steps": ["oa_b", "oa_u", "oa_a", "oa_v", "oa_c"],
"gesture": "Select Card C then add Card A; Inspect Card C without changing membership, then drag Card C to After Card B",
"fixed_slots": {"1": "oa_u", "3": "oa_v"},
"read_only_trial": "02 Read-only preview trial has two qualified tiles of reused_leaf through distinct references",
"receipt": "Pin baseline.json, manifest.json and generation command externally before interaction. Never replace baseline with a later saved snapshot.",
"evaluation": "Literal expected OA steps and complete Project equality; all SA/reuse/trace records exact",
"limits": "No pointer, focus, selection-paint, history or source-byte acceptance claim",
}))?,
)?;
equal(&p, &load(&out.join("baseline.json"))?)?;
equal(&p, &load(&out.join("source").join(&bundle.entry))?)?;
println!("PASS generated JSON and source bundle: {}", out.display());
Ok(())
}
fn check(mode: &str, baseline: &Path, saved: &Path) -> Result<()> {
let before = load(baseline)?;
let actual = load(saved)?;
validate_candidate(&before)?;
validate_candidate(&actual)?;
// Pin the exact deterministic original, not merely a reusable project UUID.
equal(&fixture()?, &before)
.map_err(|e| format!("Baseline is not the immutable original: {e}"))?;
let mut expected = before;
match mode {
"unchanged" => {}
"reordered" => set_steps(
&mut expected,
"oa_process",
&["oa_b", "oa_u", "oa_a", "oa_v", "oa_c"],
),
_ => return Err("Mode must be reordered or unchanged".into()),
}
equal(&expected, &actual)?;
roundtrip(&actual)?;
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
"result": "PASS", "mode": mode, "baseline": baseline, "saved": saved,
"comparison": "complete Project equality; literal expected order",
"limits": "No pointer/action/focus/Undo/source-byte acceptance claim",
}))?
);
Ok(())
}
fn main() -> Result<()> {
let args: Vec<_> = std::env::args().skip(1).collect();
match args.as_slice() {
[command, out] if command == "generate" => generate(Path::new(out)),
[command, mode, baseline, saved] if command == "check" => check(mode, Path::new(baseline), Path::new(saved)),
_ => Err("Usage: process_multiselect_native generate DIRECTORY | check reordered|unchanged BASELINE SAVED".into()),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn reordered(before: &Project) -> Project {
let mut session = Session::new(before.clone());
session
.execute(Command::SetSteps(
id("oa_process"),
["oa_b", "oa_u", "oa_a", "oa_v", "oa_c"].map(id).to_vec(),
))
.unwrap();
session.project().clone()
}
fn through_json(mode: &str, baseline: &Project, actual: &Project) -> Result<()> {
validate_candidate(baseline).expect("Baseline must be valid");
validate_candidate(actual).expect("Corruption must remain semantically valid");
let dir = tempfile::tempdir()?;
let before = dir.path().join("before.json");
let saved = dir.path().join("saved.json");
persistence::save(&before, baseline)?;
persistence::save(&saved, actual)?;
check(mode, &before, &saved)
}
fn mismatch(before: &Project, actual: &Project) {
let error = through_json("reordered", before, actual)
.unwrap_err()
.to_string();
assert!(
error.contains("Whole Project mismatch"),
"Unexpected rejection: {error}"
);
}
#[test]
fn generate_is_deterministic_create_only_and_checks_real_source() {
equal(&fixture().unwrap(), &fixture().unwrap()).unwrap();
let dir = tempfile::tempdir().unwrap();
let out = dir.path().join("fixture");
generate(&out).unwrap();
let baseline = out.join("baseline.json");
let bytes = fs::read(&baseline).unwrap();
check("unchanged", &baseline, &out.join("source/project.arcadia")).unwrap();
assert!(generate(&out).is_err());
assert_eq!(fs::read(&baseline).unwrap(), bytes);
let invalid = dir.path().join("invalid.arcadia");
fs::write(&invalid, "project {").unwrap();
assert!(load(&invalid).is_err());
}
#[test]
fn real_set_steps_passes_but_unchanged_and_mutated_baseline_do_not() {
let before = fixture().unwrap();
let actual = reordered(&before);
through_json("reordered", &before, &actual).unwrap();
through_json("unchanged", &before, &before).unwrap();
mismatch(&before, &before);
assert!(through_json("unchanged", &before, &actual).is_err());
let error = through_json("unchanged", &actual, &actual)
.unwrap_err()
.to_string();
assert!(error.contains("immutable original"), "{error}");
}
#[test]
fn global_splice_reversed_block_and_singleton_c_results_are_rejected() {
let before = fixture().unwrap();
// Native primary/pressed C is already after B, so a singleton C move
// is unchanged. Group {C,A} instead puts B before both selected cards.
// If A were primary, its singleton result could equal the group result.
mismatch(&before, &before);
for order in [
["oa_u", "oa_b", "oa_a", "oa_c", "oa_v"],
["oa_b", "oa_u", "oa_c", "oa_v", "oa_a"],
["oa_a", "oa_u", "oa_b", "oa_c", "oa_v"],
] {
let mut actual = before.clone();
set_steps(&mut actual, "oa_process", &order);
mismatch(&before, &actual);
}
}
#[test]
fn semantic_link_work_sa_reuse_and_trace_changes_are_rejected() {
let before = fixture().unwrap();
let correct = reordered(&before);
let mutations: [fn(&mut Project); 7] = [
|p| {
if let RelationBody::SequenceLink { guard, .. } =
&mut p.relations.get_mut(&id("oa_a_u")).unwrap().body
{
*guard = "changed".into();
}
},
|p| {
if let RelationBody::SequenceLink { target, .. } =
&mut p.relations.get_mut(&id("oa_a_u")).unwrap().body
{
*target = id("oa_v");
}
},
|p| {
p.elements
.get_mut(&id("oa_shared_work"))
.unwrap()
.name
.push_str(" drift");
},
|p| {
p.elements
.get_mut(&id("sa_shared_work"))
.unwrap()
.description
.push_str(" drift");
},
|p| {
set_steps(p, "sa_chain", &["sa_b", "sa_u", "sa_a", "sa_v", "sa_c"]);
},
|p| {
p.elements
.get_mut(&id("reused_leaf"))
.unwrap()
.description
.push_str(" drift");
},
|p| {
p.traces.get_mut(&id("trace")).unwrap().baseline = None;
},
];
for mutate in mutations {
let mut bad = correct.clone();
mutate(&mut bad);
mismatch(&before, &bad);
}
}
#[test]
fn changed_source_roundtrip_preserves_both_layers_and_checker_rejects_source_drift() {
let before = fixture().unwrap();
let actual = reordered(&before);
let dir = tempfile::tempdir().unwrap();
let baseline = dir.path().join("baseline.json");
persistence::save(&baseline, &before).unwrap();
for corrupt in [false, true] {
let mut saved = actual.clone();
if corrupt {
saved
.elements
.get_mut(&id("sa_c"))
.unwrap()
.name
.push_str(" drift");
}
let bundle = roundtrip(&saved).unwrap();
let folder = dir.path().join(if corrupt { "corrupt" } else { "saved" });
for (path, text) in &bundle.files {
write_new(&folder.join(path), text.as_bytes()).unwrap();
}
let parsed = load(&folder.join(&bundle.entry)).unwrap();
assert_eq!(parsed.elements[&id("oa_process")].kind(), Kind::Process);
assert_eq!(
parsed.elements[&id("sa_chain")].kind(),
Kind::FunctionalChain
);
let result = check("reordered", &baseline, &folder.join(&bundle.entry));
if corrupt {
assert!(
result
.unwrap_err()
.to_string()
.contains("Whole Project mismatch")
);
} else {
result.unwrap();
}
}
}
}
@@ -230,7 +230,7 @@ fn fixture() -> (Project, Id, Vec<Id>, Vec<Id>, Vec<Id>) {
)
})
.collect();
p.diagrams.insert(diagram, Diagram { id: diagram, alias: "forty_exchange_view".into(), name: "Forty service exchanges · all concerns".into(), description: "Start with All. Compare first category, overlapping concerns, global coverage and chosen processes. No hand-routed edges.".into(), layer: Layer::Oa, projection: ProjectionKind::FunctionalDataflow, view_options: ViewOptions::default(), nodes, routes: vec![] });
p.diagrams.insert(diagram, Diagram { id: diagram, alias: "forty_exchange_view".into(), name: "Forty service exchanges · all concerns".into(), description: "Start with All. Compare first category, overlapping concerns, global coverage and chosen processes. No hand-routed edges.".into(), layer: Layer::Oa, projection: ProjectionKind::FunctionalDataflow, view_options: ViewOptions::default(), nodes, layout: None, routes: vec![] });
(p, diagram, exchanges, categories, contexts)
}
@@ -0,0 +1,101 @@
//! Read-only saved-State geometry evidence for a predeclared native bend trial.
//!
//! Usage:
//! state_bend_routing_snapshot INPUT.arcadia NEW_OUTPUT.json
//!
//! Loads and validates the complete Project, then independently projects/routes
//! every saved State view through core APIs. The complete Project permits exact
//! semantic and presentation comparison; derived geometry permits declaring a
//! segment and insertion position before native actions. No desktop command,
//! planner, gesture, or pin insertion helper is called. Input is never written;
//! output uses create_new and fails rather than overwriting an existing file.
//!
//! Limits: this shares production core routing, so it is not an independent
//! mathematical test of that router, nor evidence of UI picking/gesture success.
//! It does not generate expected mutations or adapt an oracle to observed edits.
//! The acceptance recipe must declare the intended segment/index/point and exact
//! Project mutation before UI execution, and bind this executable/source/input
//! and output hashes externally. Warning strings are diagnostic Debug output,
//! not a stable interchange schema. Missing/hidden/internal transitions have no
//! routed edge; their semantic records remain in the complete Project snapshot.
use oa_core::{persistence, projection, routing, validate_candidate};
use serde_json::json;
use std::{fs::OpenOptions, io::Write, path::Path};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let args: Vec<_> = std::env::args_os().skip(1).collect();
if args.len() != 2 {
return Err("Expected input source/JSON and a new output JSON path".into());
}
let project = persistence::load(Path::new(&args[0]))?;
validate_candidate(&project)?;
let mut views = Vec::new();
for (diagram_id, diagram) in &project.diagrams {
let Some(machine) = diagram.projection.state_machine() else {
continue;
};
let projected = projection::project_diagram(&project, diagram);
let routed = routing::route_diagram(&project, diagram, &projected);
let edges: Vec<_> = routed
.edges
.iter()
.map(|(edge_id, edge)| {
json!({
"edge": edge_id,
"id": edge.id,
"points": edge.points,
"pins": edge.pins,
"pin_fractions": edge.pin_fractions,
"custom_suspended": edge.custom_suspended,
"warnings": edge.warnings.iter().map(|warning| format!("{warning:?}")).collect::<Vec<_>>(),
"label": {
"text": edge.label.text,
"fraction": edge.label.fraction,
"anchor": edge.label.anchor,
"center": edge.label.center,
"bounds": {
"position": edge.label.bounds.position,
"size": edge.label.bounds.size
}
}
})
})
.collect();
views.push(json!({
"diagram": diagram_id,
"machine": machine,
"projected": projected,
"routed_bounds": {
"position": routed.bounds.position,
"size": routed.bounds.size
},
"routed_edges": edges
}));
}
// Preserve the canonical typed Project serialization (including f32
// formatting). Serializing it through Value directly promotes f32 to f64.
let project_json: serde_json::Value = serde_json::from_slice(&serde_json::to_vec(&project)?)?;
let snapshot = json!({
"schema": "state-bend-routing-snapshot/v1",
"project": project_json,
"state_views": views
});
// Finish all reading, validation, routing and serialization before creating
// the new evidence file. A failed load cannot leave an apparent snapshot.
let mut bytes = serde_json::to_vec_pretty(&snapshot)?;
bytes.push(b'\n');
let mut output = OpenOptions::new()
.write(true)
.create_new(true)
.open(&args[1])?;
output.write_all(&bytes)?;
output.sync_all()?;
println!(
"Validated {} objects and {} saved views; snapshotted {} State views and the complete Project",
project.identities().len(),
project.diagrams.len(),
views.len()
);
Ok(())
}
@@ -0,0 +1,912 @@
//! Deterministic persisted-State native fixture and complete-Project checker.
//! This invariant evaluator derives delta from saved A, NOT from pointer input.
use oa_core::{source_project::*, *};
use std::{fs, io::Write, path::Path};
type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
fn id(alias: &str) -> Id {
// Dedicated State view is the smallest diagram UUID, for native opening.
if alias == "state_view" {
return Id::from_u128(0x29_0101);
}
Id::new_v5(
&Id::from_u128(0xc1192026_0923_5000_8000_000000002929),
alias.as_bytes(),
)
}
fn point(x: f32, y: f32) -> Position {
Position { x, y }
}
fn element(p: &mut Project, alias: &str, name: &str, kind: Kind, layer: Layer, parent: Option<Id>) {
p.elements.insert(
id(alias),
Element {
id: id(alias),
alias: alias.into(),
name: name.into(),
description: format!("Preserve engineering record {alias}."),
parent,
body: ElementBody::new(kind, layer).unwrap(),
},
);
}
fn diagram(p: &mut Project, alias: &str, name: &str, layer: Layer) {
p.diagrams.insert(
id(alias),
Diagram {
id: id(alias),
alias: alias.into(),
name: name.into(),
description: "Selection changes placement geometry only.".into(),
layer,
projection: ProjectionKind::Overview,
view_options: Default::default(),
layout: None,
nodes: Default::default(),
routes: vec![],
},
);
}
fn place(p: &mut Project, view: &str, alias: &str, object: &str, position: Position, size: Size) {
p.diagrams.get_mut(&id(view)).unwrap().nodes.insert(
id(alias),
NodePlacement {
id: id(alias),
alias: alias.into(),
element: id(object),
position,
size,
collapsed: false,
port_anchors: vec![],
},
);
}
fn route(edge: &str, pins: Vec<Position>) -> EdgeRoute {
EdgeRoute {
edge: id(edge),
waypoints: pins,
source_attachment: Some(EdgeAttachment {
side: PortAnchorSide::Right,
offset: 0.25,
}),
target_attachment: Some(EdgeAttachment {
side: PortAnchorSide::Left,
offset: 0.7,
}),
label: Some(EdgeLabel {
fraction: 0.45,
offset: point(7., -19.),
}),
}
}
fn transition(
p: &mut Project,
alias: &str,
name: &str,
source: &str,
target: &str,
kind: TransitionKind,
) -> Result<()> {
let old = p.relate(RelationKind::StateTransition, name, id(source), id(target));
reidentify(p, old, id(alias))?;
let relation = p.relations.get_mut(&id(alias)).unwrap();
relation.alias = alias.into();
relation.description = format!("Preserve transition {alias}.");
if let RelationBody::StateTransition {
transition_kind,
guard,
..
} = &mut relation.body
{
*transition_kind = kind;
*guard = if alias == "between" {
"request_ready"
} else {
""
}
.into();
}
Ok(())
}
fn fixture() -> Result<Project> {
let mut p = Project::empty();
p.id = id("project");
p.alias = "state_multiselect_native".into();
p.name = "State group native".into();
p.description = "Shift-select A and B; move their placements together. C and shared engineering records stay fixed.".into();
for (alias, name, kind, parent) in [
("crew", "Operations crew", Kind::Entity, None),
(
"machine",
"Request lifecycle",
Kind::StateMachine,
Some(id("crew")),
),
("region", "Main region", Kind::Region, Some(id("machine"))),
("state_a", "A - Processing", Kind::State, Some(id("region"))),
("state_b", "B - Ready", Kind::State, Some(id("region"))),
("state_c", "C - Complete", Kind::State, Some(id("region"))),
(
"inner",
"A nested region",
Kind::Region,
Some(id("state_a")),
),
(
"state_leaf",
"A.1 - Classify",
Kind::State,
Some(id("inner")),
),
("entry", "Enter A", Kind::EntryPoint, Some(id("inner"))),
("exit", "Leave A", Kind::ExitPoint, Some(id("inner"))),
("shared_work", "Record request", Kind::Activity, None),
] {
element(&mut p, alias, name, kind, Layer::Oa, parent);
}
element(
&mut p,
"sa_function",
"Technical record request",
Kind::Function,
Layer::Sa,
None,
);
for (alias, name, source, target, kind) in [
(
"between",
"Ready\nfor review",
"state_a",
"state_b",
TransitionKind::External,
),
(
"external",
"Complete request",
"state_b",
"state_c",
TransitionKind::External,
),
(
"entry_route",
"Begin classification",
"entry",
"state_leaf",
TransitionKind::Local,
),
(
"exit_route",
"Classification complete",
"state_leaf",
"exit",
TransitionKind::External,
),
(
"refresh",
"Refresh record",
"state_a",
"state_a",
TransitionKind::Internal,
),
] {
transition(&mut p, alias, name, source, target, kind)?;
}
for (alias, source, phase) in [
("action_a", "state_a", StateActionKind::Entry),
("action_c", "state_c", StateActionKind::Do),
] {
let old = p.relate(
RelationKind::StateAction,
"Record shared work",
id(source),
id("shared_work"),
);
reidentify(&mut p, old, id(alias))?;
let relation = p.relations.get_mut(&id(alias)).unwrap();
relation.alias = alias.into();
relation.description = format!("Preserve shared-work action {alias}.");
if let RelationBody::StateAction { phase: value, .. } = &mut relation.body {
*value = phase;
}
}
for (alias, name) in [
("state_view", "State multiselect - main"),
("state_other", "Other State view - preserve"),
] {
diagram(&mut p, alias, name, Layer::Oa);
p.diagrams.get_mut(&id(alias)).unwrap().projection = ProjectionKind::StateMachine {
machine: id("machine"),
};
}
diagram(&mut p, "sa_view", "SA engineering - preserve", Layer::Sa);
// The main machine and outer Region are deliberately unplaced to keep
// the target headers readable in a 1442 x 932 native window after Fit.
// A starts at the authored origin so saved A recovers the exact f32 delta;
// subtracting a nonzero original can round it differently from sibling XY.
for (alias, object, x, y, width, height) in [
("node_a", "state_a", 0., 0., 340., 450.),
("node_b", "state_b", 410., 30., 210., 150.),
("node_c", "state_c", 410., 330., 210., 140.),
("node_inner", "inner", 25., 220., 290., 200.),
("node_leaf", "state_leaf", 60., 290., 180., 90.),
("node_entry", "entry", -210., 280., 24., 24.),
("node_exit", "exit", 870., 350., 24., 24.),
] {
place(
&mut p,
"state_view",
alias,
object,
point(x, y),
Size { width, height },
);
}
let size = Size {
width: 224.,
height: 94.,
};
place(
&mut p,
"state_other",
"other_a",
"state_a",
point(40., 60.),
Size {
width: 340.,
height: 220.,
},
);
place(
&mut p,
"state_other",
"other_b",
"state_b",
point(460., 80.),
size,
);
place(
&mut p,
"sa_view",
"sa_node",
"sa_function",
point(100., 110.),
size,
);
p.diagrams.get_mut(&id("state_view")).unwrap().routes = vec![
route("between", vec![point(365., 70.), point(365., 120.)]),
route("external", vec![point(660., 140.), point(660., 360.)]),
route("entry_route", vec![point(40., 280.), point(40., 320.)]),
route("exit_route", vec![point(290., 335.), point(290., 370.)]),
];
p.traces.insert(
id("trace"),
Trace {
id: id("trace"),
alias: "preserved_refinement".into(),
name: "Reviewed recording refinement".into(),
description:
"Moving states or hiding placements must preserve shared work and its refinement."
.into(),
oa: id("shared_work"),
sa: id("sa_function"),
kind: TraceKind::Refinement,
baseline: Some(TraceBaseline {
oa: p.properties(id("shared_work")).unwrap(),
sa: p.properties(id("sa_function")).unwrap(),
semantic: None,
}),
},
);
validate_candidate(&p)?;
Ok(p)
}
fn load(path: &Path) -> Result<Project> {
if path
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("arcadia"))
{
let disk = source_files::load_project(path, ProjectLimits::default())?;
// This deliberately partial engineering fixture has completeness
// warnings (allocation/behavior context). They are not source errors.
// Compilation, candidate validation and whole-Project equality still
// reject malformed source or any unexpected model change.
if disk
.diagnostics
.iter()
.any(|d| d.diagnostic.severity == validation::Severity::Error)
{
return Err(format!("Source discovery diagnostics: {:?}", disk.diagnostics).into());
}
let compiled = compile_bundle(&disk.reachable_bundle(), ProjectLimits::default())
.map_err(|e| format!("Source compile diagnostics: {e:?}"))?;
Ok(compiled.decoded.project)
} else {
Ok(persistence::load(path)?)
}
}
fn equal(expected: &Project, actual: &Project) -> Result<()> {
if expected == actual {
return Ok(());
}
let e = serde_json::to_value(expected)?;
let a = serde_json::to_value(actual)?;
let fields: Vec<_> = e
.as_object()
.unwrap()
.keys()
.filter(|key| e[*key] != a[*key])
.cloned()
.collect();
Err(format!("Whole Project mismatch in {fields:?}; no partial PASS").into())
}
fn roundtrip(p: &Project) -> Result<SourceBundle> {
validate_candidate(p)?;
equal(p, &persistence::decode(&serde_json::to_vec(p)?)?)?;
let bundle = format_bundle(p);
let compiled = compile_bundle(&bundle, ProjectLimits::default())
.map_err(|e| format!("Roundtrip: {e:?}"))?;
equal(p, &compiled.decoded.project)?;
Ok(bundle)
}
fn write_new(path: &Path, bytes: &[u8]) -> Result<()> {
fs::create_dir_all(path.parent().ok_or("Missing parent directory")?)?;
fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(path)?
.write_all(bytes)?;
Ok(())
}
fn generate(out: &Path) -> Result<()> {
if out.exists() {
return Err("Output directory already exists; choose a fresh path".into());
}
let p = fixture()?;
let bundle = roundtrip(&p)?;
write_new(&out.join("baseline.json"), &serde_json::to_vec_pretty(&p)?)?;
for (path, source) in &bundle.files {
write_new(&out.join("source").join(path), source.as_bytes())?;
}
let names = [
"crew",
"machine",
"region",
"inner",
"entry",
"exit",
"shared_work",
"node_inner",
"node_entry",
"node_exit",
"entry_route",
"exit_route",
"refresh",
"action_a",
"action_c",
"other_b",
"project",
"state_view",
"state_other",
"sa_view",
"state_a",
"state_b",
"state_c",
"state_leaf",
"node_a",
"node_b",
"node_c",
"node_leaf",
"between",
"external",
"trace",
"sa_function",
"sa_node",
"other_a",
];
let ids: std::collections::BTreeMap<_, _> =
names.into_iter().map(|name| (name, id(name))).collect();
write_new(
&out.join("manifest.json"),
&serde_json::to_vec_pretty(&serde_json::json!({
"fixture": "state-multiselect-native-20260923", "ids": ids, "source_entry": bundle.entry,
"move_members": ["node_a", "node_b", "node_inner", "node_leaf", "node_entry", "node_exit"],
"translated_routes": ["between", "entry_route", "exit_route"],
"fixed_route": "external",
"window": [1442, 932],
"target_headers_world": {"A": [40, 35], "B": [450, 65]},
"delta_limits": "finite, nonzero length >=0.01, each axis <=300; no grid quantization",
"receipt": "Pin baseline.json, manifest.json and generation command externally before interaction; baseline identity alone does not authenticate evidence", "fixed_member": "node_c",
"evaluation": "Whole Project invariant comparison; saved A supplies bounded shared delta, not an exact pointer oracle",
"hide": "Remove node_a/node_b only; retain nested Region/leaf/entry/exit, stored routes, all model records and other views",
}))?,
)?;
equal(&p, &load(&out.join("baseline.json"))?)?;
equal(&p, &load(&out.join("source/project.arcadia"))?)?;
println!("PASS generated JSON and source bundle: {}", out.display());
Ok(())
}
fn check(mode: &str, baseline: &Path, saved: &Path) -> Result<()> {
let before = load(baseline)?;
let actual = load(saved)?;
validate_candidate(&before)?;
validate_candidate(&actual)?;
if before.id != id("project") {
return Err("Not this fixture's baseline project".into());
}
let mut expected = before.clone();
let mut delta = None;
match mode {
"unchanged" => {}
"hide" => {
let view = expected
.diagrams
.get_mut(&id("state_view"))
.ok_or("Missing State view")?;
for alias in ["node_a", "node_b"] {
view.nodes
.remove(&id(alias))
.ok_or("Baseline selected placement missing")?;
}
// references::remove_objects removes routes only when their semantic
// edge is deleted. Hide deletes placements; both routes MUST survive.
}
"moved" => {
let old = before
.diagrams
.get(&id("state_view"))
.ok_or("Missing baseline State view")?;
let new = actual
.diagrams
.get(&id("state_view"))
.ok_or("Missing saved State view")?;
let a = old
.nodes
.get(&id("node_a"))
.ok_or("Missing baseline A")?
.position;
let moved_a = new
.nodes
.get(&id("node_a"))
.ok_or("Missing saved A")?
.position;
let d = point(moved_a.x - a.x, moved_a.y - a.y);
if !d.x.is_finite()
|| !d.y.is_finite()
|| d.x.abs().max(d.y.abs()) > 300.
|| d.x.hypot(d.y) < 0.01
{
return Err(format!(
"Move delta must have length >=0.01 and each axis <=300 world units: {d:?}"
)
.into());
}
let view = expected.diagrams.get_mut(&id("state_view")).unwrap();
// Independent explicit fixture enumeration: no group/descendant/
// routing planner is called to predict the result.
for alias in [
"node_a",
"node_b",
"node_inner",
"node_leaf",
"node_entry",
"node_exit",
] {
let node = view
.nodes
.get_mut(&id(alias))
.ok_or("Missing expected moved placement")?;
node.position.x += d.x;
node.position.y += d.y;
}
for alias in ["between", "entry_route", "exit_route"] {
let route = view
.routes
.iter_mut()
.find(|route| route.edge == id(alias))
.ok_or("Missing explicitly expected moved route")?;
for pin in &mut route.waypoints {
pin.x += d.x;
pin.y += d.y;
}
}
delta = Some(d);
}
_ => return Err("Mode must be moved, hide, or unchanged".into()),
}
equal(&expected, &actual)?;
roundtrip(&actual)?;
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
"result": "PASS", "mode": mode, "baseline": baseline, "saved": saved,
"comparison": "complete Project equality", "inferred_common_delta": delta,
"limits": "Invariant evaluator only; no pointer/action/focus/Undo/source-byte acceptance claim",
}))?
);
Ok(())
}
fn main() -> Result<()> {
let args: Vec<_> = std::env::args().skip(1).collect();
match args.as_slice() {
[command, out] if command == "generate" => generate(Path::new(out)),
[command, mode, baseline, saved] if command == "check" => check(mode, Path::new(baseline), Path::new(saved)),
_ => Err("Usage: state_multiselect_native generate DIRECTORY | check moved|hide|unchanged BASELINE SAVED".into()),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::BTreeSet;
// Produce evidence through real validated commands; the checker itself uses
// explicit fixture enumeration and never calls this movement implementation.
fn moved(before: &Project, delta: Position) -> Project {
let command = diagram_edit::translate_placements(
before,
id("state_view"),
&BTreeSet::from([id("node_a"), id("node_b")]),
delta,
)
.unwrap();
let mut session = Session::new(before.clone());
session.execute(command).unwrap();
session.project().clone()
}
fn hidden(before: &Project) -> Project {
let mut session = Session::new(before.clone());
session
.execute(Command::Batch(vec![
Command::Delete(id("node_a")),
Command::Delete(id("node_b")),
]))
.unwrap();
session.project().clone()
}
fn through_saved_json(mode: &str, baseline: &Project, actual: &Project) -> Result<()> {
let dir = tempfile::tempdir()?;
let before = dir.path().join("before.json");
let saved = dir.path().join("saved.json");
// These assertions distinguish meaningful invariant corruptions from
// malformed projects rejected before the evaluator even sees them.
validate_candidate(baseline).expect("test baseline must be valid");
validate_candidate(actual).expect("corruption must remain a valid model");
persistence::save(&before, baseline)?;
persistence::save(&saved, actual)?;
check(mode, &before, &saved)
}
fn mismatch(mode: &str, before: &Project, actual: &Project, case: &str) {
let error = through_saved_json(mode, before, actual)
.expect_err(case)
.to_string();
assert!(
error.contains("Whole Project mismatch"),
"{case}: unexpected rejection: {error}"
);
}
fn edge<'a>(p: &'a mut Project, alias: &str) -> &'a mut EdgeRoute {
p.diagrams
.get_mut(&id("state_view"))
.unwrap()
.routes
.iter_mut()
.find(|route| route.edge == id(alias))
.unwrap()
}
#[test]
fn generated_json_and_real_source_bundle_are_accepted_unchanged() {
let dir = tempfile::tempdir().unwrap();
let out = dir.path().join("fixture");
generate(&out).unwrap();
let baseline = out.join("baseline.json");
let saved = out.join("saved.json");
persistence::save(&saved, &fixture().unwrap()).unwrap();
check("unchanged", &baseline, &saved).unwrap();
check("unchanged", &baseline, &out.join("source/project.arcadia")).unwrap();
let bytes = fs::read(&baseline).unwrap();
assert!(
generate(&out).is_err(),
"generation cannot overwrite evidence"
);
assert_eq!(fs::read(&baseline).unwrap(), bytes);
let invalid = dir.path().join("invalid.arcadia");
fs::write(&invalid, "project {").unwrap();
assert!(load(&invalid).is_err(), "source errors must still reject");
}
#[test]
fn real_group_command_outputs_pass_and_are_not_unchanged() {
let before = fixture().unwrap();
for delta in [
point(7.25, -2.75),
point(-20.5, 30.125),
point(0.25, 0.),
point(0.01, 10.),
point(0.1, -0.2),
] {
let actual = moved(&before, delta);
through_saved_json("moved", &before, &actual).unwrap();
mismatch(
"unchanged",
&before,
&actual,
"real movement cannot count as unchanged",
);
}
}
#[test]
fn real_hide_command_outputs_pass_before_and_after_movement() {
let initial = fixture().unwrap();
for before in [initial.clone(), moved(&initial, point(40., 60.))] {
let actual = hidden(&before);
through_saved_json("hide", &before, &actual).unwrap();
mismatch(
"unchanged",
&before,
&actual,
"real Hide cannot count as unchanged",
);
}
}
#[test]
fn omitted_and_double_moved_child_are_rejected() {
let before = fixture().unwrap();
let delta = point(40., 60.);
let correct = moved(&before, delta);
for alias in ["node_inner", "node_leaf", "node_entry", "node_exit"] {
let initial = before.diagrams[&id("state_view")].nodes[&id(alias)].position;
for position in [
initial,
point(initial.x + 2. * delta.x, initial.y + 2. * delta.y),
] {
let mut bad = correct.clone();
bad.diagrams
.get_mut(&id("state_view"))
.unwrap()
.nodes
.get_mut(&id(alias))
.unwrap()
.position = position;
mismatch("moved", &before, &bad, "child did not move exactly once");
}
}
}
#[test]
fn internal_pins_left_behind_and_external_pins_moved_are_rejected() {
let mut before = fixture().unwrap();
let correct = moved(&before, point(40., 60.));
for alias in ["between", "entry_route", "exit_route"] {
let original_pins = edge(&mut before, alias).waypoints.clone();
let mut bad = correct.clone();
edge(&mut bad, alias).waypoints = original_pins;
mismatch("moved", &before, &bad, "internal pins left behind");
}
let mut bad = correct;
for pin in &mut edge(&mut bad, "external").waypoints {
pin.x += 40.;
pin.y += 60.;
}
mismatch("moved", &before, &bad, "one-ended pins moved");
}
#[test]
fn manual_attachments_and_label_data_must_remain_exact() {
let before = fixture().unwrap();
let correct = moved(&before, point(40., 60.));
let mutations: [fn(&mut EdgeRoute); 4] = [
|r| r.source_attachment.as_mut().unwrap().offset = 0.65,
|r| r.target_attachment.as_mut().unwrap().side = PortAnchorSide::Top,
|r| r.label.as_mut().unwrap().offset.x += 40.,
|r| r.label.as_mut().unwrap().fraction = 0.75,
];
for mutate in mutations {
let mut bad = correct.clone();
mutate(edge(&mut bad, "between"));
mismatch("moved", &before, &bad, "relative route metadata changed");
}
}
#[test]
fn semantic_other_view_fixed_node_and_trace_drift_are_rejected() {
let before = fixture().unwrap();
let correct = moved(&before, point(40., 60.));
let mutations: [fn(&mut Project); 9] = [
|p| p.traces.get_mut(&id("trace")).unwrap().baseline = None,
|p| {
p.elements
.get_mut(&id("shared_work"))
.unwrap()
.description
.push_str(" drift")
},
|p| {
if let RelationBody::StateAction { phase, .. } =
&mut p.relations.get_mut(&id("action_a")).unwrap().body
{
*phase = StateActionKind::Exit;
}
},
|p| {
if let RelationBody::StateTransition { guard, .. } =
&mut p.relations.get_mut(&id("refresh")).unwrap().body
{
*guard = "changed_refresh_guard".into();
}
},
|p| {
p.elements
.get_mut(&id("state_a"))
.unwrap()
.description
.push_str(" drift")
},
|p| {
p.diagrams
.get_mut(&id("state_other"))
.unwrap()
.nodes
.get_mut(&id("other_a"))
.unwrap()
.position
.x += 40.
},
|p| {
p.diagrams
.get_mut(&id("sa_view"))
.unwrap()
.nodes
.get_mut(&id("sa_node"))
.unwrap()
.position
.y += 60.
},
|p| {
p.diagrams
.get_mut(&id("state_view"))
.unwrap()
.nodes
.get_mut(&id("node_c"))
.unwrap()
.position
.x += 40.
},
|p| {
p.traces
.get_mut(&id("trace"))
.unwrap()
.description
.push_str(" drift")
},
];
for mutate in mutations {
let mut bad = correct.clone();
mutate(&mut bad);
mismatch(
"moved",
&before,
&bad,
"record outside permitted movement changed",
);
}
}
#[test]
fn zero_and_excessive_moves_cannot_satisfy_move_evidence() {
let before = fixture().unwrap();
for (delta, reason) in [
(point(0., 0.), "length >=0.01"),
(point(310., 0.), "each axis <=300"),
] {
let actual = moved(&before, delta);
let error = through_saved_json("moved", &before, &actual)
.unwrap_err()
.to_string();
assert!(error.contains(reason), "{delta:?}: {error}");
}
}
#[test]
fn hide_must_preserve_child_routes_and_semantic_objects() {
let before = fixture().unwrap();
let correct = hidden(&before);
let mut missing_child = correct.clone();
missing_child
.diagrams
.get_mut(&id("state_view"))
.unwrap()
.nodes
.remove(&id("node_leaf"));
mismatch(
"hide",
&before,
&missing_child,
"Hide removed unselected child placement",
);
let mut missing_routes = correct;
missing_routes
.diagrams
.get_mut(&id("state_view"))
.unwrap()
.routes
.clear();
mismatch(
"hide",
&before,
&missing_routes,
"Hide removed persistent route records",
);
let mut semantic_delete = Session::new(before.clone());
semantic_delete
.execute(Command::Batch(vec![
Command::Delete(id("state_a")),
Command::Delete(id("state_b")),
]))
.unwrap();
mismatch(
"hide",
&before,
semantic_delete.project(),
"Hide became semantic deletion",
);
}
#[test]
fn unchanged_rejects_valid_semantic_drift_without_geometry_changes() {
let before = fixture().unwrap();
let mut actual = before.clone();
actual
.elements
.get_mut(&id("state_b"))
.unwrap()
.name
.push_str(" edited");
mismatch(
"unchanged",
&before,
&actual,
"geometry equality is not complete Project equality",
);
}
#[test]
fn moved_and_hidden_source_inputs_are_checked_against_complete_expected_projects() {
let before = fixture().unwrap();
for (mode, actual) in [
("moved", moved(&before, point(7.25, -2.75))),
("hide", hidden(&before)),
] {
let dir = tempfile::tempdir().unwrap();
let baseline = dir.path().join("baseline.json");
persistence::save(&baseline, &before).unwrap();
let bundle = roundtrip(&actual).unwrap();
for (path, source) in &bundle.files {
write_new(&dir.path().join("saved").join(path), source.as_bytes()).unwrap();
}
check(
mode,
&baseline,
&dir.path().join("saved").join(&bundle.entry),
)
.unwrap();
let mut corrupt = actual.clone();
corrupt.elements.get_mut(&id("shared_work")).unwrap().name =
"Unrequested source edit".into();
let bundle = roundtrip(&corrupt).unwrap();
for (path, source) in &bundle.files {
write_new(&dir.path().join("corrupt").join(path), source.as_bytes()).unwrap();
}
let error = check(
mode,
&baseline,
&dir.path().join("corrupt").join(&bundle.entry),
)
.unwrap_err()
.to_string();
assert!(error.contains("Whole Project mismatch"), "{error}");
}
}
}
+1
View File
@@ -81,6 +81,7 @@ fn diagram(
projection,
view_options: Default::default(),
nodes: BTreeMap::new(),
layout: None,
routes: vec![],
},
);
+91
View File
@@ -0,0 +1,91 @@
//! Pure diagram edits shared by movement previews and committed transactions.
use crate::{Command, Id, MAX_COORDINATE, ModelError, Position, Project, projection};
use std::collections::{BTreeMap, BTreeSet};
/// Translate selected placements and their projected descendants simultaneously.
///
/// Membership and geometry are read from one snapshot, including folded children.
/// Overlapping roots move each placement once. Route pins follow the combined
/// movement of their actual endpoint owners (and State boundaries). Semantic
/// ownership and other views are untouched. Apply the returned batch through the
/// normal validated command path; its selected identity is not a primary selection.
pub fn translate_placements(
project: &Project,
diagram: Id,
selected: &BTreeSet<Id>,
delta: Position,
) -> Result<Command, ModelError> {
let view = project
.diagrams
.get(&diagram)
.ok_or_else(|| ModelError("Movement requires an existing diagram.".into()))?;
if selected.is_empty() {
return Err(ModelError(
"Movement requires at least one placement.".into(),
));
}
if selected.iter().any(|id| !view.nodes.contains_key(id)) {
return Err(ModelError(
"Every selected placement must belong to the diagram.".into(),
));
}
if !delta.x.is_finite() || !delta.y.is_finite() {
return Err(ModelError("Movement displacement must be finite.".into()));
}
if delta.x == 0. && delta.y == 0. {
return Ok(Command::Batch(vec![]));
}
let mut children: BTreeMap<Id, Vec<Id>> = BTreeMap::new();
for (child, parent) in projection::parents(project, view) {
if let Some(parent) = parent {
children.entry(parent).or_default().push(child);
}
}
let mut moved = BTreeSet::new();
let mut pending: Vec<_> = selected.iter().copied().collect();
while let Some(id) = pending.pop() {
if moved.insert(id) {
pending.extend(children.get(&id).into_iter().flatten().copied());
}
}
let deltas: BTreeMap<_, _> = moved.iter().map(|id| (*id, delta)).collect();
let routes = projection::translated_routes(project, view, &deltas);
let bounded = |value: f32| value.is_finite() && value.abs() <= MAX_COORDINATE;
let mut commands = Vec::with_capacity(moved.len() + routes.len());
for id in moved {
let mut node = view.nodes[&id].clone();
node.position.x += delta.x;
node.position.y += delta.y;
if ![
node.position.x,
node.position.y,
node.size.width,
node.size.height,
]
.into_iter()
.all(bounded)
|| node.size.width <= 0.
|| node.size.height <= 0.
{
return Err(ModelError(
"Movement produces invalid placement bounds.".into(),
));
}
commands.push(Command::EditNode {
diagram,
original: id,
node,
});
}
for route in routes {
if route
.waypoints
.iter()
.any(|point| !bounded(point.x) || !bounded(point.y))
{
return Err(ModelError("Movement produces invalid route bounds.".into()));
}
commands.push(Command::SetEdgeRoute { diagram, route });
}
Ok(Command::Batch(commands))
}
+109 -21
View File
@@ -3,6 +3,8 @@
mod semantic_source;
#[path = "migration_v2/source.rs"]
mod v2_source;
pub(crate) use crate::source_defaults::Mode as DeclarationFormat;
use crate::source_defaults::{self, Context as DefaultContext};
use crate::*;
use serde_json::{Map, Value};
use std::{collections::BTreeMap, fmt};
@@ -338,6 +340,18 @@ pub(crate) fn diagnostic(source: &str, error: Error) -> Diagnostic {
},
}
}
pub(crate) fn diagnostic_with_issue(
source: &str,
error: Error,
issue: Option<validation::Issue>,
) -> Diagnostic {
let mut result = diagnostic(source, error);
if let Some(issue) = issue {
result.object_id = issue.element;
result.field = issue.field;
}
result
}
fn reserved(word: &str) -> bool {
crate::reserved_alias(word)
|| declaration_kind(word).is_some()
@@ -620,6 +634,7 @@ fn parse_document(source: &str, limits: ParseLimits) -> Result<ParsedDocument, V
if tokens.get(1).is_some_and(|t| t.text(source) == "2") {
return v2_source::parse(source, tokens, limits);
}
let mut migration_issue = None;
Parser {
source,
tokens,
@@ -627,8 +642,8 @@ fn parse_document(source: &str, limits: ParseLimits) -> Result<ParsedDocument, V
value_depth: 0,
limits,
}
.document()
.map_err(|e| vec![diagnostic(source, e)])
.document(&mut migration_issue)
.map_err(|e| vec![diagnostic_with_issue(source, e, migration_issue)])
}
fn offset_for(source: &str, line: usize, column: usize) -> usize {
let mut offset = 0;
@@ -645,25 +660,34 @@ fn offset_for(source: &str, line: usize, column: usize) -> usize {
source.len()
}
/// Canonical output uses persistent aliases and explicit identities. Typed
/// nested records are ordinary language fields; JSON documents are not embedded.
/// Canonical output uses persistent aliases and explicit identities, omitting
/// only values supplied by source defaults. Typed nested records are ordinary
/// language fields; JSON documents are not embedded.
pub fn format(project: &Project) -> String {
let mut out = std::format!(
"arcadia {SCHEMA_VERSION};\nproject {} {} id \"{}\" {{\n description {};\n\n",
"arcadia {SCHEMA_VERSION};\nproject {} {} id \"{}\" {{\n",
project.alias,
quote(&project.name),
project.id,
quote(&project.description)
);
for (_, declaration) in format_declarations(project) {
if !project.description.is_empty() {
out.push_str(&std::format!(
" description {};\n",
quote(&project.description)
));
}
out.push('\n');
for (_, declaration) in format_declarations(project, DeclarationFormat::Compact) {
out.push_str(&declaration);
}
out.push_str("}\n");
out
}
/// The same typed declaration writer is used by flat and modular source output.
pub(crate) fn format_declarations(project: &Project) -> Vec<(Id, String)> {
/// Shared typed writer for canonical output and lossless editing. Complete edit
/// comparisons retain defaults; emitted declarations and value fragments apply
/// only the omission rules valid in their selected source context.
pub(crate) fn format_declarations(project: &Project, mode: DeclarationFormat) -> Vec<(Id, String)> {
let aliases = project
.identities()
.into_iter()
@@ -675,6 +699,7 @@ pub(crate) fn format_declarations(project: &Project) -> Vec<(Id, String)> {
let mut fields = payload(&serde_json::to_value(&e.body).unwrap());
fields.insert("description".into(), Value::String(e.description.clone()));
fields.insert("parent".into(), serde_json::to_value(e.parent).unwrap());
let fields = source_defaults::fields(DefaultContext::Element(e.kind()), &fields, mode);
write_declaration(
&mut out,
&element_word(e.kind(), e.layer()),
@@ -690,6 +715,7 @@ pub(crate) fn format_declarations(project: &Project) -> Vec<(Id, String)> {
let mut out = String::new();
let mut fields = payload(&serde_json::to_value(&r.body).unwrap());
fields.insert("description".into(), Value::String(r.description.clone()));
let fields = source_defaults::fields(DefaultContext::Relation(r.kind()), &fields, mode);
write_declaration(
&mut out,
&relation_word(r.kind()),
@@ -705,6 +731,7 @@ pub(crate) fn format_declarations(project: &Project) -> Vec<(Id, String)> {
let mut out = String::new();
let mut fields = record_fields(d);
fields.remove("nodes");
let fields = source_defaults::fields(DefaultContext::Diagram, &fields, mode);
write_declaration(
&mut out,
"diagram",
@@ -719,6 +746,7 @@ pub(crate) fn format_declarations(project: &Project) -> Vec<(Id, String)> {
let mut out = String::new();
let mut fields = record_fields(n);
fields.insert("diagram".into(), Value::String(d.id.to_string()));
let fields = source_defaults::fields(DefaultContext::Node, &fields, mode);
write_declaration(&mut out, "node", &n.alias, None, n.id, &fields, &aliases);
declarations.push((n.id, out));
}
@@ -731,7 +759,7 @@ pub(crate) fn format_declarations(project: &Project) -> Vec<(Id, String)> {
&a.alias,
Some(&a.name),
a.id,
&record_fields(a),
&source_defaults::fields(DefaultContext::Artifact, &record_fields(a), mode),
&aliases,
);
declarations.push((a.id, out));
@@ -744,7 +772,7 @@ pub(crate) fn format_declarations(project: &Project) -> Vec<(Id, String)> {
&t.alias,
Some(&t.name),
t.id,
&record_fields(t),
&source_defaults::fields(DefaultContext::Trace, &record_fields(t), mode),
&aliases,
);
declarations.push((t.id, out));
@@ -1434,7 +1462,10 @@ impl Parser<'_> {
},
}
}
fn document(&mut self) -> Result<ParsedDocument, Error> {
fn document(
&mut self,
migration_issue: &mut Option<validation::Issue>,
) -> Result<ParsedDocument, Error> {
let unit = self.unit(true, false)?;
let header = unit.header.expect("entry header");
let mut aliases = BTreeMap::from([(header.project.alias.clone(), header.project.id)]);
@@ -1461,9 +1492,14 @@ impl Parser<'_> {
.map(|(key, span)| ((d.id, key.clone()), span.start)),
);
}
let decoded =
resolve_declarations(header.project, &unit.declarations, &aliases, self.limits)
.map_err(|(_, error)| error)?;
let decoded = resolve_declarations(
header.project,
&unit.declarations,
&aliases,
self.limits,
migration_issue,
)
.map_err(|(_, error)| error)?;
Ok(ParsedDocument {
project: decoded.project,
migration: decoded.migration,
@@ -1667,6 +1703,7 @@ pub(crate) fn resolve_declarations(
declarations: &[Declaration],
aliases: &BTreeMap<String, Id>,
limits: ParseLimits,
migration_issue: &mut Option<validation::Issue>,
) -> Result<crate::persistence::DecodeResult, (usize, Error)> {
let legacy = project.schema_version == 3;
let mut legacy_elements = serde_json::Map::new();
@@ -1742,7 +1779,9 @@ pub(crate) fn resolve_declarations(
*viewpoint = ScenarioViewpoint::MixedLegacy;
}
let mut body = serde_json::to_value(initial).unwrap();
let defaults = if legacy {
let defaults = if project.schema_version >= 7 {
source_defaults::parser_fields(DefaultContext::Element(kind))
} else if legacy {
crate::persistence::v3::legacy_defaults(kind)
.and_then(|value| value.as_object().cloned())
.unwrap_or_else(|| payload(&body))
@@ -1799,7 +1838,12 @@ pub(crate) fn resolve_declarations(
let mut body =
serde_json::to_value(RelationBody::new(kind, Id::nil(), Id::nil()))
.unwrap();
let merged = merge_fields(payload(&body), fields).map_err(&conversion_error)?;
let defaults = if project.schema_version >= 7 {
source_defaults::parser_fields(DefaultContext::Relation(kind))
} else {
payload(&body)
};
let merged = merge_fields(defaults, fields).map_err(&conversion_error)?;
replace_payload(&mut body, merged);
let body = serde_json::from_value(body)
.map_err(|e| conversion_error(e.to_string()))?;
@@ -1824,7 +1868,10 @@ pub(crate) fn resolve_declarations(
}
let defaults =
serde_json::json!({"description":"","layer":"Oa","projection":"Overview","routes":[],"view_options":crate::view_options::ViewOptions::default()});
let mut record = merge_fields(defaults.as_object().unwrap().clone(), fields)
let defaults = if project.schema_version >= 7 {
source_defaults::parser_fields(DefaultContext::Diagram)
} else { defaults.as_object().unwrap().clone() };
let mut record = merge_fields(defaults, fields)
.map_err(&conversion_error)?;
header(&mut record, d, true);
record.insert("nodes".into(), serde_json::json!({}));
@@ -1839,7 +1886,10 @@ pub(crate) fn resolve_declarations(
return Err(conversion_error("Missing required field 'element'.".into()));
}
let defaults = serde_json::json!({"element":null,"position":{"x":0.0,"y":0.0},"size":{"width":224.0,"height":94.0},"collapsed":false,"port_anchors":[]});
let mut record = merge_fields(defaults.as_object().unwrap().clone(), fields)
let defaults = if project.schema_version >= 7 {
source_defaults::parser_fields(DefaultContext::Node)
} else { defaults.as_object().unwrap().clone() };
let mut record = merge_fields(defaults, fields)
.map_err(&conversion_error)?;
header(&mut record, d, false);
let node: NodePlacement = serde_json::from_value(Value::Object(record))
@@ -1851,7 +1901,10 @@ pub(crate) fn resolve_declarations(
return Err(conversion_error("Missing required field 'owner'.".into()));
}
let defaults = serde_json::json!({"description":"","owner":null,"relation":"References","uri":"","revision":null,"fragment":null});
let mut record = merge_fields(defaults.as_object().unwrap().clone(), fields)
let defaults = if project.schema_version >= 7 {
source_defaults::parser_fields(DefaultContext::Artifact)
} else { defaults.as_object().unwrap().clone() };
let mut record = merge_fields(defaults, fields)
.map_err(&conversion_error)?;
header(&mut record, d, true);
let artifact = serde_json::from_value(Value::Object(record))
@@ -1867,7 +1920,10 @@ pub(crate) fn resolve_declarations(
}
}
let defaults = serde_json::json!({"description":"","oa":null,"sa":null,"kind":"Refinement","baseline":null});
let mut record = merge_fields(defaults.as_object().unwrap().clone(), fields)
let defaults = if project.schema_version >= 7 {
source_defaults::parser_fields(DefaultContext::Trace)
} else { defaults.as_object().unwrap().clone() };
let mut record = merge_fields(defaults, fields)
.map_err(&conversion_error)?;
header(&mut record, d, true);
let trace = if project.schema_version < 5 {
@@ -1955,6 +2011,38 @@ pub(crate) fn resolve_declarations(
crate::persistence::migrate_v6(project, limits)
})
.map_err(|error| {
// These frozen admission checks formerly ran in final validation.
// Keep their diagnostic code and reference location after moving
// them before schema normalization.
if let crate::persistence::FileError::Model { issues, .. } = &error
&& let Some(issue) = issues.first()
&& let Some(code) = match issue.code.as_str() {
"diagram_layer" => Some("diagram_layer"),
"view_options" => Some("view_options"),
_ => None,
}
{
let index = issue
.element
.and_then(|id| declarations.iter().position(|d| d.id == id))
.unwrap_or(usize::MAX);
let offset = declarations.get(index).map_or(0, |d| {
issue
.field
.as_ref()
.and_then(|field| d.field_spans.get(field))
.map_or(d.offset, |span| span.start)
});
*migration_issue = Some(issue.clone());
return (
index,
Error {
code,
offset,
message: issue.message.clone(),
},
);
}
(
usize::MAX,
Error {
+6
View File
@@ -1,6 +1,7 @@
//! Framework-independent OA/SA model, transactional commands and editing services.
pub mod analysis_numeric;
pub mod data;
pub mod diagram_edit;
pub mod language;
pub mod model;
pub mod operations;
@@ -9,8 +10,10 @@ pub mod ports;
pub mod projection;
pub mod references;
pub mod routing;
pub mod scenario_layout;
pub mod semantic;
pub mod session;
mod source_defaults;
pub mod source_files;
pub mod source_project;
pub mod topology_context;
@@ -19,6 +22,9 @@ pub mod validation;
pub mod view_options;
pub use model::*;
pub use ports::*;
pub use scenario_layout::{
DiagramLayout, ScenarioLanePosition, ScenarioLayout, ScenarioMessagePosition,
};
pub use semantic::*;
pub use session::*;
mod demo;
+9 -3
View File
@@ -69,8 +69,8 @@ impl Project4 {
}
crate::persistence::check_pre6_relations(self.relations.values())?;
crate::persistence::check_pre6_elements(self.elements.values())?;
Ok(Project {
schema_version: SCHEMA_VERSION,
let mut project = Project {
schema_version: self.schema_version,
id: self.id,
alias: self.alias,
name: self.name,
@@ -84,7 +84,11 @@ impl Project4 {
.into_iter()
.map(|(id, trace)| (id, trace.into()))
.collect(),
})
};
// Schema3's adapter also enters here, without calling `finish`.
crate::persistence::check_pre7_diagram_admission(&project)?;
project.schema_version = SCHEMA_VERSION;
Ok(project)
}
}
@@ -104,6 +108,8 @@ pub(crate) fn review_defaults(project: &Project, report: &mut MigrationReport) {
}
pub(crate) fn finish(mut project: Project, limits: ParseLimits) -> Result<DecodeResult, FileError> {
// Source4 enters here directly rather than through `Project4`.
crate::persistence::check_pre7_diagram_admission(&project)?;
crate::persistence::check_pre6_relations(project.relations.values())?;
crate::persistence::check_pre6_elements(project.elements.values())?;
project.schema_version = SCHEMA_VERSION;
+61
View File
@@ -1436,6 +1436,7 @@ pub enum ProjectionKind {
FunctionalDataflow,
Capabilities,
StateMachine { machine: Id },
Scenario { scenario: Id },
}
impl ProjectionKind {
pub fn state_machine(self) -> Option<Id> {
@@ -1445,8 +1446,30 @@ impl ProjectionKind {
}
}
/// The semantic owner of a saved Scenario representation.
pub fn scenario(self) -> Option<Id> {
match self {
Self::Scenario { scenario } => Some(scenario),
_ => None,
}
}
/// Explicitly classify architecture views; absence of a state machine is insufficient.
pub fn is_architecture(self) -> bool {
match self {
Self::Overview
| Self::Decomposition
| Self::Allocation
| Self::Traceability
| Self::FunctionalDataflow
| Self::Capabilities => true,
Self::StateMachine { .. } | Self::Scenario { .. } => false,
}
}
pub fn includes(self, kind: Kind) -> bool {
match self {
Self::Scenario { .. } => false,
Self::StateMachine { .. } => {
matches!(kind, Kind::StateMachine | Kind::Region) || kind.is_state()
}
@@ -1471,6 +1494,7 @@ impl std::fmt::Display for ProjectionKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
Self::StateMachine { .. } => "State machine",
Self::Scenario { .. } => "Scenario",
Self::Allocation => "Architecture · allocated work",
Self::FunctionalDataflow => "Functional dataflow",
Self::Capabilities => "Missions & capabilities",
@@ -1495,6 +1519,8 @@ pub struct Diagram {
pub nodes: BTreeMap<Id, NodePlacement>,
#[serde(default)]
pub routes: Vec<EdgeRoute>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub layout: Option<crate::scenario_layout::DiagramLayout>,
}
/// Authored presentation for one existing relation or trace in one diagram.
/// Computed endpoints and automatic routing elbows are never persisted here.
@@ -1502,11 +1528,45 @@ pub struct Diagram {
#[serde(deny_unknown_fields)]
pub struct EdgeRoute {
pub edge: Id,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source_attachment: Option<EdgeAttachment>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub target_attachment: Option<EdgeAttachment>,
#[serde(default)]
pub waypoints: Vec<Position>,
#[serde(default)]
pub label: Option<EdgeLabel>,
}
/// Presentation preference on one end of a connection. Real ports take precedence.
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct EdgeAttachment {
pub side: PortAnchorSide,
pub offset: f32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EdgeEnd {
Source,
Target,
}
impl EdgeRoute {
pub fn attachment(&self, end: EdgeEnd) -> Option<EdgeAttachment> {
match end {
EdgeEnd::Source => self.source_attachment,
EdgeEnd::Target => self.target_attachment,
}
}
pub fn set_attachment(&mut self, end: EdgeEnd, attachment: Option<EdgeAttachment>) {
match end {
EdgeEnd::Source => self.source_attachment = attachment,
EdgeEnd::Target => self.target_attachment = attachment,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct EdgeLabel {
@@ -1921,6 +1981,7 @@ impl Project {
view_options: crate::view_options::ViewOptions::default(),
nodes: BTreeMap::new(),
routes: vec![],
layout: None,
},
);
id
+46
View File
@@ -341,6 +341,50 @@ pub(crate) fn check_pre6_baseline(baseline: &SemanticBaseline) -> Result<(), Fil
}
Ok(())
}
/// Frozen sources 3–6 admit opposite-layer placements and visibility references
/// only in Traceability. Check before migration discards the advertised version;
/// current ordinary projections may retain these records as latent presentation.
pub(crate) fn check_pre7_diagram_admission(project: &Project) -> Result<(), FileError> {
let mut issues = Vec::new();
for diagram in project.diagrams.values() {
let option_issues = if diagram.projection == ProjectionKind::Traceability {
crate::view_options::validate_options_cross_layer(project, &diagram.view_options)
} else {
crate::view_options::validate_options(project, diagram.layer, &diagram.view_options)
};
for issue in option_issues {
validation::error(
&mut issues,
diagram.id,
"view_options",
issue.field,
issue.message,
);
}
for node in diagram.nodes.values() {
if !project.elements.get(&node.element).is_some_and(|element| {
diagram.projection == ProjectionKind::Traceability
|| element.layer() == diagram.layer
}) {
validation::error(
&mut issues,
node.id,
"diagram_layer",
"element",
"Diagram nodes reference existing elements in the selected layer.",
);
}
}
}
if let Some(issue) = issues.into_iter().next() {
return Err(FileError::Model {
message: issue.message.clone(),
issues: vec![issue],
});
}
Ok(())
}
pub(crate) fn migrate_v5(
mut project: Project,
limits: crate::language::ParseLimits,
@@ -348,6 +392,7 @@ pub(crate) fn migrate_v5(
if project.schema_version != 5 {
return Err(FileError::Invalid("Expected schema_version 5.".into()));
}
check_pre7_diagram_admission(&project)?;
check_pre6_relations(project.relations.values())?;
check_pre6_elements(project.elements.values())?;
for baseline in project
@@ -420,6 +465,7 @@ pub(crate) fn migrate_v6(
if project.schema_version != 6 {
return Err(FileError::Invalid("Expected schema_version 6.".into()));
}
check_pre7_diagram_admission(&project)?;
reject_source7_fields(&serde_json::to_value(&project)?).map_err(FileError::Invalid)?;
limits.check_model(&project).map_err(FileError::Limit)?;
project.schema_version = SCHEMA_VERSION;
+7
View File
@@ -85,6 +85,7 @@ pub(crate) fn parents(p: &Project, d: &Diagram) -> BTreeMap<Id, Option<Id>> {
.filter(|n| {
p.elements.get(&n.element).is_some_and(|e| {
e.kind() != Kind::Port
&& (d.projection == ProjectionKind::Traceability || e.layer() == d.layer)
&& d.projection.includes(e.kind())
&& d.projection
.state_machine()
@@ -278,6 +279,11 @@ fn project_diagram_impl(
mut availability: Option<&mut BTreeMap<Id, crate::view_options::EdgeAvailability>>,
index: Option<&crate::view_options::ViewIndex>,
) -> ProjectedView {
// Saved Scenario layout is not an architecture/state projection. Its dedicated
// renderer is not wired yet; never interpret its payload through this fallback.
if d.projection.scenario().is_some() {
return ProjectedView::default();
}
let parents = parents(p, d);
let mut nodes = Vec::new();
for n in d.nodes.values() {
@@ -394,6 +400,7 @@ fn project_diagram_impl(
continue;
}
let included = match d.projection {
ProjectionKind::Scenario { .. } => false,
ProjectionKind::StateMachine { machine } => matches!(&r.body,
RelationBody::StateTransition { transition_kind, .. }
if *transition_kind != TransitionKind::Internal
+84 -23
View File
@@ -203,7 +203,81 @@ pub fn visit_relation_refs(r: &mut Relation, mut visit: impl FnMut(&mut Id)) {
_ => {}
}
}
pub fn remap_references(p: &mut Project, old: Id, new: Id) {
/// Every semantic/presentation reference in a diagram, excluding its own identities.
pub fn diagram_references(d: &Diagram) -> Vec<Id> {
let mut copy = d.clone();
let mut refs = Vec::new();
visit_diagram_refs(&mut copy, |id| refs.push(*id));
refs
}
pub fn visit_diagram_refs(d: &mut Diagram, mut visit: impl FnMut(&mut Id)) {
d.view_options.visit_references(&mut visit);
match &mut d.projection {
ProjectionKind::StateMachine { machine } => visit(machine),
ProjectionKind::Scenario { scenario } => visit(scenario),
_ => {}
}
if let Some(layout) = &mut d.layout {
layout.visit_references(&mut visit);
}
for route in &mut d.routes {
visit(&mut route.edge);
}
for node in d.nodes.values_mut() {
for anchor in &mut node.port_anchors {
visit(&mut anchor.port);
}
visit(&mut node.element);
}
}
/// Preflight reference-key collisions before the caller changes object identities.
/// Fresh targets are allowed. This is not whole-project admission: unrelated
/// invalid records remain repairable within a transaction. Future typed reference
/// domains must compose their own preflight here before any remap mutation.
pub fn check_remap(p: &Project, old: Id, new: Id) -> Result<(), ModelError> {
if old == new {
return Ok(());
}
let remap = |id: Id| if id == old { new } else { id };
for diagram in p.diagrams.values() {
let Some(DiagramLayout::Scenario(layout)) = &diagram.layout else {
continue;
};
let mut lanes = std::collections::BTreeMap::new();
for lane in &layout.lanes {
let affected = lane.lifeline == old;
if let Some(previous_affected) = lanes.insert(remap(lane.lifeline), affected)
&& (affected || previous_affected)
{
return Err(ModelError(
"Identity remap would collide with a Scenario lane position.".into(),
));
}
}
let mut messages = std::collections::BTreeMap::new();
for message in &layout.messages {
let mut address = message.address.clone();
let affected = address.occurrence == old || address.references.contains(&old);
address.occurrence = remap(address.occurrence);
for reference in &mut address.references {
*reference = remap(*reference);
}
if let Some(previous_affected) = messages.insert(address, affected)
&& (affected || previous_affected)
{
return Err(ModelError(
"Identity remap would collide with a Scenario message position.".into(),
));
}
}
}
Ok(())
}
pub fn remap_references(p: &mut Project, old: Id, new: Id) -> Result<(), ModelError> {
check_remap(p, old, new)?;
for e in p.elements.values_mut() {
visit_element_refs(e, |id| {
if *id == old {
@@ -219,31 +293,11 @@ pub fn remap_references(p: &mut Project, old: Id, new: Id) {
});
}
for d in p.diagrams.values_mut() {
d.view_options.visit_references(|id| {
visit_diagram_refs(d, |id| {
if *id == old {
*id = new;
}
});
if let ProjectionKind::StateMachine { machine } = &mut d.projection
&& *machine == old
{
*machine = new;
}
for route in &mut d.routes {
if route.edge == old {
route.edge = new;
}
}
for n in d.nodes.values_mut() {
for anchor in &mut n.port_anchors {
if anchor.port == old {
anchor.port = new;
}
}
if n.element == old {
n.element = new;
}
}
}
for a in p.artifact_links.values_mut() {
if a.owner == old {
@@ -265,6 +319,7 @@ pub fn remap_references(p: &mut Project, old: Id, new: Id) {
});
}
}
Ok(())
}
/// IDs removed by a cascade. Cross-layer traced counterparts are never included.
pub fn deletion_impact(p: &Project, id: Id) -> BTreeSet<Id> {
@@ -339,7 +394,8 @@ fn deletion_impact_inner(p: &Project, id: Id, defer_exchange_allocations: bool)
for d in p.diagrams.values() {
if d.projection
.state_machine()
.is_some_and(|machine| removed.contains(&machine))
.or_else(|| d.projection.scenario())
.is_some_and(|owner| removed.contains(&owner))
{
removed.insert(d.id);
}
@@ -412,6 +468,7 @@ fn remove_objects(p: &mut Project, removed: BTreeSet<Id>) {
p.artifact_links.retain(|id, _| !removed.contains(id));
for d in p.diagrams.values_mut() {
d.view_options.retain_references(|id| !removed.contains(id));
crate::scenario_layout::prune_deleted(d, &removed);
d.nodes.retain(|id, _| !removed.contains(id));
d.routes.retain(|route| !removed.contains(&route.edge));
for node in d.nodes.values_mut() {
@@ -589,3 +646,7 @@ fn remove_objects(p: &mut Project, removed: BTreeSet<Id>) {
}
}
}
#[cfg(test)]
#[path = "references/scenario_layout_tests.rs"]
mod scenario_layout_tests;
@@ -0,0 +1,388 @@
use super::*;
use std::collections::BTreeMap;
struct Fixture {
project: Project,
owner: Id,
lanes: [Id; 2],
messages: [Id; 2],
views: [Id; 2],
}
fn address(occurrence: Id) -> OccurrenceEndpoint {
OccurrenceEndpoint {
references: vec![],
occurrence,
}
}
fn layout(project: &Project, view: Id) -> &ScenarioLayout {
let Some(DiagramLayout::Scenario(layout)) = &project.diagrams[&view].layout else {
panic!("expected authored Scenario positions")
};
layout
}
fn layout_mut(project: &mut Project, view: Id) -> &mut ScenarioLayout {
let Some(DiagramLayout::Scenario(layout)) =
&mut project.diagrams.get_mut(&view).unwrap().layout
else {
panic!("expected authored Scenario positions")
};
layout
}
fn admitted(project: &Project) {
let errors: Vec<_> = crate::validation::validate(project)
.into_iter()
.filter(|issue| issue.severity == crate::validation::Severity::Error)
.collect();
assert!(errors.is_empty(), "{errors:?}");
}
fn fixture() -> Fixture {
let mut project = Project::empty();
let owner = project
.add_element(Kind::Scenario, Layer::Oa, "Delivery", None)
.unwrap();
let lanes = ["Sender", "Receiver"].map(|name| {
project
.add_element(Kind::Lifeline, Layer::Oa, name, Some(owner))
.unwrap()
});
let messages = ["First message", "Second message"].map(|name| {
let id = project
.add_element(Kind::Occurrence, Layer::Oa, name, Some(owner))
.unwrap();
*project.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::Occurrence {
body: OccurrenceBody::Exchange {
exchange: None,
sender: Some(lanes[0]),
receiver: Some(lanes[1]),
message_kind: MessageKind::Asynchronous,
reply_to: None,
values: vec![],
operation: None,
},
precondition: String::new(),
postcondition: String::new(),
};
id
});
*project
.elements
.get_mut(&owner)
.unwrap()
.steps_mut()
.unwrap() = messages.to_vec();
let views = ["delivery_view", "review_view"].map(|alias| {
let id = Id::new_v4();
project.diagrams.insert(
id,
Diagram {
id,
alias: alias.into(),
name: alias.into(),
description: String::new(),
layer: Layer::Oa,
projection: ProjectionKind::Scenario { scenario: owner },
view_options: Default::default(),
nodes: BTreeMap::new(),
routes: vec![],
layout: Some(DiagramLayout::Scenario(ScenarioLayout {
lanes: vec![
ScenarioLanePosition {
lifeline: lanes[0],
x: 120.,
},
ScenarioLanePosition {
lifeline: lanes[1],
x: 420.,
},
],
messages: vec![
ScenarioMessagePosition {
address: address(messages[0]),
send_y: 220.,
},
ScenarioMessagePosition {
address: address(messages[1]),
send_y: 360.,
},
],
})),
},
);
id
});
admitted(&project);
Fixture {
project,
owner,
lanes,
messages,
views,
}
}
#[test]
fn fresh_identity_swap_preserves_coordinates_and_semantic_sequence() {
let mut f = fixture();
let before = f.project.clone();
let temporary = Id::new_v4();
for ids in [f.lanes, f.messages] {
f.project = crate::execute_batch(
&f.project,
&[
Command::Reidentify(ids[0], temporary),
Command::Reidentify(ids[1], ids[0]),
Command::Reidentify(temporary, ids[1]),
],
)
.unwrap();
}
admitted(&f.project);
assert_eq!(
f.project.elements[&f.owner].steps().unwrap(),
&vec![f.messages[1], f.messages[0]]
);
for view in f.views {
let rows = layout(&f.project, view);
assert_eq!(
rows.lanes,
vec![
ScenarioLanePosition {
lifeline: f.lanes[1],
x: 120.
},
ScenarioLanePosition {
lifeline: f.lanes[0],
x: 420.
}
]
);
assert_eq!(
rows.messages,
vec![
ScenarioMessagePosition {
address: address(f.messages[1]),
send_y: 220.
},
ScenarioMessagePosition {
address: address(f.messages[0]),
send_y: 360.
}
]
);
}
// Reverse the swaps: no values or identities other than those requested drift.
for ids in [f.lanes, f.messages] {
f.project = crate::execute_batch(
&f.project,
&[
Command::Reidentify(ids[0], temporary),
Command::Reidentify(ids[1], ids[0]),
Command::Reidentify(temporary, ids[1]),
],
)
.unwrap();
}
assert_eq!(f.project, before);
}
#[test]
fn colliding_lane_or_message_remap_rejects_before_any_reference_changes() {
for message in [false, true] {
let mut f = fixture();
let [old, new] = if message { f.messages } else { f.lanes };
let before = f.project.clone();
assert!(check_remap(&f.project, old, new).is_err());
assert!(remap_references(&mut f.project, old, new).is_err());
assert_eq!(f.project, before);
}
}
#[test]
fn reidentify_preflight_precedes_object_map_mutation_for_fresh_colliding_target() {
let mut f = fixture();
let fresh = Id::new_v4();
// A repair candidate can already have a dangling coordinate key. It must not
// silently merge with the live lane, even though the destination ID is vacant.
layout_mut(&mut f.project, f.views[0]).lanes[1].lifeline = fresh;
let before = f.project.clone();
assert!(crate::session::reidentify(&mut f.project, f.lanes[0], fresh).is_err());
assert_eq!(f.project, before);
}
#[test]
fn full_address_paths_are_walked_and_collision_checked_without_admitting_expansion() {
let mut f = fixture();
let [old, new] = [Id::new_v4(), Id::new_v4()];
let rows = layout_mut(&mut f.project, f.views[0]);
// Future/invalid expanded candidates still participate in reference safety.
rows.messages[0].address.references = vec![old];
rows.messages[1].address = OccurrenceEndpoint {
references: vec![new],
occurrence: f.messages[0],
};
let refs = diagram_references(&f.project.diagrams[&f.views[0]]);
assert!(refs.contains(&f.owner) && refs.contains(&old) && refs.contains(&new));
let before = f.project.clone();
assert!(remap_references(&mut f.project, old, new).is_err());
assert_eq!(f.project, before);
let fresh = Id::new_v4();
remap_references(&mut f.project, old, fresh).unwrap();
let rows = layout(&f.project, f.views[0]);
assert_eq!(rows.messages[0].address.references, vec![fresh]);
assert_eq!(rows.messages[0].address.occurrence, f.messages[0]);
assert_eq!(rows.messages[1].address.references, vec![new]);
}
#[test]
fn owner_reidentify_and_delete_cover_all_saved_views_without_deleting_other_owners() {
let mut f = fixture();
let other_owner = f
.project
.add_element(Kind::Scenario, Layer::Oa, "Independent", None)
.unwrap();
let mut other_view = f.project.diagrams[&f.views[0]].clone();
other_view.id = Id::new_v4();
other_view.alias = "independent_view".into();
other_view.projection = ProjectionKind::Scenario {
scenario: other_owner,
};
other_view.layout = None;
f.project.diagrams.insert(other_view.id, other_view.clone());
admitted(&f.project);
let fresh_owner = Id::new_v4();
f.project = crate::prepare_command(&f.project, &Command::Reidentify(f.owner, fresh_owner))
.unwrap()
.project;
for view in f.views {
assert_eq!(
f.project.diagrams[&view].projection.scenario(),
Some(fresh_owner)
);
}
let impact = deletion_impact(&f.project, fresh_owner);
assert!(
f.views
.iter()
.chain(&f.lanes)
.chain(&f.messages)
.all(|id| impact.contains(id))
);
assert!(!impact.contains(&other_owner) && !impact.contains(&other_view.id));
delete_cascade(&mut f.project, fresh_owner);
assert_eq!(f.project.diagrams[&other_view.id], other_view);
assert!(
f.views
.iter()
.all(|id| !f.project.diagrams.contains_key(id))
);
admitted(&f.project);
}
#[test]
fn deletion_then_recreation_never_resurrects_lane_or_message_coordinates() {
let mut f = fixture();
let lane = f.project.elements[&f.lanes[0]].clone();
let message = f.project.elements[&f.messages[0]].clone();
delete_cascade(&mut f.project, lane.id);
for view in f.views {
let rows = layout(&f.project, view);
assert_eq!(rows.lanes.len(), 1);
assert_eq!(rows.lanes[0].lifeline, f.lanes[1]);
// Missing endpoint assignment does not erase a still-live message's spacing.
assert_eq!(rows.messages.len(), 2);
}
let mut pending = BTreeSet::new();
delete_cascade_deferred(&mut f.project, message.id, &mut pending);
// Pruning happens during deletion, before later batch commands can recreate IDs.
assert!(
layout(&f.project, f.views[0])
.messages
.iter()
.all(|row| row.address.occurrence != message.id)
);
f.project.elements.insert(lane.id, lane);
f.project.elements.insert(message.id, message);
f.project
.elements
.get_mut(&f.owner)
.unwrap()
.steps_mut()
.unwrap()
.insert(0, f.messages[0]);
finish_deletions(&mut f.project, &pending);
for view in f.views {
let rows = layout(&f.project, view);
assert_eq!(rows.lanes.len(), 1);
assert_eq!(
rows.messages,
vec![ScenarioMessagePosition {
address: address(f.messages[1]),
send_y: 360.
}]
);
}
admitted(&f.project);
}
#[test]
fn unrelated_invalid_records_do_not_block_scoped_remap_or_get_repaired_implicitly() {
let mut f = fixture();
let unrelated = Id::new_v4();
let rows = layout_mut(&mut f.project, f.views[1]);
let invalid = ScenarioLanePosition {
lifeline: unrelated,
x: MAX_COORDINATE * 2.,
};
rows.lanes.extend([invalid.clone(), invalid]);
let fresh = Id::new_v4();
let untouched = f.project.diagrams[&f.views[1]].clone();
let before = f.project.clone();
remap_references(&mut f.project, f.owner, f.owner).unwrap();
assert_eq!(f.project, before);
// The duplicate lane records do not involve the remapped message identity.
remap_references(&mut f.project, f.messages[0], fresh).unwrap();
assert_eq!(
layout(&f.project, f.views[1]).lanes,
layout_from(&untouched).lanes
);
assert_eq!(
layout(&f.project, f.views[1]).messages[0]
.address
.occurrence,
fresh
);
assert_eq!(layout(&f.project, f.views[1]).messages[0].send_y, 220.);
}
fn layout_from(diagram: &Diagram) -> &ScenarioLayout {
let Some(DiagramLayout::Scenario(layout)) = &diagram.layout else {
panic!("missing layout")
};
layout
}
#[test]
fn deletion_prunes_an_explicit_address_path_without_pruning_other_message_rows() {
let mut f = fixture();
// Expanded addresses are not admitted in this slice, but deleting their
// explicit references must still make an intermediate repair candidate safe.
layout_mut(&mut f.project, f.views[0]).messages[0]
.address
.references = vec![f.lanes[0]];
delete_cascade(&mut f.project, f.lanes[0]);
assert_eq!(
layout(&f.project, f.views[0]).messages,
vec![ScenarioMessagePosition {
address: address(f.messages[1]),
send_y: 360.
}]
);
assert_eq!(layout(&f.project, f.views[1]).messages.len(), 2);
assert!(f.project.elements.contains_key(&f.messages[0]));
admitted(&f.project);
}
+132 -38
View File
@@ -1,7 +1,12 @@
//! Deterministic, bounded diagram geometry. Only authored pins belong in the model;
//! callers share this derived geometry for drawing, picking, fitting and export.
use crate::projection::{self, ProjectedEdge, ProjectedNode, ProjectedView};
use crate::{Diagram, Id, Position, Project, Relation, RelationBody, RelationKind, Size};
use crate::{
Diagram, EdgeAttachment, EdgeEnd, Id, PortAnchorSide, Position, Project, Relation,
RelationBody, RelationKind, Size,
};
mod attachments;
pub use attachments::{attachment_from_point, attachment_point, semantic_port_endpoint};
use std::cmp::Ordering;
use std::collections::{BTreeMap, BTreeSet, BinaryHeap};
use std::fmt;
@@ -41,6 +46,7 @@ pub struct RoutedEdge {
// Derived solver state, never persisted. A preview must inherit the same
// remaining work budget as this edge had in stable-ID settled traversal.
search_budget_before: usize,
attachments: [Option<EdgeAttachment>; 2],
}
#[derive(Debug, Clone, PartialEq)]
@@ -173,6 +179,7 @@ pub fn association_end_geometry(
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RouteWarning {
CoincidentAttachments,
PinInsideObstacle { pin: usize, obstacle: Id },
NoClearPath,
SearchBudgetExhausted,
@@ -181,6 +188,9 @@ pub enum RouteWarning {
impl fmt::Display for RouteWarning {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::CoincidentAttachments => f.write_str(
"The two attachments coincide; move either end to separate the loop tips.",
),
Self::PinInsideObstacle { pin, .. } => {
write!(f, "Bend {} is inside a card or its clearance", pin + 1)
}
@@ -374,7 +384,14 @@ struct Endpoint {
point: Position,
stub: Position,
}
fn endpoints(node: &ProjectedNode, port: Option<Id>, view: &ProjectedView) -> Vec<Endpoint> {
fn endpoints(
project: &Project,
diagram: &Diagram,
node: &ProjectedNode,
port: Option<Id>,
view: &ProjectedView,
attachment: Option<EdgeAttachment>,
) -> Vec<Endpoint> {
if let Some(port) = port.and_then(|id| {
view.ports
.iter()
@@ -389,26 +406,18 @@ fn endpoints(node: &ProjectedNode, port: Option<Id>, view: &ProjectedView) -> Ve
),
}];
}
let r = Rect::node(node);
let mid = pos((r.left + r.right) / 2.0, (r.top + r.bottom) / 2.0);
vec![
Endpoint {
point: pos(r.left, mid.y),
stub: pos(r.left - PORT_STUB, mid.y),
},
Endpoint {
point: pos(r.right, mid.y),
stub: pos(r.right + PORT_STUB, mid.y),
},
Endpoint {
point: pos(mid.x, r.top),
stub: pos(mid.x, r.top - PORT_STUB),
},
Endpoint {
point: pos(mid.x, r.bottom),
stub: pos(mid.x, r.bottom + PORT_STUB),
},
if let Some(attachment) = attachment {
return vec![attachments::endpoint(project, diagram, node, attachment)];
}
[
PortAnchorSide::Left,
PortAnchorSide::Right,
PortAnchorSide::Top,
PortAnchorSide::Bottom,
]
.into_iter()
.map(|side| attachments::endpoint(project, diagram, node, EdgeAttachment { side, offset: 0.5 }))
.collect()
}
fn ancestors(id: Id, nodes: &BTreeMap<Id, &ProjectedNode>, out: &mut BTreeSet<Id>) {
@@ -507,6 +516,12 @@ fn route_diagram_impl(
.is_some_and(|(source, target)| source != edge.source || target != edge.target);
let custom_suspended = promoted;
let route = if promoted { None } else { stored };
let attachments = [EdgeEnd::Source, EdgeEnd::Target].map(|end| {
semantic_port_endpoint(project, edge.id, end)
.is_none()
.then(|| route.and_then(|r| r.attachment(end)))
.flatten()
});
let pins = route.map_or_else(Vec::new, |r| r.waypoints.clone());
let obstacles = edge_obstacles(edge, &nodes, &rectangles, &headers);
@@ -517,7 +532,10 @@ fn route_diagram_impl(
});
if let Some(cached) = cached {
budget = cached.search_budget_before;
if cached.pins == pins && cached.custom_suspended == custom_suspended {
if cached.pins == pins
&& cached.attachments == attachments
&& cached.custom_suspended == custom_suspended
{
// Label-only editing cannot change the connection beneath the
// pointer, including a settled fallback with routing warnings.
for &point in &cached.points {
@@ -543,9 +561,22 @@ fn route_diagram_impl(
}
let (points, pin_lengths, failure) = route_edge(
edge,
source,
target,
view,
&endpoints(
project,
diagram,
source,
semantic_port_endpoint(project, edge.id, EdgeEnd::Source).or(edge.source_port),
view,
attachments[0],
),
&endpoints(
project,
diagram,
target,
semantic_port_endpoint(project, edge.id, EdgeEnd::Target).or(edge.target_port),
view,
attachments[1],
),
&pins,
&obstacles,
&previous,
@@ -574,6 +605,7 @@ fn route_diagram_impl(
warnings,
custom_suspended,
search_budget_before,
attachments,
},
);
}
@@ -679,6 +711,46 @@ struct RoutingSpace<'a> {
xs: Vec<f32>,
ys: Vec<f32>,
}
fn coincident_detour(a: Endpoint, obstacles: &[Rect]) -> [Position; 3] {
let direction = |delta: f32| if delta == 0. { 0. } else { delta.signum() };
let normal = pos(
direction(a.stub.x - a.point.x),
direction(a.stub.y - a.point.y),
);
let tangent = pos(-normal.y, normal.x);
let candidates: Vec<_> = [36., 72., 144., 288.]
.into_iter()
.flat_map(|extent| {
[-1., 1.].into_iter().map(move |side| {
[
pos(a.stub.x + normal.x * extent, a.stub.y + normal.y * extent),
pos(
a.stub.x + (normal.x + tangent.x * side) * extent,
a.stub.y + (normal.y + tangent.y * side) * extent,
),
pos(
a.stub.x + tangent.x * side * extent,
a.stub.y + tangent.y * side * extent,
),
]
})
})
.collect();
// These are computed loop guards, not authored hard pins. Prefer a clear
// orientation/size before asking the bounded router to connect them.
candidates
.iter()
.find(|p| clear_path(&[a.stub, p[0], p[1], p[2], a.stub], obstacles))
.or_else(|| {
candidates.iter().find(|p| {
p.iter()
.all(|point| !obstacles.iter().any(|r| r.contains(*point)))
})
})
.copied()
.unwrap_or(candidates[0])
}
impl<'a> RoutingSpace<'a> {
fn new(obstacles: &'a [Rect]) -> Self {
Self {
@@ -692,24 +764,41 @@ impl<'a> RoutingSpace<'a> {
#[allow(clippy::too_many_arguments)]
fn route_edge(
edge: &ProjectedEdge,
source: &ProjectedNode,
target: &ProjectedNode,
view: &ProjectedView,
source_ends: &[Endpoint],
target_ends: &[Endpoint],
pins: &[Position],
obstacles: &[Rect],
previous: &[Segment],
budget: &mut usize,
) -> EdgePath {
// Coincident authored tips are valid presentation, including on self loops.
// Preserve them and add an external detour; never index an empty candidate
// list or turn the connection into an out-and-back degenerate stub.
let coincident = edge.source == edge.target
&& source_ends.len() == 1
&& target_ends.len() == 1
&& source_ends[0].point == target_ends[0].point
&& source_ends[0].stub == target_ends[0].stub;
let authored_pin_count = pins.len();
let mut loop_pins = Vec::new();
if coincident {
let a = source_ends[0];
loop_pins.extend_from_slice(pins);
loop_pins.extend(coincident_detour(a, obstacles));
}
let pins = if coincident { &loop_pins[..] } else { pins };
let space = RoutingSpace::new(obstacles);
let mut candidates = Vec::new();
for a in endpoints(source, edge.source_port, view) {
for b in endpoints(target, edge.target_port, view) {
for &a in source_ends {
for &b in target_ends {
// A real self-relation must leave and return at distinct anchors.
// Reusing one anchor makes the cheapest path a degenerate stub.
if edge.source == edge.target
&& edge.source_port.is_none()
&& edge.target_port.is_none()
&& a.point == b.point
&& a.stub == b.stub
&& !coincident
{
continue;
}
@@ -729,10 +818,9 @@ fn route_edge(
// Try all side combinations cheaply before spending the bounded grid budget.
for search in [false, true] {
for &(estimate, a, b) in &candidates {
if best
.as_ref()
.is_some_and(|(cost, _, _)| estimate + 2.0 * PORT_STUB > *cost)
{
if best.as_ref().is_some_and(|(cost, _, _)| {
estimate + manhattan(a.point, a.stub) + manhattan(b.point, b.stub) > *cost
}) {
continue;
}
if obstacles
@@ -748,7 +836,7 @@ fn route_edge(
let mut occupied = previous.to_vec();
record_segments(&mut occupied, &points);
let mut pin_lengths = Vec::new();
let mut distance = PORT_STUB;
let mut distance = manhattan(a.point, a.stub);
let mut start = a.stub;
let mut failed = false;
for (index, end) in pins
@@ -792,15 +880,20 @@ fn route_edge(
break;
}
}
if let Some((_, points, pin_lengths)) = best {
return (points, pin_lengths, None);
if let Some((_, points, mut pin_lengths)) = best {
pin_lengths.truncate(authored_pin_count);
return (
points,
pin_lengths,
coincident.then_some(RouteWarning::CoincidentAttachments),
);
}
// Recoverable fallback retains every hard pin, in order, and the actual port
// leads. Its warning is deliberately visible to consumers.
let (_, a, b) = candidates[0];
let mut points = vec![a.point, a.stub];
let mut pin_lengths = Vec::new();
let mut distance = PORT_STUB;
let mut distance = manhattan(a.point, a.stub);
let mut start = a.stub;
for (index, end) in pins
.iter()
@@ -833,6 +926,7 @@ fn route_edge(
start = end;
}
points.push(b.point);
pin_lengths.truncate(authored_pin_count);
(
points,
pin_lengths,
+160
View File
@@ -0,0 +1,160 @@
//! One boundary definition shared by persisted routing and attachment gestures.
use super::*;
use crate::{EdgeAttachment, EdgeEnd, Kind, PortAnchorSide};
/// Semantic authority, independent of whether projection currently exposes a port.
pub fn semantic_port_endpoint(project: &Project, edge: Id, end: EdgeEnd) -> Option<Id> {
let endpoint = if let Some(relation) = project.relations.get(&edge) {
match (&relation.body, end) {
(RelationBody::FunctionalExchange { source_port, .. }, EdgeEnd::Source) => *source_port,
(RelationBody::FunctionalExchange { target_port, .. }, EdgeEnd::Target) => *target_port,
(_, EdgeEnd::Source) => relation.source(),
(_, EdgeEnd::Target) => relation.target(),
}
} else {
let trace = project.traces.get(&edge)?;
match end {
EdgeEnd::Source => trace.oa,
EdgeEnd::Target => trace.sa,
}
};
project
.elements
.get(&endpoint)
.filter(|e| e.kind() == Kind::Port)
.map(|_| endpoint)
}
/// Intersect the chosen cardinal scan line with the painted shape. Offset is
/// measured over the complete projected bounding side, including its corners.
/// Terminate's X uses its outermost stroke intersection rather than a contour.
pub fn attachment_point(
project: &Project,
diagram: &Diagram,
node: &ProjectedNode,
attachment: &EdgeAttachment,
) -> Position {
let vertical = matches!(
attachment.side,
PortAnchorSide::Left | PortAnchorSide::Right
);
let (extent, depth) = if vertical {
(node.size.height, node.size.width)
} else {
(node.size.width, node.size.height)
};
let along = attachment.offset.clamp(0., 1.) * extent;
let t = 2. * attachment.offset.clamp(0., 1.) - 1.;
let kind = diagram
.projection
.state_machine()
.and_then(|_| project.elements.get(&node.element))
.map(|e| e.kind());
let inset = match kind {
Some(Kind::Choice) => depth * 0.5 * t.abs(),
Some(Kind::Terminate) => {
depth
* attachment
.offset
.clamp(0., 1.)
.min(1. - attachment.offset.clamp(0., 1.))
}
Some(
Kind::Initial
| Kind::Junction
| Kind::Final
| Kind::ShallowHistory
| Kind::DeepHistory
| Kind::EntryPoint
| Kind::ExitPoint,
) => depth * 0.5 * (1. - (1. - t * t).max(0.).sqrt()),
Some(Kind::Region | Kind::Fork | Kind::Join) => 0.,
_ => {
let radius = 10_f32.min(extent / 2.).min(depth / 2.);
let corner = (radius - along.min(extent - along)).max(0.);
radius - (radius * radius - corner * corner).max(0.).sqrt()
}
};
match attachment.side {
PortAnchorSide::Left => pos(node.position.x + inset, node.position.y + along),
PortAnchorSide::Right => pos(
node.position.x + node.size.width - inset,
node.position.y + along,
),
PortAnchorSide::Top => pos(node.position.x + along, node.position.y + inset),
PortAnchorSide::Bottom => pos(
node.position.x + along,
node.position.y + node.size.height - inset,
),
}
}
/// Choose a cardinal bounding side, then map its fraction onto the painted
/// contour. Contour distances cannot distinguish the sides of a circle at its
/// extrema: Right/0 and Top/0.5 denote the same point but different lead directions.
/// Hysteresis is in diagram units, capped for small symbols. Only UI snapping
/// rounds to hundredths: source/API values retain their complete precision.
pub fn attachment_from_point(
node: &ProjectedNode,
cursor: Position,
previous: Option<PortAnchorSide>,
hysteresis: f32,
) -> EdgeAttachment {
let candidate = |side| EdgeAttachment {
side,
offset: projection::port_anchor_offset(node, side, cursor),
};
// Measure side intent at the box, not from a distant overshoot: Euclidean
// distance differences converge outside a small symbol and would otherwise
// let hysteresis retain a perpendicular side indefinitely.
let boundary_cursor = pos(
cursor
.x
.clamp(node.position.x, node.position.x + node.size.width),
cursor
.y
.clamp(node.position.y, node.position.y + node.size.height),
);
let distance = |side| {
length(
projection::port_anchor_position(node, side, candidate(side).offset),
boundary_cursor,
)
};
let mut side = [
PortAnchorSide::Left,
PortAnchorSide::Right,
PortAnchorSide::Top,
PortAnchorSide::Bottom,
]
.into_iter()
.min_by(|a, b| distance(*a).total_cmp(&distance(*b)))
.unwrap();
let hysteresis = hysteresis
.max(0.)
.min(node.size.width.min(node.size.height) * 0.2);
if let Some(old) = previous
&& distance(old) <= distance(side) + hysteresis
{
side = old;
}
let mut attachment = candidate(side);
attachment.offset = (attachment.offset * 100.).round() / 100.;
attachment
}
pub(super) fn endpoint(
project: &Project,
diagram: &Diagram,
node: &ProjectedNode,
attachment: EdgeAttachment,
) -> Endpoint {
let point = attachment_point(project, diagram, node, &attachment);
let stub = match attachment.side {
PortAnchorSide::Left => pos(node.position.x - PORT_STUB, point.y),
PortAnchorSide::Right => pos(node.position.x + node.size.width + PORT_STUB, point.y),
PortAnchorSide::Top => pos(point.x, node.position.y - PORT_STUB),
PortAnchorSide::Bottom => pos(point.x, node.position.y + node.size.height + PORT_STUB),
};
Endpoint { point, stub }
}
+429
View File
@@ -0,0 +1,429 @@
//! Authored Scenario presentation. Structural admission never changes event order.
use crate::{
Diagram, Id, Kind, Layer, MAX_COORDINATE, ModelError, OccurrenceBody, OccurrenceEndpoint,
Project, SharedBody, validation,
};
use serde::{Deserialize, Serialize};
use std::collections::BTreeSet;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub enum DiagramLayout {
Scenario(ScenarioLayout),
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct ScenarioLayout {
#[serde(default)]
pub lanes: Vec<ScenarioLanePosition>,
#[serde(default)]
pub messages: Vec<ScenarioMessagePosition>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ScenarioLanePosition {
pub lifeline: Id,
pub x: f32,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ScenarioMessagePosition {
pub address: OccurrenceEndpoint,
/// Actual send endpoint, not the renderer's receive-oriented lifecycle row.
pub send_y: f32,
}
impl DiagramLayout {
/// Visit every explicit reference, including unsupported paths in a candidate.
/// The caller owns remap preflight and final atomic validation.
pub fn visit_references(&mut self, mut visit: impl FnMut(&mut Id)) {
match self {
Self::Scenario(layout) => {
for lane in &mut layout.lanes {
visit(&mut lane.lifeline);
}
for message in &mut layout.messages {
for reference in &mut message.address.references {
visit(reference);
}
visit(&mut message.address.occurrence);
}
}
}
}
}
/// Proposed aggregate accounting contribution; the shared admission owner applies it.
/// One payload plus one record per lane/message. No graph objects are introduced.
pub fn record_count(diagram: &Diagram) -> usize {
match &diagram.layout {
None => 0,
Some(DiagramLayout::Scenario(layout)) => 1usize
.saturating_add(layout.lanes.len())
.saturating_add(layout.messages.len()),
}
}
fn coordinate(value: f32) -> bool {
value.is_finite() && value.abs() <= MAX_COORDINATE
}
fn owner(p: &Project, diagram: &Diagram) -> Result<Id, ModelError> {
let scenario = diagram
.projection
.scenario()
.ok_or_else(|| ModelError("Scenario layout requires a Scenario projection.".into()))?;
if !p
.elements
.get(&scenario)
.is_some_and(|element| element.kind() == Kind::Scenario && element.layer() == diagram.layer)
{
return Err(ModelError(
"Scenario projection requires an existing Scenario in the diagram layer.".into(),
));
}
Ok(scenario)
}
fn lane_member(p: &Project, owner: Id, layer: Layer, lifeline: Id) -> bool {
p.elements.get(&lifeline).is_some_and(|element| {
element.kind() == Kind::Lifeline
&& element.parent == Some(owner)
&& element.layer() == layer
})
}
/// Only actual steps of this Scenario and its own Fragment/Branch sequences.
/// ScenarioUse/SequenceReference are deliberately never expanded. Invalid graph
/// cycles terminate, and malformed parent/step links do not manufacture membership.
fn direct_messages(p: &Project, owner: Id, layer: Layer) -> BTreeSet<Id> {
let mut messages = BTreeSet::new();
let mut visited = BTreeSet::new();
let mut pending = vec![owner];
while let Some(scope) = pending.pop() {
if !visited.insert(scope) {
continue;
}
let Some(element) = p.elements.get(&scope) else {
continue;
};
for id in element.steps().into_iter().flatten() {
let Some(item) = p.elements.get(id) else {
continue;
};
if item.parent != Some(scope) || item.layer() != layer {
continue;
}
match item.shared() {
Some(SharedBody::Occurrence {
body: OccurrenceBody::Exchange { .. },
..
}) => {
messages.insert(*id);
}
Some(SharedBody::Occurrence {
body: OccurrenceBody::Fragment { branches, .. },
..
}) => {
for branch in branches {
if p.elements.get(branch).is_some_and(|operand| {
operand.parent == Some(*id)
&& operand.layer() == layer
&& matches!(
operand.shared(),
Some(SharedBody::Occurrence {
body: OccurrenceBody::Branch { .. },
..
})
)
}) {
pending.push(*branch);
}
}
}
_ => {}
}
}
}
messages
}
pub(crate) fn validate_diagram(p: &Project, diagram: &Diagram, out: &mut Vec<validation::Issue>) {
if diagram.projection.scenario().is_none() {
if diagram.layout.is_some() {
validation::error(
out,
diagram.id,
"scenario_layout_projection",
"layout",
"Only a Scenario projection accepts a Scenario layout, including an empty payload.",
);
}
return;
}
let scenario = match owner(p, diagram) {
Ok(owner) => owner,
Err(error) => {
validation::error(
out,
diagram.id,
"scenario_diagram_context",
"projection",
error.0,
);
return;
}
};
if !diagram.nodes.is_empty() || !diagram.routes.is_empty() {
validation::error(
out,
diagram.id,
"scenario_diagram_content",
"layout",
"Scenario views do not accept architecture node placements or edge routes.",
);
}
let Some(DiagramLayout::Scenario(layout)) = &diagram.layout else {
return;
};
let mut lanes = BTreeSet::new();
for lane in &layout.lanes {
if !lanes.insert(lane.lifeline) {
validation::error(
out,
diagram.id,
"duplicate_scenario_lane",
"layout",
"A Scenario layout has at most one position per lifeline.",
);
}
if !lane_member(p, scenario, diagram.layer, lane.lifeline) {
validation::error(
out,
diagram.id,
"scenario_layout_lifeline",
"layout",
"Lane positions reference a Lifeline directly owned by the projected Scenario.",
);
}
if !coordinate(lane.x) {
validation::error(
out,
diagram.id,
"scenario_layout_coordinate",
"layout",
"Scenario positions must be finite and within the shared coordinate bounds.",
);
}
}
let members = direct_messages(p, scenario, diagram.layer);
let mut addresses = BTreeSet::new();
for message in &layout.messages {
// Reject expanded paths without cloning or indexing their potentially large contents.
if !message.address.references.is_empty() {
validation::error(
out,
diagram.id,
"scenario_layout_address",
"layout",
"Expanded-reference message positions are not supported; references must be empty.",
);
} else if !addresses.insert(message.address.occurrence) {
validation::error(
out,
diagram.id,
"duplicate_scenario_message",
"layout",
"A Scenario layout has at most one position per message address.",
);
}
if !members.contains(&message.address.occurrence) {
validation::error(
out,
diagram.id,
"scenario_layout_message",
"layout",
"Message positions reference Exchange occurrences in the Scenario's own ordered sequence tree.",
);
}
if !coordinate(message.send_y) {
validation::error(
out,
diagram.id,
"scenario_layout_coordinate",
"layout",
"Scenario positions must be finite and within the shared coordinate bounds.",
);
}
}
}
fn editable(p: &Project, id: Id) -> Result<(&Diagram, Id), ModelError> {
let diagram = p
.diagrams
.get(&id)
.ok_or_else(|| ModelError("Scenario diagram does not exist.".into()))?;
let scenario = owner(p, diagram)?;
if !diagram.nodes.is_empty() || !diagram.routes.is_empty() {
return Err(ModelError(
"Scenario views do not accept architecture node placements or edge routes.".into(),
));
}
Ok((diagram, scenario))
}
fn check_message(
p: &Project,
diagram: &Diagram,
owner: Id,
address: &OccurrenceEndpoint,
) -> Result<(), ModelError> {
if !address.references.is_empty() {
return Err(ModelError(
"Expanded-reference message positions are not supported; references must be empty."
.into(),
));
}
if !direct_messages(p, owner, diagram.layer).contains(&address.occurrence) {
return Err(ModelError("Message position requires an Exchange occurrence in the Scenario's own ordered sequence tree.".into()));
}
Ok(())
}
fn payload(diagram: &mut Diagram) -> &mut ScenarioLayout {
let DiagramLayout::Scenario(layout) = diagram
.layout
.get_or_insert_with(|| DiagramLayout::Scenario(ScenarioLayout::default()));
layout
}
/// Target-scoped preparation, not whole-project or whole-layout validation.
/// Unrelated intermediate errors remain repairable by later commands in a batch.
pub fn set_lane(
p: &Project,
diagram: Id,
position: ScenarioLanePosition,
) -> Result<Diagram, ModelError> {
let (current, owner) = editable(p, diagram)?;
if !lane_member(p, owner, current.layer, position.lifeline) || !coordinate(position.x) {
return Err(ModelError(
"Lane position requires an owned Lifeline and a finite bounded x coordinate.".into(),
));
}
let mut result = current.clone();
let layout = payload(&mut result);
if layout
.lanes
.iter()
.filter(|row| row.lifeline == position.lifeline)
.count()
> 1
{
return Err(ModelError(
"Cannot set an ambiguous duplicate lane position.".into(),
));
}
if let Some(existing) = layout
.lanes
.iter_mut()
.find(|row| row.lifeline == position.lifeline)
{
if existing.x != position.x {
*existing = position;
}
} else {
layout.lanes.push(position);
}
Ok(result)
}
pub fn remove_lane(p: &Project, diagram: Id, lifeline: Id) -> Result<Diagram, ModelError> {
let (current, owner) = editable(p, diagram)?;
if !lane_member(p, owner, current.layer, lifeline) {
return Err(ModelError(
"Removing a lane position requires an owned Lifeline.".into(),
));
}
let mut result = current.clone();
if let Some(DiagramLayout::Scenario(layout)) = &mut result.layout {
layout.lanes.retain(|row| row.lifeline != lifeline);
}
Ok(result)
}
pub fn set_message(
p: &Project,
diagram: Id,
position: ScenarioMessagePosition,
) -> Result<Diagram, ModelError> {
let (current, owner) = editable(p, diagram)?;
check_message(p, current, owner, &position.address)?;
if !coordinate(position.send_y) {
return Err(ModelError(
"Message position requires a finite bounded send_y coordinate.".into(),
));
}
let mut result = current.clone();
let layout = payload(&mut result);
if layout
.messages
.iter()
.filter(|row| row.address == position.address)
.count()
> 1
{
return Err(ModelError(
"Cannot set an ambiguous duplicate message position.".into(),
));
}
if let Some(existing) = layout
.messages
.iter_mut()
.find(|row| row.address == position.address)
{
if existing.send_y != position.send_y {
*existing = position;
}
} else {
layout.messages.push(position);
}
Ok(result)
}
pub fn remove_message(
p: &Project,
diagram: Id,
address: &OccurrenceEndpoint,
) -> Result<Diagram, ModelError> {
let (current, owner) = editable(p, diagram)?;
check_message(p, current, owner, address)?;
let mut result = current.clone();
if let Some(DiagramLayout::Scenario(layout)) = &mut result.layout {
layout.messages.retain(|row| &row.address != address);
}
Ok(result)
}
/// Prune only identities in the caller's actual deletion closure, never visibility
/// or missing endpoint assignments. Owner-view cascade remains the caller's job.
pub fn prune_deleted(diagram: &mut Diagram, deleted: &BTreeSet<Id>) {
if let Some(DiagramLayout::Scenario(layout)) = &mut diagram.layout {
layout
.lanes
.retain(|lane| !deleted.contains(&lane.lifeline));
layout.messages.retain(|message| {
!deleted.contains(&message.address.occurrence)
&& !message
.address
.references
.iter()
.any(|id| deleted.contains(id))
});
}
}
#[cfg(test)]
mod tests;
+802
View File
@@ -0,0 +1,802 @@
use super::*;
use crate::{
Element, FragmentOperator, MessageKind, ProjectionKind, Relation, RelationBody, RelationKind,
ScenarioViewpoint,
};
use std::collections::BTreeMap;
struct Fixture {
project: Project,
owner: Id,
performer: Id,
lane: Id,
repeated_lane: Id,
first: Id,
second: Id,
nested: Id,
fragment: Id,
branch: Id,
view: Id,
other_view: Id,
exchange: Id,
}
fn set_body(element: &mut Element, body: OccurrenceBody) {
*element.shared_mut().unwrap() = SharedBody::Occurrence {
body,
precondition: String::new(),
postcondition: String::new(),
};
}
fn address(occurrence: Id) -> OccurrenceEndpoint {
OccurrenceEndpoint {
references: vec![],
occurrence,
}
}
fn diagram(owner: Id, alias: &str) -> Diagram {
Diagram {
id: Id::new_v4(),
alias: alias.into(),
name: alias.into(),
description: String::new(),
layer: Layer::Oa,
projection: ProjectionKind::Scenario { scenario: owner },
view_options: Default::default(),
nodes: BTreeMap::new(),
routes: vec![],
layout: None,
}
}
fn assert_admitted(project: &Project) {
let errors: Vec<_> = validation::validate(project)
.into_iter()
.filter(|issue| issue.severity == validation::Severity::Error)
.collect();
assert!(errors.is_empty(), "{errors:?}");
}
fn has_code(project: &Project, code: &str) -> bool {
validation::validate(project)
.iter()
.any(|issue| issue.code == code)
}
fn layout(diagram: &Diagram) -> &ScenarioLayout {
let Some(DiagramLayout::Scenario(layout)) = &diagram.layout else {
panic!("expected saved payload")
};
layout
}
fn save(project: &mut Project, replacement: Diagram) {
project.diagrams.insert(replacement.id, replacement);
}
fn fixture() -> Fixture {
let mut project = Project::empty();
let owner = project
.add_element(Kind::Scenario, Layer::Oa, "Nominal", None)
.unwrap();
let Some(SharedBody::Scenario { viewpoint, .. }) =
project.elements.get_mut(&owner).unwrap().shared_mut()
else {
panic!("Scenario fixture body");
};
*viewpoint = ScenarioViewpoint::ParticipantInteraction;
let performer = project
.add_element(Kind::Entity, Layer::Oa, "Controller", None)
.unwrap();
let receiver = project
.add_element(Kind::Entity, Layer::Oa, "Provider", None)
.unwrap();
let lane = project
.add_element(Kind::Lifeline, Layer::Oa, "Controller lane", Some(owner))
.unwrap();
let repeated_lane = project
.add_element(
Kind::Lifeline,
Layer::Oa,
"Controller review lane",
Some(owner),
)
.unwrap();
let receiver_lane = project
.add_element(Kind::Lifeline, Layer::Oa, "Provider lane", Some(owner))
.unwrap();
for (id, represented) in [
(lane, performer),
(repeated_lane, performer),
(receiver_lane, receiver),
] {
*project.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::Lifeline {
represented: Some(represented),
};
}
let exchange = Id::new_v4();
project.relations.insert(
exchange,
Relation {
id: exchange,
alias: "delivery_channel".into(),
name: "Delivery".into(),
description: String::new(),
body: RelationBody::new(RelationKind::CommunicationMean, performer, receiver),
},
);
let first = project
.add_element(Kind::Occurrence, Layer::Oa, "First delivery", Some(owner))
.unwrap();
let second = project
.add_element(Kind::Occurrence, Layer::Oa, "Second delivery", Some(owner))
.unwrap();
for (id, sender) in [(first, lane), (second, repeated_lane)] {
set_body(
project.elements.get_mut(&id).unwrap(),
OccurrenceBody::Exchange {
exchange: Some(exchange),
sender: Some(sender),
receiver: Some(receiver_lane),
message_kind: MessageKind::Asynchronous,
reply_to: None,
values: vec![],
operation: None,
},
);
}
let fragment = project
.add_element(
Kind::Occurrence,
Layer::Oa,
"Optional delivery",
Some(owner),
)
.unwrap();
set_body(
project.elements.get_mut(&fragment).unwrap(),
OccurrenceBody::Fragment {
operator: FragmentOperator::Optional,
branches: vec![],
},
);
let branch = project
.add_element(Kind::Occurrence, Layer::Oa, "Option body", Some(fragment))
.unwrap();
let nested = project
.add_element(
Kind::Occurrence,
Layer::Oa,
"Unfinished nested delivery",
Some(branch),
)
.unwrap();
set_body(
project.elements.get_mut(&nested).unwrap(),
OccurrenceBody::Exchange {
exchange: None,
sender: None,
receiver: None,
message_kind: MessageKind::Asynchronous,
reply_to: None,
values: vec![],
operation: None,
},
);
set_body(
project.elements.get_mut(&branch).unwrap(),
OccurrenceBody::Branch {
guard: "requested".into(),
occurrences: vec![nested],
},
);
set_body(
project.elements.get_mut(&fragment).unwrap(),
OccurrenceBody::Fragment {
operator: FragmentOperator::Optional,
branches: vec![branch],
},
);
*project
.elements
.get_mut(&owner)
.unwrap()
.steps_mut()
.unwrap() = vec![first, second, fragment];
let view_record = diagram(owner, "nominal_view");
let other_record = diagram(owner, "review_view");
let view = view_record.id;
let other_view = other_record.id;
save(&mut project, view_record);
save(&mut project, other_record);
assert_admitted(&project);
Fixture {
project,
owner,
performer,
lane,
repeated_lane,
first,
second,
nested,
fragment,
branch,
view,
other_view,
exchange,
}
}
#[test]
fn two_views_and_repeated_semantic_definitions_keep_independent_overrides() {
let mut f = fixture();
let semantics = (f.project.elements.clone(), f.project.relations.clone());
for (view, line, x) in [
(f.view, f.lane, 420.),
(f.view, f.repeated_lane, 680.),
(f.other_view, f.lane, 900.),
] {
let result =
set_lane(&f.project, view, ScenarioLanePosition { lifeline: line, x }).unwrap();
save(&mut f.project, result);
}
for (view, id, y) in [
(f.view, f.first, 200.),
(f.view, f.second, 350.),
(f.other_view, f.first, 260.),
] {
let result = set_message(
&f.project,
view,
ScenarioMessagePosition {
address: address(id),
send_y: y,
},
)
.unwrap();
save(&mut f.project, result);
}
assert_eq!(
layout(&f.project.diagrams[&f.view]).lanes,
vec![
ScenarioLanePosition {
lifeline: f.lane,
x: 420.
},
ScenarioLanePosition {
lifeline: f.repeated_lane,
x: 680.
},
]
);
assert_eq!(
layout(&f.project.diagrams[&f.view])
.messages
.iter()
.map(|r| (r.address.occurrence, r.send_y))
.collect::<Vec<_>>(),
vec![(f.first, 200.), (f.second, 350.)]
);
assert_eq!(
layout(&f.project.diagrams[&f.other_view]).messages[0].send_y,
260.
);
assert_eq!(layout(&f.project.diagrams[&f.other_view]).lanes[0].x, 900.);
assert_eq!(
(f.project.elements.clone(), f.project.relations.clone()),
semantics
);
assert_admitted(&f.project);
}
#[test]
fn own_nested_unfinished_message_is_admitted_but_expansion_is_not() {
let mut f = fixture();
let result = set_message(
&f.project,
f.view,
ScenarioMessagePosition {
address: address(f.nested),
send_y: -80.,
},
)
.unwrap();
save(&mut f.project, result);
assert_admitted(&f.project);
let before = f.project.clone();
let expanded = OccurrenceEndpoint {
references: vec![f.fragment],
occurrence: f.nested,
};
assert!(
set_message(
&f.project,
f.view,
ScenarioMessagePosition {
address: expanded.clone(),
send_y: 400.
}
)
.is_err()
);
assert!(remove_message(&f.project, f.view, &expanded).is_err());
assert_eq!(f.project, before);
payload(f.project.diagrams.get_mut(&f.view).unwrap()).messages[0].address = expanded;
assert!(has_code(&f.project, "scenario_layout_address"));
}
#[test]
fn only_ordered_message_appearances_and_owned_lifelines_are_targets() {
let mut f = fixture();
assert!(
set_lane(
&f.project,
f.view,
ScenarioLanePosition {
lifeline: f.performer,
x: 10.
}
)
.is_err()
);
for id in [f.exchange, f.fragment, f.branch, f.owner, Id::new_v4()] {
assert!(
set_message(
&f.project,
f.view,
ScenarioMessagePosition {
address: address(id),
send_y: 100.
}
)
.is_err()
);
}
let foreign = f
.project
.add_element(Kind::Scenario, Layer::Oa, "Foreign", None)
.unwrap();
let foreign_lane = f
.project
.add_element(Kind::Lifeline, Layer::Oa, "Foreign lane", Some(foreign))
.unwrap();
assert!(remove_lane(&f.project, f.view, foreign_lane).is_err());
f.project.elements.get_mut(&f.first).unwrap().parent = Some(foreign);
assert!(
set_message(
&f.project,
f.view,
ScenarioMessagePosition {
address: address(f.first),
send_y: 100.
}
)
.is_err()
);
f.project.elements.get_mut(&f.first).unwrap().parent = Some(f.owner);
f.project
.elements
.get_mut(&f.owner)
.unwrap()
.steps_mut()
.unwrap()
.retain(|id| *id != f.first);
assert!(
set_message(
&f.project,
f.view,
ScenarioMessagePosition {
address: address(f.first),
send_y: 100.
}
)
.is_err()
);
}
#[test]
fn duplicate_positions_reject_even_when_values_match() {
let mut f = fixture();
let lane = ScenarioLanePosition {
lifeline: f.lane,
x: 10.,
};
let message = ScenarioMessagePosition {
address: address(f.first),
send_y: 100.,
};
let entries = payload(f.project.diagrams.get_mut(&f.view).unwrap());
entries.lanes = vec![lane.clone(), lane.clone()];
entries.messages = vec![message.clone(), message.clone()];
assert!(has_code(&f.project, "duplicate_scenario_lane"));
assert!(has_code(&f.project, "duplicate_scenario_message"));
assert!(set_lane(&f.project, f.view, lane).is_err());
assert!(set_message(&f.project, f.view, message).is_err());
// Explicit removal can repair all duplicates of the targeted identity.
let result = remove_lane(&f.project, f.view, f.lane).unwrap();
save(&mut f.project, result);
let result = remove_message(&f.project, f.view, &address(f.first)).unwrap();
save(&mut f.project, result);
assert_admitted(&f.project);
}
#[test]
fn sparse_absence_empty_payload_and_signed_zero_are_preserved() {
let mut f = fixture();
let absent = f.project.diagrams[&f.view].clone();
assert_eq!(remove_lane(&f.project, f.view, f.lane).unwrap(), absent);
assert_eq!(
remove_message(&f.project, f.view, &address(f.first)).unwrap(),
absent
);
assert!(
serde_json::to_value(&absent)
.unwrap()
.get("layout")
.is_none()
);
let result = set_lane(
&f.project,
f.view,
ScenarioLanePosition {
lifeline: f.lane,
x: -0.,
},
)
.unwrap();
save(&mut f.project, result);
let result = set_message(
&f.project,
f.view,
ScenarioMessagePosition {
address: address(f.first),
send_y: -0.,
},
)
.unwrap();
save(&mut f.project, result);
let result = set_lane(
&f.project,
f.view,
ScenarioLanePosition {
lifeline: f.lane,
x: 0.,
},
)
.unwrap();
assert_eq!(layout(&result).lanes[0].x.to_bits(), (-0.0_f32).to_bits());
let result = set_message(
&f.project,
f.view,
ScenarioMessagePosition {
address: address(f.first),
send_y: 0.,
},
)
.unwrap();
assert_eq!(
layout(&result).messages[0].send_y.to_bits(),
(-0.0_f32).to_bits()
);
assert_eq!(
serde_json::to_string(&result).unwrap(),
serde_json::to_string(&f.project.diagrams[&f.view]).unwrap()
);
let result = remove_lane(&f.project, f.view, f.lane).unwrap();
save(&mut f.project, result);
let result = remove_message(&f.project, f.view, &address(f.first)).unwrap();
assert_eq!(
result.layout,
Some(DiagramLayout::Scenario(ScenarioLayout::default()))
);
assert_ne!(result, absent);
}
#[test]
fn bounds_are_structural_and_rejected_helpers_do_not_mutate_project() {
let mut f = fixture();
for value in [-MAX_COORDINATE, MAX_COORDINATE, -7., 0.] {
let result = set_lane(
&f.project,
f.view,
ScenarioLanePosition {
lifeline: f.lane,
x: value,
},
)
.unwrap();
save(&mut f.project, result);
let result = set_message(
&f.project,
f.view,
ScenarioMessagePosition {
address: address(f.first),
send_y: value,
},
)
.unwrap();
save(&mut f.project, result);
assert_admitted(&f.project);
}
let before = serde_json::to_string(&f.project).unwrap();
for value in [
f32::NAN,
f32::INFINITY,
f32::NEG_INFINITY,
MAX_COORDINATE + 1.,
] {
assert!(
set_lane(
&f.project,
f.view,
ScenarioLanePosition {
lifeline: f.lane,
x: value
}
)
.is_err()
);
assert!(
set_message(
&f.project,
f.view,
ScenarioMessagePosition {
address: address(f.first),
send_y: value
}
)
.is_err()
);
}
assert_eq!(serde_json::to_string(&f.project).unwrap(), before);
payload(f.project.diagrams.get_mut(&f.view).unwrap()).messages[0].send_y = f32::NAN;
assert!(has_code(&f.project, "scenario_layout_coordinate"));
}
#[test]
fn helpers_do_not_validate_unrelated_transient_records_in_repair_batches() {
let mut f = fixture();
payload(f.project.diagrams.get_mut(&f.view).unwrap())
.messages
.push(ScenarioMessagePosition {
address: address(f.second),
send_y: f32::INFINITY,
});
let result = set_lane(
&f.project,
f.view,
ScenarioLanePosition {
lifeline: f.lane,
x: 420.,
},
)
.unwrap();
save(&mut f.project, result);
assert!(has_code(&f.project, "scenario_layout_coordinate"));
let result = set_message(
&f.project,
f.view,
ScenarioMessagePosition {
address: address(f.second),
send_y: 300.,
},
)
.unwrap();
save(&mut f.project, result);
assert_admitted(&f.project);
}
#[test]
fn projection_context_and_architecture_payloads_are_explicitly_separate() {
let mut f = fixture();
let scenario = f.project.diagrams[&f.view].projection;
assert_eq!(scenario.scenario(), Some(f.owner));
assert!(!scenario.is_architecture());
assert!(!scenario.includes(Kind::Lifeline));
assert!(ProjectionKind::Overview.is_architecture());
assert!(!ProjectionKind::StateMachine { machine: f.owner }.is_architecture());
f.project.diagrams.get_mut(&f.view).unwrap().layer = Layer::Sa;
assert!(has_code(&f.project, "scenario_diagram_context"));
assert!(remove_lane(&f.project, f.view, f.lane).is_err());
f.project.diagrams.get_mut(&f.view).unwrap().layer = Layer::Oa;
f.project
.diagrams
.get_mut(&f.view)
.unwrap()
.routes
.push(crate::EdgeRoute {
edge: f.exchange,
source_attachment: None,
target_attachment: None,
waypoints: vec![],
label: None,
});
assert!(has_code(&f.project, "scenario_diagram_content"));
f.project.diagrams.get_mut(&f.view).unwrap().routes.clear();
payload(f.project.diagrams.get_mut(&f.view).unwrap());
f.project.diagrams.get_mut(&f.view).unwrap().projection = ProjectionKind::Overview;
assert!(has_code(&f.project, "scenario_layout_projection"));
assert!(
set_lane(
&f.project,
f.view,
ScenarioLanePosition {
lifeline: f.lane,
x: 10.
}
)
.is_err()
);
}
#[test]
fn typed_json_records_round_trip_and_reject_unknown_or_missing_fields() {
let mut f = fixture();
let result = set_message(
&f.project,
f.view,
ScenarioMessagePosition {
address: address(f.first),
send_y: 200.,
},
)
.unwrap();
save(&mut f.project, result);
let encoded = serde_json::to_value(&f.project.diagrams[&f.view]).unwrap();
assert_eq!(
serde_json::from_value::<Diagram>(encoded.clone()).unwrap(),
f.project.diagrams[&f.view]
);
assert_eq!(
encoded["layout"]["Scenario"]["messages"][0]["address"]["occurrence"],
serde_json::json!(f.first)
);
let mut unknown = encoded.clone();
unknown["layout"]["Scenario"]["messages"][0]["extra"] = serde_json::json!(1);
assert!(serde_json::from_value::<Diagram>(unknown).is_err());
let mut missing = encoded;
missing["layout"]["Scenario"]["messages"][0]["address"]
.as_object_mut()
.unwrap()
.remove("references");
assert!(serde_json::from_value::<Diagram>(missing).is_err());
}
#[test]
fn deleting_only_actual_ids_preserves_latent_message_positions_and_other_views() {
let mut f = fixture();
let result = set_lane(
&f.project,
f.view,
ScenarioLanePosition {
lifeline: f.lane,
x: 420.,
},
)
.unwrap();
save(&mut f.project, result);
let result = set_message(
&f.project,
f.view,
ScenarioMessagePosition {
address: address(f.first),
send_y: 200.,
},
)
.unwrap();
save(&mut f.project, result);
let mut copied = f.project.diagrams[&f.view].clone();
copied.id = f.other_view;
copied.alias = "review_view".into();
copied.name = "Review view".into();
save(&mut f.project, copied.clone());
assert_admitted(&f.project);
assert_eq!(layout(&copied).messages[0].address.occurrence, f.first);
let mut candidate = f.project.diagrams[&f.view].clone();
prune_deleted(&mut candidate, &BTreeSet::from([f.lane, f.exchange]));
assert!(layout(&candidate).lanes.is_empty());
assert_eq!(layout(&candidate).messages.len(), 1);
prune_deleted(&mut candidate, &BTreeSet::from([f.first]));
assert!(layout(&candidate).messages.is_empty());
assert!(candidate.layout.is_some());
assert_eq!(f.project.diagrams[&f.other_view], copied);
}
#[test]
fn reference_visitor_and_record_count_cover_explicit_rows() {
let f = fixture();
let mut d = f.project.diagrams[&f.view].clone();
assert_eq!(record_count(&d), 0);
let rows = payload(&mut d);
rows.lanes.push(ScenarioLanePosition {
lifeline: f.lane,
x: 50.,
});
rows.messages.push(ScenarioMessagePosition {
address: address(f.first),
send_y: 100.,
});
assert_eq!(record_count(&d), 3);
let replacement = Id::new_v4();
d.layout.as_mut().unwrap().visit_references(|id| {
if *id == f.first {
*id = replacement;
}
});
assert_eq!(layout(&d).messages[0].address.occurrence, replacement);
assert_eq!(layout(&d).messages[0].send_y, 100.);
assert_eq!(layout(&d).lanes[0].lifeline, f.lane);
}
#[test]
fn a_real_scenario_use_never_imports_its_called_message_as_a_direct_target() {
let mut f = fixture();
let called = f
.project
.add_element(Kind::Scenario, Layer::Oa, "Called sequence", None)
.unwrap();
let Some(SharedBody::Scenario { viewpoint, .. }) =
f.project.elements.get_mut(&called).unwrap().shared_mut()
else {
panic!("Called Scenario fixture body");
};
*viewpoint = ScenarioViewpoint::ParticipantInteraction;
let called_message = f
.project
.add_element(Kind::Occurrence, Layer::Oa, "Called delivery", Some(called))
.unwrap();
set_body(
f.project.elements.get_mut(&called_message).unwrap(),
OccurrenceBody::Exchange {
exchange: None,
sender: None,
receiver: None,
message_kind: MessageKind::Asynchronous,
reply_to: None,
values: vec![],
operation: None,
},
);
*f.project
.elements
.get_mut(&called)
.unwrap()
.steps_mut()
.unwrap() = vec![called_message];
let usage = f
.project
.add_element(Kind::Occurrence, Layer::Oa, "Call sequence", Some(f.owner))
.unwrap();
set_body(
f.project.elements.get_mut(&usage).unwrap(),
OccurrenceBody::ScenarioUse {
scenario: Some(called),
bindings: vec![],
},
);
f.project
.elements
.get_mut(&f.owner)
.unwrap()
.steps_mut()
.unwrap()
.push(usage);
assert_admitted(&f.project);
for target in [
address(called_message),
address(usage),
OccurrenceEndpoint {
references: vec![usage],
occurrence: called_message,
},
] {
assert!(
set_message(
&f.project,
f.view,
ScenarioMessagePosition {
address: target,
send_y: 200.
}
)
.is_err()
);
}
}
mod source_lifecycle;
@@ -0,0 +1,302 @@
//! Source/command lifecycle checks for the candidate payload. Public version
//! assignment remains the coordinated codec integration gate.
use super::*;
use crate::{Command, Session, language, prepare_command, source_project::*};
fn authored(f: &Fixture) -> CompiledSourceProject {
let mut bundle = format_bundle(&f.project);
let initial = compile_bundle(&bundle, ProjectLimits::default()).unwrap();
let location = &initial.index.declarations[&f.view];
let source = bundle.files.get_mut(&location.path).unwrap();
let text = format!(
r#"diagram nominal_view "Nominal review" id "{}" {{
projection {{ Scenario {{ scenario @{}; }}; }};
layout {{ Scenario {{
lanes [
// Controller spacing rationale.
{{ lifeline @ // first lane identity
{}; x 4.20e2; }},
// Repeated performer has its own lane.
{{ lifeline @{}; x 680; }}
];
messages [
// First delivery timing rationale.
{{ address {{ references []; occurrence @{}; }}; send_y 2.00e2; }},
// Second delivery timing rationale.
{{ address {{ references []; occurrence @{}; }}; send_y 350; }}
];
}}; }};
}}
"#,
f.view,
f.project.elements[&f.owner].alias,
f.project.elements[&f.lane].alias,
f.project.elements[&f.repeated_lane].alias,
f.project.elements[&f.first].alias,
f.project.elements[&f.second].alias
);
source.replace_range(location.span.start..location.span.end, &text);
compile_bundle(&bundle, ProjectLimits::default()).unwrap()
}
#[test]
fn source_rows_follow_identity_order_and_keep_comments_numeric_spelling_and_file_ownership() {
let f = fixture();
let original = authored(&f);
let mut changed = original.decoded.project.clone();
let rows = payload(changed.diagrams.get_mut(&f.view).unwrap());
rows.lanes.swap(0, 1);
rows.messages.swap(0, 1);
rows.messages[0].send_y = 390.;
changed.elements.get_mut(&f.lane).unwrap().alias = "renamed_controller_lane".into();
let updated = update_bundle(&original, &changed, ProjectLimits::default()).unwrap();
assert_eq!(updated.decoded.project, changed);
let path = &original.index.declarations[&f.view].path;
assert_eq!(&updated.index.declarations[&f.view].path, path);
let text = &updated.bundle.files[path];
assert!(
text.find("// Repeated performer").unwrap() < text.find("// Controller spacing").unwrap(),
"{text}"
);
assert!(
text.find("// Second delivery").unwrap() < text.find("// First delivery").unwrap(),
"{text}"
);
assert!(
text.contains(
"lifeline @ // first lane identity\n renamed_controller_lane; x 4.20e2;"
),
"{text}"
);
assert!(text.contains("send_y 2.00e2"), "{text}");
assert!(text.contains("send_y 390"), "{text}");
for (other, bytes) in &original.bundle.files {
if other != path && !bytes.contains(&f.project.elements[&f.lane].alias) {
assert_eq!(&updated.bundle.files[other], bytes);
}
}
assert_eq!(
compile_bundle(&updated.bundle, ProjectLimits::default())
.unwrap()
.decoded
.project,
changed
);
}
#[test]
fn source_identity_swap_keeps_each_coordinate_comment_with_its_original_object() {
let f = fixture();
let original = authored(&f);
let temporary = Id::new_v4();
let command = Command::Batch(vec![
Command::Reidentify(f.first, temporary),
Command::Reidentify(f.second, f.first),
Command::Reidentify(temporary, f.second),
]);
let prepared = prepare_command(&original.decoded.project, &command).unwrap();
let mut changed = prepared.project;
payload(changed.diagrams.get_mut(&f.view).unwrap())
.messages
.swap(0, 1);
let updated = update_bundle_with_identity_map(
&original,
&changed,
&prepared.identity_map,
ProjectLimits::default(),
)
.unwrap();
assert_eq!(updated.decoded.project, changed);
let path = &original.index.declarations[&f.view].path;
let text = &updated.bundle.files[path];
assert!(
text.find("// Second delivery").unwrap() < text.find("// First delivery").unwrap(),
"{text}"
);
let first_comment = text.find("// First delivery").unwrap();
assert!(text[first_comment..].contains(&format!(
"occurrence @{}",
changed.elements[&f.second].alias
)));
assert!(text[first_comment..].contains("send_y 2.00e2"));
}
#[test]
fn coordinate_batch_is_one_undo_and_numeric_noop_keeps_source_exact() {
let f = fixture();
let original = authored(&f);
let mut session = Session::new(original.decoded.project.clone());
session
.execute(Command::Batch(vec![
Command::SetScenarioLanePosition {
diagram: f.view,
position: ScenarioLanePosition {
lifeline: f.lane,
x: 510.,
},
},
Command::SetScenarioMessagePosition {
diagram: f.view,
position: ScenarioMessagePosition {
address: address(f.second),
send_y: 430.,
},
},
]))
.unwrap();
let changed = session.project().clone();
assert_eq!(session.revision(), 1);
assert_eq!(changed.elements, original.decoded.project.elements);
assert_eq!(changed.relations, original.decoded.project.relations);
let updated = update_bundle(&original, &changed, ProjectLimits::default()).unwrap();
session.undo();
assert_eq!(session.project(), &original.decoded.project);
assert!(!session.can_undo());
session.redo();
assert_eq!(session.project(), &changed);
session
.execute(Command::SetScenarioMessagePosition {
diagram: f.view,
position: ScenarioMessagePosition {
address: address(f.second),
send_y: 430.,
},
})
.unwrap();
assert_eq!(session.revision(), 3);
let noop = update_bundle(&updated, session.project(), ProjectLimits::default()).unwrap();
assert_eq!(noop.bundle, updated.bundle);
}
#[test]
fn absent_and_empty_payload_round_trip_distinctly_and_remove_does_not_materialize_absence() {
let f = fixture();
let before = language::format(&f.project);
let mut session = Session::new(f.project.clone());
session
.execute(Command::RemoveScenarioLanePosition {
diagram: f.view,
lifeline: f.lane,
})
.unwrap();
assert_eq!(session.project(), &f.project);
assert!(!session.can_undo());
assert_eq!(language::format(session.project()), before);
let mut present = f.project.clone();
present.diagrams.get_mut(&f.view).unwrap().layout =
Some(DiagramLayout::Scenario(ScenarioLayout::default()));
let text = language::format(&present);
assert_ne!(text, before);
assert_eq!(language::parse(&text).unwrap(), present);
assert_eq!(language::parse(&before).unwrap(), f.project);
}
#[test]
fn invalid_layout_source_rejects_without_changing_last_compiled_workspace() {
let f = fixture();
let original = authored(&f);
let before = original.bundle.clone();
let path = original.index.declarations[&f.view].path.clone();
for replacement in ["x 1e30", "x not_a_number"] {
let mut candidate = before.clone();
let source = candidate.files.get_mut(&path).unwrap();
*source = source.replace("x 4.20e2", replacement);
assert!(compile_bundle(&candidate, ProjectLimits::default()).is_err());
assert_eq!(original.bundle, before);
}
let mut candidate = before.clone();
let source = candidate.files.get_mut(&path).unwrap();
*source = source.replace(
&format!("occurrence @{}", f.project.elements[&f.first].alias),
&format!("occurrence @{}", f.project.elements[&f.performer].alias),
);
assert!(compile_bundle(&candidate, ProjectLimits::default()).is_err());
assert_eq!(original.bundle, before);
}
#[test]
fn explicit_none_survives_other_edits_while_real_payload_clear_removes_values() {
let f = fixture();
let mut bundle = format_bundle(&f.project);
let compiled = compile_bundle(&bundle, ProjectLimits::default()).unwrap();
let location = &compiled.index.declarations[&f.view];
let path = location.path.clone();
bundle.files.get_mut(&path).unwrap().insert_str(
location.span.end - 1,
"\n layout // explicitly automatic\n none;\n",
);
let original = compile_bundle(&bundle, ProjectLimits::default()).unwrap();
let mut described = original.decoded.project.clone();
described.diagrams.get_mut(&f.view).unwrap().description =
"Review spacing independently".into();
let updated = update_bundle(&original, &described, ProjectLimits::default()).unwrap();
assert!(updated.bundle.files[&path].contains("layout // explicitly automatic\n none;"));
assert_eq!(updated.decoded.project, described);
let mut empty = described.clone();
empty.diagrams.get_mut(&f.view).unwrap().layout =
Some(DiagramLayout::Scenario(ScenarioLayout::default()));
let present = update_bundle(&updated, &empty, ProjectLimits::default()).unwrap();
assert_eq!(present.decoded.project, empty);
assert!(present.bundle.files[&path].contains("// explicitly automatic"));
let cleared = update_bundle(&present, &described, ProjectLimits::default()).unwrap();
assert_eq!(cleared.decoded.project, described);
assert!(!cleared.bundle.files[&path].contains("layout"));
assert!(cleared.bundle.files[&path].contains("// explicitly automatic"));
}
#[test]
fn compact_defaults_and_scenario_row_identity_compose_through_rename_and_clear() {
let f = fixture();
let initial = authored(&f);
let mut bundle = initial.bundle.clone();
let location = &initial.index.declarations[&f.performer];
bundle.files.get_mut(&location.path).unwrap().insert_str(
location.span.end - 1,
"\n description // explicitly default prose\n \"\";\n",
);
let original = compile_bundle(&bundle, ProjectLimits::default()).unwrap();
let mut expected = original.decoded.project.clone();
let performer = expected.elements.get_mut(&f.performer).unwrap();
performer.name = "Reviewed controller".into();
performer.alias = "reviewed_controller".into();
expected.elements.get_mut(&f.lane).unwrap().alias = "reviewed_lane".into();
let rows = payload(expected.diagrams.get_mut(&f.view).unwrap());
rows.lanes.swap(0, 1);
rows.messages.swap(0, 1);
rows.messages[0].send_y = 390.;
let updated = update_bundle(&original, &expected, ProjectLimits::default()).unwrap();
assert_eq!(updated.decoded.project, expected);
let element = &updated.index.declarations[&f.performer];
let text = &updated.bundle.files[&element.path][element.span.start..element.span.end];
assert!(
text.contains("description // explicitly default prose\n \"\";"),
"{text}"
);
assert!(
!text.contains("categories"),
"an omitted default must stay omitted: {text}"
);
let diagram = &updated.index.declarations[&f.view];
let text = &updated.bundle.files[&diagram.path][diagram.span.start..diagram.span.end];
assert!(
text.find("// Repeated performer").unwrap() < text.find("// Controller spacing").unwrap()
);
assert!(text.contains("reviewed_lane; x 4.20e2;"), "{text}");
assert!(text.find("// Second delivery").unwrap() < text.find("// First delivery").unwrap());
assert!(text.contains("send_y 2.00e2"), "{text}");
assert!(!text.contains("view_options"), "{text}");
expected.diagrams.get_mut(&f.view).unwrap().layout = None;
let cleared = update_bundle(&updated, &expected, ProjectLimits::default()).unwrap();
assert_eq!(cleared.decoded.project, expected);
let declaration = &cleared.index.declarations[&f.view];
let text =
&cleared.bundle.files[&declaration.path][declaration.span.start..declaration.span.end];
assert!(!text.contains("layout"), "{text}");
assert!(text.contains("// Controller spacing rationale."), "{text}");
assert_eq!(
update_bundle(&cleared, &expected, ProjectLimits::default())
.unwrap()
.bundle,
cleared.bundle,
);
}
+54 -1
View File
@@ -23,6 +23,22 @@ pub enum Command {
EditRelation(Id, Relation),
CreateDiagram(Diagram),
EditDiagram(Id, Diagram),
SetScenarioLanePosition {
diagram: Id,
position: ScenarioLanePosition,
},
RemoveScenarioLanePosition {
diagram: Id,
lifeline: Id,
},
SetScenarioMessagePosition {
diagram: Id,
position: ScenarioMessagePosition,
},
RemoveScenarioMessagePosition {
diagram: Id,
address: OccurrenceEndpoint,
},
CreateArtifact(ArtifactLink),
EditArtifact(Id, ArtifactLink),
CreateTrace(Trace),
@@ -488,6 +504,26 @@ fn apply_command(
reidentify_tracked(p, *old, value.id, provenance)?;
Some(value.id)
}
Command::SetScenarioLanePosition { diagram, position } => {
let replacement = crate::scenario_layout::set_lane(p, *diagram, position.clone())?;
p.diagrams.insert(*diagram, replacement);
Some(*diagram)
}
Command::RemoveScenarioLanePosition { diagram, lifeline } => {
let replacement = crate::scenario_layout::remove_lane(p, *diagram, *lifeline)?;
p.diagrams.insert(*diagram, replacement);
Some(*diagram)
}
Command::SetScenarioMessagePosition { diagram, position } => {
let replacement = crate::scenario_layout::set_message(p, *diagram, position.clone())?;
p.diagrams.insert(*diagram, replacement);
Some(*diagram)
}
Command::RemoveScenarioMessagePosition { diagram, address } => {
let replacement = crate::scenario_layout::remove_message(p, *diagram, address)?;
p.diagrams.insert(*diagram, replacement);
Some(*diagram)
}
Command::CreateArtifact(value) => {
vacant(p, value.id)?;
p.artifact_links.insert(value.id, value.clone());
@@ -553,6 +589,17 @@ fn apply_command(
.map(|d| d.id)
.ok_or_else(missing)?;
if source != *diagram {
let destination = &p.diagrams[diagram];
let element = p.diagrams[&source].nodes[node].element;
if destination.projection != ProjectionKind::Traceability
&& p.elements
.get(&element)
.is_none_or(|e| e.layer() != destination.layer)
{
return Err(ModelError(
"Move the placement to a view in its layer or a traceability view.".into(),
));
}
let placement = p
.diagrams
.get_mut(&source)
@@ -878,6 +925,7 @@ pub fn reidentify(p: &mut Project, old: Id, new: Id) -> Result<(), ModelError> {
return Ok(());
}
vacant(p, new)?;
crate::references::check_remap(p, old, new)?;
if p.id == old {
p.id = new;
} else if let Some(mut v) = p.elements.remove(&old) {
@@ -904,12 +952,17 @@ pub fn reidentify(p: &mut Project, old: Id, new: Id) -> Result<(), ModelError> {
}
}
}
crate::references::remap_references(p, old, new);
crate::references::remap_references(p, old, new)?;
Ok(())
}
fn layout(p: &mut Project, diagram: Id) -> Result<(), ModelError> {
use std::collections::{BTreeMap, BTreeSet};
let d = p.diagrams.get(&diagram).ok_or_else(missing)?;
if d.projection.scenario().is_some() {
return Err(ModelError(
"Scenario layout requires the Scenario geometry operation.".into(),
));
}
let parents = crate::projection::parents(p, d);
let mut children: BTreeMap<Option<Id>, Vec<Id>> = BTreeMap::new();
for (id, parent) in &parents {
+405
View File
@@ -0,0 +1,405 @@
//! Ordinary source defaults shared by current parsing and declaration rendering.
//!
//! These are source omission defaults, not graphical creation defaults. The
//! policy operates on complete typed declaration maps. Required named fields
//! and selected constructor payloads remain distinct from omission defaults.
//! Historical versions use their existing frozen parser paths. Semantic baseline
//! history is never traversed or normalized here.
use crate::{Kind, RelationKind};
use serde_json::{Map, Value, json};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Context {
Element(Kind),
Relation(RelationKind),
Diagram,
Node,
Artifact,
Trace,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Mode {
CompleteForEdit,
Compact,
/// Internal lossless-edit fragments: retain every top-level value while
/// compacting its selected constructor's optional nested fields.
CompactValues,
}
/// Top-level field inventory for the current ordinary reader. Callers must
/// check required fields before merging; null entries only admit their names.
/// This never supplies defaults inside a selected constructor.
pub(crate) fn parser_fields(context: Context) -> Map<String, Value> {
let mut fields = declaration_defaults(context);
let required: &[&str] = match context {
Context::Element(kind) => {
fields.remove("description");
fields.remove("parent");
if kind == Kind::Port {
&["contract"]
} else {
&[]
}
}
Context::Relation(kind) => {
fields.remove("description");
match kind {
RelationKind::ContextualLink => &["owner", "source", "target", "purpose"],
RelationKind::FunctionalExchange => {
&["source", "target", "source_port", "target_port"]
}
_ => &["source", "target"],
}
}
Context::Diagram => {
// Admit the optional payload without restoring a synthetic null in
// CompleteForEdit: absence there distinguishes a real layout clear
// from an unrelated edit beside an authored explicit `layout none`.
fields.insert("layout".into(), Value::Null);
&[]
}
Context::Node => &["element"],
Context::Artifact => &["owner"],
Context::Trace => &["oa", "sa"],
};
for name in required {
fields.insert((*name).into(), Value::Null);
}
fields
}
pub(crate) fn fields(
context: Context,
input: &Map<String, Value>,
mode: Mode,
) -> Map<String, Value> {
let mut result = input.clone();
restore_serialization_skips(context, &mut result);
if mode != Mode::CompleteForEdit {
compact_nested(context, &mut result);
}
if mode == Mode::Compact {
// Compact a composite and its declared default by the same nested rules
// before whole-field comparison. This also makes compact input stable:
// e.g. String{minimum_length:0} still denotes the default definition.
let mut defaults = declaration_defaults(context);
compact_nested(context, &mut defaults);
omit_defaults(&mut result, &defaults);
}
result
}
fn restore_serialization_skips(context: Context, fields: &mut Map<String, Value>) {
match context {
Context::Element(Kind::Occurrence) => {
if let Some(exchange) = constructor_payload(fields, "body", "Exchange") {
exchange
.entry("operation".to_owned())
.or_insert(Value::Null);
}
}
Context::Diagram => {
if let Some(routes) = fields.get_mut("routes").and_then(Value::as_array_mut) {
for route in routes.iter_mut().filter_map(Value::as_object_mut) {
route
.entry("source_attachment".to_owned())
.or_insert(Value::Null);
route
.entry("target_attachment".to_owned())
.or_insert(Value::Null);
}
}
}
Context::Node => {
fields.entry("port_anchors".to_owned()).or_insert(json!([]));
}
Context::Trace => {
if let Some(baseline) = fields.get_mut("baseline").and_then(Value::as_object_mut) {
baseline.entry("semantic".to_owned()).or_insert(Value::Null);
}
}
_ => {}
}
}
fn declaration_defaults(context: Context) -> Map<String, Value> {
let mut defaults = match context {
Context::Element(kind) => element_defaults(kind),
Context::Relation(kind) => relation_defaults(kind),
Context::Diagram => object(json!({
"description": "", "layer": "Oa", "projection": "Overview",
"routes": [], "view_options": {
"hidden_edges": [], "relation_kinds": "All",
"work_categories": "All", "participant_categories": "All",
"coverage": "All", "work_contexts": "All", "exchange_labels": "Name"
}
})),
Context::Node => object(json!({
"position": {"x": 0.0, "y": 0.0},
"size": {"width": 224.0, "height": 94.0},
"collapsed": false, "port_anchors": []
})),
Context::Artifact => object(json!({
"description": "", "relation": "References", "uri": "",
"revision": null, "fragment": null
})),
Context::Trace => object(json!({
"description": "", "kind": "Refinement", "baseline": null
})),
};
match context {
Context::Element(_) => {
defaults.insert("description".into(), json!(""));
defaults.insert("parent".into(), Value::Null);
}
Context::Relation(_) => {
defaults.insert("description".into(), json!(""));
}
_ => {}
}
defaults
}
fn element_defaults(kind: Kind) -> Map<String, Value> {
if let Some(state_kind) = kind.state_kind() {
// The declaration selects this discriminator; no GUI factory is used.
return object(json!({
"kind": state_kind, "entry": "", "exit": "", "do_activity": ""
}));
}
match kind {
Kind::Process | Kind::FunctionalChain => object(json!({"steps": []})),
Kind::ExchangeItem => object(json!({
"kind": "Information", "mechanism": "Unspecified", "supertype": null
})),
Kind::ExchangeCategory => object(json!({"family": "Work", "exchanges": []})),
Kind::DataType => object(json!({
"definition": {"String": {"minimum_length": 0, "maximum_length": null}},
"default": null, "unit": ""
})),
Kind::DataField => object(json!({
"type_ref": "String", "minimum": 1, "maximum": 1, "unit": "",
"direction": "Unspecified", "storage": "Value", "default": null
})),
Kind::Interface => object(json!({"supertype": null})),
// Port.contract is required, even when its explicit value is Legacy.
Kind::Port => object(json!({"legacy": null})),
Kind::Scenario => object(json!({
"viewpoint": "MixedLegacy", "context": null,
"precondition": "", "postcondition": "", "steps": []
})),
Kind::Lifeline => object(json!({"represented": null})),
Kind::Occurrence => object(json!({
"body": {"Exchange": {
"exchange": null, "sender": null, "receiver": null,
"message_kind": "Asynchronous", "reply_to": null,
"values": [], "operation": null
}},
"precondition": "", "postcondition": ""
})),
Kind::Event => object(json!({"body": {"Signal": {"items": []}}})),
Kind::Constraint => object(json!({"expression": "", "constrained": []})),
// Unit-body declarations have no additional omission defaults. A future
// Kind reaches this branch without gaining speculative payload rules.
_ => Map::new(),
}
}
fn relation_defaults(kind: RelationKind) -> Map<String, Value> {
match kind {
RelationKind::InterfaceItemAllocation => object(json!({
"order": 0, "transmission": "Unspecified", "acquisition": "Unspecified"
})),
RelationKind::DataAssociation => object(json!({
"source_role": "", "target_role": "",
"source_minimum": 0, "target_minimum": 0,
"source_maximum": null, "target_maximum": null,
"source_navigable": false, "target_navigable": false
})),
RelationKind::Interaction | RelationKind::FunctionalExchange => {
object(json!({"items": []}))
}
RelationKind::BoundaryExchange => object(json!({"items": [], "oriented": true})),
RelationKind::CommunicationMean => object(json!({"items": [], "oriented": false})),
RelationKind::CapabilityExtend => object(json!({"condition": ""})),
RelationKind::SequenceLink => object(json!({"guard": ""})),
RelationKind::StateTransition => object(json!({
"transition_kind": "External", "trigger": "", "guard": "", "effect": "",
"triggers": [], "effects": []
})),
RelationKind::StateAction => object(json!({"phase": "Do"})),
// source/target, FunctionalExchange ports and ContextualLink's owner,
// endpoints and purpose are required, despite factory placeholder values.
_ => Map::new(),
}
}
fn compact_nested(context: Context, fields: &mut Map<String, Value>) {
match context {
Context::Element(Kind::DataType) => compact_data_definition(fields),
Context::Element(Kind::Port) => {
if let Some(legacy) = fields.get_mut("legacy").and_then(Value::as_object_mut) {
omit_nulls(legacy, &["interface"]);
}
// Every selected PortContract payload field remains required.
}
Context::Element(Kind::Occurrence) => compact_occurrence(fields),
Context::Diagram => {
if let Some(options) = fields
.get_mut("view_options")
.and_then(Value::as_object_mut)
{
omit_defaults(
options,
&object(json!({
"hidden_edges": [], "relation_kinds": "All",
"work_categories": "All", "participant_categories": "All",
"coverage": "All", "work_contexts": "All", "exchange_labels": "Name"
})),
);
}
if let Some(routes) = fields.get_mut("routes").and_then(Value::as_array_mut) {
for route in routes {
if let Some(route) = route.as_object_mut() {
omit_defaults(
route,
&object(
json!({"waypoints": [], "label": null, "source_attachment": null, "target_attachment": null}),
),
);
}
}
}
// Route entries, EdgeLabel coordinates, projection machine IDs, and
// Only([])/Any([]) selectors remain even when their values are empty.
}
Context::Trace => {
if let Some(baseline) = fields.get_mut("baseline").and_then(Value::as_object_mut) {
omit_nulls(baseline, &["semantic"]);
}
// No recursion through snapshots or semantic historical facets.
}
// DataValue/TypeRef payloads, EventBody fields, Node geometry/anchors,
// contextual endpoints/purposes, and other required records stay intact.
_ => {}
}
}
fn compact_data_definition(fields: &mut Map<String, Value>) {
for tag in ["Integer", "Real"] {
if let Some(payload) = constructor_payload(fields, "definition", tag) {
omit_nulls(payload, &["minimum", "maximum"]);
}
}
if let Some(payload) = constructor_payload(fields, "definition", "String") {
omit_nulls(payload, &["maximum_length"]);
// minimum_length is required inside a selected String constructor.
}
if let Some(payload) = constructor_payload(fields, "definition", "Record") {
omit_nulls(payload, &["supertype"]);
}
// Boolean has no payload; Enumeration.literals remains required even [].
}
fn compact_occurrence(fields: &mut Map<String, Value>) {
if let Some(exchange) = constructor_payload(fields, "body", "Exchange") {
omit_defaults(
exchange,
&object(json!({
"exchange": null, "sender": null, "receiver": null,
"message_kind": "Asynchronous", "reply_to": null,
"values": [], "operation": null
})),
);
if let Some(operation) = exchange.get_mut("operation").and_then(Value::as_object_mut) {
omit_nulls(operation, &["allocation"]);
// Keep the containing operation field even if its record becomes {}:
// explicit unresolved operation intent is not an absent operation.
}
if let Some(values) = exchange.get_mut("values").and_then(Value::as_array_mut) {
for value in values {
if let Some(message_value) = value.as_object_mut() {
omit_defaults(message_value, &object(json!({"path": []})));
}
}
}
// MessageKind::Timer.expression, MessageValue.field/expression remain.
}
for (tag, optional) in [
("Action", &["action", "lifeline"][..]),
("State", &["state", "lifeline"][..]),
("SequenceReference", &["sequence"][..]),
("ScenarioUse", &["scenario"][..]),
] {
if let Some(payload) = constructor_payload(fields, "body", tag) {
omit_nulls(payload, optional);
}
}
// ScenarioUse.bindings, Control.kind, Fragment.operator/branches and
// Branch.guard/occurrences are required, including empty strings/lists.
}
fn constructor_payload<'a>(
fields: &'a mut Map<String, Value>,
key: &str,
tag: &str,
) -> Option<&'a mut Map<String, Value>> {
let wrapper = fields.get_mut(key)?.as_object_mut()?;
if wrapper.len() != 1 {
return None;
}
wrapper.get_mut(tag)?.as_object_mut()
}
fn omit_nulls(fields: &mut Map<String, Value>, names: &[&str]) {
for name in names {
if fields.get(*name).is_some_and(Value::is_null) {
fields.remove(*name);
}
}
}
fn omit_defaults(fields: &mut Map<String, Value>, defaults: &Map<String, Value>) {
for (name, default) in defaults {
if fields.get(name).is_some_and(|value| same(value, default)) {
fields.remove(name);
}
}
}
// Comparison recurses only to compare a known field with its exact declared
// default. It never traverses arbitrary data looking for values to remove.
fn same(value: &Value, default: &Value) -> bool {
match (value, default) {
(Value::Object(a), Value::Object(b)) => {
a.len() == b.len()
&& a.iter()
.all(|(key, value)| b.get(key).is_some_and(|d| same(value, d)))
}
(Value::Array(a), Value::Array(b)) => {
a.len() == b.len() && a.iter().zip(b).all(|(value, d)| same(value, d))
}
(Value::Number(a), Value::Number(b)) => {
// serde_json numeric equality alone can equate -0.0 with +0.0.
// Extracted f32 coordinates are represented exactly as f64 here.
a == b
&& match (a.as_f64(), b.as_f64()) {
(Some(x), Some(y)) if x == 0.0 && y == 0.0 => x.to_bits() == y.to_bits(),
_ => true,
}
}
_ => value == default,
}
}
fn object(value: Value) -> Map<String, Value> {
match value {
Value::Object(fields) => fields,
_ => unreachable!("literal source default is an object"),
}
}
#[cfg(test)]
#[path = "source_defaults_tests.rs"]
mod tests;
+718
View File
@@ -0,0 +1,718 @@
//! Independent literal oracles for the production source-default policy.
use super::{self as policy, Context, Mode};
use crate::{Kind, RelationKind};
use serde_json::{Value, json};
#[test]
fn complete_comparison_keeps_populated_serializer_skip_values() {
for (context, input) in [
(
Context::Element(Kind::Occurrence),
json!({"body":{"Exchange":{"operation":{"allocation":"allocation-id"},"values":[]}}}),
),
(
Context::Node,
json!({"port_anchors":[{"port":"port-id","side":"Left","offset":0.0}]}),
),
(
Context::Trace,
json!({"baseline":{"semantic":{"historical":"payload"}}}),
),
] {
assert_eq!(rendered(context, &input, Mode::CompleteForEdit), input);
}
}
#[test]
fn compact_value_fragments_keep_selected_constructor_requirements() {
for (context, input, expected) in [
(
Context::Element(Kind::DataType),
json!({"definition":{"String":{"minimum_length":0,"maximum_length":null}},"default":null,"unit":""}),
json!({"definition":{"String":{"minimum_length":0}},"default":null,"unit":""}),
),
(
Context::Element(Kind::Event),
json!({"body":{"Signal":{"items":[]}}}),
json!({"body":{"Signal":{"items":[]}}}),
),
(
Context::Element(Kind::Occurrence),
json!({"body":{"Exchange":{"operation":{"allocation":null},"values":[]}},"precondition":""}),
json!({"body":{"Exchange":{"operation":{}}},"precondition":""}),
),
(
Context::Element(Kind::Occurrence),
json!({"body":{"ScenarioUse":{"scenario":null,"bindings":[]}}}),
json!({"body":{"ScenarioUse":{"bindings":[]}}}),
),
] {
assert_eq!(rendered(context, &input, Mode::CompactValues), expected);
}
}
#[test]
fn source_scenario_default_is_independent_of_graphical_creation() {
use crate::{ElementBody, Layer, ScenarioViewpoint, SharedBody, language};
let project = language::parse(
"arcadia 7; project p \"P\" { oa_scenario o \"O\" {} sa_scenario s \"S\" {} }",
)
.unwrap();
for element in project.elements.values() {
assert!(matches!(
element.body.shared(),
Some(SharedBody::Scenario {
viewpoint: ScenarioViewpoint::MixedLegacy,
..
})
));
assert!(!matches!(
ElementBody::new(Kind::Scenario, element.layer())
.unwrap()
.shared(),
Some(SharedBody::Scenario {
viewpoint: ScenarioViewpoint::MixedLegacy,
..
})
));
}
let source = language::format(&project);
assert!(!source.contains("description"));
assert!(!source.contains("parent"));
assert!(!source.contains("viewpoint"));
assert_eq!(language::parse(&source).unwrap(), project);
let mut typed = project;
for element in typed.elements.values_mut() {
let viewpoint = if element.layer() == Layer::Oa {
ScenarioViewpoint::OperationalActivity
} else {
ScenarioViewpoint::Functional
};
*element.body.shared_mut().unwrap() = SharedBody::Scenario {
viewpoint,
context: None,
precondition: String::new(),
postcondition: String::new(),
steps: vec![],
};
}
let source = language::format(&typed);
assert!(source.contains("viewpoint OperationalActivity;"));
assert!(source.contains("viewpoint Functional;"));
assert_eq!(language::parse(&source).unwrap(), typed);
}
#[test]
fn shared_parser_defaults_preserve_required_fields_and_kind_checks() {
for (declaration, message) in [
(
"oa_port p0 \"Port\" {}",
"Missing required field 'contract'",
),
(
"allocation r \"R\" { source @owner; }",
"Missing required field 'target'",
),
(
"functional_exchange r \"R\" { source @owner; target @work; }",
"Missing required field 'source_port'",
),
(
"contextual_link r \"R\" {}",
"Missing required field 'owner'",
),
("node n { diagram @d; }", "Missing required field 'element'"),
("artifact a \"A\" {}", "Missing required field 'owner'"),
("trace t \"T\" {}", "Missing required field 'oa'"),
(
"oa_state s \"S\" { kind Initial; }",
"Declaration kind disagrees",
),
(
"oa_activity a \"A\" { unknown true; }",
"Unknown field 'unknown'",
),
(
"oa_data_type t \"T\" { definition { String {}; }; }",
"minimum_length",
),
(
"oa_occurrence o \"O\" { body { ScenarioUse {}; }; }",
"bindings",
),
] {
let source = format!(
"arcadia 7; project p \"P\" {{ oa_entity owner \"Owner\" {{}} oa_activity work \"Work\" {{}} diagram d \"D\" {{}} {declaration} }}"
);
let errors = crate::language::parse(&source).unwrap_err();
assert!(
errors.iter().any(|error| error.message.contains(message)),
"{declaration}: {errors:?}"
);
}
}
fn rendered(context: Context, input: &Value, mode: Mode) -> Value {
Value::Object(policy::fields(context, input.as_object().unwrap(), mode))
}
fn compact(context: Context, input: Value, expected: Value) {
let original = input.clone();
let result = rendered(context, &input, Mode::Compact);
assert_eq!(result, expected, "context {context:?}");
assert_eq!(input, original, "the input remains immutable");
assert_eq!(
rendered(context, &result, Mode::Compact),
result,
"compact policy is idempotent"
);
}
#[test]
fn complete_comparison_restores_only_the_three_known_skips() {
let exchange = json!({"body":{"Exchange":{"exchange":null,"sender":null,"receiver":null,"message_kind":"Asynchronous","reply_to":null,"values":[]}}});
let mut complete = exchange.clone();
complete["body"]["Exchange"]["operation"] = Value::Null;
assert_eq!(
rendered(
Context::Element(Kind::Occurrence),
&exchange,
Mode::CompleteForEdit
),
complete
);
assert_eq!(
rendered(
Context::Node,
&json!({"element":"e","diagram":"d"}),
Mode::CompleteForEdit
),
json!({"element":"e","diagram":"d","port_anchors":[]})
);
assert_eq!(
rendered(
Context::Trace,
&json!({"baseline":{"oa":{"name":"","description":""},"sa":{"name":"","description":""}}}),
Mode::CompleteForEdit
),
json!({"baseline":{"oa":{"name":"","description":""},"sa":{"name":"","description":""},"semantic":null}})
);
for input in [
json!({"body":{"Action":{"action":null,"lifeline":null}}}),
json!({"body":{"State":{"state":null,"lifeline":null}}}),
] {
assert_eq!(
rendered(
Context::Element(Kind::Occurrence),
&input,
Mode::CompleteForEdit
),
input
);
}
for input in [
json!({"baseline":null}),
json!({"baseline":{"semantic":{"historical":[null,[],false,0,""]},"oa":{"name":"n","description":""},"sa":{"name":"s","description":""}}}),
] {
assert_eq!(
rendered(Context::Trace, &input, Mode::CompleteForEdit),
input
);
}
}
#[test]
fn every_element_omits_only_empty_common_defaults() {
for kind in Kind::ALL {
// Future/unknown payloads are preservation sentinels, not admitted source.
compact(
Context::Element(kind),
json!({"description":"","parent":null,"future":{"empty":[],"absent":null,"zero":0,"flag":false}}),
json!({"future":{"empty":[],"absent":null,"zero":0,"flag":false}}),
);
compact(
Context::Element(kind),
json!({"description":" \n","parent":"owner"}),
json!({"description":" \n","parent":"owner"}),
);
}
}
#[test]
fn ordinary_element_defaults_are_context_specific() {
for kind in [Kind::Process, Kind::FunctionalChain] {
compact(Context::Element(kind), json!({"steps":[]}), json!({}));
compact(
Context::Element(kind),
json!({"steps":["second","first"]}),
json!({"steps":["second","first"]}),
);
}
compact(
Context::Element(Kind::ExchangeItem),
json!({"kind":"Information","mechanism":"Unspecified","supertype":null}),
json!({}),
);
compact(
Context::Element(Kind::ExchangeItem),
json!({"kind":"Energy","mechanism":"Flow","supertype":"base"}),
json!({"kind":"Energy","mechanism":"Flow","supertype":"base"}),
);
compact(
Context::Element(Kind::ExchangeCategory),
json!({"family":"Work","exchanges":[]}),
json!({}),
);
compact(
Context::Element(Kind::ExchangeCategory),
json!({"family":"Participant","exchanges":["x"]}),
json!({"family":"Participant","exchanges":["x"]}),
);
compact(
Context::Element(Kind::Interface),
json!({"supertype":null}),
json!({}),
);
compact(
Context::Element(Kind::Lifeline),
json!({"represented":null}),
json!({}),
);
compact(
Context::Element(Kind::Constraint),
json!({"expression":"","constrained":[]}),
json!({}),
);
}
#[test]
fn scenario_source_default_never_uses_the_graphical_viewpoint_default() {
compact(
Context::Element(Kind::Scenario),
json!({"viewpoint":"MixedLegacy","context":null,"precondition":"","postcondition":"","steps":[]}),
json!({}),
);
for viewpoint in [
"OperationalActivity",
"Functional",
"ParticipantInteraction",
"BoundaryInterface",
] {
compact(
Context::Element(Kind::Scenario),
json!({"viewpoint":viewpoint,"context":"scope","precondition":"ready","postcondition":"","steps":["two","one"]}),
json!({"viewpoint":viewpoint,"context":"scope","precondition":"ready","steps":["two","one"]}),
);
}
}
#[test]
fn each_state_header_supplies_its_own_kind() {
for (kind, name) in [
(Kind::State, "State"),
(Kind::Mode, "Mode"),
(Kind::Initial, "Initial"),
(Kind::Final, "Final"),
(Kind::Choice, "Choice"),
(Kind::Junction, "Junction"),
(Kind::Fork, "Fork"),
(Kind::Join, "Join"),
(Kind::ShallowHistory, "ShallowHistory"),
(Kind::DeepHistory, "DeepHistory"),
(Kind::EntryPoint, "EntryPoint"),
(Kind::ExitPoint, "ExitPoint"),
(Kind::Terminate, "Terminate"),
] {
compact(
Context::Element(kind),
json!({"kind":name,"entry":"","exit":"","do_activity":""}),
json!({}),
);
}
// Invalid mismatches must not be silently normalized by the writer policy.
compact(
Context::Element(Kind::Mode),
json!({"kind":"State","entry":"work"}),
json!({"kind":"State","entry":"work"}),
);
}
#[test]
fn data_field_unbounded_and_present_empty_values_are_not_defaults() {
compact(
Context::Element(Kind::DataField),
json!({"type_ref":"String","minimum":1,"maximum":1,"unit":"","direction":"Unspecified","storage":"Value","default":null}),
json!({}),
);
compact(
Context::Element(Kind::DataField),
json!({"type_ref":{"Named":"record"},"minimum":0,"maximum":null,"unit":"m","direction":"Out","storage":"Reference","default":[]}),
json!({"type_ref":{"Named":"record"},"minimum":0,"maximum":null,"unit":"m","direction":"Out","storage":"Reference","default":[]}),
);
for value in [
json!({"Boolean":false}),
json!({"Integer":0}),
json!({"Real":0.0}),
json!({"String":""}),
] {
compact(
Context::Element(Kind::DataField),
json!({"default":[value.clone()]}),
json!({"default":[value]}),
);
}
}
#[test]
fn data_definition_whole_default_and_selected_required_fields_differ() {
compact(
Context::Element(Kind::DataType),
json!({"definition":{"String":{"minimum_length":0,"maximum_length":null}},"default":null,"unit":""}),
json!({}),
);
for (input, expected) in [
(
json!({"Integer":{"minimum":null,"maximum":null}}),
json!({"Integer":{}}),
),
(
json!({"Real":{"minimum":null,"maximum":2.5}}),
json!({"Real":{"maximum":2.5}}),
),
(
json!({"String":{"minimum_length":4,"maximum_length":null}}),
json!({"String":{"minimum_length":4}}),
),
(json!({"Record":{"supertype":null}}), json!({"Record":{}})),
(
json!({"Enumeration":{"literals":[]}}),
json!({"Enumeration":{"literals":[]}}),
),
] {
compact(
Context::Element(Kind::DataType),
json!({"definition":input}),
json!({"definition":expected}),
);
}
compact(
Context::Element(Kind::DataType),
json!({"definition":"Boolean","default":{"Boolean":false}}),
json!({"definition":"Boolean","default":{"Boolean":false}}),
);
}
#[test]
fn port_contract_and_its_required_payload_stay_explicit() {
compact(
Context::Element(Kind::Port),
json!({"contract":"Legacy","legacy":{"direction":"Out","interface":null}}),
json!({"contract":"Legacy","legacy":{"direction":"Out"}}),
);
for contract in [
json!({"FunctionInput":{"incoming_items":[]}}),
json!({"FunctionOutput":{"outgoing_items":[]}}),
json!({"Component":{"kind":"Standard","orientation":"Unset"}}),
] {
compact(
Context::Element(Kind::Port),
json!({"contract":contract.clone(),"legacy":null}),
json!({"contract":contract}),
);
}
}
#[test]
fn occurrence_whole_default_is_distinct_from_selected_constructor_payload() {
compact(
Context::Element(Kind::Occurrence),
json!({"body":{"Exchange":{"exchange":null,"sender":null,"receiver":null,"message_kind":"Asynchronous","reply_to":null,"values":[]}},"precondition":"","postcondition":""}),
json!({}),
);
for (input, expected) in [
(
json!({"Action":{"action":null,"lifeline":null}}),
json!({"Action":{}}),
),
(
json!({"State":{"state":null,"lifeline":null}}),
json!({"State":{}}),
),
(
json!({"SequenceReference":{"sequence":null}}),
json!({"SequenceReference":{}}),
),
(
json!({"ScenarioUse":{"scenario":null,"bindings":[]}}),
json!({"ScenarioUse":{"bindings":[]}}),
),
(
json!({"Branch":{"guard":"","occurrences":[]}}),
json!({"Branch":{"guard":"","occurrences":[]}}),
),
(
json!({"Fragment":{"operator":"Parallel","branches":[]}}),
json!({"Fragment":{"operator":"Parallel","branches":[]}}),
),
(
json!({"Control":{"kind":"Choice"}}),
json!({"Control":{"kind":"Choice"}}),
),
] {
compact(
Context::Element(Kind::Occurrence),
json!({"body":input}),
json!({"body":expected}),
);
}
}
#[test]
fn unresolved_operation_and_required_message_value_are_preserved() {
compact(
Context::Element(Kind::Occurrence),
json!({"body":{"Exchange":{"exchange":null,"sender":null,"receiver":null,"message_kind":"Asynchronous","reply_to":null,"values":[{"field":"f","path":[],"expression":""}],"operation":{"allocation":null}}}}),
json!({"body":{"Exchange":{"values":[{"field":"f","expression":""}],"operation":{}}}}),
);
compact(
Context::Element(Kind::Occurrence),
json!({"body":{"Exchange":{"exchange":"flow","sender":"a","receiver":"b","message_kind":{"Timer":{"expression":""}},"reply_to":"request","values":[],"operation":null}}}),
json!({"body":{"Exchange":{"exchange":"flow","sender":"a","receiver":"b","message_kind":{"Timer":{"expression":""}},"reply_to":"request"}}}),
);
}
#[test]
fn event_body_whole_default_does_not_prune_selected_required_fields() {
compact(
Context::Element(Kind::Event),
json!({"body":{"Signal":{"items":[]}}}),
json!({}),
);
for body in [
json!({"Signal":{"items":["i"]}}),
json!({"Time":{"kind":"After","expression":""}}),
json!({"Change":{"expression":""}}),
] {
compact(
Context::Element(Kind::Event),
json!({"body":body.clone()}),
json!({"body":body}),
);
}
}
#[test]
fn relation_required_endpoints_and_contextual_payloads_remain() {
for kind in RelationKind::ALL {
compact(
Context::Relation(kind),
json!({"description":"","source":"s","target":"t","future":[]}),
json!({"source":"s","target":"t","future":[]}),
);
}
compact(
Context::Relation(RelationKind::FunctionalExchange),
json!({"source":"s","target":"t","source_port":"sp","target_port":"tp","items":[]}),
json!({"source":"s","target":"t","source_port":"sp","target_port":"tp"}),
);
let contextual = json!({"owner":"o","source":{"references":[],"occurrence":"s"},"target":{"references":[],"occurrence":"t"},"purpose":{"Precedence":{"guard":""}}});
compact(
Context::Relation(RelationKind::ContextualLink),
contextual.clone(),
contextual,
);
}
#[test]
fn relation_defaults_preserve_contextual_orientation_and_multiplicity() {
for (kind, orientation) in [
(RelationKind::BoundaryExchange, true),
(RelationKind::CommunicationMean, false),
] {
compact(
Context::Relation(kind),
json!({"oriented":orientation,"items":[]}),
json!({}),
);
compact(
Context::Relation(kind),
json!({"oriented":!orientation}),
json!({"oriented":!orientation}),
);
}
compact(
Context::Relation(RelationKind::DataAssociation),
json!({"source_role":"","target_role":"","source_minimum":0,"target_minimum":0,"source_maximum":null,"target_maximum":null,"source_navigable":false,"target_navigable":false}),
json!({}),
);
compact(
Context::Relation(RelationKind::DataAssociation),
json!({"source_maximum":0,"target_maximum":1,"source_navigable":true}),
json!({"source_maximum":0,"target_maximum":1,"source_navigable":true}),
);
compact(
Context::Relation(RelationKind::InterfaceItemAllocation),
json!({"order":0,"transmission":"Unspecified","acquisition":"Unspecified"}),
json!({}),
);
compact(
Context::Relation(RelationKind::CapabilityExtend),
json!({"condition":""}),
json!({}),
);
compact(
Context::Relation(RelationKind::SequenceLink),
json!({"guard":""}),
json!({}),
);
compact(
Context::Relation(RelationKind::StateAction),
json!({"phase":"Do"}),
json!({}),
);
compact(
Context::Relation(RelationKind::StateTransition),
json!({"transition_kind":"External","trigger":"","guard":"","effect":"","triggers":[],"effects":[]}),
json!({}),
);
compact(
Context::Relation(RelationKind::StateTransition),
json!({"transition_kind":"Internal","triggers":["t"],"effects":["e"]}),
json!({"transition_kind":"Internal","triggers":["t"],"effects":["e"]}),
);
}
#[test]
fn diagram_options_remove_only_unrestricted_defaults() {
let defaults = json!({"hidden_edges":[],"relation_kinds":"All","work_categories":"All","participant_categories":"All","coverage":"All","work_contexts":"All","exchange_labels":"Name"});
compact(
Context::Diagram,
json!({"description":"","layer":"Oa","projection":"Overview","routes":[],"view_options":defaults}),
json!({}),
);
compact(
Context::Diagram,
json!({"layer":"Sa","projection":{"StateMachine":{"machine":"m"}},"view_options":{"hidden_edges":[],"relation_kinds":{"Only":[]},"work_categories":{"Any":[]},"participant_categories":"All","coverage":"All","work_contexts":{"Any":[]},"exchange_labels":"Name"}}),
json!({"layer":"Sa","projection":{"StateMachine":{"machine":"m"}},"view_options":{"relation_kinds":{"Only":[]},"work_categories":{"Any":[]},"work_contexts":{"Any":[]}}}),
);
compact(
Context::Diagram,
json!({"routes":[{"edge":"e","waypoints":[],"label":null}]}),
json!({"routes":[{"edge":"e"}]}),
);
}
#[test]
fn node_whole_defaults_preserve_negative_zero_and_required_coordinates() {
compact(
Context::Node,
json!({"diagram":"d","element":"e","position":{"x":0.0,"y":0.0},"size":{"width":224.0,"height":94.0},"collapsed":false}),
json!({"diagram":"d","element":"e"}),
);
for position in [
json!({"x":-0.0,"y":0.0}),
json!({"x":0.0,"y":-0.0}),
json!({"x":1.0,"y":0.0}),
] {
let result = rendered(
Context::Node,
&json!({"position":position.clone()}),
Mode::Compact,
);
assert_eq!(result, json!({"position":position.clone()}));
for coordinate in ["x", "y"] {
assert_eq!(
result["position"][coordinate].as_f64().unwrap().to_bits(),
position[coordinate].as_f64().unwrap().to_bits(),
"coordinate sign is part of fidelity"
);
}
}
compact(
Context::Node,
json!({"size":{"width":300.0,"height":94.0},"port_anchors":[{"port":"p","side":"Left","offset":0.0}]}),
json!({"size":{"width":300.0,"height":94.0},"port_anchors":[{"port":"p","side":"Left","offset":0.0}]}),
);
}
#[test]
fn artifact_present_empty_strings_and_trace_history_stay_distinct() {
compact(
Context::Artifact,
json!({"owner":"o","description":"","relation":"References","uri":"","revision":null,"fragment":null}),
json!({"owner":"o"}),
);
compact(
Context::Artifact,
json!({"owner":"o","relation":"Implements","revision":"","fragment":""}),
json!({"owner":"o","relation":"Implements","revision":"","fragment":""}),
);
compact(
Context::Trace,
json!({"oa":"o","sa":"s","description":"","kind":"Refinement","baseline":null}),
json!({"oa":"o","sa":"s"}),
);
compact(
Context::Trace,
json!({"baseline":{"oa":{"name":"","description":""},"sa":{"name":"s","description":""}}}),
json!({"baseline":{"oa":{"name":"","description":""},"sa":{"name":"s","description":""}}}),
);
let baseline = json!({"oa":{"name":"o","description":""},"sa":{"name":"s","description":""},"semantic":{"facets":[{"Value":null},{"Value":[]},{"Value":false},{"Value":0},{"Value":""}]}});
compact(
Context::Trace,
json!({"kind":"Manual","baseline":baseline.clone()}),
json!({"kind":"Manual","baseline":baseline}),
);
}
#[test]
fn nested_future_fields_prevent_whole_default_removal() {
// These sentinels exercise preservation, not current parser admission of
// unknown fields. Only known slots may lose their redundant defaults.
compact(
Context::Element(Kind::DataType),
json!({"definition":{"String":{"minimum_length":0,"maximum_length":null,"future":[]}}}),
json!({"definition":{"String":{"minimum_length":0,"future":[]}}}),
);
compact(
Context::Element(Kind::Occurrence),
json!({"body":{"Exchange":{"exchange":null,"sender":null,"receiver":null,"message_kind":"Asynchronous","reply_to":null,"values":[],"operation":null,"future":false}}}),
json!({"body":{"Exchange":{"future":false}}}),
);
compact(
Context::Diagram,
json!({"view_options":{"hidden_edges":[],"relation_kinds":"All","work_categories":"All","participant_categories":"All","coverage":"All","work_contexts":"All","exchange_labels":"Name","future":null}}),
json!({"view_options":{"future":null}}),
);
}
#[test]
fn unknown_and_multikey_constructor_wrappers_are_not_reinterpreted() {
for definition in [
json!({"Future":{"minimum":null,"maximum":null}}),
json!({"String":{"minimum_length":0,"maximum_length":null},"Future":{}}),
] {
compact(
Context::Element(Kind::DataType),
json!({"definition":definition.clone()}),
json!({"definition":definition}),
);
}
for body in [
json!({"Future":{"operation":null,"values":[]}}),
json!({"Exchange":{"exchange":null,"values":[]},"Future":{}}),
] {
let fields = json!({"body":body});
assert_eq!(
rendered(
Context::Element(Kind::Occurrence),
&fields,
Mode::CompleteForEdit
),
fields,
"do not invent a selected Exchange constructor in an unknown wrapper",
);
compact(Context::Element(Kind::Occurrence), fields.clone(), fields);
}
}
+6 -4
View File
@@ -40,6 +40,7 @@ pub enum DiskExpectation {
/// `bundle` retains detached and invalid buffers. Compile `reachable_bundle()`;
/// use `diagnostics` to explain files that discovery could not read or parse.
/// Diagnostic spans describe `bundle` bytes, including before UUID stabilization.
/// Expectations always describe disk bytes before UUID stabilization.
#[derive(Debug, Clone)]
pub struct DiskProject {
@@ -526,10 +527,11 @@ pub fn discover_missing(
result.expectations.keys().chain(result.bundle.files.keys()),
)?;
if result.diagnostics.is_empty() {
match compile_bundle(&result.reachable_bundle(), limits) {
Ok(compiled) => result.diagnostics = compiled.diagnostics,
Err(diagnostics) => result.diagnostics = diagnostics,
}
// Discovery retains raw editor bytes. Compilation may insert UUIDs or
// rewrite older syntax, so its returned locations belong to a different
// bundle. Lint still performs semantic migration and model validation;
// Open/Apply separately compile and install the rewritten source.
result.diagnostics = lint_bundle(&result.reachable_bundle(), limits);
}
let pending = journal_path(&result.root, &result.bundle.entry);
if fs::symlink_metadata(&pending).is_ok() {
+260 -37
View File
@@ -573,16 +573,26 @@ fn compile_once(
span,
}),
};
let decoded =
language::resolve_declarations(header.project, &declarations, &aliases, limits.model)
.map_err(|(index, error)| {
let path = declaration_paths.get(index).unwrap_or(&bundle.entry);
vec![SourceDiagnostic {
path: path.clone(),
diagnostic: language::diagnostic(&bundle.files[path], error),
related: Vec::new(),
}]
})?;
let mut migration_issue = None;
let decoded = language::resolve_declarations(
header.project,
&declarations,
&aliases,
limits.model,
&mut migration_issue,
)
.map_err(|(index, error)| {
let path = declaration_paths.get(index).unwrap_or(&bundle.entry);
vec![SourceDiagnostic {
path: path.clone(),
diagnostic: language::diagnostic_with_issue(
&bundle.files[path],
error,
migration_issue,
),
related: Vec::new(),
}]
})?;
let diagnostics = model_diagnostics(bundle, &index, &decoded.project);
let errors: Vec<_> = diagnostics
.iter()
@@ -726,7 +736,8 @@ fn apply_edits(bundle: &mut SourceBundle, edits: Edits) -> Result<(), Vec<Source
pub fn format_bundle(project: &Project) -> SourceBundle {
let ownership = default_ownership(project);
let aliases: BTreeMap<_, _> = project.identities().into_iter().collect();
let mut declarations = language::format_declarations(project);
let mut declarations =
language::format_declarations(project, language::DeclarationFormat::Compact);
declarations.sort_by_key(|(id, _)| declaration_order(project, *id, &aliases));
let mut files: BTreeMap<LogicalPath, String> = BTreeMap::new();
for (id, declaration) in declarations {
@@ -735,11 +746,13 @@ pub fn format_bundle(project: &Project) -> SourceBundle {
.or_default()
.push_str(&unindent(&declaration));
}
let mut entry = format!(
"arcadia {SCHEMA_VERSION};\n{}{{\n description {};\n",
project_header(project),
quote(&project.description)
);
let mut entry = format!("arcadia {SCHEMA_VERSION};\n{}{{\n", project_header(project));
if !project.description.is_empty() {
entry.push_str(&format!(
" description {};\n",
quote(&project.description)
));
}
for path in files.keys() {
entry.push_str(&format!(" include {};\n", quote(path)));
}
@@ -850,8 +863,25 @@ pub fn update_bundle_with_identity_map(
)]);
}
let origins: BTreeMap<_, _> = identities.iter().map(|(old, new)| (*new, *old)).collect();
let before: BTreeMap<_, _> = language::format_declarations(old).into_iter().collect();
let after: BTreeMap<_, _> = language::format_declarations(project).into_iter().collect();
// Comparison must include defaults even when persistence serialization or
// compact output omits them. Otherwise an unrelated payload edit erases
// unchanged explicit fields from the authored document.
let before: BTreeMap<_, _> =
language::format_declarations(old, language::DeclarationFormat::CompleteForEdit)
.into_iter()
.collect();
let after: BTreeMap<_, _> =
language::format_declarations(project, language::DeclarationFormat::CompleteForEdit)
.into_iter()
.collect();
let compact: BTreeMap<_, _> =
language::format_declarations(project, language::DeclarationFormat::Compact)
.into_iter()
.collect();
let compact_values: BTreeMap<_, _> =
language::format_declarations(project, language::DeclarationFormat::CompactValues)
.into_iter()
.collect();
let old_aliases: BTreeMap<_, _> = old
.identities()
.into_iter()
@@ -907,6 +937,27 @@ pub fn update_bundle_with_identity_map(
};
let mut previous = parse(old_text)?;
let mut next = parse(new_text)?;
let next_id = identities[&id];
let value_text = &compact_values[&next_id];
let insertion = InsertionSyntax::new(
&next.fields,
&parse(value_text)?.fields,
value_text,
&parse(&compact[&next_id])?.fields,
);
let old_kind = previous.header["kind"];
let new_kind = next.header["kind"];
if old_text[old_kind.start..old_kind.end] != new_text[new_kind.start..new_kind.end]
&& let language::EditKind::Record(actual_fields) = &actual.fields.kind
&& let language::EditKind::Record(previous_fields) = &mut previous.fields.kind
{
// An implicit value is supplied by its declaration context.
// After a kind change, an unchanged value may need explicit
// syntax (e.g. BoundaryExchange true -> CommunicationMean true).
// Reconsider only absent fields; authored explicit values still
// compare against their complete old values and keep trivia.
previous_fields.retain(|key, _| actual_fields.contains_key(key));
}
if actual.lexical_owner.is_some()
&& let language::EditKind::Record(fields) = &actual.fields.kind
&& !fields.contains_key("parent")
@@ -923,6 +974,7 @@ pub fn update_bundle_with_identity_map(
source,
before: old_text,
after: new_text,
insertion: Some(&insertion),
old_aliases: &old_aliases,
new_aliases: &new_aliases,
legacy_value_paths: false,
@@ -930,7 +982,18 @@ pub fn update_bundle_with_identity_map(
};
let local = edits.entry(path.clone()).or_default();
update.header(&actual.header, &previous.header, &next.header, local);
update.value(&actual.fields, Some(&previous.fields), &next.fields, local);
let context = if old.diagrams.contains_key(&id) {
EditContext::Diagram
} else {
EditContext::Other
};
update.value(
&actual.fields,
Some(&previous.fields),
&next.fields,
context,
local,
);
}
}
if old.id != project.id || old.alias != project.alias || old.name != project.name {
@@ -953,6 +1016,7 @@ pub fn update_bundle_with_identity_map(
source,
before: &before,
after: &after,
insertion: None,
old_aliases: &old_aliases,
new_aliases: &new_aliases,
legacy_value_paths: false,
@@ -1011,7 +1075,7 @@ pub fn update_bundle_with_identity_map(
project,
identities,
&original_order,
&after,
&compact,
limits,
)?;
let result = compile_bundle(&bundle, limits)?;
@@ -1119,10 +1183,96 @@ fn inserted_value(text: &str, indent: &str, source: &str) -> String {
output
}
// Value fragments retain required payload fields even when the enclosing
// declaration field is itself omittable. For example, a selected String type
// needs minimum_length; an Event Signal needs items. The complete tree remains
// the comparison authority; this parallel tree is only an insertion template.
struct InsertionSyntax<'a> {
source: &'a str,
values: BTreeMap<usize, Span>,
omitted_fields: BTreeSet<usize>,
}
impl<'a> InsertionSyntax<'a> {
fn new(
complete: &language::EditValue,
compact_values: &language::EditValue,
source: &'a str,
compact: &language::EditValue,
) -> Self {
let mut result = Self {
source,
values: BTreeMap::new(),
omitted_fields: BTreeSet::new(),
};
result.collect(complete, compact_values);
if let (language::EditKind::Record(all), language::EditKind::Record(kept)) =
(&complete.kind, &compact.kind)
{
for (key, field) in all {
if !kept.contains_key(key) {
result.omitted_fields.insert(field.value.span.start);
}
}
}
result
}
fn collect(&mut self, complete: &language::EditValue, compact: &language::EditValue) {
// Borrow the existing compact text once. Copying each containing record
// here would duplicate a large nested payload at every depth.
self.values.insert(complete.span.start, compact.span);
match (&complete.kind, &compact.kind) {
(language::EditKind::Record(all), language::EditKind::Record(kept)) => {
for (key, field) in all {
if let Some(next) = kept.get(key) {
self.collect(&field.value, &next.value);
} else {
self.omitted_fields.insert(field.value.span.start);
}
}
}
(language::EditKind::List(all), language::EditKind::List(kept)) => {
assert_eq!(
all.len(),
kept.len(),
"compaction preserves array membership"
);
for (field, next) in all.iter().zip(kept) {
self.collect(field, next);
}
}
_ => {}
}
}
}
// Schema context is explicit: similarly named fields in arbitrary records must
// never acquire Scenario row identity or move their authored trivia.
#[derive(Clone, Copy)]
enum EditContext {
Other,
Diagram,
Layout,
Scenario,
Lanes,
Messages,
}
impl EditContext {
fn field(self, name: &str) -> Self {
match (self, name) {
(Self::Diagram, "layout") => Self::Layout,
(Self::Layout, "Scenario") => Self::Scenario,
(Self::Scenario, "lanes") => Self::Lanes,
(Self::Scenario, "messages") => Self::Messages,
_ => Self::Other,
}
}
}
struct SyntaxUpdate<'a> {
source: &'a str,
before: &'a str,
after: &'a str,
insertion: Option<&'a InsertionSyntax<'a>>,
old_aliases: &'a BTreeMap<String, String>,
new_aliases: &'a BTreeMap<String, String>,
// Source3 migration only adds path qualification; the existing field identity
@@ -1131,6 +1281,16 @@ struct SyntaxUpdate<'a> {
identity_origins: Option<&'a BTreeMap<Id, Id>>,
}
impl SyntaxUpdate<'_> {
fn inserted(&self, value: &language::EditValue) -> &str {
self.insertion
.and_then(|syntax| {
syntax
.values
.get(&value.span.start)
.map(|span| &syntax.source[span.start..span.end])
})
.unwrap_or(&self.after[value.span.start..value.span.end])
}
fn header(
&self,
actual: &BTreeMap<&str, Span>,
@@ -1185,6 +1345,7 @@ impl SyntaxUpdate<'_> {
actual: &language::EditValue,
before: Option<&language::EditValue>,
after: &language::EditValue,
context: EditContext,
edits: &mut LocalEdits,
) {
use language::EditKind;
@@ -1200,7 +1361,7 @@ impl SyntaxUpdate<'_> {
None
}
});
self.record(actual.span, fields, previous, next, edits);
self.record(actual.span, fields, previous, next, context, edits);
}
(EditKind::List(items), EditKind::List(next)) => {
let previous = before.and_then(|old| {
@@ -1210,7 +1371,7 @@ impl SyntaxUpdate<'_> {
None
}
});
self.list(actual.span, items, previous, next, edits);
self.list(actual.span, items, previous, next, context, edits);
}
(EditKind::Reference(_), EditKind::Reference(alias)) => {
let token = significant(&self.source[actual.span.start..actual.span.end])
@@ -1243,7 +1404,7 @@ impl SyntaxUpdate<'_> {
_ => {
erase_syntax(self.source, actual.span, edits);
let text = inserted_value(
&self.after[after.span.start..after.span.end],
self.inserted(after),
indent_at(self.source, actual.span.start),
self.source,
);
@@ -1263,6 +1424,7 @@ impl SyntaxUpdate<'_> {
actual: &BTreeMap<String, language::EditField>,
before: Option<&BTreeMap<String, language::EditField>>,
after: &BTreeMap<String, language::EditField>,
context: EditContext,
edits: &mut LocalEdits,
) {
// A typed constructor tag change retains shared fields and their trivia.
@@ -1286,7 +1448,13 @@ impl SyntaxUpdate<'_> {
},
new_key.clone(),
));
self.value(&field.value, Some(&old.value), &new.value, edits);
self.value(
&field.value,
Some(&old.value),
&new.value,
context.field(new_key),
edits,
);
return;
}
}
@@ -1298,9 +1466,16 @@ impl SyntaxUpdate<'_> {
.and_then(|fields| fields.get(key))
.map(|field| &field.value),
&next.value,
context.field(key),
edits,
);
} else {
} else if !(matches!(context, EditContext::Diagram)
&& key == "layout"
&& before.is_some_and(|fields| !fields.contains_key(key)))
{
// Canonical None omits layout. Preserve an authored explicit
// none while another field changes; a real Some->None clear
// still removes the former payload and retains its trivia.
erase_syntax(self.source, field.span, edits);
}
}
@@ -1308,6 +1483,9 @@ impl SyntaxUpdate<'_> {
.iter()
.filter(|(key, field)| {
!actual.contains_key(*key)
&& self.insertion.is_none_or(|syntax| {
!syntax.omitted_fields.contains(&field.value.span.start)
})
&& before
.and_then(|fields| fields.get(*key))
.is_none_or(|old| !self.same(&old.value, &field.value))
@@ -1326,11 +1504,7 @@ impl SyntaxUpdate<'_> {
.unwrap_or_else(|| format!("{} ", indent_at(self.source, span.start)));
let mut text = String::new();
for (key, field) in added {
let value = inserted_value(
&self.after[field.value.span.start..field.value.span.end],
&indent,
self.source,
);
let value = inserted_value(self.inserted(&field.value), &indent, self.source);
if multiline {
text.push_str(&indent);
text.push_str(&format!("{key} {value};{}", newline(self.source)));
@@ -1355,6 +1529,7 @@ impl SyntaxUpdate<'_> {
actual: &[language::EditValue],
before: Option<&[language::EditValue]>,
after: &[language::EditValue],
context: EditContext,
edits: &mut LocalEdits,
) {
let previous = before.unwrap_or(&[]);
@@ -1367,6 +1542,7 @@ impl SyntaxUpdate<'_> {
self.old_aliases,
self.legacy_value_paths,
None,
context,
)
})
.collect();
@@ -1379,6 +1555,7 @@ impl SyntaxUpdate<'_> {
self.new_aliases,
self.legacy_value_paths,
self.identity_origins,
context,
)
})
.collect();
@@ -1407,7 +1584,7 @@ impl SyntaxUpdate<'_> {
.all(|(i, source)| *source == Some(i))
{
for (i, (current, next)) in actual.iter().zip(after).enumerate() {
self.value(current, previous.get(i), next, edits);
self.value(current, previous.get(i), next, EditContext::Other, edits);
}
return;
}
@@ -1440,7 +1617,13 @@ impl SyntaxUpdate<'_> {
let chunk = chunks[index];
text.push_str(&self.source[chunk.start..current.span.start]);
let mut local = Vec::new();
self.value(current, previous.get(index), next, &mut local);
self.value(
current,
previous.get(index),
next,
EditContext::Other,
&mut local,
);
text.push_str(&changed_text(self.source, current.span, local));
suffix = &self.source[current.span.end..chunk.end];
} else {
@@ -1450,11 +1633,7 @@ impl SyntaxUpdate<'_> {
} else {
text.push(' ');
}
text.push_str(&inserted_value(
&self.after[next.span.start..next.span.end],
&indent,
self.source,
));
text.push_str(&inserted_value(self.inserted(next), &indent, self.source));
suffix = "";
}
if i + 1 < after.len() {
@@ -1483,14 +1662,58 @@ impl SyntaxUpdate<'_> {
}
}
fn scenario_item_key(
value: &language::EditValue,
aliases: &BTreeMap<String, String>,
context: EditContext,
) -> Option<String> {
use language::EditKind;
let EditKind::Record(fields) = &value.kind else {
return None;
};
let reference = |value: &language::EditValue| {
if let EditKind::Reference(alias) = &value.kind {
aliases.get(alias).cloned()
} else {
None
}
};
match context {
EditContext::Lanes => {
reference(&fields.get("lifeline")?.value).map(|id| format!("scenario-lane:{id}"))
}
EditContext::Messages => {
let EditKind::Record(address) = &fields.get("address")?.value.kind else {
return None;
};
let occurrence = reference(&address.get("occurrence")?.value)?;
let path: Vec<_> = match address.get("references") {
None => Vec::new(),
Some(field) => {
let EditKind::List(path) = &field.value.kind else {
return None;
};
path.iter().map(reference).collect::<Option<Vec<_>>>()?
}
};
Some(format!("scenario-message:{}:{occurrence}", path.join("/")))
}
_ => None,
}
}
fn item_key(
value: &language::EditValue,
source: &str,
aliases: &BTreeMap<String, String>,
legacy_value_paths: bool,
origins: Option<&BTreeMap<Id, Id>>,
context: EditContext,
) -> Option<String> {
use language::EditKind;
if matches!(context, EditContext::Lanes | EditContext::Messages) {
return scenario_item_key(value, aliases, context);
}
if matches!(value.kind, EditKind::Record(_))
&& let Some(key) = language::semantic_row_key(
&source[value.span.start..value.span.end],
@@ -11,7 +11,10 @@ pub(super) fn upgrade(
.as_ref()
.expect("an explicit source migration report");
let project = &current.decoded.project;
let canonical: BTreeMap<_, _> = language::format_declarations(project).into_iter().collect();
let canonical: BTreeMap<_, _> =
language::format_declarations(project, language::DeclarationFormat::CompleteForEdit)
.into_iter()
.collect();
let changed: BTreeSet<_> = report
.semantic_defaults
.iter()
@@ -59,6 +62,7 @@ pub(super) fn upgrade(
source,
before: source,
after,
insertion: None,
old_aliases: &aliases,
new_aliases: &aliases,
legacy_value_paths: true,
@@ -68,6 +72,11 @@ pub(super) fn upgrade(
&actual.fields,
Some(&actual.fields),
&next.fields,
if project.diagrams.contains_key(&id) {
EditContext::Diagram
} else {
EditContext::Other
},
edits.entry(path.clone()).or_default(),
);
}
+12 -2
View File
@@ -750,7 +750,7 @@ pub fn plan_transition(
);
continue;
}
if !compatible_existing(project, *source, target, request.direction) {
if !counterpart_compatible(project, *source, target, request.direction) {
conflict(
&mut plan,
Some(*source),
@@ -1317,12 +1317,22 @@ fn compatible_kinds(source: Kind, target: Kind, direction: TransitionDirection)
}
inferred_kind(source, direction) == Some(target)
}
fn compatible_existing(
/// Whether existing records have compatible layers, categories and kinds for
/// an ordinary transition. This is candidate eligibility, not approval of a
/// complete plan: properties, dependencies and structural conflicts still need
/// the normal preview and apply validation. Manual traces are not restricted
/// by this predicate.
pub fn counterpart_compatible(
p: &Project,
source: Id,
target: Id,
direction: TransitionDirection,
) -> bool {
if p.layer_of(source) != Some(direction.source_layer())
|| p.layer_of(target) != Some(direction.target_layer())
{
return false;
}
match (p.elements.get(&source), p.elements.get(&target)) {
(Some(a), Some(b)) => compatible_kinds(a.kind(), b.kind(), direction),
_ => match (p.relations.get(&source), p.relations.get(&target)) {
+27 -7
View File
@@ -619,11 +619,15 @@ 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)
};
crate::scenario_layout::validate_diagram(p, d, &mut out);
// Ordinary projections are reversible filters over the same authored view.
// Retain opposite-layer visibility choices when leaving Traceability.
let option_issues =
if d.projection.state_machine().is_none() && d.projection.scenario().is_none() {
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);
}
@@ -643,6 +647,20 @@ pub fn validate(p: &Project) -> Vec<Issue> {
}
let mut routed_edges = BTreeSet::new();
for (index, route) in d.routes.iter().enumerate() {
for attachment in [route.source_attachment, route.target_attachment]
.into_iter()
.flatten()
{
if !attachment.offset.is_finite() || !(0.0..=1.0).contains(&attachment.offset) {
error(
&mut out,
d.id,
"invalid_attachment_offset",
"routes",
"Connection attachment offset must be finite and between zero and one.",
);
}
}
if !routed_edges.insert(route.edge) {
error(
&mut out,
@@ -783,8 +801,10 @@ pub fn validate(p: &Project) -> Vec<Issue> {
);
}
match p.elements.get(&n.element) {
Some(e) if d.projection == ProjectionKind::Traceability || e.layer() == d.layer => {
}
Some(e)
if (d.projection.state_machine().is_none()
&& d.projection.scenario().is_none())
|| e.layer() == d.layer => {}
_ => error(
&mut out,
n.id,
+3 -1
View File
@@ -218,7 +218,9 @@ pub fn validate_options(p: &Project, layer: Layer, options: &ViewOptions) -> Vec
validate_options_in(p, Some(layer), options)
}
/// Traceability views admit references from either architecture layer.
/// Ordinary saved views retain references from either architecture layer so
/// projection changes preserve authored visibility choices. Projection decides
/// which layer is visible; typed State/Scenario views use layer-scoped validation.
pub fn validate_options_cross_layer(p: &Project, options: &ViewOptions) -> Vec<OptionError> {
validate_options_in(p, None, options)
}
+385
View File
@@ -0,0 +1,385 @@
use oa_core::{projection::*, routing::*, *};
fn pos(x: f32, y: f32) -> Position {
Position { x, y }
}
fn anchor(side: PortAnchorSide, offset: f32) -> EdgeAttachment {
EdgeAttachment { side, offset }
}
fn fixture() -> (Project, Id, Id, Id, Id) {
let mut p = Project::empty();
let a = p
.add_element(Kind::Activity, Layer::Oa, "Report", None)
.unwrap();
let b = p
.add_element(Kind::Activity, Layer::Oa, "Receive", None)
.unwrap();
let edge = p.relate(RelationKind::Interaction, "Report status", a, b);
let d = p.ensure_diagram(Layer::Oa);
p.place(d, a, pos(50., 50.));
p.place(d, b, pos(750., 200.));
(p, d, a, b, edge)
}
fn stored(edge: Id) -> EdgeRoute {
EdgeRoute {
edge,
source_attachment: None,
target_attachment: None,
waypoints: vec![],
label: None,
}
}
fn routed(p: &Project, d: Id) -> RoutedView {
let diagram = &p.diagrams[&d];
route_diagram(p, diagram, &project_diagram(p, diagram))
}
fn near(a: f32, b: f32) {
assert!((a - b).abs() < 0.002, "{a} != {b}");
}
fn orthogonal(points: &[Position]) {
assert!(points.len() >= 2);
for span in points.windows(2) {
assert!(span[0].x == span[1].x || span[0].y == span[1].y, "{span:?}");
}
}
fn length(points: &[Position]) -> f32 {
points
.windows(2)
.map(|p| (p[0].x - p[1].x).abs() + (p[0].y - p[1].y).abs())
.sum()
}
#[test]
fn authored_boundary_follows_projected_owner_and_preview_matches_committed_geometry() {
let (mut p, d, a, _, edge) = fixture();
let initial = routed(&p, d);
let mut route = stored(edge);
route.source_attachment = Some(anchor(PortAnchorSide::Bottom, 0.25));
route.target_attachment = Some(anchor(PortAnchorSide::Top, 0.75));
route.waypoints = vec![pos(460., 350.)];
p.diagrams.get_mut(&d).unwrap().routes.push(route.clone());
let view = project_diagram(&p, &p.diagrams[&d]);
let preview = route_edge_preview(&p, &p.diagrams[&d], &view, &initial, edge);
let full = routed(&p, d);
assert_eq!(
preview, full,
"attachment-only changes must invalidate the label-only path cache"
);
let n = view.nodes.iter().find(|n| n.element == a).unwrap();
assert_eq!(
full.edges[&edge].points[0],
pos(
n.position.x + n.size.width * 0.25,
n.position.y + n.size.height
)
);
assert_eq!(full.edges[&edge].pins, route.waypoints);
orthogonal(&full.edges[&edge].points);
let node = p
.diagrams
.get_mut(&d)
.unwrap()
.nodes
.values_mut()
.find(|n| n.element == a)
.unwrap();
node.position.x += 75.;
node.size.width += 100.;
node.size.height += 50.;
let moved = routed(&p, d);
near(
moved.edges[&edge].points[0].x - full.edges[&edge].points[0].x,
100.,
);
near(
moved.edges[&edge].points[0].y - full.edges[&edge].points[0].y,
50.,
);
assert_eq!(p.diagrams[&d].routes[0], route);
}
#[test]
fn attachment_only_preview_cannot_reuse_old_endpoints_with_unchanged_pins() {
let (mut p, d, _, _, edge) = fixture();
let baseline = routed(&p, d);
let mut route = stored(edge);
route.source_attachment = Some(anchor(PortAnchorSide::Top, 0.2));
p.diagrams.get_mut(&d).unwrap().routes.push(route);
let view = project_diagram(&p, &p.diagrams[&d]);
let preview = route_edge_preview(&p, &p.diagrams[&d], &view, &baseline, edge);
assert_ne!(preview.edges[&edge].points, baseline.edges[&edge].points);
assert_eq!(preview, routed(&p, d));
}
#[test]
fn ports_remain_authoritative_even_when_projection_filters_the_port() {
let mut p = Project::empty();
let a = p
.add_element(Kind::Function, Layer::Sa, "Send", None)
.unwrap();
let b = p
.add_element(Kind::Function, Layer::Sa, "Receive", None)
.unwrap();
let edge = p.relate(RelationKind::FunctionalExchange, "Data", a, b);
let d = p.ensure_diagram(Layer::Sa);
p.place(d, a, pos(0., 0.));
p.place(d, b, pos(600., 0.));
let diagram = p.diagrams[&d].clone();
let view = project_diagram(&p, &diagram);
let expected = route_diagram(&p, &diagram, &view);
assert!(semantic_port_endpoint(&p, edge, EdgeEnd::Source).is_some());
let mut route = stored(edge);
route.source_attachment = Some(anchor(PortAnchorSide::Bottom, 0.1));
route.target_attachment = Some(anchor(PortAnchorSide::Top, 0.9));
p.diagrams.get_mut(&d).unwrap().routes.push(route);
assert_eq!(routed(&p, d), expected);
let mut hidden = view.clone();
hidden.ports.clear();
for e in &mut hidden.edges {
e.source_port = None;
e.target_port = None;
}
let absent = route_diagram(&p, &diagram, &hidden);
assert_eq!(
route_diagram(&p, &p.diagrams[&d], &hidden),
absent,
"hidden ports must not activate a dormant node preference"
);
}
fn state(kind: Kind) -> (Project, Id, Id, Id) {
let mut p = Project::empty();
let owner = p
.add_element(Kind::Entity, Layer::Oa, "Crew", None)
.unwrap();
let machine = p
.add_element(Kind::StateMachine, Layer::Oa, "Modes", Some(owner))
.unwrap();
let region = p
.add_element(Kind::Region, Layer::Oa, "Main", Some(machine))
.unwrap();
let a = p.add_element(kind, Layer::Oa, "A", Some(region)).unwrap();
let b = p
.add_element(Kind::State, Layer::Oa, "B", Some(region))
.unwrap();
let edge = p.relate(RelationKind::StateTransition, "Next", a, b);
let d = p.ensure_diagram(Layer::Oa);
p.diagrams.get_mut(&d).unwrap().projection = ProjectionKind::StateMachine { machine };
p.place(d, a, pos(100., 100.));
p.place(d, b, pos(600., 100.));
(p, d, a, edge)
}
#[test]
fn nonrectangular_state_attachments_intersect_the_painted_shape() {
for kind in [
Kind::Initial,
Kind::Junction,
Kind::Final,
Kind::Choice,
Kind::ShallowHistory,
Kind::DeepHistory,
Kind::EntryPoint,
Kind::ExitPoint,
Kind::Terminate,
Kind::Fork,
Kind::Join,
Kind::State,
] {
let (p, d, a, _) = state(kind);
let diagram = &p.diagrams[&d];
let view = project_diagram(&p, diagram);
let n = view.nodes.iter().find(|n| n.element == a).unwrap();
for side in [
PortAnchorSide::Left,
PortAnchorSide::Right,
PortAnchorSide::Top,
PortAnchorSide::Bottom,
] {
for offset in [0., 0.2, 0.5, 0.8, 1.] {
let at = attachment_point(&p, diagram, n, &anchor(side, offset));
let x = (at.x - n.position.x) / n.size.width;
let y = (at.y - n.position.y) / n.size.height;
match kind {
Kind::Choice => near((2. * x - 1.).abs() + (2. * y - 1.).abs(), 1.),
Kind::Terminate => near((x - y).abs().min((x + y - 1.).abs()), 0.),
Kind::Fork | Kind::Join => near(x.min(1. - x).min(y.min(1. - y)), 0.),
Kind::State => {
assert!((0. ..=1.).contains(&x) && (0. ..=1.).contains(&y));
if offset == 0. || offset == 1. {
assert!(
x != 0. && x != 1. || y != 0. && y != 1.,
"rounded corner cannot use bounding-box corner"
);
}
}
_ => near((2. * x - 1.).powi(2) + (2. * y - 1.).powi(2), 1.),
}
}
}
}
}
#[test]
fn inset_contour_leads_keep_pin_fractions_and_labels_on_the_complete_path() {
let (mut p, d, _, edge) = state(Kind::Choice);
let mut route = stored(edge);
route.source_attachment = Some(anchor(PortAnchorSide::Right, 0.));
route.waypoints = vec![pos(380., 40.)];
route.label = Some(EdgeLabel {
fraction: 0.3,
offset: pos(0., 0.),
});
p.diagrams.get_mut(&d).unwrap().routes.push(route);
let result = routed(&p, d);
let r = &result.edges[&edge];
orthogonal(&r.points);
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
let pin = r.pins[0];
let pin_index = r.points.iter().position(|p| *p == pin).unwrap();
near(
r.pin_fractions[0],
length(&r.points[..=pin_index]) / length(&r.points),
);
assert_eq!(
r.label.anchor,
point_along_polyline(&r.points, 0.3).unwrap()
);
}
#[test]
fn small_symbol_drag_chooses_the_intended_exit_side_at_low_zoom() {
for kind in [
Kind::Initial,
Kind::Choice,
Kind::Terminate,
Kind::Fork,
Kind::State,
] {
let (p, d, a, _) = state(kind);
let view = project_diagram(&p, &p.diagrams[&d]);
let n = view.nodes.iter().find(|n| n.element == a).unwrap();
for zoom in [0.5, 1., 2.] {
for overshoot in [0., 20.] {
let cursor = pos(n.position.x + n.size.width / 2., n.position.y - overshoot);
let chosen =
attachment_from_point(n, cursor, Some(PortAnchorSide::Right), 6. / zoom);
assert_eq!(
chosen,
anchor(PortAnchorSide::Top, 0.5),
"{kind:?}, zoom {zoom}, overshoot {overshoot}"
);
let painted = attachment_point(&p, &p.diagrams[&d], n, &chosen);
near(painted.x, n.position.x + n.size.width / 2.);
near(
painted.y,
n.position.y
+ if kind == Kind::Terminate {
n.size.height / 2.
} else {
0.
},
);
}
let near_corner = pos(n.position.x + n.size.width - 1., n.position.y + 1.);
let stable =
attachment_from_point(n, near_corner, Some(PortAnchorSide::Right), 6. / zoom);
assert_eq!(
stable.side,
PortAnchorSide::Right,
"corner hysteresis stays stable"
);
}
}
}
#[test]
fn coincident_self_attachments_retain_hard_pins_and_form_a_visible_loop() {
let (mut p, d, a, edge) = state(Kind::State);
if let RelationBody::StateTransition { target, .. } =
&mut p.relations.get_mut(&edge).unwrap().body
{
*target = a;
}
for pins in [vec![], vec![pos(400., 30.)]] {
let mut route = stored(edge);
route.source_attachment = Some(anchor(PortAnchorSide::Top, 0.5));
route.target_attachment = route.source_attachment;
route.waypoints = pins.clone();
p.diagrams.get_mut(&d).unwrap().routes = vec![route.clone()];
let result = routed(&p, d);
let r = &result.edges[&edge];
orthogonal(&r.points);
assert_eq!(r.points.first(), r.points.last());
assert!(length(&r.points) > 2. * PORT_STUB + 70.);
assert_eq!(r.pins, pins);
assert_eq!(r.pin_fractions.len(), pins.len());
assert!(
r.warnings.contains(&RouteWarning::CoincidentAttachments),
"{:?}",
r.warnings
);
assert_eq!(routed(&p, d), result, "deterministic fallback");
assert_eq!(p.diagrams[&d].routes[0], route);
}
}
#[test]
fn coincident_loop_chooses_clear_side_instead_of_inventing_an_obstructed_pin() {
for blocker_side in [-1., 1.] {
let (mut p, d, a, edge) = state(Kind::State);
if let RelationBody::StateTransition { target, .. } =
&mut p.relations.get_mut(&edge).unwrap().body
{
*target = a;
}
let mut route = stored(edge);
route.source_attachment = Some(anchor(PortAnchorSide::Top, 0.5));
route.target_attachment = route.source_attachment;
p.diagrams.get_mut(&d).unwrap().routes.push(route);
let view = project_diagram(&p, &p.diagrams[&d]);
let source = view.nodes.iter().find(|n| n.element == a).unwrap();
let tip = attachment_point(
&p,
&p.diagrams[&d],
source,
&anchor(PortAnchorSide::Top, 0.5),
);
let region = p.elements[&a].parent;
let blocker = p
.add_element(Kind::State, Layer::Oa, "Detour obstruction", region)
.unwrap();
let left = if blocker_side > 0. {
tip.x + 30.
} else {
tip.x - 30. - 224.
};
p.place(d, blocker, pos(left, tip.y - 144.));
let result = routed(&p, d);
let r = &result.edges[&edge];
assert_eq!(r.warnings, vec![RouteWarning::CoincidentAttachments]);
orthogonal(&r.points);
let view = project_diagram(&p, &p.diagrams[&d]);
let n = view.nodes.iter().find(|n| n.element == blocker).unwrap();
for segment in r.points.windows(2) {
for step in 0..=100 {
let t = step as f32 / 100.;
let q = pos(
segment[0].x + (segment[1].x - segment[0].x) * t,
segment[0].y + (segment[1].y - segment[0].y) * t,
);
assert!(
!(q.x > n.position.x - CLEARANCE
&& q.x < n.position.x + n.size.width + CLEARANCE
&& q.y > n.position.y - CLEARANCE
&& q.y < n.position.y + n.size.height + CLEARANCE),
"route crosses blocker: {q:?}"
);
}
}
assert!(
r.pins.is_empty(),
"generated detours must never become authored pins"
);
}
}
@@ -0,0 +1,272 @@
use oa_core::{language, projection::*, routing::*, *};
fn pos(x: f32, y: f32) -> Position {
Position { x, y }
}
fn id(project: &Project, alias: &str) -> Id {
project
.identities()
.into_iter()
.find(|(_, a)| *a == alias)
.unwrap()
.0
}
fn authored(edge: Id) -> EdgeRoute {
EdgeRoute {
edge,
source_attachment: Some(EdgeAttachment {
side: PortAnchorSide::Bottom,
offset: 0.2,
}),
target_attachment: Some(EdgeAttachment {
side: PortAnchorSide::Top,
offset: 0.8,
}),
waypoints: vec![pos(520., 420.)],
label: Some(EdgeLabel {
fraction: 0.35,
offset: pos(17., -23.),
}),
}
}
fn routed(project: &Project, diagram: Id) -> RoutedView {
let d = &project.diagrams[&diagram];
route_diagram(project, d, &project_diagram(project, d))
}
fn fixture() -> (Project, Id, Id, Id, Id, Id, Id) {
let mut p = language::parse(
r#"arcadia 7;
project lifecycle "Attachment lifecycle" {
oa_activity group "Group" { oa_activity first "First" {} }
oa_activity second "Second" {}
oa_activity replacement "Replacement" {}
interaction signal "Signal" { source @first; target @second; }
diagram view "View" {}
diagram other "Other" {}
}"#,
)
.unwrap();
let (d, other, group, first, second, replacement, edge) = (
id(&p, "view"),
id(&p, "other"),
id(&p, "group"),
id(&p, "first"),
id(&p, "second"),
id(&p, "replacement"),
id(&p, "signal"),
);
for diagram in [d, other] {
p.place(diagram, group, pos(20., 20.));
p.place(diagram, first, pos(100., 130.));
p.place(diagram, second, pos(750., 150.));
p.place(diagram, replacement, pos(750., 570.));
p.diagrams
.get_mut(&diagram)
.unwrap()
.routes
.push(authored(edge));
}
(p, d, other, group, first, replacement, edge)
}
#[test]
fn attachment_fold_promotion_suspends_the_complete_route_and_expand_restores_it() {
let (mut p, d, other, group, _, _, edge) = fixture();
assert_eq!(semantic_port_endpoint(&p, edge, EdgeEnd::Source), None);
assert_eq!(semantic_port_endpoint(&p, edge, EdgeEnd::Target), None);
let before = p.clone();
let expanded = routed(&p, d);
let other_geometry = routed(&p, other);
let parent = p.diagrams[&d]
.nodes
.values()
.find(|n| n.element == group)
.unwrap()
.id;
p.diagrams
.get_mut(&d)
.unwrap()
.nodes
.get_mut(&parent)
.unwrap()
.collapsed = true;
let folded = routed(&p, d);
assert!(folded.edges[&edge].custom_suspended);
assert_eq!(folded.edges[&edge].pins, []);
assert!(
folded.edges[&edge]
.warnings
.contains(&RouteWarning::PromotedEndpoints)
);
assert_eq!(p.diagrams[&d].routes, before.diagrams[&d].routes);
let mut automatic = p.clone();
automatic.diagrams.get_mut(&d).unwrap().routes.clear();
assert_eq!(
folded,
routed(&automatic, d),
"attachments, pins and label must all be dormant on promotion"
);
assert_eq!(p.diagrams[&other], before.diagrams[&other]);
assert_eq!(routed(&p, other), other_geometry);
p.diagrams
.get_mut(&d)
.unwrap()
.nodes
.get_mut(&parent)
.unwrap()
.collapsed = false;
assert_eq!(p, before);
assert_eq!(routed(&p, d), expanded);
}
#[test]
fn attachment_reconnect_retains_slot_preferences_and_other_view_records_with_exact_history() {
let (p, d, other, _, _, replacement, edge) = fixture();
let before = routed(&p, d);
let mut relation = p.relations[&edge].clone();
let RelationBody::Interaction { target, .. } = &mut relation.body else {
unreachable!()
};
*target = replacement;
let mut expected = p.clone();
expected.relations.insert(edge, relation.clone());
let mut session = Session::new(p.clone());
session
.execute(Command::EditRelation(edge, relation))
.unwrap();
assert_eq!(
session.project(),
&expected,
"reconnect changes only the semantic endpoint; all per-view records remain exact"
);
assert_eq!(session.project().diagrams[&other], p.diagrams[&other]);
for diagram in [d, other] {
let current = session.project();
let view = project_diagram(current, &current.diagrams[&diagram]);
let node = view
.nodes
.iter()
.find(|n| n.element == replacement)
.unwrap();
let preference = current.diagrams[&diagram].routes[0]
.target_attachment
.unwrap();
let expected_tip =
attachment_point(current, &current.diagrams[&diagram], node, &preference);
let result = routed(current, diagram);
assert_eq!(result.edges[&edge].points.last(), Some(&expected_tip));
assert_eq!(
result.edges[&edge].pins,
p.diagrams[&diagram].routes[0].waypoints
);
}
assert_ne!(
routed(session.project(), d).edges[&edge].points.last(),
before.edges[&edge].points.last()
);
session.undo();
assert_eq!(session.project(), &p);
assert_eq!(routed(session.project(), d), before);
session.redo();
assert_eq!(session.project(), &expected);
}
#[test]
fn attachment_direct_port_endpoints_remain_authoritative_when_their_glyphs_are_filtered() {
let mut p = Project::empty();
let system = p
.add_element(Kind::System, Layer::Sa, "System", None)
.unwrap();
let actor = p
.add_element(Kind::SystemActor, Layer::Sa, "Actor", None)
.unwrap();
let function = p
.add_element(Kind::Function, Layer::Sa, "Send", None)
.unwrap();
let mut ports = Vec::new();
for (owner, name, contract) in [
(
system,
"Output",
PortContract::Component {
kind: ComponentPortKind::Flow,
orientation: ComponentPortOrientation::Out,
},
),
(
actor,
"Input",
PortContract::Component {
kind: ComponentPortKind::Flow,
orientation: ComponentPortOrientation::In,
},
),
(
function,
"Function output",
PortContract::FunctionOutput {
outgoing_items: vec![],
},
),
] {
let port = Id::new_v4();
p.elements
.insert(port, make_port(&p, port, owner, name, contract).unwrap());
ports.push(port);
}
p.relate(
RelationKind::Allocation,
"Function allocation",
system,
function,
);
let boundary = p.relate(
RelationKind::BoundaryExchange,
"Boundary",
ports[0],
ports[1],
);
let allocation = p.relate(
RelationKind::PortAllocation,
"Port allocation",
ports[0],
ports[2],
);
let d = p.ensure_diagram(Layer::Sa);
p.place(d, system, pos(0., 0.));
p.place(d, actor, pos(700., 0.));
p.place(d, function, pos(0., 450.));
let original = p.clone();
let baseline = routed(&p, d);
for (edge, target) in [(boundary, ports[1]), (allocation, ports[2])] {
assert!(baseline.edges.contains_key(&edge));
assert_eq!(
semantic_port_endpoint(&p, edge, EdgeEnd::Source),
Some(ports[0])
);
assert_eq!(
semantic_port_endpoint(&p, edge, EdgeEnd::Target),
Some(target)
);
let mut route = authored(edge);
route.waypoints.clear();
route.label = None;
p.diagrams.get_mut(&d).unwrap().routes.push(route);
}
assert_eq!(routed(&p, d), baseline);
let mut hidden = project_diagram(&p, &p.diagrams[&d]);
hidden.ports.clear();
for edge in &mut hidden.edges {
edge.source_port = None;
edge.target_port = None;
}
assert_eq!(
route_diagram(&p, &p.diagrams[&d], &hidden),
route_diagram(&original, &original.diagrams[&d], &hidden),
"filtered direct Port endpoints must not activate their stored preferences"
);
}
@@ -0,0 +1,136 @@
use oa_core::{projection::*, routing::*, *};
#[test]
fn valid_item_trace_follows_manual_port_anchors_and_ignores_dormant_attachments() {
let mut project = Project::empty();
let oa = project
.add_element(Kind::Activity, Layer::Oa, "Send", None)
.unwrap();
let sa = project
.add_element(Kind::Function, Layer::Sa, "Receive", None)
.unwrap();
let ports = [(oa, Layer::Oa), (sa, Layer::Sa)].map(|(owner, layer)| {
let id = Id::new_v4();
project.elements.insert(
id,
Element {
id,
alias: format!("port_{}", id.simple()),
name: "Status port".into(),
description: "Existing legacy port retained by Item trace".into(),
parent: Some(owner),
body: ElementBody::shared_in(
layer,
SharedBody::Port {
contract: PortContract::Legacy,
legacy: Some(LegacyPortContract {
direction: PortDirection::InOut,
interface: None,
}),
},
),
},
);
id
});
let trace = Trace {
id: Id::new_v4(),
alias: "status_correspondence".into(),
name: "Status correspondence".into(),
description: "Preserve this semantic evidence".into(),
oa: ports[0],
sa: ports[1],
kind: TraceKind::Item,
baseline: None,
};
project.traces.insert(trace.id, trace.clone());
let diagram = project.ensure_diagram(Layer::Oa);
project.diagrams.get_mut(&diagram).unwrap().projection = ProjectionKind::Traceability;
let owners = [
project.place(diagram, oa, Position { x: 100., y: 100. }),
project.place(diagram, sa, Position { x: 800., y: 300. }),
];
validate_candidate(&project).unwrap();
let semantic_elements = project.elements.clone();
let semantic_relations = project.relations.clone();
let semantic_traces = project.traces.clone();
let view = project_diagram(&project, &project.diagrams[&diagram]);
let edge = view.edges.iter().find(|edge| edge.id == trace.id).unwrap();
assert!(edge.trace);
assert_eq!((edge.source, edge.target), (owners[0], owners[1]));
assert_eq!((edge.source_port, edge.target_port), (None, None));
let initial = route_diagram(&project, &project.diagrams[&diagram], &view);
for (index, (side, offset)) in [(PortAnchorSide::Top, 0.25), (PortAnchorSide::Bottom, 0.75)]
.into_iter()
.enumerate()
{
project
.diagrams
.get_mut(&diagram)
.unwrap()
.nodes
.get_mut(&owners[index])
.unwrap()
.port_anchors
.push(PortAnchor {
port: ports[index],
side,
offset,
});
}
validate_candidate(&project).unwrap();
let moved_view = project_diagram(&project, &project.diagrams[&diagram]);
let moved = route_diagram(&project, &project.diagrams[&diagram], &moved_view);
for index in 0..2 {
let marker = moved_view
.ports
.iter()
.find(|port| port.element == ports[index] && port.owner == owners[index])
.unwrap();
let points = &moved.edges[&trace.id].points;
let tip = if index == 0 {
points[0]
} else {
*points.last().unwrap()
};
assert_eq!(tip, marker.position);
let old = &initial.edges[&trace.id].points;
assert_ne!(
tip,
if index == 0 {
old[0]
} else {
*old.last().unwrap()
}
);
}
project
.diagrams
.get_mut(&diagram)
.unwrap()
.routes
.push(EdgeRoute {
edge: trace.id,
waypoints: vec![],
label: None,
source_attachment: Some(EdgeAttachment {
side: PortAnchorSide::Bottom,
offset: 0.9,
}),
target_attachment: Some(EdgeAttachment {
side: PortAnchorSide::Top,
offset: 0.1,
}),
});
validate_candidate(&project).unwrap();
let dormant_view = project_diagram(&project, &project.diagrams[&diagram]);
assert_eq!(dormant_view, moved_view);
assert_eq!(
route_diagram(&project, &project.diagrams[&diagram], &dormant_view),
moved
);
assert_eq!(project.elements, semantic_elements);
assert_eq!(project.relations, semantic_relations);
assert_eq!(project.traces, semantic_traces);
assert_eq!(project.traces[&trace.id], trace);
}
+154
View File
@@ -0,0 +1,154 @@
//! Route the exact native acceptance fixture and intended saved checkpoints.
use oa_core::{projection::project_diagram, routing::route_diagram, *};
const FIXTURE: &str =
include_str!("../../../harness/experiments/edge-attachments-20260922/native-fixture.arcadia");
fn id(project: &Project, alias: &str) -> Id {
project
.identities()
.into_iter()
.find(|(_, name)| *name == alias)
.unwrap()
.0
}
fn set(
project: &mut Project,
view: &str,
edge: &str,
end: EdgeEnd,
attachment: Option<(PortAnchorSide, f32)>,
) {
let view = id(project, view);
let edge = id(project, edge);
let route = project
.diagrams
.get_mut(&view)
.unwrap()
.routes
.iter_mut()
.find(|route| route.edge == edge)
.unwrap();
route.set_attachment(
end,
attachment.map(|(side, offset)| EdgeAttachment { side, offset }),
);
}
fn clear(project: &Project, checkpoint: &str) {
validate_candidate(project).unwrap();
for (view, edges) in [
("dispatch_view", &["dispatch_request"][..]),
("readiness_view", &["activate", "initialize", "finish"][..]),
] {
let diagram = &project.diagrams[&id(project, view)];
let routed = route_diagram(project, diagram, &project_diagram(project, diagram));
for alias in edges {
let edge = &routed.edges[&id(project, alias)];
assert!(
edge.warnings.is_empty(),
"{checkpoint}/{view}/{alias}: {:?}",
edge.warnings
);
let stored = diagram
.routes
.iter()
.find(|r| r.edge == id(project, alias))
.unwrap();
assert_eq!(
edge.pins, stored.waypoints,
"{checkpoint}/{alias}: manual pins retained"
);
}
}
}
#[test]
fn native_workshop_baseline_and_planned_attachments_have_clear_routes() {
use EdgeEnd::{Source, Target};
use PortAnchorSide::{Bottom, Top};
let mut project = language::parse(FIXTURE).unwrap();
let baseline = project.clone();
clear(&project, "B");
set(
&mut project,
"dispatch_view",
"dispatch_request",
Source,
Some((Top, 0.25)),
);
clear(&project, "O1");
set(
&mut project,
"dispatch_view",
"dispatch_request",
Target,
Some((Bottom, 0.75)),
);
clear(&project, "O2");
set(
&mut project,
"dispatch_view",
"dispatch_request",
Source,
None,
);
clear(&project, "O3");
set(
&mut project,
"readiness_view",
"activate",
Source,
Some((Top, 0.25)),
);
clear(&project, "S1");
set(
&mut project,
"readiness_view",
"activate",
Target,
Some((Bottom, 0.75)),
);
clear(&project, "S2");
set(&mut project, "readiness_view", "activate", Target, None);
clear(&project, "S3");
set(
&mut project,
"readiness_view",
"initialize",
Source,
Some((Top, 0.5)),
);
clear(&project, "S4");
set(
&mut project,
"readiness_view",
"finish",
Source,
Some((Top, 0.5)),
);
clear(&project, "S5");
assert_eq!(project.elements, baseline.elements);
assert_eq!(project.relations, baseline.relations);
for alias in ["dispatch_reference", "readiness_reference", "ports_view"] {
let view = id(&project, alias);
assert_eq!(project.diagrams[&view], baseline.diagrams[&view]);
}
for alias in ["dispatch_view", "readiness_view"] {
let view = id(&project, alias);
assert_eq!(
project.diagrams[&view].nodes,
baseline.diagrams[&view].nodes
);
for route in &project.diagrams[&view].routes {
let original = baseline.diagrams[&view]
.routes
.iter()
.find(|r| r.edge == route.edge)
.unwrap();
assert_eq!(route.waypoints, original.waypoints);
assert_eq!(route.label, original.label);
}
}
}
@@ -0,0 +1,344 @@
//! Existing source-7 nested omission compatibility, independently of formatting.
//!
//! Selected constructors use their own field contracts. These handwritten
//! sources and literal models distinguish nullable optional slots from required
//! fields; they do not prescribe compact canonical output or broaden admission.
use oa_core::{source_project::*, *};
use std::collections::BTreeMap;
fn id(number: u8) -> Id {
Id::parse_str(&format!("00000000-0000-4000-8000-{number:012}")).unwrap()
}
fn bundle(declarations: &str) -> SourceBundle {
let source = format!(
"arcadia 7;\nproject p \"Admission\" id \"00000000-0000-4000-8000-000000000001\" {{\n{declarations}\n}}\n"
);
SourceBundle {
entry: "project.arcadia".into(),
files: BTreeMap::from([("project.arcadia".into(), source)]),
}
}
fn element(number: u8, alias: &str, name: &str, parent: Option<Id>, body: ElementBody) -> Element {
Element {
id: id(number),
alias: alias.into(),
name: name.into(),
description: String::new(),
parent,
body,
}
}
fn assert_admitted(input: &SourceBundle, elements: Vec<Element>) -> Project {
let expected = Project {
schema_version: 7,
id: id(1),
alias: "p".into(),
name: "Admission".into(),
description: String::new(),
elements: elements.into_iter().map(|e| (e.id, e)).collect(),
relations: BTreeMap::new(),
diagrams: BTreeMap::new(),
artifact_links: BTreeMap::new(),
traces: BTreeMap::new(),
};
let compiled = compile_bundle(input, ProjectLimits::default()).unwrap();
assert_eq!(compiled.decoded.project, expected, "complete literal model");
assert_eq!(
compiled.bundle, *input,
"fixed IDs require no source stabilization"
);
compiled.decoded.project
}
fn prefix(layer: Layer) -> &'static str {
match layer {
Layer::Oa => "oa",
Layer::Sa => "sa",
}
}
fn data_bundle(layer: Layer, definition: &str) -> SourceBundle {
bundle(&format!(
" {}_data_type subject \"Subject\" id \"00000000-0000-4000-8000-000000000002\" {{ definition {{ {definition}; }}; }}",
prefix(layer)
))
}
fn admit_data(layer: Layer, source: &str, definition: DataTypeDefinition) -> Project {
assert_admitted(
&data_bundle(layer, source),
vec![element(
2,
"subject",
"Subject",
None,
ElementBody::shared_in(
layer,
SharedBody::DataType {
definition,
default: None,
unit: String::new(),
},
),
)],
)
}
fn assert_missing(input: &SourceBundle, field: &str) {
let errors = compile_bundle(input, ProjectLimits::default()).unwrap_err();
assert_eq!(
errors.len(),
1,
"expect the targeted admission error: {errors:?}"
);
let diagnostic = &errors[0].diagnostic;
assert_eq!(diagnostic.code, "invalid_source", "{errors:?}");
assert!(
diagnostic.message.to_lowercase().contains("missing") && diagnostic.message.contains(field),
"must reject the missing constructor field {field}, not an unrelated model rule: {errors:?}"
);
}
#[test]
fn integer_optional_bounds_can_be_omitted_individually_and_together() {
for layer in [Layer::Oa, Layer::Sa] {
for (omitted, explicit, minimum, maximum) in [
(
"Integer {}",
"Integer { minimum none; maximum none; }",
None,
None,
),
(
"Integer { maximum 9; }",
"Integer { minimum none; maximum 9; }",
None,
Some(9),
),
(
"Integer { minimum -3; }",
"Integer { minimum -3; maximum none; }",
Some(-3),
None,
),
] {
let expected = DataTypeDefinition::Integer { minimum, maximum };
assert_eq!(
admit_data(layer, omitted, expected.clone()),
admit_data(layer, explicit, expected),
"omitted and explicit optional Integer bounds agree"
);
}
}
}
#[test]
fn real_optional_bounds_can_be_omitted_individually_and_together() {
for layer in [Layer::Oa, Layer::Sa] {
for (omitted, explicit, minimum, maximum) in [
(
"Real {}",
"Real { minimum none; maximum none; }",
None,
None,
),
(
"Real { maximum 2.5; }",
"Real { minimum none; maximum 2.5; }",
None,
Some(2.5),
),
(
"Real { minimum -1.25; }",
"Real { minimum -1.25; maximum none; }",
Some(-1.25),
None,
),
] {
let expected = DataTypeDefinition::Real { minimum, maximum };
assert_eq!(
admit_data(layer, omitted, expected.clone()),
admit_data(layer, explicit, expected),
"omitted and explicit optional Real bounds agree"
);
}
}
}
#[test]
fn string_optional_maximum_can_be_omitted_without_resetting_required_minimum() {
for layer in [Layer::Oa, Layer::Sa] {
let expected = DataTypeDefinition::String {
minimum_length: 4,
maximum_length: None,
};
assert_eq!(
admit_data(layer, "String { minimum_length 4; }", expected.clone()),
admit_data(
layer,
"String { minimum_length 4; maximum_length none; }",
expected,
),
);
}
}
#[test]
fn explicitly_selected_string_requires_minimum_even_when_whole_definition_has_a_default() {
for layer in [Layer::Oa, Layer::Sa] {
assert_missing(&data_bundle(layer, "String {}"), "minimum_length");
assert_missing(
&data_bundle(layer, "String { maximum_length none; }"),
"minimum_length",
);
admit_data(
layer,
"String { minimum_length 0; }",
DataTypeDefinition::String {
minimum_length: 0,
maximum_length: None,
},
);
}
}
#[test]
fn absent_numeric_maximum_is_distinct_from_an_explicit_zero_bound() {
for layer in [Layer::Oa, Layer::Sa] {
let unbounded = admit_data(
layer,
"Integer { minimum -3; }",
DataTypeDefinition::Integer {
minimum: Some(-3),
maximum: None,
},
);
let bounded = admit_data(
layer,
"Integer { minimum -3; maximum 0; }",
DataTypeDefinition::Integer {
minimum: Some(-3),
maximum: Some(0),
},
);
assert_ne!(unbounded, bounded, "zero is a bound, not omitted syntax");
}
}
#[test]
fn record_has_no_required_payload_field_and_omitted_supertype_means_none() {
for layer in [Layer::Oa, Layer::Sa] {
let expected = DataTypeDefinition::Record { supertype: None };
assert_eq!(
admit_data(layer, "Record {}", expected.clone()),
admit_data(layer, "Record { supertype none; }", expected),
);
let errors = compile_bundle(
&data_bundle(layer, "Record { fields []; }"),
ProjectLimits::default(),
)
.unwrap_err();
assert_eq!(errors.len(), 1, "{errors:?}");
assert_eq!(errors[0].diagnostic.code, "invalid_source", "{errors:?}");
assert!(
errors[0]
.diagnostic
.message
.contains("unknown field `fields`"),
"Record remains a closed constructor: {errors:?}"
);
}
}
#[test]
fn enumeration_still_requires_its_list_even_when_empty_list_is_admitted() {
for layer in [Layer::Oa, Layer::Sa] {
assert_missing(&data_bundle(layer, "Enumeration {}"), "literals");
admit_data(
layer,
"Enumeration { literals []; }",
DataTypeDefinition::Enumeration { literals: vec![] },
);
}
}
fn port_bundle(layer: Layer, fields: &str) -> SourceBundle {
let owner_kind = match layer {
Layer::Oa => "oa_entity",
Layer::Sa => "sa_system",
};
bundle(&format!(
" {owner_kind} owner \"Owner\" id \"00000000-0000-4000-8000-000000000002\" {{}}\n {}_port subject \"Subject\" id \"00000000-0000-4000-8000-000000000003\" {{ parent @owner; {fields} }}",
prefix(layer)
))
}
fn admit_port(layer: Layer, fields: &str) -> Project {
let owner_body = match layer {
Layer::Oa => ElementBody::Oa(OaBody::Entity),
Layer::Sa => ElementBody::Sa(SaBody::System),
};
assert_admitted(
&port_bundle(layer, fields),
vec![
element(2, "owner", "Owner", None, owner_body),
element(
3,
"subject",
"Subject",
Some(id(2)),
ElementBody::shared_in(
layer,
SharedBody::Port {
contract: PortContract::Legacy,
legacy: Some(LegacyPortContract {
direction: PortDirection::Out,
interface: None,
}),
},
),
),
],
)
}
#[test]
fn legacy_port_interface_can_be_omitted_but_preserves_direction() {
for layer in [Layer::Oa, Layer::Sa] {
assert_eq!(
admit_port(layer, "contract Legacy; legacy { direction Out; };"),
admit_port(
layer,
"contract Legacy; legacy { direction Out; interface none; };"
),
);
}
}
#[test]
fn legacy_port_direction_remains_required_inside_selected_record() {
for layer in [Layer::Oa, Layer::Sa] {
assert_missing(
&port_bundle(layer, "contract Legacy; legacy {};"),
"direction",
);
assert_missing(
&port_bundle(layer, "contract Legacy; legacy { interface none; };"),
"direction",
);
}
}
#[test]
fn port_declaration_still_requires_explicit_contract() {
for layer in [Layer::Oa, Layer::Sa] {
assert_missing(
&port_bundle(layer, "legacy { direction Out; };"),
"contract",
);
}
}
@@ -0,0 +1,445 @@
//! Public flat/modular formatter fidelity over frozen input and bounded mutations.
//! Legacy fixture migration uses the public parser. The original adapter tests
//! and exact inputs remain archived at source commit 2a64344; this suite exercises
//! production formatters, not that frozen experiment or lossless model edits.
use oa_core::{language, source_project::*, *};
use std::collections::BTreeMap;
const COASTAL: &str = include_str!("../../../examples/coastal-rescue.arcadia");
const KEYED: &str =
include_str!("../../../harness/fixtures/release1-native/semantic-keyed.arcadia");
const STATES: &str =
include_str!("../../../harness/fixtures/release1-native/state-actions.arcadia");
const DENSE: &str =
include_str!("../../../harness/fixtures/release1-native/dense-40/dense-40.arcadia");
const OPERATIONS: &str = include_str!(
"../../../harness/fixtures/interface-operation-probe/output/boundary-only.arcadia"
);
fn read(source: &str) -> Project {
language::parse(source).unwrap()
}
fn identity(project: &Project, alias: &str) -> Id {
project
.identities()
.into_iter()
.find(|(_, name)| *name == alias)
.unwrap()
.0
}
fn assert_common_omissions(project: &Project, compiled: &CompiledSourceProject) {
if project.description.is_empty() {
assert!(compiled.index.project_description.is_none());
}
let descriptions = project
.elements
.values()
.map(|e| (e.id, &e.description))
.chain(project.relations.values().map(|r| (r.id, &r.description)))
.chain(project.diagrams.values().map(|d| (d.id, &d.description)))
.chain(
project
.artifact_links
.values()
.map(|a| (a.id, &a.description)),
)
.chain(project.traces.values().map(|t| (t.id, &t.description)));
for (id, description) in descriptions {
if description.is_empty() {
assert!(
!compiled
.index
.fields
.contains_key(&(id, "description".into()))
);
}
}
for element in project.elements.values().filter(|e| e.parent.is_none()) {
assert!(
!compiled
.index
.fields
.contains_key(&(element.id, "parent".into()))
);
}
}
fn assert_fidelity(label: &str, project: &Project) -> (Project, String) {
let flat = language::format(project);
let compact_model = read(&flat);
assert_eq!(
compact_model, *project,
"{label}: every persistent field survives"
);
let input = SourceBundle {
entry: "project.arcadia".into(),
files: BTreeMap::from([("project.arcadia".into(), flat.clone())]),
};
let compiled = compile_bundle(&input, ProjectLimits::default()).unwrap();
assert_eq!(
compiled.decoded.project, *project,
"{label}: source-project compile"
);
assert_eq!(
compiled.bundle, input,
"{label}: compact identities/source are stable"
);
assert_common_omissions(project, &compiled);
assert_eq!(
language::format(&compact_model),
flat,
"{label}: flat idempotence"
);
let modular = format_bundle(project);
assert!(
modular.files.len() > 1,
"{label}: exercise generated includes"
);
let modular_compiled = compile_bundle(&modular, ProjectLimits::default()).unwrap();
assert_eq!(
modular_compiled.decoded.project, *project,
"{label}: complete modular model"
);
assert_eq!(
modular_compiled.bundle, modular,
"{label}: modular source/IDs are stable"
);
assert_common_omissions(project, &modular_compiled);
assert_eq!(
format_bundle(&compact_model),
modular,
"{label}: flat-to-modular determinism"
);
assert_eq!(
format_bundle(&modular_compiled.decoded.project),
modular,
"{label}: modular idempotence"
);
// Pinned measured compact bytes from the published fidelity-1 stderr.log at
// 2a64344, not a comparison against today's (now compact) canonical writer.
// A small header-layout allowance avoids enforcing incidental blank lines.
let measured_compact_bytes = match label {
"coastal-source3" => 28_465,
"keyed-source5-history" => 12_066,
"state-actions-source5" => 6_673,
"dense40-source6" => 19_342,
"boundary-operations-source6" => 3_660,
"state-negative-zero" => 6_672,
"keyed-present-empty-default" => 12_108,
"coastal-present-empty-artifact-strings" => 28_483,
"boundary-selected-and-unresolved-operations" => 3_806,
_ => panic!("missing independent compact measurement for {label}"),
};
assert!(
flat.len() <= measured_compact_bytes + 16,
"{label}: {} bytes exceeds compact evidence budget {}",
flat.len(),
measured_compact_bytes + 16
);
(compact_model, flat)
}
#[test]
fn migrated_coastal_oa_sa_views_ports_artifacts_and_traces_roundtrip() {
assert!(COASTAL.starts_with("arcadia 3;"));
let project = read(COASTAL);
assert!(project.elements.values().any(|e| e.layer() == Layer::Oa));
assert!(project.elements.values().any(|e| e.layer() == Layer::Sa));
assert!(
project
.elements
.values()
.any(|e| matches!(e.shared(), Some(SharedBody::Port { .. })))
);
assert!(project.elements.values().any(
|e| matches!(e.shared(), Some(SharedBody::Scenario { steps, .. }) if !steps.is_empty())
));
assert!(project.diagrams.values().any(|d| d.layer == Layer::Oa
&& d.projection == ProjectionKind::FunctionalDataflow
&& !d.nodes.is_empty()));
assert!(project.diagrams.values().any(|d| d.layer == Layer::Sa
&& d.projection == ProjectionKind::Allocation
&& !d.nodes.is_empty()));
assert!(!project.artifact_links.is_empty());
assert!(project.traces.values().any(|t| t.baseline.is_some()));
assert_fidelity("coastal-source3", &project);
}
#[test]
fn keyed_scenarios_records_and_historical_binding_annotation_facets_roundtrip() {
let project = read(KEYED);
for layer in [Layer::Oa, Layer::Sa] {
assert!(project.elements.values().any(|e| e.layer() == layer
&& matches!(
e.shared(),
Some(SharedBody::DataType {
definition: DataTypeDefinition::Record { .. },
..
})
)));
assert!(project.elements.values().any(|e| e.layer() == layer && matches!(e.shared(), Some(SharedBody::Occurrence { body: OccurrenceBody::ScenarioUse { bindings, .. }, .. }) if !bindings.is_empty())));
}
assert!(project.elements.values().any(|e| matches!(e.shared(), Some(SharedBody::Occurrence { body: OccurrenceBody::Exchange { values, .. }, .. }) if values.iter().any(|v| !v.path.is_empty() && !v.expression.is_empty()))));
let facets: Vec<_> = project
.traces
.values()
.filter_map(|t| t.baseline.as_ref()?.semantic.as_ref())
.flat_map(|baseline| &baseline.facets)
.collect();
assert!(
facets
.iter()
.any(|facet| matches!(facet, semantic::FacetReview::Binding { .. }))
);
assert!(
facets
.iter()
.any(|facet| matches!(facet, semantic::FacetReview::Annotation { .. }))
);
assert!(
KEYED.contains("uuid \""),
"fixture contains historical identity atoms"
);
let (_, source) = assert_fidelity("keyed-source5-history", &project);
assert!(
source.contains("uuid \""),
"historical atoms remain historical"
);
}
#[test]
fn state_machine_projection_typed_actions_and_bounded_integer_roundtrip() {
let project = read(STATES);
let machine = identity(&project, "operating");
let view = &project.diagrams[&identity(&project, "state_view")];
assert_eq!(view.projection, ProjectionKind::StateMachine { machine });
assert!(!view.nodes.is_empty());
for phase in [
StateActionKind::Entry,
StateActionKind::Do,
StateActionKind::Exit,
] {
assert!(project.relations.values().any(|r| matches!(&r.body, RelationBody::StateAction { phase: actual, .. } if *actual == phase)));
}
assert!(project.relations.values().any(|r| matches!(&r.body, RelationBody::StateTransition { transition_kind: TransitionKind::Internal, triggers, effects, .. } if !triggers.is_empty() && !effects.is_empty())));
assert!(
matches!(project.elements[&identity(&project, "retry_budget")].shared(), Some(SharedBody::DataType { definition: DataTypeDefinition::Integer { minimum: Some(0), maximum: Some(4) }, unit, .. }) if unit == "attempts")
);
assert_fidelity("state-actions-source5", &project);
}
#[test]
fn forty_exchange_diagram_with_categories_and_processes_roundtrip() {
let project = read(DENSE);
assert_eq!(
project
.relations
.values()
.filter(|r| r.kind() == RelationKind::Interaction)
.count(),
40
);
assert_eq!(
project
.elements
.values()
.filter(|e| e.kind() == Kind::ExchangeCategory)
.count(),
5
);
assert_eq!(
project
.elements
.values()
.filter(|e| e.kind() == Kind::Process)
.count(),
3
);
let view = &project.diagrams[&identity(&project, "forty_exchange_view")];
assert_eq!(view.projection, ProjectionKind::FunctionalDataflow);
assert_eq!(view.nodes.len(), 2);
assert!(view.routes.is_empty());
assert_eq!(
view.view_options.relation_kinds,
view_options::RelationKinds::All
);
assert_fidelity("dense40-source6", &project);
}
#[test]
fn component_ports_interface_operations_and_boundary_calls_roundtrip() {
let project = read(OPERATIONS);
assert_eq!(
project
.relations
.values()
.filter(|r| r.kind() == RelationKind::InterfaceItemAllocation)
.count(),
2
);
assert_eq!(
project
.elements
.values()
.filter(|e| matches!(
e.shared(),
Some(SharedBody::Port {
contract: PortContract::Component {
kind: ComponentPortKind::Standard,
..
},
..
})
))
.count(),
2
);
assert!(project.elements.values().any(|e| matches!(
e.shared(),
Some(SharedBody::ExchangeItem {
mechanism: ExchangeMechanism::Operation,
..
})
)));
assert!(project.elements.values().any(|e| matches!(e.shared(), Some(SharedBody::Scenario { viewpoint: ScenarioViewpoint::BoundaryInterface, steps, .. }) if steps.len() == 2)));
assert_eq!(
project
.elements
.values()
.filter(|e| matches!(
e.shared(),
Some(SharedBody::Occurrence {
body: OccurrenceBody::Exchange {
message_kind: MessageKind::Call,
operation: None,
..
},
..
})
))
.count(),
2
);
assert_fidelity("boundary-operations-source6", &project);
}
#[test]
fn negative_zero_node_coordinate_survives_canonical_compact_and_bundle_compile() {
let mut project = read(STATES);
let diagram = identity(&project, "state_view");
let node = identity(&project, "view_machine");
project
.diagrams
.get_mut(&diagram)
.unwrap()
.nodes
.get_mut(&node)
.unwrap()
.position = Position { x: -0.0, y: 0.0 };
let (decoded, flat) = assert_fidelity("state-negative-zero", &project);
let bundle = SourceBundle {
entry: "project.arcadia".into(),
files: BTreeMap::from([("project.arcadia".into(), flat)]),
};
let compiled = compile_bundle(&bundle, ProjectLimits::default()).unwrap();
let modular = compile_bundle(&format_bundle(&project), ProjectLimits::default()).unwrap();
// Project/Position PartialEq alone equates the signs of zero. Check bits in
// each independently decoded representation, including source-project compile.
for actual in [
&decoded,
&compiled.decoded.project,
&modular.decoded.project,
] {
let position = &actual.diagrams[&diagram].nodes[&node].position;
assert_eq!(position.x.to_bits(), (-0.0_f32).to_bits());
assert_eq!(position.y.to_bits(), 0.0_f32.to_bits());
}
}
#[test]
fn present_empty_field_default_and_unbounded_maximum_remain_distinct_from_omission() {
let mut project = read(KEYED);
let field_id = identity(&project, "oa_navigation_latitude");
let Some(SharedBody::DataField {
minimum,
maximum,
default,
storage,
..
}) = project.elements.get_mut(&field_id).unwrap().shared_mut()
else {
panic!("fixture must contain the claimed DataField");
};
assert_eq!(*minimum, 0);
assert_eq!(*maximum, Some(1));
assert_eq!(*default, None);
assert_eq!(*storage, FieldStorage::Value);
*maximum = None;
*default = Some(vec![]);
let (decoded, _) = assert_fidelity("keyed-present-empty-default", &project);
assert!(
matches!(decoded.elements[&field_id].shared(), Some(SharedBody::DataField { maximum: None, default: Some(values), .. }) if values.is_empty())
);
}
#[test]
fn present_empty_artifact_revision_and_fragment_survive() {
let mut project = read(COASTAL);
let artifact_id = identity(&project, "console_enclosure_cad");
let artifact = project.artifact_links.get_mut(&artifact_id).unwrap();
artifact.revision = Some(String::new());
artifact.fragment = Some(String::new());
let (decoded, _) = assert_fidelity("coastal-present-empty-artifact-strings", &project);
assert_eq!(
decoded.artifact_links[&artifact_id].revision.as_deref(),
Some("")
);
assert_eq!(
decoded.artifact_links[&artifact_id].fragment.as_deref(),
Some("")
);
}
#[test]
fn selected_and_unresolved_operation_intent_survive_as_distinct_present_records() {
let mut project = read(OPERATIONS);
let allocation = identity(&project, "operation_probe_30");
assert_eq!(
project.relations[&allocation].kind(),
RelationKind::InterfaceItemAllocation
);
let selected = identity(&project, "operation_probe_43");
let unresolved = identity(&project, "operation_probe_44");
for (subject, value) in [(selected, Some(allocation)), (unresolved, None)] {
let Some(SharedBody::Occurrence {
body: OccurrenceBody::Exchange { operation, .. },
..
}) = project.elements.get_mut(&subject).unwrap().shared_mut()
else {
panic!("fixture must contain a Call Exchange");
};
assert_eq!(*operation, None);
*operation = Some(OperationUse { allocation: value });
}
let (decoded, _) = assert_fidelity("boundary-selected-and-unresolved-operations", &project);
assert!(
matches!(decoded.elements[&selected].shared(), Some(SharedBody::Occurrence { body: OccurrenceBody::Exchange { operation: Some(OperationUse { allocation: Some(actual) }), .. }, .. }) if *actual == allocation)
);
assert!(matches!(
decoded.elements[&unresolved].shared(),
Some(SharedBody::Occurrence {
body: OccurrenceBody::Exchange {
operation: Some(OperationUse { allocation: None }),
..
},
..
})
));
}
@@ -0,0 +1,256 @@
//! Focused public-output contracts. Field locations come from the real compiler;
//! token comparisons ignore layout while retaining typed tags and required syntax.
use oa_core::{language, source_project::*, *};
use std::collections::BTreeMap;
fn project(declarations: &str) -> Project {
language::parse(&format!(
"arcadia 7; project p \"P\" id \"00000000-0000-4000-8000-000000000001\" {{ {declarations} }}"
)).unwrap()
}
fn exports(project: &Project) -> Vec<CompiledSourceProject> {
let flat = language::format(project);
assert_eq!(language::parse(&flat).unwrap(), *project);
let flat_bundle = SourceBundle {
entry: "project.arcadia".into(),
files: BTreeMap::from([("project.arcadia".into(), flat)]),
};
[flat_bundle, format_bundle(project)]
.into_iter()
.map(|bundle| {
let compiled = compile_bundle(&bundle, ProjectLimits::default()).unwrap();
assert_eq!(compiled.decoded.project, *project);
assert_eq!(
compiled.bundle, bundle,
"all identities are explicit and stable"
);
compiled
})
.collect()
}
fn identity(project: &Project, alias: &str) -> Id {
project
.identities()
.into_iter()
.find(|(_, name)| *name == alias)
.unwrap()
.0
}
fn field(compiled: &CompiledSourceProject, alias: &str, name: &str) -> Option<String> {
let location = compiled
.index
.fields
.get(&(identity(&compiled.decoded.project, alias), name.into()))?;
let text = &compiled.bundle.files[&location.path][location.span.start..location.span.end];
Some(
language::tokenize(text)
.into_iter()
.filter(|token| {
!matches!(
token.class,
language::TokenClass::Whitespace | language::TokenClass::Comment
)
})
.map(|token| &text[token.span.start..token.span.end])
.collect::<Vec<_>>()
.join(" "),
)
}
#[test]
fn empty_project_has_no_synthetic_description_or_module() {
let empty = project("");
for compiled in exports(&empty) {
assert!(compiled.index.project_description.is_none());
assert!(compiled.index.fields.is_empty());
assert!(compiled.index.includes.is_empty());
assert_eq!(compiled.bundle.files.len(), 1);
assert_eq!(compiled.index.declarations.len(), 1);
}
}
#[test]
fn only_empty_descriptions_disappear_whitespace_and_text_survive_exactly() {
let mut model =
project("oa_activity work \"Work\" id \"00000000-0000-4000-8000-000000000002\" {}");
let work = identity(&model, "work");
for description in ["", " \t\n", "A quoted \"requirement\" — 雨"] {
model.description = description.into();
model.elements.get_mut(&work).unwrap().description = description.into();
for compiled in exports(&model) {
assert_eq!(
compiled.index.project_description.is_some(),
!description.is_empty()
);
assert_eq!(
field(&compiled, "work", "description").is_some(),
!description.is_empty()
);
assert!(field(&compiled, "work", "parent").is_none());
// exports compares the complete model, including exact whitespace
// and escapes in both project and element descriptions.
}
}
}
#[test]
fn sa_view_keeps_its_layer_and_node_references_but_omits_presentation_defaults() {
let model = project(
r#"
sa_function work "Work" id "00000000-0000-4000-8000-000000000002" {}
diagram view "View" id "00000000-0000-4000-8000-000000000003" { layer Sa; }
node placement id "00000000-0000-4000-8000-000000000004" {
diagram @view; element @work;
position { x 0.0; y 0.0; }; size { width 224.0; height 94.0; };
collapsed false; port_anchors [];
}
"#,
);
for compiled in exports(&model) {
assert_eq!(field(&compiled, "view", "layer").as_deref(), Some("Sa"));
for name in ["description", "projection", "routes", "view_options"] {
assert!(field(&compiled, "view", name).is_none(), "view.{name}");
}
assert_eq!(
field(&compiled, "placement", "diagram").as_deref(),
Some("@ view")
);
assert_eq!(
field(&compiled, "placement", "element").as_deref(),
Some("@ work")
);
for name in ["position", "size", "collapsed", "port_anchors"] {
assert!(
field(&compiled, "placement", name).is_none(),
"placement.{name}"
);
}
}
}
#[test]
fn nested_type_defaults_disappear_without_losing_required_or_present_empty_values() {
let model = project(
r#"
sa_data_type implicit "Implicit" id "00000000-0000-4000-8000-000000000002" {}
sa_data_type integer "Integer" id "00000000-0000-4000-8000-000000000003" {
definition { Integer { minimum none; maximum none; }; };
}
sa_data_type text "Text" id "00000000-0000-4000-8000-000000000004" {
definition { String { minimum_length 4; maximum_length none; }; };
}
sa_data_type record "Record" id "00000000-0000-4000-8000-000000000005" {
definition { Record { supertype none; }; };
}
sa_data_type flag "Flag" id "00000000-0000-4000-8000-000000000006" {
definition Boolean; default { Boolean false; };
}
sa_data_field optional "Optional" id "00000000-0000-4000-8000-000000000007" {
parent @record; minimum 0; maximum none; default [];
}
"#,
);
for compiled in exports(&model) {
assert!(field(&compiled, "implicit", "definition").is_none());
assert_eq!(
field(&compiled, "integer", "definition").as_deref(),
Some("{ Integer { } ; }")
);
assert_eq!(
field(&compiled, "text", "definition").as_deref(),
Some("{ String { minimum_length 4 ; } ; }")
);
assert_eq!(
field(&compiled, "record", "definition").as_deref(),
Some("{ Record { } ; }")
);
assert_eq!(
field(&compiled, "flag", "definition").as_deref(),
Some("Boolean")
);
assert_eq!(
field(&compiled, "flag", "default").as_deref(),
Some("{ Boolean false ; }")
);
assert_eq!(
field(&compiled, "optional", "minimum").as_deref(),
Some("0")
);
assert_eq!(
field(&compiled, "optional", "maximum").as_deref(),
Some("none")
);
assert_eq!(
field(&compiled, "optional", "default").as_deref(),
Some("[ ]")
);
assert!(field(&compiled, "optional", "type_ref").is_none());
}
}
#[test]
fn required_port_contract_and_empty_item_list_survive_while_optional_interface_disappears() {
let model = project(
r#"
sa_function work "Work" id "00000000-0000-4000-8000-000000000002" {}
sa_port sink "Sink" id "00000000-0000-4000-8000-000000000003" {
parent @work; contract { FunctionInput { incoming_items []; }; };
}
sa_system host "Host" id "00000000-0000-4000-8000-000000000004" {}
sa_port preserved "Preserved" id "00000000-0000-4000-8000-000000000005" {
parent @host; contract Legacy; legacy { direction Out; interface none; };
}
"#,
);
for compiled in exports(&model) {
assert_eq!(
field(&compiled, "sink", "contract").as_deref(),
Some("{ FunctionInput { incoming_items [ ] ; } ; }")
);
assert!(field(&compiled, "sink", "legacy").is_none());
assert_eq!(
field(&compiled, "preserved", "contract").as_deref(),
Some("Legacy")
);
assert_eq!(
field(&compiled, "preserved", "legacy").as_deref(),
Some("{ direction Out ; }")
);
}
}
#[test]
fn source_scenario_default_and_selected_occurrence_constructor_remain_distinct() {
let model = project(
r#"
sa_scenario mixed "Mixed" id "00000000-0000-4000-8000-000000000002" { viewpoint MixedLegacy; }
sa_scenario strict "Strict" id "00000000-0000-4000-8000-000000000003" { viewpoint Functional; }
oa_process flow "Flow" id "00000000-0000-4000-8000-000000000004" { steps [@exchange, @action]; }
oa_occurrence exchange "Exchange" id "00000000-0000-4000-8000-000000000005" {
parent @flow; body { Exchange {}; };
}
oa_occurrence action "Action" id "00000000-0000-4000-8000-000000000006" {
parent @flow; body { Action { action none; lifeline none; }; };
}
"#,
);
for compiled in exports(&model) {
assert!(field(&compiled, "mixed", "viewpoint").is_none());
assert_eq!(
field(&compiled, "strict", "viewpoint").as_deref(),
Some("Functional")
);
assert!(field(&compiled, "exchange", "body").is_none());
assert_eq!(
field(&compiled, "action", "body").as_deref(),
Some("{ Action { } ; }")
);
assert_eq!(
field(&compiled, "action", "parent").as_deref(),
Some("@ flow")
);
}
}
@@ -0,0 +1,613 @@
//! Exact-source regressions for fields skipped by persistence serialization.
//!
//! The archived Windows baseline confirmed three exact-source failures after
//! full model equality passed. Complete comparison now preserves these fields;
//! the former ignored witnesses are normal regression tests.
//! No-op and same-record header controls remain normal tests: they must preserve
//! the same fixtures without entering the changed-payload record-erasure branch.
use oa_core::{source_project::*, *};
use std::collections::BTreeMap;
const EXCHANGE_SOURCE: &str = r#"arcadia 7;
project p "Preservation" id "00000000-0000-4000-8000-000000000001" {
oa_activity first "First" id "00000000-0000-4000-8000-000000000002" {}
oa_activity second "Second" id "00000000-0000-4000-8000-000000000003" {}
interaction flow "Flow" id "00000000-0000-4000-8000-000000000004" {
source @first; target @second;
}
oa_process owner "Owner" id "00000000-0000-4000-8000-000000000005" {
steps [@subject];
}
oa_occurrence subject "Subject" id "00000000-0000-4000-8000-000000000006" {
parent @owner;
body { Exchange {
exchange none;
sender none; receiver none;
message_kind Asynchronous; reply_to none; values [];
operation none ; // Keep this authored default and its spelling.
}; };
}
}
"#;
const NODE_SOURCE: &str = r#"arcadia 7;
project p "Preservation" id "00000000-0000-4000-8000-000000000001" {
oa_activity work "Work" id "00000000-0000-4000-8000-000000000002" {}
diagram view "View" id "00000000-0000-4000-8000-000000000003" {}
node placement id "00000000-0000-4000-8000-000000000004" {
diagram @view; element @work;
position { x 12.5; y -4.0; };
port_anchors [] ; // Keep explicit automatic placement.
}
}
"#;
const TRACE_SOURCE: &str = r#"arcadia 7;
project p "Preservation" id "00000000-0000-4000-8000-000000000001" {
oa_activity work "Work" id "00000000-0000-4000-8000-000000000002" {}
sa_function function "Function" id "00000000-0000-4000-8000-000000000003" {}
trace mapping "Mapping" id "00000000-0000-4000-8000-000000000004" {
oa @work; sa @function; kind Manual;
baseline {
oa { name "Work"; description "Old OA snapshot"; };
sa { name "Function"; description "Old SA snapshot"; };
semantic none ; // Keep the explicit prose-only baseline.
};
}
}
"#;
const CONSTRUCTOR_SOURCE: &str = r#"arcadia 7;
project p "Preservation" id "00000000-0000-4000-8000-000000000001" {
oa_scenario owner "Owner" id "00000000-0000-4000-8000-000000000002" {
steps [@subject];
}
oa_occurrence subject "Subject" id "00000000-0000-4000-8000-000000000003" {
parent @owner;
body { Action {
action none;
lifeline none ; // Keep this shared field's distinctive spelling.
}; };
}
}
"#;
const RELATION_CONTEXT_SOURCE: &str = r#"arcadia 7;
project p "Preservation" id "00000000-0000-4000-8000-000000000001" {
oa_entity first "First" id "00000000-0000-4000-8000-000000000002" {}
oa_entity second "Second" id "00000000-0000-4000-8000-000000000003" {}
// These port declarations are untouched by the relation context change.
oa_port output "Output" id "00000000-0000-4000-8000-000000000005" {
parent @first; contract Legacy;
legacy { direction Out; interface none; };
}
oa_port input "Input" id "00000000-0000-4000-8000-000000000006" {
parent @second; contract Legacy;
legacy { direction In; interface none; };
}
boundary_exchange link "Link" id "00000000-0000-4000-8000-000000000004" {
source @output; target @input;
oriented true ; // Retain this rationale when changing the default value.
items [];
}
}
"#;
fn id(number: u8) -> Id {
Id::parse_str(&format!("00000000-0000-4000-8000-{number:012}")).unwrap()
}
fn source_bundle(source: &str) -> SourceBundle {
SourceBundle {
entry: "project.arcadia".into(),
files: BTreeMap::from([("project.arcadia".into(), source.into())]),
}
}
fn project() -> Project {
Project {
schema_version: 7,
id: id(1),
alias: "p".into(),
name: "Preservation".into(),
description: String::new(),
elements: BTreeMap::new(),
relations: BTreeMap::new(),
diagrams: BTreeMap::new(),
artifact_links: BTreeMap::new(),
traces: BTreeMap::new(),
}
}
fn element(number: u8, alias: &str, name: &str, parent: Option<Id>, body: ElementBody) -> Element {
Element {
id: id(number),
alias: alias.into(),
name: name.into(),
description: String::new(),
parent,
body,
}
}
// Handwritten typed expectations are independent of canonical serialization and
// source hydration. No model factories or generated/random identities are used.
fn exchange_project() -> Project {
let mut expected = project();
expected.elements = [
element(2, "first", "First", None, ElementBody::Oa(OaBody::Activity)),
element(
3,
"second",
"Second",
None,
ElementBody::Oa(OaBody::Activity),
),
element(
5,
"owner",
"Owner",
None,
ElementBody::Oa(OaBody::Process { steps: vec![id(6)] }),
),
element(
6,
"subject",
"Subject",
Some(id(5)),
ElementBody::Oa(OaBody::Shared(SharedBody::Occurrence {
body: OccurrenceBody::Exchange {
exchange: None,
sender: None,
receiver: None,
message_kind: MessageKind::Asynchronous,
reply_to: None,
values: vec![],
operation: None,
},
precondition: String::new(),
postcondition: String::new(),
})),
),
]
.into_iter()
.map(|element| (element.id, element))
.collect();
expected.relations.insert(
id(4),
Relation {
id: id(4),
alias: "flow".into(),
name: "Flow".into(),
description: String::new(),
body: RelationBody::Interaction {
source: id(2),
target: id(3),
items: vec![],
},
},
);
expected
}
fn node_project() -> Project {
let mut expected = project();
expected.elements.insert(
id(2),
element(2, "work", "Work", None, ElementBody::Oa(OaBody::Activity)),
);
expected.diagrams.insert(
id(3),
Diagram {
id: id(3),
alias: "view".into(),
name: "View".into(),
description: String::new(),
layer: Layer::Oa,
projection: ProjectionKind::Overview,
view_options: view_options::ViewOptions {
hidden_edges: vec![],
relation_kinds: view_options::RelationKinds::All,
work_categories: view_options::CategorySelection::All,
participant_categories: view_options::CategorySelection::All,
coverage: view_options::Coverage::All,
work_contexts: view_options::ContextSelection::All,
exchange_labels: view_options::ExchangeLabels::Name,
},
nodes: BTreeMap::from([(
id(4),
NodePlacement {
id: id(4),
alias: "placement".into(),
element: id(2),
position: Position { x: 12.5, y: -4.0 },
size: Size {
width: 224.0,
height: 94.0,
},
collapsed: false,
port_anchors: vec![],
},
)]),
routes: vec![],
layout: None,
},
);
expected
}
fn trace_project() -> Project {
let mut expected = project();
expected.elements = [
element(2, "work", "Work", None, ElementBody::Oa(OaBody::Activity)),
element(
3,
"function",
"Function",
None,
ElementBody::Sa(SaBody::Function),
),
]
.into_iter()
.map(|element| (element.id, element))
.collect();
expected.traces.insert(
id(4),
Trace {
id: id(4),
alias: "mapping".into(),
name: "Mapping".into(),
description: String::new(),
oa: id(2),
sa: id(3),
kind: TraceKind::Manual,
baseline: Some(TraceBaseline {
oa: TracedProperties {
name: "Work".into(),
description: "Old OA snapshot".into(),
},
sa: TracedProperties {
name: "Function".into(),
description: "Old SA snapshot".into(),
},
semantic: None,
}),
},
);
expected
}
fn checked_fixture(source: &str, expected: &Project) -> CompiledSourceProject {
let input = source_bundle(source);
let compiled = compile_bundle(&input, ProjectLimits::default()).unwrap();
assert_eq!(
compiled.decoded.project, *expected,
"fixture model admission"
);
assert_eq!(
compiled.bundle, input,
"fixed IDs must avoid stabilization edits"
);
compiled
}
fn replacement(source: &str, before: &str, after: &str) -> String {
assert_eq!(
source.matches(before).count(),
1,
"unique authored edit token"
);
source.replacen(before, after, 1)
}
fn assert_preserved_update(
original: &CompiledSourceProject,
expected: &Project,
expected_source: &str,
) {
// Independently admit the exact expected spelling before testing the updater.
let expected_bundle = source_bundle(expected_source);
let authored = compile_bundle(&expected_bundle, ProjectLimits::default()).unwrap();
assert_eq!(
authored.decoded.project, *expected,
"expected source/model agree"
);
assert_eq!(
authored.bundle, expected_bundle,
"expected source is already stable"
);
let updated = update_bundle(original, expected, ProjectLimits::default()).unwrap();
assert_eq!(
updated.decoded.project, *expected,
"entire intended model survives"
);
assert_eq!(
updated.bundle, expected_bundle,
"only the requested token may change; preserve explicit defaults and all trivia"
);
}
// Context changes may introduce/remove redundant default syntax. Admit each
// narrowly enumerated spelling against the same literal target model, and then
// compare complete source bundles. No normalization may mask edits to unrelated
// fields, declarations, whitespace, or comments.
fn assert_context_update(
original: &CompiledSourceProject,
expected: &Project,
allowed_sources: &[String],
) {
let allowed: Vec<_> = allowed_sources
.iter()
.map(|source| {
let bundle = source_bundle(source);
let authored = compile_bundle(&bundle, ProjectLimits::default()).unwrap();
assert_eq!(
authored.decoded.project, *expected,
"allowed source/model agree"
);
assert_eq!(authored.bundle, bundle, "allowed source is already stable");
bundle
})
.collect();
let updated = update_bundle(original, expected, ProjectLimits::default()).unwrap();
assert_eq!(
updated.decoded.project, *expected,
"entire context-change model survives"
);
assert!(
allowed.contains(&updated.bundle),
"preserve unchanged source exactly; only listed changed/default syntax alternatives are allowed\nactual: {:?}\nallowed: {:?}",
updated.bundle,
allowed
);
}
#[test]
fn explicit_exchange_operation_none_survives_noop() {
let expected = exchange_project();
let original = checked_fixture(EXCHANGE_SOURCE, &expected);
assert_preserved_update(&original, &expected, EXCHANGE_SOURCE);
}
#[test]
fn explicit_exchange_operation_none_survives_header_rename() {
let mut expected = exchange_project();
let original = checked_fixture(EXCHANGE_SOURCE, &expected);
expected.elements.get_mut(&id(6)).unwrap().name = "Renamed subject".into();
let source = replacement(EXCHANGE_SOURCE, "\"Subject\"", "\"Renamed subject\"");
assert_preserved_update(&original, &expected, &source);
}
#[test]
fn explicit_exchange_operation_none_survives_exchange_binding_edit() {
let mut expected = exchange_project();
let original = checked_fixture(EXCHANGE_SOURCE, &expected);
let Some(SharedBody::Occurrence {
body: OccurrenceBody::Exchange { exchange, .. },
..
}) = expected.elements.get_mut(&id(6)).unwrap().body.shared_mut()
else {
panic!("literal fixture must be an Exchange occurrence");
};
*exchange = Some(id(4));
let source = replacement(EXCHANGE_SOURCE, "exchange none;", "exchange @flow;");
assert_preserved_update(&original, &expected, &source);
}
#[test]
fn explicit_node_empty_port_anchors_survive_noop() {
let expected = node_project();
let original = checked_fixture(NODE_SOURCE, &expected);
assert_preserved_update(&original, &expected, NODE_SOURCE);
}
#[test]
fn explicit_node_empty_port_anchors_survive_header_alias_rename() {
let mut expected = node_project();
let original = checked_fixture(NODE_SOURCE, &expected);
// Nodes have no display-name header. Rename the same node's header alias.
expected
.diagrams
.get_mut(&id(3))
.unwrap()
.nodes
.get_mut(&id(4))
.unwrap()
.alias = "renamed_placement".into();
let source = replacement(
NODE_SOURCE,
"node placement id",
"node renamed_placement id",
);
assert_preserved_update(&original, &expected, &source);
}
#[test]
fn explicit_node_empty_port_anchors_survive_position_edit() {
let mut expected = node_project();
let original = checked_fixture(NODE_SOURCE, &expected);
expected
.diagrams
.get_mut(&id(3))
.unwrap()
.nodes
.get_mut(&id(4))
.unwrap()
.position
.x = 25.0;
let source = replacement(NODE_SOURCE, "x 12.5;", "x 25.0;");
assert_preserved_update(&original, &expected, &source);
}
#[test]
fn explicit_trace_semantic_none_survives_noop() {
let expected = trace_project();
let original = checked_fixture(TRACE_SOURCE, &expected);
assert_preserved_update(&original, &expected, TRACE_SOURCE);
}
#[test]
fn explicit_trace_semantic_none_survives_header_rename() {
let mut expected = trace_project();
let original = checked_fixture(TRACE_SOURCE, &expected);
expected.traces.get_mut(&id(4)).unwrap().name = "Renamed mapping".into();
let source = replacement(TRACE_SOURCE, "\"Mapping\"", "\"Renamed mapping\"");
assert_preserved_update(&original, &expected, &source);
}
#[test]
fn explicit_trace_semantic_none_survives_snapshot_prose_edit() {
let mut expected = trace_project();
let original = checked_fixture(TRACE_SOURCE, &expected);
expected
.traces
.get_mut(&id(4))
.unwrap()
.baseline
.as_mut()
.unwrap()
.oa
.description = "Revised OA snapshot".into();
let source = replacement(
TRACE_SOURCE,
"\"Old OA snapshot\"",
"\"Revised OA snapshot\"",
);
assert_preserved_update(&original, &expected, &source);
}
#[test]
fn constructor_change_removes_inapplicable_default_and_keeps_shared_default() {
let mut expected = project();
expected.elements = [
element(
2,
"owner",
"Owner",
None,
ElementBody::Oa(OaBody::Shared(SharedBody::Scenario {
viewpoint: ScenarioViewpoint::MixedLegacy,
context: None,
precondition: String::new(),
postcondition: String::new(),
steps: vec![id(3)],
})),
),
element(
3,
"subject",
"Subject",
Some(id(2)),
ElementBody::Oa(OaBody::Shared(SharedBody::Occurrence {
body: OccurrenceBody::Action {
action: None,
lifeline: None,
},
precondition: String::new(),
postcondition: String::new(),
})),
),
]
.into_iter()
.map(|element| (element.id, element))
.collect();
let original = checked_fixture(CONSTRUCTOR_SOURCE, &expected);
expected.elements.get_mut(&id(3)).unwrap().body =
ElementBody::Oa(OaBody::Shared(SharedBody::Occurrence {
body: OccurrenceBody::State {
state: None,
lifeline: None,
},
precondition: String::new(),
postcondition: String::new(),
}));
// Remove only the inapplicable field's syntax: its separating space and all
// surrounding trivia stay. The shared lifeline spelling/comment is exact.
let source = replacement(CONSTRUCTOR_SOURCE, "body { Action {", "body { State {");
let compact = replacement(&source, "action none;", " ");
let expanded = replacement(
&compact,
" }; };",
" state none;\n }; };",
);
// Omission of newly applicable state and explicit state none both mean the
// literal State target; neither may retain the old action field.
assert_context_update(&original, &expected, &[compact, expanded]);
}
#[test]
fn relation_kind_change_updates_explicit_value_when_context_default_changes() {
let mut expected = project();
expected.elements = [
element(2, "first", "First", None, ElementBody::Oa(OaBody::Entity)),
element(3, "second", "Second", None, ElementBody::Oa(OaBody::Entity)),
element(
5,
"output",
"Output",
Some(id(2)),
ElementBody::Oa(OaBody::Shared(SharedBody::Port {
contract: PortContract::Legacy,
legacy: Some(LegacyPortContract {
direction: PortDirection::Out,
interface: None,
}),
})),
),
element(
6,
"input",
"Input",
Some(id(3)),
ElementBody::Oa(OaBody::Shared(SharedBody::Port {
contract: PortContract::Legacy,
legacy: Some(LegacyPortContract {
direction: PortDirection::In,
interface: None,
}),
})),
),
]
.into_iter()
.map(|element| (element.id, element))
.collect();
expected.relations.insert(
id(4),
Relation {
id: id(4),
alias: "link".into(),
name: "Link".into(),
description: String::new(),
body: RelationBody::BoundaryExchange {
source: id(5),
target: id(6),
oriented: true,
items: vec![],
},
},
);
let original = checked_fixture(RELATION_CONTEXT_SOURCE, &expected);
// BoundaryExchange connects participant ports; CommunicationMean connects
// their OA entity owners. Endpoint changes are explicit parts of this target,
// not inferred or normalized away. Port records remain exactly unchanged.
expected.relations.get_mut(&id(4)).unwrap().body = RelationBody::CommunicationMean {
source: id(2),
target: id(3),
oriented: false,
items: vec![],
};
let source = replacement(
RELATION_CONTEXT_SOURCE,
"boundary_exchange link",
"communication_mean link",
);
let source = replacement(&source, "source @output;", "source @first;");
let source = replacement(&source, "target @input;", "target @second;");
let explicit = replacement(&source, "oriented true ;", "oriented false ;");
// Three spaces between name/value plus one before the semicolon survive
// syntax removal. The following rationale comment and all other bytes stay.
let omitted = replacement(&source, "oriented true ;", " ");
assert_context_update(&original, &expected, &[explicit, omitted]);
}
@@ -0,0 +1,222 @@
//! Public update witnesses for compact insertion and complete comparison.
use oa_core::{language, source_project::*, *};
use std::collections::BTreeMap;
fn compile(body: &str) -> CompiledSourceProject {
compile_bundle(
&SourceBundle {
entry: "project.arcadia".into(),
files: BTreeMap::from([
(
"project.arcadia".into(),
"arcadia 7; project p \"P\" { include \"model.arcadia\"; }".into(),
),
("model.arcadia".into(), body.into()),
]),
},
ProjectLimits::default(),
)
.unwrap()
}
fn id(project: &Project, alias: &str) -> Id {
project
.identities()
.into_iter()
.find(|(_, a)| *a == alias)
.unwrap()
.0
}
fn update(before: &CompiledSourceProject, project: &Project) -> CompiledSourceProject {
let result = update_bundle(before, project, ProjectLimits::default()).unwrap();
assert_eq!(result.decoded.project, *project);
assert_eq!(
result.bundle.files["project.arcadia"],
before.bundle.files["project.arcadia"]
);
assert_eq!(
compile_bundle(&result.bundle, ProjectLimits::default())
.unwrap()
.bundle,
result.bundle
);
assert_eq!(
update_bundle(&result, project, ProjectLimits::default())
.unwrap()
.bundle,
result.bundle
);
result
}
fn words(text: &str) -> Vec<&str> {
language::tokenize(text)
.into_iter()
.filter(|t| {
!matches!(
t.class,
language::TokenClass::Whitespace | language::TokenClass::Comment
)
})
.map(|t| &text[t.span.start..t.span.end])
.collect()
}
#[test]
fn omitted_exchange_body_changes_to_one_compact_action_constructor() {
let before = compile(
"oa_scenario s \"S\" { steps [@o]; }\noa_occurrence o \"O\" { parent @s; // keep this\n}\n",
);
let mut project = before.decoded.project.clone();
let occurrence = id(&project, "o");
let Some(SharedBody::Occurrence { body, .. }) =
project.elements.get_mut(&occurrence).unwrap().shared_mut()
else {
panic!("occurrence")
};
assert!(matches!(body, OccurrenceBody::Exchange { .. }));
*body = OccurrenceBody::Action {
action: None,
lifeline: None,
};
let result = update(&before, &project);
let text = &result.bundle.files["model.arcadia"];
let tokens = words(text);
assert_eq!(tokens.iter().filter(|&&t| t == "body").count(), 1);
assert!(tokens.contains(&"Action"));
for absent in ["Exchange", "action", "lifeline", "operation", "description"] {
assert!(!tokens.contains(&absent), "{absent}: {text}");
}
assert!(text.contains("parent @s; // keep this"));
}
#[test]
fn default_signal_constructor_still_inserts_its_required_empty_items() {
let before =
compile("oa_event e \"E\" { body { Time { expression \"1 s\"; kind After; }; }; }\n");
let mut project = before.decoded.project.clone();
let event = id(&project, "e");
let Some(SharedBody::Event { body }) = project.elements.get_mut(&event).unwrap().shared_mut()
else {
panic!("event")
};
*body = EventBody::Signal { items: vec![] };
let result = update(&before, &project);
let tokens = words(&result.bundle.files["model.arcadia"]);
assert!(tokens.contains(&"Signal"));
assert!(tokens.windows(4).any(|w| w == ["items", "[", "]", ";"]));
assert!(!tokens.contains(&"expression"));
assert!(!tokens.contains(&"kind"));
}
#[test]
fn scalar_to_default_string_record_retains_required_minimum_only() {
let before = compile("oa_data_type t \"T\" { definition Boolean; // typed rationale\n}\n");
let mut project = before.decoded.project.clone();
let datatype = id(&project, "t");
let Some(SharedBody::DataType { definition, .. }) =
project.elements.get_mut(&datatype).unwrap().shared_mut()
else {
panic!("data type")
};
*definition = DataTypeDefinition::String {
minimum_length: 0,
maximum_length: None,
};
let result = update(&before, &project);
let text = &result.bundle.files["model.arcadia"];
let tokens = words(text);
assert!(tokens.contains(&"String"));
assert!(tokens.windows(3).any(|w| w == ["minimum_length", "0", ";"]));
assert!(!tokens.contains(&"maximum_length"));
assert!(text.contains("// typed rationale"));
}
#[test]
fn inserted_route_list_item_omits_only_optional_fields() {
let before = compile(
"oa_activity a \"A\" {}\noa_activity b \"B\" {}\ninteraction flow \"Flow\" { source @a; target @b; }\ndiagram d \"D\" { routes []; // routing rationale\n}\n",
);
let mut project = before.decoded.project.clone();
let diagram = id(&project, "d");
let edge = id(&project, "flow");
// Deserialize only the explicit witness value, independently of omission policy.
project.diagrams.get_mut(&diagram).unwrap().routes =
serde_json::from_value(serde_json::json!([{"edge":edge,"waypoints":[],"label":null}]))
.unwrap();
let result = update(&before, &project);
let text = &result.bundle.files["model.arcadia"];
let tokens = words(text);
assert!(tokens.windows(4).any(|w| w == ["edge", "@", "flow", ";"]));
assert!(!tokens.contains(&"waypoints"));
assert!(!tokens.contains(&"label"));
assert!(text.contains("// routing rationale"));
}
#[test]
fn zero_sign_change_inserts_position_without_default_siblings() {
let before = compile(
"oa_activity a \"A\" {}\ndiagram d \"D\" {}\nnode n { diagram @d; element @a; // placement rationale\n}\n",
);
let mut project = before.decoded.project.clone();
let diagram = id(&project, "d");
let node = id(&project, "n");
assert_eq!(
project.diagrams[&diagram].nodes[&node].position.x.to_bits(),
0.0_f32.to_bits()
);
project
.diagrams
.get_mut(&diagram)
.unwrap()
.nodes
.get_mut(&node)
.unwrap()
.position
.x = -0.0;
let result = update(&before, &project);
assert_eq!(
result.decoded.project.diagrams[&diagram].nodes[&node]
.position
.x
.to_bits(),
(-0.0_f32).to_bits()
);
let text = &result.bundle.files["model.arcadia"];
let tokens = words(text);
assert!(tokens.windows(3).any(|w| w == ["x", "-0.0", ";"]));
for absent in ["size", "collapsed", "port_anchors"] {
assert!(!tokens.contains(&absent));
}
assert!(text.contains("// placement rationale"));
}
#[test]
fn kind_change_materializes_unchanged_value_when_its_old_omission_default_changes() {
let before = compile(
"oa_entity a \"A\" {}\noa_entity b \"B\" {}\noa_port out \"Out\" { parent @a; contract Legacy; legacy { direction Out; }; }\noa_port input \"In\" { parent @b; contract Legacy; legacy { direction In; }; }\nboundary_exchange flow \"Flow\" { source @out; target @input; // orientation inherited\n}\n",
);
let mut project = before.decoded.project.clone();
let flow = id(&project, "flow");
assert!(matches!(
project.relations[&flow].body,
RelationBody::BoundaryExchange { oriented: true, .. }
));
project.relations.get_mut(&flow).unwrap().body = RelationBody::CommunicationMean {
source: id(&before.decoded.project, "a"),
target: id(&before.decoded.project, "b"),
oriented: true,
items: vec![],
};
let result = update(&before, &project);
let text = &result.bundle.files["model.arcadia"];
assert!(
words(text)
.windows(3)
.any(|w| w == ["oriented", "true", ";"])
);
assert!(text.contains("// orientation inherited"));
let before_ports = &before.bundle.files["model.arcadia"];
assert_eq!(
text.split("communication_mean").next().unwrap(),
before_ports.split("boundary_exchange").next().unwrap()
);
}
+290
View File
@@ -0,0 +1,290 @@
use oa_core::{language, persistence, source_project::*, *};
use serde_json::{Value, json};
use std::collections::BTreeMap;
const SOURCE: &str = r#"arcadia 7;
project attachments "Connection ends" id "70000000-0000-4000-8000-000000000001" {
oa_activity first "First" id "70000000-0000-4000-8000-000000000002" {}
oa_activity second "Second" id "70000000-0000-4000-8000-000000000003" {}
interaction signal "Signal" id "70000000-0000-4000-8000-000000000004" { source @first; target @second; }
diagram view "View" id "70000000-0000-4000-8000-000000000005" {
routes [{ edge @signal; waypoints [{ x 120; y 80; }];
label { fraction 0.5; offset { x 3; y -7; }; };
source_attachment { side Left; offset 0.25; };
target_attachment { side Bottom; offset 1; };
}];
}
diagram other "Other" id "70000000-0000-4000-8000-000000000006" { routes [{ edge @signal; }]; }
}
"#;
fn id(project: &Project, alias: &str) -> Id {
project
.identities()
.into_iter()
.find(|(_, a)| *a == alias)
.unwrap()
.0
}
fn original() -> Project {
language::parse(SOURCE).unwrap()
}
fn set(diagram: Id, route: EdgeRoute) -> Command {
Command::SetEdgeRoute { diagram, route }
}
#[test]
fn edge_attachments_roundtrip_source_json_bundle_and_automatic_defaults() {
let project = original();
let view = id(&project, "view");
let route = &project.diagrams[&view].routes[0];
assert_eq!(
route.source_attachment,
Some(EdgeAttachment {
side: PortAnchorSide::Left,
offset: 0.25
})
);
assert_eq!(
route.target_attachment,
Some(EdgeAttachment {
side: PortAnchorSide::Bottom,
offset: 1.0
})
);
for text in [SOURCE.to_owned(), language::format(&project)] {
assert_eq!(language::parse(&text).unwrap(), project);
}
assert_eq!(
persistence::decode(&serde_json::to_vec(&project).unwrap()).unwrap(),
project
);
assert_eq!(
compile_bundle(&format_bundle(&project), ProjectLimits::default())
.unwrap()
.decoded
.project,
project
);
let automatic = &project.diagrams[&id(&project, "other")].routes[0];
assert_eq!(automatic.source_attachment, None);
assert_eq!(automatic.target_attachment, None);
let encoded = serde_json::to_value(automatic).unwrap();
assert!(encoded.get("source_attachment").is_none());
assert!(encoded.get("target_attachment").is_none());
let mut with_null = encoded.clone();
with_null["source_attachment"] = Value::Null;
with_null["target_attachment"] = Value::Null;
assert_eq!(
serde_json::from_value::<EdgeRoute>(with_null).unwrap(),
*automatic
);
assert_eq!(
serde_json::from_value::<EdgeRoute>(encoded).unwrap(),
*automatic
);
// Old source without either new field still means automatic presentation.
let old = SOURCE
.replace(" source_attachment { side Left; offset 0.25; };\n", "")
.replace(" target_attachment { side Bottom; offset 1; };\n", "");
let old = language::parse(&old).unwrap();
assert!(
old.diagrams
.values()
.flat_map(|d| &d.routes)
.all(|r| r.source_attachment.is_none() && r.target_attachment.is_none())
);
assert!(!language::format(&old).contains("_attachment"));
assert_eq!(
persistence::decode(&serde_json::to_vec(&old).unwrap()).unwrap(),
old
);
}
#[test]
fn edge_attachments_reject_malformed_nested_source_and_json_without_defaults() {
let project = original();
let edge = id(&project, "signal");
for bad in [
json!({}),
json!({"side":"Top"}),
json!({"offset":0.5}),
json!({"side":"Diagonal","offset":0.5}),
json!({"side":"Top","offset":null}),
json!({"side":"Top","offset":0.5,"unknown":true}),
json!({"side":"Top","offset":"NaN"}),
] {
for field in ["source_attachment", "target_attachment"] {
let mut route = json!({"edge":edge});
route[field] = bad.clone();
assert!(
serde_json::from_value::<EdgeRoute>(route).is_err(),
"{field}: {bad}"
);
}
}
for bad in [
"{}",
"{ side Top; }",
"{ offset 0.5; }",
"{ side Diagonal; offset 0.5; }",
"{ side Top; offset null; }",
"{ side Top; offset 0.5; unknown true; }",
"{ side Top; offset -0.01; }",
"{ side Top; offset 1.01; }",
"{ side Top; offset NaN; }",
"{ side Top; offset 1e999; }",
] {
let source = SOURCE.replace("{ side Left; offset 0.25; }", bad);
assert!(language::parse(&source).is_err(), "{bad}");
let bundle = SourceBundle {
entry: "p.arcadia".into(),
files: BTreeMap::from([("p.arcadia".into(), source)]),
};
assert!(
compile_bundle(&bundle, ProjectLimits::default()).is_err(),
"{bad}"
);
}
for version in [1, 2] {
let legacy =
format!("arcadia {version}; project p \"P\" {{ diagram v \"V\" {{ routes []; }} }}");
assert!(
language::parse(&legacy).is_err(),
"legacy route extension must not be silently admitted"
);
}
}
#[test]
fn edge_attachments_nonfinite_and_out_of_range_commands_reject_atomically() {
let project = original();
let view = id(&project, "view");
let first = id(&project, "first");
for offset in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, -0.01, 1.01] {
for end in [EdgeEnd::Source, EdgeEnd::Target] {
let mut route = project.diagrams[&view].routes[0].clone();
route.set_attachment(
end,
Some(EdgeAttachment {
side: PortAnchorSide::Top,
offset,
}),
);
let mut session = Session::new(project.clone());
assert!(
session
.execute(Command::Batch(vec![
Command::Rename(first, "Must roll back".into()),
set(view, route)
]))
.is_err()
);
assert_eq!(session.project(), &project);
assert!(!session.can_undo() && !session.can_redo());
}
}
for offset in [-0.01, 1.01] {
let mut value = serde_json::to_value(&project).unwrap();
value["diagrams"][view.to_string()]["routes"][0]["source_attachment"]["offset"] =
json!(offset);
assert!(persistence::decode(&serde_json::to_vec(&value).unwrap()).is_err());
}
}
#[test]
fn edge_attachments_reset_one_end_and_all_routing_have_exact_history_and_view_scope() {
let project = original();
let view = id(&project, "view");
let edge = id(&project, "signal");
let mut session = Session::new(project.clone());
let mut route = project.diagrams[&view].routes[0].clone();
route.source_attachment = Some(EdgeAttachment {
side: PortAnchorSide::Right,
offset: 0.75,
});
let command = set(view, route.clone());
assert_eq!(
serde_json::from_value::<Command>(serde_json::to_value(&command).unwrap()).unwrap(),
command
);
session.execute(command).unwrap();
let mut expected = project.clone();
expected.diagrams.get_mut(&view).unwrap().routes[0] = route.clone();
assert_eq!(
session.project(),
&expected,
"only this view/end changes; semantic fields and other view stay exact"
);
session.undo();
assert_eq!(session.project(), &project);
assert!(!session.can_undo() && session.can_redo());
session.redo();
assert_eq!(session.project(), &expected);
route.source_attachment = None;
session.execute(set(view, route.clone())).unwrap();
let mut reset = expected.clone();
reset.diagrams.get_mut(&view).unwrap().routes[0] = route;
assert_eq!(
session.project(),
&reset,
"opposite attachment, pins and label survive endpoint reset"
);
session.undo();
assert_eq!(session.project(), &expected);
session
.execute(Command::RemoveEdgeRoute {
diagram: view,
edge,
})
.unwrap();
let mut removed = expected.clone();
removed.diagrams.get_mut(&view).unwrap().routes.clear();
assert_eq!(session.project(), &removed);
session.undo();
assert_eq!(session.project(), &expected);
}
#[test]
fn edge_attachments_nested_writeback_preserves_comments_adjacent_tokens_and_other_files() {
let current = compile_bundle(&SourceBundle {
entry: "p.arcadia".into(),
files: BTreeMap::from([
("p.arcadia".into(), "// project rationale\narcadia 7; project p \"P\" { include \"model.arcadia\"; include \"views.arcadia\"; }\n".into()),
("model.arcadia".into(), "// untouched semantic ownership\noa_activity a \"A\" {}\noa_activity b \"B\" {}\ninteraction signal \"Signal\" { source @a; target @b; }\n".into()),
("views.arcadia".into(), "// view rationale\r\ndiagram view \"View\" {\r\n routes [ { edge @signal; // endpoint explanation\r\n source_attachment { side Left; offset 0.25 ; // exact nested rationale\r\n }; target_attachment { side Bottom; offset 1; };\r\n waypoints [{ x 120; y 80; }]; label { fraction 0.5; offset { x 3; y -7; }; }; } ];\r\n}\r\ndiagram other \"Other\" { routes [{ edge @signal; }]; } // adjacent view\r\n".into()),
]),
}, ProjectLimits::default()).unwrap();
let mut project = current.decoded.project.clone();
let view = id(&project, "view");
project.diagrams.get_mut(&view).unwrap().routes[0]
.source_attachment
.as_mut()
.unwrap()
.offset = 0.375;
let changed = update_bundle(&current, &project, ProjectLimits::default()).unwrap();
assert_eq!(changed.decoded.project, project);
for path in ["p.arcadia", "model.arcadia"] {
assert_eq!(changed.bundle.files[path], current.bundle.files[path]);
}
assert_eq!(
changed.bundle.files["views.arcadia"],
current.bundle.files["views.arcadia"].replace("offset 0.25 ;", "offset 0.375 ;")
);
assert_eq!(
compile_bundle(&changed.bundle, ProjectLimits::default())
.unwrap()
.bundle,
changed.bundle
);
assert_eq!(
update_bundle(&changed, &project, ProjectLimits::default())
.unwrap()
.bundle,
changed.bundle
);
}
+427
View File
@@ -0,0 +1,427 @@
use oa_core::{diagram_edit::translate_placements, source_project::*, *};
use std::collections::BTreeSet;
fn point(x: f32, y: f32) -> Position {
Position { x, y }
}
fn route(edge: Id, x: f32, y: f32) -> EdgeRoute {
EdgeRoute {
edge,
source_attachment: Some(EdgeAttachment {
side: PortAnchorSide::Bottom,
offset: 0.2,
}),
target_attachment: Some(EdgeAttachment {
side: PortAnchorSide::Top,
offset: 0.8,
}),
waypoints: vec![point(x, y)],
label: Some(EdgeLabel {
fraction: 0.4,
offset: point(3., -9.),
}),
}
}
struct Fixture {
project: Project,
diagram: Id,
a: Id,
b: Id,
child: Id,
foreign: Id,
}
fn fixture() -> Fixture {
let mut p = Project::empty();
let a = p.add_element(Kind::Activity, Layer::Oa, "A", None).unwrap();
let b = p.add_element(Kind::Activity, Layer::Oa, "B", None).unwrap();
let c = p.add_element(Kind::Activity, Layer::Oa, "C", None).unwrap();
let child = p
.add_element(Kind::Activity, Layer::Oa, "Child", Some(a))
.unwrap();
let internal = p.relate(RelationKind::Interaction, "Internal", a, b);
let external = p.relate(RelationKind::Interaction, "External", b, c);
let d = p.ensure_diagram(Layer::Oa);
let na = p.place(d, a, point(10., 20.));
let nb = p.place(d, b, point(400., 20.));
let nc = p.place(d, child, point(30., 80.));
p.place(d, c, point(800., 20.));
p.diagrams.get_mut(&d).unwrap().routes =
vec![route(internal, 320., 150.), route(external, 720., 170.)];
p.diagrams
.get_mut(&d)
.unwrap()
.nodes
.get_mut(&na)
.unwrap()
.collapsed = true;
let other = Id::new_v4();
let mut other_view = p.diagrams[&d].clone();
other_view.id = other;
other_view.alias = "other_view".into();
other_view.nodes.clear();
other_view.routes.clear();
p.diagrams.insert(other, other_view);
let foreign = p.place(other, a, point(-40., -50.));
validate_candidate(&p).unwrap();
Fixture {
project: p,
diagram: d,
a: na,
b: nb,
child: nc,
foreign,
}
}
#[test]
fn simultaneous_siblings_translate_internal_pins_once_and_preserve_complete_project() {
let f = fixture();
let before = f.project.clone();
let command = translate_placements(
&before,
f.diagram,
&BTreeSet::from([f.a, f.b]),
point(40., 60.),
)
.unwrap();
assert_eq!(
before, f.project,
"command construction must not mutate input"
);
// Independently enumerated expected edits, without the projection helpers.
let mut expected = before.clone();
let view = expected.diagrams.get_mut(&f.diagram).unwrap();
view.nodes.get_mut(&f.a).unwrap().position = point(50., 80.);
view.nodes.get_mut(&f.b).unwrap().position = point(440., 80.);
view.nodes.get_mut(&f.child).unwrap().position = point(70., 140.);
view.routes[0].waypoints = vec![point(360., 210.)];
let mut session = Session::new(before.clone());
session.execute(command.clone()).unwrap();
assert_eq!(session.project(), &expected);
session.undo();
assert_eq!(session.project(), &before);
assert!(
!session.can_undo(),
"one movement must be one history entry"
);
session.redo();
assert_eq!(session.project(), &expected);
assert!(!session.can_redo());
// Independent one-node moves cannot see the shared endpoint displacement.
let mut sequential = Session::new(before.clone());
for (node, position) in [(f.a, point(50., 80.)), (f.b, point(440., 80.))] {
sequential
.execute(Command::MoveNode {
diagram: f.diagram,
node,
position,
with_descendants: true,
})
.unwrap();
}
assert_eq!(
sequential.project().diagrams[&f.diagram].routes[0],
before.diagrams[&f.diagram].routes[0]
);
assert_ne!(sequential.project(), &expected);
let json = serde_json::to_string(&expected).unwrap();
assert_eq!(serde_json::from_str::<Project>(&json).unwrap(), expected);
let mut bundle = format_bundle(&before);
bundle
.files
.get_mut(&bundle.entry.clone())
.unwrap()
.insert_str(0, "// preserve movement rationale\n");
let original = compile_bundle(&bundle, ProjectLimits::default()).unwrap();
let prepared = prepare_command(&before, &command).unwrap();
let updated = update_bundle_with_identity_map(
&original,
&prepared.project,
&prepared.identity_map,
ProjectLimits::default(),
)
.unwrap();
assert_eq!(updated.decoded.project, expected);
assert!(updated.bundle.files[&bundle.entry].starts_with("// preserve movement rationale\n"));
let reread = compile_bundle(&updated.bundle, ProjectLimits::default()).unwrap();
assert_eq!(reread.decoded.project, expected);
assert_eq!(reread.bundle, updated.bundle);
}
#[test]
fn selecting_folded_ancestor_and_hidden_descendant_moves_each_once() {
let f = fixture();
assert!(
!projection::project_view(&f.project, f.diagram)
.nodes
.iter()
.any(|n| n.id == f.child)
);
let ancestor = translate_placements(
&f.project,
f.diagram,
&BTreeSet::from([f.a]),
point(-15., 25.),
)
.unwrap();
let overlap = translate_placements(
&f.project,
f.diagram,
&BTreeSet::from([f.a, f.child]),
point(-15., 25.),
)
.unwrap();
assert_eq!(overlap, ancestor);
let after = prepare_command(&f.project, &overlap).unwrap().project;
assert_eq!(
after.diagrams[&f.diagram].nodes[&f.child].position,
point(15., 105.)
);
assert_eq!(
after.diagrams[&f.diagram].routes,
f.project.diagrams[&f.diagram].routes
);
}
#[test]
fn invalid_movements_refuse_atomically_and_zero_has_no_history() {
let f = fixture();
let selected = BTreeSet::from([f.a, f.b]);
for (diagram, ids, delta) in [
(Id::new_v4(), selected.clone(), point(1., 1.)),
(f.diagram, BTreeSet::new(), point(1., 1.)),
(
f.diagram,
BTreeSet::from([f.a, Id::new_v4()]),
point(1., 1.),
),
(f.diagram, BTreeSet::from([f.a, f.foreign]), point(0., 0.)),
(f.diagram, selected.clone(), point(f32::NAN, 1.)),
(f.diagram, selected.clone(), point(1., f32::INFINITY)),
(f.diagram, selected.clone(), point(MAX_COORDINATE, 0.)),
(f.diagram, selected.clone(), point(f32::MAX, f32::MAX)),
] {
let before = f.project.clone();
assert!(translate_placements(&f.project, diagram, &ids, delta).is_err());
assert_eq!(f.project, before);
}
let mut bad_route = f.project.clone();
bad_route.diagrams.get_mut(&f.diagram).unwrap().routes[0].waypoints =
vec![point(MAX_COORDINATE, 0.)];
validate_candidate(&bad_route).unwrap();
let snapshot = bad_route.clone();
assert!(translate_placements(&bad_route, f.diagram, &selected, point(1., 0.)).is_err());
assert_eq!(bad_route, snapshot);
let zero = translate_placements(&f.project, f.diagram, &selected, point(0., -0.)).unwrap();
assert_eq!(zero, Command::Batch(vec![]));
let mut session = Session::new(f.project.clone());
session.execute(zero).unwrap();
assert_eq!(session.project(), &f.project);
assert!(!session.can_undo());
assert_eq!(session.revision(), 0);
}
#[test]
fn typed_component_port_endpoints_follow_their_owner_placements() {
let mut p = Project::empty();
let a = p
.add_element(Kind::System, Layer::Sa, "System", None)
.unwrap();
let b = p
.add_element(Kind::SystemActor, Layer::Sa, "Actor", None)
.unwrap();
let output = Id::new_v4();
let input = Id::new_v4();
for (id, owner, name, orientation) in [
(output, a, "Output", ComponentPortOrientation::Out),
(input, b, "Input", ComponentPortOrientation::In),
] {
let port = make_port(
&p,
id,
owner,
name,
PortContract::Component {
kind: ComponentPortKind::Flow,
orientation,
},
)
.unwrap();
p.elements.insert(id, port);
}
let edge = p.relate(RelationKind::BoundaryExchange, "Boundary", output, input);
let d = p.ensure_diagram(Layer::Sa);
let na = p.place(d, a, point(0., 0.));
let nb = p.place(d, b, point(400., 0.));
for (node, port, side, offset) in [
(na, output, PortAnchorSide::Bottom, 0.3),
(nb, input, PortAnchorSide::Top, 0.7),
] {
p.diagrams
.get_mut(&d)
.unwrap()
.nodes
.get_mut(&node)
.unwrap()
.port_anchors
.push(PortAnchor { port, side, offset });
}
p.diagrams
.get_mut(&d)
.unwrap()
.routes
.push(route(edge, 300., 100.));
validate_candidate(&p).unwrap();
let command = translate_placements(&p, d, &BTreeSet::from([na, nb]), point(10., -20.)).unwrap();
let after = prepare_command(&p, &command).unwrap().project;
let mut expected = p.clone();
let view = expected.diagrams.get_mut(&d).unwrap();
view.nodes.get_mut(&na).unwrap().position = point(10., -20.);
view.nodes.get_mut(&nb).unwrap().position = point(410., -20.);
view.routes[0].waypoints = vec![point(310., 80.)];
assert_eq!(after, expected);
}
#[test]
fn allocation_movement_follows_visual_performers_and_same_performer_work_groups() {
let mut p = Project::empty();
let performer_a = p
.add_element(Kind::Entity, Layer::Oa, "Performer A", None)
.unwrap();
let performer_b = p
.add_element(Kind::Entity, Layer::Oa, "Performer B", None)
.unwrap();
let mixed = p
.add_element(Kind::Activity, Layer::Oa, "Mixed work", None)
.unwrap();
let branch = p
.add_element(Kind::Activity, Layer::Oa, "A work", Some(mixed))
.unwrap();
let leaf_a = p
.add_element(Kind::Activity, Layer::Oa, "A leaf", Some(branch))
.unwrap();
let leaf_b = p
.add_element(Kind::Activity, Layer::Oa, "B leaf", Some(mixed))
.unwrap();
p.relate(RelationKind::Allocation, "A performs", performer_a, leaf_a);
p.relate(RelationKind::Allocation, "B performs", performer_b, leaf_b);
let d = p.ensure_diagram(Layer::Oa);
p.diagrams.get_mut(&d).unwrap().projection = ProjectionKind::Allocation;
let na = p.place(d, performer_a, point(0., 0.));
p.place(d, performer_b, point(600., 0.));
let nm = p.place(d, mixed, point(1000., 0.));
let branch_node = p.place(d, branch, point(20., 80.));
let leaf_node = p.place(d, leaf_a, point(40., 160.));
p.place(d, leaf_b, point(620., 80.));
validate_candidate(&p).unwrap();
// Mixed work retains semantic ownership of both branches, but its leaves
// have different performers. A work is visually nested under Performer A,
// with its own same-performer leaf nested inside it.
let command =
translate_placements(&p, d, &BTreeSet::from([na, branch_node]), point(15., -25.)).unwrap();
let after = prepare_command(&p, &command).unwrap().project;
let mut expected = p.clone();
let view = expected.diagrams.get_mut(&d).unwrap();
view.nodes.get_mut(&na).unwrap().position = point(15., -25.);
view.nodes.get_mut(&branch_node).unwrap().position = point(35., 55.);
view.nodes.get_mut(&leaf_node).unwrap().position = point(55., 135.);
assert_eq!(after, expected);
// Moving the semantic parent does not move children displayed elsewhere.
let command = translate_placements(&p, d, &BTreeSet::from([nm]), point(15., -25.)).unwrap();
let after = prepare_command(&p, &command).unwrap().project;
let mut expected = p.clone();
expected
.diagrams
.get_mut(&d)
.unwrap()
.nodes
.get_mut(&nm)
.unwrap()
.position = point(1015., -25.);
assert_eq!(after, expected);
}
#[test]
fn traceability_movement_is_flat_even_for_a_semantic_parent() {
let mut f = fixture();
f.project.diagrams.get_mut(&f.diagram).unwrap().projection = ProjectionKind::Traceability;
validate_candidate(&f.project).unwrap();
let command = translate_placements(
&f.project,
f.diagram,
&BTreeSet::from([f.a]),
point(40., 60.),
)
.unwrap();
let after = prepare_command(&f.project, &command).unwrap().project;
let mut expected = f.project.clone();
expected
.diagrams
.get_mut(&f.diagram)
.unwrap()
.nodes
.get_mut(&f.a)
.unwrap()
.position = point(50., 80.);
assert_eq!(
after, expected,
"flat view movement must preserve semantic descendants, routes, other views and all semantics"
);
}
#[test]
fn state_entry_pins_follow_the_boundary_only_when_it_also_moves() {
let mut p = Project::empty();
let machine = p
.add_element(Kind::StateMachine, Layer::Oa, "Machine", None)
.unwrap();
let region = p
.add_element(Kind::Region, Layer::Oa, "Region", Some(machine))
.unwrap();
let composite = p
.add_element(Kind::State, Layer::Oa, "Composite", Some(region))
.unwrap();
let inner = p
.add_element(Kind::Region, Layer::Oa, "Inner", Some(composite))
.unwrap();
let entry = p
.add_element(Kind::EntryPoint, Layer::Oa, "Entry", Some(inner))
.unwrap();
let leaf = p
.add_element(Kind::State, Layer::Oa, "Leaf", Some(inner))
.unwrap();
let edge = p.relate(RelationKind::StateTransition, "Enter", entry, leaf);
if let RelationBody::StateTransition {
transition_kind, ..
} = &mut p.relations.get_mut(&edge).unwrap().body
{
*transition_kind = TransitionKind::Local;
}
let d = p.ensure_diagram(Layer::Oa);
p.diagrams.get_mut(&d).unwrap().projection = ProjectionKind::StateMachine { machine };
let boundary = p.place(d, composite, point(0., 0.));
let ne = p.place(d, entry, point(20., 80.));
let nl = p.place(d, leaf, point(100., 80.));
p.diagrams
.get_mut(&d)
.unwrap()
.routes
.push(route(edge, 90., 100.));
validate_candidate(&p).unwrap();
let endpoints =
translate_placements(&p, d, &BTreeSet::from([ne, nl]), point(10., 20.)).unwrap();
let after = prepare_command(&p, &endpoints).unwrap().project;
assert_eq!(after.diagrams[&d].routes, p.diagrams[&d].routes);
let with_boundary =
translate_placements(&p, d, &BTreeSet::from([boundary, ne, nl]), point(10., 20.)).unwrap();
let after = prepare_command(&p, &with_boundary).unwrap().project;
assert_eq!(
after.diagrams[&d].routes[0].waypoints,
vec![point(100., 120.)]
);
assert_eq!(after.diagrams[&d].nodes[&ne].position, point(30., 100.));
}
+34 -13
View File
@@ -3,6 +3,16 @@ use oa_core::{language, persistence, validation, *};
fn read(source: &str) -> Project {
language::parse(source).unwrap_or_else(|e| panic!("{e:?}\n{source}"))
}
fn replace_once(source: &str, before: &str, after: &str) -> String {
assert_eq!(
source.matches(before).count(),
1,
"unique mutation anchor {before:?}"
);
let changed = source.replacen(before, after, 1);
assert_ne!(changed, source, "test mutation must change the source");
changed
}
fn rejected(source: &str, fragment: &str) {
let errors = language::parse(source).expect_err(source);
assert!(
@@ -753,8 +763,8 @@ fn typed_events_vertices_and_transition_kinds_round_trip_in_both_layers() {
source.push('}');
let model = read(&source);
let canonical = language::format(&model);
assert!(!canonical.contains("transition_kind External;"));
for fragment in [
"transition_kind External;",
"transition_kind Local;",
"transition_kind Internal;",
"triggers [@deadline, @assess];",
@@ -776,6 +786,7 @@ fn typed_events_vertices_and_transition_kinds_round_trip_in_both_layers() {
.find(|r| r.alias == "recover")
.unwrap();
let RelationBody::StateTransition {
transition_kind,
trigger,
guard,
effect,
@@ -784,32 +795,42 @@ fn typed_events_vertices_and_transition_kinds_round_trip_in_both_layers() {
else {
panic!("expected transition")
};
assert_eq!(*transition_kind, TransitionKind::External);
assert_eq!(trigger, "Operator confirms\nНе запускать код");
assert_eq!(guard, "available && status == \"Ready\"");
assert_eq!(effect, "Record the assessment");
// External is omitted in compact output. Insert an explicit invalid
// discriminator into Recover using its unique, nondefault trigger list.
let invalid_kind = replace_once(
&canonical,
"triggers [@deadline, @assess];",
"transition_kind Unsupported;\n triggers [@deadline, @assess];",
);
rejected(&invalid_kind, "Unsupported");
for invalid in [
canonical.replace("transition_kind External;", "transition_kind Unsupported;"),
canonical.replace(
replace_once(
&canonical,
"triggers [@deadline, @assess];",
"triggers [\"deadline\", @assess];",
),
canonical.replace(
replace_once(
&canonical,
"triggers [@deadline, @assess];",
"triggers [@deadline, @deadline];",
),
canonical.replace("effects [@assess];", "effects [@payload];"),
canonical.replace("kind After;", "kind Relative;"),
canonical.replace("items [@payload];", "items [@assess];"),
replace_once(&canonical, "effects [@assess];", "effects [@payload];"),
replace_once(&canonical, "kind After;", "kind Relative;"),
replace_once(&canonical, "items [@payload];", "items [@assess];"),
] {
assert!(
language::parse(&invalid).is_err(),
"invalid typed state/event source accepted"
);
}
let legacy = canonical
.replace("transition_kind External;", "")
.replace("triggers [@deadline, @assess];", "")
.replace("effects [@assess];", "");
// Canonical source already omits External. Removing the supplied lists
// independently exercises their empty omission defaults and retained prose.
let legacy = replace_once(&canonical, "triggers [@deadline, @assess];", "");
let legacy = replace_once(&legacy, "effects [@assess];", "");
let legacy = read(&legacy);
let RelationBody::StateTransition {
transition_kind,
@@ -829,7 +850,7 @@ fn typed_events_vertices_and_transition_kinds_round_trip_in_both_layers() {
(retained_trigger, retained_guard, retained_effect),
(trigger, guard, effect)
);
let draft = canonical.replace("expression \"30 s\";", "expression \"\";");
let draft = replace_once(&canonical, "expression \"30 s\";", "expression \"\";");
assert!(language::parse(&draft).is_ok());
let deadline = model
.elements
@@ -1038,7 +1059,7 @@ fn original_v2_text_and_json_preserve_identity_and_mixed_scenarios_after_extensi
"fixture must cover actual old scenario content"
);
let canonical = language::format(&from_source);
assert!(canonical.contains("viewpoint MixedLegacy;"));
assert!(!canonical.contains("viewpoint MixedLegacy;"));
for relation in from_source.relations.values() {
if let RelationBody::StateTransition {
transition_kind,
@@ -0,0 +1,205 @@
//! Current latent presentation must not broaden frozen source/JSON admission.
use oa_core::{view_options::*, *};
fn legacy_json(project: &Project, version: u32) -> Vec<u8> {
let mut value = serde_json::to_value(project).unwrap();
value["schema_version"] = version.into();
for diagram in value["diagrams"].as_object_mut().unwrap().values_mut() {
let fields = diagram.as_object_mut().unwrap();
fields.remove("layout");
if version < 6 {
fields.remove("view_options");
}
}
serde_json::to_vec(&value).unwrap()
}
fn model_issue(error: persistence::FileError) -> validation::Issue {
match error {
persistence::FileError::Model { mut issues, .. } => issues.remove(0),
other => panic!("Expected a located model issue, got {other:?}"),
}
}
#[test]
fn frozen_3_through_6_reject_cross_layer_nodes_but_keep_mixed_traceability() {
let node_id = Id::parse_str("00000000-0000-4000-8000-000000000004").unwrap();
for version in 3..=6 {
let source = format!(
"arcadia {version}; project compat \"Compatibility\" id \"00000000-0000-4000-8000-000000000001\" {{\n\
sa_function work \"Counterpart\" id \"00000000-0000-4000-8000-000000000002\" {{}}\n\
diagram view \"OA concern\" id \"00000000-0000-4000-8000-000000000003\" {{ layer Oa; projection Traceability; }}\n\
node occurrence id \"00000000-0000-4000-8000-000000000004\" {{ diagram @view; element @work; }}\n}}"
);
let mixed = language::parse(&source).unwrap();
assert_eq!(
persistence::decode(&legacy_json(&mixed, version)).unwrap(),
mixed
);
let ordinary = source.replace("projection Traceability", "projection Overview");
let same_layer = ordinary.replace("layer Oa", "layer Sa");
let ordinary_project = language::parse(&same_layer).unwrap();
assert_eq!(
persistence::decode(&legacy_json(&ordinary_project, version)).unwrap(),
ordinary_project
);
let errors = language::parse(&ordinary).unwrap_err();
// Source3 already reported migration errors with the offending location.
let code = if version == 3 {
"migration_error"
} else {
"diagram_layer"
};
assert!(
errors.iter().any(|error| error.code == code
&& error.line == 4
&& error.message.contains("selected layer")),
"{version}: {errors:?}"
);
let mut latent = mixed.clone();
let diagram = latent.diagrams.values_mut().next().unwrap();
diagram.projection = ProjectionKind::Overview;
let node = diagram.nodes.values().next().unwrap().id;
assert_eq!(node, node_id);
if version >= 4 {
let diagnostic = errors
.iter()
.find(|error| error.code == "diagram_layer")
.unwrap();
assert_eq!(diagnostic.object_id, Some(node_id));
assert_eq!(diagnostic.field.as_deref(), Some("element"));
let bundle = source_project::SourceBundle {
entry: "model.arcadia".into(),
files: std::collections::BTreeMap::from([("model.arcadia".into(), ordinary)]),
};
let errors =
source_project::compile_bundle(&bundle, source_project::ProjectLimits::default())
.unwrap_err();
let diagnostic = &errors
.iter()
.find(|error| error.diagnostic.code == "diagram_layer")
.unwrap()
.diagnostic;
assert_eq!(diagnostic.object_id, Some(node_id));
assert_eq!(diagnostic.field.as_deref(), Some("element"));
}
let issue = model_issue(persistence::decode(&legacy_json(&latent, version)).unwrap_err());
assert_eq!(issue.code, "diagram_layer");
assert_eq!(issue.element, Some(node));
assert_eq!(issue.field.as_deref(), Some("element"));
// The exact same record is valid under the current presentation contract.
assert_eq!(language::parse(&language::format(&latent)).unwrap(), latent);
assert_eq!(
persistence::decode(&serde_json::to_vec(&latent).unwrap()).unwrap(),
latent
);
let mut dangling = mixed;
dangling
.diagrams
.values_mut()
.next()
.unwrap()
.nodes
.values_mut()
.next()
.unwrap()
.element = Id::parse_str("00000000-0000-4000-8000-000000000005").unwrap();
let issue = model_issue(persistence::decode(&legacy_json(&dangling, version)).unwrap_err());
assert_eq!(issue.code, "diagram_layer");
assert_eq!(issue.element, Some(node));
assert_eq!(issue.field.as_deref(), Some("element"));
}
}
#[test]
fn frozen_6_checks_every_layer_sensitive_saved_selector_before_upgrade() {
let mut project = Project::empty();
let a = project
.add_element(Kind::Activity, Layer::Oa, "A", None)
.unwrap();
let b = project
.add_element(Kind::Activity, Layer::Oa, "B", None)
.unwrap();
let edge = project.relate(RelationKind::Interaction, "Interaction", a, b);
let category = project
.add_element(Kind::ExchangeCategory, Layer::Oa, "Work category", None)
.unwrap();
let participant_category = project
.add_element(
Kind::ExchangeCategory,
Layer::Oa,
"Participant category",
None,
)
.unwrap();
*project
.elements
.get_mut(&participant_category)
.unwrap()
.shared_mut()
.unwrap() = SharedBody::ExchangeCategory {
family: ExchangeFamily::Participant,
exchanges: vec![],
};
let context = project
.add_element(Kind::Process, Layer::Oa, "Process", None)
.unwrap();
let diagram = project.ensure_diagram(Layer::Sa);
for (field, options) in [
(
"hidden_edges",
ViewOptions {
hidden_edges: vec![edge],
..Default::default()
},
),
(
"work_categories",
ViewOptions {
work_categories: CategorySelection::Any(vec![category]),
..Default::default()
},
),
(
"participant_categories",
ViewOptions {
participant_categories: CategorySelection::Any(vec![participant_category]),
..Default::default()
},
),
(
"work_contexts",
ViewOptions {
work_contexts: ContextSelection::Any(vec![context]),
..Default::default()
},
),
] {
project.diagrams.get_mut(&diagram).unwrap().view_options = options;
let issue = model_issue(persistence::decode(&legacy_json(&project, 6)).unwrap_err());
assert_eq!(issue.code, "view_options");
assert_eq!(issue.element, Some(diagram));
assert_eq!(issue.field.as_deref(), Some(field));
let current_source = language::format(&project);
assert_eq!(language::parse(&current_source).unwrap(), project);
let source6 = current_source.replacen("arcadia 7;", "arcadia 6;", 1);
let errors = language::parse(&source6).unwrap_err();
let diagnostic = errors
.iter()
.find(|error| error.code == "view_options")
.unwrap();
assert_eq!(diagnostic.object_id, Some(diagram));
assert_eq!(diagnostic.field.as_deref(), Some(field));
project.diagrams.get_mut(&diagram).unwrap().projection = ProjectionKind::Traceability;
assert_eq!(
persistence::decode(&legacy_json(&project, 6)).unwrap(),
project
);
let source6 = language::format(&project).replacen("arcadia 7;", "arcadia 6;", 1);
assert_eq!(language::parse(&source6).unwrap(), project);
project.diagrams.get_mut(&diagram).unwrap().projection = ProjectionKind::Overview;
}
}
@@ -0,0 +1,304 @@
//! Populated presentation survives mixed-layer projection switches.
//! Complements projection_switching.rs without external fixture dependencies.
use oa_core::{
projection::*, routing::route_diagram, source_project::*, view_options::ViewIndex, *,
};
struct Populated {
project: Project,
diagram: Id,
groups: [Id; 2],
children: [[Id; 2]; 2],
edges: [Id; 2],
ports: [Id; 2],
trace: Id,
}
fn xy(x: f32, y: f32) -> Position {
Position { x, y }
}
fn apply(project: &mut Project, command: Command) {
*project = prepare_command(project, &command).unwrap().project;
}
fn fixture(layer: Layer, collapsed: bool) -> Populated {
let mut p = Project::empty();
let oa_group = p
.add_element(Kind::Activity, Layer::Oa, "Operational response", None)
.unwrap();
let oa_send = p
.add_element(Kind::Activity, Layer::Oa, "Request repair", Some(oa_group))
.unwrap();
let oa_receive = p
.add_element(Kind::Activity, Layer::Oa, "Perform repair", Some(oa_group))
.unwrap();
let sa_group = p
.add_element(Kind::Function, Layer::Sa, "System response", None)
.unwrap();
let sa_send = p
.add_element(Kind::Function, Layer::Sa, "Dispatch repair", Some(sa_group))
.unwrap();
let sa_receive = p
.add_element(Kind::Function, Layer::Sa, "Execute repair", Some(sa_group))
.unwrap();
let oa_edge = p.relate(
RelationKind::Interaction,
"Repair request",
oa_send,
oa_receive,
);
let sa_edge = p.relate(
RelationKind::FunctionalExchange,
"Repair dispatch",
sa_send,
sa_receive,
);
let RelationBody::FunctionalExchange {
source_port,
target_port,
..
} = p.relations[&sa_edge].body
else {
unreachable!()
};
let trace = Id::new_v4();
let refinement = Trace {
id: trace,
alias: p.fresh_alias("dispatch_refinement", "trace", trace),
name: "Dispatch refines repair request".into(),
description: "Keep this cross-layer trace and its authored route.".into(),
oa: oa_send,
sa: sa_send,
kind: TraceKind::Refinement,
baseline: Some(TraceBaseline {
oa: p.properties(oa_send).unwrap(),
sa: p.properties(sa_send).unwrap(),
semantic: None,
}),
};
apply(&mut p, Command::CreateTrace(refinement));
let d = p.ensure_diagram(layer);
p.diagrams.get_mut(&d).unwrap().projection = ProjectionKind::Traceability;
let oa_group_node = p.place(d, oa_group, xy(0., 0.));
let oa_send_node = p.place(d, oa_send, xy(40., 120.));
let oa_receive_node = p.place(d, oa_receive, xy(360., 120.));
let sa_group_node = p.place(d, sa_group, xy(0., 500.));
let sa_send_node = p.place(d, sa_send, xy(40., 620.));
let sa_receive_node = p.place(d, sa_receive, xy(360., 620.));
for (node, port, side, offset) in [
(sa_send_node, source_port, PortAnchorSide::Bottom, 0.25),
(sa_receive_node, target_port, PortAnchorSide::Top, 0.75),
] {
apply(
&mut p,
Command::SetPortAnchor {
diagram: d,
node,
anchor: PortAnchor { port, side, offset },
},
);
}
for (edge, y, port_backed) in [
(oa_edge, 260., false),
(sa_edge, 860., true),
(trace, 420., false),
] {
apply(
&mut p,
Command::SetEdgeRoute {
diagram: d,
route: EdgeRoute {
edge,
source_attachment: (!port_backed).then_some(EdgeAttachment {
side: PortAnchorSide::Right,
offset: 0.3,
}),
target_attachment: (!port_backed).then_some(EdgeAttachment {
side: PortAnchorSide::Left,
offset: 0.7,
}),
waypoints: vec![xy(680., y), xy(740., y)],
label: Some(EdgeLabel {
fraction: 0.4,
offset: xy(13., -27.),
}),
},
},
);
}
for node in [oa_group_node, sa_group_node] {
let mut placement = p.diagrams[&d].nodes[&node].clone();
placement.collapsed = collapsed;
placement.size = Size {
width: 310.,
height: 170.,
};
apply(
&mut p,
Command::EditNode {
diagram: d,
original: node,
node: placement,
},
);
}
validate_candidate(&p).unwrap();
Populated {
project: p,
diagram: d,
groups: [oa_group_node, sa_group_node],
children: [
[oa_send_node, oa_receive_node],
[sa_send_node, sa_receive_node],
],
edges: [oa_edge, sa_edge],
ports: [source_port, target_port],
trace,
}
}
fn assert_round_trips(p: &Project) {
assert_eq!(language::parse(&language::format(p)).unwrap(), *p);
assert_eq!(
persistence::decode(&serde_json::to_vec(p).unwrap()).unwrap(),
*p
);
assert_eq!(
compile_bundle(&format_bundle(p), ProjectLimits::default())
.unwrap()
.decoded
.project,
*p
);
}
fn assert_layer_only(p: &Project, d: Id, view: &ProjectedView) {
let layer = p.diagrams[&d].layer;
for node in &view.nodes {
assert_eq!(p.elements[&node.element].layer(), layer);
if let Some(parent) = node.visual_parent {
let parent = view.nodes.iter().find(|n| n.id == parent).unwrap();
assert_eq!(p.elements[&parent.element].layer(), layer);
}
}
for port in &view.ports {
assert_eq!(p.elements[&port.element].layer(), layer);
let owner = view.nodes.iter().find(|n| n.id == port.owner).unwrap();
assert_eq!(p.elements[&owner.element].layer(), layer);
}
for edge in &view.edges {
assert!(
!edge.trace,
"cross-layer trace leaked into ordinary projection"
);
assert_eq!(p.layer_of(edge.id), Some(layer));
for endpoint in [edge.source, edge.target] {
let owner = view.nodes.iter().find(|n| n.id == endpoint).unwrap();
assert_eq!(p.elements[&owner.element].layer(), layer);
}
for port in [edge.source_port, edge.target_port].into_iter().flatten() {
assert_eq!(p.elements[&port].layer(), layer);
}
}
}
#[test]
fn populated_mixed_projection_switch_conserves_authorship_and_restores_geometry() {
for layer in [Layer::Oa, Layer::Sa] {
for collapsed in [false, true] {
let f = fixture(layer, collapsed);
let baseline = &f.project;
let d = f.diagram;
let before_view = project_view(baseline, d);
let before_routes = route_diagram(baseline, &baseline.diagrams[&d], &before_view);
// Guard against a vacuous fixture: Traceability deliberately flattens parents.
assert_eq!(before_view.nodes.len(), 6);
assert!(before_view.nodes.iter().all(|n| n.visual_parent.is_none()));
for id in [f.edges[0], f.edges[1], f.trace] {
assert!(before_view.edges.iter().any(|e| e.id == id));
let route = baseline.diagrams[&d]
.routes
.iter()
.find(|r| r.edge == id)
.unwrap();
assert_eq!(route.waypoints.len(), 2);
assert!(route.label.is_some());
}
for port in f.ports {
assert!(before_view.ports.iter().any(|p| p.element == port));
}
assert_round_trips(baseline);
for projection in [
ProjectionKind::Overview,
ProjectionKind::Decomposition,
ProjectionKind::Allocation,
ProjectionKind::FunctionalDataflow,
ProjectionKind::Capabilities,
] {
let mut expected = baseline.clone();
expected.diagrams.get_mut(&d).unwrap().projection = projection;
let mut session = Session::new(baseline.clone());
session
.execute(Command::EditDiagram(d, expected.diagrams[&d].clone()))
.unwrap();
assert_eq!(session.project(), &expected);
assert_round_trips(session.project());
let view = project_view(session.project(), d);
assert_eq!(
view,
project_view_with_index(&expected, d, &ViewIndex::new(&expected))
);
assert_layer_only(session.project(), d, &view);
if projection == ProjectionKind::Overview {
let own = if layer == Layer::Oa { 0 } else { 1 };
assert!(view.nodes.iter().any(|n| n.id == f.groups[own]));
for child in f.children[own] {
if collapsed {
assert!(!view.nodes.iter().any(|n| n.id == child));
} else {
assert_eq!(
view.nodes
.iter()
.find(|n| n.id == child)
.unwrap()
.visual_parent,
Some(f.groups[own])
);
}
}
if !collapsed {
assert!(view.edges.iter().any(|e| e.id == f.edges[own]));
if layer == Layer::Sa {
assert_eq!(view.ports.len(), 2);
}
}
}
session.undo();
assert_eq!(session.project(), baseline);
assert!(
!session.can_undo(),
"projection change must be one model history step"
);
assert_round_trips(session.project());
session.redo();
assert_eq!(session.project(), &expected);
session
.execute(Command::EditDiagram(d, baseline.diagrams[&d].clone()))
.unwrap();
assert_eq!(session.project(), baseline);
let restored = project_view(session.project(), d);
assert_eq!(restored, before_view);
assert_eq!(
route_diagram(
session.project(),
&session.project().diagrams[&d],
&restored
),
before_routes
);
assert_round_trips(session.project());
}
}
}
}
+11 -1
View File
@@ -441,6 +441,8 @@ fn nested_owner_bounds_group_movement_and_authored_routes_share_the_same_geometr
diagram: d,
route: EdgeRoute {
edge,
source_attachment: None,
target_attachment: None,
waypoints: vec![pos(500., 420.)],
label: None,
},
@@ -767,7 +769,15 @@ fn resetting_last_source_anchors_preserves_comments_and_can_add_a_new_manual_rec
assert_eq!(cleared.decoded.project, prepared.project);
let text = &cleared.bundle.files["project.arcadia"];
assert!(text.contains("// first rationale") && text.contains("// second rationale"));
assert!(!text.contains("port_anchors"));
// Lossless editing may retain the authored empty field and its annotations.
// Canonical export, rather than a model update, normalizes default syntax.
assert!(!language::format(&cleared.decoded.project).contains("port_anchors"));
assert_eq!(
update_bundle(&cleared, &cleared.decoded.project, ProjectLimits::default())
.unwrap()
.bundle,
cleared.bundle
);
let prepared = prepare_command(
&cleared.decoded.project,
&Command::SetPortAnchor {
@@ -0,0 +1,156 @@
use oa_core::{source_project::*, view_options::*, *};
fn fixture(layer: Layer) -> (Project, Id, Id, Id, Id) {
let mut p = Project::empty();
let oa = p
.add_element(Kind::Activity, Layer::Oa, "Assess alarm", None)
.unwrap();
let oa2 = p
.add_element(Kind::Activity, Layer::Oa, "Respond", None)
.unwrap();
let sa = p
.add_element(Kind::Function, Layer::Sa, "Assess alarm", None)
.unwrap();
let edge = p.relate(RelationKind::Interaction, "Alarm", oa, oa2);
let category = p
.add_element(Kind::ExchangeCategory, Layer::Oa, "Alarm data", None)
.unwrap();
*p.elements.get_mut(&category).unwrap().shared_mut().unwrap() = SharedBody::ExchangeCategory {
family: ExchangeFamily::Work,
exchanges: vec![edge],
};
let context = p
.add_element(Kind::Process, Layer::Oa, "Alarm handling", None)
.unwrap();
let d = p.ensure_diagram(layer);
p.diagrams.get_mut(&d).unwrap().projection = ProjectionKind::Traceability;
for (i, element) in [oa, oa2, sa].into_iter().enumerate() {
p.place(
d,
element,
Position {
x: 300. * i as f32,
y: 40.,
},
);
}
p.diagrams.get_mut(&d).unwrap().view_options = ViewOptions {
hidden_edges: vec![edge],
work_categories: CategorySelection::Any(vec![category]),
work_contexts: ContextSelection::Any(vec![context]),
..Default::default()
};
validate_candidate(&p).unwrap();
(p, d, oa, sa, category)
}
#[test]
fn projection_switching_preserves_complete_model_history_and_all_serializations() {
for layer in [Layer::Oa, Layer::Sa] {
let (baseline, diagram, _, _, _) = fixture(layer);
for projection in [
ProjectionKind::Overview,
ProjectionKind::Decomposition,
ProjectionKind::Allocation,
ProjectionKind::FunctionalDataflow,
ProjectionKind::Capabilities,
] {
let mut expected = baseline.clone();
expected.diagrams.get_mut(&diagram).unwrap().projection = projection;
let mut session = Session::new(baseline.clone());
session
.execute(Command::EditDiagram(
diagram,
expected.diagrams[&diagram].clone(),
))
.unwrap();
assert_eq!(session.project(), &expected);
let view = projection::project_view(session.project(), diagram);
assert!(
view.nodes
.iter()
.all(|n| expected.elements[&n.element].layer() == layer)
);
assert_eq!(
view,
projection::project_view_with_index(&expected, diagram, &ViewIndex::new(&expected))
);
let source = language::format(&expected);
assert_eq!(language::parse(&source).unwrap(), expected);
assert_eq!(
persistence::decode(&serde_json::to_vec(&expected).unwrap()).unwrap(),
expected
);
let bundle = format_bundle(&expected);
assert_eq!(
compile_bundle(&bundle, ProjectLimits::default())
.unwrap()
.decoded
.project,
expected
);
session.undo();
assert_eq!(session.project(), &baseline);
assert!(!session.can_undo());
session.redo();
assert_eq!(session.project(), &expected);
session
.execute(Command::EditDiagram(
diagram,
baseline.diagrams[&diagram].clone(),
))
.unwrap();
assert_eq!(session.project(), &baseline);
assert_eq!(
projection::project_view(session.project(), diagram),
projection::project_view(&baseline, diagram)
);
}
}
}
#[test]
fn projection_switching_does_not_admit_missing_elements_or_invalid_filter_kinds() {
let (mut p, d, oa, _, category) = fixture(Layer::Sa);
p.diagrams.get_mut(&d).unwrap().projection = ProjectionKind::Allocation;
validate_candidate(&p).unwrap();
let valid = p.clone();
let node = p.diagrams[&d]
.nodes
.values()
.find(|n| n.element == oa)
.unwrap()
.id;
p.diagrams
.get_mut(&d)
.unwrap()
.nodes
.get_mut(&node)
.unwrap()
.element = Id::new_v4();
assert!(validate_candidate(&p).is_err());
p = valid.clone();
p.diagrams.get_mut(&d).unwrap().view_options.work_categories = CategorySelection::Any(vec![oa]);
assert!(validate_candidate(&p).is_err());
p = valid;
p.elements.remove(&category);
assert!(validate_candidate(&p).is_err());
}
#[test]
fn projection_switching_keeps_typed_state_membership_strict() {
let (mut p, d, _, _, _) = fixture(Layer::Sa);
let system = p
.add_element(Kind::System, Layer::Sa, "System", None)
.unwrap();
let machine = p
.add_element(Kind::StateMachine, Layer::Sa, "Modes", Some(system))
.unwrap();
p.diagrams.get_mut(&d).unwrap().projection = ProjectionKind::StateMachine { machine };
assert!(
validation::validate(&p)
.iter()
.any(|i| i.code == "state_diagram_member")
);
assert!(validate_candidate(&p).is_err());
}
+5
View File
@@ -41,6 +41,7 @@ fn fixture() -> Fixture {
projection: ProjectionKind::Overview,
view_options: Default::default(),
nodes: BTreeMap::new(),
layout: None,
routes: vec![],
},
);
@@ -70,6 +71,8 @@ fn fixture() -> Fixture {
fn route(edge: Id) -> EdgeRoute {
EdgeRoute {
edge,
source_attachment: None,
target_attachment: None,
waypoints: vec![Position { x: 550., y: 240. }, Position { x: 550., y: 360. }],
label: Some(EdgeLabel {
fraction: 0.4,
@@ -689,6 +692,8 @@ fn old_models_get_empty_routes_and_route_json_is_strict() {
serde_json::from_value::<EdgeRoute>(minimal).unwrap(),
EdgeRoute {
edge: f.edge,
source_attachment: None,
target_attachment: None,
waypoints: vec![],
label: None
}
+2
View File
@@ -27,6 +27,8 @@ fn route(p: &Project, d: Id) -> RoutedView {
fn set_route(p: &mut Project, d: Id, edge: Id, pins: Vec<Position>, label: Option<EdgeLabel>) {
p.diagrams.get_mut(&d).unwrap().routes.push(EdgeRoute {
edge,
source_attachment: None,
target_attachment: None,
waypoints: pins,
label,
});
+2
View File
@@ -117,6 +117,8 @@ fn authored_overlap_is_exact_and_reset_only_repositions_automatic_text() {
.routes
.push(EdgeRoute {
edge: fe,
source_attachment: None,
target_attachment: None,
waypoints: vec![],
label: Some(authored.clone()),
});
+12 -1
View File
@@ -67,7 +67,18 @@ fn source6_traceability_default_keeps_both_layers_and_indexed_projection_matches
.any(|e| e.id == interaction)
);
p.diagrams.get_mut(&d).unwrap().projection = ProjectionKind::Overview;
assert!(validate_candidate(&p).is_err());
validate_candidate(&p).unwrap();
let ordinary = projection::project_view_with_index(&p, d, &index);
assert!(
ordinary
.nodes
.iter()
.all(|n| p.elements[&n.element].layer() == Layer::Oa)
);
assert!(ordinary.edges.iter().any(|e| e.id == interaction));
assert!(!ordinary.edges.iter().any(|e| e.id == fe));
assert_eq!(p.diagrams[&d].nodes.len(), 4);
assert_eq!(p.diagrams[&d].view_options.hidden_edges, vec![fe]);
p.diagrams.get_mut(&d).unwrap().projection = ProjectionKind::Traceability;
p.diagrams.get_mut(&d).unwrap().view_options.relation_kinds = RelationKinds::Only(vec![]);
assert!(
+1 -1
View File
@@ -261,7 +261,7 @@ fn source7_context_lifecycle_source_history_and_closed_admission() {
assert_eq!(session.project(), &after);
let fresh = Id::new_v4();
let mut reidentified = after.clone();
references::remap_references(&mut reidentified, f.r1, fresh);
references::remap_references(&mut reidentified, f.r1, fresh).unwrap();
let mut e = reidentified.elements.remove(&f.r1).unwrap();
e.id = fresh;
reidentified.elements.insert(fresh, e);
+84
View File
@@ -383,3 +383,87 @@ fn atomic_save_preserves_existing_unix_permissions() {
0o640
);
}
#[test]
fn discovery_diagnostics_keep_raw_no_uuid_spans() {
let directory = setup();
let path = directory.path().join("parts/a.arcadia");
let raw =
"// Unicode: 航海\nsa_function first \"First\" {}\nsa_function second \"Second\" {}\n";
fs::write(&path, raw).unwrap();
let disk = load_project(
&directory.path().join("project.arcadia"),
ProjectLimits::default(),
)
.unwrap();
assert_eq!(disk.bundle.files["parts/a.arcadia"], raw);
let issue = disk
.diagnostics
.iter()
.find(|issue| {
issue.diagnostic.code == "unallocated_leaf"
&& issue.diagnostic.message.contains("Second")
})
.unwrap();
assert_eq!(issue.path, "parts/a.arcadia");
assert_eq!(issue.diagnostic.line, 3);
assert_eq!(issue.diagnostic.column, 1);
let span = issue.diagnostic.span;
assert_eq!(span.start, raw.find("sa_function second").unwrap());
assert_eq!(
&raw[span.start..span.end],
"sa_function second \"Second\" {}"
);
let compiled = compile_bundle(&disk.reachable_bundle(), ProjectLimits::default()).unwrap();
assert_ne!(compiled.bundle, disk.bundle);
assert!(compiled.diagnostics.iter().any(|later| {
later.diagnostic.code == issue.diagnostic.code
&& later.diagnostic.message == issue.diagnostic.message
&& later.diagnostic.span.start > span.start
}));
assert_eq!(fs::read_to_string(path).unwrap(), raw);
}
#[test]
fn discovery_diagnoses_legacy_semantics_before_source_rewrite() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("project.arcadia");
let raw = include_str!("fixtures/v3-data-contract.arcadia");
fs::write(&path, raw).unwrap();
let disk = load_project(&path, ProjectLimits::default()).unwrap();
assert_eq!(disk.bundle.files["project.arcadia"], raw);
assert!(!disk.diagnostics.is_empty());
assert!(
disk.diagnostics
.iter()
.all(|issue| { issue.diagnostic.severity == oa_core::validation::Severity::Warning })
);
assert_eq!(
disk.diagnostics,
lint_bundle(&disk.reachable_bundle(), ProjectLimits::default())
);
let compiled = compile_bundle(&disk.reachable_bundle(), ProjectLimits::default()).unwrap();
assert_eq!(compiled.decoded.migration.as_ref().unwrap().from_schema, 3);
assert_ne!(compiled.bundle, disk.bundle);
for issue in &disk.diagnostics {
assert!(compiled.diagnostics.iter().any(|later| {
later.diagnostic.code == issue.diagnostic.code
&& later.diagnostic.object_id == issue.diagnostic.object_id
&& later.diagnostic.message == issue.diagnostic.message
}));
let span = issue.diagnostic.span;
assert!(raw.get(span.start..span.end).is_some());
}
// Invalid legacy references still fail during semantic resolution on the
// authored bytes; deferring the source rewrite does not hide those errors.
let invalid = raw.replace("@coordinate", "@missing");
fs::write(&path, &invalid).unwrap();
let broken = load_project(&path, ProjectLimits::default()).unwrap();
assert!(broken.diagnostics.iter().any(|issue| {
issue.diagnostic.severity == oa_core::validation::Severity::Error
&& issue.diagnostic.message.contains("@missing")
}));
assert_eq!(broken.bundle.files["project.arcadia"], invalid);
assert!(compile_bundle(&broken.reachable_bundle(), ProjectLimits::default()).is_err());
assert_eq!(fs::read_to_string(path).unwrap(), invalid);
}
+17
View File
@@ -20,6 +20,7 @@ fn view(p: &mut Project, machine: Id, alias: &str) -> Id {
projection: ProjectionKind::StateMachine { machine },
view_options: Default::default(),
nodes: BTreeMap::new(),
layout: None,
routes: vec![],
},
);
@@ -301,6 +302,8 @@ fn folding_retains_authored_routes_without_false_self_transitions() {
let pins = vec![point(690., 480.), point(690., 230.)];
f.p.diagrams.get_mut(&f.d).unwrap().routes.push(EdgeRoute {
edge: out,
source_attachment: None,
target_attachment: None,
waypoints: pins.clone(),
label: None,
});
@@ -362,11 +365,15 @@ fn grouped_moves_translate_hidden_descendants_and_pins_only_in_one_view() {
let pins = vec![point(570., 460.)];
f.p.diagrams.get_mut(&f.d).unwrap().routes.push(EdgeRoute {
edge: f.local,
source_attachment: None,
target_attachment: None,
waypoints: pins.clone(),
label: Some(label.clone()),
});
f.p.diagrams.get_mut(&f.d).unwrap().routes.push(EdgeRoute {
edge: f.external,
source_attachment: None,
target_attachment: None,
waypoints: vec![point(690., 230.)],
label: None,
});
@@ -422,6 +429,8 @@ fn cross_machine_reparent_requires_atomic_placement_and_route_repair() {
let placement = f.p.place(f.d, orphan, point(30., 750.));
f.p.diagrams.get_mut(&f.d).unwrap().routes.push(EdgeRoute {
edge,
source_attachment: None,
target_attachment: None,
waypoints: vec![],
label: None,
});
@@ -456,6 +465,8 @@ fn cross_machine_reparent_requires_atomic_placement_and_route_repair() {
diagram: other,
route: EdgeRoute {
edge,
source_attachment: None,
target_attachment: None,
waypoints: vec![],
label: None,
},
@@ -583,6 +594,8 @@ fn layout_reserves_internal_rows_and_state_routes_retain_hard_pins() {
let pins = vec![point(730., 130.), point(1060., 130.)];
f.p.diagrams.get_mut(&f.d).unwrap().routes.push(EdgeRoute {
edge: f.self_edge,
source_attachment: None,
target_attachment: None,
waypoints: pins.clone(),
label: Some(EdgeLabel {
fraction: 0.25,
@@ -652,6 +665,8 @@ fn moving_a_region_does_not_translate_pins_attached_to_a_stationary_state_bounda
let pins = vec![point(280., 390.), point(280., 470.)];
f.p.diagrams.get_mut(&f.d).unwrap().routes.push(EdgeRoute {
edge: f.local,
source_attachment: None,
target_attachment: None,
waypoints: pins.clone(),
label: None,
});
@@ -730,6 +745,8 @@ fn initial_transition_attaches_to_the_compact_symbol_and_preview_keeps_settled_g
);
f.p.diagrams.get_mut(&f.d).unwrap().routes.push(EdgeRoute {
edge: f.self_edge,
source_attachment: None,
target_attachment: None,
waypoints: vec![],
label: Some(EdgeLabel {
fraction: 0.3,
@@ -0,0 +1,353 @@
//! Existing counterpart eligibility is a layer/category/kind decision, not
//! validation or acceptance of an entire transition plan. The catalog fixtures
//! below intentionally contain partial records (unowned states, etc.). The
//! final regression separately starts from a structurally valid real project.
use oa_core::{transitions::*, *};
use std::collections::{BTreeMap, BTreeSet};
fn element(p: &mut Project, layer: Layer, kind: Kind) -> Id {
let id = Id::new_v4();
let body = if kind == Kind::Port {
// Legacy ports can exist in preserved OA models. This does not offer
// new typed SA implementation ports for creation in portless OA.
ElementBody::shared_in(
layer,
SharedBody::Port {
contract: PortContract::Legacy,
legacy: None,
},
)
} else {
ElementBody::new(kind, layer).unwrap()
};
p.elements.insert(
id,
Element {
id,
alias: format!("candidate_{}", id.simple()),
name: "Identical candidate label".into(),
description: String::new(),
parent: None,
body,
},
);
id
}
#[test]
fn existing_element_candidates_obey_explicit_engineering_families_in_both_directions() {
// Contract: language.rst §5 trace families and the explicit capability /
// mission alternative. State constructors remain eligible for explicit
// review across the state family; selecting a candidate does not accept a
// constructor change or make invalid ownership legal.
let mut allowed: BTreeSet<(Kind, Kind)> = [
(Kind::Activity, Kind::Function),
(Kind::Entity, Kind::System),
(Kind::Entity, Kind::SystemActor),
(Kind::Actor, Kind::System),
(Kind::Actor, Kind::SystemActor),
(Kind::Capability, Kind::SystemCapability),
(Kind::OperationalMission, Kind::Mission),
(Kind::Capability, Kind::Mission),
(Kind::Process, Kind::FunctionalChain),
]
.into_iter()
.collect();
for kind in [
Kind::ExchangeItem,
Kind::ExchangeCategory,
Kind::DataType,
Kind::DataField,
Kind::Interface,
Kind::Port,
Kind::Scenario,
Kind::Lifeline,
Kind::Occurrence,
Kind::StateMachine,
Kind::Region,
Kind::Event,
Kind::Constraint,
] {
allowed.insert((kind, kind));
}
let vertices = [
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,
];
for oa in vertices {
for sa in vertices {
allowed.insert((oa, sa));
}
}
let mut p = Project::empty();
let oa: BTreeMap<_, _> = Kind::ALL
.into_iter()
.filter(|kind| kind.supports(Layer::Oa))
.map(|kind| (kind, element(&mut p, Layer::Oa, kind)))
.collect();
let sa: BTreeMap<_, _> = Kind::ALL
.into_iter()
.filter(|kind| kind.supports(Layer::Sa))
.map(|kind| (kind, element(&mut p, Layer::Sa, kind)))
.collect();
let original = p.clone();
for (&oa_kind, &oa_id) in &oa {
for (&sa_kind, &sa_id) in &sa {
let expected = allowed.contains(&(oa_kind, sa_kind));
assert_eq!(
counterpart_compatible(&p, oa_id, sa_id, TransitionDirection::OaToSa),
expected,
"OA {oa_kind:?} → SA {sa_kind:?}"
);
assert_eq!(
counterpart_compatible(&p, sa_id, oa_id, TransitionDirection::SaToOa),
expected,
"SA {sa_kind:?} → OA {oa_kind:?}"
);
}
}
assert_eq!(
p, original,
"Eligibility must not establish correspondence or edit records"
);
}
fn relation(p: &mut Project, kind: RelationKind, anchor: Id) -> Id {
let id = Id::new_v4();
// Only the semantic layer and relation category participate in candidate
// eligibility. Endpoint compatibility belongs to full plan validation.
p.relations.insert(
id,
Relation {
id,
alias: format!("relation_{}", id.simple()),
name: "Identical relation label".into(),
description: String::new(),
body: RelationBody::new(kind, anchor, anchor),
},
);
id
}
#[test]
fn existing_relation_candidates_distinguish_work_boundary_and_shared_relations() {
// Work and boundary exchanges are different families. The boundary /
// boundary pair permits preserved legacy OA boundary records; ordinary
// portless OA communication means map to SA boundary exchanges.
let mut allowed = vec![
(RelationKind::Interaction, RelationKind::FunctionalExchange),
(
RelationKind::CommunicationMean,
RelationKind::BoundaryExchange,
),
(
RelationKind::BoundaryExchange,
RelationKind::BoundaryExchange,
),
];
let shared = [
RelationKind::Allocation,
RelationKind::InterfaceItemAllocation,
RelationKind::DataAssociation,
RelationKind::ExchangeAllocation,
RelationKind::CapabilityInvolvement,
RelationKind::CapabilityInclude,
RelationKind::CapabilityExtend,
RelationKind::CapabilityGeneralization,
RelationKind::MissionInvolvement,
RelationKind::MissionCapability,
RelationKind::SequenceLink,
RelationKind::ContextualLink,
RelationKind::ChainInvolvement,
RelationKind::InterfaceProvision,
RelationKind::InterfaceRequirement,
RelationKind::StateTransition,
RelationKind::Availability,
RelationKind::StateAction,
];
for kind in shared {
allowed.push((kind, kind));
}
let mut p = Project::empty();
let oa_anchor = element(&mut p, Layer::Oa, Kind::Activity);
let sa_anchor = element(&mut p, Layer::Sa, Kind::Function);
let oa: Vec<_> = shared
.into_iter()
.chain([
RelationKind::Interaction,
RelationKind::CommunicationMean,
RelationKind::BoundaryExchange,
RelationKind::RoleRealization,
RelationKind::RoleAssignment,
])
.map(|kind| (kind, relation(&mut p, kind, oa_anchor)))
.collect();
let sa: Vec<_> = shared
.into_iter()
.chain([
RelationKind::FunctionalExchange,
RelationKind::BoundaryExchange,
RelationKind::PortAllocation,
])
.map(|kind| (kind, relation(&mut p, kind, sa_anchor)))
.collect();
for &(oa_kind, oa_id) in &oa {
for &(sa_kind, sa_id) in &sa {
let expected = allowed.contains(&(oa_kind, sa_kind));
assert_eq!(
counterpart_compatible(&p, oa_id, sa_id, TransitionDirection::OaToSa),
expected,
"OA {oa_kind:?} → SA {sa_kind:?}"
);
assert_eq!(
counterpart_compatible(&p, sa_id, oa_id, TransitionDirection::SaToOa),
expected,
"SA {sa_kind:?} → OA {oa_kind:?}"
);
}
}
}
#[test]
fn candidate_eligibility_rejects_wrong_layers_categories_and_missing_identities() {
let mut p = Project::empty();
let oa = element(&mut p, Layer::Oa, Kind::Activity);
let sa = element(&mut p, Layer::Sa, Kind::Function);
let oa_exchange = relation(&mut p, RelationKind::Interaction, oa);
let sa_exchange = relation(&mut p, RelationKind::FunctionalExchange, sa);
let diagram = p.ensure_diagram(Layer::Sa);
let node = p.place(diagram, sa, Position { x: 1., y: 2. });
for (direction, source, target, source_relation, target_relation) in [
(
TransitionDirection::OaToSa,
oa,
sa,
oa_exchange,
sa_exchange,
),
(
TransitionDirection::SaToOa,
sa,
oa,
sa_exchange,
oa_exchange,
),
] {
assert!(counterpart_compatible(&p, source, target, direction));
assert!(counterpart_compatible(
&p,
source_relation,
target_relation,
direction
));
for (a, b) in [
(target, source),
(source, source),
(target, target),
(target_relation, source_relation),
(source_relation, source_relation),
(source, target_relation),
(source_relation, target),
] {
assert!(!counterpart_compatible(&p, a, b, direction));
}
for absent_or_nonsemantic in [Id::nil(), Id::new_v4(), p.id, diagram, node] {
assert!(!counterpart_compatible(
&p,
source,
absent_or_nonsemantic,
direction
));
assert!(!counterpart_compatible(
&p,
absent_or_nonsemantic,
target,
direction
));
}
}
p.elements.remove(&sa);
assert!(!counterpart_compatible(
&p,
oa,
sa,
TransitionDirection::OaToSa
));
assert!(!counterpart_compatible(
&p,
sa,
oa,
TransitionDirection::SaToOa
));
// The retained relation header cannot borrow a layer from a deleted owner.
assert!(!counterpart_compatible(
&p,
oa_exchange,
sa_exchange,
TransitionDirection::OaToSa
));
}
#[test]
fn activity_picker_rejects_real_sa_port_and_agrees_with_explicit_plan_refusal() {
let p = Project::demo();
let original = p.clone();
assert!(validation::validate(&p).is_empty());
let source = p
.elements
.values()
.find(|e| e.kind() == Kind::Activity)
.unwrap()
.id;
let port = p
.elements
.values()
.find(|e| e.layer() == Layer::Sa && e.kind() == Kind::Port)
.unwrap()
.id;
let function = p
.elements
.values()
.find(|e| e.kind() == Kind::Function)
.unwrap()
.id;
assert!(counterpart_compatible(
&p,
source,
function,
TransitionDirection::OaToSa
));
assert!(!counterpart_compatible(
&p,
source,
port,
TransitionDirection::OaToSa
));
let mut request = TransitionRequest::new(TransitionDirection::OaToSa, vec![source]);
request.mappings.push(TransitionMapping {
source,
target: Some(port),
target_kind: None,
});
let plan = plan_transition(&p, &request).unwrap();
assert!(
plan.conflicts
.iter()
.any(|conflict| conflict.source == Some(source)
&& conflict.target == Some(port)
&& conflict.field.is_none())
);
assert!(apply_transition(&p, &plan).is_err());
assert_eq!(p, original);
}
@@ -0,0 +1,638 @@
//! Endpoint lifecycle acceptance for the ordinary, prose-reviewed transition API.
//! OA is portless; the SA half contains typed ports. Deletion expectations below
//! are fixture-owned identity lists, never results of the deletion implementation.
use oa_core::{
language, persistence,
source_project::{ProjectLimits, SourceBundle, compile_bundle},
transitions::*,
validation::{self, Severity},
*,
};
use std::collections::BTreeMap;
struct Side {
root: Id,
elements: Vec<Id>,
exchange: Id,
diagram: Id,
nodes: Vec<Id>,
artifacts: Vec<Id>,
}
struct Fixture {
project: Project,
source: Side,
target: Side,
trace: Id,
trace_artifact: Id,
}
fn artifact(p: &mut Project, owner: Id, label: &str) -> Id {
let id = Id::new_v4();
p.artifact_links.insert(
id,
ArtifactLink {
id,
alias: p.fresh_alias(label, "evidence", id),
name: label.into(),
description: "Independently authored rationale".into(),
owner,
relation: ArtifactRelation::References,
uri: "git:engineering/design.md".into(),
revision: Some("review-17".into()),
fragment: Some("retained-decision".into()),
},
);
id
}
fn side(p: &mut Project, layer: Layer) -> Side {
let (kind, prefix, relation) = match layer {
Layer::Oa => (Kind::Activity, "Operational", RelationKind::Interaction),
Layer::Sa => (
Kind::Function,
"Technical",
RelationKind::FunctionalExchange,
),
};
let root = p
.add_element(kind, layer, &format!("{prefix} design"), None)
.unwrap();
p.elements.get_mut(&root).unwrap().description =
format!("{prefix} independently reviewed intent");
let send = p
.add_element(kind, layer, &format!("{prefix} send"), Some(root))
.unwrap();
let receive = p
.add_element(kind, layer, &format!("{prefix} receive"), Some(root))
.unwrap();
p.elements.get_mut(&send).unwrap().description =
"Preserve the detailed sending responsibility".into();
p.elements.get_mut(&receive).unwrap().description =
"Preserve the detailed receiving responsibility".into();
// A shared, separately owned definition survives deletion of its users.
let item = p
.add_element(
Kind::ExchangeItem,
layer,
&format!("{prefix} information"),
None,
)
.unwrap();
*p.elements.get_mut(&item).unwrap().shared_mut().unwrap() = SharedBody::ExchangeItem {
kind: ExchangeItemKind::Information,
mechanism: ExchangeMechanism::Flow,
supertype: None,
};
p.add_element(
Kind::DataField,
layer,
&format!("{prefix} payload"),
Some(item),
)
.unwrap();
let exchange = p.relate(relation, &format!("{prefix} delivery"), send, receive);
let mut elements = vec![root, send, receive];
match &mut p.relations.get_mut(&exchange).unwrap().body {
RelationBody::Interaction { items, .. } => *items = vec![item],
RelationBody::FunctionalExchange {
items,
source_port,
target_port,
..
} => {
*items = vec![item];
let (output, input) = (*source_port, *target_port);
*p.elements.get_mut(&output).unwrap().shared_mut().unwrap() = SharedBody::Port {
contract: PortContract::FunctionOutput {
outgoing_items: vec![item],
},
legacy: None,
};
*p.elements.get_mut(&input).unwrap().shared_mut().unwrap() = SharedBody::Port {
contract: PortContract::FunctionInput {
incoming_items: vec![item],
},
legacy: None,
};
elements.extend([output, input]);
}
_ => unreachable!(),
}
p.relations.get_mut(&exchange).unwrap().description =
"Retain direction, item contract and relation identity".into();
let diagram = p.ensure_diagram(layer);
p.diagrams.get_mut(&diagram).unwrap().projection = ProjectionKind::Decomposition;
let nodes = [root, send, receive]
.into_iter()
.enumerate()
.map(|(index, element)| {
p.place(
diagram,
element,
Position {
x: 43.25 + index as f32 * 271.,
y: 97.5 + index as f32 * 83.,
},
)
})
.collect::<Vec<_>>();
let root_node = p
.diagrams
.get_mut(&diagram)
.unwrap()
.nodes
.get_mut(&nodes[0])
.unwrap();
root_node.size = Size {
width: 611.,
height: 319.,
};
root_node.collapsed = true;
p.diagrams
.get_mut(&diagram)
.unwrap()
.routes
.push(EdgeRoute {
edge: exchange,
// Both directions must preserve authored endpoint preferences,
// including dormant preferences on the SA semantic-port edge.
source_attachment: Some(EdgeAttachment {
side: PortAnchorSide::Bottom,
offset: 0.2,
}),
target_attachment: Some(EdgeAttachment {
side: PortAnchorSide::Top,
offset: 0.8,
}),
waypoints: vec![Position {
x: 337.5,
y: 423.25,
}],
label: Some(EdgeLabel {
fraction: 0.7,
offset: Position { x: 11., y: -17. },
}),
});
let artifacts = [root, send, exchange, nodes[0]]
.into_iter()
.enumerate()
.map(|(index, owner)| artifact(p, owner, &format!("{prefix} evidence {index}")))
.collect();
artifact(p, item, &format!("{prefix} shared item evidence"));
Side {
root,
elements,
exchange,
diagram,
nodes,
artifacts,
}
}
fn accepted(p: &Project) {
let issues = validation::validate(p);
// This fixture deliberately isolates decomposition and correspondence from
// performer allocation and process/chain/scenario inclusion. Each surviving
// leaf (including a newly transitioned root) must have exactly those two
// incomplete-work warnings; each surviving local exchange lacks a behavior
// context. Check exact record IDs and fields, including duplicate warnings.
// The system of interest and item contracts are complete fixture facts.
assert!(
issues
.iter()
.all(|issue| issue.severity == Severity::Warning),
"{issues:?}"
);
let mut expected = Vec::new();
for element in p.elements.values().filter(|element| {
matches!(element.kind(), Kind::Activity | Kind::Function)
&& !p
.elements
.values()
.any(|child| child.parent == Some(element.id) && child.kind() == element.kind())
}) {
expected.push(("unallocated_leaf", Some(element.id), Some("allocation")));
expected.push(("work_without_behavior_context", Some(element.id), None));
}
for relation in p.relations.values().filter(|relation| {
matches!(
relation.kind(),
RelationKind::Interaction | RelationKind::FunctionalExchange
)
}) {
expected.push(("exchange_without_behavior_context", Some(relation.id), None));
}
let mut actual = issues
.iter()
.map(|issue| (issue.code.as_str(), issue.element, issue.field.as_deref()))
.collect::<Vec<_>>();
expected.sort();
actual.sort();
assert_eq!(
actual, expected,
"Unexpected fixture diagnostics: {issues:?}"
);
}
fn round_trip(p: &Project) -> String {
accepted(p);
let json = serde_json::to_vec_pretty(p).unwrap();
assert_eq!(persistence::decode(&json).unwrap(), *p);
let source = language::format(p);
let compiled = compile_bundle(
&SourceBundle {
entry: "project.arcadia".into(),
files: BTreeMap::from([("project.arcadia".into(), source.clone())]),
},
ProjectLimits::default(),
)
.unwrap();
assert_eq!(compiled.decoded.project, *p);
assert_eq!(language::format(&compiled.decoded.project), source);
source
}
fn explicit(direction: TransitionDirection, source: Id, target: Id) -> TransitionRequest {
let mut request = TransitionRequest::new(direction, vec![source]);
request.mappings.push(TransitionMapping {
source,
target: Some(target),
target_kind: None,
});
request
}
fn fixture(direction: TransitionDirection) -> Fixture {
let mut project = Project::empty();
project.name = "Endpoint lifecycle conservation".into();
project
.add_element(Kind::System, Layer::Sa, "System of interest", None)
.unwrap();
let oa = side(&mut project, Layer::Oa);
let sa = side(&mut project, Layer::Sa);
let (source, target) = match direction {
TransitionDirection::OaToSa => (oa, sa),
TransitionDirection::SaToOa => (sa, oa),
};
accepted(&project);
let before = project.clone();
let mut request = explicit(direction, source.root, target.root);
for field in [TransitionField::Name, TransitionField::Description] {
request.resolutions.push(FieldResolution {
source: source.root,
field,
decision: FieldDecision::KeepTarget,
});
}
let plan = plan_transition(&project, &request).unwrap();
assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts);
project = apply_transition(&project, &plan).unwrap();
let trace = assert_only_new_trace(&before, &project, direction, source.root, target.root);
let mut without_trace = project.clone();
without_trace.traces.remove(&trace);
assert_eq!(
without_trace, before,
"Initial review must only add correspondence"
);
let trace_artifact = artifact(&mut project, trace, "Correspondence rationale");
round_trip(&project);
Fixture {
project,
source,
target,
trace,
trace_artifact,
}
}
fn assert_only_new_trace(
before: &Project,
after: &Project,
direction: TransitionDirection,
source: Id,
target: Id,
) -> Id {
let added = after
.traces
.values()
.filter(|trace| !before.traces.contains_key(&trace.id))
.collect::<Vec<_>>();
assert_eq!(added.len(), 1);
assert_eq!(after.traces.len(), before.traces.len() + 1);
let trace = added[0];
let (oa, sa) = match direction {
TransitionDirection::OaToSa => (source, target),
TransitionDirection::SaToOa => (target, source),
};
assert_eq!(
(trace.oa, trace.sa, trace.kind),
(oa, sa, TraceKind::Refinement)
);
assert_eq!(
trace.baseline,
Some(TraceBaseline {
oa: after.properties(oa).unwrap(),
sa: after.properties(sa).unwrap(),
semantic: None,
})
);
assert_eq!(trace_drift(after, trace), Drift::Unchanged);
trace.id
}
fn expected_deletion(fixture: &Fixture, side: &Side) -> Project {
let mut expected = fixture.project.clone();
for id in &side.elements {
assert!(expected.elements.remove(id).is_some());
}
assert!(expected.relations.remove(&side.exchange).is_some());
assert!(expected.traces.remove(&fixture.trace).is_some());
for id in side.artifacts.iter().chain([&fixture.trace_artifact]) {
assert!(expected.artifact_links.remove(id).is_some());
}
let diagram = expected.diagrams.get_mut(&side.diagram).unwrap();
for id in &side.nodes {
assert!(diagram.nodes.remove(id).is_some());
}
// This fixture authored exactly one route, for its removed local exchange.
assert_eq!(diagram.routes.len(), 1);
assert_eq!(diagram.routes[0].edge, side.exchange);
diagram.routes.clear();
expected
}
fn unchanged_retry(p: &Project, direction: TransitionDirection, source: Id, target: Id) {
let request = TransitionRequest::new(direction, vec![source]);
let plan = plan_transition(p, &request).unwrap();
assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts);
assert_eq!(plan.changes.len(), 1);
assert_eq!(
(
plan.changes[0].source,
plan.changes[0].target,
plan.changes[0].action
),
(source, target, TransitionAction::Reuse)
);
assert!(plan.changes[0].fields.is_empty());
assert!(plan.commands.is_empty(), "{:?}", plan.commands);
let mut session = Session::new(p.clone());
let revision = session.revision();
session
.execute(Command::Replace(apply_transition(p, &plan).unwrap()))
.unwrap();
assert_eq!(session.project(), p);
assert_eq!(session.revision(), revision);
assert!(!session.can_undo());
}
fn source_deletion(direction: TransitionDirection) {
let fixture = fixture(direction);
let original_source = round_trip(&fixture.project);
let expected = expected_deletion(&fixture, &fixture.source);
let mut session = Session::new(fixture.project.clone());
session
.execute(Command::Delete(fixture.source.root))
.unwrap();
// Whole-project equality covers every retained target child, typed SA port,
// exchange, placement, route, artifact, shared item and unchanged identity.
assert_eq!(session.project(), &expected);
let deleted_source = round_trip(session.project());
session.undo();
assert_eq!(session.project(), &fixture.project);
assert_eq!(round_trip(session.project()), original_source);
assert!(!session.can_undo(), "Deletion is one history transaction");
session.redo();
assert_eq!(session.project(), &expected);
assert_eq!(round_trip(session.project()), deleted_source);
}
fn implicit_creation(
p: &Project,
direction: TransitionDirection,
source: Id,
forbidden_target: Id,
) -> (Project, Id) {
let request = TransitionRequest::new(direction, vec![source]);
let plan = plan_transition(p, &request).unwrap();
assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts);
assert_eq!(plan.changes.len(), 1);
let change = &plan.changes[0];
assert_eq!(change.source, source);
assert_eq!(change.action, TransitionAction::Create);
let target = change.target;
assert_ne!(target, forbidden_target);
assert!(!p.contains(target));
let after = apply_transition(p, &plan).unwrap();
let trace = assert_only_new_trace(p, &after, direction, source, target);
let created = &after.elements[&target];
assert_eq!(created.id, target, "Apply preserves the preview identity");
assert_eq!(created.name, p.elements[&source].name);
assert_eq!(created.description, p.elements[&source].description);
assert_eq!(created.parent, None);
assert_eq!(
created.body,
match direction {
TransitionDirection::OaToSa => ElementBody::Sa(SaBody::Function),
TransitionDirection::SaToOa => ElementBody::Oa(OaBody::Activity),
}
);
assert!(!created.alias.is_empty());
assert!(
!p.identities()
.iter()
.any(|(_, alias)| *alias == created.alias)
);
// Remove only the independently expected two additions. Any incidental
// mutation, clone of target children or extra correspondence fails here.
let mut retained = after.clone();
retained.elements.remove(&target);
retained.traces.remove(&trace);
assert_eq!(retained, *p);
round_trip(&after);
// Replaying a creation plan after acceptance violates its snapshot, while
// a fresh unchanged request reuses the accepted counterpart.
assert!(
apply_transition(&after, &plan)
.unwrap_err()
.0
.contains("stale")
);
unchanged_retry(&after, direction, source, target);
(after, target)
}
fn target_deletion(direction: TransitionDirection) {
let fixture = fixture(direction);
let request = TransitionRequest::new(direction, vec![fixture.source.root]);
let old_plan = plan_transition(&fixture.project, &request).unwrap();
assert!(old_plan.conflicts.is_empty());
let expected = expected_deletion(&fixture, &fixture.target);
let mut session = Session::new(fixture.project.clone());
session
.execute(Command::Delete(fixture.target.root))
.unwrap();
assert_eq!(session.project(), &expected);
round_trip(session.project());
let revision = session.revision();
assert!(
apply_transition(session.project(), &old_plan)
.unwrap_err()
.0
.contains("stale")
);
let explicit_plan = plan_transition(
session.project(),
&explicit(direction, fixture.source.root, fixture.target.root),
)
.unwrap();
assert!(
explicit_plan
.conflicts
.iter()
.any(|conflict| conflict.source == Some(fixture.source.root)
&& conflict.target == Some(fixture.target.root)
&& conflict.field.is_none())
);
assert!(apply_transition(session.project(), &explicit_plan).is_err());
assert_eq!(session.project(), &expected);
assert_eq!(session.revision(), revision);
let (created, _) = implicit_creation(
session.project(),
direction,
fixture.source.root,
fixture.target.root,
);
session.execute(Command::Replace(created.clone())).unwrap();
session.undo();
assert_eq!(session.project(), &expected);
session.undo();
assert_eq!(session.project(), &fixture.project);
assert!(!session.can_undo());
session.redo();
session.redo();
assert_eq!(session.project(), &created);
round_trip(session.project());
}
fn source_recreation(direction: TransitionDirection) {
let fixture = fixture(direction);
let mut replacement = fixture.project.elements[&fixture.source.root].clone();
replacement.id = Id::new_v4();
let source = replacement.id;
// Same alias, name and prose, different UUID: name matching must never
// silently relink the retained engineering design.
assert_ne!(source, fixture.source.root);
let deleted = execute_batch(&fixture.project, &[Command::Delete(fixture.source.root)]).unwrap();
assert_eq!(deleted, expected_deletion(&fixture, &fixture.source));
let recreated =
execute_batch(&deleted, &[Command::CreateElement(replacement.clone())]).unwrap();
let mut expected = deleted.clone();
expected.elements.insert(source, replacement);
assert_eq!(recreated, expected);
round_trip(&recreated);
let (implicit, new_target) =
implicit_creation(&recreated, direction, source, fixture.target.root);
assert!(implicit.elements.contains_key(&fixture.target.root));
assert_ne!(new_target, fixture.target.root);
// The explicit branch starts from exactly the same recreated project.
let mut request = explicit(direction, source, fixture.target.root);
let unreviewed = plan_transition(&recreated, &request).unwrap();
for field in [TransitionField::Name, TransitionField::Description] {
assert!(
unreviewed
.conflicts
.iter()
.any(|conflict| conflict.source == Some(source)
&& conflict.target == Some(fixture.target.root)
&& conflict.field == Some(field))
);
}
assert!(apply_transition(&recreated, &unreviewed).is_err());
request.resolutions = vec![
FieldResolution {
source,
field: TransitionField::Name,
decision: FieldDecision::TakeSource,
},
FieldResolution {
source,
field: TransitionField::Description,
decision: FieldDecision::KeepTarget,
},
];
let plan = plan_transition(&recreated, &request).unwrap();
assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts);
assert_eq!(plan.changes.len(), 1);
assert_eq!(
(plan.changes[0].target, plan.changes[0].action),
(fixture.target.root, TransitionAction::Update)
);
assert_eq!(
plan.changes[0].fields,
vec![FieldChange {
field: TransitionField::Name,
before: recreated.elements[&fixture.target.root].name.clone(),
after: recreated.elements[&source].name.clone(),
}]
);
let relinked = apply_transition(&recreated, &plan).unwrap();
let new_trace = assert_only_new_trace(
&recreated,
&relinked,
direction,
source,
fixture.target.root,
);
assert_ne!(
new_trace, fixture.trace,
"Deleted correspondence is not silently resurrected"
);
let mut expected = recreated.clone();
expected
.elements
.get_mut(&fixture.target.root)
.unwrap()
.name = recreated.elements[&source].name.clone();
let mut without_trace = relinked.clone();
without_trace.traces.remove(&new_trace);
assert_eq!(
without_trace, expected,
"Only the reviewed target name may change; rich target design survives"
);
round_trip(&relinked);
unchanged_retry(&relinked, direction, source, fixture.target.root);
let original_source = round_trip(&recreated);
let mut session = Session::new(recreated.clone());
session.execute(Command::Replace(relinked)).unwrap();
session.undo();
assert_eq!(session.project(), &recreated);
assert_eq!(round_trip(session.project()), original_source);
}
#[test]
fn oa_source_deletion_preserves_rich_sa_design_and_exact_undo() {
source_deletion(TransitionDirection::OaToSa);
}
#[test]
fn sa_source_deletion_preserves_rich_portless_oa_design_and_exact_undo() {
source_deletion(TransitionDirection::SaToOa);
}
#[test]
fn deleted_sa_target_refuses_old_plans_and_implicit_retry_creates_once() {
target_deletion(TransitionDirection::OaToSa);
}
#[test]
fn deleted_oa_target_refuses_old_plans_and_implicit_retry_creates_once() {
target_deletion(TransitionDirection::SaToOa);
}
#[test]
fn recreated_oa_identity_requires_explicit_relink_to_retained_sa_design() {
source_recreation(TransitionDirection::OaToSa);
}
#[test]
fn recreated_sa_identity_requires_explicit_relink_to_retained_oa_design() {
source_recreation(TransitionDirection::SaToOa);
}
@@ -0,0 +1,14 @@
//! Focused test harness for the production Allocation planner and Document.
//! This does not exercise App input, painting, or native gestures.
#[cfg(test)]
#[allow(dead_code)] // App-facing entry points are intentionally absent here.
#[path = "../src/allocation_drag.rs"]
mod allocation_drag;
#[cfg(test)]
#[allow(dead_code, unused_imports)] // App-facing API and reexports are absent here.
#[path = "../src/document.rs"]
mod document;
fn main() {}
+122 -110
View File
@@ -13,6 +13,19 @@ use oa_core::{
};
use std::collections::{BTreeMap, BTreeSet};
// The pure planner is verified before its App gesture adapter is connected.
#[allow(dead_code)]
#[path = "allocation_drag/arrange.rs"]
pub mod arrange;
#[path = "allocation_drag/geometry.rs"]
mod geometry;
// The combined semantic planner is verified before connecting its App adapter.
#[allow(dead_code)]
#[path = "allocation_drag/assignment.rs"]
pub mod assignment;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Destination {
LayoutOnly,
@@ -76,6 +89,11 @@ pub enum Refusal {
pub fn capture(p: &Project, diagram: Id, work: Id, placement: Id) -> Result<Scope, Refusal> {
validate_candidate(p).map_err(|e| Refusal::InvalidCandidate(e.to_string()))?;
capture_validated(p, diagram, work, placement)
}
/// Collect work facts after the caller's single complete Project validation.
fn capture_validated(p: &Project, diagram: Id, work: Id, placement: Id) -> Result<Scope, Refusal> {
let d = p.diagrams.get(&diagram).ok_or(Refusal::StaleScope)?;
let root = p.elements.get(&work).ok_or(Refusal::StaleScope)?;
if !root.kind().is_work()
@@ -232,8 +250,93 @@ pub fn plan_semantics(
let same_performer =
matches!(destination, Destination::Performer(id) if scope.uniform == Uniform::Assigned(id));
let mut expected = p.clone();
let mut commands = Vec::new();
let mut deleted = BTreeSet::new();
let mut edits = IntendedEdits::default();
append_semantic_edits(
p,
scope,
destination,
allocation_ids,
&mut expected,
&mut edits,
)?;
// A route cannot survive the intentional deletion of its own edge, even in
// another diagram. No other saved-view cleanup is authorized by this drag.
for diagram in expected.diagrams.values_mut() {
diagram.routes.retain(|r| !edits.deleted.contains(&r.edge));
}
let actual = prepare_command(p, &Command::Batch(edits.commands.clone()))
.map_err(|e| Refusal::InvalidCandidate(e.to_string()))?
.project;
exact_project(&expected, &actual)?;
let retained_roles = scope
.leaves
.values()
.any(|l| !l.role_assignments.is_empty());
let label = match destination {
Destination::LayoutOnly => "Arrange only".into(),
Destination::Performer(id) if same_performer => format!("Arrange within {}", p.named(id)),
Destination::Performer(id) => format!(
"Assign {} leaf {} to {}{}",
scope.leaves.len(),
match (scope.diagram.layer, scope.leaves.len()) {
(Layer::Oa, 1) => "activity",
(Layer::Oa, _) => "activities",
(Layer::Sa, 1) => "function",
(Layer::Sa, _) => "functions",
},
p.named(id),
if retained_roles {
" — explicit assignment; role responsibility retained"
} else {
""
}
),
Destination::OutsidePerformers if edits.commands.is_empty() => {
"Arrange unassigned work — decomposition retained".into()
}
Destination::OutsidePerformers => "Remove assignment — decomposition retained".into(),
};
let warnings = validation::validate(&actual)
.into_iter()
.filter(|i| i.severity == Severity::Warning)
.collect();
let index = ViewIndex::new(&actual);
Ok(SemanticPlan {
scope: scope.clone(),
destination,
label,
warnings,
accepted: p.clone(),
candidate: actual,
index,
commands: edits.commands,
before_visible,
before_expanded,
})
}
#[derive(Default)]
struct IntendedEdits {
commands: Vec<Command>,
deleted: BTreeSet<Id>,
changed: BTreeSet<Id>,
}
/// Read each root's accepted responsibility while accumulating one intended
/// Project, reserving relation identities and aliases across every root.
fn append_semantic_edits(
p: &Project,
scope: &Scope,
destination: Destination,
allocation_ids: &BTreeMap<Id, Id>,
expected: &mut Project,
edits: &mut IntendedEdits,
) -> Result<(), Refusal> {
let same_performer =
matches!(destination, Destination::Performer(id) if scope.uniform == Uniform::Assigned(id));
let commands = &mut edits.commands;
let deleted = &mut edits.deleted;
let changed = &mut edits.changed;
if destination != Destination::LayoutOnly && !same_performer {
for (work, leaf) in &scope.leaves {
match destination {
@@ -246,6 +349,7 @@ pub fn plan_semantics(
target: *work,
};
expected.relations.insert(edited.id, edited.clone());
changed.insert(*work);
commands.push(Command::EditRelation(edited.id, edited));
}
} else {
@@ -269,6 +373,7 @@ pub fn plan_semantics(
},
};
expected.relations.insert(id, relation.clone());
changed.insert(*work);
commands.push(Command::CreateRelation(relation));
}
}
@@ -327,6 +432,7 @@ pub fn plan_semantics(
if let Some(existing) = &leaf.direct {
expected.relations.remove(&existing.id);
deleted.insert(existing.id);
changed.insert(*work);
commands.push(Command::Delete(existing.id));
}
}
@@ -334,60 +440,7 @@ pub fn plan_semantics(
}
}
}
// A route cannot survive the intentional deletion of its own edge, even in
// another diagram. No other saved-view cleanup is authorized by this drag.
for diagram in expected.diagrams.values_mut() {
diagram.routes.retain(|r| !deleted.contains(&r.edge));
}
let actual = prepare_command(p, &Command::Batch(commands.clone()))
.map_err(|e| Refusal::InvalidCandidate(e.to_string()))?
.project;
exact_project(&expected, &actual)?;
let retained_roles = scope
.leaves
.values()
.any(|l| !l.role_assignments.is_empty());
let label = match destination {
Destination::LayoutOnly => "Arrange only".into(),
Destination::Performer(id) if same_performer => format!("Arrange within {}", p.named(id)),
Destination::Performer(id) => format!(
"Assign {} leaf {} to {}{}",
scope.leaves.len(),
match (scope.diagram.layer, scope.leaves.len()) {
(Layer::Oa, 1) => "activity",
(Layer::Oa, _) => "activities",
(Layer::Sa, 1) => "function",
(Layer::Sa, _) => "functions",
},
p.named(id),
if retained_roles {
" — explicit assignment; role responsibility retained"
} else {
""
}
),
Destination::OutsidePerformers if commands.is_empty() => {
"Arrange unassigned work — decomposition retained".into()
}
Destination::OutsidePerformers => "Remove assignment — decomposition retained".into(),
};
let warnings = validation::validate(&actual)
.into_iter()
.filter(|i| i.severity == Severity::Warning)
.collect();
let index = ViewIndex::new(&actual);
Ok(SemanticPlan {
scope: scope.clone(),
destination,
label,
warnings,
accepted: p.clone(),
candidate: actual,
index,
commands,
before_visible,
before_expanded,
})
Ok(())
}
/// World-space rectangle in the gesture's frozen transform. The UI resolver
@@ -462,60 +515,18 @@ pub fn place(
"Make room or adjust the view, then drag again.".into(),
));
}
let root = &plan.scope.placements[&plan.scope.placement];
let delta = Position {
x: (requested.x / 10.).round() * 10. - root.position.x,
y: (requested.y / 10.).round() * 10. - root.position.y,
};
let mut diagram = plan.candidate.diagrams[&plan.scope.diagram.id].clone();
let mut commands = Vec::new();
let mut deltas = BTreeMap::new();
for (id, original) in &plan.scope.placements {
let node = diagram.nodes.get_mut(id).ok_or(Refusal::StaleScope)?;
node.position = Position {
x: original.position.x + delta.x,
y: original.position.y + delta.y,
};
if !(Rect {
position: node.position,
size: node.size,
})
.valid()
{
return Err(Refusal::InvalidCandidate(
"Moved coordinates exceed the model bounds.".into(),
));
}
deltas.insert(*id, delta);
if node != original {
commands.push(Command::EditNode {
diagram: diagram.id,
original: *id,
node: node.clone(),
});
}
}
// Resolve endpoints using the captured accepted semantics, not a newly
// regrouped descendant walk. Own-edge deletions were removed above already.
for route in projection::translated_routes(&plan.accepted, &plan.scope.diagram, &deltas) {
if let Some(existing) = diagram.routes.iter_mut().find(|r| r.edge == route.edge) {
if route.waypoints.iter().any(|p| {
!p.x.is_finite()
|| !p.y.is_finite()
|| p.x.abs() > MAX_COORDINATE
|| p.y.abs() > MAX_COORDINATE
}) {
return Err(Refusal::InvalidCandidate(
"Moved route exceeds the model bounds.".into(),
));
}
*existing = route.clone();
commands.push(Command::SetEdgeRoute {
diagram: diagram.id,
route,
});
}
}
let geometry::Translation {
mut diagram,
mut commands,
delta,
} = geometry::translate(
&plan.accepted,
&plan.scope.diagram,
&plan.candidate.diagrams[&plan.scope.diagram.id],
&plan.scope.placements,
plan.scope.placement,
requested,
)?;
let mut unfolded = None;
if let Destination::Performer(performer) = plan.destination {
let target = presentation.target.ok_or_else(|| {
@@ -833,4 +844,5 @@ fn exact_project(expected: &Project, actual: &Project) -> Result<(), Refusal> {
}
#[cfg(test)]
#[path = "allocation_drag/tests.rs"]
mod tests;
@@ -0,0 +1,232 @@
//! Explicit, semantics-preserving arrangement of one or more Allocation cards.
//!
//! The App must admit the entire original typed selection before converting it
//! to placement/element identities. These values do not grant input authority.
use super::*;
#[derive(Debug)]
pub struct Plan {
accepted: Project,
diagram: Id,
selected: BTreeMap<Id, Id>,
anchor: Id,
placements: BTreeMap<Id, NodePlacement>,
expanded_ids: BTreeSet<Id>,
index: ViewIndex,
before_visible: ProjectedView,
before_expanded: ProjectedView,
}
impl Plan {
pub fn project(&self) -> &Project {
&self.accepted
}
pub fn index(&self) -> &ViewIndex {
&self.index
}
pub fn affected(&self) -> &BTreeMap<Id, NodePlacement> {
&self.placements
}
pub fn selected(&self) -> &BTreeMap<Id, Id> {
&self.selected
}
pub fn anchor(&self) -> Id {
self.anchor
}
pub fn diagram(&self) -> &Diagram {
&self.accepted.diagrams[&self.diagram]
}
}
/// Capture every original placement identity before normalizing semantic roots.
/// Document/revision/selection-generation and clean-owner guards belong to App.
pub fn capture(
p: &Project,
diagram: Id,
selected: &BTreeMap<Id, Id>,
anchor: Id,
) -> Result<Plan, Refusal> {
validate_candidate(p).map_err(|e| Refusal::InvalidCandidate(e.to_string()))?;
let d = p.diagrams.get(&diagram).ok_or(Refusal::StaleScope)?;
if d.projection != ProjectionKind::Allocation
|| selected.is_empty()
|| !selected.contains_key(&anchor)
{
return Err(Refusal::Ineligible(
"Arrange requires selected placements and a selected anchor in an Allocation view."
.into(),
));
}
let index = ViewIndex::new(p);
let before_visible = projection::project_diagram_with_index(p, d, &index);
let visible: BTreeMap<_, _> = before_visible
.nodes
.iter()
.map(|n| (n.id, n.element))
.collect();
let mut work = BTreeMap::new();
let mut performers = Vec::new();
for (placement, element) in selected {
let node = d.nodes.get(placement).ok_or(Refusal::StaleScope)?;
let record = p.elements.get(element).ok_or(Refusal::StaleScope)?;
if node.element != *element || visible.get(placement) != Some(element) {
return Err(Refusal::StaleScope);
}
if record.layer() != d.layer || !(record.kind().is_work() || record.kind().is_performer()) {
return Err(Refusal::Ineligible(
"Arrange requires only activities, functions or performers in this layer.".into(),
));
}
if record.kind().is_work() {
work.insert(*element, *placement);
} else {
performers.push(*placement);
}
}
let mut placements = BTreeMap::new();
for (element, placement) in &work {
let root = &p.elements[element];
let mut ancestor = root.parent;
let mut covered = false;
let mut seen = BTreeSet::new();
while let Some(id) = ancestor {
if !seen.insert(id) {
return Err(Refusal::StaleScope);
}
let parent = p.elements.get(&id).ok_or(Refusal::StaleScope)?;
if parent.kind() != root.kind() {
break;
}
if work.contains_key(&id) {
covered = true;
break;
}
ancestor = parent.parent;
}
if !covered {
placements.extend(capture_validated(p, diagram, *element, *placement)?.placements);
}
}
for performer in performers {
for id in
std::iter::once(performer).chain(projection::descendant_nodes(p, diagram, performer))
{
placements.insert(id, d.nodes.get(&id).ok_or(Refusal::StaleScope)?.clone());
}
}
// Arrange preserves ancestry, so accepted diagnostic identities also apply
// to every translated candidate.
let expanded_ids = geometry::expansion_ids(p, d, &index, &placements)?;
let before_expanded = geometry::expanded_view(p, d, &index, &expanded_ids);
Ok(Plan {
accepted: p.clone(),
diagram,
selected: selected.clone(),
anchor,
placements,
expanded_ids,
index,
before_visible,
before_expanded,
})
}
/// Snap the captured anchor once; preview the complete union without cloning or
/// validating a Project, rebuilding its semantic index, or expanding scope.
pub fn place(
plan: &Plan,
requested: Position,
presentation: PresentationContext,
) -> Result<PlacementPlan, Refusal> {
if presentation.target.is_some() {
return Err(Refusal::Ineligible(
"Arrange only has no performer destination.".into(),
));
}
if !requested.x.is_finite()
|| !requested.y.is_finite()
|| !presentation.viewport.valid()
|| !presentation.zoom.is_finite()
|| presentation.zoom <= 0.
{
return Err(Refusal::LayoutRequired(
"Make room or adjust the view, then drag again.".into(),
));
}
let geometry::Translation {
diagram,
commands,
delta,
} = geometry::translate(
&plan.accepted,
plan.diagram(),
plan.diagram(),
&plan.placements,
plan.anchor,
requested,
)?;
let view = projection::project_diagram_with_index(&plan.accepted, &diagram, &plan.index);
let expanded =
geometry::expanded_view(&plan.accepted, &diagram, &plan.index, &plan.expanded_ids);
// Preserve Arrange's refusal precedence: projected bounds, then rigidity,
// then overlap growth. The shared scene check combines bounds and overlaps.
let scenes = match geometry::check_scenes(
&plan.before_visible,
&plan.before_expanded,
&view,
&expanded,
) {
Err(error @ Refusal::InvalidCandidate(_)) => return Err(error),
result => result,
};
geometry::check_rigid(&plan.before_expanded, &expanded, &plan.placements, delta)?;
scenes?;
geometry::check_headers(&view, &plan.selected, presentation.viewport)?;
Ok(PlacementPlan {
diagram,
view,
delta,
unfolded: None,
requested,
presentation,
commands,
})
}
/// Recompute the candidate and compare the complete expected Project before the
/// caller performs one source-admitted transaction. None means no history entry.
pub fn finish(
plan: &Plan,
live: &Project,
placement: &PlacementPlan,
) -> Result<Option<Command>, Refusal> {
if live != &plan.accepted {
return Err(Refusal::StaleScope);
}
let checked = place(plan, placement.requested, placement.presentation)?;
if checked.diagram != placement.diagram
|| checked.view != placement.view
|| checked.delta != placement.delta
|| checked.unfolded != placement.unfolded
|| checked.commands != placement.commands
{
return Err(Refusal::StaleScope);
}
let mut expected = plan.accepted.clone();
expected
.diagrams
.insert(checked.diagram.id, checked.diagram);
let command = Command::Batch(checked.commands);
let actual = prepare_command(live, &command)
.map_err(|e| Refusal::InvalidCandidate(e.to_string()))?
.project;
exact_project(&expected, &actual)?;
Ok((actual != *live).then_some(command))
}
@@ -0,0 +1,443 @@
use super::*;
struct Distributed {
f: Fixture,
a: Id,
b: Id,
x: Id,
y: Id,
internal: Id,
}
fn distributed(layer: Layer, folded: bool) -> Distributed {
let mut f = fixture(layer);
let kind = f.p.elements[&f.w].kind();
let x = f.p.add_element(kind, layer, "X", Some(f.w)).unwrap();
let y = f.p.add_element(kind, layer, "Y", Some(f.w)).unwrap();
let outside = f.p.add_element(kind, layer, "Outside", None).unwrap();
let xn = f.p.place(f.d, x, xy(140., 200.));
let yn = f.p.place(f.d, y, xy(940., 200.));
f.p.place(f.d, outside, xy(1800., 800.));
f.p.relate(RelationKind::Allocation, "A owns X", f.a, x);
f.p.relate(RelationKind::Allocation, "B owns Y", f.b, y);
let exchange = match layer {
Layer::Oa => RelationKind::Interaction,
Layer::Sa => RelationKind::FunctionalExchange,
};
let internal = f.p.relate(exchange, "X to Y", x, y);
let external = f.p.relate(exchange, "Y to Outside", y, outside);
let d = f.p.diagrams.get_mut(&f.d).unwrap();
let a = d.nodes.values().find(|n| n.element == f.a).unwrap().id;
let b = d.nodes.values().find(|n| n.element == f.b).unwrap().id;
d.nodes.get_mut(&a).unwrap().collapsed = folded;
d.nodes.get_mut(&f.n).unwrap().position = xy(100., 800.);
d.routes = vec![route(internal), route(external)];
validate_candidate(&f.p).unwrap();
Distributed {
f,
a,
b,
x: xn,
y: yn,
internal,
}
}
fn identities(m: &Distributed, ids: &[Id]) -> BTreeMap<Id, Id> {
ids.iter()
.map(|id| (*id, m.f.p.diagrams[&m.f.d].nodes[id].element))
.collect()
}
fn expected(m: &Distributed, include_a: bool) -> Project {
let mut p = m.f.p.clone();
let d = p.diagrams.get_mut(&m.f.d).unwrap();
for (id, position) in [
(m.f.n, xy(500., 800.)),
(m.x, xy(540., 200.)),
(m.y, xy(1340., 200.)),
] {
d.nodes.get_mut(&id).unwrap().position = position;
}
if include_a {
d.nodes.get_mut(&m.a).unwrap().position = xy(500., 100.);
}
d.routes
.iter_mut()
.find(|r| r.edge == m.internal)
.unwrap()
.waypoints = vec![xy(820., 400.), xy(820., 550.)];
p
}
#[test]
fn explicit_arrange_distributed_oa_sa_exact_scope_routes_source_history_and_anchor() {
for layer in [Layer::Oa, Layer::Sa] {
for folded in [false, true] {
for include_a in [false, true] {
let m = distributed(layer, folded);
let members = if include_a {
vec![m.f.n, m.a]
} else {
vec![m.f.n]
};
let selected = identities(&m, &members);
for anchor in selected.keys().copied() {
let plan = arrange::capture(&m.f.p, m.f.d, &selected, anchor).unwrap();
assert_eq!(plan.selected(), &selected);
let mut affected = BTreeSet::from([m.f.n, m.x, m.y]);
if include_a {
affected.insert(m.a);
}
assert_eq!(
plan.affected().keys().copied().collect::<BTreeSet<_>>(),
affected
);
let requested = if anchor == m.a {
xy(503., 102.)
} else {
xy(503., 802.)
};
let placed =
arrange::place(&plan, requested, presentation(&m.f.p, m.f.d, None))
.unwrap();
assert_eq!(placed.delta, xy(400., 0.));
assert_eq!(placed.unfolded, None);
let command = arrange::finish(&plan, &m.f.p, &placed).unwrap().unwrap();
assert_eq!(roundtrip(&m.f.p, command), expected(&m, include_a));
// Explicit Arrange does not widen ordinary distributed assignment.
assert!(matches!(
plan_semantics(
&m.f.p,
&scope(&m.f),
Destination::Performer(m.f.a),
&BTreeMap::new()
),
Err(Refusal::MixedLayoutRequired)
));
}
}
}
}
}
#[test]
fn explicit_arrange_performer_closure_does_not_acquire_incidental_semantic_root() {
for layer in [Layer::Oa, Layer::Sa] {
let m = distributed(layer, true);
let selected = identities(&m, &[m.a]);
let plan = arrange::capture(&m.f.p, m.f.d, &selected, m.a).unwrap();
assert_eq!(
plan.affected().keys().copied().collect::<BTreeSet<_>>(),
BTreeSet::from([m.a, m.x])
);
let placed =
arrange::place(&plan, xy(500., 100.), presentation(&m.f.p, m.f.d, None)).unwrap();
let mut expected = m.f.p.clone();
let d = expected.diagrams.get_mut(&m.f.d).unwrap();
d.nodes.get_mut(&m.a).unwrap().position = xy(500., 100.);
d.nodes.get_mut(&m.x).unwrap().position = xy(540., 200.);
// X-to-Y is now one-ended; all saved pins remain byte-for-byte unchanged.
assert_eq!(
roundtrip(
&m.f.p,
arrange::finish(&plan, &m.f.p, &placed).unwrap().unwrap()
),
expected
);
}
}
#[test]
fn explicit_arrange_validates_all_original_members_before_normalizing() {
let mut m = distributed(Layer::Oa, false);
let selected = identities(&m, &[m.f.n, m.x]);
let plan = arrange::capture(&m.f.p, m.f.d, &selected, m.x).unwrap();
assert_eq!(plan.selected(), &selected);
assert_eq!(plan.affected().len(), 3);
let placed = arrange::place(&plan, xy(540., 200.), presentation(&m.f.p, m.f.d, None)).unwrap();
assert_eq!(
roundtrip(
&m.f.p,
arrange::finish(&plan, &m.f.p, &placed).unwrap().unwrap()
),
expected(&m, false)
);
let mut stale = selected.clone();
stale.insert(m.x, m.f.w);
assert!(arrange::capture(&m.f.p, m.f.d, &stale, m.f.n).is_err());
m.f.p
.diagrams
.get_mut(&m.f.d)
.unwrap()
.nodes
.get_mut(&m.a)
.unwrap()
.collapsed = true;
assert!(
arrange::capture(&m.f.p, m.f.d, &selected, m.f.n).is_err(),
"covered selected X is hidden and cannot disappear in normalization"
);
assert!(arrange::capture(&m.f.p, m.f.d, &BTreeMap::new(), m.f.n).is_err());
assert!(arrange::capture(&m.f.p, m.f.d, &identities(&m, &[m.f.n]), m.a).is_err());
assert!(arrange::capture(&m.f.p, m.f.second, &identities(&m, &[m.f.n]), m.f.n).is_err());
let foreign = Id::new_v4();
assert!(
arrange::capture(
&m.f.p,
m.f.d,
&BTreeMap::from([(m.f.n, m.f.w), (foreign, m.f.w)]),
m.f.n
)
.is_err()
);
m.f.p.diagrams.get_mut(&m.f.d).unwrap().projection = ProjectionKind::Overview;
assert!(arrange::capture(&m.f.p, m.f.d, &identities(&m, &[m.f.n]), m.f.n).is_err());
}
#[test]
fn explicit_arrange_simultaneous_roots_may_enter_each_others_vacated_bounds() {
for layer in [Layer::Oa, Layer::Sa] {
let mut f = fixture(layer);
let kind = f.p.elements[&f.w].kind();
let y = f.p.add_element(kind, layer, "Second root", None).unwrap();
let yn = f.p.place(f.d, y, xy(400., 500.));
let relation = f.p.relate(
if layer == Layer::Oa {
RelationKind::Interaction
} else {
RelationKind::FunctionalExchange
},
"Between roots",
f.w,
y,
);
f.p.diagrams.get_mut(&f.d).unwrap().routes = vec![route(relation)];
let context = presentation(&f.p, f.d, None);
let single = arrange::capture(&f.p, f.d, &BTreeMap::from([(f.n, f.w)]), f.n).unwrap();
assert!(
arrange::place(&single, xy(400., 500.), context).is_err(),
"singleton would collide with stationary Y"
);
let plan =
arrange::capture(&f.p, f.d, &BTreeMap::from([(f.n, f.w), (yn, y)]), f.n).unwrap();
let placed = arrange::place(&plan, xy(400., 500.), context).unwrap();
let mut expected = f.p.clone();
let d = expected.diagrams.get_mut(&f.d).unwrap();
d.nodes.get_mut(&f.n).unwrap().position = xy(400., 500.);
d.nodes.get_mut(&yn).unwrap().position = xy(700., 500.);
d.routes[0].waypoints = vec![xy(720., 400.), xy(720., 550.)];
assert_eq!(
roundtrip(
&f.p,
arrange::finish(&plan, &f.p, &placed).unwrap().unwrap()
),
expected
);
}
}
#[test]
fn explicit_arrange_preserves_roles_and_does_not_invent_unplaced_descendants() {
let mut f = fixture(Layer::Oa);
let a = f.a;
role(&mut f, Some(a));
f.p.diagrams
.get_mut(&f.d)
.unwrap()
.nodes
.get_mut(&f.n)
.unwrap()
.position = xy(150., 200.);
let plan = arrange::capture(&f.p, f.d, &BTreeMap::from([(f.n, f.w)]), f.n).unwrap();
let placed = arrange::place(&plan, xy(160., 210.), presentation(&f.p, f.d, None)).unwrap();
let mut role_expected = f.p.clone();
role_expected
.diagrams
.get_mut(&f.d)
.unwrap()
.nodes
.get_mut(&f.n)
.unwrap()
.position = xy(160., 210.);
assert_eq!(
roundtrip(
&f.p,
arrange::finish(&plan, &f.p, &placed).unwrap().unwrap()
),
role_expected
);
for layer in [Layer::Oa, Layer::Sa] {
let mut m = distributed(layer, true);
let kind = m.f.p.elements[&m.f.w].kind();
m.f.p
.add_element(kind, layer, "Unplaced leaf", Some(m.f.w))
.unwrap();
let plan = arrange::capture(&m.f.p, m.f.d, &identities(&m, &[m.f.n]), m.f.n).unwrap();
assert_eq!(plan.affected().len(), 3);
let placed =
arrange::place(&plan, xy(500., 800.), presentation(&m.f.p, m.f.d, None)).unwrap();
assert_eq!(
roundtrip(
&m.f.p,
arrange::finish(&plan, &m.f.p, &placed).unwrap().unwrap()
),
expected(&m, false)
);
}
}
#[test]
fn explicit_arrange_detects_hidden_moved_child_and_stationary_enclosure_growth() {
for layer in [Layer::Oa, Layer::Sa] {
for hidden in [false, true] {
let mut m = distributed(layer, hidden);
let kind = m.f.p.elements[&m.f.w].kind();
let z = m.f.p.add_element(kind, layer, "Obstacle", None).unwrap();
let zn = m.f.p.place(
m.f.d,
z,
if hidden {
xy(550., 230.)
} else {
xy(1200., 100.)
},
);
// The independent candidate differs only by literal admitted edits.
// Establish which geometric case the real planner must reject.
let candidate = expected(&m, true);
let before = projection::project_view(&m.f.p, m.f.d);
let after = projection::project_view(&candidate, m.f.d);
let find =
|view: &ProjectedView, id| view.nodes.iter().find(|n| n.id == id).unwrap().clone();
if hidden {
assert!(!overlaps(&before, &after));
assert!(!after.nodes.iter().any(|n| n.id == m.x));
let mut diagnostic = candidate.diagrams[&m.f.d].clone();
diagnostic.nodes.get_mut(&m.a).unwrap().collapsed = false;
let expanded = projection::project_diagram(&candidate, &diagnostic);
assert_eq!(
overlap(&find(&expanded, m.x), &find(&expanded, zn)),
if layer == Layer::Oa { 13696. } else { 20116. }
);
} else {
assert_eq!(overlap(&find(&before, m.b), &find(&before, zn)), 0.);
assert_eq!(overlap(&find(&after, m.b), &find(&after, zn)), 21056.);
for id in [m.a, m.f.n, m.x, m.y] {
assert_eq!(overlap(&find(&after, id), &find(&after, zn)), 0.);
}
assert_eq!(
candidate.diagrams[&m.f.d].nodes[&m.b],
m.f.p.diagrams[&m.f.d].nodes[&m.b]
);
}
let plan =
arrange::capture(&m.f.p, m.f.d, &identities(&m, &[m.a, m.f.n]), m.f.n).unwrap();
assert!(matches!(
arrange::place(&plan, xy(500., 800.), presentation(&m.f.p, m.f.d, None)),
Err(Refusal::LayoutRequired(_))
));
}
}
}
#[test]
fn explicit_arrange_preserves_existing_overlap_but_refuses_its_growth() {
let mut f = fixture(Layer::Oa);
let y =
f.p.add_element(Kind::Activity, Layer::Oa, "Existing overlap", None)
.unwrap();
let yn = f.p.place(f.d, y, xy(250., 500.));
let context = presentation(&f.p, f.d, None);
let single = arrange::capture(&f.p, f.d, &BTreeMap::from([(f.n, f.w)]), f.n).unwrap();
assert!(arrange::place(&single, xy(110., 500.), context).is_err());
let plan = arrange::capture(&f.p, f.d, &BTreeMap::from([(f.n, f.w), (yn, y)]), f.n).unwrap();
let placed = arrange::place(&plan, xy(400., 500.), context).unwrap();
let mut expected = f.p.clone();
let d = expected.diagrams.get_mut(&f.d).unwrap();
d.nodes.get_mut(&f.n).unwrap().position = xy(400., 500.);
d.nodes.get_mut(&yn).unwrap().position = xy(550., 500.);
assert_eq!(
roundtrip(
&f.p,
arrange::finish(&plan, &f.p, &placed).unwrap().unwrap()
),
expected
);
}
#[test]
fn explicit_arrange_preserves_unrelated_folded_interior_diagnostic_boundary() {
for layer in [Layer::Oa, Layer::Sa] {
let mut m = distributed(layer, true);
let performer = if layer == Layer::Oa {
Kind::Actor
} else {
Kind::SystemActor
};
let d = m.f.p.add_element(performer, layer, "D", None).unwrap();
let z =
m.f.p
.add_element(m.f.p.elements[&m.f.w].kind(), layer, "Hidden Z", None)
.unwrap();
m.f.p.relate(RelationKind::Allocation, "D owns Z", d, z);
let dn = m.f.p.place(m.f.d, d, xy(2500., 100.));
m.f.p.place(m.f.d, z, xy(550., 230.));
m.f.p
.diagrams
.get_mut(&m.f.d)
.unwrap()
.nodes
.get_mut(&dn)
.unwrap()
.collapsed = true;
let plan = arrange::capture(&m.f.p, m.f.d, &identities(&m, &[m.a, m.f.n]), m.f.n).unwrap();
let placed =
arrange::place(&plan, xy(500., 800.), presentation(&m.f.p, m.f.d, None)).unwrap();
assert_eq!(
roundtrip(
&m.f.p,
arrange::finish(&plan, &m.f.p, &placed).unwrap().unwrap()
),
expected(&m, true)
);
}
}
#[test]
fn explicit_arrange_noop_stale_tamper_target_and_all_selected_headers() {
let m = distributed(Layer::Sa, false);
let selected = identities(&m, &[m.a, m.f.n]);
let plan = arrange::capture(&m.f.p, m.f.d, &selected, m.f.n).unwrap();
let context = presentation(&m.f.p, m.f.d, None);
let noop = arrange::place(&plan, xy(103., 802.), context).unwrap();
assert!(arrange::finish(&plan, &m.f.p, &noop).unwrap().is_none());
let mut placed = arrange::place(&plan, xy(500., 800.), context).unwrap();
let mut stale = m.f.p.clone();
stale.name.push_str(" changed");
assert!(matches!(
arrange::finish(&plan, &stale, &placed),
Err(Refusal::StaleScope)
));
placed.diagram.nodes.get_mut(&m.b).unwrap().position.x += 1.;
assert!(matches!(
arrange::finish(&plan, &m.f.p, &placed),
Err(Refusal::StaleScope)
));
assert!(
arrange::place(
&plan,
xy(500., 800.),
presentation(&m.f.p, m.f.d, Some(m.f.b))
)
.is_err()
);
let mut clipped = context;
clipped.viewport.position = xy(400., 700.);
assert!(
arrange::place(&plan, xy(500., 800.), clipped).is_err(),
"anchor C visible, other selected A header clipped"
);
assert!(arrange::place(&plan, xy(f32::NAN, 800.), context).is_err());
assert!(arrange::place(&plan, xy(MAX_COORDINATE, 800.), context).is_err());
}
@@ -0,0 +1,444 @@
//! Ordinary work-only group assignment, with one combined semantic/geometry plan.
//! App retains authority over the complete typed selection and gesture lifetime.
use super::*;
use std::sync::Arc;
#[derive(Debug)]
struct Captured {
accepted: Project,
diagram: Id,
selected: BTreeMap<Id, Id>,
anchor: Id,
roots: Vec<super::Scope>,
placements: BTreeMap<Id, NodePlacement>,
leaves: BTreeMap<Id, Leaf>,
index: ViewIndex,
before_visible: ProjectedView,
before_expanded: ProjectedView,
}
/// Destination plans share one immutable accepted capture.
#[derive(Debug, Clone)]
pub struct Scope(Arc<Captured>);
impl Scope {
pub fn project(&self) -> &Project {
&self.0.accepted
}
pub fn index(&self) -> &ViewIndex {
&self.0.index
}
pub fn diagram(&self) -> &Diagram {
&self.0.accepted.diagrams[&self.0.diagram]
}
pub fn selected(&self) -> &BTreeMap<Id, Id> {
&self.0.selected
}
pub fn affected(&self) -> &BTreeMap<Id, NodePlacement> {
&self.0.placements
}
pub fn anchor(&self) -> Id {
self.0.anchor
}
pub fn leaves(&self) -> &BTreeMap<Id, Leaf> {
&self.0.leaves
}
}
/// App must reject non-placement members before constructing this complete map.
/// Every original identity is checked before redundant work roots are removed.
pub fn capture(
p: &Project,
diagram: Id,
selected: &BTreeMap<Id, Id>,
anchor: Id,
) -> Result<Scope, Refusal> {
validate_candidate(p).map_err(|e| Refusal::InvalidCandidate(e.to_string()))?;
let d = p.diagrams.get(&diagram).ok_or(Refusal::StaleScope)?;
if d.projection != ProjectionKind::Allocation
|| selected.is_empty()
|| !selected.contains_key(&anchor)
{
return Err(Refusal::Ineligible(
"Assignment requires selected work placements and a selected anchor in an Allocation view."
.into(),
));
}
let index = ViewIndex::new(p);
let before_visible = projection::project_diagram_with_index(p, d, &index);
let visible: BTreeMap<_, _> = before_visible
.nodes
.iter()
.map(|node| (node.id, node.element))
.collect();
let mut work = BTreeMap::new();
for (placement, element) in selected {
let node = d.nodes.get(placement).ok_or(Refusal::StaleScope)?;
let record = p.elements.get(element).ok_or(Refusal::StaleScope)?;
if node.element != *element || visible.get(placement) != Some(element) {
return Err(Refusal::StaleScope);
}
if !record.kind().is_work() || record.layer() != d.layer {
return Err(Refusal::Ineligible(
"Assignment requires only activities or functions in this layer. Use Arrange only for a supported mixed selection."
.into(),
));
}
work.insert(*element, *placement);
}
let mut roots = Vec::new();
let mut placements = BTreeMap::new();
let mut leaves = BTreeMap::new();
for (element, placement) in &work {
let record = &p.elements[element];
let mut ancestor = record.parent;
let mut seen = BTreeSet::new();
let mut covered = false;
while let Some(id) = ancestor {
if !seen.insert(id) {
return Err(Refusal::StaleScope);
}
let parent = p.elements.get(&id).ok_or(Refusal::StaleScope)?;
if parent.kind() != record.kind() {
break;
}
if work.contains_key(&id) {
covered = true;
break;
}
ancestor = parent.parent;
}
if covered {
continue;
}
let root = capture_validated(p, diagram, *element, *placement)?;
if root.work_records.len() > 1 && root.uniform == Uniform::MixedOrUnresolved {
return Err(Refusal::MixedLayoutRequired);
}
for (id, leaf) in &root.leaves {
if leaves.insert(*id, leaf.clone()).is_some() {
return Err(Refusal::StaleScope);
}
}
placements.extend(root.placements.clone());
roots.push(root);
}
let expansion = geometry::expansion_ids(p, d, &index, &placements)?;
let before_expanded = geometry::expanded_view(p, d, &index, &expansion);
if roots
.iter()
.any(|root| root.work_records.len() > 1 && !coherent(root, &before_expanded))
{
return Err(Refusal::MixedLayoutRequired);
}
Ok(Scope(Arc::new(Captured {
accepted: p.clone(),
diagram,
selected: selected.clone(),
anchor,
roots,
placements,
leaves,
index,
before_visible,
before_expanded,
})))
}
#[derive(Debug)]
pub struct SemanticPlan {
scope: Scope,
destination: Destination,
candidate: Project,
index: ViewIndex,
commands: Vec<Command>,
changed: BTreeSet<Id>,
expanded_ids: BTreeSet<Id>,
label: String,
warnings: Vec<Issue>,
}
impl SemanticPlan {
pub fn scope(&self) -> &Scope {
&self.scope
}
pub fn project(&self) -> &Project {
&self.candidate
}
pub fn index(&self) -> &ViewIndex {
&self.index
}
pub fn destination(&self) -> Destination {
self.destination
}
pub fn label(&self) -> &str {
&self.label
}
pub fn warnings(&self) -> &[Issue] {
&self.warnings
}
pub fn changed_count(&self) -> usize {
self.changed.len()
}
/// Already effective at the performer, or already unassigned for Outside.
pub fn already_correct_count(&self) -> usize {
self.scope.leaves().len() - self.changed.len()
}
}
/// Build one destination candidate. Per-root decisions read accepted facts;
/// aliases and new identities are reserved in the combined expected Project.
pub fn plan_semantics(
scope: &Scope,
destination: Destination,
allocation_ids: &BTreeMap<Id, Id>,
) -> Result<SemanticPlan, Refusal> {
let accepted = scope.project();
match destination {
Destination::LayoutOnly => {
return Err(Refusal::Ineligible(
"Use the explicit Arrange only planner for presentation-only movement.".into(),
));
}
Destination::Performer(id) => {
if !accepted.elements.get(&id).is_some_and(|element| {
element.kind().is_performer() && element.layer() == scope.diagram().layer
}) {
return Err(Refusal::Ineligible(
"Choose a performer in this layer.".into(),
));
}
}
Destination::OutsidePerformers => {}
}
let mut expected = accepted.clone();
let mut edits = IntendedEdits::default();
for root in &scope.0.roots {
append_semantic_edits(
accepted,
root,
destination,
allocation_ids,
&mut expected,
&mut edits,
)?;
}
for diagram in expected.diagrams.values_mut() {
diagram
.routes
.retain(|route| !edits.deleted.contains(&route.edge));
}
let candidate = prepare_command(accepted, &Command::Batch(edits.commands.clone()))
.map_err(|e| Refusal::InvalidCandidate(e.to_string()))?
.project;
exact_project(&expected, &candidate)?;
let warnings = validation::validate(&candidate)
.into_iter()
.filter(|issue| issue.severity == Severity::Warning)
.collect();
let index = ViewIndex::new(&candidate);
// Assignment may change visual ancestry: never reuse accepted expansion IDs.
let expanded_ids = geometry::expansion_ids(
&candidate,
&candidate.diagrams[&scope.0.diagram],
&index,
scope.affected(),
)?;
let total = scope.leaves().len();
let changed = edits.changed.len();
let already_correct = total - changed;
let noun = match (scope.diagram().layer, total) {
(Layer::Oa, 1) => "activity",
(Layer::Oa, _) => "activities",
(Layer::Sa, 1) => "function",
(Layer::Sa, _) => "functions",
};
let retained_roles = scope
.leaves()
.values()
.any(|leaf| !leaf.role_assignments.is_empty());
let label = match destination {
Destination::Performer(id) if changed == 0 => {
format!(
"Arrange within {} — all {total} leaf {noun} already assigned",
accepted.named(id)
)
}
Destination::Performer(id) => format!(
"Assign {changed} of {total} leaf {noun} to {}{}{}",
accepted.named(id),
if already_correct > 0 {
format!(" · {already_correct} already assigned")
} else {
String::new()
},
if retained_roles {
" — role responsibility retained"
} else {
""
},
),
Destination::OutsidePerformers if changed == 0 => {
"Arrange unassigned work — decomposition retained".into()
}
Destination::OutsidePerformers => format!(
"Remove assignments from {changed} of {total} leaf {noun} — decomposition retained"
),
Destination::LayoutOnly => unreachable!(),
};
Ok(SemanticPlan {
scope: scope.clone(),
destination,
candidate,
index,
commands: edits.commands,
changed: edits.changed,
expanded_ids,
label,
warnings,
})
}
/// Translate the entire captured Q once, then evaluate the combined candidate.
/// This path does not clone/validate a Project or rebuild semantic indexes.
pub fn place(
plan: &SemanticPlan,
requested: Position,
presentation: PresentationContext,
) -> Result<PlacementPlan, Refusal> {
if !requested.x.is_finite()
|| !requested.y.is_finite()
|| !presentation.viewport.valid()
|| !presentation.zoom.is_finite()
|| presentation.zoom <= 0.
{
return Err(Refusal::LayoutRequired(
"Make room or adjust the view, then drag again.".into(),
));
}
let scope = &plan.scope;
let geometry::Translation {
mut diagram,
mut commands,
delta,
} = geometry::translate(
scope.project(),
scope.diagram(),
&plan.candidate.diagrams[&scope.0.diagram],
scope.affected(),
scope.anchor(),
requested,
)?;
let mut unfolded = None;
if let Destination::Performer(performer) = plan.destination {
let target = presentation.target.ok_or_else(|| {
Refusal::LayoutRequired("A visible accepted performer target is required.".into())
})?;
if !scope
.0
.before_visible
.nodes
.iter()
.any(|node| node.id == target.placement && node.element == performer)
{
return Err(Refusal::LayoutRequired(
"Make room or adjust the view, then drag again.".into(),
));
}
let target_node = diagram
.nodes
.get_mut(&target.placement)
.ok_or(Refusal::StaleScope)?;
if target_node.element != performer {
return Err(Refusal::StaleScope);
}
if !plan.changed.is_empty() && target_node.collapsed {
target_node.collapsed = false;
unfolded = Some(target_node.id);
commands.push(Command::EditNode {
diagram: diagram.id,
original: target_node.id,
node: target_node.clone(),
});
}
} else if presentation.target.is_some() {
return Err(Refusal::Ineligible(
"An outside destination has no performer target.".into(),
));
}
let view = projection::project_diagram_with_index(&plan.candidate, &diagram, &plan.index);
let expanded =
geometry::expanded_view(&plan.candidate, &diagram, &plan.index, &plan.expanded_ids);
if scope
.0
.roots
.iter()
.any(|root| root.work_records.len() > 1 && !coherent(root, &expanded))
{
return Err(Refusal::MixedLayoutRequired);
}
geometry::check_scenes(
&scope.0.before_visible,
&scope.0.before_expanded,
&view,
&expanded,
)?;
geometry::check_rigid(&scope.0.before_expanded, &expanded, scope.affected(), delta)?;
geometry::check_headers(&view, scope.selected(), presentation.viewport)?;
Ok(PlacementPlan {
diagram,
view,
delta,
unfolded,
requested,
presentation,
commands,
})
}
/// Recheck immutable facts and recompute geometry before one final source batch.
/// Zero delta remains a transaction when assignment semantics changed.
pub fn finish(
plan: &SemanticPlan,
live: &Project,
placement: &PlacementPlan,
) -> Result<Option<Command>, Refusal> {
if live != plan.scope.project() {
return Err(Refusal::StaleScope);
}
let checked = place(plan, placement.requested, placement.presentation)?;
if checked.diagram != placement.diagram
|| checked.view != placement.view
|| checked.delta != placement.delta
|| checked.unfolded != placement.unfolded
|| checked.commands != placement.commands
{
return Err(Refusal::StaleScope);
}
let mut expected = plan.candidate.clone();
expected
.diagrams
.insert(checked.diagram.id, checked.diagram);
let mut commands = plan.commands.clone();
commands.extend(checked.commands);
let command = Command::Batch(commands);
let actual = prepare_command(live, &command)
.map_err(|e| Refusal::InvalidCandidate(e.to_string()))?
.project;
exact_project(&expected, &actual)?;
Ok((actual != *live).then_some(command))
}
@@ -0,0 +1,497 @@
use super::*;
struct Composite {
f: Fixture,
child: Id,
child_node: Id,
unplaced: Id,
}
fn composite(layer: Layer) -> Composite {
let mut f = fixture(layer);
let kind = f.p.elements[&f.w].kind();
let child =
f.p.add_element(kind, layer, "Placed child", Some(f.w))
.unwrap();
let unplaced =
f.p.add_element(kind, layer, "Unplaced leaf", Some(f.w))
.unwrap();
let child_node = f.p.place(f.d, child, xy(140., 600.));
validate_candidate(&f.p).unwrap();
Composite {
f,
child,
child_node,
unplaced,
}
}
fn expected_allocation(id: Id, alias: &str, name: &str, performer: Id, work: Id) -> Relation {
Relation {
id,
alias: alias.into(),
name: name.into(),
description: String::new(),
body: RelationBody::Allocation {
source: performer,
target: work,
},
}
}
#[test]
fn group_assignment_checks_original_identity_and_visibility_before_normalization() {
for layer in [Layer::Oa, Layer::Sa] {
let c = composite(layer);
let valid = BTreeMap::from([(c.f.n, c.f.w), (c.child_node, c.child)]);
let admitted = assignment::capture(&c.f.p, c.f.d, &valid, c.f.n).unwrap();
assert_eq!(admitted.selected(), &valid);
let foreign = c.f.p.diagrams[&c.f.second]
.nodes
.values()
.find(|node| node.element == c.f.w)
.unwrap()
.id;
// Each extra member is semantically covered by the valid selected parent.
// Normalizing first would incorrectly hide its invalid original identity.
for invalid in [
BTreeMap::from([(c.f.n, c.f.w), (c.child_node, c.unplaced)]),
BTreeMap::from([(c.f.n, c.f.w), (Id::new_v4(), c.child)]),
BTreeMap::from([(c.f.n, c.f.w), (foreign, c.f.w)]),
] {
assert!(matches!(
assignment::capture(&c.f.p, c.f.d, &invalid, c.f.n),
Err(Refusal::StaleScope)
));
}
let mut folded = c.f.p.clone();
folded
.diagrams
.get_mut(&c.f.d)
.unwrap()
.nodes
.get_mut(&c.f.n)
.unwrap()
.collapsed = true;
validate_candidate(&folded).unwrap();
assert!(
!projection::project_view(&folded, c.f.d)
.nodes
.iter()
.any(|node| node.id == c.child_node)
);
// Hidden descendants remain in Q when only the visible parent is selected.
let parent_only =
assignment::capture(&folded, c.f.d, &BTreeMap::from([(c.f.n, c.f.w)]), c.f.n).unwrap();
assert!(parent_only.affected().contains_key(&c.child_node));
assert!(matches!(
assignment::capture(&folded, c.f.d, &valid, c.f.n),
Err(Refusal::StaleScope)
));
}
}
#[test]
fn group_assignment_normalizes_parent_child_and_assigns_unplaced_leaf_once() {
for layer in [Layer::Oa, Layer::Sa] {
let c = composite(layer);
let selected = BTreeMap::from([(c.f.n, c.f.w), (c.child_node, c.child)]);
let scope = assignment::capture(&c.f.p, c.f.d, &selected, c.f.n).unwrap();
assert_eq!(scope.selected(), &selected);
assert_eq!(
scope.affected().keys().copied().collect::<BTreeSet<_>>(),
BTreeSet::from([c.f.n, c.child_node])
);
assert_eq!(
scope.leaves().keys().copied().collect::<BTreeSet<_>>(),
BTreeSet::from([c.child, c.unplaced])
);
let child_allocation = Id::new_v4();
let unplaced_allocation = Id::new_v4();
let ids = BTreeMap::from([
(c.child, child_allocation),
(c.unplaced, unplaced_allocation),
]);
let plan = assignment::plan_semantics(&scope, Destination::Performer(c.f.a), &ids).unwrap();
assert_eq!(plan.changed_count(), 2);
let mut expected = c.f.p.clone();
expected.relations.insert(
child_allocation,
expected_allocation(
child_allocation,
"a_performs_placed_child",
"A performs Placed child",
c.f.a,
c.child,
),
);
expected.relations.insert(
unplaced_allocation,
expected_allocation(
unplaced_allocation,
"a_performs_unplaced_leaf",
"A performs Unplaced leaf",
c.f.a,
c.unplaced,
),
);
assert_eq!(plan.project(), &expected);
let placed = assignment::place(
&plan,
xy(140., 200.),
presentation(&c.f.p, c.f.d, Some(c.f.a)),
)
.unwrap();
let mut clipped = presentation(&c.f.p, c.f.d, Some(c.f.a));
clipped.viewport.size.height = 450.; // bottom350: parent header fits; selected child ends362.
for (id, should_fit) in [(c.f.n, true), (c.child_node, false)] {
let node = placed.view.nodes.iter().find(|node| node.id == id).unwrap();
let header = Rect {
position: node.position,
size: Size {
width: node.size.width,
height: 62.,
},
};
assert_eq!(clipped.viewport.contains(header), should_fit);
}
assert!(
matches!(
assignment::place(&plan, xy(140., 200.), clipped),
Err(Refusal::LayoutRequired(_))
),
"normalized-away selected child still requires its header"
);
let d = expected.diagrams.get_mut(&c.f.d).unwrap();
d.nodes.get_mut(&c.f.n).unwrap().position = xy(140., 200.);
d.nodes.get_mut(&c.child_node).unwrap().position = xy(180., 300.);
assert_eq!(placed.delta, xy(40., -300.));
assert_eq!(
roundtrip(
&c.f.p,
assignment::finish(&plan, &c.f.p, &placed).unwrap().unwrap()
),
expected
);
assert_eq!(
expected.diagrams[&c.f.d].nodes.len(),
c.f.p.diagrams[&c.f.d].nodes.len()
);
assert!(
!expected.diagrams[&c.f.d]
.nodes
.values()
.any(|node| node.element == c.unplaced)
);
assert!(
!expected
.relations
.values()
.any(|relation| relation.kind() == RelationKind::Allocation
&& relation.target() == c.f.w)
);
}
}
#[test]
fn group_assignment_distributed_parent_refuses_but_its_independent_leaves_can_reassign() {
for layer in [Layer::Oa, Layer::Sa] {
let mut c = composite(layer);
let second_node = c.f.p.place(c.f.d, c.unplaced, xy(940., 200.));
let performer =
c.f.p
.add_element(
if layer == Layer::Oa {
Kind::Actor
} else {
Kind::SystemActor
},
layer,
"C",
None,
)
.unwrap();
c.f.p.place(c.f.d, performer, xy(1700., 100.));
let d = c.f.p.diagrams.get_mut(&c.f.d).unwrap();
d.nodes.get_mut(&c.f.n).unwrap().position = xy(1700., 800.);
d.nodes.get_mut(&c.child_node).unwrap().position = xy(140., 200.);
let ax =
c.f.p
.relate(RelationKind::Allocation, "A child", c.f.a, c.child);
let by =
c.f.p
.relate(RelationKind::Allocation, "B child", c.f.b, c.unplaced);
validate_candidate(&c.f.p).unwrap();
let visible = projection::project_view(&c.f.p, c.f.d);
for id in [c.f.n, c.child_node, second_node] {
assert!(visible.nodes.iter().any(|node| node.id == id));
}
assert_eq!(scope(&c.f).uniform, Uniform::MixedOrUnresolved);
assert!(matches!(
assignment::capture(&c.f.p, c.f.d, &BTreeMap::from([(c.f.n, c.f.w)]), c.f.n,),
Err(Refusal::MixedLayoutRequired)
));
let selected = BTreeMap::from([(c.child_node, c.child), (second_node, c.unplaced)]);
let captured = assignment::capture(&c.f.p, c.f.d, &selected, c.child_node).unwrap();
let plan = assignment::plan_semantics(
&captured,
Destination::Performer(performer),
&BTreeMap::new(),
)
.unwrap();
let mut expected = c.f.p.clone();
expected.relations.get_mut(&ax).unwrap().body = RelationBody::Allocation {
source: performer,
target: c.child,
};
expected.relations.get_mut(&by).unwrap().body = RelationBody::Allocation {
source: performer,
target: c.unplaced,
};
assert_eq!(plan.project(), &expected);
// One delta moves both leaves. The unselected semantic parent remains
// authored at (1700,800), while its derived enclosure may change.
let placed = assignment::place(
&plan,
xy(1740., 120.),
presentation(&c.f.p, c.f.d, Some(performer)),
)
.unwrap();
let d = expected.diagrams.get_mut(&c.f.d).unwrap();
d.nodes.get_mut(&c.child_node).unwrap().position = xy(1740., 120.);
d.nodes.get_mut(&second_node).unwrap().position = xy(2540., 120.);
assert_eq!(
roundtrip(
&c.f.p,
assignment::finish(&plan, &c.f.p, &placed).unwrap().unwrap()
),
expected
);
}
}
fn separate_unassigned(layer: Layer) -> (Fixture, Id, Id) {
let mut f = fixture(layer);
let companion =
f.p.add_element(f.p.elements[&f.w].kind(), layer, "Companion", None)
.unwrap();
let companion_node = f.p.place(f.d, companion, xy(100., 1000.));
validate_candidate(&f.p).unwrap();
(f, companion, companion_node)
}
#[test]
fn group_assignment_finish_rejects_stale_project_and_each_tampered_result() {
let (f, companion, companion_node) = separate_unassigned(Layer::Oa);
let selected = BTreeMap::from([(f.n, f.w), (companion_node, companion)]);
let captured = assignment::capture(&f.p, f.d, &selected, f.n).unwrap();
let plan =
assignment::plan_semantics(&captured, Destination::OutsidePerformers, &BTreeMap::new())
.unwrap();
let context = presentation(&f.p, f.d, None);
let valid = assignment::place(&plan, xy(500., 500.), context).unwrap();
let mut expected = f.p.clone();
expected
.diagrams
.get_mut(&f.d)
.unwrap()
.nodes
.get_mut(&f.n)
.unwrap()
.position = xy(500., 500.);
expected
.diagrams
.get_mut(&f.d)
.unwrap()
.nodes
.get_mut(&companion_node)
.unwrap()
.position = xy(500., 1000.);
assert_eq!(
roundtrip(
&f.p,
assignment::finish(&plan, &f.p, &valid).unwrap().unwrap()
),
expected
);
let mut stale = f.p.clone();
stale.elements.get_mut(&f.b).unwrap().description = "Changed unrelated accepted record".into();
validate_candidate(&stale).unwrap();
assert!(matches!(
assignment::finish(&plan, &stale, &valid),
Err(Refusal::StaleScope)
));
for field in 0..6 {
let mut altered = assignment::place(&plan, xy(500., 500.), context).unwrap();
match field {
0 => altered.diagram.nodes.get_mut(&f.n).unwrap().position.x += 20.,
1 => {
altered
.view
.nodes
.iter_mut()
.find(|node| node.id == f.n)
.unwrap()
.position
.x += 20.
}
2 => altered.delta.x += 20.,
3 => altered.unfolded = Some(f.n),
4 => altered.commands.clear(),
5 => altered.requested.x += 10.,
_ => unreachable!(),
}
assert!(
matches!(
assignment::finish(&plan, &f.p, &altered),
Err(Refusal::StaleScope)
),
"tampered result field {field}"
);
}
}
#[test]
fn group_assignment_requires_non_anchor_selected_header_inside_viewport() {
let (f, companion, companion_node) = separate_unassigned(Layer::Oa);
let captured = assignment::capture(
&f.p,
f.d,
&BTreeMap::from([(f.n, f.w), (companion_node, companion)]),
f.n,
)
.unwrap();
let plan =
assignment::plan_semantics(&captured, Destination::OutsidePerformers, &BTreeMap::new())
.unwrap();
let wide = presentation(&f.p, f.d, None);
let placed = assignment::place(&plan, xy(500., 500.), wide).unwrap();
let mut narrow = wide;
narrow.viewport.size.height = 800.; // world bottom700: anchor header fits, companion does not.
let header = |id| {
let node = placed.view.nodes.iter().find(|node| node.id == id).unwrap();
Rect {
position: node.position,
size: Size {
width: node.size.width,
height: 62.,
},
}
};
assert!(narrow.viewport.contains(header(f.n)));
assert!(!narrow.viewport.contains(header(companion_node)));
// Only the viewport differs from the already admitted identical geometry.
assert!(matches!(
assignment::place(&plan, xy(500., 500.), narrow),
Err(Refusal::LayoutRequired(_))
));
}
#[test]
fn group_assignment_unresolved_oa_role_leaf_accepts_explicit_target_but_not_outside() {
for ambiguous in [false, true] {
let (mut f, companion, companion_node) = separate_unassigned(Layer::Oa);
let (role_id, role_assignment) = role(&mut f, None);
if ambiguous {
f.p.relate(
RelationKind::RoleRealization,
"A realizes responsibility",
f.a,
role_id,
);
f.p.relate(
RelationKind::RoleRealization,
"B realizes responsibility",
f.b,
role_id,
);
}
validate_candidate(&f.p).unwrap();
assert_eq!(validation::effective_performer(&f.p, f.w), None);
let captured = assignment::capture(
&f.p,
f.d,
&BTreeMap::from([(f.n, f.w), (companion_node, companion)]),
f.n,
)
.unwrap();
let Refusal::RoleExitRequired {
work,
roles,
performers,
..
} = assignment::plan_semantics(&captured, Destination::OutsidePerformers, &BTreeMap::new())
.unwrap_err()
else {
panic!("unresolved role must produce its specific outward refusal");
};
assert_eq!(work, f.w);
assert_eq!(roles, vec![role_id]);
assert_eq!(
performers.into_iter().collect::<BTreeSet<_>>(),
if ambiguous {
BTreeSet::from([f.a, f.b])
} else {
BTreeSet::new()
}
);
let work_allocation = Id::new_v4();
let companion_allocation = Id::new_v4();
let ids = BTreeMap::from([(f.w, work_allocation), (companion, companion_allocation)]);
let plan =
assignment::plan_semantics(&captured, Destination::Performer(f.b), &ids).unwrap();
assert_eq!(plan.changed_count(), 2);
assert!(plan.label().contains("role responsibility retained"));
let mut expected = f.p.clone();
expected.relations.insert(
work_allocation,
expected_allocation(
work_allocation,
"b_performs_work",
"B performs Work",
f.b,
f.w,
),
);
expected.relations.insert(
companion_allocation,
expected_allocation(
companion_allocation,
"b_performs_companion",
"B performs Companion",
f.b,
companion,
),
);
assert_eq!(plan.project(), &expected);
assert_eq!(
plan.project().relations[&role_assignment],
f.p.relations[&role_assignment]
);
let placed =
assignment::place(&plan, xy(940., 200.), presentation(&f.p, f.d, Some(f.b))).unwrap();
expected
.diagrams
.get_mut(&f.d)
.unwrap()
.nodes
.get_mut(&f.n)
.unwrap()
.position = xy(940., 200.);
expected
.diagrams
.get_mut(&f.d)
.unwrap()
.nodes
.get_mut(&companion_node)
.unwrap()
.position = xy(940., 700.);
assert_eq!(
roundtrip(
&f.p,
assignment::finish(&plan, &f.p, &placed).unwrap().unwrap()
),
expected
);
}
}
@@ -0,0 +1,606 @@
use super::*;
struct Roots {
f: Fixture,
c: Id,
cn: Id,
y: Id,
yn: Id,
z: Id,
zn: Id,
ax: Id,
by: Id,
}
fn roots(layer: Layer) -> Roots {
let mut f = fixture(layer);
let kind = f.p.elements[&f.w].kind();
let c =
f.p.add_element(
if layer == Layer::Oa {
Kind::Actor
} else {
Kind::SystemActor
},
layer,
"C",
None,
)
.unwrap();
let y = f.p.add_element(kind, layer, "Y", None).unwrap();
let z = f.p.add_element(kind, layer, "Z", None).unwrap();
let cn = f.p.place(f.d, c, xy(1500., 100.));
let yn = f.p.place(f.d, y, xy(140., 800.));
let zn = f.p.place(f.d, z, xy(1540., 1400.));
let d = f.p.diagrams.get_mut(&f.d).unwrap();
d.nodes.get_mut(&f.n).unwrap().position = xy(140., 200.);
d.nodes
.values_mut()
.find(|n| n.element == f.b)
.unwrap()
.position = xy(100., 700.);
let ax = f.p.relate(
RelationKind::Allocation,
"Original X responsibility",
f.a,
f.w,
);
let by = f.p.relate(
RelationKind::Allocation,
"Original Y responsibility",
f.b,
y,
);
f.p.relations.get_mut(&ax).unwrap().description = "Reviewed X identity must survive".into();
f.p.relations.get_mut(&by).unwrap().description = "Reviewed Y identity must survive".into();
if layer == Layer::Oa {
let r =
f.p.add_element(Kind::Role, layer, "Responsibility", None)
.unwrap();
f.p.relate(RelationKind::RoleAssignment, "Z role", r, z);
f.p.relate(RelationKind::RoleRealization, "C role", c, r);
} else {
f.p.relate(RelationKind::Allocation, "Z already assigned", c, z);
}
validate_candidate(&f.p).unwrap();
Roots {
f,
c,
cn,
y,
yn,
z,
zn,
ax,
by,
}
}
fn wide(p: &Project, d: Id, target: Option<Id>) -> PresentationContext {
let mut context = presentation(p, d, target);
context.viewport.size = Size {
width: 5000.,
height: 3000.,
};
context
}
fn selected(r: &Roots, with_z: bool) -> BTreeMap<Id, Id> {
let mut ids = BTreeMap::from([(r.f.n, r.f.w), (r.yn, r.y)]);
if with_z {
ids.insert(r.zn, r.z);
}
ids
}
fn retarget(p: &mut Project, relation: Id, target: Id) {
let r = p.relations.get_mut(&relation).unwrap();
r.body = RelationBody::Allocation {
source: target,
target: r.target(),
};
}
#[test]
fn group_assignment_independent_performers_preserves_already_correct_provenance() {
for layer in [Layer::Oa, Layer::Sa] {
let r = roots(layer);
let selected = selected(&r, true);
let scope = assignment::capture(&r.f.p, r.f.d, &selected, r.f.n).unwrap();
let plan =
assignment::plan_semantics(&scope, Destination::Performer(r.c), &BTreeMap::new())
.unwrap();
assert_eq!(plan.changed_count(), 2);
assert_eq!(plan.already_correct_count(), 1);
let placed =
assignment::place(&plan, xy(1540., 200.), wide(&r.f.p, r.f.d, Some(r.c))).unwrap();
let mut expected = r.f.p.clone();
retarget(&mut expected, r.ax, r.c);
retarget(&mut expected, r.by, r.c);
let d = expected.diagrams.get_mut(&r.f.d).unwrap();
d.nodes.get_mut(&r.f.n).unwrap().position = xy(1540., 200.);
d.nodes.get_mut(&r.yn).unwrap().position = xy(1540., 800.);
d.nodes.get_mut(&r.zn).unwrap().position = xy(2940., 1400.);
assert_eq!(
roundtrip(
&r.f.p,
assignment::finish(&plan, &r.f.p, &placed).unwrap().unwrap()
),
expected
);
if layer == Layer::Oa {
assert!(
!plan
.project()
.relations
.values()
.any(|relation| relation.kind() == RelationKind::Allocation
&& relation.target() == r.z)
);
assert!(matches!(
assignment::plan_semantics(
&scope,
Destination::OutsidePerformers,
&BTreeMap::new()
),
Err(Refusal::RoleExitRequired { .. })
));
}
assert!(
assignment::plan_semantics(&scope, Destination::LayoutOnly, &BTreeMap::new()).is_err()
);
}
}
#[test]
fn group_assignment_zero_delta_changes_semantics_once_and_then_is_noop() {
for layer in [Layer::Oa, Layer::Sa] {
let mut f = fixture(layer);
let d = f.p.diagrams.get_mut(&f.d).unwrap();
d.nodes
.values_mut()
.find(|n| n.element == f.a)
.unwrap()
.size = Size {
width: 600.,
height: 400.,
};
d.nodes.get_mut(&f.n).unwrap().position = xy(140., 200.);
let scope = assignment::capture(&f.p, f.d, &BTreeMap::from([(f.n, f.w)]), f.n).unwrap();
let id = Id::new_v4();
let plan = assignment::plan_semantics(
&scope,
Destination::Performer(f.a),
&BTreeMap::from([(f.w, id)]),
)
.unwrap();
let placed = assignment::place(&plan, xy(140., 200.), wide(&f.p, f.d, Some(f.a))).unwrap();
assert_eq!(placed.delta, xy(0., 0.));
let mut expected = f.p.clone();
expected.relations.insert(
id,
Relation {
id,
alias: "a_performs_work".into(),
name: "A performs Work".into(),
description: String::new(),
body: RelationBody::Allocation {
source: f.a,
target: f.w,
},
},
);
assert_eq!(
roundtrip(
&f.p,
assignment::finish(&plan, &f.p, &placed).unwrap().unwrap()
),
expected
);
let scope =
assignment::capture(&expected, f.d, &BTreeMap::from([(f.n, f.w)]), f.n).unwrap();
let noop =
assignment::plan_semantics(&scope, Destination::Performer(f.a), &BTreeMap::new())
.unwrap();
let placed =
assignment::place(&noop, xy(140., 200.), wide(&expected, f.d, Some(f.a))).unwrap();
assert!(
assignment::finish(&noop, &expected, &placed)
.unwrap()
.is_none()
);
}
}
#[test]
fn group_assignment_unfolds_only_changed_destination_and_refuses_newly_exposed_overlap() {
for layer in [Layer::Oa, Layer::Sa] {
for obstacle in [false, true] {
let mut r = roots(layer);
let d = r.f.p.diagrams.get_mut(&r.f.d).unwrap();
d.nodes
.values_mut()
.find(|n| n.element == r.f.b)
.unwrap()
.position = xy(100., 400.);
d.nodes.get_mut(&r.yn).unwrap().position = xy(140., 500.);
d.nodes.get_mut(&r.zn).unwrap().position = xy(1540., 800.);
d.nodes.get_mut(&r.cn).unwrap().collapsed = true;
if obstacle {
let k =
r.f.p
.add_element(r.f.p.elements[&r.f.w].kind(), layer, "Hidden overlap", None)
.unwrap();
r.f.p
.relate(RelationKind::Allocation, "C also owns K", r.c, k);
r.f.p.place(r.f.d, k, xy(1540., 800.));
}
assert!(
!projection::project_view(&r.f.p, r.f.d)
.nodes
.iter()
.any(|n| n.id == r.zn)
);
assert!(
assignment::capture(&r.f.p, r.f.d, &selected(&r, true), r.f.n).is_err(),
"hidden selected member is not normalized away"
);
let scope = assignment::capture(&r.f.p, r.f.d, &selected(&r, false), r.f.n).unwrap();
let plan =
assignment::plan_semantics(&scope, Destination::Performer(r.c), &BTreeMap::new())
.unwrap();
let placed = assignment::place(&plan, xy(1540., 120.), wide(&r.f.p, r.f.d, Some(r.c)));
if obstacle {
assert!(matches!(placed, Err(Refusal::LayoutRequired(_))));
continue;
}
let placed = placed.unwrap();
assert_eq!(placed.unfolded, Some(r.cn));
let mut expected = r.f.p.clone();
retarget(&mut expected, r.ax, r.c);
retarget(&mut expected, r.by, r.c);
let d = expected.diagrams.get_mut(&r.f.d).unwrap();
d.nodes.get_mut(&r.cn).unwrap().collapsed = false;
d.nodes.get_mut(&r.f.n).unwrap().position = xy(1540., 120.);
d.nodes.get_mut(&r.yn).unwrap().position = xy(1540., 420.);
assert_eq!(
roundtrip(
&r.f.p,
assignment::finish(&plan, &r.f.p, &placed).unwrap().unwrap()
),
expected
);
}
}
}
#[test]
fn group_assignment_accumulates_stable_ids_and_aliases_without_partial_reuse() {
let mut f = fixture(Layer::Oa);
let y =
f.p.add_element(Kind::Activity, Layer::Oa, "Work", None)
.unwrap();
let yn = f.p.place(f.d, y, xy(400., 500.));
let selected = BTreeMap::from([(f.n, f.w), (yn, y)]);
let scope = assignment::capture(&f.p, f.d, &selected, f.n).unwrap();
let ids = BTreeMap::from([(f.w, Id::new_v4()), (y, Id::new_v4())]);
let first = assignment::plan_semantics(&scope, Destination::Performer(f.a), &ids).unwrap();
let other = assignment::plan_semantics(&scope, Destination::Performer(f.b), &ids).unwrap();
let repeated = assignment::plan_semantics(&scope, Destination::Performer(f.a), &ids).unwrap();
assert_eq!(first.project(), repeated.project());
assert_eq!(first.changed_count(), 2);
let mut expected = f.p.clone();
let aliases: BTreeSet<_> = ids
.values()
.map(|id| first.project().relations[id].alias.clone())
.collect();
assert_eq!(aliases.len(), 2);
assert!(aliases.contains("a_performs_work"));
for (ordinal, (work, id)) in ids.iter().enumerate() {
let relation = &first.project().relations[id];
let expected_relation = Relation {
id: *id,
alias: if ordinal == 0 {
"a_performs_work".into()
} else {
format!("a_performs_work_{}", &id.simple().to_string()[..8])
},
name: "A performs Work".into(),
description: String::new(),
body: RelationBody::Allocation {
source: f.a,
target: *work,
},
};
assert_eq!(relation, &expected_relation);
assert_eq!(
other.project().relations[id].body,
RelationBody::Allocation {
source: f.b,
target: *work
}
);
expected.relations.insert(*id, expected_relation);
}
assert_eq!(first.project(), &expected);
let duplicate = Id::new_v4();
for bad in [
BTreeMap::from([(f.w, duplicate), (y, duplicate)]),
BTreeMap::from([(f.w, Id::nil()), (y, ids[&y])]),
BTreeMap::from([(f.w, f.a), (y, ids[&y])]),
] {
assert!(assignment::plan_semantics(&scope, Destination::Performer(f.a), &bad).is_err());
}
}
#[test]
fn group_assignment_source_failure_preserves_existing_history_and_retry_ids() {
for layer in [Layer::Oa, Layer::Sa] {
let mut f = fixture(layer);
let y =
f.p.add_element(f.p.elements[&f.w].kind(), layer, "Y", None)
.unwrap();
let yn = f.p.place(f.d, y, xy(400., 800.));
let mut bundle = format_bundle(&f.p);
bundle
.files
.get_mut(&bundle.entry.clone())
.unwrap()
.insert_str(
0,
"// Keep reviewed source through a refused group assignment.\n",
);
let mut limits = ProjectLimits::default();
limits.model.max_source_bytes = bundle.files.values().map(String::len).sum::<usize>() + 128;
let mut document = Document::from_bundle(bundle, limits).unwrap();
document
.execute(Command::Describe(f.w, "Reviewed".into()))
.unwrap();
document
.execute(Command::Rename(y, "Future Y".into()))
.unwrap();
let redo_project = document.project().clone();
let redo_source = document.bundle().clone();
assert!(document.undo());
assert!(document.can_undo() && document.can_redo());
let accepted = document.project().clone();
let source = document.bundle().clone();
let revision = document.revision();
let ids = BTreeMap::from([(f.w, Id::new_v4()), (y, Id::new_v4())]);
let scope =
assignment::capture(&accepted, f.d, &BTreeMap::from([(f.n, f.w), (yn, y)]), f.n)
.unwrap();
let plan = assignment::plan_semantics(&scope, Destination::Performer(f.a), &ids).unwrap();
let placed =
assignment::place(&plan, xy(150., 200.), wide(&accepted, f.d, Some(f.a))).unwrap();
let command = assignment::finish(&plan, &accepted, &placed)
.unwrap()
.unwrap();
assert!(matches!(
document.execute(command.clone()),
Err(crate::document::DocumentError::Source(_))
));
assert_eq!(document.project(), &accepted);
assert_eq!(document.bundle(), &source);
assert_eq!(document.revision(), revision);
assert!(document.can_undo() && document.can_redo());
assert!(document.redo());
assert_eq!(document.project(), &redo_project);
assert_eq!(document.bundle(), &redo_source);
assert!(document.undo());
assert_eq!(document.project(), &accepted);
assert_eq!(document.bundle(), &source);
let mut expected = accepted.clone();
for (work, name, alias) in [
(f.w, "A performs Work", "a_performs_work"),
(y, "A performs Y", "a_performs_y"),
] {
expected.relations.insert(
ids[&work],
Relation {
id: ids[&work],
alias: alias.into(),
name: name.into(),
description: String::new(),
body: RelationBody::Allocation {
source: f.a,
target: work,
},
},
);
}
let d = expected.diagrams.get_mut(&f.d).unwrap();
d.nodes.get_mut(&f.n).unwrap().position = xy(150., 200.);
d.nodes.get_mut(&yn).unwrap().position = xy(450., 500.);
assert_eq!(roundtrip(&accepted, command), expected);
}
}
#[test]
fn group_assignment_collateral_surviving_constraint_refuses_whole_semantic_candidate() {
for layer in [Layer::Oa, Layer::Sa] {
let mut r = roots(layer);
let k =
r.f.p
.add_element(Kind::Constraint, layer, "Keep reviewed assignment", None)
.unwrap();
*r.f.p.elements.get_mut(&k).unwrap().shared_mut().unwrap() = SharedBody::Constraint {
expression: "reviewed assignment".into(),
constrained: vec![r.ax, r.f.w],
};
let core = prepare_command(&r.f.p, &Command::Delete(r.ax))
.unwrap()
.project;
validate_candidate(&core).unwrap();
assert!(core.elements.contains_key(&k));
assert_ne!(core.elements[&k], r.f.p.elements[&k]);
let scope = assignment::capture(&r.f.p, r.f.d, &selected(&r, false), r.f.n).unwrap();
let Err(Refusal::CollateralChangeRequired(changed)) =
assignment::plan_semantics(&scope, Destination::OutsidePerformers, &BTreeMap::new())
else {
panic!("surviving constraint edit must refuse the entire plan");
};
assert!(changed.iter().any(|record| record.id == k));
}
}
#[test]
fn group_assignment_uses_vacated_space_and_one_complete_route_owner_map() {
for layer in [Layer::Oa, Layer::Sa] {
let mut f = fixture(layer);
let kind = f.p.elements[&f.w].kind();
let y = f.p.add_element(kind, layer, "Y", None).unwrap();
let outside = f.p.add_element(kind, layer, "Outside", None).unwrap();
let yn = f.p.place(f.d, y, xy(400., 500.));
f.p.place(f.d, outside, xy(1600., 500.));
let d = f.p.diagrams.get_mut(&f.d).unwrap();
let a = d.nodes.values_mut().find(|n| n.element == f.a).unwrap();
a.position = xy(350., 350.);
a.size = Size {
width: 1000.,
height: 600.,
};
let edge_kind = if layer == Layer::Oa {
RelationKind::Interaction
} else {
RelationKind::FunctionalExchange
};
let internal = f.p.relate(edge_kind, "Internal", f.w, y);
let external = f.p.relate(edge_kind, "External", y, outside);
let mut internal_route = route(internal);
internal_route.waypoints = vec![xy(370., 550.), xy(370., 650.)];
internal_route.source_attachment = Some(EdgeAttachment {
side: PortAnchorSide::Bottom,
offset: 0.3,
});
internal_route.target_attachment = Some(EdgeAttachment {
side: PortAnchorSide::Top,
offset: 0.7,
});
let mut external_route = internal_route.clone();
external_route.edge = external;
external_route.waypoints = vec![xy(1200., 550.), xy(1300., 650.)];
for diagram in [f.d, f.second] {
f.p.diagrams.get_mut(&diagram).unwrap().routes =
vec![internal_route.clone(), external_route.clone()];
}
if let RelationBody::FunctionalExchange { source_port, .. } = f.p.relations[&internal].body
{
f.p.diagrams
.get_mut(&f.d)
.unwrap()
.nodes
.get_mut(&f.n)
.unwrap()
.port_anchors
.push(PortAnchor {
port: source_port,
side: PortAnchorSide::Bottom,
offset: 0.4,
});
}
validate_candidate(&f.p).unwrap();
let ids = BTreeMap::from([(f.w, Id::new_v4()), (y, Id::new_v4())]);
// The singleton candidate really occupies stationary Y's card. The
// combined candidate must not inherit that sequential-planning veto.
let mut singleton = f.p.clone();
singleton
.diagrams
.get_mut(&f.d)
.unwrap()
.nodes
.get_mut(&f.n)
.unwrap()
.position = xy(400., 500.);
let projected = projection::project_view(&singleton, f.d);
let x_card = projected.nodes.iter().find(|n| n.id == f.n).unwrap();
let y_card = projected.nodes.iter().find(|n| n.id == yn).unwrap();
assert_eq!(
overlap(x_card, y_card),
if layer == Layer::Oa { 21056. } else { 29568. }
);
let single = assignment::capture(&f.p, f.d, &BTreeMap::from([(f.n, f.w)]), f.n).unwrap();
let single =
assignment::plan_semantics(&single, Destination::Performer(f.a), &ids).unwrap();
assert!(matches!(
assignment::place(&single, xy(400., 500.), wide(&f.p, f.d, Some(f.a))),
Err(Refusal::LayoutRequired(_))
));
let scope =
assignment::capture(&f.p, f.d, &BTreeMap::from([(f.n, f.w), (yn, y)]), f.n).unwrap();
let plan = assignment::plan_semantics(&scope, Destination::Performer(f.a), &ids).unwrap();
let placed = assignment::place(&plan, xy(400., 500.), wide(&f.p, f.d, Some(f.a))).unwrap();
let mut expected = f.p.clone();
for (work, name, alias) in [
(f.w, "A performs Work", "a_performs_work"),
(y, "A performs Y", "a_performs_y"),
] {
expected.relations.insert(
ids[&work],
Relation {
id: ids[&work],
alias: alias.into(),
name: name.into(),
description: String::new(),
body: RelationBody::Allocation {
source: f.a,
target: work,
},
},
);
}
let d = expected.diagrams.get_mut(&f.d).unwrap();
d.nodes.get_mut(&f.n).unwrap().position = xy(400., 500.);
d.nodes.get_mut(&yn).unwrap().position = xy(700., 500.);
d.routes[0].waypoints = vec![xy(670., 550.), xy(670., 650.)];
assert_eq!(placed.diagram.routes[1], external_route);
assert_eq!(
roundtrip(
&f.p,
assignment::finish(&plan, &f.p, &placed).unwrap().unwrap()
),
expected
);
}
}
#[test]
fn group_assignment_deletes_only_own_routes_across_views_and_keeps_internal_flow() {
for layer in [Layer::Oa, Layer::Sa] {
let mut r = roots(layer);
let kind = if layer == Layer::Oa {
RelationKind::Interaction
} else {
RelationKind::FunctionalExchange
};
let internal = r.f.p.relate(kind, "Keep internal flow", r.f.w, r.y);
for diagram in [r.f.d, r.f.second] {
r.f.p.diagrams.get_mut(&diagram).unwrap().routes =
vec![route(r.ax), route(internal), route(r.by)];
}
validate_candidate(&r.f.p).unwrap();
let scope = assignment::capture(&r.f.p, r.f.d, &selected(&r, false), r.f.n).unwrap();
let plan =
assignment::plan_semantics(&scope, Destination::OutsidePerformers, &BTreeMap::new())
.unwrap();
assert_eq!(plan.changed_count(), 2);
let placed = assignment::place(&plan, xy(640., 200.), wide(&r.f.p, r.f.d, None)).unwrap();
let mut expected = r.f.p.clone();
expected.relations.remove(&r.ax);
expected.relations.remove(&r.by);
for diagram in [r.f.d, r.f.second] {
expected.diagrams.get_mut(&diagram).unwrap().routes = vec![route(internal)];
}
let d = expected.diagrams.get_mut(&r.f.d).unwrap();
d.nodes.get_mut(&r.f.n).unwrap().position = xy(640., 200.);
d.nodes.get_mut(&r.yn).unwrap().position = xy(640., 800.);
d.routes[0].waypoints = vec![xy(920., 400.), xy(920., 550.)];
assert_eq!(
roundtrip(
&r.f.p,
assignment::finish(&plan, &r.f.p, &placed).unwrap().unwrap()
),
expected
);
}
}
@@ -0,0 +1,195 @@
//! Geometry operations over an explicitly captured placement set.
use super::*;
pub(super) struct Translation {
pub diagram: Diagram,
pub commands: Vec<Command>,
pub delta: Position,
}
/// Translate exactly `placements` using one snapped anchor delta. `base` must
/// come from an exact, validated semantic candidate; absent routes are permitted
/// only because that candidate has already admitted their semantic deletion.
pub(super) fn translate(
accepted: &Project,
original: &Diagram,
base: &Diagram,
placements: &BTreeMap<Id, NodePlacement>,
anchor: Id,
requested: Position,
) -> Result<Translation, Refusal> {
let anchor = placements.get(&anchor).ok_or(Refusal::StaleScope)?;
let delta = Position {
x: (requested.x / 10.).round() * 10. - anchor.position.x,
y: (requested.y / 10.).round() * 10. - anchor.position.y,
};
let mut diagram = base.clone();
let mut commands = Vec::new();
let mut deltas = BTreeMap::new();
for (id, captured) in placements {
let node = diagram.nodes.get_mut(id).ok_or(Refusal::StaleScope)?;
node.position = Position {
x: captured.position.x + delta.x,
y: captured.position.y + delta.y,
};
if !(Rect {
position: node.position,
size: node.size,
})
.valid()
{
return Err(Refusal::InvalidCandidate(
"Moved coordinates exceed the model bounds.".into(),
));
}
deltas.insert(*id, delta);
if node != captured {
commands.push(Command::EditNode {
diagram: diagram.id,
original: *id,
node: node.clone(),
});
}
}
// Resolve actual endpoint owners once against accepted semantics and the
// complete delta map. Never reinsert a semantically deleted saved route.
for route in projection::translated_routes(accepted, original, &deltas) {
if let Some(stored) = diagram.routes.iter_mut().find(|r| r.edge == route.edge) {
if route.waypoints.iter().any(|point| {
!point.x.is_finite()
|| !point.y.is_finite()
|| point.x.abs() > MAX_COORDINATE
|| point.y.abs() > MAX_COORDINATE
}) {
return Err(Refusal::InvalidCandidate(
"Moved route exceeds the model bounds.".into(),
));
}
*stored = route.clone();
commands.push(Command::SetEdgeRoute {
diagram: diagram.id,
route,
});
}
}
Ok(Translation {
diagram,
commands,
delta,
})
}
/// Recover hidden ancestry for identity lookup only. The all-unfolded scene is
/// never used for collision admission; only affected nodes and their ancestors
/// enter the required diagnostic expansion.
pub(super) fn expansion_ids(
p: &Project,
d: &Diagram,
index: &ViewIndex,
placements: &BTreeMap<Id, NodePlacement>,
) -> Result<BTreeSet<Id>, Refusal> {
let mut all_unfolded = d.clone();
for node in all_unfolded.nodes.values_mut() {
node.collapsed = false;
}
let all_view = projection::project_diagram_with_index(p, &all_unfolded, index);
let ancestry = ancestors(&all_view);
let mut ids: BTreeSet<_> = placements.keys().copied().collect();
for id in placements.keys() {
ids.extend(ancestry.get(id).ok_or(Refusal::StaleScope)?.iter().copied());
}
Ok(ids)
}
pub(super) fn expanded_view(
p: &Project,
d: &Diagram,
index: &ViewIndex,
ids: &BTreeSet<Id>,
) -> ProjectedView {
let mut expanded = d.clone();
for id in ids {
if let Some(node) = expanded.nodes.get_mut(id) {
node.collapsed = false;
}
}
projection::project_diagram_with_index(p, &expanded, index)
}
pub(super) fn check_scenes(
before_visible: &ProjectedView,
before_expanded: &ProjectedView,
visible: &ProjectedView,
expanded: &ProjectedView,
) -> Result<(), Refusal> {
if visible.nodes.iter().chain(&expanded.nodes).any(|node| {
!(Rect {
position: node.position,
size: node.size,
})
.valid()
}) {
return Err(Refusal::InvalidCandidate(
"The projected enclosure exceeds the model bounds.".into(),
));
}
if overlaps(before_visible, visible) || overlaps(before_expanded, expanded) {
return Err(Refusal::LayoutRequired(
"Make room before arranging: the candidate would introduce or enlarge overlapping cards."
.into(),
));
}
Ok(())
}
pub(super) fn check_rigid(
before: &ProjectedView,
after: &ProjectedView,
placements: &BTreeMap<Id, NodePlacement>,
delta: Position,
) -> Result<(), Refusal> {
let before: BTreeMap<_, _> = before.nodes.iter().map(|n| (n.id, n)).collect();
let after: BTreeMap<_, _> = after.nodes.iter().map(|n| (n.id, n)).collect();
for id in placements.keys() {
let old = before.get(id).ok_or(Refusal::StaleScope)?;
let new = after.get(id).ok_or(Refusal::StaleScope)?;
if (new.position.x - old.position.x - delta.x).abs() > 0.01
|| (new.position.y - old.position.y - delta.y).abs() > 0.01
|| (new.size.width - old.size.width).abs() > 0.01
|| (new.size.height - old.size.height).abs() > 0.01
{
return Err(Refusal::LayoutRequired(
"This selection requires an explicit layout; its footprint cannot be translated unchanged."
.into(),
));
}
}
Ok(())
}
pub(super) fn check_headers(
view: &ProjectedView,
selected: &BTreeMap<Id, Id>,
viewport: Rect,
) -> Result<(), Refusal> {
for (id, element) in selected {
let node = view
.nodes
.iter()
.find(|node| node.id == *id && node.element == *element)
.ok_or_else(|| Refusal::LayoutRequired("A selected header would be hidden.".into()))?;
let header = Rect {
position: node.position,
size: Size {
width: node.size.width,
height: node.size.height.min(62.),
},
};
if !viewport.contains(header) {
return Err(Refusal::LayoutRequired(
"Make room or adjust the view, then drag again.".into(),
));
}
}
Ok(())
}
@@ -0,0 +1,450 @@
//! Reviewed policy experiment using the production projection and translator.
//! No production admission or atomic-refusal behavior is tested here.
use super::*;
struct Mixed {
f: Fixture,
an: Id,
xn: Id,
yn: Id,
internal: Id,
include_performer: bool,
}
fn mixed(layer: Layer, collapsed: bool, obstacle: bool, include_performer: bool) -> Mixed {
let mut f = fixture(layer);
let work = f.p.elements[&f.w].kind();
let exchange = match layer {
Layer::Oa => RelationKind::Interaction,
Layer::Sa => RelationKind::FunctionalExchange,
};
let x = f.p.add_element(work, layer, "X", Some(f.w)).unwrap();
let y = f.p.add_element(work, layer, "Y", Some(f.w)).unwrap();
let outside = f.p.add_element(work, layer, "Outside", None).unwrap();
let xn = f.p.place(f.d, x, xy(140., 200.));
let yn = f.p.place(f.d, y, xy(940., 200.));
f.p.place(f.d, outside, xy(1800., 800.));
f.p.relate(RelationKind::Allocation, "A owns X", f.a, x);
f.p.relate(RelationKind::Allocation, "B owns Y", f.b, y);
// FunctionalExchange's actual builder creates valid function input/output
// ports; do not manufacture Interaction records between SA Functions.
let internal = f.p.relate(exchange, "X to Y", x, y);
let external = f.p.relate(exchange, "Y to Outside", y, outside);
let an = f.p.diagrams[&f.d]
.nodes
.values()
.find(|n| n.element == f.a)
.unwrap()
.id;
let d = f.p.diagrams.get_mut(&f.d).unwrap();
d.nodes.get_mut(&f.n).unwrap().position = xy(100., 800.);
d.nodes.get_mut(&an).unwrap().collapsed = collapsed;
d.routes = vec![route(internal), route(external)];
if obstacle {
let id =
f.p.add_element(work, layer, "Stationary obstacle", None)
.unwrap();
f.p.place(f.d, id, xy(550., 230.));
}
validate_candidate(&f.p).unwrap();
let captured = scope(&f);
assert_eq!(captured.uniform, Uniform::MixedOrUnresolved);
assert_eq!(
captured.placements.keys().copied().collect::<BTreeSet<_>>(),
BTreeSet::from([f.n, xn, yn])
);
// Explicit control: the real current API still refuses Arrange for this root.
assert!(matches!(
plan_semantics(&f.p, &captured, Destination::LayoutOnly, &BTreeMap::new()),
Err(Refusal::MixedLayoutRequired)
));
assert!(matches!(
plan_semantics(
&f.p,
&captured,
Destination::Performer(f.a),
&BTreeMap::new()
),
Err(Refusal::MixedLayoutRequired)
));
Mixed {
f,
an,
xn,
yn,
internal,
include_performer,
}
}
fn affected(m: &Mixed) -> BTreeSet<Id> {
let mut ids: BTreeSet<_> = scope(&m.f).placements.keys().copied().collect();
let mut expected = BTreeSet::from([m.f.n, m.xn, m.yn]);
if m.include_performer {
ids.insert(m.an);
ids.extend(projection::descendant_nodes(&m.f.p, m.f.d, m.an));
expected.insert(m.an);
}
assert_eq!(ids, expected);
ids
}
// Diagnostic expansion changes only a temporary Diagram. Include affected
// descendants and ancestors even when the normal projection hides them.
// Unrelated folded interiors are intentionally OUTSIDE this diagnostic domain.
fn diagnostic(p: &Project, diagram: Id, ids: &BTreeSet<Id>) -> ProjectedView {
let mut d = p.diagrams[&diagram].clone();
for (id, node) in &mut d.nodes {
if ids.contains(id)
|| projection::descendant_nodes(p, diagram, *id)
.iter()
.any(|n| ids.contains(n))
{
node.collapsed = false;
}
}
projection::project_diagram(p, &d)
}
fn rigid(before: &ProjectedView, after: &ProjectedView, ids: &BTreeSet<Id>) {
for id in ids {
let old = before.nodes.iter().find(|n| n.id == *id).unwrap();
let new = after.nodes.iter().find(|n| n.id == *id).unwrap();
assert_eq!(new.position, xy(old.position.x + 400., old.position.y));
assert_eq!(new.size, old.size, "no reflow of an affected footprint");
}
}
// The only experimental bypass: use the pure general geometry command instead
// of plan_semantics(LayoutOnly), whose mixed-root pre-veto is asserted above.
// This is not a fabricated Scope or a call to singleton check_geometry with a
// falsely enlarged Scope. The combined scene uses the real overlap predicate.
fn candidate(m: &Mixed) -> (Command, Project) {
let ids = affected(m);
let command =
oa_core::diagram_edit::translate_placements(&m.f.p, m.f.d, &ids, xy(400., 0.)).unwrap();
let actual = prepare_command(&m.f.p, &command).unwrap().project;
validate_candidate(&actual).unwrap();
// Independent literal expected Project: no planner or route helper oracle.
let mut expected = m.f.p.clone();
let d = expected.diagrams.get_mut(&m.f.d).unwrap();
for (id, position) in [
(m.f.n, xy(500., 800.)),
(m.xn, xy(540., 200.)),
(m.yn, xy(1340., 200.)),
] {
d.nodes.get_mut(&id).unwrap().position = position;
}
if m.include_performer {
d.nodes.get_mut(&m.an).unwrap().position = xy(500., 100.);
}
let internal = d.routes.iter_mut().find(|r| r.edge == m.internal).unwrap();
internal.waypoints = vec![xy(820., 400.), xy(820., 550.)];
assert_eq!(
actual, expected,
"complete records, including folds, routes, other view and relations"
);
(command, actual)
}
#[test]
fn mixed_arrange_experiment_distributed_root_and_performer_can_translate_rigidly() {
for layer in [Layer::Oa, Layer::Sa] {
let enclosure_height = match layer {
Layer::Oa => 218.,
Layer::Sa => 256.,
};
for include_performer in [false, true] {
for collapsed in [false, true] {
let m = mixed(layer, collapsed, false, include_performer);
let accepted = m.f.p.clone();
let (command, actual) = candidate(&m);
let ids = affected(&m);
let before = projection::project_view(&accepted, m.f.d);
let after = projection::project_view(&actual, m.f.d);
let before_expanded = diagnostic(&accepted, m.f.d, &ids);
let after_expanded = diagnostic(&actual, m.f.d, &ids);
assert!(
!overlaps(&before, &after),
"visible scene must admit the combined move"
);
assert!(
!overlaps(&before_expanded, &after_expanded),
"expanded scene must admit the combined move"
);
rigid(&before_expanded, &after_expanded, &ids);
for node in after.nodes.iter().chain(&after_expanded.nodes) {
assert!(
(Rect {
position: node.position,
size: node.size
})
.valid()
);
}
// Explicit selected headers remain visible. Native clipping/readability
// and complete group gesture admission are intentionally not claimed.
assert!(after.nodes.iter().any(|n| n.id == m.f.n));
if include_performer {
assert!(after.nodes.iter().any(|n| n.id == m.an));
} else {
assert_eq!(
actual.diagrams[&m.f.d].nodes[&m.an],
accepted.diagrams[&m.f.d].nodes[&m.an]
);
assert_eq!(node(&before_expanded, m.an).position, xy(100., 100.));
assert_eq!(
node(&before_expanded, m.an).size,
Size {
width: 288.,
height: enclosure_height
}
);
assert_eq!(node(&after_expanded, m.an).position, xy(100., 100.));
assert_eq!(
node(&after_expanded, m.an).size,
Size {
width: 688.,
height: enclosure_height
}
);
}
assert_eq!(roundtrip(&accepted, command), actual);
assert_eq!(m.f.p, accepted);
}
}
}
}
#[test]
fn mixed_arrange_experiment_detects_moved_folded_child_collision() {
for layer in [Layer::Oa, Layer::Sa] {
let expected_overlap = match layer {
Layer::Oa => 13696.,
Layer::Sa => 20116.,
};
let m = mixed(layer, true, true, true);
let accepted = m.f.p.clone();
let (_, actual) = candidate(&m);
let ids = affected(&m);
let before = projection::project_view(&accepted, m.f.d);
let after = projection::project_view(&actual, m.f.d);
assert!(!before.nodes.iter().any(|n| n.id == m.xn));
assert!(!after.nodes.iter().any(|n| n.id == m.xn));
assert!(
!overlaps(&before, &after),
"must distinguish hidden collision from ordinary visible refusal"
);
let before_expanded = diagnostic(&accepted, m.f.d, &ids);
let after_expanded = diagnostic(&actual, m.f.d, &ids);
rigid(&before_expanded, &after_expanded, &ids);
let obstacle = before_expanded
.nodes
.iter()
.find(|n| n.position == xy(550., 230.))
.unwrap()
.id;
assert_eq!(
overlap(
node(&before_expanded, m.xn),
node(&before_expanded, obstacle)
),
0.
);
assert_eq!(
overlap(node(&after_expanded, m.xn), node(&after_expanded, obstacle)),
expected_overlap
);
assert!(
overlaps(&before_expanded, &after_expanded),
"combined diagnostic scene must refuse"
);
// Detection only: there is no production group admission/commit API here.
assert_eq!(m.f.p, accepted);
}
}
fn node(view: &ProjectedView, id: Id) -> &ProjectedNode {
view.nodes.iter().find(|n| n.id == id).unwrap()
}
#[test]
fn mixed_arrange_experiment_detects_stationary_enclosure_growth_collision() {
for layer in [Layer::Oa, Layer::Sa] {
let enclosure_height = match layer {
Layer::Oa => 218.,
Layer::Sa => 256.,
};
let mut m = mixed(layer, false, false, true);
let kind = m.f.p.elements[&m.f.w].kind();
let z =
m.f.p
.add_element(kind, layer, "Stationary Z", None)
.unwrap();
let zn = m.f.p.place(m.f.d, z, xy(1200., 100.));
validate_candidate(&m.f.p).unwrap();
let accepted = m.f.p.clone();
let (_, actual) = candidate(&m);
let bn = accepted.diagrams[&m.f.d]
.nodes
.values()
.find(|n| n.element == m.f.b)
.unwrap()
.id;
let before = projection::project_view(&accepted, m.f.d);
let after = projection::project_view(&actual, m.f.d);
assert_eq!(node(&before, bn).position, xy(900., 100.));
assert_eq!(
node(&before, bn).size,
Size {
width: 288.,
height: enclosure_height
}
);
assert_eq!(node(&after, bn).position, xy(900., 100.));
assert_eq!(
node(&after, bn).size,
Size {
width: 688.,
height: enclosure_height
}
);
assert_eq!(node(&after, zn).position, xy(1200., 100.));
assert_eq!(
node(&after, zn).size,
Size {
width: 224.,
height: 94.
}
);
assert_eq!(overlap(node(&before, bn), node(&before, zn)), 0.);
assert_eq!(overlap(node(&after, bn), node(&after, zn)), 21056.);
assert_eq!(node(&after, m.yn).position, xy(1340., 200.));
assert_eq!(
node(&after, m.yn).position.y
- (node(&after, zn).position.y + node(&after, zn).size.height),
6.
);
for id in affected(&m) {
assert_eq!(overlap(node(&before, id), node(&before, zn)), 0.);
assert_eq!(overlap(node(&after, id), node(&after, zn)), 0.);
}
assert_eq!(
actual.diagrams[&m.f.d].nodes[&bn],
accepted.diagrams[&m.f.d].nodes[&bn]
);
assert_eq!(
actual.diagrams[&m.f.d].nodes[&zn],
accepted.diagrams[&m.f.d].nodes[&zn]
);
assert!(
overlaps(&before, &after),
"complete scene must detect stationary enclosure growth"
);
let ids = affected(&m);
assert!(overlaps(
&diagnostic(&accepted, m.f.d, &ids),
&diagnostic(&actual, m.f.d, &ids)
));
assert_eq!(m.f.p, accepted);
}
}
#[test]
fn mixed_arrange_experiment_unrelated_folded_interiors_are_outside_diagnostic_domain() {
for layer in [Layer::Oa, Layer::Sa] {
// Independent source-derived literals: SA's auto-created function ports
// raise projected X/Y height to 76 + 28 * (1 + 1), while Z is port-free.
let (work_height, expected_overlap) = match layer {
Layer::Oa => (94., 13696.),
Layer::Sa => (132., 20116.),
};
let mut m = mixed(layer, true, false, true);
let performer = match layer {
Layer::Oa => Kind::Actor,
Layer::Sa => Kind::SystemActor,
};
let kind = m.f.p.elements[&m.f.w].kind();
let d =
m.f.p
.add_element(performer, layer, "Unrelated D", None)
.unwrap();
let z =
m.f.p
.add_element(kind, layer, "Unrelated hidden Z", None)
.unwrap();
m.f.p.relate(RelationKind::Allocation, "D owns Z", d, z);
let dn = m.f.p.place(m.f.d, d, xy(2500., 100.));
let zn = m.f.p.place(m.f.d, z, xy(550., 230.));
m.f.p
.diagrams
.get_mut(&m.f.d)
.unwrap()
.nodes
.get_mut(&dn)
.unwrap()
.collapsed = true;
validate_candidate(&m.f.p).unwrap();
let accepted = m.f.p.clone();
let (_, actual) = candidate(&m);
let ids = affected(&m);
let before = diagnostic(&accepted, m.f.d, &ids);
let after = diagnostic(&actual, m.f.d, &ids);
assert!(!before.nodes.iter().any(|n| n.id == zn));
assert!(!after.nodes.iter().any(|n| n.id == zn));
assert!(
!overlaps(&before, &after),
"chosen diagnostic domain excludes unrelated folded contents"
);
assert!(!overlaps(
&projection::project_view(&accepted, m.f.d),
&projection::project_view(&actual, m.f.d)
));
let all_unfolded = |p: &Project| {
let mut diagram = p.diagrams[&m.f.d].clone();
for n in diagram.nodes.values_mut() {
n.collapsed = false;
}
projection::project_diagram(p, &diagram)
};
let broad_before = all_unfolded(&accepted);
let broad_after = all_unfolded(&actual);
assert_eq!(node(&broad_before, m.xn).position, xy(140., 200.));
assert_eq!(node(&broad_after, m.xn).position, xy(540., 200.));
assert_eq!(
node(&broad_after, m.xn).size,
Size {
width: 224.,
height: work_height
}
);
assert_eq!(node(&broad_before, zn).position, xy(550., 230.));
assert_eq!(node(&broad_after, zn).position, xy(550., 230.));
assert_eq!(
node(&broad_after, zn).size,
Size {
width: 224.,
height: 94.
}
);
assert_eq!(
overlap(node(&broad_before, m.xn), node(&broad_before, zn)),
0.
);
assert_eq!(
overlap(node(&broad_after, m.xn), node(&broad_after, zn)),
expected_overlap
);
// Other newly exposed enclosure pairs may also overlap. X/Z is a
// concrete discriminator, not a claim that it is the only cause.
assert!(overlaps(&broad_before, &broad_after));
for id in [dn, zn] {
assert_eq!(
actual.diagrams[&m.f.d].nodes[&id],
accepted.diagrams[&m.f.d].nodes[&id]
);
}
assert!(actual.diagrams[&m.f.d].nodes[&dn].collapsed);
assert_eq!(m.f.p, accepted);
}
}
@@ -2,6 +2,18 @@ use super::*;
use crate::document::Document;
use oa_core::source_project::{ProjectLimits, compile_bundle, format_bundle};
#[path = "group_arrange_experiment.rs"]
mod group_arrange_experiment;
#[path = "arrange_tests.rs"]
mod arrange_tests;
#[path = "assignment_tests.rs"]
mod assignment_tests;
#[path = "assignment_boundary_tests.rs"]
mod assignment_boundary_tests;
struct Fixture {
p: Project,
d: Id,
@@ -748,6 +760,8 @@ fn allocation_artifact_and_manual_trace_cascades_are_named_and_refused() {
fn route(edge: Id) -> EdgeRoute {
EdgeRoute {
edge,
source_attachment: None,
target_attachment: None,
waypoints: vec![xy(420., 400.), xy(420., 550.)],
label: Some(EdgeLabel {
fraction: 0.4,
@@ -60,6 +60,7 @@ impl Root<'_> {
return false;
}
let canvas = DiagramCanvas {
selection: self.app.diagram_selection.as_ref(),
allocation: Some(self.app.allocation_capability()),
project: self.app.project(),
view_index: Some(self.app.document.view_index()),
@@ -70,6 +71,7 @@ impl Root<'_> {
pan: self.app.pan,
context: self.app.route_context().unwrap(),
selected_bend: None,
selected_attachment: None,
editable: false,
placing: false,
connection: None,
+1562 -124
View File
File diff suppressed because it is too large Load Diff
+558 -128
View File
@@ -1,12 +1,21 @@
//! Allocation work-body input admission. Gesture authority lives here, while
//! the canvas retains ordered native pointer positions and frozen hit geometry.
use super::{App, Message, Page};
use crate::{allocation_drag as plan, route_edit::Context};
use crate::{
allocation_drag as plan,
diagram_selection::Snapshot,
diagram_usage::{Surface, Target as SelectionTarget},
route_edit::Context,
};
use iced::{Point, Rectangle, Task, Vector};
use oa_core::{projection::ProjectedView, routing::RoutedView, *};
use std::{collections::BTreeMap, sync::Arc};
use std::{
collections::{BTreeMap, BTreeSet},
sync::Arc,
};
pub const FINISH_EDIT: &str = "Finish or cancel the current edit before selecting or moving work.";
pub const FINISH_EDIT: &str =
"Finish or cancel the current edit before selecting or moving elements.";
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Stamp {
@@ -65,7 +74,8 @@ pub fn contains(r: Rectangle, p: Point) -> bool {
impl Frozen {
pub fn capture(
project: &Project,
scope: &plan::Scope,
diagram: &Diagram,
affected: &BTreeMap<Id, NodePlacement>,
view: &ProjectedView,
routed: &RoutedView,
zoom: f32,
@@ -73,7 +83,7 @@ impl Frozen {
let mut targets = Vec::new();
let mut cards = Vec::new();
for n in &view.nodes {
if scope.placements.contains_key(&n.id) {
if affected.contains_key(&n.id) {
continue;
}
let region = Rectangle::new(
@@ -84,7 +94,7 @@ impl Frozen {
cards.push(region);
continue;
};
if e.kind().is_performer() && e.layer() == scope.diagram.layer {
if e.kind().is_performer() && e.layer() == diagram.layer {
let mut ancestors = Vec::new();
let mut parent = e.parent;
while let Some(id) = parent {
@@ -95,7 +105,7 @@ impl Frozen {
parent = project.elements.get(&id).and_then(|e| e.parent);
}
let name = project.named(e.id).to_owned();
let collapsed = scope.diagram.nodes[&n.id].collapsed;
let collapsed = diagram.nodes[&n.id].collapsed;
targets.push(Target {
element: e.id,
placement: n.id,
@@ -118,7 +128,7 @@ impl Frozen {
let mut controls: Vec<_> = view
.ports
.iter()
.filter(|port| !scope.placements.contains_key(&port.owner))
.filter(|port| !affected.contains_key(&port.owner))
.map(|port| {
Rectangle::new(
Point::new(port.position.x - radius, port.position.y - radius),
@@ -224,17 +234,23 @@ impl Frozen {
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Mode {
Assign,
Arrange,
}
#[derive(Debug, Clone)]
pub struct Press {
pub(crate) struct Press {
pub stamp: Stamp,
pub token: Id,
pub start: Point,
pub scope: plan::Scope,
pub frozen: Frozen,
pub arrange: bool,
pub selection: Snapshot,
pub target: SelectionTarget,
pub mode: Mode,
}
#[derive(Debug, Clone)]
pub enum Event {
pub(crate) enum Event {
Press(Arc<Press>),
Preview(Stamp, Id, Option<Point>),
Release(Stamp, Id, Option<Point>),
@@ -244,23 +260,266 @@ pub enum Event {
Unfocused,
KeyboardBoundary,
}
pub struct PreviewPlacement {
pub diagram: Diagram,
pub view: ProjectedView,
pub delta: Position,
}
pub struct Preview {
pub token: Id,
pub semantic: Arc<plan::SemanticPlan>,
pub placement: plan::PlacementPlan,
pub candidate: Candidate,
pub placement: PreviewPlacement,
pub routed: Arc<RoutedView>,
}
impl Preview {
pub fn project(&self) -> &Project {
self.candidate.project()
}
#[cfg(test)]
pub fn index(&self) -> &view_options::ViewIndex {
self.candidate.index()
}
}
enum Prepared {
Singleton(Box<plan::Scope>),
Assignment(plan::assignment::Scope),
Arrange(Arc<plan::arrange::Plan>),
Performers(Arc<PerformerPlan>),
Refused(String),
}
#[derive(Clone)]
pub enum Candidate {
Singleton(Arc<plan::SemanticPlan>),
Assignment(Arc<plan::assignment::SemanticPlan>),
Arrange(Arc<plan::arrange::Plan>),
Performers(Arc<PerformerPlan>),
}
enum Placed {
Planned(plan::PlacementPlan),
Performers {
preview: PreviewPlacement,
command: Box<Command>,
requested: Position,
},
}
impl Placed {
fn diagram(&self) -> &Diagram {
match self {
Self::Planned(p) => &p.diagram,
Self::Performers { preview, .. } => &preview.diagram,
}
}
fn view(&self) -> &ProjectedView {
match self {
Self::Planned(p) => &p.view,
Self::Performers { preview, .. } => &preview.view,
}
}
fn into_preview(self) -> PreviewPlacement {
match self {
Self::Planned(p) => PreviewPlacement {
diagram: p.diagram,
view: p.view,
delta: p.delta,
},
Self::Performers { preview, .. } => preview,
}
}
}
/// Ordinary performer movement preserves the established core translation policy.
/// It shares input provenance, not the explicit Arrange geometry restrictions.
pub struct PerformerPlan {
accepted: Project,
index: view_options::ViewIndex,
diagram: Id,
selected: BTreeSet<Id>,
anchor: Id,
affected: BTreeMap<Id, NodePlacement>,
}
impl PerformerPlan {
fn place(&self, requested: Position) -> Result<Placed, plan::Refusal> {
let original = &self.accepted.diagrams[&self.diagram];
let anchor = original.nodes[&self.anchor].position;
let delta = if requested == anchor {
Position { x: 0., y: 0. }
} else {
Position {
x: (requested.x / 10.).round() * 10. - anchor.x,
y: (requested.y / 10.).round() * 10. - anchor.y,
}
};
let command =
diagram_edit::translate_placements(&self.accepted, self.diagram, &self.selected, delta)
.map_err(|e| plan::Refusal::InvalidCandidate(e.to_string()))?;
let mut diagram = original.clone();
let Command::Batch(edits) = &command else {
unreachable!("translation is an atomic batch")
};
for edit in edits {
match edit {
Command::EditNode {
diagram: id,
original,
node,
} if *id == self.diagram => {
diagram.nodes.insert(*original, node.clone());
}
Command::SetEdgeRoute { diagram: id, route } if *id == self.diagram => {
let stored = diagram
.routes
.iter_mut()
.find(|r| r.edge == route.edge)
.ok_or(plan::Refusal::StaleScope)?;
*stored = route.clone();
}
_ => {
return Err(plan::Refusal::InvalidCandidate(
"Unexpected performer movement edit.".into(),
));
}
}
}
let view = projection::project_diagram_with_index(&self.accepted, &diagram, &self.index);
Ok(Placed::Performers {
preview: PreviewPlacement {
diagram,
view,
delta,
},
command: Box::new(command),
requested,
})
}
}
impl Candidate {
pub fn project(&self) -> &Project {
match self {
Self::Singleton(p) => p.project(),
Self::Assignment(p) => p.project(),
Self::Arrange(p) => p.project(),
Self::Performers(p) => &p.accepted,
}
}
#[cfg(test)]
pub fn index(&self) -> &view_options::ViewIndex {
match self {
Self::Singleton(p) => p.index(),
Self::Assignment(p) => p.index(),
Self::Arrange(p) => p.index(),
Self::Performers(p) => &p.index,
}
}
fn place(
&self,
requested: Position,
presentation: plan::PresentationContext,
) -> Result<Placed, plan::Refusal> {
match self {
Self::Singleton(p) => plan::place(p, requested, presentation).map(Placed::Planned),
Self::Assignment(p) => {
plan::assignment::place(p, requested, presentation).map(Placed::Planned)
}
Self::Arrange(p) => {
plan::arrange::place(p, requested, presentation).map(Placed::Planned)
}
Self::Performers(p) => p.place(requested),
}
}
fn finish(&self, live: &Project, placed: &Placed) -> Result<Option<Command>, plan::Refusal> {
match (self, placed) {
(Self::Singleton(p), Placed::Planned(result)) => plan::finish(p, live, result),
(Self::Assignment(p), Placed::Planned(result)) => {
plan::assignment::finish(p, live, result)
}
(Self::Arrange(p), Placed::Planned(result)) => plan::arrange::finish(p, live, result),
(
Self::Performers(p),
Placed::Performers {
preview,
command,
requested,
},
) => {
if live != &p.accepted {
return Err(plan::Refusal::StaleScope);
}
let checked = p.place(*requested)?;
let Placed::Performers {
preview: checked,
command: checked_command,
..
} = checked
else {
unreachable!()
};
if checked.diagram != preview.diagram
|| checked.view != preview.view
|| checked.delta != preview.delta
|| &checked_command != command
{
return Err(plan::Refusal::StaleScope);
}
let mut expected = p.accepted.clone();
expected.diagrams.insert(p.diagram, checked.diagram);
let actual = prepare_command(live, &checked_command)
.map_err(|e| plan::Refusal::InvalidCandidate(e.to_string()))?
.project;
if actual != expected {
return Err(plan::Refusal::InvalidCandidate(
"Performer movement would change unrelated records.".into(),
));
}
Ok((actual != *live).then_some(*checked_command))
}
_ => Err(plan::Refusal::StaleScope),
}
}
fn label(&self) -> String {
let (label, warnings) = match self {
Self::Singleton(p) => (p.label.clone(), p.warnings.len()),
Self::Assignment(p) => (p.label().to_owned(), p.warnings().len()),
Self::Arrange(p) => (
format!(
"Arrange {} selected elements · {} affected placements",
p.selected().len(),
p.affected().len()
),
0,
),
Self::Performers(p) => (
format!("Move {} selected performer(s)", p.selected.len()),
0,
),
};
if warnings == 0 {
label
} else {
format!("{label} · {warnings} model warning(s) retained")
}
}
}
struct Active {
press: Arc<Press>,
selection: Snapshot,
crossed: bool,
prepared: Prepared,
frozen: Option<Arc<Frozen>>,
inspection_targets: Vec<usize>,
ids: BTreeMap<Id, Id>,
cache: Vec<(
plan::Destination,
Result<Arc<plan::SemanticPlan>, plan::Refusal>,
)>,
cache: Vec<(plan::Destination, Result<Candidate, plan::Refusal>)>,
}
pub struct Feedback {
pub(crate) struct Feedback {
pub press: Arc<Press>,
pub frozen: Option<Arc<Frozen>>,
ordinary_performers: bool,
pub targets: Vec<usize>,
/// Last captured target names for inspection only; never resolver input.
pub inspection_targets: Vec<usize>,
@@ -273,7 +532,13 @@ impl Feedback {
let names = self
.inspection_targets
.iter()
.map(|index| self.press.frozen.targets[*index].name.as_str())
.filter_map(|index| {
self.frozen
.as_ref()?
.targets
.get(*index)
.map(|t| t.name.as_str())
})
.collect::<Vec<_>>()
.join(" / ");
format!(
@@ -293,7 +558,9 @@ impl Feedback {
/// Captured accepted names, independent of clipped status/refusal prose.
pub fn destination_identity(&self) -> String {
match self.targets.as_slice() {
[] => if self.press.arrange {
[] => if self.ordinary_performers {
"Move performers"
} else if self.press.mode == Mode::Arrange {
"Arrange only"
} else if self.refused {
"No eligible destination"
@@ -301,12 +568,22 @@ impl Feedback {
"Outside performers"
}
.into(),
[index] => self.press.frozen.targets[*index].name.clone(),
[index] => self
.frozen
.as_ref()
.and_then(|f| f.targets.get(*index))
.map(|t| t.name.clone())
.unwrap_or_else(|| "No eligible destination".into()),
indices => format!(
"Ambiguous: {}",
indices
.iter()
.map(|index| self.press.frozen.targets[*index].name.as_str())
.filter_map(|index| self
.frozen
.as_ref()?
.targets
.get(*index)
.map(|t| t.name.as_str()))
.collect::<Vec<_>>()
.join(" / ")
),
@@ -391,6 +668,18 @@ impl App {
&& self.canvas_size.is_none_or(|s| s == stamp.bounds.size())
&& stamp.bounds.width > 0.
&& stamp.bounds.height > 0.
&& stamp.zoom > 0.
&& [
stamp.bounds.x,
stamp.bounds.y,
stamp.bounds.width,
stamp.bounds.height,
stamp.pan.x,
stamp.pan.y,
stamp.zoom,
]
.iter()
.all(|value| value.is_finite())
}
/// Called before general input dispatch. Even equal-valued actions (Fit,
/// Undo/Redo, tool/modal round trips) retire old render-time capabilities.
@@ -427,20 +716,54 @@ impl App {
pub(super) fn allocation_retire(&mut self) {
self.allocation_drag.retire();
}
fn select_work_clean(&mut self, press: &Press) -> bool {
if !self.allocation_stamp_valid(press.stamp) {
return false;
}
self.selected = press.scope.work;
self.selected_node = Some(press.scope.placement);
self.selected_bend = None;
self.refresh_draft();
true
fn allocation_selection_valid(&self, snapshot: &Snapshot) -> bool {
self.multiselect_context().is_some_and(|current| {
self.diagram_selection
.as_ref()
.is_some_and(|selection| selection.matches(snapshot, current))
})
}
pub(super) fn allocation_press_owns_current_view(&self, press: &Press) -> bool {
self.allocation_drag.active.is_none()
&& self.allocation_stamp_valid(press.stamp)
&& press.arrange == self.allocation_drag.arrange_next
if self.allocation_drag.active.is_some()
|| !self.allocation_stamp_valid(press.stamp)
|| (press.mode == Mode::Arrange) != self.allocation_drag.arrange_next
|| !self.allocation_selection_valid(&press.selection)
|| press.selection.context().scope.surface
!= Surface::Saved(press.stamp.context.diagram)
|| !press.start.x.is_finite()
|| !press.start.y.is_finite()
|| !contains(press.stamp.bounds, press.start)
{
return false;
}
let view = projection::project_view_with_index(
self.project(),
press.stamp.context.diagram,
self.document.view_index(),
);
super::multiselect::visible(
self.project(),
press.stamp.context.diagram,
&view,
&press.target,
)
}
/// Bare Allocation movement has no lifetime provenance. All actual Allocation
/// placement gestures now enter Press/Active, including ordinary performers.
pub(super) fn allocation_legacy_move(&self, message: &Message) -> bool {
let allocation = |diagram| {
self.project()
.diagrams
.get(&diagram)
.is_some_and(|d| d.projection == ProjectionKind::Allocation)
};
match message {
Message::MoveNode(..) => self.active_diagram.is_some_and(allocation),
Message::DiagramSelection(super::multiselect::Event::Move(snapshot, _)) => {
matches!(snapshot.context().scope.surface, Surface::Saved(diagram) if allocation(diagram))
}
_ => false,
}
}
pub(super) fn allocation_event_envelope(&self, event: &Event) -> bool {
match event {
@@ -451,11 +774,64 @@ impl App {
.is_some_and(|active| active.press.token == *token)
}
Event::Refused(epoch, _) => *epoch == self.allocation_drag.epoch,
// Invalid Press packets deliberately report why the view or draft
// changed. Leave their existing validation and feedback intact.
_ => true,
}
}
fn allocation_prepare(&self, press: &Press, selection: &Snapshot) -> Prepared {
let SelectionTarget::Placement(anchor) = &press.target else {
return Prepared::Refused("Move requires only placed work or performers; ports and connections cannot be moved with this selection.".into());
};
let anchor = *anchor;
let Some(ids) = super::multiselect::placements(selection) else {
return Prepared::Refused("Move requires only placed work or performers; remove ports, rows and connections from the selection.".into());
};
let project = self.project();
let diagram = press.stamp.context.diagram;
let d = &project.diagrams[&diagram];
let selected: BTreeMap<_, _> = ids.iter().map(|id| (*id, d.nodes[id].element)).collect();
let result = if press.mode == Mode::Arrange {
plan::arrange::capture(project, diagram, &selected, anchor)
.map(|p| Prepared::Arrange(Arc::new(p)))
} else if selected
.values()
.all(|id| project.elements[id].kind().is_work())
{
if selected.len() == 1 {
plan::capture(project, diagram, selected[&anchor], anchor)
.map(|scope| Prepared::Singleton(Box::new(scope)))
} else {
plan::assignment::capture(project, diagram, &selected, anchor)
.map(Prepared::Assignment)
}
} else if selected
.values()
.all(|id| project.elements[id].kind().is_performer())
{
validate_candidate(project)
.map_err(|e| plan::Refusal::InvalidCandidate(e.to_string()))
.map(|_| {
let mut affected = BTreeMap::new();
for id in &ids {
for member in std::iter::once(*id)
.chain(projection::descendant_nodes(project, diagram, *id))
{
affected.insert(member, d.nodes[&member].clone());
}
}
Prepared::Performers(Arc::new(PerformerPlan {
accepted: project.clone(),
index: view_options::ViewIndex::new(project),
diagram,
selected: ids,
anchor,
affected,
}))
})
} else {
Err(plan::Refusal::Ineligible("Assignment requires only work. Arm Arrange next drag to arrange a supported work/performer selection.".into()))
};
result.unwrap_or_else(|reason| Prepared::Refused(refusal(&reason)))
}
pub(super) fn allocation_event(&mut self, event: Event) -> Task<Message> {
if !self.allocation_event_envelope(&event) {
return Task::none();
@@ -465,7 +841,8 @@ impl App {
self.allocation_drag.retire();
if self.allocation_drag_clean() {
self.allocation_drag.arrange_next = true;
self.status = "Arrange next drag armed · moves work only · Esc cancels".into();
self.status =
"Arrange next drag armed · presentation only · Esc cancels".into();
} else {
self.status = FINISH_EDIT.into();
}
@@ -476,22 +853,13 @@ impl App {
self.allocation_drag.retire();
}
}
Event::Cancel(token) => {
if self
.allocation_drag
.active
.as_ref()
.is_some_and(|a| a.press.token == token)
{
self.allocation_drag.retire();
self.status = "Work drag canceled".into();
}
Event::Cancel(_) => {
self.allocation_drag.retire();
self.status = "Drag canceled".into();
}
Event::Refused(epoch, text) => {
if epoch == self.allocation_drag.epoch {
self.allocation_drag.retire();
self.status = text;
}
Event::Refused(_, text) => {
self.allocation_drag.retire();
self.status = text;
}
Event::Press(press) => {
if self.allocation_drag.active.is_some() {
@@ -505,32 +873,73 @@ impl App {
};
return Task::none();
}
let checked = plan::capture(
self.project(),
press.scope.diagram.id,
press.scope.work,
press.scope.placement,
);
if checked.as_ref() != Ok(&press.scope) || !self.select_work_clean(&press) {
self.status = checked
.err()
.as_ref()
.map(refusal)
.unwrap_or_else(|| "The view changed. Start this drag again.".into());
self.allocation_drag.retire();
// A selected-member press has no selection or inspector effect.
// An unselected press replaces selection atomically in this handler.
if !press.selection.targets().contains(&press.target) {
self.multiselect_event(super::multiselect::Event::Choose(
press.selection.context(),
press.target.clone(),
super::multiselect::Choice::Replace,
));
}
let Some(selection) = self.multiselect_snapshot() else {
return Task::none();
};
if !selection.targets().contains(&press.target)
|| (!press.selection.targets().contains(&press.target)
&& selection.targets() != [press.target.clone()])
{
return Task::none();
}
let ids = press
.scope
.leaves
.keys()
.map(|id| (*id, Id::new_v4()))
.collect();
let diagram = press.stamp.context.diagram;
let view = projection::project_view_with_index(
self.project(),
diagram,
self.document.view_index(),
);
if !selection.targets().iter().all(|target| {
super::multiselect::visible(self.project(), diagram, &view, target)
}) {
self.allocation_drag.retire();
self.status = "The view changed. Start this drag again.".into();
return Task::none();
}
let prepared = self.allocation_prepare(&press, &selection);
let (affected, leaf_ids) = match &prepared {
Prepared::Singleton(scope) => (
Some(&scope.placements),
scope.leaves.keys().copied().collect::<Vec<_>>(),
),
Prepared::Assignment(scope) => (
Some(scope.affected()),
scope.leaves().keys().copied().collect(),
),
Prepared::Arrange(plan) => (Some(plan.affected()), Vec::new()),
Prepared::Performers(plan) => (Some(&plan.affected), Vec::new()),
Prepared::Refused(_) => (None, Vec::new()),
};
let frozen = affected.map(|affected| {
let d = &self.project().diagrams[&diagram];
let routed = routing::route_diagram(self.project(), d, &view);
Arc::new(Frozen::capture(
self.project(),
d,
affected,
&view,
&routed,
press.stamp.zoom,
))
});
let ids = leaf_ids.into_iter().map(|id| (id, Id::new_v4())).collect();
self.allocation_drag.arrange_next = false;
self.allocation_drag.preview = None;
self.allocation_drag.feedback = None;
self.allocation_drag.active = Some(Active {
press,
selection,
crossed: false,
prepared,
frozen,
inspection_targets: Vec::new(),
ids,
cache: Vec::new(),
@@ -552,15 +961,16 @@ impl App {
pointer: Option<Point>,
release: bool,
) {
if self
.allocation_drag
.active
.as_ref()
.is_none_or(|a| a.press.token != token)
{
return; // An old packet cannot cancel a newer gesture.
let Some(active) = self.allocation_drag.active.as_ref() else {
return;
};
if active.press.token != token {
return;
}
if !self.allocation_stamp_valid(stamp) {
if !self.allocation_stamp_valid(stamp)
|| !self.allocation_selection_valid(&active.selection)
|| active.press.stamp != stamp
{
self.allocation_drag.retire();
self.status = if self.allocation_drag_clean() {
"The view changed. Start this drag again.".into()
@@ -569,17 +979,35 @@ impl App {
};
return;
}
let Some(mut active) = self.allocation_drag.active.take() else {
return;
};
if active.press.token != token || active.press.stamp != stamp {
self.allocation_drag.active = Some(active);
let mut active = self.allocation_drag.active.take().unwrap();
let press = active.press.clone();
active.crossed |= pointer.is_some_and(|p| p.distance(press.start) > 3.);
let in_bounds = pointer
.is_some_and(|p| p.x.is_finite() && p.y.is_finite() && contains(stamp.bounds, p));
if !active.crossed && in_bounds {
if release {
self.multiselect_event(super::multiselect::Event::Collapse(
active.selection,
press.target.clone(),
));
self.allocation_drag.retire();
self.status = "Element selected".into();
} else {
self.allocation_drag.active = Some(active);
}
return;
}
let press = active.press.clone();
let moved = pointer.is_some_and(|p| p.distance(press.start) > 3.);
let hit = press.frozen.resolve(stamp, pointer, press.arrange);
if moved {
let ordinary_performers = matches!(&active.prepared, Prepared::Performers(_));
let hit = if !in_bounds {
Hit::OutsideViewport
} else if ordinary_performers {
Hit::Destination(plan::Destination::LayoutOnly, None)
} else {
active.frozen.as_ref().map_or(Hit::Occluded, |f| {
f.resolve(stamp, pointer, press.mode == Mode::Arrange)
})
};
if active.crossed {
match &hit {
Hit::Destination(_, Some(target)) => active.inspection_targets = vec![*target],
Hit::Ambiguous(targets) => active.inspection_targets = targets.clone(),
@@ -588,64 +1016,63 @@ impl App {
}
let mut targets = Vec::new();
let mut preview = None;
let result: Result<String, String> = match hit {
Hit::OutsideViewport => Err(if release {
"Work drag canceled outside the canvas."
} else {
"Pointer outside canvas · return to choose a destination; releasing here cancels the drag."
}.into()),
_ if !moved => Ok("Work selected".into()),
let result: Result<String, String> = if matches!(hit, Hit::OutsideViewport) {
Err(if release { "Drag canceled outside the canvas." }
else { "Pointer outside canvas · return to choose a destination; releasing here cancels the drag." }.into())
} else if let Prepared::Refused(reason) = &active.prepared {
Err(reason.clone())
} else {
match hit {
Hit::OutsideViewport => unreachable!(),
Hit::Occluded => Err("Aim at exposed performer space or empty canvas; this card, port, connection or control blocks the drop.".into()),
Hit::Ambiguous(indices) => {
targets = indices;
Err("Two unrelated performers overlap here. Use an exposed region.".into())
targets = indices; Err("Two unrelated performers overlap here. Use an exposed region.".into())
}
Hit::Destination(destination, target) => {
targets.extend(target);
let semantic = if let Some((_, result)) = active.cache.iter().find(|(d, _)| *d == destination) {
result.clone()
} else {
let result = plan::plan_semantics(self.project(), &press.scope, destination, &active.ids).map(Arc::new);
active.cache.push((destination, result.clone()));
result
let candidate = if let Some((_, cached)) = active.cache.iter().find(|(d, _)| *d == destination) { cached.clone() }
else {
let candidate = match &active.prepared {
Prepared::Singleton(scope) => plan::plan_semantics(self.project(), scope, destination, &active.ids)
.map(|p| Candidate::Singleton(Arc::new(p))),
Prepared::Assignment(scope) => plan::assignment::plan_semantics(scope, destination, &active.ids)
.map(|p| Candidate::Assignment(Arc::new(p))),
Prepared::Arrange(plan) => Ok(Candidate::Arrange(plan.clone())),
Prepared::Performers(plan) => Ok(Candidate::Performers(plan.clone())),
Prepared::Refused(_) => unreachable!(),
};
active.cache.push((destination, candidate.clone())); candidate
};
semantic.map_err(|r| refusal(&r)).and_then(|semantic| {
if stamp.zoom * 16. < 9. {
candidate.map_err(|r| refusal(&r)).and_then(|candidate| {
if !ordinary_performers && stamp.zoom * 16. < 9. {
return Err("Make room or adjust the view, then drag again. Zoom in to read the work and destination.".into());
}
let pointer = pointer.unwrap();
let delta = pointer - press.start;
let original = press.scope.placements[&press.scope.placement].position;
let SelectionTarget::Placement(anchor) = &press.target else { unreachable!("supported placement") };
let original = self.project().diagrams[&stamp.context.diagram].nodes[anchor].position;
let delta = pointer.unwrap() - press.start;
let requested = Position { x: original.x + delta.x / stamp.zoom, y: original.y + delta.y / stamp.zoom };
let presentation = plan::PresentationContext {
viewport: stamp.viewport(), zoom: stamp.zoom,
target: target.map(|i| {
let t = &press.frozen.targets[i];
plan::TargetPresentation { placement: t.placement }
}),
};
let placement = plan::place(&semantic, requested, presentation).map_err(|r| refusal(&r))?;
let label = if semantic.warnings.is_empty() { semantic.label.clone() } else {
format!("{} · {} model warning(s) retained", semantic.label, semantic.warnings.len())
target: target.map(|i| plan::TargetPresentation { placement: active.frozen.as_ref().unwrap().targets[i].placement }),
};
let placed = candidate.place(requested, presentation).map_err(|r| refusal(&r))?;
let label = candidate.label();
if release {
let command = plan::finish(&semantic, self.project(), &placement).map_err(|r| refusal(&r))?;
if let Some(command) = command {
// Bypass applying selectors and unrelated repair helpers. The
// planner's exact Batch enters the authoritative source pipeline.
self.document.execute(command).map_err(|e| format!("{} · retry this drag after correcting the source issue", e))?;
if let Some(command) = candidate.finish(self.project(), &placed).map_err(|r| refusal(&r))? {
self.document.execute(command)
.map_err(|e| format!("{e} · retry this drag after correcting the source issue"))?;
// sync refreshes the retained primary once; no anchor promotion.
self.sync();
self.selected = press.scope.work;
self.selected_node = Some(press.scope.placement);
self.refresh_draft();
self.reconcile_multiselect();
}
} else {
let routed = Arc::new(routing::route_diagram(semantic.project(), &placement.diagram, &placement.view));
preview = Some(Arc::new(Preview { token, semantic, placement, routed }));
let routed = Arc::new(routing::route_diagram(candidate.project(), placed.diagram(), placed.view()));
preview = Some(Arc::new(Preview { token, candidate, placement: placed.into_preview(), routed }));
}
Ok(label)
})
}
}
};
let refused = result.is_err();
let text = result.unwrap_or_else(|text| text);
@@ -654,10 +1081,13 @@ impl App {
self.allocation_drag.retire();
} else {
let inspection_targets = active.inspection_targets.clone();
let frozen = active.frozen.clone();
self.allocation_drag.active = Some(active);
self.allocation_drag.preview = preview;
self.allocation_drag.feedback = Some(Feedback {
press,
frozen,
ordinary_performers,
targets,
inspection_targets,
text,
@@ -4,6 +4,9 @@ use super::*;
use crate::behavior::canvas_workspace::tests::{Mounted, key};
use iced::{Event as Input, keyboard};
#[path = "allocation_drag_tests/control_focus.rs"]
mod control_focus;
fn character(text: &str, modifiers: keyboard::Modifiers) -> Input {
Input::Keyboard(keyboard::Event::KeyPressed {
key: keyboard::Key::Character(text.into()),
@@ -86,7 +89,10 @@ async fn refused_click_keeps_field(mounted: &mut Mounted, f: &mut Fixture, field
);
assert!(!messages.iter().any(|m| matches!(
m,
Message::Select(_) | Message::SelectNode(_) | Message::MoveNode(..)
Message::Select(_)
| Message::CanvasSelect(..)
| Message::SelectNode(_)
| Message::MoveNode(..)
)));
assert_eq!(f.app.document.bundle(), &before);
assert_eq!((f.app.selected, f.app.selected_node), selected);
@@ -211,7 +217,7 @@ async fn actual_keyboard_arrange_shortcut_consumes_once_escape_cancels_and_dirty
assert!(
messages
.iter()
.any(|m| matches!(m, Message::WorkDrag(Event::Press(p)) if p.arrange))
.any(|m| matches!(m, Message::WorkDrag(Event::Press(p)) if p.mode == Mode::Arrange))
);
assert!(!f.app.allocation_drag.arrange_next);
assert_eq!(f.app.document.revision(), 1, "{}", f.app.status);
@@ -282,7 +288,7 @@ async fn actual_rendered_canvas_pixels_do_not_change_when_toolbar_feedback_text_
.as_ref()
.expect("active frozen destination");
assert!(!feedback.refused, "{}", feedback.text);
let target = &feedback.press.frozen.targets[feedback.targets[0]];
let target = &feedback.frozen.as_ref().unwrap().targets[feedback.targets[0]];
assert!(target.region.y * f.app.zoom + f.app.pan.y < 0.);
assert_eq!(feedback.destination_identity(), "A");
let canvas = canvas_bounds(&mut mounted, &f.app);
@@ -434,7 +440,7 @@ async fn actual_app_tree_exact_incoming_drag_keeps_destination_visible_and_canva
selected_header.x + selected_header.width <= canvas.x + canvas.width
&& selected_header.y + selected_header.height <= canvas.y + canvas.height
);
let mut expected = preview.semantic.project().clone();
let mut expected = preview.project().clone();
expected
.diagrams
.insert(f.diagram, preview.placement.diagram.clone());
@@ -689,7 +695,7 @@ async fn actual_toolbar_hover_inspects_last_full_name_without_authorizing_an_out
.await;
assert!(f.app.allocation_drag.active.is_none());
assert!(f.app.allocation_drag.feedback.is_none());
assert_eq!(f.app.status, "Work drag canceled outside the canvas.");
assert_eq!(f.app.status, "Drag canceled outside the canvas.");
assert_eq!(f.app.document.bundle(), &before);
assert!(!f.app.document.can_undo());
// A fresh gesture cannot inherit the previous inspection name.
@@ -8,6 +8,9 @@ use iced::{
#[path = "allocation_drag_full_tree_tests.rs"]
mod full_tree;
#[path = "allocation_drag_tests/group.rs"]
mod group;
#[path = "allocation_drag_tests/navigation_union.rs"]
mod navigation_union;
@@ -48,6 +51,7 @@ fn fixture(layer: Layer) -> Fixture {
app.zoom = 1.;
app.pan = Vector::ZERO;
app.fit_to_view = false;
app.reconcile_multiselect();
Fixture {
app,
a,
@@ -59,6 +63,7 @@ fn fixture(layer: Layer) -> Fixture {
}
fn canvas(app: &App) -> DiagramCanvas<'_> {
DiagramCanvas {
selection: app.diagram_selection.as_ref(),
allocation: Some(app.allocation_capability()),
project: app.project(),
view_index: Some(app.document.view_index()),
@@ -69,6 +74,7 @@ fn canvas(app: &App) -> DiagramCanvas<'_> {
pan: app.pan,
context: app.route_context().unwrap(),
selected_bend: None,
selected_attachment: None,
editable: !app.draft_dirty() && !app.source_dirty(),
placing: false,
connection: None,
@@ -120,6 +126,18 @@ fn press(f: &Fixture) -> Arc<Press> {
})
.expect("actual clean work-body press")
}
fn admit_press(f: &mut Fixture) -> Arc<Press> {
let p = press(f);
let _ = f.app.update(Message::WorkDrag(Event::Press(p.clone())));
assert!(f.app.allocation_drag.active.is_some(), "{}", f.app.status);
p
}
fn singleton(candidate: &Candidate) -> &Arc<plan::SemanticPlan> {
let Candidate::Singleton(plan) = candidate else {
panic!("legacy singleton candidate")
};
plan
}
fn drag(f: &mut Fixture, to: Point) {
let events = batch(&f.app, start(f), to);
assert!(matches!(
@@ -219,7 +237,10 @@ fn mounted_dirty_property_press_does_not_select_apply_or_destroy_draft() {
));
assert!(!events.iter().any(|m| matches!(
m,
Message::Select(_) | Message::SelectNode(_) | Message::MoveNode(..)
Message::Select(_)
| Message::CanvasSelect(..)
| Message::SelectNode(_)
| Message::MoveNode(..)
)));
send(&mut f.app, events);
assert_eq!((f.app.selected, f.app.selected_node), selection);
@@ -357,7 +378,7 @@ fn arrange_is_one_gesture_and_snapped_noop_preserves_redo_and_source() {
assert!(f.app.allocation_drag.arrange_next);
let p = press(&f);
let _ = f.app.update(Message::WorkDrag(Event::Press(p.clone())));
assert!(p.arrange);
assert_eq!(p.mode, Mode::Arrange);
assert!(!f.app.allocation_drag.arrange_next);
let _ = f.app.update(Message::WorkDrag(Event::Release(
p.stamp,
@@ -368,7 +389,7 @@ fn arrange_is_one_gesture_and_snapped_noop_preserves_redo_and_source() {
assert_eq!(f.app.document.revision(), revision);
assert!(f.app.document.can_redo());
assert!(f.app.project().relations.is_empty());
assert!(!press(&f).arrange);
assert_eq!(press(&f).mode, Mode::Assign);
}
#[test]
@@ -395,14 +416,15 @@ fn candidate_semantics_are_cached_per_destination_with_stable_ids_and_own_index(
let _ = f
.app
.update(Message::WorkDrag(Event::Preview(p.stamp, p.token, Some(a))));
let first = f
.app
.allocation_drag
.preview
.as_ref()
.expect(&f.app.status)
.semantic
.clone();
let first = singleton(
&f.app
.allocation_drag
.preview
.as_ref()
.expect(&f.app.status)
.candidate,
)
.clone();
let stable = first
.project()
.relations
@@ -417,20 +439,20 @@ fn candidate_semantics_are_cached_per_destination_with_stable_ids_and_own_index(
)));
assert!(Arc::ptr_eq(
&first,
&f.app.allocation_drag.preview.as_ref().unwrap().semantic
singleton(&f.app.allocation_drag.preview.as_ref().unwrap().candidate)
));
let _ = f
.app
.update(Message::WorkDrag(Event::Preview(p.stamp, p.token, Some(b))));
let preview = f.app.allocation_drag.preview.as_ref().expect(&f.app.status);
assert!(!Arc::ptr_eq(&first, &preview.semantic));
assert_eq!(preview.semantic.project().relations[&stable].source(), f.b);
assert!(!Arc::ptr_eq(&first, singleton(&preview.candidate)));
assert_eq!(preview.project().relations[&stable].source(), f.b);
assert_eq!(
preview.placement.view,
projection::project_diagram_with_index(
preview.semantic.project(),
preview.project(),
&preview.placement.diagram,
preview.semantic.index()
preview.index()
)
);
assert_ne!(
@@ -446,7 +468,7 @@ fn candidate_semantics_are_cached_per_destination_with_stable_ids_and_own_index(
.update(Message::WorkDrag(Event::Preview(p.stamp, p.token, Some(a))));
assert!(Arc::ptr_eq(
&first,
&f.app.allocation_drag.preview.as_ref().unwrap().semantic
singleton(&f.app.allocation_drag.preview.as_ref().unwrap().candidate)
));
assert_eq!(
f.app.allocation_drag.active.as_ref().unwrap().cache.len(),
@@ -456,9 +478,19 @@ fn candidate_semantics_are_cached_per_destination_with_stable_ids_and_own_index(
#[test]
fn frozen_targets_use_half_open_semantic_priority_and_refuse_incomparable_overlap() {
let f = fixture(Layer::Oa);
let p = press(&f);
let mut frozen = p.frozen.clone();
let mut f = fixture(Layer::Oa);
let p = admit_press(&mut f);
let mut frozen = f
.app
.allocation_drag
.active
.as_ref()
.unwrap()
.frozen
.as_ref()
.unwrap()
.as_ref()
.clone();
let ai = frozen
.targets
.iter()
@@ -538,6 +570,7 @@ fn release_uses_ordered_native_position_before_later_motion_in_same_batch() {
fn replace(f: &mut Fixture, project: Project) {
f.app.document = crate::document::Document::from_project(project, Default::default()).unwrap();
f.app.sync();
f.app.reconcile_multiselect();
}
#[test]
@@ -586,7 +619,7 @@ fn same_snapped_position_on_opposite_shared_border_rebuilds_semantics_even_if_la
.1
.as_ref()
.unwrap();
assert!(!Arc::ptr_eq(a, b));
assert!(!Arc::ptr_eq(singleton(a), singleton(b)));
let stable = a
.project()
.relations
@@ -653,13 +686,24 @@ fn mounted_role_only_outgoing_refusal_retains_placement_and_same_performer_creat
"{}",
f.app.status
);
let target = press(&f)
let admitted = admit_press(&mut f);
let target = f
.app
.allocation_drag
.active
.as_ref()
.unwrap()
.frozen
.as_ref()
.unwrap()
.targets
.iter()
.find(|t| t.element == f.a)
.unwrap()
.region;
let _ = f
.app
.update(Message::WorkDrag(Event::Cancel(admitted.token)));
drag(&mut f, screen(xy(target.x + 45., target.y + 100.)));
assert_eq!(f.app.document.revision(), 1, "{}", f.app.status);
assert_eq!(f.app.project().relations, relations);
@@ -772,8 +816,18 @@ fn frozen_collapsed_target_uses_only_visible_header_and_port_edge_controls_occlu
.unwrap()
.collapsed = true;
replace(&mut f, project);
let p = press(&f);
let mut frozen = p.frozen.clone();
let p = admit_press(&mut f);
let mut frozen = f
.app
.allocation_drag
.active
.as_ref()
.unwrap()
.frozen
.as_ref()
.unwrap()
.as_ref()
.clone();
let t = frozen.targets.iter().find(|t| t.element == f.a).unwrap();
let bottom = xy(t.region.x + 30., t.region.y + t.region.height);
assert!(t.region.height <= 62.);
@@ -943,7 +997,7 @@ fn toolbar_destination_names_transformed_target_whose_header_is_clipped() {
send(&mut f.app, events);
let feedback = f.app.allocation_drag.feedback.as_ref().unwrap();
assert!(!feedback.refused, "{}", feedback.text);
let target = &feedback.press.frozen.targets[feedback.targets[0]];
let target = &feedback.frozen.as_ref().unwrap().targets[feedback.targets[0]];
assert!(screen(xy(target.region.x, target.region.y)).y < bounds().y);
assert_eq!(feedback.destination_identity(), "A");
assert!(f.app.allocation_drag.preview.is_some());
@@ -1002,7 +1056,7 @@ fn oa_sa_clipped_selected_header_refuses_preview_release_and_retries_without_par
assert!(feedback.refused, "{}", feedback.text);
assert!(feedback.text.contains("Make room or adjust the view"));
assert_eq!(
feedback.press.frozen.targets[feedback.targets[0]].element,
feedback.frozen.as_ref().unwrap().targets[feedback.targets[0]].element,
f.a
);
assert_eq!(f.app.project(), &accepted);
@@ -1027,14 +1081,14 @@ fn oa_sa_clipped_selected_header_refuses_preview_release_and_retries_without_par
send(&mut f.app, events);
let active = f.app.allocation_drag.active.as_ref().unwrap();
let p = active.press.clone();
let semantic = active.cache[0].1.as_ref().unwrap().clone();
let semantic = singleton(active.cache[0].1.as_ref().unwrap()).clone();
let _ = f.app.update(Message::WorkDrag(Event::Preview(
p.stamp,
p.token,
Some(clear),
)));
let preview = f.app.allocation_drag.preview.as_ref().expect(&f.app.status);
assert!(Arc::ptr_eq(&semantic, &preview.semantic));
assert!(Arc::ptr_eq(&semantic, singleton(&preview.candidate)));
assert_eq!(
preview.placement.diagram.nodes[&f.node].position,
xy(120., 160.)
@@ -1130,7 +1184,7 @@ fn oa_sa_toolbar_identity_accepts_exact_receiver_and_collapsed_header_drag() {
assert!(!feedback.refused, "{}", feedback.text);
assert_eq!(feedback.destination_identity(), name);
assert_eq!(
feedback.press.frozen.targets[feedback.targets[0]].placement,
feedback.frozen.as_ref().unwrap().targets[feedback.targets[0]].placement,
target_id
);
let preview = f.app.allocation_drag.preview.as_ref().expect(&f.app.status);
@@ -1142,12 +1196,12 @@ fn oa_sa_toolbar_identity_accepts_exact_receiver_and_collapsed_header_drag() {
assert_eq!(
preview.placement.view,
projection::project_diagram_with_index(
preview.semantic.project(),
preview.project(),
&preview.placement.diagram,
preview.semantic.index()
preview.index()
)
);
let mut expected = preview.semantic.project().clone();
let mut expected = preview.project().clone();
expected
.diagrams
.insert(f.diagram, preview.placement.diagram.clone());
@@ -1182,6 +1236,8 @@ fn destination_identity_does_not_describe_blocked_or_outside_viewport_as_unalloc
let p = press(&f);
let mut feedback = Feedback {
press: p,
frozen: None,
ordinary_performers: false,
targets: vec![],
inspection_targets: vec![],
text: "A card blocks this drop".into(),
@@ -0,0 +1,111 @@
use super::*;
#[tokio::test]
async fn dirty_selected_pin_outer_ring_keeps_real_name_focus_and_caret() {
let mut f = fixture(Layer::Oa);
let mut p = f.app.project().clone();
let peer = p
.add_element(Kind::Activity, Layer::Oa, "Peer", None)
.unwrap();
p.place(f.diagram, peer, xy(700., 500.));
let edge = p.relate(RelationKind::Interaction, "Flow", f.work, peer);
let pin = xy(450., 547.);
p.diagrams
.get_mut(&f.diagram)
.unwrap()
.routes
.push(EdgeRoute {
edge,
source_attachment: Some(EdgeAttachment {
side: PortAnchorSide::Right,
offset: 0.5,
}),
target_attachment: Some(EdgeAttachment {
side: PortAnchorSide::Left,
offset: 0.5,
}),
waypoints: vec![pin],
label: Some(EdgeLabel {
fraction: 0.8,
offset: xy(0., -60.),
}),
});
validate_candidate(&p).unwrap();
let view = projection::project_view(&p, f.diagram);
let routed = routing::route_diagram(&p, &p.diagrams[&f.diagram], &view);
assert_eq!(routed.edges[&edge].pins, vec![pin]);
assert!(!routed.edges[&edge].custom_suspended);
assert!(
routed.edges[&edge]
.points
.iter()
.all(|point| point.y == 547.)
);
f.app = App::with_project(p);
f.app.active_diagram = Some(f.diagram);
f.app.window_size = iced::Size::new(2000., 1400.);
let _ = f.app.update(Message::Select(edge));
f.app.reconcile_multiselect();
let mut mounted = Mounted::new(&mut f.app).await;
f.app.zoom = 1.;
f.app.pan = Vector::ZERO;
f.app.fit_to_view = false;
mounted.settle(&mut f.app).await;
let field = name_field(&mut mounted, &f.app);
mounted.click(&mut f.app, field.center()).await;
mounted
.batch(
&mut f.app,
&[key(keyboard::key::Named::Home)],
mouse::Cursor::Unavailable,
)
.await;
type_key(&mut mounted, &mut f.app, "X").await;
mounted
.batch(
&mut f.app,
&[key(keyboard::key::Named::ArrowLeft)],
mouse::Cursor::Unavailable,
)
.await;
assert_eq!(f.app.draft.as_ref().unwrap().inputs["/name"].raw, "XFlow");
assert!(f.app.draft_dirty());
let accepted = f.app.document.snapshot();
let source = f.app.document.bundle().clone();
let selection = f.app.multiselect_snapshot().unwrap();
let pan = f.app.pan;
// Exposed straight-run bend: outside the 7px line hit, within the 8px handle.
let point = at(&mut mounted, &f, xy(pin.x, pin.y + 7.5));
let messages = mounted.click(&mut f.app, point).await;
assert!(
messages
.iter()
.any(|m| matches!(m, Message::WorkDrag(Event::Refused(..))))
);
assert!(!messages.iter().any(|m| matches!(
m,
Message::Pan(_)
| Message::Select(_)
| Message::SelectNode(_)
| Message::MoveNode(..)
| Message::Route(_)
| Message::DiagramSelection(_)
)));
assert!(Arc::ptr_eq(&accepted, &f.app.document.snapshot()));
assert_eq!(f.app.document.bundle(), &source);
assert_eq!(f.app.multiselect_snapshot().unwrap(), selection);
assert_eq!(f.app.pan, pan);
assert!(
is_focused(&mut mounted, &f.app, field),
"dirty handle press must stop before outside-press unfocus"
);
let messages = type_key(&mut mounted, &mut f.app, "Y").await;
assert!(
messages
.iter()
.any(|m| matches!(m, Message::Field(path, _) if path == "/name"))
);
assert_eq!(f.app.draft.as_ref().unwrap().inputs["/name"].raw, "YXFlow");
assert_eq!(f.app.document.bundle(), &source);
assert!(!f.app.document.can_undo());
}
@@ -0,0 +1,904 @@
use super::*;
use crate::app::multiselect::{self, Choice};
use crate::diagram_usage::Target as SelectionTarget;
use iced::widget::text_editor;
#[path = "group/connection_lifetime.rs"]
mod connection_lifetime;
#[path = "group/route_lifetime.rs"]
mod route_lifetime;
struct Group {
f: Fixture,
y: Id,
yn: Id,
c: Id,
cn: Id,
an: Id,
ax: Id,
by: Id,
}
fn group(layer: Layer) -> Group {
let mut f = fixture(layer);
let mut p = f.app.project().clone();
let y = p
.add_element(p.elements[&f.work].kind(), layer, "Y", None)
.unwrap();
let c = p
.add_element(
if layer == Layer::Oa {
Kind::Actor
} else {
Kind::SystemActor
},
layer,
"C",
None,
)
.unwrap();
let yn = p.place(f.diagram, y, xy(140., 600.));
let cn = p.place(f.diagram, c, xy(1500., 100.));
let d = p.diagrams.get_mut(&f.diagram).unwrap();
d.nodes.get_mut(&f.node).unwrap().position = xy(140., 200.);
d.nodes
.values_mut()
.find(|n| n.element == f.b)
.unwrap()
.position = xy(100., 500.);
d.nodes.get_mut(&cn).unwrap().size = Size {
width: 600.,
height: 900.,
};
let an = d.nodes.values().find(|n| n.element == f.a).unwrap().id;
let ax = p.relate(
RelationKind::Allocation,
"Reviewed X responsibility",
f.a,
f.work,
);
let by = p.relate(
RelationKind::Allocation,
"Reviewed Y responsibility",
f.b,
y,
);
p.relations.get_mut(&ax).unwrap().description = "Keep X provenance".into();
p.relations.get_mut(&by).unwrap().description = "Keep Y provenance".into();
validate_candidate(&p).unwrap();
f.app = App::with_project(p);
f.app.active_diagram = Some(f.diagram);
f.app.zoom = 1.;
f.app.pan = Vector::ZERO;
f.app.fit_to_view = false;
f.app.reconcile_multiselect();
Group {
f,
y,
yn,
c,
cn,
an,
ax,
by,
}
}
fn choose(app: &mut App, target: SelectionTarget, choice: Choice) {
let context = multiselect::context(app.route_context().unwrap());
let _ = app.update(Message::DiagramSelection(multiselect::Event::Choose(
context, target, choice,
)));
}
fn select(app: &mut App, ids: &[Id]) {
for (i, id) in ids.iter().enumerate() {
choose(
app,
SelectionTarget::Placement(*id),
if i == 0 { Choice::Replace } else { Choice::Add },
);
}
assert_eq!(
app.diagram_selection.as_ref().unwrap().targets(),
&ids.iter()
.copied()
.map(SelectionTarget::Placement)
.collect::<Vec<_>>()
);
}
fn selection(
app: &App,
) -> (
Vec<SelectionTarget>,
Option<SelectionTarget>,
Id,
Option<Id>,
) {
let s = app.diagram_selection.as_ref().unwrap();
(
s.targets().to_vec(),
s.primary().cloned(),
app.selected,
app.selected_node,
)
}
fn node_point(app: &App, node: Id) -> Point {
let view = projection::project_view(app.project(), app.active_diagram.unwrap());
let n = view.nodes.iter().find(|n| n.id == node).unwrap();
screen(xy(n.position.x + 40., n.position.y + 35.))
}
fn input(app: &mut App, state: &mut CanvasState, event: mouse::Event, at: Point) {
if let Some(message) = pointer(&canvas(app), state, event, at) {
let _ = app.update(message);
}
}
fn begin(app: &mut App, state: &mut CanvasState, from: Point) -> Arc<Press> {
input(
app,
state,
mouse::Event::CursorMoved { position: from },
from,
);
let message = pointer(
&canvas(app),
state,
mouse::Event::ButtonPressed(mouse::Button::Left),
from,
)
.unwrap();
let Message::WorkDrag(Event::Press(packet)) = &message else {
panic!("Allocation placement must enter its captured lifetime: {message:?}");
};
let packet = packet.clone();
let _ = app.update(message);
packet
}
fn move_pointer(app: &mut App, state: &mut CanvasState, to: Point) {
input(app, state, mouse::Event::CursorMoved { position: to }, to);
}
fn release(app: &mut App, state: &mut CanvasState, to: Point) {
input(
app,
state,
mouse::Event::ButtonReleased(mouse::Button::Left),
to,
);
}
fn exact_history(
app: &mut App,
before: &Project,
source: &oa_core::source_project::SourceBundle,
expected: &Project,
) {
assert_eq!(app.project(), expected);
let after = app.document.bundle().clone();
let reopened =
crate::document::Document::from_bundle(after.clone(), Default::default()).unwrap();
assert_eq!(reopened.project(), expected);
let _ = app.update(Message::Undo);
assert_eq!(app.project(), before);
assert_eq!(app.document.bundle(), source);
assert!(
!app.document.can_undo(),
"one group drag is one history step"
);
let _ = app.update(Message::Redo);
assert_eq!(app.project(), expected);
assert_eq!(app.document.bundle(), &after);
assert!(!app.document.can_redo());
}
#[test]
fn completed_text_apply_rejects_old_allocation_packets_without_retiring_a_new_drag() {
for layer in [Layer::Oa, Layer::Sa] {
let mut g = group(layer);
select(&mut g.f.app, &[g.f.node, g.yn]);
let before = g.f.app.document.snapshot();
assert!(!g.f.app.document.can_undo());
let from = node_point(&g.f.app, g.f.node);
let mut old_canvas = CanvasState::default();
let old = begin(&mut g.f.app, &mut old_canvas, from);
assert!(g.f.app.allocation_drag.active.is_some());
let location = g.f.app.document.compiled().index.declarations[&g.f.work].clone();
let original = &before.bundle.files[&location.path];
let quoted_name = format!("\"{}\"", g.f.app.project().elements[&g.f.work].name);
assert_eq!(original.matches(quoted_name.as_str()).count(), 1);
let edited = original.replace(quoted_name.as_str(), "\"Changed through Text\"");
let mut expected = before.decoded.project.clone();
expected.elements.get_mut(&g.f.work).unwrap().name = "Changed through Text".into();
let mut expected_bundle = before.bundle.clone();
expected_bundle
.files
.insert(location.path.clone(), edited.clone());
let _ = g.f.app.update(Message::Page(Page::Text));
assert!(g.f.app.allocation_drag.active.is_none());
let _ = g.f.app.update(Message::SourceFile(location.path));
let _ =
g.f.app
.update(Message::SourceEdit(text_editor::Action::SelectAll));
let _ =
g.f.app
.update(Message::SourceEdit(text_editor::Action::Edit(
text_editor::Edit::Paste(Arc::new(edited.clone())),
)));
assert!(g.f.app.source_dirty());
assert_eq!(g.f.app.source_editor.text(), edited);
assert!(Arc::ptr_eq(&before, &g.f.app.document.snapshot()));
let _ = g.f.app.update(Message::ApplySource);
assert!(!g.f.app.source_dirty(), "{}", g.f.app.status);
assert_eq!(g.f.app.project(), &expected);
assert_eq!(g.f.app.document.bundle(), &expected_bundle);
let _ = g.f.app.update(Message::Page(Page::Diagram));
assert_eq!(g.f.app.active_diagram, Some(g.f.diagram));
select(&mut g.f.app, &[g.f.node, g.yn]);
let from = node_point(&g.f.app, g.f.node);
let to = from + Vector::new(1400., 0.);
let mut current_canvas = CanvasState::default();
let current = begin(&mut g.f.app, &mut current_canvas, from);
assert_ne!(current.token, old.token);
assert_ne!(current.stamp.context, old.stamp.context);
move_pointer(&mut g.f.app, &mut current_canvas, to);
let preview =
g.f.app
.allocation_drag
.preview
.as_ref()
.expect(&g.f.app.status)
.clone();
let Candidate::Assignment(plan) = &preview.candidate else {
panic!("the current gesture must be a real group assignment");
};
assert_eq!(plan.changed_count(), 2);
let accepted = g.f.app.document.snapshot();
let revision = g.f.app.document.revision();
let held = selection(&g.f.app);
let selected = g.f.app.multiselect_snapshot().unwrap();
let workspace = g.f.app.source_workspace.bundle();
let editor = g.f.app.source_editor.text();
let camera = (
g.f.app.zoom,
g.f.app.pan,
g.f.app.fit_to_view,
g.f.app.canvas_size,
);
let epochs = (
g.f.app.canvas_epoch,
g.f.app.camera_epoch,
g.f.app.allocation_drag.epoch,
);
let draft =
g.f.app
.draft
.as_ref()
.map(|d| serde_json::to_value(d.recovery_snapshot()).unwrap());
let form = g.f.app.behavior.recovery_snapshot();
let status = g.f.app.status.clone();
let pending_reveal = g.f.app.pending_diagram_reveal.clone();
let arrange = g.f.app.allocation_drag.arrange_next;
// An idle obsolete press intentionally reports a stale-view explanation.
// With a newer real gesture active, both old packets must be fully inert.
for event in [
Event::Press(old.clone()),
Event::Release(old.stamp, old.token, Some(to)),
] {
let _ = g.f.app.update(Message::WorkDrag(event));
assert!(Arc::ptr_eq(&accepted, &g.f.app.document.snapshot()));
assert_eq!(g.f.app.project(), &expected);
assert_eq!(g.f.app.document.bundle(), &expected_bundle);
assert_eq!(g.f.app.document.revision(), revision);
assert_eq!(selection(&g.f.app), held);
assert_eq!(g.f.app.multiselect_snapshot(), Some(selected.clone()));
assert!(Arc::ptr_eq(
&g.f.app.allocation_drag.active.as_ref().unwrap().press,
&current
));
assert!(Arc::ptr_eq(
g.f.app.allocation_drag.preview.as_ref().unwrap(),
&preview
));
assert_eq!(g.f.app.source_workspace.bundle(), workspace);
assert_eq!(g.f.app.source_editor.text(), editor);
assert_eq!(
(
g.f.app.zoom,
g.f.app.pan,
g.f.app.fit_to_view,
g.f.app.canvas_size
),
camera
);
assert_eq!(
(
g.f.app.canvas_epoch,
g.f.app.camera_epoch,
g.f.app.allocation_drag.epoch
),
epochs
);
assert_eq!(
g.f.app
.draft
.as_ref()
.map(|d| serde_json::to_value(d.recovery_snapshot()).unwrap()),
draft
);
assert_eq!(g.f.app.behavior.recovery_snapshot(), form);
assert_eq!(g.f.app.pending_diagram_reveal, pending_reveal);
assert_eq!(g.f.app.allocation_drag.arrange_next, arrange);
assert_eq!(g.f.app.status, status);
assert!(!g.f.app.source_dirty());
assert!(g.f.app.document.can_undo());
assert!(!g.f.app.document.can_redo());
}
// The current gesture remains only a preview. History contains solely
// the accepted text rename, never either stale assignment packet.
let _ = g.f.app.update(Message::Undo);
assert_eq!(g.f.app.project(), &before.decoded.project);
assert_eq!(g.f.app.document.bundle(), &before.bundle);
assert!(!g.f.app.document.can_undo());
assert!(g.f.app.document.can_redo());
let _ = g.f.app.update(Message::Redo);
assert_eq!(g.f.app.project(), &expected);
assert_eq!(g.f.app.document.bundle(), &expected_bundle);
assert!(g.f.app.document.can_undo());
assert!(!g.f.app.document.can_redo());
}
}
#[test]
fn group_assignment_split_rebuild_preserves_primary_and_commits_one_complete_candidate() {
for layer in [Layer::Oa, Layer::Sa] {
for anchor_is_primary in [false, true] {
let mut g = group(layer);
select(&mut g.f.app, &[g.f.node, g.yn]);
let held = selection(&g.f.app);
let before = g.f.app.project().clone();
let source = g.f.app.document.bundle().clone();
let anchor = if anchor_is_primary { g.yn } else { g.f.node };
let from = node_point(&g.f.app, anchor);
let to = from + Vector::new(1400., 0.);
let mut state = CanvasState::default();
let packet = begin(&mut g.f.app, &mut state, from);
assert_eq!(packet.target, SelectionTarget::Placement(anchor));
assert_eq!(
selection(&g.f.app),
held,
"member press must not inspect/collapse"
);
assert_eq!(g.f.app.document.bundle(), &source);
move_pointer(&mut g.f.app, &mut state, to);
let preview =
g.f.app
.allocation_drag
.preview
.as_ref()
.expect(&g.f.app.status);
let Candidate::Assignment(plan) = &preview.candidate else {
panic!("group must use combined assignment");
};
assert_eq!(plan.changed_count(), 2);
assert_eq!(plan.already_correct_count(), 0);
assert_eq!(selection(&g.f.app), held);
assert_eq!(g.f.app.project(), &before, "preview is immutable");
release(&mut g.f.app, &mut state, to);
let mut expected = before.clone();
for (relation, work) in [(g.ax, g.f.work), (g.by, g.y)] {
expected.relations.get_mut(&relation).unwrap().body = RelationBody::Allocation {
source: g.c,
target: work,
};
}
let d = expected.diagrams.get_mut(&g.f.diagram).unwrap();
d.nodes.get_mut(&g.f.node).unwrap().position = xy(1540., 200.);
d.nodes.get_mut(&g.yn).unwrap().position = xy(1540., 600.);
assert_eq!(selection(&g.f.app), held);
exact_history(&mut g.f.app, &before, &source, &expected);
}
}
}
#[test]
fn group_arrange_work_and_performer_anchors_preserve_every_responsibility() {
for layer in [Layer::Oa, Layer::Sa] {
for performer_anchor in [false, true] {
let mut g = group(layer);
select(&mut g.f.app, &[g.an, g.yn]);
let held = selection(&g.f.app);
let before = g.f.app.project().clone();
let source = g.f.app.document.bundle().clone();
let _ = g.f.app.update(Message::WorkDrag(Event::ArrangeNext));
assert!(g.f.app.allocation_drag.arrange_next);
let from = node_point(&g.f.app, if performer_anchor { g.an } else { g.yn });
let to = from + Vector::new(400., 0.);
let mut state = CanvasState::default();
let packet = begin(&mut g.f.app, &mut state, from);
assert_eq!(packet.mode, Mode::Arrange);
assert!(!g.f.app.allocation_drag.arrange_next);
assert_eq!(selection(&g.f.app), held);
move_pointer(&mut g.f.app, &mut state, to);
assert!(
matches!(
g.f.app
.allocation_drag
.preview
.as_ref()
.map(|p| &p.candidate),
Some(Candidate::Arrange(_))
),
"{}",
g.f.app.status
);
release(&mut g.f.app, &mut state, to);
let mut expected = before.clone();
let d = expected.diagrams.get_mut(&g.f.diagram).unwrap();
d.nodes.get_mut(&g.an).unwrap().position = xy(500., 100.);
d.nodes.get_mut(&g.f.node).unwrap().position = xy(540., 200.);
d.nodes.get_mut(&g.yn).unwrap().position = xy(540., 600.);
assert_eq!(selection(&g.f.app), held);
exact_history(&mut g.f.app, &before, &source, &expected);
}
}
}
#[test]
fn unsupported_group_click_collapses_but_threshold_crossing_never_becomes_click() {
for performer_anchor in [false, true] {
for distance in [0., 3., 3.25] {
let mut g = group(Layer::Oa);
select(&mut g.f.app, &[g.an, g.f.node]);
let held = selection(&g.f.app);
let before = g.f.app.document.snapshot();
let anchor = if performer_anchor { g.an } else { g.f.node };
let from = node_point(&g.f.app, anchor);
let mut state = CanvasState::default();
begin(&mut g.f.app, &mut state, from);
assert_eq!(selection(&g.f.app), held);
move_pointer(&mut g.f.app, &mut state, from + Vector::new(distance, 0.));
if distance > 3. {
assert!(
g.f.app
.allocation_drag
.feedback
.as_ref()
.is_some_and(|f| f.refused)
);
assert!(g.f.app.allocation_drag.preview.is_none());
}
move_pointer(&mut g.f.app, &mut state, from);
release(&mut g.f.app, &mut state, from);
assert!(g.f.app.allocation_drag.active.is_none());
assert!(Arc::ptr_eq(&before, &g.f.app.document.snapshot()));
if distance > 3. {
assert_eq!(
selection(&g.f.app),
held,
"crossed-and-returned refusal must retain group"
);
} else {
let s = g.f.app.diagram_selection.as_ref().unwrap();
assert_eq!(s.targets(), &[SelectionTarget::Placement(anchor)]);
assert_eq!(s.primary(), Some(&SelectionTarget::Placement(anchor)));
}
}
}
}
#[test]
fn allocation_bare_moves_cannot_bypass_armed_active_or_retired_lifetime() {
for (stage, singleton) in (0..3).flat_map(|stage| [(stage, false), (stage, true)]) {
let mut g = group(Layer::Oa);
let ids = if singleton {
vec![g.an]
} else {
vec![g.an, g.cn]
};
select(&mut g.f.app, &ids);
let snapshot = g.f.app.multiselect_snapshot().unwrap();
let held = selection(&g.f.app);
let before = g.f.app.document.snapshot();
let _ = g.f.app.update(Message::WorkDrag(Event::ArrangeNext));
let mut state = CanvasState::default();
let from = node_point(&g.f.app, g.an);
if stage > 0 {
let press = begin(&mut g.f.app, &mut state, from);
if stage == 2 {
let _ =
g.f.app
.update(Message::WorkDrag(Event::Cancel(press.token)));
}
}
let epoch = g.f.app.allocation_drag.epoch;
let armed = g.f.app.allocation_drag.arrange_next;
let token =
g.f.app
.allocation_drag
.active
.as_ref()
.map(|a| a.press.token);
let _ = g.f.app.update(Message::MoveNode(g.an, xy(500., 100.)));
let _ =
g.f.app
.update(Message::DiagramSelection(multiselect::Event::Move(
snapshot,
xy(400., 0.),
)));
assert!(Arc::ptr_eq(&before, &g.f.app.document.snapshot()));
assert_eq!(selection(&g.f.app), held);
assert_eq!(g.f.app.allocation_drag.epoch, epoch);
assert_eq!(g.f.app.allocation_drag.arrange_next, armed);
assert_eq!(
g.f.app
.allocation_drag
.active
.as_ref()
.map(|a| a.press.token),
token
);
}
// A fresh ordinary performer gesture still uses the existing layout policy.
// C's right edge leaves the viewport: explicit Arrange's header gate must not
// accidentally become a new restriction on ordinary performer movement.
let mut g = group(Layer::Oa);
select(&mut g.f.app, &[g.an, g.cn]);
let held = selection(&g.f.app);
let before = g.f.app.project().clone();
let source = g.f.app.document.bundle().clone();
let _ = g.f.app.update(Message::WorkDrag(Event::ArrangeNext));
let from = node_point(&g.f.app, g.an);
let mut state = CanvasState::default();
let old = begin(&mut g.f.app, &mut state, from);
let _ = g.f.app.update(Message::WorkDrag(Event::Cancel(old.token)));
let mut state = CanvasState::default();
let fresh = begin(&mut g.f.app, &mut state, from);
assert_eq!(fresh.mode, Mode::Assign);
let to = from + Vector::new(300., 0.);
move_pointer(&mut g.f.app, &mut state, to);
assert!(
matches!(
g.f.app
.allocation_drag
.preview
.as_ref()
.map(|p| &p.candidate),
Some(Candidate::Performers(_))
),
"{}",
g.f.app.status
);
release(&mut g.f.app, &mut state, to);
let mut expected = before.clone();
let d = expected.diagrams.get_mut(&g.f.diagram).unwrap();
d.nodes.get_mut(&g.an).unwrap().position = xy(400., 100.);
d.nodes.get_mut(&g.f.node).unwrap().position = xy(440., 200.);
d.nodes.get_mut(&g.cn).unwrap().position = xy(1800., 100.);
assert_eq!(selection(&g.f.app), held);
exact_history(&mut g.f.app, &before, &source, &expected);
}
#[test]
fn idle_group_press_rejects_selection_generation_aba_with_unchanged_other_stamps() {
for membership_aba in [false, true] {
let mut g = group(Layer::Oa);
select(&mut g.f.app, &[g.f.node, g.yn]);
let old = press(&g.f);
let before = g.f.app.document.snapshot();
let held = selection(&g.f.app);
let epoch = g.f.app.allocation_drag.epoch;
// Deliberately isolate selection generation from App message retirement.
// Final selection/primary/context/Document values are unchanged; this
// exercises the snapshot boundary, not an ordinary user-input sequence.
let s = g.f.app.diagram_selection.as_mut().unwrap();
if membership_aba {
assert!(s.remove(&SelectionTarget::Placement(g.yn)));
assert!(s.add(SelectionTarget::Placement(g.yn), |_| true));
} else {
assert!(s.set_primary(&SelectionTarget::Placement(g.f.node)));
assert!(s.set_primary(&SelectionTarget::Placement(g.yn)));
}
assert_eq!(selection(&g.f.app), held);
assert_eq!(g.f.app.allocation_drag.epoch, epoch);
assert_eq!(g.f.app.route_context(), Some(old.stamp.context));
let _ = g.f.app.update(Message::WorkDrag(Event::Press(old.clone())));
assert!(
g.f.app.allocation_drag.active.is_none(),
"generation-only stale selection must not acquire input"
);
let _ = g.f.app.update(Message::WorkDrag(Event::Release(
old.stamp,
old.token,
Some(start(&g.f)),
)));
assert!(Arc::ptr_eq(&before, &g.f.app.document.snapshot()));
assert_eq!(selection(&g.f.app), held);
}
}
#[test]
fn group_selection_aba_and_old_press_cannot_retire_a_new_token() {
let mut g = group(Layer::Oa);
select(&mut g.f.app, &[g.f.node, g.yn]);
let old = press(&g.f);
choose(
&mut g.f.app,
SelectionTarget::Placement(g.f.node),
Choice::Inspect,
);
choose(
&mut g.f.app,
SelectionTarget::Placement(g.yn),
Choice::Inspect,
);
let before = g.f.app.document.snapshot();
let from = node_point(&g.f.app, g.f.node);
let mut state = CanvasState::default();
let fresh = begin(&mut g.f.app, &mut state, from);
let held = selection(&g.f.app);
let epoch = g.f.app.allocation_drag.epoch;
let _ = g.f.app.update(Message::WorkDrag(Event::Press(old.clone())));
let _ = g.f.app.update(Message::WorkDrag(Event::Release(
old.stamp,
old.token,
Some(from + Vector::new(1400., 0.)),
)));
assert_eq!(
g.f.app
.allocation_drag
.active
.as_ref()
.map(|a| a.press.token),
Some(fresh.token)
);
assert_eq!(g.f.app.allocation_drag.epoch, epoch);
assert_eq!(selection(&g.f.app), held);
assert!(Arc::ptr_eq(&before, &g.f.app.document.snapshot()));
release(&mut g.f.app, &mut state, from);
}
fn unassign(g: &mut Group, inside_c: bool) {
let mut project = g.f.app.project().clone();
project.relations.remove(&g.ax);
project.relations.remove(&g.by);
if inside_c {
let d = project.diagrams.get_mut(&g.f.diagram).unwrap();
// These unassigned cards overlap an unrelated empty performer at the
// same depth. Paint C first so the intended work press is visible and
// independent of random UUID ordering; no semantic containment exists.
let mut target = d.nodes.remove(&g.cn).unwrap();
g.cn = Id::from_u128(1);
target.id = g.cn;
assert!(d.nodes.insert(g.cn, target).is_none());
d.nodes.get_mut(&g.f.node).unwrap().position = xy(1540., 200.);
d.nodes.get_mut(&g.yn).unwrap().position = xy(1540., 600.);
}
g.f.app = App::with_project(project);
g.f.app.active_diagram = Some(g.f.diagram);
g.f.app.zoom = 1.;
g.f.app.pan = Vector::ZERO;
g.f.app.fit_to_view = false;
g.f.app.reconcile_multiselect();
}
fn c_allocations(expected: &mut Project, g: &Group, ids: &BTreeMap<Id, Id>) {
for (work, name, alias) in [
(g.f.work, "C performs Work", "c_performs_work"),
(g.y, "C performs Y", "c_performs_y"),
] {
expected.relations.insert(
ids[&work],
Relation {
id: ids[&work],
alias: alias.into(),
name: name.into(),
description: String::new(),
body: RelationBody::Allocation {
source: g.c,
target: work,
},
},
);
}
}
#[test]
fn group_out_and_back_assigns_at_zero_delta_then_true_noop_preserves_redo() {
for layer in [Layer::Oa, Layer::Sa] {
let mut g = group(layer);
unassign(&mut g, true);
select(&mut g.f.app, &[g.f.node, g.yn]);
let held = selection(&g.f.app);
let before = g.f.app.project().clone();
let source = g.f.app.document.bundle().clone();
let from = node_point(&g.f.app, g.f.node);
let mut state = CanvasState::default();
let press = begin(&mut g.f.app, &mut state, from);
assert_eq!(press.target, SelectionTarget::Placement(g.f.node));
assert_eq!(selection(&g.f.app), held);
move_pointer(&mut g.f.app, &mut state, from + Vector::new(4., 0.));
let preview =
g.f.app
.allocation_drag
.preview
.as_ref()
.expect(&g.f.app.status);
assert_eq!(preview.placement.delta, xy(0., 0.));
let Candidate::Assignment(candidate) = &preview.candidate else {
panic!("exact work group must preview assignment");
};
assert_eq!(candidate.destination(), plan::Destination::Performer(g.c));
assert_eq!(candidate.changed_count(), 2);
let ids: BTreeMap<_, _> = preview
.project()
.relations
.values()
.filter(|r| r.kind() == RelationKind::Allocation)
.map(|r| (r.target(), r.id))
.collect();
assert_eq!(ids.len(), 2);
move_pointer(&mut g.f.app, &mut state, from);
release(&mut g.f.app, &mut state, from);
let mut expected = before.clone();
c_allocations(&mut expected, &g, &ids);
assert_eq!(selection(&g.f.app), held);
exact_history(&mut g.f.app, &before, &source, &expected);
g.f.app
.document
.execute(Command::Describe(g.y, "Future rationale".into()))
.unwrap();
g.f.app.sync();
let future = g.f.app.document.bundle().clone();
let _ = g.f.app.update(Message::Undo);
assert!(g.f.app.document.can_undo() && g.f.app.document.can_redo());
let snapshot = g.f.app.document.snapshot();
let revision = g.f.app.document.revision();
let held = selection(&g.f.app);
let from = node_point(&g.f.app, g.f.node);
let mut state = CanvasState::default();
begin(&mut g.f.app, &mut state, from);
move_pointer(&mut g.f.app, &mut state, from + Vector::new(4., 0.));
move_pointer(&mut g.f.app, &mut state, from);
release(&mut g.f.app, &mut state, from);
assert!(
Arc::ptr_eq(&snapshot, &g.f.app.document.snapshot()),
"{}",
g.f.app.status
);
assert_eq!(g.f.app.document.revision(), revision);
assert_eq!(selection(&g.f.app), held);
assert!(g.f.app.document.can_undo() && g.f.app.document.can_redo());
let _ = g.f.app.update(Message::Redo);
assert_eq!(g.f.app.document.bundle(), &future);
}
}
#[test]
fn group_source_refusal_retains_primary_source_and_both_history_paths() {
let mut g = group(Layer::Oa);
unassign(&mut g, false);
let source = g.f.app.document.bundle().clone();
let mut limits = oa_core::source_project::ProjectLimits::default();
limits.model.max_source_bytes = source.files.values().map(String::len).sum::<usize>() + 128;
g.f.app.document = crate::document::Document::from_bundle(source, limits).unwrap();
g.f.app
.document
.execute(Command::Describe(g.f.work, "Reviewed".into()))
.unwrap();
g.f.app
.document
.execute(Command::Rename(g.y, "Future Y".into()))
.unwrap();
let future = g.f.app.document.bundle().clone();
assert!(g.f.app.document.undo());
g.f.app.sync();
g.f.app.reconcile_multiselect();
select(&mut g.f.app, &[g.f.node, g.yn]);
let held = selection(&g.f.app);
let before = g.f.app.document.snapshot();
let source = g.f.app.document.bundle().clone();
let revision = g.f.app.document.revision();
assert!(g.f.app.document.can_undo() && g.f.app.document.can_redo());
let from = node_point(&g.f.app, g.f.node);
let to = from + Vector::new(1400., 0.);
let mut state = CanvasState::default();
begin(&mut g.f.app, &mut state, from);
move_pointer(&mut g.f.app, &mut state, to);
assert!(
g.f.app.allocation_drag.preview.is_some(),
"geometry and semantics pass before source refusal: {}",
g.f.app.status
);
release(&mut g.f.app, &mut state, to);
assert!(g.f.app.status.contains("retry"), "{}", g.f.app.status);
assert!(g.f.app.allocation_drag.active.is_none());
assert!(Arc::ptr_eq(&before, &g.f.app.document.snapshot()));
assert_eq!(g.f.app.document.bundle(), &source);
assert_eq!(g.f.app.document.revision(), revision);
assert_eq!(selection(&g.f.app), held);
assert!(g.f.app.document.can_undo() && g.f.app.document.can_redo());
let _ = g.f.app.update(Message::Redo);
assert_eq!(g.f.app.document.bundle(), &future);
let _ = g.f.app.update(Message::Undo);
assert_eq!(g.f.app.document.bundle(), &source);
}
#[test]
fn ordinary_off_grid_performer_out_and_back_is_noop_with_redo() {
for singleton in [false, true] {
let mut g = group(Layer::Oa);
let mut project = g.f.app.project().clone();
project
.diagrams
.get_mut(&g.f.diagram)
.unwrap()
.nodes
.get_mut(&g.an)
.unwrap()
.position = xy(103., 107.);
g.f.app = App::with_project(project);
g.f.app.active_diagram = Some(g.f.diagram);
g.f.app.zoom = 1.;
g.f.app.pan = Vector::ZERO;
g.f.app.fit_to_view = false;
g.f.app
.document
.execute(Command::Describe(g.f.work, "Reviewed".into()))
.unwrap();
g.f.app
.document
.execute(Command::Describe(g.y, "Future".into()))
.unwrap();
let future = g.f.app.document.bundle().clone();
assert!(g.f.app.document.undo());
g.f.app.sync();
g.f.app.reconcile_multiselect();
select(
&mut g.f.app,
&if singleton {
vec![g.an]
} else {
vec![g.an, g.cn]
},
);
let held = selection(&g.f.app);
let before = g.f.app.document.snapshot();
let source = g.f.app.document.bundle().clone();
let revision = g.f.app.document.revision();
let from = node_point(&g.f.app, g.an);
let mut state = CanvasState::default();
begin(&mut g.f.app, &mut state, from);
move_pointer(&mut g.f.app, &mut state, from + Vector::new(20., 0.));
assert!(
g.f.app.allocation_drag.preview.is_some(),
"{}",
g.f.app.status
);
move_pointer(&mut g.f.app, &mut state, from);
assert_eq!(
g.f.app
.allocation_drag
.preview
.as_ref()
.unwrap()
.placement
.delta,
xy(0., 0.)
);
release(&mut g.f.app, &mut state, from);
assert!(Arc::ptr_eq(&before, &g.f.app.document.snapshot()));
assert_eq!(g.f.app.document.bundle(), &source);
assert_eq!(g.f.app.document.revision(), revision);
assert_eq!(selection(&g.f.app), held);
assert!(g.f.app.document.can_undo() && g.f.app.document.can_redo());
let _ = g.f.app.update(Message::Redo);
assert_eq!(g.f.app.document.bundle(), &future);
}
}
@@ -0,0 +1,292 @@
//! Delayed captured drawing packets must not retire a newer Allocation owner.
use super::*;
use crate::connection_edit::Event as ConnectionEvent;
use crate::connection_tool::Tool;
fn drawing_tool(layer: Layer) -> Tool {
match layer {
Layer::Oa => Tool::Interaction,
Layer::Sa => Tool::FunctionalExchange,
}
}
fn drawing_source(g: &mut Group, layer: Layer) -> crate::route_edit::Context {
let _ =
g.f.app
.update(Message::Connection(ConnectionEvent::Choose(Some(
drawing_tool(layer),
))));
let context = g.f.app.route_context().unwrap();
let _ =
g.f.app
.update(Message::Connection(ConnectionEvent::Endpoint(
context, g.f.work,
)));
let session = g.f.app.connection.as_ref().unwrap();
assert_eq!(session.context, context);
assert_eq!(session.source, Some(g.f.work));
assert!(session.candidates.contains(&g.y));
assert_eq!(g.f.app.route_context(), Some(context));
context
}
fn assert_captured_noop(app: &mut App, captured: crate::route_edit::Context, target: Id) {
for event in [
ConnectionEvent::Endpoint(captured, target),
ConnectionEvent::Restart(captured),
] {
assert_connection_noop(app, event);
}
}
fn assert_connection_noop(app: &mut App, event: ConnectionEvent) {
let document = app.document.snapshot();
let selected = selection(app);
let context = app.route_context();
let camera = (app.pan, app.zoom, app.canvas_epoch, app.camera_epoch);
let ownership = (app.allocation_drag.epoch, app.allocation_drag.arrange_next);
let active = app
.allocation_drag
.active
.as_ref()
.map(|a| (a.press.clone(), a.crossed, a.cache.len()));
let preview = app.allocation_drag.preview.as_ref().map(|p| {
(
p.token,
p.placement.diagram.clone(),
p.placement.delta,
p.project().clone(),
p.routed.clone(),
)
});
let feedback = app.allocation_drag.feedback.as_ref().map(|f| {
(
f.press.token,
f.text.clone(),
f.refused,
f.targets.clone(),
f.inspection_targets.clone(),
)
});
let connection = |app: &App| {
app.connection
.as_ref()
.map(|s| (s.context, s.tool, s.source, s.candidates.clone()))
};
let session = connection(app);
let interaction = app.source_symbols.interaction;
let status = app.status.clone();
let _ = app.update(Message::Connection(event));
assert_eq!(selection(app), selected);
assert_eq!(app.route_context(), context);
assert_eq!(
(app.pan, app.zoom, app.canvas_epoch, app.camera_epoch),
camera
);
assert_eq!(
(app.allocation_drag.epoch, app.allocation_drag.arrange_next),
ownership,
"rejected drawing packet must not retire the newer input owner"
);
match (active, app.allocation_drag.active.as_ref()) {
(Some((press, crossed, cached)), Some(current)) => {
assert!(Arc::ptr_eq(&press, &current.press));
assert_eq!((current.crossed, current.cache.len()), (crossed, cached));
}
(None, None) => {}
_ => panic!("rejected drawing packet changed the active Allocation token"),
}
match (preview, app.allocation_drag.preview.as_ref()) {
(Some((token, diagram, delta, project, routed)), Some(current)) => {
assert_eq!(current.token, token);
assert_eq!(current.placement.diagram, diagram);
assert_eq!(current.placement.delta, delta);
assert_eq!(current.project(), &project);
assert!(Arc::ptr_eq(&current.routed, &routed));
}
(None, None) => {}
_ => panic!("rejected drawing packet changed the Allocation preview"),
}
assert_eq!(
app.allocation_drag.feedback.as_ref().map(|f| (
f.press.token,
f.text.clone(),
f.refused,
f.targets.clone(),
f.inspection_targets.clone(),
)),
feedback
);
assert_eq!(connection(app), session);
assert_eq!(app.source_symbols.interaction, interaction);
assert_eq!(app.status, status);
assert!(Arc::ptr_eq(&document, &app.document.snapshot()));
assert_eq!(app.source_workspace.bundle(), document.bundle);
}
#[test]
fn captured_connection_after_cancel_preserves_arrange_arm_and_live_group() {
for layer in [Layer::Oa, Layer::Sa] {
let mut g = group(layer);
let old = drawing_source(&mut g, layer);
let _ = g.f.app.update(Message::Escape);
assert!(g.f.app.connection.is_none());
select(&mut g.f.app, &[g.an, g.yn]);
let _ = g.f.app.update(Message::WorkDrag(Event::ArrangeNext));
assert!(g.f.app.allocation_drag.arrange_next);
let current = g.f.app.route_context().unwrap();
assert_captured_noop(&mut g.f.app, old, g.y);
// Matching context with no session isolates the missing-owner guard.
assert_captured_noop(&mut g.f.app, current, g.y);
let before = g.f.app.project().clone();
let source = g.f.app.document.bundle().clone();
let held = selection(&g.f.app);
let from = node_point(&g.f.app, g.an);
let to = from + Vector::new(400., 0.);
let mut state = CanvasState::default();
let press = begin(&mut g.f.app, &mut state, from);
assert_eq!(press.mode, Mode::Arrange);
assert!(!g.f.app.allocation_drag.arrange_next);
move_pointer(&mut g.f.app, &mut state, to);
assert!(matches!(
g.f.app
.allocation_drag
.preview
.as_ref()
.map(|p| &p.candidate),
Some(Candidate::Arrange(_))
));
let current = g.f.app.route_context().unwrap();
assert_captured_noop(&mut g.f.app, old, g.y);
assert_captured_noop(&mut g.f.app, current, g.y);
release(&mut g.f.app, &mut state, to);
let mut expected = before.clone();
let diagram = expected.diagrams.get_mut(&g.f.diagram).unwrap();
diagram.nodes.get_mut(&g.an).unwrap().position = xy(500., 100.);
diagram.nodes.get_mut(&g.f.node).unwrap().position = xy(540., 200.);
diagram.nodes.get_mut(&g.yn).unwrap().position = xy(540., 600.);
assert_eq!(selection(&g.f.app), held);
exact_history(&mut g.f.app, &before, &source, &expected);
}
}
#[test]
fn captured_connection_context_mismatches_preserve_the_current_session() {
let mut g = group(Layer::Oa);
let current = drawing_source(&mut g, Layer::Oa);
// Session and current context agree: each old captured field is the sole
// invalid relationship, not hidden behind a missing session.
for captured in [
crate::route_edit::Context {
document: Id::new_v4(),
..current
},
crate::route_edit::Context {
diagram: Id::new_v4(),
..current
},
crate::route_edit::Context {
revision: current.revision + 1,
..current
},
crate::route_edit::Context {
epoch: current.epoch + 1,
..current
},
] {
assert_eq!(g.f.app.connection.as_ref().unwrap().context, current);
assert_captured_noop(&mut g.f.app, captured, g.y);
}
// Captured/session contexts agree, but the rendered view has advanced.
// This isolates the live-route predicate from session ownership.
g.f.app.canvas_epoch += 1;
assert_ne!(g.f.app.route_context(), Some(current));
assert_eq!(g.f.app.connection.as_ref().unwrap().context, current);
assert_captured_noop(&mut g.f.app, current, g.y);
g.f.app.canvas_epoch = current.epoch;
// Conversely, incoming/current contexts agree. Retain obsolete session
// metadata explicitly to isolate the session-context admission branch.
g.f.app.connection.as_mut().unwrap().context.epoch += 1;
assert_eq!(g.f.app.route_context(), Some(current));
assert_captured_noop(&mut g.f.app, current, g.y);
}
#[test]
fn captured_connection_current_restart_and_pair_commit_after_tool_boundary() {
for begin_tool in [false, true] {
let mut g = group(Layer::Oa);
select(&mut g.f.app, &[g.an, g.yn]);
let _ = g.f.app.update(Message::WorkDrag(Event::ArrangeNext));
let from = node_point(&g.f.app, g.an);
let mut state = CanvasState::default();
begin(&mut g.f.app, &mut state, from);
move_pointer(&mut g.f.app, &mut state, from + Vector::new(400., 0.));
assert!(g.f.app.allocation_drag.preview.is_some());
let before = g.f.app.project().clone();
let source = g.f.app.document.bundle().clone();
let epoch = g.f.app.allocation_drag.epoch;
let event = if begin_tool {
ConnectionEvent::Begin(Tool::Interaction, g.f.work)
} else {
ConnectionEvent::Choose(Some(Tool::Interaction))
};
let _ = g.f.app.update(Message::Connection(event));
assert!(g.f.app.allocation_drag.epoch > epoch);
assert!(g.f.app.allocation_drag.active.is_none());
assert!(g.f.app.allocation_drag.preview.is_none());
assert!(!g.f.app.allocation_drag.arrange_next);
let context = g.f.app.route_context().unwrap();
if !begin_tool {
let _ =
g.f.app
.update(Message::Connection(ConnectionEvent::Endpoint(
context, g.f.work,
)));
}
assert_eq!(g.f.app.connection.as_ref().unwrap().source, Some(g.f.work));
assert!(
g.f.app
.connection
.as_ref()
.unwrap()
.candidates
.contains(&g.y)
);
let document = g.f.app.document.snapshot();
let _ =
g.f.app
.update(Message::Connection(ConnectionEvent::Restart(context)));
let context = g.f.app.route_context().unwrap();
let restarted = g.f.app.connection.as_ref().unwrap();
assert_eq!(restarted.context, context);
assert_eq!(restarted.tool, Tool::Interaction);
assert_eq!(restarted.source, None);
assert!(restarted.candidates.contains(&g.f.work));
assert!(Arc::ptr_eq(&document, &g.f.app.document.snapshot()));
let _ =
g.f.app
.update(Message::Connection(ConnectionEvent::Endpoint(
context, g.f.work,
)));
assert_eq!(g.f.app.connection.as_ref().unwrap().source, Some(g.f.work));
let revision = g.f.app.document.revision();
let _ =
g.f.app
.update(Message::Connection(ConnectionEvent::Endpoint(context, g.y)));
assert!(g.f.app.connection.is_none());
assert_eq!(g.f.app.document.revision(), revision + 1);
let relation = &g.f.app.project().relations[&g.f.app.selected];
assert_eq!(relation.kind(), RelationKind::Interaction);
assert_eq!((relation.source(), relation.target()), (g.f.work, g.y));
let mut expected = before.clone();
assert!(
expected
.relations
.insert(relation.id, relation.clone())
.is_none()
);
exact_history(&mut g.f.app, &before, &source, &expected);
}
}
@@ -0,0 +1,274 @@
//! Captured route commits cannot retire a newer Allocation input owner.
use super::*;
use crate::route_edit::{Context, Event as RouteEvent};
fn routed_group() -> (Group, Id) {
let mut g = group(Layer::Oa);
let mut project = g.f.app.project().clone();
let edge = project.relate(
RelationKind::Interaction,
"Reviewed exchange",
g.f.work,
g.y,
);
project
.diagrams
.get_mut(&g.f.diagram)
.unwrap()
.routes
.push(EdgeRoute {
edge,
waypoints: vec![xy(700., 400.)],
label: Some(EdgeLabel {
fraction: 0.4,
offset: xy(5., 10.),
}),
source_attachment: None,
target_attachment: Some(EdgeAttachment {
side: PortAnchorSide::Left,
offset: 0.6,
}),
});
validate_candidate(&project).unwrap();
g.f.app = App::with_project(project);
g.f.app.active_diagram = Some(g.f.diagram);
g.f.app.zoom = 1.;
g.f.app.pan = Vector::ZERO;
g.f.app.fit_to_view = false;
choose(&mut g.f.app, SelectionTarget::Edge(edge), Choice::Replace);
assert_eq!(g.f.app.selected, edge);
let view = projection::project_view(g.f.app.project(), g.f.diagram);
assert!(view.edges.iter().any(|e| e.id == edge));
assert_eq!(
crate::route_edit::attachment_node(
g.f.app.project(),
&g.f.app.project().diagrams[&g.f.diagram],
&view,
edge,
EdgeEnd::Source,
)
.unwrap()
.id,
g.f.node
);
(g, edge)
}
fn packet(g: &Group, edge: Id, captured: Context, attachment: bool) -> RouteEvent {
let mut route =
crate::route_edit::stored_route(&g.f.app.project().diagrams[&g.f.diagram], edge);
if attachment {
route.source_attachment = Some(EdgeAttachment {
side: PortAnchorSide::Top,
offset: 0.25,
});
RouteEvent::CommitAttachment(captured, route, EdgeEnd::Source, g.f.node)
} else {
route.waypoints[0] = xy(750., 450.);
RouteEvent::Commit(captured, route, Some(0))
}
}
fn assert_route_noop(app: &mut App, event: RouteEvent) {
let document = app.document.snapshot();
let selected = selection(app);
let context = app.route_context();
let camera = (app.pan, app.zoom, app.canvas_epoch, app.camera_epoch);
let ownership = (app.allocation_drag.epoch, app.allocation_drag.arrange_next);
let press = app.allocation_drag.active.as_ref().map(|a| a.press.clone());
let preview = app.allocation_drag.preview.clone();
let feedback = app.allocation_drag.feedback.as_ref().map(|f| {
(
f.press.token,
f.text.clone(),
f.refused,
f.targets.clone(),
f.inspection_targets.clone(),
)
});
let handles = (app.selected_bend, app.selected_attachment);
let interaction = app.source_symbols.interaction;
let status = app.status.clone();
let _ = app.update(Message::Route(event));
assert_eq!(selection(app), selected);
assert_eq!(app.route_context(), context);
assert_eq!(
(app.pan, app.zoom, app.canvas_epoch, app.camera_epoch),
camera
);
assert_eq!(
(app.allocation_drag.epoch, app.allocation_drag.arrange_next),
ownership
);
match (press, app.allocation_drag.active.as_ref()) {
(Some(before), Some(after)) => assert!(Arc::ptr_eq(&before, &after.press)),
(None, None) => {}
_ => panic!("stale route packet changed the active Allocation token"),
}
match (preview, app.allocation_drag.preview.as_ref()) {
(Some(before), Some(after)) => assert!(Arc::ptr_eq(&before, after)),
(None, None) => {}
_ => panic!("stale route packet changed the Allocation preview"),
}
assert_eq!(
app.allocation_drag.feedback.as_ref().map(|f| (
f.press.token,
f.text.clone(),
f.refused,
f.targets.clone(),
f.inspection_targets.clone(),
)),
feedback
);
assert_eq!((app.selected_bend, app.selected_attachment), handles);
assert_eq!(app.source_symbols.interaction, interaction);
assert_eq!(app.status, status);
assert!(Arc::ptr_eq(&document, &app.document.snapshot()));
assert_eq!(app.source_workspace.bundle(), document.bundle);
}
fn stale_route_preserves_new_group(attachment: bool, escape: bool) {
let (mut g, edge) = routed_group();
let old = g.f.app.route_context().unwrap();
let delayed = packet(&g, edge, old, attachment);
if escape {
let _ = g.f.app.update(Message::Escape);
assert_ne!(g.f.app.route_context(), Some(old));
}
select(&mut g.f.app, &[g.an, g.yn]);
let _ = g.f.app.update(Message::WorkDrag(Event::ArrangeNext));
assert!(g.f.app.allocation_drag.arrange_next);
if !escape {
assert_eq!(g.f.app.route_context(), Some(old));
assert_ne!(g.f.app.selected, edge);
}
assert_route_noop(&mut g.f.app, delayed.clone());
let before = g.f.app.project().clone();
let source = g.f.app.document.bundle().clone();
let selected = selection(&g.f.app);
let from = node_point(&g.f.app, g.an);
let to = from + Vector::new(400., 0.);
let mut state = CanvasState::default();
let press = begin(&mut g.f.app, &mut state, from);
assert_eq!(press.mode, Mode::Arrange);
move_pointer(&mut g.f.app, &mut state, to);
assert!(g.f.app.allocation_drag.preview.is_some());
if !escape {
assert_eq!(g.f.app.route_context(), Some(old));
assert_ne!(g.f.app.selected, edge);
}
assert_route_noop(&mut g.f.app, delayed);
release(&mut g.f.app, &mut state, to);
let mut expected = before.clone();
let diagram = expected.diagrams.get_mut(&g.f.diagram).unwrap();
diagram.nodes.get_mut(&g.an).unwrap().position = xy(500., 100.);
diagram.nodes.get_mut(&g.f.node).unwrap().position = xy(540., 200.);
diagram.nodes.get_mut(&g.yn).unwrap().position = xy(540., 600.);
// Both endpoint placements move by (400, 0); the saved pin follows once,
// while label offset and endpoint attachment settings stay unchanged.
diagram
.routes
.iter_mut()
.find(|r| r.edge == edge)
.unwrap()
.waypoints = vec![xy(1100., 400.)];
assert_eq!(selection(&g.f.app), selected);
exact_history(&mut g.f.app, &before, &source, &expected);
}
#[test]
fn stale_route_commit_preserves_arrange_arm_and_live_group() {
stale_route_preserves_new_group(false, true);
}
#[test]
fn stale_route_attachment_preserves_arrange_arm_and_live_group() {
stale_route_preserves_new_group(true, true);
}
#[test]
fn same_context_route_commit_after_selection_preserves_arrange_arm_and_live_group() {
stale_route_preserves_new_group(false, false);
}
#[test]
fn same_context_route_attachment_after_selection_preserves_arrange_arm_and_live_group() {
stale_route_preserves_new_group(true, false);
}
#[test]
fn captured_route_context_fields_independently_reject_before_retirement() {
for attachment in [false, true] {
let (mut g, edge) = routed_group();
let current = g.f.app.route_context().unwrap();
// The selected edge and route payload are valid. Each capture mismatch
// is isolated; no nonexistent route session can mask admission.
for captured in [
Context {
document: Id::new_v4(),
..current
},
Context {
diagram: Id::new_v4(),
..current
},
Context {
revision: current.revision + 1,
..current
},
Context {
epoch: current.epoch + 1,
..current
},
] {
let event = packet(&g, edge, captured, attachment);
assert_route_noop(&mut g.f.app, event);
}
// Hold the captured packet fixed and advance the live context instead.
let event = packet(&g, edge, current, attachment);
g.f.app.canvas_epoch += 1;
assert_route_noop(&mut g.f.app, event.clone());
g.f.app.canvas_epoch = current.epoch;
g.f.app.active_diagram = None;
assert_route_noop(&mut g.f.app, event);
}
}
#[test]
fn current_route_commits_without_session_preserve_exact_route_history() {
for attachment in [false, true] {
let (mut g, edge) = routed_group();
let current = g.f.app.route_context().unwrap();
let event = packet(&g, edge, current, attachment);
let before = g.f.app.project().clone();
let source = g.f.app.document.bundle().clone();
let revision = g.f.app.document.revision();
assert!(g.f.app.connection.is_none());
assert!(g.f.app.selected_attachment.is_none());
let _ = g.f.app.update(Message::Route(event));
assert_eq!(g.f.app.document.revision(), revision + 1);
assert_eq!(g.f.app.selected, edge);
assert_eq!(
g.f.app.selected_bend,
if attachment { None } else { Some((edge, 0)) }
);
let mut expected = before.clone();
let route = expected
.diagrams
.get_mut(&g.f.diagram)
.unwrap()
.routes
.iter_mut()
.find(|r| r.edge == edge)
.unwrap();
if attachment {
route.source_attachment = Some(EdgeAttachment {
side: PortAnchorSide::Top,
offset: 0.25,
});
} else {
route.waypoints = vec![xy(750., 450.)];
}
exact_history(&mut g.f.app, &before, &source, &expected);
}
}
@@ -161,7 +161,7 @@ fn stale_gesture_packets_preserve_navigation_and_a_new_live_cancel_still_works()
.update(Message::WorkDrag(Event::Cancel(current.token)));
assert!(f.app.allocation_drag.active.is_none());
assert!(f.app.allocation_drag.epoch > epoch);
assert_eq!(f.app.status, "Work drag canceled");
assert_eq!(f.app.status, "Drag canceled");
assert!(Arc::ptr_eq(&before, &f.app.document.snapshot()));
assert_eq!(f.app.source_workspace.bundle(), before.bundle);
}
@@ -27,30 +27,11 @@ pub enum Event {
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))
pub(super) fn captured_escape_message(app: &App) -> Option<Message> {
app.architecture
.as_ref()
.is_some_and(State::inline)
.then_some(Message::Architecture(Event::CapturedEscape))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
@@ -138,7 +119,7 @@ pub fn validate_saved(p: &Project, s: &Saved) -> Result<(), String> {
.diagrams
.get(&s.diagram)
.ok_or("The creation draft's diagram is missing.")?;
if matches!(d.projection, ProjectionKind::StateMachine { .. })
if !d.projection.is_architecture()
|| s.query.len() > 16384
|| s.page > 1_000_000
|| s.point
@@ -180,7 +161,7 @@ pub fn validate_saved(p: &Project, s: &Saved) -> Result<(), String> {
}
fn context_kinds(p: &Project, d: &Diagram) -> Vec<Kind> {
let mut values = match (d.layer, d.projection) {
(_, ProjectionKind::StateMachine { .. }) => vec![],
(_, ProjectionKind::StateMachine { .. } | ProjectionKind::Scenario { .. }) => vec![],
(Layer::Oa, ProjectionKind::FunctionalDataflow) => vec![Kind::Activity],
(Layer::Sa, ProjectionKind::FunctionalDataflow) => vec![Kind::Function],
(Layer::Oa, ProjectionKind::Capabilities) => vec![
@@ -208,11 +189,12 @@ pub fn kinds(app: &App) -> Vec<Kind> {
.unwrap_or_default()
}
fn compatible(p: &Project, d: &Diagram, id: Id) -> bool {
p.elements.get(&id).is_some_and(|e| {
e.kind() != Kind::Port
&& d.projection.includes(e.kind())
&& (e.layer() == d.layer || d.projection == ProjectionKind::Traceability)
})
d.projection.is_architecture()
&& p.elements.get(&id).is_some_and(|e| {
e.kind() != Kind::Port
&& d.projection.includes(e.kind())
&& (e.layer() == d.layer || d.projection == ProjectionKind::Traceability)
})
}
fn suggested(p: &Project, d: &Diagram, kind: Kind) -> bool {
// Existing System remains a useful choice even when the singleton exists.
@@ -257,6 +239,17 @@ pub fn candidates(app: &App) -> Vec<(Id, String, bool)> {
}
impl App {
pub(super) fn architecture_event(&mut self, event: Event) -> Task<Message> {
if matches!(
&event,
Event::Start(..) | Event::Allocate(..) | Event::Existing(..)
) && !self
.active_diagram
.and_then(|id| self.project().diagrams.get(&id))
.is_some_and(|d| d.projection.is_architecture())
{
self.status = "Choose an architecture view before adding architecture content.".into();
return Task::none();
}
match event {
Event::Start(_, point) | Event::Allocate(_, _, point) | Event::Existing(point) => {
if self.architecture.is_some() {
@@ -301,6 +294,17 @@ impl App {
_ => return Task::none(),
};
let placing = point.is_none() && matches!(intent, Intent::Create { .. });
let filter = if matches!(intent, Intent::Existing { .. })
&& self
.project()
.diagrams
.get(&diagram)
.is_some_and(|d| d.projection == ProjectionKind::Traceability)
{
Filter::AllCompatible
} else {
Filter::Context
};
let saved = Saved {
version: 1,
base: oa_core::transitions::project_fingerprint(self.project()),
@@ -309,7 +313,7 @@ impl App {
point,
placing,
query: String::new(),
filter: Filter::Context,
filter,
show_placed: false,
page: 0,
};
@@ -1,5 +1,6 @@
//! Compact neutral tool palette. Tool state never changes its allocated width.
use super::*;
use crate::connection_tool::Tool;
use iced::{
Point, Rectangle, Size, Theme, mouse,
widget::{
@@ -11,7 +12,7 @@ use iced::{
#[derive(Clone, Copy)]
enum Icon {
Element(Kind),
Arrow,
Connection(Tool),
Existing,
Select,
Collapse(bool),
@@ -76,10 +77,59 @@ impl canvas::Program<Message> for Icon {
);
}
}
Self::Arrow => {
f.stroke(&line(&[(1., 8.), (14., 8.), (10., 4.)]), stroke);
f.stroke(&line(&[(14., 8.), (10., 12.)]), stroke);
}
Self::Connection(connection) => match connection {
// The OA and SA work-flow tools share a visual family; they
// never coexist in a palette. Keep their small glyph legible.
Tool::Interaction | Tool::FunctionalExchange => {
for x in [1., 11.] {
f.stroke(
&Path::rounded_rectangle(
Point::new(x, 5.),
Size::new(4., 6.),
1.5.into(),
),
stroke,
);
}
f.stroke(&line(&[(5., 8.), (10., 8.)]), stroke);
f.stroke(&line(&[(7.5, 5.5), (10., 8.), (7.5, 10.5)]), stroke);
}
Tool::CommunicationMean => {
// Participant endpoints, without implying a fixed direction.
for x in [1., 11.] {
f.stroke(
&Path::rectangle(Point::new(x, 4.), Size::new(4., 8.)),
stroke,
);
}
f.stroke(&line(&[(5., 8.), (11., 8.)]), stroke);
}
Tool::BoundaryExchange => {
// Ports on facing participant boundaries. Standard exchanges
// are unoriented, so the generic tool has no arrowhead.
for x in [2., 14.] {
f.stroke(&line(&[(x, 2.), (x, 14.)]), stroke);
}
for x in [1., 12.] {
f.stroke(
&Path::rectangle(Point::new(x, 6.), Size::new(3., 4.)),
stroke,
);
}
f.stroke(&line(&[(4., 8.), (12., 8.)]), stroke);
}
Tool::Allocation => {
// Work enters a performer: allocation can appear as nesting
// rather than a relationship line in allocated-work views.
f.stroke(&line(&[(1., 2.), (1., 14.), (15., 14.), (15., 2.)]), stroke);
f.stroke(
&Path::rounded_rectangle(Point::new(5., 9.), Size::new(6., 3.), 1.into()),
stroke,
);
f.stroke(&line(&[(8., 1.), (8., 7.)]), stroke);
f.stroke(&line(&[(5., 4.), (8., 7.), (11., 4.)]), stroke);
}
},
Self::Existing => {
f.stroke(
&Path::rectangle(Point::new(1., 1.), Size::new(10., 10.)),
@@ -229,18 +279,12 @@ pub fn view(app: &App) -> Element<'_, Message> {
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) {
if !connection.available_in(app.layer, d.projection) {
continue;
}
content = content.push(tool(
app,
Icon::Arrow,
Icon::Connection(*connection),
connection.label(),
Message::Connection(crate::connection_edit::Event::Choose(Some(*connection))),
app.connection
@@ -1,4 +1,5 @@
use super::*;
mod traceability;
fn setup(layer: Layer) -> App {
let mut app = App::with_project(Project::empty());
@@ -66,6 +67,7 @@ fn architecture_f2_routes_selected_node_to_inline_rename_and_exact_undo() {
let camera = (app.zoom, app.pan);
let bounds = iced::Rectangle::new(iced::Point::ORIGIN, iced::Size::new(600., 380.));
let canvas = crate::diagram::DiagramCanvas {
selection: None,
project: app.project(),
view_index: None,
diagram: app.active_diagram.unwrap(),
@@ -75,6 +77,7 @@ fn architecture_f2_routes_selected_node_to_inline_rename_and_exact_undo() {
pan: app.pan,
context: app.route_context().unwrap(),
selected_bend: None,
selected_attachment: None,
editable: true,
placing: false,
connection: None,
@@ -231,65 +234,6 @@ fn architecture_stale_submit_keeps_create_and_rename_visible_and_exact() {
}
}
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] {
@@ -309,8 +253,9 @@ fn architecture_first_focused_input_escape_cancels_create_and_rename() {
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);
let mut messages = crate::app::escape::tests::focused_input_messages(&app);
assert_eq!(messages.len(), 1);
let _ = app.update(messages.remove(0));
assert!(app.architecture.is_none());
assert!(inline_editor(&app).is_none());
assert_eq!(app.status, "Cancelled · Select / move");
@@ -340,8 +285,8 @@ fn architecture_captured_escape_does_not_cancel_other_controls_or_placement() {
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);
let messages = crate::app::escape::tests::focused_input_messages(&app);
assert!(messages.is_empty());
assert_eq!(
app.architecture
.as_ref()
@@ -353,19 +298,7 @@ fn architecture_captured_escape_does_not_cancel_other_controls_or_placement() {
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()
crate::app::escape::tests::focused_input_key(&app, iced::keyboard::key::Named::Enter,)
.is_empty()
);
}
@@ -0,0 +1,124 @@
use super::*;
#[test]
fn traceability_existing_defaults_show_opposite_layer_and_place_only_one_node() {
let mut project = Project::empty();
let sa = project
.add_element(Kind::Function, Layer::Sa, "Deliver report", None)
.unwrap();
let system = project
.add_element(Kind::System, Layer::Sa, "System", None)
.unwrap();
let diagram = project.ensure_diagram(Layer::Oa);
project.diagrams.get_mut(&diagram).unwrap().projection = ProjectionKind::Traceability;
let mut app = App::with_project(project);
app.page = Page::Diagram;
app.layer = Layer::Oa;
app.active_diagram = Some(diagram);
let before = app.document.snapshot();
send(&mut app, Event::Existing(None));
assert_eq!(
app.architecture.as_ref().unwrap().saved.filter,
Filter::AllCompatible
);
let listed = candidates(&app);
assert!(listed.iter().any(|(id, _, shown)| *id == sa && !shown));
assert!(listed.iter().any(|(id, _, shown)| *id == system && !shown));
send(&mut app, Event::Pick(sa));
assert!(app.architecture.as_ref().unwrap().saved.placing);
assert_eq!(app.project(), &before.decoded.project);
let context = app.route_context().unwrap();
let point = Position { x: 345., y: 215. };
send(&mut app, Event::Click(context, point));
assert!(app.architecture.is_none(), "{}", app.status);
assert_eq!(app.active_diagram, Some(diagram));
let placed = app.project().diagrams[&diagram]
.nodes
.values()
.find(|n| n.element == sa)
.unwrap()
.clone();
assert_eq!(placed.position, point);
let mut expected = before.decoded.project.clone();
expected
.diagrams
.get_mut(&diagram)
.unwrap()
.nodes
.insert(placed.id, placed);
assert_eq!(app.project(), &expected);
let accepted = app.document.bundle().clone();
let _ = app.update(Message::Undo);
assert_eq!(app.document.bundle(), &before.bundle);
assert!(!app.document.can_undo());
let _ = app.update(Message::Redo);
assert_eq!(app.project(), &expected);
assert_eq!(app.document.bundle(), &accepted);
}
#[test]
fn traceability_default_does_not_change_creation_or_ordinary_picker_guards() {
let mut app = setup(Layer::Oa);
let diagram = app.active_diagram.unwrap();
send(&mut app, Event::Existing(None));
assert_eq!(
app.architecture.as_ref().unwrap().saved.filter,
Filter::Context
);
send(&mut app, Event::Cancel);
let _ = app.update(Message::Projection(ProjectionKind::Traceability));
send(&mut app, Event::Start(Kind::Entity, None));
assert_eq!(
app.architecture.as_ref().unwrap().saved.filter,
Filter::Context
);
send(&mut app, Event::Cancel);
assert_eq!(app.active_diagram, Some(diagram));
let mut project = app.project().clone();
let sa = project
.add_element(Kind::Function, Layer::Sa, "Other layer", None)
.unwrap();
let port = Id::new_v4();
project.elements.insert(
port,
oa_core::Element {
id: port,
alias: "legacy_port".into(),
name: "Port".into(),
description: String::new(),
parent: Some(sa),
body: ElementBody::shared_in(
Layer::Sa,
SharedBody::Port {
contract: PortContract::Legacy,
legacy: Some(LegacyPortContract {
direction: PortDirection::InOut,
interface: None,
}),
},
),
},
);
oa_core::validate_candidate(&project).unwrap();
for projection in [ProjectionKind::Overview, ProjectionKind::Traceability] {
let mut p = project.clone();
p.diagrams.get_mut(&diagram).unwrap().projection = projection;
let mut trial = App::with_project(p);
trial.page = Page::Diagram;
trial.active_diagram = Some(diagram);
trial.layer = Layer::Oa;
let before = trial.document.bundle().clone();
send(&mut trial, Event::Existing(None));
send(&mut trial, Event::Filter(Filter::AllCompatible));
assert_eq!(
candidates(&trial).iter().any(|(id, _, _)| *id == sa),
projection == ProjectionKind::Traceability
);
assert!(!candidates(&trial).iter().any(|(id, _, _)| *id == port));
send(&mut trial, Event::Pick(port));
assert!(!trial.architecture.as_ref().unwrap().saved.placing);
send(&mut trial, Event::Cancel);
assert_eq!(trial.document.bundle(), &before);
}
}
@@ -221,6 +221,7 @@ fn architecture_canvas_left_click_uses_world_coordinate_once_at_zoom_and_pan() {
y: (local.y - app.pan.y) / app.zoom,
};
let canvas = crate::diagram::DiagramCanvas {
selection: None,
project: app.project(),
view_index: None,
diagram: app.active_diagram.unwrap(),
@@ -230,6 +231,7 @@ fn architecture_canvas_left_click_uses_world_coordinate_once_at_zoom_and_pan() {
pan: app.pan,
context: app.route_context().unwrap(),
selected_bend: None,
selected_attachment: None,
editable: true,
placing: true,
connection: None,
@@ -297,6 +299,7 @@ fn architecture_f2_rename_preserves_identity_placement_and_atomic_history() {
let diagrams = app.project().diagrams.clone();
let camera = (app.zoom, app.pan);
let canvas = crate::diagram::DiagramCanvas {
selection: None,
project: app.project(),
view_index: None,
diagram: app.active_diagram.unwrap(),
@@ -306,6 +309,7 @@ fn architecture_f2_rename_preserves_identity_placement_and_atomic_history() {
pan: app.pan,
context: app.route_context().unwrap(),
selected_bend: None,
selected_attachment: None,
editable: true,
placing: false,
connection: None,
@@ -180,10 +180,36 @@ async fn mounted_scenario_use_delete_keeps_definition_and_second_use_and_rejects
send(&mut mounted, &mut app, Message::ConfirmDelete(stale)).await;
assert!(app.delete_pending, "old token does not cancel new dialog");
// Keep selection and epoch deliberately unchanged: owner is an independent witness.
let captured = app.scenario_context().unwrap();
assert_eq!(captured.scenario, owners[0]);
assert_eq!(
app.delete_target.as_ref().unwrap().behavior_owner,
Some(owners[0])
);
app.behavior.active_owner = Some(owners[1]);
app.reconcile_scenario_workspace();
let other = app.scenario_context().unwrap();
assert_eq!(other.scenario, owners[1]);
assert_eq!(
other.representation,
crate::behavior::scenario_layout::Representation::Derived { owner: owners[1] }
);
assert_eq!(other.epoch, captured.epoch);
assert_eq!(app.selected, uses[0]);
assert!(
app.delete_target.as_ref().unwrap().snapshot.valid(&app),
"the independent owner guard must reject an otherwise current token"
);
send(&mut mounted, &mut app, Message::ConfirmDelete(token)).await;
assert_eq!(app.document.bundle(), &original);
assert!(!app.delete_pending && app.delete_target.is_none());
assert!(!app.document.can_undo() && !app.document.can_redo());
app.behavior.active_owner = Some(owners[0]);
// Restore both owner and qualified presentation before mounting pointer input.
// Passive reports from the previous owner cannot repair an inconsistent fixture.
app.reconcile_scenario_workspace();
assert_eq!(app.scenario_context().unwrap().scenario, owners[0]);
assert_eq!(app.selected, uses[0]);
mounted.settle(&mut app).await;
select(&mut mounted, &mut app, uses[0]).await;
let token = delete(&mut mounted, &mut app, false).await;
@@ -518,9 +544,16 @@ async fn mounted_qualified_process_child_delete_refuses_outer_reference_and_shar
.await;
assert!(messages.iter().any(|message| matches!(
message,
Message::TopologyCanvas(topology::CanvasEvent::SelectQualified(..))
Message::DiagramSelection(multiselect::Event::Choose(
_, crate::diagram_usage::Target::ProcessOccurrence(actual), multiselect::Choice::Replace
)) if actual == &endpoint
)));
assert_eq!(app.selected, references[0]);
assert_eq!(app.selected, f.nodes[0]);
assert!(app.draft.is_none(), "Qualified inspection stays read-only");
assert_eq!(
app.behavior.topology.selection_address().unwrap().endpoint,
endpoint
);
assert!(
app.behavior
.topology
@@ -0,0 +1,904 @@
//! Canvas inspection retains the displayed mixed-layer surface; navigation follows the model.
use super::*;
use crate::diagram_usage::Target;
use crate::model_menu::{Event as MenuEvent, Request};
struct Fixture {
app: App,
diagram: Id,
process: Id,
placement: Id,
edge: Id,
}
fn fixture() -> Fixture {
let mut p = Project::empty();
let process = p
.add_element(Kind::Process, Layer::Oa, "Operational process", None)
.unwrap();
let capability = p
.add_element(Kind::Capability, Layer::Oa, "Operational capability", None)
.unwrap();
let edge = p.relate(
RelationKind::ChainInvolvement,
"Enables",
process,
capability,
);
p.ensure_diagram(Layer::Oa);
let diagram = p.ensure_diagram(Layer::Sa);
p.diagrams.get_mut(&diagram).unwrap().projection = ProjectionKind::Traceability;
let placement = p.place(diagram, process, Position { x: 0., y: 0. });
p.place(diagram, capability, Position { x: 500., y: 0. });
let mut app = App::with_project(p);
app.page = Page::Diagram;
app.layer = Layer::Sa;
app.active_diagram = Some(diagram);
app.selected = diagram;
app.refresh_draft();
app.reconcile_multiselect();
assert!(
project_view(app.project(), diagram)
.edges
.iter()
.any(|e| e.id == edge)
);
Fixture {
app,
diagram,
process,
placement,
edge,
}
}
fn assert_surface(f: &Fixture) {
assert_eq!(f.app.page, Page::Diagram);
assert_eq!(f.app.layer, Layer::Sa);
assert_eq!(f.app.active_diagram, Some(f.diagram));
}
#[test]
fn mixed_projection_switch_restores_all_authored_content_and_one_step_history() {
let mut f = fixture();
let before = f.app.document.snapshot();
let mut expected = before.decoded.project.clone();
expected.diagrams.get_mut(&f.diagram).unwrap().projection = ProjectionKind::Allocation;
let _ = f
.app
.update(Message::Projection(ProjectionKind::Allocation));
assert_surface(&f);
assert_eq!(f.app.project(), &expected);
assert!(project_view(f.app.project(), f.diagram).nodes.is_empty());
assert_eq!(f.app.project().diagrams[&f.diagram].nodes.len(), 2);
let switched = f.app.document.snapshot();
let _ = f.app.update(Message::Undo);
assert_eq!(f.app.project(), &before.decoded.project);
assert_eq!(f.app.document.bundle(), &before.bundle);
assert!(!f.app.document.can_undo());
let _ = f.app.update(Message::Redo);
assert_eq!(f.app.project(), &switched.decoded.project);
assert_eq!(f.app.document.bundle(), &switched.bundle);
let _ = f
.app
.update(Message::Projection(ProjectionKind::Traceability));
assert_surface(&f);
assert_eq!(f.app.project(), &before.decoded.project);
assert!(
project_view(f.app.project(), f.diagram)
.edges
.iter()
.any(|e| e.id == f.edge)
);
}
#[test]
fn mixed_canvas_edge_and_group_inspection_preserve_surface_and_history() {
for grouped in [false, true] {
let mut f = fixture();
let before = f.app.document.snapshot();
let revision = f.app.document.revision();
if grouped {
let _ = f
.app
.update(Message::DiagramSelection(multiselect::Event::Choose(
f.app.multiselect_context().unwrap(),
Target::Placement(f.placement),
multiselect::Choice::Replace,
)));
}
let message = if grouped {
Message::DiagramSelection(multiselect::Event::Choose(
f.app.multiselect_context().unwrap(),
Target::Edge(f.edge),
multiselect::Choice::Add,
))
} else {
Message::CanvasSelect(f.app.route_context().unwrap(), f.edge)
};
let _ = f.app.update(message);
assert_surface(&f);
assert_eq!(f.app.selected, f.edge);
assert_eq!(f.app.draft.as_ref().unwrap().original, f.edge);
assert_eq!(
f.app.multiselect_snapshot().unwrap().primary(),
Some(&Target::Edge(f.edge))
);
assert_eq!(
f.app.multiselect_snapshot().unwrap().targets().len(),
if grouped { 2 } else { 1 }
);
assert_eq!(f.app.project(), &before.decoded.project);
assert_eq!(f.app.document.bundle(), &before.bundle);
assert_eq!(f.app.document.revision(), revision);
assert!(!f.app.document.can_undo());
}
}
fn invoke(f: &mut Fixture, object: Id, placement: Option<Id>, action: Message) {
let mut request = Request::object(object, iced::Point::ORIGIN);
request.canvas = f.app.route_context();
request.placement = placement;
let _ = f.app.update(Message::Menu(MenuEvent::Open(request)));
let token = f.app.model_menu.as_ref().unwrap().token;
let _ = f
.app
.update(Message::Menu(MenuEvent::Invoke(token, Box::new(action))));
}
#[test]
fn mixed_canvas_context_properties_and_hide_keep_the_captured_surface() {
let mut f = fixture();
let edge = f.edge;
invoke(&mut f, edge, None, Message::EditProperties(edge));
assert_surface(&f);
assert_eq!(f.app.selected, edge);
assert!(!f.app.document.can_undo());
let before = f.app.document.snapshot();
let process = f.process;
let placement = f.placement;
invoke(&mut f, process, Some(placement), Message::HideSelected);
assert_surface(&f);
assert!(
!f.app.project().diagrams[&f.diagram]
.nodes
.contains_key(&placement)
);
assert!(f.app.project().elements.contains_key(&process));
let _ = f.app.update(Message::Undo);
assert_eq!(f.app.project(), &before.decoded.project);
assert_eq!(f.app.document.bundle(), &before.bundle);
}
#[test]
fn mixed_canvas_explicit_navigation_still_follows_the_target_layer() {
let mut f = fixture();
let process = f.process;
let edge = f.edge;
invoke(&mut f, edge, None, Message::Select(process));
assert_eq!(f.app.selected, process);
assert_eq!(f.app.layer, Layer::Oa);
assert_ne!(f.app.active_diagram, Some(f.diagram));
}
#[test]
fn explicit_navigation_to_the_inspected_cross_layer_object_follows_its_layer() {
let mut f = fixture();
let _ = f.app.update(Message::CanvasSelect(
f.app.route_context().unwrap(),
f.edge,
));
assert_surface(&f);
assert_eq!(f.app.selected, f.edge);
let before = f.app.document.snapshot();
let _ = f.app.update(Message::Select(f.edge));
assert_eq!(f.app.selected, f.edge);
assert_eq!(f.app.layer, Layer::Oa);
assert_ne!(f.app.active_diagram, Some(f.diagram));
assert_eq!(f.app.project(), &before.decoded.project);
assert_eq!(f.app.document.bundle(), &before.bundle);
assert!(!f.app.document.can_undo());
}
#[test]
fn mixed_canvas_invalid_draft_and_stale_packets_preserve_selection() {
let mut f = fixture();
let context = f.app.route_context().unwrap();
let _ = f.app.update(Message::Field("/id".into(), "-".into()));
let draft = serde_json::to_value(f.app.draft.as_ref().unwrap().recovery_snapshot()).unwrap();
let _ = f.app.update(Message::CanvasSelect(context, f.edge));
assert_eq!(f.app.selected, f.diagram);
assert_surface(&f);
assert_eq!(
serde_json::to_value(f.app.draft.as_ref().unwrap().recovery_snapshot()).unwrap(),
draft
);
let _ = f
.app
.update(Message::DiagramSelection(multiselect::Event::Choose(
f.app.multiselect_context().unwrap(),
Target::Edge(f.edge),
multiselect::Choice::Replace,
)));
assert_eq!(f.app.selected, f.diagram);
assert_eq!(
serde_json::to_value(f.app.draft.as_ref().unwrap().recovery_snapshot()).unwrap(),
draft
);
let _ = f.app.update(Message::RevertProperties);
f.app.canvas_epoch += 1;
let _ = f.app.update(Message::CanvasSelect(context, f.edge));
assert_eq!(f.app.selected, f.diagram);
assert!(!f.app.document.can_undo());
}
#[test]
fn cross_layer_port_inspection_and_connection_start_retain_the_view() {
let mut p = Project::empty();
let owner = p
.add_element(Kind::Function, Layer::Sa, "Send", None)
.unwrap();
let port = Id::new_v4();
let element = make_port(
&p,
port,
owner,
"Output",
PortContract::FunctionOutput {
outgoing_items: vec![],
},
)
.unwrap();
p.elements.insert(port, element);
p.ensure_diagram(Layer::Sa);
let diagram = p.ensure_diagram(Layer::Oa);
p.diagrams.get_mut(&diagram).unwrap().projection = ProjectionKind::Traceability;
let placement = p.place(diagram, owner, Position { x: 0., y: 0. });
let mut app = App::with_project(p);
app.page = Page::Diagram;
app.layer = Layer::Oa;
app.active_diagram = Some(diagram);
app.selected = diagram;
app.refresh_draft();
app.reconcile_multiselect();
assert!(
project_view(app.project(), diagram)
.ports
.iter()
.any(|p| p.element == port)
);
let _ = app.update(Message::CanvasSelect(app.route_context().unwrap(), port));
assert_eq!(app.selected, port);
assert_eq!(app.active_diagram, Some(diagram));
assert_eq!(app.layer, Layer::Oa);
assert_eq!(
app.multiselect_snapshot().unwrap().primary(),
Some(&Target::Port { port, placement })
);
// Reset inspection so the endpoint path cannot pass by selecting the same ID.
app.selected = diagram;
app.refresh_draft();
let context = app.route_context().unwrap();
// Mixed canvases can retain a tool from either perspective.
app.connection = Some(crate::connection_edit::Session::new(
app.project(),
&project_view(app.project(), diagram),
context,
crate::connection_tool::Tool::FunctionalExchange,
));
let _ = app.update(Message::Connection(
crate::connection_edit::Event::Endpoint(context, port),
));
assert_eq!(app.selected, port);
assert_eq!(app.layer, Layer::Oa);
assert_eq!(app.active_diagram, Some(diagram));
assert_eq!(app.connection.as_ref().unwrap().source, Some(port));
assert!(!app.document.can_undo());
}
fn authoring_fixture(performer: bool) -> (Fixture, Id) {
let mut f = fixture();
let mut project = f.app.project().clone();
let owner = project
.add_element(
if performer {
Kind::Entity
} else {
Kind::Activity
},
Layer::Oa,
"Operational owner",
None,
)
.unwrap();
let target = project
.add_element(Kind::Activity, Layer::Oa, "Operational target", None)
.unwrap();
let placement = project.place(f.diagram, owner, Position { x: 0., y: 400. });
project.place(f.diagram, target, Position { x: 500., y: 400. });
f.app = App::with_project(project);
f.app.page = Page::Diagram;
f.app.layer = Layer::Sa;
f.app.active_diagram = Some(f.diagram);
f.app.selected = f.diagram;
f.app.refresh_draft();
f.app.reconcile_multiselect();
f.process = owner;
f.placement = placement;
(f, target)
}
fn assert_source_pair(app: &App) {
let compiled = oa_core::source_project::compile_bundle(
app.document.bundle(),
oa_core::source_project::ProjectLimits::default(),
)
.unwrap();
assert_eq!(&compiled.decoded.project, app.project());
assert_eq!(&compiled.bundle, app.document.bundle());
}
#[test]
fn mixed_canvas_relationship_actions_keep_operational_source_and_commit_one_step() {
for context_action in [false, true] {
let (mut f, target) = authoring_fixture(false);
let before = f.app.document.snapshot();
let owner = f.process;
let placement = f.placement;
if context_action {
invoke(
&mut f,
owner,
Some(placement),
Message::AddRelationKind(RelationKind::Interaction, false),
);
} else {
let _ = f.app.update(Message::SelectNode(placement));
let _ = f.app.update(Message::AddRelation);
}
assert_surface(&f);
let Some(crate::workflow::Workflow::Relation(relation)) = &f.app.workflow else {
panic!("relationship form did not open: {}", f.app.status);
};
assert_eq!(
relation.source.as_ref().map(|source| source.id),
Some(owner)
);
assert_eq!(relation.kind, RelationKind::Interaction);
let saved = f
.app
.workflow
.as_ref()
.unwrap()
.recovery_snapshot()
.unwrap();
assert_eq!(saved["Relation"]["layer"], "Oa");
let _ = f
.app
.update(Message::Workflow(crate::workflow::Event::Target(Choice {
id: target,
label: "Operational target".into(),
})));
let _ = f
.app
.update(Message::Workflow(crate::workflow::Event::Apply));
assert!(f.app.workflow.is_none(), "{}", f.app.status);
let created: Vec<_> = f
.app
.project()
.relations
.values()
.filter(|relation| !before.decoded.project.relations.contains_key(&relation.id))
.collect();
assert_eq!(created.len(), 1);
assert_eq!(
(created[0].kind(), created[0].source(), created[0].target()),
(RelationKind::Interaction, owner, target)
);
assert_source_pair(&f.app);
let _ = f.app.update(Message::Undo);
assert_eq!(f.app.project(), &before.decoded.project);
assert_eq!(f.app.document.bundle(), &before.bundle);
assert!(!f.app.document.can_undo());
}
}
#[test]
fn mixed_canvas_add_child_command_uses_parent_layer_for_containment_and_allocation() {
for performer in [false, true] {
let (mut f, _) = authoring_fixture(performer);
let before = f.app.document.snapshot();
let owner = f.process;
let placement = f.placement;
invoke(
&mut f,
owner,
Some(placement),
Message::CreateContextElement(Kind::Activity, true),
);
assert_surface(&f);
let created: Vec<_> = f
.app
.project()
.elements
.values()
.filter(|element| !before.decoded.project.elements.contains_key(&element.id))
.collect();
assert_eq!(created.len(), 1, "{}", f.app.status);
let child = created[0].id;
assert_eq!(created[0].kind(), Kind::Activity);
assert_eq!(created[0].layer(), Layer::Oa);
assert_eq!(
created[0].parent,
if performer { None } else { Some(owner) }
);
let allocations: Vec<_> = f
.app
.project()
.relations
.values()
.filter(|relation| !before.decoded.project.relations.contains_key(&relation.id))
.collect();
assert_eq!(allocations.len(), usize::from(performer));
if performer {
assert_eq!(
(
allocations[0].kind(),
allocations[0].source(),
allocations[0].target()
),
(RelationKind::Allocation, owner, child)
);
}
assert!(
f.app.project().diagrams[&f.diagram]
.nodes
.values()
.any(|node| node.element == child)
);
assert_source_pair(&f.app);
let _ = f.app.update(Message::Undo);
assert_eq!(f.app.project(), &before.decoded.project);
assert_eq!(f.app.document.bundle(), &before.bundle);
assert!(!f.app.document.can_undo());
}
}
#[test]
fn mixed_canvas_root_creation_still_uses_the_active_perspective() {
let (mut f, _) = authoring_fixture(false);
let _ = f.app.update(Message::SelectNode(f.placement));
let before = f.app.document.snapshot();
let _ = f
.app
.update(Message::CreateContextElement(Kind::Function, false));
assert_surface(&f);
let created: Vec<_> = f
.app
.project()
.elements
.values()
.filter(|element| !before.decoded.project.elements.contains_key(&element.id))
.collect();
assert_eq!(created.len(), 1, "{}", f.app.status);
assert_eq!(created[0].kind(), Kind::Function);
assert_eq!(created[0].layer(), Layer::Sa);
assert_eq!(created[0].parent, None);
assert_source_pair(&f.app);
let _ = f.app.update(Message::Undo);
assert_eq!(f.app.project(), &before.decoded.project);
assert_eq!(f.app.document.bundle(), &before.bundle);
}
#[test]
fn child_creation_navigates_to_a_parent_layer_preferred_view_only_when_needed() {
for existing_preferred in [false, true] {
let (mut f, _) = authoring_fixture(false);
let mut project = f.app.project().clone();
for diagram in project
.diagrams
.values_mut()
.filter(|d| d.layer == Layer::Oa)
{
diagram.projection = if existing_preferred {
ProjectionKind::Allocation
} else {
ProjectionKind::Overview
};
}
project.diagrams.get_mut(&f.diagram).unwrap().projection = ProjectionKind::Capabilities;
f.app = App::with_project(project);
let _ = f.app.update(Message::Select(f.process));
let _ = f.app.update(Message::Diagram(Choice {
id: f.diagram,
label: "System capabilities".into(),
}));
assert_eq!(f.app.selected, f.process);
assert_eq!(f.app.layer, Layer::Sa);
assert_eq!(f.app.active_diagram, Some(f.diagram));
let before = f.app.document.snapshot();
let _ = f
.app
.update(Message::CreateContextElement(Kind::Activity, true));
assert_eq!(f.app.layer, Layer::Oa, "{}", f.app.status);
let diagram = f.app.active_diagram.unwrap();
assert_ne!(diagram, f.diagram);
assert_eq!(f.app.project().diagrams[&diagram].layer, Layer::Oa);
assert_eq!(
f.app.project().diagrams[&diagram].projection,
ProjectionKind::Allocation
);
assert_eq!(
f.app.project().diagrams.len(),
before.decoded.project.diagrams.len() + usize::from(!existing_preferred)
);
assert_eq!(
f.app.project().elements[&f.app.selected].parent,
Some(f.process)
);
assert_source_pair(&f.app);
let _ = f.app.update(Message::Undo);
assert_eq!(f.app.project(), &before.decoded.project);
assert_eq!(f.app.document.bundle(), &before.bundle);
}
}
// Regression scope: a contextual click may accept a valid property draft, but
// must not inherit the draft object's semantic-layer navigation. If acceptance
// changes the originating diagram identity or hides the clicked target, retain
// that one accepted edit and refuse the old click. Do not infer a new target or
// reinterpret an old surface packet on a replacement diagram.
fn draft_click_fixture() -> (Fixture, Id) {
let mut f = fixture();
let mut project = f.app.project().clone();
let capability = project
.add_element(Kind::Capability, Layer::Oa, "Second capability", None)
.unwrap();
let other_edge = project.relate(
RelationKind::ChainInvolvement,
"Also enables",
f.process,
capability,
);
project.place(f.diagram, capability, Position { x: 500., y: 300. });
f.app = App::with_project(project);
f.app.page = Page::Diagram;
f.app.layer = Layer::Sa;
f.app.active_diagram = Some(f.diagram);
f.app.selected = f.diagram;
f.app.refresh_draft();
f.app.reconcile_multiselect();
assert!(
project_view(f.app.project(), f.diagram)
.edges
.iter()
.any(|edge| edge.id == other_edge)
);
(f, other_edge)
}
fn expected_draft_commit(app: &App, command: Command) -> Document {
let mut expected = Document::from_bundle(
app.document.bundle().clone(),
oa_core::source_project::ProjectLimits::default(),
)
.unwrap();
expected.execute(command).unwrap();
expected
}
fn assert_accepted_draft(app: &App, expected: &Document, revision: u64) {
assert_eq!(app.project(), expected.project());
assert_eq!(app.document.bundle(), expected.bundle());
assert_eq!(app.document.revision(), revision + 1);
assert!(!app.draft_dirty());
assert!(app.document.can_undo());
assert!(!app.document.can_redo());
assert_source_pair(app);
}
fn assert_one_draft_history(
app: &mut App,
before: &oa_core::source_project::CompiledSourceProject,
expected: &Document,
) {
let _ = app.update(Message::Undo);
assert_eq!(app.project(), &before.decoded.project);
assert_eq!(app.document.bundle(), &before.bundle);
assert!(
!app.document.can_undo(),
"the click must not add a second edit"
);
assert!(app.document.can_redo());
let _ = app.update(Message::Redo);
assert_eq!(app.project(), expected.project());
assert_eq!(app.document.bundle(), expected.bundle());
assert!(!app.document.can_redo());
}
fn pending_edge_name(f: &mut Fixture) {
let context = f.app.route_context().unwrap();
let _ = f.app.update(Message::CanvasSelect(context, f.edge));
assert_surface(f);
assert_eq!(f.app.selected, f.edge);
let _ = f
.app
.update(Message::Field("/name".into(), "Reviewed enables".into()));
assert!(f.app.draft_dirty());
assert!(f.app.draft.as_ref().unwrap().command().is_ok());
}
#[test]
fn valid_cross_layer_draft_then_canvas_edge_retains_surface_and_one_edit() {
let (mut f, other_edge) = draft_click_fixture();
let before = f.app.document.snapshot();
let revision = f.app.document.revision();
let expected =
expected_draft_commit(&f.app, Command::Rename(f.edge, "Reviewed enables".into()));
pending_edge_name(&mut f);
// Capture after drafting, so this exercises successful draft application,
// not refusal of a packet made stale by the property edit itself.
let context = f.app.route_context().unwrap();
let _ = f.app.update(Message::CanvasSelect(context, other_edge));
assert_accepted_draft(&f.app, &expected, revision);
assert_surface(&f);
assert_eq!(f.app.selected, other_edge);
assert_eq!(
f.app.multiselect_snapshot().unwrap().primary(),
Some(&Target::Edge(other_edge))
);
assert_one_draft_history(&mut f.app, &before, &expected);
}
#[test]
fn valid_cross_layer_draft_then_node_click_retains_surface_and_one_edit() {
let (mut f, _) = draft_click_fixture();
let before = f.app.document.snapshot();
let revision = f.app.document.revision();
let expected =
expected_draft_commit(&f.app, Command::Rename(f.edge, "Reviewed enables".into()));
pending_edge_name(&mut f);
let _ = f.app.update(Message::SelectNode(f.placement));
assert_accepted_draft(&f.app, &expected, revision);
assert_surface(&f);
assert_eq!(f.app.selected, f.process);
assert_eq!(f.app.selected_node, Some(f.placement));
assert_eq!(
f.app.multiselect_snapshot().unwrap().primary(),
Some(&Target::Placement(f.placement))
);
assert_one_draft_history(&mut f.app, &before, &expected);
}
#[test]
fn diagram_reidentity_draft_commits_but_refuses_the_old_surface_click() {
let (mut f, other_edge) = draft_click_fixture();
let before = f.app.document.snapshot();
let revision = f.app.document.revision();
let replacement = Id::new_v4();
let expected = expected_draft_commit(&f.app, Command::Reidentify(f.diagram, replacement));
let _ = f
.app
.update(Message::Field("/id".into(), replacement.to_string()));
assert!(f.app.draft_dirty());
let context = f.app.route_context().unwrap();
let _ = f.app.update(Message::CanvasSelect(context, other_edge));
assert_accepted_draft(&f.app, &expected, revision);
assert!(!f.app.project().diagrams.contains_key(&context.diagram));
assert!(
f.app
.active_diagram
.is_some_and(|id| f.app.project().diagrams.contains_key(&id)),
"never restore a removed diagram identity"
);
assert_ne!(f.app.selected, other_edge, "refuse the old surface click");
assert!(
f.app
.multiselect_snapshot()
.is_none_or(|s| !s.targets().contains(&Target::Edge(other_edge)))
);
assert_eq!(
f.app.status,
"Properties applied. The view changed; select a visible target again."
);
assert_one_draft_history(&mut f.app, &before, &expected);
}
#[test]
fn projection_draft_commits_but_refuses_the_now_hidden_canvas_target() {
let (mut f, other_edge) = draft_click_fixture();
let before = f.app.document.snapshot();
let revision = f.app.document.revision();
let mut diagram = f.app.project().diagrams[&f.diagram].clone();
diagram.projection = ProjectionKind::Allocation;
let expected = expected_draft_commit(&f.app, Command::EditDiagram(f.diagram, diagram));
let _ = f.app.update(Message::FieldShape(
"/projection".into(),
serde_json::to_value(ProjectionKind::Allocation).unwrap(),
));
assert!(f.app.draft_dirty());
let context = f.app.route_context().unwrap();
let _ = f.app.update(Message::CanvasSelect(context, other_edge));
assert_accepted_draft(&f.app, &expected, revision);
assert_surface(&f);
assert!(
!project_view(f.app.project(), f.diagram)
.edges
.iter()
.any(|edge| edge.id == other_edge)
);
assert_ne!(
f.app.selected, other_edge,
"do not inspect a no-longer-visible hit"
);
assert!(
f.app
.multiselect_snapshot()
.is_none_or(|s| !s.targets().contains(&Target::Edge(other_edge)))
);
assert_eq!(
f.app.status,
"Properties applied. The view changed; select a visible target again."
);
assert_one_draft_history(&mut f.app, &before, &expected);
}
#[test]
fn invalid_cross_layer_draft_blocks_contextual_clicks_without_changing_history() {
for click in 0..3 {
let (mut f, other_edge) = draft_click_fixture();
let context = f.app.route_context().unwrap();
let _ = f.app.update(Message::CanvasSelect(context, f.edge));
let before = f.app.document.snapshot();
let revision = f.app.document.revision();
let _ = f
.app
.update(Message::Field("/id".into(), "not-a-uuid".into()));
assert!(f.app.draft_dirty());
let expected_error = f.app.draft.as_ref().unwrap().command().unwrap_err();
let draft =
serde_json::to_value(f.app.draft.as_ref().unwrap().recovery_snapshot()).unwrap();
let selection = f.app.multiselect_snapshot().unwrap();
let message = match click {
0 => Message::CanvasSelect(f.app.route_context().unwrap(), other_edge),
1 => Message::SelectNode(f.placement),
_ => Message::DiagramSelection(multiselect::Event::Choose(
f.app.multiselect_context().unwrap(),
Target::Edge(other_edge),
multiselect::Choice::Add,
)),
};
let _ = f.app.update(message);
if click < 2 {
assert_eq!(f.app.status, expected_error);
} else {
assert_eq!(
f.app.status,
"Finish or cancel the active edit before changing the diagram selection."
);
}
assert_surface(&f);
assert_eq!(f.app.selected, f.edge);
assert_eq!(f.app.project(), &before.decoded.project);
assert_eq!(f.app.document.bundle(), &before.bundle);
assert_eq!(f.app.document.revision(), revision);
assert!(!f.app.document.can_undo());
assert!(!f.app.document.can_redo());
assert_eq!(
serde_json::to_value(f.app.draft.as_ref().unwrap().recovery_snapshot()).unwrap(),
draft
);
assert_eq!(
f.app.multiselect_snapshot().unwrap().targets(),
selection.targets()
);
assert_eq!(
f.app.multiselect_snapshot().unwrap().primary(),
selection.primary()
);
if click == 0 {
// Pre-dispatch stale rejection applies no draft and must not emit
// either success feedback or a new validation message.
let stale = f.app.route_context().unwrap();
f.app.canvas_epoch += 1;
f.app.status = "Keep previous feedback".into();
let _ = f.app.update(Message::CanvasSelect(stale, other_edge));
assert_eq!(f.app.status, "Keep previous feedback");
assert_eq!(f.app.project(), &before.decoded.project);
assert_eq!(f.app.document.bundle(), &before.bundle);
assert_eq!(f.app.document.revision(), revision);
assert_eq!(
serde_json::to_value(f.app.draft.as_ref().unwrap().recovery_snapshot()).unwrap(),
draft
);
}
}
}
#[test]
fn explicit_select_after_valid_cross_layer_draft_still_navigates_and_commits_once() {
let (mut f, other_edge) = draft_click_fixture();
let before = f.app.document.snapshot();
let revision = f.app.document.revision();
let expected =
expected_draft_commit(&f.app, Command::Rename(f.edge, "Reviewed enables".into()));
pending_edge_name(&mut f);
let _ = f.app.update(Message::Select(other_edge));
assert_accepted_draft(&f.app, &expected, revision);
assert_eq!(f.app.selected, other_edge);
assert_eq!(f.app.layer, Layer::Oa);
assert_ne!(f.app.active_diagram, Some(f.diagram));
assert_one_draft_history(&mut f.app, &before, &expected);
}
#[test]
fn properties_apply_on_cross_layer_edge_retains_surface_and_one_edit() {
let (mut f, _) = draft_click_fixture();
let before = f.app.document.snapshot();
let revision = f.app.document.revision();
let expected =
expected_draft_commit(&f.app, Command::Rename(f.edge, "Reviewed enables".into()));
pending_edge_name(&mut f);
let _ = f.app.update(Message::Apply);
assert_accepted_draft(&f.app, &expected, revision);
assert_eq!(f.app.status, "Properties applied");
assert_surface(&f);
assert_eq!(f.app.selected, f.edge);
assert_eq!(f.app.draft.as_ref().unwrap().original, f.edge);
assert_eq!(
f.app.multiselect_snapshot().unwrap().primary(),
Some(&Target::Edge(f.edge))
);
assert_one_draft_history(&mut f.app, &before, &expected);
}
#[test]
fn properties_apply_honors_an_intentional_active_diagram_layer_edit() {
let (mut f, _) = draft_click_fixture();
let before = f.app.document.snapshot();
let revision = f.app.document.revision();
let mut diagram = f.app.project().diagrams[&f.diagram].clone();
diagram.layer = Layer::Oa;
let expected = expected_draft_commit(&f.app, Command::EditDiagram(f.diagram, diagram));
let _ = f.app.update(Message::FieldShape(
"/layer".into(),
serde_json::to_value(Layer::Oa).unwrap(),
));
assert!(f.app.draft_dirty());
let _ = f.app.update(Message::Apply);
assert_accepted_draft(&f.app, &expected, revision);
assert_eq!(f.app.page, Page::Diagram);
assert_eq!(
f.app.layer,
Layer::Oa,
"honor the edited diagram's own layer"
);
assert_eq!(f.app.active_diagram, Some(f.diagram));
assert_eq!(f.app.selected, f.diagram);
assert_eq!(f.app.draft.as_ref().unwrap().original, f.diagram);
assert_one_draft_history(&mut f.app, &before, &expected);
}
#[test]
fn properties_apply_follows_the_known_active_diagram_reidentity_once() {
let (mut f, _) = draft_click_fixture();
let before = f.app.document.snapshot();
let revision = f.app.document.revision();
let replacement = Id::new_v4();
let expected = expected_draft_commit(&f.app, Command::Reidentify(f.diagram, replacement));
let _ = f
.app
.update(Message::Field("/id".into(), replacement.to_string()));
assert!(f.app.draft_dirty());
let _ = f.app.update(Message::Apply);
assert_accepted_draft(&f.app, &expected, revision);
assert_eq!(f.app.page, Page::Diagram);
assert_eq!(f.app.layer, Layer::Sa);
// This identity was explicitly authored in the diagram's own draft. No
// queued click is being remapped or inferred from semantic similarity.
assert_eq!(f.app.active_diagram, Some(replacement));
assert_eq!(f.app.selected, replacement);
assert_eq!(f.app.draft.as_ref().unwrap().original, replacement);
assert!(!f.app.project().diagrams.contains_key(&f.diagram));
assert_one_draft_history(&mut f.app, &before, &expected);
}
@@ -0,0 +1,366 @@
// Test-only proposal pinned to peer 6520a89dc042dd433454f6cc63cb23b0e6df7911.
// Independent App test module; real popup messages retain menu tokens.
use super::*;
use crate::model_menu::{Event as MenuEvent, Request};
struct BeginFixture {
app: App,
diagram: Id,
surface_layer: Layer,
source: Id,
target: Id,
wrong_layer: Id,
hidden: Id,
tool: crate::connection_tool::Tool,
}
fn begin_fixture(source_layer: Layer, allocation: bool, mixed: bool) -> BeginFixture {
use crate::connection_tool::Tool;
let mut p = Project::empty();
let (work, performer, exchange) = match source_layer {
Layer::Oa => (Kind::Activity, Kind::Entity, Tool::Interaction),
Layer::Sa => (Kind::Function, Kind::System, Tool::FunctionalExchange),
};
let opposite = if source_layer == Layer::Oa {
Layer::Sa
} else {
Layer::Oa
};
let surface_layer = if mixed { opposite } else { source_layer };
let source = p
.add_element(
if allocation { performer } else { work },
source_layer,
"Source",
None,
)
.unwrap();
let target = p.add_element(work, source_layer, "Target", None).unwrap();
let hidden = p
.add_element(work, source_layer, "Hidden target", None)
.unwrap();
let other_work = if opposite == Layer::Oa {
Kind::Activity
} else {
Kind::Function
};
let wrong_layer = p
.add_element(other_work, opposite, "Wrong layer target", None)
.unwrap();
p.ensure_diagram(source_layer);
let diagram = p.ensure_diagram(surface_layer);
p.diagrams.get_mut(&diagram).unwrap().projection = if mixed {
ProjectionKind::Traceability
} else {
ProjectionKind::Capabilities
};
p.place(diagram, source, Position { x: 0., y: 0. });
p.place(diagram, target, Position { x: 400., y: 0. });
if mixed {
p.place(diagram, wrong_layer, Position { x: 800., y: 0. });
}
let mut app = App::with_project(p);
app.page = Page::Diagram;
app.layer = surface_layer;
app.active_diagram = Some(diagram);
app.selected = diagram;
app.refresh_draft();
app.reconcile_multiselect();
assert!(!app.document.can_undo());
BeginFixture {
app,
diagram,
surface_layer,
source,
target,
wrong_layer,
hidden,
tool: if allocation {
Tool::Allocation
} else {
exchange
},
}
}
fn begin_surface(f: &BeginFixture) {
assert_eq!(f.app.page, Page::Diagram);
assert_eq!(f.app.layer, f.surface_layer);
assert_eq!(f.app.active_diagram, Some(f.diagram));
}
fn begin_popup_action(f: &mut BeginFixture) -> Option<Message> {
begin_popup_action_from(f, true)
}
fn begin_popup_action_from(f: &mut BeginFixture, canvas: bool) -> Option<Message> {
let mut request = Request::object(f.source, iced::Point::ORIGIN);
if canvas {
request.canvas = f.app.route_context();
request.placement = f.app.project().diagrams[&f.diagram]
.nodes
.values()
.find(|node| node.element == f.source)
.map(|node| node.id);
}
let _ = f.app.update(Message::Menu(MenuEvent::Open(request)));
let popup = crate::model_menu::popup(&f.app).expect("real model popup");
let expected = format!("Draw {}", f.tool.label().to_lowercase());
for entry in popup.entries {
if let crate::context_menu::Entry::Action { label, message, .. } = entry
&& label == expected
{
if let Some(Message::Menu(MenuEvent::Invoke(token, _))) = &message {
assert_eq!(*token, popup.token);
} else {
assert!(message.is_none(), "Draw must use the menu token envelope");
}
return message;
}
}
None // Omission is also a valid unavailable-action policy.
}
fn begin_from_popup(f: &mut BeginFixture) -> Message {
begin_from_popup_origin(f, true)
}
fn begin_from_popup_origin(f: &mut BeginFixture, canvas: bool) -> Message {
let before = f.app.document.snapshot();
let revision = f.app.document.revision();
let action =
begin_popup_action_from(f, canvas).expect("visible semantic source offers enabled Draw");
let _ = f.app.update(action.clone());
begin_surface(f);
assert_eq!(f.app.project(), &before.decoded.project);
assert_eq!(f.app.document.bundle(), &before.bundle);
assert_eq!(f.app.document.revision(), revision);
assert!(!f.app.document.can_undo());
let session = f
.app
.connection
.as_ref()
.expect("menu Draw must arm its tool");
assert_eq!(session.tool, f.tool);
assert_eq!(session.source, Some(f.source));
assert_eq!(session.context, f.app.route_context().unwrap());
assert!(session.candidates.contains(&f.target));
assert!(!session.candidates.contains(&f.wrong_layer));
assert!(!session.candidates.contains(&f.hidden));
action
}
fn begin_complete_and_check_history(f: &mut BeginFixture) {
let before = f.app.document.snapshot();
let revision = f.app.document.revision();
let context = f.app.route_context().unwrap();
let _ = f.app.update(Message::Connection(
crate::connection_edit::Event::Endpoint(context, f.target),
));
begin_surface(f);
assert!(f.app.connection.is_none());
assert_eq!(f.app.document.revision(), revision + 1);
let relations: Vec<_> = f
.app
.project()
.relations
.values()
.filter(|r| !before.decoded.project.relations.contains_key(&r.id))
.collect();
assert_eq!(relations.len(), 1);
let expected = match f.tool {
crate::connection_tool::Tool::Interaction => RelationKind::Interaction,
crate::connection_tool::Tool::FunctionalExchange => RelationKind::FunctionalExchange,
crate::connection_tool::Tool::Allocation => RelationKind::Allocation,
_ => unreachable!(),
};
assert_eq!(relations[0].kind(), expected);
assert_eq!(
(relations[0].source(), relations[0].target()),
(f.source, f.target)
);
// Random identities are accepted only for the precise command delta.
// Everything else, including traces, metadata and view options, stays exact.
let mut expected_project = before.decoded.project.clone();
let created = relations[0];
assert!(
expected_project
.relations
.insert(created.id, created.clone())
.is_none()
);
if let RelationBody::FunctionalExchange {
source_port,
target_port,
items,
..
} = &created.body
{
assert_eq!(f.tool, crate::connection_tool::Tool::FunctionalExchange);
assert!(items.is_empty());
assert_ne!(source_port, target_port);
let output = &f.app.project().elements[source_port];
let input = &f.app.project().elements[target_port];
assert_eq!(
(output.kind(), output.layer(), output.parent),
(Kind::Port, Layer::Sa, Some(f.source))
);
assert_eq!(
(input.kind(), input.layer(), input.parent),
(Kind::Port, Layer::Sa, Some(f.target))
);
assert!(
matches!(port_contract(output), Some(PortContract::FunctionOutput { outgoing_items }) if outgoing_items.is_empty())
);
assert!(
matches!(port_contract(input), Some(PortContract::FunctionInput { incoming_items }) if incoming_items.is_empty())
);
assert!(!before.decoded.project.elements.contains_key(source_port));
assert!(!before.decoded.project.elements.contains_key(target_port));
assert_eq!(output.id, *source_port);
assert_eq!(input.id, *target_port);
expected_project
.elements
.insert(*source_port, output.clone());
expected_project
.elements
.insert(*target_port, input.clone());
assert_eq!(
f.app.project().elements.len(),
before.decoded.project.elements.len() + 2
);
} else {
assert_ne!(f.tool, crate::connection_tool::Tool::FunctionalExchange);
assert_eq!(f.app.project().elements, before.decoded.project.elements);
}
assert_eq!(f.app.project(), &expected_project);
assert_eq!(f.app.project().diagrams, before.decoded.project.diagrams);
for (id, element) in &before.decoded.project.elements {
assert_eq!(&f.app.project().elements[id], element);
}
begin_assert_source_pair(&f.app);
let after = f.app.document.snapshot();
let _ = f.app.update(Message::Undo);
assert_eq!(f.app.project(), &before.decoded.project);
assert_eq!(f.app.document.bundle(), &before.bundle);
assert!(
!f.app.document.can_undo(),
"creation is a single history step"
);
let _ = f.app.update(Message::Redo);
assert_eq!(f.app.project(), &after.decoded.project);
assert_eq!(f.app.document.bundle(), &after.bundle);
begin_assert_source_pair(&f.app);
}
#[test]
fn contextual_begin_exchange_uses_actual_menu_in_both_mixed_directions() {
for layer in [Layer::Oa, Layer::Sa] {
let mut f = begin_fixture(layer, false, true);
begin_from_popup(&mut f);
begin_complete_and_check_history(&mut f);
}
}
#[test]
fn contextual_begin_cancel_restart_and_stale_menu_preserve_session_ownership() {
for layer in [Layer::Oa, Layer::Sa] {
let mut f = begin_fixture(layer, false, true);
let original = f.app.document.snapshot();
let stale_menu = begin_from_popup(&mut f);
let first = f.app.route_context().unwrap();
let _ = f
.app
.update(Message::Connection(crate::connection_edit::Event::Restart(
first,
)));
begin_surface(&f);
let restarted = f
.app
.connection
.as_ref()
.expect("restart retains contextual tool");
assert_eq!(restarted.tool, f.tool);
assert_eq!(restarted.source, None);
assert!(restarted.candidates.contains(&f.source));
let current = restarted.context;
assert_ne!(current, first);
let _ = f.app.update(stale_menu);
let _ = f.app.update(Message::Connection(
crate::connection_edit::Event::Endpoint(first, f.target),
));
assert_eq!(f.app.connection.as_ref().unwrap().context, current);
assert_eq!(f.app.connection.as_ref().unwrap().source, None);
let _ = f.app.update(Message::Connection(
crate::connection_edit::Event::Endpoint(current, f.source),
));
assert_eq!(f.app.connection.as_ref().unwrap().source, Some(f.source));
let _ = f.app.update(Message::Escape);
let _ = f.app.update(Message::Connection(
crate::connection_edit::Event::Endpoint(current, f.target),
));
assert!(f.app.connection.is_none());
begin_surface(&f);
assert_eq!(f.app.project(), &original.decoded.project);
assert_eq!(f.app.document.bundle(), &original.bundle);
assert!(!f.app.document.can_undo());
}
}
#[test]
fn contextual_begin_allocation_uses_actual_menu_in_both_mixed_directions() {
for layer in [Layer::Oa, Layer::Sa] {
let mut f = begin_fixture(layer, true, true);
begin_from_popup(&mut f);
begin_complete_and_check_history(&mut f);
}
}
#[test]
fn contextual_begin_allocation_is_not_enabled_when_projection_hides_work() {
for layer in [Layer::Oa, Layer::Sa] {
let mut f = begin_fixture(layer, true, false);
let view = project_view(f.app.project(), f.diagram);
assert!(view.nodes.iter().any(|n| n.element == f.source));
assert!(!view.nodes.iter().any(|n| n.element == f.target));
assert!(!f.tool.available_in(layer, ProjectionKind::Capabilities));
let before = f.app.document.snapshot();
assert!(
begin_popup_action(&mut f).is_none(),
"Draw allocation must be disabled or omitted when work is not projected"
);
let _ = f.app.update(Message::Menu(MenuEvent::Close));
// Defensive handler refusal complements actual-menu enabledness above.
let _ = f
.app
.update(Message::Connection(crate::connection_edit::Event::Begin(
f.tool, f.source,
)));
assert!(f.app.connection.is_none());
begin_surface(&f);
assert_eq!(f.app.project(), &before.decoded.project);
assert_eq!(f.app.document.bundle(), &before.bundle);
assert!(!f.app.document.can_undo());
}
}
fn begin_assert_source_pair(app: &App) {
let compiled = oa_core::source_project::compile_bundle(
app.document.bundle(),
oa_core::source_project::ProjectLimits::default(),
)
.unwrap();
assert_eq!(&compiled.decoded.project, app.project());
assert_eq!(&compiled.bundle, app.document.bundle());
}
#[test]
fn contextual_begin_explorer_draw_retains_visible_mixed_diagram_in_both_directions() {
for layer in [Layer::Oa, Layer::Sa] {
let mut f = begin_fixture(layer, false, true);
// A real Explorer Request::object has no canvas/placement envelope,
// but its generated Draw action still targets the visible current view.
begin_from_popup_origin(&mut f, false);
begin_complete_and_check_history(&mut f);
}
}
@@ -154,3 +154,440 @@ async fn mounted_f2_requires_canvas_focus_and_yields_to_clean_inspector() {
}
}
}
async fn modifier_focus_fixture() -> (App, Mounted, Id, Id, Id) {
let mut project = Project::empty();
let a = project
.add_element(Kind::Entity, Layer::Oa, "First team", None)
.unwrap();
let b = project
.add_element(Kind::Entity, Layer::Oa, "Second team", None)
.unwrap();
let diagram = project.ensure_diagram(Layer::Oa);
let first = project.place(diagram, a, Position { x: 80., y: 80. });
let second = project.place(diagram, b, Position { x: 500., y: 80. });
let mut app = App::with_project(project);
app.page = Page::Diagram;
app.layer = Layer::Oa;
app.active_diagram = Some(diagram);
app.window_size = Size::new(2000., 1400.);
let _ = app.update(Message::Select(a));
let mut mounted = Mounted::new(&mut app).await;
app.zoom = 1.;
app.pan = Vector::ZERO;
app.fit_to_view = false;
mounted.settle(&mut app).await;
(app, mounted, diagram, first, second)
}
fn modifier_node_point(mounted: &mut Mounted, app: &App, diagram: Id, placement: Id) -> Point {
let bounds = mounted
.geometry(app)
.bounds(crate::allocation_input::CANVAS_ID);
let node = projection::project_view(app.project(), diagram)
.nodes
.into_iter()
.find(|n| n.id == placement)
.unwrap();
Point::new(
bounds.x + node.position.x + 40.,
bounds.y + node.position.y + 35.,
)
}
fn modifier_canvas_focused(mounted: &mut Mounted, app: &App) -> bool {
mounted
.geometry(app)
.focused
.iter()
.find(|(id, _, _)| {
id.as_ref() == Some(&iced::advanced::widget::Id::from("diagram-keyboard-owner"))
})
.expect("production focus::Owner is mounted")
.2
}
fn modifier_event(shift: bool) -> iced::Event {
iced::Event::Keyboard(iced::keyboard::Event::ModifiersChanged(if shift {
iced::keyboard::Modifiers::SHIFT
} else {
iced::keyboard::Modifiers::default()
}))
}
#[tokio::test]
async fn mounted_shift_before_canvas_focus_toggles_exact_node_without_granting_keyboard_focus() {
use crate::diagram_usage::Target;
let (mut app, mut mounted, diagram, first, second) = modifier_focus_fixture().await;
let before = app.document.snapshot();
let field = mounted
.geometry(&app)
.text_inputs
.into_iter()
.filter(|bounds| bounds.x > app.window_size.width - 320.)
.min_by(|a, b| a.y.total_cmp(&b.y))
.expect("clean Inspector name input");
mounted.operate(&app, &mut FocusField(field));
assert!(!modifier_canvas_focused(&mut mounted, &app));
assert_eq!(
app.multiselect_snapshot().unwrap().targets(),
&[Target::Placement(first)]
);
mounted
.batch(
&mut app,
&[modifier_event(true)],
mouse::Cursor::Unavailable,
)
.await;
assert!(
!modifier_canvas_focused(&mut mounted, &app),
"modifier tracking must not acquire focus"
);
let at = modifier_node_point(&mut mounted, &app, diagram, second);
let messages = mounted.click(&mut app, at).await;
assert!(messages.iter().any(|m| matches!(m,
Message::DiagramSelection(multiselect::Event::Choose(_, Target::Placement(id), multiselect::Choice::Toggle)) if *id == second
)), "Shift already held before the first canvas press must reach its exact-target adapter");
assert_eq!(
app.multiselect_snapshot().unwrap().targets(),
&[Target::Placement(first), Target::Placement(second)]
);
assert_eq!(app.selected_node, Some(second));
assert_eq!(app.project(), &before.decoded.project);
assert_eq!(app.document.bundle(), &before.bundle);
assert!(!app.document.can_undo());
assert!(!app.draft_dirty());
}
#[tokio::test]
async fn mounted_unfocused_typing_and_delete_remain_denied_after_passive_modifiers() {
use crate::diagram_usage::Target;
let (mut app, mut mounted, diagram, first, second) = modifier_focus_fixture().await;
mounted
.batch(
&mut app,
&[modifier_event(true)],
mouse::Cursor::Unavailable,
)
.await;
let at = modifier_node_point(&mut mounted, &app, diagram, second);
mounted.click(&mut app, at).await;
assert_eq!(
app.multiselect_snapshot().unwrap().targets(),
&[Target::Placement(first), Target::Placement(second)]
);
// Revoke every field's focus through the real widget operation. No widget
// has this off-window rectangle, so neither Inspector nor canvas owns keys.
mounted.operate(
&app,
&mut FocusField(Rectangle {
x: -1000.,
y: -1000.,
width: 1.,
height: 1.,
}),
);
assert!(!modifier_canvas_focused(&mut mounted, &app));
let before = app.document.snapshot();
let selection = app.multiselect_snapshot().unwrap();
let messages = mounted
.batch(
&mut app,
&[
modifier_event(false),
crate::behavior::canvas_workspace::tests::character("x"),
key(Named::Delete),
key(Named::F2),
],
mouse::Cursor::Unavailable,
)
.await;
assert!(!messages.iter().any(|m| matches!(
m,
Message::DiagramSelection(_)
| Message::DiagramRemove(_)
| Message::HideSelected
| Message::Delete
)));
assert!(!rename_emitted(&messages));
assert!(!modifier_canvas_focused(&mut mounted, &app));
assert_eq!(
app.multiselect_snapshot().unwrap().targets(),
selection.targets()
);
assert_eq!(app.project(), &before.decoded.project);
assert_eq!(app.document.bundle(), &before.bundle);
assert!(!app.document.can_undo());
assert!(!app.draft_dirty());
assert!(!app.delete_pending);
}
#[tokio::test]
async fn mounted_suspended_modifiers_are_passive_and_survive_until_resumed_click() {
use crate::diagram_usage::Target;
let (mut app, mut mounted, diagram, first, second) = modifier_focus_fixture().await;
let before = app.document.snapshot();
let at = modifier_node_point(&mut mounted, &app, diagram, second);
app.busy = true;
mounted.settle(&mut app).await;
assert!(!modifier_canvas_focused(&mut mounted, &app));
mounted
.batch(
&mut app,
&[modifier_event(true), key(Named::Delete)],
mouse::Cursor::Unavailable,
)
.await;
mounted.click(&mut app, at).await;
assert!(
!modifier_canvas_focused(&mut mounted, &app),
"passive I/O cannot grant canvas focus"
);
assert_eq!(
app.multiselect_snapshot().unwrap().targets(),
&[Target::Placement(first)]
);
assert_eq!(app.document.bundle(), &before.bundle);
app.busy = false;
mounted.settle(&mut app).await;
assert!(!modifier_canvas_focused(&mut mounted, &app));
mounted.click(&mut app, at).await;
assert_eq!(
app.multiselect_snapshot().unwrap().targets(),
&[Target::Placement(first), Target::Placement(second)]
);
assert_eq!(app.project(), &before.decoded.project);
assert_eq!(app.document.bundle(), &before.bundle);
assert!(!app.document.can_undo());
}
#[tokio::test]
async fn mounted_shift_held_before_suspension_survives_without_another_modifier_event() {
use crate::diagram_usage::Target;
let (mut app, mut mounted, diagram, first, second) = modifier_focus_fixture().await;
let before = app.document.snapshot();
let field = mounted
.geometry(&app)
.text_inputs
.into_iter()
.filter(|bounds| bounds.x > app.window_size.width - 320.)
.min_by(|a, b| a.y.total_cmp(&b.y))
.expect("clean Inspector name input");
mounted.operate(&app, &mut FocusField(field));
mounted
.batch(
&mut app,
&[modifier_event(true)],
mouse::Cursor::Unavailable,
)
.await;
assert!(!modifier_canvas_focused(&mut mounted, &app));
app.busy = true;
mounted.settle(&mut app).await;
assert!(!modifier_canvas_focused(&mut mounted, &app));
app.busy = false;
mounted.settle(&mut app).await;
assert!(
!modifier_canvas_focused(&mut mounted, &app),
"restoring passive modifiers must not grant keyboard focus"
);
assert_eq!(
app.multiselect_snapshot().unwrap().targets(),
&[Target::Placement(first)]
);
// No modifier transition follows the subtree replacement during I/O.
let at = modifier_node_point(&mut mounted, &app, diagram, second);
let messages = mounted.click(&mut app, at).await;
assert!(messages.iter().any(|m| matches!(m,
Message::DiagramSelection(multiselect::Event::Choose(_, Target::Placement(id), multiselect::Choice::Toggle)) if *id == second
)));
assert_eq!(
app.multiselect_snapshot().unwrap().targets(),
&[Target::Placement(first), Target::Placement(second)]
);
assert_eq!(app.project(), &before.decoded.project);
assert_eq!(app.document.bundle(), &before.bundle);
assert!(!app.document.can_undo());
assert!(!app.draft_dirty());
}
#[tokio::test]
async fn mounted_shift_release_or_window_unfocus_during_suspension_does_not_restore_stale_shift() {
use crate::diagram_usage::Target;
for release in [
modifier_event(false),
iced::Event::Window(iced::window::Event::Unfocused),
] {
let (mut app, mut mounted, diagram, first, second) = modifier_focus_fixture().await;
let before = app.document.snapshot();
mounted
.batch(
&mut app,
&[modifier_event(true)],
mouse::Cursor::Unavailable,
)
.await;
app.busy = true;
mounted.settle(&mut app).await;
mounted
.batch(&mut app, &[release], mouse::Cursor::Unavailable)
.await;
assert!(!modifier_canvas_focused(&mut mounted, &app));
app.busy = false;
mounted.settle(&mut app).await;
// A further I/O cycle must restore the updated owner state too.
app.busy = true;
mounted.settle(&mut app).await;
app.busy = false;
mounted.settle(&mut app).await;
assert!(!modifier_canvas_focused(&mut mounted, &app));
assert_eq!(
app.multiselect_snapshot().unwrap().targets(),
&[Target::Placement(first)]
);
let at = modifier_node_point(&mut mounted, &app, diagram, second);
let messages = mounted.click(&mut app, at).await;
assert!(!messages.iter().any(|m| matches!(
m,
Message::DiagramSelection(multiselect::Event::Choose(
_,
_,
multiselect::Choice::Toggle
))
)));
assert_eq!(
app.multiselect_snapshot().unwrap().targets(),
&[Target::Placement(second)]
);
assert_eq!(app.project(), &before.decoded.project);
assert_eq!(app.document.bundle(), &before.bundle);
assert!(!app.document.can_undo());
assert!(!app.draft_dirty());
}
}
#[tokio::test]
async fn mounted_window_unfocus_resets_held_shift_before_next_canvas_press() {
use crate::diagram_usage::Target;
let (mut app, mut mounted, diagram, first, second) = modifier_focus_fixture().await;
let at_first = modifier_node_point(&mut mounted, &app, diagram, first);
mounted.click(&mut app, at_first).await;
let before = app.document.snapshot();
mounted
.batch(
&mut app,
&[
modifier_event(true),
iced::Event::Window(iced::window::Event::Unfocused),
],
mouse::Cursor::Unavailable,
)
.await;
assert!(!modifier_canvas_focused(&mut mounted, &app));
// No ModifiersChanged(empty) follows: a lost window must itself reset it.
let at_second = modifier_node_point(&mut mounted, &app, diagram, second);
mounted.click(&mut app, at_second).await;
assert_eq!(
app.multiselect_snapshot().unwrap().targets(),
&[Target::Placement(second)]
);
assert_eq!(app.project(), &before.decoded.project);
assert_eq!(app.document.bundle(), &before.bundle);
assert!(!app.document.can_undo());
}
#[tokio::test]
async fn mounted_selection_strip_keeps_canvas_and_fitted_camera_fixed_for_empty_single_group_and_mixed()
{
use crate::diagram_usage::Target;
let mut project = Project::empty();
let a = project
.add_element(Kind::Activity, Layer::Oa, "First activity", None)
.unwrap();
let b = project
.add_element(
Kind::Activity,
Layer::Oa,
&format!(
"Second activity {}",
"with a very long primary name ".repeat(20)
),
None,
)
.unwrap();
let edge = project.relate(RelationKind::Interaction, "Activity exchange", a, b);
let diagram = project.ensure_diagram(Layer::Oa);
project.diagrams.get_mut(&diagram).unwrap().projection = ProjectionKind::Overview;
let first = project.place(diagram, a, Position { x: 80., y: 80. });
let second = project.place(diagram, b, Position { x: 500., y: 80. });
let mut app = App::with_project(project);
app.page = Page::Diagram;
app.layer = Layer::Oa;
app.active_diagram = Some(diagram);
app.window_size = Size::new(1442., 932.);
let _ = app.update(Message::Select(diagram));
app.fit_to_view = true;
let mut mounted = Mounted::new(&mut app).await;
let baseline = app.document.snapshot();
let bounds = mounted
.geometry(&app)
.bounds(crate::allocation_input::CANVAS_ID);
let camera = (app.zoom, app.pan, app.fit_to_view, app.canvas_size);
assert!(camera.2, "exercise the native resize-triggered refit path");
assert!(app.multiselect_snapshot().unwrap().targets().is_empty());
assert!(bounds.width > 500. && bounds.height > 300.);
for (target, expected) in [
(
Some(Target::Placement(first)),
vec![Target::Placement(first)],
),
(
Some(Target::Placement(second)),
vec![Target::Placement(first), Target::Placement(second)],
),
(
Some(Target::Edge(edge)),
vec![
Target::Placement(first),
Target::Placement(second),
Target::Edge(edge),
],
),
(
Some(Target::Edge(edge)),
vec![Target::Placement(first), Target::Placement(second)],
),
(
Some(Target::Placement(second)),
vec![Target::Placement(first)],
),
(None, vec![]),
] {
let context = app.multiselect_context().unwrap();
let event = match target {
Some(target) => {
multiselect::Event::Choose(context, target, multiselect::Choice::Toggle)
}
None => multiselect::Event::Clear(context),
};
let task = app.update(Message::DiagramSelection(event));
mounted.perform(&mut app, task).await;
mounted.settle(&mut app).await;
assert_eq!(app.multiselect_snapshot().unwrap().targets(), expected);
assert_eq!(
mounted
.geometry(&app)
.bounds(crate::allocation_input::CANVAS_ID),
bounds,
"selection feedback must not change the measured canvas rectangle"
);
assert_eq!(
(app.zoom, app.pan, app.fit_to_view, app.canvas_size),
camera
);
assert_eq!(app.project(), &baseline.decoded.project);
assert_eq!(app.document.bundle(), &baseline.bundle);
assert!(!app.document.can_undo());
assert!(!app.draft_dirty());
}
}
@@ -924,6 +924,8 @@ async fn mounted_bend_delete_has_priority_and_semantic_edge_delete_is_confirmed(
diagram,
route: EdgeRoute {
edge,
source_attachment: None,
target_attachment: None,
waypoints: vec![Position { x: 400., y: -100. }],
label: None,
},
+17 -2
View File
@@ -1,5 +1,6 @@
//! Complete a navigation camera after the destination reports its real viewport.
use super::*;
use crate::behavior::scenario_layout::Representation;
use crate::diagram_usage::{Context, Surface};
#[cfg(test)]
mod tests;
@@ -47,11 +48,23 @@ impl App {
&& c.epoch == *epoch,
),
Message::ScenarioCanvas(behavior::scenario::CanvasEvent::Viewport(c, size)) => (
Surface::Scenario(c.scenario),
match c.representation {
Representation::Derived { owner } => Surface::Scenario(owner),
Representation::Saved { diagram } => Surface::Saved(diagram),
},
*size,
c.document == context.document
&& c.revision == context.revision
&& c.epoch == *epoch,
&& c.epoch == *epoch
&& self.pending_scenario_reveal_visit == Some(c.visit)
&& self.page == Page::Behavior
&& self.behavior.active_owner == Some(c.scenario)
&& self.behavior.scenario.context(
self.canvas_document,
c.scenario,
self.document.revision(),
self.canvas_epoch,
) == Some(*c),
),
Message::StateDiagram(behavior::state_edit::Event::Viewport(c, size)) => (
Surface::Saved(c.diagram),
@@ -93,6 +106,7 @@ impl App {
| Message::StateDiagram(behavior::state_edit::Event::Viewport(..))
) {
self.pending_diagram_reveal = None;
self.pending_scenario_reveal_visit = None;
if !Self::is_navigation_camera(message) {
self.diagram_navigation_focus = None;
}
@@ -142,5 +156,6 @@ impl App {
self.reveal_diagram_usage(&usage);
}
self.pending_diagram_reveal = None;
self.pending_scenario_reveal_visit = None;
}
}
@@ -98,6 +98,7 @@ fn fixture(canvas: Canvas) -> (App, Usage) {
layer: Layer::Oa,
projection: ProjectionKind::StateMachine { machine },
nodes: BTreeMap::new(),
layout: None,
routes: vec![],
view_options: Default::default(),
},
@@ -143,6 +144,7 @@ fn measurement(
epoch: u64,
surface: Surface,
size: Size,
visit: Id,
) -> Message {
match canvas {
Canvas::Architecture => Message::DiagramRevealViewport(context, epoch, size),
@@ -159,6 +161,10 @@ fn measurement(
behavior::scenario::Context {
document: context.document,
scenario: surface.id(),
representation: Representation::Derived {
owner: surface.id(),
},
visit,
revision: context.revision,
epoch,
},
@@ -176,12 +182,16 @@ fn measurement(
}
}
fn stamp(app: &App) -> (Context, u64) {
fn stamp(app: &App) -> (Context, u64, Id) {
let (context, _, epoch) = app
.pending_diagram_reveal
.as_ref()
.expect("reveal awaits measured destination");
(*context, *epoch)
(
*context,
*epoch,
app.pending_scenario_reveal_visit.unwrap_or(Id::nil()),
)
}
fn assert_centered(app: &App, canvas: Canvas, target: &Usage) {
@@ -219,7 +229,11 @@ fn assert_centered(app: &App, canvas: Canvas, target: &Usage) {
expected.choose_tool(target.surface.id(), behavior::scenario::CanvasTool::Select);
expected.viewport(ACTUAL);
expected.zoom(Some(zoom));
expected.camera(behavior::canvas_workspace::Event::Pan(pan));
expected.camera(
app.project(),
target.surface.id(),
behavior::canvas_workspace::Event::Pan(pan),
);
assert!(app.behavior.scenario.camera_boundary() == expected.camera_boundary());
}
}
@@ -238,10 +252,10 @@ fn all_reveal_surfaces_recenter_once_after_the_real_viewport_and_preserve_author
app.document.dirty(),
);
assert!(app.reveal_diagram_usage(&target));
let (context, epoch) = stamp(&app);
let (context, epoch, visit) = stamp(&app);
send(
&mut app,
measurement(canvas, context, epoch, target.surface, ACTUAL),
measurement(canvas, context, epoch, target.surface, ACTUAL, visit),
);
assert!(app.pending_diagram_reveal.is_none());
assert_centered(&app, canvas, &target);
@@ -260,7 +274,7 @@ fn all_reveal_surfaces_recenter_once_after_the_real_viewport_and_preserve_author
let camera = app.camera_boundary();
send(
&mut app,
measurement(canvas, context, epoch, target.surface, ACTUAL),
measurement(canvas, context, epoch, target.surface, ACTUAL, visit),
);
assert!(app.pending_diagram_reveal.is_none());
assert!(app.camera_boundary() == camera);
@@ -274,7 +288,7 @@ fn malformed_or_wrong_stamp_measurements_cannot_poison_a_pending_reveal() {
for invalid in 0..6 {
let (mut app, target) = fixture(canvas);
assert!(app.reveal_diagram_usage(&target));
let (context, epoch) = stamp(&app);
let (context, epoch, visit) = stamp(&app);
let camera = app.camera_boundary();
let mut stale = context;
let mut wrong_epoch = epoch;
@@ -289,13 +303,13 @@ fn malformed_or_wrong_stamp_measurements_cannot_poison_a_pending_reveal() {
}
send(
&mut app,
measurement(canvas, stale, wrong_epoch, target.surface, size),
measurement(canvas, stale, wrong_epoch, target.surface, size, visit),
);
assert!(
app.pending_diagram_reveal.is_some(),
"{canvas:?} invalid case {invalid}"
);
assert_eq!(stamp(&app), (context, epoch));
assert_eq!(stamp(&app), (context, epoch, visit));
assert_eq!(app.canvas_epoch, epoch);
assert!(
app.camera_boundary() == camera,
@@ -303,7 +317,7 @@ fn malformed_or_wrong_stamp_measurements_cannot_poison_a_pending_reveal() {
);
send(
&mut app,
measurement(canvas, context, epoch, target.surface, ACTUAL),
measurement(canvas, context, epoch, target.surface, ACTUAL, visit),
);
assert!(app.pending_diagram_reveal.is_none());
assert_centered(&app, canvas, &target);
@@ -317,9 +331,10 @@ fn actual_window_resize_before_first_measurement_retains_focus_for_the_new_conte
let (mut app, target) = fixture(canvas);
let accepted = app.document.snapshot();
assert!(app.reveal_diagram_usage(&target));
let (context, old_epoch) = stamp(&app);
let (context, old_epoch, visit) = stamp(&app);
send(&mut app, Message::WindowResized(Size::new(1100., 700.)));
let (current, epoch) = stamp(&app);
let (current, epoch, current_visit) = stamp(&app);
assert_eq!(current_visit, visit);
assert_eq!(current, context);
assert_eq!(epoch, app.canvas_epoch);
if matches!(canvas, Canvas::Process | Canvas::Scenario) {
@@ -327,14 +342,14 @@ fn actual_window_resize_before_first_measurement_retains_focus_for_the_new_conte
let camera = app.camera_boundary();
send(
&mut app,
measurement(canvas, context, old_epoch, target.surface, ACTUAL),
measurement(canvas, context, old_epoch, target.surface, ACTUAL, visit),
);
assert_eq!(stamp(&app), (current, epoch));
assert_eq!(stamp(&app), (current, epoch, visit));
assert!(app.camera_boundary() == camera);
}
send(
&mut app,
measurement(canvas, current, epoch, target.surface, ACTUAL),
measurement(canvas, current, epoch, target.surface, ACTUAL, visit),
);
assert!(app.pending_diagram_reveal.is_none());
assert_centered(&app, canvas, &target);
@@ -349,7 +364,7 @@ fn user_camera_edit_and_navigation_cancel_deferred_focus_without_late_snapping()
let (mut app, target) = fixture(canvas);
let accepted = app.document.snapshot();
assert!(app.reveal_diagram_usage(&target));
let (context, epoch) = stamp(&app);
let (context, epoch, visit) = stamp(&app);
let event = match action {
0 => match canvas {
Canvas::Architecture => Message::Pan(Vector::new(31., -17.)),
@@ -386,7 +401,7 @@ fn user_camera_edit_and_navigation_cancel_deferred_focus_without_late_snapping()
// restore the old navigation camera after deliberate user input.
send(
&mut app,
measurement(canvas, context, epoch, target.surface, WIDE),
measurement(canvas, context, epoch, target.surface, WIDE, visit),
);
assert!(app.pending_diagram_reveal.is_none());
assert!(
+100 -18
View File
@@ -1,5 +1,6 @@
//! Navigation-only adapter: validates destinations before changing presentation.
use super::*;
use crate::behavior::scenario_layout::{Representation, presentation};
use crate::diagram_usage::{self as usage, Context, Event, Panel, Surface, Target, Usage};
#[cfg(test)]
mod tests;
@@ -181,6 +182,7 @@ impl App {
// Only an accepted new chooser supersedes the previous reveal.
// Rejected/stale panel envelopes leave its first measurement intact.
self.pending_diagram_reveal = None;
self.pending_scenario_reveal_visit = None;
self.diagram_navigation_focus = None;
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
return Task::batch([
@@ -291,11 +293,78 @@ impl App {
Task::none()
}
/// Explicit saved-Scenario reveal runs before generic Apply. An absent target
/// never selects the owner or another saved arrangement as a fallback.
pub(super) fn reveal_saved_scenario(&mut self, diagram: Id, selected: Id) -> Task<Message> {
if self.diagram_usages.is_some() || !self.usage_navigation_available() {
self.status = "Apply or revert pending edits before revealing a saved Scenario. Your inputs are retained.".into();
return Task::none();
}
if presentation::checked_owner(self.project(), Representation::Saved { diagram }).is_none()
{
return Task::none();
}
let Some(found) = self
.current_surface_usages(selected, Surface::Saved(diagram))
.into_iter()
.find(|row| matches!(row.target, Target::Whole | Target::ScenarioObject(_)))
else {
self.status = "This object has no visible target in that saved Scenario.".into();
return Task::none();
};
self.reveal_diagram_usage(&found);
Task::none()
}
pub(super) fn reveal_diagram_usage(&mut self, usage: &Usage) -> bool {
if !self.usage_navigation_available() {
return false;
}
let previous_layer = self.layer;
let scenario_representation = match usage.surface {
Surface::Saved(diagram) if self.saved_scenario_diagram(diagram) => {
Some(Representation::Saved { diagram })
}
Surface::Scenario(owner) => Some(Representation::Derived { owner }),
_ => None,
};
if let Some(representation) = scenario_representation {
let Some(owner) = presentation::checked_owner(self.project(), representation) else {
return false;
};
if !self
.behavior
.scenario
.representation_usages(self.project(), representation, usage.subject)
.iter()
.any(|row| row == usage)
{
return false;
}
// Finish target/camera admission before changing the App route. The
// checked immutable owner also makes the following entry infallible.
let revealed = match representation {
Representation::Derived { .. } => self.behavior.scenario.reveal_usage(
self.document.project(),
self.canvas_document,
owner,
&usage.target,
),
Representation::Saved { .. } => self.behavior.scenario.reveal_representation_usage(
self.document.project(),
self.canvas_document,
owner,
representation,
&usage.target,
),
};
if !revealed {
return false;
}
if !self.enter_scenario_representation(representation) {
return false;
}
}
let p = self.document.project();
let (selected, placement) = match &usage.target {
Target::Whole => (usage.surface.id(), None),
@@ -319,14 +388,7 @@ impl App {
| Target::ScenarioObject(id)
| Target::AllocationGrouping { relation: id, .. }
| Target::ProcessPreviewLink { relation: id, .. } => (*id, None),
Target::ProcessOccurrence(endpoint) => (
endpoint
.references
.first()
.copied()
.unwrap_or(endpoint.occurrence),
None,
),
Target::ProcessOccurrence(endpoint) => (endpoint.occurrence, None),
};
match usage.surface {
Surface::Saved(id) => {
@@ -335,7 +397,10 @@ impl App {
};
self.layer = diagram.layer;
self.active_diagram = Some(id);
if let Some(machine) = diagram.projection.state_machine() {
if let Some(owner) = diagram.projection.scenario() {
self.behavior.active_owner = Some(owner);
self.page = Page::Behavior;
} else if let Some(machine) = diagram.projection.state_machine() {
let viewport = self.behavior.diagram.viewport.unwrap_or(iced::Size::new(
(self.window_size.width - 610.).max(280.),
(self.window_size.height - 320.).max(240.),
@@ -377,19 +442,14 @@ impl App {
self.page = Page::Behavior;
}
Surface::Scenario(owner) => {
if !self.behavior.scenario.reveal_usage(
p,
self.canvas_document,
owner,
&usage.target,
) {
return false;
}
self.layer = p.elements[&owner].layer();
self.behavior.active_owner = Some(owner);
self.page = Page::Behavior;
}
}
if scenario_representation.is_none() {
self.behavior.scenario.forget_view();
}
self.selected = selected;
self.selected_node = placement;
self.model_menu = None;
@@ -416,6 +476,18 @@ impl App {
usage: usage.clone(),
selected,
});
self.pending_scenario_reveal_visit = scenario_representation.and_then(|representation| {
let owner = presentation::checked_owner(self.project(), representation)?;
self.behavior
.scenario
.context(
self.canvas_document,
owner,
self.document.revision(),
self.canvas_epoch,
)
.map(|context| context.visit)
});
self.pending_diagram_reveal = Some((
Context {
document: self.canvas_document,
@@ -436,10 +508,20 @@ impl App {
Surface::Saved(id) => {
self.active_diagram == Some(id)
&& matches!(self.page, Page::Diagram | Page::Behavior)
&& (!self.saved_scenario_diagram(id)
|| (self.page == Page::Behavior
&& self.behavior.scenario.representation()
== Some(Representation::Saved { diagram: id })))
}
Surface::Process(owner) | Surface::Scenario(owner) => {
Surface::Process(owner) => {
self.page == Page::Behavior && self.behavior.active_owner == Some(owner)
}
Surface::Scenario(owner) => {
self.page == Page::Behavior
&& self.behavior.active_owner == Some(owner)
&& self.behavior.scenario.representation()
== Some(Representation::Derived { owner })
}
};
(current_surface
&& focus.usage.surface == surface
@@ -881,7 +881,32 @@ fn repeated_process_calls_reveal_the_chosen_caller_and_distinct_tile_camera() {
.unwrap()
.clone();
choose(&mut app, token, row);
assert_eq!(app.selected, first);
assert_eq!(app.selected, leaf);
let context = app.topology_context().unwrap();
send(
&mut app,
Message::TopologyCanvas(behavior::topology::CanvasEvent::Viewport(
context,
iced::Size::new(850., 460.),
)),
);
assert!(app.pending_diagram_reveal.is_none());
assert_eq!(
app.behavior.topology.selection_address().unwrap().endpoint,
OccurrenceEndpoint {
references: vec![first],
occurrence: leaf
}
);
assert_eq!(
app.multiselect_snapshot().unwrap().primary(),
Some(&Target::ProcessOccurrence(OccurrenceEndpoint {
references: vec![first],
occurrence: leaf
}))
);
assert!(app.process_preview_primary().is_some());
assert!(app.draft.is_none());
assert_eq!(app.page, Page::Behavior);
assert_eq!(app.behavior.active_owner, Some(fixture.owner));
let first_camera = app.behavior.topology.camera_boundary();
@@ -898,7 +923,33 @@ fn repeated_process_calls_reveal_the_chosen_caller_and_distinct_tile_camera() {
})
.unwrap();
choose(&mut app, token, row);
assert_eq!(app.selected, second);
assert_eq!(app.selected, leaf);
let context = app.topology_context().unwrap();
send(
&mut app,
Message::TopologyCanvas(behavior::topology::CanvasEvent::Viewport(
context,
iced::Size::new(850., 460.),
)),
);
assert!(app.pending_diagram_reveal.is_none());
assert_eq!(app.behavior.active_owner, Some(fixture.owner));
assert_eq!(
app.behavior.topology.selection_address().unwrap().endpoint,
OccurrenceEndpoint {
references: vec![second],
occurrence: leaf
}
);
assert_eq!(
app.multiselect_snapshot().unwrap().primary(),
Some(&Target::ProcessOccurrence(OccurrenceEndpoint {
references: vec![second],
occurrence: leaf
}))
);
assert!(app.process_preview_primary().is_some());
assert!(app.draft.is_none());
assert_eq!(app.selected_node, None);
assert!(app.behavior.topology.camera_boundary() != first_camera);
assert_eq!(authored(&app), before);
@@ -1400,3 +1451,5 @@ fn completion_releases_worker_behind_busy_modal_without_navigation() {
assert_eq!(location(&app), prior);
assert_eq!(authored(&app), before);
}
mod saved_scenario;
@@ -0,0 +1,108 @@
arcadia 7;
project incident_workspace "Incident report review" id "70200000-0000-4000-8000-000000000001" {
// Two operational activities, with one lifeline appearance per Scenario.
oa_activity report_incident "Report incident" id "70200000-0000-4000-8000-000000000002" {}
oa_activity assess_incident "Assess incident" id "70200000-0000-4000-8000-000000000003" {}
interaction incident_report "Incident report" id "70200000-0000-4000-8000-000000000004" {
source @report_incident;
target @assess_incident;
}
oa_scenario incident_response "Review a reported incident" id "70200000-0000-4000-8000-000000000010" {
viewpoint OperationalActivity;
steps [@initial_report, @updated_report, @assess_call, @assess_reply, @unfinished_notice, @use_review];
}
oa_lifeline reporter "Reporting activity" id "70200000-0000-4000-8000-000000000011" {
parent @incident_response; represented @report_incident;
}
oa_lifeline assessor "Assessment activity" id "70200000-0000-4000-8000-000000000012" {
parent @incident_response; represented @assess_incident;
}
// Both appearances reuse one definition. Navigation must retain the occurrence.
oa_occurrence initial_report "Initial incident report" id "70200000-0000-4000-8000-000000000020" {
parent @incident_response;
body { Exchange { exchange @incident_report; sender @reporter; receiver @assessor; message_kind Asynchronous; }; };
}
oa_occurrence updated_report "Updated incident report" id "70200000-0000-4000-8000-000000000021" {
parent @incident_response;
body { Exchange { exchange @incident_report; sender @reporter; receiver @assessor; message_kind Asynchronous; }; };
}
// A correlated self call/reply with deliberately unassigned exchange definitions.
oa_occurrence assess_call "Assess report details" id "70200000-0000-4000-8000-000000000022" {
parent @incident_response;
body { Exchange { exchange none; sender @assessor; receiver @assessor; message_kind Call; }; };
}
oa_occurrence assess_reply "Assessment ready" id "70200000-0000-4000-8000-000000000023" {
parent @incident_response;
body { Exchange { exchange none; sender @assessor; receiver @assessor; message_kind Reply; reply_to @assess_call; }; };
}
// Intentional unfinished work: no receiver and therefore no fabricated arrow.
oa_occurrence unfinished_notice "Follow-up recipient undecided" id "70200000-0000-4000-8000-000000000024" {
parent @incident_response;
body { Exchange { exchange @incident_report; sender @reporter; receiver none; message_kind Asynchronous; }; };
}
oa_occurrence use_review "Use the reviewed reporting procedure" id "70200000-0000-4000-8000-000000000025" {
parent @incident_response;
body { ScenarioUse {
bindings [{ local @reporter; referenced @review_reporter; }, { local @assessor; referenced @review_assessor; }];
scenario @reporting_review;
}; };
}
// This definition stays opaque in the caller. Open it, then Back to the saved view.
oa_scenario reporting_review "Reporting procedure detail" id "70200000-0000-4000-8000-000000000030" {
viewpoint OperationalActivity;
steps [@review_delivery];
}
oa_lifeline review_reporter "Reporting activity in detail" id "70200000-0000-4000-8000-000000000031" {
parent @reporting_review; represented @report_incident;
}
oa_lifeline review_assessor "Assessment activity in detail" id "70200000-0000-4000-8000-000000000032" {
parent @reporting_review; represented @assess_incident;
}
oa_occurrence review_delivery "Check the incident report" id "70200000-0000-4000-8000-000000000033" {
parent @reporting_review;
body { Exchange { exchange @incident_report; sender @review_reporter; receiver @review_assessor; message_kind Asynchronous; }; };
}
diagram incident_readable "A - Incident review readable" id "70200000-0000-4000-8000-000000000040" {
layer Oa;
projection { Scenario { scenario @incident_response; }; };
layout { Scenario {
lanes [{ lifeline @reporter; x 160; }, { lifeline @assessor; x 640; }];
messages [
{ address { references []; occurrence @initial_report; }; send_y 160; },
{ address { references []; occurrence @updated_report; }; send_y 252; },
{ address { references []; occurrence @assess_call; }; send_y 344; },
{ address { references []; occurrence @assess_reply; }; send_y 436; }
];
}; };
}
diagram incident_wide "B - Incident review negative and far" id "70200000-0000-4000-8000-000000000041" {
layer Oa;
projection { Scenario { scenario @incident_response; }; };
layout { Scenario {
lanes [{ lifeline @reporter; x -500; }, { lifeline @assessor; x 50000; }];
messages [
{ address { references []; occurrence @initial_report; }; send_y 160; },
{ address { references []; occurrence @updated_report; }; send_y 252; },
{ address { references []; occurrence @assess_call; }; send_y 344; },
{ address { references []; occurrence @assess_reply; }; send_y 436; }
];
}; };
}
diagram incident_repair "C - Incident review needs layout repair" id "70200000-0000-4000-8000-000000000042" {
layer Oa;
projection { Scenario { scenario @incident_response; }; };
layout { Scenario {
// These valid authored lanes must remain saved during complete fallback.
lanes [{ lifeline @reporter; x 900; }, { lifeline @assessor; x -500; }];
messages [
// REPAIR: change only this 300 to 424, then Undo and Redo the edit.
{ address { references []; occurrence @assess_reply; }; send_y 300; },
// This authored value stays latent while the receiver is missing.
{ address { references []; occurrence @unfinished_notice; }; send_y 2000; }
];
}; };
}
}
@@ -0,0 +1,362 @@
//! Actual App chooser/explicit-reveal checks for saved Scenario identity and source history.
use super::*;
use crate::behavior::scenario::{CanvasEvent, State};
use crate::behavior::scenario_layout::{
Representation,
presentation::{self, PresentationStatus},
};
use iced::{Size, Vector};
fn fixture() -> App {
let p = language::parse(include_str!("saved_scenario.arcadia")).unwrap();
let errors: Vec<_> = oa_core::validation::validate(&p)
.into_iter()
.filter(|d| d.severity == oa_core::validation::Severity::Error)
.collect();
assert!(errors.is_empty(), "{errors:?}");
App::with_project(p)
}
fn id(app: &App, alias: &str) -> Id {
app.project()
.identities()
.into_iter()
.find(|(_, a)| *a == alias)
.unwrap()
.0
}
fn representation(view: Id) -> Representation {
Representation::Saved { diagram: view }
}
fn find(app: &App, subject: Id, view: Id) -> Usage {
usage::saved_usages_for(app.project(), subject, Some(view))
.into_iter()
.find(|row| row.target == Target::ScenarioObject(subject))
.expect("exact saved occurrence")
}
fn context(app: &App) -> behavior::scenario::Context {
let owner = app.behavior.active_owner.unwrap();
app.behavior
.scenario
.context(
app.canvas_document,
owner,
app.document.revision(),
app.canvas_epoch,
)
.unwrap()
}
fn navigate(app: &mut App, subject: Id, view: Id) -> Id {
let (token, rows) = open(app, subject);
let found = rows
.into_iter()
.find(|u| u.surface == Surface::Saved(view) && u.target == Target::ScenarioObject(subject))
.expect("saved chooser destination");
choose(app, token, found);
assert!(app.diagram_usages.is_none());
assert_eq!(app.page, Page::Behavior);
assert_eq!(app.active_diagram, Some(view));
assert_eq!(
app.behavior.scenario.representation(),
Some(representation(view))
);
assert_eq!(app.selected, subject);
token
}
#[test]
fn saved_queries_use_each_actual_presentation_and_keep_repeated_appearances_and_references_opaque()
{
let app = fixture();
let owner = id(&app, "incident_response");
let first = id(&app, "initial_report");
let second = id(&app, "updated_report");
let exchange = id(&app, "incident_report");
let latent = id(&app, "unfinished_notice");
let callee_message = id(&app, "review_delivery");
let views = ["incident_readable", "incident_wide", "incident_repair"].map(|a| id(&app, a));
let derived = app
.behavior
.scenario
.diagram_usages(app.project(), owner, first)
.pop()
.unwrap();
let rows = views.map(|view| find(&app, first, view));
assert_eq!(rows[0].bounds.y - derived.bounds.y, 12.);
assert_ne!(rows[0].bounds, rows[1].bounds);
assert_eq!(
rows[2].bounds, derived.bounds,
"complete fallback cannot retain the authored lane override"
);
for view in views {
let represented = usage::saved_usages_for(app.project(), exchange, Some(view));
assert_eq!(represented.len(), 2);
assert!(
represented
.iter()
.any(|u| u.target == Target::ScenarioObject(first))
);
assert!(
represented
.iter()
.any(|u| u.target == Target::ScenarioObject(second))
);
assert!(
represented
.iter()
.all(|u| u.surface == Surface::Saved(view))
);
assert!(usage::saved_usages_for(app.project(), callee_message, Some(view)).is_empty());
assert_eq!(
find(&app, latent, view).target,
Target::ScenarioObject(latent)
);
let shown = presentation::present(app.project(), representation(view), owner);
let whole = usage::saved_usages_for(app.project(), view, Some(view));
assert_eq!(whole.len(), 1);
assert_eq!(whole[0].target, Target::Whole);
assert_eq!(Some(whole[0].bounds), shown.bounds());
}
let wide = presentation::present(app.project(), representation(views[1]), owner)
.bounds()
.unwrap();
assert!(wide.x < -500. && wide.x + wide.width > 50000.);
let fallback = presentation::present(app.project(), representation(views[2]), owner);
assert!(matches!(
fallback.status(),
PresentationStatus::Fallback { .. }
));
assert_eq!(fallback.latent_messages(), &[latent]);
assert!(!fallback.capabilities().coordinate_input);
}
#[test]
fn saved_chooser_and_explicit_reveal_preserve_complete_source_and_actual_undo_redo() {
for alias in ["incident_readable", "incident_wide", "incident_repair"] {
let mut app = fixture();
let view = id(&app, alias);
let first = id(&app, "initial_report");
let owner = id(&app, "incident_response");
let original = app.document.snapshot();
app.document
.execute(Command::Rename(first, "Reviewed incident report".into()))
.unwrap();
let edited = app.document.snapshot();
app.sync_source();
app.refresh_draft();
let before = authored(&app);
navigate(&mut app, first, view);
assert_eq!(app.behavior.active_owner, Some(owner));
assert_eq!(authored(&app), before);
send(&mut app, Message::RevealInDiagram(view, first));
assert_eq!(app.active_diagram, Some(view));
assert_eq!(authored(&app), before);
assert!(app.document.undo());
assert_eq!(app.project(), &original.decoded.project);
assert_eq!(app.document.bundle(), &original.bundle);
assert!(app.document.redo());
assert_eq!(app.project(), &edited.decoded.project);
assert_eq!(app.document.bundle(), &edited.bundle);
}
}
#[test]
fn saved_reveal_and_chooser_refuse_source_property_and_behavior_drafts() {
for chooser in [false, true] {
for block in 0..3 {
let mut app = fixture();
let view = id(&app, "incident_repair");
let first = id(&app, "initial_report");
let choice = chooser.then(|| {
let (token, rows) = open(&mut app, first);
(
token,
rows.into_iter()
.find(|u| u.surface == Surface::Saved(view))
.unwrap(),
)
});
match block {
0 => {
let cursor = app.source_editor.cursor();
app.source_workspace
.edit("? preserve unfinished source".into(), cursor);
}
1 => {
app.draft
.as_mut()
.unwrap()
.input("/name", "Unapplied inspector value".into());
assert!(app.draft_dirty());
}
_ => {
let p = app.project().clone();
app.behavior
.creation
.update(&p, behavior::creation::Event::Open(Layer::Oa))
.unwrap();
assert!(app.behavior.has_pending());
}
}
let before = authored(&app);
let prior = location(&app);
let draft = app
.draft
.as_ref()
.map(|d| serde_json::to_value(d.recovery_snapshot()).unwrap());
if let Some((token, row)) = choice {
choose(&mut app, token, row);
} else {
send(&mut app, Message::RevealInDiagram(view, first));
}
assert_eq!(authored(&app), before);
assert_eq!(location(&app), prior);
assert_eq!(
app.draft
.as_ref()
.map(|d| serde_json::to_value(d.recovery_snapshot()).unwrap()),
draft
);
}
}
}
#[test]
fn saved_a_b_a_rejects_old_visit_and_panel_callbacks_before_deferred_measurement() {
let mut app = fixture();
let a = id(&app, "incident_readable");
let b = id(&app, "incident_wide");
let first = id(&app, "initial_report");
let before = authored(&app);
let old_token = navigate(&mut app, first, a);
let old = context(&app);
navigate(&mut app, first, b);
navigate(&mut app, first, a);
let current = context(&app);
assert_ne!(old.visit, current.visit);
let held = behavior::scenario::Context {
epoch: current.epoch,
..old
};
let camera = app.behavior.scenario.camera_boundary();
send(
&mut app,
Message::ScenarioCanvas(CanvasEvent::Viewport(held, Size::new(900., 900.))),
);
send(
&mut app,
Message::ScenarioCanvas(CanvasEvent::Shelf(
held,
crate::behavior::scenario::Shelf::Selection,
)),
);
send(
&mut app,
Message::DiagramUsage(Event::Search(old_token, "obsolete filter".into())),
);
send(&mut app, Message::DiagramUsage(Event::Close(old_token)));
assert_eq!(app.pending_scenario_reveal_visit, Some(current.visit));
assert!(app.pending_diagram_reveal.is_some());
assert!(app.behavior.scenario.camera_boundary() == camera);
let bounds = find(&app, first, a).bounds;
send(
&mut app,
Message::ScenarioCanvas(CanvasEvent::Viewport(current, Size::new(500., 300.))),
);
assert!(app.pending_diagram_reveal.is_none());
assert_eq!(context(&app).visit, current.visit);
let mut expected = State::default();
expected.enter_representation(app.canvas_document, current.scenario, representation(a));
expected.viewport(Size::new(500., 300.));
expected.zoom(Some(1.25));
expected.camera(
app.project(),
current.scenario,
behavior::canvas_workspace::Event::Pan(Vector::new(
250. - bounds.center_x() * 1.25,
150. - bounds.center_y() * 1.25,
)),
);
assert!(app.behavior.scenario.camera_boundary() == expected.camera_boundary());
assert_eq!(authored(&app), before);
}
#[test]
fn retargeted_saved_view_cannot_reveal_a_former_owner_occurrence() {
let mut app = fixture();
let first = id(&app, "initial_report");
let view = id(&app, "incident_readable");
let other = id(&app, "reporting_review");
let (token, rows) = open(&mut app, first);
let old = rows
.into_iter()
.find(|u| u.surface == Surface::Saved(view))
.unwrap();
let mut replacement = app.project().diagrams[&view].clone();
replacement.projection = ProjectionKind::Scenario { scenario: other };
replacement.layout = None;
app.document
.execute(Command::EditDiagram(view, replacement))
.unwrap();
app.sync_source();
app.refresh_draft();
let before = authored(&app);
let prior = location(&app);
choose(&mut app, token, old);
assert_eq!(location(&app), prior);
assert_eq!(authored(&app), before);
finish_lookup(&mut app);
let token = app.diagram_usages.as_ref().unwrap().token;
assert!(
app.diagram_usages
.as_ref()
.unwrap()
.usages
.iter()
.all(|u| u.surface != Surface::Saved(view))
);
send(&mut app, Message::DiagramUsage(Event::Close(token)));
send(&mut app, Message::RevealInDiagram(view, first));
assert_eq!(location(&app), prior);
assert_eq!(authored(&app), before);
}
#[test]
fn explicit_saved_layout_repair_undo_redo_requeries_authored_and_fallback_geometry_without_extra_history()
{
let mut app = fixture();
let view = id(&app, "incident_repair");
let first = id(&app, "initial_report");
let reply = id(&app, "assess_reply");
let before = app.document.snapshot();
let fallback = find(&app, first, view);
app.document
.execute(Command::SetScenarioMessagePosition {
diagram: view,
position: oa_core::ScenarioMessagePosition {
address: oa_core::OccurrenceEndpoint {
references: vec![],
occurrence: reply,
},
send_y: 424.,
},
})
.unwrap();
app.sync_source();
app.refresh_draft();
let repaired = app.document.snapshot();
let authored_row = find(&app, first, view);
assert_ne!(authored_row.bounds, fallback.bounds);
navigate(&mut app, first, view);
assert!(app.document.undo());
app.sync_source();
app.refresh_draft();
assert_eq!(app.project(), &before.decoded.project);
assert_eq!(app.document.bundle(), &before.bundle);
assert_eq!(find(&app, first, view).bounds, fallback.bounds);
assert!(app.document.redo());
app.sync_source();
app.refresh_draft();
assert_eq!(app.project(), &repaired.decoded.project);
assert_eq!(app.document.bundle(), &repaired.bundle);
assert_eq!(find(&app, first, view).bounds, authored_row.bounds);
}
+198
View File
@@ -0,0 +1,198 @@
//! Keep overlay Escape ahead of modal cancellation in the ordinary widget tree.
//!
//! Iced skips root.update when an overlay captures an event. Keeping this
//! wrapper present even when inactive preserves the child's widget state. A
//! current-overlay preflight also handles pickers opened mid-batch. Passive
//! overlays may see an ignored Escape again: the raw-input observer only makes
//! pending focus requests stale; it does not publish modal cancellation.
use super::{App, Message, Page, architecture_authoring};
use iced::advanced::{Clipboard, Layout, Shell, Widget, layout, overlay, renderer, widget};
use iced::{Element, Event, Length, Rectangle, Size, Theme, Vector, keyboard, mouse};
pub(crate) fn root<'a, R: renderer::Renderer + 'a>(
app: &App,
content: Element<'a, Message, Theme, R>,
) -> Element<'a, Message, Theme, R> {
let modal = app.text_workspace.menu().is_none()
&& (app.behavior.creation.has_pending()
|| app.diagram_usages.is_some()
|| app.behavior.source_active()
|| (app.page == Page::Text
&& (app.text_workspace.drawer().is_some()
|| app.source_navigation.open
|| app.source_rename.open())
&& app.architecture.is_none()));
Element::new(Bridge {
content,
messages: [
architecture_authoring::captured_escape_message(app),
modal.then_some(Message::Escape),
],
})
}
struct Bridge<'a, R> {
content: Element<'a, Message, Theme, R>,
messages: [Option<Message>; 2],
}
impl<R: renderer::Renderer> Widget<Message, Theme, R> for Bridge<'_, R> {
fn children(&self) -> Vec<widget::Tree> {
vec![widget::Tree::new(self.content.as_widget())]
}
fn diff(&self, tree: &mut widget::Tree) {
tree.diff_children(std::slice::from_ref(&self.content));
}
fn size(&self) -> Size<Length> {
self.content.as_widget().size()
}
fn size_hint(&self) -> Size<Length> {
self.content.as_widget().size_hint()
}
fn layout(
&mut self,
tree: &mut widget::Tree,
renderer: &R,
limits: &layout::Limits,
) -> layout::Node {
self.content
.as_widget_mut()
.layout(&mut tree.children[0], renderer, limits)
}
fn operate(
&mut self,
tree: &mut widget::Tree,
layout: Layout<'_>,
renderer: &R,
operation: &mut dyn widget::Operation,
) {
self.content
.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 escape = matches!(
event,
Event::Keyboard(keyboard::Event::KeyPressed {
key: keyboard::Key::Named(keyboard::key::Named::Escape),
..
})
);
// Iced collects overlays before processing a batch of base events. A
// preceding click in that batch can therefore open a picker whose
// Escape would otherwise reach focused fields underneath it. Offer only
// Escape to the current overlay before updating the ordinary tree.
if escape {
if let Some(element) = self.content.as_widget_mut().overlay(
&mut tree.children[0],
layout,
renderer,
viewport,
Vector::ZERO,
) {
let mut overlay = overlay::Nested::new(element);
let node = overlay.layout(renderer, viewport.size());
overlay.update(
event,
Layout::new(&node),
cursor,
renderer,
clipboard,
shell,
);
}
if shell.is_event_captured() {
return;
}
}
self.content.as_widget_mut().update(
&mut tree.children[0],
event,
layout,
cursor,
renderer,
clipboard,
shell,
viewport,
);
if shell.is_event_captured() && escape {
for message in self.messages.iter().flatten() {
shell.publish(message.clone());
}
}
}
fn draw(
&self,
tree: &widget::Tree,
renderer: &mut R,
theme: &Theme,
style: &renderer::Style,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
) {
self.content.as_widget().draw(
&tree.children[0],
renderer,
theme,
style,
layout,
cursor,
viewport,
);
}
fn mouse_interaction(
&self,
tree: &widget::Tree,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
renderer: &R,
) -> mouse::Interaction {
self.content.as_widget().mouse_interaction(
&tree.children[0],
layout,
cursor,
viewport,
renderer,
)
}
fn overlay<'b>(
&'b mut self,
tree: &'b mut widget::Tree,
layout: Layout<'b>,
renderer: &R,
viewport: &Rectangle,
translation: Vector,
) -> Option<overlay::Element<'b, Message, Theme, R>> {
self.content.as_widget_mut().overlay(
&mut tree.children[0],
layout,
renderer,
viewport,
translation,
)
}
}
#[cfg(test)]
pub(crate) mod tests;

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