Complete source 6 engineering workflows and preserve release validation evidence
This commit is contained in:
@@ -12,3 +12,9 @@
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.zip binary
|
||||
|
||||
# Exact source/recovery oracles and archived ledgers retain their measured bytes.
|
||||
# Keep ordinary text diffs; only disable checkout/check-in newline conversion.
|
||||
harness/fixtures/release1-native/** -text
|
||||
harness/fixtures/interface-operation-probe/** -text
|
||||
harness/history/2026-09-06-ledgers-before-consolidation/** -text
|
||||
|
||||
Generated
+1
@@ -2143,6 +2143,7 @@ dependencies = [
|
||||
name = "oa-core"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"same-file",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
||||
@@ -55,12 +55,12 @@ The desktop starts at **Home**, where you can create a model, open a local proje
|
||||
|
||||
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.
|
||||
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.
|
||||
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**, 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. 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.
|
||||
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.
|
||||
@@ -69,7 +69,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.
|
||||
|
||||
@@ -84,7 +86,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 5;
|
||||
arcadia 6;
|
||||
project harbor "Harbor assistance" {
|
||||
oa_entity control "Harbor control" {}
|
||||
oa_activity respond "Respond to requests" {
|
||||
@@ -118,7 +120,9 @@ Ctrl+S saves to the current local or hosted destination, Ctrl+Shift+S opens Save
|
||||
|
||||
**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).
|
||||
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
|
||||
|
||||
@@ -149,6 +153,8 @@ To preserve authored files and comments, use `POST /api/v1/projects/import-sourc
|
||||
|
||||
## Development
|
||||
|
||||
The 2026-09-06 source-6 verification accounts for **943 passing tests and one existing ignored benchmark** across the full workspace run and complete affected-suite reruns after three compatibility-expectation corrections. The **48 HTTP tests** are included in that coverage, not additional to it. Source-6 OpenAPI validation passes 39 fixtures and checks 197 component schemas. [Current state](harness/STATE.md) identifies the exact logs and outstanding checks; this evidence does not claim one uninterrupted green workspace run or accept subsequent edits. Fresh GUI/server builds, strict checks for the final tree, source-6 native workflows, Linux/MSRV verification and current release packages remain release gates.
|
||||
|
||||
```sh
|
||||
cargo fmt --all -- --check
|
||||
cargo clippy --workspace --all-targets --locked -- -D warnings
|
||||
|
||||
@@ -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,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,441 @@
|
||||
//! 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 {
|
||||
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(())
|
||||
}
|
||||
@@ -78,6 +78,7 @@ fn diagram(
|
||||
description: "A focused projection of the shared engineering model.".into(),
|
||||
layer,
|
||||
projection,
|
||||
view_options: Default::default(),
|
||||
nodes: BTreeMap::new(),
|
||||
routes: vec![],
|
||||
},
|
||||
|
||||
@@ -350,6 +350,9 @@ fn reference_field(key: &str) -> bool {
|
||||
| "lifeline"
|
||||
| "state"
|
||||
| "constrained"
|
||||
| "exchanges"
|
||||
| "hidden_edges"
|
||||
| "Any"
|
||||
| "source"
|
||||
| "target"
|
||||
| "source_port"
|
||||
@@ -1383,9 +1386,9 @@ impl Parser<'_> {
|
||||
fn unit(&mut self, entry: bool, includes_allowed: bool) -> Result<SourceUnit, Error> {
|
||||
let mut header = if entry {
|
||||
self.take("arcadia")?;
|
||||
if !matches!(self.text(), "3" | "4" | "5") {
|
||||
if !matches!(self.text(), "3" | "4" | "5" | "6") {
|
||||
return Err(self
|
||||
.error("Unsupported schema version: expected 'arcadia 3;', 'arcadia 4;' or 'arcadia 5;'."));
|
||||
.error("Unsupported schema version: expected 'arcadia 3;', 'arcadia 4;', 'arcadia 5;' or 'arcadia 6;'."));
|
||||
}
|
||||
let schema_version = self.text().parse().expect("checked source version");
|
||||
self.cursor += 1;
|
||||
@@ -1603,6 +1606,9 @@ pub(crate) fn resolve_declarations(
|
||||
};
|
||||
match d.kind {
|
||||
DeclKind::Element(kind, layer) => {
|
||||
if project.schema_version < 6 && !crate::persistence::pre6_element_kind(kind) {
|
||||
return Err(conversion_error(std::format!("{kind:?} requires schema/source 6.")));
|
||||
}
|
||||
if legacy && !crate::persistence::v3::element_allowed(serde_json::to_value(kind).unwrap().as_str().unwrap()) {
|
||||
return Err(conversion_error("Element constructor was not part of schema3.".into()));
|
||||
}
|
||||
@@ -1677,6 +1683,9 @@ pub(crate) fn resolve_declarations(
|
||||
);
|
||||
}
|
||||
DeclKind::Relation(kind) => {
|
||||
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()));
|
||||
}
|
||||
@@ -1713,8 +1722,14 @@ pub(crate) fn resolve_declarations(
|
||||
);
|
||||
}
|
||||
DeclKind::Diagram => {
|
||||
if project.schema_version < 6 && fields.contains_key("view_options") {
|
||||
return Err(conversion_error("Diagram.view_options requires schema/source 6, including an explicitly supplied default value.".into()));
|
||||
}
|
||||
if project.schema_version < 6 && let Some(key)=fields.keys().find(|key| !matches!(key.as_str(),"description"|"layer"|"projection"|"routes")) {
|
||||
return Err(conversion_error(std::format!("Unknown diagram field '{key}' in the frozen pre6 contract.")));
|
||||
}
|
||||
let defaults =
|
||||
serde_json::json!({"description":"","layer":"Oa","projection":"Overview","routes":[]});
|
||||
serde_json::json!({"description":"","layer":"Oa","projection":"Overview","routes":[],"view_options":crate::view_options::ViewOptions::default()});
|
||||
let mut record = merge_fields(defaults.as_object().unwrap().clone(), fields)
|
||||
.map_err(&conversion_error)?;
|
||||
header(&mut record, d, true);
|
||||
@@ -1837,8 +1852,13 @@ pub(crate) fn resolve_declarations(
|
||||
}
|
||||
return Ok(migrated);
|
||||
}
|
||||
if project.schema_version == 4 {
|
||||
return crate::persistence::v4::finish(project, limits).map_err(|error| {
|
||||
if project.schema_version == 4 || project.schema_version == 5 {
|
||||
return (if project.schema_version == 4 {
|
||||
crate::persistence::v4::finish(project, limits)
|
||||
} else {
|
||||
crate::persistence::migrate_v5(project, limits)
|
||||
})
|
||||
.map_err(|error| {
|
||||
(
|
||||
usize::MAX,
|
||||
Error {
|
||||
|
||||
@@ -111,6 +111,10 @@ pub(super) fn resolve_baseline(
|
||||
let json = raw(value)?;
|
||||
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);
|
||||
|
||||
@@ -13,6 +13,7 @@ pub mod source_files;
|
||||
pub mod source_project;
|
||||
pub mod transitions;
|
||||
pub mod validation;
|
||||
pub mod view_options;
|
||||
pub use model::*;
|
||||
pub use ports::*;
|
||||
pub use semantic::*;
|
||||
|
||||
@@ -67,6 +67,8 @@ impl Project4 {
|
||||
if self.schema_version != 4 {
|
||||
return Err(FileError::Invalid("Expected schema_version 4.".into()));
|
||||
}
|
||||
crate::persistence::check_pre6_relations(self.relations.values())?;
|
||||
crate::persistence::check_pre6_elements(self.elements.values())?;
|
||||
Ok(Project {
|
||||
schema_version: SCHEMA_VERSION,
|
||||
id: self.id,
|
||||
@@ -102,6 +104,8 @@ pub(crate) fn review_defaults(project: &Project, report: &mut MigrationReport) {
|
||||
}
|
||||
|
||||
pub(crate) fn finish(mut project: Project, limits: ParseLimits) -> Result<DecodeResult, FileError> {
|
||||
crate::persistence::check_pre6_relations(project.relations.values())?;
|
||||
crate::persistence::check_pre6_elements(project.elements.values())?;
|
||||
project.schema_version = SCHEMA_VERSION;
|
||||
limits.check_model(&project).map_err(FileError::Limit)?;
|
||||
let mut report = MigrationReport::data_upgrade(4);
|
||||
|
||||
@@ -6,7 +6,7 @@ use std::{
|
||||
use uuid::Uuid;
|
||||
|
||||
pub type Id = Uuid;
|
||||
pub const SCHEMA_VERSION: u32 = 5;
|
||||
pub const SCHEMA_VERSION: u32 = 6;
|
||||
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",
|
||||
@@ -564,6 +567,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 +761,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,
|
||||
@@ -815,6 +826,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,6 +856,7 @@ pub enum RelationKind {
|
||||
RoleAssignment,
|
||||
FunctionalExchange,
|
||||
BoundaryExchange,
|
||||
CommunicationMean,
|
||||
MissionCapability,
|
||||
CapabilityInclude,
|
||||
CapabilityExtend,
|
||||
@@ -858,7 +871,7 @@ pub enum RelationKind {
|
||||
StateAction,
|
||||
}
|
||||
impl RelationKind {
|
||||
pub const ALL: [Self; 23] = [
|
||||
pub const ALL: [Self; 24] = [
|
||||
Self::InterfaceItemAllocation,
|
||||
Self::DataAssociation,
|
||||
Self::Allocation,
|
||||
@@ -870,6 +883,7 @@ impl RelationKind {
|
||||
Self::RoleAssignment,
|
||||
Self::FunctionalExchange,
|
||||
Self::BoundaryExchange,
|
||||
Self::CommunicationMean,
|
||||
Self::MissionCapability,
|
||||
Self::CapabilityInclude,
|
||||
Self::CapabilityExtend,
|
||||
@@ -902,6 +916,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)
|
||||
@@ -953,6 +971,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 => {
|
||||
@@ -963,7 +982,7 @@ impl RelationKind {
|
||||
Self::SequenceLink => "Preceding → following contribution in the same process or chain",
|
||||
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,6 +1005,7 @@ 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",
|
||||
@@ -1074,6 +1094,12 @@ pub enum RelationBody {
|
||||
oriented: bool,
|
||||
items: Vec<Id>,
|
||||
},
|
||||
CommunicationMean {
|
||||
source: Id,
|
||||
target: Id,
|
||||
oriented: bool,
|
||||
items: Vec<Id>,
|
||||
},
|
||||
MissionCapability {
|
||||
source: Id,
|
||||
target: Id,
|
||||
@@ -1160,6 +1186,7 @@ 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,
|
||||
@@ -1187,6 +1214,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, .. }
|
||||
@@ -1214,6 +1242,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 +1264,8 @@ impl RelationBody {
|
||||
match self {
|
||||
Self::Interaction { items, .. }
|
||||
| Self::FunctionalExchange { items, .. }
|
||||
| Self::BoundaryExchange { items, .. } => items,
|
||||
| Self::BoundaryExchange { items, .. }
|
||||
| Self::CommunicationMean { items, .. } => items,
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
@@ -1285,6 +1315,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 {
|
||||
@@ -1387,6 +1423,8 @@ pub struct Diagram {
|
||||
pub description: String,
|
||||
pub layer: Layer,
|
||||
pub projection: ProjectionKind,
|
||||
#[serde(default)]
|
||||
pub view_options: crate::view_options::ViewOptions,
|
||||
#[serde(deserialize_with = "unique_id_map")]
|
||||
pub nodes: BTreeMap<Id, NodePlacement>,
|
||||
#[serde(default)]
|
||||
@@ -1814,6 +1852,7 @@ impl Project {
|
||||
description: String::new(),
|
||||
layer,
|
||||
projection: ProjectionKind::Overview,
|
||||
view_options: crate::view_options::ViewOptions::default(),
|
||||
nodes: BTreeMap::new(),
|
||||
routes: vec![],
|
||||
},
|
||||
|
||||
@@ -139,6 +139,36 @@ 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 <= 5)
|
||||
&& let Some(diagrams) = header
|
||||
.get("diagrams")
|
||||
.and_then(serde_json::Value::as_object)
|
||||
{
|
||||
for diagram in diagrams.values().filter_map(serde_json::Value::as_object) {
|
||||
for key in diagram.keys() {
|
||||
if key == "view_options" {
|
||||
return Err(FileError::Invalid("Diagram.view_options requires schema/source 6, including an explicitly supplied default value.".into()));
|
||||
}
|
||||
if !matches!(
|
||||
key.as_str(),
|
||||
"id" | "alias"
|
||||
| "name"
|
||||
| "description"
|
||||
| "layer"
|
||||
| "projection"
|
||||
| "nodes"
|
||||
| "routes"
|
||||
) {
|
||||
return Err(FileError::Invalid(format!(
|
||||
"Unknown diagram field '{key}' in the frozen pre6 contract."
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
match header
|
||||
.get("schema_version")
|
||||
.and_then(serde_json::Value::as_u64)
|
||||
@@ -151,13 +181,14 @@ pub fn decode_with_report_limited(
|
||||
Some(2) => migrate_v2_limited(serde_json::from_slice(bytes)?, limits)?,
|
||||
Some(3) => v3::migrate(serde_json::from_slice(bytes)?, limits)?,
|
||||
Some(4) => v4::migrate(serde_json::from_slice(bytes)?, limits)?,
|
||||
Some(5) => DecodeResult {
|
||||
Some(5) => migrate_v5(serde_json::from_slice(bytes)?, limits)?,
|
||||
Some(6) => DecodeResult {
|
||||
project: serde_json::from_slice(bytes)?,
|
||||
migration: None,
|
||||
},
|
||||
_ => {
|
||||
return Err(FileError::Invalid(
|
||||
"Unsupported or missing schema_version; expected 1, 2, 3, 4 or 5.".into(),
|
||||
"Unsupported or missing schema_version; expected 1, 2, 3, 4, 5 or 6.".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -186,6 +217,146 @@ 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)),
|
||||
})
|
||||
}
|
||||
|
||||
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,
|
||||
|
||||
@@ -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),
|
||||
..
|
||||
@@ -152,7 +157,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 +196,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
|
||||
{
|
||||
@@ -374,6 +386,7 @@ fn remove_objects(p: &mut Project, removed: BTreeSet<Id>) {
|
||||
p.traces.retain(|id, _| !removed.contains(id));
|
||||
p.artifact_links.retain(|id, _| !removed.contains(id));
|
||||
for d in p.diagrams.values_mut() {
|
||||
d.view_options.retain_references(|id| !removed.contains(id));
|
||||
d.nodes.retain(|id, _| !removed.contains(id));
|
||||
d.routes.retain(|route| !removed.contains(&route.edge));
|
||||
for node in d.nodes.values_mut() {
|
||||
@@ -417,7 +430,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 {
|
||||
@@ -487,7 +504,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
|
||||
|
||||
@@ -191,6 +191,7 @@ fn path(own: &Own, facet: &FacetSelector) -> Option<Vec<String>> {
|
||||
F::State(StateFacet::Exit) => ("State", &["exit"]),
|
||||
F::State(StateFacet::DoActivity) => ("State", &["do_activity"]),
|
||||
F::Constraint(ConstraintFacet::Expression) => ("Constraint", &["expression"]),
|
||||
F::Category(CategoryFacet::Family) => ("ExchangeCategory", &["family"]),
|
||||
F::Relation(RelationFacet::Source) => ("*", &["source"]),
|
||||
F::Relation(RelationFacet::Target) => ("*", &["target"]),
|
||||
F::Relation(RelationFacet::AllocationOrder) => ("InterfaceItemAllocation", &["order"]),
|
||||
@@ -206,7 +207,7 @@ fn path(own: &Own, facet: &FacetSelector) -> Option<Vec<String>> {
|
||||
F::Relation(RelationFacet::TargetNavigable) => ("DataAssociation", &["target_navigable"]),
|
||||
F::Relation(RelationFacet::SourcePort) => ("FunctionalExchange", &["source_port"]),
|
||||
F::Relation(RelationFacet::TargetPort) => ("FunctionalExchange", &["target_port"]),
|
||||
F::Relation(RelationFacet::Oriented) => ("BoundaryExchange", &["oriented"]),
|
||||
F::Relation(RelationFacet::Oriented) => ("*", &["oriented"]),
|
||||
F::Relation(RelationFacet::ExtendCondition) => ("CapabilityExtend", &["condition"]),
|
||||
F::Relation(RelationFacet::SequenceGuard) => ("SequenceLink", &["guard"]),
|
||||
F::Relation(RelationFacet::TransitionKind) => ("StateTransition", &["transition_kind"]),
|
||||
@@ -311,7 +312,8 @@ pub(crate) fn collection(project: &Project, id: Id, which: ReferenceCollection)
|
||||
Some(
|
||||
RelationBody::Interaction { items, .. }
|
||||
| RelationBody::FunctionalExchange { items, .. }
|
||||
| RelationBody::BoundaryExchange { items, .. },
|
||||
| RelationBody::BoundaryExchange { items, .. }
|
||||
| RelationBody::CommunicationMean { items, .. },
|
||||
) => Some(items.clone()),
|
||||
_ => None,
|
||||
},
|
||||
@@ -327,6 +329,10 @@ pub(crate) fn collection(project: &Project, id: Id, which: ReferenceCollection)
|
||||
Some(SharedBody::Constraint { constrained, .. }) => Some(constrained.clone()),
|
||||
_ => None,
|
||||
},
|
||||
C::CategoryExchanges => match shared {
|
||||
Some(SharedBody::ExchangeCategory { exchanges, .. }) => Some(exchanges.clone()),
|
||||
_ => None,
|
||||
},
|
||||
}
|
||||
}
|
||||
pub(crate) fn order_collection(which: OrderedCollection) -> ReferenceCollection {
|
||||
@@ -575,6 +581,7 @@ fn catalog_facets(
|
||||
ReferenceCollection::FunctionOutputItems,
|
||||
ReferenceCollection::SignalItems,
|
||||
ReferenceCollection::ExchangeItems,
|
||||
ReferenceCollection::CategoryExchanges,
|
||||
ReferenceCollection::TransitionTriggers,
|
||||
ReferenceCollection::TransitionEffects,
|
||||
ReferenceCollection::Constrained,
|
||||
@@ -1036,6 +1043,11 @@ mod coverage {
|
||||
p
|
||||
};
|
||||
for (kind, parts, which) in [
|
||||
(
|
||||
"ExchangeCategory",
|
||||
vec!["exchanges"],
|
||||
ReferenceCollection::CategoryExchanges,
|
||||
),
|
||||
("Process", vec!["steps"], ReferenceCollection::Steps),
|
||||
("FunctionalChain", vec!["steps"], ReferenceCollection::Steps),
|
||||
("Scenario", vec!["steps"], ReferenceCollection::Steps),
|
||||
@@ -1079,6 +1091,11 @@ mod coverage {
|
||||
vec!["items"],
|
||||
ReferenceCollection::ExchangeItems,
|
||||
),
|
||||
(
|
||||
"CommunicationMean",
|
||||
vec!["items"],
|
||||
ReferenceCollection::ExchangeItems,
|
||||
),
|
||||
(
|
||||
"StateTransition",
|
||||
vec!["triggers"],
|
||||
|
||||
@@ -73,6 +73,7 @@ macro_rules! enums {
|
||||
} )* };
|
||||
}
|
||||
enums!(
|
||||
crate::view_options::ExchangeFamily,
|
||||
ExchangeItemKind,
|
||||
ExchangeMechanism,
|
||||
ParameterDirection,
|
||||
|
||||
@@ -63,7 +63,8 @@ pub(crate) fn set_collection(
|
||||
Some(
|
||||
RelationBody::Interaction { items, .. }
|
||||
| RelationBody::FunctionalExchange { items, .. }
|
||||
| RelationBody::BoundaryExchange { items, .. },
|
||||
| RelationBody::BoundaryExchange { items, .. }
|
||||
| RelationBody::CommunicationMean { items, .. },
|
||||
) => Some(items),
|
||||
_ => None,
|
||||
},
|
||||
@@ -79,6 +80,10 @@ pub(crate) fn set_collection(
|
||||
Some(SharedBody::Constraint { constrained, .. }) => Some(constrained),
|
||||
_ => None,
|
||||
},
|
||||
C::CategoryExchanges => match p.elements.get_mut(&id).and_then(Element::shared_mut) {
|
||||
Some(SharedBody::ExchangeCategory { exchanges, .. }) => Some(exchanges),
|
||||
_ => None,
|
||||
},
|
||||
}
|
||||
.ok_or("Collection is inapplicable on target")?;
|
||||
*slot = list;
|
||||
|
||||
@@ -795,7 +795,18 @@ fn build_review(
|
||||
current.clone()
|
||||
};
|
||||
if resolution.decision == SemanticDecision::TakeSource {
|
||||
fact = remap_fact(&fact, &map, source_layer)?;
|
||||
fact = if let Some(adapted) = transitions::review_endpoint_fact(
|
||||
initial,
|
||||
&plan.request.transition,
|
||||
&map,
|
||||
request.source,
|
||||
target,
|
||||
&fact,
|
||||
)? {
|
||||
adapted
|
||||
} else {
|
||||
remap_fact(&fact, &map, source_layer)?
|
||||
};
|
||||
}
|
||||
// A Keep reads the frozen target, never a value written by an earlier source.
|
||||
if resolution.decision != SemanticDecision::KeepTarget {
|
||||
|
||||
@@ -86,6 +86,7 @@ macro_rules! scalar_families {
|
||||
ExchangeItem, ExchangeItemFacet, ExchangeItemReview {
|
||||
Nature: ExchangeItemKind, Mechanism: ExchangeMechanism, Supertype: Option<Id>
|
||||
}
|
||||
Category, CategoryFacet, CategoryReview { Family: crate::view_options::ExchangeFamily }
|
||||
Interface, InterfaceFacet, InterfaceReview { Supertype: Option<Id> }
|
||||
Data, DataFacet, DataReview {
|
||||
Constructor: DataConstructor,
|
||||
@@ -197,6 +198,7 @@ pub enum ReferenceCollection {
|
||||
FunctionOutputItems,
|
||||
SignalItems,
|
||||
ExchangeItems,
|
||||
CategoryExchanges,
|
||||
TransitionTriggers,
|
||||
TransitionEffects,
|
||||
Constrained,
|
||||
|
||||
@@ -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,6 +1206,342 @@ fn recover_with_hook(
|
||||
mod transaction_tests {
|
||||
use super::*;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn linux_journal_publication_preserves_competing_destinations_and_our_stage() {
|
||||
use std::os::unix::{fs::MetadataExt, fs::symlink};
|
||||
|
||||
for kind in ["empty directory", "nonempty directory", "file", "symlink"] {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let stage = directory.path().join("stage");
|
||||
let journal = directory.path().join("journal");
|
||||
fs::create_dir(&stage).unwrap();
|
||||
fs::write(stage.join("journal.json"), b"our complete metadata").unwrap();
|
||||
// Simulate a competitor arriving after the caller's absence check.
|
||||
assert!(!journal.exists());
|
||||
match kind {
|
||||
"empty directory" => fs::create_dir(&journal).unwrap(),
|
||||
"nonempty directory" => {
|
||||
fs::create_dir(&journal).unwrap();
|
||||
fs::write(journal.join("journal.json"), b"competitor metadata").unwrap();
|
||||
}
|
||||
"file" => fs::write(&journal, b"competitor file").unwrap(),
|
||||
"symlink" => symlink("absent competitor target", &journal).unwrap(),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
let before = fs::symlink_metadata(&journal).unwrap();
|
||||
let error = publish_journal(&stage, &journal).unwrap_err();
|
||||
assert!(
|
||||
matches!(error, SourceFileError::Io { path, source }
|
||||
if path == journal && source.raw_os_error() == Some(libc::EEXIST)),
|
||||
"{kind} must refuse publication"
|
||||
);
|
||||
let after = fs::symlink_metadata(&journal).unwrap();
|
||||
assert_eq!((before.dev(), before.ino()), (after.dev(), after.ino()));
|
||||
assert_eq!(
|
||||
fs::read(stage.join("journal.json")).unwrap(),
|
||||
b"our complete metadata"
|
||||
);
|
||||
match kind {
|
||||
"empty directory" => assert_eq!(fs::read_dir(&journal).unwrap().count(), 0),
|
||||
"nonempty directory" => assert_eq!(
|
||||
fs::read(journal.join("journal.json")).unwrap(),
|
||||
b"competitor metadata"
|
||||
),
|
||||
"file" => assert_eq!(fs::read(&journal).unwrap(), b"competitor file"),
|
||||
"symlink" => assert_eq!(
|
||||
fs::read_link(&journal).unwrap(),
|
||||
Path::new("absent competitor target")
|
||||
),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn linux_journal_publication_preserves_raw_path_bytes_and_rejects_nul() {
|
||||
use std::{ffi::OsStr, os::unix::ffi::OsStrExt};
|
||||
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let stage = directory.path().join(OsStr::from_bytes(b"stage-\xff"));
|
||||
let journal = directory.path().join(OsStr::from_bytes(b"journal-\xfe"));
|
||||
fs::create_dir(&stage).unwrap();
|
||||
fs::write(stage.join("journal.json"), b"complete metadata").unwrap();
|
||||
for (a, b) in [
|
||||
(Path::new("bad\0path"), journal.as_path()),
|
||||
(stage.as_path(), Path::new("bad\0path")),
|
||||
] {
|
||||
let error = rename_journal_without_replacement(a, b).unwrap_err();
|
||||
assert_eq!(error.kind(), io::ErrorKind::InvalidInput);
|
||||
assert!(stage.join("journal.json").exists());
|
||||
assert!(!journal.exists());
|
||||
}
|
||||
publish_journal(&stage, &journal).unwrap();
|
||||
assert!(!stage.exists());
|
||||
assert_eq!(
|
||||
fs::read(journal.join("journal.json")).unwrap(),
|
||||
b"complete metadata"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn linux_journal_publication_has_one_winner_for_concurrent_stages() {
|
||||
use std::sync::Barrier;
|
||||
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let journal = directory.path().join("journal");
|
||||
let stages = [
|
||||
directory.path().join("stage-a"),
|
||||
directory.path().join("stage-b"),
|
||||
];
|
||||
for (i, stage) in stages.iter().enumerate() {
|
||||
fs::create_dir(stage).unwrap();
|
||||
fs::write(stage.join("journal.json"), format!("complete writer {i}")).unwrap();
|
||||
}
|
||||
let barrier = Barrier::new(2);
|
||||
let results = std::thread::scope(|scope| {
|
||||
let journal = &journal;
|
||||
let barrier = &barrier;
|
||||
let handles: Vec<_> = stages
|
||||
.iter()
|
||||
.map(|stage| {
|
||||
scope.spawn(move || {
|
||||
assert!(!journal.exists());
|
||||
barrier.wait();
|
||||
publish_journal(stage, journal)
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
handles
|
||||
.into_iter()
|
||||
.map(|handle| handle.join().unwrap())
|
||||
.collect::<Vec<_>>()
|
||||
});
|
||||
assert_eq!(results.iter().filter(|result| result.is_ok()).count(), 1);
|
||||
for (i, result) in results.into_iter().enumerate() {
|
||||
match result {
|
||||
Ok(()) => {
|
||||
assert!(!stages[i].exists());
|
||||
assert_eq!(
|
||||
fs::read_to_string(journal.join("journal.json")).unwrap(),
|
||||
format!("complete writer {i}")
|
||||
);
|
||||
}
|
||||
Err(SourceFileError::Io { path, source }) => {
|
||||
assert_eq!(path, journal);
|
||||
assert_eq!(source.raw_os_error(), Some(libc::EEXIST));
|
||||
assert_eq!(
|
||||
fs::read_to_string(stages[i].join("journal.json")).unwrap(),
|
||||
format!("complete writer {i}")
|
||||
);
|
||||
}
|
||||
Err(error) => panic!("unexpected publication error: {error}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
#[ignore = "requires strace renameat2 error injection and OA_TEST_RENAMEAT2_ERRNO"]
|
||||
fn linux_journal_publication_injected_unsupported_error_is_fail_closed() {
|
||||
let errno: i32 = std::env::var("OA_TEST_RENAMEAT2_ERRNO")
|
||||
.expect("set OA_TEST_RENAMEAT2_ERRNO to the injected Linux errno")
|
||||
.parse()
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
errno,
|
||||
libc::ENOSYS | libc::EINVAL | libc::EOPNOTSUPP
|
||||
));
|
||||
let (directory, snapshot, compiled) = fixture();
|
||||
let journal = journal_path(&snapshot.root, &snapshot.bundle.entry);
|
||||
let original: Vec<_> = snapshot
|
||||
.bundle
|
||||
.files
|
||||
.keys()
|
||||
.map(|path| (path.clone(), fs::read(directory.path().join(path)).unwrap()))
|
||||
.collect();
|
||||
let error = save_with_phases(&snapshot, &compiled, ProjectLimits::default(), |phase| {
|
||||
if matches!(
|
||||
phase,
|
||||
SavePhase::AfterPublish | SavePhase::AfterWriteMarker | SavePhase::AfterReplace(_)
|
||||
) {
|
||||
panic!("unsupported no-replace publication must stop before ownership transfer");
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.unwrap_err();
|
||||
assert!(matches!(error, SourceFileError::Io { path, source }
|
||||
if path == journal && source.raw_os_error() == Some(errno)));
|
||||
assert!(!journal.exists());
|
||||
for (path, bytes) in original {
|
||||
assert_eq!(fs::read(directory.path().join(path)).unwrap(), bytes);
|
||||
}
|
||||
check_conflicts(&snapshot.root, &snapshot.expectations).unwrap();
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn linux_journal_publication_refusal_never_starts_model_writes() {
|
||||
let (directory, snapshot, compiled) = fixture();
|
||||
let journal = journal_path(&snapshot.root, &snapshot.bundle.entry);
|
||||
let original: Vec<_> = snapshot
|
||||
.bundle
|
||||
.files
|
||||
.keys()
|
||||
.map(|path| (path.clone(), fs::read(directory.path().join(path)).unwrap()))
|
||||
.collect();
|
||||
let error = save_with_phases(&snapshot, &compiled, ProjectLimits::default(), |phase| {
|
||||
match phase {
|
||||
SavePhase::BeforePublish => fs::create_dir(&journal).unwrap(),
|
||||
SavePhase::AfterPublish
|
||||
| SavePhase::AfterWriteMarker
|
||||
| SavePhase::AfterReplace(_) => {
|
||||
panic!("a rejected publisher must not obtain ownership or write source files")
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.unwrap_err();
|
||||
assert!(matches!(error, SourceFileError::Io { path, source }
|
||||
if path == journal && source.raw_os_error() == Some(libc::EEXIST)));
|
||||
assert_eq!(fs::read_dir(&journal).unwrap().count(), 0);
|
||||
assert!(!journal.join(WRITE_STARTED).exists());
|
||||
for (path, bytes) in original {
|
||||
assert_eq!(fs::read(directory.path().join(path)).unwrap(), bytes);
|
||||
}
|
||||
check_conflicts(&snapshot.root, &snapshot.expectations).unwrap();
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn journal_publication_does_not_replace_empty_target_created_after_precheck() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let stage = directory.path().join("stage");
|
||||
let journal = directory.path().join("journal");
|
||||
fs::create_dir(&stage).unwrap();
|
||||
fs::write(stage.join("journal.json"), b"our journal").unwrap();
|
||||
let mut called = false;
|
||||
let result = publish_journal_with_retry(
|
||||
&stage,
|
||||
&journal,
|
||||
|a, b| {
|
||||
assert!(!called);
|
||||
called = true;
|
||||
fs::create_dir(b).unwrap();
|
||||
rename_journal_without_replacement(a, b)
|
||||
},
|
||||
|_| {},
|
||||
);
|
||||
assert!(result.is_err());
|
||||
assert!(called);
|
||||
assert_eq!(
|
||||
fs::read(stage.join("journal.json")).unwrap(),
|
||||
b"our journal"
|
||||
);
|
||||
assert_eq!(fs::read_dir(&journal).unwrap().count(), 0);
|
||||
assert!(
|
||||
matches!(rename_journal_without_replacement(Path::new("bad\0path"), &journal), Err(error) if error.kind() == io::ErrorKind::InvalidInput)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn journal_publication_retries_only_transient_windows_errors_before_transferring_ownership() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let stage = directory.path().join("stage");
|
||||
let journal = directory.path().join("journal");
|
||||
fs::create_dir(&stage).unwrap();
|
||||
fs::write(stage.join("journal.json"), b"complete staged metadata").unwrap();
|
||||
let mut errors = [5, 32, 33].into_iter();
|
||||
let mut waits = vec![];
|
||||
publish_journal_with_retry(
|
||||
&stage,
|
||||
&journal,
|
||||
|a, b| {
|
||||
assert!(a.join("journal.json").exists());
|
||||
assert!(!b.exists());
|
||||
match errors.next() {
|
||||
Some(code) => Err(io::Error::from_raw_os_error(code)),
|
||||
None => fs::rename(a, b),
|
||||
}
|
||||
},
|
||||
|delay| waits.push(delay.as_millis()),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(waits, [5, 10, 25]);
|
||||
assert!(!stage.exists());
|
||||
assert_eq!(
|
||||
fs::read(journal.join("journal.json")).unwrap(),
|
||||
b"complete staged metadata"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn journal_publication_exhaustion_and_permanent_errors_keep_stage_and_report_original_path() {
|
||||
for (code, expected_calls, expected_wait) in [(5, 7, 390), (123, 1, 0)] {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let stage = directory.path().join("stage");
|
||||
let journal = directory.path().join("journal");
|
||||
fs::create_dir(&stage).unwrap();
|
||||
fs::write(stage.join("journal.json"), b"before publication").unwrap();
|
||||
let (mut calls, mut waited) = (0, 0);
|
||||
let error = publish_journal_with_retry(
|
||||
&stage,
|
||||
&journal,
|
||||
|_, _| {
|
||||
calls += 1;
|
||||
Err(io::Error::from_raw_os_error(code))
|
||||
},
|
||||
|delay| waited += delay.as_millis(),
|
||||
)
|
||||
.unwrap_err();
|
||||
assert_eq!((calls, waited), (expected_calls, expected_wait));
|
||||
assert!(
|
||||
matches!(error, SourceFileError::Io { path, source } if path == journal && source.raw_os_error() == Some(code))
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read(stage.join("journal.json")).unwrap(),
|
||||
b"before publication"
|
||||
);
|
||||
assert!(!journal.exists());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn journal_publication_never_replaces_another_journal_that_appears_during_retry() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let stage = directory.path().join("stage");
|
||||
let journal = directory.path().join("journal");
|
||||
fs::create_dir(&stage).unwrap();
|
||||
fs::write(stage.join("journal.json"), b"our staged journal").unwrap();
|
||||
let mut calls = 0;
|
||||
let error = publish_journal_with_retry(
|
||||
&stage,
|
||||
&journal,
|
||||
|_, _| {
|
||||
calls += 1;
|
||||
Err(io::Error::from_raw_os_error(32))
|
||||
},
|
||||
|_| {
|
||||
fs::create_dir(&journal).unwrap();
|
||||
fs::write(journal.join("journal.json"), b"other save owns this").unwrap();
|
||||
},
|
||||
)
|
||||
.unwrap_err();
|
||||
assert_eq!(calls, 1);
|
||||
assert!(matches!(error, SourceFileError::RecoveryRequired(path) if path == journal));
|
||||
assert_eq!(
|
||||
fs::read(journal.join("journal.json")).unwrap(),
|
||||
b"other save owns this"
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read(stage.join("journal.json")).unwrap(),
|
||||
b"our staged journal"
|
||||
);
|
||||
}
|
||||
|
||||
fn fixture() -> (tempfile::TempDir, DiskProject, CompiledSourceProject) {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
fs::write(
|
||||
@@ -1014,6 +1561,160 @@ mod transaction_tests {
|
||||
(directory, snapshot, compiled)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unwritten_journal_never_rolls_back_a_completed_writer_with_identical_after_bytes() {
|
||||
for interrupt_retirement in [false, true] {
|
||||
let (directory, snapshot, compiled) = fixture();
|
||||
let error = save_with_phases(&snapshot, &compiled, ProjectLimits::default(), |phase| {
|
||||
match phase {
|
||||
SavePhase::BeforePublish => {
|
||||
// A competing save finishes during our publication wait.
|
||||
save_project(&snapshot, &compiled, ProjectLimits::default()).unwrap();
|
||||
}
|
||||
SavePhase::BeforeRetire if interrupt_retirement => {
|
||||
return Err(io::Error::other("injected no-write retirement failure"));
|
||||
}
|
||||
SavePhase::AfterWriteMarker | SavePhase::AfterReplace(_) => {
|
||||
panic!("losing writer must not start writes")
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.unwrap_err();
|
||||
assert!(matches!(
|
||||
error,
|
||||
SourceFileError::Conflict(_) | SourceFileError::RecoveryFailed { .. }
|
||||
));
|
||||
for (path, bytes) in &compiled.bundle.files {
|
||||
assert_eq!(
|
||||
fs::read_to_string(directory.path().join(path)).unwrap(),
|
||||
*bytes
|
||||
);
|
||||
}
|
||||
let journal = journal_path(&snapshot.root, &snapshot.bundle.entry);
|
||||
assert_eq!(journal.exists(), interrupt_retirement);
|
||||
if interrupt_retirement {
|
||||
assert!(!journal.join(WRITE_STARTED).exists());
|
||||
// Explicit recovery after a failed retirement must also leave
|
||||
// the completed other writer untouched, even matching after bytes.
|
||||
assert!(
|
||||
recover_project(
|
||||
&directory.path().join("project.arcadia"),
|
||||
ProjectLimits::default()
|
||||
)
|
||||
.unwrap()
|
||||
);
|
||||
for (path, bytes) in &compiled.bundle.files {
|
||||
assert_eq!(
|
||||
fs::read_to_string(directory.path().join(path)).unwrap(),
|
||||
*bytes
|
||||
);
|
||||
}
|
||||
}
|
||||
assert!(!journal.exists());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recovery_before_any_write_accepts_v2_marker_and_keeps_v1_rollback_compatibility() {
|
||||
for version in [1, 2] {
|
||||
let (directory, snapshot, compiled) = fixture();
|
||||
let interrupted = std::panic::catch_unwind(|| {
|
||||
let _ = save_with_phases(&snapshot, &compiled, ProjectLimits::default(), |phase| {
|
||||
if matches!(phase, SavePhase::AfterWriteMarker) {
|
||||
panic!("interrupted before atomic write");
|
||||
}
|
||||
Ok(())
|
||||
});
|
||||
});
|
||||
assert!(interrupted.is_err());
|
||||
check_conflicts(&snapshot.root, &snapshot.expectations).unwrap();
|
||||
let journal = journal_path(&snapshot.root, &snapshot.bundle.entry);
|
||||
assert_eq!(
|
||||
fs::read(journal.join(WRITE_STARTED)).unwrap(),
|
||||
WRITE_STARTED_BYTES
|
||||
);
|
||||
if version == 1 {
|
||||
// A frozen v1 journal has no marker and means writes may have begun.
|
||||
let path = journal.join("journal.json");
|
||||
let mut metadata: Journal =
|
||||
serde_json::from_slice(&fs::read(&path).unwrap()).unwrap();
|
||||
metadata.version = 1;
|
||||
fs::write(path, serde_json::to_vec(&metadata).unwrap()).unwrap();
|
||||
fs::remove_file(journal.join(WRITE_STARTED)).unwrap();
|
||||
for (path, bytes) in &compiled.bundle.files {
|
||||
fs::write(directory.path().join(path), bytes).unwrap();
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
recover_project(
|
||||
&directory.path().join("project.arcadia"),
|
||||
ProjectLimits::default()
|
||||
)
|
||||
.unwrap()
|
||||
);
|
||||
check_conflicts(&snapshot.root, &snapshot.expectations).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interrupted_publication_without_write_marker_never_rolls_back_external_matching_bytes() {
|
||||
let (directory, snapshot, compiled) = fixture();
|
||||
let interrupted = std::panic::catch_unwind(|| {
|
||||
let _ = save_with_phases(&snapshot, &compiled, ProjectLimits::default(), |phase| {
|
||||
if matches!(phase, SavePhase::AfterPublish) {
|
||||
panic!("interrupted before write marker");
|
||||
}
|
||||
Ok(())
|
||||
});
|
||||
});
|
||||
assert!(interrupted.is_err());
|
||||
let journal = journal_path(&snapshot.root, &snapshot.bundle.entry);
|
||||
assert!(!journal.join(WRITE_STARTED).exists());
|
||||
for (path, bytes) in &compiled.bundle.files {
|
||||
fs::write(directory.path().join(path), bytes).unwrap();
|
||||
}
|
||||
assert!(
|
||||
recover_project(
|
||||
&directory.path().join("project.arcadia"),
|
||||
ProjectLimits::default()
|
||||
)
|
||||
.unwrap()
|
||||
);
|
||||
for (path, bytes) in &compiled.bundle.files {
|
||||
assert_eq!(
|
||||
fs::read_to_string(directory.path().join(path)).unwrap(),
|
||||
*bytes
|
||||
);
|
||||
}
|
||||
assert!(!journal.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_v2_write_marker_refuses_recovery_without_changing_source_bytes() {
|
||||
let (directory, snapshot, compiled) = fixture();
|
||||
let _ = std::panic::catch_unwind(|| {
|
||||
let _ = save_with_phases(&snapshot, &compiled, ProjectLimits::default(), |phase| {
|
||||
if matches!(phase, SavePhase::AfterWriteMarker) {
|
||||
panic!("interrupted");
|
||||
}
|
||||
Ok(())
|
||||
});
|
||||
});
|
||||
let journal = journal_path(&snapshot.root, &snapshot.bundle.entry);
|
||||
fs::write(journal.join(WRITE_STARTED), b"partial").unwrap();
|
||||
assert!(matches!(
|
||||
recover_project(
|
||||
&directory.path().join("project.arcadia"),
|
||||
ProjectLimits::default()
|
||||
),
|
||||
Err(SourceFileError::Invalid(_))
|
||||
));
|
||||
check_conflicts(&snapshot.root, &snapshot.expectations).unwrap();
|
||||
assert!(journal.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_save_failures_restore_every_original_and_write_entry_last() {
|
||||
for stop in 1..=3 {
|
||||
|
||||
@@ -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,30 @@ 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 TransitionRequest {
|
||||
@@ -63,6 +89,8 @@ pub struct TransitionRequest {
|
||||
pub fields: Vec<TransitionField>,
|
||||
pub resolutions: Vec<FieldResolution>,
|
||||
pub destination_diagram: Option<Id>,
|
||||
#[serde(default)]
|
||||
pub communication_endpoints: Vec<CommunicationEndpointDecision>,
|
||||
}
|
||||
impl TransitionRequest {
|
||||
pub fn new(direction: TransitionDirection, sources: Vec<Id>) -> Self {
|
||||
@@ -74,6 +102,7 @@ impl TransitionRequest {
|
||||
fields: vec![TransitionField::Name, TransitionField::Description],
|
||||
resolutions: vec![],
|
||||
destination_diagram: None,
|
||||
communication_endpoints: vec![],
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -177,6 +206,7 @@ fn inferred_kind(kind: Kind, direction: TransitionDirection) -> Option<Kind> {
|
||||
|| matches!(
|
||||
k,
|
||||
Kind::ExchangeItem
|
||||
| Kind::ExchangeCategory
|
||||
| Kind::DataType
|
||||
| Kind::DataField
|
||||
| Kind::Interface
|
||||
@@ -255,6 +285,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 { .. }
|
||||
@@ -318,7 +370,7 @@ fn trace_kind(p: &Project, oa: Id, sa: Id) -> TraceKind {
|
||||
_ => TraceKind::Manual,
|
||||
},
|
||||
_ => {
|
||||
if matches!((p.relations.get(&oa),p.relations.get(&sa)),(Some(a),Some(b))if a.kind()==RelationKind::Interaction&&b.kind()==RelationKind::FunctionalExchange)
|
||||
if matches!((p.relations.get(&oa),p.relations.get(&sa)),(Some(a),Some(b))if exchange_counterparts(a.kind(), b.kind()))
|
||||
{
|
||||
TraceKind::Exchange
|
||||
} else {
|
||||
@@ -349,11 +401,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))
|
||||
@@ -499,6 +552,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,
|
||||
@@ -695,6 +757,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))
|
||||
@@ -764,6 +828,25 @@ pub fn plan_transition(
|
||||
let mut created = r.clone();
|
||||
created.id = target;
|
||||
created.body = body;
|
||||
// SA technical ports project through their reviewed owners; never
|
||||
// place a port→participant pair in the global correspondence map.
|
||||
if request.direction == TransitionDirection::SaToOa
|
||||
&& r.kind() == RelationKind::BoundaryExchange
|
||||
{
|
||||
let owners = [r.source(), r.target()]
|
||||
.map(|port| project.elements.get(&port).and_then(|e| e.parent));
|
||||
if let [Some(a), Some(b)] = owners {
|
||||
created.body.set_endpoints(a, b);
|
||||
} else {
|
||||
conflict(
|
||||
&mut plan,
|
||||
Some(*source),
|
||||
Some(target),
|
||||
None,
|
||||
"Boundary endpoints need participant owners before communication transition.",
|
||||
);
|
||||
}
|
||||
}
|
||||
created.alias = working.fresh_alias(
|
||||
&format!(
|
||||
"{}_{}",
|
||||
@@ -778,6 +861,21 @@ pub fn plan_transition(
|
||||
*id = *mapped;
|
||||
}
|
||||
});
|
||||
if request.direction == TransitionDirection::OaToSa
|
||||
&& r.kind() == RelationKind::CommunicationMean
|
||||
{
|
||||
if let Some((a, b)) = communication_pairs.get(source) {
|
||||
created.body.set_endpoints(*a, *b);
|
||||
} else {
|
||||
conflict(
|
||||
&mut plan,
|
||||
Some(*source),
|
||||
Some(target),
|
||||
None,
|
||||
"Choose an explicit SA component-port pair for this communication means; participant mappings cannot stand in for ports.",
|
||||
);
|
||||
}
|
||||
}
|
||||
for dependency in crate::references::relation_references(&created) {
|
||||
if dependency.is_nil()
|
||||
&& request.direction == TransitionDirection::OaToSa
|
||||
@@ -1139,9 +1237,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))
|
||||
}
|
||||
@@ -387,6 +387,23 @@ pub fn validate(p: &Project) -> Vec<Issue> {
|
||||
}
|
||||
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 { .. }
|
||||
@@ -601,6 +618,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
|
||||
@@ -1256,40 +1281,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.
|
||||
@@ -1442,7 +1576,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(),
|
||||
@@ -1922,6 +2058,7 @@ fn validate_occurrence(p: &Project, e: &Element, body: &OccurrenceBody, out: &mu
|
||||
RelationKind::Interaction
|
||||
| RelationKind::FunctionalExchange
|
||||
| RelationKind::BoundaryExchange
|
||||
| RelationKind::CommunicationMean
|
||||
) => {}
|
||||
_ => error(
|
||||
out,
|
||||
@@ -2136,18 +2273,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);
|
||||
@@ -2398,7 +2537,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()))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2739,6 +2878,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");
|
||||
|
||||
@@ -138,119 +138,10 @@ fn work_exchange(kind: RelationKind) -> bool {
|
||||
/// 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 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);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let crate::view_options::BehaviorEvidence { work, exchanges } =
|
||||
crate::view_options::all_behavior_evidence(p);
|
||||
(work, exchanges)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -0,0 +1,711 @@
|
||||
//! 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::{Id, Kind, Layer, OccurrenceBody, Project, 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);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
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,620 @@
|
||||
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 {
|
||||
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,248 @@
|
||||
//! 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 {
|
||||
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,515 @@
|
||||
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 {
|
||||
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("arcadia 6;"));
|
||||
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("arcadia 6;", &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("arcadia 6;", "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;", "\n6;", 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())));
|
||||
}
|
||||
@@ -339,6 +339,30 @@ 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],
|
||||
};
|
||||
p
|
||||
}
|
||||
|
||||
@@ -491,7 +515,7 @@ fn authoring_defaults_and_forward_references_remain_strict() {
|
||||
#[test]
|
||||
fn typed_unknown_fields_enums_duplicates_and_reference_spelling_are_rejected() {
|
||||
for (source, expected) in [
|
||||
("arcadia 6; project mission \"x\" {}", "Unsupported schema"),
|
||||
("arcadia 7; project mission \"x\" {}", "Unsupported schema"),
|
||||
(
|
||||
"arcadia 2; project mission \"x\" { oa_activity a \"A\" { nonsense 1; } }",
|
||||
"Unknown field",
|
||||
@@ -795,7 +819,7 @@ fn original_v2_text_and_json_preserve_identity_and_mixed_scenarios_after_extensi
|
||||
assert_eq!(from_source, from_json);
|
||||
let original: serde_json::Value = serde_json::from_slice(old_json).unwrap();
|
||||
let mut retained = serde_json::to_value(&from_json).unwrap();
|
||||
assert_eq!(retained["schema_version"], 5);
|
||||
assert_eq!(retained["schema_version"], oa_core::SCHEMA_VERSION);
|
||||
retained["schema_version"] = 2.into();
|
||||
let allocations: Vec<_> = from_json
|
||||
.relations
|
||||
@@ -904,6 +928,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 +1020,7 @@ fn v1_text_and_json_migrate_deterministically_without_losing_legacy_semantics()
|
||||
assert_eq!(from_text, from_json);
|
||||
assert_eq!(read(text), from_text);
|
||||
assert_eq!(persistence::decode(json).unwrap(), from_json);
|
||||
assert_eq!(from_text.schema_version, 5);
|
||||
assert_eq!(from_text.schema_version, oa_core::SCHEMA_VERSION);
|
||||
assert!(!from_text.elements.values().any(|e| e.layer() == Layer::Sa));
|
||||
let raw: serde_json::Value = serde_json::from_slice(json).unwrap();
|
||||
for (id, record) in raw["elements"].as_object().unwrap() {
|
||||
@@ -1193,7 +1222,7 @@ fn complete_normative_examples_are_accepted_by_the_real_parser() {
|
||||
if code.trim_start().starts_with("arcadia 2;")
|
||||
|| code.trim_start().starts_with("arcadia 3;")
|
||||
|| code.trim_start().starts_with("arcadia 4;")
|
||||
|| code.trim_start().starts_with("arcadia 5;")
|
||||
|| code.trim_start().starts_with("arcadia 6;")
|
||||
{
|
||||
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, 5));
|
||||
assert_eq!(
|
||||
(report.from_schema, report.to_schema),
|
||||
(2, oa_core::SCHEMA_VERSION)
|
||||
);
|
||||
assert_eq!(report.preserved_ports.len(), 4);
|
||||
assert!(report.generated_endpoints.is_empty());
|
||||
assert_eq!(report.orientation_defaults.len(), 1);
|
||||
@@ -60,6 +63,11 @@ fn frozen_original_v2_reader_preserves_every_original_record() {
|
||||
);
|
||||
let mut current = serde_json::to_value(&decoded.project).unwrap();
|
||||
for diagram in current["diagrams"].as_object_mut().unwrap().values_mut() {
|
||||
assert_eq!(
|
||||
diagram["view_options"],
|
||||
serde_json::to_value(oa_core::view_options::ViewOptions::default()).unwrap()
|
||||
);
|
||||
diagram.as_object_mut().unwrap().remove("view_options");
|
||||
assert_eq!(diagram["routes"], json!([]), "V2 adds no authored routing");
|
||||
diagram.as_object_mut().unwrap().remove("routes");
|
||||
}
|
||||
@@ -144,7 +152,7 @@ fn missing_v2_endpoints_are_deterministic_reported_and_independently_typed() {
|
||||
}
|
||||
}
|
||||
let canonical = language::format(&first.project);
|
||||
assert!(canonical.starts_with("arcadia 5;"));
|
||||
assert!(canonical.starts_with("arcadia 6;"));
|
||||
let reopened = persistence::decode_with_report(canonical.as_bytes()).unwrap();
|
||||
assert!(reopened.migration.is_none());
|
||||
assert_eq!(reopened.project, first.project);
|
||||
@@ -274,7 +282,7 @@ fn old_contract_is_validated_before_new_shapes_and_v1_flows_through_v2() {
|
||||
assert!(error.contains("V2") && error.contains(&fe), "{error}");
|
||||
let v1 = include_bytes!("../../../examples/legacy-v1-coastal-rescue.oa.json");
|
||||
let result = persistence::decode_with_report(v1).unwrap();
|
||||
assert_eq!(result.project.schema_version, 5);
|
||||
assert_eq!(result.project.schema_version, oa_core::SCHEMA_VERSION);
|
||||
assert_eq!(result.migration.unwrap().from_schema, 1);
|
||||
assert_eq!(persistence::decode(v1).unwrap(), result.project);
|
||||
}
|
||||
|
||||
@@ -30,7 +30,10 @@ project typed_ports "Typed ports" {
|
||||
fn source4_covers_typed_contracts_roles_allocations_and_nested_legacy_references() {
|
||||
let decoded = language::parse_with_report(SOURCE).unwrap();
|
||||
let migration = decoded.migration.as_ref().unwrap();
|
||||
assert_eq!((migration.from_schema, migration.to_schema), (4, 5));
|
||||
assert_eq!(
|
||||
(migration.from_schema, migration.to_schema),
|
||||
(4, oa_core::SCHEMA_VERSION)
|
||||
);
|
||||
let canonical = language::format(&decoded.project);
|
||||
for field in [
|
||||
"incoming_items [@data]",
|
||||
|
||||
@@ -351,7 +351,10 @@ fn migrated_equal_constructor_can_complete_semantic_review_without_model_changes
|
||||
for reverse in [false, true] {
|
||||
let decoded = persistence::decode_with_report(json.as_bytes()).unwrap();
|
||||
let report = decoded.migration.unwrap();
|
||||
assert_eq!((report.from_schema, report.to_schema), (4, 5));
|
||||
assert_eq!(
|
||||
(report.from_schema, report.to_schema),
|
||||
(4, oa_core::SCHEMA_VERSION)
|
||||
);
|
||||
let p = decoded.project;
|
||||
let t = p.traces.values().next().unwrap();
|
||||
assert!(t.baseline.as_ref().unwrap().semantic.is_none());
|
||||
|
||||
@@ -11,9 +11,12 @@ fn frozen_v4_json_and_source_preserve_the_old_review_without_fabricating_semanti
|
||||
let source = language::parse_with_report(SOURCE4).unwrap();
|
||||
assert_eq!(json.project, source.project);
|
||||
for decoded in [json, source] {
|
||||
assert_eq!(decoded.project.schema_version, 5);
|
||||
assert_eq!(decoded.project.schema_version, oa_core::SCHEMA_VERSION);
|
||||
let report = decoded.migration.unwrap();
|
||||
assert_eq!((report.from_schema, report.to_schema), (4, 5));
|
||||
assert_eq!(
|
||||
(report.from_schema, report.to_schema),
|
||||
(4, oa_core::SCHEMA_VERSION)
|
||||
);
|
||||
let trace = decoded.project.traces.values().next().unwrap();
|
||||
let baseline = trace.baseline.as_ref().unwrap();
|
||||
assert!(baseline.semantic.is_none());
|
||||
@@ -22,7 +25,7 @@ fn frozen_v4_json_and_source_preserve_the_old_review_without_fabricating_semanti
|
||||
assert_eq!(baseline.sa.name, "Approved SA name");
|
||||
assert_ne!(baseline.oa.name, decoded.project.named(trace.oa));
|
||||
let mut expected: Value = serde_json::from_slice(JSON4).unwrap();
|
||||
expected["schema_version"] = 5.into();
|
||||
expected["schema_version"] = oa_core::SCHEMA_VERSION.into();
|
||||
assert_eq!(serde_json::to_value(&decoded.project).unwrap(), expected);
|
||||
let reopened =
|
||||
persistence::decode_with_report(&serde_json::to_vec(&decoded.project).unwrap())
|
||||
@@ -117,7 +120,7 @@ fn authored_v4_modules_upgrade_only_the_version_and_preserve_nested_comments() {
|
||||
assert_eq!(compiled.bundle.files["contracts.arcadia"], module);
|
||||
assert_eq!(
|
||||
compiled.bundle.files["project.arcadia"],
|
||||
entry.replacen("arcadia 4;", "arcadia 5;", 1)
|
||||
entry.replacen("arcadia 4;", "arcadia 6;", 1)
|
||||
);
|
||||
assert_eq!(
|
||||
compiled.index.lexical_owners
|
||||
@@ -152,8 +155,11 @@ fn original_v1_v2_v3_inputs_complete_the_explicit_upgrade_to_five() {
|
||||
] {
|
||||
let decoded = persistence::decode_with_report(bytes).unwrap();
|
||||
let report = decoded.migration.unwrap();
|
||||
assert_eq!((report.from_schema, report.to_schema), (version, 5));
|
||||
assert_eq!(decoded.project.schema_version, 5);
|
||||
assert_eq!(
|
||||
(report.from_schema, report.to_schema),
|
||||
(version, oa_core::SCHEMA_VERSION)
|
||||
);
|
||||
assert_eq!(decoded.project.schema_version, oa_core::SCHEMA_VERSION);
|
||||
assert!(
|
||||
decoded
|
||||
.project
|
||||
|
||||
@@ -131,7 +131,7 @@ fn typed_historical_facets_roundtrip_deleted_references_and_all_leaf_categories(
|
||||
]);
|
||||
assert!(!model.contains(id(90)));
|
||||
let source = language::format(&model);
|
||||
assert!(source.starts_with("arcadia 5;"));
|
||||
assert!(source.starts_with("arcadia 6;"));
|
||||
assert!(
|
||||
source.contains(&format!("Named uuid \"{}\"", id(90))),
|
||||
"{source}"
|
||||
|
||||
@@ -21,7 +21,10 @@ fn genuine_v3_preserves_typed_ports_and_allocates_deterministic_relationships()
|
||||
assert_eq!(decoded.migration, source.migration);
|
||||
let project = &decoded.project;
|
||||
let report = decoded.migration.as_ref().unwrap();
|
||||
assert_eq!((report.from_schema, report.to_schema), (3, 5));
|
||||
assert_eq!(
|
||||
(report.from_schema, report.to_schema),
|
||||
(3, oa_core::SCHEMA_VERSION)
|
||||
);
|
||||
assert_eq!(project.identities().len(), 19);
|
||||
assert!(report.preserved_ports.is_empty());
|
||||
assert!(report.generated_endpoints.is_empty());
|
||||
@@ -71,7 +74,7 @@ fn genuine_v3_preserves_typed_ports_and_allocates_deterministic_relationships()
|
||||
.any(|issue| issue.code == "message_value_ambiguous_path")
|
||||
);
|
||||
let canonical = language::format(project);
|
||||
assert!(canonical.starts_with("arcadia 5;"));
|
||||
assert!(canonical.starts_with("arcadia 6;"));
|
||||
let reopened = language::parse_with_report(&canonical).unwrap();
|
||||
assert_eq!(&reopened.project, project);
|
||||
assert!(reopened.migration.is_none());
|
||||
@@ -154,12 +157,12 @@ fn v1_and_frozen_v2_inputs_reach_four_through_the_shared_data_upgrade() {
|
||||
language::parse_with_report(include_str!("fixtures/v2-coastal-rescue.arcadia")).unwrap();
|
||||
assert_eq!(v2.project, text.project);
|
||||
assert_eq!(v2.migration, text.migration);
|
||||
assert_eq!(v2.migration.unwrap().to_schema, 5);
|
||||
assert_eq!(v2.migration.unwrap().to_schema, oa_core::SCHEMA_VERSION);
|
||||
let v1 = language::parse_with_report(
|
||||
"arcadia 1; project \"Old\" id \"00000000-0000-0000-0000-000000000001\" {}",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(v1.project.schema_version, 5);
|
||||
assert_eq!(v1.project.schema_version, oa_core::SCHEMA_VERSION);
|
||||
assert_eq!(v1.migration.unwrap().from_schema, 1);
|
||||
}
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ fn authored_v3_bundle_upgrade_preserves_includes_nested_ownership_comments_and_i
|
||||
};
|
||||
let compiled = compile_bundle(&bundle, ProjectLimits::default()).unwrap();
|
||||
assert_eq!(compiled.bundle.files.len(), 2);
|
||||
assert!(compiled.bundle.files["project.arcadia"].contains("arcadia 5;"));
|
||||
assert!(compiled.bundle.files["project.arcadia"].contains("arcadia 6;"));
|
||||
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![],
|
||||
},
|
||||
|
||||
@@ -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,409 @@
|
||||
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("arcadia 6;", "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());
|
||||
}
|
||||
@@ -157,7 +157,17 @@ 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("arcadia 6;"));
|
||||
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(),
|
||||
|
||||
@@ -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![],
|
||||
},
|
||||
|
||||
+168
-22
@@ -1,5 +1,13 @@
|
||||
mod checkpoints;
|
||||
#[cfg(test)]
|
||||
mod diagram_selection_tests;
|
||||
mod hosted;
|
||||
#[cfg(test)]
|
||||
mod review_navigation_tests;
|
||||
#[cfg(test)]
|
||||
mod transition_context_tests;
|
||||
#[cfg(test)]
|
||||
mod view_copy_tests;
|
||||
use crate::forms::Draft;
|
||||
use crate::{
|
||||
document::Document,
|
||||
@@ -288,6 +296,7 @@ pub enum Pending {
|
||||
HostedOpen,
|
||||
New,
|
||||
Demo,
|
||||
Walkthrough,
|
||||
Open,
|
||||
Close(window::Id),
|
||||
}
|
||||
@@ -346,6 +355,7 @@ pub enum Message {
|
||||
InspectorTab(crate::navigation::InspectorTab),
|
||||
RevealInDiagram(Id, Id),
|
||||
AddDiagram,
|
||||
DuplicateDiagram(Id),
|
||||
AddArtifact,
|
||||
AddTrace,
|
||||
Field(String, String),
|
||||
@@ -354,6 +364,7 @@ pub enum Message {
|
||||
FieldAppend(String, Value),
|
||||
FieldValuePath(String, crate::data_values::FieldPathKey),
|
||||
FieldValueBrowse(String, Option<crate::data_values::FieldPathKey>, usize),
|
||||
FieldReferenceBrowse(String, String, usize),
|
||||
FieldVariant(String, Value),
|
||||
FieldArray(String, Option<usize>, i32),
|
||||
Apply,
|
||||
@@ -897,6 +908,44 @@ impl App {
|
||||
fn diagram_for(&self, layer: Layer, element: Id) -> Option<Id> {
|
||||
preferred_diagram(self.project(), self.active_diagram, layer, element)
|
||||
}
|
||||
/// Navigation after an accepted review is not another model operation.
|
||||
/// Callers retain their workspace policy; keep an already visible target's
|
||||
/// camera and never leave an old-layer diagram behind after counterpart focus.
|
||||
fn focus_after_review(&mut self, target: Id, destination: Option<Id>) -> bool {
|
||||
if !self.project().contains(target) {
|
||||
return false;
|
||||
}
|
||||
let previous = (self.layer, self.active_diagram);
|
||||
self.selected = target;
|
||||
self.selected_node = None;
|
||||
if let Some(layer) = self.project().layer_of(target) {
|
||||
let retained = self.active_diagram.filter(|id| {
|
||||
self.project().diagrams.get(id).is_some_and(|d| {
|
||||
d.layer == layer
|
||||
&& (d.nodes.values().any(|n| n.element == target)
|
||||
|| self.project().elements.get(&target).is_some_and(|e| {
|
||||
e.kind() == Kind::Port
|
||||
&& project_view(self.project(), *id)
|
||||
.ports
|
||||
.iter()
|
||||
.any(|port| port.element == target)
|
||||
}))
|
||||
})
|
||||
});
|
||||
self.layer = layer;
|
||||
self.active_diagram = destination
|
||||
.filter(|id| {
|
||||
self.project()
|
||||
.diagrams
|
||||
.get(id)
|
||||
.is_some_and(|d| d.layer == layer)
|
||||
})
|
||||
.or(retained)
|
||||
.or_else(|| self.diagram_for(layer, target));
|
||||
}
|
||||
self.refresh_draft();
|
||||
previous != (self.layer, self.active_diagram)
|
||||
}
|
||||
fn select(&mut self, id: Id) {
|
||||
if self.selected == id {
|
||||
return;
|
||||
@@ -1530,7 +1579,14 @@ impl App {
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
self.selected_bend = None;
|
||||
}
|
||||
Message::Guide(event) => self.guide.update(event),
|
||||
Message::Guide(event) => {
|
||||
if self.guide.update(event) {
|
||||
return iced::widget::operation::snap_to(
|
||||
crate::guide::ARTICLE_SCROLL_ID,
|
||||
iced::widget::scrollable::RelativeOffset::START,
|
||||
);
|
||||
}
|
||||
}
|
||||
Message::ExactStrings(enabled) => {
|
||||
if let Some(d) = &mut self.draft {
|
||||
match d.set_exact(enabled) {
|
||||
@@ -1575,6 +1631,22 @@ impl App {
|
||||
.insert(path, crate::forms::ValueBrowser { scope, page });
|
||||
}
|
||||
}
|
||||
Message::FieldReferenceBrowse(path, query, page) => {
|
||||
if let Some(draft) = &mut self.draft {
|
||||
let page = if draft
|
||||
.reference_browsers
|
||||
.get(&path)
|
||||
.is_some_and(|b| b.query == query)
|
||||
{
|
||||
page
|
||||
} else {
|
||||
0
|
||||
};
|
||||
draft
|
||||
.reference_browsers
|
||||
.insert(path, crate::forms::ReferenceBrowser { query, page });
|
||||
}
|
||||
}
|
||||
Message::FieldValuePath(path, key) => {
|
||||
if let Some(draft) = &mut self.draft
|
||||
&& let Err(error) = draft.value_path(&path, key)
|
||||
@@ -1799,13 +1871,11 @@ impl App {
|
||||
match state.command(self.project()) {
|
||||
Ok(command) => {
|
||||
if self.execute(command) {
|
||||
if let Some(target) = focus {
|
||||
self.selected = target;
|
||||
if let Some(layer) = self.project().layer_of(target)
|
||||
{
|
||||
self.layer = layer;
|
||||
}
|
||||
self.refresh_draft();
|
||||
if let Some(target) = focus
|
||||
&& self.focus_after_review(target, None)
|
||||
&& self.page == Page::Diagram
|
||||
{
|
||||
self.fit();
|
||||
}
|
||||
self.status = "Semantic review applied · model, source and baseline form one undoable change".into();
|
||||
return Task::none();
|
||||
@@ -1827,11 +1897,19 @@ impl App {
|
||||
&& matches!(&workflow, crate::workflow::Workflow::Ports(_));
|
||||
let transition_focus = match &workflow {
|
||||
crate::workflow::Workflow::Ports(state) => Some(state.focus()),
|
||||
crate::workflow::Workflow::Transition(t) => {
|
||||
t.plan.changes.first().map(|c| c.target)
|
||||
}
|
||||
crate::workflow::Workflow::Transition(t) => t
|
||||
.request
|
||||
.sources
|
||||
.first()
|
||||
.and_then(|source| t.plan.changes.iter().find(|c| c.source == *source))
|
||||
.map(|c| c.target),
|
||||
_ => None,
|
||||
};
|
||||
let behavior_transition = matches!(&workflow,
|
||||
crate::workflow::Workflow::Transition(t)
|
||||
if t.request.sources.first().is_some_and(|source|
|
||||
crate::behavior::selected_behavior(self.project(), *source).is_some()));
|
||||
let previous_behavior_context = self.state_view_identity();
|
||||
match workflow.update(self.project(), event) {
|
||||
Ok(Some(mut candidate)) => {
|
||||
let focus = transition_focus
|
||||
@@ -1849,25 +1927,33 @@ impl App {
|
||||
.find(|id| !self.project().traces.contains_key(id))
|
||||
.copied()
|
||||
});
|
||||
let behavior_owner = focus
|
||||
.filter(|_| behavior_transition)
|
||||
.and_then(|id| crate::behavior::selected_behavior(&candidate, id));
|
||||
let destination = focus.and_then(|id| {
|
||||
candidate.layer_of(id).and_then(|layer| {
|
||||
preferred_diagram(&candidate, self.active_diagram, layer, id)
|
||||
})
|
||||
});
|
||||
if let (Some(id), Some(diagram)) = (focus, destination) {
|
||||
if let (None, Some(id), Some(diagram)) =
|
||||
(behavior_owner, focus, destination)
|
||||
{
|
||||
reveal_port_owner(&mut candidate, diagram, id);
|
||||
}
|
||||
if self.execute(Command::Replace(candidate)) {
|
||||
if let Some(id) = focus {
|
||||
self.selected = id;
|
||||
if let Some(layer) = self.project().layer_of(id) {
|
||||
self.layer = layer;
|
||||
self.active_diagram =
|
||||
destination.or_else(|| self.diagram_for(layer, id));
|
||||
}
|
||||
self.refresh_draft();
|
||||
self.focus_after_review(id, destination);
|
||||
}
|
||||
if !stay_in_catalog {
|
||||
if let Some(owner) = behavior_owner {
|
||||
self.selected_node = None;
|
||||
self.behavior.active_owner = Some(owner);
|
||||
self.page = Page::Behavior;
|
||||
if self.reset_state_view_after_navigation(
|
||||
previous_behavior_context,
|
||||
) {
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
}
|
||||
} else if !stay_in_catalog {
|
||||
self.page = Page::Diagram;
|
||||
self.fit();
|
||||
}
|
||||
@@ -2105,6 +2191,7 @@ impl App {
|
||||
layer: self.layer,
|
||||
projection: ProjectionKind::Allocation,
|
||||
routes: Vec::new(),
|
||||
view_options: Default::default(),
|
||||
nodes: Default::default(),
|
||||
};
|
||||
if self.execute(Command::CreateDiagram(d)) {
|
||||
@@ -2113,6 +2200,39 @@ impl App {
|
||||
}
|
||||
}
|
||||
}
|
||||
Message::DuplicateDiagram(source) => {
|
||||
if !self.project().diagrams.contains_key(&source) {
|
||||
self.status = "The saved view to duplicate no longer exists.".into();
|
||||
return Task::none();
|
||||
}
|
||||
let original = self.selected;
|
||||
if self.apply() {
|
||||
let source = if source == original {
|
||||
self.selected
|
||||
} else {
|
||||
source
|
||||
};
|
||||
match crate::view_copy::command(self.project(), source) {
|
||||
Ok(command) => {
|
||||
if self.execute(command) {
|
||||
self.active_diagram = Some(self.selected);
|
||||
self.layer = self.project().diagrams[&self.selected].layer;
|
||||
self.selected_node = None;
|
||||
self.selected_bend = None;
|
||||
self.page = Page::Diagram;
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
self.route_state_diagram_workspace();
|
||||
if self.page == Page::Diagram {
|
||||
self.fit();
|
||||
}
|
||||
self.status =
|
||||
"View copied; engineering elements are shared.".into();
|
||||
}
|
||||
}
|
||||
Err(error) => self.status = error,
|
||||
}
|
||||
}
|
||||
}
|
||||
Message::AddArtifact => {
|
||||
if self.apply() {
|
||||
let id = Id::new_v4();
|
||||
@@ -2206,11 +2326,28 @@ impl App {
|
||||
});
|
||||
}
|
||||
Message::Diagram(choice) => {
|
||||
// Reject a stale picker event before applying unrelated property input.
|
||||
if !self.project().diagrams.contains_key(&choice.id) {
|
||||
self.status = "Diagram no longer exists.".into();
|
||||
return Task::none();
|
||||
}
|
||||
let previous_diagram = self.active_diagram;
|
||||
let follow_view = previous_diagram == Some(self.selected);
|
||||
if self.apply() {
|
||||
self.active_diagram = Some(choice.id);
|
||||
if let Some(d) = self.project().diagrams.get(&choice.id) {
|
||||
// Apply may have changed the current diagram's UUID. A picker
|
||||
// event for that same view must follow its new identity.
|
||||
let diagram = if previous_diagram == Some(choice.id) {
|
||||
self.active_diagram.unwrap_or(choice.id)
|
||||
} else {
|
||||
choice.id
|
||||
};
|
||||
self.active_diagram = Some(diagram);
|
||||
if let Some(d) = self.project().diagrams.get(&diagram) {
|
||||
self.layer = d.layer;
|
||||
}
|
||||
if follow_view {
|
||||
self.selected = diagram;
|
||||
}
|
||||
self.selected_node = None;
|
||||
self.refresh_draft();
|
||||
self.fit();
|
||||
@@ -2841,6 +2978,7 @@ impl App {
|
||||
layer: self.layer,
|
||||
projection: preferred,
|
||||
routes: Vec::new(),
|
||||
view_options: Default::default(),
|
||||
nodes: Default::default(),
|
||||
}));
|
||||
diagram_target = Some(view);
|
||||
@@ -3009,6 +3147,13 @@ impl App {
|
||||
self.reset(Project::demo(), None);
|
||||
Task::none()
|
||||
}
|
||||
Pending::Walkthrough => match crate::home::walkthrough_path() {
|
||||
Ok(path) => self.load_from(path),
|
||||
Err(error) => {
|
||||
self.status = error;
|
||||
Task::none()
|
||||
}
|
||||
},
|
||||
Pending::Open => self.choose_file(FileOperation::Open),
|
||||
Pending::Close(id) => {
|
||||
self.retire_checkpoint();
|
||||
@@ -4440,6 +4585,7 @@ mod port_view_tests {
|
||||
layer: Layer::Sa,
|
||||
projection: ProjectionKind::Capabilities,
|
||||
routes: Vec::new(),
|
||||
view_options: Default::default(),
|
||||
nodes: Default::default(),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -16,6 +16,7 @@ impl App {
|
||||
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) => {
|
||||
@@ -151,6 +152,7 @@ impl App {
|
||||
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
|
||||
{
|
||||
@@ -184,7 +186,11 @@ impl App {
|
||||
} 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.status = if self.migration.is_some() {
|
||||
"Recovered source 5 as source 6 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."
|
||||
} else {
|
||||
"Recovered an unsaved local copy. Choose Save as to save it; the original file and hosted model are unchanged."
|
||||
}.into();
|
||||
self.request_lint()
|
||||
}
|
||||
}
|
||||
@@ -279,6 +285,28 @@ mod tests {
|
||||
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,
|
||||
|
||||
@@ -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,184 @@
|
||||
//! Accepted semantic review keeps selection, layer and visible diagram coherent.
|
||||
use super::*;
|
||||
use crate::{
|
||||
semantic_review::{RowEvent, State},
|
||||
workflow::{Event, Workflow},
|
||||
};
|
||||
|
||||
fn send(app: &mut App, message: Message) {
|
||||
let _ = app.update(message);
|
||||
}
|
||||
fn state(app: &App) -> &State {
|
||||
let Some(Workflow::Semantic(state)) = &app.workflow else {
|
||||
panic!("{}", app.status)
|
||||
};
|
||||
state
|
||||
}
|
||||
fn row_event(app: &mut App, event: RowEvent) {
|
||||
let event = state(app).address_event(event);
|
||||
send(app, Message::Workflow(Event::Semantic(event)));
|
||||
}
|
||||
fn fixture(reverse: bool, destination_view: bool) -> (App, Id, Id, Id, Option<Id>) {
|
||||
let mut p = Project::empty();
|
||||
let oa = p
|
||||
.add_element(Kind::Activity, Layer::Oa, "Confirm stable pressure", None)
|
||||
.unwrap();
|
||||
let sa = p
|
||||
.add_element(Kind::Function, Layer::Sa, "Confirm safe pressure", None)
|
||||
.unwrap();
|
||||
let trace = Id::new_v4();
|
||||
p.traces.insert(
|
||||
trace,
|
||||
Trace {
|
||||
id: trace,
|
||||
alias: "confirm_trace".into(),
|
||||
name: "Confirmation refinement".into(),
|
||||
description: String::new(),
|
||||
oa,
|
||||
sa,
|
||||
kind: TraceKind::Refinement,
|
||||
baseline: None,
|
||||
},
|
||||
);
|
||||
let (source, target, source_layer, target_layer) = if reverse {
|
||||
(sa, oa, Layer::Sa, Layer::Oa)
|
||||
} else {
|
||||
(oa, sa, Layer::Oa, Layer::Sa)
|
||||
};
|
||||
let from = p.ensure_diagram(source_layer);
|
||||
p.place(from, source, Position { x: 91., y: 143. });
|
||||
let to = destination_view.then(|| {
|
||||
let view = p.ensure_diagram(target_layer);
|
||||
p.place(view, target, Position { x: 487., y: 213. });
|
||||
view
|
||||
});
|
||||
let mut app = App::with_project(p);
|
||||
app.page = Page::Diagram;
|
||||
app.layer = source_layer;
|
||||
app.active_diagram = Some(from);
|
||||
app.selected = source;
|
||||
app.refresh_draft();
|
||||
(app, source, target, from, to)
|
||||
}
|
||||
fn name_take(app: &mut App, source: Id) -> Project {
|
||||
send(app, Message::StartSemanticReview(source));
|
||||
let facet = FacetSelector::Header(HeaderFacet::Name);
|
||||
row_event(app, RowEvent::Select(facet.clone()));
|
||||
row_event(
|
||||
app,
|
||||
RowEvent::Decide(facet, Some(SemanticDecision::TakeSource)),
|
||||
);
|
||||
assert!(state(app).can_apply(), "{:?}", state(app).error);
|
||||
let Some(Workflow::Semantic(state)) = &mut app.workflow else {
|
||||
unreachable!()
|
||||
};
|
||||
let command = state.command(app.document.project()).unwrap();
|
||||
execute_batch(app.document.project(), &[command]).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn semantic_name_take_selects_the_counterpart_view_in_both_directions_with_one_exact_history_entry()
|
||||
{
|
||||
for reverse in [false, true] {
|
||||
let (mut app, source, target, from, to) = fixture(reverse, true);
|
||||
app.search = format!("@{}", app.project().elements[&source].alias);
|
||||
let query = app.search.clone();
|
||||
let before = app.document.snapshot();
|
||||
let expected = name_take(&mut app, source);
|
||||
app.selected_node = app.project().diagrams[&from].nodes.keys().next().copied();
|
||||
send(&mut app, Message::Workflow(Event::Apply));
|
||||
assert_eq!(app.project(), &expected);
|
||||
assert_eq!(app.project().diagrams, before.decoded.project.diagrams);
|
||||
assert_eq!(app.selected, target);
|
||||
assert_eq!(app.layer, app.project().elements[&target].layer());
|
||||
assert_eq!(app.active_diagram, to);
|
||||
assert_eq!(
|
||||
app.project().diagrams[&app.active_diagram.unwrap()].layer,
|
||||
app.layer
|
||||
);
|
||||
// refresh_draft deliberately selects the target's visible placement.
|
||||
// The invariant is that the old-layer placement cannot survive.
|
||||
let placement = app.selected_node.expect("visible counterpart placement");
|
||||
assert_eq!(
|
||||
app.project().diagrams[&to.unwrap()].nodes[&placement].element,
|
||||
target
|
||||
);
|
||||
assert!(!app.project().diagrams[&from].nodes.contains_key(&placement));
|
||||
assert_eq!(app.page, Page::Diagram);
|
||||
assert_eq!(app.search, query);
|
||||
assert!(app.workflow.is_none());
|
||||
assert_eq!(app.document.revision(), 1);
|
||||
let accepted = app.document.snapshot();
|
||||
assert_eq!(
|
||||
source_project::compile_bundle(&accepted.bundle, LIMITS)
|
||||
.unwrap()
|
||||
.decoded
|
||||
.project,
|
||||
expected
|
||||
);
|
||||
send(&mut app, Message::Undo);
|
||||
assert_eq!(app.project(), &before.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
assert!(!app.document.can_undo());
|
||||
send(&mut app, Message::Redo);
|
||||
assert_eq!(app.project(), &accepted.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &accepted.bundle);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn semantic_review_without_a_destination_view_clears_the_old_layer_canvas_without_creating_a_view()
|
||||
{
|
||||
let (mut app, source, target, _, _) = fixture(false, false);
|
||||
let expected = name_take(&mut app, source);
|
||||
send(&mut app, Message::Workflow(Event::Apply));
|
||||
assert_eq!(app.project(), &expected);
|
||||
assert_eq!(app.selected, target);
|
||||
assert_eq!(app.layer, Layer::Sa);
|
||||
assert_eq!(app.active_diagram, None);
|
||||
assert_eq!(app.project().diagrams.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn review_already_showing_the_target_keeps_its_camera_and_query() {
|
||||
let (mut app, source, target, _, to) = fixture(false, true);
|
||||
app.layer = Layer::Sa;
|
||||
app.active_diagram = to;
|
||||
app.selected = target;
|
||||
app.zoom = 1.31;
|
||||
app.pan = iced::Vector::new(-219., 88.);
|
||||
app.search = "pressure".into();
|
||||
app.refresh_draft();
|
||||
let expected = name_take(&mut app, source);
|
||||
send(&mut app, Message::Workflow(Event::Apply));
|
||||
assert_eq!(app.project(), &expected);
|
||||
assert_eq!(app.active_diagram, to);
|
||||
assert_eq!(app.zoom, 1.31);
|
||||
assert_eq!(app.pan, iced::Vector::new(-219., 88.));
|
||||
assert_eq!(app.search, "pressure");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejected_semantic_source_writeback_retains_review_camera_and_original_navigation() {
|
||||
let (mut app, source, _, from, _) = fixture(false, true);
|
||||
let bundle = app.document.bundle().clone();
|
||||
let mut limits = source_project::ProjectLimits::default();
|
||||
limits.model.max_source_bytes = bundle.files.values().map(String::len).sum::<usize>() + 1;
|
||||
app.document = Document::from_bundle(bundle, limits).unwrap();
|
||||
app.zoom = 1.21;
|
||||
app.pan = iced::Vector::new(72., -44.);
|
||||
app.search = "confirmation".into();
|
||||
let before = app.document.snapshot();
|
||||
let _valid_model = name_take(&mut app, source);
|
||||
send(&mut app, Message::Workflow(Event::Apply));
|
||||
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
|
||||
assert_eq!(app.selected, source);
|
||||
assert_eq!(app.layer, Layer::Oa);
|
||||
assert_eq!(app.active_diagram, Some(from));
|
||||
assert_eq!(app.page, Page::Diagram);
|
||||
assert_eq!(app.zoom, 1.21);
|
||||
assert_eq!(app.pan, iced::Vector::new(72., -44.));
|
||||
assert_eq!(app.search, "confirmation");
|
||||
assert!(state(&app).error.is_some());
|
||||
assert!(!app.document.can_undo());
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
//! Transition navigation must not add presentation edits to a behavior review.
|
||||
use super::*;
|
||||
use crate::workflow::{Event, Workflow};
|
||||
use oa_core::transitions::*;
|
||||
|
||||
fn send(app: &mut App, message: Message) {
|
||||
let _ = app.update(message);
|
||||
}
|
||||
|
||||
fn element(
|
||||
p: &mut Project,
|
||||
number: u128,
|
||||
kind: Kind,
|
||||
layer: Layer,
|
||||
name: &str,
|
||||
parent: Option<Id>,
|
||||
) -> Id {
|
||||
let mut e = p.make_element(kind, layer, name, parent).unwrap();
|
||||
e.id = Id::from_u128(number);
|
||||
let id = e.id;
|
||||
p.elements.insert(id, e);
|
||||
id
|
||||
}
|
||||
|
||||
fn fixture(layer: Layer, kind: Kind) -> (App, Id, Id, Id) {
|
||||
let mut p = Project::empty();
|
||||
let other = if layer == Layer::Oa {
|
||||
Layer::Sa
|
||||
} else {
|
||||
Layer::Oa
|
||||
};
|
||||
let work_kind = |l| {
|
||||
if l == Layer::Oa {
|
||||
Kind::Activity
|
||||
} else {
|
||||
Kind::Function
|
||||
}
|
||||
};
|
||||
let work = element(&mut p, 1, work_kind(layer), layer, "Assess pressure", None);
|
||||
let reused = element(&mut p, 10, work_kind(other), other, "Assess pressure", None);
|
||||
let root = element(&mut p, 100, kind, layer, "Restore service", None);
|
||||
if kind == Kind::Scenario {
|
||||
let line = element(&mut p, 2, Kind::Lifeline, layer, "Assessment", Some(root));
|
||||
*p.elements.get_mut(&line).unwrap().shared_mut().unwrap() = SharedBody::Lifeline {
|
||||
represented: Some(work),
|
||||
};
|
||||
}
|
||||
let view = p.ensure_diagram(other);
|
||||
p.diagrams.get_mut(&view).unwrap().name = "Unrelated architecture".into();
|
||||
p.place(view, reused, Position { x: 873., y: 421. });
|
||||
let mut app = App::with_project(p);
|
||||
app.layer = layer;
|
||||
app.page = Page::Behavior;
|
||||
app.selected = root;
|
||||
app.behavior.active_owner = Some(root);
|
||||
app.refresh_draft();
|
||||
(app, root, work, reused)
|
||||
}
|
||||
|
||||
fn review(
|
||||
app: &mut App,
|
||||
selected: Id,
|
||||
mapping: Option<(Id, Id)>,
|
||||
destination: Option<Id>,
|
||||
) -> (Id, Project) {
|
||||
app.selected = selected;
|
||||
app.refresh_draft();
|
||||
send(app, Message::StartTransition);
|
||||
let Some(Workflow::Transition(t)) = &mut app.workflow else {
|
||||
panic!("{}", app.status)
|
||||
};
|
||||
t.request.scope = DependencyScope::Subtree;
|
||||
t.request.destination_diagram = destination;
|
||||
if let Some((source, target)) = mapping {
|
||||
t.request.mappings.push(TransitionMapping {
|
||||
source,
|
||||
target: Some(target),
|
||||
target_kind: None,
|
||||
});
|
||||
}
|
||||
t.plan = plan_transition(app.document.project(), &t.request).unwrap();
|
||||
assert!(t.plan.conflicts.is_empty(), "{:?}", t.plan.conflicts);
|
||||
let target = t
|
||||
.plan
|
||||
.changes
|
||||
.iter()
|
||||
.find(|c| c.source == selected)
|
||||
.unwrap()
|
||||
.target;
|
||||
let expected = apply_transition(app.document.project(), &t.plan).unwrap();
|
||||
(target, expected)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scenario_transition_focuses_requested_counterpart_and_preserves_unrelated_views_in_both_directions()
|
||||
{
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let (mut app, root, work, reused) = fixture(layer, Kind::Scenario);
|
||||
let stale_selection = crate::behavior::scenario::CanvasEvent::Select(
|
||||
app.scenario_context().unwrap(),
|
||||
Id::from_u128(2),
|
||||
);
|
||||
let before = app.document.snapshot();
|
||||
let (target, expected) = review(&mut app, root, Some((work, reused)), None);
|
||||
let Some(Workflow::Transition(t)) = &app.workflow else {
|
||||
unreachable!()
|
||||
};
|
||||
assert_ne!(
|
||||
t.plan.changes.first().unwrap().source,
|
||||
root,
|
||||
"Fixture must expose a dependency before the selected root"
|
||||
);
|
||||
app.selected_node = Some(Id::from_u128(999));
|
||||
send(&mut app, Message::Workflow(Event::Apply));
|
||||
assert_eq!(
|
||||
app.project(),
|
||||
&expected,
|
||||
"Desktop must preserve the core candidate exactly"
|
||||
);
|
||||
assert_eq!(app.project().diagrams, before.decoded.project.diagrams);
|
||||
assert_eq!(app.page, Page::Behavior);
|
||||
assert_eq!(app.selected, target);
|
||||
assert_eq!(app.behavior.active_owner, Some(target));
|
||||
assert_eq!(app.scenario_context().unwrap().scenario, target);
|
||||
assert_eq!(app.selected_node, None);
|
||||
assert!(app.workflow.is_none());
|
||||
assert_eq!(app.document.revision(), 1);
|
||||
let accepted = app.document.snapshot();
|
||||
send(&mut app, Message::ScenarioCanvas(stale_selection.clone()));
|
||||
assert_eq!(app.selected, target);
|
||||
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
||||
assert_eq!(
|
||||
source_project::compile_bundle(&accepted.bundle, LIMITS)
|
||||
.unwrap()
|
||||
.decoded
|
||||
.project,
|
||||
expected
|
||||
);
|
||||
send(&mut app, Message::Undo);
|
||||
assert_eq!(app.project(), &before.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
assert!(!app.document.can_undo());
|
||||
let undone_selection = app.selected;
|
||||
send(&mut app, Message::ScenarioCanvas(stale_selection));
|
||||
assert_eq!(app.selected, undone_selection);
|
||||
assert_eq!(app.project(), &before.decoded.project);
|
||||
send(&mut app, Message::Redo);
|
||||
assert_eq!(app.project(), &accepted.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &accepted.bundle);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_behavior_internal_and_process_roots_keep_their_workspace_without_creating_a_view() {
|
||||
for (layer, kind) in [
|
||||
(Layer::Oa, Kind::Scenario),
|
||||
(Layer::Sa, Kind::Scenario),
|
||||
(Layer::Oa, Kind::Process),
|
||||
(Layer::Sa, Kind::FunctionalChain),
|
||||
] {
|
||||
let (mut app, root, work, reused) = fixture(layer, kind);
|
||||
let mut p = app.project().clone();
|
||||
p.diagrams.clear();
|
||||
app = App::with_project(p);
|
||||
app.page = Page::Behavior;
|
||||
let selected = if kind == Kind::Scenario {
|
||||
Id::from_u128(2)
|
||||
} else {
|
||||
root
|
||||
};
|
||||
let (target, expected) = review(&mut app, selected, Some((work, reused)), None);
|
||||
let owner = crate::behavior::selected_behavior(&expected, target).unwrap();
|
||||
send(&mut app, Message::Workflow(Event::Apply));
|
||||
assert_eq!(app.project(), &expected);
|
||||
assert!(app.project().diagrams.is_empty());
|
||||
assert_eq!(app.selected, target);
|
||||
assert_eq!(app.behavior.active_owner, Some(owner));
|
||||
assert_eq!(app.page, Page::Behavior);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_destination_is_honored_and_ordinary_work_transition_still_places_in_architecture() {
|
||||
let (mut app, root, work, reused) = fixture(Layer::Oa, Kind::Scenario);
|
||||
let destination = *app.project().diagrams.keys().next().unwrap();
|
||||
let (target, expected) = review(&mut app, root, Some((work, reused)), Some(destination));
|
||||
assert!(
|
||||
expected.diagrams[&destination]
|
||||
.nodes
|
||||
.values()
|
||||
.any(|n| n.element == target)
|
||||
);
|
||||
send(&mut app, Message::Workflow(Event::Apply));
|
||||
assert_eq!(app.project(), &expected);
|
||||
assert_eq!(app.page, Page::Behavior);
|
||||
let (mut app, _, work, _) = fixture(Layer::Oa, Kind::Scenario);
|
||||
let (target, _) = review(&mut app, work, None, None);
|
||||
send(&mut app, Message::Workflow(Event::Apply));
|
||||
assert_eq!(app.page, Page::Diagram);
|
||||
assert_eq!(app.selected, target);
|
||||
assert!(
|
||||
app.project().diagrams[&app.active_diagram.unwrap()]
|
||||
.nodes
|
||||
.values()
|
||||
.any(|n| n.element == target)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn machine_and_typed_action_transitions_resolve_the_owner_and_reusing_that_context_retains_camera()
|
||||
{
|
||||
for selected_action in [false, true] {
|
||||
let mut p = Project::empty();
|
||||
let owner = element(&mut p, 1, Kind::Entity, Layer::Oa, "Control room", None);
|
||||
let counterpart = element(&mut p, 10, Kind::System, Layer::Sa, "Control room", None);
|
||||
let machine = element(
|
||||
&mut p,
|
||||
100,
|
||||
Kind::StateMachine,
|
||||
Layer::Oa,
|
||||
"Pressure modes",
|
||||
Some(owner),
|
||||
);
|
||||
let region = element(&mut p, 101, Kind::Region, Layer::Oa, "Main", Some(machine));
|
||||
let state = element(&mut p, 102, Kind::State, Layer::Oa, "Waiting", Some(region));
|
||||
let work = element(&mut p, 2, Kind::Activity, Layer::Oa, "Assess alarm", None);
|
||||
let action = Id::from_u128(103);
|
||||
p.relations.insert(
|
||||
action,
|
||||
Relation {
|
||||
id: action,
|
||||
alias: "assess_entry".into(),
|
||||
name: "Assess on entry".into(),
|
||||
description: String::new(),
|
||||
body: RelationBody::StateAction {
|
||||
source: state,
|
||||
target: work,
|
||||
phase: StateActionKind::Entry,
|
||||
},
|
||||
},
|
||||
);
|
||||
let mut app = App::with_project(p);
|
||||
app.page = Page::Behavior;
|
||||
app.behavior.active_owner = Some(machine);
|
||||
let selected = if selected_action { action } else { machine };
|
||||
let (target, expected) = review(&mut app, selected, Some((owner, counterpart)), None);
|
||||
let target_machine = crate::behavior::selected_behavior(&expected, target).unwrap();
|
||||
send(&mut app, Message::Workflow(Event::Apply));
|
||||
assert_eq!(app.project(), &expected);
|
||||
assert_eq!(app.page, Page::Behavior);
|
||||
assert_eq!(app.selected, target);
|
||||
assert_eq!(app.behavior.active_owner, Some(target_machine));
|
||||
assert!(
|
||||
app.project().diagrams.is_empty(),
|
||||
"The state workspace must offer explicit view creation"
|
||||
);
|
||||
app.behavior.diagram.zoom = 1.17;
|
||||
app.behavior.diagram.pan = iced::Vector::new(-73., 146.);
|
||||
let epoch = app.canvas_epoch;
|
||||
let (same_target, _) = review(&mut app, selected, Some((owner, counterpart)), None);
|
||||
assert_eq!(same_target, target);
|
||||
send(&mut app, Message::Workflow(Event::Apply));
|
||||
assert_eq!(app.behavior.diagram.zoom, 1.17);
|
||||
assert_eq!(app.behavior.diagram.pan, iced::Vector::new(-73., 146.));
|
||||
assert_eq!(app.canvas_epoch, epoch);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejected_source_writeback_keeps_review_and_navigation_and_invalid_draft_still_blocks_start() {
|
||||
let (mut app, root, work, reused) = fixture(Layer::Oa, Kind::Scenario);
|
||||
let bundle = app.document.bundle().clone();
|
||||
let mut limits = oa_core::source_project::ProjectLimits::default();
|
||||
limits.model.max_source_bytes = bundle.files.values().map(String::len).sum::<usize>() + 1;
|
||||
app.document = Document::from_bundle(bundle.clone(), limits).unwrap();
|
||||
let (_, _) = review(&mut app, root, Some((work, reused)), None);
|
||||
let before = app.document.snapshot();
|
||||
send(&mut app, Message::Workflow(Event::Apply));
|
||||
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
|
||||
assert!(app.workflow.is_some());
|
||||
assert_eq!(app.page, Page::Behavior);
|
||||
assert_eq!(app.selected, root);
|
||||
assert_eq!(app.behavior.active_owner, Some(root));
|
||||
assert!(!app.document.can_undo());
|
||||
app.workflow = None;
|
||||
send(
|
||||
&mut app,
|
||||
Message::Field("/alias".into(), "not an alias!".into()),
|
||||
);
|
||||
let draft = serde_json::to_value(app.draft.as_ref().unwrap().recovery_snapshot()).unwrap();
|
||||
send(&mut app, Message::StartTransition);
|
||||
assert!(app.workflow.is_none());
|
||||
assert_eq!(
|
||||
serde_json::to_value(app.draft.as_ref().unwrap().recovery_snapshot()).unwrap(),
|
||||
draft
|
||||
);
|
||||
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
//! App-level copy behavior, with separate state/architecture view identities.
|
||||
use super::*;
|
||||
|
||||
struct Fixture {
|
||||
app: App,
|
||||
architecture: Id,
|
||||
states: Id,
|
||||
machine: Id,
|
||||
region: Id,
|
||||
state: Id,
|
||||
}
|
||||
|
||||
fn fixture() -> Fixture {
|
||||
let project = language::parse(
|
||||
r#"arcadia 3; project copied_views "Copied views" {
|
||||
oa_entity crew "Crew" {}
|
||||
oa_state_machine modes "Modes" { parent @crew; }
|
||||
oa_region main "Main" { parent @modes; }
|
||||
oa_state ready "Ready" { parent @main; }
|
||||
diagram architecture "Architecture" {}
|
||||
diagram state_view "Modes view" { projection { StateMachine { machine @modes; }; }; }
|
||||
node crew_view { diagram @architecture; element @crew; }
|
||||
node main_view { diagram @state_view; element @main; }
|
||||
node ready_view { diagram @state_view; element @ready; }
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let identity = |alias| {
|
||||
project
|
||||
.identities()
|
||||
.into_iter()
|
||||
.find(|(_, candidate)| *candidate == alias)
|
||||
.unwrap()
|
||||
.0
|
||||
};
|
||||
let architecture = identity("architecture");
|
||||
let states = identity("state_view");
|
||||
let machine = identity("modes");
|
||||
let region = identity("main");
|
||||
let state = identity("ready");
|
||||
let mut app = App::with_project(project);
|
||||
// Initial view UUID order must not determine the test's workspace.
|
||||
app.page = Page::Behavior;
|
||||
app.active_diagram = Some(architecture);
|
||||
app.behavior.active_owner = Some(machine);
|
||||
app.behavior.diagram.diagram = Some(states);
|
||||
app.selected = state;
|
||||
app.refresh_draft();
|
||||
Fixture {
|
||||
app,
|
||||
architecture,
|
||||
states,
|
||||
machine,
|
||||
region,
|
||||
state,
|
||||
}
|
||||
}
|
||||
|
||||
fn send(app: &mut App, message: Message) {
|
||||
let _ = app.update(message);
|
||||
}
|
||||
|
||||
fn raw_property(app: &App) -> serde_json::Value {
|
||||
serde_json::to_value(app.draft.as_ref().unwrap().recovery_snapshot()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copying_the_current_state_view_uses_its_own_identity_and_is_one_source_undo() {
|
||||
let mut f = fixture();
|
||||
assert_eq!(f.app.state_context().unwrap().diagram, f.states);
|
||||
assert_eq!(f.app.active_diagram, Some(f.architecture));
|
||||
let before = f.app.document.snapshot();
|
||||
let revision = f.app.document.revision();
|
||||
send(&mut f.app, Message::DuplicateDiagram(f.states));
|
||||
assert_eq!(f.app.page, Page::Behavior);
|
||||
assert_eq!(f.app.behavior.active_owner, Some(f.machine));
|
||||
let copied = f.app.state_context().unwrap().diagram;
|
||||
assert_ne!(copied, f.states);
|
||||
assert_ne!(copied, f.architecture);
|
||||
assert_eq!(f.app.active_diagram, Some(copied));
|
||||
assert_eq!(f.app.selected, copied);
|
||||
assert_eq!(
|
||||
f.app.project().diagrams[&copied].projection.state_machine(),
|
||||
Some(f.machine)
|
||||
);
|
||||
assert_eq!(
|
||||
f.app.project().diagrams[&copied]
|
||||
.nodes
|
||||
.values()
|
||||
.map(|node| node.element)
|
||||
.collect::<BTreeSet<_>>(),
|
||||
BTreeSet::from([f.region, f.state])
|
||||
);
|
||||
assert_eq!(f.app.document.revision(), revision + 1);
|
||||
let accepted = f.app.document.snapshot();
|
||||
assert_eq!(
|
||||
source_project::compile_bundle(f.app.document.bundle(), LIMITS)
|
||||
.unwrap()
|
||||
.decoded
|
||||
.project,
|
||||
*f.app.project()
|
||||
);
|
||||
let mut only_original = f.app.project().clone();
|
||||
only_original.diagrams.remove(&copied);
|
||||
assert_eq!(only_original, before.decoded.project);
|
||||
send(&mut f.app, Message::Undo);
|
||||
assert_eq!(f.app.project(), &before.decoded.project);
|
||||
assert_eq!(f.app.document.bundle(), &before.bundle);
|
||||
assert!(!f.app.document.can_undo());
|
||||
send(&mut f.app, Message::Redo);
|
||||
assert_eq!(f.app.project(), &accepted.decoded.project);
|
||||
assert_eq!(f.app.document.bundle(), &accepted.bundle);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copying_architecture_keeps_architecture_even_with_a_retained_state_view() {
|
||||
let mut f = fixture();
|
||||
f.app.page = Page::Diagram;
|
||||
f.app.active_diagram = Some(f.architecture);
|
||||
f.app.selected = f.architecture;
|
||||
f.app.refresh_draft();
|
||||
send(&mut f.app, Message::DuplicateDiagram(f.architecture));
|
||||
let copied = f.app.active_diagram.unwrap();
|
||||
assert_ne!(copied, f.architecture);
|
||||
assert_eq!(f.app.page, Page::Diagram);
|
||||
assert!(f.app.state_context().is_none());
|
||||
assert_eq!(f.app.project().diagrams[&copied].name, "Architecture copy");
|
||||
assert_eq!(
|
||||
f.app.project().diagrams[&copied].projection,
|
||||
f.app.project().diagrams[&f.architecture].projection
|
||||
);
|
||||
assert_eq!(f.app.project().diagrams[&copied].nodes.len(), 1);
|
||||
assert_eq!(f.app.selected, copied);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_copy_does_not_apply_an_unrelated_valid_property_draft() {
|
||||
let mut f = fixture();
|
||||
send(
|
||||
&mut f.app,
|
||||
Message::Field("/name".into(), "Pending state rename".into()),
|
||||
);
|
||||
let raw = raw_property(&f.app);
|
||||
let before = f.app.document.snapshot();
|
||||
let revision = f.app.document.revision();
|
||||
send(&mut f.app, Message::DuplicateDiagram(Id::new_v4()));
|
||||
assert_eq!(f.app.project(), &before.decoded.project);
|
||||
assert_eq!(f.app.document.bundle(), &before.bundle);
|
||||
assert_eq!(f.app.document.revision(), revision);
|
||||
assert_eq!(raw_property(&f.app), raw);
|
||||
assert_eq!(f.app.selected, f.state);
|
||||
assert!(!f.app.document.can_undo());
|
||||
assert!(f.app.status.contains("no longer exists"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_rejection_preserves_invalid_property_and_pending_behavior_drafts() {
|
||||
let mut f = fixture();
|
||||
send(
|
||||
&mut f.app,
|
||||
Message::Field("/alias".into(), "invalid alias!".into()),
|
||||
);
|
||||
let before = f.app.document.snapshot();
|
||||
let raw = raw_property(&f.app);
|
||||
send(&mut f.app, Message::DuplicateDiagram(f.states));
|
||||
assert_eq!(f.app.project(), &before.decoded.project);
|
||||
assert_eq!(f.app.document.bundle(), &before.bundle);
|
||||
assert_eq!(raw_property(&f.app), raw);
|
||||
assert!(!f.app.document.can_undo());
|
||||
|
||||
let mut f = fixture();
|
||||
send(
|
||||
&mut f.app,
|
||||
Message::Behavior(crate::behavior::Event::States(
|
||||
crate::behavior::states::Event::Start(
|
||||
f.machine,
|
||||
crate::behavior::states::Tool::Vertex,
|
||||
f.region,
|
||||
),
|
||||
)),
|
||||
);
|
||||
send(
|
||||
&mut f.app,
|
||||
Message::Behavior(crate::behavior::Event::States(
|
||||
crate::behavior::states::Event::Name("Unfinished nested state".into()),
|
||||
)),
|
||||
);
|
||||
assert!(f.app.behavior.has_pending());
|
||||
let raw = f.app.behavior.recovery_snapshot().unwrap();
|
||||
let before = f.app.document.snapshot();
|
||||
send(&mut f.app, Message::DuplicateDiagram(f.states));
|
||||
assert_eq!(f.app.project(), &before.decoded.project);
|
||||
assert_eq!(f.app.document.bundle(), &before.bundle);
|
||||
assert_eq!(f.app.behavior.recovery_snapshot().unwrap(), raw);
|
||||
assert_eq!(f.app.state_context().unwrap().diagram, f.states);
|
||||
assert!(!f.app.document.can_undo());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_rejection_preserves_an_incomplete_source_buffer() {
|
||||
let mut f = fixture();
|
||||
let raw = "arcadia 6; project unfinished \"Unfinished source\" {".to_owned();
|
||||
send(
|
||||
&mut f.app,
|
||||
Message::SourceEdit(text_editor::Action::SelectAll),
|
||||
);
|
||||
send(
|
||||
&mut f.app,
|
||||
Message::SourceEdit(text_editor::Action::Edit(text_editor::Edit::Paste(
|
||||
Arc::new(raw.clone()),
|
||||
))),
|
||||
);
|
||||
assert!(f.app.source_dirty());
|
||||
let before = f.app.document.snapshot();
|
||||
let source = f.app.source_workspace.bundle();
|
||||
send(&mut f.app, Message::DuplicateDiagram(f.states));
|
||||
assert_eq!(f.app.project(), &before.decoded.project);
|
||||
assert_eq!(f.app.document.bundle(), &before.bundle);
|
||||
assert_eq!(f.app.source_workspace.bundle(), source);
|
||||
assert_eq!(f.app.source_workspace.text(), raw);
|
||||
assert!(!f.app.document.can_undo());
|
||||
}
|
||||
@@ -773,7 +773,9 @@ pub fn selected_behavior(p: &Project, selected: Id) -> Option<Id> {
|
||||
.filter(|r| {
|
||||
matches!(
|
||||
r.kind(),
|
||||
RelationKind::StateTransition | RelationKind::SequenceLink
|
||||
RelationKind::StateTransition
|
||||
| RelationKind::SequenceLink
|
||||
| RelationKind::StateAction
|
||||
)
|
||||
})
|
||||
.map(|r| r.source())
|
||||
|
||||
@@ -669,6 +669,7 @@ fn exchanges(p: &Project, d: &Draft) -> Vec<Choice> {
|
||||
&& matches!(
|
||||
r.kind(),
|
||||
RelationKind::Interaction
|
||||
| RelationKind::CommunicationMean
|
||||
| RelationKind::FunctionalExchange
|
||||
| RelationKind::BoundaryExchange
|
||||
)
|
||||
@@ -677,7 +678,9 @@ fn exchanges(p: &Project, d: &Draft) -> Vec<Choice> {
|
||||
ScenarioViewpoint::Functional => r.kind() == RelationKind::FunctionalExchange,
|
||||
ScenarioViewpoint::ParticipantInteraction => matches!(
|
||||
r.kind(),
|
||||
RelationKind::Interaction | RelationKind::BoundaryExchange
|
||||
RelationKind::Interaction
|
||||
| RelationKind::CommunicationMean
|
||||
| RelationKind::BoundaryExchange
|
||||
),
|
||||
ScenarioViewpoint::BoundaryInterface => matches!(
|
||||
r.kind(),
|
||||
@@ -687,17 +690,37 @@ fn exchanges(p: &Project, d: &Draft) -> Vec<Choice> {
|
||||
}
|
||||
&& (!d.fixed_endpoints
|
||||
|| d.sender.zip(d.receiver).is_some_and(|(sender, receiver)| {
|
||||
validation::lifeline_matches_endpoint(p, sender, r.source()) == Some(true)
|
||||
&& validation::lifeline_matches_endpoint(p, receiver, r.target())
|
||||
== Some(true)
|
||||
let mut body = d.message_body();
|
||||
if let OccurrenceBody::Exchange { exchange, .. } = &mut body {
|
||||
*exchange = Some(r.id);
|
||||
}
|
||||
validation::message_endpoints(p, &body).is_some_and(|(source, target)| {
|
||||
validation::lifeline_matches_endpoint(p, sender, source) == Some(true)
|
||||
&& validation::lifeline_matches_endpoint(p, receiver, target)
|
||||
== Some(true)
|
||||
})
|
||||
}))
|
||||
})
|
||||
.map(|r| Choice {
|
||||
id: r.id,
|
||||
label: format!(
|
||||
"{} · {} → {}",
|
||||
"{} · {} {} {}",
|
||||
r.name,
|
||||
p.named(r.source()),
|
||||
if matches!(
|
||||
r.body,
|
||||
RelationBody::CommunicationMean {
|
||||
oriented: false,
|
||||
..
|
||||
} | RelationBody::BoundaryExchange {
|
||||
oriented: false,
|
||||
..
|
||||
}
|
||||
) {
|
||||
"↔"
|
||||
} else {
|
||||
"→"
|
||||
},
|
||||
p.named(r.target())
|
||||
),
|
||||
})
|
||||
@@ -837,6 +860,38 @@ impl Draft {
|
||||
lines(p, self.owner)
|
||||
.into_iter()
|
||||
.filter(|line| {
|
||||
if self
|
||||
.exchange
|
||||
.and_then(|id| p.relations.get(&id))
|
||||
.is_some_and(|r| {
|
||||
matches!(
|
||||
r.body,
|
||||
RelationBody::CommunicationMean { .. }
|
||||
| RelationBody::BoundaryExchange {
|
||||
oriented: false,
|
||||
..
|
||||
}
|
||||
)
|
||||
})
|
||||
{
|
||||
let mut body = self.message_body();
|
||||
if let OccurrenceBody::Exchange {
|
||||
sender: from,
|
||||
receiver: to,
|
||||
..
|
||||
} = &mut body
|
||||
{
|
||||
if sender {
|
||||
*from = Some(line.id);
|
||||
} else {
|
||||
*to = Some(line.id);
|
||||
}
|
||||
}
|
||||
return !matches!(
|
||||
validation::resolve_message_endpoints(p, &body),
|
||||
validation::MessageEndpointResolution::Conflict
|
||||
);
|
||||
}
|
||||
endpoint.is_none_or(|endpoint| {
|
||||
validation::lifeline_matches_endpoint(p, line.id, endpoint) == Some(true)
|
||||
})
|
||||
@@ -2315,6 +2370,81 @@ pub(crate) mod direct_tests {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn reverse_communication_canvas_choices_and_correlated_reply_share_core_direction() {
|
||||
let mut p = Project::empty();
|
||||
let a = p
|
||||
.add_element(Kind::Entity, Layer::Oa, "Crew", None)
|
||||
.unwrap();
|
||||
let b = p
|
||||
.add_element(Kind::Actor, Layer::Oa, "Station", None)
|
||||
.unwrap();
|
||||
let exchange = p.relate(RelationKind::CommunicationMean, "Radio", a, b);
|
||||
let owner = scenario(&mut p, "Participant dialogue");
|
||||
if let Some(SharedBody::Scenario { viewpoint, .. }) =
|
||||
p.elements.get_mut(&owner).unwrap().shared_mut()
|
||||
{
|
||||
*viewpoint = ScenarioViewpoint::ParticipantInteraction;
|
||||
}
|
||||
let la = lifeline(&mut p, owner, a);
|
||||
let lb = lifeline(&mut p, owner, b);
|
||||
let mut state = State::default();
|
||||
state
|
||||
.begin_message(
|
||||
&p,
|
||||
direct_tests::context(owner),
|
||||
Slot {
|
||||
parent: owner,
|
||||
before: None,
|
||||
},
|
||||
lb,
|
||||
la,
|
||||
MessageStyle::CallReply,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
exchanges(&p, state.draft.as_ref().unwrap())
|
||||
.iter()
|
||||
.any(|c| c.id == exchange)
|
||||
);
|
||||
state
|
||||
.update(&p, Event::Exchange(named_choice(&p, exchange)))
|
||||
.unwrap();
|
||||
let mut document =
|
||||
crate::document::Document::from_project(p.clone(), Default::default()).unwrap();
|
||||
let original = document.bundle().clone();
|
||||
document
|
||||
.execute(state.prepare_pending(&p).unwrap())
|
||||
.unwrap();
|
||||
let accepted = document.project();
|
||||
let steps = accepted.elements[&owner].steps().unwrap();
|
||||
assert_eq!(steps.len(), 2);
|
||||
for (id, expected) in [(steps[0], (b, a)), (steps[1], (a, b))] {
|
||||
let Some(SharedBody::Occurrence { body, .. }) = accepted.elements[&id].shared() else {
|
||||
unreachable!()
|
||||
};
|
||||
assert_eq!(
|
||||
validation::message_endpoints(accepted, body),
|
||||
Some(expected)
|
||||
);
|
||||
}
|
||||
let after = document.bundle().clone();
|
||||
assert!(document.undo());
|
||||
assert_eq!(document.bundle(), &original);
|
||||
assert!(document.redo());
|
||||
assert_eq!(document.bundle(), &after);
|
||||
let mut directed = p.clone();
|
||||
if let RelationBody::CommunicationMean { oriented, .. } =
|
||||
&mut directed.relations.get_mut(&exchange).unwrap().body
|
||||
{
|
||||
*oriented = true;
|
||||
}
|
||||
assert!(
|
||||
!exchanges(&directed, state.draft.as_ref().unwrap())
|
||||
.iter()
|
||||
.any(|c| c.id == exchange)
|
||||
);
|
||||
}
|
||||
fn scenario(p: &mut Project, name: &str) -> Id {
|
||||
let mut e = p
|
||||
.make_element(Kind::Scenario, Layer::Oa, name, None)
|
||||
|
||||
@@ -1538,6 +1538,7 @@ mod tests {
|
||||
projection: ProjectionKind::StateMachine { machine },
|
||||
nodes: BTreeMap::new(),
|
||||
routes: vec![],
|
||||
view_options: Default::default(),
|
||||
},
|
||||
);
|
||||
for (element, x, y, width, height) in [
|
||||
|
||||
@@ -293,6 +293,7 @@ pub fn create_view(project: &Project, machine: Id) -> Result<Plan, ModelError> {
|
||||
projection: ProjectionKind::StateMachine { machine },
|
||||
nodes: BTreeMap::new(),
|
||||
routes: vec![],
|
||||
view_options: Default::default(),
|
||||
},
|
||||
);
|
||||
let members: Vec<_> = project
|
||||
|
||||
@@ -41,6 +41,7 @@ pub fn popup(app: &App, opened: &Opened, clean: bool) -> Option<Menu<Message>> {
|
||||
if let Some(context) = context.filter(|context| context.diagram == id) {
|
||||
entries.extend([
|
||||
item("View properties", Message::EditProperties(id), true),
|
||||
item("Duplicate view", Message::DuplicateDiagram(id), true),
|
||||
item("Fit diagram", diagram_event(Event::Fit(context)), true),
|
||||
item(
|
||||
"Create another state view",
|
||||
|
||||
@@ -6,6 +6,7 @@ use std::collections::BTreeSet;
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Tool {
|
||||
Interaction,
|
||||
CommunicationMean,
|
||||
FunctionalExchange,
|
||||
BoundaryExchange,
|
||||
}
|
||||
@@ -13,7 +14,7 @@ pub enum Tool {
|
||||
impl Tool {
|
||||
pub fn for_layer(layer: Layer) -> &'static [Self] {
|
||||
match layer {
|
||||
Layer::Oa => &[Self::Interaction],
|
||||
Layer::Oa => &[Self::Interaction, Self::CommunicationMean],
|
||||
Layer::Sa => &[Self::FunctionalExchange, Self::BoundaryExchange],
|
||||
}
|
||||
}
|
||||
@@ -21,6 +22,7 @@ impl Tool {
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Interaction => "Interaction",
|
||||
Self::CommunicationMean => "Communication means",
|
||||
Self::FunctionalExchange => "Functional exchange",
|
||||
Self::BoundaryExchange => "Boundary exchange",
|
||||
}
|
||||
@@ -29,6 +31,9 @@ impl Tool {
|
||||
pub fn hint(self) -> &'static str {
|
||||
match self {
|
||||
Self::Interaction => "Click a source activity, then a target activity.",
|
||||
Self::CommunicationMean => {
|
||||
"Click an operational entity or actor, then another participant. Communication is bidirectional unless you change its orientation."
|
||||
}
|
||||
Self::FunctionalExchange => {
|
||||
"Click an output port or source function, then an input port or target function. Function cards use automatic ports."
|
||||
}
|
||||
@@ -40,6 +45,9 @@ impl Tool {
|
||||
|
||||
pub fn can_start(self, project: &Project, source: Id) -> bool {
|
||||
match self {
|
||||
Self::CommunicationMean => project.elements.get(&source).is_some_and(|e| {
|
||||
e.layer() == Layer::Oa && matches!(e.kind(), Kind::Entity | Kind::Actor)
|
||||
}),
|
||||
Self::Interaction => project
|
||||
.elements
|
||||
.get(&source)
|
||||
@@ -57,6 +65,9 @@ impl Tool {
|
||||
return false;
|
||||
}
|
||||
match self {
|
||||
Self::CommunicationMean => {
|
||||
validation::can_connect(project, RelationKind::CommunicationMean, source, target)
|
||||
}
|
||||
Self::Interaction => {
|
||||
validation::can_connect(project, RelationKind::Interaction, source, target)
|
||||
}
|
||||
@@ -129,6 +140,9 @@ impl Tool {
|
||||
}
|
||||
let name = engineering_name(project, self, source, target);
|
||||
let body = match self {
|
||||
Self::CommunicationMean => {
|
||||
RelationBody::new(RelationKind::CommunicationMean, source, target)
|
||||
}
|
||||
Self::Interaction => RelationBody::new(RelationKind::Interaction, source, target),
|
||||
Self::BoundaryExchange => RelationBody::BoundaryExchange {
|
||||
source,
|
||||
@@ -210,7 +224,9 @@ fn engineering_name(project: &Project, tool: Tool, source: Id, target: Id) -> St
|
||||
project.named(id).to_owned()
|
||||
}
|
||||
};
|
||||
let arrow = if tool == Tool::BoundaryExchange && !boundary_oriented(project, source) {
|
||||
let arrow = if tool == Tool::CommunicationMean
|
||||
|| (tool == Tool::BoundaryExchange && !boundary_oriented(project, source))
|
||||
{
|
||||
"—"
|
||||
} else {
|
||||
"→"
|
||||
@@ -273,7 +289,10 @@ mod tests {
|
||||
let actor = element(&mut p, Kind::Actor, Layer::Oa, "Dispatcher");
|
||||
let function = element(&mut p, Kind::Function, Layer::Sa, "Assess report");
|
||||
let before = p.clone();
|
||||
assert_eq!(Tool::for_layer(Layer::Oa), &[Tool::Interaction]);
|
||||
assert_eq!(
|
||||
Tool::for_layer(Layer::Oa),
|
||||
&[Tool::Interaction, Tool::CommunicationMean]
|
||||
);
|
||||
assert_eq!(
|
||||
Tool::Interaction.targets(&p, source),
|
||||
BTreeSet::from([target])
|
||||
@@ -289,6 +308,27 @@ mod tests {
|
||||
assert_eq!(p, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn operational_communication_uses_participants_without_creating_ports() {
|
||||
let mut p = Project::empty();
|
||||
let a = element(&mut p, Kind::Entity, Layer::Oa, "Field team");
|
||||
let b = element(&mut p, Kind::Actor, Layer::Oa, "Coordinator");
|
||||
let activity = element(&mut p, Kind::Activity, Layer::Oa, "Report");
|
||||
let sa = element(&mut p, Kind::System, Layer::Sa, "Controller");
|
||||
let before = p.clone();
|
||||
assert_eq!(Tool::CommunicationMean.targets(&p, a), BTreeSet::from([b]));
|
||||
for invalid in [a, activity, sa, Id::new_v4()] {
|
||||
assert!(Tool::CommunicationMean.command(&p, a, invalid).is_err());
|
||||
}
|
||||
let command = Tool::CommunicationMean.command(&p, a, b).unwrap();
|
||||
let (accepted, relation) = created(&p, &command);
|
||||
assert!(matches!(relation.body, RelationBody::CommunicationMean {
|
||||
source, target, oriented: false, ref items,
|
||||
} if source == a && target == b && items.is_empty()));
|
||||
assert_eq!(accepted.elements, p.elements);
|
||||
assert_eq!(p, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_function_ports_keep_owners_contracts_and_do_not_invent_carried_items() {
|
||||
let mut p = Project::empty();
|
||||
@@ -532,6 +572,8 @@ mod tests {
|
||||
let mut p = Project::empty();
|
||||
let oa_a = element(&mut p, Kind::Activity, Layer::Oa, "Detect incident");
|
||||
let oa_b = element(&mut p, Kind::Activity, Layer::Oa, "Assess incident");
|
||||
let oa_team = element(&mut p, Kind::Entity, Layer::Oa, "Response team");
|
||||
let oa_actor = element(&mut p, Kind::Actor, Layer::Oa, "Dispatcher");
|
||||
let sa_a = element(&mut p, Kind::Function, Layer::Sa, "Detect report");
|
||||
let sa_b = element(&mut p, Kind::Function, Layer::Sa, "Assess report");
|
||||
let system = element(&mut p, Kind::System, Layer::Sa, "System");
|
||||
@@ -547,6 +589,7 @@ mod tests {
|
||||
let mut document = Document::from_bundle(bundle, ProjectLimits::default()).unwrap();
|
||||
for (tool, source, target) in [
|
||||
(Tool::Interaction, oa_a, oa_b),
|
||||
(Tool::CommunicationMean, oa_team, oa_actor),
|
||||
(Tool::FunctionalExchange, sa_a, sa_b),
|
||||
(Tool::BoundaryExchange, boundary_a, boundary_b),
|
||||
] {
|
||||
|
||||
@@ -27,6 +27,9 @@ mod lifecycle_tests;
|
||||
#[cfg(test)]
|
||||
#[path = "diagram/port_hit_tests.rs"]
|
||||
mod port_hit_tests;
|
||||
#[cfg(test)]
|
||||
#[path = "diagram/view_options_tests.rs"]
|
||||
mod view_options_tests;
|
||||
|
||||
#[path = "diagram/port_labels.rs"]
|
||||
mod port_labels;
|
||||
@@ -35,6 +38,7 @@ pub use port_labels::PortLabels;
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct DiagramCanvas<'a> {
|
||||
pub project: &'a Project,
|
||||
pub view_index: Option<&'a oa_core::view_options::ViewIndex>,
|
||||
pub diagram: Id,
|
||||
pub selected: Id,
|
||||
pub zoom: f32,
|
||||
@@ -429,7 +433,15 @@ impl DiagramCanvas<'_> {
|
||||
}
|
||||
#[cfg(test)]
|
||||
fn preview_view(&self, state: &State) -> ProjectedView {
|
||||
oa_core::projection::project_diagram(self.project, &self.preview_diagram(state))
|
||||
self.projected(&self.preview_diagram(state))
|
||||
}
|
||||
fn projected(&self, diagram: &Diagram) -> ProjectedView {
|
||||
match self.view_index {
|
||||
Some(index) => {
|
||||
oa_core::projection::project_diagram_with_index(self.project, diagram, index)
|
||||
}
|
||||
None => oa_core::projection::project_diagram(self.project, diagram),
|
||||
}
|
||||
}
|
||||
fn scene(&self, state: &State) -> Arc<Scene> {
|
||||
let preview = if state.context == Some(self.context) {
|
||||
@@ -460,7 +472,7 @@ impl DiagramCanvas<'_> {
|
||||
} else {
|
||||
self.project.diagrams[&self.diagram].clone()
|
||||
};
|
||||
let view = oa_core::projection::project_diagram(self.project, &diagram);
|
||||
let view = self.projected(&diagram);
|
||||
let routed = Arc::new(
|
||||
if let Some(gesture) = state
|
||||
.route_drag
|
||||
@@ -1837,6 +1849,7 @@ mod tests {
|
||||
};
|
||||
let canvas = DiagramCanvas {
|
||||
project: &p,
|
||||
view_index: None,
|
||||
context: Context {
|
||||
document: p.id,
|
||||
diagram,
|
||||
@@ -1909,6 +1922,7 @@ mod tests {
|
||||
.id;
|
||||
let canvas = DiagramCanvas {
|
||||
project: &p,
|
||||
view_index: None,
|
||||
context: Context {
|
||||
document: p.id,
|
||||
diagram,
|
||||
@@ -2032,6 +2046,7 @@ mod tests {
|
||||
let session = Session::new(&p, &view, context, Tool::FunctionalExchange);
|
||||
let canvas = DiagramCanvas {
|
||||
project: &p,
|
||||
view_index: None,
|
||||
diagram,
|
||||
context,
|
||||
selected: p.id,
|
||||
@@ -2198,6 +2213,7 @@ mod tests {
|
||||
let (p, diagram, edge) = route_fixture();
|
||||
let canvas = DiagramCanvas {
|
||||
project: &p,
|
||||
view_index: None,
|
||||
diagram,
|
||||
selected: edge,
|
||||
zoom,
|
||||
@@ -2271,6 +2287,7 @@ mod tests {
|
||||
let (p, diagram, edge) = route_fixture();
|
||||
let canvas = DiagramCanvas {
|
||||
project: &p,
|
||||
view_index: None,
|
||||
diagram,
|
||||
selected: edge,
|
||||
zoom: 0.5,
|
||||
@@ -2365,6 +2382,7 @@ mod tests {
|
||||
let (p, diagram, edge) = route_fixture();
|
||||
let mut canvas = DiagramCanvas {
|
||||
project: &p,
|
||||
view_index: None,
|
||||
diagram,
|
||||
selected: edge,
|
||||
zoom: 1.,
|
||||
@@ -2490,6 +2508,7 @@ mod tests {
|
||||
assert!(svg.contains("data-navigation=\"target\""));
|
||||
let mut canvas = DiagramCanvas {
|
||||
project: &p,
|
||||
view_index: None,
|
||||
diagram,
|
||||
selected: edge,
|
||||
zoom: 0.7,
|
||||
@@ -2541,6 +2560,7 @@ mod tests {
|
||||
});
|
||||
let mut canvas = DiagramCanvas {
|
||||
project: &p,
|
||||
view_index: None,
|
||||
diagram,
|
||||
selected: p.id,
|
||||
zoom: 1.,
|
||||
@@ -2612,6 +2632,7 @@ mod tests {
|
||||
});
|
||||
let canvas = DiagramCanvas {
|
||||
project: &p,
|
||||
view_index: None,
|
||||
diagram,
|
||||
selected: edge,
|
||||
zoom: 1.,
|
||||
@@ -2642,6 +2663,7 @@ mod tests {
|
||||
let selected = edge.max(duplicate); // UUID ordering must not override selection.
|
||||
let canvas = DiagramCanvas {
|
||||
project: &p,
|
||||
view_index: None,
|
||||
diagram,
|
||||
selected,
|
||||
zoom: 1.,
|
||||
|
||||
@@ -54,6 +54,7 @@ fn bounds() -> Rectangle {
|
||||
fn canvas(app: &App) -> DiagramCanvas<'_> {
|
||||
DiagramCanvas {
|
||||
project: app.project(),
|
||||
view_index: None,
|
||||
diagram: app.active_diagram.unwrap(),
|
||||
selected: app.selected,
|
||||
zoom: 1.,
|
||||
|
||||
@@ -62,6 +62,7 @@ fn screen(p: Position, zoom: f32) -> Point {
|
||||
fn canvas(app: &App, zoom: f32) -> DiagramCanvas<'_> {
|
||||
DiagramCanvas {
|
||||
project: app.project(),
|
||||
view_index: None,
|
||||
diagram: app.active_diagram.unwrap(),
|
||||
selected: app.selected,
|
||||
zoom,
|
||||
|
||||
@@ -3,6 +3,7 @@ use super::*;
|
||||
fn canvas(project: &Project, diagram: Id, zoom: f32) -> DiagramCanvas<'_> {
|
||||
DiagramCanvas {
|
||||
project,
|
||||
view_index: None,
|
||||
diagram,
|
||||
selected: Id::nil(),
|
||||
zoom,
|
||||
|
||||
@@ -67,7 +67,10 @@ impl canvas::Program<Message> for PortLabels<'_> {
|
||||
.as_ref()
|
||||
.is_none_or(|(context, _)| *context != canvas.context)
|
||||
{
|
||||
*cached = Some((canvas.context, project_view(canvas.project, canvas.diagram)));
|
||||
*cached = Some((
|
||||
canvas.context,
|
||||
canvas.projected(&canvas.project.diagrams[&canvas.diagram]),
|
||||
));
|
||||
}
|
||||
let view = &cached.as_ref().unwrap().1;
|
||||
let Some(port) = cursor
|
||||
@@ -172,6 +175,7 @@ mod tests {
|
||||
fn program(app: &App) -> PortLabels<'_> {
|
||||
PortLabels(DiagramCanvas {
|
||||
project: app.project(),
|
||||
view_index: None,
|
||||
diagram: app.active_diagram.unwrap(),
|
||||
selected: app.selected,
|
||||
zoom: 1.,
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
use super::*;
|
||||
use oa_core::view_options::{ExchangeLabels, RelationKinds};
|
||||
|
||||
#[test]
|
||||
fn communication_svg_uses_saved_visibility_labels_and_direction() {
|
||||
let mut p = Project::empty();
|
||||
let a = p
|
||||
.add_element(Kind::Entity, Layer::Oa, "Crew", None)
|
||||
.unwrap();
|
||||
let b = p
|
||||
.add_element(Kind::Actor, Layer::Oa, "Station", None)
|
||||
.unwrap();
|
||||
let item = p
|
||||
.add_element(Kind::ExchangeItem, Layer::Oa, "Data & status", None)
|
||||
.unwrap();
|
||||
let edge = p.relate(RelationKind::CommunicationMean, "Radio <link>", a, b);
|
||||
let RelationBody::CommunicationMean { items, .. } =
|
||||
&mut p.relations.get_mut(&edge).unwrap().body
|
||||
else {
|
||||
unreachable!()
|
||||
};
|
||||
items.push(item);
|
||||
let diagram = p.ensure_diagram(Layer::Oa);
|
||||
p.diagrams.get_mut(&diagram).unwrap().projection = ProjectionKind::Overview;
|
||||
p.place(diagram, a, Position { x: 50., y: 80. });
|
||||
p.place(diagram, b, Position { x: 650., y: 80. });
|
||||
validate_candidate(&p).unwrap();
|
||||
let initial = p.clone();
|
||||
let path_key = format!("<path data-edge=\"{edge}\"");
|
||||
let svg = export_svg(&p, diagram);
|
||||
let edge_path = svg
|
||||
.split(&path_key)
|
||||
.nth(1)
|
||||
.unwrap()
|
||||
.split("</path>")
|
||||
.next()
|
||||
.unwrap();
|
||||
assert!(!edge_path.contains("marker-end"));
|
||||
assert!(svg.contains("Radio <link>"));
|
||||
assert_eq!(
|
||||
p, initial,
|
||||
"Export must not author presentation or model changes"
|
||||
);
|
||||
p.diagrams
|
||||
.get_mut(&diagram)
|
||||
.unwrap()
|
||||
.view_options
|
||||
.exchange_labels = ExchangeLabels::Items;
|
||||
let items_svg = export_svg(&p, diagram);
|
||||
assert!(items_svg.contains("Data & status"));
|
||||
let RelationBody::CommunicationMean { oriented, .. } =
|
||||
&mut p.relations.get_mut(&edge).unwrap().body
|
||||
else {
|
||||
unreachable!()
|
||||
};
|
||||
*oriented = true;
|
||||
let svg = export_svg(&p, diagram);
|
||||
assert!(
|
||||
svg.split(&path_key)
|
||||
.nth(1)
|
||||
.unwrap()
|
||||
.split("</path>")
|
||||
.next()
|
||||
.unwrap()
|
||||
.contains("marker-end")
|
||||
);
|
||||
let placements = p.diagrams[&diagram].nodes.clone();
|
||||
p.diagrams
|
||||
.get_mut(&diagram)
|
||||
.unwrap()
|
||||
.view_options
|
||||
.hidden_edges
|
||||
.push(edge);
|
||||
assert!(!export_svg(&p, diagram).contains(&path_key));
|
||||
p.diagrams
|
||||
.get_mut(&diagram)
|
||||
.unwrap()
|
||||
.view_options
|
||||
.hidden_edges
|
||||
.clear();
|
||||
p.diagrams
|
||||
.get_mut(&diagram)
|
||||
.unwrap()
|
||||
.view_options
|
||||
.relation_kinds = RelationKinds::Only(vec![]);
|
||||
assert!(!export_svg(&p, diagram).contains(&path_key));
|
||||
assert_eq!(p.diagrams[&diagram].nodes, placements);
|
||||
assert_eq!(language::parse(&language::format(&p)).unwrap(), p);
|
||||
}
|
||||
@@ -97,6 +97,7 @@ pub struct Document {
|
||||
current_bytes: usize,
|
||||
saved: Arc<SourceBundle>,
|
||||
revision: u64,
|
||||
view_index: std::cell::OnceCell<oa_core::view_options::ViewIndex>,
|
||||
limits: ProjectLimits,
|
||||
recovered_unsaved: bool,
|
||||
}
|
||||
@@ -126,6 +127,7 @@ impl Document {
|
||||
future: Vec::new(),
|
||||
saved: Arc::new(bundle),
|
||||
revision: 0,
|
||||
view_index: Default::default(),
|
||||
limits,
|
||||
recovered_unsaved: false,
|
||||
})
|
||||
@@ -135,6 +137,12 @@ impl Document {
|
||||
&self.current.decoded.project
|
||||
}
|
||||
|
||||
/// Semantic reachability is shared by every diagram at this accepted revision.
|
||||
pub fn view_index(&self) -> &oa_core::view_options::ViewIndex {
|
||||
self.view_index
|
||||
.get_or_init(|| oa_core::view_options::ViewIndex::new(self.project()))
|
||||
}
|
||||
|
||||
pub fn bundle(&self) -> &SourceBundle {
|
||||
&self.current.bundle
|
||||
}
|
||||
@@ -220,6 +228,7 @@ impl Document {
|
||||
// Source-position readers must reject their old revision even though
|
||||
// changing the save location does not create an Undo transaction.
|
||||
self.revision = self.revision.saturating_add(1);
|
||||
self.view_index.take();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -349,6 +358,7 @@ impl Document {
|
||||
bytes: std::mem::replace(&mut self.current_bytes, previous.bytes),
|
||||
});
|
||||
self.revision = self.revision.saturating_add(1);
|
||||
self.view_index.take();
|
||||
true
|
||||
}
|
||||
|
||||
@@ -361,6 +371,7 @@ impl Document {
|
||||
bytes: std::mem::replace(&mut self.current_bytes, next.bytes),
|
||||
});
|
||||
self.revision = self.revision.saturating_add(1);
|
||||
self.view_index.take();
|
||||
true
|
||||
}
|
||||
|
||||
@@ -382,6 +393,7 @@ impl Document {
|
||||
retained = retained.saturating_sub(self.past.remove(0).bytes);
|
||||
}
|
||||
self.revision = self.revision.saturating_add(1);
|
||||
self.view_index.take();
|
||||
true
|
||||
}
|
||||
}
|
||||
@@ -399,6 +411,37 @@ mod tests {
|
||||
Document::from_project(Project::empty(), ProjectLimits::default()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cached_view_index_tracks_accepted_creation_undo_and_redo() {
|
||||
use oa_core::{Relation, RelationBody, RelationKind, view_options::ViewOptions};
|
||||
let mut p = Project::empty();
|
||||
let source = p
|
||||
.add_element(Kind::Entity, Layer::Oa, "Team", None)
|
||||
.unwrap();
|
||||
let target = p
|
||||
.add_element(Kind::Actor, Layer::Oa, "Dispatcher", None)
|
||||
.unwrap();
|
||||
let mut document = Document::from_project(p, ProjectLimits::default()).unwrap();
|
||||
let id = Id::new_v4();
|
||||
let options = ViewOptions::default();
|
||||
let visible = |d: &Document| d.view_index().prepare(Layer::Oa, &options).includes(id);
|
||||
assert!(!visible(&document));
|
||||
document
|
||||
.execute(Command::CreateRelation(Relation {
|
||||
id,
|
||||
alias: "radio".into(),
|
||||
name: "Radio".into(),
|
||||
description: String::new(),
|
||||
body: RelationBody::new(RelationKind::CommunicationMean, source, target),
|
||||
}))
|
||||
.unwrap();
|
||||
assert!(visible(&document));
|
||||
assert!(document.undo());
|
||||
assert!(!visible(&document));
|
||||
assert!(document.redo());
|
||||
assert!(visible(&document));
|
||||
}
|
||||
|
||||
fn comment(bundle: &SourceBundle, text: &str) -> SourceBundle {
|
||||
let mut changed = bundle.clone();
|
||||
changed
|
||||
|
||||
@@ -16,6 +16,7 @@ use serde_json::{Value, json};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
mod data_fields;
|
||||
mod port_fields;
|
||||
mod view_fields;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum RecordKind {
|
||||
@@ -37,6 +38,11 @@ pub struct ValueBrowser {
|
||||
pub scope: Option<crate::data_values::FieldPathKey>,
|
||||
pub page: usize,
|
||||
}
|
||||
#[derive(Clone, Default)]
|
||||
pub struct ReferenceBrowser {
|
||||
pub query: String,
|
||||
pub page: usize,
|
||||
}
|
||||
|
||||
pub struct Draft {
|
||||
pub kind: RecordKind,
|
||||
@@ -50,6 +56,7 @@ pub struct Draft {
|
||||
constructor_drafts: BTreeMap<(String, String), (Value, BTreeMap<String, Input>)>,
|
||||
port_contract_drafts: BTreeMap<String, Value>,
|
||||
pub value_browsers: BTreeMap<String, ValueBrowser>,
|
||||
pub reference_browsers: BTreeMap<String, ReferenceBrowser>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
@@ -171,6 +178,7 @@ impl Draft {
|
||||
constructor_drafts: BTreeMap::new(),
|
||||
port_contract_drafts: BTreeMap::new(),
|
||||
value_browsers: BTreeMap::new(),
|
||||
reference_browsers: BTreeMap::new(),
|
||||
};
|
||||
draft.rebuild();
|
||||
Some(draft)
|
||||
@@ -653,7 +661,7 @@ fn friendly(name: &str) -> String {
|
||||
"incoming_items" => "Incoming exchange items".into(),
|
||||
"outgoing_items" => "Outgoing exchange items".into(),
|
||||
"orientation" => "Flow orientation".into(),
|
||||
"oriented" => "Directed component exchange".into(),
|
||||
"oriented" => "Directed exchange".into(),
|
||||
"Unset" => "Not specified".into(),
|
||||
"minimum" => "Minimum count".into(),
|
||||
"maximum" => "Maximum count".into(),
|
||||
@@ -986,6 +994,7 @@ fn reference_ids(draft: &Draft, project: &Project, path: &str) -> Vec<Id> {
|
||||
_ => matches!(
|
||||
r.kind(),
|
||||
RelationKind::Interaction
|
||||
| RelationKind::CommunicationMean
|
||||
| RelationKind::FunctionalExchange
|
||||
| RelationKind::BoundaryExchange
|
||||
),
|
||||
@@ -1656,6 +1665,7 @@ pub fn view<'a>(draft: &'a Draft, project: &'a Project) -> View<'a, Message> {
|
||||
"baseline",
|
||||
"layer",
|
||||
"projection",
|
||||
"view_options",
|
||||
"element",
|
||||
"diagram",
|
||||
"position",
|
||||
@@ -1701,6 +1711,9 @@ fn property<'a>(
|
||||
label: &str,
|
||||
skip_variant: bool,
|
||||
) -> View<'a, Message> {
|
||||
if let Some(view) = view_fields::property(draft, project, value, path) {
|
||||
return view;
|
||||
}
|
||||
if let Some(view) = data_fields::property(draft, project, value, path) {
|
||||
return view;
|
||||
}
|
||||
@@ -2563,6 +2576,7 @@ mod tests {
|
||||
layer: Layer::Oa,
|
||||
projection: ProjectionKind::Overview,
|
||||
routes: Vec::new(),
|
||||
view_options: Default::default(),
|
||||
nodes: BTreeMap::new(),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -519,11 +519,12 @@ mod tests {
|
||||
};
|
||||
state
|
||||
}
|
||||
fn semantic_send(app: &mut crate::app::App, event: crate::semantic_review::Event) {
|
||||
fn semantic_send(app: &mut crate::app::App, event: crate::semantic_review::RowEvent) {
|
||||
let event = semantic_state(app).address_event(event);
|
||||
let _ = app.update(Message::Workflow(crate::workflow::Event::Semantic(event)));
|
||||
}
|
||||
fn semantic_decide(app: &mut crate::app::App, facet: FieldFacet, decision: SemanticDecision) {
|
||||
use crate::semantic_review::Event;
|
||||
use crate::semantic_review::RowEvent as Event;
|
||||
semantic_send(app, Event::Select(FacetSelector::Field(facet)));
|
||||
semantic_send(
|
||||
app,
|
||||
@@ -548,7 +549,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn semantic_reference_choices_follow_selected_mechanism_and_revert_on_keep() {
|
||||
use crate::semantic_review::{Event, State};
|
||||
use crate::semantic_review::{RowEvent as Event, State};
|
||||
let mut p = Project::empty();
|
||||
let oa = p
|
||||
.add_element(Kind::ExchangeItem, Layer::Oa, "Source", None)
|
||||
@@ -610,7 +611,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn independent_review_combined_take_type_informs_actual_default_form_in_both_directions() {
|
||||
use crate::semantic_review::Event;
|
||||
use crate::semantic_review::RowEvent as Event;
|
||||
for reverse in [false, true] {
|
||||
for decision in [SemanticDecision::TakeSource, SemanticDecision::Candidate] {
|
||||
let mut p = Project::empty();
|
||||
|
||||
@@ -0,0 +1,645 @@
|
||||
//! Closed view intent and named category membership through ordinary property drafts.
|
||||
use super::*;
|
||||
use iced::widget::column;
|
||||
use oa_core::view_options::{
|
||||
self, CategorySelection, ContextSelection, Coverage, EdgeAvailability, EdgeKind,
|
||||
ExchangeFamily, ExchangeLabels, OmissionReason, RelationKinds, ViewIndex, ViewOptions,
|
||||
};
|
||||
|
||||
const PAGE_SIZE: usize = 24;
|
||||
|
||||
struct Candidate {
|
||||
id: Id,
|
||||
label: String,
|
||||
detail: String,
|
||||
}
|
||||
|
||||
fn choice<'a>(
|
||||
label: &str,
|
||||
path: &str,
|
||||
selected: &'static str,
|
||||
choices: Vec<(&'static str, &'static str)>,
|
||||
) -> View<'a, Message> {
|
||||
let path = path.to_owned();
|
||||
let selected = choices
|
||||
.iter()
|
||||
.find(|(_, value)| *value == selected)
|
||||
.map(|(label, _)| *label);
|
||||
let labels: Vec<_> = choices.iter().map(|(label, _)| *label).collect();
|
||||
column![
|
||||
text(label.to_owned()).size(12).color(style::MUTED),
|
||||
pick_list(labels, selected, move |label| Message::FieldShape(
|
||||
path.clone(),
|
||||
json!(
|
||||
choices
|
||||
.iter()
|
||||
.find(|(candidate, _)| *candidate == label)
|
||||
.unwrap()
|
||||
.1
|
||||
)
|
||||
))
|
||||
.text_size(12)
|
||||
.padding(7)
|
||||
.width(Fill)
|
||||
]
|
||||
.spacing(5)
|
||||
.width(Fill)
|
||||
.into()
|
||||
}
|
||||
|
||||
fn named(project: &Project, id: Id, detail: impl Into<String>) -> Candidate {
|
||||
let alias = project
|
||||
.elements
|
||||
.get(&id)
|
||||
.map(|element| element.alias.as_str())
|
||||
.or_else(|| {
|
||||
project
|
||||
.relations
|
||||
.get(&id)
|
||||
.map(|relation| relation.alias.as_str())
|
||||
})
|
||||
.or_else(|| project.traces.get(&id).map(|trace| trace.alias.as_str()))
|
||||
.unwrap_or_default();
|
||||
Candidate {
|
||||
id,
|
||||
label: format!("{} · {}", project.named(id), alias),
|
||||
detail: detail.into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn list_value(ids: &[Id], wrapper: Option<&str>) -> Value {
|
||||
match wrapper {
|
||||
Some(wrapper) => json!({wrapper: ids}),
|
||||
None => json!(ids),
|
||||
}
|
||||
}
|
||||
|
||||
fn members<'a>(
|
||||
draft: &'a Draft,
|
||||
path: &str,
|
||||
selected: &[Id],
|
||||
mut candidates: Vec<Candidate>,
|
||||
wrapper: Option<&'static str>,
|
||||
verb: &'static str,
|
||||
) -> View<'a, Message> {
|
||||
// Preserve and expose now-ineligible identities until the engineer removes them.
|
||||
for id in selected {
|
||||
if !candidates.iter().any(|candidate| candidate.id == *id) {
|
||||
candidates.push(Candidate {
|
||||
id: *id,
|
||||
label: format!("Retained reference · {id}"),
|
||||
detail:
|
||||
"Missing or outside the chosen layer/family. Remove or correct before Apply."
|
||||
.into(),
|
||||
});
|
||||
}
|
||||
}
|
||||
candidates.sort_by_key(|candidate| (candidate.label.to_lowercase(), candidate.id));
|
||||
let browser = draft.reference_browsers.get(path);
|
||||
let query = browser.map(|browser| browser.query.as_str()).unwrap_or("");
|
||||
let search = query.to_lowercase();
|
||||
candidates.retain(|candidate| {
|
||||
format!("{} {} {}", candidate.label, candidate.id, candidate.detail)
|
||||
.to_lowercase()
|
||||
.contains(&search)
|
||||
});
|
||||
let page = browser
|
||||
.map(|browser| browser.page)
|
||||
.unwrap_or(0)
|
||||
.min(candidates.len().saturating_sub(1) / PAGE_SIZE);
|
||||
let start = page * PAGE_SIZE;
|
||||
let path_owned = path.to_owned();
|
||||
let mut out = column![
|
||||
text_input("Find by name, alias, identity or reason", query)
|
||||
.on_input(move |query| Message::FieldReferenceBrowse(path_owned.clone(), query, 0))
|
||||
.size(12)
|
||||
.padding(7),
|
||||
text(format!(
|
||||
"{} {} · {} matching",
|
||||
selected.len(),
|
||||
if verb == "Hide" {
|
||||
"explicitly hidden"
|
||||
} else {
|
||||
"selected"
|
||||
},
|
||||
candidates.len()
|
||||
))
|
||||
.size(11)
|
||||
.color(style::MUTED)
|
||||
]
|
||||
.spacing(6)
|
||||
.width(Fill);
|
||||
if candidates.is_empty() {
|
||||
out = out.push(text("No matching records.").size(12));
|
||||
}
|
||||
for candidate in candidates.iter().skip(start).take(PAGE_SIZE) {
|
||||
let checked = selected.contains(&candidate.id);
|
||||
let id = candidate.id;
|
||||
let selected = selected.to_vec();
|
||||
let path = path.to_owned();
|
||||
out = out.push(
|
||||
column![
|
||||
checkbox(checked)
|
||||
.label(format!("{verb} {}", candidate.label))
|
||||
.size(14)
|
||||
.text_size(12)
|
||||
.on_toggle(move |enabled| {
|
||||
let mut ids = selected.clone();
|
||||
ids.retain(|existing| *existing != id);
|
||||
if enabled {
|
||||
ids.push(id);
|
||||
}
|
||||
Message::FieldShape(path.clone(), list_value(&ids, wrapper))
|
||||
}),
|
||||
text(candidate.detail.clone()).size(11).color(style::MUTED)
|
||||
]
|
||||
.spacing(3)
|
||||
.width(Fill),
|
||||
);
|
||||
}
|
||||
if candidates.len() > PAGE_SIZE {
|
||||
let mut previous = button(text("Previous").size(11)).style(style::nav);
|
||||
if page > 0 {
|
||||
previous = previous.on_press(Message::FieldReferenceBrowse(
|
||||
path.into(),
|
||||
query.into(),
|
||||
page - 1,
|
||||
));
|
||||
}
|
||||
let mut next = button(text("Next").size(11)).style(style::nav);
|
||||
if start + PAGE_SIZE < candidates.len() {
|
||||
next = next.on_press(Message::FieldReferenceBrowse(
|
||||
path.into(),
|
||||
query.into(),
|
||||
page + 1,
|
||||
));
|
||||
}
|
||||
out = out.push(
|
||||
row![
|
||||
previous,
|
||||
text(format!(
|
||||
"Page {} of {}",
|
||||
page + 1,
|
||||
candidates.len().div_ceil(PAGE_SIZE)
|
||||
))
|
||||
.size(11),
|
||||
next
|
||||
]
|
||||
.spacing(6),
|
||||
);
|
||||
}
|
||||
out.into()
|
||||
}
|
||||
|
||||
fn categories<'a>(
|
||||
draft: &'a Draft,
|
||||
project: &Project,
|
||||
layer: Layer,
|
||||
path: &str,
|
||||
family: ExchangeFamily,
|
||||
selection: &CategorySelection,
|
||||
cross_layer: bool,
|
||||
) -> View<'a, Message> {
|
||||
let label = match family {
|
||||
ExchangeFamily::Work => "Work exchange categories",
|
||||
ExchangeFamily::Participant => "Participant exchange categories",
|
||||
};
|
||||
let all = matches!(selection, CategorySelection::All);
|
||||
let path_owned = path.to_owned();
|
||||
let mut out = column![
|
||||
text(label).size(13),
|
||||
checkbox(all)
|
||||
.label("All, including uncategorized exchanges")
|
||||
.text_size(12)
|
||||
.on_toggle(move |all| Message::FieldShape(
|
||||
path_owned.clone(),
|
||||
if all {
|
||||
json!(CategorySelection::All)
|
||||
} else {
|
||||
json!(CategorySelection::Any(vec![]))
|
||||
}
|
||||
))
|
||||
]
|
||||
.spacing(6)
|
||||
.width(Fill);
|
||||
if let CategorySelection::Any(selected) = selection {
|
||||
out = out.push(
|
||||
text("Selected categories combine by union. Selecting none shows none of this family.")
|
||||
.size(11)
|
||||
.color(style::MUTED),
|
||||
);
|
||||
let candidates = project
|
||||
.elements
|
||||
.values()
|
||||
.filter_map(|element| {
|
||||
let SharedBody::ExchangeCategory {
|
||||
family: candidate_family,
|
||||
exchanges,
|
||||
} = element.shared()?
|
||||
else {
|
||||
return None;
|
||||
};
|
||||
((cross_layer || element.layer() == layer) && *candidate_family == family).then(
|
||||
|| {
|
||||
named(
|
||||
project,
|
||||
element.id,
|
||||
format!("{} exchange members", exchanges.len()),
|
||||
)
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
out = out.push(members(
|
||||
draft,
|
||||
path,
|
||||
selected,
|
||||
candidates,
|
||||
Some("Any"),
|
||||
"Include",
|
||||
));
|
||||
}
|
||||
out.into()
|
||||
}
|
||||
|
||||
fn reason(reason: OmissionReason) -> &'static str {
|
||||
match reason {
|
||||
OmissionReason::ExplicitHide => "explicitly hidden",
|
||||
OmissionReason::RelationKind => "relationship kind excluded",
|
||||
OmissionReason::Category(ExchangeFamily::Work) => "outside selected work categories",
|
||||
OmissionReason::Category(ExchangeFamily::Participant) => {
|
||||
"outside selected participant categories"
|
||||
}
|
||||
OmissionReason::Coverage => "outside selected global behavior coverage",
|
||||
OmissionReason::WorkContext => "outside selected process, chain or scenario",
|
||||
OmissionReason::OutsideProjection => "outside this projection",
|
||||
OmissionReason::EndpointNotShown {
|
||||
source: true,
|
||||
target: true,
|
||||
} => "source and target are not shown",
|
||||
OmissionReason::EndpointNotShown {
|
||||
source: true,
|
||||
target: false,
|
||||
} => "source is not shown",
|
||||
OmissionReason::EndpointNotShown {
|
||||
source: false,
|
||||
target: true,
|
||||
} => "target is not shown",
|
||||
OmissionReason::EndpointNotShown {
|
||||
source: false,
|
||||
target: false,
|
||||
} => "endpoint placement unavailable",
|
||||
OmissionReason::FoldedInternal => "internal to a collapsed or grouped node",
|
||||
OmissionReason::WrongLayer => "outside this layer",
|
||||
OmissionReason::UnknownEdge => "edge no longer exists",
|
||||
}
|
||||
}
|
||||
|
||||
fn options<'a>(
|
||||
draft: &'a Draft,
|
||||
project: &Project,
|
||||
path: &str,
|
||||
options: ViewOptions,
|
||||
) -> View<'a, Message> {
|
||||
let diagram = serde_json::from_value::<Diagram>(draft.context_value())
|
||||
.ok()
|
||||
.or_else(|| project.diagrams.get(&draft.original).cloned());
|
||||
let Some(diagram) = diagram else {
|
||||
return text("View options require a saved view.").size(12).into();
|
||||
};
|
||||
let layer = diagram.layer;
|
||||
let cross_layer = diagram.projection == ProjectionKind::Traceability;
|
||||
let kinds_path = format!("{path}/relation_kinds");
|
||||
let all_kinds = matches!(options.relation_kinds, RelationKinds::All);
|
||||
let toggle_path = kinds_path.clone();
|
||||
let mut out = column![text("Saved view visibility").size(15),
|
||||
text("Apply changes to this view. Model relationships, nesting and saved routes remain available.").size(11).color(style::MUTED),
|
||||
choice("Exchange labels", &format!("{path}/exchange_labels"), match options.exchange_labels { ExchangeLabels::Name => "Name", ExchangeLabels::Items => "Items", ExchangeLabels::NameAndItems => "NameAndItems" }, vec![("Names", "Name"), ("Items", "Items"), ("Names and items", "NameAndItems")]),
|
||||
checkbox(all_kinds).label("All relationship line kinds").text_size(12).on_toggle(move |all| Message::FieldShape(toggle_path.clone(), if all { json!(RelationKinds::All) } else { json!(RelationKinds::Only(vec![])) }))].spacing(10).width(Fill);
|
||||
if let RelationKinds::Only(selected) = &options.relation_kinds {
|
||||
out = out.push(text("No selected kinds means no relationship lines. Nesting and state action rows remain.").size(11).color(style::MUTED));
|
||||
for kind in RelationKind::ALL
|
||||
.into_iter()
|
||||
.map(EdgeKind::Relation)
|
||||
.chain([EdgeKind::Trace])
|
||||
{
|
||||
let label = match kind {
|
||||
EdgeKind::Relation(kind) => kind.to_string(),
|
||||
EdgeKind::Trace => "Realization traces".into(),
|
||||
};
|
||||
let checked = selected.contains(&kind);
|
||||
let selected = selected.clone();
|
||||
let path = kinds_path.clone();
|
||||
out = out.push(checkbox(checked).label(label).text_size(12).on_toggle(
|
||||
move |enabled| {
|
||||
let mut kinds = selected.clone();
|
||||
kinds.retain(|existing| *existing != kind);
|
||||
if enabled {
|
||||
kinds.push(kind);
|
||||
}
|
||||
Message::FieldShape(path.clone(), json!(RelationKinds::Only(kinds)))
|
||||
},
|
||||
));
|
||||
}
|
||||
}
|
||||
out = out.push(categories(draft, project, layer, &format!("{path}/work_categories"), ExchangeFamily::Work, &options.work_categories, cross_layer))
|
||||
.push(categories(draft, project, layer, &format!("{path}/participant_categories"), ExchangeFamily::Participant, &options.participant_categories, cross_layer))
|
||||
.push(choice("Work exchange coverage across the whole model", &format!("{path}/coverage"), match options.coverage { Coverage::All => "All", Coverage::InBehavior => "InBehavior", Coverage::OutsideBehavior => "OutsideBehavior" }, vec![("All work exchanges", "All"), ("Used in behavior", "InBehavior"), ("Outside behavior", "OutsideBehavior")]))
|
||||
.push(text("Coverage uses explicit exchange occurrences in processes, chains and scenarios, including reuse. It does not infer execution or participant transport coverage.").size(11).color(style::MUTED));
|
||||
let context_path = format!("{path}/work_contexts");
|
||||
let toggle_path = context_path.clone();
|
||||
out = out.push(
|
||||
checkbox(matches!(options.work_contexts, ContextSelection::All))
|
||||
.label("All work contexts")
|
||||
.text_size(12)
|
||||
.on_toggle(move |all| {
|
||||
Message::FieldShape(
|
||||
toggle_path.clone(),
|
||||
if all {
|
||||
json!(ContextSelection::All)
|
||||
} else {
|
||||
json!(ContextSelection::Any(vec![]))
|
||||
},
|
||||
)
|
||||
}),
|
||||
);
|
||||
if let ContextSelection::Any(selected) = &options.work_contexts {
|
||||
let candidates = project
|
||||
.elements
|
||||
.values()
|
||||
.filter(|element| {
|
||||
(cross_layer || element.layer() == layer)
|
||||
&& matches!(
|
||||
element.kind(),
|
||||
Kind::Process | Kind::FunctionalChain | Kind::Scenario
|
||||
)
|
||||
})
|
||||
.map(|element| named(project, element.id, element.kind().label()))
|
||||
.collect();
|
||||
out = out.push(text("Show work exchanges used by any selected context, intersected with the coverage and category choices above. None selected shows no work exchanges.").size(11).color(style::MUTED))
|
||||
.push(members(draft, &context_path, selected, candidates, Some("Any"), "Include"));
|
||||
}
|
||||
// Both expensive indexes are built once for the panel, never once per edge.
|
||||
let index = ViewIndex::new(project);
|
||||
let filter = if cross_layer {
|
||||
index.prepare_cross_layer(layer, &options)
|
||||
} else {
|
||||
index.prepare(layer, &options)
|
||||
};
|
||||
let availability = projection::edge_availability(project, &diagram);
|
||||
let mut shown = 0usize;
|
||||
let candidates: Vec<_> = project
|
||||
.relations
|
||||
.keys()
|
||||
.chain(project.traces.keys())
|
||||
.filter(|id| {
|
||||
cross_layer || project.layer_of(**id) == Some(layer) || project.traces.contains_key(id)
|
||||
})
|
||||
.map(|id| {
|
||||
let reasons = filter.reasons(
|
||||
*id,
|
||||
availability
|
||||
.get(id)
|
||||
.copied()
|
||||
.unwrap_or(EdgeAvailability::OutsideProjection),
|
||||
);
|
||||
shown += usize::from(reasons.is_empty());
|
||||
named(
|
||||
project,
|
||||
*id,
|
||||
if reasons.is_empty() {
|
||||
"Shown by these settings".into()
|
||||
} else {
|
||||
reasons
|
||||
.into_iter()
|
||||
.map(reason)
|
||||
.collect::<Vec<_>>()
|
||||
.join("; ")
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
out = out.push(text("Individual lines and omission reasons").size(13))
|
||||
.push(text(format!("With these settings: {shown} graphical lines shown · {} lines omitted", candidates.len() - shown)).size(11).color(style::MUTED))
|
||||
.push(text("Check Hide to retain an explicit hide. Unchecking removes only that hide; other listed reasons can still omit the line.").size(11).color(style::MUTED))
|
||||
.push(button(text("Clear all explicit hides").size(11)).style(style::nav).on_press(Message::FieldShape(format!("{path}/hidden_edges"), json!([]))))
|
||||
.push(members(draft, &format!("{path}/hidden_edges"), &options.hidden_edges, candidates, None, "Hide"));
|
||||
out.into()
|
||||
}
|
||||
|
||||
fn category_members<'a>(
|
||||
draft: &'a Draft,
|
||||
project: &Project,
|
||||
path: &str,
|
||||
value: &Value,
|
||||
) -> View<'a, Message> {
|
||||
let context = draft.context_value();
|
||||
let parent = path
|
||||
.rsplit_once('/')
|
||||
.map(|(parent, _)| parent)
|
||||
.unwrap_or_default();
|
||||
let family = context
|
||||
.pointer(&format!("{parent}/family"))
|
||||
.and_then(|value| serde_json::from_value::<ExchangeFamily>(value.clone()).ok())
|
||||
.unwrap_or(ExchangeFamily::Work);
|
||||
let layer = project.layer_of(draft.original).unwrap_or(Layer::Oa);
|
||||
let Ok(selected) = serde_json::from_value::<Vec<Id>>(value.clone()) else {
|
||||
return column![text("The retained member list contains an invalid reference. Clear it explicitly or cancel this property draft before choosing members.").size(12),
|
||||
button(text("Clear invalid member list").size(11)).style(style::nav).on_press(Message::FieldShape(path.into(), json!([])))].spacing(6).width(Fill).into();
|
||||
};
|
||||
let candidates = project
|
||||
.relations
|
||||
.values()
|
||||
.filter(|relation| {
|
||||
view_options::validate_category(project, layer, None, family, &[relation.id]).is_empty()
|
||||
})
|
||||
.map(|relation| {
|
||||
named(
|
||||
project,
|
||||
relation.id,
|
||||
format!(
|
||||
"{} → {} · {}",
|
||||
project.named(relation.source()),
|
||||
project.named(relation.target()),
|
||||
relation.kind()
|
||||
),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
column![text("Exchange members").size(13), text("Membership is a reusable concern. It does not allocate exchanges, change endpoints or imply execution. An empty category is valid.").size(11).color(style::MUTED),
|
||||
members(draft, path, &selected, candidates, None, "Include")].spacing(6).width(Fill).into()
|
||||
}
|
||||
|
||||
pub(super) fn property<'a>(
|
||||
draft: &'a Draft,
|
||||
project: &'a Project,
|
||||
value: &'a Value,
|
||||
path: &str,
|
||||
) -> Option<View<'a, Message>> {
|
||||
if path == "/view_options" {
|
||||
return Some(match serde_json::from_value::<ViewOptions>(value.clone()) {
|
||||
Ok(value) => options(draft, project, path, value),
|
||||
Err(error) => text(format!("View settings are invalid: {error}. The draft remains available for correction in source.")).size(12).into(),
|
||||
});
|
||||
}
|
||||
if path.ends_with("/ExchangeCategory") {
|
||||
return Some(
|
||||
column![
|
||||
choice(
|
||||
"Exchange family",
|
||||
&format!("{path}/family"),
|
||||
if value.get("family").and_then(Value::as_str) == Some("Participant") {
|
||||
"Participant"
|
||||
} else {
|
||||
"Work"
|
||||
},
|
||||
vec![
|
||||
("Work exchanges", "Work"),
|
||||
("Participant exchanges", "Participant")
|
||||
]
|
||||
),
|
||||
category_members(
|
||||
draft,
|
||||
project,
|
||||
&format!("{path}/exchanges"),
|
||||
value.get("exchanges").unwrap_or(&Value::Null)
|
||||
)
|
||||
]
|
||||
.spacing(10)
|
||||
.width(Fill)
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
if path.contains("/ExchangeCategory/") {
|
||||
if path.ends_with("/family") {
|
||||
return Some(choice(
|
||||
"Exchange family",
|
||||
path,
|
||||
if value.as_str() == Some("Participant") {
|
||||
"Participant"
|
||||
} else {
|
||||
"Work"
|
||||
},
|
||||
vec![
|
||||
("Work exchanges", "Work"),
|
||||
("Participant exchanges", "Participant"),
|
||||
],
|
||||
));
|
||||
}
|
||||
if path.ends_with("/exchanges") {
|
||||
return Some(category_members(draft, project, path, value));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn selective_view_property_apply_recovery_and_undo_preserve_semantics_and_empty_choices() {
|
||||
let mut project = Project::empty();
|
||||
let a = project
|
||||
.add_element(Kind::Activity, Layer::Oa, "Request", None)
|
||||
.unwrap();
|
||||
let b = project
|
||||
.add_element(Kind::Activity, Layer::Oa, "Provide", None)
|
||||
.unwrap();
|
||||
let exchange = project.relate(RelationKind::Interaction, "Request data", a, b);
|
||||
let diagram = project.ensure_diagram(Layer::Oa);
|
||||
project.place(diagram, a, Position { x: 10., y: 20. });
|
||||
project.place(diagram, b, Position { x: 300., y: 20. });
|
||||
let mut draft = Draft::from_project(&project, diagram).unwrap();
|
||||
draft
|
||||
.shape(
|
||||
"/view_options/work_categories",
|
||||
list_value(&[], Some("Any")),
|
||||
)
|
||||
.unwrap();
|
||||
draft
|
||||
.shape("/view_options/hidden_edges", list_value(&[exchange], None))
|
||||
.unwrap();
|
||||
draft
|
||||
.shape("/view_options/exchange_labels", json!("NameAndItems"))
|
||||
.unwrap();
|
||||
draft.reference_browsers.insert(
|
||||
"/view_options/hidden_edges".into(),
|
||||
ReferenceBrowser {
|
||||
query: "Request".into(),
|
||||
page: 3,
|
||||
},
|
||||
);
|
||||
let restored = Draft::from_recovery(&project, draft.recovery_snapshot()).unwrap();
|
||||
assert_eq!(
|
||||
restored.materialize().unwrap(),
|
||||
draft.materialize().unwrap()
|
||||
);
|
||||
assert!(restored.reference_browsers.is_empty());
|
||||
let mut session = Session::new(project.clone());
|
||||
session.execute(restored.command().unwrap()).unwrap();
|
||||
let accepted = session.project();
|
||||
assert_eq!(accepted.elements, project.elements);
|
||||
assert_eq!(accepted.relations, project.relations);
|
||||
assert_eq!(
|
||||
accepted.diagrams[&diagram].nodes,
|
||||
project.diagrams[&diagram].nodes
|
||||
);
|
||||
assert_eq!(
|
||||
accepted.diagrams[&diagram].view_options.work_categories,
|
||||
CategorySelection::Any(vec![])
|
||||
);
|
||||
assert_eq!(
|
||||
accepted.diagrams[&diagram].view_options.hidden_edges,
|
||||
vec![exchange]
|
||||
);
|
||||
assert_eq!(
|
||||
language::parse(&language::format(accepted)).unwrap(),
|
||||
*accepted
|
||||
);
|
||||
session.undo();
|
||||
assert_eq!(session.project(), &project);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn category_family_change_retains_members_until_explicit_correction() {
|
||||
let mut project = Project::empty();
|
||||
let a = project
|
||||
.add_element(Kind::Activity, Layer::Oa, "Request", None)
|
||||
.unwrap();
|
||||
let b = project
|
||||
.add_element(Kind::Activity, Layer::Oa, "Provide", None)
|
||||
.unwrap();
|
||||
let exchange = project.relate(RelationKind::Interaction, "Request data", a, b);
|
||||
let id = project
|
||||
.add_element(Kind::ExchangeCategory, Layer::Oa, "Service concern", None)
|
||||
.unwrap();
|
||||
*project.elements.get_mut(&id).unwrap().shared_mut().unwrap() =
|
||||
SharedBody::ExchangeCategory {
|
||||
family: ExchangeFamily::Work,
|
||||
exchanges: vec![exchange],
|
||||
};
|
||||
let mut draft = Draft::from_project(&project, id).unwrap();
|
||||
let path = "/body/Oa/Shared/ExchangeCategory";
|
||||
draft
|
||||
.shape(&format!("{path}/family"), json!("Participant"))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
draft
|
||||
.materialize()
|
||||
.unwrap()
|
||||
.pointer(&format!("{path}/exchanges")),
|
||||
Some(&json!([exchange]))
|
||||
);
|
||||
assert!(execute_batch(&project, &[draft.command().unwrap()]).is_err());
|
||||
let mut restored = Draft::from_recovery(&project, draft.recovery_snapshot()).unwrap();
|
||||
restored
|
||||
.shape(&format!("{path}/exchanges"), list_value(&[], None))
|
||||
.unwrap();
|
||||
let accepted = execute_batch(&project, &[restored.command().unwrap()]).unwrap();
|
||||
assert!(
|
||||
matches!(accepted.elements[&id].shared(), Some(SharedBody::ExchangeCategory { family: ExchangeFamily::Participant, exchanges }) if exchanges.is_empty())
|
||||
);
|
||||
assert_eq!(accepted.relations, project.relations);
|
||||
}
|
||||
}
|
||||
+306
-23
@@ -25,8 +25,10 @@ pub enum Topic {
|
||||
States,
|
||||
Interfaces,
|
||||
Transitions,
|
||||
SemanticReview,
|
||||
Artifacts,
|
||||
Source,
|
||||
ModelChecks,
|
||||
Recovery,
|
||||
Files,
|
||||
Server,
|
||||
@@ -44,8 +46,12 @@ pub enum Event {
|
||||
Topic(Topic),
|
||||
}
|
||||
|
||||
pub(crate) const ARTICLE_SCROLL_ID: &str = "engineering-guide-article";
|
||||
|
||||
impl State {
|
||||
pub fn update(&mut self, event: Event) {
|
||||
/// Returns whether navigation selected a different article.
|
||||
pub fn update(&mut self, event: Event) -> bool {
|
||||
let previous = self.topic;
|
||||
match event {
|
||||
Event::Search(query) => {
|
||||
self.query = query;
|
||||
@@ -62,11 +68,12 @@ impl State {
|
||||
self.query.clear();
|
||||
}
|
||||
}
|
||||
self.topic != previous
|
||||
}
|
||||
}
|
||||
|
||||
impl Topic {
|
||||
const ALL: [Self; 16] = [
|
||||
const ALL: [Self; 18] = [
|
||||
Self::FirstProject,
|
||||
Self::Perspectives,
|
||||
Self::Participants,
|
||||
@@ -78,8 +85,10 @@ impl Topic {
|
||||
Self::States,
|
||||
Self::Interfaces,
|
||||
Self::Transitions,
|
||||
Self::SemanticReview,
|
||||
Self::Artifacts,
|
||||
Self::Source,
|
||||
Self::ModelChecks,
|
||||
Self::Recovery,
|
||||
Self::Files,
|
||||
Self::Server,
|
||||
@@ -98,9 +107,11 @@ impl Topic {
|
||||
Self::States => "States, modes & transitions",
|
||||
Self::Interfaces => "Data types, ports & interfaces",
|
||||
Self::Transitions => "Move between OA & SA",
|
||||
Self::SemanticReview => "Review semantic changes",
|
||||
Self::Artifacts => "Connect engineering evidence",
|
||||
Self::Source => "Textual editing & Git",
|
||||
Self::Recovery => "Checks & error recovery",
|
||||
Self::ModelChecks => "Read model checks",
|
||||
Self::Recovery => "Drafts & crash recovery",
|
||||
Self::Files => "Files & keyboard shortcuts",
|
||||
Self::Server => "Hosted models & REST API",
|
||||
}
|
||||
@@ -113,8 +124,10 @@ impl Topic {
|
||||
"MODEL THE SYSTEM"
|
||||
}
|
||||
Self::Chains | Self::Scenarios | Self::States | Self::Interfaces => "DESCRIBE BEHAVIOR",
|
||||
Self::Transitions | Self::Artifacts | Self::Source => "CONNECT YOUR WORK",
|
||||
Self::Recovery | Self::Files | Self::Server => "REFERENCE",
|
||||
Self::Transitions | Self::SemanticReview | Self::Artifacts | Self::Source => {
|
||||
"CONNECT YOUR WORK"
|
||||
}
|
||||
Self::ModelChecks | Self::Recovery | Self::Files | Self::Server => "REFERENCE",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -177,13 +190,23 @@ fn article(topic: Topic) -> Article {
|
||||
"Choose Entity under Create element, then + At root. Name it Rescue coordinator and Apply changes. Use Actor for an external participant such as a Vessel in distress.",
|
||||
"Select Rescue coordinator, choose Activity, then + Allocated. Name the new activity Assess incident. It appears inside its performer; an Allocation relationship records that responsibility.",
|
||||
"Add a second activity for another participant. Select an activity, choose + Relationship, select Interaction and choose the compatible target. Name the exchange after what is conveyed.",
|
||||
"Visit Model checks to review unfinished work. Save project writes the accepted model and applied drafts to your chosen file.",
|
||||
"Visit Model checks to review unfinished work. Save all writes the accepted model and applied drafts to your chosen file.",
|
||||
],
|
||||
),
|
||||
explain(
|
||||
"Continue into system design",
|
||||
"Use Create / update counterpart on an operational element to review an OA → SA mapping. In System Analysis, identify the System under study, its external Actors and the Functions for which each is responsible. The transition tutorial explains scope, reuse and review decisions.",
|
||||
),
|
||||
walkthrough(
|
||||
"Build a reviewable OA → SA baseline",
|
||||
&[
|
||||
"State the mission and capability: for example, protect life at sea by coordinating a rescue. Link the mission to the capability and identify the participants that support it.",
|
||||
"Describe the normal operational Process and an exception Scenario using the activities and Interactions already in the architecture. Give the scenario an explicit Context capability and connect the process with Chain involvement.",
|
||||
"Review which operational work the System will support. Create or reuse its SA counterparts, then check the resulting System boundary, external actors, leaf Function allocations and exchanged payloads.",
|
||||
"Refine the system Functional chain, Scenario and State machine. Specify data contracts and entry/do/exit work where they clarify responsibilities. Link requirements, analyses or tests to the particular records they support.",
|
||||
"Read Model checks, review changes through counterpart traces, and inspect both perspectives. Save the complete source project and review its Git diff as one engineering milestone.",
|
||||
],
|
||||
),
|
||||
explain(
|
||||
"Explore a worked model",
|
||||
"Open the coastal rescue example to inspect allocation, decomposition, repeated scenario messages, operating modes, typed interfaces and artifact references. Loading the example uses the normal unsaved-work prompt. It replaces the current project only after that workflow completes.",
|
||||
@@ -194,6 +217,7 @@ fn article(topic: Topic) -> Article {
|
||||
Topic::Participants,
|
||||
Topic::Nesting,
|
||||
Topic::Transitions,
|
||||
Topic::ModelChecks,
|
||||
],
|
||||
destination: Some((Page::Diagram, "Open Architecture")),
|
||||
}
|
||||
@@ -264,7 +288,7 @@ fn article(topic: Topic) -> Article {
|
||||
const {
|
||||
Article {
|
||||
summary: "A diagram is a view of the model. Its boxes, positions and nesting reveal selected relationships; moving a box does not redefine engineering ownership.",
|
||||
keywords: "containment semantic parent hierarchy nested nesting allocation deployment diagram placement arrange fit fold hide zoom resize routing connection bend waypoint detour label reset",
|
||||
keywords: "containment semantic parent hierarchy nested nesting allocation deployment diagram placement arrange fit fold hide zoom resize routing connection bend waypoint detour label reset selective visibility category categories coverage process context duplicate copy items",
|
||||
sections: &[
|
||||
explain(
|
||||
"Three things to distinguish",
|
||||
@@ -281,7 +305,7 @@ fn article(topic: Topic) -> Article {
|
||||
walkthrough(
|
||||
"Choose and edit the relationship you mean",
|
||||
&[
|
||||
"Use the diagram selector or + View to work with a focused diagram. View properties edits its name, perspective and projection.",
|
||||
"Use the diagram selector or + View to work with a focused diagram. View properties edits its name, perspective, projection and Saved view visibility. Apply changes commits the view settings.",
|
||||
"Choose Containment for semantic children, Architecture · allocated work for responsibilities, Functional dataflow for work and exchanges, or Missions & capabilities for outcomes. All relationships is a broader exploration view; OA ↔ SA traceability displays counterparts.",
|
||||
"Select an element and edit Semantic parent to change ownership. Use an Allocation relationship to change responsibility. Apply changes validates the semantic change.",
|
||||
"Drag nodes to arrange the current view, scroll to zoom and use Fit to frame its contents. Arrange changes diagram geometry. Export SVG produces a shareable view.",
|
||||
@@ -292,6 +316,16 @@ fn article(topic: Topic) -> Article {
|
||||
"Keep diagrams readable",
|
||||
"Use separate views for responsibility, decomposition and cross-layer coverage. Place existing elements with Show in this diagram. A placement's Diagram field can relocate it while preserving its identity and artifact references. Do not duplicate a semantic element merely to show it in another view.",
|
||||
),
|
||||
walkthrough(
|
||||
"Save a focused exchange view",
|
||||
&[
|
||||
"To preserve the current presentation, right-click empty diagram space and choose Duplicate view. The copy has independent placements, routes and visibility settings while representing the same model elements and relationships. View-specific artifact links stay with the original view.",
|
||||
"Open View properties. Under Work exchange categories or Participant exchange categories, uncheck All, including uncategorized exchanges, then Include the categories you need. Selected categories combine by union; none selected shows none of that family. Work means OA Interactions or SA Functional exchanges; participant means OA Communication means or SA Boundary exchanges, including supported legacy records.",
|
||||
"Work exchange coverage across the whole model offers All work exchanges, Used in behavior and Outside behavior. This uses explicit process, chain and scenario occurrences, including reuse. To focus on particular behavior, uncheck All work contexts and Include those processes, chains or scenarios. Context and coverage intersect with categories; no selected contexts means no work lines.",
|
||||
"Choose Exchange labels: Names, Items or Names and items. In Individual lines and omission reasons, search a name or alias and check Hide to retain an explicit hide. Unchecking Hide removes only that reason; other settings or missing endpoints may still omit the line. Clear all explicit hides removes the manual hide list.",
|
||||
"Apply changes and inspect the result. To restore all eligible lines, enable All relationship line kinds, both category All choices and All work contexts, choose All work exchanges, and clear explicit hides. Empty relationship-kind selection shows no lines. These settings do not remove nodes or model facts; a line still needs endpoints eligible for the projection.",
|
||||
],
|
||||
),
|
||||
explain(
|
||||
"Read allocation summaries and ports",
|
||||
"A dashed parent activity/function with an italic name and SUMMARY heading groups leaves assigned to the same performer. It has no direct allocation of its own; its inspector explains the leaf performer. Small ports belong to the boundary of their visible owner and are selectable model elements. Folding a container hides its inner work and ports; summary exchange lines do not invent new ports on the container.",
|
||||
@@ -341,7 +375,7 @@ fn article(topic: Topic) -> Article {
|
||||
),
|
||||
explain(
|
||||
"Use scenarios as concrete evidence",
|
||||
"A scenario illustrates one path through the behavior: normal response, degraded operation or an exception. Keep its intended capability and assumptions clear in its engineering description and available model relationships. Scenarios complement the capability definition; they do not replace the outcome.",
|
||||
"A scenario illustrates normal response, degraded operation or an exception. In its Properties, choose Context to name the capability it supports in the same perspective; record assumptions and pre/postconditions too. Connect a Process or Functional chain with Chain involvement. Descriptive prose explains the intent, while these explicit references make the context inspectable.",
|
||||
),
|
||||
explain(
|
||||
"Avoid implementation names at this stage",
|
||||
@@ -362,7 +396,7 @@ fn article(topic: Topic) -> Article {
|
||||
const {
|
||||
Article {
|
||||
summary: "An exchange says what passes between work or across a participant boundary. Its payload, endpoints and allocation make that statement precise.",
|
||||
keywords: "interaction functional exchange boundary exchange flow information material energy event source target port exchange allocation",
|
||||
keywords: "interaction functional exchange boundary exchange communication mean participant directed bidirectional category categories flow information material energy event source target port exchange allocation",
|
||||
sections: &[
|
||||
walkthrough(
|
||||
"Create a typed exchange",
|
||||
@@ -371,7 +405,7 @@ fn article(topic: Topic) -> Article {
|
||||
"Choose Interaction or Functional exchange in the diagram palette. Click the source, then a highlighted compatible target, or drag between them. Functional exchanges connect output ports to input ports; function cards create or reuse real ports in the same transaction. Middle-button drag pans while keeping the source; arrow keys also pan when the drawing canvas has focus. Click empty space to restart, or Escape to cancel.",
|
||||
"Create an Exchange item under Data & interfaces. Choose its nature (Information, Material or Energy) and its mechanism (Flow, Event, Operation or Shared data). Describe the payload independently of the connection.",
|
||||
"Select the exchange in Model explorer or the diagram. Edit its properties to reference the appropriate exchange items and available ports; Apply changes validates the complete record.",
|
||||
"Choose Boundary exchange and connect compatible component ports. Flow goes from Out to In; Standard is unoriented. Select / move returns to arranging. Exchange allocation associates a boundary exchange with the functional exchange it carries.",
|
||||
"In System Analysis, choose Boundary exchange and connect compatible component ports. Flow goes from Out to In; Standard is unoriented. Select / move returns to arranging. Exchange allocation associates a boundary exchange with the functional exchange it carries.",
|
||||
],
|
||||
),
|
||||
explain(
|
||||
@@ -382,6 +416,14 @@ fn article(topic: Topic) -> Article {
|
||||
"Use engineering payloads",
|
||||
"Choose Information for data such as a distress report, Energy for delivered power and Material for a transferred substance. Mechanism separately describes how it is exchanged: an Event is a notification, an Operation has parameters, and a Flow carries values or a physical quantity. Data types and fields make payloads precise. Artifact links can point to interface-control documents.",
|
||||
),
|
||||
explain(
|
||||
"Operational and system boundaries",
|
||||
"An OA Interaction connects activities. A Communication mean connects operational Entities or Actors directly; it requires no system ports. In Operational Analysis, choose Communication means and connect its participants. Its properties hold carried items and direction; the relationship form offers Directed source → target (unchecked: bidirectional). Existing OA Boundary exchanges with Legacy ports remain editable for compatibility.",
|
||||
),
|
||||
explain(
|
||||
"Relate operational transport and concerns",
|
||||
"Use Exchange allocation to associate a Communication mean with the Interactions it carries. Allocate each activity to its actual performer and check that those performers match the communication endpoints and direction. Create an Exchange category at the perspective root, choose Work exchanges or Participant exchanges in its properties, and Include existing exchange members. Categories name reusable concerns for view filtering; they do not change endpoints, allocate work or imply execution. A category may be empty, and an exchange may belong to several categories.",
|
||||
),
|
||||
],
|
||||
related: &[
|
||||
Topic::Interfaces,
|
||||
@@ -404,8 +446,9 @@ fn article(topic: Topic) -> Article {
|
||||
&[
|
||||
"First create the activities/functions and their exchanges. Check the source and target work for every exchange.",
|
||||
"Open Behavior & scenarios. Create Process in OA or Functional chain in SA using Create element and + At root.",
|
||||
"Select that behavior in the page's selector. Use + Work or + Exchange to involve an existing source. Each contribution keeps its own identity while referring to the shared engineering definition.",
|
||||
"Select a contribution, then add another after it or use Connect to wire existing contributions. Branch / parallel creates a split, two paths and their merge in one undoable edit. Enter explicit alternative conditions; use Fit and zoom to inspect the topology.",
|
||||
"Select that behavior in the page's selector. Choose Work or Exchange, then a highlighted path or contribution position to involve an existing source. Each contribution keeps its own identity while referring to the shared engineering definition.",
|
||||
"Choose Sequence link and click or drag from the preceding contribution to the following contribution. In Select / reorder, drag a selected path endpoint to reconnect it; a contribution's ↕ handle changes its position within a column. These are local contributions and sequence links, not new architecture exchanges.",
|
||||
"Select a contribution, then add another after it. Two paths… creates a split, two paths and their merge in one undoable edit. Enter explicit alternative conditions; use Fit and zoom to inspect the topology.",
|
||||
"Use Chain involvement from the Process/Functional chain to the capability it supports. Read Model checks for unfinished or disconnected paths.",
|
||||
],
|
||||
),
|
||||
@@ -415,7 +458,7 @@ fn article(topic: Topic) -> Article {
|
||||
),
|
||||
explain(
|
||||
"Review the path as an engineer",
|
||||
"Check that exchanges express meaningful handoffs and that the links and branch guards support the selected outcome. Use Reuse to involve an existing process or chain as a nested reference. Imported ordered exchange paths retain their meaning; the first topology edit explains and preserves their implied links in the same transaction.",
|
||||
"Check that exchanges express meaningful handoffs and that the links and branch guards support the selected outcome. Use Reuse to involve an existing process or chain as a nested reference. For an imported ordered exchange path, Append exchange… continues that path; Convert ordered path to explicit links exposes its implied links for topology editing. Conversion preserves the original order in one undoable change.",
|
||||
),
|
||||
],
|
||||
related: &[Topic::Exchanges, Topic::Capabilities, Topic::Scenarios],
|
||||
@@ -436,7 +479,8 @@ fn article(topic: Topic) -> Article {
|
||||
"Use + Lifeline and choose the participant or work it represents. Choices follow the scenario's viewpoint: operational activities, operational participants, system functions or system boundary participants.",
|
||||
"Choose + Message and its style. Call + correlated reply creates both occurrences in one edit. Select the exchange and compatible sending/receiving lifelines. Reply to existing call offers valid unanswered calls.",
|
||||
"Use Action, Observe state or Fragment for other behavior. A local timer has an expression and a lifeline, with no global exchange. Create and delete messages describe participant lifetime; Model checks also flag risks inside repeated loops.",
|
||||
"Use the ordered list's up/down controls to change sequence order. Click a message or list entry to inspect that occurrence rather than the reusable exchange definition.",
|
||||
"Choose a message style and Draw message. Start at a highlighted sequence position on a lifeline, then click or drag to the receiving lifeline. The form retains the chosen position while you select the exchange and details. Escape cancels the gesture.",
|
||||
"In Select / reorder, click an occurrence and drag its ↕ handle to another position within the same sequence. The ordered list's up/down controls provide another way to reorder. Select an occurrence to inspect its particular use of the exchange, including its message values.",
|
||||
],
|
||||
),
|
||||
walkthrough(
|
||||
@@ -452,6 +496,10 @@ fn article(topic: Topic) -> Article {
|
||||
"Conditions are engineering statements",
|
||||
"Guards, actions and effects document behavior; a drawn scenario does not execute code or prove timing. Add evidence references when a simulation, test or analysis establishes a stronger claim.",
|
||||
),
|
||||
explain(
|
||||
"Keep uses distinct from definitions",
|
||||
"Several occurrences can refer to one exchange without duplicating it. Edit occurrence properties for this message's sender, receiver and value expressions; edit the exchange for the shared endpoints and payload. A Reply refers to a particular Call, not merely a similar name. Reordering across fragment operands or changing lifeline bindings needs an explicit structural edit, not an accidental drag across a boundary.",
|
||||
),
|
||||
],
|
||||
related: &[
|
||||
Topic::Chains,
|
||||
@@ -489,7 +537,16 @@ fn article(topic: Topic) -> Article {
|
||||
),
|
||||
explain(
|
||||
"Availability and state actions",
|
||||
"Availability links a participant or behavior to a State/Mode in which it is available. State action links a State/Mode to an Activity/Function and records its entry, do or exit phase. Use these typed relationships when work depends on operating conditions.",
|
||||
"Availability links a participant or behavior to a State/Mode in which it is available. State action links a State/Mode to existing Activity/Function work and records Entry, Do or Exit. The saved state view shows those named action rows above internal transitions and nested regions. Select a row to edit its actual State action relationship. The linked work remains in its own semantic owner; the row does not contain or copy it. Free-text entry/do/exit notes remain separate engineering prose.",
|
||||
),
|
||||
walkthrough(
|
||||
"Add timing without losing an existing transition",
|
||||
&[
|
||||
"Select the transition and choose Edit transition. Its current endpoints, kind, existing triggers, guard and effects populate the form.",
|
||||
"Choose Add a time event with this transition, select After for a delay or At for a time, then enter a time expression such as 30 s. This adds a typed Event to the trigger alternatives; it does not replace existing triggers.",
|
||||
"Review the complete transition before Apply. The event and transition update are one undoable change. If the form is rejected, correct the retained values; a rejected candidate creates no orphan Event.",
|
||||
"Save the entry and any included source files. On reopening, check the transition's trigger list and the Event expression. An expression documents timing intent; it is not evaluated by a simulator.",
|
||||
],
|
||||
),
|
||||
explain(
|
||||
"Keep conditions testable",
|
||||
@@ -579,7 +636,7 @@ fn article(topic: Topic) -> Article {
|
||||
),
|
||||
explain(
|
||||
"Know what reconciliation changes",
|
||||
"Semantic review supports explicit structural and scalar changes, typed constructors and individual collection rows, including members, literals, annotations and bindings. Candidate validation checks the complete proposed model before applying one undoable transaction. Ordered collections, attachment/artifact and multi-source review controls remain unfinished; some structured facts are read-only. Do not assume Refresh preview automatically synchronizes arbitrary structure or every field.",
|
||||
"Semantic review supports structural and scalar changes, typed constructors, individual collection rows and explicit ordered collections. Several trace rows can contribute to one combined destination proposal. Candidate validation checks the complete proposed model before one undoable transaction. The Review semantic changes topic explains included rows, mapped references and order decisions. Attached-record and artifact reconciliation remain outside this dialog's authoring controls; use their supported editing workflows.",
|
||||
),
|
||||
explain(
|
||||
"Avoid duplicate counterparts",
|
||||
@@ -591,6 +648,68 @@ fn article(topic: Topic) -> Article {
|
||||
Topic::Participants,
|
||||
Topic::Capabilities,
|
||||
Topic::Recovery,
|
||||
Topic::SemanticReview,
|
||||
],
|
||||
destination: Some((Page::Traces, "Open Traceability")),
|
||||
}
|
||||
}
|
||||
}
|
||||
Topic::SemanticReview => {
|
||||
const {
|
||||
Article {
|
||||
summary: "Review what changed between corresponding OA and SA records. Choose the engineering result explicitly and retain the reasoned correspondence for later changes.",
|
||||
keywords: "semantic drift review keep take final candidate previously reviewed history reset reference pairs annotation qualified field path binding lifeline membership order up down reconcile steps fragment branch direction multi-source batch",
|
||||
sections: &[
|
||||
walkthrough(
|
||||
"Compare the right records and direction",
|
||||
&[
|
||||
"From an existing counterpart trace, choose Review semantics…. Confirm the OA → SA or SA → OA direction and select the intended trace / review history. Each correspondence and direction has its own retained row draft; opening another row does not discard the first row's choices.",
|
||||
"In Properties, select a fact. Compare Previously reviewed with Current on both sides. Unreviewed means there is no prior observation; Both changed means both sides changed since that observation, not that one side is automatically wrong.",
|
||||
"Choose Keep for the destination, Take for the source, or Set final value…. For a kind change, Set final kind and payload… reviews the complete replacement and its affected references. Leave unselected makes no decision for that fact.",
|
||||
"Read Reference pairs and Additional changes before Apply reviewed changes. A missing or ambiguous correspondence must be resolved explicitly. Apply commits the validated model and the reviewed history together; Undo restores both.",
|
||||
],
|
||||
),
|
||||
explain(
|
||||
"Map references by meaning",
|
||||
"Reference pairs connect exact OA and SA identities used by the proposed change. Similar names are not sufficient. Map the records that play the same engineering role, including enclosing fields in qualified message-value paths. Mapping a referenced record does not authorize creating, moving or editing that record. If it needs a counterpart, establish that correspondence first.",
|
||||
),
|
||||
walkthrough(
|
||||
"Review one message value or lifeline binding",
|
||||
&[
|
||||
"Open Members / rows and choose the exact identities on both sides. For a message value, browse the enclosing record fields as well as the final field: origin.latitude and destination.latitude are distinct addresses.",
|
||||
"For a binding, choose the referenced lifeline key and its compatible local lifeline. Check which scenario owns each side. Keep or Take concerns that row, not all bindings in the scenario.",
|
||||
"Use Set final value… to enter the destination expression or local lifeline. Include in destination collection controls that row's presence. Unchecking removes the entry without deleting its referenced engineering object.",
|
||||
"If a row key changed, review the old entry's removal and the new entry's addition separately. A missing source identity is not the same decision as an identified source entry that has been deleted. Read the proposed value before applying.",
|
||||
],
|
||||
),
|
||||
walkthrough(
|
||||
"Review membership and sequence order separately",
|
||||
&[
|
||||
"Review which occurrences belong to Steps, or which operands belong to Fragment branches, using Members / rows. This changes membership; it does not silently reparent or create the referenced records.",
|
||||
"Choose Review final order… or the corresponding order fact in Properties. Keep retains destination positions. Take uses mapped source order when it can preserve destination-only members without an ambiguous placement.",
|
||||
"For an explicit result, choose Set final value…, then use Up and Down. If you change membership afterward, choose Reconcile with selected membership: surviving positions are retained, removed members leave the list, and new members append for you to position.",
|
||||
"Every surviving destination member must appear exactly once. Resolve an ambiguous insertion with an explicit final order; do not remove an engineering contribution merely to make the preview pass.",
|
||||
],
|
||||
),
|
||||
explain(
|
||||
"Preserve intentional differences and useful history",
|
||||
"Keep can acknowledge a deliberate difference without overwriting the destination. Reset this exact previous observation clears only the chosen review history; it does not restore an old model value. Overlapping row addresses may require an explicit history reset. Review those messages rather than clearing unrelated observations. Pending review choices and raw values can be recovered after an unexpected exit, but the plan is recomputed against the recovered accepted model.",
|
||||
),
|
||||
walkthrough(
|
||||
"Review several sources together",
|
||||
&[
|
||||
"Use Choose another trace to add or reopen, then Open row. Include this row determines whether its decisions contribute to the shared preview. The trace / review history selector switches the row you are editing; excluded rows retain their drafts.",
|
||||
"Review each included source's contribution to the destination. Compatible decisions combine; contradictory Keep, Take or final-value decisions must be resolved explicitly. Read the combined conflicts, Reference pairs and Additional changes before Apply reviewed changes. One Apply commits the combined model and review histories; one Undo restores them.",
|
||||
"Only one trace history per source can be included. Some reverse correspondences cannot share the core reference mapping; the preview explains the conflict. Exclude a row or choose a distinct correspondence without discarding its draft. Switching direction retains the other direction's choices for later review.",
|
||||
"Recovery retains included and excluded row drafts and raw final values, then recomputes the plan against the recovered accepted model. Refresh preview is not automatic synchronization and does not authorize changes to attached records or artifacts.",
|
||||
],
|
||||
),
|
||||
],
|
||||
related: &[
|
||||
Topic::Transitions,
|
||||
Topic::Interfaces,
|
||||
Topic::Scenarios,
|
||||
Topic::Recovery,
|
||||
],
|
||||
destination: Some((Page::Traces, "Open Traceability")),
|
||||
}
|
||||
@@ -608,7 +727,7 @@ fn article(topic: Topic) -> Article {
|
||||
"Select the model record the evidence concerns. This may be an element, relationship, diagram, placement or other supported persistent record.",
|
||||
"Choose + Artifact reference in Properties, or + Artifact in Traceability. Name the reference so another engineer understands its purpose.",
|
||||
"Enter the Artifact URI, optional external Revision and Fragment. Use a commit or document revision when a reproducible reference matters; the fragment identifies a particular part of the artifact.",
|
||||
"Add a description explaining whether the artifact specifies, implements or verifies the engineering decision. Apply changes, then inspect the owner and reference in Traceability.",
|
||||
"Choose the relation to the artifact: References, Satisfies, Verifies or Implements. Add a description explaining the evidence and its limits. Apply changes, then inspect the owner and reference in Traceability.",
|
||||
],
|
||||
),
|
||||
explain(
|
||||
@@ -659,6 +778,11 @@ fn article(topic: Topic) -> Article {
|
||||
"Use one contract",
|
||||
"The normative specification is docs/language.rst in the distribution. It defines source grammar and supported graphical semantics together. Text preserves semantic records, diagram placements, ordered occurrences, traces and artifact references. Graphical edits preserve unaffected source formatting and comments; accepted model Undo/Redo also restores the exact source files. Comments and source-file organization are not part of JSON export.",
|
||||
),
|
||||
example(
|
||||
"Keep included files with their entry",
|
||||
"An include is relative to the file that declares it. Put declarations in the included file and keep one project entry. A newly created graphical record may live in another source file; use Open source to locate it and include every affected file in your review and save.",
|
||||
"// Inside the project declaration:\ninclude \"oa/operations.arcadia\";\ninclude \"sa/model.arcadia\";",
|
||||
),
|
||||
explain(
|
||||
"Resolve source-control conflicts before applying",
|
||||
"Ordinary Git conflict markers are not Arcadia syntax. Resolve the text merge, preserve intended UUIDs/references, then apply and inspect Model checks. The editor does not decide which engineer's model change is correct.",
|
||||
@@ -674,6 +798,53 @@ fn article(topic: Topic) -> Article {
|
||||
}
|
||||
}
|
||||
}
|
||||
Topic::ModelChecks => {
|
||||
const {
|
||||
Article {
|
||||
summary: "Use checks to find invalid structure and unfinished engineering work. A valid model can still need decisions, behavior evidence or allocation.",
|
||||
keywords: "validation warning error completeness coverage non_leaf_work_exchange work_without_behavior_context exchange_without_behavior_context mission_without_capability capability_without_behavior_context unsupported_capability sequence_gap allocation leaf context diagnostics lint",
|
||||
sections: &[
|
||||
explain(
|
||||
"Errors and warnings answer different questions",
|
||||
"An error rejects the candidate, leaving the accepted model unchanged. A warning describes an incomplete or questionable model that can still be saved. Read the record, field and source location rather than treating the number of warnings as a quality score. Fix structural errors first: the completeness pass waits for valid structure, so its warnings may appear after those errors are corrected.",
|
||||
),
|
||||
walkthrough(
|
||||
"Connect work to explicit behavior",
|
||||
&[
|
||||
"For an exchange on non-leaf work, identify the leaf Activity or Function responsible for that endpoint. In SA, keep its typed input/output port owned by that Function. Do not add a fake child just to suppress the warning.",
|
||||
"For leaf work without behavior context, include a work action or one of its exchanges in an ordered Process/Functional chain or Scenario. Allocation alone states who performs the work, not how it is used.",
|
||||
"For an exchange without behavior context, include that exact Interaction or Functional exchange in an exchange occurrence. A similarly named exchange or an unlisted occurrence under the container does not provide evidence.",
|
||||
"Only included steps, fragment operands and valid reused behavior contribute. State actions, availability links and prose remain useful but do not stand in for an explicit process/scenario use. Coverage demonstrates inclusion, not execution or completeness of every possible path.",
|
||||
],
|
||||
),
|
||||
walkthrough(
|
||||
"Connect purpose to behavioral evidence",
|
||||
&[
|
||||
"For a mission without a capability, connect the mission to the capability that delivers it. Merely involving a participant in the mission does not identify that capability.",
|
||||
"For a supported capability without behavior context, connect a Process/Functional chain using Chain involvement or set a Scenario's Context to that capability in the same perspective.",
|
||||
"A capability used only to group child capabilities is treated differently from an isolated leaf. A directly contextualized Scenario establishes support; adding an unrelated participant link is not required merely to clear a warning.",
|
||||
"Review the engineering statement after each correction. Do not invent relationships solely to achieve a warning-free list; leave an unfinished decision visible until the model can express it accurately.",
|
||||
],
|
||||
),
|
||||
explain(
|
||||
"Review sequence gaps and incomplete contracts",
|
||||
"A sequence gap calls attention to disconnected handoffs; inspect the contribution endpoints and intended path. Missing allocations, unspecified exchange mechanisms and incomplete conditions need their own engineering decisions. An accepted model does not prove that guards are executable, units are converted, external artifacts exist or timing requirements are met.",
|
||||
),
|
||||
explain(
|
||||
"The same checks are available to text and automation",
|
||||
"Model checks, source lint and server validation use the shared model rules. Source-project diagnostics identify the included file and location; native JSON checks identify the model record and field. A validation request can inspect a candidate without committing it. Correct errors in the complete candidate before applying it graphically, as text or through the API.",
|
||||
),
|
||||
],
|
||||
related: &[
|
||||
Topic::Capabilities,
|
||||
Topic::Chains,
|
||||
Topic::Scenarios,
|
||||
Topic::Recovery,
|
||||
],
|
||||
destination: Some((Page::Validation, "Open Model checks")),
|
||||
}
|
||||
}
|
||||
}
|
||||
Topic::Recovery => {
|
||||
const {
|
||||
Article {
|
||||
@@ -721,7 +892,12 @@ fn article(topic: Topic) -> Article {
|
||||
"New, Open, opening a recent model, loading the example, Recover and Close use the unsaved-work prompt. Save and continue, discard or cancel is your decision. Checkpoints are private recovery copies; continue saving meaningful milestones and using version control or backups.",
|
||||
),
|
||||
],
|
||||
related: &[Topic::Source, Topic::Files, Topic::Transitions],
|
||||
related: &[
|
||||
Topic::ModelChecks,
|
||||
Topic::Source,
|
||||
Topic::Files,
|
||||
Topic::SemanticReview,
|
||||
],
|
||||
destination: Some((Page::Validation, "Open Model checks")),
|
||||
}
|
||||
}
|
||||
@@ -734,7 +910,7 @@ fn article(topic: Topic) -> Article {
|
||||
sections: &[
|
||||
explain(
|
||||
"Start from Home",
|
||||
"Home offers New model, Open model, the rescue example and this guide. Recent models lists local files you successfully opened or saved. A disconnected drive or moved file remains visible; use Locate to choose its new location or Refresh after reconnecting the drive. Remove forgets the recent entry without deleting its model file. Returning to Home retains the current document and uses the same draft navigation guards as other pages.",
|
||||
"Home offers New model, Open model, the rescue example and this guide. Recent models lists local files you successfully opened or saved. A disconnected drive or moved file remains visible; use Locate to choose its new location or Refresh after reconnecting the drive. Remove forgets the recent entry without deleting its model file. Home retains the current document; Continue editing returns to the last workspace, including Textual model or a saved state view, with its drafts intact.",
|
||||
),
|
||||
explain(
|
||||
"Editable formats",
|
||||
@@ -743,7 +919,7 @@ fn article(topic: Topic) -> Article {
|
||||
example(
|
||||
"Keyboard reference",
|
||||
"Shortcuts act on the current project. In Textual model, undo and redo affect the source draft.",
|
||||
"Ctrl+N New project\nCtrl+O Open model\nCtrl+S Save project\nCtrl+Shift+S Save as\nCtrl+Z Undo\nCtrl+Shift+Z Redo\nCtrl+Y Redo\nEscape Cancel the active dialog",
|
||||
"Ctrl+N New project\nCtrl+O Open model\nCtrl+S Save all\nCtrl+Shift+S Save as\nCtrl+Z Undo\nCtrl+Shift+Z Redo\nCtrl+Y Redo\nEscape Cancel the active dialog",
|
||||
),
|
||||
walkthrough(
|
||||
"Save and share a model",
|
||||
@@ -754,6 +930,10 @@ fn article(topic: Topic) -> Article {
|
||||
"If another tool changed or deleted the open file, preserve your candidate using a new Save as destination. Compare and reconcile the versions before replacing either one.",
|
||||
],
|
||||
),
|
||||
explain(
|
||||
"Save as preserves the engineering history",
|
||||
"Saving a source project under a new entry name relocates the save destination without adding a model edit or clearing Redo. Choose a name that does not collide with an included file, including one retained in Undo/Redo history. Save all includes every accepted source file; an unchanged entry file alone does not prove that an included file was saved. If a save fails, the current model and drafts remain available.",
|
||||
),
|
||||
explain(
|
||||
"Open and migration",
|
||||
"Open validates a complete candidate before replacing the project. Supported earlier versions migrate to the current schema; Model checks shows migration details such as preserved ports, supplied data defaults, generated interface allocations and unresolved message paths. Old Event items become Information notifications; unknown physical mechanisms remain Unspecified. Source upgrades retain files, includes, nested ownership and comments. Opening does not overwrite the original: use Save as if it must remain in its older format. Invalid input leaves the accepted project unchanged.",
|
||||
@@ -806,11 +986,15 @@ fn article(topic: Topic) -> Article {
|
||||
&[
|
||||
"Create or import a project using /api/v1/projects or /api/v1/projects/import. Keep the returned service resource_id; it is distinct from the editable model UUID.",
|
||||
"Read the project's envelope, source or model. Preserve its exact ETag as the precondition for your edit.",
|
||||
"Submit typed commands to /transactions, or a complete candidate to /source or /model, using If-Match. Use transaction dry-run or validation before committing substantial generated changes.",
|
||||
"Submit typed commands to /transactions, or a complete candidate to /source, /model or /source-project, using If-Match. Use transaction dry-run or validation before committing substantial generated changes.",
|
||||
"A stale write returns 412. Keep the user's draft, reload the current head and reconcile deliberately. After an uncertain write outcome, read the server state before retrying.",
|
||||
"Query /artifact-links and /traces to connect domain-tool artifacts to model records. The server stores these references; the integration is responsible for resolving its external artifacts.",
|
||||
],
|
||||
),
|
||||
explain(
|
||||
"Validate and save a source bundle",
|
||||
"GET /source-project returns the entry and included files. POST /validation with format source-project and source_project containing that bundle checks a candidate without committing it; warnings can coexist with valid: true. PUT /source-project accepts the complete bundle under the current If-Match ETag. Structural errors reject the write, preserving the accepted model and revision. Compare record IDs and fields across text/JSON diagnostics; source-project results also identify the included file and source span.",
|
||||
),
|
||||
explain(
|
||||
"Deployment and persistence",
|
||||
"Non-loopback binding requires a configured bearer token. Use the documented local-disk storage and deployment configuration. The service keeps the current revision and serializes accepted updates; it does not provide retained revision history or server-side undo. Stop the service cleanly before copying its committed store for backup.",
|
||||
@@ -922,6 +1106,13 @@ fn article_view(topic: Topic) -> Element<'static, Message> {
|
||||
);
|
||||
}
|
||||
if topic == Topic::FirstProject {
|
||||
content = content.push(text("For a worked model, open the water-pressure walkthrough. Copy the whole examples/release1 folder and open the copied project.arcadia before editing; read its README.md. If you use Save as, also copy evidence/ beside the new entry: Save as copies model source, not external artifact targets.").size(13));
|
||||
content = content.push(
|
||||
button(text("Open water-pressure walkthrough").size(13))
|
||||
.padding([11, 15])
|
||||
.style(style::nav)
|
||||
.on_press(Message::Home(crate::home::Event::Walkthrough)),
|
||||
);
|
||||
content = content.push(
|
||||
button(text("Open coastal rescue example").size(13))
|
||||
.padding([11, 15])
|
||||
@@ -938,6 +1129,7 @@ fn article_view(topic: Topic) -> Element<'static, Message> {
|
||||
bottom: 24.,
|
||||
..Default::default()
|
||||
}))
|
||||
.id(ARTICLE_SCROLL_ID)
|
||||
.width(Fill)
|
||||
.height(Fill)
|
||||
.into()
|
||||
@@ -974,7 +1166,7 @@ pub fn view(state: &State) -> Element<'_, Message> {
|
||||
button(text("Clear search").size(13)).padding([10, 14]).on_press(Message::Guide(Event::Search(String::new()))),
|
||||
].spacing(16).max_width(520)).padding(24).width(Fill).into()
|
||||
} else {
|
||||
// A different article gets a fresh scroll state and starts at its title.
|
||||
// Topic changes also issue an explicit article-only scroll reset.
|
||||
keyed_column([(state.topic, article_view(state.topic))])
|
||||
.width(Fill)
|
||||
.height(Fill)
|
||||
@@ -1022,6 +1214,97 @@ pub fn view(state: &State) -> Element<'_, Message> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[derive(Default)]
|
||||
struct ScrollProbe {
|
||||
snaps: Vec<iced::advanced::widget::operation::scrollable::RelativeOffset<Option<f32>>>,
|
||||
}
|
||||
|
||||
impl iced::advanced::widget::operation::Scrollable for ScrollProbe {
|
||||
fn snap_to(
|
||||
&mut self,
|
||||
offset: iced::advanced::widget::operation::scrollable::RelativeOffset<Option<f32>>,
|
||||
) {
|
||||
self.snaps.push(offset);
|
||||
}
|
||||
fn scroll_to(
|
||||
&mut self,
|
||||
_: iced::advanced::widget::operation::scrollable::AbsoluteOffset<Option<f32>>,
|
||||
) {
|
||||
panic!("Guide navigation must snap to the article start");
|
||||
}
|
||||
fn scroll_by(
|
||||
&mut self,
|
||||
_: iced::advanced::widget::operation::scrollable::AbsoluteOffset,
|
||||
_: iced::Rectangle,
|
||||
_: iced::Rectangle,
|
||||
) {
|
||||
panic!("Guide navigation must not use a relative pixel scroll");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn guide_topic_navigation_resets_only_article_and_same_topic_search_keeps_scroll() {
|
||||
use iced::futures::StreamExt;
|
||||
let mut app = crate::app::App::with_project(oa_core::Project::empty());
|
||||
app.page = Page::Guide;
|
||||
let accepted = app.document.snapshot();
|
||||
let revision = app.document.revision();
|
||||
for (event, topic, reset) in [
|
||||
(Event::Topic(Topic::Scenarios), Topic::Scenarios, true),
|
||||
(
|
||||
Event::Search("sender lifeline".into()),
|
||||
Topic::Scenarios,
|
||||
false,
|
||||
),
|
||||
// Native reproduction: related topic after scrolling Scenarios.
|
||||
(Event::Topic(Topic::Exchanges), Topic::Exchanges, true),
|
||||
(Event::Search("If-Match 412".into()), Topic::Server, true),
|
||||
(Event::Search("If-Match".into()), Topic::Server, false),
|
||||
(
|
||||
Event::Search("nonexistent-topic-987".into()),
|
||||
Topic::Server,
|
||||
false,
|
||||
),
|
||||
(Event::Search(String::new()), Topic::Server, false),
|
||||
(Event::Topic(Topic::Server), Topic::Server, false),
|
||||
] {
|
||||
let mut article = ScrollProbe::default();
|
||||
let mut navigation = ScrollProbe::default();
|
||||
if let Some(mut stream) =
|
||||
iced_runtime::task::into_stream(app.update(Message::Guide(event)))
|
||||
{
|
||||
while let Some(action) = stream.next().await {
|
||||
let iced_runtime::Action::Widget(mut operation) = action else {
|
||||
panic!("Guide navigation must not schedule model or external actions");
|
||||
};
|
||||
for (id, probe) in [
|
||||
(ARTICLE_SCROLL_ID, &mut article),
|
||||
("guide-topic-navigation", &mut navigation),
|
||||
] {
|
||||
operation.scrollable(
|
||||
Some(&iced::advanced::widget::Id::new(id)),
|
||||
iced::Rectangle::default(),
|
||||
iced::Rectangle::default(),
|
||||
iced::Vector::new(0., 900.),
|
||||
probe,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(app.guide.topic, topic);
|
||||
assert_eq!(article.snaps.len(), usize::from(reset));
|
||||
for offset in article.snaps {
|
||||
assert_eq!(offset.x, Some(0.));
|
||||
assert_eq!(offset.y, Some(0.));
|
||||
}
|
||||
assert!(navigation.snaps.is_empty());
|
||||
assert_eq!(app.project(), &accepted.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &accepted.bundle);
|
||||
assert_eq!(app.document.revision(), revision);
|
||||
assert!(!app.document.can_undo());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_finds_concepts_in_walkthroughs_and_api_reference() {
|
||||
assert!(search(" ALLOCATED rescue ").contains(&Topic::FirstProject));
|
||||
|
||||
@@ -21,6 +21,7 @@ pub enum Event {
|
||||
New,
|
||||
Open,
|
||||
Example,
|
||||
Walkthrough,
|
||||
Guide,
|
||||
Resume,
|
||||
OpenRecent(PathBuf),
|
||||
@@ -43,6 +44,35 @@ pub enum Availability {
|
||||
Unavailable(String),
|
||||
}
|
||||
|
||||
/// Resolve the packaged example independently of the process working directory.
|
||||
pub(crate) fn walkthrough_path() -> Result<PathBuf, String> {
|
||||
let executable = std::env::current_exe().map_err(|error| error.to_string())?;
|
||||
let development =
|
||||
cfg!(debug_assertions).then(|| Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."));
|
||||
resolve_walkthrough(&executable, development.as_deref())
|
||||
}
|
||||
|
||||
fn resolve_walkthrough(executable: &Path, development: Option<&Path>) -> Result<PathBuf, String> {
|
||||
let relative = Path::new("examples/release1/project.arcadia");
|
||||
let packaged = executable
|
||||
.parent()
|
||||
.ok_or("Application directory is unavailable.")?
|
||||
.join(relative);
|
||||
if packaged.is_file() {
|
||||
return Ok(packaged);
|
||||
}
|
||||
if let Some(root) = development {
|
||||
let source = root.join(relative);
|
||||
if source.is_file() {
|
||||
return Ok(source);
|
||||
}
|
||||
}
|
||||
Err(format!(
|
||||
"The water-pressure walkthrough is missing. Restore the complete examples/release1 folder beside the application, then try again. Expected {}.",
|
||||
packaged.display()
|
||||
))
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Recent {
|
||||
pub entry: Entry,
|
||||
@@ -311,6 +341,11 @@ pub fn view<'a>(
|
||||
action("Explore rescue example", Event::Example).style(style::nav),
|
||||
].spacing(12),
|
||||
].spacing(18).max_width(940);
|
||||
content = content.push(container(column![
|
||||
text("Restore safe water pressure").size(18),
|
||||
text("Follow an OA → SA walkthrough with processes, scenarios, states and evidence. Copy the whole examples/release1 folder and open its project.arcadia before editing; the tutorial is README.md. Save as copies model source only, so also copy evidence/ to the new folder if you use it.").size(13),
|
||||
action("Open water-pressure walkthrough", Event::Walkthrough).style(style::nav),
|
||||
].spacing(10)).padding(20).style(style::subtle));
|
||||
if let Some(warning) = &recovery.warning {
|
||||
content = content.push(text(warning).size(12).color(style::MUTED));
|
||||
}
|
||||
|
||||
@@ -1,5 +1,42 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn walkthrough_uses_packaged_bundle_before_development_and_reports_missing_assets() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let packaged = temp
|
||||
.path()
|
||||
.join("application/examples/release1/project.arcadia");
|
||||
let development = temp.path().join("checkout");
|
||||
let checkout_entry = development.join("examples/release1/project.arcadia");
|
||||
let executable = temp.path().join("application/arcadia-studio.exe");
|
||||
fs::create_dir_all(checkout_entry.parent().unwrap()).unwrap();
|
||||
fs::write(&checkout_entry, "development source").unwrap();
|
||||
assert_eq!(
|
||||
resolve_walkthrough(&executable, Some(&development)).unwrap(),
|
||||
checkout_entry
|
||||
);
|
||||
fs::create_dir_all(packaged.parent().unwrap()).unwrap();
|
||||
fs::write(&packaged, "packaged source").unwrap();
|
||||
assert_eq!(
|
||||
resolve_walkthrough(&executable, Some(&development)).unwrap(),
|
||||
packaged
|
||||
);
|
||||
assert_eq!(resolve_walkthrough(&executable, None).unwrap(), packaged);
|
||||
fs::remove_file(&packaged).unwrap();
|
||||
let error = resolve_walkthrough(&executable, None).unwrap_err();
|
||||
assert!(error.contains("complete examples/release1 folder"));
|
||||
assert!(
|
||||
error
|
||||
.replace('\\', "/")
|
||||
.contains(&packaged.display().to_string().replace('\\', "/")),
|
||||
"{error}"
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read_to_string(checkout_entry).unwrap(),
|
||||
"development source"
|
||||
);
|
||||
}
|
||||
|
||||
fn model(directory: &Path, name: &str) -> PathBuf {
|
||||
let path = directory.join(name);
|
||||
fs::write(&path, b"model contents must remain untouched").unwrap();
|
||||
|
||||
@@ -29,6 +29,7 @@ mod source_workspace;
|
||||
mod style;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
mod view_copy;
|
||||
mod views;
|
||||
mod workflow;
|
||||
|
||||
|
||||
@@ -196,6 +196,7 @@ pub fn popup(app: &App) -> Option<Menu<Message>> {
|
||||
if request.canvas.is_some() && app.active_diagram == Some(id) {
|
||||
entries.extend([
|
||||
item("View properties", Message::EditProperties(id), true),
|
||||
item("Duplicate view", Message::DuplicateDiagram(id), true),
|
||||
item("Fit diagram", Message::Fit, true),
|
||||
item(
|
||||
"Arrange elements",
|
||||
@@ -444,6 +445,11 @@ pub fn popup(app: &App) -> Option<Menu<Message>> {
|
||||
|
||||
fn relation_actions(kind: Kind) -> &'static [(&'static str, RelationKind, bool)] {
|
||||
match kind {
|
||||
Kind::Entity | Kind::Actor => &[(
|
||||
"Configure communication means…",
|
||||
RelationKind::CommunicationMean,
|
||||
false,
|
||||
)],
|
||||
Kind::OperationalMission | Kind::Mission => &[
|
||||
(
|
||||
"Exploit capability…",
|
||||
|
||||
@@ -226,6 +226,18 @@ impl State {
|
||||
state.preview = state.plan(p);
|
||||
Ok(state)
|
||||
}
|
||||
pub(crate) fn from_recovery_after_source5_upgrade(
|
||||
p: &Project,
|
||||
legacy_base: &str,
|
||||
value: serde_json::Value,
|
||||
) -> Result<Self, String> {
|
||||
let mut saved: RecoveryState = serde_json::from_value(value).map_err(|e| e.to_string())?;
|
||||
if saved.version != 1 || saved.base != legacy_base {
|
||||
return Err("Port workflow recovery does not match the accepted source-5 model identity and revision.".into());
|
||||
}
|
||||
saved.base = transitions::project_fingerprint(p);
|
||||
Self::from_recovery(p, serde_json::to_value(saved).map_err(|e| e.to_string())?)
|
||||
}
|
||||
pub fn new(p: &Project, subject: Id, action: Action) -> Result<Self, String> {
|
||||
if !actions(p, subject).contains(&action) {
|
||||
return Err("This port action is unavailable for the selected element.".into());
|
||||
@@ -802,6 +814,7 @@ mod tests {
|
||||
layer: Layer::Sa,
|
||||
projection: ProjectionKind::Overview,
|
||||
routes: Vec::new(),
|
||||
view_options: Default::default(),
|
||||
nodes: Default::default(),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -14,6 +14,9 @@ use std::{
|
||||
};
|
||||
|
||||
const MAX_BYTES: u64 = 256 * 1024 * 1024;
|
||||
mod source5;
|
||||
#[cfg(test)]
|
||||
mod source5_tests;
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Snapshot {
|
||||
@@ -126,17 +129,26 @@ impl Snapshot {
|
||||
}
|
||||
let mut document =
|
||||
Document::from_bundle(self.accepted.clone(), LIMITS).map_err(|e| e.to_string())?;
|
||||
if document.bundle() != &self.accepted {
|
||||
return Err("Checkpoint accepted source requires migration or identity insertion; it cannot be recovered exactly.".into());
|
||||
let legacy_base = source5::upgrade_binding(&self.accepted, &document)?;
|
||||
let mut saved_workspace = self.workspace;
|
||||
if legacy_base.is_some() {
|
||||
saved_workspace.upgrade_clean_buffers(&self.accepted, document.bundle());
|
||||
}
|
||||
let workspace = Workspace::from_recovery(self.workspace)?;
|
||||
let workspace = Workspace::from_recovery(saved_workspace)?;
|
||||
let property = self
|
||||
.property
|
||||
.map(|s| Draft::from_recovery(document.project(), s))
|
||||
.transpose()?;
|
||||
let workflow = self
|
||||
.workflow
|
||||
.map(|value| crate::workflow::Workflow::from_recovery(document.project(), value))
|
||||
.map(|value| match legacy_base.as_deref() {
|
||||
Some(base) => crate::workflow::Workflow::from_recovery_after_source5_upgrade(
|
||||
document.project(),
|
||||
base,
|
||||
value,
|
||||
),
|
||||
None => crate::workflow::Workflow::from_recovery(document.project(), value),
|
||||
})
|
||||
.transpose()?;
|
||||
let mut behavior = crate::behavior::State::default();
|
||||
if let Some(saved) = self.behavior {
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
//! Narrow compatibility for private checkpoints written by the source-5 desktop.
|
||||
use super::*;
|
||||
use oa_core::{
|
||||
Diagram, Project,
|
||||
language::{self, TokenClass},
|
||||
};
|
||||
use serde::ser::{SerializeStruct, Serializer};
|
||||
|
||||
/// Return the historical workflow binding only after proving that migration
|
||||
/// changed precisely the version token, with no identity or semantic insertion.
|
||||
pub(super) fn upgrade_binding(
|
||||
accepted: &SourceBundle,
|
||||
document: &Document,
|
||||
) -> Result<Option<String>, String> {
|
||||
if document.bundle() == accepted {
|
||||
return Ok(None);
|
||||
}
|
||||
let unsupported = || {
|
||||
"Checkpoint accepted source cannot be recovered exactly; only a lossless source 5 to 6 upgrade is supported. The checkpoint has been retained.".to_owned()
|
||||
};
|
||||
let report = document
|
||||
.compiled()
|
||||
.decoded
|
||||
.migration
|
||||
.as_ref()
|
||||
.ok_or_else(unsupported)?;
|
||||
if (report.from_schema, report.to_schema) != (5, 6) {
|
||||
return Err(unsupported());
|
||||
}
|
||||
let source = accepted
|
||||
.files
|
||||
.get(&accepted.entry)
|
||||
.ok_or_else(unsupported)?;
|
||||
let tokens: Vec<_> = language::tokenize(source)
|
||||
.into_iter()
|
||||
.filter(|t| !matches!(t.class, TokenClass::Whitespace | TokenClass::Comment))
|
||||
.take(3)
|
||||
.collect();
|
||||
if tokens.len() != 3
|
||||
|| &source[tokens[0].span.start..tokens[0].span.end] != "arcadia"
|
||||
|| tokens[1].class != TokenClass::Number
|
||||
|| &source[tokens[1].span.start..tokens[1].span.end] != "5"
|
||||
|| &source[tokens[2].span.start..tokens[2].span.end] != ";"
|
||||
{
|
||||
return Err(unsupported());
|
||||
}
|
||||
let mut expected = accepted.clone();
|
||||
expected
|
||||
.files
|
||||
.get_mut(&accepted.entry)
|
||||
.ok_or_else(unsupported)?
|
||||
.replace_range(tokens[1].span.start..tokens[1].span.end, "6");
|
||||
if document.bundle() != &expected {
|
||||
return Err(unsupported());
|
||||
}
|
||||
// The old source admission rejects all source-6 constructs. Exact token-only
|
||||
// migration establishes that borrowed records below retain their old shape.
|
||||
legacy_fingerprint(document.project()).map(Some)
|
||||
}
|
||||
|
||||
fn legacy_fingerprint(p: &Project) -> Result<String, String> {
|
||||
if p.schema_version != 6
|
||||
|| p.diagrams
|
||||
.values()
|
||||
.any(|d| d.view_options != Default::default())
|
||||
{
|
||||
return Err("Checkpoint migration does not have a source-5 model shape.".into());
|
||||
}
|
||||
let bytes = serde_json::to_vec(&Project5(p)).map_err(|e| e.to_string())?;
|
||||
let hash = bytes.iter().fold(0xcbf29ce484222325u64, |hash, byte| {
|
||||
(hash ^ u64::from(*byte)).wrapping_mul(0x100000001b3)
|
||||
});
|
||||
Ok(format!("{hash:016x}"))
|
||||
}
|
||||
|
||||
// Preserve the exact historical serde field order (2b2d2bc), including empty
|
||||
// maps and routes. Serializing through Value would reorder struct fields.
|
||||
struct Project5<'a>(&'a Project);
|
||||
impl Serialize for Project5<'_> {
|
||||
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
let p = self.0;
|
||||
let diagrams: std::collections::BTreeMap<_, _> =
|
||||
p.diagrams.iter().map(|(id, d)| (id, Diagram5(d))).collect();
|
||||
let mut s = serializer.serialize_struct("Project", 10)?;
|
||||
s.serialize_field("schema_version", &5u32)?;
|
||||
s.serialize_field("id", &p.id)?;
|
||||
s.serialize_field("alias", &p.alias)?;
|
||||
s.serialize_field("name", &p.name)?;
|
||||
s.serialize_field("description", &p.description)?;
|
||||
s.serialize_field("elements", &p.elements)?;
|
||||
s.serialize_field("relations", &p.relations)?;
|
||||
s.serialize_field("diagrams", &diagrams)?;
|
||||
s.serialize_field("artifact_links", &p.artifact_links)?;
|
||||
s.serialize_field("traces", &p.traces)?;
|
||||
s.end()
|
||||
}
|
||||
}
|
||||
struct Diagram5<'a>(&'a Diagram);
|
||||
impl Serialize for Diagram5<'_> {
|
||||
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
let d = self.0;
|
||||
let mut s = serializer.serialize_struct("Diagram", 8)?;
|
||||
s.serialize_field("id", &d.id)?;
|
||||
s.serialize_field("alias", &d.alias)?;
|
||||
s.serialize_field("name", &d.name)?;
|
||||
s.serialize_field("description", &d.description)?;
|
||||
s.serialize_field("layer", &d.layer)?;
|
||||
s.serialize_field("projection", &d.projection)?;
|
||||
s.serialize_field("nodes", &d.nodes)?;
|
||||
s.serialize_field("routes", &d.routes)?;
|
||||
s.end()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn source5_fingerprint_preserves_historical_field_order_and_diagram_shape() {
|
||||
// Literal follows the old Project/Diagram derives at 2b2d2bc. The FNV
|
||||
// value was independently calculated from these UTF-8 bytes with Python.
|
||||
let bytes = r#"{"schema_version":5,"id":"00000000-0000-0000-0000-000000000001","alias":"p","name":"Legacy","description":"","elements":{},"relations":{},"diagrams":{"00000000-0000-0000-0000-000000000002":{"id":"00000000-0000-0000-0000-000000000002","alias":"v","name":"View","description":"","layer":"Oa","projection":"Overview","nodes":{},"routes":[]}},"artifact_links":{},"traces":{}}"#;
|
||||
let mut p: Project = serde_json::from_str(bytes).unwrap();
|
||||
p.schema_version = 6;
|
||||
assert_eq!(serde_json::to_string(&Project5(&p)).unwrap(), bytes);
|
||||
assert_eq!(legacy_fingerprint(&p).unwrap(), "4140e158d5091161");
|
||||
assert_ne!(
|
||||
legacy_fingerprint(&p).unwrap(),
|
||||
oa_core::transitions::project_fingerprint(&p)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
//! Source-5 checkpoint compatibility preserves authored bytes and disk contents.
|
||||
use super::*;
|
||||
|
||||
fn source5_snapshot() -> Snapshot {
|
||||
let accepted = SourceBundle {
|
||||
entry: "project.arcadia".into(),
|
||||
files: [
|
||||
(
|
||||
"project.arcadia".into(),
|
||||
"// Preserve 5, 6 and Unicode 雨 in comments.\r\narcadia // header\r\n 5;\r\nproject p \"Recovered source 5\" id \"00000000-0000-4000-8000-000000000001\" {\r\n include \"work.arcadia\";\r\n}\r\n".into(),
|
||||
),
|
||||
(
|
||||
"work.arcadia".into(),
|
||||
"// Exact fragment, including unusual spacing.\r\noa_activity a \"Work\" id \"00000000-0000-4000-8000-000000000002\" {}\r\n".into(),
|
||||
),
|
||||
].into(),
|
||||
};
|
||||
Snapshot {
|
||||
version: 1,
|
||||
id: Id::from_u128(100),
|
||||
name: "Recovered source 5".into(),
|
||||
origin: Some(PathBuf::from("original.arcadia")),
|
||||
modified: 1,
|
||||
workspace: Workspace::new(&accepted, true).recovery_snapshot(),
|
||||
accepted,
|
||||
property: None,
|
||||
behavior: None,
|
||||
workflow: None,
|
||||
selected: Id::from_u128(0x40008000000000000001),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source5_checkpoint_upgrades_only_header_and_clean_editor_baseline() {
|
||||
let saved = source5_snapshot();
|
||||
let mut expected = saved.accepted.clone();
|
||||
// Fixture-specific replacement is an independent oracle for the production token span.
|
||||
expected.files.insert(
|
||||
expected.entry.clone(),
|
||||
saved.accepted.files[&saved.accepted.entry].replacen(
|
||||
"arcadia // header\r\n 5;",
|
||||
"arcadia // header\r\n 6;",
|
||||
1,
|
||||
),
|
||||
);
|
||||
let restored = saved.restore().unwrap();
|
||||
assert_eq!(restored.document.bundle(), &expected);
|
||||
let report = restored
|
||||
.document
|
||||
.compiled()
|
||||
.decoded
|
||||
.migration
|
||||
.as_ref()
|
||||
.unwrap();
|
||||
assert_eq!((report.from_schema, report.to_schema), (5, 6));
|
||||
assert_eq!(restored.workspace.text(), expected.files[&expected.entry]);
|
||||
assert!(!restored.workspace.dirty());
|
||||
assert!(restored.document.dirty());
|
||||
assert!(!restored.document.can_undo());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source5_checkpoint_preserves_dirty_invalid_text_and_baseline_exactly() {
|
||||
let mut saved = source5_snapshot();
|
||||
let mut workspace = Workspace::new(&saved.accepted, true);
|
||||
let entry = workspace.entry.clone();
|
||||
let text = format!("{}\r\nunfinished {{ 雨", workspace.text());
|
||||
workspace.edit(text.clone(), workspace.files[&entry].cursor);
|
||||
workspace.new_path = "drafts/not-created.arcadia".into();
|
||||
saved.workspace = workspace.recovery_snapshot();
|
||||
let before = serde_json::to_value(&saved.workspace).unwrap();
|
||||
let restored = saved.restore().unwrap();
|
||||
assert_eq!(
|
||||
serde_json::to_value(restored.workspace.recovery_snapshot()).unwrap(),
|
||||
before
|
||||
);
|
||||
assert_eq!(restored.workspace.text(), text);
|
||||
assert!(restored.workspace.dirty());
|
||||
assert!(restored.document.bundle().files[&entry].contains("arcadia // header\r\n 6;"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source5_checkpoint_preserves_distinct_unapplied_open_workspace() {
|
||||
let mut saved = source5_snapshot();
|
||||
let broken = SourceBundle {
|
||||
entry: "broken.arcadia".into(),
|
||||
files: [("broken.arcadia".into(), "unparsed {\r\n 雨".into())].into(),
|
||||
};
|
||||
saved.workspace = Workspace::new(&broken, false).recovery_snapshot();
|
||||
let before = serde_json::to_value(&saved.workspace).unwrap();
|
||||
let restored = saved.restore().unwrap();
|
||||
assert_eq!(
|
||||
serde_json::to_value(restored.workspace.recovery_snapshot()).unwrap(),
|
||||
before
|
||||
);
|
||||
assert!(restored.workspace.dirty());
|
||||
assert_eq!(restored.document.project().name, "Recovered source 5");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source5_checkpoint_upgrade_never_rewrites_checkpoint_or_origin() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let mut saved = source5_snapshot();
|
||||
let origin = directory.path().join("original.arcadia");
|
||||
fs::write(&origin, b"External origin bytes stay untouched.").unwrap();
|
||||
saved.origin = Some(origin.clone());
|
||||
write(directory.path(), &saved).unwrap();
|
||||
let before = fs::read(path(directory.path(), saved.id)).unwrap();
|
||||
let restored = read(directory.path(), saved.id).unwrap().restore().unwrap();
|
||||
assert!(restored.document.dirty());
|
||||
assert_eq!(fs::read(path(directory.path(), saved.id)).unwrap(), before);
|
||||
assert_eq!(
|
||||
fs::read(&origin).unwrap(),
|
||||
b"External origin bytes stay untouched."
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source5_checkpoint_missing_identity_is_not_a_lossless_upgrade() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let mut saved = source5_snapshot();
|
||||
saved.accepted.files.insert(
|
||||
"work.arcadia".into(),
|
||||
"oa_activity a \"Work\" {}\r\n".into(),
|
||||
);
|
||||
saved.workspace = Workspace::new(&saved.accepted, true).recovery_snapshot();
|
||||
write(directory.path(), &saved).unwrap();
|
||||
let before = fs::read(path(directory.path(), saved.id)).unwrap();
|
||||
assert!(read(directory.path(), saved.id).unwrap().restore().is_err());
|
||||
assert_eq!(fs::read(path(directory.path(), saved.id)).unwrap(), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source4_checkpoint_remains_explicitly_unsupported_without_touching_disk() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let mut saved = source5_snapshot();
|
||||
let entry = saved.accepted.entry.clone();
|
||||
saved.accepted.files.insert(
|
||||
entry.clone(),
|
||||
saved.accepted.files[&entry].replacen(
|
||||
"arcadia // header\r\n 5;",
|
||||
"arcadia // header\r\n 4;",
|
||||
1,
|
||||
),
|
||||
);
|
||||
saved.workspace = Workspace::new(&saved.accepted, true).recovery_snapshot();
|
||||
write(directory.path(), &saved).unwrap();
|
||||
let before = fs::read(path(directory.path(), saved.id)).unwrap();
|
||||
assert!(read(directory.path(), saved.id).unwrap().restore().is_err());
|
||||
assert_eq!(fs::read(path(directory.path(), saved.id)).unwrap(), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source5_checkpoint_workflow_rebinds_only_the_proven_old_revision() {
|
||||
use crate::workflow::Workflow;
|
||||
let mut saved = source5_snapshot();
|
||||
let document = Document::from_bundle(saved.accepted.clone(), LIMITS).unwrap();
|
||||
let old_base = source5::upgrade_binding(&saved.accepted, &document)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let work = *document.project().elements.keys().next().unwrap();
|
||||
let workflow = Workflow::transition(document.project(), work).unwrap();
|
||||
let mut raw = workflow.recovery_snapshot().unwrap();
|
||||
raw["Transition"]["base"] = old_base.into();
|
||||
// This field did not exist in source-5 request snapshots.
|
||||
raw["Transition"]["request"]
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.remove("communication_endpoints");
|
||||
saved.workflow = Some(raw.clone());
|
||||
let restored = saved.clone().restore().unwrap();
|
||||
let Workflow::Transition(restored_workflow) = restored.workflow.unwrap() else {
|
||||
panic!()
|
||||
};
|
||||
assert_eq!(restored_workflow.request.sources, vec![work]);
|
||||
assert_eq!(
|
||||
restored_workflow.plan.base_fingerprint,
|
||||
oa_core::transitions::project_fingerprint(restored.document.project())
|
||||
);
|
||||
assert!(!serde_json::to_string(&raw).unwrap().contains("commands"));
|
||||
raw["Transition"]["base"] = "different accepted revision".into();
|
||||
saved.workflow = Some(raw);
|
||||
assert!(saved.restore().err().unwrap().contains("revision"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source5_checkpoint_property_preserves_invalid_raw_input() {
|
||||
let mut saved = source5_snapshot();
|
||||
// Add a historical diagram and placement, with stable explicit identities.
|
||||
saved.accepted.files.get_mut("work.arcadia").unwrap().push_str(
|
||||
"diagram v \"View\" id \"00000000-0000-4000-8000-000000000003\" {}\r\nnode placed id \"00000000-0000-4000-8000-000000000004\" { diagram @v; element @a; }\r\n");
|
||||
saved.workspace = Workspace::new(&saved.accepted, true).recovery_snapshot();
|
||||
let document = Document::from_bundle(saved.accepted.clone(), LIMITS).unwrap();
|
||||
let node = *document
|
||||
.project()
|
||||
.diagrams
|
||||
.values()
|
||||
.next()
|
||||
.unwrap()
|
||||
.nodes
|
||||
.keys()
|
||||
.next()
|
||||
.unwrap();
|
||||
let mut draft = Draft::from_project(document.project(), node).unwrap();
|
||||
draft.input("/position/x", "-".into());
|
||||
saved.property = Some(draft.recovery_snapshot());
|
||||
let restored = saved.restore().unwrap();
|
||||
let draft = restored.property.unwrap();
|
||||
assert_eq!(draft.inputs["/position/x"].raw, "-");
|
||||
assert!(draft.materialize().is_err());
|
||||
assert!(draft.dirty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source5_checkpoint_port_draft_replans_and_rejects_stale_base() {
|
||||
use crate::{port_workflow, workflow::Workflow};
|
||||
let mut saved = source5_snapshot();
|
||||
saved
|
||||
.accepted
|
||||
.files
|
||||
.get_mut("work.arcadia")
|
||||
.unwrap()
|
||||
.push_str("sa_function f \"Function\" id \"00000000-0000-4000-8000-000000000005\" {}\r\n");
|
||||
saved.workspace = Workspace::new(&saved.accepted, true).recovery_snapshot();
|
||||
let document = Document::from_bundle(saved.accepted.clone(), LIMITS).unwrap();
|
||||
let p = document.project();
|
||||
let f = p
|
||||
.elements
|
||||
.values()
|
||||
.find(|e| e.kind() == oa_core::Kind::Function)
|
||||
.unwrap()
|
||||
.id;
|
||||
let old_base = source5::upgrade_binding(&saved.accepted, &document)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let state = port_workflow::State::new(p, f, port_workflow::Action::Input).unwrap();
|
||||
let mut raw = state.recovery_snapshot().unwrap();
|
||||
raw["name"] = " raw port name ".into();
|
||||
raw["base"] = old_base.into();
|
||||
saved.workflow = Some(serde_json::json!({"Ports": raw}));
|
||||
let restored = saved.clone().restore().unwrap();
|
||||
let Workflow::Ports(state) = restored.workflow.unwrap() else {
|
||||
panic!()
|
||||
};
|
||||
let restored_raw = state.recovery_snapshot().unwrap();
|
||||
assert_eq!(restored_raw["name"], " raw port name ");
|
||||
assert_eq!(
|
||||
restored_raw["base"],
|
||||
oa_core::transitions::project_fingerprint(restored.document.project())
|
||||
);
|
||||
saved.workflow.as_mut().unwrap()["Ports"]["base"] = "stale".into();
|
||||
assert!(saved.restore().err().unwrap().contains("revision"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source5_checkpoint_old_diagram_property_shape_remains_editable() {
|
||||
let mut saved = source5_snapshot();
|
||||
saved
|
||||
.accepted
|
||||
.files
|
||||
.get_mut("work.arcadia")
|
||||
.unwrap()
|
||||
.push_str("diagram v \"View\" id \"00000000-0000-4000-8000-000000000003\" {}\r\n");
|
||||
saved.workspace = Workspace::new(&saved.accepted, true).recovery_snapshot();
|
||||
let document = Document::from_bundle(saved.accepted.clone(), LIMITS).unwrap();
|
||||
let diagram = *document.project().diagrams.keys().next().unwrap();
|
||||
let mut draft = Draft::from_project(document.project(), diagram).unwrap();
|
||||
draft.input("/name", " raw view name ".into());
|
||||
let mut raw = serde_json::to_value(draft.recovery_snapshot()).unwrap();
|
||||
raw["value"].as_object_mut().unwrap().remove("view_options");
|
||||
raw["inputs"]
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.retain(|key, _| !key.starts_with("/view_options"));
|
||||
saved.property = Some(serde_json::from_value(raw).unwrap());
|
||||
let restored = saved.restore().unwrap();
|
||||
let draft = restored.property.unwrap();
|
||||
assert_eq!(draft.inputs["/name"].raw, " raw view name ");
|
||||
assert!(draft.materialize().is_ok());
|
||||
assert!(draft.dirty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source5_checkpoint_does_not_refresh_unrelated_clean_buffer_baseline() {
|
||||
let mut saved = source5_snapshot();
|
||||
let mut raw = serde_json::to_value(&saved.workspace).unwrap();
|
||||
// A clean buffer retained from a different accepted source must not be
|
||||
// mistaken for the source-5 entry merely because its path matches.
|
||||
raw["files"]["project.arcadia"] = serde_json::json!(["different source", "different source"]);
|
||||
saved.workspace = serde_json::from_value(raw.clone()).unwrap();
|
||||
let restored = saved.restore().unwrap();
|
||||
assert_eq!(
|
||||
serde_json::to_value(restored.workspace.recovery_snapshot()).unwrap(),
|
||||
raw
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Deliberate semantic review of one existing trace; drafts never mutate the model.
|
||||
//! Atomic semantic review session with independent, stably addressed row drafts.
|
||||
use crate::{
|
||||
app::{Choice, Message},
|
||||
forms::Draft,
|
||||
@@ -8,6 +8,18 @@ use oa_core::{transitions::*, *};
|
||||
use serde_json::Value;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
mod artifacts;
|
||||
#[cfg(test)]
|
||||
mod artifacts_recovery_falsifier_tests;
|
||||
#[cfg(test)]
|
||||
mod artifacts_tests;
|
||||
mod artifacts_view;
|
||||
mod attachments;
|
||||
mod attachments_view;
|
||||
mod batch;
|
||||
#[cfg(test)]
|
||||
mod batch_tests;
|
||||
mod compose;
|
||||
mod constructors;
|
||||
mod history;
|
||||
mod keyed;
|
||||
@@ -15,6 +27,7 @@ mod keyed_rows;
|
||||
mod message_kind;
|
||||
mod ordered;
|
||||
mod recovery;
|
||||
mod row;
|
||||
mod scalar;
|
||||
mod view;
|
||||
pub(crate) use view::summary;
|
||||
@@ -36,13 +49,17 @@ pub enum Tab {
|
||||
Facts,
|
||||
References,
|
||||
Members,
|
||||
Attachments,
|
||||
Artifacts,
|
||||
History,
|
||||
Impacts,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Event {
|
||||
pub enum RowEvent {
|
||||
#[cfg(test)]
|
||||
Trace(Choice),
|
||||
#[cfg(test)]
|
||||
Direction(TransitionDirection),
|
||||
Select(FacetSelector),
|
||||
Decide(FacetSelector, Option<SemanticDecision>),
|
||||
@@ -53,12 +70,15 @@ pub enum Event {
|
||||
Array(String, Option<usize>, i32),
|
||||
Append(String, Value),
|
||||
ValuePath(String, crate::data_values::FieldPathKey),
|
||||
ValueBrowse(String, Option<crate::data_values::FieldPathKey>, usize),
|
||||
ReferenceBrowse(String, String, usize),
|
||||
Tab(Tab),
|
||||
ShowUnchanged(bool),
|
||||
ReferenceOa(Choice),
|
||||
ReferenceSa(Choice),
|
||||
ReferenceSearch(String),
|
||||
AddReference,
|
||||
RestoreReviewedReferences,
|
||||
RemoveReference(Id),
|
||||
AcceptImpact(usize, bool),
|
||||
Preview,
|
||||
@@ -71,6 +91,8 @@ pub enum Event {
|
||||
LiteralSide(Layer, bool),
|
||||
EditPair(FacetSelector),
|
||||
StageMember,
|
||||
Attachment(attachments::Event),
|
||||
Artifact(artifacts::Event),
|
||||
DropStaged(FacetSelector),
|
||||
AnnotationAddress(Layer, Option<crate::data_values::FieldPathKey>),
|
||||
AnnotationBrowse(Layer, Option<crate::data_values::FieldPathKey>, usize),
|
||||
@@ -82,824 +104,5 @@ pub enum Event {
|
||||
OrderReconcile(OrderedCollection),
|
||||
}
|
||||
|
||||
pub struct State {
|
||||
base_identity: Id,
|
||||
base_fingerprint: String,
|
||||
pub anchor: Id,
|
||||
pub trace: Option<Id>,
|
||||
pub direction: TransitionDirection,
|
||||
pub facts: Vec<FacetDrift>,
|
||||
pub selected: Option<FacetSelector>,
|
||||
pub decisions: BTreeMap<FacetSelector, SemanticDecision>,
|
||||
pub references: BTreeMap<Id, Id>,
|
||||
pub resets: BTreeSet<FacetSelector>,
|
||||
pub staged: BTreeSet<FacetSelector>,
|
||||
pub presence: BTreeMap<FacetSelector, bool>,
|
||||
pub row_drafts: BTreeMap<FacetSelector, keyed_rows::RowDraft>,
|
||||
pub order_drafts: BTreeMap<OrderedCollection, Vec<Id>>,
|
||||
pub annotation_oa: keyed_rows::AddressDraft,
|
||||
pub annotation_sa: keyed_rows::AddressDraft,
|
||||
pub member_family: keyed::Family,
|
||||
pub member_oa: Option<Choice>,
|
||||
pub member_sa: Option<Choice>,
|
||||
pub literal: String,
|
||||
pub literal_oa: bool,
|
||||
pub literal_sa: bool,
|
||||
pub draft: Option<Draft>,
|
||||
// Each closed constructor has an independent draft, including nested kinds.
|
||||
// Scalar and sibling drafts survive decision and constructor changes.
|
||||
pub group_drafts: BTreeMap<FacetSelector, Draft>,
|
||||
pub plan: Option<SemanticTransitionPlan>,
|
||||
pub proposed: Option<Project>,
|
||||
pub error: Option<String>,
|
||||
pub tab: Tab,
|
||||
pub show_unchanged: bool,
|
||||
pub reference_oa: Option<Choice>,
|
||||
pub reference_sa: Option<Choice>,
|
||||
pub reference_search: String,
|
||||
accepted_impacts: Vec<SemanticImpact>,
|
||||
}
|
||||
|
||||
impl State {
|
||||
pub fn new(project: &Project, selected: Id) -> Result<Self, String> {
|
||||
let explicit = project.traces.get(&selected);
|
||||
let anchor = explicit.map_or(selected, |trace| trace.oa);
|
||||
let layer = project
|
||||
.layer_of(anchor)
|
||||
.ok_or("Select an OA or SA object, relationship or trace to review.")?;
|
||||
let choices: Vec<_> = project
|
||||
.traces
|
||||
.values()
|
||||
.filter(|t| t.oa == anchor || t.sa == anchor)
|
||||
.collect();
|
||||
if choices.is_empty() {
|
||||
return Err("This object has no trace. Create a counterpart or link existing counterparts first.".into());
|
||||
}
|
||||
let trace = explicit
|
||||
.map(|t| t.id)
|
||||
.or_else(|| (choices.len() == 1).then(|| choices[0].id));
|
||||
let mut state = Self {
|
||||
base_identity: project.id,
|
||||
base_fingerprint: project_fingerprint(project),
|
||||
anchor,
|
||||
trace,
|
||||
direction: if layer == Layer::Oa {
|
||||
TransitionDirection::OaToSa
|
||||
} else {
|
||||
TransitionDirection::SaToOa
|
||||
},
|
||||
facts: vec![],
|
||||
selected: None,
|
||||
decisions: BTreeMap::new(),
|
||||
references: BTreeMap::new(),
|
||||
resets: BTreeSet::new(),
|
||||
staged: BTreeSet::new(),
|
||||
presence: BTreeMap::new(),
|
||||
row_drafts: BTreeMap::new(),
|
||||
order_drafts: BTreeMap::new(),
|
||||
annotation_oa: Default::default(),
|
||||
annotation_sa: Default::default(),
|
||||
member_family: keyed::Family::Literal,
|
||||
member_oa: None,
|
||||
member_sa: None,
|
||||
literal: String::new(),
|
||||
literal_oa: true,
|
||||
literal_sa: true,
|
||||
draft: None,
|
||||
group_drafts: BTreeMap::new(),
|
||||
plan: None,
|
||||
proposed: None,
|
||||
error: None,
|
||||
tab: Tab::Facts,
|
||||
show_unchanged: false,
|
||||
reference_oa: None,
|
||||
reference_sa: None,
|
||||
reference_search: String::new(),
|
||||
accepted_impacts: vec![],
|
||||
};
|
||||
state.load_trace(project);
|
||||
Ok(state)
|
||||
}
|
||||
|
||||
pub fn target(&self, project: &Project) -> Option<Id> {
|
||||
let trace = project.traces.get(&self.trace?)?;
|
||||
Some(if self.direction == TransitionDirection::OaToSa {
|
||||
trace.sa
|
||||
} else {
|
||||
trace.oa
|
||||
})
|
||||
}
|
||||
|
||||
fn load_trace(&mut self, project: &Project) {
|
||||
self.facts.clear();
|
||||
self.decisions.clear();
|
||||
self.resets.clear();
|
||||
self.staged.clear();
|
||||
self.presence.clear();
|
||||
self.row_drafts.clear();
|
||||
self.order_drafts.clear();
|
||||
self.annotation_oa = Default::default();
|
||||
self.annotation_sa = Default::default();
|
||||
self.member_oa = None;
|
||||
self.member_sa = None;
|
||||
self.literal.clear();
|
||||
self.literal_oa = true;
|
||||
self.literal_sa = true;
|
||||
self.selected = None;
|
||||
self.accepted_impacts.clear();
|
||||
self.references.clear();
|
||||
self.draft = self
|
||||
.target(project)
|
||||
.and_then(|id| Draft::from_project(project, id));
|
||||
self.group_drafts.clear();
|
||||
if let Some(trace) = self.trace.and_then(|id| project.traces.get(&id)) {
|
||||
for facet in constructors::selectors() {
|
||||
if constructors::available(project, trace, &facet)
|
||||
&& let Some(mut draft) = self
|
||||
.target(project)
|
||||
.and_then(|id| Draft::from_project(project, id))
|
||||
{
|
||||
draft.semantic_groups = true;
|
||||
self.group_drafts.insert(facet, draft);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(trace) = self.trace.and_then(|id| project.traces.get(&id)) {
|
||||
self.references.insert(trace.oa, trace.sa);
|
||||
}
|
||||
self.member_family = self
|
||||
.member_families(project)
|
||||
.first()
|
||||
.copied()
|
||||
.unwrap_or(keyed::Family::Literal);
|
||||
self.refresh(project);
|
||||
}
|
||||
|
||||
pub fn update(&mut self, project: &Project, event: Event) {
|
||||
let mut replan = true;
|
||||
let result = match event {
|
||||
Event::Trace(choice) => {
|
||||
if !project
|
||||
.traces
|
||||
.get(&choice.id)
|
||||
.is_some_and(|t| t.oa == self.anchor || t.sa == self.anchor)
|
||||
{
|
||||
Err("Choose one of this object's existing traces.".into())
|
||||
} else {
|
||||
self.trace = Some(choice.id);
|
||||
self.load_trace(project);
|
||||
return;
|
||||
}
|
||||
}
|
||||
Event::Direction(direction) => {
|
||||
self.direction = direction;
|
||||
self.load_trace(project);
|
||||
return;
|
||||
}
|
||||
Event::Select(facet) => {
|
||||
self.selected = Some(facet);
|
||||
self.tab = Tab::Facts;
|
||||
replan = false;
|
||||
Ok(())
|
||||
}
|
||||
Event::Decide(facet, decision) => {
|
||||
if let Some(decision) = decision {
|
||||
if !self.editable(project, &facet)
|
||||
&& !(decision == SemanticDecision::KeepTarget
|
||||
&& self.can_acknowledge_constructor(project, &facet))
|
||||
{
|
||||
Err("This fact needs the structured review workflow; its history remains unchanged.".into())
|
||||
} else {
|
||||
if let FacetSelector::Order(which) = facet
|
||||
&& decision == SemanticDecision::Candidate
|
||||
{
|
||||
if let Err(error) = self.initialize_order(project, which) {
|
||||
self.error = Some(error);
|
||||
return;
|
||||
}
|
||||
} else if keyed_rows::supported(&facet) {
|
||||
self.initialize_row(project, &facet);
|
||||
} else if keyed::supported(&facet) {
|
||||
let target_presence = self
|
||||
.effective_trace(project)
|
||||
.and_then(|t| capture_facet(project, t.oa, t.sa, &facet).ok())
|
||||
.and_then(|f| scalar::value(&f, self.direction.target_layer()))
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
self.presence
|
||||
.entry(facet.clone())
|
||||
.or_insert(target_presence);
|
||||
}
|
||||
self.decisions.insert(facet, decision);
|
||||
Ok(())
|
||||
}
|
||||
} else {
|
||||
self.decisions.remove(&facet);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
Event::Input(path, value) => self.edit(project, &path, |d| {
|
||||
d.input(&path, value);
|
||||
Ok(())
|
||||
}),
|
||||
Event::Text(path, action) => self.edit(project, &path, |d| {
|
||||
d.edit_text(&path, action);
|
||||
Ok(())
|
||||
}),
|
||||
Event::Shape(path, value) => {
|
||||
self.edit(project, &path, |d| d.shape_scoped(&path, value))
|
||||
}
|
||||
Event::Variant(path, value) => {
|
||||
self.edit(project, &path, |d| d.variant_scoped(&path, value))
|
||||
}
|
||||
Event::Array(path, index, direction) => {
|
||||
self.edit(project, &path, |d| d.array_scoped(&path, index, direction))
|
||||
}
|
||||
Event::Append(path, value) => {
|
||||
self.edit(project, &path, |d| d.append_scoped(&path, value))
|
||||
}
|
||||
Event::ValuePath(path, key) => {
|
||||
self.edit(project, &path, |d| d.value_path_scoped(&path, key))
|
||||
}
|
||||
Event::Tab(tab) => {
|
||||
self.tab = tab;
|
||||
replan = false;
|
||||
Ok(())
|
||||
}
|
||||
Event::ShowUnchanged(show) => {
|
||||
self.show_unchanged = show;
|
||||
replan = false;
|
||||
Ok(())
|
||||
}
|
||||
Event::ReferenceOa(choice) => {
|
||||
self.reference_oa = Some(choice);
|
||||
replan = false;
|
||||
Ok(())
|
||||
}
|
||||
Event::ReferenceSa(choice) => {
|
||||
self.reference_sa = Some(choice);
|
||||
replan = false;
|
||||
Ok(())
|
||||
}
|
||||
Event::ReferenceSearch(value) => {
|
||||
self.reference_search = value;
|
||||
replan = false;
|
||||
Ok(())
|
||||
}
|
||||
Event::AddReference => match (&self.reference_oa, &self.reference_sa) {
|
||||
(Some(oa), Some(sa)) => {
|
||||
self.references.insert(oa.id, sa.id);
|
||||
Ok(())
|
||||
}
|
||||
_ => Err("Choose both an Operational and a System reference.".into()),
|
||||
},
|
||||
Event::RemoveReference(id) => {
|
||||
self.references.remove(&id);
|
||||
Ok(())
|
||||
}
|
||||
Event::AcceptImpact(index, accepted) => {
|
||||
if let Some(impact) = self
|
||||
.plan
|
||||
.as_ref()
|
||||
.and_then(|p| p.impacts.get(index))
|
||||
.cloned()
|
||||
{
|
||||
self.accepted_impacts.retain(|i| i.object != impact.object);
|
||||
if accepted {
|
||||
self.accepted_impacts.push(impact);
|
||||
}
|
||||
}
|
||||
self.refresh(project);
|
||||
return;
|
||||
}
|
||||
Event::Preview => Ok(()),
|
||||
Event::OrderMove(which, index, delta) => self.move_order(which, index, delta),
|
||||
Event::OrderReconcile(which) => self.reconcile_order(project, which),
|
||||
Event::Reset(facet, reset) => self.reset_history(project, facet, reset),
|
||||
Event::Presence(facet, present) => {
|
||||
if keyed::supported(&facet)
|
||||
&& self.decisions.get(&facet) == Some(&SemanticDecision::Candidate)
|
||||
{
|
||||
self.presence.insert(facet, present);
|
||||
Ok(())
|
||||
} else {
|
||||
Err("Choose Set final value for this member before editing presence.".into())
|
||||
}
|
||||
}
|
||||
Event::MemberFamily(family) => {
|
||||
self.member_family = family;
|
||||
replan = false;
|
||||
Ok(())
|
||||
}
|
||||
Event::MemberOa(choice) => {
|
||||
self.member_oa = choice;
|
||||
replan = false;
|
||||
Ok(())
|
||||
}
|
||||
Event::MemberSa(choice) => {
|
||||
self.member_sa = choice;
|
||||
replan = false;
|
||||
Ok(())
|
||||
}
|
||||
Event::Literal(value) => {
|
||||
self.literal = value;
|
||||
replan = false;
|
||||
Ok(())
|
||||
}
|
||||
Event::LiteralSide(layer, present) => {
|
||||
if layer == Layer::Oa {
|
||||
self.literal_oa = present;
|
||||
} else {
|
||||
self.literal_sa = present;
|
||||
}
|
||||
replan = false;
|
||||
Ok(())
|
||||
}
|
||||
Event::EditPair(facet) => {
|
||||
replan = false;
|
||||
self.edit_pair(project, facet)
|
||||
}
|
||||
Event::StageMember => self.stage_member(project),
|
||||
Event::DropStaged(facet) => {
|
||||
if self.staged.remove(&facet) {
|
||||
self.decisions.remove(&facet);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Event::AnnotationAddress(layer, key) => {
|
||||
self.annotation_address_mut(layer).selected = key;
|
||||
replan = false;
|
||||
Ok(())
|
||||
}
|
||||
Event::AnnotationBrowse(layer, scope, page) => {
|
||||
let address = self.annotation_address_mut(layer);
|
||||
address.scope = scope;
|
||||
address.page = page;
|
||||
replan = false;
|
||||
Ok(())
|
||||
}
|
||||
#[cfg(test)]
|
||||
Event::RowExpression(facet, expression) => self.edit_row(&facet, |draft| {
|
||||
draft.editor = text_editor::Content::with_text(&expression);
|
||||
draft.expression = expression;
|
||||
}),
|
||||
Event::RowText(facet, action) => self.edit_row(&facet, |draft| {
|
||||
draft.editor.perform(action);
|
||||
draft.expression = draft.editor.text();
|
||||
}),
|
||||
Event::RowLocal(facet, local) => self.edit_row(&facet, |draft| {
|
||||
draft.local = Some(local);
|
||||
}),
|
||||
};
|
||||
if let Err(error) = result {
|
||||
self.error = Some(error);
|
||||
self.plan = None;
|
||||
self.proposed = None;
|
||||
return;
|
||||
}
|
||||
if replan {
|
||||
self.accepted_impacts.clear();
|
||||
self.refresh(project);
|
||||
}
|
||||
}
|
||||
|
||||
fn edit(
|
||||
&mut self,
|
||||
project: &Project,
|
||||
path: &str,
|
||||
action: impl FnOnce(&mut Draft) -> Result<(), String>,
|
||||
) -> Result<(), String> {
|
||||
let facet = self.selected.as_ref().ok_or("Choose a property first.")?;
|
||||
if self.decisions.get(facet) != Some(&SemanticDecision::Candidate) {
|
||||
return Err("Choose Set final value before editing this property.".into());
|
||||
}
|
||||
let roots = self
|
||||
.target(project)
|
||||
.and_then(|id| constructors::roots(project, id, facet))
|
||||
.ok_or("Selected property is unavailable.")?;
|
||||
if !roots
|
||||
.iter()
|
||||
.any(|root| path == root || path.starts_with(&format!("{root}/")))
|
||||
{
|
||||
return Err("Only the selected review property can change in this draft.".into());
|
||||
}
|
||||
let draft = if scalar::grouped(facet) {
|
||||
self.group_drafts.get_mut(facet)
|
||||
} else {
|
||||
self.draft.as_mut()
|
||||
}
|
||||
.ok_or("Choose an existing target.")?;
|
||||
// The scoped view sends only the selected property or closed group roots.
|
||||
// Protected identity and top-level body/perspective controls are never exposed.
|
||||
if path == "/id" || path == "/alias" || path == "/body" || path == "/layer" {
|
||||
return Err("Semantic candidates retain identity, alias and perspective.".into());
|
||||
}
|
||||
action(draft)
|
||||
}
|
||||
|
||||
pub fn editable(&self, project: &Project, facet: &FacetSelector) -> bool {
|
||||
if keyed::supported(facet) || matches!(facet, FacetSelector::Order(_)) {
|
||||
return self
|
||||
.effective_trace(project)
|
||||
.is_some_and(|trace| capture_facet(project, trace.oa, trace.sa, facet).is_ok());
|
||||
}
|
||||
self.target(project)
|
||||
.and_then(|id| scalar::binding(project, id, facet))
|
||||
.is_some()
|
||||
&& (!scalar::grouped(facet)
|
||||
|| (self.group_drafts.contains_key(facet)
|
||||
&& self
|
||||
.trace
|
||||
.and_then(|id| project.traces.get(&id))
|
||||
.is_some_and(|trace| constructors::available(project, trace, facet))))
|
||||
}
|
||||
|
||||
fn candidate_draft(&self, facet: &FacetSelector) -> Option<&Draft> {
|
||||
if scalar::grouped(facet) {
|
||||
self.group_drafts.get(facet)
|
||||
} else {
|
||||
self.draft.as_ref()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn can_acknowledge_constructor(&self, project: &Project, facet: &FacetSelector) -> bool {
|
||||
if !scalar::grouped(facet) {
|
||||
return false;
|
||||
}
|
||||
let Some(trace) = self.effective_trace(project) else {
|
||||
return false;
|
||||
};
|
||||
if trace
|
||||
.baseline
|
||||
.as_ref()
|
||||
.and_then(|b| b.semantic.as_ref())
|
||||
.is_some_and(|b| b.facets.iter().any(|f| f.selector() == *facet))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
capture_facet(project, trace.oa, trace.sa, facet)
|
||||
.ok()
|
||||
.is_some_and(|current| {
|
||||
scalar::value(¤t, Layer::Oa).is_some()
|
||||
&& scalar::observation(¤t, Layer::Oa)
|
||||
== scalar::observation(¤t, Layer::Sa)
|
||||
})
|
||||
}
|
||||
|
||||
fn request(&self, project: &Project) -> Result<SemanticTransitionRequest, String> {
|
||||
let trace = self
|
||||
.trace
|
||||
.and_then(|id| project.traces.get(&id))
|
||||
.ok_or("Choose the trace whose review history you want to update.")?;
|
||||
let (source, target) = if self.direction == TransitionDirection::OaToSa {
|
||||
(trace.oa, trace.sa)
|
||||
} else {
|
||||
(trace.sa, trace.oa)
|
||||
};
|
||||
let mut transition = TransitionRequest::new(self.direction, vec![source]);
|
||||
transition.mappings.push(TransitionMapping {
|
||||
source,
|
||||
target: Some(target),
|
||||
target_kind: None,
|
||||
});
|
||||
let mut request = SemanticTransitionRequest::new(transition);
|
||||
request.traces.insert(source, trace.id);
|
||||
request.references = self.references.clone();
|
||||
request.reviews.push(SemanticReviewRequest {
|
||||
source,
|
||||
resolutions: self
|
||||
.decisions
|
||||
.iter()
|
||||
.map(|(facet, decision)| FacetResolution {
|
||||
facet: facet.clone(),
|
||||
decision: *decision,
|
||||
})
|
||||
.collect(),
|
||||
reset: self.resets.iter().cloned().collect(),
|
||||
});
|
||||
request.accepted_impacts = self.accepted_impacts.clone();
|
||||
if self
|
||||
.decisions
|
||||
.values()
|
||||
.any(|d| *d == SemanticDecision::Candidate)
|
||||
{
|
||||
let final_value = self.combined_value(project, false)?;
|
||||
let candidate = if project.elements.contains_key(&target) {
|
||||
SemanticRecord::Element(
|
||||
serde_json::from_value(final_value)
|
||||
.map_err(|e| format!("Final target value: {e}"))?,
|
||||
)
|
||||
} else {
|
||||
SemanticRecord::Relation(
|
||||
serde_json::from_value(final_value)
|
||||
.map_err(|e| format!("Final target value: {e}"))?,
|
||||
)
|
||||
};
|
||||
request.candidates.push(candidate);
|
||||
}
|
||||
Ok(request)
|
||||
}
|
||||
|
||||
/// Compose selected values for both the candidate and dependent form
|
||||
/// controls. Incomplete inputs retain their stored shape only for controls;
|
||||
/// request construction always requires every selected input to parse.
|
||||
fn combined_value(&self, project: &Project, form_context: bool) -> Result<Value, String> {
|
||||
self.combine(project, form_context, false)
|
||||
}
|
||||
|
||||
fn combine(
|
||||
&self,
|
||||
project: &Project,
|
||||
form_context: bool,
|
||||
skip_orders: bool,
|
||||
) -> Result<Value, String> {
|
||||
let trace = self.effective_trace(project).ok_or("Choose a trace.")?;
|
||||
let (source, target) = if self.direction == TransitionDirection::OaToSa {
|
||||
(trace.oa, trace.sa)
|
||||
} else {
|
||||
(trace.sa, trace.oa)
|
||||
};
|
||||
let mut final_value = Draft::from_project(project, target)
|
||||
.ok_or("Target has no editable record.")?
|
||||
.value;
|
||||
let mut map = BTreeMap::new();
|
||||
for (oa, sa) in &self.references {
|
||||
let (from, to) = if self.direction == TransitionDirection::OaToSa {
|
||||
(*oa, *sa)
|
||||
} else {
|
||||
(*sa, *oa)
|
||||
};
|
||||
if map.insert(from, to).is_some_and(|old| old != to) {
|
||||
return Err("Reverse reference correspondence is ambiguous.".into());
|
||||
}
|
||||
}
|
||||
map.insert(source, target);
|
||||
let original = final_value.clone();
|
||||
let mut group_candidates = vec![];
|
||||
// Outer groups precede nested groups and leaves. The core rejects actual
|
||||
// overlapping replacement scopes; this order also supports first-equal
|
||||
// discriminator acknowledgment together with explicit descendant values.
|
||||
let mut decisions: Vec<_> = self.decisions.iter().collect();
|
||||
decisions.sort_by_key(|(facet, _)| {
|
||||
let group = match facet {
|
||||
FacetSelector::Occurrence(OccurrenceFacet::Constructor) => 0,
|
||||
facet if scalar::grouped(facet) => 1,
|
||||
FacetSelector::Order(_) => 3,
|
||||
_ => 2,
|
||||
};
|
||||
(
|
||||
group,
|
||||
keyed_rows::target_selector(facet, self.direction.target_layer()),
|
||||
)
|
||||
});
|
||||
for (facet, decision) in decisions {
|
||||
if *decision == SemanticDecision::KeepTarget {
|
||||
continue;
|
||||
}
|
||||
if let FacetSelector::Order(which) = facet {
|
||||
if !skip_orders {
|
||||
// Invalid order drafts remain visible while dependent controls use the
|
||||
// surviving membership; request construction still rejects them.
|
||||
if let Err(error) =
|
||||
self.project_order(project, &trace, &mut final_value, *which, *decision)
|
||||
&& !form_context
|
||||
{
|
||||
return Err(error);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if matches!(facet, FacetSelector::Member { collection, .. } if ordered::ordered(*collection).is_some())
|
||||
{
|
||||
self.project_ordered_member(
|
||||
project,
|
||||
&trace,
|
||||
&mut final_value,
|
||||
facet,
|
||||
*decision,
|
||||
form_context,
|
||||
)?;
|
||||
continue;
|
||||
}
|
||||
if keyed::supported(facet) {
|
||||
self.keyed_value(project, &trace, &mut final_value, facet, *decision)?;
|
||||
continue;
|
||||
}
|
||||
let roots = constructors::roots(project, target, facet)
|
||||
.ok_or("Selected property is not applicable to the target.")?;
|
||||
let supplied = match decision {
|
||||
SemanticDecision::KeepTarget => continue,
|
||||
SemanticDecision::Candidate => {
|
||||
let draft = self
|
||||
.candidate_draft(facet)
|
||||
.ok_or("Target draft is unavailable.")?;
|
||||
match draft.materialize_scopes(&roots) {
|
||||
Ok(value) => value,
|
||||
Err(_) if form_context => draft.value.clone(),
|
||||
Err(error) => return Err(error),
|
||||
}
|
||||
}
|
||||
SemanticDecision::TakeSource if scalar::grouped(facet) => {
|
||||
if self.can_acknowledge_constructor(project, facet) {
|
||||
original.clone()
|
||||
} else {
|
||||
constructors::take_value(project, &trace, self.direction, facet, &map)?
|
||||
}
|
||||
}
|
||||
SemanticDecision::TakeSource => {
|
||||
let fact = capture_facet(project, trace.oa, trace.sa, facet)?;
|
||||
let value = scalar::source_value(&fact, self.direction.source_layer(), &map)?;
|
||||
let mut supplied = original.clone();
|
||||
*supplied
|
||||
.pointer_mut(&roots[0])
|
||||
.ok_or("Source property is unavailable.")? = value;
|
||||
supplied
|
||||
}
|
||||
};
|
||||
for path in roots {
|
||||
let value = supplied
|
||||
.pointer(&path)
|
||||
.cloned()
|
||||
.ok_or("Candidate property is no longer available.")?;
|
||||
if scalar::grouped(facet) && *decision == SemanticDecision::Candidate {
|
||||
group_candidates.push((path.clone(), value.clone()));
|
||||
}
|
||||
*final_value.pointer_mut(&path).ok_or("Final target property is unavailable; resolve the overlapping constructor decision.")? = value;
|
||||
}
|
||||
}
|
||||
if !form_context {
|
||||
for (path, authored) in group_candidates {
|
||||
if !original
|
||||
.pointer(&path)
|
||||
.zip(final_value.pointer(&path))
|
||||
.is_some_and(|(original, final_value)| {
|
||||
constructors::authored_agrees(original, &authored, final_value)
|
||||
})
|
||||
{
|
||||
return Err("The constructor candidate and descendant decision propose different final values. Keep the kind or make their final values agree; both drafts are retained.".into());
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(final_value)
|
||||
}
|
||||
|
||||
pub fn refresh(&mut self, project: &Project) {
|
||||
self.plan = None;
|
||||
self.proposed = None;
|
||||
self.error = None;
|
||||
let context = self.combined_value(project, true).ok();
|
||||
if let Some(draft) = &mut self.draft {
|
||||
draft.set_context(context.clone());
|
||||
}
|
||||
for draft in self.group_drafts.values_mut() {
|
||||
draft.set_context(context.clone());
|
||||
}
|
||||
let Some(trace) = self.effective_trace(project) else {
|
||||
self.error = Some(
|
||||
"Several traces are available. Choose the correspondence and review history above."
|
||||
.into(),
|
||||
);
|
||||
return;
|
||||
};
|
||||
match semantic_facets(project, &trace, &self.references) {
|
||||
Ok(mut facts) => {
|
||||
// Explicit addresses survive catalog normalization, pairing changes and resets.
|
||||
for selector in self.staged.iter().chain(self.decisions.keys()) {
|
||||
if !facts.iter().any(|f| &f.selector == selector) {
|
||||
match capture_facet(project, trace.oa, trace.sa, selector) {
|
||||
Ok(current) => {
|
||||
let previous = trace
|
||||
.baseline
|
||||
.as_ref()
|
||||
.and_then(|b| b.semantic.as_ref())
|
||||
.and_then(|b| {
|
||||
b.facets.iter().find(|f| f.selector() == *selector)
|
||||
});
|
||||
facts.push(FacetDrift {
|
||||
selector: selector.clone(),
|
||||
drift: facet_drift(¤t, previous),
|
||||
current,
|
||||
});
|
||||
}
|
||||
Err(error) => {
|
||||
self.error = Some(error);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
facts.sort_by(|a, b| a.selector.cmp(&b.selector));
|
||||
self.facts = facts;
|
||||
}
|
||||
Err(error) => {
|
||||
self.error = Some(error);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if self
|
||||
.selected
|
||||
.as_ref()
|
||||
.is_none_or(|s| !self.facts.iter().any(|f| &f.selector == s))
|
||||
{
|
||||
self.selected = self
|
||||
.facts
|
||||
.iter()
|
||||
.find(|f| f.drift != Drift::Unchanged)
|
||||
.or(self.facts.first())
|
||||
.map(|f| f.selector.clone());
|
||||
}
|
||||
let request = match self.request(project) {
|
||||
Ok(request) => request,
|
||||
Err(error) => {
|
||||
self.error = Some(error);
|
||||
return;
|
||||
}
|
||||
};
|
||||
match plan_semantic_transition(project, request) {
|
||||
Ok(plan) => {
|
||||
if plan.conflicts.is_empty() {
|
||||
match prepare_semantic_transition(project, &plan) {
|
||||
Ok(prepared) => self.proposed = Some(prepared.project),
|
||||
Err(error) => self.error = Some(error.to_string()),
|
||||
}
|
||||
}
|
||||
self.plan = Some(plan);
|
||||
}
|
||||
Err(error) => self.error = Some(error.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn command(&mut self, project: &Project) -> Result<Command, String> {
|
||||
if self.decisions.is_empty() && self.resets.is_empty() {
|
||||
return Err(
|
||||
"Choose at least one property to review or an exact history observation to reset."
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
if let Some(error) = &self.error {
|
||||
return Err(error.clone());
|
||||
}
|
||||
let plan = self
|
||||
.plan
|
||||
.as_ref()
|
||||
.ok_or("Refresh the review preview before applying.")?;
|
||||
prepare_semantic_transition(project, plan).map_err(|e| e.to_string())?;
|
||||
Ok(Command::Batch(plan.commands.clone()))
|
||||
}
|
||||
|
||||
pub fn can_apply(&self) -> bool {
|
||||
(!self.decisions.is_empty() || !self.resets.is_empty())
|
||||
&& self.error.is_none()
|
||||
&& self
|
||||
.plan
|
||||
.as_ref()
|
||||
.is_some_and(|p| p.conflicts.is_empty() && p.prepared_fingerprint.is_some())
|
||||
}
|
||||
|
||||
pub fn all_references(&self, project: &Project, layer: Layer) -> Vec<Choice> {
|
||||
let historical: BTreeSet<_> = self
|
||||
.facts
|
||||
.iter()
|
||||
.flat_map(|f| scalar::references(&f.current))
|
||||
.chain(
|
||||
self.trace
|
||||
.and_then(|id| project.traces.get(&id))
|
||||
.and_then(|t| t.baseline.as_ref())
|
||||
.and_then(|b| b.semantic.as_ref())
|
||||
.into_iter()
|
||||
.flat_map(|b| b.facets.iter().flat_map(scalar::references)),
|
||||
)
|
||||
.collect();
|
||||
let mut ids: BTreeSet<_> = project
|
||||
.elements
|
||||
.keys()
|
||||
.chain(project.relations.keys())
|
||||
.chain(project.artifact_links.keys())
|
||||
.filter(|id| project.layer_of(**id) == Some(layer))
|
||||
.copied()
|
||||
.collect();
|
||||
ids.extend(historical.into_iter().filter(|id| !project.contains(*id)));
|
||||
let search = self.reference_search.to_lowercase();
|
||||
let mut choices: Vec<_> = ids
|
||||
.into_iter()
|
||||
.map(|id| view::choice(project, id))
|
||||
.filter(|c| search.is_empty() || c.label.to_lowercase().contains(&search))
|
||||
.collect();
|
||||
choices.sort_by(|a, b| a.label.cmp(&b.label));
|
||||
choices
|
||||
}
|
||||
}
|
||||
|
||||
pub fn form_event(message: Message) -> Message {
|
||||
let event = match message {
|
||||
Message::Field(path, value) => Event::Input(path, value),
|
||||
Message::FieldText(path, action) => Event::Text(path, action),
|
||||
Message::FieldShape(path, value) => Event::Shape(path, value),
|
||||
Message::FieldVariant(path, value) => Event::Variant(path, value),
|
||||
Message::FieldArray(path, index, direction) => Event::Array(path, index, direction),
|
||||
Message::FieldAppend(path, value) => Event::Append(path, value),
|
||||
Message::FieldValuePath(path, key) => Event::ValuePath(path, key),
|
||||
_ => Event::Preview,
|
||||
};
|
||||
Message::Workflow(crate::workflow::Event::Semantic(event))
|
||||
}
|
||||
pub use batch::{Event, RowAddress, State};
|
||||
pub use row::ReviewRow;
|
||||
|
||||
@@ -0,0 +1,615 @@
|
||||
//! Typed artifact lifecycle drafts; all authored values belong to an exact pair/facet.
|
||||
use super::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
pub(super) const METADATA: [ArtifactFacet; 7] = [
|
||||
ArtifactFacet::Name,
|
||||
ArtifactFacet::Description,
|
||||
ArtifactFacet::Owner,
|
||||
ArtifactFacet::Relation,
|
||||
ArtifactFacet::Uri,
|
||||
ArtifactFacet::Revision,
|
||||
ArtifactFacet::Fragment,
|
||||
];
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub enum Raw {
|
||||
Text(String),
|
||||
Owner(String),
|
||||
Relation(ArtifactRelation),
|
||||
Optional { present: bool, text: String },
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Event {
|
||||
Pick(Layer, Option<Choice>),
|
||||
Search(String),
|
||||
Stage,
|
||||
Reserve,
|
||||
Input(FacetSelector, Raw),
|
||||
Description(FacetSelector, text_editor::Action),
|
||||
}
|
||||
#[derive(Default)]
|
||||
pub struct Editor {
|
||||
pub oa: Option<Choice>,
|
||||
pub sa: Option<Choice>,
|
||||
pub search: String,
|
||||
pub seeds: BTreeMap<Id, ArtifactLink>,
|
||||
pub drafts: BTreeMap<FacetSelector, Raw>,
|
||||
pub descriptions: BTreeMap<FacetSelector, text_editor::Content>,
|
||||
}
|
||||
#[derive(Default, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Saved {
|
||||
oa: Option<Id>,
|
||||
sa: Option<Id>,
|
||||
seeds: Vec<ArtifactLink>,
|
||||
drafts: Vec<(FacetSelector, Raw)>,
|
||||
}
|
||||
impl Saved {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.oa.is_none() && self.sa.is_none() && self.seeds.is_empty() && self.drafts.is_empty()
|
||||
}
|
||||
}
|
||||
pub struct Proposal {
|
||||
// The final selector addresses the actual mutation slot; the last selector
|
||||
// retains the reviewed facet solely for conflict diagnostics.
|
||||
pub slots: Vec<((Id, FacetSelector), Value, FacetSelector)>,
|
||||
pub creations: Vec<ArtifactLink>,
|
||||
pub candidates: BTreeSet<Id>,
|
||||
}
|
||||
pub(super) fn parts(facet: &FacetSelector) -> Option<(&KeyPair<Id>, ArtifactFacet)> {
|
||||
if let FacetSelector::Artifact { keys, facet } = facet {
|
||||
Some((keys, *facet))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
pub(super) fn supported(facet: &FacetSelector) -> bool {
|
||||
parts(facet).is_some()
|
||||
}
|
||||
pub(super) fn selector(keys: &KeyPair<Id>, facet: ArtifactFacet) -> FacetSelector {
|
||||
FacetSelector::Artifact {
|
||||
keys: keys.clone(),
|
||||
facet,
|
||||
}
|
||||
}
|
||||
pub(super) fn key(keys: &KeyPair<Id>, layer: Layer) -> Option<Id> {
|
||||
if layer == Layer::Oa { keys.oa } else { keys.sa }
|
||||
}
|
||||
pub(super) fn field(facet: ArtifactFacet) -> Option<&'static str> {
|
||||
Some(match facet {
|
||||
ArtifactFacet::Presence => return None,
|
||||
ArtifactFacet::Name => "name",
|
||||
ArtifactFacet::Description => "description",
|
||||
ArtifactFacet::Owner => "owner",
|
||||
ArtifactFacet::Relation => "relation",
|
||||
ArtifactFacet::Uri => "uri",
|
||||
ArtifactFacet::Revision => "revision",
|
||||
ArtifactFacet::Fragment => "fragment",
|
||||
})
|
||||
}
|
||||
pub(super) fn slot(id: Id, facet: ArtifactFacet) -> (Id, FacetSelector) {
|
||||
(
|
||||
id,
|
||||
selector(
|
||||
&KeyPair {
|
||||
oa: None,
|
||||
sa: Some(id),
|
||||
},
|
||||
if facet == ArtifactFacet::Presence {
|
||||
ArtifactFacet::Owner
|
||||
} else {
|
||||
facet
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
pub(super) fn observation(record: Option<&ArtifactLink>, facet: ArtifactFacet) -> Value {
|
||||
match record {
|
||||
None => Value::String("NotApplicable".into()),
|
||||
Some(record) => {
|
||||
serde_json::json!({"Value":serde_json::to_value(record).expect("Artifact")[field(facet).unwrap_or("owner")]})
|
||||
}
|
||||
}
|
||||
}
|
||||
pub(super) fn raw(record: &ArtifactLink, facet: ArtifactFacet) -> Option<Raw> {
|
||||
Some(match facet {
|
||||
ArtifactFacet::Presence => return None,
|
||||
ArtifactFacet::Name => Raw::Text(record.name.clone()),
|
||||
ArtifactFacet::Description => Raw::Text(record.description.clone()),
|
||||
ArtifactFacet::Uri => Raw::Text(record.uri.clone()),
|
||||
ArtifactFacet::Owner => Raw::Owner(record.owner.to_string()),
|
||||
ArtifactFacet::Relation => Raw::Relation(record.relation),
|
||||
ArtifactFacet::Revision => Raw::Optional {
|
||||
present: record.revision.is_some(),
|
||||
text: record.revision.clone().unwrap_or_default(),
|
||||
},
|
||||
ArtifactFacet::Fragment => Raw::Optional {
|
||||
present: record.fragment.is_some(),
|
||||
text: record.fragment.clone().unwrap_or_default(),
|
||||
},
|
||||
})
|
||||
}
|
||||
fn compatible(facet: ArtifactFacet, raw: &Raw) -> bool {
|
||||
matches!(
|
||||
(facet, raw),
|
||||
(
|
||||
ArtifactFacet::Name | ArtifactFacet::Description | ArtifactFacet::Uri,
|
||||
Raw::Text(_)
|
||||
) | (ArtifactFacet::Owner, Raw::Owner(_))
|
||||
| (ArtifactFacet::Relation, Raw::Relation(_))
|
||||
| (
|
||||
ArtifactFacet::Revision | ArtifactFacet::Fragment,
|
||||
Raw::Optional { .. }
|
||||
)
|
||||
)
|
||||
}
|
||||
fn write(record: &mut ArtifactLink, facet: ArtifactFacet, raw: &Raw) -> Result<(), String> {
|
||||
match (facet, raw) {
|
||||
(ArtifactFacet::Name, Raw::Text(v)) => record.name = v.clone(),
|
||||
(ArtifactFacet::Description, Raw::Text(v)) => record.description = v.clone(),
|
||||
(ArtifactFacet::Uri, Raw::Text(v)) => record.uri = v.clone(),
|
||||
(ArtifactFacet::Owner, Raw::Owner(v)) => {
|
||||
record.owner = v
|
||||
.parse()
|
||||
.map_err(|_| "Choose a valid explicit artifact owner identity.")?
|
||||
}
|
||||
(ArtifactFacet::Relation, Raw::Relation(v)) => record.relation = *v,
|
||||
(ArtifactFacet::Revision, Raw::Optional { present, text }) => {
|
||||
record.revision = present.then(|| text.clone())
|
||||
}
|
||||
(ArtifactFacet::Fragment, Raw::Optional { present, text }) => {
|
||||
record.fragment = present.then(|| text.clone())
|
||||
}
|
||||
_ => return Err("Artifact draft type disagrees with its selected metadata facet.".into()),
|
||||
};
|
||||
Ok(())
|
||||
}
|
||||
impl Editor {
|
||||
pub fn pair(&self) -> KeyPair<Id> {
|
||||
KeyPair {
|
||||
oa: self.oa.as_ref().map(|c| c.id),
|
||||
sa: self.sa.as_ref().map(|c| c.id),
|
||||
}
|
||||
}
|
||||
pub fn select_pair(&mut self, p: &Project, facet: &FacetSelector) -> Result<(), String> {
|
||||
let (keys, _) = parts(facet).ok_or("Choose an artifact address.")?;
|
||||
self.oa = keys.oa.map(|id| self.choice(p, id));
|
||||
self.sa = keys.sa.map(|id| self.choice(p, id));
|
||||
Ok(())
|
||||
}
|
||||
pub fn choice(&self, p: &Project, id: Id) -> Choice {
|
||||
if p.contains(id) {
|
||||
return view::choice(p, id);
|
||||
}
|
||||
self.seeds.get(&id).map_or_else(
|
||||
|| view::choice(p, id),
|
||||
|a| Choice {
|
||||
id,
|
||||
label: format!("{} · reserved @{}", a.name, a.alias),
|
||||
},
|
||||
)
|
||||
}
|
||||
pub fn choices(&self, p: &Project, trace: &Trace, layer: Layer) -> Vec<Choice> {
|
||||
let owner = if layer == Layer::Oa {
|
||||
trace.oa
|
||||
} else {
|
||||
trace.sa
|
||||
};
|
||||
let selected = key(&self.pair(), layer);
|
||||
let mut ids: BTreeSet<_> = p
|
||||
.artifact_links
|
||||
.values()
|
||||
.filter(|a| p.layer_of(a.owner).is_none_or(|actual| actual == layer))
|
||||
.map(|a| a.id)
|
||||
.chain(
|
||||
self.seeds
|
||||
.values()
|
||||
.filter(|a| a.owner == owner)
|
||||
.map(|a| a.id),
|
||||
)
|
||||
.collect();
|
||||
if let Some(id) = selected {
|
||||
ids.insert(id);
|
||||
}
|
||||
if let Some(base) = trace.baseline.as_ref().and_then(|b| b.semantic.as_ref()) {
|
||||
for fact in &base.facets {
|
||||
if let Some((keys, _)) = parts(&fact.selector())
|
||||
&& let Some(id) = key(keys, layer)
|
||||
{
|
||||
ids.insert(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
let search = self.search.to_lowercase();
|
||||
let mut choices: Vec<_> = ids
|
||||
.into_iter()
|
||||
.map(|id| {
|
||||
let mut choice = self.choice(p, id);
|
||||
if let Some(artifact) = p.artifact_links.get(&id) {
|
||||
choice.label.push_str(&format!(
|
||||
" · owner: {}{}",
|
||||
p.named(artifact.owner),
|
||||
if artifact.owner == owner {
|
||||
""
|
||||
} else {
|
||||
" · not attached to reviewed owner"
|
||||
}
|
||||
));
|
||||
}
|
||||
choice
|
||||
})
|
||||
.filter(|c| Some(c.id) == selected || c.label.to_lowercase().contains(&search))
|
||||
.collect();
|
||||
choices.sort_by_cached_key(|c| c.label.to_lowercase());
|
||||
choices
|
||||
}
|
||||
pub fn ensure(
|
||||
&mut self,
|
||||
p: &Project,
|
||||
facet: &FacetSelector,
|
||||
layer: Layer,
|
||||
) -> Result<(), String> {
|
||||
let (keys, which) = parts(facet).ok_or("Choose artifact metadata.")?;
|
||||
let id = key(keys, layer).ok_or("Choose a destination artifact identity.")?;
|
||||
let record = p
|
||||
.artifact_links
|
||||
.get(&id)
|
||||
.or_else(|| self.seeds.get(&id))
|
||||
.ok_or("Reserve a destination counterpart before reviewing its metadata.")?;
|
||||
let value = raw(record, which).ok_or(
|
||||
"Presence uses explicit Keep or Take; there is no deletion tombstone candidate.",
|
||||
)?;
|
||||
self.drafts.entry(facet.clone()).or_insert(value);
|
||||
if which == ArtifactFacet::Description
|
||||
&& let Some(Raw::Text(text)) = self.drafts.get(facet)
|
||||
{
|
||||
self.descriptions
|
||||
.entry(facet.clone())
|
||||
.or_insert_with(|| text_editor::Content::with_text(text));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub fn update(
|
||||
&mut self,
|
||||
p: &Project,
|
||||
trace: &Trace,
|
||||
direction: TransitionDirection,
|
||||
event: Event,
|
||||
) -> Result<Option<Vec<FacetSelector>>, String> {
|
||||
match event {
|
||||
Event::Pick(layer, choice) => {
|
||||
if layer == Layer::Oa {
|
||||
self.oa = choice
|
||||
} else {
|
||||
self.sa = choice
|
||||
}
|
||||
}
|
||||
Event::Search(search) => self.search = search,
|
||||
Event::Stage => {
|
||||
let keys = self.pair();
|
||||
if keys.oa.is_none() && keys.sa.is_none() {
|
||||
return Err("Choose at least one exact artifact identity.".into());
|
||||
}
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
if key(&keys, layer)
|
||||
.and_then(|id| p.layer_of(id))
|
||||
.is_some_and(|actual| actual != layer)
|
||||
{
|
||||
return Err(
|
||||
"Artifact pair identity belongs to the other perspective.".into()
|
||||
);
|
||||
}
|
||||
}
|
||||
capture_facet(
|
||||
p,
|
||||
trace.oa,
|
||||
trace.sa,
|
||||
&selector(&keys, ArtifactFacet::Presence),
|
||||
)?;
|
||||
return Ok(Some(
|
||||
std::iter::once(ArtifactFacet::Presence)
|
||||
.chain(METADATA)
|
||||
.map(|facet| selector(&keys, facet))
|
||||
.filter(|facet| capture_facet(p, trace.oa, trace.sa, facet).is_ok())
|
||||
.collect(),
|
||||
));
|
||||
}
|
||||
Event::Reserve => {
|
||||
let source = key(&self.pair(), direction.source_layer())
|
||||
.and_then(|id| p.artifact_links.get(&id))
|
||||
.ok_or(
|
||||
"Choose an existing source artifact before reserving its counterpart.",
|
||||
)?;
|
||||
let source_owner = if direction.source_layer() == Layer::Oa {
|
||||
trace.oa
|
||||
} else {
|
||||
trace.sa
|
||||
};
|
||||
if source.owner != source_owner {
|
||||
return Err(
|
||||
"The source artifact is not attached to this reviewed owner.".into(),
|
||||
);
|
||||
}
|
||||
let mut seed = source.clone();
|
||||
seed.id = Id::new_v4();
|
||||
seed.alias = format!("artifact_{}", seed.id.simple());
|
||||
seed.owner = if direction.target_layer() == Layer::Oa {
|
||||
trace.oa
|
||||
} else {
|
||||
trace.sa
|
||||
};
|
||||
let choice = Choice {
|
||||
id: seed.id,
|
||||
label: format!("{} · reserved @{}", seed.name, seed.alias),
|
||||
};
|
||||
self.seeds.insert(seed.id, seed);
|
||||
if direction.target_layer() == Layer::Oa {
|
||||
self.oa = Some(choice)
|
||||
} else {
|
||||
self.sa = Some(choice)
|
||||
};
|
||||
}
|
||||
Event::Input(facet, value) => {
|
||||
let (_, which) = parts(&facet).ok_or("Choose artifact metadata.")?;
|
||||
if !compatible(which, &value) || !self.drafts.contains_key(&facet) {
|
||||
return Err(
|
||||
"Choose Set final value for this exact artifact metadata first.".into(),
|
||||
);
|
||||
}
|
||||
if which == ArtifactFacet::Description
|
||||
&& let Raw::Text(text) = &value
|
||||
{
|
||||
self.descriptions
|
||||
.insert(facet.clone(), text_editor::Content::with_text(text));
|
||||
}
|
||||
self.drafts.insert(facet, value);
|
||||
}
|
||||
Event::Description(facet, action) => {
|
||||
let editor = self
|
||||
.descriptions
|
||||
.get_mut(&facet)
|
||||
.ok_or("Choose the artifact description draft first.")?;
|
||||
editor.perform(action);
|
||||
self.drafts.insert(facet, Raw::Text(editor.text()));
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
pub fn editable(
|
||||
&self,
|
||||
p: &Project,
|
||||
trace: &Trace,
|
||||
facet: &FacetSelector,
|
||||
layer: Layer,
|
||||
) -> bool {
|
||||
let Some((keys, which)) = parts(facet) else {
|
||||
return false;
|
||||
};
|
||||
key(keys, layer).is_some()
|
||||
&& capture_facet(p, trace.oa, trace.sa, facet).is_ok()
|
||||
&& (which == ArtifactFacet::Presence
|
||||
|| key(keys, layer).is_some_and(|id| {
|
||||
p.artifact_links.contains_key(&id) || self.seeds.contains_key(&id)
|
||||
}))
|
||||
}
|
||||
pub fn propose(
|
||||
&self,
|
||||
p: &Project,
|
||||
trace: &Trace,
|
||||
direction: TransitionDirection,
|
||||
references: &BTreeMap<Id, Id>,
|
||||
decisions: &BTreeMap<FacetSelector, SemanticDecision>,
|
||||
) -> Result<Proposal, String> {
|
||||
let mut result = Proposal {
|
||||
slots: vec![],
|
||||
creations: vec![],
|
||||
candidates: BTreeSet::new(),
|
||||
};
|
||||
let mut pairs: BTreeMap<Id, Vec<(&FacetSelector, SemanticDecision)>> = BTreeMap::new();
|
||||
for (facet, decision) in decisions {
|
||||
if let Some((keys, _)) = parts(facet) {
|
||||
let id = key(keys, direction.target_layer())
|
||||
.ok_or("Choose a destination artifact identity.")?;
|
||||
pairs.entry(id).or_default().push((facet, *decision));
|
||||
}
|
||||
}
|
||||
for (id, selected) in pairs {
|
||||
let original = p.artifact_links.get(&id);
|
||||
let mut record = original.cloned();
|
||||
let mut deletion_origin = None;
|
||||
let owner = if direction.target_layer() == Layer::Oa {
|
||||
trace.oa
|
||||
} else {
|
||||
trace.sa
|
||||
};
|
||||
for (facet, decision) in &selected {
|
||||
let (keys, which) = parts(facet).expect("Artifact");
|
||||
if which != ArtifactFacet::Presence {
|
||||
continue;
|
||||
}
|
||||
if *decision == SemanticDecision::Candidate {
|
||||
return Err("Artifact presence uses Keep or Take; deletion requires explicit source-owner absence.".into());
|
||||
}
|
||||
if *decision == SemanticDecision::TakeSource
|
||||
&& key(keys, direction.source_layer()).is_some()
|
||||
{
|
||||
let fact = capture_facet(p, trace.oa, trace.sa, facet)?;
|
||||
let present = scalar::value(&fact, direction.source_layer())
|
||||
.and_then(|value| value.as_bool())
|
||||
.ok_or("Source artifact attachment unavailable.")?;
|
||||
if !present {
|
||||
record = None;
|
||||
deletion_origin = Some((*facet).clone());
|
||||
} else if record.is_none() {
|
||||
record=Some(self.seeds.get(&id).cloned().ok_or("Reserve a complete destination artifact counterpart before taking its presence.")?);
|
||||
} else if record.as_ref().is_some_and(|a| a.owner != owner) {
|
||||
return Err("Take presence cannot move an artifact from another owner; review its Owner from the initially attached trace.".into());
|
||||
}
|
||||
}
|
||||
}
|
||||
for (facet, decision) in &selected {
|
||||
let (keys, which) = parts(facet).expect("Artifact");
|
||||
if which == ArtifactFacet::Presence {
|
||||
continue;
|
||||
}
|
||||
for (layer, side_owner) in [(Layer::Oa, trace.oa), (Layer::Sa, trace.sa)] {
|
||||
if key(keys, layer)
|
||||
.and_then(|id| p.artifact_links.get(&id))
|
||||
.is_some_and(|a| a.owner != side_owner)
|
||||
{
|
||||
return Err("Artifact metadata must be reviewed from its initially attached owner trace.".into());
|
||||
}
|
||||
}
|
||||
if *decision == SemanticDecision::KeepTarget {
|
||||
continue;
|
||||
}
|
||||
let value = if *decision == SemanticDecision::Candidate {
|
||||
self.drafts
|
||||
.get(*facet)
|
||||
.cloned()
|
||||
.ok_or("Choose an explicit final metadata value.")?
|
||||
} else {
|
||||
if key(keys, direction.source_layer()).is_none() {
|
||||
continue;
|
||||
}
|
||||
let source=key(keys,direction.source_layer()).and_then(|id|p.artifact_links.get(&id)).ok_or("Source artifact metadata is absent; keep or set the target metadata explicitly.")?;
|
||||
let mut value = raw(source, which).expect("Metadata");
|
||||
if let Raw::Owner(ref mut owner) = value {
|
||||
let from = source.owner;
|
||||
let target = if direction == TransitionDirection::OaToSa {
|
||||
references.get(&from).copied()
|
||||
} else {
|
||||
let ids: Vec<_> = references
|
||||
.iter()
|
||||
.filter(|(_, sa)| **sa == from)
|
||||
.map(|(oa, _)| *oa)
|
||||
.collect();
|
||||
(ids.len() == 1).then(|| ids[0])
|
||||
}
|
||||
.ok_or("Map the source artifact owner explicitly.")?;
|
||||
*owner = target.to_string();
|
||||
}
|
||||
value
|
||||
};
|
||||
write(
|
||||
record
|
||||
.as_mut()
|
||||
.ok_or("This metadata decision conflicts with absent artifact presence.")?,
|
||||
which,
|
||||
&value,
|
||||
)?;
|
||||
if *decision == SemanticDecision::Candidate {
|
||||
result.candidates.insert(id);
|
||||
}
|
||||
}
|
||||
for (facet, decision) in &selected {
|
||||
let (_, which) = parts(facet).expect("Artifact");
|
||||
result.slots.push((
|
||||
slot(id, which),
|
||||
observation(
|
||||
if *decision == SemanticDecision::KeepTarget {
|
||||
original
|
||||
} else {
|
||||
record.as_ref()
|
||||
},
|
||||
which,
|
||||
),
|
||||
(*facet).clone(),
|
||||
));
|
||||
}
|
||||
if original.is_some() && record.is_none() {
|
||||
for which in METADATA {
|
||||
result.slots.push((
|
||||
slot(id, which),
|
||||
observation(None, which),
|
||||
deletion_origin
|
||||
.clone()
|
||||
.expect("Absent record has an explicit Presence decision"),
|
||||
));
|
||||
}
|
||||
}
|
||||
if original.is_none()
|
||||
&& let Some(record) = record
|
||||
{
|
||||
result.candidates.insert(id);
|
||||
result.creations.push(record);
|
||||
}
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
pub fn recovery_snapshot(&self) -> Saved {
|
||||
Saved {
|
||||
oa: self.oa.as_ref().map(|c| c.id),
|
||||
sa: self.sa.as_ref().map(|c| c.id),
|
||||
seeds: self.seeds.values().cloned().collect(),
|
||||
drafts: self
|
||||
.drafts
|
||||
.iter()
|
||||
.map(|(facet, value)| (facet.clone(), value.clone()))
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
pub fn from_recovery(
|
||||
p: &Project,
|
||||
trace: &Trace,
|
||||
direction: TransitionDirection,
|
||||
saved: Saved,
|
||||
) -> Result<Self, String> {
|
||||
if saved.seeds.len().saturating_add(saved.drafts.len())
|
||||
> crate::source_workspace::LIMITS.model.max_records
|
||||
{
|
||||
return Err("Artifact recovery exceeds supported limits.".into());
|
||||
}
|
||||
let mut editor = Self::default();
|
||||
let owner = if direction.target_layer() == Layer::Oa {
|
||||
trace.oa
|
||||
} else {
|
||||
trace.sa
|
||||
};
|
||||
let mut aliases = BTreeSet::new();
|
||||
for seed in saved.seeds {
|
||||
if p.contains(seed.id)
|
||||
|| seed.owner != owner
|
||||
|| seed.alias != format!("artifact_{}", seed.id.simple())
|
||||
|| !aliases.insert(seed.alias.clone())
|
||||
|| editor.seeds.insert(seed.id, seed).is_some()
|
||||
{
|
||||
return Err("Recovered reserved artifact identity, owner or alias is invalid or duplicated.".into());
|
||||
}
|
||||
}
|
||||
for (facet, value) in saved.drafts {
|
||||
let (keys, which) =
|
||||
parts(&facet).ok_or("Recovered artifact draft has a different facet family.")?;
|
||||
if !compatible(which, &value) {
|
||||
return Err("Recovered artifact draft type disagrees with its facet.".into());
|
||||
}
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
if key(keys, layer)
|
||||
.and_then(|id| p.layer_of(id))
|
||||
.is_some_and(|actual| actual != layer)
|
||||
{
|
||||
return Err("Recovered artifact pair has a wrong-layer identity.".into());
|
||||
}
|
||||
}
|
||||
let id = key(keys, direction.target_layer())
|
||||
.ok_or("Recovered artifact draft has no destination identity.")?;
|
||||
if !p.artifact_links.contains_key(&id) && !editor.seeds.contains_key(&id) {
|
||||
return Err(
|
||||
"Recovered artifact draft has no existing or reserved destination record."
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
if let (ArtifactFacet::Description, Raw::Text(text)) = (which, &value) {
|
||||
editor
|
||||
.descriptions
|
||||
.insert(facet.clone(), text_editor::Content::with_text(text));
|
||||
}
|
||||
if editor.drafts.insert(facet, value).is_some() {
|
||||
return Err("Recovered artifact draft address is duplicated.".into());
|
||||
}
|
||||
}
|
||||
editor.oa = saved.oa.map(|id| editor.choice(p, id));
|
||||
editor.sa = saved.sa.map(|id| editor.choice(p, id));
|
||||
Ok(editor)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
//! Independent admission falsifiers: corrupt stored JSON, never private Saved structs.
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
struct Checkpoint {
|
||||
project: Project,
|
||||
state: State,
|
||||
target_owner: Id,
|
||||
source_link: Id,
|
||||
keys: KeyPair<Id>,
|
||||
}
|
||||
|
||||
fn send(state: &mut State, project: &Project, event: RowEvent) {
|
||||
let addressed = state.address_event(event);
|
||||
state.update(project, addressed);
|
||||
}
|
||||
|
||||
fn facet(keys: &KeyPair<Id>, which: ArtifactFacet) -> FacetSelector {
|
||||
FacetSelector::Artifact {
|
||||
keys: keys.clone(),
|
||||
facet: which,
|
||||
}
|
||||
}
|
||||
|
||||
fn checkpoint(reverse: bool) -> Checkpoint {
|
||||
let mut project = Project::empty();
|
||||
let oa = project
|
||||
.add_element(Kind::Activity, Layer::Oa, "Reviewed activity", None)
|
||||
.unwrap();
|
||||
let sa = project
|
||||
.add_element(Kind::Function, Layer::Sa, "Reviewed function", None)
|
||||
.unwrap();
|
||||
let (source_owner, target_owner, source_layer) = if reverse {
|
||||
(sa, oa, Layer::Sa)
|
||||
} else {
|
||||
(oa, sa, Layer::Oa)
|
||||
};
|
||||
let source_link = Id::new_v4();
|
||||
project.artifact_links.insert(
|
||||
source_link,
|
||||
ArtifactLink {
|
||||
id: source_link,
|
||||
alias: "reviewed_requirement".into(),
|
||||
name: "Interlock evidence".into(),
|
||||
description: "First line\r\nSecond line".into(),
|
||||
owner: source_owner,
|
||||
relation: ArtifactRelation::Verifies,
|
||||
uri: "requirements/interlock".into(),
|
||||
revision: None,
|
||||
fragment: Some(String::new()),
|
||||
},
|
||||
);
|
||||
let trace = super::tests::add_trace(&mut project, oa, sa, "Evidence correspondence");
|
||||
let mut state = State::new(&project, trace).unwrap();
|
||||
if reverse {
|
||||
state.update(&project, Event::Direction(TransitionDirection::SaToOa));
|
||||
}
|
||||
send(
|
||||
&mut state,
|
||||
&project,
|
||||
RowEvent::Artifact(artifacts::Event::Pick(
|
||||
source_layer,
|
||||
Some(view::choice(&project, source_link)),
|
||||
)),
|
||||
);
|
||||
send(
|
||||
&mut state,
|
||||
&project,
|
||||
RowEvent::Artifact(artifacts::Event::Reserve),
|
||||
);
|
||||
send(
|
||||
&mut state,
|
||||
&project,
|
||||
RowEvent::Artifact(artifacts::Event::Stage),
|
||||
);
|
||||
let keys = state.artifacts.pair();
|
||||
for which in [
|
||||
ArtifactFacet::Presence,
|
||||
ArtifactFacet::Name,
|
||||
ArtifactFacet::Description,
|
||||
ArtifactFacet::Owner,
|
||||
ArtifactFacet::Relation,
|
||||
ArtifactFacet::Uri,
|
||||
ArtifactFacet::Revision,
|
||||
ArtifactFacet::Fragment,
|
||||
] {
|
||||
let address = facet(&keys, which);
|
||||
send(&mut state, &project, RowEvent::Select(address.clone()));
|
||||
send(
|
||||
&mut state,
|
||||
&project,
|
||||
RowEvent::Decide(
|
||||
address,
|
||||
Some(if which == ArtifactFacet::Presence {
|
||||
SemanticDecision::TakeSource
|
||||
} else {
|
||||
SemanticDecision::Candidate
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
assert!(state.can_apply(), "{:?}", state.error);
|
||||
for (which, raw) in [
|
||||
(
|
||||
ArtifactFacet::Owner,
|
||||
artifacts::Raw::Owner("unfinished owner -".into()),
|
||||
),
|
||||
(
|
||||
ArtifactFacet::Revision,
|
||||
artifacts::Raw::Optional {
|
||||
present: false,
|
||||
text: "retained revision\r\nnext line".into(),
|
||||
},
|
||||
),
|
||||
(
|
||||
ArtifactFacet::Fragment,
|
||||
artifacts::Raw::Optional {
|
||||
present: true,
|
||||
text: String::new(),
|
||||
},
|
||||
),
|
||||
] {
|
||||
send(
|
||||
&mut state,
|
||||
&project,
|
||||
RowEvent::Artifact(artifacts::Event::Input(facet(&keys, which), raw)),
|
||||
);
|
||||
}
|
||||
assert!(!state.can_apply());
|
||||
Checkpoint {
|
||||
project,
|
||||
state,
|
||||
target_owner,
|
||||
source_link,
|
||||
keys,
|
||||
}
|
||||
}
|
||||
|
||||
fn artifacts_json(saved: &mut Value) -> &mut Value {
|
||||
assert_eq!(saved["version"], 2);
|
||||
// Switching direction retains the earlier row. Corrupt the exact active
|
||||
// row's authored payload, regardless of retained rows or their ordering.
|
||||
let active = saved["active"].clone();
|
||||
assert!(!active.is_null());
|
||||
let rows = saved["rows"].as_array_mut().unwrap();
|
||||
assert_eq!(
|
||||
rows.iter().filter(|row| row["address"] == active).count(),
|
||||
1
|
||||
);
|
||||
&mut rows
|
||||
.iter_mut()
|
||||
.find(|row| row["address"] == active)
|
||||
.unwrap()["draft"]["artifacts"]
|
||||
}
|
||||
|
||||
fn owner_draft<'a>(saved: &'a mut Value, keys: &KeyPair<Id>) -> &'a mut Value {
|
||||
let address = serde_json::to_value(facet(keys, ArtifactFacet::Owner)).unwrap();
|
||||
artifacts_json(saved)["drafts"]
|
||||
.as_array_mut()
|
||||
.unwrap()
|
||||
.iter_mut()
|
||||
.find(|entry| entry[0] == address)
|
||||
.expect("authored Owner draft")
|
||||
}
|
||||
|
||||
fn refuses_without_changing_authored_or_accepted_state(
|
||||
case: &str,
|
||||
checkpoint: &mut Checkpoint,
|
||||
forged: Value,
|
||||
) {
|
||||
let before = checkpoint.project.clone();
|
||||
let source = oa_core::language::format(&before);
|
||||
let authored = checkpoint.state.recovery_snapshot().unwrap();
|
||||
assert!(
|
||||
State::from_recovery(&checkpoint.project, forged).is_err(),
|
||||
"accepted forged {case}"
|
||||
);
|
||||
assert_eq!(
|
||||
checkpoint.state.recovery_snapshot().unwrap(),
|
||||
authored,
|
||||
"{case}"
|
||||
);
|
||||
assert!(
|
||||
checkpoint.state.command(&checkpoint.project).is_err(),
|
||||
"invalid raw draft became executable: {case}"
|
||||
);
|
||||
assert_eq!(checkpoint.project, before, "{case}");
|
||||
assert_eq!(
|
||||
oa_core::language::format(&checkpoint.project),
|
||||
source,
|
||||
"{case}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forged_artifact_reservations_cannot_duplicate_or_claim_an_existing_identity_or_another_owner() {
|
||||
for reverse in [false, true] {
|
||||
let mut c = checkpoint(reverse);
|
||||
let saved = c.state.recovery_snapshot().unwrap();
|
||||
for case in [
|
||||
"duplicate seed",
|
||||
"existing identity",
|
||||
"wrong owner",
|
||||
"wrong alias",
|
||||
] {
|
||||
let mut forged = saved.clone();
|
||||
let seeds = artifacts_json(&mut forged)["seeds"].as_array_mut().unwrap();
|
||||
assert_eq!(seeds.len(), 1);
|
||||
match case {
|
||||
"duplicate seed" => seeds.push(seeds[0].clone()),
|
||||
"existing identity" => {
|
||||
seeds[0]["id"] = json!(c.source_link);
|
||||
seeds[0]["alias"] = json!(format!("artifact_{}", c.source_link.simple()));
|
||||
}
|
||||
"wrong owner" => seeds[0]["owner"] = json!(c.project.id),
|
||||
"wrong alias" => seeds[0]["alias"] = json!("forged_alias"),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
refuses_without_changing_authored_or_accepted_state(case, &mut c, forged);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forged_artifact_raw_variants_duplicate_addresses_and_unknown_fields_are_refused() {
|
||||
for reverse in [false, true] {
|
||||
let mut c = checkpoint(reverse);
|
||||
let saved = c.state.recovery_snapshot().unwrap();
|
||||
for case in [
|
||||
"wrong raw variant",
|
||||
"duplicate facet",
|
||||
"unknown artifact field",
|
||||
] {
|
||||
let mut forged = saved.clone();
|
||||
match case {
|
||||
"wrong raw variant" => {
|
||||
owner_draft(&mut forged, &c.keys)[1] = json!({"Text": "unfinished owner -"})
|
||||
}
|
||||
"duplicate facet" => {
|
||||
let duplicate = owner_draft(&mut forged, &c.keys).clone();
|
||||
artifacts_json(&mut forged)["drafts"]
|
||||
.as_array_mut()
|
||||
.unwrap()
|
||||
.push(duplicate);
|
||||
}
|
||||
"unknown artifact field" => artifacts_json(&mut forged)["execute"] = json!(true),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
refuses_without_changing_authored_or_accepted_state(case, &mut c, forged);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forged_artifact_draft_cannot_target_an_unreserved_or_non_artifact_identity() {
|
||||
for reverse in [false, true] {
|
||||
let mut c = checkpoint(reverse);
|
||||
let saved = c.state.recovery_snapshot().unwrap();
|
||||
for (case, destination) in [
|
||||
("nonexistent destination", Id::new_v4()),
|
||||
("element destination", c.target_owner),
|
||||
] {
|
||||
let mut forged = saved.clone();
|
||||
let mut keys = c.keys.clone();
|
||||
if reverse {
|
||||
keys.oa = Some(destination);
|
||||
} else {
|
||||
keys.sa = Some(destination);
|
||||
}
|
||||
owner_draft(&mut forged, &c.keys)[0] =
|
||||
serde_json::to_value(facet(&keys, ArtifactFacet::Owner)).unwrap();
|
||||
refuses_without_changing_authored_or_accepted_state(case, &mut c, forged);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legitimate_invalid_artifact_checkpoint_round_trips_and_prepares_only_after_explicit_repair() {
|
||||
for reverse in [false, true] {
|
||||
let mut c = checkpoint(reverse);
|
||||
let accepted = c.project.clone();
|
||||
let accepted_source = oa_core::language::format(&accepted);
|
||||
let saved = c.state.recovery_snapshot().unwrap();
|
||||
let mut restored = State::from_recovery(&c.project, saved.clone()).unwrap();
|
||||
assert_eq!(restored.recovery_snapshot().unwrap(), saved);
|
||||
assert_eq!(
|
||||
owner_draft(&mut saved.clone(), &c.keys)[1],
|
||||
json!({"Owner": "unfinished owner -"})
|
||||
);
|
||||
assert!(!restored.can_apply());
|
||||
assert!(restored.command(&c.project).is_err());
|
||||
let repair = RowEvent::Artifact(artifacts::Event::Input(
|
||||
facet(&c.keys, ArtifactFacet::Owner),
|
||||
artifacts::Raw::Owner(c.target_owner.to_string()),
|
||||
));
|
||||
send(&mut restored, &c.project, repair.clone());
|
||||
send(&mut c.state, &c.project, repair);
|
||||
let restored_command = restored.command(&c.project).unwrap();
|
||||
let uninterrupted_command = c.state.command(&c.project).unwrap();
|
||||
let prepared = prepare_command(&c.project, &restored_command)
|
||||
.unwrap()
|
||||
.project;
|
||||
let uninterrupted = prepare_command(&c.project, &uninterrupted_command)
|
||||
.unwrap()
|
||||
.project;
|
||||
assert_eq!(prepared, uninterrupted);
|
||||
assert_eq!(
|
||||
oa_core::language::format(&prepared),
|
||||
oa_core::language::format(&uninterrupted)
|
||||
);
|
||||
let destination = if reverse {
|
||||
c.keys.oa.unwrap()
|
||||
} else {
|
||||
c.keys.sa.unwrap()
|
||||
};
|
||||
assert_eq!(prepared.artifact_links[&destination].revision, None);
|
||||
assert_eq!(
|
||||
prepared.artifact_links[&destination].fragment,
|
||||
Some(String::new())
|
||||
);
|
||||
assert_eq!(c.project, accepted);
|
||||
assert_eq!(oa_core::language::format(&c.project), accepted_source);
|
||||
assert!(
|
||||
!c.project.contains(destination),
|
||||
"preparation must not execute creation"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,634 @@
|
||||
//! Core lifecycle boundaries and graphical artifact review oracles.
|
||||
use super::*;
|
||||
struct Fixture {
|
||||
p: Project,
|
||||
owners: [Id; 2],
|
||||
other: [Id; 2],
|
||||
links: [Id; 2],
|
||||
trace: Id,
|
||||
}
|
||||
fn fixture() -> Fixture {
|
||||
let mut p = Project::empty();
|
||||
let mut owners = vec![];
|
||||
let mut other = vec![];
|
||||
let mut links = vec![];
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let kind = if layer == Layer::Oa {
|
||||
Kind::Activity
|
||||
} else {
|
||||
Kind::Function
|
||||
};
|
||||
let owner = p.add_element(kind, layer, "Reviewed owner", None).unwrap();
|
||||
let moved = p.add_element(kind, layer, "Other owner", None).unwrap();
|
||||
let id = Id::new_v4();
|
||||
p.artifact_links.insert(
|
||||
id,
|
||||
ArtifactLink {
|
||||
id,
|
||||
alias: format!("artifact_{}", id.simple()),
|
||||
name: "Interlock requirement".into(),
|
||||
description: "Exact\r\nengineering rationale".into(),
|
||||
owner,
|
||||
relation: ArtifactRelation::Satisfies,
|
||||
uri: "requirements/interlock".into(),
|
||||
revision: None,
|
||||
fragment: Some(String::new()),
|
||||
},
|
||||
);
|
||||
owners.push(owner);
|
||||
other.push(moved);
|
||||
links.push(id);
|
||||
}
|
||||
let trace = super::tests::add_trace(&mut p, owners[0], owners[1], "Requirement correspondence");
|
||||
Fixture {
|
||||
p,
|
||||
owners: [owners[0], owners[1]],
|
||||
other: [other[0], other[1]],
|
||||
links: [links[0], links[1]],
|
||||
trace,
|
||||
}
|
||||
}
|
||||
fn selector(keys: &KeyPair<Id>, facet: ArtifactFacet) -> FacetSelector {
|
||||
FacetSelector::Artifact {
|
||||
keys: keys.clone(),
|
||||
facet,
|
||||
}
|
||||
}
|
||||
fn request(
|
||||
f: &Fixture,
|
||||
reverse: bool,
|
||||
keys: &KeyPair<Id>,
|
||||
decisions: Vec<(ArtifactFacet, 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 r = SemanticTransitionRequest::new(transition);
|
||||
r.traces.insert(f.owners[source], f.trace);
|
||||
r.references.insert(f.owners[0], f.owners[1]);
|
||||
if let Some((oa, sa)) = keys.oa.zip(keys.sa) {
|
||||
r.references.insert(oa, sa);
|
||||
}
|
||||
r.reviews.push(SemanticReviewRequest {
|
||||
source: f.owners[source],
|
||||
resolutions: decisions
|
||||
.into_iter()
|
||||
.map(|(facet, decision)| FacetResolution {
|
||||
facet: selector(keys, facet),
|
||||
decision,
|
||||
})
|
||||
.collect(),
|
||||
reset: vec![],
|
||||
});
|
||||
r
|
||||
}
|
||||
const METADATA: [ArtifactFacet; 7] = [
|
||||
ArtifactFacet::Name,
|
||||
ArtifactFacet::Description,
|
||||
ArtifactFacet::Owner,
|
||||
ArtifactFacet::Relation,
|
||||
ArtifactFacet::Uri,
|
||||
ArtifactFacet::Revision,
|
||||
ArtifactFacet::Fragment,
|
||||
];
|
||||
#[test]
|
||||
fn artifact_creation_and_identified_source_deletion_follow_core_metadata_and_history_boundary_both_directions()
|
||||
{
|
||||
for reverse in [false, true] {
|
||||
let mut f = fixture();
|
||||
let source = usize::from(reverse);
|
||||
let target = 1 - source;
|
||||
f.p.artifact_links.remove(&f.links[target]);
|
||||
let keys = KeyPair {
|
||||
oa: Some(f.links[0]),
|
||||
sa: Some(f.links[1]),
|
||||
};
|
||||
let mut candidate = f.p.artifact_links[&f.links[source]].clone();
|
||||
candidate.id = f.links[target];
|
||||
candidate.alias = format!("artifact_{}", candidate.id.simple());
|
||||
candidate.owner = f.owners[target];
|
||||
let mut r = request(
|
||||
&f,
|
||||
reverse,
|
||||
&keys,
|
||||
vec![(ArtifactFacet::Presence, SemanticDecision::TakeSource)],
|
||||
);
|
||||
r.candidates
|
||||
.push(SemanticRecord::Artifact(candidate.clone()));
|
||||
assert!(
|
||||
!plan_semantic_transition(&f.p, r.clone())
|
||||
.unwrap()
|
||||
.conflicts
|
||||
.is_empty()
|
||||
);
|
||||
r.reviews[0]
|
||||
.resolutions
|
||||
.extend(METADATA.map(|facet| FacetResolution {
|
||||
facet: selector(&keys, facet),
|
||||
decision: SemanticDecision::Candidate,
|
||||
}));
|
||||
let plan = plan_semantic_transition(&f.p, r).unwrap();
|
||||
assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts);
|
||||
f.p = prepare_semantic_transition(&f.p, &plan).unwrap().project;
|
||||
assert_eq!(f.p.artifact_links[&f.links[target]], candidate);
|
||||
assert_eq!(
|
||||
f.p.traces[&f.trace]
|
||||
.baseline
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.semantic
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.facets
|
||||
.len(),
|
||||
8
|
||||
);
|
||||
f.p = prepare_command(&f.p, &Command::Delete(f.links[source]))
|
||||
.unwrap()
|
||||
.project;
|
||||
let deletion = request(
|
||||
&f,
|
||||
reverse,
|
||||
&keys,
|
||||
vec![(ArtifactFacet::Presence, SemanticDecision::TakeSource)],
|
||||
);
|
||||
let plan = plan_semantic_transition(&f.p, deletion).unwrap();
|
||||
assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts);
|
||||
assert_eq!(plan.reviews[0].retired.len(), 7);
|
||||
let after = prepare_semantic_transition(&f.p, &plan).unwrap().project;
|
||||
assert!(!after.artifact_links.contains_key(&f.links[target]));
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn artifact_candidate_is_a_complete_record_not_a_tombstone_or_unattached_creation_shortcut() {
|
||||
for reverse in [false, true] {
|
||||
let mut f = fixture();
|
||||
let target = usize::from(!reverse);
|
||||
let keys = KeyPair {
|
||||
oa: Some(f.links[0]),
|
||||
sa: Some(f.links[1]),
|
||||
};
|
||||
let mut r = request(
|
||||
&f,
|
||||
reverse,
|
||||
&keys,
|
||||
vec![(ArtifactFacet::Presence, SemanticDecision::Candidate)],
|
||||
);
|
||||
let mut moved = f.p.artifact_links[&f.links[target]].clone();
|
||||
moved.owner = f.other[target];
|
||||
r.candidates.push(SemanticRecord::Artifact(moved));
|
||||
assert!(
|
||||
!plan_semantic_transition(&f.p, r)
|
||||
.unwrap()
|
||||
.conflicts
|
||||
.is_empty(),
|
||||
"A moved record cannot serve as a deletion tombstone"
|
||||
);
|
||||
let mut candidate = f.p.artifact_links[&f.links[target]].clone();
|
||||
candidate.id = Id::new_v4();
|
||||
candidate.alias = format!("artifact_{}", candidate.id.simple());
|
||||
f.p.artifact_links.clear();
|
||||
let keys = if reverse {
|
||||
KeyPair {
|
||||
oa: Some(candidate.id),
|
||||
sa: None,
|
||||
}
|
||||
} else {
|
||||
KeyPair {
|
||||
oa: None,
|
||||
sa: Some(candidate.id),
|
||||
}
|
||||
};
|
||||
let mut decisions = vec![(ArtifactFacet::Presence, SemanticDecision::Candidate)];
|
||||
decisions.extend(METADATA.map(|facet| (facet, SemanticDecision::Candidate)));
|
||||
let mut r = request(&f, reverse, &keys, decisions);
|
||||
r.candidates.push(SemanticRecord::Artifact(candidate));
|
||||
assert!(
|
||||
!plan_semantic_transition(&f.p, r)
|
||||
.unwrap()
|
||||
.conflicts
|
||||
.is_empty(),
|
||||
"Metadata has no applicable initial source or target record"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn send(state: &mut State, p: &Project, event: RowEvent) {
|
||||
let event = state.address_event(event);
|
||||
state.update(p, event);
|
||||
}
|
||||
fn open(f: &Fixture, reverse: bool, keys: &KeyPair<Id>) -> State {
|
||||
let mut state = State::new(&f.p, f.trace).unwrap();
|
||||
if reverse {
|
||||
state.update(&f.p, Event::Direction(TransitionDirection::SaToOa));
|
||||
}
|
||||
send(
|
||||
&mut state,
|
||||
&f.p,
|
||||
RowEvent::Artifact(artifacts::Event::Pick(
|
||||
Layer::Oa,
|
||||
keys.oa.map(|id| view::choice(&f.p, id)),
|
||||
)),
|
||||
);
|
||||
send(
|
||||
&mut state,
|
||||
&f.p,
|
||||
RowEvent::Artifact(artifacts::Event::Pick(
|
||||
Layer::Sa,
|
||||
keys.sa.map(|id| view::choice(&f.p, id)),
|
||||
)),
|
||||
);
|
||||
send(
|
||||
&mut state,
|
||||
&f.p,
|
||||
RowEvent::Artifact(artifacts::Event::Stage),
|
||||
);
|
||||
state
|
||||
}
|
||||
fn decide(
|
||||
state: &mut State,
|
||||
p: &Project,
|
||||
keys: &KeyPair<Id>,
|
||||
which: ArtifactFacet,
|
||||
decision: SemanticDecision,
|
||||
) {
|
||||
let facet = selector(keys, which);
|
||||
send(state, p, RowEvent::Select(facet.clone()));
|
||||
send(state, p, RowEvent::Decide(facet, Some(decision)));
|
||||
}
|
||||
fn input(
|
||||
state: &mut State,
|
||||
p: &Project,
|
||||
keys: &KeyPair<Id>,
|
||||
which: ArtifactFacet,
|
||||
value: artifacts::Raw,
|
||||
) {
|
||||
send(
|
||||
state,
|
||||
p,
|
||||
RowEvent::Artifact(artifacts::Event::Input(selector(keys, which), value)),
|
||||
);
|
||||
}
|
||||
fn errors(state: &State) -> String {
|
||||
format!(
|
||||
"{:?} / {:?}",
|
||||
state.error,
|
||||
state.plan.as_ref().map(|p| &p.conflicts)
|
||||
)
|
||||
}
|
||||
#[test]
|
||||
fn artifact_gui_reserves_recovers_and_creates_only_after_all_seven_metadata_reviews_both_directions()
|
||||
{
|
||||
for reverse in [false, true] {
|
||||
let mut f = fixture();
|
||||
let target = usize::from(!reverse);
|
||||
f.p.artifact_links.remove(&f.links[target]);
|
||||
let keys = if reverse {
|
||||
KeyPair {
|
||||
oa: None,
|
||||
sa: Some(f.links[1]),
|
||||
}
|
||||
} else {
|
||||
KeyPair {
|
||||
oa: Some(f.links[0]),
|
||||
sa: None,
|
||||
}
|
||||
};
|
||||
let mut state = open(&f, reverse, &keys);
|
||||
send(
|
||||
&mut state,
|
||||
&f.p,
|
||||
RowEvent::Artifact(artifacts::Event::Reserve),
|
||||
);
|
||||
send(
|
||||
&mut state,
|
||||
&f.p,
|
||||
RowEvent::Artifact(artifacts::Event::Stage),
|
||||
);
|
||||
let keys = state.artifacts.pair();
|
||||
let id = artifacts::key(&keys, state.direction.target_layer()).unwrap();
|
||||
assert!(!f.p.contains(id));
|
||||
let saved = state.recovery_snapshot().unwrap();
|
||||
let mut state = State::from_recovery(&f.p, saved.clone()).unwrap();
|
||||
assert_eq!(state.recovery_snapshot().unwrap(), saved);
|
||||
assert_eq!(state.artifacts.pair(), keys);
|
||||
decide(
|
||||
&mut state,
|
||||
&f.p,
|
||||
&keys,
|
||||
ArtifactFacet::Presence,
|
||||
SemanticDecision::TakeSource,
|
||||
);
|
||||
assert!(!state.can_apply());
|
||||
for facet in METADATA {
|
||||
decide(&mut state, &f.p, &keys, facet, SemanticDecision::Candidate);
|
||||
}
|
||||
assert!(state.can_apply(), "{}", errors(&state));
|
||||
let mut direct = request(
|
||||
&f,
|
||||
reverse,
|
||||
&keys,
|
||||
vec![(ArtifactFacet::Presence, SemanticDecision::TakeSource)],
|
||||
);
|
||||
direct.reviews[0]
|
||||
.resolutions
|
||||
.extend(METADATA.map(|facet| FacetResolution {
|
||||
facet: selector(&keys, facet),
|
||||
decision: SemanticDecision::Candidate,
|
||||
}));
|
||||
direct
|
||||
.candidates
|
||||
.push(SemanticRecord::Artifact(state.artifacts.seeds[&id].clone()));
|
||||
let plan = plan_semantic_transition(&f.p, direct).unwrap();
|
||||
assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts);
|
||||
let expected = prepare_semantic_transition(&f.p, &plan).unwrap().project;
|
||||
assert_eq!(state.proposed.as_ref(), Some(&expected));
|
||||
assert_eq!(expected.artifact_links[&id].revision, None);
|
||||
assert_eq!(expected.artifact_links[&id].fragment, Some(String::new()));
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn artifact_move_metadata_none_empty_and_invalid_raw_recovery_are_independent_both_directions() {
|
||||
for reverse in [false, true] {
|
||||
let f = fixture();
|
||||
let keys = KeyPair {
|
||||
oa: Some(f.links[0]),
|
||||
sa: Some(f.links[1]),
|
||||
};
|
||||
let target = usize::from(!reverse);
|
||||
let mut state = open(&f, reverse, &keys);
|
||||
decide(
|
||||
&mut state,
|
||||
&f.p,
|
||||
&keys,
|
||||
ArtifactFacet::Owner,
|
||||
SemanticDecision::Candidate,
|
||||
);
|
||||
input(
|
||||
&mut state,
|
||||
&f.p,
|
||||
&keys,
|
||||
ArtifactFacet::Owner,
|
||||
artifacts::Raw::Owner(f.other[target].to_string()),
|
||||
);
|
||||
decide(
|
||||
&mut state,
|
||||
&f.p,
|
||||
&keys,
|
||||
ArtifactFacet::Revision,
|
||||
SemanticDecision::Candidate,
|
||||
);
|
||||
input(
|
||||
&mut state,
|
||||
&f.p,
|
||||
&keys,
|
||||
ArtifactFacet::Revision,
|
||||
artifacts::Raw::Optional {
|
||||
present: true,
|
||||
text: String::new(),
|
||||
},
|
||||
);
|
||||
decide(
|
||||
&mut state,
|
||||
&f.p,
|
||||
&keys,
|
||||
ArtifactFacet::Fragment,
|
||||
SemanticDecision::Candidate,
|
||||
);
|
||||
input(
|
||||
&mut state,
|
||||
&f.p,
|
||||
&keys,
|
||||
ArtifactFacet::Fragment,
|
||||
artifacts::Raw::Optional {
|
||||
present: false,
|
||||
text: "retained fragment draft".into(),
|
||||
},
|
||||
);
|
||||
assert!(state.can_apply(), "{}", errors(&state));
|
||||
let result = state.proposed.as_ref().unwrap();
|
||||
assert_eq!(
|
||||
result.artifact_links[&f.links[target]].owner,
|
||||
f.other[target]
|
||||
);
|
||||
assert_eq!(
|
||||
result.artifact_links[&f.links[target]].revision,
|
||||
Some(String::new())
|
||||
);
|
||||
assert_eq!(result.artifact_links[&f.links[target]].fragment, None);
|
||||
decide(
|
||||
&mut state,
|
||||
&f.p,
|
||||
&keys,
|
||||
ArtifactFacet::Presence,
|
||||
SemanticDecision::KeepTarget,
|
||||
);
|
||||
assert!(!state.can_apply());
|
||||
assert!(errors(&state).contains("disagree"), "{}", errors(&state));
|
||||
send(
|
||||
&mut state,
|
||||
&f.p,
|
||||
RowEvent::Decide(selector(&keys, ArtifactFacet::Presence), None),
|
||||
);
|
||||
assert!(state.can_apply(), "{}", errors(&state));
|
||||
input(
|
||||
&mut state,
|
||||
&f.p,
|
||||
&keys,
|
||||
ArtifactFacet::Owner,
|
||||
artifacts::Raw::Owner("-".into()),
|
||||
);
|
||||
assert!(!state.can_apply());
|
||||
let address = state.active_address.unwrap();
|
||||
state.update(&f.p, Event::Include(address, false));
|
||||
let saved = state.recovery_snapshot().unwrap();
|
||||
let mut restored = State::from_recovery(&f.p, saved.clone()).unwrap();
|
||||
assert_eq!(restored.recovery_snapshot().unwrap(), saved);
|
||||
assert_eq!(
|
||||
restored.artifacts.drafts[&selector(&keys, ArtifactFacet::Owner)],
|
||||
artifacts::Raw::Owner("-".into())
|
||||
);
|
||||
assert_eq!(
|
||||
restored.artifacts.drafts[&selector(&keys, ArtifactFacet::Fragment)],
|
||||
artifacts::Raw::Optional {
|
||||
present: false,
|
||||
text: "retained fragment draft".into()
|
||||
}
|
||||
);
|
||||
restored.update(&f.p, Event::Include(address, true));
|
||||
input(
|
||||
&mut restored,
|
||||
&f.p,
|
||||
&keys,
|
||||
ArtifactFacet::Owner,
|
||||
artifacts::Raw::Owner(f.other[target].to_string()),
|
||||
);
|
||||
assert!(restored.can_apply(), "{}", errors(&restored));
|
||||
assert_eq!(f.p.artifact_links[&f.links[target]].owner, f.owners[target]);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn artifact_moved_source_absence_deletes_but_destination_only_take_retains_both_directions() {
|
||||
for reverse in [false, true] {
|
||||
let mut f = fixture();
|
||||
let source = usize::from(reverse);
|
||||
let target = 1 - source;
|
||||
f.p.artifact_links.get_mut(&f.links[source]).unwrap().owner = f.other[source];
|
||||
// No selected pair or historical baseline can smuggle this option into
|
||||
// the list: a moved-away existing link must be directly selectable.
|
||||
let choices = artifacts::Editor::default().choices(
|
||||
&f.p,
|
||||
&f.p.traces[&f.trace],
|
||||
if reverse { Layer::Sa } else { Layer::Oa },
|
||||
);
|
||||
let moved = choices
|
||||
.iter()
|
||||
.find(|choice| choice.id == f.links[source])
|
||||
.expect("Moved-away source is a real picker option");
|
||||
assert!(moved.label.contains("Other owner"), "{}", moved.label);
|
||||
assert!(
|
||||
moved.label.contains("not attached to reviewed owner"),
|
||||
"{}",
|
||||
moved.label
|
||||
);
|
||||
assert!(!choices.iter().any(|choice| choice.id == f.links[target]));
|
||||
let mut neutral = f.p.clone();
|
||||
neutral
|
||||
.artifact_links
|
||||
.get_mut(&f.links[source])
|
||||
.unwrap()
|
||||
.owner = neutral.id;
|
||||
let choices = artifacts::Editor::default().choices(
|
||||
&neutral,
|
||||
&neutral.traces[&f.trace],
|
||||
if reverse { Layer::Sa } else { Layer::Oa },
|
||||
);
|
||||
assert!(
|
||||
choices.iter().any(|choice| choice.id == f.links[source]),
|
||||
"A link moved to a neutral project owner still has an explicit source-owner absence"
|
||||
);
|
||||
|
||||
let mut keys = KeyPair {
|
||||
oa: Some(f.links[0]),
|
||||
sa: Some(f.links[1]),
|
||||
};
|
||||
let mut state = open(&f, reverse, &keys);
|
||||
decide(
|
||||
&mut state,
|
||||
&f.p,
|
||||
&keys,
|
||||
ArtifactFacet::Presence,
|
||||
SemanticDecision::TakeSource,
|
||||
);
|
||||
assert!(state.can_apply(), "{}", errors(&state));
|
||||
assert!(
|
||||
!state
|
||||
.proposed
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.artifact_links
|
||||
.contains_key(&f.links[target])
|
||||
);
|
||||
assert_eq!(
|
||||
state.proposed.as_ref().unwrap().artifact_links[&f.links[source]],
|
||||
f.p.artifact_links[&f.links[source]]
|
||||
);
|
||||
if reverse {
|
||||
keys.sa = None
|
||||
} else {
|
||||
keys.oa = None
|
||||
};
|
||||
let mut state = open(&f, reverse, &keys);
|
||||
decide(
|
||||
&mut state,
|
||||
&f.p,
|
||||
&keys,
|
||||
ArtifactFacet::Presence,
|
||||
SemanticDecision::TakeSource,
|
||||
);
|
||||
assert!(state.can_apply(), "{}", errors(&state));
|
||||
assert_eq!(
|
||||
state.proposed.as_ref().unwrap().artifact_links[&f.links[target]],
|
||||
f.p.artifact_links[&f.links[target]]
|
||||
);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn artifact_metadata_apply_and_exact_history_reset_are_one_source_preserving_undo_redo() {
|
||||
use crate::app::App;
|
||||
let f = fixture();
|
||||
let keys = KeyPair {
|
||||
oa: Some(f.links[0]),
|
||||
sa: Some(f.links[1]),
|
||||
};
|
||||
let mut app = App::with_project(f.p.clone());
|
||||
let mut bundle = app.document.bundle().clone();
|
||||
bundle
|
||||
.files
|
||||
.get_mut(&bundle.entry.clone())
|
||||
.unwrap()
|
||||
.insert_str(0, "// Artifact rationale stays verbatim\r\n");
|
||||
app.document.apply_bundle(bundle).unwrap();
|
||||
let before = app.document.snapshot();
|
||||
super::tests::start(&mut app, f.trace);
|
||||
super::tests::send(
|
||||
&mut app,
|
||||
RowEvent::EditPair(selector(&keys, ArtifactFacet::Name)),
|
||||
);
|
||||
super::tests::send(&mut app, RowEvent::Artifact(artifacts::Event::Stage));
|
||||
super::tests::decide(
|
||||
&mut app,
|
||||
selector(&keys, ArtifactFacet::Name),
|
||||
SemanticDecision::Candidate,
|
||||
);
|
||||
super::tests::send(
|
||||
&mut app,
|
||||
RowEvent::Artifact(artifacts::Event::Input(
|
||||
selector(&keys, ArtifactFacet::Name),
|
||||
artifacts::Raw::Text("Reviewed interlock requirement".into()),
|
||||
)),
|
||||
);
|
||||
assert!(
|
||||
super::tests::state(&app).can_apply(),
|
||||
"{}",
|
||||
errors(super::tests::state(&app))
|
||||
);
|
||||
let expected = super::tests::state(&app).proposed.clone().unwrap();
|
||||
super::tests::apply(&mut app);
|
||||
assert_eq!(app.project(), &expected);
|
||||
let after = app.document.snapshot();
|
||||
assert!(
|
||||
after.bundle.files[&after.bundle.entry]
|
||||
.starts_with("// Artifact rationale stays verbatim\r\n")
|
||||
);
|
||||
let _ = app.update(Message::Undo);
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
let _ = app.update(Message::Redo);
|
||||
assert_eq!(app.document.bundle(), &after.bundle);
|
||||
super::tests::start(&mut app, f.trace);
|
||||
super::tests::send(
|
||||
&mut app,
|
||||
RowEvent::Reset(selector(&keys, ArtifactFacet::Name), true),
|
||||
);
|
||||
assert!(
|
||||
super::tests::state(&app).can_apply(),
|
||||
"{}",
|
||||
errors(super::tests::state(&app))
|
||||
);
|
||||
super::tests::apply(&mut app);
|
||||
assert_eq!(app.project().artifact_links, expected.artifact_links);
|
||||
let _ = app.update(Message::Undo);
|
||||
assert_eq!(app.document.bundle(), &after.bundle);
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
use super::artifacts::{Editor, Event, Raw};
|
||||
use super::*;
|
||||
use iced::{
|
||||
Element as View, Fill,
|
||||
widget::{button, checkbox, column, pick_list, row, text, text_editor, text_input},
|
||||
};
|
||||
pub(super) fn pairs<'a>(editor: &'a Editor, p: &'a Project, trace: &'a Trace) -> View<'a, Event> {
|
||||
let mut content=column![text("Artifact links and metadata").size(16),text("Pair links attached to these reviewed owners. Presence is attachment to the owner; taking identified source absence deletes the destination link, while a destination-only link is retained.").size(12),text_input("Filter artifact identities",&editor.search).on_input(Event::Search).padding(7)].spacing(9);
|
||||
for (layer, label, selected) in [
|
||||
(Layer::Oa, "Operational", editor.oa.clone()),
|
||||
(Layer::Sa, "System", editor.sa.clone()),
|
||||
] {
|
||||
content = content.push(text(label).size(12)).push(
|
||||
row![
|
||||
pick_list(editor.choices(p, trace, layer), selected, move |v| {
|
||||
Event::Pick(layer, Some(v))
|
||||
})
|
||||
.placeholder("Choose exact artifact identity")
|
||||
.width(Fill)
|
||||
.padding(7),
|
||||
button("No identity")
|
||||
.padding(7)
|
||||
.on_press(Event::Pick(layer, None))
|
||||
]
|
||||
.spacing(7),
|
||||
);
|
||||
}
|
||||
content.push(row![button("Reserve a new destination counterpart").padding(7).on_press(Event::Reserve),button("Review this exact artifact pair").padding(7).on_press(Event::Stage)].spacing(7).wrap()).push(text("Reservation retains a fresh identity and seeds metadata from the source. It creates nothing until Presence and all seven metadata facets are explicitly reviewed and the combined Apply succeeds. New unrelated links use ordinary artifact creation.").size(11)).padding(iced::Padding::default().right(18)).into()
|
||||
}
|
||||
pub(super) fn candidate<'a>(
|
||||
editor: &'a Editor,
|
||||
p: &'a Project,
|
||||
facet: &'a FacetSelector,
|
||||
) -> Option<View<'a, Event>> {
|
||||
let value = editor.drafts.get(facet)?;
|
||||
let (_, which) = artifacts::parts(facet)?;
|
||||
let mut content =
|
||||
column![text(format!("Final artifact {}", artifacts::field(which)?)).size(12)].spacing(7);
|
||||
match value {
|
||||
Raw::Text(_) if which == ArtifactFacet::Description => {
|
||||
let draft = editor.descriptions.get(facet)?;
|
||||
let facet = facet.clone();
|
||||
content = content.push(
|
||||
text_editor(draft)
|
||||
.height(110)
|
||||
.on_action(move |action| Event::Description(facet.clone(), action)),
|
||||
);
|
||||
}
|
||||
Raw::Text(value) => {
|
||||
let facet = facet.clone();
|
||||
content = content.push(
|
||||
text_input("Exact text", value)
|
||||
.on_input(move |value| Event::Input(facet.clone(), Raw::Text(value)))
|
||||
.padding(7),
|
||||
);
|
||||
}
|
||||
Raw::Owner(value) => {
|
||||
let mut choices: Vec<_> = p
|
||||
.identities()
|
||||
.into_iter()
|
||||
.map(|(id, alias)| Choice {
|
||||
id,
|
||||
label: format!("{} · @{alias}", p.named(id)),
|
||||
})
|
||||
.collect();
|
||||
choices.sort_by_cached_key(|c| c.label.to_lowercase());
|
||||
let selected = value.parse::<Id>().ok().map(|id| view::choice(p, id));
|
||||
let raw_facet = facet.clone();
|
||||
let pick_facet = facet.clone();
|
||||
content = content
|
||||
.push(
|
||||
pick_list(choices, selected, move |v: Choice| {
|
||||
Event::Input(pick_facet.clone(), Raw::Owner(v.id.to_string()))
|
||||
})
|
||||
.placeholder("Explicit final owner")
|
||||
.width(Fill)
|
||||
.padding(7),
|
||||
)
|
||||
.push(
|
||||
text_input("Owner identity (retains rejected input)", value)
|
||||
.on_input(move |v| Event::Input(raw_facet.clone(), Raw::Owner(v)))
|
||||
.padding(7),
|
||||
);
|
||||
}
|
||||
Raw::Relation(value) => {
|
||||
let facet = facet.clone();
|
||||
let variants = [
|
||||
ArtifactRelation::References,
|
||||
ArtifactRelation::Satisfies,
|
||||
ArtifactRelation::Verifies,
|
||||
ArtifactRelation::Implements,
|
||||
ArtifactRelation::DerivedFrom,
|
||||
];
|
||||
let choices: Vec<_> = variants.into_iter().map(RelationChoice).collect();
|
||||
content = content.push(
|
||||
pick_list(choices, Some(RelationChoice(*value)), move |v| {
|
||||
Event::Input(facet.clone(), Raw::Relation(v.0))
|
||||
})
|
||||
.padding(7),
|
||||
);
|
||||
}
|
||||
Raw::Optional {
|
||||
present,
|
||||
text: value,
|
||||
} => {
|
||||
let toggle_facet = facet.clone();
|
||||
let retained = value.clone();
|
||||
let input_facet = facet.clone();
|
||||
let enabled = *present;
|
||||
content = content
|
||||
.push(
|
||||
checkbox(*present)
|
||||
.label("Present (checked with empty text means an explicit empty value)")
|
||||
.on_toggle(move |present| {
|
||||
Event::Input(
|
||||
toggle_facet.clone(),
|
||||
Raw::Optional {
|
||||
present,
|
||||
text: retained.clone(),
|
||||
},
|
||||
)
|
||||
}),
|
||||
)
|
||||
.push(
|
||||
text_input("Retained optional text", value)
|
||||
.on_input(move |text| {
|
||||
Event::Input(
|
||||
input_facet.clone(),
|
||||
Raw::Optional {
|
||||
present: enabled,
|
||||
text,
|
||||
},
|
||||
)
|
||||
})
|
||||
.padding(7),
|
||||
);
|
||||
}
|
||||
}
|
||||
Some(content.into())
|
||||
}
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
struct RelationChoice(ArtifactRelation);
|
||||
impl std::fmt::Display for RelationChoice {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{:?}", self.0)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,456 @@
|
||||
//! Existing-record child/incident editing with independent exact-field drafts.
|
||||
//! Every proposal names its actual Parent or relationship endpoint slot.
|
||||
use super::*;
|
||||
use crate::forms::RecoveryDraft;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
pub enum Family {
|
||||
#[default]
|
||||
Child,
|
||||
Source,
|
||||
Target,
|
||||
}
|
||||
impl std::fmt::Display for Family {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
formatter.write_str(match self {
|
||||
Self::Child => "Owned children",
|
||||
Self::Source => "Relationships at source",
|
||||
Self::Target => "Relationships at target",
|
||||
})
|
||||
}
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Event {
|
||||
Family(Family),
|
||||
Pick(Layer, Option<Choice>),
|
||||
Search(String),
|
||||
Stage,
|
||||
}
|
||||
#[derive(Default)]
|
||||
struct PairDraft {
|
||||
oa: Option<Choice>,
|
||||
sa: Option<Choice>,
|
||||
}
|
||||
#[derive(Default)]
|
||||
pub struct Editor {
|
||||
pub family: Family,
|
||||
pub search: String,
|
||||
pairs: BTreeMap<Family, PairDraft>,
|
||||
pub drafts: BTreeMap<FacetSelector, Draft>,
|
||||
}
|
||||
#[derive(Default, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Saved {
|
||||
family: Family,
|
||||
pairs: Vec<(Family, Option<Id>, Option<Id>)>,
|
||||
drafts: Vec<(FacetSelector, RecoveryDraft)>,
|
||||
}
|
||||
pub struct Proposal {
|
||||
pub slot: (Id, FacetSelector),
|
||||
pub path: String,
|
||||
pub record: Value,
|
||||
}
|
||||
pub(super) fn family(facet: &FacetSelector) -> Option<Family> {
|
||||
Some(match facet {
|
||||
FacetSelector::Child(_) => Family::Child,
|
||||
FacetSelector::Incident {
|
||||
end: IncidentEnd::Source,
|
||||
..
|
||||
} => Family::Source,
|
||||
FacetSelector::Incident {
|
||||
end: IncidentEnd::Target,
|
||||
..
|
||||
} => Family::Target,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
pub(super) fn supported(facet: &FacetSelector) -> bool {
|
||||
family(facet).is_some()
|
||||
}
|
||||
fn keys(facet: &FacetSelector) -> Option<&KeyPair<Id>> {
|
||||
match facet {
|
||||
FacetSelector::Child(keys) | FacetSelector::Incident { keys, .. } => Some(keys),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
pub(super) fn key(facet: &FacetSelector, layer: Layer) -> Option<Id> {
|
||||
let keys = keys(facet)?;
|
||||
if layer == Layer::Oa { keys.oa } else { keys.sa }
|
||||
}
|
||||
pub(super) fn explicit_pair(facet: &FacetSelector) -> Option<(Id, Id)> {
|
||||
let keys = keys(facet)?;
|
||||
keys.oa.zip(keys.sa)
|
||||
}
|
||||
pub(super) fn actual_slot(facet: &FacetSelector, layer: Layer) -> Option<(Id, FacetSelector)> {
|
||||
let id = key(facet, layer)?;
|
||||
Some((
|
||||
id,
|
||||
match family(facet)? {
|
||||
Family::Child => FacetSelector::Header(HeaderFacet::Parent),
|
||||
Family::Source => FacetSelector::Relation(RelationFacet::Source),
|
||||
Family::Target => FacetSelector::Relation(RelationFacet::Target),
|
||||
},
|
||||
))
|
||||
}
|
||||
pub(super) fn path(project: &Project, facet: &FacetSelector, layer: Layer) -> Option<String> {
|
||||
let (id, slot) = actual_slot(facet, layer)?;
|
||||
if project.layer_of(id) != Some(layer) {
|
||||
return None;
|
||||
}
|
||||
// Child keys cannot accidentally name a relationship (and vice versa).
|
||||
match family(facet)? {
|
||||
Family::Child if !project.elements.contains_key(&id) => return None,
|
||||
Family::Source | Family::Target if !project.relations.contains_key(&id) => return None,
|
||||
_ => {}
|
||||
}
|
||||
scalar::binding(project, id, &slot)
|
||||
}
|
||||
pub(super) fn label(facet: &FacetSelector) -> &'static str {
|
||||
match family(facet) {
|
||||
Some(Family::Child) => "Final parent of the destination child",
|
||||
Some(Family::Source) => "Final source of the destination relationship",
|
||||
Some(Family::Target) => "Final target of the destination relationship",
|
||||
None => "Unavailable attachment",
|
||||
}
|
||||
}
|
||||
impl Editor {
|
||||
pub fn selection(&self, layer: Layer) -> Option<&Choice> {
|
||||
let pair = self.pairs.get(&self.family)?;
|
||||
if layer == Layer::Oa {
|
||||
pair.oa.as_ref()
|
||||
} else {
|
||||
pair.sa.as_ref()
|
||||
}
|
||||
}
|
||||
pub fn update(
|
||||
&mut self,
|
||||
project: &Project,
|
||||
trace: &Trace,
|
||||
event: Event,
|
||||
) -> Result<Option<FacetSelector>, String> {
|
||||
match event {
|
||||
Event::Family(family) => self.family = family,
|
||||
Event::Search(search) => self.search = search,
|
||||
Event::Pick(layer, choice) => {
|
||||
let pair = self.pairs.entry(self.family).or_default();
|
||||
if layer == Layer::Oa {
|
||||
pair.oa = choice;
|
||||
} else {
|
||||
pair.sa = choice;
|
||||
}
|
||||
}
|
||||
Event::Stage => {
|
||||
let keys = KeyPair {
|
||||
oa: self.selection(Layer::Oa).map(|c| c.id),
|
||||
sa: self.selection(Layer::Sa).map(|c| c.id),
|
||||
};
|
||||
let facet = match self.family {
|
||||
Family::Child => FacetSelector::Child(keys),
|
||||
Family::Source => FacetSelector::Incident {
|
||||
keys,
|
||||
end: IncidentEnd::Source,
|
||||
},
|
||||
Family::Target => FacetSelector::Incident {
|
||||
keys,
|
||||
end: IncidentEnd::Target,
|
||||
},
|
||||
};
|
||||
for (id, expected) in [
|
||||
(key(&facet, Layer::Oa), Layer::Oa),
|
||||
(key(&facet, Layer::Sa), Layer::Sa),
|
||||
] {
|
||||
if id
|
||||
.and_then(|id| project.layer_of(id))
|
||||
.is_some_and(|actual| actual != expected)
|
||||
{
|
||||
return Err(
|
||||
"Attachment identities must match their Operational or System side."
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
}
|
||||
capture_facet(project, trace.oa, trace.sa, &facet)?;
|
||||
return Ok(Some(facet));
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
pub fn select_pair(&mut self, project: &Project, facet: &FacetSelector) -> Result<(), String> {
|
||||
self.family = family(facet).ok_or("Choose a child or incident relationship address.")?;
|
||||
let keys = keys(facet).expect("Closed family");
|
||||
self.pairs.insert(
|
||||
self.family,
|
||||
PairDraft {
|
||||
oa: keys.oa.map(|id| view::choice(project, id)),
|
||||
sa: keys.sa.map(|id| view::choice(project, id)),
|
||||
},
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
pub fn ensure(
|
||||
&mut self,
|
||||
project: &Project,
|
||||
facet: &FacetSelector,
|
||||
layer: Layer,
|
||||
) -> Result<(), String> {
|
||||
let id = key(facet, layer).ok_or("Choose an existing destination attachment identity.")?;
|
||||
path(project, facet, layer).ok_or("The destination attachment record is absent or has a different kind. Creation needs an explicit transition mapping.")?;
|
||||
if !self.drafts.contains_key(facet) {
|
||||
self.drafts.insert(
|
||||
facet.clone(),
|
||||
Draft::from_project(project, id)
|
||||
.ok_or("Destination attachment record is absent.")?,
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub fn choices(&self, project: &Project, trace: &Trace, layer: Layer) -> Vec<Choice> {
|
||||
let owner = if layer == Layer::Oa {
|
||||
trace.oa
|
||||
} else {
|
||||
trace.sa
|
||||
};
|
||||
let owner_kind = project.elements.get(&owner).map(Element::kind);
|
||||
let mut ids: BTreeSet<_> = match self.family {
|
||||
Family::Child => project
|
||||
.elements
|
||||
.values()
|
||||
.filter(|e| {
|
||||
e.layer() == layer
|
||||
&& e.id != owner
|
||||
&& (e.parent == Some(owner)
|
||||
|| owner_kind.is_some_and(|kind| {
|
||||
oa_core::validation::parent_permits(kind, e.kind())
|
||||
}))
|
||||
})
|
||||
.map(|e| e.id)
|
||||
.collect(),
|
||||
Family::Source | Family::Target => project
|
||||
.relations
|
||||
.values()
|
||||
.filter(|relation| {
|
||||
if project.layer_of(relation.id) != Some(layer) {
|
||||
return false;
|
||||
}
|
||||
let (a, b) = relation.body.endpoints();
|
||||
let (current, opposite) = if self.family == Family::Source {
|
||||
(a, b)
|
||||
} else {
|
||||
(b, a)
|
||||
};
|
||||
if current == owner {
|
||||
return true;
|
||||
}
|
||||
match (owner_kind, project.elements.get(&opposite)) {
|
||||
(Some(owner_kind), Some(opposite)) => {
|
||||
if self.family == Family::Source {
|
||||
relation.kind().permits(owner_kind, opposite.kind())
|
||||
} else {
|
||||
relation.kind().permits(opposite.kind(), owner_kind)
|
||||
}
|
||||
}
|
||||
// Rare relationship-to-relationship endpoints use the ordinary
|
||||
// closed property picker rather than assuming element kinds.
|
||||
_ => {
|
||||
let Some(draft) = Draft::from_project(project, relation.id) else {
|
||||
return false;
|
||||
};
|
||||
let facet = FacetSelector::Relation(if self.family == Family::Source {
|
||||
RelationFacet::Source
|
||||
} else {
|
||||
RelationFacet::Target
|
||||
});
|
||||
let Some(path) = scalar::binding(project, relation.id, &facet) else {
|
||||
return false;
|
||||
};
|
||||
crate::forms::reference_choices(&draft, project, &path, Id::nil())
|
||||
.iter()
|
||||
.any(|choice| choice.id == owner)
|
||||
}
|
||||
}
|
||||
})
|
||||
.map(|relation| relation.id)
|
||||
.collect(),
|
||||
};
|
||||
if let Some(selected) = self.selection(layer) {
|
||||
ids.insert(selected.id);
|
||||
}
|
||||
let search = self.search.to_lowercase();
|
||||
let mut choices: Vec<_> = ids
|
||||
.into_iter()
|
||||
.map(|id| view::choice(project, id))
|
||||
.filter(|choice| {
|
||||
search.is_empty()
|
||||
|| choice.label.to_lowercase().contains(&search)
|
||||
|| self
|
||||
.selection(layer)
|
||||
.is_some_and(|selected| selected.id == choice.id)
|
||||
})
|
||||
.collect();
|
||||
choices.sort_by_cached_key(|choice| choice.label.to_lowercase());
|
||||
choices
|
||||
}
|
||||
pub fn propose(
|
||||
&self,
|
||||
project: &Project,
|
||||
trace: &Trace,
|
||||
direction: TransitionDirection,
|
||||
facet: &FacetSelector,
|
||||
decision: SemanticDecision,
|
||||
form_context: bool,
|
||||
) -> Result<Proposal, String> {
|
||||
let layer = direction.target_layer();
|
||||
let slot = actual_slot(facet, layer)
|
||||
.ok_or("Choose the destination attachment identity explicitly.")?;
|
||||
let path = path(project, facet, layer).ok_or(
|
||||
"Only existing destination child and relationship records are supported here.",
|
||||
)?;
|
||||
let mut record = Draft::from_project(project, slot.0)
|
||||
.ok_or("The attached record is absent.")?
|
||||
.value;
|
||||
let owner = if layer == Layer::Oa {
|
||||
trace.oa
|
||||
} else {
|
||||
trace.sa
|
||||
};
|
||||
let value = match decision {
|
||||
SemanticDecision::KeepTarget => record.pointer(&path).expect("Closed path").clone(),
|
||||
SemanticDecision::Candidate => {
|
||||
let draft = self
|
||||
.drafts
|
||||
.get(facet)
|
||||
.ok_or("Set a final attachment value first.")?;
|
||||
let supplied = match draft.materialize_scopes(std::slice::from_ref(&path)) {
|
||||
Ok(value) => value,
|
||||
Err(_) if form_context => draft.value.clone(),
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
supplied
|
||||
.pointer(&path)
|
||||
.cloned()
|
||||
.ok_or("The attachment's exact field is unavailable.")?
|
||||
}
|
||||
SemanticDecision::TakeSource if key(facet, direction.source_layer()).is_none() => {
|
||||
record.pointer(&path).expect("Closed path").clone()
|
||||
}
|
||||
SemanticDecision::TakeSource => {
|
||||
let source = capture_facet(project, trace.oa, trace.sa, facet)?;
|
||||
let present = scalar::value(&source, direction.source_layer()).and_then(|value| value.as_bool()).ok_or("Source attachment does not apply; review an existing owner correspondence.")?;
|
||||
if present {
|
||||
serde_json::to_value(owner).expect("Id")
|
||||
} else if family(facet) == Some(Family::Child) {
|
||||
if record.pointer(&path) == Some(&serde_json::to_value(owner).expect("Id")) {
|
||||
Value::Null
|
||||
} else {
|
||||
record.pointer(&path).expect("Closed path").clone()
|
||||
}
|
||||
} else {
|
||||
return Err("Taking an absent relationship endpoint is incomplete. Choose Set final value and an explicit replacement endpoint.".into());
|
||||
}
|
||||
}
|
||||
};
|
||||
*record.pointer_mut(&path).expect("Closed path") = value;
|
||||
Ok(Proposal { slot, path, record })
|
||||
}
|
||||
pub fn recovery_snapshot(&self) -> Saved {
|
||||
Saved {
|
||||
family: self.family,
|
||||
pairs: self
|
||||
.pairs
|
||||
.iter()
|
||||
.map(|(family, pair)| {
|
||||
(
|
||||
*family,
|
||||
pair.oa.as_ref().map(|c| c.id),
|
||||
pair.sa.as_ref().map(|c| c.id),
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
drafts: self
|
||||
.drafts
|
||||
.iter()
|
||||
.map(|(facet, draft)| (facet.clone(), draft.recovery_snapshot()))
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
pub fn from_recovery(project: &Project, layer: Layer, saved: Saved) -> Result<Self, String> {
|
||||
if saved.drafts.len() > crate::source_workspace::LIMITS.model.max_records
|
||||
|| saved.pairs.len() > 3
|
||||
{
|
||||
return Err("Recovered attachment drafts exceed the supported limits.".into());
|
||||
}
|
||||
let mut editor = Self {
|
||||
family: saved.family,
|
||||
..Self::default()
|
||||
};
|
||||
for (family, oa, sa) in saved.pairs {
|
||||
for (id, layer) in [(oa, Layer::Oa), (sa, Layer::Sa)] {
|
||||
if id
|
||||
.and_then(|id| project.layer_of(id))
|
||||
.is_some_and(|actual| actual != layer)
|
||||
{
|
||||
return Err("Recovered attachment picker has a wrong-layer identity.".into());
|
||||
}
|
||||
}
|
||||
if editor
|
||||
.pairs
|
||||
.insert(
|
||||
family,
|
||||
PairDraft {
|
||||
oa: oa.map(|id| view::choice(project, id)),
|
||||
sa: sa.map(|id| view::choice(project, id)),
|
||||
},
|
||||
)
|
||||
.is_some()
|
||||
{
|
||||
return Err("Recovered attachment picker family is duplicated.".into());
|
||||
}
|
||||
}
|
||||
for (facet, saved) in saved.drafts {
|
||||
for expected in [Layer::Oa, Layer::Sa] {
|
||||
if key(&facet, expected)
|
||||
.and_then(|id| project.layer_of(id))
|
||||
.is_some_and(|actual| actual != expected)
|
||||
{
|
||||
return Err("Recovered attachment address has a wrong-layer identity.".into());
|
||||
}
|
||||
}
|
||||
let (id, _) = actual_slot(&facet, layer)
|
||||
.ok_or("Recovered attachment has no destination record.")?;
|
||||
let path = path(project, &facet, layer)
|
||||
.ok_or("Recovered attachment record has a different kind.")?;
|
||||
let draft = Draft::from_recovery(project, saved)?;
|
||||
let original =
|
||||
Draft::from_project(project, id).ok_or("Recovered attachment is absent.")?;
|
||||
let mut protected = draft.value.clone();
|
||||
*protected
|
||||
.pointer_mut(&path)
|
||||
.ok_or("Recovered attachment field is absent.")? =
|
||||
original.value.pointer(&path).expect("Closed path").clone();
|
||||
if draft.original != id
|
||||
|| protected != original.value
|
||||
|| draft.inputs.iter().any(|(key, input)| {
|
||||
key != &path
|
||||
&& original
|
||||
.inputs
|
||||
.get(key)
|
||||
.is_none_or(|old| old.raw != input.raw)
|
||||
})
|
||||
{
|
||||
return Err(
|
||||
"Recovered attachment draft changes identity, kind or an unrelated property."
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
if editor.drafts.insert(facet, draft).is_some() {
|
||||
return Err("Recovered attachment draft address is duplicated.".into());
|
||||
}
|
||||
}
|
||||
Ok(editor)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "attachments_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,795 @@
|
||||
//! Exact attachment adapters, addressed session composition, and recovery oracles.
|
||||
use super::*;
|
||||
struct Fixture {
|
||||
p: Project,
|
||||
owners: [Id; 2],
|
||||
records: [Id; 2],
|
||||
outside: [Id; 2],
|
||||
trace: Id,
|
||||
facet: FacetSelector,
|
||||
}
|
||||
fn fixture(child: bool, end: IncidentEnd) -> Fixture {
|
||||
let mut p = Project::empty();
|
||||
let mut owners = vec![];
|
||||
let mut records = 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 other = p.add_element(kind, layer, "Final owner", None).unwrap();
|
||||
let record = if child {
|
||||
p.add_element(kind, layer, "Child", Some(owner)).unwrap()
|
||||
} else {
|
||||
let peer = p.add_element(kind, layer, "Other endpoint", None).unwrap();
|
||||
let (a, b) = if end == IncidentEnd::Source {
|
||||
(owner, peer)
|
||||
} else {
|
||||
(peer, owner)
|
||||
};
|
||||
p.relate(RelationKind::DataAssociation, "Association", a, b)
|
||||
};
|
||||
owners.push(owner);
|
||||
records.push(record);
|
||||
outside.push(other);
|
||||
}
|
||||
for element in p.elements.values_mut() {
|
||||
if let Some(SharedBody::DataType { definition, .. }) = element.shared_mut() {
|
||||
*definition = DataTypeDefinition::Record { supertype: None };
|
||||
}
|
||||
}
|
||||
let trace = Id::new_v4();
|
||||
p.traces.insert(
|
||||
trace,
|
||||
Trace {
|
||||
id: trace,
|
||||
alias: "attachment_trace".into(),
|
||||
name: "Attachment rationale".into(),
|
||||
description: String::new(),
|
||||
oa: owners[0],
|
||||
sa: owners[1],
|
||||
kind: TraceKind::Manual,
|
||||
baseline: None,
|
||||
},
|
||||
);
|
||||
let keys = KeyPair {
|
||||
oa: Some(records[0]),
|
||||
sa: Some(records[1]),
|
||||
};
|
||||
let facet = if child {
|
||||
FacetSelector::Child(keys)
|
||||
} else {
|
||||
FacetSelector::Incident { keys, end }
|
||||
};
|
||||
validate_candidate(&p).unwrap();
|
||||
Fixture {
|
||||
p,
|
||||
owners: [owners[0], owners[1]],
|
||||
records: [records[0], records[1]],
|
||||
outside: [outside[0], outside[1]],
|
||||
trace,
|
||||
facet,
|
||||
}
|
||||
}
|
||||
fn request(f: &Fixture, reverse: bool, decision: SemanticDecision) -> SemanticTransitionRequest {
|
||||
let source = usize::from(reverse);
|
||||
let target = 1 - source;
|
||||
let direction = if reverse {
|
||||
TransitionDirection::SaToOa
|
||||
} else {
|
||||
TransitionDirection::OaToSa
|
||||
};
|
||||
let mut transition = TransitionRequest::new(direction, vec![f.owners[source]]);
|
||||
transition.mappings.push(TransitionMapping {
|
||||
source: f.owners[source],
|
||||
target: Some(f.owners[target]),
|
||||
target_kind: None,
|
||||
});
|
||||
let mut r = SemanticTransitionRequest::new(transition);
|
||||
r.traces.insert(f.owners[source], f.trace);
|
||||
r.references
|
||||
.extend([(f.owners[0], f.owners[1]), (f.records[0], f.records[1])]);
|
||||
r.reviews.push(SemanticReviewRequest {
|
||||
source: f.owners[source],
|
||||
resolutions: vec![FacetResolution {
|
||||
facet: f.facet.clone(),
|
||||
decision,
|
||||
}],
|
||||
reset: vec![],
|
||||
});
|
||||
r
|
||||
}
|
||||
fn typed(f: &Fixture, record: Value) -> SemanticRecord {
|
||||
if matches!(f.facet, FacetSelector::Child(_)) {
|
||||
SemanticRecord::Element(serde_json::from_value(record).unwrap())
|
||||
} else {
|
||||
SemanticRecord::Relation(serde_json::from_value(record).unwrap())
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn parent_and_each_endpoint_candidate_preserve_other_fields_and_match_core_both_directions() {
|
||||
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);
|
||||
let r = request(&f, reverse, SemanticDecision::Candidate);
|
||||
let direction = r.transition.direction;
|
||||
let layer = direction.target_layer();
|
||||
let target = usize::from(!reverse);
|
||||
let mut editor = Editor::default();
|
||||
editor.ensure(&f.p, &f.facet, layer).unwrap();
|
||||
let path = path(&f.p, &f.facet, layer).unwrap();
|
||||
editor
|
||||
.drafts
|
||||
.get_mut(&f.facet)
|
||||
.unwrap()
|
||||
.shape_scoped(&path, serde_json::json!(f.outside[target]))
|
||||
.unwrap();
|
||||
let proposal = editor
|
||||
.propose(
|
||||
&f.p,
|
||||
&f.p.traces[&f.trace],
|
||||
direction,
|
||||
&f.facet,
|
||||
SemanticDecision::Candidate,
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(proposal.slot, actual_slot(&f.facet, layer).unwrap());
|
||||
assert_eq!(proposal.path, path);
|
||||
let proposed = typed(&f, proposal.record);
|
||||
let mut r = r;
|
||||
r.candidates.push(proposed.clone());
|
||||
let plan = plan_semantic_transition(&f.p, r).unwrap();
|
||||
assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts);
|
||||
let result = prepare_semantic_transition(&f.p, &plan).unwrap().project;
|
||||
assert_eq!(semantic_record(&result, f.records[target]), Some(proposed));
|
||||
assert_eq!(
|
||||
semantic_record(&result, f.records[1 - target]),
|
||||
semantic_record(&f.p, f.records[1 - target])
|
||||
);
|
||||
assert_eq!(
|
||||
result.traces.len(),
|
||||
1,
|
||||
"The attached record needs no invented trace"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn source_only_absence_and_explicit_absent_endpoint_are_different() {
|
||||
for end in [IncidentEnd::Source, IncidentEnd::Target] {
|
||||
for reverse in [false, true] {
|
||||
let mut f = fixture(false, end);
|
||||
let r = request(&f, reverse, SemanticDecision::TakeSource);
|
||||
let direction = r.transition.direction;
|
||||
let source = usize::from(reverse);
|
||||
let target = 1 - source;
|
||||
let relation = f.p.relations.get_mut(&f.records[source]).unwrap();
|
||||
let (a, b) = relation.body.endpoints();
|
||||
relation.body.set_endpoints(
|
||||
if end == IncidentEnd::Source {
|
||||
f.outside[source]
|
||||
} else {
|
||||
a
|
||||
},
|
||||
if end == IncidentEnd::Target {
|
||||
f.outside[source]
|
||||
} else {
|
||||
b
|
||||
},
|
||||
);
|
||||
let editor = Editor::default();
|
||||
assert!(
|
||||
editor
|
||||
.propose(
|
||||
&f.p,
|
||||
&f.p.traces[&f.trace],
|
||||
direction,
|
||||
&f.facet,
|
||||
SemanticDecision::TakeSource,
|
||||
false
|
||||
)
|
||||
.err()
|
||||
.unwrap()
|
||||
.contains("explicit replacement")
|
||||
);
|
||||
let FacetSelector::Incident { keys, .. } = &mut f.facet else {
|
||||
unreachable!()
|
||||
};
|
||||
if reverse {
|
||||
keys.sa = None;
|
||||
} else {
|
||||
keys.oa = None;
|
||||
}
|
||||
let proposal = editor
|
||||
.propose(
|
||||
&f.p,
|
||||
&f.p.traces[&f.trace],
|
||||
direction,
|
||||
&f.facet,
|
||||
SemanticDecision::TakeSource,
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
typed(&f, proposal.record),
|
||||
semantic_record(&f.p, f.records[target]).unwrap()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn attachment_pair_drafts_survive_family_switch_and_restore_without_mutating_model() {
|
||||
let f = fixture(false, IncidentEnd::Source);
|
||||
let mut editor = Editor::default();
|
||||
editor.select_pair(&f.p, &f.facet).unwrap();
|
||||
editor
|
||||
.update(&f.p, &f.p.traces[&f.trace], Event::Family(Family::Target))
|
||||
.unwrap();
|
||||
editor
|
||||
.update(
|
||||
&f.p,
|
||||
&f.p.traces[&f.trace],
|
||||
Event::Pick(Layer::Sa, Some(view::choice(&f.p, f.records[1]))),
|
||||
)
|
||||
.unwrap();
|
||||
editor
|
||||
.update(&f.p, &f.p.traces[&f.trace], Event::Family(Family::Source))
|
||||
.unwrap();
|
||||
let staged = editor
|
||||
.update(&f.p, &f.p.traces[&f.trace], Event::Stage)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(staged, f.facet);
|
||||
let saved = serde_json::to_value(editor.recovery_snapshot()).unwrap();
|
||||
let restored = Editor::from_recovery(
|
||||
&f.p,
|
||||
Layer::Sa,
|
||||
serde_json::from_value(saved.clone()).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
serde_json::to_value(restored.recovery_snapshot()).unwrap(),
|
||||
saved
|
||||
);
|
||||
assert_eq!(restored.selection(Layer::Oa).unwrap().id, f.records[0]);
|
||||
assert!(
|
||||
editor
|
||||
.choices(&f.p, &f.p.traces[&f.trace], Layer::Sa)
|
||||
.iter()
|
||||
.any(|choice| choice.id == f.records[1])
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn raw_rejected_attachment_reference_survives_recovery_and_wrong_scope_is_rejected() {
|
||||
let f = fixture(true, IncidentEnd::Source);
|
||||
let mut editor = Editor::default();
|
||||
editor.ensure(&f.p, &f.facet, Layer::Sa).unwrap();
|
||||
editor
|
||||
.drafts
|
||||
.get_mut(&f.facet)
|
||||
.unwrap()
|
||||
.input("/parent", "-".into());
|
||||
let saved = serde_json::to_value(editor.recovery_snapshot()).unwrap();
|
||||
let mut restored = Editor::from_recovery(
|
||||
&f.p,
|
||||
Layer::Sa,
|
||||
serde_json::from_value(saved.clone()).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(restored.drafts[&f.facet].inputs["/parent"].raw, "-");
|
||||
let proposed = restored
|
||||
.propose(
|
||||
&f.p,
|
||||
&f.p.traces[&f.trace],
|
||||
TransitionDirection::OaToSa,
|
||||
&f.facet,
|
||||
SemanticDecision::Candidate,
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(serde_json::from_value::<Element>(proposed.record).is_err());
|
||||
restored
|
||||
.drafts
|
||||
.get_mut(&f.facet)
|
||||
.unwrap()
|
||||
.input("/parent", f.outside[1].to_string());
|
||||
let repaired = restored
|
||||
.propose(
|
||||
&f.p,
|
||||
&f.p.traces[&f.trace],
|
||||
TransitionDirection::OaToSa,
|
||||
&f.facet,
|
||||
SemanticDecision::Candidate,
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
serde_json::from_value::<Element>(repaired.record)
|
||||
.unwrap()
|
||||
.parent,
|
||||
Some(f.outside[1])
|
||||
);
|
||||
let mut duplicate = saved.clone();
|
||||
let row = duplicate["drafts"][0].clone();
|
||||
duplicate["drafts"].as_array_mut().unwrap().push(row);
|
||||
assert!(
|
||||
Editor::from_recovery(&f.p, Layer::Sa, serde_json::from_value(duplicate).unwrap()).is_err()
|
||||
);
|
||||
let mut changed = saved;
|
||||
changed["drafts"][0][1]["value"]["name"] = serde_json::json!("Unrelated changed name");
|
||||
assert!(
|
||||
Editor::from_recovery(&f.p, Layer::Sa, serde_json::from_value(changed).unwrap()).is_err()
|
||||
);
|
||||
}
|
||||
|
||||
fn send(state: &mut State, p: &Project, event: RowEvent) {
|
||||
let event = state.address_event(event);
|
||||
state.update(p, event);
|
||||
}
|
||||
fn session(f: &Fixture, reverse: bool) -> State {
|
||||
let mut state = State::new(&f.p, f.trace).unwrap();
|
||||
if reverse {
|
||||
state.update(
|
||||
&f.p,
|
||||
super::super::Event::Direction(TransitionDirection::SaToOa),
|
||||
);
|
||||
}
|
||||
send(&mut state, &f.p, RowEvent::EditPair(f.facet.clone()));
|
||||
send(&mut state, &f.p, RowEvent::Attachment(Event::Stage));
|
||||
assert_eq!(state.references.get(&f.records[0]), Some(&f.records[1]));
|
||||
state
|
||||
}
|
||||
fn candidate(state: &mut State, p: &Project, facet: &FacetSelector, value: Id) {
|
||||
let path = path(p, facet, state.direction.target_layer()).unwrap();
|
||||
send(state, p, RowEvent::Select(facet.clone()));
|
||||
send(
|
||||
state,
|
||||
p,
|
||||
RowEvent::Decide(facet.clone(), Some(SemanticDecision::Candidate)),
|
||||
);
|
||||
send(state, p, RowEvent::Shape(path, serde_json::json!(value)));
|
||||
}
|
||||
fn errors(state: &State) -> String {
|
||||
format!(
|
||||
"{:?} / {:?}",
|
||||
state.error,
|
||||
state.plan.as_ref().map(|p| &p.conflicts)
|
||||
)
|
||||
}
|
||||
#[test]
|
||||
fn addressed_attachment_candidates_equal_direct_core_oracle_both_directions() {
|
||||
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);
|
||||
let target = usize::from(!reverse);
|
||||
let mut state = session(&f, reverse);
|
||||
candidate(&mut state, &f.p, &f.facet, f.outside[target]);
|
||||
assert!(state.can_apply(), "{}", errors(&state));
|
||||
let mut direct = request(&f, reverse, SemanticDecision::Candidate);
|
||||
let mut record = Draft::from_project(&f.p, f.records[target]).unwrap().value;
|
||||
*record
|
||||
.pointer_mut(&path(&f.p, &f.facet, state.direction.target_layer()).unwrap())
|
||||
.unwrap() = serde_json::json!(f.outside[target]);
|
||||
direct.candidates.push(typed(&f, record));
|
||||
let plan = plan_semantic_transition(&f.p, direct).unwrap();
|
||||
let expected = prepare_semantic_transition(&f.p, &plan).unwrap().project;
|
||||
assert_eq!(state.proposed.as_ref(), Some(&expected));
|
||||
assert!(state.command(&f.p).is_ok());
|
||||
assert_eq!(
|
||||
f.p.traces[&f.trace].baseline, None,
|
||||
"Preview cannot write source history"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn attachment_candidate_and_direct_record_keep_conflict_on_actual_slot_without_losing_drafts() {
|
||||
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 trace = super::super::tests::add_trace(
|
||||
&mut f.p,
|
||||
f.records[0],
|
||||
f.records[1],
|
||||
"Direct record rationale",
|
||||
);
|
||||
let mut state = session(&f, reverse);
|
||||
candidate(&mut state, &f.p, &f.facet, f.outside[usize::from(!reverse)]);
|
||||
let authored = state.active_address.unwrap();
|
||||
state.update(
|
||||
&f.p,
|
||||
super::super::Event::ChooseTrace(view::choice(&f.p, trace)),
|
||||
);
|
||||
state.update(&f.p, super::super::Event::OpenTrace);
|
||||
let (_, slot) = actual_slot(&f.facet, state.direction.target_layer()).unwrap();
|
||||
send(
|
||||
&mut state,
|
||||
&f.p,
|
||||
RowEvent::Decide(slot, Some(SemanticDecision::KeepTarget)),
|
||||
);
|
||||
assert!(!state.can_apply());
|
||||
assert!(
|
||||
errors(&state).contains("Independent row proposals disagree"),
|
||||
"{}",
|
||||
errors(&state)
|
||||
);
|
||||
assert!(
|
||||
state
|
||||
.row(authored)
|
||||
.unwrap()
|
||||
.attachments
|
||||
.drafts
|
||||
.contains_key(&f.facet)
|
||||
);
|
||||
let saved = state.recovery_snapshot().unwrap();
|
||||
let restored = State::from_recovery(&f.p, saved.clone()).unwrap();
|
||||
assert_eq!(restored.recovery_snapshot().unwrap(), saved);
|
||||
assert!(!restored.can_apply());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn two_incident_endpoint_candidates_form_one_complete_record_and_recover_invalid_excluded_draft() {
|
||||
let mut f = fixture(false, IncidentEnd::Source);
|
||||
// Both ends are incident to the reviewed owner, so source and target are two
|
||||
// independent attachment addresses on the same actual relationship.
|
||||
for i in 0..2 {
|
||||
f.p.relations
|
||||
.get_mut(&f.records[i])
|
||||
.unwrap()
|
||||
.body
|
||||
.set_endpoints(f.owners[i], f.owners[i]);
|
||||
}
|
||||
let other = FacetSelector::Incident {
|
||||
keys: KeyPair {
|
||||
oa: Some(f.records[0]),
|
||||
sa: Some(f.records[1]),
|
||||
},
|
||||
end: IncidentEnd::Target,
|
||||
};
|
||||
let mut state = session(&f, false);
|
||||
candidate(&mut state, &f.p, &f.facet, f.outside[1]);
|
||||
send(&mut state, &f.p, RowEvent::EditPair(other.clone()));
|
||||
send(&mut state, &f.p, RowEvent::Attachment(Event::Stage));
|
||||
candidate(&mut state, &f.p, &other, f.outside[1]);
|
||||
assert!(state.can_apply(), "{}", errors(&state));
|
||||
assert_eq!(
|
||||
state.proposed.as_ref().unwrap().relations[&f.records[1]]
|
||||
.body
|
||||
.endpoints(),
|
||||
(f.outside[1], f.outside[1])
|
||||
);
|
||||
let path = path(&f.p, &other, Layer::Sa).unwrap();
|
||||
send(&mut state, &f.p, RowEvent::Input(path.clone(), "-".into()));
|
||||
assert!(!state.can_apply());
|
||||
let address = state.active_address.unwrap();
|
||||
state.update(&f.p, super::super::Event::Include(address, false));
|
||||
let saved = state.recovery_snapshot().unwrap();
|
||||
let mut restored = State::from_recovery(&f.p, saved.clone()).unwrap();
|
||||
assert_eq!(restored.recovery_snapshot().unwrap(), saved);
|
||||
assert!(!restored.included.contains(&address));
|
||||
assert_eq!(restored.attachments.drafts[&other].inputs[&path].raw, "-");
|
||||
restored.update(&f.p, super::super::Event::Include(address, true));
|
||||
assert!(!restored.can_apply());
|
||||
send(
|
||||
&mut restored,
|
||||
&f.p,
|
||||
RowEvent::Input(path, f.outside[1].to_string()),
|
||||
);
|
||||
assert!(restored.can_apply(), "{}", errors(&restored));
|
||||
}
|
||||
#[test]
|
||||
fn attachment_apply_is_one_exact_source_and_history_undo_redo() {
|
||||
use crate::app::App;
|
||||
let f = fixture(true, IncidentEnd::Source);
|
||||
let mut app = App::with_project(f.p.clone());
|
||||
let mut bundle = app.document.bundle().clone();
|
||||
bundle
|
||||
.files
|
||||
.get_mut(&bundle.entry.clone())
|
||||
.unwrap()
|
||||
.insert_str(0, "// Child allocation rationale stays exact\r\n");
|
||||
app.document.apply_bundle(bundle).unwrap();
|
||||
let before = app.document.snapshot();
|
||||
super::super::tests::start(&mut app, f.trace);
|
||||
super::super::tests::send(&mut app, RowEvent::EditPair(f.facet.clone()));
|
||||
super::super::tests::send(&mut app, RowEvent::Attachment(Event::Stage));
|
||||
super::super::tests::decide(&mut app, f.facet.clone(), SemanticDecision::Candidate);
|
||||
super::super::tests::send(
|
||||
&mut app,
|
||||
RowEvent::Shape("/parent".into(), serde_json::json!(f.outside[1])),
|
||||
);
|
||||
let state = super::super::tests::state(&app);
|
||||
assert!(state.can_apply(), "{}", errors(state));
|
||||
let expected = state.proposed.clone().unwrap();
|
||||
super::super::tests::apply(&mut app);
|
||||
assert_eq!(app.project(), &expected);
|
||||
let after = app.document.snapshot();
|
||||
assert!(
|
||||
after.bundle.files[&after.bundle.entry]
|
||||
.starts_with("// Child allocation rationale stays exact\r\n")
|
||||
);
|
||||
let _ = app.update(Message::Undo);
|
||||
assert_eq!(app.project(), &before.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
let _ = app.update(Message::Redo);
|
||||
assert_eq!(app.project(), &expected);
|
||||
assert_eq!(app.document.bundle(), &after.bundle);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absent_attachment_payloads_migrate_v1_and_v2_and_source5_upgrade_requires_exact_legacy_base() {
|
||||
let f = fixture(true, IncidentEnd::Source);
|
||||
let mut state = State::new(&f.p, f.trace).unwrap();
|
||||
let facet = FacetSelector::Header(HeaderFacet::Name);
|
||||
send(&mut state, &f.p, RowEvent::Select(facet.clone()));
|
||||
send(
|
||||
&mut state,
|
||||
&f.p,
|
||||
RowEvent::Decide(facet, Some(SemanticDecision::Candidate)),
|
||||
);
|
||||
send(
|
||||
&mut state,
|
||||
&f.p,
|
||||
RowEvent::Input("/name".into(), "Retained exact name".into()),
|
||||
);
|
||||
for mut value in [
|
||||
state.active.recovery_snapshot().unwrap(),
|
||||
state.recovery_snapshot().unwrap(),
|
||||
] {
|
||||
if value["version"] == 1 {
|
||||
value.as_object_mut().unwrap().remove("attachments");
|
||||
} else {
|
||||
for row in value["rows"].as_array_mut().unwrap() {
|
||||
row["draft"].as_object_mut().unwrap().remove("attachments");
|
||||
}
|
||||
}
|
||||
let restored = State::from_recovery(&f.p, value.clone()).unwrap();
|
||||
assert!(restored.attachments.drafts.is_empty());
|
||||
assert_eq!(
|
||||
restored.draft.as_ref().unwrap().inputs["/name"].raw,
|
||||
"Retained exact name"
|
||||
);
|
||||
value["base_fingerprint"] = serde_json::json!("verified legacy source-5 fingerprint");
|
||||
assert!(State::from_recovery(&f.p, value.clone()).is_err());
|
||||
assert!(
|
||||
State::from_recovery_after_source5_upgrade(&f.p, "wrong base", value.clone()).is_err()
|
||||
);
|
||||
let upgraded = State::from_recovery_after_source5_upgrade(
|
||||
&f.p,
|
||||
"verified legacy source-5 fingerprint",
|
||||
value.clone(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(upgraded.base_fingerprint, project_fingerprint(&f.p));
|
||||
assert_eq!(upgraded.decisions, restored.decisions);
|
||||
assert_eq!(
|
||||
upgraded.draft.as_ref().unwrap().inputs["/name"].raw,
|
||||
"Retained exact name"
|
||||
);
|
||||
assert!(upgraded.accepted_impacts.is_empty());
|
||||
value["base_identity"] = serde_json::json!(Id::new_v4());
|
||||
assert!(
|
||||
State::from_recovery_after_source5_upgrade(
|
||||
&f.p,
|
||||
"verified legacy source-5 fingerprint",
|
||||
value
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn functional_incident_source_requires_explicit_matching_port_review_in_one_atomic_candidate() {
|
||||
let mut p = Project::empty();
|
||||
let mut owners = vec![];
|
||||
let mut replacements = vec![];
|
||||
let mut exchanges = vec![];
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let kind = if layer == Layer::Oa {
|
||||
Kind::Activity
|
||||
} else {
|
||||
Kind::Function
|
||||
};
|
||||
let owner = p.add_element(kind, layer, "Sender", None).unwrap();
|
||||
let receiver = p.add_element(kind, layer, "Receiver", None).unwrap();
|
||||
let replacement = p
|
||||
.add_element(kind, layer, "Reviewed replacement sender", None)
|
||||
.unwrap();
|
||||
let exchange = p.relate(
|
||||
if layer == Layer::Oa {
|
||||
RelationKind::Interaction
|
||||
} else {
|
||||
RelationKind::FunctionalExchange
|
||||
},
|
||||
"Payload exchange",
|
||||
owner,
|
||||
receiver,
|
||||
);
|
||||
owners.push(owner);
|
||||
replacements.push(replacement);
|
||||
exchanges.push(exchange);
|
||||
}
|
||||
let port = Id::new_v4();
|
||||
p.elements.insert(
|
||||
port,
|
||||
make_port(
|
||||
&p,
|
||||
port,
|
||||
replacements[1],
|
||||
"Reviewed replacement output",
|
||||
PortContract::FunctionOutput {
|
||||
outgoing_items: vec![],
|
||||
},
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
let owner_trace =
|
||||
super::super::tests::add_trace(&mut p, owners[0], owners[1], "Sender ownership rationale");
|
||||
let exchange_trace = super::super::tests::add_trace(
|
||||
&mut p,
|
||||
exchanges[0],
|
||||
exchanges[1],
|
||||
"Explicit exchange port rationale",
|
||||
);
|
||||
validate_candidate(&p).unwrap();
|
||||
let before = p.clone();
|
||||
let facet = FacetSelector::Incident {
|
||||
keys: KeyPair {
|
||||
oa: Some(exchanges[0]),
|
||||
sa: Some(exchanges[1]),
|
||||
},
|
||||
end: IncidentEnd::Source,
|
||||
};
|
||||
let mut state = State::new(&p, owner_trace).unwrap();
|
||||
send(&mut state, &p, RowEvent::EditPair(facet.clone()));
|
||||
send(&mut state, &p, RowEvent::Attachment(Event::Stage));
|
||||
candidate(&mut state, &p, &facet, replacements[1]);
|
||||
assert!(!state.can_apply());
|
||||
assert!(
|
||||
errors(&state).contains("functional_port"),
|
||||
"{}",
|
||||
errors(&state)
|
||||
);
|
||||
assert!(
|
||||
state.plan.as_ref().unwrap().impacts.is_empty(),
|
||||
"Matching ports are explicit review choices, never invented impacts"
|
||||
);
|
||||
|
||||
state.update(
|
||||
&p,
|
||||
super::super::Event::ChooseTrace(view::choice(&p, exchange_trace)),
|
||||
);
|
||||
state.update(&p, super::super::Event::OpenTrace);
|
||||
// The direct Source agrees with the owner's Incident Source. It also gives
|
||||
// the direct row's port picker the explicitly authored replacement owner.
|
||||
for (selector, value) in [
|
||||
(RelationFacet::Source, replacements[1]),
|
||||
(RelationFacet::SourcePort, port),
|
||||
] {
|
||||
let selector = FacetSelector::Relation(selector);
|
||||
send(&mut state, &p, RowEvent::Select(selector.clone()));
|
||||
send(
|
||||
&mut state,
|
||||
&p,
|
||||
RowEvent::Decide(selector.clone(), Some(SemanticDecision::Candidate)),
|
||||
);
|
||||
let field = scalar::binding(&p, exchanges[1], &selector).unwrap();
|
||||
if selector == FacetSelector::Relation(RelationFacet::SourcePort) {
|
||||
let draft = state.candidate_draft(&selector).unwrap();
|
||||
assert!(
|
||||
crate::forms::reference_choices(draft, &p, &field, Id::nil())
|
||||
.iter()
|
||||
.any(|choice| choice.id == port),
|
||||
"The actual scoped picker must offer the matching explicitly owned port"
|
||||
);
|
||||
}
|
||||
send(
|
||||
&mut state,
|
||||
&p,
|
||||
RowEvent::Shape(field, serde_json::json!(value)),
|
||||
);
|
||||
}
|
||||
assert!(state.can_apply(), "{}", errors(&state));
|
||||
assert!(state.plan.as_ref().unwrap().impacts.is_empty());
|
||||
let request = &state.plan.as_ref().unwrap().request;
|
||||
assert_eq!(
|
||||
request.candidates.len(),
|
||||
1,
|
||||
"Two owner records must not become two conflicting partial relationship candidates"
|
||||
);
|
||||
assert_eq!(request.reviews.len(), 2);
|
||||
assert!(
|
||||
request
|
||||
.reviews
|
||||
.iter()
|
||||
.find(|review| review.source == exchanges[0])
|
||||
.unwrap()
|
||||
.resolutions
|
||||
.iter()
|
||||
.any(|resolution| resolution.facet
|
||||
== FacetSelector::Relation(RelationFacet::SourcePort)
|
||||
&& resolution.decision == SemanticDecision::Candidate)
|
||||
);
|
||||
|
||||
let mut transition =
|
||||
TransitionRequest::new(TransitionDirection::OaToSa, vec![owners[0], exchanges[0]]);
|
||||
transition.mappings = vec![
|
||||
TransitionMapping {
|
||||
source: owners[0],
|
||||
target: Some(owners[1]),
|
||||
target_kind: None,
|
||||
},
|
||||
TransitionMapping {
|
||||
source: exchanges[0],
|
||||
target: Some(exchanges[1]),
|
||||
target_kind: None,
|
||||
},
|
||||
];
|
||||
let mut direct = SemanticTransitionRequest::new(transition);
|
||||
direct.references = BTreeMap::from([(owners[0], owners[1]), (exchanges[0], exchanges[1])]);
|
||||
direct.traces = BTreeMap::from([(owners[0], owner_trace), (exchanges[0], exchange_trace)]);
|
||||
direct.reviews = vec![
|
||||
SemanticReviewRequest {
|
||||
source: owners[0],
|
||||
resolutions: vec![FacetResolution {
|
||||
facet,
|
||||
decision: SemanticDecision::Candidate,
|
||||
}],
|
||||
reset: vec![],
|
||||
},
|
||||
SemanticReviewRequest {
|
||||
source: exchanges[0],
|
||||
resolutions: [RelationFacet::Source, RelationFacet::SourcePort]
|
||||
.into_iter()
|
||||
.map(|facet| FacetResolution {
|
||||
facet: FacetSelector::Relation(facet),
|
||||
decision: SemanticDecision::Candidate,
|
||||
})
|
||||
.collect(),
|
||||
reset: vec![],
|
||||
},
|
||||
];
|
||||
let mut relation = p.relations[&exchanges[1]].clone();
|
||||
let (_, receiver) = relation.body.endpoints();
|
||||
relation.body.set_endpoints(replacements[1], receiver);
|
||||
if let RelationBody::FunctionalExchange { source_port, .. } = &mut relation.body {
|
||||
*source_port = port;
|
||||
} else {
|
||||
unreachable!();
|
||||
}
|
||||
direct.candidates.push(SemanticRecord::Relation(relation));
|
||||
let plan = plan_semantic_transition(&p, direct).unwrap();
|
||||
assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts);
|
||||
let expected = prepare_semantic_transition(&p, &plan).unwrap().project;
|
||||
assert_eq!(state.proposed.as_ref(), Some(&expected));
|
||||
assert!(state.command(&p).is_ok());
|
||||
assert_eq!(
|
||||
p, before,
|
||||
"Combined preview never mutates accepted ports or model history"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
//! Explicit attachment pair and final destination field controls.
|
||||
use super::{
|
||||
attachments::{Editor, Event, Family},
|
||||
*,
|
||||
};
|
||||
use iced::{
|
||||
Element as View, Fill,
|
||||
widget::{button, column, pick_list, row, text, text_input},
|
||||
};
|
||||
|
||||
pub(super) fn pairs<'a>(
|
||||
editor: &'a Editor,
|
||||
project: &'a Project,
|
||||
trace: &'a Trace,
|
||||
) -> View<'a, Event> {
|
||||
let mut content = column![
|
||||
text("Existing children and relationship endpoints").size(16),
|
||||
text("Choose explicit Operational and System identities. A child's final parent or a relationship's final endpoint is reviewed independently; opening another trace keeps this draft.").size(12),
|
||||
pick_list([Family::Child, Family::Source, Family::Target], Some(editor.family), Event::Family).text_size(12).padding(7).width(Fill),
|
||||
text_input("Filter attachment identities by name or alias", &editor.search).on_input(Event::Search).size(12).padding(7),
|
||||
].spacing(9);
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let label = if layer == Layer::Oa {
|
||||
"Operational"
|
||||
} else {
|
||||
"System"
|
||||
};
|
||||
let owner = if layer == Layer::Oa {
|
||||
trace.oa
|
||||
} else {
|
||||
trace.sa
|
||||
};
|
||||
content = content.push(text(format!("{label} owner: {}", project.named(owner))).size(12));
|
||||
content = content.push(
|
||||
row![
|
||||
pick_list(
|
||||
editor.choices(project, trace, layer),
|
||||
editor.selection(layer).cloned(),
|
||||
move |choice| Event::Pick(layer, Some(choice))
|
||||
)
|
||||
.placeholder(format!("Choose an existing {label} identity"))
|
||||
.text_size(12)
|
||||
.padding(7)
|
||||
.width(Fill),
|
||||
button(text("No identity on this side").size(11))
|
||||
.style(crate::style::nav)
|
||||
.padding(7)
|
||||
.on_press(Event::Pick(layer, None)),
|
||||
]
|
||||
.spacing(7),
|
||||
);
|
||||
}
|
||||
content = content.push(
|
||||
button(text("Use exact attachment pair").size(12))
|
||||
.padding(8)
|
||||
.style(crate::style::nav)
|
||||
.on_press(Event::Stage),
|
||||
);
|
||||
content.push(text("Taking an absent relationship endpoint requires an explicit final replacement. New child and relationship creation uses a separate explicit transition mapping.").size(11)).padding(iced::Padding::default().right(18)).into()
|
||||
}
|
||||
|
||||
pub(super) fn candidate<'a>(
|
||||
editor: &'a Editor,
|
||||
project: &'a Project,
|
||||
facet: &'a FacetSelector,
|
||||
layer: Layer,
|
||||
) -> Option<View<'a, Message>> {
|
||||
let draft = editor.drafts.get(facet)?;
|
||||
let (id, _) = attachments::actual_slot(facet, layer)?;
|
||||
let path = attachments::path(project, facet, layer)?;
|
||||
Some(
|
||||
column![
|
||||
text(format!("Destination record: {}", project.named(id))).size(12),
|
||||
crate::forms::scoped(draft, project, &path, attachments::label(facet)),
|
||||
]
|
||||
.spacing(7)
|
||||
.into(),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,689 @@
|
||||
//! Session orchestration. Row focus never changes the identity of authored drafts.
|
||||
use super::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{
|
||||
ops::{Deref, DerefMut},
|
||||
sync::Arc,
|
||||
};
|
||||
pub(super) const MAX_REVIEW_ROWS: usize = 256;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct RowAddress {
|
||||
pub source: Id,
|
||||
pub target: Id,
|
||||
pub trace: Id,
|
||||
}
|
||||
impl RowAddress {
|
||||
pub(super) fn from_trace(trace: &Trace, direction: TransitionDirection) -> Self {
|
||||
let (source, target) = match direction {
|
||||
TransitionDirection::OaToSa => (trace.oa, trace.sa),
|
||||
TransitionDirection::SaToOa => (trace.sa, trace.oa),
|
||||
};
|
||||
Self {
|
||||
source,
|
||||
target,
|
||||
trace: trace.id,
|
||||
}
|
||||
}
|
||||
pub(super) fn direction(self, project: &Project) -> Result<TransitionDirection, String> {
|
||||
let trace = project
|
||||
.traces
|
||||
.get(&self.trace)
|
||||
.ok_or("Review trace no longer exists.")?;
|
||||
if self.source == trace.oa && self.target == trace.sa {
|
||||
Ok(TransitionDirection::OaToSa)
|
||||
} else if self.source == trace.sa && self.target == trace.oa {
|
||||
Ok(TransitionDirection::SaToOa)
|
||||
} else {
|
||||
Err("Review row and its exact trace correspondence disagree.".into())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Event {
|
||||
AcceptImpact(Box<SemanticImpact>, bool),
|
||||
ChooseTrace(Choice),
|
||||
OpenTrace,
|
||||
Focus(RowAddress),
|
||||
Include(RowAddress, bool),
|
||||
Direction(TransitionDirection),
|
||||
Row {
|
||||
address: Option<RowAddress>,
|
||||
facet: Option<FacetSelector>,
|
||||
event: RowEvent,
|
||||
},
|
||||
// Intermediate row-view message, always addressed by State::view before reaching App.
|
||||
Editor(RowEvent),
|
||||
}
|
||||
#[cfg(test)]
|
||||
impl From<RowEvent> for Event {
|
||||
fn from(event: RowEvent) -> Self {
|
||||
Self::Editor(event)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct State {
|
||||
pub(super) base: Arc<Project>,
|
||||
pub(super) base_identity: Id,
|
||||
pub(super) base_fingerprint: String,
|
||||
pub direction: TransitionDirection,
|
||||
pub references: BTreeMap<Id, Id>,
|
||||
pub plan: Option<SemanticTransitionPlan>,
|
||||
pub proposed: Option<Project>,
|
||||
pub error: Option<String>,
|
||||
pub(super) accepted_impacts: Vec<SemanticImpact>,
|
||||
pub(super) active: ReviewRow,
|
||||
pub(super) active_address: Option<RowAddress>,
|
||||
pub(super) bank: BTreeMap<RowAddress, ReviewRow>,
|
||||
pub(super) order: Vec<RowAddress>,
|
||||
pub(super) included: BTreeSet<RowAddress>,
|
||||
pub(super) add_trace: Option<Choice>,
|
||||
pub(super) notice: Option<String>,
|
||||
}
|
||||
// The active editor remains accessible to existing local property tests/helpers.
|
||||
// Session planning/error/reference fields deliberately take precedence over row context.
|
||||
impl Deref for State {
|
||||
type Target = ReviewRow;
|
||||
fn deref(&self) -> &ReviewRow {
|
||||
&self.active
|
||||
}
|
||||
}
|
||||
impl DerefMut for State {
|
||||
fn deref_mut(&mut self) -> &mut ReviewRow {
|
||||
&mut self.active
|
||||
}
|
||||
}
|
||||
|
||||
impl State {
|
||||
pub fn new(project: &Project, selected: Id) -> Result<Self, String> {
|
||||
let active = ReviewRow::new(project, selected)?;
|
||||
let direction = active.direction;
|
||||
let active_address = active
|
||||
.trace
|
||||
.and_then(|id| project.traces.get(&id))
|
||||
.map(|trace| RowAddress::from_trace(trace, direction));
|
||||
let mut state = Self {
|
||||
base: Arc::new(project.clone()),
|
||||
base_identity: project.id,
|
||||
base_fingerprint: project_fingerprint(project),
|
||||
direction,
|
||||
references: BTreeMap::new(),
|
||||
plan: None,
|
||||
proposed: None,
|
||||
error: None,
|
||||
accepted_impacts: vec![],
|
||||
active,
|
||||
active_address,
|
||||
bank: BTreeMap::new(),
|
||||
order: active_address.into_iter().collect(),
|
||||
included: active_address.into_iter().collect(),
|
||||
add_trace: None,
|
||||
notice: None,
|
||||
};
|
||||
state.refresh(project);
|
||||
Ok(state)
|
||||
}
|
||||
pub fn target(&self, _project: &Project) -> Option<Id> {
|
||||
self.active_address.map(|address| address.target)
|
||||
}
|
||||
pub(super) fn row(&self, address: RowAddress) -> Option<&ReviewRow> {
|
||||
if self.active_address == Some(address) {
|
||||
Some(&self.active)
|
||||
} else {
|
||||
self.bank.get(&address)
|
||||
}
|
||||
}
|
||||
fn row_mut(&mut self, address: Option<RowAddress>) -> Option<&mut ReviewRow> {
|
||||
if self.active_address == address {
|
||||
Some(&mut self.active)
|
||||
} else {
|
||||
address.and_then(|address| self.bank.get_mut(&address))
|
||||
}
|
||||
}
|
||||
pub(super) fn included_addresses(&self) -> Vec<RowAddress> {
|
||||
self.included
|
||||
.iter()
|
||||
.filter(|address| address.direction(&self.base).ok() == Some(self.direction))
|
||||
.copied()
|
||||
.collect()
|
||||
}
|
||||
pub(super) fn frozen(&self, project: &Project) -> Result<(), String> {
|
||||
if project.id != self.base_identity || project_fingerprint(project) != self.base_fingerprint
|
||||
{
|
||||
Err("This review is stale: the accepted model changed after this review began. Drafts are retained; reopen a review against the new model before applying.".into())
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
pub fn address_event(&self, event: RowEvent) -> Event {
|
||||
if let RowEvent::AcceptImpact(index, accepted) = &event
|
||||
&& let Some(impact) = self.plan.as_ref().and_then(|plan| plan.impacts.get(*index))
|
||||
{
|
||||
return Event::AcceptImpact(Box::new(impact.clone()), *accepted);
|
||||
}
|
||||
Event::Row {
|
||||
address: self.active_address,
|
||||
facet: self.active.selected.clone(),
|
||||
event,
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
pub(super) fn stage_row_references(&mut self, facet: &FacetSelector) -> Result<(), String> {
|
||||
self.active.stage_row_references(facet)?;
|
||||
self.references.extend(self.active.references.clone());
|
||||
Ok(())
|
||||
}
|
||||
pub(super) fn canonical_references(&self) -> Result<BTreeMap<Id, Id>, String> {
|
||||
let mut references = self.references.clone();
|
||||
let mut sources = BTreeSet::new();
|
||||
for address in self.included_addresses() {
|
||||
if !sources.insert(address.source) {
|
||||
return Err("Only one trace row per source can be included. Exclude its alternative history explicitly.".into());
|
||||
}
|
||||
let trace = &self.base.traces[&address.trace];
|
||||
if references
|
||||
.insert(trace.oa, trace.sa)
|
||||
.is_some_and(|old| old != trace.sa)
|
||||
{
|
||||
return Err(format!(
|
||||
"Transition mapping disagrees with frozen correspondence: {}. Reverse convergence of multiple System sources on one Operational target is not representable by the core's canonical reference map. Exclude a row or choose a distinct target; all drafts are retained.",
|
||||
self.base.named(trace.oa)
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(references)
|
||||
}
|
||||
fn open(&mut self, trace_id: Id, replace_active: bool) -> Result<(), String> {
|
||||
let trace = self
|
||||
.base
|
||||
.traces
|
||||
.get(&trace_id)
|
||||
.ok_or("Choose an existing trace.")?;
|
||||
let address = RowAddress::from_trace(trace, self.direction);
|
||||
if self.active_address == Some(address) {
|
||||
return Ok(());
|
||||
}
|
||||
let old = self.active_address;
|
||||
let transfer = replace_active && old.is_some_and(|old| self.included.contains(&old));
|
||||
let existed = self.bank.contains_key(&address);
|
||||
if !existed && self.order.len() >= MAX_REVIEW_ROWS {
|
||||
return Err("Semantic review exceeds the supported 256 retained rows.".into());
|
||||
}
|
||||
let mut next = if let Some(row) = self.bank.remove(&address) {
|
||||
row
|
||||
} else {
|
||||
let mut row = ReviewRow::new(&self.base, trace_id)?;
|
||||
row.direction = self.direction;
|
||||
row.load_trace(&self.base);
|
||||
row
|
||||
};
|
||||
// References are a derived editor context; the session retains authored pairs.
|
||||
next.references = self.references.clone();
|
||||
let previous = std::mem::replace(&mut self.active, next);
|
||||
if let Some(old) = old {
|
||||
self.bank.insert(old, previous);
|
||||
}
|
||||
self.active_address = Some(address);
|
||||
if !self.order.contains(&address) {
|
||||
self.order.push(address);
|
||||
}
|
||||
if transfer && let Some(old) = old {
|
||||
self.included.remove(&old);
|
||||
}
|
||||
let occupied = self
|
||||
.included_addresses()
|
||||
.iter()
|
||||
.any(|other| other.source == address.source && *other != address);
|
||||
if !occupied && (transfer || !existed) {
|
||||
self.included.insert(address);
|
||||
}
|
||||
if occupied {
|
||||
self.notice = Some("This source already has an included history. The alternative draft is open but excluded; exclude the other row before including this one.".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn change_direction(
|
||||
&mut self,
|
||||
direction: TransitionDirection,
|
||||
trace: Option<Id>,
|
||||
) -> Result<(), String> {
|
||||
let previous = self.direction;
|
||||
self.direction = direction;
|
||||
if let Some(trace) = trace {
|
||||
if let Err(error) = self.open(trace, false) {
|
||||
self.direction = previous;
|
||||
return Err(error);
|
||||
}
|
||||
} else {
|
||||
self.active.direction = direction;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn focus(&mut self, address: RowAddress) -> Result<(), String> {
|
||||
if self.active_address == Some(address) {
|
||||
return Ok(());
|
||||
}
|
||||
let next = self
|
||||
.bank
|
||||
.remove(&address)
|
||||
.ok_or("That review draft is no longer available.")?;
|
||||
let previous = std::mem::replace(&mut self.active, next);
|
||||
if let Some(old) = self.active_address {
|
||||
self.bank.insert(old, previous);
|
||||
}
|
||||
self.active_address = Some(address);
|
||||
self.direction = address.direction(&self.base)?;
|
||||
Ok(())
|
||||
}
|
||||
fn include(&mut self, address: RowAddress, include: bool) -> Result<(), String> {
|
||||
if self.row(address).is_none() {
|
||||
return Err("That review row is unavailable.".into());
|
||||
}
|
||||
if include {
|
||||
let direction = address.direction(&self.base)?;
|
||||
if self.included.iter().any(|other| {
|
||||
other != &address
|
||||
&& other.source == address.source
|
||||
&& other.direction(&self.base).ok() == Some(direction)
|
||||
}) {
|
||||
return Err("Only one included trace per source is allowed. Exclude the alternative history first; both drafts are retained.".into());
|
||||
}
|
||||
self.included.insert(address);
|
||||
} else {
|
||||
self.included.remove(&address);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub fn update(&mut self, project: &Project, event: impl Into<Event>) {
|
||||
let event = event.into();
|
||||
#[cfg(test)]
|
||||
let event = match event {
|
||||
Event::Editor(event) => self.address_event(event),
|
||||
other => other,
|
||||
};
|
||||
if let Err(error) = self.frozen(project) {
|
||||
// Withdrawing a pending reset is authored intent only. Retain it while
|
||||
// keeping the original accepted base and disabling every stale plan.
|
||||
if let Event::Row {
|
||||
address,
|
||||
event: RowEvent::Reset(facet, false),
|
||||
..
|
||||
} = &event
|
||||
&& let Some(row) = self.row_mut(*address)
|
||||
&& row.resets.remove(facet)
|
||||
{
|
||||
self.accepted_impacts.clear();
|
||||
}
|
||||
self.error = Some(error.clone());
|
||||
self.active.error = Some(error);
|
||||
self.plan = None;
|
||||
self.proposed = None;
|
||||
return;
|
||||
}
|
||||
let base = Arc::clone(&self.base);
|
||||
let browser_only = matches!(
|
||||
&event,
|
||||
Event::Row {
|
||||
event: RowEvent::ValueBrowse(_, _, _) | RowEvent::ReferenceBrowse(_, _, _),
|
||||
..
|
||||
}
|
||||
);
|
||||
self.notice = None;
|
||||
let mut authored = true;
|
||||
let result = match event {
|
||||
Event::AcceptImpact(impact, accepted) => {
|
||||
authored = false;
|
||||
if !self
|
||||
.plan
|
||||
.as_ref()
|
||||
.is_some_and(|plan| plan.impacts.contains(&impact))
|
||||
{
|
||||
Err("This exact prepared change is no longer in the preview. Review the current changes before accepting.".into())
|
||||
} else {
|
||||
self.accepted_impacts
|
||||
.retain(|old| old.object != impact.object);
|
||||
if accepted {
|
||||
self.accepted_impacts.push(*impact);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
Event::ChooseTrace(choice) => {
|
||||
self.add_trace = Some(choice);
|
||||
authored = false;
|
||||
Ok(())
|
||||
}
|
||||
Event::OpenTrace => self
|
||||
.add_trace
|
||||
.as_ref()
|
||||
.map(|choice| choice.id)
|
||||
.ok_or_else(|| "Choose a trace to add or reopen.".to_owned())
|
||||
.and_then(|id| self.open(id, false)),
|
||||
Event::Focus(address) => {
|
||||
authored = false;
|
||||
self.focus(address)
|
||||
}
|
||||
Event::Include(address, include) => self.include(address, include),
|
||||
Event::Direction(direction) => self.change_direction(direction, self.active.trace),
|
||||
Event::Editor(_) => {
|
||||
Err("Unaddressed editor message was rejected; choose its review row again.".into())
|
||||
}
|
||||
Event::Row {
|
||||
address,
|
||||
facet,
|
||||
event,
|
||||
} => {
|
||||
if self.row_mut(address).is_none() {
|
||||
Err("The originating review draft is no longer available.".into())
|
||||
} else {
|
||||
match event {
|
||||
#[cfg(test)]
|
||||
RowEvent::Trace(choice) => {
|
||||
if self.active_address != address {
|
||||
Err("A delayed trace selection belongs to another row; reopen that row first.".into())
|
||||
} else {
|
||||
self.open(choice.id, true)
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
RowEvent::Direction(direction) => {
|
||||
let trace = self.row_mut(address).and_then(|row| row.trace);
|
||||
self.change_direction(direction, trace)
|
||||
}
|
||||
RowEvent::AddReference => {
|
||||
let row = self.row_mut(address).expect("Checked row");
|
||||
match (&row.reference_oa, &row.reference_sa) {
|
||||
(Some(oa), Some(sa)) => {
|
||||
let (oa, sa) = (oa.id, sa.id);
|
||||
self.references.insert(oa, sa);
|
||||
Ok(())
|
||||
}
|
||||
_ => {
|
||||
Err("Choose both an Operational and a System reference.".into())
|
||||
}
|
||||
}
|
||||
}
|
||||
RowEvent::RestoreReviewedReferences => {
|
||||
let pairs = history::reviewed_pairs(
|
||||
self.row_mut(address).expect("Checked row"),
|
||||
&base,
|
||||
);
|
||||
pairs.and_then(|pairs| {
|
||||
let mut merged = self.canonical_references()?;
|
||||
for (oa, sa) in &pairs {
|
||||
if merged.insert(*oa, *sa).is_some_and(|old| old != *sa) {
|
||||
return Err("A reviewed pair conflicts with the current explicit correspondence. No pairs were restored.".into());
|
||||
}
|
||||
}
|
||||
if self.direction == TransitionDirection::SaToOa && merged.values().collect::<BTreeSet<_>>().len() != merged.len() {
|
||||
return Err("Reviewed pairs would make reverse correspondence ambiguous. No pairs were restored.".into());
|
||||
}
|
||||
self.notice = Some(format!("Restored {} exact historical key pairs. Order observations alone do not establish correspondence.", pairs.len()));
|
||||
self.references.extend(pairs);
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
RowEvent::RemoveReference(id) => {
|
||||
self.references.remove(&id);
|
||||
Ok(())
|
||||
}
|
||||
RowEvent::AcceptImpact(_, _) => {
|
||||
authored = false;
|
||||
Err("The prepared change could not be identified exactly; refresh its preview.".into())
|
||||
}
|
||||
event => {
|
||||
authored = !matches!(
|
||||
event,
|
||||
RowEvent::Select(_)
|
||||
| RowEvent::Tab(_)
|
||||
| RowEvent::ShowUnchanged(_)
|
||||
| RowEvent::ReferenceOa(_)
|
||||
| RowEvent::ReferenceSa(_)
|
||||
| RowEvent::ReferenceSearch(_)
|
||||
| RowEvent::Attachment(
|
||||
attachments::Event::Family(_)
|
||||
| attachments::Event::Pick(_, _)
|
||||
| attachments::Event::Search(_)
|
||||
)
|
||||
| RowEvent::Artifact(
|
||||
artifacts::Event::Pick(_, _) | artifacts::Event::Search(_)
|
||||
)
|
||||
| RowEvent::MemberFamily(_)
|
||||
| RowEvent::MemberOa(_)
|
||||
| RowEvent::MemberSa(_)
|
||||
| RowEvent::Literal(_)
|
||||
| RowEvent::LiteralSide(_, _)
|
||||
| RowEvent::EditPair(_)
|
||||
| RowEvent::AnnotationAddress(_, _)
|
||||
| RowEvent::AnnotationBrowse(_, _, _)
|
||||
| RowEvent::ValueBrowse(_, _, _)
|
||||
| RowEvent::ReferenceBrowse(_, _, _)
|
||||
| RowEvent::Preview
|
||||
);
|
||||
let scoped_edit = matches!(
|
||||
event,
|
||||
RowEvent::Input(_, _)
|
||||
| RowEvent::Text(_, _)
|
||||
| RowEvent::Shape(_, _)
|
||||
| RowEvent::Variant(_, _)
|
||||
| RowEvent::Array(_, _, _)
|
||||
| RowEvent::Append(_, _)
|
||||
| RowEvent::ValuePath(_, _)
|
||||
| RowEvent::ValueBrowse(_, _, _)
|
||||
| RowEvent::ReferenceBrowse(_, _, _)
|
||||
| RowEvent::OrderMove(_, _, _)
|
||||
| RowEvent::OrderReconcile(_)
|
||||
);
|
||||
let stage = matches!(
|
||||
event,
|
||||
RowEvent::StageMember
|
||||
| RowEvent::Attachment(attachments::Event::Stage)
|
||||
| RowEvent::Artifact(artifacts::Event::Stage)
|
||||
);
|
||||
let row = self.row_mut(address).expect("Checked row");
|
||||
let prior_references = stage.then(|| row.references.clone());
|
||||
let selected = row.selected.clone();
|
||||
let previous_error = row.error.take();
|
||||
if scoped_edit {
|
||||
row.selected = facet;
|
||||
}
|
||||
row.update(&base, event);
|
||||
if scoped_edit {
|
||||
row.selected = selected;
|
||||
}
|
||||
let result = row.error.clone().map_or(Ok(()), Err);
|
||||
if browser_only && result.is_ok() {
|
||||
row.error = previous_error;
|
||||
}
|
||||
if let Some(prior) = prior_references
|
||||
&& result.is_ok()
|
||||
{
|
||||
let changed: Vec<_> = row
|
||||
.references
|
||||
.iter()
|
||||
.filter(|(id, value)| prior.get(id) != Some(value))
|
||||
.map(|(id, value)| (*id, *value))
|
||||
.collect();
|
||||
self.references.extend(changed);
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
if authored {
|
||||
self.accepted_impacts.clear();
|
||||
}
|
||||
if !browser_only {
|
||||
self.refresh(project);
|
||||
}
|
||||
if let Err(error) = result {
|
||||
self.error = Some(error.clone());
|
||||
self.active.error = Some(error);
|
||||
}
|
||||
}
|
||||
pub(super) fn request(&self, _project: &Project) -> Result<SemanticTransitionRequest, String> {
|
||||
compose::request(self)
|
||||
}
|
||||
pub fn refresh(&mut self, project: &Project) {
|
||||
self.plan = None;
|
||||
self.proposed = None;
|
||||
self.error = None;
|
||||
if let Err(error) = self.frozen(project) {
|
||||
self.error = Some(error);
|
||||
return;
|
||||
}
|
||||
let base = Arc::clone(&self.base);
|
||||
let references = self.canonical_references();
|
||||
let context = references.as_ref().unwrap_or(&self.references).clone();
|
||||
for row in self
|
||||
.bank
|
||||
.values_mut()
|
||||
.chain(std::iter::once(&mut self.active))
|
||||
{
|
||||
row.references = context.clone();
|
||||
if let Some(trace) = row.trace.and_then(|id| base.traces.get(&id)) {
|
||||
row.references.insert(trace.oa, trace.sa);
|
||||
}
|
||||
row.refresh(&base);
|
||||
}
|
||||
let request = references.and_then(|_| self.request(&base));
|
||||
match request.and_then(|request| {
|
||||
plan_semantic_transition(&base, request).map_err(|error| error.to_string())
|
||||
}) {
|
||||
Ok(plan) => {
|
||||
if plan.conflicts.is_empty() {
|
||||
match prepare_semantic_transition(&base, &plan) {
|
||||
Ok(prepared) => self.proposed = Some(prepared.project),
|
||||
Err(error) => self.error = Some(error.to_string()),
|
||||
}
|
||||
}
|
||||
self.plan = Some(plan);
|
||||
}
|
||||
Err(error) => self.error = Some(error),
|
||||
}
|
||||
self.active.plan = self.plan.clone();
|
||||
self.active.proposed = self.proposed.clone();
|
||||
self.active.accepted_impacts = self.accepted_impacts.clone();
|
||||
self.active.error = self.error.clone().or_else(|| {
|
||||
self.active
|
||||
.combined_value(&base, false)
|
||||
.err()
|
||||
.map(|error| format!("This row's retained draft: {error}"))
|
||||
});
|
||||
if let Some(value) = self
|
||||
.proposed
|
||||
.as_ref()
|
||||
.and_then(|project| {
|
||||
self.target(project)
|
||||
.and_then(|id| Draft::from_project(project, id))
|
||||
})
|
||||
.map(|draft| draft.value)
|
||||
{
|
||||
if let Some(draft) = &mut self.active.draft {
|
||||
draft.set_context(Some(value.clone()));
|
||||
}
|
||||
for draft in self.active.group_drafts.values_mut() {
|
||||
draft.set_context(Some(value.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn command(&mut self, project: &Project) -> Result<Command, String> {
|
||||
self.frozen(project)?;
|
||||
if !self.has_decisions() {
|
||||
return Err("Choose at least one included property or exact history reset.".into());
|
||||
}
|
||||
if let Some(error) = &self.error {
|
||||
return Err(error.clone());
|
||||
}
|
||||
let plan = self
|
||||
.plan
|
||||
.as_ref()
|
||||
.ok_or("Refresh the combined review preview before applying.")?;
|
||||
prepare_semantic_transition(project, plan).map_err(|error| error.to_string())?;
|
||||
Ok(Command::Batch(plan.commands.clone()))
|
||||
}
|
||||
fn has_decisions(&self) -> bool {
|
||||
self.included_addresses()
|
||||
.into_iter()
|
||||
.filter_map(|address| self.row(address))
|
||||
.any(|row| !row.decisions.is_empty() || !row.resets.is_empty())
|
||||
}
|
||||
pub fn can_apply(&self) -> bool {
|
||||
self.has_decisions()
|
||||
&& self.error.is_none()
|
||||
&& self.plan.as_ref().is_some_and(|plan| {
|
||||
plan.conflicts.is_empty() && plan.prepared_fingerprint.is_some()
|
||||
})
|
||||
}
|
||||
pub fn view<'a>(&'a self, _project: &'a Project) -> iced::Element<'a, Message> {
|
||||
use iced::{
|
||||
Fill,
|
||||
widget::{button, checkbox, column, pick_list, row, text},
|
||||
};
|
||||
let message = |event| Message::Workflow(crate::workflow::Event::Semantic(event));
|
||||
let mut available: Vec<_> = self
|
||||
.base
|
||||
.traces
|
||||
.values()
|
||||
.map(|trace| Choice {
|
||||
id: trace.id,
|
||||
label: format!(
|
||||
"{} · {} ↔ {}",
|
||||
trace.name,
|
||||
self.base.named(trace.oa),
|
||||
self.base.named(trace.sa)
|
||||
),
|
||||
})
|
||||
.collect();
|
||||
available.sort_by(|a, b| a.label.cmp(&b.label));
|
||||
let choices: Vec<_> = self
|
||||
.order
|
||||
.iter()
|
||||
.filter(|address| address.direction(&self.base).ok() == Some(self.direction))
|
||||
.map(|address| Choice {
|
||||
id: address.trace,
|
||||
label: format!(
|
||||
"{} · {} → {} · {}",
|
||||
if self.included.contains(address) {
|
||||
"Included"
|
||||
} else {
|
||||
"Excluded"
|
||||
},
|
||||
self.base.named(address.source),
|
||||
self.base.named(address.target),
|
||||
self.base.named(address.trace)
|
||||
),
|
||||
})
|
||||
.collect();
|
||||
let selected = choices
|
||||
.iter()
|
||||
.find(|choice| {
|
||||
self.active_address
|
||||
.is_some_and(|address| address.trace == choice.id)
|
||||
})
|
||||
.cloned();
|
||||
let direction = self.direction;
|
||||
let base = &self.base;
|
||||
let mut header = column![
|
||||
text(format!("Review semantics · {} included rows", self.included_addresses().len())).size(22),
|
||||
row![pick_list(available, self.add_trace.clone(), move |choice| message(Event::ChooseTrace(choice))).placeholder("Choose another trace to add or reopen").width(Fill).text_size(12).padding(7), button(text("Open row").size(12)).padding(7).style(crate::style::nav).on_press(message(Event::OpenTrace)), button(text(if direction == TransitionDirection::OaToSa { "OA → SA" } else { "SA → OA" }).size(12)).padding(7).style(crate::style::nav).on_press(message(Event::Direction(if direction == TransitionDirection::OaToSa { TransitionDirection::SaToOa } else { TransitionDirection::OaToSa })))].spacing(7),
|
||||
row![pick_list(choices, selected, move |choice| message(Event::Focus(RowAddress::from_trace(&base.traces[&choice.id], direction)))).placeholder("Choose a trace / review history").width(Fill).text_size(12).padding(7), checkbox(self.active_address.is_some_and(|address| self.included.contains(&address))).label("Include this row").on_toggle_maybe(self.active_address.map(|address| move |included| message(Event::Include(address, included))))].spacing(10),
|
||||
text("Switching rows retains every draft. Only included rows contribute to the one combined preview.").size(10).color(crate::style::MUTED),
|
||||
].spacing(6);
|
||||
if let Some(notice) = &self.notice {
|
||||
header = header.push(text(notice).size(11));
|
||||
}
|
||||
self.active
|
||||
.view(&self.base, header.into(), self.can_apply())
|
||||
.map(move |message| match message {
|
||||
Message::Workflow(crate::workflow::Event::Semantic(Event::Editor(event))) => {
|
||||
Message::Workflow(crate::workflow::Event::Semantic(self.address_event(event)))
|
||||
}
|
||||
other => other,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,807 @@
|
||||
use super::*;
|
||||
use crate::{app::App, document::Document};
|
||||
|
||||
struct Ranges {
|
||||
project: Project,
|
||||
sources: [Id; 2],
|
||||
targets: [Id; 2],
|
||||
traces: [Id; 2],
|
||||
direction: TransitionDirection,
|
||||
}
|
||||
fn ranges(reverse: bool, converge: bool) -> Ranges {
|
||||
let mut project = Project::empty();
|
||||
let direction = if reverse {
|
||||
TransitionDirection::SaToOa
|
||||
} else {
|
||||
TransitionDirection::OaToSa
|
||||
};
|
||||
let mut add = |name, layer| {
|
||||
let id = project
|
||||
.add_element(Kind::DataType, layer, name, None)
|
||||
.unwrap();
|
||||
*project.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::DataType {
|
||||
definition: DataTypeDefinition::Integer {
|
||||
minimum: Some(0),
|
||||
maximum: Some(100),
|
||||
},
|
||||
default: None,
|
||||
unit: "degrees".into(),
|
||||
};
|
||||
id
|
||||
};
|
||||
let sources = [
|
||||
add("Normal operation", direction.source_layer()),
|
||||
add("Emergency operation", direction.source_layer()),
|
||||
];
|
||||
let first = add("Controller limit", direction.target_layer());
|
||||
let second = if converge {
|
||||
first
|
||||
} else {
|
||||
add("Backup controller limit", direction.target_layer())
|
||||
};
|
||||
let targets = [first, second];
|
||||
let traces = std::array::from_fn(|i| {
|
||||
let (oa, sa) = if reverse {
|
||||
(targets[i], sources[i])
|
||||
} else {
|
||||
(sources[i], targets[i])
|
||||
};
|
||||
tests::add_trace(
|
||||
&mut project,
|
||||
oa,
|
||||
sa,
|
||||
if i == 0 {
|
||||
"Normal limit rationale"
|
||||
} else {
|
||||
"Emergency limit rationale"
|
||||
},
|
||||
)
|
||||
});
|
||||
Ranges {
|
||||
project,
|
||||
sources,
|
||||
targets,
|
||||
traces,
|
||||
direction,
|
||||
}
|
||||
}
|
||||
fn address(f: &Ranges, i: usize) -> RowAddress {
|
||||
RowAddress {
|
||||
source: f.sources[i],
|
||||
target: f.targets[i],
|
||||
trace: f.traces[i],
|
||||
}
|
||||
}
|
||||
fn send(state: &mut State, project: &Project, event: RowEvent) {
|
||||
let event = state.address_event(event);
|
||||
state.update(project, event);
|
||||
}
|
||||
fn open(state: &mut State, project: &Project, trace: Id) {
|
||||
state.update(project, Event::ChooseTrace(view::choice(project, trace)));
|
||||
state.update(project, Event::OpenTrace);
|
||||
}
|
||||
fn path(direction: TransitionDirection, leaf: &str) -> String {
|
||||
format!(
|
||||
"/body/{}/Shared/DataType/definition/Integer/{leaf}",
|
||||
if direction.target_layer() == Layer::Oa {
|
||||
"Oa"
|
||||
} else {
|
||||
"Sa"
|
||||
}
|
||||
)
|
||||
}
|
||||
fn set_bound(state: &mut State, f: &Ranges, facet: DataFacet, raw: &str) {
|
||||
let selector = FacetSelector::Data(facet);
|
||||
send(state, &f.project, RowEvent::Select(selector.clone()));
|
||||
send(
|
||||
state,
|
||||
&f.project,
|
||||
RowEvent::Decide(selector, Some(SemanticDecision::Candidate)),
|
||||
);
|
||||
send(
|
||||
state,
|
||||
&f.project,
|
||||
RowEvent::Input(
|
||||
path(
|
||||
f.direction,
|
||||
if facet == DataFacet::IntegerMinimum {
|
||||
"minimum"
|
||||
} else {
|
||||
"maximum"
|
||||
},
|
||||
),
|
||||
raw.into(),
|
||||
),
|
||||
);
|
||||
}
|
||||
fn bounds(project: &Project, target: Id) -> (Option<i64>, Option<i64>) {
|
||||
match project.elements[&target].shared().unwrap() {
|
||||
SharedBody::DataType {
|
||||
definition: DataTypeDefinition::Integer { minimum, maximum },
|
||||
..
|
||||
} => (*minimum, *maximum),
|
||||
other => panic!("Unexpected target {other:?}"),
|
||||
}
|
||||
}
|
||||
fn errors(state: &State) -> String {
|
||||
format!(
|
||||
"{:?} / {:?}",
|
||||
state.error,
|
||||
state.plan.as_ref().map(|plan| &plan.conflicts)
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_joint_bounds_use_one_candidate_and_match_an_independent_atomic_core_oracle() {
|
||||
let f = ranges(false, true);
|
||||
let mut state = State::new(&f.project, f.sources[0]).unwrap();
|
||||
set_bound(&mut state, &f, DataFacet::IntegerMinimum, "120");
|
||||
assert!(!state.can_apply(), "A single 120..100 row is invalid");
|
||||
open(&mut state, &f.project, f.traces[1]);
|
||||
set_bound(&mut state, &f, DataFacet::IntegerMaximum, "150");
|
||||
assert!(state.can_apply(), "{}", errors(&state));
|
||||
assert_eq!(
|
||||
bounds(state.proposed.as_ref().unwrap(), f.targets[0]),
|
||||
(Some(120), Some(150))
|
||||
);
|
||||
let mut transition = TransitionRequest::new(f.direction, f.sources.to_vec());
|
||||
transition.mappings = (0..2)
|
||||
.map(|i| TransitionMapping {
|
||||
source: f.sources[i],
|
||||
target: Some(f.targets[i]),
|
||||
target_kind: None,
|
||||
})
|
||||
.collect();
|
||||
let mut expected = SemanticTransitionRequest::new(transition);
|
||||
expected.references = (0..2).map(|i| (f.sources[i], f.targets[i])).collect();
|
||||
expected.traces = (0..2).map(|i| (f.sources[i], f.traces[i])).collect();
|
||||
expected.reviews = [DataFacet::IntegerMinimum, DataFacet::IntegerMaximum]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(i, facet)| SemanticReviewRequest {
|
||||
source: f.sources[i],
|
||||
resolutions: vec![FacetResolution {
|
||||
facet: FacetSelector::Data(facet),
|
||||
decision: SemanticDecision::Candidate,
|
||||
}],
|
||||
reset: vec![],
|
||||
})
|
||||
.collect();
|
||||
let mut candidate = f.project.elements[&f.targets[0]].clone();
|
||||
let SharedBody::DataType { definition, .. } = candidate.shared_mut().unwrap() else {
|
||||
unreachable!()
|
||||
};
|
||||
*definition = DataTypeDefinition::Integer {
|
||||
minimum: Some(120),
|
||||
maximum: Some(150),
|
||||
};
|
||||
expected.candidates.push(SemanticRecord::Element(candidate));
|
||||
let oracle = plan_semantic_transition(&f.project, expected).unwrap();
|
||||
assert!(oracle.conflicts.is_empty(), "{:?}", oracle.conflicts);
|
||||
assert_eq!(
|
||||
state.proposed.as_ref().unwrap(),
|
||||
&prepare_semantic_transition(&f.project, &oracle)
|
||||
.unwrap()
|
||||
.project
|
||||
);
|
||||
assert_eq!(state.plan.as_ref().unwrap().request.candidates.len(), 1);
|
||||
let prepared = state.proposed.clone();
|
||||
state.order.reverse();
|
||||
state.refresh(&f.project);
|
||||
assert_eq!(
|
||||
state.proposed, prepared,
|
||||
"Display order must not choose the winning draft"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_candidate_conflict_keeps_both_raw_values_and_keep_is_a_real_constraint() {
|
||||
let f = ranges(false, true);
|
||||
let mut state = State::new(&f.project, f.sources[0]).unwrap();
|
||||
set_bound(&mut state, &f, DataFacet::IntegerMinimum, "5");
|
||||
open(&mut state, &f.project, f.traces[1]);
|
||||
set_bound(&mut state, &f, DataFacet::IntegerMinimum, "7");
|
||||
assert!(!state.can_apply());
|
||||
let error = errors(&state);
|
||||
assert!(
|
||||
error.contains("Normal operation")
|
||||
&& error.contains("Emergency operation")
|
||||
&& error.contains("disagree"),
|
||||
"{error}"
|
||||
);
|
||||
let minimum = path(f.direction, "minimum");
|
||||
assert_eq!(
|
||||
state
|
||||
.row(address(&f, 0))
|
||||
.unwrap()
|
||||
.draft
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.inputs[&minimum]
|
||||
.raw,
|
||||
"5"
|
||||
);
|
||||
assert_eq!(
|
||||
state
|
||||
.row(address(&f, 1))
|
||||
.unwrap()
|
||||
.draft
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.inputs[&minimum]
|
||||
.raw,
|
||||
"7"
|
||||
);
|
||||
send(
|
||||
&mut state,
|
||||
&f.project,
|
||||
RowEvent::Decide(
|
||||
FacetSelector::Data(DataFacet::IntegerMinimum),
|
||||
Some(SemanticDecision::KeepTarget),
|
||||
),
|
||||
);
|
||||
assert!(
|
||||
!state.can_apply(),
|
||||
"Keeping zero must conflict with another row's five"
|
||||
);
|
||||
send(
|
||||
&mut state,
|
||||
&f.project,
|
||||
RowEvent::Decide(FacetSelector::Data(DataFacet::IntegerMinimum), None),
|
||||
);
|
||||
assert!(state.can_apply(), "{}", errors(&state));
|
||||
assert_eq!(
|
||||
bounds(state.proposed.as_ref().unwrap(), f.targets[0]).0,
|
||||
Some(5)
|
||||
);
|
||||
set_bound(&mut state, &f, DataFacet::IntegerMinimum, "5");
|
||||
assert!(
|
||||
state.can_apply(),
|
||||
"Equal independently authored values agree"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_excluded_invalid_row_survives_focus_reopen_and_v2_recovery() {
|
||||
let f = ranges(false, true);
|
||||
let mut state = State::new(&f.project, f.sources[0]).unwrap();
|
||||
set_bound(&mut state, &f, DataFacet::IntegerMinimum, "5");
|
||||
open(&mut state, &f.project, f.traces[1]);
|
||||
set_bound(&mut state, &f, DataFacet::IntegerMaximum, "-");
|
||||
assert!(!state.can_apply());
|
||||
state.update(&f.project, Event::Include(address(&f, 1), false));
|
||||
assert!(state.can_apply(), "{}", errors(&state));
|
||||
state.update(&f.project, Event::Focus(address(&f, 0)));
|
||||
open(&mut state, &f.project, f.traces[1]);
|
||||
assert!(
|
||||
!state.included.contains(&address(&f, 1)),
|
||||
"Reopen is focus, not consent to include invalid work"
|
||||
);
|
||||
assert!(state.can_apply(), "{}", errors(&state));
|
||||
send(
|
||||
&mut state,
|
||||
&f.project,
|
||||
RowEvent::Select(FacetSelector::Data(DataFacet::IntegerMinimum)),
|
||||
);
|
||||
assert!(
|
||||
state.can_apply(),
|
||||
"Selecting an excluded property must not promote its draft error to a batch failure"
|
||||
);
|
||||
let saved = state.recovery_snapshot().unwrap();
|
||||
assert_eq!(saved["version"], 2);
|
||||
let restored = State::from_recovery(&f.project, saved.clone()).unwrap();
|
||||
assert_eq!(restored.recovery_snapshot().unwrap(), saved);
|
||||
assert_eq!(
|
||||
restored
|
||||
.row(address(&f, 1))
|
||||
.unwrap()
|
||||
.draft
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.inputs[&path(f.direction, "maximum")]
|
||||
.raw,
|
||||
"-"
|
||||
);
|
||||
assert!(restored.can_apply(), "{}", errors(&restored));
|
||||
assert_eq!(restored.proposed, state.proposed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_delayed_text_targets_its_original_row_and_facet_without_switching_focus() {
|
||||
let f = ranges(false, true);
|
||||
let mut state = State::new(&f.project, f.sources[0]).unwrap();
|
||||
set_bound(&mut state, &f, DataFacet::IntegerMinimum, "5");
|
||||
let late = state.address_event(RowEvent::Input(path(f.direction, "minimum"), "6".into()));
|
||||
open(&mut state, &f.project, f.traces[1]);
|
||||
set_bound(&mut state, &f, DataFacet::IntegerMaximum, "150");
|
||||
state.update(&f.project, late);
|
||||
assert_eq!(state.active_address, Some(address(&f, 1)));
|
||||
assert_eq!(
|
||||
state.selected,
|
||||
Some(FacetSelector::Data(DataFacet::IntegerMaximum))
|
||||
);
|
||||
assert!(state.can_apply(), "{}", errors(&state));
|
||||
assert_eq!(
|
||||
bounds(state.proposed.as_ref().unwrap(), f.targets[0]),
|
||||
(Some(6), Some(150))
|
||||
);
|
||||
state.update(&f.project, Event::Focus(address(&f, 0)));
|
||||
let late = state.address_event(RowEvent::Input(path(f.direction, "minimum"), "8".into()));
|
||||
set_bound(&mut state, &f, DataFacet::IntegerMaximum, "150");
|
||||
state.update(&f.project, late);
|
||||
assert_eq!(
|
||||
state.selected,
|
||||
Some(FacetSelector::Data(DataFacet::IntegerMaximum))
|
||||
);
|
||||
assert_eq!(
|
||||
state.draft.as_ref().unwrap().inputs[&path(f.direction, "minimum")].raw,
|
||||
"8"
|
||||
);
|
||||
assert_eq!(
|
||||
state.draft.as_ref().unwrap().inputs[&path(f.direction, "maximum")].raw,
|
||||
"150"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_reverse_distinct_targets_work_and_reverse_convergence_is_explicit() {
|
||||
for converge in [false, true] {
|
||||
let f = ranges(true, converge);
|
||||
let mut state = State::new(&f.project, f.sources[0]).unwrap();
|
||||
set_bound(&mut state, &f, DataFacet::IntegerMinimum, "5");
|
||||
open(&mut state, &f.project, f.traces[1]);
|
||||
set_bound(&mut state, &f, DataFacet::IntegerMaximum, "150");
|
||||
if converge {
|
||||
assert!(!state.can_apply());
|
||||
assert!(
|
||||
errors(&state).contains("Reverse convergence"),
|
||||
"{}",
|
||||
errors(&state)
|
||||
);
|
||||
assert_eq!(state.included_addresses().len(), 2);
|
||||
} else {
|
||||
assert!(state.can_apply(), "{}", errors(&state));
|
||||
assert_eq!(
|
||||
bounds(state.proposed.as_ref().unwrap(), f.targets[0]).0,
|
||||
Some(5)
|
||||
);
|
||||
assert_eq!(
|
||||
bounds(state.proposed.as_ref().unwrap(), f.targets[1]).1,
|
||||
Some(150)
|
||||
);
|
||||
assert_eq!(state.plan.as_ref().unwrap().request.candidates.len(), 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_v2_rejects_duplicate_included_sources_bad_addresses_and_stale_model() {
|
||||
let mut f = ranges(false, false);
|
||||
let alternate = tests::add_trace(
|
||||
&mut f.project,
|
||||
f.sources[0],
|
||||
f.targets[0],
|
||||
"Alternative rationale",
|
||||
);
|
||||
let mut state = State::new(&f.project, f.traces[0]).unwrap();
|
||||
set_bound(&mut state, &f, DataFacet::IntegerMinimum, "5");
|
||||
open(&mut state, &f.project, alternate);
|
||||
let alternate_address = RowAddress {
|
||||
trace: alternate,
|
||||
..address(&f, 0)
|
||||
};
|
||||
assert!(!state.included.contains(&alternate_address));
|
||||
let saved = state.recovery_snapshot().unwrap();
|
||||
let mut duplicate = saved.clone();
|
||||
duplicate["rows"][1]["included"] = Value::Bool(true);
|
||||
assert!(
|
||||
State::from_recovery(&f.project, duplicate)
|
||||
.err()
|
||||
.unwrap()
|
||||
.contains("multiple included")
|
||||
);
|
||||
let mut duplicate = saved.clone();
|
||||
let extra = duplicate["rows"][0].clone();
|
||||
duplicate["rows"].as_array_mut().unwrap().push(extra);
|
||||
assert!(State::from_recovery(&f.project, duplicate).is_err());
|
||||
let mut bad = saved.clone();
|
||||
bad["active"]["target"] = serde_json::to_value(f.sources[0]).unwrap();
|
||||
assert!(State::from_recovery(&f.project, bad).is_err());
|
||||
let mut changed = f.project.clone();
|
||||
changed.name.push_str(" newer accepted revision");
|
||||
assert!(State::from_recovery(&changed, saved).is_err());
|
||||
assert!(state.command(&changed).is_err());
|
||||
let before = state
|
||||
.row(address(&f, 0))
|
||||
.unwrap()
|
||||
.draft
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.inputs[&path(f.direction, "minimum")]
|
||||
.raw
|
||||
.clone();
|
||||
state.update(&changed, Event::Focus(address(&f, 0)));
|
||||
assert_eq!(
|
||||
state
|
||||
.row(address(&f, 0))
|
||||
.unwrap()
|
||||
.draft
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.inputs[&path(f.direction, "minimum")]
|
||||
.raw,
|
||||
before
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_constructor_retirement_conflicts_with_another_rows_kept_bound_in_both_orders() {
|
||||
for first in [0, 1] {
|
||||
let mut f = ranges(false, true);
|
||||
*f.project
|
||||
.elements
|
||||
.get_mut(&f.targets[0])
|
||||
.unwrap()
|
||||
.shared_mut()
|
||||
.unwrap() = SharedBody::DataType {
|
||||
definition: DataTypeDefinition::Real {
|
||||
minimum: Some(0.0),
|
||||
maximum: Some(100.0),
|
||||
},
|
||||
default: None,
|
||||
unit: "degrees".into(),
|
||||
};
|
||||
let mut state = State::new(&f.project, f.sources[first]).unwrap();
|
||||
for i in [first, 1 - first] {
|
||||
if state.active_address != Some(address(&f, i)) {
|
||||
open(&mut state, &f.project, f.traces[i]);
|
||||
}
|
||||
let facet = FacetSelector::Data(if i == 0 {
|
||||
DataFacet::Constructor
|
||||
} else {
|
||||
DataFacet::RealMinimum
|
||||
});
|
||||
send(&mut state, &f.project, RowEvent::Select(facet.clone()));
|
||||
send(
|
||||
&mut state,
|
||||
&f.project,
|
||||
RowEvent::Decide(
|
||||
facet,
|
||||
Some(if i == 0 {
|
||||
SemanticDecision::TakeSource
|
||||
} else {
|
||||
SemanticDecision::KeepTarget
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
!state.can_apply(),
|
||||
"A constructor must not retire another row's kept bound"
|
||||
);
|
||||
assert!(errors(&state).contains("disagree"), "{}", errors(&state));
|
||||
assert!(
|
||||
state
|
||||
.row(address(&f, 0))
|
||||
.unwrap()
|
||||
.decisions
|
||||
.contains_key(&FacetSelector::Data(DataFacet::Constructor))
|
||||
);
|
||||
assert!(
|
||||
state
|
||||
.row(address(&f, 1))
|
||||
.unwrap()
|
||||
.decisions
|
||||
.contains_key(&FacetSelector::Data(DataFacet::RealMinimum))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_apply_is_one_exact_source_and_history_undo_redo_roundtrip() {
|
||||
let f = ranges(false, true);
|
||||
let mut app = App::with_project(f.project.clone());
|
||||
let mut bundle = app.document.bundle().clone();
|
||||
bundle
|
||||
.files
|
||||
.get_mut(&bundle.entry.clone())
|
||||
.unwrap()
|
||||
.insert_str(
|
||||
0,
|
||||
"// Two independent engineering rationales stay verbatim\r\n",
|
||||
);
|
||||
app.document.apply_bundle(bundle).unwrap();
|
||||
let before = app.document.snapshot();
|
||||
tests::start(&mut app, f.sources[0]);
|
||||
tests::decide(
|
||||
&mut app,
|
||||
FacetSelector::Data(DataFacet::IntegerMinimum),
|
||||
SemanticDecision::Candidate,
|
||||
);
|
||||
tests::send(
|
||||
&mut app,
|
||||
RowEvent::Input(path(f.direction, "minimum"), "120".into()),
|
||||
);
|
||||
let _ = app.update(Message::Workflow(crate::workflow::Event::Semantic(
|
||||
Event::ChooseTrace(view::choice(&f.project, f.traces[1])),
|
||||
)));
|
||||
let _ = app.update(Message::Workflow(crate::workflow::Event::Semantic(
|
||||
Event::OpenTrace,
|
||||
)));
|
||||
tests::decide(
|
||||
&mut app,
|
||||
FacetSelector::Data(DataFacet::IntegerMaximum),
|
||||
SemanticDecision::Candidate,
|
||||
);
|
||||
tests::send(
|
||||
&mut app,
|
||||
RowEvent::Input(path(f.direction, "maximum"), "150".into()),
|
||||
);
|
||||
assert!(
|
||||
tests::state(&app).can_apply(),
|
||||
"{}",
|
||||
errors(tests::state(&app))
|
||||
);
|
||||
let expected = tests::state(&app).proposed.clone().unwrap();
|
||||
tests::apply(&mut app);
|
||||
assert_eq!(app.project(), &expected);
|
||||
let after = app.document.snapshot();
|
||||
assert!(
|
||||
after.bundle.files[&after.bundle.entry]
|
||||
.starts_with("// Two independent engineering rationales stay verbatim\r\n")
|
||||
);
|
||||
let _ = app.update(Message::Undo);
|
||||
assert_eq!(app.project(), &before.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
let _ = app.update(Message::Redo);
|
||||
assert_eq!(app.project(), &expected);
|
||||
assert_eq!(app.document.bundle(), &after.bundle);
|
||||
assert_eq!(
|
||||
Document::from_bundle(
|
||||
after.bundle.clone(),
|
||||
source_project::ProjectLimits::default()
|
||||
)
|
||||
.unwrap()
|
||||
.project(),
|
||||
&expected
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn category_family_and_members_are_independent_core_checked_facets_both_directions() {
|
||||
for reverse in [false, true] {
|
||||
let mut project = Project::empty();
|
||||
let mut owners = vec![];
|
||||
let mut exchanges = vec![];
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let work = if layer == Layer::Oa {
|
||||
Kind::Activity
|
||||
} else {
|
||||
Kind::Function
|
||||
};
|
||||
let source = project
|
||||
.add_element(work, layer, "Issue command", None)
|
||||
.unwrap();
|
||||
let target = project
|
||||
.add_element(work, layer, "Perform command", None)
|
||||
.unwrap();
|
||||
let exchange = project.relate(
|
||||
if layer == Layer::Oa {
|
||||
RelationKind::Interaction
|
||||
} else {
|
||||
RelationKind::FunctionalExchange
|
||||
},
|
||||
"Command",
|
||||
source,
|
||||
target,
|
||||
);
|
||||
let owner = project
|
||||
.add_element(Kind::ExchangeCategory, layer, "Command concern", None)
|
||||
.unwrap();
|
||||
*project
|
||||
.elements
|
||||
.get_mut(&owner)
|
||||
.unwrap()
|
||||
.shared_mut()
|
||||
.unwrap() = SharedBody::ExchangeCategory {
|
||||
family: oa_core::view_options::ExchangeFamily::Work,
|
||||
exchanges: vec![exchange],
|
||||
};
|
||||
owners.push(owner);
|
||||
exchanges.push(exchange);
|
||||
}
|
||||
tests::add_trace(&mut project, owners[0], owners[1], "Concern correspondence");
|
||||
validate_candidate(&project).unwrap();
|
||||
let mut state = State::new(&project, owners[usize::from(reverse)]).unwrap();
|
||||
state.references.insert(exchanges[0], exchanges[1]);
|
||||
state.refresh(&project);
|
||||
let member = FacetSelector::Member {
|
||||
collection: ReferenceCollection::CategoryExchanges,
|
||||
keys: KeyPair {
|
||||
oa: Some(exchanges[0]),
|
||||
sa: Some(exchanges[1]),
|
||||
},
|
||||
};
|
||||
let family = FacetSelector::Category(CategoryFacet::Family);
|
||||
assert!(state.editable(&project, &family));
|
||||
assert!(state.editable(&project, &member));
|
||||
send(&mut state, &project, RowEvent::Select(family.clone()));
|
||||
send(
|
||||
&mut state,
|
||||
&project,
|
||||
RowEvent::Decide(family.clone(), Some(SemanticDecision::Candidate)),
|
||||
);
|
||||
let target = owners[usize::from(!reverse)];
|
||||
let path = scalar::binding(&project, target, &family).unwrap();
|
||||
send(
|
||||
&mut state,
|
||||
&project,
|
||||
RowEvent::Shape(path, serde_json::json!("Participant")),
|
||||
);
|
||||
assert!(
|
||||
!state.can_apply(),
|
||||
"Work exchanges cannot belong to a Participant category"
|
||||
);
|
||||
send(&mut state, &project, RowEvent::Select(member.clone()));
|
||||
send(
|
||||
&mut state,
|
||||
&project,
|
||||
RowEvent::Decide(member.clone(), Some(SemanticDecision::Candidate)),
|
||||
);
|
||||
send(&mut state, &project, RowEvent::Presence(member, false));
|
||||
assert!(state.can_apply(), "{}", errors(&state));
|
||||
assert!(
|
||||
matches!(state.proposed.as_ref().unwrap().elements[&target].shared().unwrap(), SharedBody::ExchangeCategory { family: oa_core::view_options::ExchangeFamily::Participant, exchanges } if exchanges.is_empty())
|
||||
);
|
||||
assert_eq!(
|
||||
prepare_command(&project, &state.command(&project).unwrap())
|
||||
.unwrap()
|
||||
.project,
|
||||
state.proposed.clone().unwrap()
|
||||
);
|
||||
let _ = state.view(&project);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejected_direction_switch_at_row_limit_preserves_every_recoverable_draft() {
|
||||
let mut f = ranges(false, true);
|
||||
let mut traces = vec![f.traces[0]];
|
||||
for i in 1..batch::MAX_REVIEW_ROWS {
|
||||
traces.push(tests::add_trace(
|
||||
&mut f.project,
|
||||
f.sources[0],
|
||||
f.targets[0],
|
||||
&format!("Alternative {i}"),
|
||||
));
|
||||
}
|
||||
let mut state = State::new(&f.project, traces[0]).unwrap();
|
||||
set_bound(&mut state, &f, DataFacet::IntegerMinimum, "-");
|
||||
for trace in traces.into_iter().skip(1) {
|
||||
let row = ReviewRow::new(&f.project, trace).unwrap();
|
||||
let address = RowAddress {
|
||||
source: f.sources[0],
|
||||
target: f.targets[0],
|
||||
trace,
|
||||
};
|
||||
state.bank.insert(address, row);
|
||||
state.order.push(address);
|
||||
}
|
||||
let before = state.recovery_snapshot().unwrap();
|
||||
for event in [
|
||||
Event::Direction(TransitionDirection::SaToOa),
|
||||
state.address_event(RowEvent::Direction(TransitionDirection::SaToOa)),
|
||||
] {
|
||||
state.update(&f.project, event);
|
||||
assert_eq!(state.direction, TransitionDirection::OaToSa);
|
||||
assert!(state.error.as_ref().unwrap().contains("256"));
|
||||
let saved = state.recovery_snapshot().unwrap();
|
||||
assert_eq!(saved, before);
|
||||
let restored = State::from_recovery(&f.project, saved).unwrap();
|
||||
assert_eq!(restored.recovery_snapshot().unwrap(), before);
|
||||
assert_eq!(
|
||||
restored.draft.as_ref().unwrap().inputs[&path(f.direction, "minimum")].raw,
|
||||
"-"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_complete_constructor_cannot_erase_another_rows_new_literal() {
|
||||
let mut f = ranges(false, true);
|
||||
for id in f.sources.into_iter().chain(f.targets) {
|
||||
*f.project
|
||||
.elements
|
||||
.get_mut(&id)
|
||||
.unwrap()
|
||||
.shared_mut()
|
||||
.unwrap() = SharedBody::DataType {
|
||||
definition: DataTypeDefinition::Enumeration {
|
||||
literals: vec!["A".into()],
|
||||
},
|
||||
default: None,
|
||||
unit: String::new(),
|
||||
};
|
||||
}
|
||||
let constructor = FacetSelector::Data(DataFacet::Constructor);
|
||||
let mut first = State::new(&f.project, f.sources[0]).unwrap();
|
||||
send(
|
||||
&mut first,
|
||||
&f.project,
|
||||
RowEvent::Decide(constructor.clone(), Some(SemanticDecision::KeepTarget)),
|
||||
);
|
||||
assert!(first.can_apply(), "{}", errors(&first));
|
||||
f.project = first.proposed.unwrap();
|
||||
let mut state = State::new(&f.project, f.sources[0]).unwrap();
|
||||
send(
|
||||
&mut state,
|
||||
&f.project,
|
||||
RowEvent::Decide(constructor, Some(SemanticDecision::TakeSource)),
|
||||
);
|
||||
open(&mut state, &f.project, f.traces[1]);
|
||||
let literal = FacetSelector::Literal(KeyPair {
|
||||
oa: None,
|
||||
sa: Some("B".into()),
|
||||
});
|
||||
send(&mut state, &f.project, RowEvent::Select(literal.clone()));
|
||||
send(
|
||||
&mut state,
|
||||
&f.project,
|
||||
RowEvent::Decide(literal.clone(), Some(SemanticDecision::Candidate)),
|
||||
);
|
||||
send(&mut state, &f.project, RowEvent::Presence(literal, true));
|
||||
assert!(!state.can_apply());
|
||||
assert!(errors(&state).contains("disagree"), "{}", errors(&state));
|
||||
state.update(&f.project, Event::Include(address(&f, 0), false));
|
||||
assert!(state.can_apply(), "{}", errors(&state));
|
||||
assert!(
|
||||
matches!(state.proposed.as_ref().unwrap().elements[&f.targets[0]].shared().unwrap(), SharedBody::DataType { definition: DataTypeDefinition::Enumeration { literals }, .. } if literals == &vec!["A".to_string(), "B".to_string()])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_first_equal_constructor_acknowledgement_allows_an_independent_bound() {
|
||||
let f = ranges(false, true);
|
||||
let mut state = State::new(&f.project, f.sources[0]).unwrap();
|
||||
send(
|
||||
&mut state,
|
||||
&f.project,
|
||||
RowEvent::Decide(
|
||||
FacetSelector::Data(DataFacet::Constructor),
|
||||
Some(SemanticDecision::KeepTarget),
|
||||
),
|
||||
);
|
||||
open(&mut state, &f.project, f.traces[1]);
|
||||
set_bound(&mut state, &f, DataFacet::IntegerMinimum, "5");
|
||||
assert!(state.can_apply(), "{}", errors(&state));
|
||||
assert_eq!(
|
||||
bounds(state.proposed.as_ref().unwrap(), f.targets[0]),
|
||||
(Some(5), Some(100))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recovered_trace_picker_names_existing_trace_instead_of_deleted_reference() {
|
||||
let f = ranges(false, true);
|
||||
let mut state = State::new(&f.project, f.traces[0]).unwrap();
|
||||
state.update(
|
||||
&f.project,
|
||||
Event::ChooseTrace(view::choice(&f.project, f.traces[1])),
|
||||
);
|
||||
let saved = state.recovery_snapshot().unwrap();
|
||||
let restored = State::from_recovery(&f.project, saved).unwrap();
|
||||
let selected = restored.add_trace.as_ref().unwrap();
|
||||
assert_eq!(selected.id, f.traces[1]);
|
||||
assert!(
|
||||
selected.label.contains("Emergency limit rationale"),
|
||||
"{}",
|
||||
selected.label
|
||||
);
|
||||
assert!(!selected.label.contains("Deleted reference"));
|
||||
assert_eq!(selected.label, state.add_trace.as_ref().unwrap().label);
|
||||
assert!(
|
||||
view::choice(&f.project, Id::new_v4())
|
||||
.label
|
||||
.contains("Deleted reference")
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,626 @@
|
||||
//! Compare independent authored intent before building the core's unique target candidates.
|
||||
use super::*;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "compose_diagnostic_tests.rs"]
|
||||
mod diagnostic_tests;
|
||||
|
||||
type Slot = (Id, FacetSelector);
|
||||
struct Intent {
|
||||
value: Value,
|
||||
row: RowAddress,
|
||||
origin: FacetSelector,
|
||||
}
|
||||
struct Proposal {
|
||||
address: RowAddress,
|
||||
value: Value,
|
||||
groups: Vec<FacetSelector>,
|
||||
copies: Vec<FacetSelector>,
|
||||
}
|
||||
|
||||
fn canonical(facet: &FacetSelector, layer: Layer) -> Option<FacetSelector> {
|
||||
fn key<T: Clone>(keys: &KeyPair<T>, layer: Layer) -> Option<KeyPair<T>> {
|
||||
Some(KeyPair {
|
||||
oa: None,
|
||||
sa: Some(
|
||||
if layer == Layer::Oa {
|
||||
keys.oa.as_ref()?
|
||||
} else {
|
||||
keys.sa.as_ref()?
|
||||
}
|
||||
.clone(),
|
||||
),
|
||||
})
|
||||
}
|
||||
Some(match facet {
|
||||
FacetSelector::Member { collection, keys } => FacetSelector::Member {
|
||||
collection: *collection,
|
||||
keys: key(keys, layer)?,
|
||||
},
|
||||
FacetSelector::Literal(keys) => FacetSelector::Literal(key(keys, layer)?),
|
||||
FacetSelector::Annotation(keys) => FacetSelector::Annotation(key(keys, layer)?),
|
||||
FacetSelector::Binding(keys) => FacetSelector::Binding(key(keys, layer)?),
|
||||
FacetSelector::Child(_)
|
||||
| FacetSelector::Incident { .. }
|
||||
| FacetSelector::Artifact { .. } => return None,
|
||||
_ => facet.clone(),
|
||||
})
|
||||
}
|
||||
fn observe(project: &Project, slot: &Slot) -> Result<Value, String> {
|
||||
if let Some((_, facet)) = artifacts::parts(&slot.1) {
|
||||
return Ok(artifacts::observation(
|
||||
project.artifact_links.get(&slot.0),
|
||||
facet,
|
||||
));
|
||||
}
|
||||
// A retired destination slot can legitimately apply on neither side of this
|
||||
// synthetic target-only observation. Public capture_facet intentionally rejects
|
||||
// such historical facts; determine absence through the same closed UI binding.
|
||||
let applicable = if let Some(family) = keyed::family(&slot.1) {
|
||||
keyed::path(&record_value(project, slot.0)?, family).is_some()
|
||||
} else if let FacetSelector::Order(which) = slot.1 {
|
||||
ordered::read(&record_value(project, slot.0)?, which).is_ok()
|
||||
} else {
|
||||
scalar::binding(project, slot.0, &slot.1).is_some()
|
||||
};
|
||||
if !applicable {
|
||||
return Ok(Value::String("NotApplicable".into()));
|
||||
}
|
||||
Ok(scalar::observation(
|
||||
&capture_facet(project, slot.0, slot.0, &slot.1)?,
|
||||
Layer::Sa,
|
||||
))
|
||||
}
|
||||
fn record_value(project: &Project, id: Id) -> Result<Value, String> {
|
||||
Draft::from_project(project, id)
|
||||
.map(|draft| draft.value)
|
||||
.ok_or("Target has no editable semantic record.".into())
|
||||
}
|
||||
fn install(project: &mut Project, id: Id, value: Value) -> Result<(), String> {
|
||||
if let std::collections::btree_map::Entry::Occupied(mut entry) = project.elements.entry(id) {
|
||||
let record: Element = serde_json::from_value(value)
|
||||
.map_err(|error| format!("Final target value: {error}"))?;
|
||||
if record.id != id {
|
||||
return Err("Candidate identity changed.".into());
|
||||
}
|
||||
entry.insert(record);
|
||||
} else if let std::collections::btree_map::Entry::Occupied(mut entry) =
|
||||
project.relations.entry(id)
|
||||
{
|
||||
let record: Relation = serde_json::from_value(value)
|
||||
.map_err(|error| format!("Final target value: {error}"))?;
|
||||
if record.id != id {
|
||||
return Err("Candidate identity changed.".into());
|
||||
}
|
||||
entry.insert(record);
|
||||
} else if let std::collections::btree_map::Entry::Occupied(mut entry) =
|
||||
project.artifact_links.entry(id)
|
||||
{
|
||||
let record: ArtifactLink = serde_json::from_value(value)
|
||||
.map_err(|error| format!("Final artifact value: {error}"))?;
|
||||
if record.id != id {
|
||||
return Err("Candidate artifact identity changed.".into());
|
||||
}
|
||||
entry.insert(record);
|
||||
} else {
|
||||
return Err("The batch foundation requires an existing target record.".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn identity(base: &Project, id: Id) -> String {
|
||||
base.identities()
|
||||
.into_iter()
|
||||
.find(|(key, _)| *key == id)
|
||||
.map_or_else(
|
||||
|| format!("Missing identity {id}"),
|
||||
|(_, alias)| format!("{} · @{alias}", base.named(id)),
|
||||
)
|
||||
}
|
||||
fn review_identity(state: &State, row: RowAddress, id: Id) -> String {
|
||||
if !state.base.contains(id)
|
||||
&& let Some(review) = state.row(row)
|
||||
&& review.artifacts.seeds.contains_key(&id)
|
||||
{
|
||||
return review.review_choice(&state.base, id).label;
|
||||
}
|
||||
identity(&state.base, id)
|
||||
}
|
||||
fn review_title(state: &State, row: RowAddress, facet: &FacetSelector) -> String {
|
||||
state.row(row).map_or_else(
|
||||
|| view::title(&state.base, facet),
|
||||
|review| review.review_title(&state.base, facet),
|
||||
)
|
||||
}
|
||||
#[cfg(test)]
|
||||
fn describe(base: &Project, slot: &Slot, value: &Value) -> String {
|
||||
describe_with(slot, value, |id| identity(base, id))
|
||||
}
|
||||
fn describe_with(slot: &Slot, value: &Value, identify: impl Fn(Id) -> String) -> String {
|
||||
let text = if let Some((_, facet)) = artifacts::parts(&slot.1) {
|
||||
if value.as_str() == Some("NotApplicable") {
|
||||
"artifact absent".into()
|
||||
} else if facet == ArtifactFacet::Owner {
|
||||
value
|
||||
.get("Value")
|
||||
.and_then(Value::as_str)
|
||||
.and_then(|s| s.parse::<Id>().ok())
|
||||
.map(|id| format!("owner {}", identify(id)))
|
||||
.unwrap_or_else(|| value.to_string())
|
||||
} else {
|
||||
value.get("Value").unwrap_or(value).to_string()
|
||||
}
|
||||
} else {
|
||||
value.to_string()
|
||||
};
|
||||
if text.chars().count() > 400 {
|
||||
format!(
|
||||
"{}… (full draft retained)",
|
||||
text.chars().take(400).collect::<String>()
|
||||
)
|
||||
} else {
|
||||
text
|
||||
}
|
||||
}
|
||||
fn agree(
|
||||
state: &State,
|
||||
slots: &mut BTreeMap<Slot, Intent>,
|
||||
slot: Slot,
|
||||
value: Value,
|
||||
row: RowAddress,
|
||||
origin: &FacetSelector,
|
||||
) -> Result<(), String> {
|
||||
let base = &state.base;
|
||||
if let Some(previous) = slots.get(&slot) {
|
||||
if previous.value != value {
|
||||
let same_row = previous.row == row;
|
||||
let heading = if same_row {
|
||||
"Decisions in this review disagree"
|
||||
} else {
|
||||
"Independent row proposals disagree"
|
||||
};
|
||||
let scope = artifacts::parts(&slot.1).map_or_else(
|
||||
|| view::title(base, &slot.1),
|
||||
|(_, facet)| format!("Artifact {facet:?}"),
|
||||
);
|
||||
let first_row = format!(
|
||||
"{} → {} [trace {}]",
|
||||
identity(base, previous.row.source),
|
||||
identity(base, previous.row.target),
|
||||
identity(base, previous.row.trace)
|
||||
);
|
||||
let second_row = if same_row {
|
||||
"the same review".into()
|
||||
} else {
|
||||
format!(
|
||||
"{} → {} [trace {}]",
|
||||
identity(base, row.source),
|
||||
identity(base, row.target),
|
||||
identity(base, row.trace)
|
||||
)
|
||||
};
|
||||
let action = if same_row {
|
||||
"Edit or unselect one of these decisions."
|
||||
} else {
|
||||
"Edit or unselect a decision, or exclude a row."
|
||||
};
|
||||
return Err(format!(
|
||||
"{heading} for {} · {scope}: {} in {first_row} requires {}; {} in {second_row} requires {}. {action} Both drafts are retained.",
|
||||
review_identity(state, row, slot.0),
|
||||
review_title(state, previous.row, &previous.origin),
|
||||
describe_with(&slot, &previous.value, |id| review_identity(
|
||||
state,
|
||||
previous.row,
|
||||
id
|
||||
)),
|
||||
review_title(state, row, origin),
|
||||
describe_with(&slot, &value, |id| review_identity(state, row, id))
|
||||
));
|
||||
}
|
||||
} else {
|
||||
slots.insert(
|
||||
slot,
|
||||
Intent {
|
||||
value,
|
||||
row,
|
||||
origin: origin.clone(),
|
||||
},
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn write_slot(project: &mut Project, slot: &Slot, observation: &Value) -> Result<(), String> {
|
||||
if let Some((_, facet)) = artifacts::parts(&slot.1) {
|
||||
if observation.as_str() == Some("NotApplicable") {
|
||||
if facet == ArtifactFacet::Owner {
|
||||
project.artifact_links.remove(&slot.0);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
let mut value = record_value(project, slot.0)?;
|
||||
let field =
|
||||
artifacts::field(facet).ok_or("Artifact actual slot must identify metadata.")?;
|
||||
value[field] = observation
|
||||
.get("Value")
|
||||
.cloned()
|
||||
.ok_or("Artifact observation lacks typed value.")?;
|
||||
return install(project, slot.0, value);
|
||||
}
|
||||
if scalar::grouped(&slot.1) || observation.as_str() == Some("NotApplicable") {
|
||||
return Ok(());
|
||||
}
|
||||
let supplied = observation
|
||||
.get("Value")
|
||||
.ok_or("Missing typed destination observation.")?
|
||||
.clone();
|
||||
let mut value = record_value(project, slot.0)?;
|
||||
if keyed_rows::supported(&slot.1) {
|
||||
let key = keyed::key(&slot.1, Layer::Sa).ok_or("Destination row key missing.")?;
|
||||
let path = keyed::path(&value, keyed::family(&slot.1).expect("Supported family"))
|
||||
.ok_or("Destination row collection is no longer applicable.")?;
|
||||
let rows = value
|
||||
.pointer_mut(&path)
|
||||
.and_then(Value::as_array_mut)
|
||||
.ok_or("Destination rows missing.")?;
|
||||
let index = rows.iter().position(|row| match &slot.1 {
|
||||
FacetSelector::Annotation(_) => {
|
||||
serde_json::json!({"path":row["path"],"field":row["field"]}) == key
|
||||
}
|
||||
FacetSelector::Binding(_) => row["referenced"] == key,
|
||||
_ => false,
|
||||
});
|
||||
match (index, supplied.is_null()) {
|
||||
(Some(index), false) => rows[index] = supplied,
|
||||
(Some(index), true) => {
|
||||
rows.remove(index);
|
||||
}
|
||||
(None, false) => rows.push(supplied),
|
||||
(None, true) => {}
|
||||
}
|
||||
} else if keyed::supported(&slot.1) {
|
||||
keyed::project_presence(
|
||||
&mut value,
|
||||
&slot.1,
|
||||
Layer::Sa,
|
||||
supplied.as_bool().ok_or("Expected typed presence.")?,
|
||||
)?;
|
||||
} else if let FacetSelector::Order(which) = slot.1 {
|
||||
let order: Vec<Id> = serde_json::from_value(supplied).map_err(|error| error.to_string())?;
|
||||
let current = ordered::read(&value, which)?;
|
||||
if order.len() != order.iter().collect::<BTreeSet<_>>().len()
|
||||
|| order.iter().collect::<BTreeSet<_>>() != current.iter().collect::<BTreeSet<_>>()
|
||||
{
|
||||
return Err("Combined ordered membership needs an explicit final target order containing each surviving member once. Review membership explicitly in the order's row.".into());
|
||||
}
|
||||
let path = keyed::path(&value, keyed::Family::Member(ordered::collection(which)))
|
||||
.ok_or("Order collection missing.")?;
|
||||
*value.pointer_mut(&path).expect("Closed collection path") =
|
||||
serde_json::to_value(order).expect("Order");
|
||||
} else {
|
||||
let path = scalar::binding(project, slot.0, &slot.1)
|
||||
.ok_or("A selected property was retired by another row's constructor proposal.")?;
|
||||
*value
|
||||
.pointer_mut(&path)
|
||||
.ok_or("Selected property is no longer available.")? = supplied;
|
||||
}
|
||||
install(project, slot.0, value)
|
||||
}
|
||||
|
||||
pub(super) fn request(state: &State) -> Result<SemanticTransitionRequest, String> {
|
||||
let base = &state.base;
|
||||
let addresses = state.included_addresses();
|
||||
if addresses.is_empty() {
|
||||
return Err("Include at least one existing trace row in the combined review.".into());
|
||||
}
|
||||
let references = state.canonical_references()?;
|
||||
let mut transition = TransitionRequest::new(
|
||||
state.direction,
|
||||
addresses.iter().map(|address| address.source).collect(),
|
||||
);
|
||||
transition.mappings = addresses
|
||||
.iter()
|
||||
.map(|address| TransitionMapping {
|
||||
source: address.source,
|
||||
target: Some(address.target),
|
||||
target_kind: None,
|
||||
})
|
||||
.collect();
|
||||
let mut request = SemanticTransitionRequest::new(transition);
|
||||
request.references = references.clone();
|
||||
request.accepted_impacts = state.accepted_impacts.clone();
|
||||
let mut slots = BTreeMap::new();
|
||||
let mut proposals = vec![];
|
||||
let mut candidate_targets = BTreeSet::new();
|
||||
let mut implicit_orders = vec![];
|
||||
let mut artifact_creations = BTreeMap::new();
|
||||
for address in addresses {
|
||||
let row = state
|
||||
.row(address)
|
||||
.ok_or("Included row draft is unavailable.")?;
|
||||
let trace = row
|
||||
.effective_trace(base)
|
||||
.ok_or("Included trace is unavailable.")?;
|
||||
request.traces.insert(address.source, address.trace);
|
||||
request.reviews.push(SemanticReviewRequest {
|
||||
source: address.source,
|
||||
resolutions: row
|
||||
.decisions
|
||||
.iter()
|
||||
.map(|(facet, decision)| FacetResolution {
|
||||
facet: facet.clone(),
|
||||
decision: *decision,
|
||||
})
|
||||
.collect(),
|
||||
reset: row.resets.iter().cloned().collect(),
|
||||
});
|
||||
if row.decisions.iter().any(|(facet, decision)| {
|
||||
!attachments::supported(facet)
|
||||
&& !artifacts::supported(facet)
|
||||
&& *decision == SemanticDecision::Candidate
|
||||
}) {
|
||||
candidate_targets.insert(address.target);
|
||||
}
|
||||
let final_value = row.combined_value(base, false).map_err(|error| {
|
||||
format!(
|
||||
"{} [{}]: {error}",
|
||||
base.named(address.source),
|
||||
base.named(address.trace)
|
||||
)
|
||||
})?;
|
||||
let mut proposed = (**base).clone();
|
||||
install(&mut proposed, address.target, final_value.clone())?;
|
||||
let artifact =
|
||||
row.artifacts
|
||||
.propose(base, &trace, state.direction, &references, &row.decisions)?;
|
||||
for (slot, observation, origin) in artifact.slots {
|
||||
agree(state, &mut slots, slot, observation, address, &origin)?;
|
||||
}
|
||||
for seed in artifact.creations {
|
||||
if artifact_creations
|
||||
.insert(seed.id, seed.clone())
|
||||
.is_some_and(|prior| prior != seed)
|
||||
{
|
||||
return Err(
|
||||
"Independent artifact creation records disagree. All drafts are retained."
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
}
|
||||
candidate_targets.extend(artifact.candidates);
|
||||
let mut groups = vec![];
|
||||
let mut copies = vec![];
|
||||
for (facet, decision) in &row.decisions {
|
||||
if artifacts::supported(facet) {
|
||||
continue;
|
||||
}
|
||||
if attachments::supported(facet) {
|
||||
let attachment = row.attachments.propose(
|
||||
base,
|
||||
&trace,
|
||||
state.direction,
|
||||
facet,
|
||||
*decision,
|
||||
false,
|
||||
)?;
|
||||
let mut record = record_value(&proposed, attachment.slot.0)?;
|
||||
*record
|
||||
.pointer_mut(&attachment.path)
|
||||
.ok_or("Attachment field was retired by another selected decision.")? =
|
||||
attachment
|
||||
.record
|
||||
.pointer(&attachment.path)
|
||||
.cloned()
|
||||
.ok_or("Attachment proposal is incomplete.")?;
|
||||
install(&mut proposed, attachment.slot.0, record)?;
|
||||
let observed = observe(&proposed, &attachment.slot)?;
|
||||
agree(
|
||||
state,
|
||||
&mut slots,
|
||||
attachment.slot.clone(),
|
||||
observed,
|
||||
address,
|
||||
facet,
|
||||
)?;
|
||||
if *decision == SemanticDecision::Candidate {
|
||||
candidate_targets.insert(attachment.slot.0);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let Some(selector) = canonical(facet, state.direction.target_layer()) else {
|
||||
continue;
|
||||
};
|
||||
let slot = (address.target, selector);
|
||||
let observed = if *decision == SemanticDecision::KeepTarget {
|
||||
observe(base, &slot)?
|
||||
} else {
|
||||
observe(&proposed, &slot)?
|
||||
};
|
||||
agree(state, &mut slots, slot.clone(), observed, address, facet)?;
|
||||
let first_equal = row.can_acknowledge_constructor(base, facet)
|
||||
&& (*decision != SemanticDecision::Candidate
|
||||
|| observe(base, &slot)? == observe(&proposed, &slot)?);
|
||||
if scalar::grouped(facet) && (*decision == SemanticDecision::Candidate || !first_equal)
|
||||
{
|
||||
copies.push(facet.clone());
|
||||
}
|
||||
if scalar::grouped(facet) && first_equal && *decision == SemanticDecision::Candidate {
|
||||
// Preserve authored payload changes even when the first equal-kind
|
||||
// decision acknowledges only its discriminator. The core must see
|
||||
// these changes and require separate descendant decisions.
|
||||
let mut catalog = semantic_facets(base, &trace, &references)?;
|
||||
catalog.extend(semantic_facets(&proposed, &trace, &references)?);
|
||||
for fact in catalog {
|
||||
if constructors::contains(facet, &fact.selector)
|
||||
&& let Some(selector) =
|
||||
canonical(&fact.selector, state.direction.target_layer())
|
||||
{
|
||||
let slot = (address.target, selector);
|
||||
let supplied = observe(&proposed, &slot)?;
|
||||
if supplied != observe(base, &slot)? {
|
||||
agree(state, &mut slots, slot, supplied, address, facet)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if scalar::grouped(facet) && !first_equal {
|
||||
groups.push(facet.clone());
|
||||
let own = if *decision == SemanticDecision::KeepTarget {
|
||||
&**base
|
||||
} else {
|
||||
&proposed
|
||||
};
|
||||
let mut catalog = semantic_facets(base, &trace, &references)?;
|
||||
catalog.extend(semantic_facets(own, &trace, &references)?);
|
||||
for fact in catalog {
|
||||
if constructors::contains(facet, &fact.selector)
|
||||
&& let Some(selector) =
|
||||
canonical(&fact.selector, state.direction.target_layer())
|
||||
{
|
||||
let slot = (address.target, selector);
|
||||
agree(
|
||||
state,
|
||||
&mut slots,
|
||||
slot.clone(),
|
||||
observe(own, &slot)?,
|
||||
address,
|
||||
facet,
|
||||
)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for which in [
|
||||
OrderedCollection::Steps,
|
||||
OrderedCollection::FragmentBranches,
|
||||
] {
|
||||
if !row.decisions.contains_key(&FacetSelector::Order(which))
|
||||
&& let Ok(order) = ordered::read(&record_value(&proposed, address.target)?, which)
|
||||
&& ordered::read(&record_value(base, address.target)?, which)
|
||||
.ok()
|
||||
.as_ref()
|
||||
!= Some(&order)
|
||||
{
|
||||
implicit_orders.push((address.target, which, order));
|
||||
}
|
||||
}
|
||||
proposals.push(Proposal {
|
||||
address,
|
||||
value: final_value,
|
||||
groups,
|
||||
copies,
|
||||
});
|
||||
}
|
||||
// Recheck every selected slot against every complete constructor scope, including
|
||||
// new keys that were absent from that constructor row's own catalog.
|
||||
let selected: Vec<_> = slots.keys().cloned().collect();
|
||||
for proposal in &proposals {
|
||||
if proposal.groups.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let mut proposed = (**base).clone();
|
||||
install(
|
||||
&mut proposed,
|
||||
proposal.address.target,
|
||||
proposal.value.clone(),
|
||||
)?;
|
||||
let row = state.row(proposal.address).expect("Included row");
|
||||
for group in &proposal.groups {
|
||||
let own = if row.decisions[group] == SemanticDecision::KeepTarget {
|
||||
&**base
|
||||
} else {
|
||||
&proposed
|
||||
};
|
||||
for slot in &selected {
|
||||
if slot.0 == proposal.address.target && constructors::contains(group, &slot.1) {
|
||||
agree(
|
||||
state,
|
||||
&mut slots,
|
||||
slot.clone(),
|
||||
observe(own, slot)?,
|
||||
proposal.address,
|
||||
group,
|
||||
)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut merged = (**base).clone();
|
||||
merged.artifact_links.extend(artifact_creations);
|
||||
let mut groups: Vec<_> = proposals
|
||||
.iter()
|
||||
.flat_map(|proposal| proposal.copies.iter().map(move |group| (proposal, group)))
|
||||
.collect();
|
||||
groups.sort_by_key(|(proposal, group)| {
|
||||
(
|
||||
proposal.address.target,
|
||||
matches!(
|
||||
group,
|
||||
FacetSelector::Occurrence(OccurrenceFacet::MessageKind)
|
||||
),
|
||||
)
|
||||
});
|
||||
for (proposal, group) in groups {
|
||||
let row = state.row(proposal.address).expect("Included row");
|
||||
if row.decisions[group] == SemanticDecision::KeepTarget {
|
||||
continue;
|
||||
}
|
||||
let mut value = record_value(&merged, proposal.address.target)?;
|
||||
let supplied = &proposal.value;
|
||||
for path in constructors::roots(base, proposal.address.target, group)
|
||||
.ok_or("Constructor scope is unavailable.")?
|
||||
{
|
||||
*value
|
||||
.pointer_mut(&path)
|
||||
.ok_or("Overlapping constructors retire an authored scope.")? = supplied
|
||||
.pointer(&path)
|
||||
.cloned()
|
||||
.ok_or("Constructor candidate is incomplete.")?;
|
||||
}
|
||||
install(&mut merged, proposal.address.target, value)?;
|
||||
}
|
||||
let mut ordered_slots: Vec<_> = slots.iter().collect();
|
||||
ordered_slots.sort_by_key(|((_, facet), _)| matches!(facet, FacetSelector::Order(_)));
|
||||
for (slot, intention) in ordered_slots {
|
||||
write_slot(&mut merged, slot, &intention.value)?;
|
||||
}
|
||||
let mut final_orders = BTreeMap::new();
|
||||
for (target, which, mut order) in implicit_orders {
|
||||
if slots.contains_key(&(target, FacetSelector::Order(which))) {
|
||||
continue;
|
||||
}
|
||||
let surviving: BTreeSet<_> = ordered::read(&record_value(&merged, target)?, which)?
|
||||
.into_iter()
|
||||
.collect();
|
||||
order.retain(|id| surviving.contains(id));
|
||||
if order.iter().copied().collect::<BTreeSet<_>>() != surviving {
|
||||
return Err("Combined ordered membership needs an explicit final target order. Retained row drafts are unchanged.".into());
|
||||
}
|
||||
if final_orders
|
||||
.insert((target, which), order.clone())
|
||||
.is_some_and(|previous| previous != order)
|
||||
{
|
||||
return Err("Contradictory anchored orders need an explicit final target order. Retained row drafts are unchanged.".into());
|
||||
}
|
||||
}
|
||||
for ((target, which), order) in final_orders {
|
||||
write_slot(
|
||||
&mut merged,
|
||||
&(target, FacetSelector::Order(which)),
|
||||
&serde_json::json!({"Value":order}),
|
||||
)?;
|
||||
}
|
||||
for (slot, intention) in &slots {
|
||||
if observe(&merged, slot)? != intention.value {
|
||||
return Err(format!(
|
||||
"Combined candidate does not preserve the selected intent for {} · {}.",
|
||||
base.named(slot.0),
|
||||
view::title(base, &slot.1)
|
||||
));
|
||||
}
|
||||
}
|
||||
for target in candidate_targets {
|
||||
request
|
||||
.candidates
|
||||
.push(semantic_record(&merged, target).ok_or("Merged candidate target disappeared.")?);
|
||||
}
|
||||
Ok(request)
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn fixture() -> (Project, RowAddress, Id, Id, KeyPair<Id>) {
|
||||
let mut p = Project::empty();
|
||||
let source = p
|
||||
.add_element(Kind::Activity, Layer::Oa, "Inspect equipment", None)
|
||||
.unwrap();
|
||||
let target = p
|
||||
.add_element(Kind::Function, Layer::Sa, "Monitor equipment", None)
|
||||
.unwrap();
|
||||
let other = p
|
||||
.add_element(Kind::Function, Layer::Sa, "Report condition", None)
|
||||
.unwrap();
|
||||
let trace = super::super::tests::add_trace(&mut p, source, target, "Evidence rationale");
|
||||
let mut ids = Vec::new();
|
||||
for (owner, alias) in [
|
||||
(source, "operational_evidence"),
|
||||
(target, "system_evidence"),
|
||||
] {
|
||||
let id = Id::new_v4();
|
||||
p.artifact_links.insert(
|
||||
id,
|
||||
ArtifactLink {
|
||||
id,
|
||||
alias: alias.into(),
|
||||
name: "Evidence".into(),
|
||||
description: String::new(),
|
||||
owner,
|
||||
relation: ArtifactRelation::Verifies,
|
||||
uri: "requirements/interlock".into(),
|
||||
revision: None,
|
||||
fragment: None,
|
||||
},
|
||||
);
|
||||
ids.push(id);
|
||||
}
|
||||
(
|
||||
p,
|
||||
RowAddress {
|
||||
source,
|
||||
target,
|
||||
trace,
|
||||
},
|
||||
ids[1],
|
||||
other,
|
||||
KeyPair {
|
||||
oa: Some(ids[0]),
|
||||
sa: Some(ids[1]),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn send(state: &mut State, p: &Project, event: RowEvent) {
|
||||
let addressed = state.address_event(event);
|
||||
state.update(p, addressed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_review_presence_and_owner_conflict_names_both_facets_and_owners_and_retains_drafts() {
|
||||
let (p, row, id, other, keys) = fixture();
|
||||
let accepted = p.clone();
|
||||
let mut state = State::new(&p, row.trace).unwrap();
|
||||
for (layer, key) in [(Layer::Oa, keys.oa), (Layer::Sa, keys.sa)] {
|
||||
send(
|
||||
&mut state,
|
||||
&p,
|
||||
RowEvent::Artifact(artifacts::Event::Pick(
|
||||
layer,
|
||||
key.map(|id| view::choice(&p, id)),
|
||||
)),
|
||||
);
|
||||
}
|
||||
send(&mut state, &p, RowEvent::Artifact(artifacts::Event::Stage));
|
||||
let presence = artifacts::selector(&keys, ArtifactFacet::Presence);
|
||||
let owner = artifacts::selector(&keys, ArtifactFacet::Owner);
|
||||
send(
|
||||
&mut state,
|
||||
&p,
|
||||
RowEvent::Decide(owner.clone(), Some(SemanticDecision::Candidate)),
|
||||
);
|
||||
send(
|
||||
&mut state,
|
||||
&p,
|
||||
RowEvent::Artifact(artifacts::Event::Input(
|
||||
owner.clone(),
|
||||
artifacts::Raw::Owner(other.to_string()),
|
||||
)),
|
||||
);
|
||||
assert!(state.can_apply(), "{:?}", state.error);
|
||||
send(
|
||||
&mut state,
|
||||
&p,
|
||||
RowEvent::Decide(presence.clone(), Some(SemanticDecision::KeepTarget)),
|
||||
);
|
||||
assert!(!state.can_apply());
|
||||
assert!(state.command(&p).is_err());
|
||||
let error = state.error.as_deref().unwrap();
|
||||
assert!(
|
||||
error.starts_with("Decisions in this review disagree"),
|
||||
"{error}"
|
||||
);
|
||||
for expected in [
|
||||
"Artifact Presence",
|
||||
"Artifact Owner",
|
||||
"Monitor equipment",
|
||||
"Report condition",
|
||||
"Evidence rationale",
|
||||
"@system_evidence",
|
||||
"@operational_evidence",
|
||||
"Both drafts are retained",
|
||||
] {
|
||||
assert!(error.contains(expected), "missing {expected}: {error}");
|
||||
}
|
||||
for misleading in ["Independent row", "Absent", "{\"Value\"", "exclude a row"] {
|
||||
assert!(!error.contains(misleading), "{error}");
|
||||
}
|
||||
assert!(
|
||||
!error.contains(&row.target.to_string()),
|
||||
"named owner should not be raw UUID: {error}"
|
||||
);
|
||||
assert_eq!(state.decisions[&presence], SemanticDecision::KeepTarget);
|
||||
assert_eq!(
|
||||
state.artifacts.drafts[&owner],
|
||||
artifacts::Raw::Owner(other.to_string())
|
||||
);
|
||||
send(&mut state, &p, RowEvent::Decide(presence, None));
|
||||
let command = state.command(&p).unwrap();
|
||||
let prepared = prepare_command(&p, &command).unwrap().project;
|
||||
assert_eq!(prepared.artifact_links[&id].owner, other);
|
||||
assert_eq!(p, accepted);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distinct_review_conflict_keeps_trace_aliases_even_when_names_are_equal() {
|
||||
let (mut p, first, id, other, keys) = fixture();
|
||||
let trace =
|
||||
super::super::tests::add_trace(&mut p, first.source, first.target, "Evidence rationale");
|
||||
let second = RowAddress { trace, ..first };
|
||||
let slot = artifacts::slot(id, ArtifactFacet::Owner);
|
||||
let presence = artifacts::selector(&keys, ArtifactFacet::Presence);
|
||||
let owner = artifacts::selector(&keys, ArtifactFacet::Owner);
|
||||
let mut slots = BTreeMap::new();
|
||||
let state = State::new(&p, first.trace).unwrap();
|
||||
agree(
|
||||
&state,
|
||||
&mut slots,
|
||||
slot.clone(),
|
||||
json!({"Value": first.target}),
|
||||
first,
|
||||
&presence,
|
||||
)
|
||||
.unwrap();
|
||||
let error = agree(
|
||||
&state,
|
||||
&mut slots,
|
||||
slot,
|
||||
json!({"Value": other}),
|
||||
second,
|
||||
&owner,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
error.starts_with("Independent row proposals disagree"),
|
||||
"{error}"
|
||||
);
|
||||
assert!(error.contains(&p.traces[&first.trace].alias), "{error}");
|
||||
assert!(error.contains(&p.traces[&second.trace].alias), "{error}");
|
||||
assert!(
|
||||
error.contains("Artifact Presence") && error.contains("Artifact Owner"),
|
||||
"{error}"
|
||||
);
|
||||
assert!(error.contains("exclude a row"), "{error}");
|
||||
assert_eq!(slots.len(), 1);
|
||||
assert_eq!(
|
||||
slots.values().next().unwrap().value,
|
||||
json!({"Value": first.target})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reserved_creation_conflict_uses_its_rows_seed_instead_of_a_deleted_reference() {
|
||||
for reverse in [false, true] {
|
||||
let (p, row, _, _, existing) = fixture();
|
||||
let accepted = p.clone();
|
||||
let mut state = State::new(&p, row.trace).unwrap();
|
||||
if reverse {
|
||||
state.update(&p, Event::Direction(TransitionDirection::SaToOa));
|
||||
}
|
||||
let source_layer = if reverse { Layer::Sa } else { Layer::Oa };
|
||||
let source = if reverse {
|
||||
existing.sa.unwrap()
|
||||
} else {
|
||||
existing.oa.unwrap()
|
||||
};
|
||||
send(
|
||||
&mut state,
|
||||
&p,
|
||||
RowEvent::Artifact(artifacts::Event::Pick(
|
||||
source_layer,
|
||||
Some(view::choice(&p, source)),
|
||||
)),
|
||||
);
|
||||
send(
|
||||
&mut state,
|
||||
&p,
|
||||
RowEvent::Artifact(artifacts::Event::Reserve),
|
||||
);
|
||||
send(&mut state, &p, RowEvent::Artifact(artifacts::Event::Stage));
|
||||
let keys = state.artifacts.pair();
|
||||
let destination = if reverse {
|
||||
keys.oa.unwrap()
|
||||
} else {
|
||||
keys.sa.unwrap()
|
||||
};
|
||||
let seed = state.artifacts.seeds[&destination].clone();
|
||||
let presence = artifacts::selector(&keys, ArtifactFacet::Presence);
|
||||
let owner = artifacts::selector(&keys, ArtifactFacet::Owner);
|
||||
send(
|
||||
&mut state,
|
||||
&p,
|
||||
RowEvent::Decide(presence, Some(SemanticDecision::TakeSource)),
|
||||
);
|
||||
send(
|
||||
&mut state,
|
||||
&p,
|
||||
RowEvent::Decide(owner, Some(SemanticDecision::KeepTarget)),
|
||||
);
|
||||
assert!(!state.can_apply());
|
||||
assert!(state.command(&p).is_err());
|
||||
let saved = state.recovery_snapshot().unwrap();
|
||||
let restored = State::from_recovery(&p, saved.clone()).unwrap();
|
||||
assert_eq!(restored.recovery_snapshot().unwrap(), saved);
|
||||
for review in [&state, &restored] {
|
||||
let error = review.error.as_deref().unwrap();
|
||||
for expected in [
|
||||
"Decisions in this review disagree",
|
||||
"Artifact Presence",
|
||||
"Artifact Owner",
|
||||
"artifact absent",
|
||||
"Evidence · reserved @",
|
||||
] {
|
||||
assert!(error.contains(expected), "missing {expected}: {error}");
|
||||
}
|
||||
assert!(error.contains(&seed.alias), "{error}");
|
||||
for misleading in ["Missing identity", "Deleted reference", "{\"Value\""] {
|
||||
assert!(!error.contains(misleading), "{error}");
|
||||
}
|
||||
let actual = review.active_address.unwrap();
|
||||
assert!(review_identity(review, actual, destination).contains(&seed.alias));
|
||||
// A resolver for a different row must not borrow the active row's seed.
|
||||
let unrelated = RowAddress {
|
||||
trace: Id::new_v4(),
|
||||
..actual
|
||||
};
|
||||
assert_eq!(
|
||||
review_identity(review, unrelated, destination),
|
||||
format!("Missing identity {destination}")
|
||||
);
|
||||
}
|
||||
assert_eq!(p, accepted);
|
||||
assert!(!p.contains(destination));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typed_owner_display_does_not_reinterpret_uuid_shaped_artifact_text() {
|
||||
let (p, row, id, _, _) = fixture();
|
||||
let value = json!({"Value": row.target.to_string()});
|
||||
assert_eq!(
|
||||
describe(&p, &artifacts::slot(id, ArtifactFacet::Uri), &value),
|
||||
json!(row.target.to_string()).to_string()
|
||||
);
|
||||
assert!(
|
||||
describe(&p, &artifacts::slot(id, ArtifactFacet::Owner), &value)
|
||||
.contains("owner Monitor equipment")
|
||||
);
|
||||
assert_eq!(
|
||||
describe(
|
||||
&p,
|
||||
&artifacts::slot(id, ArtifactFacet::Owner),
|
||||
&json!("NotApplicable")
|
||||
),
|
||||
"artifact absent"
|
||||
);
|
||||
assert_eq!(
|
||||
describe(
|
||||
&p,
|
||||
&artifacts::slot(id, ArtifactFacet::Revision),
|
||||
&json!({"Value": null})
|
||||
),
|
||||
"null"
|
||||
);
|
||||
assert_eq!(
|
||||
describe(
|
||||
&p,
|
||||
&artifacts::slot(id, ArtifactFacet::Revision),
|
||||
&json!({"Value": ""})
|
||||
),
|
||||
"\"\""
|
||||
);
|
||||
}
|
||||
@@ -165,9 +165,9 @@ fn data_group_candidates_cover_every_definition_and_companion_default_both_direc
|
||||
let paths = constructors::roots(app.project(), target, &group).unwrap();
|
||||
send(
|
||||
&mut app,
|
||||
Event::Variant(paths[0].clone(), json!(definition)),
|
||||
RowEvent::Variant(paths[0].clone(), json!(definition)),
|
||||
);
|
||||
send(&mut app, Event::Shape(paths[1].clone(), json!(default)));
|
||||
send(&mut app, RowEvent::Shape(paths[1].clone(), json!(default)));
|
||||
assert!(
|
||||
state(&app).can_apply(),
|
||||
"{definition:?}: {}",
|
||||
@@ -293,9 +293,9 @@ fn group_take_reference_payload_and_scalar_candidate_match_core_and_one_document
|
||||
assert!(!state(&app).can_apply());
|
||||
let oa_choice = view::choice(app.project(), refs[0]);
|
||||
let sa_choice = view::choice(app.project(), refs[1]);
|
||||
send(&mut app, Event::ReferenceOa(oa_choice));
|
||||
send(&mut app, Event::ReferenceSa(sa_choice));
|
||||
send(&mut app, Event::AddReference);
|
||||
send(&mut app, RowEvent::ReferenceOa(oa_choice));
|
||||
send(&mut app, RowEvent::ReferenceSa(sa_choice));
|
||||
send(&mut app, RowEvent::AddReference);
|
||||
assert!(
|
||||
state(&app).can_apply(),
|
||||
"{family} reverse={reverse}: {}",
|
||||
@@ -309,7 +309,7 @@ fn group_take_reference_payload_and_scalar_candidate_match_core_and_one_document
|
||||
);
|
||||
send(
|
||||
&mut app,
|
||||
Event::Input("/description".into(), source_ref.to_string()),
|
||||
RowEvent::Input("/description".into(), source_ref.to_string()),
|
||||
);
|
||||
assert!(
|
||||
state(&app).can_apply(),
|
||||
@@ -377,14 +377,17 @@ fn data_group_retains_invalid_sibling_drafts_companion_default_and_variant_edito
|
||||
let roots = constructors::roots(app.project(), sa, &group).unwrap();
|
||||
let min_path = format!("{}/Integer/minimum", roots[0]);
|
||||
decide(&mut app, minimum.clone(), SemanticDecision::Candidate);
|
||||
send(&mut app, Event::Input(min_path.clone(), "-".into()));
|
||||
send(&mut app, RowEvent::Input(min_path.clone(), "-".into()));
|
||||
decide(&mut app, minimum.clone(), SemanticDecision::KeepTarget);
|
||||
decide(&mut app, group.clone(), SemanticDecision::Candidate);
|
||||
send(&mut app, Event::Input(min_path.clone(), "-draft".into()));
|
||||
send(&mut app, RowEvent::Input(min_path.clone(), "-draft".into()));
|
||||
let cursor = state(&app).group_drafts[&group].inputs[&min_path]
|
||||
.editor
|
||||
.cursor();
|
||||
send(&mut app, Event::Variant(roots[0].clone(), json!("Boolean")));
|
||||
send(
|
||||
&mut app,
|
||||
RowEvent::Variant(roots[0].clone(), json!("Boolean")),
|
||||
);
|
||||
assert_eq!(
|
||||
state(&app).group_drafts[&group].value.pointer(&roots[1]),
|
||||
Some(&json!({"Integer":5}))
|
||||
@@ -392,7 +395,7 @@ fn data_group_retains_invalid_sibling_drafts_companion_default_and_variant_edito
|
||||
assert!(!state(&app).can_apply()); // overlap and explicitly incompatible default retained
|
||||
send(
|
||||
&mut app,
|
||||
Event::Variant(
|
||||
RowEvent::Variant(
|
||||
roots[0].clone(),
|
||||
json!({"Integer":{"minimum":null,"maximum":null}}),
|
||||
),
|
||||
@@ -411,16 +414,19 @@ fn data_group_retains_invalid_sibling_drafts_companion_default_and_variant_edito
|
||||
state(&app).draft.as_ref().unwrap().inputs[&min_path].raw,
|
||||
"-"
|
||||
);
|
||||
send(&mut app, Event::Shape(min_path.clone(), Value::Null));
|
||||
send(&mut app, Event::Variant(roots[0].clone(), json!("Boolean")));
|
||||
send(&mut app, RowEvent::Shape(min_path.clone(), Value::Null));
|
||||
send(
|
||||
&mut app,
|
||||
Event::Shape(roots[1].clone(), json!({"Boolean":true})),
|
||||
RowEvent::Variant(roots[0].clone(), json!("Boolean")),
|
||||
);
|
||||
send(&mut app, Event::Decide(minimum, None));
|
||||
send(
|
||||
&mut app,
|
||||
RowEvent::Shape(roots[1].clone(), json!({"Boolean":true})),
|
||||
);
|
||||
send(&mut app, RowEvent::Decide(minimum, None));
|
||||
assert!(state(&app).can_apply(), "{}", failure(state(&app)));
|
||||
for protected in ["/id", "/alias", "/body", "/parent", "/name"] {
|
||||
send(&mut app, Event::Shape(protected.into(), Value::Null));
|
||||
send(&mut app, RowEvent::Shape(protected.into(), Value::Null));
|
||||
assert!(
|
||||
state(&app)
|
||||
.error
|
||||
@@ -433,7 +439,7 @@ fn data_group_retains_invalid_sibling_drafts_companion_default_and_variant_edito
|
||||
state(&app).draft.as_ref().unwrap().inputs[&min_path].raw,
|
||||
"-"
|
||||
);
|
||||
send(&mut app, Event::Preview);
|
||||
send(&mut app, RowEvent::Preview);
|
||||
assert!(state(&app).can_apply());
|
||||
}
|
||||
|
||||
@@ -595,7 +601,7 @@ fn occurrence_and_event_candidate_variants_cover_closed_families_without_touchin
|
||||
SharedBody::Event { body } => json!(body),
|
||||
_ => unreachable!(),
|
||||
};
|
||||
send(&mut app, Event::Shape(root, payload));
|
||||
send(&mut app, RowEvent::Shape(root, payload));
|
||||
assert!(
|
||||
state(&app).can_apply(),
|
||||
"{body:?}: {}",
|
||||
@@ -636,18 +642,18 @@ fn port_group_companions_typed_owner_context_and_direction_restrictions_are_expl
|
||||
let roots = constructors::roots(app.project(), sa, &group).unwrap();
|
||||
send(
|
||||
&mut app,
|
||||
Event::Shape(format!("{}/interface", roots[1]), json!(interface)),
|
||||
RowEvent::Shape(format!("{}/interface", roots[1]), json!(interface)),
|
||||
);
|
||||
send(
|
||||
&mut app,
|
||||
Event::Variant(
|
||||
RowEvent::Variant(
|
||||
roots[0].clone(),
|
||||
json!({"FunctionInput":{"incoming_items":[]}}),
|
||||
),
|
||||
);
|
||||
send(
|
||||
&mut app,
|
||||
Event::Append(
|
||||
RowEvent::Append(
|
||||
format!("{}/FunctionInput/incoming_items", roots[0]),
|
||||
json!(item),
|
||||
),
|
||||
@@ -655,7 +661,7 @@ fn port_group_companions_typed_owner_context_and_direction_restrictions_are_expl
|
||||
assert!(state(&app).can_apply(), "{}", failure(state(&app)));
|
||||
send(
|
||||
&mut app,
|
||||
Event::Variant(
|
||||
RowEvent::Variant(
|
||||
roots[0].clone(),
|
||||
json!({"FunctionOutput":{"outgoing_items":[]}}),
|
||||
),
|
||||
@@ -678,11 +684,14 @@ fn port_group_companions_typed_owner_context_and_direction_restrictions_are_expl
|
||||
FacetSelector::Header(HeaderFacet::Parent),
|
||||
SemanticDecision::Candidate,
|
||||
);
|
||||
send(&mut app, Event::Shape("/parent".into(), json!(component)));
|
||||
send(&mut app, Event::Select(group.clone()));
|
||||
send(
|
||||
&mut app,
|
||||
Event::Variant(
|
||||
RowEvent::Shape("/parent".into(), json!(component)),
|
||||
);
|
||||
send(&mut app, RowEvent::Select(group.clone()));
|
||||
send(
|
||||
&mut app,
|
||||
RowEvent::Variant(
|
||||
roots[0].clone(),
|
||||
json!({"Component":{"kind":"Flow","orientation":"InOut"}}),
|
||||
),
|
||||
@@ -693,7 +702,7 @@ fn port_group_companions_typed_owner_context_and_direction_restrictions_are_expl
|
||||
state(&app).group_drafts[&group].context_value()["parent"],
|
||||
json!(component)
|
||||
);
|
||||
send(&mut app, Event::Shape(roots[1].clone(), Value::Null));
|
||||
send(&mut app, RowEvent::Shape(roots[1].clone(), Value::Null));
|
||||
assert!(state(&app).can_apply());
|
||||
apply(&mut app);
|
||||
assert!(matches!(
|
||||
@@ -736,29 +745,29 @@ fn outer_and_nested_constructor_candidates_require_explicit_resolution_and_retai
|
||||
decide(&mut app, nested.clone(), SemanticDecision::Candidate);
|
||||
send(
|
||||
&mut app,
|
||||
Event::Input(expression.clone(), "nested draft".into()),
|
||||
RowEvent::Input(expression.clone(), "nested draft".into()),
|
||||
);
|
||||
decide(&mut app, outer.clone(), SemanticDecision::Candidate);
|
||||
send(
|
||||
&mut app,
|
||||
Event::Input(expression.clone(), "outer draft".into()),
|
||||
RowEvent::Input(expression.clone(), "outer draft".into()),
|
||||
);
|
||||
assert!(!state(&app).can_apply());
|
||||
assert!(failure(state(&app)).contains("different final values"));
|
||||
send(
|
||||
&mut app,
|
||||
Event::Variant(
|
||||
RowEvent::Variant(
|
||||
path.clone(),
|
||||
json!({"Action":{"action":null,"lifeline":null}}),
|
||||
),
|
||||
);
|
||||
assert!(!state(&app).can_apply());
|
||||
assert_eq!(state(&app).decisions.len(), 2);
|
||||
send(&mut app, Event::Decide(nested.clone(), None));
|
||||
send(&mut app, RowEvent::Decide(nested.clone(), None));
|
||||
assert!(state(&app).can_apply(), "{}", failure(state(&app)));
|
||||
send(
|
||||
&mut app,
|
||||
Event::Variant(
|
||||
RowEvent::Variant(
|
||||
path.clone(),
|
||||
json!({"Exchange":{"exchange":null,"sender":null,"receiver":null,"message_kind":"Asynchronous","reply_to":null,"values":[]}}),
|
||||
),
|
||||
@@ -777,7 +786,7 @@ fn outer_and_nested_constructor_candidates_require_explicit_resolution_and_retai
|
||||
);
|
||||
send(
|
||||
&mut app,
|
||||
Event::Variant(path, json!({"Action":{"action":null,"lifeline":null}})),
|
||||
RowEvent::Variant(path, json!({"Action":{"action":null,"lifeline":null}})),
|
||||
);
|
||||
assert!(state(&app).can_apply());
|
||||
let scope = reviewed(state(&app));
|
||||
@@ -841,7 +850,10 @@ fn historical_event_keep_retires_only_obsolete_branch_and_unchanged_kind_acknowl
|
||||
FacetSelector::Header(HeaderFacet::Name),
|
||||
SemanticDecision::Candidate,
|
||||
);
|
||||
send(&mut app, Event::Input("/name".into(), "Kind only".into()));
|
||||
send(
|
||||
&mut app,
|
||||
RowEvent::Input("/name".into(), "Kind only".into()),
|
||||
);
|
||||
assert!(state(&app).can_apply(), "{}", failure(state(&app)));
|
||||
assert_eq!(
|
||||
reviewed(state(&app)),
|
||||
@@ -950,7 +962,7 @@ fn existing_fragment_and_branch_group_candidates_edit_complete_owned_order_witho
|
||||
decide(&mut app, group, SemanticDecision::Candidate);
|
||||
send(
|
||||
&mut app,
|
||||
Event::Array(format!("{root}{suffix}"), Some(1), -1),
|
||||
RowEvent::Array(format!("{root}{suffix}"), Some(1), -1),
|
||||
);
|
||||
assert!(state(&app).can_apply(), "{}", failure(state(&app)));
|
||||
let result = state(&app).proposed.as_ref().unwrap();
|
||||
@@ -1028,7 +1040,7 @@ fn scenario_use_group_takes_typed_binding_references_and_preserves_source_text_b
|
||||
}
|
||||
s.update(
|
||||
&p,
|
||||
Event::Decide(
|
||||
RowEvent::Decide(
|
||||
FacetSelector::Occurrence(OccurrenceFacet::Constructor),
|
||||
Some(SemanticDecision::TakeSource),
|
||||
),
|
||||
@@ -1040,18 +1052,18 @@ fn scenario_use_group_takes_typed_binding_references_and_preserves_source_text_b
|
||||
.clone();
|
||||
s.update(
|
||||
&p,
|
||||
Event::Decide(
|
||||
RowEvent::Decide(
|
||||
FacetSelector::Header(HeaderFacet::Description),
|
||||
Some(SemanticDecision::Candidate),
|
||||
),
|
||||
);
|
||||
s.update(
|
||||
&p,
|
||||
Event::Select(FacetSelector::Header(HeaderFacet::Description)),
|
||||
RowEvent::Select(FacetSelector::Header(HeaderFacet::Description)),
|
||||
);
|
||||
s.update(
|
||||
&p,
|
||||
Event::Input("/description".into(), refs[side][1].to_string()),
|
||||
RowEvent::Input("/description".into(), refs[side][1].to_string()),
|
||||
);
|
||||
assert!(s.can_apply(), "{}", failure(&s));
|
||||
assert_eq!(
|
||||
@@ -1101,7 +1113,7 @@ fn reverse_existing_typed_port_reviews_keep_or_candidate_legacy_without_permitti
|
||||
if decision == SemanticDecision::Candidate {
|
||||
send(
|
||||
&mut app,
|
||||
Event::Shape("/body/Oa/Shared/Port/legacy/direction".into(), json!("In")),
|
||||
RowEvent::Shape("/body/Oa/Shared/Port/legacy/direction".into(), json!("In")),
|
||||
);
|
||||
if let SharedBody::Port {
|
||||
legacy: Some(association),
|
||||
@@ -1174,7 +1186,7 @@ fn historical_same_kind_take_preserves_target_only_literals_when_composed_with_c
|
||||
);
|
||||
let unit =
|
||||
scalar::binding(app.project(), target, &FacetSelector::Data(DataFacet::Unit)).unwrap();
|
||||
send(&mut app, Event::Input(unit, "final unit".into()));
|
||||
send(&mut app, RowEvent::Input(unit, "final unit".into()));
|
||||
assert!(state(&app).can_apply(), "{}", failure(state(&app)));
|
||||
let SharedBody::DataType {
|
||||
definition: DataTypeDefinition::Enumeration { literals },
|
||||
@@ -1250,7 +1262,7 @@ fn closed_data_and_event_take_composition_matches_core_across_every_kind_pair_an
|
||||
let mut s = State::new(&p, source).unwrap();
|
||||
s.update(
|
||||
&p,
|
||||
Event::Decide(group.clone(), Some(SemanticDecision::TakeSource)),
|
||||
RowEvent::Decide(group.clone(), Some(SemanticDecision::TakeSource)),
|
||||
);
|
||||
assert!(
|
||||
s.can_apply(),
|
||||
@@ -1264,18 +1276,18 @@ fn closed_data_and_event_take_composition_matches_core_across_every_kind_pair_an
|
||||
let expected_review = s.plan.as_ref().unwrap().reviews[0].clone();
|
||||
s.update(
|
||||
&p,
|
||||
Event::Decide(
|
||||
RowEvent::Decide(
|
||||
FacetSelector::Header(HeaderFacet::Description),
|
||||
Some(SemanticDecision::Candidate),
|
||||
),
|
||||
);
|
||||
s.update(
|
||||
&p,
|
||||
Event::Select(FacetSelector::Header(HeaderFacet::Description)),
|
||||
RowEvent::Select(FacetSelector::Header(HeaderFacet::Description)),
|
||||
);
|
||||
s.update(
|
||||
&p,
|
||||
Event::Input("/description".into(), "composed candidate".into()),
|
||||
RowEvent::Input("/description".into(), "composed candidate".into()),
|
||||
);
|
||||
assert!(
|
||||
s.can_apply(),
|
||||
@@ -1417,7 +1429,7 @@ fn exchange_constructor_takes_qualified_annotation_addresses_without_remapping_e
|
||||
}
|
||||
s.update(
|
||||
&p,
|
||||
Event::Decide(
|
||||
RowEvent::Decide(
|
||||
FacetSelector::Occurrence(OccurrenceFacet::Constructor),
|
||||
Some(SemanticDecision::TakeSource),
|
||||
),
|
||||
@@ -1425,18 +1437,18 @@ fn exchange_constructor_takes_qualified_annotation_addresses_without_remapping_e
|
||||
assert!(s.can_apply(), "reverse={reverse}: {}", failure(&s));
|
||||
s.update(
|
||||
&p,
|
||||
Event::Decide(
|
||||
RowEvent::Decide(
|
||||
FacetSelector::Header(HeaderFacet::Description),
|
||||
Some(SemanticDecision::Candidate),
|
||||
),
|
||||
);
|
||||
s.update(
|
||||
&p,
|
||||
Event::Select(FacetSelector::Header(HeaderFacet::Description)),
|
||||
RowEvent::Select(FacetSelector::Header(HeaderFacet::Description)),
|
||||
);
|
||||
s.update(
|
||||
&p,
|
||||
Event::Input("/description".into(), "annotation reviewed".into()),
|
||||
RowEvent::Input("/description".into(), "annotation reviewed".into()),
|
||||
);
|
||||
assert!(s.can_apply(), "reverse={reverse}: {}", failure(&s));
|
||||
let Some(SharedBody::Occurrence {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
use super::*;
|
||||
|
||||
pub(super) fn resettable(facet: &FacetSelector) -> bool {
|
||||
!matches!(facet, FacetSelector::Artifact { .. })
|
||||
pub(super) fn resettable(_facet: &FacetSelector) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
impl State {
|
||||
impl ReviewRow {
|
||||
pub(super) fn effective_trace(&self, project: &Project) -> Option<Trace> {
|
||||
let mut trace = project.traces.get(&self.trace?)?.clone();
|
||||
if let Some(baseline) = trace.baseline.as_mut().and_then(|b| b.semantic.as_mut()) {
|
||||
@@ -31,7 +31,7 @@ impl State {
|
||||
.and_then(|b| b.semantic.as_ref())
|
||||
.is_some_and(|b| b.facets.iter().any(|f| f.selector() == facet));
|
||||
if !exists || !resettable(&facet) {
|
||||
return Err("Choose an exact existing non-artifact observation to reset.".into());
|
||||
return Err("Choose an exact existing observation to reset.".into());
|
||||
}
|
||||
if reset {
|
||||
self.resets.insert(facet);
|
||||
@@ -41,3 +41,55 @@ impl State {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// Only persisted typed addresses establish correspondence. Order vectors and
|
||||
// arbitrary scalar values cannot be zipped or searched for UUID-shaped prose.
|
||||
pub(super) fn reviewed_pairs(
|
||||
row: &ReviewRow,
|
||||
project: &Project,
|
||||
) -> Result<BTreeMap<Id, Id>, String> {
|
||||
let trace = row
|
||||
.effective_trace(project)
|
||||
.ok_or("Choose an existing trace history.")?;
|
||||
let mut result = BTreeMap::new();
|
||||
for fact in trace
|
||||
.baseline
|
||||
.iter()
|
||||
.flat_map(|b| b.semantic.iter())
|
||||
.flat_map(|b| &b.facets)
|
||||
{
|
||||
let mut pairs = vec![];
|
||||
match fact.selector() {
|
||||
FacetSelector::Member { keys, .. }
|
||||
| FacetSelector::Binding(keys)
|
||||
| FacetSelector::Child(keys)
|
||||
| FacetSelector::Incident { keys, .. }
|
||||
| FacetSelector::Artifact { keys, .. } => {
|
||||
if let (Some(oa), Some(sa)) = (keys.oa, keys.sa) {
|
||||
pairs.push((oa, sa));
|
||||
}
|
||||
}
|
||||
FacetSelector::Annotation(KeyPair {
|
||||
oa: Some(oa),
|
||||
sa: Some(sa),
|
||||
}) => {
|
||||
if oa.path.len() != sa.path.len() {
|
||||
return Err("Historical annotation addresses have different depths; restore their correspondence explicitly.".into());
|
||||
}
|
||||
pairs.extend(
|
||||
oa.path
|
||||
.into_iter()
|
||||
.chain([oa.field])
|
||||
.zip(sa.path.into_iter().chain([sa.field])),
|
||||
);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
for (oa, sa) in pairs {
|
||||
if result.insert(oa, sa).is_some_and(|old| old != sa) {
|
||||
return Err("Historical observations pair one Operational identity with different System identities. Resolve exact history before restoring pairs.".into());
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ impl std::fmt::Display for Family {
|
||||
Self::Member(ReferenceCollection::FunctionInputItems) => "Function input items",
|
||||
Self::Member(ReferenceCollection::FunctionOutputItems) => "Function output items",
|
||||
Self::Member(ReferenceCollection::SignalItems) => "Signal items",
|
||||
Self::Member(ReferenceCollection::CategoryExchanges) => "Category exchanges",
|
||||
Self::Member(ReferenceCollection::ExchangeItems) => "Exchange items",
|
||||
Self::Member(ReferenceCollection::TransitionTriggers) => "Transition triggers",
|
||||
Self::Member(ReferenceCollection::TransitionEffects) => "Transition effects",
|
||||
@@ -26,13 +27,14 @@ impl std::fmt::Display for Family {
|
||||
})
|
||||
}
|
||||
}
|
||||
pub(super) const FAMILIES: [Family; 12] = [
|
||||
pub(super) const FAMILIES: [Family; 13] = [
|
||||
Family::Member(ReferenceCollection::Steps),
|
||||
Family::Member(ReferenceCollection::FragmentBranches),
|
||||
Family::Member(ReferenceCollection::FunctionInputItems),
|
||||
Family::Member(ReferenceCollection::FunctionOutputItems),
|
||||
Family::Member(ReferenceCollection::SignalItems),
|
||||
Family::Member(ReferenceCollection::ExchangeItems),
|
||||
Family::Member(ReferenceCollection::CategoryExchanges),
|
||||
Family::Member(ReferenceCollection::TransitionTriggers),
|
||||
Family::Member(ReferenceCollection::TransitionEffects),
|
||||
Family::Member(ReferenceCollection::Constrained),
|
||||
@@ -81,16 +83,22 @@ pub(super) fn path(value: &Value, family: Family) -> Option<String> {
|
||||
Family::Member(ReferenceCollection::FunctionOutputItems) => {
|
||||
&["Port/contract/FunctionOutput/outgoing_items"]
|
||||
}
|
||||
Family::Member(ReferenceCollection::CategoryExchanges) => &["ExchangeCategory/exchanges"],
|
||||
Family::Member(ReferenceCollection::SignalItems) => &["Event/body/Signal/items"],
|
||||
Family::Member(ReferenceCollection::Constrained) => &["Constraint/constrained"],
|
||||
Family::Literal => &["DataType/definition/Enumeration/literals"],
|
||||
Family::Annotation => &["Occurrence/body/Exchange/values"],
|
||||
Family::Binding => &["Occurrence/body/ScenarioUse/bindings"],
|
||||
Family::Member(ReferenceCollection::ExchangeItems) => {
|
||||
return ["Interaction", "FunctionalExchange", "BoundaryExchange"]
|
||||
.iter()
|
||||
.map(|kind| format!("/body/{kind}/items"))
|
||||
.find(|p| value.pointer(p).is_some_and(Value::is_array));
|
||||
return [
|
||||
"Interaction",
|
||||
"FunctionalExchange",
|
||||
"BoundaryExchange",
|
||||
"CommunicationMean",
|
||||
]
|
||||
.iter()
|
||||
.map(|kind| format!("/body/{kind}/items"))
|
||||
.find(|p| value.pointer(p).is_some_and(Value::is_array));
|
||||
}
|
||||
Family::Member(ReferenceCollection::TransitionTriggers) => {
|
||||
return value
|
||||
@@ -164,7 +172,7 @@ pub(super) fn project_presence(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl State {
|
||||
impl ReviewRow {
|
||||
pub(super) fn keyed_value(
|
||||
&self,
|
||||
project: &Project,
|
||||
|
||||
@@ -37,7 +37,7 @@ pub(super) fn target_selector(facet: &FacetSelector, layer: Layer) -> FacetSelec
|
||||
}
|
||||
}
|
||||
|
||||
impl State {
|
||||
impl ReviewRow {
|
||||
pub(super) fn annotation_address(&self, layer: Layer) -> &AddressDraft {
|
||||
if layer == Layer::Oa {
|
||||
&self.annotation_oa
|
||||
|
||||
@@ -196,7 +196,7 @@ fn ready(f: &Fixture, reverse: bool) -> State {
|
||||
let mut s = State::new(&f.p, f.owners[usize::from(reverse)]).unwrap();
|
||||
s.stage_row_references(&f.facet).unwrap();
|
||||
s.references.insert(f.locals[0], f.locals[1]);
|
||||
s.update(&f.p, Event::Select(f.facet.clone()));
|
||||
s.update(&f.p, RowEvent::Select(f.facet.clone()));
|
||||
s
|
||||
}
|
||||
|
||||
@@ -216,27 +216,27 @@ fn independent_rows_keep_take_candidate_match_core_and_preserve_siblings_both_di
|
||||
&capture_facet(&f.p, f.owners[0], f.owners[1], &f.sibling).unwrap(),
|
||||
layer,
|
||||
);
|
||||
s.update(&f.p, Event::Decide(f.facet.clone(), Some(decision)));
|
||||
s.update(&f.p, RowEvent::Decide(f.facet.clone(), Some(decision)));
|
||||
if decision == SemanticDecision::Candidate {
|
||||
s.update(&f.p, Event::Presence(f.facet.clone(), !binding));
|
||||
s.update(&f.p, RowEvent::Presence(f.facet.clone(), !binding));
|
||||
if !binding {
|
||||
s.update(
|
||||
&f.p,
|
||||
Event::RowExpression(f.facet.clone(), f.locals[0].to_string()),
|
||||
RowEvent::RowExpression(f.facet.clone(), f.locals[0].to_string()),
|
||||
);
|
||||
}
|
||||
}
|
||||
// Force the GUI to compose the row alongside another candidate,
|
||||
// so Take/Keep also exercise adapter scope and mapped references.
|
||||
let header = FacetSelector::Header(HeaderFacet::Description);
|
||||
s.update(&f.p, Event::Select(header.clone()));
|
||||
s.update(&f.p, RowEvent::Select(header.clone()));
|
||||
s.update(
|
||||
&f.p,
|
||||
Event::Decide(header, Some(SemanticDecision::Candidate)),
|
||||
RowEvent::Decide(header, Some(SemanticDecision::Candidate)),
|
||||
);
|
||||
s.update(
|
||||
&f.p,
|
||||
Event::Input("/description".into(), "Reviewed one row".into()),
|
||||
RowEvent::Input("/description".into(), "Reviewed one row".into()),
|
||||
);
|
||||
assert!(
|
||||
s.can_apply(),
|
||||
@@ -283,29 +283,29 @@ fn keyed_row_drafts_retain_expression_local_and_presence_across_decisions_and_st
|
||||
s.staged.insert(f.facet.clone());
|
||||
s.update(
|
||||
&f.p,
|
||||
Event::Decide(f.facet.clone(), Some(SemanticDecision::Candidate)),
|
||||
RowEvent::Decide(f.facet.clone(), Some(SemanticDecision::Candidate)),
|
||||
);
|
||||
if binding {
|
||||
s.update(
|
||||
&f.p,
|
||||
Event::RowLocal(f.facet.clone(), view::choice(&f.p, f.locals[1])),
|
||||
RowEvent::RowLocal(f.facet.clone(), view::choice(&f.p, f.locals[1])),
|
||||
);
|
||||
} else {
|
||||
s.update(
|
||||
&f.p,
|
||||
Event::RowExpression(f.facet.clone(), "unfinished (\n literal UUID text".into()),
|
||||
RowEvent::RowExpression(f.facet.clone(), "unfinished (\n literal UUID text".into()),
|
||||
);
|
||||
}
|
||||
s.update(&f.p, Event::Presence(f.facet.clone(), false));
|
||||
s.update(&f.p, RowEvent::Presence(f.facet.clone(), false));
|
||||
s.update(
|
||||
&f.p,
|
||||
Event::Decide(f.facet.clone(), Some(SemanticDecision::KeepTarget)),
|
||||
RowEvent::Decide(f.facet.clone(), Some(SemanticDecision::KeepTarget)),
|
||||
);
|
||||
s.update(&f.p, Event::DropStaged(f.facet.clone()));
|
||||
s.update(&f.p, Event::Select(f.facet.clone()));
|
||||
s.update(&f.p, RowEvent::DropStaged(f.facet.clone()));
|
||||
s.update(&f.p, RowEvent::Select(f.facet.clone()));
|
||||
s.update(
|
||||
&f.p,
|
||||
Event::Decide(f.facet.clone(), Some(SemanticDecision::Candidate)),
|
||||
RowEvent::Decide(f.facet.clone(), Some(SemanticDecision::Candidate)),
|
||||
);
|
||||
assert!(!s.presence[&f.facet]);
|
||||
if binding {
|
||||
@@ -323,7 +323,7 @@ fn keyed_row_drafts_retain_expression_local_and_presence_across_decisions_and_st
|
||||
// A forged event for an unselected sibling cannot alter its row draft.
|
||||
s.update(
|
||||
&f.p,
|
||||
Event::RowExpression(f.sibling.clone(), "wrong row".into()),
|
||||
RowEvent::RowExpression(f.sibling.clone(), "wrong row".into()),
|
||||
);
|
||||
assert!(s.error.as_ref().unwrap().contains("Select this row"));
|
||||
}
|
||||
@@ -333,21 +333,24 @@ fn keyed_row_drafts_retain_expression_local_and_presence_across_decisions_and_st
|
||||
fn annotation_address_browser_descends_qualified_fields_without_changing_rows() {
|
||||
let f = fixture(false);
|
||||
let mut s = ready(&f, false);
|
||||
s.update(&f.p, Event::MemberFamily(keyed::Family::Annotation));
|
||||
s.update(&f.p, RowEvent::MemberFamily(keyed::Family::Annotation));
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let roots = s.annotation_choices(&f.p, layer).unwrap();
|
||||
assert_eq!(roots.len(), 2);
|
||||
let key = roots[0].key.clone();
|
||||
s.update(&f.p, Event::AnnotationBrowse(layer, Some(key.clone()), 0));
|
||||
s.update(
|
||||
&f.p,
|
||||
RowEvent::AnnotationBrowse(layer, Some(key.clone()), 0),
|
||||
);
|
||||
let nested = s.annotation_choices(&f.p, layer).unwrap();
|
||||
assert_eq!(nested.len(), 1);
|
||||
assert_eq!(nested[0].key.path, vec![key.field]);
|
||||
s.update(
|
||||
&f.p,
|
||||
Event::AnnotationAddress(layer, Some(nested[0].key.clone())),
|
||||
RowEvent::AnnotationAddress(layer, Some(nested[0].key.clone())),
|
||||
);
|
||||
}
|
||||
s.update(&f.p, Event::StageMember);
|
||||
s.update(&f.p, RowEvent::StageMember);
|
||||
assert!(s.error.is_none(), "{}", errors(&s));
|
||||
assert_eq!(s.staged.len(), 1);
|
||||
assert!(s.decisions.is_empty());
|
||||
@@ -358,7 +361,7 @@ fn annotation_address_browser_descends_qualified_fields_without_changing_rows()
|
||||
fn binding_choices_separate_reused_keys_from_local_lifelines() {
|
||||
let f = fixture(true);
|
||||
let mut s = ready(&f, false);
|
||||
s.update(&f.p, Event::MemberFamily(keyed::Family::Binding));
|
||||
s.update(&f.p, RowEvent::MemberFamily(keyed::Family::Binding));
|
||||
for (index, layer) in [Layer::Oa, Layer::Sa].into_iter().enumerate() {
|
||||
assert_eq!(s.member_choices(&f.p, layer).len(), 2);
|
||||
assert!(
|
||||
@@ -379,14 +382,14 @@ fn binding_local_picker_follows_a_candidate_parent_before_the_review_is_valid()
|
||||
let local = add(&mut f.p, Kind::Lifeline, Layer::Sa, Some(destination));
|
||||
let mut s = ready(&f, false);
|
||||
let parent = FacetSelector::Header(HeaderFacet::Parent);
|
||||
s.update(&f.p, Event::Select(parent.clone()));
|
||||
s.update(&f.p, RowEvent::Select(parent.clone()));
|
||||
s.update(
|
||||
&f.p,
|
||||
Event::Decide(parent, Some(SemanticDecision::Candidate)),
|
||||
RowEvent::Decide(parent, Some(SemanticDecision::Candidate)),
|
||||
);
|
||||
s.update(
|
||||
&f.p,
|
||||
Event::Shape("/parent".into(), serde_json::json!(destination)),
|
||||
RowEvent::Shape("/parent".into(), serde_json::json!(destination)),
|
||||
);
|
||||
assert_eq!(
|
||||
s.binding_choices(&f.p, Layer::Sa, false)
|
||||
@@ -416,14 +419,14 @@ fn row_review_apply_preserves_exact_source_and_semantic_history_through_undo_red
|
||||
app.document.apply_bundle(bundle).unwrap();
|
||||
let before = app.document.snapshot();
|
||||
start(&mut app, f.owners[0]);
|
||||
send(&mut app, Event::EditPair(f.facet.clone()));
|
||||
send(&mut app, Event::StageMember);
|
||||
send(&mut app, Event::Select(f.facet.clone()));
|
||||
send(&mut app, RowEvent::EditPair(f.facet.clone()));
|
||||
send(&mut app, RowEvent::StageMember);
|
||||
send(&mut app, RowEvent::Select(f.facet.clone()));
|
||||
send(
|
||||
&mut app,
|
||||
Event::Decide(f.facet.clone(), Some(SemanticDecision::Candidate)),
|
||||
RowEvent::Decide(f.facet.clone(), Some(SemanticDecision::Candidate)),
|
||||
);
|
||||
send(&mut app, Event::Presence(f.facet.clone(), false));
|
||||
send(&mut app, RowEvent::Presence(f.facet.clone(), false));
|
||||
assert!(state(&app).can_apply(), "{}", errors(state(&app)));
|
||||
let expected = state(&app).proposed.as_ref().unwrap().clone();
|
||||
apply(&mut app);
|
||||
@@ -503,13 +506,13 @@ fn keyed_rows_distinguish_unpaired_source_from_explicit_absence_and_require_exac
|
||||
let mut s = ready(&f, false);
|
||||
s.update(
|
||||
&f.p,
|
||||
Event::Decide(f.facet.clone(), Some(SemanticDecision::KeepTarget)),
|
||||
RowEvent::Decide(f.facet.clone(), Some(SemanticDecision::KeepTarget)),
|
||||
);
|
||||
assert!(
|
||||
!s.can_apply(),
|
||||
"An old one-sided address cannot silently acquire a paired history"
|
||||
);
|
||||
s.update(&f.p, Event::Reset(source_only.clone(), true));
|
||||
s.update(&f.p, RowEvent::Reset(source_only.clone(), true));
|
||||
assert!(s.can_apply(), "{}", errors(&s));
|
||||
let next = s.proposed.as_ref().unwrap();
|
||||
let facts = &next.traces[&f.trace]
|
||||
@@ -541,17 +544,17 @@ fn keyed_rows_distinguish_unpaired_source_from_explicit_absence_and_require_exac
|
||||
let mut s = ready(&f, false);
|
||||
s.update(
|
||||
&f.p,
|
||||
Event::Decide(facet, Some(SemanticDecision::TakeSource)),
|
||||
RowEvent::Decide(facet, Some(SemanticDecision::TakeSource)),
|
||||
);
|
||||
let header = FacetSelector::Header(HeaderFacet::Description);
|
||||
s.update(
|
||||
&f.p,
|
||||
Event::Decide(header.clone(), Some(SemanticDecision::Candidate)),
|
||||
RowEvent::Decide(header.clone(), Some(SemanticDecision::Candidate)),
|
||||
);
|
||||
s.update(&f.p, Event::Select(header));
|
||||
s.update(&f.p, RowEvent::Select(header));
|
||||
s.update(
|
||||
&f.p,
|
||||
Event::Input("/description".into(), "Compose explicit absence".into()),
|
||||
RowEvent::Input("/description".into(), "Compose explicit absence".into()),
|
||||
);
|
||||
assert!(s.can_apply(), "{}", errors(&s));
|
||||
let fact = capture_facet(
|
||||
@@ -576,18 +579,18 @@ fn binding_take_requires_local_correspondence_and_candidate_rejects_wrong_scenar
|
||||
s.references.remove(&f.locals[0]);
|
||||
s.update(
|
||||
&f.p,
|
||||
Event::Decide(f.facet.clone(), Some(SemanticDecision::TakeSource)),
|
||||
RowEvent::Decide(f.facet.clone(), Some(SemanticDecision::TakeSource)),
|
||||
);
|
||||
assert!(!s.can_apply());
|
||||
s.references.insert(f.locals[0], f.locals[1]);
|
||||
s.update(&f.p, Event::Preview);
|
||||
s.update(&f.p, RowEvent::Preview);
|
||||
assert!(s.can_apply(), "{}", errors(&s));
|
||||
s.update(
|
||||
&f.p,
|
||||
Event::Decide(f.facet.clone(), Some(SemanticDecision::Candidate)),
|
||||
RowEvent::Decide(f.facet.clone(), Some(SemanticDecision::Candidate)),
|
||||
);
|
||||
let wrong = s.binding_choices(&f.p, Layer::Sa, true)[0].clone();
|
||||
s.update(&f.p, Event::RowLocal(f.facet.clone(), wrong));
|
||||
s.update(&f.p, RowEvent::RowLocal(f.facet.clone(), wrong));
|
||||
assert!(!s.can_apply());
|
||||
assert!(
|
||||
s.row_drafts[&f.facet].local.is_some(),
|
||||
@@ -595,7 +598,7 @@ fn binding_take_requires_local_correspondence_and_candidate_rejects_wrong_scenar
|
||||
);
|
||||
s.update(
|
||||
&f.p,
|
||||
Event::RowLocal(f.facet.clone(), view::choice(&f.p, f.locals[1])),
|
||||
RowEvent::RowLocal(f.facet.clone(), view::choice(&f.p, f.locals[1])),
|
||||
);
|
||||
assert!(s.can_apply(), "{}", errors(&s));
|
||||
}
|
||||
@@ -635,16 +638,19 @@ fn keyed_recovery_retains_raw_rows_and_staged_pairs_both_directions_without_appl
|
||||
s.staged.insert(f.facet.clone());
|
||||
s.update(
|
||||
&f.p,
|
||||
Event::Decide(f.facet.clone(), Some(SemanticDecision::Candidate)),
|
||||
RowEvent::Decide(f.facet.clone(), Some(SemanticDecision::Candidate)),
|
||||
);
|
||||
if !binding {
|
||||
s.update(
|
||||
&f.p,
|
||||
Event::RowExpression(f.facet.clone(), "unfinished (\n exact raw draft".into()),
|
||||
RowEvent::RowExpression(
|
||||
f.facet.clone(),
|
||||
"unfinished (\n exact raw draft".into(),
|
||||
),
|
||||
);
|
||||
}
|
||||
s.update(&f.p, Event::Presence(f.facet.clone(), false));
|
||||
s.update(&f.p, Event::EditPair(f.facet.clone()));
|
||||
s.update(&f.p, RowEvent::Presence(f.facet.clone(), false));
|
||||
s.update(&f.p, RowEvent::EditPair(f.facet.clone()));
|
||||
let saved = s.recovery_snapshot().unwrap();
|
||||
let restored = State::from_recovery(&f.p, saved).unwrap();
|
||||
assert_eq!(restored.direction, s.direction);
|
||||
@@ -698,23 +704,23 @@ fn multiple_new_rows_compose_in_empty_collections_using_destination_key_order()
|
||||
let object_count = f.p.elements.len();
|
||||
let mut s = ready(&f, reverse);
|
||||
for (facet, row) in facets.iter().zip(&original).rev() {
|
||||
s.update(&f.p, Event::EditPair(facet.clone()));
|
||||
s.update(&f.p, Event::StageMember);
|
||||
s.update(&f.p, RowEvent::EditPair(facet.clone()));
|
||||
s.update(&f.p, RowEvent::StageMember);
|
||||
s.update(
|
||||
&f.p,
|
||||
Event::Decide(facet.clone(), Some(SemanticDecision::Candidate)),
|
||||
RowEvent::Decide(facet.clone(), Some(SemanticDecision::Candidate)),
|
||||
);
|
||||
s.update(&f.p, Event::Presence(facet.clone(), true));
|
||||
s.update(&f.p, RowEvent::Presence(facet.clone(), true));
|
||||
if binding {
|
||||
let local = row["local"].as_str().unwrap().parse().unwrap();
|
||||
s.update(
|
||||
&f.p,
|
||||
Event::RowLocal(facet.clone(), view::choice(&f.p, local)),
|
||||
RowEvent::RowLocal(facet.clone(), view::choice(&f.p, local)),
|
||||
);
|
||||
} else {
|
||||
s.update(
|
||||
&f.p,
|
||||
Event::RowExpression(facet.clone(), "new independent value".into()),
|
||||
RowEvent::RowExpression(facet.clone(), "new independent value".into()),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -734,3 +740,90 @@ fn multiple_new_rows_compose_in_empty_collections_using_destination_key_order()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_keyed_rows_compare_exact_target_slots_and_retain_independent_values() {
|
||||
for binding in [false, true] {
|
||||
let mut f = fixture(binding);
|
||||
let other = Id::new_v4();
|
||||
let mut source = f.p.elements[&f.owners[0]].clone();
|
||||
source.id = other;
|
||||
source.alias = format!("other_{}", other.simple());
|
||||
source.name = "Alternate operational rationale".into();
|
||||
let parent = source.parent.unwrap();
|
||||
f.p.elements.insert(other, source);
|
||||
f.p.elements
|
||||
.get_mut(&parent)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap()
|
||||
.push(other);
|
||||
let second = add_trace(&mut f.p, other, f.owners[1], "Alternate keyed rationale");
|
||||
validate_candidate(&f.p).unwrap();
|
||||
let mut state = ready(&f, false);
|
||||
state.update(
|
||||
&f.p,
|
||||
RowEvent::Decide(f.facet.clone(), Some(SemanticDecision::Candidate)),
|
||||
);
|
||||
if binding {
|
||||
state.update(&f.p, RowEvent::Presence(f.facet.clone(), false));
|
||||
} else {
|
||||
state.update(
|
||||
&f.p,
|
||||
RowEvent::RowExpression(f.facet.clone(), f.locals[0].to_string()),
|
||||
);
|
||||
}
|
||||
assert!(state.can_apply(), "{}", errors(&state));
|
||||
let first_value = state.proposed.clone().unwrap();
|
||||
state.update(&f.p, Event::ChooseTrace(view::choice(&f.p, second)));
|
||||
state.update(&f.p, Event::OpenTrace);
|
||||
state.update(&f.p, RowEvent::Select(f.facet.clone()));
|
||||
state.update(
|
||||
&f.p,
|
||||
RowEvent::Decide(f.facet.clone(), Some(SemanticDecision::Candidate)),
|
||||
);
|
||||
if !binding {
|
||||
state.update(
|
||||
&f.p,
|
||||
RowEvent::RowExpression(f.facet.clone(), "Another value".into()),
|
||||
);
|
||||
}
|
||||
assert!(!state.can_apply());
|
||||
assert!(errors(&state).contains("disagree"), "{}", errors(&state));
|
||||
let saved = state.recovery_snapshot().unwrap();
|
||||
let restored = State::from_recovery(&f.p, saved.clone()).unwrap();
|
||||
assert_eq!(restored.recovery_snapshot().unwrap(), saved);
|
||||
assert!(!restored.can_apply());
|
||||
if binding {
|
||||
state.update(&f.p, RowEvent::Presence(f.facet.clone(), false));
|
||||
} else {
|
||||
state.update(
|
||||
&f.p,
|
||||
RowEvent::RowExpression(f.facet.clone(), f.locals[0].to_string()),
|
||||
);
|
||||
}
|
||||
assert!(state.can_apply(), "{}", errors(&state));
|
||||
assert_eq!(state.plan.as_ref().unwrap().request.candidates.len(), 1);
|
||||
assert_eq!(
|
||||
state.proposed.as_ref().unwrap().elements[&f.owners[1]],
|
||||
first_value.elements[&f.owners[1]]
|
||||
);
|
||||
assert_eq!(
|
||||
scalar::value(
|
||||
&capture_facet(
|
||||
state.proposed.as_ref().unwrap(),
|
||||
f.owners[0],
|
||||
f.owners[1],
|
||||
&f.sibling
|
||||
)
|
||||
.unwrap(),
|
||||
Layer::Sa
|
||||
),
|
||||
scalar::value(
|
||||
&capture_facet(&f.p, f.owners[0], f.owners[1], &f.sibling).unwrap(),
|
||||
Layer::Sa
|
||||
)
|
||||
);
|
||||
let _ = state.view(&f.p);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -194,8 +194,8 @@ fn member_pair(collection: ReferenceCollection) -> (Project, Id, Id, Id, FacetSe
|
||||
)
|
||||
}
|
||||
fn stage(app: &mut App, facet: &FacetSelector) {
|
||||
send(app, Event::EditPair(facet.clone()));
|
||||
send(app, Event::StageMember);
|
||||
send(app, RowEvent::EditPair(facet.clone()));
|
||||
send(app, RowEvent::StageMember);
|
||||
assert_eq!(
|
||||
state(app).selected.as_ref(),
|
||||
Some(facet),
|
||||
@@ -240,7 +240,7 @@ fn unordered_members_keep_take_candidate_preserve_other_rows_both_directions() {
|
||||
stage(&mut app, &facet);
|
||||
decide(&mut app, facet.clone(), decision);
|
||||
if decision == SemanticDecision::Candidate {
|
||||
send(&mut app, Event::Presence(facet.clone(), true));
|
||||
send(&mut app, RowEvent::Presence(facet.clone(), true));
|
||||
}
|
||||
// A target-only Take is an explicit acknowledgment, never implicit removal.
|
||||
decide(&mut app, local_facet.clone(), SemanticDecision::TakeSource);
|
||||
@@ -295,16 +295,16 @@ fn function_port_members_use_existing_typed_choices_and_preserve_oa_legacy() {
|
||||
state(&app).member_families(app.project()),
|
||||
vec![keyed::Family::Member(collection)]
|
||||
);
|
||||
send(&mut app, Event::EditPair(facet.clone()));
|
||||
send(&mut app, RowEvent::EditPair(facet.clone()));
|
||||
assert!(
|
||||
state(&app)
|
||||
.member_choices(app.project(), Layer::Sa)
|
||||
.iter()
|
||||
.any(|c| Some(c.id) == keys.sa)
|
||||
);
|
||||
send(&mut app, Event::StageMember);
|
||||
send(&mut app, RowEvent::StageMember);
|
||||
decide(&mut app, facet.clone(), SemanticDecision::Candidate);
|
||||
send(&mut app, Event::Presence(facet.clone(), true));
|
||||
send(&mut app, RowEvent::Presence(facet.clone(), true));
|
||||
assert!(state(&app).can_apply(), "{}", errors(state(&app)));
|
||||
apply(&mut app);
|
||||
assert!(contains(app.project(), sa, &facet, oa, sa));
|
||||
@@ -320,7 +320,7 @@ fn function_port_members_use_existing_typed_choices_and_preserve_oa_legacy() {
|
||||
decide(&mut app, facet.clone(), SemanticDecision::KeepTarget);
|
||||
assert!(state(&app).can_apply(), "{}", errors(state(&app)));
|
||||
decide(&mut app, facet.clone(), SemanticDecision::Candidate);
|
||||
send(&mut app, Event::Presence(facet, true));
|
||||
send(&mut app, RowEvent::Presence(facet, true));
|
||||
assert!(
|
||||
!state(&app).can_apply(),
|
||||
"OA typed membership must remain inapplicable"
|
||||
@@ -348,7 +348,10 @@ fn exact_literal_new_rows_and_removals_commit_both_directions_without_default_lo
|
||||
stage(&mut app, facet);
|
||||
decide(&mut app, facet.clone(), decision);
|
||||
if decision == SemanticDecision::Candidate {
|
||||
send(&mut app, Event::Presence(facet.clone(), facet == &created));
|
||||
send(
|
||||
&mut app,
|
||||
RowEvent::Presence(facet.clone(), facet == &created),
|
||||
);
|
||||
}
|
||||
}
|
||||
assert!(state(&app).can_apply(), "{}", errors(state(&app)));
|
||||
@@ -387,7 +390,7 @@ fn exact_reset_only_is_one_history_source_transaction_and_undo_redo_both_directi
|
||||
let before = app.document.snapshot();
|
||||
let revision = app.document.revision();
|
||||
start(&mut app, if reverse { sa } else { oa });
|
||||
send(&mut app, Event::Reset(reset.clone(), true));
|
||||
send(&mut app, RowEvent::Reset(reset.clone(), true));
|
||||
assert!(state(&app).decisions.is_empty());
|
||||
assert!(state(&app).can_apply(), "{}", errors(state(&app)));
|
||||
assert_eq!(
|
||||
@@ -399,7 +402,7 @@ fn exact_reset_only_is_one_history_source_transaction_and_undo_redo_both_directi
|
||||
.drift,
|
||||
Drift::Unreviewed
|
||||
);
|
||||
send(&mut app, Event::Reset(reset.clone(), false));
|
||||
send(&mut app, RowEvent::Reset(reset.clone(), false));
|
||||
assert!(!state(&app).can_apply());
|
||||
assert_eq!(
|
||||
state(&app)
|
||||
@@ -410,7 +413,7 @@ fn exact_reset_only_is_one_history_source_transaction_and_undo_redo_both_directi
|
||||
.drift,
|
||||
Drift::Unchanged
|
||||
);
|
||||
send(&mut app, Event::Reset(reset, true));
|
||||
send(&mut app, RowEvent::Reset(reset, true));
|
||||
apply(&mut app);
|
||||
assert_eq!(app.document.revision(), revision + 1);
|
||||
assert_eq!(app.project().elements, before.decoded.project.elements);
|
||||
@@ -462,17 +465,17 @@ fn historical_one_sided_pair_migration_requires_exact_reset_and_explicit_overlap
|
||||
decide(&mut app, previous.clone(), SemanticDecision::KeepTarget);
|
||||
stage(&mut app, &paired);
|
||||
decide(&mut app, paired.clone(), SemanticDecision::Candidate);
|
||||
send(&mut app, Event::Presence(paired.clone(), true));
|
||||
send(&mut app, RowEvent::Presence(paired.clone(), true));
|
||||
assert!(!state(&app).can_apply());
|
||||
assert_eq!(state(&app).decisions.len(), 2);
|
||||
send(&mut app, Event::Decide(previous.clone(), None));
|
||||
send(&mut app, RowEvent::Decide(previous.clone(), None));
|
||||
assert!(
|
||||
!state(&app).can_apply(),
|
||||
"Historic pair cannot silently migrate"
|
||||
);
|
||||
send(&mut app, Event::Reset(previous.clone(), true));
|
||||
send(&mut app, RowEvent::Reset(previous.clone(), true));
|
||||
assert!(state(&app).can_apply(), "{}", errors(state(&app)));
|
||||
send(&mut app, Event::Reset(previous, false));
|
||||
send(&mut app, RowEvent::Reset(previous, false));
|
||||
assert!(!state(&app).can_apply());
|
||||
assert!(state(&app).presence[&paired]);
|
||||
let old = state(&app).resets.clone();
|
||||
@@ -486,7 +489,7 @@ fn historical_one_sided_pair_migration_requires_exact_reset_and_explicit_overlap
|
||||
.unwrap()
|
||||
.facets[0]
|
||||
.selector();
|
||||
send(&mut app, Event::Reset(historic, true));
|
||||
send(&mut app, RowEvent::Reset(historic, true));
|
||||
apply(&mut app);
|
||||
assert!(contains(app.project(), target, &paired, oa, sa));
|
||||
assert_eq!(
|
||||
@@ -515,8 +518,8 @@ fn reset_precedes_equal_constructor_candidate_and_preserves_independent_literal_
|
||||
let created = literal("separate");
|
||||
stage(&mut app, &created);
|
||||
decide(&mut app, created.clone(), SemanticDecision::Candidate);
|
||||
send(&mut app, Event::Presence(created.clone(), true));
|
||||
send(&mut app, Event::Reset(ctor.clone(), true));
|
||||
send(&mut app, RowEvent::Presence(created.clone(), true));
|
||||
send(&mut app, RowEvent::Reset(ctor.clone(), true));
|
||||
decide(&mut app, ctor.clone(), SemanticDecision::Candidate);
|
||||
assert!(state(&app).can_acknowledge_constructor(app.project(), &ctor));
|
||||
assert!(state(&app).can_apply(), "{}", errors(state(&app)));
|
||||
@@ -530,10 +533,10 @@ fn reset_precedes_equal_constructor_candidate_and_preserves_independent_literal_
|
||||
BTreeSet::from([ctor.clone(), created.clone()])
|
||||
);
|
||||
assert!(reviewed.retired.is_empty());
|
||||
send(&mut app, Event::Reset(ctor.clone(), false));
|
||||
send(&mut app, RowEvent::Reset(ctor.clone(), false));
|
||||
assert!(!state(&app).can_apply());
|
||||
assert!(state(&app).presence[&created]);
|
||||
send(&mut app, Event::Reset(ctor, true));
|
||||
send(&mut app, RowEvent::Reset(ctor, true));
|
||||
assert!(state(&app).can_apply(), "{}", errors(state(&app)));
|
||||
}
|
||||
}
|
||||
@@ -547,7 +550,7 @@ fn invalid_literal_removal_and_unselected_scalar_buffers_are_retained_and_repair
|
||||
decide(&mut app, unit.clone(), SemanticDecision::Candidate);
|
||||
send(
|
||||
&mut app,
|
||||
Event::Input(
|
||||
RowEvent::Input(
|
||||
"/body/Sa/Shared/DataType/unit".into(),
|
||||
" untouched unit ".into(),
|
||||
),
|
||||
@@ -555,13 +558,13 @@ fn invalid_literal_removal_and_unselected_scalar_buffers_are_retained_and_repair
|
||||
decide(&mut app, unit.clone(), SemanticDecision::KeepTarget);
|
||||
let retained = literal("retained");
|
||||
decide(&mut app, retained.clone(), SemanticDecision::Candidate);
|
||||
send(&mut app, Event::Presence(retained.clone(), false));
|
||||
send(&mut app, RowEvent::Presence(retained.clone(), false));
|
||||
assert!(
|
||||
!state(&app).can_apply(),
|
||||
"Existing default must prevent removal"
|
||||
);
|
||||
assert!(!state(&app).presence[&retained]);
|
||||
send(&mut app, Event::Presence(retained.clone(), true));
|
||||
send(&mut app, RowEvent::Presence(retained.clone(), true));
|
||||
assert!(state(&app).can_apply(), "{}", errors(state(&app)));
|
||||
assert_eq!(
|
||||
state(&app).draft.as_ref().unwrap().inputs["/body/Sa/Shared/DataType/unit"].raw,
|
||||
@@ -585,15 +588,15 @@ fn unsupported_independent_families_and_missing_identities_do_not_gain_edit_auth
|
||||
};
|
||||
decide(&mut app, unsupported.clone(), SemanticDecision::Candidate);
|
||||
assert!(!state(&app).decisions.contains_key(&unsupported));
|
||||
send(&mut app, Event::MemberFamily(keyed::Family::Literal));
|
||||
send(&mut app, Event::Literal("unpaired".into()));
|
||||
send(&mut app, Event::LiteralSide(Layer::Sa, false));
|
||||
send(&mut app, Event::StageMember);
|
||||
send(&mut app, RowEvent::MemberFamily(keyed::Family::Literal));
|
||||
send(&mut app, RowEvent::Literal("unpaired".into()));
|
||||
send(&mut app, RowEvent::LiteralSide(Layer::Sa, false));
|
||||
send(&mut app, RowEvent::StageMember);
|
||||
let facet = state(&app).selected.clone().unwrap();
|
||||
decide(&mut app, facet.clone(), SemanticDecision::Candidate);
|
||||
assert!(!state(&app).can_apply());
|
||||
send(&mut app, Event::LiteralSide(Layer::Oa, false));
|
||||
send(&mut app, Event::StageMember);
|
||||
send(&mut app, RowEvent::LiteralSide(Layer::Oa, false));
|
||||
send(&mut app, RowEvent::StageMember);
|
||||
assert!(state(&app).error.is_some());
|
||||
assert_eq!(
|
||||
state(&app).decisions.get(&facet),
|
||||
@@ -654,7 +657,7 @@ fn stale_or_source_failed_reset_and_membership_apply_retain_exact_choices() {
|
||||
history(&mut p, trace, std::slice::from_ref(&reset));
|
||||
let mut app = App::with_project(p.clone());
|
||||
start(&mut app, oa);
|
||||
send(&mut app, Event::Reset(reset.clone(), true));
|
||||
send(&mut app, RowEvent::Reset(reset.clone(), true));
|
||||
app.document
|
||||
.execute(Command::Rename(oa, "Changed after preview".into()))
|
||||
.unwrap();
|
||||
@@ -669,11 +672,11 @@ fn stale_or_source_failed_reset_and_membership_apply_retain_exact_choices() {
|
||||
limits.model.max_source_bytes = bundle.files.values().map(String::len).sum::<usize>() + 10;
|
||||
app.document = Document::from_bundle(bundle, limits).unwrap();
|
||||
start(&mut app, oa);
|
||||
send(&mut app, Event::Reset(reset.clone(), true));
|
||||
send(&mut app, RowEvent::Reset(reset.clone(), true));
|
||||
let added = literal(&" exact long literal ".repeat(100));
|
||||
stage(&mut app, &added);
|
||||
decide(&mut app, added.clone(), SemanticDecision::Candidate);
|
||||
send(&mut app, Event::Presence(added.clone(), true));
|
||||
send(&mut app, RowEvent::Presence(added.clone(), true));
|
||||
assert!(state(&app).can_apply(), "{}", errors(state(&app)));
|
||||
let before = app.document.snapshot();
|
||||
apply(&mut app);
|
||||
@@ -729,10 +732,10 @@ fn member_identity_labels_and_new_tabs_preserve_exact_names_and_deleted_keys() {
|
||||
let mut app = App::with_project(p);
|
||||
start(&mut app, oa);
|
||||
for tab in [Tab::History, Tab::Members, Tab::Facts] {
|
||||
send(&mut app, Event::Tab(tab));
|
||||
send(&mut app, RowEvent::Tab(tab));
|
||||
let _ = state(&app).view(app.project());
|
||||
}
|
||||
send(&mut app, Event::Reset(old1.clone(), true));
|
||||
send(&mut app, RowEvent::Reset(old1.clone(), true));
|
||||
assert!(state(&app).can_apply(), "{}", errors(state(&app)));
|
||||
assert!(!state(&app).facts.iter().any(|f| f.selector == old1));
|
||||
assert!(state(&app).facts.iter().any(|f| f.selector == old2));
|
||||
@@ -745,12 +748,12 @@ fn staging_removal_and_obsolete_reset_recovery_keep_other_decisions_and_raw_pres
|
||||
history(&mut p, trace, std::slice::from_ref(&reset));
|
||||
let mut app = App::with_project(p.clone());
|
||||
start(&mut app, oa);
|
||||
send(&mut app, Event::Reset(reset.clone(), true));
|
||||
send(&mut app, RowEvent::Reset(reset.clone(), true));
|
||||
let added = literal("staged draft");
|
||||
stage(&mut app, &added);
|
||||
decide(&mut app, added.clone(), SemanticDecision::Candidate);
|
||||
send(&mut app, Event::Presence(added.clone(), true));
|
||||
send(&mut app, Event::DropStaged(added.clone()));
|
||||
send(&mut app, RowEvent::Presence(added.clone(), true));
|
||||
send(&mut app, RowEvent::DropStaged(added.clone()));
|
||||
assert!(state(&app).decisions.is_empty());
|
||||
assert!(!state(&app).staged.contains(&added));
|
||||
assert!(state(&app).presence[&added]);
|
||||
@@ -776,7 +779,14 @@ fn staging_removal_and_obsolete_reset_recovery_keep_other_decisions_and_raw_pres
|
||||
assert!(!s.can_apply());
|
||||
s.tab = Tab::History;
|
||||
let _ = s.view(&p);
|
||||
s.update(&p, Event::Reset(reset, false));
|
||||
assert!(s.can_apply(), "{}", errors(s));
|
||||
s.update(&p, RowEvent::Reset(reset.clone(), false));
|
||||
// The old single-row test implicitly adopted externally changed history on
|
||||
// refresh. A batch has one frozen accepted base: withdrawing raw reset intent
|
||||
// survives, while application requires an explicitly reopened review.
|
||||
assert!(!s.resets.contains(&reset));
|
||||
assert!(!s.can_apply());
|
||||
assert!(s.error.as_ref().unwrap().contains("stale"));
|
||||
assert!(s.plan.is_none() && s.proposed.is_none());
|
||||
assert_eq!(s.decisions.get(&added), Some(&SemanticDecision::Candidate));
|
||||
assert!(s.presence[&added]);
|
||||
}
|
||||
|
||||
@@ -118,7 +118,7 @@ fn message_group_take_full_timer_and_scalar_candidate_are_one_source_history_cha
|
||||
);
|
||||
send(
|
||||
&mut app,
|
||||
Event::Input("/name".into(), "Reviewed message".into()),
|
||||
RowEvent::Input("/name".into(), "Reviewed message".into()),
|
||||
);
|
||||
assert!(state(&app).can_apply(), "{:?}", state(&app).error);
|
||||
assert_eq!(
|
||||
@@ -189,7 +189,7 @@ fn message_group_candidate_timer_switches_preserve_both_drafts_and_overlap_is_ex
|
||||
);
|
||||
send(
|
||||
&mut app,
|
||||
Event::Input(expression.clone(), "independent scalar timer".into()),
|
||||
RowEvent::Input(expression.clone(), "independent scalar timer".into()),
|
||||
);
|
||||
decide(
|
||||
&mut app,
|
||||
@@ -200,11 +200,11 @@ fn message_group_candidate_timer_switches_preserve_both_drafts_and_overlap_is_ex
|
||||
assert!(!state(&app).can_apply());
|
||||
send(
|
||||
&mut app,
|
||||
Event::Input(expression.clone(), " group β timer\n\t ".into()),
|
||||
RowEvent::Input(expression.clone(), " group β timer\n\t ".into()),
|
||||
);
|
||||
send(
|
||||
&mut app,
|
||||
Event::Text(expression.clone(), text_editor::Action::SelectAll),
|
||||
RowEvent::Text(expression.clone(), text_editor::Action::SelectAll),
|
||||
);
|
||||
let cursor = state(&app)
|
||||
.group_drafts
|
||||
@@ -222,11 +222,11 @@ fn message_group_candidate_timer_switches_preserve_both_drafts_and_overlap_is_ex
|
||||
.selection();
|
||||
send(
|
||||
&mut app,
|
||||
Event::Variant(kind.clone(), json!("Asynchronous")),
|
||||
RowEvent::Variant(kind.clone(), json!("Asynchronous")),
|
||||
);
|
||||
send(
|
||||
&mut app,
|
||||
Event::Variant(kind, json!({"Timer":{"expression":""}})),
|
||||
RowEvent::Variant(kind, json!({"Timer":{"expression":""}})),
|
||||
);
|
||||
assert_eq!(
|
||||
state(&app)
|
||||
@@ -265,11 +265,11 @@ fn message_group_candidate_timer_switches_preserve_both_drafts_and_overlap_is_ex
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
// The visible overlap-resolution actions only change the explicitly chosen
|
||||
// decision. Neither independent candidate buffer is discarded.
|
||||
send(&mut app, Event::Decide(message_kind::selector(), None));
|
||||
send(&mut app, RowEvent::Decide(message_kind::selector(), None));
|
||||
assert!(state(&app).can_apply());
|
||||
send(
|
||||
&mut app,
|
||||
Event::Decide(message_kind::timer_selector(), None),
|
||||
RowEvent::Decide(message_kind::timer_selector(), None),
|
||||
);
|
||||
decide(
|
||||
&mut app,
|
||||
@@ -395,7 +395,7 @@ fn message_group_reply_and_reference_take_require_explicit_call_mapping_both_dir
|
||||
message_kind::selector(),
|
||||
SemanticDecision::Candidate,
|
||||
);
|
||||
send(&mut app, Event::Variant(path(layer), json!("Reply")));
|
||||
send(&mut app, RowEvent::Variant(path(layer), json!("Reply")));
|
||||
decide(
|
||||
&mut app,
|
||||
FacetSelector::Occurrence(OccurrenceFacet::ReplyTo),
|
||||
@@ -409,19 +409,19 @@ fn message_group_reply_and_reference_take_require_explicit_call_mapping_both_dir
|
||||
);
|
||||
send(
|
||||
&mut app,
|
||||
Event::ReferenceOa(Choice {
|
||||
RowEvent::ReferenceOa(Choice {
|
||||
id: calls[0],
|
||||
label: "OA call".into(),
|
||||
}),
|
||||
);
|
||||
send(
|
||||
&mut app,
|
||||
Event::ReferenceSa(Choice {
|
||||
RowEvent::ReferenceSa(Choice {
|
||||
id: calls[1],
|
||||
label: "SA call".into(),
|
||||
}),
|
||||
);
|
||||
send(&mut app, Event::AddReference);
|
||||
send(&mut app, RowEvent::AddReference);
|
||||
assert!(state(&app).can_apply(), "{}", failures(&app));
|
||||
assert_eq!(
|
||||
acknowledged(&app),
|
||||
@@ -480,7 +480,7 @@ fn message_group_candidate_closed_kinds_are_core_validated_in_both_directions()
|
||||
);
|
||||
send(
|
||||
&mut app,
|
||||
Event::Variant(path(layer), serde_json::to_value(&kind).unwrap()),
|
||||
RowEvent::Variant(path(layer), serde_json::to_value(&kind).unwrap()),
|
||||
);
|
||||
assert!(state(&app).can_apply(), "{kind:?}: {}", failures(&app));
|
||||
assert_eq!(
|
||||
@@ -511,7 +511,7 @@ fn message_group_candidate_closed_kinds_are_core_validated_in_both_directions()
|
||||
);
|
||||
review.update(
|
||||
&p,
|
||||
Event::Decide(message_kind::selector(), Some(SemanticDecision::Candidate)),
|
||||
RowEvent::Decide(message_kind::selector(), Some(SemanticDecision::Candidate)),
|
||||
);
|
||||
assert!(review.decisions.is_empty());
|
||||
assert!(!review.can_apply());
|
||||
@@ -552,7 +552,7 @@ fn message_group_timer_does_not_erase_exchange_without_its_own_selected_decision
|
||||
);
|
||||
send(
|
||||
&mut app,
|
||||
Event::Variant(path("Sa"), json!({"Timer":{"expression":"after 2 s"}})),
|
||||
RowEvent::Variant(path("Sa"), json!({"Timer":{"expression":"after 2 s"}})),
|
||||
);
|
||||
assert!(!state(&app).can_apply());
|
||||
assert!(
|
||||
@@ -569,7 +569,7 @@ fn message_group_timer_does_not_erase_exchange_without_its_own_selected_decision
|
||||
);
|
||||
send(
|
||||
&mut app,
|
||||
Event::Shape(
|
||||
RowEvent::Shape(
|
||||
"/body/Sa/Shared/Occurrence/body/Exchange/exchange".into(),
|
||||
Value::Null,
|
||||
),
|
||||
@@ -606,7 +606,10 @@ fn message_group_initial_equal_take_acknowledges_only_kind_and_checks_authored_t
|
||||
FacetSelector::Header(HeaderFacet::Name),
|
||||
SemanticDecision::Candidate,
|
||||
);
|
||||
send(&mut app, Event::Input("/name".into(), "Final name".into()));
|
||||
send(
|
||||
&mut app,
|
||||
RowEvent::Input("/name".into(), "Final name".into()),
|
||||
);
|
||||
assert!(state(&app).can_apply(), "{:?}", state(&app).error);
|
||||
assert_eq!(
|
||||
message_kind::value(state(&app).proposed.as_ref().unwrap(), sa),
|
||||
@@ -621,7 +624,7 @@ fn message_group_initial_equal_take_acknowledges_only_kind_and_checks_authored_t
|
||||
);
|
||||
send(
|
||||
&mut app,
|
||||
Event::Input(expression.clone(), "authored group timer".into()),
|
||||
RowEvent::Input(expression.clone(), "authored group timer".into()),
|
||||
);
|
||||
assert!(
|
||||
!state(&app).can_apply(),
|
||||
@@ -634,7 +637,7 @@ fn message_group_initial_equal_take_acknowledges_only_kind_and_checks_authored_t
|
||||
);
|
||||
send(
|
||||
&mut app,
|
||||
Event::Input(expression.clone(), "different scalar timer".into()),
|
||||
RowEvent::Input(expression.clone(), "different scalar timer".into()),
|
||||
);
|
||||
assert!(!state(&app).can_apply());
|
||||
assert!(
|
||||
@@ -646,7 +649,7 @@ fn message_group_initial_equal_take_acknowledges_only_kind_and_checks_authored_t
|
||||
);
|
||||
send(
|
||||
&mut app,
|
||||
Event::Input(expression, "authored group timer".into()),
|
||||
RowEvent::Input(expression, "authored group timer".into()),
|
||||
);
|
||||
assert!(state(&app).can_apply(), "{:?}", state(&app).error);
|
||||
assert!(acknowledged(&app).contains(&message_kind::timer_selector()));
|
||||
@@ -676,11 +679,11 @@ fn message_group_rejects_protected_edits_stale_plan_and_source_failure_without_l
|
||||
);
|
||||
let expression = format!("{}/Timer/expression", path("Sa"));
|
||||
let raw = "retain exact timer β\n".repeat(40);
|
||||
send(&mut app, Event::Input(expression.clone(), raw.clone()));
|
||||
send(&mut app, RowEvent::Input(expression.clone(), raw.clone()));
|
||||
assert!(state(&app).can_apply(), "{:?}", state(&app).error);
|
||||
send(
|
||||
&mut app,
|
||||
Event::Input("/name".into(), "Unselected protected scope".into()),
|
||||
RowEvent::Input("/name".into(), "Unselected protected scope".into()),
|
||||
);
|
||||
assert!(!state(&app).can_apply());
|
||||
assert!(
|
||||
@@ -690,7 +693,7 @@ fn message_group_rejects_protected_edits_stale_plan_and_source_failure_without_l
|
||||
.unwrap()
|
||||
.contains("selected review")
|
||||
);
|
||||
send(&mut app, Event::Preview);
|
||||
send(&mut app, RowEvent::Preview);
|
||||
assert!(state(&app).can_apply());
|
||||
if !source_failure {
|
||||
app.document
|
||||
|
||||
@@ -26,7 +26,7 @@ fn write(value: &mut Value, which: OrderedCollection, ids: Vec<Id>) -> Result<()
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl State {
|
||||
impl ReviewRow {
|
||||
pub(super) fn ordered_members(
|
||||
&self,
|
||||
project: &Project,
|
||||
|
||||
@@ -172,8 +172,8 @@ fn ready(f: &Fixture, reverse: bool) -> State {
|
||||
state
|
||||
}
|
||||
fn decide(state: &mut State, p: &Project, facet: FacetSelector, decision: SemanticDecision) {
|
||||
state.update(p, Event::Select(facet.clone()));
|
||||
state.update(p, Event::Decide(facet, Some(decision)));
|
||||
state.update(p, RowEvent::Select(facet.clone()));
|
||||
state.update(p, RowEvent::Decide(facet, Some(decision)));
|
||||
}
|
||||
fn errors(s: &State) -> String {
|
||||
s.error
|
||||
@@ -231,7 +231,7 @@ fn ordered_take_uses_unique_anchors_and_matches_core_with_unrelated_candidate_bo
|
||||
decide(&mut s, &f.p, header, SemanticDecision::Candidate);
|
||||
s.update(
|
||||
&f.p,
|
||||
Event::Input("/description".into(), "Reviewed startup insertion".into()),
|
||||
RowEvent::Input("/description".into(), "Reviewed startup insertion".into()),
|
||||
);
|
||||
let ti = 1 - usize::from(reverse);
|
||||
let mut expected = f.p.clone();
|
||||
@@ -268,7 +268,7 @@ fn ambiguous_order_preserves_local_member_and_requires_explicit_candidate_positi
|
||||
s.order_drafts[&f.which],
|
||||
vec![f.rows[ti][0], f.rows[ti][3], f.rows[ti][1], f.rows[ti][2]]
|
||||
);
|
||||
s.update(&f.p, Event::OrderMove(f.which, 3, -1));
|
||||
s.update(&f.p, RowEvent::OrderMove(f.which, 3, -1));
|
||||
let mut expected = f.p.clone();
|
||||
set_order(
|
||||
&mut expected,
|
||||
@@ -297,7 +297,7 @@ fn ordered_membership_choices_stage_exact_pairs_in_all_owner_shapes() {
|
||||
let mut s = ready(&f, false);
|
||||
s.update(
|
||||
&f.p,
|
||||
Event::MemberFamily(keyed::Family::Member(ordered::collection(f.which))),
|
||||
RowEvent::MemberFamily(keyed::Family::Member(ordered::collection(f.which))),
|
||||
);
|
||||
for (i, layer) in [(0, Layer::Oa), (1, Layer::Sa)] {
|
||||
let choices = s.member_choices(&f.p, layer);
|
||||
@@ -311,13 +311,13 @@ fn ordered_membership_choices_stage_exact_pairs_in_all_owner_shapes() {
|
||||
}
|
||||
s.update(
|
||||
&f.p,
|
||||
Event::MemberOa(Some(view::choice(&f.p, f.rows[0][2]))),
|
||||
RowEvent::MemberOa(Some(view::choice(&f.p, f.rows[0][2]))),
|
||||
);
|
||||
s.update(
|
||||
&f.p,
|
||||
Event::MemberSa(Some(view::choice(&f.p, f.rows[1][2]))),
|
||||
RowEvent::MemberSa(Some(view::choice(&f.p, f.rows[1][2]))),
|
||||
);
|
||||
s.update(&f.p, Event::StageMember);
|
||||
s.update(&f.p, RowEvent::StageMember);
|
||||
assert!(s.staged.contains(&member(&f, 2)));
|
||||
assert_eq!(s.references[&f.rows[0][2]], f.rows[1][2]);
|
||||
}
|
||||
@@ -330,20 +330,20 @@ fn raw_order_survives_membership_changes_decisions_and_recovery_until_explicit_r
|
||||
let mut s = ready(&f, reverse);
|
||||
let order = FacetSelector::Order(f.which);
|
||||
decide(&mut s, &f.p, order.clone(), SemanticDecision::Candidate);
|
||||
s.update(&f.p, Event::OrderMove(f.which, 1, -1));
|
||||
s.update(&f.p, RowEvent::OrderMove(f.which, 1, -1));
|
||||
let authored = s.order_drafts[&f.which].clone();
|
||||
decide(&mut s, &f.p, member(&f, 2), SemanticDecision::Candidate);
|
||||
s.update(&f.p, Event::Presence(member(&f, 2), true));
|
||||
s.update(&f.p, RowEvent::Presence(member(&f, 2), true));
|
||||
assert!(!s.can_apply());
|
||||
assert_eq!(s.order_drafts[&f.which], authored);
|
||||
s.update(&f.p, Event::Decide(order.clone(), None));
|
||||
s.update(&f.p, RowEvent::Decide(order.clone(), None));
|
||||
decide(&mut s, &f.p, order.clone(), SemanticDecision::Candidate);
|
||||
let saved = s.recovery_snapshot().unwrap();
|
||||
let mut restored = State::from_recovery(&f.p, saved.clone()).unwrap();
|
||||
assert_eq!(restored.order_drafts[&f.which], authored);
|
||||
assert!(!restored.can_apply());
|
||||
assert_eq!(restored.recovery_snapshot().unwrap(), saved);
|
||||
restored.update(&f.p, Event::OrderReconcile(f.which));
|
||||
restored.update(&f.p, RowEvent::OrderReconcile(f.which));
|
||||
let ti = 1 - usize::from(reverse);
|
||||
let mut expected = f.p.clone();
|
||||
let mut final_order = authored;
|
||||
@@ -385,7 +385,7 @@ fn order_take_preserves_destination_only_positions_and_order_alone_cannot_add_me
|
||||
s.refresh(&f.p);
|
||||
assert!(!s.can_apply());
|
||||
assert!(errors(&s).contains("surviving"), "{}", errors(&s));
|
||||
s.update(&f.p, Event::OrderReconcile(f.which));
|
||||
s.update(&f.p, RowEvent::OrderReconcile(f.which));
|
||||
assert!(s.can_apply(), "{}", errors(&s));
|
||||
}
|
||||
}
|
||||
@@ -406,28 +406,28 @@ fn ordered_review_apply_is_exact_source_history_transaction_with_reopen_and_undo
|
||||
for i in 0..3 {
|
||||
send(
|
||||
&mut app,
|
||||
Event::ReferenceOa(view::choice(&f.p, f.rows[0][i])),
|
||||
RowEvent::ReferenceOa(view::choice(&f.p, f.rows[0][i])),
|
||||
);
|
||||
send(
|
||||
&mut app,
|
||||
Event::ReferenceSa(view::choice(&f.p, f.rows[1][i])),
|
||||
RowEvent::ReferenceSa(view::choice(&f.p, f.rows[1][i])),
|
||||
);
|
||||
send(&mut app, Event::AddReference);
|
||||
send(&mut app, RowEvent::AddReference);
|
||||
}
|
||||
send(&mut app, Event::Select(member(&f, 2)));
|
||||
send(&mut app, RowEvent::Select(member(&f, 2)));
|
||||
send(
|
||||
&mut app,
|
||||
Event::Decide(member(&f, 2), Some(SemanticDecision::TakeSource)),
|
||||
RowEvent::Decide(member(&f, 2), Some(SemanticDecision::TakeSource)),
|
||||
);
|
||||
send(&mut app, Event::Select(FacetSelector::Order(f.which)));
|
||||
send(&mut app, RowEvent::Select(FacetSelector::Order(f.which)));
|
||||
send(
|
||||
&mut app,
|
||||
Event::Decide(
|
||||
RowEvent::Decide(
|
||||
FacetSelector::Order(f.which),
|
||||
Some(SemanticDecision::Candidate),
|
||||
),
|
||||
);
|
||||
send(&mut app, Event::OrderMove(f.which, 3, -1));
|
||||
send(&mut app, RowEvent::OrderMove(f.which, 3, -1));
|
||||
assert!(state(&app).can_apply(), "{}", errors(state(&app)));
|
||||
let expected = state(&app).proposed.clone().unwrap();
|
||||
apply(&mut app);
|
||||
@@ -479,7 +479,7 @@ fn several_incoming_members_follow_source_order_despite_independent_identity_sor
|
||||
);
|
||||
s.update(
|
||||
&f.p,
|
||||
Event::Input("/description".into(), "Multiple incoming steps".into()),
|
||||
RowEvent::Input("/description".into(), "Multiple incoming steps".into()),
|
||||
);
|
||||
let mut expected = f.p.clone();
|
||||
set_order(
|
||||
@@ -513,7 +513,7 @@ fn ordered_presence_keep_take_candidate_remove_only_selected_entry_and_order_tak
|
||||
let mut s = ready(&f, reverse);
|
||||
decide(&mut s, &f.p, member(&f, 0), decision);
|
||||
if decision == SemanticDecision::Candidate {
|
||||
s.update(&f.p, Event::Presence(member(&f, 0), false));
|
||||
s.update(&f.p, RowEvent::Presence(member(&f, 0), false));
|
||||
}
|
||||
decide(
|
||||
&mut s,
|
||||
@@ -523,7 +523,7 @@ fn ordered_presence_keep_take_candidate_remove_only_selected_entry_and_order_tak
|
||||
);
|
||||
s.update(
|
||||
&f.p,
|
||||
Event::Input("/description".into(), "Only selected membership".into()),
|
||||
RowEvent::Input("/description".into(), "Only selected membership".into()),
|
||||
);
|
||||
let mut expected = f.p.clone();
|
||||
if decision != SemanticDecision::KeepTarget {
|
||||
@@ -565,3 +565,58 @@ fn ordered_presence_keep_take_candidate_remove_only_selected_entry_and_order_tak
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn historical_pair_restore_uses_exact_member_keys_and_never_zips_order_observations() {
|
||||
let f = fixture(0, false, true);
|
||||
let mut state = ready(&f, false);
|
||||
decide(
|
||||
&mut state,
|
||||
&f.p,
|
||||
member(&f, 2),
|
||||
SemanticDecision::TakeSource,
|
||||
);
|
||||
decide(
|
||||
&mut state,
|
||||
&f.p,
|
||||
FacetSelector::Order(f.which),
|
||||
SemanticDecision::Candidate,
|
||||
);
|
||||
state.update(&f.p, RowEvent::OrderMove(f.which, 3, -1));
|
||||
assert!(state.can_apply(), "{}", errors(&state));
|
||||
let project = state.proposed.clone().unwrap();
|
||||
let mut reopened = State::new(&project, f.trace).unwrap();
|
||||
let exact = history::reviewed_pairs(&reopened, &project).unwrap();
|
||||
assert_eq!(exact, BTreeMap::from([(f.rows[0][2], f.rows[1][2])]));
|
||||
assert_eq!(
|
||||
project.traces[&f.trace]
|
||||
.baseline
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.semantic
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.facets
|
||||
.len(),
|
||||
2
|
||||
);
|
||||
reopened.update(&project, RowEvent::RestoreReviewedReferences);
|
||||
assert_eq!(reopened.references, exact);
|
||||
assert!(reopened.decisions.is_empty());
|
||||
assert_eq!(reopened.proposed.as_ref(), Some(&project));
|
||||
reopened.references.insert(f.rows[0][2], f.rows[1][0]);
|
||||
let prior = reopened.references.clone();
|
||||
reopened.update(&project, RowEvent::RestoreReviewedReferences);
|
||||
assert!(reopened.error.as_ref().unwrap().contains("conflicts"));
|
||||
assert_eq!(
|
||||
reopened.references, prior,
|
||||
"Restore is atomic on disagreement"
|
||||
);
|
||||
reopened.references.clear();
|
||||
reopened.update(&project, RowEvent::Reset(member(&f, 2), true));
|
||||
assert!(
|
||||
history::reviewed_pairs(&reopened, &project)
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Saved {
|
||||
pub(super) struct Saved {
|
||||
version: u32,
|
||||
base_identity: Id,
|
||||
base_fingerprint: String,
|
||||
@@ -31,11 +31,143 @@ struct Saved {
|
||||
rows: Vec<(FacetSelector, String, Option<Id>)>,
|
||||
#[serde(default)]
|
||||
orders: Vec<(OrderedCollection, Vec<Id>)>,
|
||||
#[serde(default)]
|
||||
attachments: attachments::Saved,
|
||||
#[serde(default)]
|
||||
artifacts: artifacts::Saved,
|
||||
reference_oa: Option<Id>,
|
||||
reference_sa: Option<Id>,
|
||||
reference_search: String,
|
||||
accepted_impacts: Vec<SemanticImpact>,
|
||||
}
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct RowPayload {
|
||||
anchor: Id,
|
||||
trace: Option<Id>,
|
||||
direction: TransitionDirection,
|
||||
selected: Option<FacetSelector>,
|
||||
decisions: Vec<(FacetSelector, SemanticDecision)>,
|
||||
resets: BTreeSet<FacetSelector>,
|
||||
staged: BTreeSet<FacetSelector>,
|
||||
presence: Vec<(FacetSelector, bool)>,
|
||||
member_family: keyed::Family,
|
||||
member_oa: Option<Id>,
|
||||
member_sa: Option<Id>,
|
||||
literal: String,
|
||||
literal_oa: bool,
|
||||
literal_sa: bool,
|
||||
annotation_oa: Option<AnnotationKey>,
|
||||
annotation_sa: Option<AnnotationKey>,
|
||||
draft: Option<RecoveryDraft>,
|
||||
group_drafts: Vec<(FacetSelector, RecoveryDraft)>,
|
||||
rows: Vec<(FacetSelector, String, Option<Id>)>,
|
||||
#[serde(default)]
|
||||
orders: Vec<(OrderedCollection, Vec<Id>)>,
|
||||
#[serde(default)]
|
||||
attachments: attachments::Saved,
|
||||
#[serde(default)]
|
||||
artifacts: artifacts::Saved,
|
||||
reference_oa: Option<Id>,
|
||||
reference_sa: Option<Id>,
|
||||
reference_search: String,
|
||||
}
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct SavedRow {
|
||||
address: RowAddress,
|
||||
included: bool,
|
||||
draft: RowPayload,
|
||||
}
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct SavedBatch {
|
||||
version: u32,
|
||||
base_identity: Id,
|
||||
base_fingerprint: String,
|
||||
direction: TransitionDirection,
|
||||
references: BTreeMap<Id, Id>,
|
||||
accepted_impacts: Vec<SemanticImpact>,
|
||||
active: Option<RowAddress>,
|
||||
rows: Vec<SavedRow>,
|
||||
pending: Option<RowPayload>,
|
||||
add_trace: Option<Id>,
|
||||
}
|
||||
impl RowPayload {
|
||||
fn from_row(row: &ReviewRow) -> Result<Self, String> {
|
||||
let saved: Saved =
|
||||
serde_json::from_value(row.recovery_snapshot()?).map_err(|e| e.to_string())?;
|
||||
Ok(Self {
|
||||
anchor: saved.anchor,
|
||||
trace: saved.trace,
|
||||
direction: saved.direction,
|
||||
selected: saved.selected,
|
||||
decisions: saved.decisions,
|
||||
resets: saved.resets,
|
||||
staged: saved.staged,
|
||||
presence: saved.presence,
|
||||
member_family: saved.member_family,
|
||||
member_oa: saved.member_oa,
|
||||
member_sa: saved.member_sa,
|
||||
literal: saved.literal,
|
||||
literal_oa: saved.literal_oa,
|
||||
literal_sa: saved.literal_sa,
|
||||
annotation_oa: saved.annotation_oa,
|
||||
annotation_sa: saved.annotation_sa,
|
||||
draft: saved.draft,
|
||||
group_drafts: saved.group_drafts,
|
||||
rows: saved.rows,
|
||||
orders: saved.orders,
|
||||
attachments: saved.attachments,
|
||||
artifacts: saved.artifacts,
|
||||
reference_oa: saved.reference_oa,
|
||||
reference_sa: saved.reference_sa,
|
||||
reference_search: saved.reference_search,
|
||||
})
|
||||
}
|
||||
fn restore(
|
||||
self,
|
||||
project: &Project,
|
||||
references: &BTreeMap<Id, Id>,
|
||||
) -> Result<ReviewRow, String> {
|
||||
ReviewRow::restore_saved(
|
||||
project,
|
||||
Saved {
|
||||
version: 1,
|
||||
base_identity: project.id,
|
||||
base_fingerprint: String::new(),
|
||||
references: references.clone(),
|
||||
accepted_impacts: vec![],
|
||||
anchor: self.anchor,
|
||||
trace: self.trace,
|
||||
direction: self.direction,
|
||||
selected: self.selected,
|
||||
decisions: self.decisions,
|
||||
resets: self.resets,
|
||||
staged: self.staged,
|
||||
presence: self.presence,
|
||||
member_family: self.member_family,
|
||||
member_oa: self.member_oa,
|
||||
member_sa: self.member_sa,
|
||||
literal: self.literal,
|
||||
literal_oa: self.literal_oa,
|
||||
literal_sa: self.literal_sa,
|
||||
annotation_oa: self.annotation_oa,
|
||||
annotation_sa: self.annotation_sa,
|
||||
draft: self.draft,
|
||||
group_drafts: self.group_drafts,
|
||||
rows: self.rows,
|
||||
orders: self.orders,
|
||||
attachments: self.attachments,
|
||||
artifacts: self.artifacts,
|
||||
reference_oa: self.reference_oa,
|
||||
reference_sa: self.reference_sa,
|
||||
reference_search: self.reference_search,
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn unique<K: Ord, V>(values: Vec<(K, V)>) -> Result<BTreeMap<K, V>, String> {
|
||||
let mut map = BTreeMap::new();
|
||||
for (key, value) in values {
|
||||
@@ -46,7 +178,7 @@ fn unique<K: Ord, V>(values: Vec<(K, V)>) -> Result<BTreeMap<K, V>, String> {
|
||||
Ok(map)
|
||||
}
|
||||
|
||||
impl State {
|
||||
impl ReviewRow {
|
||||
pub fn recovery_snapshot(&self) -> Result<Value, String> {
|
||||
let key = |address: &keyed_rows::AddressDraft| {
|
||||
address.selected.as_ref().map(|k| AnnotationKey {
|
||||
@@ -100,6 +232,8 @@ impl State {
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
attachments: self.attachments.recovery_snapshot(),
|
||||
artifacts: self.artifacts.recovery_snapshot(),
|
||||
reference_oa: self.reference_oa.as_ref().map(|c| c.id),
|
||||
orders: self
|
||||
.order_drafts
|
||||
@@ -124,6 +258,10 @@ impl State {
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
Self::restore_saved(project, saved)
|
||||
}
|
||||
|
||||
fn restore_saved(project: &Project, saved: Saved) -> Result<Self, String> {
|
||||
let count = [
|
||||
saved.decisions.len(),
|
||||
saved.references.len(),
|
||||
@@ -153,6 +291,7 @@ impl State {
|
||||
}) {
|
||||
return Err("Semantic recovery trace and selected object disagree.".into());
|
||||
}
|
||||
state.base_fingerprint = project_fingerprint(project);
|
||||
state.trace = saved.trace;
|
||||
state.direction = saved.direction;
|
||||
state.load_trace(project);
|
||||
@@ -227,6 +366,17 @@ impl State {
|
||||
)?;
|
||||
state.reference_oa = saved.reference_oa.map(|id| view::choice(project, id));
|
||||
state.order_drafts = unique(saved.orders)?;
|
||||
state.attachments = attachments::Editor::from_recovery(
|
||||
project,
|
||||
state.direction.target_layer(),
|
||||
saved.attachments,
|
||||
)?;
|
||||
if let Some(trace) = state.trace.and_then(|id| project.traces.get(&id)) {
|
||||
state.artifacts =
|
||||
artifacts::Editor::from_recovery(project, trace, state.direction, saved.artifacts)?;
|
||||
} else if !saved.artifacts.is_empty() {
|
||||
return Err("Recovered artifact drafts need an exact owner trace.".into());
|
||||
}
|
||||
state.reference_sa = saved.reference_sa.map(|id| view::choice(project, id));
|
||||
state.reference_search = saved.reference_search;
|
||||
state.accepted_impacts = saved.accepted_impacts;
|
||||
@@ -237,6 +387,230 @@ impl State {
|
||||
}
|
||||
}
|
||||
|
||||
fn batch_limits(value: &Value) -> Result<(), String> {
|
||||
let mut pending = vec![value];
|
||||
let mut count = 0usize;
|
||||
let mut bytes = 0usize;
|
||||
while let Some(value) = pending.pop() {
|
||||
count = count.saturating_add(1);
|
||||
match value {
|
||||
Value::String(text) => bytes = bytes.saturating_add(text.len()),
|
||||
Value::Array(values) => pending.extend(values),
|
||||
Value::Object(values) => {
|
||||
bytes = values
|
||||
.keys()
|
||||
.fold(bytes, |total, key| total.saturating_add(key.len()));
|
||||
pending.extend(values.values());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if count > crate::source_workspace::LIMITS.model.max_records
|
||||
|| bytes > crate::source_workspace::LIMITS.model.max_source_bytes
|
||||
{
|
||||
return Err("Semantic recovery exceeds the aggregate draft limits.".into());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl State {
|
||||
/// A source-5 upgrade may reconstruct the same accepted records with a new
|
||||
/// schema fingerprint. The caller supplies the independently verified legacy
|
||||
/// base; every saved row is still decoded and replanned through strict restore.
|
||||
pub(crate) fn from_recovery_after_source5_upgrade(
|
||||
project: &Project,
|
||||
legacy_base: &str,
|
||||
value: Value,
|
||||
) -> Result<Self, String> {
|
||||
batch_limits(&value)?;
|
||||
let value = match value.get("version").and_then(Value::as_u64) {
|
||||
Some(1) => {
|
||||
let mut saved: Saved = serde_json::from_value(value).map_err(|e| e.to_string())?;
|
||||
if saved.base_identity != project.id || saved.base_fingerprint != legacy_base {
|
||||
return Err(
|
||||
"Semantic source-5 recovery does not match the verified legacy base."
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
saved.base_fingerprint = project_fingerprint(project);
|
||||
saved.accepted_impacts.clear();
|
||||
serde_json::to_value(saved).map_err(|e| e.to_string())?
|
||||
}
|
||||
Some(2) => {
|
||||
let mut saved: SavedBatch =
|
||||
serde_json::from_value(value).map_err(|e| e.to_string())?;
|
||||
if saved.base_identity != project.id || saved.base_fingerprint != legacy_base {
|
||||
return Err(
|
||||
"Semantic source-5 recovery does not match the verified legacy base."
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
saved.base_fingerprint = project_fingerprint(project);
|
||||
saved.accepted_impacts.clear();
|
||||
serde_json::to_value(saved).map_err(|e| e.to_string())?
|
||||
}
|
||||
_ => return Err("Unsupported semantic recovery version.".into()),
|
||||
};
|
||||
Self::from_recovery(project, value)
|
||||
}
|
||||
|
||||
pub fn recovery_snapshot(&self) -> Result<Value, String> {
|
||||
let rows = self
|
||||
.order
|
||||
.iter()
|
||||
.map(|address| {
|
||||
Ok(SavedRow {
|
||||
address: *address,
|
||||
included: self.included.contains(address),
|
||||
draft: RowPayload::from_row(
|
||||
self.row(*address)
|
||||
.ok_or("Retained review row is missing.")?,
|
||||
)?,
|
||||
})
|
||||
})
|
||||
.collect::<Result<Vec<_>, String>>()?;
|
||||
if rows.len() > batch::MAX_REVIEW_ROWS {
|
||||
return Err("Semantic recovery exceeds the supported row limit.".into());
|
||||
}
|
||||
let saved = SavedBatch {
|
||||
version: 2,
|
||||
base_identity: self.base_identity,
|
||||
base_fingerprint: self.base_fingerprint.clone(),
|
||||
direction: self.direction,
|
||||
references: self.references.clone(),
|
||||
accepted_impacts: self.accepted_impacts.clone(),
|
||||
active: self.active_address,
|
||||
rows,
|
||||
pending: if self.active_address.is_none() {
|
||||
Some(RowPayload::from_row(&self.active)?)
|
||||
} else {
|
||||
None
|
||||
},
|
||||
add_trace: self.add_trace.as_ref().map(|choice| choice.id),
|
||||
};
|
||||
let value = serde_json::to_value(saved).map_err(|error| error.to_string())?;
|
||||
batch_limits(&value)?;
|
||||
Ok(value)
|
||||
}
|
||||
pub fn from_recovery(project: &Project, value: Value) -> Result<Self, String> {
|
||||
batch_limits(&value)?;
|
||||
if value.get("version").and_then(Value::as_u64) == Some(1) {
|
||||
let active = ReviewRow::from_recovery(project, value)?;
|
||||
let address = active
|
||||
.trace
|
||||
.and_then(|id| project.traces.get(&id))
|
||||
.map(|trace| RowAddress::from_trace(trace, active.direction));
|
||||
let mut state = Self {
|
||||
base: std::sync::Arc::new(project.clone()),
|
||||
base_identity: project.id,
|
||||
base_fingerprint: project_fingerprint(project),
|
||||
direction: active.direction,
|
||||
references: active.references.clone(),
|
||||
accepted_impacts: active.accepted_impacts.clone(),
|
||||
plan: None,
|
||||
proposed: None,
|
||||
error: None,
|
||||
active,
|
||||
active_address: address,
|
||||
bank: BTreeMap::new(),
|
||||
order: address.into_iter().collect(),
|
||||
included: address.into_iter().collect(),
|
||||
add_trace: None,
|
||||
notice: None,
|
||||
};
|
||||
state.refresh(project);
|
||||
return Ok(state);
|
||||
}
|
||||
let saved: SavedBatch = serde_json::from_value(value).map_err(|error| error.to_string())?;
|
||||
if saved.version != 2
|
||||
|| saved.base_identity != project.id
|
||||
|| saved.base_fingerprint != project_fingerprint(project)
|
||||
{
|
||||
return Err(
|
||||
"Semantic review recovery does not match the accepted model identity and revision."
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
if saved.rows.len() > batch::MAX_REVIEW_ROWS {
|
||||
return Err("Semantic recovery exceeds the supported row limit.".into());
|
||||
}
|
||||
let mut bank = BTreeMap::new();
|
||||
let mut order = vec![];
|
||||
let mut included = BTreeSet::new();
|
||||
let mut sources = BTreeSet::new();
|
||||
for saved_row in saved.rows {
|
||||
let address = saved_row.address;
|
||||
let direction = address.direction(project)?;
|
||||
let row = saved_row.draft.restore(project, &saved.references)?;
|
||||
if row.direction != direction
|
||||
|| row.trace != Some(address.trace)
|
||||
|| row.target(project) != Some(address.target)
|
||||
{
|
||||
return Err(
|
||||
"Recovered row draft does not match its stable source/target/trace address."
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
if bank.insert(address, row).is_some() {
|
||||
return Err("Recovery contains a duplicate semantic row address.".into());
|
||||
}
|
||||
order.push(address);
|
||||
if saved_row.included {
|
||||
if !sources.insert(address.source) {
|
||||
return Err(
|
||||
"Recovery contains multiple included histories for one source.".into(),
|
||||
);
|
||||
}
|
||||
included.insert(address);
|
||||
}
|
||||
}
|
||||
let active = match (saved.active, saved.pending) {
|
||||
(Some(address), None) => {
|
||||
if address.direction(project)? != saved.direction {
|
||||
return Err("Recovery active row has a different direction.".into());
|
||||
}
|
||||
bank.remove(&address)
|
||||
.ok_or("Recovery active review row is absent.")?
|
||||
}
|
||||
(None, Some(pending)) if bank.is_empty() => {
|
||||
let row = pending.restore(project, &saved.references)?;
|
||||
if row.trace.is_some() || row.direction != saved.direction {
|
||||
return Err("Recovery pending row is not an unresolved trace selection.".into());
|
||||
}
|
||||
row
|
||||
}
|
||||
_ => return Err("Recovery has an invalid active/pending review row.".into()),
|
||||
};
|
||||
if saved
|
||||
.add_trace
|
||||
.is_some_and(|id| !project.traces.contains_key(&id))
|
||||
{
|
||||
return Err("Recovery add-row selection is not an existing trace.".into());
|
||||
}
|
||||
let mut state = Self {
|
||||
base: std::sync::Arc::new(project.clone()),
|
||||
base_identity: saved.base_identity,
|
||||
base_fingerprint: saved.base_fingerprint,
|
||||
direction: saved.direction,
|
||||
references: saved.references,
|
||||
accepted_impacts: saved.accepted_impacts,
|
||||
plan: None,
|
||||
proposed: None,
|
||||
error: None,
|
||||
active,
|
||||
active_address: saved.active,
|
||||
bank,
|
||||
order,
|
||||
included,
|
||||
add_trace: saved.add_trace.map(|id| view::choice(project, id)),
|
||||
notice: None,
|
||||
};
|
||||
// Recompose all included rows once. Excluded invalid raw drafts remain recoverable.
|
||||
state.refresh(project);
|
||||
Ok(state)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -265,14 +639,14 @@ mod tests {
|
||||
let path = "/body/Sa/Shared/DataType/definition/Integer/minimum";
|
||||
for (raw, valid) in [("7", true), ("-", false)] {
|
||||
let mut state = State::new(&p, oa).unwrap();
|
||||
state.update(&p, Event::Select(facet.clone()));
|
||||
state.update(&p, RowEvent::Select(facet.clone()));
|
||||
state.update(
|
||||
&p,
|
||||
Event::Decide(facet.clone(), Some(SemanticDecision::Candidate)),
|
||||
RowEvent::Decide(facet.clone(), Some(SemanticDecision::Candidate)),
|
||||
);
|
||||
state.update(&p, Event::Input(path.into(), raw.into()));
|
||||
state.update(&p, RowEvent::Input(path.into(), raw.into()));
|
||||
assert_eq!(state.can_apply(), valid);
|
||||
let current = state.recovery_snapshot().unwrap();
|
||||
let current = state.active.recovery_snapshot().unwrap();
|
||||
let mut old = current.clone();
|
||||
assert_eq!(old["version"], 1);
|
||||
assert_eq!(
|
||||
@@ -283,7 +657,7 @@ mod tests {
|
||||
assert!(restored.order_drafts.is_empty());
|
||||
assert_eq!(restored.draft.as_ref().unwrap().inputs[path].raw, raw);
|
||||
assert_eq!(restored.can_apply(), valid);
|
||||
assert_eq!(restored.recovery_snapshot().unwrap(), current);
|
||||
assert_eq!(restored.active.recovery_snapshot().unwrap(), current);
|
||||
assert_eq!(
|
||||
p, accepted,
|
||||
"Recovery prepares a preview, never an accepted edit"
|
||||
@@ -316,13 +690,13 @@ mod tests {
|
||||
super::super::tests::add_trace(&mut p, oa, sa, "Recover review");
|
||||
let mut state = State::new(&p, oa).unwrap();
|
||||
let facet = FacetSelector::Data(DataFacet::IntegerMinimum);
|
||||
state.update(&p, Event::Select(facet.clone()));
|
||||
state.update(&p, RowEvent::Select(facet.clone()));
|
||||
state.update(
|
||||
&p,
|
||||
Event::Decide(facet.clone(), Some(SemanticDecision::Candidate)),
|
||||
RowEvent::Decide(facet.clone(), Some(SemanticDecision::Candidate)),
|
||||
);
|
||||
let path = "/body/Sa/Shared/DataType/definition/Integer/minimum";
|
||||
state.update(&p, Event::Input(path.into(), "-".into()));
|
||||
state.update(&p, RowEvent::Input(path.into(), "-".into()));
|
||||
assert!(!state.can_apply());
|
||||
let saved = state.recovery_snapshot().unwrap();
|
||||
let restored = State::from_recovery(&p, saved.clone()).unwrap();
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user