Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
301126f609 | ||
|
|
cf37c36be1 | ||
|
|
17f1f89fef | ||
|
|
4fbdf6f385 | ||
|
|
bfc259854f | ||
|
|
b8685416f5 | ||
|
|
26979c1093 | ||
|
|
53d01a3d44 | ||
|
|
3aa80de89c | ||
|
|
79cc9d5551 | ||
|
|
3f9e6a18af | ||
|
|
7836a93edb | ||
|
|
f714a25bbc | ||
|
|
cb941367d7 | ||
|
|
96b756bece | ||
|
|
e624d5b9c4 | ||
|
|
d7c065991d | ||
|
|
136ed6e586 | ||
|
|
46124ce76c | ||
|
|
554cfbd9fe | ||
|
|
92ff99eb54 | ||
|
|
16904398db | ||
|
|
74542ae2c1 | ||
|
|
feab2020d8 | ||
|
|
bebf9f594c | ||
|
|
022d8cda2a | ||
|
|
357da24542 | ||
|
|
44095f78c2 | ||
|
|
0ecdadbb9c | ||
|
|
c28abcf13a | ||
|
|
0d8c144355 | ||
|
|
7f2947d651 | ||
|
|
3412d4f369 | ||
|
|
5e62d88c4f | ||
|
|
13f7e6b0d1 | ||
|
|
29525d7823 | ||
|
|
0f002b4254 | ||
|
|
3ac74825f8 | ||
|
|
64fea0a505 | ||
|
|
e1240295bd | ||
|
|
659b7a655a | ||
|
|
a17ca5d066 | ||
|
|
95e0e12716 | ||
|
|
dd39e1fdc1 | ||
|
|
f555193172 | ||
|
|
e44f07565c | ||
|
|
a357b22778 | ||
|
|
05b613ea19 | ||
|
|
afa74a3059 | ||
|
|
200aa90689 | ||
|
|
2c6ededc51 | ||
|
|
bbd3b0e843 | ||
|
|
8c3e7acd14 | ||
|
|
26ca4a4c81 | ||
|
|
aadc6396d3 | ||
|
|
e0685b8e52 | ||
|
|
d61f1f3877 | ||
|
|
419c78c071 | ||
|
|
83f53c7c6e | ||
|
|
40154983cc | ||
|
|
8112dea7a3 | ||
|
|
e2c027e54c | ||
|
|
b25065c442 | ||
|
|
956a584132 | ||
|
|
e57c390d49 | ||
|
|
24bb24839b | ||
|
|
f7d84faf30 | ||
|
|
260d773852 | ||
|
|
42511f0f8a | ||
|
|
c3c2d01453 | ||
|
|
4c387ccd64 | ||
|
|
cb46ef4054 | ||
|
|
2d9e9c1849 | ||
|
|
12b2237ee9 | ||
|
|
49a4cb17b1 | ||
|
|
28e21a0ae8 | ||
|
|
dc1506f2e8 | ||
|
|
5b174a9fa6 | ||
|
|
0d6f677329 | ||
|
|
60ea483430 | ||
|
|
0c38980c70 | ||
|
|
f1bd5149db | ||
|
|
3840ca2965 | ||
|
|
5ce798b713 | ||
|
|
1b3c401a19 | ||
|
|
76209cc186 | ||
|
|
5a7018117d | ||
|
|
85173a3c95 | ||
|
|
d9b3f0dc91 | ||
|
|
94b5eecb1c | ||
|
|
6b9cc77ece | ||
|
|
bc8bc4f5a1 | ||
|
|
f52d81c1b9 | ||
|
|
97c91cc06c | ||
|
|
0bd7886608 | ||
|
|
563772dc62 | ||
|
|
eefdedaf47 | ||
|
|
bc88acd932 | ||
|
|
17d56c06f7 | ||
|
|
bcb7057ab2 | ||
|
|
36eff7225a | ||
|
|
dd38c46a4b | ||
|
|
a31b40d0f8 | ||
|
|
88c17a448f | ||
|
|
abffa4b3f0 | ||
|
|
82fd0acf64 | ||
|
|
ff0175488a | ||
|
|
06ec962129 | ||
|
|
6969c825a8 | ||
|
|
638d06c4e3 | ||
|
|
41fa5d530c | ||
|
|
828c50c491 | ||
|
|
f8a6a5264a | ||
|
|
b4c8fd7102 | ||
|
|
e7491fb00a | ||
|
|
bef1ea33e4 | ||
|
|
3c9e6eeb64 | ||
|
|
80f2588214 | ||
|
|
8895dd2e0f | ||
|
|
8d55d2d2eb | ||
|
|
03d20ec296 | ||
|
|
c0e66f94e3 | ||
|
|
7f081ad457 | ||
|
|
c7ed894900 | ||
|
|
98427b9620 | ||
|
|
cab01c3d7f | ||
|
|
d8bdbd9e48 | ||
|
|
7d14bd0dd1 | ||
|
|
dc0f2e12f3 | ||
|
|
bc311bc076 | ||
|
|
a34558d3be | ||
|
|
2cc4d115d0 | ||
|
|
1e9f0c2a97 | ||
|
|
1516949567 | ||
|
|
c4e1580bb0 | ||
|
|
c6d296a7e2 | ||
|
|
80cf197be6 | ||
|
|
865cd55e21 | ||
|
|
6d2b9619e3 | ||
|
|
65a1fef17c | ||
|
|
ef1a4478c5 | ||
|
|
632c8feb36 | ||
|
|
aa40345281 | ||
|
|
8547b85825 | ||
|
|
4ecad78433 | ||
|
|
eb5161b207 | ||
|
|
aed40d7464 | ||
|
|
b6b76efc0d | ||
|
|
01bb52a76d | ||
|
|
1797516f46 | ||
|
|
e2ecfea69b | ||
|
|
4b6b5fc197 | ||
|
|
2b2d2bc18b |
@@ -12,3 +12,80 @@
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.zip binary
|
||||
|
||||
# Exact source/recovery oracles and archived ledgers retain their measured bytes.
|
||||
# Keep ordinary text diffs; only disable checkout/check-in newline conversion.
|
||||
PROJECT_GOAL.md -text whitespace=cr-at-eol
|
||||
harness/fixtures/release1-native/** -text
|
||||
harness/fixtures/interface-operation-probe/** -text
|
||||
harness/history/2026-09-06-ledgers-before-consolidation/** -text
|
||||
harness/history/2026-09-10-ledgers-before-continuation-refresh/** -text
|
||||
harness/history/2026-09-10-ledgers-before-second-consolidation/** -text whitespace=blank-at-eol,blank-at-eof,space-before-tab,cr-at-eol
|
||||
harness/experiments/textual-authoring-20260911-local/** -text whitespace=blank-at-eol,blank-at-eof,space-before-tab,cr-at-eol
|
||||
crates/oa-desktop/src/recovery/fixtures/*.json -text
|
||||
crates/oa-core/src/analysis_numeric/fixtures/** -text whitespace=cr-at-eol
|
||||
harness/fixtures/source7-native/** -text
|
||||
harness/fixtures/basic-editing/** -text
|
||||
harness/fixtures/scenario-authoring/base/** -text whitespace=cr-at-eol
|
||||
harness/experiments/textual-authoring-20260911/** -text whitespace=cr-at-eol
|
||||
harness/experiments/textual-authoring-20260911/observed/split-entry.arcadia whitespace=-blank-at-eol,cr-at-eol
|
||||
harness/experiments/analysis-context-20260911/** -text whitespace=cr-at-eol
|
||||
harness/experiments/analysis-baseline-20260911/** -text whitespace=cr-at-eol
|
||||
harness/experiments/library-compatibility-20260911/** -text whitespace=cr-at-eol
|
||||
harness/experiments/refined-contracts-review-20260911/** -text whitespace=cr-at-eol
|
||||
harness/behavior-creation-contract.rs -text whitespace=cr-at-eol
|
||||
harness/experiments/source-cli-independent-20260911/** -text whitespace=cr-at-eol
|
||||
harness/experiments/authoring-prerelease-20260911/** -text whitespace=cr-at-eol
|
||||
harness/experiments/cli-packaging-20260911/** -text whitespace=cr-at-eol
|
||||
harness/experiments/cli-prerelease-20260911/** -text whitespace=cr-at-eol
|
||||
harness/experiments/contextual-source-20260911/** -text whitespace=cr-at-eol
|
||||
harness/experiments/contextual-source-20260911/**/*.log whitespace=-blank-at-eof,cr-at-eol
|
||||
harness/fixtures/staged-source-authoring/** -text whitespace=cr-at-eol
|
||||
harness/staged-source-authoring-contract.rs -text whitespace=cr-at-eol
|
||||
|
||||
# Preserve checksum-bound font notices verbatim through Git round trips.
|
||||
scripts/windows-notices/GNOME-Cantarell-61362600e104-COPYING -text
|
||||
scripts/windows-notices/Fira-OFL.txt -text
|
||||
scripts/windows-notices/FiraSans-Regular-NOTICE.txt -text
|
||||
harness/experiments/behavior-creation-native-20260911/** -text whitespace=cr-at-eol
|
||||
harness/experiments/constructor-defaults-20260911/** -text whitespace=cr-at-eol
|
||||
harness/experiments/constructor-defaults-20260911/results/*.arcadia whitespace=-blank-at-eol,cr-at-eol
|
||||
harness/release-scenario-native-9e8r6t-20260911.md -text whitespace=cr-at-eol
|
||||
harness/experiments/analysis-numeric-gate-20260911/** -text
|
||||
harness/experiments/chooser-correction-20260911/** -text whitespace=-blank-at-eof,cr-at-eol
|
||||
harness/experiments/chooser-lint-gate-20260911/** -text whitespace=-blank-at-eof,cr-at-eol
|
||||
harness/history/2026-09-11-behavior-wiki-before-refresh.md -text whitespace=cr-at-eol
|
||||
# Preserve the independently fingerprinted historical review verbatim.
|
||||
harness/release-behavior-canvas-phase1-review-20260911.md -text whitespace=-blank-at-eof,cr-at-eol
|
||||
harness/experiments/scenario-native-3c9e6ee-20260911/** -text whitespace=-blank-at-eof,cr-at-eol
|
||||
harness/experiments/scenario-native-3c9e6ee-20260911/**/*.arcadia whitespace=-blank-at-eol,-blank-at-eof,cr-at-eol
|
||||
harness/experiments/analysis-codec-windows-20260911/** -text whitespace=-blank-at-eof,cr-at-eol
|
||||
harness/history/2026-09-11-ledgers-before-gitea-migration/** -text whitespace=-blank-at-eol,-blank-at-eof,cr-at-eol
|
||||
harness/experiments/canvas-union-windows-20260911/** -text whitespace=-blank-at-eof,cr-at-eol
|
||||
harness/experiments/canvas-a31b40d-windows-native-20260911/** -text whitespace=-blank-at-eol,-blank-at-eof,cr-at-eol
|
||||
harness/experiments/allocation-d9b3f0d-windows-20260912/** -text whitespace=-blank-at-eol,-blank-at-eof,cr-at-eol
|
||||
harness/experiments/allocation-3840ca2-pause-20260912/** -text whitespace=-blank-at-eol,-blank-at-eof,cr-at-eol
|
||||
harness/experiments/diagram-removal-windows-20260912/** -text whitespace=-blank-at-eol,-blank-at-eof,cr-at-eol
|
||||
harness/experiments/diagram-resize-foundation-20260912/** -text whitespace=-blank-at-eol,-blank-at-eof,cr-at-eol
|
||||
harness/experiments/delete-native-f7-20260912/** -text whitespace=-blank-at-eol,-blank-at-eof,cr-at-eol
|
||||
harness/experiments/canvas-editing-followup-20260912/** -text whitespace=-blank-at-eol,-blank-at-eof,cr-at-eol
|
||||
harness/experiments/resize-port-policy-20260912/** -text whitespace=-blank-at-eol,-blank-at-eof,cr-at-eol
|
||||
harness/experiments/canvas-native-83f53c7-20260912/** -text whitespace=-blank-at-eol,-blank-at-eof,cr-at-eol
|
||||
harness/experiments/nested-lifecycle-union-20260912/** -text whitespace=-blank-at-eol,-blank-at-eof,cr-at-eol
|
||||
harness/experiments/canvas-native-d61f1f3-20260912/** -text whitespace=-blank-at-eol,-blank-at-eof,cr-at-eol
|
||||
harness/experiments/lifecycle-fixture-preflight-20260913/** -text whitespace=-blank-at-eol,-blank-at-eof,cr-at-eol
|
||||
harness/experiments/palette-readability-20260913/** -text whitespace=-blank-at-eol,-blank-at-eof,cr-at-eol
|
||||
harness/release-analysis-next-acceptance-20260913.md -text whitespace=-blank-at-eol,-blank-at-eof,cr-at-eol
|
||||
harness/experiments/runtime-notices-20260913/** -text whitespace=-blank-at-eol,-blank-at-eof,cr-at-eol
|
||||
harness/experiments/analysis-checked-save-windows-20260913/** -text whitespace=-blank-at-eol,-blank-at-eof,cr-at-eol
|
||||
harness/experiments/canvas-native-dd39e1f-20260913/** -text whitespace=-blank-at-eol,-blank-at-eof,cr-at-eol
|
||||
harness/experiments/navigation-palette-e124029-windows-20260913/** -text whitespace=-blank-at-eol,-blank-at-eof,cr-at-eol
|
||||
harness/experiments/navigation-native-preparation-e124029-20260913/** -text whitespace=-blank-at-eol,-blank-at-eof,cr-at-eol
|
||||
harness/experiments/analysis-source-s0-s1-windows-20260913/** -text whitespace=-blank-at-eol,-blank-at-eof,cr-at-eol
|
||||
harness/experiments/analysis-source3-bridge-windows-20260913/** -text whitespace=-blank-at-eol,-blank-at-eof,cr-at-eol
|
||||
harness/scenario-analysis-version-integration-review-20260913.md -text whitespace=cr-at-eol
|
||||
harness/scenario-analysis-version-integration-inputs-20260913.json -text whitespace=cr-at-eol
|
||||
harness/experiments/analysis-source-s2a-windows-20260913/** -text whitespace=-blank-at-eol,-blank-at-eof,cr-at-eol
|
||||
|
||||
# Preserve the pinned Iced dependency bytes and upstream manifest.
|
||||
crates/vendor/iced_widget/** -text whitespace=cr-at-eol
|
||||
|
||||
@@ -11,3 +11,4 @@
|
||||
/model-store/
|
||||
/.local/
|
||||
.arcadia-save-*/
|
||||
__pycache__/
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
# Agent working agreement
|
||||
|
||||
## Working method
|
||||
|
||||
- Follow `PROJECT_GOAL.md` and the user's current instructions. Work autonomously within scope. Resolve discoverable questions yourself; record, test, and revise reversible assumptions. Ask only when a consequential decision needs information or authorization not already available. Continue independent useful work while blocked elsewhere.
|
||||
- Map unfamiliar domains through primary sources, comparable systems, and real user workflows. Identify underlying needs, valuable concepts, and avoidable friction; evaluate improvements rather than merely reproducing features. Synthesize findings into decisions or experiments, and stop research when another action offers greater value.
|
||||
- Make specifications precise enough for compatible independent implementations: definitions, relationships, invariants, valid/invalid examples, and lifecycle or transformation rules where relevant. Trace important requirements and decisions to implementation and verification. Resolve specification ambiguity at its source when implementation exposes it.
|
||||
- For consequential uncertain choices, compare materially different candidates against explicit user-derived criteria. Ask independent critics to challenge assumptions and attempt to falsify proposals; refine or replace candidates as evidence warrants. Record the choice, evidence, tradeoffs, rejected approaches, and reasons to revisit it. Use proportionate effort for reversible local choices.
|
||||
- Plan milestones that resolve uncertainty or deliver useful capability, with explicit dependencies. Test foundational assumptions before substantial downstream work. At checkpoints, assess progress toward the actual objective and replan when evidence invalidates the strategy or progress stalls.
|
||||
- Define observable success and suitable evaluators before repeated improvement: invariants, examples, round trips, transformations, performance checks, realistic user tasks, or explicit review rubrics. Ensure the evaluator measures the intended outcome. For experiments, record the question, expectations, method, result, and implications; isolate exploratory prototypes from production architecture.
|
||||
- Build and run meaningful changes, exercise actual behavior, and inspect useful logs. Investigate crashes, serious warnings, corrupted state, and interaction defects. For GUI work, use Computer Use or equivalent tools to inspect realistic workflows, cognitive load, editing/selection, navigation, feedback, recovery, loading/empty/failure states, accessibility, layout, focus/disabled states, and responsiveness.
|
||||
- Match independent reviews to the artifact and risks. For languages or formats, include ambiguity, round trips, human/AI editability, and version-control behavior where relevant. Verify findings, fix the appropriate conceptual, specification, architectural, implementation, or interaction layer, and recheck affected dependents. Reuse valid evidence; repeat checks for concrete risks, changes, findings, or required gates. Compilation, screenshots, and worker reports alone do not establish correctness or usability.
|
||||
- Favor the simplest maintainable design satisfying known requirements and justified extensibility. Avoid speculative abstraction, unrelated refactoring, and repeating approaches already invalidated by evidence.
|
||||
|
||||
## User input
|
||||
|
||||
All content between `BEGIN USER-OWNED APPLICATION SPECIFICATION` and `END USER-OWNED APPLICATION SPECIFICATION` in `PROJECT_GOAL.md` belongs to the user. Its headings and structure are unrestricted. Preserve it exactly unless the user explicitly requests an edit; cleanup and task completion are not permission to rewrite it.
|
||||
|
||||
Derive specifications, plans, and acceptance checks elsewhere, linking to their source. Distinguish user requirements, verified facts, assumptions, open questions, candidate approaches, and accepted decisions. Interpretations must not silently replace the user's intent. When input changes, reconcile affected work. Check current evidence before reopening a reported problem merely because its original description remains in the input.
|
||||
|
||||
## Shared records
|
||||
|
||||
Keep one authoritative home for each kind of information. Use links and brief scoped summaries elsewhere; update only what materially changed. Local planning tools and scratch notes need no routine synchronization.
|
||||
|
||||
| Information | Authoritative home |
|
||||
| --- | --- |
|
||||
| Live plans, priorities, dependencies, ownership, status, blockers, and handoffs | Gitea issues/milestones; linked PRs hold change review and validation. |
|
||||
| Specifications, architecture, interfaces, and build/run documentation | Established version-controlled project documents. |
|
||||
| Durable decisions and reusable evidence | `harness/DECISIONS.md` and relevant topic notes, or existing equivalents. |
|
||||
| Navigation and integrated-state checkpoint | `harness/STATE.md`: links, verified code commit/release, capabilities, and material limitations. No live task ledger. |
|
||||
| Shared entrypoint | Gitea Wiki: overview and links, without duplicating versioned documentation. |
|
||||
| Published deliverables | Gitea releases, tied to source commits and verification. |
|
||||
|
||||
Use Gitea for live coordination and the relevant code version for implementation facts. Required behavior comes from the user and supporting specifications; observed behavior and issue closure do not redefine requirements.
|
||||
|
||||
## Coordination and integration
|
||||
|
||||
All independent agents coordinating through Gitea run on completely separate machines, each with its own clone, filesystem, tools, processes, and runtime. Gitea and pushed Git history are the shared communication and delivery channels. Local files (including `harness`), unpushed commits, worktrees, test results, and localhost services are not accessible to peers. Matching branch names or paths do not imply synchronized contents.
|
||||
|
||||
1. On starting or resuming, including after compaction, inspect the working branch, local changes, applicable instructions, relevant shared records, and existing implementation. Recover context from authoritative records. Check for newer shared instructions on the actual remote default branch without overwriting local work. Read what is relevant; avoid repeated full-history scans and constant polling.
|
||||
2. Search before creating tasks. Claim a meaningful outcome in its Gitea issue with a stable session identity, scope, remote repository/branch, and acceptance criteria. Keep small steps within that task. Recheck after claiming; comments are not atomic locks. Respect existing ownership and resolve overlapping claims before overlapping work.
|
||||
3. Independent sessions are peers. Each owns its contribution and delegated work on its own machine. Record one integration/release owner when a milestone needs shared coordination. Use distinct remote work branches, partition files/interfaces, and coordinate changes to shared specifications and documents. Worktrees provide local isolation only; they do not share work between machines.
|
||||
4. At meaningful checkpoints or handoffs, record changed results, commit/PR links, verification commands and environment, blockers, and next actions. Identify the tested code version and label uncommitted or unpublished work as local-only. Before asking peers to consume results, push the required code and versioned documentation, including relevant `harness` updates; publish other required artifacts in Gitea and link them. Use repository-relative paths with commit references, reproducible commands, and necessary environment details; local paths and localhost URLs alone are not handoffs. Do not assume another agent is running or has read a message; silence alone does not establish abandonment. Record evidence and preserve existing work when reassigning a task.
|
||||
5. Follow project review and merge conventions. Fetch the relevant remote branches and inspect the identified commits before reviewing or integrating another machine's work. Resolve conflicts while preserving others' edits, and verify the combined result locally; report material environment differences. Local instruction-file edits reach peers only after publication and retrieval. Do not overwrite others' work or force-push shared history to resolve coordination problems.
|
||||
6. Close work when its acceptance criteria are met and the result is integrated or delivered as appropriate. Mark duplicates and superseded tasks explicitly with replacement links. Then choose further useful unowned work while the requested objective remains incomplete.
|
||||
|
||||
Use Gitea MCP for shared coordination. If unavailable, report the failure, buffer necessary handoff information locally, and continue already-owned or clearly non-overlapping work. Reconcile with current Gitea state when access returns; do not claim an ownership update, review, or publication succeeded without confirmation.
|
||||
|
||||
## Delegation and reasoning
|
||||
|
||||
Use GPT-6 Astra exclusively. Gitea peers are independent remote sessions, not subagents controlled by this session. Spawned subagents use the execution environment actually provided by the agent tools; do not assume they share files, and publish their results before Gitea peers need them. Delegate bounded independent research, design, implementation, or review when it improves progress, judgment, or preserves the parent's context. Give subagents the relevant context, ownership boundaries, result format, and evidence requirements. Use fresh critics when practical. The parent owns synthesis and integration and normally consolidates results into Gitea. Choose concurrency according to useful independent work and available resources, including other sessions; there is no agent-count quota.
|
||||
|
||||
Optimize reasoning effort for verified outcomes per cost, including review, retries, and repair:
|
||||
|
||||
- Main agent: default to high for planning, synthesis, integration, and acceptance decisions; medium for dispatching a settled plan and routine coordination.
|
||||
- Subagents: default to medium for bounded implementation and targeted research; low for straightforward retrieval, mechanical edits, and running established checks; high for complex debugging, correctness review, adversarial analysis, or ambiguous design.
|
||||
- Use extra-high selectively for difficult foundational semantic, methodological, formal, or cross-domain decisions. Reserve max for exceptional unresolved problems where additional reasoning is likely to justify its cost. Retain higher settings when evidence supports their value.
|
||||
|
||||
Allocate by task difficulty and consequences, not role or length alone. Start higher when a clearly difficult decision has expensive downstream consequences. Escalate for concrete reasoning difficulties; resolve missing evidence or unclear requirements at their source. Set subagent effort explicitly through supported controls to avoid unintended inheritance. Do not claim an unapplied change or alter global configuration merely to enforce these preferences.
|
||||
|
||||
## Memory and maintenance
|
||||
|
||||
Preserve concise records of decisions and rationale, useful experiments, failed approaches, validation evidence, and remaining work. Link substantial evidence rather than copying logs into indexes. Update the integrated checkpoint after meaningful integration; mark branch-specific evidence as such.
|
||||
|
||||
Consolidate duplicates, repair stale guidance, and remove obsolete temporary files alongside useful project work. Before removing material, check references, active ownership, and ongoing use; preserve unique knowledge and update affected links. Prefer closing, superseding, or archiving history over deletion. User input is excluded from autonomous cleanup.
|
||||
|
||||
If legacy local task ledgers or plans duplicate Gitea, migrate their live content once into existing or appropriately scoped issues, preserve useful decisions/evidence, and mark the old records superseded. Record the migration so later sessions do not repeat it. Do not create empty documents or maintain records for ceremony.
|
||||
|
||||
## Delivery
|
||||
|
||||
Publish versioned Gitea releases at meaningful working milestones; mark unfinished versions as prereleases. Check existing releases/tags and coordinate one owner and version before publishing. Build from an identified integrated commit and verify the packaged result and launch steps.
|
||||
|
||||
Include runnable builds or appropriate artifacts, concise release notes, build/run/deployment instructions as applicable, known limitations, tested platforms, and verification evidence. Confirm publication and artifact availability. Use existing authorized API access or project tooling when MCP lacks a needed release/upload operation. Do not silently replace published versions with different contents.
|
||||
Generated
+33
-2
@@ -1567,8 +1567,6 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "iced_widget"
|
||||
version = "0.14.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b1596afa0d3109c2618e8bc12bae6c11d3064df8f95c42dfce570397dbe957ab"
|
||||
dependencies = [
|
||||
"iced_renderer",
|
||||
"log",
|
||||
@@ -2107,6 +2105,25 @@ dependencies = [
|
||||
"jni-sys 0.3.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-bigint"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "93e7820bc0a80a0238e650327316f929ba18d5be054b647490a3a6a339f3e7c0"
|
||||
dependencies = [
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-integer"
|
||||
version = "0.1.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-traits"
|
||||
version = "0.2.19"
|
||||
@@ -2139,10 +2156,23 @@ dependencies = [
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "oa-cli"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"oa-core",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "oa-core"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"num-bigint",
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
"same-file",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -2166,6 +2196,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"unicode-segmentation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
+5
-1
@@ -1,7 +1,11 @@
|
||||
[workspace]
|
||||
members = ["crates/oa-core", "crates/oa-desktop", "crates/oa-server"]
|
||||
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"
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
# ARCADIA Studio — development-run summary
|
||||
|
||||
Latest correction: your review identified a misplaced priority in this run. Advanced counterpart review received attention while basic diagram editing remained awkward. The first attempted fix added large creation buttons; you rejected that too. Actual use of the installed Capella 7.1 has now informed a redesign around a compact contextual palette, in-place naming and renaming, relevant existing elements, explicit activity allocation and clear counterpart destinations. Implementation and native verification remain in progress; the downloadable preview described below predates these corrections. [Observed Capella behavior and design decisions](harness/release-capella-basic-editing-20260907.md).
|
||||
|
||||
Updated 7 September 2026. This summarizes the whole continuation run: work integrated from earlier branches, new engineering features, defects found through actual use, and verification on this machine. **The product has advanced substantially, but the complete Windows/Linux release has not yet been accepted.** The latest Windows preview is built, packaged and available for your inspection below.
|
||||
|
||||
## Objective and starting point
|
||||
|
||||
The objective is a usable Operational Analysis (OA) and System Analysis (SA) engineering application, with equivalent graphical, textual and REST workflows, maintained traceability, offline guidance, and working Windows/Linux distributions. The run treated domain semantics, interaction design, recovery, examples and verification as part of the product—not merely as supporting documentation.
|
||||
|
||||
The initial checkout lagged behind other branches. An ancestry, patch-equivalence and code audit found that direct process/scenario/state editing, data contracts, hosted integration and earlier semantic review were already combined in `codex/data-contract-integration`. Work continued from that baseline on `codex/release1-continuation`; duplicate preliminary changes were preserved rather than overwriting integrated work. Source6 work and its evidence were subsequently committed at `4b6b5fc`; source7 and later improvements remain a separately reviewed continuation. No remote publication is implied. [Branch integration](harness/release-branch-integration-20260906.md)
|
||||
|
||||
## Engineering features and editing workflows
|
||||
|
||||
The integrated foundation includes architecture views and ports; typed data, interfaces and values; direct scenario/process gestures; and persisted state-machine views with regions, pseudostates, transitions and maintained action references. The run extended and tested those workflows rather than replacing their shared model.
|
||||
|
||||
Source6 added first-class OA **Communication Means** between participants, with allocated interactions and explicit direction. OA→SA transition requires reviewed component-port choices; it does not silently invent responsibility or substitute a participant for a port. Scenario endpoint resolution handles reverse communication on unoriented channels and correlated Call/Reply behavior. Native testing also corrected transitions that left users on the wrong layer or diagram despite producing the right model.
|
||||
|
||||
Saved views gained exchange categories, hidden-edge choices, relation-kind selection, behavior coverage/context filters and exchange-name/item labels. Filters preserve semantic identities and stable port geometry. Traceability views explicitly support both layers. Advisory checks now locate incomplete behavior coverage, non-leaf work exchanges and mission/capability gaps without turning legitimate unfinished engineering work into structural corruption. [Coverage and implementation map](harness/release-current-coverage-20260906.md)
|
||||
|
||||
Semantic reconciliation became an editable, atomic **multi-source review**. Engineers can review scalar values, constructors, keyed collections, ordered members, child ownership, incident relationships and artifact lifecycle changes. Included rows compose through the shared planner; excluded invalid rows retain their raw input. Conflicting intentions are checked against the actual destination field. Artifact creation, movement, deletion and all seven metadata fields preserve distinctions such as absent versus empty text. Native tests exercised complete Apply/Save/Undo/Redo cycles, not just individual controls. [Artifact evidence](harness/release-artifacts-native-20260906.md), [attachment evidence](harness/release-attachments-native-20260906.md)
|
||||
|
||||
The optional hosted workflow continues to use the same core model and REST service, with conditional ETags, explicit publish/save, conflict handling and retained local drafts. Connection credentials remain in memory. Typed commands, modular source, diagnostics and OpenAPI were kept aligned as representations evolved. Existing hosted acceptance is recorded separately from still-required current-platform transport cases. [Hosted workflow](harness/release-hosted-desktop.md)
|
||||
|
||||
## Source, history, recovery and onboarding
|
||||
|
||||
Home now offers New/Open, examples, the guide, recent local models and recoverable sessions. Missing recent files remain visible with a recovery route. The searchable offline engineering guide has related topics and guarded workspace actions; navigation and scroll behavior were corrected through minimum-window testing.
|
||||
|
||||
Periodic user-local checkpoints preserve the accepted source bundle and raw source, property, behavior, workflow and semantic-review drafts. They do not silently save model files or restore an executable command plan. Recovery creates an unsaved copy, rebuilds previews and requires explicit saving. Write preflight preserves the last good checkpoint if a new snapshot is invalid; close/write races and stale save receipts were covered with regressions.
|
||||
|
||||
Frozen, nonempty old5/old6 fixtures support bounded migration to source7. Accepted headers are upgraded while comments, includes and raw invalid edits are retained. Unsupported newer intent hidden in old structural fields or constructor caches is rejected without destroying the checkpoint; ordinary prose containing those words remains valid raw text. Native old5, old6 and current7 recovery cases compared full bundles, model exports and draft DTOs. [Recovery contract](harness/release-source7-recovery-20260906.md)
|
||||
|
||||
A real Save As defect was fixed: changing the entry filename had created an extra Undo transaction and cleared Redo. Save As now adopts file identity across retained history without an artificial model edit, with conservative filename-collision preflight before disk writes. Journal publication was hardened against Windows scanner/share races and accidental takeover of another writer; Linux uses no-replace publication with fail-closed unsupported behavior. These safeguards do not claim model-file compare-and-swap or universal power-loss guarantees. [Save As](harness/release-save-as-runtime-packaging.md), [journal review](harness/release-journal-publication-review-20260906.md)
|
||||
|
||||
A coherent water-pressure restoration lesson now connects OA responsibilities and communication to explicit SA ports, interfaces, processes, scenarios, state behavior, saved views and evidence. It contains 94 elements, 62 relations, five views, 68 traces and 30 source files, plus deterministic replay inputs and one deliberate warning. Home and the guide open it. Copy the **whole example folder**, including its evidence, before editing. [Walkthrough](examples/release1/README.md)
|
||||
|
||||
## Source7: two substantive semantic gaps closed
|
||||
|
||||
Independent probes established two gaps that existing constructs could not faithfully represent:
|
||||
|
||||
* **Caller-specific process assembly:** ContextualLink addresses include the caller and complete reference-occurrence path. The same leaf reused through two references remains two distinct contributions. Precedence, exchange assembly and common-work purposes have separate meanings; common work does not imply execution fusion. Deletion removes dependent links rather than silently shortening their addresses.
|
||||
* **Exact interface-operation identity:** Messages can retain unresolved operation intent or select a specific InterfaceItemAllocation. Selection requires proof through actual compatible component ports. Reply derives its operation from the exact Call while validating its own channel and correlation; parameterless operations remain distinguishable. Removing an allocation never silently selects another equivalent-looking allocation, and ordinary channel validity still applies.
|
||||
|
||||
Both landed under one source/schema7 contract across core, parser/updater, REST, graphical forms, semantic review and recovery. Old versions reject new fields even when explicitly null. Reference/path admission and bounded contextual analysis are deterministic and independent of cache size; exhausted analysis reports incompleteness rather than fabricated reachability. [Ratified contract](harness/release-source7-integration-decision-20260906.md)
|
||||
|
||||
Native inspection then exposed obscured contextual routes, endpoint hit-priority conflicts, and a separate process Fit problem. Routing and qualified selection were corrected and independently checked. The latest Fit change measures the actual remaining viewport, adds bounded tools/details disclosures and keeps pending-form actions reachable. Its first six tests caught two real failures: Iced discarded zero-size slots and reset the canvas tree. A narrow slot-preserving fix passed all six unchanged expectations, the existing pending-scroll and branch-fit regressions, and the full desktop suite. [Fit review](harness/release-process-fit-independent-review-20260907.md)
|
||||
|
||||
## What has actually been verified
|
||||
|
||||
Windows checkpoints include core **433 passed/1 ignored**, corrected full server **56/56**, and now full desktop **570/570** after the Fit fix. The latest locked workspace binaries build, formatting and strict workspace/all-target Clippy all passed. Native checks used separately hashed binaries and independent complete-model/source oracles for artifacts, batch review, communication, teaching edits, source7 operation choices, nested links and crash recovery—including Save, Undo, Redo and reopening. These are scoped results, not one blanket final-release pass. [Current evidence ledger](harness/STATE.md), [source7 native report](harness/release-source7-native-acceptance-20260907.md)
|
||||
|
||||
Linux verification ran in a real Ubuntu guest under QEMU using Rust1.90, ext4 and immutable source snapshots. Current core completed **436 passed/2 ignored**. Server compilation passed; unit **2/2** and HTTP **50/51** passed. One timing-dependent test returned the legitimate 30-second deadline response504 instead of412, including an isolated rerun. Its replacement uses a private test-only preparation gate to prove real HTTP overlap and retain exact conflict/model/disk assertions; that correction passed on Windows and still needs Linux execution. [Linux gate](harness/release-source7-linux-gate-20260907.md)
|
||||
|
||||
WSL installation was authorized, but importing the isolated distro remains pending a Windows restart. QEMU resources, shutdown budgets and caches were preserved. A cross-built probe actually executed on Linux. After VMware clock adjustments interrupted one watchdog and another run paused for your Windows build, the **full Linux desktop cross-build passed** with Rust1.90 on a fresh snapshot containing the Fit fix. Its final cached build took2m20s; the ELF dependencies, glibc2.28 link baseline and all37 teaching files were checked. Actual Linux GUI execution remains unverified. The built snapshot precedes the subsequent guide-label wording update. [Cross-build status](harness/release-source7-linux-gui-cross-build-20260907.md)
|
||||
|
||||
The next Linux test session failed during kernel startup on three bounded attempts, before any corrected server tests ran. We preserved the failures and extracted the exact verified Ubuntu boot assets for a temporary timer-check workaround. Automatic approval review rejected its preparation/launch command as “blocked by policy,” so that experiment did not run. All task VM processes are stopped. [Boot evidence and pending gate](harness/release-source7-linux-server-corrected-gate-20260907.md)
|
||||
|
||||
## Latest preview and remaining release work
|
||||
|
||||
**Run the latest Windows preview:** open [arcadia-studio.exe](dist/ArcadiaStudio-preview-source7-20260907-x86_64-pc-windows-gnullvm/arcadia-studio.exe). Keep its folder intact, including `libunwind.dll`; no compiler installation or PATH change is needed. To move it elsewhere, extract the whole [preview ZIP](dist/ArcadiaStudio-preview-source7-20260907-x86_64-pc-windows-gnullvm.zip). Home opens the guide and examples. This is a development-profile preview, not the final optimized release.
|
||||
|
||||
Or run this in PowerShell:
|
||||
|
||||
```powershell
|
||||
& 'C:\Users\User\Documents\Git\ARCADIA-Studio\dist\ArcadiaStudio-preview-source7-20260907-x86_64-pc-windows-gnullvm\arcadia-studio.exe'
|
||||
```
|
||||
|
||||
The package contains 565 files, including all 37 teaching files; independent integrity verification and source/build provenance are recorded alongside the ZIP. The packaged executable's SHA-256 begins `DC50C887`; the ZIP's is `1fac10b30daf2f8e1dac29e9469485e4bea142e77ff5a13e4c9d1e41ff03abb2`. [Provenance](dist/ArcadiaStudio-preview-source7-20260907-x86_64-pc-windows-gnullvm.zip.provenance.json)
|
||||
|
||||
The packaged app launched with a clean PATH. At **1444×932**, the accepted OA caller graph fitted at **68%** with all four cards visible. A **1102×932** width was also observed; this does **not** establish the 700-pixel minimum-height case. Your Escape stopped Computer Use before the final UI checks; no clean close is claimed. A subsequent read-only oracle confirmed the saved model and both source files were unchanged; this does not establish unobserved in-memory state. Broader native acceptance remains open.
|
||||
|
||||
The subsequent guide update names the actual Tools, Selection details, Restore panels and Reply operation controls. Formatting, independent label review and a fresh Windows workspace-binary build passed; the downloadable preview above retains its original, already-tested bytes.
|
||||
|
||||
Remaining work is to finish native Fit checks, complete outstanding engineering/hosted workflows, execute the corrected Linux server gate, verify actual Linux GUI behavior, and produce optimized, independently reviewed final packages including the updated guide. Export is explicitly **Architecture SVG**; faithful process/scenario/state SVG is deferred. Logical/physical architecture layers and simulation are outside this OA/SA release. The applicable Windows/Linux acceptance obligations remain open. [Release plan](harness/PLAN.md)
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
# Project goal
|
||||
|
||||
## Objective
|
||||
|
||||
Take the existing project to the complete, coherent, usable, and verified outcome defined by the user input below. Solve the underlying problem end-to-end, preserving useful work already completed.
|
||||
|
||||
Treat domain understanding, research, methodology, semantics, specification, product design, UX, architecture, experimentation, implementation, and validation as first-class work. Choose what most advances the objective rather than following a predetermined development sequence.
|
||||
|
||||
Follow `AGENTS.md` for execution, coordination, record ownership, and delivery. Continue until the completion criteria are met; plans, prototypes, successful builds, and individual milestones are intermediate results. Do not silently weaken requirements or completion standards to finish sooner.
|
||||
|
||||
## Completion criteria
|
||||
|
||||
Apply these criteria to the scope requested by the user:
|
||||
|
||||
- Required capabilities address the intended problem and support realistic user workflows.
|
||||
- Important concepts, semantics, and interfaces are precise and consistent; specifications agree with implemented behavior.
|
||||
- Consequential decisions have evidence and rationale. Major assumptions have been validated, explicitly accepted, or isolated without compromising required behavior.
|
||||
- The result builds, runs, and operates on the required platforms. Relevant tests and evaluators pass; integrations, edge cases, and failure states have been exercised against the actual result.
|
||||
- User-facing behavior and usability have been exercised directly, using representative data and workflows.
|
||||
- A fresh independent review covers requirements, domain/semantic consistency, usability, architecture, correctness, regressions, runtime behavior, documentation, and remaining risks. Valid material findings are addressed, blocking and high-impact issues resolved, and affected behavior reverified; no known blocker or high-severity defect remains.
|
||||
- Deliverables are maintainable, appropriately extensible, documented, and published as required by `AGENTS.md`. Obsolete scaffolding is removed; documentation and shared state accurately describe the result. Another engineer or agent can reproduce the verification and continue the work.
|
||||
|
||||
If required work or verification is blocked, record the specific gap and continue independent useful work without claiming completion.
|
||||
|
||||
## Final handoff
|
||||
|
||||
Link the delivered result and summarize capabilities, discoveries, architecture and decisions, relevant rejected approaches, verification and independent review, remaining limitations, and exact build/run/deployment/verification instructions. Refer to authoritative records instead of repeating the project history.
|
||||
|
||||
## USER-OWNED APPLICATION SPECIFICATION
|
||||
|
||||
The block below accepts all project-specific requirements, context, priorities, and feedback, with any headings or organization the user chooses.
|
||||
|
||||
<!-- BEGIN USER-OWNED APPLICATION SPECIFICATION -->
|
||||
## Application specification
|
||||
|
||||
### General description
|
||||
|
||||
Create a modern clone of ARCADIA Capella using Rust Iced framework. It should be cross platform (supporting Windows and Linux at least). Use Rust exclusively (wherever possible & practical).
|
||||
|
||||
Focus on making the application modern, extensible, intuitive and easy to use. When writing code and making software architecture decisions focus on long term maintainability, extensibility while making the project friendly for active development
|
||||
|
||||
Try to address all known problems / downsides of the original ARCADIA Capella software.
|
||||
|
||||
Version 1 MVP in the form of working Operational Analysis layer is done.
|
||||
Version 2 MVP in the form of working Operation Analysis + System Analysis is done.
|
||||
You should make the 1st release version of the project with fully feature complete Operation Analysis + System Architecture, according to this application specification.
|
||||
Remaining ARCADIA layers will be added in future releases.
|
||||
|
||||
Transitioning of model elements between layers (in both directions) should be easy and intuitive.
|
||||
|
||||
Keep in mind that the target audience for the application are system engineers. And you should satisfy their needs and use cases
|
||||
|
||||
The application should be a general systems modeling tool, like ARCADIA Capella or SysML v2
|
||||
|
||||
You should implement most of the ARCADIA semantics, while adressing original ARCADIA Capella problems / downsides
|
||||
|
||||
The original ARCADIA Capella UI is very bloated, clunky, unintuitive and hard to use. In addition, it lacks a number of quality of life features. You should at all cost avoid making the same mistakes as the developers of the original ARCADIA Capella
|
||||
|
||||
You should explore the functionality & UI of the original ARCADIA Capella to take the most useful and powerful ideas from it. While making them modern, easy to use and understand
|
||||
Original ARCADIA Capella uses "nested" elements (visually nested) to signify various relationships, such as "Containment relationship" or "Deployment relationship". Instead of using visually noisy arrows / graph edges. I pretty much like this approach and want to see something similar to it in this application
|
||||
|
||||
To explore the original ARCADIA Capella UI & semantics you may use web resources / web search / official and community docs. Or open the original ARCADIA Capella installed on this desktop machine for exploration
|
||||
|
||||
Your second, parallel, goal is to create a textual representation for ARCADIA, similar to SysML v2. So a user can either edit a system model using UI (graphical representation) or text. Both graphical and textual representations should be fully equivalent and have full parity. You may take inspiration from SysML v2. Both graphical and textual representations should have one overarching, concrete, unambiguous spec in reST format, similar to SysML v2 strictness
|
||||
The application should support both forms of editing
|
||||
The textual representation should be easily editable and understandble for both humans and AI
|
||||
The textual representation should be easily handled by version control tools such as Git
|
||||
The underlying semantics of the textual / graphical representation should support traceability of various development artifacts (for example - code, CAD files, electrical schematics, Docx files and anything in between)
|
||||
The textual representation editor should have syntax highlighting and linting
|
||||
|
||||
The project should also have a backend server / service that you can deploy. The backend server purpose is to give programmatic access to stored system models. So, there also should be a clear REST API for querying, editing and working with models. The purpose of the REST API is to allow automation, scripting and AI-driven workflows
|
||||
An example could be - writing custom plugins for various domain specific software (version control software, CAD software etc...) to establish traceability between development artifacts and a system model
|
||||
|
||||
The desktop application should contain built-in guide / help / tutorial with clear and concise info on modeling concepts, underlying semantics, common modeling scenarios and application features / how to use the app. It should be easy to navigate and explore
|
||||
|
||||
@Computer
|
||||
|
||||
### Semantic model / metamodel
|
||||
|
||||
Requirements:
|
||||
- Textual notation.
|
||||
- Graphical notation.
|
||||
- Constraints and calculations.
|
||||
- Quantities and units.
|
||||
- State and behavior modeling.
|
||||
- Reusable model libraries.
|
||||
- Strong, standardized specification.
|
||||
- Strong, standardized API.
|
||||
|
||||
#### Textual notation
|
||||
|
||||
Requirements:
|
||||
- Should not have visual noise and clutter.
|
||||
- Should have means to reduce cognitive load when developing system models.
|
||||
- Should be easily editable by humans. And AI agents.
|
||||
- System engineers should be able to easily develop system models (both big and small) using textual notation only.
|
||||
|
||||
### UI
|
||||
|
||||
The focus should be on reducing system engineer's cognitive load when creating / working with a system model. Regardless of the model size (big or small).
|
||||
|
||||
Long, homogeneous lists in the UI that require user to scroll far or search for a desired option for too long - is a bad design.
|
||||
|
||||
## Active issues / current guidance
|
||||
|
||||
Right now, the behavior & scenarios UI is hard to use. It contains static / uneditable diagrams, which is highly dubious. In addition, user workflow with the behavior & scenarios UI differ significantly from the other UIs - you can't edit diagrams and instead suppose to click on menus for model development
|
||||
|
||||
---
|
||||
|
||||
The current project state is nowhere near the usability of the original ARCADIA Capella (despite its downfalls) or its functionality / semantics. You should focus on that by studying original ARCADIA Capella very closely and implementing its features in a modern, ergonomic and easy to use way
|
||||
<!-- END USER-OWNED APPLICATION SPECIFICATION -->
|
||||
@@ -2,11 +2,11 @@
|
||||
|
||||
Arcadia Studio is a native Rust/Iced modeling workbench for system engineers. V2 covers Operational Analysis (OA) and System Analysis (SA), with graphical and textual editing of one typed model and a deployable Rust REST service. It is an independent implementation inspired by Arcadia and Eclipse Capella, not a complete Capella or SysML implementation.
|
||||
|
||||
The normative [language and editing contract](docs/language.rst) defines supported semantics, both representations, validation and migration. [Scope](docs/scope.md) states the boundaries. The [acceptance](docs/acceptance.md), [validation](docs/validation.md) and [current state](harness/STATE.md) records document V2 tests and native Windows/Linux Computer Use verification.
|
||||
The normative [language and editing contract](docs/language.rst) defines supported semantics, both representations, validation and migration. [Scope](docs/scope.md) states the boundaries. The [acceptance](docs/acceptance.md) and [validation](docs/validation.md) records document the earlier V2 baseline. [Current state](harness/STATE.md) and the [2026-09-06 coverage audit](harness/release-current-coverage-20260906.md) track subsequent implementation and outstanding release gates; the current first release is not yet accepted.
|
||||
|
||||
## Build and run
|
||||
|
||||
Install stable Rust and your platform's native compiler. The workspace declares Rust 1.90; the recorded development builds use Rust 1.98.1. Windows requires Visual Studio Build Tools with **Desktop development with C++**. Ubuntu/Debian development dependencies are:
|
||||
Install stable Rust and your platform's native compiler. The workspace declares Rust 1.90; the recorded development builds use Rust 1.98.1. The standard Windows MSVC path uses Visual Studio Build Tools with **Desktop development with C++**. Ubuntu/Debian development dependencies are:
|
||||
|
||||
```sh
|
||||
sudo apt-get install build-essential pkg-config libxkbcommon-dev libxkbcommon-x11-0 libwayland-dev libvulkan-dev libx11-dev libxrandr-dev libxi-dev
|
||||
@@ -18,14 +18,26 @@ From the repository root:
|
||||
cargo run --locked -p oa-desktop --bin arcadia-studio
|
||||
```
|
||||
|
||||
Build both desktop and server release executables:
|
||||
Build the desktop, server and source CLI release executables:
|
||||
|
||||
```sh
|
||||
cargo build --release --workspace --locked
|
||||
```
|
||||
|
||||
Stage the complete teaching examples beside these executables so **Home → Open water-pressure walkthrough** works in an optimized source build. From the repository root, use `Copy-Item -Recurse -Force examples target/release/` in PowerShell or `cp -R examples target/release/` on Linux. If you set a different Cargo target directory or pass `--target`, use that build's executable directory instead of `target/release`. The packaging scripts include these examples automatically.
|
||||
|
||||
Run `target/release/arcadia-studio.exe` on Windows or `./target/release/arcadia-studio` on Linux. No task-specific cache directory is required. A graphical desktop and compatible graphics driver are required; the application uses Iced 0.14 with WGPU and X11/Wayland support. It does not require Java, Eclipse, a browser, an account or a running server.
|
||||
|
||||
An optional Windows path uses the `stable-x86_64-pc-windows-gnullvm` Rust toolchain with LLVM-MinGW. Put LLVM-MinGW's `bin` directory on `PATH`, including `clang.exe` and `llvm-windres.exe`, then run:
|
||||
|
||||
```powershell
|
||||
rustup toolchain install stable-x86_64-pc-windows-gnullvm
|
||||
$env:CARGO_TARGET_X86_64_PC_WINDOWS_GNULLVM_LINKER = 'clang.exe'
|
||||
cargo +stable-x86_64-pc-windows-gnullvm build --release --workspace --locked
|
||||
```
|
||||
|
||||
GNU/gnullvm builds require a resource compiler to embed the normal-user (`asInvoker`) manifest in executables and tests. The build selects `llvm-windres` for gnullvm or `windres` for GNU; set `RC` to its executable path if it is not on `PATH`. This avoids Windows treating installer-like test filenames as elevation requests. The current machine uses user-local Rust 1.98.1 and LLVM-MinGW 20260826 UCRT; compilation and test progress are recorded in [current state](harness/STATE.md). That evidence is separate from native workflow and release-package acceptance.
|
||||
|
||||
On the tested Ubuntu 24.04/WSLg environment, Mesa's Vulkan software adapter is required:
|
||||
|
||||
```sh
|
||||
@@ -35,20 +47,39 @@ WGPU_BACKEND=vulkan VK_DRIVER_FILES=/usr/share/vulkan/icd.d/lvp_icd.x86_64.json
|
||||
|
||||
A compatible native Linux graphics adapter can use the ordinary command. This software-adapter workaround requires the indicated driver file. See the [platform verification record](docs/validation.md) for tested environments.
|
||||
|
||||
V2 release bundles are `dist/ArcadiaStudio-Windows.zip` and `dist/ArcadiaStudio-Linux.tar.gz`, with archive checksums in `dist/SHA256SUMS.txt`. Extract the bundle and run its `arcadia-studio.exe` or `./arcadia-studio`; Rust is not needed to run a packaged executable. Both bundles include the headless server, examples, specifications and deployment instructions. Linux bundles target the documented Ubuntu 24.04 runtime. Packaged executable and archive evidence is recorded in [package verification](harness/verification-v2-packages.md).
|
||||
The earlier V2 package record describes `dist/ArcadiaStudio-Windows.zip` and `dist/ArcadiaStudio-Linux.tar.gz`, with archive checksums in `dist/SHA256SUMS.txt`. Extract a verified bundle and run its `arcadia-studio.exe` or `./arcadia-studio`; Rust is not needed to run a packaged executable. Those bundles include the headless server, examples, specifications and deployment instructions; Linux targets the documented Ubuntu 24.04 runtime. [Package verification](harness/verification-v2-packages.md) applies to that recorded revision. Current changes still require fresh packages, checksums and acceptance.
|
||||
|
||||
The current [Windows packaging instructions](docs/windows-packaging.md) stage both executables, verify their runtime DLL dependencies, and produce an archive with checksums and provenance. Native acceptance fixtures and independent expected results are retained in [the release fixture directory](harness/fixtures/release1-native/README.md).
|
||||
|
||||
## Work with source files from the command line
|
||||
|
||||
The `arcadia` CLI checks an explicit `.arcadia` entry file and its includes without
|
||||
starting the desktop or server:
|
||||
|
||||
```sh
|
||||
cargo run --locked -p oa-cli --bin arcadia -- check examples/text-first/project.arcadia
|
||||
```
|
||||
|
||||
`stabilize ROOT` previews the identities needed for persistent editing;
|
||||
`rename-alias ROOT OLD NEW` previews an alias change and its references. Add
|
||||
`--write` to save that invocation's candidate, or `--json` for structured
|
||||
diagnostics and changed-file text/diffs. Preview leaves files untouched.
|
||||
See [source CLI commands](docs/source-cli.md) for examples and exit codes.
|
||||
|
||||
## Model operational needs and system responsibilities
|
||||
|
||||
The coastal rescue example combines people, an organization, a System of Interest, information and physical exchange items, normal/degraded behavior and code/CAD evidence. Choose **New** to start an empty project.
|
||||
The desktop starts at **Home**, where you can create a model, open a local project, continue recent files or recover retained work. Missing recent files stay visible with **Locate**; **Remove** only forgets their history entry. Choose **Explore rescue example** for a model combining people, an organization, a System of Interest, information and physical exchange items, normal/degraded behavior and code/CAD evidence.
|
||||
|
||||
The example opens in an architecture view with work nested inside its performers. Each layer also has dedicated functional dataflow and mission/capability views. Regenerate both example formats with matching identities using `cargo run --locked -p oa-core --example generate_demo`; the command verifies both saved models. Historical migration fixtures remain separate.
|
||||
|
||||
1. In **Operational Analysis**, create entities, actors, activities and capabilities. Use **In selected** for semantic decomposition and **Allocated** for new leaf work performed by a participant. Activities can decompose independently of their performer; roles capture shared operational responsibility.
|
||||
The architecture diagram has a compact side palette; expand it to see tool names. Choose an element tool, click its position, enter its name in place and press **Enter**. **Escape** cancels the draft. Select a card and press **F2** to rename it. Right-click a participant and choose **Add activity** or **Add function** to create work with its performer allocation in one undoable edit. Ordinary palette creation leaves work unallocated. **Add existing** places a compatible model element in the current view; **Include already shown** offers **Locate** for existing placements.
|
||||
|
||||
1. In **Operational Analysis**, create entities, actors, activities and capabilities. Use **In selected** for semantic decomposition and **Allocated** for new leaf work performed by a participant. Activities can decompose independently of their performer; roles capture shared operational responsibility. **Communication means** connects operational Entities or Actors directly, with direction and carried items; **Exchange allocation** associates the Interactions it carries. Operational communication requires no system ports.
|
||||
2. Switch to **System Analysis**. Define one System of Interest and external Actors, then functions, missions, capabilities and functional chains. The system boundary has no internal LA/PA components in this version.
|
||||
3. Select a System or System Actor and choose **Add port** in the browser or Properties. Choose Standard or Flow, then **Create port**. Use **Boundary exchange** in the diagram palette, or **Draw boundary exchange** on a selected visible source port, to connect compatible component ports directly. Create the other participant's matching port first if none exists. Functions expose separate input/output port actions; the **Functional exchange** palette tool connects them. **Actions** exposes other engineering relationships and off-diagram configuration; **Data & interfaces** holds reusable payloads and contracts.
|
||||
4. Create focused views. **Containment** nests semantic children; **Architecture · allocated work** nests work within performers; **Functional dataflow** shows work decomposition and exchanges; **Missions & capabilities** shows objectives and participation. Dashed, italic parent work denotes an allocation summary inferred from its leaves. Ports appear on their owner's boundary. Placement dragging changes geometry. **Hide** removes a placement; **Delete from model** removes semantics and dependent references. Use **Placement** and **View properties** for exact values.
|
||||
5. In **Behavior & scenarios**, author processes/chains, scenario lifelines and ordered occurrences, including repeated uses of the same exchange and explicit fragments/branches. Select a State machine and choose **Create diagram** to place its existing regions and states/modes in a saved view. State machines also support availability and state-action links. Conditions are descriptive, not executable simulation code. Behavior and Data authoring preserve your other diagram layouts.
|
||||
6. Select an OA or SA element and choose **Create / update counterpart**. Review dependency scope and counterpart choices, then apply one undoable transaction. Existing counterparts retain their structural choices; reviewed updates compare names/descriptions. Manual traces link other engineering correspondences. **Traceability** also edits artifact URI, revision and fragment metadata.
|
||||
4. Create focused views. **Containment** nests semantic children; **Architecture · allocated work** nests work within performers; **Functional dataflow** shows work decomposition and exchanges; **Missions & capabilities** shows objectives and participation. Dashed, italic parent work denotes an allocation summary inferred from its leaves. Ports appear on their owner's boundary. **View properties** stages saved line-kind, category, behavior-coverage, context, label and individual-hide choices. Right-click empty diagram space for **Duplicate view**, retaining the same semantic records in an independent presentation. Placement **Hide** removes a placement; line hiding retains its relationship; **Delete from model** removes semantics and dependent references.
|
||||
5. In **Behavior & scenarios**, choose **New**, select Operational process (OA), Functional chain (SA), Scenario or State machine, then enter its name. For a scenario, choose what its lifelines represent; **Add optional context…** links a context without changing its layer-root ownership. For a state machine, search for its existing participant owner; optionally select **Create the first state view**. Choose **Create and open** to start authoring work contributions, lifelines, ordered occurrences or states. **Cancel · Esc** discards the creation draft; if another dialog is open, Escape dismisses that dialog first. Creation and its optional first view form one Undo operation. A new state machine starts without regions or states; **Create diagram** can later place its existing contents in a saved view. Behavior supports repeated exchanges, fragments/branches, availability and state-action links. Conditions are descriptive, not executable simulation code. Behavior and Data authoring preserve your other diagram layouts.
|
||||
6. Select an OA or SA element and choose **Create / update counterpart**. Review dependency scope and counterpart choices, then apply one undoable transaction. For an existing trace, **Review semantics…** compares typed content and prior observations, with explicit keep, take or final-value decisions for supported structural, scalar, constructor, individual-row and ordered-collection changes. Open additional trace rows and choose **Include this row** to combine reviewed source contributions into one validated Apply/Undo transaction; conflicting decisions require resolution. Review does not automatically synchronize the perspectives. **Attachments** reviews an existing child’s parent or a relationship endpoint. **Artifact links** reviews metadata, owner moves, explicit source-absence deletion and creation of a reserved counterpart after all introduced metadata is reviewed. These controls have automated coverage; their native acceptance remains pending. Manual traces link other engineering correspondences. **Traceability** also edits artifact URI, revision and fragment metadata.
|
||||
7. Review **Model checks**, then save `.arcadia` source or `.json`. Warnings allow incomplete authoring; structural errors preserve the committed model and rejected draft.
|
||||
|
||||
**Model explorer** groups roots by engineering purpose and opens one container's direct children. Breadcrumbs navigate ownership; **Allocated work** is listed separately. Search names, kinds or `@aliases` to find elements with ancestor context. Results are paged in groups of 100. **Collapse all** folds the outline; **Locate** clears the filter and reveals the selected element's ancestry.
|
||||
@@ -57,7 +88,9 @@ The example opens in an architecture view with work nested inside its performers
|
||||
|
||||
The inspector exposes typed properties, references, ordered lists, aliases and UUID identity. Applying or navigating away attempts to commit the draft; invalid values remain editable. **Escaped strings (advanced)** uses quoted JSON escapes for exact tabs, line breaks and carriage returns, for example `"First\nSecond"`. UUID changes atomically update dependent references. Accepted model/source history retains up to 100 edits within an estimated 32 MiB history budget.
|
||||
|
||||
Choose **Interaction**, **Functional exchange** or **Boundary exchange** in the diagram palette, then click the source and a highlighted compatible target, or drag between them. Functional exchanges use output/input ports; function cards create or reuse real ports atomically. Boundary exchanges use compatible component ports. Middle-button drag pans without losing the chosen source; arrow keys also pan while drawing with the canvas focused. Click empty space to restart, or Escape to cancel. Each completed connection is one model/source history operation; edit its name and carried items in Properties.
|
||||
Choose **Interaction**, **Communication means**, **Functional exchange** or **Boundary exchange** in the diagram palette, then click the source and a highlighted compatible target, or drag between them. Communication means use OA participants directly. Functional exchanges use output/input ports; function cards create or reuse real ports atomically. Boundary exchanges use compatible component ports. Middle-button drag pans without losing the chosen source; arrow keys also pan while drawing with the canvas focused. Click empty space to restart, or Escape to cancel. Each completed connection is one model/source history operation; edit its name and carried items in Properties.
|
||||
|
||||
Create named **Exchange categories** at the perspective root to group Work exchanges or Participant exchanges. In **View properties**, each family defaults to All, including uncategorized exchanges. Selected categories combine by union; an explicit empty selection shows none of that family. Coverage and selected process/chain/scenario contexts intersect with category choices. Explicit hides survive changes to categories and labels. These settings change the saved presentation, not model relationships or node geometry. The [native record](harness/release-current-native-verification.md) covers the source-6 batch crash-recovery/Apply/Undo/Redo flow and a scoped forty-exchange view, including filtering, duplication, hiding and reopening. Communication authoring, remaining view combinations and latest attachment/artifact native acceptance remain pending.
|
||||
|
||||
In **Select / move**, drag cards on a 10-unit grid, drag empty space to pan, and scroll to zoom. Drag ports along their owner's border or onto any of its four sides. Automatic sides account for direction and visible connections. Manual positions are per diagram and remain relative to the owner through movement or resize; right-click **Reset automatic position** restores automatic placement. **Arrange** changes the current view layout; **Fit** frames its projected bounds. SVG exports the current architecture diagram as a view, not an editable model file.
|
||||
|
||||
@@ -69,10 +102,12 @@ In the state view, **Select / move** drags vertices and containers without repar
|
||||
|
||||
## Edit the same model as text
|
||||
|
||||
For a short text-only start, open [the distress-response model](examples/text-first/project.arcadia) and follow [its three-step walkthrough](examples/text-first/README.md). Four declarations and three explicit relationships describe participants, work, responsibility and an exchange; no diagram or handwritten UUID is needed.
|
||||
|
||||
**Textual model** has a source-file folder tree, per-file drafts, syntax highlighting, live linting and explicit **Apply all**. Collapse/Locate navigate the hierarchy; collapsed folders retain draft and diagnostic indicators. Right-click a file for contextual actions. Projects can use relative includes to organize source into files. For example:
|
||||
|
||||
```text
|
||||
arcadia 4;
|
||||
arcadia 7;
|
||||
project harbor "Harbor assistance" {
|
||||
oa_entity control "Harbor control" {}
|
||||
oa_activity respond "Respond to requests" {
|
||||
@@ -104,7 +139,11 @@ Reparenting a nested element through Properties moves its declaration, descendan
|
||||
|
||||
Ctrl+S saves to the current local or hosted destination, Ctrl+Shift+S opens Save as, and Ctrl+O opens a local file. An `.arcadia` suffix selects text; other suffixes select JSON. Opening detects content rather than relying on the filename. Windows uses native dialogs; Linux uses a path-entry dialog with overwrite confirmation and no portal requirement.
|
||||
|
||||
**Save all** validates a snapshot and saves the source bundle, comparing previously read dependency bytes to reject detected external changes. Each file is replaced atomically; a staged transaction journal allows explicit recovery of interrupted multi-file saves. This is not an atomic multi-file filesystem operation or distributed locking. If recovery is needed, **Recover interrupted save** appears in Textual model. Dirty New/Open/Close operations offer save/discard/cancel. There is no periodic autosave of unsaved edits. Share or commit the entry file and all included source files together.
|
||||
**Save all** validates a snapshot and saves the source bundle, comparing previously read dependency bytes to reject detected external changes. Each file is replaced atomically; a staged transaction journal allows explicit recovery of interrupted multi-file saves. This is not an atomic multi-file filesystem operation or distributed locking. If recovery is needed, **Recover interrupted save** appears in Textual model. Dirty New/Open/Close operations offer save/discard/cancel. Share or commit the entry file and all included source files together.
|
||||
|
||||
While work is unsaved, the desktop attempts a private recovery checkpoint every 10 seconds. It retains the accepted source bundle, raw source buffers, supported pending property/behavior forms and workflow drafts, including rejected input and semantic-review choices. **Home → Recover** opens an unsaved copy with no local or hosted save destination; use **Save as** to choose one. Undo/redo history is not restored. Checkpoints never write model files or save to the server, and credentials are excluded. A failed checkpoint is reported on Home; recent edits since the last completed checkpoint may be unavailable after a crash. Local history and checkpoints live under `%LOCALAPPDATA%/ArcadiaStudio` on Windows or `$XDG_DATA_HOME/arcadia-studio` (otherwise `~/.local/share/arcadia-studio`) on Linux. Explicit saves and version control remain the durable record.
|
||||
|
||||
The [current Windows native record](harness/release-current-native-verification.md) verifies scoped Home/recents workflows and forced-stop recovery of raw invalid source, invalid UUID properties, a pending timed transition, keyed semantic choices and a retained rejected order draft. Accepted repairs were saved and compared with independent source oracles, including exact Undo/Redo cases. It also records the older binary's transient journal-publication failure; the bounded fix has automated evidence in the [journal review](harness/release-journal-publication-review-20260906.md) and awaits new-binary native verification. Subsequent source-6 batch recovery and exact Apply/Save/Undo/Redo comparisons are recorded there too. These results do not accept all recovery variants, latest attachment/artifact workflows, Linux or the complete release.
|
||||
|
||||
## REST service and external tools
|
||||
|
||||
@@ -135,6 +174,8 @@ To preserve authored files and comments, use `POST /api/v1/projects/import-sourc
|
||||
|
||||
## Development
|
||||
|
||||
Source7 integration adds caller-qualified reusable-process links and explicit interface-operation identity. Its [contract decision](harness/release-source7-integration-decision-20260906.md) and [current state](harness/STATE.md) distinguish prepared code from tested behavior. Current OpenAPI validation passes72structural fixtures across204schemas; source7 core, desktop, HTTP, recovery and native acceptance remain separate gates. Earlier source6 Windows and Linux results retain their exact recorded revision scope. Final full suites, strict checks, optimized/MSRV builds and current release packages are still required.
|
||||
|
||||
```sh
|
||||
cargo fmt --all -- --check
|
||||
cargo clippy --workspace --all-targets --locked -- -D warnings
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
|
||||
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||
<security><requestedPrivileges>
|
||||
<requestedExecutionLevel level="asInvoker" uiAccess="false"/>
|
||||
</requestedPrivileges></security>
|
||||
</trustInfo>
|
||||
</assembly>
|
||||
@@ -0,0 +1 @@
|
||||
1 24 "windows.manifest"
|
||||
@@ -0,0 +1,35 @@
|
||||
// GNU Windows linkers need an explicit normal-user manifest, including tests
|
||||
// named "updates", to avoid Windows installer detection. No elevation is needed.
|
||||
fn main() {
|
||||
println!("cargo:rerun-if-changed=../../build/windows_manifest.rs");
|
||||
println!("cargo:rerun-if-changed=../../build/windows.manifest");
|
||||
println!("cargo:rerun-if-changed=../../build/windows.rc");
|
||||
println!("cargo:rerun-if-env-changed=RC");
|
||||
let target = std::env::var("TARGET").unwrap_or_default();
|
||||
if !target.ends_with("windows-gnu") && !target.ends_with("windows-gnullvm") {
|
||||
return;
|
||||
}
|
||||
let root = std::path::PathBuf::from(std::env::var_os("CARGO_MANIFEST_DIR").unwrap())
|
||||
.join("../../build");
|
||||
let object = std::path::PathBuf::from(std::env::var_os("OUT_DIR").unwrap())
|
||||
.join("normal-user-manifest.o");
|
||||
let compiler = std::env::var_os("RC").unwrap_or_else(|| {
|
||||
if target.ends_with("gnullvm") {
|
||||
"llvm-windres"
|
||||
} else {
|
||||
"windres"
|
||||
}
|
||||
.into()
|
||||
});
|
||||
let result = std::process::Command::new(compiler)
|
||||
.current_dir(root)
|
||||
.args(["-i", "windows.rc", "-O", "coff", "-o"])
|
||||
.arg(&object)
|
||||
.status()
|
||||
.expect("Windows GNU resource compiler is required (set RC if not on PATH)");
|
||||
assert!(
|
||||
result.success(),
|
||||
"Cannot compile the normal-user Windows manifest"
|
||||
);
|
||||
println!("cargo:rustc-link-arg={}", object.display());
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
[package]
|
||||
name = "oa-cli"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
[[bin]]
|
||||
name = "arcadia"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
oa-core = { path = "../oa-core" }
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
@@ -0,0 +1,451 @@
|
||||
//! Source-only authoring. Preparation captures bytes; only core owns disk writes.
|
||||
use oa_core::{
|
||||
Command, language,
|
||||
persistence::MigrationReport,
|
||||
prepare_command,
|
||||
source_files::{self, DiskProject, SourceFileError},
|
||||
source_project::{self, CompiledSourceProject, ProjectLimits, SourceDiagnostic},
|
||||
validation::Severity,
|
||||
};
|
||||
use serde::Serialize;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub const LIMITS: ProjectLimits = ProjectLimits {
|
||||
max_files: 256,
|
||||
max_include_depth: 32,
|
||||
max_include_edges: 1024,
|
||||
model: language::ParseLimits {
|
||||
max_source_bytes: 64 * 1024 * 1024,
|
||||
max_graph_objects: 100_000,
|
||||
max_records: 250_000,
|
||||
max_step_references: 500_000,
|
||||
},
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Operation {
|
||||
Check,
|
||||
Stabilize,
|
||||
RenameAlias { old: String, new: String },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct Location {
|
||||
pub path: String,
|
||||
/// Half-open UTF-8 byte offsets. Line/column are one-based Unicode scalars.
|
||||
pub start: usize,
|
||||
pub end: usize,
|
||||
pub line: Option<usize>,
|
||||
pub column: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct Diagnostic {
|
||||
pub severity: String,
|
||||
pub code: String,
|
||||
pub message: String,
|
||||
pub coordinate_space: String,
|
||||
pub location: Option<Location>,
|
||||
pub related: Vec<Location>,
|
||||
pub object_id: Option<String>,
|
||||
pub field: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct Change {
|
||||
pub path: String,
|
||||
/// Exact captured and proposed UTF-8 text, including line endings.
|
||||
pub before: String,
|
||||
pub after: String,
|
||||
pub diff: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct Report {
|
||||
pub format_version: u32,
|
||||
pub entry: String,
|
||||
pub operation: String,
|
||||
pub status: String,
|
||||
pub exit_code: u8,
|
||||
/// True only after a successful changed save. False on failure does not
|
||||
/// promise rollback: consult the specific error/recovery outcome.
|
||||
pub written: bool,
|
||||
pub diagnostics: Vec<Diagnostic>,
|
||||
pub changes: Vec<Change>,
|
||||
pub identities_to_stabilize: usize,
|
||||
pub migration: Option<MigrationReport>,
|
||||
pub notes: Vec<String>,
|
||||
}
|
||||
|
||||
impl Report {
|
||||
pub fn failure(
|
||||
entry: &Path,
|
||||
operation: &str,
|
||||
exit_code: u8,
|
||||
code: &str,
|
||||
message: String,
|
||||
) -> Self {
|
||||
let mut report = Self::empty(entry, operation);
|
||||
report.fail(exit_code, code, message);
|
||||
report
|
||||
}
|
||||
|
||||
fn empty(entry: &Path, operation: &str) -> Self {
|
||||
Self {
|
||||
format_version: 1,
|
||||
entry: entry.to_string_lossy().into_owned(),
|
||||
operation: operation.into(),
|
||||
status: "checked".into(),
|
||||
exit_code: 0,
|
||||
written: false,
|
||||
diagnostics: vec![],
|
||||
changes: vec![],
|
||||
identities_to_stabilize: 0,
|
||||
migration: None,
|
||||
notes: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
fn fail(&mut self, exit_code: u8, code: &str, message: String) {
|
||||
self.status = "rejected".into();
|
||||
self.exit_code = exit_code;
|
||||
self.diagnostics.push(Diagnostic {
|
||||
severity: "error".into(),
|
||||
code: code.into(),
|
||||
message,
|
||||
coordinate_space: "operation".into(),
|
||||
location: None,
|
||||
related: vec![],
|
||||
object_id: None,
|
||||
field: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn located(issue: SourceDiagnostic, space: &str) -> Diagnostic {
|
||||
let d = issue.diagnostic;
|
||||
Diagnostic {
|
||||
severity: match d.severity {
|
||||
Severity::Error => "error",
|
||||
Severity::Warning => "warning",
|
||||
}
|
||||
.into(),
|
||||
code: d.code,
|
||||
message: d.message,
|
||||
coordinate_space: space.into(),
|
||||
location: Some(Location {
|
||||
path: issue.path,
|
||||
start: d.span.start,
|
||||
end: d.span.end,
|
||||
line: Some(d.line),
|
||||
column: Some(d.column),
|
||||
}),
|
||||
related: issue
|
||||
.related
|
||||
.into_iter()
|
||||
.map(|at| Location {
|
||||
path: at.path,
|
||||
start: at.span.start,
|
||||
end: at.span.end,
|
||||
line: None,
|
||||
column: None,
|
||||
})
|
||||
.collect(),
|
||||
object_id: d.object_id.map(|id| id.to_string()),
|
||||
field: d.field,
|
||||
}
|
||||
}
|
||||
|
||||
fn disk_error(report: &mut Report, error: SourceFileError) {
|
||||
let code = match &error {
|
||||
SourceFileError::Conflict(_) => "external_change",
|
||||
SourceFileError::RecoveryRequired(_) => "recovery_required",
|
||||
SourceFileError::RecoveryFailed { .. } => "recovery_failed",
|
||||
SourceFileError::Path(_) => "unsafe_path",
|
||||
SourceFileError::Limit(_) => "source_limit",
|
||||
SourceFileError::UnknownExpectation(_) => "unknown_disk_expectation",
|
||||
SourceFileError::Invalid(_) => "invalid_save_candidate",
|
||||
SourceFileError::Io { .. } => "io_error",
|
||||
};
|
||||
report.fail(3, code, error.to_string());
|
||||
}
|
||||
|
||||
/// A candidate owns its original expectations and is consumed by saving.
|
||||
/// Callers cannot replace its source/model/expectations after preparation.
|
||||
pub struct Prepared {
|
||||
report: Report,
|
||||
snapshot: DiskProject,
|
||||
compiled: CompiledSourceProject,
|
||||
mutable: bool,
|
||||
}
|
||||
|
||||
impl Prepared {
|
||||
pub fn report(&self) -> &Report {
|
||||
&self.report
|
||||
}
|
||||
|
||||
pub fn into_report(self) -> Report {
|
||||
self.report
|
||||
}
|
||||
|
||||
pub fn write(mut self) -> Report {
|
||||
if !self.mutable {
|
||||
self.report
|
||||
.fail(2, "usage", "check/lint does not accept --write".into());
|
||||
return self.report;
|
||||
}
|
||||
match source_files::save_project(&self.snapshot, &self.compiled, LIMITS) {
|
||||
Ok(_) => {
|
||||
self.report.written = !self.report.changes.is_empty();
|
||||
self.report.status = if self.report.written {
|
||||
"written"
|
||||
} else {
|
||||
"unchanged"
|
||||
}
|
||||
.into();
|
||||
}
|
||||
Err(error) => disk_error(&mut self.report, error),
|
||||
}
|
||||
self.report
|
||||
}
|
||||
}
|
||||
|
||||
pub fn prepare(entry: &Path, operation: Operation) -> Result<Prepared, Box<Report>> {
|
||||
let name = match operation {
|
||||
Operation::Check => "check",
|
||||
Operation::Stabilize => "stabilize",
|
||||
Operation::RenameAlias { .. } => "rename-alias",
|
||||
};
|
||||
let mut report = Report::empty(entry, name);
|
||||
let snapshot = source_files::load_project(entry, LIMITS).map_err(|error| {
|
||||
disk_error(&mut report, error);
|
||||
Box::new(report.clone())
|
||||
})?;
|
||||
let raw = snapshot.reachable_bundle();
|
||||
// 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()
|
||||
.filter(|d| {
|
||||
matches!(
|
||||
d.diagnostic.code.as_str(),
|
||||
"pending_save"
|
||||
| "source_encoding"
|
||||
| "source_limit"
|
||||
| "invalid_path"
|
||||
| "missing_include"
|
||||
| "project_limit"
|
||||
)
|
||||
})
|
||||
.cloned()
|
||||
.collect();
|
||||
for diagnostic in source_project::lint_bundle(&raw, LIMITS) {
|
||||
if !diagnostics.contains(&diagnostic) {
|
||||
diagnostics.push(diagnostic);
|
||||
}
|
||||
}
|
||||
let disk_refusal = snapshot.diagnostics.iter().any(|d| {
|
||||
matches!(
|
||||
d.diagnostic.code.as_str(),
|
||||
"pending_save" | "missing_include" | "invalid_path"
|
||||
)
|
||||
});
|
||||
let invalid = diagnostics
|
||||
.iter()
|
||||
.any(|d| d.diagnostic.severity == Severity::Error);
|
||||
report.diagnostics = diagnostics
|
||||
.into_iter()
|
||||
.map(|d| located(d, "input"))
|
||||
.collect();
|
||||
if invalid {
|
||||
// Do not run declaration analysis or any updater on invalid raw text.
|
||||
// Failed lint may also have allocated provisional object identities.
|
||||
for diagnostic in &mut report.diagnostics {
|
||||
diagnostic.object_id = None;
|
||||
}
|
||||
report.status = "invalid".into();
|
||||
report.exit_code = if disk_refusal { 3 } else { 1 };
|
||||
return Err(Box::new(report));
|
||||
}
|
||||
let explicit_ids: std::collections::BTreeSet<_> = raw
|
||||
.files
|
||||
.values()
|
||||
.flat_map(|text| language::analyze(text).declarations)
|
||||
.filter_map(|d| d.id.map(|id| id.to_string()))
|
||||
.collect();
|
||||
for diagnostic in &mut report.diagnostics {
|
||||
if diagnostic
|
||||
.object_id
|
||||
.as_ref()
|
||||
.is_some_and(|id| !explicit_ids.contains(id))
|
||||
{
|
||||
diagnostic.object_id = None;
|
||||
}
|
||||
}
|
||||
let mut compiled = source_project::compile_bundle(&raw, LIMITS).map_err(|diagnostics| {
|
||||
report.status = "invalid".into();
|
||||
report.exit_code = 1;
|
||||
report
|
||||
.diagnostics
|
||||
.extend(diagnostics.into_iter().map(|d| located(d, "candidate")));
|
||||
Box::new(report.clone())
|
||||
})?;
|
||||
report.identities_to_stabilize = raw
|
||||
.files
|
||||
.values()
|
||||
.map(|text| {
|
||||
language::analyze(text)
|
||||
.declarations
|
||||
.into_iter()
|
||||
.filter(|d| d.id.is_none())
|
||||
.count()
|
||||
})
|
||||
.sum();
|
||||
report.migration = compiled.decoded.migration.clone();
|
||||
let mutable = !matches!(operation, Operation::Check);
|
||||
if let Operation::RenameAlias { old, new } = operation {
|
||||
let project = &compiled.decoded.project;
|
||||
let Some((id, _)) = project
|
||||
.identities()
|
||||
.into_iter()
|
||||
.find(|(_, alias)| *alias == old)
|
||||
else {
|
||||
report.fail(
|
||||
1,
|
||||
"unknown_alias",
|
||||
format!("No declaration has alias '{old}'."),
|
||||
);
|
||||
return Err(Box::new(report));
|
||||
};
|
||||
let command = prepare_command(project, &Command::SetAlias(id, new)).map_err(|error| {
|
||||
report.fail(1, "semantic_command_rejected", error.to_string());
|
||||
Box::new(report.clone())
|
||||
})?;
|
||||
compiled = source_project::update_bundle_with_identity_map(
|
||||
&compiled,
|
||||
&command.project,
|
||||
&command.identity_map,
|
||||
LIMITS,
|
||||
)
|
||||
.map_err(|diagnostics| {
|
||||
report.status = "invalid".into();
|
||||
report.exit_code = 1;
|
||||
report
|
||||
.diagnostics
|
||||
.extend(diagnostics.into_iter().map(|d| located(d, "candidate")));
|
||||
Box::new(report.clone())
|
||||
})?;
|
||||
}
|
||||
if mutable {
|
||||
for (path, after) in &compiled.bundle.files {
|
||||
let before = raw.files.get(path).cloned().unwrap_or_default();
|
||||
if &before != after {
|
||||
report.changes.push(Change {
|
||||
path: path.clone(),
|
||||
diff: unified_diff(path, &before, after),
|
||||
before,
|
||||
after: after.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
report.status = "preview".into();
|
||||
report.notes.push("This invocation's complete proposed changed-file texts and diffs are reported. Without --write nothing is saved; a later invocation may allocate different missing UUIDs. This is not a saved plan or a digest of unchanged dependencies.".into());
|
||||
}
|
||||
report.notes.push("Diagnostic object IDs are omitted for declarations without authored IDs; lint's temporary identities are not the candidate's identities.".into());
|
||||
report.notes.push("Disk writes use the core journal/conflict checks. This is not cross-process atomic visibility or a power-loss durability guarantee; pending saves are never implicitly recovered. A failed write may require explicit journal recovery.".into());
|
||||
Ok(Prepared {
|
||||
report,
|
||||
snapshot,
|
||||
compiled,
|
||||
mutable,
|
||||
})
|
||||
}
|
||||
|
||||
/// One bounded hunk per file: common prefix/suffix plus three context lines.
|
||||
/// Linear in input size; no quadratic LCS allocation for large modules.
|
||||
fn unified_diff(path: &str, before: &str, after: &str) -> String {
|
||||
let a: Vec<_> = before.split_inclusive('\n').collect();
|
||||
let b: Vec<_> = after.split_inclusive('\n').collect();
|
||||
let prefix = a.iter().zip(&b).take_while(|(a, b)| a == b).count();
|
||||
let suffix = a[prefix..]
|
||||
.iter()
|
||||
.rev()
|
||||
.zip(b[prefix..].iter().rev())
|
||||
.take_while(|(a, b)| a == b)
|
||||
.count();
|
||||
let start = prefix.saturating_sub(3);
|
||||
let a_end = (a.len() - suffix + 3).min(a.len());
|
||||
let b_end = (b.len() - suffix + 3).min(b.len());
|
||||
let a_start = if a_end == start { start } else { start + 1 };
|
||||
let b_start = if b_end == start { start } else { start + 1 };
|
||||
let mut out = format!(
|
||||
"--- a/{path}\n+++ b/{path}\n@@ -{a_start},{} +{b_start},{} @@\n",
|
||||
a_end - start,
|
||||
b_end - start
|
||||
);
|
||||
let mut emit = |prefix: char, line: &str| {
|
||||
out.push(prefix);
|
||||
out.push_str(line);
|
||||
if !line.ends_with('\n') {
|
||||
out.push_str("\n\\ No newline at end of file\n");
|
||||
}
|
||||
};
|
||||
for line in &a[start..prefix] {
|
||||
emit(' ', line);
|
||||
}
|
||||
for line in &a[prefix..a.len() - suffix] {
|
||||
emit('-', line);
|
||||
}
|
||||
for line in &b[prefix..b.len() - suffix] {
|
||||
emit('+', line);
|
||||
}
|
||||
for line in &a[a.len() - suffix..a_end] {
|
||||
emit(' ', line);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub struct Arguments {
|
||||
pub entry: PathBuf,
|
||||
pub operation: Operation,
|
||||
pub write: bool,
|
||||
pub json: bool,
|
||||
}
|
||||
|
||||
pub const HELP: &str = "arcadia check ROOT [--json]\narcadia lint ROOT [--json]\narcadia stabilize ROOT [--write] [--json]\narcadia rename-alias ROOT OLD NEW [--write] [--json]\n\nROOT is an explicit .arcadia entry file; includes resolve relative to it.\nMutation commands preview by default. --write applies this invocation's candidate.\nMissing UUIDs in a preview are provisional until written. No canonical formatting.\nExit: 0 valid/success (engineering warnings allowed), 1 invalid source or semantic\ncommand rejection, 2 usage, 3 disk/path/conflict/pending-journal/output failure.\nNo implicit journal recovery. Limits: 64 MiB, 256 files, include depth32/edges1024,\n100000 graph objects, 250000 records, 500000 ordered references.\n";
|
||||
|
||||
pub fn arguments(args: &[std::ffi::OsString]) -> Result<Arguments, String> {
|
||||
let mut positional = Vec::new();
|
||||
let (mut write, mut json) = (false, false);
|
||||
for arg in args {
|
||||
if arg == "--write" && !write {
|
||||
write = true;
|
||||
} else if arg == "--json" && !json {
|
||||
json = true;
|
||||
} else if arg.to_string_lossy().starts_with('-') {
|
||||
return Err(format!(
|
||||
"Unknown or repeated option: {}",
|
||||
arg.to_string_lossy()
|
||||
));
|
||||
} else {
|
||||
positional.push(arg);
|
||||
}
|
||||
}
|
||||
let command = positional.first().and_then(|s| s.to_str()).unwrap_or("");
|
||||
let operation = match (command, positional.len()) {
|
||||
("check" | "lint", 2) if !write => Operation::Check,
|
||||
("stabilize", 2) => Operation::Stabilize,
|
||||
("rename-alias", 4) => Operation::RenameAlias {
|
||||
old: positional[2].to_str().ok_or("Alias must be UTF-8")?.into(),
|
||||
new: positional[3].to_str().ok_or("Alias must be UTF-8")?.into(),
|
||||
},
|
||||
_ => return Err(HELP.into()),
|
||||
};
|
||||
Ok(Arguments {
|
||||
entry: PathBuf::from(positional[1]),
|
||||
operation,
|
||||
write,
|
||||
json,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
use oa_cli::{Report, arguments, prepare};
|
||||
use std::{
|
||||
io::{self, Write},
|
||||
path::Path,
|
||||
process::ExitCode,
|
||||
};
|
||||
|
||||
fn human_report(report: &Report, show_diff: bool) -> io::Result<()> {
|
||||
let mut err = io::stderr().lock();
|
||||
for d in &report.diagnostics {
|
||||
let at = d
|
||||
.location
|
||||
.as_ref()
|
||||
.map(|l| {
|
||||
format!(
|
||||
"{}:{}:{}",
|
||||
l.path,
|
||||
l.line.unwrap_or(0),
|
||||
l.column.unwrap_or(0)
|
||||
)
|
||||
})
|
||||
.unwrap_or_else(|| report.entry.clone());
|
||||
writeln!(
|
||||
err,
|
||||
"{at}: {}[{}] ({}): {}",
|
||||
d.severity, d.code, d.coordinate_space, d.message
|
||||
)?;
|
||||
}
|
||||
writeln!(
|
||||
err,
|
||||
"{}: {} file changes; {} identities to stabilize",
|
||||
report.status,
|
||||
report.changes.len(),
|
||||
report.identities_to_stabilize
|
||||
)?;
|
||||
if let Some(migration) = &report.migration {
|
||||
writeln!(
|
||||
err,
|
||||
"Source migration: schema {} → {} (separate from identity stabilization)",
|
||||
migration.from_schema, migration.to_schema
|
||||
)?;
|
||||
}
|
||||
for note in &report.notes {
|
||||
writeln!(err, "{note}")?;
|
||||
}
|
||||
if show_diff {
|
||||
let mut out = io::stdout().lock();
|
||||
for change in &report.changes {
|
||||
write!(out, "{}", change.diff)?;
|
||||
}
|
||||
out.flush()?;
|
||||
}
|
||||
err.flush()
|
||||
}
|
||||
|
||||
fn run() -> io::Result<u8> {
|
||||
let args: Vec<_> = std::env::args_os().skip(1).collect();
|
||||
if args.len() == 1 && (args[0] == "--help" || args[0] == "-h") {
|
||||
io::stdout().write_all(oa_cli::HELP.as_bytes())?;
|
||||
return Ok(0);
|
||||
}
|
||||
if args.len() == 1 && args[0] == "--version" {
|
||||
writeln!(io::stdout(), "arcadia {}", env!("CARGO_PKG_VERSION"))?;
|
||||
return Ok(0);
|
||||
}
|
||||
let json = args.iter().any(|a| a == "--json");
|
||||
let mut displayed = false;
|
||||
let report = match arguments(&args) {
|
||||
Err(error) => Report::failure(Path::new(""), "usage", 2, "usage", error),
|
||||
Ok(args) => match prepare(&args.entry, args.operation) {
|
||||
Err(report) => *report,
|
||||
Ok(prepared) => {
|
||||
if args.write {
|
||||
if !args.json {
|
||||
human_report(prepared.report(), true)?;
|
||||
displayed = true;
|
||||
}
|
||||
prepared.write()
|
||||
} else {
|
||||
prepared.into_report()
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
if json {
|
||||
serde_json::to_writer_pretty(io::stdout().lock(), &report)?;
|
||||
writeln!(io::stdout())?;
|
||||
} else {
|
||||
human_report(&report, !displayed)?;
|
||||
}
|
||||
Ok(report.exit_code)
|
||||
}
|
||||
|
||||
fn main() -> ExitCode {
|
||||
match run() {
|
||||
Ok(code) => ExitCode::from(code),
|
||||
Err(error) => {
|
||||
let _ = writeln!(io::stderr(), "Output failed: {error}");
|
||||
ExitCode::from(3)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,425 @@
|
||||
use oa_cli::{LIMITS, Operation, Prepared, prepare};
|
||||
use oa_core::{Id, Project, source_files, source_project};
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
process::Command,
|
||||
};
|
||||
|
||||
struct Fixture(PathBuf);
|
||||
impl Fixture {
|
||||
fn new() -> Self {
|
||||
let path = std::env::temp_dir().join(format!("arcadia-cli-test-{}", Id::new_v4()));
|
||||
fs::create_dir(&path).unwrap();
|
||||
Self(path)
|
||||
}
|
||||
fn put(&self, name: &str, text: &str) {
|
||||
let path = self.0.join(name);
|
||||
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
fs::write(path, text).unwrap();
|
||||
}
|
||||
fn entry(&self) -> PathBuf {
|
||||
self.0.join("project.arcadia")
|
||||
}
|
||||
fn read(&self, name: &str) -> String {
|
||||
fs::read_to_string(self.0.join(name)).unwrap()
|
||||
}
|
||||
fn prepare(&self, op: Operation) -> Prepared {
|
||||
prepare(&self.entry(), op).unwrap_or_else(|report| panic!("{report:#?}"))
|
||||
}
|
||||
fn model(&self) -> Project {
|
||||
let disk = source_files::load_project(&self.entry(), LIMITS).unwrap();
|
||||
source_project::compile_bundle(&disk.reachable_bundle(), LIMITS)
|
||||
.unwrap()
|
||||
.decoded
|
||||
.project
|
||||
}
|
||||
fn cli(&self, args: &[&str]) -> (i32, Value) {
|
||||
let mut command = Command::new(env!("CARGO_BIN_EXE_arcadia"));
|
||||
command
|
||||
.arg(args[0])
|
||||
.arg(self.entry())
|
||||
.args(&args[1..])
|
||||
.arg("--json");
|
||||
let output = command.output().unwrap();
|
||||
assert!(
|
||||
output.stderr.is_empty(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
(
|
||||
output.status.code().unwrap(),
|
||||
serde_json::from_slice(&output.stdout).unwrap(),
|
||||
)
|
||||
}
|
||||
}
|
||||
impl Drop for Fixture {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
const SIMPLE: &str = "// 雨: authored defaults stay omitted\r\narcadia 7;\r\nproject rescue \"Rescue\" {\r\n oa_entity team \"Team\" {}\r\n}\r\n";
|
||||
|
||||
#[test]
|
||||
fn complete_alarm_example_preview_then_write_and_fixed_point() {
|
||||
let fixture = Fixture::new();
|
||||
let source = include_str!(
|
||||
"../../../harness/experiments/textual-authoring-20260911/alarm-response.arcadia"
|
||||
);
|
||||
fixture.put("project.arcadia", source);
|
||||
let (code, check) = fixture.cli(&["check"]);
|
||||
assert_eq!(code, 0);
|
||||
assert_eq!(check["diagnostics"], serde_json::json!([]));
|
||||
let (code, preview) = fixture.cli(&["stabilize"]);
|
||||
assert_eq!(code, 0);
|
||||
assert_eq!(preview["identities_to_stabilize"], 40);
|
||||
assert_eq!(preview["status"], "preview");
|
||||
assert_eq!(fixture.read("project.arcadia"), source);
|
||||
assert!(
|
||||
preview["changes"][0]["diff"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("+project alarm_response")
|
||||
);
|
||||
let (code, written) = fixture.cli(&["stabilize", "--write"]);
|
||||
assert_eq!(code, 0);
|
||||
let saved = fixture.read("project.arcadia");
|
||||
assert_eq!(written["changes"][0]["after"], saved);
|
||||
let project = fixture.model();
|
||||
assert_eq!(project.identities().len(), 40);
|
||||
let mut stripped = saved.clone();
|
||||
for (id, _) in project.identities() {
|
||||
stripped = stripped.replace(&format!(" id \"{id}\""), "");
|
||||
}
|
||||
assert_eq!(stripped, source, "only identity clauses may be inserted");
|
||||
let (code, again) = fixture.cli(&["stabilize", "--write"]);
|
||||
assert_eq!(code, 0);
|
||||
assert_eq!(again["changes"], serde_json::json!([]));
|
||||
assert_eq!(again["status"], "unchanged");
|
||||
assert_eq!(fixture.model(), project);
|
||||
assert_eq!(fixture.read("project.arcadia"), saved);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn captured_candidate_exact_write_preserves_crlf_comments_and_noop_metadata() {
|
||||
let fixture = Fixture::new();
|
||||
fixture.put("project.arcadia", SIMPLE);
|
||||
let candidate = fixture.prepare(Operation::Stabilize);
|
||||
let expected = candidate.report().changes[0].after.clone();
|
||||
assert_eq!(candidate.report().identities_to_stabilize, 2);
|
||||
assert_eq!(fixture.read("project.arcadia"), SIMPLE);
|
||||
assert_eq!(candidate.write().exit_code, 0);
|
||||
assert_eq!(fixture.read("project.arcadia"), expected);
|
||||
assert!(expected.contains("// 雨: authored defaults stay omitted\r\n"));
|
||||
assert!(!expected.contains("description"));
|
||||
let metadata = fs::metadata(fixture.entry()).unwrap().modified().unwrap();
|
||||
let report = fixture.prepare(Operation::Stabilize).write();
|
||||
assert_eq!(report.status, "unchanged");
|
||||
assert!(!report.written);
|
||||
assert_eq!(
|
||||
fs::metadata(fixture.entry()).unwrap().modified().unwrap(),
|
||||
metadata
|
||||
);
|
||||
assert_eq!(fs::read_dir(&fixture.0).unwrap().count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alias_rename_across_modules_changes_only_live_tokens_and_retains_all_identity() {
|
||||
let fixture = Fixture::new();
|
||||
fixture.put("project.arcadia", "arcadia 7; project rescue \"Rescue\" { include \"participants.arcadia\"; include \"work.arcadia\"; }\n");
|
||||
fixture.put(
|
||||
"participants.arcadia",
|
||||
"// @team is quoted documentation\noa_entity team \"Team @team\" {}\n",
|
||||
);
|
||||
fixture.put("work.arcadia", "oa_activity assess \"Assess\" {}\noa_activity report \"Report\" {}\nallocation assigned \"Assigned\" { source @team; target @assess; }\ninteraction transfer \"Transfer\" { source @assess; target @report; }\n");
|
||||
assert_eq!(fixture.prepare(Operation::Stabilize).write().exit_code, 0);
|
||||
let before = fixture.model();
|
||||
let original: BTreeMap<_, _> = ["project.arcadia", "participants.arcadia", "work.arcadia"]
|
||||
.into_iter()
|
||||
.map(|p| (p, fixture.read(p)))
|
||||
.collect();
|
||||
let team = before
|
||||
.identities()
|
||||
.into_iter()
|
||||
.find(|(_, alias)| *alias == "team")
|
||||
.unwrap()
|
||||
.0;
|
||||
let candidate = fixture.prepare(Operation::RenameAlias {
|
||||
old: "team".into(),
|
||||
new: "rescue_team".into(),
|
||||
});
|
||||
assert_eq!(candidate.report().changes.len(), 2);
|
||||
assert_eq!(fixture.model(), before, "preview cannot mutate");
|
||||
assert_eq!(candidate.write().exit_code, 0);
|
||||
let mut expected = before;
|
||||
expected.elements.get_mut(&team).unwrap().alias = "rescue_team".into();
|
||||
assert_eq!(fixture.model(), expected);
|
||||
assert_eq!(fixture.read("project.arcadia"), original["project.arcadia"]);
|
||||
assert_eq!(
|
||||
fixture.read("participants.arcadia"),
|
||||
original["participants.arcadia"].replacen("oa_entity team ", "oa_entity rescue_team ", 1)
|
||||
);
|
||||
assert_eq!(
|
||||
fixture.read("work.arcadia"),
|
||||
original["work.arcadia"].replace("source @team;", "source @rescue_team;")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_unicode_raw_edit_is_located_and_never_stabilized_or_written() {
|
||||
let fixture = Fixture::new();
|
||||
let raw = "arcadia 7; project p \"雨\" {\n oa_activity a \"A\" {}\n allocation alloc \"Alloc\" { source @missing; target @a; }\n}\n";
|
||||
fixture.put("project.arcadia", raw);
|
||||
for command in [
|
||||
vec!["check"],
|
||||
vec!["stabilize", "--write"],
|
||||
vec!["rename-alias", "a", "b", "--write"],
|
||||
] {
|
||||
let (code, report) = fixture.cli(&command);
|
||||
assert_eq!(code, 1);
|
||||
assert_eq!(fixture.read("project.arcadia"), raw);
|
||||
let diagnostics = report["diagnostics"].as_array().unwrap();
|
||||
assert!(!diagnostics.is_empty());
|
||||
for d in diagnostics {
|
||||
assert_eq!(d["coordinate_space"], "input");
|
||||
let at = &d["location"];
|
||||
let start = at["start"].as_u64().unwrap() as usize;
|
||||
assert!(raw.is_char_boundary(start));
|
||||
assert_eq!(
|
||||
at["line"].as_u64().unwrap() as usize,
|
||||
raw[..start].bytes().filter(|b| *b == b'\n').count() + 1
|
||||
);
|
||||
let line = raw[..start]
|
||||
.rsplit_once('\n')
|
||||
.map_or(&raw[..start], |(_, tail)| tail);
|
||||
assert_eq!(
|
||||
at["column"].as_u64().unwrap() as usize,
|
||||
line.chars().count() + 1
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_id_warning_uses_raw_location_and_never_exposes_ephemeral_lint_id() {
|
||||
let fixture = Fixture::new();
|
||||
let raw = "arcadia 7;\nproject p \"雨\" {\n oa_activity work \"Work\" {}\n}\n";
|
||||
fixture.put("project.arcadia", raw);
|
||||
let (code, result) = fixture.cli(&["lint"]);
|
||||
assert_eq!(code, 0, "engineering warnings do not reject admission");
|
||||
let warning = result["diagnostics"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|d| d["code"] == "work_without_behavior_context")
|
||||
.unwrap();
|
||||
assert_eq!(warning["severity"], "warning");
|
||||
assert_eq!(
|
||||
warning["location"]["start"],
|
||||
raw.find("oa_activity").unwrap()
|
||||
);
|
||||
assert_eq!(warning["location"]["line"], 3);
|
||||
assert_eq!(warning["location"]["column"], 5);
|
||||
assert!(warning["object_id"].is_null());
|
||||
assert_eq!(fixture.read("project.arcadia"), raw);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn captured_dependency_change_refuses_all_writes_even_on_noop() {
|
||||
let fixture = Fixture::new();
|
||||
fixture.put("project.arcadia", SIMPLE);
|
||||
let pending = fixture.prepare(Operation::Stabilize);
|
||||
let external = format!("{SIMPLE}// editor changed this\n");
|
||||
fixture.put("project.arcadia", &external);
|
||||
let result = pending.write();
|
||||
assert_eq!(result.exit_code, 3);
|
||||
assert_eq!(result.diagnostics.last().unwrap().code, "external_change");
|
||||
assert_eq!(fixture.read("project.arcadia"), external);
|
||||
assert_eq!(fixture.prepare(Operation::Stabilize).write().exit_code, 0);
|
||||
let noop = fixture.prepare(Operation::Stabilize);
|
||||
assert!(noop.report().changes.is_empty());
|
||||
let latest = format!("{}// another edit\n", fixture.read("project.arcadia"));
|
||||
fixture.put("project.arcadia", &latest);
|
||||
assert_eq!(noop.write().exit_code, 3);
|
||||
assert_eq!(fixture.read("project.arcadia"), latest);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_journal_refuses_read_preview_and_write_without_recovery() {
|
||||
let fixture = Fixture::new();
|
||||
fixture.put("project.arcadia", SIMPLE);
|
||||
assert_eq!(fixture.prepare(Operation::Stabilize).write().exit_code, 0);
|
||||
let noop = fixture.prepare(Operation::Stabilize);
|
||||
let source = fixture.read("project.arcadia");
|
||||
// An opaque incomplete journal must never be interpreted/recovered by CLI.
|
||||
let journal = fixture.0.join(".arcadia-save-project.arcadia.journal");
|
||||
fs::create_dir(&journal).unwrap();
|
||||
fs::write(journal.join("sentinel"), b"retain").unwrap();
|
||||
for op in [Operation::Check, Operation::Stabilize] {
|
||||
let report = prepare(&fixture.entry(), op).err().unwrap();
|
||||
assert_eq!(report.exit_code, 3);
|
||||
assert!(report.diagnostics.iter().any(|d| d.code == "pending_save"));
|
||||
}
|
||||
assert_eq!(
|
||||
noop.write().exit_code,
|
||||
3,
|
||||
"saver must recheck after preview"
|
||||
);
|
||||
assert_eq!(fixture.read("project.arcadia"), source);
|
||||
assert_eq!(fs::read(journal.join("sentinel")).unwrap(), b"retain");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejected_alias_and_same_alias_preserve_source() {
|
||||
let fixture = Fixture::new();
|
||||
fixture.put("project.arcadia", SIMPLE);
|
||||
assert_eq!(fixture.prepare(Operation::Stabilize).write().exit_code, 0);
|
||||
let source = fixture.read("project.arcadia");
|
||||
for (old, new) in [
|
||||
("team", "rescue"),
|
||||
("team", "invalid alias"),
|
||||
("absent", "valid"),
|
||||
] {
|
||||
let (code, result) = fixture.cli(&["rename-alias", old, new, "--write"]);
|
||||
assert_eq!(code, 1);
|
||||
assert_eq!(result["status"], "rejected");
|
||||
assert_eq!(fixture.read("project.arcadia"), source);
|
||||
}
|
||||
let (code, result) = fixture.cli(&["rename-alias", "team", "team", "--write"]);
|
||||
assert_eq!(code, 0);
|
||||
assert_eq!(result["status"], "unchanged");
|
||||
assert_eq!(fixture.read("project.arcadia"), source);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rename_shows_missing_identity_and_separate_source_migration() {
|
||||
let fixture = Fixture::new();
|
||||
let old = "// retain\narcadia 5; project p \"P\" { oa_entity team \"Team\" {} }\n";
|
||||
fixture.put("project.arcadia", old);
|
||||
let candidate = fixture.prepare(Operation::RenameAlias {
|
||||
old: "team".into(),
|
||||
new: "crew".into(),
|
||||
});
|
||||
let report = candidate.report();
|
||||
assert_eq!(report.identities_to_stabilize, 2);
|
||||
assert_eq!(report.migration.as_ref().unwrap().from_schema, 5);
|
||||
assert_eq!(report.migration.as_ref().unwrap().to_schema, 7);
|
||||
assert!(report.changes[0].diff.contains("-arcadia 5;"));
|
||||
assert!(report.changes[0].diff.contains("+arcadia 7;"));
|
||||
assert!(report.changes[0].after.contains("oa_entity crew"));
|
||||
assert_eq!(fixture.read("project.arcadia"), old);
|
||||
assert_eq!(candidate.write().exit_code, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entry_is_explicit_and_machine_readable_usage_errors_do_not_write() {
|
||||
let fixture = Fixture::new();
|
||||
fixture.put("project.arcadia", SIMPLE);
|
||||
let output = Command::new(env!("CARGO_BIN_EXE_arcadia"))
|
||||
.current_dir(&fixture.0)
|
||||
.args(["check", "--json"])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert_eq!(output.status.code(), Some(2));
|
||||
let error: Value = serde_json::from_slice(&output.stdout).unwrap();
|
||||
assert_eq!(error["diagnostics"][0]["code"], "usage");
|
||||
assert_eq!(fixture.read("project.arcadia"), SIMPLE);
|
||||
let (code, _) = fixture.cli(&["check", "--write"]);
|
||||
assert_eq!(code, 2);
|
||||
assert!(Path::new(env!("CARGO_BIN_EXE_arcadia")).is_file());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loader_encoding_missing_dependency_and_invalid_old_source_keep_original_bytes() {
|
||||
let fixture = Fixture::new();
|
||||
let entry = "arcadia 7; project p \"P\" { include \"part.arcadia\"; }";
|
||||
fixture.put("project.arcadia", entry);
|
||||
let (code, report) = fixture.cli(&["stabilize", "--write"]);
|
||||
assert_eq!(code, 3, "a disk dependency could not be read");
|
||||
assert!(
|
||||
report["diagnostics"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|d| d["code"] == "missing_include")
|
||||
);
|
||||
fs::write(fixture.0.join("part.arcadia"), [0xff, 0xfe]).unwrap();
|
||||
let (code, report) = fixture.cli(&["stabilize", "--write"]);
|
||||
assert_eq!(code, 1, "invalid encoding is invalid raw source");
|
||||
assert!(
|
||||
report["diagnostics"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|d| d["code"] == "source_encoding")
|
||||
);
|
||||
assert_eq!(fixture.read("project.arcadia"), entry);
|
||||
assert_eq!(
|
||||
fs::read(fixture.0.join("part.arcadia")).unwrap(),
|
||||
[0xff, 0xfe]
|
||||
);
|
||||
for raw in [
|
||||
"arcadia 5; project p \"P\" { oa_entity a \"A\" { ??? } }",
|
||||
"arcadia 2; project p \"P\" {}",
|
||||
] {
|
||||
fixture.put("project.arcadia", raw);
|
||||
let (code, _) = fixture.cli(&["stabilize", "--write"]);
|
||||
assert_eq!(code, 1);
|
||||
assert_eq!(fixture.read("project.arcadia"), raw);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_element_alias_detached_file_and_unchanged_dependency_conflict() {
|
||||
let fixture = Fixture::new();
|
||||
fixture.put("project.arcadia", "arcadia 7; project p \"P\" { include \"part.arcadia\"; oa_activity a \"A\" {} allocation responsible \"Responsible\" { source @ // keep edge comment\nteam; target @a; } }");
|
||||
fixture.put("part.arcadia", "oa_entity team \"Team\" {}");
|
||||
fixture.put(
|
||||
"detached.arcadia",
|
||||
"invalid detached draft must stay exact 雨",
|
||||
);
|
||||
assert_eq!(fixture.prepare(Operation::Stabilize).write().exit_code, 0);
|
||||
let before = fixture.model();
|
||||
let relation = before
|
||||
.identities()
|
||||
.into_iter()
|
||||
.find(|(_, alias)| *alias == "responsible")
|
||||
.unwrap()
|
||||
.0;
|
||||
let (code, _) = fixture.cli(&["rename-alias", "responsible", "performs", "--write"]);
|
||||
assert_eq!(code, 0);
|
||||
let mut expected = before;
|
||||
expected.relations.get_mut(&relation).unwrap().alias = "performs".into();
|
||||
assert_eq!(fixture.model(), expected);
|
||||
assert!(
|
||||
fixture
|
||||
.read("project.arcadia")
|
||||
.contains("@ // keep edge comment\nteam")
|
||||
);
|
||||
let captured = fixture.prepare(Operation::RenameAlias {
|
||||
old: "p".into(),
|
||||
new: "rescue".into(),
|
||||
});
|
||||
assert!(
|
||||
captured
|
||||
.report()
|
||||
.changes
|
||||
.iter()
|
||||
.all(|c| c.path != "part.arcadia")
|
||||
);
|
||||
let root_before = fixture.read("project.arcadia");
|
||||
fixture.put("part.arcadia", "// external unchanged-dependency edit\n");
|
||||
assert_eq!(captured.write().exit_code, 3);
|
||||
assert_eq!(fixture.read("project.arcadia"), root_before);
|
||||
assert_eq!(
|
||||
fixture.read("part.arcadia"),
|
||||
"// external unchanged-dependency edit\n"
|
||||
);
|
||||
assert_eq!(
|
||||
fixture.read("detached.arcadia"),
|
||||
"invalid detached draft must stay exact 雨"
|
||||
);
|
||||
}
|
||||
@@ -12,3 +12,9 @@ uuid.workspace = true
|
||||
thiserror.workspace = true
|
||||
tempfile = "3"
|
||||
same-file = "1"
|
||||
num-bigint = "0.5.1"
|
||||
num-integer = "0.1.47"
|
||||
num-traits = "0.2.19"
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
libc = "0.2"
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
include!("../../build/windows_manifest.rs");
|
||||
@@ -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,53 @@
|
||||
//! Generate only the new teaching-project files; old compatibility examples stay untouched.
|
||||
#[path = "../../../examples/release1/model.rs"]
|
||||
mod teaching;
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let directory = std::env::args_os()
|
||||
.nth(1)
|
||||
.map(std::path::PathBuf::from)
|
||||
.unwrap_or_else(|| "examples/release1".into());
|
||||
let lesson = teaching::build();
|
||||
let bundle = teaching::checked_bundle(&lesson.project);
|
||||
for (name, source) in &bundle.files {
|
||||
let path = directory.join(name);
|
||||
std::fs::create_dir_all(path.parent().unwrap())?;
|
||||
std::fs::write(path, source)?;
|
||||
}
|
||||
for (name, value) in [
|
||||
("model.json", serde_json::to_value(&lesson.project)?),
|
||||
(
|
||||
"transition-before.json",
|
||||
serde_json::to_value(&lesson.before_transition)?,
|
||||
),
|
||||
(
|
||||
"transition-request.json",
|
||||
serde_json::to_value(&lesson.request)?,
|
||||
),
|
||||
(
|
||||
"expected-diagnostics.json",
|
||||
serde_json::to_value(oa_core::validation::validate(&lesson.project))?,
|
||||
),
|
||||
] {
|
||||
std::fs::write(
|
||||
directory.join(name),
|
||||
serde_json::to_string_pretty(&value)? + "\n",
|
||||
)?;
|
||||
}
|
||||
println!(
|
||||
"Validated {} elements, {} relations, {} views, {} traces; wrote {} source files to {}",
|
||||
lesson.project.elements.len(),
|
||||
lesson.project.relations.len(),
|
||||
lesson.project.diagrams.len(),
|
||||
lesson.project.traces.len(),
|
||||
bundle.files.len(),
|
||||
directory.display()
|
||||
);
|
||||
for issue in oa_core::validation::validate(&lesson.project) {
|
||||
println!(
|
||||
"{:?} {} {:?}: {}",
|
||||
issue.severity, issue.code, issue.element, issue.message
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,442 @@
|
||||
//! Deterministic dense-view fixture and semantic oracle. No GUI or authored routing.
|
||||
//! cargo run -p oa-core --example release_dense_views -- OUTPUT_DIRECTORY
|
||||
use oa_core::{view_options::*, *};
|
||||
use serde_json::json;
|
||||
use std::{
|
||||
collections::{BTreeMap, BTreeSet},
|
||||
error::Error,
|
||||
fs,
|
||||
path::PathBuf,
|
||||
time::Instant,
|
||||
};
|
||||
|
||||
fn id(n: u32) -> Id {
|
||||
format!("d4060906-0000-4000-8000-{n:012x}").parse().unwrap()
|
||||
}
|
||||
|
||||
fn element(p: &mut Project, n: u32, kind: Kind, alias: &str, name: &str, parent: Option<Id>) -> Id {
|
||||
let identity = id(n);
|
||||
assert!(
|
||||
p.elements
|
||||
.insert(
|
||||
identity,
|
||||
Element {
|
||||
id: identity,
|
||||
alias: alias.into(),
|
||||
name: name.into(),
|
||||
description: String::new(),
|
||||
parent,
|
||||
body: ElementBody::new(kind, Layer::Oa).unwrap()
|
||||
}
|
||||
)
|
||||
.is_none()
|
||||
);
|
||||
identity
|
||||
}
|
||||
|
||||
fn relation(p: &mut Project, n: u32, alias: &str, name: &str, body: RelationBody) -> Id {
|
||||
let identity = id(n);
|
||||
assert!(
|
||||
p.relations
|
||||
.insert(
|
||||
identity,
|
||||
Relation {
|
||||
id: identity,
|
||||
alias: alias.into(),
|
||||
name: name.into(),
|
||||
description: String::new(),
|
||||
body
|
||||
}
|
||||
)
|
||||
.is_none()
|
||||
);
|
||||
identity
|
||||
}
|
||||
|
||||
fn fixture() -> (Project, Id, Vec<Id>, Vec<Id>, Vec<Id>) {
|
||||
let mut p = Project::empty();
|
||||
p.id = id(1);
|
||||
p.alias = "dense_service_views".into();
|
||||
p.name = "Forty service exchanges".into();
|
||||
p.description = "Deterministic release fixture. Forty actual work exchanges share two endpoints; no authored routes or synthetic endpoints.".into();
|
||||
let requester = element(&mut p, 10, Kind::Entity, "requester", "Requester", None);
|
||||
let provider = element(&mut p, 11, Kind::Entity, "provider", "Provider", None);
|
||||
let a = element(
|
||||
&mut p,
|
||||
20,
|
||||
Kind::Activity,
|
||||
"request_service",
|
||||
"Request service",
|
||||
None,
|
||||
);
|
||||
let b = element(
|
||||
&mut p,
|
||||
21,
|
||||
Kind::Activity,
|
||||
"provide_service",
|
||||
"Provide service",
|
||||
None,
|
||||
);
|
||||
relation(
|
||||
&mut p,
|
||||
3000,
|
||||
"requester_work",
|
||||
"Requester responsibility",
|
||||
RelationBody::Allocation {
|
||||
source: requester,
|
||||
target: a,
|
||||
},
|
||||
);
|
||||
relation(
|
||||
&mut p,
|
||||
3001,
|
||||
"provider_work",
|
||||
"Provider responsibility",
|
||||
RelationBody::Allocation {
|
||||
source: provider,
|
||||
target: b,
|
||||
},
|
||||
);
|
||||
let group_names = ["Commands", "Status", "Alerts", "Measurements"];
|
||||
let items: Vec<_> = group_names
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, name)| {
|
||||
element(
|
||||
&mut p,
|
||||
100 + i as u32,
|
||||
Kind::ExchangeItem,
|
||||
&format!("payload_{}", i + 1),
|
||||
&format!("{name} payload"),
|
||||
None,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let exchanges: Vec<_> = (0..40)
|
||||
.map(|i| {
|
||||
relation(
|
||||
&mut p,
|
||||
2000 + i,
|
||||
&format!("service_data_{:02}", i + 1),
|
||||
&format!("Service data {:02}", i + 1),
|
||||
RelationBody::Interaction {
|
||||
source: a,
|
||||
target: b,
|
||||
items: vec![items[(i / 10) as usize]],
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let mut categories = vec![];
|
||||
for (i, name) in group_names.iter().enumerate() {
|
||||
let category = element(
|
||||
&mut p,
|
||||
200 + i as u32,
|
||||
Kind::ExchangeCategory,
|
||||
&format!("concern_{}", i + 1),
|
||||
name,
|
||||
None,
|
||||
);
|
||||
*p.elements.get_mut(&category).unwrap().shared_mut().unwrap() =
|
||||
SharedBody::ExchangeCategory {
|
||||
family: ExchangeFamily::Work,
|
||||
exchanges: exchanges[i * 10..i * 10 + 10].to_vec(),
|
||||
};
|
||||
categories.push(category);
|
||||
}
|
||||
let overlapping = element(
|
||||
&mut p,
|
||||
204,
|
||||
Kind::ExchangeCategory,
|
||||
"critical_concern",
|
||||
"Critical cross-cutting exchanges",
|
||||
None,
|
||||
);
|
||||
*p.elements
|
||||
.get_mut(&overlapping)
|
||||
.unwrap()
|
||||
.shared_mut()
|
||||
.unwrap() = SharedBody::ExchangeCategory {
|
||||
family: ExchangeFamily::Work,
|
||||
exchanges: [0, 10, 20, 30].map(|i| exchanges[i]).to_vec(),
|
||||
};
|
||||
categories.push(overlapping);
|
||||
let mut contexts = vec![];
|
||||
for (group, name) in group_names[..3].iter().enumerate() {
|
||||
let process = element(
|
||||
&mut p,
|
||||
300 + group as u32,
|
||||
Kind::Process,
|
||||
&format!("process_{}", group + 1),
|
||||
&format!("{name} process"),
|
||||
None,
|
||||
);
|
||||
for offset in 0..10 {
|
||||
let index = group * 10 + offset;
|
||||
let occurrence = element(
|
||||
&mut p,
|
||||
1000 + index as u32,
|
||||
Kind::Occurrence,
|
||||
&format!("use_data_{:02}", index + 1),
|
||||
&format!("Use service data {:02}", index + 1),
|
||||
Some(process),
|
||||
);
|
||||
*p.elements
|
||||
.get_mut(&occurrence)
|
||||
.unwrap()
|
||||
.shared_mut()
|
||||
.unwrap() = SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange {
|
||||
operation: None,
|
||||
exchange: Some(exchanges[index]),
|
||||
sender: None,
|
||||
receiver: None,
|
||||
message_kind: MessageKind::Asynchronous,
|
||||
reply_to: None,
|
||||
values: vec![],
|
||||
},
|
||||
precondition: String::new(),
|
||||
postcondition: String::new(),
|
||||
};
|
||||
p.elements
|
||||
.get_mut(&process)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap()
|
||||
.push(occurrence);
|
||||
}
|
||||
contexts.push(process);
|
||||
}
|
||||
let diagram = id(4000);
|
||||
let nodes = [(a, 80.), (b, 760.)]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(i, (element, x))| {
|
||||
let id = id(4100 + i as u32);
|
||||
(
|
||||
id,
|
||||
NodePlacement {
|
||||
id,
|
||||
alias: format!("service_placement_{}", i + 1),
|
||||
element,
|
||||
position: Position { x, y: 160. },
|
||||
size: Size {
|
||||
width: 230.,
|
||||
height: 150.,
|
||||
},
|
||||
collapsed: false,
|
||||
port_anchors: vec![],
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
p.diagrams.insert(diagram, Diagram { id: diagram, alias: "forty_exchange_view".into(), name: "Forty service exchanges · all concerns".into(), description: "Start with All. Compare first category, overlapping concerns, global coverage and chosen processes. No hand-routed edges.".into(), layer: Layer::Oa, projection: ProjectionKind::FunctionalDataflow, view_options: ViewOptions::default(), nodes, routes: vec![] });
|
||||
(p, diagram, exchanges, categories, contexts)
|
||||
}
|
||||
|
||||
fn projected(p: &Project, diagram: &Diagram) -> BTreeSet<Id> {
|
||||
projection::project_diagram(p, diagram)
|
||||
.edges
|
||||
.into_iter()
|
||||
.map(|edge| edge.id)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn overlap(a: routing::RoutingBounds, b: routing::RoutingBounds) -> bool {
|
||||
a.position.x < b.position.x + b.size.width
|
||||
&& b.position.x < a.position.x + a.size.width
|
||||
&& a.position.y < b.position.y + b.size.height
|
||||
&& b.position.y < a.position.y + a.size.height
|
||||
}
|
||||
|
||||
fn main() -> Result<(), Box<dyn Error>> {
|
||||
let output = PathBuf::from(
|
||||
std::env::args_os()
|
||||
.nth(1)
|
||||
.ok_or("Supply an output directory for the dense-view fixture.")?,
|
||||
);
|
||||
let (p, diagram, exchanges, categories, contexts) = fixture();
|
||||
let issues = validation::validate(&p);
|
||||
assert!(
|
||||
!issues
|
||||
.iter()
|
||||
.any(|issue| issue.severity == validation::Severity::Error),
|
||||
"{issues:?}"
|
||||
);
|
||||
let pristine = p.clone();
|
||||
let original = &p.diagrams[&diagram];
|
||||
assert!(original.routes.is_empty());
|
||||
let all = projection::project_view(&p, diagram);
|
||||
assert_eq!(all.nodes.len(), 2);
|
||||
assert_eq!(all.edges.len(), 40);
|
||||
assert_eq!(
|
||||
all.edges
|
||||
.iter()
|
||||
.map(|edge| (edge.source, edge.target))
|
||||
.collect::<BTreeSet<_>>()
|
||||
.len(),
|
||||
1
|
||||
);
|
||||
let sets = |ids: &[Id]| ids.iter().copied().collect::<BTreeSet<_>>();
|
||||
let cases = vec![
|
||||
("all", ViewOptions::default(), sets(&exchanges)),
|
||||
(
|
||||
"first_category",
|
||||
ViewOptions {
|
||||
work_categories: CategorySelection::Any(vec![categories[0]]),
|
||||
..ViewOptions::default()
|
||||
},
|
||||
sets(&exchanges[..10]),
|
||||
),
|
||||
(
|
||||
"first_and_overlap",
|
||||
ViewOptions {
|
||||
work_categories: CategorySelection::Any(vec![categories[0], categories[4]]),
|
||||
..ViewOptions::default()
|
||||
},
|
||||
exchanges[..10]
|
||||
.iter()
|
||||
.copied()
|
||||
.chain([exchanges[10], exchanges[20], exchanges[30]])
|
||||
.collect(),
|
||||
),
|
||||
(
|
||||
"in_behavior",
|
||||
ViewOptions {
|
||||
coverage: Coverage::InBehavior,
|
||||
..ViewOptions::default()
|
||||
},
|
||||
sets(&exchanges[..30]),
|
||||
),
|
||||
(
|
||||
"outside_behavior",
|
||||
ViewOptions {
|
||||
coverage: Coverage::OutsideBehavior,
|
||||
..ViewOptions::default()
|
||||
},
|
||||
sets(&exchanges[30..]),
|
||||
),
|
||||
(
|
||||
"second_process",
|
||||
ViewOptions {
|
||||
work_contexts: ContextSelection::Any(vec![contexts[1]]),
|
||||
..ViewOptions::default()
|
||||
},
|
||||
sets(&exchanges[10..20]),
|
||||
),
|
||||
(
|
||||
"outside_second_process",
|
||||
ViewOptions {
|
||||
coverage: Coverage::OutsideBehavior,
|
||||
work_contexts: ContextSelection::Any(vec![contexts[1]]),
|
||||
..ViewOptions::default()
|
||||
},
|
||||
BTreeSet::new(),
|
||||
),
|
||||
(
|
||||
"empty_categories",
|
||||
ViewOptions {
|
||||
work_categories: CategorySelection::Any(vec![]),
|
||||
..ViewOptions::default()
|
||||
},
|
||||
BTreeSet::new(),
|
||||
),
|
||||
(
|
||||
"empty_contexts",
|
||||
ViewOptions {
|
||||
work_contexts: ContextSelection::Any(vec![]),
|
||||
..ViewOptions::default()
|
||||
},
|
||||
BTreeSet::new(),
|
||||
),
|
||||
(
|
||||
"no_lines",
|
||||
ViewOptions {
|
||||
relation_kinds: RelationKinds::Only(vec![]),
|
||||
..ViewOptions::default()
|
||||
},
|
||||
BTreeSet::new(),
|
||||
),
|
||||
(
|
||||
"first_hide_05",
|
||||
ViewOptions {
|
||||
hidden_edges: vec![exchanges[4]],
|
||||
work_categories: CategorySelection::Any(vec![categories[0]]),
|
||||
exchange_labels: ExchangeLabels::NameAndItems,
|
||||
..ViewOptions::default()
|
||||
},
|
||||
exchanges[..10]
|
||||
.iter()
|
||||
.filter(|edge| **edge != exchanges[4])
|
||||
.copied()
|
||||
.collect(),
|
||||
),
|
||||
];
|
||||
let mut case_results = BTreeMap::new();
|
||||
for (name, options, expected) in cases {
|
||||
assert!(validate_options(&p, Layer::Oa, &options).is_empty());
|
||||
let mut changed = original.clone();
|
||||
changed.view_options = options;
|
||||
assert_eq!(projected(&p, &changed), expected, "{name}");
|
||||
let view = projection::project_diagram(&p, &changed);
|
||||
assert_eq!(
|
||||
view.nodes, all.nodes,
|
||||
"{name}: visibility must not move nodes"
|
||||
);
|
||||
assert_eq!(
|
||||
view.ports, all.ports,
|
||||
"{name}: visibility must not move ports"
|
||||
);
|
||||
assert!(changed.routes.is_empty());
|
||||
case_results.insert(name, json!({"count":expected.len(),"edges":expected}));
|
||||
}
|
||||
assert_eq!(
|
||||
ExchangeLabels::Items.label(&p, exchanges[0]),
|
||||
"Commands payload"
|
||||
);
|
||||
assert_eq!(
|
||||
ExchangeLabels::NameAndItems.label(&p, exchanges[39]),
|
||||
"Service data 40 · Measurements payload"
|
||||
);
|
||||
let began = Instant::now();
|
||||
let routed = routing::route_diagram(&p, original, &all);
|
||||
let route_ms = began.elapsed().as_millis();
|
||||
assert_eq!(routed.edges.len(), 40);
|
||||
let edges: Vec<_> = routed.edges.values().collect();
|
||||
let mut label_overlaps = vec![];
|
||||
let mut coincident_paths = vec![];
|
||||
for (i, first) in edges.iter().enumerate() {
|
||||
for second in &edges[i + 1..] {
|
||||
if overlap(first.label.bounds, second.label.bounds) {
|
||||
label_overlaps.push([first.id, second.id]);
|
||||
}
|
||||
if first.points == second.points {
|
||||
coincident_paths.push([first.id, second.id]);
|
||||
}
|
||||
}
|
||||
}
|
||||
let source = language::format(&p);
|
||||
assert_eq!(
|
||||
language::parse(&source).expect("Canonical fixture source must reopen"),
|
||||
p
|
||||
);
|
||||
let native_json = serde_json::to_vec_pretty(&p)?;
|
||||
assert_eq!(persistence::decode(&native_json)?, p);
|
||||
assert_eq!(
|
||||
p, pristine,
|
||||
"Oracles and routing must not mutate model facts"
|
||||
);
|
||||
let report = json!({"schema":p.schema_version,"project":p.id,"diagram":diagram,"semantic_oracles":"passed","elements":p.elements.len(),"relations":p.relations.len(),"cases":case_results,
|
||||
"routing_ms":route_ms,"label_overlap_pairs":label_overlaps,"exactly_coincident_path_pairs":coincident_paths,
|
||||
"routing_warnings":edges.iter().map(|edge| json!({"edge":edge.id,"warnings":edge.warnings.iter().map(ToString::to_string).collect::<Vec<_>>()})).collect::<Vec<_>>(),
|
||||
"native_readability":"pending; metrics do not establish a usability pass"});
|
||||
fs::create_dir_all(&output)?;
|
||||
fs::write(output.join("dense-40.arcadia"), source)?;
|
||||
fs::write(output.join("dense-40.json"), native_json)?;
|
||||
fs::write(
|
||||
output.join("dense-40-oracle.json"),
|
||||
serde_json::to_vec_pretty(&report)?,
|
||||
)?;
|
||||
println!("{}", serde_json::to_string_pretty(&report)?);
|
||||
Ok(())
|
||||
}
|
||||
@@ -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,103 @@
|
||||
//! Exact numeric foundation for analysis; no model, expression, or package resolver.
|
||||
//!
|
||||
//! Values use reduced rational coordinates. All nontrivial arithmetic takes one
|
||||
//! request-local [WorkMeter]. The future evaluator must also charge *expanded*
|
||||
//! expression/call/property/list/target visits and retain the same logical walk on
|
||||
//! cache hits. Stored AST size is not a dynamic execution limit.
|
||||
//!
|
||||
//! Approximate engineering functions/irrational unit scales require a subsequent
|
||||
//! explicit numeric profile; rationals do not implement degree/radian conversion
|
||||
//! or transcendental mathematics.
|
||||
|
||||
mod dimension;
|
||||
mod exact;
|
||||
mod meter;
|
||||
mod quantity;
|
||||
|
||||
pub use dimension::{Dimension, DimensionAxis};
|
||||
pub use exact::{Exact, ExactWire};
|
||||
pub use meter::{
|
||||
EvaluationStep, NumericOperation, WorkExhaustion, WorkLimits, WorkMeter, WorkResource,
|
||||
WorkSite, WorkUsage,
|
||||
};
|
||||
pub use quantity::{NumericType, NumericValue, ResolvedKind, Unit};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
|
||||
/// Profile identity includes the accounting rules, not crate-internal timing.
|
||||
pub const WORK_PROFILE: &str = "Q1-logical-work-1";
|
||||
pub const MAX_COMPONENT_BITS: u64 = 4096;
|
||||
/// Cross products require 8192 bits; their sum can require one additional bit.
|
||||
pub const MAX_INTERMEDIATE_BITS: u64 = 8193;
|
||||
pub const MAX_COMPONENT_DIGITS: usize = 1234;
|
||||
pub const MAX_DECIMAL_EXPONENT: i32 = 128;
|
||||
pub const MAX_INTEGER_POWER: i8 = 16;
|
||||
/// Includes ratio punctuation/whitespace without permitting unbounded scanning.
|
||||
pub const MAX_LITERAL_BYTES: usize = 2 * MAX_COMPONENT_DIGITS + 32;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum NumberComponent {
|
||||
Numerator,
|
||||
Denominator,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Error, Serialize, Deserialize)]
|
||||
pub enum NumericLimit {
|
||||
#[error("request work exhausted: {0}")]
|
||||
Work(WorkExhaustion),
|
||||
#[error("{component:?} uses {bits} bits, above the {limit}-bit {stage} limit")]
|
||||
Bits {
|
||||
component: NumberComponent,
|
||||
bits: u64,
|
||||
limit: u64,
|
||||
stage: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Error, Serialize, Deserialize)]
|
||||
pub enum NumericError {
|
||||
#[error("numeric limit: {0}")]
|
||||
Limit(NumericLimit),
|
||||
#[error("invalid numeric literal")]
|
||||
InvalidLiteral,
|
||||
#[error("numeric literal exceeds its digit or byte limit")]
|
||||
LiteralTooLong,
|
||||
#[error("decimal exponent is outside -128 through 128")]
|
||||
DecimalExponent,
|
||||
#[error("wire rational is not in canonical reduced form")]
|
||||
NonCanonicalWire,
|
||||
#[error("rational denominator is zero")]
|
||||
ZeroDenominator,
|
||||
#[error("division by zero")]
|
||||
DivisionByZero,
|
||||
#[error("zero to the power zero is undefined in this profile")]
|
||||
ZeroPowerZero,
|
||||
#[error("integer power is outside -16 through 16")]
|
||||
IntegerPower,
|
||||
#[error("dimension exponent at {axis:?} is outside -32 through 32: {value}")]
|
||||
DimensionExponent { axis: DimensionAxis, value: i32 },
|
||||
#[error("unit scale must be strictly positive")]
|
||||
UnitScale,
|
||||
#[error("a linear unit must have zero offset")]
|
||||
LinearOffset,
|
||||
#[error("an affine point must have a distinct linear difference kind")]
|
||||
DifferenceKind,
|
||||
#[error("numeric kinds or dimensions are incompatible")]
|
||||
IncompatibleTypes,
|
||||
#[error("operation is not defined for affine points")]
|
||||
AffineOperation,
|
||||
#[error("AsKind requires an anonymous matching dimension and a linear target")]
|
||||
KindAssertion,
|
||||
#[error("request work limits exceed the provisional reference ceilings")]
|
||||
InvalidWorkLimits,
|
||||
}
|
||||
|
||||
impl NumericError {
|
||||
pub fn is_terminal_work_exhaustion(&self) -> bool {
|
||||
matches!(self, Self::Limit(NumericLimit::Work(_)))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,107 @@
|
||||
use super::NumericError;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum DimensionAxis {
|
||||
Length,
|
||||
Mass,
|
||||
Time,
|
||||
ElectricCurrent,
|
||||
Temperature,
|
||||
Amount,
|
||||
LuminousIntensity,
|
||||
}
|
||||
|
||||
impl DimensionAxis {
|
||||
pub const ALL: [Self; 7] = [
|
||||
Self::Length,
|
||||
Self::Mass,
|
||||
Self::Time,
|
||||
Self::ElectricCurrent,
|
||||
Self::Temperature,
|
||||
Self::Amount,
|
||||
Self::LuminousIntensity,
|
||||
];
|
||||
}
|
||||
|
||||
/// SI base-dimension vector in DimensionAxis::ALL order, each entry -32..=32.
|
||||
/// This is a dimension, never a nominal quantity-kind compatibility proof.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(try_from = "[i16; 7]", into = "[i16; 7]")]
|
||||
pub struct Dimension([i16; 7]);
|
||||
|
||||
impl Dimension {
|
||||
pub const ZERO: Self = Self([0; 7]);
|
||||
|
||||
pub fn new(exponents: [i16; 7]) -> Result<Self, NumericError> {
|
||||
for (index, value) in exponents.iter().copied().enumerate() {
|
||||
if !(-32..=32).contains(&value) {
|
||||
return Err(NumericError::DimensionExponent {
|
||||
axis: DimensionAxis::ALL[index],
|
||||
value: i32::from(value),
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(Self(exponents))
|
||||
}
|
||||
|
||||
pub fn exponents(self) -> [i16; 7] {
|
||||
self.0
|
||||
}
|
||||
|
||||
pub fn is_dimensionless(self) -> bool {
|
||||
self == Self::ZERO
|
||||
}
|
||||
|
||||
pub fn checked_product(self, rhs: Self) -> Result<Self, NumericError> {
|
||||
self.combine(rhs, 1)
|
||||
}
|
||||
|
||||
pub fn checked_quotient(self, rhs: Self) -> Result<Self, NumericError> {
|
||||
self.combine(rhs, -1)
|
||||
}
|
||||
|
||||
pub fn checked_power(self, exponent: i8) -> Result<Self, NumericError> {
|
||||
if !(-super::MAX_INTEGER_POWER..=super::MAX_INTEGER_POWER).contains(&exponent) {
|
||||
return Err(NumericError::IntegerPower);
|
||||
}
|
||||
let mut output = [0; 7];
|
||||
for (index, value) in self.0.iter().enumerate() {
|
||||
output[index] = Self::checked_exponent(index, i32::from(*value) * i32::from(exponent))?;
|
||||
}
|
||||
Ok(Self(output))
|
||||
}
|
||||
|
||||
fn combine(self, rhs: Self, sign: i32) -> Result<Self, NumericError> {
|
||||
let mut output = [0; 7];
|
||||
for (index, value) in self.0.iter().enumerate() {
|
||||
output[index] =
|
||||
Self::checked_exponent(index, i32::from(*value) + sign * i32::from(rhs.0[index]))?;
|
||||
}
|
||||
Ok(Self(output))
|
||||
}
|
||||
|
||||
fn checked_exponent(index: usize, value: i32) -> Result<i16, NumericError> {
|
||||
if !(-32..=32).contains(&value) {
|
||||
return Err(NumericError::DimensionExponent {
|
||||
axis: DimensionAxis::ALL[index],
|
||||
value,
|
||||
});
|
||||
}
|
||||
Ok(value as i16)
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<[i16; 7]> for Dimension {
|
||||
type Error = NumericError;
|
||||
|
||||
fn try_from(value: [i16; 7]) -> Result<Self, Self::Error> {
|
||||
Self::new(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Dimension> for [i16; 7] {
|
||||
fn from(value: Dimension) -> Self {
|
||||
value.0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,469 @@
|
||||
use super::meter::{normalization_cost, parse_cost, product_cost, sum_cost, words};
|
||||
use super::{
|
||||
MAX_COMPONENT_BITS, MAX_COMPONENT_DIGITS, MAX_DECIMAL_EXPONENT, MAX_INTEGER_POWER,
|
||||
MAX_INTERMEDIATE_BITS, MAX_LITERAL_BYTES, NumberComponent, NumericError, NumericLimit,
|
||||
NumericOperation, WorkLimits, WorkMeter,
|
||||
};
|
||||
use num_bigint::BigInt;
|
||||
use num_integer::Integer;
|
||||
use num_traits::{One, Signed, Zero};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::cmp::Ordering;
|
||||
|
||||
/// The interchange representation deliberately does not use num-bigint's serde
|
||||
/// format or JSON numeric values. Duplicate/missing/unknown fields reject.
|
||||
/// Its derived decoder allocates String fields before numeric validation; a
|
||||
/// transport/document adapter must bound the encoded envelope and decoded text.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ExactWire {
|
||||
pub numerator: String,
|
||||
pub denominator: String,
|
||||
}
|
||||
|
||||
/// Reduced rational with positive denominator and <=4096-bit components.
|
||||
///
|
||||
/// There are no unmetered arithmetic/ordering traits. Eq is structural equality
|
||||
/// of already normalized bounded values, not an expression comparison API.
|
||||
/// Standalone serde admission bounds the subsequent BigInt validation, not the
|
||||
/// preceding String decoding. Document adapters must bound encoded/decoded input
|
||||
/// and aggregate admission work. Evaluation uses one explicit WorkMeter.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(try_from = "ExactWire", into = "ExactWire")]
|
||||
pub struct Exact {
|
||||
numerator: BigInt,
|
||||
denominator: BigInt,
|
||||
}
|
||||
|
||||
impl Exact {
|
||||
pub fn from_integer(value: i64) -> Self {
|
||||
Self {
|
||||
numerator: BigInt::from(value),
|
||||
denominator: BigInt::one(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_zero(&self) -> bool {
|
||||
self.numerator.is_zero()
|
||||
}
|
||||
|
||||
pub fn is_positive(&self) -> bool {
|
||||
self.numerator.is_positive()
|
||||
}
|
||||
|
||||
pub fn component_bits(&self) -> (u64, u64) {
|
||||
(self.numerator.bits(), self.denominator.bits())
|
||||
}
|
||||
|
||||
pub fn to_wire(&self) -> ExactWire {
|
||||
ExactWire {
|
||||
numerator: self.numerator.to_string(),
|
||||
denominator: self.denominator.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Canonical *literal* spelling, not a generic expression formatter.
|
||||
/// Negative literal nodes use ratio(-n,d), even when d=1. A leading minus
|
||||
/// belongs to the future expression parser's UnaryNegate node instead.
|
||||
pub fn canonical_literal(&self) -> String {
|
||||
if self.denominator.is_one() && !self.numerator.is_negative() {
|
||||
self.numerator.to_string()
|
||||
} else {
|
||||
format!("ratio({}, {})", self.numerator, self.denominator)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_wire(wire: &ExactWire, meter: &mut WorkMeter) -> Result<Self, NumericError> {
|
||||
meter.ensure_active()?;
|
||||
let n_digits = wire_digits(&wire.numerator, true)?;
|
||||
let d_digits = wire_digits(&wire.denominator, false)?;
|
||||
let n_bound = decimal_bits(n_digits.len());
|
||||
let d_bound = decimal_bits(d_digits.len());
|
||||
meter.numeric(
|
||||
NumericOperation::ParseWire,
|
||||
parse_cost(n_digits.len(), n_bound)
|
||||
+ parse_cost(d_digits.len(), d_bound)
|
||||
+ normalization_cost(n_bound, d_bound),
|
||||
)?;
|
||||
let numerator = parse_integer(&wire.numerator)?;
|
||||
let denominator = parse_integer(&wire.denominator)?;
|
||||
Self::check_bits(&numerator, &denominator, MAX_COMPONENT_BITS, "wire")?;
|
||||
if denominator.is_zero() {
|
||||
return Err(NumericError::ZeroDenominator);
|
||||
}
|
||||
if !numerator.gcd(&denominator).is_one() {
|
||||
return Err(NumericError::NonCanonicalWire);
|
||||
}
|
||||
Ok(Self {
|
||||
numerator,
|
||||
denominator,
|
||||
})
|
||||
}
|
||||
|
||||
/// Reads one unsigned decimal/integer atom or a ratio(n,d) literal only.
|
||||
/// It does not parse expressions, unary operators, units, calls or names.
|
||||
/// Source ratios/decimals may reduce; wire values must already be canonical.
|
||||
pub fn parse_literal(source: &str, meter: &mut WorkMeter) -> Result<Self, NumericError> {
|
||||
meter.ensure_active()?;
|
||||
if source.len() > MAX_LITERAL_BYTES {
|
||||
return Err(NumericError::LiteralTooLong);
|
||||
}
|
||||
let source = trim_ascii(source);
|
||||
if let Some(body) = source.strip_prefix("ratio(") {
|
||||
let body = body.strip_suffix(')').ok_or(NumericError::InvalidLiteral)?;
|
||||
let (n, d) = body.split_once(',').ok_or(NumericError::InvalidLiteral)?;
|
||||
return Self::parse_ratio(trim_ascii(n), trim_ascii(d), meter);
|
||||
}
|
||||
Self::parse_decimal(source, meter)
|
||||
}
|
||||
|
||||
fn parse_ratio(n: &str, d: &str, meter: &mut WorkMeter) -> Result<Self, NumericError> {
|
||||
let n_digits = source_integer_digits(n, true)?;
|
||||
let d_digits = source_integer_digits(d, false)?;
|
||||
let n_bound = decimal_bits(n_digits.len());
|
||||
let d_bound = decimal_bits(d_digits.len());
|
||||
Self::check_estimates(n_bound, d_bound)?;
|
||||
meter.numeric(
|
||||
NumericOperation::ParseLiteral,
|
||||
parse_cost(n_digits.len(), n_bound)
|
||||
+ parse_cost(d_digits.len(), d_bound)
|
||||
+ normalization_cost(n_bound, d_bound),
|
||||
)?;
|
||||
Self::normalize_precharged(parse_integer(n)?, parse_integer(d)?)
|
||||
}
|
||||
|
||||
fn parse_decimal(source: &str, meter: &mut WorkMeter) -> Result<Self, NumericError> {
|
||||
let mut parts = source.split(['e', 'E']);
|
||||
let mantissa = parts.next().ok_or(NumericError::InvalidLiteral)?;
|
||||
let exponent = match parts.next() {
|
||||
Some(text) => parse_exponent(text)?,
|
||||
None => 0,
|
||||
};
|
||||
if parts.next().is_some() {
|
||||
return Err(NumericError::InvalidLiteral);
|
||||
}
|
||||
let (whole, fraction) = match mantissa.split_once('.') {
|
||||
Some((whole, fraction)) if !fraction.is_empty() => (whole, fraction),
|
||||
Some(_) => return Err(NumericError::InvalidLiteral),
|
||||
None => (mantissa, ""),
|
||||
};
|
||||
source_integer_digits(whole, false)?;
|
||||
if !fraction.bytes().all(|byte| byte.is_ascii_digit()) {
|
||||
return Err(NumericError::InvalidLiteral);
|
||||
}
|
||||
let digit_count = whole.len() + fraction.len();
|
||||
if digit_count > MAX_COMPONENT_DIGITS {
|
||||
return Err(NumericError::LiteralTooLong);
|
||||
}
|
||||
let adjustment = exponent - fraction.len() as i32;
|
||||
let power = adjustment.unsigned_abs();
|
||||
let mantissa_bound = decimal_bits(digit_count);
|
||||
let scale_bound = decimal_bits(power as usize + 1);
|
||||
let (n_bound, d_bound) = if adjustment >= 0 {
|
||||
(mantissa_bound + scale_bound, 1)
|
||||
} else {
|
||||
(mantissa_bound, scale_bound)
|
||||
};
|
||||
Self::check_estimates(n_bound, d_bound)?;
|
||||
// Fixed logical allowance for constructing 10^power; independent of
|
||||
// num-bigint's selected multiplication/exponentiation implementation.
|
||||
let power_steps = 1 + 2 * u64::from(u32::BITS - power.leading_zeros());
|
||||
meter.numeric(
|
||||
NumericOperation::ParseLiteral,
|
||||
parse_cost(digit_count, mantissa_bound)
|
||||
+ power_steps * product_cost(scale_bound, scale_bound)
|
||||
+ product_cost(mantissa_bound, scale_bound)
|
||||
+ normalization_cost(n_bound, d_bound),
|
||||
)?;
|
||||
let digits = format!("{whole}{fraction}");
|
||||
let mut numerator = parse_integer(&digits)?;
|
||||
let scale = BigInt::from(10_u8).pow(power);
|
||||
let denominator = if adjustment >= 0 {
|
||||
numerator *= scale;
|
||||
BigInt::one()
|
||||
} else {
|
||||
scale
|
||||
};
|
||||
Self::normalize_precharged(numerator, denominator)
|
||||
}
|
||||
|
||||
pub fn add(&self, rhs: &Self, meter: &mut WorkMeter) -> Result<Self, NumericError> {
|
||||
self.add_or_subtract(rhs, false, meter)
|
||||
}
|
||||
|
||||
pub fn subtract(&self, rhs: &Self, meter: &mut WorkMeter) -> Result<Self, NumericError> {
|
||||
self.add_or_subtract(rhs, true, meter)
|
||||
}
|
||||
|
||||
fn add_or_subtract(
|
||||
&self,
|
||||
rhs: &Self,
|
||||
subtract: bool,
|
||||
meter: &mut WorkMeter,
|
||||
) -> Result<Self, NumericError> {
|
||||
let (a, b) = self.component_bits();
|
||||
let (c, d) = rhs.component_bits();
|
||||
let left_bound = a + d;
|
||||
let right_bound = c + b;
|
||||
let n_bound = left_bound.max(right_bound) + 1;
|
||||
let d_bound = b + d;
|
||||
Self::check_estimates(n_bound, d_bound)?;
|
||||
meter.numeric(
|
||||
if subtract {
|
||||
NumericOperation::Subtract
|
||||
} else {
|
||||
NumericOperation::Add
|
||||
},
|
||||
product_cost(a, d)
|
||||
+ product_cost(c, b)
|
||||
+ sum_cost(left_bound, right_bound)
|
||||
+ product_cost(b, d)
|
||||
+ normalization_cost(n_bound, d_bound),
|
||||
)?;
|
||||
let left = &self.numerator * &rhs.denominator;
|
||||
let right = &rhs.numerator * &self.denominator;
|
||||
let numerator = if subtract { left - right } else { left + right };
|
||||
let denominator = &self.denominator * &rhs.denominator;
|
||||
Self::normalize_precharged(numerator, denominator)
|
||||
}
|
||||
|
||||
pub fn multiply(&self, rhs: &Self, meter: &mut WorkMeter) -> Result<Self, NumericError> {
|
||||
let (a, b) = self.component_bits();
|
||||
let (c, d) = rhs.component_bits();
|
||||
Self::check_estimates(a + c, b + d)?;
|
||||
meter.numeric(
|
||||
NumericOperation::Multiply,
|
||||
product_cost(a, c) + product_cost(b, d) + normalization_cost(a + c, b + d),
|
||||
)?;
|
||||
Self::normalize_precharged(
|
||||
&self.numerator * &rhs.numerator,
|
||||
&self.denominator * &rhs.denominator,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn divide(&self, rhs: &Self, meter: &mut WorkMeter) -> Result<Self, NumericError> {
|
||||
let (a, b) = self.component_bits();
|
||||
let (c, d) = rhs.component_bits();
|
||||
Self::check_estimates(a + d, b + c)?;
|
||||
meter.numeric(
|
||||
NumericOperation::Divide,
|
||||
product_cost(a, d) + product_cost(b, c) + normalization_cost(a + d, b + c),
|
||||
)?;
|
||||
if rhs.is_zero() {
|
||||
return Err(NumericError::DivisionByZero);
|
||||
}
|
||||
Self::normalize_precharged(
|
||||
&self.numerator * &rhs.denominator,
|
||||
&self.denominator * &rhs.numerator,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn compare(&self, rhs: &Self, meter: &mut WorkMeter) -> Result<Ordering, NumericError> {
|
||||
let (a, b) = self.component_bits();
|
||||
let (c, d) = rhs.component_bits();
|
||||
Self::check_estimates((a + d).max(c + b), 1)?;
|
||||
meter.numeric(
|
||||
NumericOperation::Compare,
|
||||
product_cost(a, d) + product_cost(c, b) + sum_cost(a + d, c + b),
|
||||
)?;
|
||||
Ok((&self.numerator * &rhs.denominator).cmp(&(&rhs.numerator * &self.denominator)))
|
||||
}
|
||||
|
||||
pub fn negated(&self, meter: &mut WorkMeter) -> Result<Self, NumericError> {
|
||||
meter.numeric(NumericOperation::Negate, self.copy_cost())?;
|
||||
Ok(Self {
|
||||
numerator: -&self.numerator,
|
||||
denominator: self.denominator.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn absolute(&self, meter: &mut WorkMeter) -> Result<Self, NumericError> {
|
||||
meter.numeric(NumericOperation::Absolute, self.copy_cost())?;
|
||||
Ok(Self {
|
||||
numerator: self.numerator.abs(),
|
||||
denominator: self.denominator.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn reciprocal(&self, meter: &mut WorkMeter) -> Result<Self, NumericError> {
|
||||
meter.numeric(NumericOperation::Reciprocal, self.copy_cost())?;
|
||||
if self.is_zero() {
|
||||
return Err(NumericError::DivisionByZero);
|
||||
}
|
||||
Ok(Self {
|
||||
numerator: if self.numerator.is_negative() {
|
||||
-&self.denominator
|
||||
} else {
|
||||
self.denominator.clone()
|
||||
},
|
||||
denominator: self.numerator.abs(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Fixed left-to-right repeated multiplication, at most 16 multiplications.
|
||||
/// Each reduced stage obeys the same component bound; no large power is
|
||||
/// allocated and subsequently discarded after discovering it is oversized.
|
||||
pub fn power(&self, exponent: i8, meter: &mut WorkMeter) -> Result<Self, NumericError> {
|
||||
meter.numeric(NumericOperation::Power, self.copy_cost())?;
|
||||
if !(-MAX_INTEGER_POWER..=MAX_INTEGER_POWER).contains(&exponent) {
|
||||
return Err(NumericError::IntegerPower);
|
||||
}
|
||||
if self.is_zero() && exponent == 0 {
|
||||
return Err(NumericError::ZeroPowerZero);
|
||||
}
|
||||
let factor = if exponent < 0 {
|
||||
self.reciprocal(meter)?
|
||||
} else {
|
||||
self.clone()
|
||||
};
|
||||
let mut value = Self::from_integer(1);
|
||||
for _ in 0..exponent.unsigned_abs() {
|
||||
value = value.multiply(&factor, meter)?;
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
pub(super) fn copy_cost(&self) -> u64 {
|
||||
let (n, d) = self.component_bits();
|
||||
1 + words(n) + words(d)
|
||||
}
|
||||
|
||||
fn normalize_precharged(
|
||||
mut numerator: BigInt,
|
||||
mut denominator: BigInt,
|
||||
) -> Result<Self, NumericError> {
|
||||
Self::check_bits(
|
||||
&numerator,
|
||||
&denominator,
|
||||
MAX_INTERMEDIATE_BITS,
|
||||
"intermediate",
|
||||
)?;
|
||||
if denominator.is_zero() {
|
||||
return Err(NumericError::ZeroDenominator);
|
||||
}
|
||||
if denominator.is_negative() {
|
||||
numerator = -numerator;
|
||||
denominator = -denominator;
|
||||
}
|
||||
let gcd = numerator.gcd(&denominator);
|
||||
numerator /= &gcd;
|
||||
denominator /= gcd;
|
||||
Self::check_bits(&numerator, &denominator, MAX_COMPONENT_BITS, "reduced")?;
|
||||
Ok(Self {
|
||||
numerator,
|
||||
denominator,
|
||||
})
|
||||
}
|
||||
|
||||
fn check_estimates(n: u64, d: u64) -> Result<(), NumericError> {
|
||||
Self::check_bit_count(
|
||||
n,
|
||||
NumberComponent::Numerator,
|
||||
MAX_INTERMEDIATE_BITS,
|
||||
"predicted",
|
||||
)?;
|
||||
Self::check_bit_count(
|
||||
d,
|
||||
NumberComponent::Denominator,
|
||||
MAX_INTERMEDIATE_BITS,
|
||||
"predicted",
|
||||
)
|
||||
}
|
||||
|
||||
fn check_bits(n: &BigInt, d: &BigInt, limit: u64, stage: &str) -> Result<(), NumericError> {
|
||||
Self::check_bit_count(n.bits(), NumberComponent::Numerator, limit, stage)?;
|
||||
Self::check_bit_count(d.bits(), NumberComponent::Denominator, limit, stage)
|
||||
}
|
||||
|
||||
fn check_bit_count(
|
||||
bits: u64,
|
||||
component: NumberComponent,
|
||||
limit: u64,
|
||||
stage: &str,
|
||||
) -> Result<(), NumericError> {
|
||||
if bits > limit {
|
||||
return Err(NumericError::Limit(NumericLimit::Bits {
|
||||
component,
|
||||
bits,
|
||||
limit,
|
||||
stage: stage.to_owned(),
|
||||
}));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<ExactWire> for Exact {
|
||||
type Error = NumericError;
|
||||
|
||||
fn try_from(value: ExactWire) -> Result<Self, Self::Error> {
|
||||
// BigInt validation is bounded after ExactWire strings have decoded.
|
||||
// The adapter owns envelope/text/aggregate admission bounds and must not
|
||||
// reset evaluation meters.
|
||||
Self::from_wire(&value, &mut WorkMeter::new(WorkLimits::REFERENCE))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Exact> for ExactWire {
|
||||
fn from(value: Exact) -> Self {
|
||||
value.to_wire()
|
||||
}
|
||||
}
|
||||
|
||||
fn trim_ascii(text: &str) -> &str {
|
||||
text.trim_matches(|ch: char| ch.is_ascii_whitespace())
|
||||
}
|
||||
|
||||
fn source_integer_digits(text: &str, signed: bool) -> Result<&str, NumericError> {
|
||||
let digits = if signed {
|
||||
text.strip_prefix('-').unwrap_or(text)
|
||||
} else {
|
||||
text
|
||||
};
|
||||
if digits.len() > MAX_COMPONENT_DIGITS {
|
||||
return Err(NumericError::LiteralTooLong);
|
||||
}
|
||||
if digits.is_empty() || !digits.bytes().all(|byte| byte.is_ascii_digit()) {
|
||||
return Err(NumericError::InvalidLiteral);
|
||||
}
|
||||
Ok(digits)
|
||||
}
|
||||
|
||||
fn wire_digits(text: &str, signed: bool) -> Result<&str, NumericError> {
|
||||
let digits = source_integer_digits(text, signed).map_err(|error| match error {
|
||||
NumericError::LiteralTooLong => error,
|
||||
_ => NumericError::NonCanonicalWire,
|
||||
})?;
|
||||
if (digits.len() > 1 && digits.starts_with('0')) || (text.starts_with('-') && digits == "0") {
|
||||
return Err(NumericError::NonCanonicalWire);
|
||||
}
|
||||
Ok(digits)
|
||||
}
|
||||
|
||||
fn parse_integer(text: &str) -> Result<BigInt, NumericError> {
|
||||
BigInt::parse_bytes(text.as_bytes(), 10).ok_or(NumericError::InvalidLiteral)
|
||||
}
|
||||
|
||||
fn parse_exponent(text: &str) -> Result<i32, NumericError> {
|
||||
let (negative, digits) = if let Some(digits) = text.strip_prefix('-') {
|
||||
(true, digits)
|
||||
} else {
|
||||
(false, text.strip_prefix('+').unwrap_or(text))
|
||||
};
|
||||
if digits.is_empty() || !digits.bytes().all(|byte| byte.is_ascii_digit()) {
|
||||
return Err(NumericError::InvalidLiteral);
|
||||
}
|
||||
let mut value = 0_i32;
|
||||
for digit in digits.bytes() {
|
||||
value = value * 10 + i32::from(digit - b'0');
|
||||
if value > MAX_DECIMAL_EXPONENT {
|
||||
return Err(NumericError::DecimalExponent);
|
||||
}
|
||||
}
|
||||
Ok(if negative { -value } else { value })
|
||||
}
|
||||
|
||||
fn decimal_bits(digits: usize) -> u64 {
|
||||
// 3.322 exceeds log2(10); dimensions are bounded before this conversion.
|
||||
(digits as u64 * 3322).div_ceil(1000).max(1)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
[
|
||||
{
|
||||
"canonical_source": "1000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000",
|
||||
"expression": {
|
||||
"Number": {
|
||||
"denominator": "1",
|
||||
"numerator": "1000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"
|
||||
}
|
||||
},
|
||||
"name": "ten_to_300"
|
||||
},
|
||||
{
|
||||
"canonical_source": "ratio(1, 522194440706576253345876355358312191289982124523691890192116741641976953985778728424413405967498779170445053357219631418993786719092896803631618043925682638972978488271854999170180795067191859157214035005927973113188159419698856372836167342172293308748403954352901852035642024370059304557233988891799014503343469488440893892973452815095130470299789726716411734651513348221529512507986199933857107770846917779942645743159118957217248367043905936319748237550094520674504208530837546834166925275516486044134775384991808184705966507606898412918594045916828375610659246423184062775112999150206172392431297837246097308511903252956622805412865917690043804311051417135098849101156584508839003337597742539960818209685142687562392007453579567729991395256699805775897135553415567045292136442139895777424891477161767258532611634530697452993846501061481697843891439474220308003706472837459911525285821188577408160690315522951458068463354171428220365223949985950890732881736611925133626529949897998045399734600887312408859224933727829625089164535236559716582775403784110923285873186648442456409760158728501220463308455437074192539205964902261490928669488824051563042951500651206733594863336608245755565801460390869016718045121902354170201577095168)",
|
||||
"expression": {
|
||||
"Number": {
|
||||
"denominator": "522194440706576253345876355358312191289982124523691890192116741641976953985778728424413405967498779170445053357219631418993786719092896803631618043925682638972978488271854999170180795067191859157214035005927973113188159419698856372836167342172293308748403954352901852035642024370059304557233988891799014503343469488440893892973452815095130470299789726716411734651513348221529512507986199933857107770846917779942645743159118957217248367043905936319748237550094520674504208530837546834166925275516486044134775384991808184705966507606898412918594045916828375610659246423184062775112999150206172392431297837246097308511903252956622805412865917690043804311051417135098849101156584508839003337597742539960818209685142687562392007453579567729991395256699805775897135553415567045292136442139895777424891477161767258532611634530697452993846501061481697843891439474220308003706472837459911525285821188577408160690315522951458068463354171428220365223949985950890732881736611925133626529949897998045399734600887312408859224933727829625089164535236559716582775403784110923285873186648442456409760158728501220463308455437074192539205964902261490928669488824051563042951500651206733594863336608245755565801460390869016718045121902354170201577095168",
|
||||
"numerator": "1"
|
||||
}
|
||||
},
|
||||
"name": "one_over_two_to_4095"
|
||||
},
|
||||
{
|
||||
"canonical_source": "ratio(-1044388881413152506691752710716624382579964249047383780384233483283953907971557456848826811934997558340890106714439262837987573438185793607263236087851365277945956976543709998340361590134383718314428070011855946226376318839397712745672334684344586617496807908705803704071284048740118609114467977783598029006686938976881787785946905630190260940599579453432823469303026696443059025015972399867714215541693835559885291486318237914434496734087811872639496475100189041349008417061675093668333850551032972088269550769983616369411933015213796825837188091833656751221318492846368125550225998300412344784862595674492194617023806505913245610825731835380087608622102834270197698202313169017678006675195485079921636419370285375124784014907159135459982790513399611551794271106831134090584272884279791554849782954323534517065223269061394905987693002122963395687782878948440616007412945674919823050571642377154816321380631045902916136926708342856440730447899971901781465763473223850267253059899795996090799469201774624817718449867455659250178329070473119433165550807568221846571746373296884912819520317457002440926616910874148385078411929804522981857338977648103126085903001302413467189726673216491511131602920781738033436090243804708340403154190335, 1)",
|
||||
"expression": {
|
||||
"Number": {
|
||||
"denominator": "1",
|
||||
"numerator": "-1044388881413152506691752710716624382579964249047383780384233483283953907971557456848826811934997558340890106714439262837987573438185793607263236087851365277945956976543709998340361590134383718314428070011855946226376318839397712745672334684344586617496807908705803704071284048740118609114467977783598029006686938976881787785946905630190260940599579453432823469303026696443059025015972399867714215541693835559885291486318237914434496734087811872639496475100189041349008417061675093668333850551032972088269550769983616369411933015213796825837188091833656751221318492846368125550225998300412344784862595674492194617023806505913245610825731835380087608622102834270197698202313169017678006675195485079921636419370285375124784014907159135459982790513399611551794271106831134090584272884279791554849782954323534517065223269061394905987693002122963395687782878948440616007412945674919823050571642377154816321380631045902916136926708342856440730447899971901781465763473223850267253059899795996090799469201774624817718449867455659250178329070473119433165550807568221846571746373296884912819520317457002440926616910874148385078411929804522981857338977648103126085903001302413467189726673216491511131602920781738033436090243804708340403154190335"
|
||||
}
|
||||
},
|
||||
"name": "negative_4096_bit_bound"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,240 @@
|
||||
use super::{NumericError, NumericLimit};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
|
||||
/// Every event is charged once per dynamic occurrence, before entering it.
|
||||
///
|
||||
/// A Call expression therefore charges Expression and CalculationEntry; a
|
||||
/// Property read charges Expression and PropertyBinding. List element visits
|
||||
/// are additional to evaluation of their expressions. Only executed branches
|
||||
/// are charged. Targets are sorted by category (properties, then checks), then
|
||||
/// stable UUID bytes, by the integrating evaluator.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum EvaluationStep {
|
||||
Target,
|
||||
Expression,
|
||||
CalculationEntry,
|
||||
PropertyBinding,
|
||||
ListElement,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum NumericOperation {
|
||||
ParseWire,
|
||||
ParseLiteral,
|
||||
Add,
|
||||
Subtract,
|
||||
Multiply,
|
||||
Divide,
|
||||
Compare,
|
||||
Negate,
|
||||
Absolute,
|
||||
Reciprocal,
|
||||
Power,
|
||||
KindAssertion,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum WorkResource {
|
||||
DynamicEvents,
|
||||
NumericUnits,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum WorkSite {
|
||||
Evaluation(EvaluationStep),
|
||||
Numeric(NumericOperation),
|
||||
}
|
||||
|
||||
/// Failed reservations consume no partial charge. The first failure is latched.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Error, Serialize, Deserialize)]
|
||||
#[error("{resource:?}: used {used}, requested {requested}, limit {limit}, at {site:?}")]
|
||||
pub struct WorkExhaustion {
|
||||
pub resource: WorkResource,
|
||||
pub used: u64,
|
||||
pub requested: u64,
|
||||
pub limit: u64,
|
||||
pub site: WorkSite,
|
||||
}
|
||||
|
||||
/// Provisional calibration ceilings, not measured execution-time guarantees.
|
||||
///
|
||||
/// Effective limits, profile identity, capture, targets and context must all be
|
||||
/// recorded in request/result/frozen identity. A caller may lower these ceilings.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(try_from = "WorkLimitsWire", into = "WorkLimitsWire")]
|
||||
pub struct WorkLimits {
|
||||
dynamic_events: u64,
|
||||
numeric_units: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct WorkLimitsWire {
|
||||
dynamic_events: u64,
|
||||
numeric_units: u64,
|
||||
}
|
||||
|
||||
impl WorkLimits {
|
||||
pub const REFERENCE: Self = Self {
|
||||
dynamic_events: 1_000_000,
|
||||
numeric_units: 50_000_000,
|
||||
};
|
||||
|
||||
pub fn new(dynamic_events: u64, numeric_units: u64) -> Result<Self, NumericError> {
|
||||
if dynamic_events > Self::REFERENCE.dynamic_events
|
||||
|| numeric_units > Self::REFERENCE.numeric_units
|
||||
{
|
||||
return Err(NumericError::InvalidWorkLimits);
|
||||
}
|
||||
Ok(Self {
|
||||
dynamic_events,
|
||||
numeric_units,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn dynamic_events(self) -> u64 {
|
||||
self.dynamic_events
|
||||
}
|
||||
|
||||
pub fn numeric_units(self) -> u64 {
|
||||
self.numeric_units
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<WorkLimitsWire> for WorkLimits {
|
||||
type Error = NumericError;
|
||||
|
||||
fn try_from(value: WorkLimitsWire) -> Result<Self, Self::Error> {
|
||||
Self::new(value.dynamic_events, value.numeric_units)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<WorkLimits> for WorkLimitsWire {
|
||||
fn from(value: WorkLimits) -> Self {
|
||||
Self {
|
||||
dynamic_events: value.dynamic_events,
|
||||
numeric_units: value.numeric_units,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct WorkUsage {
|
||||
pub dynamic_events: u64,
|
||||
pub numeric_units: u64,
|
||||
}
|
||||
|
||||
/// Intentionally neither Clone nor Default: pass the same mutable meter through
|
||||
/// every target, numeric operation and expanded call in one evaluation request.
|
||||
///
|
||||
/// Exhaustion is terminal. Integrators must stop traversing siblings/targets and
|
||||
/// mark already collected causes as incomplete. Replaying an aggregate cache
|
||||
/// cost is insufficient if it changes the exact exhaustion location; initially
|
||||
/// retain logical traversal even when numerical values come from a cache.
|
||||
#[derive(Debug)]
|
||||
pub struct WorkMeter {
|
||||
limits: WorkLimits,
|
||||
usage: WorkUsage,
|
||||
exhausted: Option<WorkExhaustion>,
|
||||
}
|
||||
|
||||
impl WorkMeter {
|
||||
pub fn new(limits: WorkLimits) -> Self {
|
||||
Self {
|
||||
limits,
|
||||
usage: WorkUsage::default(),
|
||||
exhausted: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn limits(&self) -> WorkLimits {
|
||||
self.limits
|
||||
}
|
||||
|
||||
pub fn usage(&self) -> WorkUsage {
|
||||
self.usage
|
||||
}
|
||||
|
||||
pub fn exhaustion(&self) -> Option<&WorkExhaustion> {
|
||||
self.exhausted.as_ref()
|
||||
}
|
||||
|
||||
pub fn ensure_active(&self) -> Result<(), NumericError> {
|
||||
match &self.exhausted {
|
||||
Some(failure) => Err(NumericError::Limit(NumericLimit::Work(failure.clone()))),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn visit(&mut self, step: EvaluationStep) -> Result<(), NumericError> {
|
||||
self.reserve(WorkResource::DynamicEvents, 1, WorkSite::Evaluation(step))
|
||||
}
|
||||
|
||||
pub(super) fn numeric(
|
||||
&mut self,
|
||||
operation: NumericOperation,
|
||||
units: u64,
|
||||
) -> Result<(), NumericError> {
|
||||
self.reserve(
|
||||
WorkResource::NumericUnits,
|
||||
units,
|
||||
WorkSite::Numeric(operation),
|
||||
)
|
||||
}
|
||||
|
||||
fn reserve(
|
||||
&mut self,
|
||||
resource: WorkResource,
|
||||
requested: u64,
|
||||
site: WorkSite,
|
||||
) -> Result<(), NumericError> {
|
||||
self.ensure_active()?;
|
||||
let (used, limit) = match resource {
|
||||
WorkResource::DynamicEvents => (self.usage.dynamic_events, self.limits.dynamic_events),
|
||||
WorkResource::NumericUnits => (self.usage.numeric_units, self.limits.numeric_units),
|
||||
};
|
||||
// Subtraction is safe: only successful reservations update usage.
|
||||
if requested > limit - used {
|
||||
let failure = WorkExhaustion {
|
||||
resource,
|
||||
used,
|
||||
requested,
|
||||
limit,
|
||||
site,
|
||||
};
|
||||
self.exhausted = Some(failure.clone());
|
||||
return Err(NumericError::Limit(NumericLimit::Work(failure)));
|
||||
}
|
||||
match resource {
|
||||
WorkResource::DynamicEvents => self.usage.dynamic_events += requested,
|
||||
WorkResource::NumericUnits => self.usage.numeric_units += requested,
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// Logical 64-bit words are platform independent. These are deterministic
|
||||
// accounting weights, not a claim about num-bigint's internal algorithm/timing.
|
||||
pub(super) fn words(bits: u64) -> u64 {
|
||||
bits.max(1).div_ceil(64)
|
||||
}
|
||||
|
||||
pub(super) fn product_cost(a_bits: u64, b_bits: u64) -> u64 {
|
||||
1 + words(a_bits) * words(b_bits)
|
||||
}
|
||||
|
||||
pub(super) fn sum_cost(a_bits: u64, b_bits: u64) -> u64 {
|
||||
1 + words(a_bits.max(b_bits))
|
||||
}
|
||||
|
||||
pub(super) fn normalization_cost(n_bits: u64, d_bits: u64) -> u64 {
|
||||
let width = words(n_bits.max(d_bits));
|
||||
// 64*w² for GCD plus 2*w² for the two quotient operations.
|
||||
1 + 66 * width * width
|
||||
}
|
||||
|
||||
pub(super) fn parse_cost(digits: usize, estimated_bits: u64) -> u64 {
|
||||
1 + digits as u64 * words(estimated_bits)
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
use super::{Dimension, Exact, MAX_INTEGER_POWER, NumericError, NumericOperation, WorkMeter};
|
||||
use std::cmp::Ordering;
|
||||
|
||||
/// K is supplied by the common resolver's verified nominal compatibility
|
||||
/// relation. It is not inferred from dimension/name/symbol/archive bytes here.
|
||||
/// Coordinate basis/algebra/difference-kind closure must be part of that proof.
|
||||
/// Exact definition references and pins remain the caller's provenance.
|
||||
/// K must be a bounded, cheap-to-clone/compare verified handle. Constructing or
|
||||
/// comparing an unbounded rooted contract graph is resolver admission work and
|
||||
/// must not be hidden inside K's Clone/Eq implementations. Rust trait bounds do
|
||||
/// not enforce this resource contract.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ResolvedKind<K> {
|
||||
key: K,
|
||||
dimension: Dimension,
|
||||
difference: Option<K>,
|
||||
}
|
||||
|
||||
impl<K> ResolvedKind<K> {
|
||||
pub fn linear(key: K, dimension: Dimension) -> Self {
|
||||
Self {
|
||||
key,
|
||||
dimension,
|
||||
difference: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn key(&self) -> &K {
|
||||
&self.key
|
||||
}
|
||||
|
||||
pub fn dimension(&self) -> Dimension {
|
||||
self.dimension
|
||||
}
|
||||
|
||||
pub fn is_affine(&self) -> bool {
|
||||
self.difference.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
impl<K: Clone + Eq> ResolvedKind<K> {
|
||||
pub fn affine(key: K, difference: &Self) -> Result<Self, NumericError> {
|
||||
if difference.is_affine() || key == difference.key {
|
||||
return Err(NumericError::DifferenceKind);
|
||||
}
|
||||
Ok(Self {
|
||||
key,
|
||||
dimension: difference.dimension,
|
||||
difference: Some(difference.key.clone()),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn difference_kind(&self) -> Option<Self> {
|
||||
self.difference
|
||||
.as_ref()
|
||||
.map(|key| Self::linear(key.clone(), self.dimension))
|
||||
}
|
||||
}
|
||||
|
||||
/// Canonical coordinate = magnitude * scale + offset.
|
||||
/// Unit symbols and display preferences are outside this numeric representation.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Unit<K> {
|
||||
kind: ResolvedKind<K>,
|
||||
scale: Exact,
|
||||
offset: Exact,
|
||||
}
|
||||
|
||||
impl<K: Clone + Eq> Unit<K> {
|
||||
pub fn new(kind: ResolvedKind<K>, scale: Exact, offset: Exact) -> Result<Self, NumericError> {
|
||||
if !scale.is_positive() {
|
||||
return Err(NumericError::UnitScale);
|
||||
}
|
||||
if !kind.is_affine() && !offset.is_zero() {
|
||||
return Err(NumericError::LinearOffset);
|
||||
}
|
||||
Ok(Self {
|
||||
kind,
|
||||
scale,
|
||||
offset,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn kind(&self) -> &ResolvedKind<K> {
|
||||
&self.kind
|
||||
}
|
||||
|
||||
pub fn scale(&self) -> &Exact {
|
||||
&self.scale
|
||||
}
|
||||
|
||||
pub fn offset(&self) -> &Exact {
|
||||
&self.offset
|
||||
}
|
||||
|
||||
/// Fixed multiply-then-add path. Each reduced arithmetic stage is bounded.
|
||||
/// A mathematically cancelling wider intermediate still reports NumericLimit;
|
||||
/// replacing this path with a fused operation requires a new work profile.
|
||||
pub fn to_canonical(
|
||||
&self,
|
||||
magnitude: &Exact,
|
||||
meter: &mut WorkMeter,
|
||||
) -> Result<Exact, NumericError> {
|
||||
magnitude
|
||||
.multiply(&self.scale, meter)?
|
||||
.add(&self.offset, meter)
|
||||
}
|
||||
|
||||
/// Fixed subtract-then-divide path; never rounds a coordinate.
|
||||
pub fn from_canonical(
|
||||
&self,
|
||||
coordinate: &Exact,
|
||||
meter: &mut WorkMeter,
|
||||
) -> Result<Exact, NumericError> {
|
||||
coordinate
|
||||
.subtract(&self.offset, meter)?
|
||||
.divide(&self.scale, meter)
|
||||
}
|
||||
|
||||
pub fn convert(
|
||||
&self,
|
||||
magnitude: &Exact,
|
||||
target: &Self,
|
||||
meter: &mut WorkMeter,
|
||||
) -> Result<Exact, NumericError> {
|
||||
meter.ensure_active()?;
|
||||
if self.kind != target.kind {
|
||||
return Err(NumericError::IncompatibleTypes);
|
||||
}
|
||||
target.from_canonical(&self.to_canonical(magnitude, meter)?, meter)
|
||||
}
|
||||
}
|
||||
|
||||
/// Internal numerical types. Boolean/list/check semantics belong to the common
|
||||
/// expression evaluator. Anonymous zero dimension normalizes to Number.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum NumericType<K> {
|
||||
Number,
|
||||
Named(ResolvedKind<K>),
|
||||
Anonymous(Dimension),
|
||||
}
|
||||
|
||||
impl<K> NumericType<K> {
|
||||
pub fn dimension(&self) -> Dimension {
|
||||
match self {
|
||||
Self::Number => Dimension::ZERO,
|
||||
Self::Named(kind) => kind.dimension,
|
||||
Self::Anonymous(dimension) => *dimension,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_affine(&self) -> bool {
|
||||
matches!(self, Self::Named(kind) if kind.is_affine())
|
||||
}
|
||||
|
||||
fn anonymous(dimension: Dimension) -> Self {
|
||||
if dimension.is_dimensionless() {
|
||||
Self::Number
|
||||
} else {
|
||||
Self::Anonymous(dimension)
|
||||
}
|
||||
}
|
||||
|
||||
fn normalized(self) -> Self {
|
||||
match self {
|
||||
Self::Anonymous(dimension) => Self::anonymous(dimension),
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A canonical rational coordinate with an internal numerical type.
|
||||
///
|
||||
/// Derived Eq compares stored representation. Formal equality/ordering must call
|
||||
/// compare(), which rejects mismatched nominal kinds instead of treating them
|
||||
/// as a false predicate. Public property output must still check its declared
|
||||
/// type and anchor the result to that declaration's exact definition reference.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct NumericValue<K> {
|
||||
value_type: NumericType<K>,
|
||||
coordinate: Exact,
|
||||
}
|
||||
|
||||
impl<K: Clone + Eq> NumericValue<K> {
|
||||
pub fn new(value_type: NumericType<K>, coordinate: Exact) -> Self {
|
||||
Self {
|
||||
value_type: value_type.normalized(),
|
||||
coordinate,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn number(coordinate: Exact) -> Self {
|
||||
Self::new(NumericType::Number, coordinate)
|
||||
}
|
||||
|
||||
pub fn from_unit(
|
||||
magnitude: &Exact,
|
||||
unit: &Unit<K>,
|
||||
meter: &mut WorkMeter,
|
||||
) -> Result<Self, NumericError> {
|
||||
meter.ensure_active()?;
|
||||
Ok(Self::new(
|
||||
NumericType::Named(unit.kind.clone()),
|
||||
unit.to_canonical(magnitude, meter)?,
|
||||
))
|
||||
}
|
||||
|
||||
pub fn value_type(&self) -> &NumericType<K> {
|
||||
&self.value_type
|
||||
}
|
||||
|
||||
pub fn coordinate(&self) -> &Exact {
|
||||
&self.coordinate
|
||||
}
|
||||
|
||||
pub fn display_in(&self, unit: &Unit<K>, meter: &mut WorkMeter) -> Result<Exact, NumericError> {
|
||||
meter.ensure_active()?;
|
||||
if !matches!(&self.value_type, NumericType::Named(kind) if kind == &unit.kind) {
|
||||
return Err(NumericError::IncompatibleTypes);
|
||||
}
|
||||
unit.from_canonical(&self.coordinate, meter)
|
||||
}
|
||||
|
||||
pub fn add(&self, rhs: &Self, meter: &mut WorkMeter) -> Result<Self, NumericError> {
|
||||
meter.ensure_active()?;
|
||||
let output = self.additive_type(rhs, false)?;
|
||||
Ok(Self::new(
|
||||
output,
|
||||
self.coordinate.add(&rhs.coordinate, meter)?,
|
||||
))
|
||||
}
|
||||
|
||||
pub fn subtract(&self, rhs: &Self, meter: &mut WorkMeter) -> Result<Self, NumericError> {
|
||||
meter.ensure_active()?;
|
||||
let output = self.additive_type(rhs, true)?;
|
||||
Ok(Self::new(
|
||||
output,
|
||||
self.coordinate.subtract(&rhs.coordinate, meter)?,
|
||||
))
|
||||
}
|
||||
|
||||
fn additive_type(&self, rhs: &Self, subtract: bool) -> Result<NumericType<K>, NumericError> {
|
||||
use NumericType::{Anonymous, Named, Number};
|
||||
match (&self.value_type, &rhs.value_type) {
|
||||
(Number, Number) => Ok(Number),
|
||||
(Anonymous(left), Anonymous(right)) if left == right => Ok(Anonymous(*left)),
|
||||
(Named(left), Named(right)) => match (&left.difference, &right.difference) {
|
||||
(None, None) if left == right => Ok(Named(left.clone())),
|
||||
(Some(difference), Some(_)) if subtract && left == right => Ok(Named(
|
||||
ResolvedKind::linear(difference.clone(), left.dimension),
|
||||
)),
|
||||
(Some(difference), None)
|
||||
if difference == &right.key && left.dimension == right.dimension =>
|
||||
{
|
||||
Ok(Named(left.clone()))
|
||||
}
|
||||
(None, Some(difference))
|
||||
if !subtract
|
||||
&& difference == &left.key
|
||||
&& left.dimension == right.dimension =>
|
||||
{
|
||||
Ok(Named(right.clone()))
|
||||
}
|
||||
_ => Err(NumericError::IncompatibleTypes),
|
||||
},
|
||||
_ => Err(NumericError::IncompatibleTypes),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn multiply(&self, rhs: &Self, meter: &mut WorkMeter) -> Result<Self, NumericError> {
|
||||
meter.ensure_active()?;
|
||||
let output = self.multiplicative_type(rhs, false)?;
|
||||
Ok(Self::new(
|
||||
output,
|
||||
self.coordinate.multiply(&rhs.coordinate, meter)?,
|
||||
))
|
||||
}
|
||||
|
||||
pub fn divide(&self, rhs: &Self, meter: &mut WorkMeter) -> Result<Self, NumericError> {
|
||||
meter.ensure_active()?;
|
||||
let output = self.multiplicative_type(rhs, true)?;
|
||||
Ok(Self::new(
|
||||
output,
|
||||
self.coordinate.divide(&rhs.coordinate, meter)?,
|
||||
))
|
||||
}
|
||||
|
||||
fn multiplicative_type(
|
||||
&self,
|
||||
rhs: &Self,
|
||||
divide: bool,
|
||||
) -> Result<NumericType<K>, NumericError> {
|
||||
use NumericType::Number;
|
||||
if self.value_type.is_affine() || rhs.value_type.is_affine() {
|
||||
return Err(NumericError::AffineOperation);
|
||||
}
|
||||
match (&self.value_type, &rhs.value_type) {
|
||||
(Number, Number) => Ok(Number),
|
||||
(_, Number) => Ok(self.value_type.clone()),
|
||||
(Number, _) if !divide => Ok(rhs.value_type.clone()),
|
||||
_ => {
|
||||
let left = self.value_type.dimension();
|
||||
let right = rhs.value_type.dimension();
|
||||
let dimension = if divide {
|
||||
left.checked_quotient(right)?
|
||||
} else {
|
||||
left.checked_product(right)?
|
||||
};
|
||||
Ok(NumericType::anonymous(dimension))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn negated(&self, meter: &mut WorkMeter) -> Result<Self, NumericError> {
|
||||
meter.ensure_active()?;
|
||||
if self.value_type.is_affine() {
|
||||
return Err(NumericError::AffineOperation);
|
||||
}
|
||||
Ok(Self::new(
|
||||
self.value_type.clone(),
|
||||
self.coordinate.negated(meter)?,
|
||||
))
|
||||
}
|
||||
|
||||
pub fn absolute(&self, meter: &mut WorkMeter) -> Result<Self, NumericError> {
|
||||
meter.ensure_active()?;
|
||||
if self.value_type.is_affine() {
|
||||
return Err(NumericError::AffineOperation);
|
||||
}
|
||||
Ok(Self::new(
|
||||
self.value_type.clone(),
|
||||
self.coordinate.absolute(meter)?,
|
||||
))
|
||||
}
|
||||
|
||||
pub fn power(&self, exponent: i8, meter: &mut WorkMeter) -> Result<Self, NumericError> {
|
||||
meter.ensure_active()?;
|
||||
if !(-MAX_INTEGER_POWER..=MAX_INTEGER_POWER).contains(&exponent) {
|
||||
return Err(NumericError::IntegerPower);
|
||||
}
|
||||
if self.value_type.is_affine() {
|
||||
return Err(NumericError::AffineOperation);
|
||||
}
|
||||
let output = match exponent {
|
||||
0 => NumericType::Number,
|
||||
1 => self.value_type.clone(),
|
||||
_ => NumericType::anonymous(self.value_type.dimension().checked_power(exponent)?),
|
||||
};
|
||||
Ok(Self::new(output, self.coordinate.power(exponent, meter)?))
|
||||
}
|
||||
|
||||
pub fn compare(&self, rhs: &Self, meter: &mut WorkMeter) -> Result<Ordering, NumericError> {
|
||||
meter.ensure_active()?;
|
||||
if self.value_type != rhs.value_type {
|
||||
return Err(NumericError::IncompatibleTypes);
|
||||
}
|
||||
self.coordinate.compare(&rhs.coordinate, meter)
|
||||
}
|
||||
|
||||
pub fn as_kind(
|
||||
&self,
|
||||
target: &ResolvedKind<K>,
|
||||
meter: &mut WorkMeter,
|
||||
) -> Result<Self, NumericError> {
|
||||
meter.ensure_active()?;
|
||||
if target.is_affine()
|
||||
|| matches!(self.value_type, NumericType::Named(_))
|
||||
|| self.value_type.dimension() != target.dimension
|
||||
{
|
||||
return Err(NumericError::KindAssertion);
|
||||
}
|
||||
meter.numeric(NumericOperation::KindAssertion, self.coordinate.copy_cost())?;
|
||||
Ok(Self::new(
|
||||
NumericType::Named(target.clone()),
|
||||
self.coordinate.clone(),
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,804 @@
|
||||
use super::*;
|
||||
use num_bigint::BigInt;
|
||||
use num_traits::One;
|
||||
use std::cmp::Ordering;
|
||||
|
||||
fn meter() -> WorkMeter {
|
||||
WorkMeter::new(WorkLimits::REFERENCE)
|
||||
}
|
||||
|
||||
fn exact(n: impl ToString, d: impl ToString) -> Exact {
|
||||
Exact::from_wire(
|
||||
&ExactWire {
|
||||
numerator: n.to_string(),
|
||||
denominator: d.to_string(),
|
||||
},
|
||||
&mut meter(),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn source(text: &str) -> Exact {
|
||||
Exact::parse_literal(text, &mut meter()).unwrap()
|
||||
}
|
||||
|
||||
fn assert_fraction(value: &Exact, n: impl ToString, d: impl ToString) {
|
||||
assert_eq!(
|
||||
value.to_wire(),
|
||||
ExactWire {
|
||||
numerator: n.to_string(),
|
||||
denominator: d.to_string(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Independent bounded i128 oracle. It does not call the production Exact/GCD
|
||||
// implementation when deriving expected arithmetic or ordering.
|
||||
fn small_fraction(mut n: i128, mut d: i128) -> (i128, i128) {
|
||||
assert_ne!(d, 0);
|
||||
if d < 0 {
|
||||
n = -n;
|
||||
d = -d;
|
||||
}
|
||||
let (mut a, mut b) = (n.abs(), d);
|
||||
while b != 0 {
|
||||
(a, b) = (b, a % b);
|
||||
}
|
||||
(n / a, d / a)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arithmetic_matches_independent_small_integer_oracle() {
|
||||
for a in [-5_i128, -2, 0, 1, 4] {
|
||||
for b in [1_i128, 2, 3, 7] {
|
||||
for c in [-5_i128, -2, 0, 1, 4] {
|
||||
for d in [1_i128, 2, 3, 7] {
|
||||
let (an, ad) = small_fraction(a, b);
|
||||
let (bn, bd) = small_fraction(c, d);
|
||||
let left = exact(an, ad);
|
||||
let right = exact(bn, bd);
|
||||
let mut work = meter();
|
||||
let (n, den) = small_fraction(a * d + c * b, b * d);
|
||||
assert_fraction(&left.add(&right, &mut work).unwrap(), n, den);
|
||||
let (n, den) = small_fraction(a * d - c * b, b * d);
|
||||
assert_fraction(&left.subtract(&right, &mut work).unwrap(), n, den);
|
||||
let (n, den) = small_fraction(a * c, b * d);
|
||||
assert_fraction(&left.multiply(&right, &mut work).unwrap(), n, den);
|
||||
if c != 0 {
|
||||
let (n, den) = small_fraction(a * d, b * c);
|
||||
assert_fraction(&left.divide(&right, &mut work).unwrap(), n, den);
|
||||
} else {
|
||||
assert_eq!(
|
||||
left.divide(&right, &mut work),
|
||||
Err(NumericError::DivisionByZero)
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
left.compare(&right, &mut work).unwrap(),
|
||||
(a * d).cmp(&(c * b))
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decimals_are_exact_and_source_ratios_reduce() {
|
||||
assert_fraction(
|
||||
&source("0.1").add(&source("0.2"), &mut meter()).unwrap(),
|
||||
3,
|
||||
10,
|
||||
);
|
||||
assert_fraction(&source("0.50"), 1, 2);
|
||||
assert_fraction(&source("12.50e-2"), 1, 8);
|
||||
assert_fraction(&source("1e+3"), 1000, 1);
|
||||
assert_fraction(&source("0001.2500"), 5, 4);
|
||||
assert_fraction(&source(" ratio( -6, 8 ) "), -3, 4);
|
||||
assert_fraction(&source("ratio(-0, 27)"), 0, 1);
|
||||
assert_eq!(source("0.5").canonical_literal(), "ratio(1, 2)");
|
||||
assert_eq!(source("42").canonical_literal(), "42");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn powers_and_signs_have_explicit_domains() {
|
||||
let x = exact(-2, 3);
|
||||
assert_fraction(&x.power(3, &mut meter()).unwrap(), -8, 27);
|
||||
assert_fraction(&x.power(-3, &mut meter()).unwrap(), -27, 8);
|
||||
assert_fraction(&x.power(0, &mut meter()).unwrap(), 1, 1);
|
||||
assert_fraction(&x.absolute(&mut meter()).unwrap(), 2, 3);
|
||||
assert_fraction(&x.negated(&mut meter()).unwrap(), 2, 3);
|
||||
assert_fraction(&x.reciprocal(&mut meter()).unwrap(), -3, 2);
|
||||
let zero = Exact::from_integer(0);
|
||||
assert_eq!(
|
||||
zero.power(0, &mut meter()),
|
||||
Err(NumericError::ZeroPowerZero)
|
||||
);
|
||||
assert_eq!(
|
||||
zero.power(-1, &mut meter()),
|
||||
Err(NumericError::DivisionByZero)
|
||||
);
|
||||
assert_eq!(x.power(17, &mut meter()), Err(NumericError::IntegerPower));
|
||||
assert_eq!(
|
||||
x.power(i8::MIN, &mut meter()),
|
||||
Err(NumericError::IntegerPower)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonical_wire_rejects_both_component_noncanonical_forms() {
|
||||
for (n, d) in [
|
||||
("+1", "1"),
|
||||
("01", "1"),
|
||||
("-0", "1"),
|
||||
("1", "+1"),
|
||||
("1", "-1"),
|
||||
("1", "01"),
|
||||
("2", "4"),
|
||||
("0", "3"),
|
||||
("", "1"),
|
||||
("1", ""),
|
||||
("1.0", "1"),
|
||||
("1", "1.0"),
|
||||
("1e1", "1"),
|
||||
("1", "1e1"),
|
||||
(" 1", "1"),
|
||||
("1", "1 "),
|
||||
] {
|
||||
assert!(
|
||||
Exact::from_wire(
|
||||
&ExactWire {
|
||||
numerator: n.into(),
|
||||
denominator: d.into()
|
||||
},
|
||||
&mut meter(),
|
||||
)
|
||||
.is_err(),
|
||||
"accepted {n}/{d}"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
Exact::from_wire(
|
||||
&ExactWire {
|
||||
numerator: "1".into(),
|
||||
denominator: "0".into()
|
||||
},
|
||||
&mut meter()
|
||||
),
|
||||
Err(NumericError::ZeroDenominator)
|
||||
);
|
||||
assert_fraction(&exact(0, 1), 0, 1);
|
||||
assert_fraction(&exact(-1, 2), -1, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serde_uses_closed_canonical_string_wire() {
|
||||
let value = exact(-3, 7);
|
||||
let encoded = serde_json::to_value(&value).unwrap();
|
||||
assert_eq!(
|
||||
encoded,
|
||||
serde_json::json!({"numerator":"-3","denominator":"7"})
|
||||
);
|
||||
assert_eq!(serde_json::from_value::<Exact>(encoded).unwrap(), value);
|
||||
for invalid in [
|
||||
r#"{"numerator":1,"denominator":"2"}"#,
|
||||
r#"{"numerator":"1","denominator":2}"#,
|
||||
r#"{"numerator":"1","denominator":"2","other":0}"#,
|
||||
r#"{"numerator":"1","numerator":"2","denominator":"3"}"#,
|
||||
r#"{"numerator":"1"}"#,
|
||||
r#"{"numerator":"2","denominator":"4"}"#,
|
||||
] {
|
||||
assert!(serde_json::from_str::<Exact>(invalid).is_err(), "{invalid}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn literal_reader_does_not_fold_expression_unary_minus() {
|
||||
assert_eq!(
|
||||
Exact::parse_literal("-2", &mut meter()),
|
||||
Err(NumericError::InvalidLiteral)
|
||||
);
|
||||
let negative_literal = source("ratio(-2, 1)");
|
||||
assert_eq!(negative_literal.canonical_literal(), "ratio(-2, 1)");
|
||||
assert_eq!(source("2").negated(&mut meter()).unwrap(), negative_literal);
|
||||
// Equality of coordinates does not claim AST equality. The expression
|
||||
// parser must preserve UnaryNegate independently of its operand literal.
|
||||
assert_eq!(source("2").canonical_literal(), "2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn literal_syntax_and_decimal_exponent_are_bounded() {
|
||||
for invalid in [
|
||||
"",
|
||||
"-",
|
||||
"+1",
|
||||
".5",
|
||||
"1.",
|
||||
"1.2.3",
|
||||
"1e",
|
||||
"1ee2",
|
||||
"1e+-2",
|
||||
"1_000",
|
||||
"NaN",
|
||||
"Infinity",
|
||||
"1/2",
|
||||
"ratio(1)",
|
||||
"ratio(1,2,3)",
|
||||
"ratio(1,-2)",
|
||||
"ratio(+1,2)",
|
||||
"ratio(1,2) + 1",
|
||||
"12",
|
||||
] {
|
||||
assert!(
|
||||
Exact::parse_literal(invalid, &mut meter()).is_err(),
|
||||
"{invalid}"
|
||||
);
|
||||
}
|
||||
for invalid in ["1e129", "1e-129", "1e9999999999999999999999"] {
|
||||
assert_eq!(
|
||||
Exact::parse_literal(invalid, &mut meter()),
|
||||
Err(NumericError::DecimalExponent)
|
||||
);
|
||||
}
|
||||
assert!(Exact::parse_literal("1e128", &mut meter()).is_ok());
|
||||
assert!(Exact::parse_literal("1e-128", &mut meter()).is_ok());
|
||||
assert_eq!(
|
||||
Exact::parse_literal(&"1".repeat(MAX_COMPONENT_DIGITS + 1), &mut meter()),
|
||||
Err(NumericError::LiteralTooLong)
|
||||
);
|
||||
assert_eq!(
|
||||
Exact::parse_literal(&" ".repeat(MAX_LITERAL_BYTES + 1), &mut meter()),
|
||||
Err(NumericError::LiteralTooLong)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_independent_r7_packets_round_trip_as_literals_and_wire() {
|
||||
let packets: serde_json::Value =
|
||||
serde_json::from_str(include_str!("fixtures/r7-literal-boundaries.json")).unwrap();
|
||||
for packet in packets.as_array().unwrap() {
|
||||
let wire: ExactWire =
|
||||
serde_json::from_value(packet["expression"]["Number"].clone()).unwrap();
|
||||
let expected = Exact::from_wire(&wire, &mut meter()).unwrap();
|
||||
let spelling = packet["canonical_source"].as_str().unwrap();
|
||||
assert_eq!(expected.canonical_literal(), spelling);
|
||||
assert_eq!(
|
||||
Exact::parse_literal(spelling, &mut meter()).unwrap(),
|
||||
expected
|
||||
);
|
||||
let serialized = serde_json::to_string(&expected).unwrap();
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Exact>(&serialized).unwrap(),
|
||||
expected
|
||||
);
|
||||
assert_eq!(expected.to_wire(), wire);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_8193_bit_sum_intermediate_can_reduce_to_admitted_value() {
|
||||
// N=(2^4096)-1, D=N-1. gcd(N,D)=1 and D is even.
|
||||
// N/D + N/D = N/(D/2), although its un-reduced numerator needs 8193 bits.
|
||||
let n = (BigInt::one() << 4096_usize) - 1_u8;
|
||||
let d = &n - 1_u8;
|
||||
let value = exact(&n, &d);
|
||||
let result = value.add(&value, &mut meter()).unwrap();
|
||||
assert_fraction(&result, &n, &d / 2_u8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cross_products_cancel_before_reduced_component_limit() {
|
||||
let n = BigInt::one() << 4095_usize;
|
||||
let d = &n - 1_u8;
|
||||
let left = exact(&n, &d);
|
||||
let right = exact(&d, &n);
|
||||
assert_fraction(&left.multiply(&right, &mut meter()).unwrap(), 1, 1);
|
||||
assert_fraction(
|
||||
&left
|
||||
.add(&left.negated(&mut meter()).unwrap(), &mut meter())
|
||||
.unwrap(),
|
||||
0,
|
||||
1,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn both_reduced_component_overflows_are_reported() {
|
||||
let n = BigInt::one() << 4095_usize;
|
||||
let mut work = meter();
|
||||
let failure = exact(&n, 1)
|
||||
.multiply(&Exact::from_integer(2), &mut work)
|
||||
.unwrap_err();
|
||||
assert!(matches!(
|
||||
failure,
|
||||
NumericError::Limit(NumericLimit::Bits {
|
||||
component: NumberComponent::Numerator,
|
||||
bits: 4097,
|
||||
..
|
||||
})
|
||||
));
|
||||
assert!(work.exhaustion().is_none());
|
||||
let failure = exact(1, &n)
|
||||
.divide(&Exact::from_integer(2), &mut work)
|
||||
.unwrap_err();
|
||||
assert!(matches!(
|
||||
failure,
|
||||
NumericError::Limit(NumericLimit::Bits {
|
||||
component: NumberComponent::Denominator,
|
||||
bits: 4097,
|
||||
..
|
||||
})
|
||||
));
|
||||
// Value overflow is a nonterminal supported-operation failure; fuel
|
||||
// exhaustion, in contrast, terminates the entire request.
|
||||
assert_fraction(&source("1").add(&source("1"), &mut work).unwrap(), 2, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wire_rejects_4097_bits_before_gcd_can_reduce_them() {
|
||||
let oversized = (BigInt::one() << 4096_usize).to_string();
|
||||
for wire in [
|
||||
ExactWire {
|
||||
numerator: oversized.clone(),
|
||||
denominator: "1".into(),
|
||||
},
|
||||
ExactWire {
|
||||
numerator: "1".into(),
|
||||
denominator: oversized.clone(),
|
||||
},
|
||||
ExactWire {
|
||||
numerator: oversized.clone(),
|
||||
denominator: oversized,
|
||||
},
|
||||
] {
|
||||
assert!(matches!(
|
||||
Exact::from_wire(&wire, &mut meter()),
|
||||
Err(NumericError::Limit(NumericLimit::Bits { stage, .. })) if stage == "wire"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numeric_precharge_precedes_big_integer_result_allocation() {
|
||||
let n = BigInt::one() << 4095_usize;
|
||||
let left = exact(&n, 1);
|
||||
let mut work = WorkMeter::new(WorkLimits::new(10, 0).unwrap());
|
||||
let failure = left
|
||||
.multiply(&Exact::from_integer(2), &mut work)
|
||||
.unwrap_err();
|
||||
assert!(failure.is_terminal_work_exhaustion());
|
||||
assert_eq!(work.usage().numeric_units, 0);
|
||||
assert!(matches!(
|
||||
work.exhaustion().unwrap().site,
|
||||
WorkSite::Numeric(NumericOperation::Multiply)
|
||||
));
|
||||
// With available fuel the same operation reaches the 4097-bit result check.
|
||||
assert!(matches!(
|
||||
left.multiply(&Exact::from_integer(2), &mut meter()),
|
||||
Err(NumericError::Limit(NumericLimit::Bits { .. }))
|
||||
));
|
||||
let too_wide_wire = ExactWire {
|
||||
numerator: (BigInt::one() << 4096_usize).to_string(),
|
||||
denominator: "1".into(),
|
||||
};
|
||||
let mut parse_work = WorkMeter::new(WorkLimits::new(0, 0).unwrap());
|
||||
assert!(
|
||||
Exact::from_wire(&too_wide_wire, &mut parse_work)
|
||||
.unwrap_err()
|
||||
.is_terminal_work_exhaustion()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provisional_limits_and_effective_limits_round_trip() {
|
||||
let limits = WorkLimits::new(500, 2000).unwrap();
|
||||
let encoded = serde_json::to_value(limits).unwrap();
|
||||
assert_eq!(
|
||||
encoded,
|
||||
serde_json::json!({"dynamic_events":500,"numeric_units":2000})
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_value::<WorkLimits>(encoded).unwrap(),
|
||||
limits
|
||||
);
|
||||
assert_eq!(
|
||||
WorkLimits::new(1_000_001, 0),
|
||||
Err(NumericError::InvalidWorkLimits)
|
||||
);
|
||||
assert_eq!(
|
||||
WorkLimits::new(0, 50_000_001),
|
||||
Err(NumericError::InvalidWorkLimits)
|
||||
);
|
||||
assert!(
|
||||
serde_json::from_str::<WorkLimits>(r#"{"dynamic_events":1000001,"numeric_units":0}"#)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_accounting_boundary_and_terminal_failure_are_stable() {
|
||||
// One-word addition: three products at 2, one sum at 2,
|
||||
// normalization at 67 => 75 logical numeric units.
|
||||
let mut work = WorkMeter::new(WorkLimits::new(4, 75).unwrap());
|
||||
assert_fraction(&exact(1, 2).add(&exact(1, 3), &mut work).unwrap(), 5, 6);
|
||||
assert_eq!(work.usage().numeric_units, 75);
|
||||
assert!(work.exhaustion().is_none());
|
||||
let first = Exact::from_integer(1).negated(&mut work).unwrap_err();
|
||||
let at_failure = work.usage();
|
||||
assert_eq!(
|
||||
work.exhaustion().unwrap(),
|
||||
&WorkExhaustion {
|
||||
resource: WorkResource::NumericUnits,
|
||||
used: 75,
|
||||
requested: 3,
|
||||
limit: 75,
|
||||
site: WorkSite::Numeric(NumericOperation::Negate),
|
||||
}
|
||||
);
|
||||
assert_eq!(work.visit(EvaluationStep::Target).unwrap_err(), first);
|
||||
assert_eq!(
|
||||
Exact::from_integer(1)
|
||||
.divide(&Exact::from_integer(0), &mut work)
|
||||
.unwrap_err(),
|
||||
first
|
||||
);
|
||||
assert_eq!(
|
||||
work.numeric(NumericOperation::Compare, 0).unwrap_err(),
|
||||
first
|
||||
);
|
||||
assert_eq!(work.usage(), at_failure);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_cost_and_maximum_reservation_do_not_underflow_or_reactivate() {
|
||||
let mut work = WorkMeter::new(WorkLimits::new(0, 0).unwrap());
|
||||
work.numeric(NumericOperation::Compare, 0).unwrap();
|
||||
let failure = work
|
||||
.numeric(NumericOperation::Multiply, u64::MAX)
|
||||
.unwrap_err();
|
||||
assert!(failure.is_terminal_work_exhaustion());
|
||||
assert_eq!(work.usage(), WorkUsage::default());
|
||||
assert_eq!(
|
||||
work.numeric(NumericOperation::Compare, 0).unwrap_err(),
|
||||
failure
|
||||
);
|
||||
let mut dynamic = WorkMeter::new(WorkLimits::new(0, 5).unwrap());
|
||||
assert!(dynamic.visit(EvaluationStep::Target).is_err());
|
||||
assert_eq!(dynamic.usage(), WorkUsage::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_targets_share_one_aggregate_meter() {
|
||||
let mut work = WorkMeter::new(WorkLimits::new(3, 50).unwrap());
|
||||
work.visit(EvaluationStep::Target).unwrap();
|
||||
work.visit(EvaluationStep::Expression).unwrap();
|
||||
work.visit(EvaluationStep::Target).unwrap();
|
||||
let failure = work.visit(EvaluationStep::Expression).unwrap_err();
|
||||
assert!(failure.is_terminal_work_exhaustion());
|
||||
assert_eq!(work.usage().dynamic_events, 3);
|
||||
}
|
||||
|
||||
// A direct bounded call-tree workload, not an expression interpreter/dialect.
|
||||
fn expanded_calls(
|
||||
depth: u8,
|
||||
x: u64,
|
||||
work: &mut WorkMeter,
|
||||
entered: &mut u64,
|
||||
) -> Result<u64, NumericError> {
|
||||
work.visit(EvaluationStep::CalculationEntry)?;
|
||||
*entered += 1;
|
||||
if depth == 0 {
|
||||
return Ok(x);
|
||||
}
|
||||
let left = expanded_calls(depth - 1, 2 * x, work, entered)?;
|
||||
let right = expanded_calls(depth - 1, 2 * x + 1, work, entered)?;
|
||||
Ok(left + right)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn c1_expanded_call_witness_terminates_at_dynamic_limit() {
|
||||
let mut work = WorkMeter::new(WorkLimits::new(10_000, 0).unwrap());
|
||||
let mut entered = 0;
|
||||
let failure = expanded_calls(30, 0, &mut work, &mut entered).unwrap_err();
|
||||
assert!(failure.is_terminal_work_exhaustion());
|
||||
assert_eq!(entered, 10_000);
|
||||
assert_eq!(work.usage().dynamic_events, 10_000);
|
||||
assert_eq!(
|
||||
work.exhaustion().unwrap().site,
|
||||
WorkSite::Evaluation(EvaluationStep::CalculationEntry)
|
||||
);
|
||||
// Nearby finite control: 63 invocations, leaves 0..31, sum 496.
|
||||
let mut bounded = WorkMeter::new(WorkLimits::new(63, 0).unwrap());
|
||||
let mut entered = 0;
|
||||
assert_eq!(
|
||||
expanded_calls(5, 0, &mut bounded, &mut entered).unwrap(),
|
||||
496
|
||||
);
|
||||
assert_eq!(entered, 63);
|
||||
assert!(bounded.exhaustion().is_none());
|
||||
}
|
||||
|
||||
fn dim(value: [i16; 7]) -> Dimension {
|
||||
Dimension::new(value).unwrap()
|
||||
}
|
||||
|
||||
fn linear(key: &str, dimension: Dimension) -> ResolvedKind<String> {
|
||||
ResolvedKind::linear(key.to_owned(), dimension)
|
||||
}
|
||||
|
||||
fn named(n: i64, kind: &ResolvedKind<String>) -> NumericValue<String> {
|
||||
NumericValue::new(NumericType::Named(kind.clone()), Exact::from_integer(n))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dimensions_use_checked_seven_axis_integer_algebra() {
|
||||
let length = dim([1, 0, 0, 0, 0, 0, 0]);
|
||||
let mass = dim([0, 1, 0, 0, 0, 0, 0]);
|
||||
let time = dim([0, 0, 1, 0, 0, 0, 0]);
|
||||
assert_eq!(
|
||||
mass.checked_product(length)
|
||||
.unwrap()
|
||||
.checked_quotient(time.checked_power(2).unwrap())
|
||||
.unwrap(),
|
||||
dim([1, 1, -2, 0, 0, 0, 0])
|
||||
);
|
||||
assert!(time.checked_quotient(time).unwrap().is_dimensionless());
|
||||
assert!(matches!(
|
||||
dim([32, 0, 0, 0, 0, 0, 0]).checked_product(length),
|
||||
Err(NumericError::DimensionExponent {
|
||||
axis: DimensionAxis::Length,
|
||||
value: 33
|
||||
})
|
||||
));
|
||||
assert!(dim([0, 0, -32, 0, 0, 0, 0]).checked_quotient(time).is_err());
|
||||
assert!(length.checked_power(i8::MIN).is_err());
|
||||
assert!(Dimension::new([33, 0, 0, 0, 0, 0, 0]).is_err());
|
||||
assert_eq!(
|
||||
serde_json::to_value(time).unwrap(),
|
||||
serde_json::json!([0, 0, 1, 0, 0, 0, 0])
|
||||
);
|
||||
for invalid in ["[0,0,33,0,0,0,0]", "[0,0,1]", "[0,0,1,0,0,0,0,0]"] {
|
||||
assert!(serde_json::from_str::<Dimension>(invalid).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duration_unit_coordinates_are_exact_and_nominal() {
|
||||
let duration = linear("duration", dim([0, 0, 1, 0, 0, 0, 0]));
|
||||
let s = Unit::new(duration.clone(), source("1"), source("0")).unwrap();
|
||||
let ms = Unit::new(duration.clone(), source("0.001"), source("0")).unwrap();
|
||||
assert_fraction(&ms.convert(&source("750"), &s, &mut meter()).unwrap(), 3, 4);
|
||||
let receive = NumericValue::from_unit(&source("750"), &ms, &mut meter()).unwrap();
|
||||
let dispatch = NumericValue::from_unit(&source("1"), &s, &mut meter()).unwrap();
|
||||
let total = receive.add(&dispatch, &mut meter()).unwrap();
|
||||
assert_fraction(total.coordinate(), 7, 4);
|
||||
assert_fraction(&total.display_in(&ms, &mut meter()).unwrap(), 1750, 1);
|
||||
let different = Unit::new(
|
||||
linear("different-kind", duration.dimension()),
|
||||
source("1"),
|
||||
source("0"),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
ms.convert(&source("750"), &different, &mut meter()),
|
||||
Err(NumericError::IncompatibleTypes)
|
||||
);
|
||||
let inconsistent = Unit::new(
|
||||
linear("duration", dim([1, 0, 0, 0, 0, 0, 0])),
|
||||
source("1"),
|
||||
source("0"),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
ms.convert(&source("1"), &inconsistent, &mut meter())
|
||||
.is_err()
|
||||
);
|
||||
assert!(Unit::new(duration.clone(), source("0"), source("0")).is_err());
|
||||
assert!(Unit::new(duration.clone(), source("ratio(-1,1)"), source("0")).is_err());
|
||||
assert_eq!(
|
||||
Unit::new(duration, source("1"), source("1")).unwrap_err(),
|
||||
NumericError::LinearOffset
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn affine_point_and_difference_arithmetic_has_distinct_kinds() {
|
||||
let interval = linear("temperature-difference", dim([0, 0, 0, 0, 1, 0, 0]));
|
||||
let point = ResolvedKind::affine("temperature-point".to_owned(), &interval).unwrap();
|
||||
let celsius = Unit::new(point.clone(), source("1"), source("273.15")).unwrap();
|
||||
let fahrenheit = Unit::new(
|
||||
point.clone(),
|
||||
source("ratio(5,9)"),
|
||||
source("ratio(45967,180)"),
|
||||
)
|
||||
.unwrap();
|
||||
let kelvin_interval = Unit::new(interval.clone(), source("1"), source("0")).unwrap();
|
||||
assert_fraction(
|
||||
&celsius
|
||||
.convert(&source("0"), &fahrenheit, &mut meter())
|
||||
.unwrap(),
|
||||
32,
|
||||
1,
|
||||
);
|
||||
assert_fraction(
|
||||
&fahrenheit
|
||||
.convert(&source("70"), &celsius, &mut meter())
|
||||
.unwrap(),
|
||||
190,
|
||||
9,
|
||||
);
|
||||
let p20 = NumericValue::from_unit(&source("20"), &celsius, &mut meter()).unwrap();
|
||||
let p10 = NumericValue::from_unit(&source("10"), &celsius, &mut meter()).unwrap();
|
||||
let difference = p20.subtract(&p10, &mut meter()).unwrap();
|
||||
assert_eq!(
|
||||
difference.value_type(),
|
||||
&NumericType::Named(interval.clone())
|
||||
);
|
||||
assert_fraction(
|
||||
&difference
|
||||
.display_in(&kelvin_interval, &mut meter())
|
||||
.unwrap(),
|
||||
10,
|
||||
1,
|
||||
);
|
||||
let delta5 = named(5, &interval);
|
||||
let p25 = p20.add(&delta5, &mut meter()).unwrap();
|
||||
assert_fraction(&p25.display_in(&celsius, &mut meter()).unwrap(), 25, 1);
|
||||
assert_eq!(delta5.add(&p20, &mut meter()).unwrap(), p25);
|
||||
assert!(p20.add(&p10, &mut meter()).is_err());
|
||||
assert!(delta5.subtract(&p20, &mut meter()).is_err());
|
||||
assert_eq!(
|
||||
p20.power(1, &mut meter()),
|
||||
Err(NumericError::AffineOperation)
|
||||
);
|
||||
assert_eq!(
|
||||
p20.negated(&mut meter()),
|
||||
Err(NumericError::AffineOperation)
|
||||
);
|
||||
assert_eq!(p20.compare(&p10, &mut meter()).unwrap(), Ordering::Greater);
|
||||
assert!(p20.display_in(&kelvin_interval, &mut meter()).is_err());
|
||||
assert!(ResolvedKind::affine(interval.key().clone(), &interval).is_err());
|
||||
assert!(ResolvedKind::affine("other".to_owned(), &point).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn affine_conversion_rejects_a_wider_stage_even_when_the_final_value_would_fit() {
|
||||
// A fused coordinate calculation 2*m-m equals m, where m=2^4095 fits.
|
||||
// The fixed multiply stage produces 2^4096, so it must fail before offset
|
||||
// cancellation. The inverse path likewise rejects (m-(-m))/2 at subtraction.
|
||||
let large = BigInt::one() << 4095_usize;
|
||||
let magnitude = exact(&large, 1);
|
||||
let interval = linear("temperature-interval", dim([0, 0, 0, 0, 1, 0, 0]));
|
||||
let point = ResolvedKind::affine("temperature-point".to_owned(), &interval).unwrap();
|
||||
let unit = Unit::new(point, Exact::from_integer(2), exact(-&large, 1)).unwrap();
|
||||
let mut work = meter();
|
||||
for outcome in [
|
||||
unit.to_canonical(&magnitude, &mut work),
|
||||
unit.from_canonical(&magnitude, &mut work),
|
||||
] {
|
||||
assert!(matches!(
|
||||
outcome,
|
||||
Err(NumericError::Limit(NumericLimit::Bits {
|
||||
component: NumberComponent::Numerator,
|
||||
bits: 4097,
|
||||
limit: MAX_COMPONENT_BITS,
|
||||
..
|
||||
}))
|
||||
));
|
||||
}
|
||||
assert!(work.exhaustion().is_none());
|
||||
let half = exact(&large >> 1_usize, 1);
|
||||
assert_fraction(&unit.to_canonical(&half, &mut work).unwrap(), 0, 1);
|
||||
assert_eq!(
|
||||
unit.from_canonical(&Exact::from_integer(0), &mut work)
|
||||
.unwrap(),
|
||||
half
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn r6_anonymous_compound_energy_needs_one_outer_assertion() {
|
||||
let force = linear("force", dim([1, 1, -2, 0, 0, 0, 0]));
|
||||
let length = linear("length", dim([1, 0, 0, 0, 0, 0, 0]));
|
||||
let energy = linear("energy", dim([2, 1, -2, 0, 0, 0, 0]));
|
||||
let a = named(3, &force)
|
||||
.multiply(&named(4, &length), &mut meter())
|
||||
.unwrap();
|
||||
let b = named(2, &force)
|
||||
.multiply(&named(7, &length), &mut meter())
|
||||
.unwrap();
|
||||
assert_eq!(a.value_type(), &NumericType::Anonymous(energy.dimension()));
|
||||
let sum = a.add(&b, &mut meter()).unwrap();
|
||||
let result = sum.as_kind(&energy, &mut meter()).unwrap();
|
||||
assert_fraction(result.coordinate(), 26, 1);
|
||||
assert_eq!(result.value_type(), &NumericType::Named(energy.clone()));
|
||||
assert_eq!(a.compare(&b, &mut meter()).unwrap(), Ordering::Less);
|
||||
assert!(result.add(&sum, &mut meter()).is_err());
|
||||
assert_eq!(
|
||||
result.as_kind(&energy, &mut meter()),
|
||||
Err(NumericError::KindAssertion)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anonymous_product_then_quotient_closes_and_preserves_scaling() {
|
||||
let mass = linear("mass", dim([0, 1, 0, 0, 0, 0, 0]));
|
||||
let length = linear("length", dim([1, 0, 0, 0, 0, 0, 0]));
|
||||
let time = linear("duration", dim([0, 0, 1, 0, 0, 0, 0]));
|
||||
let momentum = linear("momentum", dim([1, 1, -1, 0, 0, 0, 0]));
|
||||
let result = named(2, &mass)
|
||||
.multiply(&named(3, &length), &mut meter())
|
||||
.unwrap()
|
||||
.divide(&named(2, &time), &mut meter())
|
||||
.unwrap()
|
||||
.as_kind(&momentum, &mut meter())
|
||||
.unwrap();
|
||||
assert_fraction(result.coordinate(), 3, 1);
|
||||
let scalar = NumericValue::number(source("2"));
|
||||
let scaled = scalar.multiply(&named(3, &time), &mut meter()).unwrap();
|
||||
assert_eq!(scaled.value_type(), &NumericType::Named(time.clone()));
|
||||
assert_fraction(scaled.coordinate(), 6, 1);
|
||||
assert_eq!(
|
||||
named(3, &time)
|
||||
.divide(&scalar, &mut meter())
|
||||
.unwrap()
|
||||
.value_type(),
|
||||
&NumericType::Named(time.clone())
|
||||
);
|
||||
assert_eq!(
|
||||
scalar
|
||||
.divide(&named(3, &time), &mut meter())
|
||||
.unwrap()
|
||||
.value_type(),
|
||||
&NumericType::Anonymous(dim([0, 0, -1, 0, 0, 0, 0]))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nominal_kinds_survive_comparison_but_explicit_quotients_can_erase_dimension() {
|
||||
let energy = linear("energy", dim([2, 1, -2, 0, 0, 0, 0]));
|
||||
let torque = linear("torque", energy.dimension());
|
||||
let left = named(7, &energy);
|
||||
let right = named(7, &torque);
|
||||
assert!(left.add(&right, &mut meter()).is_err());
|
||||
assert!(left.compare(&right, &mut meter()).is_err());
|
||||
let ratio = left.divide(&right, &mut meter()).unwrap();
|
||||
assert_eq!(ratio.value_type(), &NumericType::Number);
|
||||
assert_fraction(ratio.coordinate(), 1, 1);
|
||||
let named_ratio = linear("chosen-ratio", Dimension::ZERO);
|
||||
let asserted = ratio.as_kind(&named_ratio, &mut meter()).unwrap();
|
||||
assert_eq!(
|
||||
asserted.value_type(),
|
||||
&NumericType::Named(named_ratio.clone())
|
||||
);
|
||||
assert!(asserted.add(&ratio, &mut meter()).is_err());
|
||||
assert_eq!(
|
||||
NumericValue::<String>::new(NumericType::Anonymous(Dimension::ZERO), source("1")),
|
||||
NumericValue::number(source("1"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numerical_power_types_and_dimension_overflow_are_checked() {
|
||||
let length = linear("length", dim([1, 0, 0, 0, 0, 0, 0]));
|
||||
let value = named(2, &length);
|
||||
assert_eq!(
|
||||
value.power(0, &mut meter()).unwrap().value_type(),
|
||||
&NumericType::Number
|
||||
);
|
||||
assert_eq!(
|
||||
value.power(1, &mut meter()).unwrap().value_type(),
|
||||
value.value_type()
|
||||
);
|
||||
assert_eq!(
|
||||
value.power(-2, &mut meter()).unwrap().value_type(),
|
||||
&NumericType::Anonymous(dim([-2, 0, 0, 0, 0, 0, 0]))
|
||||
);
|
||||
let large_dimension = linear("large-dimension", dim([32, 0, 0, 0, 0, 0, 0]));
|
||||
assert!(
|
||||
named(1, &large_dimension)
|
||||
.multiply(&value, &mut meter())
|
||||
.is_err()
|
||||
);
|
||||
assert!(named(1, &large_dimension).power(2, &mut meter()).is_err());
|
||||
}
|
||||
@@ -15,6 +15,7 @@ fn occurrence(
|
||||
let id = add(p, Kind::Occurrence, layer, name, Some(owner));
|
||||
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange {
|
||||
operation: None,
|
||||
exchange: Some(exchange),
|
||||
sender,
|
||||
receiver,
|
||||
@@ -78,6 +79,7 @@ fn diagram(
|
||||
description: "A focused projection of the shared engineering model.".into(),
|
||||
layer,
|
||||
projection,
|
||||
view_options: Default::default(),
|
||||
nodes: BTreeMap::new(),
|
||||
routes: vec![],
|
||||
},
|
||||
|
||||
+262
-50
@@ -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};
|
||||
@@ -84,7 +86,20 @@ impl ParseLimits {
|
||||
.elements
|
||||
.values()
|
||||
.map(|e| e.steps().map_or(0, Vec::len))
|
||||
.sum();
|
||||
.sum::<usize>()
|
||||
.saturating_add(
|
||||
project
|
||||
.relations
|
||||
.values()
|
||||
.map(|r| match &r.body {
|
||||
RelationBody::ContextualLink { source, target, .. } => source
|
||||
.references
|
||||
.len()
|
||||
.saturating_add(target.references.len()),
|
||||
_ => 0,
|
||||
})
|
||||
.sum::<usize>(),
|
||||
);
|
||||
self.check_counts(graph, records, steps)
|
||||
}
|
||||
}
|
||||
@@ -129,12 +144,87 @@ pub struct Analysis {
|
||||
pub declarations: Vec<DeclarationSpan>,
|
||||
}
|
||||
|
||||
/// Check the decoded spelling required by declaration-header UUID clauses.
|
||||
/// This is the shared 36-byte/hyphen shape rule, not a hexadecimal parser:
|
||||
/// callers must additionally parse the UUID. Uppercase hex and JSON escapes
|
||||
/// that decode to this spelling remain accepted. URN/braced/compact forms do not.
|
||||
pub fn has_uuid_header_shape(decoded: &str) -> bool {
|
||||
decoded.len() == 36
|
||||
&& [8, 13, 18, 23]
|
||||
.into_iter()
|
||||
.all(|index| decoded.as_bytes()[index] == b'-')
|
||||
}
|
||||
|
||||
/// Budgets for one original source buffer. Policy is caller-owned; these
|
||||
/// limits do not change the grammar or perform model admission.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct OutlineLimits {
|
||||
pub max_source_bytes: usize,
|
||||
pub max_tokens: usize,
|
||||
pub max_declarations: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum OutlineResource {
|
||||
SourceBytes,
|
||||
Tokens,
|
||||
Declarations,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct OutlineLimit {
|
||||
pub resource: OutlineResource,
|
||||
pub limit: usize,
|
||||
pub observed_at_least: usize,
|
||||
/// Original byte offset where admission stopped; never a rewritten offset.
|
||||
pub offset: usize,
|
||||
}
|
||||
|
||||
/// Roles discovered by the existing tolerant declaration walk. Keeping the
|
||||
/// wrapper preserves DeclarationSpan and Analysis as existing public APIs.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct OutlineDeclaration {
|
||||
pub declaration: DeclarationSpan,
|
||||
pub kind_span: Span,
|
||||
pub alias_span: Span,
|
||||
/// Whole quoted header ID token, even if it is not a valid UUID. In that
|
||||
/// case declaration.id is None. Missing/non-string ID values have no span.
|
||||
pub id_literal_span: Option<Span>,
|
||||
}
|
||||
|
||||
/// A full-file tolerant scan, not a successful parse or validated model. Invalid
|
||||
/// text remains available. Lexical errors and unmatched braces are facts only:
|
||||
/// their absence does not establish syntactic or semantic validity.
|
||||
///
|
||||
/// A budget failure returns Err and no partial Outline. Include reachability,
|
||||
/// project completeness and editor revision are intentionally not inferred here.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Outline<'a> {
|
||||
pub source: &'a str,
|
||||
pub tokens: Vec<TokenSpan>,
|
||||
pub declarations: Vec<OutlineDeclaration>,
|
||||
pub has_lexical_errors: bool,
|
||||
pub has_unmatched_braces: bool,
|
||||
}
|
||||
|
||||
/// Tolerant highlighting tokens cover the whole input, including incomplete
|
||||
/// strings and unexpected characters. No model is created or modified.
|
||||
pub fn tokenize(source: &str) -> Vec<TokenSpan> {
|
||||
tokenize_with_limit(source, usize::MAX).expect("unlimited token scan")
|
||||
}
|
||||
|
||||
fn tokenize_with_limit(source: &str, max_tokens: usize) -> Result<Vec<TokenSpan>, OutlineLimit> {
|
||||
let mut tokens = Vec::new();
|
||||
let mut cursor = 0;
|
||||
while cursor < source.len() {
|
||||
if tokens.len() == max_tokens {
|
||||
return Err(OutlineLimit {
|
||||
resource: OutlineResource::Tokens,
|
||||
limit: max_tokens,
|
||||
observed_at_least: tokens.len().saturating_add(1),
|
||||
offset: cursor,
|
||||
});
|
||||
}
|
||||
let start = cursor;
|
||||
let ch = source[cursor..].chars().next().expect("in bounds");
|
||||
let class = if matches!(ch, ' ' | '\t' | '\r' | '\n') {
|
||||
@@ -211,7 +301,7 @@ pub fn tokenize(source: &str) -> Vec<TokenSpan> {
|
||||
span: Span { start, end: cursor },
|
||||
});
|
||||
}
|
||||
tokens
|
||||
Ok(tokens)
|
||||
}
|
||||
|
||||
pub fn lint(source: &str) -> Vec<Diagnostic> {
|
||||
@@ -343,6 +433,10 @@ fn reference_field(key: &str) -> bool {
|
||||
| "local"
|
||||
| "field"
|
||||
| "path"
|
||||
| "references"
|
||||
| "occurrence"
|
||||
| "allocation"
|
||||
| "work"
|
||||
| "supertype"
|
||||
| "sender"
|
||||
| "receiver"
|
||||
@@ -350,6 +444,9 @@ fn reference_field(key: &str) -> bool {
|
||||
| "lifeline"
|
||||
| "state"
|
||||
| "constrained"
|
||||
| "exchanges"
|
||||
| "hidden_edges"
|
||||
| "Any"
|
||||
| "source"
|
||||
| "target"
|
||||
| "source_port"
|
||||
@@ -550,25 +647,34 @@ fn offset_for(source: &str, line: usize, column: usize) -> usize {
|
||||
source.len()
|
||||
}
|
||||
|
||||
/// Canonical output uses persistent aliases and explicit identities. Typed
|
||||
/// nested records are ordinary language fields; JSON documents are not embedded.
|
||||
/// Canonical output uses persistent aliases and explicit identities, omitting
|
||||
/// only values supplied by source defaults. Typed nested records are ordinary
|
||||
/// language fields; JSON documents are not embedded.
|
||||
pub fn format(project: &Project) -> String {
|
||||
let mut out = std::format!(
|
||||
"arcadia {SCHEMA_VERSION};\nproject {} {} id \"{}\" {{\n description {};\n\n",
|
||||
"arcadia {SCHEMA_VERSION};\nproject {} {} id \"{}\" {{\n",
|
||||
project.alias,
|
||||
quote(&project.name),
|
||||
project.id,
|
||||
quote(&project.description)
|
||||
);
|
||||
for (_, declaration) in format_declarations(project) {
|
||||
if !project.description.is_empty() {
|
||||
out.push_str(&std::format!(
|
||||
" description {};\n",
|
||||
quote(&project.description)
|
||||
));
|
||||
}
|
||||
out.push('\n');
|
||||
for (_, declaration) in format_declarations(project, DeclarationFormat::Compact) {
|
||||
out.push_str(&declaration);
|
||||
}
|
||||
out.push_str("}\n");
|
||||
out
|
||||
}
|
||||
|
||||
/// The same typed declaration writer is used by flat and modular source output.
|
||||
pub(crate) fn format_declarations(project: &Project) -> Vec<(Id, String)> {
|
||||
/// Shared typed writer for canonical output and lossless editing. Complete edit
|
||||
/// comparisons retain defaults; emitted declarations and value fragments apply
|
||||
/// only the omission rules valid in their selected source context.
|
||||
pub(crate) fn format_declarations(project: &Project, mode: DeclarationFormat) -> Vec<(Id, String)> {
|
||||
let aliases = project
|
||||
.identities()
|
||||
.into_iter()
|
||||
@@ -580,6 +686,7 @@ pub(crate) fn format_declarations(project: &Project) -> Vec<(Id, String)> {
|
||||
let mut fields = payload(&serde_json::to_value(&e.body).unwrap());
|
||||
fields.insert("description".into(), Value::String(e.description.clone()));
|
||||
fields.insert("parent".into(), serde_json::to_value(e.parent).unwrap());
|
||||
let fields = source_defaults::fields(DefaultContext::Element(e.kind()), &fields, mode);
|
||||
write_declaration(
|
||||
&mut out,
|
||||
&element_word(e.kind(), e.layer()),
|
||||
@@ -595,6 +702,7 @@ pub(crate) fn format_declarations(project: &Project) -> Vec<(Id, String)> {
|
||||
let mut out = String::new();
|
||||
let mut fields = payload(&serde_json::to_value(&r.body).unwrap());
|
||||
fields.insert("description".into(), Value::String(r.description.clone()));
|
||||
let fields = source_defaults::fields(DefaultContext::Relation(r.kind()), &fields, mode);
|
||||
write_declaration(
|
||||
&mut out,
|
||||
&relation_word(r.kind()),
|
||||
@@ -610,6 +718,7 @@ pub(crate) fn format_declarations(project: &Project) -> Vec<(Id, String)> {
|
||||
let mut out = String::new();
|
||||
let mut fields = record_fields(d);
|
||||
fields.remove("nodes");
|
||||
let fields = source_defaults::fields(DefaultContext::Diagram, &fields, mode);
|
||||
write_declaration(
|
||||
&mut out,
|
||||
"diagram",
|
||||
@@ -624,6 +733,7 @@ pub(crate) fn format_declarations(project: &Project) -> Vec<(Id, String)> {
|
||||
let mut out = String::new();
|
||||
let mut fields = record_fields(n);
|
||||
fields.insert("diagram".into(), Value::String(d.id.to_string()));
|
||||
let fields = source_defaults::fields(DefaultContext::Node, &fields, mode);
|
||||
write_declaration(&mut out, "node", &n.alias, None, n.id, &fields, &aliases);
|
||||
declarations.push((n.id, out));
|
||||
}
|
||||
@@ -636,7 +746,7 @@ pub(crate) fn format_declarations(project: &Project) -> Vec<(Id, String)> {
|
||||
&a.alias,
|
||||
Some(&a.name),
|
||||
a.id,
|
||||
&record_fields(a),
|
||||
&source_defaults::fields(DefaultContext::Artifact, &record_fields(a), mode),
|
||||
&aliases,
|
||||
);
|
||||
declarations.push((a.id, out));
|
||||
@@ -649,7 +759,7 @@ pub(crate) fn format_declarations(project: &Project) -> Vec<(Id, String)> {
|
||||
&t.alias,
|
||||
Some(&t.name),
|
||||
t.id,
|
||||
&record_fields(t),
|
||||
&source_defaults::fields(DefaultContext::Trace, &record_fields(t), mode),
|
||||
&aliases,
|
||||
);
|
||||
declarations.push((t.id, out));
|
||||
@@ -1210,11 +1320,7 @@ impl Parser<'_> {
|
||||
self.cursor += 1;
|
||||
let offset = self.current().span.start;
|
||||
let value = self.quoted()?;
|
||||
if value.len() != 36
|
||||
|| ![8, 13, 18, 23]
|
||||
.into_iter()
|
||||
.all(|i| value.as_bytes()[i] == b'-')
|
||||
{
|
||||
if !has_uuid_header_shape(&value) {
|
||||
return Err(Error {
|
||||
code: "invalid_source",
|
||||
offset,
|
||||
@@ -1383,9 +1489,10 @@ impl Parser<'_> {
|
||||
fn unit(&mut self, entry: bool, includes_allowed: bool) -> Result<SourceUnit, Error> {
|
||||
let mut header = if entry {
|
||||
self.take("arcadia")?;
|
||||
if !matches!(self.text(), "3" | "4" | "5") {
|
||||
return Err(self
|
||||
.error("Unsupported schema version: expected 'arcadia 3;', 'arcadia 4;' or 'arcadia 5;'."));
|
||||
if !matches!(self.text(), "3" | "4" | "5" | "6" | "7") {
|
||||
return Err(
|
||||
self.error("Unsupported schema version: expected arcadia 3, 4, 5, 6 or 7.")
|
||||
);
|
||||
}
|
||||
let schema_version = self.text().parse().expect("checked source version");
|
||||
self.cursor += 1;
|
||||
@@ -1603,6 +1710,10 @@ pub(crate) fn resolve_declarations(
|
||||
};
|
||||
match d.kind {
|
||||
DeclKind::Element(kind, layer) => {
|
||||
if project.schema_version < 7 { crate::persistence::reject_source7_fields(&Value::Object(fields.clone())).map_err(&conversion_error)?; }
|
||||
if project.schema_version < 6 && !crate::persistence::pre6_element_kind(kind) {
|
||||
return Err(conversion_error(std::format!("{kind:?} requires schema/source 6.")));
|
||||
}
|
||||
if legacy && !crate::persistence::v3::element_allowed(serde_json::to_value(kind).unwrap().as_str().unwrap()) {
|
||||
return Err(conversion_error("Element constructor was not part of schema3.".into()));
|
||||
}
|
||||
@@ -1646,7 +1757,9 @@ pub(crate) fn resolve_declarations(
|
||||
*viewpoint = ScenarioViewpoint::MixedLegacy;
|
||||
}
|
||||
let mut body = serde_json::to_value(initial).unwrap();
|
||||
let defaults = if legacy {
|
||||
let defaults = if project.schema_version >= 7 {
|
||||
source_defaults::parser_fields(DefaultContext::Element(kind))
|
||||
} else if legacy {
|
||||
crate::persistence::v3::legacy_defaults(kind)
|
||||
.and_then(|value| value.as_object().cloned())
|
||||
.unwrap_or_else(|| payload(&body))
|
||||
@@ -1677,12 +1790,18 @@ pub(crate) fn resolve_declarations(
|
||||
);
|
||||
}
|
||||
DeclKind::Relation(kind) => {
|
||||
if project.schema_version < 7 && kind == RelationKind::ContextualLink { return Err(conversion_error("ContextualLink requires schema/source 7.".into())); }
|
||||
if project.schema_version < 6 && !crate::persistence::pre6_relation_kind(kind) {
|
||||
return Err(conversion_error(std::format!("{kind:?} requires schema/source 6.")));
|
||||
}
|
||||
if legacy && !crate::persistence::v3::relation_allowed(serde_json::to_value(kind).unwrap().as_str().unwrap()) {
|
||||
return Err(conversion_error("Relation constructor was not part of schema3.".into()));
|
||||
}
|
||||
let description =
|
||||
take_string(&mut fields, "description", "").map_err(&conversion_error)?;
|
||||
let required: &[&str] = if kind == RelationKind::FunctionalExchange {
|
||||
let required: &[&str] = if kind == RelationKind::ContextualLink {
|
||||
&["owner", "source", "target", "purpose"]
|
||||
} else if kind == RelationKind::FunctionalExchange {
|
||||
&["source", "target", "source_port", "target_port"]
|
||||
} else {
|
||||
&["source", "target"]
|
||||
@@ -1697,7 +1816,12 @@ pub(crate) fn resolve_declarations(
|
||||
let mut body =
|
||||
serde_json::to_value(RelationBody::new(kind, Id::nil(), Id::nil()))
|
||||
.unwrap();
|
||||
let merged = merge_fields(payload(&body), fields).map_err(&conversion_error)?;
|
||||
let defaults = if project.schema_version >= 7 {
|
||||
source_defaults::parser_fields(DefaultContext::Relation(kind))
|
||||
} else {
|
||||
payload(&body)
|
||||
};
|
||||
let merged = merge_fields(defaults, fields).map_err(&conversion_error)?;
|
||||
replace_payload(&mut body, merged);
|
||||
let body = serde_json::from_value(body)
|
||||
.map_err(|e| conversion_error(e.to_string()))?;
|
||||
@@ -1713,9 +1837,19 @@ pub(crate) fn resolve_declarations(
|
||||
);
|
||||
}
|
||||
DeclKind::Diagram => {
|
||||
if project.schema_version < 7 { crate::persistence::reject_source7_fields(&Value::Object(fields.clone())).map_err(&conversion_error)?; }
|
||||
if project.schema_version < 6 && fields.contains_key("view_options") {
|
||||
return Err(conversion_error("Diagram.view_options requires schema/source 6, including an explicitly supplied default value.".into()));
|
||||
}
|
||||
if project.schema_version < 6 && let Some(key)=fields.keys().find(|key| !matches!(key.as_str(),"description"|"layer"|"projection"|"routes")) {
|
||||
return Err(conversion_error(std::format!("Unknown diagram field '{key}' in the frozen pre6 contract.")));
|
||||
}
|
||||
let defaults =
|
||||
serde_json::json!({"description":"","layer":"Oa","projection":"Overview","routes":[]});
|
||||
let mut record = merge_fields(defaults.as_object().unwrap().clone(), fields)
|
||||
serde_json::json!({"description":"","layer":"Oa","projection":"Overview","routes":[],"view_options":crate::view_options::ViewOptions::default()});
|
||||
let defaults = if project.schema_version >= 7 {
|
||||
source_defaults::parser_fields(DefaultContext::Diagram)
|
||||
} else { defaults.as_object().unwrap().clone() };
|
||||
let mut record = merge_fields(defaults, fields)
|
||||
.map_err(&conversion_error)?;
|
||||
header(&mut record, d, true);
|
||||
record.insert("nodes".into(), serde_json::json!({}));
|
||||
@@ -1730,7 +1864,10 @@ pub(crate) fn resolve_declarations(
|
||||
return Err(conversion_error("Missing required field 'element'.".into()));
|
||||
}
|
||||
let defaults = serde_json::json!({"element":null,"position":{"x":0.0,"y":0.0},"size":{"width":224.0,"height":94.0},"collapsed":false,"port_anchors":[]});
|
||||
let mut record = merge_fields(defaults.as_object().unwrap().clone(), fields)
|
||||
let defaults = if project.schema_version >= 7 {
|
||||
source_defaults::parser_fields(DefaultContext::Node)
|
||||
} else { defaults.as_object().unwrap().clone() };
|
||||
let mut record = merge_fields(defaults, fields)
|
||||
.map_err(&conversion_error)?;
|
||||
header(&mut record, d, false);
|
||||
let node: NodePlacement = serde_json::from_value(Value::Object(record))
|
||||
@@ -1742,7 +1879,10 @@ pub(crate) fn resolve_declarations(
|
||||
return Err(conversion_error("Missing required field 'owner'.".into()));
|
||||
}
|
||||
let defaults = serde_json::json!({"description":"","owner":null,"relation":"References","uri":"","revision":null,"fragment":null});
|
||||
let mut record = merge_fields(defaults.as_object().unwrap().clone(), fields)
|
||||
let defaults = if project.schema_version >= 7 {
|
||||
source_defaults::parser_fields(DefaultContext::Artifact)
|
||||
} else { defaults.as_object().unwrap().clone() };
|
||||
let mut record = merge_fields(defaults, fields)
|
||||
.map_err(&conversion_error)?;
|
||||
header(&mut record, d, true);
|
||||
let artifact = serde_json::from_value(Value::Object(record))
|
||||
@@ -1758,7 +1898,10 @@ pub(crate) fn resolve_declarations(
|
||||
}
|
||||
}
|
||||
let defaults = serde_json::json!({"description":"","oa":null,"sa":null,"kind":"Refinement","baseline":null});
|
||||
let mut record = merge_fields(defaults.as_object().unwrap().clone(), fields)
|
||||
let defaults = if project.schema_version >= 7 {
|
||||
source_defaults::parser_fields(DefaultContext::Trace)
|
||||
} else { defaults.as_object().unwrap().clone() };
|
||||
let mut record = merge_fields(defaults, fields)
|
||||
.map_err(&conversion_error)?;
|
||||
header(&mut record, d, true);
|
||||
let trace = if project.schema_version < 5 {
|
||||
@@ -1837,8 +1980,15 @@ pub(crate) fn resolve_declarations(
|
||||
}
|
||||
return Ok(migrated);
|
||||
}
|
||||
if project.schema_version == 4 {
|
||||
return crate::persistence::v4::finish(project, limits).map_err(|error| {
|
||||
if matches!(project.schema_version, 4..=6) {
|
||||
return (if project.schema_version == 4 {
|
||||
crate::persistence::v4::finish(project, limits)
|
||||
} else if project.schema_version == 5 {
|
||||
crate::persistence::migrate_v5(project, limits)
|
||||
} else {
|
||||
crate::persistence::migrate_v6(project, limits)
|
||||
})
|
||||
.map_err(|error| {
|
||||
(
|
||||
usize::MAX,
|
||||
Error {
|
||||
@@ -1917,8 +2067,49 @@ fn take_required_id(fields: &mut Map<String, Value>, key: &str) -> Result<Id, St
|
||||
|
||||
/// Analyze an incomplete source buffer without adopting a candidate. Declaration
|
||||
/// spans remain available even when a later declaration contains errors.
|
||||
/// Invalid compact/braced/URN header IDs retain no authored identity, matching
|
||||
/// the declaration parser rather than the UUID crate's broader accepted forms.
|
||||
pub fn analyze(source: &str) -> Analysis {
|
||||
let tokens = tokenize(source);
|
||||
let (declarations, _) =
|
||||
declaration_outline(source, &tokens, usize::MAX).expect("unlimited declaration walk");
|
||||
Analysis {
|
||||
tokens,
|
||||
diagnostics: lint(source),
|
||||
declarations: declarations.into_iter().map(|d| d.declaration).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Read exact current text without lint, parsing a model, stabilization or I/O.
|
||||
/// Source bytes are admitted before token allocation; token/declaration limits
|
||||
/// are checked before lexing the next token or materializing the next record.
|
||||
pub fn outline(source: &str, limits: OutlineLimits) -> Result<Outline<'_>, OutlineLimit> {
|
||||
if source.len() > limits.max_source_bytes {
|
||||
return Err(OutlineLimit {
|
||||
resource: OutlineResource::SourceBytes,
|
||||
limit: limits.max_source_bytes,
|
||||
observed_at_least: source.len(),
|
||||
offset: 0,
|
||||
});
|
||||
}
|
||||
let tokens = tokenize_with_limit(source, limits.max_tokens)?;
|
||||
let has_lexical_errors = tokens.iter().any(|token| token.class == TokenClass::Error);
|
||||
let (declarations, has_unmatched_braces) =
|
||||
declaration_outline(source, &tokens, limits.max_declarations)?;
|
||||
Ok(Outline {
|
||||
source,
|
||||
tokens,
|
||||
declarations,
|
||||
has_lexical_errors,
|
||||
has_unmatched_braces,
|
||||
})
|
||||
}
|
||||
|
||||
fn declaration_outline(
|
||||
source: &str,
|
||||
tokens: &[TokenSpan],
|
||||
max_declarations: usize,
|
||||
) -> Result<(Vec<OutlineDeclaration>, bool), OutlineLimit> {
|
||||
let significant = tokens
|
||||
.iter()
|
||||
.filter(|t| !matches!(t.class, TokenClass::Whitespace | TokenClass::Comment))
|
||||
@@ -1927,17 +2118,21 @@ pub fn analyze(source: &str) -> Analysis {
|
||||
// again would make tolerant navigation quadratic on deeply nested drafts.
|
||||
let mut body_ends = vec![None; significant.len()];
|
||||
let mut bodies = Vec::new();
|
||||
let mut unmatched_close = false;
|
||||
for (index, token) in significant.iter().enumerate() {
|
||||
match &source[token.span.start..token.span.end] {
|
||||
"{" => bodies.push(index),
|
||||
"}" => {
|
||||
if let Some(open) = bodies.pop() {
|
||||
body_ends[open] = Some(token.span.end);
|
||||
} else {
|
||||
unmatched_close = true;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let has_unmatched_braces = unmatched_close || !bodies.is_empty();
|
||||
let mut declarations = Vec::new();
|
||||
for (i, t) in significant.iter().enumerate() {
|
||||
let word = &source[t.span.start..t.span.end];
|
||||
@@ -1962,7 +2157,16 @@ pub fn analyze(source: &str) -> Analysis {
|
||||
}
|
||||
j += 1;
|
||||
}
|
||||
if declarations.len() == max_declarations {
|
||||
return Err(OutlineLimit {
|
||||
resource: OutlineResource::Declarations,
|
||||
limit: max_declarations,
|
||||
observed_at_least: declarations.len().saturating_add(1),
|
||||
offset: t.span.start,
|
||||
});
|
||||
}
|
||||
let mut id = None;
|
||||
let mut id_literal_span = None;
|
||||
if significant
|
||||
.get(j)
|
||||
.is_some_and(|t| &source[t.span.start..t.span.end] == "id")
|
||||
@@ -1970,39 +2174,51 @@ pub fn analyze(source: &str) -> Analysis {
|
||||
if let Some(value) = significant.get(j + 1)
|
||||
&& value.class == TokenClass::String
|
||||
{
|
||||
id_literal_span = Some(value.span);
|
||||
let text =
|
||||
serde_json::from_str::<String>(&source[value.span.start..value.span.end])
|
||||
.unwrap();
|
||||
id = Id::parse_str(&text).ok();
|
||||
if has_uuid_header_shape(&text) {
|
||||
id = Id::parse_str(&text).ok();
|
||||
}
|
||||
}
|
||||
j += 2;
|
||||
}
|
||||
let mut end = significant
|
||||
.get(j)
|
||||
.map_or(alias_token.span.end, |t| t.span.end);
|
||||
// A recognized header ID at EOF must remain inside the whole span.
|
||||
// Keep the old next-token/body behavior; only the EOF fallback changes.
|
||||
let header_end = significant
|
||||
.get(j.saturating_sub(1))
|
||||
.or_else(|| significant.last())
|
||||
.map_or(alias_token.span.end, |token| token.span.end);
|
||||
let mut end = significant.get(j).map_or(header_end, |t| t.span.end);
|
||||
if significant
|
||||
.get(j)
|
||||
.is_some_and(|t| &source[t.span.start..t.span.end] == "{")
|
||||
{
|
||||
end = body_ends[j].unwrap_or(source.len());
|
||||
}
|
||||
declarations.push(DeclarationSpan {
|
||||
alias: alias.into(),
|
||||
id,
|
||||
kind: word.into(),
|
||||
span: Span {
|
||||
start: t.span.start,
|
||||
end,
|
||||
declarations.push(OutlineDeclaration {
|
||||
declaration: DeclarationSpan {
|
||||
alias: alias.into(),
|
||||
id,
|
||||
kind: word.into(),
|
||||
span: Span {
|
||||
start: t.span.start,
|
||||
end,
|
||||
},
|
||||
},
|
||||
kind_span: t.span,
|
||||
alias_span: alias_token.span,
|
||||
id_literal_span,
|
||||
});
|
||||
}
|
||||
Analysis {
|
||||
tokens,
|
||||
diagnostics: lint(source),
|
||||
declarations,
|
||||
}
|
||||
Ok((declarations, has_unmatched_braces))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "language_outline_tests.rs"]
|
||||
mod outline_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "language_nesting_tests.rs"]
|
||||
mod nesting_tests;
|
||||
@@ -2397,11 +2613,7 @@ pub(crate) mod legacy {
|
||||
let value = self.quoted()?;
|
||||
// Require the portable 8-4-4-4-12 spelling, not the UUID crate's URN,
|
||||
// braces, or compact alternatives. Uppercase hexadecimal is accepted.
|
||||
if value.len() != 36
|
||||
|| ![8, 13, 18, 23]
|
||||
.into_iter()
|
||||
.all(|index| value.as_bytes()[index] == b'-')
|
||||
{
|
||||
if !super::has_uuid_header_shape(&value) {
|
||||
return Err(Error {
|
||||
code: "invalid_source",
|
||||
offset,
|
||||
|
||||
@@ -109,8 +109,16 @@ pub(super) fn resolve_baseline(
|
||||
continue;
|
||||
}
|
||||
let json = raw(value)?;
|
||||
if version < 7 {
|
||||
crate::persistence::reject_source7_fields(&json)
|
||||
.map_err(|cause| error(value, cause))?;
|
||||
}
|
||||
let baseline: SemanticBaseline = serde_json::from_value(json.clone())
|
||||
.map_err(|cause| error(value, format!("Invalid semantic baseline: {cause}")))?;
|
||||
if version < 6 {
|
||||
crate::persistence::check_pre6_baseline(&baseline)
|
||||
.map_err(|cause| error(value, cause.to_string()))?;
|
||||
}
|
||||
let slots = historical_source_slots(&baseline).into_iter().collect();
|
||||
validate_tokens(value, &mut vec![], &slots)?;
|
||||
resolved.insert(key.clone(), json);
|
||||
|
||||
@@ -402,6 +402,37 @@ fn checked_in_source_project() -> SourceBundle {
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_architecture_example_warnings(compiled: &crate::source_project::CompiledSourceProject) {
|
||||
// This architecture-only example intentionally has no process/scenario
|
||||
// context. Preserve its explicit incompleteness instead of inventing work.
|
||||
let expected: std::collections::BTreeSet<_> = [
|
||||
("unspecified_exchange_mechanism", "oa_report_data"),
|
||||
("unspecified_exchange_mechanism", "sa_report_data"),
|
||||
("work_without_behavior_context", "report_distress"),
|
||||
("work_without_behavior_context", "assess_incident"),
|
||||
("work_without_behavior_context", "transmit_report"),
|
||||
("work_without_behavior_context", "assess_report"),
|
||||
("work_without_behavior_context", "review_response"),
|
||||
("exchange_without_behavior_context", "distress_signal"),
|
||||
("exchange_without_behavior_context", "validated_report"),
|
||||
("capability_without_behavior_context", "protect_life"),
|
||||
("capability_without_behavior_context", "coordinate_response"),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|(code, alias)| (code, Some(identity(&compiled.decoded.project, alias))))
|
||||
.collect();
|
||||
let actual: std::collections::BTreeSet<_> = compiled
|
||||
.diagnostics
|
||||
.iter()
|
||||
.map(|issue| {
|
||||
assert_eq!(issue.diagnostic.severity, validation::Severity::Warning);
|
||||
(issue.diagnostic.code.as_str(), issue.diagnostic.object_id)
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(actual, expected);
|
||||
assert_eq!(compiled.diagnostics.len(), expected.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checked_in_nested_example_has_stable_ids_typed_exchanges_and_three_clear_views() {
|
||||
let input = checked_in_source_project();
|
||||
@@ -428,15 +459,7 @@ fn checked_in_nested_example_has_stable_ids_typed_exchanges_and_three_clear_view
|
||||
let reopened = compile_bundle(&compiled.bundle, ProjectLimits::default()).unwrap();
|
||||
assert_eq!(reopened.bundle, compiled.bundle);
|
||||
assert!(reopened.decoded.migration.is_none());
|
||||
assert_eq!(compiled.diagnostics.len(), 2);
|
||||
assert!(
|
||||
compiled
|
||||
.diagnostics
|
||||
.iter()
|
||||
.all(|issue| issue.diagnostic.code == "unspecified_exchange_mechanism"),
|
||||
"{:?}",
|
||||
compiled.diagnostics
|
||||
);
|
||||
assert_architecture_example_warnings(&compiled);
|
||||
let project = &compiled.decoded.project;
|
||||
assert_eq!(project.diagrams.len(), 3);
|
||||
let assess = identity(project, "assess_report");
|
||||
@@ -495,15 +518,7 @@ fn checked_in_example_supports_cross_file_reparent_without_changing_allocations(
|
||||
ProjectLimits::default(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(result.diagnostics.len(), 2);
|
||||
assert!(
|
||||
result
|
||||
.diagnostics
|
||||
.iter()
|
||||
.all(|issue| issue.diagnostic.code == "unspecified_exchange_mechanism"),
|
||||
"{:?}",
|
||||
result.diagnostics
|
||||
);
|
||||
assert_architecture_example_warnings(&result);
|
||||
assert_eq!(
|
||||
result.index.declarations[&assess].path,
|
||||
"sa/assurance.arcadia"
|
||||
|
||||
@@ -0,0 +1,386 @@
|
||||
//! Prepared outline tests. Explicit compatibility tests additionally exercise
|
||||
//! the existing parsers/analyze; the outline entry itself never calls them.
|
||||
use super::*;
|
||||
|
||||
fn limits() -> OutlineLimits {
|
||||
OutlineLimits {
|
||||
max_source_bytes: 1024 * 1024,
|
||||
max_tokens: 100_000,
|
||||
max_declarations: 1_000,
|
||||
}
|
||||
}
|
||||
|
||||
fn declaration<'a>(result: &'a Outline<'_>, alias: &str) -> &'a OutlineDeclaration {
|
||||
result
|
||||
.declarations
|
||||
.iter()
|
||||
.find(|d| d.declaration.alias == alias)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn text(source: &str, span: Span) -> &str {
|
||||
&source[span.start..span.end]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outline_roles_preserve_unicode_crlf_and_exact_escaped_uuid_literal() {
|
||||
let source = "// 検査 🚒\r\noa_actor responder \"救助\" id \"\\u00300000000-0000-0000-0000-000000000001\" {}\r\nsource @ // 使用\r\n responder;";
|
||||
let before = source.as_bytes().to_vec();
|
||||
let result = outline(source, limits()).unwrap();
|
||||
assert_eq!(result.source.as_bytes(), before);
|
||||
assert_eq!(result.source.as_ptr(), source.as_ptr());
|
||||
let d = declaration(&result, "responder");
|
||||
assert_eq!(text(source, d.kind_span), "oa_actor");
|
||||
assert_eq!(text(source, d.alias_span), "responder");
|
||||
assert_eq!(
|
||||
text(source, d.id_literal_span.unwrap()),
|
||||
"\"\\u00300000000-0000-0000-0000-000000000001\""
|
||||
);
|
||||
assert_eq!(d.declaration.id, Some(Id::from_u128(1)));
|
||||
assert_eq!(d.declaration.span.start, source.find("oa_actor").unwrap());
|
||||
assert_eq!(d.declaration.span.end, source.find("{}\r\n").unwrap() + 2);
|
||||
let mut end = 0;
|
||||
for token in &result.tokens {
|
||||
assert_eq!(token.span.start, end);
|
||||
assert!(token.span.start < token.span.end);
|
||||
assert!(source.is_char_boundary(token.span.start));
|
||||
assert!(source.is_char_boundary(token.span.end));
|
||||
end = token.span.end;
|
||||
}
|
||||
assert_eq!(end, source.len());
|
||||
assert!(!result.has_lexical_errors);
|
||||
assert!(!result.has_unmatched_braces);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closed_id_at_eof_is_inside_the_declaration_extent() {
|
||||
for source in [
|
||||
"oa_actor a \"Actor\" id \"00000000-0000-0000-0000-000000000001\"",
|
||||
"node placed id \"00000000-0000-0000-0000-000000000001\"",
|
||||
] {
|
||||
let result = outline(source, limits()).unwrap();
|
||||
let d = &result.declarations[0];
|
||||
let literal = d.id_literal_span.unwrap();
|
||||
assert_eq!(d.declaration.id, Some(Id::from_u128(1)));
|
||||
assert_eq!(d.declaration.span.end, source.len());
|
||||
assert!(d.declaration.span.start <= literal.start);
|
||||
assert!(literal.end <= d.declaration.span.end);
|
||||
}
|
||||
for source in ["oa_actor a \"Actor\"", "oa_actor a \"Actor\" id"] {
|
||||
let result = outline(source, limits()).unwrap();
|
||||
let d = &result.declarations[0];
|
||||
assert_eq!(d.declaration.span.end, source.len());
|
||||
assert_eq!(d.declaration.id, None);
|
||||
assert_eq!(d.id_literal_span, None);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_bad_and_historical_ids_never_allocate_authored_identity() {
|
||||
let source = "oa_actor missing \"No ID\" {}\noa_actor bad \"Bad ID\" id \"not-a-uuid\" {}\noa_actor historical \"00000000-0000-0000-0000-000000000001\" { history uuid \"00000000-0000-0000-0000-000000000001\"; }";
|
||||
let first = outline(source, limits()).unwrap();
|
||||
let second = outline(source, limits()).unwrap();
|
||||
assert_eq!(first, second);
|
||||
assert!(
|
||||
first
|
||||
.declarations
|
||||
.iter()
|
||||
.all(|d| d.declaration.id.is_none())
|
||||
);
|
||||
assert_eq!(declaration(&first, "missing").id_literal_span, None);
|
||||
assert_eq!(declaration(&first, "historical").id_literal_span, None);
|
||||
assert_eq!(
|
||||
text(source, declaration(&first, "bad").id_literal_span.unwrap()),
|
||||
"\"not-a-uuid\""
|
||||
);
|
||||
// Balanced tokens are not a successful syntax/semantic validation claim.
|
||||
assert!(!first.has_lexical_errors);
|
||||
assert!(!first.has_unmatched_braces);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_full_scan_keeps_closed_children_and_reports_lexical_body_facts() {
|
||||
let source = "oa_entity outer \"Outer\" {\n oa_actor ready \"Ready\" { description \"} stays text\"; }\n oa_actor pending \"Pending\" { description \"unfinished 🌊";
|
||||
let result = outline(source, limits()).unwrap();
|
||||
assert_eq!(result.source, source);
|
||||
assert_eq!(result.tokens.last().unwrap().span.end, source.len());
|
||||
assert!(result.has_lexical_errors);
|
||||
assert!(result.has_unmatched_braces);
|
||||
let ready = declaration(&result, "ready");
|
||||
assert_eq!(
|
||||
text(source, ready.declaration.span),
|
||||
"oa_actor ready \"Ready\" { description \"} stays text\"; }"
|
||||
);
|
||||
assert_eq!(
|
||||
declaration(&result, "outer").declaration.span.end,
|
||||
source.len()
|
||||
);
|
||||
assert_eq!(
|
||||
declaration(&result, "pending").declaration.span.end,
|
||||
source.len()
|
||||
);
|
||||
let unmatched_close = outline("}\n// brace in comment {\n", limits()).unwrap();
|
||||
assert!(unmatched_close.has_unmatched_braces);
|
||||
assert!(!unmatched_close.has_lexical_errors);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_body_syntax_is_unchecked_even_when_known_error_flags_are_clear() {
|
||||
let source = "oa_actor a \"A\" id \"00000000-0000-0000-0000-000000000001\"";
|
||||
let result = outline(source, limits()).unwrap();
|
||||
assert_eq!(result.declarations.len(), 1);
|
||||
assert!(!result.has_lexical_errors);
|
||||
assert!(!result.has_unmatched_braces);
|
||||
// There is deliberately no `valid` flag: the known-complete fact is scanning
|
||||
// these bytes, not the missing body becoming legal or the draft being applied.
|
||||
assert_eq!(result.source, source);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keyword_alias_and_collisions_keep_every_source_ordered_header() {
|
||||
let source = "project root_model \"Model\" {\n oa_actor oa_activity \"First\" id \"00000000-0000-0000-0000-000000000001\" {}\n oa_actor oa_activity \"Second\" id \"00000000-0000-0000-0000-000000000001\" {}\n diagram view_one \"View\" {}\n node placement { diagram @view_one; }\n}";
|
||||
let result = outline(source, limits()).unwrap();
|
||||
assert_eq!(result.declarations.len(), 5);
|
||||
let duplicates: Vec<_> = result
|
||||
.declarations
|
||||
.iter()
|
||||
.filter(|d| d.declaration.alias == "oa_activity")
|
||||
.collect();
|
||||
assert_eq!(duplicates.len(), 2);
|
||||
assert!(duplicates[0].alias_span.start < duplicates[1].alias_span.start);
|
||||
assert_eq!(duplicates[0].declaration.id, duplicates[1].declaration.id);
|
||||
assert!(
|
||||
result
|
||||
.tokens
|
||||
.iter()
|
||||
.any(|t| t.span == duplicates[0].alias_span && t.class == TokenClass::Keyword)
|
||||
);
|
||||
assert_eq!(
|
||||
text(source, declaration(&result, "placement").kind_span),
|
||||
"node"
|
||||
);
|
||||
assert_eq!(declaration(&result, "placement").declaration.id, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounds_refuse_before_returning_a_partial_outline() {
|
||||
let source = "oa_actor a \"A\" {}\noa_actor b \"B\" {}";
|
||||
let admitted = outline(source, limits()).unwrap();
|
||||
let exact = OutlineLimits {
|
||||
max_source_bytes: source.len(),
|
||||
max_tokens: admitted.tokens.len(),
|
||||
max_declarations: 2,
|
||||
};
|
||||
assert_eq!(outline(source, exact).unwrap(), admitted);
|
||||
for resource in [
|
||||
OutlineResource::SourceBytes,
|
||||
OutlineResource::Tokens,
|
||||
OutlineResource::Declarations,
|
||||
] {
|
||||
let mut lower = exact;
|
||||
match resource {
|
||||
OutlineResource::SourceBytes => lower.max_source_bytes -= 1,
|
||||
OutlineResource::Tokens => lower.max_tokens -= 1,
|
||||
OutlineResource::Declarations => lower.max_declarations -= 1,
|
||||
}
|
||||
let error = outline(source, lower).unwrap_err();
|
||||
assert_eq!(error.resource, resource);
|
||||
assert!(error.observed_at_least > error.limit);
|
||||
assert!(source.is_char_boundary(error.offset));
|
||||
if resource == OutlineResource::Declarations {
|
||||
assert_eq!(error.offset, source.find("oa_actor b").unwrap());
|
||||
}
|
||||
}
|
||||
let empty = outline(
|
||||
"",
|
||||
OutlineLimits {
|
||||
max_source_bytes: 0,
|
||||
max_tokens: 0,
|
||||
max_declarations: 0,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert!(empty.tokens.is_empty() && empty.declarations.is_empty());
|
||||
let unicode = "// 🌊\r\noa_actor a \"A\" {}";
|
||||
let error = outline(
|
||||
unicode,
|
||||
OutlineLimits {
|
||||
max_source_bytes: 4,
|
||||
..limits()
|
||||
},
|
||||
)
|
||||
.unwrap_err();
|
||||
assert_eq!(error.resource, OutlineResource::SourceBytes);
|
||||
assert_eq!(error.offset, 0);
|
||||
let error = outline(
|
||||
unicode,
|
||||
OutlineLimits {
|
||||
max_tokens: 1,
|
||||
..limits()
|
||||
},
|
||||
)
|
||||
.unwrap_err();
|
||||
assert_eq!(error.resource, OutlineResource::Tokens);
|
||||
assert_eq!(error.offset, unicode.find('\n').unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outline_ranges_follow_current_bytes_without_identity_stabilization() {
|
||||
let old = "oa_actor a \"A\" {}\nsource @a;";
|
||||
let current = format!("// 航海 🚒\r\n{old}");
|
||||
let old_outline = outline(old, limits()).unwrap();
|
||||
let current_outline = outline(¤t, limits()).unwrap();
|
||||
let shift = current.len() - old.len();
|
||||
let old_role = &old_outline.declarations[0];
|
||||
let current_role = ¤t_outline.declarations[0];
|
||||
assert_eq!(
|
||||
current_role.alias_span.start,
|
||||
old_role.alias_span.start + shift
|
||||
);
|
||||
assert_eq!(
|
||||
current_role.kind_span.start,
|
||||
old_role.kind_span.start + shift
|
||||
);
|
||||
assert_eq!(
|
||||
current_role.declaration.span.end,
|
||||
old_role.declaration.span.end + shift
|
||||
);
|
||||
assert_eq!(current_outline.source, current);
|
||||
assert_eq!(old_outline.source, old);
|
||||
assert_eq!(current_role.declaration.id, None);
|
||||
assert!(!current_outline.source.contains(" id "));
|
||||
// Workspace revision/stale-handle rejection belongs to the query/adapter,
|
||||
// not a claim that an old owned span can be applied to any other string.
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deeply_nested_bounds_use_the_same_iterative_walk_and_declaration_budget() {
|
||||
let mut source = String::new();
|
||||
for index in 0..600 {
|
||||
source.push_str(&format!("oa_activity work_{index} \"Work\" {{\n"));
|
||||
}
|
||||
source.push_str(&"}\n".repeat(600));
|
||||
let result = outline(&source, limits()).unwrap();
|
||||
assert_eq!(result.declarations.len(), 600);
|
||||
assert!(!result.has_unmatched_braces);
|
||||
assert_eq!(
|
||||
result.declarations[0].declaration.span.end,
|
||||
source.len() - 1
|
||||
);
|
||||
let error = outline(
|
||||
&source,
|
||||
OutlineLimits {
|
||||
max_declarations: 599,
|
||||
..limits()
|
||||
},
|
||||
)
|
||||
.unwrap_err();
|
||||
assert_eq!(error.resource, OutlineResource::Declarations);
|
||||
assert_eq!(error.observed_at_least, 600);
|
||||
assert_eq!(error.offset, source.find("oa_activity work_599").unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn noncanonical_uuid_spellings_have_no_outline_or_analysis_identity() {
|
||||
for raw in [
|
||||
"00000000000000000000000000000001",
|
||||
"{00000000-0000-0000-0000-000000000001}",
|
||||
"urn:uuid:00000000-0000-0000-0000-000000000001",
|
||||
] {
|
||||
assert!(
|
||||
Id::parse_str(raw).is_ok(),
|
||||
"The UUID crate alone is too permissive"
|
||||
);
|
||||
assert!(!has_uuid_header_shape(raw));
|
||||
let literal = format!("\"{raw}\"");
|
||||
let modern = format!("arcadia 7; project model \"Model\" id {literal} {{}}");
|
||||
let before = modern.clone();
|
||||
let result = outline(&modern, limits()).unwrap();
|
||||
let d = declaration(&result, "model");
|
||||
assert_eq!(d.declaration.id, None);
|
||||
assert_eq!(text(&modern, d.id_literal_span.unwrap()), literal);
|
||||
assert_eq!(modern, before);
|
||||
let errors = parse(&modern).unwrap_err();
|
||||
assert!(
|
||||
errors
|
||||
.iter()
|
||||
.any(|error| error.message.contains("8-4-4-4-12")
|
||||
&& error.span.start == modern.find(&literal).unwrap())
|
||||
);
|
||||
// Intentional compatibility correction: old analyze yielded Some(Id(1))
|
||||
// here despite lint rejecting the header. It now shares outline's None.
|
||||
let analysis = analyze(&modern);
|
||||
assert_eq!(
|
||||
analysis
|
||||
.declarations
|
||||
.iter()
|
||||
.find(|d| d.alias == "model")
|
||||
.unwrap()
|
||||
.id,
|
||||
None
|
||||
);
|
||||
assert!(
|
||||
analysis
|
||||
.diagnostics
|
||||
.iter()
|
||||
.any(|error| error.message.contains("8-4-4-4-12"))
|
||||
);
|
||||
let legacy_source = format!("arcadia 1; project \"Legacy\" id {literal} {{}}");
|
||||
let legacy_errors = legacy::parse(&legacy_source).unwrap_err();
|
||||
assert!(
|
||||
legacy_errors
|
||||
.iter()
|
||||
.any(|error| error.message.contains("8-4-4-4-12"))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uppercase_and_json_escaped_header_ids_survive_both_parsers_and_outline() {
|
||||
let expected = Id::from_u128(0xabcdef);
|
||||
let canonical = expected.to_string();
|
||||
let uppercase = canonical.to_uppercase();
|
||||
assert_ne!(uppercase, canonical);
|
||||
for raw in [uppercase.clone(), format!("\\u0030{}", &uppercase[1..])] {
|
||||
let literal = format!("\"{raw}\"");
|
||||
let decoded: String = serde_json::from_str(&literal).unwrap();
|
||||
assert!(has_uuid_header_shape(&decoded));
|
||||
let modern = format!("arcadia 7; project model \"Model\" id {literal} {{}}");
|
||||
let result = outline(&modern, limits()).unwrap();
|
||||
let d = declaration(&result, "model");
|
||||
assert_eq!(d.declaration.id, Some(expected));
|
||||
assert_eq!(text(&modern, d.id_literal_span.unwrap()), literal);
|
||||
assert_eq!(result.source.as_bytes(), modern.as_bytes());
|
||||
assert_eq!(parse(&modern).unwrap().id, expected);
|
||||
let analysis = analyze(&modern);
|
||||
assert_eq!(analysis.declarations[0].id, Some(expected));
|
||||
assert!(
|
||||
!analysis
|
||||
.diagnostics
|
||||
.iter()
|
||||
.any(|error| error.severity == crate::validation::Severity::Error)
|
||||
);
|
||||
let legacy_source = format!("arcadia 1; project \"Legacy\" id {literal} {{}}");
|
||||
assert_eq!(legacy::parse(&legacy_source).unwrap().id, expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn header_shape_still_requires_valid_hexadecimal_uuid_data() {
|
||||
let raw = "00000000-0000-0000-0000-00000000000Z";
|
||||
assert!(has_uuid_header_shape(raw));
|
||||
assert!(Id::parse_str(raw).is_err());
|
||||
let literal = format!("\"{raw}\"");
|
||||
let source = format!("arcadia 7; project model \"Model\" id {literal} {{}}");
|
||||
let result = outline(&source, limits()).unwrap();
|
||||
let d = declaration(&result, "model");
|
||||
assert_eq!(d.declaration.id, None);
|
||||
assert_eq!(text(&source, d.id_literal_span.unwrap()), literal);
|
||||
assert!(
|
||||
parse(&source)
|
||||
.unwrap_err()
|
||||
.iter()
|
||||
.any(|error| error.message == "Invalid UUID identity.")
|
||||
);
|
||||
assert_eq!(analyze(&source).declarations[0].id, None);
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
//! Framework-independent OA/SA model, transactional commands and editing services.
|
||||
pub mod analysis_numeric;
|
||||
pub mod data;
|
||||
pub mod language;
|
||||
pub mod model;
|
||||
pub mod operations;
|
||||
pub mod persistence;
|
||||
pub mod ports;
|
||||
pub mod projection;
|
||||
@@ -9,10 +11,13 @@ pub mod references;
|
||||
pub mod routing;
|
||||
pub mod semantic;
|
||||
pub mod session;
|
||||
mod source_defaults;
|
||||
pub mod source_files;
|
||||
pub mod source_project;
|
||||
pub mod topology_context;
|
||||
pub mod transitions;
|
||||
pub mod validation;
|
||||
pub mod view_options;
|
||||
pub use model::*;
|
||||
pub use ports::*;
|
||||
pub use semantic::*;
|
||||
|
||||
@@ -67,6 +67,8 @@ impl Project4 {
|
||||
if self.schema_version != 4 {
|
||||
return Err(FileError::Invalid("Expected schema_version 4.".into()));
|
||||
}
|
||||
crate::persistence::check_pre6_relations(self.relations.values())?;
|
||||
crate::persistence::check_pre6_elements(self.elements.values())?;
|
||||
Ok(Project {
|
||||
schema_version: SCHEMA_VERSION,
|
||||
id: self.id,
|
||||
@@ -102,6 +104,8 @@ pub(crate) fn review_defaults(project: &Project, report: &mut MigrationReport) {
|
||||
}
|
||||
|
||||
pub(crate) fn finish(mut project: Project, limits: ParseLimits) -> Result<DecodeResult, FileError> {
|
||||
crate::persistence::check_pre6_relations(project.relations.values())?;
|
||||
crate::persistence::check_pre6_elements(project.elements.values())?;
|
||||
project.schema_version = SCHEMA_VERSION;
|
||||
limits.check_model(&project).map_err(FileError::Limit)?;
|
||||
let mut report = MigrationReport::data_upgrade(4);
|
||||
|
||||
+145
-6
@@ -6,7 +6,7 @@ use std::{
|
||||
use uuid::Uuid;
|
||||
|
||||
pub type Id = Uuid;
|
||||
pub const SCHEMA_VERSION: u32 = 5;
|
||||
pub const SCHEMA_VERSION: u32 = 7;
|
||||
pub const MAX_COORDINATE: f32 = 1_000_000.;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
@@ -39,6 +39,7 @@ pub enum Kind {
|
||||
Mission,
|
||||
FunctionalChain,
|
||||
ExchangeItem,
|
||||
ExchangeCategory,
|
||||
DataType,
|
||||
DataField,
|
||||
Interface,
|
||||
@@ -65,7 +66,7 @@ pub enum Kind {
|
||||
Constraint,
|
||||
}
|
||||
impl Kind {
|
||||
pub const ALL: [Self; 38] = [
|
||||
pub const ALL: [Self; 39] = [
|
||||
Self::Entity,
|
||||
Self::Actor,
|
||||
Self::Role,
|
||||
@@ -80,6 +81,7 @@ impl Kind {
|
||||
Self::Mission,
|
||||
Self::FunctionalChain,
|
||||
Self::ExchangeItem,
|
||||
Self::ExchangeCategory,
|
||||
Self::DataType,
|
||||
Self::DataField,
|
||||
Self::Interface,
|
||||
@@ -121,6 +123,7 @@ impl Kind {
|
||||
Self::Mission => "Mission",
|
||||
Self::FunctionalChain => "Functional chain",
|
||||
Self::ExchangeItem => "Exchange item",
|
||||
Self::ExchangeCategory => "Exchange category",
|
||||
Self::DataType => "Data type",
|
||||
Self::DataField => "Data field",
|
||||
Self::Interface => "Interface",
|
||||
@@ -520,6 +523,28 @@ pub struct MessageValue {
|
||||
pub field: Id,
|
||||
pub expression: String,
|
||||
}
|
||||
/// Explicit operation intent. A missing allocation remains an unfinished operation,
|
||||
/// whereas an absent OperationUse retains ordinary channel-message semantics.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct OperationUse {
|
||||
pub allocation: Option<Id>,
|
||||
}
|
||||
/// A contribution in a caller instance. Paths contain reference occurrence IDs,
|
||||
/// never sequence definitions; repeated references therefore remain distinct.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct OccurrenceEndpoint {
|
||||
pub references: Vec<Id>,
|
||||
pub occurrence: Id,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub enum ContextualLinkPurpose {
|
||||
Precedence { guard: String },
|
||||
Exchange { exchange: Id },
|
||||
CommonWork { work: Id },
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub enum OccurrenceBody {
|
||||
@@ -533,6 +558,8 @@ pub enum OccurrenceBody {
|
||||
reply_to: Option<Id>,
|
||||
#[serde(default)]
|
||||
values: Vec<MessageValue>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
operation: Option<OperationUse>,
|
||||
},
|
||||
Action {
|
||||
action: Option<Id>,
|
||||
@@ -564,6 +591,10 @@ pub enum OccurrenceBody {
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub enum SharedBody {
|
||||
ExchangeCategory {
|
||||
family: crate::view_options::ExchangeFamily,
|
||||
exchanges: Vec<Id>,
|
||||
},
|
||||
ExchangeItem {
|
||||
kind: ExchangeItemKind,
|
||||
mechanism: ExchangeMechanism,
|
||||
@@ -754,6 +785,10 @@ impl ElementBody {
|
||||
default: None,
|
||||
unit: String::new(),
|
||||
},
|
||||
Kind::ExchangeCategory => SharedBody::ExchangeCategory {
|
||||
family: crate::view_options::ExchangeFamily::Work,
|
||||
exchanges: vec![],
|
||||
},
|
||||
Kind::DataField => SharedBody::DataField {
|
||||
type_ref: TypeRef::String,
|
||||
minimum: 1,
|
||||
@@ -786,6 +821,7 @@ impl ElementBody {
|
||||
message_kind: MessageKind::Asynchronous,
|
||||
reply_to: None,
|
||||
values: vec![],
|
||||
operation: None,
|
||||
},
|
||||
precondition: String::new(),
|
||||
postcondition: String::new(),
|
||||
@@ -815,6 +851,7 @@ impl SharedBody {
|
||||
pub fn kind(&self) -> Kind {
|
||||
match self {
|
||||
Self::ExchangeItem { .. } => Kind::ExchangeItem,
|
||||
Self::ExchangeCategory { .. } => Kind::ExchangeCategory,
|
||||
Self::DataType { .. } => Kind::DataType,
|
||||
Self::DataField { .. } => Kind::DataField,
|
||||
Self::Interface { .. } => Kind::Interface,
|
||||
@@ -844,12 +881,14 @@ pub enum RelationKind {
|
||||
RoleAssignment,
|
||||
FunctionalExchange,
|
||||
BoundaryExchange,
|
||||
CommunicationMean,
|
||||
MissionCapability,
|
||||
CapabilityInclude,
|
||||
CapabilityExtend,
|
||||
CapabilityGeneralization,
|
||||
MissionInvolvement,
|
||||
SequenceLink,
|
||||
ContextualLink,
|
||||
StateTransition,
|
||||
Availability,
|
||||
ExchangeAllocation,
|
||||
@@ -858,7 +897,7 @@ pub enum RelationKind {
|
||||
StateAction,
|
||||
}
|
||||
impl RelationKind {
|
||||
pub const ALL: [Self; 23] = [
|
||||
pub const ALL: [Self; 25] = [
|
||||
Self::InterfaceItemAllocation,
|
||||
Self::DataAssociation,
|
||||
Self::Allocation,
|
||||
@@ -870,12 +909,14 @@ impl RelationKind {
|
||||
Self::RoleAssignment,
|
||||
Self::FunctionalExchange,
|
||||
Self::BoundaryExchange,
|
||||
Self::CommunicationMean,
|
||||
Self::MissionCapability,
|
||||
Self::CapabilityInclude,
|
||||
Self::CapabilityExtend,
|
||||
Self::CapabilityGeneralization,
|
||||
Self::MissionInvolvement,
|
||||
Self::SequenceLink,
|
||||
Self::ContextualLink,
|
||||
Self::StateTransition,
|
||||
Self::Availability,
|
||||
Self::ExchangeAllocation,
|
||||
@@ -902,6 +943,10 @@ impl RelationKind {
|
||||
Self::RoleAssignment => from == Kind::Role && to == Kind::Activity,
|
||||
Self::FunctionalExchange => from == Kind::Function && to == Kind::Function,
|
||||
Self::BoundaryExchange => from == Kind::Port && to == Kind::Port,
|
||||
Self::CommunicationMean => {
|
||||
matches!(from, Kind::Entity | Kind::Actor)
|
||||
&& matches!(to, Kind::Entity | Kind::Actor)
|
||||
}
|
||||
Self::MissionCapability => matches!(
|
||||
(from, to),
|
||||
(Kind::OperationalMission, Kind::Capability)
|
||||
@@ -919,7 +964,9 @@ impl RelationKind {
|
||||
(Kind::Entity | Kind::Actor, Kind::OperationalMission)
|
||||
| (Kind::System | Kind::SystemActor, Kind::Mission)
|
||||
),
|
||||
Self::SequenceLink => from == Kind::Occurrence && to == Kind::Occurrence,
|
||||
Self::SequenceLink | Self::ContextualLink => {
|
||||
from == Kind::Occurrence && to == Kind::Occurrence
|
||||
}
|
||||
Self::StateTransition => from.is_state() && to.is_state(),
|
||||
Self::ExchangeAllocation => false,
|
||||
Self::InterfaceProvision | Self::InterfaceRequirement => {
|
||||
@@ -953,6 +1000,7 @@ impl RelationKind {
|
||||
Self::RoleAssignment => "OA role → activity",
|
||||
Self::FunctionalExchange => "SA function → SA function",
|
||||
Self::BoundaryExchange => "Output port → input port",
|
||||
Self::CommunicationMean => "OA entity/actor → OA entity/actor",
|
||||
Self::MissionCapability => "Mission → capability in the same perspective",
|
||||
Self::CapabilityInclude => "Including capability → included capability",
|
||||
Self::CapabilityExtend => {
|
||||
@@ -961,9 +1009,12 @@ impl RelationKind {
|
||||
Self::CapabilityGeneralization => "Specific capability → general capability",
|
||||
Self::MissionInvolvement => "Participant → mission in the same perspective",
|
||||
Self::SequenceLink => "Preceding → following contribution in the same process or chain",
|
||||
Self::ContextualLink => {
|
||||
"Contributions addressed in an explicit process or chain caller"
|
||||
}
|
||||
Self::StateTransition => "State → state in one machine",
|
||||
Self::ExchangeAllocation => {
|
||||
"Boundary exchange → operational interaction / functional exchange"
|
||||
"Communication means / boundary exchange → operational interaction / functional exchange"
|
||||
}
|
||||
Self::InterfaceProvision => "Participant/port → provided interface",
|
||||
Self::InterfaceRequirement => "Participant/port → required interface",
|
||||
@@ -986,12 +1037,14 @@ impl fmt::Display for RelationKind {
|
||||
Self::RoleAssignment => "Role assignment",
|
||||
Self::FunctionalExchange => "Functional exchange",
|
||||
Self::BoundaryExchange => "Boundary exchange",
|
||||
Self::CommunicationMean => "Communication means",
|
||||
Self::MissionCapability => "Mission capability",
|
||||
Self::CapabilityInclude => "Capability include",
|
||||
Self::CapabilityExtend => "Capability extend",
|
||||
Self::CapabilityGeneralization => "Capability generalization",
|
||||
Self::MissionInvolvement => "Mission involvement",
|
||||
Self::SequenceLink => "Sequence link",
|
||||
Self::ContextualLink => "Contextual link",
|
||||
Self::StateTransition => "State transition",
|
||||
Self::ExchangeAllocation => "Exchange allocation",
|
||||
Self::InterfaceProvision => "Interface provision",
|
||||
@@ -1074,6 +1127,12 @@ pub enum RelationBody {
|
||||
oriented: bool,
|
||||
items: Vec<Id>,
|
||||
},
|
||||
CommunicationMean {
|
||||
source: Id,
|
||||
target: Id,
|
||||
oriented: bool,
|
||||
items: Vec<Id>,
|
||||
},
|
||||
MissionCapability {
|
||||
source: Id,
|
||||
target: Id,
|
||||
@@ -1100,6 +1159,12 @@ pub enum RelationBody {
|
||||
target: Id,
|
||||
guard: String,
|
||||
},
|
||||
ContextualLink {
|
||||
owner: Id,
|
||||
source: OccurrenceEndpoint,
|
||||
target: OccurrenceEndpoint,
|
||||
purpose: ContextualLinkPurpose,
|
||||
},
|
||||
StateTransition {
|
||||
source: Id,
|
||||
target: Id,
|
||||
@@ -1136,6 +1201,13 @@ pub enum RelationBody {
|
||||
},
|
||||
}
|
||||
impl Relation {
|
||||
/// Caller ownership cannot be inferred from a contextual link's leaf IDs.
|
||||
pub fn contextual_owner(&self) -> Option<Id> {
|
||||
match self.body {
|
||||
RelationBody::ContextualLink { owner, .. } => Some(owner),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
pub fn kind(&self) -> RelationKind {
|
||||
self.body.kind()
|
||||
}
|
||||
@@ -1160,12 +1232,14 @@ impl RelationBody {
|
||||
Self::RoleAssignment { .. } => RelationKind::RoleAssignment,
|
||||
Self::FunctionalExchange { .. } => RelationKind::FunctionalExchange,
|
||||
Self::BoundaryExchange { .. } => RelationKind::BoundaryExchange,
|
||||
Self::CommunicationMean { .. } => RelationKind::CommunicationMean,
|
||||
Self::MissionCapability { .. } => RelationKind::MissionCapability,
|
||||
Self::CapabilityInclude { .. } => RelationKind::CapabilityInclude,
|
||||
Self::CapabilityExtend { .. } => RelationKind::CapabilityExtend,
|
||||
Self::CapabilityGeneralization { .. } => RelationKind::CapabilityGeneralization,
|
||||
Self::MissionInvolvement { .. } => RelationKind::MissionInvolvement,
|
||||
Self::SequenceLink { .. } => RelationKind::SequenceLink,
|
||||
Self::ContextualLink { .. } => RelationKind::ContextualLink,
|
||||
Self::StateTransition { .. } => RelationKind::StateTransition,
|
||||
Self::ExchangeAllocation { .. } => RelationKind::ExchangeAllocation,
|
||||
Self::InterfaceProvision { .. } => RelationKind::InterfaceProvision,
|
||||
@@ -1176,6 +1250,7 @@ impl RelationBody {
|
||||
}
|
||||
pub fn endpoints(&self) -> (Id, Id) {
|
||||
match self {
|
||||
Self::ContextualLink { source, target, .. } => (source.occurrence, target.occurrence),
|
||||
Self::InterfaceItemAllocation { source, target, .. }
|
||||
| Self::DataAssociation { source, target, .. }
|
||||
| Self::Allocation { source, target }
|
||||
@@ -1187,6 +1262,7 @@ impl RelationBody {
|
||||
| Self::RoleAssignment { source, target }
|
||||
| Self::FunctionalExchange { source, target, .. }
|
||||
| Self::BoundaryExchange { source, target, .. }
|
||||
| Self::CommunicationMean { source, target, .. }
|
||||
| Self::MissionCapability { source, target }
|
||||
| Self::CapabilityInclude { source, target }
|
||||
| Self::CapabilityExtend { source, target, .. }
|
||||
@@ -1203,6 +1279,10 @@ impl RelationBody {
|
||||
}
|
||||
pub fn set_endpoints(&mut self, a: Id, b: Id) {
|
||||
match self {
|
||||
Self::ContextualLink { source, target, .. } => {
|
||||
source.occurrence = a;
|
||||
target.occurrence = b;
|
||||
}
|
||||
Self::InterfaceItemAllocation { source, target, .. }
|
||||
| Self::DataAssociation { source, target, .. }
|
||||
| Self::Allocation { source, target }
|
||||
@@ -1214,6 +1294,7 @@ impl RelationBody {
|
||||
| Self::RoleAssignment { source, target }
|
||||
| Self::FunctionalExchange { source, target, .. }
|
||||
| Self::BoundaryExchange { source, target, .. }
|
||||
| Self::CommunicationMean { source, target, .. }
|
||||
| Self::MissionCapability { source, target }
|
||||
| Self::CapabilityInclude { source, target }
|
||||
| Self::CapabilityExtend { source, target, .. }
|
||||
@@ -1235,7 +1316,8 @@ impl RelationBody {
|
||||
match self {
|
||||
Self::Interaction { items, .. }
|
||||
| Self::FunctionalExchange { items, .. }
|
||||
| Self::BoundaryExchange { items, .. } => items,
|
||||
| Self::BoundaryExchange { items, .. }
|
||||
| Self::CommunicationMean { items, .. } => items,
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
@@ -1285,6 +1367,12 @@ impl RelationBody {
|
||||
oriented: true,
|
||||
items: vec![],
|
||||
},
|
||||
RelationKind::CommunicationMean => Self::CommunicationMean {
|
||||
source,
|
||||
target,
|
||||
oriented: false,
|
||||
items: vec![],
|
||||
},
|
||||
RelationKind::MissionCapability => Self::MissionCapability { source, target },
|
||||
RelationKind::CapabilityInclude => Self::CapabilityInclude { source, target },
|
||||
RelationKind::CapabilityExtend => Self::CapabilityExtend {
|
||||
@@ -1301,6 +1389,20 @@ impl RelationBody {
|
||||
target,
|
||||
guard: String::new(),
|
||||
},
|
||||
RelationKind::ContextualLink => Self::ContextualLink {
|
||||
owner: Id::nil(),
|
||||
source: OccurrenceEndpoint {
|
||||
references: vec![],
|
||||
occurrence: source,
|
||||
},
|
||||
target: OccurrenceEndpoint {
|
||||
references: vec![],
|
||||
occurrence: target,
|
||||
},
|
||||
purpose: ContextualLinkPurpose::Precedence {
|
||||
guard: String::new(),
|
||||
},
|
||||
},
|
||||
RelationKind::StateTransition => Self::StateTransition {
|
||||
source,
|
||||
target,
|
||||
@@ -1387,6 +1489,8 @@ pub struct Diagram {
|
||||
pub description: String,
|
||||
pub layer: Layer,
|
||||
pub projection: ProjectionKind,
|
||||
#[serde(default)]
|
||||
pub view_options: crate::view_options::ViewOptions,
|
||||
#[serde(deserialize_with = "unique_id_map")]
|
||||
pub nodes: BTreeMap<Id, NodePlacement>,
|
||||
#[serde(default)]
|
||||
@@ -1398,11 +1502,45 @@ pub struct Diagram {
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct EdgeRoute {
|
||||
pub edge: Id,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub source_attachment: Option<EdgeAttachment>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub target_attachment: Option<EdgeAttachment>,
|
||||
#[serde(default)]
|
||||
pub waypoints: Vec<Position>,
|
||||
#[serde(default)]
|
||||
pub label: Option<EdgeLabel>,
|
||||
}
|
||||
|
||||
/// Presentation preference on one end of a connection. Real ports take precedence.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct EdgeAttachment {
|
||||
pub side: PortAnchorSide,
|
||||
pub offset: f32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum EdgeEnd {
|
||||
Source,
|
||||
Target,
|
||||
}
|
||||
|
||||
impl EdgeRoute {
|
||||
pub fn attachment(&self, end: EdgeEnd) -> Option<EdgeAttachment> {
|
||||
match end {
|
||||
EdgeEnd::Source => self.source_attachment,
|
||||
EdgeEnd::Target => self.target_attachment,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_attachment(&mut self, end: EdgeEnd, attachment: Option<EdgeAttachment>) {
|
||||
match end {
|
||||
EdgeEnd::Source => self.source_attachment = attachment,
|
||||
EdgeEnd::Target => self.target_attachment = attachment,
|
||||
}
|
||||
}
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct EdgeLabel {
|
||||
@@ -1814,6 +1952,7 @@ impl Project {
|
||||
description: String::new(),
|
||||
layer,
|
||||
projection: ProjectionKind::Overview,
|
||||
view_options: crate::view_options::ViewOptions::default(),
|
||||
nodes: BTreeMap::new(),
|
||||
routes: vec![],
|
||||
},
|
||||
|
||||
@@ -0,0 +1,345 @@
|
||||
//! One operation resolver for admission, forms and scenario labels.
|
||||
use crate::*;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ResolvedOperation {
|
||||
/// The actual Call/Asynchronous declaration, also when resolving its Reply.
|
||||
pub invocation: Id,
|
||||
pub allocation: Id,
|
||||
pub interface: Id,
|
||||
pub item: Id,
|
||||
pub caller_port: Id,
|
||||
pub provider_port: Id,
|
||||
pub transmission: TransmissionProtocol,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum OperationResolution {
|
||||
Ordinary,
|
||||
Unresolved { invocation: Id, reason: String },
|
||||
Selected(ResolvedOperation),
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
||||
#[error("{message}")]
|
||||
pub struct OperationError {
|
||||
pub code: &'static str,
|
||||
pub message: String,
|
||||
}
|
||||
fn error(code: &'static str, message: &str) -> OperationError {
|
||||
OperationError {
|
||||
code,
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn resolve(p: &Project, occurrence: Id) -> Result<OperationResolution, OperationError> {
|
||||
let e = p
|
||||
.elements
|
||||
.get(&occurrence)
|
||||
.ok_or_else(|| error("operation_context", "The message no longer exists."))?;
|
||||
let Some(SharedBody::Occurrence {
|
||||
body:
|
||||
OccurrenceBody::Exchange {
|
||||
operation,
|
||||
message_kind,
|
||||
reply_to,
|
||||
..
|
||||
},
|
||||
..
|
||||
}) = e.shared()
|
||||
else {
|
||||
return Ok(OperationResolution::Ordinary);
|
||||
};
|
||||
let invocation = if *message_kind == MessageKind::Reply {
|
||||
if operation.is_some() {
|
||||
return Err(error(
|
||||
"reply_operation",
|
||||
"A Reply derives its operation from its exact Call; it cannot store a second selection.",
|
||||
));
|
||||
}
|
||||
let Some(call) = reply_to else {
|
||||
return Ok(OperationResolution::Ordinary);
|
||||
};
|
||||
if !matches!(
|
||||
p.elements.get(call).and_then(Element::shared),
|
||||
Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange {
|
||||
message_kind: MessageKind::Call,
|
||||
..
|
||||
},
|
||||
..
|
||||
})
|
||||
) {
|
||||
return Ok(OperationResolution::Ordinary);
|
||||
}
|
||||
*call
|
||||
} else {
|
||||
occurrence
|
||||
};
|
||||
let Some(SharedBody::Occurrence {
|
||||
body:
|
||||
body @ OccurrenceBody::Exchange {
|
||||
operation: Some(operation),
|
||||
message_kind,
|
||||
exchange,
|
||||
sender,
|
||||
receiver,
|
||||
..
|
||||
},
|
||||
..
|
||||
}) = p.elements.get(&invocation).and_then(Element::shared)
|
||||
else {
|
||||
return Ok(OperationResolution::Ordinary);
|
||||
};
|
||||
if p.layer_of(invocation) != Some(Layer::Sa)
|
||||
|| !matches!(message_kind, MessageKind::Call | MessageKind::Asynchronous)
|
||||
|| validation::ancestor_of_kind(p, invocation, Kind::Scenario)
|
||||
.and_then(|id| p.elements.get(&id))
|
||||
.and_then(Element::shared)
|
||||
.is_none_or(|body| {
|
||||
!matches!(
|
||||
body,
|
||||
SharedBody::Scenario {
|
||||
viewpoint: ScenarioViewpoint::BoundaryInterface,
|
||||
..
|
||||
}
|
||||
)
|
||||
})
|
||||
{
|
||||
return Err(error(
|
||||
"operation_context",
|
||||
"Operation use belongs to a Call or Asynchronous message in an SA BoundaryInterface scenario.",
|
||||
));
|
||||
}
|
||||
let unresolved = |reason: &str| {
|
||||
Ok(OperationResolution::Unresolved {
|
||||
invocation,
|
||||
reason: reason.into(),
|
||||
})
|
||||
};
|
||||
if exchange
|
||||
.and_then(|id| p.relations.get(&id))
|
||||
.is_some_and(|r| r.kind() != RelationKind::BoundaryExchange)
|
||||
{
|
||||
return Err(error(
|
||||
"operation_channel",
|
||||
"Operation use requires an explicit boundary exchange channel.",
|
||||
));
|
||||
}
|
||||
let Some(allocation) = operation.allocation else {
|
||||
return unresolved(
|
||||
"Choose the interface operation allocation; existing value expressions are preserved.",
|
||||
);
|
||||
};
|
||||
let Some(relation) = p.relations.get(&allocation) else {
|
||||
return Err(error(
|
||||
"operation_allocation",
|
||||
"The selected interface operation allocation no longer exists.",
|
||||
));
|
||||
};
|
||||
let RelationBody::InterfaceItemAllocation {
|
||||
source: interface,
|
||||
target: item,
|
||||
transmission,
|
||||
..
|
||||
} = relation.body
|
||||
else {
|
||||
return Err(error(
|
||||
"operation_allocation",
|
||||
"Choose an InterfaceItemAllocation, not an item or another relationship.",
|
||||
));
|
||||
};
|
||||
if p.layer_of(allocation) != Some(Layer::Sa)
|
||||
|| !matches!(
|
||||
p.elements.get(&item).and_then(Element::shared),
|
||||
Some(SharedBody::ExchangeItem {
|
||||
kind: ExchangeItemKind::Information,
|
||||
mechanism: ExchangeMechanism::Operation,
|
||||
..
|
||||
})
|
||||
)
|
||||
{
|
||||
return Err(error(
|
||||
"operation_allocation",
|
||||
"The selected SA allocation must declare an Information item with Operation mechanism.",
|
||||
));
|
||||
}
|
||||
let Some(channel) = exchange.and_then(|id| p.relations.get(&id)) else {
|
||||
return Err(error(
|
||||
"operation_channel",
|
||||
"A selected operation requires an existing boundary exchange carrying its item; clear the allocation to retain unresolved intent.",
|
||||
));
|
||||
};
|
||||
if channel.kind() != RelationKind::BoundaryExchange
|
||||
|| !channel
|
||||
.body
|
||||
.items()
|
||||
.iter()
|
||||
.any(|carried| data::item_satisfies(p, *carried, item))
|
||||
{
|
||||
return Err(error(
|
||||
"operation_channel",
|
||||
"The boundary exchange must carry the selected operation item or an admitted subtype.",
|
||||
));
|
||||
}
|
||||
if sender.is_none() || receiver.is_none() {
|
||||
return Err(error(
|
||||
"operation_lifelines",
|
||||
"A selected operation requires actual caller and provider lifelines; clear the allocation to retain unresolved intent.",
|
||||
));
|
||||
}
|
||||
let (caller_port, provider_port) = match validation::resolve_message_endpoints(p, body) {
|
||||
validation::MessageEndpointResolution::Resolved { source, target } => (source, target),
|
||||
validation::MessageEndpointResolution::Conflict => {
|
||||
return Err(error(
|
||||
"operation_direction",
|
||||
"The selected lifelines conflict with the boundary channel's actual caller/provider direction.",
|
||||
));
|
||||
}
|
||||
_ => {
|
||||
return Err(error(
|
||||
"operation_direction",
|
||||
"A selected operation requires a proven actual caller/provider channel direction; clear the allocation to retain unresolved intent.",
|
||||
));
|
||||
}
|
||||
};
|
||||
for port in [caller_port, provider_port] {
|
||||
if !matches!(
|
||||
p.elements.get(&port).and_then(ports::port_contract),
|
||||
Some(PortContract::Component {
|
||||
kind: ComponentPortKind::Standard,
|
||||
..
|
||||
})
|
||||
) {
|
||||
return Err(error(
|
||||
"operation_ports",
|
||||
"Operation proof requires concrete Standard component ports.",
|
||||
));
|
||||
}
|
||||
}
|
||||
let exposes = |interface| {
|
||||
data::effective_interface_allocations(p, interface)
|
||||
.iter()
|
||||
.any(|r| r.id == allocation)
|
||||
};
|
||||
let providers: Vec<_> = p
|
||||
.relations
|
||||
.values()
|
||||
.filter(|r| {
|
||||
r.kind() == RelationKind::InterfaceProvision
|
||||
&& r.source() == provider_port
|
||||
&& exposes(r.target())
|
||||
})
|
||||
.map(Relation::target)
|
||||
.collect();
|
||||
let valid = p.relations.values().any(|r| {
|
||||
r.kind() == RelationKind::InterfaceRequirement
|
||||
&& r.source() == caller_port
|
||||
&& exposes(r.target())
|
||||
&& providers
|
||||
.iter()
|
||||
.any(|provided| data::interface_satisfies(p, *provided, r.target()))
|
||||
});
|
||||
if !valid {
|
||||
return Err(error(
|
||||
"operation_roles",
|
||||
"The actual receiver port must provide, and sender port require, compatible interfaces exposing this exact allocation.",
|
||||
));
|
||||
}
|
||||
if !matches!(
|
||||
(message_kind, transmission),
|
||||
(_, TransmissionProtocol::Unspecified)
|
||||
| (MessageKind::Call, TransmissionProtocol::Synchronous)
|
||||
| (
|
||||
MessageKind::Asynchronous,
|
||||
TransmissionProtocol::Asynchronous
|
||||
)
|
||||
) {
|
||||
return Err(error(
|
||||
"operation_transmission",
|
||||
"Calls require synchronous transmission; asynchronous operation messages require asynchronous transmission.",
|
||||
));
|
||||
}
|
||||
let selected = ResolvedOperation {
|
||||
invocation,
|
||||
allocation,
|
||||
interface,
|
||||
item,
|
||||
caller_port,
|
||||
provider_port,
|
||||
transmission,
|
||||
};
|
||||
if occurrence != invocation {
|
||||
let Some(SharedBody::Occurrence {
|
||||
body:
|
||||
OccurrenceBody::Exchange {
|
||||
exchange: reply_exchange,
|
||||
..
|
||||
},
|
||||
..
|
||||
}) = e.shared()
|
||||
else {
|
||||
unreachable!()
|
||||
};
|
||||
if let Some(response) = reply_exchange.and_then(|id| p.relations.get(&id))
|
||||
&& response.id != channel.id
|
||||
&& !response
|
||||
.body
|
||||
.items()
|
||||
.iter()
|
||||
.any(|carried| data::item_satisfies(p, *carried, item))
|
||||
{
|
||||
return Err(error(
|
||||
"operation_reply_channel",
|
||||
"A separate reply channel must carry the Call's selected operation item or an admitted subtype, including parameterless returns.",
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(OperationResolution::Selected(selected))
|
||||
}
|
||||
|
||||
/// Valid selections under the proposed message context, without mutating the model.
|
||||
pub fn eligible_allocations(p: &Project, occurrence: Id) -> Vec<Id> {
|
||||
let mut candidate = p.clone();
|
||||
let ids: Vec<_> = p
|
||||
.relations
|
||||
.values()
|
||||
.filter(|r| r.kind() == RelationKind::InterfaceItemAllocation)
|
||||
.map(|r| r.id)
|
||||
.collect();
|
||||
ids.into_iter()
|
||||
.filter(|id| {
|
||||
let Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange { operation, .. },
|
||||
..
|
||||
}) = candidate
|
||||
.elements
|
||||
.get_mut(&occurrence)
|
||||
.and_then(Element::shared_mut)
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
*operation = Some(OperationUse {
|
||||
allocation: Some(*id),
|
||||
});
|
||||
matches!(
|
||||
resolve(&candidate, occurrence),
|
||||
Ok(OperationResolution::Selected(_))
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Reuse the existing bounded field-path engine with exactly one selected item.
|
||||
pub fn signature_relation(operation: &ResolvedOperation) -> Relation {
|
||||
Relation {
|
||||
id: operation.allocation,
|
||||
alias: String::new(),
|
||||
name: String::new(),
|
||||
description: String::new(),
|
||||
body: RelationBody::Interaction {
|
||||
source: operation.caller_port,
|
||||
target: operation.provider_port,
|
||||
items: vec![operation.item],
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -139,6 +139,43 @@ pub fn decode_with_report_limited(
|
||||
}
|
||||
let decoded = if bytes.iter().find(|b| !b.is_ascii_whitespace()) == Some(&b'{') {
|
||||
let header: serde_json::Value = serde_json::from_slice(bytes)?;
|
||||
if header
|
||||
.get("schema_version")
|
||||
.and_then(serde_json::Value::as_u64)
|
||||
.is_some_and(|v| v <= 6)
|
||||
{
|
||||
reject_source7_fields(&header).map_err(FileError::Invalid)?;
|
||||
}
|
||||
if header
|
||||
.get("schema_version")
|
||||
.and_then(serde_json::Value::as_u64)
|
||||
.is_some_and(|v| v <= 5)
|
||||
&& let Some(diagrams) = header
|
||||
.get("diagrams")
|
||||
.and_then(serde_json::Value::as_object)
|
||||
{
|
||||
for diagram in diagrams.values().filter_map(serde_json::Value::as_object) {
|
||||
for key in diagram.keys() {
|
||||
if key == "view_options" {
|
||||
return Err(FileError::Invalid("Diagram.view_options requires schema/source 6, including an explicitly supplied default value.".into()));
|
||||
}
|
||||
if !matches!(
|
||||
key.as_str(),
|
||||
"id" | "alias"
|
||||
| "name"
|
||||
| "description"
|
||||
| "layer"
|
||||
| "projection"
|
||||
| "nodes"
|
||||
| "routes"
|
||||
) {
|
||||
return Err(FileError::Invalid(format!(
|
||||
"Unknown diagram field '{key}' in the frozen pre6 contract."
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
match header
|
||||
.get("schema_version")
|
||||
.and_then(serde_json::Value::as_u64)
|
||||
@@ -151,13 +188,15 @@ pub fn decode_with_report_limited(
|
||||
Some(2) => migrate_v2_limited(serde_json::from_slice(bytes)?, limits)?,
|
||||
Some(3) => v3::migrate(serde_json::from_slice(bytes)?, limits)?,
|
||||
Some(4) => v4::migrate(serde_json::from_slice(bytes)?, limits)?,
|
||||
Some(5) => DecodeResult {
|
||||
Some(5) => migrate_v5(serde_json::from_slice(bytes)?, limits)?,
|
||||
Some(6) => migrate_v6(serde_json::from_slice(bytes)?, limits)?,
|
||||
Some(7) => DecodeResult {
|
||||
project: serde_json::from_slice(bytes)?,
|
||||
migration: None,
|
||||
},
|
||||
_ => {
|
||||
return Err(FileError::Invalid(
|
||||
"Unsupported or missing schema_version; expected 1, 2, 3, 4 or 5.".into(),
|
||||
"Unsupported or missing schema_version; expected 1, 2, 3, 4, 5, 6 or 7.".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -186,6 +225,210 @@ pub fn decode_with_report_limited(
|
||||
Ok(decoded)
|
||||
}
|
||||
|
||||
/// Versions 4 and 5 share unchanged typed records, but their constructor set
|
||||
/// remains frozen. Apply this before normalizing the advertised version.
|
||||
pub(crate) fn pre6_relation_kind(kind: RelationKind) -> bool {
|
||||
matches!(
|
||||
kind,
|
||||
RelationKind::InterfaceItemAllocation
|
||||
| RelationKind::DataAssociation
|
||||
| RelationKind::Allocation
|
||||
| RelationKind::PortAllocation
|
||||
| RelationKind::Interaction
|
||||
| RelationKind::CapabilityInvolvement
|
||||
| RelationKind::ChainInvolvement
|
||||
| RelationKind::RoleRealization
|
||||
| RelationKind::RoleAssignment
|
||||
| RelationKind::FunctionalExchange
|
||||
| RelationKind::BoundaryExchange
|
||||
| RelationKind::MissionCapability
|
||||
| RelationKind::CapabilityInclude
|
||||
| RelationKind::CapabilityExtend
|
||||
| RelationKind::CapabilityGeneralization
|
||||
| RelationKind::MissionInvolvement
|
||||
| RelationKind::SequenceLink
|
||||
| RelationKind::StateTransition
|
||||
| RelationKind::Availability
|
||||
| RelationKind::ExchangeAllocation
|
||||
| RelationKind::InterfaceProvision
|
||||
| RelationKind::InterfaceRequirement
|
||||
| RelationKind::StateAction
|
||||
)
|
||||
}
|
||||
pub(crate) fn pre6_element_kind(kind: Kind) -> bool {
|
||||
matches!(
|
||||
kind,
|
||||
Kind::Entity
|
||||
| Kind::Actor
|
||||
| Kind::Role
|
||||
| Kind::Activity
|
||||
| Kind::Capability
|
||||
| Kind::OperationalMission
|
||||
| Kind::Process
|
||||
| Kind::System
|
||||
| Kind::SystemActor
|
||||
| Kind::Function
|
||||
| Kind::SystemCapability
|
||||
| Kind::Mission
|
||||
| Kind::FunctionalChain
|
||||
| Kind::ExchangeItem
|
||||
| Kind::DataType
|
||||
| Kind::DataField
|
||||
| Kind::Interface
|
||||
| Kind::Port
|
||||
| Kind::Scenario
|
||||
| Kind::Lifeline
|
||||
| Kind::Occurrence
|
||||
| Kind::StateMachine
|
||||
| Kind::Region
|
||||
| Kind::State
|
||||
| Kind::Mode
|
||||
| Kind::Initial
|
||||
| Kind::Final
|
||||
| Kind::Choice
|
||||
| Kind::Junction
|
||||
| Kind::Fork
|
||||
| Kind::Join
|
||||
| Kind::ShallowHistory
|
||||
| Kind::DeepHistory
|
||||
| Kind::EntryPoint
|
||||
| Kind::ExitPoint
|
||||
| Kind::Terminate
|
||||
| Kind::Event
|
||||
| Kind::Constraint
|
||||
)
|
||||
}
|
||||
pub(crate) fn check_pre6_relations<'a>(
|
||||
relations: impl Iterator<Item = &'a Relation>,
|
||||
) -> Result<(), FileError> {
|
||||
for relation in relations {
|
||||
if !pre6_relation_kind(relation.kind()) {
|
||||
return Err(FileError::Invalid(format!(
|
||||
"{:?} requires schema/source 6.",
|
||||
relation.kind()
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub(crate) fn check_pre6_elements<'a>(
|
||||
elements: impl Iterator<Item = &'a Element>,
|
||||
) -> Result<(), FileError> {
|
||||
for element in elements {
|
||||
if !pre6_element_kind(element.kind()) {
|
||||
return Err(FileError::Invalid(format!(
|
||||
"{:?} requires schema/source 6.",
|
||||
element.kind()
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub(crate) fn check_pre6_baseline(baseline: &SemanticBaseline) -> Result<(), FileError> {
|
||||
if baseline.facets.iter().any(|f| {
|
||||
matches!(
|
||||
f,
|
||||
FacetReview::Category(_)
|
||||
| FacetReview::Member {
|
||||
collection: ReferenceCollection::CategoryExchanges,
|
||||
..
|
||||
}
|
||||
)
|
||||
}) {
|
||||
return Err(FileError::Invalid(
|
||||
"Exchange category semantic facts require schema/source 6.".into(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub(crate) fn migrate_v5(
|
||||
mut project: Project,
|
||||
limits: crate::language::ParseLimits,
|
||||
) -> Result<DecodeResult, FileError> {
|
||||
if project.schema_version != 5 {
|
||||
return Err(FileError::Invalid("Expected schema_version 5.".into()));
|
||||
}
|
||||
check_pre6_relations(project.relations.values())?;
|
||||
check_pre6_elements(project.elements.values())?;
|
||||
for baseline in project
|
||||
.traces
|
||||
.values()
|
||||
.filter_map(|t| t.baseline.as_ref().and_then(|b| b.semantic.as_ref()))
|
||||
{
|
||||
check_pre6_baseline(baseline)?;
|
||||
}
|
||||
limits.check_model(&project).map_err(FileError::Limit)?;
|
||||
project.schema_version = SCHEMA_VERSION;
|
||||
Ok(DecodeResult {
|
||||
project,
|
||||
migration: Some(MigrationReport::data_upgrade(5)),
|
||||
})
|
||||
}
|
||||
|
||||
/// Reject explicit new fields before serde defaults can erase their presence.
|
||||
/// This is also applied to source's resolved structured values.
|
||||
pub(crate) fn reject_source7_fields(value: &serde_json::Value) -> Result<(), String> {
|
||||
match value {
|
||||
serde_json::Value::Object(fields) => {
|
||||
if fields.contains_key("ContextualLink") {
|
||||
return Err("ContextualLink requires schema/source 7.".into());
|
||||
}
|
||||
if fields.get("Relation").and_then(serde_json::Value::as_str) == Some("ContextualLink")
|
||||
{
|
||||
return Err("ContextualLink view selectors require schema/source 7.".into());
|
||||
}
|
||||
if fields.keys().any(|key| {
|
||||
matches!(
|
||||
key.as_str(),
|
||||
"Operation"
|
||||
| "ContextOwner"
|
||||
| "ContextSource"
|
||||
| "ContextTarget"
|
||||
| "ContextPurpose"
|
||||
)
|
||||
}) {
|
||||
return Err(
|
||||
"Operation/contextual-link semantic facets require schema/source 7.".into(),
|
||||
);
|
||||
}
|
||||
if fields
|
||||
.get("Exchange")
|
||||
.and_then(serde_json::Value::as_object)
|
||||
.is_some_and(|body| body.contains_key("operation"))
|
||||
{
|
||||
return Err(
|
||||
"Exchange.operation requires schema/source 7, including explicit null.".into(),
|
||||
);
|
||||
}
|
||||
for nested in fields.values() {
|
||||
reject_source7_fields(nested)?;
|
||||
}
|
||||
}
|
||||
serde_json::Value::Array(values) => {
|
||||
for nested in values {
|
||||
reject_source7_fields(nested)?;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub(crate) fn migrate_v6(
|
||||
mut project: Project,
|
||||
limits: crate::language::ParseLimits,
|
||||
) -> Result<DecodeResult, FileError> {
|
||||
if project.schema_version != 6 {
|
||||
return Err(FileError::Invalid("Expected schema_version 6.".into()));
|
||||
}
|
||||
reject_source7_fields(&serde_json::to_value(&project)?).map_err(FileError::Invalid)?;
|
||||
limits.check_model(&project).map_err(FileError::Limit)?;
|
||||
project.schema_version = SCHEMA_VERSION;
|
||||
Ok(DecodeResult {
|
||||
project,
|
||||
migration: Some(MigrationReport::data_upgrade(6)),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn migrate_v1_with_report_limited(
|
||||
old: v1::Project,
|
||||
limits: crate::language::ParseLimits,
|
||||
|
||||
@@ -1182,6 +1182,48 @@ pub(crate) fn validate_exchange_allocation(p: &Project, r: &Relation, out: &mut
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if let RelationBody::CommunicationMean {
|
||||
source,
|
||||
target,
|
||||
oriented,
|
||||
..
|
||||
} = &boundary.body
|
||||
{
|
||||
let actual = (
|
||||
actual_performer(p, work.source()),
|
||||
actual_performer(p, work.target()),
|
||||
);
|
||||
let compatible = |a, b| {
|
||||
actual.0.is_none_or(|owner| owner == a) && actual.1.is_none_or(|owner| owner == b)
|
||||
};
|
||||
if !compatible(*source, *target) && (*oriented || !compatible(*target, *source)) {
|
||||
error(
|
||||
out,
|
||||
r.id,
|
||||
"exchange_allocation_performer",
|
||||
"endpoints",
|
||||
"Actual activity allocations must agree with one permitted communication direction; review the means or the activity allocations.",
|
||||
);
|
||||
} else if actual.0.is_none() || actual.1.is_none() {
|
||||
warning(
|
||||
out,
|
||||
r.id,
|
||||
"incomplete_exchange_allocation",
|
||||
"endpoints",
|
||||
"Add or review actual activity-to-participant Allocations; role responsibility alone does not resolve communication allocation.",
|
||||
);
|
||||
}
|
||||
if !items_satisfy(p, work.body.items(), boundary.body.items()) {
|
||||
error(
|
||||
out,
|
||||
r.id,
|
||||
"exchange_allocation_items",
|
||||
"items",
|
||||
"Communication means must declare every allocated interaction item; review the carried item contract explicitly.",
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let RelationBody::BoundaryExchange {
|
||||
source: a,
|
||||
target: b,
|
||||
@@ -1219,7 +1261,11 @@ pub(crate) fn validate_exchange_allocation(p: &Project, r: &Relation, out: &mut
|
||||
r.id,
|
||||
"legacy_exchange_allocation",
|
||||
"endpoints",
|
||||
"Resolve legacy ports and explicitly allocate the corresponding typed ports.",
|
||||
if p.layer_of(boundary.id) == Some(Layer::Oa) {
|
||||
"This preserved OA boundary uses legacy ports; review its communication representation without inventing SA port contracts."
|
||||
} else {
|
||||
"Resolve legacy ports and explicitly allocate the corresponding typed ports."
|
||||
},
|
||||
);
|
||||
for (function, port) in [(work.source(), *a), (work.target(), *b)] {
|
||||
match (actual_performer(p, function), owner(port)) {
|
||||
|
||||
@@ -226,8 +226,58 @@ pub fn project_view(p: &Project, diagram: Id) -> ProjectedView {
|
||||
};
|
||||
project_diagram(p, d)
|
||||
}
|
||||
/// Reuse an index built for this accepted semantic revision during presentation
|
||||
/// previews. Callers must invalidate it when semantic records change.
|
||||
pub fn project_view_with_index(
|
||||
p: &Project,
|
||||
diagram: Id,
|
||||
index: &crate::view_options::ViewIndex,
|
||||
) -> ProjectedView {
|
||||
p.diagrams
|
||||
.get(&diagram)
|
||||
.map_or_else(ProjectedView::default, |d| {
|
||||
project_diagram_with_index(p, d, index)
|
||||
})
|
||||
}
|
||||
/// Project alternate placement geometry without copying or mutating semantics.
|
||||
pub fn project_diagram(p: &Project, d: &Diagram) -> ProjectedView {
|
||||
let index = crate::view_options::ViewIndex::new(p);
|
||||
project_diagram_with_index(p, d, &index)
|
||||
}
|
||||
pub fn project_diagram_with_index(
|
||||
p: &Project,
|
||||
d: &Diagram,
|
||||
index: &crate::view_options::ViewIndex,
|
||||
) -> ProjectedView {
|
||||
project_diagram_impl(p, d, None, Some(index))
|
||||
}
|
||||
/// Unfiltered eligibility from the same projection/anchor/fold decisions used
|
||||
/// for drawing. Compute once for a view-options editor, then combine with the
|
||||
/// prepared filter's reasons; hidden lines remain geometrically eligible here.
|
||||
pub fn edge_availability(
|
||||
p: &Project,
|
||||
d: &Diagram,
|
||||
) -> BTreeMap<Id, crate::view_options::EdgeAvailability> {
|
||||
let mut availability = p
|
||||
.relations
|
||||
.keys()
|
||||
.chain(p.traces.keys())
|
||||
.map(|id| {
|
||||
(
|
||||
*id,
|
||||
crate::view_options::EdgeAvailability::OutsideProjection,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
project_diagram_impl(p, d, Some(&mut availability), None);
|
||||
availability
|
||||
}
|
||||
fn project_diagram_impl(
|
||||
p: &Project,
|
||||
d: &Diagram,
|
||||
mut availability: Option<&mut BTreeMap<Id, crate::view_options::EdgeAvailability>>,
|
||||
index: Option<&crate::view_options::ViewIndex>,
|
||||
) -> ProjectedView {
|
||||
let parents = parents(p, d);
|
||||
let mut nodes = Vec::new();
|
||||
for n in d.nodes.values() {
|
||||
@@ -354,6 +404,7 @@ pub fn project_diagram(p: &Project, d: &Diagram) -> ProjectedView {
|
||||
RelationKind::Interaction
|
||||
| RelationKind::FunctionalExchange
|
||||
| RelationKind::BoundaryExchange
|
||||
| RelationKind::CommunicationMean
|
||||
),
|
||||
ProjectionKind::FunctionalDataflow => matches!(
|
||||
r.kind(),
|
||||
@@ -367,20 +418,36 @@ pub fn project_diagram(p: &Project, d: &Diagram) -> ProjectedView {
|
||||
| RelationKind::MissionCapability
|
||||
| RelationKind::MissionInvolvement
|
||||
| RelationKind::CapabilityInvolvement
|
||||
| RelationKind::CommunicationMean
|
||||
),
|
||||
_ => true,
|
||||
};
|
||||
if !included {
|
||||
continue;
|
||||
}
|
||||
if let (Some(source), Some(target)) = (anchor(r.source()), anchor(r.target()))
|
||||
&& (source != target
|
||||
|| ((d.projection.state_machine().is_some()
|
||||
|| r.kind() == RelationKind::DataAssociation)
|
||||
&& r.source() == r.target()
|
||||
&& nodes
|
||||
.iter()
|
||||
.any(|n| n.id == source && n.element == r.source())))
|
||||
let ends = (anchor(r.source()), anchor(r.target()));
|
||||
let self_loop = (d.projection.state_machine().is_some()
|
||||
|| r.kind() == RelationKind::DataAssociation)
|
||||
&& r.source() == r.target()
|
||||
&& nodes
|
||||
.iter()
|
||||
.any(|n| Some(n.id) == ends.0 && n.element == r.source());
|
||||
if let Some(availability) = availability.as_deref_mut() {
|
||||
use crate::view_options::EdgeAvailability as A;
|
||||
availability.insert(
|
||||
r.id,
|
||||
match ends {
|
||||
(Some(a), Some(b)) if a != b || self_loop => A::Visible,
|
||||
(Some(_), Some(_)) => A::FoldedInternal,
|
||||
(a, b) => A::EndpointNotShown {
|
||||
source: a.is_none(),
|
||||
target: b.is_none(),
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
if let (Some(source), Some(target)) = ends
|
||||
&& (source != target || self_loop)
|
||||
{
|
||||
let (source_port, target_port) = match &r.body {
|
||||
RelationBody::FunctionalExchange {
|
||||
@@ -402,6 +469,9 @@ pub fn project_diagram(p: &Project, d: &Diagram) -> ProjectedView {
|
||||
RelationBody::BoundaryExchange {
|
||||
oriented: false,
|
||||
..
|
||||
} | RelationBody::CommunicationMean {
|
||||
oriented: false,
|
||||
..
|
||||
} | RelationBody::DataAssociation { .. }
|
||||
),
|
||||
source_port: source_port.filter(|id| visible_ports.contains(id)),
|
||||
@@ -411,7 +481,22 @@ pub fn project_diagram(p: &Project, d: &Diagram) -> ProjectedView {
|
||||
}
|
||||
if d.projection == ProjectionKind::Traceability {
|
||||
for t in p.traces.values() {
|
||||
if let (Some(source), Some(target)) = (anchor(t.oa), anchor(t.sa))
|
||||
let ends = (anchor(t.oa), anchor(t.sa));
|
||||
if let Some(availability) = availability.as_deref_mut() {
|
||||
use crate::view_options::EdgeAvailability as A;
|
||||
availability.insert(
|
||||
t.id,
|
||||
match ends {
|
||||
(Some(a), Some(b)) if a != b => A::Visible,
|
||||
(Some(_), Some(_)) => A::FoldedInternal,
|
||||
(a, b) => A::EndpointNotShown {
|
||||
source: a.is_none(),
|
||||
target: b.is_none(),
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
if let (Some(source), Some(target)) = ends
|
||||
&& source != target
|
||||
{
|
||||
edges.push(ProjectedEdge {
|
||||
@@ -433,6 +518,16 @@ pub fn project_diagram(p: &Project, d: &Diagram) -> ProjectedView {
|
||||
} else {
|
||||
port_layout::layout(p, d, &mut nodes, &edges)
|
||||
};
|
||||
// Port geometry uses every eligible edge. Presentation filters must never
|
||||
// move visible ports when another line is hidden or a category is selected.
|
||||
if let Some(index) = index {
|
||||
let filter = if d.projection == ProjectionKind::Traceability {
|
||||
index.prepare_cross_layer(d.layer, &d.view_options)
|
||||
} else {
|
||||
index.prepare(d.layer, &d.view_options)
|
||||
};
|
||||
edges.retain(|edge| filter.includes(edge.id));
|
||||
}
|
||||
ProjectedView {
|
||||
nodes,
|
||||
edges,
|
||||
|
||||
@@ -53,7 +53,12 @@ pub fn state_header_height(p: &Project, element: Id) -> f32 {
|
||||
if *source == element)
|
||||
})
|
||||
.count();
|
||||
64. + if actions { 28. } else { 0. } + internal as f32 * 56.
|
||||
let typed_actions = p
|
||||
.relations
|
||||
.values()
|
||||
.filter(|r| r.kind() == RelationKind::StateAction && r.source() == element)
|
||||
.count();
|
||||
64. + if actions { 28. } else { 0. } + typed_actions as f32 * 28. + internal as f32 * 56.
|
||||
}
|
||||
|
||||
pub(crate) fn node_size(p: &Project, e: &Element, size: Size, collapsed: bool) -> Size {
|
||||
|
||||
@@ -29,6 +29,11 @@ pub fn visit_element_refs(e: &mut Element, mut visit: impl FnMut(&mut Id)) {
|
||||
}
|
||||
if let Some(shared) = e.shared_mut() {
|
||||
match shared {
|
||||
SharedBody::ExchangeCategory { exchanges, .. } => {
|
||||
for id in exchanges {
|
||||
visit(id);
|
||||
}
|
||||
}
|
||||
SharedBody::DataField {
|
||||
type_ref: TypeRef::Named(id),
|
||||
..
|
||||
@@ -82,8 +87,15 @@ pub fn visit_element_refs(e: &mut Element, mut visit: impl FnMut(&mut Id)) {
|
||||
receiver,
|
||||
reply_to,
|
||||
values,
|
||||
operation,
|
||||
..
|
||||
} => {
|
||||
if let Some(id) = operation
|
||||
.as_mut()
|
||||
.and_then(|operation| operation.allocation.as_mut())
|
||||
{
|
||||
visit(id);
|
||||
}
|
||||
for id in [exchange, sender, receiver, reply_to].into_iter().flatten() {
|
||||
visit(id);
|
||||
}
|
||||
@@ -145,6 +157,22 @@ pub fn visit_relation_refs(r: &mut Relation, mut visit: impl FnMut(&mut Id)) {
|
||||
visit(&mut target);
|
||||
r.body.set_endpoints(source, target);
|
||||
match &mut r.body {
|
||||
RelationBody::ContextualLink {
|
||||
owner,
|
||||
source,
|
||||
target,
|
||||
purpose,
|
||||
} => {
|
||||
visit(owner);
|
||||
for id in source.references.iter_mut().chain(&mut target.references) {
|
||||
visit(id);
|
||||
}
|
||||
match purpose {
|
||||
ContextualLinkPurpose::Exchange { exchange } => visit(exchange),
|
||||
ContextualLinkPurpose::CommonWork { work } => visit(work),
|
||||
ContextualLinkPurpose::Precedence { .. } => {}
|
||||
}
|
||||
}
|
||||
RelationBody::StateTransition {
|
||||
triggers, effects, ..
|
||||
} => {
|
||||
@@ -152,7 +180,9 @@ pub fn visit_relation_refs(r: &mut Relation, mut visit: impl FnMut(&mut Id)) {
|
||||
visit(id);
|
||||
}
|
||||
}
|
||||
RelationBody::Interaction { items, .. } | RelationBody::BoundaryExchange { items, .. } => {
|
||||
RelationBody::Interaction { items, .. }
|
||||
| RelationBody::BoundaryExchange { items, .. }
|
||||
| RelationBody::CommunicationMean { items, .. } => {
|
||||
for id in items {
|
||||
visit(id);
|
||||
}
|
||||
@@ -189,6 +219,11 @@ pub fn remap_references(p: &mut Project, old: Id, new: Id) {
|
||||
});
|
||||
}
|
||||
for d in p.diagrams.values_mut() {
|
||||
d.view_options.visit_references(|id| {
|
||||
if *id == old {
|
||||
*id = new;
|
||||
}
|
||||
});
|
||||
if let ProjectionKind::StateMachine { machine } = &mut d.projection
|
||||
&& *machine == old
|
||||
{
|
||||
@@ -262,6 +297,8 @@ fn deletion_impact_inner(p: &Project, id: Id, defer_exchange_allocations: bool)
|
||||
});
|
||||
if removed.contains(&r.source())
|
||||
|| removed.contains(&r.target())
|
||||
|| (r.kind() == RelationKind::ContextualLink
|
||||
&& relation_references(r).iter().any(|id| removed.contains(id)))
|
||||
|| port_removed
|
||||
|| work_allocation_removed
|
||||
{
|
||||
@@ -374,6 +411,7 @@ fn remove_objects(p: &mut Project, removed: BTreeSet<Id>) {
|
||||
p.traces.retain(|id, _| !removed.contains(id));
|
||||
p.artifact_links.retain(|id, _| !removed.contains(id));
|
||||
for d in p.diagrams.values_mut() {
|
||||
d.view_options.retain_references(|id| !removed.contains(id));
|
||||
d.nodes.retain(|id, _| !removed.contains(id));
|
||||
d.routes.retain(|route| !removed.contains(&route.edge));
|
||||
for node in d.nodes.values_mut() {
|
||||
@@ -417,7 +455,11 @@ fn remove_objects(p: &mut Project, removed: BTreeSet<Id>) {
|
||||
}
|
||||
SharedBody::Scenario { context, .. } => clear(context),
|
||||
SharedBody::Lifeline { represented } => clear(represented),
|
||||
SharedBody::Constraint { constrained, .. } => {
|
||||
SharedBody::ExchangeCategory {
|
||||
exchanges: constrained,
|
||||
..
|
||||
}
|
||||
| SharedBody::Constraint { constrained, .. } => {
|
||||
constrained.retain(|id| !removed.contains(id))
|
||||
}
|
||||
SharedBody::Occurrence { body, .. } => match body {
|
||||
@@ -427,8 +469,12 @@ fn remove_objects(p: &mut Project, removed: BTreeSet<Id>) {
|
||||
receiver,
|
||||
reply_to,
|
||||
values,
|
||||
operation,
|
||||
..
|
||||
} => {
|
||||
if let Some(operation) = operation {
|
||||
clear(&mut operation.allocation);
|
||||
}
|
||||
clear(exchange);
|
||||
clear(sender);
|
||||
clear(receiver);
|
||||
@@ -487,7 +533,8 @@ fn remove_objects(p: &mut Project, removed: BTreeSet<Id>) {
|
||||
}
|
||||
}
|
||||
RelationBody::Interaction { items, .. }
|
||||
| RelationBody::BoundaryExchange { items, .. } => {
|
||||
| RelationBody::BoundaryExchange { items, .. }
|
||||
| RelationBody::CommunicationMean { items, .. } => {
|
||||
items.retain(|id| !removed.contains(id))
|
||||
}
|
||||
RelationBody::FunctionalExchange { items, .. } => {
|
||||
|
||||
+152
-39
@@ -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,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -607,6 +639,7 @@ fn route_diagram_impl(
|
||||
.flatten();
|
||||
routed.label = make_label(
|
||||
project,
|
||||
diagram.view_options.exchange_labels,
|
||||
edge.id,
|
||||
&routed.points,
|
||||
authored_label,
|
||||
@@ -678,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 {
|
||||
@@ -691,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;
|
||||
}
|
||||
@@ -728,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
|
||||
@@ -747,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
|
||||
@@ -791,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()
|
||||
@@ -832,6 +926,7 @@ fn route_edge(
|
||||
start = end;
|
||||
}
|
||||
points.push(b.point);
|
||||
pin_lengths.truncate(authored_pin_count);
|
||||
(
|
||||
points,
|
||||
pin_lengths,
|
||||
@@ -1232,6 +1327,7 @@ fn label_text(project: &Project, edge: Id) -> String {
|
||||
RelationKind::Interaction
|
||||
| RelationKind::FunctionalExchange
|
||||
| RelationKind::BoundaryExchange
|
||||
| RelationKind::CommunicationMean
|
||||
| RelationKind::DataAssociation => relation.name.as_str(),
|
||||
RelationKind::CapabilityInclude => "«include»",
|
||||
RelationKind::CapabilityExtend => "«extend»",
|
||||
@@ -1262,8 +1358,11 @@ fn empty_label() -> EdgeLabelGeometry {
|
||||
}
|
||||
}
|
||||
|
||||
// Keep semantic label inputs and the independent collision sets explicit.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn make_label(
|
||||
project: &Project,
|
||||
labels: crate::view_options::ExchangeLabels,
|
||||
edge: Id,
|
||||
points: &[Position],
|
||||
authored: Option<&crate::EdgeLabel>,
|
||||
@@ -1271,7 +1370,21 @@ fn make_label(
|
||||
previous_labels: &[RoutingBounds],
|
||||
strokes: &[Segment],
|
||||
) -> EdgeLabelGeometry {
|
||||
let text = label_text(project, edge);
|
||||
let text = if labels != crate::view_options::ExchangeLabels::Name
|
||||
&& project
|
||||
.relations
|
||||
.get(&edge)
|
||||
.is_some_and(|r| crate::view_options::exchange_family(r.kind()).is_some())
|
||||
{
|
||||
let label = labels.label(project, edge);
|
||||
if label.chars().count() > 48 {
|
||||
label.chars().take(47).chain(std::iter::once('…')).collect()
|
||||
} else {
|
||||
label
|
||||
}
|
||||
} else {
|
||||
label_text(project, edge)
|
||||
};
|
||||
let size = Size {
|
||||
width: if text.is_empty() {
|
||||
0.0
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -81,6 +81,35 @@ impl Own {
|
||||
}
|
||||
let kind = enum_tag(value)?.to_string();
|
||||
prefix.push(kind.clone());
|
||||
let mut whole = whole;
|
||||
if let Some(SharedBody::Occurrence {
|
||||
body:
|
||||
OccurrenceBody::Exchange {
|
||||
message_kind: MessageKind::Call | MessageKind::Asynchronous,
|
||||
..
|
||||
},
|
||||
..
|
||||
}) = project.elements.get(&id).and_then(Element::shared)
|
||||
&& project.layer_of(id) == Some(Layer::Sa)
|
||||
&& crate::validation::ancestor_of_kind(project, id, Kind::Scenario)
|
||||
.and_then(|id| project.elements.get(&id))
|
||||
.and_then(Element::shared)
|
||||
.is_some_and(|body| {
|
||||
matches!(
|
||||
body,
|
||||
SharedBody::Scenario {
|
||||
viewpoint: ScenarioViewpoint::BoundaryInterface,
|
||||
..
|
||||
}
|
||||
)
|
||||
})
|
||||
{
|
||||
let mut operation_path = prefix.clone();
|
||||
operation_path.extend(["body".into(), "Exchange".into()]);
|
||||
if let Some(body) = at_mut(&mut whole, &operation_path).and_then(Value::as_object_mut) {
|
||||
body.entry("operation").or_insert(Value::Null);
|
||||
}
|
||||
}
|
||||
Some(Self {
|
||||
whole,
|
||||
prefix,
|
||||
@@ -163,6 +192,9 @@ fn path(own: &Own, facet: &FacetSelector) -> Option<Vec<String>> {
|
||||
F::Occurrence(OccurrenceFacet::ReplyTo) => {
|
||||
("Occurrence", &["body", "Exchange", "reply_to"])
|
||||
}
|
||||
F::Occurrence(OccurrenceFacet::Operation) => {
|
||||
("Occurrence", &["body", "Exchange", "operation"])
|
||||
}
|
||||
F::Occurrence(OccurrenceFacet::Action) => ("Occurrence", &["body", "Action", "action"]),
|
||||
F::Occurrence(OccurrenceFacet::ActionLifeline) => {
|
||||
("Occurrence", &["body", "Action", "lifeline"])
|
||||
@@ -191,8 +223,13 @@ fn path(own: &Own, facet: &FacetSelector) -> Option<Vec<String>> {
|
||||
F::State(StateFacet::Exit) => ("State", &["exit"]),
|
||||
F::State(StateFacet::DoActivity) => ("State", &["do_activity"]),
|
||||
F::Constraint(ConstraintFacet::Expression) => ("Constraint", &["expression"]),
|
||||
F::Relation(RelationFacet::Source) => ("*", &["source"]),
|
||||
F::Relation(RelationFacet::Target) => ("*", &["target"]),
|
||||
F::Category(CategoryFacet::Family) => ("ExchangeCategory", &["family"]),
|
||||
F::Relation(RelationFacet::Source) if own.kind != "ContextualLink" => ("*", &["source"]),
|
||||
F::Relation(RelationFacet::Target) if own.kind != "ContextualLink" => ("*", &["target"]),
|
||||
F::Relation(RelationFacet::ContextOwner) => ("ContextualLink", &["owner"]),
|
||||
F::Relation(RelationFacet::ContextSource) => ("ContextualLink", &["source"]),
|
||||
F::Relation(RelationFacet::ContextTarget) => ("ContextualLink", &["target"]),
|
||||
F::Relation(RelationFacet::ContextPurpose) => ("ContextualLink", &["purpose"]),
|
||||
F::Relation(RelationFacet::AllocationOrder) => ("InterfaceItemAllocation", &["order"]),
|
||||
F::Relation(RelationFacet::Transmission) => ("InterfaceItemAllocation", &["transmission"]),
|
||||
F::Relation(RelationFacet::Acquisition) => ("InterfaceItemAllocation", &["acquisition"]),
|
||||
@@ -206,7 +243,7 @@ fn path(own: &Own, facet: &FacetSelector) -> Option<Vec<String>> {
|
||||
F::Relation(RelationFacet::TargetNavigable) => ("DataAssociation", &["target_navigable"]),
|
||||
F::Relation(RelationFacet::SourcePort) => ("FunctionalExchange", &["source_port"]),
|
||||
F::Relation(RelationFacet::TargetPort) => ("FunctionalExchange", &["target_port"]),
|
||||
F::Relation(RelationFacet::Oriented) => ("BoundaryExchange", &["oriented"]),
|
||||
F::Relation(RelationFacet::Oriented) => ("*", &["oriented"]),
|
||||
F::Relation(RelationFacet::ExtendCondition) => ("CapabilityExtend", &["condition"]),
|
||||
F::Relation(RelationFacet::SequenceGuard) => ("SequenceLink", &["guard"]),
|
||||
F::Relation(RelationFacet::TransitionKind) => ("StateTransition", &["transition_kind"]),
|
||||
@@ -311,7 +348,8 @@ pub(crate) fn collection(project: &Project, id: Id, which: ReferenceCollection)
|
||||
Some(
|
||||
RelationBody::Interaction { items, .. }
|
||||
| RelationBody::FunctionalExchange { items, .. }
|
||||
| RelationBody::BoundaryExchange { items, .. },
|
||||
| RelationBody::BoundaryExchange { items, .. }
|
||||
| RelationBody::CommunicationMean { items, .. },
|
||||
) => Some(items.clone()),
|
||||
_ => None,
|
||||
},
|
||||
@@ -327,6 +365,10 @@ pub(crate) fn collection(project: &Project, id: Id, which: ReferenceCollection)
|
||||
Some(SharedBody::Constraint { constrained, .. }) => Some(constrained.clone()),
|
||||
_ => None,
|
||||
},
|
||||
C::CategoryExchanges => match shared {
|
||||
Some(SharedBody::ExchangeCategory { exchanges, .. }) => Some(exchanges.clone()),
|
||||
_ => None,
|
||||
},
|
||||
}
|
||||
}
|
||||
pub(crate) fn order_collection(which: OrderedCollection) -> ReferenceCollection {
|
||||
@@ -575,6 +617,7 @@ fn catalog_facets(
|
||||
ReferenceCollection::FunctionOutputItems,
|
||||
ReferenceCollection::SignalItems,
|
||||
ReferenceCollection::ExchangeItems,
|
||||
ReferenceCollection::CategoryExchanges,
|
||||
ReferenceCollection::TransitionTriggers,
|
||||
ReferenceCollection::TransitionEffects,
|
||||
ReferenceCollection::Constrained,
|
||||
@@ -919,6 +962,7 @@ mod coverage {
|
||||
] {
|
||||
add(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange {
|
||||
operation: None,
|
||||
exchange: Some(id),
|
||||
sender: Some(id),
|
||||
receiver: Some(id),
|
||||
@@ -1036,6 +1080,11 @@ mod coverage {
|
||||
p
|
||||
};
|
||||
for (kind, parts, which) in [
|
||||
(
|
||||
"ExchangeCategory",
|
||||
vec!["exchanges"],
|
||||
ReferenceCollection::CategoryExchanges,
|
||||
),
|
||||
("Process", vec!["steps"], ReferenceCollection::Steps),
|
||||
("FunctionalChain", vec!["steps"], ReferenceCollection::Steps),
|
||||
("Scenario", vec!["steps"], ReferenceCollection::Steps),
|
||||
@@ -1079,6 +1128,11 @@ mod coverage {
|
||||
vec!["items"],
|
||||
ReferenceCollection::ExchangeItems,
|
||||
),
|
||||
(
|
||||
"CommunicationMean",
|
||||
vec!["items"],
|
||||
ReferenceCollection::ExchangeItems,
|
||||
),
|
||||
(
|
||||
"StateTransition",
|
||||
vec!["triggers"],
|
||||
|
||||
@@ -73,6 +73,7 @@ macro_rules! enums {
|
||||
} )* };
|
||||
}
|
||||
enums!(
|
||||
crate::view_options::ExchangeFamily,
|
||||
ExchangeItemKind,
|
||||
ExchangeMechanism,
|
||||
ParameterDirection,
|
||||
@@ -170,6 +171,31 @@ impl Historical for LifelineBinding {
|
||||
w.at("local", |w| self.local.walk(w));
|
||||
}
|
||||
}
|
||||
impl Historical for OperationUse {
|
||||
fn walk(&mut self, w: &mut Walk<'_>) {
|
||||
w.at("allocation", |w| self.allocation.walk(w));
|
||||
}
|
||||
}
|
||||
impl Historical for OccurrenceEndpoint {
|
||||
fn walk(&mut self, w: &mut Walk<'_>) {
|
||||
if self.references.len() > crate::topology_context::MAX_REFERENCE_DEPTH {
|
||||
w.error("historical contextual path depth exceeded");
|
||||
}
|
||||
w.at("references", |w| self.references.walk(w));
|
||||
w.at("occurrence", |w| self.occurrence.walk(w));
|
||||
}
|
||||
}
|
||||
impl Historical for ContextualLinkPurpose {
|
||||
fn walk(&mut self, w: &mut Walk<'_>) {
|
||||
match self {
|
||||
Self::Precedence { guard } => w.at("Precedence", |w| w.at("guard", |w| guard.walk(w))),
|
||||
Self::Exchange { exchange } => {
|
||||
w.at("Exchange", |w| w.at("exchange", |w| exchange.walk(w)))
|
||||
}
|
||||
Self::CommonWork { work } => w.at("CommonWork", |w| w.at("work", |w| work.walk(w))),
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Historical for DataValue {
|
||||
fn walk(&mut self, w: &mut Walk<'_>) {
|
||||
match self {
|
||||
|
||||
@@ -63,7 +63,8 @@ pub(crate) fn set_collection(
|
||||
Some(
|
||||
RelationBody::Interaction { items, .. }
|
||||
| RelationBody::FunctionalExchange { items, .. }
|
||||
| RelationBody::BoundaryExchange { items, .. },
|
||||
| RelationBody::BoundaryExchange { items, .. }
|
||||
| RelationBody::CommunicationMean { items, .. },
|
||||
) => Some(items),
|
||||
_ => None,
|
||||
},
|
||||
@@ -79,6 +80,10 @@ pub(crate) fn set_collection(
|
||||
Some(SharedBody::Constraint { constrained, .. }) => Some(constrained),
|
||||
_ => None,
|
||||
},
|
||||
C::CategoryExchanges => match p.elements.get_mut(&id).and_then(Element::shared_mut) {
|
||||
Some(SharedBody::ExchangeCategory { exchanges, .. }) => Some(exchanges),
|
||||
_ => None,
|
||||
},
|
||||
}
|
||||
.ok_or("Collection is inapplicable on target")?;
|
||||
*slot = list;
|
||||
@@ -350,10 +355,7 @@ pub(crate) fn write_fact(
|
||||
else {
|
||||
return Err("Target has no message annotations".into());
|
||||
};
|
||||
values.retain(|v| AnnotationKey::from(v) != *key);
|
||||
if let Some(row) = row {
|
||||
values.push(row);
|
||||
}
|
||||
replace_keyed_row(values, row, |v| AnnotationKey::from(v) == *key);
|
||||
Ok(())
|
||||
}
|
||||
FacetReview::Binding { keys, review } => {
|
||||
@@ -374,10 +376,7 @@ pub(crate) fn write_fact(
|
||||
else {
|
||||
return Err("Target has no scenario bindings".into());
|
||||
};
|
||||
bindings.retain(|v| v.referenced != key);
|
||||
if let Some(row) = row {
|
||||
bindings.push(row);
|
||||
}
|
||||
replace_keyed_row(bindings, row, |v| v.referenced == key);
|
||||
Ok(())
|
||||
}
|
||||
FacetReview::Child { keys, review } => {
|
||||
@@ -467,6 +466,27 @@ pub(crate) fn write_fact(
|
||||
}
|
||||
}
|
||||
|
||||
// Reviewing an unordered row does not authorize changing its authored position.
|
||||
// Preserve surviving entries in place; only genuinely new rows append.
|
||||
fn replace_keyed_row<T: PartialEq>(
|
||||
rows: &mut Vec<T>,
|
||||
row: Option<T>,
|
||||
matches: impl FnMut(&T) -> bool,
|
||||
) {
|
||||
match (rows.iter().position(matches), row) {
|
||||
(Some(index), Some(row)) => {
|
||||
if rows[index] != row {
|
||||
rows[index] = row;
|
||||
}
|
||||
}
|
||||
(Some(index), None) => {
|
||||
rows.remove(index);
|
||||
}
|
||||
(None, Some(row)) => rows.push(row),
|
||||
(None, None) => {}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn constructor_contains(group: &FacetSelector, facet: &FacetSelector) -> bool {
|
||||
use FacetSelector as F;
|
||||
match group {
|
||||
@@ -498,7 +518,11 @@ pub(crate) fn constructor_contains(group: &FacetSelector, facet: &FacetSelector)
|
||||
}
|
||||
F::Occurrence(OccurrenceFacet::MessageKind) => matches!(
|
||||
facet,
|
||||
F::Occurrence(OccurrenceFacet::MessageKind | OccurrenceFacet::TimerExpression)
|
||||
F::Occurrence(
|
||||
OccurrenceFacet::MessageKind
|
||||
| OccurrenceFacet::TimerExpression
|
||||
| OccurrenceFacet::Operation
|
||||
)
|
||||
),
|
||||
F::Event(EventFacet::Constructor) => matches!(
|
||||
facet,
|
||||
|
||||
@@ -795,7 +795,18 @@ fn build_review(
|
||||
current.clone()
|
||||
};
|
||||
if resolution.decision == SemanticDecision::TakeSource {
|
||||
fact = remap_fact(&fact, &map, source_layer)?;
|
||||
fact = if let Some(adapted) = transitions::review_endpoint_fact(
|
||||
initial,
|
||||
&plan.request.transition,
|
||||
&map,
|
||||
request.source,
|
||||
target,
|
||||
&fact,
|
||||
)? {
|
||||
adapted
|
||||
} else {
|
||||
remap_fact(&fact, &map, source_layer)?
|
||||
};
|
||||
}
|
||||
// A Keep reads the frozen target, never a value written by an earlier source.
|
||||
if resolution.decision != SemanticDecision::KeepTarget {
|
||||
|
||||
@@ -86,6 +86,7 @@ macro_rules! scalar_families {
|
||||
ExchangeItem, ExchangeItemFacet, ExchangeItemReview {
|
||||
Nature: ExchangeItemKind, Mechanism: ExchangeMechanism, Supertype: Option<Id>
|
||||
}
|
||||
Category, CategoryFacet, CategoryReview { Family: crate::view_options::ExchangeFamily }
|
||||
Interface, InterfaceFacet, InterfaceReview { Supertype: Option<Id> }
|
||||
Data, DataFacet, DataReview {
|
||||
Constructor: DataConstructor,
|
||||
@@ -111,6 +112,7 @@ macro_rules! scalar_families {
|
||||
Constructor: OccurrenceConstructor, Precondition: String, Postcondition: String,
|
||||
Exchange: Option<Id>, Sender: Option<Id>, Receiver: Option<Id>,
|
||||
MessageKind: MessageConstructor, TimerExpression: String, ReplyTo: Option<Id>,
|
||||
Operation: Option<OperationUse>,
|
||||
Action: Option<Id>, ActionLifeline: Option<Id>, ControlKind: ControlKind,
|
||||
Sequence: Option<Id>, Scenario: Option<Id>, State: Option<Id>,
|
||||
StateLifeline: Option<Id>, FragmentOperator: FragmentOperator, BranchGuard: String
|
||||
@@ -128,6 +130,8 @@ macro_rules! scalar_families {
|
||||
SourcePort: Id, TargetPort: Id, Oriented: bool, ExtendCondition: String,
|
||||
SequenceGuard: String, TransitionKind: TransitionKind, Trigger: String,
|
||||
Guard: String, Effect: String, StateActionPhase: StateActionKind
|
||||
, ContextOwner: Id, ContextSource: OccurrenceEndpoint, ContextTarget: OccurrenceEndpoint,
|
||||
ContextPurpose: ContextualLinkPurpose
|
||||
}
|
||||
}};
|
||||
}
|
||||
@@ -197,6 +201,7 @@ pub enum ReferenceCollection {
|
||||
FunctionOutputItems,
|
||||
SignalItems,
|
||||
ExchangeItems,
|
||||
CategoryExchanges,
|
||||
TransitionTriggers,
|
||||
TransitionEffects,
|
||||
Constrained,
|
||||
|
||||
@@ -660,6 +660,7 @@ fn apply_command(
|
||||
.unwrap();
|
||||
*occ.shared_mut().unwrap() = SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange {
|
||||
operation: None,
|
||||
exchange: Some(*exchange),
|
||||
sender: None,
|
||||
receiver: None,
|
||||
|
||||
@@ -0,0 +1,399 @@
|
||||
//! Ordinary source defaults shared by current parsing and declaration rendering.
|
||||
//!
|
||||
//! These are source omission defaults, not graphical creation defaults. The
|
||||
//! policy operates on complete typed declaration maps. Required named fields
|
||||
//! and selected constructor payloads remain distinct from omission defaults.
|
||||
//! Historical versions use their existing frozen parser paths. Semantic baseline
|
||||
//! history is never traversed or normalized here.
|
||||
use crate::{Kind, RelationKind};
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum Context {
|
||||
Element(Kind),
|
||||
Relation(RelationKind),
|
||||
Diagram,
|
||||
Node,
|
||||
Artifact,
|
||||
Trace,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum Mode {
|
||||
CompleteForEdit,
|
||||
Compact,
|
||||
/// Internal lossless-edit fragments: retain every top-level value while
|
||||
/// compacting its selected constructor's optional nested fields.
|
||||
CompactValues,
|
||||
}
|
||||
|
||||
/// Top-level field inventory for the current ordinary reader. Callers must
|
||||
/// check required fields before merging; null entries only admit their names.
|
||||
/// This never supplies defaults inside a selected constructor.
|
||||
pub(crate) fn parser_fields(context: Context) -> Map<String, Value> {
|
||||
let mut fields = declaration_defaults(context);
|
||||
let required: &[&str] = match context {
|
||||
Context::Element(kind) => {
|
||||
fields.remove("description");
|
||||
fields.remove("parent");
|
||||
if kind == Kind::Port {
|
||||
&["contract"]
|
||||
} else {
|
||||
&[]
|
||||
}
|
||||
}
|
||||
Context::Relation(kind) => {
|
||||
fields.remove("description");
|
||||
match kind {
|
||||
RelationKind::ContextualLink => &["owner", "source", "target", "purpose"],
|
||||
RelationKind::FunctionalExchange => {
|
||||
&["source", "target", "source_port", "target_port"]
|
||||
}
|
||||
_ => &["source", "target"],
|
||||
}
|
||||
}
|
||||
Context::Diagram => &[],
|
||||
Context::Node => &["element"],
|
||||
Context::Artifact => &["owner"],
|
||||
Context::Trace => &["oa", "sa"],
|
||||
};
|
||||
for name in required {
|
||||
fields.insert((*name).into(), Value::Null);
|
||||
}
|
||||
fields
|
||||
}
|
||||
|
||||
pub(crate) fn fields(
|
||||
context: Context,
|
||||
input: &Map<String, Value>,
|
||||
mode: Mode,
|
||||
) -> Map<String, Value> {
|
||||
let mut result = input.clone();
|
||||
restore_serialization_skips(context, &mut result);
|
||||
if mode != Mode::CompleteForEdit {
|
||||
compact_nested(context, &mut result);
|
||||
}
|
||||
if mode == Mode::Compact {
|
||||
// Compact a composite and its declared default by the same nested rules
|
||||
// before whole-field comparison. This also makes compact input stable:
|
||||
// e.g. String{minimum_length:0} still denotes the default definition.
|
||||
let mut defaults = declaration_defaults(context);
|
||||
compact_nested(context, &mut defaults);
|
||||
omit_defaults(&mut result, &defaults);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn restore_serialization_skips(context: Context, fields: &mut Map<String, Value>) {
|
||||
match context {
|
||||
Context::Element(Kind::Occurrence) => {
|
||||
if let Some(exchange) = constructor_payload(fields, "body", "Exchange") {
|
||||
exchange
|
||||
.entry("operation".to_owned())
|
||||
.or_insert(Value::Null);
|
||||
}
|
||||
}
|
||||
Context::Diagram => {
|
||||
if let Some(routes) = fields.get_mut("routes").and_then(Value::as_array_mut) {
|
||||
for route in routes.iter_mut().filter_map(Value::as_object_mut) {
|
||||
route
|
||||
.entry("source_attachment".to_owned())
|
||||
.or_insert(Value::Null);
|
||||
route
|
||||
.entry("target_attachment".to_owned())
|
||||
.or_insert(Value::Null);
|
||||
}
|
||||
}
|
||||
}
|
||||
Context::Node => {
|
||||
fields.entry("port_anchors".to_owned()).or_insert(json!([]));
|
||||
}
|
||||
Context::Trace => {
|
||||
if let Some(baseline) = fields.get_mut("baseline").and_then(Value::as_object_mut) {
|
||||
baseline.entry("semantic".to_owned()).or_insert(Value::Null);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn declaration_defaults(context: Context) -> Map<String, Value> {
|
||||
let mut defaults = match context {
|
||||
Context::Element(kind) => element_defaults(kind),
|
||||
Context::Relation(kind) => relation_defaults(kind),
|
||||
Context::Diagram => object(json!({
|
||||
"description": "", "layer": "Oa", "projection": "Overview",
|
||||
"routes": [], "view_options": {
|
||||
"hidden_edges": [], "relation_kinds": "All",
|
||||
"work_categories": "All", "participant_categories": "All",
|
||||
"coverage": "All", "work_contexts": "All", "exchange_labels": "Name"
|
||||
}
|
||||
})),
|
||||
Context::Node => object(json!({
|
||||
"position": {"x": 0.0, "y": 0.0},
|
||||
"size": {"width": 224.0, "height": 94.0},
|
||||
"collapsed": false, "port_anchors": []
|
||||
})),
|
||||
Context::Artifact => object(json!({
|
||||
"description": "", "relation": "References", "uri": "",
|
||||
"revision": null, "fragment": null
|
||||
})),
|
||||
Context::Trace => object(json!({
|
||||
"description": "", "kind": "Refinement", "baseline": null
|
||||
})),
|
||||
};
|
||||
match context {
|
||||
Context::Element(_) => {
|
||||
defaults.insert("description".into(), json!(""));
|
||||
defaults.insert("parent".into(), Value::Null);
|
||||
}
|
||||
Context::Relation(_) => {
|
||||
defaults.insert("description".into(), json!(""));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
defaults
|
||||
}
|
||||
|
||||
fn element_defaults(kind: Kind) -> Map<String, Value> {
|
||||
if let Some(state_kind) = kind.state_kind() {
|
||||
// The declaration selects this discriminator; no GUI factory is used.
|
||||
return object(json!({
|
||||
"kind": state_kind, "entry": "", "exit": "", "do_activity": ""
|
||||
}));
|
||||
}
|
||||
match kind {
|
||||
Kind::Process | Kind::FunctionalChain => object(json!({"steps": []})),
|
||||
Kind::ExchangeItem => object(json!({
|
||||
"kind": "Information", "mechanism": "Unspecified", "supertype": null
|
||||
})),
|
||||
Kind::ExchangeCategory => object(json!({"family": "Work", "exchanges": []})),
|
||||
Kind::DataType => object(json!({
|
||||
"definition": {"String": {"minimum_length": 0, "maximum_length": null}},
|
||||
"default": null, "unit": ""
|
||||
})),
|
||||
Kind::DataField => object(json!({
|
||||
"type_ref": "String", "minimum": 1, "maximum": 1, "unit": "",
|
||||
"direction": "Unspecified", "storage": "Value", "default": null
|
||||
})),
|
||||
Kind::Interface => object(json!({"supertype": null})),
|
||||
// Port.contract is required, even when its explicit value is Legacy.
|
||||
Kind::Port => object(json!({"legacy": null})),
|
||||
Kind::Scenario => object(json!({
|
||||
"viewpoint": "MixedLegacy", "context": null,
|
||||
"precondition": "", "postcondition": "", "steps": []
|
||||
})),
|
||||
Kind::Lifeline => object(json!({"represented": null})),
|
||||
Kind::Occurrence => object(json!({
|
||||
"body": {"Exchange": {
|
||||
"exchange": null, "sender": null, "receiver": null,
|
||||
"message_kind": "Asynchronous", "reply_to": null,
|
||||
"values": [], "operation": null
|
||||
}},
|
||||
"precondition": "", "postcondition": ""
|
||||
})),
|
||||
Kind::Event => object(json!({"body": {"Signal": {"items": []}}})),
|
||||
Kind::Constraint => object(json!({"expression": "", "constrained": []})),
|
||||
// Unit-body declarations have no additional omission defaults. A future
|
||||
// Kind reaches this branch without gaining speculative payload rules.
|
||||
_ => Map::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn relation_defaults(kind: RelationKind) -> Map<String, Value> {
|
||||
match kind {
|
||||
RelationKind::InterfaceItemAllocation => object(json!({
|
||||
"order": 0, "transmission": "Unspecified", "acquisition": "Unspecified"
|
||||
})),
|
||||
RelationKind::DataAssociation => object(json!({
|
||||
"source_role": "", "target_role": "",
|
||||
"source_minimum": 0, "target_minimum": 0,
|
||||
"source_maximum": null, "target_maximum": null,
|
||||
"source_navigable": false, "target_navigable": false
|
||||
})),
|
||||
RelationKind::Interaction | RelationKind::FunctionalExchange => {
|
||||
object(json!({"items": []}))
|
||||
}
|
||||
RelationKind::BoundaryExchange => object(json!({"items": [], "oriented": true})),
|
||||
RelationKind::CommunicationMean => object(json!({"items": [], "oriented": false})),
|
||||
RelationKind::CapabilityExtend => object(json!({"condition": ""})),
|
||||
RelationKind::SequenceLink => object(json!({"guard": ""})),
|
||||
RelationKind::StateTransition => object(json!({
|
||||
"transition_kind": "External", "trigger": "", "guard": "", "effect": "",
|
||||
"triggers": [], "effects": []
|
||||
})),
|
||||
RelationKind::StateAction => object(json!({"phase": "Do"})),
|
||||
// source/target, FunctionalExchange ports and ContextualLink's owner,
|
||||
// endpoints and purpose are required, despite factory placeholder values.
|
||||
_ => Map::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn compact_nested(context: Context, fields: &mut Map<String, Value>) {
|
||||
match context {
|
||||
Context::Element(Kind::DataType) => compact_data_definition(fields),
|
||||
Context::Element(Kind::Port) => {
|
||||
if let Some(legacy) = fields.get_mut("legacy").and_then(Value::as_object_mut) {
|
||||
omit_nulls(legacy, &["interface"]);
|
||||
}
|
||||
// Every selected PortContract payload field remains required.
|
||||
}
|
||||
Context::Element(Kind::Occurrence) => compact_occurrence(fields),
|
||||
Context::Diagram => {
|
||||
if let Some(options) = fields
|
||||
.get_mut("view_options")
|
||||
.and_then(Value::as_object_mut)
|
||||
{
|
||||
omit_defaults(
|
||||
options,
|
||||
&object(json!({
|
||||
"hidden_edges": [], "relation_kinds": "All",
|
||||
"work_categories": "All", "participant_categories": "All",
|
||||
"coverage": "All", "work_contexts": "All", "exchange_labels": "Name"
|
||||
})),
|
||||
);
|
||||
}
|
||||
if let Some(routes) = fields.get_mut("routes").and_then(Value::as_array_mut) {
|
||||
for route in routes {
|
||||
if let Some(route) = route.as_object_mut() {
|
||||
omit_defaults(
|
||||
route,
|
||||
&object(
|
||||
json!({"waypoints": [], "label": null, "source_attachment": null, "target_attachment": null}),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Route entries, EdgeLabel coordinates, projection machine IDs, and
|
||||
// Only([])/Any([]) selectors remain even when their values are empty.
|
||||
}
|
||||
Context::Trace => {
|
||||
if let Some(baseline) = fields.get_mut("baseline").and_then(Value::as_object_mut) {
|
||||
omit_nulls(baseline, &["semantic"]);
|
||||
}
|
||||
// No recursion through snapshots or semantic historical facets.
|
||||
}
|
||||
// DataValue/TypeRef payloads, EventBody fields, Node geometry/anchors,
|
||||
// contextual endpoints/purposes, and other required records stay intact.
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn compact_data_definition(fields: &mut Map<String, Value>) {
|
||||
for tag in ["Integer", "Real"] {
|
||||
if let Some(payload) = constructor_payload(fields, "definition", tag) {
|
||||
omit_nulls(payload, &["minimum", "maximum"]);
|
||||
}
|
||||
}
|
||||
if let Some(payload) = constructor_payload(fields, "definition", "String") {
|
||||
omit_nulls(payload, &["maximum_length"]);
|
||||
// minimum_length is required inside a selected String constructor.
|
||||
}
|
||||
if let Some(payload) = constructor_payload(fields, "definition", "Record") {
|
||||
omit_nulls(payload, &["supertype"]);
|
||||
}
|
||||
// Boolean has no payload; Enumeration.literals remains required even [].
|
||||
}
|
||||
|
||||
fn compact_occurrence(fields: &mut Map<String, Value>) {
|
||||
if let Some(exchange) = constructor_payload(fields, "body", "Exchange") {
|
||||
omit_defaults(
|
||||
exchange,
|
||||
&object(json!({
|
||||
"exchange": null, "sender": null, "receiver": null,
|
||||
"message_kind": "Asynchronous", "reply_to": null,
|
||||
"values": [], "operation": null
|
||||
})),
|
||||
);
|
||||
if let Some(operation) = exchange.get_mut("operation").and_then(Value::as_object_mut) {
|
||||
omit_nulls(operation, &["allocation"]);
|
||||
// Keep the containing operation field even if its record becomes {}:
|
||||
// explicit unresolved operation intent is not an absent operation.
|
||||
}
|
||||
if let Some(values) = exchange.get_mut("values").and_then(Value::as_array_mut) {
|
||||
for value in values {
|
||||
if let Some(message_value) = value.as_object_mut() {
|
||||
omit_defaults(message_value, &object(json!({"path": []})));
|
||||
}
|
||||
}
|
||||
}
|
||||
// MessageKind::Timer.expression, MessageValue.field/expression remain.
|
||||
}
|
||||
for (tag, optional) in [
|
||||
("Action", &["action", "lifeline"][..]),
|
||||
("State", &["state", "lifeline"][..]),
|
||||
("SequenceReference", &["sequence"][..]),
|
||||
("ScenarioUse", &["scenario"][..]),
|
||||
] {
|
||||
if let Some(payload) = constructor_payload(fields, "body", tag) {
|
||||
omit_nulls(payload, optional);
|
||||
}
|
||||
}
|
||||
// ScenarioUse.bindings, Control.kind, Fragment.operator/branches and
|
||||
// Branch.guard/occurrences are required, including empty strings/lists.
|
||||
}
|
||||
|
||||
fn constructor_payload<'a>(
|
||||
fields: &'a mut Map<String, Value>,
|
||||
key: &str,
|
||||
tag: &str,
|
||||
) -> Option<&'a mut Map<String, Value>> {
|
||||
let wrapper = fields.get_mut(key)?.as_object_mut()?;
|
||||
if wrapper.len() != 1 {
|
||||
return None;
|
||||
}
|
||||
wrapper.get_mut(tag)?.as_object_mut()
|
||||
}
|
||||
|
||||
fn omit_nulls(fields: &mut Map<String, Value>, names: &[&str]) {
|
||||
for name in names {
|
||||
if fields.get(*name).is_some_and(Value::is_null) {
|
||||
fields.remove(*name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn omit_defaults(fields: &mut Map<String, Value>, defaults: &Map<String, Value>) {
|
||||
for (name, default) in defaults {
|
||||
if fields.get(name).is_some_and(|value| same(value, default)) {
|
||||
fields.remove(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Comparison recurses only to compare a known field with its exact declared
|
||||
// default. It never traverses arbitrary data looking for values to remove.
|
||||
fn same(value: &Value, default: &Value) -> bool {
|
||||
match (value, default) {
|
||||
(Value::Object(a), Value::Object(b)) => {
|
||||
a.len() == b.len()
|
||||
&& a.iter()
|
||||
.all(|(key, value)| b.get(key).is_some_and(|d| same(value, d)))
|
||||
}
|
||||
(Value::Array(a), Value::Array(b)) => {
|
||||
a.len() == b.len() && a.iter().zip(b).all(|(value, d)| same(value, d))
|
||||
}
|
||||
(Value::Number(a), Value::Number(b)) => {
|
||||
// serde_json numeric equality alone can equate -0.0 with +0.0.
|
||||
// Extracted f32 coordinates are represented exactly as f64 here.
|
||||
a == b
|
||||
&& match (a.as_f64(), b.as_f64()) {
|
||||
(Some(x), Some(y)) if x == 0.0 && y == 0.0 => x.to_bits() == y.to_bits(),
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
_ => value == default,
|
||||
}
|
||||
}
|
||||
|
||||
fn object(value: Value) -> Map<String, Value> {
|
||||
match value {
|
||||
Value::Object(fields) => fields,
|
||||
_ => unreachable!("literal source default is an object"),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "source_defaults_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,718 @@
|
||||
//! Independent literal oracles for the production source-default policy.
|
||||
use super::{self as policy, Context, Mode};
|
||||
use crate::{Kind, RelationKind};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
#[test]
|
||||
fn complete_comparison_keeps_populated_serializer_skip_values() {
|
||||
for (context, input) in [
|
||||
(
|
||||
Context::Element(Kind::Occurrence),
|
||||
json!({"body":{"Exchange":{"operation":{"allocation":"allocation-id"},"values":[]}}}),
|
||||
),
|
||||
(
|
||||
Context::Node,
|
||||
json!({"port_anchors":[{"port":"port-id","side":"Left","offset":0.0}]}),
|
||||
),
|
||||
(
|
||||
Context::Trace,
|
||||
json!({"baseline":{"semantic":{"historical":"payload"}}}),
|
||||
),
|
||||
] {
|
||||
assert_eq!(rendered(context, &input, Mode::CompleteForEdit), input);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compact_value_fragments_keep_selected_constructor_requirements() {
|
||||
for (context, input, expected) in [
|
||||
(
|
||||
Context::Element(Kind::DataType),
|
||||
json!({"definition":{"String":{"minimum_length":0,"maximum_length":null}},"default":null,"unit":""}),
|
||||
json!({"definition":{"String":{"minimum_length":0}},"default":null,"unit":""}),
|
||||
),
|
||||
(
|
||||
Context::Element(Kind::Event),
|
||||
json!({"body":{"Signal":{"items":[]}}}),
|
||||
json!({"body":{"Signal":{"items":[]}}}),
|
||||
),
|
||||
(
|
||||
Context::Element(Kind::Occurrence),
|
||||
json!({"body":{"Exchange":{"operation":{"allocation":null},"values":[]}},"precondition":""}),
|
||||
json!({"body":{"Exchange":{"operation":{}}},"precondition":""}),
|
||||
),
|
||||
(
|
||||
Context::Element(Kind::Occurrence),
|
||||
json!({"body":{"ScenarioUse":{"scenario":null,"bindings":[]}}}),
|
||||
json!({"body":{"ScenarioUse":{"bindings":[]}}}),
|
||||
),
|
||||
] {
|
||||
assert_eq!(rendered(context, &input, Mode::CompactValues), expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_scenario_default_is_independent_of_graphical_creation() {
|
||||
use crate::{ElementBody, Layer, ScenarioViewpoint, SharedBody, language};
|
||||
let project = language::parse(
|
||||
"arcadia 7; project p \"P\" { oa_scenario o \"O\" {} sa_scenario s \"S\" {} }",
|
||||
)
|
||||
.unwrap();
|
||||
for element in project.elements.values() {
|
||||
assert!(matches!(
|
||||
element.body.shared(),
|
||||
Some(SharedBody::Scenario {
|
||||
viewpoint: ScenarioViewpoint::MixedLegacy,
|
||||
..
|
||||
})
|
||||
));
|
||||
assert!(!matches!(
|
||||
ElementBody::new(Kind::Scenario, element.layer())
|
||||
.unwrap()
|
||||
.shared(),
|
||||
Some(SharedBody::Scenario {
|
||||
viewpoint: ScenarioViewpoint::MixedLegacy,
|
||||
..
|
||||
})
|
||||
));
|
||||
}
|
||||
let source = language::format(&project);
|
||||
assert!(!source.contains("description"));
|
||||
assert!(!source.contains("parent"));
|
||||
assert!(!source.contains("viewpoint"));
|
||||
assert_eq!(language::parse(&source).unwrap(), project);
|
||||
|
||||
let mut typed = project;
|
||||
for element in typed.elements.values_mut() {
|
||||
let viewpoint = if element.layer() == Layer::Oa {
|
||||
ScenarioViewpoint::OperationalActivity
|
||||
} else {
|
||||
ScenarioViewpoint::Functional
|
||||
};
|
||||
*element.body.shared_mut().unwrap() = SharedBody::Scenario {
|
||||
viewpoint,
|
||||
context: None,
|
||||
precondition: String::new(),
|
||||
postcondition: String::new(),
|
||||
steps: vec![],
|
||||
};
|
||||
}
|
||||
let source = language::format(&typed);
|
||||
assert!(source.contains("viewpoint OperationalActivity;"));
|
||||
assert!(source.contains("viewpoint Functional;"));
|
||||
assert_eq!(language::parse(&source).unwrap(), typed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_parser_defaults_preserve_required_fields_and_kind_checks() {
|
||||
for (declaration, message) in [
|
||||
(
|
||||
"oa_port p0 \"Port\" {}",
|
||||
"Missing required field 'contract'",
|
||||
),
|
||||
(
|
||||
"allocation r \"R\" { source @owner; }",
|
||||
"Missing required field 'target'",
|
||||
),
|
||||
(
|
||||
"functional_exchange r \"R\" { source @owner; target @work; }",
|
||||
"Missing required field 'source_port'",
|
||||
),
|
||||
(
|
||||
"contextual_link r \"R\" {}",
|
||||
"Missing required field 'owner'",
|
||||
),
|
||||
("node n { diagram @d; }", "Missing required field 'element'"),
|
||||
("artifact a \"A\" {}", "Missing required field 'owner'"),
|
||||
("trace t \"T\" {}", "Missing required field 'oa'"),
|
||||
(
|
||||
"oa_state s \"S\" { kind Initial; }",
|
||||
"Declaration kind disagrees",
|
||||
),
|
||||
(
|
||||
"oa_activity a \"A\" { unknown true; }",
|
||||
"Unknown field 'unknown'",
|
||||
),
|
||||
(
|
||||
"oa_data_type t \"T\" { definition { String {}; }; }",
|
||||
"minimum_length",
|
||||
),
|
||||
(
|
||||
"oa_occurrence o \"O\" { body { ScenarioUse {}; }; }",
|
||||
"bindings",
|
||||
),
|
||||
] {
|
||||
let source = format!(
|
||||
"arcadia 7; project p \"P\" {{ oa_entity owner \"Owner\" {{}} oa_activity work \"Work\" {{}} diagram d \"D\" {{}} {declaration} }}"
|
||||
);
|
||||
let errors = crate::language::parse(&source).unwrap_err();
|
||||
assert!(
|
||||
errors.iter().any(|error| error.message.contains(message)),
|
||||
"{declaration}: {errors:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn rendered(context: Context, input: &Value, mode: Mode) -> Value {
|
||||
Value::Object(policy::fields(context, input.as_object().unwrap(), mode))
|
||||
}
|
||||
|
||||
fn compact(context: Context, input: Value, expected: Value) {
|
||||
let original = input.clone();
|
||||
let result = rendered(context, &input, Mode::Compact);
|
||||
assert_eq!(result, expected, "context {context:?}");
|
||||
assert_eq!(input, original, "the input remains immutable");
|
||||
assert_eq!(
|
||||
rendered(context, &result, Mode::Compact),
|
||||
result,
|
||||
"compact policy is idempotent"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn complete_comparison_restores_only_the_three_known_skips() {
|
||||
let exchange = json!({"body":{"Exchange":{"exchange":null,"sender":null,"receiver":null,"message_kind":"Asynchronous","reply_to":null,"values":[]}}});
|
||||
let mut complete = exchange.clone();
|
||||
complete["body"]["Exchange"]["operation"] = Value::Null;
|
||||
assert_eq!(
|
||||
rendered(
|
||||
Context::Element(Kind::Occurrence),
|
||||
&exchange,
|
||||
Mode::CompleteForEdit
|
||||
),
|
||||
complete
|
||||
);
|
||||
assert_eq!(
|
||||
rendered(
|
||||
Context::Node,
|
||||
&json!({"element":"e","diagram":"d"}),
|
||||
Mode::CompleteForEdit
|
||||
),
|
||||
json!({"element":"e","diagram":"d","port_anchors":[]})
|
||||
);
|
||||
assert_eq!(
|
||||
rendered(
|
||||
Context::Trace,
|
||||
&json!({"baseline":{"oa":{"name":"","description":""},"sa":{"name":"","description":""}}}),
|
||||
Mode::CompleteForEdit
|
||||
),
|
||||
json!({"baseline":{"oa":{"name":"","description":""},"sa":{"name":"","description":""},"semantic":null}})
|
||||
);
|
||||
for input in [
|
||||
json!({"body":{"Action":{"action":null,"lifeline":null}}}),
|
||||
json!({"body":{"State":{"state":null,"lifeline":null}}}),
|
||||
] {
|
||||
assert_eq!(
|
||||
rendered(
|
||||
Context::Element(Kind::Occurrence),
|
||||
&input,
|
||||
Mode::CompleteForEdit
|
||||
),
|
||||
input
|
||||
);
|
||||
}
|
||||
for input in [
|
||||
json!({"baseline":null}),
|
||||
json!({"baseline":{"semantic":{"historical":[null,[],false,0,""]},"oa":{"name":"n","description":""},"sa":{"name":"s","description":""}}}),
|
||||
] {
|
||||
assert_eq!(
|
||||
rendered(Context::Trace, &input, Mode::CompleteForEdit),
|
||||
input
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_element_omits_only_empty_common_defaults() {
|
||||
for kind in Kind::ALL {
|
||||
// Future/unknown payloads are preservation sentinels, not admitted source.
|
||||
compact(
|
||||
Context::Element(kind),
|
||||
json!({"description":"","parent":null,"future":{"empty":[],"absent":null,"zero":0,"flag":false}}),
|
||||
json!({"future":{"empty":[],"absent":null,"zero":0,"flag":false}}),
|
||||
);
|
||||
compact(
|
||||
Context::Element(kind),
|
||||
json!({"description":" \n","parent":"owner"}),
|
||||
json!({"description":" \n","parent":"owner"}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ordinary_element_defaults_are_context_specific() {
|
||||
for kind in [Kind::Process, Kind::FunctionalChain] {
|
||||
compact(Context::Element(kind), json!({"steps":[]}), json!({}));
|
||||
compact(
|
||||
Context::Element(kind),
|
||||
json!({"steps":["second","first"]}),
|
||||
json!({"steps":["second","first"]}),
|
||||
);
|
||||
}
|
||||
compact(
|
||||
Context::Element(Kind::ExchangeItem),
|
||||
json!({"kind":"Information","mechanism":"Unspecified","supertype":null}),
|
||||
json!({}),
|
||||
);
|
||||
compact(
|
||||
Context::Element(Kind::ExchangeItem),
|
||||
json!({"kind":"Energy","mechanism":"Flow","supertype":"base"}),
|
||||
json!({"kind":"Energy","mechanism":"Flow","supertype":"base"}),
|
||||
);
|
||||
compact(
|
||||
Context::Element(Kind::ExchangeCategory),
|
||||
json!({"family":"Work","exchanges":[]}),
|
||||
json!({}),
|
||||
);
|
||||
compact(
|
||||
Context::Element(Kind::ExchangeCategory),
|
||||
json!({"family":"Participant","exchanges":["x"]}),
|
||||
json!({"family":"Participant","exchanges":["x"]}),
|
||||
);
|
||||
compact(
|
||||
Context::Element(Kind::Interface),
|
||||
json!({"supertype":null}),
|
||||
json!({}),
|
||||
);
|
||||
compact(
|
||||
Context::Element(Kind::Lifeline),
|
||||
json!({"represented":null}),
|
||||
json!({}),
|
||||
);
|
||||
compact(
|
||||
Context::Element(Kind::Constraint),
|
||||
json!({"expression":"","constrained":[]}),
|
||||
json!({}),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scenario_source_default_never_uses_the_graphical_viewpoint_default() {
|
||||
compact(
|
||||
Context::Element(Kind::Scenario),
|
||||
json!({"viewpoint":"MixedLegacy","context":null,"precondition":"","postcondition":"","steps":[]}),
|
||||
json!({}),
|
||||
);
|
||||
for viewpoint in [
|
||||
"OperationalActivity",
|
||||
"Functional",
|
||||
"ParticipantInteraction",
|
||||
"BoundaryInterface",
|
||||
] {
|
||||
compact(
|
||||
Context::Element(Kind::Scenario),
|
||||
json!({"viewpoint":viewpoint,"context":"scope","precondition":"ready","postcondition":"","steps":["two","one"]}),
|
||||
json!({"viewpoint":viewpoint,"context":"scope","precondition":"ready","steps":["two","one"]}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_state_header_supplies_its_own_kind() {
|
||||
for (kind, name) in [
|
||||
(Kind::State, "State"),
|
||||
(Kind::Mode, "Mode"),
|
||||
(Kind::Initial, "Initial"),
|
||||
(Kind::Final, "Final"),
|
||||
(Kind::Choice, "Choice"),
|
||||
(Kind::Junction, "Junction"),
|
||||
(Kind::Fork, "Fork"),
|
||||
(Kind::Join, "Join"),
|
||||
(Kind::ShallowHistory, "ShallowHistory"),
|
||||
(Kind::DeepHistory, "DeepHistory"),
|
||||
(Kind::EntryPoint, "EntryPoint"),
|
||||
(Kind::ExitPoint, "ExitPoint"),
|
||||
(Kind::Terminate, "Terminate"),
|
||||
] {
|
||||
compact(
|
||||
Context::Element(kind),
|
||||
json!({"kind":name,"entry":"","exit":"","do_activity":""}),
|
||||
json!({}),
|
||||
);
|
||||
}
|
||||
// Invalid mismatches must not be silently normalized by the writer policy.
|
||||
compact(
|
||||
Context::Element(Kind::Mode),
|
||||
json!({"kind":"State","entry":"work"}),
|
||||
json!({"kind":"State","entry":"work"}),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn data_field_unbounded_and_present_empty_values_are_not_defaults() {
|
||||
compact(
|
||||
Context::Element(Kind::DataField),
|
||||
json!({"type_ref":"String","minimum":1,"maximum":1,"unit":"","direction":"Unspecified","storage":"Value","default":null}),
|
||||
json!({}),
|
||||
);
|
||||
compact(
|
||||
Context::Element(Kind::DataField),
|
||||
json!({"type_ref":{"Named":"record"},"minimum":0,"maximum":null,"unit":"m","direction":"Out","storage":"Reference","default":[]}),
|
||||
json!({"type_ref":{"Named":"record"},"minimum":0,"maximum":null,"unit":"m","direction":"Out","storage":"Reference","default":[]}),
|
||||
);
|
||||
for value in [
|
||||
json!({"Boolean":false}),
|
||||
json!({"Integer":0}),
|
||||
json!({"Real":0.0}),
|
||||
json!({"String":""}),
|
||||
] {
|
||||
compact(
|
||||
Context::Element(Kind::DataField),
|
||||
json!({"default":[value.clone()]}),
|
||||
json!({"default":[value]}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn data_definition_whole_default_and_selected_required_fields_differ() {
|
||||
compact(
|
||||
Context::Element(Kind::DataType),
|
||||
json!({"definition":{"String":{"minimum_length":0,"maximum_length":null}},"default":null,"unit":""}),
|
||||
json!({}),
|
||||
);
|
||||
for (input, expected) in [
|
||||
(
|
||||
json!({"Integer":{"minimum":null,"maximum":null}}),
|
||||
json!({"Integer":{}}),
|
||||
),
|
||||
(
|
||||
json!({"Real":{"minimum":null,"maximum":2.5}}),
|
||||
json!({"Real":{"maximum":2.5}}),
|
||||
),
|
||||
(
|
||||
json!({"String":{"minimum_length":4,"maximum_length":null}}),
|
||||
json!({"String":{"minimum_length":4}}),
|
||||
),
|
||||
(json!({"Record":{"supertype":null}}), json!({"Record":{}})),
|
||||
(
|
||||
json!({"Enumeration":{"literals":[]}}),
|
||||
json!({"Enumeration":{"literals":[]}}),
|
||||
),
|
||||
] {
|
||||
compact(
|
||||
Context::Element(Kind::DataType),
|
||||
json!({"definition":input}),
|
||||
json!({"definition":expected}),
|
||||
);
|
||||
}
|
||||
compact(
|
||||
Context::Element(Kind::DataType),
|
||||
json!({"definition":"Boolean","default":{"Boolean":false}}),
|
||||
json!({"definition":"Boolean","default":{"Boolean":false}}),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn port_contract_and_its_required_payload_stay_explicit() {
|
||||
compact(
|
||||
Context::Element(Kind::Port),
|
||||
json!({"contract":"Legacy","legacy":{"direction":"Out","interface":null}}),
|
||||
json!({"contract":"Legacy","legacy":{"direction":"Out"}}),
|
||||
);
|
||||
for contract in [
|
||||
json!({"FunctionInput":{"incoming_items":[]}}),
|
||||
json!({"FunctionOutput":{"outgoing_items":[]}}),
|
||||
json!({"Component":{"kind":"Standard","orientation":"Unset"}}),
|
||||
] {
|
||||
compact(
|
||||
Context::Element(Kind::Port),
|
||||
json!({"contract":contract.clone(),"legacy":null}),
|
||||
json!({"contract":contract}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn occurrence_whole_default_is_distinct_from_selected_constructor_payload() {
|
||||
compact(
|
||||
Context::Element(Kind::Occurrence),
|
||||
json!({"body":{"Exchange":{"exchange":null,"sender":null,"receiver":null,"message_kind":"Asynchronous","reply_to":null,"values":[]}},"precondition":"","postcondition":""}),
|
||||
json!({}),
|
||||
);
|
||||
for (input, expected) in [
|
||||
(
|
||||
json!({"Action":{"action":null,"lifeline":null}}),
|
||||
json!({"Action":{}}),
|
||||
),
|
||||
(
|
||||
json!({"State":{"state":null,"lifeline":null}}),
|
||||
json!({"State":{}}),
|
||||
),
|
||||
(
|
||||
json!({"SequenceReference":{"sequence":null}}),
|
||||
json!({"SequenceReference":{}}),
|
||||
),
|
||||
(
|
||||
json!({"ScenarioUse":{"scenario":null,"bindings":[]}}),
|
||||
json!({"ScenarioUse":{"bindings":[]}}),
|
||||
),
|
||||
(
|
||||
json!({"Branch":{"guard":"","occurrences":[]}}),
|
||||
json!({"Branch":{"guard":"","occurrences":[]}}),
|
||||
),
|
||||
(
|
||||
json!({"Fragment":{"operator":"Parallel","branches":[]}}),
|
||||
json!({"Fragment":{"operator":"Parallel","branches":[]}}),
|
||||
),
|
||||
(
|
||||
json!({"Control":{"kind":"Choice"}}),
|
||||
json!({"Control":{"kind":"Choice"}}),
|
||||
),
|
||||
] {
|
||||
compact(
|
||||
Context::Element(Kind::Occurrence),
|
||||
json!({"body":input}),
|
||||
json!({"body":expected}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unresolved_operation_and_required_message_value_are_preserved() {
|
||||
compact(
|
||||
Context::Element(Kind::Occurrence),
|
||||
json!({"body":{"Exchange":{"exchange":null,"sender":null,"receiver":null,"message_kind":"Asynchronous","reply_to":null,"values":[{"field":"f","path":[],"expression":""}],"operation":{"allocation":null}}}}),
|
||||
json!({"body":{"Exchange":{"values":[{"field":"f","expression":""}],"operation":{}}}}),
|
||||
);
|
||||
compact(
|
||||
Context::Element(Kind::Occurrence),
|
||||
json!({"body":{"Exchange":{"exchange":"flow","sender":"a","receiver":"b","message_kind":{"Timer":{"expression":""}},"reply_to":"request","values":[],"operation":null}}}),
|
||||
json!({"body":{"Exchange":{"exchange":"flow","sender":"a","receiver":"b","message_kind":{"Timer":{"expression":""}},"reply_to":"request"}}}),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn event_body_whole_default_does_not_prune_selected_required_fields() {
|
||||
compact(
|
||||
Context::Element(Kind::Event),
|
||||
json!({"body":{"Signal":{"items":[]}}}),
|
||||
json!({}),
|
||||
);
|
||||
for body in [
|
||||
json!({"Signal":{"items":["i"]}}),
|
||||
json!({"Time":{"kind":"After","expression":""}}),
|
||||
json!({"Change":{"expression":""}}),
|
||||
] {
|
||||
compact(
|
||||
Context::Element(Kind::Event),
|
||||
json!({"body":body.clone()}),
|
||||
json!({"body":body}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relation_required_endpoints_and_contextual_payloads_remain() {
|
||||
for kind in RelationKind::ALL {
|
||||
compact(
|
||||
Context::Relation(kind),
|
||||
json!({"description":"","source":"s","target":"t","future":[]}),
|
||||
json!({"source":"s","target":"t","future":[]}),
|
||||
);
|
||||
}
|
||||
compact(
|
||||
Context::Relation(RelationKind::FunctionalExchange),
|
||||
json!({"source":"s","target":"t","source_port":"sp","target_port":"tp","items":[]}),
|
||||
json!({"source":"s","target":"t","source_port":"sp","target_port":"tp"}),
|
||||
);
|
||||
let contextual = json!({"owner":"o","source":{"references":[],"occurrence":"s"},"target":{"references":[],"occurrence":"t"},"purpose":{"Precedence":{"guard":""}}});
|
||||
compact(
|
||||
Context::Relation(RelationKind::ContextualLink),
|
||||
contextual.clone(),
|
||||
contextual,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relation_defaults_preserve_contextual_orientation_and_multiplicity() {
|
||||
for (kind, orientation) in [
|
||||
(RelationKind::BoundaryExchange, true),
|
||||
(RelationKind::CommunicationMean, false),
|
||||
] {
|
||||
compact(
|
||||
Context::Relation(kind),
|
||||
json!({"oriented":orientation,"items":[]}),
|
||||
json!({}),
|
||||
);
|
||||
compact(
|
||||
Context::Relation(kind),
|
||||
json!({"oriented":!orientation}),
|
||||
json!({"oriented":!orientation}),
|
||||
);
|
||||
}
|
||||
compact(
|
||||
Context::Relation(RelationKind::DataAssociation),
|
||||
json!({"source_role":"","target_role":"","source_minimum":0,"target_minimum":0,"source_maximum":null,"target_maximum":null,"source_navigable":false,"target_navigable":false}),
|
||||
json!({}),
|
||||
);
|
||||
compact(
|
||||
Context::Relation(RelationKind::DataAssociation),
|
||||
json!({"source_maximum":0,"target_maximum":1,"source_navigable":true}),
|
||||
json!({"source_maximum":0,"target_maximum":1,"source_navigable":true}),
|
||||
);
|
||||
compact(
|
||||
Context::Relation(RelationKind::InterfaceItemAllocation),
|
||||
json!({"order":0,"transmission":"Unspecified","acquisition":"Unspecified"}),
|
||||
json!({}),
|
||||
);
|
||||
compact(
|
||||
Context::Relation(RelationKind::CapabilityExtend),
|
||||
json!({"condition":""}),
|
||||
json!({}),
|
||||
);
|
||||
compact(
|
||||
Context::Relation(RelationKind::SequenceLink),
|
||||
json!({"guard":""}),
|
||||
json!({}),
|
||||
);
|
||||
compact(
|
||||
Context::Relation(RelationKind::StateAction),
|
||||
json!({"phase":"Do"}),
|
||||
json!({}),
|
||||
);
|
||||
compact(
|
||||
Context::Relation(RelationKind::StateTransition),
|
||||
json!({"transition_kind":"External","trigger":"","guard":"","effect":"","triggers":[],"effects":[]}),
|
||||
json!({}),
|
||||
);
|
||||
compact(
|
||||
Context::Relation(RelationKind::StateTransition),
|
||||
json!({"transition_kind":"Internal","triggers":["t"],"effects":["e"]}),
|
||||
json!({"transition_kind":"Internal","triggers":["t"],"effects":["e"]}),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diagram_options_remove_only_unrestricted_defaults() {
|
||||
let defaults = json!({"hidden_edges":[],"relation_kinds":"All","work_categories":"All","participant_categories":"All","coverage":"All","work_contexts":"All","exchange_labels":"Name"});
|
||||
compact(
|
||||
Context::Diagram,
|
||||
json!({"description":"","layer":"Oa","projection":"Overview","routes":[],"view_options":defaults}),
|
||||
json!({}),
|
||||
);
|
||||
compact(
|
||||
Context::Diagram,
|
||||
json!({"layer":"Sa","projection":{"StateMachine":{"machine":"m"}},"view_options":{"hidden_edges":[],"relation_kinds":{"Only":[]},"work_categories":{"Any":[]},"participant_categories":"All","coverage":"All","work_contexts":{"Any":[]},"exchange_labels":"Name"}}),
|
||||
json!({"layer":"Sa","projection":{"StateMachine":{"machine":"m"}},"view_options":{"relation_kinds":{"Only":[]},"work_categories":{"Any":[]},"work_contexts":{"Any":[]}}}),
|
||||
);
|
||||
compact(
|
||||
Context::Diagram,
|
||||
json!({"routes":[{"edge":"e","waypoints":[],"label":null}]}),
|
||||
json!({"routes":[{"edge":"e"}]}),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn node_whole_defaults_preserve_negative_zero_and_required_coordinates() {
|
||||
compact(
|
||||
Context::Node,
|
||||
json!({"diagram":"d","element":"e","position":{"x":0.0,"y":0.0},"size":{"width":224.0,"height":94.0},"collapsed":false}),
|
||||
json!({"diagram":"d","element":"e"}),
|
||||
);
|
||||
for position in [
|
||||
json!({"x":-0.0,"y":0.0}),
|
||||
json!({"x":0.0,"y":-0.0}),
|
||||
json!({"x":1.0,"y":0.0}),
|
||||
] {
|
||||
let result = rendered(
|
||||
Context::Node,
|
||||
&json!({"position":position.clone()}),
|
||||
Mode::Compact,
|
||||
);
|
||||
assert_eq!(result, json!({"position":position.clone()}));
|
||||
for coordinate in ["x", "y"] {
|
||||
assert_eq!(
|
||||
result["position"][coordinate].as_f64().unwrap().to_bits(),
|
||||
position[coordinate].as_f64().unwrap().to_bits(),
|
||||
"coordinate sign is part of fidelity"
|
||||
);
|
||||
}
|
||||
}
|
||||
compact(
|
||||
Context::Node,
|
||||
json!({"size":{"width":300.0,"height":94.0},"port_anchors":[{"port":"p","side":"Left","offset":0.0}]}),
|
||||
json!({"size":{"width":300.0,"height":94.0},"port_anchors":[{"port":"p","side":"Left","offset":0.0}]}),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn artifact_present_empty_strings_and_trace_history_stay_distinct() {
|
||||
compact(
|
||||
Context::Artifact,
|
||||
json!({"owner":"o","description":"","relation":"References","uri":"","revision":null,"fragment":null}),
|
||||
json!({"owner":"o"}),
|
||||
);
|
||||
compact(
|
||||
Context::Artifact,
|
||||
json!({"owner":"o","relation":"Implements","revision":"","fragment":""}),
|
||||
json!({"owner":"o","relation":"Implements","revision":"","fragment":""}),
|
||||
);
|
||||
compact(
|
||||
Context::Trace,
|
||||
json!({"oa":"o","sa":"s","description":"","kind":"Refinement","baseline":null}),
|
||||
json!({"oa":"o","sa":"s"}),
|
||||
);
|
||||
compact(
|
||||
Context::Trace,
|
||||
json!({"baseline":{"oa":{"name":"","description":""},"sa":{"name":"s","description":""}}}),
|
||||
json!({"baseline":{"oa":{"name":"","description":""},"sa":{"name":"s","description":""}}}),
|
||||
);
|
||||
let baseline = json!({"oa":{"name":"o","description":""},"sa":{"name":"s","description":""},"semantic":{"facets":[{"Value":null},{"Value":[]},{"Value":false},{"Value":0},{"Value":""}]}});
|
||||
compact(
|
||||
Context::Trace,
|
||||
json!({"kind":"Manual","baseline":baseline.clone()}),
|
||||
json!({"kind":"Manual","baseline":baseline}),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_future_fields_prevent_whole_default_removal() {
|
||||
// These sentinels exercise preservation, not current parser admission of
|
||||
// unknown fields. Only known slots may lose their redundant defaults.
|
||||
compact(
|
||||
Context::Element(Kind::DataType),
|
||||
json!({"definition":{"String":{"minimum_length":0,"maximum_length":null,"future":[]}}}),
|
||||
json!({"definition":{"String":{"minimum_length":0,"future":[]}}}),
|
||||
);
|
||||
compact(
|
||||
Context::Element(Kind::Occurrence),
|
||||
json!({"body":{"Exchange":{"exchange":null,"sender":null,"receiver":null,"message_kind":"Asynchronous","reply_to":null,"values":[],"operation":null,"future":false}}}),
|
||||
json!({"body":{"Exchange":{"future":false}}}),
|
||||
);
|
||||
compact(
|
||||
Context::Diagram,
|
||||
json!({"view_options":{"hidden_edges":[],"relation_kinds":"All","work_categories":"All","participant_categories":"All","coverage":"All","work_contexts":"All","exchange_labels":"Name","future":null}}),
|
||||
json!({"view_options":{"future":null}}),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_and_multikey_constructor_wrappers_are_not_reinterpreted() {
|
||||
for definition in [
|
||||
json!({"Future":{"minimum":null,"maximum":null}}),
|
||||
json!({"String":{"minimum_length":0,"maximum_length":null},"Future":{}}),
|
||||
] {
|
||||
compact(
|
||||
Context::Element(Kind::DataType),
|
||||
json!({"definition":definition.clone()}),
|
||||
json!({"definition":definition}),
|
||||
);
|
||||
}
|
||||
for body in [
|
||||
json!({"Future":{"operation":null,"values":[]}}),
|
||||
json!({"Exchange":{"exchange":null,"values":[]},"Future":{}}),
|
||||
] {
|
||||
let fields = json!({"body":body});
|
||||
assert_eq!(
|
||||
rendered(
|
||||
Context::Element(Kind::Occurrence),
|
||||
&fields,
|
||||
Mode::CompleteForEdit
|
||||
),
|
||||
fields,
|
||||
"do not invent a selected Exchange constructor in an unknown wrapper",
|
||||
);
|
||||
compact(Context::Element(Kind::Occurrence), fields.clone(), fields);
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,13 @@
|
||||
//! may remain after interruption during staging/completed cleanup. They contain
|
||||
//! no pending transaction unless named `.arcadia-save-ENTRY.journal`. Windows
|
||||
//! has no portable directory fsync; this is not a power-loss durability promise.
|
||||
//! Journal v2 records whether writes may have begun with a flushed marker.
|
||||
//! Recovery of a v2 journal without that marker discards only journal metadata;
|
||||
//! v1 journals retain their prior rollback rules.
|
||||
//! Journal publication refuses replacement on Windows and Linux, including a
|
||||
//! competing empty directory. Linux requires kernel/filesystem support for
|
||||
//! `renameat2(RENAME_NOREPLACE)` and fails without a fallback when unavailable.
|
||||
//! Other operating systems retain standard rename semantics.
|
||||
use crate::{language, source_project::*};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{
|
||||
@@ -22,6 +29,8 @@ use thiserror::Error;
|
||||
const MAX_PATH_BYTES: usize = 4096;
|
||||
const MAX_JOURNAL_BYTES: usize = 32 * 1024 * 1024;
|
||||
const MAX_JOURNAL_FILES: usize = 4096;
|
||||
const WRITE_STARTED: &str = "write-started";
|
||||
const WRITE_STARTED_BYTES: &[u8] = b"Arcadia journal v2: model writes may have started\n";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum DiskExpectation {
|
||||
@@ -31,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 {
|
||||
@@ -517,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() {
|
||||
@@ -588,6 +599,127 @@ fn staged_path(journal: &Path, index: usize, suffix: &str) -> PathBuf {
|
||||
journal.join(format!("{index}.{suffix}"))
|
||||
}
|
||||
|
||||
fn publish_journal(stage: &Path, journal: &Path) -> Result<(), SourceFileError> {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
publish_journal_with_retry(
|
||||
stage,
|
||||
journal,
|
||||
rename_journal_without_replacement,
|
||||
std::thread::sleep,
|
||||
)
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
rename_journal_without_replacement(stage, journal).map_err(|error| io_at(journal, error))
|
||||
}
|
||||
// Other platforms retain their existing rename behavior; the native
|
||||
// no-replacement publication guarantee is currently Windows/Linux only.
|
||||
#[cfg(not(any(windows, target_os = "linux")))]
|
||||
{
|
||||
fs::rename(stage, journal).map_err(|error| io_at(journal, error))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn rename_journal_without_replacement(stage: &Path, journal: &Path) -> io::Result<()> {
|
||||
use std::{ffi::CString, os::unix::ffi::OsStrExt};
|
||||
|
||||
let path_bytes = |path: &Path| {
|
||||
CString::new(path.as_os_str().as_bytes()).map_err(|_| {
|
||||
io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
"Journal path contains an interior NUL.",
|
||||
)
|
||||
})
|
||||
};
|
||||
let (stage, journal) = (path_bytes(stage)?, path_bytes(journal)?);
|
||||
// Use libc's architecture-specific syscall number, avoiding a dependency on
|
||||
// the newer glibc renameat2 wrapper. Both NUL-terminated byte buffers remain
|
||||
// alive for the call. RENAME_NOREPLACE makes even an empty competing directory
|
||||
// an error. Unsupported kernels/filesystems fail closed: never fall back to
|
||||
// ordinary rename, whose replacement semantics would lose journal ownership.
|
||||
let result = unsafe {
|
||||
libc::syscall(
|
||||
libc::SYS_renameat2,
|
||||
libc::AT_FDCWD as libc::c_long,
|
||||
stage.as_ptr(),
|
||||
libc::AT_FDCWD as libc::c_long,
|
||||
journal.as_ptr(),
|
||||
libc::RENAME_NOREPLACE as libc::c_ulong,
|
||||
)
|
||||
};
|
||||
if result == -1 {
|
||||
Err(io::Error::last_os_error())
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn rename_journal_without_replacement(stage: &Path, journal: &Path) -> io::Result<()> {
|
||||
use std::os::windows::ffi::OsStrExt;
|
||||
#[link(name = "kernel32")]
|
||||
unsafe extern "system" {
|
||||
fn MoveFileExW(existing: *const u16, destination: *const u16, flags: u32) -> i32;
|
||||
}
|
||||
let wide = |path: &Path| -> io::Result<Vec<u16>> {
|
||||
let mut value: Vec<_> = path.as_os_str().encode_wide().collect();
|
||||
if value.contains(&0) {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
"Journal path contains an interior NUL.",
|
||||
));
|
||||
}
|
||||
value.push(0);
|
||||
Ok(value)
|
||||
};
|
||||
let (stage, journal) = (wide(stage)?, wide(journal)?);
|
||||
// Both terminated UTF-16 buffers live through the call. Flags=0 requests
|
||||
// neither replacement, cross-volume copying nor deferred execution. Unlike
|
||||
// fs::rename on modern Windows, an existing empty directory is not replaced.
|
||||
if unsafe { MoveFileExW(stage.as_ptr(), journal.as_ptr(), 0) } == 0 {
|
||||
Err(io::Error::last_os_error())
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// Windows scanners may briefly open newly written stage files without delete
|
||||
// sharing. Retry only publication, before any model file has been replaced.
|
||||
// Ownership still transfers only after one successful rename. Never overwrite
|
||||
// a journal that appeared while waiting, and retain the final OS error/path.
|
||||
#[cfg(any(windows, test))]
|
||||
fn publish_journal_with_retry(
|
||||
stage: &Path,
|
||||
journal: &Path,
|
||||
mut rename: impl FnMut(&Path, &Path) -> io::Result<()>,
|
||||
mut pause: impl FnMut(std::time::Duration),
|
||||
) -> Result<(), SourceFileError> {
|
||||
let mut delays = [5, 10, 25, 50, 100, 200].into_iter();
|
||||
loop {
|
||||
match fs::symlink_metadata(journal) {
|
||||
Ok(_) => return Err(SourceFileError::RecoveryRequired(journal.into())),
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
|
||||
Err(error) => return Err(io_at(journal, error)),
|
||||
}
|
||||
match rename(stage, journal) {
|
||||
Ok(()) => return Ok(()),
|
||||
Err(error) => {
|
||||
// ERROR_ACCESS_DENIED, ERROR_SHARING_VIOLATION, ERROR_LOCK_VIOLATION.
|
||||
// A permanent ACL failure is still returned after at most 390 ms.
|
||||
if matches!(error.raw_os_error(), Some(5 | 32 | 33))
|
||||
&& let Some(delay) = delays.next()
|
||||
{
|
||||
pause(std::time::Duration::from_millis(delay));
|
||||
continue;
|
||||
}
|
||||
return Err(io_at(journal, error));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn write_synced(path: &Path, bytes: &[u8]) -> Result<(), SourceFileError> {
|
||||
let mut file = File::options()
|
||||
.write(true)
|
||||
@@ -699,6 +831,26 @@ fn save_with_hook(
|
||||
compiled: &CompiledSourceProject,
|
||||
limits: ProjectLimits,
|
||||
mut after_replace: impl FnMut(usize) -> io::Result<()>,
|
||||
) -> Result<DiskProject, SourceFileError> {
|
||||
save_with_phases(snapshot, compiled, limits, |phase| match phase {
|
||||
SavePhase::AfterReplace(count) => after_replace(count),
|
||||
_ => Ok(()),
|
||||
})
|
||||
}
|
||||
|
||||
enum SavePhase {
|
||||
BeforePublish,
|
||||
AfterPublish,
|
||||
AfterWriteMarker,
|
||||
AfterReplace(usize),
|
||||
BeforeRetire,
|
||||
}
|
||||
|
||||
fn save_with_phases(
|
||||
snapshot: &DiskProject,
|
||||
compiled: &CompiledSourceProject,
|
||||
limits: ProjectLimits,
|
||||
mut phase: impl FnMut(SavePhase) -> io::Result<()>,
|
||||
) -> Result<DiskProject, SourceFileError> {
|
||||
check_snapshot(snapshot, limits)?;
|
||||
let checked = compile_bundle(&compiled.bundle, limits).map_err(|diagnostics| {
|
||||
@@ -739,7 +891,7 @@ fn save_with_hook(
|
||||
// Stable dependency order, with the project entry committed last.
|
||||
entries.sort_by_key(|file| (file.path == snapshot.bundle.entry, file.path.clone()));
|
||||
let journal = Journal {
|
||||
version: 1,
|
||||
version: 2,
|
||||
entry: snapshot.bundle.entry.clone(),
|
||||
files: entries,
|
||||
};
|
||||
@@ -775,20 +927,46 @@ fn save_with_hook(
|
||||
}
|
||||
write_synced(&stage.path().join("journal.json"), &metadata)?;
|
||||
sync_directory(stage.path())?;
|
||||
phase(SavePhase::BeforePublish).map_err(|error| io_at(&journal_dir, error))?;
|
||||
// Publishing a complete directory makes interrupted staging harmless.
|
||||
fs::rename(stage.path(), &journal_dir).map_err(|error| io_at(&journal_dir, error))?;
|
||||
publish_journal(stage.path(), &journal_dir)?;
|
||||
let _ = stage.keep(); // The renamed directory now belongs to recovery.
|
||||
sync_directory(&snapshot.root)?;
|
||||
phase(SavePhase::AfterPublish).map_err(|error| io_at(&journal_dir, error))?;
|
||||
// Publication may have waited while another writer completed the same
|
||||
// candidate. Nothing has been written by this save: never interpret their
|
||||
// matching after-bytes as ours and roll them back. The absent v2 marker
|
||||
// also preserves this distinction after interruption or retirement failure.
|
||||
if let Err(error) = check_conflicts(&snapshot.root, &snapshot.expectations) {
|
||||
let retirement = phase(SavePhase::BeforeRetire)
|
||||
.map_err(|error| io_at(&journal_dir, error))
|
||||
.and_then(|()| retire_journal(&snapshot.root, &journal_dir, &journal));
|
||||
return match retirement {
|
||||
Ok(()) => Err(error),
|
||||
Err(retirement) => Err(SourceFileError::RecoveryFailed {
|
||||
save: error.to_string(),
|
||||
recovery: retirement.to_string(),
|
||||
journal: journal_dir,
|
||||
}),
|
||||
};
|
||||
}
|
||||
write_synced(&journal_dir.join(WRITE_STARTED), WRITE_STARTED_BYTES)?;
|
||||
sync_directory(&journal_dir)?;
|
||||
phase(SavePhase::AfterWriteMarker).map_err(|error| io_at(&journal_dir, error))?;
|
||||
let result = (|| {
|
||||
let mut current = snapshot.expectations.clone();
|
||||
let mut replacements = 0;
|
||||
for file in journal.files.iter().filter(|file| file.write) {
|
||||
check_conflicts(&snapshot.root, ¤t)?;
|
||||
// The first check is above, before the durable write-attempt marker.
|
||||
// Further checks follow real write attempts and may need rollback.
|
||||
if replacements != 0 {
|
||||
check_conflicts(&snapshot.root, ¤t)?;
|
||||
}
|
||||
let bytes = compiled.bundle.files[&file.path].as_bytes();
|
||||
atomic_write(&snapshot.root, &file.path, bytes)?;
|
||||
current.insert(file.path.clone(), DiskExpectation::Bytes(bytes.to_vec()));
|
||||
replacements += 1;
|
||||
after_replace(replacements)
|
||||
phase(SavePhase::AfterReplace(replacements))
|
||||
.map_err(|error| io_at(&snapshot.root.join(&file.path), error))?;
|
||||
}
|
||||
check_conflicts(&snapshot.root, ¤t)?;
|
||||
@@ -804,6 +982,7 @@ fn save_with_hook(
|
||||
}),
|
||||
};
|
||||
}
|
||||
phase(SavePhase::BeforeRetire).map_err(|error| io_at(&journal_dir, error))?;
|
||||
retire_journal(&snapshot.root, &journal_dir, &journal)?;
|
||||
Ok(saved_snapshot(snapshot, compiled))
|
||||
}
|
||||
@@ -833,6 +1012,14 @@ fn remove_journal(directory: &Path, journal: &Journal) -> Result<(), SourceFileE
|
||||
}
|
||||
}
|
||||
}
|
||||
if journal.version == 2 {
|
||||
let marker = directory.join(WRITE_STARTED);
|
||||
match fs::remove_file(&marker) {
|
||||
Ok(()) => {}
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
|
||||
Err(error) => return Err(io_at(&marker, error)),
|
||||
}
|
||||
}
|
||||
let metadata = directory.join("journal.json");
|
||||
fs::remove_file(&metadata).map_err(|error| io_at(&metadata, error))?;
|
||||
fs::remove_dir(directory).map_err(|error| io_at(directory, error))
|
||||
@@ -898,7 +1085,10 @@ fn recover_with_hook(
|
||||
let bytes = read_journal_file(&metadata_path, MAX_JOURNAL_BYTES)?;
|
||||
let journal: Journal = serde_json::from_slice(&bytes)
|
||||
.map_err(|error| SourceFileError::Invalid(format!("Invalid recovery journal: {error}")))?;
|
||||
if journal.version != 1 || journal.entry != entry || journal.files.len() > limits.max_files {
|
||||
if !matches!(journal.version, 1 | 2)
|
||||
|| journal.entry != entry
|
||||
|| journal.files.len() > limits.max_files
|
||||
{
|
||||
return Err(SourceFileError::Invalid(
|
||||
"Recovery journal version, entry or file budget is invalid.".into(),
|
||||
));
|
||||
@@ -908,6 +1098,25 @@ fn recover_with_hook(
|
||||
let mut folded = BTreeSet::new();
|
||||
let (mut before_bytes, mut after_bytes) = (0usize, 0usize);
|
||||
let mut allowed = BTreeSet::from(["journal.json".to_string()]);
|
||||
let write_started = if journal.version == 2 {
|
||||
let marker = directory.join(WRITE_STARTED);
|
||||
match fs::symlink_metadata(&marker) {
|
||||
Ok(_) => {
|
||||
if read_journal_file(&marker, WRITE_STARTED_BYTES.len())? != WRITE_STARTED_BYTES {
|
||||
return Err(SourceFileError::Invalid(
|
||||
"Invalid journal write-started marker.".into(),
|
||||
));
|
||||
}
|
||||
allowed.insert(WRITE_STARTED.into());
|
||||
true
|
||||
}
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => false,
|
||||
Err(error) => return Err(io_at(&marker, error)),
|
||||
}
|
||||
} else {
|
||||
// Frozen v1 journals predate the marker and retain their rollback rules.
|
||||
true
|
||||
};
|
||||
for (index, file) in journal.files.iter().enumerate() {
|
||||
disk_path(&root, &file.path)?;
|
||||
if !folded.insert(file.path.to_lowercase()) {
|
||||
@@ -952,6 +1161,10 @@ fn recover_with_hook(
|
||||
));
|
||||
}
|
||||
}
|
||||
if !write_started {
|
||||
retire_journal(&root, &directory, &journal)?;
|
||||
return Ok(true);
|
||||
}
|
||||
let mut current = BTreeMap::new();
|
||||
let mut conflicts = Vec::new();
|
||||
for (path, before) in &original {
|
||||
@@ -995,6 +1208,342 @@ fn recover_with_hook(
|
||||
mod transaction_tests {
|
||||
use super::*;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn linux_journal_publication_preserves_competing_destinations_and_our_stage() {
|
||||
use std::os::unix::{fs::MetadataExt, fs::symlink};
|
||||
|
||||
for kind in ["empty directory", "nonempty directory", "file", "symlink"] {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let stage = directory.path().join("stage");
|
||||
let journal = directory.path().join("journal");
|
||||
fs::create_dir(&stage).unwrap();
|
||||
fs::write(stage.join("journal.json"), b"our complete metadata").unwrap();
|
||||
// Simulate a competitor arriving after the caller's absence check.
|
||||
assert!(!journal.exists());
|
||||
match kind {
|
||||
"empty directory" => fs::create_dir(&journal).unwrap(),
|
||||
"nonempty directory" => {
|
||||
fs::create_dir(&journal).unwrap();
|
||||
fs::write(journal.join("journal.json"), b"competitor metadata").unwrap();
|
||||
}
|
||||
"file" => fs::write(&journal, b"competitor file").unwrap(),
|
||||
"symlink" => symlink("absent competitor target", &journal).unwrap(),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
let before = fs::symlink_metadata(&journal).unwrap();
|
||||
let error = publish_journal(&stage, &journal).unwrap_err();
|
||||
assert!(
|
||||
matches!(error, SourceFileError::Io { path, source }
|
||||
if path == journal && source.raw_os_error() == Some(libc::EEXIST)),
|
||||
"{kind} must refuse publication"
|
||||
);
|
||||
let after = fs::symlink_metadata(&journal).unwrap();
|
||||
assert_eq!((before.dev(), before.ino()), (after.dev(), after.ino()));
|
||||
assert_eq!(
|
||||
fs::read(stage.join("journal.json")).unwrap(),
|
||||
b"our complete metadata"
|
||||
);
|
||||
match kind {
|
||||
"empty directory" => assert_eq!(fs::read_dir(&journal).unwrap().count(), 0),
|
||||
"nonempty directory" => assert_eq!(
|
||||
fs::read(journal.join("journal.json")).unwrap(),
|
||||
b"competitor metadata"
|
||||
),
|
||||
"file" => assert_eq!(fs::read(&journal).unwrap(), b"competitor file"),
|
||||
"symlink" => assert_eq!(
|
||||
fs::read_link(&journal).unwrap(),
|
||||
Path::new("absent competitor target")
|
||||
),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn linux_journal_publication_preserves_raw_path_bytes_and_rejects_nul() {
|
||||
use std::{ffi::OsStr, os::unix::ffi::OsStrExt};
|
||||
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let stage = directory.path().join(OsStr::from_bytes(b"stage-\xff"));
|
||||
let journal = directory.path().join(OsStr::from_bytes(b"journal-\xfe"));
|
||||
fs::create_dir(&stage).unwrap();
|
||||
fs::write(stage.join("journal.json"), b"complete metadata").unwrap();
|
||||
for (a, b) in [
|
||||
(Path::new("bad\0path"), journal.as_path()),
|
||||
(stage.as_path(), Path::new("bad\0path")),
|
||||
] {
|
||||
let error = rename_journal_without_replacement(a, b).unwrap_err();
|
||||
assert_eq!(error.kind(), io::ErrorKind::InvalidInput);
|
||||
assert!(stage.join("journal.json").exists());
|
||||
assert!(!journal.exists());
|
||||
}
|
||||
publish_journal(&stage, &journal).unwrap();
|
||||
assert!(!stage.exists());
|
||||
assert_eq!(
|
||||
fs::read(journal.join("journal.json")).unwrap(),
|
||||
b"complete metadata"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn linux_journal_publication_has_one_winner_for_concurrent_stages() {
|
||||
use std::sync::Barrier;
|
||||
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let journal = directory.path().join("journal");
|
||||
let stages = [
|
||||
directory.path().join("stage-a"),
|
||||
directory.path().join("stage-b"),
|
||||
];
|
||||
for (i, stage) in stages.iter().enumerate() {
|
||||
fs::create_dir(stage).unwrap();
|
||||
fs::write(stage.join("journal.json"), format!("complete writer {i}")).unwrap();
|
||||
}
|
||||
let barrier = Barrier::new(2);
|
||||
let results = std::thread::scope(|scope| {
|
||||
let journal = &journal;
|
||||
let barrier = &barrier;
|
||||
let handles: Vec<_> = stages
|
||||
.iter()
|
||||
.map(|stage| {
|
||||
scope.spawn(move || {
|
||||
assert!(!journal.exists());
|
||||
barrier.wait();
|
||||
publish_journal(stage, journal)
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
handles
|
||||
.into_iter()
|
||||
.map(|handle| handle.join().unwrap())
|
||||
.collect::<Vec<_>>()
|
||||
});
|
||||
assert_eq!(results.iter().filter(|result| result.is_ok()).count(), 1);
|
||||
for (i, result) in results.into_iter().enumerate() {
|
||||
match result {
|
||||
Ok(()) => {
|
||||
assert!(!stages[i].exists());
|
||||
assert_eq!(
|
||||
fs::read_to_string(journal.join("journal.json")).unwrap(),
|
||||
format!("complete writer {i}")
|
||||
);
|
||||
}
|
||||
Err(SourceFileError::Io { path, source }) => {
|
||||
assert_eq!(path, journal);
|
||||
assert_eq!(source.raw_os_error(), Some(libc::EEXIST));
|
||||
assert_eq!(
|
||||
fs::read_to_string(stages[i].join("journal.json")).unwrap(),
|
||||
format!("complete writer {i}")
|
||||
);
|
||||
}
|
||||
Err(error) => panic!("unexpected publication error: {error}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
#[ignore = "requires strace renameat2 error injection and OA_TEST_RENAMEAT2_ERRNO"]
|
||||
fn linux_journal_publication_injected_unsupported_error_is_fail_closed() {
|
||||
let errno: i32 = std::env::var("OA_TEST_RENAMEAT2_ERRNO")
|
||||
.expect("set OA_TEST_RENAMEAT2_ERRNO to the injected Linux errno")
|
||||
.parse()
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
errno,
|
||||
libc::ENOSYS | libc::EINVAL | libc::EOPNOTSUPP
|
||||
));
|
||||
let (directory, snapshot, compiled) = fixture();
|
||||
let journal = journal_path(&snapshot.root, &snapshot.bundle.entry);
|
||||
let original: Vec<_> = snapshot
|
||||
.bundle
|
||||
.files
|
||||
.keys()
|
||||
.map(|path| (path.clone(), fs::read(directory.path().join(path)).unwrap()))
|
||||
.collect();
|
||||
let error = save_with_phases(&snapshot, &compiled, ProjectLimits::default(), |phase| {
|
||||
if matches!(
|
||||
phase,
|
||||
SavePhase::AfterPublish | SavePhase::AfterWriteMarker | SavePhase::AfterReplace(_)
|
||||
) {
|
||||
panic!("unsupported no-replace publication must stop before ownership transfer");
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.unwrap_err();
|
||||
assert!(matches!(error, SourceFileError::Io { path, source }
|
||||
if path == journal && source.raw_os_error() == Some(errno)));
|
||||
assert!(!journal.exists());
|
||||
for (path, bytes) in original {
|
||||
assert_eq!(fs::read(directory.path().join(path)).unwrap(), bytes);
|
||||
}
|
||||
check_conflicts(&snapshot.root, &snapshot.expectations).unwrap();
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn linux_journal_publication_refusal_never_starts_model_writes() {
|
||||
let (directory, snapshot, compiled) = fixture();
|
||||
let journal = journal_path(&snapshot.root, &snapshot.bundle.entry);
|
||||
let original: Vec<_> = snapshot
|
||||
.bundle
|
||||
.files
|
||||
.keys()
|
||||
.map(|path| (path.clone(), fs::read(directory.path().join(path)).unwrap()))
|
||||
.collect();
|
||||
let error = save_with_phases(&snapshot, &compiled, ProjectLimits::default(), |phase| {
|
||||
match phase {
|
||||
SavePhase::BeforePublish => fs::create_dir(&journal).unwrap(),
|
||||
SavePhase::AfterPublish
|
||||
| SavePhase::AfterWriteMarker
|
||||
| SavePhase::AfterReplace(_) => {
|
||||
panic!("a rejected publisher must not obtain ownership or write source files")
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.unwrap_err();
|
||||
assert!(matches!(error, SourceFileError::Io { path, source }
|
||||
if path == journal && source.raw_os_error() == Some(libc::EEXIST)));
|
||||
assert_eq!(fs::read_dir(&journal).unwrap().count(), 0);
|
||||
assert!(!journal.join(WRITE_STARTED).exists());
|
||||
for (path, bytes) in original {
|
||||
assert_eq!(fs::read(directory.path().join(path)).unwrap(), bytes);
|
||||
}
|
||||
check_conflicts(&snapshot.root, &snapshot.expectations).unwrap();
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn journal_publication_does_not_replace_empty_target_created_after_precheck() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let stage = directory.path().join("stage");
|
||||
let journal = directory.path().join("journal");
|
||||
fs::create_dir(&stage).unwrap();
|
||||
fs::write(stage.join("journal.json"), b"our journal").unwrap();
|
||||
let mut called = false;
|
||||
let result = publish_journal_with_retry(
|
||||
&stage,
|
||||
&journal,
|
||||
|a, b| {
|
||||
assert!(!called);
|
||||
called = true;
|
||||
fs::create_dir(b).unwrap();
|
||||
rename_journal_without_replacement(a, b)
|
||||
},
|
||||
|_| {},
|
||||
);
|
||||
assert!(result.is_err());
|
||||
assert!(called);
|
||||
assert_eq!(
|
||||
fs::read(stage.join("journal.json")).unwrap(),
|
||||
b"our journal"
|
||||
);
|
||||
assert_eq!(fs::read_dir(&journal).unwrap().count(), 0);
|
||||
assert!(
|
||||
matches!(rename_journal_without_replacement(Path::new("bad\0path"), &journal), Err(error) if error.kind() == io::ErrorKind::InvalidInput)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn journal_publication_retries_only_transient_windows_errors_before_transferring_ownership() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let stage = directory.path().join("stage");
|
||||
let journal = directory.path().join("journal");
|
||||
fs::create_dir(&stage).unwrap();
|
||||
fs::write(stage.join("journal.json"), b"complete staged metadata").unwrap();
|
||||
let mut errors = [5, 32, 33].into_iter();
|
||||
let mut waits = vec![];
|
||||
publish_journal_with_retry(
|
||||
&stage,
|
||||
&journal,
|
||||
|a, b| {
|
||||
assert!(a.join("journal.json").exists());
|
||||
assert!(!b.exists());
|
||||
match errors.next() {
|
||||
Some(code) => Err(io::Error::from_raw_os_error(code)),
|
||||
None => fs::rename(a, b),
|
||||
}
|
||||
},
|
||||
|delay| waits.push(delay.as_millis()),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(waits, [5, 10, 25]);
|
||||
assert!(!stage.exists());
|
||||
assert_eq!(
|
||||
fs::read(journal.join("journal.json")).unwrap(),
|
||||
b"complete staged metadata"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn journal_publication_exhaustion_and_permanent_errors_keep_stage_and_report_original_path() {
|
||||
for (code, expected_calls, expected_wait) in [(5, 7, 390), (123, 1, 0)] {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let stage = directory.path().join("stage");
|
||||
let journal = directory.path().join("journal");
|
||||
fs::create_dir(&stage).unwrap();
|
||||
fs::write(stage.join("journal.json"), b"before publication").unwrap();
|
||||
let (mut calls, mut waited) = (0, 0);
|
||||
let error = publish_journal_with_retry(
|
||||
&stage,
|
||||
&journal,
|
||||
|_, _| {
|
||||
calls += 1;
|
||||
Err(io::Error::from_raw_os_error(code))
|
||||
},
|
||||
|delay| waited += delay.as_millis(),
|
||||
)
|
||||
.unwrap_err();
|
||||
assert_eq!((calls, waited), (expected_calls, expected_wait));
|
||||
assert!(
|
||||
matches!(error, SourceFileError::Io { path, source } if path == journal && source.raw_os_error() == Some(code))
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read(stage.join("journal.json")).unwrap(),
|
||||
b"before publication"
|
||||
);
|
||||
assert!(!journal.exists());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn journal_publication_never_replaces_another_journal_that_appears_during_retry() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let stage = directory.path().join("stage");
|
||||
let journal = directory.path().join("journal");
|
||||
fs::create_dir(&stage).unwrap();
|
||||
fs::write(stage.join("journal.json"), b"our staged journal").unwrap();
|
||||
let mut calls = 0;
|
||||
let error = publish_journal_with_retry(
|
||||
&stage,
|
||||
&journal,
|
||||
|_, _| {
|
||||
calls += 1;
|
||||
Err(io::Error::from_raw_os_error(32))
|
||||
},
|
||||
|_| {
|
||||
fs::create_dir(&journal).unwrap();
|
||||
fs::write(journal.join("journal.json"), b"other save owns this").unwrap();
|
||||
},
|
||||
)
|
||||
.unwrap_err();
|
||||
assert_eq!(calls, 1);
|
||||
assert!(matches!(error, SourceFileError::RecoveryRequired(path) if path == journal));
|
||||
assert_eq!(
|
||||
fs::read(journal.join("journal.json")).unwrap(),
|
||||
b"other save owns this"
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read(stage.join("journal.json")).unwrap(),
|
||||
b"our staged journal"
|
||||
);
|
||||
}
|
||||
|
||||
fn fixture() -> (tempfile::TempDir, DiskProject, CompiledSourceProject) {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
fs::write(
|
||||
@@ -1014,6 +1563,160 @@ mod transaction_tests {
|
||||
(directory, snapshot, compiled)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unwritten_journal_never_rolls_back_a_completed_writer_with_identical_after_bytes() {
|
||||
for interrupt_retirement in [false, true] {
|
||||
let (directory, snapshot, compiled) = fixture();
|
||||
let error = save_with_phases(&snapshot, &compiled, ProjectLimits::default(), |phase| {
|
||||
match phase {
|
||||
SavePhase::BeforePublish => {
|
||||
// A competing save finishes during our publication wait.
|
||||
save_project(&snapshot, &compiled, ProjectLimits::default()).unwrap();
|
||||
}
|
||||
SavePhase::BeforeRetire if interrupt_retirement => {
|
||||
return Err(io::Error::other("injected no-write retirement failure"));
|
||||
}
|
||||
SavePhase::AfterWriteMarker | SavePhase::AfterReplace(_) => {
|
||||
panic!("losing writer must not start writes")
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.unwrap_err();
|
||||
assert!(matches!(
|
||||
error,
|
||||
SourceFileError::Conflict(_) | SourceFileError::RecoveryFailed { .. }
|
||||
));
|
||||
for (path, bytes) in &compiled.bundle.files {
|
||||
assert_eq!(
|
||||
fs::read_to_string(directory.path().join(path)).unwrap(),
|
||||
*bytes
|
||||
);
|
||||
}
|
||||
let journal = journal_path(&snapshot.root, &snapshot.bundle.entry);
|
||||
assert_eq!(journal.exists(), interrupt_retirement);
|
||||
if interrupt_retirement {
|
||||
assert!(!journal.join(WRITE_STARTED).exists());
|
||||
// Explicit recovery after a failed retirement must also leave
|
||||
// the completed other writer untouched, even matching after bytes.
|
||||
assert!(
|
||||
recover_project(
|
||||
&directory.path().join("project.arcadia"),
|
||||
ProjectLimits::default()
|
||||
)
|
||||
.unwrap()
|
||||
);
|
||||
for (path, bytes) in &compiled.bundle.files {
|
||||
assert_eq!(
|
||||
fs::read_to_string(directory.path().join(path)).unwrap(),
|
||||
*bytes
|
||||
);
|
||||
}
|
||||
}
|
||||
assert!(!journal.exists());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recovery_before_any_write_accepts_v2_marker_and_keeps_v1_rollback_compatibility() {
|
||||
for version in [1, 2] {
|
||||
let (directory, snapshot, compiled) = fixture();
|
||||
let interrupted = std::panic::catch_unwind(|| {
|
||||
let _ = save_with_phases(&snapshot, &compiled, ProjectLimits::default(), |phase| {
|
||||
if matches!(phase, SavePhase::AfterWriteMarker) {
|
||||
panic!("interrupted before atomic write");
|
||||
}
|
||||
Ok(())
|
||||
});
|
||||
});
|
||||
assert!(interrupted.is_err());
|
||||
check_conflicts(&snapshot.root, &snapshot.expectations).unwrap();
|
||||
let journal = journal_path(&snapshot.root, &snapshot.bundle.entry);
|
||||
assert_eq!(
|
||||
fs::read(journal.join(WRITE_STARTED)).unwrap(),
|
||||
WRITE_STARTED_BYTES
|
||||
);
|
||||
if version == 1 {
|
||||
// A frozen v1 journal has no marker and means writes may have begun.
|
||||
let path = journal.join("journal.json");
|
||||
let mut metadata: Journal =
|
||||
serde_json::from_slice(&fs::read(&path).unwrap()).unwrap();
|
||||
metadata.version = 1;
|
||||
fs::write(path, serde_json::to_vec(&metadata).unwrap()).unwrap();
|
||||
fs::remove_file(journal.join(WRITE_STARTED)).unwrap();
|
||||
for (path, bytes) in &compiled.bundle.files {
|
||||
fs::write(directory.path().join(path), bytes).unwrap();
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
recover_project(
|
||||
&directory.path().join("project.arcadia"),
|
||||
ProjectLimits::default()
|
||||
)
|
||||
.unwrap()
|
||||
);
|
||||
check_conflicts(&snapshot.root, &snapshot.expectations).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interrupted_publication_without_write_marker_never_rolls_back_external_matching_bytes() {
|
||||
let (directory, snapshot, compiled) = fixture();
|
||||
let interrupted = std::panic::catch_unwind(|| {
|
||||
let _ = save_with_phases(&snapshot, &compiled, ProjectLimits::default(), |phase| {
|
||||
if matches!(phase, SavePhase::AfterPublish) {
|
||||
panic!("interrupted before write marker");
|
||||
}
|
||||
Ok(())
|
||||
});
|
||||
});
|
||||
assert!(interrupted.is_err());
|
||||
let journal = journal_path(&snapshot.root, &snapshot.bundle.entry);
|
||||
assert!(!journal.join(WRITE_STARTED).exists());
|
||||
for (path, bytes) in &compiled.bundle.files {
|
||||
fs::write(directory.path().join(path), bytes).unwrap();
|
||||
}
|
||||
assert!(
|
||||
recover_project(
|
||||
&directory.path().join("project.arcadia"),
|
||||
ProjectLimits::default()
|
||||
)
|
||||
.unwrap()
|
||||
);
|
||||
for (path, bytes) in &compiled.bundle.files {
|
||||
assert_eq!(
|
||||
fs::read_to_string(directory.path().join(path)).unwrap(),
|
||||
*bytes
|
||||
);
|
||||
}
|
||||
assert!(!journal.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_v2_write_marker_refuses_recovery_without_changing_source_bytes() {
|
||||
let (directory, snapshot, compiled) = fixture();
|
||||
let _ = std::panic::catch_unwind(|| {
|
||||
let _ = save_with_phases(&snapshot, &compiled, ProjectLimits::default(), |phase| {
|
||||
if matches!(phase, SavePhase::AfterWriteMarker) {
|
||||
panic!("interrupted");
|
||||
}
|
||||
Ok(())
|
||||
});
|
||||
});
|
||||
let journal = journal_path(&snapshot.root, &snapshot.bundle.entry);
|
||||
fs::write(journal.join(WRITE_STARTED), b"partial").unwrap();
|
||||
assert!(matches!(
|
||||
recover_project(
|
||||
&directory.path().join("project.arcadia"),
|
||||
ProjectLimits::default()
|
||||
),
|
||||
Err(SourceFileError::Invalid(_))
|
||||
));
|
||||
check_conflicts(&snapshot.root, &snapshot.expectations).unwrap();
|
||||
assert!(journal.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_save_failures_restore_every_original_and_write_entry_last() {
|
||||
for stop in 1..=3 {
|
||||
|
||||
@@ -726,7 +726,8 @@ fn apply_edits(bundle: &mut SourceBundle, edits: Edits) -> Result<(), Vec<Source
|
||||
pub fn format_bundle(project: &Project) -> SourceBundle {
|
||||
let ownership = default_ownership(project);
|
||||
let aliases: BTreeMap<_, _> = project.identities().into_iter().collect();
|
||||
let mut declarations = language::format_declarations(project);
|
||||
let mut declarations =
|
||||
language::format_declarations(project, language::DeclarationFormat::Compact);
|
||||
declarations.sort_by_key(|(id, _)| declaration_order(project, *id, &aliases));
|
||||
let mut files: BTreeMap<LogicalPath, String> = BTreeMap::new();
|
||||
for (id, declaration) in declarations {
|
||||
@@ -735,11 +736,13 @@ pub fn format_bundle(project: &Project) -> SourceBundle {
|
||||
.or_default()
|
||||
.push_str(&unindent(&declaration));
|
||||
}
|
||||
let mut entry = format!(
|
||||
"arcadia {SCHEMA_VERSION};\n{}{{\n description {};\n",
|
||||
project_header(project),
|
||||
quote(&project.description)
|
||||
);
|
||||
let mut entry = format!("arcadia {SCHEMA_VERSION};\n{}{{\n", project_header(project));
|
||||
if !project.description.is_empty() {
|
||||
entry.push_str(&format!(
|
||||
" description {};\n",
|
||||
quote(&project.description)
|
||||
));
|
||||
}
|
||||
for path in files.keys() {
|
||||
entry.push_str(&format!(" include {};\n", quote(path)));
|
||||
}
|
||||
@@ -850,8 +853,25 @@ pub fn update_bundle_with_identity_map(
|
||||
)]);
|
||||
}
|
||||
let origins: BTreeMap<_, _> = identities.iter().map(|(old, new)| (*new, *old)).collect();
|
||||
let before: BTreeMap<_, _> = language::format_declarations(old).into_iter().collect();
|
||||
let after: BTreeMap<_, _> = language::format_declarations(project).into_iter().collect();
|
||||
// Comparison must include defaults even when persistence serialization or
|
||||
// compact output omits them. Otherwise an unrelated payload edit erases
|
||||
// unchanged explicit fields from the authored document.
|
||||
let before: BTreeMap<_, _> =
|
||||
language::format_declarations(old, language::DeclarationFormat::CompleteForEdit)
|
||||
.into_iter()
|
||||
.collect();
|
||||
let after: BTreeMap<_, _> =
|
||||
language::format_declarations(project, language::DeclarationFormat::CompleteForEdit)
|
||||
.into_iter()
|
||||
.collect();
|
||||
let compact: BTreeMap<_, _> =
|
||||
language::format_declarations(project, language::DeclarationFormat::Compact)
|
||||
.into_iter()
|
||||
.collect();
|
||||
let compact_values: BTreeMap<_, _> =
|
||||
language::format_declarations(project, language::DeclarationFormat::CompactValues)
|
||||
.into_iter()
|
||||
.collect();
|
||||
let old_aliases: BTreeMap<_, _> = old
|
||||
.identities()
|
||||
.into_iter()
|
||||
@@ -907,6 +927,27 @@ pub fn update_bundle_with_identity_map(
|
||||
};
|
||||
let mut previous = parse(old_text)?;
|
||||
let mut next = parse(new_text)?;
|
||||
let next_id = identities[&id];
|
||||
let value_text = &compact_values[&next_id];
|
||||
let insertion = InsertionSyntax::new(
|
||||
&next.fields,
|
||||
&parse(value_text)?.fields,
|
||||
value_text,
|
||||
&parse(&compact[&next_id])?.fields,
|
||||
);
|
||||
let old_kind = previous.header["kind"];
|
||||
let new_kind = next.header["kind"];
|
||||
if old_text[old_kind.start..old_kind.end] != new_text[new_kind.start..new_kind.end]
|
||||
&& let language::EditKind::Record(actual_fields) = &actual.fields.kind
|
||||
&& let language::EditKind::Record(previous_fields) = &mut previous.fields.kind
|
||||
{
|
||||
// An implicit value is supplied by its declaration context.
|
||||
// After a kind change, an unchanged value may need explicit
|
||||
// syntax (e.g. BoundaryExchange true -> CommunicationMean true).
|
||||
// Reconsider only absent fields; authored explicit values still
|
||||
// compare against their complete old values and keep trivia.
|
||||
previous_fields.retain(|key, _| actual_fields.contains_key(key));
|
||||
}
|
||||
if actual.lexical_owner.is_some()
|
||||
&& let language::EditKind::Record(fields) = &actual.fields.kind
|
||||
&& !fields.contains_key("parent")
|
||||
@@ -923,6 +964,7 @@ pub fn update_bundle_with_identity_map(
|
||||
source,
|
||||
before: old_text,
|
||||
after: new_text,
|
||||
insertion: Some(&insertion),
|
||||
old_aliases: &old_aliases,
|
||||
new_aliases: &new_aliases,
|
||||
legacy_value_paths: false,
|
||||
@@ -953,6 +995,7 @@ pub fn update_bundle_with_identity_map(
|
||||
source,
|
||||
before: &before,
|
||||
after: &after,
|
||||
insertion: None,
|
||||
old_aliases: &old_aliases,
|
||||
new_aliases: &new_aliases,
|
||||
legacy_value_paths: false,
|
||||
@@ -1011,7 +1054,7 @@ pub fn update_bundle_with_identity_map(
|
||||
project,
|
||||
identities,
|
||||
&original_order,
|
||||
&after,
|
||||
&compact,
|
||||
limits,
|
||||
)?;
|
||||
let result = compile_bundle(&bundle, limits)?;
|
||||
@@ -1119,10 +1162,73 @@ fn inserted_value(text: &str, indent: &str, source: &str) -> String {
|
||||
output
|
||||
}
|
||||
|
||||
// Value fragments retain required payload fields even when the enclosing
|
||||
// declaration field is itself omittable. For example, a selected String type
|
||||
// needs minimum_length; an Event Signal needs items. The complete tree remains
|
||||
// the comparison authority; this parallel tree is only an insertion template.
|
||||
struct InsertionSyntax<'a> {
|
||||
source: &'a str,
|
||||
values: BTreeMap<usize, Span>,
|
||||
omitted_fields: BTreeSet<usize>,
|
||||
}
|
||||
impl<'a> InsertionSyntax<'a> {
|
||||
fn new(
|
||||
complete: &language::EditValue,
|
||||
compact_values: &language::EditValue,
|
||||
source: &'a str,
|
||||
compact: &language::EditValue,
|
||||
) -> Self {
|
||||
let mut result = Self {
|
||||
source,
|
||||
values: BTreeMap::new(),
|
||||
omitted_fields: BTreeSet::new(),
|
||||
};
|
||||
result.collect(complete, compact_values);
|
||||
if let (language::EditKind::Record(all), language::EditKind::Record(kept)) =
|
||||
(&complete.kind, &compact.kind)
|
||||
{
|
||||
for (key, field) in all {
|
||||
if !kept.contains_key(key) {
|
||||
result.omitted_fields.insert(field.value.span.start);
|
||||
}
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
fn collect(&mut self, complete: &language::EditValue, compact: &language::EditValue) {
|
||||
// Borrow the existing compact text once. Copying each containing record
|
||||
// here would duplicate a large nested payload at every depth.
|
||||
self.values.insert(complete.span.start, compact.span);
|
||||
match (&complete.kind, &compact.kind) {
|
||||
(language::EditKind::Record(all), language::EditKind::Record(kept)) => {
|
||||
for (key, field) in all {
|
||||
if let Some(next) = kept.get(key) {
|
||||
self.collect(&field.value, &next.value);
|
||||
} else {
|
||||
self.omitted_fields.insert(field.value.span.start);
|
||||
}
|
||||
}
|
||||
}
|
||||
(language::EditKind::List(all), language::EditKind::List(kept)) => {
|
||||
assert_eq!(
|
||||
all.len(),
|
||||
kept.len(),
|
||||
"compaction preserves array membership"
|
||||
);
|
||||
for (field, next) in all.iter().zip(kept) {
|
||||
self.collect(field, next);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SyntaxUpdate<'a> {
|
||||
source: &'a str,
|
||||
before: &'a str,
|
||||
after: &'a str,
|
||||
insertion: Option<&'a InsertionSyntax<'a>>,
|
||||
old_aliases: &'a BTreeMap<String, String>,
|
||||
new_aliases: &'a BTreeMap<String, String>,
|
||||
// Source3 migration only adds path qualification; the existing field identity
|
||||
@@ -1131,6 +1237,16 @@ struct SyntaxUpdate<'a> {
|
||||
identity_origins: Option<&'a BTreeMap<Id, Id>>,
|
||||
}
|
||||
impl SyntaxUpdate<'_> {
|
||||
fn inserted(&self, value: &language::EditValue) -> &str {
|
||||
self.insertion
|
||||
.and_then(|syntax| {
|
||||
syntax
|
||||
.values
|
||||
.get(&value.span.start)
|
||||
.map(|span| &syntax.source[span.start..span.end])
|
||||
})
|
||||
.unwrap_or(&self.after[value.span.start..value.span.end])
|
||||
}
|
||||
fn header(
|
||||
&self,
|
||||
actual: &BTreeMap<&str, Span>,
|
||||
@@ -1243,7 +1359,7 @@ impl SyntaxUpdate<'_> {
|
||||
_ => {
|
||||
erase_syntax(self.source, actual.span, edits);
|
||||
let text = inserted_value(
|
||||
&self.after[after.span.start..after.span.end],
|
||||
self.inserted(after),
|
||||
indent_at(self.source, actual.span.start),
|
||||
self.source,
|
||||
);
|
||||
@@ -1308,6 +1424,9 @@ impl SyntaxUpdate<'_> {
|
||||
.iter()
|
||||
.filter(|(key, field)| {
|
||||
!actual.contains_key(*key)
|
||||
&& self.insertion.is_none_or(|syntax| {
|
||||
!syntax.omitted_fields.contains(&field.value.span.start)
|
||||
})
|
||||
&& before
|
||||
.and_then(|fields| fields.get(*key))
|
||||
.is_none_or(|old| !self.same(&old.value, &field.value))
|
||||
@@ -1326,11 +1445,7 @@ impl SyntaxUpdate<'_> {
|
||||
.unwrap_or_else(|| format!("{} ", indent_at(self.source, span.start)));
|
||||
let mut text = String::new();
|
||||
for (key, field) in added {
|
||||
let value = inserted_value(
|
||||
&self.after[field.value.span.start..field.value.span.end],
|
||||
&indent,
|
||||
self.source,
|
||||
);
|
||||
let value = inserted_value(self.inserted(&field.value), &indent, self.source);
|
||||
if multiline {
|
||||
text.push_str(&indent);
|
||||
text.push_str(&format!("{key} {value};{}", newline(self.source)));
|
||||
@@ -1450,11 +1565,7 @@ impl SyntaxUpdate<'_> {
|
||||
} else {
|
||||
text.push(' ');
|
||||
}
|
||||
text.push_str(&inserted_value(
|
||||
&self.after[next.span.start..next.span.end],
|
||||
&indent,
|
||||
self.source,
|
||||
));
|
||||
text.push_str(&inserted_value(self.inserted(next), &indent, self.source));
|
||||
suffix = "";
|
||||
}
|
||||
if i + 1 < after.len() {
|
||||
@@ -1553,7 +1664,11 @@ fn semantic_owner(project: &Project, id: Id) -> Option<Id> {
|
||||
return element.parent;
|
||||
}
|
||||
if let Some(relation) = project.relations.get(&id) {
|
||||
return Some(relation.source());
|
||||
return Some(
|
||||
relation
|
||||
.contextual_owner()
|
||||
.unwrap_or_else(|| relation.source()),
|
||||
);
|
||||
}
|
||||
if let Some(artifact) = project.artifact_links.get(&id) {
|
||||
return Some(artifact.owner);
|
||||
|
||||
@@ -11,7 +11,10 @@ pub(super) fn upgrade(
|
||||
.as_ref()
|
||||
.expect("an explicit source migration report");
|
||||
let project = ¤t.decoded.project;
|
||||
let canonical: BTreeMap<_, _> = language::format_declarations(project).into_iter().collect();
|
||||
let canonical: BTreeMap<_, _> =
|
||||
language::format_declarations(project, language::DeclarationFormat::CompleteForEdit)
|
||||
.into_iter()
|
||||
.collect();
|
||||
let changed: BTreeSet<_> = report
|
||||
.semantic_defaults
|
||||
.iter()
|
||||
@@ -59,6 +62,7 @@ pub(super) fn upgrade(
|
||||
source,
|
||||
before: source,
|
||||
after,
|
||||
insertion: None,
|
||||
old_aliases: &aliases,
|
||||
new_aliases: &aliases,
|
||||
legacy_value_paths: true,
|
||||
|
||||
@@ -0,0 +1,352 @@
|
||||
//! Caller-qualified process/chain addresses. Cache residency never affects admission.
|
||||
use crate::*;
|
||||
use std::{
|
||||
cell::RefCell,
|
||||
collections::{BTreeMap, BTreeSet},
|
||||
};
|
||||
|
||||
mod analysis;
|
||||
pub use analysis::*;
|
||||
pub const MAX_REFERENCE_DEPTH: usize = 64;
|
||||
pub const MAX_ANALYSIS_NODES: usize = 50_000;
|
||||
pub const MAX_ANALYSIS_EDGES: usize = 100_000;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub struct ContextAddress {
|
||||
pub owner: Id,
|
||||
pub endpoint: OccurrenceEndpoint,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ResolvedOccurrence {
|
||||
pub address: ContextAddress,
|
||||
pub defining_sequence: Id,
|
||||
pub work: Option<Id>,
|
||||
pub exchange: Option<Id>,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
||||
#[error("{message}")]
|
||||
pub struct ContextError {
|
||||
pub code: &'static str,
|
||||
pub message: String,
|
||||
}
|
||||
fn failure(code: &'static str, message: &str) -> ContextError {
|
||||
ContextError {
|
||||
code,
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
struct Record {
|
||||
parent: Option<Id>,
|
||||
layer: Layer,
|
||||
kind: Kind,
|
||||
reference: Option<Id>,
|
||||
work: Option<Id>,
|
||||
exchange: Option<Id>,
|
||||
contribution: bool,
|
||||
}
|
||||
pub struct TopologyIndex<'a> {
|
||||
project: &'a Project,
|
||||
records: BTreeMap<Id, Record>,
|
||||
children: BTreeMap<Id, Vec<Id>>,
|
||||
link_definitions: BTreeMap<Id, Vec<Id>>,
|
||||
contextual_callers: BTreeSet<Id>,
|
||||
cache: RefCell<BTreeMap<ContextAddress, ResolvedOccurrence>>,
|
||||
cache_capacity: usize,
|
||||
}
|
||||
impl<'a> TopologyIndex<'a> {
|
||||
pub fn new(project: &'a Project) -> Self {
|
||||
Self::with_cache_capacity(project, 4096)
|
||||
}
|
||||
pub fn with_cache_capacity(project: &'a Project, cache_capacity: usize) -> Self {
|
||||
let records: BTreeMap<_, _> = project
|
||||
.elements
|
||||
.values()
|
||||
.map(|e| {
|
||||
let (reference, work, exchange, contribution) = match e.shared() {
|
||||
Some(SharedBody::Occurrence { body, .. }) => match body {
|
||||
OccurrenceBody::SequenceReference { sequence } => {
|
||||
(*sequence, None, None, true)
|
||||
}
|
||||
OccurrenceBody::Action { action, .. } => (None, *action, None, true),
|
||||
OccurrenceBody::Exchange { exchange, .. } => (None, None, *exchange, true),
|
||||
OccurrenceBody::Control { .. } => (None, None, None, true),
|
||||
_ => (None, None, None, false),
|
||||
},
|
||||
_ => (None, None, None, false),
|
||||
};
|
||||
(
|
||||
e.id,
|
||||
Record {
|
||||
parent: e.parent,
|
||||
layer: e.layer(),
|
||||
kind: e.kind(),
|
||||
reference,
|
||||
work,
|
||||
exchange,
|
||||
contribution,
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let mut children: BTreeMap<_, _> = project
|
||||
.elements
|
||||
.values()
|
||||
.filter_map(|e| e.steps().map(|steps| (e.id, steps.clone())))
|
||||
.collect();
|
||||
let mut child_sets: BTreeMap<_, BTreeSet<_>> = children
|
||||
.iter()
|
||||
.map(|(owner, children)| (*owner, children.iter().copied().collect()))
|
||||
.collect();
|
||||
for (id, record) in &records {
|
||||
if record.contribution
|
||||
&& let Some(parent) = record.parent
|
||||
{
|
||||
let list = children.entry(parent).or_default();
|
||||
if child_sets.entry(parent).or_default().insert(*id) {
|
||||
list.push(*id);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (owner, list) in &mut children {
|
||||
list.retain(|id| {
|
||||
records
|
||||
.get(id)
|
||||
.is_some_and(|r| r.parent == Some(*owner) && r.contribution)
|
||||
});
|
||||
}
|
||||
let mut link_definitions: BTreeMap<_, Vec<_>> = BTreeMap::new();
|
||||
let mut contextual_callers = BTreeSet::new();
|
||||
for relation in project.relations.values() {
|
||||
let owner = relation.contextual_owner().or_else(|| {
|
||||
if relation.kind() == RelationKind::SequenceLink {
|
||||
records.get(&relation.source()).and_then(|r| r.parent)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
if let Some(owner) = owner {
|
||||
link_definitions.entry(owner).or_default().push(relation.id);
|
||||
}
|
||||
if let Some(owner) = relation.contextual_owner() {
|
||||
contextual_callers.insert(owner);
|
||||
}
|
||||
}
|
||||
let mut callers: BTreeMap<_, Vec<_>> = BTreeMap::new();
|
||||
for record in records.values() {
|
||||
if let (Some(parent), Some(reference)) = (record.parent, record.reference) {
|
||||
callers.entry(reference).or_default().push(parent);
|
||||
}
|
||||
}
|
||||
let mut pending: Vec<_> = contextual_callers.iter().copied().collect();
|
||||
while let Some(sequence) = pending.pop() {
|
||||
for caller in callers.get(&sequence).into_iter().flatten() {
|
||||
if contextual_callers.insert(*caller) {
|
||||
pending.push(*caller);
|
||||
}
|
||||
}
|
||||
}
|
||||
Self {
|
||||
project,
|
||||
records,
|
||||
children,
|
||||
link_definitions,
|
||||
contextual_callers,
|
||||
cache: RefCell::new(BTreeMap::new()),
|
||||
cache_capacity,
|
||||
}
|
||||
}
|
||||
/// Resolve a caller path while an endpoint leaf is still being edited.
|
||||
pub fn resolve_scope(&self, owner: Id, references: &[Id]) -> Result<Id, ContextError> {
|
||||
if references.len() > MAX_REFERENCE_DEPTH {
|
||||
return Err(failure(
|
||||
"context_depth",
|
||||
"A contextual path exceeds 64 references.",
|
||||
));
|
||||
}
|
||||
let root = self.records.get(&owner).ok_or_else(|| {
|
||||
failure(
|
||||
"context_owner",
|
||||
"The caller must be an existing process or functional chain.",
|
||||
)
|
||||
})?;
|
||||
if !matches!(root.kind, Kind::Process | Kind::FunctionalChain) {
|
||||
return Err(failure(
|
||||
"context_owner",
|
||||
"The caller must be a process or functional chain.",
|
||||
));
|
||||
}
|
||||
let mut current = owner;
|
||||
let mut visited = BTreeSet::from([owner]);
|
||||
for step in references {
|
||||
let reference = self.records.get(step).ok_or_else(|| {
|
||||
failure(
|
||||
"context_path",
|
||||
"A reference occurrence in the endpoint path no longer exists.",
|
||||
)
|
||||
})?;
|
||||
if reference.parent != Some(current) || reference.layer != root.layer {
|
||||
return Err(failure(
|
||||
"context_path",
|
||||
"Each reference occurrence must belong directly to the preceding caller context.",
|
||||
));
|
||||
}
|
||||
let next = reference.reference.ok_or_else(|| {
|
||||
failure(
|
||||
"context_path",
|
||||
"Each path component must be a resolved sequence-reference occurrence.",
|
||||
)
|
||||
})?;
|
||||
let target = self.records.get(&next).ok_or_else(|| {
|
||||
failure(
|
||||
"context_path",
|
||||
"A referenced process or chain no longer exists.",
|
||||
)
|
||||
})?;
|
||||
if target.layer != root.layer || target.kind != root.kind {
|
||||
return Err(failure(
|
||||
"context_path",
|
||||
"References must resolve to sequences of the same kind and layer.",
|
||||
));
|
||||
}
|
||||
if !visited.insert(next) {
|
||||
return Err(failure(
|
||||
"context_cycle",
|
||||
"A contextual endpoint cannot traverse recursive sequence reuse.",
|
||||
));
|
||||
}
|
||||
current = next;
|
||||
}
|
||||
Ok(current)
|
||||
}
|
||||
pub fn resolve(
|
||||
&self,
|
||||
owner: Id,
|
||||
endpoint: &OccurrenceEndpoint,
|
||||
) -> Result<ResolvedOccurrence, ContextError> {
|
||||
if endpoint.references.len() > MAX_REFERENCE_DEPTH {
|
||||
return Err(failure(
|
||||
"context_depth",
|
||||
"A contextual endpoint exceeds the 64-reference depth limit.",
|
||||
));
|
||||
}
|
||||
let address = ContextAddress {
|
||||
owner,
|
||||
endpoint: endpoint.clone(),
|
||||
};
|
||||
if let Some(cached) = self.cache.borrow().get(&address) {
|
||||
return Ok(cached.clone());
|
||||
}
|
||||
let current = self.resolve_scope(owner, &endpoint.references)?;
|
||||
let root = &self.records[&owner];
|
||||
let leaf = self.records.get(&endpoint.occurrence).ok_or_else(|| {
|
||||
failure(
|
||||
"context_leaf",
|
||||
"The endpoint contribution no longer exists.",
|
||||
)
|
||||
})?;
|
||||
if leaf.parent != Some(current) || leaf.layer != root.layer || !leaf.contribution {
|
||||
return Err(failure(
|
||||
"context_leaf",
|
||||
"The endpoint must be a direct contribution of its resolved process or chain.",
|
||||
));
|
||||
}
|
||||
let resolved = ResolvedOccurrence {
|
||||
address: address.clone(),
|
||||
defining_sequence: current,
|
||||
work: leaf.work,
|
||||
exchange: leaf.exchange,
|
||||
};
|
||||
if self.cache_capacity > 0 {
|
||||
let mut cache = self.cache.borrow_mut();
|
||||
if cache.len() >= self.cache_capacity {
|
||||
cache.pop_first();
|
||||
}
|
||||
cache.insert(address, resolved.clone());
|
||||
}
|
||||
Ok(resolved)
|
||||
}
|
||||
/// Validate a caller-owned semantic connection, independent of graphical expansion.
|
||||
pub fn validate_link(
|
||||
&self,
|
||||
owner: Id,
|
||||
source: &OccurrenceEndpoint,
|
||||
target: &OccurrenceEndpoint,
|
||||
purpose: &ContextualLinkPurpose,
|
||||
) -> Result<(ResolvedOccurrence, ResolvedOccurrence), ContextError> {
|
||||
let a = self.resolve(owner, source)?;
|
||||
let b = self.resolve(owner, target)?;
|
||||
if a.address == b.address {
|
||||
return Err(failure(
|
||||
"context_self",
|
||||
"A contextual link needs two distinct occurrence addresses.",
|
||||
));
|
||||
}
|
||||
match purpose {
|
||||
ContextualLinkPurpose::Precedence { guard } => {
|
||||
if source.references.is_empty() && target.references.is_empty() {
|
||||
return Err(failure(
|
||||
"context_direct_precedence",
|
||||
"Use a SequenceLink for precedence between two direct contributions.",
|
||||
));
|
||||
}
|
||||
if !validation::valid_text(guard) {
|
||||
return Err(failure(
|
||||
"invalid_text",
|
||||
"The precedence guard contains invalid text.",
|
||||
));
|
||||
}
|
||||
let previous = a.exchange.and_then(|id| self.project.relations.get(&id));
|
||||
let next = b.exchange.and_then(|id| self.project.relations.get(&id));
|
||||
let mismatch = match (a.work, previous, b.work, next) {
|
||||
(Some(work), _, _, Some(exchange)) => work != exchange.source(),
|
||||
(_, Some(exchange), Some(work), _) => exchange.target() != work,
|
||||
(_, Some(previous), _, Some(next)) => previous.target() != next.source(),
|
||||
_ => false,
|
||||
};
|
||||
if mismatch {
|
||||
return Err(failure(
|
||||
"sequence_exchange_endpoint",
|
||||
"Adjacent work/exchange contributions must agree with the referenced exchange endpoints.",
|
||||
));
|
||||
}
|
||||
}
|
||||
ContextualLinkPurpose::Exchange { exchange } => {
|
||||
let relation = self.project.relations.get(exchange).ok_or_else(|| {
|
||||
failure("context_exchange", "Choose an existing work exchange.")
|
||||
})?;
|
||||
let expected = if self.project.layer_of(owner) == Some(Layer::Oa) {
|
||||
RelationKind::Interaction
|
||||
} else {
|
||||
RelationKind::FunctionalExchange
|
||||
};
|
||||
if relation.kind() != expected
|
||||
|| self.project.layer_of(*exchange) != self.project.layer_of(owner)
|
||||
|| a.work != Some(relation.source())
|
||||
|| b.work != Some(relation.target())
|
||||
{
|
||||
return Err(failure(
|
||||
"context_exchange",
|
||||
"The selected same-layer exchange must connect the exact work referenced by the source and target Actions.",
|
||||
));
|
||||
}
|
||||
}
|
||||
ContextualLinkPurpose::CommonWork { work } => {
|
||||
if !self.project.elements.get(work).is_some_and(|e| {
|
||||
e.kind().is_work() && Some(e.layer()) == self.project.layer_of(owner)
|
||||
}) || a.work != Some(*work)
|
||||
|| b.work != Some(*work)
|
||||
{
|
||||
return Err(failure(
|
||||
"context_common_work",
|
||||
"Both Actions must reference the exact same existing work in this layer.",
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok((a, b))
|
||||
}
|
||||
/// Direct child candidates for lazy UI exploration; never expands a Cartesian reuse tree.
|
||||
pub fn contributions(&self, sequence: Id) -> Vec<Id> {
|
||||
self.children.get(&sequence).cloned().unwrap_or_default()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,529 @@
|
||||
use super::*;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "analysis_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum ReferenceBoundary {
|
||||
Entry,
|
||||
Exit,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub struct ContextVertex {
|
||||
pub address: ContextAddress,
|
||||
pub boundary: Option<ReferenceBoundary>,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ContextEdgeKind {
|
||||
Authored,
|
||||
SyntheticBoundary,
|
||||
LegacyOrder,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ContextEdge {
|
||||
pub relation: Option<Id>,
|
||||
pub instance: Vec<Id>,
|
||||
pub source: ContextVertex,
|
||||
pub target: ContextVertex,
|
||||
pub guard: String,
|
||||
pub kind: ContextEdgeKind,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ContextAssembly {
|
||||
pub relation: Id,
|
||||
pub instance: Vec<Id>,
|
||||
pub source: ContextAddress,
|
||||
pub target: ContextAddress,
|
||||
pub purpose: ContextualLinkPurpose,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ContextNotice {
|
||||
pub address: ContextAddress,
|
||||
pub code: &'static str,
|
||||
pub message: String,
|
||||
pub relations: Vec<Id>,
|
||||
}
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct AnalysisLimits {
|
||||
pub nodes: usize,
|
||||
pub edges: usize,
|
||||
}
|
||||
impl Default for AnalysisLimits {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
nodes: MAX_ANALYSIS_NODES,
|
||||
edges: MAX_ANALYSIS_EDGES,
|
||||
}
|
||||
}
|
||||
}
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct ContextAnalysis {
|
||||
pub nodes: BTreeMap<ContextAddress, ResolvedOccurrence>,
|
||||
pub precedence: Vec<ContextEdge>,
|
||||
pub assembly: Vec<ContextAssembly>,
|
||||
pub entries: Vec<ContextVertex>,
|
||||
pub exits: Vec<ContextVertex>,
|
||||
pub notices: Vec<ContextNotice>,
|
||||
/// A partial expansion must never be used for complete reachability conclusions.
|
||||
pub complete: bool,
|
||||
}
|
||||
struct Builder<'a, 'p> {
|
||||
index: &'a TopologyIndex<'p>,
|
||||
root: Id,
|
||||
limits: AnalysisLimits,
|
||||
result: ContextAnalysis,
|
||||
definitions: BTreeMap<Id, Vec<&'p Relation>>,
|
||||
examined_edges: usize,
|
||||
exhausted: bool,
|
||||
}
|
||||
impl TopologyIndex<'_> {
|
||||
pub fn analyze(&self, owner: Id, limits: AnalysisLimits) -> ContextAnalysis {
|
||||
self.analyze_counted(owner, limits).0
|
||||
}
|
||||
fn analyze_counted(&self, owner: Id, limits: AnalysisLimits) -> (ContextAnalysis, usize) {
|
||||
let mut definitions: BTreeMap<_, Vec<_>> = BTreeMap::new();
|
||||
for (owner, relations) in &self.link_definitions {
|
||||
definitions.insert(
|
||||
*owner,
|
||||
relations
|
||||
.iter()
|
||||
.filter_map(|id| self.project.relations.get(id))
|
||||
.collect(),
|
||||
);
|
||||
}
|
||||
let mut b = Builder {
|
||||
index: self,
|
||||
root: owner,
|
||||
limits,
|
||||
result: ContextAnalysis {
|
||||
complete: true,
|
||||
..ContextAnalysis::default()
|
||||
},
|
||||
definitions,
|
||||
examined_edges: 0,
|
||||
exhausted: false,
|
||||
};
|
||||
(b.result.entries, b.result.exits) = b.expand(owner, &[], &mut BTreeSet::new());
|
||||
if b.result.complete {
|
||||
b.cardinality();
|
||||
b.connectivity();
|
||||
}
|
||||
(b.result, b.examined_edges)
|
||||
}
|
||||
pub fn has_contextual_links(&self, owner: Id) -> bool {
|
||||
self.contextual_callers.contains(&owner)
|
||||
}
|
||||
}
|
||||
impl Builder<'_, '_> {
|
||||
fn address(&self, path: &[Id], occurrence: Id) -> ContextAddress {
|
||||
ContextAddress {
|
||||
owner: self.root,
|
||||
endpoint: OccurrenceEndpoint {
|
||||
references: path.to_vec(),
|
||||
occurrence,
|
||||
},
|
||||
}
|
||||
}
|
||||
fn vertex(&self, address: ContextAddress, source: bool) -> ContextVertex {
|
||||
let reference = self
|
||||
.index
|
||||
.project
|
||||
.elements
|
||||
.get(&address.endpoint.occurrence)
|
||||
.and_then(Element::shared)
|
||||
.is_some_and(|s| {
|
||||
matches!(
|
||||
s,
|
||||
SharedBody::Occurrence {
|
||||
body: OccurrenceBody::SequenceReference { .. },
|
||||
..
|
||||
}
|
||||
)
|
||||
});
|
||||
ContextVertex {
|
||||
address,
|
||||
boundary: reference.then_some(if source {
|
||||
ReferenceBoundary::Exit
|
||||
} else {
|
||||
ReferenceBoundary::Entry
|
||||
}),
|
||||
}
|
||||
}
|
||||
fn incomplete(&mut self, address: ContextAddress, code: &'static str, message: &str) {
|
||||
self.result.complete = false;
|
||||
if !self
|
||||
.result
|
||||
.notices
|
||||
.iter()
|
||||
.any(|n| n.address == address && n.code == code)
|
||||
{
|
||||
self.result.notices.push(ContextNotice {
|
||||
address,
|
||||
code,
|
||||
message: message.into(),
|
||||
relations: vec![],
|
||||
});
|
||||
}
|
||||
}
|
||||
// Reserve work before resolving an authored endpoint, not merely before storing
|
||||
// its output. Repeated malformed or filtered endpoints consume the same budget.
|
||||
fn reserve_edge(&mut self, address: ContextAddress) -> bool {
|
||||
if self.exhausted {
|
||||
return false;
|
||||
}
|
||||
if self.examined_edges >= self.limits.edges {
|
||||
self.exhausted = true;
|
||||
self.incomplete(
|
||||
address,
|
||||
"context_analysis_limit",
|
||||
"Contextual edge limit reached; connectivity analysis is incomplete.",
|
||||
);
|
||||
return false;
|
||||
}
|
||||
self.examined_edges += 1;
|
||||
true
|
||||
}
|
||||
fn edge(&mut self, edge: ContextEdge) -> bool {
|
||||
if !self.reserve_edge(edge.source.address.clone()) {
|
||||
return false;
|
||||
}
|
||||
self.result.precedence.push(edge);
|
||||
true
|
||||
}
|
||||
fn expand(
|
||||
&mut self,
|
||||
sequence: Id,
|
||||
prefix: &[Id],
|
||||
active: &mut BTreeSet<Id>,
|
||||
) -> (Vec<ContextVertex>, Vec<ContextVertex>) {
|
||||
if self.exhausted {
|
||||
return (vec![], vec![]);
|
||||
}
|
||||
if prefix.len() > MAX_REFERENCE_DEPTH || !active.insert(sequence) {
|
||||
self.incomplete(
|
||||
self.address(prefix, sequence),
|
||||
"context_analysis_incomplete",
|
||||
"Recursive or over-depth reuse has no proven boundary path.",
|
||||
);
|
||||
return (vec![], vec![]);
|
||||
}
|
||||
let children = self.index.contributions(sequence);
|
||||
if children.is_empty() {
|
||||
self.incomplete(
|
||||
self.address(prefix, sequence),
|
||||
"context_analysis_incomplete",
|
||||
"An empty or unresolved sequence has no proven boundary path.",
|
||||
);
|
||||
active.remove(&sequence);
|
||||
return (vec![], vec![]);
|
||||
}
|
||||
let start = self.result.precedence.len();
|
||||
for id in &children {
|
||||
let address = self.address(prefix, *id);
|
||||
if self.result.nodes.len() >= self.limits.nodes {
|
||||
self.exhausted = true;
|
||||
self.incomplete(
|
||||
address,
|
||||
"context_analysis_limit",
|
||||
"Contextual node limit reached; connectivity analysis is incomplete.",
|
||||
);
|
||||
break;
|
||||
}
|
||||
match self.index.resolve(self.root, &address.endpoint) {
|
||||
Ok(resolved) => {
|
||||
self.result.nodes.insert(address.clone(), resolved);
|
||||
}
|
||||
Err(e) => {
|
||||
self.incomplete(address, "context_analysis_incomplete", &e.message);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let reference = self.index.project.elements[id]
|
||||
.shared()
|
||||
.and_then(|s| match s {
|
||||
SharedBody::Occurrence {
|
||||
body: OccurrenceBody::SequenceReference { sequence },
|
||||
..
|
||||
} => Some(*sequence),
|
||||
_ => None,
|
||||
});
|
||||
if let Some(reference) = reference {
|
||||
let Some(callee) = reference else {
|
||||
self.incomplete(
|
||||
address,
|
||||
"context_analysis_incomplete",
|
||||
"An unresolved reference has no proven boundary path.",
|
||||
);
|
||||
continue;
|
||||
};
|
||||
let mut path = prefix.to_vec();
|
||||
path.push(*id);
|
||||
let (entries, exits) = self.expand(callee, &path, active);
|
||||
if self.exhausted {
|
||||
active.remove(&sequence);
|
||||
return (vec![], vec![]);
|
||||
}
|
||||
if entries.is_empty() || exits.is_empty() {
|
||||
self.incomplete(
|
||||
address.clone(),
|
||||
"context_analysis_incomplete",
|
||||
"No referenced entry or exit is proven; no bypass is inferred.",
|
||||
);
|
||||
}
|
||||
for target in entries {
|
||||
if !self.edge(ContextEdge {
|
||||
relation: None,
|
||||
instance: prefix.to_vec(),
|
||||
source: self.vertex(address.clone(), false),
|
||||
target,
|
||||
guard: String::new(),
|
||||
kind: ContextEdgeKind::SyntheticBoundary,
|
||||
}) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
for source in exits {
|
||||
if !self.edge(ContextEdge {
|
||||
relation: None,
|
||||
instance: prefix.to_vec(),
|
||||
source,
|
||||
target: self.vertex(address.clone(), true),
|
||||
guard: String::new(),
|
||||
kind: ContextEdgeKind::SyntheticBoundary,
|
||||
}) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if self.exhausted {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if self.exhausted {
|
||||
active.remove(&sequence);
|
||||
return (vec![], vec![]);
|
||||
}
|
||||
let relations = self.definitions.get(&sequence).cloned().unwrap_or_default();
|
||||
for r in &relations {
|
||||
if !self.reserve_edge(self.address(prefix, r.source())) {
|
||||
break;
|
||||
}
|
||||
let (source, target, purpose) = match &r.body {
|
||||
RelationBody::SequenceLink {
|
||||
source,
|
||||
target,
|
||||
guard,
|
||||
} => (
|
||||
OccurrenceEndpoint {
|
||||
references: vec![],
|
||||
occurrence: *source,
|
||||
},
|
||||
OccurrenceEndpoint {
|
||||
references: vec![],
|
||||
occurrence: *target,
|
||||
},
|
||||
ContextualLinkPurpose::Precedence {
|
||||
guard: guard.clone(),
|
||||
},
|
||||
),
|
||||
RelationBody::ContextualLink {
|
||||
source,
|
||||
target,
|
||||
purpose,
|
||||
..
|
||||
} => (source.clone(), target.clone(), purpose.clone()),
|
||||
_ => continue,
|
||||
};
|
||||
let qualify = |mut endpoint: OccurrenceEndpoint| {
|
||||
let mut path = prefix.to_vec();
|
||||
path.extend(endpoint.references);
|
||||
endpoint.references = path;
|
||||
ContextAddress {
|
||||
owner: self.root,
|
||||
endpoint,
|
||||
}
|
||||
};
|
||||
let source = qualify(source);
|
||||
let target = qualify(target);
|
||||
if !self.result.nodes.contains_key(&source) || !self.result.nodes.contains_key(&target)
|
||||
{
|
||||
self.incomplete(
|
||||
source,
|
||||
"context_analysis_incomplete",
|
||||
"A link endpoint is unresolved in this bounded analysis.",
|
||||
);
|
||||
continue;
|
||||
}
|
||||
match purpose {
|
||||
ContextualLinkPurpose::Precedence { guard } => {
|
||||
self.result.precedence.push(ContextEdge {
|
||||
relation: Some(r.id),
|
||||
instance: prefix.to_vec(),
|
||||
source: self.vertex(source, true),
|
||||
target: self.vertex(target, false),
|
||||
guard,
|
||||
kind: ContextEdgeKind::Authored,
|
||||
})
|
||||
}
|
||||
purpose => {
|
||||
self.result.assembly.push(ContextAssembly {
|
||||
relation: r.id,
|
||||
instance: prefix.to_vec(),
|
||||
source,
|
||||
target,
|
||||
purpose,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
if self.exhausted {
|
||||
active.remove(&sequence);
|
||||
return (vec![], vec![]);
|
||||
}
|
||||
if relations.is_empty()
|
||||
&& children.iter().all(|id| {
|
||||
matches!(
|
||||
self.index.project.elements[id].shared(),
|
||||
Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange { .. },
|
||||
..
|
||||
})
|
||||
)
|
||||
})
|
||||
{
|
||||
for pair in children.windows(2) {
|
||||
if !self.edge(ContextEdge {
|
||||
relation: None,
|
||||
instance: prefix.to_vec(),
|
||||
source: self.vertex(self.address(prefix, pair[0]), true),
|
||||
target: self.vertex(self.address(prefix, pair[1]), false),
|
||||
guard: String::new(),
|
||||
kind: ContextEdgeKind::LegacyOrder,
|
||||
}) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if self.exhausted {
|
||||
active.remove(&sequence);
|
||||
return (vec![], vec![]);
|
||||
}
|
||||
// Intrinsic boundaries are determined before any enclosing caller links.
|
||||
let edges = &self.result.precedence[start..];
|
||||
let targets: BTreeSet<_> = edges.iter().map(|e| &e.target).collect();
|
||||
let sources: BTreeSet<_> = edges.iter().map(|e| &e.source).collect();
|
||||
let entries = children
|
||||
.iter()
|
||||
.map(|id| self.vertex(self.address(prefix, *id), false))
|
||||
.filter(|v| !targets.contains(v))
|
||||
.collect();
|
||||
let exits = children
|
||||
.iter()
|
||||
.map(|id| self.vertex(self.address(prefix, *id), true))
|
||||
.filter(|v| !sources.contains(v))
|
||||
.collect();
|
||||
active.remove(&sequence);
|
||||
(entries, exits)
|
||||
}
|
||||
fn cardinality(&mut self) {
|
||||
// Index authored incidence once; synthetic boundaries never count as controls.
|
||||
let mut incoming: BTreeMap<_, Vec<_>> = BTreeMap::new();
|
||||
let mut outgoing: BTreeMap<_, Vec<_>> = BTreeMap::new();
|
||||
for edge in &self.result.precedence {
|
||||
if edge.kind == ContextEdgeKind::Authored {
|
||||
incoming.entry(&edge.target.address).or_default().push(edge);
|
||||
outgoing.entry(&edge.source.address).or_default().push(edge);
|
||||
}
|
||||
}
|
||||
for address in self.result.nodes.keys() {
|
||||
let ins = incoming.get(address).cloned().unwrap_or_default();
|
||||
let outs = outgoing.get(address).cloned().unwrap_or_default();
|
||||
let before = ins.len();
|
||||
let after = outs.len();
|
||||
let control = match self.index.project.elements[&address.endpoint.occurrence].shared() {
|
||||
Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Control { kind },
|
||||
..
|
||||
}) => Some(*kind),
|
||||
_ => None,
|
||||
};
|
||||
let issue = match control {
|
||||
Some(kind) => { let valid = match kind { ControlKind::Choice | ControlKind::Fork => before == 1 && after >= 2, ControlKind::Merge | ControlKind::Join => before >= 2 && after == 1, ControlKind::Loop => before >= 1 && after >= 2 }; (!valid).then_some(("control_cardinality", format!("{kind:?} has {before} incoming and {after} outgoing authored precedence links in this caller context. Review the intended paths."))) }
|
||||
None => (before > 1 || after > 1).then_some(("ambiguous_sequence_branch", "This contribution has multiple incoming or outgoing precedence links in this caller context. Review whether explicit merge/join or branching control is needed to describe the intended paths.".into())),
|
||||
};
|
||||
if let Some((code, message)) = issue {
|
||||
self.result.notices.push(ContextNotice {
|
||||
address: address.clone(),
|
||||
code,
|
||||
message,
|
||||
relations: ins.iter().chain(&outs).filter_map(|e| e.relation).collect(),
|
||||
});
|
||||
}
|
||||
if matches!(control, Some(ControlKind::Choice | ControlKind::Loop)) {
|
||||
for edge in outs.iter().filter(|e| e.guard.trim().is_empty()) {
|
||||
self.result.notices.push(ContextNotice { address: address.clone(), code: "missing_sequence_guard", message: "Describe the choice/loop condition for this path, or use an explicit 'else' guard.".into(), relations: edge.relation.into_iter().collect() });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
fn connectivity(&mut self) {
|
||||
let mut forward: BTreeMap<_, Vec<_>> = BTreeMap::new();
|
||||
let mut reverse: BTreeMap<_, Vec<_>> = BTreeMap::new();
|
||||
for edge in &self.result.precedence {
|
||||
forward
|
||||
.entry(edge.source.clone())
|
||||
.or_default()
|
||||
.push(edge.target.clone());
|
||||
reverse
|
||||
.entry(edge.target.clone())
|
||||
.or_default()
|
||||
.push(edge.source.clone());
|
||||
}
|
||||
let visit = |starts: &[ContextVertex],
|
||||
graph: &BTreeMap<ContextVertex, Vec<ContextVertex>>| {
|
||||
let mut pending = starts.to_vec();
|
||||
let mut seen = BTreeSet::new();
|
||||
while let Some(vertex) = pending.pop() {
|
||||
if seen.insert(vertex.clone()) {
|
||||
pending.extend(graph.get(&vertex).into_iter().flatten().cloned());
|
||||
}
|
||||
}
|
||||
seen
|
||||
};
|
||||
let from_start = visit(&self.result.entries, &forward);
|
||||
let to_end = visit(&self.result.exits, &reverse);
|
||||
for address in self.result.nodes.keys() {
|
||||
let entry = self.vertex(address.clone(), false);
|
||||
let exit = self.vertex(address.clone(), true);
|
||||
for (missing, code, message) in [
|
||||
(
|
||||
!from_start.contains(&entry),
|
||||
"unreachable_contribution",
|
||||
"No path from a structural caller entry reaches this contribution in this context.",
|
||||
),
|
||||
(
|
||||
!to_end.contains(&exit),
|
||||
"nonterminating_contribution",
|
||||
"No structural caller exit is reachable from this contribution in this context; review the intended loop.",
|
||||
),
|
||||
(
|
||||
self.result.nodes.len() > 1
|
||||
&& !reverse.contains_key(&entry)
|
||||
&& !forward.contains_key(&exit),
|
||||
"isolated_contribution",
|
||||
"This contribution has no incoming or outgoing precedence path in this caller context; assembly incidence is separate.",
|
||||
),
|
||||
] {
|
||||
if missing {
|
||||
self.result.notices.push(ContextNotice {
|
||||
address: address.clone(),
|
||||
code,
|
||||
message: message.into(),
|
||||
relations: vec![],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
use super::*;
|
||||
|
||||
fn occurrence(p: &mut Project, owner: Id, name: &str, body: OccurrenceBody) -> Id {
|
||||
let id = p
|
||||
.add_element(Kind::Occurrence, Layer::Sa, name, Some(owner))
|
||||
.unwrap();
|
||||
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::Occurrence {
|
||||
body,
|
||||
precondition: String::new(),
|
||||
postcondition: String::new(),
|
||||
};
|
||||
p.elements
|
||||
.get_mut(&owner)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap()
|
||||
.push(id);
|
||||
id
|
||||
}
|
||||
|
||||
fn repeated(references: usize, links: usize) -> (Project, Id, Id) {
|
||||
let mut p = Project::empty();
|
||||
let root = p
|
||||
.add_element(Kind::FunctionalChain, Layer::Sa, "Caller", None)
|
||||
.unwrap();
|
||||
let procedure = p
|
||||
.add_element(Kind::FunctionalChain, Layer::Sa, "Reusable procedure", None)
|
||||
.unwrap();
|
||||
let mut leaves = Vec::new();
|
||||
for name in ["A", "B"] {
|
||||
let action = p
|
||||
.add_element(Kind::Function, Layer::Sa, name, None)
|
||||
.unwrap();
|
||||
leaves.push(occurrence(
|
||||
&mut p,
|
||||
procedure,
|
||||
name,
|
||||
OccurrenceBody::Action {
|
||||
action: Some(action),
|
||||
lifeline: None,
|
||||
},
|
||||
));
|
||||
}
|
||||
for _ in 0..links {
|
||||
p.relate(
|
||||
RelationKind::SequenceLink,
|
||||
"Authored A to B",
|
||||
leaves[0],
|
||||
leaves[1],
|
||||
);
|
||||
}
|
||||
for _ in 0..references {
|
||||
occurrence(
|
||||
&mut p,
|
||||
root,
|
||||
"Reuse",
|
||||
OccurrenceBody::SequenceReference {
|
||||
sequence: Some(procedure),
|
||||
},
|
||||
);
|
||||
}
|
||||
(p, root, procedure)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edge_work_budget_stops_repeated_expansion_not_just_output_storage() {
|
||||
// Only ~3,000 possible contribution nodes, but a million authored edge
|
||||
// instances. The edge budget must stop actual work before later references.
|
||||
let (p, root, _) = repeated(1_000, 1_000);
|
||||
let limits = AnalysisLimits::default();
|
||||
let index = TopologyIndex::with_cache_capacity(&p, 1);
|
||||
let (first, attempts) = index.analyze_counted(root, limits);
|
||||
assert_eq!(attempts, limits.edges);
|
||||
assert_eq!(first.precedence.len(), limits.edges);
|
||||
assert!(!first.complete);
|
||||
assert!(first.entries.is_empty() && first.exits.is_empty());
|
||||
assert!(
|
||||
first.nodes.len() < 1_000,
|
||||
"later caller references must not be expanded"
|
||||
);
|
||||
assert_eq!(
|
||||
first
|
||||
.notices
|
||||
.iter()
|
||||
.filter(|n| n.code == "context_analysis_limit")
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
assert!(
|
||||
first
|
||||
.notices
|
||||
.iter()
|
||||
.all(|n| n.code == "context_analysis_limit")
|
||||
);
|
||||
let (warm, warm_attempts) = index.analyze_counted(root, limits);
|
||||
assert_eq!(warm_attempts, attempts);
|
||||
assert_eq!(warm.nodes, first.nodes);
|
||||
assert_eq!(warm.precedence, first.precedence);
|
||||
assert_eq!(warm.notices, first.notices);
|
||||
drop(warm);
|
||||
let (uncached, uncached_attempts) =
|
||||
TopologyIndex::with_cache_capacity(&p, 0).analyze_counted(root, limits);
|
||||
assert_eq!(uncached_attempts, attempts);
|
||||
assert_eq!(uncached.precedence, first.precedence);
|
||||
assert_eq!(uncached.notices, first.notices);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unresolved_authored_edges_consume_work_budget_without_stored_output() {
|
||||
let (mut p, root, _) = repeated(20, 100);
|
||||
let missing = Id::nil();
|
||||
for relation in p.relations.values_mut() {
|
||||
relation.body.set_endpoints(relation.source(), missing);
|
||||
}
|
||||
let (result, attempts) = TopologyIndex::new(&p).analyze_counted(
|
||||
root,
|
||||
AnalysisLimits {
|
||||
nodes: 1_000,
|
||||
edges: 17,
|
||||
},
|
||||
);
|
||||
assert_eq!(attempts, 17);
|
||||
assert!(result.precedence.is_empty());
|
||||
assert_eq!(result.nodes.len(), 3);
|
||||
assert!(!result.complete);
|
||||
assert!(result.entries.is_empty() && result.exits.is_empty());
|
||||
assert_eq!(
|
||||
result
|
||||
.notices
|
||||
.iter()
|
||||
.filter(|n| n.code == "context_analysis_limit")
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_edge_budget_includes_reference_boundaries_and_node_abort_has_no_bypass() {
|
||||
let (p, root, _) = repeated(1, 2);
|
||||
let index = TopologyIndex::new(&p);
|
||||
let (complete, count) = index.analyze_counted(root, AnalysisLimits { nodes: 3, edges: 4 });
|
||||
assert!(complete.complete);
|
||||
assert_eq!(count, 4); // two authored edges plus entry and exit connectors
|
||||
assert_eq!(
|
||||
complete
|
||||
.precedence
|
||||
.iter()
|
||||
.filter(|e| e.kind == ContextEdgeKind::SyntheticBoundary)
|
||||
.count(),
|
||||
2
|
||||
);
|
||||
for limits in [
|
||||
AnalysisLimits { nodes: 3, edges: 3 },
|
||||
AnalysisLimits {
|
||||
nodes: 1,
|
||||
edges: 10,
|
||||
},
|
||||
] {
|
||||
let (partial, count) = index.analyze_counted(root, limits);
|
||||
assert!(!partial.complete);
|
||||
assert!(partial.entries.is_empty() && partial.exits.is_empty());
|
||||
assert!(count <= limits.edges);
|
||||
assert_eq!(
|
||||
partial
|
||||
.notices
|
||||
.iter()
|
||||
.filter(|n| n.code == "context_analysis_limit")
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
use crate::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
mod communication;
|
||||
pub(crate) use communication::review_endpoint_fact;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum TransitionDirection {
|
||||
@@ -53,6 +55,36 @@ pub struct FieldResolution {
|
||||
pub field: TransitionField,
|
||||
pub decision: FieldDecision,
|
||||
}
|
||||
/// Technical endpoint choices are scoped to one OA exchange. Participant
|
||||
/// correspondence remains in `mappings`, never in these port identities.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub enum CommunicationPortChoice {
|
||||
Reuse {
|
||||
port: Id,
|
||||
},
|
||||
Create {
|
||||
id: Id,
|
||||
alias: String,
|
||||
name: String,
|
||||
description: String,
|
||||
kind: ComponentPortKind,
|
||||
orientation: ComponentPortOrientation,
|
||||
},
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct CommunicationEndpointDecision {
|
||||
pub source: Id,
|
||||
pub source_port: CommunicationPortChoice,
|
||||
pub target_port: CommunicationPortChoice,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct OperationOmission {
|
||||
pub source: Id,
|
||||
pub rationale: String,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct TransitionRequest {
|
||||
@@ -63,6 +95,10 @@ pub struct TransitionRequest {
|
||||
pub fields: Vec<TransitionField>,
|
||||
pub resolutions: Vec<FieldResolution>,
|
||||
pub destination_diagram: Option<Id>,
|
||||
#[serde(default)]
|
||||
pub communication_endpoints: Vec<CommunicationEndpointDecision>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub operation_omissions: Vec<OperationOmission>,
|
||||
}
|
||||
impl TransitionRequest {
|
||||
pub fn new(direction: TransitionDirection, sources: Vec<Id>) -> Self {
|
||||
@@ -74,6 +110,8 @@ impl TransitionRequest {
|
||||
fields: vec![TransitionField::Name, TransitionField::Description],
|
||||
resolutions: vec![],
|
||||
destination_diagram: None,
|
||||
communication_endpoints: vec![],
|
||||
operation_omissions: vec![],
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -177,6 +215,7 @@ fn inferred_kind(kind: Kind, direction: TransitionDirection) -> Option<Kind> {
|
||||
|| matches!(
|
||||
k,
|
||||
Kind::ExchangeItem
|
||||
| Kind::ExchangeCategory
|
||||
| Kind::DataType
|
||||
| Kind::DataField
|
||||
| Kind::Interface
|
||||
@@ -255,6 +294,28 @@ fn mapped_relation(r: &Relation, direction: TransitionDirection) -> Option<Relat
|
||||
items: items.clone(),
|
||||
}
|
||||
}
|
||||
(
|
||||
RelationBody::CommunicationMean {
|
||||
oriented, items, ..
|
||||
},
|
||||
TransitionDirection::OaToSa,
|
||||
) => RelationBody::BoundaryExchange {
|
||||
source,
|
||||
target,
|
||||
oriented: *oriented,
|
||||
items: items.clone(),
|
||||
},
|
||||
(
|
||||
RelationBody::BoundaryExchange {
|
||||
oriented, items, ..
|
||||
},
|
||||
TransitionDirection::SaToOa,
|
||||
) => RelationBody::CommunicationMean {
|
||||
source,
|
||||
target,
|
||||
oriented: *oriented,
|
||||
items: items.clone(),
|
||||
},
|
||||
(
|
||||
RelationBody::Allocation { .. }
|
||||
| RelationBody::InterfaceItemAllocation { .. }
|
||||
@@ -267,6 +328,7 @@ fn mapped_relation(r: &Relation, direction: TransitionDirection) -> Option<Relat
|
||||
| RelationBody::MissionInvolvement { .. }
|
||||
| RelationBody::MissionCapability { .. }
|
||||
| RelationBody::SequenceLink { .. }
|
||||
| RelationBody::ContextualLink { .. }
|
||||
| RelationBody::ChainInvolvement { .. }
|
||||
| RelationBody::BoundaryExchange { .. }
|
||||
| RelationBody::InterfaceProvision { .. }
|
||||
@@ -318,7 +380,7 @@ fn trace_kind(p: &Project, oa: Id, sa: Id) -> TraceKind {
|
||||
_ => TraceKind::Manual,
|
||||
},
|
||||
_ => {
|
||||
if matches!((p.relations.get(&oa),p.relations.get(&sa)),(Some(a),Some(b))if a.kind()==RelationKind::Interaction&&b.kind()==RelationKind::FunctionalExchange)
|
||||
if matches!((p.relations.get(&oa),p.relations.get(&sa)),(Some(a),Some(b))if exchange_counterparts(a.kind(), b.kind()))
|
||||
{
|
||||
TraceKind::Exchange
|
||||
} else {
|
||||
@@ -349,11 +411,12 @@ fn sa_port_omissions(p: &Project) -> BTreeSet<Id> {
|
||||
.collect();
|
||||
loop {
|
||||
let before = omitted.len();
|
||||
for r in p
|
||||
.relations
|
||||
.values()
|
||||
.filter(|r| r.kind() != RelationKind::FunctionalExchange)
|
||||
{
|
||||
for r in p.relations.values().filter(|r| {
|
||||
!matches!(
|
||||
r.kind(),
|
||||
RelationKind::FunctionalExchange | RelationKind::BoundaryExchange
|
||||
)
|
||||
}) {
|
||||
if crate::references::relation_references(r)
|
||||
.iter()
|
||||
.any(|id| omitted.contains(id))
|
||||
@@ -447,7 +510,16 @@ pub fn plan_transition(
|
||||
continue;
|
||||
}
|
||||
if let Some(e) = project.elements.get(&id) {
|
||||
selected.extend(crate::references::element_references(e));
|
||||
let mut dependencies = e.clone();
|
||||
if request.direction == TransitionDirection::SaToOa
|
||||
&& let Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange { operation, .. },
|
||||
..
|
||||
}) = dependencies.shared_mut()
|
||||
{
|
||||
*operation = None;
|
||||
}
|
||||
selected.extend(crate::references::element_references(&dependencies));
|
||||
// Allocations are owned by interfaces. Independent data
|
||||
// associations travel only when both endpoint types are
|
||||
// already required; they do not expand the dictionary.
|
||||
@@ -470,12 +542,13 @@ pub fn plan_transition(
|
||||
// their endpoint involvements, independently of list order.
|
||||
if matches!(e.kind(), Kind::Process | Kind::FunctionalChain) {
|
||||
selected.extend(project.relations.values().filter_map(|relation| {
|
||||
(relation.kind() == RelationKind::SequenceLink
|
||||
&& project
|
||||
.elements
|
||||
.get(&relation.source())
|
||||
.and_then(|e| e.parent)
|
||||
== Some(id))
|
||||
(relation.contextual_owner() == Some(id)
|
||||
|| (relation.kind() == RelationKind::SequenceLink
|
||||
&& project
|
||||
.elements
|
||||
.get(&relation.source())
|
||||
.and_then(|e| e.parent)
|
||||
== Some(id)))
|
||||
.then_some(relation.id)
|
||||
}));
|
||||
}
|
||||
@@ -499,6 +572,15 @@ pub fn plan_transition(
|
||||
}
|
||||
} else if let Some(r) = project.relations.get(&id) {
|
||||
if request.direction == TransitionDirection::SaToOa
|
||||
&& r.kind() == RelationKind::BoundaryExchange
|
||||
{
|
||||
selected.extend(
|
||||
[r.source(), r.target()].into_iter().filter_map(|port| {
|
||||
project.elements.get(&port).and_then(|e| e.parent)
|
||||
}),
|
||||
);
|
||||
selected.extend(r.body.items());
|
||||
} else if request.direction == TransitionDirection::SaToOa
|
||||
&& let RelationBody::FunctionalExchange {
|
||||
source,
|
||||
target,
|
||||
@@ -532,6 +614,67 @@ pub fn plan_transition(
|
||||
false
|
||||
} else { true }
|
||||
});
|
||||
let mut operation_omissions = BTreeSet::new();
|
||||
for decision in &request.operation_omissions {
|
||||
if request.direction != TransitionDirection::SaToOa
|
||||
|| !selected.contains(&decision.source)
|
||||
|| !matches!(
|
||||
project
|
||||
.elements
|
||||
.get(&decision.source)
|
||||
.and_then(Element::shared),
|
||||
Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange {
|
||||
operation: Some(_),
|
||||
message_kind: MessageKind::Call | MessageKind::Asynchronous,
|
||||
..
|
||||
},
|
||||
..
|
||||
})
|
||||
)
|
||||
|| decision.rationale.trim().is_empty()
|
||||
|| !validation::valid_text(&decision.rationale)
|
||||
|| !operation_omissions.insert(decision.source)
|
||||
{
|
||||
conflict(
|
||||
&mut plan,
|
||||
Some(decision.source),
|
||||
None,
|
||||
None,
|
||||
"Operation omission needs one selected SA invocation, SA→OA direction, and a nonempty rationale; irrelevant or duplicate decisions reject.",
|
||||
);
|
||||
} else {
|
||||
plan.warnings.push(format!(
|
||||
"Reviewed operation omission: {} ({}): {}. Source contract is preserved.",
|
||||
project.named(decision.source),
|
||||
decision.source,
|
||||
decision.rationale
|
||||
));
|
||||
}
|
||||
}
|
||||
if request.direction == TransitionDirection::SaToOa {
|
||||
for source in &selected {
|
||||
if matches!(
|
||||
project.elements.get(source).and_then(Element::shared),
|
||||
Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange {
|
||||
operation: Some(_),
|
||||
..
|
||||
},
|
||||
..
|
||||
})
|
||||
) && !operation_omissions.contains(source)
|
||||
{
|
||||
conflict(
|
||||
&mut plan,
|
||||
Some(*source),
|
||||
None,
|
||||
None,
|
||||
"This technical operation has no OA operation slot. Review an explicit per-invocation omission with rationale; Reply derives the Call's decision.",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut choices = BTreeMap::new();
|
||||
for mapping in &request.mappings {
|
||||
if choices.insert(mapping.source, mapping).is_some() {
|
||||
@@ -695,6 +838,8 @@ pub fn plan_transition(
|
||||
}
|
||||
}
|
||||
let mut working = project.clone();
|
||||
let communication_pairs =
|
||||
communication::prepare_ports(project, &mut working, &targets, &mut plan);
|
||||
let ordered_creations: Vec<_> = selected
|
||||
.iter()
|
||||
.filter(|id| creating.contains(id) && project.elements.contains_key(id))
|
||||
@@ -723,6 +868,15 @@ pub fn plan_transition(
|
||||
let mut created = e.clone();
|
||||
created.id = target;
|
||||
created.body = body;
|
||||
if request.direction == TransitionDirection::SaToOa
|
||||
&& operation_omissions.contains(source)
|
||||
&& let Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange { operation, .. },
|
||||
..
|
||||
}) = created.shared_mut()
|
||||
{
|
||||
*operation = None;
|
||||
}
|
||||
created.alias = working.fresh_alias(
|
||||
&format!(
|
||||
"{}_{}",
|
||||
@@ -764,6 +918,25 @@ pub fn plan_transition(
|
||||
let mut created = r.clone();
|
||||
created.id = target;
|
||||
created.body = body;
|
||||
// SA technical ports project through their reviewed owners; never
|
||||
// place a port→participant pair in the global correspondence map.
|
||||
if request.direction == TransitionDirection::SaToOa
|
||||
&& r.kind() == RelationKind::BoundaryExchange
|
||||
{
|
||||
let owners = [r.source(), r.target()]
|
||||
.map(|port| project.elements.get(&port).and_then(|e| e.parent));
|
||||
if let [Some(a), Some(b)] = owners {
|
||||
created.body.set_endpoints(a, b);
|
||||
} else {
|
||||
conflict(
|
||||
&mut plan,
|
||||
Some(*source),
|
||||
Some(target),
|
||||
None,
|
||||
"Boundary endpoints need participant owners before communication transition.",
|
||||
);
|
||||
}
|
||||
}
|
||||
created.alias = working.fresh_alias(
|
||||
&format!(
|
||||
"{}_{}",
|
||||
@@ -778,6 +951,21 @@ pub fn plan_transition(
|
||||
*id = *mapped;
|
||||
}
|
||||
});
|
||||
if request.direction == TransitionDirection::OaToSa
|
||||
&& r.kind() == RelationKind::CommunicationMean
|
||||
{
|
||||
if let Some((a, b)) = communication_pairs.get(source) {
|
||||
created.body.set_endpoints(*a, *b);
|
||||
} else {
|
||||
conflict(
|
||||
&mut plan,
|
||||
Some(*source),
|
||||
Some(target),
|
||||
None,
|
||||
"Choose an explicit SA component-port pair for this communication means; participant mappings cannot stand in for ports.",
|
||||
);
|
||||
}
|
||||
}
|
||||
for dependency in crate::references::relation_references(&created) {
|
||||
if dependency.is_nil()
|
||||
&& request.direction == TransitionDirection::OaToSa
|
||||
@@ -1139,9 +1327,22 @@ fn compatible_existing(
|
||||
(Some(a), Some(b)) => compatible_kinds(a.kind(), b.kind(), direction),
|
||||
_ => match (p.relations.get(&source), p.relations.get(&target)) {
|
||||
(Some(a), Some(b)) => {
|
||||
mapped_relation(a, direction).is_some_and(|body| body.kind() == b.kind())
|
||||
(a.kind() == RelationKind::BoundaryExchange
|
||||
&& b.kind() == RelationKind::BoundaryExchange)
|
||||
|| mapped_relation(a, direction).is_some_and(|body| body.kind() == b.kind())
|
||||
}
|
||||
_ => false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn exchange_counterparts(oa: RelationKind, sa: RelationKind) -> bool {
|
||||
matches!(
|
||||
(oa, sa),
|
||||
(RelationKind::Interaction, RelationKind::FunctionalExchange)
|
||||
| (
|
||||
RelationKind::CommunicationMean,
|
||||
RelationKind::BoundaryExchange
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
use super::*;
|
||||
|
||||
fn port_id(choice: &CommunicationPortChoice) -> Id {
|
||||
match choice {
|
||||
CommunicationPortChoice::Reuse { port } => *port,
|
||||
CommunicationPortChoice::Create { id, .. } => *id,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn prepare_ports(
|
||||
original: &Project,
|
||||
working: &mut Project,
|
||||
targets: &BTreeMap<Id, Id>,
|
||||
plan: &mut TransitionPlan,
|
||||
) -> BTreeMap<Id, (Id, Id)> {
|
||||
let mut pairs = BTreeMap::new();
|
||||
for decision in plan.request.communication_endpoints.clone() {
|
||||
let result = (|| -> Result<(Id, Id), String> {
|
||||
if plan.request.direction != TransitionDirection::OaToSa {
|
||||
return Err(
|
||||
"SA endpoint choices apply only to OA→SA communication transition.".into(),
|
||||
);
|
||||
}
|
||||
let means = original
|
||||
.relations
|
||||
.get(&decision.source)
|
||||
.filter(|r| r.kind() == RelationKind::CommunicationMean)
|
||||
.ok_or("Endpoint decision must select an OA communication means.")?;
|
||||
if !plan.request.sources.contains(&means.id) && !targets.contains_key(&means.id) {
|
||||
return Err("Endpoint decision is outside this transition selection.".into());
|
||||
}
|
||||
if pairs.contains_key(&decision.source) {
|
||||
return Err("Choose one component-port pair per communication means.".into());
|
||||
}
|
||||
let mut pending = vec![];
|
||||
for (participant, choice) in [
|
||||
(means.source(), &decision.source_port),
|
||||
(means.target(), &decision.target_port),
|
||||
] {
|
||||
let owner = targets.get(&participant).copied().ok_or("Review each OA participant's SA counterpart before selecting its component port.")?;
|
||||
if working.elements.get(&owner).is_some_and(|e| {
|
||||
e.layer() != Layer::Sa || !matches!(e.kind(), Kind::System | Kind::SystemActor)
|
||||
}) {
|
||||
return Err(
|
||||
"Component-port owner must be the reviewed SA participant counterpart."
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
match choice {
|
||||
CommunicationPortChoice::Reuse { port } => {
|
||||
let e = working
|
||||
.elements
|
||||
.get(port)
|
||||
.ok_or("Selected component port does not exist.")?;
|
||||
if e.parent != Some(owner)
|
||||
|| e.layer() != Layer::Sa
|
||||
|| !matches!(
|
||||
crate::ports::port_contract(e),
|
||||
Some(PortContract::Component { .. })
|
||||
)
|
||||
{
|
||||
return Err("Selected component port must belong to the reviewed SA participant and have an explicit component contract.".into());
|
||||
}
|
||||
}
|
||||
CommunicationPortChoice::Create {
|
||||
id,
|
||||
alias,
|
||||
name,
|
||||
description,
|
||||
kind,
|
||||
orientation,
|
||||
} => {
|
||||
if id.is_nil()
|
||||
|| working.contains(*id)
|
||||
|| pending.iter().any(|e: &Element| e.id == *id)
|
||||
{
|
||||
return Err("New component-port identity must be unused; choose Reuse for an existing port.".into());
|
||||
}
|
||||
pending.push(Element {
|
||||
id: *id,
|
||||
alias: alias.clone(),
|
||||
name: name.clone(),
|
||||
description: description.clone(),
|
||||
parent: Some(owner),
|
||||
body: ElementBody::shared_in(
|
||||
Layer::Sa,
|
||||
SharedBody::Port {
|
||||
contract: PortContract::Component {
|
||||
kind: *kind,
|
||||
orientation: *orientation,
|
||||
},
|
||||
legacy: None,
|
||||
},
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
let pair = (
|
||||
port_id(&decision.source_port),
|
||||
port_id(&decision.target_port),
|
||||
);
|
||||
if pair.0 == pair.1 || targets.get(&means.source()) == targets.get(&means.target()) {
|
||||
return Err("Communication counterparts must have different participant owners; review the participant boundary mapping.".into());
|
||||
}
|
||||
for e in pending {
|
||||
working.elements.insert(e.id, e.clone());
|
||||
plan.commands.push(Command::CreateElement(e));
|
||||
}
|
||||
Ok(pair)
|
||||
})();
|
||||
match result {
|
||||
Ok(pair) => {
|
||||
pairs.insert(decision.source, pair);
|
||||
}
|
||||
Err(message) => conflict(
|
||||
plan,
|
||||
Some(decision.source),
|
||||
targets.get(&decision.source).copied(),
|
||||
None,
|
||||
message,
|
||||
),
|
||||
}
|
||||
}
|
||||
pairs
|
||||
}
|
||||
|
||||
/// Adapt only the transferred endpoint value. Reviewed baseline facts continue
|
||||
/// to retain the actual participant/port identities on their respective sides.
|
||||
pub(crate) fn review_endpoint_fact(
|
||||
project: &Project,
|
||||
request: &TransitionRequest,
|
||||
map: &BTreeMap<Id, Id>,
|
||||
source: Id,
|
||||
target: Id,
|
||||
fact: &FacetReview,
|
||||
) -> Result<Option<FacetReview>, String> {
|
||||
let (Some(a), Some(b)) = (
|
||||
project.relations.get(&source),
|
||||
project.relations.get(&target),
|
||||
) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let forward =
|
||||
a.kind() == RelationKind::CommunicationMean && b.kind() == RelationKind::BoundaryExchange;
|
||||
let reverse =
|
||||
a.kind() == RelationKind::BoundaryExchange && b.kind() == RelationKind::CommunicationMean;
|
||||
if !forward && !reverse {
|
||||
return Ok(None);
|
||||
}
|
||||
let (endpoint, is_source) = match fact {
|
||||
FacetReview::Relation(RelationReview::Source(_)) => (a.source(), true),
|
||||
FacetReview::Relation(RelationReview::Target(_)) => (a.target(), false),
|
||||
_ => return Ok(None),
|
||||
};
|
||||
let mapped = if forward {
|
||||
let decision = request.communication_endpoints.iter().find(|d| d.source == source)
|
||||
.ok_or("Taking an OA endpoint requires this exchange's explicit SA component-port decision.")?;
|
||||
let port = port_id(if is_source {
|
||||
&decision.source_port
|
||||
} else {
|
||||
&decision.target_port
|
||||
});
|
||||
let expected_owner = map.get(&endpoint).ok_or(
|
||||
"Review the participant counterpart before taking its communication endpoint.",
|
||||
)?;
|
||||
let e = project
|
||||
.elements
|
||||
.get(&port)
|
||||
.ok_or("Selected communication endpoint port does not exist.")?;
|
||||
if e.parent != Some(*expected_owner)
|
||||
|| e.layer() != Layer::Sa
|
||||
|| !matches!(
|
||||
crate::ports::port_contract(e),
|
||||
Some(PortContract::Component { .. })
|
||||
)
|
||||
{
|
||||
return Err(
|
||||
"Selected component port disagrees with the reviewed participant counterpart."
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
port
|
||||
} else {
|
||||
let owner = project
|
||||
.elements
|
||||
.get(&endpoint)
|
||||
.and_then(|e| e.parent)
|
||||
.ok_or("SA boundary port needs a participant owner.")?;
|
||||
let participant = *map.get(&owner).ok_or(
|
||||
"Review the SA port owner's OA participant counterpart before taking this endpoint.",
|
||||
)?;
|
||||
if !project.elements.get(&participant).is_some_and(|e| {
|
||||
e.layer() == Layer::Oa && matches!(e.kind(), Kind::Entity | Kind::Actor)
|
||||
}) {
|
||||
return Err("Boundary endpoint owner must map to an OA entity or actor.".into());
|
||||
}
|
||||
participant
|
||||
};
|
||||
let mut result = fact.clone();
|
||||
if let FacetReview::Relation(RelationReview::Source(values) | RelationReview::Target(values)) =
|
||||
&mut result
|
||||
{
|
||||
match request.direction.source_layer() {
|
||||
Layer::Oa => values.oa = Observed::Value(mapped),
|
||||
Layer::Sa => values.sa = Observed::Value(mapped),
|
||||
}
|
||||
}
|
||||
Ok(Some(result))
|
||||
}
|
||||
@@ -2,6 +2,8 @@ use crate::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
mod coverage;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum Severity {
|
||||
Error,
|
||||
@@ -179,6 +181,8 @@ fn check_ids(out: &mut Vec<Issue>, owner: Id, field: &str, ids: &[Id]) {
|
||||
|
||||
pub fn validate(p: &Project) -> Vec<Issue> {
|
||||
let mut out = Vec::new();
|
||||
let coverage = coverage::Index::new(p);
|
||||
let topology_index = crate::topology_context::TopologyIndex::new(p);
|
||||
if p.schema_version != SCHEMA_VERSION {
|
||||
error(
|
||||
&mut out,
|
||||
@@ -346,19 +350,15 @@ pub fn validate(p: &Project) -> Vec<Issue> {
|
||||
);
|
||||
}
|
||||
if matches!(e.kind(), Kind::Capability | Kind::SystemCapability)
|
||||
&& !p.relations.values().any(|r| {
|
||||
matches!(
|
||||
r.kind(),
|
||||
RelationKind::CapabilityInvolvement | RelationKind::ChainInvolvement
|
||||
) && r.target() == e.id
|
||||
})
|
||||
&& !coverage.grouping.contains(&e.id)
|
||||
&& !coverage.supported_capabilities.contains(&e.id)
|
||||
{
|
||||
warning(
|
||||
&mut out,
|
||||
e.id,
|
||||
"unsupported_capability",
|
||||
"involvement",
|
||||
"Connect the capability to involved participants, work, or a process/chain.",
|
||||
"Connect the capability to involved participants, work, a process/chain, or an explicit scenario context.",
|
||||
);
|
||||
}
|
||||
if let Some(steps) = e.steps() {
|
||||
@@ -384,10 +384,27 @@ pub fn validate(p: &Project) -> Vec<Issue> {
|
||||
"Add ordered occurrences.",
|
||||
);
|
||||
}
|
||||
validate_sequence(p, e, steps, &mut out);
|
||||
validate_sequence(p, e, steps, &topology_index, &mut out);
|
||||
}
|
||||
if let Some(shared) = e.shared() {
|
||||
match shared {
|
||||
SharedBody::ExchangeCategory { family, exchanges } => {
|
||||
for issue in crate::view_options::validate_category(
|
||||
p,
|
||||
e.layer(),
|
||||
e.parent,
|
||||
*family,
|
||||
exchanges,
|
||||
) {
|
||||
error(
|
||||
&mut out,
|
||||
e.id,
|
||||
"exchange_category",
|
||||
issue.field,
|
||||
issue.message,
|
||||
);
|
||||
}
|
||||
}
|
||||
SharedBody::DataType { .. }
|
||||
| SharedBody::DataField { .. }
|
||||
| SharedBody::Interface { .. }
|
||||
@@ -584,7 +601,7 @@ pub fn validate(p: &Project) -> Vec<Issue> {
|
||||
}
|
||||
text(&mut out, r.id, "name", &r.name);
|
||||
text(&mut out, r.id, "description", &r.description);
|
||||
validate_relation(p, r, &mut out);
|
||||
validate_relation(p, r, &topology_index, &mut out);
|
||||
crate::data::validate_relation(p, r, &mut out);
|
||||
crate::ports::validate_boundary_interface_requirements(p, r, &mut out);
|
||||
validate_relation_context(p, r, &mut out);
|
||||
@@ -602,6 +619,14 @@ pub fn validate(p: &Project) -> Vec<Issue> {
|
||||
}
|
||||
text(&mut out, d.id, "name", &d.name);
|
||||
text(&mut out, d.id, "description", &d.description);
|
||||
let option_issues = if d.projection == ProjectionKind::Traceability {
|
||||
crate::view_options::validate_options_cross_layer(p, &d.view_options)
|
||||
} else {
|
||||
crate::view_options::validate_options(p, d.layer, &d.view_options)
|
||||
};
|
||||
for issue in option_issues {
|
||||
error(&mut out, d.id, "view_options", issue.field, issue.message);
|
||||
}
|
||||
if let Some(machine) = d.projection.state_machine()
|
||||
&& !p
|
||||
.elements
|
||||
@@ -618,6 +643,20 @@ pub fn validate(p: &Project) -> Vec<Issue> {
|
||||
}
|
||||
let mut routed_edges = BTreeSet::new();
|
||||
for (index, route) in d.routes.iter().enumerate() {
|
||||
for attachment in [route.source_attachment, route.target_attachment]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
if !attachment.offset.is_finite() || !(0.0..=1.0).contains(&attachment.offset) {
|
||||
error(
|
||||
&mut out,
|
||||
d.id,
|
||||
"invalid_attachment_offset",
|
||||
"routes",
|
||||
"Connection attachment offset must be finite and between zero and one.",
|
||||
);
|
||||
}
|
||||
}
|
||||
if !routed_edges.insert(route.edge) {
|
||||
error(
|
||||
&mut out,
|
||||
@@ -921,12 +960,62 @@ pub fn validate(p: &Project) -> Vec<Issue> {
|
||||
);
|
||||
}
|
||||
}
|
||||
if !out.iter().any(|issue| issue.severity == Severity::Error) {
|
||||
coverage.validate(p, &mut out);
|
||||
}
|
||||
out
|
||||
}
|
||||
fn validate_sequence(p: &Project, e: &Element, steps: &[Id], out: &mut Vec<Issue>) {
|
||||
fn validate_sequence(
|
||||
p: &Project,
|
||||
e: &Element,
|
||||
steps: &[Id],
|
||||
topology: &crate::topology_context::TopologyIndex<'_>,
|
||||
out: &mut Vec<Issue>,
|
||||
) {
|
||||
if !matches!(e.kind(), Kind::Process | Kind::FunctionalChain) {
|
||||
return;
|
||||
}
|
||||
if topology.has_contextual_links(e.id) {
|
||||
let analysis = topology.analyze(e.id, crate::topology_context::AnalysisLimits::default());
|
||||
for notice in analysis.notices {
|
||||
let breadcrumb = notice
|
||||
.address
|
||||
.endpoint
|
||||
.references
|
||||
.iter()
|
||||
.chain(std::iter::once(¬ice.address.endpoint.occurrence))
|
||||
.map(|id| format!("{} ({id})", p.named(*id)))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" / ");
|
||||
warning(
|
||||
out,
|
||||
e.id,
|
||||
notice.code,
|
||||
"steps",
|
||||
format!("Caller '{}', {breadcrumb}: {}", e.name, notice.message),
|
||||
);
|
||||
}
|
||||
if analysis.complete {
|
||||
for (code, label, count) in [
|
||||
("sequence_starts", "start", analysis.entries.len()),
|
||||
("sequence_ends", "end", analysis.exits.len()),
|
||||
] {
|
||||
if count != 1 {
|
||||
warning(
|
||||
out,
|
||||
e.id,
|
||||
code,
|
||||
"steps",
|
||||
format!(
|
||||
"Caller '{}' has {count} structural {label} boundaries; review the intended independent paths.",
|
||||
e.name
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
let links: Vec<_> = p
|
||||
.relations
|
||||
.values()
|
||||
@@ -1254,40 +1343,149 @@ pub fn semantic_representation_compatible(
|
||||
pub fn lifeline_matches_endpoint(p: &Project, line: Id, endpoint: Id) -> Option<bool> {
|
||||
semantic_representation_compatible(p, represented(p, line)?, endpoint)
|
||||
}
|
||||
/// Effective semantic direction, including a Reply that reuses its Call exchange.
|
||||
/// An incomplete reply correlation and a local timer have no inferred endpoints.
|
||||
pub fn message_endpoints(p: &Project, body: &OccurrenceBody) -> Option<(Id, Id)> {
|
||||
/// A single shared answer for scenario admission, drawing, and endpoint pickers.
|
||||
/// Incomplete or ambiguous bindings never choose an arbitrary direction.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum MessageEndpointResolution {
|
||||
NotApplicable,
|
||||
Incomplete,
|
||||
Resolved { source: Id, target: Id },
|
||||
Ambiguous,
|
||||
Conflict,
|
||||
}
|
||||
pub fn resolve_message_endpoints(p: &Project, body: &OccurrenceBody) -> MessageEndpointResolution {
|
||||
use MessageEndpointResolution as R;
|
||||
let OccurrenceBody::Exchange {
|
||||
exchange: Some(exchange),
|
||||
message_kind,
|
||||
reply_to,
|
||||
sender,
|
||||
receiver,
|
||||
..
|
||||
} = body
|
||||
else {
|
||||
return None;
|
||||
return R::NotApplicable;
|
||||
};
|
||||
if matches!(message_kind, MessageKind::Timer { .. }) {
|
||||
return None;
|
||||
return R::NotApplicable;
|
||||
}
|
||||
let Some(relation) = p.relations.get(exchange) else {
|
||||
return R::Incomplete;
|
||||
};
|
||||
let mut candidates = vec![relation.body.endpoints()];
|
||||
// A mapped unoriented SA boundary retains the OA communication's possible
|
||||
// directions. Fixed oriented/legacy exchange behavior remains unchanged.
|
||||
let communication = matches!(
|
||||
relation.body,
|
||||
RelationBody::CommunicationMean { .. }
|
||||
| RelationBody::BoundaryExchange {
|
||||
oriented: false,
|
||||
..
|
||||
}
|
||||
);
|
||||
if matches!(
|
||||
relation.body,
|
||||
RelationBody::CommunicationMean {
|
||||
oriented: false,
|
||||
..
|
||||
} | RelationBody::BoundaryExchange {
|
||||
oriented: false,
|
||||
..
|
||||
}
|
||||
) {
|
||||
candidates.push((relation.target(), relation.source()));
|
||||
}
|
||||
if matches!(
|
||||
relation.body,
|
||||
RelationBody::BoundaryExchange {
|
||||
oriented: false,
|
||||
..
|
||||
}
|
||||
) {
|
||||
candidates.retain(|(source, target)| {
|
||||
match (
|
||||
p.elements.get(source).and_then(crate::ports::port_contract),
|
||||
p.elements.get(target).and_then(crate::ports::port_contract),
|
||||
) {
|
||||
(
|
||||
Some(a @ PortContract::Component { .. }),
|
||||
Some(b @ PortContract::Component { .. }),
|
||||
) => {
|
||||
crate::ports::component_allows(a, true)
|
||||
&& crate::ports::component_allows(b, false)
|
||||
}
|
||||
// Legacy direction contracts remain fixed even for an
|
||||
// unoriented declaration; do not reinterpret old ports.
|
||||
_ => (*source, *target) == relation.body.endpoints(),
|
||||
}
|
||||
});
|
||||
}
|
||||
let relation = p.relations.get(exchange)?;
|
||||
if message_kind == &MessageKind::Reply {
|
||||
let Some(reply_to) = reply_to else {
|
||||
return R::Incomplete;
|
||||
};
|
||||
let Some(SharedBody::Occurrence {
|
||||
body:
|
||||
OccurrenceBody::Exchange {
|
||||
call_body @ OccurrenceBody::Exchange {
|
||||
message_kind: MessageKind::Call,
|
||||
exchange: Some(call_exchange),
|
||||
..
|
||||
},
|
||||
..
|
||||
}) = p.elements.get(&(*reply_to)?).and_then(Element::shared)
|
||||
}) = p.elements.get(reply_to).and_then(Element::shared)
|
||||
else {
|
||||
return None;
|
||||
return R::Incomplete;
|
||||
};
|
||||
if call_exchange == exchange {
|
||||
return Some((relation.target(), relation.source()));
|
||||
let R::Resolved { source, target } = resolve_message_endpoints(p, call_body) else {
|
||||
return R::Incomplete;
|
||||
};
|
||||
candidates = vec![(target, source)];
|
||||
} else if communication {
|
||||
let R::Resolved { source, target } = resolve_message_endpoints(p, call_body) else {
|
||||
return R::Incomplete;
|
||||
};
|
||||
let owner = |id| {
|
||||
p.elements
|
||||
.get(&id)
|
||||
.filter(|e| e.kind() == Kind::Port)
|
||||
.and_then(|e| e.parent)
|
||||
.unwrap_or(id)
|
||||
};
|
||||
candidates.retain(|(a, b)| {
|
||||
semantic_representation_compatible(p, owner(*a), owner(target)) == Some(true)
|
||||
&& semantic_representation_compatible(p, owner(*b), owner(source)) == Some(true)
|
||||
});
|
||||
}
|
||||
}
|
||||
Some(relation.body.endpoints())
|
||||
if communication {
|
||||
candidates.retain(|(a, b)| {
|
||||
sender.is_none_or(|line| lifeline_matches_endpoint(p, line, *a) != Some(false))
|
||||
&& receiver.is_none_or(|line| lifeline_matches_endpoint(p, line, *b) != Some(false))
|
||||
});
|
||||
if candidates.is_empty() {
|
||||
return R::Conflict;
|
||||
}
|
||||
if sender.and_then(|line| represented(p, line)).is_none()
|
||||
|| receiver.and_then(|line| represented(p, line)).is_none()
|
||||
{
|
||||
return R::Incomplete;
|
||||
}
|
||||
if candidates.len() > 1 {
|
||||
return R::Ambiguous;
|
||||
}
|
||||
}
|
||||
let Some((source, target)) = candidates.first().copied() else {
|
||||
return R::Conflict;
|
||||
};
|
||||
R::Resolved { source, target }
|
||||
}
|
||||
/// Compatibility adapter: only a resolved direction has inferred endpoints.
|
||||
pub fn message_endpoints(p: &Project, body: &OccurrenceBody) -> Option<(Id, Id)> {
|
||||
match resolve_message_endpoints(p, body) {
|
||||
MessageEndpointResolution::Resolved { source, target } => Some((source, target)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// Only report order guaranteed by a common sequence or an enclosing branch path.
|
||||
/// A call in one alternative does not become an unconditional call after the fragment.
|
||||
@@ -1418,6 +1616,7 @@ fn validate_message(p: &Project, e: &Element, body: &OccurrenceBody, out: &mut V
|
||||
receiver,
|
||||
reply_to,
|
||||
values,
|
||||
..
|
||||
} = body
|
||||
else {
|
||||
return;
|
||||
@@ -1440,7 +1639,9 @@ fn validate_message(p: &Project, e: &Element, body: &OccurrenceBody, out: &mut V
|
||||
ScenarioViewpoint::Functional => relation.kind() == RelationKind::FunctionalExchange,
|
||||
ScenarioViewpoint::ParticipantInteraction => matches!(
|
||||
relation.kind(),
|
||||
RelationKind::Interaction | RelationKind::BoundaryExchange
|
||||
RelationKind::Interaction
|
||||
| RelationKind::BoundaryExchange
|
||||
| RelationKind::CommunicationMean
|
||||
),
|
||||
ScenarioViewpoint::BoundaryInterface => matches!(
|
||||
relation.kind(),
|
||||
@@ -1628,6 +1829,34 @@ fn validate_message(p: &Project, e: &Element, body: &OccurrenceBody, out: &mut V
|
||||
}
|
||||
}
|
||||
let relation = exchange.and_then(|id| p.relations.get(&id));
|
||||
let operation = crate::operations::resolve(p, e.id);
|
||||
let signature = match &operation {
|
||||
Ok(crate::operations::OperationResolution::Selected(selected)) => {
|
||||
if selected.transmission == TransmissionProtocol::Unspecified {
|
||||
warning(
|
||||
out,
|
||||
e.id,
|
||||
"operation_transmission_unspecified",
|
||||
"operation",
|
||||
"The selected operation transmission protocol is unspecified.",
|
||||
);
|
||||
}
|
||||
Some(crate::operations::signature_relation(selected))
|
||||
}
|
||||
Ok(crate::operations::OperationResolution::Unresolved { reason, .. }) => {
|
||||
warning(out, e.id, "operation_unresolved", "operation", reason);
|
||||
None
|
||||
}
|
||||
Err(problem) => {
|
||||
error(out, e.id, problem.code, "operation", &problem.message);
|
||||
None
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let value_relation = match &operation {
|
||||
Ok(crate::operations::OperationResolution::Ordinary) => relation,
|
||||
_ => signature.as_ref(),
|
||||
};
|
||||
if message_kind == &MessageKind::Call
|
||||
&& relation.is_some_and(|r| {
|
||||
!r.body
|
||||
@@ -1673,10 +1902,48 @@ fn validate_message(p: &Project, e: &Element, body: &OccurrenceBody, out: &mut V
|
||||
"The value path exceeds the bounded authoring depth.",
|
||||
);
|
||||
}
|
||||
if let Some(r) = relation {
|
||||
if matches!(
|
||||
operation,
|
||||
Ok(crate::operations::OperationResolution::Unresolved { .. })
|
||||
) && let Some(owner) = value
|
||||
.path
|
||||
.first()
|
||||
.or(Some(&value.field))
|
||||
.and_then(|id| p.elements.get(id))
|
||||
.and_then(|field| field.parent)
|
||||
{
|
||||
let path_context = Relation {
|
||||
id: e.id,
|
||||
alias: String::new(),
|
||||
name: String::new(),
|
||||
description: String::new(),
|
||||
body: RelationBody::Interaction {
|
||||
source: e.id,
|
||||
target: e.id,
|
||||
items: vec![owner],
|
||||
},
|
||||
};
|
||||
if crate::data::carried_value_path(p, &path_context, &value.path, value.field).is_none()
|
||||
{
|
||||
error(
|
||||
out,
|
||||
e.id,
|
||||
"message_value_path",
|
||||
"values.path",
|
||||
"Even while the operation is unresolved, a value path must follow existing Value record fields in order.",
|
||||
);
|
||||
}
|
||||
}
|
||||
if let Some(r) = value_relation {
|
||||
if let Some(path) = crate::data::carried_value_path(p, r, &value.path, value.field) {
|
||||
if crate::data::item_mechanism(p, path.item) == Some(ExchangeMechanism::Operation) {
|
||||
let permitted = match message_kind {
|
||||
MessageKind::Asynchronous if signature.is_some() => matches!(
|
||||
path.direction,
|
||||
ParameterDirection::Unspecified
|
||||
| ParameterDirection::In
|
||||
| ParameterDirection::InOut
|
||||
),
|
||||
MessageKind::Call => matches!(
|
||||
path.direction,
|
||||
ParameterDirection::Unspecified
|
||||
@@ -1920,6 +2187,7 @@ fn validate_occurrence(p: &Project, e: &Element, body: &OccurrenceBody, out: &mu
|
||||
RelationKind::Interaction
|
||||
| RelationKind::FunctionalExchange
|
||||
| RelationKind::BoundaryExchange
|
||||
| RelationKind::CommunicationMean
|
||||
) => {}
|
||||
_ => error(
|
||||
out,
|
||||
@@ -2115,7 +2383,24 @@ fn relation_path_exists(p: &Project, kind: RelationKind, from: Id, to: Id) -> bo
|
||||
false
|
||||
}
|
||||
|
||||
fn validate_relation(p: &Project, r: &Relation, out: &mut Vec<Issue>) {
|
||||
fn validate_relation(
|
||||
p: &Project,
|
||||
r: &Relation,
|
||||
topology: &crate::topology_context::TopologyIndex<'_>,
|
||||
out: &mut Vec<Issue>,
|
||||
) {
|
||||
if let RelationBody::ContextualLink {
|
||||
owner,
|
||||
source,
|
||||
target,
|
||||
purpose,
|
||||
} = &r.body
|
||||
{
|
||||
if let Err(problem) = topology.validate_link(*owner, source, target, purpose) {
|
||||
error(out, r.id, problem.code, "body", problem.message);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let (source, target) = r.body.endpoints();
|
||||
for id in crate::references::relation_references(r) {
|
||||
// Most relation references name elements. Avoid constructing all model
|
||||
@@ -2134,18 +2419,20 @@ fn validate_relation(p: &Project, r: &Relation, out: &mut Vec<Issue>) {
|
||||
if let RelationBody::ExchangeAllocation { .. } = r.body {
|
||||
match (p.relations.get(&source), p.relations.get(&target)) {
|
||||
(Some(a), Some(b))
|
||||
if a.kind() == RelationKind::BoundaryExchange
|
||||
if ((a.kind() == RelationKind::BoundaryExchange
|
||||
&& matches!(
|
||||
b.kind(),
|
||||
RelationKind::FunctionalExchange | RelationKind::Interaction
|
||||
)
|
||||
))
|
||||
|| (a.kind() == RelationKind::CommunicationMean
|
||||
&& b.kind() == RelationKind::Interaction))
|
||||
&& p.layer_of(source) == p.layer_of(target) => {}
|
||||
_ => error(
|
||||
out,
|
||||
r.id,
|
||||
"exchange_allocation",
|
||||
"endpoints",
|
||||
"Exchange allocation relates a boundary exchange to a work exchange in the same layer.",
|
||||
"Exchange allocation relates communication means to an OA interaction, or a boundary exchange to a work exchange, in the same layer.",
|
||||
),
|
||||
};
|
||||
validate_exchange_allocation(p, r, out);
|
||||
@@ -2396,7 +2683,7 @@ pub fn trace_permits(p: &Project, t: &Trace) -> bool {
|
||||
_ => false,
|
||||
},
|
||||
_ => {
|
||||
matches!((p.relations.get(&t.oa),p.relations.get(&t.sa)),(Some(a),Some(b))if t.kind==TraceKind::Exchange&&a.kind()==RelationKind::Interaction&&b.kind()==RelationKind::FunctionalExchange)
|
||||
matches!((p.relations.get(&t.oa),p.relations.get(&t.sa)),(Some(a),Some(b))if t.kind==TraceKind::Exchange&&crate::transitions::exchange_counterparts(a.kind(), b.kind()))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2491,7 +2778,12 @@ pub fn can_connect(p: &Project, kind: RelationKind, source: Id, target: Id) -> b
|
||||
*transition_kind = TransitionKind::Local;
|
||||
}
|
||||
let mut issues = vec![];
|
||||
validate_relation(p, &candidate, &mut issues);
|
||||
validate_relation(
|
||||
p,
|
||||
&candidate,
|
||||
&crate::topology_context::TopologyIndex::new(p),
|
||||
&mut issues,
|
||||
);
|
||||
validate_relation_context(p, &candidate, &mut issues);
|
||||
if kind == RelationKind::StateTransition {
|
||||
for endpoint in [source, target] {
|
||||
@@ -2521,7 +2813,12 @@ pub fn can_connect_boundary(p: &Project, source: Id, target: Id, oriented: bool)
|
||||
},
|
||||
};
|
||||
let mut issues = vec![];
|
||||
validate_relation(p, &candidate, &mut issues);
|
||||
validate_relation(
|
||||
p,
|
||||
&candidate,
|
||||
&crate::topology_context::TopologyIndex::new(p),
|
||||
&mut issues,
|
||||
);
|
||||
validate_relation_context(p, &candidate, &mut issues);
|
||||
!issues.iter().any(|i| i.severity == Severity::Error)
|
||||
}
|
||||
@@ -2737,6 +3034,23 @@ fn validate_occurrence_context(
|
||||
OccurrenceBody::Exchange {
|
||||
sender, receiver, ..
|
||||
} => {
|
||||
match resolve_message_endpoints(p, body) {
|
||||
MessageEndpointResolution::Conflict => error(
|
||||
out,
|
||||
e.id,
|
||||
"scenario_endpoint",
|
||||
"sender",
|
||||
"Sender and receiver must agree with one permitted communication direction and the correlated Call.",
|
||||
),
|
||||
MessageEndpointResolution::Ambiguous => warning(
|
||||
out,
|
||||
e.id,
|
||||
"ambiguous_message_direction",
|
||||
"sender",
|
||||
"Bind explicit participant lifelines to resolve the communication direction; shared roles allow both directions.",
|
||||
),
|
||||
_ => {}
|
||||
}
|
||||
if let Some((source, target)) = message_endpoints(p, body) {
|
||||
check(out, *sender, Some(source), "sender");
|
||||
check(out, *receiver, Some(target), "receiver");
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
//! Advisory presence of explicit behavior evidence, never inferred execution.
|
||||
use super::*;
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct Index {
|
||||
pub grouping: BTreeSet<Id>,
|
||||
pub supported_capabilities: BTreeSet<Id>,
|
||||
contextual_capabilities: BTreeSet<Id>,
|
||||
supported_missions: BTreeSet<Id>,
|
||||
}
|
||||
|
||||
impl Index {
|
||||
pub fn new(p: &Project) -> Self {
|
||||
let mut index = Self::default();
|
||||
for element in p.elements.values() {
|
||||
if let Some(parent) = element.parent.and_then(|id| p.elements.get(&id))
|
||||
&& parent.kind() == element.kind()
|
||||
&& parent.layer() == element.layer()
|
||||
{
|
||||
index.grouping.insert(parent.id);
|
||||
}
|
||||
if let Some(SharedBody::Scenario {
|
||||
context: Some(id), ..
|
||||
}) = element.shared()
|
||||
&& p.elements.get(id).is_some_and(|context| {
|
||||
matches!(context.kind(), Kind::Capability | Kind::SystemCapability)
|
||||
&& context.layer() == element.layer()
|
||||
})
|
||||
{
|
||||
index.contextual_capabilities.insert(*id);
|
||||
index.supported_capabilities.insert(*id);
|
||||
}
|
||||
}
|
||||
for relation in p.relations.values() {
|
||||
let (Some(source), Some(target)) = (
|
||||
p.elements.get(&relation.source()),
|
||||
p.elements.get(&relation.target()),
|
||||
) else {
|
||||
continue;
|
||||
};
|
||||
if source.layer() != target.layer()
|
||||
|| !relation.kind().permits(source.kind(), target.kind())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
match relation.kind() {
|
||||
RelationKind::CapabilityInvolvement => {
|
||||
index.supported_capabilities.insert(target.id);
|
||||
}
|
||||
RelationKind::ChainInvolvement => {
|
||||
index.supported_capabilities.insert(target.id);
|
||||
index.contextual_capabilities.insert(target.id);
|
||||
}
|
||||
RelationKind::MissionCapability => {
|
||||
index.supported_missions.insert(source.id);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
index
|
||||
}
|
||||
|
||||
pub fn validate(&self, p: &Project, out: &mut Vec<Issue>) {
|
||||
let (work, exchanges) = included_evidence(p);
|
||||
for element in p.elements.values() {
|
||||
if element.kind().is_work()
|
||||
&& !self.grouping.contains(&element.id)
|
||||
&& !work.contains(&element.id)
|
||||
{
|
||||
warning(
|
||||
out,
|
||||
element.id,
|
||||
"work_without_behavior_context",
|
||||
"",
|
||||
"Include this leaf activity/function in a process/chain or scenario through a work action or exchange occurrence.",
|
||||
);
|
||||
}
|
||||
if matches!(element.kind(), Kind::OperationalMission | Kind::Mission)
|
||||
&& !self.supported_missions.contains(&element.id)
|
||||
{
|
||||
warning(
|
||||
out,
|
||||
element.id,
|
||||
"mission_without_capability",
|
||||
"",
|
||||
"Connect this mission to a capability that delivers it.",
|
||||
);
|
||||
}
|
||||
if matches!(element.kind(), Kind::Capability | Kind::SystemCapability)
|
||||
&& !self.grouping.contains(&element.id)
|
||||
&& self.supported_capabilities.contains(&element.id)
|
||||
&& !self.contextual_capabilities.contains(&element.id)
|
||||
{
|
||||
warning(
|
||||
out,
|
||||
element.id,
|
||||
"capability_without_behavior_context",
|
||||
"",
|
||||
"Add an explicit behavior context: link a process/chain with Chain involvement, or set a scenario's Context to this capability.",
|
||||
);
|
||||
}
|
||||
}
|
||||
for relation in p.relations.values().filter(|r| work_exchange(r.kind())) {
|
||||
let mut non_leaf = false;
|
||||
for (field, endpoint) in [("source", relation.source()), ("target", relation.target())]
|
||||
{
|
||||
if self.grouping.contains(&endpoint) {
|
||||
non_leaf = true;
|
||||
warning(
|
||||
out,
|
||||
relation.id,
|
||||
"non_leaf_work_exchange",
|
||||
field,
|
||||
"Choose the leaf activity/function responsible for this exchange; keep any typed port owned by that function.",
|
||||
);
|
||||
}
|
||||
}
|
||||
if !non_leaf && !exchanges.contains(&relation.id) {
|
||||
warning(
|
||||
out,
|
||||
relation.id,
|
||||
"exchange_without_behavior_context",
|
||||
"",
|
||||
"Use this exchange in a process/chain or scenario.",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn work_exchange(kind: RelationKind) -> bool {
|
||||
matches!(
|
||||
kind,
|
||||
RelationKind::Interaction | RelationKind::FunctionalExchange
|
||||
)
|
||||
}
|
||||
|
||||
/// Only ordered, owned occurrences contribute. Each identity is visited once,
|
||||
/// including reused containers; this remains bounded even on malformed drafts.
|
||||
fn included_evidence(p: &Project) -> (BTreeSet<Id>, BTreeSet<Id>) {
|
||||
let crate::view_options::BehaviorEvidence { work, exchanges } =
|
||||
crate::view_options::all_behavior_evidence(p);
|
||||
(work, exchanges)
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn malformed_self_reuse_is_bounded_even_without_structural_gate() {
|
||||
let mut p = Project::empty();
|
||||
let scenario = p
|
||||
.add_element(Kind::Scenario, Layer::Oa, "Self reuse", None)
|
||||
.unwrap();
|
||||
let occurrence = p
|
||||
.add_element(Kind::Occurrence, Layer::Oa, "Cycle", Some(scenario))
|
||||
.unwrap();
|
||||
if let Some(SharedBody::Occurrence { body, .. }) =
|
||||
p.elements.get_mut(&occurrence).unwrap().shared_mut()
|
||||
{
|
||||
*body = OccurrenceBody::ScenarioUse {
|
||||
scenario: Some(scenario),
|
||||
bindings: vec![],
|
||||
};
|
||||
}
|
||||
p.elements
|
||||
.get_mut(&scenario)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap()
|
||||
.push(occurrence);
|
||||
assert_eq!(included_evidence(&p), (BTreeSet::new(), BTreeSet::new()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,733 @@
|
||||
//! Persisted view intent and pure visibility decisions over actual model identities.
|
||||
//! Build `ViewIndex` once per accepted model revision. Preparing a view performs
|
||||
//! set unions/reachability once; testing an edge never walks the model.
|
||||
use crate::{
|
||||
ContextualLinkPurpose, Id, Kind, Layer, OccurrenceBody, Project, RelationBody, RelationKind,
|
||||
SharedBody,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ExchangeFamily {
|
||||
Work,
|
||||
Participant,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub enum EdgeKind {
|
||||
Relation(RelationKind),
|
||||
Trace,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub enum RelationKinds {
|
||||
#[default]
|
||||
All,
|
||||
Only(Vec<EdgeKind>),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub enum CategorySelection {
|
||||
#[default]
|
||||
All,
|
||||
Any(Vec<Id>),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub enum ContextSelection {
|
||||
#[default]
|
||||
All,
|
||||
Any(Vec<Id>),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum Coverage {
|
||||
#[default]
|
||||
All,
|
||||
InBehavior,
|
||||
OutsideBehavior,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ExchangeLabels {
|
||||
#[default]
|
||||
Name,
|
||||
Items,
|
||||
NameAndItems,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct ViewOptions {
|
||||
pub hidden_edges: Vec<Id>,
|
||||
pub relation_kinds: RelationKinds,
|
||||
pub work_categories: CategorySelection,
|
||||
pub participant_categories: CategorySelection,
|
||||
pub coverage: Coverage,
|
||||
pub work_contexts: ContextSelection,
|
||||
pub exchange_labels: ExchangeLabels,
|
||||
}
|
||||
|
||||
impl ViewOptions {
|
||||
pub fn visit_references(&mut self, mut visit: impl FnMut(&mut Id)) {
|
||||
for id in &mut self.hidden_edges {
|
||||
visit(id);
|
||||
}
|
||||
for selection in [&mut self.work_categories, &mut self.participant_categories] {
|
||||
if let CategorySelection::Any(ids) = selection {
|
||||
for id in ids {
|
||||
visit(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let ContextSelection::Any(ids) = &mut self.work_contexts {
|
||||
for id in ids {
|
||||
visit(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Removing the last selected identity retains the explicit empty selector.
|
||||
pub fn retain_references(&mut self, mut keep: impl FnMut(&Id) -> bool) {
|
||||
self.hidden_edges.retain(&mut keep);
|
||||
for selection in [&mut self.work_categories, &mut self.participant_categories] {
|
||||
if let CategorySelection::Any(ids) = selection {
|
||||
ids.retain(&mut keep);
|
||||
}
|
||||
}
|
||||
if let ContextSelection::Any(ids) = &mut self.work_contexts {
|
||||
ids.retain(keep);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Stable field names allow core/source adapters to locate the offending option.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct OptionError {
|
||||
pub field: &'static str,
|
||||
pub reference: Option<Id>,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
pub fn exchange_family(kind: RelationKind) -> Option<ExchangeFamily> {
|
||||
match kind {
|
||||
RelationKind::Interaction | RelationKind::FunctionalExchange => Some(ExchangeFamily::Work),
|
||||
RelationKind::BoundaryExchange | RelationKind::CommunicationMean => {
|
||||
Some(ExchangeFamily::Participant)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn category_member(p: &Project, layer: Layer, family: ExchangeFamily, id: Id) -> bool {
|
||||
p.relations.get(&id).is_some_and(|r| {
|
||||
p.layer_of(id) == Some(layer)
|
||||
&& matches!(
|
||||
(family, layer, r.kind()),
|
||||
(ExchangeFamily::Work, Layer::Oa, RelationKind::Interaction)
|
||||
| (
|
||||
ExchangeFamily::Work,
|
||||
Layer::Sa,
|
||||
RelationKind::FunctionalExchange
|
||||
)
|
||||
| (
|
||||
ExchangeFamily::Participant,
|
||||
Layer::Oa,
|
||||
RelationKind::CommunicationMean
|
||||
)
|
||||
| (
|
||||
ExchangeFamily::Participant,
|
||||
_,
|
||||
RelationKind::BoundaryExchange
|
||||
)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn context_kind(kind: Kind) -> bool {
|
||||
matches!(kind, Kind::Process | Kind::FunctionalChain | Kind::Scenario)
|
||||
}
|
||||
|
||||
fn edge_layers(p: &Project, id: Id) -> Vec<Layer> {
|
||||
if p.relations.contains_key(&id) {
|
||||
p.layer_of(id).into_iter().collect()
|
||||
} else if p.traces.get(&id).is_some_and(|trace| {
|
||||
p.layer_of(trace.oa) == Some(Layer::Oa) && p.layer_of(trace.sa) == Some(Layer::Sa)
|
||||
}) {
|
||||
vec![Layer::Oa, Layer::Sa]
|
||||
} else {
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
|
||||
fn error(field: &'static str, reference: Option<Id>, message: &str) -> OptionError {
|
||||
OptionError {
|
||||
field,
|
||||
reference,
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn distinct_ids(field: &'static str, ids: &[Id], out: &mut Vec<OptionError>) {
|
||||
let mut seen = BTreeSet::new();
|
||||
for id in ids {
|
||||
if !seen.insert(*id) {
|
||||
out.push(error(
|
||||
field,
|
||||
Some(*id),
|
||||
"Each identity must occur only once.",
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn validate_category(
|
||||
p: &Project,
|
||||
layer: Layer,
|
||||
parent: Option<Id>,
|
||||
family: ExchangeFamily,
|
||||
exchanges: &[Id],
|
||||
) -> Vec<OptionError> {
|
||||
let mut out = vec![];
|
||||
if parent.is_some() {
|
||||
out.push(error(
|
||||
"parent",
|
||||
parent,
|
||||
"Exchange categories belong at the layer root.",
|
||||
));
|
||||
}
|
||||
distinct_ids("exchanges", exchanges, &mut out);
|
||||
for id in exchanges {
|
||||
if !category_member(p, layer, family, *id) {
|
||||
out.push(error(
|
||||
"exchanges",
|
||||
Some(*id),
|
||||
"Choose an existing exchange of this category's family and layer.",
|
||||
));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub fn validate_options(p: &Project, layer: Layer, options: &ViewOptions) -> Vec<OptionError> {
|
||||
validate_options_in(p, Some(layer), options)
|
||||
}
|
||||
|
||||
/// Traceability views admit references from either architecture layer.
|
||||
pub fn validate_options_cross_layer(p: &Project, options: &ViewOptions) -> Vec<OptionError> {
|
||||
validate_options_in(p, None, options)
|
||||
}
|
||||
|
||||
fn validate_options_in(
|
||||
p: &Project,
|
||||
layer: Option<Layer>,
|
||||
options: &ViewOptions,
|
||||
) -> Vec<OptionError> {
|
||||
let mut out = vec![];
|
||||
distinct_ids("hidden_edges", &options.hidden_edges, &mut out);
|
||||
for id in &options.hidden_edges {
|
||||
let layers = edge_layers(p, *id);
|
||||
if layers.is_empty() || layer.is_some_and(|layer| !layers.contains(&layer)) {
|
||||
out.push(error(
|
||||
"hidden_edges",
|
||||
Some(*id),
|
||||
"Choose an existing relationship or applicable trace in this view's layer.",
|
||||
));
|
||||
}
|
||||
}
|
||||
if let RelationKinds::Only(kinds) = &options.relation_kinds {
|
||||
for (index, kind) in kinds.iter().enumerate() {
|
||||
if kinds[..index].contains(kind) {
|
||||
out.push(error(
|
||||
"relation_kinds",
|
||||
None,
|
||||
"Each relationship-line kind must occur only once.",
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
for (field, selection, family) in [
|
||||
(
|
||||
"work_categories",
|
||||
&options.work_categories,
|
||||
ExchangeFamily::Work,
|
||||
),
|
||||
(
|
||||
"participant_categories",
|
||||
&options.participant_categories,
|
||||
ExchangeFamily::Participant,
|
||||
),
|
||||
] {
|
||||
if let CategorySelection::Any(ids) = selection {
|
||||
distinct_ids(field, ids, &mut out);
|
||||
for id in ids {
|
||||
if !p.elements.get(id).is_some_and(|e| {
|
||||
layer.is_none_or(|layer| e.layer() == layer)
|
||||
&& matches!(e.shared(), Some(SharedBody::ExchangeCategory { family: f, .. }) if *f == family)
|
||||
}) {
|
||||
out.push(error(field, Some(*id), "Choose a category of this selector's family and layer."));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let ContextSelection::Any(ids) = &options.work_contexts {
|
||||
distinct_ids("work_contexts", ids, &mut out);
|
||||
for id in ids {
|
||||
if !p.elements.get(id).is_some_and(|e| {
|
||||
layer.is_none_or(|layer| e.layer() == layer) && context_kind(e.kind())
|
||||
}) {
|
||||
out.push(error(
|
||||
"work_contexts",
|
||||
Some(*id),
|
||||
"Choose a Process, Functional chain or Scenario in this view's layer.",
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct BehaviorEvidence {
|
||||
pub work: BTreeSet<Id>,
|
||||
pub exchanges: BTreeSet<Id>,
|
||||
}
|
||||
|
||||
/// Explicit inclusion/reuse graph, independent of semantic-parent descendants.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
struct BehaviorIndex {
|
||||
roots: BTreeMap<Id, Layer>,
|
||||
next: BTreeMap<Id, Vec<Id>>,
|
||||
facts: BTreeMap<Id, BehaviorEvidence>,
|
||||
}
|
||||
|
||||
impl BehaviorIndex {
|
||||
fn new(p: &Project) -> Self {
|
||||
let mut index = Self::default();
|
||||
for element in p.elements.values() {
|
||||
let id = element.id;
|
||||
if context_kind(element.kind()) {
|
||||
index.roots.insert(id, element.layer());
|
||||
}
|
||||
let next = index.next.entry(id).or_default();
|
||||
if let Some(steps) = element.steps() {
|
||||
next.extend(
|
||||
steps
|
||||
.iter()
|
||||
.filter(|step| {
|
||||
p.elements.get(step).is_some_and(|child| {
|
||||
child.kind() == Kind::Occurrence
|
||||
&& child.parent == Some(id)
|
||||
&& child.layer() == element.layer()
|
||||
})
|
||||
})
|
||||
.copied(),
|
||||
);
|
||||
}
|
||||
let Some(SharedBody::Occurrence { body, .. }) = element.shared() else {
|
||||
continue;
|
||||
};
|
||||
match body {
|
||||
OccurrenceBody::Action {
|
||||
action: Some(work), ..
|
||||
} => {
|
||||
if p.elements
|
||||
.get(work)
|
||||
.is_some_and(|e| e.kind().is_work() && e.layer() == element.layer())
|
||||
{
|
||||
index.facts.entry(id).or_default().work.insert(*work);
|
||||
}
|
||||
}
|
||||
OccurrenceBody::Exchange {
|
||||
exchange: Some(exchange),
|
||||
..
|
||||
} => {
|
||||
if let Some(r) = p.relations.get(exchange)
|
||||
&& exchange_family(r.kind()) == Some(ExchangeFamily::Work)
|
||||
&& [r.source(), r.target()].iter().all(|id| {
|
||||
p.elements
|
||||
.get(id)
|
||||
.is_some_and(|e| e.kind().is_work() && e.layer() == element.layer())
|
||||
})
|
||||
{
|
||||
let facts = index.facts.entry(id).or_default();
|
||||
facts.exchanges.insert(*exchange);
|
||||
facts.work.extend([r.source(), r.target()]);
|
||||
}
|
||||
}
|
||||
OccurrenceBody::Fragment { branches, .. } => {
|
||||
next.extend(
|
||||
branches
|
||||
.iter()
|
||||
.filter(|branch| {
|
||||
p.elements.get(branch).is_some_and(|child| {
|
||||
child.parent == Some(id)
|
||||
&& child.layer() == element.layer()
|
||||
&& matches!(
|
||||
child.shared(),
|
||||
Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Branch { .. },
|
||||
..
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
.copied(),
|
||||
);
|
||||
}
|
||||
OccurrenceBody::SequenceReference {
|
||||
sequence: Some(sequence),
|
||||
} => {
|
||||
if let Some(owner) = element.parent.and_then(|id| p.elements.get(&id))
|
||||
&& matches!(owner.kind(), Kind::Process | Kind::FunctionalChain)
|
||||
&& p.elements.get(sequence).is_some_and(|target| {
|
||||
target.kind() == owner.kind() && target.layer() == element.layer()
|
||||
})
|
||||
{
|
||||
next.push(*sequence);
|
||||
}
|
||||
}
|
||||
OccurrenceBody::ScenarioUse {
|
||||
scenario: Some(scenario),
|
||||
..
|
||||
} if p.elements.get(scenario).is_some_and(|target| {
|
||||
target.kind() == Kind::Scenario && target.layer() == element.layer()
|
||||
}) =>
|
||||
{
|
||||
next.push(*scenario);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let topology = crate::topology_context::TopologyIndex::new(p);
|
||||
for relation in p.relations.values() {
|
||||
if let RelationBody::ContextualLink {
|
||||
owner,
|
||||
source,
|
||||
target,
|
||||
purpose: purpose @ ContextualLinkPurpose::Exchange { exchange },
|
||||
} = &relation.body
|
||||
&& topology
|
||||
.validate_link(*owner, source, target, purpose)
|
||||
.is_ok()
|
||||
{
|
||||
let facts = index.facts.entry(*owner).or_default();
|
||||
facts.exchanges.insert(*exchange);
|
||||
if let Some(exchange) = p.relations.get(exchange) {
|
||||
facts.work.extend([exchange.source(), exchange.target()]);
|
||||
}
|
||||
}
|
||||
}
|
||||
index
|
||||
}
|
||||
|
||||
fn evidence(&self, roots: impl IntoIterator<Item = Id>) -> BehaviorEvidence {
|
||||
let mut pending: Vec<_> = roots
|
||||
.into_iter()
|
||||
.filter(|id| self.roots.contains_key(id))
|
||||
.collect();
|
||||
let mut seen = BTreeSet::new();
|
||||
let mut result = BehaviorEvidence::default();
|
||||
while let Some(id) = pending.pop() {
|
||||
if !seen.insert(id) {
|
||||
continue;
|
||||
}
|
||||
if let Some(next) = self.next.get(&id) {
|
||||
pending.extend(next);
|
||||
}
|
||||
if let Some(facts) = self.facts.get(&id) {
|
||||
result.work.extend(&facts.work);
|
||||
result.exchanges.extend(&facts.exchanges);
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
pub fn behavior_evidence(p: &Project, roots: impl IntoIterator<Item = Id>) -> BehaviorEvidence {
|
||||
BehaviorIndex::new(p).evidence(roots)
|
||||
}
|
||||
|
||||
pub fn all_behavior_evidence(p: &Project) -> BehaviorEvidence {
|
||||
let index = BehaviorIndex::new(p);
|
||||
index.evidence(index.roots.keys().copied())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct EdgeFact {
|
||||
kind: EdgeKind,
|
||||
family: Option<ExchangeFamily>,
|
||||
layers: Vec<Layer>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct CategoryFact {
|
||||
layer: Layer,
|
||||
family: ExchangeFamily,
|
||||
exchanges: BTreeSet<Id>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ViewIndex {
|
||||
edges: BTreeMap<Id, EdgeFact>,
|
||||
categories: BTreeMap<Id, CategoryFact>,
|
||||
behavior: BehaviorIndex,
|
||||
global: BehaviorEvidence,
|
||||
}
|
||||
|
||||
impl ViewIndex {
|
||||
pub fn new(p: &Project) -> Self {
|
||||
let behavior = BehaviorIndex::new(p);
|
||||
let global = behavior.evidence(behavior.roots.keys().copied());
|
||||
let mut edges: BTreeMap<_, _> = p
|
||||
.relations
|
||||
.values()
|
||||
.map(|r| {
|
||||
(
|
||||
r.id,
|
||||
EdgeFact {
|
||||
kind: EdgeKind::Relation(r.kind()),
|
||||
family: exchange_family(r.kind()),
|
||||
layers: edge_layers(p, r.id),
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
edges.extend(p.traces.keys().map(|id| {
|
||||
(
|
||||
*id,
|
||||
EdgeFact {
|
||||
kind: EdgeKind::Trace,
|
||||
family: None,
|
||||
layers: edge_layers(p, *id),
|
||||
},
|
||||
)
|
||||
}));
|
||||
let categories = p
|
||||
.elements
|
||||
.values()
|
||||
.filter_map(|e| {
|
||||
let SharedBody::ExchangeCategory { family, exchanges } = e.shared()? else {
|
||||
return None;
|
||||
};
|
||||
Some((
|
||||
e.id,
|
||||
CategoryFact {
|
||||
layer: e.layer(),
|
||||
family: *family,
|
||||
exchanges: exchanges
|
||||
.iter()
|
||||
.filter(|id| category_member(p, e.layer(), *family, **id))
|
||||
.copied()
|
||||
.collect(),
|
||||
},
|
||||
))
|
||||
})
|
||||
.collect();
|
||||
Self {
|
||||
edges,
|
||||
categories,
|
||||
behavior,
|
||||
global,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn all_evidence(&self) -> &BehaviorEvidence {
|
||||
&self.global
|
||||
}
|
||||
|
||||
/// Options must pass `validate_options` before model admission. Malformed
|
||||
/// selectors are nevertheless fail-closed here, useful for draft previews.
|
||||
pub fn prepare(&self, layer: Layer, options: &ViewOptions) -> PreparedFilter<'_> {
|
||||
self.prepare_in(layer, options, false)
|
||||
}
|
||||
|
||||
fn prepare_in(
|
||||
&self,
|
||||
layer: Layer,
|
||||
options: &ViewOptions,
|
||||
cross_layer: bool,
|
||||
) -> PreparedFilter<'_> {
|
||||
let category_union = |selection: &CategorySelection, family| match selection {
|
||||
CategorySelection::All => None,
|
||||
CategorySelection::Any(ids) => Some(
|
||||
ids.iter()
|
||||
.filter_map(|id| self.categories.get(id))
|
||||
.filter(|c| (cross_layer || c.layer == layer) && c.family == family)
|
||||
.flat_map(|c| c.exchanges.iter().copied())
|
||||
.collect(),
|
||||
),
|
||||
};
|
||||
let contexts = match &options.work_contexts {
|
||||
ContextSelection::All => None,
|
||||
ContextSelection::Any(ids) => Some(
|
||||
self.behavior
|
||||
.evidence(
|
||||
ids.iter()
|
||||
.filter(|id| {
|
||||
self.behavior
|
||||
.roots
|
||||
.get(id)
|
||||
.is_some_and(|candidate| cross_layer || *candidate == layer)
|
||||
})
|
||||
.copied(),
|
||||
)
|
||||
.exchanges,
|
||||
),
|
||||
};
|
||||
PreparedFilter {
|
||||
index: self,
|
||||
layer,
|
||||
cross_layer,
|
||||
hidden: options.hidden_edges.iter().copied().collect(),
|
||||
kinds: options.relation_kinds.clone(),
|
||||
coverage: options.coverage,
|
||||
work_categories: category_union(&options.work_categories, ExchangeFamily::Work),
|
||||
participant_categories: category_union(
|
||||
&options.participant_categories,
|
||||
ExchangeFamily::Participant,
|
||||
),
|
||||
contexts,
|
||||
}
|
||||
}
|
||||
|
||||
/// Traceability diagrams admit edges, categories and contexts in both layers.
|
||||
pub fn prepare_cross_layer(&self, layer: Layer, options: &ViewOptions) -> PreparedFilter<'_> {
|
||||
self.prepare_in(layer, options, true)
|
||||
}
|
||||
}
|
||||
|
||||
/// Projection reports why an actual edge cannot have a drawable endpoint pair.
|
||||
/// Filtering does not perform anchoring, folding, layout or placement insertion.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum EdgeAvailability {
|
||||
Visible,
|
||||
OutsideProjection,
|
||||
EndpointNotShown { source: bool, target: bool },
|
||||
FoldedInternal,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum OmissionReason {
|
||||
ExplicitHide,
|
||||
RelationKind,
|
||||
Category(ExchangeFamily),
|
||||
Coverage,
|
||||
WorkContext,
|
||||
OutsideProjection,
|
||||
EndpointNotShown { source: bool, target: bool },
|
||||
FoldedInternal,
|
||||
WrongLayer,
|
||||
UnknownEdge,
|
||||
}
|
||||
|
||||
pub struct PreparedFilter<'a> {
|
||||
index: &'a ViewIndex,
|
||||
layer: Layer,
|
||||
cross_layer: bool,
|
||||
hidden: BTreeSet<Id>,
|
||||
kinds: RelationKinds,
|
||||
coverage: Coverage,
|
||||
work_categories: Option<BTreeSet<Id>>,
|
||||
participant_categories: Option<BTreeSet<Id>>,
|
||||
contexts: Option<BTreeSet<Id>>,
|
||||
}
|
||||
|
||||
impl PreparedFilter<'_> {
|
||||
pub fn includes(&self, edge: Id) -> bool {
|
||||
self.reasons(edge, EdgeAvailability::Visible).is_empty()
|
||||
}
|
||||
|
||||
pub fn reasons(&self, edge: Id, availability: EdgeAvailability) -> Vec<OmissionReason> {
|
||||
let mut out = vec![];
|
||||
if self.hidden.contains(&edge) {
|
||||
out.push(OmissionReason::ExplicitHide);
|
||||
}
|
||||
match availability {
|
||||
EdgeAvailability::Visible => {}
|
||||
EdgeAvailability::OutsideProjection => out.push(OmissionReason::OutsideProjection),
|
||||
EdgeAvailability::EndpointNotShown { source, target } => {
|
||||
out.push(OmissionReason::EndpointNotShown { source, target })
|
||||
}
|
||||
EdgeAvailability::FoldedInternal => out.push(OmissionReason::FoldedInternal),
|
||||
}
|
||||
let Some(fact) = self.index.edges.get(&edge) else {
|
||||
out.push(OmissionReason::UnknownEdge);
|
||||
return out;
|
||||
};
|
||||
if !fact.layers.contains(&self.layer) && !(self.cross_layer && !fact.layers.is_empty()) {
|
||||
out.push(OmissionReason::WrongLayer);
|
||||
}
|
||||
if let RelationKinds::Only(kinds) = &self.kinds
|
||||
&& !kinds.contains(&fact.kind)
|
||||
{
|
||||
out.push(OmissionReason::RelationKind);
|
||||
}
|
||||
if let Some(family) = fact.family {
|
||||
let categories = match family {
|
||||
ExchangeFamily::Work => &self.work_categories,
|
||||
ExchangeFamily::Participant => &self.participant_categories,
|
||||
};
|
||||
if categories.as_ref().is_some_and(|ids| !ids.contains(&edge)) {
|
||||
out.push(OmissionReason::Category(family));
|
||||
}
|
||||
if family == ExchangeFamily::Work {
|
||||
let covered = self.index.global.exchanges.contains(&edge);
|
||||
if matches!(self.coverage, Coverage::InBehavior) && !covered
|
||||
|| matches!(self.coverage, Coverage::OutsideBehavior) && covered
|
||||
{
|
||||
out.push(OmissionReason::Coverage);
|
||||
}
|
||||
if self
|
||||
.contexts
|
||||
.as_ref()
|
||||
.is_some_and(|ids| !ids.contains(&edge))
|
||||
{
|
||||
out.push(OmissionReason::WorkContext);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
impl ExchangeLabels {
|
||||
pub fn label(self, p: &Project, edge: Id) -> String {
|
||||
let name = p.named(edge);
|
||||
let Some(relation) = p
|
||||
.relations
|
||||
.get(&edge)
|
||||
.filter(|r| exchange_family(r.kind()).is_some())
|
||||
else {
|
||||
return name.into();
|
||||
};
|
||||
if self == Self::Name {
|
||||
return name.into();
|
||||
}
|
||||
let items = if relation.body.items().is_empty() {
|
||||
"No items".into()
|
||||
} else {
|
||||
relation
|
||||
.body
|
||||
.items()
|
||||
.iter()
|
||||
.map(|id| p.named(*id))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
};
|
||||
if self == Self::Items {
|
||||
items
|
||||
} else {
|
||||
format!("{name} · {items}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "view_options_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,621 @@
|
||||
use super::*;
|
||||
use crate::{FragmentOperator, MessageKind, RelationBody, Trace, TraceKind};
|
||||
|
||||
fn add(p: &mut Project, kind: Kind, layer: Layer, name: &str) -> Id {
|
||||
p.add_element(kind, layer, name, None).unwrap()
|
||||
}
|
||||
|
||||
fn occurrence(p: &mut Project, owner: Id, body: OccurrenceBody) -> Id {
|
||||
let id = p
|
||||
.add_element(
|
||||
Kind::Occurrence,
|
||||
p.elements[&owner].layer(),
|
||||
"Contribution",
|
||||
Some(owner),
|
||||
)
|
||||
.unwrap();
|
||||
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::Occurrence {
|
||||
body,
|
||||
precondition: String::new(),
|
||||
postcondition: String::new(),
|
||||
};
|
||||
if let Some(steps) = p.elements.get_mut(&owner).unwrap().steps_mut() {
|
||||
steps.push(id);
|
||||
}
|
||||
id
|
||||
}
|
||||
|
||||
fn exchange(id: Id) -> OccurrenceBody {
|
||||
OccurrenceBody::Exchange {
|
||||
operation: None,
|
||||
exchange: Some(id),
|
||||
sender: None,
|
||||
receiver: None,
|
||||
message_kind: MessageKind::Asynchronous,
|
||||
reply_to: None,
|
||||
values: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
fn category(p: &mut Project, layer: Layer, family: ExchangeFamily, exchanges: Vec<Id>) -> Id {
|
||||
let id = add(p, Kind::ExchangeCategory, layer, "Concern");
|
||||
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() =
|
||||
SharedBody::ExchangeCategory { family, exchanges };
|
||||
id
|
||||
}
|
||||
|
||||
struct Model {
|
||||
p: Project,
|
||||
work: [Id; 4],
|
||||
means: [Id; 2],
|
||||
categories: [Id; 3],
|
||||
contexts: [Id; 2],
|
||||
allocation: Id,
|
||||
trace: Id,
|
||||
}
|
||||
|
||||
fn model() -> Model {
|
||||
let mut p = Project::empty();
|
||||
let a = add(&mut p, Kind::Activity, Layer::Oa, "Request");
|
||||
let b = add(&mut p, Kind::Activity, Layer::Oa, "Provide");
|
||||
let c = add(&mut p, Kind::Activity, Layer::Oa, "Monitor");
|
||||
let x = add(&mut p, Kind::Entity, Layer::Oa, "Requester");
|
||||
let y = add(&mut p, Kind::Entity, Layer::Oa, "Provider");
|
||||
let z = add(&mut p, Kind::Entity, Layer::Oa, "Observer");
|
||||
let allocation = p.relate(RelationKind::Allocation, "Responsible", x, a);
|
||||
p.relate(RelationKind::Allocation, "Responsible", y, b);
|
||||
p.relate(RelationKind::Allocation, "Responsible", z, c);
|
||||
let work = [
|
||||
p.relate(RelationKind::Interaction, "Request data", a, b),
|
||||
p.relate(RelationKind::Interaction, "Report", b, c),
|
||||
p.relate(RelationKind::Interaction, "Feedback", c, a),
|
||||
p.relate(RelationKind::Interaction, "Direct monitor", a, c),
|
||||
];
|
||||
let means = [
|
||||
p.relate(RelationKind::CommunicationMean, "Service channel", x, y),
|
||||
p.relate(RelationKind::CommunicationMean, "Monitoring channel", y, z),
|
||||
];
|
||||
let contexts = [
|
||||
add(&mut p, Kind::Process, Layer::Oa, "Service path"),
|
||||
add(&mut p, Kind::Process, Layer::Oa, "Feedback path"),
|
||||
];
|
||||
for edge in &work[..2] {
|
||||
occurrence(&mut p, contexts[0], exchange(*edge));
|
||||
}
|
||||
for edge in &work[1..3] {
|
||||
occurrence(&mut p, contexts[1], exchange(*edge));
|
||||
}
|
||||
let categories = [
|
||||
category(&mut p, Layer::Oa, ExchangeFamily::Work, work[..2].to_vec()),
|
||||
category(&mut p, Layer::Oa, ExchangeFamily::Work, work[1..3].to_vec()),
|
||||
category(
|
||||
&mut p,
|
||||
Layer::Oa,
|
||||
ExchangeFamily::Participant,
|
||||
vec![means[0]],
|
||||
),
|
||||
];
|
||||
let function = add(&mut p, Kind::Function, Layer::Sa, "System request");
|
||||
let trace = Id::new_v4();
|
||||
p.traces.insert(
|
||||
trace,
|
||||
Trace {
|
||||
id: trace,
|
||||
alias: "request_realization".into(),
|
||||
name: "Request realization".into(),
|
||||
description: String::new(),
|
||||
oa: a,
|
||||
sa: function,
|
||||
kind: TraceKind::Refinement,
|
||||
baseline: None,
|
||||
},
|
||||
);
|
||||
Model {
|
||||
p,
|
||||
work,
|
||||
means,
|
||||
categories,
|
||||
contexts,
|
||||
allocation,
|
||||
trace,
|
||||
}
|
||||
}
|
||||
|
||||
fn accepted(filter: &PreparedFilter<'_>, ids: &[Id]) -> Vec<Id> {
|
||||
ids.iter()
|
||||
.filter(|id| filter.includes(**id))
|
||||
.copied()
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn traceability_defaults_preserve_both_layers_and_explicit_selectors_still_apply() {
|
||||
let mut m = model();
|
||||
let a = add(&mut m.p, Kind::Function, Layer::Sa, "Request in system");
|
||||
let b = add(&mut m.p, Kind::Function, Layer::Sa, "Provide in system");
|
||||
let sa_exchange =
|
||||
m.p.relate(RelationKind::FunctionalExchange, "System data", a, b);
|
||||
let sa_category = category(&mut m.p, Layer::Sa, ExchangeFamily::Work, vec![sa_exchange]);
|
||||
let sa_context = add(
|
||||
&mut m.p,
|
||||
Kind::FunctionalChain,
|
||||
Layer::Sa,
|
||||
"System service path",
|
||||
);
|
||||
occurrence(&mut m.p, sa_context, exchange(sa_exchange));
|
||||
let index = ViewIndex::new(&m.p);
|
||||
let defaults = ViewOptions::default();
|
||||
assert!(!index.prepare(Layer::Oa, &defaults).includes(sa_exchange));
|
||||
let cross_layer = index.prepare_cross_layer(Layer::Oa, &defaults);
|
||||
assert!(cross_layer.includes(sa_exchange));
|
||||
assert!(cross_layer.includes(m.work[0]));
|
||||
assert!(cross_layer.includes(m.trace));
|
||||
let selected = ViewOptions {
|
||||
work_categories: CategorySelection::Any(vec![m.categories[0]]),
|
||||
..defaults
|
||||
};
|
||||
let cross_layer = index.prepare_cross_layer(Layer::Oa, &selected);
|
||||
assert!(cross_layer.includes(m.work[0]));
|
||||
assert_eq!(
|
||||
cross_layer.reasons(sa_exchange, EdgeAvailability::Visible),
|
||||
vec![OmissionReason::Category(ExchangeFamily::Work)]
|
||||
);
|
||||
let none = ViewOptions {
|
||||
relation_kinds: RelationKinds::Only(vec![]),
|
||||
..ViewOptions::default()
|
||||
};
|
||||
assert!(
|
||||
!index
|
||||
.prepare_cross_layer(Layer::Oa, &none)
|
||||
.includes(sa_exchange)
|
||||
);
|
||||
let mixed = ViewOptions {
|
||||
work_categories: CategorySelection::Any(vec![m.categories[0], sa_category]),
|
||||
work_contexts: ContextSelection::Any(vec![m.contexts[0], sa_context]),
|
||||
hidden_edges: vec![sa_exchange],
|
||||
..ViewOptions::default()
|
||||
};
|
||||
assert!(validate_options_cross_layer(&m.p, &mixed).is_empty());
|
||||
assert!(!validate_options(&m.p, Layer::Oa, &mixed).is_empty());
|
||||
let cross_layer = index.prepare_cross_layer(Layer::Oa, &mixed);
|
||||
assert!(cross_layer.includes(m.work[0]));
|
||||
assert_eq!(
|
||||
cross_layer.reasons(sa_exchange, EdgeAvailability::Visible),
|
||||
vec![OmissionReason::ExplicitHide]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn separate_category_families_obey_all_any_empty_and_union_truth_table() {
|
||||
let m = model();
|
||||
let index = ViewIndex::new(&m.p);
|
||||
let work_cases = [
|
||||
(CategorySelection::All, m.work.to_vec()),
|
||||
(CategorySelection::Any(vec![]), vec![]),
|
||||
(
|
||||
CategorySelection::Any(vec![m.categories[0]]),
|
||||
m.work[..2].to_vec(),
|
||||
),
|
||||
(
|
||||
CategorySelection::Any(m.categories[..2].to_vec()),
|
||||
m.work[..3].to_vec(),
|
||||
),
|
||||
];
|
||||
let participant_cases = [
|
||||
(CategorySelection::All, m.means.to_vec()),
|
||||
(CategorySelection::Any(vec![]), vec![]),
|
||||
(
|
||||
CategorySelection::Any(vec![m.categories[2]]),
|
||||
vec![m.means[0]],
|
||||
),
|
||||
];
|
||||
for (work_categories, expected_work) in &work_cases {
|
||||
for (participant_categories, expected_means) in &participant_cases {
|
||||
let options = ViewOptions {
|
||||
work_categories: work_categories.clone(),
|
||||
participant_categories: participant_categories.clone(),
|
||||
..ViewOptions::default()
|
||||
};
|
||||
assert!(validate_options(&m.p, Layer::Oa, &options).is_empty());
|
||||
let filter = index.prepare(Layer::Oa, &options);
|
||||
assert_eq!(&accepted(&filter, &m.work), expected_work);
|
||||
assert_eq!(&accepted(&filter, &m.means), expected_means);
|
||||
assert!(filter.includes(m.allocation));
|
||||
assert!(filter.includes(m.trace));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn global_behavior_coverage_and_chosen_context_are_independent_intersecting_predicates() {
|
||||
let m = model();
|
||||
let index = ViewIndex::new(&m.p);
|
||||
let coverage_cases = [
|
||||
(Coverage::All, m.work.to_vec()),
|
||||
(Coverage::InBehavior, m.work[..3].to_vec()),
|
||||
(Coverage::OutsideBehavior, vec![m.work[3]]),
|
||||
];
|
||||
let context_cases = [
|
||||
(ContextSelection::All, m.work.to_vec()),
|
||||
(ContextSelection::Any(vec![]), vec![]),
|
||||
(
|
||||
ContextSelection::Any(vec![m.contexts[0]]),
|
||||
m.work[..2].to_vec(),
|
||||
),
|
||||
(
|
||||
ContextSelection::Any(vec![m.contexts[1]]),
|
||||
m.work[1..3].to_vec(),
|
||||
),
|
||||
(
|
||||
ContextSelection::Any(m.contexts.to_vec()),
|
||||
m.work[..3].to_vec(),
|
||||
),
|
||||
];
|
||||
for (coverage, global) in &coverage_cases {
|
||||
for (work_contexts, chosen) in &context_cases {
|
||||
let expected: Vec<_> = global
|
||||
.iter()
|
||||
.filter(|id| chosen.contains(id))
|
||||
.copied()
|
||||
.collect();
|
||||
let options = ViewOptions {
|
||||
coverage: *coverage,
|
||||
work_contexts: work_contexts.clone(),
|
||||
..ViewOptions::default()
|
||||
};
|
||||
let filter = index.prepare(Layer::Oa, &options);
|
||||
assert_eq!(accepted(&filter, &m.work), expected);
|
||||
assert_eq!(accepted(&filter, &m.means), m.means);
|
||||
assert!(filter.includes(m.allocation));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_lines_and_explicit_trace_selection_include_actual_trace_identities() {
|
||||
let m = model();
|
||||
let index = ViewIndex::new(&m.p);
|
||||
let all: Vec<_> = m
|
||||
.work
|
||||
.into_iter()
|
||||
.chain(m.means)
|
||||
.chain([m.allocation, m.trace])
|
||||
.collect();
|
||||
for (relation_kinds, expected) in [
|
||||
(RelationKinds::All, all.clone()),
|
||||
(RelationKinds::Only(vec![]), vec![]),
|
||||
(RelationKinds::Only(vec![EdgeKind::Trace]), vec![m.trace]),
|
||||
(
|
||||
RelationKinds::Only(vec![
|
||||
EdgeKind::Relation(RelationKind::Interaction),
|
||||
EdgeKind::Trace,
|
||||
]),
|
||||
m.work.into_iter().chain([m.trace]).collect(),
|
||||
),
|
||||
] {
|
||||
let options = ViewOptions {
|
||||
relation_kinds,
|
||||
..ViewOptions::default()
|
||||
};
|
||||
assert_eq!(
|
||||
accepted(&index.prepare(Layer::Oa, &options), &all),
|
||||
expected
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hidden_identity_survives_label_and_category_changes_without_mutating_the_model() {
|
||||
let mut m = model();
|
||||
let item = add(&mut m.p, Kind::ExchangeItem, Layer::Oa, "Request payload");
|
||||
if let RelationBody::Interaction { items, .. } =
|
||||
&mut m.p.relations.get_mut(&m.work[0]).unwrap().body
|
||||
{
|
||||
items.push(item);
|
||||
}
|
||||
let before = m.p.clone();
|
||||
let index = ViewIndex::new(&m.p);
|
||||
let mut options = ViewOptions {
|
||||
hidden_edges: vec![m.work[0]],
|
||||
..ViewOptions::default()
|
||||
};
|
||||
for labels in [
|
||||
ExchangeLabels::Name,
|
||||
ExchangeLabels::Items,
|
||||
ExchangeLabels::NameAndItems,
|
||||
] {
|
||||
options.exchange_labels = labels;
|
||||
for categories in [
|
||||
CategorySelection::All,
|
||||
CategorySelection::Any(vec![m.categories[0]]),
|
||||
CategorySelection::Any(vec![]),
|
||||
] {
|
||||
options.work_categories = categories;
|
||||
let filter = index.prepare(Layer::Oa, &options);
|
||||
assert!(!filter.includes(m.work[0]));
|
||||
assert!(
|
||||
filter
|
||||
.reasons(m.work[0], EdgeAvailability::Visible)
|
||||
.contains(&OmissionReason::ExplicitHide)
|
||||
);
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
ExchangeLabels::Items.label(&m.p, m.work[0]),
|
||||
"Request payload"
|
||||
);
|
||||
assert_eq!(
|
||||
ExchangeLabels::NameAndItems.label(&m.p, m.work[0]),
|
||||
"Request data · Request payload"
|
||||
);
|
||||
assert_eq!(ExchangeLabels::Items.label(&m.p, m.work[1]), "No items");
|
||||
assert_eq!(
|
||||
ExchangeLabels::Items.label(&m.p, m.trace),
|
||||
"Request realization"
|
||||
);
|
||||
assert_eq!(m.p, before);
|
||||
assert_eq!(options.hidden_edges, vec![m.work[0]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn omission_reasons_distinguish_missing_placements_folded_edges_and_explicit_hides() {
|
||||
let m = model();
|
||||
let index = ViewIndex::new(&m.p);
|
||||
let options = ViewOptions::default();
|
||||
let filter = index.prepare(Layer::Oa, &options);
|
||||
assert_eq!(
|
||||
filter.reasons(
|
||||
m.work[0],
|
||||
EdgeAvailability::EndpointNotShown {
|
||||
source: false,
|
||||
target: true
|
||||
}
|
||||
),
|
||||
vec![OmissionReason::EndpointNotShown {
|
||||
source: false,
|
||||
target: true
|
||||
}]
|
||||
);
|
||||
assert_eq!(
|
||||
filter.reasons(m.work[0], EdgeAvailability::FoldedInternal),
|
||||
vec![OmissionReason::FoldedInternal]
|
||||
);
|
||||
assert_eq!(
|
||||
filter.reasons(m.work[0], EdgeAvailability::OutsideProjection),
|
||||
vec![OmissionReason::OutsideProjection]
|
||||
);
|
||||
let options = ViewOptions {
|
||||
hidden_edges: vec![m.work[0]],
|
||||
work_categories: CategorySelection::Any(vec![]),
|
||||
..options
|
||||
};
|
||||
let reasons = index.prepare(Layer::Oa, &options).reasons(
|
||||
m.work[0],
|
||||
EdgeAvailability::EndpointNotShown {
|
||||
source: true,
|
||||
target: true,
|
||||
},
|
||||
);
|
||||
assert!(reasons.contains(&OmissionReason::ExplicitHide));
|
||||
assert!(reasons.contains(&OmissionReason::Category(ExchangeFamily::Work)));
|
||||
assert!(reasons.contains(&OmissionReason::EndpointNotShown {
|
||||
source: true,
|
||||
target: true
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrong_family_layer_and_unknown_references_reject_and_draft_filters_fail_closed() {
|
||||
let m = model();
|
||||
let index = ViewIndex::new(&m.p);
|
||||
for wrong in [m.categories[2], m.work[0], Id::new_v4()] {
|
||||
let options = ViewOptions {
|
||||
work_categories: CategorySelection::Any(vec![wrong]),
|
||||
..ViewOptions::default()
|
||||
};
|
||||
assert!(
|
||||
validate_options(&m.p, Layer::Oa, &options)
|
||||
.iter()
|
||||
.any(|e| e.field == "work_categories")
|
||||
);
|
||||
assert!(accepted(&index.prepare(Layer::Oa, &options), &m.work).is_empty());
|
||||
}
|
||||
let options = ViewOptions {
|
||||
work_categories: CategorySelection::Any(vec![m.categories[0]]),
|
||||
work_contexts: ContextSelection::Any(vec![m.contexts[0]]),
|
||||
hidden_edges: vec![m.work[0]],
|
||||
..ViewOptions::default()
|
||||
};
|
||||
let errors = validate_options(&m.p, Layer::Sa, &options);
|
||||
for field in ["work_categories", "work_contexts", "hidden_edges"] {
|
||||
assert!(errors.iter().any(|e| e.field == field));
|
||||
}
|
||||
assert!(validate_category(&m.p, Layer::Oa, None, ExchangeFamily::Work, &m.work).is_empty());
|
||||
assert!(
|
||||
validate_category(&m.p, Layer::Oa, None, ExchangeFamily::Participant, &m.means).is_empty()
|
||||
);
|
||||
for (family, members) in [
|
||||
(ExchangeFamily::Work, m.means.to_vec()),
|
||||
(ExchangeFamily::Participant, m.work.to_vec()),
|
||||
(ExchangeFamily::Work, vec![m.work[0], m.work[0]]),
|
||||
] {
|
||||
assert!(!validate_category(&m.p, Layer::Oa, None, family, &members).is_empty());
|
||||
}
|
||||
assert!(
|
||||
!validate_category(
|
||||
&m.p,
|
||||
Layer::Oa,
|
||||
Some(m.contexts[0]),
|
||||
ExchangeFamily::Work,
|
||||
&[]
|
||||
)
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persisted_empty_selectors_and_lifecycle_never_widen_to_all() {
|
||||
let m = model();
|
||||
let mut options = ViewOptions {
|
||||
hidden_edges: vec![m.work[0]],
|
||||
relation_kinds: RelationKinds::Only(vec![]),
|
||||
work_categories: CategorySelection::Any(vec![m.categories[0]]),
|
||||
participant_categories: CategorySelection::Any(vec![m.categories[2]]),
|
||||
work_contexts: ContextSelection::Any(vec![m.contexts[0]]),
|
||||
coverage: Coverage::OutsideBehavior,
|
||||
exchange_labels: ExchangeLabels::Items,
|
||||
};
|
||||
let replacements: BTreeMap<_, _> = [m.work[0], m.categories[0], m.categories[2], m.contexts[0]]
|
||||
.into_iter()
|
||||
.map(|id| (id, Id::new_v4()))
|
||||
.collect();
|
||||
options.visit_references(|id| *id = replacements[id]);
|
||||
assert_eq!(options.hidden_edges, vec![replacements[&m.work[0]]]);
|
||||
assert_eq!(
|
||||
options.work_categories,
|
||||
CategorySelection::Any(vec![replacements[&m.categories[0]]])
|
||||
);
|
||||
assert_eq!(
|
||||
options.participant_categories,
|
||||
CategorySelection::Any(vec![replacements[&m.categories[2]]])
|
||||
);
|
||||
assert_eq!(
|
||||
options.work_contexts,
|
||||
ContextSelection::Any(vec![replacements[&m.contexts[0]]])
|
||||
);
|
||||
options.retain_references(|_| false);
|
||||
assert_eq!(options.work_categories, CategorySelection::Any(vec![]));
|
||||
assert_eq!(
|
||||
options.participant_categories,
|
||||
CategorySelection::Any(vec![])
|
||||
);
|
||||
assert_eq!(options.work_contexts, ContextSelection::Any(vec![]));
|
||||
assert_eq!(options.relation_kinds, RelationKinds::Only(vec![]));
|
||||
assert_eq!(options.coverage, Coverage::OutsideBehavior);
|
||||
assert_eq!(options.exchange_labels, ExchangeLabels::Items);
|
||||
assert_eq!(
|
||||
serde_json::from_value::<ViewOptions>(serde_json::to_value(&options).unwrap()).unwrap(),
|
||||
options
|
||||
);
|
||||
assert_ne!(options, ViewOptions::default());
|
||||
assert_eq!(
|
||||
serde_json::from_str::<ViewOptions>("{}").unwrap(),
|
||||
ViewOptions::default()
|
||||
);
|
||||
assert!(serde_json::from_str::<ViewOptions>(r#"{"future_filter":true}"#).is_err());
|
||||
assert!(serde_json::from_str::<ViewOptions>(r#"{"coverage":"Maybe"}"#).is_err());
|
||||
let duplicate = ViewOptions {
|
||||
relation_kinds: RelationKinds::Only(vec![EdgeKind::Trace, EdgeKind::Trace]),
|
||||
hidden_edges: vec![m.trace, m.trace],
|
||||
..ViewOptions::default()
|
||||
};
|
||||
let errors = validate_options(&m.p, Layer::Oa, &duplicate);
|
||||
assert!(errors.iter().any(|e| e.field == "relation_kinds"));
|
||||
assert!(errors.iter().any(|e| e.field == "hidden_edges"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reusable_sequences_and_nested_scenarios_use_listed_contributions_in_both_layers() {
|
||||
for (layer, work_kind, sequence_kind, exchange_kind) in [
|
||||
(
|
||||
Layer::Oa,
|
||||
Kind::Activity,
|
||||
Kind::Process,
|
||||
RelationKind::Interaction,
|
||||
),
|
||||
(
|
||||
Layer::Sa,
|
||||
Kind::Function,
|
||||
Kind::FunctionalChain,
|
||||
RelationKind::FunctionalExchange,
|
||||
),
|
||||
] {
|
||||
let mut p = Project::empty();
|
||||
let a = add(&mut p, work_kind, layer, "Request");
|
||||
let b = add(&mut p, work_kind, layer, "Provide");
|
||||
let used = p.relate(exchange_kind, "Included", a, b);
|
||||
let unlisted = p.relate(exchange_kind, "Unlisted", b, a);
|
||||
let inner = add(&mut p, sequence_kind, layer, "Reusable sequence");
|
||||
let outer = add(&mut p, sequence_kind, layer, "Chosen sequence");
|
||||
occurrence(&mut p, inner, exchange(used));
|
||||
occurrence(
|
||||
&mut p,
|
||||
outer,
|
||||
OccurrenceBody::SequenceReference {
|
||||
sequence: Some(inner),
|
||||
},
|
||||
);
|
||||
let unused = occurrence(&mut p, inner, exchange(unlisted));
|
||||
p.elements
|
||||
.get_mut(&inner)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap()
|
||||
.retain(|id| *id != unused);
|
||||
assert_eq!(
|
||||
behavior_evidence(&p, [outer]).exchanges,
|
||||
BTreeSet::from([used])
|
||||
);
|
||||
assert!(behavior_evidence(&p, [unused]).exchanges.is_empty());
|
||||
let scenario = add(&mut p, Kind::Scenario, layer, "Conversation");
|
||||
let fragment = occurrence(
|
||||
&mut p,
|
||||
scenario,
|
||||
OccurrenceBody::Fragment {
|
||||
operator: FragmentOperator::Optional,
|
||||
branches: vec![],
|
||||
},
|
||||
);
|
||||
let branch = occurrence(
|
||||
&mut p,
|
||||
fragment,
|
||||
OccurrenceBody::Branch {
|
||||
guard: "requested".into(),
|
||||
occurrences: vec![],
|
||||
},
|
||||
);
|
||||
if let Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Fragment { branches, .. },
|
||||
..
|
||||
}) = p.elements.get_mut(&fragment).unwrap().shared_mut()
|
||||
{
|
||||
branches.push(branch);
|
||||
}
|
||||
occurrence(&mut p, branch, exchange(unlisted));
|
||||
let reuse = add(&mut p, Kind::Scenario, layer, "Chosen conversation");
|
||||
occurrence(
|
||||
&mut p,
|
||||
reuse,
|
||||
OccurrenceBody::ScenarioUse {
|
||||
scenario: Some(scenario),
|
||||
bindings: vec![],
|
||||
},
|
||||
);
|
||||
assert_eq!(
|
||||
behavior_evidence(&p, [reuse]).exchanges,
|
||||
BTreeSet::from([unlisted])
|
||||
);
|
||||
assert_eq!(
|
||||
all_behavior_evidence(&p).exchanges,
|
||||
BTreeSet::from([used, unlisted])
|
||||
);
|
||||
assert_eq!(behavior_evidence(&p, [outer]).work, BTreeSet::from([a, b]));
|
||||
p.elements
|
||||
.get_mut(&scenario)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap()
|
||||
.clear();
|
||||
assert!(behavior_evidence(&p, [reuse]).exchanges.is_empty());
|
||||
// A malformed reuse cycle must terminate without inventing an occurrence.
|
||||
occurrence(
|
||||
&mut p,
|
||||
scenario,
|
||||
OccurrenceBody::ScenarioUse {
|
||||
scenario: Some(reuse),
|
||||
bindings: vec![],
|
||||
},
|
||||
);
|
||||
assert!(behavior_evidence(&p, [reuse]).exchanges.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
use oa_core::{projection::*, routing::*, *};
|
||||
|
||||
fn pos(x: f32, y: f32) -> Position {
|
||||
Position { x, y }
|
||||
}
|
||||
fn anchor(side: PortAnchorSide, offset: f32) -> EdgeAttachment {
|
||||
EdgeAttachment { side, offset }
|
||||
}
|
||||
fn fixture() -> (Project, Id, Id, Id, Id) {
|
||||
let mut p = Project::empty();
|
||||
let a = p
|
||||
.add_element(Kind::Activity, Layer::Oa, "Report", None)
|
||||
.unwrap();
|
||||
let b = p
|
||||
.add_element(Kind::Activity, Layer::Oa, "Receive", None)
|
||||
.unwrap();
|
||||
let edge = p.relate(RelationKind::Interaction, "Report status", a, b);
|
||||
let d = p.ensure_diagram(Layer::Oa);
|
||||
p.place(d, a, pos(50., 50.));
|
||||
p.place(d, b, pos(750., 200.));
|
||||
(p, d, a, b, edge)
|
||||
}
|
||||
fn stored(edge: Id) -> EdgeRoute {
|
||||
EdgeRoute {
|
||||
edge,
|
||||
source_attachment: None,
|
||||
target_attachment: None,
|
||||
waypoints: vec![],
|
||||
label: None,
|
||||
}
|
||||
}
|
||||
fn routed(p: &Project, d: Id) -> RoutedView {
|
||||
let diagram = &p.diagrams[&d];
|
||||
route_diagram(p, diagram, &project_diagram(p, diagram))
|
||||
}
|
||||
fn near(a: f32, b: f32) {
|
||||
assert!((a - b).abs() < 0.002, "{a} != {b}");
|
||||
}
|
||||
fn orthogonal(points: &[Position]) {
|
||||
assert!(points.len() >= 2);
|
||||
for span in points.windows(2) {
|
||||
assert!(span[0].x == span[1].x || span[0].y == span[1].y, "{span:?}");
|
||||
}
|
||||
}
|
||||
fn length(points: &[Position]) -> f32 {
|
||||
points
|
||||
.windows(2)
|
||||
.map(|p| (p[0].x - p[1].x).abs() + (p[0].y - p[1].y).abs())
|
||||
.sum()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authored_boundary_follows_projected_owner_and_preview_matches_committed_geometry() {
|
||||
let (mut p, d, a, _, edge) = fixture();
|
||||
let initial = routed(&p, d);
|
||||
let mut route = stored(edge);
|
||||
route.source_attachment = Some(anchor(PortAnchorSide::Bottom, 0.25));
|
||||
route.target_attachment = Some(anchor(PortAnchorSide::Top, 0.75));
|
||||
route.waypoints = vec![pos(460., 350.)];
|
||||
p.diagrams.get_mut(&d).unwrap().routes.push(route.clone());
|
||||
let view = project_diagram(&p, &p.diagrams[&d]);
|
||||
let preview = route_edge_preview(&p, &p.diagrams[&d], &view, &initial, edge);
|
||||
let full = routed(&p, d);
|
||||
assert_eq!(
|
||||
preview, full,
|
||||
"attachment-only changes must invalidate the label-only path cache"
|
||||
);
|
||||
let n = view.nodes.iter().find(|n| n.element == a).unwrap();
|
||||
assert_eq!(
|
||||
full.edges[&edge].points[0],
|
||||
pos(
|
||||
n.position.x + n.size.width * 0.25,
|
||||
n.position.y + n.size.height
|
||||
)
|
||||
);
|
||||
assert_eq!(full.edges[&edge].pins, route.waypoints);
|
||||
orthogonal(&full.edges[&edge].points);
|
||||
let node = p
|
||||
.diagrams
|
||||
.get_mut(&d)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.values_mut()
|
||||
.find(|n| n.element == a)
|
||||
.unwrap();
|
||||
node.position.x += 75.;
|
||||
node.size.width += 100.;
|
||||
node.size.height += 50.;
|
||||
let moved = routed(&p, d);
|
||||
near(
|
||||
moved.edges[&edge].points[0].x - full.edges[&edge].points[0].x,
|
||||
100.,
|
||||
);
|
||||
near(
|
||||
moved.edges[&edge].points[0].y - full.edges[&edge].points[0].y,
|
||||
50.,
|
||||
);
|
||||
assert_eq!(p.diagrams[&d].routes[0], route);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attachment_only_preview_cannot_reuse_old_endpoints_with_unchanged_pins() {
|
||||
let (mut p, d, _, _, edge) = fixture();
|
||||
let baseline = routed(&p, d);
|
||||
let mut route = stored(edge);
|
||||
route.source_attachment = Some(anchor(PortAnchorSide::Top, 0.2));
|
||||
p.diagrams.get_mut(&d).unwrap().routes.push(route);
|
||||
let view = project_diagram(&p, &p.diagrams[&d]);
|
||||
let preview = route_edge_preview(&p, &p.diagrams[&d], &view, &baseline, edge);
|
||||
assert_ne!(preview.edges[&edge].points, baseline.edges[&edge].points);
|
||||
assert_eq!(preview, routed(&p, d));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ports_remain_authoritative_even_when_projection_filters_the_port() {
|
||||
let mut p = Project::empty();
|
||||
let a = p
|
||||
.add_element(Kind::Function, Layer::Sa, "Send", None)
|
||||
.unwrap();
|
||||
let b = p
|
||||
.add_element(Kind::Function, Layer::Sa, "Receive", None)
|
||||
.unwrap();
|
||||
let edge = p.relate(RelationKind::FunctionalExchange, "Data", a, b);
|
||||
let d = p.ensure_diagram(Layer::Sa);
|
||||
p.place(d, a, pos(0., 0.));
|
||||
p.place(d, b, pos(600., 0.));
|
||||
let diagram = p.diagrams[&d].clone();
|
||||
let view = project_diagram(&p, &diagram);
|
||||
let expected = route_diagram(&p, &diagram, &view);
|
||||
assert!(semantic_port_endpoint(&p, edge, EdgeEnd::Source).is_some());
|
||||
let mut route = stored(edge);
|
||||
route.source_attachment = Some(anchor(PortAnchorSide::Bottom, 0.1));
|
||||
route.target_attachment = Some(anchor(PortAnchorSide::Top, 0.9));
|
||||
p.diagrams.get_mut(&d).unwrap().routes.push(route);
|
||||
assert_eq!(routed(&p, d), expected);
|
||||
let mut hidden = view.clone();
|
||||
hidden.ports.clear();
|
||||
for e in &mut hidden.edges {
|
||||
e.source_port = None;
|
||||
e.target_port = None;
|
||||
}
|
||||
let absent = route_diagram(&p, &diagram, &hidden);
|
||||
assert_eq!(
|
||||
route_diagram(&p, &p.diagrams[&d], &hidden),
|
||||
absent,
|
||||
"hidden ports must not activate a dormant node preference"
|
||||
);
|
||||
}
|
||||
|
||||
fn state(kind: Kind) -> (Project, Id, Id, Id) {
|
||||
let mut p = Project::empty();
|
||||
let owner = p
|
||||
.add_element(Kind::Entity, Layer::Oa, "Crew", None)
|
||||
.unwrap();
|
||||
let machine = p
|
||||
.add_element(Kind::StateMachine, Layer::Oa, "Modes", Some(owner))
|
||||
.unwrap();
|
||||
let region = p
|
||||
.add_element(Kind::Region, Layer::Oa, "Main", Some(machine))
|
||||
.unwrap();
|
||||
let a = p.add_element(kind, Layer::Oa, "A", Some(region)).unwrap();
|
||||
let b = p
|
||||
.add_element(Kind::State, Layer::Oa, "B", Some(region))
|
||||
.unwrap();
|
||||
let edge = p.relate(RelationKind::StateTransition, "Next", a, b);
|
||||
let d = p.ensure_diagram(Layer::Oa);
|
||||
p.diagrams.get_mut(&d).unwrap().projection = ProjectionKind::StateMachine { machine };
|
||||
p.place(d, a, pos(100., 100.));
|
||||
p.place(d, b, pos(600., 100.));
|
||||
(p, d, a, edge)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nonrectangular_state_attachments_intersect_the_painted_shape() {
|
||||
for kind in [
|
||||
Kind::Initial,
|
||||
Kind::Junction,
|
||||
Kind::Final,
|
||||
Kind::Choice,
|
||||
Kind::ShallowHistory,
|
||||
Kind::DeepHistory,
|
||||
Kind::EntryPoint,
|
||||
Kind::ExitPoint,
|
||||
Kind::Terminate,
|
||||
Kind::Fork,
|
||||
Kind::Join,
|
||||
Kind::State,
|
||||
] {
|
||||
let (p, d, a, _) = state(kind);
|
||||
let diagram = &p.diagrams[&d];
|
||||
let view = project_diagram(&p, diagram);
|
||||
let n = view.nodes.iter().find(|n| n.element == a).unwrap();
|
||||
for side in [
|
||||
PortAnchorSide::Left,
|
||||
PortAnchorSide::Right,
|
||||
PortAnchorSide::Top,
|
||||
PortAnchorSide::Bottom,
|
||||
] {
|
||||
for offset in [0., 0.2, 0.5, 0.8, 1.] {
|
||||
let at = attachment_point(&p, diagram, n, &anchor(side, offset));
|
||||
let x = (at.x - n.position.x) / n.size.width;
|
||||
let y = (at.y - n.position.y) / n.size.height;
|
||||
match kind {
|
||||
Kind::Choice => near((2. * x - 1.).abs() + (2. * y - 1.).abs(), 1.),
|
||||
Kind::Terminate => near((x - y).abs().min((x + y - 1.).abs()), 0.),
|
||||
Kind::Fork | Kind::Join => near(x.min(1. - x).min(y.min(1. - y)), 0.),
|
||||
Kind::State => {
|
||||
assert!((0. ..=1.).contains(&x) && (0. ..=1.).contains(&y));
|
||||
if offset == 0. || offset == 1. {
|
||||
assert!(
|
||||
x != 0. && x != 1. || y != 0. && y != 1.,
|
||||
"rounded corner cannot use bounding-box corner"
|
||||
);
|
||||
}
|
||||
}
|
||||
_ => near((2. * x - 1.).powi(2) + (2. * y - 1.).powi(2), 1.),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inset_contour_leads_keep_pin_fractions_and_labels_on_the_complete_path() {
|
||||
let (mut p, d, _, edge) = state(Kind::Choice);
|
||||
let mut route = stored(edge);
|
||||
route.source_attachment = Some(anchor(PortAnchorSide::Right, 0.));
|
||||
route.waypoints = vec![pos(380., 40.)];
|
||||
route.label = Some(EdgeLabel {
|
||||
fraction: 0.3,
|
||||
offset: pos(0., 0.),
|
||||
});
|
||||
p.diagrams.get_mut(&d).unwrap().routes.push(route);
|
||||
let result = routed(&p, d);
|
||||
let r = &result.edges[&edge];
|
||||
orthogonal(&r.points);
|
||||
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
|
||||
let pin = r.pins[0];
|
||||
let pin_index = r.points.iter().position(|p| *p == pin).unwrap();
|
||||
near(
|
||||
r.pin_fractions[0],
|
||||
length(&r.points[..=pin_index]) / length(&r.points),
|
||||
);
|
||||
assert_eq!(
|
||||
r.label.anchor,
|
||||
point_along_polyline(&r.points, 0.3).unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn small_symbol_drag_chooses_the_intended_exit_side_at_low_zoom() {
|
||||
for kind in [
|
||||
Kind::Initial,
|
||||
Kind::Choice,
|
||||
Kind::Terminate,
|
||||
Kind::Fork,
|
||||
Kind::State,
|
||||
] {
|
||||
let (p, d, a, _) = state(kind);
|
||||
let view = project_diagram(&p, &p.diagrams[&d]);
|
||||
let n = view.nodes.iter().find(|n| n.element == a).unwrap();
|
||||
for zoom in [0.5, 1., 2.] {
|
||||
for overshoot in [0., 20.] {
|
||||
let cursor = pos(n.position.x + n.size.width / 2., n.position.y - overshoot);
|
||||
let chosen =
|
||||
attachment_from_point(n, cursor, Some(PortAnchorSide::Right), 6. / zoom);
|
||||
assert_eq!(
|
||||
chosen,
|
||||
anchor(PortAnchorSide::Top, 0.5),
|
||||
"{kind:?}, zoom {zoom}, overshoot {overshoot}"
|
||||
);
|
||||
let painted = attachment_point(&p, &p.diagrams[&d], n, &chosen);
|
||||
near(painted.x, n.position.x + n.size.width / 2.);
|
||||
near(
|
||||
painted.y,
|
||||
n.position.y
|
||||
+ if kind == Kind::Terminate {
|
||||
n.size.height / 2.
|
||||
} else {
|
||||
0.
|
||||
},
|
||||
);
|
||||
}
|
||||
let near_corner = pos(n.position.x + n.size.width - 1., n.position.y + 1.);
|
||||
let stable =
|
||||
attachment_from_point(n, near_corner, Some(PortAnchorSide::Right), 6. / zoom);
|
||||
assert_eq!(
|
||||
stable.side,
|
||||
PortAnchorSide::Right,
|
||||
"corner hysteresis stays stable"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coincident_self_attachments_retain_hard_pins_and_form_a_visible_loop() {
|
||||
let (mut p, d, a, edge) = state(Kind::State);
|
||||
if let RelationBody::StateTransition { target, .. } =
|
||||
&mut p.relations.get_mut(&edge).unwrap().body
|
||||
{
|
||||
*target = a;
|
||||
}
|
||||
for pins in [vec![], vec![pos(400., 30.)]] {
|
||||
let mut route = stored(edge);
|
||||
route.source_attachment = Some(anchor(PortAnchorSide::Top, 0.5));
|
||||
route.target_attachment = route.source_attachment;
|
||||
route.waypoints = pins.clone();
|
||||
p.diagrams.get_mut(&d).unwrap().routes = vec![route.clone()];
|
||||
let result = routed(&p, d);
|
||||
let r = &result.edges[&edge];
|
||||
orthogonal(&r.points);
|
||||
assert_eq!(r.points.first(), r.points.last());
|
||||
assert!(length(&r.points) > 2. * PORT_STUB + 70.);
|
||||
assert_eq!(r.pins, pins);
|
||||
assert_eq!(r.pin_fractions.len(), pins.len());
|
||||
assert!(
|
||||
r.warnings.contains(&RouteWarning::CoincidentAttachments),
|
||||
"{:?}",
|
||||
r.warnings
|
||||
);
|
||||
assert_eq!(routed(&p, d), result, "deterministic fallback");
|
||||
assert_eq!(p.diagrams[&d].routes[0], route);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coincident_loop_chooses_clear_side_instead_of_inventing_an_obstructed_pin() {
|
||||
for blocker_side in [-1., 1.] {
|
||||
let (mut p, d, a, edge) = state(Kind::State);
|
||||
if let RelationBody::StateTransition { target, .. } =
|
||||
&mut p.relations.get_mut(&edge).unwrap().body
|
||||
{
|
||||
*target = a;
|
||||
}
|
||||
let mut route = stored(edge);
|
||||
route.source_attachment = Some(anchor(PortAnchorSide::Top, 0.5));
|
||||
route.target_attachment = route.source_attachment;
|
||||
p.diagrams.get_mut(&d).unwrap().routes.push(route);
|
||||
let view = project_diagram(&p, &p.diagrams[&d]);
|
||||
let source = view.nodes.iter().find(|n| n.element == a).unwrap();
|
||||
let tip = attachment_point(
|
||||
&p,
|
||||
&p.diagrams[&d],
|
||||
source,
|
||||
&anchor(PortAnchorSide::Top, 0.5),
|
||||
);
|
||||
let region = p.elements[&a].parent;
|
||||
let blocker = p
|
||||
.add_element(Kind::State, Layer::Oa, "Detour obstruction", region)
|
||||
.unwrap();
|
||||
let left = if blocker_side > 0. {
|
||||
tip.x + 30.
|
||||
} else {
|
||||
tip.x - 30. - 224.
|
||||
};
|
||||
p.place(d, blocker, pos(left, tip.y - 144.));
|
||||
let result = routed(&p, d);
|
||||
let r = &result.edges[&edge];
|
||||
assert_eq!(r.warnings, vec![RouteWarning::CoincidentAttachments]);
|
||||
orthogonal(&r.points);
|
||||
let view = project_diagram(&p, &p.diagrams[&d]);
|
||||
let n = view.nodes.iter().find(|n| n.element == blocker).unwrap();
|
||||
for segment in r.points.windows(2) {
|
||||
for step in 0..=100 {
|
||||
let t = step as f32 / 100.;
|
||||
let q = pos(
|
||||
segment[0].x + (segment[1].x - segment[0].x) * t,
|
||||
segment[0].y + (segment[1].y - segment[0].y) * t,
|
||||
);
|
||||
assert!(
|
||||
!(q.x > n.position.x - CLEARANCE
|
||||
&& q.x < n.position.x + n.size.width + CLEARANCE
|
||||
&& q.y > n.position.y - CLEARANCE
|
||||
&& q.y < n.position.y + n.size.height + CLEARANCE),
|
||||
"route crosses blocker: {q:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
r.pins.is_empty(),
|
||||
"generated detours must never become authored pins"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
use oa_core::{language, projection::*, routing::*, *};
|
||||
|
||||
fn pos(x: f32, y: f32) -> Position {
|
||||
Position { x, y }
|
||||
}
|
||||
|
||||
fn id(project: &Project, alias: &str) -> Id {
|
||||
project
|
||||
.identities()
|
||||
.into_iter()
|
||||
.find(|(_, a)| *a == alias)
|
||||
.unwrap()
|
||||
.0
|
||||
}
|
||||
|
||||
fn authored(edge: Id) -> EdgeRoute {
|
||||
EdgeRoute {
|
||||
edge,
|
||||
source_attachment: Some(EdgeAttachment {
|
||||
side: PortAnchorSide::Bottom,
|
||||
offset: 0.2,
|
||||
}),
|
||||
target_attachment: Some(EdgeAttachment {
|
||||
side: PortAnchorSide::Top,
|
||||
offset: 0.8,
|
||||
}),
|
||||
waypoints: vec![pos(520., 420.)],
|
||||
label: Some(EdgeLabel {
|
||||
fraction: 0.35,
|
||||
offset: pos(17., -23.),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn routed(project: &Project, diagram: Id) -> RoutedView {
|
||||
let d = &project.diagrams[&diagram];
|
||||
route_diagram(project, d, &project_diagram(project, d))
|
||||
}
|
||||
|
||||
fn fixture() -> (Project, Id, Id, Id, Id, Id, Id) {
|
||||
let mut p = language::parse(
|
||||
r#"arcadia 7;
|
||||
project lifecycle "Attachment lifecycle" {
|
||||
oa_activity group "Group" { oa_activity first "First" {} }
|
||||
oa_activity second "Second" {}
|
||||
oa_activity replacement "Replacement" {}
|
||||
interaction signal "Signal" { source @first; target @second; }
|
||||
diagram view "View" {}
|
||||
diagram other "Other" {}
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let (d, other, group, first, second, replacement, edge) = (
|
||||
id(&p, "view"),
|
||||
id(&p, "other"),
|
||||
id(&p, "group"),
|
||||
id(&p, "first"),
|
||||
id(&p, "second"),
|
||||
id(&p, "replacement"),
|
||||
id(&p, "signal"),
|
||||
);
|
||||
for diagram in [d, other] {
|
||||
p.place(diagram, group, pos(20., 20.));
|
||||
p.place(diagram, first, pos(100., 130.));
|
||||
p.place(diagram, second, pos(750., 150.));
|
||||
p.place(diagram, replacement, pos(750., 570.));
|
||||
p.diagrams
|
||||
.get_mut(&diagram)
|
||||
.unwrap()
|
||||
.routes
|
||||
.push(authored(edge));
|
||||
}
|
||||
(p, d, other, group, first, replacement, edge)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attachment_fold_promotion_suspends_the_complete_route_and_expand_restores_it() {
|
||||
let (mut p, d, other, group, _, _, edge) = fixture();
|
||||
assert_eq!(semantic_port_endpoint(&p, edge, EdgeEnd::Source), None);
|
||||
assert_eq!(semantic_port_endpoint(&p, edge, EdgeEnd::Target), None);
|
||||
let before = p.clone();
|
||||
let expanded = routed(&p, d);
|
||||
let other_geometry = routed(&p, other);
|
||||
let parent = p.diagrams[&d]
|
||||
.nodes
|
||||
.values()
|
||||
.find(|n| n.element == group)
|
||||
.unwrap()
|
||||
.id;
|
||||
p.diagrams
|
||||
.get_mut(&d)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&parent)
|
||||
.unwrap()
|
||||
.collapsed = true;
|
||||
let folded = routed(&p, d);
|
||||
assert!(folded.edges[&edge].custom_suspended);
|
||||
assert_eq!(folded.edges[&edge].pins, []);
|
||||
assert!(
|
||||
folded.edges[&edge]
|
||||
.warnings
|
||||
.contains(&RouteWarning::PromotedEndpoints)
|
||||
);
|
||||
assert_eq!(p.diagrams[&d].routes, before.diagrams[&d].routes);
|
||||
let mut automatic = p.clone();
|
||||
automatic.diagrams.get_mut(&d).unwrap().routes.clear();
|
||||
assert_eq!(
|
||||
folded,
|
||||
routed(&automatic, d),
|
||||
"attachments, pins and label must all be dormant on promotion"
|
||||
);
|
||||
assert_eq!(p.diagrams[&other], before.diagrams[&other]);
|
||||
assert_eq!(routed(&p, other), other_geometry);
|
||||
p.diagrams
|
||||
.get_mut(&d)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&parent)
|
||||
.unwrap()
|
||||
.collapsed = false;
|
||||
assert_eq!(p, before);
|
||||
assert_eq!(routed(&p, d), expanded);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attachment_reconnect_retains_slot_preferences_and_other_view_records_with_exact_history() {
|
||||
let (p, d, other, _, _, replacement, edge) = fixture();
|
||||
let before = routed(&p, d);
|
||||
let mut relation = p.relations[&edge].clone();
|
||||
let RelationBody::Interaction { target, .. } = &mut relation.body else {
|
||||
unreachable!()
|
||||
};
|
||||
*target = replacement;
|
||||
let mut expected = p.clone();
|
||||
expected.relations.insert(edge, relation.clone());
|
||||
let mut session = Session::new(p.clone());
|
||||
session
|
||||
.execute(Command::EditRelation(edge, relation))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
session.project(),
|
||||
&expected,
|
||||
"reconnect changes only the semantic endpoint; all per-view records remain exact"
|
||||
);
|
||||
assert_eq!(session.project().diagrams[&other], p.diagrams[&other]);
|
||||
for diagram in [d, other] {
|
||||
let current = session.project();
|
||||
let view = project_diagram(current, ¤t.diagrams[&diagram]);
|
||||
let node = view
|
||||
.nodes
|
||||
.iter()
|
||||
.find(|n| n.element == replacement)
|
||||
.unwrap();
|
||||
let preference = current.diagrams[&diagram].routes[0]
|
||||
.target_attachment
|
||||
.unwrap();
|
||||
let expected_tip =
|
||||
attachment_point(current, ¤t.diagrams[&diagram], node, &preference);
|
||||
let result = routed(current, diagram);
|
||||
assert_eq!(result.edges[&edge].points.last(), Some(&expected_tip));
|
||||
assert_eq!(
|
||||
result.edges[&edge].pins,
|
||||
p.diagrams[&diagram].routes[0].waypoints
|
||||
);
|
||||
}
|
||||
assert_ne!(
|
||||
routed(session.project(), d).edges[&edge].points.last(),
|
||||
before.edges[&edge].points.last()
|
||||
);
|
||||
session.undo();
|
||||
assert_eq!(session.project(), &p);
|
||||
assert_eq!(routed(session.project(), d), before);
|
||||
session.redo();
|
||||
assert_eq!(session.project(), &expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attachment_direct_port_endpoints_remain_authoritative_when_their_glyphs_are_filtered() {
|
||||
let mut p = Project::empty();
|
||||
let system = p
|
||||
.add_element(Kind::System, Layer::Sa, "System", None)
|
||||
.unwrap();
|
||||
let actor = p
|
||||
.add_element(Kind::SystemActor, Layer::Sa, "Actor", None)
|
||||
.unwrap();
|
||||
let function = p
|
||||
.add_element(Kind::Function, Layer::Sa, "Send", None)
|
||||
.unwrap();
|
||||
let mut ports = Vec::new();
|
||||
for (owner, name, contract) in [
|
||||
(
|
||||
system,
|
||||
"Output",
|
||||
PortContract::Component {
|
||||
kind: ComponentPortKind::Flow,
|
||||
orientation: ComponentPortOrientation::Out,
|
||||
},
|
||||
),
|
||||
(
|
||||
actor,
|
||||
"Input",
|
||||
PortContract::Component {
|
||||
kind: ComponentPortKind::Flow,
|
||||
orientation: ComponentPortOrientation::In,
|
||||
},
|
||||
),
|
||||
(
|
||||
function,
|
||||
"Function output",
|
||||
PortContract::FunctionOutput {
|
||||
outgoing_items: vec![],
|
||||
},
|
||||
),
|
||||
] {
|
||||
let port = Id::new_v4();
|
||||
p.elements
|
||||
.insert(port, make_port(&p, port, owner, name, contract).unwrap());
|
||||
ports.push(port);
|
||||
}
|
||||
p.relate(
|
||||
RelationKind::Allocation,
|
||||
"Function allocation",
|
||||
system,
|
||||
function,
|
||||
);
|
||||
let boundary = p.relate(
|
||||
RelationKind::BoundaryExchange,
|
||||
"Boundary",
|
||||
ports[0],
|
||||
ports[1],
|
||||
);
|
||||
let allocation = p.relate(
|
||||
RelationKind::PortAllocation,
|
||||
"Port allocation",
|
||||
ports[0],
|
||||
ports[2],
|
||||
);
|
||||
let d = p.ensure_diagram(Layer::Sa);
|
||||
p.place(d, system, pos(0., 0.));
|
||||
p.place(d, actor, pos(700., 0.));
|
||||
p.place(d, function, pos(0., 450.));
|
||||
let original = p.clone();
|
||||
let baseline = routed(&p, d);
|
||||
for (edge, target) in [(boundary, ports[1]), (allocation, ports[2])] {
|
||||
assert!(baseline.edges.contains_key(&edge));
|
||||
assert_eq!(
|
||||
semantic_port_endpoint(&p, edge, EdgeEnd::Source),
|
||||
Some(ports[0])
|
||||
);
|
||||
assert_eq!(
|
||||
semantic_port_endpoint(&p, edge, EdgeEnd::Target),
|
||||
Some(target)
|
||||
);
|
||||
let mut route = authored(edge);
|
||||
route.waypoints.clear();
|
||||
route.label = None;
|
||||
p.diagrams.get_mut(&d).unwrap().routes.push(route);
|
||||
}
|
||||
assert_eq!(routed(&p, d), baseline);
|
||||
let mut hidden = project_diagram(&p, &p.diagrams[&d]);
|
||||
hidden.ports.clear();
|
||||
for edge in &mut hidden.edges {
|
||||
edge.source_port = None;
|
||||
edge.target_port = None;
|
||||
}
|
||||
assert_eq!(
|
||||
route_diagram(&p, &p.diagrams[&d], &hidden),
|
||||
route_diagram(&original, &original.diagrams[&d], &hidden),
|
||||
"filtered direct Port endpoints must not activate their stored preferences"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
use oa_core::{projection::*, routing::*, *};
|
||||
|
||||
#[test]
|
||||
fn valid_item_trace_follows_manual_port_anchors_and_ignores_dormant_attachments() {
|
||||
let mut project = Project::empty();
|
||||
let oa = project
|
||||
.add_element(Kind::Activity, Layer::Oa, "Send", None)
|
||||
.unwrap();
|
||||
let sa = project
|
||||
.add_element(Kind::Function, Layer::Sa, "Receive", None)
|
||||
.unwrap();
|
||||
let ports = [(oa, Layer::Oa), (sa, Layer::Sa)].map(|(owner, layer)| {
|
||||
let id = Id::new_v4();
|
||||
project.elements.insert(
|
||||
id,
|
||||
Element {
|
||||
id,
|
||||
alias: format!("port_{}", id.simple()),
|
||||
name: "Status port".into(),
|
||||
description: "Existing legacy port retained by Item trace".into(),
|
||||
parent: Some(owner),
|
||||
body: ElementBody::shared_in(
|
||||
layer,
|
||||
SharedBody::Port {
|
||||
contract: PortContract::Legacy,
|
||||
legacy: Some(LegacyPortContract {
|
||||
direction: PortDirection::InOut,
|
||||
interface: None,
|
||||
}),
|
||||
},
|
||||
),
|
||||
},
|
||||
);
|
||||
id
|
||||
});
|
||||
let trace = Trace {
|
||||
id: Id::new_v4(),
|
||||
alias: "status_correspondence".into(),
|
||||
name: "Status correspondence".into(),
|
||||
description: "Preserve this semantic evidence".into(),
|
||||
oa: ports[0],
|
||||
sa: ports[1],
|
||||
kind: TraceKind::Item,
|
||||
baseline: None,
|
||||
};
|
||||
project.traces.insert(trace.id, trace.clone());
|
||||
let diagram = project.ensure_diagram(Layer::Oa);
|
||||
project.diagrams.get_mut(&diagram).unwrap().projection = ProjectionKind::Traceability;
|
||||
let owners = [
|
||||
project.place(diagram, oa, Position { x: 100., y: 100. }),
|
||||
project.place(diagram, sa, Position { x: 800., y: 300. }),
|
||||
];
|
||||
validate_candidate(&project).unwrap();
|
||||
let semantic_elements = project.elements.clone();
|
||||
let semantic_relations = project.relations.clone();
|
||||
let semantic_traces = project.traces.clone();
|
||||
let view = project_diagram(&project, &project.diagrams[&diagram]);
|
||||
let edge = view.edges.iter().find(|edge| edge.id == trace.id).unwrap();
|
||||
assert!(edge.trace);
|
||||
assert_eq!((edge.source, edge.target), (owners[0], owners[1]));
|
||||
assert_eq!((edge.source_port, edge.target_port), (None, None));
|
||||
let initial = route_diagram(&project, &project.diagrams[&diagram], &view);
|
||||
for (index, (side, offset)) in [(PortAnchorSide::Top, 0.25), (PortAnchorSide::Bottom, 0.75)]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
project
|
||||
.diagrams
|
||||
.get_mut(&diagram)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&owners[index])
|
||||
.unwrap()
|
||||
.port_anchors
|
||||
.push(PortAnchor {
|
||||
port: ports[index],
|
||||
side,
|
||||
offset,
|
||||
});
|
||||
}
|
||||
validate_candidate(&project).unwrap();
|
||||
let moved_view = project_diagram(&project, &project.diagrams[&diagram]);
|
||||
let moved = route_diagram(&project, &project.diagrams[&diagram], &moved_view);
|
||||
for index in 0..2 {
|
||||
let marker = moved_view
|
||||
.ports
|
||||
.iter()
|
||||
.find(|port| port.element == ports[index] && port.owner == owners[index])
|
||||
.unwrap();
|
||||
let points = &moved.edges[&trace.id].points;
|
||||
let tip = if index == 0 {
|
||||
points[0]
|
||||
} else {
|
||||
*points.last().unwrap()
|
||||
};
|
||||
assert_eq!(tip, marker.position);
|
||||
let old = &initial.edges[&trace.id].points;
|
||||
assert_ne!(
|
||||
tip,
|
||||
if index == 0 {
|
||||
old[0]
|
||||
} else {
|
||||
*old.last().unwrap()
|
||||
}
|
||||
);
|
||||
}
|
||||
project
|
||||
.diagrams
|
||||
.get_mut(&diagram)
|
||||
.unwrap()
|
||||
.routes
|
||||
.push(EdgeRoute {
|
||||
edge: trace.id,
|
||||
waypoints: vec![],
|
||||
label: None,
|
||||
source_attachment: Some(EdgeAttachment {
|
||||
side: PortAnchorSide::Bottom,
|
||||
offset: 0.9,
|
||||
}),
|
||||
target_attachment: Some(EdgeAttachment {
|
||||
side: PortAnchorSide::Top,
|
||||
offset: 0.1,
|
||||
}),
|
||||
});
|
||||
validate_candidate(&project).unwrap();
|
||||
let dormant_view = project_diagram(&project, &project.diagrams[&diagram]);
|
||||
assert_eq!(dormant_view, moved_view);
|
||||
assert_eq!(
|
||||
route_diagram(&project, &project.diagrams[&diagram], &dormant_view),
|
||||
moved
|
||||
);
|
||||
assert_eq!(project.elements, semantic_elements);
|
||||
assert_eq!(project.relations, semantic_relations);
|
||||
assert_eq!(project.traces, semantic_traces);
|
||||
assert_eq!(project.traces[&trace.id], trace);
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
//! Route the exact native acceptance fixture and intended saved checkpoints.
|
||||
use oa_core::{projection::project_diagram, routing::route_diagram, *};
|
||||
|
||||
const FIXTURE: &str =
|
||||
include_str!("../../../harness/experiments/edge-attachments-20260922/native-fixture.arcadia");
|
||||
|
||||
fn id(project: &Project, alias: &str) -> Id {
|
||||
project
|
||||
.identities()
|
||||
.into_iter()
|
||||
.find(|(_, name)| *name == alias)
|
||||
.unwrap()
|
||||
.0
|
||||
}
|
||||
|
||||
fn set(
|
||||
project: &mut Project,
|
||||
view: &str,
|
||||
edge: &str,
|
||||
end: EdgeEnd,
|
||||
attachment: Option<(PortAnchorSide, f32)>,
|
||||
) {
|
||||
let view = id(project, view);
|
||||
let edge = id(project, edge);
|
||||
let route = project
|
||||
.diagrams
|
||||
.get_mut(&view)
|
||||
.unwrap()
|
||||
.routes
|
||||
.iter_mut()
|
||||
.find(|route| route.edge == edge)
|
||||
.unwrap();
|
||||
route.set_attachment(
|
||||
end,
|
||||
attachment.map(|(side, offset)| EdgeAttachment { side, offset }),
|
||||
);
|
||||
}
|
||||
|
||||
fn clear(project: &Project, checkpoint: &str) {
|
||||
validate_candidate(project).unwrap();
|
||||
for (view, edges) in [
|
||||
("dispatch_view", &["dispatch_request"][..]),
|
||||
("readiness_view", &["activate", "initialize", "finish"][..]),
|
||||
] {
|
||||
let diagram = &project.diagrams[&id(project, view)];
|
||||
let routed = route_diagram(project, diagram, &project_diagram(project, diagram));
|
||||
for alias in edges {
|
||||
let edge = &routed.edges[&id(project, alias)];
|
||||
assert!(
|
||||
edge.warnings.is_empty(),
|
||||
"{checkpoint}/{view}/{alias}: {:?}",
|
||||
edge.warnings
|
||||
);
|
||||
let stored = diagram
|
||||
.routes
|
||||
.iter()
|
||||
.find(|r| r.edge == id(project, alias))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
edge.pins, stored.waypoints,
|
||||
"{checkpoint}/{alias}: manual pins retained"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_workshop_baseline_and_planned_attachments_have_clear_routes() {
|
||||
use EdgeEnd::{Source, Target};
|
||||
use PortAnchorSide::{Bottom, Top};
|
||||
let mut project = language::parse(FIXTURE).unwrap();
|
||||
let baseline = project.clone();
|
||||
clear(&project, "B");
|
||||
set(
|
||||
&mut project,
|
||||
"dispatch_view",
|
||||
"dispatch_request",
|
||||
Source,
|
||||
Some((Top, 0.25)),
|
||||
);
|
||||
clear(&project, "O1");
|
||||
set(
|
||||
&mut project,
|
||||
"dispatch_view",
|
||||
"dispatch_request",
|
||||
Target,
|
||||
Some((Bottom, 0.75)),
|
||||
);
|
||||
clear(&project, "O2");
|
||||
set(
|
||||
&mut project,
|
||||
"dispatch_view",
|
||||
"dispatch_request",
|
||||
Source,
|
||||
None,
|
||||
);
|
||||
clear(&project, "O3");
|
||||
set(
|
||||
&mut project,
|
||||
"readiness_view",
|
||||
"activate",
|
||||
Source,
|
||||
Some((Top, 0.25)),
|
||||
);
|
||||
clear(&project, "S1");
|
||||
set(
|
||||
&mut project,
|
||||
"readiness_view",
|
||||
"activate",
|
||||
Target,
|
||||
Some((Bottom, 0.75)),
|
||||
);
|
||||
clear(&project, "S2");
|
||||
set(&mut project, "readiness_view", "activate", Target, None);
|
||||
clear(&project, "S3");
|
||||
set(
|
||||
&mut project,
|
||||
"readiness_view",
|
||||
"initialize",
|
||||
Source,
|
||||
Some((Top, 0.5)),
|
||||
);
|
||||
clear(&project, "S4");
|
||||
set(
|
||||
&mut project,
|
||||
"readiness_view",
|
||||
"finish",
|
||||
Source,
|
||||
Some((Top, 0.5)),
|
||||
);
|
||||
clear(&project, "S5");
|
||||
assert_eq!(project.elements, baseline.elements);
|
||||
assert_eq!(project.relations, baseline.relations);
|
||||
for alias in ["dispatch_reference", "readiness_reference", "ports_view"] {
|
||||
let view = id(&project, alias);
|
||||
assert_eq!(project.diagrams[&view], baseline.diagrams[&view]);
|
||||
}
|
||||
for alias in ["dispatch_view", "readiness_view"] {
|
||||
let view = id(&project, alias);
|
||||
assert_eq!(
|
||||
project.diagrams[&view].nodes,
|
||||
baseline.diagrams[&view].nodes
|
||||
);
|
||||
for route in &project.diagrams[&view].routes {
|
||||
let original = baseline.diagrams[&view]
|
||||
.routes
|
||||
.iter()
|
||||
.find(|r| r.edge == route.edge)
|
||||
.unwrap();
|
||||
assert_eq!(route.waypoints, original.waypoints);
|
||||
assert_eq!(route.label, original.label);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
//! Independent oracle for the OA communication / SA boundary direction seam.
|
||||
use oa_core::{
|
||||
transitions::*,
|
||||
validation::{self, MessageEndpointResolution as Resolution},
|
||||
*,
|
||||
};
|
||||
|
||||
fn line(p: &mut Project, scenario: Id, owner: Id) -> Id {
|
||||
let id = p
|
||||
.add_element(Kind::Lifeline, Layer::Sa, "Line", Some(scenario))
|
||||
.unwrap();
|
||||
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::Lifeline {
|
||||
represented: Some(owner),
|
||||
};
|
||||
id
|
||||
}
|
||||
fn message(
|
||||
exchange: Id,
|
||||
sender: Id,
|
||||
receiver: Id,
|
||||
kind: MessageKind,
|
||||
reply: Option<Id>,
|
||||
) -> OccurrenceBody {
|
||||
OccurrenceBody::Exchange {
|
||||
operation: None,
|
||||
exchange: Some(exchange),
|
||||
sender: Some(sender),
|
||||
receiver: Some(receiver),
|
||||
message_kind: kind,
|
||||
reply_to: reply,
|
||||
values: vec![],
|
||||
}
|
||||
}
|
||||
fn occurrence(p: &mut Project, scenario: Id, body: OccurrenceBody) -> Id {
|
||||
let id = p
|
||||
.add_element(Kind::Occurrence, Layer::Sa, "Message", Some(scenario))
|
||||
.unwrap();
|
||||
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::Occurrence {
|
||||
body,
|
||||
precondition: String::new(),
|
||||
postcondition: String::new(),
|
||||
};
|
||||
let steps = p.elements.get_mut(&scenario).unwrap().steps_mut().unwrap();
|
||||
if !steps.contains(&id) {
|
||||
steps.push(id);
|
||||
}
|
||||
id
|
||||
}
|
||||
fn boundary_fixture() -> (Project, Id, Id, Id, Id) {
|
||||
let mut p = Project::empty();
|
||||
let oa = p
|
||||
.add_element(Kind::Entity, Layer::Oa, "Team", None)
|
||||
.unwrap();
|
||||
let ob = p
|
||||
.add_element(Kind::Actor, Layer::Oa, "Control", None)
|
||||
.unwrap();
|
||||
let sa = p
|
||||
.add_element(Kind::System, Layer::Sa, "Radio", None)
|
||||
.unwrap();
|
||||
let sb = p
|
||||
.add_element(Kind::SystemActor, Layer::Sa, "Radio control", None)
|
||||
.unwrap();
|
||||
let mean = p.relate(RelationKind::CommunicationMean, "Channel", oa, ob);
|
||||
let mut request = TransitionRequest::new(TransitionDirection::OaToSa, vec![mean]);
|
||||
request.mappings = vec![
|
||||
TransitionMapping {
|
||||
source: oa,
|
||||
target: Some(sa),
|
||||
target_kind: None,
|
||||
},
|
||||
TransitionMapping {
|
||||
source: ob,
|
||||
target: Some(sb),
|
||||
target_kind: None,
|
||||
},
|
||||
];
|
||||
let port = |alias: &str| CommunicationPortChoice::Create {
|
||||
id: Id::new_v4(),
|
||||
alias: alias.into(),
|
||||
name: alias.into(),
|
||||
description: String::new(),
|
||||
kind: ComponentPortKind::Standard,
|
||||
orientation: ComponentPortOrientation::Unset,
|
||||
};
|
||||
request
|
||||
.communication_endpoints
|
||||
.push(CommunicationEndpointDecision {
|
||||
source: mean,
|
||||
source_port: port("radio_a"),
|
||||
target_port: port("radio_b"),
|
||||
});
|
||||
let plan = plan_transition(&p, &request).unwrap();
|
||||
assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts);
|
||||
p = apply_transition(&p, &plan).unwrap();
|
||||
let boundary = p.traces.values().find(|t| t.oa == mean).unwrap().sa;
|
||||
let scenario = p
|
||||
.add_element(Kind::Scenario, Layer::Sa, "Reverse request", None)
|
||||
.unwrap();
|
||||
if let Some(SharedBody::Scenario { viewpoint, .. }) =
|
||||
p.elements.get_mut(&scenario).unwrap().shared_mut()
|
||||
{
|
||||
*viewpoint = ScenarioViewpoint::BoundaryInterface;
|
||||
}
|
||||
let a = line(&mut p, scenario, sa);
|
||||
let b = line(&mut p, scenario, sb);
|
||||
(p, boundary, scenario, a, b)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mapped_unoriented_boundary_resolves_reverse_call_and_reply_from_actual_binding() {
|
||||
let (mut p, boundary, scenario, a, b) = boundary_fixture();
|
||||
let (source, target) = p.relations[&boundary].body.endpoints();
|
||||
let call = message(boundary, b, a, MessageKind::Call, None);
|
||||
assert_eq!(
|
||||
validation::resolve_message_endpoints(&p, &call),
|
||||
Resolution::Resolved {
|
||||
source: target,
|
||||
target: source
|
||||
}
|
||||
);
|
||||
let call = occurrence(&mut p, scenario, call);
|
||||
let reply = message(boundary, a, b, MessageKind::Reply, Some(call));
|
||||
assert_eq!(
|
||||
validation::resolve_message_endpoints(&p, &reply),
|
||||
Resolution::Resolved { source, target }
|
||||
);
|
||||
occurrence(&mut p, scenario, reply);
|
||||
let errors: Vec<_> = validation::validate(&p)
|
||||
.into_iter()
|
||||
.filter(|i| i.severity == validation::Severity::Error)
|
||||
.collect();
|
||||
assert!(errors.is_empty(), "{errors:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unoriented_flow_boundary_honors_ordinary_direction_but_preserves_correlated_reply_exception() {
|
||||
let (mut p, boundary, scenario, a, b) = boundary_fixture();
|
||||
let (source, target) = p.relations[&boundary].body.endpoints();
|
||||
for (port, orientation) in [
|
||||
(source, ComponentPortOrientation::Out),
|
||||
(target, ComponentPortOrientation::In),
|
||||
] {
|
||||
if let Some(SharedBody::Port { contract, .. }) =
|
||||
p.elements.get_mut(&port).unwrap().shared_mut()
|
||||
{
|
||||
*contract = PortContract::Component {
|
||||
kind: ComponentPortKind::Flow,
|
||||
orientation,
|
||||
};
|
||||
}
|
||||
}
|
||||
let reverse = message(boundary, b, a, MessageKind::Call, None);
|
||||
assert_eq!(
|
||||
validation::resolve_message_endpoints(&p, &reverse),
|
||||
Resolution::Conflict
|
||||
);
|
||||
let forward = message(boundary, a, b, MessageKind::Call, None);
|
||||
assert_eq!(
|
||||
validation::resolve_message_endpoints(&p, &forward),
|
||||
Resolution::Resolved { source, target }
|
||||
);
|
||||
let call = occurrence(&mut p, scenario, forward);
|
||||
let reply = message(boundary, b, a, MessageKind::Reply, Some(call));
|
||||
assert_eq!(
|
||||
validation::resolve_message_endpoints(&p, &reply),
|
||||
Resolution::Resolved {
|
||||
source: target,
|
||||
target: source
|
||||
}
|
||||
);
|
||||
occurrence(&mut p, scenario, reply);
|
||||
let errors: Vec<_> = validation::validate(&p)
|
||||
.into_iter()
|
||||
.filter(|i| i.severity == validation::Severity::Error)
|
||||
.collect();
|
||||
assert!(errors.is_empty(), "{errors:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reverse_boundary_scenario_transition_keeps_call_orientation_and_reply_identity() {
|
||||
let (mut p, boundary, scenario, a, b) = boundary_fixture();
|
||||
let mean = p.traces.values().find(|t| t.sa == boundary).unwrap().oa;
|
||||
let call = message(boundary, b, a, MessageKind::Call, None);
|
||||
let call = occurrence(&mut p, scenario, call);
|
||||
let reply = message(boundary, a, b, MessageKind::Reply, Some(call));
|
||||
let reply = occurrence(&mut p, scenario, reply);
|
||||
let owner = |port| p.elements[&port].parent.unwrap();
|
||||
let mut request = TransitionRequest::new(TransitionDirection::SaToOa, vec![scenario]);
|
||||
request.scope = DependencyScope::Subtree;
|
||||
request.mappings = [
|
||||
(boundary, mean),
|
||||
(
|
||||
owner(p.relations[&boundary].source()),
|
||||
p.relations[&mean].source(),
|
||||
),
|
||||
(
|
||||
owner(p.relations[&boundary].target()),
|
||||
p.relations[&mean].target(),
|
||||
),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|(source, target)| TransitionMapping {
|
||||
source,
|
||||
target: Some(target),
|
||||
target_kind: None,
|
||||
})
|
||||
.collect();
|
||||
// The explicit participant counterparts intentionally use different names.
|
||||
// Review those prose differences without changing the endpoint decision.
|
||||
request.resolutions = request
|
||||
.mappings
|
||||
.iter()
|
||||
.map(|mapping| FieldResolution {
|
||||
source: mapping.source,
|
||||
field: TransitionField::Name,
|
||||
decision: FieldDecision::KeepTarget,
|
||||
})
|
||||
.collect();
|
||||
let plan = plan_transition(&p, &request).unwrap();
|
||||
assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts);
|
||||
let q = apply_transition(&p, &plan).unwrap();
|
||||
let mapped = |source| q.traces.values().find(|t| t.sa == source).unwrap().oa;
|
||||
let body = |id| match q.elements[&id].shared().unwrap() {
|
||||
SharedBody::Occurrence { body, .. } => body,
|
||||
_ => panic!("expected occurrence"),
|
||||
};
|
||||
let mapped_call = mapped(call);
|
||||
let mapped_reply = mapped(reply);
|
||||
assert_eq!(
|
||||
validation::resolve_message_endpoints(&q, body(mapped_call)),
|
||||
Resolution::Resolved {
|
||||
source: q.relations[&mean].target(),
|
||||
target: q.relations[&mean].source(),
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
validation::resolve_message_endpoints(&q, body(mapped_reply)),
|
||||
Resolution::Resolved {
|
||||
source: q.relations[&mean].source(),
|
||||
target: q.relations[&mean].target(),
|
||||
}
|
||||
);
|
||||
assert!(
|
||||
matches!(body(mapped_reply), OccurrenceBody::Exchange { reply_to: Some(id), .. } if *id == mapped_call)
|
||||
);
|
||||
assert_eq!(q.elements[&scenario], p.elements[&scenario]);
|
||||
assert_eq!(q.elements[&call], p.elements[&call]);
|
||||
assert_eq!(q.elements[&reply], p.elements[&reply]);
|
||||
}
|
||||
@@ -0,0 +1,523 @@
|
||||
use oa_core::{
|
||||
transitions::*,
|
||||
validation::{self, MessageEndpointResolution as Resolution, Severity},
|
||||
*,
|
||||
};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
fn add(p: &mut Project, kind: Kind, layer: Layer, name: &str) -> Id {
|
||||
p.add_element(kind, layer, name, None).unwrap()
|
||||
}
|
||||
fn fixture() -> (Project, Id, Id, Id) {
|
||||
let mut p = Project::empty();
|
||||
let a = add(&mut p, Kind::Entity, Layer::Oa, "Rescue team");
|
||||
let b = add(&mut p, Kind::Actor, Layer::Oa, "Control centre");
|
||||
let mean = p.relate(RelationKind::CommunicationMean, "Radio", a, b);
|
||||
(p, a, b, mean)
|
||||
}
|
||||
fn errors(p: &Project) -> Vec<validation::Issue> {
|
||||
validation::validate(p)
|
||||
.into_iter()
|
||||
.filter(|i| i.severity == Severity::Error)
|
||||
.collect()
|
||||
}
|
||||
fn valid(p: &Project) {
|
||||
assert!(errors(p).is_empty(), "{:?}", errors(p));
|
||||
}
|
||||
fn oriented(p: &mut Project, id: Id, value: bool) {
|
||||
if let RelationBody::CommunicationMean { oriented, .. } =
|
||||
&mut p.relations.get_mut(&id).unwrap().body
|
||||
{
|
||||
*oriented = value;
|
||||
}
|
||||
}
|
||||
fn items(p: &mut Project, id: Id, values: Vec<Id>) {
|
||||
match &mut p.relations.get_mut(&id).unwrap().body {
|
||||
RelationBody::CommunicationMean { items, .. } | RelationBody::Interaction { items, .. } => {
|
||||
*items = values
|
||||
}
|
||||
_ => panic!(),
|
||||
}
|
||||
}
|
||||
fn has(p: &Project, id: Id, code: &str, severity: Severity) -> bool {
|
||||
validation::validate(p)
|
||||
.iter()
|
||||
.any(|i| i.element == Some(id) && i.code == code && i.severity == severity)
|
||||
}
|
||||
fn line(p: &mut Project, scenario: Id, represented: Id) -> Id {
|
||||
if let Some(SharedBody::Scenario { viewpoint, .. }) =
|
||||
p.elements.get_mut(&scenario).unwrap().shared_mut()
|
||||
{
|
||||
*viewpoint = ScenarioViewpoint::ParticipantInteraction;
|
||||
}
|
||||
let id = p
|
||||
.add_element(
|
||||
Kind::Lifeline,
|
||||
Layer::Oa,
|
||||
&format!("line_{represented}"),
|
||||
Some(scenario),
|
||||
)
|
||||
.unwrap();
|
||||
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::Lifeline {
|
||||
represented: Some(represented),
|
||||
};
|
||||
id
|
||||
}
|
||||
fn message(
|
||||
mean: Id,
|
||||
sender: Id,
|
||||
receiver: Id,
|
||||
kind: MessageKind,
|
||||
reply_to: Option<Id>,
|
||||
) -> OccurrenceBody {
|
||||
OccurrenceBody::Exchange {
|
||||
operation: None,
|
||||
exchange: Some(mean),
|
||||
sender: Some(sender),
|
||||
receiver: Some(receiver),
|
||||
message_kind: kind,
|
||||
reply_to,
|
||||
values: vec![],
|
||||
}
|
||||
}
|
||||
fn occurrence(p: &mut Project, scenario: Id, body: OccurrenceBody) -> Id {
|
||||
let id = p
|
||||
.add_element(Kind::Occurrence, Layer::Oa, "Message", Some(scenario))
|
||||
.unwrap();
|
||||
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::Occurrence {
|
||||
body,
|
||||
precondition: String::new(),
|
||||
postcondition: String::new(),
|
||||
};
|
||||
let steps = p.elements.get_mut(&scenario).unwrap().steps_mut().unwrap();
|
||||
if !steps.contains(&id) {
|
||||
steps.push(id);
|
||||
}
|
||||
id
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn communication_means_are_distinct_oa_participant_relations_and_strict_source6_records() {
|
||||
let (mut p, a, b, mean) = fixture();
|
||||
valid(&p);
|
||||
assert!(matches!(
|
||||
p.relations[&mean].body,
|
||||
RelationBody::CommunicationMean {
|
||||
oriented: false,
|
||||
..
|
||||
}
|
||||
));
|
||||
p.relate(RelationKind::CommunicationMean, "Telephone", a, b);
|
||||
valid(&p);
|
||||
let text = language::format(&p);
|
||||
assert!(text.contains("communication_mean"));
|
||||
assert!(text.starts_with(&format!("arcadia {SCHEMA_VERSION};")));
|
||||
assert_eq!(language::parse(&text).unwrap(), p);
|
||||
let bytes = serde_json::to_vec(&p).unwrap();
|
||||
assert_eq!(persistence::decode(&bytes).unwrap(), p);
|
||||
for version in 1..=5 {
|
||||
let mut old = serde_json::to_value(&p).unwrap();
|
||||
old["schema_version"] = version.into();
|
||||
assert!(
|
||||
persistence::decode(&serde_json::to_vec(&old).unwrap()).is_err(),
|
||||
"schema {version}"
|
||||
);
|
||||
assert!(
|
||||
language::parse(&text.replacen(
|
||||
&format!("arcadia {SCHEMA_VERSION};"),
|
||||
&format!("arcadia {version};"),
|
||||
1
|
||||
))
|
||||
.is_err(),
|
||||
"source {version}"
|
||||
);
|
||||
}
|
||||
let mut bad = serde_json::to_value(&p).unwrap();
|
||||
bad["relations"][mean.to_string()]["body"]["CommunicationMean"]["typo"] = true.into();
|
||||
assert!(persistence::decode(&serde_json::to_vec(&bad).unwrap()).is_err());
|
||||
for endpoint in [
|
||||
a,
|
||||
add(&mut p, Kind::Role, Layer::Oa, "Role"),
|
||||
add(&mut p, Kind::System, Layer::Sa, "System"),
|
||||
] {
|
||||
let mut q = p.clone();
|
||||
q.relations
|
||||
.get_mut(&mean)
|
||||
.unwrap()
|
||||
.body
|
||||
.set_endpoints(a, endpoint);
|
||||
assert!(!errors(&q).is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn communication_schema5_upgrade_preserves_legacy_data_and_exact_source_trivia() {
|
||||
let p = Project::demo();
|
||||
let mut json = serde_json::to_value(&p).unwrap();
|
||||
json["schema_version"] = 5.into();
|
||||
for d in json["diagrams"].as_object_mut().unwrap().values_mut() {
|
||||
d.as_object_mut().unwrap().remove("view_options");
|
||||
}
|
||||
let result = persistence::decode_with_report(&serde_json::to_vec(&json).unwrap()).unwrap();
|
||||
assert_eq!(result.project, p);
|
||||
assert_eq!(result.migration.unwrap().from_schema, 5);
|
||||
let mut without_views = p.clone();
|
||||
without_views.diagrams.clear();
|
||||
let old = language::format(&without_views).replacen(
|
||||
&format!("arcadia {SCHEMA_VERSION};"),
|
||||
"arcadia // old contract\n5;",
|
||||
1,
|
||||
);
|
||||
let bundle = source_project::SourceBundle {
|
||||
entry: "entry.arcadia".into(),
|
||||
files: BTreeMap::from([("entry.arcadia".into(), old.clone())]),
|
||||
};
|
||||
let compiled =
|
||||
source_project::compile_bundle(&bundle, source_project::ProjectLimits::default()).unwrap();
|
||||
assert_eq!(
|
||||
compiled.bundle.files["entry.arcadia"],
|
||||
old.replacen("\n5;", &format!("\n{SCHEMA_VERSION};"), 1)
|
||||
);
|
||||
assert_eq!(compiled.decoded.project, without_views);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn communication_allocation_checks_whole_partial_pair_and_does_not_guess_roles() {
|
||||
let (mut p, a, b, mean) = fixture();
|
||||
let x = add(&mut p, Kind::Activity, Layer::Oa, "Send");
|
||||
let y = add(&mut p, Kind::Activity, Layer::Oa, "Receive");
|
||||
let work = p.relate(RelationKind::Interaction, "Report", x, y);
|
||||
let allocation = p.relate(
|
||||
RelationKind::ExchangeAllocation,
|
||||
"Radio carries report",
|
||||
mean,
|
||||
work,
|
||||
);
|
||||
let role = add(&mut p, Kind::Role, Layer::Oa, "Communicator");
|
||||
p.relate(RelationKind::RoleRealization, "Role", a, role);
|
||||
p.relate(RelationKind::RoleAssignment, "Responsibility", role, x);
|
||||
valid(&p);
|
||||
assert!(has(
|
||||
&p,
|
||||
allocation,
|
||||
"incomplete_exchange_allocation",
|
||||
Severity::Warning
|
||||
));
|
||||
p.relate(RelationKind::Allocation, "Actual receiver", a, y);
|
||||
valid(&p); // reverse candidate survives
|
||||
oriented(&mut p, mean, true);
|
||||
assert!(has(
|
||||
&p,
|
||||
allocation,
|
||||
"exchange_allocation_performer",
|
||||
Severity::Error
|
||||
));
|
||||
oriented(&mut p, mean, false);
|
||||
p.relate(RelationKind::Allocation, "Actual sender", b, x);
|
||||
valid(&p);
|
||||
let mut q = p.clone();
|
||||
q.relations.get_mut(&mean).unwrap().body.set_endpoints(b, a);
|
||||
valid(&q);
|
||||
let third = add(&mut q, Kind::Entity, Layer::Oa, "Third");
|
||||
q.relations
|
||||
.get_mut(&mean)
|
||||
.unwrap()
|
||||
.body
|
||||
.set_endpoints(a, third);
|
||||
assert!(has(
|
||||
&q,
|
||||
allocation,
|
||||
"exchange_allocation_performer",
|
||||
Severity::Error
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn communication_allocation_items_use_specialization_and_empty_is_not_wildcard() {
|
||||
let (mut p, a, b, mean) = fixture();
|
||||
let x = add(&mut p, Kind::Activity, Layer::Oa, "Send");
|
||||
let y = add(&mut p, Kind::Activity, Layer::Oa, "Receive");
|
||||
p.relate(RelationKind::Allocation, "Send allocation", a, x);
|
||||
p.relate(RelationKind::Allocation, "Receive allocation", b, y);
|
||||
let work = p.relate(RelationKind::Interaction, "Report", x, y);
|
||||
let allocation = p.relate(RelationKind::ExchangeAllocation, "Carried", mean, work);
|
||||
let base = add(&mut p, Kind::ExchangeItem, Layer::Oa, "Report item");
|
||||
let specialized = add(&mut p, Kind::ExchangeItem, Layer::Oa, "Distress item");
|
||||
if let Some(SharedBody::ExchangeItem { supertype, .. }) =
|
||||
p.elements.get_mut(&specialized).unwrap().shared_mut()
|
||||
{
|
||||
*supertype = Some(base);
|
||||
}
|
||||
items(&mut p, work, vec![specialized]);
|
||||
assert!(has(
|
||||
&p,
|
||||
allocation,
|
||||
"exchange_allocation_items",
|
||||
Severity::Error
|
||||
));
|
||||
items(&mut p, mean, vec![base]);
|
||||
valid(&p);
|
||||
items(&mut p, work, vec![base]);
|
||||
items(&mut p, mean, vec![specialized]);
|
||||
assert!(has(
|
||||
&p,
|
||||
allocation,
|
||||
"exchange_allocation_items",
|
||||
Severity::Error
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn communication_scenario_reverse_call_and_reply_follow_actual_direction() {
|
||||
let (mut p, a, b, mean) = fixture();
|
||||
let scenario = add(&mut p, Kind::Scenario, Layer::Oa, "Radio conversation");
|
||||
let la = line(&mut p, scenario, a);
|
||||
let lb = line(&mut p, scenario, b);
|
||||
let call_body = message(mean, lb, la, MessageKind::Call, None);
|
||||
let call = occurrence(&mut p, scenario, call_body.clone());
|
||||
let reply_body = message(mean, la, lb, MessageKind::Reply, Some(call));
|
||||
occurrence(&mut p, scenario, reply_body.clone());
|
||||
assert_eq!(
|
||||
validation::resolve_message_endpoints(&p, &call_body),
|
||||
Resolution::Resolved {
|
||||
source: b,
|
||||
target: a
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
validation::resolve_message_endpoints(&p, &reply_body),
|
||||
Resolution::Resolved {
|
||||
source: a,
|
||||
target: b
|
||||
}
|
||||
);
|
||||
valid(&p);
|
||||
let mut edited = p.relations[&mean].clone();
|
||||
if let RelationBody::CommunicationMean { oriented, .. } = &mut edited.body {
|
||||
*oriented = true;
|
||||
}
|
||||
assert!(prepare_command(&p, &Command::EditRelation(mean, edited)).is_err());
|
||||
valid(&p);
|
||||
// The established operation-response exception also allows same-means Reply on oriented means.
|
||||
if let Some(SharedBody::Occurrence { body, .. }) =
|
||||
p.elements.get_mut(&call).unwrap().shared_mut()
|
||||
{
|
||||
*body = message(mean, la, lb, MessageKind::Call, None);
|
||||
}
|
||||
oriented(&mut p, mean, true);
|
||||
assert_eq!(
|
||||
validation::resolve_message_endpoints(
|
||||
&p,
|
||||
&message(mean, lb, la, MessageKind::Reply, Some(call))
|
||||
),
|
||||
Resolution::Resolved {
|
||||
source: b,
|
||||
target: a
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn communication_scenario_ambiguous_and_incomplete_bindings_never_guess() {
|
||||
let (mut p, a, b, mean) = fixture();
|
||||
let scenario = add(&mut p, Kind::Scenario, Layer::Oa, "Roles");
|
||||
let role = add(&mut p, Kind::Role, Layer::Oa, "Shared role");
|
||||
p.relate(RelationKind::RoleRealization, "A role", a, role);
|
||||
p.relate(RelationKind::RoleRealization, "B role", b, role);
|
||||
let one = line(&mut p, scenario, role);
|
||||
let two = line(&mut p, scenario, role);
|
||||
let mut body = message(mean, one, two, MessageKind::Asynchronous, None);
|
||||
assert_eq!(
|
||||
validation::resolve_message_endpoints(&p, &body),
|
||||
Resolution::Ambiguous
|
||||
);
|
||||
assert_eq!(validation::message_endpoints(&p, &body), None);
|
||||
if let OccurrenceBody::Exchange { receiver, .. } = &mut body {
|
||||
*receiver = None;
|
||||
}
|
||||
assert_eq!(
|
||||
validation::resolve_message_endpoints(&p, &body),
|
||||
Resolution::Incomplete
|
||||
);
|
||||
let third = add(&mut p, Kind::Entity, Layer::Oa, "Other");
|
||||
let other = line(&mut p, scenario, third);
|
||||
if let OccurrenceBody::Exchange { sender, .. } = &mut body {
|
||||
*sender = Some(other);
|
||||
}
|
||||
assert_eq!(
|
||||
validation::resolve_message_endpoints(&p, &body),
|
||||
Resolution::Conflict
|
||||
);
|
||||
}
|
||||
|
||||
fn transition_fixture() -> (Project, Id, Id, Id, TransitionRequest) {
|
||||
let (mut p, a, b, mean) = fixture();
|
||||
let sa = add(&mut p, Kind::System, Layer::Sa, "System");
|
||||
let sb = add(&mut p, Kind::SystemActor, Layer::Sa, "External");
|
||||
let mut r = TransitionRequest::new(TransitionDirection::OaToSa, vec![mean]);
|
||||
r.mappings = vec![
|
||||
TransitionMapping {
|
||||
source: a,
|
||||
target: Some(sa),
|
||||
target_kind: None,
|
||||
},
|
||||
TransitionMapping {
|
||||
source: b,
|
||||
target: Some(sb),
|
||||
target_kind: None,
|
||||
},
|
||||
];
|
||||
let port = |name: &str| CommunicationPortChoice::Create {
|
||||
id: Id::new_v4(),
|
||||
alias: name.into(),
|
||||
name: name.into(),
|
||||
description: "Explicit technical choice".into(),
|
||||
kind: ComponentPortKind::Standard,
|
||||
orientation: ComponentPortOrientation::Unset,
|
||||
};
|
||||
r.communication_endpoints
|
||||
.push(CommunicationEndpointDecision {
|
||||
source: mean,
|
||||
source_port: port("radio_source"),
|
||||
target_port: port("radio_target"),
|
||||
});
|
||||
(p, mean, sa, sb, r)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn communication_transition_requires_explicit_ports_and_preserves_participant_mapping() {
|
||||
let (p, mean, sa, sb, r) = transition_fixture();
|
||||
let mut incomplete = r.clone();
|
||||
incomplete.communication_endpoints.clear();
|
||||
assert!(
|
||||
!plan_transition(&p, &incomplete)
|
||||
.unwrap()
|
||||
.conflicts
|
||||
.is_empty()
|
||||
);
|
||||
let plan = plan_transition(&p, &r).unwrap();
|
||||
assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts);
|
||||
let q = apply_transition(&p, &plan).unwrap();
|
||||
valid(&q);
|
||||
let trace = q.traces.values().find(|t| t.oa == mean).unwrap();
|
||||
assert_eq!(trace.kind, TraceKind::Exchange);
|
||||
let boundary = &q.relations[&trace.sa];
|
||||
assert_eq!(boundary.kind(), RelationKind::BoundaryExchange);
|
||||
assert_eq!(q.elements[&boundary.source()].parent, Some(sa));
|
||||
assert_eq!(q.elements[&boundary.target()].parent, Some(sb));
|
||||
assert_eq!(q.relations[&mean], p.relations[&mean]);
|
||||
assert_eq!(q.elements.len(), p.elements.len() + 2);
|
||||
let mut collision = r.clone();
|
||||
collision.mappings[1].target = Some(sa);
|
||||
assert!(
|
||||
!plan_transition(&p, &collision)
|
||||
.unwrap()
|
||||
.conflicts
|
||||
.is_empty()
|
||||
);
|
||||
let mut duplicate = r.clone();
|
||||
duplicate
|
||||
.communication_endpoints
|
||||
.push(r.communication_endpoints[0].clone());
|
||||
assert!(
|
||||
!plan_transition(&p, &duplicate)
|
||||
.unwrap()
|
||||
.conflicts
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn communication_reverse_transition_projects_owners_without_oa_ports() {
|
||||
let (p, mean, sa, sb, r) = transition_fixture();
|
||||
let q = apply_transition(&p, &plan_transition(&p, &r).unwrap()).unwrap();
|
||||
let boundary = q.traces.values().find(|t| t.oa == mean).unwrap().sa;
|
||||
let mut standalone = q.clone();
|
||||
standalone.traces.clear();
|
||||
let mut reverse = TransitionRequest::new(TransitionDirection::SaToOa, vec![boundary]);
|
||||
reverse.mappings = vec![
|
||||
TransitionMapping {
|
||||
source: sa,
|
||||
target: Some(p.relations[&mean].source()),
|
||||
target_kind: None,
|
||||
},
|
||||
TransitionMapping {
|
||||
source: sb,
|
||||
target: Some(p.relations[&mean].target()),
|
||||
target_kind: None,
|
||||
},
|
||||
];
|
||||
let plan = plan_transition(&standalone, &reverse).unwrap();
|
||||
assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts);
|
||||
let result = apply_transition(&standalone, &plan).unwrap();
|
||||
assert_eq!(result.elements, standalone.elements);
|
||||
let counterpart = &result.relations[&result
|
||||
.traces
|
||||
.values()
|
||||
.find(|t| t.sa == boundary)
|
||||
.unwrap()
|
||||
.oa];
|
||||
assert_eq!(counterpart.kind(), RelationKind::CommunicationMean);
|
||||
assert_eq!(
|
||||
counterpart.body.endpoints(),
|
||||
p.relations[&mean].body.endpoints()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn communication_semantic_endpoint_take_needs_scoped_decision_and_exact_history() {
|
||||
let (p, mean, _, _, r) = transition_fixture();
|
||||
let q = apply_transition(&p, &plan_transition(&p, &r).unwrap()).unwrap();
|
||||
let trace = q.traces.values().find(|t| t.oa == mean).unwrap();
|
||||
let boundary = trace.sa;
|
||||
let mut transition = TransitionRequest::new(TransitionDirection::OaToSa, vec![mean]);
|
||||
transition.mappings = r.mappings.clone();
|
||||
transition.mappings.push(TransitionMapping {
|
||||
source: mean,
|
||||
target: Some(boundary),
|
||||
target_kind: None,
|
||||
});
|
||||
let mut request = SemanticTransitionRequest::new(transition);
|
||||
request.references = r
|
||||
.mappings
|
||||
.iter()
|
||||
.map(|m| (m.source, m.target.unwrap()))
|
||||
.collect();
|
||||
request.reviews.push(SemanticReviewRequest {
|
||||
source: mean,
|
||||
resolutions: vec![FacetResolution {
|
||||
facet: FacetSelector::Relation(RelationFacet::Source),
|
||||
decision: SemanticDecision::TakeSource,
|
||||
}],
|
||||
reset: vec![],
|
||||
});
|
||||
assert!(
|
||||
!plan_semantic_transition(&q, request.clone())
|
||||
.unwrap()
|
||||
.conflicts
|
||||
.is_empty()
|
||||
);
|
||||
request
|
||||
.transition
|
||||
.communication_endpoints
|
||||
.push(CommunicationEndpointDecision {
|
||||
source: mean,
|
||||
source_port: CommunicationPortChoice::Reuse {
|
||||
port: q.relations[&boundary].source(),
|
||||
},
|
||||
target_port: CommunicationPortChoice::Reuse {
|
||||
port: q.relations[&boundary].target(),
|
||||
},
|
||||
});
|
||||
let plan = plan_semantic_transition(&q, request).unwrap();
|
||||
assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts);
|
||||
let result = apply_semantic_transition(&q, &plan).unwrap();
|
||||
let baseline = result.traces[&trace.id]
|
||||
.baseline
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.semantic
|
||||
.as_ref()
|
||||
.unwrap();
|
||||
assert!(baseline.facets.iter().any(|f|matches!(f,FacetReview::Relation(RelationReview::Source(Reviewed{oa:Observed::Value(a),sa:Observed::Value(b)})) if *a==p.relations[&mean].source()&&*b==q.relations[&boundary].source())));
|
||||
}
|
||||
@@ -0,0 +1,585 @@
|
||||
use oa_core::{
|
||||
language,
|
||||
source_project::*,
|
||||
validation::{self, Severity},
|
||||
*,
|
||||
};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
const CODES: [&str; 5] = [
|
||||
"non_leaf_work_exchange",
|
||||
"work_without_behavior_context",
|
||||
"exchange_without_behavior_context",
|
||||
"mission_without_capability",
|
||||
"capability_without_behavior_context",
|
||||
];
|
||||
type Located = (String, Id, Option<String>);
|
||||
fn coverage(p: &Project) -> BTreeSet<Located> {
|
||||
let issues = validation::validate(p);
|
||||
assert!(
|
||||
!issues.iter().any(|i| i.severity == Severity::Error),
|
||||
"{issues:?}"
|
||||
);
|
||||
issues
|
||||
.into_iter()
|
||||
.filter(|i| CODES.contains(&i.code.as_str()))
|
||||
.map(|i| {
|
||||
assert_eq!(i.severity, Severity::Warning);
|
||||
(i.code, i.element.unwrap(), i.field)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
fn located(code: &str, id: Id, field: &str) -> Located {
|
||||
(code.into(), id, (!field.is_empty()).then(|| field.into()))
|
||||
}
|
||||
fn add(p: &mut Project, kind: Kind, layer: Layer, name: &str) -> Id {
|
||||
p.add_element(kind, layer, name, None).unwrap()
|
||||
}
|
||||
fn occurrence(p: &mut Project, owner: Id, body: OccurrenceBody) -> Id {
|
||||
let id = p
|
||||
.add_element(
|
||||
Kind::Occurrence,
|
||||
p.elements[&owner].layer(),
|
||||
"Contribution",
|
||||
Some(owner),
|
||||
)
|
||||
.unwrap();
|
||||
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::Occurrence {
|
||||
body,
|
||||
precondition: String::new(),
|
||||
postcondition: String::new(),
|
||||
};
|
||||
if let Some(steps) = p.elements.get_mut(&owner).unwrap().steps_mut() {
|
||||
steps.push(id);
|
||||
}
|
||||
id
|
||||
}
|
||||
fn exchange(id: Id) -> OccurrenceBody {
|
||||
OccurrenceBody::Exchange {
|
||||
operation: None,
|
||||
exchange: Some(id),
|
||||
sender: None,
|
||||
receiver: None,
|
||||
message_kind: MessageKind::Asynchronous,
|
||||
reply_to: None,
|
||||
values: vec![],
|
||||
}
|
||||
}
|
||||
fn action(id: Id) -> OccurrenceBody {
|
||||
OccurrenceBody::Action {
|
||||
action: Some(id),
|
||||
lifeline: None,
|
||||
}
|
||||
}
|
||||
fn line(p: &mut Project, scenario: Id, work: Id) -> Id {
|
||||
let id = p
|
||||
.add_element(
|
||||
Kind::Lifeline,
|
||||
p.elements[&scenario].layer(),
|
||||
"Work line",
|
||||
Some(scenario),
|
||||
)
|
||||
.unwrap();
|
||||
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::Lifeline {
|
||||
represented: Some(work),
|
||||
};
|
||||
id
|
||||
}
|
||||
fn set_context(p: &mut Project, scenario: Id, capability: Id) {
|
||||
if let Some(SharedBody::Scenario { context, .. }) =
|
||||
p.elements.get_mut(&scenario).unwrap().shared_mut()
|
||||
{
|
||||
*context = Some(capability);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn included_process_and_nested_scenario_evidence_is_identity_based_in_both_layers() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let mut p = Project::empty();
|
||||
let (
|
||||
work_kind,
|
||||
sequence_kind,
|
||||
capability_kind,
|
||||
mission_kind,
|
||||
performer_kind,
|
||||
exchange_kind,
|
||||
) = if layer == Layer::Oa {
|
||||
(
|
||||
Kind::Activity,
|
||||
Kind::Process,
|
||||
Kind::Capability,
|
||||
Kind::OperationalMission,
|
||||
Kind::Entity,
|
||||
RelationKind::Interaction,
|
||||
)
|
||||
} else {
|
||||
(
|
||||
Kind::Function,
|
||||
Kind::FunctionalChain,
|
||||
Kind::SystemCapability,
|
||||
Kind::Mission,
|
||||
Kind::System,
|
||||
RelationKind::FunctionalExchange,
|
||||
)
|
||||
};
|
||||
let performer = add(&mut p, performer_kind, layer, "Service provider");
|
||||
let a = add(&mut p, work_kind, layer, "Request service");
|
||||
let b = add(&mut p, work_kind, layer, "Provide service");
|
||||
for work in [a, b] {
|
||||
p.relate(RelationKind::Allocation, "Performed work", performer, work);
|
||||
}
|
||||
let e = p.relate(exchange_kind, "Request", a, b);
|
||||
let inner = add(&mut p, sequence_kind, layer, "Service path");
|
||||
let outer = add(&mut p, sequence_kind, layer, "Reusable path");
|
||||
occurrence(&mut p, inner, exchange(e));
|
||||
occurrence(
|
||||
&mut p,
|
||||
outer,
|
||||
OccurrenceBody::SequenceReference {
|
||||
sequence: Some(inner),
|
||||
},
|
||||
);
|
||||
let capability = add(&mut p, capability_kind, layer, "Deliver service");
|
||||
p.relate(
|
||||
RelationKind::ChainInvolvement,
|
||||
"Service context",
|
||||
outer,
|
||||
capability,
|
||||
);
|
||||
let mission = add(&mut p, mission_kind, layer, "Serve users");
|
||||
p.relate(
|
||||
RelationKind::MissionCapability,
|
||||
"Delivered capability",
|
||||
mission,
|
||||
capability,
|
||||
);
|
||||
let scenario = add(&mut p, Kind::Scenario, layer, "Conversation");
|
||||
let sender = line(&mut p, scenario, a);
|
||||
let receiver = line(&mut p, scenario, b);
|
||||
let fragment = occurrence(
|
||||
&mut p,
|
||||
scenario,
|
||||
OccurrenceBody::Fragment {
|
||||
operator: FragmentOperator::Optional,
|
||||
branches: vec![],
|
||||
},
|
||||
);
|
||||
let branch = occurrence(
|
||||
&mut p,
|
||||
fragment,
|
||||
OccurrenceBody::Branch {
|
||||
guard: "service_requested".into(),
|
||||
occurrences: vec![],
|
||||
},
|
||||
);
|
||||
if let Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Fragment { branches, .. },
|
||||
..
|
||||
}) = p.elements.get_mut(&fragment).unwrap().shared_mut()
|
||||
{
|
||||
branches.push(branch);
|
||||
}
|
||||
let call = occurrence(
|
||||
&mut p,
|
||||
branch,
|
||||
OccurrenceBody::Exchange {
|
||||
operation: None,
|
||||
exchange: Some(e),
|
||||
sender: Some(sender),
|
||||
receiver: Some(receiver),
|
||||
message_kind: MessageKind::Call,
|
||||
reply_to: None,
|
||||
values: vec![],
|
||||
},
|
||||
);
|
||||
occurrence(
|
||||
&mut p,
|
||||
branch,
|
||||
OccurrenceBody::Exchange {
|
||||
operation: None,
|
||||
exchange: Some(e),
|
||||
sender: Some(receiver),
|
||||
receiver: Some(sender),
|
||||
message_kind: MessageKind::Reply,
|
||||
reply_to: Some(call),
|
||||
values: vec![],
|
||||
},
|
||||
);
|
||||
let reuse = add(&mut p, Kind::Scenario, layer, "Reused conversation");
|
||||
let outer_sender = line(&mut p, reuse, a);
|
||||
let outer_receiver = line(&mut p, reuse, b);
|
||||
occurrence(
|
||||
&mut p,
|
||||
reuse,
|
||||
OccurrenceBody::ScenarioUse {
|
||||
scenario: Some(scenario),
|
||||
bindings: vec![
|
||||
LifelineBinding {
|
||||
referenced: sender,
|
||||
local: outer_sender,
|
||||
},
|
||||
LifelineBinding {
|
||||
referenced: receiver,
|
||||
local: outer_receiver,
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
assert!(coverage(&p).is_empty());
|
||||
let inner_steps = std::mem::take(p.elements.get_mut(&inner).unwrap().steps_mut().unwrap());
|
||||
p.elements
|
||||
.get_mut(&scenario)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap()
|
||||
.clear();
|
||||
assert_eq!(
|
||||
coverage(&p),
|
||||
BTreeSet::from([
|
||||
located("work_without_behavior_context", a, ""),
|
||||
located("work_without_behavior_context", b, ""),
|
||||
located("exchange_without_behavior_context", e, ""),
|
||||
]),
|
||||
"Unordered fragments and reused containers cannot activate omitted messages"
|
||||
);
|
||||
*p.elements.get_mut(&inner).unwrap().steps_mut().unwrap() = inner_steps;
|
||||
p.elements
|
||||
.get_mut(&scenario)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap()
|
||||
.push(fragment);
|
||||
assert!(coverage(&p).is_empty());
|
||||
// Removing every message leaves lifelines and reuse intact: neither is
|
||||
// an execution claim. Empty sequences remain saveable, with old warnings.
|
||||
for element in p.elements.values_mut() {
|
||||
if let Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange { .. },
|
||||
..
|
||||
}) = element.shared()
|
||||
{
|
||||
*element.shared_mut().unwrap() = SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Action {
|
||||
action: None,
|
||||
lifeline: None,
|
||||
},
|
||||
precondition: String::new(),
|
||||
postcondition: String::new(),
|
||||
};
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
coverage(&p),
|
||||
BTreeSet::from([
|
||||
located("work_without_behavior_context", a, ""),
|
||||
located("work_without_behavior_context", b, ""),
|
||||
located("exchange_without_behavior_context", e, "")
|
||||
])
|
||||
);
|
||||
occurrence(&mut p, scenario, action(a)); // known work, unfinished lifeline
|
||||
assert_eq!(
|
||||
coverage(&p),
|
||||
BTreeSet::from([
|
||||
located("work_without_behavior_context", b, ""),
|
||||
located("exchange_without_behavior_context", e, "")
|
||||
])
|
||||
);
|
||||
assert_eq!(language::parse(&language::format(&p)).unwrap(), p);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn incomplete_oa_repairs_exact_records_and_ordered_membership_without_inference() {
|
||||
let mut p = Project::empty();
|
||||
let performer = add(&mut p, Kind::Entity, Layer::Oa, "Crew");
|
||||
let parent = add(&mut p, Kind::Activity, Layer::Oa, "Manage service");
|
||||
let a = p
|
||||
.add_element(Kind::Activity, Layer::Oa, "Prepare", Some(parent))
|
||||
.unwrap();
|
||||
let b = p
|
||||
.add_element(Kind::Activity, Layer::Oa, "Deliver", Some(parent))
|
||||
.unwrap();
|
||||
let c = add(&mut p, Kind::Activity, Layer::Oa, "Verify");
|
||||
for work in [a, b, c] {
|
||||
p.relate(RelationKind::Allocation, "Responsibility", performer, work);
|
||||
}
|
||||
let ab = p.relate(RelationKind::Interaction, "Prepared service", a, b);
|
||||
let pc = p.relate(RelationKind::Interaction, "Grouped service", parent, c);
|
||||
let ca = p.relate(RelationKind::Interaction, "Verified service", c, a);
|
||||
let process = add(&mut p, Kind::Process, Layer::Oa, "Delivery path");
|
||||
occurrence(&mut p, process, exchange(ab));
|
||||
let mission = add(
|
||||
&mut p,
|
||||
Kind::OperationalMission,
|
||||
Layer::Oa,
|
||||
"Deliver reliably",
|
||||
);
|
||||
let capability = add(&mut p, Kind::Capability, Layer::Oa, "Reliable delivery");
|
||||
p.relate(
|
||||
RelationKind::CapabilityInvolvement,
|
||||
"Crew support",
|
||||
performer,
|
||||
capability,
|
||||
);
|
||||
p.relate(
|
||||
RelationKind::MissionInvolvement,
|
||||
"Mission responsibility",
|
||||
performer,
|
||||
mission,
|
||||
);
|
||||
let machine = p
|
||||
.add_element(Kind::StateMachine, Layer::Oa, "Crew modes", Some(performer))
|
||||
.unwrap();
|
||||
let region = p
|
||||
.add_element(Kind::Region, Layer::Oa, "Crew region", Some(machine))
|
||||
.unwrap();
|
||||
let state = p
|
||||
.add_element(Kind::State, Layer::Oa, "Available", Some(region))
|
||||
.unwrap();
|
||||
p.relate(RelationKind::StateAction, "Verification entry", state, c);
|
||||
let observation = add(&mut p, Kind::Scenario, Layer::Oa, "Observation only");
|
||||
let observed = line(&mut p, observation, c);
|
||||
occurrence(
|
||||
&mut p,
|
||||
observation,
|
||||
OccurrenceBody::State {
|
||||
state: Some(state),
|
||||
lifeline: Some(observed),
|
||||
},
|
||||
);
|
||||
let expected = BTreeSet::from([
|
||||
located("non_leaf_work_exchange", pc, "source"),
|
||||
located("work_without_behavior_context", c, ""),
|
||||
located("exchange_without_behavior_context", ca, ""),
|
||||
located("mission_without_capability", mission, ""),
|
||||
located("capability_without_behavior_context", capability, ""),
|
||||
]);
|
||||
assert_eq!(coverage(&p), expected);
|
||||
let unordered = occurrence(&mut p, process, exchange(ca));
|
||||
p.elements
|
||||
.get_mut(&process)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap()
|
||||
.retain(|id| *id != unordered);
|
||||
assert_eq!(coverage(&p), expected);
|
||||
assert!(
|
||||
validation::validate(&p)
|
||||
.iter()
|
||||
.any(|i| i.element == Some(unordered) && i.code == "unordered_occurrence")
|
||||
);
|
||||
let before = p.clone();
|
||||
p.elements
|
||||
.get_mut(&process)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap()
|
||||
.push(unordered);
|
||||
if let RelationBody::Interaction { source, .. } = &mut p.relations.get_mut(&pc).unwrap().body {
|
||||
*source = a;
|
||||
}
|
||||
occurrence(&mut p, process, exchange(pc));
|
||||
p.relate(
|
||||
RelationKind::MissionCapability,
|
||||
"Mission delivery",
|
||||
mission,
|
||||
capability,
|
||||
);
|
||||
p.relate(
|
||||
RelationKind::ChainInvolvement,
|
||||
"Capability path",
|
||||
process,
|
||||
capability,
|
||||
);
|
||||
assert!(coverage(&p).is_empty());
|
||||
// Public session admission and undo retain the previous exact warning set.
|
||||
let mut session = Session::new(before.clone());
|
||||
session
|
||||
.execute(Command::Batch(diff_commands(&before, &p)))
|
||||
.unwrap();
|
||||
assert!(coverage(session.project()).is_empty());
|
||||
session.undo();
|
||||
assert_eq!(session.project(), &before);
|
||||
assert_eq!(coverage(session.project()), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_leaf_exchange_locations_use_work_endpoints_including_typed_sa_ports() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let mut p = Project::empty();
|
||||
let (kind, relation_kind) = if layer == Layer::Oa {
|
||||
(Kind::Activity, RelationKind::Interaction)
|
||||
} else {
|
||||
(Kind::Function, RelationKind::FunctionalExchange)
|
||||
};
|
||||
let source = add(&mut p, kind, layer, "Source group");
|
||||
let target = add(&mut p, kind, layer, "Target group");
|
||||
let a = p
|
||||
.add_element(kind, layer, "Source leaf", Some(source))
|
||||
.unwrap();
|
||||
let b = p
|
||||
.add_element(kind, layer, "Target leaf", Some(target))
|
||||
.unwrap();
|
||||
let edge = p.relate(relation_kind, "Decompose exchange", source, target);
|
||||
assert_eq!(
|
||||
coverage(&p),
|
||||
BTreeSet::from([
|
||||
located("non_leaf_work_exchange", edge, "source"),
|
||||
located("non_leaf_work_exchange", edge, "target"),
|
||||
located("work_without_behavior_context", a, ""),
|
||||
located("work_without_behavior_context", b, ""),
|
||||
])
|
||||
);
|
||||
if let RelationBody::FunctionalExchange {
|
||||
source_port,
|
||||
target_port,
|
||||
..
|
||||
} = p.relations[&edge].body
|
||||
{
|
||||
assert_eq!(p.elements[&source_port].parent, Some(source));
|
||||
assert_eq!(p.elements[&target_port].parent, Some(target));
|
||||
assert!(
|
||||
coverage(&p)
|
||||
.iter()
|
||||
.all(|(_, id, _)| *id != source_port && *id != target_port)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grouping_and_scenario_only_capabilities_do_not_duplicate_support_warnings() {
|
||||
let mut p = Project::empty();
|
||||
let parent = add(&mut p, Kind::Capability, Layer::Oa, "Capability group");
|
||||
let leaf = p
|
||||
.add_element(Kind::Capability, Layer::Oa, "Leaf capability", Some(parent))
|
||||
.unwrap();
|
||||
let issues = validation::validate(&p);
|
||||
assert!(
|
||||
issues
|
||||
.iter()
|
||||
.any(|i| i.element == Some(leaf) && i.code == "unsupported_capability")
|
||||
);
|
||||
assert!(
|
||||
!issues
|
||||
.iter()
|
||||
.any(|i| i.element == Some(parent) && i.code.contains("capability"))
|
||||
);
|
||||
assert!(coverage(&p).is_empty());
|
||||
let scenario = add(&mut p, Kind::Scenario, Layer::Oa, "Capability scenario");
|
||||
set_context(&mut p, scenario, leaf);
|
||||
let issues = validation::validate(&p);
|
||||
assert!(!issues.iter().any(|i| i.code == "unsupported_capability"));
|
||||
assert!(
|
||||
issues
|
||||
.iter()
|
||||
.any(|i| i.element == Some(scenario) && i.code == "empty_sequence")
|
||||
);
|
||||
assert!(coverage(&p).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn structural_errors_suppress_completeness_cascades_and_rejection_keeps_the_model() {
|
||||
let mut p = Project::empty();
|
||||
let a = add(&mut p, Kind::Activity, Layer::Oa, "Request");
|
||||
let b = add(&mut p, Kind::Activity, Layer::Oa, "Respond");
|
||||
let e = p.relate(RelationKind::Interaction, "Request data", a, b);
|
||||
let process = add(&mut p, Kind::Process, Layer::Oa, "Path");
|
||||
let step = occurrence(&mut p, process, exchange(e));
|
||||
assert!(coverage(&p).is_empty());
|
||||
let mut bad = p.clone();
|
||||
if let RelationBody::Interaction { source, .. } = &mut bad.relations.get_mut(&e).unwrap().body {
|
||||
*source = Id::new_v4();
|
||||
}
|
||||
let mut wrong_owner = p.clone();
|
||||
wrong_owner.elements.get_mut(&step).unwrap().parent = Some(a);
|
||||
let mut recursive = p.clone();
|
||||
let scenario = add(&mut recursive, Kind::Scenario, Layer::Oa, "Cycle");
|
||||
occurrence(
|
||||
&mut recursive,
|
||||
scenario,
|
||||
OccurrenceBody::ScenarioUse {
|
||||
scenario: Some(scenario),
|
||||
bindings: vec![],
|
||||
},
|
||||
);
|
||||
for (candidate, expected_error) in [
|
||||
(bad, "invalid_endpoints"),
|
||||
(wrong_owner, "invalid_occurrence"),
|
||||
(recursive, "recursive_scenario_use"),
|
||||
] {
|
||||
let issues = validation::validate(&candidate);
|
||||
assert!(
|
||||
issues
|
||||
.iter()
|
||||
.any(|i| i.severity == Severity::Error && i.code == expected_error),
|
||||
"{issues:?}"
|
||||
);
|
||||
assert!(
|
||||
!issues.iter().any(|i| CODES.contains(&i.code.as_str())),
|
||||
"{issues:?}"
|
||||
);
|
||||
let mut session = Session::new(p.clone());
|
||||
assert!(
|
||||
session
|
||||
.execute(Command::Batch(diff_commands(&p, &candidate)))
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(session.project(), &p);
|
||||
assert_eq!(session.revision(), 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multifile_lint_preserves_core_codes_ids_and_actual_owning_source_paths() {
|
||||
let source = SourceBundle { entry: "project.arcadia".into(), files: BTreeMap::from([
|
||||
("project.arcadia".into(), "arcadia 5; project p \"Coverage\" { include \"work.arcadia\"; include \"exchanges.arcadia\"; }".into()),
|
||||
("work.arcadia".into(), "oa_activity a \"Request\" {}\noa_activity b \"Respond\" {}".into()),
|
||||
("exchanges.arcadia".into(), "interaction e \"Data\" { source @a; target @b; }".into()),
|
||||
]) };
|
||||
let compiled = compile_bundle(&source, ProjectLimits::default()).unwrap();
|
||||
let expected = coverage(&compiled.decoded.project);
|
||||
assert_eq!(expected.len(), 3);
|
||||
let diagnostics: Vec<_> = lint_bundle(&compiled.bundle, ProjectLimits::default())
|
||||
.into_iter()
|
||||
.filter(|i| CODES.contains(&i.diagnostic.code.as_str()))
|
||||
.collect();
|
||||
let actual: BTreeSet<_> = diagnostics
|
||||
.iter()
|
||||
.map(|i| {
|
||||
assert_eq!(i.diagnostic.severity, Severity::Warning);
|
||||
let id = i.diagnostic.object_id.unwrap();
|
||||
assert_eq!(i.path, compiled.index.declarations[&id].path);
|
||||
assert!(i.diagnostic.span.end > i.diagnostic.span.start);
|
||||
(i.diagnostic.code.clone(), id, i.diagnostic.field.clone())
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(actual, expected);
|
||||
assert_eq!(diagnostics.len(), expected.len());
|
||||
}
|
||||
|
||||
// Authoring-equivalent complete record edits keep the test independent of a
|
||||
// private completeness helper and exercise actual transaction/undo boundaries.
|
||||
fn diff_commands(before: &Project, after: &Project) -> Vec<Command> {
|
||||
let mut commands = Vec::new();
|
||||
for (id, element) in &after.elements {
|
||||
match before.elements.get(id) {
|
||||
None => commands.push(Command::CreateElement(element.clone())),
|
||||
Some(old) if old != element => {
|
||||
commands.push(Command::EditElement(*id, element.clone()))
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
for (id, relation) in &after.relations {
|
||||
match before.relations.get(id) {
|
||||
None => commands.push(Command::CreateRelation(relation.clone())),
|
||||
Some(old) if old != relation => {
|
||||
commands.push(Command::EditRelation(*id, relation.clone()))
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
commands
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
//! Existing source-7 nested omission compatibility, independently of formatting.
|
||||
//!
|
||||
//! Selected constructors use their own field contracts. These handwritten
|
||||
//! sources and literal models distinguish nullable optional slots from required
|
||||
//! fields; they do not prescribe compact canonical output or broaden admission.
|
||||
|
||||
use oa_core::{source_project::*, *};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
fn id(number: u8) -> Id {
|
||||
Id::parse_str(&format!("00000000-0000-4000-8000-{number:012}")).unwrap()
|
||||
}
|
||||
|
||||
fn bundle(declarations: &str) -> SourceBundle {
|
||||
let source = format!(
|
||||
"arcadia 7;\nproject p \"Admission\" id \"00000000-0000-4000-8000-000000000001\" {{\n{declarations}\n}}\n"
|
||||
);
|
||||
SourceBundle {
|
||||
entry: "project.arcadia".into(),
|
||||
files: BTreeMap::from([("project.arcadia".into(), source)]),
|
||||
}
|
||||
}
|
||||
|
||||
fn element(number: u8, alias: &str, name: &str, parent: Option<Id>, body: ElementBody) -> Element {
|
||||
Element {
|
||||
id: id(number),
|
||||
alias: alias.into(),
|
||||
name: name.into(),
|
||||
description: String::new(),
|
||||
parent,
|
||||
body,
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_admitted(input: &SourceBundle, elements: Vec<Element>) -> Project {
|
||||
let expected = Project {
|
||||
schema_version: 7,
|
||||
id: id(1),
|
||||
alias: "p".into(),
|
||||
name: "Admission".into(),
|
||||
description: String::new(),
|
||||
elements: elements.into_iter().map(|e| (e.id, e)).collect(),
|
||||
relations: BTreeMap::new(),
|
||||
diagrams: BTreeMap::new(),
|
||||
artifact_links: BTreeMap::new(),
|
||||
traces: BTreeMap::new(),
|
||||
};
|
||||
let compiled = compile_bundle(input, ProjectLimits::default()).unwrap();
|
||||
assert_eq!(compiled.decoded.project, expected, "complete literal model");
|
||||
assert_eq!(
|
||||
compiled.bundle, *input,
|
||||
"fixed IDs require no source stabilization"
|
||||
);
|
||||
compiled.decoded.project
|
||||
}
|
||||
|
||||
fn prefix(layer: Layer) -> &'static str {
|
||||
match layer {
|
||||
Layer::Oa => "oa",
|
||||
Layer::Sa => "sa",
|
||||
}
|
||||
}
|
||||
|
||||
fn data_bundle(layer: Layer, definition: &str) -> SourceBundle {
|
||||
bundle(&format!(
|
||||
" {}_data_type subject \"Subject\" id \"00000000-0000-4000-8000-000000000002\" {{ definition {{ {definition}; }}; }}",
|
||||
prefix(layer)
|
||||
))
|
||||
}
|
||||
|
||||
fn admit_data(layer: Layer, source: &str, definition: DataTypeDefinition) -> Project {
|
||||
assert_admitted(
|
||||
&data_bundle(layer, source),
|
||||
vec![element(
|
||||
2,
|
||||
"subject",
|
||||
"Subject",
|
||||
None,
|
||||
ElementBody::shared_in(
|
||||
layer,
|
||||
SharedBody::DataType {
|
||||
definition,
|
||||
default: None,
|
||||
unit: String::new(),
|
||||
},
|
||||
),
|
||||
)],
|
||||
)
|
||||
}
|
||||
|
||||
fn assert_missing(input: &SourceBundle, field: &str) {
|
||||
let errors = compile_bundle(input, ProjectLimits::default()).unwrap_err();
|
||||
assert_eq!(
|
||||
errors.len(),
|
||||
1,
|
||||
"expect the targeted admission error: {errors:?}"
|
||||
);
|
||||
let diagnostic = &errors[0].diagnostic;
|
||||
assert_eq!(diagnostic.code, "invalid_source", "{errors:?}");
|
||||
assert!(
|
||||
diagnostic.message.to_lowercase().contains("missing") && diagnostic.message.contains(field),
|
||||
"must reject the missing constructor field {field}, not an unrelated model rule: {errors:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn integer_optional_bounds_can_be_omitted_individually_and_together() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
for (omitted, explicit, minimum, maximum) in [
|
||||
(
|
||||
"Integer {}",
|
||||
"Integer { minimum none; maximum none; }",
|
||||
None,
|
||||
None,
|
||||
),
|
||||
(
|
||||
"Integer { maximum 9; }",
|
||||
"Integer { minimum none; maximum 9; }",
|
||||
None,
|
||||
Some(9),
|
||||
),
|
||||
(
|
||||
"Integer { minimum -3; }",
|
||||
"Integer { minimum -3; maximum none; }",
|
||||
Some(-3),
|
||||
None,
|
||||
),
|
||||
] {
|
||||
let expected = DataTypeDefinition::Integer { minimum, maximum };
|
||||
assert_eq!(
|
||||
admit_data(layer, omitted, expected.clone()),
|
||||
admit_data(layer, explicit, expected),
|
||||
"omitted and explicit optional Integer bounds agree"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn real_optional_bounds_can_be_omitted_individually_and_together() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
for (omitted, explicit, minimum, maximum) in [
|
||||
(
|
||||
"Real {}",
|
||||
"Real { minimum none; maximum none; }",
|
||||
None,
|
||||
None,
|
||||
),
|
||||
(
|
||||
"Real { maximum 2.5; }",
|
||||
"Real { minimum none; maximum 2.5; }",
|
||||
None,
|
||||
Some(2.5),
|
||||
),
|
||||
(
|
||||
"Real { minimum -1.25; }",
|
||||
"Real { minimum -1.25; maximum none; }",
|
||||
Some(-1.25),
|
||||
None,
|
||||
),
|
||||
] {
|
||||
let expected = DataTypeDefinition::Real { minimum, maximum };
|
||||
assert_eq!(
|
||||
admit_data(layer, omitted, expected.clone()),
|
||||
admit_data(layer, explicit, expected),
|
||||
"omitted and explicit optional Real bounds agree"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn string_optional_maximum_can_be_omitted_without_resetting_required_minimum() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let expected = DataTypeDefinition::String {
|
||||
minimum_length: 4,
|
||||
maximum_length: None,
|
||||
};
|
||||
assert_eq!(
|
||||
admit_data(layer, "String { minimum_length 4; }", expected.clone()),
|
||||
admit_data(
|
||||
layer,
|
||||
"String { minimum_length 4; maximum_length none; }",
|
||||
expected,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicitly_selected_string_requires_minimum_even_when_whole_definition_has_a_default() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
assert_missing(&data_bundle(layer, "String {}"), "minimum_length");
|
||||
assert_missing(
|
||||
&data_bundle(layer, "String { maximum_length none; }"),
|
||||
"minimum_length",
|
||||
);
|
||||
admit_data(
|
||||
layer,
|
||||
"String { minimum_length 0; }",
|
||||
DataTypeDefinition::String {
|
||||
minimum_length: 0,
|
||||
maximum_length: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absent_numeric_maximum_is_distinct_from_an_explicit_zero_bound() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let unbounded = admit_data(
|
||||
layer,
|
||||
"Integer { minimum -3; }",
|
||||
DataTypeDefinition::Integer {
|
||||
minimum: Some(-3),
|
||||
maximum: None,
|
||||
},
|
||||
);
|
||||
let bounded = admit_data(
|
||||
layer,
|
||||
"Integer { minimum -3; maximum 0; }",
|
||||
DataTypeDefinition::Integer {
|
||||
minimum: Some(-3),
|
||||
maximum: Some(0),
|
||||
},
|
||||
);
|
||||
assert_ne!(unbounded, bounded, "zero is a bound, not omitted syntax");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn record_has_no_required_payload_field_and_omitted_supertype_means_none() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let expected = DataTypeDefinition::Record { supertype: None };
|
||||
assert_eq!(
|
||||
admit_data(layer, "Record {}", expected.clone()),
|
||||
admit_data(layer, "Record { supertype none; }", expected),
|
||||
);
|
||||
let errors = compile_bundle(
|
||||
&data_bundle(layer, "Record { fields []; }"),
|
||||
ProjectLimits::default(),
|
||||
)
|
||||
.unwrap_err();
|
||||
assert_eq!(errors.len(), 1, "{errors:?}");
|
||||
assert_eq!(errors[0].diagnostic.code, "invalid_source", "{errors:?}");
|
||||
assert!(
|
||||
errors[0]
|
||||
.diagnostic
|
||||
.message
|
||||
.contains("unknown field `fields`"),
|
||||
"Record remains a closed constructor: {errors:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enumeration_still_requires_its_list_even_when_empty_list_is_admitted() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
assert_missing(&data_bundle(layer, "Enumeration {}"), "literals");
|
||||
admit_data(
|
||||
layer,
|
||||
"Enumeration { literals []; }",
|
||||
DataTypeDefinition::Enumeration { literals: vec![] },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn port_bundle(layer: Layer, fields: &str) -> SourceBundle {
|
||||
let owner_kind = match layer {
|
||||
Layer::Oa => "oa_entity",
|
||||
Layer::Sa => "sa_system",
|
||||
};
|
||||
bundle(&format!(
|
||||
" {owner_kind} owner \"Owner\" id \"00000000-0000-4000-8000-000000000002\" {{}}\n {}_port subject \"Subject\" id \"00000000-0000-4000-8000-000000000003\" {{ parent @owner; {fields} }}",
|
||||
prefix(layer)
|
||||
))
|
||||
}
|
||||
|
||||
fn admit_port(layer: Layer, fields: &str) -> Project {
|
||||
let owner_body = match layer {
|
||||
Layer::Oa => ElementBody::Oa(OaBody::Entity),
|
||||
Layer::Sa => ElementBody::Sa(SaBody::System),
|
||||
};
|
||||
assert_admitted(
|
||||
&port_bundle(layer, fields),
|
||||
vec![
|
||||
element(2, "owner", "Owner", None, owner_body),
|
||||
element(
|
||||
3,
|
||||
"subject",
|
||||
"Subject",
|
||||
Some(id(2)),
|
||||
ElementBody::shared_in(
|
||||
layer,
|
||||
SharedBody::Port {
|
||||
contract: PortContract::Legacy,
|
||||
legacy: Some(LegacyPortContract {
|
||||
direction: PortDirection::Out,
|
||||
interface: None,
|
||||
}),
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_port_interface_can_be_omitted_but_preserves_direction() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
assert_eq!(
|
||||
admit_port(layer, "contract Legacy; legacy { direction Out; };"),
|
||||
admit_port(
|
||||
layer,
|
||||
"contract Legacy; legacy { direction Out; interface none; };"
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_port_direction_remains_required_inside_selected_record() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
assert_missing(
|
||||
&port_bundle(layer, "contract Legacy; legacy {};"),
|
||||
"direction",
|
||||
);
|
||||
assert_missing(
|
||||
&port_bundle(layer, "contract Legacy; legacy { interface none; };"),
|
||||
"direction",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn port_declaration_still_requires_explicit_contract() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
assert_missing(
|
||||
&port_bundle(layer, "legacy { direction Out; };"),
|
||||
"contract",
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,445 @@
|
||||
//! Public flat/modular formatter fidelity over frozen input and bounded mutations.
|
||||
//! Legacy fixture migration uses the public parser. The original adapter tests
|
||||
//! and exact inputs remain archived at source commit 2a64344; this suite exercises
|
||||
//! production formatters, not that frozen experiment or lossless model edits.
|
||||
|
||||
use oa_core::{language, source_project::*, *};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
const COASTAL: &str = include_str!("../../../examples/coastal-rescue.arcadia");
|
||||
const KEYED: &str =
|
||||
include_str!("../../../harness/fixtures/release1-native/semantic-keyed.arcadia");
|
||||
const STATES: &str =
|
||||
include_str!("../../../harness/fixtures/release1-native/state-actions.arcadia");
|
||||
const DENSE: &str =
|
||||
include_str!("../../../harness/fixtures/release1-native/dense-40/dense-40.arcadia");
|
||||
const OPERATIONS: &str = include_str!(
|
||||
"../../../harness/fixtures/interface-operation-probe/output/boundary-only.arcadia"
|
||||
);
|
||||
|
||||
fn read(source: &str) -> Project {
|
||||
language::parse(source).unwrap()
|
||||
}
|
||||
|
||||
fn identity(project: &Project, alias: &str) -> Id {
|
||||
project
|
||||
.identities()
|
||||
.into_iter()
|
||||
.find(|(_, name)| *name == alias)
|
||||
.unwrap()
|
||||
.0
|
||||
}
|
||||
|
||||
fn assert_common_omissions(project: &Project, compiled: &CompiledSourceProject) {
|
||||
if project.description.is_empty() {
|
||||
assert!(compiled.index.project_description.is_none());
|
||||
}
|
||||
let descriptions = project
|
||||
.elements
|
||||
.values()
|
||||
.map(|e| (e.id, &e.description))
|
||||
.chain(project.relations.values().map(|r| (r.id, &r.description)))
|
||||
.chain(project.diagrams.values().map(|d| (d.id, &d.description)))
|
||||
.chain(
|
||||
project
|
||||
.artifact_links
|
||||
.values()
|
||||
.map(|a| (a.id, &a.description)),
|
||||
)
|
||||
.chain(project.traces.values().map(|t| (t.id, &t.description)));
|
||||
for (id, description) in descriptions {
|
||||
if description.is_empty() {
|
||||
assert!(
|
||||
!compiled
|
||||
.index
|
||||
.fields
|
||||
.contains_key(&(id, "description".into()))
|
||||
);
|
||||
}
|
||||
}
|
||||
for element in project.elements.values().filter(|e| e.parent.is_none()) {
|
||||
assert!(
|
||||
!compiled
|
||||
.index
|
||||
.fields
|
||||
.contains_key(&(element.id, "parent".into()))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_fidelity(label: &str, project: &Project) -> (Project, String) {
|
||||
let flat = language::format(project);
|
||||
let compact_model = read(&flat);
|
||||
assert_eq!(
|
||||
compact_model, *project,
|
||||
"{label}: every persistent field survives"
|
||||
);
|
||||
|
||||
let input = SourceBundle {
|
||||
entry: "project.arcadia".into(),
|
||||
files: BTreeMap::from([("project.arcadia".into(), flat.clone())]),
|
||||
};
|
||||
let compiled = compile_bundle(&input, ProjectLimits::default()).unwrap();
|
||||
assert_eq!(
|
||||
compiled.decoded.project, *project,
|
||||
"{label}: source-project compile"
|
||||
);
|
||||
assert_eq!(
|
||||
compiled.bundle, input,
|
||||
"{label}: compact identities/source are stable"
|
||||
);
|
||||
|
||||
assert_common_omissions(project, &compiled);
|
||||
assert_eq!(
|
||||
language::format(&compact_model),
|
||||
flat,
|
||||
"{label}: flat idempotence"
|
||||
);
|
||||
|
||||
let modular = format_bundle(project);
|
||||
assert!(
|
||||
modular.files.len() > 1,
|
||||
"{label}: exercise generated includes"
|
||||
);
|
||||
let modular_compiled = compile_bundle(&modular, ProjectLimits::default()).unwrap();
|
||||
assert_eq!(
|
||||
modular_compiled.decoded.project, *project,
|
||||
"{label}: complete modular model"
|
||||
);
|
||||
assert_eq!(
|
||||
modular_compiled.bundle, modular,
|
||||
"{label}: modular source/IDs are stable"
|
||||
);
|
||||
assert_common_omissions(project, &modular_compiled);
|
||||
assert_eq!(
|
||||
format_bundle(&compact_model),
|
||||
modular,
|
||||
"{label}: flat-to-modular determinism"
|
||||
);
|
||||
assert_eq!(
|
||||
format_bundle(&modular_compiled.decoded.project),
|
||||
modular,
|
||||
"{label}: modular idempotence"
|
||||
);
|
||||
|
||||
// Pinned measured compact bytes from the published fidelity-1 stderr.log at
|
||||
// 2a64344, not a comparison against today's (now compact) canonical writer.
|
||||
// A small header-layout allowance avoids enforcing incidental blank lines.
|
||||
let measured_compact_bytes = match label {
|
||||
"coastal-source3" => 28_465,
|
||||
"keyed-source5-history" => 12_066,
|
||||
"state-actions-source5" => 6_673,
|
||||
"dense40-source6" => 19_342,
|
||||
"boundary-operations-source6" => 3_660,
|
||||
"state-negative-zero" => 6_672,
|
||||
"keyed-present-empty-default" => 12_108,
|
||||
"coastal-present-empty-artifact-strings" => 28_483,
|
||||
"boundary-selected-and-unresolved-operations" => 3_806,
|
||||
_ => panic!("missing independent compact measurement for {label}"),
|
||||
};
|
||||
assert!(
|
||||
flat.len() <= measured_compact_bytes + 16,
|
||||
"{label}: {} bytes exceeds compact evidence budget {}",
|
||||
flat.len(),
|
||||
measured_compact_bytes + 16
|
||||
);
|
||||
(compact_model, flat)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migrated_coastal_oa_sa_views_ports_artifacts_and_traces_roundtrip() {
|
||||
assert!(COASTAL.starts_with("arcadia 3;"));
|
||||
let project = read(COASTAL);
|
||||
assert!(project.elements.values().any(|e| e.layer() == Layer::Oa));
|
||||
assert!(project.elements.values().any(|e| e.layer() == Layer::Sa));
|
||||
assert!(
|
||||
project
|
||||
.elements
|
||||
.values()
|
||||
.any(|e| matches!(e.shared(), Some(SharedBody::Port { .. })))
|
||||
);
|
||||
assert!(project.elements.values().any(
|
||||
|e| matches!(e.shared(), Some(SharedBody::Scenario { steps, .. }) if !steps.is_empty())
|
||||
));
|
||||
assert!(project.diagrams.values().any(|d| d.layer == Layer::Oa
|
||||
&& d.projection == ProjectionKind::FunctionalDataflow
|
||||
&& !d.nodes.is_empty()));
|
||||
assert!(project.diagrams.values().any(|d| d.layer == Layer::Sa
|
||||
&& d.projection == ProjectionKind::Allocation
|
||||
&& !d.nodes.is_empty()));
|
||||
assert!(!project.artifact_links.is_empty());
|
||||
assert!(project.traces.values().any(|t| t.baseline.is_some()));
|
||||
assert_fidelity("coastal-source3", &project);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keyed_scenarios_records_and_historical_binding_annotation_facets_roundtrip() {
|
||||
let project = read(KEYED);
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
assert!(project.elements.values().any(|e| e.layer() == layer
|
||||
&& matches!(
|
||||
e.shared(),
|
||||
Some(SharedBody::DataType {
|
||||
definition: DataTypeDefinition::Record { .. },
|
||||
..
|
||||
})
|
||||
)));
|
||||
assert!(project.elements.values().any(|e| e.layer() == layer && matches!(e.shared(), Some(SharedBody::Occurrence { body: OccurrenceBody::ScenarioUse { bindings, .. }, .. }) if !bindings.is_empty())));
|
||||
}
|
||||
assert!(project.elements.values().any(|e| matches!(e.shared(), Some(SharedBody::Occurrence { body: OccurrenceBody::Exchange { values, .. }, .. }) if values.iter().any(|v| !v.path.is_empty() && !v.expression.is_empty()))));
|
||||
let facets: Vec<_> = project
|
||||
.traces
|
||||
.values()
|
||||
.filter_map(|t| t.baseline.as_ref()?.semantic.as_ref())
|
||||
.flat_map(|baseline| &baseline.facets)
|
||||
.collect();
|
||||
assert!(
|
||||
facets
|
||||
.iter()
|
||||
.any(|facet| matches!(facet, semantic::FacetReview::Binding { .. }))
|
||||
);
|
||||
assert!(
|
||||
facets
|
||||
.iter()
|
||||
.any(|facet| matches!(facet, semantic::FacetReview::Annotation { .. }))
|
||||
);
|
||||
assert!(
|
||||
KEYED.contains("uuid \""),
|
||||
"fixture contains historical identity atoms"
|
||||
);
|
||||
let (_, source) = assert_fidelity("keyed-source5-history", &project);
|
||||
assert!(
|
||||
source.contains("uuid \""),
|
||||
"historical atoms remain historical"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_machine_projection_typed_actions_and_bounded_integer_roundtrip() {
|
||||
let project = read(STATES);
|
||||
let machine = identity(&project, "operating");
|
||||
let view = &project.diagrams[&identity(&project, "state_view")];
|
||||
assert_eq!(view.projection, ProjectionKind::StateMachine { machine });
|
||||
assert!(!view.nodes.is_empty());
|
||||
for phase in [
|
||||
StateActionKind::Entry,
|
||||
StateActionKind::Do,
|
||||
StateActionKind::Exit,
|
||||
] {
|
||||
assert!(project.relations.values().any(|r| matches!(&r.body, RelationBody::StateAction { phase: actual, .. } if *actual == phase)));
|
||||
}
|
||||
assert!(project.relations.values().any(|r| matches!(&r.body, RelationBody::StateTransition { transition_kind: TransitionKind::Internal, triggers, effects, .. } if !triggers.is_empty() && !effects.is_empty())));
|
||||
assert!(
|
||||
matches!(project.elements[&identity(&project, "retry_budget")].shared(), Some(SharedBody::DataType { definition: DataTypeDefinition::Integer { minimum: Some(0), maximum: Some(4) }, unit, .. }) if unit == "attempts")
|
||||
);
|
||||
assert_fidelity("state-actions-source5", &project);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forty_exchange_diagram_with_categories_and_processes_roundtrip() {
|
||||
let project = read(DENSE);
|
||||
assert_eq!(
|
||||
project
|
||||
.relations
|
||||
.values()
|
||||
.filter(|r| r.kind() == RelationKind::Interaction)
|
||||
.count(),
|
||||
40
|
||||
);
|
||||
assert_eq!(
|
||||
project
|
||||
.elements
|
||||
.values()
|
||||
.filter(|e| e.kind() == Kind::ExchangeCategory)
|
||||
.count(),
|
||||
5
|
||||
);
|
||||
assert_eq!(
|
||||
project
|
||||
.elements
|
||||
.values()
|
||||
.filter(|e| e.kind() == Kind::Process)
|
||||
.count(),
|
||||
3
|
||||
);
|
||||
let view = &project.diagrams[&identity(&project, "forty_exchange_view")];
|
||||
assert_eq!(view.projection, ProjectionKind::FunctionalDataflow);
|
||||
assert_eq!(view.nodes.len(), 2);
|
||||
assert!(view.routes.is_empty());
|
||||
assert_eq!(
|
||||
view.view_options.relation_kinds,
|
||||
view_options::RelationKinds::All
|
||||
);
|
||||
assert_fidelity("dense40-source6", &project);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn component_ports_interface_operations_and_boundary_calls_roundtrip() {
|
||||
let project = read(OPERATIONS);
|
||||
assert_eq!(
|
||||
project
|
||||
.relations
|
||||
.values()
|
||||
.filter(|r| r.kind() == RelationKind::InterfaceItemAllocation)
|
||||
.count(),
|
||||
2
|
||||
);
|
||||
assert_eq!(
|
||||
project
|
||||
.elements
|
||||
.values()
|
||||
.filter(|e| matches!(
|
||||
e.shared(),
|
||||
Some(SharedBody::Port {
|
||||
contract: PortContract::Component {
|
||||
kind: ComponentPortKind::Standard,
|
||||
..
|
||||
},
|
||||
..
|
||||
})
|
||||
))
|
||||
.count(),
|
||||
2
|
||||
);
|
||||
assert!(project.elements.values().any(|e| matches!(
|
||||
e.shared(),
|
||||
Some(SharedBody::ExchangeItem {
|
||||
mechanism: ExchangeMechanism::Operation,
|
||||
..
|
||||
})
|
||||
)));
|
||||
assert!(project.elements.values().any(|e| matches!(e.shared(), Some(SharedBody::Scenario { viewpoint: ScenarioViewpoint::BoundaryInterface, steps, .. }) if steps.len() == 2)));
|
||||
assert_eq!(
|
||||
project
|
||||
.elements
|
||||
.values()
|
||||
.filter(|e| matches!(
|
||||
e.shared(),
|
||||
Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange {
|
||||
message_kind: MessageKind::Call,
|
||||
operation: None,
|
||||
..
|
||||
},
|
||||
..
|
||||
})
|
||||
))
|
||||
.count(),
|
||||
2
|
||||
);
|
||||
assert_fidelity("boundary-operations-source6", &project);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn negative_zero_node_coordinate_survives_canonical_compact_and_bundle_compile() {
|
||||
let mut project = read(STATES);
|
||||
let diagram = identity(&project, "state_view");
|
||||
let node = identity(&project, "view_machine");
|
||||
project
|
||||
.diagrams
|
||||
.get_mut(&diagram)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&node)
|
||||
.unwrap()
|
||||
.position = Position { x: -0.0, y: 0.0 };
|
||||
let (decoded, flat) = assert_fidelity("state-negative-zero", &project);
|
||||
let bundle = SourceBundle {
|
||||
entry: "project.arcadia".into(),
|
||||
files: BTreeMap::from([("project.arcadia".into(), flat)]),
|
||||
};
|
||||
let compiled = compile_bundle(&bundle, ProjectLimits::default()).unwrap();
|
||||
let modular = compile_bundle(&format_bundle(&project), ProjectLimits::default()).unwrap();
|
||||
// Project/Position PartialEq alone equates the signs of zero. Check bits in
|
||||
// each independently decoded representation, including source-project compile.
|
||||
for actual in [
|
||||
&decoded,
|
||||
&compiled.decoded.project,
|
||||
&modular.decoded.project,
|
||||
] {
|
||||
let position = &actual.diagrams[&diagram].nodes[&node].position;
|
||||
assert_eq!(position.x.to_bits(), (-0.0_f32).to_bits());
|
||||
assert_eq!(position.y.to_bits(), 0.0_f32.to_bits());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn present_empty_field_default_and_unbounded_maximum_remain_distinct_from_omission() {
|
||||
let mut project = read(KEYED);
|
||||
let field_id = identity(&project, "oa_navigation_latitude");
|
||||
let Some(SharedBody::DataField {
|
||||
minimum,
|
||||
maximum,
|
||||
default,
|
||||
storage,
|
||||
..
|
||||
}) = project.elements.get_mut(&field_id).unwrap().shared_mut()
|
||||
else {
|
||||
panic!("fixture must contain the claimed DataField");
|
||||
};
|
||||
assert_eq!(*minimum, 0);
|
||||
assert_eq!(*maximum, Some(1));
|
||||
assert_eq!(*default, None);
|
||||
assert_eq!(*storage, FieldStorage::Value);
|
||||
*maximum = None;
|
||||
*default = Some(vec![]);
|
||||
let (decoded, _) = assert_fidelity("keyed-present-empty-default", &project);
|
||||
assert!(
|
||||
matches!(decoded.elements[&field_id].shared(), Some(SharedBody::DataField { maximum: None, default: Some(values), .. }) if values.is_empty())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn present_empty_artifact_revision_and_fragment_survive() {
|
||||
let mut project = read(COASTAL);
|
||||
let artifact_id = identity(&project, "console_enclosure_cad");
|
||||
let artifact = project.artifact_links.get_mut(&artifact_id).unwrap();
|
||||
artifact.revision = Some(String::new());
|
||||
artifact.fragment = Some(String::new());
|
||||
let (decoded, _) = assert_fidelity("coastal-present-empty-artifact-strings", &project);
|
||||
assert_eq!(
|
||||
decoded.artifact_links[&artifact_id].revision.as_deref(),
|
||||
Some("")
|
||||
);
|
||||
assert_eq!(
|
||||
decoded.artifact_links[&artifact_id].fragment.as_deref(),
|
||||
Some("")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_and_unresolved_operation_intent_survive_as_distinct_present_records() {
|
||||
let mut project = read(OPERATIONS);
|
||||
let allocation = identity(&project, "operation_probe_30");
|
||||
assert_eq!(
|
||||
project.relations[&allocation].kind(),
|
||||
RelationKind::InterfaceItemAllocation
|
||||
);
|
||||
let selected = identity(&project, "operation_probe_43");
|
||||
let unresolved = identity(&project, "operation_probe_44");
|
||||
for (subject, value) in [(selected, Some(allocation)), (unresolved, None)] {
|
||||
let Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange { operation, .. },
|
||||
..
|
||||
}) = project.elements.get_mut(&subject).unwrap().shared_mut()
|
||||
else {
|
||||
panic!("fixture must contain a Call Exchange");
|
||||
};
|
||||
assert_eq!(*operation, None);
|
||||
*operation = Some(OperationUse { allocation: value });
|
||||
}
|
||||
let (decoded, _) = assert_fidelity("boundary-selected-and-unresolved-operations", &project);
|
||||
assert!(
|
||||
matches!(decoded.elements[&selected].shared(), Some(SharedBody::Occurrence { body: OccurrenceBody::Exchange { operation: Some(OperationUse { allocation: Some(actual) }), .. }, .. }) if *actual == allocation)
|
||||
);
|
||||
assert!(matches!(
|
||||
decoded.elements[&unresolved].shared(),
|
||||
Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange {
|
||||
operation: Some(OperationUse { allocation: None }),
|
||||
..
|
||||
},
|
||||
..
|
||||
})
|
||||
));
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
//! Focused public-output contracts. Field locations come from the real compiler;
|
||||
//! token comparisons ignore layout while retaining typed tags and required syntax.
|
||||
use oa_core::{language, source_project::*, *};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
fn project(declarations: &str) -> Project {
|
||||
language::parse(&format!(
|
||||
"arcadia 7; project p \"P\" id \"00000000-0000-4000-8000-000000000001\" {{ {declarations} }}"
|
||||
)).unwrap()
|
||||
}
|
||||
|
||||
fn exports(project: &Project) -> Vec<CompiledSourceProject> {
|
||||
let flat = language::format(project);
|
||||
assert_eq!(language::parse(&flat).unwrap(), *project);
|
||||
let flat_bundle = SourceBundle {
|
||||
entry: "project.arcadia".into(),
|
||||
files: BTreeMap::from([("project.arcadia".into(), flat)]),
|
||||
};
|
||||
[flat_bundle, format_bundle(project)]
|
||||
.into_iter()
|
||||
.map(|bundle| {
|
||||
let compiled = compile_bundle(&bundle, ProjectLimits::default()).unwrap();
|
||||
assert_eq!(compiled.decoded.project, *project);
|
||||
assert_eq!(
|
||||
compiled.bundle, bundle,
|
||||
"all identities are explicit and stable"
|
||||
);
|
||||
compiled
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn identity(project: &Project, alias: &str) -> Id {
|
||||
project
|
||||
.identities()
|
||||
.into_iter()
|
||||
.find(|(_, name)| *name == alias)
|
||||
.unwrap()
|
||||
.0
|
||||
}
|
||||
|
||||
fn field(compiled: &CompiledSourceProject, alias: &str, name: &str) -> Option<String> {
|
||||
let location = compiled
|
||||
.index
|
||||
.fields
|
||||
.get(&(identity(&compiled.decoded.project, alias), name.into()))?;
|
||||
let text = &compiled.bundle.files[&location.path][location.span.start..location.span.end];
|
||||
Some(
|
||||
language::tokenize(text)
|
||||
.into_iter()
|
||||
.filter(|token| {
|
||||
!matches!(
|
||||
token.class,
|
||||
language::TokenClass::Whitespace | language::TokenClass::Comment
|
||||
)
|
||||
})
|
||||
.map(|token| &text[token.span.start..token.span.end])
|
||||
.collect::<Vec<_>>()
|
||||
.join(" "),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_project_has_no_synthetic_description_or_module() {
|
||||
let empty = project("");
|
||||
for compiled in exports(&empty) {
|
||||
assert!(compiled.index.project_description.is_none());
|
||||
assert!(compiled.index.fields.is_empty());
|
||||
assert!(compiled.index.includes.is_empty());
|
||||
assert_eq!(compiled.bundle.files.len(), 1);
|
||||
assert_eq!(compiled.index.declarations.len(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_empty_descriptions_disappear_whitespace_and_text_survive_exactly() {
|
||||
let mut model =
|
||||
project("oa_activity work \"Work\" id \"00000000-0000-4000-8000-000000000002\" {}");
|
||||
let work = identity(&model, "work");
|
||||
for description in ["", " \t\n", "A quoted \"requirement\" — 雨"] {
|
||||
model.description = description.into();
|
||||
model.elements.get_mut(&work).unwrap().description = description.into();
|
||||
for compiled in exports(&model) {
|
||||
assert_eq!(
|
||||
compiled.index.project_description.is_some(),
|
||||
!description.is_empty()
|
||||
);
|
||||
assert_eq!(
|
||||
field(&compiled, "work", "description").is_some(),
|
||||
!description.is_empty()
|
||||
);
|
||||
assert!(field(&compiled, "work", "parent").is_none());
|
||||
// exports compares the complete model, including exact whitespace
|
||||
// and escapes in both project and element descriptions.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sa_view_keeps_its_layer_and_node_references_but_omits_presentation_defaults() {
|
||||
let model = project(
|
||||
r#"
|
||||
sa_function work "Work" id "00000000-0000-4000-8000-000000000002" {}
|
||||
diagram view "View" id "00000000-0000-4000-8000-000000000003" { layer Sa; }
|
||||
node placement id "00000000-0000-4000-8000-000000000004" {
|
||||
diagram @view; element @work;
|
||||
position { x 0.0; y 0.0; }; size { width 224.0; height 94.0; };
|
||||
collapsed false; port_anchors [];
|
||||
}
|
||||
"#,
|
||||
);
|
||||
for compiled in exports(&model) {
|
||||
assert_eq!(field(&compiled, "view", "layer").as_deref(), Some("Sa"));
|
||||
for name in ["description", "projection", "routes", "view_options"] {
|
||||
assert!(field(&compiled, "view", name).is_none(), "view.{name}");
|
||||
}
|
||||
assert_eq!(
|
||||
field(&compiled, "placement", "diagram").as_deref(),
|
||||
Some("@ view")
|
||||
);
|
||||
assert_eq!(
|
||||
field(&compiled, "placement", "element").as_deref(),
|
||||
Some("@ work")
|
||||
);
|
||||
for name in ["position", "size", "collapsed", "port_anchors"] {
|
||||
assert!(
|
||||
field(&compiled, "placement", name).is_none(),
|
||||
"placement.{name}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_type_defaults_disappear_without_losing_required_or_present_empty_values() {
|
||||
let model = project(
|
||||
r#"
|
||||
sa_data_type implicit "Implicit" id "00000000-0000-4000-8000-000000000002" {}
|
||||
sa_data_type integer "Integer" id "00000000-0000-4000-8000-000000000003" {
|
||||
definition { Integer { minimum none; maximum none; }; };
|
||||
}
|
||||
sa_data_type text "Text" id "00000000-0000-4000-8000-000000000004" {
|
||||
definition { String { minimum_length 4; maximum_length none; }; };
|
||||
}
|
||||
sa_data_type record "Record" id "00000000-0000-4000-8000-000000000005" {
|
||||
definition { Record { supertype none; }; };
|
||||
}
|
||||
sa_data_type flag "Flag" id "00000000-0000-4000-8000-000000000006" {
|
||||
definition Boolean; default { Boolean false; };
|
||||
}
|
||||
sa_data_field optional "Optional" id "00000000-0000-4000-8000-000000000007" {
|
||||
parent @record; minimum 0; maximum none; default [];
|
||||
}
|
||||
"#,
|
||||
);
|
||||
for compiled in exports(&model) {
|
||||
assert!(field(&compiled, "implicit", "definition").is_none());
|
||||
assert_eq!(
|
||||
field(&compiled, "integer", "definition").as_deref(),
|
||||
Some("{ Integer { } ; }")
|
||||
);
|
||||
assert_eq!(
|
||||
field(&compiled, "text", "definition").as_deref(),
|
||||
Some("{ String { minimum_length 4 ; } ; }")
|
||||
);
|
||||
assert_eq!(
|
||||
field(&compiled, "record", "definition").as_deref(),
|
||||
Some("{ Record { } ; }")
|
||||
);
|
||||
assert_eq!(
|
||||
field(&compiled, "flag", "definition").as_deref(),
|
||||
Some("Boolean")
|
||||
);
|
||||
assert_eq!(
|
||||
field(&compiled, "flag", "default").as_deref(),
|
||||
Some("{ Boolean false ; }")
|
||||
);
|
||||
assert_eq!(
|
||||
field(&compiled, "optional", "minimum").as_deref(),
|
||||
Some("0")
|
||||
);
|
||||
assert_eq!(
|
||||
field(&compiled, "optional", "maximum").as_deref(),
|
||||
Some("none")
|
||||
);
|
||||
assert_eq!(
|
||||
field(&compiled, "optional", "default").as_deref(),
|
||||
Some("[ ]")
|
||||
);
|
||||
assert!(field(&compiled, "optional", "type_ref").is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn required_port_contract_and_empty_item_list_survive_while_optional_interface_disappears() {
|
||||
let model = project(
|
||||
r#"
|
||||
sa_function work "Work" id "00000000-0000-4000-8000-000000000002" {}
|
||||
sa_port sink "Sink" id "00000000-0000-4000-8000-000000000003" {
|
||||
parent @work; contract { FunctionInput { incoming_items []; }; };
|
||||
}
|
||||
sa_system host "Host" id "00000000-0000-4000-8000-000000000004" {}
|
||||
sa_port preserved "Preserved" id "00000000-0000-4000-8000-000000000005" {
|
||||
parent @host; contract Legacy; legacy { direction Out; interface none; };
|
||||
}
|
||||
"#,
|
||||
);
|
||||
for compiled in exports(&model) {
|
||||
assert_eq!(
|
||||
field(&compiled, "sink", "contract").as_deref(),
|
||||
Some("{ FunctionInput { incoming_items [ ] ; } ; }")
|
||||
);
|
||||
assert!(field(&compiled, "sink", "legacy").is_none());
|
||||
assert_eq!(
|
||||
field(&compiled, "preserved", "contract").as_deref(),
|
||||
Some("Legacy")
|
||||
);
|
||||
assert_eq!(
|
||||
field(&compiled, "preserved", "legacy").as_deref(),
|
||||
Some("{ direction Out ; }")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_scenario_default_and_selected_occurrence_constructor_remain_distinct() {
|
||||
let model = project(
|
||||
r#"
|
||||
sa_scenario mixed "Mixed" id "00000000-0000-4000-8000-000000000002" { viewpoint MixedLegacy; }
|
||||
sa_scenario strict "Strict" id "00000000-0000-4000-8000-000000000003" { viewpoint Functional; }
|
||||
oa_process flow "Flow" id "00000000-0000-4000-8000-000000000004" { steps [@exchange, @action]; }
|
||||
oa_occurrence exchange "Exchange" id "00000000-0000-4000-8000-000000000005" {
|
||||
parent @flow; body { Exchange {}; };
|
||||
}
|
||||
oa_occurrence action "Action" id "00000000-0000-4000-8000-000000000006" {
|
||||
parent @flow; body { Action { action none; lifeline none; }; };
|
||||
}
|
||||
"#,
|
||||
);
|
||||
for compiled in exports(&model) {
|
||||
assert!(field(&compiled, "mixed", "viewpoint").is_none());
|
||||
assert_eq!(
|
||||
field(&compiled, "strict", "viewpoint").as_deref(),
|
||||
Some("Functional")
|
||||
);
|
||||
assert!(field(&compiled, "exchange", "body").is_none());
|
||||
assert_eq!(
|
||||
field(&compiled, "action", "body").as_deref(),
|
||||
Some("{ Action { } ; }")
|
||||
);
|
||||
assert_eq!(
|
||||
field(&compiled, "action", "parent").as_deref(),
|
||||
Some("@ flow")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,612 @@
|
||||
//! Exact-source regressions for fields skipped by persistence serialization.
|
||||
//!
|
||||
//! The archived Windows baseline confirmed three exact-source failures after
|
||||
//! full model equality passed. Complete comparison now preserves these fields;
|
||||
//! the former ignored witnesses are normal regression tests.
|
||||
//! No-op and same-record header controls remain normal tests: they must preserve
|
||||
//! the same fixtures without entering the changed-payload record-erasure branch.
|
||||
|
||||
use oa_core::{source_project::*, *};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
const EXCHANGE_SOURCE: &str = r#"arcadia 7;
|
||||
project p "Preservation" id "00000000-0000-4000-8000-000000000001" {
|
||||
oa_activity first "First" id "00000000-0000-4000-8000-000000000002" {}
|
||||
oa_activity second "Second" id "00000000-0000-4000-8000-000000000003" {}
|
||||
interaction flow "Flow" id "00000000-0000-4000-8000-000000000004" {
|
||||
source @first; target @second;
|
||||
}
|
||||
oa_process owner "Owner" id "00000000-0000-4000-8000-000000000005" {
|
||||
steps [@subject];
|
||||
}
|
||||
oa_occurrence subject "Subject" id "00000000-0000-4000-8000-000000000006" {
|
||||
parent @owner;
|
||||
body { Exchange {
|
||||
exchange none;
|
||||
sender none; receiver none;
|
||||
message_kind Asynchronous; reply_to none; values [];
|
||||
operation none ; // Keep this authored default and its spelling.
|
||||
}; };
|
||||
}
|
||||
}
|
||||
"#;
|
||||
|
||||
const NODE_SOURCE: &str = r#"arcadia 7;
|
||||
project p "Preservation" id "00000000-0000-4000-8000-000000000001" {
|
||||
oa_activity work "Work" id "00000000-0000-4000-8000-000000000002" {}
|
||||
diagram view "View" id "00000000-0000-4000-8000-000000000003" {}
|
||||
node placement id "00000000-0000-4000-8000-000000000004" {
|
||||
diagram @view; element @work;
|
||||
position { x 12.5; y -4.0; };
|
||||
port_anchors [] ; // Keep explicit automatic placement.
|
||||
}
|
||||
}
|
||||
"#;
|
||||
|
||||
const TRACE_SOURCE: &str = r#"arcadia 7;
|
||||
project p "Preservation" id "00000000-0000-4000-8000-000000000001" {
|
||||
oa_activity work "Work" id "00000000-0000-4000-8000-000000000002" {}
|
||||
sa_function function "Function" id "00000000-0000-4000-8000-000000000003" {}
|
||||
trace mapping "Mapping" id "00000000-0000-4000-8000-000000000004" {
|
||||
oa @work; sa @function; kind Manual;
|
||||
baseline {
|
||||
oa { name "Work"; description "Old OA snapshot"; };
|
||||
sa { name "Function"; description "Old SA snapshot"; };
|
||||
semantic none ; // Keep the explicit prose-only baseline.
|
||||
};
|
||||
}
|
||||
}
|
||||
"#;
|
||||
|
||||
const CONSTRUCTOR_SOURCE: &str = r#"arcadia 7;
|
||||
project p "Preservation" id "00000000-0000-4000-8000-000000000001" {
|
||||
oa_scenario owner "Owner" id "00000000-0000-4000-8000-000000000002" {
|
||||
steps [@subject];
|
||||
}
|
||||
oa_occurrence subject "Subject" id "00000000-0000-4000-8000-000000000003" {
|
||||
parent @owner;
|
||||
body { Action {
|
||||
action none;
|
||||
lifeline none ; // Keep this shared field's distinctive spelling.
|
||||
}; };
|
||||
}
|
||||
}
|
||||
"#;
|
||||
|
||||
const RELATION_CONTEXT_SOURCE: &str = r#"arcadia 7;
|
||||
project p "Preservation" id "00000000-0000-4000-8000-000000000001" {
|
||||
oa_entity first "First" id "00000000-0000-4000-8000-000000000002" {}
|
||||
oa_entity second "Second" id "00000000-0000-4000-8000-000000000003" {}
|
||||
// These port declarations are untouched by the relation context change.
|
||||
oa_port output "Output" id "00000000-0000-4000-8000-000000000005" {
|
||||
parent @first; contract Legacy;
|
||||
legacy { direction Out; interface none; };
|
||||
}
|
||||
oa_port input "Input" id "00000000-0000-4000-8000-000000000006" {
|
||||
parent @second; contract Legacy;
|
||||
legacy { direction In; interface none; };
|
||||
}
|
||||
boundary_exchange link "Link" id "00000000-0000-4000-8000-000000000004" {
|
||||
source @output; target @input;
|
||||
oriented true ; // Retain this rationale when changing the default value.
|
||||
items [];
|
||||
}
|
||||
}
|
||||
"#;
|
||||
|
||||
fn id(number: u8) -> Id {
|
||||
Id::parse_str(&format!("00000000-0000-4000-8000-{number:012}")).unwrap()
|
||||
}
|
||||
|
||||
fn source_bundle(source: &str) -> SourceBundle {
|
||||
SourceBundle {
|
||||
entry: "project.arcadia".into(),
|
||||
files: BTreeMap::from([("project.arcadia".into(), source.into())]),
|
||||
}
|
||||
}
|
||||
|
||||
fn project() -> Project {
|
||||
Project {
|
||||
schema_version: 7,
|
||||
id: id(1),
|
||||
alias: "p".into(),
|
||||
name: "Preservation".into(),
|
||||
description: String::new(),
|
||||
elements: BTreeMap::new(),
|
||||
relations: BTreeMap::new(),
|
||||
diagrams: BTreeMap::new(),
|
||||
artifact_links: BTreeMap::new(),
|
||||
traces: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn element(number: u8, alias: &str, name: &str, parent: Option<Id>, body: ElementBody) -> Element {
|
||||
Element {
|
||||
id: id(number),
|
||||
alias: alias.into(),
|
||||
name: name.into(),
|
||||
description: String::new(),
|
||||
parent,
|
||||
body,
|
||||
}
|
||||
}
|
||||
|
||||
// Handwritten typed expectations are independent of canonical serialization and
|
||||
// source hydration. No model factories or generated/random identities are used.
|
||||
fn exchange_project() -> Project {
|
||||
let mut expected = project();
|
||||
expected.elements = [
|
||||
element(2, "first", "First", None, ElementBody::Oa(OaBody::Activity)),
|
||||
element(
|
||||
3,
|
||||
"second",
|
||||
"Second",
|
||||
None,
|
||||
ElementBody::Oa(OaBody::Activity),
|
||||
),
|
||||
element(
|
||||
5,
|
||||
"owner",
|
||||
"Owner",
|
||||
None,
|
||||
ElementBody::Oa(OaBody::Process { steps: vec![id(6)] }),
|
||||
),
|
||||
element(
|
||||
6,
|
||||
"subject",
|
||||
"Subject",
|
||||
Some(id(5)),
|
||||
ElementBody::Oa(OaBody::Shared(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange {
|
||||
exchange: None,
|
||||
sender: None,
|
||||
receiver: None,
|
||||
message_kind: MessageKind::Asynchronous,
|
||||
reply_to: None,
|
||||
values: vec![],
|
||||
operation: None,
|
||||
},
|
||||
precondition: String::new(),
|
||||
postcondition: String::new(),
|
||||
})),
|
||||
),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|element| (element.id, element))
|
||||
.collect();
|
||||
expected.relations.insert(
|
||||
id(4),
|
||||
Relation {
|
||||
id: id(4),
|
||||
alias: "flow".into(),
|
||||
name: "Flow".into(),
|
||||
description: String::new(),
|
||||
body: RelationBody::Interaction {
|
||||
source: id(2),
|
||||
target: id(3),
|
||||
items: vec![],
|
||||
},
|
||||
},
|
||||
);
|
||||
expected
|
||||
}
|
||||
|
||||
fn node_project() -> Project {
|
||||
let mut expected = project();
|
||||
expected.elements.insert(
|
||||
id(2),
|
||||
element(2, "work", "Work", None, ElementBody::Oa(OaBody::Activity)),
|
||||
);
|
||||
expected.diagrams.insert(
|
||||
id(3),
|
||||
Diagram {
|
||||
id: id(3),
|
||||
alias: "view".into(),
|
||||
name: "View".into(),
|
||||
description: String::new(),
|
||||
layer: Layer::Oa,
|
||||
projection: ProjectionKind::Overview,
|
||||
view_options: view_options::ViewOptions {
|
||||
hidden_edges: vec![],
|
||||
relation_kinds: view_options::RelationKinds::All,
|
||||
work_categories: view_options::CategorySelection::All,
|
||||
participant_categories: view_options::CategorySelection::All,
|
||||
coverage: view_options::Coverage::All,
|
||||
work_contexts: view_options::ContextSelection::All,
|
||||
exchange_labels: view_options::ExchangeLabels::Name,
|
||||
},
|
||||
nodes: BTreeMap::from([(
|
||||
id(4),
|
||||
NodePlacement {
|
||||
id: id(4),
|
||||
alias: "placement".into(),
|
||||
element: id(2),
|
||||
position: Position { x: 12.5, y: -4.0 },
|
||||
size: Size {
|
||||
width: 224.0,
|
||||
height: 94.0,
|
||||
},
|
||||
collapsed: false,
|
||||
port_anchors: vec![],
|
||||
},
|
||||
)]),
|
||||
routes: vec![],
|
||||
},
|
||||
);
|
||||
expected
|
||||
}
|
||||
|
||||
fn trace_project() -> Project {
|
||||
let mut expected = project();
|
||||
expected.elements = [
|
||||
element(2, "work", "Work", None, ElementBody::Oa(OaBody::Activity)),
|
||||
element(
|
||||
3,
|
||||
"function",
|
||||
"Function",
|
||||
None,
|
||||
ElementBody::Sa(SaBody::Function),
|
||||
),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|element| (element.id, element))
|
||||
.collect();
|
||||
expected.traces.insert(
|
||||
id(4),
|
||||
Trace {
|
||||
id: id(4),
|
||||
alias: "mapping".into(),
|
||||
name: "Mapping".into(),
|
||||
description: String::new(),
|
||||
oa: id(2),
|
||||
sa: id(3),
|
||||
kind: TraceKind::Manual,
|
||||
baseline: Some(TraceBaseline {
|
||||
oa: TracedProperties {
|
||||
name: "Work".into(),
|
||||
description: "Old OA snapshot".into(),
|
||||
},
|
||||
sa: TracedProperties {
|
||||
name: "Function".into(),
|
||||
description: "Old SA snapshot".into(),
|
||||
},
|
||||
semantic: None,
|
||||
}),
|
||||
},
|
||||
);
|
||||
expected
|
||||
}
|
||||
|
||||
fn checked_fixture(source: &str, expected: &Project) -> CompiledSourceProject {
|
||||
let input = source_bundle(source);
|
||||
let compiled = compile_bundle(&input, ProjectLimits::default()).unwrap();
|
||||
assert_eq!(
|
||||
compiled.decoded.project, *expected,
|
||||
"fixture model admission"
|
||||
);
|
||||
assert_eq!(
|
||||
compiled.bundle, input,
|
||||
"fixed IDs must avoid stabilization edits"
|
||||
);
|
||||
compiled
|
||||
}
|
||||
|
||||
fn replacement(source: &str, before: &str, after: &str) -> String {
|
||||
assert_eq!(
|
||||
source.matches(before).count(),
|
||||
1,
|
||||
"unique authored edit token"
|
||||
);
|
||||
source.replacen(before, after, 1)
|
||||
}
|
||||
|
||||
fn assert_preserved_update(
|
||||
original: &CompiledSourceProject,
|
||||
expected: &Project,
|
||||
expected_source: &str,
|
||||
) {
|
||||
// Independently admit the exact expected spelling before testing the updater.
|
||||
let expected_bundle = source_bundle(expected_source);
|
||||
let authored = compile_bundle(&expected_bundle, ProjectLimits::default()).unwrap();
|
||||
assert_eq!(
|
||||
authored.decoded.project, *expected,
|
||||
"expected source/model agree"
|
||||
);
|
||||
assert_eq!(
|
||||
authored.bundle, expected_bundle,
|
||||
"expected source is already stable"
|
||||
);
|
||||
|
||||
let updated = update_bundle(original, expected, ProjectLimits::default()).unwrap();
|
||||
assert_eq!(
|
||||
updated.decoded.project, *expected,
|
||||
"entire intended model survives"
|
||||
);
|
||||
assert_eq!(
|
||||
updated.bundle, expected_bundle,
|
||||
"only the requested token may change; preserve explicit defaults and all trivia"
|
||||
);
|
||||
}
|
||||
|
||||
// Context changes may introduce/remove redundant default syntax. Admit each
|
||||
// narrowly enumerated spelling against the same literal target model, and then
|
||||
// compare complete source bundles. No normalization may mask edits to unrelated
|
||||
// fields, declarations, whitespace, or comments.
|
||||
fn assert_context_update(
|
||||
original: &CompiledSourceProject,
|
||||
expected: &Project,
|
||||
allowed_sources: &[String],
|
||||
) {
|
||||
let allowed: Vec<_> = allowed_sources
|
||||
.iter()
|
||||
.map(|source| {
|
||||
let bundle = source_bundle(source);
|
||||
let authored = compile_bundle(&bundle, ProjectLimits::default()).unwrap();
|
||||
assert_eq!(
|
||||
authored.decoded.project, *expected,
|
||||
"allowed source/model agree"
|
||||
);
|
||||
assert_eq!(authored.bundle, bundle, "allowed source is already stable");
|
||||
bundle
|
||||
})
|
||||
.collect();
|
||||
let updated = update_bundle(original, expected, ProjectLimits::default()).unwrap();
|
||||
assert_eq!(
|
||||
updated.decoded.project, *expected,
|
||||
"entire context-change model survives"
|
||||
);
|
||||
assert!(
|
||||
allowed.contains(&updated.bundle),
|
||||
"preserve unchanged source exactly; only listed changed/default syntax alternatives are allowed\nactual: {:?}\nallowed: {:?}",
|
||||
updated.bundle,
|
||||
allowed
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_exchange_operation_none_survives_noop() {
|
||||
let expected = exchange_project();
|
||||
let original = checked_fixture(EXCHANGE_SOURCE, &expected);
|
||||
assert_preserved_update(&original, &expected, EXCHANGE_SOURCE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_exchange_operation_none_survives_header_rename() {
|
||||
let mut expected = exchange_project();
|
||||
let original = checked_fixture(EXCHANGE_SOURCE, &expected);
|
||||
expected.elements.get_mut(&id(6)).unwrap().name = "Renamed subject".into();
|
||||
let source = replacement(EXCHANGE_SOURCE, "\"Subject\"", "\"Renamed subject\"");
|
||||
assert_preserved_update(&original, &expected, &source);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_exchange_operation_none_survives_exchange_binding_edit() {
|
||||
let mut expected = exchange_project();
|
||||
let original = checked_fixture(EXCHANGE_SOURCE, &expected);
|
||||
let Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange { exchange, .. },
|
||||
..
|
||||
}) = expected.elements.get_mut(&id(6)).unwrap().body.shared_mut()
|
||||
else {
|
||||
panic!("literal fixture must be an Exchange occurrence");
|
||||
};
|
||||
*exchange = Some(id(4));
|
||||
let source = replacement(EXCHANGE_SOURCE, "exchange none;", "exchange @flow;");
|
||||
assert_preserved_update(&original, &expected, &source);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_node_empty_port_anchors_survive_noop() {
|
||||
let expected = node_project();
|
||||
let original = checked_fixture(NODE_SOURCE, &expected);
|
||||
assert_preserved_update(&original, &expected, NODE_SOURCE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_node_empty_port_anchors_survive_header_alias_rename() {
|
||||
let mut expected = node_project();
|
||||
let original = checked_fixture(NODE_SOURCE, &expected);
|
||||
// Nodes have no display-name header. Rename the same node's header alias.
|
||||
expected
|
||||
.diagrams
|
||||
.get_mut(&id(3))
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&id(4))
|
||||
.unwrap()
|
||||
.alias = "renamed_placement".into();
|
||||
let source = replacement(
|
||||
NODE_SOURCE,
|
||||
"node placement id",
|
||||
"node renamed_placement id",
|
||||
);
|
||||
assert_preserved_update(&original, &expected, &source);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_node_empty_port_anchors_survive_position_edit() {
|
||||
let mut expected = node_project();
|
||||
let original = checked_fixture(NODE_SOURCE, &expected);
|
||||
expected
|
||||
.diagrams
|
||||
.get_mut(&id(3))
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&id(4))
|
||||
.unwrap()
|
||||
.position
|
||||
.x = 25.0;
|
||||
let source = replacement(NODE_SOURCE, "x 12.5;", "x 25.0;");
|
||||
assert_preserved_update(&original, &expected, &source);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_trace_semantic_none_survives_noop() {
|
||||
let expected = trace_project();
|
||||
let original = checked_fixture(TRACE_SOURCE, &expected);
|
||||
assert_preserved_update(&original, &expected, TRACE_SOURCE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_trace_semantic_none_survives_header_rename() {
|
||||
let mut expected = trace_project();
|
||||
let original = checked_fixture(TRACE_SOURCE, &expected);
|
||||
expected.traces.get_mut(&id(4)).unwrap().name = "Renamed mapping".into();
|
||||
let source = replacement(TRACE_SOURCE, "\"Mapping\"", "\"Renamed mapping\"");
|
||||
assert_preserved_update(&original, &expected, &source);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_trace_semantic_none_survives_snapshot_prose_edit() {
|
||||
let mut expected = trace_project();
|
||||
let original = checked_fixture(TRACE_SOURCE, &expected);
|
||||
expected
|
||||
.traces
|
||||
.get_mut(&id(4))
|
||||
.unwrap()
|
||||
.baseline
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.oa
|
||||
.description = "Revised OA snapshot".into();
|
||||
let source = replacement(
|
||||
TRACE_SOURCE,
|
||||
"\"Old OA snapshot\"",
|
||||
"\"Revised OA snapshot\"",
|
||||
);
|
||||
assert_preserved_update(&original, &expected, &source);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constructor_change_removes_inapplicable_default_and_keeps_shared_default() {
|
||||
let mut expected = project();
|
||||
expected.elements = [
|
||||
element(
|
||||
2,
|
||||
"owner",
|
||||
"Owner",
|
||||
None,
|
||||
ElementBody::Oa(OaBody::Shared(SharedBody::Scenario {
|
||||
viewpoint: ScenarioViewpoint::MixedLegacy,
|
||||
context: None,
|
||||
precondition: String::new(),
|
||||
postcondition: String::new(),
|
||||
steps: vec![id(3)],
|
||||
})),
|
||||
),
|
||||
element(
|
||||
3,
|
||||
"subject",
|
||||
"Subject",
|
||||
Some(id(2)),
|
||||
ElementBody::Oa(OaBody::Shared(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Action {
|
||||
action: None,
|
||||
lifeline: None,
|
||||
},
|
||||
precondition: String::new(),
|
||||
postcondition: String::new(),
|
||||
})),
|
||||
),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|element| (element.id, element))
|
||||
.collect();
|
||||
let original = checked_fixture(CONSTRUCTOR_SOURCE, &expected);
|
||||
expected.elements.get_mut(&id(3)).unwrap().body =
|
||||
ElementBody::Oa(OaBody::Shared(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::State {
|
||||
state: None,
|
||||
lifeline: None,
|
||||
},
|
||||
precondition: String::new(),
|
||||
postcondition: String::new(),
|
||||
}));
|
||||
// Remove only the inapplicable field's syntax: its separating space and all
|
||||
// surrounding trivia stay. The shared lifeline spelling/comment is exact.
|
||||
let source = replacement(CONSTRUCTOR_SOURCE, "body { Action {", "body { State {");
|
||||
let compact = replacement(&source, "action none;", " ");
|
||||
let expanded = replacement(
|
||||
&compact,
|
||||
" }; };",
|
||||
" state none;\n }; };",
|
||||
);
|
||||
// Omission of newly applicable state and explicit state none both mean the
|
||||
// literal State target; neither may retain the old action field.
|
||||
assert_context_update(&original, &expected, &[compact, expanded]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relation_kind_change_updates_explicit_value_when_context_default_changes() {
|
||||
let mut expected = project();
|
||||
expected.elements = [
|
||||
element(2, "first", "First", None, ElementBody::Oa(OaBody::Entity)),
|
||||
element(3, "second", "Second", None, ElementBody::Oa(OaBody::Entity)),
|
||||
element(
|
||||
5,
|
||||
"output",
|
||||
"Output",
|
||||
Some(id(2)),
|
||||
ElementBody::Oa(OaBody::Shared(SharedBody::Port {
|
||||
contract: PortContract::Legacy,
|
||||
legacy: Some(LegacyPortContract {
|
||||
direction: PortDirection::Out,
|
||||
interface: None,
|
||||
}),
|
||||
})),
|
||||
),
|
||||
element(
|
||||
6,
|
||||
"input",
|
||||
"Input",
|
||||
Some(id(3)),
|
||||
ElementBody::Oa(OaBody::Shared(SharedBody::Port {
|
||||
contract: PortContract::Legacy,
|
||||
legacy: Some(LegacyPortContract {
|
||||
direction: PortDirection::In,
|
||||
interface: None,
|
||||
}),
|
||||
})),
|
||||
),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|element| (element.id, element))
|
||||
.collect();
|
||||
expected.relations.insert(
|
||||
id(4),
|
||||
Relation {
|
||||
id: id(4),
|
||||
alias: "link".into(),
|
||||
name: "Link".into(),
|
||||
description: String::new(),
|
||||
body: RelationBody::BoundaryExchange {
|
||||
source: id(5),
|
||||
target: id(6),
|
||||
oriented: true,
|
||||
items: vec![],
|
||||
},
|
||||
},
|
||||
);
|
||||
let original = checked_fixture(RELATION_CONTEXT_SOURCE, &expected);
|
||||
// BoundaryExchange connects participant ports; CommunicationMean connects
|
||||
// their OA entity owners. Endpoint changes are explicit parts of this target,
|
||||
// not inferred or normalized away. Port records remain exactly unchanged.
|
||||
expected.relations.get_mut(&id(4)).unwrap().body = RelationBody::CommunicationMean {
|
||||
source: id(2),
|
||||
target: id(3),
|
||||
oriented: false,
|
||||
items: vec![],
|
||||
};
|
||||
let source = replacement(
|
||||
RELATION_CONTEXT_SOURCE,
|
||||
"boundary_exchange link",
|
||||
"communication_mean link",
|
||||
);
|
||||
let source = replacement(&source, "source @output;", "source @first;");
|
||||
let source = replacement(&source, "target @input;", "target @second;");
|
||||
let explicit = replacement(&source, "oriented true ;", "oriented false ;");
|
||||
// Three spaces between name/value plus one before the semicolon survive
|
||||
// syntax removal. The following rationale comment and all other bytes stay.
|
||||
let omitted = replacement(&source, "oriented true ;", " ");
|
||||
assert_context_update(&original, &expected, &[explicit, omitted]);
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
//! Public update witnesses for compact insertion and complete comparison.
|
||||
use oa_core::{language, source_project::*, *};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
fn compile(body: &str) -> CompiledSourceProject {
|
||||
compile_bundle(
|
||||
&SourceBundle {
|
||||
entry: "project.arcadia".into(),
|
||||
files: BTreeMap::from([
|
||||
(
|
||||
"project.arcadia".into(),
|
||||
"arcadia 7; project p \"P\" { include \"model.arcadia\"; }".into(),
|
||||
),
|
||||
("model.arcadia".into(), body.into()),
|
||||
]),
|
||||
},
|
||||
ProjectLimits::default(),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
fn id(project: &Project, alias: &str) -> Id {
|
||||
project
|
||||
.identities()
|
||||
.into_iter()
|
||||
.find(|(_, a)| *a == alias)
|
||||
.unwrap()
|
||||
.0
|
||||
}
|
||||
fn update(before: &CompiledSourceProject, project: &Project) -> CompiledSourceProject {
|
||||
let result = update_bundle(before, project, ProjectLimits::default()).unwrap();
|
||||
assert_eq!(result.decoded.project, *project);
|
||||
assert_eq!(
|
||||
result.bundle.files["project.arcadia"],
|
||||
before.bundle.files["project.arcadia"]
|
||||
);
|
||||
assert_eq!(
|
||||
compile_bundle(&result.bundle, ProjectLimits::default())
|
||||
.unwrap()
|
||||
.bundle,
|
||||
result.bundle
|
||||
);
|
||||
assert_eq!(
|
||||
update_bundle(&result, project, ProjectLimits::default())
|
||||
.unwrap()
|
||||
.bundle,
|
||||
result.bundle
|
||||
);
|
||||
result
|
||||
}
|
||||
fn words(text: &str) -> Vec<&str> {
|
||||
language::tokenize(text)
|
||||
.into_iter()
|
||||
.filter(|t| {
|
||||
!matches!(
|
||||
t.class,
|
||||
language::TokenClass::Whitespace | language::TokenClass::Comment
|
||||
)
|
||||
})
|
||||
.map(|t| &text[t.span.start..t.span.end])
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn omitted_exchange_body_changes_to_one_compact_action_constructor() {
|
||||
let before = compile(
|
||||
"oa_scenario s \"S\" { steps [@o]; }\noa_occurrence o \"O\" { parent @s; // keep this\n}\n",
|
||||
);
|
||||
let mut project = before.decoded.project.clone();
|
||||
let occurrence = id(&project, "o");
|
||||
let Some(SharedBody::Occurrence { body, .. }) =
|
||||
project.elements.get_mut(&occurrence).unwrap().shared_mut()
|
||||
else {
|
||||
panic!("occurrence")
|
||||
};
|
||||
assert!(matches!(body, OccurrenceBody::Exchange { .. }));
|
||||
*body = OccurrenceBody::Action {
|
||||
action: None,
|
||||
lifeline: None,
|
||||
};
|
||||
let result = update(&before, &project);
|
||||
let text = &result.bundle.files["model.arcadia"];
|
||||
let tokens = words(text);
|
||||
assert_eq!(tokens.iter().filter(|&&t| t == "body").count(), 1);
|
||||
assert!(tokens.contains(&"Action"));
|
||||
for absent in ["Exchange", "action", "lifeline", "operation", "description"] {
|
||||
assert!(!tokens.contains(&absent), "{absent}: {text}");
|
||||
}
|
||||
assert!(text.contains("parent @s; // keep this"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_signal_constructor_still_inserts_its_required_empty_items() {
|
||||
let before =
|
||||
compile("oa_event e \"E\" { body { Time { expression \"1 s\"; kind After; }; }; }\n");
|
||||
let mut project = before.decoded.project.clone();
|
||||
let event = id(&project, "e");
|
||||
let Some(SharedBody::Event { body }) = project.elements.get_mut(&event).unwrap().shared_mut()
|
||||
else {
|
||||
panic!("event")
|
||||
};
|
||||
*body = EventBody::Signal { items: vec![] };
|
||||
let result = update(&before, &project);
|
||||
let tokens = words(&result.bundle.files["model.arcadia"]);
|
||||
assert!(tokens.contains(&"Signal"));
|
||||
assert!(tokens.windows(4).any(|w| w == ["items", "[", "]", ";"]));
|
||||
assert!(!tokens.contains(&"expression"));
|
||||
assert!(!tokens.contains(&"kind"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scalar_to_default_string_record_retains_required_minimum_only() {
|
||||
let before = compile("oa_data_type t \"T\" { definition Boolean; // typed rationale\n}\n");
|
||||
let mut project = before.decoded.project.clone();
|
||||
let datatype = id(&project, "t");
|
||||
let Some(SharedBody::DataType { definition, .. }) =
|
||||
project.elements.get_mut(&datatype).unwrap().shared_mut()
|
||||
else {
|
||||
panic!("data type")
|
||||
};
|
||||
*definition = DataTypeDefinition::String {
|
||||
minimum_length: 0,
|
||||
maximum_length: None,
|
||||
};
|
||||
let result = update(&before, &project);
|
||||
let text = &result.bundle.files["model.arcadia"];
|
||||
let tokens = words(text);
|
||||
assert!(tokens.contains(&"String"));
|
||||
assert!(tokens.windows(3).any(|w| w == ["minimum_length", "0", ";"]));
|
||||
assert!(!tokens.contains(&"maximum_length"));
|
||||
assert!(text.contains("// typed rationale"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inserted_route_list_item_omits_only_optional_fields() {
|
||||
let before = compile(
|
||||
"oa_activity a \"A\" {}\noa_activity b \"B\" {}\ninteraction flow \"Flow\" { source @a; target @b; }\ndiagram d \"D\" { routes []; // routing rationale\n}\n",
|
||||
);
|
||||
let mut project = before.decoded.project.clone();
|
||||
let diagram = id(&project, "d");
|
||||
let edge = id(&project, "flow");
|
||||
// Deserialize only the explicit witness value, independently of omission policy.
|
||||
project.diagrams.get_mut(&diagram).unwrap().routes =
|
||||
serde_json::from_value(serde_json::json!([{"edge":edge,"waypoints":[],"label":null}]))
|
||||
.unwrap();
|
||||
let result = update(&before, &project);
|
||||
let text = &result.bundle.files["model.arcadia"];
|
||||
let tokens = words(text);
|
||||
assert!(tokens.windows(4).any(|w| w == ["edge", "@", "flow", ";"]));
|
||||
assert!(!tokens.contains(&"waypoints"));
|
||||
assert!(!tokens.contains(&"label"));
|
||||
assert!(text.contains("// routing rationale"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_sign_change_inserts_position_without_default_siblings() {
|
||||
let before = compile(
|
||||
"oa_activity a \"A\" {}\ndiagram d \"D\" {}\nnode n { diagram @d; element @a; // placement rationale\n}\n",
|
||||
);
|
||||
let mut project = before.decoded.project.clone();
|
||||
let diagram = id(&project, "d");
|
||||
let node = id(&project, "n");
|
||||
assert_eq!(
|
||||
project.diagrams[&diagram].nodes[&node].position.x.to_bits(),
|
||||
0.0_f32.to_bits()
|
||||
);
|
||||
project
|
||||
.diagrams
|
||||
.get_mut(&diagram)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&node)
|
||||
.unwrap()
|
||||
.position
|
||||
.x = -0.0;
|
||||
let result = update(&before, &project);
|
||||
assert_eq!(
|
||||
result.decoded.project.diagrams[&diagram].nodes[&node]
|
||||
.position
|
||||
.x
|
||||
.to_bits(),
|
||||
(-0.0_f32).to_bits()
|
||||
);
|
||||
let text = &result.bundle.files["model.arcadia"];
|
||||
let tokens = words(text);
|
||||
assert!(tokens.windows(3).any(|w| w == ["x", "-0.0", ";"]));
|
||||
for absent in ["size", "collapsed", "port_anchors"] {
|
||||
assert!(!tokens.contains(&absent));
|
||||
}
|
||||
assert!(text.contains("// placement rationale"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kind_change_materializes_unchanged_value_when_its_old_omission_default_changes() {
|
||||
let before = compile(
|
||||
"oa_entity a \"A\" {}\noa_entity b \"B\" {}\noa_port out \"Out\" { parent @a; contract Legacy; legacy { direction Out; }; }\noa_port input \"In\" { parent @b; contract Legacy; legacy { direction In; }; }\nboundary_exchange flow \"Flow\" { source @out; target @input; // orientation inherited\n}\n",
|
||||
);
|
||||
let mut project = before.decoded.project.clone();
|
||||
let flow = id(&project, "flow");
|
||||
assert!(matches!(
|
||||
project.relations[&flow].body,
|
||||
RelationBody::BoundaryExchange { oriented: true, .. }
|
||||
));
|
||||
project.relations.get_mut(&flow).unwrap().body = RelationBody::CommunicationMean {
|
||||
source: id(&before.decoded.project, "a"),
|
||||
target: id(&before.decoded.project, "b"),
|
||||
oriented: true,
|
||||
items: vec![],
|
||||
};
|
||||
let result = update(&before, &project);
|
||||
let text = &result.bundle.files["model.arcadia"];
|
||||
assert!(
|
||||
words(text)
|
||||
.windows(3)
|
||||
.any(|w| w == ["oriented", "true", ";"])
|
||||
);
|
||||
assert!(text.contains("// orientation inherited"));
|
||||
let before_ports = &before.bundle.files["model.arcadia"];
|
||||
assert_eq!(
|
||||
text.split("communication_mean").next().unwrap(),
|
||||
before_ports.split("boundary_exchange").next().unwrap()
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
use oa_core::{language, persistence, source_project::*, *};
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
const SOURCE: &str = r#"arcadia 7;
|
||||
project attachments "Connection ends" id "70000000-0000-4000-8000-000000000001" {
|
||||
oa_activity first "First" id "70000000-0000-4000-8000-000000000002" {}
|
||||
oa_activity second "Second" id "70000000-0000-4000-8000-000000000003" {}
|
||||
interaction signal "Signal" id "70000000-0000-4000-8000-000000000004" { source @first; target @second; }
|
||||
diagram view "View" id "70000000-0000-4000-8000-000000000005" {
|
||||
routes [{ edge @signal; waypoints [{ x 120; y 80; }];
|
||||
label { fraction 0.5; offset { x 3; y -7; }; };
|
||||
source_attachment { side Left; offset 0.25; };
|
||||
target_attachment { side Bottom; offset 1; };
|
||||
}];
|
||||
}
|
||||
diagram other "Other" id "70000000-0000-4000-8000-000000000006" { routes [{ edge @signal; }]; }
|
||||
}
|
||||
"#;
|
||||
|
||||
fn id(project: &Project, alias: &str) -> Id {
|
||||
project
|
||||
.identities()
|
||||
.into_iter()
|
||||
.find(|(_, a)| *a == alias)
|
||||
.unwrap()
|
||||
.0
|
||||
}
|
||||
|
||||
fn original() -> Project {
|
||||
language::parse(SOURCE).unwrap()
|
||||
}
|
||||
|
||||
fn set(diagram: Id, route: EdgeRoute) -> Command {
|
||||
Command::SetEdgeRoute { diagram, route }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edge_attachments_roundtrip_source_json_bundle_and_automatic_defaults() {
|
||||
let project = original();
|
||||
let view = id(&project, "view");
|
||||
let route = &project.diagrams[&view].routes[0];
|
||||
assert_eq!(
|
||||
route.source_attachment,
|
||||
Some(EdgeAttachment {
|
||||
side: PortAnchorSide::Left,
|
||||
offset: 0.25
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
route.target_attachment,
|
||||
Some(EdgeAttachment {
|
||||
side: PortAnchorSide::Bottom,
|
||||
offset: 1.0
|
||||
})
|
||||
);
|
||||
for text in [SOURCE.to_owned(), language::format(&project)] {
|
||||
assert_eq!(language::parse(&text).unwrap(), project);
|
||||
}
|
||||
assert_eq!(
|
||||
persistence::decode(&serde_json::to_vec(&project).unwrap()).unwrap(),
|
||||
project
|
||||
);
|
||||
assert_eq!(
|
||||
compile_bundle(&format_bundle(&project), ProjectLimits::default())
|
||||
.unwrap()
|
||||
.decoded
|
||||
.project,
|
||||
project
|
||||
);
|
||||
|
||||
let automatic = &project.diagrams[&id(&project, "other")].routes[0];
|
||||
assert_eq!(automatic.source_attachment, None);
|
||||
assert_eq!(automatic.target_attachment, None);
|
||||
let encoded = serde_json::to_value(automatic).unwrap();
|
||||
assert!(encoded.get("source_attachment").is_none());
|
||||
assert!(encoded.get("target_attachment").is_none());
|
||||
let mut with_null = encoded.clone();
|
||||
with_null["source_attachment"] = Value::Null;
|
||||
with_null["target_attachment"] = Value::Null;
|
||||
assert_eq!(
|
||||
serde_json::from_value::<EdgeRoute>(with_null).unwrap(),
|
||||
*automatic
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_value::<EdgeRoute>(encoded).unwrap(),
|
||||
*automatic
|
||||
);
|
||||
|
||||
// Old source without either new field still means automatic presentation.
|
||||
let old = SOURCE
|
||||
.replace(" source_attachment { side Left; offset 0.25; };\n", "")
|
||||
.replace(" target_attachment { side Bottom; offset 1; };\n", "");
|
||||
let old = language::parse(&old).unwrap();
|
||||
assert!(
|
||||
old.diagrams
|
||||
.values()
|
||||
.flat_map(|d| &d.routes)
|
||||
.all(|r| r.source_attachment.is_none() && r.target_attachment.is_none())
|
||||
);
|
||||
assert!(!language::format(&old).contains("_attachment"));
|
||||
assert_eq!(
|
||||
persistence::decode(&serde_json::to_vec(&old).unwrap()).unwrap(),
|
||||
old
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edge_attachments_reject_malformed_nested_source_and_json_without_defaults() {
|
||||
let project = original();
|
||||
let edge = id(&project, "signal");
|
||||
for bad in [
|
||||
json!({}),
|
||||
json!({"side":"Top"}),
|
||||
json!({"offset":0.5}),
|
||||
json!({"side":"Diagonal","offset":0.5}),
|
||||
json!({"side":"Top","offset":null}),
|
||||
json!({"side":"Top","offset":0.5,"unknown":true}),
|
||||
json!({"side":"Top","offset":"NaN"}),
|
||||
] {
|
||||
for field in ["source_attachment", "target_attachment"] {
|
||||
let mut route = json!({"edge":edge});
|
||||
route[field] = bad.clone();
|
||||
assert!(
|
||||
serde_json::from_value::<EdgeRoute>(route).is_err(),
|
||||
"{field}: {bad}"
|
||||
);
|
||||
}
|
||||
}
|
||||
for bad in [
|
||||
"{}",
|
||||
"{ side Top; }",
|
||||
"{ offset 0.5; }",
|
||||
"{ side Diagonal; offset 0.5; }",
|
||||
"{ side Top; offset null; }",
|
||||
"{ side Top; offset 0.5; unknown true; }",
|
||||
"{ side Top; offset -0.01; }",
|
||||
"{ side Top; offset 1.01; }",
|
||||
"{ side Top; offset NaN; }",
|
||||
"{ side Top; offset 1e999; }",
|
||||
] {
|
||||
let source = SOURCE.replace("{ side Left; offset 0.25; }", bad);
|
||||
assert!(language::parse(&source).is_err(), "{bad}");
|
||||
let bundle = SourceBundle {
|
||||
entry: "p.arcadia".into(),
|
||||
files: BTreeMap::from([("p.arcadia".into(), source)]),
|
||||
};
|
||||
assert!(
|
||||
compile_bundle(&bundle, ProjectLimits::default()).is_err(),
|
||||
"{bad}"
|
||||
);
|
||||
}
|
||||
for version in [1, 2] {
|
||||
let legacy =
|
||||
format!("arcadia {version}; project p \"P\" {{ diagram v \"V\" {{ routes []; }} }}");
|
||||
assert!(
|
||||
language::parse(&legacy).is_err(),
|
||||
"legacy route extension must not be silently admitted"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edge_attachments_nonfinite_and_out_of_range_commands_reject_atomically() {
|
||||
let project = original();
|
||||
let view = id(&project, "view");
|
||||
let first = id(&project, "first");
|
||||
for offset in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, -0.01, 1.01] {
|
||||
for end in [EdgeEnd::Source, EdgeEnd::Target] {
|
||||
let mut route = project.diagrams[&view].routes[0].clone();
|
||||
route.set_attachment(
|
||||
end,
|
||||
Some(EdgeAttachment {
|
||||
side: PortAnchorSide::Top,
|
||||
offset,
|
||||
}),
|
||||
);
|
||||
let mut session = Session::new(project.clone());
|
||||
assert!(
|
||||
session
|
||||
.execute(Command::Batch(vec![
|
||||
Command::Rename(first, "Must roll back".into()),
|
||||
set(view, route)
|
||||
]))
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(session.project(), &project);
|
||||
assert!(!session.can_undo() && !session.can_redo());
|
||||
}
|
||||
}
|
||||
for offset in [-0.01, 1.01] {
|
||||
let mut value = serde_json::to_value(&project).unwrap();
|
||||
value["diagrams"][view.to_string()]["routes"][0]["source_attachment"]["offset"] =
|
||||
json!(offset);
|
||||
assert!(persistence::decode(&serde_json::to_vec(&value).unwrap()).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edge_attachments_reset_one_end_and_all_routing_have_exact_history_and_view_scope() {
|
||||
let project = original();
|
||||
let view = id(&project, "view");
|
||||
let edge = id(&project, "signal");
|
||||
let mut session = Session::new(project.clone());
|
||||
let mut route = project.diagrams[&view].routes[0].clone();
|
||||
route.source_attachment = Some(EdgeAttachment {
|
||||
side: PortAnchorSide::Right,
|
||||
offset: 0.75,
|
||||
});
|
||||
let command = set(view, route.clone());
|
||||
assert_eq!(
|
||||
serde_json::from_value::<Command>(serde_json::to_value(&command).unwrap()).unwrap(),
|
||||
command
|
||||
);
|
||||
session.execute(command).unwrap();
|
||||
let mut expected = project.clone();
|
||||
expected.diagrams.get_mut(&view).unwrap().routes[0] = route.clone();
|
||||
assert_eq!(
|
||||
session.project(),
|
||||
&expected,
|
||||
"only this view/end changes; semantic fields and other view stay exact"
|
||||
);
|
||||
session.undo();
|
||||
assert_eq!(session.project(), &project);
|
||||
assert!(!session.can_undo() && session.can_redo());
|
||||
session.redo();
|
||||
assert_eq!(session.project(), &expected);
|
||||
route.source_attachment = None;
|
||||
session.execute(set(view, route.clone())).unwrap();
|
||||
let mut reset = expected.clone();
|
||||
reset.diagrams.get_mut(&view).unwrap().routes[0] = route;
|
||||
assert_eq!(
|
||||
session.project(),
|
||||
&reset,
|
||||
"opposite attachment, pins and label survive endpoint reset"
|
||||
);
|
||||
session.undo();
|
||||
assert_eq!(session.project(), &expected);
|
||||
session
|
||||
.execute(Command::RemoveEdgeRoute {
|
||||
diagram: view,
|
||||
edge,
|
||||
})
|
||||
.unwrap();
|
||||
let mut removed = expected.clone();
|
||||
removed.diagrams.get_mut(&view).unwrap().routes.clear();
|
||||
assert_eq!(session.project(), &removed);
|
||||
session.undo();
|
||||
assert_eq!(session.project(), &expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edge_attachments_nested_writeback_preserves_comments_adjacent_tokens_and_other_files() {
|
||||
let current = compile_bundle(&SourceBundle {
|
||||
entry: "p.arcadia".into(),
|
||||
files: BTreeMap::from([
|
||||
("p.arcadia".into(), "// project rationale\narcadia 7; project p \"P\" { include \"model.arcadia\"; include \"views.arcadia\"; }\n".into()),
|
||||
("model.arcadia".into(), "// untouched semantic ownership\noa_activity a \"A\" {}\noa_activity b \"B\" {}\ninteraction signal \"Signal\" { source @a; target @b; }\n".into()),
|
||||
("views.arcadia".into(), "// view rationale\r\ndiagram view \"View\" {\r\n routes [ { edge @signal; // endpoint explanation\r\n source_attachment { side Left; offset 0.25 ; // exact nested rationale\r\n }; target_attachment { side Bottom; offset 1; };\r\n waypoints [{ x 120; y 80; }]; label { fraction 0.5; offset { x 3; y -7; }; }; } ];\r\n}\r\ndiagram other \"Other\" { routes [{ edge @signal; }]; } // adjacent view\r\n".into()),
|
||||
]),
|
||||
}, ProjectLimits::default()).unwrap();
|
||||
let mut project = current.decoded.project.clone();
|
||||
let view = id(&project, "view");
|
||||
project.diagrams.get_mut(&view).unwrap().routes[0]
|
||||
.source_attachment
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.offset = 0.375;
|
||||
let changed = update_bundle(¤t, &project, ProjectLimits::default()).unwrap();
|
||||
assert_eq!(changed.decoded.project, project);
|
||||
for path in ["p.arcadia", "model.arcadia"] {
|
||||
assert_eq!(changed.bundle.files[path], current.bundle.files[path]);
|
||||
}
|
||||
assert_eq!(
|
||||
changed.bundle.files["views.arcadia"],
|
||||
current.bundle.files["views.arcadia"].replace("offset 0.25 ;", "offset 0.375 ;")
|
||||
);
|
||||
assert_eq!(
|
||||
compile_bundle(&changed.bundle, ProjectLimits::default())
|
||||
.unwrap()
|
||||
.bundle,
|
||||
changed.bundle
|
||||
);
|
||||
assert_eq!(
|
||||
update_bundle(&changed, &project, ProjectLimits::default())
|
||||
.unwrap()
|
||||
.bundle,
|
||||
changed.bundle
|
||||
);
|
||||
}
|
||||
@@ -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!(
|
||||
@@ -339,6 +349,73 @@ project mission "Harbor safety — 港 🌊" {
|
||||
component_port,
|
||||
function_port,
|
||||
);
|
||||
let participants: Vec<_> = p
|
||||
.elements
|
||||
.values()
|
||||
.filter(|e| matches!(e.kind(), Kind::Entity | Kind::Actor))
|
||||
.map(|e| e.id)
|
||||
.collect();
|
||||
let means = p.relate(
|
||||
RelationKind::CommunicationMean,
|
||||
"Operational communication",
|
||||
participants[0],
|
||||
participants[1],
|
||||
);
|
||||
let category = p
|
||||
.add_element(
|
||||
Kind::ExchangeCategory,
|
||||
Layer::Oa,
|
||||
"Operational exchange category",
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
*p.elements.get_mut(&category).unwrap().shared_mut().unwrap() = SharedBody::ExchangeCategory {
|
||||
family: oa_core::view_options::ExchangeFamily::Participant,
|
||||
exchanges: vec![means],
|
||||
};
|
||||
let caller = p
|
||||
.add_element(Kind::Process, Layer::Oa, "Common work caller", None)
|
||||
.unwrap();
|
||||
let work = p
|
||||
.elements
|
||||
.values()
|
||||
.find(|e| e.kind() == Kind::Activity)
|
||||
.unwrap()
|
||||
.id;
|
||||
let mut occurrences = vec![];
|
||||
for name in ["First contribution", "Second contribution"] {
|
||||
let id = p
|
||||
.add_element(Kind::Occurrence, Layer::Oa, name, Some(caller))
|
||||
.unwrap();
|
||||
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Action {
|
||||
action: Some(work),
|
||||
lifeline: None,
|
||||
},
|
||||
precondition: String::new(),
|
||||
postcondition: String::new(),
|
||||
};
|
||||
occurrences.push(id);
|
||||
}
|
||||
*p.elements.get_mut(&caller).unwrap().steps_mut().unwrap() = occurrences.clone();
|
||||
let id = p.relate(
|
||||
RelationKind::ContextualLink,
|
||||
"Shared work incidence",
|
||||
occurrences[0],
|
||||
occurrences[1],
|
||||
);
|
||||
p.relations.get_mut(&id).unwrap().body = RelationBody::ContextualLink {
|
||||
owner: caller,
|
||||
source: OccurrenceEndpoint {
|
||||
references: vec![],
|
||||
occurrence: occurrences[0],
|
||||
},
|
||||
target: OccurrenceEndpoint {
|
||||
references: vec![],
|
||||
occurrence: occurrences[1],
|
||||
},
|
||||
purpose: ContextualLinkPurpose::CommonWork { work },
|
||||
};
|
||||
p
|
||||
}
|
||||
|
||||
@@ -491,7 +568,7 @@ fn authoring_defaults_and_forward_references_remain_strict() {
|
||||
#[test]
|
||||
fn typed_unknown_fields_enums_duplicates_and_reference_spelling_are_rejected() {
|
||||
for (source, expected) in [
|
||||
("arcadia 6; project mission \"x\" {}", "Unsupported schema"),
|
||||
("arcadia 8; project mission \"x\" {}", "Unsupported schema"),
|
||||
(
|
||||
"arcadia 2; project mission \"x\" { oa_activity a \"A\" { nonsense 1; } }",
|
||||
"Unknown field",
|
||||
@@ -686,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];",
|
||||
@@ -709,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,
|
||||
@@ -717,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,
|
||||
@@ -762,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
|
||||
@@ -795,7 +883,7 @@ fn original_v2_text_and_json_preserve_identity_and_mixed_scenarios_after_extensi
|
||||
assert_eq!(from_source, from_json);
|
||||
let original: serde_json::Value = serde_json::from_slice(old_json).unwrap();
|
||||
let mut retained = serde_json::to_value(&from_json).unwrap();
|
||||
assert_eq!(retained["schema_version"], 5);
|
||||
assert_eq!(retained["schema_version"], oa_core::SCHEMA_VERSION);
|
||||
retained["schema_version"] = 2.into();
|
||||
let allocations: Vec<_> = from_json
|
||||
.relations
|
||||
@@ -904,6 +992,11 @@ fn original_v2_text_and_json_preserve_identity_and_mixed_scenarios_after_extensi
|
||||
}
|
||||
}
|
||||
for diagram in retained["diagrams"].as_object_mut().unwrap().values_mut() {
|
||||
assert_eq!(
|
||||
diagram["view_options"],
|
||||
serde_json::to_value(oa_core::view_options::ViewOptions::default()).unwrap()
|
||||
);
|
||||
diagram.as_object_mut().unwrap().remove("view_options");
|
||||
assert_eq!(diagram["routes"], serde_json::json!([]));
|
||||
diagram.as_object_mut().unwrap().remove("routes");
|
||||
}
|
||||
@@ -966,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,
|
||||
@@ -991,7 +1084,7 @@ fn v1_text_and_json_migrate_deterministically_without_losing_legacy_semantics()
|
||||
assert_eq!(from_text, from_json);
|
||||
assert_eq!(read(text), from_text);
|
||||
assert_eq!(persistence::decode(json).unwrap(), from_json);
|
||||
assert_eq!(from_text.schema_version, 5);
|
||||
assert_eq!(from_text.schema_version, oa_core::SCHEMA_VERSION);
|
||||
assert!(!from_text.elements.values().any(|e| e.layer() == Layer::Sa));
|
||||
let raw: serde_json::Value = serde_json::from_slice(json).unwrap();
|
||||
for (id, record) in raw["elements"].as_object().unwrap() {
|
||||
@@ -1190,11 +1283,10 @@ fn complete_normative_examples_are_accepted_by_the_real_parser() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if code.trim_start().starts_with("arcadia 2;")
|
||||
|| code.trim_start().starts_with("arcadia 3;")
|
||||
|| code.trim_start().starts_with("arcadia 4;")
|
||||
|| code.trim_start().starts_with("arcadia 5;")
|
||||
{
|
||||
if (1..=SCHEMA_VERSION).any(|version| {
|
||||
code.trim_start()
|
||||
.starts_with(&format!("arcadia {version};"))
|
||||
}) {
|
||||
read(&code);
|
||||
examples += 1;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -39,7 +39,10 @@ fn frozen_original_v2_reader_preserves_every_original_record() {
|
||||
assert_eq!(decoded.project, from_source.project);
|
||||
assert_eq!(decoded.migration, from_source.migration);
|
||||
let report = decoded.migration.unwrap();
|
||||
assert_eq!((report.from_schema, report.to_schema), (2, 5));
|
||||
assert_eq!(
|
||||
(report.from_schema, report.to_schema),
|
||||
(2, oa_core::SCHEMA_VERSION)
|
||||
);
|
||||
assert_eq!(report.preserved_ports.len(), 4);
|
||||
assert!(report.generated_endpoints.is_empty());
|
||||
assert_eq!(report.orientation_defaults.len(), 1);
|
||||
@@ -60,6 +63,11 @@ fn frozen_original_v2_reader_preserves_every_original_record() {
|
||||
);
|
||||
let mut current = serde_json::to_value(&decoded.project).unwrap();
|
||||
for diagram in current["diagrams"].as_object_mut().unwrap().values_mut() {
|
||||
assert_eq!(
|
||||
diagram["view_options"],
|
||||
serde_json::to_value(oa_core::view_options::ViewOptions::default()).unwrap()
|
||||
);
|
||||
diagram.as_object_mut().unwrap().remove("view_options");
|
||||
assert_eq!(diagram["routes"], json!([]), "V2 adds no authored routing");
|
||||
diagram.as_object_mut().unwrap().remove("routes");
|
||||
}
|
||||
@@ -144,7 +152,7 @@ fn missing_v2_endpoints_are_deterministic_reported_and_independently_typed() {
|
||||
}
|
||||
}
|
||||
let canonical = language::format(&first.project);
|
||||
assert!(canonical.starts_with("arcadia 5;"));
|
||||
assert!(canonical.starts_with(&format!("arcadia {SCHEMA_VERSION};")));
|
||||
let reopened = persistence::decode_with_report(canonical.as_bytes()).unwrap();
|
||||
assert!(reopened.migration.is_none());
|
||||
assert_eq!(reopened.project, first.project);
|
||||
@@ -274,7 +282,7 @@ fn old_contract_is_validated_before_new_shapes_and_v1_flows_through_v2() {
|
||||
assert!(error.contains("V2") && error.contains(&fe), "{error}");
|
||||
let v1 = include_bytes!("../../../examples/legacy-v1-coastal-rescue.oa.json");
|
||||
let result = persistence::decode_with_report(v1).unwrap();
|
||||
assert_eq!(result.project.schema_version, 5);
|
||||
assert_eq!(result.project.schema_version, oa_core::SCHEMA_VERSION);
|
||||
assert_eq!(result.migration.unwrap().from_schema, 1);
|
||||
assert_eq!(persistence::decode(v1).unwrap(), result.project);
|
||||
}
|
||||
|
||||
@@ -30,7 +30,10 @@ project typed_ports "Typed ports" {
|
||||
fn source4_covers_typed_contracts_roles_allocations_and_nested_legacy_references() {
|
||||
let decoded = language::parse_with_report(SOURCE).unwrap();
|
||||
let migration = decoded.migration.as_ref().unwrap();
|
||||
assert_eq!((migration.from_schema, migration.to_schema), (4, 5));
|
||||
assert_eq!(
|
||||
(migration.from_schema, migration.to_schema),
|
||||
(4, oa_core::SCHEMA_VERSION)
|
||||
);
|
||||
let canonical = language::format(&decoded.project);
|
||||
for field in [
|
||||
"incoming_items [@data]",
|
||||
|
||||
@@ -102,6 +102,7 @@ fn fixture(family: Family) -> (Project, Id, Id, Id, FacetSelector, FacetSelector
|
||||
id,
|
||||
SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange {
|
||||
operation: None,
|
||||
exchange: None,
|
||||
sender: None,
|
||||
receiver: None,
|
||||
@@ -351,7 +352,10 @@ fn migrated_equal_constructor_can_complete_semantic_review_without_model_changes
|
||||
for reverse in [false, true] {
|
||||
let decoded = persistence::decode_with_report(json.as_bytes()).unwrap();
|
||||
let report = decoded.migration.unwrap();
|
||||
assert_eq!((report.from_schema, report.to_schema), (4, 5));
|
||||
assert_eq!(
|
||||
(report.from_schema, report.to_schema),
|
||||
(4, oa_core::SCHEMA_VERSION)
|
||||
);
|
||||
let p = decoded.project;
|
||||
let t = p.traces.values().next().unwrap();
|
||||
assert!(t.baseline.as_ref().unwrap().semantic.is_none());
|
||||
|
||||
@@ -11,9 +11,12 @@ fn frozen_v4_json_and_source_preserve_the_old_review_without_fabricating_semanti
|
||||
let source = language::parse_with_report(SOURCE4).unwrap();
|
||||
assert_eq!(json.project, source.project);
|
||||
for decoded in [json, source] {
|
||||
assert_eq!(decoded.project.schema_version, 5);
|
||||
assert_eq!(decoded.project.schema_version, oa_core::SCHEMA_VERSION);
|
||||
let report = decoded.migration.unwrap();
|
||||
assert_eq!((report.from_schema, report.to_schema), (4, 5));
|
||||
assert_eq!(
|
||||
(report.from_schema, report.to_schema),
|
||||
(4, oa_core::SCHEMA_VERSION)
|
||||
);
|
||||
let trace = decoded.project.traces.values().next().unwrap();
|
||||
let baseline = trace.baseline.as_ref().unwrap();
|
||||
assert!(baseline.semantic.is_none());
|
||||
@@ -22,7 +25,7 @@ fn frozen_v4_json_and_source_preserve_the_old_review_without_fabricating_semanti
|
||||
assert_eq!(baseline.sa.name, "Approved SA name");
|
||||
assert_ne!(baseline.oa.name, decoded.project.named(trace.oa));
|
||||
let mut expected: Value = serde_json::from_slice(JSON4).unwrap();
|
||||
expected["schema_version"] = 5.into();
|
||||
expected["schema_version"] = oa_core::SCHEMA_VERSION.into();
|
||||
assert_eq!(serde_json::to_value(&decoded.project).unwrap(), expected);
|
||||
let reopened =
|
||||
persistence::decode_with_report(&serde_json::to_vec(&decoded.project).unwrap())
|
||||
@@ -117,7 +120,7 @@ fn authored_v4_modules_upgrade_only_the_version_and_preserve_nested_comments() {
|
||||
assert_eq!(compiled.bundle.files["contracts.arcadia"], module);
|
||||
assert_eq!(
|
||||
compiled.bundle.files["project.arcadia"],
|
||||
entry.replacen("arcadia 4;", "arcadia 5;", 1)
|
||||
entry.replacen("arcadia 4;", &format!("arcadia {SCHEMA_VERSION};"), 1)
|
||||
);
|
||||
assert_eq!(
|
||||
compiled.index.lexical_owners
|
||||
@@ -152,8 +155,11 @@ fn original_v1_v2_v3_inputs_complete_the_explicit_upgrade_to_five() {
|
||||
] {
|
||||
let decoded = persistence::decode_with_report(bytes).unwrap();
|
||||
let report = decoded.migration.unwrap();
|
||||
assert_eq!((report.from_schema, report.to_schema), (version, 5));
|
||||
assert_eq!(decoded.project.schema_version, 5);
|
||||
assert_eq!(
|
||||
(report.from_schema, report.to_schema),
|
||||
(version, oa_core::SCHEMA_VERSION)
|
||||
);
|
||||
assert_eq!(decoded.project.schema_version, oa_core::SCHEMA_VERSION);
|
||||
assert!(
|
||||
decoded
|
||||
.project
|
||||
|
||||
@@ -515,6 +515,7 @@ fn payload(p: &mut Project, layer: Layer, prefix: &str) -> Payload {
|
||||
scenario,
|
||||
&format!("{prefix} message"),
|
||||
OccurrenceBody::Exchange {
|
||||
operation: None,
|
||||
exchange: Some(exchange),
|
||||
sender: None,
|
||||
receiver: None,
|
||||
@@ -642,6 +643,11 @@ fn atomic_annotation_full_path_updates_only_selected_row() {
|
||||
let rows = values_mut(&mut q, b.message);
|
||||
assert_eq!(rows.len(), 2);
|
||||
assert!(rows.contains(&retained));
|
||||
assert_eq!(AnnotationKey::from(&rows[0]), b.first);
|
||||
assert_eq!(
|
||||
rows[1], retained,
|
||||
"Unselected rows retain their authored position"
|
||||
);
|
||||
assert!(
|
||||
rows.iter()
|
||||
.any(|v| AnnotationKey::from(v) == b.first && v.expression == "new first")
|
||||
@@ -651,6 +657,73 @@ fn atomic_annotation_full_path_updates_only_selected_row() {
|
||||
.any(|v| AnnotationKey::from(v) == b.second && v.expression == retained.expression)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeping_keyed_rows_with_an_unrelated_header_candidate_preserves_exact_authored_order() {
|
||||
for binding in [false, true] {
|
||||
for reverse in [false, true] {
|
||||
let mut p = Project::empty();
|
||||
let (oa, sa, selector, references) = if binding {
|
||||
let a = scenario_use(&mut p, Layer::Oa, "oa");
|
||||
let b = scenario_use(&mut p, Layer::Sa, "sa");
|
||||
(
|
||||
a.use_id,
|
||||
b.use_id,
|
||||
FacetSelector::Binding(KeyPair {
|
||||
oa: Some(a.referenced),
|
||||
sa: Some(b.referenced),
|
||||
}),
|
||||
BTreeMap::from([(a.referenced, b.referenced)]),
|
||||
)
|
||||
} else {
|
||||
let a = payload(&mut p, Layer::Oa, "oa");
|
||||
let b = payload(&mut p, Layer::Sa, "sa");
|
||||
let mut references: BTreeMap<_, _> = a
|
||||
.first
|
||||
.path
|
||||
.iter()
|
||||
.copied()
|
||||
.zip(b.first.path.iter().copied())
|
||||
.collect();
|
||||
references.insert(a.leaf, b.leaf);
|
||||
(
|
||||
a.message,
|
||||
b.message,
|
||||
FacetSelector::Annotation(KeyPair {
|
||||
oa: Some(a.first),
|
||||
sa: Some(b.first),
|
||||
}),
|
||||
references,
|
||||
)
|
||||
};
|
||||
trace(&mut p, oa, sa);
|
||||
let (source, target) = if reverse { (sa, oa) } else { (oa, sa) };
|
||||
let mut candidate = p.elements[&target].clone();
|
||||
candidate.description = "Review the header while retaining authored rows".into();
|
||||
let mut review = request(
|
||||
source,
|
||||
target,
|
||||
vec![
|
||||
(selector, SemanticDecision::KeepTarget),
|
||||
(
|
||||
FacetSelector::Header(HeaderFacet::Description),
|
||||
SemanticDecision::Candidate,
|
||||
),
|
||||
],
|
||||
);
|
||||
if reverse {
|
||||
review.transition.direction = TransitionDirection::SaToOa;
|
||||
}
|
||||
review.references = references;
|
||||
review
|
||||
.candidates
|
||||
.push(SemanticRecord::Element(candidate.clone()));
|
||||
let accepted = applied(&p, review);
|
||||
assert_eq!(accepted.elements[&target], candidate);
|
||||
assert_eq!(accepted.elements[&source], p.elements[&source]);
|
||||
}
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn atomic_binding_updates_local_and_rejects_pair_collision_and_remapping_without_reset() {
|
||||
let mut p = Project::empty();
|
||||
@@ -671,6 +744,8 @@ fn atomic_binding_updates_local_and_rejects_pair_collision_and_remapping_without
|
||||
r.references = BTreeMap::from([(a.referenced, b.referenced), (a.alternate, b.alternate)]);
|
||||
let mut q = applied(&p, r.clone());
|
||||
assert!(bindings_mut(&mut q, b.use_id).contains(&retained));
|
||||
assert_eq!(bindings_mut(&mut q, b.use_id)[0].referenced, b.referenced);
|
||||
assert_eq!(bindings_mut(&mut q, b.use_id)[1], retained);
|
||||
assert!(
|
||||
bindings_mut(&mut q, b.use_id)
|
||||
.iter()
|
||||
|
||||
@@ -131,7 +131,7 @@ fn typed_historical_facets_roundtrip_deleted_references_and_all_leaf_categories(
|
||||
]);
|
||||
assert!(!model.contains(id(90)));
|
||||
let source = language::format(&model);
|
||||
assert!(source.starts_with("arcadia 5;"));
|
||||
assert!(source.starts_with(&format!("arcadia {SCHEMA_VERSION};")));
|
||||
assert!(
|
||||
source.contains(&format!("Named uuid \"{}\"", id(90))),
|
||||
"{source}"
|
||||
|
||||
@@ -91,6 +91,7 @@ fn message(
|
||||
id,
|
||||
SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange {
|
||||
operation: None,
|
||||
exchange: Some(exchange),
|
||||
sender: None,
|
||||
receiver: None,
|
||||
|
||||
@@ -21,7 +21,10 @@ fn genuine_v3_preserves_typed_ports_and_allocates_deterministic_relationships()
|
||||
assert_eq!(decoded.migration, source.migration);
|
||||
let project = &decoded.project;
|
||||
let report = decoded.migration.as_ref().unwrap();
|
||||
assert_eq!((report.from_schema, report.to_schema), (3, 5));
|
||||
assert_eq!(
|
||||
(report.from_schema, report.to_schema),
|
||||
(3, oa_core::SCHEMA_VERSION)
|
||||
);
|
||||
assert_eq!(project.identities().len(), 19);
|
||||
assert!(report.preserved_ports.is_empty());
|
||||
assert!(report.generated_endpoints.is_empty());
|
||||
@@ -71,7 +74,7 @@ fn genuine_v3_preserves_typed_ports_and_allocates_deterministic_relationships()
|
||||
.any(|issue| issue.code == "message_value_ambiguous_path")
|
||||
);
|
||||
let canonical = language::format(project);
|
||||
assert!(canonical.starts_with("arcadia 5;"));
|
||||
assert!(canonical.starts_with(&format!("arcadia {SCHEMA_VERSION};")));
|
||||
let reopened = language::parse_with_report(&canonical).unwrap();
|
||||
assert_eq!(&reopened.project, project);
|
||||
assert!(reopened.migration.is_none());
|
||||
@@ -154,12 +157,12 @@ fn v1_and_frozen_v2_inputs_reach_four_through_the_shared_data_upgrade() {
|
||||
language::parse_with_report(include_str!("fixtures/v2-coastal-rescue.arcadia")).unwrap();
|
||||
assert_eq!(v2.project, text.project);
|
||||
assert_eq!(v2.migration, text.migration);
|
||||
assert_eq!(v2.migration.unwrap().to_schema, 5);
|
||||
assert_eq!(v2.migration.unwrap().to_schema, oa_core::SCHEMA_VERSION);
|
||||
let v1 = language::parse_with_report(
|
||||
"arcadia 1; project \"Old\" id \"00000000-0000-0000-0000-000000000001\" {}",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(v1.project.schema_version, 5);
|
||||
assert_eq!(v1.project.schema_version, oa_core::SCHEMA_VERSION);
|
||||
assert_eq!(v1.migration.unwrap().from_schema, 1);
|
||||
}
|
||||
|
||||
|
||||
@@ -26,7 +26,9 @@ fn authored_v3_bundle_upgrade_preserves_includes_nested_ownership_comments_and_i
|
||||
};
|
||||
let compiled = compile_bundle(&bundle, ProjectLimits::default()).unwrap();
|
||||
assert_eq!(compiled.bundle.files.len(), 2);
|
||||
assert!(compiled.bundle.files["project.arcadia"].contains("arcadia 5;"));
|
||||
assert!(
|
||||
compiled.bundle.files["project.arcadia"].contains(&format!("arcadia {SCHEMA_VERSION};"))
|
||||
);
|
||||
assert!(compiled.bundle.files["project.arcadia"].contains("include \"contracts.arcadia\";"));
|
||||
for text in [
|
||||
"// Contracts stay in their original module.",
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
//! Engineering oracles for the distributable current-profile lesson.
|
||||
#[path = "../../../examples/release1/model.rs"]
|
||||
mod teaching;
|
||||
use oa_core::{projection, source_project, transitions::*, validation, *};
|
||||
use teaching::id;
|
||||
|
||||
#[test]
|
||||
fn release1_teaching_regeneration_and_shipped_source_are_exact_with_one_intentional_warning() {
|
||||
let first = teaching::build();
|
||||
let second = teaching::build();
|
||||
assert_eq!(
|
||||
first.project, second.project,
|
||||
"fresh planner UUIDs must not churn the teaching source"
|
||||
);
|
||||
assert_eq!(first.before_transition, second.before_transition);
|
||||
assert_eq!(first.request, second.request);
|
||||
let bundle = teaching::checked_bundle(&first.project);
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../examples/release1");
|
||||
for (name, bytes) in bundle.files {
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(root.join(&name)).unwrap(),
|
||||
bytes,
|
||||
"regenerate {name}"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
persistence::decode(&std::fs::read(root.join("model.json")).unwrap()).unwrap(),
|
||||
first.project
|
||||
);
|
||||
for (path, expected) in [
|
||||
(
|
||||
"transition-before.json",
|
||||
serde_json::to_value(&first.before_transition).unwrap(),
|
||||
),
|
||||
(
|
||||
"transition-request.json",
|
||||
serde_json::to_value(&first.request).unwrap(),
|
||||
),
|
||||
(
|
||||
"expected-diagnostics.json",
|
||||
serde_json::to_value(validation::validate(&first.project)).unwrap(),
|
||||
),
|
||||
] {
|
||||
let shipped: serde_json::Value =
|
||||
serde_json::from_slice(&std::fs::read(root.join(path)).unwrap()).unwrap();
|
||||
assert_eq!(shipped, expected, "regenerate {path}");
|
||||
}
|
||||
let issues = validation::validate(&first.project);
|
||||
assert_eq!(issues.len(), 1, "{issues:?}");
|
||||
assert_eq!(issues[0].severity, validation::Severity::Warning);
|
||||
assert_eq!(issues[0].code, "work_without_behavior_context");
|
||||
assert_eq!(issues[0].element, Some(id("sa_audit_thresholds")));
|
||||
assert!(
|
||||
first
|
||||
.project
|
||||
.elements
|
||||
.values()
|
||||
.filter(|e| e.layer() == Layer::Oa)
|
||||
.all(|e| e.kind() != Kind::Port)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release1_teaching_preserves_explicit_transport_direction_reuse_and_view_identity() {
|
||||
let lesson = teaching::build();
|
||||
let p = &lesson.project;
|
||||
for prefix in ["oa", "sa"] {
|
||||
assert!(
|
||||
matches!(p.elements[&id(&format!("{prefix}_assess_step"))].shared(), Some(SharedBody::Occurrence { body: OccurrenceBody::SequenceReference { sequence: Some(target) }, .. }) if *target == id(&format!("{prefix}_assessment_procedure")))
|
||||
);
|
||||
assert!(
|
||||
matches!(p.elements[&id(&format!("{prefix}_assessment_action"))].shared(), Some(SharedBody::Occurrence { body: OccurrenceBody::Action { action: Some(target), .. }, .. }) if *target == id(&format!("{prefix}_assess")))
|
||||
);
|
||||
assert!(
|
||||
p.relations
|
||||
.values()
|
||||
.any(|relation| relation.kind() == RelationKind::ChainInvolvement
|
||||
&& relation.source() == id(&format!("{prefix}_assessment_procedure"))
|
||||
&& relation.target() == id(&format!("{prefix}_triage")))
|
||||
);
|
||||
assert!(
|
||||
matches!(p.elements[&id(&format!("{prefix}_conversation"))].shared(), Some(SharedBody::Scenario { context: Some(context), .. }) if *context == id(&format!("{prefix}_restore")))
|
||||
);
|
||||
assert!(p.relations.values().any(|relation| relation.kind()
|
||||
== RelationKind::CapabilityInvolvement
|
||||
&& relation.source() == id(&format!("{prefix}_assess"))
|
||||
&& relation.target() == id(&format!("{prefix}_triage"))));
|
||||
let call = &p.elements[&id(&format!("{prefix}_call"))];
|
||||
let reply = &p.elements[&id(&format!("{prefix}_reply"))];
|
||||
let body = |e: &Element| {
|
||||
if let Some(SharedBody::Occurrence { body, .. }) = e.shared() {
|
||||
body.clone()
|
||||
} else {
|
||||
panic!()
|
||||
}
|
||||
};
|
||||
let validation::MessageEndpointResolution::Resolved { source, target } =
|
||||
validation::resolve_message_endpoints(p, &body(call))
|
||||
else {
|
||||
panic!("call not resolved")
|
||||
};
|
||||
assert_eq!(
|
||||
validation::resolve_message_endpoints(p, &body(reply)),
|
||||
validation::MessageEndpointResolution::Resolved {
|
||||
source: target,
|
||||
target: source
|
||||
}
|
||||
);
|
||||
assert!(
|
||||
matches!(p.elements[&id(&format!("{prefix}_use_field"))].shared(), Some(SharedBody::Occurrence { body: OccurrenceBody::SequenceReference { sequence: Some(target) }, .. }) if *target == id(&format!("{prefix}_field_procedure")))
|
||||
);
|
||||
assert!(
|
||||
matches!(p.elements[&id(&format!("{prefix}_use_conversation"))].shared(), Some(SharedBody::Occurrence { body: OccurrenceBody::ScenarioUse { scenario: Some(target), bindings }, .. }) if *target == id(&format!("{prefix}_conversation")) && bindings.len() == 2)
|
||||
);
|
||||
}
|
||||
let full = projection::project_view(p, id("sa_allocation_view"));
|
||||
let focused = projection::project_view(p, id("sa_service_view"));
|
||||
let edges: std::collections::BTreeSet<_> = full.edges.iter().map(|e| e.id).collect();
|
||||
assert_eq!(
|
||||
edges,
|
||||
[
|
||||
id("sa_contact"),
|
||||
id("sa_repair_request"),
|
||||
id("sa_repair_report")
|
||||
]
|
||||
.into()
|
||||
);
|
||||
assert_eq!(focused.edges.len(), 1);
|
||||
assert_eq!(focused.edges[0].id, id("sa_contact"));
|
||||
assert!(
|
||||
p.diagrams[&id("sa_service_view")]
|
||||
.view_options
|
||||
.exchange_labels
|
||||
.label(p, focused.edges[0].id)
|
||||
.contains("Valve repair")
|
||||
);
|
||||
let ports = |view: &projection::ProjectedView| {
|
||||
view.ports
|
||||
.iter()
|
||||
.map(|v| (v.element, v.position))
|
||||
.collect::<std::collections::BTreeMap<_, _>>()
|
||||
};
|
||||
assert_eq!(
|
||||
ports(&full),
|
||||
ports(&focused),
|
||||
"filtering must not move the shared technical port geometry"
|
||||
);
|
||||
assert!(
|
||||
p.traces
|
||||
.values()
|
||||
.any(|t| t.oa == id("oa_contact") && t.sa == id("sa_contact"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release1_teaching_rejects_guessed_ports_and_wrong_performer_without_changing_source() {
|
||||
let lesson = teaching::build();
|
||||
let mut missing_decisions = lesson.request.clone();
|
||||
missing_decisions.communication_endpoints.clear();
|
||||
let plan = plan_transition(&lesson.before_transition, &missing_decisions).unwrap();
|
||||
assert!(
|
||||
!plan.conflicts.is_empty(),
|
||||
"an operational channel does not determine technical ports"
|
||||
);
|
||||
let compiled = source_project::compile_bundle(
|
||||
&teaching::checked_bundle(&lesson.project),
|
||||
source_project::ProjectLimits::default(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut wrong = lesson.project.relations[&id("oa_repair_request")].clone();
|
||||
wrong.body.set_endpoints(id("oa_assess"), id("oa_reset"));
|
||||
assert!(
|
||||
execute_batch(
|
||||
&lesson.project,
|
||||
&[Command::EditRelation(wrong.id, wrong.clone())]
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
let mut candidate = lesson.project.clone();
|
||||
candidate.relations.insert(wrong.id, wrong);
|
||||
assert!(
|
||||
validation::validate(&candidate)
|
||||
.iter()
|
||||
.any(|i| i.severity == validation::Severity::Error
|
||||
&& i.code == "exchange_allocation_performer")
|
||||
);
|
||||
assert!(
|
||||
source_project::update_bundle(
|
||||
&compiled,
|
||||
&candidate,
|
||||
source_project::ProjectLimits::default()
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(compiled.decoded.project, lesson.project);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release1_teaching_review_changes_only_the_selected_counterpart_and_preserves_modules() {
|
||||
let lesson = teaching::build();
|
||||
let mut changed = lesson.project.clone();
|
||||
changed.elements.get_mut(&id("oa_confirm")).unwrap().name = "Confirm stable pressure".into();
|
||||
let compiled = source_project::compile_bundle(
|
||||
&teaching::checked_bundle(&changed),
|
||||
source_project::ProjectLimits::default(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut transition =
|
||||
TransitionRequest::new(TransitionDirection::OaToSa, vec![id("oa_confirm")]);
|
||||
transition.fields.clear();
|
||||
transition.mappings.push(TransitionMapping {
|
||||
source: id("oa_confirm"),
|
||||
target: Some(id("sa_confirm")),
|
||||
target_kind: None,
|
||||
});
|
||||
let mut request = SemanticTransitionRequest::new(transition);
|
||||
request.reviews.push(SemanticReviewRequest {
|
||||
source: id("oa_confirm"),
|
||||
resolutions: vec![FacetResolution {
|
||||
facet: FacetSelector::Header(HeaderFacet::Name),
|
||||
decision: SemanticDecision::TakeSource,
|
||||
}],
|
||||
reset: vec![],
|
||||
});
|
||||
let plan = plan_semantic_transition(&changed, request).unwrap();
|
||||
assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts);
|
||||
let accepted = apply_semantic_transition(&changed, &plan).unwrap();
|
||||
assert_eq!(
|
||||
accepted.elements[&id("sa_confirm")].name,
|
||||
"Confirm stable pressure"
|
||||
);
|
||||
assert_eq!(
|
||||
accepted.elements[&id("sa_control")].name,
|
||||
lesson.project.elements[&id("sa_control")].name
|
||||
);
|
||||
let updated = source_project::update_bundle(
|
||||
&compiled,
|
||||
&accepted,
|
||||
source_project::ProjectLimits::default(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(updated.decoded.project, accepted);
|
||||
assert_eq!(
|
||||
updated.bundle.files.keys().collect::<Vec<_>>(),
|
||||
compiled.bundle.files.keys().collect::<Vec<_>>()
|
||||
);
|
||||
for (path, original) in &compiled.bundle.files {
|
||||
if path != "sa/functions/function_sa_restore_work.arcadia" && path != "traceability.arcadia"
|
||||
{
|
||||
assert_eq!(
|
||||
&updated.bundle.files[path], original,
|
||||
"unrelated module changed: {path}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -733,12 +733,36 @@ fn transitions_create_sa_ports_and_omit_sa_implementation_from_portless_oa() {
|
||||
0
|
||||
);
|
||||
let (mut p, a, b, _, fe) = fixture();
|
||||
allocate(&mut p, a, b);
|
||||
let (system, partner) = allocate(&mut p, a, b);
|
||||
let component_req = component_request(&p, fe, ComponentPortKind::Flow);
|
||||
let boundary = boundary_id(&component_req);
|
||||
p = execute_batch(&p, &plan_component_exchange(&p, &component_req).unwrap()).unwrap();
|
||||
let mut reverse = TransitionRequest::new(TransitionDirection::SaToOa, vec![fe, boundary]);
|
||||
reverse.scope = DependencyScope::Required;
|
||||
// A boundary now has a portless OA CommunicationMean counterpart. Its
|
||||
// participant owners require an explicit operational boundary decision.
|
||||
let incomplete = plan_transition(&p, &reverse).unwrap();
|
||||
for owner in [system, partner] {
|
||||
assert!(
|
||||
incomplete
|
||||
.conflicts
|
||||
.iter()
|
||||
.any(|conflict| conflict.source == Some(owner)
|
||||
&& conflict.message.contains("counterpart kind explicitly"))
|
||||
);
|
||||
}
|
||||
reverse.mappings = vec![
|
||||
TransitionMapping {
|
||||
source: system,
|
||||
target: None,
|
||||
target_kind: Some(Kind::Entity),
|
||||
},
|
||||
TransitionMapping {
|
||||
source: partner,
|
||||
target: None,
|
||||
target_kind: Some(Kind::Actor),
|
||||
},
|
||||
];
|
||||
let plan = plan_transition(&p, &reverse).unwrap();
|
||||
assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts);
|
||||
assert!(plan.warnings.iter().any(|w| w.contains("SA-only omission")));
|
||||
@@ -757,6 +781,28 @@ fn transitions_create_sa_ports_and_omit_sa_implementation_from_portless_oa() {
|
||||
.values()
|
||||
.any(|r| r.kind() == RelationKind::Interaction)
|
||||
);
|
||||
let mean = result
|
||||
.traces
|
||||
.values()
|
||||
.find(|trace| trace.sa == boundary)
|
||||
.unwrap()
|
||||
.oa;
|
||||
let relation = &result.relations[&mean];
|
||||
assert_eq!(relation.kind(), RelationKind::CommunicationMean);
|
||||
assert_eq!(result.elements[&relation.source()].kind(), Kind::Entity);
|
||||
assert_eq!(result.elements[&relation.target()].kind(), Kind::Actor);
|
||||
assert!(
|
||||
result
|
||||
.traces
|
||||
.values()
|
||||
.any(|trace| trace.sa == system && trace.oa == relation.source())
|
||||
);
|
||||
assert!(
|
||||
result
|
||||
.traces
|
||||
.values()
|
||||
.any(|trace| trace.sa == partner && trace.oa == relation.target())
|
||||
);
|
||||
for e in p.elements.values() {
|
||||
assert_eq!(result.elements[&e.id], *e);
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ fn fixture() -> Fixture {
|
||||
description: String::new(),
|
||||
layer: Layer::Sa,
|
||||
projection: ProjectionKind::Overview,
|
||||
view_options: Default::default(),
|
||||
nodes: BTreeMap::new(),
|
||||
routes: vec![],
|
||||
},
|
||||
@@ -69,6 +70,8 @@ fn fixture() -> Fixture {
|
||||
fn route(edge: Id) -> EdgeRoute {
|
||||
EdgeRoute {
|
||||
edge,
|
||||
source_attachment: None,
|
||||
target_attachment: None,
|
||||
waypoints: vec![Position { x: 550., y: 240. }, Position { x: 550., y: 360. }],
|
||||
label: Some(EdgeLabel {
|
||||
fraction: 0.4,
|
||||
@@ -688,6 +691,8 @@ fn old_models_get_empty_routes_and_route_json_is_strict() {
|
||||
serde_json::from_value::<EdgeRoute>(minimal).unwrap(),
|
||||
EdgeRoute {
|
||||
edge: f.edge,
|
||||
source_attachment: None,
|
||||
target_attachment: None,
|
||||
waypoints: vec![],
|
||||
label: None
|
||||
}
|
||||
|
||||
@@ -51,6 +51,7 @@ fn message(
|
||||
call: Option<Id>,
|
||||
) -> OccurrenceBody {
|
||||
OccurrenceBody::Exchange {
|
||||
operation: None,
|
||||
exchange,
|
||||
sender: Some(sender),
|
||||
receiver,
|
||||
|
||||
@@ -271,6 +271,7 @@ fn contextual_links_match_work_exchange_endpoints_and_reject_foreign_owners() {
|
||||
owner,
|
||||
"Contextual message",
|
||||
OccurrenceBody::Exchange {
|
||||
operation: None,
|
||||
exchange: Some(global),
|
||||
sender: None,
|
||||
receiver: None,
|
||||
|
||||
@@ -27,6 +27,8 @@ fn route(p: &Project, d: Id) -> RoutedView {
|
||||
fn set_route(p: &mut Project, d: Id, edge: Id, pins: Vec<Position>, label: Option<EdgeLabel>) {
|
||||
p.diagrams.get_mut(&d).unwrap().routes.push(EdgeRoute {
|
||||
edge,
|
||||
source_attachment: None,
|
||||
target_attachment: None,
|
||||
waypoints: pins,
|
||||
label,
|
||||
});
|
||||
|
||||
@@ -117,6 +117,8 @@ fn authored_overlap_is_exact_and_reset_only_repositions_automatic_text() {
|
||||
.routes
|
||||
.push(EdgeRoute {
|
||||
edge: fe,
|
||||
source_attachment: None,
|
||||
target_attachment: None,
|
||||
waypoints: vec![],
|
||||
label: Some(authored.clone()),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
//! Independent GUI boundary: Presence observes attachment, not record existence.
|
||||
use oa_core::{transitions::*, *};
|
||||
|
||||
fn artifact(p: &mut Project, owner: Id, name: &str) -> Id {
|
||||
let id = Id::new_v4();
|
||||
p.artifact_links.insert(
|
||||
id,
|
||||
ArtifactLink {
|
||||
id,
|
||||
alias: format!("artifact_{}", id.simple()),
|
||||
name: name.into(),
|
||||
description: String::new(),
|
||||
owner,
|
||||
relation: ArtifactRelation::References,
|
||||
uri: "urn:engineering:requirement".into(),
|
||||
revision: None,
|
||||
fragment: Some(String::new()),
|
||||
},
|
||||
);
|
||||
id
|
||||
}
|
||||
|
||||
fn fixture(reverse: bool) -> (Project, Id, Id, TransitionDirection) {
|
||||
let mut p = Project::empty();
|
||||
let oa = p
|
||||
.add_element(Kind::Activity, Layer::Oa, "OA owner", None)
|
||||
.unwrap();
|
||||
let sa = p
|
||||
.add_element(Kind::Function, Layer::Sa, "SA owner", None)
|
||||
.unwrap();
|
||||
let id = Id::new_v4();
|
||||
p.traces.insert(
|
||||
id,
|
||||
Trace {
|
||||
id,
|
||||
alias: format!("trace_{}", id.simple()),
|
||||
name: "Owner review".into(),
|
||||
description: String::new(),
|
||||
oa,
|
||||
sa,
|
||||
kind: TraceKind::Manual,
|
||||
baseline: Some(TraceBaseline {
|
||||
oa: p.properties(oa).unwrap(),
|
||||
sa: p.properties(sa).unwrap(),
|
||||
semantic: None,
|
||||
}),
|
||||
},
|
||||
);
|
||||
if reverse {
|
||||
(p, sa, oa, TransitionDirection::SaToOa)
|
||||
} else {
|
||||
(p, oa, sa, TransitionDirection::OaToSa)
|
||||
}
|
||||
}
|
||||
|
||||
fn removal(
|
||||
source: Id,
|
||||
target: Id,
|
||||
direction: TransitionDirection,
|
||||
source_link: Option<Id>,
|
||||
target_link: Id,
|
||||
) -> SemanticTransitionRequest {
|
||||
let mut transition = TransitionRequest::new(direction, vec![source]);
|
||||
transition.mappings.push(TransitionMapping {
|
||||
source,
|
||||
target: Some(target),
|
||||
target_kind: None,
|
||||
});
|
||||
let mut request = SemanticTransitionRequest::new(transition);
|
||||
let keys = if direction == TransitionDirection::OaToSa {
|
||||
KeyPair {
|
||||
oa: source_link,
|
||||
sa: Some(target_link),
|
||||
}
|
||||
} else {
|
||||
KeyPair {
|
||||
oa: Some(target_link),
|
||||
sa: source_link,
|
||||
}
|
||||
};
|
||||
if let (Some(oa), Some(sa)) = (keys.oa, keys.sa) {
|
||||
request.references.insert(oa, sa);
|
||||
}
|
||||
request.reviews.push(SemanticReviewRequest {
|
||||
source,
|
||||
reset: vec![],
|
||||
resolutions: vec![FacetResolution {
|
||||
facet: FacetSelector::Artifact {
|
||||
keys,
|
||||
facet: ArtifactFacet::Presence,
|
||||
},
|
||||
decision: SemanticDecision::TakeSource,
|
||||
}],
|
||||
});
|
||||
request
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn target_only_artifact_take_preserves_without_fabricated_source_identity() {
|
||||
for reverse in [false, true] {
|
||||
let (mut p, source, target, direction) = fixture(reverse);
|
||||
let link = artifact(&mut p, target, "Target-only requirement");
|
||||
let before = p.clone();
|
||||
let plan =
|
||||
plan_semantic_transition(&p, removal(source, target, direction, None, link)).unwrap();
|
||||
assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts);
|
||||
assert_eq!(p, before, "planning must be pure");
|
||||
let after = apply_semantic_transition(&p, &plan).unwrap();
|
||||
assert_eq!(after.artifact_links, before.artifact_links);
|
||||
assert_eq!(after.elements, before.elements);
|
||||
assert_eq!(after.relations, before.relations);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moved_away_source_artifact_counts_as_absent_from_reviewed_owner() {
|
||||
for reverse in [false, true] {
|
||||
let (mut p, source, target, direction) = fixture(reverse);
|
||||
let (kind, layer) = if reverse {
|
||||
(Kind::Function, Layer::Sa)
|
||||
} else {
|
||||
(Kind::Activity, Layer::Oa)
|
||||
};
|
||||
let elsewhere = p
|
||||
.add_element(kind, layer, "Moved source owner", None)
|
||||
.unwrap();
|
||||
let source_link = artifact(&mut p, elsewhere, "Still exists elsewhere");
|
||||
let target_link = artifact(&mut p, target, "Old counterpart attachment");
|
||||
let original_source = p.artifact_links[&source_link].clone();
|
||||
let plan = plan_semantic_transition(
|
||||
&p,
|
||||
removal(source, target, direction, Some(source_link), target_link),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts);
|
||||
let after = apply_semantic_transition(&p, &plan).unwrap();
|
||||
assert!(!after.artifact_links.contains_key(&target_link));
|
||||
assert_eq!(after.artifact_links[&source_link], original_source);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
//! Direct core oracles for the next existing-record graphical attachment slice.
|
||||
use oa_core::{transitions::*, *};
|
||||
|
||||
struct Fixture {
|
||||
p: Project,
|
||||
owners: [Id; 2],
|
||||
attached: [Id; 2],
|
||||
outside: [Id; 2],
|
||||
trace: Id,
|
||||
child: bool,
|
||||
end: IncidentEnd,
|
||||
}
|
||||
fn trace(p: &mut Project, oa: Id, sa: Id, name: &str) -> Id {
|
||||
let id = Id::new_v4();
|
||||
p.traces.insert(
|
||||
id,
|
||||
Trace {
|
||||
id,
|
||||
alias: format!("trace_{}", id.simple()),
|
||||
name: name.into(),
|
||||
description: String::new(),
|
||||
oa,
|
||||
sa,
|
||||
kind: TraceKind::Manual,
|
||||
baseline: None,
|
||||
},
|
||||
);
|
||||
id
|
||||
}
|
||||
fn fixture(child: bool, end: IncidentEnd) -> Fixture {
|
||||
let mut p = Project::empty();
|
||||
let mut owners = vec![];
|
||||
let mut attached = vec![];
|
||||
let mut outside = vec![];
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let kind = if child {
|
||||
if layer == Layer::Oa {
|
||||
Kind::Activity
|
||||
} else {
|
||||
Kind::Function
|
||||
}
|
||||
} else {
|
||||
Kind::DataType
|
||||
};
|
||||
let owner = p.add_element(kind, layer, "Current owner", None).unwrap();
|
||||
let replacement = p
|
||||
.add_element(kind, layer, "Reviewed replacement", None)
|
||||
.unwrap();
|
||||
let record = if child {
|
||||
p.add_element(kind, layer, "Attached work", Some(owner))
|
||||
.unwrap()
|
||||
} else {
|
||||
let peer = p.add_element(kind, layer, "Other endpoint", None).unwrap();
|
||||
let (source, target) = if end == IncidentEnd::Source {
|
||||
(owner, peer)
|
||||
} else {
|
||||
(peer, owner)
|
||||
};
|
||||
p.relate(
|
||||
RelationKind::DataAssociation,
|
||||
"Incident data association",
|
||||
source,
|
||||
target,
|
||||
)
|
||||
};
|
||||
owners.push(owner);
|
||||
attached.push(record);
|
||||
outside.push(replacement);
|
||||
}
|
||||
for element in p.elements.values_mut() {
|
||||
if let Some(SharedBody::DataType { definition, .. }) = element.shared_mut() {
|
||||
*definition = DataTypeDefinition::Record { supertype: None };
|
||||
}
|
||||
}
|
||||
let trace = trace(&mut p, owners[0], owners[1], "Owner attachment rationale");
|
||||
validate_candidate(&p).unwrap();
|
||||
Fixture {
|
||||
p,
|
||||
owners: [owners[0], owners[1]],
|
||||
attached: [attached[0], attached[1]],
|
||||
outside: [outside[0], outside[1]],
|
||||
trace,
|
||||
child,
|
||||
end,
|
||||
}
|
||||
}
|
||||
fn selector(f: &Fixture) -> FacetSelector {
|
||||
let keys = KeyPair {
|
||||
oa: Some(f.attached[0]),
|
||||
sa: Some(f.attached[1]),
|
||||
};
|
||||
if f.child {
|
||||
FacetSelector::Child(keys)
|
||||
} else {
|
||||
FacetSelector::Incident { keys, end: f.end }
|
||||
}
|
||||
}
|
||||
fn candidate(f: &Fixture, side: usize) -> SemanticRecord {
|
||||
if f.child {
|
||||
let mut value = f.p.elements[&f.attached[side]].clone();
|
||||
value.parent = Some(f.outside[side]);
|
||||
SemanticRecord::Element(value)
|
||||
} else {
|
||||
let mut value = f.p.relations[&f.attached[side]].clone();
|
||||
let (a, b) = value.body.endpoints();
|
||||
value.body.set_endpoints(
|
||||
if f.end == IncidentEnd::Source {
|
||||
f.outside[side]
|
||||
} else {
|
||||
a
|
||||
},
|
||||
if f.end == IncidentEnd::Target {
|
||||
f.outside[side]
|
||||
} else {
|
||||
b
|
||||
},
|
||||
);
|
||||
SemanticRecord::Relation(value)
|
||||
}
|
||||
}
|
||||
fn request(f: &Fixture, reverse: bool, decision: SemanticDecision) -> SemanticTransitionRequest {
|
||||
let source = usize::from(reverse);
|
||||
let target = 1 - source;
|
||||
let mut transition = TransitionRequest::new(
|
||||
if reverse {
|
||||
TransitionDirection::SaToOa
|
||||
} else {
|
||||
TransitionDirection::OaToSa
|
||||
},
|
||||
vec![f.owners[source]],
|
||||
);
|
||||
transition.mappings.push(TransitionMapping {
|
||||
source: f.owners[source],
|
||||
target: Some(f.owners[target]),
|
||||
target_kind: None,
|
||||
});
|
||||
let mut result = SemanticTransitionRequest::new(transition);
|
||||
result.traces.insert(f.owners[source], f.trace);
|
||||
result.references.insert(f.owners[0], f.owners[1]);
|
||||
result.references.insert(f.attached[0], f.attached[1]);
|
||||
result.reviews.push(SemanticReviewRequest {
|
||||
source: f.owners[source],
|
||||
resolutions: vec![FacetResolution {
|
||||
facet: selector(f),
|
||||
decision,
|
||||
}],
|
||||
reset: vec![],
|
||||
});
|
||||
result
|
||||
}
|
||||
fn prepared(p: &Project, request: SemanticTransitionRequest) -> (Project, SemanticTransitionPlan) {
|
||||
let plan = plan_semantic_transition(p, request).unwrap();
|
||||
assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts);
|
||||
let result = prepare_semantic_transition(p, &plan).unwrap().project;
|
||||
(result, plan)
|
||||
}
|
||||
#[test]
|
||||
fn attachment_candidate_changes_existing_parent_or_endpoint_without_attached_record_trace() {
|
||||
for child in [true, false] {
|
||||
for end in [IncidentEnd::Source, IncidentEnd::Target] {
|
||||
if child && end == IncidentEnd::Target {
|
||||
continue;
|
||||
}
|
||||
for reverse in [false, true] {
|
||||
let f = fixture(child, end);
|
||||
assert_eq!(f.p.traces.len(), 1);
|
||||
let side = usize::from(!reverse);
|
||||
let expected = candidate(&f, side);
|
||||
let mut request = request(&f, reverse, SemanticDecision::Candidate);
|
||||
request.candidates.push(expected.clone());
|
||||
let original_source = semantic_record(&f.p, f.attached[1 - side]);
|
||||
let (result, plan) = prepared(&f.p, request);
|
||||
assert_eq!(semantic_record(&result, f.attached[side]), Some(expected));
|
||||
assert_eq!(
|
||||
semantic_record(&result, f.attached[1 - side]),
|
||||
original_source
|
||||
);
|
||||
assert_eq!(
|
||||
result.traces.len(),
|
||||
1,
|
||||
"No relation/child trace is invented"
|
||||
);
|
||||
assert_eq!(plan.reviews[0].acknowledged, vec![selector(&f)]);
|
||||
let source = language::format(&result);
|
||||
assert_eq!(language::parse(&source).unwrap(), result);
|
||||
let mut document = f.p.clone();
|
||||
let receipt = apply_semantic_transition(&document, &plan).unwrap();
|
||||
document = receipt;
|
||||
assert_eq!(document, result);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn incident_absence_take_refuses_but_explicit_candidate_endpoint_is_reviewable() {
|
||||
for end in [IncidentEnd::Source, IncidentEnd::Target] {
|
||||
for reverse in [false, true] {
|
||||
let mut f = fixture(false, end);
|
||||
let source = usize::from(reverse);
|
||||
let SemanticRecord::Relation(detached) = candidate(&f, source) else {
|
||||
unreachable!()
|
||||
};
|
||||
f.p.relations.insert(f.attached[source], detached);
|
||||
let take =
|
||||
plan_semantic_transition(&f.p, request(&f, reverse, SemanticDecision::TakeSource))
|
||||
.unwrap();
|
||||
assert!(
|
||||
take.conflicts
|
||||
.iter()
|
||||
.any(|e| e.contains("explicit final endpoint")),
|
||||
"{:?}",
|
||||
take.conflicts
|
||||
);
|
||||
let mut request = request(&f, reverse, SemanticDecision::Candidate);
|
||||
request.candidates.push(candidate(&f, 1 - source));
|
||||
let _ = prepared(&f.p, request);
|
||||
}
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn attachment_candidate_and_direct_record_keep_conflict_by_actual_slot_in_either_order() {
|
||||
for child in [true, false] {
|
||||
for end in [IncidentEnd::Source, IncidentEnd::Target] {
|
||||
if child && end == IncidentEnd::Target {
|
||||
continue;
|
||||
}
|
||||
for reverse in [false, true] {
|
||||
let mut f = fixture(child, end);
|
||||
let linked = trace(
|
||||
&mut f.p,
|
||||
f.attached[0],
|
||||
f.attached[1],
|
||||
"Independent record rationale",
|
||||
);
|
||||
let source = usize::from(reverse);
|
||||
let target = 1 - source;
|
||||
let mut request = request(&f, reverse, SemanticDecision::Candidate);
|
||||
request.candidates.push(candidate(&f, target));
|
||||
request.transition.sources.push(f.attached[source]);
|
||||
request.transition.mappings.push(TransitionMapping {
|
||||
source: f.attached[source],
|
||||
target: Some(f.attached[target]),
|
||||
target_kind: None,
|
||||
});
|
||||
request.traces.insert(f.attached[source], linked);
|
||||
let facet = if child {
|
||||
FacetSelector::Header(HeaderFacet::Parent)
|
||||
} else {
|
||||
FacetSelector::Relation(if end == IncidentEnd::Source {
|
||||
RelationFacet::Source
|
||||
} else {
|
||||
RelationFacet::Target
|
||||
})
|
||||
};
|
||||
request.reviews.push(SemanticReviewRequest {
|
||||
source: f.attached[source],
|
||||
resolutions: vec![FacetResolution {
|
||||
facet,
|
||||
decision: SemanticDecision::KeepTarget,
|
||||
}],
|
||||
reset: vec![],
|
||||
});
|
||||
for permute in [false, true] {
|
||||
let mut request = request.clone();
|
||||
if permute {
|
||||
request.reviews.reverse();
|
||||
request.transition.sources.reverse();
|
||||
request.transition.mappings.reverse();
|
||||
}
|
||||
let plan = plan_semantic_transition(&f.p, request).unwrap();
|
||||
assert!(
|
||||
plan.conflicts.iter().any(|e| e.contains("Contradictory")),
|
||||
"{child}/{end:?}/{reverse}: {:?}",
|
||||
plan.conflicts
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convergent_owner_attachment_candidates_agree_and_keep_cannot_override_them() {
|
||||
for child in [true, false] {
|
||||
for end in [IncidentEnd::Source, IncidentEnd::Target] {
|
||||
if child && end == IncidentEnd::Target {
|
||||
continue;
|
||||
}
|
||||
let mut f = fixture(child, end);
|
||||
let second_owner =
|
||||
f.p.add_element(
|
||||
f.p.elements[&f.owners[0]].kind(),
|
||||
Layer::Oa,
|
||||
"Second operational concern",
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
if let Some(SharedBody::DataType { definition, .. }) =
|
||||
f.p.elements.get_mut(&second_owner).unwrap().shared_mut()
|
||||
{
|
||||
*definition = DataTypeDefinition::Record { supertype: None };
|
||||
}
|
||||
let second_record = Id::new_v4();
|
||||
if child {
|
||||
let mut value = f.p.elements[&f.attached[0]].clone();
|
||||
value.id = second_record;
|
||||
value.alias = format!("second_{}", second_record.simple());
|
||||
value.parent = Some(second_owner);
|
||||
f.p.elements.insert(second_record, value);
|
||||
} else {
|
||||
let mut value = f.p.relations[&f.attached[0]].clone();
|
||||
value.id = second_record;
|
||||
value.alias = format!("second_{}", second_record.simple());
|
||||
let (a, b) = value.body.endpoints();
|
||||
value.body.set_endpoints(
|
||||
if end == IncidentEnd::Source {
|
||||
second_owner
|
||||
} else {
|
||||
a
|
||||
},
|
||||
if end == IncidentEnd::Target {
|
||||
second_owner
|
||||
} else {
|
||||
b
|
||||
},
|
||||
);
|
||||
f.p.relations.insert(second_record, value);
|
||||
}
|
||||
let second_trace = trace(
|
||||
&mut f.p,
|
||||
second_owner,
|
||||
f.owners[1],
|
||||
"Second owner correspondence",
|
||||
);
|
||||
validate_candidate(&f.p).unwrap();
|
||||
let mut request = request(&f, false, SemanticDecision::Candidate);
|
||||
request.candidates.push(candidate(&f, 1));
|
||||
request.transition.sources.push(second_owner);
|
||||
request.transition.mappings.push(TransitionMapping {
|
||||
source: second_owner,
|
||||
target: Some(f.owners[1]),
|
||||
target_kind: None,
|
||||
});
|
||||
request.references.insert(second_owner, f.owners[1]);
|
||||
request.references.insert(second_record, f.attached[1]);
|
||||
request.traces.insert(second_owner, second_trace);
|
||||
let keys = KeyPair {
|
||||
oa: Some(second_record),
|
||||
sa: Some(f.attached[1]),
|
||||
};
|
||||
let facet = if child {
|
||||
FacetSelector::Child(keys)
|
||||
} else {
|
||||
FacetSelector::Incident { keys, end }
|
||||
};
|
||||
request.reviews.push(SemanticReviewRequest {
|
||||
source: second_owner,
|
||||
resolutions: vec![FacetResolution {
|
||||
facet,
|
||||
decision: SemanticDecision::Candidate,
|
||||
}],
|
||||
reset: vec![],
|
||||
});
|
||||
let (agreed, _) = prepared(&f.p, request.clone());
|
||||
assert_eq!(
|
||||
semantic_record(&agreed, f.attached[1]),
|
||||
Some(candidate(&f, 1))
|
||||
);
|
||||
for keep in [false, true] {
|
||||
for permute in [false, true] {
|
||||
let mut review = request.clone();
|
||||
if keep {
|
||||
review.reviews[1].resolutions[0].decision = SemanticDecision::KeepTarget;
|
||||
}
|
||||
if permute {
|
||||
review.reviews.reverse();
|
||||
review.transition.sources.reverse();
|
||||
review.transition.mappings.reverse();
|
||||
}
|
||||
let plan = plan_semantic_transition(&f.p, review).unwrap();
|
||||
if keep {
|
||||
assert!(
|
||||
plan.conflicts.iter().any(|e| e.contains("Contradictory")),
|
||||
"{:?}",
|
||||
plan.conflicts
|
||||
);
|
||||
} else {
|
||||
assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts);
|
||||
assert_eq!(
|
||||
prepare_semantic_transition(&f.p, &plan).unwrap().project,
|
||||
agreed
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,410 @@
|
||||
use oa_core::{source_project::*, transitions::*, view_options::*, *};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
#[test]
|
||||
fn source6_traceability_default_keeps_both_layers_and_indexed_projection_matches() {
|
||||
let mut p = Project::empty();
|
||||
let oa = p
|
||||
.add_element(Kind::Activity, Layer::Oa, "OA send", None)
|
||||
.unwrap();
|
||||
let ob = p
|
||||
.add_element(Kind::Activity, Layer::Oa, "OA receive", None)
|
||||
.unwrap();
|
||||
let sa = p
|
||||
.add_element(Kind::Function, Layer::Sa, "SA send", None)
|
||||
.unwrap();
|
||||
let sb = p
|
||||
.add_element(Kind::Function, Layer::Sa, "SA receive", None)
|
||||
.unwrap();
|
||||
let interaction = p.relate(RelationKind::Interaction, "OA report", oa, ob);
|
||||
let fe = Id::new_v4();
|
||||
let request = FunctionalExchangeRequest {
|
||||
id: fe,
|
||||
alias: "sa_report".into(),
|
||||
name: "SA report".into(),
|
||||
description: String::new(),
|
||||
source: sa,
|
||||
target: sb,
|
||||
source_port: PortChoice::Auto,
|
||||
target_port: PortChoice::Auto,
|
||||
items: vec![],
|
||||
};
|
||||
p = execute_batch(&p, &plan_functional_exchange(&p, &request).unwrap()).unwrap();
|
||||
let d = p.ensure_diagram(Layer::Oa);
|
||||
p.diagrams.get_mut(&d).unwrap().projection = ProjectionKind::Traceability;
|
||||
for (i, id) in [oa, ob, sa, sb].into_iter().enumerate() {
|
||||
p.place(
|
||||
d,
|
||||
id,
|
||||
Position {
|
||||
x: 300. * i as f32,
|
||||
y: 0.,
|
||||
},
|
||||
);
|
||||
}
|
||||
let index = ViewIndex::new(&p);
|
||||
let view = projection::project_view_with_index(&p, d, &index);
|
||||
assert!(view.edges.iter().any(|e| e.id == interaction));
|
||||
assert!(view.edges.iter().any(|e| e.id == fe));
|
||||
assert_eq!(view, projection::project_view(&p, d));
|
||||
p.diagrams
|
||||
.get_mut(&d)
|
||||
.unwrap()
|
||||
.view_options
|
||||
.hidden_edges
|
||||
.push(fe);
|
||||
validate_candidate(&p).unwrap();
|
||||
assert!(
|
||||
!projection::project_view_with_index(&p, d, &index)
|
||||
.edges
|
||||
.iter()
|
||||
.any(|e| e.id == fe)
|
||||
);
|
||||
assert!(
|
||||
projection::project_view_with_index(&p, d, &index)
|
||||
.edges
|
||||
.iter()
|
||||
.any(|e| e.id == interaction)
|
||||
);
|
||||
p.diagrams.get_mut(&d).unwrap().projection = ProjectionKind::Overview;
|
||||
assert!(validate_candidate(&p).is_err());
|
||||
p.diagrams.get_mut(&d).unwrap().projection = ProjectionKind::Traceability;
|
||||
p.diagrams.get_mut(&d).unwrap().view_options.relation_kinds = RelationKinds::Only(vec![]);
|
||||
assert!(
|
||||
projection::project_view_with_index(&p, d, &index)
|
||||
.edges
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source6_edge_availability_uses_actual_shared_projection_fold_and_placement_rules() {
|
||||
let mut p = Project::empty();
|
||||
let parent = p
|
||||
.add_element(Kind::Entity, Layer::Oa, "Team", None)
|
||||
.unwrap();
|
||||
let a = p
|
||||
.add_element(Kind::Entity, Layer::Oa, "A", Some(parent))
|
||||
.unwrap();
|
||||
let b = p
|
||||
.add_element(Kind::Entity, Layer::Oa, "B", Some(parent))
|
||||
.unwrap();
|
||||
let mean = p.relate(
|
||||
RelationKind::CommunicationMean,
|
||||
"Internal communication",
|
||||
a,
|
||||
b,
|
||||
);
|
||||
let d = p.ensure_diagram(Layer::Oa);
|
||||
let outer = p.place(d, parent, Position { x: 0., y: 0. });
|
||||
p.place(d, a, Position { x: 20., y: 100. });
|
||||
let nb = p.place(d, b, Position { x: 350., y: 100. });
|
||||
assert_eq!(
|
||||
projection::edge_availability(&p, &p.diagrams[&d])[&mean],
|
||||
EdgeAvailability::Visible
|
||||
);
|
||||
p.diagrams
|
||||
.get_mut(&d)
|
||||
.unwrap()
|
||||
.view_options
|
||||
.hidden_edges
|
||||
.push(mean);
|
||||
assert_eq!(
|
||||
projection::edge_availability(&p, &p.diagrams[&d])[&mean],
|
||||
EdgeAvailability::Visible
|
||||
);
|
||||
p.diagrams
|
||||
.get_mut(&d)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&outer)
|
||||
.unwrap()
|
||||
.collapsed = true;
|
||||
assert_eq!(
|
||||
projection::edge_availability(&p, &p.diagrams[&d])[&mean],
|
||||
EdgeAvailability::FoldedInternal
|
||||
);
|
||||
p.diagrams
|
||||
.get_mut(&d)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&outer)
|
||||
.unwrap()
|
||||
.collapsed = false;
|
||||
p.diagrams.get_mut(&d).unwrap().nodes.remove(&nb);
|
||||
assert_eq!(
|
||||
projection::edge_availability(&p, &p.diagrams[&d])[&mean],
|
||||
EdgeAvailability::EndpointNotShown {
|
||||
source: false,
|
||||
target: true
|
||||
}
|
||||
);
|
||||
p.diagrams.get_mut(&d).unwrap().projection = ProjectionKind::FunctionalDataflow;
|
||||
assert_eq!(
|
||||
projection::edge_availability(&p, &p.diagrams[&d])[&mean],
|
||||
EdgeAvailability::OutsideProjection
|
||||
);
|
||||
}
|
||||
|
||||
fn setup() -> (Project, Id, Id, Id, Id) {
|
||||
let mut p = Project::empty();
|
||||
let a = p.add_element(Kind::Entity, Layer::Oa, "A", None).unwrap();
|
||||
let b = p.add_element(Kind::Actor, Layer::Oa, "B", None).unwrap();
|
||||
let exchange = p.relate(RelationKind::CommunicationMean, "Communication", a, b);
|
||||
let category = p
|
||||
.add_element(
|
||||
Kind::ExchangeCategory,
|
||||
Layer::Oa,
|
||||
"Participant exchanges",
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
*p.elements.get_mut(&category).unwrap().shared_mut().unwrap() = SharedBody::ExchangeCategory {
|
||||
family: ExchangeFamily::Participant,
|
||||
exchanges: vec![exchange],
|
||||
};
|
||||
let diagram = p.ensure_diagram(Layer::Oa);
|
||||
p.place(diagram, a, Position { x: 0., y: 0. });
|
||||
p.place(diagram, b, Position { x: 400., y: 0. });
|
||||
p.diagrams.get_mut(&diagram).unwrap().view_options = ViewOptions {
|
||||
participant_categories: CategorySelection::Any(vec![category]),
|
||||
hidden_edges: vec![exchange],
|
||||
exchange_labels: ExchangeLabels::NameAndItems,
|
||||
..Default::default()
|
||||
};
|
||||
(p, exchange, category, diagram, a)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source6_views_round_trip_strict_fields_and_reference_aliases() {
|
||||
let (p, _, category, _, _) = setup();
|
||||
validate_candidate(&p).unwrap();
|
||||
let source = language::format(&p);
|
||||
assert!(source.contains("exchange_category"));
|
||||
assert!(source.contains(&format!("@{}", p.elements[&category].alias)));
|
||||
assert_eq!(language::parse(&source).unwrap(), p);
|
||||
assert_eq!(
|
||||
persistence::decode(&serde_json::to_vec(&p).unwrap()).unwrap(),
|
||||
p
|
||||
);
|
||||
let bundle = SourceBundle {
|
||||
entry: "model.arcadia".into(),
|
||||
files: BTreeMap::from([("model.arcadia".into(), source)]),
|
||||
};
|
||||
assert_eq!(
|
||||
compile_bundle(&bundle, ProjectLimits::default())
|
||||
.unwrap()
|
||||
.decoded
|
||||
.project,
|
||||
p
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source6_view_defaults_cannot_be_smuggled_under_old_json_headers() {
|
||||
let mut p = Project::empty();
|
||||
p.ensure_diagram(Layer::Oa);
|
||||
let current = serde_json::to_value(&p).unwrap();
|
||||
for version in 3..=5 {
|
||||
let mut old = current.clone();
|
||||
old["schema_version"] = version.into();
|
||||
for diagram in old["diagrams"].as_object_mut().unwrap().values_mut() {
|
||||
diagram["view_options"] = serde_json::json!({});
|
||||
}
|
||||
let error = persistence::decode(&serde_json::to_vec(&old).unwrap())
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(
|
||||
error.contains("requires schema/source 6"),
|
||||
"{version}: {error}"
|
||||
);
|
||||
for diagram in old["diagrams"].as_object_mut().unwrap().values_mut() {
|
||||
diagram.as_object_mut().unwrap().remove("view_options");
|
||||
}
|
||||
assert_eq!(
|
||||
persistence::decode(&serde_json::to_vec(&old).unwrap()).unwrap(),
|
||||
p
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source6_category_and_saved_selection_reidentify_delete_preserve_empty_filters() {
|
||||
let (p, exchange, category, diagram, a) = setup();
|
||||
let replacement = Id::new_v4();
|
||||
let q = execute_batch(&p, &[Command::Reidentify(exchange, replacement)]).unwrap();
|
||||
assert!(
|
||||
matches!(q.elements[&category].shared(),Some(SharedBody::ExchangeCategory{exchanges,..}) if exchanges==&[replacement])
|
||||
);
|
||||
assert_eq!(
|
||||
q.diagrams[&diagram].view_options.hidden_edges,
|
||||
vec![replacement]
|
||||
);
|
||||
let r = execute_batch(&q, &[Command::Delete(replacement)]).unwrap();
|
||||
assert!(r.contains(category));
|
||||
assert!(r.contains(a));
|
||||
assert!(r.diagrams[&diagram].view_options.hidden_edges.is_empty());
|
||||
assert!(
|
||||
matches!(r.elements[&category].shared(),Some(SharedBody::ExchangeCategory{exchanges,..}) if exchanges.is_empty())
|
||||
);
|
||||
let s = execute_batch(&r, &[Command::Delete(category)]).unwrap();
|
||||
assert_eq!(
|
||||
s.diagrams[&diagram].view_options.participant_categories,
|
||||
CategorySelection::Any(vec![])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source6_category_transition_requires_mapped_members_and_reviews_exact_set() {
|
||||
let (mut p, exchange, category, _, _) = setup();
|
||||
let sa = p
|
||||
.add_element(Kind::System, Layer::Sa, "System", None)
|
||||
.unwrap();
|
||||
let sb = p
|
||||
.add_element(Kind::SystemActor, Layer::Sa, "External", None)
|
||||
.unwrap();
|
||||
let port = |p: &mut Project, owner, name: &str| {
|
||||
let id = Id::new_v4();
|
||||
p.elements.insert(
|
||||
id,
|
||||
Element {
|
||||
id,
|
||||
alias: p.fresh_alias(name, "port", id),
|
||||
name: name.into(),
|
||||
description: String::new(),
|
||||
parent: Some(owner),
|
||||
body: ElementBody::shared_in(
|
||||
Layer::Sa,
|
||||
SharedBody::Port {
|
||||
contract: PortContract::Component {
|
||||
kind: ComponentPortKind::Standard,
|
||||
orientation: ComponentPortOrientation::Unset,
|
||||
},
|
||||
legacy: None,
|
||||
},
|
||||
),
|
||||
},
|
||||
);
|
||||
id
|
||||
};
|
||||
let pa = port(&mut p, sa, "A endpoint");
|
||||
let pb = port(&mut p, sb, "B endpoint");
|
||||
let boundary = p.relate(RelationKind::BoundaryExchange, "Boundary", pa, pb);
|
||||
let mut request = TransitionRequest::new(TransitionDirection::OaToSa, vec![category]);
|
||||
assert!(!plan_transition(&p, &request).unwrap().conflicts.is_empty());
|
||||
request.mappings.push(TransitionMapping {
|
||||
source: exchange,
|
||||
target: Some(boundary),
|
||||
target_kind: None,
|
||||
});
|
||||
let plan = plan_transition(&p, &request).unwrap();
|
||||
assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts);
|
||||
let q = apply_transition(&p, &plan).unwrap();
|
||||
let trace = q.traces.values().find(|t| t.oa == category).unwrap();
|
||||
let target = trace.sa;
|
||||
assert!(
|
||||
matches!(q.elements[&target].shared(),Some(SharedBody::ExchangeCategory{family:ExchangeFamily::Participant,exchanges}) if exchanges==&[boundary])
|
||||
);
|
||||
let facets = semantic_facets(&q, trace, &BTreeMap::from([(exchange, boundary)])).unwrap();
|
||||
assert!(
|
||||
facets
|
||||
.iter()
|
||||
.any(|f| f.selector == FacetSelector::Category(CategoryFacet::Family))
|
||||
);
|
||||
assert!(facets.iter().any(|f| matches!(
|
||||
f.selector,
|
||||
FacetSelector::Member {
|
||||
collection: ReferenceCollection::CategoryExchanges,
|
||||
..
|
||||
}
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source6_new_historical_facts_are_rejected_under_source5_even_without_live_categories() {
|
||||
let mut p = Project::empty();
|
||||
let oa = p.add_element(Kind::Entity, Layer::Oa, "OA", None).unwrap();
|
||||
let sa = p
|
||||
.add_element(Kind::SystemActor, Layer::Sa, "SA", None)
|
||||
.unwrap();
|
||||
for fact in [
|
||||
FacetReview::Category(CategoryReview::Family(Reviewed {
|
||||
oa: Observed::NotApplicable,
|
||||
sa: Observed::NotApplicable,
|
||||
})),
|
||||
FacetReview::Member {
|
||||
collection: ReferenceCollection::CategoryExchanges,
|
||||
keys: KeyPair {
|
||||
oa: Some(Id::new_v4()),
|
||||
sa: None,
|
||||
},
|
||||
review: Reviewed {
|
||||
oa: Observed::NotApplicable,
|
||||
sa: Observed::NotApplicable,
|
||||
},
|
||||
},
|
||||
] {
|
||||
let id = Id::new_v4();
|
||||
p.traces.clear();
|
||||
p.traces.insert(
|
||||
id,
|
||||
Trace {
|
||||
id,
|
||||
alias: "review".into(),
|
||||
name: "History".into(),
|
||||
description: String::new(),
|
||||
oa,
|
||||
sa,
|
||||
kind: TraceKind::Manual,
|
||||
baseline: Some(TraceBaseline {
|
||||
oa: p.properties(oa).unwrap(),
|
||||
sa: p.properties(sa).unwrap(),
|
||||
semantic: Some(SemanticBaseline { facets: vec![fact] }),
|
||||
}),
|
||||
},
|
||||
);
|
||||
let mut json = serde_json::to_value(&p).unwrap();
|
||||
json["schema_version"] = 5.into();
|
||||
let error = persistence::decode(&serde_json::to_vec(&json).unwrap())
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(
|
||||
error.contains("category semantic facts require schema/source 6"),
|
||||
"{error}"
|
||||
);
|
||||
let old =
|
||||
language::format(&p).replacen(&format!("arcadia {SCHEMA_VERSION};"), "arcadia 5;", 1);
|
||||
assert!(language::parse(&old).unwrap_err().iter().any(|d| {
|
||||
d.message
|
||||
.contains("category semantic facts require schema/source 6")
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source6_projection_filters_keep_placements_and_label_modes_are_presentation_only() {
|
||||
let (mut p, exchange, _, diagram, _) = setup();
|
||||
let hidden = projection::project_view(&p, diagram);
|
||||
assert!(hidden.edges.is_empty());
|
||||
p.diagrams
|
||||
.get_mut(&diagram)
|
||||
.unwrap()
|
||||
.view_options
|
||||
.hidden_edges
|
||||
.clear();
|
||||
let shown = projection::project_view(&p, diagram);
|
||||
assert_eq!(shown.edges.len(), 1);
|
||||
assert_eq!(shown.edges[0].id, exchange);
|
||||
assert_eq!(shown.nodes, hidden.nodes);
|
||||
assert_eq!(shown.edges[0].source_port, None);
|
||||
assert_eq!(shown.edges[0].target_port, None);
|
||||
assert!(!shown.edges[0].oriented);
|
||||
let route = routing::route_diagram(&p, &p.diagrams[&diagram], &shown);
|
||||
assert!(route.edges[&exchange].label.text.contains("No items"));
|
||||
p.diagrams
|
||||
.get_mut(&diagram)
|
||||
.unwrap()
|
||||
.view_options
|
||||
.relation_kinds = RelationKinds::Only(vec![]);
|
||||
assert!(projection::project_view(&p, diagram).edges.is_empty());
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user