diff --git a/README.md b/README.md
index 51265d2c..9a142008 100644
--- a/README.md
+++ b/README.md
@@ -2,11 +2,11 @@
Arcadia Studio is a native Rust/Iced modeling workbench for system engineers. V2 covers Operational Analysis (OA) and System Analysis (SA), with graphical and textual editing of one typed model and a deployable Rust REST service. It is an independent implementation inspired by Arcadia and Eclipse Capella, not a complete Capella or SysML implementation.
-The normative [language and editing contract](docs/language.rst) defines supported semantics, both representations, validation and migration. [Scope](docs/scope.md) states the boundaries. The [acceptance](docs/acceptance.md), [validation](docs/validation.md) and [current state](harness/STATE.md) records document V2 tests and native Windows/Linux Computer Use verification.
+The normative [language and editing contract](docs/language.rst) defines supported semantics, both representations, validation and migration. [Scope](docs/scope.md) states the boundaries. The [acceptance](docs/acceptance.md) and [validation](docs/validation.md) records document the earlier V2 baseline. [Current state](harness/STATE.md) and the [2026-09-06 coverage audit](harness/release-current-coverage-20260906.md) track subsequent implementation and outstanding release gates; the current first release is not yet accepted.
## Build and run
-Install stable Rust and your platform's native compiler. The workspace declares Rust 1.90; the recorded development builds use Rust 1.98.1. Windows requires Visual Studio Build Tools with **Desktop development with C++**. Ubuntu/Debian development dependencies are:
+Install stable Rust and your platform's native compiler. The workspace declares Rust 1.90; the recorded development builds use Rust 1.98.1. The standard Windows MSVC path uses Visual Studio Build Tools with **Desktop development with C++**. Ubuntu/Debian development dependencies are:
```sh
sudo apt-get install build-essential pkg-config libxkbcommon-dev libxkbcommon-x11-0 libwayland-dev libvulkan-dev libx11-dev libxrandr-dev libxi-dev
@@ -26,6 +26,16 @@ cargo build --release --workspace --locked
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,11 +45,13 @@ 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).
## 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.
@@ -48,7 +60,7 @@ The example opens in an architecture view with work nested inside its performers
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.
+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 and individual-row changes. Review does not automatically synchronize the perspectives. Ordered, attachment, artifact and multi-source review controls remain unfinished. 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.
@@ -72,7 +84,7 @@ In the state view, **Select / move** drags vertices and containers without repar
**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 5;
project harbor "Harbor assistance" {
oa_entity control "Harbor control" {}
oa_activity respond "Respond to requests" {
@@ -104,7 +116,9 @@ Reparenting a nested element through Properties moves its declaration, descendan
Ctrl+S saves to the current local or hosted destination, Ctrl+Shift+S opens Save as, and Ctrl+O opens a local file. An `.arcadia` suffix selects text; other suffixes select JSON. Opening detects content rather than relying on the filename. Windows uses native dialogs; Linux uses a path-entry dialog with overwrite confirmation and no portal requirement.
-**Save all** validates a snapshot and saves the source bundle, comparing previously read dependency bytes to reject detected external changes. Each file is replaced atomically; a staged transaction journal allows explicit recovery of interrupted multi-file saves. This is not an atomic multi-file filesystem operation or distributed locking. If recovery is needed, **Recover interrupted save** appears in Textual model. Dirty New/Open/Close operations offer save/discard/cancel. There is no periodic autosave of unsaved edits. Share or commit the entry file and all included source files together.
+**Save all** validates a snapshot and saves the source bundle, comparing previously read dependency bytes to reject detected external changes. Each file is replaced atomically; a staged transaction journal allows explicit recovery of interrupted multi-file saves. This is not an atomic multi-file filesystem operation or distributed locking. If recovery is needed, **Recover interrupted save** appears in Textual model. Dirty New/Open/Close operations offer save/discard/cancel. Share or commit the entry file and all included source files together.
+
+While work is unsaved, the desktop attempts a private recovery checkpoint every 10 seconds. It retains the accepted source bundle, raw source buffers, supported pending property/behavior forms and workflow drafts, including rejected input and semantic-review choices. **Home → Recover** opens an unsaved copy with no local or hosted save destination; use **Save as** to choose one. Undo/redo history is not restored. Checkpoints never write model files or save to the server, and credentials are excluded. A failed checkpoint is reported on Home; recent edits since the last completed checkpoint may be unavailable after a crash. Local history and checkpoints live under `%LOCALAPPDATA%/ArcadiaStudio` on Windows or `$XDG_DATA_HOME/arcadia-studio` (otherwise `~/.local/share/arcadia-studio`) on Linux. Explicit saves and version control remain the durable record. Home/recovery is implemented; current native acceptance remains pending in the [coverage audit](harness/release-current-coverage-20260906.md).
## REST service and external tools
diff --git a/build/windows.manifest b/build/windows.manifest
new file mode 100644
index 00000000..1cf7cdca
--- /dev/null
+++ b/build/windows.manifest
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/build/windows.rc b/build/windows.rc
new file mode 100644
index 00000000..bcafbdfe
--- /dev/null
+++ b/build/windows.rc
@@ -0,0 +1 @@
+1 24 "windows.manifest"
diff --git a/build/windows_manifest.rs b/build/windows_manifest.rs
new file mode 100644
index 00000000..1e39a4b0
--- /dev/null
+++ b/build/windows_manifest.rs
@@ -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());
+}
diff --git a/crates/oa-core/build.rs b/crates/oa-core/build.rs
new file mode 100644
index 00000000..f182d7d0
--- /dev/null
+++ b/crates/oa-core/build.rs
@@ -0,0 +1 @@
+include!("../../build/windows_manifest.rs");
diff --git a/crates/oa-core/src/language_nesting_tests.rs b/crates/oa-core/src/language_nesting_tests.rs
index cac29fe6..11df0b3b 100644
--- a/crates/oa-core/src/language_nesting_tests.rs
+++ b/crates/oa-core/src/language_nesting_tests.rs
@@ -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"
diff --git a/crates/oa-core/src/projection/state.rs b/crates/oa-core/src/projection/state.rs
index cc9e55b7..1419b033 100644
--- a/crates/oa-core/src/projection/state.rs
+++ b/crates/oa-core/src/projection/state.rs
@@ -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 {
diff --git a/crates/oa-core/src/semantic/mutate.rs b/crates/oa-core/src/semantic/mutate.rs
index bc31f624..9f8ff260 100644
--- a/crates/oa-core/src/semantic/mutate.rs
+++ b/crates/oa-core/src/semantic/mutate.rs
@@ -350,10 +350,7 @@ pub(crate) fn write_fact(
else {
return Err("Target has no message annotations".into());
};
- values.retain(|v| AnnotationKey::from(v) != *key);
- if let Some(row) = row {
- values.push(row);
- }
+ replace_keyed_row(values, row, |v| AnnotationKey::from(v) == *key);
Ok(())
}
FacetReview::Binding { keys, review } => {
@@ -374,10 +371,7 @@ pub(crate) fn write_fact(
else {
return Err("Target has no scenario bindings".into());
};
- bindings.retain(|v| v.referenced != key);
- if let Some(row) = row {
- bindings.push(row);
- }
+ replace_keyed_row(bindings, row, |v| v.referenced == key);
Ok(())
}
FacetReview::Child { keys, review } => {
@@ -467,6 +461,27 @@ pub(crate) fn write_fact(
}
}
+// Reviewing an unordered row does not authorize changing its authored position.
+// Preserve surviving entries in place; only genuinely new rows append.
+fn replace_keyed_row(
+ rows: &mut Vec,
+ row: Option,
+ 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 {
diff --git a/crates/oa-core/src/validation.rs b/crates/oa-core/src/validation.rs
index f6d24f1a..8987acc1 100644
--- a/crates/oa-core/src/validation.rs
+++ b/crates/oa-core/src/validation.rs
@@ -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,7 @@ fn check_ids(out: &mut Vec, owner: Id, field: &str, ids: &[Id]) {
pub fn validate(p: &Project) -> Vec {
let mut out = Vec::new();
+ let coverage = coverage::Index::new(p);
if p.schema_version != SCHEMA_VERSION {
error(
&mut out,
@@ -346,19 +349,15 @@ pub fn validate(p: &Project) -> Vec {
);
}
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() {
@@ -921,6 +920,9 @@ pub fn validate(p: &Project) -> Vec {
);
}
}
+ 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) {
diff --git a/crates/oa-core/src/validation/coverage.rs b/crates/oa-core/src/validation/coverage.rs
new file mode 100644
index 00000000..1e9a2003
--- /dev/null
+++ b/crates/oa-core/src/validation/coverage.rs
@@ -0,0 +1,283 @@
+//! Advisory presence of explicit behavior evidence, never inferred execution.
+use super::*;
+
+#[derive(Default)]
+pub(super) struct Index {
+ pub grouping: BTreeSet,
+ pub supported_capabilities: BTreeSet,
+ contextual_capabilities: BTreeSet,
+ supported_missions: BTreeSet,
+}
+
+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) {
+ 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, BTreeSet) {
+ let mut pending: Vec<_> = p
+ .elements
+ .values()
+ .filter(|e| {
+ matches!(
+ e.kind(),
+ Kind::Process | Kind::FunctionalChain | Kind::Scenario
+ )
+ })
+ .map(|e| e.id)
+ .collect();
+ let mut seen = BTreeSet::new();
+ let mut work = BTreeSet::new();
+ let mut exchanges = BTreeSet::new();
+ while let Some(id) = pending.pop() {
+ if !seen.insert(id) {
+ continue;
+ }
+ let Some(element) = p.elements.get(&id) else {
+ continue;
+ };
+ if let Some(steps) = element.steps() {
+ pending.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(action),
+ ..
+ } => {
+ if p.elements
+ .get(action)
+ .is_some_and(|e| e.kind().is_work() && e.layer() == element.layer())
+ {
+ work.insert(*action);
+ }
+ }
+ OccurrenceBody::Exchange {
+ exchange: Some(exchange),
+ ..
+ } => {
+ if let Some(relation) = p.relations.get(exchange)
+ && work_exchange(relation.kind())
+ && [relation.source(), relation.target()].iter().all(|id| {
+ p.elements
+ .get(id)
+ .is_some_and(|e| e.kind().is_work() && e.layer() == element.layer())
+ })
+ {
+ exchanges.insert(*exchange);
+ work.extend([relation.source(), relation.target()]);
+ }
+ }
+ OccurrenceBody::Fragment { branches, .. } => {
+ pending.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()
+ })
+ {
+ pending.push(*sequence);
+ }
+ }
+ OccurrenceBody::ScenarioUse {
+ scenario: Some(scenario),
+ ..
+ } if p.elements.get(scenario).is_some_and(|target| {
+ target.kind() == Kind::Scenario && target.layer() == element.layer()
+ }) =>
+ {
+ pending.push(*scenario);
+ }
+ _ => {}
+ }
+ }
+ (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()));
+ }
+}
diff --git a/crates/oa-core/tests/coverage_diagnostics.rs b/crates/oa-core/tests/coverage_diagnostics.rs
new file mode 100644
index 00000000..6dc1ea18
--- /dev/null
+++ b/crates/oa-core/tests/coverage_diagnostics.rs
@@ -0,0 +1,582 @@
+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);
+fn coverage(p: &Project) -> BTreeSet {
+ 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 {
+ 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 {
+ exchange: Some(e),
+ sender: Some(sender),
+ receiver: Some(receiver),
+ message_kind: MessageKind::Call,
+ reply_to: None,
+ values: vec![],
+ },
+ );
+ occurrence(
+ &mut p,
+ branch,
+ OccurrenceBody::Exchange {
+ 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 {
+ 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
+}
diff --git a/crates/oa-core/tests/r3_semantic.rs b/crates/oa-core/tests/r3_semantic.rs
index 1e0ef9fe..dbe6f548 100644
--- a/crates/oa-core/tests/r3_semantic.rs
+++ b/crates/oa-core/tests/r3_semantic.rs
@@ -642,6 +642,11 @@ fn atomic_annotation_full_path_updates_only_selected_row() {
let rows = values_mut(&mut q, b.message);
assert_eq!(rows.len(), 2);
assert!(rows.contains(&retained));
+ assert_eq!(AnnotationKey::from(&rows[0]), b.first);
+ assert_eq!(
+ rows[1], retained,
+ "Unselected rows retain their authored position"
+ );
assert!(
rows.iter()
.any(|v| AnnotationKey::from(v) == b.first && v.expression == "new first")
@@ -651,6 +656,73 @@ fn atomic_annotation_full_path_updates_only_selected_row() {
.any(|v| AnnotationKey::from(v) == b.second && v.expression == retained.expression)
);
}
+
+#[test]
+fn keeping_keyed_rows_with_an_unrelated_header_candidate_preserves_exact_authored_order() {
+ for binding in [false, true] {
+ for reverse in [false, true] {
+ let mut p = Project::empty();
+ let (oa, sa, selector, references) = if binding {
+ let a = scenario_use(&mut p, Layer::Oa, "oa");
+ let b = scenario_use(&mut p, Layer::Sa, "sa");
+ (
+ a.use_id,
+ b.use_id,
+ FacetSelector::Binding(KeyPair {
+ oa: Some(a.referenced),
+ sa: Some(b.referenced),
+ }),
+ BTreeMap::from([(a.referenced, b.referenced)]),
+ )
+ } else {
+ let a = payload(&mut p, Layer::Oa, "oa");
+ let b = payload(&mut p, Layer::Sa, "sa");
+ let mut references: BTreeMap<_, _> = a
+ .first
+ .path
+ .iter()
+ .copied()
+ .zip(b.first.path.iter().copied())
+ .collect();
+ references.insert(a.leaf, b.leaf);
+ (
+ a.message,
+ b.message,
+ FacetSelector::Annotation(KeyPair {
+ oa: Some(a.first),
+ sa: Some(b.first),
+ }),
+ references,
+ )
+ };
+ trace(&mut p, oa, sa);
+ let (source, target) = if reverse { (sa, oa) } else { (oa, sa) };
+ let mut candidate = p.elements[&target].clone();
+ candidate.description = "Review the header while retaining authored rows".into();
+ let mut review = request(
+ source,
+ target,
+ vec![
+ (selector, SemanticDecision::KeepTarget),
+ (
+ FacetSelector::Header(HeaderFacet::Description),
+ SemanticDecision::Candidate,
+ ),
+ ],
+ );
+ if reverse {
+ review.transition.direction = TransitionDirection::SaToOa;
+ }
+ review.references = references;
+ review
+ .candidates
+ .push(SemanticRecord::Element(candidate.clone()));
+ let accepted = applied(&p, review);
+ assert_eq!(accepted.elements[&target], candidate);
+ assert_eq!(accepted.elements[&source], p.elements[&source]);
+ }
+ }
+}
#[test]
fn atomic_binding_updates_local_and_rejects_pair_collision_and_remapping_without_reset() {
let mut p = Project::empty();
@@ -671,6 +743,8 @@ fn atomic_binding_updates_local_and_rejects_pair_collision_and_remapping_without
r.references = BTreeMap::from([(a.referenced, b.referenced), (a.alternate, b.alternate)]);
let mut q = applied(&p, r.clone());
assert!(bindings_mut(&mut q, b.use_id).contains(&retained));
+ assert_eq!(bindings_mut(&mut q, b.use_id)[0].referenced, b.referenced);
+ assert_eq!(bindings_mut(&mut q, b.use_id)[1], retained);
assert!(
bindings_mut(&mut q, b.use_id)
.iter()
diff --git a/crates/oa-desktop/Cargo.toml b/crates/oa-desktop/Cargo.toml
index d7fa0fdb..2eed9a7c 100644
--- a/crates/oa-desktop/Cargo.toml
+++ b/crates/oa-desktop/Cargo.toml
@@ -17,10 +17,10 @@ serde.workspace = true
serde_json.workspace = true
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
tokio = { version = "1", features = ["rt", "time"] }
+tempfile = "3"
[dev-dependencies]
iced_runtime = "0.14"
oa-server = { path = "../oa-server" }
axum = { version = "0.8", default-features = false, features = ["http1", "json", "tokio"] }
-tempfile = "3"
tokio = { version = "1", features = ["macros", "rt-multi-thread", "net", "sync"] }
diff --git a/crates/oa-desktop/build.rs b/crates/oa-desktop/build.rs
new file mode 100644
index 00000000..f182d7d0
--- /dev/null
+++ b/crates/oa-desktop/build.rs
@@ -0,0 +1 @@
+include!("../../build/windows_manifest.rs");
diff --git a/crates/oa-desktop/src/app.rs b/crates/oa-desktop/src/app.rs
index 2564a670..acafdf04 100644
--- a/crates/oa-desktop/src/app.rs
+++ b/crates/oa-desktop/src/app.rs
@@ -1,3 +1,4 @@
+mod checkpoints;
mod hosted;
use crate::forms::Draft;
use crate::{
@@ -260,6 +261,7 @@ pub struct RecoverySnapshot {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Page {
+ Home,
Diagram,
Catalog,
Behavior,
@@ -281,6 +283,8 @@ impl std::fmt::Display for Choice {
}
#[derive(Debug, Clone, Copy)]
pub enum Pending {
+ Recent,
+ Recover(Id),
HostedOpen,
New,
Demo,
@@ -301,6 +305,10 @@ pub struct FilePrompt {
#[derive(Debug, Clone)]
pub enum Message {
+ Home(crate::home::Event),
+ CheckpointTick,
+ CheckpointWritten(Id, Result<(), String>),
+ CheckpointLoaded(Result, String>),
Hosted(crate::hosted::Event),
PortAnchor(crate::port_edit::Event),
Connection(crate::connection_edit::Event),
@@ -413,6 +421,10 @@ pub enum Message {
}
pub struct App {
+ pub home: crate::home::State,
+ pub recovery: crate::recovery::State,
+ recent_target: Option,
+ checkpoint_close: Option,
pub hosted: crate::hosted::State,
pub connection: Option,
pub model_menu: Option,
@@ -424,6 +436,8 @@ pub struct App {
pub exact_strings: bool,
pub document: Document,
pub page: Page,
+ /// Home resumes this page; explicit Architecture navigation remains separate.
+ last_workspace_page: Page,
pub guide: crate::guide::State,
pub behavior: crate::behavior::State,
pub data_state: crate::data_view::State,
@@ -473,7 +487,11 @@ pub struct App {
impl App {
pub fn new() -> Self {
- let mut app = Self::with_project(Project::demo());
+ let mut app = Self::with_project(Project::empty());
+ app.home = crate::home::State::load_default();
+ app.recovery = crate::recovery::State::load_default();
+ app.page = Page::Home;
+ app.status = "Create a model, open a project or explore the example.".into();
app.fit();
app
}
@@ -502,7 +520,11 @@ impl App {
let draft = Draft::from_project(&project, selected);
let explorer_index = crate::explorer::Index::new(&project, layer);
let explorer_collapsed = project.elements.values().filter_map(|e| e.parent).collect();
- Self {
+ let mut app = Self {
+ home: Default::default(),
+ recovery: Default::default(),
+ recent_target: None,
+ checkpoint_close: None,
connection: None,
model_menu: None,
source_tree: Default::default(),
@@ -513,6 +535,7 @@ impl App {
exact_strings: false,
document,
page: Page::Diagram,
+ last_workspace_page: Page::Diagram,
guide: crate::guide::State::default(),
behavior: Default::default(),
data_state: Default::default(),
@@ -562,7 +585,10 @@ impl App {
explorer_group: None,
explorer_page: 0,
workflow: None,
- }
+ };
+ app.route_state_diagram_workspace();
+ app.last_workspace_page = app.page;
+ app
}
pub fn project(&self) -> &Project {
self.document.project()
@@ -586,6 +612,7 @@ impl App {
}
pub fn dirty(&self) -> bool {
self.document.dirty()
+ || self.workflow.is_some()
|| self.draft_dirty()
|| self.source_dirty()
|| self.behavior.has_pending()
@@ -844,6 +871,9 @@ impl App {
}
}
fn reset(&mut self, project: Project, path: Option) {
+ self.retire_checkpoint();
+ let home = std::mem::take(&mut self.home);
+ let recovery = std::mem::take(&mut self.recovery);
let window_size = self.window_size;
let canvas_size = self.canvas_size;
let source_version = self.source_version + 1;
@@ -852,6 +882,8 @@ impl App {
let mut hosted = std::mem::take(&mut self.hosted);
hosted.leave_document();
*self = Self::with_project(project);
+ self.home = home;
+ self.recovery = recovery;
self.hosted = hosted;
self.window_size = window_size;
self.canvas_size = canvas_size;
@@ -923,6 +955,7 @@ impl App {
}
pub fn subscription(&self) -> Subscription {
Subscription::batch([
+ iced::time::every(std::time::Duration::from_secs(10)).map(|_| Message::CheckpointTick),
window::close_requests().map(|id| Message::Request(Pending::Close(id))),
window::resize_events().map(|(_, size)| Message::WindowResized(size)),
iced::event::listen_with(|event, status, _| {
@@ -992,6 +1025,9 @@ impl App {
&& !matches!(
message,
Message::Menu(_)
+ | Message::CheckpointWritten(..)
+ | Message::CheckpointLoaded(..)
+ | Message::CheckpointTick
| Message::WindowResized(_)
| Message::CanvasResized(_)
| Message::Lint(_)
@@ -1030,7 +1066,9 @@ impl App {
if self.busy
&& !matches!(
message,
- Message::Saved(_)
+ Message::CheckpointWritten(..)
+ | Message::CheckpointLoaded(..)
+ | Message::Saved(_)
| Message::Hosted(crate::hosted::Event::Completed(..))
| Message::Loaded(_)
| Message::IncludesLoaded(_, _)
@@ -1073,7 +1111,10 @@ impl App {
}
let modal_event = matches!(
message,
- Message::WindowResized(_)
+ Message::CheckpointWritten(..)
+ | Message::CheckpointTick
+ | Message::CheckpointLoaded(..)
+ | Message::WindowResized(_)
| Message::CanvasResized(_)
| Message::SourceViewport
| Message::Lint(_)
@@ -1145,7 +1186,14 @@ impl App {
let previous_layer = self.layer;
let entering_catalog =
self.page != Page::Catalog && matches!(message, Message::Page(Page::Catalog));
+ if self.page != Page::Home {
+ self.last_workspace_page = self.page;
+ }
let mut task = self.handle(message);
+ self.route_state_diagram_workspace();
+ if self.page != Page::Home {
+ self.last_workspace_page = self.page;
+ }
if let Some(context) = self.topology_context() {
self.behavior
.topology
@@ -1206,6 +1254,16 @@ impl App {
}
fn handle(&mut self, message: Message) -> Task {
match message {
+ Message::Home(event) => return self.home_event(event),
+ Message::CheckpointTick => return self.checkpoint_tick(),
+ Message::CheckpointWritten(id, result) => {
+ self.checkpoint_written(id, result);
+ if let Some(window) = self.checkpoint_close.take() {
+ self.retire_checkpoint();
+ return window::close(window);
+ }
+ }
+ Message::CheckpointLoaded(result) => return self.checkpoint_loaded(result),
Message::Hosted(event) => return self.hosted_event(event),
Message::ScenarioCanvas(event) => {
use crate::behavior::scenario::{self, CanvasEvent};
@@ -1600,6 +1658,27 @@ impl App {
}
Message::Page(page) => {
if page != Page::Text || !self.draft_dirty() || self.apply() {
+ if page == Page::Diagram
+ && self.active_diagram.is_some_and(|id| {
+ self.project()
+ .diagrams
+ .get(&id)
+ .is_some_and(|d| d.projection.state_machine().is_some())
+ })
+ {
+ // Architecture remains reachable from a state workspace.
+ // Its picker can still open a saved state view explicitly.
+ self.active_diagram = self
+ .project()
+ .diagrams
+ .values()
+ .filter(|d| {
+ d.layer == self.layer && d.projection.state_machine().is_none()
+ })
+ .min_by_key(|d| (d.projection != ProjectionKind::Allocation, d.id))
+ .map(|d| d.id);
+ self.fit();
+ }
if page == Page::Behavior && self.page != Page::Behavior {
let previous_state_view = self.state_view_identity();
if let Some(owner) =
@@ -2545,7 +2624,9 @@ impl App {
self.pending_recovery = None;
}
if receipt.snapshot.bundle.entry != self.document.bundle().entry {
- if receipt.revision != self.document.revision() {
+ if receipt.revision != self.document.revision()
+ || self.source_dirty()
+ {
self.status = format!(
"Snapshot saved to {}. Newer edits retain the current source location.",
receipt.path.display()
@@ -2553,8 +2634,9 @@ impl App {
self.continue_after_save = false;
return Task::none();
}
- if let Err(error) =
- self.document.apply_bundle(receipt.snapshot.bundle.clone())
+ if let Err(error) = self
+ .document
+ .adopt_saved_entry(&receipt.snapshot, receipt.revision)
{
self.status = error.to_string();
self.continue_after_save = false;
@@ -2568,6 +2650,10 @@ impl App {
self.hosted.review = None;
self.hosted.uncertain = None;
self.document.mark_saved(receipt.snapshot);
+ self.remember_local();
+ if !self.dirty() {
+ self.retire_checkpoint();
+ }
self.prune_closed_files(!self.document.dirty());
self.status = "All source files saved".into();
} else {
@@ -2608,6 +2694,10 @@ impl App {
match result {
Ok(Some(LoadedSnapshot::Legacy(path, decoded, _bytes))) => {
self.reset(decoded.project, None);
+ let name = self.project().name.clone();
+ if let Err(error) = self.home.remember(&path, &name) {
+ self.home.notice = Some(error);
+ }
self.migration = decoded.migration;
if self.migration.is_some() {
self.status = "Imported earlier schema · review migration details in Model checks before saving".into();
@@ -2905,6 +2995,11 @@ impl App {
fn proceed(&mut self, pending: Pending) -> Task {
self.pending = None;
match pending {
+ Pending::Recent => self
+ .recent_target
+ .take()
+ .map_or_else(Task::none, |path| self.load_from(path)),
+ Pending::Recover(id) => self.load_checkpoint(id),
Pending::HostedOpen => self.open_hosted(),
Pending::New => {
self.reset(Project::empty(), None);
@@ -2915,7 +3010,16 @@ impl App {
Task::none()
}
Pending::Open => self.choose_file(FileOperation::Open),
- Pending::Close(id) => window::close(id),
+ Pending::Close(id) => {
+ self.retire_checkpoint();
+ if self.recovery.writing {
+ self.checkpoint_close = Some(id);
+ self.busy = true;
+ Task::none()
+ } else {
+ window::close(id)
+ }
+ }
}
}
fn choose_file(&mut self, operation: FileOperation) -> Task {
@@ -2990,6 +3094,25 @@ impl App {
)
}
fn save_to(&mut self, path: PathBuf) -> Task {
+ if !path
+ .extension()
+ .is_some_and(|ext| ext.eq_ignore_ascii_case("json"))
+ {
+ let result = path
+ .file_name()
+ .and_then(|name| name.to_str())
+ .ok_or_else(|| "Choose a portable .arcadia entry file name.".to_owned())
+ .and_then(|entry| {
+ self.document
+ .check_save_entry(entry)
+ .map_err(|e| e.to_string())
+ });
+ if let Err(error) = result {
+ self.status = error;
+ self.continue_after_save = false;
+ return Task::none();
+ }
+ }
self.busy = true;
let snapshot = self.document.snapshot();
let document = self.canvas_document;
@@ -3084,6 +3207,9 @@ impl App {
}
self.pending_recovery = pending_recovery;
self.disk_project = Some(disk);
+ if self.source_workspace.applied {
+ self.remember_local();
+ }
}
fn prune_closed_files(&mut self, prune_expectations: bool) {
@@ -3731,6 +3857,10 @@ fn read_snapshot(path: PathBuf) -> Result {
))
}
+#[cfg(test)]
+#[path = "app/save_as_tests.rs"]
+mod save_as_tests;
+
#[cfg(test)]
mod source_project_tests {
use super::*;
diff --git a/crates/oa-desktop/src/app/checkpoints.rs b/crates/oa-desktop/src/app/checkpoints.rs
new file mode 100644
index 00000000..73b39ac0
--- /dev/null
+++ b/crates/oa-desktop/src/app/checkpoints.rs
@@ -0,0 +1,479 @@
+//! 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 {
+ 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::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 {
+ 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()))
+ {
+ 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 {
+ 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, String>,
+ ) -> Task {
+ 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.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.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
+ };
+ self.status="Recovered an unsaved local copy. Choose Save as to save it; the original file and hosted model are unchanged.".into();
+ self.request_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()));
+ }
+
+ 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()
+ }
+
+ #[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"));
+ }
+}
diff --git a/crates/oa-desktop/src/app/save_as_tests.rs b/crates/oa-desktop/src/app/save_as_tests.rs
new file mode 100644
index 00000000..c6ddab7d
--- /dev/null
+++ b/crates/oa-desktop/src/app/save_as_tests.rs
@@ -0,0 +1,114 @@
+use super::*;
+
+fn complete_save(app: &mut App, path: PathBuf) {
+ let receipt = write_snapshot(
+ app.canvas_document,
+ app.document.revision(),
+ app.document.snapshot(),
+ path,
+ app.disk_project.clone(),
+ )
+ .unwrap();
+ let _ = app.update(Message::Saved(Ok(Some(receipt))));
+}
+
+#[test]
+fn save_as_receipt_then_one_undo_restores_meaning_and_saving_preserves_redo() {
+ let directory = tempfile::tempdir().unwrap();
+ let path = directory.path().join("semantic-accepted.arcadia");
+ let mut app = App::with_project(Project::empty());
+ app.document.mark_recovered();
+ let original = app.document.bundle().files[&app.document.bundle().entry].clone();
+ app.execute(Command::Rename(
+ app.project().id,
+ "Accepted semantic change".into(),
+ ));
+ let after = app.document.bundle().files[&app.document.bundle().entry].clone();
+ complete_save(&mut app, path.clone());
+ assert!(!app.dirty());
+ assert_eq!(app.path.as_ref(), Some(&path));
+ assert_eq!(std::fs::read_to_string(&path).unwrap(), after);
+ let _ = app.update(Message::Undo);
+ assert_eq!(app.document.bundle().entry, "semantic-accepted.arcadia");
+ assert_eq!(
+ app.document.bundle().files[&app.document.bundle().entry],
+ original
+ );
+ assert!(
+ !app.document.can_undo(),
+ "Save as did not insert a history step"
+ );
+ assert!(app.document.can_redo());
+ complete_save(&mut app, path.clone());
+ assert_eq!(std::fs::read_to_string(&path).unwrap(), original);
+ assert!(
+ app.document.can_redo(),
+ "Saving an undone state preserves the future"
+ );
+ let _ = app.update(Message::Redo);
+ assert_eq!(
+ app.document.bundle().files[&app.document.bundle().entry],
+ after
+ );
+ assert!(app.dirty());
+ assert_eq!(
+ source_project::compile_bundle(app.document.bundle(), LIMITS)
+ .unwrap()
+ .decoded
+ .project,
+ *app.project()
+ );
+}
+
+#[test]
+fn save_as_preflight_rejects_historical_collision_before_starting_io() {
+ let directory = tempfile::tempdir().unwrap();
+ let path = directory.path().join("taken.arcadia");
+ let mut app = App::with_project(Project::empty());
+ let base = app.document.bundle().clone();
+ let mut included = base.clone();
+ let text = included.files.get_mut(&included.entry).unwrap();
+ text.insert_str(text.rfind('}').unwrap(), "\n include \"taken.arcadia\";\n");
+ included
+ .files
+ .insert("taken.arcadia".into(), "// retained\n".into());
+ app.document.apply_bundle(included).unwrap();
+ app.document.apply_bundle(base).unwrap();
+ app.sync_source();
+ let before = app.document.snapshot();
+ let task = app.save_to(path.clone());
+ assert!(iced_runtime::task::into_stream(task).is_none());
+ assert!(!path.exists());
+ assert!(!app.busy);
+ assert!(app.status.contains("Undo/Redo"));
+ assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
+ let json = app.save_to(directory.path().join("taken.json"));
+ assert!(
+ iced_runtime::task::into_stream(json).is_some(),
+ "JSON exports do not relocate source entries"
+ );
+}
+
+#[test]
+fn save_as_late_receipt_preserves_newer_raw_source_draft_and_destination() {
+ let directory = tempfile::tempdir().unwrap();
+ let mut app = App::with_project(Project::empty());
+ let entry = app.document.bundle().entry.clone();
+ let receipt = write_snapshot(
+ app.canvas_document,
+ app.document.revision(),
+ app.document.snapshot(),
+ directory.path().join("accepted.arcadia"),
+ None,
+ )
+ .unwrap();
+ let raw = format!("{}\nunfinished {{", app.source_workspace.text());
+ let cursor = app.source_workspace.files[&entry].cursor;
+ app.source_workspace.edit(raw.clone(), cursor);
+ let _ = app.update(Message::Saved(Ok(Some(receipt))));
+ assert_eq!(app.source_workspace.text(), raw);
+ assert_eq!(app.document.bundle().entry, entry);
+ assert!(app.path.is_none());
+ assert!(app.dirty());
+ assert!(app.status.contains("Newer edits"));
+}
diff --git a/crates/oa-desktop/src/app/state_diagram.rs b/crates/oa-desktop/src/app/state_diagram.rs
index 63e9cfe3..bf6cd7bc 100644
--- a/crates/oa-desktop/src/app/state_diagram.rs
+++ b/crates/oa-desktop/src/app/state_diagram.rs
@@ -6,6 +6,41 @@ use crate::behavior::{
};
impl App {
+ /// A saved state view has one renderer and tool set: the behavior workspace.
+ /// Run after navigation/adoption, never as a model or property edit. Keeping
+ /// selection and its Draft intact also preserves unrelated raw properties.
+ pub(super) fn route_state_diagram_workspace(&mut self) {
+ if self.page != Page::Diagram {
+ return;
+ }
+ let Some((diagram, machine, layer)) = self.active_diagram.and_then(|id| {
+ let view = self.project().diagrams.get(&id)?;
+ Some((id, view.projection.state_machine()?, view.layer))
+ }) else {
+ return;
+ };
+ // Existing navigation guards own pending behavior forms. Never replace
+ // their owner/camera if an exceptional caller leaves one on another page.
+ if self.behavior.has_pending() {
+ return;
+ }
+ self.layer = layer;
+ self.page = Page::Behavior;
+ self.behavior.active_owner = Some(machine);
+ self.behavior.diagram = behavior::state_view::State {
+ diagram: Some(diagram),
+ placement: self.project().diagrams[&diagram]
+ .nodes
+ .values()
+ .find(|node| node.id == self.selected || node.element == self.selected)
+ .map(|node| node.id),
+ ..Default::default()
+ };
+ self.selected_node = None;
+ self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
+ self.fit_state_diagram();
+ }
+
/// The behavior view resolved independently of the current page. Capture
/// this before selection/layer changes, while it still identifies the view
/// whose camera and tool are retained during navigation.
diff --git a/crates/oa-desktop/src/app/state_diagram_tests.rs b/crates/oa-desktop/src/app/state_diagram_tests.rs
index 47a69842..0be1b787 100644
--- a/crates/oa-desktop/src/app/state_diagram_tests.rs
+++ b/crates/oa-desktop/src/app/state_diagram_tests.rs
@@ -89,6 +89,153 @@ fn assert_source_model_agree(app: &App) {
assert!(!app.source_dirty());
}
+#[test]
+fn state_diagram_routes_constructor_and_source_open_to_the_existing_workspace_without_edits() {
+ let mut f = fixture();
+ let diagram = create_view(&mut f).diagram;
+ let project = f.app.project().clone();
+ let app = App::with_project(project.clone());
+ assert_eq!(app.page, Page::Behavior);
+ assert_eq!(app.state_context().unwrap().diagram, diagram);
+ assert_eq!(behavior::selected_owner(&app), Some(f.machine));
+ assert_eq!(app.behavior.diagram.tool, Tool::Select);
+ assert_eq!(app.selected, project.id);
+ assert_eq!(app.project(), &project);
+ assert!(!app.document.can_undo());
+
+ let directory = tempfile::tempdir().unwrap();
+ let path = directory.path().join("state-view.arcadia");
+ let text = format!(
+ "// Exact authored source must remain unchanged.\n{}",
+ language::format(&project)
+ );
+ std::fs::write(&path, &text).unwrap();
+ let mut reopened = App::with_project(Project::empty());
+ send(
+ &mut reopened,
+ Message::Loaded(Ok(Some(read_snapshot(path.clone()).unwrap()))),
+ );
+ assert_eq!(reopened.page, Page::Behavior);
+ assert_eq!(reopened.state_context().unwrap().diagram, diagram);
+ assert_eq!(reopened.project(), &project);
+ assert_eq!(
+ reopened.document.bundle().files[&reopened.document.bundle().entry],
+ text
+ );
+ assert_eq!(std::fs::read_to_string(path).unwrap(), text);
+ assert!(!reopened.document.can_undo());
+ assert_source_model_agree(&reopened);
+}
+
+#[test]
+fn state_diagram_routes_picker_and_reveal_but_explicit_architecture_does_not_bounce() {
+ let mut f = fixture();
+ let diagram = create_view(&mut f).diagram;
+ let mut project = f.app.project().clone();
+ let second = state_edit::create_view(&project, f.machine).unwrap();
+ project = prepare_command(&project, &second.command).unwrap().project;
+ let transition = project.relate(RelationKind::StateTransition, "Switch", f.a, f.b);
+ let mut app = App::with_project(project.clone());
+ let before = app.document.snapshot();
+ send(&mut app, Message::Page(Page::Diagram));
+ assert_eq!(app.page, Page::Diagram);
+ assert_eq!(app.active_diagram, None);
+ send(
+ &mut app,
+ Message::Diagram(Choice {
+ id: second.diagram,
+ label: "Second state view".into(),
+ }),
+ );
+ assert_eq!(app.page, Page::Behavior);
+ assert_eq!(app.state_context().unwrap().diagram, second.diagram);
+ send(&mut app, Message::RevealInDiagram(diagram, transition));
+ assert_eq!(app.page, Page::Behavior);
+ assert_eq!(app.state_context().unwrap().diagram, diagram);
+ assert_eq!(app.selected, transition);
+ assert_eq!(app.draft.as_ref().unwrap().original, transition);
+ assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
+ assert_source_model_agree(&app);
+
+ let architecture = project.ensure_diagram(Layer::Sa);
+ let mut app = App::with_project(project);
+ send(
+ &mut app,
+ Message::Diagram(Choice {
+ id: diagram,
+ label: "State view".into(),
+ }),
+ );
+ assert_eq!(app.page, Page::Behavior);
+ send(&mut app, Message::Page(Page::Diagram));
+ assert_eq!(app.page, Page::Diagram);
+ assert_eq!(app.active_diagram, Some(architecture));
+ assert!(app.state_context().is_none());
+ assert!(!app.document.can_undo());
+}
+
+#[test]
+fn state_diagram_routes_preserve_selected_raw_properties_and_pending_behavior_guards() {
+ let mut f = fixture();
+ let diagram = create_view(&mut f).diagram;
+ let mut app = App::with_project(f.app.project().clone());
+ send(&mut app, Message::Select(f.a));
+ send(
+ &mut app,
+ Message::Field("/alias".into(), "invalid alias!".into()),
+ );
+ let raw = serde_json::to_value(app.draft.as_ref().unwrap().recovery_snapshot()).unwrap();
+ assert!(!app.apply());
+ let before = app.document.snapshot();
+ // Exercise normalization itself with an existing raw draft, including callers
+ // that have just adopted a view without a successful property Apply.
+ app.page = Page::Diagram;
+ app.route_state_diagram_workspace();
+ assert_eq!(app.page, Page::Behavior);
+ assert_eq!(app.selected, f.a);
+ assert_eq!(
+ serde_json::to_value(app.draft.as_ref().unwrap().recovery_snapshot()).unwrap(),
+ raw
+ );
+ assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
+ send(
+ &mut app,
+ Message::Diagram(Choice {
+ id: diagram,
+ label: "State view".into(),
+ }),
+ );
+ assert_eq!(
+ serde_json::to_value(app.draft.as_ref().unwrap().recovery_snapshot()).unwrap(),
+ raw
+ );
+ assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
+
+ let mut app = App::with_project(f.app.project().clone());
+ form(
+ &mut app,
+ states::Event::Start(f.machine, states::Tool::Transition, f.a),
+ );
+ form(
+ &mut app,
+ states::Event::Name("Pending behavior stays".into()),
+ );
+ let pending = app.behavior.recovery_snapshot().unwrap();
+ let context = app.state_context().unwrap();
+ send(&mut app, Message::Page(Page::Diagram));
+ send(
+ &mut app,
+ Message::Diagram(Choice {
+ id: diagram,
+ label: "State view".into(),
+ }),
+ );
+ assert_eq!(app.page, Page::Behavior);
+ assert_eq!(app.state_context().unwrap().diagram, context.diagram);
+ assert_eq!(app.behavior.recovery_snapshot().unwrap(), pending);
+ assert!(!app.document.can_undo());
+}
+
struct NavigationFixture {
app: App,
machine_a: Id,
@@ -789,12 +936,15 @@ fn fallback_owner_first_selection_keeps_the_captured_context_for_its_first_move(
let f = fixture();
let mut project = f.app.project().clone();
// One eligible machine makes the fallback deterministic. Start from a loaded
- // project with no selected behavior element and no explicitly chosen owner.
+ // architecture view with no selected behavior element or chosen owner. A
+ // state-only project now opens its saved view with an explicit owner.
project
.elements
.retain(|id, element| f.members.contains(id) || element.kind() == Kind::System);
let plan = state_edit::create_view(&project, f.machine).unwrap();
- let project = prepare_command(&project, &plan.command).unwrap().project;
+ let mut project = prepare_command(&project, &plan.command).unwrap().project;
+ let architecture = project.ensure_diagram(Layer::Sa);
+ project.diagrams.get_mut(&architecture).unwrap().projection = ProjectionKind::Allocation;
let mut app = App::with_project(project);
assert_eq!(app.selected, app.project().id);
assert!(app.behavior.active_owner.is_none());
@@ -917,7 +1067,11 @@ fn shared_semantic_states_keep_architecture_and_behavior_placements_independent(
id: architecture,
label: app.project().named(architecture).into(),
};
+ // The initial UUID-ordered view may be a state view, which correctly opens
+ // Behavior. Enter Architecture explicitly before testing its selection.
+ send(&mut app, Message::Page(Page::Diagram));
send(&mut app, Message::Diagram(architecture_choice));
+ assert_eq!(app.page, Page::Diagram);
send(&mut app, Message::Select(f.b));
assert_eq!(app.active_diagram, Some(architecture));
assert_eq!(app.selected_node, Some(arch_b));
diff --git a/crates/oa-desktop/src/behavior.rs b/crates/oa-desktop/src/behavior.rs
index b122640b..8f63e764 100644
--- a/crates/oa-desktop/src/behavior.rs
+++ b/crates/oa-desktop/src/behavior.rs
@@ -556,7 +556,72 @@ pub struct State {
states: states::State,
}
const PENDING_DRAFT: &str = "Apply or cancel the current behavior form before starting another.";
+#[derive(serde::Serialize, serde::Deserialize)]
+#[serde(deny_unknown_fields)]
+struct RecoveryDraft {
+ version: u32,
+ kind: RecoveryKind,
+ draft: serde_json::Value,
+}
+#[derive(serde::Serialize, serde::Deserialize)]
+enum RecoveryKind {
+ State,
+ Scenario,
+ Process,
+}
impl State {
+ /// Capture only the pending authored form. Pointer gestures, cached choices,
+ /// viewport geometry and document/revision tokens never cross a restart.
+ pub fn recovery_snapshot(&self) -> Result