Compare commits
104
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
88d1e5eb89 | ||
|
|
47bec93bcf | ||
|
|
2568c70455 | ||
|
|
4db1ef543c | ||
|
|
0eca66f669 | ||
|
|
2488ee5955 | ||
|
|
46b4f3c0d0 | ||
|
|
f23615ffeb | ||
|
|
fdf756e6a4 | ||
|
|
7aa007b077 | ||
|
|
0c53c21030 | ||
|
|
4dd0f60c75 | ||
|
|
60f714853f | ||
|
|
5511755a5e | ||
|
|
33e30c1cd7 | ||
|
|
51846f5a2b | ||
|
|
436ea4b29a | ||
|
|
46c8783c72 | ||
|
|
0d39d58ebd | ||
|
|
cb91525cfd | ||
|
|
108b1dd208 | ||
|
|
a57c334b30 | ||
|
|
32334c6631 | ||
|
|
09da0d90aa | ||
|
|
865483b4c6 | ||
|
|
f796a27277 | ||
|
|
321897fd55 | ||
|
|
4da92ab481 | ||
|
|
7831abeed5 | ||
|
|
fe8364e7eb | ||
|
|
3ade397008 | ||
|
|
29084050a7 | ||
|
|
cb611a71df | ||
|
|
fb77b857d2 | ||
|
|
a9a5768144 | ||
|
|
d041212d17 | ||
|
|
b96286ea37 | ||
|
|
dd42d0ab01 | ||
|
|
023e3a8f09 | ||
|
|
52f6e176cd | ||
|
|
b853a1eca3 | ||
|
|
e207b04114 | ||
|
|
c81e285573 | ||
|
|
c6d296a7e2 | ||
|
|
80cf197be6 | ||
|
|
865cd55e21 | ||
|
|
6d2b9619e3 | ||
|
|
65a1fef17c | ||
|
|
ef1a4478c5 | ||
|
|
632c8feb36 | ||
|
|
aa40345281 | ||
|
|
8547b85825 | ||
|
|
4ecad78433 | ||
|
|
ff615d0ee0 | ||
|
|
eb5161b207 | ||
|
|
aed40d7464 | ||
|
|
b6b76efc0d | ||
|
|
01bb52a76d | ||
|
|
1797516f46 | ||
|
|
e2ecfea69b | ||
|
|
4b6b5fc197 | ||
|
|
2b2d2bc18b | ||
|
|
c091f80e6e | ||
|
|
14b8e187ea | ||
|
|
00ac6eb86c | ||
|
|
843635ed26 | ||
|
|
2c0f93839f | ||
|
|
09b7ebddc4 | ||
|
|
cf04ff1442 | ||
|
|
1a68ae0795 | ||
|
|
0e47384532 | ||
|
|
28ebbbbc4c | ||
|
|
4f5906b0ff | ||
|
|
f08507a789 | ||
|
|
691ef26a01 | ||
|
|
5930f91ed6 | ||
|
|
1b80671dd0 | ||
|
|
49a2446f85 | ||
|
|
67ed8e09be | ||
|
|
1086e54ecc | ||
|
|
f4cb486243 | ||
|
|
8c600087dc | ||
|
|
1b0b072c4b | ||
|
|
cf337851ff | ||
|
|
767634aea5 | ||
|
|
e7fa061e23 | ||
|
|
d114a5d40c | ||
|
|
c6db9d41d6 | ||
|
|
332e46bb9e | ||
|
|
6ad3cb92df | ||
|
|
036276813c | ||
|
|
6bb2583b4a | ||
|
|
1a07475cfc | ||
|
|
d130013155 | ||
|
|
60d162c259 | ||
|
|
ae1602ad7a | ||
|
|
29df024a7f | ||
|
|
c6c07cc3a3 | ||
|
|
02f99a9ac5 | ||
|
|
c746ab63c9 | ||
|
|
346fb24692 | ||
|
|
b2d713e774 | ||
|
|
ab960cbebb | ||
|
|
ab02b1b255 |
@@ -12,3 +12,75 @@
|
||||
*.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
|
||||
harness/history/2026-09-11-ytr83r-integration/** -text whitespace=cr-at-eol
|
||||
crates/oa-desktop/src/recovery/fixtures/*.json -text
|
||||
harness/fixtures/source7-native/** -text
|
||||
harness/fixtures/basic-editing/** -text
|
||||
harness/fixtures/scenario-authoring/base/** -text whitespace=cr-at-eol
|
||||
harness/experiments/textual-authoring-20260911/** -text whitespace=cr-at-eol
|
||||
harness/experiments/textual-authoring-20260911/observed/split-entry.arcadia whitespace=-blank-at-eol,cr-at-eol
|
||||
harness/experiments/analysis-context-20260911/** -text whitespace=cr-at-eol
|
||||
harness/experiments/constructor-defaults-20260911/** -text whitespace=blank-at-eol,blank-at-eof,space-before-tab,cr-at-eol
|
||||
harness/experiments/constructor-defaults-20260911/results/*.arcadia whitespace=-blank-at-eol,cr-at-eol
|
||||
harness/experiments/behavior-creation-native-20260911/** -text whitespace=blank-at-eol,blank-at-eof,space-before-tab,cr-at-eol
|
||||
harness/behavior-creation-contract.rs -text whitespace=cr-at-eol
|
||||
harness/experiments/source-cli-independent-20260911/** -text whitespace=cr-at-eol
|
||||
harness/fixtures/scenario-lifecycle-native/**/*.arcadia -text whitespace=cr-at-eol
|
||||
harness/experiments/lifecycle-native-ytr83r-20260911/** -text whitespace=cr-at-eol
|
||||
harness/experiments/lifecycle-native-ytr83r-20260911/*.log -text -whitespace
|
||||
harness/experiments/lifecycle-native-ui-oa-20260911/** -text -whitespace
|
||||
harness/experiments/lifecycle-native-ui-sa-20260911/** -text -whitespace
|
||||
harness/experiments/wsl-postboot-ytr83r-20260911/** -text -whitespace
|
||||
|
||||
# Preserve checksum-bound font notices verbatim through Git round trips.
|
||||
scripts/windows-notices/GNOME-Cantarell-61362600e104-COPYING -text
|
||||
scripts/windows-notices/Fira-OFL.txt -text
|
||||
scripts/windows-notices/FiraSans-Regular-NOTICE.txt -text
|
||||
scripts/windows-notices/llvm-6dfe1677ab8d-libunwind-LICENSE.TXT -text
|
||||
|
||||
harness/experiments/lifecycle-header-overflow-before-20260911/** -text -whitespace
|
||||
harness/history/2026-09-11-ytr83r-record-migration/STATE-before.md -text -whitespace
|
||||
|
||||
harness/fixtures/staged-source-authoring/** -text whitespace=cr-at-eol
|
||||
harness/staged-source-authoring-contract.rs -text whitespace=cr-at-eol
|
||||
|
||||
harness/experiments/contextual-lifecycle-integration-ytr83r-20260911/** -text -whitespace
|
||||
harness/experiments/windows-runtime-321897f-20260911/** -text -whitespace
|
||||
harness/child-work-native-contract.rs -text whitespace=cr-at-eol
|
||||
harness/fixtures/child-work-native/** -text whitespace=cr-at-eol
|
||||
harness/experiments/windows-gates-865483b-20260911/** -text -whitespace
|
||||
harness/experiments/native-child-oa-865483b-20260911/** -text -whitespace
|
||||
harness/experiments/packaged-server-865483b-20260911/** -text -whitespace
|
||||
|
||||
harness/experiments/canvas-union-a57c334-20260911/** -text -whitespace
|
||||
harness/experiments/windows-gates-a57c334-20260911/** -text -whitespace
|
||||
harness/experiments/native-child-continuation-865483b-20260911/** -text -whitespace
|
||||
harness/experiments/lifecycle-header-correction-native-865483b-20260911/** -text -whitespace
|
||||
harness/release-native-child-continuation-review-20260911.md -text -whitespace
|
||||
|
||||
harness/experiments/packaged-gui-close-865483b-20260911/** -text -whitespace
|
||||
harness/release-grapheme-shortening-review-20260911.md -text -whitespace
|
||||
harness/experiments/staged-source-native-a57c334-20260911/** -text -whitespace
|
||||
harness/experiments/grapheme-windows-cb91525-20260911/** -text -whitespace
|
||||
harness/experiments/canvas-native-close-a57c334-20260911/** -text -whitespace
|
||||
harness/release-staged-native-a57-independent-review-20260911.md -text -whitespace
|
||||
harness/release-saved-destination-independent-review-20260912.md -text -whitespace
|
||||
harness/experiments/repair-union-windows-33e30c1-20260912/** -text -whitespace
|
||||
harness/experiments/canvas-camera-a57c334-20260912/** -text -whitespace
|
||||
harness/release-camera-a57-independent-review-20260912.md -text -whitespace
|
||||
|
||||
harness/experiments/canvas-repair-package-33e30c1-20260912/** -text -whitespace
|
||||
harness/experiments/canvas-repair-native-33e30c1-20260912/** -text -whitespace
|
||||
harness/experiments/canvas-repair-server-33e30c1-20260912/** -text -whitespace
|
||||
|
||||
harness/experiments/canvas-repair-publication-20260912/** -text -whitespace
|
||||
|
||||
@@ -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
+17
@@ -2139,10 +2139,20 @@ dependencies = [
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "oa-cli"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"oa-core",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "oa-core"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"same-file",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -2155,11 +2165,18 @@ dependencies = [
|
||||
name = "oa-desktop"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"iced",
|
||||
"iced_runtime",
|
||||
"oa-core",
|
||||
"oa-server",
|
||||
"reqwest",
|
||||
"rfd",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"unicode-segmentation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
[workspace]
|
||||
members = ["crates/oa-core", "crates/oa-desktop", "crates/oa-server"]
|
||||
members = ["crates/oa-core", "crates/oa-desktop", "crates/oa-server", "crates/oa-cli"]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
|
||||
@@ -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,14 +2,14 @@
|
||||
|
||||
Arcadia Studio is a native Rust/Iced modeling workbench for system engineers. V2 covers Operational Analysis (OA) and System Analysis (SA), with graphical and textual editing of one typed model and a deployable Rust REST service. It is an independent implementation inspired by Arcadia and Eclipse Capella, not a complete Capella or SysML implementation.
|
||||
|
||||
The normative [language and editing contract](docs/language.rst) defines supported semantics, both representations, validation and migration. [Scope](docs/scope.md) states the boundaries. The [acceptance](docs/acceptance.md), [validation](docs/validation.md) and [current state](harness/STATE.md) records document V2 tests and native Windows/Linux Computer Use verification.
|
||||
The normative [language and editing contract](docs/language.rst) defines supported semantics, both representations, validation and migration. [Scope](docs/scope.md) states the boundaries. The [acceptance](docs/acceptance.md) and [validation](docs/validation.md) records document the earlier V2 baseline. [Current state](harness/STATE.md) and the [2026-09-06 coverage audit](harness/release-current-coverage-20260906.md) track subsequent implementation and outstanding release gates; the current first release is not yet accepted.
|
||||
|
||||
## Build and run
|
||||
|
||||
Install stable Rust and your platform's native compiler. The workspace declares Rust 1.90; the recorded development builds use Rust 1.98.1. Windows requires Visual Studio Build Tools with **Desktop development with C++**. Ubuntu/Debian development dependencies are:
|
||||
Install stable Rust and your platform's native compiler. The workspace declares Rust 1.90; the recorded development builds use Rust 1.98.1. The standard Windows MSVC path uses Visual Studio Build Tools with **Desktop development with C++**. Ubuntu/Debian development dependencies are:
|
||||
|
||||
```sh
|
||||
sudo apt-get install build-essential pkg-config libxkbcommon-dev libwayland-dev libvulkan-dev libx11-dev libxrandr-dev libxi-dev
|
||||
sudo apt-get install build-essential pkg-config libxkbcommon-dev libxkbcommon-x11-0 libwayland-dev libvulkan-dev libx11-dev libxrandr-dev libxi-dev
|
||||
```
|
||||
|
||||
From the repository root:
|
||||
@@ -18,14 +18,26 @@ From the repository root:
|
||||
cargo run --locked -p oa-desktop --bin arcadia-studio
|
||||
```
|
||||
|
||||
Build both desktop and server release executables:
|
||||
Build the desktop, server and source CLI release executables:
|
||||
|
||||
```sh
|
||||
cargo build --release --workspace --locked
|
||||
```
|
||||
|
||||
Stage the complete teaching examples beside these executables so **Home → Open water-pressure walkthrough** works in an optimized source build. From the repository root, use `Copy-Item -Recurse -Force examples target/release/` in PowerShell or `cp -R examples target/release/` on Linux. If you set a different Cargo target directory or pass `--target`, use that build's executable directory instead of `target/release`. The packaging scripts include these examples automatically.
|
||||
|
||||
Run `target/release/arcadia-studio.exe` on Windows or `./target/release/arcadia-studio` on Linux. No task-specific cache directory is required. A graphical desktop and compatible graphics driver are required; the application uses Iced 0.14 with WGPU and X11/Wayland support. It does not require Java, Eclipse, a browser, an account or a running server.
|
||||
|
||||
An optional Windows path uses the `stable-x86_64-pc-windows-gnullvm` Rust toolchain with LLVM-MinGW. Put LLVM-MinGW's `bin` directory on `PATH`, including `clang.exe` and `llvm-windres.exe`, then run:
|
||||
|
||||
```powershell
|
||||
rustup toolchain install stable-x86_64-pc-windows-gnullvm
|
||||
$env:CARGO_TARGET_X86_64_PC_WINDOWS_GNULLVM_LINKER = 'clang.exe'
|
||||
cargo +stable-x86_64-pc-windows-gnullvm build --release --workspace --locked
|
||||
```
|
||||
|
||||
GNU/gnullvm builds require a resource compiler to embed the normal-user (`asInvoker`) manifest in executables and tests. The build selects `llvm-windres` for gnullvm or `windres` for GNU; set `RC` to its executable path if it is not on `PATH`. This avoids Windows treating installer-like test filenames as elevation requests. The current machine uses user-local Rust 1.98.1 and LLVM-MinGW 20260826 UCRT; compilation and test progress are recorded in [current state](harness/STATE.md). That evidence is separate from native workflow and release-package acceptance.
|
||||
|
||||
On the tested Ubuntu 24.04/WSLg environment, Mesa's Vulkan software adapter is required:
|
||||
|
||||
```sh
|
||||
@@ -35,20 +47,39 @@ WGPU_BACKEND=vulkan VK_DRIVER_FILES=/usr/share/vulkan/icd.d/lvp_icd.x86_64.json
|
||||
|
||||
A compatible native Linux graphics adapter can use the ordinary command. This software-adapter workaround requires the indicated driver file. See the [platform verification record](docs/validation.md) for tested environments.
|
||||
|
||||
V2 release bundles are `dist/ArcadiaStudio-Windows.zip` and `dist/ArcadiaStudio-Linux.tar.gz`, with archive checksums in `dist/SHA256SUMS.txt`. Extract the bundle and run its `arcadia-studio.exe` or `./arcadia-studio`; Rust is not needed to run a packaged executable. Both bundles include the headless server, examples, specifications and deployment instructions. Linux bundles target the documented Ubuntu 24.04 runtime. Packaged executable and archive evidence is recorded in [package verification](harness/verification-v2-packages.md).
|
||||
The earlier V2 package record describes `dist/ArcadiaStudio-Windows.zip` and `dist/ArcadiaStudio-Linux.tar.gz`, with archive checksums in `dist/SHA256SUMS.txt`. Extract a verified bundle and run its `arcadia-studio.exe` or `./arcadia-studio`; Rust is not needed to run a packaged executable. Those bundles include the headless server, examples, specifications and deployment instructions; Linux targets the documented Ubuntu 24.04 runtime. [Package verification](harness/verification-v2-packages.md) applies to that recorded revision. Current changes still require fresh packages, checksums and acceptance.
|
||||
|
||||
The current [Windows packaging instructions](docs/windows-packaging.md) stage both executables, verify their runtime DLL dependencies, and produce an archive with checksums and provenance. Native acceptance fixtures and independent expected results are retained in [the release fixture directory](harness/fixtures/release1-native/README.md).
|
||||
|
||||
## Work with source files from the command line
|
||||
|
||||
The `arcadia` CLI checks an explicit `.arcadia` entry file and its includes without
|
||||
starting the desktop or server:
|
||||
|
||||
```sh
|
||||
cargo run --locked -p oa-cli --bin arcadia -- check examples/text-first/project.arcadia
|
||||
```
|
||||
|
||||
`stabilize ROOT` previews the identities needed for persistent editing;
|
||||
`rename-alias ROOT OLD NEW` previews an alias change and its references. Add
|
||||
`--write` to save that invocation's candidate, or `--json` for structured
|
||||
diagnostics and changed-file text/diffs. Preview leaves files untouched.
|
||||
See [source CLI commands](docs/source-cli.md) for examples and exit codes.
|
||||
|
||||
## Model operational needs and system responsibilities
|
||||
|
||||
The coastal rescue example combines people, an organization, a System of Interest, information and physical exchange items, normal/degraded behavior and code/CAD evidence. Choose **New** to start an empty project.
|
||||
The desktop starts at **Home**, where you can create a model, open a local project, continue recent files or recover retained work. Missing recent files stay visible with **Locate**; **Remove** only forgets their history entry. Choose **Explore rescue example** for a model combining people, an organization, a System of Interest, information and physical exchange items, normal/degraded behavior and code/CAD evidence.
|
||||
|
||||
The example opens in an architecture view with work nested inside its performers. Each layer also has dedicated functional dataflow and mission/capability views. Regenerate both example formats with matching identities using `cargo run --locked -p oa-core --example generate_demo`; the command verifies both saved models. Historical migration fixtures remain separate.
|
||||
|
||||
1. In **Operational Analysis**, create entities, actors, activities and capabilities. Use **In selected** for semantic decomposition and **Allocated** for new leaf work performed by a participant. Activities can decompose independently of their performer; roles capture shared operational responsibility.
|
||||
The architecture diagram has a compact side palette; expand it to see tool names. Choose an element tool, click its position, enter its name in place and press **Enter**. **Escape** cancels the draft. Select a card and press **F2** to rename it. Right-click a participant and choose **Add activity** or **Add function** to create work with its performer allocation in one undoable edit. Ordinary palette creation leaves work unallocated. **Add existing** places a compatible model element in the current view; **Include already shown** offers **Locate** for existing placements.
|
||||
|
||||
1. In **Operational Analysis**, create entities, actors, activities and capabilities. Use **In selected** for semantic decomposition and **Allocated** for new leaf work performed by a participant. Activities can decompose independently of their performer; roles capture shared operational responsibility. **Communication means** connects operational Entities or Actors directly, with direction and carried items; **Exchange allocation** associates the Interactions it carries. Operational communication requires no system ports.
|
||||
2. Switch to **System Analysis**. Define one System of Interest and external Actors, then functions, missions, capabilities and functional chains. The system boundary has no internal LA/PA components in this version.
|
||||
3. Select a System or System Actor and choose **Add port** in the browser or Properties. Choose Standard or Flow, then **Create port**. Use **Boundary exchange** in the diagram palette, or **Draw boundary exchange** on a selected visible source port, to connect compatible component ports directly. Create the other participant's matching port first if none exists. Functions expose separate input/output port actions; the **Functional exchange** palette tool connects them. **Actions** exposes other engineering relationships and off-diagram configuration; **Data & interfaces** holds reusable payloads and contracts.
|
||||
4. Create focused views. **Containment** nests semantic children; **Architecture · allocated work** nests work within performers; **Functional dataflow** shows work decomposition and exchanges; **Missions & capabilities** shows objectives and participation. Dashed, italic parent work denotes an allocation summary inferred from its leaves. Ports appear on their owner's boundary. Placement dragging changes geometry. **Hide** removes a placement; **Delete from model** removes semantics and dependent references. Use **Placement** and **View properties** for exact values.
|
||||
5. In **Behavior & scenarios**, author processes/chains, scenario lifelines and ordered occurrences, including repeated uses of the same exchange and explicit fragments/branches. Select a State machine and choose **Create diagram** to place its existing regions and states/modes in a saved view. State machines also support availability and state-action links. Conditions are descriptive, not executable simulation code. Behavior and Data authoring preserve your other diagram layouts.
|
||||
6. Select an OA or SA element and choose **Create / update counterpart**. Review dependency scope and counterpart choices, then apply one undoable transaction. Existing counterparts retain their structural choices; reviewed updates compare names/descriptions. Manual traces link other engineering correspondences. **Traceability** also edits artifact URI, revision and fragment metadata.
|
||||
4. Create focused views. **Containment** nests semantic children; **Architecture · allocated work** nests work within performers; **Functional dataflow** shows work decomposition and exchanges; **Missions & capabilities** shows objectives and participation. Dashed, italic parent work denotes an allocation summary inferred from its leaves. Ports appear on their owner's boundary. **View properties** stages saved line-kind, category, behavior-coverage, context, label and individual-hide choices. Right-click empty diagram space for **Duplicate view**, retaining the same semantic records in an independent presentation. Placement **Hide** removes a placement; line hiding retains its relationship; **Delete from model** removes semantics and dependent references.
|
||||
5. In **Behavior & scenarios**, choose **New**, select Operational process (OA), Functional chain (SA), Scenario or State machine, then enter its name. For a scenario, choose what its lifelines represent; **Add optional context…** links a context without changing its layer-root ownership. For a state machine, search for its existing participant owner; optionally select **Create the first state view**. Choose **Create and open** to start authoring work contributions, lifelines, ordered occurrences or states. **Cancel · Esc** discards the creation draft; if another dialog is open, Escape dismisses that dialog first. Creation and its optional first view form one Undo operation. A new state machine starts without regions or states; **Create diagram** can later place its existing contents in a saved view. Behavior supports repeated exchanges, fragments/branches, availability and state-action links. Conditions are descriptive, not executable simulation code. Behavior and Data authoring preserve your other diagram layouts.
|
||||
6. Select an OA or SA element and choose **Create / update counterpart**. Review dependency scope and counterpart choices, then apply one undoable transaction. For an existing trace, **Review semantics…** compares typed content and prior observations, with explicit keep, take or final-value decisions for supported structural, scalar, constructor, individual-row and ordered-collection changes. Open additional trace rows and choose **Include this row** to combine reviewed source contributions into one validated Apply/Undo transaction; conflicting decisions require resolution. Review does not automatically synchronize the perspectives. **Attachments** reviews an existing child’s parent or a relationship endpoint. **Artifact links** reviews metadata, owner moves, explicit source-absence deletion and creation of a reserved counterpart after all introduced metadata is reviewed. These controls have automated coverage; their native acceptance remains pending. Manual traces link other engineering correspondences. **Traceability** also edits artifact URI, revision and fragment metadata.
|
||||
7. Review **Model checks**, then save `.arcadia` source or `.json`. Warnings allow incomplete authoring; structural errors preserve the committed model and rejected draft.
|
||||
|
||||
**Model explorer** groups roots by engineering purpose and opens one container's direct children. Breadcrumbs navigate ownership; **Allocated work** is listed separately. Search names, kinds or `@aliases` to find elements with ancestor context. Results are paged in groups of 100. **Collapse all** folds the outline; **Locate** clears the filter and reveals the selected element's ancestry.
|
||||
@@ -57,7 +88,9 @@ The example opens in an architecture view with work nested inside its performers
|
||||
|
||||
The inspector exposes typed properties, references, ordered lists, aliases and UUID identity. Applying or navigating away attempts to commit the draft; invalid values remain editable. **Escaped strings (advanced)** uses quoted JSON escapes for exact tabs, line breaks and carriage returns, for example `"First\nSecond"`. UUID changes atomically update dependent references. Accepted model/source history retains up to 100 edits within an estimated 32 MiB history budget.
|
||||
|
||||
Choose **Interaction**, **Functional exchange** or **Boundary exchange** in the diagram palette, then click the source and a highlighted compatible target, or drag between them. Functional exchanges use output/input ports; function cards create or reuse real ports atomically. Boundary exchanges use compatible component ports. Middle-button drag pans without losing the chosen source; arrow keys also pan while drawing with the canvas focused. Click empty space to restart, or Escape to cancel. Each completed connection is one model/source history operation; edit its name and carried items in Properties.
|
||||
Choose **Interaction**, **Communication means**, **Functional exchange** or **Boundary exchange** in the diagram palette, then click the source and a highlighted compatible target, or drag between them. Communication means use OA participants directly. Functional exchanges use output/input ports; function cards create or reuse real ports atomically. Boundary exchanges use compatible component ports. Middle-button drag pans without losing the chosen source; arrow keys also pan while drawing with the canvas focused. Click empty space to restart, or Escape to cancel. Each completed connection is one model/source history operation; edit its name and carried items in Properties.
|
||||
|
||||
Create named **Exchange categories** at the perspective root to group Work exchanges or Participant exchanges. In **View properties**, each family defaults to All, including uncategorized exchanges. Selected categories combine by union; an explicit empty selection shows none of that family. Coverage and selected process/chain/scenario contexts intersect with category choices. Explicit hides survive changes to categories and labels. These settings change the saved presentation, not model relationships or node geometry. The [native record](harness/release-current-native-verification.md) covers the source-6 batch crash-recovery/Apply/Undo/Redo flow and a scoped forty-exchange view, including filtering, duplication, hiding and reopening. Communication authoring, remaining view combinations and latest attachment/artifact native acceptance remain pending.
|
||||
|
||||
In **Select / move**, drag cards on a 10-unit grid, drag empty space to pan, and scroll to zoom. Drag ports along their owner's border or onto any of its four sides. Automatic sides account for direction and visible connections. Manual positions are per diagram and remain relative to the owner through movement or resize; right-click **Reset automatic position** restores automatic placement. **Arrange** changes the current view layout; **Fit** frames its projected bounds. SVG exports the current architecture diagram as a view, not an editable model file.
|
||||
|
||||
@@ -69,10 +102,12 @@ In the state view, **Select / move** drags vertices and containers without repar
|
||||
|
||||
## Edit the same model as text
|
||||
|
||||
For a short text-only start, open [the distress-response model](examples/text-first/project.arcadia) and follow [its three-step walkthrough](examples/text-first/README.md). Four declarations and three explicit relationships describe participants, work, responsibility and an exchange; no diagram or handwritten UUID is needed.
|
||||
|
||||
**Textual model** has a source-file folder tree, per-file drafts, syntax highlighting, live linting and explicit **Apply all**. Collapse/Locate navigate the hierarchy; collapsed folders retain draft and diagnostic indicators. Right-click a file for contextual actions. Projects can use relative includes to organize source into files. For example:
|
||||
|
||||
```text
|
||||
arcadia 4;
|
||||
arcadia 7;
|
||||
project harbor "Harbor assistance" {
|
||||
oa_entity control "Harbor control" {}
|
||||
oa_activity respond "Respond to requests" {
|
||||
@@ -102,9 +137,13 @@ Reparenting a nested element through Properties moves its declaration, descendan
|
||||
|
||||
## Save and reopen
|
||||
|
||||
Ctrl+S saves, Ctrl+Shift+S saves as, and Ctrl+O opens. An `.arcadia` suffix selects text; other suffixes select JSON. Opening detects content rather than relying on the filename. Windows uses native dialogs; Linux uses a path-entry dialog with overwrite confirmation and no portal requirement.
|
||||
Ctrl+S saves to the current local or hosted destination, Ctrl+Shift+S opens Save as, and Ctrl+O opens a local file. An `.arcadia` suffix selects text; other suffixes select JSON. Opening detects content rather than relying on the filename. Windows uses native dialogs; Linux uses a path-entry dialog with overwrite confirmation and no portal requirement.
|
||||
|
||||
**Save all** validates a snapshot and saves the source bundle, comparing previously read dependency bytes to reject detected external changes. Each file is replaced atomically; a staged transaction journal allows explicit recovery of interrupted multi-file saves. This is not an atomic multi-file filesystem operation or distributed locking. If recovery is needed, **Recover interrupted save** appears in Textual model. Dirty New/Open/Close operations offer save/discard/cancel. There is no periodic autosave of unsaved edits. Share or commit the entry file and all included source files together.
|
||||
**Save all** validates a snapshot and saves the source bundle, comparing previously read dependency bytes to reject detected external changes. Each file is replaced atomically; a staged transaction journal allows explicit recovery of interrupted multi-file saves. This is not an atomic multi-file filesystem operation or distributed locking. If recovery is needed, **Recover interrupted save** appears in Textual model. Dirty New/Open/Close operations offer save/discard/cancel. Share or commit the entry file and all included source files together.
|
||||
|
||||
While work is unsaved, the desktop attempts a private recovery checkpoint every 10 seconds. It retains the accepted source bundle, raw source buffers, supported pending property/behavior forms and workflow drafts, including rejected input and semantic-review choices. **Home → Recover** opens an unsaved copy with no local or hosted save destination; use **Save as** to choose one. Undo/redo history is not restored. Checkpoints never write model files or save to the server, and credentials are excluded. A failed checkpoint is reported on Home; recent edits since the last completed checkpoint may be unavailable after a crash. Local history and checkpoints live under `%LOCALAPPDATA%/ArcadiaStudio` on Windows or `$XDG_DATA_HOME/arcadia-studio` (otherwise `~/.local/share/arcadia-studio`) on Linux. Explicit saves and version control remain the durable record.
|
||||
|
||||
The [current Windows native record](harness/release-current-native-verification.md) verifies scoped Home/recents workflows and forced-stop recovery of raw invalid source, invalid UUID properties, a pending timed transition, keyed semantic choices and a retained rejected order draft. Accepted repairs were saved and compared with independent source oracles, including exact Undo/Redo cases. It also records the older binary's transient journal-publication failure; the bounded fix has automated evidence in the [journal review](harness/release-journal-publication-review-20260906.md) and awaits new-binary native verification. Subsequent source-6 batch recovery and exact Apply/Save/Undo/Redo comparisons are recorded there too. These results do not accept all recovery variants, latest attachment/artifact workflows, Linux or the complete release.
|
||||
|
||||
## REST service and external tools
|
||||
|
||||
@@ -118,19 +157,25 @@ The default is `127.0.0.1:8787`, with a platform-local data directory. `--data-d
|
||||
|
||||
The API queries and edits complete models, canonical source, multi-file source projects, elements, relationships, artifacts and traces. Transactions and transition previews use the same core validation and source writer as the desktop. Writes require the returned ETag in `If-Match`. One revision protects the accepted model and source bundle together, including comment-only edits. The server retains the current revision only.
|
||||
|
||||
Desktop files and hosted resources transfer explicitly; the desktop has no direct server connection. Use **Save as** to export `system.json`, import it, then edit using the API. The model interchange endpoint accepts a complete JSON model or standalone canonical source:
|
||||
Use **Hosted models...** in the desktop top bar to connect, **Publish as new** to upload the current authored source project, or select a catalog entry and choose **Open selected**. Diagrams, properties and text share the same local editing history. **Save to server** / Ctrl+S explicitly sends valid changes; invalid drafts and failed writes retain your work. A revision conflict preserves local work and checks the server for review, with read-only source files and an explicit, undoable **Reload server version**. **Save as local...** switches back to a local destination after successfully saving an `.arcadia` source project; JSON export keeps the hosted destination. See the [hosted desktop walkthrough](docs/server.md#edit-hosted-models-in-the-desktop) for conflict recovery and connection setup.
|
||||
|
||||
The desktop keeps bearer credentials in memory and accepts HTTPS, with HTTP allowed only on localhost/loopback. Hosted editing does not require a cloud account and does not provide automatic synchronization or merges.
|
||||
|
||||
Scripts can also import a complete JSON model or standalone canonical source through the model interchange endpoint:
|
||||
|
||||
```sh
|
||||
curl --fail-with-body -X POST http://127.0.0.1:8787/api/v1/projects/import \
|
||||
-H 'Content-Type: application/json' --data-binary @system.json
|
||||
```
|
||||
|
||||
The response supplies the service `resource_id` and ETag. After API changes, export `/api/v1/projects/<resource-id>/source` to a new `.arcadia` file and open it in the desktop. This preserves model UUIDs, aliases, diagrams, traces and artifact metadata. Importing creates a separate hosted resource. Artifact URIs and revisions are opaque references for code, CAD, schematics, documents and other evidence; the application does not fetch or execute them.
|
||||
The response supplies the service `resource_id` and ETag. Refresh the desktop catalog to open that resource; after API changes to an open model, use **Check server** to review them. Alternatively, export `/api/v1/projects/<resource-id>/source` to a new `.arcadia` file for local interchange. This preserves model UUIDs, aliases, diagrams, traces and artifact metadata. Importing creates a separate hosted resource. Artifact URIs and revisions are opaque references for code, CAD, schematics, documents and other evidence; the application does not fetch or execute them.
|
||||
|
||||
To preserve authored files and comments, use `POST /api/v1/projects/import-source-project` with a JSON bundle containing `entry` and a `files` map of relative paths to source strings. `GET` and conditional `PUT /api/v1/projects/<resource-id>/source-project` exchange that complete accepted bundle. Include resolution uses only the submitted files; the server never opens the client's disk paths. See the [REST contract](docs/api.rst) for payloads, located diagnostics and admission limits.
|
||||
|
||||
## Development
|
||||
|
||||
Source7 integration adds caller-qualified reusable-process links and explicit interface-operation identity. Its [contract decision](harness/release-source7-integration-decision-20260906.md) and [current state](harness/STATE.md) distinguish prepared code from tested behavior. Current OpenAPI validation passes72structural fixtures across204schemas; source7 core, desktop, HTTP, recovery and native acceptance remain separate gates. Earlier source6 Windows and Linux results retain their exact recorded revision scope. Final full suites, strict checks, optimized/MSRV builds and current release packages are still required.
|
||||
|
||||
```sh
|
||||
cargo fmt --all -- --check
|
||||
cargo clippy --workspace --all-targets --locked -- -D warnings
|
||||
@@ -142,4 +187,4 @@ cargo build --release --workspace --locked
|
||||
- `crates/oa-desktop`: Iced state/drafts, typed forms, architecture/behavior views, source highlighting and file workflows.
|
||||
- `crates/oa-server`: REST transport, conditional writes, bounded queries and atomic local store.
|
||||
|
||||
The [architecture guide](docs/architecture.md) explains extension boundaries. Windows/Linux CI is configured; remote CI was not run in this environment. LA/PA, simulation, native Capella interchange, complete SysML/Capella conformance, direct hosted editing, semantic merge tooling and production-scale collaboration remain outside this prototype.
|
||||
The [architecture guide](docs/architecture.md) explains extension boundaries. Windows/Linux CI is configured; remote CI was not run in this environment. LA/PA, simulation, native Capella interchange, complete SysML/Capella conformance, semantic merge tooling and production-scale collaboration remain outside this prototype.
|
||||
|
||||
@@ -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();
|
||||
// The loader may include warnings from stabilized compilation. Only its
|
||||
// discovery/IO diagnostics are authoritative on the original bytes.
|
||||
let mut diagnostics: Vec<_> = snapshot
|
||||
.diagnostics
|
||||
.iter()
|
||||
.filter(|d| {
|
||||
matches!(
|
||||
d.diagnostic.code.as_str(),
|
||||
"pending_save"
|
||||
| "source_encoding"
|
||||
| "source_limit"
|
||||
| "invalid_path"
|
||||
| "missing_include"
|
||||
| "project_limit"
|
||||
)
|
||||
})
|
||||
.cloned()
|
||||
.collect();
|
||||
for diagnostic in source_project::lint_bundle(&raw, LIMITS) {
|
||||
if !diagnostics.contains(&diagnostic) {
|
||||
diagnostics.push(diagnostic);
|
||||
}
|
||||
}
|
||||
let disk_refusal = snapshot.diagnostics.iter().any(|d| {
|
||||
matches!(
|
||||
d.diagnostic.code.as_str(),
|
||||
"pending_save" | "missing_include" | "invalid_path"
|
||||
)
|
||||
});
|
||||
let invalid = diagnostics
|
||||
.iter()
|
||||
.any(|d| d.diagnostic.severity == Severity::Error);
|
||||
report.diagnostics = diagnostics
|
||||
.into_iter()
|
||||
.map(|d| located(d, "input"))
|
||||
.collect();
|
||||
if invalid {
|
||||
// Do not run declaration analysis or any updater on invalid raw text.
|
||||
// Failed lint may also have allocated provisional object identities.
|
||||
for diagnostic in &mut report.diagnostics {
|
||||
diagnostic.object_id = None;
|
||||
}
|
||||
report.status = "invalid".into();
|
||||
report.exit_code = if disk_refusal { 3 } else { 1 };
|
||||
return Err(Box::new(report));
|
||||
}
|
||||
let explicit_ids: std::collections::BTreeSet<_> = raw
|
||||
.files
|
||||
.values()
|
||||
.flat_map(|text| language::analyze(text).declarations)
|
||||
.filter_map(|d| d.id.map(|id| id.to_string()))
|
||||
.collect();
|
||||
for diagnostic in &mut report.diagnostics {
|
||||
if diagnostic
|
||||
.object_id
|
||||
.as_ref()
|
||||
.is_some_and(|id| !explicit_ids.contains(id))
|
||||
{
|
||||
diagnostic.object_id = None;
|
||||
}
|
||||
}
|
||||
let mut compiled = source_project::compile_bundle(&raw, LIMITS).map_err(|diagnostics| {
|
||||
report.status = "invalid".into();
|
||||
report.exit_code = 1;
|
||||
report
|
||||
.diagnostics
|
||||
.extend(diagnostics.into_iter().map(|d| located(d, "candidate")));
|
||||
Box::new(report.clone())
|
||||
})?;
|
||||
report.identities_to_stabilize = raw
|
||||
.files
|
||||
.values()
|
||||
.map(|text| {
|
||||
language::analyze(text)
|
||||
.declarations
|
||||
.into_iter()
|
||||
.filter(|d| d.id.is_none())
|
||||
.count()
|
||||
})
|
||||
.sum();
|
||||
report.migration = compiled.decoded.migration.clone();
|
||||
let mutable = !matches!(operation, Operation::Check);
|
||||
if let Operation::RenameAlias { old, new } = operation {
|
||||
let project = &compiled.decoded.project;
|
||||
let Some((id, _)) = project
|
||||
.identities()
|
||||
.into_iter()
|
||||
.find(|(_, alias)| *alias == old)
|
||||
else {
|
||||
report.fail(
|
||||
1,
|
||||
"unknown_alias",
|
||||
format!("No declaration has alias '{old}'."),
|
||||
);
|
||||
return Err(Box::new(report));
|
||||
};
|
||||
let command = prepare_command(project, &Command::SetAlias(id, new)).map_err(|error| {
|
||||
report.fail(1, "semantic_command_rejected", error.to_string());
|
||||
Box::new(report.clone())
|
||||
})?;
|
||||
compiled = source_project::update_bundle_with_identity_map(
|
||||
&compiled,
|
||||
&command.project,
|
||||
&command.identity_map,
|
||||
LIMITS,
|
||||
)
|
||||
.map_err(|diagnostics| {
|
||||
report.status = "invalid".into();
|
||||
report.exit_code = 1;
|
||||
report
|
||||
.diagnostics
|
||||
.extend(diagnostics.into_iter().map(|d| located(d, "candidate")));
|
||||
Box::new(report.clone())
|
||||
})?;
|
||||
}
|
||||
if mutable {
|
||||
for (path, after) in &compiled.bundle.files {
|
||||
let before = raw.files.get(path).cloned().unwrap_or_default();
|
||||
if &before != after {
|
||||
report.changes.push(Change {
|
||||
path: path.clone(),
|
||||
diff: unified_diff(path, &before, after),
|
||||
before,
|
||||
after: after.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
report.status = "preview".into();
|
||||
report.notes.push("This invocation's complete proposed changed-file texts and diffs are reported. Without --write nothing is saved; a later invocation may allocate different missing UUIDs. This is not a saved plan or a digest of unchanged dependencies.".into());
|
||||
}
|
||||
report.notes.push("Diagnostic object IDs are omitted for declarations without authored IDs; lint's temporary identities are not the candidate's identities.".into());
|
||||
report.notes.push("Disk writes use the core journal/conflict checks. This is not cross-process atomic visibility or a power-loss durability guarantee; pending saves are never implicitly recovered. A failed write may require explicit journal recovery.".into());
|
||||
Ok(Prepared {
|
||||
report,
|
||||
snapshot,
|
||||
compiled,
|
||||
mutable,
|
||||
})
|
||||
}
|
||||
|
||||
/// One bounded hunk per file: common prefix/suffix plus three context lines.
|
||||
/// Linear in input size; no quadratic LCS allocation for large modules.
|
||||
fn unified_diff(path: &str, before: &str, after: &str) -> String {
|
||||
let a: Vec<_> = before.split_inclusive('\n').collect();
|
||||
let b: Vec<_> = after.split_inclusive('\n').collect();
|
||||
let prefix = a.iter().zip(&b).take_while(|(a, b)| a == b).count();
|
||||
let suffix = a[prefix..]
|
||||
.iter()
|
||||
.rev()
|
||||
.zip(b[prefix..].iter().rev())
|
||||
.take_while(|(a, b)| a == b)
|
||||
.count();
|
||||
let start = prefix.saturating_sub(3);
|
||||
let a_end = (a.len() - suffix + 3).min(a.len());
|
||||
let b_end = (b.len() - suffix + 3).min(b.len());
|
||||
let a_start = if a_end == start { start } else { start + 1 };
|
||||
let b_start = if b_end == start { start } else { start + 1 };
|
||||
let mut out = format!(
|
||||
"--- a/{path}\n+++ b/{path}\n@@ -{a_start},{} +{b_start},{} @@\n",
|
||||
a_end - start,
|
||||
b_end - start
|
||||
);
|
||||
let mut emit = |prefix: char, line: &str| {
|
||||
out.push(prefix);
|
||||
out.push_str(line);
|
||||
if !line.ends_with('\n') {
|
||||
out.push_str("\n\\ No newline at end of file\n");
|
||||
}
|
||||
};
|
||||
for line in &a[start..prefix] {
|
||||
emit(' ', line);
|
||||
}
|
||||
for line in &a[prefix..a.len() - suffix] {
|
||||
emit('-', line);
|
||||
}
|
||||
for line in &b[prefix..b.len() - suffix] {
|
||||
emit('+', line);
|
||||
}
|
||||
for line in &a[a.len() - suffix..a_end] {
|
||||
emit(' ', line);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub struct Arguments {
|
||||
pub entry: PathBuf,
|
||||
pub operation: Operation,
|
||||
pub write: bool,
|
||||
pub json: bool,
|
||||
}
|
||||
|
||||
pub const HELP: &str = "arcadia check ROOT [--json]\narcadia lint ROOT [--json]\narcadia stabilize ROOT [--write] [--json]\narcadia rename-alias ROOT OLD NEW [--write] [--json]\n\nROOT is an explicit .arcadia entry file; includes resolve relative to it.\nMutation commands preview by default. --write applies this invocation's candidate.\nMissing UUIDs in a preview are provisional until written. No canonical formatting.\nExit: 0 valid/success (engineering warnings allowed), 1 invalid source or semantic\ncommand rejection, 2 usage, 3 disk/path/conflict/pending-journal/output failure.\nNo implicit journal recovery. Limits: 64 MiB, 256 files, include depth32/edges1024,\n100000 graph objects, 250000 records, 500000 ordered references.\n";
|
||||
|
||||
pub fn arguments(args: &[std::ffi::OsString]) -> Result<Arguments, String> {
|
||||
let mut positional = Vec::new();
|
||||
let (mut write, mut json) = (false, false);
|
||||
for arg in args {
|
||||
if arg == "--write" && !write {
|
||||
write = true;
|
||||
} else if arg == "--json" && !json {
|
||||
json = true;
|
||||
} else if arg.to_string_lossy().starts_with('-') {
|
||||
return Err(format!(
|
||||
"Unknown or repeated option: {}",
|
||||
arg.to_string_lossy()
|
||||
));
|
||||
} else {
|
||||
positional.push(arg);
|
||||
}
|
||||
}
|
||||
let command = positional.first().and_then(|s| s.to_str()).unwrap_or("");
|
||||
let operation = match (command, positional.len()) {
|
||||
("check" | "lint", 2) if !write => Operation::Check,
|
||||
("stabilize", 2) => Operation::Stabilize,
|
||||
("rename-alias", 4) => Operation::RenameAlias {
|
||||
old: positional[2].to_str().ok_or("Alias must be UTF-8")?.into(),
|
||||
new: positional[3].to_str().ok_or("Alias must be UTF-8")?.into(),
|
||||
},
|
||||
_ => return Err(HELP.into()),
|
||||
};
|
||||
Ok(Arguments {
|
||||
entry: PathBuf::from(positional[1]),
|
||||
operation,
|
||||
write,
|
||||
json,
|
||||
})
|
||||
}
|
||||
@@ -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,6 @@ uuid.workspace = true
|
||||
thiserror.workspace = true
|
||||
tempfile = "3"
|
||||
same-file = "1"
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
libc = "0.2"
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
include!("../../build/windows_manifest.rs");
|
||||
@@ -0,0 +1,53 @@
|
||||
//! Generate only the new teaching-project files; old compatibility examples stay untouched.
|
||||
#[path = "../../../examples/release1/model.rs"]
|
||||
mod teaching;
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let directory = std::env::args_os()
|
||||
.nth(1)
|
||||
.map(std::path::PathBuf::from)
|
||||
.unwrap_or_else(|| "examples/release1".into());
|
||||
let lesson = teaching::build();
|
||||
let bundle = teaching::checked_bundle(&lesson.project);
|
||||
for (name, source) in &bundle.files {
|
||||
let path = directory.join(name);
|
||||
std::fs::create_dir_all(path.parent().unwrap())?;
|
||||
std::fs::write(path, source)?;
|
||||
}
|
||||
for (name, value) in [
|
||||
("model.json", serde_json::to_value(&lesson.project)?),
|
||||
(
|
||||
"transition-before.json",
|
||||
serde_json::to_value(&lesson.before_transition)?,
|
||||
),
|
||||
(
|
||||
"transition-request.json",
|
||||
serde_json::to_value(&lesson.request)?,
|
||||
),
|
||||
(
|
||||
"expected-diagnostics.json",
|
||||
serde_json::to_value(oa_core::validation::validate(&lesson.project))?,
|
||||
),
|
||||
] {
|
||||
std::fs::write(
|
||||
directory.join(name),
|
||||
serde_json::to_string_pretty(&value)? + "\n",
|
||||
)?;
|
||||
}
|
||||
println!(
|
||||
"Validated {} elements, {} relations, {} views, {} traces; wrote {} source files to {}",
|
||||
lesson.project.elements.len(),
|
||||
lesson.project.relations.len(),
|
||||
lesson.project.diagrams.len(),
|
||||
lesson.project.traces.len(),
|
||||
bundle.files.len(),
|
||||
directory.display()
|
||||
);
|
||||
for issue in oa_core::validation::validate(&lesson.project) {
|
||||
println!(
|
||||
"{:?} {} {:?}: {}",
|
||||
issue.severity, issue.code, issue.element, issue.message
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,442 @@
|
||||
//! Deterministic dense-view fixture and semantic oracle. No GUI or authored routing.
|
||||
//! cargo run -p oa-core --example release_dense_views -- OUTPUT_DIRECTORY
|
||||
use oa_core::{view_options::*, *};
|
||||
use serde_json::json;
|
||||
use std::{
|
||||
collections::{BTreeMap, BTreeSet},
|
||||
error::Error,
|
||||
fs,
|
||||
path::PathBuf,
|
||||
time::Instant,
|
||||
};
|
||||
|
||||
fn id(n: u32) -> Id {
|
||||
format!("d4060906-0000-4000-8000-{n:012x}").parse().unwrap()
|
||||
}
|
||||
|
||||
fn element(p: &mut Project, n: u32, kind: Kind, alias: &str, name: &str, parent: Option<Id>) -> Id {
|
||||
let identity = id(n);
|
||||
assert!(
|
||||
p.elements
|
||||
.insert(
|
||||
identity,
|
||||
Element {
|
||||
id: identity,
|
||||
alias: alias.into(),
|
||||
name: name.into(),
|
||||
description: String::new(),
|
||||
parent,
|
||||
body: ElementBody::new(kind, Layer::Oa).unwrap()
|
||||
}
|
||||
)
|
||||
.is_none()
|
||||
);
|
||||
identity
|
||||
}
|
||||
|
||||
fn relation(p: &mut Project, n: u32, alias: &str, name: &str, body: RelationBody) -> Id {
|
||||
let identity = id(n);
|
||||
assert!(
|
||||
p.relations
|
||||
.insert(
|
||||
identity,
|
||||
Relation {
|
||||
id: identity,
|
||||
alias: alias.into(),
|
||||
name: name.into(),
|
||||
description: String::new(),
|
||||
body
|
||||
}
|
||||
)
|
||||
.is_none()
|
||||
);
|
||||
identity
|
||||
}
|
||||
|
||||
fn fixture() -> (Project, Id, Vec<Id>, Vec<Id>, Vec<Id>) {
|
||||
let mut p = Project::empty();
|
||||
p.id = id(1);
|
||||
p.alias = "dense_service_views".into();
|
||||
p.name = "Forty service exchanges".into();
|
||||
p.description = "Deterministic release fixture. Forty actual work exchanges share two endpoints; no authored routes or synthetic endpoints.".into();
|
||||
let requester = element(&mut p, 10, Kind::Entity, "requester", "Requester", None);
|
||||
let provider = element(&mut p, 11, Kind::Entity, "provider", "Provider", None);
|
||||
let a = element(
|
||||
&mut p,
|
||||
20,
|
||||
Kind::Activity,
|
||||
"request_service",
|
||||
"Request service",
|
||||
None,
|
||||
);
|
||||
let b = element(
|
||||
&mut p,
|
||||
21,
|
||||
Kind::Activity,
|
||||
"provide_service",
|
||||
"Provide service",
|
||||
None,
|
||||
);
|
||||
relation(
|
||||
&mut p,
|
||||
3000,
|
||||
"requester_work",
|
||||
"Requester responsibility",
|
||||
RelationBody::Allocation {
|
||||
source: requester,
|
||||
target: a,
|
||||
},
|
||||
);
|
||||
relation(
|
||||
&mut p,
|
||||
3001,
|
||||
"provider_work",
|
||||
"Provider responsibility",
|
||||
RelationBody::Allocation {
|
||||
source: provider,
|
||||
target: b,
|
||||
},
|
||||
);
|
||||
let group_names = ["Commands", "Status", "Alerts", "Measurements"];
|
||||
let items: Vec<_> = group_names
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, name)| {
|
||||
element(
|
||||
&mut p,
|
||||
100 + i as u32,
|
||||
Kind::ExchangeItem,
|
||||
&format!("payload_{}", i + 1),
|
||||
&format!("{name} payload"),
|
||||
None,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let exchanges: Vec<_> = (0..40)
|
||||
.map(|i| {
|
||||
relation(
|
||||
&mut p,
|
||||
2000 + i,
|
||||
&format!("service_data_{:02}", i + 1),
|
||||
&format!("Service data {:02}", i + 1),
|
||||
RelationBody::Interaction {
|
||||
source: a,
|
||||
target: b,
|
||||
items: vec![items[(i / 10) as usize]],
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let mut categories = vec![];
|
||||
for (i, name) in group_names.iter().enumerate() {
|
||||
let category = element(
|
||||
&mut p,
|
||||
200 + i as u32,
|
||||
Kind::ExchangeCategory,
|
||||
&format!("concern_{}", i + 1),
|
||||
name,
|
||||
None,
|
||||
);
|
||||
*p.elements.get_mut(&category).unwrap().shared_mut().unwrap() =
|
||||
SharedBody::ExchangeCategory {
|
||||
family: ExchangeFamily::Work,
|
||||
exchanges: exchanges[i * 10..i * 10 + 10].to_vec(),
|
||||
};
|
||||
categories.push(category);
|
||||
}
|
||||
let overlapping = element(
|
||||
&mut p,
|
||||
204,
|
||||
Kind::ExchangeCategory,
|
||||
"critical_concern",
|
||||
"Critical cross-cutting exchanges",
|
||||
None,
|
||||
);
|
||||
*p.elements
|
||||
.get_mut(&overlapping)
|
||||
.unwrap()
|
||||
.shared_mut()
|
||||
.unwrap() = SharedBody::ExchangeCategory {
|
||||
family: ExchangeFamily::Work,
|
||||
exchanges: [0, 10, 20, 30].map(|i| exchanges[i]).to_vec(),
|
||||
};
|
||||
categories.push(overlapping);
|
||||
let mut contexts = vec![];
|
||||
for (group, name) in group_names[..3].iter().enumerate() {
|
||||
let process = element(
|
||||
&mut p,
|
||||
300 + group as u32,
|
||||
Kind::Process,
|
||||
&format!("process_{}", group + 1),
|
||||
&format!("{name} process"),
|
||||
None,
|
||||
);
|
||||
for offset in 0..10 {
|
||||
let index = group * 10 + offset;
|
||||
let occurrence = element(
|
||||
&mut p,
|
||||
1000 + index as u32,
|
||||
Kind::Occurrence,
|
||||
&format!("use_data_{:02}", index + 1),
|
||||
&format!("Use service data {:02}", index + 1),
|
||||
Some(process),
|
||||
);
|
||||
*p.elements
|
||||
.get_mut(&occurrence)
|
||||
.unwrap()
|
||||
.shared_mut()
|
||||
.unwrap() = SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange {
|
||||
operation: None,
|
||||
exchange: Some(exchanges[index]),
|
||||
sender: None,
|
||||
receiver: None,
|
||||
message_kind: MessageKind::Asynchronous,
|
||||
reply_to: None,
|
||||
values: vec![],
|
||||
},
|
||||
precondition: String::new(),
|
||||
postcondition: String::new(),
|
||||
};
|
||||
p.elements
|
||||
.get_mut(&process)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap()
|
||||
.push(occurrence);
|
||||
}
|
||||
contexts.push(process);
|
||||
}
|
||||
let diagram = id(4000);
|
||||
let nodes = [(a, 80.), (b, 760.)]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(i, (element, x))| {
|
||||
let id = id(4100 + i as u32);
|
||||
(
|
||||
id,
|
||||
NodePlacement {
|
||||
id,
|
||||
alias: format!("service_placement_{}", i + 1),
|
||||
element,
|
||||
position: Position { x, y: 160. },
|
||||
size: Size {
|
||||
width: 230.,
|
||||
height: 150.,
|
||||
},
|
||||
collapsed: false,
|
||||
port_anchors: vec![],
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
p.diagrams.insert(diagram, Diagram { id: diagram, alias: "forty_exchange_view".into(), name: "Forty service exchanges · all concerns".into(), description: "Start with All. Compare first category, overlapping concerns, global coverage and chosen processes. No hand-routed edges.".into(), layer: Layer::Oa, projection: ProjectionKind::FunctionalDataflow, view_options: ViewOptions::default(), nodes, routes: vec![] });
|
||||
(p, diagram, exchanges, categories, contexts)
|
||||
}
|
||||
|
||||
fn projected(p: &Project, diagram: &Diagram) -> BTreeSet<Id> {
|
||||
projection::project_diagram(p, diagram)
|
||||
.edges
|
||||
.into_iter()
|
||||
.map(|edge| edge.id)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn overlap(a: routing::RoutingBounds, b: routing::RoutingBounds) -> bool {
|
||||
a.position.x < b.position.x + b.size.width
|
||||
&& b.position.x < a.position.x + a.size.width
|
||||
&& a.position.y < b.position.y + b.size.height
|
||||
&& b.position.y < a.position.y + a.size.height
|
||||
}
|
||||
|
||||
fn main() -> Result<(), Box<dyn Error>> {
|
||||
let output = PathBuf::from(
|
||||
std::env::args_os()
|
||||
.nth(1)
|
||||
.ok_or("Supply an output directory for the dense-view fixture.")?,
|
||||
);
|
||||
let (p, diagram, exchanges, categories, contexts) = fixture();
|
||||
let issues = validation::validate(&p);
|
||||
assert!(
|
||||
!issues
|
||||
.iter()
|
||||
.any(|issue| issue.severity == validation::Severity::Error),
|
||||
"{issues:?}"
|
||||
);
|
||||
let pristine = p.clone();
|
||||
let original = &p.diagrams[&diagram];
|
||||
assert!(original.routes.is_empty());
|
||||
let all = projection::project_view(&p, diagram);
|
||||
assert_eq!(all.nodes.len(), 2);
|
||||
assert_eq!(all.edges.len(), 40);
|
||||
assert_eq!(
|
||||
all.edges
|
||||
.iter()
|
||||
.map(|edge| (edge.source, edge.target))
|
||||
.collect::<BTreeSet<_>>()
|
||||
.len(),
|
||||
1
|
||||
);
|
||||
let sets = |ids: &[Id]| ids.iter().copied().collect::<BTreeSet<_>>();
|
||||
let cases = vec![
|
||||
("all", ViewOptions::default(), sets(&exchanges)),
|
||||
(
|
||||
"first_category",
|
||||
ViewOptions {
|
||||
work_categories: CategorySelection::Any(vec![categories[0]]),
|
||||
..ViewOptions::default()
|
||||
},
|
||||
sets(&exchanges[..10]),
|
||||
),
|
||||
(
|
||||
"first_and_overlap",
|
||||
ViewOptions {
|
||||
work_categories: CategorySelection::Any(vec![categories[0], categories[4]]),
|
||||
..ViewOptions::default()
|
||||
},
|
||||
exchanges[..10]
|
||||
.iter()
|
||||
.copied()
|
||||
.chain([exchanges[10], exchanges[20], exchanges[30]])
|
||||
.collect(),
|
||||
),
|
||||
(
|
||||
"in_behavior",
|
||||
ViewOptions {
|
||||
coverage: Coverage::InBehavior,
|
||||
..ViewOptions::default()
|
||||
},
|
||||
sets(&exchanges[..30]),
|
||||
),
|
||||
(
|
||||
"outside_behavior",
|
||||
ViewOptions {
|
||||
coverage: Coverage::OutsideBehavior,
|
||||
..ViewOptions::default()
|
||||
},
|
||||
sets(&exchanges[30..]),
|
||||
),
|
||||
(
|
||||
"second_process",
|
||||
ViewOptions {
|
||||
work_contexts: ContextSelection::Any(vec![contexts[1]]),
|
||||
..ViewOptions::default()
|
||||
},
|
||||
sets(&exchanges[10..20]),
|
||||
),
|
||||
(
|
||||
"outside_second_process",
|
||||
ViewOptions {
|
||||
coverage: Coverage::OutsideBehavior,
|
||||
work_contexts: ContextSelection::Any(vec![contexts[1]]),
|
||||
..ViewOptions::default()
|
||||
},
|
||||
BTreeSet::new(),
|
||||
),
|
||||
(
|
||||
"empty_categories",
|
||||
ViewOptions {
|
||||
work_categories: CategorySelection::Any(vec![]),
|
||||
..ViewOptions::default()
|
||||
},
|
||||
BTreeSet::new(),
|
||||
),
|
||||
(
|
||||
"empty_contexts",
|
||||
ViewOptions {
|
||||
work_contexts: ContextSelection::Any(vec![]),
|
||||
..ViewOptions::default()
|
||||
},
|
||||
BTreeSet::new(),
|
||||
),
|
||||
(
|
||||
"no_lines",
|
||||
ViewOptions {
|
||||
relation_kinds: RelationKinds::Only(vec![]),
|
||||
..ViewOptions::default()
|
||||
},
|
||||
BTreeSet::new(),
|
||||
),
|
||||
(
|
||||
"first_hide_05",
|
||||
ViewOptions {
|
||||
hidden_edges: vec![exchanges[4]],
|
||||
work_categories: CategorySelection::Any(vec![categories[0]]),
|
||||
exchange_labels: ExchangeLabels::NameAndItems,
|
||||
..ViewOptions::default()
|
||||
},
|
||||
exchanges[..10]
|
||||
.iter()
|
||||
.filter(|edge| **edge != exchanges[4])
|
||||
.copied()
|
||||
.collect(),
|
||||
),
|
||||
];
|
||||
let mut case_results = BTreeMap::new();
|
||||
for (name, options, expected) in cases {
|
||||
assert!(validate_options(&p, Layer::Oa, &options).is_empty());
|
||||
let mut changed = original.clone();
|
||||
changed.view_options = options;
|
||||
assert_eq!(projected(&p, &changed), expected, "{name}");
|
||||
let view = projection::project_diagram(&p, &changed);
|
||||
assert_eq!(
|
||||
view.nodes, all.nodes,
|
||||
"{name}: visibility must not move nodes"
|
||||
);
|
||||
assert_eq!(
|
||||
view.ports, all.ports,
|
||||
"{name}: visibility must not move ports"
|
||||
);
|
||||
assert!(changed.routes.is_empty());
|
||||
case_results.insert(name, json!({"count":expected.len(),"edges":expected}));
|
||||
}
|
||||
assert_eq!(
|
||||
ExchangeLabels::Items.label(&p, exchanges[0]),
|
||||
"Commands payload"
|
||||
);
|
||||
assert_eq!(
|
||||
ExchangeLabels::NameAndItems.label(&p, exchanges[39]),
|
||||
"Service data 40 · Measurements payload"
|
||||
);
|
||||
let began = Instant::now();
|
||||
let routed = routing::route_diagram(&p, original, &all);
|
||||
let route_ms = began.elapsed().as_millis();
|
||||
assert_eq!(routed.edges.len(), 40);
|
||||
let edges: Vec<_> = routed.edges.values().collect();
|
||||
let mut label_overlaps = vec![];
|
||||
let mut coincident_paths = vec![];
|
||||
for (i, first) in edges.iter().enumerate() {
|
||||
for second in &edges[i + 1..] {
|
||||
if overlap(first.label.bounds, second.label.bounds) {
|
||||
label_overlaps.push([first.id, second.id]);
|
||||
}
|
||||
if first.points == second.points {
|
||||
coincident_paths.push([first.id, second.id]);
|
||||
}
|
||||
}
|
||||
}
|
||||
let source = language::format(&p);
|
||||
assert_eq!(
|
||||
language::parse(&source).expect("Canonical fixture source must reopen"),
|
||||
p
|
||||
);
|
||||
let native_json = serde_json::to_vec_pretty(&p)?;
|
||||
assert_eq!(persistence::decode(&native_json)?, p);
|
||||
assert_eq!(
|
||||
p, pristine,
|
||||
"Oracles and routing must not mutate model facts"
|
||||
);
|
||||
let report = json!({"schema":p.schema_version,"project":p.id,"diagram":diagram,"semantic_oracles":"passed","elements":p.elements.len(),"relations":p.relations.len(),"cases":case_results,
|
||||
"routing_ms":route_ms,"label_overlap_pairs":label_overlaps,"exactly_coincident_path_pairs":coincident_paths,
|
||||
"routing_warnings":edges.iter().map(|edge| json!({"edge":edge.id,"warnings":edge.warnings.iter().map(ToString::to_string).collect::<Vec<_>>()})).collect::<Vec<_>>(),
|
||||
"native_readability":"pending; metrics do not establish a usability pass"});
|
||||
fs::create_dir_all(&output)?;
|
||||
fs::write(output.join("dense-40.arcadia"), source)?;
|
||||
fs::write(output.join("dense-40.json"), native_json)?;
|
||||
fs::write(
|
||||
output.join("dense-40-oracle.json"),
|
||||
serde_json::to_vec_pretty(&report)?,
|
||||
)?;
|
||||
println!("{}", serde_json::to_string_pretty(&report)?);
|
||||
Ok(())
|
||||
}
|
||||
@@ -507,6 +507,7 @@ fn workshop() -> Project {
|
||||
let baseline = TraceBaseline {
|
||||
oa: p.properties(t.oa).unwrap(),
|
||||
sa: p.properties(t.sa).unwrap(),
|
||||
semantic: None,
|
||||
};
|
||||
p.traces.get_mut(&id).unwrap().baseline = Some(baseline);
|
||||
}
|
||||
|
||||
@@ -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![],
|
||||
},
|
||||
@@ -111,6 +113,7 @@ fn trace(p: &mut Project, oa: Id, sa: Id, kind: TraceKind) {
|
||||
baseline: Some(TraceBaseline {
|
||||
oa: p.properties(oa).unwrap(),
|
||||
sa: p.properties(sa).unwrap(),
|
||||
semantic: None,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
+276
-41
@@ -1,4 +1,6 @@
|
||||
//! Typed OA/SA language, tolerant source analysis, and explicit V1/V2/V3 migration.
|
||||
//! Typed OA/SA language, tolerant source analysis, and explicit V1/V2/V3/V4 migration.
|
||||
#[path = "language/semantic_source.rs"]
|
||||
mod semantic_source;
|
||||
#[path = "migration_v2/source.rs"]
|
||||
mod v2_source;
|
||||
use crate::*;
|
||||
@@ -82,7 +84,20 @@ impl ParseLimits {
|
||||
.elements
|
||||
.values()
|
||||
.map(|e| e.steps().map_or(0, Vec::len))
|
||||
.sum();
|
||||
.sum::<usize>()
|
||||
.saturating_add(
|
||||
project
|
||||
.relations
|
||||
.values()
|
||||
.map(|r| match &r.body {
|
||||
RelationBody::ContextualLink { source, target, .. } => source
|
||||
.references
|
||||
.len()
|
||||
.saturating_add(target.references.len()),
|
||||
_ => 0,
|
||||
})
|
||||
.sum::<usize>(),
|
||||
);
|
||||
self.check_counts(graph, records, steps)
|
||||
}
|
||||
}
|
||||
@@ -127,12 +142,87 @@ pub struct Analysis {
|
||||
pub declarations: Vec<DeclarationSpan>,
|
||||
}
|
||||
|
||||
/// Check the decoded spelling required by declaration-header UUID clauses.
|
||||
/// This is the shared 36-byte/hyphen shape rule, not a hexadecimal parser:
|
||||
/// callers must additionally parse the UUID. Uppercase hex and JSON escapes
|
||||
/// that decode to this spelling remain accepted. URN/braced/compact forms do not.
|
||||
pub fn has_uuid_header_shape(decoded: &str) -> bool {
|
||||
decoded.len() == 36
|
||||
&& [8, 13, 18, 23]
|
||||
.into_iter()
|
||||
.all(|index| decoded.as_bytes()[index] == b'-')
|
||||
}
|
||||
|
||||
/// Budgets for one original source buffer. Policy is caller-owned; these
|
||||
/// limits do not change the grammar or perform model admission.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct OutlineLimits {
|
||||
pub max_source_bytes: usize,
|
||||
pub max_tokens: usize,
|
||||
pub max_declarations: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum OutlineResource {
|
||||
SourceBytes,
|
||||
Tokens,
|
||||
Declarations,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct OutlineLimit {
|
||||
pub resource: OutlineResource,
|
||||
pub limit: usize,
|
||||
pub observed_at_least: usize,
|
||||
/// Original byte offset where admission stopped; never a rewritten offset.
|
||||
pub offset: usize,
|
||||
}
|
||||
|
||||
/// Roles discovered by the existing tolerant declaration walk. Keeping the
|
||||
/// wrapper preserves DeclarationSpan and Analysis as existing public APIs.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct OutlineDeclaration {
|
||||
pub declaration: DeclarationSpan,
|
||||
pub kind_span: Span,
|
||||
pub alias_span: Span,
|
||||
/// Whole quoted header ID token, even if it is not a valid UUID. In that
|
||||
/// case declaration.id is None. Missing/non-string ID values have no span.
|
||||
pub id_literal_span: Option<Span>,
|
||||
}
|
||||
|
||||
/// A full-file tolerant scan, not a successful parse or validated model. Invalid
|
||||
/// text remains available. Lexical errors and unmatched braces are facts only:
|
||||
/// their absence does not establish syntactic or semantic validity.
|
||||
///
|
||||
/// A budget failure returns Err and no partial Outline. Include reachability,
|
||||
/// project completeness and editor revision are intentionally not inferred here.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Outline<'a> {
|
||||
pub source: &'a str,
|
||||
pub tokens: Vec<TokenSpan>,
|
||||
pub declarations: Vec<OutlineDeclaration>,
|
||||
pub has_lexical_errors: bool,
|
||||
pub has_unmatched_braces: bool,
|
||||
}
|
||||
|
||||
/// Tolerant highlighting tokens cover the whole input, including incomplete
|
||||
/// strings and unexpected characters. No model is created or modified.
|
||||
pub fn tokenize(source: &str) -> Vec<TokenSpan> {
|
||||
tokenize_with_limit(source, usize::MAX).expect("unlimited token scan")
|
||||
}
|
||||
|
||||
fn tokenize_with_limit(source: &str, max_tokens: usize) -> Result<Vec<TokenSpan>, OutlineLimit> {
|
||||
let mut tokens = Vec::new();
|
||||
let mut cursor = 0;
|
||||
while cursor < source.len() {
|
||||
if tokens.len() == max_tokens {
|
||||
return Err(OutlineLimit {
|
||||
resource: OutlineResource::Tokens,
|
||||
limit: max_tokens,
|
||||
observed_at_least: tokens.len().saturating_add(1),
|
||||
offset: cursor,
|
||||
});
|
||||
}
|
||||
let start = cursor;
|
||||
let ch = source[cursor..].chars().next().expect("in bounds");
|
||||
let class = if matches!(ch, ' ' | '\t' | '\r' | '\n') {
|
||||
@@ -209,7 +299,7 @@ pub fn tokenize(source: &str) -> Vec<TokenSpan> {
|
||||
span: Span { start, end: cursor },
|
||||
});
|
||||
}
|
||||
tokens
|
||||
Ok(tokens)
|
||||
}
|
||||
|
||||
pub fn lint(source: &str) -> Vec<Diagnostic> {
|
||||
@@ -341,6 +431,10 @@ fn reference_field(key: &str) -> bool {
|
||||
| "local"
|
||||
| "field"
|
||||
| "path"
|
||||
| "references"
|
||||
| "occurrence"
|
||||
| "allocation"
|
||||
| "work"
|
||||
| "supertype"
|
||||
| "sender"
|
||||
| "receiver"
|
||||
@@ -348,6 +442,9 @@ fn reference_field(key: &str) -> bool {
|
||||
| "lifeline"
|
||||
| "state"
|
||||
| "constrained"
|
||||
| "exchanges"
|
||||
| "hidden_edges"
|
||||
| "Any"
|
||||
| "source"
|
||||
| "target"
|
||||
| "source_port"
|
||||
@@ -387,7 +484,7 @@ fn enum_field(key: &str) -> bool {
|
||||
)
|
||||
}
|
||||
|
||||
/// Parse V4, or explicitly migrate a valid V1/V2/V3 source candidate. Parsing never
|
||||
/// Parse V5, or explicitly migrate a valid V1/V2/V3/V4 source candidate. Parsing never
|
||||
/// adopts a model and never overwrites an editor draft.
|
||||
pub fn parse(source: &str) -> Result<Project, Vec<Diagnostic>> {
|
||||
parse_limited(source, ParseLimits::UNLIMITED)
|
||||
@@ -552,7 +649,7 @@ fn offset_for(source: &str, line: usize, column: usize) -> usize {
|
||||
/// nested records are ordinary language fields; JSON documents are not embedded.
|
||||
pub fn format(project: &Project) -> String {
|
||||
let mut out = std::format!(
|
||||
"arcadia 4;\nproject {} {} id \"{}\" {{\n description {};\n\n",
|
||||
"arcadia {SCHEMA_VERSION};\nproject {} {} id \"{}\" {{\n description {};\n\n",
|
||||
project.alias,
|
||||
quote(&project.name),
|
||||
project.id,
|
||||
@@ -743,6 +840,10 @@ fn write_value(
|
||||
indent: usize,
|
||||
aliases: &BTreeMap<Id, String>,
|
||||
) {
|
||||
if key == "semantic" {
|
||||
semantic_source::write(out, value, indent);
|
||||
return;
|
||||
}
|
||||
match value {
|
||||
Value::Null => out.push_str("none"),
|
||||
Value::Bool(v) => out.push_str(if *v { "true" } else { "false" }),
|
||||
@@ -786,6 +887,7 @@ enum Ast {
|
||||
Literal(Value, usize),
|
||||
Word(String, usize),
|
||||
Reference(String, usize),
|
||||
HistoricalUuid(Id, usize),
|
||||
Array(Vec<Ast>, usize),
|
||||
Object(BTreeMap<String, Ast>, usize),
|
||||
}
|
||||
@@ -795,12 +897,20 @@ impl Ast {
|
||||
Self::Literal(_, p)
|
||||
| Self::Word(_, p)
|
||||
| Self::Reference(_, p)
|
||||
| Self::HistoricalUuid(_, p)
|
||||
| Self::Array(_, p)
|
||||
| Self::Object(_, p) => *p,
|
||||
}
|
||||
}
|
||||
fn resolve(&self, key: &str, aliases: &BTreeMap<String, Id>) -> Result<Value, Error> {
|
||||
match self {
|
||||
Self::HistoricalUuid(_, _) => Err(Error {
|
||||
code: "invalid_source",
|
||||
offset: self.offset(),
|
||||
message:
|
||||
"A uuid literal is allowed only in a typed historical semantic reference slot."
|
||||
.into(),
|
||||
}),
|
||||
Self::Word(word, offset) => {
|
||||
if !enum_field(key) {
|
||||
return Err(Error {
|
||||
@@ -968,6 +1078,7 @@ pub(crate) struct EditValue {
|
||||
pub(crate) enum EditKind {
|
||||
Atom,
|
||||
Reference(String),
|
||||
HistoricalUuid(Id),
|
||||
Record(BTreeMap<String, EditField>),
|
||||
List(Vec<EditValue>),
|
||||
}
|
||||
@@ -1080,7 +1191,7 @@ fn edit_positions(
|
||||
let span = Span {
|
||||
start: ast.offset(),
|
||||
end: match ast {
|
||||
Ast::Reference(..) => self.tokens[index + 1].span.end,
|
||||
Ast::Reference(..) | Ast::HistoricalUuid(..) => self.tokens[index + 1].span.end,
|
||||
_ => self.ends[index],
|
||||
},
|
||||
};
|
||||
@@ -1090,6 +1201,7 @@ fn edit_positions(
|
||||
EditKind::List(items.iter().map(|item| self.value(item)).collect())
|
||||
}
|
||||
Ast::Reference(alias, _) => EditKind::Reference(alias.clone()),
|
||||
Ast::HistoricalUuid(id, _) => EditKind::HistoricalUuid(*id),
|
||||
_ => EditKind::Atom,
|
||||
};
|
||||
EditValue { span, kind }
|
||||
@@ -1193,11 +1305,7 @@ impl Parser<'_> {
|
||||
self.cursor += 1;
|
||||
let offset = self.current().span.start;
|
||||
let value = self.quoted()?;
|
||||
if value.len() != 36
|
||||
|| ![8, 13, 18, 23]
|
||||
.into_iter()
|
||||
.all(|i| value.as_bytes()[i] == b'-')
|
||||
{
|
||||
if !has_uuid_header_shape(&value) {
|
||||
return Err(Error {
|
||||
code: "invalid_source",
|
||||
offset,
|
||||
@@ -1270,6 +1378,16 @@ impl Parser<'_> {
|
||||
let token = self.current();
|
||||
let offset = token.span.start;
|
||||
match self.text() {
|
||||
"uuid" => {
|
||||
self.cursor += 1;
|
||||
let value = self.quoted()?;
|
||||
let id = Id::parse_str(&value).map_err(|_| Error {
|
||||
code: "invalid_source",
|
||||
offset,
|
||||
message: "Expected a UUID after uuid.".into(),
|
||||
})?;
|
||||
Ok(Ast::HistoricalUuid(id, offset))
|
||||
}
|
||||
"@" => {
|
||||
self.cursor += 1;
|
||||
Ok(Ast::Reference(self.identifier()?, offset))
|
||||
@@ -1356,11 +1474,12 @@ 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") {
|
||||
return Err(self
|
||||
.error("Unsupported schema version: expected 'arcadia 3;' or 'arcadia 4;'."));
|
||||
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 = if self.text() == "3" { 3 } else { 4 };
|
||||
let schema_version = self.text().parse().expect("checked source version");
|
||||
self.cursor += 1;
|
||||
self.take(";")?;
|
||||
let start = self.current().span.start;
|
||||
@@ -1557,7 +1676,12 @@ pub(crate) fn resolve_declarations(
|
||||
let mut fields = declaration
|
||||
.fields
|
||||
.iter()
|
||||
.map(|(k, v)| Ok((k.clone(), v.resolve(k, aliases)?)))
|
||||
.map(|(k, v)| {
|
||||
let value = if matches!(declaration.kind, DeclKind::Trace) && k == "baseline" {
|
||||
semantic_source::resolve_baseline(v, aliases, project.schema_version)?
|
||||
} else { v.resolve(k, aliases)? };
|
||||
Ok((k.clone(), value))
|
||||
})
|
||||
.collect::<Result<Map<_, _>, Error>>()?;
|
||||
let d = declaration;
|
||||
let conversion_error = |message: String| Error {
|
||||
@@ -1571,6 +1695,10 @@ pub(crate) fn resolve_declarations(
|
||||
};
|
||||
match d.kind {
|
||||
DeclKind::Element(kind, layer) => {
|
||||
if project.schema_version < 7 { crate::persistence::reject_source7_fields(&Value::Object(fields.clone())).map_err(&conversion_error)?; }
|
||||
if project.schema_version < 6 && !crate::persistence::pre6_element_kind(kind) {
|
||||
return Err(conversion_error(std::format!("{kind:?} requires schema/source 6.")));
|
||||
}
|
||||
if legacy && !crate::persistence::v3::element_allowed(serde_json::to_value(kind).unwrap().as_str().unwrap()) {
|
||||
return Err(conversion_error("Element constructor was not part of schema3.".into()));
|
||||
}
|
||||
@@ -1645,12 +1773,18 @@ pub(crate) fn resolve_declarations(
|
||||
);
|
||||
}
|
||||
DeclKind::Relation(kind) => {
|
||||
if project.schema_version < 7 && kind == RelationKind::ContextualLink { return Err(conversion_error("ContextualLink requires schema/source 7.".into())); }
|
||||
if project.schema_version < 6 && !crate::persistence::pre6_relation_kind(kind) {
|
||||
return Err(conversion_error(std::format!("{kind:?} requires schema/source 6.")));
|
||||
}
|
||||
if legacy && !crate::persistence::v3::relation_allowed(serde_json::to_value(kind).unwrap().as_str().unwrap()) {
|
||||
return Err(conversion_error("Relation constructor was not part of schema3.".into()));
|
||||
}
|
||||
let description =
|
||||
take_string(&mut fields, "description", "").map_err(&conversion_error)?;
|
||||
let required: &[&str] = if kind == RelationKind::FunctionalExchange {
|
||||
let required: &[&str] = if kind == RelationKind::ContextualLink {
|
||||
&["owner", "source", "target", "purpose"]
|
||||
} else if kind == RelationKind::FunctionalExchange {
|
||||
&["source", "target", "source_port", "target_port"]
|
||||
} else {
|
||||
&["source", "target"]
|
||||
@@ -1681,8 +1815,15 @@ pub(crate) fn resolve_declarations(
|
||||
);
|
||||
}
|
||||
DeclKind::Diagram => {
|
||||
if project.schema_version < 7 { crate::persistence::reject_source7_fields(&Value::Object(fields.clone())).map_err(&conversion_error)?; }
|
||||
if project.schema_version < 6 && fields.contains_key("view_options") {
|
||||
return Err(conversion_error("Diagram.view_options requires schema/source 6, including an explicitly supplied default value.".into()));
|
||||
}
|
||||
if project.schema_version < 6 && let Some(key)=fields.keys().find(|key| !matches!(key.as_str(),"description"|"layer"|"projection"|"routes")) {
|
||||
return Err(conversion_error(std::format!("Unknown diagram field '{key}' in the frozen pre6 contract.")));
|
||||
}
|
||||
let defaults =
|
||||
serde_json::json!({"description":"","layer":"Oa","projection":"Overview","routes":[]});
|
||||
serde_json::json!({"description":"","layer":"Oa","projection":"Overview","routes":[],"view_options":crate::view_options::ViewOptions::default()});
|
||||
let mut record = merge_fields(defaults.as_object().unwrap().clone(), fields)
|
||||
.map_err(&conversion_error)?;
|
||||
header(&mut record, d, true);
|
||||
@@ -1729,8 +1870,12 @@ pub(crate) fn resolve_declarations(
|
||||
let mut record = merge_fields(defaults.as_object().unwrap().clone(), fields)
|
||||
.map_err(&conversion_error)?;
|
||||
header(&mut record, d, true);
|
||||
let trace = serde_json::from_value(Value::Object(record))
|
||||
.map_err(|e| conversion_error(e.to_string()))?;
|
||||
let trace = if project.schema_version < 5 {
|
||||
let old: crate::persistence::v4::Trace4 = serde_json::from_value(Value::Object(record)).map_err(|e| conversion_error(e.to_string()))?;
|
||||
Trace::from(old)
|
||||
} else {
|
||||
serde_json::from_value(Value::Object(record)).map_err(|e| conversion_error(e.to_string()))?
|
||||
};
|
||||
project.traces.insert(d.id, trace);
|
||||
}
|
||||
}
|
||||
@@ -1801,6 +1946,29 @@ pub(crate) fn resolve_declarations(
|
||||
}
|
||||
return Ok(migrated);
|
||||
}
|
||||
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 {
|
||||
code: if matches!(error, crate::persistence::FileError::Limit(_)) {
|
||||
"model_limit"
|
||||
} else {
|
||||
"migration_error"
|
||||
},
|
||||
offset: 0,
|
||||
message: error.to_string(),
|
||||
},
|
||||
)
|
||||
});
|
||||
}
|
||||
limits.check_model(&project).map_err(|message| {
|
||||
(
|
||||
usize::MAX,
|
||||
@@ -1865,8 +2033,49 @@ fn take_required_id(fields: &mut Map<String, Value>, key: &str) -> Result<Id, St
|
||||
|
||||
/// Analyze an incomplete source buffer without adopting a candidate. Declaration
|
||||
/// spans remain available even when a later declaration contains errors.
|
||||
/// Invalid compact/braced/URN header IDs retain no authored identity, matching
|
||||
/// the declaration parser rather than the UUID crate's broader accepted forms.
|
||||
pub fn analyze(source: &str) -> Analysis {
|
||||
let tokens = tokenize(source);
|
||||
let (declarations, _) =
|
||||
declaration_outline(source, &tokens, usize::MAX).expect("unlimited declaration walk");
|
||||
Analysis {
|
||||
tokens,
|
||||
diagnostics: lint(source),
|
||||
declarations: declarations.into_iter().map(|d| d.declaration).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Read exact current text without lint, parsing a model, stabilization or I/O.
|
||||
/// Source bytes are admitted before token allocation; token/declaration limits
|
||||
/// are checked before lexing the next token or materializing the next record.
|
||||
pub fn outline(source: &str, limits: OutlineLimits) -> Result<Outline<'_>, OutlineLimit> {
|
||||
if source.len() > limits.max_source_bytes {
|
||||
return Err(OutlineLimit {
|
||||
resource: OutlineResource::SourceBytes,
|
||||
limit: limits.max_source_bytes,
|
||||
observed_at_least: source.len(),
|
||||
offset: 0,
|
||||
});
|
||||
}
|
||||
let tokens = tokenize_with_limit(source, limits.max_tokens)?;
|
||||
let has_lexical_errors = tokens.iter().any(|token| token.class == TokenClass::Error);
|
||||
let (declarations, has_unmatched_braces) =
|
||||
declaration_outline(source, &tokens, limits.max_declarations)?;
|
||||
Ok(Outline {
|
||||
source,
|
||||
tokens,
|
||||
declarations,
|
||||
has_lexical_errors,
|
||||
has_unmatched_braces,
|
||||
})
|
||||
}
|
||||
|
||||
fn declaration_outline(
|
||||
source: &str,
|
||||
tokens: &[TokenSpan],
|
||||
max_declarations: usize,
|
||||
) -> Result<(Vec<OutlineDeclaration>, bool), OutlineLimit> {
|
||||
let significant = tokens
|
||||
.iter()
|
||||
.filter(|t| !matches!(t.class, TokenClass::Whitespace | TokenClass::Comment))
|
||||
@@ -1875,17 +2084,21 @@ pub fn analyze(source: &str) -> Analysis {
|
||||
// again would make tolerant navigation quadratic on deeply nested drafts.
|
||||
let mut body_ends = vec![None; significant.len()];
|
||||
let mut bodies = Vec::new();
|
||||
let mut unmatched_close = false;
|
||||
for (index, token) in significant.iter().enumerate() {
|
||||
match &source[token.span.start..token.span.end] {
|
||||
"{" => bodies.push(index),
|
||||
"}" => {
|
||||
if let Some(open) = bodies.pop() {
|
||||
body_ends[open] = Some(token.span.end);
|
||||
} else {
|
||||
unmatched_close = true;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let has_unmatched_braces = unmatched_close || !bodies.is_empty();
|
||||
let mut declarations = Vec::new();
|
||||
for (i, t) in significant.iter().enumerate() {
|
||||
let word = &source[t.span.start..t.span.end];
|
||||
@@ -1910,7 +2123,16 @@ pub fn analyze(source: &str) -> Analysis {
|
||||
}
|
||||
j += 1;
|
||||
}
|
||||
if declarations.len() == max_declarations {
|
||||
return Err(OutlineLimit {
|
||||
resource: OutlineResource::Declarations,
|
||||
limit: max_declarations,
|
||||
observed_at_least: declarations.len().saturating_add(1),
|
||||
offset: t.span.start,
|
||||
});
|
||||
}
|
||||
let mut id = None;
|
||||
let mut id_literal_span = None;
|
||||
if significant
|
||||
.get(j)
|
||||
.is_some_and(|t| &source[t.span.start..t.span.end] == "id")
|
||||
@@ -1918,39 +2140,51 @@ pub fn analyze(source: &str) -> Analysis {
|
||||
if let Some(value) = significant.get(j + 1)
|
||||
&& value.class == TokenClass::String
|
||||
{
|
||||
id_literal_span = Some(value.span);
|
||||
let text =
|
||||
serde_json::from_str::<String>(&source[value.span.start..value.span.end])
|
||||
.unwrap();
|
||||
id = Id::parse_str(&text).ok();
|
||||
if has_uuid_header_shape(&text) {
|
||||
id = Id::parse_str(&text).ok();
|
||||
}
|
||||
}
|
||||
j += 2;
|
||||
}
|
||||
let mut end = significant
|
||||
.get(j)
|
||||
.map_or(alias_token.span.end, |t| t.span.end);
|
||||
// A recognized header ID at EOF must remain inside the whole span.
|
||||
// Keep the old next-token/body behavior; only the EOF fallback changes.
|
||||
let header_end = significant
|
||||
.get(j.saturating_sub(1))
|
||||
.or_else(|| significant.last())
|
||||
.map_or(alias_token.span.end, |token| token.span.end);
|
||||
let mut end = significant.get(j).map_or(header_end, |t| t.span.end);
|
||||
if significant
|
||||
.get(j)
|
||||
.is_some_and(|t| &source[t.span.start..t.span.end] == "{")
|
||||
{
|
||||
end = body_ends[j].unwrap_or(source.len());
|
||||
}
|
||||
declarations.push(DeclarationSpan {
|
||||
alias: alias.into(),
|
||||
id,
|
||||
kind: word.into(),
|
||||
span: Span {
|
||||
start: t.span.start,
|
||||
end,
|
||||
declarations.push(OutlineDeclaration {
|
||||
declaration: DeclarationSpan {
|
||||
alias: alias.into(),
|
||||
id,
|
||||
kind: word.into(),
|
||||
span: Span {
|
||||
start: t.span.start,
|
||||
end,
|
||||
},
|
||||
},
|
||||
kind_span: t.span,
|
||||
alias_span: alias_token.span,
|
||||
id_literal_span,
|
||||
});
|
||||
}
|
||||
Analysis {
|
||||
tokens,
|
||||
diagnostics: lint(source),
|
||||
declarations,
|
||||
}
|
||||
Ok((declarations, has_unmatched_braces))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "language_outline_tests.rs"]
|
||||
mod outline_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "language_nesting_tests.rs"]
|
||||
mod nesting_tests;
|
||||
@@ -2345,11 +2579,7 @@ pub(crate) mod legacy {
|
||||
let value = self.quoted()?;
|
||||
// Require the portable 8-4-4-4-12 spelling, not the UUID crate's URN,
|
||||
// braces, or compact alternatives. Uppercase hexadecimal is accepted.
|
||||
if value.len() != 36
|
||||
|| ![8, 13, 18, 23]
|
||||
.into_iter()
|
||||
.all(|index| value.as_bytes()[index] == b'-')
|
||||
{
|
||||
if !super::has_uuid_header_shape(&value) {
|
||||
return Err(Error {
|
||||
code: "invalid_source",
|
||||
offset,
|
||||
@@ -2595,3 +2825,8 @@ pub(crate) mod legacy {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Closed historical facet identity for the targeted source writer.
|
||||
pub(crate) fn semantic_row_key(source: &str, origins: &BTreeMap<Id, Id>) -> Option<String> {
|
||||
semantic_source::row_key(source, origins)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
//! Typed historical source leaves; live alias resolution remains in the parent.
|
||||
use super::*;
|
||||
use crate::semantic::{HistoricalSourceKind, SemanticBaseline, historical_source_slots};
|
||||
|
||||
fn error(ast: &Ast, message: impl Into<String>) -> Error {
|
||||
Error {
|
||||
code: "invalid_source",
|
||||
offset: ast.offset(),
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn raw(ast: &Ast) -> Result<Value, Error> {
|
||||
Ok(match ast {
|
||||
Ast::Literal(value, _) => value.clone(),
|
||||
Ast::Word(value, _) => Value::String(value.clone()),
|
||||
Ast::HistoricalUuid(value, _) => Value::String(value.to_string()),
|
||||
Ast::Reference(_, _) => {
|
||||
return Err(error(
|
||||
ast,
|
||||
"Historical semantic references require uuid \"...\", not @alias.",
|
||||
));
|
||||
}
|
||||
Ast::Array(values, _) => Value::Array(values.iter().map(raw).collect::<Result<_, _>>()?),
|
||||
Ast::Object(fields, _) => Value::Object(
|
||||
fields
|
||||
.iter()
|
||||
.map(|(key, value)| Ok((key.clone(), raw(value)?)))
|
||||
.collect::<Result<_, Error>>()?,
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
fn validate_tokens(
|
||||
ast: &Ast,
|
||||
path: &mut Vec<String>,
|
||||
slots: &BTreeMap<Vec<String>, HistoricalSourceKind>,
|
||||
) -> Result<(), Error> {
|
||||
match ast {
|
||||
Ast::Object(fields, _) => {
|
||||
for (key, value) in fields {
|
||||
path.push(key.clone());
|
||||
validate_tokens(value, path, slots)?;
|
||||
path.pop();
|
||||
}
|
||||
}
|
||||
Ast::Array(values, _) => {
|
||||
for (index, value) in values.iter().enumerate() {
|
||||
path.push(index.to_string());
|
||||
validate_tokens(value, path, slots)?;
|
||||
path.pop();
|
||||
}
|
||||
}
|
||||
_ => match (slots.get(path), ast) {
|
||||
(Some(HistoricalSourceKind::Reference), Ast::HistoricalUuid(..))
|
||||
| (Some(HistoricalSourceKind::Enum), Ast::Word(..)) => {}
|
||||
(Some(HistoricalSourceKind::Reference), _) => {
|
||||
return Err(error(
|
||||
ast,
|
||||
"This historical reference requires uuid \"...\".",
|
||||
));
|
||||
}
|
||||
(Some(HistoricalSourceKind::Enum), _) => {
|
||||
return Err(error(
|
||||
ast,
|
||||
"This typed historical enum requires an unquoted enum word.",
|
||||
));
|
||||
}
|
||||
(None, Ast::HistoricalUuid(..)) => {
|
||||
return Err(error(
|
||||
ast,
|
||||
"A uuid literal is allowed only in a typed historical reference slot.",
|
||||
));
|
||||
}
|
||||
(None, Ast::Word(..)) => {
|
||||
return Err(error(
|
||||
ast,
|
||||
"This historical text value requires a quoted string.",
|
||||
));
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn resolve_baseline(
|
||||
ast: &Ast,
|
||||
aliases: &BTreeMap<String, Id>,
|
||||
version: u32,
|
||||
) -> Result<Value, Error> {
|
||||
let Ast::Object(fields, _) = ast else {
|
||||
return ast.resolve("baseline", aliases);
|
||||
};
|
||||
let mut resolved = Map::new();
|
||||
for (key, value) in fields {
|
||||
if key != "semantic" {
|
||||
resolved.insert(key.clone(), value.resolve(key, aliases)?);
|
||||
continue;
|
||||
}
|
||||
if version < 5 {
|
||||
return Err(error(
|
||||
value,
|
||||
"Semantic trace baselines require schema/source5; the older baseline contains prose only.",
|
||||
));
|
||||
}
|
||||
if matches!(value, Ast::Literal(Value::Null, _)) {
|
||||
resolved.insert(key.clone(), Value::Null);
|
||||
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);
|
||||
}
|
||||
Ok(Value::Object(resolved))
|
||||
}
|
||||
|
||||
pub(super) fn write(out: &mut String, value: &Value, indent: usize) {
|
||||
if value.is_null() {
|
||||
out.push_str("none");
|
||||
return;
|
||||
}
|
||||
let baseline: SemanticBaseline =
|
||||
serde_json::from_value(value.clone()).expect("typed semantic baseline");
|
||||
let slots = historical_source_slots(&baseline).into_iter().collect();
|
||||
write_at(out, value, indent, &mut vec![], &slots);
|
||||
}
|
||||
|
||||
fn write_at(
|
||||
out: &mut String,
|
||||
value: &Value,
|
||||
indent: usize,
|
||||
path: &mut Vec<String>,
|
||||
slots: &BTreeMap<Vec<String>, HistoricalSourceKind>,
|
||||
) {
|
||||
match value {
|
||||
Value::Null => out.push_str("none"),
|
||||
Value::Bool(value) => out.push_str(if *value { "true" } else { "false" }),
|
||||
Value::Number(value) => out.push_str(&value.to_string()),
|
||||
Value::String(value) => match slots.get(path) {
|
||||
Some(HistoricalSourceKind::Reference) => {
|
||||
out.push_str("uuid ");
|
||||
out.push_str("e(value));
|
||||
}
|
||||
Some(HistoricalSourceKind::Enum) => out.push_str(value),
|
||||
None => out.push_str("e(value)),
|
||||
},
|
||||
Value::Array(values) => {
|
||||
out.push('[');
|
||||
for (index, value) in values.iter().enumerate() {
|
||||
if index != 0 {
|
||||
out.push_str(", ");
|
||||
}
|
||||
path.push(index.to_string());
|
||||
write_at(out, value, indent, path, slots);
|
||||
path.pop();
|
||||
}
|
||||
out.push(']');
|
||||
}
|
||||
Value::Object(fields) => {
|
||||
out.push_str("{\n");
|
||||
for (key, value) in fields {
|
||||
out.push_str(&" ".repeat(indent + 1));
|
||||
out.push_str(key);
|
||||
out.push(' ');
|
||||
path.push(key.clone());
|
||||
write_at(out, value, indent + 1, path, slots);
|
||||
path.pop();
|
||||
out.push_str(";\n");
|
||||
}
|
||||
out.push_str(&" ".repeat(indent));
|
||||
out.push('}');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Recognize only complete typed facet rows. Their closed selector, with real
|
||||
/// identity provenance applied, owns comments when review rows reorder.
|
||||
pub(super) fn row_key(source: &str, origins: &BTreeMap<Id, Id>) -> Option<String> {
|
||||
let mut parser = Parser {
|
||||
source,
|
||||
tokens: strict_tokens(source).ok()?,
|
||||
cursor: 0,
|
||||
value_depth: 0,
|
||||
limits: ParseLimits::UNLIMITED,
|
||||
};
|
||||
let ast = parser.value().ok()?;
|
||||
let facet = serde_json::from_value(raw(&ast).ok()?).ok()?;
|
||||
let mut baseline = SemanticBaseline {
|
||||
facets: vec![facet],
|
||||
};
|
||||
crate::semantic::visit_semantic_references(&mut baseline, |id| {
|
||||
if let Some(original) = origins.get(id) {
|
||||
*id = *original;
|
||||
}
|
||||
});
|
||||
serde_json::to_string(&baseline.facets[0].selector())
|
||||
.ok()
|
||||
.map(|key| format!("semantic:{key}"))
|
||||
}
|
||||
@@ -3,7 +3,7 @@ use crate::source_project::{ProjectLimits, SourceBundle, compile_bundle, lint_bu
|
||||
|
||||
fn project_source(body: &str) -> String {
|
||||
format!(
|
||||
"arcadia 4; project model \"Model\" id \"00000000-0000-0000-0000-000000000001\" {{\n{body}\n}}"
|
||||
"arcadia 5; project model \"Model\" id \"00000000-0000-0000-0000-000000000001\" {{\n{body}\n}}"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -177,7 +177,7 @@ fn nested_fragment_id_stabilization_preserves_comments_and_indexes_real_bytes()
|
||||
files: BTreeMap::from([
|
||||
(
|
||||
"project.arcadia".into(),
|
||||
"arcadia 4; project model \"Model\" { include \"children.arcadia\"; }".into(),
|
||||
"arcadia 5; project model \"Model\" { include \"children.arcadia\"; }".into(),
|
||||
),
|
||||
(
|
||||
"children.arcadia".into(),
|
||||
@@ -300,7 +300,7 @@ fn deep_declaration_parsing_and_positions_use_a_bounded_rust_stack() {
|
||||
|
||||
#[test]
|
||||
fn tolerant_nested_analysis_retains_closed_children_before_an_unfinished_sibling() {
|
||||
let source = "arcadia 4; project model \"航海\" {\noa_activity outer_one \"Outer\" {\n oa_activity ready \"Ready\" { description \"} is text\"; }\n oa_activity unfinished \"Unfinished\" { description \"unterminated";
|
||||
let source = "arcadia 5; project model \"航海\" {\noa_activity outer_one \"Outer\" {\n oa_activity ready \"Ready\" { description \"} is text\"; }\n oa_activity unfinished \"Unfinished\" { description \"unterminated";
|
||||
let analysis = analyze(source);
|
||||
let ready = analysis
|
||||
.declarations
|
||||
@@ -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);
|
||||
}
|
||||
@@ -2,18 +2,23 @@
|
||||
pub mod data;
|
||||
pub mod language;
|
||||
pub mod model;
|
||||
pub mod operations;
|
||||
pub mod persistence;
|
||||
pub mod ports;
|
||||
pub mod projection;
|
||||
pub mod references;
|
||||
pub mod routing;
|
||||
pub mod semantic;
|
||||
pub mod session;
|
||||
pub mod source_files;
|
||||
pub mod source_project;
|
||||
pub mod topology_context;
|
||||
pub mod transitions;
|
||||
pub mod validation;
|
||||
pub mod view_options;
|
||||
pub use model::*;
|
||||
pub use ports::*;
|
||||
pub use semantic::*;
|
||||
pub use session::*;
|
||||
mod demo;
|
||||
mod state_validation;
|
||||
|
||||
@@ -13,7 +13,9 @@ generic port/optional FE endpoint contracts therefore remain explicit.
|
||||
|
||||
`adapter.rs` budgets generated data, validates V2 and transforms only the changed
|
||||
port/endpoint payloads into an explicit schema 3 candidate. The schema 3 reader
|
||||
then performs the data-contract upgrade and validates schema 4. Serialization
|
||||
then performs the data-contract upgrade into the strict schema 4 envelope. The
|
||||
schema 4 review adapter preserves its prose-only trace baselines and returns
|
||||
schema 5 with semantic review absent. Serialization
|
||||
between compatibility DTOs copies unchanged fields; original input bytes are
|
||||
never deserialized into the new `Project`. V1 normalization also constructs this
|
||||
V2 DTO before the shared stages. Combined generation stays within one budget.
|
||||
|
||||
@@ -24,7 +24,7 @@ pub(crate) struct Project3 {
|
||||
#[serde(deserialize_with = "crate::unique_id_map")]
|
||||
pub artifact_links: BTreeMap<Id, ArtifactLink>,
|
||||
#[serde(deserialize_with = "crate::unique_id_map")]
|
||||
pub traces: BTreeMap<Id, Trace>,
|
||||
pub traces: BTreeMap<Id, crate::persistence::v4::Trace4>,
|
||||
}
|
||||
#[derive(Deserialize, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
@@ -486,8 +486,10 @@ pub(crate) fn migrate(old: Project3, limits: ParseLimits) -> Result<DecodeResult
|
||||
));
|
||||
}
|
||||
}
|
||||
value["schema_version"] = SCHEMA_VERSION.into();
|
||||
let mut project: Project = serde_json::from_value(value)?;
|
||||
value["schema_version"] = 4.into();
|
||||
let normalized: crate::persistence::v4::Project4 = serde_json::from_value(value)?;
|
||||
let mut project = normalized.into_current()?;
|
||||
crate::persistence::v4::review_defaults(&project, &mut report);
|
||||
let mut references = project
|
||||
.elements
|
||||
.values()
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
//! Frozen schema4 review envelopes. Other model records are unchanged in schema5.
|
||||
use crate::language::ParseLimits;
|
||||
use crate::persistence::{DecodeResult, FileError, MigrationReport, SemanticDefault};
|
||||
use crate::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(crate) struct TraceBaseline4 {
|
||||
pub oa: TracedProperties,
|
||||
pub sa: TracedProperties,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(crate) struct Trace4 {
|
||||
pub id: Id,
|
||||
pub alias: String,
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub oa: Id,
|
||||
pub sa: Id,
|
||||
pub kind: TraceKind,
|
||||
pub baseline: Option<TraceBaseline4>,
|
||||
}
|
||||
impl From<Trace4> for Trace {
|
||||
fn from(old: Trace4) -> Self {
|
||||
Self {
|
||||
id: old.id,
|
||||
alias: old.alias,
|
||||
name: old.name,
|
||||
description: old.description,
|
||||
oa: old.oa,
|
||||
sa: old.sa,
|
||||
kind: old.kind,
|
||||
baseline: old.baseline.map(|baseline| TraceBaseline {
|
||||
oa: baseline.oa,
|
||||
sa: baseline.sa,
|
||||
semantic: None,
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(crate) struct Project4 {
|
||||
pub schema_version: u32,
|
||||
pub id: Id,
|
||||
pub alias: String,
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
#[serde(deserialize_with = "crate::unique_id_map")]
|
||||
pub elements: BTreeMap<Id, Element>,
|
||||
#[serde(deserialize_with = "crate::unique_id_map")]
|
||||
pub relations: BTreeMap<Id, Relation>,
|
||||
#[serde(deserialize_with = "crate::unique_id_map")]
|
||||
pub diagrams: BTreeMap<Id, Diagram>,
|
||||
#[serde(deserialize_with = "crate::unique_id_map")]
|
||||
pub artifact_links: BTreeMap<Id, ArtifactLink>,
|
||||
#[serde(deserialize_with = "crate::unique_id_map")]
|
||||
pub traces: BTreeMap<Id, Trace4>,
|
||||
}
|
||||
impl Project4 {
|
||||
pub(crate) fn into_current(self) -> Result<Project, FileError> {
|
||||
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,
|
||||
alias: self.alias,
|
||||
name: self.name,
|
||||
description: self.description,
|
||||
elements: self.elements,
|
||||
relations: self.relations,
|
||||
diagrams: self.diagrams,
|
||||
artifact_links: self.artifact_links,
|
||||
traces: self
|
||||
.traces
|
||||
.into_iter()
|
||||
.map(|(id, trace)| (id, trace.into()))
|
||||
.collect(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn review_defaults(project: &Project, report: &mut MigrationReport) {
|
||||
report.semantic_defaults.extend(
|
||||
project
|
||||
.traces
|
||||
.values()
|
||||
.filter(|trace| trace.baseline.is_some())
|
||||
.map(|trace| SemanticDefault {
|
||||
object: trace.id,
|
||||
field: "baseline.semantic".into(),
|
||||
description: "Original prose review preserved; semantic facets remain unreviewed."
|
||||
.into(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
review_defaults(&project, &mut report);
|
||||
Ok(DecodeResult {
|
||||
project,
|
||||
migration: Some(report),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn migrate(old: Project4, limits: ParseLimits) -> Result<DecodeResult, FileError> {
|
||||
finish(old.into_current()?, limits)
|
||||
}
|
||||
+115
-8
@@ -6,7 +6,7 @@ use std::{
|
||||
use uuid::Uuid;
|
||||
|
||||
pub type Id = Uuid;
|
||||
pub const SCHEMA_VERSION: u32 = 4;
|
||||
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)]
|
||||
@@ -1492,13 +1596,15 @@ pub struct TracedProperties {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct TraceBaseline {
|
||||
pub oa: TracedProperties,
|
||||
pub sa: TracedProperties,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub semantic: Option<crate::semantic::SemanticBaseline>,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Trace {
|
||||
pub id: Id,
|
||||
@@ -1812,6 +1918,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],
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,8 @@ pub(crate) mod v2;
|
||||
pub(crate) use v2::adapter::migrate as migrate_v2_limited;
|
||||
#[path = "migration_v3/mod.rs"]
|
||||
pub(crate) mod v3;
|
||||
#[path = "migration_v4.rs"]
|
||||
pub(crate) mod v4;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
@@ -137,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)
|
||||
@@ -148,13 +187,16 @@ 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) => DecodeResult {
|
||||
Some(4) => v4::migrate(serde_json::from_slice(bytes)?, limits)?,
|
||||
Some(5) => migrate_v5(serde_json::from_slice(bytes)?, limits)?,
|
||||
Some(6) => migrate_v6(serde_json::from_slice(bytes)?, limits)?,
|
||||
Some(7) => DecodeResult {
|
||||
project: serde_json::from_slice(bytes)?,
|
||||
migration: None,
|
||||
},
|
||||
_ => {
|
||||
return Err(FileError::Invalid(
|
||||
"Unsupported or missing schema_version; expected 1, 2, 3 or 4.".into(),
|
||||
"Unsupported or missing schema_version; expected 1, 2, 3, 4, 5, 6 or 7.".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -183,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
|
||||
{
|
||||
@@ -222,6 +257,13 @@ pub fn remap_references(p: &mut Project, old: Id, new: Id) {
|
||||
if t.sa == old {
|
||||
t.sa = new;
|
||||
}
|
||||
if let Some(baseline) = t.baseline.as_mut().and_then(|b| b.semantic.as_mut()) {
|
||||
crate::semantic::visit_semantic_references(baseline, |id| {
|
||||
if *id == old {
|
||||
*id = new;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
/// IDs removed by a cascade. Cross-layer traced counterparts are never included.
|
||||
@@ -255,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
|
||||
{
|
||||
@@ -367,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() {
|
||||
@@ -410,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 {
|
||||
@@ -420,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);
|
||||
@@ -480,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, .. } => {
|
||||
|
||||
@@ -607,6 +607,7 @@ fn route_diagram_impl(
|
||||
.flatten();
|
||||
routed.label = make_label(
|
||||
project,
|
||||
diagram.view_options.exchange_labels,
|
||||
edge.id,
|
||||
&routed.points,
|
||||
authored_label,
|
||||
@@ -1232,6 +1233,7 @@ fn label_text(project: &Project, edge: Id) -> String {
|
||||
RelationKind::Interaction
|
||||
| RelationKind::FunctionalExchange
|
||||
| RelationKind::BoundaryExchange
|
||||
| RelationKind::CommunicationMean
|
||||
| RelationKind::DataAssociation => relation.name.as_str(),
|
||||
RelationKind::CapabilityInclude => "«include»",
|
||||
RelationKind::CapabilityExtend => "«extend»",
|
||||
@@ -1262,8 +1264,11 @@ fn empty_label() -> EdgeLabelGeometry {
|
||||
}
|
||||
}
|
||||
|
||||
// Keep semantic label inputs and the independent collision sets explicit.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn make_label(
|
||||
project: &Project,
|
||||
labels: crate::view_options::ExchangeLabels,
|
||||
edge: Id,
|
||||
points: &[Position],
|
||||
authored: Option<&crate::EdgeLabel>,
|
||||
@@ -1271,7 +1276,21 @@ fn make_label(
|
||||
previous_labels: &[RoutingBounds],
|
||||
strokes: &[Segment],
|
||||
) -> EdgeLabelGeometry {
|
||||
let text = label_text(project, edge);
|
||||
let text = if labels != crate::view_options::ExchangeLabels::Name
|
||||
&& project
|
||||
.relations
|
||||
.get(&edge)
|
||||
.is_some_and(|r| crate::view_options::exchange_family(r.kind()).is_some())
|
||||
{
|
||||
let label = labels.label(project, edge);
|
||||
if label.chars().count() > 48 {
|
||||
label.chars().take(47).chain(std::iter::once('…')).collect()
|
||||
} else {
|
||||
label
|
||||
}
|
||||
} else {
|
||||
label_text(project, edge)
|
||||
};
|
||||
let size = Size {
|
||||
width: if text.is_empty() {
|
||||
0.0
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
//! Closed typed facts for deliberately reviewed OA/SA semantics.
|
||||
//!
|
||||
//! Historical facts are independently acknowledged observations, not a synthetic
|
||||
//! live model. Their UUIDs are not live references and survive ordinary deletion.
|
||||
mod catalog;
|
||||
mod historical;
|
||||
mod linked;
|
||||
mod mutate;
|
||||
mod review;
|
||||
mod types;
|
||||
pub use catalog::*;
|
||||
pub(crate) use historical::{HistoricalSourceKind, historical_source_slots};
|
||||
pub use historical::{validate_semantic_baseline, visit_semantic_references};
|
||||
pub use linked::*;
|
||||
pub use review::*;
|
||||
pub use types::*;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,430 @@
|
||||
use super::types::*;
|
||||
use crate::model::*;
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum HistoricalSourceKind {
|
||||
Reference,
|
||||
Enum,
|
||||
}
|
||||
|
||||
const MAX_FACTS: usize = 16_384;
|
||||
const MAX_LEAVES: usize = 1_000_000;
|
||||
const MAX_PATH: usize = 128;
|
||||
|
||||
struct Walk<'a> {
|
||||
path: Vec<String>,
|
||||
slots: Vec<(Vec<String>, HistoricalSourceKind)>,
|
||||
errors: Vec<String>,
|
||||
leaves: usize,
|
||||
visitor: &'a mut dyn FnMut(&mut Id),
|
||||
}
|
||||
impl Walk<'_> {
|
||||
fn at(&mut self, key: impl ToString, f: impl FnOnce(&mut Self)) {
|
||||
if self.leaves >= MAX_LEAVES {
|
||||
return;
|
||||
}
|
||||
self.leaves += 1;
|
||||
self.path.push(key.to_string());
|
||||
f(self);
|
||||
self.path.pop();
|
||||
}
|
||||
fn slot(&mut self, kind: HistoricalSourceKind) {
|
||||
self.slots.push((self.path.clone(), kind));
|
||||
}
|
||||
fn error(&mut self, message: &str) {
|
||||
self.errors
|
||||
.push(format!("{}: {message}", self.path.join(".")));
|
||||
}
|
||||
}
|
||||
trait Historical {
|
||||
fn walk(&mut self, w: &mut Walk<'_>);
|
||||
}
|
||||
impl Historical for Id {
|
||||
fn walk(&mut self, w: &mut Walk<'_>) {
|
||||
w.slot(HistoricalSourceKind::Reference);
|
||||
(w.visitor)(self);
|
||||
}
|
||||
}
|
||||
macro_rules! plain {
|
||||
($($ty:ty),*) => { $( impl Historical for $ty { fn walk(&mut self, _: &mut Walk<'_>) {} } )* };
|
||||
}
|
||||
plain!(bool, u32, i64);
|
||||
impl Historical for String {
|
||||
fn walk(&mut self, w: &mut Walk<'_>) {
|
||||
if !crate::validation::valid_text(self) {
|
||||
w.error("historical text contains an invalid character");
|
||||
}
|
||||
if self.len() > 1_000_000 {
|
||||
w.error("historical text work limit exceeded");
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Historical for f64 {
|
||||
fn walk(&mut self, w: &mut Walk<'_>) {
|
||||
if !self.is_finite() {
|
||||
w.error("historical real must be finite");
|
||||
}
|
||||
}
|
||||
}
|
||||
macro_rules! enums {
|
||||
($($ty:ty),*) => { $( impl Historical for $ty {
|
||||
fn walk(&mut self, w: &mut Walk<'_>) { w.slot(HistoricalSourceKind::Enum); }
|
||||
} )* };
|
||||
}
|
||||
enums!(
|
||||
crate::view_options::ExchangeFamily,
|
||||
ExchangeItemKind,
|
||||
ExchangeMechanism,
|
||||
ParameterDirection,
|
||||
FieldStorage,
|
||||
ComponentPortKind,
|
||||
ComponentPortOrientation,
|
||||
PortDirection,
|
||||
ScenarioViewpoint,
|
||||
ControlKind,
|
||||
FragmentOperator,
|
||||
TimeEventKind,
|
||||
StateKind,
|
||||
TransmissionProtocol,
|
||||
AcquisitionProtocol,
|
||||
TransitionKind,
|
||||
StateActionKind,
|
||||
ArtifactRelation,
|
||||
ReferenceCollection,
|
||||
OrderedCollection,
|
||||
IncidentEnd,
|
||||
DataConstructor,
|
||||
PortConstructor,
|
||||
OccurrenceConstructor,
|
||||
EventConstructor,
|
||||
MessageConstructor
|
||||
);
|
||||
impl<T: Historical> Historical for Option<T> {
|
||||
fn walk(&mut self, w: &mut Walk<'_>) {
|
||||
if let Some(v) = self {
|
||||
v.walk(w);
|
||||
}
|
||||
}
|
||||
}
|
||||
impl<T: Historical> Historical for Vec<T> {
|
||||
fn walk(&mut self, w: &mut Walk<'_>) {
|
||||
for (i, v) in self.iter_mut().enumerate().take(MAX_LEAVES) {
|
||||
w.at(i, |w| v.walk(w));
|
||||
}
|
||||
if self.len() > MAX_LEAVES {
|
||||
w.error("historical collection work limit exceeded");
|
||||
}
|
||||
}
|
||||
}
|
||||
impl<T: Historical> Historical for Observed<T> {
|
||||
fn walk(&mut self, w: &mut Walk<'_>) {
|
||||
match self {
|
||||
Self::NotApplicable => w.slot(HistoricalSourceKind::Enum),
|
||||
Self::Value(v) => w.at("Value", |w| v.walk(w)),
|
||||
}
|
||||
}
|
||||
}
|
||||
impl<T: Historical> Historical for Reviewed<T> {
|
||||
fn walk(&mut self, w: &mut Walk<'_>) {
|
||||
if matches!(
|
||||
(&self.oa, &self.sa),
|
||||
(Observed::NotApplicable, Observed::NotApplicable)
|
||||
) {
|
||||
w.error("a reviewed facet must apply on at least one side");
|
||||
}
|
||||
w.at("oa", |w| self.oa.walk(w));
|
||||
w.at("sa", |w| self.sa.walk(w));
|
||||
}
|
||||
}
|
||||
impl<T: Historical> Historical for KeyPair<T> {
|
||||
fn walk(&mut self, w: &mut Walk<'_>) {
|
||||
if self.oa.is_none() && self.sa.is_none() {
|
||||
w.error("key pair cannot be empty on both sides");
|
||||
}
|
||||
w.at("oa", |w| self.oa.walk(w));
|
||||
w.at("sa", |w| self.sa.walk(w));
|
||||
}
|
||||
}
|
||||
impl Historical for AnnotationKey {
|
||||
fn walk(&mut self, w: &mut Walk<'_>) {
|
||||
if self.path.len() > MAX_PATH {
|
||||
w.error("historical annotation path limit exceeded");
|
||||
}
|
||||
w.at("path", |w| self.path.walk(w));
|
||||
w.at("field", |w| self.field.walk(w));
|
||||
}
|
||||
}
|
||||
impl Historical for MessageValue {
|
||||
fn walk(&mut self, w: &mut Walk<'_>) {
|
||||
if self.path.len() > MAX_PATH {
|
||||
w.error("historical annotation path limit exceeded");
|
||||
}
|
||||
w.at("path", |w| self.path.walk(w));
|
||||
w.at("field", |w| self.field.walk(w));
|
||||
w.at("expression", |w| self.expression.walk(w));
|
||||
}
|
||||
}
|
||||
impl Historical for LifelineBinding {
|
||||
fn walk(&mut self, w: &mut Walk<'_>) {
|
||||
w.at("referenced", |w| self.referenced.walk(w));
|
||||
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 {
|
||||
Self::Real(value) => w.at("Real", |w| value.walk(w)),
|
||||
Self::String(value) => w.at("String", |w| value.walk(w)),
|
||||
Self::Enumeration(value) => w.at("Enumeration", |w| value.walk(w)),
|
||||
Self::Boolean(_) | Self::Integer(_) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Historical for TypeRef {
|
||||
fn walk(&mut self, w: &mut Walk<'_>) {
|
||||
match self {
|
||||
Self::Named(id) => w.at("Named", |w| id.walk(w)),
|
||||
_ => w.slot(HistoricalSourceKind::Enum),
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Historical for ArtifactReview {
|
||||
fn walk(&mut self, w: &mut Walk<'_>) {
|
||||
match self {
|
||||
Self::Presence(v) => w.at("Presence", |w| v.walk(w)),
|
||||
Self::Name(v) => w.at("Name", |w| v.walk(w)),
|
||||
Self::Description(v) => w.at("Description", |w| v.walk(w)),
|
||||
Self::Owner(v) => w.at("Owner", |w| v.walk(w)),
|
||||
Self::Relation(v) => w.at("Relation", |w| v.walk(w)),
|
||||
Self::Uri(v) => w.at("Uri", |w| v.walk(w)),
|
||||
Self::Revision(v) => w.at("Revision", |w| v.walk(w)),
|
||||
Self::Fragment(v) => w.at("Fragment", |w| v.walk(w)),
|
||||
}
|
||||
}
|
||||
}
|
||||
macro_rules! family_visitor {
|
||||
($( $family:ident, $selector:ident, $review:ident { $( $leaf:ident: $ty:ty ),* } )*) => {
|
||||
impl Historical for FacetReview {
|
||||
fn walk(&mut self, w: &mut Walk<'_>) {
|
||||
match self {
|
||||
$( Self::$family(v) => w.at(stringify!($family), |w| match v {
|
||||
$( $review::$leaf(v) => w.at(stringify!($leaf), |w| v.walk(w)), )*
|
||||
}), )*
|
||||
Self::Member { collection, keys, review } => w.at("Member", |w| {
|
||||
w.at("collection", |w| collection.walk(w)); w.at("keys", |w| keys.walk(w)); w.at("review", |w| review.walk(w));
|
||||
}),
|
||||
Self::Order { collection, review } => w.at("Order", |w| {
|
||||
w.at("collection", |w| collection.walk(w)); w.at("review", |w| review.walk(w));
|
||||
for side in [&review.oa, &review.sa] { if let Observed::Value(order) = side {
|
||||
if order.iter().collect::<BTreeSet<_>>().len() != order.len() { w.error("duplicate historical order member"); }
|
||||
}}
|
||||
}),
|
||||
Self::Literal { keys, review } => w.at("Literal", |w| {
|
||||
w.at("keys", |w| keys.walk(w)); w.at("review", |w| review.walk(w));
|
||||
}),
|
||||
Self::Annotation { keys, review } => w.at("Annotation", |w| {
|
||||
w.at("keys", |w| keys.walk(w)); w.at("review", |w| review.walk(w));
|
||||
for (key, value) in [(&keys.oa, &review.oa), (&keys.sa, &review.sa)] {
|
||||
if let Observed::Value(Some(row)) = value {
|
||||
if key.as_ref() != Some(&AnnotationKey::from(row)) { w.error("historical annotation key and payload disagree"); }
|
||||
}
|
||||
}
|
||||
}),
|
||||
Self::Binding { keys, review } => w.at("Binding", |w| {
|
||||
w.at("keys", |w| keys.walk(w)); w.at("review", |w| review.walk(w));
|
||||
for (key, value) in [(&keys.oa, &review.oa), (&keys.sa, &review.sa)] {
|
||||
if let Observed::Value(Some(row)) = value {
|
||||
if *key != Some(row.referenced) { w.error("historical binding key and payload disagree"); }
|
||||
}
|
||||
}
|
||||
}),
|
||||
Self::Child { keys, review } => w.at("Child", |w| {
|
||||
w.at("keys", |w| keys.walk(w)); w.at("review", |w| review.walk(w));
|
||||
}),
|
||||
Self::Incident { keys, end, review } => w.at("Incident", |w| {
|
||||
w.at("keys", |w| keys.walk(w)); w.at("end", |w| end.walk(w)); w.at("review", |w| review.walk(w));
|
||||
}),
|
||||
Self::Artifact { keys, review } => w.at("Artifact", |w| {
|
||||
w.at("keys", |w| keys.walk(w)); w.at("review", |w| review.walk(w));
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
scalar_families!(family_visitor);
|
||||
|
||||
fn walk_baseline<'a>(
|
||||
baseline: &mut SemanticBaseline,
|
||||
visitor: &'a mut dyn FnMut(&mut Id),
|
||||
) -> Walk<'a> {
|
||||
let mut walk = Walk {
|
||||
path: vec!["facets".into()],
|
||||
slots: vec![],
|
||||
errors: vec![],
|
||||
leaves: 0,
|
||||
visitor,
|
||||
};
|
||||
if baseline.facets.len() > MAX_FACTS {
|
||||
walk.error("historical facet count limit exceeded");
|
||||
}
|
||||
for (i, facet) in baseline.facets.iter_mut().enumerate().take(MAX_FACTS) {
|
||||
walk.at(i, |w| facet.walk(w));
|
||||
}
|
||||
if walk.leaves >= MAX_LEAVES {
|
||||
walk.error("historical reference work limit exceeded");
|
||||
}
|
||||
walk
|
||||
}
|
||||
/// Historical UUIDs are visited only on explicit identity remapping, never deletion.
|
||||
pub fn visit_semantic_references(
|
||||
baseline: &mut SemanticBaseline,
|
||||
mut visitor: impl FnMut(&mut Id),
|
||||
) {
|
||||
walk_baseline(baseline, &mut visitor);
|
||||
}
|
||||
pub(crate) fn historical_source_slots(
|
||||
baseline: &SemanticBaseline,
|
||||
) -> Vec<(Vec<String>, HistoricalSourceKind)> {
|
||||
walk_baseline(&mut baseline.clone(), &mut |_| {}).slots
|
||||
}
|
||||
/// Checks persisted facts without demanding that historical references still exist.
|
||||
pub fn validate_semantic_baseline(baseline: &SemanticBaseline) -> Result<(), Vec<String>> {
|
||||
let mut errors = walk_baseline(&mut baseline.clone(), &mut |_| {}).errors;
|
||||
let mut slots = BTreeSet::new();
|
||||
for selector in baseline
|
||||
.facets
|
||||
.iter()
|
||||
.take(MAX_FACTS)
|
||||
.map(FacetReview::selector)
|
||||
{
|
||||
if selector_slots(&selector)
|
||||
.into_iter()
|
||||
.any(|slot| !slots.insert(slot))
|
||||
{
|
||||
errors.push(format!(
|
||||
"duplicate or overlapping historical facet: {selector:?}"
|
||||
));
|
||||
}
|
||||
}
|
||||
if errors.is_empty() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(errors)
|
||||
}
|
||||
}
|
||||
pub(super) fn selector_slots(selector: &FacetSelector) -> Vec<FacetSelector> {
|
||||
fn split<T: Clone>(keys: &KeyPair<T>) -> Vec<KeyPair<T>> {
|
||||
let mut result = vec![];
|
||||
if let Some(oa) = &keys.oa {
|
||||
result.push(KeyPair {
|
||||
oa: Some(oa.clone()),
|
||||
sa: None,
|
||||
});
|
||||
}
|
||||
if let Some(sa) = &keys.sa {
|
||||
result.push(KeyPair {
|
||||
oa: None,
|
||||
sa: Some(sa.clone()),
|
||||
});
|
||||
}
|
||||
result
|
||||
}
|
||||
match selector {
|
||||
FacetSelector::Member { collection, keys } => split(keys)
|
||||
.into_iter()
|
||||
.map(|keys| FacetSelector::Member {
|
||||
collection: *collection,
|
||||
keys,
|
||||
})
|
||||
.collect(),
|
||||
FacetSelector::Literal(keys) => split(keys)
|
||||
.into_iter()
|
||||
.map(FacetSelector::Literal)
|
||||
.collect(),
|
||||
FacetSelector::Annotation(keys) => split(keys)
|
||||
.into_iter()
|
||||
.map(FacetSelector::Annotation)
|
||||
.collect(),
|
||||
FacetSelector::Binding(keys) => split(keys)
|
||||
.into_iter()
|
||||
.map(FacetSelector::Binding)
|
||||
.collect(),
|
||||
FacetSelector::Child(keys) => split(keys).into_iter().map(FacetSelector::Child).collect(),
|
||||
FacetSelector::Incident { keys, end } => split(keys)
|
||||
.into_iter()
|
||||
.map(|keys| FacetSelector::Incident { keys, end: *end })
|
||||
.collect(),
|
||||
FacetSelector::Artifact { keys, facet } => split(keys)
|
||||
.into_iter()
|
||||
.map(|keys| FacetSelector::Artifact {
|
||||
keys,
|
||||
facet: *facet,
|
||||
})
|
||||
.collect(),
|
||||
value => vec![value.clone()],
|
||||
}
|
||||
}
|
||||
fn pair_overlap<T: Eq>(a: &KeyPair<T>, b: &KeyPair<T>) -> bool {
|
||||
a.oa.as_ref()
|
||||
.zip(b.oa.as_ref())
|
||||
.is_some_and(|(a, b)| a == b)
|
||||
|| a.sa
|
||||
.as_ref()
|
||||
.zip(b.sa.as_ref())
|
||||
.is_some_and(|(a, b)| a == b)
|
||||
}
|
||||
/// Slot identity is each actual side's key, not equality of the complete pair.
|
||||
pub(crate) fn keyed_slots_overlap(a: &FacetSelector, b: &FacetSelector) -> bool {
|
||||
match (a, b) {
|
||||
(
|
||||
FacetSelector::Member {
|
||||
collection: a,
|
||||
keys: ak,
|
||||
},
|
||||
FacetSelector::Member {
|
||||
collection: b,
|
||||
keys: bk,
|
||||
},
|
||||
) => a == b && pair_overlap(ak, bk),
|
||||
(FacetSelector::Literal(a), FacetSelector::Literal(b)) => pair_overlap(a, b),
|
||||
(FacetSelector::Annotation(a), FacetSelector::Annotation(b)) => pair_overlap(a, b),
|
||||
(FacetSelector::Binding(a), FacetSelector::Binding(b)) => pair_overlap(a, b),
|
||||
(FacetSelector::Child(a), FacetSelector::Child(b)) => pair_overlap(a, b),
|
||||
(
|
||||
FacetSelector::Incident { keys: ak, end: a },
|
||||
FacetSelector::Incident { keys: bk, end: b },
|
||||
) => a == b && pair_overlap(ak, bk),
|
||||
(
|
||||
FacetSelector::Artifact { keys: ak, facet: a },
|
||||
FacetSelector::Artifact { keys: bk, facet: b },
|
||||
) => a == b && pair_overlap(ak, bk),
|
||||
_ => a == b,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
use super::*;
|
||||
use crate::model::*;
|
||||
use crate::transitions::Drift;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::{BTreeMap, BTreeSet, VecDeque};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct LinkedSemanticImpact {
|
||||
pub objects: KeyPair<Id>,
|
||||
pub traces: Vec<Id>,
|
||||
pub drift: Drift,
|
||||
pub unreviewed: bool,
|
||||
}
|
||||
const MAX_LINKED_OBJECTS: usize = 4_096;
|
||||
const MAX_LINKED_EDGES: usize = 32_768;
|
||||
|
||||
/// Derived dependency diagnostics, never another historical baseline. Follow
|
||||
/// direct references/children, then only reusable dictionary/event definitions.
|
||||
/// Association endpoints and ordinary architecture neighborhoods do not expand.
|
||||
pub fn linked_semantic_impacts(
|
||||
project: &Project,
|
||||
trace: &Trace,
|
||||
references: &BTreeMap<Id, Id>,
|
||||
) -> Result<Vec<LinkedSemanticImpact>, String> {
|
||||
let mut children: BTreeMap<Id, Vec<Id>> = BTreeMap::new();
|
||||
for element in project.elements.values() {
|
||||
if let Some(parent) = element.parent {
|
||||
children.entry(parent).or_default().push(element.id);
|
||||
}
|
||||
}
|
||||
let mut contracts: BTreeMap<Id, Vec<Id>> = BTreeMap::new();
|
||||
for relation in project.relations.values() {
|
||||
if let RelationBody::InterfaceItemAllocation { source, .. } = relation.body {
|
||||
contracts.entry(source).or_default().push(relation.id);
|
||||
}
|
||||
}
|
||||
let direct = |id| -> Vec<Id> {
|
||||
let mut out = vec![];
|
||||
if let Some(element) = project.elements.get(&id) {
|
||||
out.extend(
|
||||
crate::references::element_references(element)
|
||||
.into_iter()
|
||||
.filter(|r| Some(*r) != element.parent),
|
||||
);
|
||||
}
|
||||
if let Some(relation) = project.relations.get(&id) {
|
||||
out.extend(crate::references::relation_references(relation));
|
||||
}
|
||||
out.extend(children.get(&id).into_iter().flatten().copied());
|
||||
out.extend(contracts.get(&id).into_iter().flatten().copied());
|
||||
out
|
||||
};
|
||||
let expandable = |id| {
|
||||
project.elements.get(&id).is_some_and(|e| {
|
||||
matches!(
|
||||
e.kind(),
|
||||
Kind::DataType
|
||||
| Kind::DataField
|
||||
| Kind::ExchangeItem
|
||||
| Kind::Interface
|
||||
| Kind::Event
|
||||
)
|
||||
}) || project
|
||||
.relations
|
||||
.get(&id)
|
||||
.is_some_and(|r| matches!(r.body, RelationBody::InterfaceItemAllocation { .. }))
|
||||
};
|
||||
let mut edges = 0usize;
|
||||
let mut traverse = |root| -> Result<BTreeSet<Id>, String> {
|
||||
let mut seen = BTreeSet::from([root]);
|
||||
let first = direct(root);
|
||||
edges += first.len();
|
||||
if edges > MAX_LINKED_EDGES {
|
||||
return Err(
|
||||
"Linked semantic dependency work limit exceeded; narrow the review scope".into(),
|
||||
);
|
||||
}
|
||||
let mut queue: VecDeque<_> = first.into();
|
||||
while let Some(id) = queue.pop_front() {
|
||||
if !seen.insert(id) {
|
||||
continue;
|
||||
}
|
||||
if seen.len() > MAX_LINKED_OBJECTS || edges > MAX_LINKED_EDGES {
|
||||
return Err(
|
||||
"Linked semantic dependency work limit exceeded; narrow the review scope"
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
if expandable(id) {
|
||||
let next = direct(id);
|
||||
edges += next.len();
|
||||
if edges > MAX_LINKED_EDGES {
|
||||
return Err(
|
||||
"Linked semantic dependency work limit exceeded; narrow the review scope"
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
queue.extend(next);
|
||||
}
|
||||
}
|
||||
seen.remove(&root);
|
||||
Ok(seen)
|
||||
};
|
||||
let oa = traverse(trace.oa)?;
|
||||
let sa = traverse(trace.sa)?;
|
||||
let mut traced: BTreeMap<(Id, Id), Vec<&Trace>> = BTreeMap::new();
|
||||
for t in project.traces.values() {
|
||||
traced.entry((t.oa, t.sa)).or_default().push(t);
|
||||
}
|
||||
let mut pairs = vec![];
|
||||
let mut used = BTreeSet::new();
|
||||
for id in oa {
|
||||
let target = references.get(&id).copied();
|
||||
if let Some(id) = target {
|
||||
used.insert(id);
|
||||
}
|
||||
pairs.push(KeyPair {
|
||||
oa: Some(id),
|
||||
sa: target,
|
||||
});
|
||||
}
|
||||
for id in sa.difference(&used) {
|
||||
pairs.push(KeyPair {
|
||||
oa: None,
|
||||
sa: Some(*id),
|
||||
});
|
||||
}
|
||||
let mut result = vec![];
|
||||
for objects in pairs {
|
||||
if objects.oa == Some(trace.oa) && objects.sa == Some(trace.sa) {
|
||||
continue;
|
||||
}
|
||||
let links = objects
|
||||
.oa
|
||||
.zip(objects.sa)
|
||||
.and_then(|pair| traced.get(&pair));
|
||||
let mut flags = (false, false, links.is_none());
|
||||
let mut ids = vec![];
|
||||
for linked in links.into_iter().flatten() {
|
||||
ids.push(linked.id);
|
||||
let facts = super::catalog::own_semantic_facets(project, linked, references)?;
|
||||
if facts.is_empty() {
|
||||
flags.2 = true;
|
||||
}
|
||||
for fact in facts {
|
||||
match fact.drift {
|
||||
Drift::OaChanged => flags.0 = true,
|
||||
Drift::SaChanged => flags.1 = true,
|
||||
Drift::BothChanged => {
|
||||
flags.0 = true;
|
||||
flags.1 = true;
|
||||
}
|
||||
Drift::Unreviewed => flags.2 = true,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
let drift = match flags {
|
||||
(true, true, _) => Drift::BothChanged,
|
||||
(true, false, _) => Drift::OaChanged,
|
||||
(false, true, _) => Drift::SaChanged,
|
||||
(false, false, true) => Drift::Unreviewed,
|
||||
_ => Drift::Unchanged,
|
||||
};
|
||||
result.push(LinkedSemanticImpact {
|
||||
objects,
|
||||
traces: ids,
|
||||
drift,
|
||||
unreviewed: flags.2,
|
||||
});
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
@@ -0,0 +1,616 @@
|
||||
use super::catalog::{collection, order_collection, sides, write_scalar};
|
||||
use super::*;
|
||||
use crate::model::*;
|
||||
use crate::transitions::TransitionDirection;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
pub(crate) fn set_collection(
|
||||
p: &mut Project,
|
||||
id: Id,
|
||||
which: ReferenceCollection,
|
||||
list: Vec<Id>,
|
||||
) -> Result<(), String> {
|
||||
if list.iter().collect::<BTreeSet<_>>().len() != list.len() {
|
||||
return Err("Collection contains duplicate members".into());
|
||||
}
|
||||
use ReferenceCollection as C;
|
||||
if which == C::Steps {
|
||||
let e = p.elements.get_mut(&id).ok_or("Missing sequence")?;
|
||||
if let Some(steps) = e.steps_mut() {
|
||||
*steps = list;
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Branch { occurrences, .. },
|
||||
..
|
||||
}) = e.shared_mut()
|
||||
{
|
||||
*occurrences = list;
|
||||
return Ok(());
|
||||
}
|
||||
return Err("Target has no ordered steps".into());
|
||||
}
|
||||
let slot = match which {
|
||||
C::Steps => unreachable!(),
|
||||
C::FragmentBranches => match p.elements.get_mut(&id).and_then(Element::shared_mut) {
|
||||
Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Fragment { branches, .. },
|
||||
..
|
||||
}) => Some(branches),
|
||||
_ => None,
|
||||
},
|
||||
C::FunctionInputItems => match p.elements.get_mut(&id).and_then(Element::shared_mut) {
|
||||
Some(SharedBody::Port {
|
||||
contract: PortContract::FunctionInput { incoming_items },
|
||||
..
|
||||
}) => Some(incoming_items),
|
||||
_ => None,
|
||||
},
|
||||
C::FunctionOutputItems => match p.elements.get_mut(&id).and_then(Element::shared_mut) {
|
||||
Some(SharedBody::Port {
|
||||
contract: PortContract::FunctionOutput { outgoing_items },
|
||||
..
|
||||
}) => Some(outgoing_items),
|
||||
_ => None,
|
||||
},
|
||||
C::SignalItems => match p.elements.get_mut(&id).and_then(Element::shared_mut) {
|
||||
Some(SharedBody::Event {
|
||||
body: EventBody::Signal { items },
|
||||
}) => Some(items),
|
||||
_ => None,
|
||||
},
|
||||
C::ExchangeItems => match p.relations.get_mut(&id).map(|r| &mut r.body) {
|
||||
Some(
|
||||
RelationBody::Interaction { items, .. }
|
||||
| RelationBody::FunctionalExchange { items, .. }
|
||||
| RelationBody::BoundaryExchange { items, .. }
|
||||
| RelationBody::CommunicationMean { items, .. },
|
||||
) => Some(items),
|
||||
_ => None,
|
||||
},
|
||||
C::TransitionTriggers => match p.relations.get_mut(&id).map(|r| &mut r.body) {
|
||||
Some(RelationBody::StateTransition { triggers, .. }) => Some(triggers),
|
||||
_ => None,
|
||||
},
|
||||
C::TransitionEffects => match p.relations.get_mut(&id).map(|r| &mut r.body) {
|
||||
Some(RelationBody::StateTransition { effects, .. }) => Some(effects),
|
||||
_ => None,
|
||||
},
|
||||
C::Constrained => match p.elements.get_mut(&id).and_then(Element::shared_mut) {
|
||||
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;
|
||||
Ok(())
|
||||
}
|
||||
pub(crate) fn chosen<T>(pair: &KeyPair<T>, layer: Layer) -> Option<&T> {
|
||||
match layer {
|
||||
Layer::Oa => pair.oa.as_ref(),
|
||||
Layer::Sa => pair.sa.as_ref(),
|
||||
}
|
||||
}
|
||||
pub(crate) fn value<T>(review: &Reviewed<T>, layer: Layer) -> Result<&T, String> {
|
||||
match match layer {
|
||||
Layer::Oa => &review.oa,
|
||||
Layer::Sa => &review.sa,
|
||||
} {
|
||||
Observed::Value(v) => Ok(v),
|
||||
Observed::NotApplicable => {
|
||||
Err("Facet does not exist in the selected constructor branch".into())
|
||||
}
|
||||
}
|
||||
}
|
||||
pub(crate) fn directional_map(
|
||||
map: &BTreeMap<Id, Id>,
|
||||
direction: TransitionDirection,
|
||||
) -> Result<BTreeMap<Id, Id>, String> {
|
||||
if direction == TransitionDirection::OaToSa {
|
||||
return Ok(map.clone());
|
||||
}
|
||||
let mut inverse = BTreeMap::new();
|
||||
for (oa, sa) in map {
|
||||
if inverse.insert(*sa, *oa).is_some_and(|old| old != *oa) {
|
||||
return Err(format!(
|
||||
"Reverse reference {sa} has multiple OA correspondences; supply an unambiguous mapping"
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(inverse)
|
||||
}
|
||||
pub(crate) fn remap_record(
|
||||
record: &mut SemanticRecord,
|
||||
map: &BTreeMap<Id, Id>,
|
||||
) -> Result<(), String> {
|
||||
let mut missing = BTreeSet::new();
|
||||
let mut remap = |id: &mut Id| {
|
||||
if let Some(new) = map.get(id) {
|
||||
*id = *new;
|
||||
} else {
|
||||
missing.insert(*id);
|
||||
}
|
||||
};
|
||||
match record {
|
||||
SemanticRecord::Element(e) => crate::references::visit_element_refs(e, &mut remap),
|
||||
SemanticRecord::Relation(r) => crate::references::visit_relation_refs(r, &mut remap),
|
||||
SemanticRecord::Artifact(a) => remap(&mut a.owner),
|
||||
}
|
||||
if missing.is_empty() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!(
|
||||
"Missing explicit reference correspondence: {missing:?}"
|
||||
))
|
||||
}
|
||||
}
|
||||
pub(crate) fn remap_fact(
|
||||
fact: &FacetReview,
|
||||
map: &BTreeMap<Id, Id>,
|
||||
source: Layer,
|
||||
) -> Result<FacetReview, String> {
|
||||
// Remap only the selected observation. Keys remain in canonical OA/SA space.
|
||||
let mut json = serde_json::to_value(fact).map_err(|e| e.to_string())?;
|
||||
let base = SemanticBaseline {
|
||||
facets: vec![fact.clone()],
|
||||
};
|
||||
let side = match source {
|
||||
Layer::Oa => "oa",
|
||||
Layer::Sa => "sa",
|
||||
};
|
||||
let mut missing = BTreeSet::new();
|
||||
for (path, kind) in historical_source_slots(&base) {
|
||||
if kind != HistoricalSourceKind::Reference {
|
||||
continue;
|
||||
}
|
||||
let rest = &path[2..];
|
||||
if !rest.windows(2).any(|w| w[0] == side && w[1] == "Value") {
|
||||
continue;
|
||||
}
|
||||
let mut leaf = &mut json;
|
||||
for part in rest {
|
||||
leaf = if leaf.is_array() {
|
||||
leaf.get_mut(
|
||||
part.parse::<usize>()
|
||||
.map_err(|_| "Invalid historical array index")?,
|
||||
)
|
||||
} else {
|
||||
leaf.get_mut(part)
|
||||
}
|
||||
.ok_or("Invalid historical source slot")?;
|
||||
}
|
||||
let id: Id = serde_json::from_value(leaf.clone()).map_err(|e| e.to_string())?;
|
||||
if let Some(new) = map.get(&id) {
|
||||
*leaf = serde_json::to_value(new).unwrap();
|
||||
} else {
|
||||
missing.insert(id);
|
||||
}
|
||||
}
|
||||
if !missing.is_empty() {
|
||||
return Err(format!(
|
||||
"Missing explicit reference correspondence: {missing:?}"
|
||||
));
|
||||
}
|
||||
serde_json::from_value(json).map_err(|e| e.to_string())
|
||||
}
|
||||
pub(crate) fn check_keys(selector: &FacetSelector, map: &BTreeMap<Id, Id>) -> Result<(), String> {
|
||||
fn pair(keys: &KeyPair<Id>, map: &BTreeMap<Id, Id>) -> Result<(), String> {
|
||||
if let (Some(oa), Some(sa)) = (keys.oa, keys.sa)
|
||||
&& map.get(&oa) != Some(&sa)
|
||||
{
|
||||
return Err("Key pair disagrees with frozen reference correspondence".into());
|
||||
}
|
||||
if keys.oa.is_none() && keys.sa.is_none() {
|
||||
return Err("Empty key pair".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
match selector {
|
||||
FacetSelector::Member { keys, .. }
|
||||
| FacetSelector::Binding(keys)
|
||||
| FacetSelector::Child(keys)
|
||||
| FacetSelector::Incident { keys, .. }
|
||||
| FacetSelector::Artifact { keys, .. } => pair(keys, map),
|
||||
FacetSelector::Annotation(keys) => {
|
||||
if let (Some(oa), Some(sa)) = (&keys.oa, &keys.sa) {
|
||||
if oa.path.len() != sa.path.len() {
|
||||
return Err("Annotation path pairing disagrees".into());
|
||||
}
|
||||
for (oa, sa) in oa
|
||||
.path
|
||||
.iter()
|
||||
.chain([&oa.field])
|
||||
.zip(sa.path.iter().chain([&sa.field]))
|
||||
{
|
||||
pair(
|
||||
&KeyPair {
|
||||
oa: Some(*oa),
|
||||
sa: Some(*sa),
|
||||
},
|
||||
map,
|
||||
)?;
|
||||
}
|
||||
}
|
||||
if keys.oa.is_none() && keys.sa.is_none() {
|
||||
return Err("Empty annotation pair".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
FacetSelector::Literal(keys) => {
|
||||
if keys
|
||||
.oa
|
||||
.as_ref()
|
||||
.zip(keys.sa.as_ref())
|
||||
.is_some_and(|(oa, sa)| oa != sa)
|
||||
{
|
||||
return Err(
|
||||
"Enum literal identity is exact text; renaming is removal/addition".into(),
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply just one selected fact to an in-memory expected project. The caller
|
||||
/// subsequently prepares ordinary Commands and checks every induced difference.
|
||||
pub(crate) fn write_fact(
|
||||
p: &mut Project,
|
||||
target: Id,
|
||||
fact: &FacetReview,
|
||||
from: Layer,
|
||||
target_layer: Layer,
|
||||
preserve_target_only: bool,
|
||||
) -> Result<(), String> {
|
||||
match fact {
|
||||
FacetReview::Member {
|
||||
collection: which,
|
||||
keys,
|
||||
review,
|
||||
} => {
|
||||
if preserve_target_only && chosen(keys, from).is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
let present = *value(review, from)?;
|
||||
let key = chosen(keys, target_layer)
|
||||
.copied()
|
||||
.ok_or("Map the target collection member explicitly")?;
|
||||
let mut list = collection(p, target, *which).ok_or("Inapplicable target collection")?;
|
||||
if present {
|
||||
if !list.contains(&key) {
|
||||
list.push(key);
|
||||
}
|
||||
} else {
|
||||
list.retain(|id| *id != key);
|
||||
}
|
||||
set_collection(p, target, *which, list)
|
||||
}
|
||||
FacetReview::Order {
|
||||
collection: which,
|
||||
review,
|
||||
} => {
|
||||
let supplied = value(review, from)?.clone();
|
||||
let current = collection(p, target, order_collection(*which))
|
||||
.ok_or("Inapplicable target order")?;
|
||||
let supplied_set: BTreeSet<_> = supplied.iter().copied().collect();
|
||||
let current_set: BTreeSet<_> = current.iter().copied().collect();
|
||||
if supplied_set != current_set {
|
||||
if preserve_target_only
|
||||
&& supplied_set.is_subset(¤t_set)
|
||||
&& current
|
||||
.iter()
|
||||
.filter(|id| supplied_set.contains(id))
|
||||
.copied()
|
||||
.collect::<Vec<_>>()
|
||||
== supplied
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
return Err("Order must contain every surviving target member exactly once; target-only insertion requires an explicit final order".into());
|
||||
}
|
||||
set_collection(p, target, order_collection(*which), supplied)
|
||||
}
|
||||
FacetReview::Literal { keys, review } => {
|
||||
if preserve_target_only && chosen(keys, from).is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
let present = *value(review, from)?;
|
||||
let key = chosen(keys, target_layer).ok_or("Missing literal key on target")?;
|
||||
let Some(SharedBody::DataType {
|
||||
definition: DataTypeDefinition::Enumeration { literals },
|
||||
..
|
||||
}) = p.elements.get_mut(&target).and_then(Element::shared_mut)
|
||||
else {
|
||||
return Err("Target is not an enumeration".into());
|
||||
};
|
||||
if present {
|
||||
if !literals.contains(key) {
|
||||
literals.push(key.clone());
|
||||
}
|
||||
} else {
|
||||
literals.retain(|v| v != key);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
FacetReview::Annotation { keys, review } => {
|
||||
if preserve_target_only && chosen(keys, from).is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
let key = chosen(keys, target_layer).ok_or("Missing target annotation key")?;
|
||||
let row = value(review, from)?.clone();
|
||||
if row.as_ref().is_some_and(|v| AnnotationKey::from(v) != *key) {
|
||||
return Err(
|
||||
"Annotation candidate changes its row key; review removal/addition".into(),
|
||||
);
|
||||
}
|
||||
let Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange { values, .. },
|
||||
..
|
||||
}) = p.elements.get_mut(&target).and_then(Element::shared_mut)
|
||||
else {
|
||||
return Err("Target has no message annotations".into());
|
||||
};
|
||||
replace_keyed_row(values, row, |v| AnnotationKey::from(v) == *key);
|
||||
Ok(())
|
||||
}
|
||||
FacetReview::Binding { keys, review } => {
|
||||
if preserve_target_only && chosen(keys, from).is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
let key = *chosen(keys, target_layer).ok_or("Missing target binding key")?;
|
||||
let row = value(review, from)?.clone();
|
||||
if row.as_ref().is_some_and(|v| v.referenced != key) {
|
||||
return Err(
|
||||
"Binding candidate changes its row key; review removal/addition".into(),
|
||||
);
|
||||
}
|
||||
let Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::ScenarioUse { bindings, .. },
|
||||
..
|
||||
}) = p.elements.get_mut(&target).and_then(Element::shared_mut)
|
||||
else {
|
||||
return Err("Target has no scenario bindings".into());
|
||||
};
|
||||
replace_keyed_row(bindings, row, |v| v.referenced == key);
|
||||
Ok(())
|
||||
}
|
||||
FacetReview::Child { keys, review } => {
|
||||
if preserve_target_only && chosen(keys, from).is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
let key = chosen(keys, target_layer).ok_or("Map target child explicitly")?;
|
||||
let e = p
|
||||
.elements
|
||||
.get_mut(key)
|
||||
.ok_or("Target child does not exist; create it through transition mapping")?;
|
||||
if *value(review, from)? {
|
||||
e.parent = Some(target);
|
||||
} else if e.parent == Some(target) {
|
||||
e.parent = None;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
FacetReview::Incident { keys, end, review } => {
|
||||
if preserve_target_only && chosen(keys, from).is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
let key = chosen(keys, target_layer).ok_or("Map incident relation explicitly")?;
|
||||
if !*value(review, from)? {
|
||||
return Err("Unlinking an incident relation requires an explicit final endpoint through the relation trace".into());
|
||||
}
|
||||
let relation = p
|
||||
.relations
|
||||
.get_mut(key)
|
||||
.ok_or("Mapped incident relation is absent")?;
|
||||
let (a, b) = relation.body.endpoints();
|
||||
match end {
|
||||
IncidentEnd::Source => relation.body.set_endpoints(target, b),
|
||||
IncidentEnd::Target => relation.body.set_endpoints(a, target),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
FacetReview::Artifact { keys, review } => {
|
||||
if preserve_target_only && chosen(keys, from).is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
let key = chosen(keys, target_layer).ok_or("Map artifact explicitly")?;
|
||||
if let ArtifactReview::Presence(review) = review {
|
||||
if !*value(review, from)? {
|
||||
p.artifact_links.remove(key);
|
||||
return Ok(());
|
||||
}
|
||||
if p.artifact_links.get(key).is_some_and(|a| a.owner == target) {
|
||||
return Ok(());
|
||||
}
|
||||
return Err("Artifact creation requires a complete typed candidate".into());
|
||||
}
|
||||
let a = p
|
||||
.artifact_links
|
||||
.get_mut(key)
|
||||
.ok_or("Artifact candidate is absent")?;
|
||||
if a.owner != target {
|
||||
return Err(
|
||||
"Artifact metadata can only be reviewed on its currently attached owner trace"
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
match review {
|
||||
ArtifactReview::Presence(_) => unreachable!(),
|
||||
ArtifactReview::Name(r) => a.name = value(r, from)?.clone(),
|
||||
ArtifactReview::Description(r) => a.description = value(r, from)?.clone(),
|
||||
ArtifactReview::Owner(r) => a.owner = *value(r, from)?,
|
||||
ArtifactReview::Relation(r) => a.relation = *value(r, from)?,
|
||||
ArtifactReview::Uri(r) => a.uri = value(r, from)?.clone(),
|
||||
ArtifactReview::Revision(r) => a.revision = value(r, from)?.clone(),
|
||||
ArtifactReview::Fragment(r) => a.fragment = value(r, from)?.clone(),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
_ => {
|
||||
let (oa, sa) = sides(fact);
|
||||
let side = match from {
|
||||
Layer::Oa => oa,
|
||||
Layer::Sa => sa,
|
||||
};
|
||||
let value = side
|
||||
.get("Value")
|
||||
.ok_or("Selected branch is not applicable")?
|
||||
.clone();
|
||||
write_scalar(p, target, &fact.selector(), value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
F::Data(DataFacet::Constructor) => {
|
||||
matches!(facet,F::Data(f) if *f != DataFacet::Unit) || matches!(facet, F::Literal(_))
|
||||
}
|
||||
F::Port(PortFacet::Constructor) => matches!(
|
||||
facet,
|
||||
F::Port(_)
|
||||
| F::Member {
|
||||
collection: ReferenceCollection::FunctionInputItems
|
||||
| ReferenceCollection::FunctionOutputItems,
|
||||
..
|
||||
}
|
||||
),
|
||||
F::Occurrence(OccurrenceFacet::Constructor) => {
|
||||
matches!(facet,F::Occurrence(f) if !matches!(f,OccurrenceFacet::Precondition | OccurrenceFacet::Postcondition))
|
||||
|| matches!(
|
||||
facet,
|
||||
F::Annotation(_)
|
||||
| F::Binding(_)
|
||||
| F::Member {
|
||||
collection: ReferenceCollection::Steps
|
||||
| ReferenceCollection::FragmentBranches,
|
||||
..
|
||||
}
|
||||
| F::Order(_)
|
||||
)
|
||||
}
|
||||
F::Occurrence(OccurrenceFacet::MessageKind) => matches!(
|
||||
facet,
|
||||
F::Occurrence(
|
||||
OccurrenceFacet::MessageKind
|
||||
| OccurrenceFacet::TimerExpression
|
||||
| OccurrenceFacet::Operation
|
||||
)
|
||||
),
|
||||
F::Event(EventFacet::Constructor) => matches!(
|
||||
facet,
|
||||
F::Event(_)
|
||||
| F::Member {
|
||||
collection: ReferenceCollection::SignalItems,
|
||||
..
|
||||
}
|
||||
),
|
||||
F::State(StateFacet::Kind) => facet == group,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
pub(crate) fn copy_constructor(
|
||||
p: &mut Project,
|
||||
target: Id,
|
||||
source: &SemanticRecord,
|
||||
group: &FacetSelector,
|
||||
) -> Result<(), String> {
|
||||
let SemanticRecord::Element(source) = source else {
|
||||
return Err("Constructor source must be an element".into());
|
||||
};
|
||||
let target = p
|
||||
.elements
|
||||
.get_mut(&target)
|
||||
.ok_or("Missing target element")?;
|
||||
let a = source.shared().ok_or("Source has no shared constructor")?;
|
||||
let b = target
|
||||
.shared_mut()
|
||||
.ok_or("Target has no shared constructor")?;
|
||||
match (group, a, b) {
|
||||
(
|
||||
FacetSelector::Data(DataFacet::Constructor),
|
||||
SharedBody::DataType {
|
||||
definition,
|
||||
default,
|
||||
..
|
||||
},
|
||||
SharedBody::DataType {
|
||||
definition: d,
|
||||
default: v,
|
||||
..
|
||||
},
|
||||
) => {
|
||||
*d = definition.clone();
|
||||
*v = default.clone();
|
||||
}
|
||||
(
|
||||
FacetSelector::Port(PortFacet::Constructor),
|
||||
SharedBody::Port { contract, legacy },
|
||||
SharedBody::Port {
|
||||
contract: c,
|
||||
legacy: l,
|
||||
},
|
||||
) => {
|
||||
*c = contract.clone();
|
||||
*l = legacy.clone();
|
||||
}
|
||||
(
|
||||
FacetSelector::Occurrence(OccurrenceFacet::Constructor),
|
||||
SharedBody::Occurrence { body, .. },
|
||||
SharedBody::Occurrence { body: b, .. },
|
||||
) => *b = body.clone(),
|
||||
(
|
||||
FacetSelector::Occurrence(OccurrenceFacet::MessageKind),
|
||||
SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange { message_kind, .. },
|
||||
..
|
||||
},
|
||||
SharedBody::Occurrence {
|
||||
body:
|
||||
OccurrenceBody::Exchange {
|
||||
message_kind: k, ..
|
||||
},
|
||||
..
|
||||
},
|
||||
) => *k = message_kind.clone(),
|
||||
(
|
||||
FacetSelector::Event(EventFacet::Constructor),
|
||||
SharedBody::Event { body },
|
||||
SharedBody::Event { body: b },
|
||||
) => *b = body.clone(),
|
||||
(
|
||||
FacetSelector::State(StateFacet::Kind),
|
||||
SharedBody::State { kind, .. },
|
||||
SharedBody::State { kind: k, .. },
|
||||
) => *k = *kind,
|
||||
_ => return Err("Constructor family mismatch".into()),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,260 @@
|
||||
use crate::model::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub enum Observed<T> {
|
||||
NotApplicable,
|
||||
Value(T),
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Reviewed<T> {
|
||||
pub oa: Observed<T>,
|
||||
pub sa: Observed<T>,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct KeyPair<K> {
|
||||
pub oa: Option<K>,
|
||||
pub sa: Option<K>,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct AnnotationKey {
|
||||
pub path: Vec<Id>,
|
||||
pub field: Id,
|
||||
}
|
||||
impl From<&MessageValue> for AnnotationKey {
|
||||
fn from(value: &MessageValue) -> Self {
|
||||
Self {
|
||||
path: value.path.clone(),
|
||||
field: value.field,
|
||||
}
|
||||
}
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum DataConstructor {
|
||||
Boolean,
|
||||
Integer,
|
||||
Real,
|
||||
String,
|
||||
Enumeration,
|
||||
Record,
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum PortConstructor {
|
||||
FunctionInput,
|
||||
FunctionOutput,
|
||||
Component,
|
||||
Legacy,
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum OccurrenceConstructor {
|
||||
Exchange,
|
||||
Action,
|
||||
Control,
|
||||
SequenceReference,
|
||||
ScenarioUse,
|
||||
State,
|
||||
Fragment,
|
||||
Branch,
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum EventConstructor {
|
||||
Signal,
|
||||
Time,
|
||||
Change,
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum MessageConstructor {
|
||||
Asynchronous,
|
||||
Call,
|
||||
Reply,
|
||||
Create,
|
||||
Delete,
|
||||
Timer,
|
||||
}
|
||||
|
||||
// This finite catalog is also consumed by the typed historical leaf visitor.
|
||||
// Each selector fixes its payload type; there is no arbitrary value/path schema.
|
||||
macro_rules! scalar_families {
|
||||
($m:ident) => { $m! {
|
||||
Header, HeaderFacet, HeaderReview {
|
||||
Name: String, Description: String, Parent: Option<Id>
|
||||
}
|
||||
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,
|
||||
IntegerMinimum: Option<i64>, IntegerMaximum: Option<i64>,
|
||||
RealMinimum: Option<f64>, RealMaximum: Option<f64>,
|
||||
StringMinimumLength: u32, StringMaximumLength: Option<u32>,
|
||||
RecordSupertype: Option<Id>, Default: Option<DataValue>, Unit: String
|
||||
}
|
||||
Field, FieldFacet, FieldReview {
|
||||
Type: TypeRef, Minimum: u32, Maximum: Option<u32>, Unit: String,
|
||||
Direction: ParameterDirection, Storage: FieldStorage, Default: Option<Vec<DataValue>>
|
||||
}
|
||||
Port, PortFacet, PortReview {
|
||||
Constructor: PortConstructor, ComponentKind: ComponentPortKind,
|
||||
Orientation: ComponentPortOrientation, LegacyDirection: PortDirection,
|
||||
LegacyInterface: Option<Id>
|
||||
}
|
||||
Scenario, ScenarioFacet, ScenarioReview {
|
||||
Viewpoint: ScenarioViewpoint, Context: Option<Id>, Precondition: String, Postcondition: String
|
||||
}
|
||||
Lifeline, LifelineFacet, LifelineReview { Represented: Option<Id> }
|
||||
Occurrence, OccurrenceFacet, OccurrenceReview {
|
||||
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
|
||||
}
|
||||
Event, EventFacet, EventReview {
|
||||
Constructor: EventConstructor, TimeKind: TimeEventKind, TimeExpression: String, ChangeExpression: String
|
||||
}
|
||||
State, StateFacet, StateReview { Kind: StateKind, Entry: String, Exit: String, DoActivity: String }
|
||||
Constraint, ConstraintFacet, ConstraintReview { Expression: String }
|
||||
Relation, RelationFacet, RelationReview {
|
||||
Source: Id, Target: Id, AllocationOrder: u32, Transmission: TransmissionProtocol,
|
||||
Acquisition: AcquisitionProtocol, SourceRole: String, TargetRole: String,
|
||||
SourceMinimum: u32, SourceMaximum: Option<u32>, TargetMinimum: u32,
|
||||
TargetMaximum: Option<u32>, SourceNavigable: bool, TargetNavigable: bool,
|
||||
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
|
||||
}
|
||||
}};
|
||||
}
|
||||
pub(super) use scalar_families;
|
||||
|
||||
macro_rules! define_families {
|
||||
($( $family:ident, $selector:ident, $review:ident { $( $leaf:ident: $ty:ty ),* } )*) => {
|
||||
$(
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
pub enum $selector { $( $leaf ),* }
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub enum $review { $( $leaf(Reviewed<$ty>) ),* }
|
||||
)*
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub enum FacetSelector {
|
||||
$( $family($selector), )*
|
||||
Member { collection: ReferenceCollection, keys: KeyPair<Id> },
|
||||
Order(OrderedCollection),
|
||||
Literal(KeyPair<String>),
|
||||
Annotation(KeyPair<AnnotationKey>),
|
||||
Binding(KeyPair<Id>),
|
||||
Child(KeyPair<Id>),
|
||||
Incident { keys: KeyPair<Id>, end: IncidentEnd },
|
||||
Artifact { keys: KeyPair<Id>, facet: ArtifactFacet },
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub enum FacetReview {
|
||||
$( $family($review), )*
|
||||
Member { collection: ReferenceCollection, keys: KeyPair<Id>, review: Reviewed<bool> },
|
||||
Order { collection: OrderedCollection, review: Reviewed<Vec<Id>> },
|
||||
Literal { keys: KeyPair<String>, review: Reviewed<bool> },
|
||||
Annotation { keys: KeyPair<AnnotationKey>, review: Reviewed<Option<MessageValue>> },
|
||||
Binding { keys: KeyPair<Id>, review: Reviewed<Option<LifelineBinding>> },
|
||||
Child { keys: KeyPair<Id>, review: Reviewed<bool> },
|
||||
Incident { keys: KeyPair<Id>, end: IncidentEnd, review: Reviewed<bool> },
|
||||
Artifact { keys: KeyPair<Id>, review: ArtifactReview },
|
||||
}
|
||||
impl FacetReview {
|
||||
pub fn selector(&self) -> FacetSelector {
|
||||
match self {
|
||||
$( Self::$family(value) => FacetSelector::$family(match value {
|
||||
$( $review::$leaf(_) => $selector::$leaf, )*
|
||||
}), )*
|
||||
Self::Member { collection, keys, .. } => FacetSelector::Member { collection: *collection, keys: keys.clone() },
|
||||
Self::Order { collection, .. } => FacetSelector::Order(*collection),
|
||||
Self::Literal { keys, .. } => FacetSelector::Literal(keys.clone()),
|
||||
Self::Annotation { keys, .. } => FacetSelector::Annotation(keys.clone()),
|
||||
Self::Binding { keys, .. } => FacetSelector::Binding(keys.clone()),
|
||||
Self::Child { keys, .. } => FacetSelector::Child(keys.clone()),
|
||||
Self::Incident { keys, end, .. } => FacetSelector::Incident { keys: keys.clone(), end: *end },
|
||||
Self::Artifact { keys, review } => FacetSelector::Artifact { keys: keys.clone(), facet: review.facet() },
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
scalar_families!(define_families);
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
pub enum ReferenceCollection {
|
||||
Steps,
|
||||
FragmentBranches,
|
||||
FunctionInputItems,
|
||||
FunctionOutputItems,
|
||||
SignalItems,
|
||||
ExchangeItems,
|
||||
CategoryExchanges,
|
||||
TransitionTriggers,
|
||||
TransitionEffects,
|
||||
Constrained,
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
pub enum OrderedCollection {
|
||||
Steps,
|
||||
FragmentBranches,
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
pub enum IncidentEnd {
|
||||
Source,
|
||||
Target,
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
pub enum ArtifactFacet {
|
||||
Presence,
|
||||
Name,
|
||||
Description,
|
||||
Owner,
|
||||
Relation,
|
||||
Uri,
|
||||
Revision,
|
||||
Fragment,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub enum ArtifactReview {
|
||||
Presence(Reviewed<bool>),
|
||||
Name(Reviewed<String>),
|
||||
Description(Reviewed<String>),
|
||||
Owner(Reviewed<Id>),
|
||||
Relation(Reviewed<ArtifactRelation>),
|
||||
Uri(Reviewed<String>),
|
||||
Revision(Reviewed<Option<String>>),
|
||||
Fragment(Reviewed<Option<String>>),
|
||||
}
|
||||
impl ArtifactReview {
|
||||
pub fn facet(&self) -> ArtifactFacet {
|
||||
match self {
|
||||
Self::Presence(_) => ArtifactFacet::Presence,
|
||||
Self::Name(_) => ArtifactFacet::Name,
|
||||
Self::Description(_) => ArtifactFacet::Description,
|
||||
Self::Owner(_) => ArtifactFacet::Owner,
|
||||
Self::Relation(_) => ArtifactFacet::Relation,
|
||||
Self::Uri(_) => ArtifactFacet::Uri,
|
||||
Self::Revision(_) => ArtifactFacet::Revision,
|
||||
Self::Fragment(_) => ArtifactFacet::Fragment,
|
||||
}
|
||||
}
|
||||
}
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct SemanticBaseline {
|
||||
pub facets: Vec<FacetReview>,
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -9,6 +9,13 @@
|
||||
//! may remain after interruption during staging/completed cleanup. They contain
|
||||
//! no pending transaction unless named `.arcadia-save-ENTRY.journal`. Windows
|
||||
//! has no portable directory fsync; this is not a power-loss durability promise.
|
||||
//! Journal v2 records whether writes may have begun with a flushed marker.
|
||||
//! Recovery of a v2 journal without that marker discards only journal metadata;
|
||||
//! v1 journals retain their prior rollback rules.
|
||||
//! Journal publication refuses replacement on Windows and Linux, including a
|
||||
//! competing empty directory. Linux requires kernel/filesystem support for
|
||||
//! `renameat2(RENAME_NOREPLACE)` and fails without a fallback when unavailable.
|
||||
//! Other operating systems retain standard rename semantics.
|
||||
use crate::{language, source_project::*};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{
|
||||
@@ -22,6 +29,8 @@ use thiserror::Error;
|
||||
const MAX_PATH_BYTES: usize = 4096;
|
||||
const MAX_JOURNAL_BYTES: usize = 32 * 1024 * 1024;
|
||||
const MAX_JOURNAL_FILES: usize = 4096;
|
||||
const WRITE_STARTED: &str = "write-started";
|
||||
const WRITE_STARTED_BYTES: &[u8] = b"Arcadia journal v2: model writes may have started\n";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum DiskExpectation {
|
||||
@@ -588,6 +597,127 @@ fn staged_path(journal: &Path, index: usize, suffix: &str) -> PathBuf {
|
||||
journal.join(format!("{index}.{suffix}"))
|
||||
}
|
||||
|
||||
fn publish_journal(stage: &Path, journal: &Path) -> Result<(), SourceFileError> {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
publish_journal_with_retry(
|
||||
stage,
|
||||
journal,
|
||||
rename_journal_without_replacement,
|
||||
std::thread::sleep,
|
||||
)
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
rename_journal_without_replacement(stage, journal).map_err(|error| io_at(journal, error))
|
||||
}
|
||||
// Other platforms retain their existing rename behavior; the native
|
||||
// no-replacement publication guarantee is currently Windows/Linux only.
|
||||
#[cfg(not(any(windows, target_os = "linux")))]
|
||||
{
|
||||
fs::rename(stage, journal).map_err(|error| io_at(journal, error))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn rename_journal_without_replacement(stage: &Path, journal: &Path) -> io::Result<()> {
|
||||
use std::{ffi::CString, os::unix::ffi::OsStrExt};
|
||||
|
||||
let path_bytes = |path: &Path| {
|
||||
CString::new(path.as_os_str().as_bytes()).map_err(|_| {
|
||||
io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
"Journal path contains an interior NUL.",
|
||||
)
|
||||
})
|
||||
};
|
||||
let (stage, journal) = (path_bytes(stage)?, path_bytes(journal)?);
|
||||
// Use libc's architecture-specific syscall number, avoiding a dependency on
|
||||
// the newer glibc renameat2 wrapper. Both NUL-terminated byte buffers remain
|
||||
// alive for the call. RENAME_NOREPLACE makes even an empty competing directory
|
||||
// an error. Unsupported kernels/filesystems fail closed: never fall back to
|
||||
// ordinary rename, whose replacement semantics would lose journal ownership.
|
||||
let result = unsafe {
|
||||
libc::syscall(
|
||||
libc::SYS_renameat2,
|
||||
libc::AT_FDCWD as libc::c_long,
|
||||
stage.as_ptr(),
|
||||
libc::AT_FDCWD as libc::c_long,
|
||||
journal.as_ptr(),
|
||||
libc::RENAME_NOREPLACE as libc::c_ulong,
|
||||
)
|
||||
};
|
||||
if result == -1 {
|
||||
Err(io::Error::last_os_error())
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn rename_journal_without_replacement(stage: &Path, journal: &Path) -> io::Result<()> {
|
||||
use std::os::windows::ffi::OsStrExt;
|
||||
#[link(name = "kernel32")]
|
||||
unsafe extern "system" {
|
||||
fn MoveFileExW(existing: *const u16, destination: *const u16, flags: u32) -> i32;
|
||||
}
|
||||
let wide = |path: &Path| -> io::Result<Vec<u16>> {
|
||||
let mut value: Vec<_> = path.as_os_str().encode_wide().collect();
|
||||
if value.contains(&0) {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
"Journal path contains an interior NUL.",
|
||||
));
|
||||
}
|
||||
value.push(0);
|
||||
Ok(value)
|
||||
};
|
||||
let (stage, journal) = (wide(stage)?, wide(journal)?);
|
||||
// Both terminated UTF-16 buffers live through the call. Flags=0 requests
|
||||
// neither replacement, cross-volume copying nor deferred execution. Unlike
|
||||
// fs::rename on modern Windows, an existing empty directory is not replaced.
|
||||
if unsafe { MoveFileExW(stage.as_ptr(), journal.as_ptr(), 0) } == 0 {
|
||||
Err(io::Error::last_os_error())
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// Windows scanners may briefly open newly written stage files without delete
|
||||
// sharing. Retry only publication, before any model file has been replaced.
|
||||
// Ownership still transfers only after one successful rename. Never overwrite
|
||||
// a journal that appeared while waiting, and retain the final OS error/path.
|
||||
#[cfg(any(windows, test))]
|
||||
fn publish_journal_with_retry(
|
||||
stage: &Path,
|
||||
journal: &Path,
|
||||
mut rename: impl FnMut(&Path, &Path) -> io::Result<()>,
|
||||
mut pause: impl FnMut(std::time::Duration),
|
||||
) -> Result<(), SourceFileError> {
|
||||
let mut delays = [5, 10, 25, 50, 100, 200].into_iter();
|
||||
loop {
|
||||
match fs::symlink_metadata(journal) {
|
||||
Ok(_) => return Err(SourceFileError::RecoveryRequired(journal.into())),
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
|
||||
Err(error) => return Err(io_at(journal, error)),
|
||||
}
|
||||
match rename(stage, journal) {
|
||||
Ok(()) => return Ok(()),
|
||||
Err(error) => {
|
||||
// ERROR_ACCESS_DENIED, ERROR_SHARING_VIOLATION, ERROR_LOCK_VIOLATION.
|
||||
// A permanent ACL failure is still returned after at most 390 ms.
|
||||
if matches!(error.raw_os_error(), Some(5 | 32 | 33))
|
||||
&& let Some(delay) = delays.next()
|
||||
{
|
||||
pause(std::time::Duration::from_millis(delay));
|
||||
continue;
|
||||
}
|
||||
return Err(io_at(journal, error));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn write_synced(path: &Path, bytes: &[u8]) -> Result<(), SourceFileError> {
|
||||
let mut file = File::options()
|
||||
.write(true)
|
||||
@@ -699,6 +829,26 @@ fn save_with_hook(
|
||||
compiled: &CompiledSourceProject,
|
||||
limits: ProjectLimits,
|
||||
mut after_replace: impl FnMut(usize) -> io::Result<()>,
|
||||
) -> Result<DiskProject, SourceFileError> {
|
||||
save_with_phases(snapshot, compiled, limits, |phase| match phase {
|
||||
SavePhase::AfterReplace(count) => after_replace(count),
|
||||
_ => Ok(()),
|
||||
})
|
||||
}
|
||||
|
||||
enum SavePhase {
|
||||
BeforePublish,
|
||||
AfterPublish,
|
||||
AfterWriteMarker,
|
||||
AfterReplace(usize),
|
||||
BeforeRetire,
|
||||
}
|
||||
|
||||
fn save_with_phases(
|
||||
snapshot: &DiskProject,
|
||||
compiled: &CompiledSourceProject,
|
||||
limits: ProjectLimits,
|
||||
mut phase: impl FnMut(SavePhase) -> io::Result<()>,
|
||||
) -> Result<DiskProject, SourceFileError> {
|
||||
check_snapshot(snapshot, limits)?;
|
||||
let checked = compile_bundle(&compiled.bundle, limits).map_err(|diagnostics| {
|
||||
@@ -739,7 +889,7 @@ fn save_with_hook(
|
||||
// Stable dependency order, with the project entry committed last.
|
||||
entries.sort_by_key(|file| (file.path == snapshot.bundle.entry, file.path.clone()));
|
||||
let journal = Journal {
|
||||
version: 1,
|
||||
version: 2,
|
||||
entry: snapshot.bundle.entry.clone(),
|
||||
files: entries,
|
||||
};
|
||||
@@ -775,20 +925,46 @@ fn save_with_hook(
|
||||
}
|
||||
write_synced(&stage.path().join("journal.json"), &metadata)?;
|
||||
sync_directory(stage.path())?;
|
||||
phase(SavePhase::BeforePublish).map_err(|error| io_at(&journal_dir, error))?;
|
||||
// Publishing a complete directory makes interrupted staging harmless.
|
||||
fs::rename(stage.path(), &journal_dir).map_err(|error| io_at(&journal_dir, error))?;
|
||||
publish_journal(stage.path(), &journal_dir)?;
|
||||
let _ = stage.keep(); // The renamed directory now belongs to recovery.
|
||||
sync_directory(&snapshot.root)?;
|
||||
phase(SavePhase::AfterPublish).map_err(|error| io_at(&journal_dir, error))?;
|
||||
// Publication may have waited while another writer completed the same
|
||||
// candidate. Nothing has been written by this save: never interpret their
|
||||
// matching after-bytes as ours and roll them back. The absent v2 marker
|
||||
// also preserves this distinction after interruption or retirement failure.
|
||||
if let Err(error) = check_conflicts(&snapshot.root, &snapshot.expectations) {
|
||||
let retirement = phase(SavePhase::BeforeRetire)
|
||||
.map_err(|error| io_at(&journal_dir, error))
|
||||
.and_then(|()| retire_journal(&snapshot.root, &journal_dir, &journal));
|
||||
return match retirement {
|
||||
Ok(()) => Err(error),
|
||||
Err(retirement) => Err(SourceFileError::RecoveryFailed {
|
||||
save: error.to_string(),
|
||||
recovery: retirement.to_string(),
|
||||
journal: journal_dir,
|
||||
}),
|
||||
};
|
||||
}
|
||||
write_synced(&journal_dir.join(WRITE_STARTED), WRITE_STARTED_BYTES)?;
|
||||
sync_directory(&journal_dir)?;
|
||||
phase(SavePhase::AfterWriteMarker).map_err(|error| io_at(&journal_dir, error))?;
|
||||
let result = (|| {
|
||||
let mut current = snapshot.expectations.clone();
|
||||
let mut replacements = 0;
|
||||
for file in journal.files.iter().filter(|file| file.write) {
|
||||
check_conflicts(&snapshot.root, ¤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 +980,7 @@ fn save_with_hook(
|
||||
}),
|
||||
};
|
||||
}
|
||||
phase(SavePhase::BeforeRetire).map_err(|error| io_at(&journal_dir, error))?;
|
||||
retire_journal(&snapshot.root, &journal_dir, &journal)?;
|
||||
Ok(saved_snapshot(snapshot, compiled))
|
||||
}
|
||||
@@ -833,6 +1010,14 @@ fn remove_journal(directory: &Path, journal: &Journal) -> Result<(), SourceFileE
|
||||
}
|
||||
}
|
||||
}
|
||||
if journal.version == 2 {
|
||||
let marker = directory.join(WRITE_STARTED);
|
||||
match fs::remove_file(&marker) {
|
||||
Ok(()) => {}
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
|
||||
Err(error) => return Err(io_at(&marker, error)),
|
||||
}
|
||||
}
|
||||
let metadata = directory.join("journal.json");
|
||||
fs::remove_file(&metadata).map_err(|error| io_at(&metadata, error))?;
|
||||
fs::remove_dir(directory).map_err(|error| io_at(directory, error))
|
||||
@@ -898,7 +1083,10 @@ fn recover_with_hook(
|
||||
let bytes = read_journal_file(&metadata_path, MAX_JOURNAL_BYTES)?;
|
||||
let journal: Journal = serde_json::from_slice(&bytes)
|
||||
.map_err(|error| SourceFileError::Invalid(format!("Invalid recovery journal: {error}")))?;
|
||||
if journal.version != 1 || journal.entry != entry || journal.files.len() > limits.max_files {
|
||||
if !matches!(journal.version, 1 | 2)
|
||||
|| journal.entry != entry
|
||||
|| journal.files.len() > limits.max_files
|
||||
{
|
||||
return Err(SourceFileError::Invalid(
|
||||
"Recovery journal version, entry or file budget is invalid.".into(),
|
||||
));
|
||||
@@ -908,6 +1096,25 @@ fn recover_with_hook(
|
||||
let mut folded = BTreeSet::new();
|
||||
let (mut before_bytes, mut after_bytes) = (0usize, 0usize);
|
||||
let mut allowed = BTreeSet::from(["journal.json".to_string()]);
|
||||
let write_started = if journal.version == 2 {
|
||||
let marker = directory.join(WRITE_STARTED);
|
||||
match fs::symlink_metadata(&marker) {
|
||||
Ok(_) => {
|
||||
if read_journal_file(&marker, WRITE_STARTED_BYTES.len())? != WRITE_STARTED_BYTES {
|
||||
return Err(SourceFileError::Invalid(
|
||||
"Invalid journal write-started marker.".into(),
|
||||
));
|
||||
}
|
||||
allowed.insert(WRITE_STARTED.into());
|
||||
true
|
||||
}
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => false,
|
||||
Err(error) => return Err(io_at(&marker, error)),
|
||||
}
|
||||
} else {
|
||||
// Frozen v1 journals predate the marker and retain their rollback rules.
|
||||
true
|
||||
};
|
||||
for (index, file) in journal.files.iter().enumerate() {
|
||||
disk_path(&root, &file.path)?;
|
||||
if !folded.insert(file.path.to_lowercase()) {
|
||||
@@ -952,6 +1159,10 @@ fn recover_with_hook(
|
||||
));
|
||||
}
|
||||
}
|
||||
if !write_started {
|
||||
retire_journal(&root, &directory, &journal)?;
|
||||
return Ok(true);
|
||||
}
|
||||
let mut current = BTreeMap::new();
|
||||
let mut conflicts = Vec::new();
|
||||
for (path, before) in &original {
|
||||
@@ -995,11 +1206,347 @@ 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(
|
||||
directory.path().join("project.arcadia"),
|
||||
"arcadia 4; project p \"P\" { include \"a.arcadia\"; include \"b.arcadia\"; }",
|
||||
"arcadia 5; project p \"P\" { include \"a.arcadia\"; include \"b.arcadia\"; }",
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(directory.path().join("a.arcadia"), "oa_activity a \"A\" {}").unwrap();
|
||||
@@ -1014,6 +1561,160 @@ mod transaction_tests {
|
||||
(directory, snapshot, compiled)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unwritten_journal_never_rolls_back_a_completed_writer_with_identical_after_bytes() {
|
||||
for interrupt_retirement in [false, true] {
|
||||
let (directory, snapshot, compiled) = fixture();
|
||||
let error = save_with_phases(&snapshot, &compiled, ProjectLimits::default(), |phase| {
|
||||
match phase {
|
||||
SavePhase::BeforePublish => {
|
||||
// A competing save finishes during our publication wait.
|
||||
save_project(&snapshot, &compiled, ProjectLimits::default()).unwrap();
|
||||
}
|
||||
SavePhase::BeforeRetire if interrupt_retirement => {
|
||||
return Err(io::Error::other("injected no-write retirement failure"));
|
||||
}
|
||||
SavePhase::AfterWriteMarker | SavePhase::AfterReplace(_) => {
|
||||
panic!("losing writer must not start writes")
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.unwrap_err();
|
||||
assert!(matches!(
|
||||
error,
|
||||
SourceFileError::Conflict(_) | SourceFileError::RecoveryFailed { .. }
|
||||
));
|
||||
for (path, bytes) in &compiled.bundle.files {
|
||||
assert_eq!(
|
||||
fs::read_to_string(directory.path().join(path)).unwrap(),
|
||||
*bytes
|
||||
);
|
||||
}
|
||||
let journal = journal_path(&snapshot.root, &snapshot.bundle.entry);
|
||||
assert_eq!(journal.exists(), interrupt_retirement);
|
||||
if interrupt_retirement {
|
||||
assert!(!journal.join(WRITE_STARTED).exists());
|
||||
// Explicit recovery after a failed retirement must also leave
|
||||
// the completed other writer untouched, even matching after bytes.
|
||||
assert!(
|
||||
recover_project(
|
||||
&directory.path().join("project.arcadia"),
|
||||
ProjectLimits::default()
|
||||
)
|
||||
.unwrap()
|
||||
);
|
||||
for (path, bytes) in &compiled.bundle.files {
|
||||
assert_eq!(
|
||||
fs::read_to_string(directory.path().join(path)).unwrap(),
|
||||
*bytes
|
||||
);
|
||||
}
|
||||
}
|
||||
assert!(!journal.exists());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recovery_before_any_write_accepts_v2_marker_and_keeps_v1_rollback_compatibility() {
|
||||
for version in [1, 2] {
|
||||
let (directory, snapshot, compiled) = fixture();
|
||||
let interrupted = std::panic::catch_unwind(|| {
|
||||
let _ = save_with_phases(&snapshot, &compiled, ProjectLimits::default(), |phase| {
|
||||
if matches!(phase, SavePhase::AfterWriteMarker) {
|
||||
panic!("interrupted before atomic write");
|
||||
}
|
||||
Ok(())
|
||||
});
|
||||
});
|
||||
assert!(interrupted.is_err());
|
||||
check_conflicts(&snapshot.root, &snapshot.expectations).unwrap();
|
||||
let journal = journal_path(&snapshot.root, &snapshot.bundle.entry);
|
||||
assert_eq!(
|
||||
fs::read(journal.join(WRITE_STARTED)).unwrap(),
|
||||
WRITE_STARTED_BYTES
|
||||
);
|
||||
if version == 1 {
|
||||
// A frozen v1 journal has no marker and means writes may have begun.
|
||||
let path = journal.join("journal.json");
|
||||
let mut metadata: Journal =
|
||||
serde_json::from_slice(&fs::read(&path).unwrap()).unwrap();
|
||||
metadata.version = 1;
|
||||
fs::write(path, serde_json::to_vec(&metadata).unwrap()).unwrap();
|
||||
fs::remove_file(journal.join(WRITE_STARTED)).unwrap();
|
||||
for (path, bytes) in &compiled.bundle.files {
|
||||
fs::write(directory.path().join(path), bytes).unwrap();
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
recover_project(
|
||||
&directory.path().join("project.arcadia"),
|
||||
ProjectLimits::default()
|
||||
)
|
||||
.unwrap()
|
||||
);
|
||||
check_conflicts(&snapshot.root, &snapshot.expectations).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interrupted_publication_without_write_marker_never_rolls_back_external_matching_bytes() {
|
||||
let (directory, snapshot, compiled) = fixture();
|
||||
let interrupted = std::panic::catch_unwind(|| {
|
||||
let _ = save_with_phases(&snapshot, &compiled, ProjectLimits::default(), |phase| {
|
||||
if matches!(phase, SavePhase::AfterPublish) {
|
||||
panic!("interrupted before write marker");
|
||||
}
|
||||
Ok(())
|
||||
});
|
||||
});
|
||||
assert!(interrupted.is_err());
|
||||
let journal = journal_path(&snapshot.root, &snapshot.bundle.entry);
|
||||
assert!(!journal.join(WRITE_STARTED).exists());
|
||||
for (path, bytes) in &compiled.bundle.files {
|
||||
fs::write(directory.path().join(path), bytes).unwrap();
|
||||
}
|
||||
assert!(
|
||||
recover_project(
|
||||
&directory.path().join("project.arcadia"),
|
||||
ProjectLimits::default()
|
||||
)
|
||||
.unwrap()
|
||||
);
|
||||
for (path, bytes) in &compiled.bundle.files {
|
||||
assert_eq!(
|
||||
fs::read_to_string(directory.path().join(path)).unwrap(),
|
||||
*bytes
|
||||
);
|
||||
}
|
||||
assert!(!journal.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_v2_write_marker_refuses_recovery_without_changing_source_bytes() {
|
||||
let (directory, snapshot, compiled) = fixture();
|
||||
let _ = std::panic::catch_unwind(|| {
|
||||
let _ = save_with_phases(&snapshot, &compiled, ProjectLimits::default(), |phase| {
|
||||
if matches!(phase, SavePhase::AfterWriteMarker) {
|
||||
panic!("interrupted");
|
||||
}
|
||||
Ok(())
|
||||
});
|
||||
});
|
||||
let journal = journal_path(&snapshot.root, &snapshot.bundle.entry);
|
||||
fs::write(journal.join(WRITE_STARTED), b"partial").unwrap();
|
||||
assert!(matches!(
|
||||
recover_project(
|
||||
&directory.path().join("project.arcadia"),
|
||||
ProjectLimits::default()
|
||||
),
|
||||
Err(SourceFileError::Invalid(_))
|
||||
));
|
||||
check_conflicts(&snapshot.root, &snapshot.expectations).unwrap();
|
||||
assert!(journal.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_save_failures_restore_every_original_and_write_entry_last() {
|
||||
for stop in 1..=3 {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Pure, bounded source projects. All includes resolve against supplied UTF-8
|
||||
//! documents; this module never reads a filesystem, URL, or process directory.
|
||||
use crate::{Id, Kind, Project, language, persistence::DecodeResult, validation};
|
||||
use crate::{Id, Kind, Project, SCHEMA_VERSION, language, persistence::DecodeResult, validation};
|
||||
use language::{Diagnostic, ParseLimits, Span};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
@@ -280,7 +280,7 @@ pub fn lint_bundle(bundle: &SourceBundle, limits: ProjectLimits) -> Vec<SourceDi
|
||||
}
|
||||
}
|
||||
|
||||
/// Compile reachable source3/4 files, explicitly upgrade data contracts, and stabilize UUIDs.
|
||||
/// Compile reachable source3/4/5 files, explicitly upgrade contracts, and stabilize UUIDs.
|
||||
/// Missing/unreachable files and every admission or engineering error leave the
|
||||
/// input untouched. V1/V2 single-file migration remains in `language::parse`.
|
||||
pub fn compile_bundle(
|
||||
@@ -720,7 +720,7 @@ fn apply_edits(bundle: &mut SourceBundle, edits: Edits) -> Result<(), Vec<Source
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Canonical, readable source4 export. Like `language::format`, the caller is
|
||||
/// Canonical, readable current-version export. Like `language::format`, the caller is
|
||||
/// responsible for supplying a valid model; compilation checks the result.
|
||||
/// Existing source layout is preserved by `update_bundle`, not this formatter.
|
||||
pub fn format_bundle(project: &Project) -> SourceBundle {
|
||||
@@ -736,7 +736,7 @@ pub fn format_bundle(project: &Project) -> SourceBundle {
|
||||
.push_str(&unindent(&declaration));
|
||||
}
|
||||
let mut entry = format!(
|
||||
"arcadia 4;\n{}{{\n description {};\n",
|
||||
"arcadia {SCHEMA_VERSION};\n{}{{\n description {};\n",
|
||||
project_header(project),
|
||||
quote(&project.description)
|
||||
);
|
||||
@@ -926,6 +926,7 @@ pub fn update_bundle_with_identity_map(
|
||||
old_aliases: &old_aliases,
|
||||
new_aliases: &new_aliases,
|
||||
legacy_value_paths: false,
|
||||
identity_origins: Some(&origins),
|
||||
};
|
||||
let local = edits.entry(path.clone()).or_default();
|
||||
update.header(&actual.header, &previous.header, &next.header, local);
|
||||
@@ -955,6 +956,7 @@ pub fn update_bundle_with_identity_map(
|
||||
old_aliases: &old_aliases,
|
||||
new_aliases: &new_aliases,
|
||||
legacy_value_paths: false,
|
||||
identity_origins: Some(&origins),
|
||||
};
|
||||
let mut local = Vec::new();
|
||||
update.header(
|
||||
@@ -1126,6 +1128,7 @@ struct SyntaxUpdate<'a> {
|
||||
// Source3 migration only adds path qualification; the existing field identity
|
||||
// must keep its authored argument trivia during that one-time transformation.
|
||||
legacy_value_paths: bool,
|
||||
identity_origins: Option<&'a BTreeMap<Id, Id>>,
|
||||
}
|
||||
impl SyntaxUpdate<'_> {
|
||||
fn header(
|
||||
@@ -1221,6 +1224,18 @@ impl SyntaxUpdate<'_> {
|
||||
alias.clone(),
|
||||
));
|
||||
}
|
||||
(EditKind::HistoricalUuid(_), EditKind::HistoricalUuid(id)) => {
|
||||
let token = significant(&self.source[actual.span.start..actual.span.end])
|
||||
.pop()
|
||||
.expect("historical UUID string");
|
||||
edits.push((
|
||||
Span {
|
||||
start: actual.span.start + token.span.start,
|
||||
end: actual.span.start + token.span.end,
|
||||
},
|
||||
quote(&id.to_string()),
|
||||
));
|
||||
}
|
||||
(EditKind::Atom, EditKind::Atom) => edits.push((
|
||||
actual.span,
|
||||
self.after[after.span.start..after.span.end].into(),
|
||||
@@ -1345,11 +1360,27 @@ impl SyntaxUpdate<'_> {
|
||||
let previous = before.unwrap_or(&[]);
|
||||
let old_keys: Option<Vec<_>> = previous
|
||||
.iter()
|
||||
.map(|item| item_key(item, self.before, self.old_aliases, self.legacy_value_paths))
|
||||
.map(|item| {
|
||||
item_key(
|
||||
item,
|
||||
self.before,
|
||||
self.old_aliases,
|
||||
self.legacy_value_paths,
|
||||
None,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let new_keys: Option<Vec<_>> = after
|
||||
.iter()
|
||||
.map(|item| item_key(item, self.after, self.new_aliases, self.legacy_value_paths))
|
||||
.map(|item| {
|
||||
item_key(
|
||||
item,
|
||||
self.after,
|
||||
self.new_aliases,
|
||||
self.legacy_value_paths,
|
||||
self.identity_origins,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let keyed = old_keys.zip(new_keys).filter(|(old, new)| {
|
||||
old.iter().collect::<BTreeSet<_>>().len() == old.len()
|
||||
@@ -1457,9 +1488,22 @@ fn item_key(
|
||||
source: &str,
|
||||
aliases: &BTreeMap<String, String>,
|
||||
legacy_value_paths: bool,
|
||||
origins: Option<&BTreeMap<Id, Id>>,
|
||||
) -> Option<String> {
|
||||
use language::EditKind;
|
||||
if matches!(value.kind, EditKind::Record(_))
|
||||
&& let Some(key) = language::semantic_row_key(
|
||||
&source[value.span.start..value.span.end],
|
||||
origins.unwrap_or(&BTreeMap::new()),
|
||||
)
|
||||
{
|
||||
return Some(key);
|
||||
}
|
||||
match &value.kind {
|
||||
EditKind::HistoricalUuid(id) => Some(format!(
|
||||
"uuid:{}",
|
||||
origins.and_then(|map| map.get(id)).unwrap_or(id)
|
||||
)),
|
||||
EditKind::Reference(alias) => aliases.get(alias).map(|id| format!("ref:{id}")),
|
||||
EditKind::Atom => Some(format!(
|
||||
"literal:{}",
|
||||
@@ -1509,7 +1553,11 @@ fn semantic_owner(project: &Project, id: Id) -> Option<Id> {
|
||||
return element.parent;
|
||||
}
|
||||
if let Some(relation) = project.relations.get(&id) {
|
||||
return Some(relation.source());
|
||||
return Some(
|
||||
relation
|
||||
.contextual_owner()
|
||||
.unwrap_or_else(|| relation.source()),
|
||||
);
|
||||
}
|
||||
if let Some(artifact) = project.artifact_links.get(&id) {
|
||||
return Some(artifact.owner);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Source3 data upgrade through token edits; authored modules, nesting and trivia survive.
|
||||
//! Source3/4 upgrade through token edits; authored modules, nesting and trivia survive.
|
||||
use super::*;
|
||||
|
||||
pub(super) fn upgrade(
|
||||
@@ -15,6 +15,7 @@ pub(super) fn upgrade(
|
||||
let changed: BTreeSet<_> = report
|
||||
.semantic_defaults
|
||||
.iter()
|
||||
.filter(|default| default.field != "baseline.semantic")
|
||||
.map(|default| default.object)
|
||||
.collect();
|
||||
let aliases: BTreeMap<_, _> = project
|
||||
@@ -61,6 +62,7 @@ pub(super) fn upgrade(
|
||||
old_aliases: &aliases,
|
||||
new_aliases: &aliases,
|
||||
legacy_value_paths: true,
|
||||
identity_origins: None,
|
||||
};
|
||||
update.value(
|
||||
&actual.fields,
|
||||
@@ -78,7 +80,7 @@ pub(super) fn upgrade(
|
||||
edits
|
||||
.entry(current.bundle.entry.clone())
|
||||
.or_default()
|
||||
.push((version, "4".into()));
|
||||
.push((version, crate::SCHEMA_VERSION.to_string()));
|
||||
// New allocation relations are emitted into the allocating interface's
|
||||
// existing module, outside its lexical element body. Existing source never moves.
|
||||
let mut added: BTreeMap<String, String> = BTreeMap::new();
|
||||
@@ -117,7 +119,7 @@ pub(super) fn upgrade(
|
||||
&bundle.entry,
|
||||
Span::default(),
|
||||
"migration_source_mismatch",
|
||||
"Targeted source3 upgrade did not reproduce the migrated project.",
|
||||
"Targeted source upgrade did not reproduce the migrated project.",
|
||||
)]);
|
||||
}
|
||||
result.decoded.migration = Some(report.clone());
|
||||
|
||||
@@ -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
|
||||
@@ -202,9 +241,15 @@ fn mapped_body(e: &Element, kind: Kind, layer: Layer) -> Option<ElementBody> {
|
||||
return None;
|
||||
}
|
||||
if let Some(shared) = e.shared()
|
||||
&& kind == e.kind()
|
||||
&& (kind == e.kind() || (kind.is_state() && e.kind().is_state()))
|
||||
{
|
||||
let mut shared = shared.clone();
|
||||
if let SharedBody::State {
|
||||
kind: state_kind, ..
|
||||
} = &mut shared
|
||||
{
|
||||
*state_kind = kind.state_kind()?;
|
||||
}
|
||||
if let SharedBody::Scenario { viewpoint, .. } = &mut shared {
|
||||
*viewpoint = match (*viewpoint, layer) {
|
||||
(ScenarioViewpoint::OperationalActivity, Layer::Sa) => {
|
||||
@@ -249,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 { .. }
|
||||
@@ -261,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 { .. }
|
||||
@@ -312,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 {
|
||||
@@ -343,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))
|
||||
@@ -389,11 +458,30 @@ pub fn plan_transition(
|
||||
commands: vec![],
|
||||
};
|
||||
let mut selected: BTreeSet<_> = request.sources.iter().copied().collect();
|
||||
let omitted = if request.direction == TransitionDirection::SaToOa {
|
||||
let mut omitted = if request.direction == TransitionDirection::SaToOa {
|
||||
sa_port_omissions(project)
|
||||
} else {
|
||||
BTreeSet::new()
|
||||
};
|
||||
// An explicit existing Legacy counterpart can be compared and reviewed
|
||||
// without inventing an OA typed port. Only this port is retained: dependent
|
||||
// SA implementation records and new counterpart requests stay omitted.
|
||||
for mapping in &request.mappings {
|
||||
if project
|
||||
.elements
|
||||
.get(&mapping.source)
|
||||
.is_some_and(|e| e.layer() == Layer::Sa && crate::ports::port_contract(e).is_some())
|
||||
&& mapping
|
||||
.target
|
||||
.and_then(|target| project.elements.get(&target))
|
||||
.is_some_and(|e| {
|
||||
e.layer() == Layer::Oa
|
||||
&& crate::ports::port_contract(e) == Some(&PortContract::Legacy)
|
||||
})
|
||||
{
|
||||
omitted.remove(&mapping.source);
|
||||
}
|
||||
}
|
||||
let mut reported_omissions = BTreeSet::new();
|
||||
if selected.len() != request.sources.len() {
|
||||
conflict(
|
||||
@@ -422,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.
|
||||
@@ -445,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)
|
||||
}));
|
||||
}
|
||||
@@ -474,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,
|
||||
@@ -507,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() {
|
||||
@@ -670,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))
|
||||
@@ -698,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!(
|
||||
"{}_{}",
|
||||
@@ -739,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!(
|
||||
"{}_{}",
|
||||
@@ -753,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
|
||||
@@ -927,6 +1140,7 @@ pub fn plan_transition(
|
||||
.unwrap_or(TraceBaseline {
|
||||
oa: current_oa.clone(),
|
||||
sa: current_sa.clone(),
|
||||
semantic: None,
|
||||
});
|
||||
for field in &reviewed_fields {
|
||||
set(&mut baseline.oa, *field, value(¤t_oa, *field).clone());
|
||||
@@ -1081,6 +1295,11 @@ pub fn counterpart_kinds(source: Kind, direction: TransitionDirection) -> Vec<Ki
|
||||
}
|
||||
|
||||
fn compatible_kinds(source: Kind, target: Kind, direction: TransitionDirection) -> bool {
|
||||
// State kind is a reviewed structural facet of one state family. An existing
|
||||
// counterpart remains selectable after either side changes its vertex kind.
|
||||
if source.is_state() && target.is_state() {
|
||||
return true;
|
||||
}
|
||||
if matches!(
|
||||
(direction, source, target),
|
||||
(
|
||||
@@ -1108,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
|
||||
@@ -876,6 +901,19 @@ pub fn validate(p: &Project) -> Vec<Issue> {
|
||||
text(&mut out, t.id, "baseline.name", &props.name);
|
||||
text(&mut out, t.id, "baseline.description", &props.description);
|
||||
}
|
||||
if let Some(semantic) = &b.semantic
|
||||
&& let Err(errors) = crate::semantic::validate_semantic_baseline(semantic)
|
||||
{
|
||||
for message in errors {
|
||||
error(
|
||||
&mut out,
|
||||
t.id,
|
||||
"semantic_baseline",
|
||||
"baseline.semantic",
|
||||
message,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
match crate::transitions::trace_drift(p, t) {
|
||||
crate::transitions::Drift::Unchanged => {}
|
||||
@@ -908,12 +946,62 @@ pub fn validate(p: &Project) -> Vec<Issue> {
|
||||
);
|
||||
}
|
||||
}
|
||||
if !out.iter().any(|issue| issue.severity == Severity::Error) {
|
||||
coverage.validate(p, &mut out);
|
||||
}
|
||||
out
|
||||
}
|
||||
fn validate_sequence(p: &Project, e: &Element, steps: &[Id], out: &mut Vec<Issue>) {
|
||||
fn validate_sequence(
|
||||
p: &Project,
|
||||
e: &Element,
|
||||
steps: &[Id],
|
||||
topology: &crate::topology_context::TopologyIndex<'_>,
|
||||
out: &mut Vec<Issue>,
|
||||
) {
|
||||
if !matches!(e.kind(), Kind::Process | Kind::FunctionalChain) {
|
||||
return;
|
||||
}
|
||||
if topology.has_contextual_links(e.id) {
|
||||
let analysis = topology.analyze(e.id, crate::topology_context::AnalysisLimits::default());
|
||||
for notice in analysis.notices {
|
||||
let breadcrumb = notice
|
||||
.address
|
||||
.endpoint
|
||||
.references
|
||||
.iter()
|
||||
.chain(std::iter::once(¬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()
|
||||
@@ -1241,40 +1329,149 @@ pub fn semantic_representation_compatible(
|
||||
pub fn lifeline_matches_endpoint(p: &Project, line: Id, endpoint: Id) -> Option<bool> {
|
||||
semantic_representation_compatible(p, represented(p, line)?, endpoint)
|
||||
}
|
||||
/// Effective semantic direction, including a Reply that reuses its Call exchange.
|
||||
/// An incomplete reply correlation and a local timer have no inferred endpoints.
|
||||
pub fn message_endpoints(p: &Project, body: &OccurrenceBody) -> Option<(Id, Id)> {
|
||||
/// A single shared answer for scenario admission, drawing, and endpoint pickers.
|
||||
/// Incomplete or ambiguous bindings never choose an arbitrary direction.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum MessageEndpointResolution {
|
||||
NotApplicable,
|
||||
Incomplete,
|
||||
Resolved { source: Id, target: Id },
|
||||
Ambiguous,
|
||||
Conflict,
|
||||
}
|
||||
pub fn resolve_message_endpoints(p: &Project, body: &OccurrenceBody) -> MessageEndpointResolution {
|
||||
use MessageEndpointResolution as R;
|
||||
let OccurrenceBody::Exchange {
|
||||
exchange: Some(exchange),
|
||||
message_kind,
|
||||
reply_to,
|
||||
sender,
|
||||
receiver,
|
||||
..
|
||||
} = body
|
||||
else {
|
||||
return None;
|
||||
return R::NotApplicable;
|
||||
};
|
||||
if matches!(message_kind, MessageKind::Timer { .. }) {
|
||||
return None;
|
||||
return R::NotApplicable;
|
||||
}
|
||||
let Some(relation) = p.relations.get(exchange) else {
|
||||
return R::Incomplete;
|
||||
};
|
||||
let mut candidates = vec![relation.body.endpoints()];
|
||||
// A mapped unoriented SA boundary retains the OA communication's possible
|
||||
// directions. Fixed oriented/legacy exchange behavior remains unchanged.
|
||||
let communication = matches!(
|
||||
relation.body,
|
||||
RelationBody::CommunicationMean { .. }
|
||||
| RelationBody::BoundaryExchange {
|
||||
oriented: false,
|
||||
..
|
||||
}
|
||||
);
|
||||
if matches!(
|
||||
relation.body,
|
||||
RelationBody::CommunicationMean {
|
||||
oriented: false,
|
||||
..
|
||||
} | RelationBody::BoundaryExchange {
|
||||
oriented: false,
|
||||
..
|
||||
}
|
||||
) {
|
||||
candidates.push((relation.target(), relation.source()));
|
||||
}
|
||||
if matches!(
|
||||
relation.body,
|
||||
RelationBody::BoundaryExchange {
|
||||
oriented: false,
|
||||
..
|
||||
}
|
||||
) {
|
||||
candidates.retain(|(source, target)| {
|
||||
match (
|
||||
p.elements.get(source).and_then(crate::ports::port_contract),
|
||||
p.elements.get(target).and_then(crate::ports::port_contract),
|
||||
) {
|
||||
(
|
||||
Some(a @ PortContract::Component { .. }),
|
||||
Some(b @ PortContract::Component { .. }),
|
||||
) => {
|
||||
crate::ports::component_allows(a, true)
|
||||
&& crate::ports::component_allows(b, false)
|
||||
}
|
||||
// Legacy direction contracts remain fixed even for an
|
||||
// unoriented declaration; do not reinterpret old ports.
|
||||
_ => (*source, *target) == relation.body.endpoints(),
|
||||
}
|
||||
});
|
||||
}
|
||||
let relation = p.relations.get(exchange)?;
|
||||
if message_kind == &MessageKind::Reply {
|
||||
let Some(reply_to) = reply_to else {
|
||||
return R::Incomplete;
|
||||
};
|
||||
let Some(SharedBody::Occurrence {
|
||||
body:
|
||||
OccurrenceBody::Exchange {
|
||||
call_body @ OccurrenceBody::Exchange {
|
||||
message_kind: MessageKind::Call,
|
||||
exchange: Some(call_exchange),
|
||||
..
|
||||
},
|
||||
..
|
||||
}) = p.elements.get(&(*reply_to)?).and_then(Element::shared)
|
||||
}) = p.elements.get(reply_to).and_then(Element::shared)
|
||||
else {
|
||||
return None;
|
||||
return R::Incomplete;
|
||||
};
|
||||
if call_exchange == exchange {
|
||||
return Some((relation.target(), relation.source()));
|
||||
let R::Resolved { source, target } = resolve_message_endpoints(p, call_body) else {
|
||||
return R::Incomplete;
|
||||
};
|
||||
candidates = vec![(target, source)];
|
||||
} else if communication {
|
||||
let R::Resolved { source, target } = resolve_message_endpoints(p, call_body) else {
|
||||
return R::Incomplete;
|
||||
};
|
||||
let owner = |id| {
|
||||
p.elements
|
||||
.get(&id)
|
||||
.filter(|e| e.kind() == Kind::Port)
|
||||
.and_then(|e| e.parent)
|
||||
.unwrap_or(id)
|
||||
};
|
||||
candidates.retain(|(a, b)| {
|
||||
semantic_representation_compatible(p, owner(*a), owner(target)) == Some(true)
|
||||
&& semantic_representation_compatible(p, owner(*b), owner(source)) == Some(true)
|
||||
});
|
||||
}
|
||||
}
|
||||
Some(relation.body.endpoints())
|
||||
if communication {
|
||||
candidates.retain(|(a, b)| {
|
||||
sender.is_none_or(|line| lifeline_matches_endpoint(p, line, *a) != Some(false))
|
||||
&& receiver.is_none_or(|line| lifeline_matches_endpoint(p, line, *b) != Some(false))
|
||||
});
|
||||
if candidates.is_empty() {
|
||||
return R::Conflict;
|
||||
}
|
||||
if sender.and_then(|line| represented(p, line)).is_none()
|
||||
|| receiver.and_then(|line| represented(p, line)).is_none()
|
||||
{
|
||||
return R::Incomplete;
|
||||
}
|
||||
if candidates.len() > 1 {
|
||||
return R::Ambiguous;
|
||||
}
|
||||
}
|
||||
let Some((source, target)) = candidates.first().copied() else {
|
||||
return R::Conflict;
|
||||
};
|
||||
R::Resolved { source, target }
|
||||
}
|
||||
/// Compatibility adapter: only a resolved direction has inferred endpoints.
|
||||
pub fn message_endpoints(p: &Project, body: &OccurrenceBody) -> Option<(Id, Id)> {
|
||||
match resolve_message_endpoints(p, body) {
|
||||
MessageEndpointResolution::Resolved { source, target } => Some((source, target)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// Only report order guaranteed by a common sequence or an enclosing branch path.
|
||||
/// A call in one alternative does not become an unconditional call after the fragment.
|
||||
@@ -1405,6 +1602,7 @@ fn validate_message(p: &Project, e: &Element, body: &OccurrenceBody, out: &mut V
|
||||
receiver,
|
||||
reply_to,
|
||||
values,
|
||||
..
|
||||
} = body
|
||||
else {
|
||||
return;
|
||||
@@ -1427,7 +1625,9 @@ fn validate_message(p: &Project, e: &Element, body: &OccurrenceBody, out: &mut V
|
||||
ScenarioViewpoint::Functional => relation.kind() == RelationKind::FunctionalExchange,
|
||||
ScenarioViewpoint::ParticipantInteraction => matches!(
|
||||
relation.kind(),
|
||||
RelationKind::Interaction | RelationKind::BoundaryExchange
|
||||
RelationKind::Interaction
|
||||
| RelationKind::BoundaryExchange
|
||||
| RelationKind::CommunicationMean
|
||||
),
|
||||
ScenarioViewpoint::BoundaryInterface => matches!(
|
||||
relation.kind(),
|
||||
@@ -1615,6 +1815,34 @@ fn validate_message(p: &Project, e: &Element, body: &OccurrenceBody, out: &mut V
|
||||
}
|
||||
}
|
||||
let relation = exchange.and_then(|id| p.relations.get(&id));
|
||||
let operation = crate::operations::resolve(p, e.id);
|
||||
let signature = match &operation {
|
||||
Ok(crate::operations::OperationResolution::Selected(selected)) => {
|
||||
if selected.transmission == TransmissionProtocol::Unspecified {
|
||||
warning(
|
||||
out,
|
||||
e.id,
|
||||
"operation_transmission_unspecified",
|
||||
"operation",
|
||||
"The selected operation transmission protocol is unspecified.",
|
||||
);
|
||||
}
|
||||
Some(crate::operations::signature_relation(selected))
|
||||
}
|
||||
Ok(crate::operations::OperationResolution::Unresolved { reason, .. }) => {
|
||||
warning(out, e.id, "operation_unresolved", "operation", reason);
|
||||
None
|
||||
}
|
||||
Err(problem) => {
|
||||
error(out, e.id, problem.code, "operation", &problem.message);
|
||||
None
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let value_relation = match &operation {
|
||||
Ok(crate::operations::OperationResolution::Ordinary) => relation,
|
||||
_ => signature.as_ref(),
|
||||
};
|
||||
if message_kind == &MessageKind::Call
|
||||
&& relation.is_some_and(|r| {
|
||||
!r.body
|
||||
@@ -1660,10 +1888,48 @@ fn validate_message(p: &Project, e: &Element, body: &OccurrenceBody, out: &mut V
|
||||
"The value path exceeds the bounded authoring depth.",
|
||||
);
|
||||
}
|
||||
if let Some(r) = relation {
|
||||
if matches!(
|
||||
operation,
|
||||
Ok(crate::operations::OperationResolution::Unresolved { .. })
|
||||
) && let Some(owner) = value
|
||||
.path
|
||||
.first()
|
||||
.or(Some(&value.field))
|
||||
.and_then(|id| p.elements.get(id))
|
||||
.and_then(|field| field.parent)
|
||||
{
|
||||
let path_context = Relation {
|
||||
id: e.id,
|
||||
alias: String::new(),
|
||||
name: String::new(),
|
||||
description: String::new(),
|
||||
body: RelationBody::Interaction {
|
||||
source: e.id,
|
||||
target: e.id,
|
||||
items: vec![owner],
|
||||
},
|
||||
};
|
||||
if crate::data::carried_value_path(p, &path_context, &value.path, value.field).is_none()
|
||||
{
|
||||
error(
|
||||
out,
|
||||
e.id,
|
||||
"message_value_path",
|
||||
"values.path",
|
||||
"Even while the operation is unresolved, a value path must follow existing Value record fields in order.",
|
||||
);
|
||||
}
|
||||
}
|
||||
if let Some(r) = value_relation {
|
||||
if let Some(path) = crate::data::carried_value_path(p, r, &value.path, value.field) {
|
||||
if crate::data::item_mechanism(p, path.item) == Some(ExchangeMechanism::Operation) {
|
||||
let permitted = match message_kind {
|
||||
MessageKind::Asynchronous if signature.is_some() => matches!(
|
||||
path.direction,
|
||||
ParameterDirection::Unspecified
|
||||
| ParameterDirection::In
|
||||
| ParameterDirection::InOut
|
||||
),
|
||||
MessageKind::Call => matches!(
|
||||
path.direction,
|
||||
ParameterDirection::Unspecified
|
||||
@@ -1907,6 +2173,7 @@ fn validate_occurrence(p: &Project, e: &Element, body: &OccurrenceBody, out: &mu
|
||||
RelationKind::Interaction
|
||||
| RelationKind::FunctionalExchange
|
||||
| RelationKind::BoundaryExchange
|
||||
| RelationKind::CommunicationMean
|
||||
) => {}
|
||||
_ => error(
|
||||
out,
|
||||
@@ -2102,7 +2369,24 @@ fn relation_path_exists(p: &Project, kind: RelationKind, from: Id, to: Id) -> bo
|
||||
false
|
||||
}
|
||||
|
||||
fn validate_relation(p: &Project, r: &Relation, out: &mut Vec<Issue>) {
|
||||
fn validate_relation(
|
||||
p: &Project,
|
||||
r: &Relation,
|
||||
topology: &crate::topology_context::TopologyIndex<'_>,
|
||||
out: &mut Vec<Issue>,
|
||||
) {
|
||||
if let RelationBody::ContextualLink {
|
||||
owner,
|
||||
source,
|
||||
target,
|
||||
purpose,
|
||||
} = &r.body
|
||||
{
|
||||
if let Err(problem) = topology.validate_link(*owner, source, target, purpose) {
|
||||
error(out, r.id, problem.code, "body", problem.message);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let (source, target) = r.body.endpoints();
|
||||
for id in crate::references::relation_references(r) {
|
||||
// Most relation references name elements. Avoid constructing all model
|
||||
@@ -2121,18 +2405,20 @@ fn validate_relation(p: &Project, r: &Relation, out: &mut Vec<Issue>) {
|
||||
if let RelationBody::ExchangeAllocation { .. } = r.body {
|
||||
match (p.relations.get(&source), p.relations.get(&target)) {
|
||||
(Some(a), Some(b))
|
||||
if a.kind() == RelationKind::BoundaryExchange
|
||||
if ((a.kind() == RelationKind::BoundaryExchange
|
||||
&& matches!(
|
||||
b.kind(),
|
||||
RelationKind::FunctionalExchange | RelationKind::Interaction
|
||||
)
|
||||
))
|
||||
|| (a.kind() == RelationKind::CommunicationMean
|
||||
&& b.kind() == RelationKind::Interaction))
|
||||
&& p.layer_of(source) == p.layer_of(target) => {}
|
||||
_ => error(
|
||||
out,
|
||||
r.id,
|
||||
"exchange_allocation",
|
||||
"endpoints",
|
||||
"Exchange allocation relates a boundary exchange to a work exchange in the same layer.",
|
||||
"Exchange allocation relates communication means to an OA interaction, or a boundary exchange to a work exchange, in the same layer.",
|
||||
),
|
||||
};
|
||||
validate_exchange_allocation(p, r, out);
|
||||
@@ -2383,7 +2669,7 @@ pub fn trace_permits(p: &Project, t: &Trace) -> bool {
|
||||
_ => false,
|
||||
},
|
||||
_ => {
|
||||
matches!((p.relations.get(&t.oa),p.relations.get(&t.sa)),(Some(a),Some(b))if t.kind==TraceKind::Exchange&&a.kind()==RelationKind::Interaction&&b.kind()==RelationKind::FunctionalExchange)
|
||||
matches!((p.relations.get(&t.oa),p.relations.get(&t.sa)),(Some(a),Some(b))if t.kind==TraceKind::Exchange&&crate::transitions::exchange_counterparts(a.kind(), b.kind()))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2478,7 +2764,12 @@ pub fn can_connect(p: &Project, kind: RelationKind, source: Id, target: Id) -> b
|
||||
*transition_kind = TransitionKind::Local;
|
||||
}
|
||||
let mut issues = vec![];
|
||||
validate_relation(p, &candidate, &mut issues);
|
||||
validate_relation(
|
||||
p,
|
||||
&candidate,
|
||||
&crate::topology_context::TopologyIndex::new(p),
|
||||
&mut issues,
|
||||
);
|
||||
validate_relation_context(p, &candidate, &mut issues);
|
||||
if kind == RelationKind::StateTransition {
|
||||
for endpoint in [source, target] {
|
||||
@@ -2508,7 +2799,12 @@ pub fn can_connect_boundary(p: &Project, source: Id, target: Id, oriented: bool)
|
||||
},
|
||||
};
|
||||
let mut issues = vec![];
|
||||
validate_relation(p, &candidate, &mut issues);
|
||||
validate_relation(
|
||||
p,
|
||||
&candidate,
|
||||
&crate::topology_context::TopologyIndex::new(p),
|
||||
&mut issues,
|
||||
);
|
||||
validate_relation_context(p, &candidate, &mut issues);
|
||||
!issues.iter().any(|i| i.severity == Severity::Error)
|
||||
}
|
||||
@@ -2724,6 +3020,23 @@ fn validate_occurrence_context(
|
||||
OccurrenceBody::Exchange {
|
||||
sender, receiver, ..
|
||||
} => {
|
||||
match resolve_message_endpoints(p, body) {
|
||||
MessageEndpointResolution::Conflict => error(
|
||||
out,
|
||||
e.id,
|
||||
"scenario_endpoint",
|
||||
"sender",
|
||||
"Sender and receiver must agree with one permitted communication direction and the correlated Call.",
|
||||
),
|
||||
MessageEndpointResolution::Ambiguous => warning(
|
||||
out,
|
||||
e.id,
|
||||
"ambiguous_message_direction",
|
||||
"sender",
|
||||
"Bind explicit participant lifelines to resolve the communication direction; shared roles allow both directions.",
|
||||
),
|
||||
_ => {}
|
||||
}
|
||||
if let Some((source, target)) = message_endpoints(p, body) {
|
||||
check(out, *sender, Some(source), "sender");
|
||||
check(out, *receiver, Some(target), "receiver");
|
||||
|
||||
@@ -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,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,27 @@
|
||||
// Frozen schema4 input: reviewed prose is deliberately different from live prose.
|
||||
arcadia 4;
|
||||
project reviewed_prose "Reviewed dimensions" id "40000000-0000-4000-8000-000000000001" {
|
||||
description "Preserve the original review without inventing semantic history.";
|
||||
oa_data_type oa_dimension "OA dimension now" id "40000000-0000-4000-8000-000000000002" {
|
||||
description "Unreviewed OA description";
|
||||
definition { Integer { minimum 0; maximum 100; }; };
|
||||
default { Integer 20; };
|
||||
unit "m";
|
||||
}
|
||||
sa_data_type sa_dimension "SA dimension now" id "40000000-0000-4000-8000-000000000003" {
|
||||
description "Unreviewed SA description";
|
||||
definition { Real { minimum 0.0; maximum 100.0; }; };
|
||||
default { Real 20.0; };
|
||||
unit "ft";
|
||||
}
|
||||
trace dimension_review "Dimension rationale" id "40000000-0000-4000-8000-000000000004" {
|
||||
description "The target unit and representation are intentional.";
|
||||
oa @oa_dimension;
|
||||
sa @sa_dimension;
|
||||
kind Item;
|
||||
baseline {
|
||||
oa { name "Approved OA name"; description "Approved OA\nprose"; };
|
||||
sa { name "Approved SA name"; description "Approved SA prose"; };
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"schema_version": 4,
|
||||
"id": "40000000-0000-4000-8000-000000000001",
|
||||
"alias": "reviewed_prose",
|
||||
"name": "Reviewed dimensions",
|
||||
"description": "Preserve the original review without inventing semantic history.",
|
||||
"elements": {
|
||||
"40000000-0000-4000-8000-000000000002": {
|
||||
"id": "40000000-0000-4000-8000-000000000002",
|
||||
"alias": "oa_dimension",
|
||||
"name": "OA dimension now",
|
||||
"description": "Unreviewed OA description",
|
||||
"parent": null,
|
||||
"body": {"Oa": {"Shared": {"DataType": {
|
||||
"definition": {"Integer": {"minimum": 0, "maximum": 100}},
|
||||
"default": {"Integer": 20},
|
||||
"unit": "m"
|
||||
}}}}
|
||||
},
|
||||
"40000000-0000-4000-8000-000000000003": {
|
||||
"id": "40000000-0000-4000-8000-000000000003",
|
||||
"alias": "sa_dimension",
|
||||
"name": "SA dimension now",
|
||||
"description": "Unreviewed SA description",
|
||||
"parent": null,
|
||||
"body": {"Sa": {"Shared": {"DataType": {
|
||||
"definition": {"Real": {"minimum": 0.0, "maximum": 100.0}},
|
||||
"default": {"Real": 20.0},
|
||||
"unit": "ft"
|
||||
}}}}
|
||||
}
|
||||
},
|
||||
"relations": {},
|
||||
"diagrams": {},
|
||||
"artifact_links": {},
|
||||
"traces": {
|
||||
"40000000-0000-4000-8000-000000000004": {
|
||||
"id": "40000000-0000-4000-8000-000000000004",
|
||||
"alias": "dimension_review",
|
||||
"name": "Dimension rationale",
|
||||
"description": "The target unit and representation are intentional.",
|
||||
"oa": "40000000-0000-4000-8000-000000000002",
|
||||
"sa": "40000000-0000-4000-8000-000000000003",
|
||||
"kind": "Item",
|
||||
"baseline": {
|
||||
"oa": {"name": "Approved OA name", "description": "Approved OA\nprose"},
|
||||
"sa": {"name": "Approved SA name", "description": "Approved SA prose"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -339,6 +339,73 @@ project mission "Harbor safety — 港 🌊" {
|
||||
component_port,
|
||||
function_port,
|
||||
);
|
||||
let participants: Vec<_> = p
|
||||
.elements
|
||||
.values()
|
||||
.filter(|e| matches!(e.kind(), Kind::Entity | Kind::Actor))
|
||||
.map(|e| e.id)
|
||||
.collect();
|
||||
let means = p.relate(
|
||||
RelationKind::CommunicationMean,
|
||||
"Operational communication",
|
||||
participants[0],
|
||||
participants[1],
|
||||
);
|
||||
let category = p
|
||||
.add_element(
|
||||
Kind::ExchangeCategory,
|
||||
Layer::Oa,
|
||||
"Operational exchange category",
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
*p.elements.get_mut(&category).unwrap().shared_mut().unwrap() = SharedBody::ExchangeCategory {
|
||||
family: oa_core::view_options::ExchangeFamily::Participant,
|
||||
exchanges: vec![means],
|
||||
};
|
||||
let caller = p
|
||||
.add_element(Kind::Process, Layer::Oa, "Common work caller", None)
|
||||
.unwrap();
|
||||
let work = p
|
||||
.elements
|
||||
.values()
|
||||
.find(|e| e.kind() == Kind::Activity)
|
||||
.unwrap()
|
||||
.id;
|
||||
let mut occurrences = vec![];
|
||||
for name in ["First contribution", "Second contribution"] {
|
||||
let id = p
|
||||
.add_element(Kind::Occurrence, Layer::Oa, name, Some(caller))
|
||||
.unwrap();
|
||||
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Action {
|
||||
action: Some(work),
|
||||
lifeline: None,
|
||||
},
|
||||
precondition: String::new(),
|
||||
postcondition: String::new(),
|
||||
};
|
||||
occurrences.push(id);
|
||||
}
|
||||
*p.elements.get_mut(&caller).unwrap().steps_mut().unwrap() = occurrences.clone();
|
||||
let id = p.relate(
|
||||
RelationKind::ContextualLink,
|
||||
"Shared work incidence",
|
||||
occurrences[0],
|
||||
occurrences[1],
|
||||
);
|
||||
p.relations.get_mut(&id).unwrap().body = RelationBody::ContextualLink {
|
||||
owner: caller,
|
||||
source: OccurrenceEndpoint {
|
||||
references: vec![],
|
||||
occurrence: occurrences[0],
|
||||
},
|
||||
target: OccurrenceEndpoint {
|
||||
references: vec![],
|
||||
occurrence: occurrences[1],
|
||||
},
|
||||
purpose: ContextualLinkPurpose::CommonWork { work },
|
||||
};
|
||||
p
|
||||
}
|
||||
|
||||
@@ -491,7 +558,7 @@ fn authoring_defaults_and_forward_references_remain_strict() {
|
||||
#[test]
|
||||
fn typed_unknown_fields_enums_duplicates_and_reference_spelling_are_rejected() {
|
||||
for (source, expected) in [
|
||||
("arcadia 5; project mission \"x\" {}", "Unsupported schema"),
|
||||
("arcadia 8; project mission \"x\" {}", "Unsupported schema"),
|
||||
(
|
||||
"arcadia 2; project mission \"x\" { oa_activity a \"A\" { nonsense 1; } }",
|
||||
"Unknown field",
|
||||
@@ -795,7 +862,7 @@ fn original_v2_text_and_json_preserve_identity_and_mixed_scenarios_after_extensi
|
||||
assert_eq!(from_source, from_json);
|
||||
let original: serde_json::Value = serde_json::from_slice(old_json).unwrap();
|
||||
let mut retained = serde_json::to_value(&from_json).unwrap();
|
||||
assert_eq!(retained["schema_version"], 4);
|
||||
assert_eq!(retained["schema_version"], oa_core::SCHEMA_VERSION);
|
||||
retained["schema_version"] = 2.into();
|
||||
let allocations: Vec<_> = from_json
|
||||
.relations
|
||||
@@ -904,6 +971,11 @@ fn original_v2_text_and_json_preserve_identity_and_mixed_scenarios_after_extensi
|
||||
}
|
||||
}
|
||||
for diagram in retained["diagrams"].as_object_mut().unwrap().values_mut() {
|
||||
assert_eq!(
|
||||
diagram["view_options"],
|
||||
serde_json::to_value(oa_core::view_options::ViewOptions::default()).unwrap()
|
||||
);
|
||||
diagram.as_object_mut().unwrap().remove("view_options");
|
||||
assert_eq!(diagram["routes"], serde_json::json!([]));
|
||||
diagram.as_object_mut().unwrap().remove("routes");
|
||||
}
|
||||
@@ -991,7 +1063,7 @@ fn v1_text_and_json_migrate_deterministically_without_losing_legacy_semantics()
|
||||
assert_eq!(from_text, from_json);
|
||||
assert_eq!(read(text), from_text);
|
||||
assert_eq!(persistence::decode(json).unwrap(), from_json);
|
||||
assert_eq!(from_text.schema_version, 4);
|
||||
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,10 +1262,10 @@ fn complete_normative_examples_are_accepted_by_the_real_parser() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if code.trim_start().starts_with("arcadia 2;")
|
||||
|| code.trim_start().starts_with("arcadia 3;")
|
||||
|| code.trim_start().starts_with("arcadia 4;")
|
||||
{
|
||||
if (1..=SCHEMA_VERSION).any(|version| {
|
||||
code.trim_start()
|
||||
.starts_with(&format!("arcadia {version};"))
|
||||
}) {
|
||||
read(&code);
|
||||
examples += 1;
|
||||
}
|
||||
|
||||
@@ -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, 4));
|
||||
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 4;"));
|
||||
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, 4);
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -29,7 +29,11 @@ project typed_ports "Typed ports" {
|
||||
#[test]
|
||||
fn source4_covers_typed_contracts_roles_allocations_and_nested_legacy_references() {
|
||||
let decoded = language::parse_with_report(SOURCE).unwrap();
|
||||
assert!(decoded.migration.is_none());
|
||||
let migration = decoded.migration.as_ref().unwrap();
|
||||
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]",
|
||||
|
||||
@@ -0,0 +1,684 @@
|
||||
use oa_core::{transitions::*, *};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
enum Family {
|
||||
Data,
|
||||
Port,
|
||||
Occurrence,
|
||||
Message,
|
||||
Event,
|
||||
State,
|
||||
}
|
||||
const FAMILIES: [Family; 6] = [
|
||||
Family::Data,
|
||||
Family::Port,
|
||||
Family::Occurrence,
|
||||
Family::Message,
|
||||
Family::Event,
|
||||
Family::State,
|
||||
];
|
||||
|
||||
fn add(p: &mut Project, kind: Kind, layer: Layer, parent: Option<Id>) -> Id {
|
||||
p.add_element(kind, layer, "reviewed", parent).unwrap()
|
||||
}
|
||||
fn trace(p: &mut Project, oa: Id, sa: Id) -> Id {
|
||||
let id = Id::new_v4();
|
||||
p.traces.insert(
|
||||
id,
|
||||
Trace {
|
||||
id,
|
||||
alias: format!("review_{}", id.simple()),
|
||||
name: "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,
|
||||
}),
|
||||
},
|
||||
);
|
||||
id
|
||||
}
|
||||
fn set(p: &mut Project, id: Id, body: SharedBody) {
|
||||
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = body;
|
||||
}
|
||||
fn integer(p: &mut Project, id: Id) {
|
||||
set(
|
||||
p,
|
||||
id,
|
||||
SharedBody::DataType {
|
||||
definition: DataTypeDefinition::Integer {
|
||||
minimum: Some(0),
|
||||
maximum: Some(10),
|
||||
},
|
||||
default: None,
|
||||
unit: "m".into(),
|
||||
},
|
||||
);
|
||||
}
|
||||
fn fixture(family: Family) -> (Project, Id, Id, Id, FacetSelector, FacetSelector) {
|
||||
let mut p = Project::empty();
|
||||
let mut pair = vec![];
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let id = match family {
|
||||
Family::Data => {
|
||||
let id = add(&mut p, Kind::DataType, layer, None);
|
||||
integer(&mut p, id);
|
||||
id
|
||||
}
|
||||
Family::Port => {
|
||||
let kind = if layer == Layer::Oa {
|
||||
Kind::Actor
|
||||
} else {
|
||||
Kind::SystemActor
|
||||
};
|
||||
let owner = add(&mut p, kind, layer, None);
|
||||
let id = Id::new_v4();
|
||||
let mut port = make_port(&p, id, owner, "port", PortContract::Legacy).unwrap();
|
||||
if let Some(SharedBody::Port { legacy, .. }) = port.shared_mut() {
|
||||
*legacy = Some(LegacyPortContract {
|
||||
direction: PortDirection::InOut,
|
||||
interface: None,
|
||||
});
|
||||
}
|
||||
p.elements.insert(id, port);
|
||||
id
|
||||
}
|
||||
Family::Occurrence | Family::Message => {
|
||||
let owner = add(&mut p, Kind::Scenario, layer, None);
|
||||
let id = add(&mut p, Kind::Occurrence, layer, Some(owner));
|
||||
p.elements
|
||||
.get_mut(&owner)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap()
|
||||
.push(id);
|
||||
set(
|
||||
&mut p,
|
||||
id,
|
||||
SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange {
|
||||
operation: None,
|
||||
exchange: None,
|
||||
sender: None,
|
||||
receiver: None,
|
||||
message_kind: MessageKind::Timer {
|
||||
expression: "10s".into(),
|
||||
},
|
||||
reply_to: None,
|
||||
values: vec![],
|
||||
},
|
||||
precondition: String::new(),
|
||||
postcondition: String::new(),
|
||||
},
|
||||
);
|
||||
id
|
||||
}
|
||||
Family::Event => {
|
||||
let id = add(&mut p, Kind::Event, layer, None);
|
||||
set(
|
||||
&mut p,
|
||||
id,
|
||||
SharedBody::Event {
|
||||
body: EventBody::Time {
|
||||
kind: TimeEventKind::After,
|
||||
expression: "10s".into(),
|
||||
},
|
||||
},
|
||||
);
|
||||
id
|
||||
}
|
||||
Family::State => {
|
||||
let machine = add(&mut p, Kind::StateMachine, layer, None);
|
||||
let region = add(&mut p, Kind::Region, layer, Some(machine));
|
||||
add(&mut p, Kind::State, layer, Some(region))
|
||||
}
|
||||
};
|
||||
pair.push(id);
|
||||
}
|
||||
let (oa, sa) = (pair[0], pair[1]);
|
||||
let t = trace(&mut p, oa, sa);
|
||||
let (constructor, descendant) = match family {
|
||||
Family::Data => (
|
||||
FacetSelector::Data(DataFacet::Constructor),
|
||||
FacetSelector::Data(DataFacet::IntegerMaximum),
|
||||
),
|
||||
Family::Port => (
|
||||
FacetSelector::Port(PortFacet::Constructor),
|
||||
FacetSelector::Port(PortFacet::LegacyDirection),
|
||||
),
|
||||
Family::Occurrence => (
|
||||
FacetSelector::Occurrence(OccurrenceFacet::Constructor),
|
||||
FacetSelector::Occurrence(OccurrenceFacet::TimerExpression),
|
||||
),
|
||||
Family::Message => (
|
||||
FacetSelector::Occurrence(OccurrenceFacet::MessageKind),
|
||||
FacetSelector::Occurrence(OccurrenceFacet::TimerExpression),
|
||||
),
|
||||
Family::Event => (
|
||||
FacetSelector::Event(EventFacet::Constructor),
|
||||
FacetSelector::Event(EventFacet::TimeExpression),
|
||||
),
|
||||
Family::State => (
|
||||
FacetSelector::State(StateFacet::Kind),
|
||||
FacetSelector::State(StateFacet::Entry),
|
||||
),
|
||||
};
|
||||
(p, oa, sa, t, constructor, descendant)
|
||||
}
|
||||
fn change_descendant(p: &mut Project, id: Id) {
|
||||
match p.elements.get_mut(&id).unwrap().shared_mut().unwrap() {
|
||||
SharedBody::DataType {
|
||||
definition: DataTypeDefinition::Integer { maximum, .. },
|
||||
..
|
||||
} => *maximum = Some(20),
|
||||
SharedBody::Port {
|
||||
legacy: Some(LegacyPortContract { direction, .. }),
|
||||
..
|
||||
} => *direction = PortDirection::Out,
|
||||
SharedBody::Occurrence {
|
||||
body:
|
||||
OccurrenceBody::Exchange {
|
||||
message_kind: MessageKind::Timer { expression },
|
||||
..
|
||||
},
|
||||
..
|
||||
}
|
||||
| SharedBody::Event {
|
||||
body: EventBody::Time { expression, .. },
|
||||
} => *expression = "20s".into(),
|
||||
SharedBody::State { entry, .. } => *entry = "initialize".into(),
|
||||
_ => panic!("Unexpected test family"),
|
||||
}
|
||||
}
|
||||
fn request(
|
||||
oa: Id,
|
||||
sa: Id,
|
||||
reverse: bool,
|
||||
resolutions: Vec<(FacetSelector, SemanticDecision)>,
|
||||
) -> SemanticTransitionRequest {
|
||||
let (direction, source, target) = if reverse {
|
||||
(TransitionDirection::SaToOa, sa, oa)
|
||||
} else {
|
||||
(TransitionDirection::OaToSa, oa, sa)
|
||||
};
|
||||
let mut transition = TransitionRequest::new(direction, vec![source]);
|
||||
transition.mappings.push(TransitionMapping {
|
||||
source,
|
||||
target: Some(target),
|
||||
target_kind: None,
|
||||
});
|
||||
let mut r = SemanticTransitionRequest::new(transition);
|
||||
r.reviews.push(SemanticReviewRequest {
|
||||
source,
|
||||
resolutions: resolutions
|
||||
.into_iter()
|
||||
.map(|(facet, decision)| FacetResolution { facet, decision })
|
||||
.collect(),
|
||||
reset: vec![],
|
||||
});
|
||||
r
|
||||
}
|
||||
fn apply(p: &Project, r: SemanticTransitionRequest) -> (Project, SemanticTransitionPlan) {
|
||||
let plan = plan_semantic_transition(p, r).unwrap();
|
||||
assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts);
|
||||
(apply_semantic_transition(p, &plan).unwrap(), plan)
|
||||
}
|
||||
fn drift(p: &Project, t: Id, selector: &FacetSelector) -> Drift {
|
||||
semantic_facets(p, &p.traces[&t], &BTreeMap::new())
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.find(|f| f.selector == *selector)
|
||||
.unwrap()
|
||||
.drift
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_equal_constructor_acknowledges_only_discriminator_in_every_family_and_direction() {
|
||||
for family in FAMILIES {
|
||||
for reverse in [false, true] {
|
||||
for decision in [
|
||||
SemanticDecision::KeepTarget,
|
||||
SemanticDecision::TakeSource,
|
||||
SemanticDecision::Candidate,
|
||||
] {
|
||||
let (mut p, oa, sa, t, constructor, descendant) = fixture(family);
|
||||
let (source, target) = if reverse { (sa, oa) } else { (oa, sa) };
|
||||
change_descendant(&mut p, target);
|
||||
let mut r = request(oa, sa, reverse, vec![(constructor.clone(), decision)]);
|
||||
if decision == SemanticDecision::Candidate {
|
||||
r.candidates
|
||||
.push(SemanticRecord::Element(p.elements[&target].clone()));
|
||||
}
|
||||
let (q, plan) = apply(&p, r.clone());
|
||||
assert_eq!(q.elements, p.elements, "{family:?} {decision:?}");
|
||||
assert_eq!(plan.reviews[0].acknowledged, vec![constructor.clone()]);
|
||||
assert!(plan.reviews[0].retired.is_empty());
|
||||
let facts = semantic_facets(&q, &q.traces[&t], &BTreeMap::new()).unwrap();
|
||||
assert!(facts.iter().all(|f| f.drift
|
||||
== if f.selector == constructor {
|
||||
Drift::Unchanged
|
||||
} else {
|
||||
Drift::Unreviewed
|
||||
}));
|
||||
let (mut q, _) = apply(
|
||||
&q,
|
||||
request(
|
||||
oa,
|
||||
sa,
|
||||
reverse,
|
||||
vec![(descendant.clone(), SemanticDecision::KeepTarget)],
|
||||
),
|
||||
);
|
||||
change_descendant(&mut q, source);
|
||||
assert_eq!(
|
||||
drift(&q, t, &descendant),
|
||||
if reverse {
|
||||
Drift::SaChanged
|
||||
} else {
|
||||
Drift::OaChanged
|
||||
}
|
||||
);
|
||||
let repeated = plan_semantic_transition(&q, r).unwrap();
|
||||
assert!(
|
||||
repeated
|
||||
.conflicts
|
||||
.iter()
|
||||
.any(|c| c.contains("No constructor switch"))
|
||||
);
|
||||
assert!(apply_semantic_transition(&q, &repeated).is_err());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn independent_constructor_and_leaf_selections_complete_missing_or_empty_baselines() {
|
||||
for family in FAMILIES {
|
||||
for reverse in [false, true] {
|
||||
for history in 0..3 {
|
||||
let (mut p, oa, sa, t, _, _) = fixture(family);
|
||||
if history == 0 {
|
||||
p.traces.get_mut(&t).unwrap().baseline = None;
|
||||
} else if history == 2 {
|
||||
p.traces
|
||||
.get_mut(&t)
|
||||
.unwrap()
|
||||
.baseline
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.semantic = Some(SemanticBaseline::default());
|
||||
}
|
||||
let selectors: Vec<_> = semantic_facets(&p, &p.traces[&t], &BTreeMap::new())
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|f| f.selector)
|
||||
.collect();
|
||||
let (q, plan) = apply(
|
||||
&p,
|
||||
request(
|
||||
oa,
|
||||
sa,
|
||||
reverse,
|
||||
selectors
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(|s| (s, SemanticDecision::KeepTarget))
|
||||
.collect(),
|
||||
),
|
||||
);
|
||||
assert_eq!(q.elements, p.elements);
|
||||
assert_eq!(plan.reviews[0].acknowledged.len(), selectors.len());
|
||||
assert!(plan.reviews[0].retired.is_empty());
|
||||
let status = trace_semantic_drift(&q, &q.traces[&t], &BTreeMap::new()).unwrap();
|
||||
assert_eq!(status.own, Drift::Unchanged, "{family:?} {history}");
|
||||
assert!(!status.own_unreviewed);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migrated_equal_constructor_can_complete_semantic_review_without_model_changes() {
|
||||
let json = include_str!("fixtures/v4-reviewed-prose.json")
|
||||
.replace("\"Real\"", "\"Integer\"")
|
||||
.replace("0.0", "0")
|
||||
.replace("100.0", "100")
|
||||
.replace("20.0", "20");
|
||||
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, oa_core::SCHEMA_VERSION)
|
||||
);
|
||||
let p = decoded.project;
|
||||
let t = p.traces.values().next().unwrap();
|
||||
assert!(t.baseline.as_ref().unwrap().semantic.is_none());
|
||||
let original_prose = t.baseline.clone().unwrap();
|
||||
let (oa, sa, id) = (t.oa, t.sa, t.id);
|
||||
let selections = semantic_facets(&p, t, &BTreeMap::new())
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|f| (f.selector, SemanticDecision::KeepTarget))
|
||||
.collect();
|
||||
let (q, _) = apply(&p, request(oa, sa, reverse, selections));
|
||||
assert_eq!(q.elements, p.elements);
|
||||
let baseline = q.traces[&id].baseline.as_ref().unwrap();
|
||||
assert_eq!(baseline.oa, original_prose.oa);
|
||||
assert_eq!(baseline.sa, original_prose.sa);
|
||||
let status = trace_semantic_drift(&q, &q.traces[&id], &BTreeMap::new()).unwrap();
|
||||
assert_eq!(status.own, Drift::Unchanged);
|
||||
assert!(!status.own_unreviewed);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equal_constructor_candidate_requires_independent_selection_of_changed_descendants() {
|
||||
for reverse in [false, true] {
|
||||
let (p, oa, sa, t, constructor, descendant) = fixture(Family::Data);
|
||||
let target = if reverse { oa } else { sa };
|
||||
let mut changed = p.clone();
|
||||
change_descendant(&mut changed, target);
|
||||
let mut r = request(
|
||||
oa,
|
||||
sa,
|
||||
reverse,
|
||||
vec![(constructor.clone(), SemanticDecision::Candidate)],
|
||||
);
|
||||
r.candidates
|
||||
.push(SemanticRecord::Element(changed.elements[&target].clone()));
|
||||
let invalid = plan_semantic_transition(&p, r.clone()).unwrap();
|
||||
assert!(
|
||||
invalid
|
||||
.conflicts
|
||||
.iter()
|
||||
.any(|c| c.contains("changes an unselected facet"))
|
||||
);
|
||||
assert!(apply_semantic_transition(&p, &invalid).is_err());
|
||||
r.reviews[0].resolutions.push(FacetResolution {
|
||||
facet: descendant.clone(),
|
||||
decision: SemanticDecision::Candidate,
|
||||
});
|
||||
let (q, plan) = apply(&p, r);
|
||||
assert_eq!(q.elements, changed.elements);
|
||||
assert_eq!(plan.reviews[0].acknowledged.len(), 2);
|
||||
assert_eq!(drift(&q, t, &constructor), Drift::Unchanged);
|
||||
assert_eq!(drift(&q, t, &descendant), Drift::Unchanged);
|
||||
assert_eq!(
|
||||
drift(&q, t, &FacetSelector::Data(DataFacet::Default)),
|
||||
Drift::Unreviewed
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_equal_constructor_does_not_refresh_previously_reviewed_descendants() {
|
||||
for reverse in [false, true] {
|
||||
let (p, oa, sa, t, constructor, descendant) = fixture(Family::Data);
|
||||
let (mut p, _) = apply(
|
||||
&p,
|
||||
request(
|
||||
oa,
|
||||
sa,
|
||||
reverse,
|
||||
vec![
|
||||
(constructor.clone(), SemanticDecision::KeepTarget),
|
||||
(descendant.clone(), SemanticDecision::KeepTarget),
|
||||
],
|
||||
),
|
||||
);
|
||||
let original = p.traces[&t]
|
||||
.baseline
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.semantic
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.clone();
|
||||
change_descendant(&mut p, if reverse { sa } else { oa });
|
||||
let mut r = request(
|
||||
oa,
|
||||
sa,
|
||||
reverse,
|
||||
vec![(constructor.clone(), SemanticDecision::KeepTarget)],
|
||||
);
|
||||
r.reviews[0].reset.push(constructor.clone());
|
||||
let (q, plan) = apply(&p, r);
|
||||
assert_eq!(q.elements, p.elements);
|
||||
assert_eq!(plan.reviews[0].acknowledged, vec![constructor]);
|
||||
assert!(plan.reviews[0].retired.is_empty());
|
||||
assert_eq!(
|
||||
q.traces[&t]
|
||||
.baseline
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.semantic
|
||||
.as_ref()
|
||||
.unwrap(),
|
||||
&original
|
||||
);
|
||||
assert_eq!(
|
||||
drift(&q, t, &descendant),
|
||||
if reverse {
|
||||
Drift::SaChanged
|
||||
} else {
|
||||
Drift::OaChanged
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_constructor_acknowledgement_agrees_only_on_its_canonical_discriminator_slot() {
|
||||
for switch in [false, true] {
|
||||
let (mut p, oa, sa, t, constructor, descendant) = fixture(Family::Data);
|
||||
let second = add(&mut p, Kind::DataType, Layer::Oa, None);
|
||||
integer(&mut p, second);
|
||||
if switch {
|
||||
set(
|
||||
&mut p,
|
||||
second,
|
||||
SharedBody::DataType {
|
||||
definition: DataTypeDefinition::Real {
|
||||
minimum: Some(0.),
|
||||
maximum: Some(30.),
|
||||
},
|
||||
default: None,
|
||||
unit: "m".into(),
|
||||
},
|
||||
);
|
||||
} else {
|
||||
change_descendant(&mut p, second);
|
||||
}
|
||||
trace(&mut p, second, sa);
|
||||
let mut r = request(
|
||||
oa,
|
||||
sa,
|
||||
false,
|
||||
vec![(constructor.clone(), SemanticDecision::KeepTarget)],
|
||||
);
|
||||
r.transition.sources.push(second);
|
||||
r.transition.mappings.push(TransitionMapping {
|
||||
source: second,
|
||||
target: Some(sa),
|
||||
target_kind: None,
|
||||
});
|
||||
r.reviews.push(SemanticReviewRequest {
|
||||
source: second,
|
||||
reset: vec![],
|
||||
resolutions: vec![FacetResolution {
|
||||
facet: if switch {
|
||||
constructor.clone()
|
||||
} else {
|
||||
descendant.clone()
|
||||
},
|
||||
decision: SemanticDecision::TakeSource,
|
||||
}],
|
||||
});
|
||||
for reversed in [false, true] {
|
||||
let mut r = r.clone();
|
||||
if reversed {
|
||||
r.reviews.reverse();
|
||||
}
|
||||
let plan = plan_semantic_transition(&p, r).unwrap();
|
||||
if switch {
|
||||
assert!(plan.conflicts.iter().any(|c| c.contains("Contradictory")));
|
||||
assert!(apply_semantic_transition(&p, &plan).is_err());
|
||||
} else {
|
||||
assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts);
|
||||
let q = apply_semantic_transition(&p, &plan).unwrap();
|
||||
assert_eq!(drift(&q, t, &constructor), Drift::Unchanged);
|
||||
assert_eq!(drift(&q, t, &descendant), Drift::Unreviewed);
|
||||
assert!(matches!(
|
||||
q.elements[&sa].shared(),
|
||||
Some(SharedBody::DataType {
|
||||
definition: DataTypeDefinition::Integer {
|
||||
maximum: Some(20),
|
||||
..
|
||||
},
|
||||
..
|
||||
})
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_sided_or_disappeared_message_constructor_is_not_an_equal_scalar_acknowledgement() {
|
||||
for reverse in [false, true] {
|
||||
let (mut p, oa, sa, t, constructor, descendant) = fixture(Family::Message);
|
||||
let source = if reverse { sa } else { oa };
|
||||
let target = if reverse { oa } else { sa };
|
||||
let mut action = p.elements[&source].shared().unwrap().clone();
|
||||
if let SharedBody::Occurrence { body, .. } = &mut action {
|
||||
*body = OccurrenceBody::Action {
|
||||
action: None,
|
||||
lifeline: None,
|
||||
};
|
||||
}
|
||||
set(&mut p, source, action.clone());
|
||||
let (q, plan) = apply(
|
||||
&p,
|
||||
request(
|
||||
oa,
|
||||
sa,
|
||||
reverse,
|
||||
vec![(constructor.clone(), SemanticDecision::KeepTarget)],
|
||||
),
|
||||
);
|
||||
// One-sided applicability still has a constructor-group scope and records its timer leaf.
|
||||
assert_eq!(
|
||||
plan.reviews[0].acknowledged,
|
||||
vec![constructor.clone(), descendant.clone()]
|
||||
);
|
||||
assert_eq!(q.elements, p.elements);
|
||||
assert_eq!(drift(&q, t, &descendant), Drift::Unchanged);
|
||||
let mut p = q;
|
||||
set(&mut p, target, action);
|
||||
let mut r = request(
|
||||
oa,
|
||||
sa,
|
||||
reverse,
|
||||
vec![(constructor.clone(), SemanticDecision::KeepTarget)],
|
||||
);
|
||||
r.reviews[0].reset.push(constructor);
|
||||
let plan = plan_semantic_transition(&p, r).unwrap();
|
||||
assert!(!plan.conflicts.is_empty());
|
||||
assert!(apply_semantic_transition(&p, &plan).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn historical_switches_retain_groups_and_completed_requests_still_reject_in_both_directions() {
|
||||
for reverse in [false, true] {
|
||||
let (p, oa, sa, t, constructor, _) = fixture(Family::Data);
|
||||
let (source, target) = if reverse { (sa, oa) } else { (oa, sa) };
|
||||
let keep = request(
|
||||
oa,
|
||||
sa,
|
||||
reverse,
|
||||
vec![(constructor.clone(), SemanticDecision::KeepTarget)],
|
||||
);
|
||||
let (mut p, _) = apply(&p, keep.clone());
|
||||
set(
|
||||
&mut p,
|
||||
source,
|
||||
SharedBody::DataType {
|
||||
definition: DataTypeDefinition::Real {
|
||||
minimum: Some(0.),
|
||||
maximum: Some(20.),
|
||||
},
|
||||
default: None,
|
||||
unit: "m".into(),
|
||||
},
|
||||
);
|
||||
let (mut q, plan) = apply(&p, keep.clone());
|
||||
let maximum = FacetSelector::Data(DataFacet::RealMaximum);
|
||||
assert!(plan.reviews[0].acknowledged.contains(&maximum));
|
||||
assert!(
|
||||
plan.reviews[0]
|
||||
.acknowledged
|
||||
.contains(&FacetSelector::Data(DataFacet::Default))
|
||||
);
|
||||
assert!(
|
||||
!plan.reviews[0]
|
||||
.acknowledged
|
||||
.contains(&FacetSelector::Data(DataFacet::Unit))
|
||||
);
|
||||
assert_eq!(q.elements, p.elements);
|
||||
if let Some(SharedBody::DataType {
|
||||
definition: DataTypeDefinition::Real { maximum, .. },
|
||||
..
|
||||
}) = q.elements.get_mut(&source).unwrap().shared_mut()
|
||||
{
|
||||
*maximum = Some(30.);
|
||||
}
|
||||
let repeated = plan_semantic_transition(&q, keep).unwrap();
|
||||
assert!(
|
||||
repeated
|
||||
.conflicts
|
||||
.iter()
|
||||
.any(|c| c.contains("No constructor switch"))
|
||||
);
|
||||
assert!(apply_semantic_transition(&q, &repeated).is_err());
|
||||
assert_eq!(
|
||||
drift(&q, t, &maximum),
|
||||
if reverse {
|
||||
Drift::SaChanged
|
||||
} else {
|
||||
Drift::OaChanged
|
||||
}
|
||||
);
|
||||
let take = request(
|
||||
oa,
|
||||
sa,
|
||||
reverse,
|
||||
vec![(constructor, SemanticDecision::TakeSource)],
|
||||
);
|
||||
let (p, _) = apply(&q, take.clone());
|
||||
assert!(matches!(
|
||||
p.elements[&target].shared(),
|
||||
Some(SharedBody::DataType {
|
||||
definition: DataTypeDefinition::Real {
|
||||
maximum: Some(30.),
|
||||
..
|
||||
},
|
||||
..
|
||||
})
|
||||
));
|
||||
let completed = plan_semantic_transition(&p, take).unwrap();
|
||||
assert!(
|
||||
completed
|
||||
.conflicts
|
||||
.iter()
|
||||
.any(|c| c.contains("No constructor switch"))
|
||||
);
|
||||
assert!(apply_semantic_transition(&p, &completed).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
use oa_core::{language, persistence, source_project::*, *};
|
||||
use serde_json::Value;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
const JSON4: &[u8] = include_bytes!("fixtures/v4-reviewed-prose.json");
|
||||
const SOURCE4: &str = include_str!("fixtures/v4-reviewed-prose.arcadia");
|
||||
|
||||
#[test]
|
||||
fn frozen_v4_json_and_source_preserve_the_old_review_without_fabricating_semantics() {
|
||||
let json = persistence::decode_with_report(JSON4).unwrap();
|
||||
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, oa_core::SCHEMA_VERSION);
|
||||
let report = decoded.migration.unwrap();
|
||||
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());
|
||||
assert_eq!(baseline.oa.name, "Approved OA name");
|
||||
assert_eq!(baseline.oa.description, "Approved OA\nprose");
|
||||
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"] = 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())
|
||||
.unwrap();
|
||||
assert!(reopened.migration.is_none());
|
||||
assert_eq!(reopened.project, decoded.project);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn older_headers_cannot_admit_new_semantic_baselines() {
|
||||
let mut value: Value = serde_json::from_slice(JSON4).unwrap();
|
||||
let trace = value["traces"]
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.values_mut()
|
||||
.next()
|
||||
.unwrap();
|
||||
trace["baseline"]["semantic"] = serde_json::json!({"facets": []});
|
||||
for version in [3, 4] {
|
||||
value["schema_version"] = version.into();
|
||||
let error = persistence::decode(&serde_json::to_vec(&value).unwrap())
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(error.contains("semantic"), "{error}");
|
||||
}
|
||||
let source = SOURCE4.replace("baseline {", "baseline { semantic { facets []; };");
|
||||
assert!(
|
||||
language::parse(&source)
|
||||
.unwrap_err()
|
||||
.iter()
|
||||
.any(|d| d.message.contains("require schema/source5"))
|
||||
);
|
||||
let bundle = SourceBundle {
|
||||
entry: "project.arcadia".into(),
|
||||
files: BTreeMap::from([("project.arcadia".into(), source)]),
|
||||
};
|
||||
assert!(
|
||||
compile_bundle(&bundle, ProjectLimits::default())
|
||||
.unwrap_err()
|
||||
.iter()
|
||||
.any(|d| d.diagnostic.message.contains("require schema/source5"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frozen_v4_trace_envelope_keeps_duplicate_and_unknown_field_rejection() {
|
||||
let json = std::str::from_utf8(JSON4).unwrap();
|
||||
let duplicate = json.replace("\"baseline\": {", "\"baseline\": null, \"baseline\": {");
|
||||
assert!(
|
||||
persistence::decode(duplicate.as_bytes())
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("duplicate field")
|
||||
);
|
||||
let unknown = json.replace("\"baseline\": {", "\"baseline\": { \"reviewed\": true,");
|
||||
assert!(
|
||||
persistence::decode(unknown.as_bytes())
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("unknown field")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authored_v4_modules_upgrade_only_the_version_and_preserve_nested_comments() {
|
||||
let start = SOURCE4.find(" oa_data_type").unwrap();
|
||||
let end = SOURCE4.rfind('}').unwrap();
|
||||
let entry = format!(
|
||||
"{} include \"contracts.arcadia\";\n}}\n",
|
||||
&SOURCE4[..start]
|
||||
);
|
||||
let mut module =
|
||||
SOURCE4[start..end].replace("baseline {", "baseline { // original review rationale");
|
||||
module.push_str(
|
||||
r#"
|
||||
oa_data_type record "Independent record" id "40000000-0000-4000-8000-000000000011" {
|
||||
definition { Record { supertype none; }; };
|
||||
// This field remains inside its authored record.
|
||||
oa_data_field flag "Flag" id "40000000-0000-4000-8000-000000000012" { type_ref Boolean; }
|
||||
}
|
||||
"#,
|
||||
);
|
||||
let bundle = SourceBundle {
|
||||
entry: "project.arcadia".into(),
|
||||
files: BTreeMap::from([
|
||||
("project.arcadia".into(), entry.clone()),
|
||||
("contracts.arcadia".into(), module.clone()),
|
||||
]),
|
||||
};
|
||||
let compiled = compile_bundle(&bundle, ProjectLimits::default()).unwrap();
|
||||
assert_eq!(compiled.bundle.files["contracts.arcadia"], module);
|
||||
assert_eq!(
|
||||
compiled.bundle.files["project.arcadia"],
|
||||
entry.replacen("arcadia 4;", &format!("arcadia {SCHEMA_VERSION};"), 1)
|
||||
);
|
||||
assert_eq!(
|
||||
compiled.index.lexical_owners
|
||||
[&Id::parse_str("40000000-0000-4000-8000-000000000012").unwrap()],
|
||||
Id::parse_str("40000000-0000-4000-8000-000000000011").unwrap()
|
||||
);
|
||||
let reopened = compile_bundle(&compiled.bundle, ProjectLimits::default()).unwrap();
|
||||
assert!(reopened.decoded.migration.is_none());
|
||||
assert_eq!(reopened.bundle, compiled.bundle);
|
||||
assert_eq!(reopened.decoded.project, compiled.decoded.project);
|
||||
assert_eq!(
|
||||
bundle.files["project.arcadia"], entry,
|
||||
"caller draft is unchanged"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn original_v1_v2_v3_inputs_complete_the_explicit_upgrade_to_five() {
|
||||
for (version, bytes) in [
|
||||
(
|
||||
1,
|
||||
include_bytes!("../../../examples/legacy-v1-coastal-rescue.oa.json").as_slice(),
|
||||
),
|
||||
(
|
||||
2,
|
||||
include_bytes!("fixtures/v2-coastal-rescue.json").as_slice(),
|
||||
),
|
||||
(
|
||||
3,
|
||||
include_bytes!("fixtures/v3-data-contract.json").as_slice(),
|
||||
),
|
||||
] {
|
||||
let decoded = persistence::decode_with_report(bytes).unwrap();
|
||||
let report = decoded.migration.unwrap();
|
||||
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
|
||||
.traces
|
||||
.values()
|
||||
.all(|t| t.baseline.as_ref().is_none_or(|b| b.semantic.is_none()))
|
||||
);
|
||||
assert_eq!(
|
||||
language::parse(&language::format(&decoded.project)).unwrap(),
|
||||
decoded.project
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,431 @@
|
||||
use oa_core::{language, persistence, semantic::*, source_project::*, *};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
fn id(n: u128) -> Id {
|
||||
Id::from_u128(0x40000000_0000_4000_8000_000000000000 + n)
|
||||
}
|
||||
|
||||
fn reviewed<T>(oa: T, sa: T) -> Reviewed<T> {
|
||||
Reviewed {
|
||||
oa: Observed::Value(oa),
|
||||
sa: Observed::Value(sa),
|
||||
}
|
||||
}
|
||||
|
||||
fn project(facets: Vec<FacetReview>) -> Project {
|
||||
let mut project =
|
||||
persistence::decode(include_bytes!("fixtures/v4-reviewed-prose.json")).unwrap();
|
||||
project
|
||||
.traces
|
||||
.get_mut(&id(4))
|
||||
.unwrap()
|
||||
.baseline
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.semantic = Some(SemanticBaseline { facets });
|
||||
project
|
||||
}
|
||||
|
||||
fn facets(project: &mut Project) -> &mut Vec<FacetReview> {
|
||||
&mut project
|
||||
.traces
|
||||
.get_mut(&id(4))
|
||||
.unwrap()
|
||||
.baseline
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.semantic
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.facets
|
||||
}
|
||||
|
||||
fn compile(source: String) -> CompiledSourceProject {
|
||||
compile_bundle(
|
||||
&SourceBundle {
|
||||
entry: "project.arcadia".into(),
|
||||
files: BTreeMap::from([("project.arcadia".into(), source)]),
|
||||
},
|
||||
ProjectLimits::default(),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typed_historical_facets_roundtrip_deleted_references_and_all_leaf_categories() {
|
||||
let row = MessageValue {
|
||||
path: vec![id(91), id(92)],
|
||||
field: id(93),
|
||||
expression: "x + 1".into(),
|
||||
};
|
||||
let model = project(vec![
|
||||
FacetReview::Data(DataReview::Constructor(reviewed(
|
||||
DataConstructor::Integer,
|
||||
DataConstructor::Real,
|
||||
))),
|
||||
FacetReview::Data(DataReview::IntegerMinimum(Reviewed {
|
||||
oa: Observed::Value(None),
|
||||
sa: Observed::NotApplicable,
|
||||
})),
|
||||
FacetReview::Data(DataReview::RealMaximum(Reviewed {
|
||||
oa: Observed::NotApplicable,
|
||||
sa: Observed::Value(Some(1.5)),
|
||||
})),
|
||||
FacetReview::Field(FieldReview::Type(reviewed(
|
||||
TypeRef::Named(id(90)),
|
||||
TypeRef::Boolean,
|
||||
))),
|
||||
FacetReview::Field(FieldReview::Default(reviewed(
|
||||
Some(vec![DataValue::String(id(90).to_string())]),
|
||||
None,
|
||||
))),
|
||||
FacetReview::Member {
|
||||
collection: ReferenceCollection::Steps,
|
||||
keys: KeyPair {
|
||||
oa: Some(id(91)),
|
||||
sa: None,
|
||||
},
|
||||
review: reviewed(true, false),
|
||||
},
|
||||
FacetReview::Order {
|
||||
collection: OrderedCollection::Steps,
|
||||
review: reviewed(vec![id(91), id(92)], vec![]),
|
||||
},
|
||||
FacetReview::Annotation {
|
||||
keys: KeyPair {
|
||||
oa: Some(AnnotationKey::from(&row)),
|
||||
sa: None,
|
||||
},
|
||||
review: reviewed(Some(row), None),
|
||||
},
|
||||
FacetReview::Binding {
|
||||
keys: KeyPair {
|
||||
oa: Some(id(94)),
|
||||
sa: None,
|
||||
},
|
||||
review: reviewed(
|
||||
Some(LifelineBinding {
|
||||
referenced: id(94),
|
||||
local: id(95),
|
||||
}),
|
||||
None,
|
||||
),
|
||||
},
|
||||
FacetReview::Literal {
|
||||
keys: KeyPair {
|
||||
oa: Some(id(90).to_string()),
|
||||
sa: None,
|
||||
},
|
||||
review: reviewed(true, false),
|
||||
},
|
||||
FacetReview::Artifact {
|
||||
keys: KeyPair {
|
||||
oa: Some(id(96)),
|
||||
sa: None,
|
||||
},
|
||||
review: ArtifactReview::Relation(reviewed(
|
||||
ArtifactRelation::References,
|
||||
ArtifactRelation::References,
|
||||
)),
|
||||
},
|
||||
]);
|
||||
assert!(!model.contains(id(90)));
|
||||
let source = language::format(&model);
|
||||
assert!(source.starts_with(&format!("arcadia {SCHEMA_VERSION};")));
|
||||
assert!(
|
||||
source.contains(&format!("Named uuid \"{}\"", id(90))),
|
||||
"{source}"
|
||||
);
|
||||
assert!(source.contains("sa NotApplicable;"));
|
||||
assert!(source.contains("Value none;"));
|
||||
assert!(source.contains(&format!("String \"{}\"", id(90))));
|
||||
assert_eq!(language::parse(&source).unwrap(), model);
|
||||
assert_eq!(compile(source).decoded.project, model);
|
||||
assert_eq!(
|
||||
persistence::decode(&serde_json::to_vec(&model).unwrap()).unwrap(),
|
||||
model
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn historical_uuid_and_enum_tokens_are_contextual_and_text_remains_quoted() {
|
||||
let model = project(vec![
|
||||
FacetReview::Field(FieldReview::Type(reviewed(
|
||||
TypeRef::Named(id(2)),
|
||||
TypeRef::Boolean,
|
||||
))),
|
||||
FacetReview::Data(DataReview::Unit(reviewed(id(2).to_string(), "m".into()))),
|
||||
]);
|
||||
let source = language::format(&model);
|
||||
let reference = format!("Named uuid \"{}\"", id(2));
|
||||
for replacement in [format!("Named \"{}\"", id(2)), "Named @oa_dimension".into()] {
|
||||
let invalid = source.replace(&reference, &replacement);
|
||||
assert!(language::parse(&invalid).is_err(), "{invalid}");
|
||||
assert!(
|
||||
compile_bundle(
|
||||
&SourceBundle {
|
||||
entry: "p.arcadia".into(),
|
||||
files: BTreeMap::from([("p.arcadia".into(), invalid)])
|
||||
},
|
||||
ProjectLimits::default()
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
let invalid_text = source.replace("Value \"m\";", &format!("Value uuid \"{}\";", id(2)));
|
||||
assert!(language::parse(&invalid_text).is_err());
|
||||
let invalid_enum = source.replace("Value Boolean;", "Value \"Boolean\";");
|
||||
assert!(language::parse(&invalid_enum).is_err());
|
||||
let invalid_live = source.replace("oa @oa_dimension;", &format!("oa uuid \"{}\";", id(2)));
|
||||
assert!(language::parse(&invalid_live).is_err());
|
||||
let invalid_prose = source.replace(
|
||||
"name \"Approved OA name\";",
|
||||
&format!("name uuid \"{}\";", id(2)),
|
||||
);
|
||||
assert!(language::parse(&invalid_prose).is_err());
|
||||
// `uuid` remains an ordinary legal declaration alias after @.
|
||||
assert!(language::parse(&source.replace("oa_dimension", "uuid")).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn semantic_values_remain_closed_typed_records_in_both_formats() {
|
||||
let model = project(vec![FacetReview::Data(DataReview::IntegerMinimum(
|
||||
reviewed(Some(0), None),
|
||||
))]);
|
||||
let source = language::format(&model);
|
||||
for invalid in [
|
||||
source.replace("IntegerMinimum", "UnknownFacet"),
|
||||
source.replace("Value 0;", "Value \"0\";"),
|
||||
source.replace("facets [", "unexpected true; facets ["),
|
||||
] {
|
||||
assert!(language::parse(&invalid).is_err(), "{invalid}");
|
||||
}
|
||||
let mut json = serde_json::to_value(&model).unwrap();
|
||||
json["traces"][id(4).to_string()]["baseline"]["semantic"] = serde_json::json!({"facets": [{"Data": {"IntegerMinimum": {"oa": {"Value": "0"}, "sa": {"Value": null}}}}]});
|
||||
assert!(persistence::decode(&serde_json::to_vec(&json).unwrap()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn facet_reordering_and_reidentification_keep_comments_with_typed_row_identity() {
|
||||
let row = MessageValue {
|
||||
path: vec![id(2)],
|
||||
field: id(90),
|
||||
expression: id(2).to_string(),
|
||||
};
|
||||
let model = project(vec![
|
||||
FacetReview::Annotation {
|
||||
keys: KeyPair {
|
||||
oa: Some(AnnotationKey::from(&row)),
|
||||
sa: None,
|
||||
},
|
||||
review: reviewed(Some(row), None),
|
||||
},
|
||||
FacetReview::Literal {
|
||||
keys: KeyPair {
|
||||
oa: Some(id(2).to_string()),
|
||||
sa: None,
|
||||
},
|
||||
review: reviewed(true, false),
|
||||
},
|
||||
FacetReview::Field(FieldReview::Type(reviewed(
|
||||
TypeRef::Named(id(2)),
|
||||
TypeRef::Boolean,
|
||||
))),
|
||||
]);
|
||||
let source = language::format(&model)
|
||||
.replace("Annotation {", "Annotation { // annotation rationale")
|
||||
.replace("Literal {", "Literal { // literal rationale")
|
||||
.replace("Named uuid ", "Named uuid // historical type rationale\n");
|
||||
let current = compile(source);
|
||||
let mut prepared = prepare_command(
|
||||
¤t.decoded.project,
|
||||
&Command::Reidentify(id(2), id(20)),
|
||||
)
|
||||
.unwrap();
|
||||
facets(&mut prepared.project).reverse();
|
||||
let updated = update_bundle_with_identity_map(
|
||||
¤t,
|
||||
&prepared.project,
|
||||
&prepared.identity_map,
|
||||
ProjectLimits::default(),
|
||||
)
|
||||
.unwrap();
|
||||
let text = &updated.bundle.files["project.arcadia"];
|
||||
assert_eq!(updated.decoded.project, prepared.project);
|
||||
assert!(
|
||||
text.contains(&format!(
|
||||
"Named uuid // historical type rationale\n\"{}\"",
|
||||
id(20)
|
||||
)),
|
||||
"{text}"
|
||||
);
|
||||
let type_note = text.find("// historical type rationale").unwrap();
|
||||
let literal_note = text.find("// literal rationale").unwrap();
|
||||
let annotation_note = text.find("// annotation rationale").unwrap();
|
||||
assert!(
|
||||
type_note < literal_note && literal_note < annotation_note,
|
||||
"{text}"
|
||||
);
|
||||
assert!(text.contains(&format!("expression \"{}\"", id(2))));
|
||||
let FacetReview::Literal { keys, .. } = &facets(&mut prepared.project)[1] else {
|
||||
panic!()
|
||||
};
|
||||
assert_eq!(
|
||||
keys.oa,
|
||||
Some(id(2).to_string()),
|
||||
"UUID-shaped literal keys are text"
|
||||
);
|
||||
assert_eq!(
|
||||
compile_bundle(&updated.bundle, ProjectLimits::default())
|
||||
.unwrap()
|
||||
.bundle,
|
||||
updated.bundle
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn historical_reference_deletion_does_not_remove_the_review_or_create_a_live_alias() {
|
||||
let mut model = project(vec![FacetReview::Field(FieldReview::Type(reviewed(
|
||||
TypeRef::Named(id(90)),
|
||||
TypeRef::Boolean,
|
||||
)))]);
|
||||
let mut former_type = model.elements[&id(2)].clone();
|
||||
former_type.id = id(90);
|
||||
former_type.alias = "former_type".into();
|
||||
model.elements.insert(id(90), former_type);
|
||||
let current = compile(language::format(&model));
|
||||
let prepared = prepare_command(¤t.decoded.project, &Command::Delete(id(90))).unwrap();
|
||||
let updated = update_bundle_with_identity_map(
|
||||
¤t,
|
||||
&prepared.project,
|
||||
&prepared.identity_map,
|
||||
ProjectLimits::default(),
|
||||
)
|
||||
.unwrap();
|
||||
let source = &updated.bundle.files["project.arcadia"];
|
||||
assert!(!updated.decoded.project.contains(id(90)));
|
||||
assert!(!source.contains("@former_type"));
|
||||
assert!(source.contains(&format!("Named uuid \"{}\"", id(90))));
|
||||
assert_eq!(updated.decoded.project.traces, model.traces);
|
||||
assert_eq!(language::parse(source).unwrap(), prepared.project);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn included_historical_review_edits_save_and_reopen_without_rewriting_other_modules() {
|
||||
use oa_core::source_files::{load_project, save_project};
|
||||
let model = project(vec![FacetReview::Field(FieldReview::Type(reviewed(
|
||||
TypeRef::Named(id(90)),
|
||||
TypeRef::Boolean,
|
||||
)))]);
|
||||
let source = language::format(&model);
|
||||
let trace_at = source.find(" trace ").unwrap();
|
||||
let end = source.rfind('}').unwrap();
|
||||
let entry = format!(
|
||||
"{} include \"reviews.arcadia\";\n}}\n",
|
||||
&source[..trace_at]
|
||||
);
|
||||
let review =
|
||||
source[trace_at..end].replace("Named uuid ", "Named uuid // deleted type retained\n");
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let entry_path = directory.path().join("project.arcadia");
|
||||
std::fs::write(&entry_path, &entry).unwrap();
|
||||
std::fs::write(directory.path().join("reviews.arcadia"), &review).unwrap();
|
||||
let snapshot = load_project(&entry_path, ProjectLimits::default()).unwrap();
|
||||
let compiled = compile_bundle(&snapshot.reachable_bundle(), ProjectLimits::default()).unwrap();
|
||||
assert_eq!(compiled.index.declarations[&id(4)].path, "reviews.arcadia");
|
||||
let mut changed = compiled.decoded.project.clone();
|
||||
let FacetReview::Field(FieldReview::Type(value)) = &mut facets(&mut changed)[0] else {
|
||||
panic!()
|
||||
};
|
||||
value.sa = Observed::Value(TypeRef::String);
|
||||
let updated = update_bundle(&compiled, &changed, ProjectLimits::default()).unwrap();
|
||||
assert_eq!(updated.bundle.files["project.arcadia"], entry);
|
||||
let saved = save_project(&snapshot, &updated, ProjectLimits::default()).unwrap();
|
||||
let reopened = load_project(&entry_path, ProjectLimits::default()).unwrap();
|
||||
assert_eq!(saved.bundle, reopened.bundle);
|
||||
assert_eq!(
|
||||
compile_bundle(&reopened.reachable_bundle(), ProjectLimits::default())
|
||||
.unwrap()
|
||||
.decoded
|
||||
.project,
|
||||
changed
|
||||
);
|
||||
assert_eq!(std::fs::read_to_string(&entry_path).unwrap(), entry);
|
||||
assert!(reopened.bundle.files["reviews.arcadia"].contains("uuid // deleted type retained"));
|
||||
assert!(!changed.contains(id(90)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prepared_semantic_review_writes_back_and_preserves_the_accepted_target_difference() {
|
||||
use oa_core::transitions::{TransitionDirection, TransitionMapping, TransitionRequest};
|
||||
let model = project(vec![]);
|
||||
let current = compile(language::format(&model).replace(
|
||||
"baseline {",
|
||||
"baseline { // prior prose review is independent",
|
||||
));
|
||||
let mut transition = TransitionRequest::new(TransitionDirection::OaToSa, vec![id(2)]);
|
||||
transition.mappings.push(TransitionMapping {
|
||||
source: id(2),
|
||||
target: Some(id(3)),
|
||||
target_kind: None,
|
||||
});
|
||||
let mut request = SemanticTransitionRequest::new(transition);
|
||||
request.reviews.push(SemanticReviewRequest {
|
||||
source: id(2),
|
||||
resolutions: vec![
|
||||
FacetResolution {
|
||||
facet: FacetSelector::Data(DataFacet::Unit),
|
||||
decision: SemanticDecision::KeepTarget,
|
||||
},
|
||||
FacetResolution {
|
||||
facet: FacetSelector::Header(HeaderFacet::Name),
|
||||
decision: SemanticDecision::TakeSource,
|
||||
},
|
||||
],
|
||||
reset: vec![],
|
||||
});
|
||||
let plan = plan_semantic_transition(¤t.decoded.project, request).unwrap();
|
||||
assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts);
|
||||
let prepared = prepare_semantic_transition(¤t.decoded.project, &plan).unwrap();
|
||||
let updated = update_bundle_with_identity_map(
|
||||
¤t,
|
||||
&prepared.project,
|
||||
&prepared.identity_map,
|
||||
ProjectLimits::default(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
updated.decoded.project.elements[&id(3)].name,
|
||||
"OA dimension now"
|
||||
);
|
||||
assert!(
|
||||
matches!(updated.decoded.project.elements[&id(3)].shared(), Some(SharedBody::DataType { unit, .. }) if unit == "ft")
|
||||
);
|
||||
assert!(
|
||||
updated.bundle.files["project.arcadia"].contains("// prior prose review is independent")
|
||||
);
|
||||
let baseline = updated.decoded.project.traces[&id(4)]
|
||||
.baseline
|
||||
.as_ref()
|
||||
.unwrap();
|
||||
assert_eq!(baseline.oa.name, "Approved OA name");
|
||||
assert!(
|
||||
baseline
|
||||
.semantic
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.facets
|
||||
.contains(&FacetReview::Data(DataReview::Unit(reviewed(
|
||||
"m".into(),
|
||||
"ft".into()
|
||||
))))
|
||||
);
|
||||
assert_eq!(
|
||||
language::parse(&language::format(&updated.decoded.project)).unwrap(),
|
||||
prepared.project
|
||||
);
|
||||
assert_eq!(
|
||||
persistence::decode(&serde_json::to_vec(&updated.decoded.project).unwrap()).unwrap(),
|
||||
prepared.project
|
||||
);
|
||||
}
|
||||
@@ -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, 4));
|
||||
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 4;"));
|
||||
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, 4);
|
||||
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, 4);
|
||||
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 4;"));
|
||||
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![],
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -5,7 +5,7 @@ fn setup() -> tempfile::TempDir {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
fs::write(
|
||||
directory.path().join("project.arcadia"),
|
||||
"arcadia 4; project p \"P\" { include \"parts/a.arcadia\"; }\n",
|
||||
"arcadia 5; project p \"P\" { include \"parts/a.arcadia\"; }\n",
|
||||
)
|
||||
.unwrap();
|
||||
fs::create_dir(directory.path().join("parts")).unwrap();
|
||||
@@ -59,7 +59,7 @@ fn loading_keeps_original_no_uuid_bytes_and_saving_stabilizes_exactly_once() {
|
||||
fn invalid_and_missing_includes_keep_editable_text_and_located_diagnostics() {
|
||||
let directory = setup();
|
||||
let entry = directory.path().join("project.arcadia");
|
||||
fs::write(&entry, "arcadia 4; project p \"P\" {\n include \"missing.arcadia\";\n include \"parts/a.arcadia\";\n}\n").unwrap();
|
||||
fs::write(&entry, "arcadia 5; project p \"P\" {\n include \"missing.arcadia\";\n include \"parts/a.arcadia\";\n}\n").unwrap();
|
||||
fs::write(
|
||||
directory.path().join("parts/a.arcadia"),
|
||||
"oa_activity a \"A\" { invalid",
|
||||
@@ -102,7 +102,7 @@ fn discovery_never_refreshes_drafts_and_preserves_detached_buffers() {
|
||||
let overlays = BTreeMap::from([
|
||||
(
|
||||
"project.arcadia".into(),
|
||||
"arcadia 4; project p \"P\" { include \"new.arcadia\"; }".into(),
|
||||
"arcadia 5; project p \"P\" { include \"new.arcadia\"; }".into(),
|
||||
),
|
||||
(
|
||||
"parts/a.arcadia".into(),
|
||||
@@ -176,7 +176,7 @@ fn new_destinations_require_explicit_missing_expectations_and_reject_collisions(
|
||||
files: BTreeMap::from([
|
||||
(
|
||||
"project.arcadia".into(),
|
||||
"arcadia 4; project p \"P\" { include \"child.arcadia\"; }".into(),
|
||||
"arcadia 5; project p \"P\" { include \"child.arcadia\"; }".into(),
|
||||
),
|
||||
("child.arcadia".into(), "oa_activity a \"A\" {}".into()),
|
||||
]),
|
||||
@@ -256,7 +256,7 @@ fn physical_hard_link_aliases_are_rejected() {
|
||||
.unwrap();
|
||||
fs::write(
|
||||
directory.path().join("project.arcadia"),
|
||||
"arcadia 4; project p \"P\" { include \"parts/a.arcadia\"; include \"alias.arcadia\"; }",
|
||||
"arcadia 5; project p \"P\" { include \"parts/a.arcadia\"; include \"alias.arcadia\"; }",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
@@ -286,7 +286,7 @@ fn symlinks_to_outside_or_inside_project_are_rejected() {
|
||||
std::os::unix::fs::symlink(&destination, &link).unwrap();
|
||||
fs::write(
|
||||
directory.path().join("project.arcadia"),
|
||||
"arcadia 4; project p \"P\" { include \"alias.arcadia\"; }",
|
||||
"arcadia 5; project p \"P\" { include \"alias.arcadia\"; }",
|
||||
)
|
||||
.unwrap();
|
||||
let loaded = load_project(
|
||||
|
||||
@@ -6,7 +6,7 @@ fn source() -> CompiledSourceProject {
|
||||
&SourceBundle {
|
||||
entry: "project.arcadia".into(),
|
||||
files: BTreeMap::from([
|
||||
("project.arcadia".into(), "arcadia 4; project p \"Identity review\" { include \"team/first.arcadia\"; include \"team/second.arcadia\"; include \"contracts.arcadia\"; }".into()),
|
||||
("project.arcadia".into(), "arcadia 5; project p \"Identity review\" { include \"team/first.arcadia\"; include \"team/second.arcadia\"; include \"contracts.arcadia\"; }".into()),
|
||||
("team/first.arcadia".into(), "// Rationale for first\nsa_exchange_item first \"First\" { description \"First rationale\"; }\n".into()),
|
||||
("team/second.arcadia".into(), "// Rationale for second\nsa_exchange_item second \"Second\" {}\n".into()),
|
||||
("contracts.arcadia".into(), "sa_event contract \"Contract\" { body { Signal { items [\n @first // first item rationale\n ,\n @second // second item rationale\n ]; }; }; }\n".into()),
|
||||
|
||||
@@ -5,7 +5,7 @@ fn compile(a: &str, b: &str) -> CompiledSourceProject {
|
||||
compile_bundle(&SourceBundle {
|
||||
entry: "project.arcadia".into(),
|
||||
files: BTreeMap::from([
|
||||
("project.arcadia".into(), "arcadia 4; project p \"Nested writeback\" { include \"a.arcadia\"; include \"b.arcadia\"; }\n".into()),
|
||||
("project.arcadia".into(), "arcadia 5; project p \"Nested writeback\" { include \"a.arcadia\"; include \"b.arcadia\"; }\n".into()),
|
||||
("a.arcadia".into(), a.into()), ("b.arcadia".into(), b.into()),
|
||||
]),
|
||||
}, ProjectLimits::default()).unwrap()
|
||||
|
||||
@@ -30,7 +30,7 @@ fn id(project: &Project, alias: &str) -> Id {
|
||||
#[test]
|
||||
fn route_edits_keep_diagram_file_ownership_and_comments_with_edge_identity() {
|
||||
let input = bundle(
|
||||
"arcadia 4; project p \"Routing\" { include \"views.arcadia\"; include \"work.arcadia\"; }",
|
||||
"arcadia 5; project p \"Routing\" { include \"views.arcadia\"; include \"work.arcadia\"; }",
|
||||
&[
|
||||
(
|
||||
"work.arcadia",
|
||||
@@ -95,7 +95,7 @@ diagram review "Review" {
|
||||
#[test]
|
||||
fn invalid_cross_file_route_references_and_geometry_locate_the_authored_view() {
|
||||
let mut input = bundle(
|
||||
"arcadia 4; project p \"Routing\" { include \"views.arcadia\"; include \"work.arcadia\"; }",
|
||||
"arcadia 5; project p \"Routing\" { include \"views.arcadia\"; include \"work.arcadia\"; }",
|
||||
&[
|
||||
(
|
||||
"work.arcadia",
|
||||
@@ -127,7 +127,7 @@ fn invalid_cross_file_route_references_and_geometry_locate_the_authored_view() {
|
||||
|
||||
fn typed() -> SourceBundle {
|
||||
bundle(
|
||||
"arcadia 4; project test_model \"航海\" {\n include \"exchanges.arcadia\";\n include \"work/send.arcadia\";\n include \"work/receive.arcadia\";\n include \"types.arcadia\";\n}",
|
||||
"arcadia 5; project test_model \"航海\" {\n include \"exchanges.arcadia\";\n include \"work/send.arcadia\";\n include \"work/receive.arcadia\";\n include \"types.arcadia\";\n}",
|
||||
&[
|
||||
(
|
||||
"exchanges.arcadia",
|
||||
@@ -157,7 +157,19 @@ fn cross_file_typed_ports_global_forward_references_and_stable_ids() {
|
||||
assert_eq!(again.decoded.project, result.decoded.project);
|
||||
assert_eq!(again.bundle, result.bundle);
|
||||
assert_eq!(again.index, result.index);
|
||||
assert!(result.decoded.migration.is_none());
|
||||
let migration = result.decoded.migration.as_ref().unwrap();
|
||||
assert_eq!(
|
||||
(migration.from_schema, migration.to_schema),
|
||||
(5, oa_core::SCHEMA_VERSION)
|
||||
);
|
||||
assert!(input.files[&input.entry].contains("arcadia 5;"));
|
||||
assert!(
|
||||
result.bundle.files[&result.bundle.entry].contains(&format!("arcadia {SCHEMA_VERSION};"))
|
||||
);
|
||||
assert!(
|
||||
again.decoded.migration.is_none(),
|
||||
"stabilized current source must not migrate twice"
|
||||
);
|
||||
let project = &result.decoded.project;
|
||||
assert_eq!(
|
||||
project.relations[&id(project, "message")].source(),
|
||||
@@ -194,7 +206,7 @@ fn cross_file_typed_ports_global_forward_references_and_stable_ids() {
|
||||
#[test]
|
||||
fn advisory_lint_locations_refer_to_raw_drafts_before_identity_stabilization() {
|
||||
let input = bundle(
|
||||
"arcadia 4; project p \"P\" { include \"work.arcadia\"; }",
|
||||
"arcadia 5; project p \"P\" { include \"work.arcadia\"; }",
|
||||
&[(
|
||||
"work.arcadia",
|
||||
"sa_function first \"First\" {}\nsa_function second \"Second\" {}\n",
|
||||
@@ -228,7 +240,7 @@ fn advisory_lint_locations_refer_to_raw_drafts_before_identity_stabilization() {
|
||||
#[test]
|
||||
fn source_errors_keep_exact_fragment_unicode_positions() {
|
||||
let input = bundle(
|
||||
"arcadia 4; project p \"P\" { include \"child.arcadia\"; }",
|
||||
"arcadia 5; project p \"P\" { include \"child.arcadia\"; }",
|
||||
&[(
|
||||
"child.arcadia",
|
||||
"// Пример\nsa_function child \"航海\" { parent @missing; }",
|
||||
@@ -274,7 +286,7 @@ fn duplicate_alias_and_identity_report_both_files_including_project_identity() {
|
||||
),
|
||||
] {
|
||||
let input = bundle(
|
||||
"arcadia 4; project p \"P\" { include \"a.arcadia\"; include \"b.arcadia\"; }",
|
||||
"arcadia 5; project p \"P\" { include \"a.arcadia\"; include \"b.arcadia\"; }",
|
||||
&[("a.arcadia", left), ("b.arcadia", right)],
|
||||
);
|
||||
let error = compile_bundle(&input, ProjectLimits::default())
|
||||
@@ -285,7 +297,7 @@ fn duplicate_alias_and_identity_report_both_files_including_project_identity() {
|
||||
assert_eq!(error.related[0].path, "a.arcadia");
|
||||
}
|
||||
let input = bundle(
|
||||
"arcadia 4; project p \"P\" { include \"child.arcadia\"; }",
|
||||
"arcadia 5; project p \"P\" { include \"child.arcadia\"; }",
|
||||
&[("child.arcadia", "oa_activity p \"Collision\" {}")],
|
||||
);
|
||||
let error = compile_bundle(&input, ProjectLimits::default())
|
||||
@@ -297,7 +309,7 @@ fn duplicate_alias_and_identity_report_both_files_including_project_identity() {
|
||||
#[test]
|
||||
fn includes_are_once_only_and_cycles_missing_and_unreachable_files_are_located() {
|
||||
let mut input = bundle(
|
||||
"arcadia 4; project p \"P\" { include \"sub/a.arcadia\"; include \"sub/b.arcadia\"; }",
|
||||
"arcadia 5; project p \"P\" { include \"sub/a.arcadia\"; include \"sub/b.arcadia\"; }",
|
||||
&[
|
||||
("sub/a.arcadia", "include \"../shared.arcadia\";"),
|
||||
("sub/b.arcadia", "include \"../shared.arcadia\";"),
|
||||
@@ -356,7 +368,7 @@ fn portable_paths_reject_escapes_devices_urls_and_case_collisions() {
|
||||
);
|
||||
assert!(resolve_include("../bad.arcadia", "a.arcadia").is_err());
|
||||
let input = bundle(
|
||||
"arcadia 4; project p \"P\" {}",
|
||||
"arcadia 5; project p \"P\" {}",
|
||||
&[("A.arcadia", ""), ("a.arcadia", "")],
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -370,7 +382,7 @@ fn portable_paths_reject_escapes_devices_urls_and_case_collisions() {
|
||||
#[test]
|
||||
fn project_admission_is_aggregate_and_precedes_global_resolution() {
|
||||
let input = bundle(
|
||||
"arcadia 4; project p \"P\" { include \"a.arcadia\"; include \"b.arcadia\"; }",
|
||||
"arcadia 5; project p \"P\" { include \"a.arcadia\"; include \"b.arcadia\"; }",
|
||||
&[
|
||||
("a.arcadia", "oa_activity one \"One\" { parent @missing; }"),
|
||||
("b.arcadia", "oa_activity two \"Two\" {}"),
|
||||
@@ -591,7 +603,7 @@ fn failed_update_preserves_candidate_and_rejects_stale_index() {
|
||||
|
||||
#[test]
|
||||
fn single_string_and_legacy_apis_remain_explicit_and_filesystem_free() {
|
||||
let source = "arcadia 4; project p \"P\" { include \"missing.arcadia\"; }";
|
||||
let source = "arcadia 5; project p \"P\" { include \"missing.arcadia\"; }";
|
||||
assert!(
|
||||
language::parse(source).unwrap_err()[0]
|
||||
.message
|
||||
@@ -681,7 +693,7 @@ fn json_bundle_rejects_duplicate_file_keys_and_unknown_fields() {
|
||||
#[test]
|
||||
fn generated_id_insertion_handles_adjacent_header_tokens_and_remains_exactly_indexed() {
|
||||
let input = bundle(
|
||||
"arcadia 4;project p\"P\"{include \"child.arcadia\";}",
|
||||
"arcadia 5;project p\"P\"{include \"child.arcadia\";}",
|
||||
&[(
|
||||
"child.arcadia",
|
||||
"oa_activity a\"A\"{}diagram d\"D\"{}node n{diagram @d;element @a;}",
|
||||
@@ -703,7 +715,7 @@ fn generated_id_insertion_handles_adjacent_header_tokens_and_remains_exactly_ind
|
||||
#[test]
|
||||
fn duplicate_include_edges_count_toward_admission_and_ordered_references_are_aggregate() {
|
||||
let input = bundle(
|
||||
"arcadia 4; project p \"P\" { include \"a.arcadia\"; include \"a.arcadia\"; }",
|
||||
"arcadia 5; project p \"P\" { include \"a.arcadia\"; include \"a.arcadia\"; }",
|
||||
&[("a.arcadia", "")],
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -720,7 +732,7 @@ fn duplicate_include_edges_count_toward_admission_and_ordered_references_are_agg
|
||||
"project_limit"
|
||||
);
|
||||
let input = bundle(
|
||||
"arcadia 4; project p \"P\" { include \"a.arcadia\"; include \"b.arcadia\"; }",
|
||||
"arcadia 5; project p \"P\" { include \"a.arcadia\"; include \"b.arcadia\"; }",
|
||||
&[
|
||||
(
|
||||
"a.arcadia",
|
||||
@@ -763,7 +775,7 @@ fn include_inspection_reuses_strict_entry_fragment_syntax_and_real_spans() {
|
||||
&source[span.start..span.end],
|
||||
"include \"../shared.arcadia\";"
|
||||
);
|
||||
let entry = "arcadia 4; project p \"P\" { include \"sub/work.arcadia\"; }";
|
||||
let entry = "arcadia 5; project p \"P\" { include \"sub/work.arcadia\"; }";
|
||||
assert_eq!(
|
||||
inspect_includes("project.arcadia", entry, true, ProjectLimits::default()).unwrap()[0]
|
||||
.target,
|
||||
@@ -829,7 +841,7 @@ fn comments(source: &str) -> Vec<String> {
|
||||
|
||||
#[test]
|
||||
fn scalar_header_guard_and_project_edits_preserve_exact_crlf_trivia_and_escape_spelling() {
|
||||
let entry = "arcadia 4;\r\nproject p // project title\r\n \"Original project\" {\r\n description // project reason\r\n \"Before\";\r\n include \"behavior.arcadia\";\r\n}\r\n";
|
||||
let entry = "arcadia 5;\r\nproject p // project title\r\n \"Original project\" {\r\n description // project reason\r\n \"Before\";\r\n include \"behavior.arcadia\";\r\n}\r\n";
|
||||
let source = "oa_state_machine machine \"Machine\" {}\r\noa_region region_one \"Region\" { parent @machine; }\r\noa_state first \"First\" { parent @region_one; }\r\noa_state second \"Second\" { parent @region_one; }\r\nstate_transition route // route title\r\n \"Original transition\" {\r\n\tguard // feasibility\r\n\t \"old guard\"; // retain explanation\r\n\tdescription \"Keep\\u0020spelling\";\r\n\tsource @ // endpoint proof\r\n first; target @second;\r\n}\r\n";
|
||||
let current = compile(&bundle(entry, &[("behavior.arcadia", source)]));
|
||||
let mut project = current.decoded.project.clone();
|
||||
@@ -864,7 +876,7 @@ fn scalar_header_guard_and_project_edits_preserve_exact_crlf_trivia_and_escape_s
|
||||
#[test]
|
||||
fn alias_updates_touch_only_header_and_reference_tokens_even_with_comments_after_at() {
|
||||
let input = bundle(
|
||||
"arcadia 4; project p \"P\" { include \"work.arcadia\"; }",
|
||||
"arcadia 5; project p \"P\" { include \"work.arcadia\"; }",
|
||||
&[(
|
||||
"work.arcadia",
|
||||
"oa_activity root_work // root rationale\n \"Root\\u0020name\" { description \"root_work is prose\"; }\noa_activity child \"Child\" { parent @ // parent proof\n root_work; }\n",
|
||||
@@ -887,7 +899,7 @@ fn alias_updates_touch_only_header_and_reference_tokens_even_with_comments_after
|
||||
#[test]
|
||||
fn omitted_defaults_stay_omitted_and_new_fields_do_not_reformat_surviving_fields() {
|
||||
let input = bundle(
|
||||
"arcadia 4; project p \"P\" { include \"work.arcadia\"; }",
|
||||
"arcadia 5; project p \"P\" { include \"work.arcadia\"; }",
|
||||
&[(
|
||||
"work.arcadia",
|
||||
"oa_activity work \"Work\" {\r\n\t// owner rationale\r\n\tparent none ; // preserve spacing\r\n}\r\n",
|
||||
@@ -913,7 +925,7 @@ fn omitted_defaults_stay_omitted_and_new_fields_do_not_reformat_surviving_fields
|
||||
#[test]
|
||||
fn kind_change_removes_field_syntax_but_keeps_its_comments_and_other_fields() {
|
||||
let input = bundle(
|
||||
"arcadia 4; project p \"P\" { include \"item.arcadia\"; }",
|
||||
"arcadia 5; project p \"P\" { include \"item.arcadia\"; }",
|
||||
&[(
|
||||
"item.arcadia",
|
||||
"oa_exchange_item item \"Item\" {\n description \"Keep\\u0020this\" ; // description note\n kind // classification rationale\n Information ;\n parent none;\n}\n",
|
||||
@@ -953,7 +965,7 @@ fn significant_words(source: &str) -> Vec<String> {
|
||||
#[test]
|
||||
fn constructor_tag_changes_retain_shared_nested_fields_and_removed_field_comments() {
|
||||
let input = bundle(
|
||||
"arcadia 4; project p \"P\" { include \"event.arcadia\"; }",
|
||||
"arcadia 5; project p \"P\" { include \"event.arcadia\"; }",
|
||||
&[(
|
||||
"event.arcadia",
|
||||
"oa_event timer \"Timer\" { body { Time // time rationale\n { expression // expression rationale\n \"before\" ; kind After; // clock rationale\n }; }; }\n",
|
||||
@@ -986,7 +998,7 @@ fn constructor_tag_changes_retain_shared_nested_fields_and_removed_field_comment
|
||||
#[test]
|
||||
fn scalar_record_constructor_changes_keep_comments_inside_removed_values() {
|
||||
let input = bundle(
|
||||
"arcadia 4; project p \"P\" { include \"ports.arcadia\"; }",
|
||||
"arcadia 5; project p \"P\" { include \"ports.arcadia\"; }",
|
||||
&[(
|
||||
"ports.arcadia",
|
||||
"sa_function work \"Work\" {}\nsa_port input \"Input\" { parent @work; contract Legacy; // contract rationale\n legacy { direction In; // direction rationale\n interface none; // interface rationale\n }; }\n",
|
||||
@@ -1017,7 +1029,7 @@ fn scalar_record_constructor_changes_keep_comments_inside_removed_values() {
|
||||
#[test]
|
||||
fn reference_list_reordering_carries_item_comments_and_alias_spelling_with_identity() {
|
||||
let input = bundle(
|
||||
"arcadia 4; project p \"P\" { include \"types.arcadia\"; }",
|
||||
"arcadia 5; project p \"P\" { include \"types.arcadia\"; }",
|
||||
&[(
|
||||
"types.arcadia",
|
||||
"sa_exchange_item a \"A\" {} sa_exchange_item b \"B\" {}\nsa_event service \"Service\" { body { Signal { items [\n // before A\n @ // A reference rationale\n a // after A\n ,\n // before B\n @b // after B\n ]; }; }; }\n",
|
||||
@@ -1052,7 +1064,7 @@ fn reference_list_reordering_carries_item_comments_and_alias_spelling_with_ident
|
||||
#[test]
|
||||
fn removed_list_items_and_declarations_keep_comments_as_enclosing_annotations() {
|
||||
let input = bundle(
|
||||
"arcadia 4; project p \"P\" { include \"types.arcadia\"; }",
|
||||
"arcadia 5; project p \"P\" { include \"types.arcadia\"; }",
|
||||
&[(
|
||||
"types.arcadia",
|
||||
"sa_exchange_item a \"A\" { // declaration explanation\n } sa_exchange_item b \"B\" {}\nsa_event service \"Service\" { body { Signal { items [\n // explanation for removed A\n @a,\n // explanation for retained B\n @b ]; }; }; }\n",
|
||||
@@ -1104,7 +1116,7 @@ oa_occurrence message "Message" { parent @conversation; body { Exchange {
|
||||
}; }; }
|
||||
"#;
|
||||
let current = compile(&bundle(
|
||||
"arcadia 4; project p \"P\" { include \"conversation.arcadia\"; }",
|
||||
"arcadia 5; project p \"P\" { include \"conversation.arcadia\"; }",
|
||||
&[("conversation.arcadia", source)],
|
||||
));
|
||||
let mut project = current.decoded.project.clone();
|
||||
@@ -1153,7 +1165,7 @@ oa_occurrence message "Message" { parent @conversation; body { Exchange {
|
||||
#[test]
|
||||
fn added_list_items_empty_lists_and_literal_changes_preserve_existing_annotations() {
|
||||
let input = bundle(
|
||||
"arcadia 4; project p \"P\" { include \"types.arcadia\"; }",
|
||||
"arcadia 5; project p \"P\" { include \"types.arcadia\"; }",
|
||||
&[(
|
||||
"types.arcadia",
|
||||
"oa_data_type values \"Values\" { definition { Enumeration { literals [ // enumeration rationale\n ]; }; }; }\n",
|
||||
|
||||
@@ -18,6 +18,7 @@ fn view(p: &mut Project, machine: Id, alias: &str) -> Id {
|
||||
description: String::new(),
|
||||
layer: Layer::Oa,
|
||||
projection: ProjectionKind::StateMachine { machine },
|
||||
view_options: Default::default(),
|
||||
nodes: BTreeMap::new(),
|
||||
routes: vec![],
|
||||
},
|
||||
|
||||
@@ -42,6 +42,7 @@ fn make_trace(p: &mut Project, oa: Id, sa: Id) -> Id {
|
||||
baseline: Some(TraceBaseline {
|
||||
oa: p.properties(oa).unwrap(),
|
||||
sa: p.properties(sa).unwrap(),
|
||||
semantic: None,
|
||||
}),
|
||||
};
|
||||
p.traces.insert(id, t);
|
||||
|
||||
@@ -13,5 +13,15 @@ path = "src/main.rs"
|
||||
oa-core = { path = "../oa-core" }
|
||||
iced = { version = "0.14", default-features = false, features = ["wgpu", "canvas", "tokio", "x11", "wayland", "advanced"] }
|
||||
rfd = "0.15"
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||
tokio = { version = "1", features = ["rt", "time"] }
|
||||
tempfile = "3"
|
||||
unicode-segmentation = "1.13.3"
|
||||
|
||||
[dev-dependencies]
|
||||
iced_runtime = "0.14"
|
||||
oa-server = { path = "../oa-server" }
|
||||
axum = { version = "0.8", default-features = false, features = ["http1", "json", "tokio"] }
|
||||
tokio = { version = "1", features = ["macros", "rt-multi-thread", "net", "sync"] }
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
include!("../../build/windows_manifest.rs");
|
||||
+1189
-87
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,718 @@
|
||||
//! Named, view-local creation. A draft never enters the accepted model.
|
||||
use super::*;
|
||||
use iced::widget::{button, column, pick_list, row, scrollable, text, text_input};
|
||||
use serde::{Deserialize, Serialize};
|
||||
mod palette;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
pub use palette::view as palette;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Event {
|
||||
Start(Kind, Option<Position>),
|
||||
Allocate(Kind, Id, Option<Position>),
|
||||
Rename(crate::route_edit::Context, Id, Position),
|
||||
TogglePalette,
|
||||
Existing(Option<Position>),
|
||||
Name(String),
|
||||
Submit,
|
||||
Click(crate::route_edit::Context, Position),
|
||||
Pick(Id),
|
||||
Locate(Id),
|
||||
Search(String),
|
||||
Filter(Filter),
|
||||
ShowPlaced(bool),
|
||||
Page(usize),
|
||||
CapturedEscape,
|
||||
Cancel,
|
||||
}
|
||||
|
||||
pub fn subscription(app: &App) -> Subscription<Message> {
|
||||
if app.architecture.as_ref().is_some_and(State::inline) {
|
||||
iced::event::listen_with(captured_escape)
|
||||
} else {
|
||||
Subscription::none()
|
||||
}
|
||||
}
|
||||
|
||||
fn captured_escape(
|
||||
event: iced::Event,
|
||||
status: iced::event::Status,
|
||||
_: iced::window::Id,
|
||||
) -> Option<Message> {
|
||||
// A focused Iced text input captures Escape while dropping its focus. Only
|
||||
// inline architecture naming opts into also cancelling that same keypress.
|
||||
(status == iced::event::Status::Captured
|
||||
&& matches!(
|
||||
event,
|
||||
iced::Event::Keyboard(iced::keyboard::Event::KeyPressed {
|
||||
key: iced::keyboard::Key::Named(iced::keyboard::key::Named::Escape),
|
||||
..
|
||||
})
|
||||
))
|
||||
.then_some(Message::Architecture(Event::CapturedEscape))
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub enum Filter {
|
||||
Context,
|
||||
AllCompatible,
|
||||
Kind(Kind),
|
||||
}
|
||||
impl std::fmt::Display for Filter {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Context => f.write_str("Suggested for this view"),
|
||||
Self::AllCompatible => f.write_str("All compatible types"),
|
||||
Self::Kind(k) => f.write_str(k.label()),
|
||||
}
|
||||
}
|
||||
}
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub enum Intent {
|
||||
Create {
|
||||
kind: Kind,
|
||||
name: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
performer: Option<Id>,
|
||||
},
|
||||
Rename {
|
||||
element: Id,
|
||||
name: String,
|
||||
},
|
||||
Existing {
|
||||
selected: Option<Id>,
|
||||
},
|
||||
}
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Saved {
|
||||
pub version: u32,
|
||||
pub base: String,
|
||||
pub diagram: Id,
|
||||
pub intent: Intent,
|
||||
pub point: Option<Position>,
|
||||
pub placing: bool,
|
||||
pub query: String,
|
||||
pub filter: Filter,
|
||||
pub show_placed: bool,
|
||||
pub page: usize,
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct State {
|
||||
pub saved: Saved,
|
||||
document: Id,
|
||||
revision: u64,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
impl State {
|
||||
pub fn restore(app: &App, saved: Saved) -> Result<Self, String> {
|
||||
validate_saved(app.project(), &saved)?;
|
||||
Ok(Self {
|
||||
saved,
|
||||
document: app.canvas_document,
|
||||
revision: app.document.revision(),
|
||||
error: None,
|
||||
})
|
||||
}
|
||||
pub fn modal(&self) -> bool {
|
||||
!self.saved.placing && matches!(self.saved.intent, Intent::Existing { .. })
|
||||
}
|
||||
pub fn inline(&self) -> bool {
|
||||
!self.saved.placing
|
||||
&& self.saved.point.is_some()
|
||||
&& !matches!(self.saved.intent, Intent::Existing { .. })
|
||||
}
|
||||
pub fn unnamed_tool(&self) -> bool {
|
||||
self.saved.placing
|
||||
&& self.saved.point.is_none()
|
||||
&& matches!(&self.saved.intent, Intent::Create { name, .. } if name.is_empty())
|
||||
}
|
||||
}
|
||||
pub fn validate_saved(p: &Project, s: &Saved) -> Result<(), String> {
|
||||
if s.version != 1 || s.base != oa_core::transitions::project_fingerprint(p) {
|
||||
return Err("The architecture draft does not match its accepted model.".into());
|
||||
}
|
||||
let d = p
|
||||
.diagrams
|
||||
.get(&s.diagram)
|
||||
.ok_or("The creation draft's diagram is missing.")?;
|
||||
if matches!(d.projection, ProjectionKind::StateMachine { .. })
|
||||
|| s.query.len() > 16384
|
||||
|| s.page > 1_000_000
|
||||
|| s.point
|
||||
.is_some_and(|p| !p.x.is_finite() || !p.y.is_finite())
|
||||
{
|
||||
return Err("Invalid architecture creation draft context.".into());
|
||||
}
|
||||
match &s.intent {
|
||||
Intent::Create {
|
||||
kind,
|
||||
name,
|
||||
performer,
|
||||
} if name.len() > 16384
|
||||
|| !kind.supports(d.layer)
|
||||
|| !d.projection.includes(*kind)
|
||||
|| *kind == Kind::Port
|
||||
|| performer.is_some_and(|id| {
|
||||
!kind.is_work()
|
||||
|| !p
|
||||
.elements
|
||||
.get(&id)
|
||||
.is_some_and(|e| e.kind().is_performer() && e.layer() == d.layer)
|
||||
}) =>
|
||||
{
|
||||
Err("Invalid architecture creation draft type or name.".into())
|
||||
}
|
||||
Intent::Existing { selected: Some(id) } if !compatible(p, d, *id) => {
|
||||
Err("The creation draft's existing element is incompatible.".into())
|
||||
}
|
||||
Intent::Rename { element, name }
|
||||
if name.len() > 16384
|
||||
|| !p.elements.contains_key(element)
|
||||
|| !d.nodes.values().any(|n| n.element == *element) =>
|
||||
{
|
||||
Err("The rename draft's element is not shown in this view.".into())
|
||||
}
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
fn context_kinds(p: &Project, d: &Diagram) -> Vec<Kind> {
|
||||
let mut values = match (d.layer, d.projection) {
|
||||
(_, ProjectionKind::StateMachine { .. }) => vec![],
|
||||
(Layer::Oa, ProjectionKind::FunctionalDataflow) => vec![Kind::Activity],
|
||||
(Layer::Sa, ProjectionKind::FunctionalDataflow) => vec![Kind::Function],
|
||||
(Layer::Oa, ProjectionKind::Capabilities) => vec![
|
||||
Kind::OperationalMission,
|
||||
Kind::Capability,
|
||||
Kind::Entity,
|
||||
Kind::Actor,
|
||||
],
|
||||
(Layer::Sa, ProjectionKind::Capabilities) => vec![
|
||||
Kind::Mission,
|
||||
Kind::SystemCapability,
|
||||
Kind::System,
|
||||
Kind::SystemActor,
|
||||
],
|
||||
(Layer::Oa, _) => vec![Kind::Entity, Kind::Actor, Kind::Activity],
|
||||
(Layer::Sa, _) => vec![Kind::System, Kind::SystemActor, Kind::Function],
|
||||
};
|
||||
values.retain(|k| *k != Kind::System || !p.elements.values().any(|e| e.kind() == Kind::System));
|
||||
values
|
||||
}
|
||||
pub fn kinds(app: &App) -> Vec<Kind> {
|
||||
app.active_diagram
|
||||
.and_then(|id| app.project().diagrams.get(&id))
|
||||
.map(|d| context_kinds(app.project(), d))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
fn compatible(p: &Project, d: &Diagram, id: Id) -> bool {
|
||||
p.elements.get(&id).is_some_and(|e| {
|
||||
e.kind() != Kind::Port
|
||||
&& d.projection.includes(e.kind())
|
||||
&& (e.layer() == d.layer || d.projection == ProjectionKind::Traceability)
|
||||
})
|
||||
}
|
||||
fn suggested(p: &Project, d: &Diagram, kind: Kind) -> bool {
|
||||
// Existing System remains a useful choice even when the singleton exists.
|
||||
context_kinds(p, d).contains(&kind)
|
||||
|| (kind == Kind::System
|
||||
&& d.layer == Layer::Sa
|
||||
&& d.projection != ProjectionKind::FunctionalDataflow)
|
||||
}
|
||||
pub fn candidates(app: &App) -> Vec<(Id, String, bool)> {
|
||||
let Some(s) = &app.architecture else {
|
||||
return vec![];
|
||||
};
|
||||
let p = app.project();
|
||||
let Some(d) = p.diagrams.get(&s.saved.diagram) else {
|
||||
return vec![];
|
||||
};
|
||||
let query = s.saved.query.to_lowercase();
|
||||
let mut entries: Vec<_> = p
|
||||
.elements
|
||||
.values()
|
||||
.filter(|e| compatible(p, d, e.id))
|
||||
.filter(|e| match s.saved.filter {
|
||||
Filter::Context => suggested(p, d, e.kind()),
|
||||
Filter::AllCompatible => true,
|
||||
Filter::Kind(k) => e.kind() == k,
|
||||
})
|
||||
.filter_map(|e| {
|
||||
let shown = d.nodes.values().any(|n| n.element == e.id);
|
||||
let label = format!(
|
||||
"{} · @{} · {} · {}",
|
||||
e.name,
|
||||
e.alias,
|
||||
e.kind().label(),
|
||||
e.parent.map(|id| p.named(id)).unwrap_or("At root")
|
||||
);
|
||||
((!shown || s.saved.show_placed) && label.to_lowercase().contains(&query))
|
||||
.then_some((e.id, label, shown))
|
||||
})
|
||||
.collect();
|
||||
entries.sort_by(|a, b| a.1.cmp(&b.1).then(a.0.cmp(&b.0)));
|
||||
entries
|
||||
}
|
||||
impl App {
|
||||
pub(super) fn architecture_event(&mut self, event: Event) -> Task<Message> {
|
||||
match event {
|
||||
Event::Start(_, point) | Event::Allocate(_, _, point) | Event::Existing(point) => {
|
||||
if self.architecture.is_some() {
|
||||
if self.architecture.as_ref().is_some_and(State::unnamed_tool) {
|
||||
self.architecture = None;
|
||||
} else {
|
||||
self.status =
|
||||
"Finish or cancel the current name before choosing another tool."
|
||||
.into();
|
||||
return Task::none();
|
||||
}
|
||||
}
|
||||
if self.page != Page::Diagram
|
||||
|| self.source_dirty()
|
||||
|| self.draft_dirty()
|
||||
|| self.behavior.has_pending()
|
||||
|| self.workflow.is_some()
|
||||
{
|
||||
self.status =
|
||||
"Apply or cancel pending edits before creating diagram content.".into();
|
||||
return Task::none();
|
||||
}
|
||||
let Some(diagram) = self.active_diagram else {
|
||||
return Task::none();
|
||||
};
|
||||
let intent = match event {
|
||||
Event::Start(kind, _) if kinds(self).contains(&kind) => Intent::Create {
|
||||
kind,
|
||||
name: String::new(),
|
||||
performer: None,
|
||||
},
|
||||
Event::Allocate(kind, performer, _)
|
||||
if kinds(self).contains(&kind) && kind.is_work() =>
|
||||
{
|
||||
Intent::Create {
|
||||
kind,
|
||||
name: String::new(),
|
||||
performer: Some(performer),
|
||||
}
|
||||
}
|
||||
Event::Existing(_) => Intent::Existing { selected: None },
|
||||
_ => return Task::none(),
|
||||
};
|
||||
let placing = point.is_none() && matches!(intent, Intent::Create { .. });
|
||||
let saved = Saved {
|
||||
version: 1,
|
||||
base: oa_core::transitions::project_fingerprint(self.project()),
|
||||
diagram,
|
||||
intent,
|
||||
point,
|
||||
placing,
|
||||
query: String::new(),
|
||||
filter: Filter::Context,
|
||||
show_placed: false,
|
||||
page: 0,
|
||||
};
|
||||
if let Ok(state) = State::restore(self, saved) {
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
self.architecture = Some(state);
|
||||
self.connection = None;
|
||||
self.fit_to_view = false;
|
||||
self.status =
|
||||
"Click in the diagram to position the new element · Esc cancels".into();
|
||||
}
|
||||
return iced::widget::operation::focus("architecture-name");
|
||||
}
|
||||
Event::TogglePalette => {
|
||||
self.architecture_palette_expanded = !self.architecture_palette_expanded;
|
||||
self.fit_to_view = false;
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
}
|
||||
Event::Rename(context, element, point) => {
|
||||
if self.architecture.is_some()
|
||||
|| self.route_context() != Some(context)
|
||||
|| self.page != Page::Diagram
|
||||
|| self.draft_dirty()
|
||||
|| self.source_dirty()
|
||||
|| self.behavior.has_pending()
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
let Some(e) = self.project().elements.get(&element) else {
|
||||
return Task::none();
|
||||
};
|
||||
let saved = Saved {
|
||||
version: 1,
|
||||
base: oa_core::transitions::project_fingerprint(self.project()),
|
||||
diagram: context.diagram,
|
||||
intent: Intent::Rename {
|
||||
element,
|
||||
name: e.name.clone(),
|
||||
},
|
||||
point: Some(point),
|
||||
placing: false,
|
||||
query: String::new(),
|
||||
filter: Filter::Context,
|
||||
show_placed: false,
|
||||
page: 0,
|
||||
};
|
||||
if let Ok(state) = State::restore(self, saved) {
|
||||
self.architecture = Some(state);
|
||||
self.connection = None;
|
||||
self.fit_to_view = false;
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
}
|
||||
return iced::widget::operation::focus("architecture-name");
|
||||
}
|
||||
Event::CapturedEscape => {
|
||||
if self.architecture.as_ref().is_some_and(State::inline) {
|
||||
return self.architecture_event(Event::Cancel);
|
||||
}
|
||||
}
|
||||
Event::Cancel => {
|
||||
self.architecture = None;
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
self.status = "Cancelled · Select / move".into();
|
||||
}
|
||||
Event::Name(value) => {
|
||||
if value.len() <= 16384
|
||||
&& let Some(s) = &mut self.architecture
|
||||
&& let Intent::Create { name, .. } | Intent::Rename { name, .. } =
|
||||
&mut s.saved.intent
|
||||
{
|
||||
*name = value;
|
||||
s.error = None;
|
||||
}
|
||||
}
|
||||
Event::Search(query) => {
|
||||
if query.len() <= 16384
|
||||
&& let Some(s) = &mut self.architecture
|
||||
{
|
||||
s.saved.query = query;
|
||||
s.saved.page = 0;
|
||||
}
|
||||
}
|
||||
Event::Filter(filter) => {
|
||||
if let Some(s) = &mut self.architecture {
|
||||
s.saved.filter = filter;
|
||||
s.saved.page = 0;
|
||||
}
|
||||
}
|
||||
Event::ShowPlaced(value) => {
|
||||
if let Some(s) = &mut self.architecture {
|
||||
s.saved.show_placed = value;
|
||||
s.saved.page = 0;
|
||||
}
|
||||
}
|
||||
Event::Page(page) => {
|
||||
if let Some(s) = &mut self.architecture {
|
||||
s.saved.page = page.min(1_000_000);
|
||||
}
|
||||
}
|
||||
Event::Pick(id) => {
|
||||
if !candidates(self)
|
||||
.iter()
|
||||
.any(|(candidate, _, shown)| *candidate == id && !shown)
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
if let Some(s) = &mut self.architecture {
|
||||
s.saved.intent = Intent::Existing { selected: Some(id) };
|
||||
}
|
||||
return self.architecture_event(Event::Submit);
|
||||
}
|
||||
Event::Locate(id) => {
|
||||
if candidates(self)
|
||||
.iter()
|
||||
.any(|(candidate, _, shown)| *candidate == id && *shown)
|
||||
&& let Some(s) = self.architecture.take()
|
||||
{
|
||||
return self.update(Message::RevealInDiagram(s.saved.diagram, id));
|
||||
}
|
||||
}
|
||||
Event::Submit => {
|
||||
let Some(s) = &mut self.architecture else {
|
||||
return Task::none();
|
||||
};
|
||||
if matches!(&s.saved.intent, Intent::Create { name, .. } | Intent::Rename { name, .. } if name.trim().is_empty())
|
||||
{
|
||||
s.error = Some("Enter an engineering name before placing this element.".into());
|
||||
return Task::none();
|
||||
}
|
||||
if matches!(s.saved.intent, Intent::Existing { selected: None }) {
|
||||
return Task::none();
|
||||
}
|
||||
s.saved.placing = true;
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
if let Some(point) = s.saved.point {
|
||||
self.commit_architecture(point);
|
||||
}
|
||||
}
|
||||
Event::Click(context, point) => {
|
||||
if self.route_context() != Some(context)
|
||||
|| !point.x.is_finite()
|
||||
|| !point.y.is_finite()
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
if self.architecture.as_ref().is_some_and(|s| s.saved.placing) {
|
||||
if matches!(
|
||||
self.architecture.as_ref().unwrap().saved.intent,
|
||||
Intent::Existing { .. }
|
||||
) {
|
||||
self.commit_architecture(point);
|
||||
} else {
|
||||
let s = self.architecture.as_mut().unwrap();
|
||||
s.saved.point = Some(point);
|
||||
s.saved.placing = false;
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
return iced::widget::operation::focus("architecture-name");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
fn commit_architecture(&mut self, point: Position) {
|
||||
let Some(s) = self.architecture.as_ref() else {
|
||||
return;
|
||||
};
|
||||
if s.document != self.canvas_document
|
||||
|| s.revision != self.document.revision()
|
||||
|| Some(s.saved.diagram) != self.active_diagram
|
||||
|| self.page != Page::Diagram
|
||||
|| self.source_dirty()
|
||||
|| self.draft_dirty()
|
||||
|| self.behavior.has_pending()
|
||||
|| !point.x.is_finite()
|
||||
|| !point.y.is_finite()
|
||||
{
|
||||
self.status =
|
||||
"The diagram changed. The name is retained; cancel and restart this placement."
|
||||
.into();
|
||||
if let Some(s) = &mut self.architecture {
|
||||
s.saved.placing = false;
|
||||
s.error = Some(self.status.clone());
|
||||
}
|
||||
return;
|
||||
}
|
||||
let diagram = s.saved.diagram;
|
||||
if let Intent::Rename { element, name } = &s.saved.intent {
|
||||
let element = *element;
|
||||
let name = name.clone();
|
||||
if name.trim().is_empty() {
|
||||
return;
|
||||
}
|
||||
if self.execute(Command::Rename(element, name)) {
|
||||
self.architecture = None;
|
||||
self.selected = element;
|
||||
self.refresh_draft();
|
||||
self.status = "Name updated · Undo restores the previous name".into();
|
||||
} else if let Some(s) = &mut self.architecture {
|
||||
s.error = Some(self.status.clone());
|
||||
s.saved.placing = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
let mut candidate = self.project().clone();
|
||||
let mut commands = vec![];
|
||||
let id = match &s.saved.intent {
|
||||
Intent::Create {
|
||||
kind,
|
||||
name,
|
||||
performer,
|
||||
} => {
|
||||
if name.trim().is_empty() || !kinds(self).contains(kind) {
|
||||
return;
|
||||
}
|
||||
let Some(element) =
|
||||
candidate.make_element(*kind, candidate.diagrams[&diagram].layer, name, None)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let id = element.id;
|
||||
candidate.elements.insert(id, element.clone());
|
||||
commands.push(Command::CreateElement(element));
|
||||
if let Some(performer) = performer {
|
||||
commands.push(Command::Connect(
|
||||
RelationKind::Allocation,
|
||||
"Performs".into(),
|
||||
*performer,
|
||||
id,
|
||||
));
|
||||
}
|
||||
id
|
||||
}
|
||||
Intent::Existing { selected: Some(id) }
|
||||
if compatible(&candidate, &candidate.diagrams[&diagram], *id)
|
||||
&& !candidate.diagrams[&diagram]
|
||||
.nodes
|
||||
.values()
|
||||
.any(|n| n.element == *id) =>
|
||||
{
|
||||
*id
|
||||
}
|
||||
_ => return,
|
||||
};
|
||||
let node = candidate.place(diagram, id, point);
|
||||
commands.push(Command::CreateNode {
|
||||
diagram,
|
||||
node: candidate.diagrams[&diagram].nodes[&node].clone(),
|
||||
});
|
||||
if self.execute(Command::Batch(commands)) {
|
||||
self.architecture = None;
|
||||
self.selected = id;
|
||||
self.selected_node = Some(node);
|
||||
self.selected_bend = None;
|
||||
self.inspector_tab = crate::navigation::InspectorTab::Properties;
|
||||
self.refresh_draft();
|
||||
self.fit_to_view = false;
|
||||
self.status = "Element placed · Undo restores the whole change. Drag to move; Fit is available when needed.".into();
|
||||
} else if let Some(s) = &mut self.architecture {
|
||||
s.error = Some(self.status.clone());
|
||||
s.saved.placing = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn inline_editor(app: &App) -> Option<Element<'_, Message>> {
|
||||
let s = app.architecture.as_ref().filter(|s| s.inline())?;
|
||||
let point = s.saved.point?;
|
||||
let size = app.canvas_size.unwrap_or(iced::Size::new(450., 350.));
|
||||
let width = (size.width - 16.).clamp(120., 320.);
|
||||
let x = (point.x * app.zoom + app.pan.x).clamp(8., (size.width - width - 8.).max(8.));
|
||||
let y = (point.y * app.zoom + app.pan.y).clamp(8., (size.height - 152.).max(8.));
|
||||
Some(
|
||||
column![
|
||||
iced::widget::Space::new().height(y),
|
||||
row![
|
||||
iced::widget::Space::new().width(x),
|
||||
iced::widget::opaque(
|
||||
iced::widget::container(scrollable(name_editor(app)))
|
||||
.padding(10)
|
||||
.width(width)
|
||||
.max_height((size.height - y - 8.).max(0.))
|
||||
.style(crate::style::card)
|
||||
)
|
||||
]
|
||||
]
|
||||
.into(),
|
||||
)
|
||||
}
|
||||
fn name_editor(app: &App) -> Element<'_, Message> {
|
||||
let s = app.architecture.as_ref().unwrap();
|
||||
let (title, name, action) = match &s.saved.intent {
|
||||
Intent::Create {
|
||||
kind,
|
||||
name,
|
||||
performer,
|
||||
} => (
|
||||
performer
|
||||
.map(|id| {
|
||||
format!(
|
||||
"{} · performed by {}",
|
||||
kind.label(),
|
||||
crate::views::short(app.project().named(id), 32)
|
||||
)
|
||||
})
|
||||
.unwrap_or_else(|| format!("New {}", kind.label())),
|
||||
name,
|
||||
"Create",
|
||||
),
|
||||
Intent::Rename { name, .. } => ("Rename".into(), name, "Apply"),
|
||||
Intent::Existing { .. } => unreachable!(),
|
||||
};
|
||||
let mut content = column![
|
||||
text(title).size(12),
|
||||
text_input("Engineering name", name)
|
||||
.id("architecture-name")
|
||||
.on_input(|v| Message::Architecture(Event::Name(v)))
|
||||
.on_submit(Message::Architecture(Event::Submit))
|
||||
.padding(7)
|
||||
.size(13),
|
||||
row![
|
||||
button(text(action).size(12))
|
||||
.padding([5, 9])
|
||||
.style(crate::style::nav)
|
||||
.on_press(Message::Architecture(Event::Submit)),
|
||||
button(text("Cancel").size(12))
|
||||
.padding([5, 9])
|
||||
.style(crate::style::nav)
|
||||
.on_press(Message::Architecture(Event::Cancel))
|
||||
]
|
||||
.spacing(6)
|
||||
]
|
||||
.spacing(7);
|
||||
if let Some(error) = &s.error {
|
||||
content = content.push(text(crate::views::short(error, 100)).size(11));
|
||||
}
|
||||
content.into()
|
||||
}
|
||||
pub fn dialog(app: &App) -> Element<'_, Message> {
|
||||
let s = app.architecture.as_ref().unwrap();
|
||||
let action = |label: String, event| {
|
||||
button(text(label).size(12))
|
||||
.padding(8)
|
||||
.on_press(Message::Architecture(event))
|
||||
};
|
||||
let mut content = column![].spacing(12);
|
||||
match &s.saved.intent {
|
||||
Intent::Create { .. } | Intent::Rename { .. } => return name_editor(app),
|
||||
Intent::Existing { .. } => {
|
||||
let p = app.project();
|
||||
let d = &p.diagrams[&s.saved.diagram];
|
||||
let mut filters = vec![Filter::Context, Filter::AllCompatible];
|
||||
filters.extend(
|
||||
Kind::ALL
|
||||
.into_iter()
|
||||
.filter(|k| {
|
||||
*k != Kind::Port
|
||||
&& d.projection.includes(*k)
|
||||
&& (k.supports(d.layer) || d.projection == ProjectionKind::Traceability)
|
||||
})
|
||||
.map(Filter::Kind),
|
||||
);
|
||||
let entries = candidates(app);
|
||||
let page = s.saved.page.min(entries.len().saturating_sub(1) / 30);
|
||||
let mut rows = column![].spacing(5);
|
||||
for (id, label, shown) in entries.iter().skip(page * 30).take(30) {
|
||||
rows = rows.push(
|
||||
action(
|
||||
format!("{}{}", if *shown { "Locate · " } else { "Add · " }, label),
|
||||
if *shown {
|
||||
Event::Locate(*id)
|
||||
} else {
|
||||
Event::Pick(*id)
|
||||
},
|
||||
)
|
||||
.width(iced::Fill),
|
||||
);
|
||||
}
|
||||
content = content.push(text("Add existing to this diagram").size(22))
|
||||
.push(text("Add a model element to this view. Its name and properties stay shared across views.").size(13))
|
||||
.push(text_input("Optional name, alias or owner filter", &s.saved.query).id("architecture-name").on_input(|v| Message::Architecture(Event::Search(v))).padding(9))
|
||||
.push(pick_list(filters, Some(s.saved.filter), |f| Message::Architecture(Event::Filter(f))))
|
||||
.push(iced::widget::checkbox(s.saved.show_placed).label("Include already shown (Locate)").on_toggle(|v| Message::Architecture(Event::ShowPlaced(v))))
|
||||
.push(text(format!("{} compatible matches · page {}", entries.len(), page + 1)).size(12));
|
||||
if entries.is_empty() {
|
||||
content = content.push(text("No unplaced matches. Try All compatible types or include already shown elements.").size(13));
|
||||
}
|
||||
content = content
|
||||
.push(scrollable(rows).height((app.window_size.height - 500.).clamp(120., 260.)))
|
||||
.push(
|
||||
row![
|
||||
action("Previous".into(), Event::Page(page.saturating_sub(1))),
|
||||
action(
|
||||
"Next".into(),
|
||||
Event::Page((page + 1).min(entries.len().saturating_sub(1) / 30))
|
||||
),
|
||||
action("Cancel".into(), Event::Cancel)
|
||||
]
|
||||
.spacing(8),
|
||||
);
|
||||
}
|
||||
}
|
||||
if let Some(error) = &s.error {
|
||||
content = content.push(text(error).size(13));
|
||||
}
|
||||
content.into()
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
//! Compact neutral tool palette. Tool state never changes its allocated width.
|
||||
use super::*;
|
||||
use iced::{
|
||||
Point, Rectangle, Size, Theme, mouse,
|
||||
widget::{
|
||||
canvas::{self, Frame, Geometry, Path, Stroke},
|
||||
container, tooltip,
|
||||
},
|
||||
};
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum Icon {
|
||||
Element(Kind),
|
||||
Arrow,
|
||||
Existing,
|
||||
Select,
|
||||
Collapse(bool),
|
||||
}
|
||||
impl canvas::Program<Message> for Icon {
|
||||
type State = ();
|
||||
fn draw(
|
||||
&self,
|
||||
_: &(),
|
||||
renderer: &iced::Renderer,
|
||||
_: &Theme,
|
||||
bounds: Rectangle,
|
||||
_: mouse::Cursor,
|
||||
) -> Vec<Geometry> {
|
||||
let mut f = Frame::new(renderer, bounds.size());
|
||||
let color = match self {
|
||||
Self::Element(k) => crate::style::kind_color(*k),
|
||||
_ => crate::style::INK,
|
||||
};
|
||||
let stroke = Stroke::default().with_color(color).with_width(1.4);
|
||||
let line = |points: &[(f32, f32)]| {
|
||||
Path::new(|p| {
|
||||
p.move_to(Point::new(points[0].0, points[0].1));
|
||||
for &(x, y) in &points[1..] {
|
||||
p.line_to(Point::new(x, y));
|
||||
}
|
||||
})
|
||||
};
|
||||
match self {
|
||||
Self::Element(Kind::Actor | Kind::SystemActor | Kind::Role) => {
|
||||
f.stroke(&Path::circle(Point::new(8., 4.), 2.5), stroke);
|
||||
for points in [
|
||||
&[(8., 6.5), (8., 11.)][..],
|
||||
&[(3., 8.), (13., 8.)],
|
||||
&[(4., 15.), (8., 11.), (12., 15.)],
|
||||
] {
|
||||
f.stroke(&line(points), stroke);
|
||||
}
|
||||
}
|
||||
Self::Element(Kind::Capability | Kind::SystemCapability) => {
|
||||
f.stroke(&Path::circle(Point::new(8., 8.), 6.), stroke);
|
||||
}
|
||||
Self::Element(Kind::Mission | Kind::OperationalMission) => {
|
||||
f.stroke(
|
||||
&line(&[(3., 15.), (3., 2.), (13., 2.), (10., 6.), (3., 6.)]),
|
||||
stroke,
|
||||
);
|
||||
}
|
||||
Self::Element(k) => {
|
||||
f.stroke(
|
||||
&Path::rounded_rectangle(
|
||||
Point::new(1., 3.),
|
||||
Size::new(14., 10.),
|
||||
if k.is_work() { 3.into() } else { 0.into() },
|
||||
),
|
||||
stroke,
|
||||
);
|
||||
if !k.is_work() {
|
||||
f.stroke(
|
||||
&Path::rectangle(Point::new(4., 6.), Size::new(8., 4.)),
|
||||
stroke,
|
||||
);
|
||||
}
|
||||
}
|
||||
Self::Arrow => {
|
||||
f.stroke(&line(&[(1., 8.), (14., 8.), (10., 4.)]), stroke);
|
||||
f.stroke(&line(&[(14., 8.), (10., 12.)]), stroke);
|
||||
}
|
||||
Self::Existing => {
|
||||
f.stroke(
|
||||
&Path::rectangle(Point::new(1., 1.), Size::new(10., 10.)),
|
||||
stroke,
|
||||
);
|
||||
f.stroke(
|
||||
&Path::rectangle(Point::new(5., 5.), Size::new(10., 10.)),
|
||||
stroke,
|
||||
);
|
||||
}
|
||||
Self::Select => {
|
||||
f.stroke(
|
||||
&line(&[
|
||||
(3., 1.),
|
||||
(3., 14.),
|
||||
(7., 10.),
|
||||
(11., 15.),
|
||||
(13., 14.),
|
||||
(9., 9.),
|
||||
(15., 8.),
|
||||
(3., 1.),
|
||||
]),
|
||||
stroke,
|
||||
);
|
||||
}
|
||||
Self::Collapse(expanded) => {
|
||||
let (a, b) = if *expanded { (5., 11.) } else { (11., 5.) };
|
||||
f.stroke(&line(&[(a, 3.), (b, 8.), (a, 13.)]), stroke);
|
||||
}
|
||||
}
|
||||
vec![f.into_geometry()]
|
||||
}
|
||||
}
|
||||
fn tool<'a>(
|
||||
app: &App,
|
||||
icon: Icon,
|
||||
label: &'a str,
|
||||
message: Message,
|
||||
selected: bool,
|
||||
) -> Element<'a, Message> {
|
||||
let width = if app.architecture_palette_expanded {
|
||||
156.
|
||||
} else {
|
||||
36.
|
||||
};
|
||||
let graphic = iced::widget::canvas(icon).width(16).height(16);
|
||||
let mut content = row![graphic].spacing(7).align_y(iced::Center);
|
||||
if app.architecture_palette_expanded {
|
||||
content = content.push(text(label).size(11).wrapping(text::Wrapping::None));
|
||||
}
|
||||
let mut enabled = app.architecture.as_ref().is_none_or(State::unnamed_tool)
|
||||
|| matches!(
|
||||
&message,
|
||||
Message::Architecture(Event::Cancel | Event::TogglePalette)
|
||||
);
|
||||
let mut hint = label.to_owned();
|
||||
if let Message::Connection(crate::connection_edit::Event::Choose(Some(connection))) = &message {
|
||||
let view = oa_core::projection::project_view(app.project(), app.active_diagram.unwrap());
|
||||
if !crate::connection_edit::visible(&view).any(|id| connection.can_start(app.project(), id))
|
||||
{
|
||||
enabled = false;
|
||||
hint.push_str(" — add or show a compatible source first");
|
||||
}
|
||||
}
|
||||
let control = button(content)
|
||||
.padding([5, 7])
|
||||
.height(28)
|
||||
.width(width - 4.)
|
||||
.clip(true)
|
||||
.style(if selected {
|
||||
crate::style::selected
|
||||
} else {
|
||||
crate::style::nav
|
||||
})
|
||||
.on_press_maybe(enabled.then_some(message));
|
||||
tooltip(
|
||||
control,
|
||||
container(text(hint).size(12))
|
||||
.padding(7)
|
||||
.style(crate::style::card),
|
||||
tooltip::Position::Left,
|
||||
)
|
||||
.into()
|
||||
}
|
||||
pub fn view(app: &App) -> Element<'_, Message> {
|
||||
let mut content = iced::widget::column![tool(
|
||||
app,
|
||||
Icon::Collapse(app.architecture_palette_expanded),
|
||||
if app.architecture_palette_expanded {
|
||||
"Collapse tools"
|
||||
} else {
|
||||
"Expand tools"
|
||||
},
|
||||
Message::Architecture(Event::TogglePalette),
|
||||
false
|
||||
)]
|
||||
.spacing(3);
|
||||
let group = |name: &'static str| -> Element<'_, Message> {
|
||||
if app.architecture_palette_expanded {
|
||||
container(text(name).size(10).color(crate::style::MUTED))
|
||||
.padding([5, 4])
|
||||
.into()
|
||||
} else {
|
||||
iced::widget::rule::horizontal(1).into()
|
||||
}
|
||||
};
|
||||
content = content.push(tool(
|
||||
app,
|
||||
Icon::Select,
|
||||
if app.architecture.is_some() {
|
||||
"Cancel / select"
|
||||
} else {
|
||||
"Select / move"
|
||||
},
|
||||
if app.architecture.is_some() {
|
||||
Message::Architecture(Event::Cancel)
|
||||
} else {
|
||||
Message::Connection(crate::connection_edit::Event::Choose(None))
|
||||
},
|
||||
app.architecture.is_none() && app.connection.is_none(),
|
||||
));
|
||||
for (heading, work) in [
|
||||
("Participants / capabilities", false),
|
||||
("Activities / functions", true),
|
||||
] {
|
||||
let choices: Vec<_> = kinds(app)
|
||||
.into_iter()
|
||||
.filter(|k| k.is_work() == work)
|
||||
.collect();
|
||||
if !choices.is_empty() {
|
||||
content = content.push(group(heading));
|
||||
}
|
||||
for kind in choices {
|
||||
let active = app
|
||||
.architecture
|
||||
.as_ref()
|
||||
.is_some_and(|s| matches!(s.saved.intent,Intent::Create{kind:k,..} if kind==k));
|
||||
content = content.push(tool(
|
||||
app,
|
||||
Icon::Element(kind),
|
||||
kind.label(),
|
||||
Message::Architecture(Event::Start(kind, None)),
|
||||
active,
|
||||
));
|
||||
}
|
||||
}
|
||||
content = content.push(group("Connections"));
|
||||
for connection in crate::connection_tool::Tool::for_layer(app.layer) {
|
||||
let d = &app.project().diagrams[&app.active_diagram.unwrap()];
|
||||
let kind = match connection {
|
||||
crate::connection_tool::Tool::Interaction => Kind::Activity,
|
||||
crate::connection_tool::Tool::FunctionalExchange => Kind::Function,
|
||||
crate::connection_tool::Tool::CommunicationMean => Kind::Entity,
|
||||
crate::connection_tool::Tool::BoundaryExchange => Kind::System,
|
||||
};
|
||||
if !d.projection.includes(kind) {
|
||||
continue;
|
||||
}
|
||||
content = content.push(tool(
|
||||
app,
|
||||
Icon::Arrow,
|
||||
connection.label(),
|
||||
Message::Connection(crate::connection_edit::Event::Choose(Some(*connection))),
|
||||
app.connection
|
||||
.as_ref()
|
||||
.is_some_and(|s| s.tool == *connection),
|
||||
));
|
||||
}
|
||||
content = content.push(group("Common")).push(tool(
|
||||
app,
|
||||
Icon::Existing,
|
||||
"Add existing…",
|
||||
Message::Architecture(Event::Existing(None)),
|
||||
false,
|
||||
));
|
||||
container(scrollable(content))
|
||||
.width(if app.architecture_palette_expanded {
|
||||
156
|
||||
} else {
|
||||
36
|
||||
})
|
||||
.height(iced::Fill)
|
||||
.padding(2)
|
||||
.style(crate::style::panel)
|
||||
.into()
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
use super::*;
|
||||
|
||||
fn setup(layer: Layer) -> App {
|
||||
let mut app = App::with_project(Project::empty());
|
||||
app.layer = layer;
|
||||
app.active_diagram = None;
|
||||
let _ = app.update(Message::AddDiagram);
|
||||
app.zoom = 0.75;
|
||||
app.pan = iced::Vector::new(37., -19.);
|
||||
app
|
||||
}
|
||||
fn send(app: &mut App, event: Event) {
|
||||
let _ = app.update(Message::Architecture(event));
|
||||
}
|
||||
fn create(app: &mut App, kind: Kind, name: &str) -> Id {
|
||||
send(app, Event::Start(kind, Some(Position { x: 120., y: 160. })));
|
||||
send(app, Event::Name(name.into()));
|
||||
send(app, Event::Submit);
|
||||
assert!(app.architecture.is_none(), "{}", app.status);
|
||||
app.selected
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn architecture_allocated_work_is_root_owned_and_one_exact_history_change() {
|
||||
for (layer, participant, work) in [
|
||||
(Layer::Oa, Kind::Entity, Kind::Activity),
|
||||
(Layer::Sa, Kind::System, Kind::Function),
|
||||
] {
|
||||
let mut app = setup(layer);
|
||||
let performer = create(&mut app, participant, "Control team");
|
||||
let before = app.document.bundle().clone();
|
||||
let camera = (app.zoom, app.pan);
|
||||
let point = Position { x: 151., y: 221. };
|
||||
send(&mut app, Event::Allocate(work, performer, Some(point)));
|
||||
send(&mut app, Event::Name(" Report 雨 ".into()));
|
||||
assert_eq!(app.document.bundle(), &before);
|
||||
send(&mut app, Event::Submit);
|
||||
assert!(app.architecture.is_none(), "{}", app.status);
|
||||
let created = app.selected;
|
||||
let element = &app.project().elements[&created];
|
||||
assert_eq!(element.kind(), work);
|
||||
assert_eq!(element.name, " Report 雨 ");
|
||||
assert_eq!(element.parent, None);
|
||||
assert!(app.project().relations.values().any(|r| {
|
||||
r.kind() == RelationKind::Allocation && r.source() == performer && r.target() == created
|
||||
}));
|
||||
let d = &app.project().diagrams[&app.active_diagram.unwrap()];
|
||||
assert_eq!(d.nodes[&app.selected_node.unwrap()].position, point);
|
||||
assert_eq!((app.zoom, app.pan), camera);
|
||||
let accepted = app.document.bundle().clone();
|
||||
let _ = app.update(Message::Undo);
|
||||
assert_eq!(app.document.bundle(), &before);
|
||||
let _ = app.update(Message::Redo);
|
||||
assert_eq!(app.document.bundle(), &accepted);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn architecture_f2_routes_selected_node_to_inline_rename_and_exact_undo() {
|
||||
use iced::widget::canvas::{Event as CanvasEvent, Program};
|
||||
use iced::{keyboard, mouse};
|
||||
let mut app = setup(Layer::Oa);
|
||||
let element = create(&mut app, Kind::Entity, "Original team");
|
||||
let before = app.document.bundle().clone();
|
||||
let diagrams = app.project().diagrams.clone();
|
||||
let camera = (app.zoom, app.pan);
|
||||
let bounds = iced::Rectangle::new(iced::Point::ORIGIN, iced::Size::new(600., 380.));
|
||||
let canvas = crate::diagram::DiagramCanvas {
|
||||
project: app.project(),
|
||||
view_index: None,
|
||||
diagram: app.active_diagram.unwrap(),
|
||||
selected: element,
|
||||
navigation_focus: None,
|
||||
zoom: app.zoom,
|
||||
pan: app.pan,
|
||||
context: app.route_context().unwrap(),
|
||||
selected_bend: None,
|
||||
editable: true,
|
||||
placing: false,
|
||||
connection: None,
|
||||
};
|
||||
let mut state = crate::diagram::State::default();
|
||||
let key = CanvasEvent::Keyboard(keyboard::Event::KeyPressed {
|
||||
key: keyboard::Key::Named(keyboard::key::Named::F2),
|
||||
modified_key: keyboard::Key::Named(keyboard::key::Named::F2),
|
||||
physical_key: keyboard::key::Physical::Code(keyboard::key::Code::F2),
|
||||
location: keyboard::Location::Standard,
|
||||
modifiers: keyboard::Modifiers::empty(),
|
||||
text: None,
|
||||
repeat: false,
|
||||
});
|
||||
assert!(
|
||||
canvas
|
||||
.update(&mut state, &key, bounds, mouse::Cursor::Unavailable)
|
||||
.is_none()
|
||||
);
|
||||
let _ = canvas.update(
|
||||
&mut state,
|
||||
&CanvasEvent::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
|
||||
bounds,
|
||||
mouse::Cursor::Available(iced::Point::new(150., 120.)),
|
||||
);
|
||||
let action = canvas
|
||||
.update(&mut state, &key, bounds, mouse::Cursor::Unavailable)
|
||||
.unwrap();
|
||||
let message = action.into_inner().0.unwrap();
|
||||
let _ = app.update(message);
|
||||
assert!(app.architecture.as_ref().unwrap().inline());
|
||||
assert!(matches!(&app.architecture.as_ref().unwrap().saved.intent,
|
||||
Intent::Rename { element: id, name } if *id == element && name == "Original team"));
|
||||
send(&mut app, Event::Name(" Renamed team 雨 ".into()));
|
||||
assert_eq!(app.document.bundle(), &before);
|
||||
send(&mut app, Event::Submit);
|
||||
assert!(app.architecture.is_none(), "{}", app.status);
|
||||
assert_eq!(app.project().elements[&element].name, " Renamed team 雨 ");
|
||||
assert_eq!(app.project().diagrams, diagrams);
|
||||
assert_eq!((app.zoom, app.pan), camera);
|
||||
let accepted = app.document.bundle().clone();
|
||||
let _ = app.update(Message::Undo);
|
||||
assert_eq!(app.document.bundle(), &before);
|
||||
let _ = app.update(Message::Redo);
|
||||
assert_eq!(app.document.bundle(), &accepted);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn architecture_blank_names_and_escape_never_accept_placeholder_or_rename() {
|
||||
let mut app = setup(Layer::Oa);
|
||||
let element = create(&mut app, Kind::Actor, "Dispatcher");
|
||||
let before = app.document.bundle().clone();
|
||||
let context = app.route_context().unwrap();
|
||||
for event in [
|
||||
Event::Start(Kind::Activity, Some(Position { x: 220., y: 330. })),
|
||||
Event::Rename(context, element, Position { x: 150., y: 200. }),
|
||||
] {
|
||||
// Every cancel changes the gesture context; use the current context for F2.
|
||||
let event = match event {
|
||||
Event::Rename(_, element, point) => {
|
||||
Event::Rename(app.route_context().unwrap(), element, point)
|
||||
}
|
||||
event => event,
|
||||
};
|
||||
send(&mut app, event);
|
||||
send(&mut app, Event::Name(" \t ".into()));
|
||||
send(&mut app, Event::Submit);
|
||||
assert!(app.architecture.as_ref().unwrap().inline());
|
||||
assert!(app.architecture.as_ref().unwrap().error.is_some());
|
||||
assert_eq!(app.document.bundle(), &before);
|
||||
let _ = app.update(Message::Escape);
|
||||
assert!(app.architecture.is_none());
|
||||
assert_eq!(app.document.bundle(), &before);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn architecture_invalid_click_cannot_poison_inline_or_checkpoint_geometry() {
|
||||
let mut app = setup(Layer::Oa);
|
||||
send(&mut app, Event::Start(Kind::Entity, None));
|
||||
let before = app.document.bundle().clone();
|
||||
let context = app.route_context().unwrap();
|
||||
for point in [
|
||||
Position { x: f32::NAN, y: 0. },
|
||||
Position {
|
||||
x: 0.,
|
||||
y: f32::INFINITY,
|
||||
},
|
||||
] {
|
||||
send(&mut app, Event::Click(context, point));
|
||||
let draft = app.architecture.as_ref().unwrap();
|
||||
assert!(draft.unnamed_tool());
|
||||
assert!(validate_saved(app.project(), &draft.saved).is_ok());
|
||||
assert_eq!(app.document.bundle(), &before);
|
||||
}
|
||||
send(&mut app, Event::Click(context, Position { x: 25., y: 30. }));
|
||||
assert!(app.architecture.as_ref().unwrap().inline());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn architecture_stale_submit_keeps_create_and_rename_visible_and_exact() {
|
||||
for rename in [false, true] {
|
||||
for stale_document in [false, true] {
|
||||
let mut app = setup(Layer::Oa);
|
||||
let element = create(&mut app, Kind::Entity, "Accepted team");
|
||||
let point = Position {
|
||||
x: 151.5,
|
||||
y: 227.25,
|
||||
};
|
||||
let event = if rename {
|
||||
Event::Rename(app.route_context().unwrap(), element, point)
|
||||
} else {
|
||||
Event::Start(Kind::Activity, Some(point))
|
||||
};
|
||||
send(&mut app, event);
|
||||
send(
|
||||
&mut app,
|
||||
Event::Name(" Retained engineering name 雨 ".into()),
|
||||
);
|
||||
// Change only the binding. The accepted model/source must not be
|
||||
// rewritten or a rejected draft silently rebound to the new context.
|
||||
if stale_document {
|
||||
app.architecture.as_mut().unwrap().document = Id::new_v4();
|
||||
} else {
|
||||
app.architecture.as_mut().unwrap().revision = app.document.revision() + 1;
|
||||
}
|
||||
let draft = app.architecture.as_ref().unwrap();
|
||||
let saved = serde_json::to_value(&draft.saved).unwrap();
|
||||
let binding = (draft.document, draft.revision);
|
||||
let model = app.project().clone();
|
||||
let source = app.document.bundle().clone();
|
||||
let revision = app.document.revision();
|
||||
let camera = (app.zoom, app.pan);
|
||||
|
||||
for _ in 0..2 {
|
||||
send(&mut app, Event::Submit);
|
||||
let draft = app.architecture.as_ref().unwrap();
|
||||
assert!(
|
||||
draft.inline(),
|
||||
"rename={rename}, stale_document={stale_document}"
|
||||
);
|
||||
assert!(inline_editor(&app).is_some());
|
||||
assert_eq!(draft.error.as_deref(), Some(app.status.as_str()));
|
||||
assert!(app.status.contains("cancel and restart"));
|
||||
assert_eq!(serde_json::to_value(&draft.saved).unwrap(), saved);
|
||||
assert_eq!((draft.document, draft.revision), binding);
|
||||
assert_eq!(app.project(), &model);
|
||||
assert_eq!(app.document.bundle(), &source);
|
||||
assert_eq!(app.document.revision(), revision);
|
||||
assert_eq!((app.zoom, app.pan), camera);
|
||||
}
|
||||
let _ = app.update(Message::Escape);
|
||||
assert!(app.architecture.is_none());
|
||||
assert_eq!(app.project(), &model);
|
||||
assert_eq!(app.document.bundle(), &source);
|
||||
let fresh = create(&mut app, Kind::Actor, "Fresh context");
|
||||
assert_eq!(app.project().elements[&fresh].name, "Fresh context");
|
||||
let _ = app.update(Message::Undo);
|
||||
assert_eq!(app.project(), &model);
|
||||
assert_eq!(app.document.bundle(), &source);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn key_event(named: iced::keyboard::key::Named) -> iced::Event {
|
||||
use iced::keyboard;
|
||||
iced::Event::Keyboard(keyboard::Event::KeyPressed {
|
||||
key: keyboard::Key::Named(named),
|
||||
modified_key: keyboard::Key::Named(named),
|
||||
physical_key: keyboard::key::Physical::Code(keyboard::key::Code::Escape),
|
||||
location: keyboard::Location::Standard,
|
||||
modifiers: keyboard::Modifiers::empty(),
|
||||
text: None,
|
||||
repeat: false,
|
||||
})
|
||||
}
|
||||
|
||||
fn focused_text_input_escape(name: &str) -> Message {
|
||||
use iced::advanced::{Layout, Shell, layout, widget};
|
||||
let mut field: iced::Element<'_, (), iced::Theme, ()> = text_input("Engineering name", name)
|
||||
.id("architecture-name")
|
||||
.on_input(|_| ())
|
||||
.into();
|
||||
let mut tree = widget::Tree::new(field.as_widget());
|
||||
let size = iced::Size::new(300., 40.);
|
||||
let node =
|
||||
field
|
||||
.as_widget_mut()
|
||||
.layout(&mut tree, &(), &layout::Limits::new(iced::Size::ZERO, size));
|
||||
tree.state
|
||||
.downcast_mut::<iced::widget::text_input::State<()>>()
|
||||
.focus();
|
||||
let escape = key_event(iced::keyboard::key::Named::Escape);
|
||||
let mut messages = Vec::new();
|
||||
let mut shell = Shell::new(&mut messages);
|
||||
field.as_widget_mut().update(
|
||||
&mut tree,
|
||||
&escape,
|
||||
Layout::new(&node),
|
||||
iced::mouse::Cursor::Unavailable,
|
||||
&(),
|
||||
&mut iced::advanced::clipboard::Null,
|
||||
&mut shell,
|
||||
&iced::Rectangle::new(iced::Point::ORIGIN, size),
|
||||
);
|
||||
assert!(
|
||||
shell.is_event_captured(),
|
||||
"Iced consumes focused-input Escape"
|
||||
);
|
||||
assert!(
|
||||
!tree
|
||||
.state
|
||||
.downcast_ref::<iced::widget::text_input::State<()>>()
|
||||
.is_focused()
|
||||
);
|
||||
captured_escape(
|
||||
escape,
|
||||
iced::event::Status::Captured,
|
||||
iced::window::Id::unique(),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn architecture_first_focused_input_escape_cancels_create_and_rename() {
|
||||
for rename in [false, true] {
|
||||
let mut app = setup(Layer::Oa);
|
||||
let element = create(&mut app, Kind::Actor, "Accepted dispatcher");
|
||||
let model = app.project().clone();
|
||||
let source = app.document.bundle().clone();
|
||||
let revision = app.document.revision();
|
||||
let camera = (app.zoom, app.pan);
|
||||
let point = Position { x: 439., y: 328.2 };
|
||||
let event = if rename {
|
||||
Event::Rename(app.route_context().unwrap(), element, point)
|
||||
} else {
|
||||
Event::Start(Kind::Actor, Some(point))
|
||||
};
|
||||
send(&mut app, event);
|
||||
let name = " Unaccepted long engineering name 雨 ";
|
||||
send(&mut app, Event::Name(name.into()));
|
||||
assert!(app.architecture.as_ref().unwrap().inline());
|
||||
let message = focused_text_input_escape(name);
|
||||
let _ = app.update(message);
|
||||
assert!(app.architecture.is_none());
|
||||
assert!(inline_editor(&app).is_none());
|
||||
assert_eq!(app.status, "Cancelled · Select / move");
|
||||
assert_eq!(app.project(), &model);
|
||||
assert_eq!(app.document.bundle(), &source);
|
||||
assert_eq!(app.document.revision(), revision);
|
||||
assert_eq!((app.zoom, app.pan), camera);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn architecture_captured_escape_does_not_cancel_other_controls_or_placement() {
|
||||
let mut app = setup(Layer::Oa);
|
||||
for event in [
|
||||
None,
|
||||
Some(Event::Start(Kind::Entity, None)),
|
||||
Some(Event::Existing(None)),
|
||||
] {
|
||||
send(&mut app, Event::Cancel);
|
||||
if let Some(event) = event {
|
||||
send(&mut app, event);
|
||||
}
|
||||
let saved = app
|
||||
.architecture
|
||||
.as_ref()
|
||||
.map(|s| serde_json::to_value(&s.saved).unwrap());
|
||||
let before = app.document.bundle().clone();
|
||||
let status = app.status.clone();
|
||||
let epoch = app.canvas_epoch;
|
||||
let message = focused_text_input_escape("Other field or existing-element search");
|
||||
let _ = app.update(message);
|
||||
assert_eq!(
|
||||
app.architecture
|
||||
.as_ref()
|
||||
.map(|s| serde_json::to_value(&s.saved).unwrap()),
|
||||
saved
|
||||
);
|
||||
assert_eq!(app.document.bundle(), &before);
|
||||
assert_eq!(app.status, status);
|
||||
assert_eq!(app.canvas_epoch, epoch);
|
||||
}
|
||||
assert!(
|
||||
captured_escape(
|
||||
key_event(iced::keyboard::key::Named::Escape),
|
||||
iced::event::Status::Ignored,
|
||||
iced::window::Id::unique(),
|
||||
)
|
||||
.is_none()
|
||||
);
|
||||
assert!(
|
||||
captured_escape(
|
||||
key_event(iced::keyboard::key::Named::Enter),
|
||||
iced::event::Status::Captured,
|
||||
iced::window::Id::unique(),
|
||||
)
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,490 @@
|
||||
use super::*;
|
||||
use crate::app::architecture_authoring::{Event, kinds};
|
||||
|
||||
fn setup(layer: Layer) -> App {
|
||||
let mut app = App::with_project(Project::empty());
|
||||
app.layer = layer;
|
||||
app.active_diagram = None;
|
||||
let _ = app.update(Message::AddDiagram);
|
||||
app.zoom = 0.75;
|
||||
app.pan = iced::Vector::new(37., -19.);
|
||||
app
|
||||
}
|
||||
fn send(app: &mut App, event: Event) {
|
||||
let _ = app.update(Message::Architecture(event));
|
||||
}
|
||||
#[test]
|
||||
fn architecture_creation_names_before_commit_preserves_camera_and_undoes_once() {
|
||||
let mut app = setup(Layer::Oa);
|
||||
let before = app.document.bundle().clone();
|
||||
let model = app.project().clone();
|
||||
let camera = (app.zoom, app.pan);
|
||||
send(&mut app, Event::Start(Kind::Entity, None));
|
||||
assert_eq!(app.project(), &model);
|
||||
let context = app.route_context().unwrap();
|
||||
send(
|
||||
&mut app,
|
||||
Event::Click(context, Position { x: 219., y: 117. }),
|
||||
);
|
||||
assert_eq!(app.project(), &model);
|
||||
assert!(app.architecture.as_ref().unwrap().inline());
|
||||
send(&mut app, Event::Name(" Response team 雨 ".into()));
|
||||
send(&mut app, Event::Submit);
|
||||
assert!(app.architecture.is_none());
|
||||
assert_eq!((app.zoom, app.pan), camera);
|
||||
let element = &app.project().elements[&app.selected];
|
||||
assert_eq!(element.name, " Response team 雨 ");
|
||||
assert_eq!(element.parent, None);
|
||||
let node =
|
||||
&app.project().diagrams[&app.active_diagram.unwrap()].nodes[&app.selected_node.unwrap()];
|
||||
assert_eq!(node.position, Position { x: 219., y: 117. });
|
||||
let accepted = app.document.bundle().clone();
|
||||
let _ = app.update(Message::Undo);
|
||||
assert_eq!(app.document.bundle(), &before);
|
||||
let _ = app.update(Message::Redo);
|
||||
assert_eq!(app.document.bundle(), &accepted);
|
||||
}
|
||||
#[test]
|
||||
fn architecture_context_choices_are_small_and_system_is_singleton() {
|
||||
let mut app = setup(Layer::Oa);
|
||||
assert_eq!(kinds(&app), vec![Kind::Entity, Kind::Actor, Kind::Activity]);
|
||||
app = setup(Layer::Sa);
|
||||
assert_eq!(
|
||||
kinds(&app),
|
||||
vec![Kind::System, Kind::SystemActor, Kind::Function]
|
||||
);
|
||||
send(
|
||||
&mut app,
|
||||
Event::Start(Kind::System, Some(Position { x: 60., y: 60. })),
|
||||
);
|
||||
send(&mut app, Event::Name("Pump controller".into()));
|
||||
send(&mut app, Event::Submit);
|
||||
assert_eq!(kinds(&app), vec![Kind::SystemActor, Kind::Function]);
|
||||
}
|
||||
#[test]
|
||||
fn architecture_name_survives_rejected_click_resize_navigation_and_escape_is_explicit() {
|
||||
let mut app = setup(Layer::Oa);
|
||||
let before = app.document.bundle().clone();
|
||||
send(&mut app, Event::Start(Kind::Activity, None));
|
||||
send(&mut app, Event::Name("Diagnose pressure".into()));
|
||||
send(&mut app, Event::Start(Kind::Entity, None));
|
||||
assert!(
|
||||
matches!(&app.architecture.as_ref().unwrap().saved.intent, architecture_authoring::Intent::Create { name, kind: Kind::Activity, .. } if name == "Diagnose pressure")
|
||||
);
|
||||
send(&mut app, Event::Submit);
|
||||
let mut stale = app.route_context().unwrap();
|
||||
stale.revision += 1;
|
||||
send(&mut app, Event::Click(stale, Position { x: 1., y: 2. }));
|
||||
let _ = app.update(Message::Page(Page::Home));
|
||||
let _ = app.update(Message::WindowResized(iced::Size::new(1100., 700.)));
|
||||
assert_eq!(app.page, Page::Diagram);
|
||||
assert!(app.architecture.is_some());
|
||||
assert_eq!(app.document.bundle(), &before);
|
||||
let _ = app.update(Message::Escape);
|
||||
assert!(app.architecture.is_none());
|
||||
assert_eq!(app.document.bundle(), &before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn architecture_existing_inventory_is_contextual_and_placement_never_implies_allocation() {
|
||||
let mut app = setup(Layer::Oa);
|
||||
let entity = app
|
||||
.project()
|
||||
.make_element(Kind::Entity, Layer::Oa, "Response team", None)
|
||||
.unwrap();
|
||||
let entity_id = entity.id;
|
||||
let work = app
|
||||
.project()
|
||||
.make_element(Kind::Activity, Layer::Oa, "Report status", None)
|
||||
.unwrap();
|
||||
let work_id = work.id;
|
||||
let data = app
|
||||
.project()
|
||||
.make_element(Kind::DataType, Layer::Oa, "Pressure", None)
|
||||
.unwrap();
|
||||
let data_id = data.id;
|
||||
assert!(app.execute(Command::Batch(vec![
|
||||
Command::CreateElement(entity),
|
||||
Command::CreateElement(work),
|
||||
Command::CreateElement(data)
|
||||
])));
|
||||
send(&mut app, Event::Existing(Some(Position { x: 80., y: 80. })));
|
||||
let ids: Vec<_> = architecture_authoring::candidates(&app)
|
||||
.iter()
|
||||
.map(|row| row.0)
|
||||
.collect();
|
||||
assert!(ids.contains(&entity_id) && ids.contains(&work_id));
|
||||
assert!(!ids.contains(&data_id));
|
||||
send(&mut app, Event::Pick(entity_id));
|
||||
let semantic = app.project().elements.clone();
|
||||
let before = app.document.bundle().clone();
|
||||
let camera = (app.zoom, app.pan);
|
||||
send(
|
||||
&mut app,
|
||||
Event::Existing(Some(Position { x: 90., y: 100. })),
|
||||
);
|
||||
assert!(
|
||||
!architecture_authoring::candidates(&app)
|
||||
.iter()
|
||||
.any(|row| row.0 == entity_id)
|
||||
);
|
||||
send(&mut app, Event::ShowPlaced(true));
|
||||
assert!(
|
||||
architecture_authoring::candidates(&app)
|
||||
.iter()
|
||||
.any(|row| row.0 == entity_id && row.2)
|
||||
);
|
||||
send(&mut app, Event::Pick(work_id));
|
||||
assert_eq!(app.project().elements, semantic);
|
||||
assert!(app.project().relations.is_empty());
|
||||
assert_eq!((app.zoom, app.pan), camera);
|
||||
let _ = app.update(Message::Undo);
|
||||
assert_eq!(app.document.bundle(), &before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn architecture_checkpoint_retains_exact_name_point_and_fresh_binding_without_commands() {
|
||||
for (name, placing) in [(" Team 雨 ", true), (" ", false)] {
|
||||
let mut app = setup(Layer::Oa);
|
||||
send(&mut app, Event::Start(Kind::Entity, None));
|
||||
send(&mut app, Event::Name(name.into()));
|
||||
if placing {
|
||||
send(&mut app, Event::Submit);
|
||||
}
|
||||
app.architecture.as_mut().unwrap().saved.point = Some(Position { x: 127.5, y: 215. });
|
||||
app.architecture.as_mut().unwrap().saved.placing = placing;
|
||||
let snapshot = crate::recovery::Snapshot::capture(
|
||||
app.canvas_document,
|
||||
&app.document,
|
||||
&app.source_workspace,
|
||||
app.draft.as_ref(),
|
||||
&app.behavior,
|
||||
None,
|
||||
app.selected,
|
||||
)
|
||||
.unwrap()
|
||||
.with_architecture(app.architecture.as_ref());
|
||||
let bytes = serde_json::to_vec(&snapshot).unwrap();
|
||||
let decoded: crate::recovery::Snapshot = serde_json::from_slice(&bytes).unwrap();
|
||||
let accepted = app.document.bundle().clone();
|
||||
let mut restored = setup(Layer::Sa);
|
||||
let _ = restored.update(Message::CheckpointLoaded(Ok(Arc::new(decoded))));
|
||||
assert_eq!(restored.document.bundle(), &accepted);
|
||||
let draft = &restored.architecture.as_ref().unwrap().saved;
|
||||
assert!(
|
||||
matches!(&draft.intent, architecture_authoring::Intent::Create { name: actual, .. } if actual == name)
|
||||
);
|
||||
assert_eq!(draft.placing, placing);
|
||||
assert_eq!(draft.point, Some(Position { x: 127.5, y: 215. }));
|
||||
if placing {
|
||||
let context = restored.route_context().unwrap();
|
||||
send(
|
||||
&mut restored,
|
||||
Event::Click(context, Position { x: 20., y: 30. }),
|
||||
);
|
||||
assert!(restored.architecture.as_ref().unwrap().inline());
|
||||
send(&mut restored, Event::Submit);
|
||||
assert!(restored.architecture.is_none());
|
||||
assert_eq!(restored.project().elements[&restored.selected].name, name);
|
||||
} else {
|
||||
send(&mut restored, Event::Submit);
|
||||
assert!(restored.architecture.is_some());
|
||||
assert_eq!(restored.document.bundle(), &accepted);
|
||||
}
|
||||
let mut value: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
|
||||
value["architecture"]["base"] = json!("stale");
|
||||
assert!(
|
||||
serde_json::from_value::<crate::recovery::Snapshot>(value)
|
||||
.unwrap()
|
||||
.restore()
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn architecture_canvas_left_click_uses_world_coordinate_once_at_zoom_and_pan() {
|
||||
use iced::widget::canvas::Program;
|
||||
let mut app = setup(Layer::Oa);
|
||||
send(&mut app, Event::Start(Kind::Actor, None));
|
||||
send(&mut app, Event::Name("Control centre".into()));
|
||||
send(&mut app, Event::Submit);
|
||||
let bounds = iced::Rectangle {
|
||||
x: 260.,
|
||||
y: 180.,
|
||||
width: 600.,
|
||||
height: 380.,
|
||||
};
|
||||
let local = iced::Point::new(190., 100.);
|
||||
let expected = Position {
|
||||
x: (local.x - app.pan.x) / app.zoom,
|
||||
y: (local.y - app.pan.y) / app.zoom,
|
||||
};
|
||||
let canvas = crate::diagram::DiagramCanvas {
|
||||
project: app.project(),
|
||||
view_index: None,
|
||||
diagram: app.active_diagram.unwrap(),
|
||||
selected: app.selected,
|
||||
navigation_focus: None,
|
||||
zoom: app.zoom,
|
||||
pan: app.pan,
|
||||
context: app.route_context().unwrap(),
|
||||
selected_bend: None,
|
||||
editable: true,
|
||||
placing: true,
|
||||
connection: None,
|
||||
};
|
||||
let mut state = crate::diagram::State::default();
|
||||
let action = canvas
|
||||
.update(
|
||||
&mut state,
|
||||
&iced::widget::canvas::Event::Mouse(iced::mouse::Event::ButtonPressed(
|
||||
iced::mouse::Button::Left,
|
||||
)),
|
||||
bounds,
|
||||
iced::mouse::Cursor::Available(iced::Point::new(
|
||||
bounds.x + local.x,
|
||||
bounds.y + local.y,
|
||||
)),
|
||||
)
|
||||
.unwrap();
|
||||
let message = action
|
||||
.into_inner()
|
||||
.0
|
||||
.expect("named placement emits a command");
|
||||
let _ = app.update(message);
|
||||
assert_eq!(
|
||||
app.architecture.as_ref().unwrap().saved.point,
|
||||
Some(expected)
|
||||
);
|
||||
send(&mut app, Event::Submit);
|
||||
let node =
|
||||
&app.project().diagrams[&app.active_diagram.unwrap()].nodes[&app.selected_node.unwrap()];
|
||||
assert_eq!(node.position, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn architecture_cancelled_gesture_cannot_commit_a_later_named_draft() {
|
||||
let mut app = setup(Layer::Oa);
|
||||
send(&mut app, Event::Start(Kind::Entity, None));
|
||||
send(&mut app, Event::Name("First".into()));
|
||||
send(&mut app, Event::Submit);
|
||||
let stale = app.route_context().unwrap();
|
||||
send(&mut app, Event::Cancel);
|
||||
send(&mut app, Event::Start(Kind::Actor, None));
|
||||
send(&mut app, Event::Name("Second".into()));
|
||||
send(&mut app, Event::Submit);
|
||||
let before = app.document.bundle().clone();
|
||||
send(&mut app, Event::Click(stale, Position { x: 1., y: 1. }));
|
||||
assert_eq!(app.document.bundle(), &before);
|
||||
assert!(app.architecture.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn architecture_f2_rename_preserves_identity_placement_and_atomic_history() {
|
||||
use iced::{keyboard, mouse, widget::canvas::Program};
|
||||
let mut app = setup(Layer::Oa);
|
||||
send(
|
||||
&mut app,
|
||||
Event::Start(Kind::Entity, Some(Position { x: 200., y: 120. })),
|
||||
);
|
||||
send(&mut app, Event::Name("Response team".into()));
|
||||
send(&mut app, Event::Submit);
|
||||
let before = app.document.bundle().clone();
|
||||
let element = app.selected;
|
||||
let node = app.selected_node;
|
||||
let diagrams = app.project().diagrams.clone();
|
||||
let camera = (app.zoom, app.pan);
|
||||
let canvas = crate::diagram::DiagramCanvas {
|
||||
project: app.project(),
|
||||
view_index: None,
|
||||
diagram: app.active_diagram.unwrap(),
|
||||
selected: element,
|
||||
navigation_focus: None,
|
||||
zoom: app.zoom,
|
||||
pan: app.pan,
|
||||
context: app.route_context().unwrap(),
|
||||
selected_bend: None,
|
||||
editable: true,
|
||||
placing: false,
|
||||
connection: None,
|
||||
};
|
||||
let bounds = iced::Rectangle::with_size(iced::Size::new(800., 600.));
|
||||
let cursor = mouse::Cursor::Available(iced::Point::new(500., 400.));
|
||||
let mut state = crate::diagram::State::default();
|
||||
let key = iced::widget::canvas::Event::Keyboard(keyboard::Event::KeyPressed {
|
||||
key: keyboard::Key::Named(keyboard::key::Named::F2),
|
||||
modified_key: keyboard::Key::Named(keyboard::key::Named::F2),
|
||||
physical_key: keyboard::key::Physical::Code(keyboard::key::Code::F2),
|
||||
location: keyboard::Location::Standard,
|
||||
modifiers: keyboard::Modifiers::empty(),
|
||||
text: None,
|
||||
repeat: false,
|
||||
});
|
||||
assert!(canvas.update(&mut state, &key, bounds, cursor).is_none());
|
||||
let _ = canvas.update(
|
||||
&mut state,
|
||||
&iced::widget::canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
|
||||
bounds,
|
||||
cursor,
|
||||
);
|
||||
let message = canvas
|
||||
.update(&mut state, &key, bounds, cursor)
|
||||
.expect("focused F2 opens the selected semantic name")
|
||||
.into_inner()
|
||||
.0
|
||||
.unwrap();
|
||||
let _ = app.update(message);
|
||||
assert!(app.architecture.as_ref().unwrap().inline());
|
||||
send(&mut app, Event::Name(" Emergency team 雨 ".into()));
|
||||
assert_eq!(app.document.bundle(), &before);
|
||||
send(&mut app, Event::Submit);
|
||||
assert!(app.architecture.is_none());
|
||||
assert_eq!((app.selected, app.selected_node), (element, node));
|
||||
assert_eq!(
|
||||
app.project().elements[&element].name,
|
||||
" Emergency team 雨 "
|
||||
);
|
||||
assert_eq!(app.project().diagrams, diagrams);
|
||||
assert_eq!((app.zoom, app.pan), camera);
|
||||
let accepted = app.document.bundle().clone();
|
||||
let _ = app.update(Message::Undo);
|
||||
assert_eq!(app.document.bundle(), &before);
|
||||
let _ = app.update(Message::Redo);
|
||||
assert_eq!(app.document.bundle(), &accepted);
|
||||
let context = app.route_context().unwrap();
|
||||
send(
|
||||
&mut app,
|
||||
Event::Rename(context, element, Position { x: 200., y: 120. }),
|
||||
);
|
||||
send(&mut app, Event::Name("Cancelled name".into()));
|
||||
let _ = app.update(Message::Escape);
|
||||
assert!(app.architecture.is_none());
|
||||
assert_eq!(app.document.bundle(), &accepted);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn architecture_contextual_allocation_creates_work_relation_and_node_in_one_undo() {
|
||||
for (layer, performer_kind, work_kind) in [
|
||||
(Layer::Oa, Kind::Entity, Kind::Activity),
|
||||
(Layer::Sa, Kind::System, Kind::Function),
|
||||
] {
|
||||
let mut app = setup(layer);
|
||||
send(
|
||||
&mut app,
|
||||
Event::Start(performer_kind, Some(Position { x: 20., y: 20. })),
|
||||
);
|
||||
send(&mut app, Event::Name("Control team".into()));
|
||||
send(&mut app, Event::Submit);
|
||||
let performer = app.selected;
|
||||
let before = app.document.bundle().clone();
|
||||
send(
|
||||
&mut app,
|
||||
Event::Allocate(work_kind, performer, Some(Position { x: 55., y: 90. })),
|
||||
);
|
||||
send(&mut app, Event::Name("Assess pressure".into()));
|
||||
assert_eq!(app.document.bundle(), &before);
|
||||
send(&mut app, Event::Submit);
|
||||
assert!(app.architecture.is_none(), "{}", app.status);
|
||||
let work = app.selected;
|
||||
assert_eq!(app.project().elements[&work].parent, None);
|
||||
assert!(
|
||||
app.project()
|
||||
.relations
|
||||
.values()
|
||||
.any(|r| r.kind() == RelationKind::Allocation
|
||||
&& r.source() == performer
|
||||
&& r.target() == work)
|
||||
);
|
||||
let node = &app.project().diagrams[&app.active_diagram.unwrap()].nodes
|
||||
[&app.selected_node.unwrap()];
|
||||
assert_eq!(node.position, Position { x: 55., y: 90. });
|
||||
let accepted = app.document.bundle().clone();
|
||||
let _ = app.update(Message::Undo);
|
||||
assert_eq!(app.document.bundle(), &before);
|
||||
let _ = app.update(Message::Redo);
|
||||
assert_eq!(app.document.bundle(), &accepted);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn architecture_rejected_submission_keeps_inline_name_visible_and_invalid_click_is_ignored() {
|
||||
let mut app = setup(Layer::Oa);
|
||||
send(&mut app, Event::Start(Kind::Entity, None));
|
||||
let context = app.route_context().unwrap();
|
||||
send(
|
||||
&mut app,
|
||||
Event::Click(
|
||||
context,
|
||||
Position {
|
||||
x: f32::NAN,
|
||||
y: 30.,
|
||||
},
|
||||
),
|
||||
);
|
||||
assert!(app.architecture.as_ref().unwrap().unnamed_tool());
|
||||
send(&mut app, Event::Click(context, Position { x: 40., y: 30. }));
|
||||
send(&mut app, Event::Name("Retained team".into()));
|
||||
// A replaced document must not accept a queued draft submission.
|
||||
app.canvas_document = Id::new_v4();
|
||||
let before = app.document.bundle().clone();
|
||||
send(&mut app, Event::Submit);
|
||||
let draft = app.architecture.as_ref().unwrap();
|
||||
assert!(draft.inline());
|
||||
assert!(draft.error.is_some());
|
||||
assert!(
|
||||
matches!(&draft.saved.intent, architecture_authoring::Intent::Create { name, .. } if name == "Retained team")
|
||||
);
|
||||
assert_eq!(app.document.bundle(), &before);
|
||||
let _ = app.update(Message::Escape);
|
||||
assert!(app.architecture.is_none());
|
||||
assert_eq!(app.document.bundle(), &before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn basic_transition_explicit_overview_focus_wins_over_an_already_populated_architecture() {
|
||||
use crate::workflow::{Event as W, Workflow};
|
||||
let mut p = Project::empty();
|
||||
let work = p
|
||||
.add_element(Kind::Activity, Layer::Oa, "Report status", None)
|
||||
.unwrap();
|
||||
let architecture = p.ensure_diagram(Layer::Sa);
|
||||
p.diagrams.get_mut(&architecture).unwrap().projection = ProjectionKind::Allocation;
|
||||
let mut first = Workflow::transition(&p, work).unwrap();
|
||||
let Workflow::Transition(t) = &first else {
|
||||
unreachable!()
|
||||
};
|
||||
let target = t
|
||||
.plan
|
||||
.changes
|
||||
.iter()
|
||||
.find(|c| c.source == work)
|
||||
.unwrap()
|
||||
.target;
|
||||
let mut p = first.update(&p, W::Apply).unwrap().unwrap();
|
||||
let mut overview = p.diagrams[&architecture].clone();
|
||||
overview.id = Id::new_v4();
|
||||
overview.alias = "chosen_overview".into();
|
||||
overview.name = "Chosen system overview".into();
|
||||
overview.projection = ProjectionKind::Overview;
|
||||
overview.nodes.clear();
|
||||
let chosen = overview.id;
|
||||
p.diagrams.insert(chosen, overview);
|
||||
let placement = p.place(chosen, target, Position { x: 823., y: 417. });
|
||||
let mut app = App::with_project(p);
|
||||
app.selected = work;
|
||||
app.active_diagram = Some(architecture);
|
||||
app.refresh_draft();
|
||||
let _ = app.update(Message::StartTransition);
|
||||
let _ = app.update(Message::Workflow(W::Destination(Choice {
|
||||
id: chosen,
|
||||
label: "Chosen system overview".into(),
|
||||
})));
|
||||
let before = app.project().diagrams.clone();
|
||||
let _ = app.update(Message::Workflow(W::Apply));
|
||||
assert!(app.workflow.is_none(), "{}", app.status);
|
||||
assert_eq!(app.active_diagram, Some(chosen));
|
||||
assert_eq!(app.selected, target);
|
||||
assert_eq!(app.selected_node, Some(placement));
|
||||
assert_eq!(app.project().diagrams, before);
|
||||
}
|
||||
@@ -0,0 +1,713 @@
|
||||
//! Home navigation and asynchronous private checkpoints share the normal guards.
|
||||
use super::*;
|
||||
use crate::{home, recovery};
|
||||
|
||||
impl App {
|
||||
pub(super) fn remember_local(&mut self) {
|
||||
if let Some(path) = self.path.clone() {
|
||||
let name = self.project().name.clone();
|
||||
if let Err(error) = self.home.remember(&path, &name) {
|
||||
self.home.notice = Some(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
pub(super) fn home_event(&mut self, event: home::Event) -> Task<Message> {
|
||||
match event {
|
||||
home::Event::New => self.handle(Message::Request(Pending::New)),
|
||||
home::Event::Open => self.handle(Message::Request(Pending::Open)),
|
||||
home::Event::Example => self.handle(Message::Request(Pending::Demo)),
|
||||
home::Event::Walkthrough => self.handle(Message::Request(Pending::Walkthrough)),
|
||||
home::Event::Guide => self.update(Message::Page(Page::Guide)),
|
||||
home::Event::Resume => self.update(Message::Page(self.last_workspace_page)),
|
||||
home::Event::OpenRecent(path) => {
|
||||
self.recent_target = Some(path);
|
||||
self.handle(Message::Request(Pending::Recent))
|
||||
}
|
||||
home::Event::Recover(id) => self.handle(Message::Request(Pending::Recover(id))),
|
||||
home::Event::Forget(path) => {
|
||||
if let Err(error) = self.home.forget(&path) {
|
||||
self.home.notice = Some(error);
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
home::Event::Refresh => {
|
||||
self.home.refresh();
|
||||
self.recovery.refresh();
|
||||
self.recovery.entries.retain(|entry| {
|
||||
entry.id != self.canvas_document && Some(entry.id) != self.recovery.restored
|
||||
});
|
||||
Task::none()
|
||||
}
|
||||
}
|
||||
}
|
||||
pub(super) fn checkpoint_tick(&mut self) -> Task<Message> {
|
||||
if self.recovery.writing || self.busy {
|
||||
return Task::none();
|
||||
}
|
||||
if !self.dirty() {
|
||||
self.retire_checkpoint();
|
||||
return Task::none();
|
||||
}
|
||||
let Some(directory) = self.recovery.directory.clone() else {
|
||||
return Task::none();
|
||||
};
|
||||
let id = self.canvas_document;
|
||||
let snapshot = match recovery::Snapshot::capture(
|
||||
id,
|
||||
&self.document,
|
||||
&self.source_workspace,
|
||||
self.draft.as_ref(),
|
||||
&self.behavior,
|
||||
self.path.clone(),
|
||||
self.selected,
|
||||
)
|
||||
.and_then(|snapshot| snapshot.with_workflow(self.workflow.as_ref()))
|
||||
.map(|snapshot| snapshot.with_architecture(self.architecture.as_ref()))
|
||||
{
|
||||
Ok(snapshot) => snapshot,
|
||||
Err(error) => {
|
||||
self.recovery.warning = Some(error);
|
||||
return Task::none();
|
||||
}
|
||||
};
|
||||
self.recovery.writing = true;
|
||||
Task::perform(
|
||||
async move {
|
||||
tokio::task::spawn_blocking(move || recovery::write(&directory, &snapshot))
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
},
|
||||
move |result| Message::CheckpointWritten(id, result),
|
||||
)
|
||||
}
|
||||
pub(super) fn checkpoint_written(&mut self, id: Id, result: Result<(), String>) {
|
||||
self.recovery.writing = false;
|
||||
match result {
|
||||
Ok(()) if id == self.canvas_document && self.dirty() => {
|
||||
self.recovery.active = Some(id);
|
||||
}
|
||||
Ok(()) => {
|
||||
if let Some(directory) = &self.recovery.directory
|
||||
&& let Err(error) = recovery::remove(directory, id)
|
||||
{
|
||||
self.recovery.warning = Some(error);
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
self.recovery.warning = Some(format!(
|
||||
"Recovery checkpoint failed: {error}. Save your work explicitly."
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
pub(super) fn retire_checkpoint(&mut self) {
|
||||
let Some(directory) = &self.recovery.directory else {
|
||||
return;
|
||||
};
|
||||
for id in [self.recovery.active.take(), self.recovery.restored.take()]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
if let Err(error) = recovery::remove(directory, id) {
|
||||
self.recovery.warning = Some(error);
|
||||
} else {
|
||||
self.recovery.entries.retain(|e| e.id != id);
|
||||
}
|
||||
}
|
||||
}
|
||||
pub(super) fn load_checkpoint(&mut self, id: Id) -> Task<Message> {
|
||||
let Some(directory) = self.recovery.directory.clone() else {
|
||||
return Task::none();
|
||||
};
|
||||
self.busy = true;
|
||||
Task::perform(
|
||||
async move {
|
||||
tokio::task::spawn_blocking(move || recovery::read(&directory, id).map(Arc::new))
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
},
|
||||
Message::CheckpointLoaded,
|
||||
)
|
||||
}
|
||||
pub(super) fn checkpoint_loaded(
|
||||
&mut self,
|
||||
result: Result<Arc<recovery::Snapshot>, String>,
|
||||
) -> Task<Message> {
|
||||
self.busy = false;
|
||||
let snapshot = match result {
|
||||
Ok(s) => s,
|
||||
Err(error) => {
|
||||
self.status = error;
|
||||
return Task::none();
|
||||
}
|
||||
};
|
||||
let id = snapshot.id;
|
||||
let restored = match (*snapshot).clone().restore() {
|
||||
Ok(r) => r,
|
||||
Err(error) => {
|
||||
self.status = error;
|
||||
return Task::none();
|
||||
}
|
||||
};
|
||||
// Fully prepare recovery before touching the currently accepted document.
|
||||
self.reset(restored.document.project().clone(), None);
|
||||
self.hosted.leave_document();
|
||||
self.document = restored.document;
|
||||
self.migration = self.document.compiled().decoded.migration.clone();
|
||||
self.selected = if self.project().layer_of(restored.selected).is_some()
|
||||
|| restored.selected == self.project().id
|
||||
{
|
||||
restored.selected
|
||||
} else {
|
||||
self.project().id
|
||||
};
|
||||
// Installing the raw workspace must follow all accepted-source synchronization.
|
||||
self.source_workspace = restored.workspace;
|
||||
self.show_source_file(false);
|
||||
self.refresh_draft();
|
||||
if let Some(draft) = restored.property {
|
||||
self.selected = draft.original;
|
||||
self.exact_strings = draft.exact_strings;
|
||||
self.draft = Some(draft);
|
||||
}
|
||||
self.behavior = restored.behavior;
|
||||
self.workflow = restored.workflow;
|
||||
let focus = self.behavior.pending_owner().unwrap_or(self.selected);
|
||||
if let Some(layer) = self
|
||||
.behavior
|
||||
.creation
|
||||
.pending_layer()
|
||||
.or_else(|| self.project().layer_of(focus))
|
||||
{
|
||||
self.layer = layer;
|
||||
self.explorer_index = crate::explorer::Index::new(self.project(), layer);
|
||||
self.active_diagram = self.diagram_for(layer, focus);
|
||||
}
|
||||
self.recovery.restored = Some(id);
|
||||
self.recovery.entries.retain(|entry| entry.id != id);
|
||||
self.page = if self.behavior.has_pending() {
|
||||
Page::Behavior
|
||||
} else if self.source_dirty() {
|
||||
Page::Text
|
||||
} else {
|
||||
Page::Diagram
|
||||
};
|
||||
if let Some(saved) = restored.architecture {
|
||||
self.active_diagram = Some(saved.diagram);
|
||||
self.layer = self.project().diagrams[&saved.diagram].layer;
|
||||
self.explorer_index = crate::explorer::Index::new(self.project(), self.layer);
|
||||
self.page = Page::Diagram;
|
||||
self.architecture = architecture_authoring::State::restore(self, saved).ok();
|
||||
}
|
||||
self.status = if let Some(report) = &self.migration {
|
||||
format!(
|
||||
"Recovered source {} as source {} in an unsaved local copy. Draft text is preserved; workflow previews were rebuilt and semantic impact approvals need review. Choose Save as to save it.",
|
||||
report.from_schema, report.to_schema
|
||||
)
|
||||
} else {
|
||||
"Recovered an unsaved local copy. Choose Save as to save it; the original file and hosted model are unchanged.".into()
|
||||
};
|
||||
let lint = self.request_lint();
|
||||
if self
|
||||
.architecture
|
||||
.as_ref()
|
||||
.is_some_and(architecture_authoring::State::inline)
|
||||
{
|
||||
Task::batch([lint, iced::widget::operation::focus("architecture-name")])
|
||||
} else if self.page == Page::Behavior && self.behavior.source_open() {
|
||||
Task::batch([
|
||||
lint,
|
||||
iced::widget::operation::focus(behavior::source_draft::NAME_INPUT),
|
||||
])
|
||||
} else if self.page == Page::Behavior && self.behavior.creation.created().is_some() {
|
||||
Task::batch([
|
||||
lint,
|
||||
iced::widget::operation::focus(behavior::creation::NAME_INPUT),
|
||||
])
|
||||
} else {
|
||||
lint
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn home_resume_returns_to_each_actual_workspace_without_model_edits() {
|
||||
let mut app = App::with_project(Project::demo());
|
||||
let accepted = app.document.snapshot();
|
||||
for page in [
|
||||
Page::Diagram,
|
||||
Page::Catalog,
|
||||
Page::Behavior,
|
||||
Page::Data,
|
||||
Page::Traces,
|
||||
Page::Text,
|
||||
Page::Validation,
|
||||
Page::Guide,
|
||||
] {
|
||||
let _ = app.update(Message::Page(page));
|
||||
assert_eq!(app.page, page);
|
||||
let selected = app.selected;
|
||||
let _ = app.update(Message::Page(Page::Home));
|
||||
assert_eq!(app.last_workspace_page, page);
|
||||
// Repeated Home events must not replace the remembered workspace.
|
||||
let _ = app.update(Message::Page(Page::Home));
|
||||
let _ = app.update(Message::Home(home::Event::Resume));
|
||||
assert_eq!(app.page, page);
|
||||
assert_eq!(app.selected, selected);
|
||||
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn home_resume_keeps_state_view_while_explicit_architecture_remains_reachable() {
|
||||
let mut project = Project::empty();
|
||||
let owner = project
|
||||
.add_element(Kind::System, Layer::Sa, "System", None)
|
||||
.unwrap();
|
||||
let machine = project
|
||||
.add_element(Kind::StateMachine, Layer::Sa, "Modes", Some(owner))
|
||||
.unwrap();
|
||||
let plan = crate::behavior::state_edit::create_view(&project, machine).unwrap();
|
||||
project = prepare_command(&project, &plan.command).unwrap().project;
|
||||
let mut app = App::with_project(project);
|
||||
assert_eq!(app.page, Page::Behavior);
|
||||
let accepted = app.document.snapshot();
|
||||
let _ = app.update(Message::Page(Page::Home));
|
||||
let _ = app.update(Message::Home(home::Event::Resume));
|
||||
assert_eq!(app.page, Page::Behavior);
|
||||
assert_eq!(app.state_context().unwrap().diagram, plan.diagram);
|
||||
assert_eq!(app.behavior.active_owner, Some(machine));
|
||||
let _ = app.update(Message::Page(Page::Diagram));
|
||||
assert_eq!(app.page, Page::Diagram);
|
||||
assert!(app.active_diagram.is_none());
|
||||
let _ = app.update(Message::Page(Page::Home));
|
||||
let _ = app.update(Message::Home(home::Event::Resume));
|
||||
assert_eq!(app.page, Page::Diagram);
|
||||
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn home_resume_retains_invalid_source_and_property_drafts() {
|
||||
let mut app = App::with_project(Project::demo());
|
||||
let accepted = app.document.snapshot();
|
||||
let _ = app.update(Message::Page(Page::Text));
|
||||
let raw = format!("{}\nunfinished draft", app.source_workspace.text());
|
||||
app.source_workspace
|
||||
.edit(raw.clone(), app.source_editor.cursor());
|
||||
let _ = app.update(Message::Page(Page::Home));
|
||||
let _ = app.update(Message::Home(home::Event::Resume));
|
||||
assert_eq!(app.page, Page::Text);
|
||||
assert_eq!(app.source_workspace.text(), raw);
|
||||
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
||||
|
||||
let mut app = App::with_project(Project::demo());
|
||||
let accepted = app.document.snapshot();
|
||||
let _ = app.update(Message::Page(Page::Data));
|
||||
let _ = app.update(Message::Field("/id".into(), "-".into()));
|
||||
let raw = serde_json::to_value(app.draft.as_ref().unwrap().recovery_snapshot()).unwrap();
|
||||
assert!(app.draft_dirty());
|
||||
let _ = app.update(Message::Page(Page::Home));
|
||||
let _ = app.update(Message::Home(home::Event::Resume));
|
||||
assert_eq!(app.page, Page::Data);
|
||||
assert_eq!(
|
||||
serde_json::to_value(app.draft.as_ref().unwrap().recovery_snapshot()).unwrap(),
|
||||
raw
|
||||
);
|
||||
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn walkthrough_request_keeps_unfinished_source_behind_the_normal_pending_guard() {
|
||||
let mut app = App::with_project(Project::demo());
|
||||
let accepted = app.document.snapshot();
|
||||
let raw = format!(
|
||||
"{}\nunfinished walkthrough draft",
|
||||
app.source_workspace.text()
|
||||
);
|
||||
app.source_workspace
|
||||
.edit(raw.clone(), app.source_editor.cursor());
|
||||
let task = app.update(Message::Home(home::Event::Walkthrough));
|
||||
assert!(matches!(app.pending, Some(Pending::Walkthrough)));
|
||||
assert!(!app.busy);
|
||||
assert!(iced_runtime::task::into_stream(task).is_none());
|
||||
assert_eq!(app.source_workspace.text(), raw);
|
||||
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
||||
let _ = app.update(Message::Escape);
|
||||
assert!(app.pending.is_none());
|
||||
assert_eq!(app.source_workspace.text(), raw);
|
||||
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
||||
}
|
||||
|
||||
fn retained_snapshot(app: &App) -> recovery::Snapshot {
|
||||
recovery::Snapshot::capture(
|
||||
app.canvas_document,
|
||||
&app.document,
|
||||
&app.source_workspace,
|
||||
app.draft.as_ref(),
|
||||
&app.behavior,
|
||||
app.path.clone(),
|
||||
app.selected,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
async fn run_recovery_tasks(app: &mut App, task: Task<Message>) -> (usize, bool, bool) {
|
||||
use iced::advanced::{Layout, layout, widget};
|
||||
use iced::futures::StreamExt;
|
||||
// Mount real inputs, with focus initially in another control. This
|
||||
// verifies the returned operation targets the naming field by its ID.
|
||||
let mut fields: iced::Element<'_, (), iced::Theme, ()> = iced::widget::column![
|
||||
iced::widget::text_input("Engineering name", "Retained name")
|
||||
.id("architecture-name")
|
||||
.on_input(|_| ()),
|
||||
iced::widget::text_input("Other control", "Other value")
|
||||
.id("unrelated-field")
|
||||
.on_input(|_| ())
|
||||
]
|
||||
.into();
|
||||
let mut tree = widget::Tree::new(fields.as_widget());
|
||||
let node = fields.as_widget_mut().layout(
|
||||
&mut tree,
|
||||
&(),
|
||||
&layout::Limits::new(iced::Size::ZERO, iced::Size::new(320., 100.)),
|
||||
);
|
||||
tree.children[1]
|
||||
.state
|
||||
.downcast_mut::<iced::widget::text_input::State<()>>()
|
||||
.focus();
|
||||
let mut operations = 0;
|
||||
let mut lint_completions = 0;
|
||||
let mut stream = iced_runtime::task::into_stream(task).expect("Recovery must still lint");
|
||||
while let Some(action) = stream.next().await {
|
||||
match action {
|
||||
iced_runtime::Action::Widget(mut operation) => {
|
||||
operations += 1;
|
||||
fields.as_widget_mut().operate(
|
||||
&mut tree,
|
||||
Layout::new(&node),
|
||||
&(),
|
||||
operation.as_mut(),
|
||||
);
|
||||
}
|
||||
iced_runtime::Action::Output(message @ Message::Linted(_, _)) => {
|
||||
lint_completions += 1;
|
||||
let _ = app.update(message);
|
||||
}
|
||||
_ => panic!("Unexpected recovery task action"),
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
lint_completions, 1,
|
||||
"Autofocus must not replace request_lint"
|
||||
);
|
||||
assert!(app.lint_running.is_none());
|
||||
let focused = |index: usize| {
|
||||
tree.children[index]
|
||||
.state
|
||||
.downcast_ref::<iced::widget::text_input::State<()>>()
|
||||
.is_focused()
|
||||
};
|
||||
(operations, focused(0), focused(1))
|
||||
}
|
||||
|
||||
fn architecture_recovery_app() -> App {
|
||||
let mut app = App::with_project(Project::empty());
|
||||
let _ = app.update(Message::AddDiagram);
|
||||
let _ = app.update(Message::Architecture(architecture_authoring::Event::Start(
|
||||
Kind::Entity,
|
||||
Some(Position { x: 80., y: 90. }),
|
||||
)));
|
||||
let _ = app.update(Message::Architecture(architecture_authoring::Event::Name(
|
||||
"Accepted team".into(),
|
||||
)));
|
||||
let _ = app.update(Message::Architecture(architecture_authoring::Event::Submit));
|
||||
assert!(app.architecture.is_none());
|
||||
app
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn checkpoint_inline_create_and_rename_restore_focus_without_changing_drafts_or_history()
|
||||
{
|
||||
use architecture_authoring::Event;
|
||||
for rename in [false, true] {
|
||||
let mut app = architecture_recovery_app();
|
||||
let point = Position { x: 439., y: 328.2 };
|
||||
let event = if rename {
|
||||
Event::Rename(app.route_context().unwrap(), app.selected, point)
|
||||
} else {
|
||||
Event::Start(Kind::Actor, Some(point))
|
||||
};
|
||||
let _ = app.update(Message::Architecture(event));
|
||||
let _ = app.update(Message::Architecture(Event::Name(
|
||||
" Recovered engineering name 雨 ".into(),
|
||||
)));
|
||||
let saved = serde_json::to_value(&app.architecture.as_ref().unwrap().saved).unwrap();
|
||||
let snapshot = retained_snapshot(&app).with_architecture(app.architecture.as_ref());
|
||||
let expected = snapshot.clone().restore().unwrap();
|
||||
let checkpoint = snapshot.id;
|
||||
let mut recovered = App::with_project(Project::empty());
|
||||
let task = recovered.checkpoint_loaded(Ok(Arc::new(snapshot)));
|
||||
assert_eq!(
|
||||
run_recovery_tasks(&mut recovered, task).await,
|
||||
(1, true, false)
|
||||
);
|
||||
assert_eq!(recovered.page, Page::Diagram);
|
||||
assert!(recovered.architecture.as_ref().unwrap().inline());
|
||||
assert_eq!(
|
||||
serde_json::to_value(&recovered.architecture.as_ref().unwrap().saved).unwrap(),
|
||||
saved
|
||||
);
|
||||
assert_eq!(recovered.project(), expected.document.project());
|
||||
assert_eq!(recovered.document.bundle(), expected.document.bundle());
|
||||
assert_eq!(recovered.document.revision(), expected.document.revision());
|
||||
assert_eq!(recovered.document.can_undo(), expected.document.can_undo());
|
||||
assert_eq!(recovered.document.can_redo(), expected.document.can_redo());
|
||||
assert_eq!(
|
||||
recovered.source_workspace.bundle(),
|
||||
expected.workspace.bundle()
|
||||
);
|
||||
assert_eq!(recovered.recovery.restored, Some(checkpoint));
|
||||
assert!(recovered.path.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn checkpoint_noninline_contexts_keep_other_focus_and_still_lint() {
|
||||
use architecture_authoring::Event;
|
||||
for context in ["accepted", "armed", "picker", "property", "source"] {
|
||||
let mut app = architecture_recovery_app();
|
||||
match context {
|
||||
"armed" => {
|
||||
let _ = app.update(Message::Architecture(Event::Start(Kind::Actor, None)));
|
||||
}
|
||||
"picker" => {
|
||||
let _ = app.update(Message::Architecture(Event::Existing(None)));
|
||||
}
|
||||
"property" => {
|
||||
app.draft
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.input("/name", "Unapplied property".into());
|
||||
assert!(app.draft_dirty());
|
||||
}
|
||||
"source" => {
|
||||
app.source_workspace
|
||||
.edit("incomplete source 雨".into(), app.source_editor.cursor());
|
||||
assert!(app.source_dirty());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let snapshot = retained_snapshot(&app).with_architecture(app.architecture.as_ref());
|
||||
let expected = snapshot.clone().restore().unwrap();
|
||||
let mut recovered = App::with_project(Project::empty());
|
||||
let task = recovered.checkpoint_loaded(Ok(Arc::new(snapshot)));
|
||||
assert_eq!(
|
||||
run_recovery_tasks(&mut recovered, task).await,
|
||||
(0, false, true),
|
||||
"{context}"
|
||||
);
|
||||
assert!(
|
||||
!recovered
|
||||
.architecture
|
||||
.as_ref()
|
||||
.is_some_and(architecture_authoring::State::inline)
|
||||
);
|
||||
assert_eq!(recovered.project(), expected.document.project());
|
||||
assert_eq!(recovered.document.bundle(), expected.document.bundle());
|
||||
assert_eq!(recovered.document.revision(), expected.document.revision());
|
||||
assert_eq!(
|
||||
recovered.source_workspace.bundle(),
|
||||
expected.workspace.bundle()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saving_an_older_snapshot_keeps_recovery_for_newer_unsaved_work() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let mut app = App::with_project(Project::demo());
|
||||
app.recovery.directory = Some(directory.path().join("recovery"));
|
||||
let id = app.project().id;
|
||||
app.execute(Command::Rename(id, "Saved milestone".into()));
|
||||
let path = directory.path().join(&app.document.bundle().entry);
|
||||
let receipt = write_snapshot(
|
||||
app.canvas_document,
|
||||
app.document.revision(),
|
||||
app.document.snapshot(),
|
||||
path,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
app.execute(Command::Rename(id, "Later unsaved engineering".into()));
|
||||
let snapshot = retained_snapshot(&app);
|
||||
let recovery_directory = app.recovery.directory.clone().unwrap();
|
||||
recovery::write(&recovery_directory, &snapshot).unwrap();
|
||||
app.recovery.active = Some(app.canvas_document);
|
||||
let _ = app.update(Message::Saved(Ok(Some(receipt))));
|
||||
assert!(app.dirty());
|
||||
assert_eq!(app.project().name, "Later unsaved engineering");
|
||||
assert_eq!(app.recovery.active, Some(app.canvas_document));
|
||||
let restored = recovery::read(&recovery_directory, app.canvas_document)
|
||||
.unwrap()
|
||||
.restore()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
restored.document.project().name,
|
||||
"Later unsaved engineering"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checkpoint_completion_releases_the_writer_while_a_context_menu_is_open() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let mut app = App::with_project(Project::demo());
|
||||
app.recovery.directory = Some(directory.path().into());
|
||||
let id = app.project().id;
|
||||
app.execute(Command::Rename(id, "Unsaved engineering".into()));
|
||||
let snapshot = retained_snapshot(&app);
|
||||
recovery::write(directory.path(), &snapshot).unwrap();
|
||||
app.recovery.writing = true;
|
||||
let _ = app.update(Message::Menu(crate::model_menu::Event::Open(
|
||||
crate::model_menu::Request::object(id, iced::Point::ORIGIN),
|
||||
)));
|
||||
assert!(app.model_menu.is_some());
|
||||
let _ = app.update(Message::CheckpointWritten(app.canvas_document, Ok(())));
|
||||
assert!(
|
||||
!app.recovery.writing,
|
||||
"A passive completion cannot be swallowed by an open menu"
|
||||
);
|
||||
assert_eq!(app.recovery.active, Some(app.canvas_document));
|
||||
assert!(
|
||||
app.model_menu.is_some(),
|
||||
"Saving a checkpoint does not dismiss the menu"
|
||||
);
|
||||
assert_eq!(app.project().name, "Unsaved engineering");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discard_and_close_waits_for_the_late_checkpoint_then_retires_it() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let mut app = App::with_project(Project::demo());
|
||||
app.recovery.directory = Some(directory.path().into());
|
||||
app.execute(Command::Rename(
|
||||
app.project().id,
|
||||
"Discarded on close".into(),
|
||||
));
|
||||
let snapshot = retained_snapshot(&app);
|
||||
let document = app.canvas_document;
|
||||
app.recovery.writing = true;
|
||||
let window = window::Id::unique();
|
||||
let _ = app.update(Message::Request(Pending::Close(window)));
|
||||
assert!(matches!(app.pending, Some(Pending::Close(id)) if id == window));
|
||||
let closing = app.update(Message::Discard);
|
||||
assert!(
|
||||
iced_runtime::task::into_stream(closing).is_none(),
|
||||
"The window must remain until its outstanding writer finishes"
|
||||
);
|
||||
assert_eq!(app.checkpoint_close, Some(window));
|
||||
assert!(app.busy);
|
||||
recovery::write(directory.path(), &snapshot).unwrap();
|
||||
assert!(recovery::read(directory.path(), document).is_ok());
|
||||
let close = app.update(Message::CheckpointWritten(document, Ok(())));
|
||||
assert!(
|
||||
iced_runtime::task::into_stream(close).is_some(),
|
||||
"Close is emitted after checkpoint cleanup"
|
||||
);
|
||||
assert!(app.checkpoint_close.is_none());
|
||||
assert!(recovery::read(directory.path(), document).is_err());
|
||||
assert!(app.recovery.active.is_none());
|
||||
}
|
||||
#[test]
|
||||
fn recent_and_recovery_navigation_respect_unsaved_decision() {
|
||||
let mut app = App::with_project(Project::demo());
|
||||
let id = app.project().id;
|
||||
app.execute(Command::Rename(id, "Retained work".into()));
|
||||
let _ = app.update(Message::Home(home::Event::OpenRecent(PathBuf::from(
|
||||
"missing.arcadia",
|
||||
))));
|
||||
assert!(matches!(app.pending, Some(Pending::Recent)));
|
||||
assert!(!app.busy);
|
||||
assert_eq!(app.project().name, "Retained work");
|
||||
let _ = app.update(Message::Cancel);
|
||||
let recovery_id = Id::new_v4();
|
||||
let _ = app.update(Message::Home(home::Event::Recover(recovery_id)));
|
||||
assert!(matches!(app.pending,Some(Pending::Recover(id)) if id==recovery_id));
|
||||
assert!(!app.busy);
|
||||
}
|
||||
#[test]
|
||||
fn restored_copy_keeps_raw_property_and_requires_save_without_local_or_hosted_binding() {
|
||||
let mut app = App::with_project(Project::demo());
|
||||
let selected = app.project().id;
|
||||
app.draft
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.input("/name", "Not yet applied".into());
|
||||
let snapshot = recovery::Snapshot::capture(
|
||||
Id::new_v4(),
|
||||
&app.document,
|
||||
&app.source_workspace,
|
||||
app.draft.as_ref(),
|
||||
&app.behavior,
|
||||
Some(PathBuf::from("original.arcadia")),
|
||||
selected,
|
||||
)
|
||||
.unwrap();
|
||||
let checkpoint_id = snapshot.id;
|
||||
app.recovery.entries.push(recovery::Entry {
|
||||
id: checkpoint_id,
|
||||
name: "Retained work".into(),
|
||||
origin: None,
|
||||
modified: snapshot.modified,
|
||||
});
|
||||
let _ = app.checkpoint_loaded(Ok(Arc::new(snapshot)));
|
||||
assert!(app.path.is_none());
|
||||
assert!(app.disk_project.is_none());
|
||||
assert!(app.hosted.session.is_none());
|
||||
assert!(app.document.dirty());
|
||||
assert!(app.draft_dirty());
|
||||
assert_eq!(app.recovery.restored, Some(checkpoint_id));
|
||||
assert!(
|
||||
app.recovery.entries.is_empty(),
|
||||
"Home must not offer the copy currently being recovered again"
|
||||
);
|
||||
assert_eq!(
|
||||
app.draft.as_ref().unwrap().inputs["/name"].raw,
|
||||
"Not yet applied"
|
||||
);
|
||||
assert_ne!(app.project().name, "Not yet applied");
|
||||
}
|
||||
#[test]
|
||||
fn stale_write_completion_cannot_recover_a_discarded_document() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let mut app = App::with_project(Project::demo());
|
||||
app.recovery.directory = Some(directory.path().into());
|
||||
let old = app.canvas_document;
|
||||
let s = recovery::Snapshot::capture(
|
||||
old,
|
||||
&app.document,
|
||||
&app.source_workspace,
|
||||
None,
|
||||
&app.behavior,
|
||||
None,
|
||||
app.selected,
|
||||
)
|
||||
.unwrap();
|
||||
app.reset(Project::empty(), None);
|
||||
recovery::write(directory.path(), &s).unwrap();
|
||||
app.checkpoint_written(old, Ok(()));
|
||||
assert!(recovery::read(directory.path(), old).is_err());
|
||||
assert!(app.recovery.active.is_none());
|
||||
}
|
||||
#[test]
|
||||
fn malformed_checkpoint_does_not_replace_the_current_model() {
|
||||
let mut app = App::with_project(Project::demo());
|
||||
let original = app.document.bundle().clone();
|
||||
let _ = app.checkpoint_loaded(Err("Damaged checkpoint".into()));
|
||||
assert_eq!(app.document.bundle(), &original);
|
||||
assert!(app.status.contains("Damaged"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
//! Complete a navigation camera after the destination reports its real viewport.
|
||||
use super::*;
|
||||
use crate::diagram_usage::{Context, Surface};
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
impl App {
|
||||
pub(super) fn is_diagram_viewport(message: &Message) -> bool {
|
||||
matches!(
|
||||
message,
|
||||
Message::CanvasResized(_)
|
||||
| Message::DiagramRevealViewport(..)
|
||||
| Message::TopologyCanvas(behavior::topology::CanvasEvent::Viewport(..))
|
||||
| Message::ScenarioCanvas(behavior::scenario::CanvasEvent::Viewport(..))
|
||||
| Message::StateDiagram(behavior::state_edit::Event::Viewport(..))
|
||||
)
|
||||
}
|
||||
pub(crate) fn architecture_reveal_stamp(&self, diagram: Id) -> Option<(Context, u64)> {
|
||||
self.pending_diagram_reveal
|
||||
.as_ref()
|
||||
.filter(|(_, u, _)| u.surface == Surface::Saved(diagram))
|
||||
.map(|(context, _, epoch)| (*context, *epoch))
|
||||
}
|
||||
|
||||
pub(super) fn diagram_reveal_viewport(&self, message: &Message) -> Option<Surface> {
|
||||
let (context, usage, epoch) = self.pending_diagram_reveal.as_ref()?;
|
||||
if context.document != self.canvas_document
|
||||
|| context.revision != self.document.revision()
|
||||
|| *epoch != self.canvas_epoch
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let (surface, size, valid) = match message {
|
||||
Message::DiagramRevealViewport(c, e, size) => (
|
||||
usage.surface,
|
||||
*size,
|
||||
c == context
|
||||
&& e == epoch
|
||||
&& self.page == Page::Diagram
|
||||
&& Some(usage.surface.id()) == self.active_diagram,
|
||||
),
|
||||
Message::TopologyCanvas(behavior::topology::CanvasEvent::Viewport(c, size)) => (
|
||||
Surface::Process(c.owner),
|
||||
*size,
|
||||
c.document == context.document
|
||||
&& c.revision == context.revision
|
||||
&& c.epoch == *epoch,
|
||||
),
|
||||
Message::ScenarioCanvas(behavior::scenario::CanvasEvent::Viewport(c, size)) => (
|
||||
Surface::Scenario(c.scenario),
|
||||
*size,
|
||||
c.document == context.document
|
||||
&& c.revision == context.revision
|
||||
&& c.epoch == *epoch,
|
||||
),
|
||||
Message::StateDiagram(behavior::state_edit::Event::Viewport(c, size)) => (
|
||||
Surface::Saved(c.diagram),
|
||||
*size,
|
||||
c.document == context.document
|
||||
&& c.revision == context.revision
|
||||
&& c.epoch == *epoch,
|
||||
),
|
||||
_ => return None,
|
||||
};
|
||||
(valid
|
||||
&& surface == usage.surface
|
||||
&& size.width.is_finite()
|
||||
&& size.height.is_finite()
|
||||
&& size.width > 0.
|
||||
&& size.height > 0.)
|
||||
.then_some(surface)
|
||||
}
|
||||
|
||||
pub(super) fn reconcile_pending_diagram_reveal(&mut self, message: &Message) {
|
||||
if !matches!(
|
||||
message,
|
||||
Message::CheckpointTick
|
||||
| Message::CheckpointWritten(..)
|
||||
| Message::Lint(_)
|
||||
| Message::Linted(..)
|
||||
| Message::SourceViewport(_)
|
||||
| Message::WindowResized(_)
|
||||
| Message::CanvasResized(_)
|
||||
| Message::DiagramRevealViewport(..)
|
||||
| Message::DiagramUsage(_)
|
||||
| Message::DiagramUsagesReady(_)
|
||||
| Message::SourceSymbolsReady(_)
|
||||
| Message::TopologyCanvas(behavior::topology::CanvasEvent::Viewport(..))
|
||||
| Message::ScenarioCanvas(behavior::scenario::CanvasEvent::Viewport(..))
|
||||
| Message::StateDiagram(behavior::state_edit::Event::Viewport(..))
|
||||
) {
|
||||
self.pending_diagram_reveal = None;
|
||||
if !Self::is_navigation_camera(message) {
|
||||
self.diagram_navigation_focus = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_navigation_camera(message: &Message) -> bool {
|
||||
if let Message::BehaviorControl(intent) = message {
|
||||
return Self::is_navigation_camera(&intent.message);
|
||||
}
|
||||
matches!(
|
||||
message,
|
||||
Message::Pan(_)
|
||||
| Message::Zoom(_)
|
||||
| Message::Fit
|
||||
| Message::TopologyCanvas(
|
||||
behavior::topology::CanvasEvent::Camera(..)
|
||||
| behavior::topology::CanvasEvent::Zoom(..)
|
||||
| behavior::topology::CanvasEvent::Scrolled(..)
|
||||
| behavior::topology::CanvasEvent::Focus(..)
|
||||
)
|
||||
| Message::ScenarioCanvas(
|
||||
behavior::scenario::CanvasEvent::Camera(..)
|
||||
| behavior::scenario::CanvasEvent::Zoom(..)
|
||||
| behavior::scenario::CanvasEvent::Scrolled(..)
|
||||
| behavior::scenario::CanvasEvent::Focus(..)
|
||||
)
|
||||
| Message::StateDiagram(
|
||||
behavior::state_edit::Event::Pan(..)
|
||||
| behavior::state_edit::Event::Zoom(..)
|
||||
| behavior::state_edit::Event::Fit(..)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn finish_diagram_reveal(&mut self, surface: Surface) {
|
||||
let Some((context, usage, _)) = self.pending_diagram_reveal.take() else {
|
||||
return;
|
||||
};
|
||||
if context.document == self.canvas_document
|
||||
&& context.revision == self.document.revision()
|
||||
&& usage.surface == surface
|
||||
&& !self.draft_dirty()
|
||||
&& !self.source_dirty()
|
||||
&& !self.behavior.has_pending()
|
||||
{
|
||||
// The normal viewport handler has now stored the measured size.
|
||||
// Target identity was validated for this revision before navigation.
|
||||
self.reveal_diagram_usage(&usage);
|
||||
}
|
||||
self.pending_diagram_reveal = None;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,403 @@
|
||||
//! Independent first-layout navigation oracles, through the real App boundary.
|
||||
use super::*;
|
||||
use crate::diagram_usage::{self as usage, Target, Usage};
|
||||
use iced::{Size, Vector};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
enum Canvas {
|
||||
Architecture,
|
||||
Process,
|
||||
Scenario,
|
||||
State,
|
||||
}
|
||||
const CANVASES: [Canvas; 4] = [
|
||||
Canvas::Architecture,
|
||||
Canvas::Process,
|
||||
Canvas::Scenario,
|
||||
Canvas::State,
|
||||
];
|
||||
const WIDE: Size = Size::new(1200., 700.);
|
||||
const ACTUAL: Size = Size::new(500., 300.);
|
||||
|
||||
fn send(app: &mut App, message: Message) {
|
||||
let _ = app.update(message);
|
||||
}
|
||||
|
||||
fn fixture(canvas: Canvas) -> (App, Usage) {
|
||||
let mut p = Project::empty();
|
||||
let work = p
|
||||
.add_element(Kind::Activity, Layer::Oa, "Respond", None)
|
||||
.unwrap();
|
||||
let (surface, subject) = match canvas {
|
||||
Canvas::Architecture => {
|
||||
let diagram = p.ensure_diagram(Layer::Oa);
|
||||
p.place(
|
||||
diagram,
|
||||
work,
|
||||
Position {
|
||||
x: 12_000.,
|
||||
y: 9_000.,
|
||||
},
|
||||
);
|
||||
(Surface::Saved(diagram), work)
|
||||
}
|
||||
Canvas::Process | Canvas::Scenario => {
|
||||
let kind = if matches!(canvas, Canvas::Process) {
|
||||
Kind::Process
|
||||
} else {
|
||||
Kind::Scenario
|
||||
};
|
||||
let owner = p.add_element(kind, Layer::Oa, "Response", None).unwrap();
|
||||
let occurrence = p
|
||||
.add_element(Kind::Occurrence, Layer::Oa, "Respond here", Some(owner))
|
||||
.unwrap();
|
||||
*p.elements
|
||||
.get_mut(&occurrence)
|
||||
.unwrap()
|
||||
.shared_mut()
|
||||
.unwrap() = SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Action {
|
||||
action: Some(work),
|
||||
lifeline: None,
|
||||
},
|
||||
precondition: String::new(),
|
||||
postcondition: String::new(),
|
||||
};
|
||||
let steps = p.elements.get_mut(&owner).unwrap().steps_mut().unwrap();
|
||||
if !steps.contains(&occurrence) {
|
||||
steps.push(occurrence);
|
||||
}
|
||||
(
|
||||
if matches!(canvas, Canvas::Process) {
|
||||
Surface::Process(owner)
|
||||
} else {
|
||||
Surface::Scenario(owner)
|
||||
},
|
||||
occurrence,
|
||||
)
|
||||
}
|
||||
Canvas::State => {
|
||||
let machine = p
|
||||
.add_element(Kind::StateMachine, Layer::Oa, "Modes", None)
|
||||
.unwrap();
|
||||
let region = p
|
||||
.add_element(Kind::Region, Layer::Oa, "Main", Some(machine))
|
||||
.unwrap();
|
||||
let state = p
|
||||
.add_element(Kind::State, Layer::Oa, "Ready", Some(region))
|
||||
.unwrap();
|
||||
let diagram = Id::new_v4();
|
||||
p.diagrams.insert(
|
||||
diagram,
|
||||
Diagram {
|
||||
id: diagram,
|
||||
alias: "ready_view".into(),
|
||||
name: "Ready view".into(),
|
||||
description: String::new(),
|
||||
layer: Layer::Oa,
|
||||
projection: ProjectionKind::StateMachine { machine },
|
||||
nodes: BTreeMap::new(),
|
||||
routes: vec![],
|
||||
view_options: Default::default(),
|
||||
},
|
||||
);
|
||||
p.place(
|
||||
diagram,
|
||||
state,
|
||||
Position {
|
||||
x: 8_000.,
|
||||
y: 11_000.,
|
||||
},
|
||||
);
|
||||
(Surface::Saved(diagram), state)
|
||||
}
|
||||
};
|
||||
let mut app = App::with_project(p);
|
||||
let found = usage::collect(
|
||||
app.project(),
|
||||
subject,
|
||||
&app.behavior.topology,
|
||||
&app.behavior.scenario,
|
||||
)
|
||||
.into_iter()
|
||||
.find(|u| u.surface == surface && u.target != Target::Whole)
|
||||
.unwrap();
|
||||
app.canvas_size = Some(WIDE);
|
||||
app.behavior.diagram.viewport = Some(WIDE);
|
||||
if let Surface::Process(owner) = surface {
|
||||
app.behavior.topology.enter(app.canvas_document, owner);
|
||||
app.behavior.topology.viewport(WIDE);
|
||||
}
|
||||
if let Surface::Scenario(owner) = surface {
|
||||
app.behavior.scenario.enter(app.canvas_document, owner);
|
||||
app.behavior.scenario.viewport(WIDE);
|
||||
}
|
||||
app.page = Page::Text;
|
||||
(app, found)
|
||||
}
|
||||
|
||||
fn measurement(
|
||||
canvas: Canvas,
|
||||
context: Context,
|
||||
epoch: u64,
|
||||
surface: Surface,
|
||||
size: Size,
|
||||
) -> Message {
|
||||
match canvas {
|
||||
Canvas::Architecture => Message::DiagramRevealViewport(context, epoch, size),
|
||||
Canvas::Process => Message::TopologyCanvas(behavior::topology::CanvasEvent::Viewport(
|
||||
behavior::topology::Context {
|
||||
document: context.document,
|
||||
owner: surface.id(),
|
||||
revision: context.revision,
|
||||
epoch,
|
||||
},
|
||||
size,
|
||||
)),
|
||||
Canvas::Scenario => Message::ScenarioCanvas(behavior::scenario::CanvasEvent::Viewport(
|
||||
behavior::scenario::Context {
|
||||
document: context.document,
|
||||
scenario: surface.id(),
|
||||
revision: context.revision,
|
||||
epoch,
|
||||
},
|
||||
size,
|
||||
)),
|
||||
Canvas::State => Message::StateDiagram(behavior::state_edit::Event::Viewport(
|
||||
behavior::state_edit::Context {
|
||||
document: context.document,
|
||||
diagram: surface.id(),
|
||||
revision: context.revision,
|
||||
epoch,
|
||||
},
|
||||
size,
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn stamp(app: &App) -> (Context, u64) {
|
||||
let (context, _, epoch) = app
|
||||
.pending_diagram_reveal
|
||||
.as_ref()
|
||||
.expect("reveal awaits measured destination");
|
||||
(*context, *epoch)
|
||||
}
|
||||
|
||||
fn assert_centered(app: &App, canvas: Canvas, target: &Usage) {
|
||||
// All independently authored fixture targets fit ACTUAL at the readable cap.
|
||||
// This oracle does not call the production camera/reveal implementation.
|
||||
let zoom = 1.25;
|
||||
let pan = Vector::new(
|
||||
250. - target.bounds.center_x() * zoom,
|
||||
150. - target.bounds.center_y() * zoom,
|
||||
);
|
||||
match canvas {
|
||||
Canvas::Architecture => {
|
||||
assert_eq!(app.canvas_size, Some(ACTUAL));
|
||||
assert_eq!(app.zoom, zoom);
|
||||
assert_eq!(app.pan, pan);
|
||||
assert!(!app.fit_to_view);
|
||||
}
|
||||
Canvas::State => {
|
||||
assert_eq!(app.behavior.diagram.viewport, Some(ACTUAL));
|
||||
assert_eq!(app.behavior.diagram.zoom, zoom);
|
||||
assert_eq!(app.behavior.diagram.pan, pan);
|
||||
assert!(!app.behavior.diagram.fit);
|
||||
}
|
||||
Canvas::Process => {
|
||||
let mut expected = behavior::topology::State::default();
|
||||
expected.enter(app.canvas_document, target.surface.id());
|
||||
expected.viewport(ACTUAL);
|
||||
expected.zoom(Some(zoom));
|
||||
expected.camera(behavior::canvas_workspace::Event::Pan(pan));
|
||||
assert!(app.behavior.topology.camera_boundary() == expected.camera_boundary());
|
||||
}
|
||||
Canvas::Scenario => {
|
||||
let mut expected = behavior::scenario::State::default();
|
||||
expected.enter(app.canvas_document, target.surface.id());
|
||||
expected.choose_tool(target.surface.id(), behavior::scenario::CanvasTool::Select);
|
||||
expected.viewport(ACTUAL);
|
||||
expected.zoom(Some(zoom));
|
||||
expected.camera(behavior::canvas_workspace::Event::Pan(pan));
|
||||
assert!(app.behavior.scenario.camera_boundary() == expected.camera_boundary());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_reveal_surfaces_recenter_once_after_the_real_viewport_and_preserve_authored_state() {
|
||||
for canvas in CANVASES {
|
||||
let (mut app, target) = fixture(canvas);
|
||||
let accepted = app.document.snapshot();
|
||||
let drafts = app.source_workspace.bundle();
|
||||
let revision = app.document.revision();
|
||||
let history = (
|
||||
app.document.can_undo(),
|
||||
app.document.can_redo(),
|
||||
app.document.dirty(),
|
||||
);
|
||||
assert!(app.reveal_diagram_usage(&target));
|
||||
let (context, epoch) = stamp(&app);
|
||||
send(
|
||||
&mut app,
|
||||
measurement(canvas, context, epoch, target.surface, ACTUAL),
|
||||
);
|
||||
assert!(app.pending_diagram_reveal.is_none());
|
||||
assert_centered(&app, canvas, &target);
|
||||
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
||||
assert_eq!(app.source_workspace.bundle(), drafts);
|
||||
assert_eq!(app.document.revision(), revision);
|
||||
assert_eq!(
|
||||
(
|
||||
app.document.can_undo(),
|
||||
app.document.can_redo(),
|
||||
app.document.dirty()
|
||||
),
|
||||
history
|
||||
);
|
||||
// The exact delivered callback is a replay after the one-shot is consumed.
|
||||
let camera = app.camera_boundary();
|
||||
send(
|
||||
&mut app,
|
||||
measurement(canvas, context, epoch, target.surface, ACTUAL),
|
||||
);
|
||||
assert!(app.pending_diagram_reveal.is_none());
|
||||
assert!(app.camera_boundary() == camera);
|
||||
assert_centered(&app, canvas, &target);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_or_wrong_stamp_measurements_cannot_poison_a_pending_reveal() {
|
||||
for canvas in CANVASES {
|
||||
for invalid in 0..6 {
|
||||
let (mut app, target) = fixture(canvas);
|
||||
assert!(app.reveal_diagram_usage(&target));
|
||||
let (context, epoch) = stamp(&app);
|
||||
let camera = app.camera_boundary();
|
||||
let mut stale = context;
|
||||
let mut wrong_epoch = epoch;
|
||||
let mut size = Size::new(111., 122.);
|
||||
match invalid {
|
||||
0 => stale.document = Id::new_v4(),
|
||||
1 => stale.revision = stale.revision.wrapping_add(1),
|
||||
2 => wrong_epoch = wrong_epoch.wrapping_add(1),
|
||||
3 => size.width = f32::NAN,
|
||||
4 => size.height = f32::INFINITY,
|
||||
_ => size.width = 0.,
|
||||
}
|
||||
send(
|
||||
&mut app,
|
||||
measurement(canvas, stale, wrong_epoch, target.surface, size),
|
||||
);
|
||||
assert!(
|
||||
app.pending_diagram_reveal.is_some(),
|
||||
"{canvas:?} invalid case {invalid}"
|
||||
);
|
||||
assert_eq!(stamp(&app), (context, epoch));
|
||||
assert_eq!(app.canvas_epoch, epoch);
|
||||
assert!(
|
||||
app.camera_boundary() == camera,
|
||||
"{canvas:?} invalid case {invalid}"
|
||||
);
|
||||
send(
|
||||
&mut app,
|
||||
measurement(canvas, context, epoch, target.surface, ACTUAL),
|
||||
);
|
||||
assert!(app.pending_diagram_reveal.is_none());
|
||||
assert_centered(&app, canvas, &target);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn actual_window_resize_before_first_measurement_retains_focus_for_the_new_context() {
|
||||
for canvas in CANVASES {
|
||||
let (mut app, target) = fixture(canvas);
|
||||
let accepted = app.document.snapshot();
|
||||
assert!(app.reveal_diagram_usage(&target));
|
||||
let (context, old_epoch) = stamp(&app);
|
||||
send(&mut app, Message::WindowResized(Size::new(1100., 700.)));
|
||||
let (current, epoch) = stamp(&app);
|
||||
assert_eq!(current, context);
|
||||
assert_eq!(epoch, app.canvas_epoch);
|
||||
if matches!(canvas, Canvas::Process | Canvas::Scenario) {
|
||||
assert_ne!(epoch, old_epoch);
|
||||
let camera = app.camera_boundary();
|
||||
send(
|
||||
&mut app,
|
||||
measurement(canvas, context, old_epoch, target.surface, ACTUAL),
|
||||
);
|
||||
assert_eq!(stamp(&app), (current, epoch));
|
||||
assert!(app.camera_boundary() == camera);
|
||||
}
|
||||
send(
|
||||
&mut app,
|
||||
measurement(canvas, current, epoch, target.surface, ACTUAL),
|
||||
);
|
||||
assert!(app.pending_diagram_reveal.is_none());
|
||||
assert_centered(&app, canvas, &target);
|
||||
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_camera_edit_and_navigation_cancel_deferred_focus_without_late_snapping() {
|
||||
for canvas in CANVASES {
|
||||
for action in 0..3 {
|
||||
let (mut app, target) = fixture(canvas);
|
||||
let accepted = app.document.snapshot();
|
||||
assert!(app.reveal_diagram_usage(&target));
|
||||
let (context, epoch) = stamp(&app);
|
||||
let event = match action {
|
||||
0 => match canvas {
|
||||
Canvas::Architecture => Message::Pan(Vector::new(31., -17.)),
|
||||
Canvas::State => Message::StateDiagram(behavior::state_edit::Event::Pan(
|
||||
app.state_context().unwrap(),
|
||||
Vector::new(31., -17.),
|
||||
)),
|
||||
Canvas::Process => {
|
||||
Message::TopologyCanvas(behavior::topology::CanvasEvent::Camera(
|
||||
app.topology_context().unwrap(),
|
||||
app.camera_epoch,
|
||||
behavior::canvas_workspace::Event::Pan(Vector::new(31., -17.)),
|
||||
))
|
||||
}
|
||||
Canvas::Scenario => {
|
||||
Message::ScenarioCanvas(behavior::scenario::CanvasEvent::Camera(
|
||||
app.scenario_context().unwrap(),
|
||||
app.camera_epoch,
|
||||
behavior::canvas_workspace::Event::Pan(Vector::new(31., -17.)),
|
||||
))
|
||||
}
|
||||
},
|
||||
1 => Message::Field("/name".into(), "Pending title edit".into()),
|
||||
_ => Message::Page(Page::Catalog),
|
||||
};
|
||||
send(&mut app, event);
|
||||
assert!(
|
||||
app.pending_diagram_reveal.is_none(),
|
||||
"{canvas:?} action {action}"
|
||||
);
|
||||
let camera = app.camera_boundary();
|
||||
let page = app.page;
|
||||
// A stale layout callback at the already retained size cannot
|
||||
// restore the old navigation camera after deliberate user input.
|
||||
send(
|
||||
&mut app,
|
||||
measurement(canvas, context, epoch, target.surface, WIDE),
|
||||
);
|
||||
assert!(app.pending_diagram_reveal.is_none());
|
||||
assert!(
|
||||
app.camera_boundary() == camera,
|
||||
"{canvas:?} action {action}"
|
||||
);
|
||||
assert_eq!(app.page, page);
|
||||
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
||||
if action == 1 {
|
||||
assert!(app.draft_dirty());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
//! A view picker follows a view inspector without stealing semantic selection.
|
||||
use super::*;
|
||||
|
||||
fn fixture() -> (App, Id, Id, Id) {
|
||||
let project = language::parse(
|
||||
r#"arcadia 6; project views "View switching" {
|
||||
oa_activity work "Inspect work" {}
|
||||
diagram first "Original concern" {}
|
||||
diagram second "Commands review" {}
|
||||
node original_work { diagram @first; element @work; }
|
||||
node copied_work { diagram @second; element @work; }
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let id = |alias| {
|
||||
project
|
||||
.identities()
|
||||
.into_iter()
|
||||
.find(|(_, value)| *value == alias)
|
||||
.unwrap()
|
||||
.0
|
||||
};
|
||||
let (first, second, work) = (id("first"), id("second"), id("work"));
|
||||
let mut app = App::with_project(project);
|
||||
app.page = Page::Diagram;
|
||||
app.active_diagram = Some(second);
|
||||
app.selected = second;
|
||||
app.refresh_draft();
|
||||
(app, first, second, work)
|
||||
}
|
||||
fn choose(app: &mut App, id: Id) {
|
||||
let _ = app.update(Message::Diagram(Choice {
|
||||
id,
|
||||
label: "Captured choice".into(),
|
||||
}));
|
||||
}
|
||||
fn field(app: &mut App, path: &str, value: &str) {
|
||||
let _ = app.update(Message::Field(path.into(), value.into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changing_visible_view_follows_its_clean_inspector_without_a_model_edit() {
|
||||
let (mut app, original, copy, _) = fixture();
|
||||
let before = app.document.snapshot();
|
||||
let revision = app.document.revision();
|
||||
choose(&mut app, original);
|
||||
assert_eq!(app.active_diagram, Some(original));
|
||||
assert_eq!(app.selected, original);
|
||||
assert_eq!(app.draft.as_ref().unwrap().original, original);
|
||||
assert_eq!(
|
||||
app.draft.as_ref().unwrap().inputs["/name"].raw,
|
||||
"Original concern"
|
||||
);
|
||||
assert_eq!(app.project(), &before.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
assert_eq!(app.document.revision(), revision);
|
||||
assert!(!app.document.can_undo());
|
||||
field(&mut app, "/name", "Edited visible original");
|
||||
assert!(app.apply());
|
||||
assert_eq!(
|
||||
app.project().diagrams[&original].name,
|
||||
"Edited visible original"
|
||||
);
|
||||
assert_eq!(app.project().diagrams[©].name, "Commands review");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_view_draft_blocks_the_switch_and_retains_raw_input() {
|
||||
let (mut app, original, copy, _) = fixture();
|
||||
field(&mut app, "/id", "-");
|
||||
let raw = serde_json::to_value(app.draft.as_ref().unwrap().recovery_snapshot()).unwrap();
|
||||
let before = app.document.snapshot();
|
||||
choose(&mut app, original);
|
||||
assert_eq!(app.active_diagram, Some(copy));
|
||||
assert_eq!(app.selected, copy);
|
||||
assert_eq!(
|
||||
serde_json::to_value(app.draft.as_ref().unwrap().recovery_snapshot()).unwrap(),
|
||||
raw
|
||||
);
|
||||
assert_eq!(app.project(), &before.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
assert!(!app.document.can_undo());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_pending_view_edit_applies_to_old_view_then_inspects_the_new_view() {
|
||||
let (mut app, original, copy, _) = fixture();
|
||||
let before = app.document.snapshot();
|
||||
field(&mut app, "/name", "Reviewed copy");
|
||||
choose(&mut app, original);
|
||||
assert_eq!(app.project().diagrams[©].name, "Reviewed copy");
|
||||
assert_eq!(
|
||||
app.project().diagrams[&original],
|
||||
before.decoded.project.diagrams[&original]
|
||||
);
|
||||
assert_eq!(app.selected, original);
|
||||
assert_eq!(app.draft.as_ref().unwrap().original, original);
|
||||
let _ = app.update(Message::Undo);
|
||||
assert_eq!(app.project(), &before.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
assert!(!app.document.can_undo());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diagram_uuid_edit_follows_the_same_view_or_a_different_picker_target() {
|
||||
for same_view in [false, true] {
|
||||
let (mut app, original, copy, _) = fixture();
|
||||
let changed = Id::from_u128(0xabcd123440008000000000000001);
|
||||
field(&mut app, "/id", &changed.to_string());
|
||||
choose(&mut app, if same_view { copy } else { original });
|
||||
let expected = if same_view { changed } else { original };
|
||||
assert!(!app.project().diagrams.contains_key(©));
|
||||
assert!(app.project().diagrams.contains_key(&changed));
|
||||
assert_eq!(app.active_diagram, Some(expected));
|
||||
assert_eq!(app.selected, expected);
|
||||
assert_eq!(app.draft.as_ref().unwrap().original, expected);
|
||||
assert!(!app.draft_dirty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diagram_switch_preserves_an_independent_semantic_inspection() {
|
||||
let (mut app, original, _, work) = fixture();
|
||||
let _ = app.update(Message::Select(work));
|
||||
let before = app.document.snapshot();
|
||||
choose(&mut app, original);
|
||||
assert_eq!(app.active_diagram, Some(original));
|
||||
assert_eq!(app.selected, work);
|
||||
assert_eq!(app.draft.as_ref().unwrap().original, work);
|
||||
assert_eq!(app.project(), &before.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_diagram_choice_never_applies_an_unrelated_valid_draft() {
|
||||
let (mut app, _, copy, _) = fixture();
|
||||
field(&mut app, "/name", "Pending copy name");
|
||||
let before = app.document.snapshot();
|
||||
choose(&mut app, Id::new_v4());
|
||||
assert_eq!(app.active_diagram, Some(copy));
|
||||
assert_eq!(app.selected, copy);
|
||||
assert_eq!(
|
||||
app.draft.as_ref().unwrap().inputs["/name"].raw,
|
||||
"Pending copy name"
|
||||
);
|
||||
assert_eq!(app.project(), &before.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
assert!(!app.document.can_undo());
|
||||
assert!(app.status.contains("no longer exists"));
|
||||
}
|
||||
@@ -0,0 +1,445 @@
|
||||
//! Navigation-only adapter: validates destinations before changing presentation.
|
||||
use super::*;
|
||||
use crate::diagram_usage::{self as usage, Context, Event, Panel, Surface, Target, Usage};
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
impl App {
|
||||
pub(crate) fn diagram_usage_message(&self, subject: Id) -> Message {
|
||||
Message::DiagramUsage(Event::Open(Context {
|
||||
document: self.canvas_document,
|
||||
revision: self.document.revision(),
|
||||
subject,
|
||||
}))
|
||||
}
|
||||
|
||||
fn usage_navigation_available(&self) -> bool {
|
||||
!self.busy
|
||||
&& self.pending.is_none()
|
||||
&& self.file_prompt.is_none()
|
||||
&& !self.delete_pending
|
||||
&& self.workflow.is_none()
|
||||
&& !self.hosted.visible
|
||||
&& self.architecture.is_none()
|
||||
&& !self.reference_input_active()
|
||||
&& !self.behavior.has_pending()
|
||||
&& !self.draft_dirty()
|
||||
&& !self.source_dirty()
|
||||
}
|
||||
|
||||
/// Admit at most one expensive lookup, including one accepted Project clone.
|
||||
/// A replacement panel waits for the previous worker's exact completion.
|
||||
fn start_diagram_lookup(&mut self) -> Task<Message> {
|
||||
let Some(panel) = self.diagram_usages.as_ref() else {
|
||||
return Task::none();
|
||||
};
|
||||
if !panel.loading || self.diagram_lookup_in_flight.is_some() {
|
||||
return Task::none();
|
||||
}
|
||||
let (token, context) = (panel.token, panel.context);
|
||||
if context.document != self.canvas_document
|
||||
|| context.revision != self.document.revision()
|
||||
|| !self.usage_navigation_available()
|
||||
{
|
||||
let panel = self.diagram_usages.as_mut().unwrap();
|
||||
panel.loading = false;
|
||||
panel.notice = Some(
|
||||
"The model or pending edits changed. Reopen diagram navigation when ready.".into(),
|
||||
);
|
||||
return Task::none();
|
||||
}
|
||||
let project = self.project().clone();
|
||||
let presentation = self.behavior.topology.usage_snapshot();
|
||||
self.diagram_lookup_in_flight = Some(token);
|
||||
Task::perform(
|
||||
async move {
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
let process = presentation.into_query_state();
|
||||
let scenario = behavior::scenario::State::default();
|
||||
usage::collect(&project, context.subject, &process, &scenario)
|
||||
})
|
||||
.await
|
||||
.map_err(|error| format!("Diagram lookup failed: {error}. Close and try again."));
|
||||
usage::Completion {
|
||||
token,
|
||||
context,
|
||||
result,
|
||||
}
|
||||
},
|
||||
Message::DiagramUsagesReady,
|
||||
)
|
||||
}
|
||||
|
||||
fn refresh_diagram_lookup(&mut self, notice: &str) -> Task<Message> {
|
||||
let Some(panel) = self.diagram_usages.as_mut() else {
|
||||
return Task::none();
|
||||
};
|
||||
panel.token = Id::new_v4();
|
||||
panel.context.revision = self.document.revision();
|
||||
panel.usages.clear();
|
||||
panel.loading = true;
|
||||
panel.page = 0;
|
||||
panel.cursor = 0;
|
||||
panel.destination = None;
|
||||
panel.notice = Some(notice.into());
|
||||
self.start_diagram_lookup()
|
||||
}
|
||||
|
||||
/// Must run before modal/input dispatch. A closed or replaced panel still
|
||||
/// owns its worker slot until this exact completion arrives.
|
||||
pub(super) fn diagram_usages_ready(&mut self, completion: usage::Completion) -> Task<Message> {
|
||||
if self.diagram_lookup_in_flight != Some(completion.token) {
|
||||
return Task::none();
|
||||
}
|
||||
self.diagram_lookup_in_flight = None;
|
||||
let Some(panel) = self.diagram_usages.as_ref() else {
|
||||
return Task::none();
|
||||
};
|
||||
if panel.context.document != self.canvas_document {
|
||||
self.diagram_usages = None;
|
||||
return Task::none();
|
||||
}
|
||||
if panel.context.revision != self.document.revision() {
|
||||
return self
|
||||
.refresh_diagram_lookup("The model changed. Review the refreshed destinations.");
|
||||
}
|
||||
if panel.token != completion.token {
|
||||
// A newer Open/refresh waited without cloning or spawning a second job.
|
||||
return self.start_diagram_lookup();
|
||||
}
|
||||
let panel = self.diagram_usages.as_mut().unwrap();
|
||||
if panel.context != completion.context || !panel.loading {
|
||||
panel.loading = false;
|
||||
panel.notice = Some(
|
||||
"This lookup no longer matches the requested model. Close and try again.".into(),
|
||||
);
|
||||
return Task::none();
|
||||
}
|
||||
panel.loading = false;
|
||||
match completion.result {
|
||||
Ok(usages) => panel.usages = usages,
|
||||
Err(error) => panel.notice = Some(error),
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
|
||||
/// Selection validates one destination only; opening the chooser does the
|
||||
/// whole-project scan on its worker, never on the GUI thread.
|
||||
fn current_surface_usages(&self, subject: Id, surface: Surface) -> Vec<Usage> {
|
||||
match surface {
|
||||
Surface::Saved(diagram) => {
|
||||
usage::saved_usages_for(self.project(), subject, Some(diagram))
|
||||
}
|
||||
Surface::Process(owner) => {
|
||||
self.behavior
|
||||
.topology
|
||||
.diagram_usages(self.project(), owner, subject)
|
||||
}
|
||||
Surface::Scenario(owner) => {
|
||||
self.behavior
|
||||
.scenario
|
||||
.diagram_usages(self.project(), owner, subject)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn diagram_usage_event(&mut self, event: Event) -> Task<Message> {
|
||||
if let Event::Open(context) = event {
|
||||
if context.document != self.canvas_document
|
||||
|| context.revision != self.document.revision()
|
||||
|| !self.project().contains(context.subject)
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
if !self.usage_navigation_available() {
|
||||
self.status = "Apply or revert pending edits before opening diagram navigation. Your inputs are retained.".into();
|
||||
return Task::none();
|
||||
}
|
||||
let mut panel = Panel::new(context, Vec::new());
|
||||
panel.loading = true;
|
||||
let filter_id = format!("diagram-usage-filter-{}", panel.token);
|
||||
self.diagram_usages = Some(panel);
|
||||
self.model_menu = None;
|
||||
// Only an accepted new chooser supersedes the previous reveal.
|
||||
// Rejected/stale panel envelopes leave its first measurement intact.
|
||||
self.pending_diagram_reveal = None;
|
||||
self.diagram_navigation_focus = None;
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
return Task::batch([
|
||||
self.start_diagram_lookup(),
|
||||
iced::widget::operation::focus(filter_id),
|
||||
]);
|
||||
}
|
||||
let token = match &event {
|
||||
Event::Open(_) => unreachable!(),
|
||||
Event::Close(t)
|
||||
| Event::Search(t, _)
|
||||
| Event::Page(t, _)
|
||||
| Event::Move(t, _)
|
||||
| Event::Activate(t)
|
||||
| Event::Destination(t, _)
|
||||
| Event::Choose(t, _) => *t,
|
||||
};
|
||||
let Some(panel) = &self.diagram_usages else {
|
||||
return Task::none();
|
||||
};
|
||||
if panel.token != token {
|
||||
return Task::none();
|
||||
}
|
||||
if matches!(event, Event::Close(_)) {
|
||||
self.diagram_usages = None;
|
||||
if self.page == Page::Text {
|
||||
self.restore_source_editor_focus();
|
||||
}
|
||||
return Task::none();
|
||||
}
|
||||
if panel.context.document != self.canvas_document {
|
||||
self.diagram_usages = None;
|
||||
return Task::none();
|
||||
}
|
||||
if panel.context.revision != self.document.revision() {
|
||||
return self
|
||||
.refresh_diagram_lookup("The model changed. Review the refreshed destinations.");
|
||||
}
|
||||
if panel.loading {
|
||||
// Search and Close remain useful while loading; queued activation
|
||||
// must not perform a synchronous fallback or choose an old row.
|
||||
if let Event::Search(_, query) = event {
|
||||
self.diagram_usages.as_mut().unwrap().search = query;
|
||||
}
|
||||
return Task::none();
|
||||
}
|
||||
match event {
|
||||
Event::Move(_, direction) => {
|
||||
let panel = self.diagram_usages.as_ref().unwrap();
|
||||
let count = panel.choices(self.project()).len();
|
||||
let panel = self.diagram_usages.as_mut().unwrap();
|
||||
if count > 0 {
|
||||
panel.cursor = if direction < 0 {
|
||||
(panel.cursor + count - 1) % count
|
||||
} else {
|
||||
(panel.cursor + 1) % count
|
||||
};
|
||||
panel.page = panel.cursor / 6;
|
||||
}
|
||||
}
|
||||
Event::Activate(_) => {
|
||||
let panel = self.diagram_usages.as_ref().unwrap();
|
||||
if let Some(event) = panel.choices(self.project()).get(panel.cursor).cloned() {
|
||||
return self.diagram_usage_event(event);
|
||||
}
|
||||
}
|
||||
Event::Search(_, query) => {
|
||||
let panel = self.diagram_usages.as_mut().unwrap();
|
||||
panel.search = query;
|
||||
panel.page = 0;
|
||||
panel.cursor = 0;
|
||||
}
|
||||
Event::Page(_, page) => {
|
||||
let panel = self.diagram_usages.as_mut().unwrap();
|
||||
panel.page = page;
|
||||
panel.cursor = page.saturating_mul(6);
|
||||
}
|
||||
Event::Destination(_, surface) => {
|
||||
let panel = self.diagram_usages.as_mut().unwrap();
|
||||
if surface.is_none() || panel.usages.iter().any(|u| Some(u.surface) == surface) {
|
||||
panel.destination = surface;
|
||||
panel.page = 0;
|
||||
panel.cursor = 0;
|
||||
}
|
||||
}
|
||||
Event::Choose(_, requested) => {
|
||||
if !self.usage_navigation_available() {
|
||||
return Task::none();
|
||||
}
|
||||
let subject = self.diagram_usages.as_ref().unwrap().context.subject;
|
||||
let fresh = self
|
||||
.current_surface_usages(subject, requested.surface)
|
||||
.into_iter()
|
||||
.find(|u| u.subject == requested.subject && u.target == requested.target);
|
||||
let Some(fresh) = fresh else {
|
||||
return self.refresh_diagram_lookup(
|
||||
"This representation changed. Review the refreshed destinations.",
|
||||
);
|
||||
};
|
||||
if self.reveal_diagram_usage(&fresh) {
|
||||
self.diagram_usages = None;
|
||||
self.status = format!(
|
||||
"{} · {} in {}",
|
||||
self.project().named(subject),
|
||||
fresh.role,
|
||||
self.project().named(fresh.surface.id())
|
||||
);
|
||||
return Task::batch(["model-explorer", "model-browser", "inspector"].map(
|
||||
|id| {
|
||||
iced::widget::operation::snap_to(
|
||||
id,
|
||||
iced::widget::scrollable::RelativeOffset::START,
|
||||
)
|
||||
},
|
||||
));
|
||||
}
|
||||
}
|
||||
Event::Open(_) | Event::Close(_) => unreachable!(),
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
|
||||
pub(super) fn reveal_diagram_usage(&mut self, usage: &Usage) -> bool {
|
||||
if !self.usage_navigation_available() {
|
||||
return false;
|
||||
}
|
||||
let previous_layer = self.layer;
|
||||
let p = self.document.project();
|
||||
let (selected, placement) = match &usage.target {
|
||||
Target::Whole => (usage.surface.id(), None),
|
||||
Target::Placement(id) => {
|
||||
let Some(node) = p
|
||||
.diagrams
|
||||
.get(&usage.surface.id())
|
||||
.and_then(|d| d.nodes.get(id))
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
(node.element, Some(*id))
|
||||
}
|
||||
Target::Port { port, placement } => (*port, Some(*placement)),
|
||||
Target::StateRow {
|
||||
placement,
|
||||
relation,
|
||||
} => (*relation, Some(*placement)),
|
||||
Target::Edge(id)
|
||||
| Target::ProcessLink(id)
|
||||
| Target::ScenarioObject(id)
|
||||
| Target::AllocationGrouping { relation: id, .. }
|
||||
| Target::ProcessPreviewLink { relation: id, .. } => (*id, None),
|
||||
Target::ProcessOccurrence(endpoint) => (
|
||||
endpoint
|
||||
.references
|
||||
.first()
|
||||
.copied()
|
||||
.unwrap_or(endpoint.occurrence),
|
||||
None,
|
||||
),
|
||||
};
|
||||
match usage.surface {
|
||||
Surface::Saved(id) => {
|
||||
let Some(diagram) = p.diagrams.get(&id) else {
|
||||
return false;
|
||||
};
|
||||
self.layer = diagram.layer;
|
||||
self.active_diagram = Some(id);
|
||||
if let Some(machine) = diagram.projection.state_machine() {
|
||||
let viewport = self.behavior.diagram.viewport.unwrap_or(iced::Size::new(
|
||||
(self.window_size.width - 610.).max(280.),
|
||||
(self.window_size.height - 320.).max(240.),
|
||||
));
|
||||
let (zoom, pan) = usage::camera_for(&usage.target, usage.bounds, viewport);
|
||||
self.behavior.diagram = behavior::state_view::State {
|
||||
diagram: Some(id),
|
||||
placement,
|
||||
viewport: Some(viewport),
|
||||
zoom,
|
||||
pan,
|
||||
fit: false,
|
||||
..Default::default()
|
||||
};
|
||||
self.behavior.active_owner = Some(machine);
|
||||
self.page = Page::Behavior;
|
||||
} else {
|
||||
let viewport = self.canvas_size.unwrap_or(iced::Size::new(
|
||||
(self.window_size.width - 550.).max(280.),
|
||||
(self.window_size.height - 284.).max(240.),
|
||||
));
|
||||
(self.zoom, self.pan) =
|
||||
usage::camera_for(&usage.target, usage.bounds, viewport);
|
||||
self.fit_to_view = false;
|
||||
self.page = Page::Diagram;
|
||||
}
|
||||
}
|
||||
Surface::Process(owner) => {
|
||||
if !self.behavior.topology.reveal_usage(
|
||||
p,
|
||||
self.canvas_document,
|
||||
owner,
|
||||
&usage.target,
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
self.layer = p.elements[&owner].layer();
|
||||
self.behavior.active_owner = Some(owner);
|
||||
self.page = Page::Behavior;
|
||||
}
|
||||
Surface::Scenario(owner) => {
|
||||
if !self.behavior.scenario.reveal_usage(
|
||||
p,
|
||||
self.canvas_document,
|
||||
owner,
|
||||
&usage.target,
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
self.layer = p.elements[&owner].layer();
|
||||
self.behavior.active_owner = Some(owner);
|
||||
self.page = Page::Behavior;
|
||||
}
|
||||
}
|
||||
self.selected = selected;
|
||||
self.selected_node = placement;
|
||||
self.model_menu = None;
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
self.selected_bend = None;
|
||||
self.connection = None;
|
||||
self.last_workspace_page = self.page;
|
||||
if previous_layer != self.layer {
|
||||
self.data_state = Default::default();
|
||||
self.explorer_index = crate::explorer::Index::new(self.project(), self.layer);
|
||||
self.explorer_focus = None;
|
||||
self.explorer_group = None;
|
||||
self.explorer_page = 0;
|
||||
}
|
||||
self.normalize_creation();
|
||||
self.reveal_explorer_selection();
|
||||
self.refresh_draft();
|
||||
self.diagram_navigation_focus = Some(usage::Focus {
|
||||
context: Context {
|
||||
document: self.canvas_document,
|
||||
revision: self.document.revision(),
|
||||
subject: usage.subject,
|
||||
},
|
||||
usage: usage.clone(),
|
||||
selected,
|
||||
});
|
||||
self.pending_diagram_reveal = Some((
|
||||
Context {
|
||||
document: self.canvas_document,
|
||||
revision: self.document.revision(),
|
||||
subject: usage.subject,
|
||||
},
|
||||
usage.clone(),
|
||||
self.canvas_epoch,
|
||||
));
|
||||
true
|
||||
}
|
||||
|
||||
/// Validated presentation identity. Edit selection and Delete capture remain
|
||||
/// semantic; a port owner or allocation work box is only a visual marker.
|
||||
pub(crate) fn diagram_focus(&self, surface: Surface) -> Option<&Usage> {
|
||||
let focus = self.diagram_navigation_focus.as_ref()?;
|
||||
let current_surface = match surface {
|
||||
Surface::Saved(id) => {
|
||||
self.active_diagram == Some(id)
|
||||
&& matches!(self.page, Page::Diagram | Page::Behavior)
|
||||
}
|
||||
Surface::Process(owner) | Surface::Scenario(owner) => {
|
||||
self.page == Page::Behavior && self.behavior.active_owner == Some(owner)
|
||||
}
|
||||
};
|
||||
(current_surface
|
||||
&& focus.usage.surface == surface
|
||||
&& focus.context.document == self.canvas_document
|
||||
&& focus.context.revision == self.document.revision()
|
||||
&& focus.selected == self.selected)
|
||||
.then_some(&focus.usage)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user